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.
377 lines
12 KiB
377 lines
12 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.opSet) {
|
|
// SET payload is "key value"; the key is everything before the 1st space.
|
|
final payload = utf8.decode(data.sublist(2, data.indexOf(0, 2)));
|
|
final key = payload.split(' ').first;
|
|
final frame = failKeys.contains(key)
|
|
? _resp(ObserverConfigClient.rErr, 'ERROR not available')
|
|
: _resp(ObserverConfigClient.rAck, '$key = ok');
|
|
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();
|
|
},
|
|
);
|
|
|
|
// ---- #80: broker save handshake (enabled-last, per-field ACK, recovery) ----
|
|
List<String> setKeys(_AutoConnector c) => c.sent
|
|
.where((f) => f.length > 2 && f[1] == ObserverConfigClient.opSet)
|
|
.map((f) => utf8.decode(f.sublist(2, f.indexOf(0, 2))).split(' ').first)
|
|
.toList();
|
|
|
|
test(
|
|
'saveBroker on a live slot disables first, writes fields, enables LAST',
|
|
() async {
|
|
final auto = _AutoConnector();
|
|
final s = ObserverConfigService(auto);
|
|
final r = await s.saveBroker(
|
|
2,
|
|
fields: {'url': 'mqtt://h', 'port': '1883'},
|
|
enable: true,
|
|
wasLive: true,
|
|
);
|
|
expect(r.ok, isTrue);
|
|
final keys = setKeys(auto);
|
|
expect(
|
|
keys.first,
|
|
'mqtt.broker.2.enabled',
|
|
reason: 'a live slot is disabled first',
|
|
);
|
|
expect(
|
|
keys.last,
|
|
'mqtt.broker.2.enabled',
|
|
reason: 'enabled is written LAST (activation guard)',
|
|
);
|
|
expect(keys.where((k) => k == 'mqtt.broker.2.enabled').length, 2);
|
|
expect(
|
|
keys.indexOf('mqtt.broker.2.url'),
|
|
greaterThan(keys.indexOf('mqtt.broker.2.enabled')),
|
|
);
|
|
expect(
|
|
keys.indexOf('mqtt.broker.2.port'),
|
|
lessThan(keys.lastIndexOf('mqtt.broker.2.enabled')),
|
|
);
|
|
auto.closeStream();
|
|
},
|
|
);
|
|
|
|
test('saveBroker stops on a field ERR and never reaches the enabled-last '
|
|
'activation', () async {
|
|
final auto = _AutoConnector(failKeys: {'mqtt.broker.2.port'});
|
|
final s = ObserverConfigService(auto);
|
|
final r = await s.saveBroker(
|
|
2,
|
|
fields: {'url': 'mqtt://h', 'port': '1883'},
|
|
enable: true,
|
|
wasLive: false,
|
|
);
|
|
expect(r.ok, isFalse);
|
|
expect(r.failedField, 'port');
|
|
expect(
|
|
setKeys(auto),
|
|
isNot(contains('mqtt.broker.2.enabled')),
|
|
reason: 'a failed field must never reach enabled=1 — slot stays safe',
|
|
);
|
|
auto.closeStream();
|
|
});
|
|
|
|
test('clearBroker sends the slot clear op', () async {
|
|
final auto = _AutoConnector();
|
|
final s = ObserverConfigService(auto);
|
|
expect(await s.clearBroker(3), isTrue);
|
|
expect(setKeys(auto), contains('mqtt.broker.3.clear'));
|
|
auto.closeStream();
|
|
});
|
|
|
|
test(
|
|
'getBroker re-reads one slot field-by-field, never the pool dump',
|
|
() async {
|
|
final auto = _AutoConnector();
|
|
final s = ObserverConfigService(auto);
|
|
final b = await s.getBroker(4);
|
|
expect(b, isNotNull);
|
|
expect(b!.slot, 4);
|
|
final getKeys = auto.sent
|
|
.where((f) => f.length > 2 && f[1] == ObserverConfigClient.opGet)
|
|
.map((f) => utf8.decode(f.sublist(2, f.indexOf(0, 2))))
|
|
.toList();
|
|
expect(getKeys, contains('mqtt.broker.4.url'));
|
|
expect(getKeys, contains('mqtt.broker.4.enabled'));
|
|
expect(
|
|
auto.sent.any((f) => f[1] == ObserverConfigClient.opBrokers),
|
|
isFalse,
|
|
reason: 'single-slot re-GET must not trigger the 84-frame pool dump',
|
|
);
|
|
auto.closeStream();
|
|
},
|
|
);
|
|
}
|