Skip to content

Commit b4868f9

Browse files
feat(recoverbull): warn before saving a custom key server
Removing the permission page took the custom-server warning with it. Put it back where it is actually useful: at the moment the user chooses a server, rather than on a screen they walk past afterwards. Saving a URL that is neither the default nor the server already in use now opens the shared warning sheet, and the URL is stored only if the user confirms. Dismissing stores nothing and drops back to showing the server in use, so nobody can end up pointed at someone else's key server without having read the warning. Returning to the default is the safe direction and passes straight through. Validation still runs first: a malformed URL is rejected before any warning, so the user is never asked to confirm something the app is about to refuse.
1 parent 97f2af0 commit b4868f9

3 files changed

Lines changed: 169 additions & 0 deletions

File tree

lib/features/recoverbull/ui/pages/settings_page.dart

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ import 'package:bb_mobile/core/recoverbull/domain/usecases/fetch_recoverbull_url
22
import 'package:bb_mobile/core/recoverbull/domain/usecases/store_recoverbull_url_usecase.dart';
33
import 'package:bb_mobile/core/themes/app_theme.dart';
44
import 'package:bb_mobile/core/utils/build_context_x.dart';
5+
import 'package:bb_mobile/core/utils/constants.dart';
56
import 'package:bb_mobile/core/utils/logger.dart';
67
import 'package:bb_mobile/core/widgets/buttons/button.dart';
78
import 'package:bb_mobile/core/widgets/text/text.dart';
9+
import 'package:bb_mobile/core/widgets/warning_bottom_sheet.dart';
810
import 'package:bb_mobile/locator.dart';
911
import 'package:flutter/material.dart';
1012
import 'package:gap/gap.dart';
@@ -53,8 +55,23 @@ class _SettingsPageState extends State<SettingsPage> {
5355
}
5456

5557
Future<void> _saveUrl() async {
58+
// Validate first: nobody should confirm a security warning only to be told
59+
// the URL was malformed.
5660
if (!_formKey.currentState!.validate()) return;
5761

62+
// Warn at the moment of choice, and only in the unsafe direction. Adopting
63+
// a key server we do not run is the risk; returning to the default, or
64+
// re-saving the server already in use, needs no ceremony.
65+
final entered = _urlController.text;
66+
final isNewCustomServer =
67+
entered != SettingsConstants.recoverbullUrl && entered != _originalUrl;
68+
if (isNewCustomServer && !await _confirmCustomServer()) {
69+
// Nothing is stored, and the field goes back to the server in use.
70+
if (mounted) _cancelEdit();
71+
return;
72+
}
73+
if (!mounted) return;
74+
5875
setState(() => _isSaving = true);
5976
try {
6077
final url = Uri.parse(_urlController.text);
@@ -70,6 +87,18 @@ class _SettingsPageState extends State<SettingsPage> {
7087
}
7188
}
7289

90+
Future<bool> _confirmCustomServer() async {
91+
var confirmed = false;
92+
await WarningBottomSheet.show(
93+
context,
94+
title: context.loc.securityWarningTitle,
95+
message: context.loc.recoverbullServerCustomWarning,
96+
confirmLabel: context.loc.recoverbullContinue,
97+
onConfirm: () => confirmed = true,
98+
);
99+
return confirmed;
100+
}
101+
73102
void _cancelEdit() => setState(() => _isEditing = false);
74103

75104
Future<void> _openRecoverBullWebsite() async {

localization/app_en.arb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15065,5 +15065,9 @@
1506515065
"backupSettingsStartBackup": "Start Backup",
1506615066
"@backupSettingsStartBackup": {
1506715067
"description": "Button text to start new backup process"
15068+
},
15069+
"recoverbullServerCustomWarning": "You are about to use a custom Recoverbull server. Make sure you trust this server.",
15070+
"@recoverbullServerCustomWarning": {
15071+
"description": "Warning shown before saving a custom key server URL in the encrypted vault settings"
1506815072
}
1506915073
}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import 'package:bb_mobile/core/recoverbull/domain/usecases/fetch_recoverbull_url_usecase.dart';
2+
import 'package:bb_mobile/core/recoverbull/domain/usecases/store_recoverbull_url_usecase.dart';
3+
import 'package:bb_mobile/core/utils/constants.dart';
4+
import 'package:bb_mobile/features/recoverbull/ui/pages/settings_page.dart';
5+
import 'package:bb_mobile/generated/l10n/localization.dart';
6+
import 'package:bb_mobile/locator.dart';
7+
import 'package:flutter/material.dart';
8+
import 'package:flutter_test/flutter_test.dart';
9+
import 'package:go_router/go_router.dart';
10+
import 'package:mocktail/mocktail.dart';
11+
12+
class _MockFetchUrlUsecase extends Mock implements FetchRecoverbullUrlUsecase {}
13+
14+
class _MockStoreUrlUsecase extends Mock implements StoreRecoverbullUrlUsecase {}
15+
16+
void main() {
17+
const defaultUrl = SettingsConstants.recoverbullUrl;
18+
const customUrl =
19+
'http://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.onion';
20+
const otherCustomUrl =
21+
'http://bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.onion';
22+
23+
late AppLocalizations loc;
24+
late _MockFetchUrlUsecase fetchUrl;
25+
late _MockStoreUrlUsecase storeUrl;
26+
27+
setUpAll(() async {
28+
loc = await AppLocalizations.delegate.load(const Locale('en'));
29+
registerFallbackValue(Uri.parse(defaultUrl));
30+
});
31+
32+
setUp(() {
33+
fetchUrl = _MockFetchUrlUsecase();
34+
storeUrl = _MockStoreUrlUsecase();
35+
when(() => storeUrl.execute(any())).thenAnswer((_) async {});
36+
locator
37+
..registerFactory<FetchRecoverbullUrlUsecase>(() => fetchUrl)
38+
..registerFactory<StoreRecoverbullUrlUsecase>(() => storeUrl);
39+
});
40+
41+
tearDown(() async => locator.reset());
42+
43+
/// Opens the page with [current] as the stored server and types [entered]
44+
/// into the URL field, then presses Save.
45+
Future<void> editAndSave(
46+
WidgetTester tester, {
47+
required String current,
48+
required String entered,
49+
}) async {
50+
when(() => fetchUrl.execute()).thenAnswer((_) async => Uri.parse(current));
51+
52+
await tester.pumpWidget(
53+
MaterialApp.router(
54+
localizationsDelegates: AppLocalizations.localizationsDelegates,
55+
supportedLocales: AppLocalizations.supportedLocales,
56+
routerConfig: GoRouter(
57+
routes: [GoRoute(path: '/', builder: (_, _) => const SettingsPage())],
58+
),
59+
),
60+
);
61+
await tester.pumpAndSettle();
62+
63+
await tester.tap(find.text(loc.recoverbullSettingsEdit));
64+
await tester.pumpAndSettle();
65+
await tester.enterText(find.byType(TextFormField), entered);
66+
await tester.tap(find.text(loc.recoverbullSettingsSave));
67+
await tester.pumpAndSettle();
68+
}
69+
70+
testWidgets('a new custom server is only stored once the warning is '
71+
'confirmed', (tester) async {
72+
await editAndSave(tester, current: defaultUrl, entered: customUrl);
73+
74+
expect(find.text(loc.securityWarningTitle), findsOneWidget);
75+
expect(find.text(loc.recoverbullServerCustomWarning), findsOneWidget);
76+
verifyNever(() => storeUrl.execute(any()));
77+
78+
await tester.tap(find.text(loc.recoverbullContinue));
79+
await tester.pumpAndSettle();
80+
81+
verify(() => storeUrl.execute(Uri.parse(customUrl))).called(1);
82+
});
83+
84+
testWidgets('dismissing the warning stores nothing and keeps the server in '
85+
'use', (tester) async {
86+
await editAndSave(tester, current: defaultUrl, entered: customUrl);
87+
expect(find.text(loc.securityWarningTitle), findsOneWidget);
88+
89+
// Tap the barrier: the same escape a user gets from any bottom sheet.
90+
await tester.tapAt(const Offset(10, 10));
91+
await tester.pumpAndSettle();
92+
93+
verifyNever(() => storeUrl.execute(any()));
94+
expect(find.text(loc.securityWarningTitle), findsNothing);
95+
// Back out of editing, showing the server that is actually in use.
96+
expect(find.text(defaultUrl), findsOneWidget);
97+
expect(find.text(customUrl), findsNothing);
98+
});
99+
100+
testWidgets('returning to the default server needs no warning', (
101+
tester,
102+
) async {
103+
await editAndSave(tester, current: customUrl, entered: defaultUrl);
104+
105+
expect(find.text(loc.securityWarningTitle), findsNothing);
106+
verify(() => storeUrl.execute(Uri.parse(defaultUrl))).called(1);
107+
});
108+
109+
testWidgets('re-saving the server already in use does not re-prompt', (
110+
tester,
111+
) async {
112+
await editAndSave(tester, current: customUrl, entered: customUrl);
113+
114+
expect(find.text(loc.securityWarningTitle), findsNothing);
115+
verify(() => storeUrl.execute(Uri.parse(customUrl))).called(1);
116+
});
117+
118+
testWidgets('swapping one custom server for another still warns', (
119+
tester,
120+
) async {
121+
await editAndSave(tester, current: customUrl, entered: otherCustomUrl);
122+
123+
expect(find.text(loc.securityWarningTitle), findsOneWidget);
124+
verifyNever(() => storeUrl.execute(any()));
125+
});
126+
127+
testWidgets('an invalid URL is rejected before the warning appears', (
128+
tester,
129+
) async {
130+
await editAndSave(tester, current: defaultUrl, entered: 'https://evil.com');
131+
132+
expect(find.text(loc.recoverbullSettingsUrlMustBeHttp), findsOneWidget);
133+
expect(find.text(loc.securityWarningTitle), findsNothing);
134+
verifyNever(() => storeUrl.execute(any()));
135+
});
136+
}

0 commit comments

Comments
 (0)