1package daemon23import (4 "context"5 "encoding/json"6 "fmt"7 "strings"8 "testing"9 "time"1011 "github.com/FelineStateMachine/tinyrelay/internal/event"12 "github.com/FelineStateMachine/tinyrelay/internal/relay"13)1415const (16 wikiOwnerSecret = "0000000000000000000000000000000000000000000000000000000000000001"17 wikiMemberSecret = "2222222222222222222222222222222222222222222222222222222222222222"18 wikiOtherSecret = "3333333333333333333333333333333333333333333333333333333333333333"19 wikiThirdSecret = "4444444444444444444444444444444444444444444444444444444444444444"20)2122func wikiKey(t *testing.T, secret string) string {23 t.Helper()24 pk, err := event.PublicKey(secret)25 if err != nil {26 t.Fatal(err)27 }28 return pk29}3031func wikiPublish(t *testing.T, tenant *Tenant, secret string, kind int, createdAt int64, content string, tags ...[]string) event.Event {32 t.Helper()33 e := event.Event{Kind: kind, CreatedAt: createdAt, Content: content, Tags: tags}34 if err := event.Sign(&e, secret); err != nil {35 t.Fatal(err)36 }37 if _, err := tenant.Publish(context.Background(), e, relay.Session{PubKeys: []string{e.PubKey}, RelayURL: tenant.RelayURL()}); err != nil {38 t.Fatalf("publish kind %d: %v", kind, err)39 }40 return e41}4243func wikiArticle(t *testing.T, tenant *Tenant, secret, title string, createdAt int64, extra ...[]string) event.Event {44 t.Helper()45 tags := append([][]string{{"d", strings.ToLower(strings.ReplaceAll(title, " ", "-"))}, {"title", title}, {"summary", "Summary about " + title}}, extra...)46 return wikiPublish(t, tenant, secret, kindWikiArticle, createdAt, "Content of "+title+" with a [[Wiki Link]].", tags...)47}4849func wikiMember(t *testing.T, tenant *Tenant, pubkey string) {50 t.Helper()51 if _, err := tenant.community.Execute(context.Background(), tenant.Policy().Owner, "setmember", []json.RawMessage{json.RawMessage(fmt.Sprintf("%q", pubkey)), json.RawMessage(fmt.Sprintf(`{"name":"member-%s"}`, pubkey[:8]))}); err != nil {52 t.Fatal(err)53 }54}5556func wikiCall(t *testing.T, tenant *Tenant, actor, method string, params map[string]any) map[string]any {57 t.Helper()58 raw, err := json.Marshal(params)59 if err != nil {60 t.Fatal(err)61 }62 value, err := tenant.Execute(context.Background(), actor, method, []json.RawMessage{raw})63 if err != nil {64 t.Fatalf("%s %s: %v", method, raw, err)65 }66 encoded, err := json.Marshal(value)67 if err != nil {68 t.Fatal(err)69 }70 var result map[string]any71 if err := json.Unmarshal(encoded, &result); err != nil {72 t.Fatal(err)73 }74 return result75}7677func wikiItems(result map[string]any, key string) []map[string]any {78 var items []map[string]any79 for _, item := range result[key].([]any) {80 items = append(items, item.(map[string]any))81 }82 return items83}8485func TestWikiPreferenceOrder(t *testing.T) {86 _, tenant := testTenant(t)87 owner := tenant.Policy().Owner88 member, other, third := wikiKey(t, wikiMemberSecret), wikiKey(t, wikiOtherSecret), wikiKey(t, wikiThirdSecret)89 wikiMember(t, tenant, member)90 wikiMember(t, tenant, third)91 now := time.Now().Unix()9293 ownerBitcoin := wikiArticle(t, tenant, wikiOwnerSecret, "Bitcoin", now-300)94 memberBitcoin := wikiArticle(t, tenant, wikiMemberSecret, "Bitcoin", now-200)95 otherBitcoin := wikiArticle(t, tenant, wikiOtherSecret, "Bitcoin", now-100)9697 memberLightning := wikiArticle(t, tenant, wikiMemberSecret, "Lightning", now-300)98 otherLightning := wikiArticle(t, tenant, wikiOtherSecret, "Lightning", now-200)99 wikiPublish(t, tenant, wikiOwnerSecret, kindReaction, now-150, "+", []string{"e", memberLightning.ID})100 wikiPublish(t, tenant, wikiThirdSecret, kindReaction, now-140, "", []string{"a", "30818:" + member + ":lightning"})101 wikiPublish(t, tenant, wikiThirdSecret, kindReaction, now-130, "-", []string{"e", memberLightning.ID})102 // Non-members do not count, and one member counts once per version.103 wikiPublish(t, tenant, wikiOtherSecret, kindReaction, now-120, "+", []string{"e", otherLightning.ID})104 wikiPublish(t, tenant, wikiThirdSecret, kindReaction, now-110, "+", []string{"e", memberLightning.ID})105106 wikiArticle(t, tenant, wikiMemberSecret, "Nostr", now-300)107 otherNostr := wikiArticle(t, tenant, wikiOtherSecret, "Nostr", now-200)108109 page := wikiCall(t, tenant, "", "browsewikipage", map[string]any{"d": "bitcoin"})110 version := page["version"].(map[string]any)111 if version["author"] != owner || page["preferred_by"] != "owner" || version["id"] != ownerBitcoin.ID {112 t.Fatalf("owner preference %v", page)113 }114 if version["content"] == "" || fmt.Sprint(version["links"]) != "[wiki-link]" {115 t.Fatalf("preferred version content %v", version)116 }117 versions := wikiItems(page, "versions")118 if len(versions) != 3 || versions[0]["id"] != otherBitcoin.ID || versions[1]["id"] != memberBitcoin.ID || versions[2]["id"] != ownerBitcoin.ID {119 t.Fatalf("versions newest first %v", versions)120 }121 if _, ok := versions[0]["content"]; ok {122 t.Fatalf("version list carries content %v", versions[0])123 }124 page = wikiCall(t, tenant, "", "browsewikipage", map[string]any{"d": "Bitcoin", "author": member})125 if page["version"].(map[string]any)["id"] != memberBitcoin.ID || page["preferred_by"] != "author" {126 t.Fatalf("author preference %v", page)127 }128 page = wikiCall(t, tenant, "", "browsewikipage", map[string]any{"d": "bitcoin", "version": otherBitcoin.ID})129 if page["version"].(map[string]any)["id"] != otherBitcoin.ID || page["preferred_by"] != "version" {130 t.Fatalf("version selection %v", page)131 }132 if _, err := tenant.Execute(context.Background(), "", "browsewikipage", []json.RawMessage{json.RawMessage(`{"d":"bitcoin","version":"` + strings.Repeat("f", 64) + `"}`)}); err == nil {133 t.Fatal("unknown version selected")134 }135136 page = wikiCall(t, tenant, "", "browsewikipage", map[string]any{"d": "lightning"})137 version = page["version"].(map[string]any)138 if version["id"] != memberLightning.ID || page["preferred_by"] != "reactions" || version["likes"].(float64) != 2 {139 t.Fatalf("reaction preference %v", page)140 }141 for _, v := range wikiItems(page, "versions") {142 if v["id"] == otherLightning.ID && v["likes"].(float64) != 0 {143 t.Fatalf("non-member like counted %v", v)144 }145 }146147 page = wikiCall(t, tenant, "", "browsewikipage", map[string]any{"d": "nostr"})148 if page["version"].(map[string]any)["id"] != otherNostr.ID || page["preferred_by"] != "newest" {149 t.Fatalf("newest preference %v", page)150 }151152 list := wikiCall(t, tenant, "", "browsewiki", nil)153 items := wikiItems(list, "items")154 if len(items) != 3 || items[0]["d"] != "bitcoin" || items[1]["d"] != "lightning" || items[2]["d"] != "nostr" || list["next_cursor"] != "" {155 t.Fatalf("page list %v", list)156 }157 if items[0]["versions"].(float64) != 3 || items[0]["version"].(map[string]any)["author"] != owner || items[1]["version"].(map[string]any)["id"] != memberLightning.ID {158 t.Fatalf("page list preference %v", items)159 }160 list = wikiCall(t, tenant, "", "browsewiki", map[string]any{"author": other})161 if wikiItems(list, "items")[0]["version"].(map[string]any)["author"] != other {162 t.Fatalf("author preference in list %v", list)163 }164}165166func TestWikiSearchAndPagination(t *testing.T) {167 _, tenant := testTenant(t)168 now := time.Now().Unix()169 names := []string{"Alpha", "Beta", "Delta", "Epsilon", "Gamma"}170 for i, name := range names {171 wikiArticle(t, tenant, wikiOwnerSecret, name, now-int64(i))172 }173 var got []string174 cursor := ""175 for page := 0; page < 5; page++ {176 result := wikiCall(t, tenant, "", "browsewiki", map[string]any{"limit": 2, "cursor": cursor})177 for _, item := range wikiItems(result, "items") {178 got = append(got, item["d"].(string))179 }180 cursor = result["next_cursor"].(string)181 if cursor == "" {182 break183 }184 }185 if fmt.Sprint(got) != "[alpha beta delta epsilon gamma]" {186 t.Fatalf("paginated pages = %v", got)187 }188 result := wikiCall(t, tenant, "", "browsewiki", map[string]any{"q": "about BETA"})189 if items := wikiItems(result, "items"); len(items) != 1 || items[0]["d"] != "beta" {190 t.Fatalf("summary search %v", result)191 }192 result = wikiCall(t, tenant, "", "browsewiki", map[string]any{"q": "gamm"})193 if items := wikiItems(result, "items"); len(items) != 1 || items[0]["title"] != "Gamma" {194 t.Fatalf("title search %v", result)195 }196 if items := wikiItems(wikiCall(t, tenant, "", "browsewiki", map[string]any{"q": "zeta"}), "items"); len(items) != 0 {197 t.Fatalf("unmatched search %v", items)198 }199}200201func TestWikiMergeRequestStatusFollowsDestinationReactions(t *testing.T) {202 _, tenant := testTenant(t)203 ctx := context.Background()204 owner := tenant.Policy().Owner205 member := wikiKey(t, wikiMemberSecret)206 wikiMember(t, tenant, member)207 now := time.Now().Unix()208 original := wikiArticle(t, tenant, wikiOwnerSecret, "Bitcoin", now-500)209 coordinate := "30818:" + owner + ":bitcoin"210 fork := wikiArticle(t, tenant, wikiMemberSecret, "Bitcoin", now-400, []string{"a", coordinate, "", "fork"}, []string{"e", original.ID, "", "fork"})211 merge := wikiPublish(t, tenant, wikiMemberSecret, kindWikiMerge, now-300, "Added the block size limit", []string{"a", coordinate}, []string{"e", original.ID}, []string{"p", owner}, []string{"e", fork.ID, "", "source"})212213 detail := wikiCall(t, tenant, "", "browsewikimerge", map[string]any{"id": merge.ID})214 m := detail["merge"].(map[string]any)215 if m["status"] != "open" || m["source"] != fork.ID || m["base"] != original.ID || m["destination"] != owner || m["target_d"] != "bitcoin" {216 t.Fatalf("merge detail %v", m)217 }218 if detail["proposed"].(map[string]any)["id"] != fork.ID || detail["target"].(map[string]any)["id"] != original.ID {219 t.Fatalf("merge versions %v", detail)220 }221 if proposed := detail["proposed"].(map[string]any); proposed["fork"].(map[string]any)["e"] != original.ID || proposed["fork"].(map[string]any)["a"] != coordinate {222 t.Fatalf("fork reference %v", proposed)223 }224 page := wikiCall(t, tenant, "", "browsewikipage", map[string]any{"d": "bitcoin"})225 if merges := wikiItems(page, "merges"); len(merges) != 1 || merges[0]["id"] != merge.ID || merges[0]["status"] != "open" {226 t.Fatalf("page merges %v", page)227 }228 if items := wikiItems(wikiCall(t, tenant, "", "browsewiki", nil), "items"); items[0]["open_merges"].(float64) != 1 {229 t.Fatalf("open merge count %v", items)230 }231232 // Only the destination author's reaction answers the request.233 wikiPublish(t, tenant, wikiMemberSecret, kindReaction, now-250, "+", []string{"e", merge.ID}, []string{"p", owner})234 if answer, err := tenant.wikiMergeStatus(ctx, merge.ID, owner); err != nil || answer.Status != wikiMergeOpen {235 t.Fatalf("foreign reaction %+v %v", answer, err)236 }237 accept := wikiPublish(t, tenant, wikiOwnerSecret, kindReaction, now-200, "+", []string{"e", merge.ID}, []string{"p", member})238 answer, err := tenant.wikiMergeStatus(ctx, merge.ID, owner)239 if err != nil || answer.Status != wikiMergeAccepted || answer.AnsweredAt != now-200 || answer.Reaction != accept.ID {240 t.Fatalf("accepted %+v %v", answer, err)241 }242 reject := wikiPublish(t, tenant, wikiOwnerSecret, kindReaction, now-100, "-", []string{"e", merge.ID})243 answer, err = tenant.wikiMergeStatus(ctx, merge.ID, owner)244 if err != nil || answer.Status != wikiMergeRejected || answer.Reaction != reject.ID {245 t.Fatalf("rejected %+v %v", answer, err)246 }247 // Other reaction content does not answer.248 wikiPublish(t, tenant, wikiOwnerSecret, kindReaction, now-50, "🤔", []string{"e", merge.ID})249 if answer, _ := tenant.wikiMergeStatus(ctx, merge.ID, owner); answer.Status != wikiMergeRejected {250 t.Fatalf("emoji reaction changed the answer %+v", answer)251 }252 detail = wikiCall(t, tenant, "", "browsewikimerge", map[string]any{"event": merge.ID})253 if detail["merge"].(map[string]any)["status"] != "rejected" {254 t.Fatalf("merge detail status %v", detail)255 }256 if items := wikiItems(wikiCall(t, tenant, "", "browsewiki", nil), "items"); items[0]["open_merges"].(float64) != 0 {257 t.Fatalf("answered merge still open %v", items)258 }259 if _, err := tenant.Execute(ctx, "", "browsewikimerge", []json.RawMessage{json.RawMessage(`{"id":"` + original.ID + `"}`)}); err == nil {260 t.Fatal("article served as a merge request")261 }262}263264func TestWikiRedirects(t *testing.T) {265 _, tenant := testTenant(t)266 owner := tenant.Policy().Owner267 now := time.Now().Unix()268 wikiArticle(t, tenant, wikiOwnerSecret, "Bitcoin", now-100)269 redirect := wikiPublish(t, tenant, wikiMemberSecret, kindWikiRedirect, now-50, "", []string{"d", "btc"}, []string{"a", "30818:" + owner + ":bitcoin"})270 page := wikiCall(t, tenant, "", "browsewikipage", map[string]any{"d": "bitcoin"})271 if to := wikiItems(page, "redirects_to"); len(to) != 1 || to[0]["d"] != "btc" || to[0]["id"] != redirect.ID || len(wikiItems(page, "redirects_from")) != 0 {272 t.Fatalf("redirects to %v", page)273 }274 page = wikiCall(t, tenant, "", "browsewikipage", map[string]any{"d": "BTC"})275 if from := wikiItems(page, "redirects_from"); page["version"] != nil || len(from) != 1 || from[0]["target_d"] != "bitcoin" || from[0]["target_author"] != owner {276 t.Fatalf("redirects from %v", page)277 }278 if _, err := tenant.Execute(context.Background(), "", "browsewikipage", []json.RawMessage{json.RawMessage(`{"d":"missing"}`)}); err == nil || !strings.Contains(err.Error(), "not found") {279 t.Fatalf("missing page %v", err)280 }281}282283func TestWikiMembersOnlyHidesPagesFromGuests(t *testing.T) {284 _, tenant := testTenant(t)285 ctx := context.Background()286 member, other := wikiKey(t, wikiMemberSecret), wikiKey(t, wikiOtherSecret)287 wikiMember(t, tenant, member)288 wikiArticle(t, tenant, wikiOwnerSecret, "Bitcoin", time.Now().Unix()-10)289 if items := wikiItems(wikiCall(t, tenant, "", "browsewiki", nil), "items"); len(items) != 1 {290 t.Fatalf("open relay list %v", items)291 }292 p := tenant.Policy()293 p.Reads = "members"294 if err := tenant.applyPolicy(ctx, p); err != nil {295 t.Fatal(err)296 }297 for _, actor := range []string{"", other} {298 for _, method := range []string{"browsewiki", "browsewikipage", "browsewikimerge"} {299 if _, err := tenant.Execute(ctx, actor, method, []json.RawMessage{json.RawMessage(`{"d":"bitcoin","id":"` + strings.Repeat("a", 64) + `"}`)}); err == nil {300 t.Fatalf("%s exposed to %q", method, actor)301 }302 }303 }304 if items := wikiItems(wikiCall(t, tenant, member, "browsewiki", nil), "items"); len(items) != 1 {305 t.Fatalf("member list %v", items)306 }307 if page := wikiCall(t, tenant, tenant.Policy().Owner, "browsewikipage", map[string]any{"d": "bitcoin"}); page["version"] == nil {308 t.Fatalf("owner page %v", page)309 }310}311312func TestWikiMergeRequestWakesDestinationDevices(t *testing.T) {313 _, tenant := testTenant(t)314 ctx := context.Background()315 owner := tenant.Policy().Owner316 member, other := wikiKey(t, wikiMemberSecret), wikiKey(t, wikiOtherSecret)317 wikiMember(t, tenant, member)318 now := time.Now().Unix()319 for _, pubkey := range []string{owner, other} {320 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 {321 t.Fatal(err)322 }323 }324 wikiArticle(t, tenant, wikiOwnerSecret, "Bitcoin Basics", now-100)325 // A request aimed at someone who is not a member, and one the owner326 // sends to themselves, wake nobody.327 wikiPublish(t, tenant, wikiMemberSecret, kindWikiMerge, now-90, "", []string{"a", "30818:" + other + ":bitcoin-basics"}, []string{"p", other}, []string{"e", strings.Repeat("a", 64), "", "source"})328 wikiPublish(t, tenant, wikiOwnerSecret, kindWikiMerge, now-80, "", []string{"a", "30818:" + owner + ":bitcoin-basics"}, []string{"p", owner}, []string{"e", strings.Repeat("b", 64), "", "source"})329 if got := countIntents(t, tenant, notificationPush); got != 0 {330 t.Fatalf("unexpected notifications %d", got)331 }332 merge := wikiPublish(t, tenant, wikiMemberSecret, kindWikiMerge, now-70, "Please merge", []string{"a", "30818:" + owner + ":bitcoin-basics"}, []string{"p", owner}, []string{"e", strings.Repeat("c", 64), "", "source"})333 var payload string334 if err := tenant.store.DB().QueryRowContext(ctx, "SELECT payload FROM work_intents WHERE kind=? AND target=?", notificationPush, owner).Scan(&payload); err != nil {335 t.Fatalf("notification intent: %v", err)336 }337 var notice pushPayload338 if err := json.Unmarshal([]byte(payload), ¬ice); err != nil {339 t.Fatal(err)340 }341 if notice.Kind != pushReplies || notice.Text != "Merge request for Bitcoin Basics" || notice.URL != "http://relay.test/wiki/bitcoin-basics/proposals/"+merge.ID {342 t.Fatalf("notice %+v", notice)343 }344}345346func TestWikiMergeFromReadsEveryTagShape(t *testing.T) {347 owner := strings.Repeat("a", 64)348 base, source := strings.Repeat("b", 64), strings.Repeat("c", 64)349 coordinate := "30818:" + owner + ":bitcoin"350 cases := []struct {351 name string352 tags [][]string353 base, source string354 }{355 {"source marker", [][]string{{"a", coordinate}, {"e", base}, {"p", owner}, {"e", source, "", "source"}}, base, source},356 {"source tag", [][]string{{"a", coordinate}, {"e", base}, {"source", source}}, base, source},357 {"single e", [][]string{{"a", coordinate}, {"e", source}}, "", source},358 {"two plain e", [][]string{{"a", coordinate}, {"e", base}, {"e", source}}, base, source},359 }360 for _, tc := range cases {361 m := wikiMergeFrom(event.Event{Kind: kindWikiMerge, Tags: tc.tags})362 if m.Base != tc.base || m.Source != tc.source || m.Destination != owner || m.TargetD != "bitcoin" || m.Status != wikiMergeOpen {363 t.Errorf("%s: %+v", tc.name, m)364 }365 }366 if m := wikiMergeFrom(event.Event{Kind: kindWikiMerge, Tags: [][]string{{"a", "30617:" + owner + ":repo"}}}); m.Destination != "" || m.TargetD != "" {367 t.Errorf("foreign coordinate accepted %+v", m)368 }369}