package web
import (
"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) error {
b := m[repo]
b.ProbedAt, b.ProbeErr = at.UTC().Format(time.RFC3339), probeErr
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, error) 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")
}