package api
import (
"context"
"errors"
"strings"
"testing"
"craftdesign.group/quarry/internal/artifact"
"craftdesign.group/quarry/internal/index"
"craftdesign.group/quarry/internal/oltp"
"craftdesign.group/quarry/internal/ops"
)
// A removal is the uploader's or a moderator's, and nobody else's.
//
// G-147, found by jva in the hour the attachment surface first became
// testable from a non-admin vantage: b and B sit in the READER preset,
// because attaching is a reader's act in fossil's model, and this op
// took those letters and nothing else. So any reader could permanently
// remove ANY attachment — an administrator's vouched one included —
// while the sibling `reject` refused them, which handed a non-moderator
// the destructive half of moderation and none of the reviewing half.
//
// The three rows are one decision rather than three: removing your own
// upload is part of attaching, removing somebody else's is moderation,
// and the only thing that separates them is the A card's author.
func TestARemovalIsTheUploadersOrAModeratorsAlone(t *testing.T) {
ix, z, event := attachStore(t)
ctx := context.Background()
for _, who := range []string{"lou", "dan"} {
art, blob, err := z.AttachAdd(testToken, ctx, event, who+".txt", "text/plain", who, "", []byte(who+"'s file\n"))
if err != nil {
t.Fatalf("attaching as %s: %v", who, err)
}
if err := ix.FoldNow([]index.Parsed{{Hash: art, Art: &artifact.Artifact{
Type: artifact.Attachment, Date: "2026-01-02T00:00:00", User: who, Mimetype: "text/plain",
Attachment: &artifact.Attach{Name: who + ".txt", Target: event, Source: blob}}}}); err != nil {
t.Fatal(err)
}
}
d := Deps{Version: "v", Repos: NewRepos(map[string]*Repo{"test": {Index: ix, Zit: z}})}
r := ops.NewRegistry()
if err := RegisterAll(r, d); err != nil {
t.Fatal(err)
}
reader := as("lou", "bB") // exactly what the Reader preset carries here
moderator := as("mo", "bBl") // l is the moderation letter for a technote
_, err := r.Invoke(reader, "attachment.remove",
attachmentRemoveIn{Repo: "test", Target: event, Name: "dan.txt"})
if !errors.Is(err, ops.ErrForbidden) {
t.Errorf("FAIL: a reader removed somebody else's attachment: %v", err)
} else if !strings.Contains(err.Error(), "dan") || !strings.Contains(err.Error(), "l capability") {
t.Errorf("FAIL: the refusal names neither the uploader nor the capability: %v", err)
}
if _, err := r.Invoke(reader, "attachment.remove",
attachmentRemoveIn{Repo: "test", Target: event, Name: "lou.txt"}); err != nil {
t.Errorf("FAIL: a reader could not remove their OWN attachment: %v", err)
}
if _, err := r.Invoke(moderator, "attachment.remove",
attachmentRemoveIn{Repo: "test", Target: event, Name: "dan.txt"}); err != nil {
t.Errorf("FAIL: a moderator could not remove another's attachment: %v", err)
}
// An anonymous caller matches an anonymous upload's empty author by
// string alone; two absences are not one person.
if _, err := r.Invoke(ops.WithCaps(context.Background(), oltp.CapSet("bB")), "attachment.remove",
attachmentRemoveIn{Repo: "test", Target: event, Name: "dan.txt"}); !errors.Is(err, ops.ErrForbidden) {
t.Errorf("FAIL: an anonymous caller removed an attachment: %v", err)
}
t.Log("ok: removal is the uploader's or a moderator's")
}