Artifact e68fcb22240de1aad270b5518d0aab379ba3b0285c66f96a3dc47c69f664b175:
- File
internal/harness/write_test.go
— part of check-in
[bf5ba9d2fc]
at
2026-08-16 17:07:31
on branch trunk
— oltp: accounts and per-repo roles
0001 §8's v2 tables. Accounts are the web/API identity (argon2id verifier, site-admin flag, read/write/admin per repo); the RFC §6.3 shared secret stays the sync credential, since the protocol fixes its derivation and the server must recompute it.
ops.Auth becomes the required role, so the Admin level that was already declared now means something. The gate stays open until the first account exists, mirroring the sync-user policy, so existing deployments and local single-user serving are unaffected. (user: dmestas, size: 10192) [annotate] [blame] [check-ins using]
package harness import ( "io" "net/http" "net/url" "path/filepath" "strings" "testing" "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) bool { return repo == "alpha" && user == "dan" && pass == "hunter2" }) 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) bool { return user == "dan" && pass == "pw" }) 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) bool { if !db.HasAccounts() { return true } acct, ok := db.Authenticate(user, pass) if !ok { return false } var want oltp.Role switch need { case ops.Admin: want = oltp.RepoAdmin case ops.Token: want = oltp.Write } return db.RoleOf(acct, repo) >= want } 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. if got := save("reader", "pw1", "R3", "read-only tried to write"); got != http.StatusUnauthorized { t.Errorf("read-only account wrote: status %d, want 401", 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) } }