Artifact 979ed733ce97d8ab1568414cf2c56ba520a96df6b2024ceecd295968155bf818:
- File
internal/harness/write_test.go
— part of check-in
[893885b3d5]
at
2026-08-21 19:49:17
on branch quarry-forum
— forum subscriptions: auto-sub on authoring, reply/mention fanout, unsub sticks
Fanout rides the write path only (a reindex can never re-notify); delivery/dedup stays in the notify companion via TxnID. (user: dmestas, size: 42659) [annotate] [blame] [check-ins using] [more...]
package harness import ( "context" "encoding/json" "io" "net/http" "net/url" "path/filepath" "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) for _, want := range []string{ `name="priority" value="High"`, `name="severity" value="Severe"`, `name="type" value="Code_Defect"`, `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, "in reply 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 technote, then edit it: the edit supersedes, and the event // id is stable across the edit (one technote, not two). resp = post("/r/alpha/technote-new", url.Values{"body": {"# Note\n\nfirst version\n"}}, 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) } if resp := post(noteLoc+"/edit", url.Values{"body": {"# Note\n\nsecond version\n"}}, true); resp.StatusCode != http.StatusSeeOther { t.Fatalf("technote edit: status %d", resp.StatusCode) } if b := get(noteLoc); !strings.Contains(b, "second version") || strings.Contains(b, "first version") { t.Fatalf("edit did not supersede:\n%s", b) } if list := get("/r/alpha/technote"); strings.Count(list, "<li>") != 1 { t.Fatalf("edit created a second technote:\n%s", list) } } // 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, "▲") || strings.Contains(body, "score") { 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, "▲") { 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, "score 2") { t.Fatalf("anonymous page missing derived score:\n%s", body) } if body := get("dan", loc); !strings.Contains(body, "retract upvote") { t.Fatalf("cast arrow not lit for dan:\n%s", body) } if body := get("", "/r/alpha/forum"); !strings.Contains(body, "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, "score") { 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, "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, "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, "age_hours + 2") { t.Fatalf("hot view does not print its formula:\n%s", hot) } if strings.Contains(recent, "age_hours") { 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, ">announcement</span>") || !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, "new announcement") { t.Fatal("maintainer missing announce control") } for _, u := range []string{"eve", ""} { if s := get(u, "/r/alpha/forum"); strings.Contains(s, "new announcement") { 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, "hidden by a moderator — off-topic") || !strings.Contains(page, "<details>") { 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", "recorded here permanently"} { 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 } 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, "unsubscribe") { 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, ">subscribe<") { t.Fatalf("unsubscribed page does not offer subscribe:\n%s", page) } }