package harness
import (
"context"
"encoding/json"
"io"
"net/http"
"net/url"
"path/filepath"
"regexp"
"strings"
"sync"
"testing"
"time"
"craftdesign.group/quarry/internal/api"
"craftdesign.group/quarry/internal/oltp"
"craftdesign.group/quarry/internal/ops"
)
// TestWriteOps drives the wiki/ticket write path the way a browser
// does: form POSTs through the web handler, gated by Basic auth, with
// read-your-writes (the op syncs before answering).
func TestWriteOps(t *testing.T) {
stores := map[string]string{"alpha": buildStore(t, "writeseed")}
dataDir := t.TempDir()
srv := ingestAndServeAuth(t, stores, dataDir, func(repo, user, pass string, _ ops.Auth) api.Decision {
if repo == "alpha" && user == "dan" && pass == "hunter2" {
return api.Allowed
}
return api.Denied
})
client := srv.Client()
client.CheckRedirect = func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}
post := func(path string, form url.Values, auth bool) *http.Response {
req, _ := http.NewRequest("POST", srv.URL+path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if auth {
req.SetBasicAuth("dan", "hunter2")
}
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
return resp
}
get := func(path string) string {
resp, err := client.Get(srv.URL + path)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return string(b)
}
// Unauthenticated write: challenged, nothing recorded.
if resp := post("/r/alpha/wiki/Guide/edit", url.Values{"body": {"nope"}}, false); resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("unauthenticated wiki save: status %d", resp.StatusCode)
}
// Authenticated wiki save: redirect, then the page serves the body
// and the history records the author.
if resp := post("/r/alpha/wiki/Guide/edit", url.Values{"body": {"# Guide\n\nwritten from the web\n"}}, true); resp.StatusCode != http.StatusSeeOther {
t.Fatalf("wiki save: status %d", resp.StatusCode)
}
if body := get("/r/alpha/wiki/Guide"); !strings.Contains(body, "written from the web") {
t.Fatalf("saved wiki not served:\n%s", body)
}
if body := get("/r/alpha/wiki/Guide/history"); !strings.Contains(body, "dan") {
t.Fatalf("author missing from history:\n%s", body)
}
// A second save supersedes the first: zit's millisecond D card
// (zit#75) gives same-second writes real submission order, so
// last-write-wins holds and the history records both revisions.
post("/r/alpha/wiki/Guide/edit", url.Values{"body": {"revised from the web\n"}}, true)
hist := get("/r/alpha/wiki/Guide/history")
if n := strings.Count(hist, "<li>"); n < 2 {
t.Fatalf("second revision not recorded (%d in history):\n%s", n, hist)
}
if body := get("/r/alpha/wiki/Guide"); !strings.Contains(body, "revised from the web") {
t.Fatalf("latest save is not current:\n%s", body)
}
// Ticket: new, then set, both visible immediately.
resp := post("/r/alpha/ticket/new", url.Values{
"title": {"web-born ticket"}, "comment": {"filed from the form"},
}, true)
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("ticket new: status %d", resp.StatusCode)
}
loc := resp.Header.Get("Location")
if body := get(loc); !strings.Contains(body, "web-born ticket") || !strings.Contains(body, "Open") {
t.Fatalf("new ticket page:\n%s", body)
}
id := strings.TrimPrefix(loc, "/r/alpha/ticket/")
if resp := post("/r/alpha/ticket/"+id[:10]+"/edit", url.Values{
"status": {"Closed"}, "resolution": {"Fixed"},
"priority": {"High"}, "severity": {"Severe"}, "type": {"Code_Defect"},
}, true); resp.StatusCode != http.StatusSeeOther {
t.Fatalf("ticket set: status %d", resp.StatusCode)
}
if body := get(loc); !strings.Contains(body, "Closed") || !strings.Contains(body, "Fixed") {
t.Fatalf("ticket change not folded:\n%s", body)
}
// The triage loop must be closable by a human with nothing but the
// forms: the fields authored above appear on the detail page and the
// edit form offers them back for the next change. If either half
// breaks, triage silently becomes a CLI-only capability.
if body := get(loc); !strings.Contains(body, "High") || !strings.Contains(body, "Severe") {
t.Fatalf("triage fields not on the detail page:\n%s", body)
}
freq, _ := http.NewRequest("GET", srv.URL+"/r/alpha/ticket/"+id[:10]+"/edit", nil)
freq.SetBasicAuth("dan", "hunter2")
fresp, err := client.Do(freq)
if err != nil {
t.Fatal(err)
}
fb, _ := io.ReadAll(fresp.Body)
fresp.Body.Close()
editForm := string(fb)
// The triage fields are pickers now — a select over the tracker's
// words plus a box to coin a new one — so the ticket's current
// value arrives SELECTED rather than as an input value. What this
// still pins is that the form opens showing what the ticket
// actually holds: an edit form that forgets the current triage is
// how a save silently clears it.
for _, want := range []string{
`<option value="High" selected>High</option>`,
`<option value="Severe" selected>Severe</option>`,
`<option value="Code_Defect" selected>Code_Defect</option>`,
`name="subsystem"`,
} {
if !strings.Contains(editForm, want) {
t.Fatalf("edit form is missing %s:\n%s", want, editForm)
}
}
// Replying must APPEND. Fossil's schema puts a change's text under
// "icomment" and the ticket's description under "comment"; writing a
// reply into the latter silently replaces the description, which
// reads as a successful post and destroys the report it answers.
if resp := post("/r/alpha/ticket/"+id[:10]+"/edit", url.Values{
"comment": {"a reply, which must not eat the description"},
}, true); resp.StatusCode != http.StatusSeeOther {
t.Fatalf("ticket comment: status %d", resp.StatusCode)
}
body := get(loc)
if !strings.Contains(body, "a reply, which must not eat the description") {
t.Fatalf("reply did not appear:\n%s", body)
}
if !strings.Contains(body, "filed from the form") {
t.Fatalf("the reply replaced the description instead of appending:\n%s", body)
}
// The timeline defaults to check-ins now; tickets are one pill away.
if body := get("/r/alpha/timeline?y=t"); !strings.Contains(body, "web-born ticket") && !strings.Contains(body, "Ticket") {
t.Fatalf("ticket missing from timeline:\n%s", body)
}
}
// TestForumTechnoteAuthoring drives the v2 write plane for the two
// record types zit#76 unlocked: open a thread, reply to it, add a
// technote, then edit that technote — all through the forms, gated.
func TestForumTechnoteAuthoring(t *testing.T) {
stores := map[string]string{"alpha": buildStore(t, "authorseed")}
srv := ingestAndServeAuth(t, stores, t.TempDir(), func(repo, user, pass string, _ ops.Auth) api.Decision {
if user == "dan" && pass == "pw" {
return api.Allowed
}
return api.Denied
})
client := srv.Client()
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
post := func(path string, form url.Values, auth bool) *http.Response {
req, _ := http.NewRequest("POST", srv.URL+path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if auth {
req.SetBasicAuth("dan", "pw")
}
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
return resp
}
get := func(path string) string {
resp, err := client.Get(srv.URL + path)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return string(b)
}
// Unauthenticated thread creation is challenged.
if resp := post("/r/alpha/forum-new", url.Values{"title": {"nope"}, "body": {"x"}}, false); resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("unauthenticated forum post: status %d", resp.StatusCode)
}
// Open a thread; the redirect names it.
resp := post("/r/alpha/forum-new", url.Values{
"title": {"Web-born thread"}, "body": {"opening from the form\n"},
}, true)
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("forum-new: status %d", resp.StatusCode)
}
loc := resp.Header.Get("Location")
if body := get(loc); !strings.Contains(body, "opening from the form") {
t.Fatalf("thread page:\n%s", body)
}
if body := get("/r/alpha/forum"); !strings.Contains(body, "Web-born thread") {
t.Fatalf("thread missing from list:\n%s", body)
}
// Reply in that thread: two posts, and the reply names its parent.
if resp := post(loc+"/reply", url.Values{"body": {"answering from the form\n"}}, true); resp.StatusCode != http.StatusSeeOther {
t.Fatalf("reply: status %d", resp.StatusCode)
}
body := get(loc)
if !strings.Contains(body, "answering from the form") || !strings.Contains(body, "replying to") {
t.Fatalf("reply not threaded:\n%s", body)
}
if n := strings.Count(body, "<article"); n != 2 {
t.Fatalf("thread has %d posts, want 2", n)
}
// Add a note, then edit it: the edit supersedes, and the event id is
// stable across the edit (one note, not two). The note is FILED at a
// moment the author chose — back-dated here, which is the case that
// separates a note from a forum post.
resp = post("/r/alpha/technote-new", url.Values{
"body": {"# Note\n\nfirst version\n"}, "comment": {"Original title"},
"time": {"2019-04-05T09:30"},
}, true)
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("technote-new: status %d", resp.StatusCode)
}
noteLoc := resp.Header.Get("Location")
if b := get(noteLoc); !strings.Contains(b, "first version") {
t.Fatalf("technote page:\n%s", b)
}
// An edit that names no time KEEPS the filing time. Without this the
// note silently moves to the moment it was edited, which is a
// different place in the timeline than its author put it.
if resp := post(noteLoc+"/edit", url.Values{
"body": {"# Note\n\nsecond version\n"}, "comment": {"Corrected title"},
}, true); resp.StatusCode != http.StatusSeeOther {
t.Fatalf("technote edit: status %d", resp.StatusCode)
}
nb := get(noteLoc)
if !strings.Contains(nb, "second version") || strings.Contains(nb, "first version") {
t.Fatalf("edit did not supersede:\n%s", nb)
}
if !strings.Contains(nb, "Corrected title") {
t.Fatalf("edit did not retitle:\n%s", nb)
}
if !strings.Contains(nb, "2019") {
t.Fatalf("edit moved the note off its filing date:\n%s", nb)
}
// One note on the listing, and its history holds both versions.
list := get("/r/alpha/technote")
if n := strings.Count(list, "class=\"tn-item\""); n != 1 {
t.Fatalf("listing shows %d notes, want 1 — an edit is not a new note:\n%s", n, list)
}
if hist := get(noteLoc + "/history"); strings.Count(hist, "class=\"tn-hrow\"") != 2 {
t.Fatalf("history lost a version:\n%s", hist)
}
}
// TestAccountRolesGateWrites drives the real OLTP-backed authorization
// end to end: a read-only account must not be able to write, a write
// account must, and the site admin must reach every repo without a
// per-repo grant. The gate under test is the one the composition root
// builds, not a stub.
func TestAccountRolesGateWrites(t *testing.T) {
stores := map[string]string{"alpha": buildStore(t, "roleseed")}
dataDir := t.TempDir()
db, err := oltp.Open(filepath.Join(dataDir, "oltp.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
for _, a := range []struct {
name, pw string
admin bool
}{{"reader", "pw1", false}, {"writer", "pw2", false}, {"root", "pw3", true}} {
if err := db.AddAccount(a.name, a.pw, a.admin); err != nil {
t.Fatal(err)
}
}
if err := db.Grant("reader", "alpha", oltp.Read); err != nil {
t.Fatal(err)
}
if err := db.Grant("writer", "alpha", oltp.Write); err != nil {
t.Fatal(err)
}
// The same policy the composition root installs.
verify := func(repo, user, pass string, need ops.Auth) api.Decision {
if !db.HasAccounts() {
return api.Allowed
}
acct, ok := db.Authenticate(user, pass)
if !ok {
return api.Denied
}
var want oltp.Role
switch need {
case ops.Admin:
want = oltp.RepoAdmin
case ops.Token:
want = oltp.Write
}
if db.RoleOf(acct, repo) >= want {
return api.Allowed
}
return api.Insufficient
}
srv := ingestAndServeAuth(t, stores, dataDir, verify)
client := srv.Client()
client.CheckRedirect = func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}
save := func(user, pw, page, body string) int {
form := url.Values{"body": {body}}
req, _ := http.NewRequest("POST", srv.URL+"/r/alpha/wiki/"+page+"/edit",
strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if user != "" {
req.SetBasicAuth(user, pw)
}
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
return resp.StatusCode
}
if got := save("", "", "R1", "anon"); got != http.StatusUnauthorized {
t.Errorf("anonymous write: status %d, want 401", got)
}
if got := save("writer", "wrong", "R2", "bad password"); got != http.StatusUnauthorized {
t.Errorf("wrong password: status %d, want 401", got)
}
// Read is a real account with a real role — and still must not write.
// 403, not 401: the credentials were correct and the capability was
// not, and 401 would tell this client to retry the same password
// forever. The two refusals above are 401 because those really are
// credential failures.
if got := save("reader", "pw1", "R3", "read-only tried to write"); got != http.StatusForbidden {
t.Errorf("read-only account wrote: status %d, want 403", got)
}
if got := save("writer", "pw2", "R4", "written by writer"); got != http.StatusSeeOther {
t.Errorf("writer blocked: status %d, want 303", got)
}
// The site admin holds every repo without any repo_role row.
if got := save("root", "pw3", "R5", "written by site admin"); got != http.StatusSeeOther {
t.Errorf("site admin blocked: status %d, want 303", got)
}
}
// TestAuthoredCardsReachTheArtifact pins zit#77's cards to the write
// path: the technote C card (a title zit refuses to attach later, so it
// is write-once here) and the forum N card (an omitted one reads back as
// text/x-fossil-wiki, which would render a markdown post as wiki markup).
func TestAuthoredCardsReachTheArtifact(t *testing.T) {
stores := map[string]string{"alpha": buildStore(t, "cardseed")}
srv := ingestAndServeAuth(t, stores, t.TempDir(), nil)
client := srv.Client()
client.CheckRedirect = func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}
post := func(path string, form url.Values) *http.Response {
req, _ := http.NewRequest("POST", srv.URL+path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
return resp
}
get := func(path string) string {
resp, err := client.Get(srv.URL + path)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return string(b)
}
// A technote authored with a title shows that title, not its event id.
resp := post("/r/alpha/technote-new", url.Values{
"comment": {"Release notes for 1.0"},
"body": {"# Notes\n\nthe first release\n"},
})
resp.Body.Close()
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("technote save: status %d", resp.StatusCode)
}
if list := get("/r/alpha/technote"); !strings.Contains(list, "Release notes for 1.0") {
t.Fatalf("technote title missing from the list:\n%s", list)
}
// A forum post typed as markdown must render as markdown. Without the
// N card fossil's rule makes it text/x-fossil-wiki, where a leading #
// is not a heading.
resp = post("/r/alpha/forum-new", url.Values{
"title": {"Markdown thread"},
"body": {"# Heading\n\nbody text\n"},
})
resp.Body.Close()
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("forum post: status %d", resp.StatusCode)
}
loc := resp.Header.Get("Location")
if page := get(loc); !strings.Contains(page, "<h2 id=\"heading\">Heading</h2>") &&
!strings.Contains(page, "<h2>Heading</h2>") {
t.Fatalf("forum body did not render as markdown (no N card?):\n%s", page)
}
}
// Every authoring box in quarry is labelled Markdown, so what quarry
// stores must SAY it is markdown. An absent mimetype means fossil wiki
// (renderBody dispatches exactly as fossil does), so a description
// typed with headings and fenced code came back with its "##" and
// backticks as literal characters — the card lied by omission and the
// renderer believed it.
func TestAuthoredTextIsRecordedAsMarkdown(t *testing.T) {
stores := map[string]string{"alpha": buildStore(t, "mdseed")}
srv := ingestAndServeAuth(t, stores, t.TempDir(),
func(repo, user, pass string, _ ops.Auth) api.Decision {
if repo == "alpha" && user == "dan" && pass == "hunter2" {
return api.Allowed
}
return api.Denied
})
client := srv.Client()
client.CheckRedirect = func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}
post := func(path string, form url.Values, auth bool) *http.Response {
req, _ := http.NewRequest("POST", srv.URL+path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if auth {
req.SetBasicAuth("dan", "hunter2")
}
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
return resp
}
get := func(path string) string {
resp, err := client.Get(srv.URL + path)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return string(b)
}
const body = "## A heading\n\nSome `code` and **bold** text.\n"
resp := post("/r/alpha/ticket/new", url.Values{
"title": {"markdown ticket"}, "comment": {body},
}, true)
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("ticket new: status %d", resp.StatusCode)
}
loc := resp.Header.Get("Location")
page := get(loc)
// A heading element, not specifically h2: the renderer demotes
// levels so a document's headings cannot outrank the page's own h1.
if !strings.Contains(page, `id="a-heading"`) {
t.Errorf("the description's heading did not render as one:\n%s", page)
}
if strings.Contains(page, "## A heading") {
t.Error("the description rendered its markdown as literal text")
}
if !strings.Contains(page, "<code>code</code>") {
t.Error("inline code did not render")
}
if !strings.Contains(page, "<strong>bold</strong>") {
t.Error("emphasis did not render")
}
// And a comment appended later carries its own mimetype field,
// which fossil spells differently from the description's.
id := strings.TrimPrefix(loc, "/r/alpha/ticket/")
if resp := post("/r/alpha/ticket/"+id[:10]+"/edit", url.Values{
"comment": {"a **later** remark"},
}, true); resp.StatusCode != http.StatusSeeOther {
t.Fatalf("ticket set: status %d", resp.StatusCode)
}
page = get(loc)
if !strings.Contains(page, "<strong>later</strong>") {
t.Errorf("an appended comment did not render as markdown:\n%s", page)
}
}
// TestForumVotes drives the vote plane through the forms: two accounts
// vote, flip, and retract, and every page renders the DERIVED score —
// there is no counter to disagree with the vote rows.
func TestForumVotes(t *testing.T) {
stores := map[string]string{"alpha": buildStore(t, "voteseed")}
srv := ingestAndServeAuth(t, stores, t.TempDir(), func(repo, user, pass string, _ ops.Auth) api.Decision {
if (user == "dan" || user == "eve") && pass == "pw" {
return api.Allowed
}
return api.Denied
})
client := srv.Client()
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
post := func(user, path string, form url.Values) *http.Response {
req, _ := http.NewRequest("POST", srv.URL+path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if user != "" {
req.SetBasicAuth(user, "pw")
}
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
return resp
}
get := func(user, path string) string {
req, _ := http.NewRequest("GET", srv.URL+path, nil)
if user != "" {
req.SetBasicAuth(user, "pw")
}
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return string(b)
}
resp := post("dan", "/r/alpha/forum-new", url.Values{
"title": {"Votable thread"}, "body": {"cast away\n"},
})
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("forum-new: status %d", resp.StatusCode)
}
loc := resp.Header.Get("Location")
root := strings.TrimPrefix(loc, "/r/alpha/forum/")
// Anonymous readers see no arrows and no zero score.
if body := get("", loc); strings.Contains(body, `class="vote-pill"`) {
t.Fatalf("anonymous page shows vote chrome:\n%s", body)
}
// A signed-in (Basic) writer sees the arrows.
if body := get("dan", loc); !strings.Contains(body, `class="vote-pill"`) {
t.Fatalf("writer page shows no arrows:\n%s", body)
}
// Voting without credentials is challenged, and records nothing.
if resp := post("", loc+"/vote", url.Values{"post": {root}, "dir": {"up"}}); resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("anonymous vote: status %d", resp.StatusCode)
}
// dan and eve upvote: score 2, dan's arrow is lit (retract offered).
for _, u := range []string{"dan", "eve"} {
if resp := post(u, loc+"/vote", url.Values{"post": {root}, "dir": {"up"}}); resp.StatusCode != http.StatusSeeOther {
t.Fatalf("%s vote: status %d", u, resp.StatusCode)
}
}
if body := get("", loc); !strings.Contains(body, `data-score="2"`) {
t.Fatalf("anonymous page missing derived score:\n%s", body)
}
if body := get("dan", loc); !strings.Contains(body, `class="vote-cast"`) {
t.Fatalf("cast arrow not lit for dan:\n%s", body)
}
if body := get("", "/r/alpha/forum"); !strings.Contains(body, `data-score="2"`) {
t.Fatalf("thread list missing score:\n%s", body)
}
// eve flips to a downvote — one account, one vote: net 0, and a
// zero score renders as nothing rather than a meaningless "0".
if resp := post("eve", loc+"/vote", url.Values{"post": {root}, "dir": {"down"}}); resp.StatusCode != http.StatusSeeOther {
t.Fatalf("eve flip: status %d", resp.StatusCode)
}
if body := get("", loc); strings.Contains(body, `class="vote-pill"`) {
t.Fatalf("net-zero score rendered:\n%s", body)
}
// eve retracts: dan's upvote stands alone again.
if resp := post("eve", loc+"/vote", url.Values{"post": {root}, "dir": {"none"}}); resp.StatusCode != http.StatusSeeOther {
t.Fatalf("eve retract: status %d", resp.StatusCode)
}
if body := get("", loc); !strings.Contains(body, `data-score="1"`) {
t.Fatalf("score after retract:\n%s", body)
}
// A direction outside the vocabulary is refused, not recorded.
if resp := post("dan", loc+"/vote", url.Values{"post": {root}, "dir": {"sideways"}}); resp.StatusCode == http.StatusSeeOther {
t.Fatal("invalid direction accepted")
}
if body := get("", loc); !strings.Contains(body, `data-score="1"`) {
t.Fatalf("invalid direction changed the score:\n%s", body)
}
}
// TestForumHotTab: votes reorder the hot view while recent keeps
// arrival order, and the page prints the formula it ranked by.
func TestForumHotTab(t *testing.T) {
stores := map[string]string{"alpha": buildStore(t, "hotseed")}
srv := ingestAndServeAuth(t, stores, t.TempDir(), func(repo, user, pass string, _ ops.Auth) api.Decision {
if user == "dan" && pass == "pw" {
return api.Allowed
}
return api.Denied
})
client := srv.Client()
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
post := func(path string, form url.Values) *http.Response {
req, _ := http.NewRequest("POST", srv.URL+path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth("dan", "pw")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
return resp
}
get := func(path string) string {
resp, err := client.Get(srv.URL + path)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return string(b)
}
first := post("/r/alpha/forum-new", url.Values{"title": {"First thread"}, "body": {"a\n"}})
post("/r/alpha/forum-new", url.Values{"title": {"Second thread"}, "body": {"b\n"}})
firstLoc := first.Header.Get("Location")
// Upvote the FIRST (older) thread; ages are near-equal so the vote decides.
if resp := post(firstLoc+"/vote", url.Values{
"post": {strings.TrimPrefix(firstLoc, "/r/alpha/forum/")}, "dir": {"up"},
}); resp.StatusCode != http.StatusSeeOther {
t.Fatalf("vote: status %d", resp.StatusCode)
}
recent := get("/r/alpha/forum")
if strings.Index(recent, "Second thread") > strings.Index(recent, "First thread") {
t.Fatalf("recent view lost arrival order:\n%s", recent)
}
hot := get("/r/alpha/forum?sort=hot")
if strings.Index(hot, "First thread") > strings.Index(hot, "Second thread") {
t.Fatalf("vote did not lift thread in hot view:\n%s", hot)
}
if !strings.Contains(hot, `class="hot-formula"`) {
t.Fatalf("hot view does not print its formula:\n%s", hot)
}
if strings.Contains(recent, `class="hot-formula"`) {
t.Fatalf("recent view prints the hot formula:\n%s", recent)
}
}
// TestForumAnchorsCheckin: a thread that mentions a check-in by hash
// prefix surfaces as "discussed in" on that check-in's page — and only
// there. Anchors extract at fold, resolve at render.
func TestForumAnchorsCheckin(t *testing.T) {
stores := map[string]string{"alpha": buildStore(t, "anchorseed")}
srv := ingestAndServeAuth(t, stores, t.TempDir(), func(repo, user, pass string, _ ops.Auth) api.Decision {
if user == "dan" && pass == "pw" {
return api.Allowed
}
return api.Denied
})
client := srv.Client()
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
get := func(path string) string {
resp, err := client.Get(srv.URL + path)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return string(b)
}
// Two real check-ins from the store, via the API.
var tl struct {
Entries []struct {
Kind string `json:"kind"`
Hash string `json:"hash"`
} `json:"entries"`
}
if err := json.Unmarshal([]byte(get("/api/v1/r/alpha/timeline")), &tl); err != nil {
t.Fatal(err)
}
var checkins []string
for _, e := range tl.Entries {
if e.Kind == "ci" {
checkins = append(checkins, e.Hash)
}
}
if len(checkins) < 2 {
t.Fatalf("timeline has %d check-ins", len(checkins))
}
mentioned, unmentioned := checkins[0], checkins[1]
form := url.Values{"title": {"About that change"},
"body": {"I think [" + mentioned[:10] + "] broke the build\n"}}
req, _ := http.NewRequest("POST", srv.URL+"/r/alpha/forum-new", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth("dan", "pw")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("forum-new: status %d", resp.StatusCode)
}
if body := get("/r/alpha/checkin/" + mentioned); !strings.Contains(body, "discussed in") ||
!strings.Contains(body, "About that change") {
t.Fatalf("mentioned check-in has no backlink:\n%s", body)
}
// The honesty gate: a check-in nobody mentioned shows nothing.
if body := get("/r/alpha/checkin/" + unmentioned); strings.Contains(body, "discussed in") {
t.Fatalf("unmentioned check-in grew a backlink:\n%s", body)
}
}
// TestForumAnnouncements: announcement-as-a-type — starting one takes
// Admin, replying stays open to Token, the strip pins above the forum
// and the repo home surfaces the newest one.
func TestForumAnnouncements(t *testing.T) {
stores := map[string]string{"alpha": buildStore(t, "annseed")}
srv := ingestAndServeAuth(t, stores, t.TempDir(), func(repo, user, pass string, level ops.Auth) api.Decision {
switch {
case user == "dan" && pass == "pw": // maintainer
return api.Allowed
case user == "eve" && pass == "pw": // ordinary account
if level >= ops.Admin {
return api.Insufficient
}
return api.Allowed
}
return api.Denied
})
client := srv.Client()
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
post := func(user, path string, form url.Values) *http.Response {
req, _ := http.NewRequest("POST", srv.URL+path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth(user, "pw")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
return resp
}
get := func(user, path string) string {
req, _ := http.NewRequest("GET", srv.URL+path, nil)
if user != "" {
req.SetBasicAuth(user, "pw")
}
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return string(b)
}
form := url.Values{"title": {"v2.0 released"}, "body": {"it shipped\n"}}
// An ordinary account cannot start an announcement...
if resp := post("eve", "/r/alpha/announce-new", form); resp.StatusCode != http.StatusForbidden {
t.Fatalf("eve announce: status %d, want 403", resp.StatusCode)
}
// ...a maintainer can.
resp := post("dan", "/r/alpha/announce-new", form)
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("dan announce: status %d", resp.StatusCode)
}
loc := resp.Header.Get("Location")
list := get("", "/r/alpha/forum")
if !strings.Contains(list, `class="f-pinned"`) || !strings.Contains(list, "v2.0 released") {
t.Fatalf("forum list has no pinned strip:\n%s", list)
}
if strings.Count(list, "v2.0 released") != 1 {
t.Fatalf("announcement rendered twice (strip AND list):\n%s", list)
}
// The maintainer sees the announce control; an ordinary account
// and anonymous readers do not.
if s := get("dan", "/r/alpha/forum"); !strings.Contains(s, "announce-new") {
t.Fatal("maintainer missing announce control")
}
for _, u := range []string{"eve", ""} {
if s := get(u, "/r/alpha/forum"); strings.Contains(s, "announce-new") {
t.Fatalf("%q sees the announce control", u)
}
}
// Top-level visibility: the repo home carries the newest announcement.
if home := get("", "/r/alpha"); !strings.Contains(home, "v2.0 released") {
t.Fatalf("repo home has no announcement:\n%s", home)
}
// Anyone-can-reply is untouched.
if resp := post("eve", loc+"/reply", url.Values{"body": {"congrats\n"}}); resp.StatusCode != http.StatusSeeOther {
t.Fatalf("eve reply on announcement: status %d", resp.StatusCode)
}
}
// TestForumPolls: the definition is markdown in the post (fossil-
// compatible by degradation), ballots are forge state — one per
// account, changeable until close, option range enforced from the body.
func TestForumPolls(t *testing.T) {
stores := map[string]string{"alpha": buildStore(t, "pollseed")}
srv := ingestAndServeAuth(t, stores, t.TempDir(), func(repo, user, pass string, _ ops.Auth) api.Decision {
if (user == "dan" || user == "eve") && pass == "pw" {
return api.Allowed
}
return api.Denied
})
client := srv.Client()
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
post := func(user, path string, form url.Values) *http.Response {
req, _ := http.NewRequest("POST", srv.URL+path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth(user, "pw")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
return resp
}
get := func(user, path string) string {
req, _ := http.NewRequest("GET", srv.URL+path, nil)
if user != "" {
req.SetBasicAuth(user, "pw")
}
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return string(b)
}
body := "Time to pick.\n\n```poll\nquestion: Which default branch?\n- trunk\n- main\n```\n"
resp := post("dan", "/r/alpha/forum-new", url.Values{"title": {"Branch poll"}, "body": {body}})
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("forum-new: status %d", resp.StatusCode)
}
loc := resp.Header.Get("Location")
root := strings.TrimPrefix(loc, "/r/alpha/forum/")
page := get("dan", loc)
if !strings.Contains(page, "Which default branch?") || strings.Contains(page, "```poll") {
t.Fatalf("widget did not replace the block:\n%s", page)
}
if !strings.Contains(page, "Time to pick.") {
t.Fatalf("prose around the poll lost:\n%s", page)
}
// Two ballots, one changed mind: trunk 1, main 1.
for _, c := range []struct{ user, choice string }{
{"dan", "0"}, {"eve", "0"}, {"eve", "1"},
} {
if resp := post(c.user, loc+"/poll", url.Values{"post": {root}, "choice": {c.choice}}); resp.StatusCode != http.StatusSeeOther {
t.Fatalf("%s ballot %s: status %d", c.user, c.choice, resp.StatusCode)
}
}
page = get("dan", loc)
if !strings.Contains(page, "trunk · 1") || !strings.Contains(page, "main · 1") ||
!strings.Contains(page, "2 ballots") {
t.Fatalf("counts wrong after change of mind:\n%s", page)
}
// dan's own ballot is marked.
if !strings.Contains(page, "◉") {
t.Fatalf("cast ballot not marked:\n%s", page)
}
// An out-of-range choice is refused by the BODY's option count.
if resp := post("dan", loc+"/poll", url.Values{"post": {root}, "choice": {"7"}}); resp.StatusCode == http.StatusSeeOther {
t.Fatal("out-of-range choice accepted")
}
// Anonymous readers see results, no ballot buttons.
anon := get("", loc)
if !strings.Contains(anon, "trunk · 1") || strings.Contains(anon, "◉") || strings.Contains(anon, "○") {
t.Fatalf("anonymous poll view wrong:\n%s", anon)
}
}
// TestForumModeration drives the whole gate: an untrusted author's post
// stages (no artifact), the queue shows it with the hook's advisory
// verdict, approval births the artifact under the original author,
// rejection leaves nothing, hides collapse with a public reason, and
// every action lands in the public log.
func TestForumModeration(t *testing.T) {
stores := map[string]string{"alpha": buildStore(t, "modseed")}
hooked := make(chan struct{}, 4)
srv := ingestAndServeAuthMod(t, stores, t.TempDir(),
func(repo, user, pass string, level ops.Auth) api.Decision {
switch {
case user == "dan" && pass == "pw":
return api.Allowed
case user == "eve" && pass == "pw":
if level >= ops.Admin {
return api.Insufficient
}
return api.Allowed
}
return api.Denied
},
func(account, repo string) bool { return account == "dan" },
func(_ context.Context, s oltp.Staged) (api.HookVerdict, error) {
defer func() { hooked <- struct{}{} }()
if strings.Contains(s.Body, "buy cheap") {
return api.HookVerdict{Action: "reject", Score: 0.98, Reason: "reads as spam"}, nil
}
return api.HookVerdict{Action: "hold", Score: 0.4, Reason: "uncertain"}, nil
}, nil)
client := srv.Client()
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
post := func(user, path string, form url.Values) *http.Response {
req, _ := http.NewRequest("POST", srv.URL+path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth(user, "pw")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
return resp
}
get := func(user, path string) string {
req, _ := http.NewRequest("GET", srv.URL+path, nil)
if user != "" {
req.SetBasicAuth(user, "pw")
}
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return string(b)
}
// dan switches the gate on (logged).
if resp := post("dan", "/r/alpha/forum/queue", url.Values{"toggle": {"on"}}); resp.StatusCode != http.StatusSeeOther {
t.Fatalf("toggle: status %d", resp.StatusCode)
}
// The queue is admin-only; the log is public.
if resp := post("eve", "/r/alpha/forum/queue", url.Values{"toggle": {"off"}}); resp.StatusCode != http.StatusForbidden {
t.Fatalf("eve toggled moderation: status %d", resp.StatusCode)
}
// eve (no role) posts twice: both stage, neither becomes an artifact.
resp := post("eve", "/r/alpha/forum-new", url.Values{"title": {"Genuine question"}, "body": {"how do I build?\n"}})
if resp.StatusCode != http.StatusSeeOther || !strings.Contains(resp.Header.Get("Location"), "staged=1") {
t.Fatalf("stage redirect: %d %s", resp.StatusCode, resp.Header.Get("Location"))
}
post("eve", "/r/alpha/forum-new", url.Values{"title": {"Deals"}, "body": {"buy cheap watches\n"}})
<-hooked
<-hooked
if list := get("", "/r/alpha/forum"); strings.Contains(list, "Genuine question") || strings.Contains(list, "Deals") {
t.Fatalf("staged post visible before approval:\n%s", list)
}
// dan (trusted) posts directly, no staging.
if resp := post("dan", "/r/alpha/forum-new", url.Values{"title": {"Direct thread"}, "body": {"hi\n"}}); strings.Contains(resp.Header.Get("Location"), "staged") {
t.Fatal("trusted author was staged")
}
// The queue shows both with the hook's advisory verdicts.
queue := get("dan", "/r/alpha/forum/queue")
if !strings.Contains(queue, "Genuine question") || !strings.Contains(queue, "reads as spam") ||
!strings.Contains(queue, "uncertain") {
t.Fatalf("queue missing entries or verdicts:\n%s", queue)
}
// Approve the genuine one, reject the spam (reason required).
approveID, rejectID := "1", "2"
if strings.Index(queue, "Genuine question") > strings.Index(queue, "Deals") {
approveID, rejectID = "2", "1"
}
if resp := post("dan", "/r/alpha/forum/queue", url.Values{"id": {approveID}, "action": {"approve"}}); resp.StatusCode != http.StatusSeeOther {
t.Fatalf("approve: status %d", resp.StatusCode)
}
if resp := post("dan", "/r/alpha/forum/queue", url.Values{"id": {rejectID}, "action": {"reject"}}); resp.StatusCode == http.StatusSeeOther {
t.Fatal("reject without a reason accepted")
}
if resp := post("dan", "/r/alpha/forum/queue", url.Values{"id": {rejectID}, "action": {"reject"}, "reason": {"spam"}}); resp.StatusCode != http.StatusSeeOther {
t.Fatalf("reject: status %d", resp.StatusCode)
}
// The approved post is live, authored by eve; the rejected one never existed.
list := get("", "/r/alpha/forum")
if !strings.Contains(list, "Genuine question") || !strings.Contains(list, "eve") {
t.Fatalf("approved post missing or misattributed:\n%s", list)
}
if strings.Contains(list, "Deals") {
t.Fatalf("rejected post exists:\n%s", list)
}
// Hide the direct thread's post with a public reason; it collapses.
dq := get("", "/r/alpha/forum")
i := strings.Index(dq, "/forum/")
_ = i
// find the Direct thread link
var directLoc string
for _, line := range strings.Split(dq, "\n") {
if strings.Contains(line, "Direct thread") {
s := strings.SplitN(line, `href="`, 2)[1]
directLoc = strings.SplitN(s, `"`, 2)[0]
}
}
if directLoc == "" {
t.Fatalf("direct thread not found:\n%s", dq)
}
root := strings.TrimPrefix(directLoc, "/r/alpha/forum/")
if resp := post("dan", directLoc+"/hide", url.Values{"post": {root}, "action": {"hide"}, "reason": {"off-topic"}}); resp.StatusCode != http.StatusSeeOther {
t.Fatalf("hide: status %d", resp.StatusCode)
}
page := get("", directLoc)
if !strings.Contains(page, `class="f-hidden"`) || !strings.Contains(page, "off-topic") {
t.Fatalf("hidden post not collapsed:\n%s", page)
}
// The public log carries the whole story, anonymously readable.
log := get("", "/r/alpha/forum/modlog")
for _, want := range []string{"enable", "approve", "reject", "spam", "hide", "off-topic", "ai-hold", "ai-reject", "hook"} {
if !strings.Contains(log, want) {
t.Fatalf("modlog missing %q:\n%s", want, log)
}
}
}
type notifyCapture struct {
mu sync.Mutex
events []api.NotifyEvent
}
func (n *notifyCapture) Trigger(_ context.Context, e api.NotifyEvent) error {
n.mu.Lock()
defer n.mu.Unlock()
n.events = append(n.events, e)
return nil
}
func (n *notifyCapture) count(workflow, to string) int {
n.mu.Lock()
defer n.mu.Unlock()
c := 0
for _, e := range n.events {
if e.Workflow == workflow && e.To == to {
c++
}
}
return c
}
// countTxn is count narrowed to one idempotency key, for asserting
// that a particular event did NOT reach somebody who got others.
func (n *notifyCapture) countTxn(workflow, to, txn string) int {
n.mu.Lock()
defer n.mu.Unlock()
c := 0
for _, e := range n.events {
if e.Workflow == workflow && e.To == to && e.TxnID == txn {
c++
}
}
return c
}
func (n *notifyCapture) waitFor(t *testing.T, workflow, to string, want int) {
t.Helper()
for i := 0; i < 100; i++ {
if n.count(workflow, to) >= want {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("never saw %d %s → %s events", want, workflow, to)
}
// TestForumSubscriptions: authoring auto-subscribes, replies notify
// subscribers (never the actor), @mentions route to their people, and
// an explicit unsubscribe is honored — and survives posting again.
func TestForumSubscriptions(t *testing.T) {
stores := map[string]string{"alpha": buildStore(t, "subseed")}
capture := ¬ifyCapture{}
srv := ingestAndServeAuthMod(t, stores, t.TempDir(),
func(repo, user, pass string, _ ops.Auth) api.Decision {
if (user == "dan" || user == "eve") && pass == "pw" {
return api.Allowed
}
return api.Denied
}, nil, nil, capture)
client := srv.Client()
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
post := func(user, path string, form url.Values) *http.Response {
req, _ := http.NewRequest("POST", srv.URL+path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth(user, "pw")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
return resp
}
get := func(user, path string) string {
req, _ := http.NewRequest("GET", srv.URL+path, nil)
req.SetBasicAuth(user, "pw")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return string(b)
}
resp := post("dan", "/r/alpha/forum-new", url.Values{
"title": {"Subscribable"}, "body": {"opening, cc @carol\n"}})
loc := resp.Header.Get("Location")
// The mention routed to carol; the author got nothing.
capture.waitFor(t, "quarry-mention", "carol", 1)
if capture.count("quarry-forum-reply", "dan") != 0 {
t.Fatal("author notified about their own opener")
}
// The author auto-subscribed: the page offers unsubscribe.
if page := get("dan", loc); !strings.Contains(page, `name="state" value="unsub"`) {
t.Fatalf("author not auto-subscribed:\n%s", page)
}
// eve replies: dan (subscriber) hears about it, eve does not.
post("eve", loc+"/reply", url.Values{"body": {"first answer\n"}})
capture.waitFor(t, "quarry-forum-reply", "dan", 1)
if capture.count("quarry-forum-reply", "eve") != 0 {
t.Fatal("actor notified about their own reply")
}
// dan unsubscribes; the next reply stays silent for dan...
if resp := post("dan", loc+"/subscribe", url.Values{"state": {"unsub"}}); resp.StatusCode != http.StatusSeeOther {
t.Fatalf("unsubscribe: status %d", resp.StatusCode)
}
post("eve", loc+"/reply", url.Values{"body": {"second answer\n"}})
// eve auto-subscribed by her first reply: she hears about nothing of
// her own, but her subscription proves the fanout ran for this post.
capture.waitFor(t, "quarry-forum-reply", "dan", 1) // still exactly 1
// ...and dan REPLYING AGAIN does not quietly re-subscribe him.
post("dan", loc+"/reply", url.Values{"body": {"my own follow-up\n"}})
capture.waitFor(t, "quarry-forum-reply", "eve", 1) // eve hears dan's reply
post("eve", loc+"/reply", url.Values{"body": {"third answer\n"}})
time.Sleep(200 * time.Millisecond)
if got := capture.count("quarry-forum-reply", "dan"); got != 1 {
t.Fatalf("explicit unsub did not survive dan's later reply: %d events", got)
}
if page := get("dan", loc); !strings.Contains(page, `name="state" value="sub"`) {
t.Fatalf("unsubscribed page does not offer subscribe:\n%s", page)
}
}
// TestForumVoteDatastarPatch: a datastar-flagged vote answers with an
// SSE element patch of the widget (no redirect, no page flash); the
// plain form path keeps its redirect — the enhancement is a layer.
func TestForumVoteDatastarPatch(t *testing.T) {
stores := map[string]string{"alpha": buildStore(t, "dsseed")}
srv := ingestAndServeAuth(t, stores, t.TempDir(), func(repo, user, pass string, _ ops.Auth) api.Decision {
if user == "dan" && pass == "pw" {
return api.Allowed
}
return api.Denied
})
client := srv.Client()
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
form := url.Values{"title": {"DS thread"}, "body": {"x\n"}}
req, _ := http.NewRequest("POST", srv.URL+"/r/alpha/forum-new", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth("dan", "pw")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
loc := resp.Header.Get("Location")
root := strings.TrimPrefix(loc, "/r/alpha/forum/")
vote := url.Values{"post": {root}, "dir": {"up"}}
req, _ = http.NewRequest("POST", srv.URL+loc+"/vote", strings.NewReader(vote.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("datastar-request", "true")
req.SetBasicAuth("dan", "pw")
resp, err = client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK || !strings.HasPrefix(resp.Header.Get("Content-Type"), "text/event-stream") {
t.Fatalf("datastar vote: status %d type %s", resp.StatusCode, resp.Header.Get("Content-Type"))
}
b, _ := io.ReadAll(resp.Body)
body := string(b)
if !strings.Contains(body, "datastar-patch-elements") || !strings.Contains(body, `id="vote-`) ||
!strings.Contains(body, `class="vote-cast"`) || !strings.Contains(body, `data-score="1"`) {
t.Fatalf("patch missing widget state:\n%s", body)
}
// The plain path is untouched: same POST without the header redirects.
req, _ = http.NewRequest("POST", srv.URL+loc+"/vote", strings.NewReader(url.Values{"post": {root}, "dir": {"down"}}.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth("dan", "pw")
resp, err = client.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("plain vote: status %d, want redirect", resp.StatusCode)
}
}
// TestComposerPreviewAndForumHashLinks: the preview endpoint renders
// through the production pipeline (SSE patch), and a forum post that
// mentions another post's hash links to its thread, anchored — with a
// mentioned CHECK-IN carrying its comment as excerpt chrome.
func TestComposerPreviewAndForumHashLinks(t *testing.T) {
stores := map[string]string{"alpha": buildStore(t, "prevseed")}
srv := ingestAndServeAuth(t, stores, t.TempDir(), func(repo, user, pass string, _ ops.Auth) api.Decision {
if user == "dan" && pass == "pw" {
return api.Allowed
}
return api.Denied
})
client := srv.Client()
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
post := func(path string, form url.Values, ds bool) (*http.Response, string) {
req, _ := http.NewRequest("POST", srv.URL+path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if ds {
req.Header.Set("datastar-request", "true")
}
req.SetBasicAuth("dan", "pw")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return resp, string(b)
}
get := func(path string) string {
req, _ := http.NewRequest("GET", srv.URL+path, nil)
req.SetBasicAuth("dan", "pw")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return string(b)
}
// A check-in hash for the preview body.
var tl struct {
Entries []struct {
Kind string `json:"kind"`
Hash string `json:"hash"`
} `json:"entries"`
}
json.Unmarshal([]byte(get("/api/v1/r/alpha/timeline")), &tl)
var ci string
for _, e := range tl.Entries {
if e.Kind == "ci" {
ci = e.Hash
break
}
}
// Preview: markdown renders, the mentioned check-in gets its
// comment as title + visible excerpt — production-pipeline parity.
resp, body := post("/r/alpha/markup-preview", url.Values{
"body": {"**bold** and [" + ci[:12] + "]"}}, true)
// A bare fragment: composer.js sets innerHTML for both the live
// pane and the Preview tab, so no SSE envelope.
if resp.StatusCode != 200 || strings.Contains(body, "datastar-patch-elements") ||
!strings.HasPrefix(resp.Header.Get("Content-Type"), "text/html") {
t.Fatalf("preview: status %d\n%s", resp.StatusCode, body)
}
if !strings.Contains(body, "<strong>bold</strong>") ||
!strings.Contains(body, `class="cref"`) || !strings.Contains(body, "/r/alpha/checkin/") {
t.Fatalf("preview render wrong:\n%s", body)
}
// A post mentioning another POST's hash links to its thread+anchor.
resp, _ = post("/r/alpha/forum-new", url.Values{"title": {"First"}, "body": {"origin\n"}}, false)
first := strings.TrimPrefix(resp.Header.Get("Location"), "/r/alpha/forum/")
post("/r/alpha/forum-new", url.Values{"title": {"Second"}, "body": {"see [" + first + "] for context\n"}}, false)
// Find the second thread and check the rendered link.
list := get("/r/alpha/forum")
var secondLoc string
for _, line := range strings.Split(list, "\n") {
if strings.Contains(line, "Second") && strings.Contains(line, "/forum/") {
secondLoc = strings.SplitN(strings.SplitN(line, `href="`, 2)[1], `"`, 2)[0]
}
}
page := get(secondLoc)
if !strings.Contains(page, `href="/r/alpha/forum/`+first[:16]+`#p-`) {
t.Fatalf("forum hash not linked to thread anchor:\n%s", page)
}
}
// TestLivePreviewPlainFragment: composer.js's live pane endpoint — a
// bare sanitized fragment, and a poll block previews as the widget it
// will become, not a code fence.
func TestLivePreviewPlainFragment(t *testing.T) {
stores := map[string]string{"alpha": buildStore(t, "liveseed")}
srv := ingestAndServeAuth(t, stores, t.TempDir(), func(repo, user, pass string, _ ops.Auth) api.Decision {
if user == "dan" && pass == "pw" {
return api.Allowed
}
return api.Denied
})
body := "Intro prose.\n\n```poll\nquestion: Ship it <now>?\n- yes\n- no\n```\n"
form := url.Values{"body": {body}}
req, _ := http.NewRequest("POST", srv.URL+"/r/alpha/markup-preview?plain=1", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth("dan", "pw")
resp, err := srv.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
out := string(b)
if !strings.HasPrefix(resp.Header.Get("Content-Type"), "text/html") || strings.Contains(out, "datastar") {
t.Fatalf("plain fragment wrong shape: %s\n%s", resp.Header.Get("Content-Type"), out)
}
if !strings.Contains(out, `class="f-pollprev"`) || strings.Contains(out, "```") {
t.Fatalf("poll block did not preview as widget:\n%s", out)
}
// Draft content is escaped — it is the least trusted input there is.
if !strings.Contains(out, "Ship it <now>?") {
t.Fatalf("question not escaped:\n%s", out)
}
if !strings.Contains(out, "<p>Intro prose.</p>") {
t.Fatalf("prose around the poll lost:\n%s", out)
}
}
// TestBranchRootKeepsAboutAside: switching branches at the repo root
// must not drop the About column — but the doc TABS stay default-ref
// only (they render default-branch docs), and the branch's own README
// renders plainly below.
func TestBranchRootKeepsAboutAside(t *testing.T) {
stores := map[string]string{"alpha": buildStore(t, "aboutseed")}
srv := ingestAndServe(t, stores, t.TempDir())
get := func(path string) string {
resp, err := srv.Client().Get(srv.URL + path)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return string(b)
}
home := get("/r/alpha")
if !strings.Contains(home, `class="about-card"`) {
t.Fatalf("default root lost the About aside:\n%s", home)
}
branch := get("/r/alpha/tree/trunk/")
if !strings.Contains(branch, `class="about-card"`) {
t.Fatalf("branch root has no About aside:\n%s", branch)
}
// The branch view keeps its own plain README, not the tab panel.
if !strings.Contains(branch, "store aboutseed") {
t.Fatalf("branch README missing:\n%s", branch)
}
// Deeper paths stay working views: no About aside.
if deep := get("/r/alpha/tree/trunk/src/"); strings.Contains(deep, `class="about-card"`) {
t.Fatalf("deep path grew the About aside:\n%s", deep)
}
}
// TestTopicPillsFilterTheFleet: a topic pill links to /?q=<topic>; the
// landing filter must FIND repositories by their topics, not only by
// name substring.
func TestTopicPillsFilterTheFleet(t *testing.T) {
stores := map[string]string{"alpha": buildStore(t, "topicseed"), "beta": buildStore(t, "topicbeta")}
dataDir := t.TempDir()
srv := ingestAndServe(t, stores, dataDir)
// Register alpha with topics the way settings would store them.
odb, err := oltp.Open(filepath.Join(dataDir, "oltp.db"))
if err != nil {
t.Fatal(err)
}
defer odb.Close()
if err := odb.EnsureRepo("alpha", stores["alpha"]); err != nil {
t.Fatal(err)
}
if err := odb.SetRepoTopics("alpha", "forge zig"); err != nil {
t.Fatal(err)
}
get := func(path string) string {
resp, err := srv.Client().Get(srv.URL + path)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return string(b)
}
page := get("/?q=zig")
if !strings.Contains(page, `href="/r/alpha"`) {
t.Fatalf("topic did not find its repo:\n%s", page)
}
if strings.Contains(page, `href="/r/beta"`) {
t.Fatalf("untagged repo matched the topic:\n%s", page)
}
// Topics match as whole words, not substrings.
if page := get("/?q=zi"); strings.Contains(page, `href="/r/alpha"`) {
t.Fatal("topic matched by prefix")
}
}
// TestReplyDAGAndDirectNotification: replies target SPECIFIC posts
// (fossil's reply DAG, not a linear fake), the page joins each reply to
// its parent's author, and the answered author is notified even after
// unsubscribing from the thread.
func TestReplyDAGAndDirectNotification(t *testing.T) {
stores := map[string]string{"alpha": buildStore(t, "dagseed")}
capture := ¬ifyCapture{}
srv := ingestAndServeAuthMod(t, stores, t.TempDir(),
func(repo, user, pass string, _ ops.Auth) api.Decision {
if (user == "dan" || user == "eve") && pass == "pw" {
return api.Allowed
}
return api.Denied
}, nil, nil, capture)
client := srv.Client()
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
post := func(user, path string, form url.Values) *http.Response {
req, _ := http.NewRequest("POST", srv.URL+path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth(user, "pw")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
return resp
}
get := func(path string) string {
req, _ := http.NewRequest("GET", srv.URL+path, nil)
req.SetBasicAuth("dan", "pw")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return string(b)
}
resp := post("dan", "/r/alpha/forum-new", url.Values{"title": {"DAG"}, "body": {"root\n"}})
loc := resp.Header.Get("Location")
post("eve", loc+"/reply", url.Values{"body": {"first answer\n"}})
// dan finds eve's reply and answers IT, not the root.
page := get(loc)
m := regexp.MustCompile(`id="p-([0-9a-f]{12})"`).FindAllStringSubmatch(page, -1)
if len(m) < 2 {
t.Fatalf("thread has %d posts", len(m))
}
evePost := m[1][1]
// The aimed composer names its target post.
aimed := get(loc + "?reply-to=" + evePost)
if !strings.Contains(aimed, `name="to" value="`+evePost+`"`) {
t.Fatalf("composer not aimed at the post:\n%s", aimed)
}
// dan unsubscribes — the DIRECT reply must still reach eve... and
// eve unsubscribes too, proving the replied-to path alone delivers.
post("eve", loc+"/subscribe", url.Values{"state": {"unsub"}})
before := capture.count("quarry-forum-reply", "eve")
if resp := post("dan", loc+"/reply", url.Values{"body": {"answering eve directly\n"}, "to": {evePost}}); resp.StatusCode != http.StatusSeeOther {
t.Fatalf("aimed reply: status %d", resp.StatusCode)
}
capture.waitFor(t, "quarry-forum-reply", "eve", before+1)
// The page joins the reply to its PARENT: a link to eve's post
// carrying eve's name, not the root's.
page = get(loc)
if !strings.Contains(page, `href="#p-`+evePost+`"`) {
t.Fatalf("reply not linked to its parent post:\n%s", page)
}
last := page[strings.LastIndex(page, `href="#p-`+evePost+`"`):]
if !strings.Contains(last[:200], "eve") {
t.Fatalf("parent link does not name its author:\n%s", last[:200])
}
}