| 1 | package daemon |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "strconv" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | "time" |
| 10 | |
| 11 | "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 12 | ) |
| 13 | |
| 14 | const ( |
| 15 | approvalAskerSecret = "5555555555555555555555555555555555555555555555555555555555555555" |
| 16 | approvalOtherSecret = "6666666666666666666666666666666666666666666666666666666666666666" |
| 17 | ) |
| 18 | |
| 19 | func approvalCall(t *testing.T, tenant *Tenant, actor, method string, params map[string]any) (map[string]any, error) { |
| 20 | t.Helper() |
| 21 | raw, err := json.Marshal(params) |
| 22 | if err != nil { |
| 23 | t.Fatal(err) |
| 24 | } |
| 25 | value, err := tenant.Execute(context.Background(), actor, method, []json.RawMessage{raw}) |
| 26 | if err != nil { |
| 27 | return nil, err |
| 28 | } |
| 29 | encoded, err := json.Marshal(value) |
| 30 | if err != nil { |
| 31 | t.Fatal(err) |
| 32 | } |
| 33 | var result map[string]any |
| 34 | if err := json.Unmarshal(encoded, &result); err != nil { |
| 35 | t.Fatal(err) |
| 36 | } |
| 37 | return result, nil |
| 38 | } |
| 39 | |
| 40 | func approvalByID(items []map[string]any, id string) map[string]any { |
| 41 | for _, item := range items { |
| 42 | if item["id" ] == id { |
| 43 | return item |
| 44 | } |
| 45 | } |
| 46 | return nil |
| 47 | } |
| 48 | |
| 49 | func TestBrowseApprovalsStatesAndAccess(t *testing.T) { |
| 50 | _, tenant := testTenant(t) |
| 51 | ctx := context.Background() |
| 52 | owner := tenant.Policy().Owner |
| 53 | asker, other := wikiKey(t, approvalAskerSecret), wikiKey(t, approvalOtherSecret) |
| 54 | now := time.Now().Unix() |
| 55 | |
| 56 | open := wikiPublish(t, tenant, approvalAskerSecret, kindComment, now-400, "Publish release notes 1.4 as drafted?" , []string{"request" , "approve" }, []string{"p" , owner}, []string{"subject" , "Publish release notes 1.4" }, []string{"a" , "30617:" + owner + ":notes" }, []string{"expiration" , strconv.FormatInt(now+3600, 10)}) |
| 57 | decided := wikiPublish(t, tenant, approvalAskerSecret, kindThreadRoot, now-300, "Mark pull request 23 merged?" , []string{"request" , "decide" }, []string{"p" , owner}, []string{"h" , tenant.meta.Name}) |
| 58 | // An older denial, a newer approval and a stranger's reaction: the |
| 59 | // newest reaction from an asked key wins and the stranger is ignored. |
| 60 | wikiPublish(t, tenant, wikiOwnerSecret, kindReaction, now-250, "-" , []string{"e" , decided.ID}, []string{"p" , asker}) |
| 61 | approval := wikiPublish(t, tenant, wikiOwnerSecret, kindReaction, now-200, "+" , []string{"e" , decided.ID}, []string{"p" , asker}) |
| 62 | wikiPublish(t, tenant, approvalOtherSecret, kindReaction, now-100, "-" , []string{"e" , decided.ID}, []string{"p" , asker}) |
| 63 | question := wikiPublish(t, tenant, approvalAskerSecret, kindComment, now-200, "Should the edge be mentioned?" , []string{"request" , "question" }, []string{"p" , owner}, []string{"E" , decided.ID, "" , asker}, []string{"K" , "11" }, []string{"P" , asker}) |
| 64 | reply := wikiPublish(t, tenant, wikiOwnerSecret, kindComment, now-150, "Leave it out." , []string{"E" , question.ID, "" , asker}, []string{"K" , "1111" }, []string{"P" , asker}, []string{"e" , question.ID, "" , asker}, []string{"k" , "1111" }, []string{"p" , asker}) |
| 65 | // An expired request is saved directly, as it would have been before it |
| 66 | // lapsed, and shows as expired until maintenance removes it. |
| 67 | expired := event.Event{Kind: kindComment, CreatedAt: now - 7200, Content: "Rotate the key tonight?" , Tags: [][]string{{"request" , "approve" }, {"p" , owner}, {"expiration" , strconv.FormatInt(now-3600, 10)}}} |
| 68 | if err := event.Sign(&expired, approvalAskerSecret); err != nil { |
| 69 | t.Fatal(err) |
| 70 | } |
| 71 | if _, err := tenant.store.Save(ctx, expired, storageSave(tenant)); err != nil { |
| 72 | t.Fatal(err) |
| 73 | } |
| 74 | elsewhere := wikiPublish(t, tenant, approvalAskerSecret, kindComment, now-50, "Only for someone else" , []string{"request" , "approve" }, []string{"p" , other}) |
| 75 | // Ordinary comments and unknown request types are not requests. |
| 76 | wikiPublish(t, tenant, approvalAskerSecret, kindComment, now-40, "Plain mention" , []string{"p" , owner}) |
| 77 | wikiPublish(t, tenant, approvalAskerSecret, kindComment, now-30, "Unknown type" , []string{"request" , "veto" }, []string{"p" , owner}) |
| 78 | |
| 79 | result, err := approvalCall(t, tenant, owner, "browseapprovals" , map[string]any{}) |
| 80 | if err != nil { |
| 81 | t.Fatal(err) |
| 82 | } |
| 83 | items := wikiItems(result, "items" ) |
| 84 | if len(items) != 4 || approvalByID(items, elsewhere.ID) != nil { |
| 85 | t.Fatalf("owner sees %d items: %v" , len(items), items) |
| 86 | } |
| 87 | counts := result["counts" ].(map[string]any) |
| 88 | if counts["open" ] != 1.0 || counts["answered" ] != 2.0 || counts["expired" ] != 1.0 { |
| 89 | t.Fatalf("counts %v" , counts) |
| 90 | } |
| 91 | if result["oldest_open" ] != float64(open.CreatedAt) { |
| 92 | t.Fatalf("oldest open %v" , result["oldest_open" ]) |
| 93 | } |
| 94 | item := approvalByID(items, open.ID) |
| 95 | if item["state" ] != "open" || item["type" ] != "approve" || item["subject" ] != "Publish release notes 1.4" || item["asker" ] != asker || item["expires" ] != float64(now+3600) || item["answer" ] != nil { |
| 96 | t.Fatalf("open item %v" , item) |
| 97 | } |
| 98 | if about := item["about" ].(map[string]any); about["coordinate" ] != "30617:" +owner+":notes" || !strings.HasSuffix(about["url" ].(string), "/repo?owner=" +owner+"&repo=notes&view=home" ) { |
| 99 | t.Fatalf("about %v" , about) |
| 100 | } |
| 101 | item = approvalByID(items, decided.ID) |
| 102 | answer, _ := item["answer" ].(map[string]any) |
| 103 | if item["state" ] != "answered" || item["room" ] != tenant.meta.Name || answer == nil || answer["decision" ] != "approved" || answer["id" ] != approval.ID { |
| 104 | t.Fatalf("decided item %v" , item) |
| 105 | } |
| 106 | item = approvalByID(items, question.ID) |
| 107 | answer, _ = item["answer" ].(map[string]any) |
| 108 | if item["state" ] != "answered" || answer == nil || answer["decision" ] != "replied" || answer["id" ] != reply.ID || answer["content" ] != "Leave it out." { |
| 109 | t.Fatalf("question item %v" , item) |
| 110 | } |
| 111 | if about := item["about" ].(map[string]any); about["event" ] != decided.ID || about["kind" ] != "11" || !strings.HasSuffix(about["url" ].(string), "/e/" +decided.ID) { |
| 112 | t.Fatalf("question about %v" , about) |
| 113 | } |
| 114 | if item = approvalByID(items, expired.ID); item["state" ] != "expired" { |
| 115 | t.Fatalf("expired item %v" , item) |
| 116 | } |
| 117 | |
| 118 | filtered, err := approvalCall(t, tenant, owner, "browseapprovals" , map[string]any{"state" : "open" }) |
| 119 | if err != nil { |
| 120 | t.Fatal(err) |
| 121 | } |
| 122 | if items := wikiItems(filtered, "items" ); len(items) != 1 || items[0]["id" ] != open.ID { |
| 123 | t.Fatalf("open filter %v" , items) |
| 124 | } |
| 125 | if _, err := approvalCall(t, tenant, owner, "browseapprovals" , map[string]any{"state" : "bogus" }); err == nil || !strings.HasPrefix(err.Error(), "invalid:" ) { |
| 126 | t.Fatalf("bogus state %v" , err) |
| 127 | } |
| 128 | paged, err := approvalCall(t, tenant, owner, "browseapprovals" , map[string]any{"limit" : 2}) |
| 129 | if err != nil { |
| 130 | t.Fatal(err) |
| 131 | } |
| 132 | if items := wikiItems(paged, "items" ); len(items) != 2 || paged["next_cursor" ] == "" { |
| 133 | t.Fatalf("paged %v" , paged) |
| 134 | } |
| 135 | rest, err := approvalCall(t, tenant, owner, "browseapprovals" , map[string]any{"limit" : 2, "cursor" : paged["next_cursor" ]}) |
| 136 | if err != nil { |
| 137 | t.Fatal(err) |
| 138 | } |
| 139 | if items := wikiItems(rest, "items" ); len(items) != 2 || items[0]["id" ] == wikiItems(paged, "items" )[0]["id" ] { |
| 140 | t.Fatalf("second page %v" , rest) |
| 141 | } |
| 142 | |
| 143 | // The other key sees only its own request; guests see nothing. |
| 144 | theirs, err := approvalCall(t, tenant, other, "browseapprovals" , map[string]any{}) |
| 145 | if err != nil { |
| 146 | t.Fatal(err) |
| 147 | } |
| 148 | if items := wikiItems(theirs, "items" ); len(items) != 1 || items[0]["id" ] != elsewhere.ID { |
| 149 | t.Fatalf("other sees %v" , items) |
| 150 | } |
| 151 | if _, err := approvalCall(t, tenant, "" , "browseapprovals" , map[string]any{}); err == nil || !strings.HasPrefix(err.Error(), "auth-required:" ) { |
| 152 | t.Fatalf("guest %v" , err) |
| 153 | } |
| 154 | |
| 155 | // One request with its history: the asked person and the asker may |
| 156 | // read it, nobody else. |
| 157 | detail, err := approvalCall(t, tenant, owner, "browseapproval" , map[string]any{"id" : decided.ID}) |
| 158 | if err != nil { |
| 159 | t.Fatal(err) |
| 160 | } |
| 161 | if got := detail["item" ].(map[string]any); got["state" ] != "answered" || got["answer" ].(map[string]any)["decision" ] != "approved" { |
| 162 | t.Fatalf("detail %v" , detail) |
| 163 | } |
| 164 | if answers := detail["answers" ].([]any); len(answers) != 2 || answers[0].(map[string]any)["id" ] != approval.ID { |
| 165 | t.Fatalf("answers %v" , answers) |
| 166 | } |
| 167 | if _, err := approvalCall(t, tenant, asker, "browseapproval" , map[string]any{"id" : decided.ID}); err != nil { |
| 168 | t.Fatalf("asker detail %v" , err) |
| 169 | } |
| 170 | if _, err := approvalCall(t, tenant, other, "browseapproval" , map[string]any{"id" : decided.ID}); err == nil || !strings.HasPrefix(err.Error(), "restricted:" ) { |
| 171 | t.Fatalf("stranger detail %v" , err) |
| 172 | } |
| 173 | if _, err := approvalCall(t, tenant, owner, "browseapproval" , map[string]any{"id" : reply.ID}); err == nil || !strings.HasPrefix(err.Error(), "not found:" ) { |
| 174 | t.Fatalf("reply as request %v" , err) |
| 175 | } |
| 176 | } |
| 177 | |
| 178 | func TestBrowseApprovalsIncludesWikiProposalsAndMerges(t *testing.T) { |
| 179 | _, tenant, owner, _, moderator, member := wikiProposalTenant(t, "propose" ) |
| 180 | now := time.Now().Unix() |
| 181 | proposal := wikiArticle(t, tenant, testAgentSecret, "Approvals" , now-200) |
| 182 | wikiArticle(t, tenant, wikiOwnerSecret, "Approvals" , now-300) |
| 183 | merge := wikiPublish(t, tenant, wikiMemberSecret, kindWikiMerge, now-100, "Please take this change." , []string{"a" , "30818:" + owner + ":approvals" }, []string{"p" , owner}, []string{"e" , proposal.ID, "" , "source" }) |
| 184 | result, err := approvalCall(t, tenant, owner, "browseapprovals" , map[string]any{}) |
| 185 | if err != nil { |
| 186 | t.Fatal(err) |
| 187 | } |
| 188 | items := wikiItems(result, "items" ) |
| 189 | if approvalByID(items, proposal.ID) == nil || approvalByID(items, merge.ID) == nil { |
| 190 | t.Fatalf("wiki approvals missing: %v" , items) |
| 191 | } |
| 192 | proposalItem := approvalByID(items, proposal.ID) |
| 193 | if proposalItem["type" ] != approvalWikiProposal || proposalItem["state" ] != "open" || proposalItem["about" ].(map[string]any)["url" ] != "http://relay.test/wiki/approvals?version=" +proposal.ID { |
| 194 | t.Fatalf("proposal approval %v" , proposalItem) |
| 195 | } |
| 196 | mergeItem := approvalByID(items, merge.ID) |
| 197 | if mergeItem["type" ] != approvalWikiMerge || mergeItem["state" ] != "open" || !strings.Contains(mergeItem["about" ].(map[string]any)["url" ].(string), "?merge=" +merge.ID) { |
| 198 | t.Fatalf("merge approval %v" , mergeItem) |
| 199 | } |
| 200 | if got, err := approvalCall(t, tenant, member, "browseapprovals" , map[string]any{}); err != nil || len(wikiItems(got, "items" )) != 0 { |
| 201 | t.Fatalf("member saw wiki approvals: %v %v" , got, err) |
| 202 | } |
| 203 | wikiPublish(t, tenant, wikiOwnerSecret, kindReaction, now-50, "+" , []string{"e" , proposal.ID}) |
| 204 | wikiPublish(t, tenant, wikiOwnerSecret, kindReaction, now-40, "-" , []string{"e" , merge.ID}) |
| 205 | result, err = approvalCall(t, tenant, moderator, "browseapprovals" , map[string]any{}) |
| 206 | if err != nil { |
| 207 | t.Fatal(err) |
| 208 | } |
| 209 | if approvalByID(wikiItems(result, "items" ), proposal.ID) == nil { |
| 210 | t.Fatal("moderator cannot see proposal" ) |
| 211 | } |
| 212 | if approvalByID(wikiItems(result, "items" ), proposal.ID)["state" ] != "answered" { |
| 213 | t.Fatalf("proposal state %v" , result) |
| 214 | } |
| 215 | detail, err := approvalCall(t, tenant, moderator, "browseapproval" , map[string]any{"id" : proposal.ID}) |
| 216 | if err != nil || detail["item" ].(map[string]any)["state" ] != "answered" { |
| 217 | t.Fatalf("proposal detail %v %v" , detail, err) |
| 218 | } |
| 219 | if _, err := approvalCall(t, tenant, member, "browseapproval" , map[string]any{"id" : proposal.ID}); err == nil || !strings.HasPrefix(err.Error(), "not found:" ) { |
| 220 | t.Fatalf("proposal detail leaked to member: %v" , err) |
| 221 | } |
| 222 | detail, err = approvalCall(t, tenant, owner, "browseapproval" , map[string]any{"id" : merge.ID}) |
| 223 | if err != nil || detail["item" ].(map[string]any)["state" ] != "answered" { |
| 224 | t.Fatalf("merge detail %v %v" , detail, err) |
| 225 | } |
| 226 | proposal2 := wikiArticle(t, tenant, testAgentSecret, "Reply stays open" , now-20) |
| 227 | wikiPublish(t, tenant, wikiOwnerSecret, kindComment, now-10, "A note" , []string{"e" , proposal2.ID}) |
| 228 | detail, err = approvalCall(t, tenant, owner, "browseapproval" , map[string]any{"id" : proposal2.ID}) |
| 229 | if err != nil || detail["item" ].(map[string]any)["state" ] != "open" { |
| 230 | t.Fatalf("wiki reply settled proposal: %v %v" , detail, err) |
| 231 | } |
| 232 | } |
| 233 | |
| 234 | func TestApprovalRequestsWakeDevicesWithActionsAndCountOnBadge(t *testing.T) { |
| 235 | _, tenant := testTenant(t) |
| 236 | ctx := context.Background() |
| 237 | owner := tenant.Policy().Owner |
| 238 | asker := wikiKey(t, approvalAskerSecret) |
| 239 | now := time.Now().Unix() |
| 240 | |
| 241 | request := event.Event{Kind: kindComment, PubKey: asker, CreatedAt: now, Tags: [][]string{{"request" , "decide" }, {"p" , owner}, {"subject" , "Publish release notes" }, {"expiration" , strconv.FormatInt(now+3600, 10)}}, Content: "Long body" } |
| 242 | notices := tenant.pushNotices(ctx, request) |
| 243 | if len(notices) != 1 || notices[0].category != pushApprovals || notices[0].recipient != owner || notices[0].body != asker[:12]+" asks: Publish release notes" || !strings.HasSuffix(notices[0].url, "/approvals?id=" +request.ID) { |
| 244 | t.Fatalf("decision notices %+v" , notices) |
| 245 | } |
| 246 | if actions := notices[0].actions; len(actions) != 3 || actions[0].Action != "approve" || actions[1].Action != "deny" || actions[2].Action != "reply" || actions[0].Title != "Approve" { |
| 247 | t.Fatalf("decision actions %+v" , notices[0].actions) |
| 248 | } |
| 249 | question := event.Event{Kind: kindChatMessage, PubKey: asker, CreatedAt: now, Tags: [][]string{{"request" , "question" }, {"p" , owner}, {"h" , "build" }}, Content: "Mention the edge?" } |
| 250 | notices = tenant.pushNotices(ctx, question) |
| 251 | if len(notices) != 1 || notices[0].body != asker[:12]+" asks: Mention the edge?" || len(notices[0].actions) != 1 || notices[0].actions[0].Action != "reply" { |
| 252 | t.Fatalf("question notices %+v" , notices) |
| 253 | } |
| 254 | // A comment that asks nobody keeps its mention notice. |
| 255 | plain := event.Event{Kind: kindComment, PubKey: asker, CreatedAt: now, Tags: [][]string{{"p" , owner}}, Content: "Hello" } |
| 256 | if notices := tenant.pushNotices(ctx, plain); len(notices) != 1 || notices[0].category != pushMentions { |
| 257 | t.Fatalf("plain notices %+v" , notices) |
| 258 | } |
| 259 | if !containsString(pushCategories, pushApprovals) { |
| 260 | t.Fatal("approvals is not a device category" ) |
| 261 | } |
| 262 | |
| 263 | // The actions travel through the payload to the device message. |
| 264 | message := tenant.pushMessage(ctx, pushPayload{Recipient: owner, Kind: pushApprovals, Text: notices[0].body, URL: notices[0].url, Actions: notices[0].actions}) |
| 265 | encoded, err := json.Marshal(message) |
| 266 | if err != nil { |
| 267 | t.Fatal(err) |
| 268 | } |
| 269 | if !strings.Contains(string(encoded), `"actions":[{"action":"reply","title":"Reply"}]` ) || !strings.Contains(string(encoded), `"tag":"tiny-approvals"` ) { |
| 270 | t.Fatalf("message %s" , encoded) |
| 271 | } |
| 272 | if encoded, _ := json.Marshal(tenant.pushMessage(ctx, pushPayload{Recipient: owner, Kind: pushMentions, Text: "hi" })); strings.Contains(string(encoded), "actions" ) { |
| 273 | t.Fatalf("mention message carries actions: %s" , encoded) |
| 274 | } |
| 275 | |
| 276 | // The badge counts open requests once, whether they arrived before or |
| 277 | // after the last inbox visit, and drops them once answered. |
| 278 | if err := tenant.markInboxSeen(ctx, owner); err != nil { |
| 279 | t.Fatal(err) |
| 280 | } |
| 281 | seen := tenant.inboxSeen(ctx, owner) |
| 282 | older := wikiPublish(t, tenant, approvalAskerSecret, kindComment, seen-100, "Older request" , []string{"request" , "approve" }, []string{"p" , owner}) |
| 283 | if got := tenant.inboxUnread(ctx, owner); got != 1 { |
| 284 | t.Fatalf("unread with an older open request = %d" , got) |
| 285 | } |
| 286 | newer := wikiPublish(t, tenant, approvalAskerSecret, kindComment, seen+100, "Newer request" , []string{"request" , "approve" }, []string{"p" , owner}) |
| 287 | wikiPublish(t, tenant, approvalAskerSecret, kindChatMessage, seen+100, "Room request" , []string{"request" , "question" }, []string{"p" , owner}, []string{"h" , tenant.meta.Name}) |
| 288 | if got := tenant.inboxUnread(ctx, owner); got != 3 { |
| 289 | t.Fatalf("unread with three open requests = %d" , got) |
| 290 | } |
| 291 | wikiPublish(t, tenant, wikiOwnerSecret, kindReaction, seen+200, "+" , []string{"e" , older.ID}, []string{"p" , asker}) |
| 292 | wikiPublish(t, tenant, wikiOwnerSecret, kindReaction, seen+200, "-" , []string{"e" , newer.ID}, []string{"p" , asker}) |
| 293 | // The newer request still counts as a conversation event since the |
| 294 | // visit; the older one is answered and gone from the badge. |
| 295 | if got := tenant.inboxUnread(ctx, owner); got != 2 { |
| 296 | t.Fatalf("unread after answering = %d" , got) |
| 297 | } |
| 298 | } |