halkyon_app: instances, sign-in and sync plumbing for Halkyon's apps

What every Halkyon app's shell needs, taken from Halkyon Learn's and set
on halkyon_base's client: the instances a device keeps an account on,
the first start's tiles and where an instance keeps people's data, the
account switcher; signing in anonymously, with GitHub or an email code,
switching person and the start screen; the gates that build an app's
dependencies per instance and per person; and the sync service with its
status, Settings card and banner. Texts of its own in English and
German take the app's name; what only the app can say, it passes in.
Fakes for apps' tests are in testing.dart.

An instance's basics now name the app it serves (`app`, set by the
host's configuration), and an app accepts only an instance of its own.
SerialQueue and CoalescingRunner move into halkyon_core.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-19 15:18:49 +02:00
commit e23cc77f7c
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 10761 additions and 10 deletions

View file

@ -0,0 +1,153 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
void main() {
group('AuthRepository.ensureSignedIn', () {
test('signs in anonymously when nobody is signed in', () async {
final gateway = FakeAuthGateway();
await AuthRepository(gateway, MemorySignInAddresses()).ensureSignedIn();
expect(gateway.signIns, 1);
expect(
gateway.status,
const SignedIn(authUserId: 'anonymous-1', isIdentified: false),
);
});
test('keeps the person already signed in', () async {
const stored = SignedIn(authUserId: 'stored', isIdentified: false);
final gateway = FakeAuthGateway(status: stored);
await AuthRepository(gateway, MemorySignInAddresses()).ensureSignedIn();
expect(gateway.signIns, 0);
expect(gateway.status, stored);
});
});
group('AuthRepository.signOut', () {
test(
'deletes an anonymous account, which nobody could sign into again',
() async {
final gateway = FakeAuthGateway(
status: const SignedIn(
authUserId: 'anonymous-1',
isIdentified: false,
),
);
await AuthRepository(gateway, MemorySignInAddresses()).signOut();
expect(gateway.accountDeletions, 1);
expect(gateway.signOuts, 0);
expect(gateway.status, const SignedOut());
},
);
test('only signs an identified person out on this device', () async {
final gateway = FakeAuthGateway(
status: const SignedIn(authUserId: 'github-1', isIdentified: true),
);
await AuthRepository(gateway, MemorySignInAddresses()).signOut();
expect(gateway.signOuts, 1);
expect(gateway.accountDeletions, 0);
});
});
test('deletes the account of whoever is signed in', () async {
final gateway = FakeAuthGateway(
status: const SignedIn(authUserId: 'github-1', isIdentified: true),
);
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);
});
});
}

View file

@ -0,0 +1,259 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:ui' show AppLifecycleState;
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_web_auth_2/flutter_web_auth_2.dart';
import 'package:flutter_web_auth_2_platform_interface/flutter_web_auth_2_platform_interface.dart';
import 'package:halkyon_app/halkyon_app.dart';
void main() {
const gitHubPage = 'https://github.com/login/oauth/authorize?client_id=app';
late int port;
late String callback;
late Completer<Uri> pageOpened;
late int broughtToFront;
late LoopbackWebAuth webAuth;
/// The loopback part with a fake browser that only records the page it
/// opens; [opens] is whether opening it works.
LoopbackWebAuth loopback({
bool opens = true,
Future<void> Function()? bringToFront,
Duration timeout = const Duration(minutes: 1),
}) => LoopbackWebAuth(
appName: 'Halkyon Learn',
launch: (url) {
pageOpened.complete(url);
return Future.value(opens);
},
bringToFront:
bringToFront ??
() {
broughtToFront++;
return Future.value();
},
timeout: timeout,
);
setUp(() async {
// A port that is free right now, so the tests never meet a running app.
final probe = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
port = probe.port;
await probe.close();
callback = 'http://127.0.0.1:$port/github-callback';
pageOpened = Completer();
broughtToFront = 0;
webAuth = loopback();
});
tearDown(() => webAuth.cancel());
/// Starts a sign-in as flutter_web_auth_2 hands it over.
Future<String> signIn() => webAuth.authenticate(
url: gitHubPage,
callbackUrlScheme: callback,
options: const {},
);
/// The browser asking this machine's [address] for [pathAndQuery], as when
/// GitHub sends it back. Over a raw socket, so that the fake HTTP of
/// Flutter's test binding can't get in between.
Future<({int status, String body})> browserAsks(
String pathAndQuery, {
InternetAddress? address,
}) async {
final socket = await Socket.connect(
address ?? InternetAddress.loopbackIPv4,
port,
timeout: const Duration(seconds: 2),
);
socket.write(
'GET $pathAndQuery HTTP/1.1\r\n'
'Host: 127.0.0.1:$port\r\n'
'Connection: close\r\n\r\n',
);
final response = await utf8.decoder.bind(socket).join();
socket.destroy();
return (
status: int.parse(response.split(' ')[1]),
body: response.substring(response.indexOf('\r\n\r\n') + 4),
);
}
test(
"opens GitHub's page and returns where the browser comes back to",
() async {
final result = signIn();
expect(await pageOpened.future, Uri.parse(gitHubPage));
final response = await browserAsks('/github-callback?code=c&state=s');
expect(await result, '$callback?code=c&state=s');
expect(response.status, HttpStatus.ok);
expect(response.body, contains('return to Halkyon Learn'));
expect(broughtToFront, 1);
},
);
test(
"keeps waiting when the app's window regains focus",
() async {
// The way the bug went: flutter_web_auth_2's front ends every pending
// sign-in when the app is resumed, and on the desktop the window
// regaining focus resumes it.
TestWidgetsFlutterBinding.ensureInitialized();
FlutterWebAuth2Platform.instance = webAuth;
final result = FlutterWebAuth2.authenticate(
url: gitHubPage,
callbackUrlScheme: callback,
);
await pageOpened.future;
// The browser takes the focus; then the person looks back at the app.
await _embedderReports(AppLifecycleState.inactive);
await _embedderReports(AppLifecycleState.resumed);
final response = await browserAsks('/github-callback?code=c&state=s');
expect(response.status, HttpStatus.ok);
expect(await result, '$callback?code=c&state=s');
},
// Elsewhere, flutter_web_auth_2 insists on a URL scheme as the callback.
skip: !(Platform.isLinux || Platform.isWindows),
);
test('a cancel ends the wait as cancelled and stops listening', () async {
final result = signIn();
await pageOpened.future;
await webAuth.cancel();
await expectLater(result, throwsA(_cancelled));
await expectLater(
browserAsks('/github-callback?code=c&state=s'),
throwsA(isA<SocketException>()),
);
});
test('gives up after the timeout and stops listening', () async {
webAuth = loopback(timeout: const Duration(milliseconds: 50));
await expectLater(signIn(), throwsA(isA<TimeoutException>()));
await expectLater(
browserAsks('/github-callback?code=c&state=s'),
throwsA(isA<SocketException>()),
);
});
test('a second sign-in ends the first as cancelled', () async {
final first = signIn();
await pageOpened.future;
pageOpened = Completer();
final second = signIn();
await expectLater(first, throwsA(_cancelled));
await pageOpened.future;
await browserAsks('/github-callback?code=c&state=s');
expect(await second, '$callback?code=c&state=s');
});
test('answers other paths with "not found" and keeps waiting', () async {
final result = signIn();
await pageOpened.future;
final stray = await browserAsks('/favicon.ico');
final response = await browserAsks('/github-callback?code=c&state=s');
expect(stray.status, HttpStatus.notFound);
expect(response.status, HttpStatus.ok);
expect(await result, '$callback?code=c&state=s');
});
test('listens on the loopback address only', () async {
final otherAddresses = [
for (final interface in await NetworkInterface.list())
for (final address in interface.addresses)
if (!address.isLoopback) address,
];
final result = signIn();
await pageOpened.future;
for (final address in otherAddresses) {
await expectLater(
browserAsks('/github-callback?code=c&state=s', address: address),
throwsA(isA<SocketException>()),
reason: '$address',
);
}
await browserAsks('/github-callback?code=c&state=s');
expect(await result, '$callback?code=c&state=s');
});
test("stops listening when the browser can't be opened", () async {
webAuth = loopback(opens: false);
await expectLater(signIn(), throwsA(isA<PlatformException>()));
await expectLater(
browserAsks('/github-callback?code=c&state=s'),
throwsA(isA<SocketException>()),
);
});
test("signs in even when the window can't come to the front", () async {
webAuth = loopback(
bringToFront: () => Future.error(MissingPluginException()),
);
final result = signIn();
await pageOpened.future;
await browserAsks('/github-callback?code=c&state=s');
expect(await result, '$callback?code=c&state=s');
});
test(
'refuses a callback other than a path on 127.0.0.1 with a port',
() async {
for (final address in [
'http://0.0.0.0:$port/github-callback',
'http://localhost:$port/github-callback',
'http://example.com:$port/github-callback',
'https://127.0.0.1:$port/github-callback',
'http://127.0.0.1/github-callback',
'http://127.0.0.1:$port',
]) {
await expectLater(
webAuth.authenticate(
url: gitHubPage,
callbackUrlScheme: address,
options: const {},
),
throwsArgumentError,
reason: address,
);
}
expect(pageOpened.isCompleted, isFalse);
},
);
}
/// How flutter_web_auth_2 reports a cancelled sign-in, which is how the
/// gateway's callers learn of it.
final _cancelled = isA<PlatformException>().having(
(error) => error.code,
'code',
'CANCELED',
);
/// The embedder reporting the app's lifecycle, as it does when the window
/// gains or loses focus.
Future<void> _embedderReports(AppLifecycleState state) =>
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.handlePlatformMessage(
SystemChannels.lifecycle.name,
SystemChannels.lifecycle.codec.encodeMessage(state.toString()),
(_) {},
);

View file

@ -0,0 +1,186 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_base_client/halkyon_base_client.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:serverpod_auth_idp_flutter/serverpod_auth_idp_flutter.dart';
/// A session kept in memory, as no test reaches the system's storage.
final class _MemoryStorage() implements ClientAuthSuccessStorage {
AuthSuccess? _session;
@override
Future<AuthSuccess?> get() async => _session;
@override
Future<void> set(AuthSuccess? data) async => _session = data;
}
/// An instance's halkyon_base as the network shows it: anonymous sign-ins
/// get `anonymous-token`, its basics offer GitHub and email, and an address
/// belongs to Ben, whom a switch signs in as with `ben-token`.
final class _Instance() {
static final anonymous = UuidValue.withValidation(
'0191f0c4-1111-7000-8000-000000000001',
);
static final ben = UuidValue.withValidation(
'0191f0c4-2222-7000-8000-000000000002',
);
/// Each call, by its endpoint and method, with the session it carried.
final calls = <({String call, String? session})>[];
/// What mailing a code is refused for, while set.
EmailSignInRefusal? sendRefused;
http.Client client() => MockClient((request) async {
final call = request.url.path.substring(1);
calls.add((call: call, session: request.headers['authorization']));
String sent(Object? body) => jsonEncode(body);
return switch (call) {
'halkyon_base.anonymousIdp/login' => http.Response(
sent(_session(anonymous, 'anonymous-token', identified: false)),
200,
),
'halkyon_base.instance/basics' => http.Response(
sent(
InstanceBasics(
app: 'learn',
gitHubClientId: 'github-client',
emailSignIn: true,
sourceCodeUrl: 'https://code.example.org/learn',
minimumAppBuild: 1,
).toJson(),
),
200,
),
'halkyon_base.emailCode/sendCode' => switch (sendRefused) {
final reason? => http.Response(
sent({
'className': 'halkyon_base.EmailSignInRefusedException',
'data': EmailSignInRefusedException(reason: reason).toJson(),
}),
400,
),
null => http.Response(
sent('0191f0c4-3333-7000-8000-000000000003'),
200,
),
},
'halkyon_base.emailCode/verifyCode' => http.Response(
sent(EmailCodeAccepted(switchToken: 'switch-to-ben').toJson()),
200,
),
'halkyon_base.emailSignIn/signIn' => http.Response(
sent(_session(ben, 'ben-token', identified: true)),
200,
),
'halkyon_base.account/delete' => http.Response('null', 200),
_ => http.Response('', 404),
};
});
static Map<String, dynamic> _session(
UuidValue person,
String token, {
required bool identified,
}) => AuthSuccess(
authStrategy: 'session',
token: token,
authUserId: person,
scopeNames: {if (identified) 'identified'},
).toJson();
}
void main() {
late _Instance instance;
late BaseClient app;
late ServerpodAuthGateway gateway;
setUp(() async {
instance = _Instance();
app =
BaseClient(
'https://learn.example.org/',
httpClientOverride: instance.client(),
)
..authSessionManager = FlutterAuthSessionManager(
storage: _MemoryStorage(),
);
await app.auth.initialize();
gateway = ServerpodAuthGateway(
app,
base: app.modules,
callbackScheme: 'app.halkyon.test',
appName: 'Halkyon Test',
httpClient: instance.client,
);
addTearDown(() async {
await gateway.dispose();
app.close();
});
});
test(
'signs in anonymously through halkyon_base, nobody identified',
() async {
await gateway.signInAnonymously();
expect(
gateway.status,
SignedIn(authUserId: '${_Instance.anonymous}', isIdentified: false),
);
},
);
test('tells what the instance offers from its basics', () async {
expect(await gateway.gitHubClientId(), 'github-client');
expect(await gateway.offersEmailSignIn(), isTrue);
});
test("the instance's refusals to mail a code arrive in the package's own "
'words', () async {
await gateway.signInAnonymously();
instance.sendRefused = EmailSignInRefusal.tooManyCodes;
await expectLater(
gateway.sendEmailCode('ben@example.org', language: 'en'),
throwsA(
isA<EmailSignInFailed>().having(
(failure) => failure.problem,
'problem',
EmailSignInProblem.tooManyCodes,
),
),
);
});
test("switching to an address's person signs in without this device's "
'session, then deletes the anonymous account left behind as '
'itself', () async {
await gateway.signInAnonymously();
final request = await gateway.sendEmailCode(
'ben@example.org',
language: 'en',
);
final outcome = await gateway.verifyEmailCode(request, 'K7QXM2PD');
expect(outcome, const EmailTakenByAnotherPerson('switch-to-ben'));
expect(await gateway.switchToEmail('switch-to-ben'), '${_Instance.ben}');
expect(
gateway.status,
SignedIn(authUserId: '${_Instance.ben}', isIdentified: true),
);
final signIn = instance.calls.singleWhere(
(call) => call.call == 'halkyon_base.emailSignIn/signIn',
);
expect(signIn.session, isNull, reason: 'no session goes with it');
final deletion = instance.calls.singleWhere(
(call) => call.call == 'halkyon_base.account/delete',
);
expect(deletion.session, contains('anonymous-token'));
});
}