todo-app/test/firebase_backend_test.dart
Krzysztof kuhy Rudnicki a0d37c4df8
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Re-point paths for the ~ reorganisation (home-tidy)
Also bumps Flutter 3.47.3, flutter_secure_storage 11.1.0, idb_shim 2.9.9,
sqflite_common_ffi 2.4.3, very_good_analysis 11 and mcp 2.2.0 so the
freshness gate lets this through; v11 adds async_return_with_no_await, so
the 18 flagged return sites now await explicitly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwtowrowDXx6ccmWVPkCaF
2026-09-11 19:49:03 +02:00

195 lines
6.9 KiB
Dart

import 'dart:convert';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart' as http_testing;
import 'package:todo/sync/firebase_backend.dart';
import 'package:todo/sync/google_sign_in_backend.dart';
import 'fake_secure_storage.dart';
/// Answers a `signInWithIdp` call the way Firebase does.
http_testing.MockClient _signsInAs(String email, {String uid = kSyncUid}) =>
http_testing.MockClient(
(request) async => http.Response(
jsonEncode({
'idToken': 'id',
'refreshToken': 'refresh',
'expiresIn': '3600',
'email': email,
'localId': uid,
}),
200,
),
);
void main() {
// installFakeSecureStorage touches the test binary messenger, which only
// exists once the binding is initialised.
TestWidgetsFlutterBinding.ensureInitialized();
// openFirebaseWithGoogle reaches the keystore through a platform channel;
// the fake stands in so the account write is really exercised rather than
// stubbed out at the call site.
setUp(installFakeSecureStorage);
group('openFirebaseWithGoogle', () {
test('returns null when the picker is dismissed', () async {
// Cancelling is ordinary: no session, no account, no error.
final saved = <FirebaseAccount>[];
final client = await openFirebaseWithGoogle(
tokenFetcher: () async => null,
accountSaver: (account) async => saved.add(account),
);
expect(client, isNull);
expect(saved, isEmpty);
});
test('stores the account under the email Firebase reports', () async {
// The regression this test exists for. A fresh install has no email
// anywhere on the device, so it must come from the sign-in response. An
// earlier cut read it from the settings screen's text field -- empty on
// exactly this path -- and persisted FirebaseAccount(email: ''); the
// next launch then took the password branch with '' and failed.
final saved = <FirebaseAccount>[];
final client = await openFirebaseWithGoogle(
tokenFetcher: () async => 'a-token',
accountSaver: (account) async => saved.add(account),
httpClient: _signsInAs('signed-in@example.com'),
);
expect(client, isNotNull);
expect(saved, hasLength(1));
expect(saved.single.email, 'signed-in@example.com');
// No password to store: this device authenticates with the refresh
// token, and openFirebase() must not later try to sign in with ''.
expect(saved.single.password, isEmpty);
client!.close();
});
test('stores nothing when Google resolves to the wrong uid', () async {
// Authentication succeeds and the uid is wrong, which the security
// rules would deny on every read and write. Failing here -- with
// nothing persisted -- leaves the device retryable rather than stuck.
final saved = <FirebaseAccount>[];
await expectLater(
openFirebaseWithGoogle(
tokenFetcher: () async => 'a-token',
accountSaver: (account) async => saved.add(account),
httpClient: _signsInAs('someone@example.com', uid: 'a-different-uid'),
),
throwsA(isA<FirebaseAuthError>()),
);
expect(saved, isEmpty);
});
});
group('isFirebaseConfigured', () {
// Regression (2026-08-11): the marker was checked first, so a device whose
// refresh token had been revoked kept reporting "Connected" while every
// sync failed with TOKEN_EXPIRED.
test('is false when only a stale account marker survives', () async {
await saveAccount(
const FirebaseAccount(email: 'someone@example.com', password: 'pw'),
);
await credentialStore().clear();
expect(await isFirebaseConfigured(), isFalse);
expect(
await storedAccount(),
isNull,
reason: 'the stale marker must not be left behind',
);
});
test('is true when a session exists but the marker does not', () async {
// The opposite failure: reporting the marker alone is what made a phone
// that was in fact syncing look disconnected.
await credentialStore().save(
FirebaseCredentials(
idToken: 'id',
refreshToken: 'refresh',
expiresAt: DateTime.now().toUtc().add(const Duration(hours: 1)),
),
);
expect(await isFirebaseConfigured(), isTrue);
});
});
group('loadAccount', () {
// The account's password grant is retired fleet-wide (see
// link_google.py/seed_session.py in ~/src/utils/crdt-sync), so the wrapper's
// seeded-credentials route is the only fallback that can actually
// authenticate a fresh desktop install. These exercise loadAccount()'s
// adoption of that route directly, since Uri.base in a test is
// file:///, not a wrapper origin -- the same reason accountFromWrapper
// itself needs an injected client to be testable at all.
test('adopts seeded credentials from the wrapper', () async {
final client = http_testing.MockClient((request) async {
expect(request.url.path, '/sync-credentials');
return http.Response(
jsonEncode({
'id_token': 'id',
'refresh_token': 'refresh',
'expires_at': '2026-01-01T00:00:00.000Z',
'email': 'seeded@example.com',
}),
200,
);
});
final account = await loadAccount(httpClient: client);
expect(account?.email, 'seeded@example.com');
expect(account?.password, isEmpty);
expect(await credentialStore().load(), isNotNull);
expect(await isFirebaseConfigured(), isTrue);
});
test(
'falls back to the account route when no credentials are served',
() async {
final client = http_testing.MockClient((request) async {
if (request.url.path == '/sync-credentials') {
return http.Response('', 404);
}
return http.Response(
jsonEncode({
'email': 'password-account@example.com',
'password': 'pw',
}),
200,
);
});
final account = await loadAccount(httpClient: client);
expect(account?.email, 'password-account@example.com');
expect(account?.password, 'pw');
},
);
test('returns null when the wrapper serves neither route', () async {
final client = http_testing.MockClient(
(request) async => http.Response('', 404),
);
expect(await loadAccount(httpClient: client), isNull);
});
test('ignores a malformed credentials body', () async {
final client = http_testing.MockClient((request) async {
if (request.url.path == '/sync-credentials') {
return http.Response(jsonEncode({'id_token': 'id'}), 200);
}
return http.Response('', 404);
});
expect(await loadAccount(httpClient: client), isNull);
});
});
}