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:
Tizian 「ティツィアーン」 2026-10-19 20:08:06 +02:00
commit 649499be23
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 0 additions and 0 deletions

View file

@ -0,0 +1,365 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:clock/clock.dart';
import 'package:halkyon_base_server/base.dart';
import 'package:halkyon_learn_server/src/generated/protocol.dart';
import 'package:serverpod/protocol.dart' show CloudStorageEntry;
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(
'Public profiles',
rollbackDatabase: RollbackDatabase.disabled,
(sessionBuilder, endpoints) {
late Session session;
setUp(() {
useLearnBase(sessionBuilder);
session = sessionBuilder.build();
});
tearDown(
() => PublicProfile.db.deleteWhere(session, where: (t) => t.id > 0),
);
/// A person with an identity, signed in.
TestSessionBuilder identifiedPerson() => sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
const Uuid().v7(),
{identified},
),
);
final alice = identifiedPerson();
final bob = identifiedPerson();
Future<Profile> save(
TestSessionBuilder person,
String handle, {
String displayName = '',
String bio = '',
List<String> links = const [],
}) => endpoints.profile.save(person, handle, displayName, bio, links);
Matcher refused(ProfileRefusal reason) => throwsA(
isA<ProfileRefusedException>().having(
(error) => error.reason,
'reason',
reason,
),
);
test('a person with an identity makes a profile, found by its handle '
'whatever its case, at the account domain', () async {
final saved = await save(
alice,
'Raisondetredev',
displayName: ' Tizian ',
bio: 'Learning Japanese.',
links: [' https://halkyon.app ', ''],
);
expect(saved.handle, 'Raisondetredev');
expect(saved.address, startsWith('Raisondetredev@'));
expect(saved.displayName, 'Tizian');
expect(saved.links, ['https://halkyon.app']);
expect(saved.handleFixed, isFalse);
// Generated classes compare by identity: their JSON by value.
expect((await endpoints.profile.mine(alice))!.toJson(), saved.toJson());
expect(
(await endpoints.profiles.byHandle(bob, 'raisondetredev')).toJson(),
saved.toJson(),
);
});
test('an anonymous person has no profile to make or read', () async {
final anonymous = sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
const Uuid().v7(),
{},
),
);
await expectLater(
save(anonymous, 'nobody'),
throwsA(isA<ServerpodInsufficientAccessException>()),
);
await expectLater(
endpoints.profile.mine(anonymous),
throwsA(isA<ServerpodInsufficientAccessException>()),
);
await expectLater(
endpoints.profile.checkHandle(anonymous, 'nobody'),
throwsA(isA<ServerpodInsufficientAccessException>()),
);
expect(await PublicProfile.db.count(session), 0);
});
test('a handle is checked as saving would check it: free, or your '
'own, whatever its case; else why not', () async {
await save(alice, 'alice');
Future<ProfileRefusal?> check(String handle) =>
endpoints.profile.checkHandle(bob, handle);
expect(await check('bob'), isNull);
expect(await check('ALICE'), ProfileRefusal.handleTaken);
expect(await check('a-b'), ProfileRefusal.handleInvalid);
expect(await check('Admin'), ProfileRefusal.handleReserved);
expect(
await endpoints.profile.checkHandle(alice, 'Alice'),
isNull,
);
// Checking makes nothing.
expect(await endpoints.profile.mine(bob), isNull);
});
test('nobody signed in reads profiles', () async {
await save(alice, 'alice');
await expectLater(
endpoints.profiles.byHandle(sessionBuilder, 'alice'),
throwsA(isA<ServerpodUnauthenticatedException>()),
);
});
test('a handle someone else has is refused, whatever its case; their '
'profile stays theirs', () async {
await save(alice, 'alice', displayName: 'Alice');
await expectLater(
save(bob, 'ALICE'),
refused(ProfileRefusal.handleTaken),
);
expect((await endpoints.profile.mine(alice))!.displayName, 'Alice');
expect(await endpoints.profile.mine(bob), isNull);
});
test('handles are 1 to 30 letters, digits or underscores, not one '
'kept for the instance', () async {
for (final handle in ['', 'with space', 'é', 'a-b', 'a' * 31]) {
await expectLater(
save(alice, handle),
refused(ProfileRefusal.handleInvalid),
reason: handle,
);
}
await expectLater(
save(alice, 'Admin'),
refused(ProfileRefusal.handleReserved),
);
expect((await save(alice, 'a_1' * 10)).handle, 'a_1' * 10);
});
test('the name, bio and links stay within their bounds', () async {
await expectLater(
save(alice, 'alice', displayName: 'x' * 31),
refused(ProfileRefusal.displayNameTooLong),
);
await expectLater(
save(alice, 'alice', bio: 'x' * 501),
refused(ProfileRefusal.bioTooLong),
);
await expectLater(
save(alice, 'alice', links: List.filled(5, 'https://a.example')),
refused(ProfileRefusal.tooManyLinks),
);
for (final link in ['halkyon.app', 'ftp://a.example', 'https://']) {
await expectLater(
save(alice, 'alice', links: [link]),
refused(ProfileRefusal.linkInvalid),
reason: link,
);
}
});
test('the handle changes until the first publish; then it is fixed, '
'and the profile stays, though the rest still changes', () async {
await save(alice, 'alice');
expect((await save(alice, 'alice_jp')).handle, 'alice_jp');
final row = (await PublicProfile.db.findFirstRow(
session,
where: (t) => t.handleKey.equals('alice_jp'),
))!;
await PublicProfile.db.updateRow(
session,
row.copyWith(firstPublishedAt: clock.now().toUtc()),
);
await expectLater(
save(alice, 'alice'),
refused(ProfileRefusal.published),
);
expect(
await endpoints.profile.checkHandle(alice, 'alice'),
ProfileRefusal.published,
);
expect(
await endpoints.profile.checkHandle(alice, 'ALICE_jp'),
isNull,
);
await expectLater(
endpoints.profile.remove(alice),
refused(ProfileRefusal.published),
);
final renamed = await save(alice, 'Alice_JP', displayName: 'Alice');
expect((renamed.handle, renamed.handleFixed), ('Alice_JP', true));
});
test('removing a profile frees its handle', () async {
await save(alice, 'alice');
await endpoints.profile.remove(alice);
expect(await endpoints.profile.mine(alice), isNull);
expect((await save(bob, 'alice')).handle, 'alice');
});
test("a handle nobody has isn't found", () async {
await expectLater(
endpoints.profiles.byHandle(alice, 'nobody'),
throwsA(isA<NotFoundException>()),
);
});
test('deleting a person deletes their profile', () async {
useLearnBase(sessionBuilder);
final carol = (await AuthServices.instance.authUsers.create(session))
.id;
await endpoints.profile.save(
sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
carol.uuid,
{identified},
),
),
'carol',
'',
'',
[],
);
await deletePerson(session, carol);
expect(await PublicProfile.db.count(session), 0);
});
group('An avatar', () {
/// A PNG of one pixel, [shade] of grey.
ByteData png(int shade) {
final bytes = base64Decode(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
);
// A byte past the signature, so each shade is another file.
bytes[bytes.length - 1] = shade;
return ByteData.sublistView(bytes);
}
Future<int> stored() => CloudStorageEntry.db.count(
session,
where: (t) => t.storageId.equals('public'),
);
tearDown(
() => CloudStorageEntry.db.deleteWhere(
session,
where: (t) => t.storageId.equals('public'),
),
);
test('is shown from a public URL that names the picture, not the '
'person', () async {
await save(alice, 'alice');
final profile = await endpoints.profile.setAvatar(alice, png(1));
final url = profile.avatarUrl!;
expect(url, contains('avatars'));
final row = (await PublicProfile.db.findFirstRow(
session,
where: (t) => t.handleKey.equals('alice'),
))!;
expect(url, isNot(contains(row.authUserId.toString())));
expect(await stored(), 1);
});
test('is a PNG of at most 1 MB, nothing else', () async {
await save(alice, 'alice');
await expectLater(
endpoints.profile.setAvatar(
alice,
ByteData.sublistView(Uint8List.fromList(utf8.encode('GIF89a…'))),
),
refused(ProfileRefusal.avatarInvalid),
);
final huge = Uint8List(1024 * 1024 + 1)
..setRange(0, 8, png(1).buffer.asUint8List(0, 8));
await expectLater(
endpoints.profile.setAvatar(alice, ByteData.sublistView(huge)),
refused(ProfileRefusal.avatarInvalid),
);
expect(await stored(), 0);
});
test('needs a profile to show on', () async {
await expectLater(
endpoints.profile.setAvatar(alice, png(1)),
throwsA(isA<NotFoundException>()),
);
});
test('a new one replaces the old, whose file goes; one another '
'profile shows stays', () async {
await save(alice, 'alice');
await save(bob, 'bob');
await endpoints.profile.setAvatar(alice, png(1));
await endpoints.profile.setAvatar(bob, png(1));
await endpoints.profile.setAvatar(alice, png(2));
expect(await stored(), 2);
await endpoints.profile.setAvatar(bob, png(3));
expect(await stored(), 2);
final removed = await endpoints.profile.removeAvatar(alice);
expect(removed.avatarUrl, isNull);
expect(await stored(), 1);
});
test('goes with its person, and with the profile', () async {
useLearnBase(sessionBuilder);
final carol = (await AuthServices.instance.authUsers.create(
session,
)).id;
final asCarol = sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
carol.uuid,
{identified},
),
);
await save(asCarol, 'carol');
await endpoints.profile.setAvatar(asCarol, png(4));
await save(alice, 'alice');
await endpoints.profile.setAvatar(alice, png(5));
await deletePerson(session, carol);
expect(await stored(), 1);
await endpoints.profile.remove(alice);
expect(await stored(), 0);
});
});
test('the instance says which domain handles are on', () async {
expect(
(await endpoints.instance.info(sessionBuilder)).accountDomain,
isNotEmpty,
);
});
},
);
}

View file

@ -0,0 +1,232 @@
import 'package:halkyon_base_server/base.dart';
import 'package:halkyon_learn_server/src/generated/protocol.dart';
import 'package:halkyon_learn_server/src/profile/link_verification.dart';
import 'package:halkyon_learn_server/src/profile/profile_page.dart';
import 'package:halkyon_learn_server/src/profile/profile_rules.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
import 'package:test/test.dart';
import '../test_tools/serverpod_test_tools.dart';
/// Pages by their address, in place of the web; a link without one can't
/// be had.
final class _Web implements LinkFetcher {
final pages = <String, String>{};
@override
Future<FetchedPage?> fetch(Uri link) async => switch (pages['$link']) {
final html? => (url: link, html: html),
null => null,
};
}
void main() {
withServerpod(
'Verified profile links',
// Checks are counted by Serverpod's rate limiter, in transactions of
// their own, which the test's rollback can't wrap.
rollbackDatabase: RollbackDatabase.disabled,
(sessionBuilder, endpoints) {
late Session session;
late _Web web;
TestSessionBuilder identifiedPerson() => sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
const Uuid().v7(),
{identified},
),
);
final alice = identifiedPerson();
final bob = identifiedPerson();
setUp(() {
session = sessionBuilder.build();
web = _Web();
LinkChecks.of(session.serverpod).fetcher = web;
});
tearDown(() async {
await PublicProfile.db.deleteWhere(session, where: (t) => t.id > 0);
await RateLimitedRequestAttempt.db.deleteWhere(
session,
where: (t) => t.id.notEquals(null),
);
});
/// A page linking back to [handle]'s profile page with `rel="me"`.
String linkingBack(String handle) =>
'<a rel="me" href="${profilePageUrl(session.serverpod, handle)}">'
'My profile</a>';
Future<Profile> save(
TestSessionBuilder person,
String handle,
List<String> links,
) => endpoints.profile.save(person, handle, 'Alice', '', links);
Matcher refused(ProfileRefusal reason) => throwsA(
isA<ProfileRefusedException>().having(
(error) => error.reason,
'reason',
reason,
),
);
test('checking is refused when not signed in, and to an anonymous '
'person, who has no profile', () async {
await expectLater(
endpoints.profile.checkLinks(
sessionBuilder.copyWith(
authentication: AuthenticationOverride.unauthenticated(),
),
),
throwsA(isA<ServerpodUnauthenticatedException>()),
);
await expectLater(
endpoints.profile.checkLinks(
sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
const Uuid().v7(),
{},
),
),
),
throwsA(isA<ServerpodInsufficientAccessException>()),
);
});
test('checking marks the links whose pages link back to the profile '
"page, which others see too, beside the page's address", () async {
await save(alice, 'Alice', [
'https://alice.example/',
'https://social.example/@alice',
'https://elsewhere.example/',
]);
web.pages['https://alice.example/'] = linkingBack('alice');
web.pages['https://social.example/@alice'] =
'<a href="${profilePageUrl(session.serverpod, 'Alice')}">me</a>';
final checked = await endpoints.profile.checkLinks(alice);
expect(checked.verifiedLinks, ['https://alice.example/']);
expect(
checked.pageUrl,
'${profilePageUrl(session.serverpod, 'Alice')}',
);
expect(checked.pageUrl, endsWith('/@Alice'));
expect(
(await endpoints.profiles.byHandle(bob, 'alice')).verifiedLinks,
['https://alice.example/'],
);
});
test('saving keeps the checks of the links left as they were, and drops '
'those of links changed or removed; a new handle drops them all, as '
'the page moved', () async {
await save(alice, 'alice', [
'https://alice.example/',
'https://blog.example/',
]);
web.pages['https://alice.example/'] = linkingBack('alice');
web.pages['https://blog.example/'] = linkingBack('alice');
await endpoints.profile.checkLinks(alice);
final saved = await save(alice, 'alice', [
'https://alice.example/',
'https://blog.example/new',
]);
expect(saved.verifiedLinks, ['https://alice.example/']);
final renamed = await save(alice, 'alice2', [
'https://alice.example/',
]);
expect(renamed.verifiedLinks, isEmpty);
});
test('an eleventh check within the hour is refused', () async {
await save(alice, 'alice', ['https://alice.example/']);
for (var i = 0; i < 10; i++) {
await endpoints.profile.checkLinks(alice);
}
await expectLater(
endpoints.profile.checkLinks(alice),
refused(ProfileRefusal.tooManyLinkChecks),
);
});
group('the profile page', () {
test("shows the profile in the visitor's language, its links marked "
'rel="me", what anybody wrote escaped', () async {
await endpoints.profile.save(
alice,
'alice',
'<script>Alice</script>',
'Learns Japanese & German.\nLikes tea.',
['https://alice.example/', 'https://blog.example/?a=1&b=2'],
);
web.pages['https://alice.example/'] = linkingBack('alice');
await endpoints.profile.checkLinks(alice);
final page = await profilePage(
session,
'@ALICE',
host: profilePageUrl(session.serverpod, 'alice').host,
acceptLanguage: 'de-CH,de;q=0.9,en;q=0.5',
);
expect(page.status, 200);
expect(page.html, contains('<html lang="de">'));
expect(page.html, contains('&lt;script&gt;Alice&lt;/script&gt;'));
expect(page.html, isNot(contains('<script>')));
expect(page.html, contains('Learns Japanese &amp; German.<br>'));
expect(
page.html,
contains('<link rel="me" href="https://alice.example/">'),
);
expect(
page.html,
contains(
'<a rel="me noopener" href="https://blog.example/?a=1&amp;b=2">',
),
);
expect(page.html, contains('geprüft'));
expect(page.html, contains('alice@'));
});
test(
'is not found for a handle nobody has, or anything else',
() async {
for (final segment in ['@nobody', 'alice', '@', '@a/b']) {
final page = await profilePage(
session,
segment,
host: profilePageUrl(session.serverpod, 'x').host,
);
expect(page.status, 404, reason: segment);
}
},
);
test(
'asked for on another host, sends the visitor to its own address',
() async {
await save(alice, 'alice', const []);
final page = await profilePage(
session,
'@alice',
host: 'elsewhere.example',
);
expect(page.status, 308);
expect(
page.location,
'${profilePageUrl(session.serverpod, 'alice')}',
);
},
);
});
},
);
}