package web import ( "net/http" "net/http/httptest" "net/url" "strconv" "strings" "testing" "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, ``) body := get(t, h, "/~bigbes/rfcs/p/7", "bigbes").Body.String() if strings.Contains(body, "