halkyon/packages/halkyon_base_server/test/integration/profile/profile_test.dart
Tizian 「ティツィアーン」 d5651c5beb
Some checks failed
CI / check (push) Failing after 30m48s
CI / deploy (push) Has been skipped
feat(base)!: removing a profile makes it private (ADR 0020, D27)
Every account keeps a handle now, so removing a public profile no
longer deletes it and frees the handle: it takes away what the profile
shows and leaves it private, as a profile is until its person goes
public (D5).

- `profile.remove` and the register's `removeProfile` (members ask the
  register) empty the display name, bio, links with their checks and
  the avatar, keep the row with its handle, and return what is left,
  null for none. `/@handle` and `profiles.byHandle` then answer as for
  a handle nobody has. Once the person has published, removing is
  refused as before.
- Learn: the profile form starts afresh from what is left, "Remove
  profile" shows only while something is filled in, and the dialog says
  the handle stays.
- The module's README and ADR 0020 amended.

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

516 lines
17 KiB
Dart

import 'dart:convert';
import 'dart:typed_data';
import 'package:clock/clock.dart';
import 'package:halkyon_base_server/base.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/fixtures.dart';
import '../test_tools/serverpod_test_tools.dart';
void main() {
withServerpod(
'Public profiles',
rollbackDatabase: RollbackDatabase.disabled,
(sessionBuilder, endpoints) {
late Session session;
setUp(() {
useBase(sessionBuilder);
session = sessionBuilder.build();
});
tearDown(() async {
await PublicProfile.db.deleteWhere(session, where: (t) => t.id > 0);
await RateLimitedRequestAttempt.db.deleteWhere(
session,
where: (t) => t.id.notEquals(null),
);
});
/// 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 makes it private: its handle stays its '
"person's, what was filled in goes, and others find it as a handle "
'nobody has (ADR 0020, D27)', () async {
await save(
alice,
'Alice',
displayName: 'Alice',
bio: 'Learning Japanese.',
links: ['https://halkyon.app'],
);
expect(
(await endpoints.profiles.byHandle(bob, 'alice')).handle,
'Alice',
);
final removed = (await endpoints.profile.remove(alice))!;
expect(
(
removed.handle,
removed.displayName,
removed.bio,
removed.avatarUrl,
removed.handleFixed,
),
('Alice', '', '', null, false),
);
expect(removed.links, isEmpty);
expect(removed.verifiedLinks ?? const <String>[], isEmpty);
expect(
(await endpoints.profile.mine(alice))!.toJson(),
removed.toJson(),
);
await expectLater(
endpoints.profiles.byHandle(bob, 'alice'),
throwsA(isA<ProfileNotFoundException>()),
);
await expectLater(
save(bob, 'alice'),
refused(ProfileRefusal.handleTaken),
);
// Filled in again, it is public again.
await save(alice, 'Alice', displayName: 'Alice');
expect(
(await endpoints.profiles.byHandle(bob, 'alice')).displayName,
'Alice',
);
});
test('removing nothing gives nothing: without a profile', () async {
expect(await endpoints.profile.remove(bob), isNull);
expect(await endpoints.profile.mine(bob), isNull);
});
test('a profile is found by its handle only once its person goes '
'public: with something published, or the profile filled in; '
'their own they see all along (D5)', () async {
Future<String?> found() async {
try {
return (await endpoints.profiles.byHandle(bob, 'alice')).handle;
} on ProfileNotFoundException {
return null;
}
}
await save(alice, 'Alice');
expect(await found(), isNull);
expect((await endpoints.profile.mine(alice))!.handle, 'Alice');
for (final (displayName, bio, links) in [
('Alice', '', const <String>[]),
('', 'Learning Japanese.', const <String>[]),
('', '', ['https://halkyon.app']),
]) {
await save(
alice,
'Alice',
displayName: displayName,
bio: bio,
links: links,
);
expect(await found(), 'Alice', reason: '$displayName$bio$links');
}
await save(alice, 'Alice');
expect(await found(), isNull);
final row = (await PublicProfile.db.findFirstRow(
session,
where: (t) => t.handleKey.equals('alice'),
))!;
await PublicProfile.db.updateRow(
session,
row.copyWith(firstPublishedAt: clock.now().toUtc()),
);
expect(await found(), 'Alice');
});
group('Answers about handles', () {
Future<ProfileRefusal?> check(
TestSessionBuilder person,
String handle,
) => endpoints.profile.checkHandle(person, handle);
test('are at most 30 an hour for a person: checking a handle and '
'saving a new one count, a refused one neither', () async {
await save(alice, 'alice');
for (var i = 0; i < 29; i++) {
await check(bob, 'bob$i');
}
await save(bob, 'bob');
await expectLater(
check(bob, 'alice'),
refused(ProfileRefusal.tooManyHandleChecks),
);
await expectLater(
save(bob, 'alice'),
refused(ProfileRefusal.tooManyHandleChecks),
);
expect((await endpoints.profile.mine(bob))!.handle, 'bob');
// Saving their own handle again, whatever its case, answers nothing
// new, nor does checking it, or a handle that is none.
expect(
(await save(bob, 'Bob', displayName: 'Bob')).displayName,
'Bob',
);
expect(await check(bob, 'BOB'), isNull);
expect(await check(bob, 'a-b'), ProfileRefusal.handleInvalid);
expect(await check(bob, 'admin'), ProfileRefusal.handleReserved);
// Others ask on.
expect(await check(alice, 'carla'), isNull);
});
test('are at most 60 an hour from one network, whoever asks', () async {
for (var i = 0; i < 30; i++) {
await check(alice, 'carla$i');
}
for (var i = 0; i < 30; i++) {
await check(bob, 'dora$i');
}
await expectLater(
check(identifiedPerson(), 'emil'),
refused(ProfileRefusal.tooManyHandleChecks),
);
});
});
test("a handle nobody has isn't found", () async {
await expectLater(
endpoints.profiles.byHandle(alice, 'nobody'),
throwsA(isA<ProfileNotFoundException>()),
);
});
test('deleting a person deletes their profile', () async {
useBase(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('makes a profile public by itself (D5)', () async {
await save(alice, 'alice');
await endpoints.profile.setAvatar(alice, png(1));
expect(
(await endpoints.profiles.byHandle(bob, 'alice')).handle,
'alice',
);
await endpoints.profile.removeAvatar(alice);
await expectLater(
endpoints.profiles.byHandle(bob, 'alice'),
throwsA(isA<ProfileNotFoundException>()),
);
});
test('needs a profile to show on', () async {
await expectLater(
endpoints.profile.setAvatar(alice, png(1)),
throwsA(isA<ProfileNotFoundException>()),
);
});
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 {
useBase(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);
});
});
},
);
}