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.
meshcore-client/test/services/observer_config_service_tes...

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();
},
);
}

Powered by TurnKey Linux.