utils/crdt_sync_dart/test/firebase_account_provisioning_test.dart
Krzysztof kuhy Rudnicki 6a080333c2 Let a desktop wrapper provision the sync account, no UI required
Desktop builds of todo/diet-guard/home_inventory are Flutter *web* in a
Chrome --app window, so FlutterSecureStorage resolves to the web backend:
AES-encrypted localStorage keyed inside the browser profile. That makes the
account unsettable from outside the page -- `secret-tool` writes the wrong
store (libsecret, which only the Linux build would read), and a Dart CLI
cannot even import flutter_secure_storage, since it is a Flutter plugin and
fails to resolve on the VM. Verified both, rather than assumed.

The one process that can read the filesystem *and* reach the page is the
wrapper already serving it -- the same seam diet-guard uses to keep the
GitHub token out of the browser. So this adds the shared half of a wrapper
route: the path constant, the opt-in env var, an opt-out marker key, and
accountFromWrapper(), which fetches and hands the body to the existing
FirebaseAccount.tryParse.

Additive only, so consumers adopt it per-app on their own schedule; nothing
forces a phone rebuild. Route handlers stay per-app because each wrapper owns
its own server.

Deliberately opt-in (CRDT_SYNC_SERVE_ACCOUNT): the route hands a credential
with database write access to anything that can reach the wrapper's port, so
it is set once to provision an install rather than left on. kSyncAccountOptOutKey
exists so "Disconnect" cannot be silently undone by the next launch
re-adopting the account.

Every failure path returns null rather than throwing -- an absent route is
the *normal* case -- so the caller degrades to "not configured", which the
settings screens already handle by asking.

254 tests, analyzer clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177xwzcpfpWG2qca8Mr39q9
2026-08-09 21:33:22 +02:00

73 lines
2.2 KiB
Dart

import 'dart:convert';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart' as http_testing;
import 'package:test/test.dart';
final _base = Uri.parse('http://localhost:8730');
void main() {
group('accountFromWrapper', () {
test('parses the account the wrapper serves', () async {
final client = http_testing.MockClient(
(request) async => http.Response(
jsonEncode({'email': 'a@b.c', 'password': 'pw'}),
200,
),
);
final account = await accountFromWrapper(_base, client: client);
expect(account?.email, 'a@b.c');
expect(account?.password, 'pw');
});
test('requests the shared route path', () async {
var requested = '';
final client = http_testing.MockClient((request) async {
requested = request.url.path;
return http.Response('{}', 404);
});
await accountFromWrapper(_base, client: client);
expect(requested, kSyncAccountPath);
});
test('a missing route is not configured, not an error', () async {
// The normal case: the route is opt-in, so 404 is what most launches
// see and it must degrade to "ask the user" rather than throw.
final client = http_testing.MockClient(
(request) async => http.Response('', 404),
);
expect(await accountFromWrapper(_base, client: client), isNull);
});
test('a malformed body yields null rather than throwing', () async {
final client = http_testing.MockClient(
(request) async => http.Response('{not json', 200),
);
expect(await accountFromWrapper(_base, client: client), isNull);
});
test('a body missing the password yields null', () async {
final client = http_testing.MockClient(
(request) async => http.Response(jsonEncode({'email': 'a@b.c'}), 200),
);
expect(await accountFromWrapper(_base, client: client), isNull);
});
test('a wrapper that is not running yields null', () async {
final client = http_testing.MockClient(
(request) async => throw http.ClientException('refused'),
);
expect(await accountFromWrapper(_base, client: client), isNull);
});
});
}