mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
A teammate's agent writes a decision into runbook.md, it lands in your folder in fifteen seconds, and nothing tells you. Two halves of the fix: post_sync's payload gains `user` and `note` per changed entry, so a local recipe can post "Dana's Claude updated shared/findings/eu-checkout.md" instead of "1 file changed" — the copy was blocked by the payload, not by formatting. journal.LastOps is what makes it possible: Replay drops deleted paths and FileState carries no author, so deletes had nothing to attribute to. Less, Replay and FileState are untouched. And a hub-side webhook: one optional Webhook on Project, admin-set, https on Slack/Teams hosts only, never returned by any API. It fires AFTER the response from both places an op is born — handleStorePut and RemoteSource.OnCommit at the end of appendOps, which covers uploads, removes, restores and undo-run in one field. Goroutine, bounded client, log once, no retry queue: a slow Slack must never 502 a push, because the client retries a 502 and the retry re-fires the webhook. A project with no webhook set produces zero new behavior and zero new outbound requests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
437 lines
17 KiB
Go
437 lines
17 KiB
Go
package webapp
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestPermRankAndAtLeast(t *testing.T) {
|
|
// An unknown level must fail closed — it is the answer for a corrupt
|
|
// grant, and reading it as anything but "none" would open a hole.
|
|
for _, l := range []string{"", "none", "bogus", "Admin"} {
|
|
if permRank(l) != 0 {
|
|
t.Errorf("permRank(%q) = %d, want 0", l, permRank(l))
|
|
}
|
|
}
|
|
if !(permRank(PermRead) < permRank(PermWrite) && permRank(PermWrite) < permRank(PermAdmin)) {
|
|
t.Fatal("levels are not ordered read < write < admin")
|
|
}
|
|
if !atLeast(PermAdmin, PermWrite) || !atLeast(PermWrite, PermWrite) || atLeast(PermRead, PermWrite) {
|
|
t.Fatal("atLeast is wrong")
|
|
}
|
|
}
|
|
|
|
// permHub builds an org hub where alice owns the org, bob and carol are plain
|
|
// members, and dave is in another org entirely. The project is alice's.
|
|
func permHub(t *testing.T) (h http.Handler, srv *Server, cookies map[string]*http.Cookie, p Project) {
|
|
h, srv, cookies, p, _ = permHubAt(t)
|
|
return
|
|
}
|
|
|
|
// permHubAt is permHub plus the storage root, for tests that seed a journal
|
|
// through newFakeRemoteAt.
|
|
func permHubAt(t *testing.T) (h http.Handler, srv *Server, cookies map[string]*http.Cookie, p Project, root string) {
|
|
t.Helper()
|
|
srv, _, root = newHub(t, true, nil)
|
|
auth, err := OpenBuiltinAuth(filepath.Join(t.TempDir(), "auth.json"), true, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srv.Auth = auth
|
|
orgs, err := OpenOrgDB(filepath.Join(t.TempDir(), "orgs.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srv.Dir = LocalDirectory{OrgDB: orgs}
|
|
shares, err := OpenShareDB(filepath.Join(t.TempDir(), "shares.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srv.Shares = shares
|
|
// A served hub always has one (cmd/bdrive/web.go), and without it every
|
|
// device-ownership decision in store.go returns before it can decide
|
|
// anything — so a journal push through this fixture would measure
|
|
// org/project permission only. See TestSec_Audit_PermHubRefusesAForeign
|
|
// JournalOutOfTheBox.
|
|
devices, err := OpenDeviceRegistry(filepath.Join(t.TempDir(), "devices.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srv.Devices = devices
|
|
h = srv.Handler()
|
|
|
|
cookies = map[string]*http.Cookie{}
|
|
for _, who := range []string{"alice", "bob", "carol", "dave"} {
|
|
cookies[who] = signupAndSession(t, h, who+"@x.io", strings.ToUpper(who[:1])+who[1:], "password1")
|
|
}
|
|
|
|
rec := doAs(t, h, "POST", "/api/projects", map[string]string{"name": "wiki"}, cookies["alice"])
|
|
if rec.Code != 200 {
|
|
t.Fatalf("create project: %d %s", rec.Code, rec.Body)
|
|
}
|
|
var out struct {
|
|
Project Project `json:"project"`
|
|
}
|
|
json.Unmarshal(rec.Body.Bytes(), &out)
|
|
p = out.Project
|
|
for _, who := range []string{"bob", "carol"} {
|
|
if err := orgs.AddMember(p.Org, who+"@x.io", RoleMember); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
return h, srv, cookies, p, root
|
|
}
|
|
|
|
// Nothing changes for an existing hub: with no permission edits, every org
|
|
// member still has full read+write on every project.
|
|
func TestDefaultIsWriteForEveryMember(t *testing.T) {
|
|
h, _, c, p := permHub(t)
|
|
if rec := doAs(t, h, "GET", "/api/p/"+p.ID+"/tree", nil, c["bob"]); rec.Code != 200 {
|
|
t.Fatalf("member read: %d %s", rec.Code, rec.Body)
|
|
}
|
|
if rec := doAs(t, h, "PUT", "/api/p/"+p.ID+"/store/object?key=journal/d.jsonl", []byte("{}"), c["bob"]); rec.Code == http.StatusForbidden {
|
|
t.Fatalf("member write refused by default: %s", rec.Body)
|
|
}
|
|
// and an outsider is still walled out
|
|
if rec := doAs(t, h, "GET", "/api/p/"+p.ID+"/tree", nil, c["dave"]); rec.Code != http.StatusForbidden {
|
|
t.Fatalf("outsider read: %d, want 403", rec.Code)
|
|
}
|
|
}
|
|
|
|
// The creator of a project becomes its first admin — unless they are an org
|
|
// owner, who is implicitly admin and needs no grant.
|
|
func TestCreatorBecomesAdmin(t *testing.T) {
|
|
h, srv, c, p := permHub(t)
|
|
// alice created it as an org owner: implicit admin, no explicit grant.
|
|
if got, _ := srv.Projects.Get(p.ID); got.Creator != "alice@x.io" {
|
|
t.Fatalf("creator = %q, want alice@x.io", got.Creator)
|
|
}
|
|
// bob, a plain member, creates one: he gets the explicit admin grant.
|
|
rec := doAs(t, h, "POST", "/api/projects", map[string]any{"name": "bobs", "org": p.Org}, c["bob"])
|
|
if rec.Code != 200 {
|
|
t.Fatalf("bob create: %d %s", rec.Code, rec.Body)
|
|
}
|
|
var out struct {
|
|
Project map[string]any `json:"project"`
|
|
}
|
|
json.Unmarshal(rec.Body.Bytes(), &out)
|
|
if out.Project["perm"] != PermAdmin {
|
|
t.Fatalf("creator's own level = %v, want admin", out.Project["perm"])
|
|
}
|
|
bp, _ := srv.Projects.Get(out.Project["id"].(string))
|
|
if bp.Perms["bob@x.io"] != PermAdmin {
|
|
t.Fatalf("creator grant = %+v", bp.Perms)
|
|
}
|
|
// and a plain member who is a project admin can rename and delete it —
|
|
// this used to be org-owner-only.
|
|
id := out.Project["id"].(string)
|
|
if rec := doAs(t, h, "PATCH", "/api/projects/"+id, map[string]string{"name": "bobs2"}, c["bob"]); rec.Code != 200 {
|
|
t.Fatalf("project admin rename: %d %s", rec.Code, rec.Body)
|
|
}
|
|
if rec := doAs(t, h, "DELETE", "/api/projects/"+id, nil, c["bob"]); rec.Code != 200 {
|
|
t.Fatalf("project admin delete: %d %s", rec.Code, rec.Body)
|
|
}
|
|
}
|
|
|
|
// A read grant admits every read route and refuses every write route.
|
|
func TestReadOnlyMemberRoutes(t *testing.T) {
|
|
h, srv, c, p := permHub(t)
|
|
if err := srv.Projects.SetPerm(p.ID, "bob@x.io", PermRead); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
base := "/api/p/" + p.ID + "/"
|
|
// A live link to re-date: PATCH /api/shares/{token} resolves the token
|
|
// before the level check, so an unknown one would 404 instead of 403.
|
|
sh, err := srv.Shares.Create(p.ID, "x.md", "alice@x.io", 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writes := []struct {
|
|
method, url string
|
|
body any
|
|
}{
|
|
{"PATCH", "/api/shares/" + sh.Token, map[string]string{"expires_in": "24h"}},
|
|
{"POST", base + "upload/init", map[string]any{"path": "x.md", "sha256": strings.Repeat("a", 64), "size": 1}},
|
|
{"PUT", base + "upload/content?path=x.md", []byte("hi")},
|
|
{"POST", base + "upload/commit", map[string]any{"path": "x.md", "sha256": strings.Repeat("a", 64), "size": 1}},
|
|
{"PUT", base + "store/object?key=journal/d.jsonl", []byte("{}")},
|
|
{"POST", base + "store/sign", map[string]any{"key": "blobs/" + strings.Repeat("a", 64), "size": 1}},
|
|
{"POST", base + "shares", map[string]string{"path": "x.md"}},
|
|
{"POST", base + "restore", map[string]string{"path": "x.md", "sha": strings.Repeat("a", 64)}},
|
|
{"PATCH", "/api/projects/" + p.ID, map[string]string{"name": "nope"}},
|
|
{"DELETE", "/api/projects/" + p.ID, nil},
|
|
{"PUT", base + "permissions", map[string]string{"default": "read"}},
|
|
{"PUT", base + "permissions/carol@x.io", map[string]string{"level": "read"}},
|
|
{"DELETE", base + "permissions/carol@x.io", nil},
|
|
}
|
|
for _, rt := range writes {
|
|
if rec := doAs(t, h, rt.method, rt.url, rt.body, c["bob"]); rec.Code != http.StatusForbidden {
|
|
t.Errorf("%s %s as read-only: %d, want 403", rt.method, rt.url, rec.Code)
|
|
}
|
|
}
|
|
reads := []struct{ method, url string }{
|
|
{"GET", base + "tree"},
|
|
{"GET", base + "file?path=x.md"},
|
|
{"GET", base + "download?path=x.md"},
|
|
{"GET", base + "render?path=x.md"},
|
|
{"GET", base + "history"},
|
|
{"GET", base + "blob?sha=" + strings.Repeat("a", 64)},
|
|
{"GET", base + "heat"},
|
|
{"GET", base + "shares"},
|
|
{"GET", base + "store/list?prefix=journal/"},
|
|
{"GET", base + "store/object?key=journal/d.jsonl"},
|
|
{"GET", base + "store/exists?key=journal/d.jsonl"},
|
|
{"GET", base + "permissions"},
|
|
}
|
|
for _, rt := range reads {
|
|
if rec := doAs(t, h, rt.method, rt.url, nil, c["bob"]); rec.Code == http.StatusForbidden {
|
|
t.Errorf("%s %s as read-only: 403, want access (%s)", rt.method, rt.url, rec.Body)
|
|
}
|
|
}
|
|
if rec := doAs(t, h, "POST", base+"reads", map[string]any{"reads": []any{}}, c["bob"]); rec.Code == http.StatusForbidden {
|
|
t.Errorf("read report as read-only: 403, want access")
|
|
}
|
|
// a read member still sees the project and can open it
|
|
if rec := doAs(t, h, "GET", "/api/projects", nil, c["bob"]); !strings.Contains(rec.Body.String(), p.ID) {
|
|
t.Error("read-only member does not see the project in the list")
|
|
}
|
|
}
|
|
|
|
// A none grant is treated exactly like a non-member: hidden from the list,
|
|
// 403 everywhere.
|
|
func TestNoAccessMemberIsInvisible(t *testing.T) {
|
|
h, srv, c, p := permHub(t)
|
|
if err := srv.Projects.SetPerm(p.ID, "bob@x.io", PermNone); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
base := "/api/p/" + p.ID + "/"
|
|
for _, url := range []string{"tree", "history", "heat", "shares", "permissions", "store/list?prefix=journal/"} {
|
|
if rec := doAs(t, h, "GET", base+url, nil, c["bob"]); rec.Code != http.StatusForbidden {
|
|
t.Errorf("GET %s as none: %d, want 403", url, rec.Code)
|
|
}
|
|
}
|
|
if rec := doAs(t, h, "GET", "/api/projects", nil, c["bob"]); strings.Contains(rec.Body.String(), p.ID) {
|
|
t.Error("a none member sees the project in the list")
|
|
}
|
|
// create-or-join by name must not hand the id back either
|
|
if rec := doAs(t, h, "POST", "/api/projects", map[string]any{"name": p.Name, "org": p.Org}, c["bob"]); rec.Code != http.StatusForbidden {
|
|
t.Errorf("join-by-name as none: %d, want 403", rec.Code)
|
|
}
|
|
// carol, with no explicit grant, is unaffected
|
|
if rec := doAs(t, h, "GET", base+"tree", nil, c["carol"]); rec.Code != 200 {
|
|
t.Errorf("carol: %d %s", rec.Code, rec.Body)
|
|
}
|
|
}
|
|
|
|
// Default none makes a project invite-only: only explicit grants and org
|
|
// owners get in.
|
|
func TestInviteOnlyDefault(t *testing.T) {
|
|
h, srv, c, p := permHub(t)
|
|
if rec := doAs(t, h, "PUT", "/api/p/"+p.ID+"/permissions", map[string]string{"default": "none"}, c["alice"]); rec.Code != 200 {
|
|
t.Fatalf("set default: %d %s", rec.Code, rec.Body)
|
|
}
|
|
if err := srv.Projects.SetPerm(p.ID, "bob@x.io", PermRead); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
base := "/api/p/" + p.ID + "/tree"
|
|
if rec := doAs(t, h, "GET", base, nil, c["carol"]); rec.Code != http.StatusForbidden {
|
|
t.Errorf("carol with default none: %d, want 403", rec.Code)
|
|
}
|
|
if rec := doAs(t, h, "GET", base, nil, c["bob"]); rec.Code != 200 {
|
|
t.Errorf("bob with an explicit read grant: %d %s", rec.Code, rec.Body)
|
|
}
|
|
if rec := doAs(t, h, "GET", base, nil, c["alice"]); rec.Code != 200 {
|
|
t.Errorf("org owner locked out by default none: %d", rec.Code)
|
|
}
|
|
// admin is not a legal default
|
|
if rec := doAs(t, h, "PUT", "/api/p/"+p.ID+"/permissions", map[string]string{"default": "admin"}, c["alice"]); rec.Code != http.StatusBadRequest {
|
|
t.Errorf("default admin: %d, want 400", rec.Code)
|
|
}
|
|
}
|
|
|
|
// An org owner always resolves to admin, whatever the grant list says, and a
|
|
// grant naming one is refused rather than silently ignored.
|
|
func TestOrgOwnerAlwaysAdmin(t *testing.T) {
|
|
h, srv, c, p := permHub(t)
|
|
if err := srv.Projects.SetPerm(p.ID, "bob@x.io", PermAdmin); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// bob (a project admin) tries to cut alice, the org owner, out
|
|
rec := doAs(t, h, "PUT", "/api/p/"+p.ID+"/permissions/alice@x.io", map[string]string{"level": "none"}, c["bob"])
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("grant on an org owner: %d, want 400", rec.Code)
|
|
}
|
|
if rec := doAs(t, h, "GET", "/api/p/"+p.ID+"/tree", nil, c["alice"]); rec.Code != 200 {
|
|
t.Fatalf("org owner locked out: %d", rec.Code)
|
|
}
|
|
// even a hand-written grant in storage cannot outrank her
|
|
if err := srv.Projects.SetPerm(p.ID, "alice@x.io", PermNone); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rec := doAs(t, h, "DELETE", "/api/projects/"+p.ID, nil, c["alice"]); rec.Code != 200 {
|
|
t.Fatalf("org owner delete after a none grant: %d %s", rec.Code, rec.Body)
|
|
}
|
|
}
|
|
|
|
// The last explicit admin cannot be removed or demoted — including by
|
|
// themselves.
|
|
func TestLastProjectAdminHeld(t *testing.T) {
|
|
h, srv, c, p := permHub(t)
|
|
if err := srv.Projects.SetPerm(p.ID, "bob@x.io", PermAdmin); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, tc := range []struct {
|
|
method, url string
|
|
body any
|
|
}{
|
|
{"PUT", "/api/p/" + p.ID + "/permissions/bob@x.io", map[string]string{"level": "none"}},
|
|
{"PUT", "/api/p/" + p.ID + "/permissions/bob@x.io", map[string]string{"level": "read"}},
|
|
{"DELETE", "/api/p/" + p.ID + "/permissions/bob@x.io", nil},
|
|
} {
|
|
if rec := doAs(t, h, tc.method, tc.url, tc.body, c["bob"]); rec.Code != http.StatusBadRequest {
|
|
t.Errorf("%s %s: %d, want 400", tc.method, tc.url, rec.Code)
|
|
}
|
|
if got, _ := srv.Projects.Get(p.ID); got.Perms["bob@x.io"] != PermAdmin {
|
|
t.Fatalf("last admin changed anyway: %+v", got.Perms)
|
|
}
|
|
}
|
|
// with a second admin, the first can step down
|
|
if rec := doAs(t, h, "PUT", "/api/p/"+p.ID+"/permissions/carol@x.io", map[string]string{"level": "admin"}, c["bob"]); rec.Code != 200 {
|
|
t.Fatalf("grant second admin: %d %s", rec.Code, rec.Body)
|
|
}
|
|
if rec := doAs(t, h, "DELETE", "/api/p/"+p.ID+"/permissions/bob@x.io", nil, c["bob"]); rec.Code != 200 {
|
|
t.Fatalf("step down with another admin present: %d %s", rec.Code, rec.Body)
|
|
}
|
|
}
|
|
|
|
// Grants are org members only.
|
|
func TestGrantsAreOrgMembersOnly(t *testing.T) {
|
|
h, _, c, p := permHub(t)
|
|
rec := doAs(t, h, "PUT", "/api/p/"+p.ID+"/permissions/dave@x.io", map[string]string{"level": "read"}, c["alice"])
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("grant to a non-member: %d, want 400", rec.Code)
|
|
}
|
|
rec = doAs(t, h, "PUT", "/api/p/"+p.ID+"/permissions/bob@x.io", map[string]string{"level": "bogus"}, c["alice"])
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("unknown level: %d, want 400", rec.Code)
|
|
}
|
|
}
|
|
|
|
// GET /permissions reports the default, the caller's own level, and grants.
|
|
func TestPermissionsGET(t *testing.T) {
|
|
h, srv, c, p := permHub(t)
|
|
if err := srv.Projects.SetPerm(p.ID, "bob@x.io", PermRead); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rec := doAs(t, h, "GET", "/api/p/"+p.ID+"/permissions", nil, c["bob"])
|
|
if rec.Code != 200 {
|
|
t.Fatalf("GET permissions as a read member: %d %s", rec.Code, rec.Body)
|
|
}
|
|
var out struct {
|
|
Default string `json:"default"`
|
|
Me string `json:"me"`
|
|
Grants []map[string]string `json:"grants"`
|
|
}
|
|
json.Unmarshal(rec.Body.Bytes(), &out)
|
|
if out.Default != PermWrite || out.Me != PermRead {
|
|
t.Fatalf("default=%q me=%q, want write/read", out.Default, out.Me)
|
|
}
|
|
if len(out.Grants) != 1 || out.Grants[0]["email"] != "bob@x.io" {
|
|
t.Fatalf("grants = %+v", out.Grants)
|
|
}
|
|
}
|
|
|
|
// A read member sees the People matrix and the project's public links, creator
|
|
// included. Deliberate, and answered by the owner (BEA-69, 2026-08-03): the
|
|
// benchmark is Google Drive, where a viewer can see who has access — hiding
|
|
// grants makes "why can't I edit this?" unanswerable, and the per-file
|
|
// ShareBanner already tells a read-only member the file they rely on is
|
|
// public. TestReadOnlyMemberRoutes only checks these two routes are not 403,
|
|
// so an "improvement" that returned an empty body to read members would pass
|
|
// every other test in the repo. Tightening either route to PermWrite is a
|
|
// product decision, not a hardening fix; this test is here so it cannot happen
|
|
// by accident.
|
|
func TestReadMemberSeesSharesAndGrants(t *testing.T) {
|
|
h, srv, c, p := permHub(t)
|
|
if err := srv.Projects.SetPerm(p.ID, "bob@x.io", PermRead); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sh, err := srv.Shares.Create(p.ID, "x.md", "alice@x.io", 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rec := doAs(t, h, "GET", "/api/p/"+p.ID+"/shares", nil, c["bob"])
|
|
if rec.Code != 200 {
|
|
t.Fatalf("GET shares as a read member: %d %s", rec.Code, rec.Body)
|
|
}
|
|
var out struct {
|
|
Shares []map[string]any `json:"shares"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(out.Shares) != 1 {
|
|
t.Fatalf("shares = %+v, want the one link alice minted", out.Shares)
|
|
}
|
|
got := out.Shares[0]
|
|
if got["token"] != sh.Token || got["path"] != "x.md" {
|
|
t.Errorf("share = %+v, want token %q on x.md", got, sh.Token)
|
|
}
|
|
if url, _ := got["url"].(string); !strings.HasSuffix(url, "/s/"+sh.Token) {
|
|
t.Errorf("url = %q, want it to end in /s/%s", url, sh.Token)
|
|
}
|
|
// The field the issue is actually about, and the one a hardening pass
|
|
// would strip first. It exposes nothing new: the People table already
|
|
// shows every member's email to every member.
|
|
if got["creator"] != "alice@x.io" {
|
|
t.Errorf("creator = %v, want alice@x.io", got["creator"])
|
|
}
|
|
// And the grants alongside it, from the same read-only session.
|
|
rec = doAs(t, h, "GET", "/api/p/"+p.ID+"/permissions", nil, c["bob"])
|
|
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "bob@x.io") {
|
|
t.Fatalf("GET permissions as a read member: %d %s", rec.Code, rec.Body)
|
|
}
|
|
}
|
|
|
|
// The project list carries the caller's level *alongside* every ordinary
|
|
// Project field. Regression guard: an earlier version hand-listed the fields
|
|
// it returned, which silently dropped description and icon the moment those
|
|
// were added — the client saw a project with no metadata and no error.
|
|
func TestProjectListCarriesWholeProject(t *testing.T) {
|
|
h, srv, c, p := permHub(t)
|
|
desc, icon := "everything support needs", "book-open"
|
|
if err := srv.Projects.Update(p.ID, nil, &desc, &icon, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rec := doAs(t, h, "GET", "/api/projects", nil, c["alice"])
|
|
var out struct {
|
|
Projects []map[string]any `json:"projects"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var row map[string]any
|
|
for _, r := range out.Projects {
|
|
if r["id"] == p.ID {
|
|
row = r
|
|
}
|
|
}
|
|
if row == nil {
|
|
t.Fatalf("project missing from the list: %s", rec.Body)
|
|
}
|
|
for key, want := range map[string]any{
|
|
"name": p.Name, "description": desc, "icon": icon, "perm": PermAdmin,
|
|
} {
|
|
if row[key] != want {
|
|
t.Errorf("list row %q = %v, want %v", key, row[key], want)
|
|
}
|
|
}
|
|
// The grant list is not list-response material — /permissions owns it.
|
|
if _, leaked := row["perms"]; leaked {
|
|
t.Errorf("grant list leaked into the project list: %v", row["perms"])
|
|
}
|
|
}
|