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, so
// nothing lands here and Landings goes. CI and Chat stay for whoever
// may use them: CI runs on what a mirror pulls, and chat is the forge's
// own, syndicated upstream by a bridge.
func TestTrackingMirrorHidesLandingsButKeepsCIAndChat(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
d.MaySetUpCI = true
html := renderBase(t, d)
if strings.Contains(html, `href="/r/alpha/landing"`) {
t.Error("a tracking mirror shows Landings")
}
for _, tab := range []string{"/r/alpha/ci", "/r/alpha/chat", "/r/alpha/timeline", "/r/alpha/ticket", "/r/alpha/forum"} {
if !strings.Contains(html, `href="`+tab+`"`) {
t.Errorf("a tracking mirror lacks %s", tab)
}
}
// The same rules as anywhere else still decide: without the setup
// ability and without runs there is no CI tab, mirror or not.
d.MaySetUpCI = false
if strings.Contains(renderBase(t, d), `href="/r/alpha/ci"`) {
t.Error("a mirror offers CI to someone who could not set it up")
}
// Detached: quarry is the source of truth again, Landings included.
// renderBase skips repoChrome, so say what repoChrome says on a
// forge with no capability plane: anyone may set CI up.
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&seen=`) ||
!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"
// This reader may file, post and write notes. Said explicitly
// because the menu's entries follow those letters now: the test's
// subject here is MIRROR versus NATIVE, and leaving the letters at
// their zero value would silently make it about capability
// instead.
d.CanFile, d.CanPost, d.CanNote = true, true, true
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")
}
// And an account that cannot file is not offered the form. A
// role-less account was shown every create entry and got a 403
// from each one (0457b6e6); the menu still has its help group, so
// it is never empty.
d.CanFile, d.CanPost, d.CanNote = false, false, false
html = renderBase(t, d)
for _, gone := range []string{"/r/alpha/ticket/new", "/r/alpha/forum-new", "/r/alpha/technote-new"} {
if strings.Contains(html, `href="`+gone+`"`) {
t.Errorf("a role-less account is offered %s, which its op refuses", gone)
}
}
if !strings.Contains(html, `href="/start"`) {
t.Error("the menu lost its help group and is now empty for a role-less account")
}
}
// /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, "<") {
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)
}
}