halkyon/packages/halkyon_base_server/test/integration/auth/account_test.dart
Tizian 「ティツィアーン」 af1de799f4
halkyon_base: a Serverpod module for sign-in, accounts and instance basics
What every Halkyon server has, taken from Halkyon Learn's: anonymous,
GitHub and email-code sign-in with their endpoints, an account's ways
in and its deletion, the pruning of abandoned anonymous people, of
sign-up attempts and of expired codes, and the instance's basics (the
sign-in ways offered, the source link, the minimum app build, where
people's data and its backups are). Endpoints sit under the nickname
`base`. A host configures it in one place before its pod starts: its
name for the mails, its source link, its minimum build, and hooks that
delete its own data with a person.

The tables keep Learn's names, columns and indexes, so a host moving
onto the module migrates nothing but version rows. Server package under
the SSPL, client under the AGPL, as Learn's are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-19 14:29:28 +02:00

282 lines
8.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(),
),
]);
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: 'anonymous',
source: 'account_creation',
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 abandoned anonymous people', (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(
'deletes anonymous people unused for 90 days, and only them, 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 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));
final pruned = await pruneAbandonedAnonymousPeople(session, now: now);
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);
}
expect(deletedByHost, unorderedEquals([unused, neverSignedIn]));
},
);
});
}
/// 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),
);