package web import ( "context" "crypto/rand" "crypto/sha1" "encoding/base64" "encoding/hex" "encoding/json" "fmt" "net/http" "net/http/httptest" "os" "sort" "strings" "testing" "time" "github.com/fernet/fernet-go" "github.com/vaughan0/go-ini" "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-spec/authn" "sourcecraft.dev/bigbes/sr-ht-spec/core" "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) // 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 \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 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 } // 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() 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"}, // /tokens redirects here, and the external origin is the one a browser // can reach. "tokens.sr.ht": ini.Section{"origin": "https://tokens.example"}, } 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: 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 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, "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) } } }