tinyrelayfile
tinyrelay/internal/daemon/wiki_approvals_test.go/

internal/daemon/wiki_approvals_test.go raw
1package daemon
2
3import (
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.
14func 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
28func 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
39func 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
89func 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
113func 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
214func 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), &notice); 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}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs