Skip to content
quarry Artifact [0bd761dc99]
Login

Artifact [0bd761dc99]

Artifact 0bd761dc998be7a322717aae276826c299ad89df722eafa2406cdd13ad2a892b:


package web

import (
	"bytes"
	"context"
	"encoding/base64"
	"fmt"
	"io"
	"mime/multipart"
	"net/http"
	"net/http/httptest"
	"net/url"
	"path/filepath"
	"regexp"
	"sort"
	"strconv"
	"strings"
	"sync"
	"testing"

	"craftdesign.group/quarry/internal/api"
	"craftdesign.group/quarry/internal/oltp"
	"craftdesign.group/quarry/internal/ops"
)

// uvObjects is an S3 stand-in holding what unversioned files need:
// objects with their metadata, HEAD, GET, DELETE and a listing.
type uvObjects struct {
	mu      sync.Mutex
	objects map[string][]byte
	meta    map[string]http.Header
}

func newUVObjects(t *testing.T) (*httptest.Server, *uvObjects) {
	t.Helper()
	o := &uvObjects{objects: map[string][]byte{}, meta: map[string]http.Header{}}
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Query().Get("X-Amz-Signature") == "" {
			w.WriteHeader(http.StatusForbidden)
			return
		}
		o.mu.Lock()
		defer o.mu.Unlock()
		bucket, key, isObject := strings.Cut(strings.TrimPrefix(r.URL.Path, "/"), "/")
		if !isObject {
			var keys []string
			for k := range o.objects {
				if rest, ok := strings.CutPrefix(k, bucket+"/"); ok && strings.HasPrefix(rest, r.URL.Query().Get("prefix")) {
					keys = append(keys, rest)
				}
			}
			sort.Strings(keys)
			fmt.Fprint(w, "<ListBucketResult>")
			for _, k := range keys {
				fmt.Fprintf(w, "<Contents><Key>%s</Key><Size>%d</Size></Contents>", k, len(o.objects[bucket+"/"+k]))
			}
			fmt.Fprint(w, "</ListBucketResult>")
			return
		}
		key = bucket + "/" + key
		switch r.Method {
		case http.MethodPut:
			h := http.Header{}
			for name := range r.Header {
				if strings.HasPrefix(strings.ToLower(name), "x-amz-meta-") {
					h.Set(name, r.Header.Get(name))
				}
			}
			body, _ := io.ReadAll(r.Body)
			o.objects[key], o.meta[key] = body, h
		case http.MethodHead, http.MethodGet:
			body, ok := o.objects[key]
			if !ok {
				w.WriteHeader(http.StatusNotFound)
				return
			}
			for k, v := range o.meta[key] {
				w.Header()[k] = v
			}
			w.Header().Set("Content-Length", strconv.Itoa(len(body)))
			if r.Method == http.MethodGet {
				w.Write(body)
			}
		case http.MethodDelete:
			delete(o.objects, key)
			w.WriteHeader(http.StatusNoContent)
		}
	}))
	t.Cleanup(srv.Close)
	return srv, o
}

type uvSite struct {
	srv     *httptest.Server
	r       *ops.Registry
	buckets webBuckets
	s3      string
	caps    *memCaps
}

// newUVSite is a forge with one repository, alpha, an OLTP catalog and
// an S3 stand-in, read by dan holding letters.
func newUVSite(t *testing.T, letters string, bound bool) uvSite {
	t.Helper()
	return newUVSiteWith(t, letters, bound, nil)
}

// newUVSiteWith is newUVSite with the api's Deps adjusted first.
func newUVSiteWith(t *testing.T, letters string, bound bool, mutate func(*api.Deps)) uvSite {
	t.Helper()
	db, err := oltp.Open(filepath.Join(t.TempDir(), "o.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { db.Close() })
	s3srv, _ := newUVObjects(t)
	buckets := webBuckets{}
	if bound {
		buckets["alpha"] = oltp.BucketBinding{Endpoint: s3srv.URL, Bucket: "files", Region: "auto",
			AccessKey: "AK", Secret: "SK", LimitBytes: 1 << 20}
	}
	r := ops.NewRegistry()
	d := api.Deps{Version: "v", Buckets: buckets, UV: db,
		Repos: api.NewRepos(map[string]*api.Repo{"alpha": {Index: landingIndex(t, false)}})}
	if mutate != nil {
		mutate(&d)
	}
	if err := api.RegisterAll(r, d); err != nil {
		t.Fatal(err)
	}
	caps := &memCaps{stored: map[string]string{"dan": letters}}
	verify := Verify(func(string, string, string, ops.Auth) api.Decision { return api.Allowed })
	srv := httptest.NewServer(HandlerWith(r, nil, nil, []Option{
		WithSessions(fakeSessions{user: "dan", roles: map[string]ops.Auth{"alpha": ops.Admin}}),
		WithCaps(caps),
		WithUVUpload(func(ctx context.Context, repo string, w http.ResponseWriter, req *http.Request) (string, error) {
			return api.UVUploadForm(ctx, d, r, repo, w, req)
		})}, verify))
	t.Cleanup(srv.Close)
	return uvSite{srv: srv, r: r, buckets: buckets, s3: s3srv.URL, caps: caps}
}

// put publishes a file as a holder of y, straight through the op.
func (s uvSite) put(t *testing.T, name string, mtime int64, body string) {
	t.Helper()
	ctx := ops.WithCaps(ops.WithUser(context.Background(), "pub"), oltp.CapSet("y"))
	if _, err := invoke(ctx, s.r, "uv.put", url.Values{"repo": {"alpha"}, "name": {name},
		"mtime": {strconv.FormatInt(mtime, 10)}, "content": {base64.StdEncoding.EncodeToString([]byte(body))}}); err != nil {
		t.Fatalf("put %s: %v", name, err)
	}
}

func (s uvSite) rm(t *testing.T, name string, mtime int64) {
	t.Helper()
	ctx := ops.WithCaps(ops.WithUser(context.Background(), "pub"), oltp.CapSet("y"))
	if _, err := invoke(ctx, s.r, "uv.rm", url.Values{"repo": {"alpha"}, "name": {name},
		"mtime": {strconv.FormatInt(mtime, 10)}}); err != nil {
		t.Fatalf("rm %s: %v", name, err)
	}
}

func (s uvSite) do(t *testing.T, req *http.Request) *http.Response {
	t.Helper()
	req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "good"})
	client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
	resp, err := client.Do(req)
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { resp.Body.Close() })
	return resp
}

func (s uvSite) get(t *testing.T, path string) (int, string) {
	t.Helper()
	req, _ := http.NewRequest("GET", s.srv.URL+path, nil)
	resp := s.do(t, req)
	b, _ := io.ReadAll(resp.Body)
	return resp.StatusCode, string(b)
}

// after is the listing as the browser sees it after a write: the
// response's cookies carried, the flash among them.
func (s uvSite) after(t *testing.T, resp *http.Response) string {
	t.Helper()
	req, _ := http.NewRequest("GET", s.srv.URL+"/r/alpha/uv", nil)
	for _, c := range resp.Cookies() {
		req.AddCookie(c)
	}
	b, _ := io.ReadAll(s.do(t, req).Body)
	return string(b)
}

// rowNames is the data-name of every element carrying hook, in order.
func rowNames(body, hook string) []string {
	var out []string
	for _, m := range regexp.MustCompile(`data-hook="`+hook+`" data-name="([^"]*)"`).FindAllStringSubmatch(body, -1) {
		out = append(out, m[1])
	}
	return out
}

const secretContent = "the-bytes-of-this-file-never-appear-in-a-page"

// The listing: one row per live file with its link, size, hash prefix
// (full on hover), mtime and who; removals in a collapsed section; by
// name unless asked by age. A reader gets no upload form and no remove.
func TestUVListingRowsAndRemoved(t *testing.T) {
	s := newUVSite(t, "o", true)
	s.put(t, "b.txt", 1000, secretContent)
	s.put(t, "a/z.tar.gz", 3000, "z")
	s.put(t, "old.bin", 1000, "old")
	s.rm(t, "old.bin", 2000)
	code, body := s.get(t, "/r/alpha/uv")
	if code != http.StatusOK {
		t.Fatalf("FAIL: listing = %d", code)
	}
	if got := strings.Join(rowNames(body, "uv-row"), " "); got != "a/z.tar.gz b.txt" {
		t.Errorf("FAIL: rows by name = %q", got)
	}
	for _, want := range []string{
		`href="/r/alpha/uv/a/z.tar.gz"`, `data-hook="uv-list"`,
		`data-hook="uv-hash" title="`, `data-hook="uv-who"`,
	} {
		if !strings.Contains(body, want) {
			t.Errorf("FAIL: the listing lacks %s", want)
		}
	}
	removed := regexp.MustCompile(`(?s)<details[^>]*data-hook="uv-removed"[^>]*>.*?</details>`).FindString(body)
	if removed == "" || strings.Contains(removed[:strings.Index(removed, ">")], " open") {
		t.Errorf("FAIL: no collapsed removed section: %q", removed)
	}
	if got := strings.Join(rowNames(removed, "uv-removed-row"), " "); got != "old.bin" {
		t.Errorf("FAIL: removed rows = %q", got)
	}
	for _, absent := range []string{`data-hook="uv-upload"`, `data-hook="uv-remove"`, `data-hook="uv-unbound"`, secretContent} {
		if strings.Contains(body, absent) {
			t.Errorf("FAIL: a reader's listing carries %s", absent)
		}
	}
	_, byAge := s.get(t, "/r/alpha/uv?sort=age")
	if got := strings.Join(rowNames(byAge, "uv-row"), " "); got != "a/z.tar.gz b.txt" {
		t.Errorf("FAIL: rows by age = %q", got)
	}
	s.put(t, "c.txt", 5000, "c")
	_, byAge = s.get(t, "/r/alpha/uv?sort=age")
	if got := strings.Join(rowNames(byAge, "uv-row"), " "); got != "c.txt a/z.tar.gz b.txt" {
		t.Errorf("FAIL: rows by age = %q", got)
	}
	if !regexp.MustCompile(`data-hook="uv-sort" data-sort="age"[^>]*aria-current`).MatchString(byAge) {
		t.Errorf("FAIL: the age sort is not marked current")
	}
	t.Log("ok: rows, hash on hover, collapsed removals, both sorts, nothing for a reader to change")
}

// No bucket: the page says so; an admin is pointed at the bucket
// settings and a reader is not.
func TestUVListingWithoutABucket(t *testing.T) {
	admin := newUVSite(t, "oa", false)
	_, body := admin.get(t, "/r/alpha/uv")
	if !strings.Contains(body, `data-hook="uv-unbound"`) || !strings.Contains(body, `data-hook="uv-bind" href="/r/alpha/settings#attachments"`) {
		t.Errorf("FAIL: the admin's unbound page lacks the pointer")
	}
	if strings.Contains(body, `data-hook="uv-upload"`) || strings.Contains(body, `data-hook="uv-list"`) {
		t.Errorf("FAIL: an unbound page offers an upload or a listing")
	}
	reader := newUVSite(t, "oy", false)
	_, body = reader.get(t, "/r/alpha/uv")
	if !strings.Contains(body, `data-hook="uv-unbound"`) || strings.Contains(body, `data-hook="uv-bind"`) {
		t.Errorf("FAIL: a non-admin's unbound page is wrong")
	}
	if strings.Contains(body, `data-hook="uv-upload"`) {
		t.Errorf("FAIL: an unbound page offers y an upload")
	}
	t.Log("ok: no bucket is said, with the settings pointer only for a")
}

func uploadForm(t *testing.T, fields map[string]string, filename, content string) (*bytes.Buffer, string) {
	t.Helper()
	var buf bytes.Buffer
	mw := multipart.NewWriter(&buf)
	for k, v := range fields {
		mw.WriteField(k, v)
	}
	fw, err := mw.CreateFormFile("file", filename)
	if err != nil {
		t.Fatal(err)
	}
	fw.Write([]byte(content))
	mw.Close()
	return &buf, mw.FormDataContentType()
}

// A holder of y gets the form and a remove on every live row; the form
// publishes under the file's name, a prefix, or a name given, and the
// remove tombstones.
func TestUVUploadAndRemoveForY(t *testing.T) {
	s := newUVSite(t, "oy", true)
	s.put(t, "keep.txt", 1000, "k")
	_, body := s.get(t, "/r/alpha/uv")
	for _, want := range []string{
		`data-hook="uv-upload"`, `enctype="multipart/form-data"`, `action="/r/alpha/uv"`,
		`type="file" name="file"`, `name="name"`, `name="prefix"`,
	} {
		if !strings.Contains(body, want) {
			t.Errorf("FAIL: y's listing lacks %s", want)
		}
	}
	if n := strings.Count(body, `data-hook="uv-remove"`); n != 1 {
		t.Errorf("FAIL: %d remove buttons for one file", n)
	}
	for _, tc := range []struct {
		fields   map[string]string
		filename string
		want     string
	}{
		{nil, "plain.bin", "plain.bin"},
		{map[string]string{"prefix": "releases/2.0/"}, "z.tar.gz", "releases/2.0/z.tar.gz"},
		{map[string]string{"prefix": "releases/2.0", "name": "named.bin"}, "ignored.bin", "releases/2.0/named.bin"},
	} {
		buf, ctype := uploadForm(t, tc.fields, tc.filename, "content of "+tc.want)
		req, _ := http.NewRequest("POST", s.srv.URL+"/r/alpha/uv", buf)
		req.Header.Set("Content-Type", ctype)
		resp := s.do(t, req)
		if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != "/r/alpha/uv" {
			t.Errorf("FAIL: upload %s = %d %s", tc.want, resp.StatusCode, resp.Header.Get("Location"))
		}
		if page := s.after(t, resp); !strings.Contains(page, `data-hook="uv-done"`) || strings.Contains(page, `data-hook="uv-error"`) {
			t.Errorf("FAIL: upload %s did not say it published", tc.want)
		}
	}
	_, body = s.get(t, "/r/alpha/uv")
	if got := strings.Join(rowNames(body, "uv-row"), " "); got != "keep.txt plain.bin releases/2.0/named.bin releases/2.0/z.tar.gz" {
		t.Errorf("FAIL: after uploads = %q", got)
	}
	remove := func(name, seen string) *http.Response {
		req, _ := http.NewRequest("POST", s.srv.URL+"/r/alpha/uv/rm", strings.NewReader(url.Values{
			"name": {name}, "seen": {seen}}.Encode()))
		req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
		return s.do(t, req)
	}
	// A removal naming a version the file no longer has is refused, so
	// nobody removes a version they never saw.
	if page := s.after(t, remove("plain.bin", "1")); !strings.Contains(page, `data-hook="uv-error"`) ||
		!strings.Contains(strings.Join(rowNames(page, "uv-row"), " "), "plain.bin") {
		t.Errorf("FAIL: a stale removal was not refused")
	}
	// Removing a file put this second still lands: the form names the
	// version shown and the server dates the removal after it.
	seen := regexp.MustCompile(`data-name="plain.bin" data-mtime="(\d+)"`).FindStringSubmatch(body)[1]
	if page := s.after(t, remove("plain.bin", seen)); !strings.Contains(page, `data-hook="uv-done"`) ||
		!strings.Contains(strings.Join(rowNames(page, "uv-removed-row"), " "), "plain.bin") {
		t.Errorf("FAIL: plain.bin was not removed")
	}
	// A refusal comes back to the page in words, once.
	buf, ctype := uploadForm(t, map[string]string{"name": "../up"}, "x.bin", "x")
	req, _ := http.NewRequest("POST", s.srv.URL+"/r/alpha/uv", buf)
	req.Header.Set("Content-Type", ctype)
	if page := s.after(t, s.do(t, req)); !strings.Contains(page, `data-hook="uv-error"`) {
		t.Errorf("FAIL: the refusal is not on the page")
	}
	// Words in the URL are not the forge's: nothing drawn from them.
	if _, page := s.get(t, "/r/alpha/uv?err=Your+account+is+suspended&done=ok"); strings.Contains(page, `data-hook="uv-error"`) ||
		strings.Contains(page, `data-hook="uv-done"`) || strings.Contains(page, "suspended") {
		t.Errorf("FAIL: the page draws a message from its query")
	}
	t.Log("ok: y uploads by name, prefix or given name, removes, and sees refusals")
}

// The upload route refuses whoever lacks y, before reading a byte.
func TestUVUploadRefusedWithoutY(t *testing.T) {
	s := newUVSite(t, "oa", true)
	buf, ctype := uploadForm(t, nil, "x.bin", "x")
	req, _ := http.NewRequest("POST", s.srv.URL+"/r/alpha/uv", buf)
	req.Header.Set("Content-Type", ctype)
	if resp := s.do(t, req); resp.StatusCode != http.StatusForbidden {
		t.Errorf("FAIL: upload without y = %d", resp.StatusCode)
	}
	t.Log("ok: a without y cannot publish")
}

// /uv/{name...} is a redirect to the bucket, the name keeping its
// slashes; a missing name is not found, with a bucket or without one,
// so `curl -fL` (deploy.sh --release) fails on it rather than saving the
// listing page as the file.
func TestUVDownloadRedirects(t *testing.T) {
	s := newUVSite(t, "o", true)
	s.put(t, "releases/1.0/x.tar.gz", 1000, secretContent)
	req, _ := http.NewRequest("GET", s.srv.URL+"/r/alpha/uv/releases/1.0/x.tar.gz", nil)
	resp := s.do(t, req)
	loc := resp.Header.Get("Location")
	if resp.StatusCode != http.StatusFound || !strings.HasPrefix(loc, s.s3+"/files/unversioned/alpha/releases/1.0/x.tar.gz?") ||
		!strings.Contains(loc, "X-Amz-Signature=") {
		t.Errorf("FAIL: download = %d %s", resp.StatusCode, loc)
	}
	if b, _ := io.ReadAll(resp.Body); strings.Contains(string(b), secretContent) {
		t.Errorf("FAIL: the forge served the bytes")
	}
	if code, _ := s.get(t, "/r/alpha/uv/nope.bin"); code != http.StatusNotFound {
		t.Errorf("FAIL: a missing file = %d", code)
	}
	unbound := newUVSite(t, "o", false)
	req, _ = http.NewRequest("GET", unbound.srv.URL+"/r/alpha/uv/download.html", nil)
	if resp := unbound.do(t, req); resp.StatusCode != http.StatusNotFound {
		t.Errorf("FAIL: unbound download = %d %s; want 404", resp.StatusCode, resp.Header.Get("Location"))
	}
	t.Log("ok: downloads redirect to the bucket; a missing one is 404, bucket or not")
}

// The short URL carries a name the way uvURL spells it, and the name
// is unescaped exactly once: a literal %2f or %25 in the name survives,
// a name that is not held is still 404, and an escaped .. reaches no
// file outside the name space.
func TestUVDownloadEscapedNames(t *testing.T) {
	s := newUVSite(t, "o", true)
	names := []string{"a%2fb.txt", "100%25.txt", "%2e%2e/x", "café.txt", "日本/語.bin"}
	for _, n := range names {
		s.put(t, n, 1000, secretContent)
	}
	for _, n := range names {
		path := uvURL("/r/alpha", n)
		req, _ := http.NewRequest("GET", s.srv.URL+path, nil)
		resp := s.do(t, req)
		if resp.StatusCode != http.StatusFound || !strings.Contains(resp.Header.Get("Location"), "X-Amz-Signature=") {
			t.Errorf("FAIL: %q via %s = %d", n, path, resp.StatusCode)
		}
	}
	for _, path := range []string{"/r/alpha/uv/a%2fb.txt", "/r/alpha/uv/%2e%2e/x", "/r/alpha/uv/a//b.txt"} {
		if code, _ := s.get(t, path); code == http.StatusFound {
			t.Errorf("FAIL: %s found a file", path)
		}
	}
	t.Log("ok: the short URL unescapes a name once; .. and empty segments find nothing")
}

const releaseManifestJSON = `{"schema_version": 1,
 "artifacts": [{"name": "t-2.0.0-linux-x86_64.tar.gz", "target": "linux-x86_64", "bytes": 2048, "sha256": "aa11bb22", "qualification": "passed"}],
 "omitted_targets": [{"target": "darwin-arm64", "reason": "no cross-link"}, "windows-x86_64"],
 "qualification": {"linux-x86_64": ["unit", "e2e"]},
 "release": {"version": "2.0.0", "date": "2026-09-30", "fossil_check_in": "abcdef0123456789abcdef", "fossil_tag": "version-2.0.0"},
 "source": {"name": "t-2.0.0-source.tar.gz", "bytes": 10, "sha256": "dd44", "fossil_check_in": "abcdef0123456789abcdef"}}`

// The releases page: groups newest first with one marked, the manifest
// read into its fields with the check-in linked, SHA256SUMS noted where
// there is no manifest, and no file's content on the page.
func TestReleasesPage(t *testing.T) {
	s := newUVSite(t, "o", true)
	s.put(t, "releases/1.9.0/t.tar.gz", 5000, secretContent)
	s.put(t, "releases/1.9.0/SHA256SUMS", 5000, "ff  t.tar.gz\n")
	s.put(t, "releases/2.0.0/release-manifest.json", 1000, releaseManifestJSON)
	s.put(t, "releases/2.0.0/t-2.0.0-linux-x86_64.tar.gz", 1000, "bin")
	s.put(t, "releases/1.10.0/t.tar.gz", 1000, "t")
	code, body := s.get(t, "/r/alpha/releases")
	if code != http.StatusOK {
		t.Fatalf("FAIL: releases = %d", code)
	}
	if got := strings.Join(rowNames(body, "release"), " "); got != "2.0.0 1.10.0 1.9.0" {
		t.Errorf("FAIL: order = %q", got)
	}
	if n := strings.Count(body, `data-hook="release-newest"`); n != 1 {
		t.Errorf("FAIL: %d newest marks", n)
	}
	first := regexp.MustCompile(`(?s)data-hook="release" data-name="2.0.0".*?data-hook="release" data-name="1.10.0"`).FindString(body)
	for _, want := range []string{
		`data-hook="release-newest"`, `data-hook="release-manifest"`,
		`data-hook="release-artifact" data-target="linux-x86_64"`, `aa11bb22`,
		`href="/r/alpha/uv/releases/2.0.0/t-2.0.0-linux-x86_64.tar.gz"`,
		`data-hook="release-checkin" href="/r/alpha/checkin/abcdef0123456789abcdef"`,
		`data-hook="release-omitted"`, `data-hook="release-qualification"`,
	} {
		if !strings.Contains(first, want) {
			t.Errorf("FAIL: 2.0.0 lacks %s", want)
		}
	}
	omitted := regexp.MustCompile(`(?s)data-hook="release-omitted".*?</p>`).FindString(first)
	if got := strings.Join(rowNames(omitted, "release-omitted-target"), " "); got != "darwin-arm64 windows-x86_64" ||
		!regexp.MustCompile(`data-name="darwin-arm64"[^>]*>[^<]*<[^>]*>[^<]*</span>\s*<span[^>]*data-hook="release-omitted-reason"`).MatchString(omitted) ||
		strings.Count(omitted, `data-hook="release-omitted-reason"`) != 1 {
		t.Errorf("FAIL: omitted targets = %q", omitted)
	}
	if regexp.MustCompile(`data-label="qualified">\s*</td>`).MatchString(body) {
		t.Errorf("FAIL: an empty qualified field is drawn")
	}
	last := body[strings.Index(body, `data-hook="release" data-name="1.9.0"`):]
	if !strings.Contains(last, `data-hook="release-sums" href="/r/alpha/uv/releases/1.9.0/SHA256SUMS"`) ||
		strings.Contains(last, `data-hook="release-manifest"`) {
		t.Errorf("FAIL: 1.9.0 without a manifest is not a file list with its sums")
	}
	if strings.Contains(body, secretContent) {
		t.Errorf("FAIL: a file's content is on the page")
	}
	t.Log("ok: grouped newest first, one newest, manifest read, sums noted, no content")
}

// The changelog section renders beside its release, the way /changelog
// renders the forge's.
func TestReleaseNotesRenderBesideTheRelease(t *testing.T) {
	var buf bytes.Buffer
	if err := noHooks.template(releasesPage).ExecuteTemplate(&buf, "content", pageData{Repo: "alpha",
		Releases: &releasesView{Bound: true, Groups: []releaseGroup{{Name: "2.0.0", Newest: true,
			Notes: &releaseNotesView{Heading: "2.0.0 — 2026-09-30", HTML: "<p>the notes</p>"}}}}}); err != nil {
		t.Fatal(err)
	}
	out := buf.String()
	if !regexp.MustCompile(`(?s)data-hook="release-notes".*2\.0\.0 — 2026-09-30.*<p>the notes</p>`).MatchString(out) {
		t.Errorf("FAIL: the notes are not rendered: %s", out)
	}
	t.Log("ok: the changelog section is beside its release")
}

// The tabs: Unversioned with a bucket or any file, Releases only with a
// The tabs: UV on every repository, bucket or none, until its setting
// is off, and then the page still answers a direct link; Releases only
// with a releases/ file.
func TestUVTabsFollowTheSettingAndTheCatalog(t *testing.T) {
	nav := func(s uvSite) string {
		t.Helper()
		_, body := s.get(t, "/r/alpha/releases")
		return regexp.MustCompile(`(?s)<nav id="repo-tabs">.*?</nav>`).FindString(body)
	}
	none := newUVSite(t, "o", false)
	if tabs := nav(none); !strings.Contains(tabs, `href="/r/alpha/uv"`) || strings.Contains(tabs, `href="/r/alpha/releases"`) {
		t.Errorf("FAIL: an unbound repository's tabs are wrong: %q", tabs)
	}
	s := newUVSite(t, "o", true)
	s.put(t, "releases/1.0/a.bin", 1, "a")
	if tabs := nav(s); !strings.Contains(tabs, `href="/r/alpha/uv"`) || !strings.Contains(tabs, `href="/r/alpha/releases"`) {
		t.Errorf("FAIL: a repository with a release lacks a tab: %q", tabs)
	}
	off := newUVSiteWith(t, "o", true, func(d *api.Deps) {
		d.RepoMeta = func() map[string]api.RepoMeta { return map[string]api.RepoMeta{"alpha": {Chat: true, UV: false}} }
	})
	off.put(t, "a.txt", 1, "a")
	if tabs := nav(off); tabs == "" || strings.Contains(tabs, `href="/r/alpha/uv"`) {
		t.Errorf("FAIL: the UV tab shows with its setting off: %q", tabs)
	}
	if code, body := off.get(t, "/r/alpha/uv"); code != http.StatusOK || !strings.Contains(body, `data-name="a.txt"`) {
		t.Errorf("FAIL: with the tab off the page no longer answers: %d", code)
	}
	req, _ := http.NewRequest("GET", off.srv.URL+"/r/alpha/uv/a.txt", nil)
	if resp := off.do(t, req); resp.StatusCode != http.StatusFound {
		t.Errorf("FAIL: with the tab off a download link = %d", resp.StatusCode)
	}
	t.Log("ok: UV by default and by setting, Releases by catalog, links answer either way")
}

// The repository settings carry the UV switch beside Chat.
func TestSettingsCarryTheUVSwitch(t *testing.T) {
	on := renderSettings(t, pageData{Repo: "alpha", RepoUV: true})
	if !regexp.MustCompile(`data-hook="repo-uv"><label><input type="checkbox" name="uv" value="on" checked>`).MatchString(on) {
		t.Errorf("FAIL: the switch is missing or unchecked")
	}
	if off := renderSettings(t, pageData{Repo: "alpha"}); !strings.Contains(off, `name="uv" value="on">`) {
		t.Errorf("FAIL: an off switch renders checked or not at all")
	}
	t.Log("ok: the UV switch is in the settings form")
}

// One release has its own page, reached from the list's heading and,
// for the newest, from /releases/latest; a path is not a release.
func TestReleasePageAndLatest(t *testing.T) {
	s := newUVSite(t, "o", true)
	s.put(t, "releases/2.0.0/release-manifest.json", 1000, releaseManifestJSON)
	s.put(t, "releases/2.0.0/t-2.0.0-linux-x86_64.tar.gz", 1000, "bin")
	s.put(t, "releases/1.10.0/t.tar.gz", 1000, "t")
	s.put(t, "releases/1.10.0/SHA256SUMS", 1000, "ff  t.tar.gz\n")
	_, list := s.get(t, "/r/alpha/releases")
	for _, want := range []string{`data-hook="release-link" href="/r/alpha/releases/2.0.0"`, `data-hook="release-link" href="/r/alpha/releases/1.10.0"`} {
		if !strings.Contains(list, want) {
			t.Errorf("FAIL: the list lacks %s", want)
		}
	}
	code, one := s.get(t, "/r/alpha/releases/2.0.0")
	if code != http.StatusOK || !strings.Contains(one, `data-hook="release-page"`) || len(rowNames(one, "release")) != 1 ||
		!strings.Contains(one, `data-hook="release-newest"`) || !strings.Contains(one, `data-hook="release-manifest"`) ||
		!strings.Contains(one, `data-hook="release-checkin" href="/r/alpha/checkin/abcdef0123456789abcdef"`) {
		t.Errorf("FAIL: the release page = %d", code)
	}
	_, older := s.get(t, "/r/alpha/releases/1.10.0")
	if strings.Contains(older, `data-hook="release-newest"`) || !strings.Contains(older, `data-hook="release-sums"`) {
		t.Errorf("FAIL: the older release page is wrong")
	}
	req, _ := http.NewRequest("GET", s.srv.URL+"/r/alpha/releases/latest", nil)
	if resp := s.do(t, req); resp.StatusCode != http.StatusFound || resp.Header.Get("Location") != "/r/alpha/releases/2.0.0" {
		t.Errorf("FAIL: latest = %d %s", resp.StatusCode, resp.Header.Get("Location"))
	}
	for path, want := range map[string]int{"/r/alpha/releases/3.0.0": 404, "/r/alpha/releases/..": 0, "/r/alpha/releases/%2e%2e": 0} {
		code, _ := s.get(t, path)
		if want != 0 && code != want || want == 0 && code == http.StatusOK {
			t.Errorf("FAIL: %s = %d", path, code)
		}
	}
	t.Log("ok: a release's own page, linked from the list and from latest")
}

// The Files page's Releases block: the count and the newest, linked.
func TestFilesPageReleasesBox(t *testing.T) {
	render := func(box *releasesBox) string {
		var buf bytes.Buffer
		if err := noHooks.template(treePage).ExecuteTemplate(&buf, "content", pageData{Repo: "alpha",
			About: &aboutData{}, ReleasesBox: box}); err != nil {
			t.Fatal(err)
		}
		return buf.String()
	}
	with := render(&releasesBox{Count: 3, Latest: "2.0.0", When: "2026-09-30T00:00:00Z",
		Link: "/r/alpha/releases/2.0.0", All: "/r/alpha/releases"})
	if !regexp.MustCompile(`data-hook="releases-box-count">(?:<[^>]+>)*3<`).MatchString(with) {
		t.Errorf("FAIL: the count is not 3")
	}
	for _, want := range []string{`data-hook="releases-box"`,
		`data-hook="releases-box-latest" href="/r/alpha/releases/2.0.0"`, `href="/r/alpha/releases"`} {
		if !strings.Contains(with, want) {
			t.Errorf("FAIL: the block lacks %s", want)
		}
	}
	if without := render(nil); strings.Contains(without, `data-hook="releases-box"`) {
		t.Errorf("FAIL: a repository without releases shows the block")
	}
	t.Log("ok: the Files page names the releases when there are some")
}

// A reader the repository refuses learns nothing from any of the three
// routes: not found, no file name, and never a redirect to the bucket.
func TestUVPagesHidePrivateRepositories(t *testing.T) {
	hidden := false
	s := newUVSiteWith(t, "o", true, func(d *api.Deps) {
		d.CanRead = func(context.Context, string) bool { return !hidden }
	})
	s.put(t, "releases/1.0/secret-build.tar.gz", 1000, secretContent)
	s.put(t, "releases/1.0/other-build.tar.gz", 1000, "o")
	hidden = true
	for _, path := range []string{"/r/alpha/uv", "/r/alpha/uv/releases/1.0/secret-build.tar.gz", "/r/alpha/releases"} {
		req, _ := http.NewRequest("GET", s.srv.URL+path, nil)
		resp := s.do(t, req)
		b, _ := io.ReadAll(resp.Body)
		// The not-found page may repeat the path asked for; it names
		// nothing else.
		if resp.StatusCode != http.StatusNotFound || strings.Contains(string(b), "other-build") ||
			strings.HasPrefix(resp.Header.Get("Location"), s.s3) {
			t.Errorf("FAIL: %s = %d %q, leaks", path, resp.StatusCode, resp.Header.Get("Location"))
		}
	}
	t.Log("ok: a private repository's files are not found by a stranger")
}

// A manifest's words are drawn as text, and a check-in that is not a
// hash, or an artifact named outside the folder, links nowhere.
func TestReleaseManifestFieldsAreText(t *testing.T) {
	s := newUVSite(t, "o", true)
	s.put(t, "releases/3.0.0/release-manifest.json", 1000, `{"schema_version": 1,
 "artifacts": [{"name": "../../uv/download.html", "target": "<b>linux</b>", "bytes": 1, "sha256": "<i>aa</i>", "qualification": "<script>x</script>"}],
 "omitted_targets": [{"target": "<em>win</em>", "reason": "<a href=/x>why</a>"}],
 "release": {"version": "3.0.0", "date": "<b>d</b>", "fossil_check_in": "../../settings"}}`)
	_, body := s.get(t, "/r/alpha/releases")
	for _, raw := range []string{"<b>linux</b>", "<i>aa</i>", "<script>x</script>", "<em>win</em>", "<a href=/x>", "<b>d</b>"} {
		if strings.Contains(body, raw) {
			t.Errorf("FAIL: %s drawn as markup", raw)
		}
	}
	if !strings.Contains(body, "&lt;b&gt;linux&lt;/b&gt;") {
		t.Errorf("FAIL: the target is not shown as text")
	}
	if strings.Contains(body, `data-hook="release-checkin"`) || strings.Contains(body, "/settings") && strings.Contains(body, "checkin/") ||
		strings.Contains(body, `href="/r/alpha/uv/releases/3.0.0/../`) {
		t.Errorf("FAIL: a manifest field became a link")
	}
	t.Log("ok: manifest fields are text and odd ones link nowhere")
}

// #326: an artifact whose manifest sha256 differs from SHA256SUMS is
// marked in its row; the release that agrees is not.
func TestReleasePageMarksASumsMismatch(t *testing.T) {
	s := newUVSite(t, "o", true)
	s.put(t, "releases/2.0.0/release-manifest.json", 1000, releaseManifestJSON)
	s.put(t, "releases/2.0.0/SHA256SUMS", 1000, strings.Repeat("ab", 32)+"  t-2.0.0-linux-x86_64.tar.gz\n")
	code, body := s.get(t, "/r/alpha/releases")
	if code != http.StatusOK {
		t.Fatalf("FAIL: releases = %d", code)
	}
	row := regexp.MustCompile(`(?s)data-hook="release-artifact" data-target="linux-x86_64".*?</tr>`).FindString(body)
	if !strings.Contains(row, `data-hook="release-sums-mismatch"`) || strings.Count(body, `data-hook="release-sums-mismatch"`) != 1 {
		t.Fatalf("FAIL: mismatch mark = %d on the page; row %q", strings.Count(body, `data-hook="release-sums-mismatch"`), row)
	}
	t.Log("ok: the disagreeing artifact is marked, nothing else is")
}