Skip to content
quarry Artifact [367e82af42]
Login

Artifact [367e82af42]

Artifact 367e82af423afa392b830c85b02c2971722e16109e0951c09ba3ee55d852e6aa:


package harness

import (
	"encoding/json"
	"io"
	"net/http"
	"net/url"
	"path/filepath"
	"strings"
	"testing"

	"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)
	}
}