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, "<b>linux</b>") {
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")
}