xworkmate-app/test/runtime/gateway_runtime_suite.dart
2026-03-23 18:57:29 +08:00

480 lines
15 KiB
Dart

@TestOn('vm')
library;
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:xworkmate/runtime/device_identity_store.dart';
import 'package:xworkmate/runtime/gateway_runtime.dart';
import 'package:xworkmate/runtime/runtime_models.dart';
import '../test_support.dart';
void main() {
test(
'GatewayRuntime uses explicit shared token override for the initial connect handshake',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final store = createIsolatedTestStore();
final runtime = GatewayRuntime(
store: store,
identityStore: DeviceIdentityStore(store),
);
final server = await _FakeGatewayRuntimeServer.start();
addTearDown(runtime.dispose);
addTearDown(server.close);
await runtime.connectProfile(
GatewayConnectionProfile.defaults().copyWith(
mode: RuntimeConnectionMode.local,
host: '127.0.0.1',
port: server.port,
tls: false,
useSetupCode: false,
),
authTokenOverride: 'shared-token-from-form',
);
expect(server.connectAuth?['token'], 'shared-token-from-form');
expect(server.connectAuth?['deviceToken'], isNull);
expect(runtime.snapshot.status, RuntimeConnectionStatus.connected);
expect(runtime.snapshot.connectAuthMode, 'shared-token');
expect(runtime.snapshot.connectAuthFields, const <String>['token']);
expect(runtime.snapshot.connectAuthSources, const <String>[
'shared:form',
]);
expect(
runtime.logs.any(
(entry) => entry.message.contains('shared-token-from-form'),
),
isFalse,
);
expect(
runtime.logs.any(
(entry) => entry.message.contains('auth: shared-token'),
),
isTrue,
);
},
);
test(
'GatewayRuntime sends stored operator device token using auth.deviceToken',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final store = createIsolatedTestStore();
final identityStore = DeviceIdentityStore(store);
final identity = await identityStore.loadOrCreate();
await store.saveDeviceToken(
deviceId: identity.deviceId,
role: 'operator',
token: 'stored-device-token',
);
final runtime = GatewayRuntime(
store: store,
identityStore: identityStore,
);
final server = await _FakeGatewayRuntimeServer.start();
addTearDown(runtime.dispose);
addTearDown(server.close);
await runtime.connectProfile(
GatewayConnectionProfile.defaults().copyWith(
mode: RuntimeConnectionMode.local,
host: '127.0.0.1',
port: server.port,
tls: false,
useSetupCode: false,
),
);
expect(server.connectAuth?['token'], 'stored-device-token');
expect(server.connectAuth?['deviceToken'], 'stored-device-token');
expect(runtime.snapshot.hasDeviceToken, isTrue);
expect(runtime.snapshot.deviceId, identity.deviceId);
expect(runtime.snapshot.connectAuthMode, 'device-token');
expect(runtime.snapshot.connectAuthFields, const <String>[
'token',
'deviceToken',
]);
expect(runtime.snapshot.connectAuthSources, const <String>[
'device:store',
]);
},
);
test(
'GatewayRuntime parses device pairing state and syncs rotated local role tokens',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final store = createIsolatedTestStore();
final identityStore = DeviceIdentityStore(store);
final identity = await identityStore.loadOrCreate();
final runtime = GatewayRuntime(
store: store,
identityStore: identityStore,
);
final server = await _FakeGatewayRuntimeServer.start(
currentDeviceId: identity.deviceId,
);
addTearDown(runtime.dispose);
addTearDown(server.close);
await runtime.connectProfile(
GatewayConnectionProfile.defaults().copyWith(
mode: RuntimeConnectionMode.local,
host: '127.0.0.1',
port: server.port,
tls: false,
useSetupCode: false,
),
authTokenOverride: 'shared-token-from-form',
);
final devices = await runtime.listDevicePairing();
expect(devices.pending.single.requestId, 'req-1');
expect(devices.paired.single.currentDevice, isTrue);
expect(devices.paired.single.tokens.single.role, 'operator');
final rotated = await runtime.rotateDeviceToken(
deviceId: identity.deviceId,
role: 'operator',
scopes: const <String>['operator.admin', 'operator.pairing'],
);
expect(rotated, 'rotated-local-device-token');
expect(
await store.loadDeviceToken(
deviceId: identity.deviceId,
role: 'operator',
),
'rotated-local-device-token',
);
await runtime.revokeDeviceToken(
deviceId: identity.deviceId,
role: 'operator',
);
expect(
await store.loadDeviceToken(
deviceId: identity.deviceId,
role: 'operator',
),
isNull,
);
},
);
test(
'GatewayRuntime does not auto reconnect after non-retryable pairing errors',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final store = createIsolatedTestStore();
final runtime = GatewayRuntime(
store: store,
identityStore: DeviceIdentityStore(store),
);
final server = await _FakeGatewayRuntimeServer.start(
connectErrorCode: 'INVALID_REQUEST',
connectErrorDetailCode: 'PAIRING_REQUIRED',
connectErrorMessage: 'pairing required',
closeAfterConnectError: true,
);
addTearDown(runtime.dispose);
addTearDown(server.close);
await expectLater(
() => runtime.connectProfile(
GatewayConnectionProfile.defaults().copyWith(
mode: RuntimeConnectionMode.local,
host: '127.0.0.1',
port: server.port,
tls: false,
useSetupCode: false,
),
authTokenOverride: 'shared-token-from-form',
),
throwsA(isA<GatewayRuntimeException>()),
);
await Future<void>.delayed(const Duration(milliseconds: 2400));
expect(server.connectRequestCount, 1);
expect(runtime.snapshot.pairingRequired, isTrue);
expect(
runtime.logs.any(
(entry) =>
entry.category == 'socket' &&
entry.message.contains('auto reconnect suppressed'),
),
isTrue,
);
},
);
test(
'GatewayRuntime clears a stale stored device token after NOT_PAIRED',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final store = createIsolatedTestStore();
final identityStore = DeviceIdentityStore(store);
final identity = await identityStore.loadOrCreate();
await store.saveDeviceToken(
deviceId: identity.deviceId,
role: 'operator',
token: 'stale-device-token',
);
final runtime = GatewayRuntime(
store: store,
identityStore: identityStore,
);
final server = await _FakeGatewayRuntimeServer.start(
connectErrorCode: 'NOT_PAIRED',
connectErrorDetailCode: 'PAIRING_REQUIRED',
connectErrorMessage: 'pairing required',
closeAfterConnectError: true,
);
addTearDown(runtime.dispose);
addTearDown(server.close);
await expectLater(
() => runtime.connectProfile(
GatewayConnectionProfile.defaults().copyWith(
mode: RuntimeConnectionMode.remote,
host: '127.0.0.1',
port: server.port,
tls: false,
useSetupCode: false,
),
),
throwsA(isA<GatewayRuntimeException>()),
);
expect(server.connectAuth?['token'], 'stale-device-token');
expect(server.connectAuth?['deviceToken'], 'stale-device-token');
expect(
await store.loadDeviceToken(
deviceId: identity.deviceId,
role: 'operator',
),
isNull,
);
expect(
runtime.logs.any(
(entry) =>
entry.category == 'auth' &&
entry.message.contains('cleared stale device token'),
),
isTrue,
);
},
);
}
class _FakeGatewayRuntimeServer {
_FakeGatewayRuntimeServer._(
this._server, {
required this.currentDeviceId,
required this.connectErrorCode,
required this.connectErrorDetailCode,
required this.connectErrorMessage,
required this.closeAfterConnectError,
});
final HttpServer _server;
final String? currentDeviceId;
final String? connectErrorCode;
final String? connectErrorDetailCode;
final String? connectErrorMessage;
final bool closeAfterConnectError;
Map<String, dynamic>? connectAuth;
int connectRequestCount = 0;
int get port => _server.port;
static Future<_FakeGatewayRuntimeServer> start({
String? currentDeviceId,
String? connectErrorCode,
String? connectErrorDetailCode,
String? connectErrorMessage,
bool closeAfterConnectError = false,
}) async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
final fake = _FakeGatewayRuntimeServer._(
server,
currentDeviceId: currentDeviceId,
connectErrorCode: connectErrorCode,
connectErrorDetailCode: connectErrorDetailCode,
connectErrorMessage: connectErrorMessage,
closeAfterConnectError: closeAfterConnectError,
);
unawaited(fake._serve());
return fake;
}
Future<void> close() async {
await _server.close(force: true);
}
Future<void> _serve() async {
await for (final request in _server) {
final socket = await WebSocketTransformer.upgrade(request);
socket.add(
jsonEncode(<String, dynamic>{
'type': 'event',
'event': 'connect.challenge',
'payload': <String, dynamic>{'nonce': 'nonce-1'},
}),
);
await for (final raw in socket) {
final frame = jsonDecode(raw as String) as Map<String, dynamic>;
if (frame['type'] != 'req') {
continue;
}
final method = frame['method'] as String? ?? '';
final id = frame['id'] as String? ?? 'req-id';
final params =
(frame['params'] as Map?)?.cast<String, dynamic>() ??
const <String, dynamic>{};
switch (method) {
case 'connect':
connectRequestCount += 1;
connectAuth =
(params['auth'] as Map?)?.cast<String, dynamic>() ??
const <String, dynamic>{};
if (connectErrorCode != null) {
socket.add(
jsonEncode(<String, dynamic>{
'type': 'res',
'id': id,
'ok': false,
'error': <String, dynamic>{
'code': connectErrorCode,
'message': connectErrorMessage ?? 'connect failed',
'details': <String, dynamic>{
if (connectErrorDetailCode != null)
'code': connectErrorDetailCode,
},
},
}),
);
if (closeAfterConnectError) {
await socket.close(
WebSocketStatus.policyViolation,
'connect failed',
);
}
break;
}
socket.add(
jsonEncode(<String, dynamic>{
'type': 'res',
'id': id,
'ok': true,
'payload': <String, dynamic>{
'server': <String, dynamic>{'host': '127.0.0.1'},
'snapshot': <String, dynamic>{
'sessionDefaults': <String, dynamic>{
'mainSessionKey': 'main',
},
},
'auth': <String, dynamic>{
'role': 'operator',
'scopes': const <String>[
'operator.admin',
'operator.pairing',
],
},
},
}),
);
break;
case 'device.pair.list':
socket.add(
jsonEncode(<String, dynamic>{
'type': 'res',
'id': id,
'ok': true,
'payload': <String, dynamic>{
'pending': <Map<String, dynamic>>[
<String, dynamic>{
'requestId': 'req-1',
'deviceId': 'device-pending',
'displayName': 'Pending Device',
'role': 'operator',
'scopes': const <String>['operator.read'],
'remoteIp': '10.0.0.8',
'ts': 1700000000000,
},
],
'paired': <Map<String, dynamic>>[
<String, dynamic>{
'deviceId': currentDeviceId ?? 'device-current',
'displayName': 'Current Device',
'roles': const <String>['operator'],
'scopes': const <String>[
'operator.admin',
'operator.pairing',
],
'tokens': <Map<String, dynamic>>[
<String, dynamic>{
'role': 'operator',
'scopes': const <String>[
'operator.admin',
'operator.pairing',
],
'createdAtMs': 1700000001000,
},
],
},
],
},
}),
);
break;
case 'device.token.rotate':
socket.add(
jsonEncode(<String, dynamic>{
'type': 'res',
'id': id,
'ok': true,
'payload': <String, dynamic>{
'deviceId': params['deviceId'],
'role': params['role'],
'token': 'rotated-local-device-token',
'scopes': params['scopes'] ?? const <String>[],
},
}),
);
break;
case 'device.token.revoke':
socket.add(
jsonEncode(<String, dynamic>{
'type': 'res',
'id': id,
'ok': true,
'payload': <String, dynamic>{
'deviceId': params['deviceId'],
'role': params['role'],
},
}),
);
break;
default:
socket.add(
jsonEncode(<String, dynamic>{
'type': 'res',
'id': id,
'ok': true,
'payload': const <String, dynamic>{},
}),
);
break;
}
}
}
}
}