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( () => 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 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 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()), ); }); 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('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); }); }); }, ); }