618 lines
19 KiB
Dart
618 lines
19 KiB
Dart
|
|
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 = <UuidValue, Map<UuidValue, bool>>{};
|
||
|
|
|
||
|
|
@override
|
||
|
|
Future<UuidValue> create(
|
||
|
|
Session session, {
|
||
|
|
required UuidValue owner,
|
||
|
|
required Transaction transaction,
|
||
|
|
}) async {
|
||
|
|
final space = const Uuid().v7obj();
|
||
|
|
spaces[space] = {owner: true};
|
||
|
|
return space;
|
||
|
|
}
|
||
|
|
|
||
|
|
@override
|
||
|
|
Future<void> grant(
|
||
|
|
Session session,
|
||
|
|
UuidValue space,
|
||
|
|
UuidValue person, {
|
||
|
|
required bool writes,
|
||
|
|
required Transaction transaction,
|
||
|
|
}) async => spaces[space]![person] = writes;
|
||
|
|
|
||
|
|
@override
|
||
|
|
Future<void> revoke(
|
||
|
|
Session session,
|
||
|
|
UuidValue space,
|
||
|
|
UuidValue person, {
|
||
|
|
required Transaction transaction,
|
||
|
|
}) async => spaces[space]?.remove(person);
|
||
|
|
|
||
|
|
@override
|
||
|
|
Future<void> 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<UuidValue> 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<AgentRefusedException>().having((e) => e.reason, 'reason', reason),
|
||
|
|
);
|
||
|
|
Matcher refusedInSpace(SharedSpaceRefusal reason) => throwsA(
|
||
|
|
isA<SharedSpaceRefusedException>().having(
|
||
|
|
(e) => e.reason,
|
||
|
|
'reason',
|
||
|
|
reason,
|
||
|
|
),
|
||
|
|
);
|
||
|
|
Matcher refusedToOperator(OperatorRefusal reason) => throwsA(
|
||
|
|
isA<OperatorRefusedException>().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<ProfileRefusedException>().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<ArgumentError>()),
|
||
|
|
);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
group('in shared spaces', () {
|
||
|
|
late UuidValue agent;
|
||
|
|
late UuidValue space;
|
||
|
|
|
||
|
|
/// The agent's membership of [space].
|
||
|
|
Future<UuidValue> 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),
|
||
|
|
);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|