tinyrelaycommit

Commit 426b46e733c2

browse files at this commit | history

1diff --git a/internal/daemon/wiki_approvals_test.go b/internal/daemon/wiki_approvals_test.go
2new file mode 100644
3index 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), &notice); 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+}

About

ownerc01503e8035f
visibilitypublic
head426b46e733c2

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs