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>
516 lines
17 KiB
Dart
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);
|
|
});
|
|
});
|
|
},
|
|
);
|
|
}
|