package web
import (
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"sourcecraft.dev/bigbes/sr-ht-ecore/pages"
"sourcecraft.dev/bigbes/sr-ht-spec/core"
"sourcecraft.dev/bigbes/sr-ht-spec/service"
)
// The document under review: two paragraphs under one heading, one of which the
// proposal edits. Block 1 is "The first paragraph."; block 2 is the edited one.
const (
commentBase = "# Storage\n\nThe first paragraph.\n\nThe second paragraph.\n"
commentProposed = "# Storage\n\nThe first paragraph.\n\nThe second paragraph, revised.\n"
// It has no frontmatter, so its anchoring key is its path minus the
// extension — the archive's rule for a document with no well-formed id.
commentDocID = "specs/0007-storage"
)
func commentDocs() []service.ProposalDoc {
return []service.ProposalDoc{{
Path: "specs/0007-storage.md",
Base: []byte(commentBase),
Proposed: []byte(commentProposed),
}}
}
// seedThread hangs a thread off one block of the proposed document, anchored the
// way the service would anchor it — through service.AnchorOf, so the test cannot
// disagree with production about which block it named.
func seedThread(t *testing.T, r *fakeReader, proposalID, ordinal int, body string) *service.Thread {
t.Helper()
anchor, err := service.AnchorOf(commentDocID, []byte(commentProposed), ordinal, core.SideNew)
if err != nil {
t.Fatalf("AnchorOf: %v", err)
}
r.nextThread++
th := &service.Thread{
Root: service.Comment{ID: r.nextThread, Author: "bigbes", Body: body},
DocPath: "specs/0007-storage.md",
Anchor: anchor,
Block: -1,
}
r.threads[proposalID] = append(r.threads[proposalID], th)
return th
}
// postForm issues a form POST with a urlencoded body, as a browser would.
func postForm(t *testing.T, h http.Handler, target, user, origin string, form url.Values) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if user != "" {
login(req, user)
}
if origin != "" {
req.Header.Set("Origin", origin)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
// commentServer is a fake reader holding one open proposal with one changed
// document, plus the handler.
func commentServer(t *testing.T) (http.Handler, *fakeReader) {
t.Helper()
r := newFakeReader()
seedProposal(r, openProposal(), commentDocs())
h, reader, _ := testServerWith(t, r)
return h, reader
}
// TestDocIDForFollowsTheArchiveAddressingRule proves a comment anchors to the
// document's frontmatter id when it has a well-formed one — which is what lets
// the comment survive a rename — and to its path when it does not.
func TestDocIDForFollowsTheArchiveAddressingRule(t *testing.T) {
withID := []byte("---\nid: SPEC-0007\ntitle: Storage\n---\n\n# Storage\n")
if got := docIDFor("specs/0007-storage.md", withID); got != "SPEC-0007" {
t.Errorf("docIDFor = %q, want the frontmatter id", got)
}
if got := docIDFor("notes/plain.md", []byte("# Just a note\n")); got != "notes/plain" {
t.Errorf("docIDFor = %q, want the path without its extension", got)
}
}
// TestProposalPageDrawsComposeFormsForTheOwner proves the owner can start a
// thread on a block — including on one the proposal did not change, which is
// the whole point of rendering unchanged blocks.
func TestProposalPageDrawsComposeFormsForTheOwner(t *testing.T) {
h, _ := commentServer(t)
body := get(t, h, "/~bigbes/rfcs/p/7", "bigbes").Body.String()
if !strings.Contains(body, "/p/7/comment") {
t.Fatalf("the owner has no compose form; body:\n%s", body)
}
// One form per rendered block: heading, unchanged paragraph, changed one.
if n := strings.Count(body, `action="/~bigbes/rfcs/p/7/comment"`); n != 3 {
t.Errorf("%d compose forms, want one per rendered block (3); body:\n%s", n, body)
}
if !strings.Contains(body, `name="side" value="new"`) {
t.Errorf("the compose form does not name the side it anchors to; body:\n%s", body)
}
}
// TestProposalPageHidesOwnerControlsFromAnAgent proves a principal who may read
// and reply is not offered the two controls that are the owner's: opening a
// thread and resolving one.
func TestProposalPageHidesOwnerControlsFromAnAgent(t *testing.T) {
h, r := commentServer(t)
seedThread(t, r, 7, 1, "please reword this")
body := getAgent(t, h, "/~bigbes/rfcs/p/7", agentTk).Body.String()
if !strings.Contains(body, "please reword this") {
t.Fatalf("an agent cannot see the comments on its own proposal; body:\n%s", body)
}
if strings.Contains(body, "/p/7/comment") {
t.Errorf("an agent was offered the compose form; body:\n%s", body)
}
if strings.Contains(body, "/p/7/resolve") {
t.Errorf("an agent was offered the resolve control; body:\n%s", body)
}
if !strings.Contains(body, "/p/7/reply") {
t.Errorf("an agent was not offered the reply form, which is its half of the loop; body:\n%s", body)
}
}
// TestProposalPageMarksAnEditedAnchor proves a comment whose block has changed
// since it was written is shown — the reviewer still needs it — and marked, so
// nobody reads a stale critique as a current one.
func TestProposalPageMarksAnEditedAnchor(t *testing.T) {
h, r := commentServer(t)
// Anchored to the second paragraph as it read before the revision, so the
// hash misses and heading-path + index carries it: edited, not lost.
anchor, err := service.AnchorOf(commentDocID, []byte(commentBase), 2, core.SideNew)
if err != nil {
t.Fatalf("AnchorOf: %v", err)
}
r.nextThread++
r.threads[7] = append(r.threads[7], &service.Thread{
Root: service.Comment{ID: r.nextThread, Author: "bigbes", Body: "the tense is wrong"},
DocPath: "specs/0007-storage.md",
Anchor: anchor,
Block: -1,
})
body := get(t, h, "/~bigbes/rfcs/p/7", "bigbes").Body.String()
if !strings.Contains(body, "the tense is wrong") {
t.Fatalf("an edited-anchor comment is not on the page; body:\n%s", body)
}
if !strings.Contains(body, "block edited since") {
t.Errorf("an edited anchor is shown as if it still fitted; body:\n%s", body)
}
if strings.Contains(body, "lost their anchor") {
t.Errorf("an edited anchor was demoted to lost; body:\n%s", body)
}
}
// TestProposalPageKeepsAnOutdatedComment proves a comment whose block is gone is
// still on the page, in the area that says so, and is not attached to whatever
// block happens to be nearby.
func TestProposalPageKeepsAnOutdatedComment(t *testing.T) {
h, r := commentServer(t)
// Anchored to a paragraph that exists in neither revision on the page.
const gone = commentBase + "\nA paragraph the revision dropped.\n"
anchor, err := service.AnchorOf(commentDocID, []byte(gone), 3, core.SideNew)
if err != nil {
t.Fatalf("AnchorOf: %v", err)
}
r.nextThread++
r.threads[7] = append(r.threads[7], &service.Thread{
Root: service.Comment{ID: r.nextThread, Author: "bigbes", Body: "this claim is unsupported"},
DocPath: "specs/0007-storage.md",
Anchor: anchor,
Block: -1,
})
body := get(t, h, "/~bigbes/rfcs/p/7", "bigbes").Body.String()
if !strings.Contains(body, "this claim is unsupported") {
t.Fatalf("an outdated comment was dropped from the page; body:\n%s", body)
}
lost := strings.Index(body, "Comments that lost their anchor")
if lost < 0 {
t.Fatalf("no area for comments whose anchor is lost; body:\n%s", body)
}
// It belongs to that area and to no block: the diff ends before it.
if strings.Index(body, "this claim is unsupported") < lost {
t.Errorf("an outdated comment was rendered against a block; body:\n%s", body)
}
}
// TestProposalPageKeepsCommentsOnARevertedDocument proves a thread whose
// document the proposal no longer changes — so no diff renders it at all — is
// still shown rather than silently disappearing.
func TestProposalPageKeepsCommentsOnARevertedDocument(t *testing.T) {
h, r := commentServer(t)
r.nextThread++
r.threads[7] = append(r.threads[7], &service.Thread{
Root: service.Comment{ID: r.nextThread, Author: "bigbes", Body: "comment on a reverted file"},
DocPath: "specs/reverted.md",
Block: -1,
})
body := get(t, h, "/~bigbes/rfcs/p/7", "bigbes").Body.String()
if !strings.Contains(body, "comment on a reverted file") {
t.Fatalf("a comment on a document no longer changed was dropped; body:\n%s", body)
}
if !strings.Contains(body, "specs/reverted.md") {
t.Errorf("the lost comment does not say which document it came from; body:\n%s", body)
}
}
// TestCommentBodyIsEscaped proves a comment is text: markup in a body cannot
// become markup on the page.
func TestCommentBodyIsEscaped(t *testing.T) {
h, r := commentServer(t)
seedThread(t, r, 7, 1, `<img src=x onerror="alert(1)">`)
body := get(t, h, "/~bigbes/rfcs/p/7", "bigbes").Body.String()
if strings.Contains(body, "<img src=x") {
t.Fatalf("an unescaped comment body reached the page; body:\n%s", body)
}
if !strings.Contains(body, "<img src=x") {
t.Errorf("the escaped comment body is missing; body:\n%s", body)
}
}
// TestOwnerOpensAThreadOnABlock proves the compose POST anchors through
// service.AnchorOf and redirects to what was just written.
func TestOwnerOpensAThreadOnABlock(t *testing.T) {
h, r := commentServer(t)
anchor, err := service.AnchorOf(commentDocID, []byte(commentProposed), 1, core.SideNew)
if err != nil {
t.Fatalf("AnchorOf: %v", err)
}
rec := postForm(t, h, "/~bigbes/rfcs/p/7/comment", "bigbes", "https://spec.example", url.Values{
"doc": {"specs/0007-storage.md"},
"block": {"1"},
"side": {"new"},
"hash": {anchor.BlockHash},
"body": {"say why"},
})
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303; body:\n%s", rec.Code, rec.Body)
}
if len(r.threads[7]) != 1 {
t.Fatalf("%d threads stored, want 1", len(r.threads[7]))
}
got := r.threads[7][0]
if got.Anchor.BlockHash != anchor.BlockHash {
t.Errorf("stored anchor hash = %q, want the block's %q", got.Anchor.BlockHash, anchor.BlockHash)
}
if got.Anchor.Index != anchor.Index || got.Anchor.DocID != anchor.DocID {
t.Errorf("stored anchor = %+v, want service.AnchorOf's %+v", got.Anchor, anchor)
}
if loc := rec.Header().Get("Location"); loc != "/~bigbes/rfcs/p/7#thread-1" {
t.Errorf("redirect = %q, want the new thread on the proposal page", loc)
}
}
// A mutation must come from a form's body and never from the URL it was posted
// to. pages.FormValues answers r.PostForm, so a comment whose fields sit in the
// query string carries no body and is refused — where r.Form, which merges the
// two, would have written it.
//
// This is the one request the same-origin guard cannot fault: the link is
// followed from our own page, so the Origin header is ours and the guard is
// satisfied. The body-only read is what stops it.
func TestCommentFieldsInTheQueryStringDoNotWrite(t *testing.T) {
h, r := commentServer(t)
anchor, err := service.AnchorOf(commentDocID, []byte(commentProposed), 1, core.SideNew)
if err != nil {
t.Fatalf("AnchorOf: %v", err)
}
query := url.Values{
"doc": {"specs/0007-storage.md"},
"block": {"1"},
"side": {"new"},
"hash": {anchor.BlockHash},
"body": {"written from a URL"},
}.Encode()
rec := postForm(t, h, "/~bigbes/rfcs/p/7/comment?"+query,
"bigbes", "https://spec.example", url.Values{})
if rec.Code == http.StatusSeeOther {
t.Fatalf("a comment was accepted from the query string alone; body:\n%s", rec.Body)
}
if n := len(r.threads[7]); n != 0 {
t.Fatalf("%d threads stored, want none written from a URL", n)
}
}
// The submitted body is bounded. Nothing here bounded it before: net/http's own
// ceiling is 10 MiB per request, three orders of magnitude past anything this
// form sends, and every one of these routes is reachable by anybody who can log
// in. Over the limit is a 400 and not a stored comment.
func TestOversizedCommentBodyIsRefused(t *testing.T) {
h, r := commentServer(t)
anchor, err := service.AnchorOf(commentDocID, []byte(commentProposed), 1, core.SideNew)
if err != nil {
t.Fatalf("AnchorOf: %v", err)
}
rec := postForm(t, h, "/~bigbes/rfcs/p/7/comment", "bigbes", "https://spec.example", url.Values{
"doc": {"specs/0007-storage.md"},
"block": {"1"},
"side": {"new"},
"hash": {anchor.BlockHash},
"body": {strings.Repeat("x", pages.DefaultMaxFormBytes+1)},
})
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 for a body over the limit", rec.Code)
}
if n := len(r.threads[7]); n != 0 {
t.Fatalf("%d threads stored, want none from an over-long body", n)
}
}
// TestCommentOnAStaleBlockIsRefused proves the form's block hash is a guard, not
// decoration: if the agent revised the document while the page was open, the
// comment is refused rather than attached to whatever moved into that position.
func TestCommentOnAStaleBlockIsRefused(t *testing.T) {
h, r := commentServer(t)
rec := postForm(t, h, "/~bigbes/rfcs/p/7/comment", "bigbes", "https://spec.example", url.Values{
"doc": {"specs/0007-storage.md"},
"block": {"1"},
"side": {"new"},
"hash": {"a-hash-from-a-revision-that-is-gone"},
"body": {"say why"},
})
if rec.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409 for a block that moved under the form", rec.Code)
}
if len(r.threads[7]) != 0 {
t.Errorf("a comment was stored against a stale block: %+v", r.threads[7])
}
}
// The hash is required rather than checked-when-present, so that a template
// refactor dropping the hidden field fails here instead of silently disabling
// the staleness guard above — the comment would still store a coherent anchor,
// just not against the block the reviewer was actually reading.
func TestCommentWithoutABlockHashIsRefused(t *testing.T) {
h, r := commentServer(t)
rec := postForm(t, h, "/~bigbes/rfcs/p/7/comment", "bigbes", "https://spec.example", url.Values{
"doc": {"specs/0007-storage.md"},
"block": {"1"},
"side": {"new"},
"body": {"say why"},
})
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 when the form carries no block hash", rec.Code)
}
if len(r.threads[7]) != 0 {
t.Errorf("a comment was stored with no staleness guard: %+v", r.threads[7])
}
}
// TestAgentMayNotOpenAThread proves the owner-only rule is the service's and
// this page surfaces it: an agent posting the form directly gets a 403.
// The request is deliberately well-formed, block hash included: the refusal
// under test is the authority one, and a form the handler rejects before it
// ever reaches service.CommentOn would prove nothing about who may open a
// thread.
func TestAgentMayNotOpenAThread(t *testing.T) {
h, r := commentServer(t)
anchor, err := service.AnchorOf(commentDocID, []byte(commentProposed), 1, core.SideNew)
if err != nil {
t.Fatalf("AnchorOf: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/~bigbes/rfcs/p/7/comment",
strings.NewReader(url.Values{
"doc": {"specs/0007-storage.md"}, "block": {"1"}, "side": {"new"},
"hash": {anchor.BlockHash}, "body": {"mine now"},
}.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
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 opening a thread", rec.Code)
}
if len(r.threads[7]) != 0 {
t.Errorf("the thread was opened despite the refusal: %+v", r.threads[7])
}
}
// TestAgentMayReply proves the other half of that rule: replying is how the
// agent answers a critique, and it is allowed.
func TestAgentMayReply(t *testing.T) {
h, r := commentServer(t)
th := seedThread(t, r, 7, 1, "please reword this")
req := httptest.NewRequest(http.MethodPost, "/~bigbes/rfcs/p/7/reply",
strings.NewReader(url.Values{"thread": {strconv.Itoa(th.Root.ID)}, "body": {"reworded"}}.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", "Bearer "+agentTk)
req.Header.Set("Origin", "https://spec.example")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303; body:\n%s", rec.Code, rec.Body)
}
if len(th.Replies) != 1 || th.Replies[0].Body != "reworded" {
t.Fatalf("replies = %+v, want the agent's answer", th.Replies)
}
if !th.Replies[0].Agent {
t.Errorf("the reply is not attributed to the agent: %+v", th.Replies[0])
}
}
// TestAgentMayNotResolve proves an agent cannot clear the gate its own proposal
// is held by.
func TestAgentMayNotResolve(t *testing.T) {
h, r := commentServer(t)
th := seedThread(t, r, 7, 1, "please reword this")
req := httptest.NewRequest(http.MethodPost, "/~bigbes/rfcs/p/7/resolve",
strings.NewReader(url.Values{"thread": {strconv.Itoa(th.Root.ID)}, "resolved": {"1"}}.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
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 resolving", rec.Code)
}
if !th.Open() {
t.Errorf("the thread was resolved despite the refusal")
}
}
// TestOwnerResolvesAndReopens proves the resolve control is a toggle, and that
// the page then offers the other direction.
func TestOwnerResolvesAndReopens(t *testing.T) {
h, r := commentServer(t)
th := seedThread(t, r, 7, 1, "please reword this")
form := url.Values{"thread": {strconv.Itoa(th.Root.ID)}, "resolved": {"1"}}
if rec := postForm(t, h, "/~bigbes/rfcs/p/7/resolve", "bigbes", "https://spec.example", form); rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303; body:\n%s", rec.Code, rec.Body)
}
if th.Open() {
t.Fatalf("the thread is still open after the owner resolved it")
}
body := get(t, h, "/~bigbes/rfcs/p/7", "bigbes").Body.String()
if !strings.Contains(body, "Reopen") {
t.Errorf("a resolved thread offers no way back; body:\n%s", body)
}
form.Set("resolved", "0")
if rec := postForm(t, h, "/~bigbes/rfcs/p/7/resolve", "bigbes", "https://spec.example", form); rec.Code != http.StatusSeeOther {
t.Fatalf("reopen status = %d, want 303", rec.Code)
}
if !th.Open() {
t.Errorf("the thread did not reopen")
}
}
// TestCommentRefusedCrossOrigin proves the comment writes carry the same CSRF
// defense as approve and reject.
func TestCommentRefusedCrossOrigin(t *testing.T) {
h, r := commentServer(t)
rec := postForm(t, h, "/~bigbes/rfcs/p/7/comment", "bigbes", "https://evil.example", url.Values{
"doc": {"specs/0007-storage.md"}, "block": {"1"}, "side": {"new"}, "body": {"forged"},
})
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403 for a cross-origin comment", rec.Code)
}
if len(r.threads[7]) != 0 {
t.Errorf("a forged comment was stored: %+v", r.threads[7])
}
}
// TestCommentThroughAnotherSpacesURLIs404 proves a comment POST is held to the
// same rule as the page it comes from: the proposal id is global, but the link
// names a space, and answering for the wrong one would let one space's URL write
// into another's review.
func TestCommentThroughAnotherSpacesURLIs404(t *testing.T) {
h, r := commentServer(t)
rec := postForm(t, h, "/~bigbes/other/p/7/comment", "bigbes", "https://spec.example", url.Values{
"doc": {"specs/0007-storage.md"}, "block": {"1"}, "side": {"new"}, "body": {"wrong space"},
})
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404 through another space's URL", rec.Code)
}
if len(r.threads[7]) != 0 {
t.Errorf("a comment was stored through the wrong space's URL: %+v", r.threads[7])
}
}
// TestAnonymousMayNotComment proves the comment writes are behind the read ACL
// as well as behind the service's owner rule: a viewer who may not read the
// proposal cannot write to its review either.
func TestAnonymousMayNotComment(t *testing.T) {
h, r := commentServer(t)
rec := postForm(t, h, "/~bigbes/rfcs/p/7/comment", "", "https://spec.example", url.Values{
"doc": {"specs/0007-storage.md"}, "block": {"1"}, "side": {"new"}, "body": {"uninvited"},
})
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403 for an anonymous comment", rec.Code)
}
if len(r.threads[7]) != 0 {
t.Errorf("an anonymous comment was stored: %+v", r.threads[7])
}
}
// TestReplyToAnotherProposalsThreadIs404 proves the thread id in a form is
// checked against the proposal in the URL: a global id must not let a reply land
// on a conversation the reviewer was never looking at.
func TestReplyToAnotherProposalsThreadIs404(t *testing.T) {
h, r := commentServer(t)
other := openProposal()
other.ID = 8
seedProposal(r, other, commentDocs())
th := seedThread(t, r, 8, 1, "on the other proposal")
rec := postForm(t, h, "/~bigbes/rfcs/p/7/reply", "bigbes", "https://spec.example",
url.Values{"thread": {strconv.Itoa(th.Root.ID)}, "body": {"misdirected"}})
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404 for a thread on another proposal", rec.Code)
}
if len(th.Replies) != 0 {
t.Errorf("the reply landed on the other proposal's thread: %+v", th.Replies)
}
}