mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 21:07:15 +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
216 lines
7.0 KiB
Dart
216 lines
7.0 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
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';
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
const routes = WrapperRoutes(
|
|
credentialsPath: '/sync-credentials',
|
|
accountPath: '/sync-account',
|
|
);
|
|
final base = Uri.parse('http://127.0.0.1:8080/');
|
|
|
|
group('loadAccount', () {
|
|
test('returns the stored account without touching the wrapper', () async {
|
|
const stored = FirebaseAccount(email: 'a@b.c', password: 'pw');
|
|
installFakeSecureStorage(
|
|
initial: {kAccountKey: stored.toJsonString()},
|
|
);
|
|
var requests = 0;
|
|
final account = await loadAccount(
|
|
routes: routes,
|
|
base: base,
|
|
httpClient: MockClient((_) async {
|
|
requests += 1;
|
|
return http.Response('', 404);
|
|
}),
|
|
);
|
|
|
|
expect(account?.email, 'a@b.c');
|
|
expect(requests, 0, reason: 'the keystore already answered');
|
|
});
|
|
|
|
test('returns null with no account and no routes', () async {
|
|
installFakeSecureStorage();
|
|
expect(await loadAccount(), isNull);
|
|
});
|
|
|
|
test('honours the opt-out flag instead of re-provisioning', () async {
|
|
installFakeSecureStorage(initial: {kOptOutKey: 'true'});
|
|
var requests = 0;
|
|
final account = await loadAccount(
|
|
routes: routes,
|
|
base: base,
|
|
httpClient: MockClient((_) async {
|
|
requests += 1;
|
|
return http.Response('', 200);
|
|
}),
|
|
);
|
|
|
|
expect(account, isNull);
|
|
expect(requests, 0, reason: 'disconnect must stick');
|
|
});
|
|
|
|
test('adopts a seeded session and persists it', () async {
|
|
installFakeSecureStorage();
|
|
final body = jsonEncode({
|
|
'id_token': 'id',
|
|
'refresh_token': 'refresh',
|
|
'expires_at': DateTime.now()
|
|
.add(const Duration(hours: 1))
|
|
.toIso8601String(),
|
|
'email': 'seeded@b.c',
|
|
});
|
|
final account = await loadAccount(
|
|
routes: routes,
|
|
base: base,
|
|
httpClient: MockClient(
|
|
(request) async => request.url.path == '/sync-credentials'
|
|
? http.Response(body, 200)
|
|
: http.Response('', 404),
|
|
),
|
|
);
|
|
|
|
expect(account?.email, 'seeded@b.c');
|
|
expect(account?.password, isEmpty);
|
|
// Persisted, so the route is consulted once rather than forever.
|
|
expect((await storedAccount())?.email, 'seeded@b.c');
|
|
});
|
|
|
|
test('ignores a seeded body missing a required field', () async {
|
|
installFakeSecureStorage();
|
|
final account = await loadAccount(
|
|
routes: routes,
|
|
base: base,
|
|
httpClient: MockClient(
|
|
(_) async => http.Response(jsonEncode({'id_token': 'id'}), 200),
|
|
),
|
|
);
|
|
|
|
expect(account, isNull);
|
|
});
|
|
|
|
test('ignores a seeded body that is not a JSON object', () async {
|
|
installFakeSecureStorage();
|
|
final account = await loadAccount(
|
|
routes: routes,
|
|
base: base,
|
|
httpClient: MockClient((_) async => http.Response('[]', 200)),
|
|
);
|
|
|
|
expect(account, isNull);
|
|
});
|
|
|
|
test('skips the legacy route when the app declares none', () async {
|
|
installFakeSecureStorage();
|
|
var paths = <String>[];
|
|
final account = await loadAccount(
|
|
routes: const WrapperRoutes(credentialsPath: '/sync-credentials'),
|
|
base: base,
|
|
httpClient: MockClient((request) async {
|
|
paths.add(request.url.path);
|
|
return http.Response('', 404);
|
|
}),
|
|
);
|
|
|
|
expect(account, isNull);
|
|
expect(paths, ['/sync-credentials']);
|
|
});
|
|
|
|
test('reports not-configured when the keystore itself throws', () async {
|
|
installFakeSecureStorage(throwing: true);
|
|
expect(await loadAccount(routes: routes, base: base), isNull);
|
|
});
|
|
|
|
test('falls back to the legacy route and persists what it finds', () async {
|
|
// The un-reseeded machine: no seeded session, but the wrapper still
|
|
// serves an email/password pair from ~/.config/crdt-sync.
|
|
installFakeSecureStorage();
|
|
final account = await loadAccount(
|
|
routes: routes,
|
|
base: base,
|
|
httpClient: MockClient(
|
|
(request) async => request.url.path == '/sync-account'
|
|
? http.Response(
|
|
jsonEncode({'email': 'legacy@b.c', 'password': 'pw'}),
|
|
200,
|
|
)
|
|
: http.Response('', 404),
|
|
),
|
|
);
|
|
|
|
expect(account?.email, 'legacy@b.c');
|
|
// Written through, so the route is consulted once and not forever.
|
|
expect((await storedAccount())?.password, 'pw');
|
|
});
|
|
|
|
test('defaults to Uri.base and a real client with no wrapper', () async {
|
|
// The production call shape: no base, no injected client. Under
|
|
// `flutter test` Uri.base is a file:// URL with no host, so resolving
|
|
// the route raises an ArgumentError -- an Error, not an Exception,
|
|
// which is exactly the Android case the broad catch exists for.
|
|
installFakeSecureStorage();
|
|
|
|
expect(await loadAccount(routes: routes), isNull);
|
|
});
|
|
|
|
test('survives a wrapper that drops the connection', () async {
|
|
installFakeSecureStorage();
|
|
final account = await loadAccount(
|
|
routes: routes,
|
|
base: base,
|
|
httpClient: MockClient((_) async => throw const SocketException('x')),
|
|
);
|
|
|
|
expect(account, isNull);
|
|
});
|
|
});
|
|
|
|
group('account lifecycle', () {
|
|
test('saveAccount then storedAccount round-trips', () async {
|
|
installFakeSecureStorage();
|
|
await saveAccount(const FirebaseAccount(email: 'x@y.z', password: 'p'));
|
|
expect((await storedAccount())?.password, 'p');
|
|
});
|
|
|
|
test('clearAccount forgets the account and sets the opt-out', () async {
|
|
installFakeSecureStorage();
|
|
await saveAccount(const FirebaseAccount(email: 'x@y.z', password: 'p'));
|
|
await clearAccount();
|
|
|
|
expect(await storedAccount(), isNull);
|
|
// The flag is what stops the wrapper silently re-adopting it.
|
|
expect(await loadAccount(routes: routes, base: base), isNull);
|
|
});
|
|
|
|
test('clearAccountMarkerOnly leaves re-provisioning possible', () async {
|
|
installFakeSecureStorage();
|
|
await saveAccount(const FirebaseAccount(email: 'x@y.z', password: 'p'));
|
|
await clearAccountMarkerOnly();
|
|
|
|
expect(await storedAccount(), isNull);
|
|
final body = jsonEncode({
|
|
'id_token': 'id',
|
|
'refresh_token': 'refresh',
|
|
'expires_at': DateTime.now()
|
|
.add(const Duration(hours: 1))
|
|
.toIso8601String(),
|
|
'email': 'again@b.c',
|
|
});
|
|
final account = await loadAccount(
|
|
routes: routes,
|
|
base: base,
|
|
httpClient: MockClient((_) async => http.Response(body, 200)),
|
|
);
|
|
expect(account?.email, 'again@b.c');
|
|
});
|
|
});
|
|
}
|