test(#69): adversarial service test suite (A2, TDD-RED)
4 tests for ObserverConfigService via a fake connector (subclass override of the 4 members the service uses -- no production change). 2 green (capability gate, happy-path setFlat). 2 RED BY DESIGN, documenting the Gemini findings to be fixed by S1 (#73): - BLOCKER #1: overlapping getFlat/setFlat cross responses (single-flight is assumed, not enforced) -- setFlat receives the GET's value, not its ACK. - MINOR #6: a secret key name leaks into the error string ("SET wifi.pwd failed: ..."). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feat/64-observer-config
parent
ada332b465
commit
9f4feb7cad
@ -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<Uint8List> _frames =
|
||||
StreamController<Uint8List>.broadcast();
|
||||
final List<Uint8List> sent = [];
|
||||
int? ver = 14;
|
||||
int? caps = 0x01;
|
||||
|
||||
@override
|
||||
Stream<Uint8List> get receivedFrames => _frames.stream;
|
||||
|
||||
@override
|
||||
int? get firmwareVerCode => ver;
|
||||
|
||||
@override
|
||||
int? get offbandCaps => caps;
|
||||
|
||||
@override
|
||||
Future<void> 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<void> _tick() => Future<void>.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',
|
||||
);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in new issue