tinyrelayfile
tinyrelay/internal/daemon/approvals_test.go/

internal/daemon/approvals_test.go raw
1package daemon
2
3import (
4 "context"
5 "encoding/json"
6 "strconv"
7 "strings"
8 "testing"
9 "time"
10
11 "github.com/FelineStateMachine/tinyrelay/internal/event"
12)
13
14const (
15 approvalAskerSecret = "5555555555555555555555555555555555555555555555555555555555555555"
16 approvalOtherSecret = "6666666666666666666666666666666666666666666666666666666666666666"
17)
18
19func 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
40func 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
49func 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
178func 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
234func 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}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs