You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
276 lines
8.3 KiB
276 lines
8.3 KiB
// 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();
|
|
}
|
|
|
|
/// Auto-responds to each request so refresh()'s round-trips complete without
|
|
/// manual interleaving. [failKeys] answer ERR; others answer VALUE; the broker
|
|
/// dump is an empty pool (START -> END).
|
|
class _AutoConnector extends MeshCoreConnector {
|
|
_AutoConnector({this.failKeys = const {}, this.failBrokers = false});
|
|
final Set<String> failKeys;
|
|
final bool failBrokers;
|
|
final List<Uint8List> sent = [];
|
|
final StreamController<Uint8List> _frames =
|
|
StreamController<Uint8List>.broadcast();
|
|
|
|
@override
|
|
Stream<Uint8List> get receivedFrames => _frames.stream;
|
|
@override
|
|
int? get firmwareVerCode => 14;
|
|
@override
|
|
int? get offbandCaps => 0x01;
|
|
|
|
@override
|
|
Future<void> sendFrame(
|
|
Uint8List data, {
|
|
String? channelSendQueueId,
|
|
bool expectsGenericAck = false,
|
|
}) async {
|
|
sent.add(data);
|
|
final op = data[1];
|
|
if (op == ObserverConfigClient.opGet) {
|
|
final key = utf8.decode(data.sublist(2, data.length - 1));
|
|
final frame = failKeys.contains(key)
|
|
? _resp(ObserverConfigClient.rErr, 'ERROR not available')
|
|
: _resp(ObserverConfigClient.rValue, '$key = ${_valueFor(key)}');
|
|
Future.microtask(() => _frames.add(frame));
|
|
} else if (op == ObserverConfigClient.opBrokers) {
|
|
if (failBrokers) return; // no response -> getBrokers times out
|
|
Future.microtask(() {
|
|
_frames.add(
|
|
Uint8List.fromList([
|
|
ObserverConfigClient.respConfig,
|
|
ObserverConfigClient.rBrokersStart,
|
|
0,
|
|
]),
|
|
);
|
|
_frames.add(
|
|
Uint8List.fromList([
|
|
ObserverConfigClient.respConfig,
|
|
ObserverConfigClient.rBrokersEnd,
|
|
]),
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
String _valueFor(String key) => switch (key) {
|
|
'wifi.enabled' => '1',
|
|
'display.always_on' => '0',
|
|
'mqtt.status_interval' => '60',
|
|
'display.rotation' => '0',
|
|
'wifi.status' => 'StaConnected',
|
|
_ => 'x',
|
|
};
|
|
|
|
Uint8List _resp(int sub, String text) => Uint8List.fromList([
|
|
ObserverConfigClient.respConfig,
|
|
sub,
|
|
...utf8.encode(text),
|
|
0,
|
|
]);
|
|
|
|
void closeStream() => _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',
|
|
);
|
|
});
|
|
|
|
// ---- MINOR-A (#78): a partial read failure must stay visible ----
|
|
test(
|
|
'refresh keeps the error visible + stale on a partial read failure',
|
|
() async {
|
|
final auto = _AutoConnector(failKeys: {'wifi.ssid'});
|
|
final s = ObserverConfigService(auto);
|
|
await s.refresh();
|
|
expect(
|
|
s.stale,
|
|
isTrue,
|
|
reason: 'a failed field must mark the snapshot stale',
|
|
);
|
|
expect(
|
|
s.lastError,
|
|
isNotNull,
|
|
reason: 'a failed GET error must not be wiped by partial success',
|
|
);
|
|
auto.closeStream();
|
|
},
|
|
);
|
|
|
|
test('refresh clears the error + is not stale on a full read', () async {
|
|
final auto = _AutoConnector();
|
|
final s = ObserverConfigService(auto);
|
|
await s.refresh();
|
|
expect(s.stale, isFalse);
|
|
expect(s.lastError, isNull);
|
|
auto.closeStream();
|
|
});
|
|
|
|
// ---- #79: a broker-dump failure must not blank the flat settings ----
|
|
test(
|
|
'broker-dump failure keeps flat settings + flags brokers unavailable',
|
|
() async {
|
|
final auto = _AutoConnector(failBrokers: true);
|
|
final s = ObserverConfigService(auto);
|
|
s.timeout = const Duration(milliseconds: 50);
|
|
await s.refresh();
|
|
expect(
|
|
s.config,
|
|
isNotNull,
|
|
reason: 'settings that read cleanly must still show',
|
|
);
|
|
expect(s.config!.wifi.ssid, 'x');
|
|
expect(s.brokersUnavailable, isTrue);
|
|
expect(
|
|
s.stale,
|
|
isFalse,
|
|
reason: 'a broker-dump miss is not a stale snapshot',
|
|
);
|
|
auto.closeStream();
|
|
},
|
|
);
|
|
|
|
// ---- #81: a flat refresh must not re-pull the broker dump ----
|
|
test(
|
|
'refresh(includeBrokers:false) skips the broker dump, keeps brokers',
|
|
() async {
|
|
final auto = _AutoConnector();
|
|
final s = ObserverConfigService(auto);
|
|
int brokerReqs() => auto.sent
|
|
.where((f) => f.length > 1 && f[1] == ObserverConfigClient.opBrokers)
|
|
.length;
|
|
await s.refresh(); // full read sends one OCFG_BROKERS
|
|
expect(brokerReqs(), 1, reason: 'full refresh dumps the pool');
|
|
await s.refresh(includeBrokers: false); // flat-only
|
|
expect(
|
|
brokerReqs(),
|
|
1,
|
|
reason: 'includeBrokers:false must NOT send another OCFG_BROKERS',
|
|
);
|
|
expect(s.config, isNotNull);
|
|
auto.closeStream();
|
|
},
|
|
);
|
|
}
|