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 save( TestSessionBuilder person, String handle, { String displayName = '', String bio = '', List links = const [], }) => endpoints.profile.save(person, handle, displayName, bio, links); Matcher refused(ProfileRefusal reason) => throwsA( isA().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()), ); await expectLater( endpoints.profile.mine(anonymous), throwsA(isA()), ); await expectLater( endpoints.profile.checkHandle(anonymous, 'nobody'), throwsA(isA()), ); 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 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()), ); }); 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 [], isEmpty); expect( (await endpoints.profile.mine(alice))!.toJson(), removed.toJson(), ); await expectLater( endpoints.profiles.byHandle(bob, 'alice'), throwsA(isA()), ); 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 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 []), ('', 'Learning Japanese.', const []), ('', '', ['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 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()), ); }); 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 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()), ); }); test('needs a profile to show on', () async { await expectLater( endpoints.profile.setAvatar(alice, png(1)), throwsA(isA()), ); }); 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); }); }); }, ); }