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")
}
// The review page is the one full-bleed page of this surface: two prose
// columns side by side do not fit the centred container.
if !strings.Contains(body, `class="container-fluid"`) {
t.Errorf("the review page is not full width; body:\n%s", body)
}
}
// 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)
}
}