import 'dart:typed_data'; import 'package:clock/clock.dart'; import 'package:halkyon_base_server/base.dart'; import 'package:serverpod/serverpod.dart'; import 'package:serverpod_auth_idp_server/core.dart'; import 'package:test/test.dart'; import '../support/fixtures.dart'; import '../test_tools/serverpod_test_tools.dart'; /// The sync engine as a host hands it over, in memory: each space's /// members, and whether each may write. final class _Engine implements SpaceGrants { final spaces = >{}; @override Future create( Session session, { required UuidValue owner, required Transaction transaction, }) async { final space = const Uuid().v7obj(); spaces[space] = {owner: true}; return space; } @override Future grant( Session session, UuidValue space, UuidValue person, { required bool writes, required Transaction transaction, }) async => spaces[space]![person] = writes; @override Future revoke( Session session, UuidValue space, UuidValue person, { required Transaction transaction, }) async => spaces[space]?.remove(person); @override Future delete( Session session, UuidValue space, { required Transaction transaction, }) async => spaces.remove(space); } /// Agents (Halkyon's ADR 0044, D6, D12, D43, D45): accounts their owner /// makes from the Profile, signing in with a token for a day at a time, /// each a Viewer alone, which do nothing only people do; every rule refused /// too. void main() { withServerpod('Agents', (sessionBuilder, endpoints) { late Session session; late Serverpod pod; late _Engine engine; late UuidValue alice; late UuidValue bob; /// A person with an account here and a public profile of [handle]. Future person(String handle) async { final id = (await AuthServices.instance.authUsers.create(session)).id; await saveProfile(session, id, handle: handle, displayName: handle); return id; } setUp(() async { engine = _Engine(); useBase(sessionBuilder, spaces: engine); session = sessionBuilder.build(); pod = session.serverpod; alice = await person('alice'); bob = await person('bob'); }); tearDown(() => useAgents(pod, null)); TestSessionBuilder signedIn(UuidValue person) => sessionBuilder.copyWith( authentication: AuthenticationOverride.authenticationInfo(person.uuid, { identified, }), ); TestSessionBuilder asAgent(UuidValue agent) => sessionBuilder.copyWith( authentication: AuthenticationOverride.authenticationInfo(agent.uuid, { identified, agentScope, }), ); final agents = endpoints.agents; final signIn = endpoints.agentSignIn; Matcher refused(AgentRefusal reason) => throwsA( isA().having((e) => e.reason, 'reason', reason), ); Matcher refusedInSpace(SharedSpaceRefusal reason) => throwsA( isA().having( (e) => e.reason, 'reason', reason, ), ); Matcher refusedToOperator(OperatorRefusal reason) => throwsA( isA().having( (e) => e.reason, 'reason', reason, ), ); group('making and listing', () { test('a person makes an agent with a name and a handle: an account ' 'of its own, marked as an agent, its token shown once', () async { final made = await agents.create( signedIn(alice), name: 'Claude', handle: 'claude', ); expect(made.token, startsWith(agentTokenPrefix)); expect(made.agent.name, 'Claude'); expect(made.agent.handle, 'claude'); expect(made.agent.hasToken, isTrue); expect(made.agent.hasKeys, isFalse); expect(made.agent.lastUsedAt, isNull); final account = await AuthUser.db.findById(session, made.agent.id); expect(account!.scopeNames, {identified.name, agentScope.name}); expect( (await agents.mine(signedIn(alice))).map((agent) => agent.id), [made.agent.id], ); // Only the token's hash is kept. final row = await AgentToken.db.findFirstRow( session, where: (t) => t.agentId.equals(made.agent.id), ); expect(row!.tokenHash, isNot(contains(made.token))); expect(row.tokenHash, hasLength(64)); }); test( "someone else's agents are listed to nobody but their owner", () async { await agents.create( signedIn(alice), name: 'Claude', handle: 'claude', ); expect(await agents.mine(signedIn(bob)), isEmpty); }, ); test("its public profile names its owner's handle", () async { final made = await agents.create( signedIn(alice), name: 'Claude', handle: 'claude', ); final profile = await AccountRegister.of( pod, ).profileOf(session, made.agent.id); expect(profile!.agentOf, 'alice'); expect( (await AccountRegister.of(pod).profileOf(session, alice))!.agentOf, isNull, ); }); test('a handle someone has is refused, and nothing is made', () async { await expectLater( agents.create(signedIn(alice), name: 'Bob', handle: 'BOB'), throwsA( isA().having( (e) => e.reason, 'reason', ProfileRefusal.handleTaken, ), ), ); expect(await agents.mine(signedIn(alice)), isEmpty); }); test('a person has five agents at most', () async { for (var i = 0; i < maxAgentsPerPerson; i++) { await agents.create(signedIn(alice), name: 'A$i', handle: 'agent_$i'); } await expectLater( agents.create(signedIn(alice), name: 'More', handle: 'more'), refused(AgentRefusal.tooMany), ); // Another person's count is their own. await agents.create(signedIn(bob), name: 'B', handle: 'bobs_agent'); }); test('where the operator switched agents off, none is made, and the ' "instance's basics say so", () async { expect( (await endpoints.instance.basics(sessionBuilder)).agents, isTrue, ); useAgents(pod, false); await expectLater( agents.create(signedIn(alice), name: 'Claude', handle: 'claude'), refused(AgentRefusal.agentsOff), ); expect( (await endpoints.instance.basics(sessionBuilder)).agents, isFalse, ); }); test('an agent makes no agent', () async { final made = await agents.create( signedIn(alice), name: 'Claude', handle: 'claude', ); await expectLater( agents.create(asAgent(made.agent.id), name: 'Sub', handle: 'sub'), refused(AgentRefusal.notForAgents), ); await expectLater( agents.mine(asAgent(made.agent.id)), refused(AgentRefusal.notForAgents), ); }); }); group('signing in', () { late AgentMade made; setUp(() async { made = await agents.create( signedIn(alice), name: 'Claude', handle: 'claude', ); }); test('the token signs the agent in for a day, as an agent, and its ' 'last use shows to its owner', () async { final now = DateTime.utc(2026, 11, 11, 20); final success = await withClock( Clock.fixed(now), () => signIn.signIn(sessionBuilder, token: made.token), ); expect(success.authUserId, made.agent.id); expect(success.scopeNames, {identified.name, agentScope.name}); final row = await ServerSideSession.db.findFirstRow( session, where: (t) => t.authUserId.equals(made.agent.id), ); expect(row!.expiresAt, now.add(agentSessionLifetime)); expect(row.method, agentSignInMethod); expect((await agents.mine(signedIn(alice))).single.lastUsedAt, now); }); test('a token that is none signs nobody in', () async { await expectLater( signIn.signIn(sessionBuilder, token: '${agentTokenPrefix}nothing'), refused(AgentRefusal.notValid), ); await expectLater( signIn.signIn(sessionBuilder, token: ''), refused(AgentRefusal.notValid), ); }); test('a token unused for 90 days lapses: refused, and the pruning ' 'forgets it', () async { final later = clock.now().add( agentTokenLapsesAfter + const Duration(minutes: 1), ); await withClock(Clock.fixed(later), () async { await expectLater( signIn.signIn(sessionBuilder, token: made.token), refused(AgentRefusal.notValid), ); expect(await forgetLapsedAgentTokens(session), 1); }); expect((await agents.mine(signedIn(alice))).single.hasToken, isFalse); }); test('a token used within 90 days holds on', () async { final start = clock.now(); await withClock( Clock.fixed(start.add(const Duration(days: 80))), () => signIn.signIn(sessionBuilder, token: made.token), ); await withClock( Clock.fixed(start.add(const Duration(days: 160))), () async { await signIn.signIn(sessionBuilder, token: made.token); expect(await forgetLapsedAgentTokens(session), 0); }, ); }); test('revoked, the token signs the agent in no more, and its sessions ' 'end', () async { await signIn.signIn(sessionBuilder, token: made.token); await agents.revoke(signedIn(alice), agent: made.agent.id); expect( await ServerSideSession.db.count( session, where: (t) => t.authUserId.equals(made.agent.id), ), 0, ); await expectLater( signIn.signIn(sessionBuilder, token: made.token), refused(AgentRefusal.notValid), ); expect((await agents.mine(signedIn(alice))).single.hasToken, isFalse); }); test('a new token ends the one before and its sessions', () async { await signIn.signIn(sessionBuilder, token: made.token); final renewed = await agents.newToken( signedIn(alice), agent: made.agent.id, ); expect(renewed.token, isNot(made.token)); expect( await ServerSideSession.db.count( session, where: (t) => t.authUserId.equals(made.agent.id), ), 0, ); await expectLater( signIn.signIn(sessionBuilder, token: made.token), refused(AgentRefusal.notValid), ); await signIn.signIn(sessionBuilder, token: renewed.token); }); test('nobody but its owner revokes, renews or deletes an agent, and ' "another's is answered as none", () async { final unknown = const Uuid().v7obj(); for (final agent in [made.agent.id, unknown]) { await expectLater( agents.revoke(signedIn(bob), agent: agent), refused(AgentRefusal.noSuchAgent), ); await expectLater( agents.newToken(signedIn(bob), agent: agent), refused(AgentRefusal.noSuchAgent), ); await expectLater( agents.delete(signedIn(bob), agent: agent), refused(AgentRefusal.noSuchAgent), ); } await signIn.signIn(sessionBuilder, token: made.token); }); test('an agent signs in no other way than by its token', () async { // It has no password, no address and no SimpleLogin: the ways a // person signs in find nothing of it. final ways = await AccountRegister.of( pod, ).signInWaysOf(session, made.agent.id); expect(ways.password, isFalse); expect(ways.email, isFalse); expect(ways.simpleLogin, isFalse); }); }); group('deleting', () { test( 'its owner deletes an agent: its account goes, and its token', () async { final made = await agents.create( signedIn(alice), name: 'Claude', handle: 'claude', ); await agents.delete(signedIn(alice), agent: made.agent.id); expect(await AuthUser.db.findById(session, made.agent.id), isNull); expect(await AgentToken.db.count(session), 0); expect(await AgentAccount.db.count(session), 0); expect(await agents.mine(signedIn(alice)), isEmpty); await expectLater( signIn.signIn(sessionBuilder, token: made.token), refused(AgentRefusal.notValid), ); }, ); test('deleting a person deletes their agents first', () async { final made = await agents.create( signedIn(alice), name: 'Claude', handle: 'claude', ); await agents.create(signedIn(bob), name: 'Other', handle: 'other'); await deletePerson(session, alice); expect(await AuthUser.db.findById(session, made.agent.id), isNull); expect(await agents.mine(signedIn(bob)), hasLength(1)); }); }); group('keys', () { test( 'an agent sets its public keys once, which members then find', () async { final made = await agents.create( signedIn(alice), name: 'Claude', handle: 'claude', ); final encryption = ByteData.sublistView(Uint8List(32)..[0] = 1); final signing = ByteData.sublistView(Uint8List(32)..[0] = 2); await agents.setKeys( asAgent(made.agent.id), encryption: encryption, signing: signing, ); final keys = await AccountRegister.of( pod, ).publicKeysOf(session, {made.agent.id, alice}); expect(keys.keys, [made.agent.id]); expect(Uint8List.sublistView(keys[made.agent.id]!.encryption)[0], 1); expect(Uint8List.sublistView(keys[made.agent.id]!.signing)[0], 2); expect((await agents.mine(signedIn(alice))).single.hasKeys, isTrue); await expectLater( agents.setKeys( asAgent(made.agent.id), encryption: signing, signing: encryption, ), refused(AgentRefusal.keysSet), ); }, ); test("a person sets no agent's keys, nor keys as an agent", () async { await agents.create(signedIn(alice), name: 'Claude', handle: 'claude'); final key = ByteData.sublistView(Uint8List(32)); await expectLater( agents.setKeys(signedIn(alice), encryption: key, signing: key), refused(AgentRefusal.notForAgents), ); }); test('keys of the wrong length are refused', () async { final made = await agents.create( signedIn(alice), name: 'Claude', handle: 'claude', ); final short = ByteData.sublistView(Uint8List(31)); final key = ByteData.sublistView(Uint8List(32)); await expectLater( agents.setKeys( asAgent(made.agent.id), encryption: short, signing: key, ), throwsA(isA()), ); }); }); group('in shared spaces', () { late UuidValue agent; late UuidValue space; /// The agent's membership of [space]. Future membership() async => (await endpoints.sharedSpace.members( signedIn(alice), space, )).singleWhere((member) => member.agent).id; setUp(() async { agent = (await agents.create( signedIn(alice), name: 'Claude', handle: 'claude', )).agent.id; space = (await endpoints.sharedSpace.create( signedIn(alice), 'studyset', )).id; }); test('an agent is invited as a Viewer, and marked as an agent among ' 'the members', () async { await endpoints.sharedSpace.invite( signedIn(alice), space, 'claude', SharedSpaceRole.viewer, ); final members = await endpoints.sharedSpace.members( signedIn(alice), space, ); expect( {for (final member in members) member.handle: member.agent}, {'alice': false, 'claude': true}, ); expect(engine.spaces[space]![agent], isFalse); }); test('an agent is invited as nothing but a Viewer', () async { await expectLater( endpoints.sharedSpace.invite( signedIn(alice), space, 'claude', SharedSpaceRole.editor, ), refusedInSpace(SharedSpaceRefusal.agentViewer), ); expect(engine.spaces[space]!.containsKey(agent), isFalse); }); test('an agent is raised to nothing but a Viewer', () async { await endpoints.sharedSpace.invite( signedIn(alice), space, 'claude', SharedSpaceRole.viewer, ); await expectLater( endpoints.sharedSpace.setRole( signedIn(alice), space, await membership(), SharedSpaceRole.editor, ), refusedInSpace(SharedSpaceRefusal.agentViewer), ); expect(engine.spaces[space]![agent], isFalse); }); test('an agent creates no shared space', () async { await expectLater( endpoints.sharedSpace.create(asAgent(agent), 'studyset'), refusedInSpace(SharedSpaceRefusal.agentViewer), ); }); test('the Owner leaving with an agent the only other member, the ' 'space never passes to it', () async { await endpoints.sharedSpace.invite( signedIn(alice), space, 'claude', SharedSpaceRole.viewer, ); await expectLater( endpoints.sharedSpace.handOn( signedIn(alice), space, await membership(), ), refusedInSpace(SharedSpaceRefusal.agentViewer), ); await deletePerson(session, alice); expect(engine.spaces.containsKey(space), isFalse); }); }); group("the operator's", () { late UuidValue agent; setUp(() async { agent = (await agents.create( signedIn(alice), name: 'Claude', handle: 'claude', )).agent.id; }); TestSessionBuilder asOperator(UuidValue person) => sessionBuilder.copyWith( authentication: AuthenticationOverride.authenticationInfo( person.uuid, {identified, instanceOperator}, ), ); test('an agent is never named an operator', () async { await expectLater( endpoints.instanceOperator.appoint(asOperator(bob), handle: 'claude'), refusedToOperator(OperatorRefusal.isAgent), ); }); test('an agent is granted no assistant', () async { await expectLater( endpoints.assistantOperator.grant( asOperator(bob), accounts: [agent], assistants: const ['any'], budget: AssistantLimits(), ), refusedToOperator(OperatorRefusal.isAgent), ); }); }); }); }