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