clone

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

test the wiki proposal state machine, visibility rule and notifications

Dami committed 426b46e
commit
426b46e733c27585884179a37a27f98e71ddfb6a
parent
0dbee9115994a504fda9c80bbff28241f4d607c8
author
Dami <dami.etoile@gmail.com>
1 file changed, +251 -0
Ainternal/daemon/wiki_approvals_test.go251++++++++++++++++++++++++++++++
A internal/daemon/wiki_approvals_test.go +251 -0
@@ -0,0 +1,251 @@
1+package daemon2+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 the13+// 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().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+}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 v33+ }34+ }35+ t.Fatalf("version %s missing from %v", id, versions)36+ return nil37+}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 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+ }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 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+}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 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+}