| 1 | package tinyclient |
| 2 | |
| 3 | import ( |
| 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. |
| 17 | type agentsBackend struct { |
| 18 | fakeBackend |
| 19 | calls []string |
| 20 | } |
| 21 | |
| 22 | var ( |
| 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 | |
| 29 | func (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 | |
| 73 | func 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 * 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 owner pubkey:identifier:read 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=""` + agentActive + `""><button>Pause</button></rpc-form>` , |
| 110 | `<rpc-form method="revokeagent" refresh><input type="hidden" name="param" value=""` + agentActive + `""><button>Revoke</button></rpc-form>` , |
| 111 | `<rpc-form method="resumeagent" refresh><input type="hidden" name="param" value=""` + agentPaused + `""><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=""cb-active""><button>Pause</button></rpc-form><rpc-form method="removecallback" refresh><input type="hidden" name="param" value=""cb-active""><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=""cb-paused""><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 | |
| 157 | type emptyAgentsBackend struct{ fakeBackend } |
| 158 | |
| 159 | func (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 | |
| 164 | func 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 | |
| 190 | func 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 | |
| 229 | func 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 * ttl=7">` + agentSite + ` ttl=30 encrypted |
| 248 | *</textarea>`, |
| 249 | `placeholder="1, 1111, 1621" value="9, 1111, 1621">` , |
| 250 | `<button>Sign the replacement</button>` , |
| 251 | `&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 | } |