feat(base)!: sign-ups as the operator says, waiting accounts, operators (ADR 0021, step 1)
SIGN_UP takes open (unset), approval or closed; anything else stops the server as it starts. Closed refuses a SimpleLogin sign-up before its handle is counted, an address sign-up only once the code has proved the address, so nothing tells whether an address has an account. Approval lets a new account wait, at most ten at once: no session is issued for a waiting or suspended account, only a pass (32 random bytes, kept as its SHA-256, 30 days, five per account) and four BIP-39 words the operator reads out to match it. The pass looks, forgets, and deletes an account that was never approved; nothing else. Operators hold the scope operator, given first by SQL (D32), then named in the app: InstanceOperatorEndpoint lists, approves and refuses waiting accounts, finds accounts, suspends them (ending their sessions), and names and takes back operators, never the last. account.operating() counts the waiting for the badge. Members ask the register (five new operations, a refusal kind signUp), and say closed while it is out of reach. Never-approved accounts go after 14 days in the daily pruning. The migration only adds the tables waiting_account and waiting_pass, so the server before it keeps working on the new schema. BREAKING CHANGE: a register and its members must run this version together: a member asks the register operations an older one doesn't know. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
e2b3344e99
commit
a060fbc21d
5 changed files with 50068 additions and 37 deletions
|
|
@ -0,0 +1,997 @@
|
|||
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);
|
||||
}
|
||||
|
|
@ -787,6 +787,55 @@ final class _Register implements AccountRegister {
|
|||
askedAbout.add(people);
|
||||
return people.intersection(accounts);
|
||||
});
|
||||
|
||||
/// Who may sign up (Halkyon Learn's ADR 0021, D19).
|
||||
SignUpMode signUps = SignUpMode.open;
|
||||
|
||||
/// The accounts that wait, as the register hands each its wait, and the
|
||||
/// accounts asked about before a session, apart from [asked], as every
|
||||
/// sign-in asks.
|
||||
final waiting = <UuidValue, AccountWaitingException>{};
|
||||
final waitsAsked = <UuidValue>[];
|
||||
|
||||
/// Thrown by [waitOf] alone while set (as [failing] throws from
|
||||
/// everything): the register gone out of reach after the sign-in.
|
||||
Exception? waitFailing;
|
||||
|
||||
/// What each pass says, and the passes deleted with and forgotten.
|
||||
final passes = <String, WaitingState>{};
|
||||
final passesDeleted = <String>[];
|
||||
final passesForgotten = <String>[];
|
||||
|
||||
@override
|
||||
Future<SignUpMode> signUpMode(Session session) =>
|
||||
_answer('signUpMode', () => signUps);
|
||||
|
||||
@override
|
||||
Future<AccountWaitingException?> waitOf(
|
||||
Session session,
|
||||
UuidValue account,
|
||||
) async {
|
||||
waitsAsked.add(account);
|
||||
if (waitFailing ?? failing case final error?) {
|
||||
throw error;
|
||||
}
|
||||
return waiting[account];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<WaitingState> lookWhileWaiting(Session session, String pass) =>
|
||||
_answer('lookWhileWaiting', () => passes[pass] ?? WaitingState.unknown);
|
||||
|
||||
@override
|
||||
Future<bool> deleteWaitingAccount(Session session, String pass) =>
|
||||
_answer('deleteWaitingAccount', () {
|
||||
passesDeleted.add(pass);
|
||||
return passes.remove(pass) != null;
|
||||
});
|
||||
|
||||
@override
|
||||
Future<void> forgetWaitingPass(Session session, String pass) =>
|
||||
_answer('forgetWaitingPass', () => passesForgotten.add(pass));
|
||||
}
|
||||
|
||||
void main() {
|
||||
|
|
@ -1193,6 +1242,144 @@ void main() {
|
|||
});
|
||||
});
|
||||
|
||||
group("accounts waiting for the register's operator (Halkyon Learn's "
|
||||
'ADR 0021)', () {
|
||||
test('get no session here, nor become people here: the wait the '
|
||||
'register hands out is thrown in its place', () async {
|
||||
final carla = const Uuid().v7obj();
|
||||
register.simpleLoginProof = SimpleLoginProof(
|
||||
outcome: SimpleLoginOutcome.signedIn,
|
||||
account: carla,
|
||||
hasKeys: true,
|
||||
handle: 'Carla',
|
||||
);
|
||||
register.waiting[carla] = AccountWaitingException(
|
||||
pass: 'a pass',
|
||||
words: const ['abandon', 'ability', 'able', 'about'],
|
||||
handle: 'Carla',
|
||||
suspended: false,
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
endpoints.simpleLogin.complete(
|
||||
notSignedIn(),
|
||||
attempt: const Uuid().v7obj(),
|
||||
code: 'code',
|
||||
),
|
||||
throwsA(
|
||||
isA<AccountWaitingException>()
|
||||
.having((e) => e.pass, 'pass', 'a pass')
|
||||
.having((e) => e.words, 'words', hasLength(4)),
|
||||
),
|
||||
);
|
||||
|
||||
expect(register.waitsAsked, [carla]);
|
||||
expect(await AuthUser.db.findById(session, carla), isNull);
|
||||
expect(
|
||||
await ServerSideSession.db.count(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(carla),
|
||||
),
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('sign in here once the register admits them', () async {
|
||||
final carla = const Uuid().v7obj();
|
||||
register.simpleLoginProof = SimpleLoginProof(
|
||||
outcome: SimpleLoginOutcome.signedIn,
|
||||
account: carla,
|
||||
hasKeys: true,
|
||||
handle: 'Carla',
|
||||
);
|
||||
|
||||
final completed = await endpoints.simpleLogin.complete(
|
||||
notSignedIn(),
|
||||
attempt: const Uuid().v7obj(),
|
||||
code: 'code',
|
||||
);
|
||||
|
||||
expect(register.waitsAsked, [carla]);
|
||||
expect(completed.session!.authUserId, carla);
|
||||
});
|
||||
|
||||
test('get no session while the register is out of reach', () async {
|
||||
final carla = const Uuid().v7obj();
|
||||
register
|
||||
..simpleLoginProof = SimpleLoginProof(
|
||||
outcome: SimpleLoginOutcome.signedIn,
|
||||
account: carla,
|
||||
hasKeys: true,
|
||||
)
|
||||
..waitFailing = RegisterUnavailableException('down');
|
||||
|
||||
await expectLater(
|
||||
endpoints.simpleLogin.complete(
|
||||
notSignedIn(),
|
||||
attempt: const Uuid().v7obj(),
|
||||
code: 'code',
|
||||
),
|
||||
throwsA(isA<RegisterUnavailableException>()),
|
||||
);
|
||||
expect(await AuthUser.db.findById(session, carla), isNull);
|
||||
});
|
||||
|
||||
test("look, delete and forget with the register's passes", () async {
|
||||
register.passes['a pass'] = WaitingState.suspended;
|
||||
|
||||
expect(
|
||||
await endpoints.waiting.look(notSignedIn(), pass: 'a pass'),
|
||||
WaitingState.suspended,
|
||||
);
|
||||
expect(
|
||||
await endpoints.waiting.look(notSignedIn(), pass: 'another'),
|
||||
WaitingState.unknown,
|
||||
);
|
||||
expect(
|
||||
await endpoints.waiting.deleteAccount(
|
||||
notSignedIn(),
|
||||
pass: 'a pass',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
await endpoints.waiting.forget(notSignedIn(), pass: 'another');
|
||||
|
||||
expect(register.asked, [
|
||||
'lookWhileWaiting',
|
||||
'lookWhileWaiting',
|
||||
'deleteWaitingAccount',
|
||||
'forgetWaitingPass',
|
||||
]);
|
||||
expect(register.passesDeleted, ['a pass']);
|
||||
expect(register.passesForgotten, ['another']);
|
||||
});
|
||||
|
||||
test("tell the register's sign-ups in the basics, closed while it is "
|
||||
'out of reach', () async {
|
||||
register.signUps = SignUpMode.approval;
|
||||
expect(
|
||||
(await endpoints.instance.basics(notSignedIn())).signUp,
|
||||
SignUpMode.approval,
|
||||
);
|
||||
|
||||
register.failing = RegisterUnavailableException('down');
|
||||
expect(
|
||||
(await endpoints.instance.basics(notSignedIn())).signUp,
|
||||
SignUpMode.closed,
|
||||
);
|
||||
});
|
||||
|
||||
test("refuse every operator's call: no session here is an "
|
||||
"operator's", () async {
|
||||
await expectLater(
|
||||
endpoints.instanceOperator.waiting(asBen()),
|
||||
throwsA(isA<ServerpodInsufficientAccessException>()),
|
||||
);
|
||||
expect(await endpoints.account.operating(asBen()), isNull);
|
||||
expect(register.asked, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('SimpleLogin, by asking the register (ADR 0020)', () {
|
||||
test('begins for this app and way back, signed out, the device '
|
||||
'network passed on', () async {
|
||||
|
|
|
|||
|
|
@ -1654,6 +1654,59 @@ void main() {
|
|||
});
|
||||
});
|
||||
|
||||
group("accounts waiting for the operator (Halkyon Learn's ADR 0021)", () {
|
||||
tearDown(() => useSignUpMode(pod, null));
|
||||
|
||||
test("tells a member who may sign up, and an account's wait with a "
|
||||
'pass for its device', () async {
|
||||
useSignUpMode(pod, SignUpMode.approval);
|
||||
expect(await remote.signUpMode(session), SignUpMode.approval);
|
||||
expect(await remote.waitOf(session, ben), isNull);
|
||||
await WaitingAccount.db.insertRow(
|
||||
session,
|
||||
WaitingAccount(
|
||||
authUserId: ben,
|
||||
since: clock.now(),
|
||||
words: 'abandon ability able about',
|
||||
suspendedAt: clock.now(),
|
||||
),
|
||||
);
|
||||
|
||||
final waiting = (await remote.waitOf(session, ben))!;
|
||||
|
||||
expect(waiting.words, ['abandon', 'ability', 'able', 'about']);
|
||||
expect(waiting.suspended, isTrue);
|
||||
expect(
|
||||
await remote.lookWhileWaiting(session, waiting.pass),
|
||||
WaitingState.suspended,
|
||||
);
|
||||
expect(
|
||||
await remote.deleteWaitingAccount(session, waiting.pass),
|
||||
isFalse,
|
||||
);
|
||||
await remote.forgetWaitingPass(session, waiting.pass);
|
||||
expect(
|
||||
await remote.lookWhileWaiting(session, waiting.pass),
|
||||
WaitingState.unknown,
|
||||
);
|
||||
});
|
||||
|
||||
test('passes on a refused sign-up as itself', () {
|
||||
for (final reason in SignUpRefusal.values) {
|
||||
expect(
|
||||
refusalFrom(
|
||||
refusalJson(SignUpRefusedException(reason: reason)),
|
||||
),
|
||||
isA<SignUpRefusedException>().having(
|
||||
(e) => e.reason,
|
||||
'reason',
|
||||
reason,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('deleting an account', () {
|
||||
test('deletes it here and tells every member, keeping no notice '
|
||||
'once they confirmed', () async {
|
||||
|
|
@ -1888,6 +1941,8 @@ Future<void> _deleteAll(Session session) async {
|
|||
);
|
||||
await AccountPassword.db.deleteWhere(session, where: (t) => t.id > 0);
|
||||
await AccountDeletionNotice.db.deleteWhere(session, where: (t) => t.id > 0);
|
||||
await WaitingPass.db.deleteWhere(session, where: (t) => t.id > 0);
|
||||
await WaitingAccount.db.deleteWhere(session, where: (t) => t.id > 0);
|
||||
await PublicProfile.db.deleteWhere(session, where: (t) => t.id > 0);
|
||||
await RateLimitedRequestAttempt.db.deleteWhere(
|
||||
session,
|
||||
|
|
|
|||
|
|
@ -52,6 +52,9 @@ Future<void> deleteAll(Session session) async {
|
|||
session,
|
||||
where: (t) => t.id.notEquals(null),
|
||||
);
|
||||
// Accounts waiting for an operator (Halkyon Learn's ADR 0021, D31).
|
||||
await WaitingPass.db.deleteWhere(session, where: (t) => t.id > 0);
|
||||
await WaitingAccount.db.deleteWhere(session, where: (t) => t.id > 0);
|
||||
// Handles, which signing up and first passwords give (ADR 0020, D26, D28).
|
||||
await PublicProfile.db.deleteWhere(
|
||||
session,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@
|
|||
import 'dart:async' as _ida;
|
||||
import 'dart:io' as _idi;
|
||||
import 'dart:typed_data' as _idt;
|
||||
import 'package:halkyon_base_server/src/generated/admission/waiting_state.dart'
|
||||
as _iuvj70hb;
|
||||
import 'package:halkyon_base_server/src/generated/auth/email_code_purpose.dart'
|
||||
as _i5775c0l;
|
||||
import 'package:halkyon_base_server/src/generated/auth/sign_in_ways.dart'
|
||||
|
|
@ -32,6 +34,8 @@ import 'package:halkyon_base_server/src/generated/keys/keys_proof.dart'
|
|||
as _iy592cuk;
|
||||
import 'package:halkyon_base_server/src/generated/keys/keys_setup.dart'
|
||||
as _ii64ejy6;
|
||||
import 'package:halkyon_base_server/src/generated/operator/account_overview.dart'
|
||||
as _iiuvgbjo;
|
||||
import 'package:halkyon_base_server/src/generated/password/authenticator.dart'
|
||||
as _ip34xal6;
|
||||
import 'package:halkyon_base_server/src/generated/password/authenticator_enrolment.dart'
|
||||
|
|
@ -200,6 +204,8 @@ void withServerpod(
|
|||
}
|
||||
|
||||
class TestEndpoints {
|
||||
late final _WaitingEndpoint waiting;
|
||||
|
||||
late final _AccountEndpoint account;
|
||||
|
||||
late final _EmailCodeEndpoint emailCode;
|
||||
|
|
@ -208,6 +214,8 @@ class TestEndpoints {
|
|||
|
||||
late final _KeysEndpoint keys;
|
||||
|
||||
late final _InstanceOperatorEndpoint instanceOperator;
|
||||
|
||||
late final _AccountRecoveryEndpoint accountRecovery;
|
||||
|
||||
late final _AuthenticatorEndpoint authenticator;
|
||||
|
|
@ -230,6 +238,10 @@ class _InternalTestEndpoints extends TestEndpoints
|
|||
_is.SerializationManager serializationManager,
|
||||
_is.EndpointDispatch endpoints,
|
||||
) {
|
||||
waiting = _WaitingEndpoint(
|
||||
endpoints,
|
||||
serializationManager,
|
||||
);
|
||||
account = _AccountEndpoint(
|
||||
endpoints,
|
||||
serializationManager,
|
||||
|
|
@ -246,6 +258,10 @@ class _InternalTestEndpoints extends TestEndpoints
|
|||
endpoints,
|
||||
serializationManager,
|
||||
);
|
||||
instanceOperator = _InstanceOperatorEndpoint(
|
||||
endpoints,
|
||||
serializationManager,
|
||||
);
|
||||
accountRecovery = _AccountRecoveryEndpoint(
|
||||
endpoints,
|
||||
serializationManager,
|
||||
|
|
@ -277,6 +293,110 @@ class _InternalTestEndpoints extends TestEndpoints
|
|||
}
|
||||
}
|
||||
|
||||
class _WaitingEndpoint {
|
||||
_WaitingEndpoint(
|
||||
this._endpointDispatch,
|
||||
this._serializationManager,
|
||||
);
|
||||
|
||||
final _is.EndpointDispatch _endpointDispatch;
|
||||
|
||||
final _is.SerializationManager _serializationManager;
|
||||
|
||||
_ida.Future<_iuvj70hb.WaitingState> look(
|
||||
_ist.TestSessionBuilder sessionBuilder, {
|
||||
required String pass,
|
||||
}) async {
|
||||
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
|
||||
var _localUniqueSession =
|
||||
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
|
||||
endpoint: 'waiting',
|
||||
method: 'look',
|
||||
);
|
||||
try {
|
||||
var _localCallContext = await _endpointDispatch.getMethodCallContext(
|
||||
createSessionCallback: (_) => _localUniqueSession,
|
||||
endpointPath: 'waiting',
|
||||
methodName: 'look',
|
||||
parameters: _ist.testObjectToJson({'pass': pass}),
|
||||
serializationManager: _serializationManager,
|
||||
);
|
||||
var _localReturnValue =
|
||||
await (_localCallContext.method.call(
|
||||
_localUniqueSession,
|
||||
_localCallContext.arguments,
|
||||
)
|
||||
as _ida.Future<_iuvj70hb.WaitingState>);
|
||||
return _localReturnValue;
|
||||
} finally {
|
||||
await _localUniqueSession.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_ida.Future<bool> deleteAccount(
|
||||
_ist.TestSessionBuilder sessionBuilder, {
|
||||
required String pass,
|
||||
}) async {
|
||||
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
|
||||
var _localUniqueSession =
|
||||
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
|
||||
endpoint: 'waiting',
|
||||
method: 'deleteAccount',
|
||||
);
|
||||
try {
|
||||
var _localCallContext = await _endpointDispatch.getMethodCallContext(
|
||||
createSessionCallback: (_) => _localUniqueSession,
|
||||
endpointPath: 'waiting',
|
||||
methodName: 'deleteAccount',
|
||||
parameters: _ist.testObjectToJson({'pass': pass}),
|
||||
serializationManager: _serializationManager,
|
||||
);
|
||||
var _localReturnValue =
|
||||
await (_localCallContext.method.call(
|
||||
_localUniqueSession,
|
||||
_localCallContext.arguments,
|
||||
)
|
||||
as _ida.Future<bool>);
|
||||
return _localReturnValue;
|
||||
} finally {
|
||||
await _localUniqueSession.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_ida.Future<void> forget(
|
||||
_ist.TestSessionBuilder sessionBuilder, {
|
||||
required String pass,
|
||||
}) async {
|
||||
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
|
||||
var _localUniqueSession =
|
||||
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
|
||||
endpoint: 'waiting',
|
||||
method: 'forget',
|
||||
);
|
||||
try {
|
||||
var _localCallContext = await _endpointDispatch.getMethodCallContext(
|
||||
createSessionCallback: (_) => _localUniqueSession,
|
||||
endpointPath: 'waiting',
|
||||
methodName: 'forget',
|
||||
parameters: _ist.testObjectToJson({'pass': pass}),
|
||||
serializationManager: _serializationManager,
|
||||
);
|
||||
var _localReturnValue =
|
||||
await (_localCallContext.method.call(
|
||||
_localUniqueSession,
|
||||
_localCallContext.arguments,
|
||||
)
|
||||
as _ida.Future<void>);
|
||||
return _localReturnValue;
|
||||
} finally {
|
||||
await _localUniqueSession.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _AccountEndpoint {
|
||||
_AccountEndpoint(
|
||||
this._endpointDispatch,
|
||||
|
|
@ -351,6 +471,34 @@ class _AccountEndpoint {
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
_ida.Future<int?> operating(_ist.TestSessionBuilder sessionBuilder) async {
|
||||
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
|
||||
var _localUniqueSession =
|
||||
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
|
||||
endpoint: 'account',
|
||||
method: 'operating',
|
||||
);
|
||||
try {
|
||||
var _localCallContext = await _endpointDispatch.getMethodCallContext(
|
||||
createSessionCallback: (_) => _localUniqueSession,
|
||||
endpointPath: 'account',
|
||||
methodName: 'operating',
|
||||
parameters: _ist.testObjectToJson({}),
|
||||
serializationManager: _serializationManager,
|
||||
);
|
||||
var _localReturnValue =
|
||||
await (_localCallContext.method.call(
|
||||
_localUniqueSession,
|
||||
_localCallContext.arguments,
|
||||
)
|
||||
as _ida.Future<int?>);
|
||||
return _localReturnValue;
|
||||
} finally {
|
||||
await _localUniqueSession.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _EmailCodeEndpoint {
|
||||
|
|
@ -785,6 +933,263 @@ class _KeysEndpoint {
|
|||
}
|
||||
}
|
||||
|
||||
class _InstanceOperatorEndpoint {
|
||||
_InstanceOperatorEndpoint(
|
||||
this._endpointDispatch,
|
||||
this._serializationManager,
|
||||
);
|
||||
|
||||
final _is.EndpointDispatch _endpointDispatch;
|
||||
|
||||
final _is.SerializationManager _serializationManager;
|
||||
|
||||
_ida.Future<List<_iiuvgbjo.AccountOverview>> waiting(
|
||||
_ist.TestSessionBuilder sessionBuilder,
|
||||
) async {
|
||||
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
|
||||
var _localUniqueSession =
|
||||
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
|
||||
endpoint: 'instanceOperator',
|
||||
method: 'waiting',
|
||||
);
|
||||
try {
|
||||
var _localCallContext = await _endpointDispatch.getMethodCallContext(
|
||||
createSessionCallback: (_) => _localUniqueSession,
|
||||
endpointPath: 'instanceOperator',
|
||||
methodName: 'waiting',
|
||||
parameters: _ist.testObjectToJson({}),
|
||||
serializationManager: _serializationManager,
|
||||
);
|
||||
var _localReturnValue =
|
||||
await (_localCallContext.method.call(
|
||||
_localUniqueSession,
|
||||
_localCallContext.arguments,
|
||||
)
|
||||
as _ida.Future<List<_iiuvgbjo.AccountOverview>>);
|
||||
return _localReturnValue;
|
||||
} finally {
|
||||
await _localUniqueSession.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_ida.Future<void> approve(
|
||||
_ist.TestSessionBuilder sessionBuilder, {
|
||||
required _is.UuidValue account,
|
||||
}) async {
|
||||
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
|
||||
var _localUniqueSession =
|
||||
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
|
||||
endpoint: 'instanceOperator',
|
||||
method: 'approve',
|
||||
);
|
||||
try {
|
||||
var _localCallContext = await _endpointDispatch.getMethodCallContext(
|
||||
createSessionCallback: (_) => _localUniqueSession,
|
||||
endpointPath: 'instanceOperator',
|
||||
methodName: 'approve',
|
||||
parameters: _ist.testObjectToJson({'account': account}),
|
||||
serializationManager: _serializationManager,
|
||||
);
|
||||
var _localReturnValue =
|
||||
await (_localCallContext.method.call(
|
||||
_localUniqueSession,
|
||||
_localCallContext.arguments,
|
||||
)
|
||||
as _ida.Future<void>);
|
||||
return _localReturnValue;
|
||||
} finally {
|
||||
await _localUniqueSession.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_ida.Future<void> refuse(
|
||||
_ist.TestSessionBuilder sessionBuilder, {
|
||||
required _is.UuidValue account,
|
||||
}) async {
|
||||
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
|
||||
var _localUniqueSession =
|
||||
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
|
||||
endpoint: 'instanceOperator',
|
||||
method: 'refuse',
|
||||
);
|
||||
try {
|
||||
var _localCallContext = await _endpointDispatch.getMethodCallContext(
|
||||
createSessionCallback: (_) => _localUniqueSession,
|
||||
endpointPath: 'instanceOperator',
|
||||
methodName: 'refuse',
|
||||
parameters: _ist.testObjectToJson({'account': account}),
|
||||
serializationManager: _serializationManager,
|
||||
);
|
||||
var _localReturnValue =
|
||||
await (_localCallContext.method.call(
|
||||
_localUniqueSession,
|
||||
_localCallContext.arguments,
|
||||
)
|
||||
as _ida.Future<void>);
|
||||
return _localReturnValue;
|
||||
} finally {
|
||||
await _localUniqueSession.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_ida.Future<List<_iiuvgbjo.AccountOverview>> accounts(
|
||||
_ist.TestSessionBuilder sessionBuilder, {
|
||||
required String startingWith,
|
||||
}) async {
|
||||
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
|
||||
var _localUniqueSession =
|
||||
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
|
||||
endpoint: 'instanceOperator',
|
||||
method: 'accounts',
|
||||
);
|
||||
try {
|
||||
var _localCallContext = await _endpointDispatch.getMethodCallContext(
|
||||
createSessionCallback: (_) => _localUniqueSession,
|
||||
endpointPath: 'instanceOperator',
|
||||
methodName: 'accounts',
|
||||
parameters: _ist.testObjectToJson({'startingWith': startingWith}),
|
||||
serializationManager: _serializationManager,
|
||||
);
|
||||
var _localReturnValue =
|
||||
await (_localCallContext.method.call(
|
||||
_localUniqueSession,
|
||||
_localCallContext.arguments,
|
||||
)
|
||||
as _ida.Future<List<_iiuvgbjo.AccountOverview>>);
|
||||
return _localReturnValue;
|
||||
} finally {
|
||||
await _localUniqueSession.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_ida.Future<void> suspend(
|
||||
_ist.TestSessionBuilder sessionBuilder, {
|
||||
required _is.UuidValue account,
|
||||
}) async {
|
||||
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
|
||||
var _localUniqueSession =
|
||||
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
|
||||
endpoint: 'instanceOperator',
|
||||
method: 'suspend',
|
||||
);
|
||||
try {
|
||||
var _localCallContext = await _endpointDispatch.getMethodCallContext(
|
||||
createSessionCallback: (_) => _localUniqueSession,
|
||||
endpointPath: 'instanceOperator',
|
||||
methodName: 'suspend',
|
||||
parameters: _ist.testObjectToJson({'account': account}),
|
||||
serializationManager: _serializationManager,
|
||||
);
|
||||
var _localReturnValue =
|
||||
await (_localCallContext.method.call(
|
||||
_localUniqueSession,
|
||||
_localCallContext.arguments,
|
||||
)
|
||||
as _ida.Future<void>);
|
||||
return _localReturnValue;
|
||||
} finally {
|
||||
await _localUniqueSession.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_ida.Future<List<_iiuvgbjo.AccountOverview>> operators(
|
||||
_ist.TestSessionBuilder sessionBuilder,
|
||||
) async {
|
||||
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
|
||||
var _localUniqueSession =
|
||||
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
|
||||
endpoint: 'instanceOperator',
|
||||
method: 'operators',
|
||||
);
|
||||
try {
|
||||
var _localCallContext = await _endpointDispatch.getMethodCallContext(
|
||||
createSessionCallback: (_) => _localUniqueSession,
|
||||
endpointPath: 'instanceOperator',
|
||||
methodName: 'operators',
|
||||
parameters: _ist.testObjectToJson({}),
|
||||
serializationManager: _serializationManager,
|
||||
);
|
||||
var _localReturnValue =
|
||||
await (_localCallContext.method.call(
|
||||
_localUniqueSession,
|
||||
_localCallContext.arguments,
|
||||
)
|
||||
as _ida.Future<List<_iiuvgbjo.AccountOverview>>);
|
||||
return _localReturnValue;
|
||||
} finally {
|
||||
await _localUniqueSession.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_ida.Future<void> appoint(
|
||||
_ist.TestSessionBuilder sessionBuilder, {
|
||||
required String handle,
|
||||
}) async {
|
||||
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
|
||||
var _localUniqueSession =
|
||||
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
|
||||
endpoint: 'instanceOperator',
|
||||
method: 'appoint',
|
||||
);
|
||||
try {
|
||||
var _localCallContext = await _endpointDispatch.getMethodCallContext(
|
||||
createSessionCallback: (_) => _localUniqueSession,
|
||||
endpointPath: 'instanceOperator',
|
||||
methodName: 'appoint',
|
||||
parameters: _ist.testObjectToJson({'handle': handle}),
|
||||
serializationManager: _serializationManager,
|
||||
);
|
||||
var _localReturnValue =
|
||||
await (_localCallContext.method.call(
|
||||
_localUniqueSession,
|
||||
_localCallContext.arguments,
|
||||
)
|
||||
as _ida.Future<void>);
|
||||
return _localReturnValue;
|
||||
} finally {
|
||||
await _localUniqueSession.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_ida.Future<void> takeBack(
|
||||
_ist.TestSessionBuilder sessionBuilder, {
|
||||
required _is.UuidValue account,
|
||||
}) async {
|
||||
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
|
||||
var _localUniqueSession =
|
||||
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
|
||||
endpoint: 'instanceOperator',
|
||||
method: 'takeBack',
|
||||
);
|
||||
try {
|
||||
var _localCallContext = await _endpointDispatch.getMethodCallContext(
|
||||
createSessionCallback: (_) => _localUniqueSession,
|
||||
endpointPath: 'instanceOperator',
|
||||
methodName: 'takeBack',
|
||||
parameters: _ist.testObjectToJson({'account': account}),
|
||||
serializationManager: _serializationManager,
|
||||
);
|
||||
var _localReturnValue =
|
||||
await (_localCallContext.method.call(
|
||||
_localUniqueSession,
|
||||
_localCallContext.arguments,
|
||||
)
|
||||
as _ida.Future<void>);
|
||||
return _localReturnValue;
|
||||
} finally {
|
||||
await _localUniqueSession.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _AccountRecoveryEndpoint {
|
||||
_AccountRecoveryEndpoint(
|
||||
this._endpointDispatch,
|
||||
|
|
|
|||
Loading…
Reference in a new issue