feat(base)!: no anonymous accounts on the server (ADR 0020, D25)
The server's half of D25: every account has a way in, and the anonymous accounts left from before go with the daily pruning. - The module signs nobody in anonymously: `anonymousIdp` and `gitHubUpgrade` are gone, and so is linking GitHub at the register. - A password is set only by someone whose address is theirs (a `firstPassword` code) or who signs in with GitHub alone (a fresh sign-in and a `signUp` code); an anonymous account is refused `wrongCredentials` before any code is used. - SimpleLogin refuses to link onto a caller without `identified` (`wrongCaller`), the member's endpoint too, so nothing turns an anonymous account into one with a way in. - `pruneAnonymousPeople` deletes every anonymous account left, however new, never one identified. Learn and Todo schedule it as `prune-accounts` and cancel the old `prune-abandoned-anonymous-people` entry once. - Learn: someone without `identified` keeps no files; `FILE_LIMIT_ANONYMOUS_MB` is gone. `minimumAppBuild` is 23, as builds up to 22 start with an anonymous account. - Learn's sync tests make their person and session by SQL in the development database, as Serverpod stores sessions. - Docs, store listings and ADRs 0010, 0014, 0016, 0017 and 0019 amended. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
5b9df73756
commit
87671208df
13 changed files with 837 additions and 1353 deletions
|
|
@ -168,8 +168,8 @@ void main() {
|
|||
(await RateLimitedRequestAttempt.db.insertRow(
|
||||
session,
|
||||
RateLimitedRequestAttempt(
|
||||
domain: 'anonymous',
|
||||
source: 'account_creation',
|
||||
domain: 'password_sign_in',
|
||||
source: 'proofs_per_address',
|
||||
key: '203.0.113.7',
|
||||
ipAddress: '203.0.113.7',
|
||||
attemptedAt: now.subtract(ago),
|
||||
|
|
@ -198,7 +198,10 @@ void main() {
|
|||
});
|
||||
});
|
||||
|
||||
withServerpod('Pruning abandoned anonymous people', (sessionBuilder, _) {
|
||||
withServerpod('Pruning anonymous people (ADR 0020, D25)', (
|
||||
sessionBuilder,
|
||||
_,
|
||||
) {
|
||||
late Session session;
|
||||
final now = DateTime.now().toUtc();
|
||||
DateTime daysAgo(int days) => now.subtract(Duration(days: days));
|
||||
|
|
@ -220,45 +223,70 @@ void main() {
|
|||
session = sessionBuilder.build();
|
||||
});
|
||||
|
||||
test('the module signs nobody in anonymously', () {
|
||||
expect(
|
||||
session.serverpod.endpoints.getConnectorByName('anonymousIdp'),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'deletes anonymous people unused for 90 days, and only them, with '
|
||||
"the host's data of theirs",
|
||||
'deletes every anonymous account left, however new or lately used, '
|
||||
"with the host's data of theirs",
|
||||
() async {
|
||||
final unused = await newAnonymousPerson(
|
||||
session,
|
||||
createdAt: daysAgo(100),
|
||||
);
|
||||
await _lastUsed(session, unused, daysAgo(95));
|
||||
final neverSignedIn = await newAnonymousPerson(
|
||||
session,
|
||||
createdAt: daysAgo(100),
|
||||
);
|
||||
final old = await newAnonymousPerson(session, createdAt: daysAgo(100));
|
||||
final recentlyUsed = await newAnonymousPerson(
|
||||
session,
|
||||
createdAt: daysAgo(100),
|
||||
);
|
||||
await _lastUsed(session, recentlyUsed, daysAgo(10));
|
||||
final newcomer = await newAnonymousPerson(
|
||||
session,
|
||||
createdAt: daysAgo(5),
|
||||
);
|
||||
final identifiedPerson = (await AuthServices.instance.authUsers.create(
|
||||
session,
|
||||
scopes: {identified},
|
||||
)).id;
|
||||
await _lastUsed(session, identifiedPerson, daysAgo(365));
|
||||
await _lastUsed(session, recentlyUsed, daysAgo(1));
|
||||
final newcomer = await newAnonymousPerson(session);
|
||||
await signInNewDevice(session, newcomer);
|
||||
|
||||
final pruned = await pruneAbandonedAnonymousPeople(session, now: now);
|
||||
// Others may have been left by earlier runs.
|
||||
expect(await pruneAnonymousPeople(session), greaterThanOrEqualTo(3));
|
||||
|
||||
expect(pruned, 2);
|
||||
expect(await AuthUser.db.findById(session, unused), isNull);
|
||||
expect(await AuthUser.db.findById(session, neverSignedIn), isNull);
|
||||
for (final kept in [recentlyUsed, newcomer, identifiedPerson]) {
|
||||
expect(await AuthUser.db.findById(session, kept), isNotNull);
|
||||
for (final gone in [old, recentlyUsed, newcomer]) {
|
||||
expect(await AuthUser.db.findById(session, gone), isNull);
|
||||
expect(await _sessionsOf(session, gone), 0);
|
||||
}
|
||||
expect(deletedByHost, unorderedEquals([unused, neverSignedIn]));
|
||||
expect(deletedByHost, containsAll([old, recentlyUsed, newcomer]));
|
||||
},
|
||||
);
|
||||
|
||||
test('deletes nobody but anonymous accounts: never someone identified, '
|
||||
"even with an anonymous account's row left, nor anyone without "
|
||||
'one', () async {
|
||||
final identifiedPerson = (await AuthServices.instance.authUsers.create(
|
||||
session,
|
||||
scopes: {identified},
|
||||
)).id;
|
||||
// INSERT INTO "serverpod_auth_idp_anonymous_account" (…) VALUES (…)
|
||||
await AnonymousAccount.db.insertRow(
|
||||
session,
|
||||
AnonymousAccount(authUserId: identifiedPerson, createdAt: daysAgo(400)),
|
||||
);
|
||||
await _lastUsed(session, identifiedPerson, daysAgo(365));
|
||||
final noAnonymousAccount = (await AuthServices.instance.authUsers.create(
|
||||
session,
|
||||
)).id;
|
||||
|
||||
await pruneAnonymousPeople(session);
|
||||
|
||||
for (final kept in [identifiedPerson, noAnonymousAccount]) {
|
||||
expect(await AuthUser.db.findById(session, kept), isNotNull);
|
||||
expect(deletedByHost, isNot(contains(kept)));
|
||||
}
|
||||
expect(await _sessionsOf(session, identifiedPerson), 1);
|
||||
});
|
||||
|
||||
test('deletes at most as many as it is given at once', () async {
|
||||
for (var i = 0; i < 3; i++) {
|
||||
await newAnonymousPerson(session);
|
||||
}
|
||||
|
||||
expect(await pruneAnonymousPeople(session, limit: 2), 2);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,77 +0,0 @@
|
|||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_auth_idp_server/core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../support/fixtures.dart';
|
||||
import '../test_tools/serverpod_test_tools.dart';
|
||||
|
||||
void main() {
|
||||
withServerpod(
|
||||
'Anonymous sign-in',
|
||||
// Signing in runs concurrent transactions (the rate limit and the new
|
||||
// account), which the test's database rollback can't wrap. So these tests
|
||||
// don't roll back; they delete what they create instead.
|
||||
rollbackDatabase: RollbackDatabase.disabled,
|
||||
(sessionBuilder, endpoints) {
|
||||
setUp(() async {
|
||||
useBase(sessionBuilder);
|
||||
await _deleteAll(sessionBuilder.build());
|
||||
});
|
||||
tearDown(() => _deleteAll(sessionBuilder.build()));
|
||||
|
||||
test('creates a person', () async {
|
||||
final authSuccess = await endpoints.anonymousIdp.login(sessionBuilder);
|
||||
|
||||
final authUser = await AuthServices.instance.authUsers.get(
|
||||
sessionBuilder.build(),
|
||||
authUserId: authSuccess.authUserId,
|
||||
);
|
||||
expect(authUser.id, authSuccess.authUserId);
|
||||
});
|
||||
|
||||
test("counts a new account once, by the visitor's network, not as "
|
||||
"Serverpod's own limit counts the connection's", () async {
|
||||
await endpoints.anonymousIdp.login(sessionBuilder);
|
||||
|
||||
final notes = await RateLimitedRequestAttempt.db.find(
|
||||
sessionBuilder.build(),
|
||||
);
|
||||
expect(
|
||||
notes.map((note) => (note.domain, note.source, note.key)),
|
||||
// `networkOf`'s key for a test's session, which has no request.
|
||||
[('anonymous', 'accounts_per_network', 'null')],
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'refuses the 31st account from one IP address within an hour',
|
||||
() async {
|
||||
for (var i = 0; i < 30; i++) {
|
||||
await endpoints.anonymousIdp.login(sessionBuilder);
|
||||
}
|
||||
|
||||
await expectLater(
|
||||
endpoints.anonymousIdp.login(sessionBuilder),
|
||||
throwsA(
|
||||
isA<AnonymousAccountBlockedException>().having(
|
||||
(e) => e.reason,
|
||||
'reason',
|
||||
AnonymousAccountBlockedExceptionReason.tooManyAttempts,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Deletes every rate-limit record and every person; their anonymous accounts
|
||||
/// and sessions go with them (`ON DELETE CASCADE`).
|
||||
Future<void> _deleteAll(Session session) async {
|
||||
await RateLimitedRequestAttempt.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => t.id.notEquals(null),
|
||||
);
|
||||
await AuthUser.db.deleteWhere(session, where: (t) => t.id.notEquals(null));
|
||||
}
|
||||
|
|
@ -86,7 +86,8 @@ void main() {
|
|||
late Session session;
|
||||
late _Mailbox mailbox;
|
||||
|
||||
/// Anonymous, signed in on her phone.
|
||||
/// Signed in on her phone without an address: an anonymous account
|
||||
/// left from before (ADR 0020, D25), asking for codes as anyone may.
|
||||
late UuidValue anna;
|
||||
late UuidValue annasPhone;
|
||||
|
||||
|
|
@ -103,11 +104,7 @@ void main() {
|
|||
mailbox = _Mailbox();
|
||||
EmailCodes.of(session.serverpod).mailer = mailbox;
|
||||
|
||||
anna = (await AuthServices.instance.authUsers.create(session)).id;
|
||||
await AnonymousAccount.db.insertRow(
|
||||
session,
|
||||
AnonymousAccount(authUserId: anna),
|
||||
);
|
||||
anna = await newAnonymousPerson(session);
|
||||
annasPhone = await signInNewDevice(session, anna);
|
||||
ben = (await AuthServices.instance.authUsers.create(
|
||||
session,
|
||||
|
|
|
|||
|
|
@ -12,18 +12,12 @@ void main() {
|
|||
withServerpod('GitHub sign-in', (sessionBuilder, endpoints) {
|
||||
late Session session;
|
||||
|
||||
/// Anonymous, signed in on her phone.
|
||||
late UuidValue anna;
|
||||
late UuidValue annasPhone;
|
||||
|
||||
/// Identified, with the GitHub account 1001.
|
||||
late UuidValue ben;
|
||||
|
||||
setUp(() async {
|
||||
useBase(sessionBuilder);
|
||||
session = sessionBuilder.build();
|
||||
anna = await _newAnonymousPerson(session);
|
||||
annasPhone = await signInNewDevice(session, anna);
|
||||
ben = (await AuthServices.instance.authUsers.create(
|
||||
session,
|
||||
scopes: {identified},
|
||||
|
|
@ -39,35 +33,8 @@ void main() {
|
|||
);
|
||||
|
||||
group('refuses', () {
|
||||
test('upgrading when not signed in', () async {
|
||||
await expectLater(
|
||||
endpoints.gitHubUpgrade.upgrade(
|
||||
notSignedIn(),
|
||||
code: 'code',
|
||||
codeVerifier: 'verifier',
|
||||
redirectUri: _redirectUri,
|
||||
),
|
||||
throwsA(isA<ServerpodUnauthenticatedException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('both ways on an instance without a GitHub OAuth app', () async {
|
||||
test('signing in on an instance without a GitHub OAuth app', () async {
|
||||
// The test configuration has no githubClientId/githubClientSecret.
|
||||
await expectLater(
|
||||
endpoints.gitHubUpgrade.upgrade(
|
||||
sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
anna.uuid,
|
||||
{},
|
||||
authId: annasPhone.uuid,
|
||||
),
|
||||
),
|
||||
code: 'code',
|
||||
codeVerifier: 'verifier',
|
||||
redirectUri: _redirectUri,
|
||||
),
|
||||
throwsA(_refusal(GitHubSignInRefusal.unavailable)),
|
||||
);
|
||||
await expectLater(
|
||||
endpoints.gitHubSignIn.signIn(
|
||||
notSignedIn(),
|
||||
|
|
@ -79,37 +46,6 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
test('linking a GitHub account that belongs to someone else; nothing '
|
||||
'changes', () async {
|
||||
await expectLater(
|
||||
linkGitHubAccount(session, authUserId: anna, gitHubUserId: '1001'),
|
||||
throwsA(_refusal(GitHubSignInRefusal.takenByAnotherPerson)),
|
||||
);
|
||||
|
||||
expect(await _gitHubAccountsOf(session, anna), isEmpty);
|
||||
expect(await _isAnonymous(session, anna), isTrue);
|
||||
expect(
|
||||
await _scopesOf(session, anna),
|
||||
isNot(contains(identified.name)),
|
||||
);
|
||||
expect(
|
||||
[
|
||||
for (final account in await _gitHubAccountsOf(session, ben))
|
||||
account.userIdentifier,
|
||||
],
|
||||
['1001'],
|
||||
);
|
||||
});
|
||||
|
||||
test('a second GitHub account for the same person', () async {
|
||||
await expectLater(
|
||||
linkGitHubAccount(session, authUserId: ben, gitHubUserId: '2002'),
|
||||
throwsA(_refusal(GitHubSignInRefusal.otherGitHubAccountLinked)),
|
||||
);
|
||||
|
||||
expect(await _gitHubAccount(session, '2002'), isNull);
|
||||
});
|
||||
|
||||
test('signing in with a GitHub account nobody has; nobody is '
|
||||
'created', () async {
|
||||
final people = await AuthUser.db.count(session);
|
||||
|
|
@ -124,33 +60,12 @@ void main() {
|
|||
});
|
||||
});
|
||||
|
||||
test('linking keeps the person and makes them identified, on every '
|
||||
'session', () async {
|
||||
await linkGitHubAccount(session, authUserId: anna, gitHubUserId: '2002');
|
||||
test('adds GitHub to nobody: the module has no way to (ADR 0020, '
|
||||
'D25)', () {
|
||||
final pod = session.serverpod;
|
||||
|
||||
final account = await _gitHubAccount(session, '2002');
|
||||
// The same person, so everything they have stays where it is.
|
||||
expect(account?.authUserId, anna);
|
||||
// Only the GitHub ID is kept.
|
||||
expect(account?.email, isNull);
|
||||
expect(await _scopesOf(session, anna), contains(identified.name));
|
||||
expect(
|
||||
(await ServerSideSession.db.findById(session, annasPhone))?.scopeNames,
|
||||
contains(identified.name),
|
||||
);
|
||||
expect(await _isAnonymous(session, anna), isFalse);
|
||||
});
|
||||
|
||||
test('linking the account a person already has changes nothing', () async {
|
||||
await linkGitHubAccount(session, authUserId: ben, gitHubUserId: '1001');
|
||||
|
||||
expect(
|
||||
[
|
||||
for (final account in await _gitHubAccountsOf(session, ben))
|
||||
account.userIdentifier,
|
||||
],
|
||||
['1001'],
|
||||
);
|
||||
expect(pod.endpoints.getConnectorByName('gitHubUpgrade'), isNull);
|
||||
expect(pod.endpoints.getConnectorByName('gitHubSignIn'), isNotNull);
|
||||
});
|
||||
|
||||
test('signing in with a known GitHub account starts a session for that '
|
||||
|
|
@ -179,38 +94,15 @@ void main() {
|
|||
useGitHubSignInEnds(session.serverpod, null);
|
||||
});
|
||||
|
||||
test('links no new GitHub account where people sign in with a '
|
||||
'password, and still signs in those who have one', () async {
|
||||
test('still signs in those who have it where people sign in with a '
|
||||
'password', () async {
|
||||
usePasswordKeys(session.serverpod, testPasswordKeys);
|
||||
EmailCodes.of(session.serverpod).mailer = const LogCodeMailer();
|
||||
|
||||
await expectLater(
|
||||
linkGitHubAccount(session, authUserId: anna, gitHubUserId: '2002'),
|
||||
throwsA(_refusal(GitHubSignInRefusal.deprecated)),
|
||||
);
|
||||
expect(await _isAnonymous(session, anna), isTrue);
|
||||
expect(await _gitHubAccount(session, '2002'), isNull);
|
||||
await linkGitHubAccount(session, authUserId: ben, gitHubUserId: '1001');
|
||||
expect(await gitHubAccountToSignIn(session, '1001'), ben);
|
||||
});
|
||||
|
||||
test(
|
||||
"tells an anonymous person whose GitHub account is another's so, "
|
||||
'as linking nothing, for the app to switch to that account',
|
||||
() async {
|
||||
usePasswordKeys(session.serverpod, testPasswordKeys);
|
||||
EmailCodes.of(session.serverpod).mailer = const LogCodeMailer();
|
||||
|
||||
await expectLater(
|
||||
linkGitHubAccount(session, authUserId: anna, gitHubUserId: '1001'),
|
||||
throwsA(_refusal(GitHubSignInRefusal.takenByAnotherPerson)),
|
||||
);
|
||||
expect(await _isAnonymous(session, anna), isTrue);
|
||||
expect(await gitHubAccountToSignIn(session, '1001'), ben);
|
||||
},
|
||||
);
|
||||
|
||||
test('signs nobody in and links nothing from the day it ends, after '
|
||||
test('signs nobody in from the day it ends, after '
|
||||
'which pruning forgets every GitHub ID', () async {
|
||||
final ends = DateTime.utc(2027, 1, 22);
|
||||
useGitHubSignInEnds(session.serverpod, ends);
|
||||
|
|
@ -232,21 +124,6 @@ void main() {
|
|||
),
|
||||
throwsA(_refusal(GitHubSignInRefusal.ended)),
|
||||
);
|
||||
await expectLater(
|
||||
endpoints.gitHubUpgrade.upgrade(
|
||||
sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
anna.uuid,
|
||||
{},
|
||||
authId: annasPhone.uuid,
|
||||
),
|
||||
),
|
||||
code: 'code',
|
||||
codeVerifier: 'verifier',
|
||||
redirectUri: _redirectUri,
|
||||
),
|
||||
throwsA(_refusal(GitHubSignInRefusal.ended)),
|
||||
);
|
||||
});
|
||||
// For 30 days after, nothing goes: a date set wrong can be put
|
||||
// right meanwhile.
|
||||
|
|
@ -431,34 +308,6 @@ Matcher _refusal(GitHubSignInRefusal reason) =>
|
|||
reason,
|
||||
);
|
||||
|
||||
/// A person as the app's first launch creates them: anonymous.
|
||||
Future<UuidValue> _newAnonymousPerson(Session session) async {
|
||||
final person = (await AuthServices.instance.authUsers.create(session)).id;
|
||||
await AnonymousAccount.db.insertRow(
|
||||
session,
|
||||
AnonymousAccount(authUserId: person),
|
||||
);
|
||||
return person;
|
||||
}
|
||||
|
||||
Future<bool> _isAnonymous(Session session, UuidValue person) async =>
|
||||
await AnonymousAccount.db.count(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(person),
|
||||
) >
|
||||
0;
|
||||
|
||||
Future<Set<String>> _scopesOf(Session session, UuidValue person) async =>
|
||||
(await AuthServices.instance.authUsers.get(
|
||||
session,
|
||||
authUserId: person,
|
||||
)).scopeNames;
|
||||
|
||||
Future<List<GitHubAccount>> _gitHubAccountsOf(
|
||||
Session session,
|
||||
UuidValue person,
|
||||
) => GitHubAccount.db.find(session, where: (t) => t.authUserId.equals(person));
|
||||
|
||||
Future<GitHubAccount?> _gitHubAccount(Session session, String gitHubUserId) =>
|
||||
GitHubAccount.db.findFirstRow(
|
||||
session,
|
||||
|
|
|
|||
|
|
@ -184,10 +184,13 @@ void main() {
|
|||
usePasswordKeys(pod, testPasswordKeys);
|
||||
now = DateTime.utc(2026, 10, 20, 12);
|
||||
device = await PasswordDevice.make();
|
||||
anna = await newAnonymousPerson(session);
|
||||
anna = await newPersonWithAddress(session, 'anna@example.org');
|
||||
annasPhone = await signInNewDevice(session, anna);
|
||||
annasLaptop = await signInNewDevice(session, anna);
|
||||
final code = await mailedCode('anna@example.org');
|
||||
final code = await mailedCode(
|
||||
'anna@example.org',
|
||||
purpose: EmailCodePurpose.firstPassword,
|
||||
);
|
||||
await endpoints.password.set(
|
||||
onPhone(),
|
||||
setup: device.setup(),
|
||||
|
|
@ -296,7 +299,7 @@ void main() {
|
|||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
device.privateValue = mine;
|
||||
final ben = await newAnonymousPerson(session);
|
||||
final ben = await newPersonWithAddress(session, 'ben@example.org');
|
||||
final bensLaptop = await signInNewDevice(session, ben);
|
||||
await expectLater(
|
||||
change(
|
||||
|
|
@ -539,7 +542,10 @@ void main() {
|
|||
test('which nobody else reads or dismisses, nor anyone signed '
|
||||
'out', () async {
|
||||
final ended = await endPhone();
|
||||
final bert = await newAnonymousPerson(session);
|
||||
final bert = await newPersonWithAddress(
|
||||
session,
|
||||
'bert@example.org',
|
||||
);
|
||||
final bertsPhone = await signInNewDevice(session, bert);
|
||||
final asBert = sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
|
|
@ -615,7 +621,7 @@ void main() {
|
|||
});
|
||||
|
||||
test('is begun only by someone with a password', () async {
|
||||
final ben = await newAnonymousPerson(session);
|
||||
final ben = await newPersonWithAddress(session, 'ben@example.org');
|
||||
final bensLaptop = await signInNewDevice(session, ben);
|
||||
|
||||
await expectLater(
|
||||
|
|
@ -844,7 +850,7 @@ void main() {
|
|||
newPhrase(purpose: PasswordProofPurpose.newPassword),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
final ben = await newAnonymousPerson(session);
|
||||
final ben = await newPersonWithAddress(session, 'ben@example.org');
|
||||
final bensLaptop = await signInNewDevice(session, ben);
|
||||
await expectLater(
|
||||
newPhrase(
|
||||
|
|
@ -969,7 +975,7 @@ void main() {
|
|||
'code by mail asked for deleting, nothing less, nor one asked for '
|
||||
'anything else',
|
||||
() async {
|
||||
final ben = await newAnonymousPerson(session);
|
||||
final ben = await newPersonWithAddress(session, 'ben@example.org');
|
||||
final bensLaptop = await signInNewDevice(session, ben);
|
||||
final bens = await PasswordDevice.make(seed: 7);
|
||||
TestSessionBuilder asBen() => sessionBuilder.copyWith(
|
||||
|
|
@ -979,7 +985,10 @@ void main() {
|
|||
authId: bensLaptop.uuid,
|
||||
),
|
||||
);
|
||||
final setUp = await mailedCode('ben@example.org');
|
||||
final setUp = await mailedCode(
|
||||
'ben@example.org',
|
||||
purpose: EmailCodePurpose.firstPassword,
|
||||
);
|
||||
await endpoints.password.set(
|
||||
asBen(),
|
||||
setup: bens.setup(),
|
||||
|
|
@ -1132,7 +1141,8 @@ void main() {
|
|||
});
|
||||
|
||||
test(
|
||||
'of an anonymous person takes its session alone, as before',
|
||||
'of an anonymous account left from before takes its session '
|
||||
'alone, as the app deletes it (ADR 0020, D25)',
|
||||
() async {
|
||||
final ben = await newAnonymousPerson(session);
|
||||
final bensLaptop = await signInNewDevice(session, ben);
|
||||
|
|
|
|||
|
|
@ -34,7 +34,8 @@ void main() {
|
|||
/// The tests' time, moved on by hand.
|
||||
late DateTime now;
|
||||
|
||||
/// Anonymous, signed in on her phone.
|
||||
/// Signs in with a code mailed to anna@example.org, as accounts did
|
||||
/// before passwords (ADR 0017), on her phone.
|
||||
late UuidValue anna;
|
||||
late UuidValue annasPhone;
|
||||
|
||||
|
|
@ -47,8 +48,12 @@ void main() {
|
|||
EmailCodes.of(pod).mailer = mailbox;
|
||||
usePasswordKeys(pod, testPasswordKeys);
|
||||
now = DateTime.utc(2026, 10, 20, 12);
|
||||
anna = await newAnonymousPerson(session);
|
||||
annasPhone = await signInNewDevice(session, anna);
|
||||
anna = await newPersonWithAddress(session, 'anna@example.org');
|
||||
annasPhone = await signInNewDevice(
|
||||
session,
|
||||
anna,
|
||||
scopes: {identified},
|
||||
);
|
||||
device = await PasswordDevice.make();
|
||||
});
|
||||
tearDown(() async {
|
||||
|
|
@ -126,9 +131,13 @@ void main() {
|
|||
return (request: request, code: mailbox.codeFor(address));
|
||||
}
|
||||
|
||||
/// Anna's password, with anna@example.org made hers by a code.
|
||||
/// Anna's password, anna@example.org proved hers by a code mailed
|
||||
/// there (the fourth reading: a session alone sets none).
|
||||
Future<void> annaSetsPassword({PasswordDevice? with_}) async {
|
||||
final code = await mailedCode('anna@example.org');
|
||||
final code = await mailedCode(
|
||||
'anna@example.org',
|
||||
purpose: EmailCodePurpose.firstPassword,
|
||||
);
|
||||
await endpoints.password.set(
|
||||
asAnna(),
|
||||
setup: (with_ ?? device).setup(),
|
||||
|
|
@ -209,24 +218,14 @@ void main() {
|
|||
(await AuthUser.db.findById(session, person))!.scopeNames;
|
||||
|
||||
group('setting a password', () {
|
||||
test('makes an anonymous person identified with the address a code '
|
||||
'proved, keeping their ID, sessions and data', () async {
|
||||
test('gives someone whose address is theirs a password, keeping '
|
||||
'their ID, sessions and data', () async {
|
||||
await annaSetsPassword();
|
||||
|
||||
expect(await scopesOf(anna), contains(identified.name));
|
||||
expect(
|
||||
(await ServerSideSession.db.findById(
|
||||
session,
|
||||
annasPhone,
|
||||
))!.scopeNames,
|
||||
contains(identified.name),
|
||||
);
|
||||
expect(
|
||||
await AnonymousAccount.db.count(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(anna),
|
||||
),
|
||||
0,
|
||||
await ServerSideSession.db.findById(session, annasPhone),
|
||||
isNotNull,
|
||||
);
|
||||
expect(
|
||||
(await EmailIdentity.db.findFirstRow(
|
||||
|
|
@ -317,13 +316,58 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
test('is refused to an anonymous account left from before '
|
||||
'(ADR 0020, D25): a code to any address sets nothing, and stays '
|
||||
'unused', () async {
|
||||
final ben = await newAnonymousPerson(session);
|
||||
final bensPhone = await signInNewDevice(session, ben);
|
||||
final code = await mailedCode('ben@example.org');
|
||||
|
||||
await expectLater(
|
||||
endpoints.password.set(
|
||||
sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
ben.uuid,
|
||||
{},
|
||||
authId: bensPhone.uuid,
|
||||
),
|
||||
),
|
||||
setup: device.setup(),
|
||||
request: code.request,
|
||||
code: code.code,
|
||||
),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
expect(
|
||||
await EmailIdentity.db.count(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(ben),
|
||||
),
|
||||
0,
|
||||
);
|
||||
expect(await AccountPassword.db.count(session), 0);
|
||||
expect(await scopesOf(ben), isNot(contains(identified.name)));
|
||||
// The code still signs up whoever asked for it.
|
||||
final signedUp = await endpoints.passwordSignIn.signUp(
|
||||
notSignedIn(),
|
||||
request: code.request,
|
||||
code: code.code,
|
||||
setup: device.setup(),
|
||||
);
|
||||
expect(signedUp.authUserId, isNot(ben));
|
||||
});
|
||||
|
||||
group('for someone who signs in with GitHub alone', () {
|
||||
setUp(() async {
|
||||
// Anna has no address here: GitHub is her only way in.
|
||||
await EmailIdentity.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(anna),
|
||||
);
|
||||
await GitHubAccount.db.insertRow(
|
||||
session,
|
||||
GitHubAccount(userIdentifier: '1001', authUserId: anna),
|
||||
);
|
||||
await grantIdentified(session, anna);
|
||||
});
|
||||
|
||||
TestSessionBuilder from(UuidValue device) => sessionBuilder.copyWith(
|
||||
|
|
@ -386,15 +430,58 @@ void main() {
|
|||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test('is refused without an address, keeping nothing', () async {
|
||||
final fresh = await atNow(
|
||||
() => signInNewDevice(session, anna, method: gitHubMethod),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
atNow(
|
||||
() =>
|
||||
endpoints.password.set(from(fresh), setup: device.setup()),
|
||||
),
|
||||
refused(PasswordRefusal.noAddress),
|
||||
);
|
||||
expect(await AccountPassword.db.count(session), 0);
|
||||
});
|
||||
|
||||
test('is refused for an address another account has, keeping '
|
||||
'nothing', () async {
|
||||
await newPersonWithAddress(session, 'ben@example.org');
|
||||
final code = await mailedCode('ben@example.org');
|
||||
final fresh = await atNow(
|
||||
() => signInNewDevice(session, anna, method: gitHubMethod),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
atNow(
|
||||
() => endpoints.password.set(
|
||||
from(fresh),
|
||||
setup: device.setup(),
|
||||
request: code.request,
|
||||
code: code.code,
|
||||
),
|
||||
),
|
||||
refused(PasswordRefusal.addressTaken),
|
||||
);
|
||||
expect(
|
||||
await EmailIdentity.db.count(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(anna),
|
||||
),
|
||||
0,
|
||||
);
|
||||
expect(await AccountPassword.db.count(session), 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('is refused to someone neither anonymous nor with a way in (a '
|
||||
'GitHub ID forgotten): a session alone and a code to any address '
|
||||
'set nothing', () async {
|
||||
// Anna, identified, her GitHub ID forgotten (the full reading's
|
||||
// M1): a session of hers, stolen, outlived it.
|
||||
await grantIdentified(session, anna);
|
||||
await AnonymousAccount.db.deleteWhere(
|
||||
test('is refused to someone without a way in (a GitHub ID '
|
||||
'forgotten): a session alone and a code to any address set '
|
||||
'nothing', () async {
|
||||
// Anna, identified, without an address, her GitHub ID forgotten
|
||||
// (the full reading's M1): a session of hers, stolen, outlived it.
|
||||
await EmailIdentity.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(anna),
|
||||
);
|
||||
|
|
@ -425,45 +512,8 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
test('is refused without an address, keeping nothing', () async {
|
||||
await expectLater(
|
||||
endpoints.password.set(asAnna(), setup: device.setup()),
|
||||
refused(PasswordRefusal.noAddress),
|
||||
);
|
||||
expect(await AccountPassword.db.count(session), 0);
|
||||
});
|
||||
|
||||
test('is refused for an address another account has, the person '
|
||||
'staying anonymous', () async {
|
||||
final ben = (await AuthServices.instance.authUsers.create(
|
||||
session,
|
||||
scopes: {identified},
|
||||
)).id;
|
||||
await EmailIdentity.db.insertRow(
|
||||
session,
|
||||
EmailIdentity(
|
||||
authUserId: ben,
|
||||
addressHash: addressHash(pod, 'ben@example.org'),
|
||||
createdAt: clock.now(),
|
||||
),
|
||||
);
|
||||
final code = await mailedCode('ben@example.org');
|
||||
|
||||
await expectLater(
|
||||
endpoints.password.set(
|
||||
asAnna(),
|
||||
setup: device.setup(),
|
||||
request: code.request,
|
||||
code: code.code,
|
||||
),
|
||||
refused(PasswordRefusal.addressTaken),
|
||||
);
|
||||
expect(await scopesOf(anna), isNot(contains(identified.name)));
|
||||
expect(await AccountPassword.db.count(session), 0);
|
||||
});
|
||||
|
||||
test('is refused for a wrong code, keeping nothing', () async {
|
||||
final code = await mailedCode('anna@example.org');
|
||||
final code = await ownAddressCode();
|
||||
|
||||
await expectLater(
|
||||
endpoints.password.set(
|
||||
|
|
@ -475,7 +525,7 @@ void main() {
|
|||
refusedAsEmail(EmailSignInRefusal.wrongCode),
|
||||
);
|
||||
expect(await AccountPassword.db.count(session), 0);
|
||||
expect(await EmailIdentity.db.count(session), 0);
|
||||
expect(await EmailIdentity.db.count(session), 1);
|
||||
});
|
||||
|
||||
test('is refused for a setup out of range, part by part', () async {
|
||||
|
|
@ -524,7 +574,7 @@ void main() {
|
|||
),
|
||||
),
|
||||
};
|
||||
final code = await mailedCode('anna@example.org');
|
||||
final code = await ownAddressCode();
|
||||
for (final MapEntry(key: what, value: setup) in wrong.entries) {
|
||||
await expectLater(
|
||||
endpoints.password.set(
|
||||
|
|
@ -538,7 +588,14 @@ void main() {
|
|||
);
|
||||
}
|
||||
expect(await AccountPassword.db.count(session), 0);
|
||||
expect(await scopesOf(anna), isNot(contains(identified.name)));
|
||||
// Refused before the code was used: it still serves.
|
||||
await endpoints.password.set(
|
||||
asAnna(),
|
||||
setup: setup,
|
||||
request: code.request,
|
||||
code: code.code,
|
||||
);
|
||||
expect(await AccountPassword.db.count(session), 1);
|
||||
});
|
||||
|
||||
test('replaced before an authenticator is confirmed, ends every '
|
||||
|
|
@ -1384,7 +1441,7 @@ void main() {
|
|||
proof: proof,
|
||||
),
|
||||
);
|
||||
final ben = await newAnonymousPerson(session);
|
||||
final ben = await newPersonWithAddress(session, 'ben@example.org');
|
||||
final bensLaptop = await signInNewDevice(session, ben);
|
||||
final asBen = sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
|
|
@ -1394,7 +1451,10 @@ void main() {
|
|||
),
|
||||
);
|
||||
final bensDevice = await PasswordDevice.make(seed: 7);
|
||||
final bensCode = await mailedCode('ben@example.org');
|
||||
final bensCode = await mailedCode(
|
||||
'ben@example.org',
|
||||
purpose: EmailCodePurpose.firstPassword,
|
||||
);
|
||||
await endpoints.password.set(
|
||||
asBen,
|
||||
setup: bensDevice.setup(),
|
||||
|
|
@ -1753,7 +1813,7 @@ void main() {
|
|||
refused(PasswordRefusal.unknownAuthenticator),
|
||||
);
|
||||
|
||||
final ben = await newAnonymousPerson(session);
|
||||
final ben = await newPersonWithAddress(session, 'ben@example.org');
|
||||
final bensLaptop = await signInNewDevice(session, ben);
|
||||
final asBen = sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
|
|
@ -1762,7 +1822,10 @@ void main() {
|
|||
authId: bensLaptop.uuid,
|
||||
),
|
||||
);
|
||||
final code = await mailedCode('ben@example.org');
|
||||
final code = await mailedCode(
|
||||
'ben@example.org',
|
||||
purpose: EmailCodePurpose.firstPassword,
|
||||
);
|
||||
await endpoints.password.set(
|
||||
asBen,
|
||||
setup: (await PasswordDevice.make(seed: 7)).setup(),
|
||||
|
|
|
|||
|
|
@ -36,7 +36,8 @@ void main() {
|
|||
/// The tests' time, moved on by hand.
|
||||
late DateTime now;
|
||||
|
||||
/// Anonymous, signed in on her phone.
|
||||
/// Signs in with a code mailed to anna@example.org, as accounts did
|
||||
/// before passwords (ADR 0017), on her phone.
|
||||
late UuidValue anna;
|
||||
late UuidValue annasPhone;
|
||||
|
||||
|
|
@ -49,8 +50,12 @@ void main() {
|
|||
EmailCodes.of(pod).mailer = mailbox;
|
||||
usePasswordKeys(pod, testPasswordKeys);
|
||||
now = DateTime.utc(2026, 10, 20, 12);
|
||||
anna = await newAnonymousPerson(session);
|
||||
annasPhone = await signInNewDevice(session, anna);
|
||||
anna = await newPersonWithAddress(session, 'anna@example.org');
|
||||
annasPhone = await signInNewDevice(
|
||||
session,
|
||||
anna,
|
||||
scopes: {identified},
|
||||
);
|
||||
device = await PasswordDevice.make();
|
||||
});
|
||||
tearDown(() async {
|
||||
|
|
@ -103,7 +108,10 @@ void main() {
|
|||
|
||||
/// Anna: a password and a confirmed authenticator.
|
||||
Future<AuthenticatorEnrolment> annaComplete() => atNow(() async {
|
||||
final code = await mailedCode('anna@example.org');
|
||||
final code = await mailedCode(
|
||||
'anna@example.org',
|
||||
purpose: EmailCodePurpose.firstPassword,
|
||||
);
|
||||
await endpoints.password.set(
|
||||
asAnna(),
|
||||
setup: device.setup(),
|
||||
|
|
@ -787,7 +795,7 @@ void main() {
|
|||
resetLapsesAfter + const Duration(minutes: 1),
|
||||
);
|
||||
// Bob's authenticators' reset, carried out long ago.
|
||||
final bob = await newAnonymousPerson(session);
|
||||
final bob = await newPersonWithAddress(session, 'bob@example.org');
|
||||
await AccountReset.db.insertRow(
|
||||
session,
|
||||
AccountReset(
|
||||
|
|
@ -799,7 +807,10 @@ void main() {
|
|||
);
|
||||
// Carl's account reset, asked 31 days ago and moved on by
|
||||
// hold-outs past now: lapsed all the same.
|
||||
final carl = await newAnonymousPerson(session);
|
||||
final carl = await newPersonWithAddress(
|
||||
session,
|
||||
'carl@example.org',
|
||||
);
|
||||
await AccountReset.db.insertRow(
|
||||
session,
|
||||
AccountReset(
|
||||
|
|
|
|||
|
|
@ -173,15 +173,7 @@ void main() {
|
|||
|
||||
test('gives an account with the address and no password (codes by '
|
||||
'mail before) the password, keeping its ID', () async {
|
||||
final ben = await newAnonymousPerson(session);
|
||||
await EmailIdentity.db.insertRow(
|
||||
session,
|
||||
EmailIdentity(
|
||||
authUserId: ben,
|
||||
addressHash: addressHash(pod, 'ben@example.org'),
|
||||
createdAt: now,
|
||||
),
|
||||
);
|
||||
final ben = await newPersonWithAddress(session, 'ben@example.org');
|
||||
|
||||
final signedUp = await signUp('ben@example.org');
|
||||
|
||||
|
|
|
|||
|
|
@ -67,9 +67,6 @@ final class _Register implements AccountRegister {
|
|||
/// When the session dismissing a warning was made, by person.
|
||||
final dismissedMadeAt = <UuidValue, DateTime?>{};
|
||||
|
||||
/// Those [setPassword] was told are anonymous on the member.
|
||||
final anonymousHere_ = <UuidValue>{};
|
||||
|
||||
/// What a password sign-in proves; null refuses it.
|
||||
PasswordProof? proof;
|
||||
|
||||
|
|
@ -209,16 +206,6 @@ final class _Register implements AccountRegister {
|
|||
return request;
|
||||
});
|
||||
|
||||
@override
|
||||
Future<void> linkGitHub(
|
||||
Session session, {
|
||||
required UuidValue person,
|
||||
required String gitHubUserId,
|
||||
}) => _answer('linkGitHub', () {
|
||||
accounts.add(person);
|
||||
gitHub[gitHubUserId] = person;
|
||||
});
|
||||
|
||||
@override
|
||||
Future<UuidValue?> accountWithGitHub(Session session, String gitHubUserId) =>
|
||||
_answer('accountWithGitHub', () => gitHub[gitHubUserId]);
|
||||
|
|
@ -477,7 +464,6 @@ final class _Register implements AccountRegister {
|
|||
UuidValue? request,
|
||||
String? code,
|
||||
bool freshGitHubSignIn = false,
|
||||
bool anonymousHere = false,
|
||||
UuidValue? keepSession,
|
||||
}) => _answer('setPassword', () {
|
||||
add(person);
|
||||
|
|
@ -485,9 +471,6 @@ final class _Register implements AccountRegister {
|
|||
if (freshGitHubSignIn) {
|
||||
freshGitHubSignIns.add(person);
|
||||
}
|
||||
if (anonymousHere) {
|
||||
anonymousHere_.add(person);
|
||||
}
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -750,7 +733,9 @@ void main() {
|
|||
late Serverpod pod;
|
||||
late _Register register;
|
||||
|
||||
/// Anonymous here, signed in on her phone.
|
||||
/// Not identified here, signed in on her phone: an anonymous account
|
||||
/// left from before (ADR 0020, D25), or one the register linked whose
|
||||
/// becoming identified here failed (the full reading's L7).
|
||||
late UuidValue anna;
|
||||
late UuidValue annasPhone;
|
||||
|
||||
|
|
@ -1095,17 +1080,7 @@ void main() {
|
|||
|
||||
expect(register.passwords, {anna});
|
||||
expect(register.freshGitHubSignIns, isEmpty);
|
||||
// Anna was anonymous here, which the register can't know (the
|
||||
// full reading's M1).
|
||||
expect(register.anonymousHere_, {anna});
|
||||
expect(await scopesOf(anna), contains(identified.name));
|
||||
expect(
|
||||
await AnonymousAccount.db.count(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(anna),
|
||||
),
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
|
|
@ -1201,16 +1176,44 @@ void main() {
|
|||
|
||||
test('begins signed in for the caller', () async {
|
||||
await endpoints.simpleLogin.begin(
|
||||
asAnna(),
|
||||
asBen(),
|
||||
back: SimpleLoginReturn.loopback,
|
||||
);
|
||||
|
||||
expect(register.simpleLoginsBegun.single, (
|
||||
marker: '$testApp-loopback',
|
||||
caller: anna,
|
||||
caller: ben,
|
||||
));
|
||||
});
|
||||
|
||||
test('refuses a caller not identified here, asking the register '
|
||||
'nothing (ADR 0020, D25)', () async {
|
||||
Matcher wrongCaller() => throwsA(
|
||||
isA<OidcRefusedException>().having(
|
||||
(error) => error.reason,
|
||||
'reason',
|
||||
OidcRefusal.wrongCaller,
|
||||
),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
endpoints.simpleLogin.begin(
|
||||
asAnna(),
|
||||
back: SimpleLoginReturn.loopback,
|
||||
),
|
||||
wrongCaller(),
|
||||
);
|
||||
await expectLater(
|
||||
endpoints.simpleLogin.complete(
|
||||
asAnna(),
|
||||
attempt: const Uuid().v7obj(),
|
||||
code: 'code',
|
||||
),
|
||||
wrongCaller(),
|
||||
);
|
||||
expect(register.asked, isEmpty);
|
||||
});
|
||||
|
||||
test('signs the account the register found in here, made a person '
|
||||
'here the first time', () async {
|
||||
final carla = const Uuid().v7obj();
|
||||
|
|
@ -1241,31 +1244,24 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
test('makes the caller identified here once the register added '
|
||||
'SimpleLogin to their account', () async {
|
||||
test("adds SimpleLogin at the register to the caller's account, "
|
||||
'who stays identified here', () async {
|
||||
register.simpleLoginProof = SimpleLoginProof(
|
||||
outcome: SimpleLoginOutcome.linked,
|
||||
);
|
||||
|
||||
final completed = await endpoints.simpleLogin.complete(
|
||||
asAnna(),
|
||||
asBen(),
|
||||
attempt: const Uuid().v7obj(),
|
||||
code: 'code',
|
||||
);
|
||||
|
||||
expect(completed.outcome, SimpleLoginOutcome.linked);
|
||||
expect(completed.session, isNull);
|
||||
expect(register.simpleLoginLinked, {anna});
|
||||
expect(await scopesOf(anna), contains(identified.name));
|
||||
expect(register.simpleLoginLinked, {ben});
|
||||
expect(await scopesOf(ben), contains(identified.name));
|
||||
expect(
|
||||
await AnonymousAccount.db.find(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(anna),
|
||||
),
|
||||
isEmpty,
|
||||
);
|
||||
expect(
|
||||
(await endpoints.account.signInWays(asAnna())).simpleLogin,
|
||||
(await endpoints.account.signInWays(asBen())).simpleLogin,
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
|
@ -1297,7 +1293,7 @@ void main() {
|
|||
|
||||
await expectLater(
|
||||
endpoints.simpleLogin.complete(
|
||||
asAnna(),
|
||||
asBen(),
|
||||
attempt: const Uuid().v7obj(),
|
||||
code: 'code',
|
||||
),
|
||||
|
|
@ -1309,7 +1305,7 @@ void main() {
|
|||
),
|
||||
),
|
||||
);
|
||||
expect(await scopesOf(anna), isNot(contains(identified.name)));
|
||||
expect(register.simpleLoginLinked, isEmpty);
|
||||
});
|
||||
|
||||
test('checks a handle for someone new at the register, the device '
|
||||
|
|
@ -1759,8 +1755,8 @@ void main() {
|
|||
expect(register.deletedWithKeys.single?.attempt, attempt);
|
||||
});
|
||||
|
||||
test('of an anonymous person deletes it here alone, once the '
|
||||
'register says it has no such account', () async {
|
||||
test('of an anonymous account left from before deletes it here '
|
||||
'alone, once the register says it has no such account', () async {
|
||||
await endpoints.account.delete(asAnna());
|
||||
|
||||
expect(register.asked, ['knownAccounts']);
|
||||
|
|
|
|||
|
|
@ -435,56 +435,62 @@ void main() {
|
|||
expect(mailbox.sent, hasLength(11));
|
||||
});
|
||||
|
||||
test("a code proves the address for a member's password, and a "
|
||||
'wrong one refuses it, keeping nothing', () async {
|
||||
test("a code to a member's person's address proves it for their "
|
||||
'password, a wrong one refusing it, and someone without a way '
|
||||
'in sets none, before the code is used', () async {
|
||||
usePasswordKeys(pod, testPasswordKeys);
|
||||
addTearDown(() => usePasswordKeys(pod, null));
|
||||
final device = await PasswordDevice.make();
|
||||
final annaOnTodo = const Uuid().v7obj();
|
||||
final annaOnTodo = await newPersonWithAddress(
|
||||
session,
|
||||
'anna@example.org',
|
||||
);
|
||||
final request = await remote.sendSignInCode(
|
||||
session,
|
||||
address: 'Anna@example.org',
|
||||
language: 'de',
|
||||
network: '203.0.113.7',
|
||||
purpose: EmailCodePurpose.signUp,
|
||||
purpose: EmailCodePurpose.firstPassword,
|
||||
);
|
||||
Future<int> annasPasswords() => AccountPassword.db.count(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(annaOnTodo),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
remote.setPassword(
|
||||
session,
|
||||
person: annaOnTodo,
|
||||
anonymousHere: true,
|
||||
setup: device.setup(),
|
||||
request: request,
|
||||
code: 'AAAA-AAAA',
|
||||
),
|
||||
refused(EmailSignInRefusal.wrongCode),
|
||||
);
|
||||
expect(await AuthUser.db.findById(session, annaOnTodo), isNull);
|
||||
// Without the member saying Anna is anonymous there, refused
|
||||
// before her code is used (the full reading's M1).
|
||||
expect(await annasPasswords(), 0);
|
||||
// Someone without a way in (an anonymous account left from
|
||||
// before, ADR 0020, D25, or a GitHub ID forgotten, the full
|
||||
// reading's M1) is refused before the code is used.
|
||||
final carlOnTodo = const Uuid().v7obj();
|
||||
await expectLater(
|
||||
remote.setPassword(
|
||||
session,
|
||||
person: annaOnTodo,
|
||||
person: carlOnTodo,
|
||||
setup: device.setup(),
|
||||
request: request,
|
||||
code: mailbox.codeFor('anna@example.org'),
|
||||
),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
expect(await AuthUser.db.findById(session, carlOnTodo), isNull);
|
||||
await remote.setPassword(
|
||||
session,
|
||||
person: annaOnTodo,
|
||||
anonymousHere: true,
|
||||
setup: device.setup(),
|
||||
request: request,
|
||||
code: mailbox.codeFor('anna@example.org'),
|
||||
);
|
||||
expect(
|
||||
(await AuthUser.db.findById(session, annaOnTodo))!.scopeNames,
|
||||
contains(identified.name),
|
||||
);
|
||||
expect(await annasPasswords(), 1);
|
||||
expect(await remote.knownAccounts(session, {annaOnTodo, ben}), {
|
||||
annaOnTodo,
|
||||
ben,
|
||||
|
|
@ -493,41 +499,11 @@ void main() {
|
|||
});
|
||||
|
||||
group('GitHub', () {
|
||||
test("links a member's person, who becomes an account under the "
|
||||
'same ID, found by it afterwards', () async {
|
||||
final annaOnTodo = const Uuid().v7obj();
|
||||
|
||||
await remote.linkGitHub(
|
||||
session,
|
||||
person: annaOnTodo,
|
||||
gitHubUserId: '2002',
|
||||
);
|
||||
|
||||
expect(await remote.accountWithGitHub(session, '2002'), annaOnTodo);
|
||||
test('finds the account a GitHub account signs into, and nobody for '
|
||||
'one nobody has', () async {
|
||||
expect(await remote.accountWithGitHub(session, '1001'), ben);
|
||||
expect(await remote.accountWithGitHub(session, '3003'), isNull);
|
||||
expect(
|
||||
(await AuthUser.db.findById(session, annaOnTodo))!.scopeNames,
|
||||
contains(identified.name),
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
"refuses another person's GitHub account, keeping nothing",
|
||||
() async {
|
||||
final annaOnTodo = const Uuid().v7obj();
|
||||
|
||||
await expectLater(
|
||||
remote.linkGitHub(
|
||||
session,
|
||||
person: annaOnTodo,
|
||||
gitHubUserId: '1001',
|
||||
),
|
||||
refused(GitHubSignInRefusal.takenByAnotherPerson),
|
||||
);
|
||||
expect(await AuthUser.db.findById(session, annaOnTodo), isNull);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('SimpleLogin (ADR 0020)', () {
|
||||
|
|
@ -1109,18 +1085,20 @@ void main() {
|
|||
'register refuses', () async {
|
||||
final device = await PasswordDevice.make();
|
||||
await atNow(() async {
|
||||
final annaOnTodo = const Uuid().v7obj();
|
||||
final annaOnTodo = await newPersonWithAddress(
|
||||
session,
|
||||
'anna@example.org',
|
||||
);
|
||||
final request = await remote.sendSignInCode(
|
||||
session,
|
||||
address: 'anna@example.org',
|
||||
language: 'en',
|
||||
network: '203.0.113.7',
|
||||
purpose: EmailCodePurpose.signUp,
|
||||
purpose: EmailCodePurpose.firstPassword,
|
||||
);
|
||||
await remote.setPassword(
|
||||
session,
|
||||
person: annaOnTodo,
|
||||
anonymousHere: true,
|
||||
setup: device.setup(),
|
||||
request: request,
|
||||
code: mailbox.codeFor('anna@example.org'),
|
||||
|
|
@ -1409,19 +1387,21 @@ void main() {
|
|||
() async {
|
||||
final device = await PasswordDevice.make();
|
||||
await atNow(() async {
|
||||
final annaOnTodo = const Uuid().v7obj();
|
||||
final annaOnTodo = await newPersonWithAddress(
|
||||
session,
|
||||
'anna@example.org',
|
||||
);
|
||||
final annasPhone = const Uuid().v7obj();
|
||||
final request = await remote.sendSignInCode(
|
||||
session,
|
||||
address: 'anna@example.org',
|
||||
language: 'en',
|
||||
network: '203.0.113.7',
|
||||
purpose: EmailCodePurpose.signUp,
|
||||
purpose: EmailCodePurpose.firstPassword,
|
||||
);
|
||||
await remote.setPassword(
|
||||
session,
|
||||
person: annaOnTodo,
|
||||
anonymousHere: true,
|
||||
setup: device.setup(),
|
||||
request: request,
|
||||
code: mailbox.codeFor('anna@example.org'),
|
||||
|
|
@ -1691,8 +1671,8 @@ void main() {
|
|||
expect(members.told, [(_todo, gone)]);
|
||||
});
|
||||
|
||||
test('tells nobody of an anonymous person, a member never having '
|
||||
'had them', () async {
|
||||
test('tells nobody of an anonymous account left from before, a member '
|
||||
'never having had it', () async {
|
||||
final anna = await newAnonymousPerson(session);
|
||||
|
||||
await remote.deleteAccount(session, anna);
|
||||
|
|
|
|||
|
|
@ -38,7 +38,9 @@ void main() {
|
|||
late Serverpod pod;
|
||||
late FakeProvider simpleLogin;
|
||||
|
||||
/// Anonymous, signed in on her phone.
|
||||
/// Signs in with a code mailed to anna@example.org, as accounts did
|
||||
/// before passwords (ADR 0017), on her phone: SimpleLogin is hers to
|
||||
/// add.
|
||||
late UuidValue anna;
|
||||
late UuidValue annasPhone;
|
||||
|
||||
|
|
@ -57,8 +59,12 @@ void main() {
|
|||
callbackUrl: Uri.parse('https://learn.test/simplelogin-callback'),
|
||||
);
|
||||
useSimpleLogin(sessionBuilder, simpleLogin);
|
||||
anna = await newAnonymousPerson(session);
|
||||
annasPhone = await signInNewDevice(session, anna);
|
||||
anna = await newPersonWithAddress(session, 'anna@example.org');
|
||||
annasPhone = await signInNewDevice(
|
||||
session,
|
||||
anna,
|
||||
scopes: {identified},
|
||||
);
|
||||
ben = (await AuthServices.instance.authUsers.create(
|
||||
session,
|
||||
scopes: {identified},
|
||||
|
|
@ -80,7 +86,7 @@ void main() {
|
|||
TestSessionBuilder asAnna() => sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
anna.uuid,
|
||||
{},
|
||||
{identified},
|
||||
authId: annasPhone.uuid,
|
||||
),
|
||||
);
|
||||
|
|
@ -431,8 +437,7 @@ void main() {
|
|||
});
|
||||
|
||||
group('signed in', () {
|
||||
test('adds SimpleLogin to the caller, who becomes identified and is '
|
||||
'anonymous no longer', () async {
|
||||
test("adds SimpleLogin to the caller's account", () async {
|
||||
final (:attempt, :code) = await allowed(asAnna(), subject: 'sl-anna');
|
||||
|
||||
final completed = await endpoints.simpleLogin.complete(
|
||||
|
|
@ -452,12 +457,45 @@ void main() {
|
|||
(await AuthUser.db.findById(session, anna))!.scopeNames,
|
||||
contains(identified.name),
|
||||
);
|
||||
expect(
|
||||
await AnonymousAccount.db.count(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(anna),
|
||||
});
|
||||
|
||||
test('refuses a caller signed in without `identified`, an anonymous '
|
||||
'account left from before (ADR 0020, D25), beginning and adding '
|
||||
'nothing', () async {
|
||||
final carl = await newAnonymousPerson(session);
|
||||
final carlsPhone = await signInNewDevice(session, carl);
|
||||
final asCarl = sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
carl.uuid,
|
||||
{},
|
||||
authId: carlsPhone.uuid,
|
||||
),
|
||||
0,
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
endpoints.simpleLogin.begin(
|
||||
asCarl,
|
||||
back: SimpleLoginReturn.loopback,
|
||||
),
|
||||
refused(OidcRefusal.wrongCaller),
|
||||
);
|
||||
// Nor does a sign-in begun signed out end on their account.
|
||||
final (:attempt, :code) = await allowed(
|
||||
notSignedIn(),
|
||||
subject: 'sl-carl',
|
||||
);
|
||||
await expectLater(
|
||||
endpoints.simpleLogin.complete(
|
||||
asCarl,
|
||||
attempt: attempt,
|
||||
code: code,
|
||||
),
|
||||
refused(OidcRefusal.wrongCaller),
|
||||
);
|
||||
expect(await identitiesOf(carl), isEmpty);
|
||||
expect(
|
||||
(await AuthUser.db.findById(session, carl))!.scopeNames,
|
||||
isNot(contains(identified.name)),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -494,13 +532,6 @@ void main() {
|
|||
refused(OidcRefusal.takenByAnotherPerson),
|
||||
);
|
||||
expect(await identitiesOf(anna), isEmpty);
|
||||
expect(
|
||||
await AnonymousAccount.db.count(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(anna),
|
||||
),
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test('refuses a second SimpleLogin identity', () async {
|
||||
|
|
@ -1362,20 +1393,6 @@ void main() {
|
|||
expect(await identitiesOf(ben), hasLength(1));
|
||||
});
|
||||
|
||||
test('no new GitHub account is linked where SimpleLogin is offered: '
|
||||
'GitHub is deprecated', () async {
|
||||
await expectLater(
|
||||
linkGitHubAccount(session, authUserId: anna, gitHubUserId: '2002'),
|
||||
throwsA(
|
||||
isA<GitHubSignInRefusedException>().having(
|
||||
(e) => e.reason,
|
||||
'reason',
|
||||
GitHubSignInRefusal.deprecated,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("forgets the attempts whose time ran out, a newcomer's too, and "
|
||||
'says how many', () async {
|
||||
await allowed(notSignedIn(), subject: 'sl-ben');
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:halkyon_base_server/base.dart';
|
||||
import 'package:halkyon_oidc_server/testing.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
|
|
@ -102,7 +103,28 @@ Future<UuidValue> signInNewDevice(
|
|||
return (await sessionIds()).difference(before).single;
|
||||
}
|
||||
|
||||
/// A person as an app's first launch creates them: anonymous.
|
||||
/// A person as codes by mail left them before passwords (ADR 0017):
|
||||
/// identified, with [address] as their only way in, and no password.
|
||||
Future<UuidValue> newPersonWithAddress(Session session, String address) async {
|
||||
final person = (await AuthServices.instance.authUsers.create(
|
||||
session,
|
||||
scopes: {identified},
|
||||
)).id;
|
||||
// INSERT INTO "email_identity" ("authUserId", "addressHash", "createdAt")
|
||||
// VALUES ($1, $2, $3)
|
||||
await EmailIdentity.db.insertRow(
|
||||
session,
|
||||
EmailIdentity(
|
||||
authUserId: person,
|
||||
addressHash: addressHash(session.serverpod, address),
|
||||
createdAt: clock.now(),
|
||||
),
|
||||
);
|
||||
return person;
|
||||
}
|
||||
|
||||
/// An anonymous account, as apps made them on their first start until
|
||||
/// ADR 0020, D25, left from before.
|
||||
Future<UuidValue> newAnonymousPerson(
|
||||
Session session, {
|
||||
DateTime? createdAt,
|
||||
|
|
|
|||
|
|
@ -202,14 +202,10 @@ void withServerpod(
|
|||
class TestEndpoints {
|
||||
late final _AccountEndpoint account;
|
||||
|
||||
late final _AnonymousIdpEndpoint anonymousIdp;
|
||||
|
||||
late final _EmailCodeEndpoint emailCode;
|
||||
|
||||
late final _GitHubSignInEndpoint gitHubSignIn;
|
||||
|
||||
late final _GitHubUpgradeEndpoint gitHubUpgrade;
|
||||
|
||||
late final _InstanceEndpoint instance;
|
||||
|
||||
late final _KeysEndpoint keys;
|
||||
|
|
@ -240,10 +236,6 @@ class _InternalTestEndpoints extends TestEndpoints
|
|||
endpoints,
|
||||
serializationManager,
|
||||
);
|
||||
anonymousIdp = _AnonymousIdpEndpoint(
|
||||
endpoints,
|
||||
serializationManager,
|
||||
);
|
||||
emailCode = _EmailCodeEndpoint(
|
||||
endpoints,
|
||||
serializationManager,
|
||||
|
|
@ -252,10 +244,6 @@ class _InternalTestEndpoints extends TestEndpoints
|
|||
endpoints,
|
||||
serializationManager,
|
||||
);
|
||||
gitHubUpgrade = _GitHubUpgradeEndpoint(
|
||||
endpoints,
|
||||
serializationManager,
|
||||
);
|
||||
instance = _InstanceEndpoint(
|
||||
endpoints,
|
||||
serializationManager,
|
||||
|
|
@ -371,48 +359,6 @@ class _AccountEndpoint {
|
|||
}
|
||||
}
|
||||
|
||||
class _AnonymousIdpEndpoint {
|
||||
_AnonymousIdpEndpoint(
|
||||
this._endpointDispatch,
|
||||
this._serializationManager,
|
||||
);
|
||||
|
||||
final _is.EndpointDispatch _endpointDispatch;
|
||||
|
||||
final _is.SerializationManager _serializationManager;
|
||||
|
||||
_ida.Future<_iacs.AuthSuccess> login(
|
||||
_ist.TestSessionBuilder sessionBuilder, {
|
||||
String? token,
|
||||
}) async {
|
||||
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
|
||||
var _localUniqueSession =
|
||||
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
|
||||
endpoint: 'anonymousIdp',
|
||||
method: 'login',
|
||||
);
|
||||
try {
|
||||
var _localCallContext = await _endpointDispatch.getMethodCallContext(
|
||||
createSessionCallback: (_) => _localUniqueSession,
|
||||
endpointPath: 'anonymousIdp',
|
||||
methodName: 'login',
|
||||
parameters: _ist.testObjectToJson({'token': token}),
|
||||
serializationManager: _serializationManager,
|
||||
);
|
||||
var _localReturnValue =
|
||||
await (_localCallContext.method.call(
|
||||
_localUniqueSession,
|
||||
_localCallContext.arguments,
|
||||
)
|
||||
as _ida.Future<_iacs.AuthSuccess>);
|
||||
return _localReturnValue;
|
||||
} finally {
|
||||
await _localUniqueSession.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _EmailCodeEndpoint {
|
||||
_EmailCodeEndpoint(
|
||||
this._endpointDispatch,
|
||||
|
|
@ -509,54 +455,6 @@ class _GitHubSignInEndpoint {
|
|||
}
|
||||
}
|
||||
|
||||
class _GitHubUpgradeEndpoint {
|
||||
_GitHubUpgradeEndpoint(
|
||||
this._endpointDispatch,
|
||||
this._serializationManager,
|
||||
);
|
||||
|
||||
final _is.EndpointDispatch _endpointDispatch;
|
||||
|
||||
final _is.SerializationManager _serializationManager;
|
||||
|
||||
_ida.Future<void> upgrade(
|
||||
_ist.TestSessionBuilder sessionBuilder, {
|
||||
required String code,
|
||||
required String codeVerifier,
|
||||
required String redirectUri,
|
||||
}) async {
|
||||
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
|
||||
var _localUniqueSession =
|
||||
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
|
||||
endpoint: 'gitHubUpgrade',
|
||||
method: 'upgrade',
|
||||
);
|
||||
try {
|
||||
var _localCallContext = await _endpointDispatch.getMethodCallContext(
|
||||
createSessionCallback: (_) => _localUniqueSession,
|
||||
endpointPath: 'gitHubUpgrade',
|
||||
methodName: 'upgrade',
|
||||
parameters: _ist.testObjectToJson({
|
||||
'code': code,
|
||||
'codeVerifier': codeVerifier,
|
||||
'redirectUri': redirectUri,
|
||||
}),
|
||||
serializationManager: _serializationManager,
|
||||
);
|
||||
var _localReturnValue =
|
||||
await (_localCallContext.method.call(
|
||||
_localUniqueSession,
|
||||
_localCallContext.arguments,
|
||||
)
|
||||
as _ida.Future<void>);
|
||||
return _localReturnValue;
|
||||
} finally {
|
||||
await _localUniqueSession.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _InstanceEndpoint {
|
||||
_InstanceEndpoint(
|
||||
this._endpointDispatch,
|
||||
|
|
|
|||
Loading…
Reference in a new issue