halkyon/packages/halkyon_base_server/test/integration/auth/account_test.dart
Tizian 「ティツィアーン」 4e33133733
feat(base,app)!: GitHub signs nobody in any more (ADR 0020, D36)
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>
2026-10-26 07:16:39 +01:00

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),
);