package web
import (
"bytes"
"context"
"io"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"testing"
"craftdesign.group/quarry/internal/ops"
)
const tkID = "abababababababababababababababababababab"
func renderTicketAttachments(t *testing.T, cards []attachCard, write *attachWrite) string {
t.Helper()
var buf bytes.Buffer
if err := ticketPage.ExecuteTemplate(&buf, "content", pageData{
Repo: "tk", Ticket: ticketDetail{ID: tkID},
AttachTarget: tkID, AttachCards: cards, AttachWrite: write,
}); err != nil {
t.Fatal(err)
}
return buf.String()
}
// The control (RFC 0016 Phase 3): a holder of the letter with a bucket
// bound sees the form — a real multipart form posting to the upload
// route, the floor the script enhances — and a remove on every card.
// Without the letter, none of it; an administrator on a repository
// with no bucket sees one line pointing at settings.
func TestAttachControlFollowsTheLetterAndTheBinding(t *testing.T) {
r := attachStub(t, mixedFiles(), nil)
cards := attachCards(context.Background(), r, "tk", mixedRows())
withLetter := renderTicketAttachments(t, cards, &attachWrite{Family: "ticket", CanAttach: true, Limit: "100.0 MB", Back: "/r/tk/ticket/abab#attachments"})
for _, want := range []string{
`data-hook="attach-form"`, `enctype="multipart/form-data"`, `method="post"`,
`action="/r/tk/attach/upload/` + tkID + `?back=`, `type="file" name="file"`,
`<progress`, `data-hook="attach-error"`, `data-family="ticket"`,
`data-hook="attachment-remove"`, `action="/r/tk/attach/remove"`,
`name="target" value="` + tkID + `"`, `name="name" value="shot.png"`,
} {
if !strings.Contains(withLetter, want) {
t.Errorf("FAIL: with the letter, the section lacks %s", want)
}
}
if n := strings.Count(withLetter, `data-hook="attachment-remove"`); n != 4 {
t.Errorf("FAIL: %d remove buttons for 4 cards", n)
}
if !regexp.MustCompile(`data-hook="attachment-remove" data-confirm="[^"]+"`).MatchString(withLetter) {
t.Errorf("FAIL: remove is not a confirmed submit")
}
without := renderTicketAttachments(t, cards, nil)
for _, absent := range []string{`data-hook="attach-form"`, `data-hook="attachment-remove"`, `data-hook="attach-unbound"`, `data-hook="attachment-vouch"`} {
if strings.Contains(without, absent) {
t.Errorf("FAIL: without the letter, the section carries %s", absent)
}
}
unbound := renderTicketAttachments(t, nil, &attachWrite{Family: "ticket", Unbound: true})
if !strings.Contains(unbound, `data-hook="attach-unbound"`) || !strings.Contains(unbound, `href="/r/tk/settings#attachments"`) {
t.Errorf("FAIL: the unbound line is missing or does not point at settings")
}
if strings.Contains(unbound, `data-hook="attach-form"`) {
t.Errorf("FAIL: an unbound repository still offers the form")
}
// With no cards and nothing to offer, nothing is drawn — the shape
// the section has always had.
if none := renderTicketAttachments(t, nil, &attachWrite{Family: "ticket", CanMod: true}); strings.Contains(none, `data-hook="attachments"`) {
t.Errorf("FAIL: a moderator with nothing to review sees an empty section")
}
// A refusal carried back on the redirect is drawn in the error line.
refused := renderTicketAttachments(t, nil, &attachWrite{Family: "ticket", CanAttach: true, Error: "files of type x cannot be attached here"})
if !regexp.MustCompile(`data-hook="attach-error" role="alert">files of type`).MatchString(refused) {
t.Errorf("FAIL: the refusal is not in the error line")
}
t.Log("ok: the form follows the letter and the binding; admins see the unbound pointer")
}
// A held upload is a link with its name and size, never a picture,
// and a holder of the moderation letter sees vouch and reject on it.
func TestHeldCardIsALinkNeverAnImage(t *testing.T) {
cards := []attachCard{{
Name: "shot.png", Src: "blob1", Hash: "art1", Date: "2026-01-01T00:00:00", User: "lou",
Kind: "image", Ext: "PNG", Size: "12 KB", External: true, Held: true,
Open: "/r/tk/attachment/blob1/get", Download: "/r/tk/attachment/blob1/get",
}}
mod := renderTicketAttachments(t, cards, &attachWrite{Family: "ticket", CanMod: true, Back: "/r/tk/ticket/abab#attachments"})
card := regexp.MustCompile(`(?s)<article class="attach-card"[^>]*>.*?</article>`).FindString(mod)
if !strings.Contains(card, `data-held="true"`) || !strings.Contains(card, `data-external="true"`) {
t.Errorf("FAIL: the held card is not marked: %s", card)
}
if strings.Contains(card, "<img") || strings.Contains(card, "/view") {
t.Errorf("FAIL: a held upload rendered as a picture: %s", card)
}
for _, want := range []string{`href="/r/tk/attachment/blob1/get"`, `shot.png`, `12 KB`,
`data-hook="attachment-vouch"`, `action="/r/tk/attach/vouch"`, `name="artifact" value="art1"`,
`data-hook="attachment-reject"`, `action="/r/tk/attach/reject"`} {
if !strings.Contains(card, want) {
t.Errorf("FAIL: the held card lacks %s", want)
}
}
if !regexp.MustCompile(`data-hook="attachment-reject" data-confirm="`).MatchString(card) {
t.Errorf("FAIL: reject is not a confirmed submit")
}
reader := renderTicketAttachments(t, cards, nil)
if strings.Contains(reader, `data-hook="attachment-vouch"`) || strings.Contains(reader, `data-hook="attachment-reject"`) {
t.Errorf("FAIL: a reader sees the moderation buttons")
}
if attachHasImage(cards) {
t.Errorf("FAIL: a held image asks for the lightbox")
}
t.Log("ok: held cards are links with vouch and reject for moderators")
}
// The settings section carries the two switches and the held queue.
func TestSettingsAttachmentsPolicyAndHeldQueue(t *testing.T) {
html := renderSettings(t, pageData{Repo: "alpha", Bucket: &bucketPanel{
Bound: true, Endpoint: "https://x.example", Bucket: "files", LimitMiB: 100,
Archives: true, Held: []heldRow{{Artifact: "art1", Target: tkID, Name: "clip.mp4", Who: "lou", Created: "2026-09-11T10:00:00Z"}}}})
for _, want := range []string{
`data-hook="bucket-policy"`, `action="/r/alpha/settings/bucket/policy"`,
`name="archives" value="1" checked`, `name="deleteObjects" value="1">`,
`data-hook="attach-held"`, `href="/r/alpha/attachment/art1/get"`,
`data-hook="attachment-vouch"`, `data-hook="attachment-reject"`,
`name="back" value="/r/alpha/settings#attachments"`,
} {
if !strings.Contains(html, want) {
t.Errorf("FAIL: the section lacks %s", want)
}
}
quiet := renderSettings(t, pageData{Repo: "alpha", Bucket: &bucketPanel{Bound: true, Endpoint: "https://x.example", Bucket: "files", LimitMiB: 100}})
if strings.Contains(quiet, `data-hook="attach-held"`) {
t.Errorf("FAIL: an empty queue is drawn")
}
unbound := renderSettings(t, pageData{Repo: "alpha", Bucket: &bucketPanel{LimitMiB: 100}})
if strings.Contains(unbound, `data-hook="bucket-policy"`) {
t.Errorf("FAIL: policy switches on an unbound repository")
}
t.Log("ok: the switches and the queue draw on a bound repository")
}
type linkIn struct {
Repo string `json:"repo" validate:"required,max=64"`
Hash string `json:"hash" validate:"required,min=1,max=64"`
}
type linkOut struct {
External bool `json:"external"`
Download string `json:"download,omitempty"`
Inline string `json:"inline,omitempty"`
}
// The routes for an upload's bytes: /get redirects to what the link op
// answers, /view redirects only when an inline answer exists, and the
// plain download route redirects to /get rather than serving the stub
// as the file. The fragment route renders the section alone.
func TestStubRoutesRedirectToTheBucket(t *testing.T) {
r := attachStub(t, map[string][]byte{"pic": pngBytes, "stub": []byte("quarry-attachment 1\n")},
map[string][]attachmentRow{tkID: {{Name: "shot.png", Src: "pic", Hash: "a1", Date: "2026-01-01T00:00:00", User: "dan"}}})
links := map[string]linkOut{
"stub": {External: true, Download: "https://files.example/attachments/abc", Inline: "https://files.example/attachments/abc"},
"held": {External: true, Download: "https://bucket.example/files/attachments/def?X-Amz-Signature=x"},
"pic": {External: false},
}
if err := ops.Register(r, ops.Op[linkIn, linkOut]{
Name: "attachment.link", Caps: "jr", Doc: "test stub", Kind: ops.Query, Auth: ops.Anonymous,
Route: "GET /api/v1/r/{repo}/attachment/{hash}/link",
Run: func(_ context.Context, in linkIn) (linkOut, error) {
l, ok := links[in.Hash]
if !ok {
return linkOut{}, ops.ErrNotFound
}
return l, nil
},
}); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(Handler(r, nil, nil))
defer srv.Close()
client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
get := func(path string) (int, string) {
res, err := client.Get(srv.URL + path)
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
io.Copy(io.Discard, res.Body)
return res.StatusCode, res.Header.Get("Location")
}
if code, loc := get("/r/tk/attachment/stub/get"); code != http.StatusFound || loc != links["stub"].Download {
t.Errorf("FAIL: /get = %d %s", code, loc)
}
if code, loc := get("/r/tk/attachment/stub"); code != http.StatusFound || loc != "/r/tk/attachment/stub/get" {
t.Errorf("FAIL: download of a stub = %d %s", code, loc)
}
if code, loc := get("/r/tk/attachment/stub/view"); code != http.StatusFound || loc != links["stub"].Inline {
t.Errorf("FAIL: /view of a vouched stub = %d %s", code, loc)
}
if code, _ := get("/r/tk/attachment/held/view"); code != http.StatusNotFound {
t.Errorf("FAIL: /view of a held stub = %d", code)
}
if code, loc := get("/r/tk/attachment/pic/get"); code != http.StatusFound || loc != "/r/tk/attachment/pic" {
t.Errorf("FAIL: /get of a real blob = %d %s", code, loc)
}
res, err := http.Get(srv.URL + "/r/tk/attach.frag?target=" + tkID + "&family=ticket")
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(res.Body)
res.Body.Close()
if res.StatusCode != http.StatusOK || !strings.Contains(string(body), `data-hook="attachments"`) || !strings.Contains(string(body), `data-kind="image"`) {
t.Errorf("FAIL: frag = %d %s", res.StatusCode, body)
}
if strings.Contains(string(body), "<html") {
t.Errorf("FAIL: the fragment is a whole page")
}
t.Log("ok: stub routes redirect; the fragment is the section alone")
}
// The form floor's route: the multipart post is handed to the upload
// function and the poster is sent back where they came from — with
// the refusal in the query when there was one — so the page without
// script says what the script would.
func TestUploadRouteRedirectsBackWithTheRefusal(t *testing.T) {
r := attachStub(t, mixedFiles(), nil)
var gotTarget string
upload := func(_ context.Context, repo, target string, req *http.Request) (any, error) {
gotTarget = target
if strings.Contains(req.Header.Get("Content-Type"), "boundary=refuse") {
return nil, ops.ErrInvalid
}
return map[string]any{"name": "a.txt", "held": false}, nil
}
srv := httptest.NewServer(HandlerWith(r, nil, nil, []Option{WithUploadForm(upload)}))
defer srv.Close()
client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
post := func(boundary string) *http.Response {
body := "--" + boundary + "\r\nContent-Disposition: form-data; name=\"file\"; filename=\"a.txt\"\r\n\r\nhi\r\n--" + boundary + "--\r\n"
req, _ := http.NewRequest("POST", srv.URL+"/r/tk/attach/upload/"+tkID+"?back=/r/tk/ticket/abab%23attachments", strings.NewReader(body))
req.Header.Set("Content-Type", "multipart/form-data; boundary="+boundary)
res, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
io.Copy(io.Discard, res.Body)
res.Body.Close()
return res
}
if res := post("ok"); res.StatusCode != http.StatusSeeOther || res.Header.Get("Location") != "/r/tk/ticket/abab#attachments" || gotTarget != tkID {
t.Errorf("FAIL: ok = %d %s target %q", res.StatusCode, res.Header.Get("Location"), gotTarget)
}
if res := post("refuse"); res.StatusCode != http.StatusSeeOther || !strings.Contains(res.Header.Get("Location"), "/r/tk/ticket/abab?attach=") {
t.Errorf("FAIL: refused = %d %s", res.StatusCode, res.Header.Get("Location"))
}
// A back that leaves the repository's pages is not followed.
req, _ := http.NewRequest("POST", srv.URL+"/r/tk/attach/upload/"+tkID+"?back=https://evil.example/", strings.NewReader("--ok\r\nContent-Disposition: form-data; name=\"file\"; filename=\"a\"\r\n\r\nx\r\n--ok--\r\n"))
req.Header.Set("Content-Type", "multipart/form-data; boundary=ok")
res, _ := client.Do(req)
io.Copy(io.Discard, res.Body)
res.Body.Close()
if res.Header.Get("Location") != "/r/tk" {
t.Errorf("FAIL: an off-site back was followed: %s", res.Header.Get("Location"))
}
t.Log("ok: the upload route hands off the form and returns the poster")
}