From baa8a0fefd0aa86bd2123d2060628e2e87182bb1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 3 Nov 2026 03:43:42 +0000 Subject: [PATCH 1/2] test(backup): RLS-scoped fake and owner-guard mutant tests (5746993924) Scope fake GET/DELETE by auth user; add Z5/Z6/Z9, GoTrue refresh body, Z12/Z13/Z14, member revoke, and backfill. Backfill owner_id from server select; clarify stranded-install revoke copy. Co-authored-by: Uday Chauhan --- app/lib/identity/profile_secrets_store.dart | 6 + app/lib/identity/supabase_profile_cloud.dart | 37 +- .../supabase_profile_cloud_http_test.dart | 390 ++++++++++++++++-- 3 files changed, 395 insertions(+), 38 deletions(-) diff --git a/app/lib/identity/profile_secrets_store.dart b/app/lib/identity/profile_secrets_store.dart index 58fcc16..fd7e473 100644 --- a/app/lib/identity/profile_secrets_store.dart +++ b/app/lib/identity/profile_secrets_store.dart @@ -1,6 +1,8 @@ import 'dart:convert'; import 'dart:io'; +import 'package:flutter/foundation.dart'; + import 'secret_storage.dart'; /// Supabase session tokens and the profile recovery code — never Auto Backup. @@ -60,6 +62,10 @@ class ProfileSecretsStore { Future writeCloudProfileOwnerId(String ownerId) => _secure.write(_cloudOwnerKey, ownerId); + @visibleForTesting + Future deleteCloudProfileOwnerIdForTest() => + _secure.delete(_cloudOwnerKey); + /// Clears Supabase session and recovery code after a successful cloud revoke. Future clearCloudBackupCredentials() async { await _secure.delete(_sessionKey); diff --git a/app/lib/identity/supabase_profile_cloud.dart b/app/lib/identity/supabase_profile_cloud.dart index dabf5e7..5a7da93 100644 --- a/app/lib/identity/supabase_profile_cloud.dart +++ b/app/lib/identity/supabase_profile_cloud.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'dart:io'; import 'dart:math'; +import 'package:flutter/foundation.dart'; import 'package:identity_core/identity_core.dart'; import 'cloud_backup_preferences.dart'; @@ -76,6 +77,7 @@ class SupabaseProfileCloud implements ProfileCloud { payload: payload, ); if (!updated) return false; + await _maybeBackfillCloudProfileOwnerId(session, identity.id); } return true; } catch (_) { @@ -103,8 +105,12 @@ class SupabaseProfileCloud implements ProfileCloud { 'or the copy was already removed.'; static const _reconnectRecoveryMessage = - 'Cloud backup is linked to another sign-in. Restore with your recovery ' - 'code, then turn backup off again.'; + 'This device cannot remove the cloud backup copy. Use the phone that ' + 'turned backup on, or request deletion by email as described in the ' + 'privacy policy.'; + + @visibleForTesting + static const reconnectRecoveryMessageForTest = _reconnectRecoveryMessage; @override Future revokeBackup({String? identityId}) async { @@ -113,6 +119,7 @@ class SupabaseProfileCloud implements ProfileCloud { final session = await _ensureSession(); final id = identityId ?? await _primaryIdentityId(session); if (id != null) { + await _maybeBackfillCloudProfileOwnerId(session, id); final response = await _send( method: 'DELETE', uri: _rest('profiles').replace( @@ -281,6 +288,32 @@ class SupabaseProfileCloud implements ProfileCloud { return response.status >= 200 && response.status < 300; } + Future _maybeBackfillCloudProfileOwnerId( + _Session session, + String identityId, + ) async { + if (await secrets.readCloudProfileOwnerId() != null) return; + final response = await _send( + method: 'GET', + uri: _rest('profiles').replace( + queryParameters: { + 'select': 'owner_id', + 'identity_id': 'eq.$identityId', + }, + ), + headers: _userHeaders(session), + ); + if (response.status < 200 || response.status >= 300) return; + final decoded = jsonDecode(response.body); + if (decoded is! List || decoded.isEmpty) return; + final row = decoded.first; + if (row is! Map) return; + final ownerId = row['owner_id']; + if (ownerId is String && ownerId == session.userId) { + await secrets.writeCloudProfileOwnerId(ownerId); + } + } + Future> _selectPayloads(_Session session) async { final response = await _send( method: 'GET', diff --git a/app/test/identity/supabase_profile_cloud_http_test.dart b/app/test/identity/supabase_profile_cloud_http_test.dart index 26282e0..eecc9ee 100644 --- a/app/test/identity/supabase_profile_cloud_http_test.dart +++ b/app/test/identity/supabase_profile_cloud_http_test.dart @@ -9,6 +9,13 @@ import 'package:amiro_app/identity/supabase_profile_cloud.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:identity_core/identity_core.dart'; +/// Real GoTrue refresh error body (not the OAuth-style `error`/`error_description`). +final _goTrueRefreshTokenNotFoundBody = jsonEncode({ + 'code': 400, + 'error_code': 'refresh_token_not_found', + 'msg': 'Invalid Refresh Token: Refresh Token Not Found', +}); + void main() { test('push sends no HTTP when backup is disabled', () async { final harness = await _FakeSupabaseHarness.start(); @@ -215,10 +222,7 @@ void main() { final harness = await _FakeSupabaseHarness.start(); addTearDown(harness.close); harness.refreshStatusCode = 400; - harness.refreshBody = jsonEncode({ - 'error': 'invalid_grant', - 'error_description': 'refresh_token_not_found', - }); + harness.refreshBody = _goTrueRefreshTokenNotFoundBody; final built = await harness.buildCloud( enabled: true, @@ -300,7 +304,27 @@ void main() { expect(await built.secrets.readSessionJson(), isNull); }); - test('B1: session swap after dead refresh cannot falsely revoke', () async { + test('Z2/Z3/Z4: real GoTrue refresh body swaps on refresh_token_not_found', () async { + final harness = await _FakeSupabaseHarness.start(); + addTearDown(harness.close); + harness.refreshStatusCode = 400; + harness.refreshBody = _goTrueRefreshTokenNotFoundBody; + + final built = await harness.buildCloud( + enabled: true, + sessionExpiresAt: DateTime.now().subtract(const Duration(minutes: 5)), + ); + + expect( + await built.cloud.push( + const Identity(id: 'id-gotrue', displayName: 'G', username: 'g'), + ), + isTrue, + ); + expect(harness.anonymousSignupCount, 1); + }); + + test('Z5/B1: swapped owner fails empty re-select and keeps credentials', () async { final harness = await _FakeSupabaseHarness.start(); addTearDown(harness.close); @@ -311,10 +335,7 @@ void main() { expect(await built.secrets.readCloudProfileOwnerId(), 'user-1'); harness.refreshStatusCode = 400; - harness.refreshBody = jsonEncode({ - 'error': 'invalid_grant', - 'error_description': 'refresh_token_not_found', - }); + harness.refreshBody = _goTrueRefreshTokenNotFoundBody; harness.deleteReturnsEmpty = true; await built.secrets.writeSessionJson( jsonEncode({ @@ -330,9 +351,168 @@ void main() { final result = await built.cloud.revokeBackup(identityId: 'id-swap'); expect(result.success, isFalse); + expect( + result.userMessage, + SupabaseProfileCloud.reconnectRecoveryMessageForTest, + ); expect(await built.secrets.readSessionJson(), isNotNull); expect(await built.cloud.recoveryCode(), isNotNull); expect(await built.secrets.readCloudProfileOwnerId(), 'user-1'); + expect(harness.anonymousSignupCount, 1); + }); + + test('Z6: legacy install with recovery code fails when row is not visible', () async { + final harness = await _FakeSupabaseHarness.start(); + addTearDown(harness.close); + harness.deleteReturnsEmpty = true; + + final built = await harness.buildCloud(enabled: true); + await built.cloud.push( + const Identity(id: 'id-legacy', displayName: 'L', username: 'l'), + ); + await built.secrets.deleteCloudProfileOwnerIdForTest(); + expect(await built.secrets.readCloudProfileOwnerId(), isNull); + expect(await built.cloud.recoveryCode(), isNotNull); + harness.profileRows.clear(); + + final result = await built.cloud.revokeBackup(identityId: 'id-legacy'); + expect(result.success, isFalse); + expect( + result.userMessage, + SupabaseProfileCloud.reconnectRecoveryMessageForTest, + ); + expect(await built.secrets.readSessionJson(), isNotNull); + expect(await built.cloud.recoveryCode(), isNotNull); + }); + + test('Z9: no owner id and no recovery code succeeds when row is gone', () async { + final harness = await _FakeSupabaseHarness.start(); + addTearDown(harness.close); + + final built = await harness.buildCloud(enabled: true); + expect(await built.cloud.recoveryCode(), isNull); + + final result = await built.cloud.revokeBackup(identityId: 'never-uploaded'); + expect(result.success, isTrue); + expect(await built.secrets.readSessionJson(), isNull); + }); + + test('member revoke sees row but cannot delete (owner-only DELETE)', () async { + final harness = await _FakeSupabaseHarness.start(); + addTearDown(harness.close); + + harness.seedProfile( + identityId: 'id-member', + ownerId: 'user-1', + payload: const { + 'id': 'id-member', + 'displayName': 'O', + 'username': 'o', + }, + recoveryCode: '1234567890123456', + members: {'user-2'}, + ); + + final built = await harness.buildCloud( + enabled: true, + userId: 'user-2', + accessToken: 'access-user-2', + ); + await built.secrets.writeRecoveryCode('1234567890123456'); + + final result = await built.cloud.revokeBackup(identityId: 'id-member'); + expect(result.success, isFalse); + expect(result.userMessage, isNotNull); + expect( + result.userMessage, + isNot(SupabaseProfileCloud.reconnectRecoveryMessageForTest), + ); + expect(await built.secrets.readSessionJson(), isNotNull); + }); + + test('Z12: claim does not write cloud profile owner id', () async { + final harness = await _FakeSupabaseHarness.start(); + addTearDown(harness.close); + + harness.seedProfile( + identityId: 'id-claim', + ownerId: 'user-owner', + payload: const { + 'id': 'id-claim', + 'displayName': 'C', + 'username': 'c', + }, + recoveryCode: 'abcdef0123456789', + ); + + final built = await harness.buildCloud(enabled: true); + expect(await built.secrets.readCloudProfileOwnerId(), isNull); + + final identity = await built.cloud.claim('abcdef0123456789'); + expect(identity, isNotNull); + expect(await built.secrets.readCloudProfileOwnerId(), isNull); + }); + + test('Z13: member push does not write cloud profile owner id', () async { + final harness = await _FakeSupabaseHarness.start(); + addTearDown(harness.close); + + harness.seedProfile( + identityId: 'id-z13', + ownerId: 'user-1', + payload: const { + 'id': 'id-z13', + 'displayName': 'M', + 'username': 'm', + }, + recoveryCode: 'fedcba9876543210', + ); + + final built = await harness.buildCloud( + enabled: true, + userId: 'user-2', + accessToken: 'access-user-2', + ); + expect(await built.secrets.readCloudProfileOwnerId(), isNull); + + expect( + await built.cloud.push( + const Identity(id: 'id-z13', displayName: 'M', username: 'm'), + ), + isTrue, + ); + expect(await built.secrets.readCloudProfileOwnerId(), isNull); + }); + + test('Z14: offline claim throws SessionRefreshFailure', () async { + final harness = await _FakeSupabaseHarness.start(); + addTearDown(harness.close); + + final built = await harness.buildCloud(enabled: true); + await harness.close(); + + await expectLater( + built.cloud.claim('1234567890123456'), + throwsA(isA()), + ); + }); + + test('legacy owner id backfills from server before revoke DELETE', () async { + final harness = await _FakeSupabaseHarness.start(); + addTearDown(harness.close); + + final built = await harness.buildCloud(enabled: true); + await built.cloud.push( + const Identity(id: 'id-backfill', displayName: 'B', username: 'b'), + ); + await built.secrets.deleteCloudProfileOwnerIdForTest(); + expect(await built.secrets.readCloudProfileOwnerId(), isNull); + + expect( + (await built.cloud.revokeBackup(identityId: 'id-backfill')).success, + isTrue, + ); + expect(await built.secrets.readSessionJson(), isNull); }); test('revoke succeeds on retry when row was already deleted', () async { @@ -447,6 +627,25 @@ class _ThrowOnSessionReadStorage implements SecretStorage { Future write(String key, String value) async {} } +class _ProfileRow { + _ProfileRow({ + required this.identityId, + required this.ownerId, + required this.payload, + required this.recoveryCode, + Set? members, + }) : members = members ?? {}; + + final String identityId; + final String ownerId; + Map payload; + final String recoveryCode; + final Set members; + + bool visibleTo(String userId) => + ownerId == userId || members.contains(userId); +} + class _FakeSupabaseHarness { _FakeSupabaseHarness(this._server, this.port); @@ -462,7 +661,12 @@ class _FakeSupabaseHarness { Uri? lastDeleteUri; String? lastDeletePrefer; Map? lastProfileInsert; - final List> profileRows = []; + final List<_ProfileRow> profileRows = []; + final Map accessTokenToUserId = { + 'access-token': 'user-1', + 'new-access': 'new-user', + 'access-user-2': 'user-2', + }; static Future<_FakeSupabaseHarness> start() async { final server = await HttpServer.bind('127.0.0.1', 0); @@ -471,6 +675,24 @@ class _FakeSupabaseHarness { return harness; } + void seedProfile({ + required String identityId, + required String ownerId, + required Map payload, + required String recoveryCode, + Set? members, + }) { + profileRows.add( + _ProfileRow( + identityId: identityId, + ownerId: ownerId, + payload: Map.from(payload), + recoveryCode: recoveryCode, + members: members, + ), + ); + } + Future<({ SupabaseProfileCloud cloud, CloudBackupPreferences prefs, @@ -478,6 +700,8 @@ class _FakeSupabaseHarness { })> buildCloud({ required bool enabled, DateTime? sessionExpiresAt, + String userId = 'user-1', + String accessToken = 'access-token', }) async { final dir = Directory.systemTemp.createTempSync('amiro_fake_sb'); final prefs = CloudBackupPreferences.inDirectory(dir); @@ -488,14 +712,15 @@ class _FakeSupabaseHarness { legacyDirectory: dir, secureStorage: InMemorySecretStorage(), ); + accessTokenToUserId[accessToken] = userId; final expires = sessionExpiresAt ?? DateTime.now().add(const Duration(hours: 1)); await secrets.writeSessionJson( jsonEncode({ - 'access_token': 'access-token', + 'access_token': accessToken, 'refresh_token': 'refresh-token', - 'user_id': 'user-1', + 'user_id': userId, 'expires_at': expires.toUtc().toIso8601String(), }), ); @@ -516,26 +741,58 @@ class _FakeSupabaseHarness { await _server.close(force: true); } + String? _userIdFromRequest(HttpRequest request) { + final auth = request.headers.value('authorization'); + if (auth == null || !auth.toLowerCase().startsWith('bearer ')) { + return null; + } + final token = auth.substring(7).trim(); + return accessTokenToUserId[token]; + } + + Iterable<_ProfileRow> _visibleRows(String? userId) sync* { + if (userId == null) return; + for (final row in profileRows) { + if (row.visibleTo(userId)) yield row; + } + } + + _ProfileRow? _rowByIdentity(String identityId) { + for (final row in profileRows) { + if (row.identityId == identityId) return row; + } + return null; + } + Future _handle(HttpRequest request) async { requestCount += 1; final path = request.uri.path; + final userId = _userIdFromRequest(request); if (path == '/rest/v1/profiles' && request.method == 'GET') { request.response.statusCode = 200; - if (request.uri.queryParameters['select'] == 'identity_id') { - if (profileRows.isEmpty) { - request.response.write('[]'); - } else { - request.response.write( - jsonEncode([ - for (final row in profileRows) - {'identity_id': row['identity_id']}, - ]), - ); - } + final select = request.uri.queryParameters['select']; + final identityEq = request.uri.queryParameters['identity_id']; + var visible = _visibleRows(userId).toList(); + if (identityEq != null && identityEq.startsWith('eq.')) { + final id = identityEq.substring(3); + visible = visible.where((row) => row.identityId == id).toList(); + } + if (select == 'identity_id') { + request.response.write( + jsonEncode([ + for (final row in visible) {'identity_id': row.identityId}, + ]), + ); + } else if (select == 'owner_id') { + request.response.write( + jsonEncode([ + for (final row in visible) {'owner_id': row.ownerId}, + ]), + ); } else { request.response.write( jsonEncode([ - for (final row in profileRows) {'payload': row['payload']}, + for (final row in visible) {'payload': row.payload}, ]), ); } @@ -551,13 +808,54 @@ class _FakeSupabaseHarness { row = {}; } lastProfileInsert = row; - profileRows.add(row); - request.response.statusCode = 201; - request.response.write('[]'); + final identityId = row['identity_id'] as String?; + final ownerId = row['owner_id'] as String? ?? userId; + if (identityId != null && _rowByIdentity(identityId) != null) { + request.response.statusCode = 409; + request.response.write('{}'); + } else if (identityId != null && ownerId != null) { + profileRows.add( + _ProfileRow( + identityId: identityId, + ownerId: ownerId, + payload: Map.from( + row['payload'] as Map? ?? {}, + ), + recoveryCode: row['recovery_code'] as String? ?? '', + ), + ); + request.response.statusCode = 201; + request.response.write('[]'); + } else { + request.response.statusCode = 400; + request.response.write('{}'); + } } else if (path == '/rest/v1/profiles' && request.method == 'PATCH') { + final identityEq = request.uri.queryParameters['identity_id']; + if (identityEq != null && identityEq.startsWith('eq.')) { + final id = identityEq.substring(3); + final row = _rowByIdentity(id); + if (row != null && userId != null && row.visibleTo(userId)) { + final body = await utf8.decoder.bind(request).join(); + final decoded = jsonDecode(body); + if (decoded is Map && decoded['payload'] is Map) { + row.payload = Map.from(decoded['payload'] as Map); + } + } + } request.response.statusCode = 200; request.response.write('[]'); } else if (path == '/rest/v1/profile_members' && request.method == 'POST') { + final body = await utf8.decoder.bind(request).join(); + final decoded = jsonDecode(body); + if (decoded is Map) { + final identityId = decoded['identity_id'] as String?; + final memberId = decoded['user_id'] as String? ?? userId; + final row = identityId != null ? _rowByIdentity(identityId) : null; + if (row != null && memberId != null) { + row.members.add(memberId); + } + } request.response.statusCode = 201; request.response.write('[]'); } else if (path == '/rest/v1/profiles' && request.method == 'DELETE') { @@ -565,17 +863,16 @@ class _FakeSupabaseHarness { lastDeleteUri = request.uri; lastDeletePrefer = request.headers.value('prefer'); final idFilter = request.uri.queryParameters['identity_id']; - final hadRow = profileRows.any( - (row) => idFilter == 'eq.${row['identity_id']}', - ); - if (!deleteReturnsEmpty && hadRow) { - profileRows.removeWhere( - (row) => idFilter == 'eq.${row['identity_id']}', - ); + final identityId = idFilter?.replaceFirst('eq.', ''); + final row = identityId != null ? _rowByIdentity(identityId) : null; + final canDelete = + row != null && userId != null && row.ownerId == userId; + if (!deleteReturnsEmpty && canDelete) { + profileRows.remove(row); request.response.statusCode = 200; request.response.write( jsonEncode([ - {'identity_id': idFilter?.replaceFirst('eq.', '') ?? 'deleted'}, + {'identity_id': identityId}, ]), ); } else { @@ -600,7 +897,28 @@ class _FakeSupabaseHarness { ); } else if (path == '/rest/v1/rpc/join_profile') { request.response.statusCode = claimStatusCode; - request.response.write('null'); + if (claimStatusCode >= 200 && claimStatusCode < 300) { + final body = await utf8.decoder.bind(request).join(); + final decoded = jsonDecode(body); + final code = decoded is Map ? decoded['p_code'] as String? : null; + _ProfileRow? match; + if (code != null) { + for (final row in profileRows) { + if (row.recoveryCode == code.trim()) { + match = row; + break; + } + } + } + if (match != null && userId != null) { + match.members.add(userId); + request.response.write(jsonEncode(match.payload)); + } else { + request.response.write('null'); + } + } else { + request.response.write('null'); + } } else { request.response.statusCode = 404; } From e4195a07ab1460831cd12db9bcf3d6efe1300e03 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 3 Nov 2026 04:50:03 +0000 Subject: [PATCH 2/2] test(backup): pin backfill, X2b/X11/X12, and stranded copy (5747379532) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add BF1–BF6 backfill tests with RLS-scoped fake hardening; Z4/Z13 fixes; literal stranded-install copy; try/catch on backfill JSON; X11/X11b secrets tests; X12 revoke UI test. Co-authored-by: Uday Chauhan --- app/lib/identity/supabase_profile_cloud.dart | 13 +- .../identity_edit_revoke_widget_test.dart | 41 +++ .../identity/profile_secrets_store_test.dart | 40 +++ .../supabase_profile_cloud_http_test.dart | 330 +++++++++++++++--- 4 files changed, 377 insertions(+), 47 deletions(-) create mode 100644 app/test/identity/profile_secrets_store_test.dart diff --git a/app/lib/identity/supabase_profile_cloud.dart b/app/lib/identity/supabase_profile_cloud.dart index 5a7da93..65cd1b9 100644 --- a/app/lib/identity/supabase_profile_cloud.dart +++ b/app/lib/identity/supabase_profile_cloud.dart @@ -105,9 +105,9 @@ class SupabaseProfileCloud implements ProfileCloud { 'or the copy was already removed.'; static const _reconnectRecoveryMessage = - 'This device cannot remove the cloud backup copy. Use the phone that ' - 'turned backup on, or request deletion by email as described in the ' - 'privacy policy.'; + 'This device cannot remove the cloud backup copy. If sign-in on this ' + 'device no longer matches the account that enabled backup, request ' + 'deletion by email as described in the privacy policy.'; @visibleForTesting static const reconnectRecoveryMessageForTest = _reconnectRecoveryMessage; @@ -304,7 +304,12 @@ class SupabaseProfileCloud implements ProfileCloud { headers: _userHeaders(session), ); if (response.status < 200 || response.status >= 300) return; - final decoded = jsonDecode(response.body); + Object? decoded; + try { + decoded = jsonDecode(response.body); + } catch (_) { + return; + } if (decoded is! List || decoded.isEmpty) return; final row = decoded.first; if (row is! Map) return; diff --git a/app/test/identity/identity_edit_revoke_widget_test.dart b/app/test/identity/identity_edit_revoke_widget_test.dart index 3b18824..77dedb8 100644 --- a/app/test/identity/identity_edit_revoke_widget_test.dart +++ b/app/test/identity/identity_edit_revoke_widget_test.dart @@ -60,6 +60,47 @@ void main() { expect(find.byKey(const Key('recoveryCode')), findsOneWidget); }); + testWidgets('X12: successful revoke clears recovery code from the UI', ( + tester, + ) async { + useTallTestViewport(tester); + final prefs = CloudBackupPreferences.inMemory(enabled: true); + final cloud = RecordingProfileCloud(); + const identity = Identity(id: 'id-1', displayName: 'A', username: 'a'); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + identityRepositoryProvider.overrideWithValue( + InMemoryIdentityRepository(identity), + ), + cloudBackupPreferencesProvider.overrideWithValue(prefs), + profileCloudProvider.overrideWithValue(cloud), + ], + child: const MaterialApp(home: IdentityEditScreen()), + ), + ); + await tester.pump(); + await pumpUntilFound(tester, find.byKey(const Key('recoveryCode'))); + expect(find.byKey(const Key('recoveryCode')), findsOneWidget); + + await _waitForCloudToggle(tester); + await tester.ensureVisible(find.byKey(const Key('cloudBackupToggle'))); + await tester.tap( + find.descendant( + of: find.byKey(const Key('cloudBackupToggle')), + matching: find.byType(Switch), + ), + warnIfMissed: false, + ); + await pumpUntilFound(tester, find.byKey(const Key('cloudBackupRevokeConfirm'))); + await tester.tap(find.byKey(const Key('cloudBackupRevokeConfirm'))); + await pumpShort(tester, frames: 8); + + expect(cloud.revokeCalls, 1); + expect(find.byKey(const Key('recoveryCode')), findsNothing); + }); + testWidgets('cancel revoke dialog keeps backup on', (tester) async { useTallTestViewport(tester); final prefs = CloudBackupPreferences.inMemory(enabled: true); diff --git a/app/test/identity/profile_secrets_store_test.dart b/app/test/identity/profile_secrets_store_test.dart new file mode 100644 index 0000000..a4e7577 --- /dev/null +++ b/app/test/identity/profile_secrets_store_test.dart @@ -0,0 +1,40 @@ +import 'dart:io'; + +import 'package:amiro_app/identity/profile_secrets_store.dart'; +import 'package:amiro_app/identity/secret_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('X11: migrateFromLegacyPlaintext imports recovery code and deletes file', () async { + final dir = Directory.systemTemp.createTempSync('amiro_legacy_migrate'); + final secrets = ProfileSecretsStore( + legacyDirectory: dir, + secureStorage: InMemorySecretStorage(), + ); + final legacyFile = File('${dir.path}/profile_recovery_code.txt'); + await legacyFile.writeAsString('legacy-plaintext-code'); + + await secrets.migrateFromLegacyPlaintext(); + + expect(await legacyFile.exists(), isFalse); + expect(await secrets.readRecoveryCode(), 'legacy-plaintext-code'); + }); + + test('X11b: clearCloudBackupCredentials deletes legacy recovery plaintext file', () async { + final dir = Directory.systemTemp.createTempSync('amiro_legacy_clear'); + final secrets = ProfileSecretsStore( + legacyDirectory: dir, + secureStorage: InMemorySecretStorage(), + ); + await secrets.writeSessionJson('{"access_token":"a"}'); + await secrets.writeRecoveryCode('secure-code'); + final legacyFile = File('${dir.path}/profile_recovery_code.txt'); + await legacyFile.writeAsString('leftover-on-disk'); + + await secrets.clearCloudBackupCredentials(); + + expect(await legacyFile.exists(), isFalse); + expect(await secrets.readSessionJson(), isNull); + expect(await secrets.readRecoveryCode(), isNull); + }); +} diff --git a/app/test/identity/supabase_profile_cloud_http_test.dart b/app/test/identity/supabase_profile_cloud_http_test.dart index eecc9ee..3db61fb 100644 --- a/app/test/identity/supabase_profile_cloud_http_test.dart +++ b/app/test/identity/supabase_profile_cloud_http_test.dart @@ -16,6 +16,12 @@ final _goTrueRefreshTokenNotFoundBody = jsonEncode({ 'msg': 'Invalid Refresh Token: Refresh Token Not Found', }); +/// Pinned copy (BF7); must match production [SupabaseProfileCloud] stranded text. +const _strandedInstallRevokeMessage = + 'This device cannot remove the cloud backup copy. If sign-in on this ' + 'device no longer matches the account that enabled backup, request ' + 'deletion by email as described in the privacy policy.'; + void main() { test('push sends no HTTP when backup is disabled', () async { final harness = await _FakeSupabaseHarness.start(); @@ -304,7 +310,7 @@ void main() { expect(await built.secrets.readSessionJson(), isNull); }); - test('Z2/Z3/Z4: real GoTrue refresh body swaps on refresh_token_not_found', () async { + test('Z2/Z3: real GoTrue refresh body swaps on refresh_token_not_found', () async { final harness = await _FakeSupabaseHarness.start(); addTearDown(harness.close); harness.refreshStatusCode = 400; @@ -324,6 +330,29 @@ void main() { expect(harness.anonymousSignupCount, 1); }); + test('Z4: refresh auth failure reads error_description without error_code', () async { + final harness = await _FakeSupabaseHarness.start(); + addTearDown(harness.close); + harness.refreshStatusCode = 400; + harness.refreshBody = jsonEncode({ + 'code': 400, + 'error_description': 'refresh_token_not_found', + }); + + final built = await harness.buildCloud( + enabled: true, + sessionExpiresAt: DateTime.now().subtract(const Duration(minutes: 5)), + ); + + expect( + await built.cloud.push( + const Identity(id: 'id-z4', displayName: 'Z', username: 'z'), + ), + isTrue, + ); + expect(harness.anonymousSignupCount, 1); + }); + test('Z5/B1: swapped owner fails empty re-select and keeps credentials', () async { final harness = await _FakeSupabaseHarness.start(); addTearDown(harness.close); @@ -351,10 +380,7 @@ void main() { final result = await built.cloud.revokeBackup(identityId: 'id-swap'); expect(result.success, isFalse); - expect( - result.userMessage, - SupabaseProfileCloud.reconnectRecoveryMessageForTest, - ); + expect(result.userMessage, _strandedInstallRevokeMessage); expect(await built.secrets.readSessionJson(), isNotNull); expect(await built.cloud.recoveryCode(), isNotNull); expect(await built.secrets.readCloudProfileOwnerId(), 'user-1'); @@ -377,10 +403,7 @@ void main() { final result = await built.cloud.revokeBackup(identityId: 'id-legacy'); expect(result.success, isFalse); - expect( - result.userMessage, - SupabaseProfileCloud.reconnectRecoveryMessageForTest, - ); + expect(result.userMessage, _strandedInstallRevokeMessage); expect(await built.secrets.readSessionJson(), isNotNull); expect(await built.cloud.recoveryCode(), isNotNull); }); @@ -423,10 +446,7 @@ void main() { final result = await built.cloud.revokeBackup(identityId: 'id-member'); expect(result.success, isFalse); expect(result.userMessage, isNotNull); - expect( - result.userMessage, - isNot(SupabaseProfileCloud.reconnectRecoveryMessageForTest), - ); + expect(result.userMessage, isNot(_strandedInstallRevokeMessage)); expect(await built.secrets.readSessionJson(), isNotNull); }); @@ -453,7 +473,7 @@ void main() { expect(await built.secrets.readCloudProfileOwnerId(), isNull); }); - test('Z13: member push does not write cloud profile owner id', () async { + test('Z13/BF1: member push does not write cloud profile owner id', () async { final harness = await _FakeSupabaseHarness.start(); addTearDown(harness.close); @@ -466,6 +486,7 @@ void main() { 'username': 'm', }, recoveryCode: 'fedcba9876543210', + members: {'user-2'}, ); final built = await harness.buildCloud( @@ -484,6 +505,34 @@ void main() { expect(await built.secrets.readCloudProfileOwnerId(), isNull); }); + test('BF1b: member revoke does not backfill cloud profile owner id', () async { + final harness = await _FakeSupabaseHarness.start(); + addTearDown(harness.close); + + harness.seedProfile( + identityId: 'id-bf1b', + ownerId: 'user-1', + payload: const { + 'id': 'id-bf1b', + 'displayName': 'M', + 'username': 'm', + }, + recoveryCode: '1234567890123456', + members: {'user-2'}, + ); + + final built = await harness.buildCloud( + enabled: true, + userId: 'user-2', + accessToken: 'access-user-2', + ); + await built.secrets.writeRecoveryCode('1234567890123456'); + + await built.cloud.revokeBackup(identityId: 'id-bf1b'); + + expect(await built.secrets.readCloudProfileOwnerId(), isNull); + }); + test('Z14: offline claim throws SessionRefreshFailure', () async { final harness = await _FakeSupabaseHarness.start(); addTearDown(harness.close); @@ -497,22 +546,207 @@ void main() { ); }); - test('legacy owner id backfills from server before revoke DELETE', () async { + test('BF3: legacy push backfills stored cloud profile owner id', () async { + final harness = await _FakeSupabaseHarness.start(); + addTearDown(harness.close); + + final built = await harness.buildCloud(enabled: true); + const identity = Identity(id: 'id-bf3', displayName: 'B', username: 'b'); + expect(await built.cloud.push(identity), isTrue); + await built.secrets.deleteCloudProfileOwnerIdForTest(); + expect(await built.secrets.readCloudProfileOwnerId(), isNull); + + expect(await built.cloud.push(identity), isTrue); + expect(await built.secrets.readCloudProfileOwnerId(), 'user-1'); + }); + + test('BF2: revoke backfills owner id before DELETE runs', () async { final harness = await _FakeSupabaseHarness.start(); addTearDown(harness.close); final built = await harness.buildCloud(enabled: true); await built.cloud.push( - const Identity(id: 'id-backfill', displayName: 'B', username: 'b'), + const Identity(id: 'id-bf2', displayName: 'B', username: 'b'), ); await built.secrets.deleteCloudProfileOwnerIdForTest(); + harness.onBeforeDelete = () async { + expect(await built.secrets.readCloudProfileOwnerId(), 'user-1'); + }; + + expect( + (await built.cloud.revokeBackup(identityId: 'id-bf2')).success, + isTrue, + ); + }); + + test('BF4: backfill does not overwrite an existing cloud profile owner id', () async { + final harness = await _FakeSupabaseHarness.start(); + addTearDown(harness.close); + + final built = await harness.buildCloud(enabled: true); + await built.cloud.push( + const Identity(id: 'id-bf4', displayName: 'B', username: 'b'), + ); + await built.secrets.writeCloudProfileOwnerId('already-set'); + + await built.cloud.push( + const Identity(id: 'id-bf4', displayName: 'B2', username: 'b2'), + ); + + expect(await built.secrets.readCloudProfileOwnerId(), 'already-set'); + }); + + test('BF5/BF6: owner select 500 leaves owner id unchanged', () async { + final harness = await _FakeSupabaseHarness.start(); + addTearDown(harness.close); + harness.ownerIdSelectStatusCode = 500; + + final built = await harness.buildCloud(enabled: true); + await built.cloud.push( + const Identity(id: 'id-bf5', displayName: 'B', username: 'b'), + ); + await built.secrets.deleteCloudProfileOwnerIdForTest(); + + expect(await built.cloud.push( + const Identity(id: 'id-bf5', displayName: 'B2', username: 'b2'), + ), isTrue); expect(await built.secrets.readCloudProfileOwnerId(), isNull); expect( - (await built.cloud.revokeBackup(identityId: 'id-backfill')).success, + (await built.cloud.revokeBackup(identityId: 'id-bf5')).success, isTrue, ); - expect(await built.secrets.readSessionJson(), isNull); + }); + + test('BF6: backfill uses identity_id filter when multiple rows visible', () async { + final harness = await _FakeSupabaseHarness.start(); + addTearDown(harness.close); + + final built = await harness.buildCloud(enabled: true); + const identity = Identity(id: 'id-target', displayName: 'T', username: 't'); + expect(await built.cloud.push(identity), isTrue); + harness.seedProfile( + identityId: 'id-other', + ownerId: 'other-owner', + payload: const { + 'id': 'id-other', + 'displayName': 'O', + 'username': 'o', + }, + recoveryCode: 'aaaaaaaabbbbbbbb', + members: {'user-1'}, + ); + await built.secrets.deleteCloudProfileOwnerIdForTest(); + + expect(await built.cloud.push(identity), isTrue); + expect(await built.secrets.readCloudProfileOwnerId(), 'user-1'); + }); + + test('backfill ignores non-JSON owner select body', () async { + final harness = await _FakeSupabaseHarness.start(); + addTearDown(harness.close); + harness.ownerIdSelectBody = 'not json'; + + final built = await harness.buildCloud(enabled: true); + await built.cloud.push( + const Identity(id: 'id-html', displayName: 'H', username: 'h'), + ); + await built.secrets.deleteCloudProfileOwnerIdForTest(); + + expect(await built.cloud.push( + const Identity(id: 'id-html', displayName: 'H2', username: 'h2'), + ), isTrue); + expect(await built.secrets.readCloudProfileOwnerId(), isNull); + }); + + test('BF3 swap: backfill does not write after session swap', () async { + final harness = await _FakeSupabaseHarness.start(); + addTearDown(harness.close); + + final built = await harness.buildCloud(enabled: true); + await built.cloud.push( + const Identity(id: 'id-swap-bf', displayName: 'S', username: 's'), + ); + await built.secrets.deleteCloudProfileOwnerIdForTest(); + harness.refreshStatusCode = 400; + harness.refreshBody = _goTrueRefreshTokenNotFoundBody; + await built.secrets.writeSessionJson( + jsonEncode({ + 'access_token': 'access-token', + 'refresh_token': 'refresh-token', + 'user_id': 'user-1', + 'expires_at': DateTime.now() + .subtract(const Duration(minutes: 5)) + .toUtc() + .toIso8601String(), + }), + ); + + await built.cloud.revokeBackup(identityId: 'id-swap-bf'); + + expect(await built.secrets.readCloudProfileOwnerId(), isNull); + expect(harness.anonymousSignupCount, 1); + }); + + test('X2b: revoke with null identityId uses primary row and fails on zero DELETE', () async { + final harness = await _FakeSupabaseHarness.start(); + addTearDown(harness.close); + harness.deleteReturnsEmpty = true; + + final built = await harness.buildCloud(enabled: true); + await built.cloud.push( + const Identity(id: 'id-x2b', displayName: 'X', username: 'x'), + ); + + final result = await built.cloud.revokeBackup(); + expect(result.success, isFalse); + expect(harness.deleteCount, greaterThan(0)); + }); + + test('fake POST profiles rejects owner_id mismatch with 403', () async { + final harness = await _FakeSupabaseHarness.start(); + addTearDown(harness.close); + + final client = HttpClient(); + final request = await client.postUrl( + Uri.parse('http://127.0.0.1:${harness.port}/rest/v1/profiles'), + ); + request.headers.set('Authorization', 'Bearer access-token'); + request.headers.set('Content-Type', 'application/json'); + request.write(jsonEncode({ + 'identity_id': 'id-forbidden', + 'owner_id': 'someone-else', + 'payload': {'id': 'id-forbidden', 'displayName': 'F', 'username': 'f'}, + 'recovery_code': '1234567890123456', + })); + final response = await request.close(); + expect(response.statusCode, 403); + client.close(); + }); + + test('fake profile_members rejects non-owner join with 403', () async { + final harness = await _FakeSupabaseHarness.start(); + addTearDown(harness.close); + harness.seedProfile( + identityId: 'id-403', + ownerId: 'user-1', + payload: const {'id': 'id-403', 'displayName': 'O', 'username': 'o'}, + recoveryCode: '1234567890123456', + ); + + final client = HttpClient(); + final request = await client.postUrl( + Uri.parse('http://127.0.0.1:${harness.port}/rest/v1/profile_members'), + ); + request.headers.set('Authorization', 'Bearer access-user-2'); + request.headers.set('Content-Type', 'application/json'); + request.write(jsonEncode({ + 'identity_id': 'id-403', + 'user_id': 'user-2', + })); + final response = await request.close(); + expect(response.statusCode, 403); + client.close(); }); test('revoke succeeds on retry when row was already deleted', () async { @@ -557,23 +791,6 @@ void main() { expect(harness.requestCount, 0); }); - test('X11: successful revoke removes legacy recovery plaintext file', () async { - final harness = await _FakeSupabaseHarness.start(); - addTearDown(harness.close); - - final built = await harness.buildCloud(enabled: true); - final legacyFile = File( - '${built.secrets.legacyDirectory.path}/profile_recovery_code.txt', - ); - await legacyFile.writeAsString('legacy-on-disk'); - await built.cloud.push( - const Identity(id: 'id-x11', displayName: 'L', username: 'l'), - ); - - expect((await built.cloud.revokeBackup(identityId: 'id-x11')).success, isTrue); - expect(await legacyFile.exists(), isFalse); - }); - test('X8: pull returns null when session read throws', () async { final harness = await _FakeSupabaseHarness.start(); addTearDown(harness.close); @@ -658,6 +875,9 @@ class _FakeSupabaseHarness { String refreshBody = ''; int anonymousSignupCount = 0; int claimStatusCode = 200; + int? ownerIdSelectStatusCode; + String? ownerIdSelectBody; + Future Function()? onBeforeDelete; Uri? lastDeleteUri; String? lastDeletePrefer; Map? lastProfileInsert; @@ -784,11 +1004,18 @@ class _FakeSupabaseHarness { ]), ); } else if (select == 'owner_id') { - request.response.write( - jsonEncode([ - for (final row in visible) {'owner_id': row.ownerId}, - ]), - ); + if (ownerIdSelectStatusCode != null) { + request.response.statusCode = ownerIdSelectStatusCode!; + request.response.write(ownerIdSelectBody ?? ''); + } else if (ownerIdSelectBody != null) { + request.response.write(ownerIdSelectBody); + } else { + request.response.write( + jsonEncode([ + for (final row in visible) {'owner_id': row.ownerId}, + ]), + ); + } } else { request.response.write( jsonEncode([ @@ -810,7 +1037,13 @@ class _FakeSupabaseHarness { lastProfileInsert = row; final identityId = row['identity_id'] as String?; final ownerId = row['owner_id'] as String? ?? userId; - if (identityId != null && _rowByIdentity(identityId) != null) { + if (identityId != null && + ownerId != null && + userId != null && + ownerId != userId) { + request.response.statusCode = 403; + request.response.write('{}'); + } else if (identityId != null && _rowByIdentity(identityId) != null) { request.response.statusCode = 409; request.response.write('{}'); } else if (identityId != null && ownerId != null) { @@ -852,13 +1085,24 @@ class _FakeSupabaseHarness { final identityId = decoded['identity_id'] as String?; final memberId = decoded['user_id'] as String? ?? userId; final row = identityId != null ? _rowByIdentity(identityId) : null; - if (row != null && memberId != null) { - row.members.add(memberId); + if (row == null || + userId == null || + memberId == null || + row.ownerId != userId || + memberId != userId) { + request.response.statusCode = 403; + request.response.write('{}'); + await request.response.close(); + return; } + row.members.add(memberId); } request.response.statusCode = 201; request.response.write('[]'); } else if (path == '/rest/v1/profiles' && request.method == 'DELETE') { + if (onBeforeDelete != null) { + await onBeforeDelete!(); + } deleteCount += 1; lastDeleteUri = request.uri; lastDeletePrefer = request.headers.value('prefer');