package web import ( "context" "crypto/rand" "encoding/base64" "encoding/json" "fmt" "net/http" "net/http/httptest" "os" "sort" "strings" "testing" "github.com/fernet/fernet-go" "github.com/go-git/go-git/v5/plumbing" "github.com/vaughan0/go-ini" "sourcecraft.dev/bigbes/sr-ht-core/crypto" "sourcecraft.dev/bigbes/sr-ht-spec/authn" "sourcecraft.dev/bigbes/sr-ht-spec/core" "sourcecraft.dev/bigbes/sr-ht-spec/doc" "sourcecraft.dev/bigbes/sr-ht-spec/gitx" "sourcecraft.dev/bigbes/sr-ht-spec/search" "sourcecraft.dev/bigbes/sr-ht-spec/service" ) // testConf carries the crypto keys established in TestMain so tests can seal // unified-login cookies the way meta.sr.ht does. var testConf ini.File func TestMain(m *testing.M) { var fk fernet.Key if err := fk.Generate(); err != nil { panic("generate fernet key: " + err.Error()) } seed := make([]byte, 32) if _, err := rand.Read(seed); err != nil { panic("generate webhook seed: " + err.Error()) } testConf = ini.File{ "sr.ht": ini.Section{"network-key": fk.Encode()}, "webhooks": ini.Section{"private-key": base64.StdEncoding.EncodeToString(seed)}, } crypto.InitCrypto(testConf) os.Exit(m.Run()) } // ---- fixtures ------------------------------------------------------------- const ( headRev = "1111111111111111111111111111111111111111" oldRev = "2222222222222222222222222222222222222222" agentTk = "test-agent-token" ) 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 } func newFakeReader() *fakeReader { return &fakeReader{ revs: map[string]map[string]string{headRev: headDocs, oldRev: oldDocs}, head: headRev, } } 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) gd := make([]gitx.Document, 0, len(paths)) bodies := make(map[string][]byte, len(paths)) for _, p := range paths { data := []byte(docs[p]) gd = append(gd, gitx.Document{ Path: p, Blob: plumbing.ComputeHash(plumbing.BlobObject, data), Data: data, }) bodies[p] = data } return &Snapshot{ Ref: ref, Rev: resolved, Archive: doc.FromDocuments(ref, resolved, gd), Bodies: bodies, }, 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: plumbing.ComputeHash(plumbing.BlobObject, []byte(content)).String(), Rev: resolved, Data: []byte(content), }, nil } // fakeSearcher returns one fixed hit whose snippet carries the 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 authoritative & boring`, }}, }, nil } // stubTokenStore knows exactly one live agent token. type stubTokenStore struct{} func (stubTokenStore) LookupAgentToken(_ context.Context, hash []byte) (authn.AgentToken, error) { want := authn.HashToken(agentTk) if string(hash) != string(want) { return authn.AgentToken{}, authn.ErrUnknownToken } return authn.AgentToken{ID: 1, Name: "test", Hash: want}, nil } // testServer wires a Server with the fake reader/searcher behind the same // middleware the daemon installs, and returns the handler. func testServer(t *testing.T) (http.Handler, *fakeSearcher) { t.Helper() conf := ini.File{ "sr.ht": ini.Section{ "network-key": testConf.Section("sr.ht")["network-key"], "site-name": "sourcehut", "environment": "development", "owner-name": "bigbes", }, "webhooks": ini.Section{"private-key": testConf.Section("webhooks")["private-key"]}, "spec.sr.ht": ini.Section{"origin": "https://spec.example"}, "meta.sr.ht": ini.Section{"origin": "https://meta.example"}, "git.sr.ht": ini.Section{"origin": "https://git.example"}, // Extra service sections to exercise nav ordering/exclusions. "todo.sr.ht": ini.Section{"origin": "https://todo.example"}, "paste.sr.ht": ini.Section{"origin": "https://paste.example"}, "pages.sr.ht": ini.Section{"origin": "https://pages.example"}, "hub.sr.ht": ini.Section{"origin": "https://hub.example"}, } resolver, err := authn.NewResolver("bigbes", stubTokenStore{}) if err != nil { t.Fatalf("NewResolver: %v", err) } searcher := &fakeSearcher{} srv, err := New(Options{ Conf: conf, Reader: newFakeReader(), Searcher: searcher, Resolver: resolver, }) if err != nil { t.Fatalf("New: %v", err) } return srv.Handler(), 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: authn.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, "Backlinks") 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) } } 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 -------------------------------------------------- func TestForgedCookieYieldsLoggedInNav(t *testing.T) { h, _ := testServer(t) rec := get(t, h, "/", "bigbes") if rec.Code != http.StatusOK { t.Fatalf("status = %d", rec.Code) } body := rec.Body.String() if !strings.Contains(body, "Logged in as") || !strings.Contains(body, ">bigbes<") { t.Fatalf("cookie did not produce a logged-in nav:\n%s", body) } if !strings.Contains(body, "~bigbes/rfcs") { t.Fatal("logged-in landing page missing the space list") } if !strings.Contains(body, "https://todo.example") { t.Fatal("nav missing an expected service") } if strings.Contains(body, "https://paste.example") || strings.Contains(body, "https://pages.example") { t.Fatal("nav must exclude paste/pages") } nav := body[strings.Index(body, `")] if strings.Contains(nav, "hub.example") { t.Fatal("hub is the brand, never a switcher item") } if !strings.Contains(nav, "nav-item active") { t.Fatal("spec should be the active nav item") } if !strings.Contains(body, "DEVELOPMENT ENVIRONMENT") { t.Fatal("non-production environment banner missing") } } // A cookie sealed for somebody who is not the instance owner carries no // authority: authn resolves it to anonymous, and the nav must agree. func TestNonOwnerCookieIsAnonymous(t *testing.T) { h, _ := testServer(t) rec := get(t, h, "/", "someoneelse") body := rec.Body.String() if strings.Contains(body, "Logged in as") { t.Fatalf("a non-owner cookie produced a logged-in nav:\n%s", body) } if strings.Contains(body, "~bigbes/rfcs") { t.Fatal("a non-owner must not see the space list") } } func TestAnonymousLandingRendersWithoutContent(t *testing.T) { h, _ := testServer(t) rec := get(t, h, "/", "") if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } body := rec.Body.String() if !strings.Contains(body, "return_to=") { t.Fatal("login URL missing return_to") } if strings.Contains(body, "~bigbes/rfcs") { t.Fatal("anonymous landing page leaked a space name") } } 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, "") if rec.Code != http.StatusFound { t.Fatalf("%s: status = %d, want 302\n%s", target, rec.Code, rec.Body.String()) } loc := rec.Header().Get("Location") if !strings.HasPrefix(loc, "https://meta.example/login?return_to=") { t.Fatalf("%s: location = %q", target, loc) } if !strings.Contains(loc, "spec.example") { t.Fatalf("%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 len(searcher.last.Spaces) != 1 || searcher.last.Spaces[0] != demoSpace { t.Fatalf("space filter = %+v", searcher.last.Spaces) } body := rec.Body.String() if !strings.Contains(body, "authoritative") { 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()) } } func TestStaticLogoIsServed(t *testing.T) { h, _ := testServer(t) rec := get(t, h, "/static/logo.svg", "") if rec.Code != http.StatusOK { t.Fatalf("status = %d", rec.Code) } if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "max-age") { t.Fatalf("cache-control = %q", cc) } } // TestHashedCSSIsImmutable checks the cache policy without depending on a built // stylesheet: `make css` needs sassc and the shared sourcehut partials, neither // of which a test may assume. func TestHashedCSSIsImmutable(t *testing.T) { for _, name := range []string{"main.min.79713f25.css", "main.min.abc123.css"} { if !hashedCSSRe.MatchString(name) { t.Fatalf("%s should be recognised as a hashed stylesheet", name) } } for _, name := range []string{"main.css", "main.min.css", "logo.svg"} { if hashedCSSRe.MatchString(name) { t.Fatalf("%s should not be recognised as a hashed stylesheet", name) } } } // ---- 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) } } }