Skip to content
quarry Artifact [73ab277d93]
Login

Artifact [73ab277d93]

Artifact 73ab277d93d2ca5291d5134207ae3b7a1ae6d7afd02e533eb326308425e7e814:


package api

import (
	"context"
	"errors"
	"strings"
	"testing"

	"craftdesign.group/quarry/internal/artifact"
	"craftdesign.group/quarry/internal/attach"
	"craftdesign.group/quarry/internal/index"
	"craftdesign.group/quarry/internal/oltp"
	"craftdesign.group/quarry/internal/ops"
	"craftdesign.group/quarry/internal/zitcli"
)

// zitSync is the Repo.Sync a test wires: walk the store's frames from
// where the last walk stopped and fold what is new, which is what the
// ingest does in production. The write ops call it before they
// answer, so an object written a moment ago is in the index by the
// time the composer's attachments are committed against it.
func zitSync(z *zitcli.Client, ix *index.Index) func() (int, error) {
	var cur zitcli.Cursor
	return func() (int, error) {
		var parsed []index.Parsed
		next, err := z.FramesEach(cur, func(a zitcli.Artifact) error {
			art, perr := artifact.Parse(a.Bytes)
			if perr != nil || art == nil {
				return nil
			}
			p := index.Parsed{Hash: a.Hash, Art: art}
			if art.Type == artifact.Attachment && art.Attachment != nil {
				if raw, rerr := z.ArtifactRaw(context.Background(), art.Attachment.Source, 4096); rerr == nil {
					if stub, serr := attach.Parse(raw); serr == nil {
						p.Stub = &stub
					}
				}
			}
			parsed = append(parsed, p)
			return nil
		})
		if err != nil {
			return 0, err
		}
		cur = next
		return len(parsed), ix.FoldNow(parsed)
	}
}

// deferredFixture is a real store with one technote and one forum
// thread, a bucket, and the ops; the repo syncs through zitSync.
type deferredFixture struct {
	r       *ops.Registry
	store   *memAttachments
	objects *objectS3
	ix      *index.Index
	sync    func() (int, error)
	note    string // the technote's event id
	root    string // the thread's hash
}

func newDeferredFixture(t *testing.T) deferredFixture {
	t.Helper()
	ix, z, event := attachStore(t)
	srv, objects := newObjectS3(t)
	buckets := newMemBuckets()
	buckets.rows["test"] = oltp.BucketBinding{Endpoint: srv.URL, Bucket: "files", Region: "auto", AccessKey: "AK", Secret: "SK", LimitBytes: 1 << 20}
	store := newMemAttachments(buckets)
	sync := zitSync(z, ix)
	d := Deps{Version: "v", Repos: NewRepos(map[string]*Repo{"test": {Index: ix, Zit: z, Sync: sync}}),
		Buckets: buckets, Attachments: store}
	r := ops.NewRegistry()
	if err := RegisterAll(r, d); err != nil {
		t.Fatal(err)
	}
	out, err := r.Invoke(as("dan", "3"), "forum.post", forumPostIn{Repo: "test", Title: "hello", Body: "first\n"})
	if err != nil {
		t.Fatalf("forum.post: %v", err)
	}
	root := out.(forumPostOut).Hash
	if root == "" {
		t.Fatal("forum.post answered no hash")
	}
	return deferredFixture{r: r, store: store, objects: objects, ix: ix, sync: sync, note: event, root: root}
}

func (f deferredFixture) begin(ctx context.Context, family string) (attachmentBeginOut, error) {
	out, err := f.r.Invoke(ctx, "attachment.begin", attachmentBeginIn{Repo: "test", Family: family, Name: "shot.png", Size: 500, Type: "image/png", SHA256: sha})
	if err != nil {
		return attachmentBeginOut{}, err
	}
	return out.(attachmentBeginOut), nil
}

func (f deferredFixture) commit(ctx context.Context, ticket, target string) (attachmentCommitOut, error) {
	out, err := f.r.Invoke(ctx, "attachment.commit", attachmentCommitIn{Repo: "test", Ticket: ticket, Target: target})
	if err != nil {
		return attachmentCommitOut{}, err
	}
	return out.(attachmentCommitOut), nil
}

// A deferred ticket: begun with a family and no target, gated on that
// family's letter, and committed only with a target of that family.
func TestDeferredTicketCommitsWithATarget(t *testing.T) {
	f := newDeferredFixture(t)
	if _, err := f.begin(as("lou", "B"), "technote"); !errors.Is(err, ops.ErrInvalid) {
		t.Errorf("FAIL: a technote family deferred: %v", err)
	}
	if _, err := f.begin(as("lou", "B"), ""); !errors.Is(err, ops.ErrInvalid) {
		t.Errorf("FAIL: no target and no family: %v", err)
	}
	if _, err := f.begin(as("lou", "b"), "forum"); !errors.Is(err, ops.ErrForbidden) {
		t.Errorf("FAIL: the ticket letter began a forum upload: %v", err)
	}
	lou := as("lou", "B")
	b, err := f.begin(lou, "forum")
	if err != nil {
		t.Fatalf("FAIL: deferred begin: %v", err)
	}
	if tk := f.store.tickets[b.Ticket]; tk.Target != "" || tk.Family != "forum" || tk.Who != "lou" {
		t.Errorf("FAIL: ticket = %+v", tk)
	}
	f.objects.objects[attach.Key(sha)] = 500
	if _, err := f.commit(lou, b.Ticket, ""); !errors.Is(err, ops.ErrInvalid) {
		t.Errorf("FAIL: commit without a target: %v", err)
	}
	if _, err := f.commit(lou, b.Ticket, "nothing-here"); !errors.Is(err, ops.ErrNotFound) {
		t.Errorf("FAIL: commit to an unknown target: %v", err)
	}
	// Begun for the forum with B; a technote wants b, and the ticket
	// must not cross families whatever letters the caller holds now.
	if _, err := f.commit(as("lou", "bB"), b.Ticket, f.note[:16]); !errors.Is(err, ops.ErrInvalid) {
		t.Errorf("FAIL: a forum ticket landed on a technote: %v", err)
	}
	if _, ok, _ := f.store.UploadTicket(b.Ticket); !ok {
		t.Fatal("FAIL: the ticket was spent by a refusal")
	}
	c, err := f.commit(lou, b.Ticket, f.root[:16])
	if err != nil {
		t.Fatalf("FAIL: commit with the target: %v", err)
	}
	if len(c.Artifact) != 64 || c.Name != "shot.png" || !c.Held {
		t.Errorf("FAIL: commit = %+v", c)
	}
	if _, ok, _ := f.store.UploadTicket(b.Ticket); ok {
		t.Errorf("FAIL: the ticket was not spent")
	}
	t.Log("ok: a deferred ticket is gated on the family's letter and needs a target of its family")
}

// forum.reply with attach: the reply is written, the tickets are
// committed against its hash, and a bad ticket is reported without
// undoing the reply.
func TestForumReplyAttachesWhatWasPasted(t *testing.T) {
	f := newDeferredFixture(t)
	lou := as("lou", "3B5")
	b, err := f.begin(lou, "forum")
	if err != nil {
		t.Fatal(err)
	}
	f.objects.objects[attach.Key(sha)] = 500
	if _, err := f.r.Invoke(lou, "forum.reply", forumReplyIn{Repo: "test", Root: f.root[:16], Body: "x\n",
		Attach: make([]string, maxComposerAttachments+1)}); !errors.Is(err, ops.ErrInvalid) {
		t.Errorf("FAIL: more tickets than the op takes: %v", err)
	}
	got, err := f.r.Invoke(lou, "forum.reply", forumReplyIn{Repo: "test", Root: f.root[:16], Body: "![shot](attach:shot.png)\n", Attach: []string{b.Ticket, "nope"}})
	if err != nil {
		t.Fatalf("FAIL: reply: %v", err)
	}
	rep := got.(forumReplyOut)
	if rep.Hash == "" || rep.Staged {
		t.Fatalf("FAIL: reply = %+v", rep)
	}
	if len(rep.AttachErrors) != 1 || !strings.HasPrefix(rep.AttachErrors[0], "nope: ") {
		t.Errorf("FAIL: attachErrors = %v", rep.AttachErrors)
	}
	if _, ok, _ := f.store.UploadTicket(b.Ticket); ok {
		t.Errorf("FAIL: the good ticket was not spent")
	}
	// The A card names the reply: after the next sync the row is on it.
	if _, err := f.sync(); err != nil {
		t.Fatal(err)
	}
	rows, err := f.ix.AttachmentsFor(rep.Hash)
	if err != nil {
		t.Fatal(err)
	}
	if len(rows) != 1 || rows[0].Name != "shot.png" || !rows[0].External {
		t.Errorf("FAIL: attachments on the reply = %+v", rows)
	}
	t.Log("ok: forum.reply commits the pasted files against the reply and reports the bad ticket")
}

// ticket.set and wiki.save take the same list: the files pasted into
// a comment box or the wiki editor are committed against the object
// after the change is written, and files alone on a ticket are
// attached without a J card.
func TestTicketSetAndWikiSaveAttach(t *testing.T) {
	f := newDeferredFixture(t)
	dan := as("dan", "bcwkfq")
	f.objects.objects[attach.Key(sha)] = 500
	out, err := f.r.Invoke(dan, "wiki.save", wikiSaveIn{Repo: "test", Name: "Design", Body: "page\n"})
	if err != nil {
		t.Fatal(err)
	}
	if out.(wikiSaveOut).AttachErrors != nil {
		t.Errorf("FAIL: a save with no files reported %v", out.(wikiSaveOut).AttachErrors)
	}
	b, err := f.begin(dan, "wiki")
	if err != nil {
		t.Fatal(err)
	}
	out, err = f.r.Invoke(dan, "wiki.save", wikiSaveIn{Repo: "test", Name: "Design", Body: "![shot](attach:shot.png)\n", Attach: []string{b.Ticket}})
	if err != nil {
		t.Fatalf("FAIL: wiki.save with a file: %v", err)
	}
	if ws := out.(wikiSaveOut); ws.Hash == "" || ws.AttachErrors != nil {
		t.Errorf("FAIL: wiki.save = %+v", ws)
	}
	if _, err := f.sync(); err != nil {
		t.Fatal(err)
	}
	if rows, _ := f.ix.AttachmentsFor("Design"); len(rows) != 1 || rows[0].Name != "shot.png" {
		t.Errorf("FAIL: attachments on the page = %+v", rows)
	}

	out, err = f.r.Invoke(dan, "ticket.new", ticketNewIn{Repo: "test", Title: "broken"})
	if err != nil {
		t.Fatal(err)
	}
	id := out.(ticketNewOut).ID
	b, err = f.begin(dan, "ticket")
	if err != nil {
		t.Fatal(err)
	}
	// A wiki-family ticket does not land on a ticket, and the change
	// itself is still written.
	wrong, err := f.begin(dan, "wiki")
	if err != nil {
		t.Fatal(err)
	}
	out, err = f.r.Invoke(dan, "ticket.set", ticketSetIn{Repo: "test", ID: id[:10], Comment: "see the shot", Attach: []string{b.Ticket, wrong.Ticket}})
	if err != nil {
		t.Fatalf("FAIL: ticket.set with files: %v", err)
	}
	if ts := out.(ticketSetOut); ts.Hash == "" || len(ts.AttachErrors) != 1 || !strings.HasPrefix(ts.AttachErrors[0], wrong.Ticket+": ") {
		t.Errorf("FAIL: ticket.set = %+v", ts)
	}
	if _, err := f.r.Invoke(dan, "ticket.set", ticketSetIn{Repo: "test", ID: id[:10]}); !errors.Is(err, ops.ErrInvalid) {
		t.Errorf("FAIL: nothing to set and nothing to attach: %v", err)
	}
	b2, err := f.begin(dan, "ticket")
	if err != nil {
		t.Fatal(err)
	}
	out, err = f.r.Invoke(dan, "ticket.set", ticketSetIn{Repo: "test", ID: id[:10], Attach: []string{b2.Ticket}})
	if err != nil {
		t.Fatalf("FAIL: files alone: %v", err)
	}
	if ts := out.(ticketSetOut); ts.Hash != "" || ts.AttachErrors != nil {
		t.Errorf("FAIL: files alone = %+v", ts)
	}
	if _, err := f.sync(); err != nil {
		t.Fatal(err)
	}
	// Two A cards with one name on one target: fossil's rule is that
	// the later replaces the earlier, so the ticket shows one file.
	if rows, _ := f.ix.AttachmentsFor(id); len(rows) != 1 || rows[0].Name != "shot.png" {
		t.Errorf("FAIL: attachments on the ticket = %+v", rows)
	}
	t.Log("ok: ticket.set and wiki.save commit the pasted files against their object")
}