feat(auth): sign in with an email address, keeping only a keyed hash of it
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>
This commit is contained in:
parent
58e13521b1
commit
1cfd391dbc
12 changed files with 21720 additions and 103 deletions
|
|
@ -2,6 +2,9 @@ import 'package:flutter_test/flutter_test.dart';
|
|||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/text_limits.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/data/sign_in_addresses.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
|
||||
|
|
@ -15,12 +18,14 @@ import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_conten
|
|||
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import '../support/fake_auth_gateway.dart';
|
||||
import '../support/fake_card_gateway.dart';
|
||||
import '../support/fake_learning_path_gateway.dart';
|
||||
import '../support/fake_note_gateway.dart';
|
||||
import '../support/fake_settings_gateway.dart';
|
||||
import '../support/fake_studyset_gateway.dart';
|
||||
import '../support/fake_tag_gateway.dart';
|
||||
import '../support/memory_sign_in_addresses.dart';
|
||||
import '../support/pump_app.dart';
|
||||
import '../support/test_dependencies.dart';
|
||||
|
||||
|
|
@ -106,13 +111,20 @@ void main() {
|
|||
});
|
||||
|
||||
for (final (window, width) in [('a phone', 360.0), ('a desktop', 1200.0)]) {
|
||||
Future<void> show(WidgetTester tester, String location) async {
|
||||
Future<void> show(
|
||||
WidgetTester tester,
|
||||
String location, {
|
||||
AuthGateway? authGateway,
|
||||
SignInAddresses? signInAddresses,
|
||||
}) async {
|
||||
tester.view.physicalSize = Size(width, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await pumpApp(
|
||||
tester,
|
||||
testDependencies(
|
||||
authGateway: authGateway,
|
||||
signInAddresses: signInAddresses,
|
||||
studysetGateway: studysets,
|
||||
cardGateway: cardGateway,
|
||||
noteGateway: noteGateway,
|
||||
|
|
@ -175,6 +187,23 @@ void main() {
|
|||
testWidgets('on $window, studying a long studyset', (tester) async {
|
||||
await show(tester, RoutePaths.study('japanese'));
|
||||
});
|
||||
|
||||
testWidgets('on $window, the profile of someone signing in with a long '
|
||||
'email address', (tester) async {
|
||||
final address =
|
||||
'${long('anna', TextLimits.emailAddress - 12)}@example.org';
|
||||
await show(
|
||||
tester,
|
||||
RoutePaths.profile,
|
||||
authGateway: FakeAuthGateway(
|
||||
status: const SignedIn(authUserId: 'anna', isIdentified: true),
|
||||
)..addressOwners[address] = 'anna',
|
||||
signInAddresses: MemorySignInAddresses()..addresses['anna'] = address,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('anna anna'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,233 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/bloc/email_sign_in_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/model/email_sign_in.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||
|
||||
import '../../../support/fake_auth_gateway.dart';
|
||||
import '../../../support/fake_studyset_gateway.dart';
|
||||
import '../../../support/memory_sign_in_addresses.dart';
|
||||
|
||||
void main() {
|
||||
const anonymous = SignedIn(authUserId: 'anonymous-1', isIdentified: false);
|
||||
|
||||
late FakeAuthGateway gateway;
|
||||
late MemorySignInAddresses addresses;
|
||||
late StudysetRepository studysets;
|
||||
|
||||
setUp(() {
|
||||
gateway = FakeAuthGateway(status: anonymous);
|
||||
addresses = MemorySignInAddresses();
|
||||
studysets = StudysetRepository(FakeStudysetGateway());
|
||||
});
|
||||
tearDown(() => studysets.dispose());
|
||||
|
||||
EmailSignInBloc newBloc({String language = 'en'}) {
|
||||
final bloc = EmailSignInBloc(
|
||||
AuthRepository(gateway, addresses),
|
||||
studysets,
|
||||
language: language,
|
||||
);
|
||||
addTearDown(bloc.close);
|
||||
return bloc;
|
||||
}
|
||||
|
||||
/// A bloc waiting for the code mailed to [address].
|
||||
Future<EmailSignInBloc> codeMailed(String address) async {
|
||||
final bloc = newBloc()..add(EmailSignInEventCodeRequested(address));
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsThrough(isA<EmailSignInStateCode>()),
|
||||
);
|
||||
return bloc;
|
||||
}
|
||||
|
||||
test('starts at the address', () {
|
||||
expect(newBloc().state, const EmailSignInStateAddress());
|
||||
});
|
||||
|
||||
test("mails a code in the app's language, and the code makes the address "
|
||||
"the person's: they keep everything and become identified", () async {
|
||||
final bloc = newBloc(language: 'de')
|
||||
..add(const EmailSignInEventCodeRequested(' Anna@Example.org '));
|
||||
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsInOrder([
|
||||
const EmailSignInStateAddress(sending: true),
|
||||
const EmailSignInStateCode(
|
||||
address: 'anna@example.org',
|
||||
request: 'request-1',
|
||||
),
|
||||
]),
|
||||
);
|
||||
expect(gateway.mailed.single.language, 'de');
|
||||
|
||||
bloc.add(const EmailSignInEventCodeEntered('code-1'));
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsInOrder([
|
||||
const EmailSignInStateCode(
|
||||
address: 'anna@example.org',
|
||||
request: 'request-1',
|
||||
checking: true,
|
||||
),
|
||||
const EmailSignInStateLinked(address: 'anna@example.org'),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
gateway.status,
|
||||
const SignedIn(authUserId: 'anonymous-1', isIdentified: true),
|
||||
);
|
||||
expect(addresses.addresses, {'anonymous-1': 'anna@example.org'});
|
||||
});
|
||||
|
||||
test('says what is wrong with an address, and with a code, and takes the '
|
||||
'next try', () async {
|
||||
final bloc = newBloc()..add(const EmailSignInEventCodeRequested('anna'));
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsInOrder([
|
||||
const EmailSignInStateAddress(sending: true),
|
||||
const EmailSignInStateAddress(
|
||||
problem: EmailSignInProblem.notAnAddress,
|
||||
),
|
||||
]),
|
||||
);
|
||||
|
||||
bloc.add(const EmailSignInEventCodeRequested('anna@example.org'));
|
||||
await expectLater(bloc.stream, emitsThrough(isA<EmailSignInStateCode>()));
|
||||
bloc.add(const EmailSignInEventCodeEntered('CODE-9'));
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsThrough(
|
||||
const EmailSignInStateCode(
|
||||
address: 'anna@example.org',
|
||||
request: 'request-1',
|
||||
problem: EmailSignInProblem.wrongCode,
|
||||
),
|
||||
),
|
||||
);
|
||||
bloc.add(const EmailSignInEventCodeEntered('CODE-1'));
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsThrough(const EmailSignInStateLinked(address: 'anna@example.org')),
|
||||
);
|
||||
});
|
||||
|
||||
test('mails a new code on request, and goes back to the address', () async {
|
||||
final bloc = await codeMailed('anna@example.org');
|
||||
|
||||
bloc.add(const EmailSignInEventCodeResent());
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsInOrder([
|
||||
const EmailSignInStateCode(
|
||||
address: 'anna@example.org',
|
||||
request: 'request-1',
|
||||
checking: true,
|
||||
),
|
||||
const EmailSignInStateCode(
|
||||
address: 'anna@example.org',
|
||||
request: 'request-2',
|
||||
resent: true,
|
||||
),
|
||||
]),
|
||||
);
|
||||
|
||||
bloc.add(const EmailSignInEventAddressReopened());
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emits(const EmailSignInStateAddress()),
|
||||
);
|
||||
});
|
||||
|
||||
test("another person's address: a device holding nothing switches to "
|
||||
'them straight away', () async {
|
||||
gateway.addressOwners['ben@example.org'] = 'ben';
|
||||
final bloc = await codeMailed('ben@example.org');
|
||||
|
||||
bloc.add(const EmailSignInEventCodeEntered('CODE-1'));
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsThrough(const EmailSignInStateSwitching()),
|
||||
);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(gateway.emailSwitches, 1);
|
||||
expect(
|
||||
gateway.status,
|
||||
const SignedIn(authUserId: 'ben', isIdentified: true),
|
||||
);
|
||||
expect(addresses.addresses, {'ben': 'ben@example.org'});
|
||||
});
|
||||
|
||||
test('a device holding studysets asks first: declining changes nothing, '
|
||||
'confirming switches', () async {
|
||||
gateway.addressOwners['ben@example.org'] = 'ben';
|
||||
await studysets.create(StudysetContent.titled('Japanese'));
|
||||
final bloc = await codeMailed('ben@example.org');
|
||||
|
||||
bloc.add(const EmailSignInEventCodeEntered('CODE-1'));
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsThrough(
|
||||
const EmailSignInStateConfirmSwitch(
|
||||
address: 'ben@example.org',
|
||||
switchToken: 'switch:ben@example.org',
|
||||
),
|
||||
),
|
||||
);
|
||||
bloc.add(const EmailSignInEventSwitchDeclined());
|
||||
await expectLater(bloc.stream, emits(const EmailSignInStateAddress()));
|
||||
expect(gateway.emailSwitches, 0);
|
||||
expect(gateway.status, anonymous);
|
||||
|
||||
bloc.add(const EmailSignInEventCodeRequested('ben@example.org'));
|
||||
await expectLater(bloc.stream, emitsThrough(isA<EmailSignInStateCode>()));
|
||||
bloc.add(const EmailSignInEventCodeEntered('CODE-2'));
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsThrough(isA<EmailSignInStateConfirmSwitch>()),
|
||||
);
|
||||
bloc.add(const EmailSignInEventSwitchConfirmed());
|
||||
await expectLater(bloc.stream, emits(const EmailSignInStateSwitching()));
|
||||
await pumpEventQueue();
|
||||
expect(gateway.emailSwitches, 1);
|
||||
});
|
||||
|
||||
test("someone identified whose address is another person's is told so, "
|
||||
'never switched: their own account stays theirs', () async {
|
||||
gateway = FakeAuthGateway(
|
||||
status: const SignedIn(authUserId: 'carla', isIdentified: true),
|
||||
)..addressOwners['ben@example.org'] = 'ben';
|
||||
final bloc = await codeMailed('ben@example.org');
|
||||
|
||||
bloc.add(const EmailSignInEventCodeEntered('CODE-1'));
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsThrough(const EmailSignInStateTaken(address: 'ben@example.org')),
|
||||
);
|
||||
expect(gateway.emailSwitches, 0);
|
||||
expect(
|
||||
gateway.status,
|
||||
const SignedIn(authUserId: 'carla', isIdentified: true),
|
||||
);
|
||||
});
|
||||
|
||||
test("says when the instance can't be reached", () async {
|
||||
gateway.emailError = Exception('offline');
|
||||
final bloc = newBloc()
|
||||
..add(const EmailSignInEventCodeRequested('anna@example.org'));
|
||||
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsInOrder([
|
||||
const EmailSignInStateAddress(sending: true),
|
||||
const EmailSignInStateAddress(problem: EmailSignInProblem.failed),
|
||||
]),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_conten
|
|||
|
||||
import '../../../support/fake_auth_gateway.dart';
|
||||
import '../../../support/fake_studyset_gateway.dart';
|
||||
import '../../../support/memory_sign_in_addresses.dart';
|
||||
|
||||
void main() {
|
||||
const anonymous = SignedIn(authUserId: 'anonymous-1', isIdentified: false);
|
||||
|
|
@ -24,8 +25,10 @@ void main() {
|
|||
/// A bloc as the account dialog starts it, once it knows whether this
|
||||
/// instance offers GitHub sign-in.
|
||||
Future<GitHubSignInBloc> ready() async {
|
||||
final bloc = GitHubSignInBloc(AuthRepository(gateway), studysets)
|
||||
..add(const GitHubSignInEventStarted());
|
||||
final bloc = GitHubSignInBloc(
|
||||
AuthRepository(gateway, MemorySignInAddresses()),
|
||||
studysets,
|
||||
)..add(const GitHubSignInEventStarted());
|
||||
addTearDown(bloc.close);
|
||||
await expectLater(bloc.stream, emits(const GitHubSignInStateReady()));
|
||||
return bloc;
|
||||
|
|
@ -33,8 +36,10 @@ void main() {
|
|||
|
||||
test('offers nothing where the instance has no GitHub sign-in', () async {
|
||||
gateway.offersGitHub = false;
|
||||
final bloc = GitHubSignInBloc(AuthRepository(gateway), studysets)
|
||||
..add(const GitHubSignInEventStarted());
|
||||
final bloc = GitHubSignInBloc(
|
||||
AuthRepository(gateway, MemorySignInAddresses()),
|
||||
studysets,
|
||||
)..add(const GitHubSignInEventStarted());
|
||||
addTearDown(bloc.close);
|
||||
|
||||
await expectLater(bloc.stream, emits(const GitHubSignInStateUnavailable()));
|
||||
|
|
@ -156,6 +161,29 @@ void main() {
|
|||
expect(gateway.gitHubSwitches, 1);
|
||||
});
|
||||
|
||||
test("someone identified whose GitHub account is another person's is told "
|
||||
'so, never switched: their own account stays theirs', () async {
|
||||
gateway = FakeAuthGateway(
|
||||
status: const SignedIn(authUserId: 'carla', isIdentified: true),
|
||||
)..gitHubLinkOutcome = GitHubLinkOutcome.takenByAnotherPerson;
|
||||
final bloc = await ready();
|
||||
|
||||
bloc.add(const GitHubSignInEventRequested());
|
||||
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsInOrder([
|
||||
const GitHubSignInStateBusy(),
|
||||
const GitHubSignInStateTaken(),
|
||||
]),
|
||||
);
|
||||
expect(gateway.gitHubSwitches, 0);
|
||||
expect(
|
||||
gateway.status,
|
||||
const SignedIn(authUserId: 'carla', isIdentified: true),
|
||||
);
|
||||
});
|
||||
|
||||
group("when the GitHub account is someone else's and this device holds "
|
||||
'studysets', () {
|
||||
late GitHubSignInBloc bloc;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_repository.dart
|
|||
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
|
||||
|
||||
import '../../../support/fake_auth_gateway.dart';
|
||||
import '../../../support/memory_sign_in_addresses.dart';
|
||||
|
||||
void main() {
|
||||
late FakeAuthGateway gateway;
|
||||
|
|
@ -15,7 +16,7 @@ void main() {
|
|||
|
||||
void start({AuthStatus status = const SignedOut()}) {
|
||||
gateway = FakeAuthGateway(status: status);
|
||||
bloc = SessionBloc(AuthRepository(gateway));
|
||||
bloc = SessionBloc(AuthRepository(gateway, MemorySignInAddresses()));
|
||||
}
|
||||
|
||||
tearDown(() => bloc.close());
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/model/email_sign_in.dart';
|
||||
|
||||
import '../../../support/fake_auth_gateway.dart';
|
||||
import '../../../support/memory_sign_in_addresses.dart';
|
||||
|
||||
void main() {
|
||||
group('AuthRepository.ensureSignedIn', () {
|
||||
test('signs in anonymously when nobody is signed in', () async {
|
||||
final gateway = FakeAuthGateway();
|
||||
|
||||
await AuthRepository(gateway).ensureSignedIn();
|
||||
await AuthRepository(gateway, MemorySignInAddresses()).ensureSignedIn();
|
||||
|
||||
expect(gateway.signIns, 1);
|
||||
expect(
|
||||
|
|
@ -22,7 +24,7 @@ void main() {
|
|||
const stored = SignedIn(authUserId: 'stored', isIdentified: false);
|
||||
final gateway = FakeAuthGateway(status: stored);
|
||||
|
||||
await AuthRepository(gateway).ensureSignedIn();
|
||||
await AuthRepository(gateway, MemorySignInAddresses()).ensureSignedIn();
|
||||
|
||||
expect(gateway.signIns, 0);
|
||||
expect(gateway.status, stored);
|
||||
|
|
@ -40,7 +42,7 @@ void main() {
|
|||
),
|
||||
);
|
||||
|
||||
await AuthRepository(gateway).signOut();
|
||||
await AuthRepository(gateway, MemorySignInAddresses()).signOut();
|
||||
|
||||
expect(gateway.accountDeletions, 1);
|
||||
expect(gateway.signOuts, 0);
|
||||
|
|
@ -53,7 +55,7 @@ void main() {
|
|||
status: const SignedIn(authUserId: 'github-1', isIdentified: true),
|
||||
);
|
||||
|
||||
await AuthRepository(gateway).signOut();
|
||||
await AuthRepository(gateway, MemorySignInAddresses()).signOut();
|
||||
|
||||
expect(gateway.signOuts, 1);
|
||||
expect(gateway.accountDeletions, 0);
|
||||
|
|
@ -65,9 +67,91 @@ void main() {
|
|||
status: const SignedIn(authUserId: 'github-1', isIdentified: true),
|
||||
);
|
||||
|
||||
await AuthRepository(gateway).deleteAccount();
|
||||
await AuthRepository(gateway, MemorySignInAddresses()).deleteAccount();
|
||||
|
||||
expect(gateway.accountDeletions, 1);
|
||||
expect(gateway.status, const SignedOut());
|
||||
});
|
||||
|
||||
group('signing in with an email address', () {
|
||||
late FakeAuthGateway gateway;
|
||||
late MemorySignInAddresses addresses;
|
||||
late AuthRepository auth;
|
||||
|
||||
setUp(() {
|
||||
gateway = FakeAuthGateway(
|
||||
status: const SignedIn(authUserId: 'anonymous-1', isIdentified: false),
|
||||
);
|
||||
addresses = MemorySignInAddresses();
|
||||
auth = AuthRepository(gateway, addresses);
|
||||
});
|
||||
|
||||
test("a code for a new address makes it the person's, and this device "
|
||||
'remembers it for them, to show', () async {
|
||||
final request = await auth.sendEmailCode(
|
||||
'anna@example.org',
|
||||
language: 'en',
|
||||
);
|
||||
|
||||
final outcome = await auth.verifyEmailCode(
|
||||
request,
|
||||
'CODE-1',
|
||||
address: 'anna@example.org',
|
||||
);
|
||||
|
||||
expect(outcome, const EmailLinked());
|
||||
expect(auth.isIdentified, isTrue);
|
||||
expect(await auth.emailAddress(), 'anna@example.org');
|
||||
});
|
||||
|
||||
test("a code for another person's address changes nothing here; "
|
||||
'switching remembers the address for the person switched to, not '
|
||||
'the one left', () async {
|
||||
gateway.addressOwners['ben@example.org'] = 'ben';
|
||||
final request = await auth.sendEmailCode(
|
||||
'ben@example.org',
|
||||
language: 'en',
|
||||
);
|
||||
|
||||
final outcome = await auth.verifyEmailCode(
|
||||
request,
|
||||
'CODE-1',
|
||||
address: 'ben@example.org',
|
||||
);
|
||||
expect(outcome, isA<EmailTakenByAnotherPerson>());
|
||||
expect(auth.isIdentified, isFalse);
|
||||
expect(addresses.addresses, isEmpty);
|
||||
|
||||
await auth.switchToEmail(
|
||||
(outcome as EmailTakenByAnotherPerson).switchToken,
|
||||
address: 'ben@example.org',
|
||||
);
|
||||
|
||||
expect(
|
||||
gateway.status,
|
||||
const SignedIn(authUserId: 'ben', isIdentified: true),
|
||||
);
|
||||
expect(addresses.addresses, {'ben': 'ben@example.org'});
|
||||
expect(await auth.emailAddress(), 'ben@example.org');
|
||||
});
|
||||
|
||||
test('a wrong code remembers nothing', () async {
|
||||
final request = await auth.sendEmailCode(
|
||||
'anna@example.org',
|
||||
language: 'en',
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
auth.verifyEmailCode(request, 'CODE-9', address: 'anna@example.org'),
|
||||
throwsA(
|
||||
isA<EmailSignInFailed>().having(
|
||||
(failure) => failure.problem,
|
||||
'problem',
|
||||
EmailSignInProblem.wrongCode,
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(addresses.addresses, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,157 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import '../../../support/fake_auth_gateway.dart';
|
||||
import '../../../support/memory_sign_in_addresses.dart';
|
||||
import '../../../support/pump_app.dart';
|
||||
import '../../../support/test_dependencies.dart';
|
||||
|
||||
void main() {
|
||||
late FakeAuthGateway auth;
|
||||
late MemorySignInAddresses addresses;
|
||||
|
||||
setUp(() {
|
||||
auth = FakeAuthGateway();
|
||||
addresses = MemorySignInAddresses();
|
||||
});
|
||||
|
||||
Future<void> openProfile(WidgetTester tester) async {
|
||||
await pumpApp(
|
||||
tester,
|
||||
testDependencies(authGateway: auth, signInAddresses: addresses),
|
||||
);
|
||||
await openTab(tester, 'Profile');
|
||||
}
|
||||
|
||||
/// The sheet's text field.
|
||||
final inSheet = find.descendant(
|
||||
of: find.byType(SheetScaffold),
|
||||
matching: find.byType(TextField),
|
||||
);
|
||||
|
||||
/// Types [address] and sends a code to it, then types [code].
|
||||
Future<void> signInWith(
|
||||
WidgetTester tester,
|
||||
String address, {
|
||||
String code = 'CODE-1',
|
||||
}) async {
|
||||
await tester.enterText(inSheet, address);
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Send code'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.text("We mailed a code to $address. It's valid for 15 minutes."),
|
||||
findsOneWidget,
|
||||
);
|
||||
await tester.enterText(inSheet, code);
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Continue'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('an anonymous person continues with email: the address, then '
|
||||
'the code mailed to it, and their progress is kept with their account, '
|
||||
'which shows the address', (tester) async {
|
||||
await openProfile(tester);
|
||||
|
||||
await tester.tap(find.text('Continue with email'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.textContaining('keeps only a fingerprint of your address'),
|
||||
findsOneWidget,
|
||||
);
|
||||
await signInWith(tester, 'anna@example.org');
|
||||
|
||||
expect(find.text('Signed in with anna@example.org.'), findsOneWidget);
|
||||
expect(
|
||||
find.text('Your progress is kept with your account.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Ways to sign in'), findsOneWidget);
|
||||
expect(
|
||||
find.widgetWithText(ListTile, 'anna@example.org'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Add GitHub'), findsOneWidget);
|
||||
expect(addresses.addresses, {'anonymous-1': 'anna@example.org'});
|
||||
});
|
||||
|
||||
testWidgets('a wrong code says so, and a new one can be asked for', (
|
||||
tester,
|
||||
) async {
|
||||
await openProfile(tester);
|
||||
await tester.tap(find.text('Continue with email'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await signInWith(tester, 'anna@example.org', code: 'CODE-9');
|
||||
|
||||
expect(find.text("That code isn't right."), findsOneWidget);
|
||||
await tester.tap(find.text('Send a new code'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('A new code is on its way.'), findsOneWidget);
|
||||
expect(auth.mailed, hasLength(2));
|
||||
});
|
||||
|
||||
testWidgets('no email sign-in where the instance offers none', (
|
||||
tester,
|
||||
) async {
|
||||
auth.offersEmail = false;
|
||||
|
||||
await openProfile(tester);
|
||||
|
||||
expect(find.text('Continue with email'), findsNothing);
|
||||
expect(find.text('Sign in with GitHub'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('someone who signs in with GitHub adds an email address', (
|
||||
tester,
|
||||
) async {
|
||||
auth = FakeAuthGateway(
|
||||
status: const SignedIn(authUserId: 'github-person', isIdentified: true),
|
||||
)..gitHubPeople.add('github-person');
|
||||
await openProfile(tester);
|
||||
|
||||
expect(find.text('Ways to sign in'), findsOneWidget);
|
||||
expect(find.text('Add GitHub'), findsNothing);
|
||||
await tester.tap(find.text('Add an email address'));
|
||||
await tester.pumpAndSettle();
|
||||
await signInWith(tester, 'carla@example.org');
|
||||
|
||||
expect(
|
||||
find.widgetWithText(ListTile, 'carla@example.org'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Add an email address'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets("someone identified whose address is another account's is "
|
||||
'told so, and stays who they are', (tester) async {
|
||||
auth =
|
||||
FakeAuthGateway(
|
||||
status: const SignedIn(
|
||||
authUserId: 'github-person',
|
||||
isIdentified: true,
|
||||
),
|
||||
)
|
||||
..gitHubPeople.add('github-person')
|
||||
..addressOwners['ben@example.org'] = 'ben';
|
||||
await openProfile(tester);
|
||||
|
||||
await tester.tap(find.text('Add an email address'));
|
||||
await tester.pumpAndSettle();
|
||||
await signInWith(tester, 'ben@example.org');
|
||||
|
||||
expect(
|
||||
find.text(
|
||||
'ben@example.org already signs in to another account. To switch to '
|
||||
'it, sign out first.',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(auth.emailSwitches, 0);
|
||||
expect(
|
||||
auth.status,
|
||||
const SignedIn(authUserId: 'github-person', isIdentified: true),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -249,7 +249,7 @@ void main() {
|
|||
await showProfile(tester, FakeProfileGateway(), identified: false);
|
||||
|
||||
expect(
|
||||
find.text('Sign in with GitHub to have a public profile and publish.'),
|
||||
find.text('Sign in to have a public profile and publish.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Public profile'), findsNothing);
|
||||
|
|
|
|||
|
|
@ -383,7 +383,7 @@ void main() {
|
|||
await openSheet(tester);
|
||||
|
||||
expect(
|
||||
find.text('Sign in with GitHub to have a public profile and publish.'),
|
||||
find.text('Sign in to have a public profile and publish.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -805,7 +805,7 @@ void main() {
|
|||
|
||||
expect(find.text('Publish'), findsOneWidget);
|
||||
expect(
|
||||
find.text('Sign in with GitHub to have a public profile and publish.'),
|
||||
find.text('Sign in to have a public profile and publish.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,12 +2,17 @@ import 'dart:async';
|
|||
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/model/email_sign_in.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/model/github_link_outcome.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/model/sign_in_ways.dart';
|
||||
|
||||
/// An [AuthGateway] without a server: signing in creates people named
|
||||
/// `anonymous-1`, `anonymous-2`, …, and [signInError] makes it fail. GitHub
|
||||
/// sign-in links the signed-in person, or switches to `github-person`; with
|
||||
/// [gitHubPageOpens], it first waits for [answerGitHub] or a cancel.
|
||||
/// [gitHubPageOpens], it first waits for [answerGitHub] or a cancel. Email
|
||||
/// sign-in mails `CODE-1`, `CODE-2`, … ([mailed]) and links the address,
|
||||
/// unless [addressOwners] gives it to another person, whose token then
|
||||
/// switches to them.
|
||||
class FakeAuthGateway({var AuthStatus _status = const SignedOut()})
|
||||
implements AuthGateway {
|
||||
final _changes = StreamController<AuthStatus>.broadcast();
|
||||
|
|
@ -49,6 +54,25 @@ class FakeAuthGateway({var AuthStatus _status = const SignedOut()})
|
|||
|
||||
Completer<bool>? _gitHubPage;
|
||||
|
||||
/// The people who sign in with GitHub.
|
||||
final gitHubPeople = <String>{};
|
||||
|
||||
/// Whether the instance offers email sign-in.
|
||||
bool offersEmail = true;
|
||||
|
||||
/// The codes mailed, in order.
|
||||
final mailed =
|
||||
<({String address, String language, String request, String code})>[];
|
||||
|
||||
/// Who signs in with which address (lower case), by the person's id.
|
||||
final addressOwners = <String, String>{};
|
||||
|
||||
/// Thrown by the next email sign-in steps while set.
|
||||
Exception? emailError;
|
||||
|
||||
/// How often this device switched to the person of an address.
|
||||
int emailSwitches = 0;
|
||||
|
||||
@override
|
||||
AuthStatus get status => _status;
|
||||
|
||||
|
|
@ -93,6 +117,7 @@ class FakeAuthGateway({var AuthStatus _status = const SignedOut()})
|
|||
}
|
||||
if (gitHubLinkOutcome == GitHubLinkOutcome.linked) {
|
||||
final person = _status as SignedIn;
|
||||
gitHubPeople.add(person.authUserId);
|
||||
_set(SignedIn(authUserId: person.authUserId, isIdentified: true));
|
||||
}
|
||||
return gitHubLinkOutcome;
|
||||
|
|
@ -107,6 +132,7 @@ class FakeAuthGateway({var AuthStatus _status = const SignedOut()})
|
|||
return false;
|
||||
}
|
||||
gitHubSwitches++;
|
||||
gitHubPeople.add('github-person');
|
||||
_set(const SignedIn(authUserId: 'github-person', isIdentified: true));
|
||||
return true;
|
||||
}
|
||||
|
|
@ -128,6 +154,74 @@ class FakeAuthGateway({var AuthStatus _status = const SignedOut()})
|
|||
Future<bool> _authorize() =>
|
||||
gitHubPageOpens ? (_gitHubPage = Completer()).future : Future.value(true);
|
||||
|
||||
@override
|
||||
Future<bool> offersEmailSignIn() async => offersEmail;
|
||||
|
||||
@override
|
||||
Future<String> sendEmailCode(
|
||||
String address, {
|
||||
required String language,
|
||||
}) async {
|
||||
if (emailError case final error?) {
|
||||
throw error;
|
||||
}
|
||||
final to = address.trim().toLowerCase();
|
||||
if (!to.contains('@')) {
|
||||
throw const EmailSignInFailed(EmailSignInProblem.notAnAddress);
|
||||
}
|
||||
final number = mailed.length + 1;
|
||||
mailed.add((
|
||||
address: to,
|
||||
language: language,
|
||||
request: 'request-$number',
|
||||
code: 'CODE-$number',
|
||||
));
|
||||
return 'request-$number';
|
||||
}
|
||||
|
||||
@override
|
||||
Future<EmailCodeOutcome> verifyEmailCode(String request, String code) async {
|
||||
if (emailError case final error?) {
|
||||
throw error;
|
||||
}
|
||||
final mail = mailed.where((each) => each.request == request).lastOrNull;
|
||||
if (mail == null) {
|
||||
throw const EmailSignInFailed(EmailSignInProblem.codeExpired);
|
||||
}
|
||||
if (mail.code != code.trim().toUpperCase()) {
|
||||
throw const EmailSignInFailed(EmailSignInProblem.wrongCode);
|
||||
}
|
||||
final person = _status as SignedIn;
|
||||
switch (addressOwners[mail.address]) {
|
||||
case final owner? when owner != person.authUserId:
|
||||
return EmailTakenByAnotherPerson('switch:${mail.address}');
|
||||
case _:
|
||||
addressOwners[mail.address] = person.authUserId;
|
||||
_set(SignedIn(authUserId: person.authUserId, isIdentified: true));
|
||||
return const EmailLinked();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> switchToEmail(String switchToken) async {
|
||||
if (emailError case final error?) {
|
||||
throw error;
|
||||
}
|
||||
final owner = addressOwners[switchToken.substring('switch:'.length)]!;
|
||||
emailSwitches++;
|
||||
_set(SignedIn(authUserId: owner, isIdentified: true));
|
||||
return owner;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SignInWays> signInWays() async {
|
||||
final person = (_status as SignedIn).authUserId;
|
||||
return SignInWays(
|
||||
gitHub: gitHubPeople.contains(person),
|
||||
email: addressOwners.containsValue(person),
|
||||
);
|
||||
}
|
||||
|
||||
void _set(AuthStatus status) {
|
||||
_status = status;
|
||||
_changes.add(status);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
import 'package:halkyon_learn_flutter/src/feature/auth/data/sign_in_addresses.dart';
|
||||
|
||||
/// [SignInAddresses] in memory.
|
||||
class MemorySignInAddresses() implements SignInAddresses {
|
||||
/// The addresses remembered, by person.
|
||||
final addresses = <String, String>{};
|
||||
|
||||
@override
|
||||
Future<String?> of(String authUserId) async => addresses[authUserId];
|
||||
|
||||
@override
|
||||
Future<void> remember(String authUserId, String address) async {
|
||||
addresses[authUserId] = address;
|
||||
}
|
||||
}
|
||||
|
|
@ -29,6 +29,7 @@ import 'package:halkyon_learn_flutter/src/feature/assistant/data/assistant_repos
|
|||
import 'package:halkyon_learn_flutter/src/feature/assistant/data/web_search.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/data/sign_in_addresses.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_deletions.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||
|
|
@ -108,6 +109,7 @@ import 'memory_asset_file_cache.dart';
|
|||
import 'memory_device_settings_store.dart';
|
||||
import 'memory_pomodoro_run_store.dart';
|
||||
import 'memory_published_store.dart';
|
||||
import 'memory_sign_in_addresses.dart';
|
||||
import 'memory_studyset_tab_store.dart';
|
||||
|
||||
/// The app's dependencies with fakes instead of the server and the device's
|
||||
|
|
@ -126,6 +128,7 @@ import 'memory_studyset_tab_store.dart';
|
|||
/// the person asks; never on timers, which a widget test would find pending.
|
||||
Dependencies testDependencies({
|
||||
AuthGateway? authGateway,
|
||||
SignInAddresses? signInAddresses,
|
||||
StudysetGateway? studysetGateway,
|
||||
CardGateway? cardGateway,
|
||||
NoteGateway? noteGateway,
|
||||
|
|
@ -171,7 +174,10 @@ Dependencies testDependencies({
|
|||
MediaPlayers? mediaPlayers,
|
||||
}) => Dependencies(
|
||||
serverName: 'learn.example.org',
|
||||
auth: AuthRepository(authGateway ?? FakeAuthGateway()),
|
||||
auth: AuthRepository(
|
||||
authGateway ?? FakeAuthGateway(),
|
||||
signInAddresses ?? MemorySignInAddresses(),
|
||||
),
|
||||
about: AboutRepository(aboutGateway ?? FakeAboutGateway()),
|
||||
dictionaries: DictionaryRepository(
|
||||
dictionaryGateway ?? FakeDictionaryGateway(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue