package web import ( "net/http" "net/http/httptest" "strings" "testing" "sourcecraft.dev/bigbes/sr-ht-spec/core" "sourcecraft.dev/bigbes/sr-ht-spec/service" ) // seedProposal registers a proposal and its diff on the fake reader. func seedProposal(r *fakeReader, p service.Proposal, docs []service.ProposalDoc) { r.proposals[p.ID] = p r.diffs[p.ID] = docs } func openProposal() service.Proposal { return service.Proposal{ ID: 7, Space: demoSpace, Title: "Revise storage model", Rationale: "clearer wording", BaseRev: headRev, Branch: "proposals/7", State: core.StateOpen, Agent: "claude-code/spec-writer", AgentSession: "sess-1", } } // post issues a form POST as a user, with the Origin header set unless overridden. func post(t *testing.T, h http.Handler, target, user, origin string) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodPost, target, nil) if user != "" { login(req, user) } if origin != "" { req.Header.Set("Origin", origin) } rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } // TestProposalPageRendersDiffAndControls proves the owner sees the diff and the // approve/reject controls on an open proposal. func TestProposalPageRendersDiffAndControls(t *testing.T) { r := newFakeReader() seedProposal(r, openProposal(), []service.ProposalDoc{{ Path: "specs/0007-storage.md", Base: []byte("# Storage\n\nGit is authoritative here.\n"), Proposed: []byte("# Storage\n\nGit is the authoritative source here.\n"), }}) h, _, _ := testServerWith(t, r) rec := get(t, h, "/~bigbes/rfcs/p/7", "bigbes") if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body:\n%s", rec.Code, rec.Body) } body := rec.Body.String() if !strings.Contains(body, "prosediff") { t.Errorf("page has no rendered diff; body:\n%s", body) } if !strings.Contains(body, "/p/7/approve") || !strings.Contains(body, "/p/7/reject") { t.Errorf("owner viewing an open proposal has no approve/reject controls") } if !strings.Contains(body, "specs/0007-storage.md") { t.Errorf("page does not name the changed document") } } // TestProposalPageHidesControlsWhenMerged proves a terminal proposal shows no // controls and states its outcome. func TestProposalPageHidesControlsWhenMerged(t *testing.T) { r := newFakeReader() p := openProposal() p.State = core.StateMerged p.Approval = core.ApprovalHuman p.MergedRev = oldRev seedProposal(r, p, nil) h, _, _ := testServerWith(t, r) rec := get(t, h, "/~bigbes/rfcs/p/7", "bigbes") if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } if strings.Contains(rec.Body.String(), "/p/7/approve") { t.Errorf("a merged proposal still shows the approve control") } } // TestProposalPageWrongSpaceIs404 proves a proposal id addressed through the // wrong space's URL is not found. func TestProposalPageWrongSpaceIs404(t *testing.T) { r := newFakeReader() p := openProposal() p.Space = core.SpaceRef{Owner: "bigbes", Name: "other"} seedProposal(r, p, nil) h, _, _ := testServerWith(t, r) rec := get(t, h, "/~bigbes/rfcs/p/7", "bigbes") if rec.Code != http.StatusNotFound { t.Fatalf("status = %d, want 404 for a proposal in another space", rec.Code) } } // TestProposalPageNonNumericIs404 proves the "/p/" namespace refuses a // non-numeric id rather than treating it as a document. func TestProposalPageNonNumericIs404(t *testing.T) { h, _, _ := testServerWith(t, newFakeReader()) rec := get(t, h, "/~bigbes/rfcs/p/not-a-number", "bigbes") if rec.Code != http.StatusNotFound { t.Fatalf("status = %d, want 404", rec.Code) } } // TestProposalPageAnonymousRedirected proves a viewer with no read authority is // sent to login, not shown the proposal. func TestProposalPageAnonymousRedirected(t *testing.T) { r := newFakeReader() seedProposal(r, openProposal(), nil) h, _, _ := testServerWith(t, r) rec := get(t, h, "/~bigbes/rfcs/p/7", "") if rec.Code != http.StatusSeeOther && rec.Code != http.StatusFound { t.Fatalf("status = %d, want a login redirect", rec.Code) } } // TestApproveMergesAsOwner proves the owner's approve POST merges the proposal // and redirects back. func TestApproveMergesAsOwner(t *testing.T) { r := newFakeReader() seedProposal(r, openProposal(), nil) h, reader, _ := testServerWith(t, r) rec := post(t, h, "/~bigbes/rfcs/p/7/approve", "bigbes", "https://spec.example") if rec.Code != http.StatusSeeOther { t.Fatalf("status = %d, want 303; body:\n%s", rec.Code, rec.Body) } if got := reader.proposals[7].State; got != core.StateMerged { t.Errorf("proposal state = %s, want merged", got) } if loc := rec.Header().Get("Location"); loc != "/~bigbes/rfcs/p/7" { t.Errorf("redirect = %q, want the proposal page", loc) } } // TestRejectResolvesAsOwner proves the owner's reject POST rejects the proposal. func TestRejectResolvesAsOwner(t *testing.T) { r := newFakeReader() seedProposal(r, openProposal(), nil) h, reader, _ := testServerWith(t, r) rec := post(t, h, "/~bigbes/rfcs/p/7/reject", "bigbes", "https://spec.example") if rec.Code != http.StatusSeeOther { t.Fatalf("status = %d, want 303", rec.Code) } if got := reader.proposals[7].State; got != core.StateRejected { t.Errorf("proposal state = %s, want rejected", got) } } // TestApproveForbiddenForAgent proves an agent — authenticated but not the owner // — may not approve. func TestApproveForbiddenForAgent(t *testing.T) { r := newFakeReader() seedProposal(r, openProposal(), nil) h, reader, _ := testServerWith(t, r) req := httptest.NewRequest(http.MethodPost, "/~bigbes/rfcs/p/7/approve", nil) req.Header.Set("Authorization", "Bearer "+agentTk) req.Header.Set("Origin", "https://spec.example") rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("status = %d, want 403 for an agent approving", rec.Code) } if reader.proposals[7].State != core.StateOpen { t.Errorf("the proposal was resolved despite the agent being refused") } } // TestApproveRefusedCrossOrigin proves a POST whose Origin is not this site is // refused — the CSRF defense. func TestApproveRefusedCrossOrigin(t *testing.T) { r := newFakeReader() seedProposal(r, openProposal(), nil) h, reader, _ := testServerWith(t, r) rec := post(t, h, "/~bigbes/rfcs/p/7/approve", "bigbes", "https://evil.example") if rec.Code != http.StatusForbidden { t.Fatalf("status = %d, want 403 for a cross-origin POST", rec.Code) } if reader.proposals[7].State != core.StateOpen { t.Errorf("the proposal was resolved despite the cross-origin refusal") } } // TestApproveMissingOriginRefused proves a POST with no Origin or Referer is // refused rather than trusted. func TestApproveMissingOriginRefused(t *testing.T) { r := newFakeReader() seedProposal(r, openProposal(), nil) h, _, _ := testServerWith(t, r) rec := post(t, h, "/~bigbes/rfcs/p/7/approve", "bigbes", "") if rec.Code != http.StatusForbidden { t.Fatalf("status = %d, want 403 when no Origin is presented", rec.Code) } } // TestApproveStaleIs409 proves a merge that the service reports stale surfaces as // a 409, not a 500. func TestApproveStaleIs409(t *testing.T) { r := newFakeReader() seedProposal(r, openProposal(), nil) r.actErr = service.ErrStale h, _, _ := testServerWith(t, r) rec := post(t, h, "/~bigbes/rfcs/p/7/approve", "bigbes", "https://spec.example") if rec.Code != http.StatusConflict { t.Fatalf("status = %d, want 409 for a stale approve", rec.Code) } }