package web
import (
"encoding/json"
"io"
"net/http/httptest"
"net/url"
"regexp"
"strings"
"testing"
"time"
"craftdesign.group/quarry/internal/api"
"craftdesign.group/quarry/internal/oltp"
"craftdesign.group/quarry/internal/ops"
)
// webBuckets is an api.BucketStore in memory for the page tests.
type webBuckets map[string]oltp.BucketBinding
func (m webBuckets) BindBucket(repo string, b oltp.BucketBinding) error { m[repo] = b; return nil }
func (m webBuckets) Bucket(repo string) (oltp.BucketBinding, bool, error) {
b, ok := m[repo]
return b, ok, nil
}
func (m webBuckets) UnbindBucket(repo string) error { delete(m, repo); return nil }
func (m webBuckets) SetBucketProbe(repo string, at time.Time, probeErr string, corsOK bool) error {
b := m[repo]
b.ProbedAt, b.ProbeErr, b.CorsOK = at.UTC().Format(time.RFC3339), probeErr, corsOK
m[repo] = b
return nil
}
// webMirrors is an api.MirrorStore with one tracking mirror.
type webMirrors map[string]api.Mirror
func (m webMirrors) Mirrors() ([]api.Mirror, error) {
var out []api.Mirror
for _, v := range m {
out = append(out, v)
}
return out, nil
}
func (m webMirrors) MirrorFor(repo string) (api.Mirror, bool, error) {
v, ok := m[repo]
return v, ok, nil
}
func (m webMirrors) SetMirror(repo, url string, every int) error {
m[repo] = api.Mirror{Repo: repo, URL: url, Every: every, Enabled: true}
return nil
}
func (m webMirrors) DetachMirror(string) error { return nil }
func (m webMirrors) SetMirrorWrites(string, bool) error { return nil }
func (m webMirrors) RecordMirrorRun(string, time.Duration, bool, error) (int, error) { return 0, nil }
func (m webMirrors) MirrorPulls(string, int) ([]api.MirrorPull, error) { return nil, nil }
func (m webMirrors) MirrorDiverged(string, string, time.Time) error { return nil }
// webCompanions binds one parent to one forum store.
type webCompanions map[string]string
func (c webCompanions) Companion(repo, _ string) (string, bool, error) {
s, ok := c[repo]
return s, ok, nil
}
func (c webCompanions) CompanionParent(store string) (string, string, bool, error) {
for p, s := range c {
if s == store {
return p, "forum", true, nil
}
}
return "", "", false, nil
}
func (c webCompanions) BindCompanion(repo, _, store string) error { c[repo] = store; return nil }
func bucketServer(t *testing.T, mutate func(*api.Deps)) *httptest.Server {
t.Helper()
d := api.Deps{Version: "v", Buckets: webBuckets{},
Repos: api.NewRepos(map[string]*api.Repo{"alpha": {Index: landingIndex(t, false)}, "alpha-forum": {Index: landingIndex(t, false)}})}
if mutate != nil {
mutate(&d)
}
r := ops.NewRegistry()
if err := api.RegisterAll(r, d); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(HandlerWith(r, nil, nil, []Option{
WithSessions(fakeSessions{user: "dan", roles: map[string]ops.Auth{"alpha": ops.Admin, "alpha-forum": ops.Admin}}),
WithCaps(&memCaps{stored: map[string]string{"dan": "a"}})}))
t.Cleanup(srv.Close)
return srv
}
// The section carries its hooks: the status line with data-ok, the
// three forms, a password field that is never filled in, and an entry
// in the rail. Unbound, there is nothing to probe or unbind.
func TestAttachmentsSectionHooks(t *testing.T) {
bound := renderSettings(t, pageData{Repo: "alpha", Caps: &capsPanel{}, Bucket: &bucketPanel{
Bound: true, Endpoint: "https://acct.r2.cloudflarestorage.com", Bucket: "files", Region: "auto",
AccessKey: "AK", LimitMiB: 100, ProbedAt: "2026-09-11T10:00:00Z"}})
for _, want := range []string{
`<section id="attachments">`, `href="#attachments"`,
`data-hook="bucket" data-ok="true"`,
`action="/r/alpha/settings/bucket"`, `action="/r/alpha/settings/bucket/probe"`,
`action="/r/alpha/settings/bucket/unbind"`,
`name="secret" type="password"`, `placeholder="unchanged"`,
`name="limitMiB" type="number" min="1" max="10240" size="6" value="100"`,
} {
if !strings.Contains(bound, want) {
t.Errorf("FAIL: bound section lacks %s", want)
}
}
if m := regexp.MustCompile(`name="secret"[^>]*value=`).FindString(bound); m != "" {
t.Errorf("FAIL: the secret field carries a value: %s", m)
}
unbind := regexp.MustCompile(`(?s)<form[^>]*bucket/unbind.*?</form>`).FindString(bound)
if !strings.Contains(unbind, `data-confirm=`) || !strings.Contains(unbind, `type="submit"`) {
t.Errorf("FAIL: unbind is not a confirmed submit: %s", unbind)
}
failing := renderSettings(t, pageData{Repo: "alpha", Bucket: &bucketPanel{Bound: true, Bucket: "files",
Endpoint: "https://x.example", ProbedAt: "2026-09-11T10:00:00Z", ProbeErr: "PUT returned 403 AccessDenied"}})
if !strings.Contains(failing, `data-hook="bucket" data-ok="false"`) {
t.Error("FAIL: a failed probe does not read data-ok=false")
}
unbound := renderSettings(t, pageData{Repo: "alpha", Bucket: &bucketPanel{LimitMiB: 100}})
for _, absent := range []string{`data-hook="bucket"`, `bucket/unbind`, `bucket/probe`, `placeholder="unchanged"`} {
if strings.Contains(unbound, absent) {
t.Errorf("FAIL: unbound section carries %s", absent)
}
}
if !strings.Contains(unbound, `name="secret" type="password"`) || !regexp.MustCompile(`name="secret"[^>]*required`).MatchString(unbound) {
t.Error("FAIL: a first binding does not require the secret")
}
none := renderSettings(t, pageData{Repo: "alpha"})
if strings.Contains(none, `id="attachments"`) || strings.Contains(none, `href="#attachments"`) {
t.Error("FAIL: the section draws with no panel")
}
t.Log("ok: hooks present when bound, absent when unbound, section gone with no panel")
}
// A mirror accepts no writes, so its settings page has no Attachments
// section; the same page on a native repository has one.
func TestAttachmentsSectionAbsentOnAMirror(t *testing.T) {
ms := webMirrors{}
_ = ms.SetMirror("alpha", "https://upstream.example/home", 3600)
srv := bucketServer(t, func(d *api.Deps) { d.Mirrors = ms })
if body := asDan(t, srv, "/r/alpha/settings"); strings.Contains(body, `id="attachments"`) {
t.Fatal("FAIL: a mirror's settings page draws the Attachments section")
}
if body := asDan(t, srv, "/r/alpha-forum/settings"); !strings.Contains(body, `id="attachments"`) {
t.Fatal("FAIL: a native repository's settings page has no Attachments section")
}
t.Log("ok: hidden on the mirror, present on the native store")
}
// A companion store says whose bucket it uses and links there; it
// draws no form of its own.
func TestAttachmentsCompanionNamesItsParent(t *testing.T) {
buckets := webBuckets{"alpha": {Endpoint: "https://x.example", Bucket: "files", Region: "auto", AccessKey: "AK", Secret: "s"}}
srv := bucketServer(t, func(d *api.Deps) {
d.Buckets = buckets
d.Companions = webCompanions{"alpha": "alpha-forum"}
})
body := asDan(t, srv, "/r/alpha-forum/settings")
if !strings.Contains(body, `data-hook="bucket-inherited"`) || !strings.Contains(body, `href="/r/alpha/settings#attachments"`) {
t.Fatalf("FAIL: the companion does not name its parent's bucket")
}
if strings.Contains(body, `action="/r/alpha-forum/settings/bucket"`) {
t.Fatal("FAIL: the companion draws a bucket form")
}
t.Log("ok: companion points at its parent")
}
// A refused save comes back as the settings page with the reason in
// the section and the typed fields kept — all but the secret.
func TestAttachmentsRefusedSaveRedraws(t *testing.T) {
srv := bucketServer(t, nil)
resp := postAsDan(t, srv, "/r/alpha/settings/bucket", url.Values{
"endpoint": {"http://bucket.example"}, "bucket": {"files"}, "region": {"auto"},
"accessKey": {"AK"}, "secret": {"topsecret"}, "limitMiB": {"25"}})
if resp.StatusCode != 422 {
t.Fatalf("FAIL: status %d, want 422", resp.StatusCode)
}
b, _ := io.ReadAll(resp.Body)
body := string(b)
for _, want := range []string{`<section id="attachments">`, `data-hook="bucket-refused"`, `value="http://bucket.example"`, `value="AK"`, `value="25"`} {
if !strings.Contains(body, want) {
t.Errorf("FAIL: redraw lacks %s", want)
}
}
if strings.Contains(body, "topsecret") {
t.Error("FAIL: the secret was drawn back")
}
if strings.Contains(body, "invalid request") {
t.Error("FAIL: the sentinel leaked into the reason")
}
t.Log("ok: refused save redraws the section with its reason")
}
// Fleet shows a bound bucket's last probe on the store's row the way
// it shows a mirror's last pull, with the verdict as data-ok.
func TestFleetRowShowsTheBucketProbe(t *testing.T) {
var row fleetRow
row.Name, row.Bucket, row.BucketProbedAt = "alpha", "files", "2026-09-11T10:00:00Z"
var sb strings.Builder
if err := fleetAdminPage.ExecuteTemplate(&sb, "fleet-row", row); err != nil {
t.Fatal(err)
}
if !strings.Contains(sb.String(), `data-hook="bucket-probe" data-ok="true"`) {
t.Fatalf("FAIL: no probe on the row:\n%s", sb.String())
}
row.BucketErr = "PUT returned 403 AccessDenied"
sb.Reset()
if err := fleetAdminPage.ExecuteTemplate(&sb, "fleet-row", row); err != nil {
t.Fatal(err)
}
if !strings.Contains(sb.String(), `data-hook="bucket-probe" data-ok="false"`) || !strings.Contains(sb.String(), `class="row-err"`) {
t.Fatalf("FAIL: a failing probe is not on the row:\n%s", sb.String())
}
row.Bucket = ""
sb.Reset()
_ = fleetAdminPage.ExecuteTemplate(&sb, "fleet-row", row)
if strings.Contains(sb.String(), `bucket-probe`) {
t.Fatal("FAIL: an unbound store shows a probe")
}
t.Log("ok: the fleet row carries the probe and its verdict")
}
// The browser's half of the probe has its own line: a bucket without
// a CORS rule for the forge says so with data-ok=false and the exact
// rule to paste — the forge's origin, R2's shape — under a copy
// button; one that answered the preflight says so with data-ok=true.
func TestAttachmentsSectionShowsTheCORSVerdictAndTheRule(t *testing.T) {
rule := corsRule("https://forge.example")
blocked := renderSettings(t, pageData{Repo: "alpha", Bucket: &bucketPanel{
Bound: true, Endpoint: "https://acct.r2.cloudflarestorage.com", Bucket: "files",
LimitMiB: 100, ProbedAt: "2026-09-11T10:00:00Z", CorsRule: rule}})
for _, want := range []string{
`data-hook="bucket-cors" data-ok="false"`, `<pre><code id="bucket-cors-rule">`,
`data-copy="bucket-cors-rule"`, `href="/r/alpha/settings#attachments"`,
} {
if !strings.Contains(blocked, want) && want != `href="/r/alpha/settings#attachments"` {
t.Errorf("FAIL: blocked section lacks %s", want)
}
}
if !strings.Contains(blocked, `>[{"AllowedOrigins":["https://forge.example"]`) {
t.Errorf("FAIL: the rule is not drawn in the code block: %s", regexp.MustCompile(`<code id="bucket-cors-rule">[^<]*`).FindString(blocked))
}
var parsed []struct {
AllowedOrigins, AllowedMethods, AllowedHeaders []string
MaxAgeSeconds int
}
if err := json.Unmarshal([]byte(rule), &parsed); err != nil || len(parsed) != 1 ||
parsed[0].AllowedOrigins[0] != "https://forge.example" || parsed[0].MaxAgeSeconds != 3600 ||
strings.Join(parsed[0].AllowedMethods, ",") != "PUT,HEAD,GET" ||
strings.Join(parsed[0].AllowedHeaders, ",") != "content-type,content-length" {
t.Errorf("FAIL: the rule is not R2's shape: %s (%v)", rule, err)
}
ok := renderSettings(t, pageData{Repo: "alpha", Bucket: &bucketPanel{
Bound: true, Endpoint: "https://x.example", Bucket: "files", LimitMiB: 100, CorsOK: true, CorsRule: rule}})
if !strings.Contains(ok, `data-hook="bucket-cors" data-ok="true"`) || strings.Contains(ok, `id="bucket-cors-rule"`) {
t.Error("FAIL: a bucket that answered the preflight still shows the rule")
}
unbound := renderSettings(t, pageData{Repo: "alpha", Bucket: &bucketPanel{LimitMiB: 100, CorsRule: rule}})
if strings.Contains(unbound, `data-hook="bucket-cors"`) {
t.Error("FAIL: an unbound section talks about CORS")
}
t.Log("ok: the CORS verdict is its own line and the rule is pasteable")
}
// Through the page: the panel reads corsOk from bucket.get and the
// rule carries the forge's configured origin.
func TestAttachmentsSectionReadsCORSFromTheBinding(t *testing.T) {
buckets := webBuckets{"alpha": {Endpoint: "https://x.example", Bucket: "files", Region: "auto", AccessKey: "AK", Secret: "s", CorsOK: true}}
srv := bucketServer(t, func(d *api.Deps) { d.Buckets = buckets })
if body := asDan(t, srv, "/r/alpha/settings"); !strings.Contains(body, `data-hook="bucket-cors" data-ok="true"`) {
t.Fatal("FAIL: corsOk from the binding did not reach the page")
}
b := buckets["alpha"]
b.CorsOK = false
buckets["alpha"] = b
body := asDan(t, srv, "/r/alpha/settings")
if !strings.Contains(body, `data-hook="bucket-cors" data-ok="false"`) || !strings.Contains(body, `AllowedOrigins":["http://`) {
t.Fatal("FAIL: a bucket without the rule does not show it with the forge's origin")
}
t.Log("ok: the verdict follows the binding")
}