import 'dart:convert'; import 'dart:typed_data'; import 'package:halkyon_base_server/base.dart'; import 'package:halkyon_base_server/src/register/deletion_notices.dart'; import 'package:halkyon_base_server/src/register/instance_api.dart'; import 'package:halkyon_base_server/src/register/people.dart' show maxClockSkew; import 'package:halkyon_base_server/src/register/register_protocol.dart'; import 'package:halkyon_base_server/src/register/remote_register.dart'; import 'package:serverpod/serverpod.dart'; import 'package:serverpod_auth_idp_server/core.dart'; import 'package:test/test.dart'; import '../support/fixtures.dart'; import '../support/password_device.dart'; import '../test_tools/serverpod_test_tools.dart'; const _secret = 'a shared secret only the instance knows'; final _learn = Uri.parse('http://learn.test:8082'); /// The instance's register, in memory: what a member asks it, and what it /// answers. final class _Register implements AccountRegister { final accounts = {}; final addresses = {}; final gitHub = {}; final profiles = {}; final codes = {}; /// The calls asked, by name, in their order. final asked = []; final networks = []; final deleted = []; /// What [knownAccounts] was asked about, call by call. final askedAbout = >[]; bool offered = true; /// Whether it offers password sign-in. bool passwordOffered = true; /// The accounts with a password, and those with an authenticator too. final passwords = {}; final authenticated = {}; /// What a password sign-in proves; null refuses it. PasswordProof? proof; /// Thrown by every call while set: the register refusing, or down. Exception? failing; /// The hosts sign-ins were bound to, as members named them. final hosts = []; Future _answer(String call, T Function() answer) async { asked.add(call); if (failing case final error?) { throw error; } return answer(); } /// [person] as an account with the address [address]. void add(UuidValue person, {String? address}) { accounts.add(person); if (address != null) { addresses[address] = person; } } @override Future profileOf(Session session, UuidValue person) => _answer('profileOf', () => profiles[person]); @override Future> profilesOf( Session session, Set people, ) => _answer( 'profilesOf', () => { for (final person in people) person: ?profiles[person], }, ); @override Future profileByHandle(Session session, String handle) => _answer( 'profileByHandle', () => profiles.values .where((profile) => profile.handle.toLowerCase() == handle) .firstOrNull, ); @override Future personWithHandle(Session session, String handle) => _answer( 'personWithHandle', () => profiles.entries .where((entry) => entry.value.handle.toLowerCase() == handle) .firstOrNull ?.key, ); @override Future saveProfile( Session session, UuidValue person, { required String handle, required String displayName, required String bio, required List links, }) => _answer( 'saveProfile', () => profiles[person] = Profile( handle: handle, address: '$handle@learn.test', displayName: displayName, bio: bio, links: links, pageUrl: 'https://learn.test/@$handle', handleFixed: false, ), ); @override Future checkHandle( Session session, UuidValue person, String handle, ) => _answer('checkHandle', () => null); @override Future checkLinks(Session session, UuidValue person) => _answer('checkLinks', () => profiles[person]!); @override Future setAvatar(Session session, UuidValue person, ByteData png) => _answer('setAvatar', () => profiles[person]!); @override Future removeAvatar(Session session, UuidValue person) => _answer('removeAvatar', () => profiles[person]!); @override Future removeProfile(Session session, UuidValue person) => _answer('removeProfile', () => profiles.remove(person)); @override Future fixHandle(Session session, UuidValue person) => _answer('fixHandle', () {}); @override Future emailSignInOffered(Session session) => _answer('emailSignInOffered', () => offered); @override Future sendSignInCode( Session session, { required String address, required String language, required String network, }) => _answer('sendSignInCode', () { networks.add(network); final request = const Uuid().v7obj(); codes[request] = (address: address, code: 'K7QX-M2PD'); return request; }); @override Future linkGitHub( Session session, { required UuidValue person, required String gitHubUserId, }) => _answer('linkGitHub', () { accounts.add(person); gitHub[gitHubUserId] = person; }); @override Future accountWithGitHub(Session session, String gitHubUserId) => _answer('accountWithGitHub', () => gitHub[gitHubUserId]); @override Future signInWaysOf(Session session, UuidValue person) => _answer( 'signInWaysOf', () => SignInWays( gitHub: gitHub.containsValue(person), email: addresses.containsValue(person), password: passwords.contains(person), authenticator: authenticated.contains(person), passwordOnly: passwords.contains(person) && authenticated.contains(person), ), ); @override Future passwordSignInOffered(Session session) => _answer('passwordSignInOffered', () => passwordOffered); @override Future setPassword( Session session, { required UuidValue person, required PasswordSetup setup, UuidValue? request, String? code, }) => _answer('setPassword', () { add(person); passwords.add(person); }); @override Future beginPasswordSignIn( Session session, { required String address, required String network, }) => _answer('beginPasswordSignIn', () { networks.add(network); return PasswordChallenge( challenge: const Uuid().v7obj(), salt: ByteData(16), costs: newPasswordCosts, serverValue: ByteData(384), ); }); PasswordProof _proven() => proof ?? (throw PasswordRefusedException( reason: PasswordRefusal.wrongCredentials, )); @override Future completePasswordSignIn( Session session, { required UuidValue challenge, required ByteData clientValue, required ByteData clientProof, String? totpCode, String? recoveryCode, UuidValue? request, String? code, required String host, }) { hosts.add(host); return _answer('completePasswordSignIn', _proven); } @override Future signUpWithCode( Session session, { required UuidValue request, required String code, required PasswordSetup setup, }) => _answer('signUpWithCode', () { final account = const Uuid().v7obj(); add(account); passwords.add(account); return account; }); @override Future enrolAuthenticator( Session session, { required UuidValue person, required String name, required String label, }) => _answer( 'enrolAuthenticator', () => AuthenticatorEnrolment( authenticator: const Uuid().v7obj(), uri: 'otpauth://totp/learn.test:$label', ), ); @override Future> confirmAuthenticator( Session session, { required UuidValue person, required UuidValue authenticator, required String code, String? existingCode, }) => _answer('confirmAuthenticator', () { authenticated.add(person); return const ['K7QX-M2PD']; }); @override Future> authenticatorsOf( Session session, UuidValue person, ) => _answer('authenticatorsOf', () => const []); @override Future removeAuthenticator( Session session, { required UuidValue person, required UuidValue authenticator, String? code, }) => _answer('removeAuthenticator', () {}); @override Future> newRecoveryCodes( Session session, { required UuidValue person, required String code, }) => _answer('newRecoveryCodes', () => const ['K7QX-M2PD']); @override Future resetAuthenticator( Session session, { required UuidValue challenge, required ByteData clientValue, required ByteData clientProof, required String address, required String language, UuidValue? request, String? code, UuidValue? keyChallenge, ByteData? signature, required String host, }) => _answer( 'resetAuthenticator', () => ResetPending(takesEffectAt: DateTime.utc(2026, 10, 23)), ); @override Future beginKeyProof( Session session, { required String address, required String network, }) => _answer('beginKeyProof', () { networks.add(network); return KeyProofChallenge( challenge: const Uuid().v7obj(), nonce: ByteData(32), encryptionPublicKey: ByteData(32), signingPublicKey: ByteData(32), recoveryKeys: WrappedKeys(nonce: ByteData(24), cipherText: ByteData(112)), ); }); /// The moment a reset ended the account's sessions at the register. DateTime endedBefore = DateTime.utc(2026, 10, 20, 12); @override Future resetPasswordWithPhrase( Session session, { required UuidValue challenge, required ByteData signature, required NewPassword password, String? totpCode, String? recoveryCode, required String host, }) => _answer( 'resetPasswordWithPhrase', () => (account: _proven().account, endedBefore: endedBefore), ); @override Future startAccountReset( Session session, { required String address, required String language, required UuidValue request, required String code, }) => _answer( 'startAccountReset', () => ResetPending(takesEffectAt: DateTime.utc(2026, 10, 23)), ); @override Future completeAccountReset( Session session, { required String address, required UuidValue request, required String code, required PasswordSetup setup, }) => _answer( 'completeAccountReset', () => (account: _proven().account, endedBefore: endedBefore), ); /// The sessions kept by the password changes asked, by person. final changed = {}; @override Future beginPasswordChange( Session session, { required UuidValue person, }) => _answer( 'beginPasswordChange', () => PasswordChallenge( challenge: const Uuid().v7obj(), salt: ByteData(16), costs: newPasswordCosts, serverValue: ByteData(384), ), ); @override Future changePassword( Session session, { required UuidValue person, required UuidValue challenge, required ByteData clientValue, required ByteData clientProof, String? totpCode, String? recoveryCode, required NewPassword password, UuidValue? keepSession, required String host, }) => _answer('changePassword', () { changed[person] = keepSession; }); @override Future setRecoveryKeys( Session session, { required UuidValue person, required UuidValue challenge, required ByteData clientValue, required ByteData clientProof, String? totpCode, String? recoveryCode, required WrappedKeys recoveryKeys, required ByteData recoveryProofKey, required String host, }) => _answer('setRecoveryKeys', () {}); @override Future deleteAccount(Session session, UuidValue person) => _answer('deleteAccount', () { deleted.add(person); accounts.remove(person); }); @override Future> knownAccounts( Session session, Set people, ) => _answer('knownAccounts', () { askedAbout.add(people); return people.intersection(accounts); }); } void main() { withServerpod( 'A member of an instance', // Sessions issued and revoked, and deletions in transactions of their // own: these tests delete what they make instead. rollbackDatabase: RollbackDatabase.disabled, (sessionBuilder, endpoints) { late Session session; late Serverpod pod; late _Register register; /// Anonymous here, signed in on her phone. late UuidValue anna; late UuidValue annasPhone; /// An account of the register, identified here, signed in on his /// laptop. late UuidValue ben; late UuidValue bensLaptop; /// Whose data the host deleted. late List hostDeleted; setUp(() async { hostDeleted = []; useBase( sessionBuilder, accounts: MemberOfRegister(_learn), deletePersonData: [ (session, person, transaction) async { hostDeleted.add(person); return null; }, ], ); session = sessionBuilder.build(); pod = session.serverpod; await _deleteAll(session); useInstanceSecret(pod, _secret); register = _Register(); AccountRegister.use(pod, register); anna = await newAnonymousPerson(session); annasPhone = await signInNewDevice(session, anna); ben = (await AuthServices.instance.authUsers.create( session, scopes: {identified}, )).id; register.add(ben, address: 'ben@example.org'); bensLaptop = await signInNewDevice(session, ben, scopes: {identified}); }); tearDown(() async { AccountRegister.use(pod, null); useInstanceSecret(pod, null); await _deleteAll(sessionBuilder.build()); }); TestSessionBuilder asAnna() => sessionBuilder.copyWith( authentication: AuthenticationOverride.authenticationInfo( anna.uuid, {}, authId: annasPhone.uuid, ), ); TestSessionBuilder asBen() => sessionBuilder.copyWith( authentication: AuthenticationOverride.authenticationInfo( ben.uuid, {identified}, authId: bensLaptop.uuid, ), ); TestSessionBuilder notSignedIn() => sessionBuilder.copyWith( authentication: AuthenticationOverride.unauthenticated(), ); Future> scopesOf(UuidValue person) async => (await AuthUser.db.findById(session, person))!.scopeNames; group('signs in by asking the register', () { test('which mails the code, its device network passed on', () async { await endpoints.emailCode.sendCode( asAnna(), address: 'anna@example.org', language: 'de', ); expect(register.asked, ['sendSignInCode']); expect(register.codes.values.single.address, 'anna@example.org'); expect(register.networks, hasLength(1)); }); test('and refuses as the register refuses', () async { register.failing = EmailSignInRefusedException( reason: EmailSignInRefusal.tooManyCodes, ); await expectLater( endpoints.emailCode.sendCode( asAnna(), address: 'anna@example.org', language: 'de', ), throwsA( isA().having( (error) => error.reason, 'reason', EmailSignInRefusal.tooManyCodes, ), ), ); }); test('an account signing in again is the same person here', () async { await issueSession(session, ben, method: 'test'); await issueSession(session, ben, method: 'test'); expect( await AuthUser.db.count(session, where: (t) => t.id.equals(ben)), 1, ); expect( await ServerSideSession.db.count( session, where: (t) => t.authUserId.equals(ben), ), 3, ); }); test('an account the register proved is identified here even where ' 'linking its way in was cut off', () async { final success = await issueSession(session, anna, method: 'test'); expect(success.authUserId, anna); expect(await scopesOf(anna), contains(identified.name)); expect( (await ServerSideSession.db.findById( session, annasPhone, ))!.scopeNames, contains(identified.name), ); }); test('email sign-in is offered as the register offers it', () async { expect( (await endpoints.instance.basics(notSignedIn())).emailSignIn, isTrue, ); register.offered = false; expect( (await endpoints.instance.basics(notSignedIn())).emailSignIn, isFalse, ); }); test('the basics still answer while the register is out of reach, ' 'without email sign-in', () async { register.failing = RegisterUnavailableException('test'); final basics = await endpoints.instance.basics(notSignedIn()); expect(basics.emailSignIn, isFalse); expect(basics.app, testApp); }); test('the ways in are those the register knows', () async { register.gitHub['1001'] = ben; final ways = await endpoints.account.signInWays(asBen()); expect(ways.toJson(), SignInWays(gitHub: true, email: true).toJson()); }); }); group('passwords, by asking the register (ADR 0017)', () { test('sign the account the register proved in here, made a person ' 'here the first time, the device network passed on', () async { final carla = const Uuid().v7obj(); register.proof = PasswordProof( account: carla, serverProof: ByteData(32), encryptionPublicKey: ByteData(32), signingPublicKey: ByteData(32), passwordKeys: WrappedKeys( nonce: ByteData(24), cipherText: ByteData(112), ), needsAuthenticator: false, ); final challenge = await endpoints.passwordSignIn.begin( notSignedIn(), address: 'carla@example.org', ); final signedIn = await endpoints.passwordSignIn.complete( notSignedIn(), challenge: challenge.challenge, clientValue: ByteData(384), clientProof: ByteData(32), totpCode: '123456', host: testHost, ); expect(register.asked, [ 'beginPasswordSignIn', 'completePasswordSignIn', ]); expect(register.networks, hasLength(1)); // Bound to the host the device addressed: this member's. expect(register.hosts, [testHost]); expect(signedIn.authUserId, carla); expect(await scopesOf(carla), {identified.name}); expect( (await ServerSideSession.db.find( session, where: (t) => t.authUserId.equals(carla), )).single.method, passwordMethod, ); }); test('sign up at the register, the new account signed in here, ' 'identified', () async { final device = await PasswordDevice.make(); final signedUp = await endpoints.passwordSignIn.signUp( notSignedIn(), request: const Uuid().v7obj(), code: 'K7QX-M2PD', setup: device.setup(), ); expect(register.asked, ['signUpWithCode']); expect(register.passwords, {signedUp.authUserId}); expect(await scopesOf(signedUp.authUserId), {identified.name}); expect( (await ServerSideSession.db.find( session, where: (t) => t.authUserId.equals(signedUp.authUserId), )).single.method, passwordMethod, ); }); test('refuse as the register refuses, signing nobody in', () async { final people = await AuthUser.db.count(session); await expectLater( endpoints.passwordSignIn.complete( notSignedIn(), challenge: const Uuid().v7obj(), clientValue: ByteData(384), clientProof: ByteData(32), host: testHost, ), throwsA( isA().having( (error) => error.reason, 'reason', PasswordRefusal.wrongCredentials, ), ), ); expect(await AuthUser.db.count(session), people); }); test('set one at the register, the person becoming identified ' 'here', () async { final device = await PasswordDevice.make(); await endpoints.password.set( asAnna(), setup: device.setup(), request: const Uuid().v7obj(), code: 'K7QX-M2PD', ); expect(register.passwords, {anna}); expect(await scopesOf(anna), contains(identified.name)); expect( await AnonymousAccount.db.count( session, where: (t) => t.authUserId.equals(anna), ), 0, ); }); test('are offered as the register offers them, and not while it is ' 'out of reach', () async { expect( (await endpoints.instance.basics(notSignedIn())).passwordSignIn, isTrue, ); register.passwordOffered = false; expect( (await endpoints.instance.basics(notSignedIn())).passwordSignIn, isFalse, ); register ..passwordOffered = true ..failing = RegisterUnavailableException('test'); expect( (await endpoints.instance.basics(notSignedIn())).passwordSignIn, isFalse, ); }); test('keep GitHub from signing into an account that has one, as ' 'the register knows it', () async { register.gitHub['1001'] = ben; expect(await gitHubAccountToSignIn(session, '1001'), ben); register ..passwords.add(ben) ..authenticated.add(ben); await expectLater( gitHubAccountToSignIn(session, '1001'), throwsA( isA().having( (error) => error.reason, 'reason', GitHubSignInRefusal.usePassword, ), ), ); }); }); group('profiles', () { test('are saved and found at the register', () async { final saved = await endpoints.profile.save( asBen(), 'Ben', 'Ben', '', const [], ); expect(register.profiles[ben]!.toJson(), saved.toJson()); expect( (await endpoints.profiles.byHandle(asAnna(), 'ben')).toJson(), saved.toJson(), ); expect((await profileOf(session, ben))!.toJson(), saved.toJson()); expect(await PublicProfile.db.count(session), 0); }); test("have their page at the register's address, where this " "server's sends its visitors", () async { await endpoints.profile.save(asBen(), 'Ben', 'Ben', '', const []); final page = await profilePage( session, '@ben', host: 'todo.test', ); expect(page.status, 308); expect(page.location, 'https://learn.test/@Ben'); }); }); group('deleting the account', () { test('of an identified person deletes it at the register, which ' 'tells every member, and here at once', () async { await endpoints.account.delete(asBen()); expect(register.deleted, [ben]); expect(await AuthUser.db.findById(session, ben), isNull); expect( await ServerSideSession.db.count( session, where: (t) => t.authUserId.equals(ben), ), 0, ); expect(hostDeleted, [ben]); expect(await AuthUser.db.findById(session, anna), isNotNull); }); test('of an anonymous person deletes it here alone', () async { await endpoints.account.delete(asAnna()); expect(register.asked, isEmpty); expect(await AuthUser.db.findById(session, anna), isNull); expect(hostDeleted, [anna]); }); test('is refused while the register is out of reach, deleting ' 'nothing here', () async { register.failing = RegisterUnavailableException('test'); await expectLater( endpoints.account.delete(asBen()), throwsA(isA()), ); expect(await AuthUser.db.findById(session, ben), isNotNull); expect( await ServerSideSession.db.count( session, where: (t) => t.authUserId.equals(ben), ), 1, ); expect(hostDeleted, isEmpty); }); }); group('told by the register that an account is gone', () { Future tell( Object? account, { String? authorization = 'Bearer $_secret', }) => answerRegister( session, jsonEncode({'account': account}), authorization: authorization, ); test('deletes that person here with their sessions, and nobody ' 'else', () async { final answer = await tell(ben.uuid); expect(answer.status, 200); expect(await AuthUser.db.findById(session, ben), isNull); expect( await ServerSideSession.db.count( session, where: (t) => t.authUserId.equals(ben), ), 0, ); expect(hostDeleted, [ben]); expect(await AuthUser.db.findById(session, anna), isNotNull); expect(register.asked, isEmpty); }); test('confirms an account it never had', () async { expect((await tell(const Uuid().v7())).status, 200); expect(hostDeleted, isEmpty); }); test('refuses without the instance secret, or with a wrong one, ' 'deleting nobody', () async { for (final authorization in [ null, 'Bearer wrong', 'Basic $_secret', ]) { expect( (await tell(ben.uuid, authorization: authorization)).status, 401, ); } useInstanceSecret(pod, null); expect((await tell(ben.uuid)).status, 401); expect(await AuthUser.db.findById(session, ben), isNotNull); expect(hostDeleted, isEmpty); }); test('refuses what it cannot read', () async { for (final account in [null, 7, 'not an id']) { expect((await tell(account)).status, 400, reason: '$account'); } expect( (await answerRegister( session, 'not JSON', authorization: 'Bearer $_secret', )).status, 400, ); expect(await AuthUser.db.findById(session, ben), isNotNull); }); test('is answered by no register', () async { useBase(sessionBuilder); expect((await tell(ben.uuid)).status, 404); expect(await AuthUser.db.findById(session, ben), isNotNull); }); }); group('told by the register that sessions ended (ADR 0017)', () { Future tell( Map body, { String? authorization = 'Bearer $_secret', }) => answerSessionsEnded( session, jsonEncode(body), authorization: authorization, ); Future createdAt(UuidValue id) async => (await ServerSideSession.db.findById(session, id))!.createdAt; test('ends those of the account made before the moment, but the one ' 'kept and those made since, and nobody else', () async { final bensPhone = await signInNewDevice(session, ben); final endedBefore = (await createdAt( bensPhone, )).add(const Duration(microseconds: 1)); await Future.delayed(const Duration(milliseconds: 5)); final bensTablet = await signInNewDevice(session, ben); final answer = await tell({ 'account': ben.uuid, 'endedBefore': endedBefore.toIso8601String(), 'keepSession': bensLaptop.uuid, }); expect(answer.status, 200); final left = { for (final row in await ServerSideSession.db.find( session, where: (t) => t.authUserId.equals(ben), )) row.id!, }; expect(left, {bensLaptop, bensTablet}); expect( await ServerSideSession.db.findById(session, annasPhone), isNotNull, ); expect(register.asked, isEmpty); }); test('refuses without the secret and what it cannot read, and is ' 'answered by no register, ending nothing', () async { final everything = { 'account': ben.uuid, 'endedBefore': DateTime.now().toUtc().toIso8601String(), }; expect((await tell(everything, authorization: null)).status, 401); expect( (await tell(everything, authorization: 'Bearer wrong')).status, 401, ); expect( (await tell({'account': ben.uuid, 'endedBefore': 'soon'})).status, 400, ); expect((await tell({'account': ben.uuid})).status, 400); useBase(sessionBuilder); expect((await tell(everything)).status, 404); expect( await ServerSideSession.db.findById(session, bensLaptop), isNotNull, ); }); }); group('changing a password, by asking the register (ADR 0017)', () { test('keeps the session asking: its ID goes to the register, which ' 'tells every service', () async { final challenge = await endpoints.password.beginChange(asBen()); await endpoints.password.change( asBen(), challenge: challenge.challenge, clientValue: ByteData(384), clientProof: ByteData(32), totpCode: '123456', password: NewPassword( salt: ByteData(16), costs: newPasswordCosts, verifier: ByteData(384), passwordKeys: WrappedKeys( nonce: ByteData(24), cipherText: ByteData(112), ), ), host: testHost, ); expect(register.asked, ['beginPasswordChange', 'changePassword']); expect(register.changed, {ben: bensLaptop}); }); test('a reset ends the sessions here made before the register ended ' 'them, the new one made after', () async { register ..proof = PasswordProof( account: ben, serverProof: ByteData(32), encryptionPublicKey: ByteData(32), signingPublicKey: ByteData(32), passwordKeys: WrappedKeys( nonce: ByteData(24), cipherText: ByteData(112), ), needsAuthenticator: false, ) ..endedBefore = DateTime.now().toUtc(); final signedIn = await endpoints.accountRecovery.resetPassword( notSignedIn(), challenge: const Uuid().v7obj(), signature: ByteData(64), password: NewPassword( salt: ByteData(16), costs: newPasswordCosts, verifier: ByteData(384), passwordKeys: WrappedKeys( nonce: ByteData(24), cipherText: ByteData(112), ), ), totpCode: '123456', host: testHost, ); expect(signedIn.authUserId, ben); final left = await ServerSideSession.db.find( session, where: (t) => t.authUserId.equals(ben), ); expect(left.single.id, isNot(bensLaptop)); expect( left.single.createdAt.isBefore(register.endedBefore), isFalse, ); }); test("waits a few seconds at most for the register's moment: a " 'clock further behind is refused at once, and signs nobody in, ' 'rather than holding the request', () async { register ..proof = PasswordProof( account: ben, serverProof: ByteData(32), encryptionPublicKey: ByteData(32), signingPublicKey: ByteData(32), passwordKeys: WrappedKeys( nonce: ByteData(24), cipherText: ByteData(112), ), needsAuthenticator: false, ) ..endedBefore = DateTime.now().toUtc().add( const Duration(minutes: 1), ); final watch = Stopwatch()..start(); await expectLater( endpoints.accountRecovery.resetPassword( notSignedIn(), challenge: const Uuid().v7obj(), signature: ByteData(64), password: NewPassword( salt: ByteData(16), costs: newPasswordCosts, verifier: ByteData(384), passwordKeys: WrappedKeys( nonce: ByteData(24), cipherText: ByteData(112), ), ), totpCode: '123456', host: testHost, ), throwsA( isA().having( (error) => error.reason, 'reason', PasswordRefusal.unavailable, ), ), ); expect(watch.elapsed, lessThan(maxClockSkew)); expect( await ServerSideSession.db.count( session, where: (t) => t.authUserId.equals(ben), ), 0, ); }); }); group('pruning', () { late UuidValue carla; setUp(() async { carla = (await AuthServices.instance.authUsers.create( session, scopes: {identified}, )).id; }); test('deletes the identified people the register no longer knows, ' 'asking only about those', () async { final forgotten = await forgetPeopleTheRegisterForgot( session, batch: 1, ); expect(forgotten, 1); expect(await AuthUser.db.findById(session, carla), isNull); expect(await AuthUser.db.findById(session, ben), isNotNull); expect(await AuthUser.db.findById(session, anna), isNotNull); expect(register.askedAbout.expand((people) => people), {ben, carla}); }); test('deletes nobody while the register knows none of those asked ' 'about, as a register misnamed or emptied would say', () async { register.accounts.clear(); expect(await forgetPeopleTheRegisterForgot(session), 0); expect(await AuthUser.db.findById(session, ben), isNotNull); expect(await AuthUser.db.findById(session, carla), isNotNull); }); test('deletes nobody while the register is out of reach', () async { register.failing = RegisterUnavailableException('test'); await expectLater( forgetPeopleTheRegisterForgot(session), throwsA(isA()), ); expect(await AuthUser.db.findById(session, ben), isNotNull); expect(await AuthUser.db.findById(session, carla), isNotNull); }); test('does nothing on the register', () async { useBase(sessionBuilder); expect(await forgetPeopleTheRegisterForgot(session), 0); expect(register.asked, isEmpty); expect(await AuthUser.db.findById(session, carla), isNotNull); }); }); test('does not start without the secret where it would need it, and ' 'needs none alone', () { expect(() => checkInstanceSecret(pod), returnsNormally); useInstanceSecret(pod, null); expect(() => checkInstanceSecret(pod), throwsStateError); useBase(sessionBuilder, accounts: HoldsRegister(members: [_learn])); expect(() => checkInstanceSecret(pod), throwsStateError); useBase(sessionBuilder); expect(() => checkInstanceSecret(pod), returnsNormally); }); test('does not start with a secret shorter than 32 characters, alone ' 'or not: it gates every account of the instance', () { for (final accounts in [ const HoldsRegister(), HoldsRegister(members: [_learn]), MemberOfRegister(_learn), ]) { useBase(sessionBuilder, accounts: accounts); useInstanceSecret(pod, 'x' * 31); expect(() => checkInstanceSecret(pod), throwsStateError); useInstanceSecret(pod, 'x' * 32); expect(() => checkInstanceSecret(pod), returnsNormally); } }); test('asks the register at its address with the secret, and asks ' 'nothing without one', () { AccountRegister.use(pod, null); expect( AccountRegister.of(pod), isA() .having((remote) => remote.register, 'register', _learn) .having((remote) => remote.secret, 'secret', _secret), ); useInstanceSecret(pod, null); expect(() => AccountRegister.of(pod), throwsStateError); expect( MemberNotifier.of(pod), isA().having( (notifier) => notifier.secret, 'secret', isNull, ), ); }); }, ); } Future _deleteAll(Session session) async { await AuthUser.db.deleteWhere(session, where: (t) => t.id.notEquals(null)); }