feat(app): adding SimpleLogin to an account (ADR 0020, D21, D23)
halkyon_app reads where the device's person stands with SimpleLogin (nothing to show, linked, or missing) and shows it in SimpleLoginSection, which each app places above the password part: Add SimpleLogin, once added one more way in. GitHub is added to nobody where SimpleLogin is offered, and Learn's Profile offers an anonymous person SimpleLogin alone there (D23). No cut-off day, reminder or banner (D24): with the developer the only person on learn.halkyon.eu, the cut-off is the operator taking GitHub's OAuth app out of the deploy's settings once that account has SimpleLogin. ADR 0020 records it and drops step 5. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
3a93dc4fd8
commit
fad12b4411
4 changed files with 829 additions and 77 deletions
|
|
@ -21,10 +21,12 @@ final class _MemoryStorage() implements ClientAuthSuccessStorage {
|
|||
|
||||
/// An instance's halkyon_base as the network shows it: anonymous sign-ins
|
||||
/// get `anonymous-token`, its basics offer GitHub (and passwords while
|
||||
/// [passwords]), and a sign-up makes Ben, signed in with `ben-token`.
|
||||
/// [passwords], SimpleLogin while [simpleLogin]), and a sign-up makes Ben,
|
||||
/// signed in with `ben-token`.
|
||||
final class _Instance() {
|
||||
/// Whether its basics offer password sign-in.
|
||||
/// Whether its basics offer password sign-in, and SimpleLogin.
|
||||
bool passwords = false;
|
||||
bool simpleLogin = false;
|
||||
|
||||
static final anonymous = UuidValue.withValidation(
|
||||
'0191f0c4-1111-7000-8000-000000000001',
|
||||
|
|
@ -54,6 +56,7 @@ final class _Instance() {
|
|||
sourceCodeUrl: 'https://code.example.org/learn',
|
||||
minimumAppBuild: 1,
|
||||
passwordSignIn: passwords,
|
||||
simpleLogin: simpleLogin,
|
||||
).toJson(),
|
||||
),
|
||||
200,
|
||||
|
|
@ -121,7 +124,8 @@ void main() {
|
|||
);
|
||||
|
||||
test('tells what the instance offers from its basics: GitHub sign-in, '
|
||||
'and linking GitHub to people only where passwords are not', () async {
|
||||
'and linking GitHub to people only where neither passwords nor '
|
||||
'SimpleLogin are (ADR 0020, D23)', () async {
|
||||
expect(
|
||||
await gateway.gitHubOffer(),
|
||||
const GitHubOffer(signIn: true, linking: true),
|
||||
|
|
@ -139,6 +143,14 @@ void main() {
|
|||
hasLength(2),
|
||||
reason: 'one read of the basics each',
|
||||
);
|
||||
|
||||
instance
|
||||
..passwords = false
|
||||
..simpleLogin = true;
|
||||
expect(
|
||||
await gateway.gitHubOffer(),
|
||||
const GitHubOffer(signIn: true, linking: false),
|
||||
);
|
||||
});
|
||||
|
||||
test("puts the instance's refusals of GitHub as the app's problems", () {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_app/testing.dart';
|
||||
|
||||
void main() {
|
||||
late FakeAuthGateway auth;
|
||||
late FakeSimpleLoginGateway gateway;
|
||||
late SimpleLoginRepository simpleLogin;
|
||||
|
||||
setUp(() async {
|
||||
auth = FakeAuthGateway();
|
||||
gateway = FakeSimpleLoginGateway(auth);
|
||||
simpleLogin = SimpleLoginRepository(
|
||||
gateway,
|
||||
FakeOidcBrowser(),
|
||||
FakeAccountCrypto(),
|
||||
MemoryAccountKeyStore(),
|
||||
auth,
|
||||
callbackScheme: 'app.halkyon.test',
|
||||
loopbackPort: 43823,
|
||||
platform: TargetPlatform.android,
|
||||
);
|
||||
addTearDown(simpleLogin.dispose);
|
||||
await auth.signInAnonymously();
|
||||
});
|
||||
|
||||
SimpleLoginStandingCubit cubit() {
|
||||
final cubit = SimpleLoginStandingCubit(simpleLogin);
|
||||
addTearDown(cubit.close);
|
||||
return cubit;
|
||||
}
|
||||
|
||||
test('shows nothing until the instance has answered', () {
|
||||
expect(cubit().state, const SimpleLoginChecking());
|
||||
});
|
||||
|
||||
test('reads where the person stands', () async {
|
||||
final standing = cubit();
|
||||
await standing.load();
|
||||
|
||||
expect(standing.state, const SimpleLoginMissing());
|
||||
});
|
||||
|
||||
test('keeps what the instance last said while it is out of reach', () async {
|
||||
final standing = cubit();
|
||||
await standing.load();
|
||||
|
||||
gateway.failing = const SimpleLoginFailed(SimpleLoginProblem.failed);
|
||||
await standing.load();
|
||||
|
||||
expect(standing.state, const SimpleLoginMissing());
|
||||
});
|
||||
|
||||
test('reads it anew once another person signs in, or nobody', () async {
|
||||
final standing = cubit();
|
||||
await standing.load();
|
||||
|
||||
await auth.signOut();
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(standing.state, const SimpleLoginUnavailable());
|
||||
});
|
||||
}
|
||||
|
|
@ -436,4 +436,65 @@ void main() {
|
|||
expect(gateway.linked, {'simplelogin-anna': 'person-9'});
|
||||
});
|
||||
});
|
||||
|
||||
group('where the person stands (D21)', () {
|
||||
tearDown(() => simpleLogin.dispose());
|
||||
|
||||
test('nothing to show while nobody is signed in, without asking the '
|
||||
'instance', () async {
|
||||
expect(await simpleLogin.standing(), const SimpleLoginUnavailable());
|
||||
expect(gateway.calls, isEmpty);
|
||||
});
|
||||
|
||||
test('nothing to show where the instance offers no SimpleLogin', () async {
|
||||
await auth.signInAnonymously();
|
||||
gateway.isOffered = false;
|
||||
|
||||
expect(await simpleLogin.standing(), const SimpleLoginUnavailable());
|
||||
});
|
||||
|
||||
test('an anonymous person lacks it, without asking for ways in they '
|
||||
'have none of', () async {
|
||||
await auth.signInAnonymously();
|
||||
|
||||
expect(await simpleLogin.standing(), const SimpleLoginMissing());
|
||||
expect(gateway.calls, ['offered']);
|
||||
});
|
||||
|
||||
test('an account without it lacks it', () async {
|
||||
await auth.useSession(const SessionGrant('person-9', 'session-9'));
|
||||
|
||||
expect(await simpleLogin.standing(), const SimpleLoginMissing());
|
||||
});
|
||||
|
||||
test('an account that signs in with it has it', () async {
|
||||
gateway.addAccount('person-9', subject: 'simplelogin-ben');
|
||||
await auth.useSession(const SessionGrant('person-9', 'session-9'));
|
||||
|
||||
expect(await simpleLogin.standing(), const SimpleLoginLinked());
|
||||
});
|
||||
|
||||
test('changes once SimpleLogin is added, and with the person', () async {
|
||||
await auth.signInAnonymously();
|
||||
var changes = 0;
|
||||
final following = simpleLogin.changes.listen((_) => changes++);
|
||||
addTearDown(following.cancel);
|
||||
|
||||
final account =
|
||||
(await simpleLogin.signIn(link: true))! as SimpleLoginAccount;
|
||||
final made = await simpleLogin.makeKeys(password, into: account);
|
||||
await simpleLogin.enter(
|
||||
await simpleLogin.setFirstKeys(account, made: made, handle: 'anna'),
|
||||
);
|
||||
await pumpEventQueue();
|
||||
expect(changes, greaterThan(0));
|
||||
expect(await simpleLogin.standing(), const SimpleLoginLinked());
|
||||
|
||||
changes = 0;
|
||||
await auth.signOut();
|
||||
await pumpEventQueue();
|
||||
expect(changes, 1);
|
||||
expect(await simpleLogin.standing(), const SimpleLoginUnavailable());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,117 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_app/testing.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
const password = 'correct horse battery';
|
||||
|
||||
void main() {
|
||||
late FakeAuthGateway gateway;
|
||||
late TestAppDependencies dependencies;
|
||||
|
||||
setUp(() {
|
||||
gateway = FakeAuthGateway();
|
||||
dependencies = TestAppDependencies(gateway: gateway);
|
||||
dependencies.simpleLoginGateway.isOffered = true;
|
||||
});
|
||||
tearDown(() => dependencies.dispose());
|
||||
|
||||
/// An app's account section.
|
||||
Future<void> show(WidgetTester tester) async {
|
||||
tester.view.physicalSize = const Size(412, 1200);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(
|
||||
testApp(
|
||||
const Scaffold(
|
||||
body: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: SimpleLoginSection(gapAbove: 16),
|
||||
),
|
||||
),
|
||||
dependencies: dependencies,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<void> signInAccount() =>
|
||||
gateway.useSession(const SessionGrant('person-9', 'session-9'));
|
||||
|
||||
testWidgets('an account without SimpleLogin is offered to add it', (
|
||||
tester,
|
||||
) async {
|
||||
await signInAccount();
|
||||
await show(tester);
|
||||
|
||||
expect(find.text('SimpleLogin'), findsOneWidget);
|
||||
expect(find.widgetWithText(ListTile, 'Add SimpleLogin'), findsOneWidget);
|
||||
expect(
|
||||
find.text('Sign in with SimpleLogin, or with your Proton account.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('nothing where the instance offers no SimpleLogin', (
|
||||
tester,
|
||||
) async {
|
||||
dependencies.simpleLoginGateway.isOffered = false;
|
||||
await signInAccount();
|
||||
await show(tester);
|
||||
|
||||
expect(find.textContaining('SimpleLogin'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('an account that has it: one more way in', (tester) async {
|
||||
dependencies.simpleLoginGateway.addAccount(
|
||||
'person-9',
|
||||
subject: 'simplelogin-ben',
|
||||
);
|
||||
await signInAccount();
|
||||
await show(tester);
|
||||
|
||||
expect(find.widgetWithText(ListTile, 'SimpleLogin'), findsOneWidget);
|
||||
expect(find.text('Signs you in'), findsOneWidget);
|
||||
expect(find.text('Add SimpleLogin'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('adding it: an account with its keys is done once the browser '
|
||||
'is back, says so, and shows it as a way in', (tester) async {
|
||||
final material = await dependencies.crypto.newKeys(password);
|
||||
dependencies.simpleLoginGateway.accounts['person-9'] =
|
||||
FakeSimpleLoginAccount(handle: 'ben', keys: material.setup);
|
||||
await signInAccount();
|
||||
await show(tester);
|
||||
|
||||
await tester.tap(find.widgetWithText(ListTile, 'Add SimpleLogin'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('SimpleLogin added.'), findsOneWidget);
|
||||
expect(dependencies.simpleLoginGateway.linked, {
|
||||
'simplelogin-anna': 'person-9',
|
||||
});
|
||||
expect(find.text('Signs you in'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets("a SimpleLogin account that is another account's is refused", (
|
||||
tester,
|
||||
) async {
|
||||
dependencies.simpleLoginGateway.addAccount(
|
||||
'person-7',
|
||||
subject: 'simplelogin-anna',
|
||||
);
|
||||
await signInAccount();
|
||||
await show(tester);
|
||||
|
||||
await tester.tap(find.widgetWithText(ListTile, 'Add SimpleLogin'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.text('This SimpleLogin account belongs to another account here.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(dependencies.simpleLoginGateway.linked, {
|
||||
'simplelogin-anna': 'person-7',
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue