1package daemon23import (4 "context"5 "encoding/json"6 "strconv"7 "strings"8 "testing"9 "time"1011 "github.com/FelineStateMachine/tinyrelay/internal/event"12)1314const (15 approvalAskerSecret = "5555555555555555555555555555555555555555555555555555555555555555"16 approvalOtherSecret = "6666666666666666666666666666666666666666666666666666666666666666"17)1819func 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, err28 }29 encoded, err := json.Marshal(value)30 if err != nil {31 t.Fatal(err)32 }33 var result map[string]any34 if err := json.Unmarshal(encoded, &result); err != nil {35 t.Fatal(err)36 }37 return result, nil38}3940func approvalByID(items []map[string]any, id string) map[string]any {41 for _, item := range items {42 if item["id"] == id {43 return item44 }45 }46 return nil47}4849func TestBrowseApprovalsStatesAndAccess(t *testing.T) {50 _, tenant := testTenant(t)51 ctx := context.Background()52 owner := tenant.Policy().Owner53 asker, other := wikiKey(t, approvalAskerSecret), wikiKey(t, approvalOtherSecret)54 now := time.Now().Unix()5556 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: the59 // 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 it66 // 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})7879 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 }117118 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 }142143 // 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 }154155 // One request with its history: the asked person and the asker may156 // 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}177178func 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}233234func TestApprovalRequestsWakeDevicesWithActionsAndCountOnBadge(t *testing.T) {235 _, tenant := testTenant(t)236 ctx := context.Background()237 owner := tenant.Policy().Owner238 asker := wikiKey(t, approvalAskerSecret)239 now := time.Now().Unix()240241 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 }262263 // 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 }275276 // The badge counts open requests once, whether they arrived before or277 // 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 the294 // 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}