clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat/standalone-relay
tinyrelay / internal / daemon / wiki_approvals_test.go
wiki_approvals_test.go 12.0K
1package daemon23import (4 "context"5 "encoding/json"6 "net/http"7 "strings"8 "testing"9 "time"10)1112// wikiProposalTenant grants the agent key wiki access and returns the13// 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().Owner18 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, member26}2728func 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 v33 }34 }35 t.Fatalf("version %s missing from %v", id, versions)36 return nil37}3839func TestWikiProposalStateFollowsOwnerAndModeratorReactions(t *testing.T) {40 _, tenant, owner, agent, moderator, _ := wikiProposalTenant(t, "propose")41 now := time.Now().Unix()4243 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 }4950 // 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 }6263 // 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 }7475 // 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}8889func 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}112113func 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)120121 // The owner, a moderator and the author see the pending versions with122 // 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 }162163 // 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 }179180 // The MCP read tools go through the same functions. MCP requests are181 // signed, so the outsider is a key with no standing on the relay; an182 // 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 value194 }195 for _, viewer := range []struct {196 secret string197 sees bool198 }{{"", 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}213214func 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 string234 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 pushPayload238 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}