Skip to content
quarry Artifact [a5a9b865bb]
Login

Artifact [a5a9b865bb]

Artifact a5a9b865bbdcb5cd5d154559de56691fcf20cfc01e03e1578bd4bf182a1633de:


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") != "private, 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")
}

// A picture the body already shows through an attach: reference is
// not previewed a second time in the section: its card shrinks to a
// row that still downloads and removes.
func TestInlineImagesAreNotPreviewedTwice(t *testing.T) {
	r := attachStub(t, mixedFiles(), nil)
	cards := attachCards(context.Background(), r, "tk", mixedRows())
	var img *attachCard
	for i := range cards {
		if cards[i].Kind == "image" {
			img = &cards[i]
			break
		}
	}
	if img == nil {
		t.Fatal("the fixture has no image")
	}
	body := `<p><img src="/r/tk/attachment/` + img.Src + `/view" alt="x"></p>`
	cards = markInline(cards, body)
	html := renderTicketAttachments(t, cards, nil)
	if !strings.Contains(html, `data-inline="true"`) {
		t.Fatal("FAIL: the referenced image was not marked inline")
	}
	if n := strings.Count(html, `data-inline="true"`); n != 1 {
		t.Errorf("FAIL: %d cards marked inline, want 1", n)
	}
	if strings.Contains(html, `<img src="`+img.Open+`"`) {
		t.Error("FAIL: the inline image was previewed a second time in the section")
	}
	t.Log("ok: a picture in the text is listed once, not shown twice")
}