Every account signs in with SimpleLogin or a password now, so GitHub goes from the module, the shared app package, Learn and Todo, ahead of ADR 0017's GITHUB_SIGN_IN_ENDS. - halkyon_base: no `gitHubSignIn` endpoint, GitHub identity provider or GitHub refusals; no `githubClientId`/`githubClientSecret`, no `GITHUB_SIGN_IN_ENDS` and no `forgetGitHubAccounts`; the register loses `account-with-github`. `SignInWays.gitHub`, the basics' `gitHubClientId` and `gitHubSignInEnds`, and `PasswordRefusal`'s `signInAgain` and `noAddress` go. A first password takes a code mailed to the person's own address; an account without one sets none (`wrongCredentials`). - Learn's server: `instance.info()` without `gitHubClientId`, the pruning without GitHub, and `minimumAppBuild` 24, as builds up to 23 (v0.20.0) require `SignInWays.gitHub`: the server deploys with the release of build 24. - halkyon_app: no GitHub button or problems on the start screen, no password offered after a GitHub sign-in, and `ServerpodAuthGateway` without the browser half only GitHub used (`(client, base:)`). The account section offers a first password only to an account with an address. `LoopbackWebAuth` and the apps' schemes stay, SimpleLogin's ways back. - Learn's Profile lists no GitHub; the texts go (English, German). - Serverpod's `serverpod_auth_idp_github_account` stays, as its migrations make it; a row goes with its account, and ADR 0020's D36 gives operators the SQL to delete those left. - deploy/: compose.yaml and .env.example without GitHub. The READMEs, AGENTS.md files and ADR 0017 amended. BREAKING CHANGE: builds up to 23 can't read the instance's answers about an account's ways in; minimumAppBuild is 24. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
389 lines
13 KiB
Dart
389 lines
13 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:clock/clock.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:halkyon_app/halkyon_app.dart';
|
|
import 'package:halkyon_app/testing.dart';
|
|
import 'package:halkyon_ui/halkyon_ui.dart';
|
|
import 'package:material_ui/material_ui.dart';
|
|
|
|
/// A signed-in person's app as a test makes one: whose it is, and the
|
|
/// account's tiles in the app's own words.
|
|
class const _PersonApp(final SignedIn person) extends StatelessWidget {
|
|
@override
|
|
Widget build(BuildContext context) => MaterialApp(
|
|
localizationsDelegates: testLocalizationsDelegates,
|
|
supportedLocales: HalkyonAppLocalizations.supportedLocales,
|
|
home: Scaffold(
|
|
body: ListView(
|
|
children: [
|
|
Text('App of ${person.authUserId}'),
|
|
const AccountTiles(
|
|
deleteMessage: 'Your notes are deleted everywhere.',
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void main() {
|
|
late FakeAuthGateway gateway;
|
|
late TestAppDependencies dependencies;
|
|
|
|
Future<void> start(WidgetTester tester, {InstanceChoice? choice}) async {
|
|
dependencies = TestAppDependencies(gateway: gateway);
|
|
final gate = AppScope(
|
|
dependencies: dependencies,
|
|
child: SessionGate(
|
|
look: (context) => (themeMode: ThemeMode.light, locale: null),
|
|
app: _PersonApp.new,
|
|
),
|
|
);
|
|
await tester.pumpWidget(
|
|
AppFrameScope(
|
|
frame: testFrame(),
|
|
child: switch (choice) {
|
|
final choice? => InstanceScope(
|
|
notifier: choice,
|
|
checker: FakeInstanceChecker(),
|
|
countryNames: MemoryCountryNames(const {}),
|
|
subdivisionNames: const MemorySubdivisionNames({}),
|
|
child: gate,
|
|
),
|
|
null => gate,
|
|
},
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
}
|
|
|
|
setUp(() => gateway = FakeAuthGateway());
|
|
|
|
testWidgets('says on which instance the account is signed in', (
|
|
tester,
|
|
) async {
|
|
await start(tester);
|
|
|
|
expect(find.text('Signed in on test.example.org'), findsOneWidget);
|
|
expect(
|
|
find.text('Switch account'),
|
|
findsNothing,
|
|
reason: 'no instances to switch among here',
|
|
);
|
|
expect(find.text('Sign out'), findsOneWidget);
|
|
expect(find.text('Delete account'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('switching account lists the accounts on the device, the '
|
|
'instance in use named', (tester) async {
|
|
final instance = Instance.parse('learn.example.org')!;
|
|
final choice = InstanceChoice(
|
|
MemoryInstanceStore((current: instance, known: [instance])),
|
|
builtIn: instance,
|
|
forget: (instance) async {},
|
|
sharesStorage: (a, b) => false,
|
|
);
|
|
await choice.load(usedBefore: false);
|
|
await start(tester, choice: choice);
|
|
|
|
expect(find.text('Switch account'), findsOneWidget);
|
|
expect(find.text('learn.example.org'), findsWidgets);
|
|
|
|
await tester.tap(find.text('Switch account'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Accounts'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('signing out asks nothing, deleting nothing', (tester) async {
|
|
await start(tester);
|
|
|
|
await tester.tap(find.text('Sign out'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Sign out?'), findsNothing);
|
|
expect(gateway.status, const SignedOut());
|
|
expect(gateway.accountDeletions, 0);
|
|
});
|
|
|
|
testWidgets("deleting the account asks first, in the app's words; Cancel "
|
|
'keeps it', (tester) async {
|
|
await start(tester);
|
|
|
|
await tester.tap(find.text('Delete account'));
|
|
await tester.pumpAndSettle();
|
|
expect(find.text('Delete your account?'), findsOneWidget);
|
|
expect(find.text('Your notes are deleted everywhere.'), findsOneWidget);
|
|
|
|
await tester.tap(find.text('Cancel'));
|
|
await tester.pumpAndSettle();
|
|
expect(gateway.accountDeletions, 0);
|
|
|
|
await tester.tap(find.text('Delete account'));
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.text('Delete account').last);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(gateway.accountDeletions, 1);
|
|
expect(find.text('Start on test.example.org:'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('deleting an identified account while the instance is out of '
|
|
'reach says so, and deletes nothing (the short reading)', (tester) async {
|
|
gateway = FakeAuthGateway(
|
|
status: const SignedIn(authUserId: 'person-2', isIdentified: true),
|
|
);
|
|
await start(tester);
|
|
dependencies.passwordGateway.failing = const PasswordFailed(
|
|
PasswordProblem.failed,
|
|
);
|
|
|
|
await tester.tap(find.text('Delete account'));
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.text('Delete account').last);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(
|
|
find.text(
|
|
"Couldn't reach the instance. Check the connection and try again.",
|
|
),
|
|
findsOneWidget,
|
|
);
|
|
expect(gateway.accountDeletions, 0);
|
|
expect(find.text('App of person-2'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('deleting an account whose setup never finished asks for the '
|
|
'password and a code by mail', (tester) async {
|
|
// The first person the fake password gateway makes.
|
|
const person = '00000000-0000-4000-8000-000000000001';
|
|
gateway = FakeAuthGateway(
|
|
status: const SignedIn(authUserId: person, isIdentified: true),
|
|
);
|
|
await start(tester);
|
|
dependencies.passwordGateway.addAccount(
|
|
'anna@example.org',
|
|
password: 'correct horse',
|
|
crypto: dependencies.crypto,
|
|
finished: false,
|
|
);
|
|
dependencies.addresses.addresses[person] = 'anna@example.org';
|
|
Finder field(String label) => find.widgetWithText(TextField, label);
|
|
|
|
await tester.tap(find.text('Delete account'));
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.text('Delete account').last);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.textContaining('a code by mail confirms it'), findsOneWidget);
|
|
await tester.enterText(field('Current password'), 'correct horse');
|
|
await tester.tap(find.text('Email me a code'));
|
|
await tester.pumpAndSettle();
|
|
expect(
|
|
dependencies.passwordGateway.mailed.single.purpose,
|
|
MailCodePurpose.deletion,
|
|
);
|
|
expect(find.textContaining('anna@example.org'), findsOneWidget);
|
|
expect(gateway.accountDeletions, 0);
|
|
|
|
await tester.enterText(
|
|
field('Code from the email'),
|
|
dependencies.passwordGateway.codeFor('anna@example.org'),
|
|
);
|
|
await tester.tap(find.text('Delete account').last);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(gateway.accountDeletions, 1);
|
|
expect(
|
|
dependencies.passwordGateway.accounts,
|
|
isNot(contains('anna@example.org')),
|
|
);
|
|
});
|
|
|
|
testWidgets("deleting an account whose authenticators' reset took effect "
|
|
'asks for a code by mail, as the instance takes it, and for the '
|
|
"address where this device doesn't know it", (tester) async {
|
|
// The first person the fake password gateway makes.
|
|
const person = '00000000-0000-4000-8000-000000000001';
|
|
gateway = FakeAuthGateway(
|
|
status: const SignedIn(authUserId: person, isIdentified: true),
|
|
);
|
|
await start(tester);
|
|
// Its authenticator still confirmed, the reset taken effect but not
|
|
// carried out (the third reading's fourth finding).
|
|
dependencies.passwordGateway
|
|
.addAccount(
|
|
'anna@example.org',
|
|
password: 'correct horse',
|
|
crypto: dependencies.crypto,
|
|
)
|
|
.authenticatorReset = clock.now().subtract(
|
|
const Duration(hours: 1),
|
|
);
|
|
Finder field(String label) => find.widgetWithText(TextField, label);
|
|
|
|
await tester.tap(find.text('Delete account'));
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.text('Delete account').last);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.textContaining('a code by mail confirms it'), findsOneWidget);
|
|
await tester.enterText(field('Current password'), 'correct horse');
|
|
await tester.enterText(field('Email address'), 'anna@example.org');
|
|
await tester.tap(find.text('Email me a code'));
|
|
await tester.pumpAndSettle();
|
|
expect(
|
|
dependencies.passwordGateway.mailed.single.purpose,
|
|
MailCodePurpose.deletion,
|
|
);
|
|
|
|
await tester.enterText(
|
|
field('Code from the email'),
|
|
dependencies.passwordGateway.codeFor('anna@example.org'),
|
|
);
|
|
await tester.tap(find.text('Delete account').last);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(gateway.accountDeletions, 1);
|
|
});
|
|
|
|
testWidgets('deleting an account with a password asks for it and a '
|
|
'code, which a wrong one fails, deleting nothing', (tester) async {
|
|
// The first person the fake password gateway makes.
|
|
const person = '00000000-0000-4000-8000-000000000001';
|
|
gateway = FakeAuthGateway(
|
|
status: const SignedIn(authUserId: person, isIdentified: true),
|
|
);
|
|
await start(tester);
|
|
dependencies.passwordGateway.addAccount(
|
|
'anna@example.org',
|
|
password: 'correct horse',
|
|
crypto: dependencies.crypto,
|
|
);
|
|
// The address this device signed in with, which deleting sends.
|
|
dependencies.addresses.addresses[person] = 'anna@example.org';
|
|
Finder field(String label) => find.widgetWithText(TextField, label);
|
|
|
|
await tester.tap(find.text('Delete account'));
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.text('Delete account').last);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(gateway.accountDeletions, 0);
|
|
expect(field('Current password'), findsOneWidget);
|
|
await tester.enterText(field('Current password'), 'wrong horse');
|
|
await tester.enterText(
|
|
field('Code from your authenticator app'),
|
|
fakeTotpCode,
|
|
);
|
|
await tester.tap(find.text('Delete account').last);
|
|
await tester.pumpAndSettle();
|
|
expect(
|
|
find.text("The address, the password or the code isn't right."),
|
|
findsOneWidget,
|
|
);
|
|
expect(gateway.accountDeletions, 0);
|
|
expect(dependencies.passwordGateway.accounts, contains('anna@example.org'));
|
|
|
|
await tester.enterText(field('Current password'), 'correct horse');
|
|
await tester.tap(find.text('Delete account').last);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(gateway.accountDeletions, 1);
|
|
expect(
|
|
dependencies.passwordGateway.accounts,
|
|
isNot(contains('anna@example.org')),
|
|
);
|
|
expect(find.text('Start on test.example.org:'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('while the account is deleted, the person stays in their app, '
|
|
'Delete account showing it is under way', (tester) async {
|
|
await start(tester);
|
|
gateway.deletionWaits = Completer();
|
|
|
|
await tester.tap(find.text('Delete account'));
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.text('Delete account').last);
|
|
// The spinner never settles while it runs.
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 100));
|
|
|
|
expect(
|
|
find.text('App of person-1'),
|
|
findsOneWidget,
|
|
reason: 'never the launch screen in between',
|
|
);
|
|
expect(
|
|
find.descendant(
|
|
of: find.widgetWithText(ListTile, 'Delete account'),
|
|
matching: find.byType(ButtonLoadingIndicator),
|
|
),
|
|
findsOneWidget,
|
|
);
|
|
|
|
gateway.deletionWaits!.complete();
|
|
await tester.pumpAndSettle();
|
|
expect(find.text('Start on test.example.org:'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('a deletion the instance never got keeps the person in their '
|
|
'app, and says so once', (tester) async {
|
|
await start(tester);
|
|
gateway.deletionError = Exception('No connection');
|
|
|
|
await tester.tap(find.text('Delete account'));
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.text('Delete account').last);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(
|
|
find.text(
|
|
"Couldn't delete your account: the instance wasn't reached. "
|
|
'Try again.',
|
|
),
|
|
findsOneWidget,
|
|
);
|
|
expect(find.text('App of person-1'), findsOneWidget);
|
|
expect(gateway.status, isA<SignedIn>());
|
|
expect(find.byType(ButtonLoadingIndicator), findsNothing);
|
|
});
|
|
|
|
testWidgets("a sign-out the device's storage failed keeps the person in "
|
|
'their app, and says so', (tester) async {
|
|
gateway = FakeAuthGateway(
|
|
status: const SignedIn(authUserId: 'person-2', isIdentified: true),
|
|
)..signOutError = Exception('The keyring is locked');
|
|
await start(tester);
|
|
|
|
await tester.tap(find.text('Sign out'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(
|
|
find.text("Couldn't sign out on this device. Try again."),
|
|
findsOneWidget,
|
|
);
|
|
expect(find.text('App of person-2'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('speaks German', (tester) async {
|
|
await start(tester);
|
|
final german = lookupHalkyonAppLocalizations(const Locale('de'));
|
|
|
|
expect(german.accountSignedInOn('x'), 'Angemeldet auf x');
|
|
expect(german.accountSwitch, 'Konto wechseln');
|
|
expect(german.accountSignOut, 'Abmelden');
|
|
expect(german.accountDelete, 'Konto löschen');
|
|
expect(german.accountDeleteTitle, 'Dein Konto löschen?');
|
|
expect(
|
|
german.accountSignOutFailed,
|
|
'Abmelden auf diesem Gerät hat nicht geklappt. Versuch es noch einmal.',
|
|
);
|
|
expect(
|
|
german.accountDeleteFailed,
|
|
'Dein Konto wurde nicht gelöscht: Die Instanz war nicht erreichbar. '
|
|
'Versuch es noch einmal.',
|
|
);
|
|
});
|
|
}
|