import 'package:clock/clock.dart'; import 'package:crypto/crypto.dart'; import 'package:halkyon_base_server/base.dart'; import 'package:halkyon_core/halkyon_core.dart' show bip39English; import 'package:halkyon_oidc_server/oidc.dart'; import 'package:halkyon_oidc_server/testing.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/keys_device.dart'; import '../support/mailbox.dart'; import '../support/password_device.dart'; import '../support/password_support.dart'; import '../test_tools/serverpod_test_tools.dart'; // Who signs up, and accounts waiting for an operator (Halkyon Learn's // ADR 0021, D19 to D26 and D31 to D33): SIGN_UP open, approval or closed; a // new account waiting with no session but a pass and four words; approved, // refused or suspended by an operator, who never waits; and what the daily // pruning forgets. On the instance's register, signing up with SimpleLogin // (and once with an address, as passwords do) against a fake SimpleLogin. void main() { late TestKey key; setUpAll(() => key = TestKey.generate('sl', seed: 12)); withServerpod( 'Sign-ups and operators', // Sessions issued, and the rate limiter's attempts in transactions of // their own: these tests delete what they make instead. rollbackDatabase: RollbackDatabase.disabled, (sessionBuilder, endpoints) { late Session session; late Serverpod pod; late FakeProvider simpleLogin; late KeysDevice device; /// The instance's operator, signed in on her laptop. late UuidValue olga; late UuidValue olgasLaptop; /// Identified, signed in on his phone; no operator. late UuidValue ben; late UuidValue bensPhone; setUp(() async { useBase(sessionBuilder, app: 'learn'); session = sessionBuilder.build(); pod = session.serverpod; await _deleteAll(session); simpleLogin = FakeProvider.simpleLogin( key: key, clientId: 'halkyon-learn', clientSecret: 'a secret of the app', callbackUrl: Uri.parse('https://learn.test/simplelogin-callback'), ); useSimpleLogin(sessionBuilder, simpleLogin); device = await KeysDevice.make(); olga = await newPersonWithAddress( session, 'olga@example.org', handle: 'Olga', ); await _makeOperator(session, olga); olgasLaptop = await signInNewDevice( session, olga, scopes: {identified, instanceOperator}, ); ben = await newPersonWithAddress( session, 'ben@example.org', handle: 'ben', ); bensPhone = await signInNewDevice(session, ben, scopes: {identified}); }); tearDown(() async { useSignUpMode(pod, null); useSimpleLoginApp(pod, null); await _deleteAll(sessionBuilder.build()); }); TestSessionBuilder notSignedIn() => sessionBuilder.copyWith( authentication: AuthenticationOverride.unauthenticated(), ); TestSessionBuilder asOlga() => sessionBuilder.copyWith( authentication: AuthenticationOverride.authenticationInfo( olga.uuid, {identified, instanceOperator}, authId: olgasLaptop.uuid, ), ); TestSessionBuilder asBen() => sessionBuilder.copyWith( authentication: AuthenticationOverride.authenticationInfo( ben.uuid, {identified}, authId: bensPhone.uuid, ), ); Matcher refusedSignUp(SignUpRefusal reason) => throwsA( isA().having((e) => e.reason, 'reason', reason), ); Matcher refusedForOperator(OperatorRefusal reason) => throwsA( isA().having( (e) => e.reason, 'reason', reason, ), ); final waits = throwsA(isA()); /// Someone new allowed at SimpleLogin as [subject]: the attempt their /// identity waits under. Future newcomer({String subject = 'sl-carla'}) async { final start = await endpoints.simpleLogin.begin( notSignedIn(), back: SimpleLoginReturn.loopback, ); final completed = await endpoints.simpleLogin.complete( notSignedIn(), attempt: start.attempt, code: simpleLogin.authorize( start.authorizationUrl, subject: subject, claims: {'name': 'Carla Müller'}, ), ); expect(completed.outcome, SimpleLoginOutcome.newcomer); return start.attempt; } /// Signs up someone new as [subject] with [handle]: the session, or /// the wait thrown. Future signUp({ String subject = 'sl-carla', String handle = 'Carla_M', }) async => await endpoints.simpleLogin.signUp( notSignedIn(), attempt: await newcomer(subject: subject), handle: handle, setup: device.setup(), ); /// Signs up someone new who waits: the wait. Future signUpToWait({ String subject = 'sl-carla', String handle = 'Carla_M', }) async { try { await signUp(subject: subject, handle: handle); } on AccountWaitingException catch (waiting) { return waiting; } fail('$handle signed in at once'); } /// Signs in with SimpleLogin as [subject], who has an account: what /// the sign-in ended with, or the wait thrown. Future signIn({String subject = 'sl-carla'}) async { final start = await endpoints.simpleLogin.begin( notSignedIn(), back: SimpleLoginReturn.loopback, ); return await endpoints.simpleLogin.complete( notSignedIn(), attempt: start.attempt, code: simpleLogin.authorize( start.authorizationUrl, subject: subject, ), ); } /// Signs in as [subject] while their account waits: the wait. Future signInToWait({ String subject = 'sl-carla', }) async { try { await signIn(subject: subject); } on AccountWaitingException catch (waiting) { return waiting; } fail('$subject signed in'); } /// The account whose handle is [handle]. Future accountOf(String handle) async => (await PublicProfile.db.findFirstRow( session, where: (t) => t.handleKey.equals(handle.toLowerCase()), ))!.authUserId; Future> sessionsOf(UuidValue person) => ServerSideSession.db.find( session, where: (t) => t.authUserId.equals(person), ); group('SIGN_UP (D19)', () { test('open, as unset: someone new signs in at once, and waits for ' 'nobody', () async { expect( (await endpoints.instance.basics(notSignedIn())).signUp, SignUpMode.open, ); final carla = (await signUp()).authUserId; expect(await sessionsOf(carla), hasLength(1)); expect(await WaitingAccount.db.count(session), 0); }); test('is told in the basics as the operator set it', () async { for (final mode in SignUpMode.values) { useSignUpMode(pod, mode); expect( (await endpoints.instance.basics(notSignedIn())).signUp, mode, ); } }); test('closed: nobody new signs up with SimpleLogin, the identity left ' 'waiting for when it opens', () async { useSignUpMode(pod, SignUpMode.closed); final attempt = await newcomer(); await expectLater( endpoints.simpleLogin.signUp( notSignedIn(), attempt: attempt, handle: 'Carla_M', setup: device.setup(), ), refusedSignUp(SignUpRefusal.closed), ); expect(await AuthUser.db.count(session), 2); useSignUpMode(pod, SignUpMode.open); await endpoints.simpleLogin.signUp( notSignedIn(), attempt: attempt, handle: 'Carla_M', setup: device.setup(), ); expect(await AuthUser.db.count(session), 3); }); test('closed: those with accounts still sign in', () async { await signUp(); useSignUpMode(pod, SignUpMode.closed); final completed = await signIn(); expect(completed.outcome, SimpleLoginOutcome.signedIn); expect(completed.session, isNotNull); }); group('with an address', () { late Mailbox mailbox; late PasswordDevice passwords; setUp(() async { mailbox = Mailbox(); EmailCodes.of(pod).mailer = mailbox; usePasswordKeys(pod, testPasswordKeys); passwords = await PasswordDevice.make(); }); tearDown(() { EmailCodes.of(pod).mailer = null; usePasswordKeys(pod, null); }); Future signUpWithAddress( String address, { required String handle, }) async { final request = await endpoints.emailCode.sendCode( notSignedIn(), address: address, language: 'en', purpose: EmailCodePurpose.signUp, ); return await endpoints.passwordSignIn.signUp( notSignedIn(), request: request, code: mailbox.codeFor(address), setup: passwords.setup(), handle: handle, ); } test('closed: nobody new, but an account of before takes its ' 'password, as it is no new account', () async { useSignUpMode(pod, SignUpMode.closed); await expectLater( signUpWithAddress('dora@example.org', handle: 'dora'), refusedSignUp(SignUpRefusal.closed), ); expect(await EmailIdentity.db.count(session), 2); final signedUp = await signUpWithAddress( 'ben@example.org', handle: 'ben_b', ); expect(signedUp.authUserId, ben); }); test('approval: a new account waits, an account of before taking ' 'its password does not', () async { useSignUpMode(pod, SignUpMode.approval); await expectLater( signUpWithAddress('dora@example.org', handle: 'dora'), waits, ); final dora = await accountOf('dora'); expect(await sessionsOf(dora), isEmpty); expect( (await WaitingAccount.db.findFirstRow( session, where: (t) => t.authUserId.equals(dora), ))!.suspendedAt, isNull, ); final signedUp = await signUpWithAddress( 'ben@example.org', handle: 'ben_b', ); expect(signedUp.authUserId, ben); }); }); test('reads as the operator writes it, and refuses what it does not ' 'know, so the server stops as it starts', () { expect(signUpModeFrom(null), SignUpMode.open); expect(signUpModeFrom(''), SignUpMode.open); expect(signUpModeFrom('open'), SignUpMode.open); expect(signUpModeFrom(' Approval '), SignUpMode.approval); expect(signUpModeFrom('CLOSED'), SignUpMode.closed); for (final value in ['invite', 'approved', 'open,closed']) { expect( () => signUpModeFrom(value), throwsA(isA()), reason: value, ); } }); }); group('waiting for approval (D23, D31, D33)', () { setUp(() => useSignUpMode(pod, SignUpMode.approval)); test( 'a new account gets no session but a pass and four words of ' "BIP-39's list, the instance keeping the pass's hash alone", () async { final waiting = await signUpToWait(); final carla = await accountOf('Carla_M'); expect(await sessionsOf(carla), isEmpty); expect(waiting.handle, 'Carla_M'); expect(waiting.suspended, isFalse); expect(waiting.words, hasLength(4)); expect(waiting.words.every(bip39English.contains), isTrue); expect(waiting.pass.length, greaterThanOrEqualTo(43)); final pass = (await WaitingPass.db.find(session)).single; expect(pass.authUserId, carla); expect( pass.passHash, sha256.convert(waiting.pass.codeUnits).toString(), ); final row = (await WaitingAccount.db.find(session)).single; expect(row.authUserId, carla); expect(row.words, waiting.words.join(' ')); // Still a person, with a handle and keys, as signing up makes them. expect( (await AuthUser.db.findById(session, carla))!.scopeNames, {identified.name}, ); expect( await AccountPassword.db.count( session, where: (t) => t.authUserId.equals(carla), ), 1, ); }, ); test('signing in again waits too: another pass, the same words, and ' 'still no session', () async { final first = await signUpToWait(); final again = await signInToWait(); expect(again.pass, isNot(first.pass)); expect(again.words, first.words); expect(await sessionsOf(await accountOf('Carla_M')), isEmpty); expect(await WaitingPass.db.count(session), 2); }); test('the pass says the account waits; one nobody was given is ' 'unknown', () async { final waiting = await signUpToWait(); expect( await endpoints.waiting.look(notSignedIn(), pass: waiting.pass), WaitingState.waiting, ); expect( await endpoints.waiting.look(notSignedIn(), pass: 'made up'), WaitingState.unknown, ); expect( await endpoints.waiting.look( notSignedIn(), pass: '${waiting.pass}x', ), WaitingState.unknown, ); }); test('at most 10 new accounts wait at once: one more is refused, its ' 'identity left waiting, suspended accounts not counted', () async { for (var i = 0; i < 10; i++) { await signUpToWait(subject: 'sl-new$i', handle: 'new$i'); } final attempt = await newcomer(); await expectLater( endpoints.simpleLogin.signUp( notSignedIn(), attempt: attempt, handle: 'Carla_M', setup: device.setup(), ), refusedSignUp(SignUpRefusal.tooManyWaiting), ); expect(await PublicProfile.db.count(session), 12); await endpoints.instanceOperator.suspend(asOlga(), account: ben); await expectLater( endpoints.simpleLogin.signUp( notSignedIn(), attempt: attempt, handle: 'Carla_M', setup: device.setup(), ), refusedSignUp(SignUpRefusal.tooManyWaiting), ); await endpoints.instanceOperator.refuse( asOlga(), account: await accountOf('new0'), ); await expectLater( endpoints.simpleLogin.signUp( notSignedIn(), attempt: attempt, handle: 'Carla_M', setup: device.setup(), ), waits, ); }); test('approved, the pass says so once and is gone; the next sign-in ' 'gives a session', () async { final waiting = await signUpToWait(); final carla = await accountOf('Carla_M'); await endpoints.instanceOperator.approve(asOlga(), account: carla); expect( await endpoints.waiting.look(notSignedIn(), pass: waiting.pass), WaitingState.approved, ); expect( await endpoints.waiting.look(notSignedIn(), pass: waiting.pass), WaitingState.unknown, ); expect(await sessionsOf(carla), isEmpty); final completed = await signIn(); expect(completed.session!.authUserId, carla); expect(await WaitingAccount.db.count(session), 0); }); test('an operator never waits: the next sign-in gives a session, the ' 'wait gone (D32)', () async { final waiting = await signUpToWait(); final carla = await accountOf('Carla_M'); await _makeOperator(session, carla); final completed = await signIn(); expect(completed.session!.authUserId, carla); expect(await WaitingAccount.db.count(session), 0); expect( await endpoints.waiting.look(notSignedIn(), pass: waiting.pass), WaitingState.approved, ); }); test('its pass deletes a new account with all it has', () async { final waiting = await signUpToWait(); final carla = await accountOf('Carla_M'); await signInToWait(); expect( await endpoints.waiting.deleteAccount( notSignedIn(), pass: waiting.pass, ), isTrue, ); expect(await AuthUser.db.findById(session, carla), isNull); expect(await PublicProfile.db.count(session), 2); expect(await WaitingAccount.db.count(session), 0); expect(await WaitingPass.db.count(session), 0); expect( await endpoints.waiting.look(notSignedIn(), pass: waiting.pass), WaitingState.unknown, ); }); test('a pass nobody was given deletes nothing', () async { await signUpToWait(); expect( await endpoints.waiting.deleteAccount( notSignedIn(), pass: 'made up', ), isFalse, ); expect(await AuthUser.db.count(session), 3); }); test('forgetting its pass forgets that one alone', () async { final first = await signUpToWait(); final second = await signInToWait(); await endpoints.waiting.forget(notSignedIn(), pass: first.pass); expect( await endpoints.waiting.look(notSignedIn(), pass: first.pass), WaitingState.unknown, ); expect( await endpoints.waiting.look(notSignedIn(), pass: second.pass), WaitingState.waiting, ); expect(await AuthUser.db.count(session), 3); }); test('a pass holds 30 days', () async { final waiting = await signUpToWait(); expect( await withClock( Clock.fixed(clock.now().add(const Duration(days: 29))), () => endpoints.waiting.look(notSignedIn(), pass: waiting.pass), ), WaitingState.waiting, ); expect( await withClock( Clock.fixed(clock.now().add(const Duration(days: 31))), () => endpoints.waiting.look(notSignedIn(), pass: waiting.pass), ), WaitingState.unknown, ); }); test( 'an account keeps at most 5 passes, the oldest going first', () async { final first = await signUpToWait(); final later = [for (var i = 0; i < 5; i++) await signInToWait()]; expect(await WaitingPass.db.count(session), 5); expect( await endpoints.waiting.look(notSignedIn(), pass: first.pass), WaitingState.unknown, ); for (final waiting in later) { expect( await endpoints.waiting.look(notSignedIn(), pass: waiting.pass), WaitingState.waiting, ); } }, ); test('refused by an operator, the account is deleted, and its person ' 'may sign up again and wait again (D26)', () async { final waiting = await signUpToWait(); final carla = await accountOf('Carla_M'); await endpoints.instanceOperator.refuse(asOlga(), account: carla); expect(await AuthUser.db.findById(session, carla), isNull); expect(await OidcIdentity.db.count(session), 0); expect( await endpoints.waiting.look(notSignedIn(), pass: waiting.pass), WaitingState.unknown, ); final again = await signUpToWait(); expect(again.handle, 'Carla_M'); }); }); group('suspended (D22)', () { test('every session of the account ends, and its next sign-in waits ' 'suspended, with words of its own', () async { final carla = (await signUp()).authUserId; await signInNewDevice(session, carla, scopes: {identified}); await endpoints.instanceOperator.suspend(asOlga(), account: carla); expect(await sessionsOf(carla), isEmpty); final waiting = await signInToWait(); expect(waiting.suspended, isTrue); expect(waiting.words, hasLength(4)); expect( await endpoints.waiting.look(notSignedIn(), pass: waiting.pass), WaitingState.suspended, ); // Its data is kept. expect( await AccountPassword.db.count( session, where: (t) => t.authUserId.equals(carla), ), 1, ); }); test('is not deleted with its pass: deleting it takes the password ' 'and a code (ADR 0017)', () async { final carla = (await signUp()).authUserId; await endpoints.instanceOperator.suspend(asOlga(), account: carla); final waiting = await signInToWait(); expect( await endpoints.waiting.deleteAccount( notSignedIn(), pass: waiting.pass, ), isFalse, ); expect(await AuthUser.db.findById(session, carla), isNotNull); }); test("can't be refused, being no new account, and signs in once " 'approved anew', () async { final carla = (await signUp()).authUserId; await endpoints.instanceOperator.suspend(asOlga(), account: carla); await expectLater( endpoints.instanceOperator.refuse(asOlga(), account: carla), refusedForOperator(OperatorRefusal.notFound), ); await endpoints.instanceOperator.approve(asOlga(), account: carla); expect((await signIn()).session!.authUserId, carla); }); test('waits whatever SIGN_UP says, open too', () async { final carla = (await signUp()).authUserId; await endpoints.instanceOperator.suspend(asOlga(), account: carla); await signInToWait(); }); }); group('the pruning (D25)', () { test('deletes new accounts unapproved after 14 days with their passes, ' 'never suspended ones, and forgets passes after 30 days', () async { useSignUpMode(pod, SignUpMode.approval); await signUpToWait(); final carla = await accountOf('Carla_M'); await signUpToWait(subject: 'sl-dora', handle: 'dora'); final dora = await accountOf('dora'); await endpoints.instanceOperator.suspend(asOlga(), account: ben); final now = clock.now(); expect( await forgetUnapprovedSignUps( session, now: now.add(const Duration(days: 13)), ), 0, ); await withClock( Clock.fixed(now.add(const Duration(days: 10))), () => endpoints.instanceOperator.approve(asOlga(), account: dora), ); expect( await forgetUnapprovedSignUps( session, now: now.add(const Duration(days: 15)), ), 1, ); expect(await AuthUser.db.findById(session, carla), isNull); expect(await AuthUser.db.findById(session, dora), isNotNull); expect(await AuthUser.db.findById(session, ben), isNotNull); expect(await WaitingAccount.db.count(session), 1); // Dora's pass, approved and never looked at again, until its time. expect(await WaitingPass.db.count(session), 1); await forgetUnapprovedSignUps( session, now: now.add(const Duration(days: 31)), ); expect(await WaitingPass.db.count(session), 0); }); }); group("the operator's calls (D21, D22, D32)", () { test( 'are refused to anyone but an operator, signed in or not', () async { useSignUpMode(pod, SignUpMode.approval); await signUpToWait(); final carla = await accountOf('Carla_M'); final calls = Function(TestSessionBuilder)>{ 'waiting': (as) => endpoints.instanceOperator.waiting(as), 'approve': (as) => endpoints.instanceOperator.approve(as, account: carla), 'refuse': (as) => endpoints.instanceOperator.refuse(as, account: carla), 'accounts': (as) => endpoints.instanceOperator.accounts(as, startingWith: ''), 'suspend': (as) => endpoints.instanceOperator.suspend(as, account: olga), 'operators': (as) => endpoints.instanceOperator.operators(as), 'appoint': (as) => endpoints.instanceOperator.appoint(as, handle: 'ben'), 'takeBack': (as) => endpoints.instanceOperator.takeBack(as, account: olga), }; for (final MapEntry(key: name, value: call) in calls.entries) { await expectLater( call(asBen()), throwsA(isA()), reason: name, ); await expectLater( call(notSignedIn()), throwsA(isA()), reason: name, ); } expect(await WaitingAccount.db.count(session), 1); expect(await AuthUser.db.findById(session, carla), isNotNull); expect( (await AuthUser.db.findById(session, ben))!.scopeNames, {identified.name}, ); expect( (await AuthUser.db.findById(session, olga))!.scopeNames, contains(instanceOperator.name), ); }, ); test('list who waits, oldest first, with handle and words, suspended ' 'ones marked', () async { useSignUpMode(pod, SignUpMode.approval); final carlas = await signUpToWait(); await endpoints.instanceOperator.suspend(asOlga(), account: ben); final waiting = await endpoints.instanceOperator.waiting(asOlga()); expect( [ for (final account in waiting) (account.handle, account.suspended), ], [('Carla_M', false), ('ben', true)], ); expect(waiting.first.account, await accountOf('Carla_M')); expect(waiting.first.words, carlas.words); expect(waiting.first.waitingSince, isNotNull); expect(waiting.last.words, hasLength(4)); }); test( 'approve and refuse only accounts that wait as each needs', () async { final carla = (await signUp()).authUserId; await expectLater( endpoints.instanceOperator.approve(asOlga(), account: carla), refusedForOperator(OperatorRefusal.notFound), ); await expectLater( endpoints.instanceOperator.refuse(asOlga(), account: carla), refusedForOperator(OperatorRefusal.notFound), ); await expectLater( endpoints.instanceOperator.approve( asOlga(), account: UuidValue.fromString( '00000000-0000-7000-8000-000000000000', ), ), refusedForOperator(OperatorRefusal.notFound), ); expect(await AuthUser.db.findById(session, carla), isNotNull); }, ); test('find accounts by the start of their handle, whatever its case, ' 'by handle, at most 50', () async { for (var i = 0; i < 52; i++) { await newPersonWithAddress( session, 'b$i@example.org', handle: 'Bee${i.toString().padLeft(2, '0')}', ); } final found = await endpoints.instanceOperator.accounts( asOlga(), startingWith: 'BEE', ); expect(found, hasLength(50)); expect(found.first.handle, 'Bee00'); expect(found.last.handle, 'Bee49'); final olgas = (await endpoints.instanceOperator.accounts( asOlga(), startingWith: 'olg', )).single; expect( (olgas.account, olgas.isOperator, olgas.waitingSince), ( olga, true, null, ), ); expect( await endpoints.instanceOperator.accounts( asOlga(), startingWith: '%', ), isEmpty, ); }); test( 'suspend no operator, and no account that waits already', () async { useSignUpMode(pod, SignUpMode.approval); await signUpToWait(); await expectLater( endpoints.instanceOperator.suspend(asOlga(), account: olga), refusedForOperator(OperatorRefusal.isOperator), ); await expectLater( endpoints.instanceOperator.suspend( asOlga(), account: await accountOf('Carla_M'), ), refusedForOperator(OperatorRefusal.notFound), ); expect(await sessionsOf(olga), hasLength(1)); }, ); test('name another operator by handle, whatever its case: the role on ' 'the account and its sessions, approving it if it waits', () async { await endpoints.instanceOperator.suspend(asOlga(), account: ben); final bensLaptop = await signInNewDevice( session, ben, scopes: {identified}, ); await endpoints.instanceOperator.appoint(asOlga(), handle: 'BEN'); expect( (await AuthUser.db.findById(session, ben))!.scopeNames, {identified.name, instanceOperator.name}, ); expect( (await ServerSideSession.db.findById( session, bensLaptop, ))!.scopeNames, {identified.name, instanceOperator.name}, ); expect(await WaitingAccount.db.count(session), 0); expect( [ for (final account in await endpoints.instanceOperator.operators( asOlga(), )) account.handle, ], ['ben', 'Olga'], ); await expectLater( endpoints.instanceOperator.appoint(asOlga(), handle: 'nobody'), refusedForOperator(OperatorRefusal.notFound), ); }); test('take the role back from the account and its sessions, never ' 'from the last operator', () async { await endpoints.instanceOperator.appoint(asOlga(), handle: 'ben'); await endpoints.instanceOperator.takeBack(asOlga(), account: ben); expect( (await AuthUser.db.findById(session, ben))!.scopeNames, {identified.name}, ); expect( (await ServerSideSession.db.findById( session, bensPhone, ))!.scopeNames, {identified.name}, ); await expectLater( endpoints.instanceOperator.takeBack(asOlga(), account: olga), refusedForOperator(OperatorRefusal.lastOperator), ); await expectLater( endpoints.instanceOperator.takeBack(asOlga(), account: ben), refusedForOperator(OperatorRefusal.notFound), ); expect( (await AuthUser.db.findById(session, olga))!.scopeNames, contains(instanceOperator.name), ); }); test( "tell an operator's app how many wait, and nobody else's", () async { useSignUpMode(pod, SignUpMode.approval); await signUpToWait(); await signUpToWait(subject: 'sl-dora', handle: 'dora'); expect(await endpoints.account.operating(asOlga()), 2); expect(await endpoints.account.operating(asBen()), isNull); }, ); }); }, ); } /// Makes [person] an operator, as D32's statement does: the scope on the /// account and on its sessions. Future _makeOperator(Session session, UuidValue person) async { final row = (await AuthUser.db.findById(session, person))!; await AuthUser.db.updateRow( session, row.copyWith(scopeNames: {...row.scopeNames, instanceOperator.name!}), ); for (final serverSideSession in await ServerSideSession.db.find( session, where: (t) => t.authUserId.equals(person), )) { await ServerSideSession.db.updateRow( session, serverSideSession.copyWith( scopeNames: {...serverSideSession.scopeNames, instanceOperator.name!}, ), ); } } Future _deleteAll(Session session) async { await OidcAttempt.db.deleteWhere(session, where: (t) => t.id.notEquals(null)); await deleteAll(session); }