| 1 | diff --git a/internal/daemon/wiki_approvals_test.go b/internal/daemon/wiki_approvals_test.go |
| 2 | new file mode 100644 |
| 3 | index 0000000..d87da5d |
| 4 | --- /dev/null |
| 5 | +++ b/internal/daemon/wiki_approvals_test.go |
| 6 | @@ -0,0 +1,251 @@ |
| 7 | +package daemon |
| 8 | + |
| 9 | +import ( |
| 10 | + "context" |
| 11 | + "encoding/json" |
| 12 | + "net/http" |
| 13 | + "strings" |
| 14 | + "testing" |
| 15 | + "time" |
| 16 | +) |
| 17 | + |
| 18 | +// wikiProposalTenant grants the agent key wiki access and returns the |
| 19 | +// owner, agent, moderator and member keys. |
| 20 | +func wikiProposalTenant(t *testing.T, access string) (*App, *Tenant, string, string, string, string) { |
| 21 | + t.Helper() |
| 22 | + app, tenant := testTenant(t) |
| 23 | + owner := tenant.Policy().Owner |
| 24 | + agent, moderator, member := wikiKey(t, testAgentSecret), wikiKey(t, testModSecret), wikiKey(t, wikiMemberSecret) |
| 25 | + setRole(t, tenant, moderator, "moderator") |
| 26 | + wikiMember(t, tenant, member) |
| 27 | + now := time.Now().Unix() |
| 28 | + if err := publishAs(t, tenant, agentGrantEvent(t, agent, now-1000, now+3600, []string{"wiki", access})); err != nil { |
| 29 | + t.Fatalf("grant rejected: %v", err) |
| 30 | + } |
| 31 | + return app, tenant, owner, agent, moderator, member |
| 32 | +} |
| 33 | + |
| 34 | +func wikiVersionByID(t *testing.T, versions []map[string]any, id string) map[string]any { |
| 35 | + t.Helper() |
| 36 | + for _, v := range versions { |
| 37 | + if v["id"] == id { |
| 38 | + return v |
| 39 | + } |
| 40 | + } |
| 41 | + t.Fatalf("version %s missing from %v", id, versions) |
| 42 | + return nil |
| 43 | +} |
| 44 | + |
| 45 | +func TestWikiProposalStateFollowsOwnerAndModeratorReactions(t *testing.T) { |
| 46 | + _, tenant, owner, agent, moderator, _ := wikiProposalTenant(t, "propose") |
| 47 | + now := time.Now().Unix() |
| 48 | + |
| 49 | + first := wikiArticle(t, tenant, testAgentSecret, "Bitcoin", now-500) |
| 50 | + page := wikiCall(t, tenant, owner, "browsewikipage", map[string]any{"d": "bitcoin"}) |
| 51 | + version := page["version"].(map[string]any) |
| 52 | + if version["id"] != first.ID || version["proposal"] != true || version["approval"] != "pending" || version["approval_event"] != nil || page["can_approve"] != true { |
| 53 | + t.Fatalf("new proposal %v", page) |
| 54 | + } |
| 55 | + |
| 56 | + // A member's + is a like, not an approval; the owner's + approves. |
| 57 | + wikiPublish(t, tenant, wikiMemberSecret, kindReaction, now-490, "+", []string{"e", first.ID}) |
| 58 | + page = wikiCall(t, tenant, owner, "browsewikipage", map[string]any{"d": "bitcoin"}) |
| 59 | + if page["version"].(map[string]any)["approval"] != "pending" { |
| 60 | + t.Fatalf("member reaction counted %v", page["version"]) |
| 61 | + } |
| 62 | + approval := wikiPublish(t, tenant, wikiOwnerSecret, kindReaction, now-480, "+", []string{"e", first.ID}, []string{"p", agent}, []string{"k", "30818"}) |
| 63 | + page = wikiCall(t, tenant, owner, "browsewikipage", map[string]any{"d": "bitcoin"}) |
| 64 | + version = page["version"].(map[string]any) |
| 65 | + if version["approval"] != "approved" || version["approval_event"] != approval.ID || version["approval_by"] != owner || version["approval_at"].(float64) != float64(now-480) { |
| 66 | + t.Fatalf("approved proposal %v", version) |
| 67 | + } |
| 68 | + |
| 69 | + // An edit is a new event id and needs its own approval. |
| 70 | + second := wikiArticle(t, tenant, testAgentSecret, "Bitcoin", now-400) |
| 71 | + page = wikiCall(t, tenant, owner, "browsewikipage", map[string]any{"d": "bitcoin"}) |
| 72 | + version = page["version"].(map[string]any) |
| 73 | + if version["id"] != second.ID || version["approval"] != "pending" || version["approval_event"] != nil { |
| 74 | + t.Fatalf("edit kept the approval %v", version) |
| 75 | + } |
| 76 | + items := wikiItems(wikiCall(t, tenant, owner, "browsewiki", nil), "items") |
| 77 | + if len(items) != 1 || items[0]["version"].(map[string]any)["approval"] != "pending" || items[0]["version"].(map[string]any)["proposal"] != true { |
| 78 | + t.Fatalf("list state %v", items) |
| 79 | + } |
| 80 | + |
| 81 | + // A moderator decides too; the newest deciding reaction wins. |
| 82 | + rejection := wikiPublish(t, tenant, testModSecret, kindReaction, now-390, "-", []string{"e", second.ID}) |
| 83 | + page = wikiCall(t, tenant, moderator, "browsewikipage", map[string]any{"d": "bitcoin"}) |
| 84 | + version = page["version"].(map[string]any) |
| 85 | + if version["approval"] != "rejected" || version["approval_event"] != rejection.ID || version["approval_by"] != moderator || page["can_approve"] != true { |
| 86 | + t.Fatalf("rejected proposal %v", page) |
| 87 | + } |
| 88 | + wikiPublish(t, tenant, wikiOwnerSecret, kindReaction, now-380, "+", []string{"e", second.ID}) |
| 89 | + page = wikiCall(t, tenant, owner, "browsewikipage", map[string]any{"d": "bitcoin"}) |
| 90 | + if page["version"].(map[string]any)["approval"] != "approved" { |
| 91 | + t.Fatalf("newest decision %v", page["version"]) |
| 92 | + } |
| 93 | +} |
| 94 | + |
| 95 | +func TestWikiEditGrantAndPeopleAreNotProposals(t *testing.T) { |
| 96 | + _, tenant, owner, _, _, _ := wikiProposalTenant(t, "edit") |
| 97 | + now := time.Now().Unix() |
| 98 | + agentPage := wikiArticle(t, tenant, testAgentSecret, "Lightning", now-300) |
| 99 | + memberPage := wikiArticle(t, tenant, wikiMemberSecret, "Lightning", now-200) |
| 100 | + page := wikiCall(t, tenant, "", "browsewikipage", map[string]any{"d": "lightning"}) |
| 101 | + versions := wikiItems(page, "versions") |
| 102 | + if len(versions) != 2 { |
| 103 | + t.Fatalf("versions %v", versions) |
| 104 | + } |
| 105 | + for _, id := range []string{agentPage.ID, memberPage.ID} { |
| 106 | + v := wikiVersionByID(t, versions, id) |
| 107 | + if v["proposal"] != nil || v["approval"] != nil { |
| 108 | + t.Fatalf("version marked as a proposal %v", v) |
| 109 | + } |
| 110 | + } |
| 111 | + if page["can_approve"] != false { |
| 112 | + t.Fatalf("guest may approve %v", page) |
| 113 | + } |
| 114 | + if page = wikiCall(t, tenant, owner, "browsewikipage", map[string]any{"d": "lightning"}); page["can_approve"] != true { |
| 115 | + t.Fatalf("owner may not approve %v", page) |
| 116 | + } |
| 117 | +} |
| 118 | + |
| 119 | +func TestWikiPendingProposalsAreInvisibleToMembersAndGuests(t *testing.T) { |
| 120 | + app, tenant, owner, agent, moderator, member := wikiProposalTenant(t, "propose") |
| 121 | + ctx := context.Background() |
| 122 | + now := time.Now().Unix() |
| 123 | + pending := wikiArticle(t, tenant, testAgentSecret, "Bitcoin", now-500) |
| 124 | + ownerNostr := wikiArticle(t, tenant, wikiOwnerSecret, "Nostr", now-400) |
| 125 | + agentNostr := wikiArticle(t, tenant, testAgentSecret, "Nostr", now-300) |
| 126 | + |
| 127 | + // The owner, a moderator and the author see the pending versions with |
| 128 | + // their state; a member and a guest do not. |
| 129 | + for _, actor := range []string{owner, moderator, agent} { |
| 130 | + items := wikiItems(wikiCall(t, tenant, actor, "browsewiki", nil), "items") |
| 131 | + if len(items) != 2 || items[0]["d"] != "bitcoin" || items[0]["version"].(map[string]any)["approval"] != "pending" || items[1]["versions"].(float64) != 2 { |
| 132 | + t.Fatalf("%s list %v", actor, items) |
| 133 | + } |
| 134 | + page := wikiCall(t, tenant, actor, "browsewikipage", map[string]any{"d": "bitcoin"}) |
| 135 | + if page["version"].(map[string]any)["id"] != pending.ID || page["version"].(map[string]any)["approval"] != "pending" { |
| 136 | + t.Fatalf("%s page %v", actor, page) |
| 137 | + } |
| 138 | + page = wikiCall(t, tenant, actor, "browsewikipage", map[string]any{"d": "nostr"}) |
| 139 | + if len(wikiItems(page, "versions")) != 2 || page["version"].(map[string]any)["id"] != ownerNostr.ID { |
| 140 | + t.Fatalf("%s nostr page %v", actor, page) |
| 141 | + } |
| 142 | + if page["can_approve"] != (actor != agent) { |
| 143 | + t.Fatalf("%s can_approve %v", actor, page["can_approve"]) |
| 144 | + } |
| 145 | + } |
| 146 | + for _, actor := range []string{member, ""} { |
| 147 | + items := wikiItems(wikiCall(t, tenant, actor, "browsewiki", nil), "items") |
| 148 | + if len(items) != 1 || items[0]["d"] != "nostr" || items[0]["versions"].(float64) != 1 || items[0]["version"].(map[string]any)["proposal"] != nil { |
| 149 | + t.Fatalf("%q list %v", actor, items) |
| 150 | + } |
| 151 | + if _, err := tenant.Execute(ctx, actor, "browsewikipage", []json.RawMessage{json.RawMessage(`{"d":"bitcoin"}`)}); err == nil || !strings.HasPrefix(err.Error(), "not found:") { |
| 152 | + t.Fatalf("%q saw the pending page: %v", actor, err) |
| 153 | + } |
| 154 | + page := wikiCall(t, tenant, actor, "browsewikipage", map[string]any{"d": "nostr"}) |
| 155 | + versions := wikiItems(page, "versions") |
| 156 | + if len(versions) != 1 || versions[0]["id"] != ownerNostr.ID { |
| 157 | + t.Fatalf("%q nostr versions %v", actor, versions) |
| 158 | + } |
| 159 | + if _, err := tenant.Execute(ctx, actor, "browsewikipage", []json.RawMessage{json.RawMessage(`{"d":"nostr","version":"` + agentNostr.ID + `"}`)}); err == nil { |
| 160 | + t.Fatalf("%q opened the pending version by id", actor) |
| 161 | + } |
| 162 | + if _, err := tenant.Execute(ctx, actor, "browsewikipage", []json.RawMessage{json.RawMessage(`{"d":"nostr","author":"` + agent + `"}`)}); err != nil { |
| 163 | + t.Fatal(err) |
| 164 | + } else if page = wikiCall(t, tenant, actor, "browsewikipage", map[string]any{"d": "nostr", "author": agent}); page["version"].(map[string]any)["id"] != ownerNostr.ID { |
| 165 | + t.Fatalf("%q author preference selected the pending version %v", actor, page) |
| 166 | + } |
| 167 | + } |
| 168 | + |
| 169 | + // A rejection keeps the version hidden; an approval shows it to all. |
| 170 | + wikiPublish(t, tenant, testModSecret, kindReaction, now-290, "-", []string{"e", pending.ID}) |
| 171 | + wikiPublish(t, tenant, wikiOwnerSecret, kindReaction, now-280, "+", []string{"e", agentNostr.ID}) |
| 172 | + for _, actor := range []string{member, ""} { |
| 173 | + items := wikiItems(wikiCall(t, tenant, actor, "browsewiki", nil), "items") |
| 174 | + if len(items) != 1 || items[0]["d"] != "nostr" || items[0]["versions"].(float64) != 2 { |
| 175 | + t.Fatalf("%q list after decisions %v", actor, items) |
| 176 | + } |
| 177 | + page := wikiCall(t, tenant, actor, "browsewikipage", map[string]any{"d": "nostr", "version": agentNostr.ID}) |
| 178 | + if v := page["version"].(map[string]any); v["approval"] != "approved" || v["proposal"] != true || v["content"] == "" { |
| 179 | + t.Fatalf("%q approved version %v", actor, v) |
| 180 | + } |
| 181 | + } |
| 182 | + if page := wikiCall(t, tenant, owner, "browsewikipage", map[string]any{"d": "bitcoin"}); page["version"].(map[string]any)["approval"] != "rejected" { |
| 183 | + t.Fatalf("owner rejected page %v", page) |
| 184 | + } |
| 185 | + |
| 186 | + // The MCP read tools go through the same functions. MCP requests are |
| 187 | + // signed, so the outsider is a key with no standing on the relay; an |
| 188 | + // empty secret signs as the owner. |
| 189 | + call := func(name string, arguments map[string]any, secret string) (map[string]any, bool) { |
| 190 | + t.Helper() |
| 191 | + w, response := mcpCall{method: "tools/call", name: name, arguments: arguments, sign: true, secret: secret}.do(t, app, "/mcp") |
| 192 | + if w.Code != http.StatusOK { |
| 193 | + t.Fatalf("%s: %d %s", name, w.Code, w.Body.String()) |
| 194 | + } |
| 195 | + return mcpToolResult(t, response) |
| 196 | + } |
| 197 | + structured := func(result map[string]any) map[string]any { |
| 198 | + value, _ := result["structuredContent"].(map[string]any) |
| 199 | + return value |
| 200 | + } |
| 201 | + for _, viewer := range []struct { |
| 202 | + secret string |
| 203 | + sees bool |
| 204 | + }{{"", true}, {testModSecret, true}, {testAgentSecret, true}, {wikiMemberSecret, false}, {wikiOtherSecret, false}} { |
| 205 | + result, isError := call("list_wiki", map[string]any{}, viewer.secret) |
| 206 | + items, _ := structured(result)["items"].([]any) |
| 207 | + if isError || len(items) != map[bool]int{true: 2, false: 1}[viewer.sees] { |
| 208 | + t.Fatalf("list_wiki as %q: %v", viewer.secret, result) |
| 209 | + } |
| 210 | + result, isError = call("read_wiki_page", map[string]any{"d": "bitcoin"}, viewer.secret) |
| 211 | + if isError == viewer.sees { |
| 212 | + t.Fatalf("read_wiki_page as %q: %v", viewer.secret, result) |
| 213 | + } |
| 214 | + if viewer.sees && structured(result)["version"].(map[string]any)["approval"] != "rejected" { |
| 215 | + t.Fatalf("read_wiki_page state as %q: %v", viewer.secret, result) |
| 216 | + } |
| 217 | + } |
| 218 | +} |
| 219 | + |
| 220 | +func TestWikiProposalWakesOwnerAndModeratorDevices(t *testing.T) { |
| 221 | + _, tenant, owner, agent, moderator, member := wikiProposalTenant(t, "propose") |
| 222 | + ctx := context.Background() |
| 223 | + now := time.Now().Unix() |
| 224 | + for _, pubkey := range []string{owner, moderator, member, agent} { |
| 225 | + if _, err := tenant.store.DB().ExecContext(ctx, "INSERT INTO web_push(endpoint,pubkey,subscription,created_at) VALUES(?,?,?,?)", "https://push.example/"+pubkey[:8], pubkey, "{}", now); err != nil { |
| 226 | + t.Fatal(err) |
| 227 | + } |
| 228 | + } |
| 229 | + // A person's version wakes nobody. |
| 230 | + wikiArticle(t, tenant, wikiMemberSecret, "Bitcoin", now-100) |
| 231 | + if got := countIntents(t, tenant, notificationPush); got != 0 { |
| 232 | + t.Fatalf("unexpected notifications %d", got) |
| 233 | + } |
| 234 | + wikiArticle(t, tenant, testAgentSecret, "Bitcoin Basics", now-90) |
| 235 | + if got := countIntents(t, tenant, notificationPush); got != 2 { |
| 236 | + t.Fatalf("notifications %d, want the owner and the moderator", got) |
| 237 | + } |
| 238 | + for _, recipient := range []string{owner, moderator} { |
| 239 | + var payload string |
| 240 | + if err := tenant.store.DB().QueryRowContext(ctx, "SELECT payload FROM work_intents WHERE kind=? AND target=?", notificationPush, recipient).Scan(&payload); err != nil { |
| 241 | + t.Fatalf("notification for %s: %v", recipient, err) |
| 242 | + } |
| 243 | + var notice pushPayload |
| 244 | + if err := json.Unmarshal([]byte(payload), ¬ice); err != nil { |
| 245 | + t.Fatal(err) |
| 246 | + } |
| 247 | + if notice.Kind != pushApprovals || notice.Text != "helper proposes wiki: Bitcoin Basics" || notice.URL != "http://relay.test/wiki/bitcoin-basics" { |
| 248 | + t.Fatalf("notice %+v", notice) |
| 249 | + } |
| 250 | + } |
| 251 | + // A decision wakes nobody. |
| 252 | + page := wikiCall(t, tenant, owner, "browsewikipage", map[string]any{"d": "bitcoin-basics"}) |
| 253 | + wikiPublish(t, tenant, wikiOwnerSecret, kindReaction, now-80, "+", []string{"e", page["version"].(map[string]any)["id"].(string)}, []string{"p", agent}) |
| 254 | + if got := countIntents(t, tenant, notificationPush); got != 2 { |
| 255 | + t.Fatalf("decision queued notifications: %d", got) |
| 256 | + } |
| 257 | +} |