package web
import (
"archive/zip"
"bytes"
"context"
"fmt"
"net/http/httptest"
"strings"
"testing"
"craftdesign.group/quarry/internal/ops"
"craftdesign.group/quarry/internal/sniff"
)
type headIn struct {
Repo string `json:"repo" validate:"required,max=64"`
Hash string `json:"hash" validate:"required,min=1,max=64"`
Lines int `json:"lines" validate:"min=0,max=1000"`
}
type headOut struct {
Name string `json:"name"`
Size int `json:"size"`
Kind string `json:"kind"`
Mimetype string `json:"mimetype,omitempty"`
Lines int `json:"lines,omitempty"`
Text string `json:"text,omitempty"`
Truncated bool `json:"truncated,omitempty"`
}
// bigSizes lets a test say a file is larger than its stub bytes, which
// is how the bundle's size gate is exercised without a 32 MiB fixture.
var bigSizes = map[string]int{}
type listIn struct {
Repo string `json:"repo" validate:"required,max=64"`
Target string `json:"target" validate:"required,max=200"`
}
type listOut struct {
Attachments []attachmentRow `json:"attachments"`
}
// attachStub answers the three read ops a card needs with bytes the
// test chose, so what is under test is the web layer's use of the
// answers rather than a store.
func attachStub(t *testing.T, files map[string][]byte, live map[string][]attachmentRow) *ops.Registry {
t.Helper()
r := viewRegistry(t, files)
if err := ops.Register(r, ops.Op[headIn, headOut]{
Name: "attachment.head", Caps: "jr", Doc: "test stub",
Kind: ops.Query, Auth: ops.Anonymous,
Route: "GET /api/v1/r/{repo}/attachment/{hash}/head",
Run: func(_ context.Context, in headIn) (headOut, error) {
raw, ok := files[in.Hash]
if !ok {
return headOut{}, ops.ErrNotFound
}
out := headOut{Name: in.Hash + ".bin", Size: len(raw), Kind: sniff.Kind(raw)}
if n, ok := bigSizes[in.Hash]; ok {
out.Size, out.Truncated = n, true
}
switch out.Kind {
case sniff.KindImage:
out.Mimetype, _ = sniff.Image(raw)
case sniff.KindMedia:
out.Mimetype, _ = sniff.Media(raw)
case sniff.KindText:
lines := strings.Split(strings.TrimSuffix(string(raw), "\n"), "\n")
out.Lines = len(lines)
if len(lines) > 8 {
lines = lines[:8]
}
out.Text = strings.Join(lines, "\n")
}
return out, nil
},
}); err != nil {
t.Fatal(err)
}
if err := ops.Register(r, ops.Op[listIn, listOut]{
Name: "attachment.list", Caps: "jr", Doc: "test stub",
Kind: ops.Query, Auth: ops.Anonymous,
Route: "GET /api/v1/r/{repo}/attachments/{target}",
Run: func(_ context.Context, in listIn) (listOut, error) {
return listOut{Attachments: live[in.Target]}, nil
},
}); err != nil {
t.Fatal(err)
}
return r
}
var (
mp4Bytes = append([]byte{0, 0, 0, 0x18}, []byte("ftypisom0000")...)
textBytes = []byte("one\ntwo\nthree\n")
pdfBytes = []byte("%PDF-1.7\n\x00\x01binary")
)
func mixedFiles() map[string][]byte {
return map[string][]byte{
"pic": pngBytes, "clip": mp4Bytes, "log": textBytes, "doc": pdfBytes,
}
}
func mixedRows() []attachmentRow {
return []attachmentRow{
{Name: "shot.png", Src: "pic", Date: "2026-01-01T00:00:00", User: "dan"},
{Name: "clip.mp4", Src: "clip", Date: "2026-01-01T00:00:00", User: "dan"},
{Name: "build.log", Src: "log", Date: "2026-01-01T00:00:00", User: "dan"},
{Name: "spec.pdf", Src: "doc", Date: "2026-01-01T00:00:00", User: "dan"},
}
}
// The section is the same shape on every page, and each card says which
// of the four kinds it is — decided from the bytes, not the name. The
// ticket page is where the old bare list was, so it is where the
// replacement is asserted.
func TestTicketPageDrawsAttachmentCards(t *testing.T) {
r := attachStub(t, mixedFiles(), nil)
cards := attachCards(context.Background(), r, "tk", mixedRows())
if len(cards) != 4 {
t.Fatalf("FAIL: %d cards, want 4", len(cards))
}
var buf bytes.Buffer
if err := ticketPage.ExecuteTemplate(&buf, "content", pageData{
Repo: "tk", Ticket: ticketDetail{ID: strings.Repeat("ab", 20)},
AttachTarget: strings.Repeat("ab", 20), AttachCards: cards,
}); err != nil {
t.Fatal(err)
}
html := buf.String()
for _, want := range []string{
`data-hook="attachments"`, `class="attach-grid"`,
`data-hook="attachment" data-kind="image"`,
`data-hook="attachment" data-kind="media"`,
`data-hook="attachment" data-kind="text"`,
`data-hook="attachment" data-kind="other"`,
`/r/tk/attachment/pic/view`,
`/r/tk/attachment/log/text`,
`/r/tk/attachment/doc"`,
`class="attach-dl"`,
`class="attach-sub"`,
"/attachments/" + strings.Repeat("ab", 20) + ".zip",
} {
if !strings.Contains(html, want) {
t.Errorf("FAIL: the section is missing %q", want)
}
}
// The old bare list is gone: one shape, not two.
if strings.Contains(html, "<h3>Attachments</h3>") {
t.Error("FAIL: the old unstyled list is still rendered")
}
t.Log("ok: the ticket page draws one card per file, each tagged with its sniffed kind")
}
// No files, no section. An empty heading on every ticket that never had
// an attachment is furniture a reader has to scroll past.
func TestAttachmentSectionAbsentWithoutFiles(t *testing.T) {
var buf bytes.Buffer
if err := ticketPage.ExecuteTemplate(&buf, "content", pageData{
Repo: "tk", Ticket: ticketDetail{ID: strings.Repeat("ab", 20)},
}); err != nil {
t.Fatal(err)
}
if strings.Contains(buf.String(), `data-hook="attachments"`) {
t.Fatal("FAIL: a ticket with no files still drew the section")
}
t.Log("ok: the section is absent when there is nothing to put in it")
}
// A single file has nothing to bundle, so the zip link is not offered.
func TestDownloadAllOnlyWhenThereIsMoreThanOne(t *testing.T) {
r := attachStub(t, mixedFiles(), nil)
one := attachCards(context.Background(), r, "tk", mixedRows()[:1])
var buf bytes.Buffer
if err := ticketPage.ExecuteTemplate(&buf, "content", pageData{
Repo: "tk", Ticket: ticketDetail{ID: "aa"}, AttachTarget: "aa", AttachCards: one,
}); err != nil {
t.Fatal(err)
}
if strings.Contains(buf.String(), "/attachments/aa.zip") {
t.Fatal("FAIL: a lone attachment was offered a bundle")
}
t.Log("ok: Download all appears only where there is more than one file")
}
// Inside a post the same cards arrive without the heading and with the
// compact class — same markup, smaller grid.
func TestForumPostCarriesCompactSection(t *testing.T) {
r := attachStub(t, mixedFiles(), nil)
cards := attachCards(context.Background(), r, "tk", mixedRows()[:1])
var buf bytes.Buffer
if err := forumThreadPage.ExecuteTemplate(&buf, "content", pageData{
Repo: "tk", ForumRoot: "f1",
ForumPosts: []forumPostRow{{Hash: "f1", User: "dan", Attach: cards}},
}); err != nil {
t.Fatal(err)
}
html := buf.String()
if !strings.Contains(html, "attach attach-compact") {
t.Fatalf("FAIL: the post's section is not compact:\n%.800s", html)
}
if strings.Contains(html, "<h3>Attachments") {
t.Error("FAIL: the compact section drew a heading")
}
t.Log("ok: a forum post carries the compact grid and no heading")
}
// The lightbox is sent to a page that has something to open and to no
// other page.
func TestLightboxScriptOnlyWhereAnImageIs(t *testing.T) {
r := attachStub(t, mixedFiles(), nil)
withImage := attachCards(context.Background(), r, "tk", mixedRows())
without := attachCards(context.Background(), r, "tk", mixedRows()[1:])
if !attachHasImage(withImage) {
t.Fatal("FAIL: a page holding a PNG card reported no image")
}
if attachHasImage(without) {
t.Fatal("FAIL: a page with no image card asked for the lightbox")
}
// A body that resolved attach: to an inline image wants it too,
// even with no cards at all.
if !attachInlineImage(`<p><img src="/r/tk/attachment/pic/view" alt="shot"></p>`) {
t.Fatal("FAIL: an inline attachment image was not noticed")
}
if attachInlineImage(`<p><a href="/r/tk/attachment/doc">spec.pdf</a></p>`) {
t.Fatal("FAIL: a plain download link was mistaken for an inline image")
}
var buf bytes.Buffer
if err := ticketPage.Execute(&buf, pageData{
Repo: "tk", Ticket: ticketDetail{ID: "aa"}, AttachCards: withImage,
AttachJS: true, AttachJSURL: attachURLJS,
}); err != nil {
t.Fatal(err)
}
if !strings.Contains(buf.String(), `src="`+attachURLJS+`"`) {
t.Fatal("FAIL: the lightbox script was not linked on a page that asked for it")
}
buf.Reset()
if err := ticketPage.Execute(&buf, pageData{
Repo: "tk", Ticket: ticketDetail{ID: "aa"}, AttachJSURL: attachURLJS,
}); err != nil {
t.Fatal(err)
}
if strings.Contains(buf.String(), `src="`+attachURLJS+`"`) {
t.Fatal("FAIL: a page with no images still loaded the lightbox")
}
t.Log("ok: the lightbox script rides only on pages that hold an attachment image")
}
// The text viewer draws the whole file with a gutter, points at the raw
// bytes, and refuses anything that is not text.
func TestAttachmentTextViewer(t *testing.T) {
r := attachStub(t, mixedFiles(), nil)
rec := httptest.NewRecorder()
Handler(r, nil, nil).ServeHTTP(rec,
httptest.NewRequest("GET", "/r/tk/attachment/log/text", nil))
if rec.Code != 200 {
t.Fatalf("FAIL: status %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{`data-hook="attachment-text"`, "lntable", `href="/r/tk/attachment/log"`} {
if !strings.Contains(body, want) {
t.Errorf("FAIL: the viewer is missing %q", want)
}
}
// A page, not a payload: it carries the signed-in chrome, so it
// revalidates like every other page. The raw link beside it is the
// immutable address.
if rec.Header().Get("Cache-Control") != "no-cache" {
t.Errorf("FAIL: cache-control %q", rec.Header().Get("Cache-Control"))
}
for _, hash := range []string{"pic", "clip", "doc", "missing"} {
rec := httptest.NewRecorder()
Handler(r, nil, nil).ServeHTTP(rec,
httptest.NewRequest("GET", "/r/tk/attachment/"+hash+"/text", nil))
if rec.Code != 404 {
t.Errorf("FAIL: %s answered %d from the text viewer, want 404", hash, rec.Code)
}
}
t.Log("ok: the text viewer serves text with numbered lines and 404s on everything else")
}
// Media is served inline with ranges, so seeking works. Everything else
// about the route's posture is what the image path already keeps.
func TestAttachmentViewServesMediaWithRanges(t *testing.T) {
r := attachStub(t, mixedFiles(), nil)
rec := httptest.NewRecorder()
Handler(r, nil, nil).ServeHTTP(rec,
httptest.NewRequest("GET", "/r/tk/attachment/clip/view", nil))
if rec.Code != 200 {
t.Fatalf("FAIL: status %d", rec.Code)
}
h := rec.Header()
if h.Get("Content-Type") != "video/mp4" {
t.Errorf("FAIL: content-type %q", h.Get("Content-Type"))
}
if h.Get("Accept-Ranges") != "bytes" {
t.Errorf("FAIL: accept-ranges %q", h.Get("Accept-Ranges"))
}
if h.Get("X-Content-Type-Options") != "nosniff" ||
h.Get("Content-Security-Policy") != "default-src 'none'; sandbox" {
t.Errorf("FAIL: the denying headers were dropped for media: %v", h)
}
// A partial request is answered as one.
req := httptest.NewRequest("GET", "/r/tk/attachment/clip/view", nil)
req.Header.Set("Range", "bytes=0-3")
part := httptest.NewRecorder()
Handler(r, nil, nil).ServeHTTP(part, req)
if part.Code != 206 || part.Body.Len() != 4 {
t.Errorf("FAIL: range request answered %d with %d bytes", part.Code, part.Body.Len())
}
// A PDF is not media and not an image: still no inline view.
rec = httptest.NewRecorder()
Handler(r, nil, nil).ServeHTTP(rec,
httptest.NewRequest("GET", "/r/tk/attachment/doc/view", nil))
if rec.Code != 404 {
t.Errorf("FAIL: a PDF answered %d from the view route, want 404", rec.Code)
}
t.Log("ok: audio and video are served inline with byte ranges; PDF still is not")
}
// The bundle is a real zip, named by the A card's filenames.
func TestAttachmentsZipRoute(t *testing.T) {
r := attachStub(t, mixedFiles(), map[string][]attachmentRow{
"tkt1": mixedRows(),
})
rec := httptest.NewRecorder()
Handler(r, nil, nil).ServeHTTP(rec,
httptest.NewRequest("GET", "/r/tk/attachments/tkt1.zip", nil))
if rec.Code != 200 {
t.Fatalf("FAIL: status %d", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); ct != "application/zip" {
t.Errorf("FAIL: content-type %q", ct)
}
zr, err := zip.NewReader(bytes.NewReader(rec.Body.Bytes()), int64(rec.Body.Len()))
if err != nil {
t.Fatalf("FAIL: not a zip: %v", err)
}
got := map[string]int{}
for _, f := range zr.File {
got[f.Name] = int(f.UncompressedSize64)
}
for _, want := range []string{"shot.png", "clip.mp4", "build.log", "spec.pdf"} {
if _, ok := got[want]; !ok {
t.Errorf("FAIL: %q missing from the bundle (%v)", want, got)
}
}
if got["build.log"] != len(textBytes) {
t.Errorf("FAIL: build.log is %d bytes, want %d", got["build.log"], len(textBytes))
}
// A target with nothing on it has no archive.
rec = httptest.NewRecorder()
Handler(r, nil, nil).ServeHTTP(rec,
httptest.NewRequest("GET", "/r/tk/attachments/empty.zip", nil))
if rec.Code != 404 {
t.Errorf("FAIL: an empty target answered %d, want 404", rec.Code)
}
t.Log("ok: the bundle is a zip holding every live file under its own name")
}
// A path element in an attachment's name must not become a path in the
// archive: fossil's A card carries whatever the attacher typed.
func TestZipEntryNamesAreSingleElements(t *testing.T) {
r := attachStub(t, map[string][]byte{"log": textBytes}, map[string][]attachmentRow{
"t": {{Name: "../../etc/passwd", Src: "log", Date: "2026-01-01T00:00:00", User: "dan"}},
})
rec := httptest.NewRecorder()
Handler(r, nil, nil).ServeHTTP(rec,
httptest.NewRequest("GET", "/r/tk/attachments/t.zip", nil))
zr, err := zip.NewReader(bytes.NewReader(rec.Body.Bytes()), int64(rec.Body.Len()))
if err != nil {
t.Fatalf("FAIL: not a zip: %v", err)
}
if len(zr.File) != 1 || zr.File[0].Name != "passwd" {
t.Fatalf("FAIL: entry name %q escaped its archive", zr.File[0].Name)
}
t.Log("ok: an attachment named with a path lands as one file at the archive root")
}
// byteSize is what the sub line says, so it is asserted directly rather
// than through a rendered string somewhere.
func TestByteSizeReadsAsAPersonWouldSayIt(t *testing.T) {
for _, c := range []struct {
n int
want string
}{{0, "0 B"}, {512, "512 B"}, {41 * 1024, "41 KB"}, {3 << 20, "3.0 MB"}, {2 << 30, "2.0 GB"}} {
if got := byteSize(c.n); got != c.want {
t.Errorf("FAIL: byteSize(%d) = %q, want %q", c.n, got, c.want)
}
}
t.Log("ok: sizes are reported in the units a reader compares")
}
// A blob whose head cannot be read for a TRANSIENT reason — the store
// busy, a subprocess refused — still gets a named download card: the
// bytes exist, this render just could not look at them. Only a blob the
// store does not hold at all becomes the "missing" card.
func TestUnreadableHeadStillDrawsACard(t *testing.T) {
r := ops.NewRegistry()
if err := ops.Register(r, ops.Op[headIn, headOut]{
Name: "attachment.head", Caps: "jr", Doc: "test stub",
Kind: ops.Query, Auth: ops.Anonymous,
Route: "GET /api/v1/r/{repo}/attachment/{hash}/head",
Run: func(context.Context, headIn) (headOut, error) {
return headOut{}, fmt.Errorf("store busy: %w", ops.ErrUnavailable)
},
}); err != nil {
t.Fatal(err)
}
cards := attachCards(context.Background(), r, "tk", []attachmentRow{
{Name: "gone.bin", Src: "busy", Date: "2026-01-01T00:00:00", User: "dan"},
})
if len(cards) != 1 || cards[0].Kind != sniff.KindOther || cards[0].Ext != "BIN" {
t.Fatalf("FAIL: cards = %+v", cards)
}
if cards[0].Open != "/r/tk/attachment/busy" || cards[0].Download != "/r/tk/attachment/busy" {
t.Fatalf("FAIL: the fallback card does not point at its download: %+v", cards[0])
}
t.Log("ok: a file whose head cannot be read right now is still a named download")
}
// A file whose verdict came from a prefix says so: the badge counts
// what was seen and marks it a floor.
func TestTruncatedTextBadgeSaysSo(t *testing.T) {
bigSizes["log"] = 5 << 20
t.Cleanup(func() { delete(bigSizes, "log") })
r := attachStub(t, mixedFiles(), nil)
cards := attachCards(context.Background(), r, "tk", mixedRows()[2:3])
if len(cards) != 1 || !strings.Contains(cards[0].Badge, "+") {
t.Fatalf("FAIL: badge %q does not mark the count as a floor", cards[0].Badge)
}
t.Log("ok: a line count taken from a prefix is badged as a floor, not a total")
}
// A file past the op's read cap cannot be fetched, so the bundle leaves
// it out and NAMES it rather than failing the archive or going quietly
// short.
func TestBundleNamesWhatItLeftOut(t *testing.T) {
bigSizes["clip"] = 64 << 20
t.Cleanup(func() { delete(bigSizes, "clip") })
r := attachStub(t, mixedFiles(), map[string][]attachmentRow{"tkt1": mixedRows()})
rec := httptest.NewRecorder()
Handler(r, nil, nil).ServeHTTP(rec,
httptest.NewRequest("GET", "/r/tk/attachments/tkt1.zip", nil))
if rec.Code != 200 {
t.Fatalf("FAIL: status %d", rec.Code)
}
zr, err := zip.NewReader(bytes.NewReader(rec.Body.Bytes()), int64(rec.Body.Len()))
if err != nil {
t.Fatalf("FAIL: not a zip: %v", err)
}
if len(zr.File) != 3 {
t.Errorf("FAIL: %d entries, want the three that fit", len(zr.File))
}
for _, f := range zr.File {
if f.Name == "clip.mp4" {
t.Error("FAIL: the oversize file was pulled through anyway")
}
}
if !strings.Contains(zr.Comment, "clip.mp4") {
t.Errorf("FAIL: the archive does not say what it left out: %q", zr.Comment)
}
t.Log("ok: an oversize file is skipped and named in the archive comment")
}
// An A card whose blob this store does not hold — a mirror whose
// upstream never served it — gets a card that says so: no preview, no
// link, no download control, and a sub line that does not open with a
// separator for a size nobody knows.
func TestAttachmentWhoseBlobIsMissingSaysSo(t *testing.T) {
r := attachStub(t, map[string][]byte{"pic": pngBytes}, nil)
rows := []attachmentRow{
{Name: "shot.png", Src: "pic", Date: "2026-01-01T00:00:00", User: "dan"},
{Name: "lost.bat", Src: "gone", Date: "2026-01-01T00:00:00", User: "dan"},
}
cards := attachCards(context.Background(), r, "tk", rows)
if len(cards) != 2 || cards[1].Kind != attachKindMissing || cards[1].Download != "" || cards[1].Open != "" {
t.Fatalf("FAIL: missing blob card = %+v", cards[1])
}
var buf bytes.Buffer
if err := ticketPage.ExecuteTemplate(&buf, "content", pageData{
Repo: "tk", Ticket: ticketDetail{ID: strings.Repeat("ab", 20)},
AttachTarget: strings.Repeat("ab", 20), AttachCards: cards,
}); err != nil {
t.Fatal(err)
}
html := buf.String()
i := strings.Index(html, `data-kind="missing"`)
if i < 0 {
t.Fatal("FAIL: no card is marked missing")
}
card := html[i:]
if j := strings.Index(card, "</article>"); j > 0 {
card = card[:j]
}
if strings.Contains(card, `class="attach-dl"`) || strings.Contains(card, `href="/r/tk/attachment/gone`) {
t.Errorf("FAIL: a missing blob still offers a download: %s", card)
}
if strings.Contains(card, `<span class="attach-sub"> · `) {
t.Errorf("FAIL: the sub line opens with a separator for an unknown size: %s", card)
}
if !strings.Contains(card, `class="attach-prev attach-missing"`) {
t.Errorf("FAIL: the missing card has no placeholder: %s", card)
}
t.Log("ok: a blob the store lacks is a card that says so, with nothing to click")
}
// An empty section carries no count: a zero is a stale-looking number
// on a heading that already says there is nothing under it.
func TestEmptyAttachmentsSectionShowsNoCount(t *testing.T) {
html := renderTicketAttachments(t, nil, &attachWrite{Family: "ticket", Unbound: true})
if strings.Contains(html, `class="n"`) {
t.Error("FAIL: the empty section drew a count badge")
}
t.Log("ok: an empty section has no count")
}