People without GitHub can now keep an anonymous account, and sign in on another device, with an email address and a code mailed to it each time; no password. The instance keeps only HMAC-SHA256 of the address, keyed with its emailSignInPepper (ADR 0010): from what someone types it finds the person and tells whether an address is free, it mails only an address someone has just typed, and it can't write to anybody otherwise or show anybody their address. The device remembers the address it signed in with, in the person's folder, to show it. It works as GitHub does. A code for a new address makes it the signed-in person's, who becomes identified and keeps everything. A code for another person's address gives a switch token, which signs in as them without a session; a device holding studysets asks first. Asking for a code answers alike for every address. Codes are eight Crockford characters, valid 15 minutes and three tries (Serverpod's code challenges); three codes an hour per address, ten per network. In development the code goes to the server's log; elsewhere there is no email sign-in until a mail service is set up (instance.info().emailSignIn). Someone identified may add the other way in: the Profile tab lists how they sign in and adds GitHub or an address. They are never switched to another account: an address or GitHub account that is another's is said to be so. Switching now deletes the account left behind only when it was anonymous; it deleted any, which only anonymous people could reach so far. The migration adds email_identity and email_code_request with their indexes and foreign keys to Serverpod's challenges; nothing else changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
356 lines
12 KiB
Dart
356 lines
12 KiB
Dart
import 'dart:async';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
|
import 'package:halkyon_learn_flutter/src/core/widget/chevron_field.dart';
|
|
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
|
|
import 'package:halkyon_learn_flutter/src/feature/profile/bloc/public_profile_bloc.dart';
|
|
import 'package:halkyon_learn_flutter/src/feature/profile/data/profile_repository.dart';
|
|
import 'package:halkyon_learn_flutter/src/feature/profile/model/public_profile.dart';
|
|
import 'package:material_ui/material_ui.dart';
|
|
|
|
import '../../support/fake_auth_gateway.dart';
|
|
import '../../support/fake_file_picker.dart';
|
|
import '../../support/fake_profile_gateway.dart';
|
|
import '../../support/pump_app.dart';
|
|
import '../../support/test_dependencies.dart';
|
|
|
|
void main() {
|
|
/// Opens the Profile tab of a person with an identity, whose profile the
|
|
/// instance answers through [profiles].
|
|
Future<void> showProfile(
|
|
WidgetTester tester,
|
|
FakeProfileGateway profiles, {
|
|
bool identified = true,
|
|
FakeFilePicker? files,
|
|
}) async {
|
|
tester.view.physicalSize = const Size(1000, 1800);
|
|
tester.view.devicePixelRatio = 1;
|
|
addTearDown(tester.view.reset);
|
|
await pumpApp(
|
|
tester,
|
|
testDependencies(
|
|
authGateway: FakeAuthGateway(
|
|
status: SignedIn(
|
|
authUserId: 'github-person',
|
|
isIdentified: identified,
|
|
),
|
|
),
|
|
profileGateway: profiles,
|
|
files: files,
|
|
),
|
|
location: RoutePaths.profile,
|
|
);
|
|
await tester.pumpAndSettle();
|
|
}
|
|
|
|
Finder field(String label) => find.widgetWithText(TextField, label);
|
|
|
|
/// The dialog's field for the handle.
|
|
Finder handleInDialog() => find.descendant(
|
|
of: find.byType(AlertDialog),
|
|
matching: find.byType(TextField),
|
|
);
|
|
|
|
/// Types [handle] into the handle's dialog and waits for the instance to
|
|
/// say whether it is free.
|
|
Future<void> typeHandle(WidgetTester tester, String handle) async {
|
|
await tester.enterText(handleInDialog(), handle);
|
|
await tester.pump(const Duration(milliseconds: 400));
|
|
await tester.pumpAndSettle();
|
|
}
|
|
|
|
testWidgets('a person with an identity writes first, then chooses a '
|
|
'handle in a dialog saying whether it is free; that makes the profile '
|
|
'with what was written', (tester) async {
|
|
final profiles = FakeProfileGateway(taken: {'Tizian'});
|
|
await showProfile(tester, profiles);
|
|
|
|
expect(find.text('Public profile'), findsOneWidget);
|
|
expect(find.text('Save'), findsNothing);
|
|
await tester.enterText(field('Display name'), 'Tizian');
|
|
await tester.enterText(field('Bio'), 'Learning Japanese.');
|
|
await tester.tap(find.text('Display name'));
|
|
await tester.pumpAndSettle();
|
|
// Nothing to save without a handle.
|
|
expect(profiles.saves, isEmpty);
|
|
|
|
await tester.tap(find.text('Choose a handle'));
|
|
await tester.pumpAndSettle();
|
|
expect(find.text('Choose your handle'), findsOneWidget);
|
|
await typeHandle(tester, 'tizian');
|
|
expect(find.text('Someone else has this handle.'), findsOneWidget);
|
|
expect(
|
|
tester
|
|
.widget<FilledButton>(find.widgetWithText(FilledButton, 'Choose'))
|
|
.onPressed,
|
|
isNull,
|
|
);
|
|
await typeHandle(tester, 'raisondetredev');
|
|
expect(find.text('@raisondetredev is free'), findsOneWidget);
|
|
await tester.tap(find.widgetWithText(FilledButton, 'Choose'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(
|
|
find.text('Others find you as @raisondetredev@halkyon.eu'),
|
|
findsOneWidget,
|
|
);
|
|
expect(find.text('@raisondetredev'), findsOneWidget);
|
|
expect(profiles.profile!.displayName, 'Tizian');
|
|
expect(profiles.profile!.bio, 'Learning Japanese.');
|
|
expect(find.text('Saved'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('a profile saves a field when it is left or Enter is pressed, '
|
|
'only what changed, without losing what is being written', (
|
|
tester,
|
|
) async {
|
|
final profiles = FakeProfileGateway(
|
|
profile: const PublicProfile(
|
|
handle: 'raisondetredev',
|
|
address: 'raisondetredev@halkyon.eu',
|
|
displayName: 'Tizian',
|
|
),
|
|
);
|
|
await showProfile(tester, profiles);
|
|
|
|
await tester.enterText(field('Display name'), 'Tizian K.');
|
|
await tester.testTextInput.receiveAction(TextInputAction.done);
|
|
await tester.pumpAndSettle();
|
|
expect(profiles.saves.single.displayName, 'Tizian K.');
|
|
|
|
await tester.enterText(field('Bio'), 'Learning Japanese.');
|
|
// Into the next field: the bio is left, and saves.
|
|
await tester.tap(find.text('Add link'));
|
|
await tester.pumpAndSettle();
|
|
expect(profiles.saves.last.bio, 'Learning Japanese.');
|
|
expect(profiles.saves, hasLength(2));
|
|
|
|
await tester.enterText(
|
|
find.widgetWithText(TextField, 'https://…'),
|
|
'https://halkyon.app',
|
|
);
|
|
await tester.testTextInput.receiveAction(TextInputAction.done);
|
|
await tester.pumpAndSettle();
|
|
expect(profiles.profile!.links, ['https://halkyon.app']);
|
|
// What was written stays as it was written.
|
|
expect(find.text('Tizian K.'), findsOneWidget);
|
|
|
|
await tester.tap(find.byTooltip('Remove link'));
|
|
await tester.pumpAndSettle();
|
|
expect(profiles.profile!.links, isEmpty);
|
|
expect(profiles.saves, hasLength(4));
|
|
});
|
|
|
|
testWidgets('the handle changes through the dialog, which says one is '
|
|
'yours', (tester) async {
|
|
final profiles = FakeProfileGateway(
|
|
profile: const PublicProfile(
|
|
handle: 'raisondetredev',
|
|
address: 'raisondetredev@halkyon.eu',
|
|
displayName: 'Tizian',
|
|
),
|
|
);
|
|
await showProfile(tester, profiles);
|
|
|
|
await tester.tap(find.text('@raisondetredev'));
|
|
await tester.pumpAndSettle();
|
|
expect(find.text('Change your handle'), findsOneWidget);
|
|
expect(find.text('This is your handle'), findsOneWidget);
|
|
await typeHandle(tester, 'tizian_k');
|
|
await tester.tap(find.widgetWithText(FilledButton, 'Change'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(profiles.profile!.handle, 'tizian_k');
|
|
expect(profiles.profile!.displayName, 'Tizian');
|
|
expect(find.text('@tizian_k'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('once its person has published, the handle is fixed and the '
|
|
'profile stays', (tester) async {
|
|
await showProfile(
|
|
tester,
|
|
FakeProfileGateway(
|
|
profile: const PublicProfile(
|
|
handle: 'raisondetredev',
|
|
address: 'raisondetredev@halkyon.eu',
|
|
handleFixed: true,
|
|
),
|
|
),
|
|
);
|
|
|
|
expect(
|
|
find.text('Fixed since you published: others refer to it.'),
|
|
findsOneWidget,
|
|
);
|
|
expect(
|
|
tester.widget<ChevronField>(find.byType(ChevronField)).onTap,
|
|
isNull,
|
|
);
|
|
expect(find.text('Remove profile'), findsNothing);
|
|
});
|
|
|
|
testWidgets('removing a profile asks first, then frees its handle', (
|
|
tester,
|
|
) async {
|
|
final profiles = FakeProfileGateway(
|
|
profile: const PublicProfile(
|
|
handle: 'raisondetredev',
|
|
address: 'raisondetredev@halkyon.eu',
|
|
),
|
|
);
|
|
await showProfile(tester, profiles);
|
|
|
|
await tester.tap(find.text('Remove profile'));
|
|
await tester.pumpAndSettle();
|
|
expect(find.text('Remove your public profile?'), findsOneWidget);
|
|
await tester.tap(find.text('Remove profile').last);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Profile removed.'), findsOneWidget);
|
|
expect(profiles.profile, isNull);
|
|
});
|
|
|
|
testWidgets('a saved profile takes a picture as its avatar, and gives it '
|
|
'up', (tester) async {
|
|
final profiles = FakeProfileGateway(
|
|
profile: const PublicProfile(
|
|
handle: 'raisondetredev',
|
|
address: 'raisondetredev@halkyon.eu',
|
|
),
|
|
);
|
|
final files = FakeFilePicker()
|
|
..next = (name: 'me.jpg', bytes: Uint8List.fromList([1, 2, 3]));
|
|
await showProfile(tester, profiles, files: files);
|
|
|
|
await tester.tap(find.text('Choose picture'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(profiles.avatars.single, [1, 2, 3]);
|
|
expect(files.asked.single, contains('jpg'));
|
|
expect(find.text('Saved'), findsOneWidget);
|
|
await tester.tap(find.text('Remove picture'));
|
|
await tester.pumpAndSettle();
|
|
expect(profiles.profile!.avatarUrl, isNull);
|
|
expect(find.text('Remove picture'), findsNothing);
|
|
});
|
|
|
|
testWidgets('a profile not yet saved has no picture to choose', (
|
|
tester,
|
|
) async {
|
|
await showProfile(tester, FakeProfileGateway());
|
|
|
|
expect(find.text('Choose your handle to add a picture.'), findsOneWidget);
|
|
expect(find.text('Choose picture'), findsNothing);
|
|
});
|
|
|
|
testWidgets('an anonymous person is told how to have one', (tester) async {
|
|
await showProfile(tester, FakeProfileGateway(), identified: false);
|
|
|
|
expect(
|
|
find.text('Sign in to have a public profile and publish.'),
|
|
findsOneWidget,
|
|
);
|
|
expect(find.text('Public profile'), findsNothing);
|
|
});
|
|
|
|
testWidgets('without the instance, it says so and tries again', (
|
|
tester,
|
|
) async {
|
|
final profiles = FakeProfileGateway()..fail = Exception('offline');
|
|
await showProfile(tester, profiles);
|
|
|
|
expect(
|
|
find.text("Your public profile can't be reached right now."),
|
|
findsOneWidget,
|
|
);
|
|
profiles.fail = null;
|
|
await tester.tap(find.text('Retry'));
|
|
await tester.pumpAndSettle();
|
|
expect(find.byType(ChevronField), findsOneWidget);
|
|
});
|
|
|
|
test('a save asked for while another is on its way follows it, the '
|
|
'latest of those waiting; a handle chosen holds for the saves after '
|
|
'it', () async {
|
|
final gateway = _HeldProfileGateway(
|
|
profile: const PublicProfile(
|
|
handle: 'raisondetredev',
|
|
address: 'raisondetredev@halkyon.eu',
|
|
),
|
|
);
|
|
final bloc = PublicProfileBloc(ProfileRepository(gateway))
|
|
..add(const PublicProfileEventStarted());
|
|
addTearDown(bloc.close);
|
|
await bloc.stream.firstWhere((state) => state is PublicProfileStateReady);
|
|
|
|
PublicProfileEventSaved saved(String name, {String? handle}) =>
|
|
PublicProfileEventSaved(
|
|
displayName: name,
|
|
bio: '',
|
|
links: const [],
|
|
handle: handle,
|
|
);
|
|
bloc
|
|
..add(saved('A'))
|
|
..add(saved('A', handle: 'tizian'))
|
|
..add(saved('B'));
|
|
await pumpEventQueue();
|
|
gateway.release();
|
|
await pumpEventQueue();
|
|
gateway.release();
|
|
await pumpEventQueue();
|
|
|
|
// The second gave way to the third, which kept its handle.
|
|
expect(
|
|
[for (final s in gateway.saves) (s.handle, s.displayName)],
|
|
[
|
|
('raisondetredev', 'A'),
|
|
('tizian', 'B'),
|
|
],
|
|
);
|
|
});
|
|
|
|
test(
|
|
'the repository leaves blank links out and trims what it saves',
|
|
() async {
|
|
final profiles = FakeProfileGateway();
|
|
await ProfileRepository(profiles).save(
|
|
handle: ' tizian ',
|
|
displayName: ' Tizian ',
|
|
bio: '',
|
|
links: ['', ' https://halkyon.app '],
|
|
);
|
|
|
|
expect(profiles.profile!.handle, 'tizian');
|
|
expect(profiles.profile!.links, ['https://halkyon.app']);
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Saves only once [release] lets each through.
|
|
class _HeldProfileGateway({super.profile}) extends FakeProfileGateway {
|
|
final _held = <Completer<void>>[];
|
|
|
|
/// Lets the save waiting longest through.
|
|
void release() => _held.removeAt(0).complete();
|
|
|
|
@override
|
|
Future<PublicProfile> save({
|
|
required String handle,
|
|
required String displayName,
|
|
required String bio,
|
|
required List<String> links,
|
|
}) async {
|
|
final held = Completer<void>();
|
|
_held.add(held);
|
|
await held.future;
|
|
return await super.save(
|
|
handle: handle,
|
|
displayName: displayName,
|
|
bio: bio,
|
|
links: links,
|
|
);
|
|
}
|
|
}
|