clone

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

feat/tinyagent
tinyrelay / internal / gates / agents_test.go
agents_test.go 19.7K
1package gates23import (4 "context"5 "database/sql"6 "encoding/json"7 "strconv"8 "strings"9 "testing"1011 "github.com/FelineStateMachine/tinyrelay/internal/community"12 "github.com/FelineStateMachine/tinyrelay/internal/event"13 "github.com/FelineStateMachine/tinyrelay/internal/policy"14 "github.com/FelineStateMachine/tinyrelay/internal/relay"15 "github.com/FelineStateMachine/tinyrelay/internal/sites"16 "github.com/FelineStateMachine/tinyrelay/internal/storage"17)1819const (20 agentOwnerSecret = "0000000000000000000000000000000000000000000000000000000000000001"21 agentSecret = "0000000000000000000000000000000000000000000000000000000000000002"22 agentOtherSecret = "0000000000000000000000000000000000000000000000000000000000000003"23 agentNow = int64(1_700_000_000)24)2526type agentFixture struct {27 gate *Gate28 community *community.Service29 store *storage.Store30 owner string31 agent string32}3334func newAgentFixture(t *testing.T) agentFixture {35 t.Helper()36 ctx := context.Background()37 store, err := storage.Open(ctx, t.TempDir()+"/relay.db")38 if err != nil {39 t.Fatal(err)40 }41 t.Cleanup(func() { _ = store.Close() })42 owner, _ := event.PublicKey(agentOwnerSecret)43 agent, _ := event.PublicKey(agentSecret)44 svc, err := community.New(ctx, store, owner)45 if err != nil {46 t.Fatal(err)47 }48 p := policy.Defaults(owner)49 gate, err := New(Config{Store: store, Community: svc, Policy: func() policy.Policy { return p }, Slug: "general"})50 if err != nil {51 t.Fatal(err)52 }53 return agentFixture{gate: gate, community: svc, store: store, owner: owner, agent: agent}54}5556func signed(t *testing.T, secret string, kind int, createdAt int64, tags [][]string, content string) event.Event {57 t.Helper()58 if tags == nil {59 tags = [][]string{}60 }61 e := event.Event{Kind: kind, CreatedAt: createdAt, Tags: tags, Content: content}62 if err := event.Sign(&e, secret); err != nil {63 t.Fatal(err)64 }65 return e66}6768func grantTags(agent string, expires int64, extra ...[]string) [][]string {69 tags := [][]string{{"d", agent}, {"p", agent}, {"name", "helper"}, {"expiration", strconv.FormatInt(expires, 10)}}70 return append(tags, extra...)71}7273// grant publishes a grant through the gate and mirrors it as the tenant74// would. Later grants for the same agent need a later createdAt to replace75// the earlier one.76func (f agentFixture) grant(t *testing.T, createdAt int64, tags [][]string) event.Event {77 t.Helper()78 ctx := context.Background()79 e := signed(t, agentOwnerSecret, event.KIND_AGENT_GRANT, createdAt, tags, "")80 if err := f.gate.Write(ctx, e, relay.Session{PubKeys: []string{f.owner}}, agentNow); err != nil {81 t.Fatalf("grant rejected: %v", err)82 }83 if err := f.store.WithTx(ctx, func(tx *sql.Tx) error {84 if _, err := storage.SaveTx(ctx, tx, e, storage.SaveOptions{Now: agentNow}); err != nil {85 return err86 }87 return f.community.ApplyAgentEventTx(ctx, tx, e, agentNow)88 }); err != nil {89 t.Fatal(err)90 }91 return e92}9394func expectRestricted(t *testing.T, err error, fragment string) {95 t.Helper()96 if err == nil || !strings.HasPrefix(err.Error(), "restricted: agent grant") || !strings.Contains(err.Error(), fragment) {97 t.Fatalf("want restricted agent grant error containing %q, got %v", fragment, err)98 }99}100101func TestAgentGrantScopesKindsRoomsAndRepositories(t *testing.T) {102 f := newAgentFixture(t)103 ctx := context.Background()104 repo := f.owner + ":project"105 f.grant(t, agentNow, grantTags(f.agent, agentNow+3600, []string{"room", "general"}, []string{"repo", repo + ":read"}, []string{"k", "1"}, []string{"k", "1621"}, []string{"k", "1630"}, []string{"k", "1111"}))106 if role, _ := f.community.Role(ctx, f.agent); role != "agent" {107 t.Fatalf("role = %q, want agent", role)108 }109 session := relay.Session{PubKeys: []string{f.agent}}110 if err := f.gate.Write(ctx, signed(t, agentSecret, 1, agentNow, nil, "hello"), session, agentNow); err != nil {111 t.Fatalf("allowed kind rejected: %v", err)112 }113 if err := f.gate.Write(ctx, signed(t, agentSecret, 0, agentNow, nil, "{}"), session, agentNow); err != nil {114 t.Fatalf("profile rejected: %v", err)115 }116 expectRestricted(t, f.gate.Write(ctx, signed(t, agentSecret, 7, agentNow, nil, "+"), session, agentNow), "kind 7")117 if err := f.gate.Write(ctx, signed(t, agentSecret, 1, agentNow, [][]string{{"h", "general"}}, "in room"), session, agentNow); err != nil {118 t.Fatalf("allowed room rejected: %v", err)119 }120 expectRestricted(t, f.gate.Write(ctx, signed(t, agentSecret, 1, agentNow, [][]string{{"h", "other"}}, "wrong room"), session, agentNow), "room other")121 address := "30617:" + repo122 if err := f.gate.Write(ctx, signed(t, agentSecret, 1621, agentNow, [][]string{{"a", address}}, "issue"), session, agentNow); err != nil {123 t.Fatalf("issue in readable repository rejected: %v", err)124 }125 if err := f.gate.Write(ctx, signed(t, agentSecret, 1111, agentNow, [][]string{{"A", address}}, "comment"), session, agentNow); err != nil {126 t.Fatalf("comment in readable repository rejected: %v", err)127 }128 expectRestricted(t, f.gate.Write(ctx, signed(t, agentSecret, 1630, agentNow, [][]string{{"a", address}}, "status"), session, agentNow), "status changes")129 expectRestricted(t, f.gate.Write(ctx, signed(t, agentSecret, 1621, agentNow, [][]string{{"a", "30617:" + f.owner + ":elsewhere"}}, "issue"), session, agentNow), "repository")130 expectRestricted(t, f.gate.Write(ctx, signed(t, agentSecret, 1621, agentNow, nil, "issue"), session, agentNow), "repository address")131 f.grant(t, agentNow+1, grantTags(f.agent, agentNow+3600, []string{"repo", repo + ":maintain"}, []string{"k", "1630"}))132 if err := f.gate.Write(ctx, signed(t, agentSecret, 1630, agentNow, [][]string{{"a", address}}, "status"), session, agentNow); err != nil {133 t.Fatalf("maintainer status rejected: %v", err)134 }135 // Propose publishes the same kinds as read; status changes still need136 // maintain.137 f.grant(t, agentNow+2, grantTags(f.agent, agentNow+3600, []string{"repo", repo + ":propose"}, []string{"k", "1621"}, []string{"k", "1618"}, []string{"k", "1617"}, []string{"k", "1111"}, []string{"k", "1630"}))138 for _, kind := range []int{1621, 1618, 1617, 1111} {139 if err := f.gate.Write(ctx, signed(t, agentSecret, kind, agentNow, [][]string{{"a", address}}, "proposal"), session, agentNow); err != nil {140 t.Fatalf("kind %d in proposable repository rejected: %v", kind, err)141 }142 }143 expectRestricted(t, f.gate.Write(ctx, signed(t, agentSecret, 1630, agentNow, [][]string{{"a", address}}, "status"), session, agentNow), "status changes")144 if _, err := community.ParseAgentGrant(signed(t, agentOwnerSecret, event.KIND_AGENT_GRANT, agentNow, grantTags(f.agent, agentNow+3600, []string{"repo", repo + ":admin"}), ""), agentNow); err == nil || !strings.Contains(err.Error(), ":propose, :read or :maintain") {145 t.Fatalf("unknown level accepted: %v", err)146 }147 // Wiki access carries the wiki kinds without listing them.148 f.grant(t, agentNow+3, grantTags(f.agent, agentNow+3600, []string{"wiki", "propose"}))149 for _, kind := range []int{event.KIND_WIKI_ARTICLE, event.KIND_WIKI_MERGE} {150 if err := f.gate.Write(ctx, signed(t, agentSecret, kind, agentNow, [][]string{{"d", "notes"}, {"title", "Notes"}, {"a", "30818:" + f.owner + ":notes"}, {"p", f.owner}, {"e", strings.Repeat("e", 64), "", "source"}}, "wiki"), session, agentNow); err != nil {151 t.Fatalf("wiki kind %d rejected for propose: %v", kind, err)152 }153 }154 expectRestricted(t, f.gate.Write(ctx, signed(t, agentSecret, event.KIND_WIKI_REDIRECT, agentNow, [][]string{{"d", "notes"}}, ""), session, agentNow), "kind 30819")155 f.grant(t, agentNow+4, grantTags(f.agent, agentNow+3600, []string{"wiki", "edit"}))156 if err := f.gate.Write(ctx, signed(t, agentSecret, event.KIND_WIKI_REDIRECT, agentNow, [][]string{{"d", "notes"}, {"redirect", "30818:" + f.owner + ":notes"}}, ""), session, agentNow); err != nil {157 t.Fatalf("redirect rejected for edit: %v", err)158 }159}160161func TestAgentGrantStateRejectsWrites(t *testing.T) {162 f := newAgentFixture(t)163 ctx := context.Background()164 session := relay.Session{PubKeys: []string{f.agent}}165 note := signed(t, agentSecret, 1, agentNow, nil, "hello")166 f.grant(t, agentNow, grantTags(f.agent, agentNow+60, []string{"k", "1"}))167 if err := f.gate.Write(ctx, note, session, agentNow); err != nil {168 t.Fatalf("active grant rejected: %v", err)169 }170 expectRestricted(t, f.gate.Write(ctx, note, session, agentNow+61), "expired")171 if _, err := f.community.SetAgentPaused(ctx, f.owner, f.agent, true); err != nil {172 t.Fatal(err)173 }174 expectRestricted(t, f.gate.Write(ctx, note, session, agentNow), "paused")175 if _, err := f.community.SetAgentPaused(ctx, f.owner, f.agent, false); err != nil {176 t.Fatal(err)177 }178 if err := f.gate.Write(ctx, note, session, agentNow); err != nil {179 t.Fatalf("resumed grant rejected: %v", err)180 }181 if _, err := f.community.RevokeAgent(ctx, f.owner, f.agent, agentNow); err != nil {182 t.Fatal(err)183 }184 expectRestricted(t, f.gate.Write(ctx, note, session, agentNow), "revoked")185 if role, _ := f.community.Role(ctx, f.agent); role != "" {186 t.Fatalf("revoked agent still has role %q", role)187 }188 f.grant(t, agentNow+1, grantTags(f.agent, agentNow-1, []string{"k", "1"}))189 expectRestricted(t, f.gate.Write(ctx, note, session, agentNow), "revoked")190}191192func TestAgentGrantRateLimitSlidesOverOneMinute(t *testing.T) {193 f := newAgentFixture(t)194 ctx := context.Background()195 session := relay.Session{PubKeys: []string{f.agent}}196 f.grant(t, agentNow, grantTags(f.agent, agentNow+3600, []string{"k", "1"}, []string{"rate", "2"}))197 for i := 0; i < 2; i++ {198 if err := f.gate.Write(ctx, signed(t, agentSecret, 1, agentNow+int64(i), nil, "n"), session, agentNow+int64(i)); err != nil {199 t.Fatalf("event %d rejected: %v", i, err)200 }201 }202 expectRestricted(t, f.gate.Write(ctx, signed(t, agentSecret, 1, agentNow+2, nil, "n"), session, agentNow+2), "2 events per minute")203 if err := f.gate.Write(ctx, signed(t, agentSecret, 1, agentNow+61, nil, "n"), session, agentNow+61); err != nil {204 t.Fatalf("event after the window rejected: %v", err)205 }206}207208func TestHumanMemberWithGrantKeepsMemberPermissions(t *testing.T) {209 f := newAgentFixture(t)210 ctx := context.Background()211 raw, _ := json.Marshal(f.agent)212 if _, err := f.community.Execute(ctx, f.owner, "setmember", []json.RawMessage{raw}); err != nil {213 t.Fatal(err)214 }215 f.grant(t, agentNow, grantTags(f.agent, agentNow+3600, []string{"k", "1"}))216 if role, _ := f.community.Role(ctx, f.agent); role != "member" {217 t.Fatalf("grant changed a human role to %q", role)218 }219 if err := f.gate.Write(ctx, signed(t, agentSecret, 7, agentNow, nil, "+"), relay.Session{PubKeys: []string{f.agent}}, agentNow); err != nil {220 t.Fatalf("member constrained by grant: %v", err)221 }222}223224func TestImportEnforcesAgentGrant(t *testing.T) {225 f := newAgentFixture(t)226 ctx := context.Background()227 f.grant(t, agentNow, grantTags(f.agent, agentNow+3600, []string{"k", "1"}))228 if err := f.gate.Import(ctx, signed(t, agentSecret, 1, agentNow, nil, "synced"), agentNow); err != nil {229 t.Fatalf("allowed import rejected: %v", err)230 }231 expectRestricted(t, f.gate.Import(ctx, signed(t, agentSecret, 7, agentNow, nil, "+"), agentNow), "kind 7")232}233234func TestAgentGrantShapeAndSigner(t *testing.T) {235 f := newAgentFixture(t)236 ctx := context.Background()237 other, _ := event.PublicKey(agentOtherSecret)238 cases := []struct {239 name string240 secret string241 tags [][]string242 want string243 }{244 {"stranger signer", agentOtherSecret, grantTags(f.agent, agentNow+60), "restricted: only the owner or a moderator"},245 {"missing expiration", agentOwnerSecret, [][]string{{"d", f.agent}, {"p", f.agent}}, "invalid: agent grant needs an expiration"},246 {"too far out", agentOwnerSecret, grantTags(f.agent, agentNow+366*86400), "invalid: agent grant expiration"},247 {"mismatched p", agentOwnerSecret, [][]string{{"d", f.agent}, {"p", other}, {"expiration", "1700003600"}}, "invalid: agent grant p tag"},248 {"self grant", agentOwnerSecret, grantTags(f.owner, agentNow+60), "invalid: agent grant may not name its signer"},249 {"bad repo", agentOwnerSecret, grantTags(f.agent, agentNow+60, []string{"repo", "nope"}), "invalid: agent grant repo tag"},250 {"bad rate", agentOwnerSecret, grantTags(f.agent, agentNow+60, []string{"rate", "601"}), "invalid: agent grant rate"},251 {"bad wiki", agentOwnerSecret, grantTags(f.agent, agentNow+60, []string{"wiki", "admin"}), "invalid: agent grant wiki tag"},252 }253 for _, tc := range cases {254 t.Run(tc.name, func(t *testing.T) {255 e := signed(t, tc.secret, event.KIND_AGENT_GRANT, agentNow, tc.tags, "")256 err := f.gate.Write(ctx, e, relay.Session{PubKeys: []string{e.PubKey}}, agentNow)257 if err == nil || !strings.HasPrefix(err.Error(), tc.want) {258 t.Fatalf("want %q, got %v", tc.want, err)259 }260 })261 }262 e := signed(t, agentOwnerSecret, event.KIND_AGENT_GRANT, agentNow, grantTags(f.agent, agentNow+60, []string{"k", "1"}, []string{"wiki", "propose"}), `{"note":"ok"}`)263 if err := f.gate.Write(ctx, e, relay.Session{PubKeys: []string{f.owner}}, agentNow); err != nil {264 t.Fatalf("valid grant rejected: %v", err)265 }266 grant, err := community.ParseAgentGrant(e, agentNow)267 if err != nil || grant.Scope.Rate != community.AgentRateDefault || grant.Scope.Wiki != "propose" || grant.Name != "helper" {268 t.Fatalf("parsed grant %+v %v", grant, err)269 }270}271272func TestAgentGrantSitesCoverLabelsAndRequireExpiry(t *testing.T) {273 f := newAgentFixture(t)274 ctx := context.Background()275 session := relay.Session{PubKeys: []string{f.agent}}276 hash := strings.Repeat("a", 64)277 paths := [][]string{{"path", "/index.html", hash}}278 own := sites.SiteLabel(event.Event{Kind: sites.KindSite, PubKey: f.agent})279 base, _ := sites.Base36(f.agent)280 named := base + "docs"281 manifest := func(kind int, tags ...[]string) event.Event {282 return signed(t, agentSecret, kind, agentNow, append(append([][]string{}, paths...), tags...), "")283 }284 // Without a sites tag the manifest kinds stay closed.285 f.grant(t, agentNow, grantTags(f.agent, agentNow+3600, []string{"k", "1"}))286 expectRestricted(t, f.gate.Write(ctx, manifest(sites.KindSite), session, agentNow), "kind 15128")287 // One label without a ttl: that site only, no expiration needed.288 f.grant(t, agentNow+1, grantTags(f.agent, agentNow+3600, []string{"sites", named}))289 if err := f.gate.Write(ctx, manifest(sites.KindNamedSite, []string{"d", "docs"}), session, agentNow); err != nil {290 t.Fatalf("covered named site rejected: %v", err)291 }292 expectRestricted(t, f.gate.Write(ctx, manifest(sites.KindNamedSite, []string{"d", "blog"}), session, agentNow), "site "+base+"blog")293 expectRestricted(t, f.gate.Write(ctx, manifest(sites.KindSite), session, agentNow), "site "+own)294 // A ttl requires an expiration tag within it; the reason says what to add.295 f.grant(t, agentNow+2, grantTags(f.agent, agentNow+3600, []string{"sites", own, "ttl=2"}, []string{"sites", named, "ttl=1", "encrypted"}))296 latest := strconv.FormatInt(agentNow+2*86400, 10)297 err := f.gate.Write(ctx, manifest(sites.KindSite), session, agentNow)298 if err == nil || !strings.HasPrefix(err.Error(), "invalid: agent grant allows site "+own+" for 2 days: add an expiration tag no later than "+latest) {299 t.Fatalf("manifest without expiration: %v", err)300 }301 err = f.gate.Write(ctx, manifest(sites.KindSite, []string{"expiration", strconv.FormatInt(agentNow+3*86400, 10)}), session, agentNow)302 if err == nil || !strings.Contains(err.Error(), "set the expiration tag no later than "+latest) {303 t.Fatalf("manifest expiring too late: %v", err)304 }305 if err := f.gate.Write(ctx, manifest(sites.KindSite, []string{"expiration", latest}), session, agentNow); err != nil {306 t.Fatalf("manifest within the ttl rejected: %v", err)307 }308 if err := f.gate.Write(ctx, manifest(sites.KindNamedSite, []string{"d", "docs"}, []string{"expiration", strconv.FormatInt(agentNow+86400, 10)}), session, agentNow); err != nil {309 t.Fatalf("named manifest within its own ttl rejected: %v", err)310 }311 if err := f.gate.Write(ctx, manifest(sites.KindNamedSite, []string{"d", "docs"}, []string{"expiration", latest}), session, agentNow); err == nil {312 t.Fatal("named manifest beyond its own ttl accepted")313 }314 // A star covers every label under the key and the longest ttl applies.315 grant := f.grant(t, agentNow+3, grantTags(f.agent, agentNow+3600, []string{"sites", "*", "ttl=5"}, []string{"sites", named, "ttl=1"}))316 if err := f.gate.Write(ctx, manifest(sites.KindNamedSite, []string{"d", "blog"}, []string{"expiration", strconv.FormatInt(agentNow+5*86400, 10)}), session, agentNow); err != nil {317 t.Fatalf("star label rejected: %v", err)318 }319 if err := f.gate.Write(ctx, manifest(sites.KindNamedSite, []string{"d", "docs"}, []string{"expiration", strconv.FormatInt(agentNow+5*86400, 10)}), session, agentNow); err != nil {320 t.Fatalf("longest ttl not applied: %v", err)321 }322 parsed, err := community.ParseAgentGrant(grant, agentNow)323 if err != nil || parsed.UploadTTLDays() != 5 || parsed.UploadsEncrypted() {324 t.Fatalf("parsed sites %+v %v", parsed.Scope.Sites, err)325 }326 // Snapshots are not admitted by the sites tag.327 expectRestricted(t, f.gate.Write(ctx, manifest(sites.KindSiteSnapshot), session, agentNow), "kind 5128")328 // Paused and revoked grants stop manifests like every other event.329 if _, err := f.community.SetAgentPaused(ctx, f.owner, f.agent, true); err != nil {330 t.Fatal(err)331 }332 expectRestricted(t, f.gate.Write(ctx, manifest(sites.KindSite, []string{"expiration", latest}), session, agentNow), "paused")333 if _, err := f.community.RevokeAgent(ctx, f.owner, f.agent, agentNow); err != nil {334 t.Fatal(err)335 }336 expectRestricted(t, f.gate.Write(ctx, manifest(sites.KindSite, []string{"expiration", latest}), session, agentNow), "revoked")337}338339func TestAgentGrantSitesShape(t *testing.T) {340 f := newAgentFixture(t)341 ctx := context.Background()342 other, _ := event.PublicKey(agentOtherSecret)343 own := sites.SiteLabel(event.Event{Kind: sites.KindSite, PubKey: f.agent})344 foreign := sites.SiteLabel(event.Event{Kind: sites.KindSite, PubKey: other})345 for _, tc := range []struct {346 name string347 tag []string348 want string349 }{350 {"empty", []string{"sites"}, "invalid: agent grant sites tag needs a site label"},351 {"bad label", []string{"sites", "not-a-site"}, "invalid: agent grant sites tag needs a site label"},352 {"other key", []string{"sites", foreign}, "invalid: agent grant sites label must be a site under the agent's own key"},353 {"snapshot", []string{"sites", "v" + strings.Repeat("0", 50)}, "invalid: agent grant sites tag needs a site label"},354 {"ttl zero", []string{"sites", "*", "ttl=0"}, "invalid: agent grant sites ttl must be between 1 and 365"},355 {"ttl long", []string{"sites", "*", "ttl=366"}, "invalid: agent grant sites ttl must be between 1 and 365"},356 {"ttl text", []string{"sites", "*", "ttl=soon"}, "invalid: agent grant sites ttl must be between 1 and 365"},357 {"unknown flag", []string{"sites", own, "public"}, "invalid: agent grant sites tag allows only ttl=<days> and encrypted"},358 } {359 t.Run(tc.name, func(t *testing.T) {360 e := signed(t, agentOwnerSecret, event.KIND_AGENT_GRANT, agentNow, grantTags(f.agent, agentNow+60, tc.tag), "")361 err := f.gate.Write(ctx, e, relay.Session{PubKeys: []string{f.owner}}, agentNow)362 if err == nil || !strings.HasPrefix(err.Error(), tc.want) {363 t.Fatalf("want %q, got %v", tc.want, err)364 }365 })366 }367 e := signed(t, agentOwnerSecret, event.KIND_AGENT_GRANT, agentNow, grantTags(f.agent, agentNow+60, []string{"sites", own, "ttl=30", "encrypted"}, []string{"sites", own, "ttl=7"}, []string{"sites", "*"}), "")368 if err := f.gate.Write(ctx, e, relay.Session{PubKeys: []string{f.owner}}, agentNow); err != nil {369 t.Fatalf("valid sites grant rejected: %v", err)370 }371 grant, err := community.ParseAgentGrant(e, agentNow)372 if err != nil {373 t.Fatal(err)374 }375 want := []community.AgentSite{{Label: own, TTLDays: 30, Encrypted: true}, {Label: "*"}}376 if len(grant.Scope.Sites) != len(want) || grant.Scope.Sites[0] != want[0] || grant.Scope.Sites[1] != want[1] {377 t.Fatalf("sites = %+v, want %+v", grant.Scope.Sites, want)378 }379 if !grant.UploadsEncrypted() || grant.UploadTTLDays() != 0 {380 t.Fatalf("an entry without a ttl should leave uploads unlimited: ttl=%d encrypted=%v", grant.UploadTTLDays(), grant.UploadsEncrypted())381 }382}