| 1 | package daemon |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "net/http" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | "time" |
| 10 | |
| 11 | "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 12 | "github.com/FelineStateMachine/tinyrelay/internal/replication" |
| 13 | ) |
| 14 | |
| 15 | // wikiImport signs an article and takes it in through the import path. |
| 16 | func wikiImport(t *testing.T, tenant *Tenant, secret, title string, createdAt int64) event.Event { |
| 17 | t.Helper() |
| 18 | d := strings.ToLower(strings.ReplaceAll(title, " " , "-" )) |
| 19 | e := event.Event{Kind: kindWikiArticle, CreatedAt: createdAt, Content: "Imported " + title, Tags: [][]string{{"d" , d}, {"title" , title}}} |
| 20 | if err := event.Sign(&e, secret); err != nil { |
| 21 | t.Fatal(err) |
| 22 | } |
| 23 | if err := tenant.ingest(context.Background(), e, replication.OriginImport); err != nil { |
| 24 | t.Fatalf("import: %v" , err) |
| 25 | } |
| 26 | return e |
| 27 | } |
| 28 | |
| 29 | func wikiIDs(items []map[string]any) []string { |
| 30 | ids := make([]string, 0, len(items)) |
| 31 | for _, item := range items { |
| 32 | ids = append(ids, item["id" ].(string)) |
| 33 | } |
| 34 | return ids |
| 35 | } |
| 36 | |
| 37 | func TestWikiRevisionsAreKeptWhenAVersionIsReplaced(t *testing.T) { |
| 38 | _, tenant := testTenant(t) |
| 39 | ctx := context.Background() |
| 40 | owner := tenant.Policy().Owner |
| 41 | member := wikiKey(t, wikiMemberSecret) |
| 42 | wikiMember(t, tenant, member) |
| 43 | now := time.Now().Unix() |
| 44 | first := wikiArticle(t, tenant, wikiOwnerSecret, "Bitcoin" , now-500) |
| 45 | second := wikiArticle(t, tenant, wikiOwnerSecret, "Bitcoin" , now-400) |
| 46 | third := wikiImport(t, tenant, wikiOwnerSecret, "Bitcoin" , now-300) |
| 47 | // A reaction to an old revision id is an ordinary event. |
| 48 | wikiPublish(t, tenant, wikiMemberSecret, kindReaction, now-250, "+" , []string{"e" , first.ID}) |
| 49 | |
| 50 | rows, err := tenant.store.WikiRevisions(ctx, owner, "bitcoin" ) |
| 51 | if err != nil || len(rows) != 2 || rows[0].ID != second.ID || rows[0].SupersededBy != third.ID || rows[1].ID != first.ID || rows[1].SupersededBy != second.ID || rows[1].Event.Content != first.Content { |
| 52 | t.Fatalf("stored revisions %+v %v" , rows, err) |
| 53 | } |
| 54 | page := wikiCall(t, tenant, "" , "browsewikipage" , map[string]any{"d" : "bitcoin" }) |
| 55 | versions := wikiItems(page, "versions" ) |
| 56 | version := page["version" ].(map[string]any) |
| 57 | if len(versions) != 1 || version["id" ] != third.ID || version["revision" ].(float64) != 3 || version["revisions" ].(float64) != 3 || version["superseded_by" ] != nil || version["content" ] != "Imported Bitcoin" { |
| 58 | t.Fatalf("current version %v" , page) |
| 59 | } |
| 60 | history := wikiItems(page, "history" ) |
| 61 | if got := wikiIDs(history); strings.Join(got, "," ) != third.ID+"," +second.ID+"," +first.ID { |
| 62 | t.Fatalf("history order %v" , got) |
| 63 | } |
| 64 | for i, want := range []struct { |
| 65 | revision float64 |
| 66 | superseded any |
| 67 | likes float64 |
| 68 | }{{3, nil, 0}, {2, third.ID, 0}, {1, second.ID, 1}} { |
| 69 | h := history[i] |
| 70 | if h["revision" ].(float64) != want.revision || h["revisions" ].(float64) != 3 || h["superseded_by" ] != want.superseded || h["author" ] != owner || h["title" ] != "Bitcoin" || h["likes" ].(float64) != want.likes { |
| 71 | t.Fatalf("history entry %d %v" , i, h) |
| 72 | } |
| 73 | if _, ok := h["content" ]; ok { |
| 74 | t.Fatalf("history entry carries content %v" , h) |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | // An archived revision opens by id with a note of what replaced it. |
| 79 | page = wikiCall(t, tenant, "" , "browsewikipage" , map[string]any{"d" : "bitcoin" , "version" : first.ID}) |
| 80 | version = page["version" ].(map[string]any) |
| 81 | if version["id" ] != first.ID || version["content" ] != first.Content || version["superseded_by" ] != second.ID || version["revision" ].(float64) != 1 || version["likes" ].(float64) != 1 || page["preferred_by" ] != "version" { |
| 82 | t.Fatalf("archived version %v" , version) |
| 83 | } |
| 84 | if len(wikiItems(page, "versions" )) != 1 { |
| 85 | t.Fatalf("archived lookup changed the versions %v" , page["versions" ]) |
| 86 | } |
| 87 | items := wikiItems(wikiCall(t, tenant, "" , "browsewiki" , nil), "items" ) |
| 88 | if len(items) != 1 || items[0]["version" ].(map[string]any)["revision" ].(float64) != 3 || items[0]["versions" ].(float64) != 1 { |
| 89 | t.Fatalf("list %v" , items) |
| 90 | } |
| 91 | |
| 92 | // A member's version of the same page is numbered on its own. |
| 93 | memberVersion := wikiArticle(t, tenant, wikiMemberSecret, "Bitcoin" , now-200) |
| 94 | page = wikiCall(t, tenant, "" , "browsewikipage" , map[string]any{"d" : "bitcoin" , "author" : member}) |
| 95 | if v := page["version" ].(map[string]any); v["id" ] != memberVersion.ID || v["revision" ].(float64) != 1 || v["revisions" ].(float64) != 1 { |
| 96 | t.Fatalf("member version %v" , v) |
| 97 | } |
| 98 | if history := wikiItems(page, "history" ); len(history) != 4 || history[0]["id" ] != memberVersion.ID { |
| 99 | t.Fatalf("history across authors %v" , wikiIDs(history)) |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | func TestWikiRevisionIsDroppedByTheAuthorsDeletion(t *testing.T) { |
| 104 | _, tenant := testTenant(t) |
| 105 | now := time.Now().Unix() |
| 106 | first := wikiArticle(t, tenant, wikiOwnerSecret, "Bitcoin" , now-500) |
| 107 | second := wikiArticle(t, tenant, wikiOwnerSecret, "Bitcoin" , now-400) |
| 108 | wikiArticle(t, tenant, wikiOwnerSecret, "Bitcoin" , now-300) |
| 109 | // Someone else's deletion changes nothing. |
| 110 | wikiPublish(t, tenant, wikiMemberSecret, 5, now-250, "" , []string{"e" , first.ID}) |
| 111 | if history := wikiItems(wikiCall(t, tenant, "" , "browsewikipage" , map[string]any{"d" : "bitcoin" }), "history" ); len(history) != 3 { |
| 112 | t.Fatalf("foreign deletion dropped a revision %v" , wikiIDs(history)) |
| 113 | } |
| 114 | wikiPublish(t, tenant, wikiOwnerSecret, 5, now-200, "" , []string{"e" , first.ID}) |
| 115 | page := wikiCall(t, tenant, "" , "browsewikipage" , map[string]any{"d" : "bitcoin" }) |
| 116 | history := wikiItems(page, "history" ) |
| 117 | if len(history) != 2 || history[1]["id" ] != second.ID || history[1]["revision" ].(float64) != 1 || history[0]["revisions" ].(float64) != 2 { |
| 118 | t.Fatalf("history after deletion %v" , history) |
| 119 | } |
| 120 | if _, err := tenant.Execute(context.Background(), "" , "browsewikipage" , []json.RawMessage{json.RawMessage(`{"d":"bitcoin","version":"` + first.ID + `"}` )}); err == nil || !strings.HasPrefix(err.Error(), "not found:" ) { |
| 121 | t.Fatalf("deleted revision still opens: %v" , err) |
| 122 | } |
| 123 | if rows, err := tenant.store.WikiRevisions(context.Background(), "" , "" ); err != nil || len(rows) != 1 { |
| 124 | t.Fatalf("stored revisions %+v %v" , rows, err) |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | func TestWikiReadersFallBackToTheNewestApprovedRevision(t *testing.T) { |
| 129 | app, tenant, owner, agent, moderator, member := wikiProposalTenant(t, "propose" ) |
| 130 | ctx := context.Background() |
| 131 | now := time.Now().Unix() |
| 132 | first := wikiArticle(t, tenant, testAgentSecret, "Bitcoin" , now-500) |
| 133 | wikiPublish(t, tenant, wikiOwnerSecret, kindReaction, now-490, "+" , []string{"e" , first.ID}) |
| 134 | second := wikiArticle(t, tenant, testAgentSecret, "Bitcoin" , now-400) |
| 135 | // A page whose revisions were never approved stays absent. |
| 136 | wikiArticle(t, tenant, testAgentSecret, "Nostr" , now-380) |
| 137 | wikiArticle(t, tenant, testAgentSecret, "Nostr" , now-370) |
| 138 | |
| 139 | // The owner, a moderator and the agent see the pending edit in place, |
| 140 | // with the approved revision behind it in the history. |
| 141 | for _, actor := range []string{owner, moderator, agent} { |
| 142 | page := wikiCall(t, tenant, actor, "browsewikipage" , map[string]any{"d" : "bitcoin" }) |
| 143 | version := page["version" ].(map[string]any) |
| 144 | if version["id" ] != second.ID || version["approval" ] != "pending" || version["revision" ].(float64) != 2 || version["revisions" ].(float64) != 2 || version["superseded_by" ] != nil { |
| 145 | t.Fatalf("%s page %v" , actor, version) |
| 146 | } |
| 147 | history := wikiItems(page, "history" ) |
| 148 | if len(history) != 2 || history[0]["id" ] != second.ID || history[0]["approval" ] != "pending" || history[1]["id" ] != first.ID || history[1]["approval" ] != "approved" || history[1]["superseded_by" ] != second.ID { |
| 149 | t.Fatalf("%s history %v" , actor, history) |
| 150 | } |
| 151 | items := wikiItems(wikiCall(t, tenant, actor, "browsewiki" , nil), "items" ) |
| 152 | if len(items) != 2 || items[0]["version" ].(map[string]any)["id" ] != second.ID || items[0]["versions" ].(float64) != 1 { |
| 153 | t.Fatalf("%s list %v" , actor, items) |
| 154 | } |
| 155 | } |
| 156 | // A member and a guest read the approved revision while the edit |
| 157 | // waits, and the history shows only what they may see. |
| 158 | for _, actor := range []string{member, "" } { |
| 159 | page := wikiCall(t, tenant, actor, "browsewikipage" , map[string]any{"d" : "bitcoin" }) |
| 160 | version := page["version" ].(map[string]any) |
| 161 | if version["id" ] != first.ID || version["approval" ] != "approved" || version["proposal" ] != true || version["content" ] != first.Content || version["superseded_by" ] != second.ID || version["revision" ].(float64) != 1 || version["revisions" ].(float64) != 2 { |
| 162 | t.Fatalf("%q page %v" , actor, version) |
| 163 | } |
| 164 | if page["can_approve" ] != false { |
| 165 | t.Fatalf("%q page %v" , actor, page) |
| 166 | } |
| 167 | versions := wikiItems(page, "versions" ) |
| 168 | history := wikiItems(page, "history" ) |
| 169 | if len(versions) != 1 || versions[0]["id" ] != first.ID || len(history) != 1 || history[0]["id" ] != first.ID { |
| 170 | t.Fatalf("%q versions %v history %v" , actor, wikiIDs(versions), wikiIDs(history)) |
| 171 | } |
| 172 | items := wikiItems(wikiCall(t, tenant, actor, "browsewiki" , nil), "items" ) |
| 173 | if len(items) != 1 || items[0]["d" ] != "bitcoin" || items[0]["version" ].(map[string]any)["id" ] != first.ID || items[0]["versions" ].(float64) != 1 { |
| 174 | t.Fatalf("%q list %v" , actor, items) |
| 175 | } |
| 176 | for _, params := range []string{`{"d":"bitcoin","version":"` + second.ID + `"}` , `{"d":"nostr"}` } { |
| 177 | if _, err := tenant.Execute(ctx, actor, "browsewikipage" , []json.RawMessage{json.RawMessage(params)}); err == nil || !strings.HasPrefix(err.Error(), "not found:" ) { |
| 178 | t.Fatalf("%q read %s: %v" , actor, params, err) |
| 179 | } |
| 180 | } |
| 181 | } |
| 182 | |
| 183 | // The MCP tools read through the same code. An empty secret signs as |
| 184 | // the owner; the other key has no standing on the relay. |
| 185 | call := func(name string, arguments map[string]any, secret string) map[string]any { |
| 186 | t.Helper() |
| 187 | w, response := mcpCall{method: "tools/call" , name: name, arguments: arguments, sign: true, secret: secret}.do(t, app, "/mcp" ) |
| 188 | if w.Code != http.StatusOK { |
| 189 | t.Fatalf("%s: %d %s" , name, w.Code, w.Body.String()) |
| 190 | } |
| 191 | result, isError := mcpToolResult(t, response) |
| 192 | if isError { |
| 193 | t.Fatalf("%s as %q: %v" , name, secret, result) |
| 194 | } |
| 195 | value, _ := result["structuredContent" ].(map[string]any) |
| 196 | return value |
| 197 | } |
| 198 | for _, viewer := range []struct { |
| 199 | secret string |
| 200 | id string |
| 201 | }{{"" , second.ID}, {testModSecret, second.ID}, {testAgentSecret, second.ID}, {wikiMemberSecret, first.ID}, {wikiOtherSecret, first.ID}} { |
| 202 | page := call("read_wiki_page" , map[string]any{"d" : "bitcoin" }, viewer.secret) |
| 203 | history, _ := page["history" ].([]any) |
| 204 | if page["version" ].(map[string]any)["id" ] != viewer.id || len(history) != map[bool]int{true: 2, false: 1}[viewer.id == second.ID] { |
| 205 | t.Fatalf("read_wiki_page as %q: %v" , viewer.secret, page) |
| 206 | } |
| 207 | items, _ := call("list_wiki" , map[string]any{}, viewer.secret)["items" ].([]any) |
| 208 | if len(items) == 0 || items[0].(map[string]any)["version" ].(map[string]any)["id" ] != viewer.id { |
| 209 | t.Fatalf("list_wiki as %q: %v" , viewer.secret, items) |
| 210 | } |
| 211 | } |
| 212 | |
| 213 | // A rejected edit keeps the approved revision in view; approving the |
| 214 | // edit shows it to everyone with the full history. |
| 215 | wikiPublish(t, tenant, testModSecret, kindReaction, now-390, "-" , []string{"e" , second.ID}) |
| 216 | if page := wikiCall(t, tenant, member, "browsewikipage" , map[string]any{"d" : "bitcoin" }); page["version" ].(map[string]any)["id" ] != first.ID { |
| 217 | t.Fatalf("rejected edit shown %v" , page["version" ]) |
| 218 | } |
| 219 | wikiPublish(t, tenant, wikiOwnerSecret, kindReaction, now-380, "+" , []string{"e" , second.ID}) |
| 220 | for _, actor := range []string{owner, member, "" } { |
| 221 | page := wikiCall(t, tenant, actor, "browsewikipage" , map[string]any{"d" : "bitcoin" }) |
| 222 | history := wikiItems(page, "history" ) |
| 223 | if page["version" ].(map[string]any)["id" ] != second.ID || len(history) != 2 || history[1]["superseded_by" ] != second.ID { |
| 224 | t.Fatalf("%q after approval %v" , actor, page) |
| 225 | } |
| 226 | // The approved older revision still opens from the history. |
| 227 | page = wikiCall(t, tenant, actor, "browsewikipage" , map[string]any{"d" : "bitcoin" , "version" : first.ID}) |
| 228 | if v := page["version" ].(map[string]any); v["id" ] != first.ID || v["approval" ] != "approved" || v["superseded_by" ] != second.ID || v["content" ] != first.Content { |
| 229 | t.Fatalf("%q archived approved revision %v" , actor, v) |
| 230 | } |
| 231 | } |
| 232 | } |