halkyon/packages/halkyon_base_server/test/integration/profile/profile_test.dart

471 lines
16 KiB
Dart
Raw Normal View History

feat: public profiles, a handle on the account domain with name, bio, links and picture Stage 1 of the public tier (ADR 0005). A person with an identity has a public profile: a handle by Mastodon's rules (1 to 30 letters, digits or underscores, unique whatever its case, a few kept for the instance), changeable until they first publish, on the instance's account domain (ACCOUNT_DOMAIN, else the server's host; the instance info names it), so `raisondetredev@halkyon.eu`; a display name, a bio, up to four links and an avatar. The table is server only: the app gets a Profile without the person's id. ProfileEndpoint (identified only) reads, saves and removes the caller's own; ProfilesEndpoint finds anyone's by handle. An avatar is a PNG of at most 1 MB that the app draws anew from the picture chosen, its middle square at 400 pixels, so a photo's metadata never leaves the device. The server keeps it in the public storage named by its content, so its URL tells nothing about whose it is, and deletes it once no profile shows it. Deleting a person deletes their profile and its picture. In the app, the Profile tab holds the profile as a form, laid out as the old app's; anonymous people are told GitHub sign-in gives them one. Migration 20261009184924904-public-profiles creates public_profile with a unique index on the person and one on the handle in lower case; it touches no other table, and the old server never sees it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 20:55:16 +02:00
import 'dart:convert';
import 'dart:typed_data';
import 'package:clock/clock.dart';
import 'package:halkyon_base_server/base.dart';
feat: public profiles, a handle on the account domain with name, bio, links and picture Stage 1 of the public tier (ADR 0005). A person with an identity has a public profile: a handle by Mastodon's rules (1 to 30 letters, digits or underscores, unique whatever its case, a few kept for the instance), changeable until they first publish, on the instance's account domain (ACCOUNT_DOMAIN, else the server's host; the instance info names it), so `raisondetredev@halkyon.eu`; a display name, a bio, up to four links and an avatar. The table is server only: the app gets a Profile without the person's id. ProfileEndpoint (identified only) reads, saves and removes the caller's own; ProfilesEndpoint finds anyone's by handle. An avatar is a PNG of at most 1 MB that the app draws anew from the picture chosen, its middle square at 400 pixels, so a photo's metadata never leaves the device. The server keeps it in the public storage named by its content, so its URL tells nothing about whose it is, and deletes it once no profile shows it. Deleting a person deletes their profile and its picture. In the app, the Profile tab holds the profile as a form, laid out as the old app's; anonymous people are told GitHub sign-in gives them one. Migration 20261009184924904-public-profiles creates public_profile with a unique index on the person and one on the handle in lower case; it touches no other table, and the old server never sees it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 20:55:16 +02:00
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';
feat: public profiles, a handle on the account domain with name, bio, links and picture Stage 1 of the public tier (ADR 0005). A person with an identity has a public profile: a handle by Mastodon's rules (1 to 30 letters, digits or underscores, unique whatever its case, a few kept for the instance), changeable until they first publish, on the instance's account domain (ACCOUNT_DOMAIN, else the server's host; the instance info names it), so `raisondetredev@halkyon.eu`; a display name, a bio, up to four links and an avatar. The table is server only: the app gets a Profile without the person's id. ProfileEndpoint (identified only) reads, saves and removes the caller's own; ProfilesEndpoint finds anyone's by handle. An avatar is a PNG of at most 1 MB that the app draws anew from the picture chosen, its middle square at 400 pixels, so a photo's metadata never leaves the device. The server keeps it in the public storage named by its content, so its URL tells nothing about whose it is, and deletes it once no profile shows it. Deleting a person deletes their profile and its picture. In the app, the Profile tab holds the profile as a form, laid out as the old app's; anonymous people are told GitHub sign-in gives them one. Migration 20261009184924904-public-profiles creates public_profile with a unique index on the person and one on the handle in lower case; it touches no other table, and the old server never sees it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 20:55:16 +02:00
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),
);
});
feat: public profiles, a handle on the account domain with name, bio, links and picture Stage 1 of the public tier (ADR 0005). A person with an identity has a public profile: a handle by Mastodon's rules (1 to 30 letters, digits or underscores, unique whatever its case, a few kept for the instance), changeable until they first publish, on the instance's account domain (ACCOUNT_DOMAIN, else the server's host; the instance info names it), so `raisondetredev@halkyon.eu`; a display name, a bio, up to four links and an avatar. The table is server only: the app gets a Profile without the person's id. ProfileEndpoint (identified only) reads, saves and removes the caller's own; ProfilesEndpoint finds anyone's by handle. An avatar is a PNG of at most 1 MB that the app draws anew from the picture chosen, its middle square at 400 pixels, so a photo's metadata never leaves the device. The server keeps it in the public storage named by its content, so its URL tells nothing about whose it is, and deletes it once no profile shows it. Deleting a person deletes their profile and its picture. In the app, the Profile tab holds the profile as a form, laid out as the old app's; anonymous people are told GitHub sign-in gives them one. Migration 20261009184924904-public-profiles creates public_profile with a unique index on the person and one on the handle in lower case; it touches no other table, and the old server never sees it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 20:55:16 +02:00
/// 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>()),
);
feat: public profiles, a handle on the account domain with name, bio, links and picture Stage 1 of the public tier (ADR 0005). A person with an identity has a public profile: a handle by Mastodon's rules (1 to 30 letters, digits or underscores, unique whatever its case, a few kept for the instance), changeable until they first publish, on the instance's account domain (ACCOUNT_DOMAIN, else the server's host; the instance info names it), so `raisondetredev@halkyon.eu`; a display name, a bio, up to four links and an avatar. The table is server only: the app gets a Profile without the person's id. ProfileEndpoint (identified only) reads, saves and removes the caller's own; ProfilesEndpoint finds anyone's by handle. An avatar is a PNG of at most 1 MB that the app draws anew from the picture chosen, its middle square at 400 pixels, so a photo's metadata never leaves the device. The server keeps it in the public storage named by its content, so its URL tells nothing about whose it is, and deletes it once no profile shows it. Deleting a person deletes their profile and its picture. In the app, the Profile tab holds the profile as a form, laid out as the old app's; anonymous people are told GitHub sign-in gives them one. Migration 20261009184924904-public-profiles creates public_profile with a unique index on the person and one on the handle in lower case; it touches no other table, and the old server never sees it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 20:55:16 +02:00
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);
});
feat: public profiles, a handle on the account domain with name, bio, links and picture Stage 1 of the public tier (ADR 0005). A person with an identity has a public profile: a handle by Mastodon's rules (1 to 30 letters, digits or underscores, unique whatever its case, a few kept for the instance), changeable until they first publish, on the instance's account domain (ACCOUNT_DOMAIN, else the server's host; the instance info names it), so `raisondetredev@halkyon.eu`; a display name, a bio, up to four links and an avatar. The table is server only: the app gets a Profile without the person's id. ProfileEndpoint (identified only) reads, saves and removes the caller's own; ProfilesEndpoint finds anyone's by handle. An avatar is a PNG of at most 1 MB that the app draws anew from the picture chosen, its middle square at 400 pixels, so a photo's metadata never leaves the device. The server keeps it in the public storage named by its content, so its URL tells nothing about whose it is, and deletes it once no profile shows it. Deleting a person deletes their profile and its picture. In the app, the Profile tab holds the profile as a form, laid out as the old app's; anonymous people are told GitHub sign-in gives them one. Migration 20261009184924904-public-profiles creates public_profile with a unique index on the person and one on the handle in lower case; it touches no other table, and the old server never sees it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 20:55:16 +02:00
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,
);
feat: public profiles, a handle on the account domain with name, bio, links and picture Stage 1 of the public tier (ADR 0005). A person with an identity has a public profile: a handle by Mastodon's rules (1 to 30 letters, digits or underscores, unique whatever its case, a few kept for the instance), changeable until they first publish, on the instance's account domain (ACCOUNT_DOMAIN, else the server's host; the instance info names it), so `raisondetredev@halkyon.eu`; a display name, a bio, up to four links and an avatar. The table is server only: the app gets a Profile without the person's id. ProfileEndpoint (identified only) reads, saves and removes the caller's own; ProfilesEndpoint finds anyone's by handle. An avatar is a PNG of at most 1 MB that the app draws anew from the picture chosen, its middle square at 400 pixels, so a photo's metadata never leaves the device. The server keeps it in the public storage named by its content, so its URL tells nothing about whose it is, and deletes it once no profile shows it. Deleting a person deletes their profile and its picture. In the app, the Profile tab holds the profile as a form, laid out as the old app's; anonymous people are told GitHub sign-in gives them one. Migration 20261009184924904-public-profiles creates public_profile with a unique index on the person and one on the handle in lower case; it touches no other table, and the old server never sees it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 20:55:16 +02:00
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),
);
});
});
feat: public profiles, a handle on the account domain with name, bio, links and picture Stage 1 of the public tier (ADR 0005). A person with an identity has a public profile: a handle by Mastodon's rules (1 to 30 letters, digits or underscores, unique whatever its case, a few kept for the instance), changeable until they first publish, on the instance's account domain (ACCOUNT_DOMAIN, else the server's host; the instance info names it), so `raisondetredev@halkyon.eu`; a display name, a bio, up to four links and an avatar. The table is server only: the app gets a Profile without the person's id. ProfileEndpoint (identified only) reads, saves and removes the caller's own; ProfilesEndpoint finds anyone's by handle. An avatar is a PNG of at most 1 MB that the app draws anew from the picture chosen, its middle square at 400 pixels, so a photo's metadata never leaves the device. The server keeps it in the public storage named by its content, so its URL tells nothing about whose it is, and deletes it once no profile shows it. Deleting a person deletes their profile and its picture. In the app, the Profile tab holds the profile as a form, laid out as the old app's; anonymous people are told GitHub sign-in gives them one. Migration 20261009184924904-public-profiles creates public_profile with a unique index on the person and one on the handle in lower case; it touches no other table, and the old server never sees it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 20:55:16 +02:00
test("a handle nobody has isn't found", () async {
await expectLater(
endpoints.profiles.byHandle(alice, 'nobody'),
throwsA(isA<ProfileNotFoundException>()),
feat: public profiles, a handle on the account domain with name, bio, links and picture Stage 1 of the public tier (ADR 0005). A person with an identity has a public profile: a handle by Mastodon's rules (1 to 30 letters, digits or underscores, unique whatever its case, a few kept for the instance), changeable until they first publish, on the instance's account domain (ACCOUNT_DOMAIN, else the server's host; the instance info names it), so `raisondetredev@halkyon.eu`; a display name, a bio, up to four links and an avatar. The table is server only: the app gets a Profile without the person's id. ProfileEndpoint (identified only) reads, saves and removes the caller's own; ProfilesEndpoint finds anyone's by handle. An avatar is a PNG of at most 1 MB that the app draws anew from the picture chosen, its middle square at 400 pixels, so a photo's metadata never leaves the device. The server keeps it in the public storage named by its content, so its URL tells nothing about whose it is, and deletes it once no profile shows it. Deleting a person deletes their profile and its picture. In the app, the Profile tab holds the profile as a form, laid out as the old app's; anonymous people are told GitHub sign-in gives them one. Migration 20261009184924904-public-profiles creates public_profile with a unique index on the person and one on the handle in lower case; it touches no other table, and the old server never sees it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 20:55:16 +02:00
);
});
test('deleting a person deletes their profile', () async {
useBase(sessionBuilder);
feat: public profiles, a handle on the account domain with name, bio, links and picture Stage 1 of the public tier (ADR 0005). A person with an identity has a public profile: a handle by Mastodon's rules (1 to 30 letters, digits or underscores, unique whatever its case, a few kept for the instance), changeable until they first publish, on the instance's account domain (ACCOUNT_DOMAIN, else the server's host; the instance info names it), so `raisondetredev@halkyon.eu`; a display name, a bio, up to four links and an avatar. The table is server only: the app gets a Profile without the person's id. ProfileEndpoint (identified only) reads, saves and removes the caller's own; ProfilesEndpoint finds anyone's by handle. An avatar is a PNG of at most 1 MB that the app draws anew from the picture chosen, its middle square at 400 pixels, so a photo's metadata never leaves the device. The server keeps it in the public storage named by its content, so its URL tells nothing about whose it is, and deletes it once no profile shows it. Deleting a person deletes their profile and its picture. In the app, the Profile tab holds the profile as a form, laid out as the old app's; anonymous people are told GitHub sign-in gives them one. Migration 20261009184924904-public-profiles creates public_profile with a unique index on the person and one on the handle in lower case; it touches no other table, and the old server never sees it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 20:55:16 +02:00
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>()),
);
});
feat: public profiles, a handle on the account domain with name, bio, links and picture Stage 1 of the public tier (ADR 0005). A person with an identity has a public profile: a handle by Mastodon's rules (1 to 30 letters, digits or underscores, unique whatever its case, a few kept for the instance), changeable until they first publish, on the instance's account domain (ACCOUNT_DOMAIN, else the server's host; the instance info names it), so `raisondetredev@halkyon.eu`; a display name, a bio, up to four links and an avatar. The table is server only: the app gets a Profile without the person's id. ProfileEndpoint (identified only) reads, saves and removes the caller's own; ProfilesEndpoint finds anyone's by handle. An avatar is a PNG of at most 1 MB that the app draws anew from the picture chosen, its middle square at 400 pixels, so a photo's metadata never leaves the device. The server keeps it in the public storage named by its content, so its URL tells nothing about whose it is, and deletes it once no profile shows it. Deleting a person deletes their profile and its picture. In the app, the Profile tab holds the profile as a form, laid out as the old app's; anonymous people are told GitHub sign-in gives them one. Migration 20261009184924904-public-profiles creates public_profile with a unique index on the person and one on the handle in lower case; it touches no other table, and the old server never sees it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 20:55:16 +02:00
test('needs a profile to show on', () async {
await expectLater(
endpoints.profile.setAvatar(alice, png(1)),
throwsA(isA<ProfileNotFoundException>()),
feat: public profiles, a handle on the account domain with name, bio, links and picture Stage 1 of the public tier (ADR 0005). A person with an identity has a public profile: a handle by Mastodon's rules (1 to 30 letters, digits or underscores, unique whatever its case, a few kept for the instance), changeable until they first publish, on the instance's account domain (ACCOUNT_DOMAIN, else the server's host; the instance info names it), so `raisondetredev@halkyon.eu`; a display name, a bio, up to four links and an avatar. The table is server only: the app gets a Profile without the person's id. ProfileEndpoint (identified only) reads, saves and removes the caller's own; ProfilesEndpoint finds anyone's by handle. An avatar is a PNG of at most 1 MB that the app draws anew from the picture chosen, its middle square at 400 pixels, so a photo's metadata never leaves the device. The server keeps it in the public storage named by its content, so its URL tells nothing about whose it is, and deletes it once no profile shows it. Deleting a person deletes their profile and its picture. In the app, the Profile tab holds the profile as a form, laid out as the old app's; anonymous people are told GitHub sign-in gives them one. Migration 20261009184924904-public-profiles creates public_profile with a unique index on the person and one on the handle in lower case; it touches no other table, and the old server never sees it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 20:55:16 +02:00
);
});
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);
feat: public profiles, a handle on the account domain with name, bio, links and picture Stage 1 of the public tier (ADR 0005). A person with an identity has a public profile: a handle by Mastodon's rules (1 to 30 letters, digits or underscores, unique whatever its case, a few kept for the instance), changeable until they first publish, on the instance's account domain (ACCOUNT_DOMAIN, else the server's host; the instance info names it), so `raisondetredev@halkyon.eu`; a display name, a bio, up to four links and an avatar. The table is server only: the app gets a Profile without the person's id. ProfileEndpoint (identified only) reads, saves and removes the caller's own; ProfilesEndpoint finds anyone's by handle. An avatar is a PNG of at most 1 MB that the app draws anew from the picture chosen, its middle square at 400 pixels, so a photo's metadata never leaves the device. The server keeps it in the public storage named by its content, so its URL tells nothing about whose it is, and deletes it once no profile shows it. Deleting a person deletes their profile and its picture. In the app, the Profile tab holds the profile as a form, laid out as the old app's; anonymous people are told GitHub sign-in gives them one. Migration 20261009184924904-public-profiles creates public_profile with a unique index on the person and one on the handle in lower case; it touches no other table, and the old server never sees it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 20:55:16 +02:00
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);
});
});
},
);
}