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