Skip to content
quarry Artifact [3850f83a47]
Login

Artifact [3850f83a47]

Artifact 3850f83a47755ebedeceb1a0ba111410e33fb9c816dc059f1a94d6e28edf4814:


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, `>[{&#34;AllowedOrigins&#34;:[&#34;https://forge.example&#34;]`) {
		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&#34;:[&#34;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")
}