- commit
- 426b46e733c27585884179a37a27f98e71ddfb6a
- parent
- 0dbee9115994a504fda9c80bbff28241f4d607c8
- author
- Dami <dami.etoile@gmail.com>
| A | internal/ | 251 | ++++++++++++++++++++++++++++++ |
@@ -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), ¬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+}