package harness
import (
"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"},
}, 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)
}
if body := get("/r/alpha/timeline"); !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)
}
}