| 1 | package daemon |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "net/http" |
| 7 | "strconv" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | "time" |
| 11 | |
| 12 | "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 13 | ) |
| 14 | |
| 15 | // The maintainer agent holds a maintain grant; the human maintainer is |
| 16 | // named in the announcement's maintainers tag. |
| 17 | const ( |
| 18 | proposalBotSecret = "0000000000000000000000000000000000000000000000000000000000000005" |
| 19 | proposalHumanSecret = wikiOtherSecret |
| 20 | proposalGuestSecret = wikiThirdSecret |
| 21 | proposalRepo = "proposals" |
| 22 | ) |
| 23 | |
| 24 | // proposalKeys are the people around one repository. |
| 25 | type proposalKeys struct { |
| 26 | owner, agent, bot, human, moderator, member string |
| 27 | coordinate string |
| 28 | } |
| 29 | |
| 30 | // proposalTenant announces a repository with one human maintainer, grants |
| 31 | // the agent key the given level on it and the bot key maintain, and adds |
| 32 | // a moderator and a member. |
| 33 | func proposalTenant(t *testing.T, level string) (*App, *Tenant, proposalKeys) { |
| 34 | t.Helper() |
| 35 | app, tenant := testTenant(t) |
| 36 | p := tenant.Policy() |
| 37 | p.Features.Grasp = true |
| 38 | if err := tenant.applyPolicy(context.Background(), p); err != nil { |
| 39 | t.Fatal(err) |
| 40 | } |
| 41 | k := proposalKeys{owner: tenant.Policy().Owner, agent: wikiKey(t, testAgentSecret), bot: wikiKey(t, proposalBotSecret), human: wikiKey(t, proposalHumanSecret), moderator: wikiKey(t, testModSecret), member: wikiKey(t, testMemberSecret)} |
| 42 | k.coordinate = "30617:" + k.owner + ":" + proposalRepo |
| 43 | setRole(t, tenant, k.moderator, "moderator" ) |
| 44 | wikiMember(t, tenant, k.member) |
| 45 | wikiMember(t, tenant, k.human) |
| 46 | now := time.Now().Unix() |
| 47 | wikiPublish(t, tenant, testOwnerSecret, event.KIND_REPO, now-2000, "" , []string{"d" , proposalRepo}, []string{"clone" , "http://relay.test/" + k.owner + "/" + proposalRepo + ".git" }, []string{"relays" , "ws://relay.test" }, []string{"maintainers" , k.human}) |
| 48 | kinds := [][]string{{"k" , "1621" }, {"k" , "1618" }, {"k" , "1617" }, {"k" , "1111" }, {"k" , "7" }, {"k" , "1630" }, {"k" , "1631" }} |
| 49 | if err := publishAs(t, tenant, agentGrantEvent(t, k.agent, now-1000, now+3600, append([][]string{{"repo" , k.owner + ":" + proposalRepo + ":" + level}}, kinds...)...)); err != nil { |
| 50 | t.Fatalf("agent grant rejected: %v" , err) |
| 51 | } |
| 52 | if err := publishAs(t, tenant, agentGrantEvent(t, k.bot, now-1000, now+3600, append([][]string{{"repo" , k.owner + ":" + proposalRepo + ":maintain" }}, kinds...)...)); err != nil { |
| 53 | t.Fatalf("bot grant rejected: %v" , err) |
| 54 | } |
| 55 | return app, tenant, k |
| 56 | } |
| 57 | |
| 58 | func proposalRoot(t *testing.T, tenant *Tenant, k proposalKeys, secret string, kind int, subject string, createdAt int64) event.Event { |
| 59 | t.Helper() |
| 60 | return wikiPublish(t, tenant, secret, kind, createdAt, "Body of " +subject, []string{"a" , k.coordinate}, []string{"subject" , subject}) |
| 61 | } |
| 62 | |
| 63 | func proposalComment(t *testing.T, tenant *Tenant, k proposalKeys, secret string, root event.Event, content string, createdAt int64) event.Event { |
| 64 | t.Helper() |
| 65 | kind := strconv.Itoa(root.Kind) |
| 66 | return wikiPublish(t, tenant, secret, 1111, createdAt, content, []string{"A" , k.coordinate}, []string{"E" , root.ID, "" , root.PubKey}, []string{"K" , kind}, []string{"P" , root.PubKey}, []string{"e" , root.ID, "" , root.PubKey}, []string{"k" , kind}, []string{"p" , root.PubKey}) |
| 67 | } |
| 68 | |
| 69 | func proposalReaction(t *testing.T, tenant *Tenant, secret string, target event.Event, content string, createdAt int64) event.Event { |
| 70 | t.Helper() |
| 71 | return wikiPublish(t, tenant, secret, kindReaction, createdAt, content, []string{"e" , target.ID, "" , target.PubKey}, []string{"p" , target.PubKey}, []string{"k" , strconv.Itoa(target.Kind)}) |
| 72 | } |
| 73 | |
| 74 | func proposalArgs(k proposalKeys, id string) map[string]any { |
| 75 | args := map[string]any{"owner" : k.owner, "repo" : proposalRepo} |
| 76 | if id != "" { |
| 77 | args["event" ] = id |
| 78 | } |
| 79 | return args |
| 80 | } |
| 81 | |
| 82 | func proposalDetail(t *testing.T, tenant *Tenant, actor, method string, k proposalKeys, id string) map[string]any { |
| 83 | t.Helper() |
| 84 | return wikiCall(t, tenant, actor, method, proposalArgs(k, id)) |
| 85 | } |
| 86 | |
| 87 | func proposalItem(t *testing.T, tenant *Tenant, actor, method string, k proposalKeys, id string) map[string]any { |
| 88 | t.Helper() |
| 89 | item, _ := proposalDetail(t, tenant, actor, method, k, id)["item" ].(map[string]any) |
| 90 | if item == nil { |
| 91 | t.Fatalf("%s %s: no item" , method, id) |
| 92 | } |
| 93 | return item |
| 94 | } |
| 95 | |
| 96 | func proposalNotFound(t *testing.T, tenant *Tenant, actor, method string, k proposalKeys, id string) { |
| 97 | t.Helper() |
| 98 | raw, _ := json.Marshal(proposalArgs(k, id)) |
| 99 | if _, err := tenant.Execute(context.Background(), actor, method, []json.RawMessage{raw}); err == nil || !strings.HasPrefix(err.Error(), "not found:" ) { |
| 100 | t.Fatalf("%s as %q saw %s: %v" , method, actor, id, err) |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | func proposalIDs(items []map[string]any) []string { |
| 105 | ids := make([]string, 0, len(items)) |
| 106 | for _, item := range items { |
| 107 | ids = append(ids, item["id" ].(string)) |
| 108 | } |
| 109 | return ids |
| 110 | } |
| 111 | |
| 112 | func TestRepositoryProposalStateFollowsDecidingReactions(t *testing.T) { |
| 113 | _, tenant, k := proposalTenant(t, "propose" ) |
| 114 | now := time.Now().Unix() |
| 115 | |
| 116 | issue := proposalRoot(t, tenant, k, testAgentSecret, event.KIND_GIT_ISSUE, "Needs review" , now-900) |
| 117 | item := proposalItem(t, tenant, k.owner, "browseissue" , k, issue.ID) |
| 118 | if item["proposal" ] != true || item["approval" ] != "pending" || item["approval_event" ] != nil { |
| 119 | t.Fatalf("new proposal %v" , item) |
| 120 | } |
| 121 | if detail := proposalDetail(t, tenant, k.owner, "browseissue" , k, issue.ID); detail["can_approve" ] != true { |
| 122 | t.Fatalf("owner may not approve %v" , detail) |
| 123 | } |
| 124 | if detail := proposalDetail(t, tenant, k.agent, "browseissue" , k, issue.ID); detail["can_approve" ] != false { |
| 125 | t.Fatalf("author may approve %v" , detail) |
| 126 | } |
| 127 | |
| 128 | // A member's + is a like and the agent's own does not count; the |
| 129 | // owner's + approves. |
| 130 | proposalReaction(t, tenant, testMemberSecret, issue, "+" , now-890) |
| 131 | proposalReaction(t, tenant, testAgentSecret, issue, "+" , now-889) |
| 132 | if item = proposalItem(t, tenant, k.owner, "browseissue" , k, issue.ID); item["approval" ] != "pending" { |
| 133 | t.Fatalf("member or author reaction counted %v" , item) |
| 134 | } |
| 135 | approval := proposalReaction(t, tenant, testOwnerSecret, issue, "+" , now-880) |
| 136 | item = proposalItem(t, tenant, k.owner, "browseissue" , k, issue.ID) |
| 137 | if item["approval" ] != "approved" || item["approval_event" ] != approval.ID || item["approval_by" ] != k.owner || item["approval_at" ].(float64) != float64(now-880) { |
| 138 | t.Fatalf("approved proposal %v" , item) |
| 139 | } |
| 140 | |
| 141 | // An agent with a maintain grant decides a comment; an empty reaction |
| 142 | // approves like +. |
| 143 | comment := proposalComment(t, tenant, k, testAgentSecret, issue, "One more thing" , now-870) |
| 144 | replies := wikiItems(proposalDetail(t, tenant, k.owner, "browseissue" , k, issue.ID), "replies" ) |
| 145 | if len(replies) != 1 || replies[0]["proposal" ] != true || replies[0]["approval" ] != "pending" { |
| 146 | t.Fatalf("new comment %v" , replies) |
| 147 | } |
| 148 | decision := proposalReaction(t, tenant, proposalBotSecret, comment, "" , now-860) |
| 149 | replies = wikiItems(proposalDetail(t, tenant, k.owner, "browseissue" , k, issue.ID), "replies" ) |
| 150 | if replies[0]["approval" ] != "approved" || replies[0]["approval_by" ] != k.bot || replies[0]["approval_event" ] != decision.ID { |
| 151 | t.Fatalf("maintainer agent decision %v" , replies) |
| 152 | } |
| 153 | |
| 154 | // The human maintainer rejects a pull request, a moderator's newer + |
| 155 | // approves it, and the owner's newer - rejects it again. |
| 156 | pull := proposalRoot(t, tenant, k, testAgentSecret, event.KIND_GIT_PR, "Add tests" , now-800) |
| 157 | rejection := proposalReaction(t, tenant, proposalHumanSecret, pull, "-" , now-790) |
| 158 | item = proposalItem(t, tenant, k.human, "browsepull" , k, pull.ID) |
| 159 | if item["approval" ] != "rejected" || item["approval_event" ] != rejection.ID || item["approval_by" ] != k.human { |
| 160 | t.Fatalf("maintainer rejection %v" , item) |
| 161 | } |
| 162 | if detail := proposalDetail(t, tenant, k.human, "browsepull" , k, pull.ID); detail["can_approve" ] != true { |
| 163 | t.Fatalf("maintainer may not approve %v" , detail) |
| 164 | } |
| 165 | proposalReaction(t, tenant, testModSecret, pull, "+" , now-780) |
| 166 | if item = proposalItem(t, tenant, k.moderator, "browsepull" , k, pull.ID); item["approval" ] != "approved" || item["approval_by" ] != k.moderator { |
| 167 | t.Fatalf("moderator decision %v" , item) |
| 168 | } |
| 169 | proposalReaction(t, tenant, testOwnerSecret, pull, "-" , now-770) |
| 170 | if item = proposalItem(t, tenant, k.owner, "browsepull" , k, pull.ID); item["approval" ] != "rejected" { |
| 171 | t.Fatalf("newest decision %v" , item) |
| 172 | } |
| 173 | |
| 174 | // A resubmitted issue is a new event id and starts pending; the list |
| 175 | // carries the state of each. |
| 176 | again := proposalRoot(t, tenant, k, testAgentSecret, event.KIND_GIT_ISSUE, "Needs review" , now-700) |
| 177 | list := proposalDetail(t, tenant, k.owner, "browseissues" , k, "" ) |
| 178 | items := wikiItems(list, "items" ) |
| 179 | if len(items) != 2 || items[0]["id" ] != again.ID || items[0]["approval" ] != "pending" || items[1]["id" ] != issue.ID || items[1]["approval" ] != "approved" || items[1]["approval_by" ] != k.owner || list["can_approve" ] != true { |
| 180 | t.Fatalf("list state %v" , list) |
| 181 | } |
| 182 | } |
| 183 | |
| 184 | func TestRepositoryReadGrantIsNeverAProposal(t *testing.T) { |
| 185 | _, tenant, k := proposalTenant(t, "read" ) |
| 186 | now := time.Now().Unix() |
| 187 | issue := proposalRoot(t, tenant, k, testAgentSecret, event.KIND_GIT_ISSUE, "Plain issue" , now-500) |
| 188 | proposalComment(t, tenant, k, testAgentSecret, issue, "Plain comment" , now-490) |
| 189 | for _, actor := range []string{k.owner, k.member, "" } { |
| 190 | items := wikiItems(proposalDetail(t, tenant, actor, "browseissues" , k, "" ), "items" ) |
| 191 | if len(items) != 1 || items[0]["proposal" ] != nil || items[0]["approval" ] != nil { |
| 192 | t.Fatalf("%q list %v" , actor, items) |
| 193 | } |
| 194 | detail := proposalDetail(t, tenant, actor, "browseissue" , k, issue.ID) |
| 195 | replies := wikiItems(detail, "replies" ) |
| 196 | if detail["item" ].(map[string]any)["proposal" ] != nil || len(replies) != 1 || replies[0]["proposal" ] != nil { |
| 197 | t.Fatalf("%q detail %v" , actor, detail) |
| 198 | } |
| 199 | if detail["can_approve" ] != (actor == k.owner) { |
| 200 | t.Fatalf("%q can_approve %v" , actor, detail["can_approve" ]) |
| 201 | } |
| 202 | } |
| 203 | } |
| 204 | |
| 205 | func TestRepositoryPendingProposalsAreInvisibleToMembersAndGuests(t *testing.T) { |
| 206 | app, tenant, k := proposalTenant(t, "propose" ) |
| 207 | now := time.Now().Unix() |
| 208 | pendingIssue := proposalRoot(t, tenant, k, testAgentSecret, event.KIND_GIT_ISSUE, "Agent issue" , now-900) |
| 209 | ownerIssue := proposalRoot(t, tenant, k, testOwnerSecret, event.KIND_GIT_ISSUE, "Owner issue" , now-800) |
| 210 | memberReply := proposalComment(t, tenant, k, testMemberSecret, ownerIssue, "From a member" , now-790) |
| 211 | pendingReply := proposalComment(t, tenant, k, testAgentSecret, ownerIssue, "From the agent" , now-780) |
| 212 | pendingPull := proposalRoot(t, tenant, k, testAgentSecret, event.KIND_GIT_PR, "Agent pull" , now-700) |
| 213 | ownerPull := proposalRoot(t, tenant, k, testOwnerSecret, event.KIND_GIT_PR, "Owner pull" , now-600) |
| 214 | pendingPatch := proposalRoot(t, tenant, k, testAgentSecret, event.KIND_GIT_PATCH, "Agent patch" , now-500) |
| 215 | |
| 216 | activity := func(actor string) []string { |
| 217 | t.Helper() |
| 218 | result := wikiCall(t, tenant, actor, "browserepo" , map[string]any{"owner" : k.owner, "repo" : proposalRepo, "view" : "activity" , "limit" : 50}) |
| 219 | return proposalIDs(wikiItems(result, "events" )) |
| 220 | } |
| 221 | |
| 222 | // The deciders and the author see the pending events with their state. |
| 223 | for _, actor := range []string{k.owner, k.human, k.bot, k.moderator, k.agent} { |
| 224 | issues := wikiItems(proposalDetail(t, tenant, actor, "browseissues" , k, "" ), "items" ) |
| 225 | if len(issues) != 2 || issues[1]["id" ] != pendingIssue.ID || issues[1]["approval" ] != "pending" || issues[0]["proposal" ] != nil { |
| 226 | t.Fatalf("%s issues %v" , actor, issues) |
| 227 | } |
| 228 | if item := proposalItem(t, tenant, actor, "browseissue" , k, pendingIssue.ID); item["approval" ] != "pending" { |
| 229 | t.Fatalf("%s pending issue %v" , actor, item) |
| 230 | } |
| 231 | replies := wikiItems(proposalDetail(t, tenant, actor, "browseissue" , k, ownerIssue.ID), "replies" ) |
| 232 | if len(replies) != 2 || replies[0]["id" ] != pendingReply.ID || replies[0]["approval" ] != "pending" || replies[1]["id" ] != memberReply.ID || replies[1]["proposal" ] != nil { |
| 233 | t.Fatalf("%s replies %v" , actor, replies) |
| 234 | } |
| 235 | pulls := wikiItems(proposalDetail(t, tenant, actor, "browsepulls" , k, "" ), "items" ) |
| 236 | if len(pulls) != 2 || pulls[1]["id" ] != pendingPull.ID || pulls[1]["approval" ] != "pending" { |
| 237 | t.Fatalf("%s pulls %v" , actor, pulls) |
| 238 | } |
| 239 | if item := proposalItem(t, tenant, actor, "browsepull" , k, pendingPull.ID); item["approval" ] != "pending" { |
| 240 | t.Fatalf("%s pending pull %v" , actor, item) |
| 241 | } |
| 242 | if ids := activity(actor); !contains(ids, pendingPatch.ID) || !contains(ids, pendingIssue.ID) || !contains(ids, pendingReply.ID) { |
| 243 | t.Fatalf("%s activity %v" , actor, ids) |
| 244 | } |
| 245 | } |
| 246 | // A member and a guest see none of them. |
| 247 | for _, actor := range []string{k.member, "" } { |
| 248 | issues := wikiItems(proposalDetail(t, tenant, actor, "browseissues" , k, "" ), "items" ) |
| 249 | if len(issues) != 1 || issues[0]["id" ] != ownerIssue.ID || issues[0]["proposal" ] != nil { |
| 250 | t.Fatalf("%q issues %v" , actor, issues) |
| 251 | } |
| 252 | proposalNotFound(t, tenant, actor, "browseissue" , k, pendingIssue.ID) |
| 253 | detail := proposalDetail(t, tenant, actor, "browseissue" , k, ownerIssue.ID) |
| 254 | if replies := wikiItems(detail, "replies" ); len(replies) != 1 || replies[0]["id" ] != memberReply.ID || detail["can_approve" ] != false { |
| 255 | t.Fatalf("%q replies %v" , actor, detail) |
| 256 | } |
| 257 | pulls := wikiItems(proposalDetail(t, tenant, actor, "browsepulls" , k, "" ), "items" ) |
| 258 | if len(pulls) != 1 || pulls[0]["id" ] != ownerPull.ID { |
| 259 | t.Fatalf("%q pulls %v" , actor, pulls) |
| 260 | } |
| 261 | proposalNotFound(t, tenant, actor, "browsepull" , k, pendingPull.ID) |
| 262 | if ids := activity(actor); contains(ids, pendingPatch.ID) || contains(ids, pendingIssue.ID) || contains(ids, pendingReply.ID) || contains(ids, pendingPull.ID) || !contains(ids, ownerIssue.ID) || !contains(ids, memberReply.ID) { |
| 263 | t.Fatalf("%q activity %v" , actor, ids) |
| 264 | } |
| 265 | } |
| 266 | |
| 267 | // A rejection keeps an event hidden; an approval shows it to all. |
| 268 | proposalReaction(t, tenant, testOwnerSecret, pendingIssue, "-" , now-400) |
| 269 | proposalReaction(t, tenant, proposalHumanSecret, pendingPull, "+" , now-390) |
| 270 | proposalReaction(t, tenant, testModSecret, pendingReply, "+" , now-380) |
| 271 | for _, actor := range []string{k.member, "" } { |
| 272 | proposalNotFound(t, tenant, actor, "browseissue" , k, pendingIssue.ID) |
| 273 | if item := proposalItem(t, tenant, actor, "browsepull" , k, pendingPull.ID); item["proposal" ] != true || item["approval" ] != "approved" || item["approval_by" ] != k.human { |
| 274 | t.Fatalf("%q approved pull %v" , actor, item) |
| 275 | } |
| 276 | if pulls := wikiItems(proposalDetail(t, tenant, actor, "browsepulls" , k, "" ), "items" ); len(pulls) != 2 { |
| 277 | t.Fatalf("%q pulls after approval %v" , actor, pulls) |
| 278 | } |
| 279 | if replies := wikiItems(proposalDetail(t, tenant, actor, "browseissue" , k, ownerIssue.ID), "replies" ); len(replies) != 2 || replies[0]["approval" ] != "approved" { |
| 280 | t.Fatalf("%q replies after approval %v" , actor, replies) |
| 281 | } |
| 282 | } |
| 283 | if item := proposalItem(t, tenant, k.owner, "browseissue" , k, pendingIssue.ID); item["approval" ] != "rejected" { |
| 284 | t.Fatalf("owner rejected issue %v" , item) |
| 285 | } |
| 286 | |
| 287 | // The MCP tools read through the same functions. An empty secret signs |
| 288 | // as the owner; the guest is a key with no standing on the relay. |
| 289 | call := func(name string, arguments map[string]any, secret string) (map[string]any, bool) { |
| 290 | t.Helper() |
| 291 | w, response := mcpCall{method: "tools/call" , name: name, arguments: arguments, sign: true, secret: secret}.do(t, app, "/mcp" ) |
| 292 | if w.Code != http.StatusOK { |
| 293 | t.Fatalf("%s: %d %s" , name, w.Code, w.Body.String()) |
| 294 | } |
| 295 | return mcpToolResult(t, response) |
| 296 | } |
| 297 | structured := func(result map[string]any) map[string]any { |
| 298 | value, _ := result["structuredContent" ].(map[string]any) |
| 299 | return value |
| 300 | } |
| 301 | for _, viewer := range []struct { |
| 302 | secret string |
| 303 | sees bool |
| 304 | }{{"" , true}, {proposalHumanSecret, true}, {proposalBotSecret, true}, {testModSecret, true}, {testAgentSecret, true}, {testMemberSecret, false}, {proposalGuestSecret, false}} { |
| 305 | result, isError := call("list_issues" , proposalArgs(k, "" ), viewer.secret) |
| 306 | items, _ := structured(result)["items" ].([]any) |
| 307 | if isError || len(items) != map[bool]int{true: 2, false: 1}[viewer.sees] || structured(result)["can_approve" ] != (viewer.sees && viewer.secret != testAgentSecret) { |
| 308 | t.Fatalf("list_issues as %q: %v" , viewer.secret, result) |
| 309 | } |
| 310 | result, isError = call("read_issue" , proposalArgs(k, pendingIssue.ID), viewer.secret) |
| 311 | if isError == viewer.sees { |
| 312 | t.Fatalf("read_issue as %q: %v" , viewer.secret, result) |
| 313 | } |
| 314 | if viewer.sees && structured(result)["item" ].(map[string]any)["approval" ] != "rejected" { |
| 315 | t.Fatalf("read_issue state as %q: %v" , viewer.secret, result) |
| 316 | } |
| 317 | result, isError = call("list_pull_requests" , proposalArgs(k, "" ), viewer.secret) |
| 318 | if items, _ = structured(result)["items" ].([]any); isError || len(items) != 2 { |
| 319 | t.Fatalf("list_pull_requests as %q: %v" , viewer.secret, result) |
| 320 | } |
| 321 | result, isError = call("read_pull_request" , proposalArgs(k, pendingPull.ID), viewer.secret) |
| 322 | if isError || structured(result)["item" ].(map[string]any)["approval" ] != "approved" { |
| 323 | t.Fatalf("read_pull_request as %q: %v" , viewer.secret, result) |
| 324 | } |
| 325 | } |
| 326 | // A new pull request from the agent is pending again and hidden from |
| 327 | // the member and the guest over MCP too. |
| 328 | later := proposalRoot(t, tenant, k, testAgentSecret, event.KIND_GIT_PR, "Agent pull again" , now-300) |
| 329 | for _, secret := range []string{testMemberSecret, proposalGuestSecret} { |
| 330 | result, isError := call("list_pull_requests" , proposalArgs(k, "" ), secret) |
| 331 | if items, _ := structured(result)["items" ].([]any); isError || len(items) != 2 { |
| 332 | t.Fatalf("list_pull_requests after resubmit as %q: %v" , secret, result) |
| 333 | } |
| 334 | if _, isError := call("read_pull_request" , proposalArgs(k, later.ID), secret); !isError { |
| 335 | t.Fatalf("read_pull_request as %q opened the pending pull request" , secret) |
| 336 | } |
| 337 | } |
| 338 | if result, isError := call("read_pull_request" , proposalArgs(k, later.ID), "" ); isError || structured(result)["item" ].(map[string]any)["approval" ] != "pending" { |
| 339 | t.Fatalf("owner read_pull_request: %v" , result) |
| 340 | } |
| 341 | } |
| 342 | |
| 343 | func TestRepositoryStatusIgnoresPendingEvents(t *testing.T) { |
| 344 | _, tenant, k := proposalTenant(t, "propose" ) |
| 345 | now := time.Now().Unix() |
| 346 | issue := proposalRoot(t, tenant, k, testAgentSecret, event.KIND_GIT_ISSUE, "Agent issue" , now-500) |
| 347 | // A status change from the proposer is stored here as if it had arrived |
| 348 | // through synchronization; the grant refuses it at the gate. |
| 349 | closed := signedCollaborationEvent(t, testAgentSecret, 1632, now-490, [][]string{{"a" , k.coordinate}, {"e" , issue.ID}}, "closed" ) |
| 350 | saveCollaborationEvents(t, tenant, closed) |
| 351 | if item := proposalItem(t, tenant, k.owner, "browseissue" , k, issue.ID); item["status" ] != "open" { |
| 352 | t.Fatalf("proposer status counted %v" , item) |
| 353 | } |
| 354 | if items := wikiItems(proposalDetail(t, tenant, k.owner, "browseissues" , k, "" ), "items" ); items[0]["status" ] != "open" { |
| 355 | t.Fatalf("proposer status counted in the list %v" , items) |
| 356 | } |
| 357 | if detail := proposalDetail(t, tenant, k.owner, "browseissue" , k, issue.ID); len(wikiItems(detail, "statuses" )) != 0 { |
| 358 | t.Fatalf("proposer status listed %v" , detail["statuses" ]) |
| 359 | } |
| 360 | // The owner's status counts. |
| 361 | wikiPublish(t, tenant, testOwnerSecret, 1631, now-480, "resolved" , []string{"a" , k.coordinate}, []string{"e" , issue.ID}) |
| 362 | if item := proposalItem(t, tenant, k.owner, "browseissue" , k, issue.ID); item["status" ] != "resolved" { |
| 363 | t.Fatalf("owner status ignored %v" , item) |
| 364 | } |
| 365 | } |
| 366 | |
| 367 | func TestRepositoryProposalWakesOwnerAndMaintainerDevices(t *testing.T) { |
| 368 | _, tenant, k := proposalTenant(t, "propose" ) |
| 369 | ctx := context.Background() |
| 370 | now := time.Now().Unix() |
| 371 | for _, pubkey := range []string{k.owner, k.human, k.bot, k.moderator, k.member, k.agent} { |
| 372 | 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 { |
| 373 | t.Fatal(err) |
| 374 | } |
| 375 | } |
| 376 | // A member's issue wakes the owner as a reply, not as a request. |
| 377 | proposalRoot(t, tenant, k, testMemberSecret, event.KIND_GIT_ISSUE, "Member issue" , now-100) |
| 378 | if got := countIntents(t, tenant, notificationPush); got != 1 { |
| 379 | t.Fatalf("member issue notifications %d" , got) |
| 380 | } |
| 381 | issue := proposalRoot(t, tenant, k, testAgentSecret, event.KIND_GIT_ISSUE, "Needs review" , now-90) |
| 382 | if got := countIntents(t, tenant, notificationPush); got != 4 { |
| 383 | t.Fatalf("notifications %d, want the owner and the two maintainers" , got-1) |
| 384 | } |
| 385 | for _, recipient := range []string{k.human, k.bot} { |
| 386 | var payload string |
| 387 | if err := tenant.store.DB().QueryRowContext(ctx, "SELECT payload FROM work_intents WHERE kind=? AND target=?" , notificationPush, recipient).Scan(&payload); err != nil { |
| 388 | t.Fatalf("notification for %s: %v" , recipient, err) |
| 389 | } |
| 390 | var notice pushPayload |
| 391 | if err := json.Unmarshal([]byte(payload), ¬ice); err != nil { |
| 392 | t.Fatal(err) |
| 393 | } |
| 394 | if notice.Kind != pushApprovals || notice.Text != "helper proposes issue: Needs review" || notice.URL != "http://relay.test/repo?owner=" +k.owner+"&repo=" +proposalRepo+"&view=issue&id=" +issue.ID { |
| 395 | t.Fatalf("notice %+v" , notice) |
| 396 | } |
| 397 | } |
| 398 | // A decision wakes nobody. |
| 399 | proposalReaction(t, tenant, testOwnerSecret, issue, "+" , now-80) |
| 400 | if got := countIntents(t, tenant, notificationPush); got != 4 { |
| 401 | t.Fatalf("decision queued notifications: %d" , got) |
| 402 | } |
| 403 | // A comment and a pull request name their kind and page; the notices |
| 404 | // are read directly since the approvals category coalesces. |
| 405 | comment := proposalComment(t, tenant, k, testAgentSecret, issue, "Also this line looks wrong" , now-70) |
| 406 | notices, ok := tenant.collaborationProposalNotices(ctx, comment) |
| 407 | if !ok || len(notices) != 3 || notices[0].recipient != k.owner || notices[0].category != pushApprovals || notices[0].body != "helper proposes comment: Also this line looks wrong" || notices[0].url != "http://relay.test/repo?owner=" +k.owner+"&repo=" +proposalRepo+"&view=issue&id=" +issue.ID { |
| 408 | t.Fatalf("comment notices %v %+v" , ok, notices) |
| 409 | } |
| 410 | pull := proposalRoot(t, tenant, k, testAgentSecret, event.KIND_GIT_PR, "Add tests" , now-60) |
| 411 | if notices, ok = tenant.collaborationProposalNotices(ctx, pull); !ok || notices[0].body != "helper proposes pull request: Add tests" || notices[0].url != "http://relay.test/repo?owner=" +k.owner+"&repo=" +proposalRepo+"&view=pr&id=" +pull.ID { |
| 412 | t.Fatalf("pull notices %v %+v" , ok, notices) |
| 413 | } |
| 414 | if _, ok = tenant.collaborationProposalNotices(ctx, proposalRoot(t, tenant, k, testOwnerSecret, event.KIND_GIT_PR, "Owner pull" , now-50)); ok { |
| 415 | t.Fatal("an owner's pull request is a proposal" ) |
| 416 | } |
| 417 | } |