Files
plezy/test/services/companion_remote_lan_discovery_service_test.dart
T

264 lines
7.4 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/services/companion_remote/lan_discovery_service.dart';
import 'package:plezy/services/companion_remote/remote_auth_context.dart';
import 'package:plezy/services/companion_remote/remote_auth_service.dart';
void main() {
group('LanDiscoveryService', () {
test(
'publishes a changed normalized IP set for an existing host',
() async {
final context = _authContext(
id: 'context-a',
discoveryKey: List<int>.generate(32, (index) => index),
);
final listener = await _DiscoveryListener.start([context]);
try {
listener.sendBeacon(context: context, ips: const ['192.0.2.10']);
await _waitFor(() => listener.emissions.length == 1);
listener.sendBeacon(
context: context,
ips: const ['192.0.2.30', '10.0.0.30'],
);
await _waitFor(() => listener.emissions.length == 2);
final hosts = listener.emissions.last;
expect(hosts, hasLength(1));
final host = hosts.single;
expect(host.clientId, 'shared-client');
expect(host.authContextId, 'context-a');
expect(host.ips, ['10.0.0.30', '192.0.2.30']);
expect(
host.addresses,
unorderedEquals(['10.0.0.30:52100', '192.0.2.30:52100']),
);
expect(host.addresses, isNot(contains('192.0.2.10:52100')));
} finally {
await listener.close();
}
},
);
test(
'suppresses reordered IPs and publishes a platform-only change',
() async {
final context = _authContext(
id: 'context-a',
discoveryKey: List<int>.generate(32, (index) => index + 32),
);
final listener = await _DiscoveryListener.start([context]);
try {
listener.sendBeacon(
context: context,
platform: 'macOS',
ips: const ['192.0.2.40', '10.0.0.40'],
);
await _waitFor(() => listener.emissions.length == 1);
listener.sendBeacon(
context: context,
platform: 'macOS',
ips: const ['10.0.0.40', '192.0.2.40'],
);
listener.sendBeacon(
context: context,
platform: 'Android',
ips: const ['192.0.2.40', '10.0.0.40'],
);
await _waitFor(
() => listener.emissions.any(
(hosts) => hosts.single.platform == 'Android',
),
);
expect(listener.emissions, hasLength(2));
final hosts = listener.emissions.last;
expect(hosts, hasLength(1));
final host = hosts.single;
expect(host.clientId, 'shared-client');
expect(host.platform, 'Android');
expect(
host.addresses,
unorderedEquals(['10.0.0.40:52100', '192.0.2.40:52100']),
);
} finally {
await listener.close();
}
},
);
test(
'suppresses context-only churn and retains the usable context',
() async {
final firstContext = _authContext(
id: 'context-a',
discoveryKey: List<int>.generate(32, (index) => index + 64),
);
final secondContext = _authContext(
id: 'context-b',
discoveryKey: List<int>.generate(32, (index) => index + 96),
);
final listener = await _DiscoveryListener.start([
firstContext,
secondContext,
]);
try {
listener.sendBeacon(
context: firstContext,
name: 'Living Room',
ips: const ['192.0.2.50'],
);
await _waitFor(() => listener.emissions.length == 1);
listener.sendBeacon(
context: secondContext,
name: 'Living Room',
ips: const ['192.0.2.50'],
);
listener.sendBeacon(
context: secondContext,
name: 'Living Room TV',
ips: const ['192.0.2.50'],
);
await _waitFor(
() => listener.emissions.any(
(hosts) => hosts.single.name == 'Living Room TV',
),
);
expect(listener.emissions, hasLength(2));
final hosts = listener.emissions.last;
expect(hosts, hasLength(1));
expect(hosts.single.clientId, 'shared-client');
expect(hosts.single.name, 'Living Room TV');
expect(hosts.single.authContextId, 'context-a');
} finally {
await listener.close();
}
},
);
});
}
RemoteAuthContext _authContext({
required String id,
required List<int> discoveryKey,
}) {
return RemoteAuthContext(
id: id,
backend: 'plex',
connectionId: 'connection-$id',
homeSecret: List<int>.filled(32, 7),
discoveryKey: discoveryKey,
clientIdentifier: 'shared-client',
userUuid: 'user-$id',
allowedUserUuids: ['user-$id'],
);
}
class _DiscoveryListener {
_DiscoveryListener._({
required this.service,
required this.sender,
required this.subscription,
required this.emissions,
});
final LanDiscoveryService service;
final RawDatagramSocket sender;
final StreamSubscription<List<DiscoveredHost>> subscription;
final List<List<DiscoveredHost>> emissions;
static Future<_DiscoveryListener> start(
List<RemoteAuthContext> contexts,
) async {
final service = LanDiscoveryService();
final emissions = <List<DiscoveredHost>>[];
final subscription = service
.startListeningForContexts(contexts)
.listen(emissions.add);
final sender = await RawDatagramSocket.bind(
InternetAddress.loopbackIPv4,
0,
);
try {
await _waitFor(() => service.isListening);
return _DiscoveryListener._(
service: service,
sender: sender,
subscription: subscription,
emissions: emissions,
);
} catch (_) {
sender.close();
await subscription.cancel();
service.dispose();
rethrow;
}
}
void sendBeacon({
required RemoteAuthContext context,
required List<String> ips,
String name = 'Living Room',
String platform = 'macOS',
int port = 52100,
}) {
const version = 1;
final auth = RemoteAuthService.instance;
final homeHash = auth.computeDiscoveryTag(context.discoveryKey);
final hmac = auth.computeBeaconHmac(
discoveryKey: context.discoveryKey,
version: version,
homeHash: homeHash,
name: name,
platform: platform,
clientId: context.clientIdentifier,
port: port,
ips: ips,
);
final packet = utf8.encode(
jsonEncode({
'app': 'plezy',
'v': version,
'homeHash': homeHash,
'name': name,
'platform': platform,
'clientId': context.clientIdentifier,
'port': port,
'ips': ips,
'hmac': hmac,
}),
);
sender.send(
packet,
InternetAddress.loopbackIPv4,
LanDiscoveryService.discoveryPort,
);
}
Future<void> close() async {
sender.close();
await subscription.cancel();
service.dispose();
}
}
Future<void> _waitFor(bool Function() condition) async {
for (var attempt = 0; attempt < 100; attempt++) {
if (condition()) return;
await Future<void>.delayed(const Duration(milliseconds: 20));
}
fail('Timed out waiting for LAN discovery behavior');
}