halkyon/packages/halkyon_base_server/test/integration/register/register_test.dart
Tizian 「ティツィアーン」 7eb1961b9f
feat(base): handles hidden until their people go public, every answer about a new handle counted (ADR 0020, D5)
A handle's page and profiles.byHandle answer as for a handle nobody has
until its person goes public: a handle fixed by a publish, or a display
name, a bio, a link or an avatar (isPublic). A person's own profile, and
the host's own lookups (personWithHandle), are unchanged.

So every answer about a new handle now tells that an account has it,
and countHandleCheck counts them together on the register, 30 an hour
per person (or attempt) and 60 per network, refused beyond with
ProfileRefusal.tooManyHandleChecks: profile.checkHandle and saving a
profile with a handle new for the person, simpleLogin.checkHandle and
signUp, keys.set with a handle, and Halkyon Learn's inbox.block and
unblock, which look people up by handle. A person's own handle and what
is no handle aren't counted. The SimpleLogin limiters of commit 3 give
way to it, so a network's 60 are shared between people signed in and
people new. The register's checkHandle, saveProfile,
signUpWithSimpleLogin and setFirstKeys carry the visitor's network from
the member that asked.

Halkyon Learn's handle dialog words the new refusal (English, German).

Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-24 09:17:49 +02:00

1897 lines
64 KiB
Dart

import 'dart:convert';
import 'dart:typed_data';
import 'package:clock/clock.dart';
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/register_protocol.dart';
import 'package:halkyon_base_server/src/register/remote_register.dart';
import 'package:halkyon_base_server/src/register/sessions_ended.dart'
show sessionNoticesDelivered;
import 'package:halkyon_oidc_server/oidc.dart';
import 'package:halkyon_oidc_server/testing.dart';
import 'package:http/http.dart' as http;
import 'package:http/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/password_device.dart';
import '../test_tools/serverpod_test_tools.dart';
const _secret = 'a shared secret only the instance knows';
final _todo = Uri.parse('http://todo.test:8082');
final _train = Uri.parse('http://train.test:8082');
/// Keeps every code mailed, in place of a mail service.
final class _Mailbox implements CodeMailer {
final sent = <({String to, String code})>[];
@override
Future<void> send(
Session session, {
required String to,
required String code,
required EmailCodePurpose purpose,
required String language,
}) async => sent.add((to: to, code: code));
@override
Future<void> sendResetNotice(
Session session, {
required String to,
required AccountResetKind kind,
required DateTime takesEffectAt,
required String language,
}) async {}
@override
Future<void> sendCodesWaitNotice(
Session session, {
required String to,
required DateTime until,
required String language,
}) async {}
String codeFor(String address) =>
sent.lastWhere((mail) => mail.to == address).code;
}
/// The members as the register tells them, each answering as [answers]
/// says (confirming by default).
final class _Members implements MemberNotifier {
final told = <(Uri, UuidValue)>[];
/// The sessions ended they were told of: member, account, moment, kept.
final sessionsTold = <(Uri, UuidValue, DateTime, UuidValue?)>[];
bool Function(Uri member) answers = (_) => true;
@override
Future<bool> accountGone(Uri member, UuidValue account) async {
told.add((member, account));
return answers(member);
}
@override
Future<bool> sessionsEnded(
Uri member,
UuidValue account, {
required DateTime endedBefore,
UuidValue? keepSession,
}) async {
sessionsTold.add((member, account, endedBefore, keepSession));
return answers(member);
}
}
void main() {
withServerpod(
'The register answering its members',
// Mailing codes counts attempts in transactions of their own
// (Serverpod's rate limiter), which the test's rollback can't wrap.
rollbackDatabase: RollbackDatabase.disabled,
(sessionBuilder, endpoints) {
late Session session;
late Serverpod pod;
late _Mailbox mailbox;
late _Members members;
/// The register as a member's server asks it, over HTTP into this
/// server's answers.
late RemoteRegister remote;
/// Identified, signing in with ben@example.org and GitHub 1001.
late UuidValue ben;
setUp(() async {
useBase(sessionBuilder, accounts: HoldsRegister(members: [_todo]));
session = sessionBuilder.build();
pod = session.serverpod;
await _deleteAll(session);
useInstanceSecret(pod, _secret);
mailbox = _Mailbox();
EmailCodes.of(pod).mailer = mailbox;
members = _Members();
MemberNotifier.use(pod, members);
remote = RemoteRegister(
Uri.parse('http://learn.test:8082'),
secret: _secret,
client: () => MockClient((request) async {
final answer = await answerMember(
session,
request.url.pathSegments.last,
request.body,
authorization: request.headers['authorization'],
);
return http.Response.bytes(
utf8.encode(jsonEncode(answer.body)),
answer.status,
);
}),
);
ben = (await AuthServices.instance.authUsers.create(
session,
scopes: {identified},
)).id;
await EmailIdentity.db.insertRow(
session,
EmailIdentity(
authUserId: ben,
addressHash: addressHash(pod, 'ben@example.org'),
createdAt: clock.now(),
),
);
await GitHubAccount.db.insertRow(
session,
GitHubAccount(userIdentifier: '1001', authUserId: ben),
);
});
tearDown(() async {
EmailCodes.of(pod).mailer = null;
MemberNotifier.use(pod, null);
useInstanceSecret(pod, null);
await _deleteAll(sessionBuilder.build());
});
Future<InstanceAnswer> ask(
String operation,
Map<String, Object?> arguments, {
String? authorization = 'Bearer $_secret',
}) => answerMember(
session,
operation,
jsonEncode(arguments),
authorization: authorization,
);
Matcher refused(Object reason) => throwsA(
isA<Exception>().having(
(error) => refusalJson(error)?['reason'],
'reason',
(reason as Enum).name,
),
);
group('refuses', () {
test('a call without the instance secret, with a wrong one or in '
'another scheme, doing nothing', () async {
for (final authorization in [
null,
'',
'Bearer',
'Bearer ',
'Bearer not the secret',
'Bearer $_secret ',
'Basic $_secret',
_secret,
]) {
final answer = await ask(RegisterOperation.deleteAccount, {
'person': ben.uuid,
}, authorization: authorization);
expect(answer.status, 401, reason: authorization);
}
expect(await AuthUser.db.findById(session, ben), isNotNull);
});
test(
'every call on a register without a secret, whatever is sent',
() async {
useInstanceSecret(pod, null);
for (final authorization in [null, 'Bearer ', 'Bearer null']) {
final answer = await ask(RegisterOperation.deleteAccount, {
'person': ben.uuid,
}, authorization: authorization);
expect(answer.status, 401, reason: authorization);
}
expect(await AuthUser.db.findById(session, ben), isNotNull);
},
);
test('register calls on a member, which holds no register', () async {
useBase(
sessionBuilder,
accounts: MemberOfRegister(Uri.parse('http://learn.test:8082')),
);
final answer = await ask(RegisterOperation.knownAccounts, {
'people': [ben.uuid],
});
expect(answer.status, 404);
});
test('what it cannot read, and operations it does not know', () async {
for (final body in ['', 'not JSON', '[]', '"ben"', 'null']) {
final answer = await answerMember(
session,
RegisterOperation.knownAccounts,
body,
authorization: 'Bearer $_secret',
);
expect(answer.status, 400, reason: body);
}
for (final arguments in <Map<String, Object?>>[
{},
{'people': 'not a list'},
{
'people': [1, 2],
},
{
'people': ['not an id'],
},
]) {
final answer = await ask(
RegisterOperation.knownAccounts,
arguments,
);
expect(answer.status, 400, reason: '$arguments');
}
expect(
(await ask(RegisterOperation.deleteAccount, {
'person': 'not an id',
})).status,
400,
);
expect((await ask('drop-everything', {})).status, 404);
expect(await AuthUser.db.findById(session, ben), isNotNull);
});
});
group('profiles', () {
test('are saved, read and fixed for a member as for this server, '
'the same handle never twice', () async {
final anna = const Uuid().v7obj();
final saved = await remote.saveProfile(
session,
anna,
handle: 'Anna',
displayName: ' Anna ',
bio: 'Learning Greek.',
links: ['https://anna.example.org'],
);
expect(saved.handle, 'Anna');
expect(saved.displayName, 'Anna');
expect(
(await remote.profileOf(session, anna))!.toJson(),
saved.toJson(),
);
expect((await profileOf(session, anna))!.toJson(), saved.toJson());
expect(
(await remote.profileByHandle(session, 'anna'))!.toJson(),
saved.toJson(),
);
expect(await remote.personWithHandle(session, 'ANNA'), anna);
expect((await remote.profilesOf(session, {anna, ben})).keys, [anna]);
expect(
await remote.checkHandle(
session,
anna,
'Anna',
network: '203.0.113.7',
),
isNull,
);
expect(
await remote.checkHandle(
session,
ben,
'anna',
network: '203.0.113.7',
),
ProfileRefusal.handleTaken,
);
await expectLater(
remote.saveProfile(
session,
ben,
handle: 'anna',
displayName: '',
bio: '',
links: const [],
),
refused(ProfileRefusal.handleTaken),
);
await remote.fixHandle(session, anna);
expect((await remote.profileOf(session, anna))!.handleFixed, isTrue);
await expectLater(
remote.removeProfile(session, anna),
refused(ProfileRefusal.published),
);
});
test("not public yet are nobody's to a member's apps, while its code "
'still finds whose a handle is (D5)', () async {
final anna = const Uuid().v7obj();
await remote.saveProfile(
session,
anna,
handle: 'Anna',
displayName: '',
bio: '',
links: const [],
network: '198.51.100.7',
);
expect(await remote.profileByHandle(session, 'anna'), isNull);
expect(await remote.personWithHandle(session, 'anna'), anna);
expect((await remote.profileOf(session, anna))!.handle, 'Anna');
});
test("count a member's answers about handles on the register, by "
"the person and the visitor's network", () async {
final anna = const Uuid().v7obj();
await remote.saveProfile(
session,
anna,
handle: 'Anna',
displayName: '',
bio: '',
links: const [],
network: '198.51.100.7',
);
await remote.checkHandle(
session,
anna,
'Anna_G',
network: '198.51.100.7',
);
final counted = await RateLimitedRequestAttempt.db.find(
session,
where: (t) => t.domain.equals('profile'),
);
expect(
counted.map((note) => (note.source, note.key)).toSet(),
{
('handle_checks', anna.uuid),
('handle_checks_per_network', '198.51.100.7'),
},
);
expect(counted, hasLength(4));
});
test('missing are said to be so', () async {
final nobody = const Uuid().v7obj();
expect(await remote.profileOf(session, nobody), isNull);
expect(await remote.profileByHandle(session, 'nobody'), isNull);
expect(await remote.personWithHandle(session, 'nobody'), isNull);
expect(await remote.profilesOf(session, {nobody}), isEmpty);
await expectLater(
remote.removeAvatar(session, nobody),
throwsA(isA<ProfileNotFoundException>()),
);
await expectLater(
remote.checkLinks(session, nobody),
throwsA(isA<ProfileNotFoundException>()),
);
});
});
group('email sign-in', () {
test('is offered as the register offers it', () async {
expect(await remote.emailSignInOffered(session), isTrue);
EmailCodes.of(pod).mailer = null;
expect(await remote.emailSignInOffered(session), isFalse);
});
test("counts the codes a member asks for by its device's network, "
"not the member's", () async {
for (var i = 0; i < 10; i++) {
await remote.sendSignInCode(
session,
address: 'person$i@example.org',
language: 'en',
network: '203.0.113.7',
purpose: EmailCodePurpose.signUp,
);
}
await expectLater(
remote.sendSignInCode(
session,
address: 'person10@example.org',
language: 'en',
network: '203.0.113.7',
purpose: EmailCodePurpose.signUp,
),
refused(EmailSignInRefusal.tooManyCodes),
);
await remote.sendSignInCode(
session,
address: 'person10@example.org',
language: 'en',
network: '198.51.100.4',
purpose: EmailCodePurpose.signUp,
);
expect(mailbox.sent, hasLength(11));
});
test("a code proves the address for a member's password, and a "
'wrong one refuses it, keeping nothing', () async {
usePasswordKeys(pod, testPasswordKeys);
addTearDown(() => usePasswordKeys(pod, null));
final device = await PasswordDevice.make();
final annaOnTodo = const Uuid().v7obj();
final request = await remote.sendSignInCode(
session,
address: 'Anna@example.org',
language: 'de',
network: '203.0.113.7',
purpose: EmailCodePurpose.signUp,
);
await expectLater(
remote.setPassword(
session,
person: annaOnTodo,
anonymousHere: true,
setup: device.setup(),
request: request,
code: 'AAAA-AAAA',
),
refused(EmailSignInRefusal.wrongCode),
);
expect(await AuthUser.db.findById(session, annaOnTodo), isNull);
// Without the member saying Anna is anonymous there, refused
// before her code is used (the full reading's M1).
await expectLater(
remote.setPassword(
session,
person: annaOnTodo,
setup: device.setup(),
request: request,
code: mailbox.codeFor('anna@example.org'),
),
refused(PasswordRefusal.wrongCredentials),
);
await remote.setPassword(
session,
person: annaOnTodo,
anonymousHere: true,
setup: device.setup(),
request: request,
code: mailbox.codeFor('anna@example.org'),
);
expect(
(await AuthUser.db.findById(session, annaOnTodo))!.scopeNames,
contains(identified.name),
);
expect(await remote.knownAccounts(session, {annaOnTodo, ben}), {
annaOnTodo,
ben,
});
});
});
group('GitHub', () {
test("links a member's person, who becomes an account under the "
'same ID, found by it afterwards', () async {
final annaOnTodo = const Uuid().v7obj();
await remote.linkGitHub(
session,
person: annaOnTodo,
gitHubUserId: '2002',
);
expect(await remote.accountWithGitHub(session, '2002'), annaOnTodo);
expect(await remote.accountWithGitHub(session, '1001'), ben);
expect(await remote.accountWithGitHub(session, '3003'), isNull);
expect(
(await AuthUser.db.findById(session, annaOnTodo))!.scopeNames,
contains(identified.name),
);
});
test(
"refuses another person's GitHub account, keeping nothing",
() async {
final annaOnTodo = const Uuid().v7obj();
await expectLater(
remote.linkGitHub(
session,
person: annaOnTodo,
gitHubUserId: '1001',
),
refused(GitHubSignInRefusal.takenByAnotherPerson),
);
expect(await AuthUser.db.findById(session, annaOnTodo), isNull);
},
);
});
group('SimpleLogin (ADR 0020)', () {
late TestKey key;
late FakeProvider simpleLogin;
setUpAll(() => key = TestKey.generate('sl', seed: 12));
setUp(() {
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);
});
tearDown(() => useSimpleLoginApp(pod, null));
test('is offered as the register offers it', () async {
expect(await remote.simpleLoginOffered(session), isTrue);
useSimpleLogin(sessionBuilder, null);
expect(await remote.simpleLoginOffered(session), isFalse);
});
test("begins for a member's app, signed in or out, and signs in "
'the account with that identity', () async {
await OidcIdentity.db.insertRow(
session,
OidcIdentity(
authUserId: ben,
provider: simpleLoginProvider,
subject: 'sl-ben',
),
);
final start = await remote.beginSimpleLogin(
session,
marker: 'todo-desktop',
network: '203.0.113.0/24',
);
final proof = await remote.completeSimpleLogin(
session,
attempt: start.attempt,
code: simpleLogin.authorize(
start.authorizationUrl,
subject: 'sl-ben',
),
);
expect(start.state, startsWith('todo-desktop.'));
expect(proof.outcome, SimpleLoginOutcome.signedIn);
expect(proof.account, ben);
});
test("adds SimpleLogin to a member's person, who becomes an account "
'under the same ID', () async {
final annaOnTodo = const Uuid().v7obj();
final start = await remote.beginSimpleLogin(
session,
marker: 'todo-phone',
caller: annaOnTodo,
network: '203.0.113.0/24',
);
final proof = await remote.completeSimpleLogin(
session,
attempt: start.attempt,
code: simpleLogin.authorize(
start.authorizationUrl,
subject: 'sl-anna',
),
caller: annaOnTodo,
);
expect(proof.outcome, SimpleLoginOutcome.linked);
expect(proof.account, annaOnTodo);
expect(
(await AuthUser.db.findById(session, annaOnTodo))!.scopeNames,
contains(identified.name),
);
expect(
(await remote.signInWaysOf(session, annaOnTodo)).simpleLogin,
isTrue,
);
});
test('passes on the handle suggested to someone new', () async {
final start = await remote.beginSimpleLogin(
session,
marker: 'todo-desktop',
network: '203.0.113.0/24',
);
final proof = await remote.completeSimpleLogin(
session,
attempt: start.attempt,
code: simpleLogin.authorize(
start.authorizationUrl,
subject: 'sl-carla',
claims: {'name': 'Carla Müller'},
),
);
expect(proof.outcome, SimpleLoginOutcome.newcomer);
expect(proof.account, isNull);
expect(proof.suggestedHandle, 'carla_mueller');
});
test("refuses as the register refuses, halkyon_oidc's refusals "
'crossing whole', () async {
final start = await remote.beginSimpleLogin(
session,
marker: 'todo-desktop',
network: '203.0.113.0/24',
);
final code = simpleLogin.authorize(
start.authorizationUrl,
subject: 'sl-carla',
);
await expectLater(
remote.completeSimpleLogin(
session,
attempt: start.attempt,
code: code,
caller: const Uuid().v7obj(),
),
throwsA(
isA<OidcRefusedException>().having(
(error) => error.reason,
'reason',
OidcRefusal.wrongCaller,
),
),
);
await expectLater(
remote.completeSimpleLogin(
session,
attempt: const Uuid().v7obj(),
code: code,
),
refused(OidcRefusal.unknownAttempt),
);
});
test("signs up someone new for a member's app, the handle checked "
'first, whose keys then unlock with the password', () async {
final device = await KeysDevice.make();
final start = await remote.beginSimpleLogin(
session,
marker: 'todo-desktop',
network: '203.0.113.0/24',
);
await remote.completeSimpleLogin(
session,
attempt: start.attempt,
code: simpleLogin.authorize(
start.authorizationUrl,
subject: 'sl-carla',
),
);
expect(
await remote.checkSimpleLoginHandle(
session,
attempt: start.attempt,
handle: 'carla',
network: '203.0.113.0/24',
),
isNull,
);
final carla = await remote.signUpWithSimpleLogin(
session,
attempt: start.attempt,
handle: 'carla',
setup: device.setup(),
network: '203.0.113.0/24',
);
expect(
(await AuthUser.db.findById(session, carla))!.scopeNames,
{identified.name},
);
final ways = await remote.signInWaysOf(session, carla);
expect(
(ways.simpleLogin, ways.keys, ways.password),
(
true,
true,
false,
),
);
final keys = await remote.unlockKeys(
session,
carla,
await device.prove(
await remote.beginPasswordKeyProof(session, carla),
),
);
expect(
Uint8List.sublistView(keys.signingPublicKey),
device.signingPublicKey,
);
});
test('refuses a sign-up and keys as the register refuses, a '
"handle's refusals crossing whole", () async {
final device = await KeysDevice.make();
final start = await remote.beginSimpleLogin(
session,
marker: 'todo-desktop',
network: '203.0.113.0/24',
);
await remote.completeSimpleLogin(
session,
attempt: start.attempt,
code: simpleLogin.authorize(
start.authorizationUrl,
subject: 'sl-carla',
),
);
expect(
await remote.checkSimpleLoginHandle(
session,
attempt: start.attempt,
handle: 'admin',
network: '203.0.113.0/24',
),
ProfileRefusal.handleReserved,
);
await expectLater(
remote.signUpWithSimpleLogin(
session,
attempt: start.attempt,
handle: 'admin',
setup: device.setup(),
network: '203.0.113.0/24',
),
refused(ProfileRefusal.handleReserved),
);
await expectLater(
remote.signUpWithSimpleLogin(
session,
attempt: const Uuid().v7obj(),
handle: 'carla',
setup: device.setup(),
network: '203.0.113.0/24',
),
refused(OidcRefusal.unknownAttempt),
);
await expectLater(
remote.beginPasswordKeyProof(session, ben),
refused(PasswordRefusal.noPassword),
);
final carla = await remote.signUpWithSimpleLogin(
session,
attempt: start.attempt,
handle: 'carla',
setup: device.setup(),
network: '203.0.113.0/24',
);
await expectLater(
remote.unlockKeys(
session,
carla,
await (await KeysDevice.make(seed: 5)).prove(
await remote.beginPasswordKeyProof(session, carla),
),
),
refused(PasswordRefusal.wrongCredentials),
);
});
/// A SimpleLogin sign-in of [person]'s through a member, as
/// [subject], just now: its attempt, which stands for it ten
/// minutes (D20).
Future<UuidValue> signedInAgain(
UuidValue person,
String subject,
) async {
final start = await remote.beginSimpleLogin(
session,
marker: 'todo-desktop',
caller: person,
network: '203.0.113.0/24',
);
await remote.completeSimpleLogin(
session,
attempt: start.attempt,
code: simpleLogin.authorize(
start.authorizationUrl,
subject: subject,
),
caller: person,
);
return start.attempt;
}
test(
"gives a member's account its first keys and handle, once "
'SimpleLogin was added to it, its refusals crossing whole',
() async {
final device = await KeysDevice.make();
final attempt = await signedInAgain(ben, 'sl-ben');
await expectLater(
remote.setFirstKeys(
session,
person: ben,
attempt: attempt,
setup: device.setup(),
network: '203.0.113.0/24',
),
refused(ProfileRefusal.handleInvalid),
);
await expectLater(
remote.setFirstKeys(
session,
person: ben,
attempt: const Uuid().v7obj(),
setup: device.setup(),
network: '203.0.113.0/24',
handle: 'ben',
),
refused(OidcRefusal.unknownAttempt),
);
await remote.setFirstKeys(
session,
person: ben,
attempt: attempt,
setup: device.setup(),
network: '203.0.113.0/24',
handle: 'ben',
);
expect((await remote.signInWaysOf(session, ben)).keys, isTrue);
expect((await remote.profileOf(session, ben))!.handle, 'ben');
await expectLater(
remote.setFirstKeys(
session,
person: ben,
attempt: await signedInAgain(ben, 'sl-ben'),
setup: device.setup(),
network: '203.0.113.0/24',
),
refused(PasswordRefusal.passwordExists),
);
},
);
test("changes a member's account's keys, each change with the "
"password's proof (the phrase's for a lost one) and a sign-in, "
'and deletes it alike', () async {
final device = await KeysDevice.make();
final next = await KeysDevice.make(seed: 7);
final start = await remote.beginSimpleLogin(
session,
marker: 'todo-desktop',
network: '203.0.113.0/24',
);
await remote.completeSimpleLogin(
session,
attempt: start.attempt,
code: simpleLogin.authorize(
start.authorizationUrl,
subject: 'sl-carla',
),
);
final carla = await remote.signUpWithSimpleLogin(
session,
attempt: start.attempt,
handle: 'carla',
setup: device.setup(),
network: '203.0.113.0/24',
);
final keepSession = const Uuid().v7obj();
await remote.changeKeysPassword(
session,
person: carla,
proof: await device.ownerProof(
await signedInAgain(carla, 'sl-carla'),
challenge: await remote.beginPasswordKeyProof(session, carla),
purpose: KeyProofPurpose.newPassword,
digest: newKeysPasswordDigest(next.password()),
),
password: next.password(),
keepSession: keepSession,
);
// Every member is told to end the account's other sessions.
await sessionNoticesDelivered();
expect(members.sessionsTold.single.$2, carla);
expect(members.sessionsTold.single.$4, keepSession);
await remote.unlockKeys(
session,
carla,
await next.prove(
await remote.beginPasswordKeyProof(session, carla),
),
);
await remote.resetKeysPassword(
session,
person: carla,
proof: await device.ownerProof(
await signedInAgain(carla, 'sl-carla'),
phrase: await remote.beginPhraseKeyProof(session, carla),
purpose: KeyProofPurpose.passwordReset,
digest: newKeysPasswordDigest(device.password()),
),
password: device.password(),
);
await remote.setKeysRecovery(
session,
person: carla,
proof: await device.ownerProof(
await signedInAgain(carla, 'sl-carla'),
challenge: await remote.beginPasswordKeyProof(session, carla),
purpose: KeyProofPurpose.newPhrase,
digest: newPhraseDigest(
next.recoveryKeys,
ByteData.sublistView(next.recoveryProofKey),
),
),
recoveryKeys: next.recoveryKeys,
recoveryProofKey: ByteData.sublistView(next.recoveryProofKey),
);
expect(
(await remote.beginPhraseKeyProof(
session,
carla,
)).recoveryKeys.toJson(),
next.recoveryKeys.toJson(),
);
final deletion = await signedInAgain(carla, 'sl-carla');
await expectLater(
remote.deleteAccount(session, carla),
refused(PasswordRefusal.wrongCredentials),
);
await remote.deleteAccount(
session,
carla,
keysProof: await device.ownerProof(
deletion,
challenge: await remote.beginPasswordKeyProof(session, carla),
purpose: KeyProofPurpose.deleteAccount,
),
);
expect(await AuthUser.db.findById(session, carla), isNull);
});
test(
"mends a member's account's broken link with its handle and "
'recovery phrase, a decoy and its refusal crossing whole',
() async {
usePasswordKeys(pod, testPasswordKeys);
final device = await KeysDevice.make();
final first = await remote.beginSimpleLogin(
session,
marker: 'todo-desktop',
network: '203.0.113.0/24',
);
await remote.completeSimpleLogin(
session,
attempt: first.attempt,
code: simpleLogin.authorize(
first.authorizationUrl,
subject: 'sl-dora-old',
),
);
final dora = await remote.signUpWithSimpleLogin(
session,
attempt: first.attempt,
handle: 'Dora',
setup: device.setup(),
network: '203.0.113.0/24',
);
final start = await remote.beginSimpleLogin(
session,
marker: 'todo-desktop',
network: '203.0.113.0/24',
);
await remote.completeSimpleLogin(
session,
attempt: start.attempt,
code: simpleLogin.authorize(
start.authorizationUrl,
subject: 'sl-dora-new',
),
);
final decoy = await remote.beginSimpleLoginMend(
session,
attempt: start.attempt,
handle: 'nobody',
network: '203.0.113.0/24',
);
await expectLater(
remote.mendSimpleLogin(
session,
attempt: start.attempt,
proof: await device.provePhrase(
decoy,
digest: mendLinkDigest(start.attempt),
),
),
refused(PasswordRefusal.wrongCredentials),
);
final mended = await remote.mendSimpleLogin(
session,
attempt: start.attempt,
proof: await device.provePhrase(
await remote.beginSimpleLoginMend(
session,
attempt: start.attempt,
handle: 'dora',
network: '203.0.113.0/24',
),
digest: mendLinkDigest(start.attempt),
),
);
expect(mended.outcome, SimpleLoginOutcome.signedIn);
expect(mended.account, dora);
expect(mended.endedBefore, isNotNull);
expect(
(await OidcIdentity.db.findFirstRow(
session,
where: (t) => t.authUserId.equals(dora),
))!.subject,
'sl-dora-new',
);
// Every member is told to end the account's sessions.
await sessionNoticesDelivered();
expect(members.sessionsTold.single.$2, dora);
},
);
test('refuses where the register has no SimpleLogin app', () async {
useSimpleLogin(sessionBuilder, null);
await expectLater(
remote.beginSimpleLogin(
session,
marker: 'todo-desktop',
network: '203.0.113.0/24',
),
refused(OidcRefusal.unknownProvider),
);
});
});
group('passwords (ADR 0017)', () {
late DateTime now;
Future<T> atNow<T>(Future<T> Function() body) =>
withClock(Clock(() => now), body);
setUp(() {
usePasswordKeys(pod, testPasswordKeys);
now = DateTime.utc(2026, 10, 20, 12);
});
test('are offered as the register offers them', () async {
expect(await remote.passwordSignInOffered(session), isTrue);
usePasswordKeys(pod, null);
expect(await remote.passwordSignInOffered(session), isFalse);
await expectLater(
remote.beginPasswordSignIn(
session,
address: 'ben@example.org',
network: '203.0.113.7',
),
refused(PasswordRefusal.unavailable),
);
});
test("cross for a member's device, every operation, with what the "
'register refuses', () async {
final device = await PasswordDevice.make();
await atNow(() async {
final annaOnTodo = const Uuid().v7obj();
final request = await remote.sendSignInCode(
session,
address: 'anna@example.org',
language: 'en',
network: '203.0.113.7',
purpose: EmailCodePurpose.signUp,
);
await remote.setPassword(
session,
person: annaOnTodo,
anonymousHere: true,
setup: device.setup(),
request: request,
code: mailbox.codeFor('anna@example.org'),
);
expect(
(await AuthUser.db.findById(session, annaOnTodo))!.scopeNames,
contains(identified.name),
);
await expectLater(
remote.setPassword(
session,
person: ben,
setup: device.setup().copyWith(salt: ByteData(3)),
),
refused(PasswordRefusal.invalidSetup),
);
final enrolment = await remote.enrolAuthenticator(
session,
person: annaOnTodo,
name: 'Phone',
label: 'anna@example.org',
);
final codes = await remote.confirmAuthenticator(
session,
person: annaOnTodo,
authenticator: enrolment.authenticator,
code: codeOf(enrolment),
proof: device.ownerProof(
await remote.beginPasswordChange(session, person: annaOnTodo),
purpose: PasswordProofPurpose.firstAuthenticator,
digest: authenticatorDigest(enrolment.authenticator),
),
);
expect(codes, hasLength(recoveryCodeCount));
expect(
(await remote.authenticatorsOf(session, annaOnTodo)).single.id,
enrolment.authenticator,
);
final spare = await remote.enrolAuthenticator(
session,
person: annaOnTodo,
name: 'Spare',
label: '',
);
await expectLater(
remote.confirmAuthenticator(
session,
person: annaOnTodo,
authenticator: spare.authenticator,
code: codeOf(spare),
),
refused(PasswordRefusal.wrongCredentials),
);
now = now.add(totpPeriod);
Future<OwnerProof> ownerProof(
PasswordProofPurpose purpose, {
List<int> digest = const [],
}) async => device.ownerProof(
await remote.beginPasswordChange(session, person: annaOnTodo),
purpose: purpose,
digest: digest,
totpCode: codeOf(enrolment),
);
expect(
await remote.confirmAuthenticator(
session,
person: annaOnTodo,
authenticator: spare.authenticator,
code: codeOf(spare),
proof: await ownerProof(
PasswordProofPurpose.addAuthenticator,
digest: authenticatorDigest(spare.authenticator),
),
),
isEmpty,
);
now = now.add(totpPeriod);
await remote.removeAuthenticator(
session,
person: annaOnTodo,
authenticator: spare.authenticator,
proof: await ownerProof(
PasswordProofPurpose.removeAuthenticator,
digest: authenticatorDigest(spare.authenticator),
),
);
final ways = await remote.signInWaysOf(session, annaOnTodo);
expect(ways.password && ways.authenticator, isTrue);
expect(ways.sessionEndedForProofsAt, isNull);
// A member's session ended for its wrong proofs: the owner's
// warning, kept here, read and dismissed through the member.
await remote.noteSessionEndedForProofs(session, annaOnTodo);
final warned = now;
expect(
(await remote.signInWaysOf(
session,
annaOnTodo,
)).sessionEndedForProofsAt,
warned,
);
now = now.add(totpPeriod);
final challenge = await remote.beginPasswordSignIn(
session,
address: 'anna@example.org',
network: '203.0.113.7',
);
final answer = device.answer(challenge);
final proof = await remote.completePasswordSignIn(
session,
challenge: challenge.challenge,
clientValue: answer.clientValue,
clientProof: answer.clientProof,
totpCode: codeOf(enrolment, at: now),
instanceUrl: testInstanceUrl,
address: 'anna@example.org',
language: 'en',
);
expect(proof.account, annaOnTodo);
expect(answer.verifiesServer(proof.serverProof), isTrue);
expect(proof.passwordKeys.toJson(), device.passwordKeys.toJson());
expect(proof.sessionEndedForProofsAt, warned);
// An older session of the member's dismisses nothing (the full
// reading's L1); one made since does.
await expectLater(
remote.dismissSessionEndedWarning(
session,
annaOnTodo,
sessionMadeAt: warned,
),
refused(PasswordRefusal.signedInBefore),
);
await remote.dismissSessionEndedWarning(
session,
annaOnTodo,
sessionMadeAt: warned.add(const Duration(seconds: 1)),
);
expect(
(await remote.signInWaysOf(
session,
annaOnTodo,
)).sessionEndedForProofsAt,
isNull,
);
final again = await remote.beginPasswordSignIn(
session,
address: 'anna@example.org',
network: '203.0.113.7',
);
final againAnswer = device.answer(again);
await expectLater(
remote.completePasswordSignIn(
session,
challenge: again.challenge,
clientValue: againAnswer.clientValue,
clientProof: againAnswer.clientProof,
totpCode: codeOf(enrolment, at: now),
instanceUrl: testInstanceUrl,
address: 'anna@example.org',
language: 'en',
),
refused(PasswordRefusal.wrongCredentials),
);
now = now.add(totpPeriod);
expect(
await remote.newRecoveryCodes(
session,
person: annaOnTodo,
proof: await ownerProof(PasswordProofPurpose.newRecoveryCodes),
),
hasLength(recoveryCodeCount),
);
final keyChallenge = await remote.beginKeyProof(
session,
address: 'anna@example.org',
network: '203.0.113.7',
);
expect(
keyChallenge.recoveryKeys.toJson(),
device.recoveryKeys.toJson(),
);
now = now.add(totpPeriod);
final newPassword = device.newPassword();
expect(
await remote.resetPasswordWithPhrase(
session,
challenge: keyChallenge.challenge,
signature: await device.sign(
keyChallenge,
password: newPassword,
),
password: newPassword,
totpCode: codeOf(enrolment, at: now),
instanceUrl: testInstanceUrl,
address: 'anna@example.org',
language: 'en',
),
(account: annaOnTodo, endedBefore: now),
);
// Every member is told to end the account's sessions too.
await sessionNoticesDelivered();
expect(members.sessionsTold.single, (_todo, annaOnTodo, now, null));
final forReset = await remote.beginPasswordSignIn(
session,
address: 'anna@example.org',
network: '203.0.113.7',
);
final resetAnswer = device.answer(
forReset,
purpose: PasswordProofPurpose.authenticatorReset,
);
final resetRequest = await remote.sendSignInCode(
session,
address: 'anna@example.org',
language: 'en',
network: '203.0.113.7',
purpose: EmailCodePurpose.authenticatorReset,
);
final pending = await remote.resetAuthenticator(
session,
challenge: forReset.challenge,
clientValue: resetAnswer.clientValue,
clientProof: resetAnswer.clientProof,
address: 'anna@example.org',
language: 'en',
request: resetRequest,
code: mailbox.codeFor('anna@example.org'),
instanceUrl: testInstanceUrl,
);
expect(pending.takesEffectAt, now.add(resetDelay));
final startRequest = await remote.sendSignInCode(
session,
address: 'anna@example.org',
language: 'en',
network: '203.0.113.7',
purpose: EmailCodePurpose.accountReset,
);
await remote.startAccountReset(
session,
address: 'anna@example.org',
language: 'en',
request: startRequest,
code: mailbox.codeFor('anna@example.org'),
);
now = now.add(resetDelay);
final fresh = await PasswordDevice.make(seed: 99);
final completeRequest = await remote.sendSignInCode(
session,
address: 'anna@example.org',
language: 'en',
network: '203.0.113.7',
purpose: EmailCodePurpose.accountReset,
);
expect(
await remote.completeAccountReset(
session,
address: 'anna@example.org',
request: completeRequest,
code: mailbox.codeFor('anna@example.org'),
setup: fresh.setup(),
),
(account: annaOnTodo, endedBefore: now),
);
});
});
});
group('changes while signed in (ADR 0017)', () {
late DateTime now;
Future<T> atNow<T>(Future<T> Function() body) =>
withClock(Clock(() => now), body);
setUp(() {
usePasswordKeys(pod, testPasswordKeys);
now = DateTime.utc(2026, 10, 20, 12);
});
test(
"cross for a member's device: the password, keeping the "
"member's session, the phrase's wrapping, and a reset's date",
() async {
final device = await PasswordDevice.make();
await atNow(() async {
final annaOnTodo = const Uuid().v7obj();
final annasPhone = const Uuid().v7obj();
final request = await remote.sendSignInCode(
session,
address: 'anna@example.org',
language: 'en',
network: '203.0.113.7',
purpose: EmailCodePurpose.signUp,
);
await remote.setPassword(
session,
person: annaOnTodo,
anonymousHere: true,
setup: device.setup(),
request: request,
code: mailbox.codeFor('anna@example.org'),
);
final enrolment = await remote.enrolAuthenticator(
session,
person: annaOnTodo,
name: 'Phone',
label: 'anna@example.org',
);
await remote.confirmAuthenticator(
session,
person: annaOnTodo,
authenticator: enrolment.authenticator,
code: codeOf(enrolment),
proof: device.ownerProof(
await remote.beginPasswordChange(
session,
person: annaOnTodo,
),
purpose: PasswordProofPurpose.firstAuthenticator,
digest: authenticatorDigest(enrolment.authenticator),
),
);
now = now.add(totpPeriod);
final challenge = await remote.beginPasswordChange(
session,
person: annaOnTodo,
);
// The old password proves it: the device takes the new one's
// x as it makes it.
final old = device.privateValue;
final newPassword = device.newPassword();
await remote.changePassword(
session,
person: annaOnTodo,
proof: device.ownerProof(
challenge,
purpose: PasswordProofPurpose.newPassword,
digest: newPasswordDigest(newPassword),
privateValue: old,
totpCode: codeOf(enrolment),
),
password: newPassword,
keepSession: annasPhone,
);
await sessionNoticesDelivered();
expect(members.sessionsTold.single, (
_todo,
annaOnTodo,
now,
annasPhone,
));
now = now.add(totpPeriod);
final fresh = WrappedKeys(
nonce: ByteData(24),
cipherText: ByteData(wrappedKeysLength),
);
Future<void> newPhrase() async {
final challenge = await remote.beginPasswordChange(
session,
person: annaOnTodo,
);
final proofKey = ByteData.sublistView(device.recoveryProofKey);
await remote.setRecoveryKeys(
session,
person: annaOnTodo,
proof: device.ownerProof(
challenge,
purpose: PasswordProofPurpose.newPhrase,
digest: newPhraseDigest(fresh, proofKey),
totpCode: codeOf(enrolment),
),
recoveryKeys: fresh,
recoveryProofKey: proofKey,
);
}
await newPhrase();
// The same step's code again.
await expectLater(
newPhrase(),
refused(PasswordRefusal.wrongCredentials),
);
final start = await remote.sendSignInCode(
session,
address: 'anna@example.org',
language: 'en',
network: '203.0.113.7',
purpose: EmailCodePurpose.accountReset,
);
final pending = await remote.startAccountReset(
session,
address: 'anna@example.org',
language: 'en',
request: start,
code: mailbox.codeFor('anna@example.org'),
);
final early = await remote.sendSignInCode(
session,
address: 'anna@example.org',
language: 'en',
network: '203.0.113.7',
purpose: EmailCodePurpose.accountReset,
);
await expectLater(
remote.completeAccountReset(
session,
address: 'anna@example.org',
request: early,
code: mailbox.codeFor('anna@example.org'),
setup: device.setup(),
),
throwsA(
isA<PasswordRefusedException>().having(
(error) => error.takesEffectAt,
'takesEffectAt',
pending.takesEffectAt,
),
),
);
// The member's device is shown the reset, and its owner calls
// it off with their proof (the full reading's H1).
expect(
(await remote.signInWaysOf(session, annaOnTodo)).accountResetAt,
pending.takesEffectAt,
);
now = now.add(totpPeriod);
await remote.callOffResets(
session,
person: annaOnTodo,
proof: device.ownerProof(
await remote.beginPasswordChange(
session,
person: annaOnTodo,
),
purpose: PasswordProofPurpose.callOffResets,
totpCode: codeOf(enrolment),
),
);
expect(
(await remote.signInWaysOf(session, annaOnTodo)).accountResetAt,
isNull,
);
});
},
);
});
group('ways in without a working password (ADR 0017)', () {
late DateTime now;
Future<T> atNow<T>(Future<T> Function() body) =>
withClock(Clock(() => now), body);
setUp(() {
usePasswordKeys(pod, testPasswordKeys);
now = DateTime.utc(2026, 10, 20, 12);
});
test("cross for a member's device: signing up with a code, and a "
'setup never finished signing in with the password and a code, '
'with what the register refuses', () async {
final device = await PasswordDevice.make();
await atNow(() async {
Future<UuidValue> code([
EmailCodePurpose purpose = EmailCodePurpose.signUp,
]) => remote.sendSignInCode(
session,
address: 'carla@example.org',
language: 'en',
network: '203.0.113.7',
purpose: purpose,
);
final carla = await remote.signUpWithCode(
session,
request: await code(),
code: mailbox.codeFor('carla@example.org'),
setup: device.setup(),
);
expect(
(await AuthUser.db.findById(session, carla))!.scopeNames,
contains(identified.name),
);
await expectLater(
remote.signUpWithCode(
session,
request: await code(),
code: mailbox.codeFor('carla@example.org'),
setup: device.setup(),
),
refused(PasswordRefusal.passwordExists),
);
final challenge = await remote.beginPasswordSignIn(
session,
address: 'carla@example.org',
network: '203.0.113.7',
);
final answer = device.answer(challenge);
final proof = await remote.completePasswordSignIn(
session,
challenge: challenge.challenge,
clientValue: answer.clientValue,
clientProof: answer.clientProof,
request: await code(EmailCodePurpose.finishSetup),
code: mailbox.codeFor('carla@example.org'),
instanceUrl: testInstanceUrl,
address: 'carla@example.org',
language: 'en',
);
expect(proof.account, carla);
expect(proof.needsAuthenticator, isTrue);
});
});
});
group('deleting an account', () {
test('deletes it here and tells every member, keeping no notice '
'once they confirmed', () async {
await remote.deleteAccount(session, ben);
expect(await AuthUser.db.findById(session, ben), isNull);
expect(await EmailIdentity.db.count(session), 0);
expect(await GitHubAccount.db.count(session), 0);
expect(members.told, [(_todo, ben)]);
expect(await AccountDeletionNotice.db.count(session), 0);
expect(await remote.knownAccounts(session, {ben}), isEmpty);
});
test(
'tells a member that does not answer again until it does',
() async {
members.answers = (member) => member != _todo;
await remote.deleteAccount(session, ben);
expect(await AuthUser.db.findById(session, ben), isNull);
final kept = await AccountDeletionNotice.db.find(session);
expect(kept.single.account, ben);
expect(kept.single.member, '$_todo');
expect(kept.single.attempts, 1);
expect(kept.single.lastAttemptAt, isNotNull);
expect(await deliverDeletionNotices(session), 0);
expect(
(await AccountDeletionNotice.db.find(session)).single.attempts,
2,
);
members.answers = (_) => true;
expect(await deliverDeletionNotices(session), 1);
expect(await AccountDeletionNotice.db.count(session), 0);
expect(members.told, [(_todo, ben), (_todo, ben), (_todo, ben)]);
},
);
test('tells the members of an account this server no longer has, '
'as one may still hold its data', () async {
final gone = const Uuid().v7obj();
await remote.deleteAccount(session, gone);
expect(members.told, [(_todo, gone)]);
});
test('tells nobody of an anonymous person, a member never having '
'had them', () async {
final anna = await newAnonymousPerson(session);
await remote.deleteAccount(session, anna);
expect(await AuthUser.db.findById(session, anna), isNull);
expect(members.told, isEmpty);
expect(await AccountDeletionNotice.db.count(session), 0);
});
test(
'drops the notices of a member the operator no longer names',
() async {
members.answers = (_) => false;
useBase(
sessionBuilder,
accounts: HoldsRegister(members: [_todo, _train]),
);
await remote.deleteAccount(session, ben);
expect(await AccountDeletionNotice.db.count(session), 2);
useBase(sessionBuilder, accounts: HoldsRegister(members: [_train]));
await deliverDeletionNotices(session);
expect(
(await AccountDeletionNotice.db.find(session)).single.member,
'$_train',
);
useBase(sessionBuilder);
await deliverDeletionNotices(session);
expect(await AccountDeletionNotice.db.count(session), 0);
},
);
});
},
);
group('A member asking the register', () {
late List<http.Request> requests;
/// A register answering every request with [status] and [body].
RemoteRegister answering(int status, String body) {
requests = [];
return RemoteRegister(
Uri.parse('http://learn.test:8082'),
secret: _secret,
client: () => MockClient((request) async {
requests.add(request);
return http.Response(body, status);
}),
);
}
test(
'posts JSON to the operation with the secret as its bearer token',
() async {
final account = const Uuid().v7obj();
final register = answering(200, '{"account": "${account.uuid}"}');
expect(await register.accountWithGitHub(_noSession, '1001'), account);
final request = requests.single;
expect(request.method, 'POST');
expect(
'${request.url}',
'http://learn.test:8082/halkyon-base/register/account-with-github',
);
expect(request.headers['authorization'], 'Bearer $_secret');
expect(jsonDecode(request.body), {'gitHubUserId': '1001'});
},
);
test(
'fails as unavailable for an answer it cannot take, never guessing',
() async {
for (final (status, body) in [
(500, '{}'),
(401, '{"error": "secret"}'),
(200, 'not JSON'),
(200, '[]'),
(409, '{"refusal": {"kind": "email", "reason": "unheardOf"}}'),
(409, '{"refusal": "no"}'),
(404, '{"error": "not here"}'),
]) {
await expectLater(
answering(status, body).accountWithGitHub(_noSession, '1001'),
throwsA(isA<RegisterUnavailableException>()),
reason: '$status $body',
);
}
await expectLater(
RemoteRegister(
Uri.parse('http://learn.test:8082'),
secret: _secret,
client: () => MockClient(
(_) async => throw http.ClientException('Connection refused'),
),
).accountWithGitHub(_noSession, '1001'),
throwsA(isA<RegisterUnavailableException>()),
);
},
);
});
group('The register telling a member', () {
test("posts the account to the member's path with the secret, and "
'counts only a 200 as confirmed', () async {
final account = const Uuid().v7obj();
final requests = <http.Request>[];
var status = 200;
final notifier = HttpMemberNotifier(
secret: _secret,
client: MockClient((request) async {
requests.add(request);
return http.Response('{}', status);
}),
);
expect(await notifier.accountGone(_todo, account), isTrue);
final request = requests.single;
expect(request.method, 'POST');
expect('${request.url}', 'http://todo.test:8082$accountGonePath');
expect(request.headers['authorization'], 'Bearer $_secret');
expect(jsonDecode(request.body), {'account': account.uuid});
status = 401;
expect(await notifier.accountGone(_todo, account), isFalse);
});
test('counts a failed request as not confirmed, and sends nothing '
'without a secret', () async {
final account = const Uuid().v7obj();
var sent = 0;
expect(
await HttpMemberNotifier(
secret: _secret,
client: MockClient((_) {
sent++;
return Future.error(http.ClientException('Connection refused'));
}),
).accountGone(_todo, account),
isFalse,
);
expect(
await HttpMemberNotifier(
secret: null,
client: MockClient((_) async {
sent++;
return http.Response('{}', 200);
}),
).accountGone(_todo, account),
isFalse,
);
expect(sent, 1);
});
});
}
/// No session: [RemoteRegister] only passes it on.
final Session _noSession = _NoSession();
final class _NoSession implements Session {
@override
Object? noSuchMethod(Invocation invocation) =>
throw UnsupportedError('no session in this test');
}
Future<void> _deleteAll(Session session) async {
await OidcAttempt.db.deleteWhere(
session,
where: (t) => t.id.notEquals(null),
);
await SignInChallenge.db.deleteWhere(
session,
where: (t) => t.id.notEquals(null),
);
await AccountReset.db.deleteWhere(session, where: (t) => t.id > 0);
await RecoveryCode.db.deleteWhere(session, where: (t) => t.id > 0);
await TotpAuthenticator.db.deleteWhere(
session,
where: (t) => t.id.notEquals(null),
);
await AccountPassword.db.deleteWhere(session, where: (t) => t.id > 0);
await AccountDeletionNotice.db.deleteWhere(session, where: (t) => t.id > 0);
await PublicProfile.db.deleteWhere(session, where: (t) => t.id > 0);
await RateLimitedRequestAttempt.db.deleteWhere(
session,
where: (t) => t.id.notEquals(null),
);
await EmailCodeRequest.db.deleteWhere(
session,
where: (t) => t.id.notEquals(null),
);
await SecretChallenge.db.deleteWhere(
session,
where: (t) => t.id.notEquals(null),
);
await EmailIdentity.db.deleteWhere(
session,
where: (t) => t.id.notEquals(null),
);
await AuthUser.db.deleteWhere(session, where: (t) => t.id.notEquals(null));
}