clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat/standalone-relay
tinyrelay / tinyclient / agents_test.go
agents_test.go 14.1K
1package tinyclient23import (4 "context"5 "encoding/json"6 "net/http"7 "net/http/httptest"8 "strings"9 "testing"10 "time"1112 "github.com/FelineStateMachine/tinyrelay/internal/policy"13)1415// agentsBackend answers listagents with three grants in every state and16// browseagent with the selected grant plus two events.17type agentsBackend struct {18 fakeBackend19 calls []string20}2122var (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)2829func (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 = params32 owner := b.policy.Owner33 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, nil49 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 }, nil55 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 }}, nil66 }67 }68 return nil, context.Canceled69 }70 return map[string]any{"method": method}, nil71}7273func 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 = nil150 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}156157type emptyAgentsBackend struct{ fakeBackend }158159func (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{}, nil162}163164func 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 }181182 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}189190func TestAgentStateHelpers(t *testing.T) {191 now := time.Now().Unix()192 for _, tc := range []struct {193 agent map[string]any194 state string195 since string196 }{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}228229func 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:maintain245` + 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 encrypted248*</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}