package api
import (
"context"
"path/filepath"
"strings"
"testing"
"craftdesign.group/quarry/internal/artifact"
"craftdesign.group/quarry/internal/index"
"craftdesign.group/quarry/internal/oltp"
"craftdesign.group/quarry/internal/ops"
)
// prefixAttachIndex folds a public ticket and a private one, each with
// a file, plus a wiki page whose NAME reads like a hex prefix of the
// public ticket's id. That page is the collision this fix must not
// cause: it is a legitimate target of its own and its attachments must
// keep answering, rather than being shadowed by the ticket whose id
// merely starts with the same digits.
func prefixAttachIndex(t *testing.T) (ix *index.Index, public, private string) {
t.Helper()
ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ix.Close() })
public = strings.Repeat("ab", 20) // ababab...
private = strings.Repeat("cd", 20) // cdcdcd...
att := func(hash, name, target string) index.Parsed {
return index.Parsed{Hash: hash, Art: &artifact.Artifact{
Type: artifact.Attachment, Date: "2026-01-02T00:00:00", User: "dan",
Mimetype: "image/png",
Attachment: &artifact.Attach{Name: name, Target: target, Source: "blob" + hash},
}}
}
if err := ix.FoldNow([]index.Parsed{
{Hash: "t1", Art: &artifact.Artifact{Type: artifact.Ticket, TicketID: public,
Date: "2026-01-01T00:00:00", User: "dan",
Fields: []artifact.Field{{Name: "status", Value: "Open"}, {Name: "title", Value: "a public ticket"}}}},
{Hash: "t2", Art: &artifact.Artifact{Type: artifact.Ticket, TicketID: private,
Date: "2026-01-01T00:00:00", User: "dan",
Fields: []artifact.Field{{Name: "status", Value: "Open"}, {Name: "title", Value: "a private ticket"},
{Name: "is_private", Value: "1"}}}},
// A page named exactly the prefix a caller would abbreviate
// the public ticket to.
{Hash: "w1", Art: &artifact.Artifact{Type: artifact.Wiki, WikiTitle: "abababab",
Date: "2026-01-01T00:00:00", User: "dan", Mimetype: "text/x-markdown",
Body: []byte("a page\n")}},
att("a1", "public.png", public),
att("a2", "private.png", private),
att("a3", "page.png", "abababab"),
}); err != nil {
t.Fatal(err)
}
return ix, public, private
}
func prefixAttachRegistry(t *testing.T) (*ops.Registry, string, string) {
t.Helper()
ix, public, private := prefixAttachIndex(t)
r := ops.NewRegistry()
if err := RegisterAll(r, Deps{Version: "v", Repos: testRepos(ix)}); err != nil {
t.Fatal(err)
}
return r, public, private
}
func attachNames(t *testing.T, r *ops.Registry, ctx context.Context, target string) []string {
t.Helper()
got, err := r.Invoke(ctx, "attachment.list", attachmentListIn{Repo: "test", Target: target})
if err != nil {
t.Fatalf("attachment.list(%q): %v", target, err)
}
var names []string
for _, a := range got.(attachmentListOut).Attachments {
names = append(names, a.Name)
}
return names
}
// The bug: a ticket id PREFIX — the form this forge's own pages link —
// answered 200 and an empty list, which is exactly what a ticket with
// no files answers. Indistinguishable from a correct answer, and it
// cost a real measurement before it was noticed (7a0f3c9f).
func TestAttachmentListResolvesATicketPrefix(t *testing.T) {
r, public, _ := prefixAttachRegistry(t)
ctx := context.Background()
// Not public[:8]: that is the wiki page's name, and the page is
// meant to win there. TestAnExactTargetIsNeverShadowedByATicketPrefix
// owns that case.
for _, target := range []string{public[:6], public[:12], public} {
got := attachNames(t, r, ctx, target)
if len(got) != 1 || got[0] != "public.png" {
t.Errorf("FAIL: target %q -> %v, want [public.png]", target, got)
continue
}
t.Logf("ok: %-40s -> %v", target, got)
}
}
// An exact hit always wins. The page named "abababab" is a target in
// its own right, and a ticket whose id starts with those digits must
// not shadow it — the expansion runs only when the literal target
// answered nothing, so no answer that is correct today can change.
func TestAnExactTargetIsNeverShadowedByATicketPrefix(t *testing.T) {
r, _, _ := prefixAttachRegistry(t)
got := attachNames(t, r, context.Background(), "abababab")
if len(got) != 1 || got[0] != "page.png" {
t.Fatalf("FAIL: the page's own file was shadowed: %v, want [page.png]", got)
}
t.Logf("ok: the page keeps its own file: %v", got)
}
// Privacy follows ticket.get. Without fossil's e the private ticket
// does not resolve, so a caller cannot confirm one exists by watching
// its attachments appear — and WITH e the prefix resolves, or the
// fix would have made private tickets unaddressable.
func TestAPrivateTicketPrefixResolvesOnlyWithE(t *testing.T) {
r, _, private := prefixAttachRegistry(t)
if got := attachNames(t, r, context.Background(), private[:8]); len(got) != 0 {
t.Errorf("FAIL: a private ticket's files leaked to a caller without e: %v", got)
} else {
t.Log("ok: without e, a private ticket's prefix resolves to nothing")
}
ctx := ops.WithCaps(context.Background(), oltp.CapSet("e"))
if got := attachNames(t, r, ctx, private[:8]); len(got) != 1 || got[0] != "private.png" {
t.Errorf("FAIL: with e, prefix -> %v, want [private.png]", got)
} else {
t.Log("ok: with e, the prefix resolves")
}
}
// A target that resolves to nothing is handed on as it arrived, and
// still answers the empty list rather than a refusal: the op cannot
// tell a bare ticket from a target that never existed, which is the
// reason the empty answer is right in the first place.
func TestAnUnresolvableTargetStillAnswersEmpty(t *testing.T) {
r, _, _ := prefixAttachRegistry(t)
for _, target := range []string{"9999", "Some Page", "", strings.Repeat("ef", 20)} {
if got := attachNames(t, r, context.Background(), target); len(got) != 0 {
t.Errorf("FAIL: target %q -> %v, want []", target, got)
}
}
t.Log("ok: an unresolvable target still answers the empty list")
}