package web
import (
"context"
"crypto/sha1"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"sort"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sourcecraft.dev/bigbes/sr-ht-core/auth"
"sourcecraft.dev/bigbes/sr-ht-core/crypto"
"sourcecraft.dev/bigbes/sr-ht-ecore/bearer"
"sourcecraft.dev/bigbes/sr-ht-ecore/csrf"
"sourcecraft.dev/bigbes/sr-ht-ecore/ecoretest"
// Aliased: this file's own helper for signing a request in is called login.
ecorelogin "sourcecraft.dev/bigbes/sr-ht-ecore/login"
"sourcecraft.dev/bigbes/sr-ht-spec/authn"
"sourcecraft.dev/bigbes/sr-ht-spec/core"
"sourcecraft.dev/bigbes/sr-ht-spec/search"
"sourcecraft.dev/bigbes/sr-ht-spec/service"
)
// TestMain installs sr-ht-ecore's fixed test keyset, which is what lets these
// tests seal a unified-login cookie the way meta.sr.ht does with no meta and no
// network. The keys are constants there, so it is idempotent: two packages of
// this service can both call it without the second rotating what the first
// sealed with.
func TestMain(m *testing.M) {
ecoretest.InitCrypto()
// The agent credential is a signed tokens.sr.ht working token now, so it
// cannot be a constant: it is minted here, once the signing key exists.
agentTk = agentToken("spec:read")
os.Exit(m.Run())
}
// ---- fixtures -------------------------------------------------------------
const (
headRev = "1111111111111111111111111111111111111111"
oldRev = "2222222222222222222222222222222222222222"
)
// agentTk is a live working token for the instance owner, carrying spec:read —
// what an agent reading through the web UI presents. Set by TestMain.
var agentTk string
// agentToken mints a signed working token the way tokens.sr.ht does.
func agentToken(grantString string) string {
bt := &auth.BearerToken{
Version: auth.TokenVersion,
Expires: auth.ToTimestamp(time.Now().Add(time.Hour)),
Grants: grantString,
ClientID: bearer.TokensClientID,
Username: "bigbes",
}
return bt.Encode()
}
var demoSpace = core.SpaceRef{Owner: "bigbes", Name: "rfcs"}
// headDocs is the space at its approved head. SPEC-0007 links to SPEC-0003, so
// SPEC-0003 has a backlink; notes/plain.md has no frontmatter at all and is
// therefore addressed by its path, per the design's addressing rule.
var headDocs = map[string]string{
"specs/0007-storage.md": `---
id: SPEC-0007
title: Proposal storage model
status: draft
tags: [storage, review]
summary: How proposals are stored.
---
# Proposal storage model
Git is authoritative, and this supersedes [[SPEC-0003]].
`,
"specs/0003-old.md": `---
id: SPEC-0003
title: Older storage sketch
status: superseded
---
# Older storage sketch
Superseded by the storage model.
`,
"notes/plain.md": `# Just a note
No frontmatter here at all.
`,
}
// oldDocs is the same space at a pinned, older revision: the title differs, so
// a test can prove ?rev= actually reached a different tree.
var oldDocs = map[string]string{
"specs/0007-storage.md": `---
id: SPEC-0007
title: Storage, first draft
status: draft
---
# Storage, first draft
An earlier sketch.
`,
}
// fakeReader is an in-memory Reader: a space is a revision-keyed set of
// documents. It exists so the handlers can be tested against a document set
// rather than against Postgres plus a tree of bare repositories.
type fakeReader struct {
revs map[string]map[string]string // rev -> path -> content
head string
// proposals, diffs and actErr back the Phase 4 review-page tests. proposals
// is keyed by id; diffs by proposal id; actErr, when set, is what Approve
// and Reject return instead of acting.
proposals map[int]service.Proposal
diffs map[int][]service.ProposalDoc
actErr error
// mark/marked back the digest_mark tests: the owner's "last looked at"
// timestamp and whether one has been set. MarkDigestSeen moves them.
mark time.Time
marked bool
// threads back the Phase 5b comment tests: review threads by proposal id,
// with ids handed out by nextThread. The authority rules below are service/'s
// and are restated here only so the fake refuses what the real service
// refuses — a page that draws a control the service would reject would pass
// against a fake that accepts everything.
threads map[int][]*service.Thread
nextThread int
}
func newFakeReader() *fakeReader {
return &fakeReader{
revs: map[string]map[string]string{headRev: headDocs, oldRev: oldDocs},
head: headRev,
proposals: map[int]service.Proposal{},
diffs: map[int][]service.ProposalDoc{},
threads: map[int][]*service.Thread{},
}
}
func (f *fakeReader) ListSpaces(context.Context) ([]core.SpaceRef, error) {
return []core.SpaceRef{demoSpace}, nil
}
// at resolves a revision the way service.ResolveRev does: ApprovedRev means the
// approved head, anything else must name a revision that exists.
func (f *fakeReader) at(ref core.SpaceRef, rev string) (string, map[string]string, error) {
if ref != demoSpace {
return "", nil, fmt.Errorf("%w: space %s", service.ErrNotFound, ref)
}
if rev == service.ApprovedRev {
rev = f.head
}
docs, ok := f.revs[rev]
if !ok {
return "", nil, fmt.Errorf("%w: revision %q in %s", service.ErrNotFound, rev, ref)
}
return rev, docs, nil
}
func (f *fakeReader) Snapshot(_ context.Context, ref core.SpaceRef, rev string) (*Snapshot, error) {
resolved, docs, err := f.at(ref, rev)
if err != nil {
return nil, err
}
paths := make([]string, 0, len(docs))
for p := range docs {
paths = append(paths, p)
}
sort.Strings(paths)
// Built through service.ArchiveFrom, exactly as serviceReader gets it: the
// addressing rule and the link graph under test are the production ones,
// not a fixture's idea of them.
sd := make([]service.Document, 0, len(paths))
for _, p := range paths {
data := []byte(docs[p])
sd = append(sd, service.Document{
Path: p,
Blob: blobSha(data),
Rev: resolved,
Data: data,
})
}
arc, bodies, err := service.ArchiveFrom(ref, resolved, sd)
if err != nil {
return nil, err
}
return &Snapshot{Ref: ref, Rev: resolved, Archive: arc, Bodies: bodies}, nil
}
// blobSha is git's object name for a blob: sha1 over "blob <len>\0" and the
// content. Spelled out rather than taken from go-git so that this package —
// including its tests — never imports the git layer, which is the layering rule
// the Reader interface exists to keep.
func blobSha(data []byte) string {
h := sha1.New()
fmt.Fprintf(h, "blob %d", len(data))
h.Write([]byte{0})
h.Write(data)
return hex.EncodeToString(h.Sum(nil))
}
func (f *fakeReader) ReadDocument(_ context.Context, ref core.SpaceRef, rev, p string) (service.Document, error) {
resolved, docs, err := f.at(ref, rev)
if err != nil {
return service.Document{}, err
}
content, ok := docs[p]
if !ok {
return service.Document{}, fmt.Errorf("%w: %s in %s at %s", service.ErrNotFound, p, ref, resolved)
}
return service.Document{
Path: p,
Blob: blobSha([]byte(content)),
Rev: resolved,
Data: []byte(content),
}, nil
}
func (f *fakeReader) GetProposal(_ context.Context, id int) (service.Proposal, error) {
p, ok := f.proposals[id]
if !ok {
return service.Proposal{}, fmt.Errorf("%w: proposal %d", service.ErrNotFound, id)
}
return p, nil
}
func (f *fakeReader) ListProposals(_ context.Context, ref core.SpaceRef, state core.ProposalState) ([]service.Proposal, error) {
var out []service.Proposal
for _, p := range f.proposals {
if p.Space == ref && p.State == state {
out = append(out, p)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
return out, nil
}
func (f *fakeReader) ProposalDiff(_ context.Context, p service.Proposal) ([]service.ProposalDoc, error) {
return f.diffs[p.ID], nil
}
func (f *fakeReader) Approve(_ context.Context, _ core.SpaceRef, id int) (service.Proposal, error) {
if f.actErr != nil {
return service.Proposal{}, f.actErr
}
p := f.proposals[id]
p.State = core.StateMerged
p.Approval = core.ApprovalHuman
f.proposals[id] = p
return p, nil
}
func (f *fakeReader) Reject(_ context.Context, _ core.SpaceRef, id int) (service.Proposal, error) {
if f.actErr != nil {
return service.Proposal{}, f.actErr
}
p := f.proposals[id]
p.State = core.StateRejected
f.proposals[id] = p
return p, nil
}
func (f *fakeReader) Threads(_ context.Context, p authn.Principal, proposalID int) ([]service.Thread, error) {
if !p.CanRead() {
return nil, fmt.Errorf("%w: %s may not read review threads", service.ErrForbidden, p)
}
out := make([]service.Thread, 0, len(f.threads[proposalID]))
for _, t := range f.threads[proposalID] {
out = append(out, *t)
}
return out, nil
}
func (f *fakeReader) CommentOn(_ context.Context, req service.CommentRequest) (service.Thread, error) {
if !req.Principal.IsOwner() {
return service.Thread{}, fmt.Errorf("%w: %s may not open a review thread", service.ErrForbidden, req.Principal)
}
f.nextThread++
t := &service.Thread{
Root: service.Comment{
ID: f.nextThread,
Body: req.Body,
Author: req.Principal.Owner,
Created: time.Now(),
},
DocPath: req.DocPath,
Anchor: req.Anchor,
Block: -1,
}
f.threads[req.ProposalID] = append(f.threads[req.ProposalID], t)
return *t, nil
}
func (f *fakeReader) ReplyTo(_ context.Context, p authn.Principal, threadID int, body string) (service.Comment, error) {
if !p.CanRead() {
return service.Comment{}, fmt.Errorf("%w: %s may not comment", service.ErrForbidden, p)
}
t := f.thread(threadID)
if t == nil {
return service.Comment{}, fmt.Errorf("%w: no review thread %d", service.ErrNotFound, threadID)
}
f.nextThread++
author, agent := p.Owner, false
if p.IsAgent() {
author, agent = p.Agent, true
}
reply := service.Comment{
ID: f.nextThread, ParentID: threadID, Body: body,
Author: author, Agent: agent, Created: time.Now(),
}
t.Replies = append(t.Replies, reply)
return reply, nil
}
func (f *fakeReader) ResolveThread(_ context.Context, p authn.Principal, threadID int, resolved bool) error {
if !p.IsOwner() {
return fmt.Errorf("%w: %s may not resolve a review thread", service.ErrForbidden, p)
}
t := f.thread(threadID)
if t == nil {
return fmt.Errorf("%w: no review thread %d", service.ErrNotFound, threadID)
}
if !resolved {
t.Resolved = nil
return nil
}
now := time.Now()
t.Resolved = &now
return nil
}
// thread finds a root thread by id across every proposal, the way a global
// thread id resolves in the real store.
func (f *fakeReader) thread(id int) *service.Thread {
for _, ts := range f.threads {
for _, t := range ts {
if t.Root.ID == id {
return t
}
}
}
return nil
}
func (f *fakeReader) Inbox(_ context.Context) ([]service.Proposal, error) {
var out []service.Proposal
for _, p := range f.proposals {
if p.State == core.StateOpen {
out = append(out, p)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
return out, nil
}
func (f *fakeReader) Digest(_ context.Context) ([]service.Proposal, error) {
var out []service.Proposal
for _, p := range f.proposals {
if p.State == core.StateMerged && p.Approval == core.ApprovalPolicy {
out = append(out, p)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
return out, nil
}
func (f *fakeReader) DigestMark(context.Context) (time.Time, bool, error) {
return f.mark, f.marked, nil
}
func (f *fakeReader) MarkDigestSeen(_ context.Context, seenAt time.Time) error {
f.mark, f.marked = seenAt, true
return nil
}
// fakeSearcher returns one fixed hit whose snippet carries the <mark> tags
// bleve's highlighter emits.
type fakeSearcher struct {
last search.Query
err error
}
func (s *fakeSearcher) Search(_ context.Context, q search.Query) (search.Results, error) {
s.last = q
if s.err != nil {
return search.Results{}, s.err
}
return search.Results{
Total: 1,
Hits: []search.Hit{{
Space: demoSpace,
ID: "SPEC-0007",
Rev: headRev,
Path: "specs/0007-storage.md",
Title: "Proposal storage model",
Section: "specs",
Score: 1.5,
Snippet: `Git is <mark>authoritative</mark> & boring`,
}},
}, nil
}
// stubUsers resolves the owner an instance token names to a local row.
type stubUsers struct{}
func (stubUsers) LookupUser(_ context.Context, username string) (authn.InstanceUser, error) {
return authn.InstanceUser{ID: 1, Username: username}, nil
}
// testResolver is the resolver the daemon builds, with the one agent credential
// plane wired. Its origin is never reached: these tests present stateless
// tokens, which carry no row id and so skip the revocation round trip.
func testResolver(t *testing.T) *authn.Resolver {
t.Helper()
v, err := bearer.New(bearer.Options{
Origin: "https://tokens.srht.invalid",
ClientID: "spec.sr.ht",
NodeID: "web-test",
})
if err != nil {
t.Fatalf("bearer.New: %v", err)
}
resolver, err := authn.NewResolver("bigbes", authn.WithInstancePlane(v, stubUsers{}))
if err != nil {
t.Fatalf("NewResolver: %v", err)
}
return resolver
}
// testServer wires a Server with a fresh fake reader/searcher behind the same
// middleware the daemon installs, and returns the handler.
func testServer(t *testing.T) (http.Handler, *fakeSearcher) {
t.Helper()
h, _, sr := testServerWith(t, newFakeReader())
return h, sr
}
// testServerWith is testServer with a caller-supplied reader, so the review-page
// tests can seed proposals into it and still get the same middleware stack.
func testServerWith(t *testing.T, reader *fakeReader) (http.Handler, *fakeReader, *fakeSearcher) {
t.Helper()
// The synthetic instance config of sr-ht-ecore: this service's own section
// with an origin, meta's, tokens' (which /tokens redirects to), and the rest
// of the instance so that these pages render against a realistic switcher.
// Which entries the switcher shows and in what order is ecore's decision and
// is tested there.
conf := ecoretest.Config(authn.ConfigSection)
resolver := testResolver(t)
searcher := &fakeSearcher{}
srv, err := New(Options{
Conf: conf,
Reader: reader,
Searcher: searcher,
Resolver: resolver,
})
if err != nil {
t.Fatalf("New: %v", err)
}
return srv.Handler(), reader, searcher
}
// login seals a unified-login cookie for the given user onto a request — the
// same shape meta.sr.ht writes, sealed with the shared network key.
func login(req *http.Request, user string) {
payload, _ := json.Marshal(map[string]string{"name": user})
req.AddCookie(&http.Cookie{Name: ecorelogin.CookieName, Value: string(crypto.Encrypt(payload))})
}
func get(t *testing.T, h http.Handler, target, user string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, target, nil)
if user != "" {
login(req, user)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func getAgent(t *testing.T, h http.Handler, target, token string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, target, nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
// ---- URL grammar ----------------------------------------------------------
func TestDocumentAddressHasNoExtension(t *testing.T) {
h, _ := testServer(t)
rec := get(t, h, "/~bigbes/rfcs/specs/0007-storage", "bigbes")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200\n%s", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
t.Fatalf("content-type = %q, want text/html", ct)
}
body := rec.Body.String()
if !strings.Contains(body, "Proposal storage model") {
t.Fatal("rendered page missing the document title")
}
if !strings.Contains(body, "<h1") {
t.Fatalf("body was not rendered as markdown:\n%s", body)
}
// The rendered wikilink must point at the target document's own
// extensionless address.
if !strings.Contains(body, `href="/~bigbes/rfcs/specs/0003-old"`) {
t.Fatalf("wikilink not resolved to an extensionless address:\n%s", body)
}
}
func TestDocumentRawFormat(t *testing.T) {
h, _ := testServer(t)
rec := get(t, h, "/~bigbes/rfcs/specs/0007-storage.md", "bigbes")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/markdown") {
t.Fatalf("content-type = %q, want text/markdown", ct)
}
body := rec.Body.String()
if body != headDocs["specs/0007-storage.md"] {
t.Fatalf(".md is not the verbatim source:\n%s", body)
}
if !strings.HasPrefix(body, "---\n") {
t.Fatal(".md must include the frontmatter")
}
if rec.Header().Get("X-Spec-Rev") != headRev {
t.Fatalf("X-Spec-Rev = %q, want %q", rec.Header().Get("X-Spec-Rev"), headRev)
}
}
func TestDocumentJSONFormat(t *testing.T) {
h, _ := testServer(t)
rec := get(t, h, "/~bigbes/rfcs/specs/0007-storage.json", "bigbes")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200\n%s", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
t.Fatalf("content-type = %q, want application/json", ct)
}
var payload docJSON
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode: %v\n%s", err, rec.Body.String())
}
if payload.ID != "SPEC-0007" || payload.DocID != "SPEC-0007" {
t.Fatalf("id = %q / doc_id = %q, want SPEC-0007", payload.ID, payload.DocID)
}
if payload.Address != "specs/0007-storage" {
t.Fatalf("address = %q, want the extensionless address", payload.Address)
}
if payload.Path != "specs/0007-storage.md" {
t.Fatalf("path = %q, want the tree path", payload.Path)
}
if payload.Rev != headRev {
t.Fatalf("rev = %q, want %q", payload.Rev, headRev)
}
if !strings.Contains(payload.Body, "# Proposal storage model") {
t.Fatalf("body missing:\n%s", payload.Body)
}
if strings.Contains(payload.Body, "id: SPEC-0007") {
t.Fatal("body must be the body, with the frontmatter lifted into metadata")
}
if payload.Status != "draft" || len(payload.Tags) != 2 {
t.Fatalf("metadata not carried: %+v", payload)
}
}
func TestPinnedRevReachesAnotherTree(t *testing.T) {
h, _ := testServer(t)
head := get(t, h, "/~bigbes/rfcs/specs/0007-storage", "bigbes")
if !strings.Contains(head.Body.String(), "Proposal storage model") {
t.Fatal("approved head did not render the current title")
}
for _, target := range []string{
"/~bigbes/rfcs/specs/0007-storage?rev=" + oldRev,
"/~bigbes/rfcs/specs/0007-storage.md?rev=" + oldRev,
"/~bigbes/rfcs/specs/0007-storage.json?rev=" + oldRev,
"/~bigbes/rfcs?rev=" + oldRev,
} {
rec := get(t, h, target, "bigbes")
if rec.Code != http.StatusOK {
t.Fatalf("%s: status = %d\n%s", target, rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "Storage, first draft") {
t.Fatalf("%s: pinned read did not reach the older revision:\n%s", target, rec.Body.String())
}
}
}
func TestPinnedPageKeepsThePinOnItsLinks(t *testing.T) {
h, _ := testServer(t)
rec := get(t, h, "/~bigbes/rfcs?rev="+headRev, "bigbes")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "/~bigbes/rfcs/specs/0007-storage?rev="+headRev) {
t.Fatalf("space listing dropped the pin:\n%s", rec.Body.String())
}
rec = get(t, h, "/~bigbes/rfcs/specs/0007-storage?rev="+headRev, "bigbes")
if !strings.Contains(rec.Body.String(), `href="/~bigbes/rfcs/specs/0003-old?rev=`+headRev+`"`) {
t.Fatalf("a wikilink out of a pinned page dropped the pin:\n%s", rec.Body.String())
}
}
func TestUnknownRevIs404(t *testing.T) {
h, _ := testServer(t)
rec := get(t, h, "/~bigbes/rfcs/specs/0007-storage?rev=deadbeef", "bigbes")
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rec.Code)
}
}
func TestDocumentIDRedirectsToItsPath(t *testing.T) {
h, _ := testServer(t)
rec := get(t, h, "/~bigbes/rfcs/SPEC-0007", "bigbes")
if rec.Code != http.StatusFound {
t.Fatalf("status = %d, want 302\n%s", rec.Code, rec.Body.String())
}
if loc := rec.Header().Get("Location"); loc != "/~bigbes/rfcs/specs/0007-storage" {
t.Fatalf("location = %q", loc)
}
}
// ---- addressing rule ------------------------------------------------------
func TestDocumentWithoutFrontmatterIsAddressedByPath(t *testing.T) {
h, _ := testServer(t)
rec := get(t, h, "/~bigbes/rfcs/notes/plain.json", "bigbes")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d\n%s", rec.Code, rec.Body.String())
}
var payload docJSON
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload.DocID != "" {
t.Fatalf("doc_id = %q, want empty for a document with no id:", payload.DocID)
}
if payload.ID != "notes/plain" {
t.Fatalf("id = %q, want the path minus its extension", payload.ID)
}
if payload.Title != "Just a note" {
t.Fatalf("title = %q, want the first H1", payload.Title)
}
}
func TestBacklinksAreRendered(t *testing.T) {
h, _ := testServer(t)
rec := get(t, h, "/~bigbes/rfcs/specs/0003-old", "bigbes")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d\n%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
i := strings.Index(body, "<h4>Backlinks</h4>")
if i < 0 {
t.Fatal("no backlinks section")
}
if !strings.Contains(body[i:], "/~bigbes/rfcs/specs/0007-storage") {
t.Fatalf("SPEC-0007 links to SPEC-0003 but is not listed as a backlink:\n%s", body[i:])
}
}
// ---- not found ------------------------------------------------------------
func TestMissingDocumentIs404(t *testing.T) {
h, _ := testServer(t)
for _, target := range []string{
"/~bigbes/rfcs/specs/nope",
"/~bigbes/rfcs/specs/nope.md",
"/~bigbes/rfcs/specs/nope.json",
} {
rec := get(t, h, target, "bigbes")
if rec.Code != http.StatusNotFound {
t.Fatalf("%s: status = %d, want 404\n%s", target, rec.Code, rec.Body.String())
}
}
}
func TestMissingSpaceIs404(t *testing.T) {
h, _ := testServer(t)
rec := get(t, h, "/~bigbes/nope", "bigbes")
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rec.Code)
}
}
// The router's own two refusals are this service's error page and not net/http's
// plain text. They are the ones a viewer is most likely to meet — a mistyped URL
// and a stale bookmark — and until chimw.RenderRefusals was installed they were
// the only refusals on this surface with no nav to get out of.
func TestRoutingFailuresRenderTheChromePage(t *testing.T) {
h, _ := testServer(t)
rec := get(t, h, "/no/such/route", "bigbes")
assert.Equal(t, http.StatusNotFound, rec.Code)
assert.Contains(t, rec.Body.String(), "navbar-brand", "the 404 carries the chrome")
// A method the router does not allow on a path it does serve: /inbox/seen is
// a POST, so a GET of it is chi's 405 rather than a miss.
rec = get(t, h, "/inbox/seen", "bigbes")
assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
assert.Contains(t, rec.Body.String(), "navbar-brand", "the 405 carries the chrome")
}
// Every read route answers HEAD, because RFC 9110 §9.3.2 makes it mandatory for
// anything serving GET and because a monitor and a revalidating cache both reach
// for it. The pair shares a handler, so the status is the GET's — including the
// 404 of a document that is not there, which must not become a 200 on a method
// that returns no body to contradict it.
func TestReadRoutesAnswerHead(t *testing.T) {
h, _ := testServer(t)
for target, want := range map[string]int{
"/": http.StatusOK,
"/healthz": http.StatusOK,
"/inbox": http.StatusOK,
"/~bigbes/rfcs": http.StatusOK,
"/~bigbes/rfcs/specs/0007-storage": http.StatusOK,
"/~bigbes/rfcs/specs/nope": http.StatusNotFound,
} {
t.Run(target, func(t *testing.T) {
req := httptest.NewRequest(http.MethodHead, target, nil)
login(req, "bigbes")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
assert.Equal(t, want, rec.Code, "HEAD %s", target)
})
}
}
func TestTrailingSlashRedirectsToTheSpace(t *testing.T) {
h, _ := testServer(t)
rec := get(t, h, "/~bigbes/rfcs/", "bigbes")
if rec.Code != http.StatusFound {
t.Fatalf("status = %d, want 302", rec.Code)
}
if loc := rec.Header().Get("Location"); loc != "/~bigbes/rfcs" {
t.Fatalf("location = %q", loc)
}
}
// ---- identity and chrome --------------------------------------------------
//
// What the shared chrome itself decides — the switcher's order, the services it
// excludes, the shape of a login URL, the environment banner — is sr-ht-ecore's
// and is tested there. What is left here is the seam: that this service hands
// ecore the identity its own authn resolved, and that the page around the
// chrome shows the right thing to that identity.
// The <head> links come from ecore's own partial, and the icon is this
// service's: chrome ships a built-in data: URI as its default and New overrides
// it with the resolved href of the embedded logo. What this pins is the seam —
// that the override reached the page — and not the partial, which is ecore's.
func TestPagesLinkThisServicesIcon(t *testing.T) {
h, _ := testServer(t)
body := get(t, h, "/", "bigbes").Body.String()
assert.Contains(t, body, `<link rel="icon" href="/static/logo.svg">`)
// This build has no stylesheet, and the partial guards the link rather than
// emitting an empty href, which would re-request the page it sits on.
assert.NotContains(t, body, `<link rel="stylesheet" href="">`)
}
// The owner's cookie must reach the chrome as an identity: ecore renders the
// login block from the username it is given, so a greeting by name is the proof
// that view() passed one.
func TestOwnerCookieReachesTheChromeAsAnIdentity(t *testing.T) {
h, _ := testServer(t)
rec := get(t, h, "/", "bigbes")
require.Equal(t, http.StatusOK, rec.Code)
body := rec.Body.String()
assert.Contains(t, body, "Logged in as")
assert.Contains(t, body, ">bigbes<")
assert.Contains(t, body, "~bigbes/rfcs", "the logged-in landing page lists the spaces")
}
// A cookie sealed for somebody who is not the instance owner carries no
// authority: authn resolves it to anonymous, and the page must agree.
func TestNonOwnerCookieIsAnonymous(t *testing.T) {
h, _ := testServer(t)
rec := get(t, h, "/", "someoneelse")
body := rec.Body.String()
assert.NotContains(t, body, "Logged in as", "a non-owner cookie produced a logged-in nav")
assert.NotContains(t, body, "~bigbes/rfcs", "a non-owner must not see the space list")
}
func TestAnonymousLandingRendersWithoutContent(t *testing.T) {
h, _ := testServer(t)
rec := get(t, h, "/", "")
require.Equal(t, http.StatusOK, rec.Code)
body := rec.Body.String()
assert.Contains(t, body, "https://meta.example/login", "the landing page offers a login")
assert.NotContains(t, body, "~bigbes/rfcs", "anonymous landing page leaked a space name")
}
// The read gate is this package's; the URL it redirects to is the chrome's. The
// assertions below are about the gate firing at all and about it sending a
// browser somewhere it can come back from — not about how ecore spells a login
// URL.
func TestAnonymousContentRedirectsToLogin(t *testing.T) {
h, _ := testServer(t)
for _, target := range []string{
"/~bigbes/rfcs",
"/~bigbes/rfcs/specs/0007-storage",
"/search?q=storage",
} {
rec := get(t, h, target, "")
require.Equal(t, http.StatusFound, rec.Code, "%s: body %s", target, rec.Body)
loc := rec.Header().Get("Location")
assert.True(t, strings.HasPrefix(loc, "https://meta.example/login?return_to="),
"%s: location = %q", target, loc)
assert.Contains(t, loc, "spec.example",
"%s: return_to does not point back at us: %q", target, loc)
}
}
func TestAnonymousMachineFormatsAre401(t *testing.T) {
h, _ := testServer(t)
for _, target := range []string{
"/~bigbes/rfcs/specs/0007-storage.md",
"/~bigbes/rfcs/specs/0007-storage.json",
} {
rec := get(t, h, target, "")
if rec.Code != http.StatusUnauthorized {
t.Fatalf("%s: status = %d, want 401", target, rec.Code)
}
if strings.Contains(rec.Body.String(), "Proposal storage model") {
t.Fatalf("%s: content leaked to an anonymous client", target)
}
}
}
func TestAgentTokenReads(t *testing.T) {
h, _ := testServer(t)
rec := getAgent(t, h, "/~bigbes/rfcs/specs/0007-storage.md", agentTk)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200\n%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "id: SPEC-0007") {
t.Fatal("agent read did not return the document")
}
}
func TestUnknownAgentTokenIs401(t *testing.T) {
h, _ := testServer(t)
rec := getAgent(t, h, "/~bigbes/rfcs/specs/0007-storage.md", "not-a-token")
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
}
// ---- search ---------------------------------------------------------------
func TestSearchRendersSnippetAsHTML(t *testing.T) {
h, searcher := testServer(t)
rec := get(t, h, "/search?q=authoritative&space=~bigbes/rfcs", "bigbes")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d\n%s", rec.Code, rec.Body.String())
}
if searcher.last.Text != "authoritative" {
t.Fatalf("query text = %q", searcher.last.Text)
}
if refs := searcher.last.Spaces.Refs(); len(refs) != 1 || refs[0] != demoSpace {
t.Fatalf("space filter = %s", searcher.last.Spaces)
}
if searcher.last.Spaces.Everything() {
t.Fatal("naming a space must restrict the search, not widen it")
}
body := rec.Body.String()
if !strings.Contains(body, "<mark>authoritative</mark>") {
t.Fatalf("snippet was escaped instead of rendered as HTML:\n%s", body)
}
// The hit's URL is the pinned, extensionless address.
if !strings.Contains(body, "/~bigbes/rfcs/specs/0007-storage?rev="+headRev) {
t.Fatalf("hit href is not a pinned extensionless address:\n%s", body)
}
}
func TestSearchWithoutQueryDoesNotSearch(t *testing.T) {
h, searcher := testServer(t)
rec := get(t, h, "/search", "bigbes")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
if searcher.last.Text != "" {
t.Fatal("an empty query must not reach the index")
}
}
// ---- static and health ----------------------------------------------------
func TestHealthz(t *testing.T) {
h, _ := testServer(t)
rec := get(t, h, "/healthz", "")
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "ok") {
t.Fatalf("healthz = %d %q", rec.Code, rec.Body.String())
}
}
// The same-origin guard is router-wide (Handler) rather than a line three
// handlers remember, and this is what that buys: it runs before routing, so a
// mutation aimed at an address this surface does not serve is refused too, and
// a form added tomorrow is protected by having been registered.
func TestTheGuardCoversRoutesThisSurfaceDoesNotServe(t *testing.T) {
h, _ := testServer(t)
rec := post(t, h, "/no/such/route", "bigbes", "")
assert.Equal(t, http.StatusForbidden, rec.Code)
assert.Contains(t, rec.Body.String(), csrf.Message)
// With this site's own Origin the guard passes and the router answers for
// itself — the refusal above is the guard's and not the router's.
rec = post(t, h, "/no/such/route", "bigbes", ecoretest.Origin(authn.ConfigSection))
assert.Equal(t, http.StatusNotFound, rec.Code)
}
// The asset tree is mounted and served publicly. Which lifetime a name earns is
// sr-ht-ecore/assets' rule and is tested there; what this asserts is that this
// service's static mount reaches the embedded tree at all, and that the page
// policy (private, no-store) does not follow an asset out.
func TestStaticLogoIsServed(t *testing.T) {
h, _ := testServer(t)
rec := get(t, h, "/static/logo.svg", "")
assert.Equal(t, http.StatusOK, rec.Code)
assert.Contains(t, rec.Header().Get("Cache-Control"), "max-age")
assert.Empty(t, rec.Header().Get("Vary"), "an asset is the same bytes for everybody")
}
// An asset name that is not in the tree gets this service's own 404 page rather
// than net/http's plaintext one — a URL typed by hand is a dead end without a
// nav to get out of.
func TestMissingAssetGetsTheChromePage(t *testing.T) {
h, _ := testServer(t)
rec := get(t, h, "/static/nope.css", "")
assert.Equal(t, http.StatusNotFound, rec.Code)
assert.Contains(t, rec.Body.String(), "navbar-brand", "the 404 carries the chrome")
}
// ---- unit-level grammar ---------------------------------------------------
func TestSplitFormat(t *testing.T) {
cases := []struct {
in string
addr string
f format
}{
{"specs/0007-storage", "specs/0007-storage", formatHTML},
{"specs/0007-storage.md", "specs/0007-storage", formatRaw},
{"specs/0007-storage.json", "specs/0007-storage", formatJSON},
// A document whose own name ends in ".json" is still addressable: the
// selector is peeled off once, from the tail.
{"notes/2026.json.md", "notes/2026.json", formatRaw},
{"notes/report", "notes/report", formatHTML},
}
for _, c := range cases {
addr, f := splitFormat(c.in)
if addr != c.addr || f != c.f {
t.Fatalf("splitFormat(%q) = %q/%v, want %q/%v", c.in, addr, f, c.addr, c.f)
}
}
}