Artifact 2eebb3becb2710441c249955b25d3c02ef249ed3d99ac39619be022dd3510d8e:
- File
internal/harness/write_test.go
— part of check-in
[86880118d5]
at
2026-08-18 02:09:54
on branch trunk
— account: manage identity through the API, and stop lying about why
Six ops (account.list/add/passwd/delete/grant/revoke, admin) plus quarry accounts on top of them. Creating a login or granting someone read on a repository no longer needs shell access on the server.
quarry account still opens OLTP directly and has to: the FIRST account must exist before any server runs or any admin exists to authenticate as. Everything after that is the API, so the CLI has no privileged path.
The verifier never crosses the seam in either direction, and account.add /passwd scrub the password out of any error before it travels — an error is the one value that reaches a log, an HTTP body and a terminal at once.
Verify returns a three-valued Decision instead of a bool. Denied and Insufficient are not the same thing and the wire has separate codes for them: an authenticated client short of a capability was being told 401 with a challenge, which invites it to retry the same password forever. Now 403, and deliberately without a challenge. A harness test pinned the old behaviour and was updated with the reason. (user: dmestas, size: 12884) [annotate] [blame] [check-ins using]
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) } }