| 1 | package daemon |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | "time" |
| 9 | |
| 10 | "github.com/FelineStateMachine/tinyrelay/internal/community" |
| 11 | "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 12 | ) |
| 13 | |
| 14 | func approvalGrantRequest(t *testing.T, grant event.Event, createdAt int64) event.Event { |
| 15 | t.Helper() |
| 16 | raw, err := json.Marshal(community.AgentGrantRequest{Base: grant.ID, Changes: community.AgentGrantChanges{Kinds: []int{30617, 30618}, Repos: []community.AgentRepo{{Owner: event.Tag(grant, "d" ), Identifier: "seedmark" , Level: "maintain" }}}}) |
| 17 | if err != nil { |
| 18 | t.Fatal(err) |
| 19 | } |
| 20 | return signedEvent(t, testAgentSecret, 1111, createdAt, [][]string{{"request" , "grant" }, {"grant" , string(raw)}, {"E" , grant.ID, "" , grant.PubKey}, {"K" , "30392" }, {"P" , grant.PubKey}, {"e" , grant.ID, "" , grant.PubKey}, {"k" , "30392" }, {"p" , grant.PubKey}}, "Publish the Seedmark repository and its hosting manifest." ) |
| 21 | } |
| 22 | |
| 23 | func TestGrantApprovalAppliesAndRetainsDecision(t *testing.T) { |
| 24 | _, tenant := testTenant(t) |
| 25 | ctx := context.Background() |
| 26 | now := time.Now().Unix() |
| 27 | agent, _ := event.PublicKey(testAgentSecret) |
| 28 | grant := agentGrantEvent(t, agent, now-30, now+3600, []string{"k" , "9" }, []string{"room" , tenant.meta.Name}, []string{"sites" , "*" }) |
| 29 | if err := publishAs(t, tenant, grant); err != nil { |
| 30 | t.Fatal(err) |
| 31 | } |
| 32 | request := approvalGrantRequest(t, grant, now-20) |
| 33 | if err := publishAs(t, tenant, request); err != nil { |
| 34 | t.Fatalf("request with no comment scope: %v" , err) |
| 35 | } |
| 36 | detail, err := approvalCall(t, tenant, grant.PubKey, "browseapproval" , map[string]any{"id" : request.ID}) |
| 37 | if err != nil { |
| 38 | t.Fatal(err) |
| 39 | } |
| 40 | item := detail["item" ].(map[string]any) |
| 41 | if item["type" ] != "grant" || item["state" ] != "open" || item["grant" ] == nil { |
| 42 | t.Fatalf("missing actionable review: %v" , item) |
| 43 | } |
| 44 | for _, actor := range []string{agent, grant.PubKey} { |
| 45 | if _, err := approvalCall(t, tenant, actor, "browseapproval" , map[string]any{"id" : request.ID}); err != nil { |
| 46 | t.Fatalf("participant cannot read review: %v" , err) |
| 47 | } |
| 48 | } |
| 49 | if _, err := approvalCall(t, tenant, wikiKey(t, approvalOtherSecret), "browseapproval" , map[string]any{"id" : request.ID}); err == nil { |
| 50 | t.Fatal("stranger read grant request" ) |
| 51 | } |
| 52 | // A reaction alone must never make the UI claim access was granted. |
| 53 | positive := signedEvent(t, testOwnerSecret, 7, now-10, [][]string{{"e" , request.ID}, {"p" , agent}}, "+" ) |
| 54 | if err := publishAs(t, tenant, positive); err != nil { |
| 55 | t.Fatal(err) |
| 56 | } |
| 57 | detail, err = approvalCall(t, tenant, grant.PubKey, "browseapproval" , map[string]any{"id" : request.ID}) |
| 58 | if err != nil || detail["item" ].(map[string]any)["state" ] != "open" { |
| 59 | t.Fatalf("reaction settled an unapplied grant: %v %v" , detail, err) |
| 60 | } |
| 61 | review, err := tenant.community.ReviewAgentGrantRequest(ctx, request, now) |
| 62 | if err != nil { |
| 63 | t.Fatal(err) |
| 64 | } |
| 65 | replacement := review.Unsigned |
| 66 | if err := event.Sign(&replacement, testOwnerSecret); err != nil { |
| 67 | t.Fatal(err) |
| 68 | } |
| 69 | if err := publishAs(t, tenant, replacement); err != nil { |
| 70 | t.Fatalf("reviewed replacement rejected: %v" , err) |
| 71 | } |
| 72 | current, found, err := tenant.community.AgentGrant(ctx, agent) |
| 73 | if err != nil || !found || !containsInt(current.Scope.Kinds, 30617) || len(current.Scope.Sites) != 1 { |
| 74 | t.Fatalf("grant not applied or existing site lost: %+v %v" , current, err) |
| 75 | } |
| 76 | // A later ordinary edit must not erase the approval's receipt. |
| 77 | manual := agentGrantEvent(t, agent, now+1, now+3600, []string{"k" , "9" }) |
| 78 | if err := publishAs(t, tenant, manual); err != nil { |
| 79 | t.Fatal(err) |
| 80 | } |
| 81 | for _, method := range []string{"browseapproval" , "browseapprovals" } { |
| 82 | result, err := approvalCall(t, tenant, grant.PubKey, method, map[string]any{"id" : request.ID}) |
| 83 | if err != nil { |
| 84 | t.Fatal(err) |
| 85 | } |
| 86 | got := result["item" ] |
| 87 | if method == "browseapprovals" { |
| 88 | got = approvalByID(wikiItems(result, "items" ), request.ID) |
| 89 | } |
| 90 | item := got.(map[string]any) |
| 91 | answer := item["answer" ].(map[string]any) |
| 92 | if item["state" ] != "answered" || answer["decision" ] != "approved" || answer["id" ] != replacement.ID { |
| 93 | t.Fatalf("lost grant approval history: %v" , item) |
| 94 | } |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | func TestGrantApprovalDenialAndStaleReview(t *testing.T) { |
| 99 | for _, scenario := range []string{"deny" , "supersede" } { |
| 100 | t.Run(scenario, func(t *testing.T) { |
| 101 | _, tenant := testTenant(t) |
| 102 | now := time.Now().Unix() |
| 103 | agent, _ := event.PublicKey(testAgentSecret) |
| 104 | grant := agentGrantEvent(t, agent, now-30, now+3600, []string{"k" , "9" }) |
| 105 | if err := publishAs(t, tenant, grant); err != nil { |
| 106 | t.Fatal(err) |
| 107 | } |
| 108 | request := approvalGrantRequest(t, grant, now-20) |
| 109 | if err := publishAs(t, tenant, request); err != nil { |
| 110 | t.Fatal(err) |
| 111 | } |
| 112 | review, err := tenant.community.ReviewAgentGrantRequest(context.Background(), request, now) |
| 113 | if err != nil { |
| 114 | t.Fatal(err) |
| 115 | } |
| 116 | if scenario == "deny" { |
| 117 | if err := publishAs(t, tenant, signedEvent(t, testOwnerSecret, 7, now-10, [][]string{{"e" , request.ID}, {"p" , agent}}, "\n-\t" )); err != nil { |
| 118 | t.Fatal(err) |
| 119 | } |
| 120 | } else if err := publishAs(t, tenant, agentGrantEvent(t, agent, now-5, now+3600, []string{"k" , "1" })); err != nil { |
| 121 | t.Fatal(err) |
| 122 | } |
| 123 | replacement := review.Unsigned |
| 124 | if err := event.Sign(&replacement, testOwnerSecret); err != nil { |
| 125 | t.Fatal(err) |
| 126 | } |
| 127 | if err := publishAs(t, tenant, replacement); err == nil { |
| 128 | t.Fatal("obsolete request changed the grant" ) |
| 129 | } |
| 130 | detail, err := approvalCall(t, tenant, grant.PubKey, "browseapproval" , map[string]any{"id" : request.ID}) |
| 131 | if err != nil { |
| 132 | t.Fatal(err) |
| 133 | } |
| 134 | item := detail["item" ].(map[string]any) |
| 135 | if item["grant" ] != nil { |
| 136 | t.Fatalf("inactive request still actionable: %v" , item) |
| 137 | } |
| 138 | if scenario == "deny" && (item["state" ] != "answered" || item["answer" ].(map[string]any)["decision" ] != "denied" ) { |
| 139 | t.Fatalf("denial not shown: %v" , item) |
| 140 | } |
| 141 | if scenario == "supersede" && (item["state" ] != "expired" || !strings.HasPrefix(item["grant_error" ].(string), "conflict:" )) { |
| 142 | t.Fatalf("stale review not marked: %v" , item) |
| 143 | } |
| 144 | }) |
| 145 | } |
| 146 | } |
| 147 | |
| 148 | func TestGrantRequestNotificationRequiresReview(t *testing.T) { |
| 149 | _, tenant := testTenant(t) |
| 150 | now := time.Now().Unix() |
| 151 | agent, _ := event.PublicKey(testAgentSecret) |
| 152 | request := approvalGrantRequest(t, agentGrantEvent(t, agent, now-30, now+3600, []string{"k" , "9" }), now-20) |
| 153 | notices := tenant.pushNotices(context.Background(), request) |
| 154 | if len(notices) != 1 || notices[0].recipient != tenant.Policy().Owner || notices[0].category != pushApprovals || len(notices[0].actions) != 1 || notices[0].actions[0].Action != "review" || !strings.Contains(notices[0].url, "/approvals?id=" +request.ID) { |
| 155 | t.Fatalf("grant request notification: %+v" , notices) |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | func TestGrantReviewReplacementAdvancesTimestamp(t *testing.T) { |
| 160 | _, tenant := testTenant(t) |
| 161 | now := time.Now().Unix() |
| 162 | agent, _ := event.PublicKey(testAgentSecret) |
| 163 | grant := agentGrantEvent(t, agent, now, now+3600, []string{"k" , "9" }) |
| 164 | if err := publishAs(t, tenant, grant); err != nil { |
| 165 | t.Fatal(err) |
| 166 | } |
| 167 | request := approvalGrantRequest(t, grant, now) |
| 168 | review, err := tenant.community.ReviewAgentGrantRequest(context.Background(), request, now) |
| 169 | if err != nil { |
| 170 | t.Fatal(err) |
| 171 | } |
| 172 | if review.Unsigned.CreatedAt <= grant.CreatedAt { |
| 173 | t.Fatal("replacement can lose the same-second event ordering tie" ) |
| 174 | } |
| 175 | } |