tinyrelayfile
tinyrelay/internal/daemon/agents_test.go/

internal/daemon/agents_test.go raw
1package daemon
2
3import (
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
18const (
19 testOwnerSecret = "0000000000000000000000000000000000000000000000000000000000000001"
20 testAgentSecret = "0000000000000000000000000000000000000000000000000000000000000002"
21 testMemberSecret = "0000000000000000000000000000000000000000000000000000000000000003"
22 testModSecret = "0000000000000000000000000000000000000000000000000000000000000004"
23)
24
25func 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
37func 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
43func 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
49func 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
144func 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
152func 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
259func 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}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs