Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions app/lib/identity/profile_secrets_store.dart
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -60,6 +62,10 @@ class ProfileSecretsStore {
Future<void> writeCloudProfileOwnerId(String ownerId) =>
_secure.write(_cloudOwnerKey, ownerId);

@visibleForTesting
Future<void> deleteCloudProfileOwnerIdForTest() =>
_secure.delete(_cloudOwnerKey);

/// Clears Supabase session and recovery code after a successful cloud revoke.
Future<void> clearCloudBackupCredentials() async {
await _secure.delete(_sessionKey);
Expand Down
42 changes: 40 additions & 2 deletions app/lib/identity/supabase_profile_cloud.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -76,6 +77,7 @@ class SupabaseProfileCloud implements ProfileCloud {
payload: payload,
);
if (!updated) return false;
await _maybeBackfillCloudProfileOwnerId(session, identity.id);
}
return true;
} catch (_) {
Expand Down Expand Up @@ -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. 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;

@override
Future<RevokeBackupResult> revokeBackup({String? identityId}) async {
Expand All @@ -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(
Expand Down Expand Up @@ -281,6 +288,37 @@ class SupabaseProfileCloud implements ProfileCloud {
return response.status >= 200 && response.status < 300;
}

Future<void> _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;
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;
final ownerId = row['owner_id'];
if (ownerId is String && ownerId == session.userId) {
await secrets.writeCloudProfileOwnerId(ownerId);
}
}

Future<List<Object?>> _selectPayloads(_Session session) async {
final response = await _send(
method: 'GET',
Expand Down
41 changes: 41 additions & 0 deletions app/test/identity/identity_edit_revoke_widget_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
40 changes: 40 additions & 0 deletions app/test/identity/profile_secrets_store_test.dart
Original file line number Diff line number Diff line change
@@ -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);
});
}
Loading
Loading