A handle's page and profiles.byHandle answer as for a handle nobody has until its person goes public: a handle fixed by a publish, or a display name, a bio, a link or an avatar (isPublic). A person's own profile, and the host's own lookups (personWithHandle), are unchanged. So every answer about a new handle now tells that an account has it, and countHandleCheck counts them together on the register, 30 an hour per person (or attempt) and 60 per network, refused beyond with ProfileRefusal.tooManyHandleChecks: profile.checkHandle and saving a profile with a handle new for the person, simpleLogin.checkHandle and signUp, keys.set with a handle, and Halkyon Learn's inbox.block and unblock, which look people up by handle. A person's own handle and what is no handle aren't counted. The SimpleLogin limiters of commit 3 give way to it, so a network's 60 are shared between people signed in and people new. The register's checkHandle, saveProfile, signUpWithSimpleLogin and setFirstKeys carry the visitor's network from the member that asked. Halkyon Learn's handle dialog words the new refusal (English, German). Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
471 lines
16 KiB
Dart
471 lines
16 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 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 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);
|
|
});
|
|
});
|
|
},
|
|
);
|
|
}
|