tinyrelayfile
tinyrelay/tinyclient/agents_test.go/

tinyclient/agents_test.go raw
1package tinyclient
2
3import (
4 "context"
5 "encoding/json"
6 "net/http"
7 "net/http/httptest"
8 "strings"
9 "testing"
10 "time"
11
12 "github.com/FelineStateMachine/tinyrelay/internal/policy"
13)
14
15// agentsBackend answers listagents with three grants in every state and
16// browseagent with the selected grant plus two events.
17type agentsBackend struct {
18 fakeBackend
19 calls []string
20}
21
22var (
23 agentActive = strings.Repeat("1", 64)
24 agentPaused = strings.Repeat("2", 64)
25 agentRevoked = strings.Repeat("3", 64)
26 agentSite = "npub1" + strings.Repeat("q", 58)
27)
28
29func (b *agentsBackend) Query(_ context.Context, method string, params []json.RawMessage, actor string) (any, error) {
30 b.calls = append(b.calls, method+":"+actor)
31 b.params = params
32 owner := b.policy.Owner
33 now := time.Now().Unix()
34 scope := func(rooms []string, repos []map[string]any, wiki string, kinds []int) map[string]any {
35 return map[string]any{"rooms": rooms, "repos": repos, "wiki": wiki, "kinds": kinds, "rate": 60}
36 }
37 hermes := scope([]string{"build", "agents"}, []map[string]any{{"owner": owner, "identifier": "tinyrelay", "level": "maintain"}, {"owner": owner, "identifier": "docs", "level": "propose"}}, "propose", []int{9, 1111, 1621})
38 hermes["sites"] = []map[string]any{{"label": agentSite, "ttl": 30, "encrypted": true}, {"label": "*"}}
39 agents := []any{
40 map[string]any{"pubkey": agentActive, "owner": owner, "name": "hermes", "expires": now + 86400*30, "paused": false, "revoked": 0, "lastEvent": now - 120, "scope": hermes},
41 map[string]any{"pubkey": agentPaused, "owner": owner, "name": "reviewer-bot", "expires": now + 86400*30, "paused": true, "revoked": 0, "lastEvent": 0,
42 "scope": scope([]string{"build"}, []map[string]any{{"owner": owner, "identifier": "tinyrelay", "level": "read"}}, "", nil)},
43 map[string]any{"pubkey": agentRevoked, "owner": strings.Repeat("c", 64), "name": "", "expires": now + 86400*30, "paused": false, "revoked": now - 86400, "lastEvent": now - 86400*2,
44 "scope": scope([]string{"general"}, nil, "", []int{9})},
45 }
46 switch method {
47 case "listagents":
48 return agents, nil
49 case "listcallbacks":
50 return []any{
51 map[string]any{"id": "cb-active", "owner": agentActive, "host": "hooks.example", "filter": map[string]any{"kinds": []int{1621, 1111}, "#p": []string{agentActive}}, "created": now - 3600, "lastDelivery": now - 60, "lastStatus": "ok", "failures": 0, "paused": false},
52 map[string]any{"id": "cb-paused", "owner": agentActive, "host": "worker.example:8443", "filter": map[string]any{"kinds": []int{9}}, "created": now - 3600, "lastDelivery": 0, "lastStatus": "paused after 20 failures: HTTP 500", "failures": 20, "paused": true},
53 map[string]any{"id": "cb-other", "owner": strings.Repeat("c", 64), "host": "elsewhere.example", "filter": map[string]any{"kinds": []int{1}}, "created": now, "lastDelivery": 0, "lastStatus": "", "failures": 0, "paused": false},
54 }, nil
55 case "browseagent":
56 var query struct {
57 Agent string `json:"agent"`
58 }
59 _ = json.Unmarshal(params[0], &query)
60 for _, agent := range agents {
61 if agent.(map[string]any)["pubkey"] == query.Agent {
62 return map[string]any{"agent": agent, "events": []any{
63 map[string]any{"id": strings.Repeat("d", 64), "kind": 9, "pubkey": query.Agent, "created_at": now - 120, "content": "Draft is up as wiki: release-notes-1-4", "tags": [][]string{}},
64 map[string]any{"id": strings.Repeat("e", 64), "kind": 1111, "pubkey": query.Agent, "created_at": now - 3600, "content": "Reviewed PR 23: two nits, see inline.", "tags": [][]string{}},
65 }}, nil
66 }
67 }
68 return nil, context.Canceled
69 }
70 return map[string]any{"method": method}, nil
71}
72
73func TestAgentsPageRendersTableCardsActivityAndGrantForm(t *testing.T) {
74 backend := &agentsBackend{fakeBackend: fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}}
75 app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return backend.policy.Owner, nil }})
76 if err != nil {
77 t.Fatal(err)
78 }
79 recorder := httptest.NewRecorder()
80 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/manage/agents", nil))
81 body := recorder.Body.String()
82 if recorder.Code != http.StatusOK {
83 t.Fatalf("status=%d body=%s", recorder.Code, body)
84 }
85 if strings.Contains(body, "class=") {
86 t.Fatal("agents page carries a class attribute")
87 }
88 if strings.Contains(body, "\u00b7") {
89 t.Fatal("agents page contains a middle dot")
90 }
91 for _, want := range []string{
92 `<a href="/manage/agents" aria-current="page">/agents</a>`,
93 `<table id="rows">`,
94 `<a href="#agent-` + agentActive + `">hermes</a>`,
95 `rooms: build, agents | repos: tinyrelay (maintain), docs (propose) | wiki: propose | kinds: 9, 1111, 1621 | sites: ` + agentSite + `, *`,
96 `<tr><th>sites</th><td><code>` + agentSite + `</code> 30 days encrypted<br><code>*</code></td></tr>`,
97 `<tr><th>sites</th><td>none</td></tr>`,
98 `<textarea name="sites" placeholder="npub1... ttl=30 encrypted&#10;* ttl=7"></textarea>`,
99 `<td data-state="active">active | `,
100 `<td data-state="paused">paused</td>`,
101 `<td data-state="revoked">revoked | `,
102 `<a href="#agent-` + agentRevoked + `">` + shortID(agentRevoked) + `</a>`,
103 `<agent-card id="agent-` + agentActive + `">`,
104 `<h3>hermes <small><span data-state="active">active</span> | signed by you | expires `,
105 `<code>tinyrelay</code> maintain, owner <nostr-name pubkey="` + backend.policy.Owner + `"`,
106 `<br><code>docs</code> propose, owner <nostr-name pubkey="` + backend.policy.Owner + `"`,
107 `<textarea name="repos" placeholder="owner pubkey:identifier:propose&#10;owner pubkey:identifier:read&#10;owner pubkey:identifier:maintain">`,
108 `<th>rate</th><td>60 events per minute</td>`,
109 `<rpc-form method="pauseagent" refresh><input type="hidden" name="param" value="&quot;` + agentActive + `&quot;"><button>Pause</button></rpc-form>`,
110 `<rpc-form method="revokeagent" refresh><input type="hidden" name="param" value="&quot;` + agentActive + `&quot;"><button>Revoke</button></rpc-form>`,
111 `<rpc-form method="resumeagent" refresh><input type="hidden" name="param" value="&quot;` + agentPaused + `&quot;"><button>Resume</button></rpc-form>`,
112 `<h3>Recent activity of hermes</h3>`,
113 `<ol id="events">`,
114 `<nostr-event id="event-` + strings.Repeat("d", 64) + `">`,
115 `Reviewed PR 23: two nits, see inline.`,
116 `<agent-grant>`,
117 `<select name="key"><option value="generate">generate here, show once</option><option value="paste">paste a public key</option></select>`,
118 `<textarea name="repos"`,
119 `<input name="expires" type="date" value="` + dateAfter(90) + `" required>`,
120 `<h4>Agents</h4><table><tr><th>active</th><td>1</td></tr><tr><th>paused</th><td>1</td></tr><tr><th>revoked</th><td>1</td></tr><tr><th>callbacks</th><td>2 active, 1 paused</td></tr></table>`,
121 `<tr><th>callbacks</th><td><agent-callback data-state="active"><code>hooks.example</code> kinds 1621, 1111 | <span data-state="active" title="ok">active</span> | delivered <time datetime="`,
122 `<rpc-form method="pausecallback" refresh><input type="hidden" name="param" value="&quot;cb-active&quot;"><button>Pause</button></rpc-form><rpc-form method="removecallback" refresh><input type="hidden" name="param" value="&quot;cb-active&quot;"><button>Remove</button></rpc-form></agent-callback>`,
123 `<agent-callback data-state="paused"><code>worker.example:8443</code> kinds 9 | <span data-state="paused" title="paused after 20 failures: HTTP 500">paused</span> <rpc-form method="resumecallback" refresh><input type="hidden" name="param" value="&quot;cb-paused&quot;"><button>Resume</button></rpc-form>`,
124 `<tr><th>callbacks</th><td>none</td></tr>`,
125 `<code>/mcp</code> 2026-07-28`,
126 `<rpc-form method="pauseallagents" refresh><button>Pause all agents</button></rpc-form>`,
127 `<rpc-form method="resumeallagents" refresh><button>Resume all agents</button></rpc-form>`,
128 } {
129 if !strings.Contains(body, want) {
130 t.Errorf("agents page missing %q", want)
131 }
132 }
133 // The revoked agent keeps its facts but offers no controls.
134 revokedCard := body[strings.Index(body, `<agent-card id="agent-`+agentRevoked+`">`):]
135 revokedCard = revokedCard[:strings.Index(revokedCard, "</agent-card>")]
136 if strings.Contains(revokedCard, "<rpc-form") || !strings.Contains(revokedCard, `<span data-state="revoked">revoked</span>`) {
137 t.Errorf("revoked card should show state without controls: %s", revokedCard)
138 }
139 if strings.Contains(body, "elsewhere.example") {
140 t.Error("a callback of a key without a card was shown")
141 }
142 if got := strings.Join(backend.calls, " "); got != "listagents:"+backend.policy.Owner+" listcallbacks:"+backend.policy.Owner+" browseagent:"+backend.policy.Owner {
143 t.Fatalf("backend calls = %s", got)
144 }
145 if !strings.Contains(string(backend.params[0]), agentActive) {
146 t.Fatalf("default selection should be the first active agent: %s", backend.params[0])
147 }
148 // ?agent= selects whose activity shows.
149 backend.calls = nil
150 recorder = httptest.NewRecorder()
151 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/manage/agents?agent="+agentPaused, nil))
152 if body := recorder.Body.String(); !strings.Contains(body, `<h3>Recent activity of reviewer-bot</h3>`) {
153 t.Fatalf("selected agent activity missing: %s", body[:min(500, len(body))])
154 }
155}
156
157type emptyAgentsBackend struct{ fakeBackend }
158
159func (b *emptyAgentsBackend) Query(ctx context.Context, method string, params []json.RawMessage, actor string) (any, error) {
160 _, _ = b.fakeBackend.Query(ctx, method, params, actor)
161 return []any{}, nil
162}
163
164func TestAgentsPageWithoutAgentsAndForGuests(t *testing.T) {
165 backend := &emptyAgentsBackend{fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}}
166 app, err := New(backend, Options{Actor: func(r *http.Request) (string, error) { return r.Header.Get("X-Actor"), nil }})
167 if err != nil {
168 t.Fatal(err)
169 }
170 request := httptest.NewRequest(http.MethodGet, "/manage/agents", nil)
171 request.Header.Set("X-Actor", backend.policy.Owner)
172 recorder := httptest.NewRecorder()
173 app.ServeHTTP(recorder, request)
174 body := recorder.Body.String()
175 if recorder.Code != http.StatusOK || !strings.Contains(body, "No agents yet.") || !strings.Contains(body, "<agent-grant>") || strings.Contains(body, "<agent-card") {
176 t.Fatalf("empty agents page: %d %s", recorder.Code, body[:min(600, len(body))])
177 }
178 if backend.call != "listcallbacks:"+backend.policy.Owner {
179 t.Fatalf("empty list should not query an agent: %s", backend.call)
180 }
181
182 recorder = httptest.NewRecorder()
183 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/manage/agents", nil))
184 body = recorder.Body.String()
185 if recorder.Code != http.StatusOK || !strings.Contains(body, "Sign in to manage this relay.") || strings.Contains(body, "<agent-grant>") || strings.Contains(body, "pauseallagents") {
186 t.Fatalf("guest saw the agents page: %d %s", recorder.Code, body[:min(600, len(body))])
187 }
188}
189
190func TestAgentStateHelpers(t *testing.T) {
191 now := time.Now().Unix()
192 for _, tc := range []struct {
193 agent map[string]any
194 state string
195 since string
196 }{
197 {map[string]any{"expires": float64(now + 3600), "lastEvent": float64(now - 60)}, "active", short(now - 60)},
198 {map[string]any{"expires": float64(now + 3600), "paused": true}, "paused", ""},
199 {map[string]any{"expires": float64(now + 3600), "revoked": float64(now - 10)}, "revoked", short(now - 10)},
200 {map[string]any{"expires": float64(now - 10), "paused": true}, "revoked", "expired " + short(now-10)},
201 } {
202 if got := agentState(tc.agent); got != tc.state {
203 t.Errorf("agentState(%v) = %q, want %q", tc.agent, got, tc.state)
204 }
205 if got := agentSince(tc.agent); got != tc.since {
206 t.Errorf("agentSince(%v) = %q, want %q", tc.agent, got, tc.since)
207 }
208 }
209 if got := agentScope(map[string]any{"scope": map[string]any{}}); got != "profile and relay list only" {
210 t.Errorf("empty scope = %q", got)
211 }
212 counts := agentCounts([]any{map[string]any{"expires": float64(now + 1)}, map[string]any{"expires": float64(now + 1), "paused": true}})
213 if counts["active"] != 1 || counts["paused"] != 1 || counts["revoked"] != 0 {
214 t.Errorf("counts = %v", counts)
215 }
216 callbacks := []any{
217 map[string]any{"owner": agentActive, "paused": false, "filter": map[string]any{"kinds": []any{float64(1621), float64(1111)}}},
218 map[string]any{"owner": agentActive, "paused": true, "filter": map[string]any{}},
219 map[string]any{"owner": agentPaused, "paused": false},
220 }
221 if mine := callbacksFor(callbacks, agentActive); len(mine) != 2 || callbackKinds(mine[0]) != "1621, 1111" || callbackKinds(mine[1]) != "" || callbackState(mine[1]) != "paused" {
222 t.Errorf("callbacksFor = %v", mine)
223 }
224 if counts := callbackCounts(callbacks); counts["active"] != 2 || counts["paused"] != 1 {
225 t.Errorf("callback counts = %v", counts)
226 }
227}
228
229func TestAgentsPageEditPrefillsTheGrantForm(t *testing.T) {
230 backend := &agentsBackend{fakeBackend: fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}}
231 app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return backend.policy.Owner, nil }})
232 if err != nil {
233 t.Fatal(err)
234 }
235 recorder := httptest.NewRecorder()
236 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/manage/agents?agent="+agentActive+"&edit="+agentActive, nil))
237 body := recorder.Body.String()
238 for _, marker := range []string{
239 `<h3 id="grant">Replace the grant for hermes</h3>`,
240 `<input name="name" maxlength="64" required placeholder="release-notes" value="hermes">`,
241 `<option value="paste" selected>paste a public key</option>`,
242 `<input name="pubkey" pattern="[0-9a-f]{64}" placeholder="64 hex characters" value="` + agentActive + `">`,
243 `placeholder="build, agents" value="build, agents">`,
244 `>` + strings.Repeat("a", 64) + `:tinyrelay:maintain
245` + strings.Repeat("a", 64) + `:docs:propose</textarea>`,
246 `<option value="propose" selected>propose</option>`,
247 `<textarea name="sites" placeholder="npub1... ttl=30 encrypted&#10;* ttl=7">` + agentSite + ` ttl=30 encrypted
248*</textarea>`,
249 `placeholder="1, 1111, 1621" value="9, 1111, 1621">`,
250 `<button>Sign the replacement</button>`,
251 `&amp;edit=` + agentActive + `#grant">edit</a>`,
252 } {
253 if !strings.Contains(body, marker) {
254 t.Errorf("edit form missing %q", marker)
255 }
256 }
257 recorder = httptest.NewRecorder()
258 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/manage/agents?edit=nonsense", nil))
259 if body := recorder.Body.String(); !strings.Contains(body, `<h3 id="grant">New agent</h3>`) || strings.Contains(body, "selected>paste") {
260 t.Fatal("unknown edit key did not fall back to the blank form")
261 }
262}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs