Skip to content
quarry Artifact [a92811daef]
Login

Artifact [a92811daef]

Artifact a92811daefff762aa18459687708f30a53fd84ec0bff4b2f698a189dd8c159b0:


package web

import (
	"bytes"
	"context"
	"craftdesign.group/quarry/internal/artifact"
	"craftdesign.group/quarry/internal/index"
	"html/template"
	"net/http"
	"net/http/httptest"
	"net/url"
	"os"
	"path/filepath"
	"regexp"
	"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. renderBase skips
	// repoChrome, so say what repoChrome says on a forge with no
	// capability plane: anyone may set CI up. This test is about the
	// mirror posture, not about whether CI exists.
	d.Posture.Mirror.Tracking, d.Posture.DetachedFrom = false, "https://x/y"
	d.MaySetUpCI = 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 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.
	// Without the letter there is no rail to list anything in: the
	// page itself is refused (see TestChatNeedsTheChatLetter).
	srv := httptest.NewServer(HandlerWith(r, nil, nil,
		[]Option{WithSessions(fakeSessions{user: "dan"}), WithCaps(stubCaps{letters: "o"})},
		Verify(func(string, string, string, ops.Auth) api.Decision { return api.Allowed })))
	t.Cleanup(srv.Close)
	req, _ := http.NewRequest("GET", srv.URL+"/r/alpha/chat", nil)
	req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "good"})
	if res, err := http.DefaultClient.Do(req); err != nil || res.StatusCode != 403 {
		t.Errorf("without C the chat page still renders: %v %v", err, res.StatusCode)
	}
}

// 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&amp;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")
	}
}

// A reader who holds no repository gets orientation, not two empty
// columns: what the forge is, an empty state that offers three ways in,
// and the repositories people regard. A reader with a repository gets
// the ordinary dashboard.
func TestFirstVisitReplacesTheEmptyDashboard(t *testing.T) {
	mk := func(t *testing.T, title string) *index.Index {
		ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
		if err != nil {
			t.Fatal(err)
		}
		t.Cleanup(func() { ix.Close() })
		if err := ix.FoldNow([]index.Parsed{{Hash: title[:4], Art: &artifact.Artifact{
			Type: artifact.Ticket, TicketID: strings.Repeat(title[:1], 40),
			Date: "2026-08-19T09:00:00", User: "dan",
			Fields: []artifact.Field{{Name: "status", Value: "Closed"},
				{Name: "title", Value: title}}}}}); err != nil {
			t.Fatal(err)
		}
		return ix
	}
	r := ops.NewRegistry()
	if err := api.RegisterAll(r, api.Deps{Version: "v", Tracker: "alpha",
		Repos: api.NewRepos(map[string]*api.Repo{
			"alpha": {Index: mk(t, "alpha bug")},
			"beta":  {Index: mk(t, "beta bug")},
		}),
		Accounts: stubAccts{grants: map[string]map[string]string{"dan": {"alpha": "write"}, "eve": {}}},
	}); err != nil {
		t.Fatal(err)
	}
	for _, c := range []struct {
		user  string
		first bool
	}{{"eve", true}, {"dan", false}} {
		srv := httptest.NewServer(HandlerWith(r, nil, nil, []Option{WithSessions(fakeSessions{user: c.user}), WithTracker("alpha")}))
		req, _ := http.NewRequest("GET", srv.URL+"/", nil)
		req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "good"})
		res, err := http.DefaultClient.Do(req)
		if err != nil {
			t.Fatal(err)
		}
		page := body(res)
		if dump := os.Getenv("QUARRY_DUMP"); dump != "" && c.first {
			if m := regexp.MustCompile(`<link rel="stylesheet" href="([^"]+)">`).FindStringSubmatch(page); m != nil {
				sres, _ := http.Get(srv.URL + m[1])
				_ = os.WriteFile(dump, []byte(strings.Replace(page, m[0], "<style>"+body(sres)+"</style>", 1)), 0o644)
			}
		}
		srv.Close()
		if got := strings.Contains(page, `class="dash-band fv-hello"`); got != c.first {
			t.Errorf("%s: first-visit band = %v, want %v", c.user, got, c.first)
		}
		if !c.first {
			continue
		}
		for _, want := range []string{`class="fv-empty"`, `href="/r/alpha"`, `href="/r/beta"`, `href="/start"`, `#i-stamp`, `href="/r/alpha/ticket/`} {
			if !strings.Contains(page, want) {
				t.Errorf("first visit lacks %s", want)
			}
		}
		// Creation is open to any signed-in account (repo.add), not
		// just a site admin — so eve, who holds no role anywhere, is
		// still offered the door.
		if !strings.Contains(page, `href="/new"`) {
			t.Errorf("a signed-in account with no repository was not offered a new one")
		}
		if strings.Contains(page, "Nothing yet") || strings.Contains(page, `class="dash-chips`) {
			t.Errorf("the empty feed still renders on a first visit")
		}
	}
}

// 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")
	}
}

// Behind a TLS-terminating proxy the forge sees plain HTTP; the session
// cookie must still be Secure. The proxy's X-Forwarded-Proto or the
// configured https origin is the evidence.
func TestSessionCookieIsSecureBehindAProxy(t *testing.T) {
	srv, _, _ := betaForge(t)
	form := url.Values{"user": {"root"}, "password": {"root-pw"}}
	req, _ := http.NewRequest("POST", srv.URL+"/login", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	req.Header.Set("X-Forwarded-Proto", "https")
	client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
	res, err := client.Do(req)
	if err != nil {
		t.Fatal(err)
	}
	res.Body.Close()
	var secure bool
	for _, c := range res.Cookies() {
		if c.Name == sessionCookie {
			secure = c.Secure
		}
	}
	if !secure {
		t.Errorf("session cookie set over a proxied https request lacks Secure")
	}
	// Plain http with no proxy header: not Secure, so local development keeps working.
	req2, _ := http.NewRequest("POST", srv.URL+"/login", strings.NewReader(form.Encode()))
	req2.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	res2, err := client.Do(req2)
	if err != nil {
		t.Fatal(err)
	}
	res2.Body.Close()
	for _, c := range res2.Cookies() {
		if c.Name == sessionCookie && c.Secure {
			t.Errorf("session cookie over plain http claims Secure, which a browser would drop")
		}
	}
}

// The create menu is never an empty box. A signed-in reader with no
// repository open and no admin right still gets "start here"; a mirror
// points at its upstream instead of at forms the forge would refuse.
func TestCreateMenuAlwaysHasSomewhereToGo(t *testing.T) {
	var d pageData
	d.SignedIn = "eve"
	html := renderBase(t, d)
	if !strings.Contains(html, `href="/start"`) || !strings.Contains(html, `href="/start#agents"`) {
		t.Errorf("a signed-in reader's menu has no start link")
	}
	if strings.Contains(html, `href="/new"`) {
		t.Errorf("a non-admin is offered a new repository")
	}
	d.IsAdmin = true
	if html = renderBase(t, d); !strings.Contains(html, `href="/new"`) {
		t.Errorf("an admin is not offered a new repository")
	}
	d.IsAdmin = false
	d.Repo, d.Section = "alpha", "timeline"
	d.Posture.Mirror.Tracking, d.Posture.Mirror.URL = true, "https://x/y"
	html = renderBase(t, d)
	if strings.Contains(html, `href="/r/alpha/ticket/new"`) || strings.Contains(html, `href="/r/alpha/forum-new"`) {
		t.Errorf("a mirror's menu offers forms the forge refuses")
	}
	if !strings.Contains(html, `href="https://x/y/tktnew"`) {
		t.Errorf("a mirror's menu does not point at the upstream")
	}
	d.Posture.Mirror.Tracking = false
	if html = renderBase(t, d); !strings.Contains(html, `href="/r/alpha/ticket/new"`) {
		t.Errorf("a native repository lost its ticket entry")
	}
}

// /start is public and points at the agent guide and the API reference;
// /llms.txt is plain text with the forge's own origin filled in.
func TestStartPageAndAgentGuide(t *testing.T) {
	srv, _, _ := betaForge(t)
	res := as(t, srv, "", "GET", "/start", nil)
	page := body(res)
	if res.StatusCode != 200 || !strings.Contains(page, `id="agents"`) || !strings.Contains(page, `href="/llms.txt"`) || !strings.Contains(page, `href="/docs"`) {
		t.Errorf("start page = %d, missing hooks", res.StatusCode)
	}
	res = as(t, srv, "", "GET", "/llms.txt", nil)
	guide := body(res)
	if res.StatusCode != 200 || !strings.HasPrefix(res.Header.Get("Content-Type"), "text/plain") {
		t.Fatalf("llms.txt = %d %s", res.StatusCode, res.Header.Get("Content-Type"))
	}
	for _, want := range []string{srv.URL + "/docs", srv.URL + "/api/v1/", "fossil clone " + srv.URL + "/r/", "/landing"} {
		if !strings.Contains(guide, want) {
			t.Errorf("guide lacks %q", want)
		}
	}
	if strings.Contains(guide, "<html") || strings.Contains(guide, "&lt;") {
		t.Errorf("guide carries markup or escaped text")
	}
}

// Only a repository admin may write a ticket straight into the fossil;
// everyone else files locally. The checkbox follows the capability.
// Behind a TLS-terminating proxy the forge sees plain HTTP; the session
// cookie must still be Secure. The proxy's X-Forwarded-Proto or the
// configured https origin is the evidence.
// The create menu is never an empty box. A signed-in reader with no
// repository open and no admin right still gets "start here"; a mirror
// points at its upstream instead of at forms the forge would refuse.

// A feed row's title is one line, clipped on a word the way the
// timeline clips a landing's title: a landing message that is a whole
// paragraph must not arrive in the feed as a wall of bold.
func TestDashboardFeedClipsLongTitles(t *testing.T) {
	long := strings.Repeat("the account switcher keeps two sessions and swaps them ", 6)
	var buf strings.Builder
	if err := dashboardPage.ExecuteTemplate(&buf, "dash-event", dashEvent{Kind: "l", Repo: "alpha", Ref: strings.Repeat("ab", 20), Comment: long, User: "dan", Date: "2026-09-05T00:00:00"}); err != nil {
		t.Fatal(err)
	}
	html := buf.String()
	if strings.Contains(html, long) {
		t.Fatal("the whole paragraph was drawn as the title")
	}
	title := html[strings.Index(html, `class="t-title"`):strings.Index(html, "</a>")]
	if len(title) > 220 || !strings.Contains(title, "…") {
		t.Fatalf("title not clipped on a word with a mark: %q", title)
	}
}

// Chat is a capability: a reader without the chat letter gets no tab,
// no page and no stream, whatever the repository's chat setting says.
// The API op already refused them; the page and the stream did not.
func TestChatNeedsTheChatLetter(t *testing.T) {
	ix := landingIndex(t, false)
	serve := func(letters string) *httptest.Server {
		r := ops.NewRegistry()
		if err := api.RegisterAll(r, api.Deps{Version: "v", Chat: &webChatStub{},
			RepoMeta: func() map[string]api.RepoMeta { return map[string]api.RepoMeta{"alpha": {Chat: true}} },
			Repos:    api.NewRepos(map[string]*api.Repo{"alpha": {Index: ix}})}); err != nil {
			t.Fatal(err)
		}
		srv := httptest.NewServer(HandlerWith(r, nil, newFakeChatLive(),
			[]Option{WithSessions(fakeSessions{user: "dan"}), WithCaps(stubCaps{letters: letters})},
			Verify(func(string, string, string, ops.Auth) api.Decision { return api.Denied })))
		t.Cleanup(srv.Close)
		return srv
	}
	get := func(srv *httptest.Server, path string) (int, string) {
		req, _ := http.NewRequest("GET", srv.URL+path, nil)
		req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "good"})
		res, err := (&http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}).Do(req)
		if err != nil {
			t.Fatal(err)
		}
		return res.StatusCode, body(res)
	}
	without := serve("o")
	if code, page := get(without, "/r/alpha/timeline"); code != 200 || strings.Contains(page, `href="/r/alpha/chat"`) {
		t.Errorf("without C: timeline %d, chat tab shown", code)
	}
	if code, _ := get(without, "/r/alpha/chat"); code != 403 {
		t.Errorf("without C: chat page = %d, want 403", code)
	}
	if code, _ := get(without, "/r/alpha/chat.sse"); code != 403 {
		t.Errorf("without C: chat stream = %d, want 403", code)
	}
	with := serve("oC")
	if code, page := get(with, "/r/alpha/timeline"); code != 200 || !strings.Contains(page, `href="/r/alpha/chat"`) {
		t.Errorf("with C: timeline %d, chat tab missing", code)
	}
	if code, _ := get(with, "/r/alpha/chat"); code != 200 {
		t.Errorf("with C: chat page = %d, want 200", code)
	}
}

// CI is a capability: without the letter there is no CI tab, no CI
// page and no run stream; a visitor is sent to sign in, a signed-in
// reader is refused. The ops blank the facts; the pages close the doors.
func TestCINeedsTheLetter(t *testing.T) {
	ix := landingIndex(t, false)
	serve := func(letters string) *httptest.Server {
		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)
		}
		srv := httptest.NewServer(HandlerWith(r, nil, nil,
			[]Option{WithSessions(fakeSessions{user: "dan"}), WithCaps(stubCaps{letters: letters})},
			Verify(func(string, string, string, ops.Auth) api.Decision { return api.Denied })))
		t.Cleanup(srv.Close)
		return srv
	}
	get := func(srv *httptest.Server, path string, signedIn bool) (int, string) {
		req, _ := http.NewRequest("GET", srv.URL+path, nil)
		if signedIn {
			req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "good"})
		}
		res, err := (&http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}).Do(req)
		if err != nil {
			t.Fatal(err)
		}
		return res.StatusCode, body(res)
	}
	without := serve("o")
	if code, page := get(without, "/r/alpha/timeline", true); code != 200 || strings.Contains(page, `href="/r/alpha/ci"`) {
		t.Errorf("without 8: timeline %d, CI tab shown", code)
	}
	if code, _ := get(without, "/r/alpha/ci", true); code != 403 {
		t.Errorf("without 8, signed in: CI page = %d, want 403", code)
	}
	if code, _ := get(without, "/r/alpha/ci", false); code != 303 {
		t.Errorf("without 8, anonymous: CI page = %d, want a sign-in redirect", code)
	}
	if code, _ := get(without, "/r/alpha/ci/run-frag/abcdef123456", true); code != 403 {
		t.Errorf("without 8: run fragment = %d, want 403", code)
	}
	// With the letter the page opens. The TAB is a separate question:
	// this repository has no runs, so the tab is offered only to someone
	// who could set CI up (i or a). A reader holding just the CI letter
	// can still reach the page, and is not invited to build something
	// they have no part in.
	with := serve("o8")
	if code, page := get(with, "/r/alpha/timeline", true); code != 200 || strings.Contains(page, `href="/r/alpha/ci"`) {
		t.Errorf("with 8 but no i/a, no runs: timeline %d, CI tab shown", code)
	}
	if code, _ := get(with, "/r/alpha/ci", true); code != 200 {
		t.Errorf("with 8: CI page = %d, want 200", code)
	}
	dev := serve("i8")
	if code, page := get(dev, "/r/alpha/timeline", true); code != 200 || !strings.Contains(page, `href="/r/alpha/ci"`) {
		t.Errorf("with 8 and i, no runs: timeline %d, CI tab missing — the offer to set CI up is gone", code)
	}
}