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
}