An account that signs in with SimpleLogin keeps its keys under a password that signs nobody in (D19): `account_password` keeps the password's own proof key (Ed25519, `passwordProofKey`) in place of SRP's verifier, which becomes nullable. The migration adds the column and drops the NOT NULL, nothing else. - `simpleLogin.signUp(attempt, handle, setup)`: someone new whose identity waits under the attempt gets, in one transaction, an account (identified), the link, a profile with the handle and the keys; then a session (D18). Refused, the identity still waiting, for a setup out of range and a handle refused as saving a profile refuses it. - `simpleLogin.checkHandle(attempt, handle)`: whether someone new could take a handle, at most 30 an hour per attempt and 60 per network. - `simpleLogin.complete` says, for an account, whether it has its keys and its handle, and keeps the sign-in ten minutes under its attempt, the account as its caller: D20's confirmation for the changes to come. - `keys.begin`, `keys.unlock`: the keys for a signed-in device once the password is proven with its proof key, for a nonce, bound to the base URL the device addressed; wrong proofs counted per session as ADR 0017's. - An authenticator is enrolled only for a password that signs in. - Members ask the register for all of it; `RemoteRegister` and the instance API carry the four new operations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
1563 lines
53 KiB
Dart
1563 lines
53 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'), isNull);
|
|
expect(
|
|
await remote.checkHandle(session, ben, 'anna'),
|
|
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('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(),
|
|
);
|
|
|
|
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(),
|
|
),
|
|
refused(ProfileRefusal.handleReserved),
|
|
);
|
|
await expectLater(
|
|
remote.signUpWithSimpleLogin(
|
|
session,
|
|
attempt: const Uuid().v7obj(),
|
|
handle: 'carla',
|
|
setup: device.setup(),
|
|
),
|
|
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(),
|
|
);
|
|
await expectLater(
|
|
remote.unlockKeys(
|
|
session,
|
|
carla,
|
|
await (await KeysDevice.make(seed: 5)).prove(
|
|
await remote.beginPasswordKeyProof(session, carla),
|
|
),
|
|
),
|
|
refused(PasswordRefusal.wrongCredentials),
|
|
);
|
|
});
|
|
|
|
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));
|
|
}
|