| 1 | package daemon |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "net/http" |
| 7 | "net/http/httptest" |
| 8 | "strconv" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | "time" |
| 12 | |
| 13 | "github.com/FelineStateMachine/tinyrelay/internal/community" |
| 14 | "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 15 | "github.com/FelineStateMachine/tinyrelay/internal/relay" |
| 16 | ) |
| 17 | |
| 18 | const ( |
| 19 | testOwnerSecret = "0000000000000000000000000000000000000000000000000000000000000001" |
| 20 | testAgentSecret = "0000000000000000000000000000000000000000000000000000000000000002" |
| 21 | testMemberSecret = "0000000000000000000000000000000000000000000000000000000000000003" |
| 22 | testModSecret = "0000000000000000000000000000000000000000000000000000000000000004" |
| 23 | ) |
| 24 | |
| 25 | func signedEvent(t *testing.T, secret string, kind int, createdAt int64, tags [][]string, content string) event.Event { |
| 26 | t.Helper() |
| 27 | if tags == nil { |
| 28 | tags = [][]string{} |
| 29 | } |
| 30 | e := event.Event{Kind: kind, CreatedAt: createdAt, Tags: tags, Content: content} |
| 31 | if err := event.Sign(&e, secret); err != nil { |
| 32 | t.Fatal(err) |
| 33 | } |
| 34 | return e |
| 35 | } |
| 36 | |
| 37 | func agentGrantEvent(t *testing.T, agent string, createdAt, expires int64, extra ...[]string) event.Event { |
| 38 | t.Helper() |
| 39 | tags := [][]string{{"d" , agent}, {"p" , agent}, {"name" , "helper" }, {"expiration" , strconv.FormatInt(expires, 10)}} |
| 40 | return signedEvent(t, testOwnerSecret, event.KIND_AGENT_GRANT, createdAt, append(tags, extra...), "" ) |
| 41 | } |
| 42 | |
| 43 | func publishAs(t *testing.T, tenant *Tenant, e event.Event) error { |
| 44 | t.Helper() |
| 45 | _, err := tenant.Publish(context.Background(), e, relay.Session{PubKeys: []string{e.PubKey}}) |
| 46 | return err |
| 47 | } |
| 48 | |
| 49 | func TestAgentGrantPublishCreatesRoleRowAndRevokesOnDeletion(t *testing.T) { |
| 50 | app, tenant := testTenant(t) |
| 51 | ctx := context.Background() |
| 52 | agent, _ := event.PublicKey(testAgentSecret) |
| 53 | now := time.Now().Unix() |
| 54 | grant := agentGrantEvent(t, agent, now, now+3600, []string{"k" , "1" }) |
| 55 | if err := publishAs(t, tenant, grant); err != nil { |
| 56 | t.Fatalf("grant rejected: %v" , err) |
| 57 | } |
| 58 | if role, _ := tenant.community.Role(ctx, agent); role != "agent" { |
| 59 | t.Fatalf("role = %q, want agent" , role) |
| 60 | } |
| 61 | var owner, eventID, name string |
| 62 | var expires int64 |
| 63 | if err := tenant.store.DB().QueryRow(`SELECT owner,event_id,name,expires_at FROM agent_grants WHERE agent=?` , agent).Scan(&owner, &eventID, &name, &expires); err != nil { |
| 64 | t.Fatal(err) |
| 65 | } |
| 66 | if owner != tenant.Policy().Owner || eventID != grant.ID || name != "helper" || expires != now+3600 { |
| 67 | t.Fatalf("grant row owner=%s event=%s name=%s expires=%d" , owner, eventID, name, expires) |
| 68 | } |
| 69 | if err := publishAs(t, tenant, signedEvent(t, testAgentSecret, 1, now, nil, "hello" )); err != nil { |
| 70 | t.Fatalf("agent note rejected: %v" , err) |
| 71 | } |
| 72 | if err := publishAs(t, tenant, signedEvent(t, testAgentSecret, 7, now, nil, "+" )); err == nil || !strings.HasPrefix(err.Error(), "restricted: agent grant does not allow kind 7" ) { |
| 73 | t.Fatalf("agent reaction: %v" , err) |
| 74 | } |
| 75 | records, err := tenant.records.PublishMembership(ctx, now) |
| 76 | if err != nil { |
| 77 | t.Fatal(err) |
| 78 | } |
| 79 | labelled := map[int]bool{} |
| 80 | for _, e := range records { |
| 81 | for _, tag := range e.Tags { |
| 82 | if len(tag) == 3 && tag[1] == agent && tag[2] == "agent" && (e.Kind == event.KIND_ROSTER || e.Kind == event.KIND_GROUP_MEMBERS) { |
| 83 | labelled[e.Kind] = true |
| 84 | } |
| 85 | if e.Kind == event.KIND_ROLE_DEF && tag[0] == "d" && tag[1] == "agent" { |
| 86 | labelled[e.Kind] = true |
| 87 | } |
| 88 | } |
| 89 | } |
| 90 | if !labelled[event.KIND_ROSTER] || !labelled[event.KIND_GROUP_MEMBERS] || !labelled[event.KIND_ROLE_DEF] { |
| 91 | t.Fatalf("agent role missing from records: %v" , labelled) |
| 92 | } |
| 93 | found := false |
| 94 | for _, capability := range tenant.Capabilities(nil) { |
| 95 | if capability.ID == "agents" && capability.Status == "enabled" { |
| 96 | found = true |
| 97 | } |
| 98 | } |
| 99 | if !found { |
| 100 | t.Fatal("agents capability not advertised" ) |
| 101 | } |
| 102 | req := httptest.NewRequest(http.MethodGet, "http://relay.test/" , nil) |
| 103 | req.Header.Set("Accept" , "application/nostr+json" ) |
| 104 | res := httptest.NewRecorder() |
| 105 | app.ServeHTTP(res, req) |
| 106 | var info map[string]json.RawMessage |
| 107 | if err := json.Unmarshal(res.Body.Bytes(), &info); err != nil || res.Code != 200 { |
| 108 | t.Fatalf("info %d %s %v" , res.Code, res.Body.String(), err) |
| 109 | } |
| 110 | if !strings.Contains(string(info["agents" ]), `"id":"agents"` ) { |
| 111 | t.Fatalf("NIP-11 agents entry = %s" , info["agents" ]) |
| 112 | } |
| 113 | deletion := signedEvent(t, testOwnerSecret, event.KIND_DELETION, now+1, [][]string{{"a" , "30392:" + tenant.Policy().Owner + ":" + agent}}, "" ) |
| 114 | if err := publishAs(t, tenant, deletion); err != nil { |
| 115 | t.Fatalf("deletion rejected: %v" , err) |
| 116 | } |
| 117 | if role, _ := tenant.community.Role(ctx, agent); role != "" { |
| 118 | t.Fatalf("role after deletion = %q" , role) |
| 119 | } |
| 120 | if err := publishAs(t, tenant, signedEvent(t, testAgentSecret, 1, now+2, nil, "again" )); err == nil || !strings.Contains(err.Error(), "revoked" ) { |
| 121 | t.Fatalf("revoked agent note: %v" , err) |
| 122 | } |
| 123 | if err := publishAs(t, tenant, agentGrantEvent(t, agent, now+3, now+3600, []string{"k" , "1" })); err != nil { |
| 124 | t.Fatalf("new grant rejected: %v" , err) |
| 125 | } |
| 126 | if err := publishAs(t, tenant, signedEvent(t, testAgentSecret, 1, now+4, nil, "back" )); err != nil { |
| 127 | t.Fatalf("re-granted agent rejected: %v" , err) |
| 128 | } |
| 129 | if err := publishAs(t, tenant, agentGrantEvent(t, agent, now+5, now-1, []string{"k" , "1" })); err != nil { |
| 130 | t.Fatalf("expired grant rejected: %v" , err) |
| 131 | } |
| 132 | if role, _ := tenant.community.Role(ctx, agent); role != "" { |
| 133 | t.Fatalf("role after expired grant = %q" , role) |
| 134 | } |
| 135 | var revoked int64 |
| 136 | if err := tenant.store.DB().QueryRow(`SELECT revoked_at FROM agent_grants WHERE agent=?` , agent).Scan(&revoked); err != nil || revoked == 0 { |
| 137 | t.Fatalf("expired grant not revoked: %d %v" , revoked, err) |
| 138 | } |
| 139 | if found := capabilityIDs(tenant)["agents" ]; found { |
| 140 | t.Fatal("agents capability advertised without an active agent" ) |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | func capabilityIDs(tenant *Tenant) map[string]bool { |
| 145 | out := map[string]bool{} |
| 146 | for _, capability := range tenant.Capabilities(nil) { |
| 147 | out[capability.ID] = true |
| 148 | } |
| 149 | return out |
| 150 | } |
| 151 | |
| 152 | func TestAgentManagementMethodsAndPermissions(t *testing.T) { |
| 153 | _, tenant := testTenant(t) |
| 154 | ctx := context.Background() |
| 155 | owner := tenant.Policy().Owner |
| 156 | agent, _ := event.PublicKey(testAgentSecret) |
| 157 | member, _ := event.PublicKey(testMemberSecret) |
| 158 | moderator, _ := event.PublicKey(testModSecret) |
| 159 | now := time.Now().Unix() |
| 160 | for _, target := range []struct { |
| 161 | pubkey, role string |
| 162 | }{{member, "member" }, {moderator, "moderator" }} { |
| 163 | raw, _ := json.Marshal(map[string]string{"role" : target.role}) |
| 164 | if _, err := tenant.Execute(ctx, owner, "setmember" , []json.RawMessage{json.RawMessage(strconv.Quote(target.pubkey)), raw}); err != nil { |
| 165 | t.Fatal(err) |
| 166 | } |
| 167 | } |
| 168 | if err := publishAs(t, tenant, agentGrantEvent(t, agent, now, now+3600, []string{"k" , "1" })); err != nil { |
| 169 | t.Fatal(err) |
| 170 | } |
| 171 | methods, err := tenant.Execute(ctx, owner, "supportedmethods" , nil) |
| 172 | if err != nil { |
| 173 | t.Fatal(err) |
| 174 | } |
| 175 | for _, name := range []string{"listagents" , "pauseagent" , "resumeagent" , "revokeagent" , "pauseallagents" , "resumeallagents" } { |
| 176 | if !containsString(methods.([]string), name) { |
| 177 | t.Fatalf("supportedmethods lacks %s" , name) |
| 178 | } |
| 179 | } |
| 180 | param := []json.RawMessage{json.RawMessage(strconv.Quote(agent))} |
| 181 | for _, actor := range []string{member, agent, strings.Repeat("e" , 64)} { |
| 182 | for _, method := range []string{"listagents" , "pauseagent" , "revokeagent" , "pauseallagents" } { |
| 183 | if _, err := tenant.Execute(ctx, actor, method, param); err == nil || !strings.HasPrefix(err.Error(), "restricted:" ) { |
| 184 | t.Fatalf("%s by non-admin: %v" , method, err) |
| 185 | } |
| 186 | } |
| 187 | } |
| 188 | listed, err := tenant.Execute(ctx, moderator, "listagents" , nil) |
| 189 | if err != nil { |
| 190 | t.Fatalf("moderator listagents: %v" , err) |
| 191 | } |
| 192 | agents := listed.([]community.AgentSummary) |
| 193 | if len(agents) != 1 || agents[0].Agent != agent || agents[0].Name != "helper" || agents[0].Owner != owner || agents[0].Paused || agents[0].RevokedAt != 0 || agents[0].Scope.Kinds[0] != 1 { |
| 194 | t.Fatalf("listagents = %+v" , agents) |
| 195 | } |
| 196 | if err := publishAs(t, tenant, signedEvent(t, testAgentSecret, 1, now, nil, "hello" )); err != nil { |
| 197 | t.Fatal(err) |
| 198 | } |
| 199 | listed, _ = tenant.Execute(ctx, owner, "listagents" , nil) |
| 200 | if last := listed.([]community.AgentSummary)[0].LastEvent; last != now { |
| 201 | t.Fatalf("lastEvent = %d, want %d" , last, now) |
| 202 | } |
| 203 | if _, err := tenant.Execute(ctx, moderator, "pauseagent" , param); err != nil { |
| 204 | t.Fatal(err) |
| 205 | } |
| 206 | if err := publishAs(t, tenant, signedEvent(t, testAgentSecret, 1, now+1, nil, "paused" )); err == nil || !strings.Contains(err.Error(), "paused" ) { |
| 207 | t.Fatalf("paused agent note: %v" , err) |
| 208 | } |
| 209 | if _, err := tenant.Execute(ctx, owner, "resumeagent" , param); err != nil { |
| 210 | t.Fatal(err) |
| 211 | } |
| 212 | if err := publishAs(t, tenant, signedEvent(t, testAgentSecret, 1, now+2, nil, "resumed" )); err != nil { |
| 213 | t.Fatalf("resumed agent note: %v" , err) |
| 214 | } |
| 215 | result, err := tenant.Execute(ctx, owner, "pauseallagents" , nil) |
| 216 | if err != nil || result.(map[string]any)["changed" ] != 1 { |
| 217 | t.Fatalf("pauseallagents = %v %v" , result, err) |
| 218 | } |
| 219 | if err := publishAs(t, tenant, signedEvent(t, testAgentSecret, 1, now+3, nil, "all paused" )); err == nil || !strings.Contains(err.Error(), "paused" ) { |
| 220 | t.Fatalf("note during pause: %v" , err) |
| 221 | } |
| 222 | if result, err := tenant.Execute(ctx, owner, "resumeallagents" , nil); err != nil || result.(map[string]any)["changed" ] != 1 { |
| 223 | t.Fatalf("resumeallagents = %v %v" , result, err) |
| 224 | } |
| 225 | if _, err := tenant.Execute(ctx, owner, "revokeagent" , param); err != nil { |
| 226 | t.Fatal(err) |
| 227 | } |
| 228 | if role, _ := tenant.community.Role(ctx, agent); role != "" { |
| 229 | t.Fatalf("role after revoke = %q" , role) |
| 230 | } |
| 231 | if err := publishAs(t, tenant, signedEvent(t, testAgentSecret, 1, now+4, nil, "revoked" )); err == nil || !strings.Contains(err.Error(), "revoked" ) { |
| 232 | t.Fatalf("revoked agent note: %v" , err) |
| 233 | } |
| 234 | var stored int |
| 235 | if err := tenant.store.DB().QueryRow(`SELECT count(*) FROM events WHERE kind=? AND d=?` , event.KIND_AGENT_GRANT, agent).Scan(&stored); err != nil || stored != 1 { |
| 236 | t.Fatalf("grant event kept for audit: %d %v" , stored, err) |
| 237 | } |
| 238 | if _, err := tenant.Execute(ctx, owner, "pauseagent" , param); err == nil { |
| 239 | t.Fatal("paused a revoked agent" ) |
| 240 | } |
| 241 | if _, err := tenant.Execute(ctx, owner, "revokeagent" , []json.RawMessage{json.RawMessage(strconv.Quote(strings.Repeat("f" , 64)))}); err == nil { |
| 242 | t.Fatal("revoked an unknown agent" ) |
| 243 | } |
| 244 | audit, err := tenant.Execute(ctx, owner, "listaudit" , nil) |
| 245 | if err != nil { |
| 246 | t.Fatal(err) |
| 247 | } |
| 248 | seen := map[string]bool{} |
| 249 | for _, row := range audit.([]community.AuditRow) { |
| 250 | seen[row.Action] = true |
| 251 | } |
| 252 | for _, action := range []string{"grantagent" , "pauseagent" , "resumeagent" , "pauseallagents" , "resumeallagents" , "revokeagent" } { |
| 253 | if !seen[action] { |
| 254 | t.Fatalf("audit lacks %s: %v" , action, seen) |
| 255 | } |
| 256 | } |
| 257 | } |
| 258 | |
| 259 | func TestBrowseAgentReturnsGrantAndRecentEventsForOperators(t *testing.T) { |
| 260 | _, tenant := testTenant(t) |
| 261 | ctx := context.Background() |
| 262 | owner := tenant.Policy().Owner |
| 263 | agent, _ := event.PublicKey(testAgentSecret) |
| 264 | member, _ := event.PublicKey(testMemberSecret) |
| 265 | moderator, _ := event.PublicKey(testModSecret) |
| 266 | now := time.Now().Unix() |
| 267 | for _, target := range []struct { |
| 268 | pubkey, role string |
| 269 | }{{member, "member" }, {moderator, "moderator" }} { |
| 270 | raw, _ := json.Marshal(map[string]string{"role" : target.role}) |
| 271 | if _, err := tenant.Execute(ctx, owner, "setmember" , []json.RawMessage{json.RawMessage(strconv.Quote(target.pubkey)), raw}); err != nil { |
| 272 | t.Fatal(err) |
| 273 | } |
| 274 | } |
| 275 | if err := publishAs(t, tenant, agentGrantEvent(t, agent, now, now+3600, []string{"k" , "1" }, []string{"room" , "build" })); err != nil { |
| 276 | t.Fatal(err) |
| 277 | } |
| 278 | for i := int64(0); i < 12; i++ { |
| 279 | if err := publishAs(t, tenant, signedEvent(t, testAgentSecret, 1, now-20+i, nil, "note " +strconv.FormatInt(i, 10))); err != nil { |
| 280 | t.Fatal(err) |
| 281 | } |
| 282 | } |
| 283 | params := []json.RawMessage{json.RawMessage(`{"agent":"` + agent + `"}` )} |
| 284 | for _, actor := range []string{member, agent, "" } { |
| 285 | if _, err := tenant.Execute(ctx, actor, "browseagent" , params); err == nil || !strings.HasPrefix(err.Error(), "restricted:" ) { |
| 286 | t.Fatalf("browseagent by %q: %v" , actor, err) |
| 287 | } |
| 288 | } |
| 289 | for _, actor := range []string{owner, moderator} { |
| 290 | result, err := tenant.Execute(ctx, actor, "browseagent" , params) |
| 291 | if err != nil { |
| 292 | t.Fatalf("browseagent by %s: %v" , actor, err) |
| 293 | } |
| 294 | detail := result.(map[string]any) |
| 295 | summary := detail["agent" ].(community.AgentSummary) |
| 296 | events := detail["events" ].([]event.Event) |
| 297 | if summary.Agent != agent || summary.Name != "helper" || summary.Scope.Rooms[0] != "build" || summary.LastEvent != now-9 { |
| 298 | t.Fatalf("browseagent summary = %+v" , summary) |
| 299 | } |
| 300 | if len(events) != 10 || events[0].CreatedAt != now-9 || events[9].CreatedAt != now-18 { |
| 301 | t.Fatalf("browseagent events = %d newest %d" , len(events), events[0].CreatedAt) |
| 302 | } |
| 303 | } |
| 304 | if _, err := tenant.Execute(ctx, owner, "browseagent" , []json.RawMessage{json.RawMessage(`{"agent":"` + strings.Repeat("e" , 64) + `"}` )}); err == nil || !strings.HasPrefix(err.Error(), "not found:" ) { |
| 305 | t.Fatalf("unknown agent: %v" , err) |
| 306 | } |
| 307 | if _, err := tenant.Execute(ctx, owner, "browseagent" , []json.RawMessage{json.RawMessage(`{"agent":"nope"}` )}); err == nil || !strings.HasPrefix(err.Error(), "invalid:" ) { |
| 308 | t.Fatalf("bad agent key: %v" , err) |
| 309 | } |
| 310 | } |