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,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,
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -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('<script>Alice</script>'));
|
||||
expect(page.html, isNot(contains('<script>')));
|
||||
expect(page.html, contains('Learns Japanese & 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&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')}',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue