chore: Halkyon Learn moves into apps/learn
Its server, client, app and curator, and the tools that write its assets (maps, Anki fixtures, icons), as they are; paths are fixed in the commits that follow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
11abd9afce
commit
649499be23
2 changed files with 0 additions and 0 deletions
|
|
@ -0,0 +1,192 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_base_server/base.dart';
|
||||
import 'package:halkyon_learn_server/src/asset/asset_endpoint.dart';
|
||||
import 'package:halkyon_learn_server/src/generated/protocol.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_auth_idp_server/core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../support/auth_fixtures.dart';
|
||||
import '../test_tools/serverpod_test_tools.dart';
|
||||
|
||||
void main() {
|
||||
// The account endpoint, its refusals and the order of a deletion are
|
||||
// halkyon_base's, tested there (ADR 0014); here, that Learn's own data of a
|
||||
// person goes with them, through the hook Learn configures, and nobody
|
||||
// else's.
|
||||
withServerpod('Deleting an account', (sessionBuilder, _) {
|
||||
late Session session;
|
||||
late UuidValue anna;
|
||||
late UuidValue annasStudyset;
|
||||
late UuidValue ben;
|
||||
late UuidValue bensStudyset;
|
||||
|
||||
setUp(() async {
|
||||
useLearnBase(sessionBuilder);
|
||||
session = sessionBuilder.build();
|
||||
anna = await _newAnonymousPerson(session);
|
||||
await signInNewDevice(session, anna);
|
||||
annasStudyset = await _studysetOf(session, anna);
|
||||
ben = await _newAnonymousPerson(session);
|
||||
await signInNewDevice(session, ben);
|
||||
bensStudyset = await _studysetOf(session, ben);
|
||||
});
|
||||
|
||||
test('deletes the person with everything they have, and nobody '
|
||||
"else's", () async {
|
||||
final annasFile = await _fileOf(session, anna);
|
||||
final bensFile = await _fileOf(session, ben);
|
||||
await FileAllowance.db.insert(session, [
|
||||
FileAllowance(authUserId: anna, maxBytes: 1),
|
||||
FileAllowance(authUserId: ben, maxBytes: 1),
|
||||
]);
|
||||
|
||||
await deletePerson(session, anna);
|
||||
|
||||
expect(await AuthUser.db.findById(session, anna), isNull);
|
||||
expect(await _sessionsOf(session, anna), 0);
|
||||
expect(await _isAnonymous(session, anna), isFalse);
|
||||
expect(await _studysetExists(session, annasStudyset), isFalse);
|
||||
expect(await _fileExists(session, annasFile), isFalse);
|
||||
|
||||
expect(await AuthUser.db.findById(session, ben), isNotNull);
|
||||
expect(await _sessionsOf(session, ben), 1);
|
||||
expect(await _studysetExists(session, bensStudyset), isTrue);
|
||||
expect(await _fileExists(session, bensFile), isTrue);
|
||||
expect(
|
||||
[
|
||||
for (final allowance in await FileAllowance.db.find(
|
||||
session,
|
||||
where: (t) => t.authUserId.inSet({anna, ben}),
|
||||
))
|
||||
allowance.authUserId,
|
||||
],
|
||||
[ben],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// halkyon_base's pruning, with Learn's deletion as it runs on the server.
|
||||
withServerpod('Pruning abandoned anonymous people', (sessionBuilder, _) {
|
||||
late Session session;
|
||||
final now = DateTime.now().toUtc();
|
||||
DateTime daysAgo(int days) => now.subtract(Duration(days: days));
|
||||
|
||||
setUp(() {
|
||||
useLearnBase(sessionBuilder);
|
||||
session = sessionBuilder.build();
|
||||
});
|
||||
|
||||
test(
|
||||
'deletes anonymous people unused for 90 days, and only them',
|
||||
() 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);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// A person as the app's first launch creates them: anonymous.
|
||||
Future<UuidValue> _newAnonymousPerson(
|
||||
Session session, {
|
||||
DateTime? createdAt,
|
||||
}) async {
|
||||
final person = (await AuthServices.instance.authUsers.create(session)).id;
|
||||
await AnonymousAccount.db.insertRow(
|
||||
session,
|
||||
AnonymousAccount(authUserId: person, createdAt: createdAt),
|
||||
);
|
||||
return person;
|
||||
}
|
||||
|
||||
/// 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));
|
||||
}
|
||||
|
||||
/// A studyset in [person]'s personal space, written the way the sync engine
|
||||
/// stores it; returns its id.
|
||||
Future<UuidValue> _studysetOf(Session session, UuidValue person) async {
|
||||
// INSERT INTO "offline_sync_spaces" ("uuidSpaceId") VALUES ($1) RETURNING "id"
|
||||
final space = await session.db.unsafeQuery(
|
||||
'INSERT INTO "offline_sync_spaces" ("uuidSpaceId") '
|
||||
'VALUES (@person::uuid) RETURNING "id"',
|
||||
parameters: QueryParameters.named({'person': person.uuid}),
|
||||
);
|
||||
// INSERT INTO "studyset" ("spaceId", "payload") VALUES ($1, $2) RETURNING "id"
|
||||
final studyset = await session.db.unsafeQuery(
|
||||
'INSERT INTO "studyset" ("spaceId", "payload") '
|
||||
r"VALUES (@space, '\x7b7d'::bytea) RETURNING "
|
||||
'"id"',
|
||||
parameters: QueryParameters.named({'space': space.first.first}),
|
||||
);
|
||||
return UuidValue.withValidation(studyset.first.first.toString());
|
||||
}
|
||||
|
||||
/// A file stored in [person]'s space, as an upload leaves it; returns its
|
||||
/// path.
|
||||
Future<String> _fileOf(Session session, UuidValue person) async {
|
||||
final path = assetPath(person, '0' * 64);
|
||||
await session.storage.storeFile(
|
||||
storageId: assetStorage,
|
||||
path: path,
|
||||
byteData: ByteData(1),
|
||||
);
|
||||
return path;
|
||||
}
|
||||
|
||||
Future<bool> _fileExists(Session session, String path) =>
|
||||
session.storage.fileExists(storageId: assetStorage, path: path);
|
||||
|
||||
Future<bool> _studysetExists(Session session, UuidValue id) async =>
|
||||
(await session.db.unsafeQuery(
|
||||
'SELECT 1 FROM "studyset" WHERE "id" = @id::uuid',
|
||||
parameters: QueryParameters.named({'id': id.uuid}),
|
||||
)).isNotEmpty;
|
||||
|
||||
Future<int> _sessionsOf(Session session, UuidValue person) =>
|
||||
ServerSideSession.db.count(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(person),
|
||||
);
|
||||
|
||||
Future<bool> _isAnonymous(Session session, UuidValue person) async =>
|
||||
await AnonymousAccount.db.count(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(person),
|
||||
) >
|
||||
0;
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
import 'package:halkyon_base_server/base.dart';
|
||||
import 'package:halkyon_learn_server/src/generated/protocol.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_auth_idp_server/core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../support/auth_fixtures.dart';
|
||||
import '../test_tools/serverpod_test_tools.dart';
|
||||
|
||||
void main() {
|
||||
withServerpod('Your devices', (sessionBuilder, endpoints) {
|
||||
late UuidValue alice;
|
||||
late UuidValue aliceLaptop;
|
||||
late UuidValue alicePhone;
|
||||
late UuidValue bob;
|
||||
late UuidValue bobPhone;
|
||||
|
||||
setUp(() async {
|
||||
useLearnBase(sessionBuilder);
|
||||
final session = sessionBuilder.build();
|
||||
final authUsers = AuthServices.instance.authUsers;
|
||||
alice = (await authUsers.create(session, scopes: {identified})).id;
|
||||
bob = (await authUsers.create(session, scopes: {identified})).id;
|
||||
aliceLaptop = await signInNewDevice(session, alice, scopes: {identified});
|
||||
alicePhone = await signInNewDevice(session, alice, scopes: {identified});
|
||||
bobPhone = await signInNewDevice(session, bob, scopes: {identified});
|
||||
});
|
||||
|
||||
/// A caller signed in as [person] on [device], identified unless [scopes]
|
||||
/// says otherwise.
|
||||
TestSessionBuilder signedIn(
|
||||
UuidValue person,
|
||||
UuidValue device, {
|
||||
Set<Scope>? scopes,
|
||||
}) => sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
person.uuid,
|
||||
scopes ?? {identified},
|
||||
authId: device.uuid,
|
||||
),
|
||||
);
|
||||
|
||||
test('refuses anonymous people', () async {
|
||||
await expectLater(
|
||||
endpoints.devices.list(signedIn(alice, aliceLaptop, scopes: {})),
|
||||
throwsA(isA<ServerpodInsufficientAccessException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('refuses callers who are not signed in', () async {
|
||||
await expectLater(
|
||||
endpoints.devices.list(
|
||||
sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.unauthenticated(),
|
||||
),
|
||||
),
|
||||
throwsA(isA<ServerpodUnauthenticatedException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('lists only your own devices, and marks the one asking', () async {
|
||||
final devices = await endpoints.devices.list(
|
||||
signedIn(alice, aliceLaptop),
|
||||
);
|
||||
|
||||
expect(
|
||||
devices.map((device) => device.sessionId),
|
||||
unorderedEquals([aliceLaptop, alicePhone]),
|
||||
);
|
||||
expect(
|
||||
devices.singleWhere((device) => device.isThisDevice).sessionId,
|
||||
aliceLaptop,
|
||||
);
|
||||
});
|
||||
|
||||
test('signs out one of your devices', () async {
|
||||
await endpoints.devices.signOut(signedIn(alice, aliceLaptop), alicePhone);
|
||||
|
||||
final devices = await endpoints.devices.list(
|
||||
signedIn(alice, aliceLaptop),
|
||||
);
|
||||
expect(devices.map((device) => device.sessionId), [aliceLaptop]);
|
||||
});
|
||||
|
||||
test(
|
||||
"refuses to sign out someone else's device, exactly like a missing one",
|
||||
() async {
|
||||
final notFound = throwsA(
|
||||
isA<NotFoundException>().having((e) => e.what, 'what', 'device'),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
endpoints.devices.signOut(signedIn(alice, aliceLaptop), bobPhone),
|
||||
notFound,
|
||||
);
|
||||
await expectLater(
|
||||
endpoints.devices.signOut(
|
||||
signedIn(alice, aliceLaptop),
|
||||
const Uuid().v4obj(),
|
||||
),
|
||||
notFound,
|
||||
);
|
||||
|
||||
final bobsDevices = await endpoints.devices.list(
|
||||
signedIn(bob, bobPhone),
|
||||
);
|
||||
expect(bobsDevices.map((device) => device.sessionId), [bobPhone]);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue