feat(#75): harden observer settings UI + lock behavior (U1)

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 <noreply@anthropic.com>
pull/99/head
Strycher 4 weeks ago
parent 81c5b4e72f
commit 708f168d45

@ -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<ObserverSettingsView> {
_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<ObserverSettingsView> {
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<ObserverSettingsView> {
),
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<ObserverSettingsView> {
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<ObserverSettingsView> {
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: _saving ? null : _save,
onPressed: (_loading || _saving) ? null : _save,
icon: _saving
? const SizedBox(
width: 16,

@ -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<MapEntry<String, String>> sets = [];
@override
bool get supported => true;
@override
ObserverConfig? get config => _cfg;
@override
bool get stale => staleFlag;
@override
String? get lastError => errorText;
@override
Future<void> refresh() async {}
@override
Future<bool> 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<void> _pump(WidgetTester tester, _FakeSvc fake) async {
await tester.pumpWidget(
ChangeNotifierProvider<ObserverConfigService>.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<void> _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<TextField>(
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);
});
}
Loading…
Cancel
Save

Powered by TurnKey Linux.