mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 22:27:13 +01:00
`crdt_sync` is pure Dart so it can run from CLI tools and headless systemd jobs, which means it takes closures instead of depending on flutter_secure_storage and google_sign_in. Every Flutter app then wrote the same ~390 lines to supply them, and five apps ended up carrying near-identical copies of firebase_backend.dart. Those copies drifted. `todo` gained an opt-out flag and a seeded-session path that home_inventory and wake_alarm never received, so the same bug was fixed in one copy and left live in the others -- which is the actual cost being paid here, not the line count. This package owns that glue once: the keystore adapter, the persisted per-install device id, the account store and a one-call bootstrap. A new app needs a SyncApp const and `openSync(kApp)`. The wrapper routes are a parameter rather than a constant, because todo's copy hardcoded its own `~/.config/todo/firebase_auth.json`. An extraction only one app could adopt would be worse than the copies. Also documents what made this hard to find: crdt_sync_dart's README covered only the *old* GitHub transport and never mentioned the Firebase surface at all, and sync_settings_ui -- a complete drop-in settings screen -- had no README whatsoever. The five existing apps stay on their copies and on crdt_sync_dart-v0.10.0. Migration is forward-only and those fallbacks are load-bearing; converging them is a separate task, and the README says so. 35 tests, 100% line coverage, no suppressions. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
211 lines
6.3 KiB
Dart
211 lines
6.3 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:crdt_sync/crdt_sync.dart';
|
|
import 'package:crdt_sync_flutter/crdt_sync_flutter.dart';
|
|
import 'package:crdt_sync_flutter/testing/fake_secure_storage.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:http/testing.dart';
|
|
|
|
const _app = SyncApp(
|
|
project: FirebaseProject(
|
|
apiKey: 'test-key',
|
|
databaseUrl: 'https://example-rtdb.europe-west1.firebasedatabase.app',
|
|
),
|
|
expectedUid: 'uid-123',
|
|
);
|
|
|
|
/// A live session, so no sign-in round trip is attempted.
|
|
String _session({Duration valid = const Duration(hours: 1)}) => jsonEncode({
|
|
'id_token': 'id',
|
|
'refresh_token': 'refresh',
|
|
'expires_at': DateTime.now().add(valid).toIso8601String(),
|
|
});
|
|
|
|
/// Answers Identity Toolkit as though [email] signed in.
|
|
MockClient _signsInAs(String? email, {String uid = 'uid-123'}) => MockClient(
|
|
(_) async => http.Response(
|
|
jsonEncode({
|
|
'idToken': 'id',
|
|
'refreshToken': 'refresh',
|
|
'expiresIn': '3600',
|
|
'email': email,
|
|
'localId': uid,
|
|
}),
|
|
200,
|
|
),
|
|
);
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
group('openSync', () {
|
|
test('returns null when nothing is configured', () async {
|
|
installFakeSecureStorage();
|
|
expect(await openSync(_app), isNull);
|
|
});
|
|
|
|
test('recovers a client from a stored session with no marker', () async {
|
|
// The state a Google sign-in used to leave behind. The refresh token is
|
|
// the credential, so this device can sync even with no account blob.
|
|
installFakeSecureStorage(
|
|
initial: {SecureCredentialStore.defaultKey: _session()},
|
|
);
|
|
|
|
final client = await openSync(_app);
|
|
|
|
expect(client, isNotNull);
|
|
client?.close();
|
|
});
|
|
|
|
test('uses a stored account with a live session', () async {
|
|
installFakeSecureStorage(
|
|
initial: {
|
|
kAccountKey: const FirebaseAccount(
|
|
email: 'a@b.c',
|
|
password: 'pw',
|
|
).toJsonString(),
|
|
SecureCredentialStore.defaultKey: _session(),
|
|
},
|
|
);
|
|
|
|
final client = await openSync(_app);
|
|
|
|
expect(client, isNotNull);
|
|
client?.close();
|
|
});
|
|
|
|
test('treats a Google account (empty password) as configured', () async {
|
|
// The regression this package's crdt_sync bump fixed: the marker parses
|
|
// now, so this takes the account path rather than falling through.
|
|
installFakeSecureStorage(
|
|
initial: {
|
|
kAccountKey: const FirebaseAccount(
|
|
email: 'g@b.c',
|
|
password: '',
|
|
).toJsonString(),
|
|
SecureCredentialStore.defaultKey: _session(),
|
|
},
|
|
);
|
|
|
|
expect(await storedAccount(), isNotNull);
|
|
final client = await openSync(_app);
|
|
|
|
expect(client, isNotNull);
|
|
client?.close();
|
|
});
|
|
});
|
|
|
|
group('signInWithGoogle', () {
|
|
test('returns null when the user dismisses the picker', () async {
|
|
installFakeSecureStorage();
|
|
|
|
final client = await signInWithGoogle(
|
|
_app,
|
|
tokenFetcher: () async => null,
|
|
);
|
|
|
|
expect(client, isNull);
|
|
expect(await storedAccount(), isNull);
|
|
});
|
|
|
|
test('persists the email Firebase reports', () async {
|
|
installFakeSecureStorage();
|
|
|
|
final client = await signInWithGoogle(
|
|
_app,
|
|
tokenFetcher: () async => 'google-token',
|
|
httpClient: _signsInAs('reported@b.c'),
|
|
);
|
|
|
|
expect(client, isNotNull);
|
|
expect((await storedAccount())?.email, 'reported@b.c');
|
|
client?.close();
|
|
});
|
|
|
|
test('stays configured when Google hides the address', () async {
|
|
// signInWithIdp omits `email` for a hidden address, so the marker is
|
|
// written as `email: ''` -- which `tryParse` rejects, deliberately: an
|
|
// address-less marker carries no identity. The device is still fully
|
|
// signed in, because the refresh token is the credential and it is
|
|
// durable. This is exactly why openSync falls back to stored-session
|
|
// recovery instead of trusting the marker.
|
|
installFakeSecureStorage();
|
|
|
|
final client = await signInWithGoogle(
|
|
_app,
|
|
tokenFetcher: () async => 'google-token',
|
|
httpClient: _signsInAs(null),
|
|
);
|
|
client?.close();
|
|
|
|
expect(client, isNotNull);
|
|
expect(await isSyncConfigured(_app), isTrue);
|
|
final recovered = await openSync(_app);
|
|
expect(recovered, isNotNull, reason: 'the session alone is enough');
|
|
recovered?.close();
|
|
});
|
|
|
|
test('throws when Google resolves to the wrong uid', () async {
|
|
installFakeSecureStorage();
|
|
|
|
await expectLater(
|
|
signInWithGoogle(
|
|
_app,
|
|
tokenFetcher: () async => 'google-token',
|
|
httpClient: _signsInAs('a@b.c', uid: 'someone-else'),
|
|
),
|
|
throwsA(isA<FirebaseAuthError>()),
|
|
);
|
|
});
|
|
});
|
|
|
|
group('isSyncConfigured', () {
|
|
test('is true with a live session', () async {
|
|
installFakeSecureStorage(
|
|
initial: {SecureCredentialStore.defaultKey: _session()},
|
|
);
|
|
expect(await isSyncConfigured(_app), isTrue);
|
|
});
|
|
|
|
test('is false with nothing stored', () async {
|
|
installFakeSecureStorage();
|
|
expect(await isSyncConfigured(_app), isFalse);
|
|
});
|
|
|
|
test('drops a marker whose session is gone', () async {
|
|
// A revoked refresh token clears the session; a marker left behind
|
|
// would report "Connected" while every sync failed.
|
|
installFakeSecureStorage(
|
|
initial: {
|
|
kAccountKey: const FirebaseAccount(
|
|
email: 'a@b.c',
|
|
password: 'pw',
|
|
).toJsonString(),
|
|
},
|
|
);
|
|
|
|
expect(await isSyncConfigured(_app), isFalse);
|
|
expect(
|
|
await storedAccount(),
|
|
isNull,
|
|
reason: 'the stale half is dropped so settings offers a sign-in',
|
|
);
|
|
});
|
|
|
|
test('reports not-configured when the keystore throws', () async {
|
|
installFakeSecureStorage(throwing: true);
|
|
expect(await isSyncConfigured(_app), isFalse);
|
|
});
|
|
});
|
|
|
|
test('openSync survives a keystore that cannot be read at all', () async {
|
|
// A Linux box with no libsecret: every keystore call raises. There is no
|
|
// account and no session to be had, so this device simply does not sync
|
|
// -- but it must not take the caller down with it.
|
|
installFakeSecureStorage(throwing: true);
|
|
|
|
expect(await openSync(_app), isNull);
|
|
});
|
|
}
|