Skip to content
quarry Artifact [35774073e0]
Login

Artifact [35774073e0]

Artifact 35774073e0a2dab8c241a761266bcdcb2f225258f9132c3136f728e705f7909a:


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")
}