Skip to content
quarry Artifact [81d54b510b]
Login

Artifact [81d54b510b]

Artifact 81d54b510b68bdde78c712c791def3c500e807b2cb7c8cf98d78e1f21b96c570:


package api

import (
	"context"
	"errors"
	"path/filepath"
	"testing"

	"craftdesign.group/quarry/internal/attach"
	"craftdesign.group/quarry/internal/oltp"
	"craftdesign.group/quarry/internal/ops"
)

// noLocalAttachments is the held-file half of LocalTicketStore for the
// stubs that stand in for a tier with no files: every answer is empty.
type noLocalAttachments struct{}

func (noLocalAttachments) LocalAttachmentAdd(oltp.LocalAttachment) error { return nil }
func (noLocalAttachments) LocalAttachments(string, string) ([]oltp.LocalAttachment, error) {
	return nil, nil
}
func (noLocalAttachments) LocalAttachmentBySHA(string, string) (oltp.LocalAttachment, bool, error) {
	return oltp.LocalAttachment{}, false, nil
}
func (noLocalAttachments) LocalAttachmentRemove(string, string, string) error { return nil }
func (noLocalAttachments) LocalSHACount(string, string) (int, error)          { return 0, nil }

// A TICKET ON THE FORGE TIER CARRIES ATTACHMENTS, AND THEY BECOME A
// CARDS WHEN IT IS PROMOTED.
//
// A local ticket lives in the forge database; an attachment is an A
// card in the artifact log naming its target; the two never met, so
// the default tier's tickets could carry nothing and the refusal said
// so by naming the tier (9e628be2). dmestas ruled: hold the files on
// the forge tier — the object goes to the bucket exactly as for any
// upload, the stub is kept in the forge database — and write the A
// cards at promotion. Not the "forge-tier only, lost at promotion"
// shape. This walks the whole life of one such file: begin, commit,
// list, describe, link, refuse a stranger's removal, remove, attach
// from a composer, promote, and find both in the store with the held
// rule applied to the uploader.
func TestAForgeTierTicketCarriesAttachmentsUntilPromotion(t *testing.T) {
	ix, z, _ := 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)
	db, err := oltp.Open(filepath.Join(t.TempDir(), "oltp.db"))
	if err != nil {
		t.Fatal(err)
	}
	defer db.Close()
	local, err := db.LocalTicketNew("test", "lou", "", map[string]string{"title": "held files", "status": "Open"})
	if err != nil {
		t.Fatal(err)
	}
	d := Deps{Version: "v", Repos: NewRepos(map[string]*Repo{"test": {Index: ix, Zit: z}}),
		Buckets: buckets, Attachments: store, Local: db,
		// lou may attach but not vouch: the held rule applies to her
		// files when they reach the DAG.
		CapsFor: func(account, _ string) CapSet {
			if account == "lou" {
				return oltp.CapSet("b")
			}
			return oltp.CapSet("qa")
		}}
	r := ops.NewRegistry()
	if err := RegisterAll(r, d); err != nil {
		t.Fatal(err)
	}
	lou := as("lou", "b")
	upload := func(name, sha string, size int64, target, family string) string {
		t.Helper()
		out, err := r.Invoke(lou, "attachment.begin", attachmentBeginIn{Repo: "test", Target: target, Family: family, Name: name, Size: size, Type: "text/plain", SHA256: sha})
		if err != nil {
			t.Fatalf("begin %s: %v", name, err)
		}
		objects.objects[attach.Key(sha)] = size
		return out.(attachmentBeginOut).Ticket
	}

	// Begin and commit against the local ticket, by prefix as a page
	// would: the file is held, not written.
	tk := upload("notes.txt", sha, 500, "tkt:"+local[:12], "")
	got, err := r.Invoke(lou, "attachment.commit", attachmentCommitIn{Repo: "test", Ticket: tk})
	if err != nil {
		t.Fatalf("FAIL: commit against a forge-tier ticket: %v", err)
	}
	c := got.(attachmentCommitOut)
	if !c.Local || c.Artifact != "" || c.Blob != "" || c.Name != "notes.txt" || c.SHA256 != sha {
		t.Fatalf("FAIL: commit = %+v, want a held file with no artifact", c)
	}
	if _, ok, _ := store.UploadTicket(tk); ok {
		t.Error("FAIL: the upload ticket was not spent")
	}
	if rows, _ := z.AttachList(context.Background(), local); len(rows) != 0 {
		t.Errorf("FAIL: an A card was written for a forge-tier ticket: %+v", rows)
	}

	// It lists, on the attachments op and on the ticket itself, as an
	// upload with no artifact yet.
	list, err := r.Invoke(lou, "attachment.list", attachmentListIn{Repo: "test", Target: local[:12]})
	if err != nil {
		t.Fatal(err)
	}
	if rows := list.(attachmentListOut).Attachments; len(rows) != 1 || !rows[0].External || rows[0].SHA256 != sha || rows[0].Hash != "" || rows[0].Src != sha || rows[0].User != "lou" || rows[0].Target != local {
		t.Errorf("FAIL: list = %+v", rows)
	}
	tg, err := r.Invoke(lou, "ticket.get", ticketGetIn{Repo: "test", ID: local[:12]})
	if err != nil {
		t.Fatal(err)
	}
	if out := tg.(ticketGetOut); !out.Local || len(out.Attachments) != 1 || out.Attachments[0].Name != "notes.txt" {
		t.Errorf("FAIL: ticket.get local = local:%v attachments:%+v", out.Local, out.Attachments)
	}
	// And it reads by its object key, as the pages address an upload.
	hd, err := r.Invoke(lou, "attachment.head", attachmentHeadIn{Repo: "test", Hash: sha})
	if err != nil {
		t.Fatalf("FAIL: head of a held file: %v", err)
	}
	if h := hd.(attachmentHeadOut); !h.External || h.Name != "notes.txt" || h.Size != 500 || h.SHA256 != sha {
		t.Errorf("FAIL: head = %+v", h)
	}
	lk, err := r.Invoke(lou, "attachment.link", attachmentLinkIn{Repo: "test", Hash: sha})
	if err != nil {
		t.Fatalf("FAIL: link of a held file: %v", err)
	}
	if l := lk.(attachmentLinkOut); !l.External || l.Download == "" || l.Name != "notes.txt" {
		t.Errorf("FAIL: link = %+v", l)
	}

	// Removal is the same ownership rule as everywhere: not a
	// stranger's, the uploader's own.
	if _, err := r.Invoke(as("mallory", "b"), "attachment.remove", attachmentRemoveIn{Repo: "test", Target: local, Name: "notes.txt"}); !errors.Is(err, ops.ErrForbidden) {
		t.Errorf("FAIL: a stranger removed a held file: %v", err)
	}
	rm, err := r.Invoke(lou, "attachment.remove", attachmentRemoveIn{Repo: "test", Target: local, Name: "notes.txt"})
	if err != nil {
		t.Fatalf("FAIL: remove: %v", err)
	}
	if o := rm.(attachmentRemoveOut); !o.Removed || o.Artifact != "" {
		t.Errorf("FAIL: remove = %+v", o)
	}
	if list, _ = r.Invoke(lou, "attachment.list", attachmentListIn{Repo: "test", Target: local}); len(list.(attachmentListOut).Attachments) != 0 {
		t.Errorf("FAIL: the held file is still listed after removal")
	}

	// Two files for the promotion: one committed directly, one pasted
	// while composing a change and named by ticket.set's attach list —
	// the composer path that used to answer "cannot carry attachments".
	const sha2 = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
	tk = upload("notes.txt", sha, 500, "tkt:"+local, "")
	if _, err := r.Invoke(lou, "attachment.commit", attachmentCommitIn{Repo: "test", Ticket: tk}); err != nil {
		t.Fatal(err)
	}
	deferred := upload("shot.txt", sha2, 12, "", "ticket")
	st, err := r.Invoke(lou, "ticket.set", ticketSetIn{Repo: "test", ID: local[:12], Comment: "with a file", Attach: []string{deferred}})
	if err != nil {
		t.Fatalf("FAIL: ticket.set with a pasted file on a local ticket: %v", err)
	}
	if o := st.(ticketSetOut); !o.Local || len(o.AttachErrors) != 0 {
		t.Fatalf("FAIL: ticket.set = %+v, want the file attached with no refusal", o)
	}
	if list, _ = r.Invoke(lou, "attachment.list", attachmentListIn{Repo: "test", Target: local}); len(list.(attachmentListOut).Attachments) != 2 {
		t.Fatalf("FAIL: two held files expected, got %+v", list.(attachmentListOut).Attachments)
	}

	// Promotion writes both A cards as lou's, applies the held rule to
	// her, and leaves nothing held under the local id.
	pr, err := r.Invoke(as("adm", "a"), "ticket.promote", ticketPromoteIn{Repo: "test", ID: local[:12]})
	if err != nil {
		t.Fatalf("FAIL: promote: %v", err)
	}
	p := pr.(ticketPromoteOut)
	if p.Attached != 2 || len(p.AttachErrors) != 0 || p.ID == "" {
		t.Fatalf("FAIL: promote = %+v", p)
	}
	rows, err := z.AttachList(context.Background(), p.ID)
	if err != nil {
		t.Fatal(err)
	}
	names := map[string]string{}
	for _, row := range rows {
		names[row.Name] = row.User
	}
	if names["notes.txt"] != "lou" || names["shot.txt"] != "lou" {
		t.Errorf("FAIL: A cards on the promoted ticket = %+v, want both files as lou's", rows)
	}
	if held, _ := db.LocalAttachments("test", local); len(held) != 0 {
		t.Errorf("FAIL: files still held under the local id after promotion: %+v", held)
	}
	holds, _ := store.Holds("test")
	if len(holds) != 2 {
		t.Errorf("FAIL: the held rule was not applied to the uploader at promotion: holds = %+v", holds)
	}
	for _, h := range holds {
		if h.Who != "lou" || h.Target != p.ID {
			t.Errorf("FAIL: hold = %+v, want lou's on %s", h, p.ID)
		}
	}
	// The local id is a redirect now, and attaching to it says where
	// the files belong rather than that nothing is there.
	_, err = r.Invoke(lou, "attachment.remove", attachmentRemoveIn{Repo: "test", Target: "tkt:" + local, Name: "notes.txt"})
	if !errors.Is(err, ops.ErrNotFound) || !contains(err.Error(), "was promoted to") {
		t.Errorf("FAIL: attaching to a promoted local id: %v", err)
	}
	t.Log("ok: a forge-tier ticket holds its files and hands them to the artifact at promotion")
}

func contains(s, sub string) bool {
	return len(sub) == 0 || (len(s) >= len(sub) && indexOf(s, sub) >= 0)
}

func indexOf(s, sub string) int {
	for i := 0; i+len(sub) <= len(s); i++ {
		if s[i:i+len(sub)] == sub {
			return i
		}
	}
	return -1
}