Every account signs in with SimpleLogin or a password now, so GitHub goes from the module, the shared app package, Learn and Todo, ahead of ADR 0017's GITHUB_SIGN_IN_ENDS. - halkyon_base: no `gitHubSignIn` endpoint, GitHub identity provider or GitHub refusals; no `githubClientId`/`githubClientSecret`, no `GITHUB_SIGN_IN_ENDS` and no `forgetGitHubAccounts`; the register loses `account-with-github`. `SignInWays.gitHub`, the basics' `gitHubClientId` and `gitHubSignInEnds`, and `PasswordRefusal`'s `signInAgain` and `noAddress` go. A first password takes a code mailed to the person's own address; an account without one sets none (`wrongCredentials`). - Learn's server: `instance.info()` without `gitHubClientId`, the pruning without GitHub, and `minimumAppBuild` 24, as builds up to 23 (v0.20.0) require `SignInWays.gitHub`: the server deploys with the release of build 24. - halkyon_app: no GitHub button or problems on the start screen, no password offered after a GitHub sign-in, and `ServerpodAuthGateway` without the browser half only GitHub used (`(client, base:)`). The account section offers a first password only to an account with an address. `LoopbackWebAuth` and the apps' schemes stay, SimpleLogin's ways back. - Learn's Profile lists no GitHub; the texts go (English, German). - Serverpod's `serverpod_auth_idp_github_account` stays, as its migrations make it; a row goes with its account, and ADR 0020's D36 gives operators the SQL to delete those left. - deploy/: compose.yaml and .env.example without GitHub. The READMEs, AGENTS.md files and ADR 0017 amended. BREAKING CHANGE: builds up to 23 can't read the instance's answers about an account's ways in; minimumAppBuild is 24. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
311 lines
9.7 KiB
Dart
311 lines
9.7 KiB
Dart
import 'package:halkyon_base_server/base.dart';
|
|
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('Deleting an account', (sessionBuilder, endpoints) {
|
|
late Session session;
|
|
late UuidValue anna;
|
|
late UuidValue annasPhone;
|
|
late UuidValue ben;
|
|
|
|
/// What the host's deletions saw, in the order they ran.
|
|
late List<String> seen;
|
|
|
|
/// The host's deletions: each notes what it finds of the person while it
|
|
/// runs, and what runs once the deletion holds.
|
|
List<PersonDataDeletion> noting(String name) => [
|
|
(session, person, transaction) async {
|
|
final sessions = await ServerSideSession.db.count(
|
|
session,
|
|
where: (t) => t.authUserId.equals(person),
|
|
transaction: transaction,
|
|
);
|
|
final stillThere = await AuthUser.db.findById(
|
|
session,
|
|
person,
|
|
transaction: transaction,
|
|
);
|
|
seen.add(
|
|
'$name: $sessions sessions, '
|
|
'person ${stillThere == null ? 'gone' : 'there'}',
|
|
);
|
|
return () async {
|
|
final gone = await AuthUser.db.findById(session, person) == null;
|
|
seen.add('after $name: person ${gone ? 'gone' : 'there'}');
|
|
};
|
|
},
|
|
];
|
|
|
|
Future<void> deleteAnna() => endpoints.account.delete(
|
|
sessionBuilder.copyWith(
|
|
authentication: AuthenticationOverride.authenticationInfo(
|
|
anna.uuid,
|
|
{},
|
|
authId: annasPhone.uuid,
|
|
),
|
|
),
|
|
);
|
|
|
|
setUp(() async {
|
|
seen = [];
|
|
useBase(
|
|
sessionBuilder,
|
|
deletePersonData: [...noting('first'), ...noting('second')],
|
|
);
|
|
session = sessionBuilder.build();
|
|
anna = await newAnonymousPerson(session);
|
|
annasPhone = await signInNewDevice(session, anna);
|
|
await signInNewDevice(session, anna);
|
|
ben = await newAnonymousPerson(session);
|
|
await signInNewDevice(session, ben);
|
|
});
|
|
|
|
test('refuses callers who are not signed in', () async {
|
|
await expectLater(
|
|
endpoints.account.delete(
|
|
sessionBuilder.copyWith(
|
|
authentication: AuthenticationOverride.unauthenticated(),
|
|
),
|
|
),
|
|
throwsA(isA<ServerpodUnauthenticatedException>()),
|
|
);
|
|
expect(seen, isEmpty);
|
|
});
|
|
|
|
test('deletes the caller with their sessions and ways in, and nobody '
|
|
'else', () async {
|
|
await EmailIdentity.db.insert(session, [
|
|
for (final (person, hash) in [(anna, 'a' * 64), (ben, 'b' * 64)])
|
|
EmailIdentity(
|
|
authUserId: person,
|
|
addressHash: hash,
|
|
createdAt: DateTime.now().toUtc(),
|
|
),
|
|
]);
|
|
// A GitHub ID left from before (ADR 0020, D36) goes too.
|
|
await GitHubAccount.db.insertRow(
|
|
session,
|
|
GitHubAccount(userIdentifier: '4711', authUserId: anna),
|
|
);
|
|
|
|
await deleteAnna();
|
|
|
|
expect(await AuthUser.db.findById(session, anna), isNull);
|
|
expect(await _sessionsOf(session, anna), 0);
|
|
expect(await _anonymousAccountsOf(session, anna), 0);
|
|
expect(
|
|
await EmailIdentity.db.count(
|
|
session,
|
|
where: (t) => t.authUserId.equals(anna),
|
|
),
|
|
0,
|
|
);
|
|
expect(
|
|
await GitHubAccount.db.count(
|
|
session,
|
|
where: (t) => t.authUserId.equals(anna),
|
|
),
|
|
0,
|
|
);
|
|
|
|
expect(await AuthUser.db.findById(session, ben), isNotNull);
|
|
expect(await _sessionsOf(session, ben), 1);
|
|
expect(await _anonymousAccountsOf(session, ben), 1);
|
|
expect(
|
|
await EmailIdentity.db.count(
|
|
session,
|
|
where: (t) => t.authUserId.equals(ben),
|
|
),
|
|
1,
|
|
);
|
|
});
|
|
|
|
test("runs the host's deletions in order, once the sessions ended and "
|
|
'before the person goes, and what they leave for afterwards once '
|
|
'it holds', () async {
|
|
await deleteAnna();
|
|
|
|
expect(seen, [
|
|
'first: 0 sessions, person there',
|
|
'second: 0 sessions, person there',
|
|
'after first: person gone',
|
|
'after second: person gone',
|
|
]);
|
|
});
|
|
|
|
test("leaves everything as it was when a host's deletion fails, and "
|
|
'runs nothing afterwards', () async {
|
|
useBase(
|
|
sessionBuilder,
|
|
deletePersonData: [
|
|
...noting('first'),
|
|
(_, _, _) async => throw StateError('the host failed'),
|
|
],
|
|
);
|
|
|
|
await expectLater(deleteAnna(), throwsA(isA<StateError>()));
|
|
|
|
expect(await AuthUser.db.findById(session, anna), isNotNull);
|
|
expect(await _sessionsOf(session, anna), 2);
|
|
expect(await _anonymousAccountsOf(session, anna), 1);
|
|
expect(seen, ['first: 0 sessions, person there']);
|
|
});
|
|
});
|
|
|
|
withServerpod('Forgetting sign-up attempts', (sessionBuilder, _) {
|
|
late Session session;
|
|
final now = DateTime.now().toUtc();
|
|
|
|
setUp(() => session = sessionBuilder.build());
|
|
|
|
test('deletes those the rate limit is done with, with their IP '
|
|
'addresses, and keeps the rest', () async {
|
|
Future<UuidValue> attempt(Duration ago) async =>
|
|
(await RateLimitedRequestAttempt.db.insertRow(
|
|
session,
|
|
RateLimitedRequestAttempt(
|
|
domain: 'password_sign_in',
|
|
source: 'proofs_per_address',
|
|
key: '203.0.113.7',
|
|
ipAddress: '203.0.113.7',
|
|
attemptedAt: now.subtract(ago),
|
|
),
|
|
)).id!;
|
|
final old = await attempt(const Duration(days: 3));
|
|
final done = await attempt(const Duration(minutes: 61));
|
|
final recent = await attempt(const Duration(minutes: 59));
|
|
|
|
// Others may have been left by earlier runs.
|
|
expect(
|
|
await forgetOldSignUpAttempts(session, now: now),
|
|
greaterThanOrEqualTo(2),
|
|
);
|
|
|
|
for (final forgotten in [old, done]) {
|
|
expect(
|
|
await RateLimitedRequestAttempt.db.findById(session, forgotten),
|
|
isNull,
|
|
);
|
|
}
|
|
expect(
|
|
await RateLimitedRequestAttempt.db.findById(session, recent),
|
|
isNotNull,
|
|
);
|
|
});
|
|
});
|
|
|
|
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));
|
|
|
|
/// Whom the host's deletion was asked to delete.
|
|
late List<UuidValue> deletedByHost;
|
|
|
|
setUp(() {
|
|
deletedByHost = [];
|
|
useBase(
|
|
sessionBuilder,
|
|
deletePersonData: [
|
|
(_, person, _) async {
|
|
deletedByHost.add(person);
|
|
return null;
|
|
},
|
|
],
|
|
);
|
|
session = sessionBuilder.build();
|
|
});
|
|
|
|
test('the module signs nobody in anonymously', () {
|
|
expect(
|
|
session.serverpod.endpoints.getConnectorByName('anonymousIdp'),
|
|
isNull,
|
|
);
|
|
});
|
|
|
|
test(
|
|
'deletes every anonymous account left, however new or lately used, '
|
|
"with the host's data of theirs",
|
|
() async {
|
|
final old = await newAnonymousPerson(session, createdAt: daysAgo(100));
|
|
final recentlyUsed = await newAnonymousPerson(
|
|
session,
|
|
createdAt: daysAgo(100),
|
|
);
|
|
await _lastUsed(session, recentlyUsed, daysAgo(1));
|
|
final newcomer = await newAnonymousPerson(session);
|
|
await signInNewDevice(session, newcomer);
|
|
|
|
// Others may have been left by earlier runs.
|
|
expect(await pruneAnonymousPeople(session), greaterThanOrEqualTo(3));
|
|
|
|
for (final gone in [old, recentlyUsed, newcomer]) {
|
|
expect(await AuthUser.db.findById(session, gone), isNull);
|
|
expect(await _sessionsOf(session, gone), 0);
|
|
}
|
|
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);
|
|
});
|
|
});
|
|
}
|
|
|
|
/// Signs [person] in on a device last used at [at].
|
|
Future<void> _lastUsed(Session session, UuidValue person, DateTime at) async {
|
|
final device = await signInNewDevice(session, person);
|
|
final row = await ServerSideSession.db.findById(session, device);
|
|
await ServerSideSession.db.updateRow(session, row!.copyWith(lastUsedAt: at));
|
|
}
|
|
|
|
Future<int> _sessionsOf(Session session, UuidValue person) =>
|
|
ServerSideSession.db.count(
|
|
session,
|
|
where: (t) => t.authUserId.equals(person),
|
|
);
|
|
|
|
Future<int> _anonymousAccountsOf(Session session, UuidValue person) =>
|
|
AnonymousAccount.db.count(
|
|
session,
|
|
where: (t) => t.authUserId.equals(person),
|
|
);
|