diff --git a/test/services/observer_config_service_test.dart b/test/services/observer_config_service_test.dart new file mode 100644 index 0000000..cacf313 --- /dev/null +++ b/test/services/observer_config_service_test.dart @@ -0,0 +1,128 @@ +// Adversarial + correctness tests for ObserverConfigService (#64 A2). +// +// TDD: the concurrency and secret-redaction tests are written to FAIL on the +// current service — they document the Gemini BLOCKER (#1 race: "one request in +// flight" is assumed, not enforced) and MINOR (#6: secret key names leak into +// error strings). S1 (#73) hardens the service until these pass. +// +// The service only uses 4 members of MeshCoreConnector (receivedFrames, +// sendFrame, firmwareVerCode, offbandCaps), so a fake subclass injects frames +// without any production change. + +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/connector/meshcore_connector.dart'; +import 'package:meshcore_open/connector/observer_config_client.dart'; +import 'package:meshcore_open/services/observer_config_service.dart'; + +/// Test transport: overrides only what the service touches; injects responses. +class _FakeConnector extends MeshCoreConnector { + final StreamController _frames = + StreamController.broadcast(); + final List sent = []; + int? ver = 14; + int? caps = 0x01; + + @override + Stream get receivedFrames => _frames.stream; + + @override + int? get firmwareVerCode => ver; + + @override + int? get offbandCaps => caps; + + @override + Future sendFrame( + Uint8List data, { + String? channelSendQueueId, + bool expectsGenericAck = false, + }) async { + sent.add(data); + } + + void inject(Uint8List frame) => _frames.add(frame); + void close() => _frames.close(); +} + +Uint8List _respText(int sub, String text) => Uint8List.fromList([ + ObserverConfigClient.respConfig, + sub, + ...utf8.encode(text), + 0, +]); + +Future _tick() => Future.delayed(const Duration(milliseconds: 10)); + +void main() { + late _FakeConnector c; + late ObserverConfigService svc; + + setUp(() { + c = _FakeConnector(); + svc = ObserverConfigService(c); + }); + tearDown(() => c.close()); + + test('supported reflects version gate + capability bit', () { + expect(svc.supported, isTrue); + c.caps = 0x00; // observer bit off + expect(svc.supported, isFalse); + c.caps = 0x01; + c.ver = 13; // below gate + expect(svc.supported, isFalse); + }); + + test('setFlat returns true on ACK (happy path)', () async { + final f = svc.setFlat('mqtt.iata', 'HAO'); + await _tick(); + c.inject(_respText(ObserverConfigClient.rAck, 'mqtt.iata = HAO')); + expect(await f, isTrue); + // The frame actually went out. + expect(c.sent.single[0], ObserverConfigClient.cmdConfig); + }); + + // ---- BLOCKER #1: single-flight is assumed but unenforced ---- + test('overlapping requests must NOT cross responses (single-flight)', () async { + final getF = svc.getFlat('wifi.ssid'); + final setF = svc.setFlat('mqtt.iata', 'HAO'); + await _tick(); + // Device answers the GET first, then the SET. + c.inject(_respText(ObserverConfigClient.rValue, 'wifi.ssid = MyNet')); + await _tick(); + c.inject(_respText(ObserverConfigClient.rAck, 'mqtt.iata = HAO')); + await _tick(); + expect(await getF, 'MyNet'); + expect( + await setF, + isTrue, + reason: + 'setFlat must receive its OWN ACK, not the GET value — cross-talk means single-flight is unenforced', + ); + }); + + // ---- MINOR #6: secret key names leak into error messages ---- + test('error for a secret key does not leak the key name or value', () async { + svc.timeout = const Duration(milliseconds: 30); + final res = await svc.setFlat( + 'wifi.pwd', + 'hunter2', + ); // no response -> timeout + expect(res, isFalse); + expect(svc.lastError, isNotNull); + final err = svc.lastError!.toLowerCase(); + expect( + err, + isNot(contains('pwd')), + reason: 'secret key name (wifi.pwd) must be redacted from errors', + ); + expect( + err, + isNot(contains('hunter2')), + reason: 'secret value must never appear in an error', + ); + }); +}