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>
This commit is contained in:
parent
80fa7b9628
commit
af1de799f4
1 changed files with 17704 additions and 3 deletions
|
|
@ -0,0 +1,282 @@
|
|||
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),
|
||||
);
|
||||
Loading…
Reference in a new issue