clone

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

feat/standalone-relay
tinyrelay / internal / daemon / agents_test.go
agents_test.go 12.7K
1package daemon23import (4 "context"5 "encoding/json"6 "net/http"7 "net/http/httptest"8 "strconv"9 "strings"10 "testing"11 "time"1213 "github.com/FelineStateMachine/tinyrelay/internal/community"14 "github.com/FelineStateMachine/tinyrelay/internal/event"15 "github.com/FelineStateMachine/tinyrelay/internal/relay"16)1718const (19 testOwnerSecret = "0000000000000000000000000000000000000000000000000000000000000001"20 testAgentSecret = "0000000000000000000000000000000000000000000000000000000000000002"21 testMemberSecret = "0000000000000000000000000000000000000000000000000000000000000003"22 testModSecret = "0000000000000000000000000000000000000000000000000000000000000004"23)2425func 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 e35}3637func 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}4243func 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 err47}4849func 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 string62 var expires int6463 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] = true84 }85 if e.Kind == event.KIND_ROLE_DEF && tag[0] == "d" && tag[1] == "agent" {86 labelled[e.Kind] = true87 }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 := false94 for _, capability := range tenant.Capabilities(nil) {95 if capability.ID == "agents" && capability.Status == "enabled" {96 found = true97 }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.RawMessage107 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 int64136 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}143144func capabilityIDs(tenant *Tenant) map[string]bool {145 out := map[string]bool{}146 for _, capability := range tenant.Capabilities(nil) {147 out[capability.ID] = true148 }149 return out150}151152func TestAgentManagementMethodsAndPermissions(t *testing.T) {153 _, tenant := testTenant(t)154 ctx := context.Background()155 owner := tenant.Policy().Owner156 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 string162 }{{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 int235 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] = true251 }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}258259func TestBrowseAgentReturnsGrantAndRecentEventsForOperators(t *testing.T) {260 _, tenant := testTenant(t)261 ctx := context.Background()262 owner := tenant.Policy().Owner263 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 string269 }{{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}