From 708f168d4537da7a87bd506ddd0fdaa891676d25 Mon Sep 17 00:00:00 2001 From: Strycher Date: Mon, 22 Jun 2026 03:21:01 -0400 Subject: [PATCH] feat(#75): harden observer settings UI + lock behavior (U1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hardened the staged-save pane (settings_screen capability gate already correct): - Normalize an unexpected device rotation to a valid SegmentedButton segment {0,180} so the control always has a valid selection and never echoes an out-of-range value back on save. - Disable Save during a refresh (no save/refresh race on the controllers). - digitsOnly formatter on status-interval (firmware still range-checks). - Keys on the SSID/password fields for robust widget-test targeting. 4 widget tests pin the security-sensitive behavior: saves send ONLY changed keys, a blank password keeps the stored secret, an entered password is sent as wifi.pwd then the field clears, rotation is normalized, stale/error surfaced (SAFELANE §6). Full observer suite (33 tests) green. Co-Authored-By: Claude Opus 4.8 --- .../settings/observer_settings_view.dart | 10 +- test/screens/observer_settings_view_test.dart | 138 ++++++++++++++++++ 2 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 test/screens/observer_settings_view_test.dart diff --git a/lib/screens/settings/observer_settings_view.dart b/lib/screens/settings/observer_settings_view.dart index 8bba3fa..415b734 100644 --- a/lib/screens/settings/observer_settings_view.dart +++ b/lib/screens/settings/observer_settings_view.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../../models/observer_config.dart'; @@ -66,7 +67,9 @@ class _ObserverSettingsViewState extends State { _iata.text = c.mqtt.iata; _statusInterval.text = '${c.mqtt.statusInterval}'; _displayAlwaysOn = c.display.alwaysOn; - _rotation = c.display.rotation; + // Device may report any int; the SegmentedButton only offers {0, 180}, so + // normalize to a valid selection rather than seed a dangling value. + _rotation = c.display.rotation == 180 ? 180 : 0; _seeded = true; } @@ -151,6 +154,7 @@ class _ObserverSettingsViewState extends State { const SizedBox(height: 8), _sectionTitle('WiFi'), TextField( + key: const Key('observer_ssid'), controller: _ssid, decoration: const InputDecoration( labelText: 'SSID', @@ -159,6 +163,7 @@ class _ObserverSettingsViewState extends State { ), const SizedBox(height: 12), TextField( + key: const Key('observer_pwd'), controller: _pwd, obscureText: true, decoration: const InputDecoration( @@ -187,6 +192,7 @@ class _ObserverSettingsViewState extends State { TextField( controller: _statusInterval, keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], decoration: const InputDecoration( labelText: 'Status interval (sec, 10-3600)', border: OutlineInputBorder(), @@ -236,7 +242,7 @@ class _ObserverSettingsViewState extends State { ), const SizedBox(width: 8), FilledButton.icon( - onPressed: _saving ? null : _save, + onPressed: (_loading || _saving) ? null : _save, icon: _saving ? const SizedBox( width: 16, diff --git a/test/screens/observer_settings_view_test.dart b/test/screens/observer_settings_view_test.dart new file mode 100644 index 0000000..41fa6f3 --- /dev/null +++ b/test/screens/observer_settings_view_test.dart @@ -0,0 +1,138 @@ +// Widget tests for the Observer settings pane (#64 U1). +// +// Security-sensitive UI behavior is pinned here: secrets are write-only and +// staged (a blank password keeps the stored one; an entered one is sent then +// cleared), saves send ONLY changed keys, an unexpected device value is +// normalized before it round-trips back, and every failure is surfaced +// (SAFELANE §6). + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:meshcore_open/connector/meshcore_connector.dart'; +import 'package:meshcore_open/models/observer_config.dart'; +import 'package:meshcore_open/screens/settings/observer_settings_view.dart'; +import 'package:meshcore_open/services/observer_config_service.dart'; + +class _DummyConn extends MeshCoreConnector {} + +class _FakeSvc extends ObserverConfigService { + _FakeSvc(this._cfg, {this.staleFlag = false, this.errorText}) + : super(_DummyConn()); + + final ObserverConfig _cfg; + final bool staleFlag; + final String? errorText; + final List> sets = []; + + @override + bool get supported => true; + @override + ObserverConfig? get config => _cfg; + @override + bool get stale => staleFlag; + @override + String? get lastError => errorText; + @override + Future refresh() async {} + @override + Future setFlat(String key, String value) async { + sets.add(MapEntry(key, value)); + return true; + } +} + +ObserverConfig _cfg({String ssid = 'MyNet', int rotation = 0}) => + ObserverConfig( + wifi: WifiConfig( + ssid: ssid, + enabled: true, + status: WifiStatus.staConnected, + ip: '10.0.0.5', + ), + mqtt: const MqttGlobalConfig(iata: 'HAO', statusInterval: 60), + brokers: const [], + display: DisplayConfig(rotation: rotation), + ); + +Future _pump(WidgetTester tester, _FakeSvc fake) async { + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: fake, + child: const MaterialApp(home: Scaffold(body: ObserverSettingsView())), + ), + ); + await tester.pumpAndSettle(); +} + +// Tap Save, run the staged sets, then drain the SnackBar's auto-dismiss timer +// so the test ends with no pending timers. +Future _save(WidgetTester tester) async { + await tester.tap(find.widgetWithText(FilledButton, 'Save')); + await tester.pumpAndSettle(); + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets('save sends only changed keys; blank password is never sent', ( + tester, + ) async { + final fake = _FakeSvc(_cfg(ssid: 'MyNet')); + await _pump(tester, fake); + + await tester.enterText(find.byKey(const Key('observer_ssid')), 'NewNet'); + // password left blank -> keep the stored one + await _save(tester); + + final keys = fake.sets.map((e) => e.key).toList(); + expect(keys, contains('wifi.ssid')); + expect(keys, isNot(contains('wifi.pwd'))); + expect(fake.sets.firstWhere((e) => e.key == 'wifi.ssid').value, 'NewNet'); + }); + + testWidgets('entered password is sent as wifi.pwd, then the field clears', ( + tester, + ) async { + final fake = _FakeSvc(_cfg()); + await _pump(tester, fake); + + await tester.enterText(find.byKey(const Key('observer_pwd')), 'hunter2'); + await _save(tester); + + final pwd = fake.sets.where((e) => e.key == 'wifi.pwd').toList(); + expect(pwd, hasLength(1)); + expect(pwd.single.value, 'hunter2'); + final field = tester.widget( + find.byKey(const Key('observer_pwd')), + ); + expect(field.controller!.text, isEmpty); + }); + + testWidgets( + 'an unexpected device rotation is normalized, never echoed back', + (tester) async { + final fake = _FakeSvc(_cfg(rotation: 90)); + await _pump(tester, fake); + expect(tester.takeException(), isNull); + + await _save(tester); + + final rot = fake.sets.where((e) => e.key == 'display.rotation').toList(); + expect(rot, hasLength(1)); + expect(rot.single.value, '0'); // normalized to a valid segment, not '90' + }, + ); + + testWidgets('stale + error are surfaced (SAFELANE §6)', (tester) async { + final fake = _FakeSvc( + _cfg(), + staleFlag: true, + errorText: 'GET wifi.ssid failed', + ); + await _pump(tester, fake); + + expect(find.textContaining('last read'), findsOneWidget); + expect(find.text('GET wifi.ssid failed'), findsOneWidget); + }); +}