package web
import (
"bytes"
"context"
"html/template"
"net/http"
"net/http/httptest"
"strings"
"testing"
"craftdesign.group/quarry/internal/api"
"craftdesign.group/quarry/internal/ops"
)
// A tab for a plane that cannot be used here is not an empty section
// but a wrong one: Chat shows only where the forge runs a chat plane
// and the repository's chat setting is on, and the page behind a hidden
// tab is a 404 rather than an empty channel.
func TestChatTabFollowsThePlaneAndTheSetting(t *testing.T) {
ix := landingIndex(t, false)
serve := func(chat api.ChatPlane, meta func() map[string]api.RepoMeta) *httptest.Server {
r := ops.NewRegistry()
deps := api.Deps{Version: "v", Chat: chat, RepoMeta: meta,
Repos: api.NewRepos(map[string]*api.Repo{"alpha": {Index: ix}})}
if err := api.RegisterAll(r, deps); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(HandlerWith(r, nil, nil, nil))
t.Cleanup(srv.Close)
return srv
}
on := func() map[string]api.RepoMeta { return map[string]api.RepoMeta{"alpha": {Chat: true}} }
off := func() map[string]api.RepoMeta { return map[string]api.RepoMeta{"alpha": {Chat: false}} }
cases := []struct {
name string
srv *httptest.Server
tab bool
}{
{"no chat plane", serve(nil, on), false},
{"plane and setting on", serve(&webChatStub{}, on), true},
{"plane, setting off", serve(&webChatStub{}, off), false},
{"plane, no settings at all", serve(&webChatStub{}, nil), true},
}
for _, c := range cases {
page := landingGet(t, c.srv, "/r/alpha/timeline")
if got := strings.Contains(page, `href="/r/alpha/chat"`); got != c.tab {
t.Errorf("%s: chat tab shown=%v, want %v", c.name, got, c.tab)
}
// Landings and CI stay: this store is its own source of truth.
for _, tab := range []string{"/r/alpha/landing", "/r/alpha/ci"} {
if !strings.Contains(page, `href="`+tab+`"`) {
t.Errorf("%s: %s tab missing", c.name, tab)
}
}
res, err := http.Get(c.srv.URL + "/r/alpha/chat")
if err != nil {
t.Fatal(err)
}
res.Body.Close()
if want := map[bool]int{true: 200, false: 404}[c.tab]; res.StatusCode != want {
t.Errorf("%s: GET /chat = %d, want %d", c.name, res.StatusCode, want)
}
}
}
// renderBase draws the chrome — header, repo bar, tabs — around an
// empty page, which is all a tab test needs.
func renderBase(t *testing.T, d pageData) string {
t.Helper()
page := template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}{{end}}`))
var buf bytes.Buffer
if err := page.Execute(&buf, d); err != nil {
t.Fatal(err)
}
return buf.String()
}
// A repository that tracks an upstream is not the source of truth:
// nothing lands, builds or is discussed here, so those tabs go and
// the rest stay.
func TestTrackingMirrorHidesLandingsCIAndChat(t *testing.T) {
var d pageData
d.Repo, d.Section = "alpha", "timeline"
d.Posture.Mirror.Tracking, d.Posture.Mirror.URL, d.Posture.Chat = true, "https://x/y", true
html := renderBase(t, d)
for _, tab := range []string{"/r/alpha/landing", "/r/alpha/ci", "/r/alpha/chat"} {
if strings.Contains(html, `href="`+tab+`"`) {
t.Errorf("a tracking mirror shows %s", tab)
}
}
for _, tab := range []string{"/r/alpha/timeline", "/r/alpha/ticket", "/r/alpha/forum"} {
if !strings.Contains(html, `href="`+tab+`"`) {
t.Errorf("a tracking mirror lost %s", tab)
}
}
// Detached: quarry is the source of truth again.
d.Posture.Mirror.Tracking, d.Posture.DetachedFrom = false, "https://x/y"
html = renderBase(t, d)
for _, tab := range []string{"/r/alpha/landing", "/r/alpha/ci", "/r/alpha/chat"} {
if !strings.Contains(html, `href="`+tab+`"`) {
t.Errorf("a detached store lacks %s", tab)
}
}
}
// The chat rail lists only rooms the viewer can enter: a repository
// with chat off is not a channel, and neither is one where the viewer
// holds no chat capability.
func TestChatRailListsOnlyUsableChannels(t *testing.T) {
ix := landingIndex(t, false)
r := ops.NewRegistry()
deps := api.Deps{Version: "v", Chat: &webChatStub{},
RepoMeta: func() map[string]api.RepoMeta {
return map[string]api.RepoMeta{"alpha": {Chat: true}, "beta": {Chat: false}, "gamma": {Chat: true}}
},
Repos: api.NewRepos(map[string]*api.Repo{"alpha": {Index: ix}, "beta": {Index: ix}, "gamma": {Index: ix}})}
if err := api.RegisterAll(r, deps); err != nil {
t.Fatal(err)
}
rail := func(caps Caps) string {
srv := httptest.NewServer(HandlerWith(r, nil, nil,
[]Option{WithSessions(fakeSessions{user: "dan"}), WithCaps(caps)},
Verify(func(string, string, string, ops.Auth) api.Decision { return api.Allowed })))
t.Cleanup(srv.Close)
page := asDan(t, srv, "/r/alpha/chat")
i := strings.Index(page, `id="rail-channels"`)
if i < 0 {
t.Fatalf("no rail on the chat page")
}
return page[i : i+strings.Index(page[i:], "</ul>")]
}
// Chat everywhere the viewer is allowed: alpha and gamma, not beta.
got := rail(stubCaps{letters: "C"})
for name, want := range map[string]bool{"alpha": true, "beta": false, "gamma": true} {
if strings.Contains(got, "/r/"+name+"/chat") != want {
t.Errorf("with C, rail lists %s = %v, want %v", name, !want, want)
}
}
// No chat capability anywhere: no channels, even where chat is on.
if got := rail(stubCaps{letters: "o"}); strings.Contains(got, "/r/alpha/chat") || strings.Contains(got, "/r/gamma/chat") {
t.Errorf("without C the rail still lists channels: %s", got)
}
}
// Accepting a landing merges on the store and can take a while: with
// script the decision form goes busy when a control is pressed, so
// nothing takes a second press and the pressed control says what it is
// doing. The hook must be on the click, never on the form's submit:
// Datastar prevents the default of a submit it binds on a form, which
// cancelled the real accept. Read from the template source, which is
// what the page is made of.
func TestLandingDecisionFormGoesBusyOnSubmit(t *testing.T) {
src := landingSource
for _, want := range []string{
`data-on:click="$pressed = 'accept'; $busy = true"`,
`data-class:busy="$busy"`,
`data-class:is-busy="$busy && $pressed == 'accept'"`,
`<span class="b-busy">Accepting…</span>`,
`data-class:show="$busy"`,
} {
if !strings.Contains(src, want) {
t.Errorf("landing decision form lacks %s", want)
}
}
if strings.Contains(src, `data-on:submit`) {
t.Errorf("a data-on:submit on the decision form cancels the real submission")
}
}
// A signed-in person sees a report control that files on the forge's
// own tracker, prefilled with the build and the page; a forge with no
// tracker shows none, and so does an anonymous reader.
func TestHeaderReportControlFilesOnTheTracker(t *testing.T) {
var d pageData
d.SignedIn, d.TrackerRepo, d.Version, d.Origin, d.Path = "dan", "quarry", "0.1.0-beta+abc", "https://forge.example", "/r/alpha/timeline"
html := renderBase(t, d)
// html/template escapes the query its own way; assert the parts.
if !strings.Contains(html, `href="/r/quarry/ticket/new?foundin=0.1.0-beta%2babc&comment=`) ||
!strings.Contains(html, `forge.example%2Fr%2Falpha%2Ftimeline"`) {
t.Errorf("report control is not prefilled with build and page:\n%s", html[strings.Index(html, "bar-acts"):][:600])
}
d.SignedIn = ""
if strings.Contains(renderBase(t, d), "report a problem") {
t.Errorf("anonymous readers get a report control")
}
d.SignedIn, d.TrackerRepo = "dan", ""
if strings.Contains(renderBase(t, d), "report a problem") {
t.Errorf("a forge without a tracker shows a report control")
}
d.TrackerRepo, d.ZitVersion = "quarry", "0.3.4"
if !strings.Contains(renderBase(t, d), "zit 0.3.4") {
t.Errorf("the footer does not name the zit version")
}
}
// The changelog page renders the tracker's CHANGELOG.md for anyone and
// offers the announce control to admins only; announcing reaches every
// open account through the notify plane.
func TestChangelogPageAndAnnounce(t *testing.T) {
md := []byte("# Changelog\n\n## 0.2.0 — soon\n\n- more\n\n## 0.1.0-beta.1 — 2026-09-03\n\n- first\n")
var fired []api.NotifyEvent
var members []string
accts := newBetaAccts()
_ = accts.AddAccount("root", "pw", true)
_ = accts.AddAccount("eve", "pw", false)
r := ops.NewRegistry()
if err := api.RegisterAll(r, api.Deps{Version: "0.1.0-beta+abc", Accounts: accts,
Changelog: func(context.Context) ([]byte, error) { return md, nil },
Notify: notifyFunc(func(e api.NotifyEvent) error { fired = append(fired, e); return nil }),
Provision: provisionFunc(func(repo, account string) { members = append(members, repo+"/"+account) }),
Repos: api.NewRepos(map[string]*api.Repo{})}); err != nil {
t.Fatal(err)
}
sess := &betaSessions{accts: accts, admin: "root"}
srv := httptest.NewServer(HandlerWith(r, nil, nil,
[]Option{WithSessions(sess), WithCaps(betaCaps{admin: "root"})},
Verify(func(string, string, string, ops.Auth) api.Decision { return api.Denied })))
t.Cleanup(srv.Close)
page := body(as(t, srv, "", "GET", "/changelog", nil))
if !strings.Contains(page, "0.1.0-beta.1") || !strings.Contains(page, "<li>first</li>") || strings.Contains(page, "Announce") {
t.Errorf("anonymous changelog page wrong:\n%s", page[strings.Index(page, "<main"):][:800])
}
page = body(as(t, srv, "root", "GET", "/changelog", nil))
if !strings.Contains(page, "Announce “0.2.0 — soon” to everyone") {
t.Errorf("admin has no announce control")
}
res := as(t, srv, "root", "POST", "/changelog/announce", nil)
body(res)
if res.StatusCode != 303 {
t.Fatalf("announce = %d", res.StatusCode)
}
if len(fired) != 1 || fired[0].Workflow != "quarry-changelog" || fired[0].To != "repo:changelog" || fired[0].Payload["title"] != "0.2.0 — soon" {
t.Errorf("announce fired %+v", fired)
}
if len(members) != 2 {
t.Errorf("topic filled with %v, want both open accounts", members)
}
// A non-admin may not announce.
if res := as(t, srv, "eve", "POST", "/changelog/announce", nil); res.StatusCode == 303 {
t.Errorf("eve announced")
}
}
type notifyFunc func(api.NotifyEvent) error
func (f notifyFunc) Trigger(_ context.Context, e api.NotifyEvent) error { return f(e) }
type provisionFunc func(repo, account string)
func (f provisionFunc) EnsureSubscriber(context.Context, string) error { return nil }
func (f provisionFunc) SetTopicMember(_ context.Context, repo, account string, member bool) error {
if member {
f(repo, account)
}
return nil
}
// The listener binds before repositories register. Until a repository
// has registered, its pages say "still starting" with a refresh and a
// 503, never a 404 — and the front page lists it as starting.
func TestPendingRepositoryAnswersStarting(t *testing.T) {
ix := landingIndex(t, false)
r := ops.NewRegistry()
if err := api.RegisterAll(r, api.Deps{Version: "v",
Pending: func() []string { return []string{"warming"} },
Repos: api.NewRepos(map[string]*api.Repo{"alpha": {Index: ix}})}); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(HandlerWith(r, nil, nil, nil))
t.Cleanup(srv.Close)
res := as(t, srv, "", "GET", "/r/warming/timeline", nil)
page := body(res)
if res.StatusCode != http.StatusServiceUnavailable || res.Header.Get("Retry-After") == "" {
t.Errorf("pending repo = %d (Retry-After %q), want 503 with Retry-After", res.StatusCode, res.Header.Get("Retry-After"))
}
if !strings.Contains(page, "still starting") || !strings.Contains(page, `http-equiv="refresh"`) {
t.Errorf("pending repo page is not the starting page")
}
// An unknown name is still not found: pending is a promise, not a wildcard.
if res := as(t, srv, "", "GET", "/r/nothere/timeline", nil); res.StatusCode != 404 {
t.Errorf("unknown repo = %d, want 404", res.StatusCode)
}
// The front page lists it as starting.
out, err := invoke(context.Background(), r, "repo.list", nil)
if err != nil {
t.Fatal(err)
}
var rl struct {
Repos []struct {
Name string `json:"name"`
Starting bool `json:"starting"`
Indexing bool `json:"indexing"`
} `json:"repos"`
}
remarshal(out, &rl)
var found bool
for _, rp := range rl.Repos {
if rp.Name == "warming" && rp.Starting && rp.Indexing {
found = true
}
}
if !found {
t.Errorf("repo.list does not list the pending repository as starting: %+v", rl.Repos)
}
}
// With a configured origin, links the forge writes use it rather than
// the host the request arrived on: the report control here, invite and
// announcement links in the ops.
func TestConfiguredOriginWinsOverTheRequestHost(t *testing.T) {
ix := landingIndex(t, false)
r := ops.NewRegistry()
if err := api.RegisterAll(r, api.Deps{Version: "v", Origin: "https://forge.example",
Repos: api.NewRepos(map[string]*api.Repo{"alpha": {Index: ix}})}); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(HandlerWith(r, nil, nil,
[]Option{WithSessions(fakeSessions{user: "dan"}), WithTracker("alpha"), WithOrigin("https://forge.example")}))
t.Cleanup(srv.Close)
page := asDan(t, srv, "/r/alpha/timeline")
if !strings.Contains(page, "https%3A%2F%2Fforge.example%2Fr%2Falpha%2Ftimeline") {
t.Errorf("report control does not use the configured origin")
}
if strings.Contains(page, "127.0.0.1") {
t.Errorf("the request host leaked into the page's links")
}
}
// Only a repository admin may write a ticket straight into the fossil;
// everyone else files locally. The checkbox follows the capability.
func TestFossilNowIsAnAdminChoice(t *testing.T) {
ix := landingIndex(t, false)
r := ops.NewRegistry()
if err := api.RegisterAll(r, api.Deps{Version: "v",
Repos: api.NewRepos(map[string]*api.Repo{"alpha": {Index: ix}})}); err != nil {
t.Fatal(err)
}
serve := func(letters string) *httptest.Server {
srv := httptest.NewServer(HandlerWith(r, nil, nil,
[]Option{WithSessions(fakeSessions{user: "dan", roles: map[string]ops.Auth{"alpha": ops.Admin}}), WithCaps(stubCaps{letters: letters})},
Verify(func(string, string, string, ops.Auth) api.Decision { return api.Allowed })))
t.Cleanup(srv.Close)
return srv
}
if page := asDan(t, serve("in"), "/r/alpha/ticket/new"); strings.Contains(page, `name="fossil"`) || !strings.Contains(page, `name="website"`) {
t.Errorf("a developer is offered the fossil checkbox")
}
// An admin who may also file tickets (n): a alone opens no form.
if page := asDan(t, serve("an"), "/r/alpha/ticket/new"); !strings.Contains(page, `name="fossil"`) {
t.Errorf("an admin is not offered the fossil checkbox")
}
}