fix(relay): secure reconnect and room ownership
This commit is contained in:
@@ -34,12 +34,25 @@ void main() {
|
||||
|
||||
test('RecentRoom preserves epoch timestamp and control mode index', () {
|
||||
final lastUsed = DateTime.fromMillisecondsSinceEpoch(1700000000000);
|
||||
final room = RecentRoom(code: 'ABCD', name: 'Movie night', lastUsed: lastUsed, controlMode: ControlMode.anyone);
|
||||
final room = RecentRoom(
|
||||
code: 'ABCD',
|
||||
relayScope: 'scope-digest',
|
||||
name: 'Movie night',
|
||||
lastUsed: lastUsed,
|
||||
controlMode: ControlMode.anyone,
|
||||
);
|
||||
|
||||
expect(room.toJson(), {'code': 'ABCD', 'name': 'Movie night', 'lastUsed': 1700000000000, 'controlMode': 1});
|
||||
expect(room.toJson(), {
|
||||
'code': 'ABCD',
|
||||
'relayScope': 'scope-digest',
|
||||
'name': 'Movie night',
|
||||
'lastUsed': 1700000000000,
|
||||
'controlMode': 1,
|
||||
});
|
||||
|
||||
final decoded = RecentRoom.fromJson(room.toJson());
|
||||
expect(decoded.code, room.code);
|
||||
expect(decoded.relayScope, room.relayScope);
|
||||
expect(decoded.name, room.name);
|
||||
expect(decoded.lastUsed, lastUsed);
|
||||
expect(decoded.controlMode, ControlMode.anyone);
|
||||
@@ -47,9 +60,13 @@ void main() {
|
||||
|
||||
test('RecentRoom omits nullable fields when absent', () {
|
||||
final lastUsed = DateTime.fromMillisecondsSinceEpoch(1700000000000);
|
||||
final room = RecentRoom(code: 'ABCD', lastUsed: lastUsed);
|
||||
final room = RecentRoom(code: 'ABCD', relayScope: 'scope-digest', lastUsed: lastUsed);
|
||||
|
||||
expect(room.toJson(), {'code': 'ABCD', 'lastUsed': 1700000000000});
|
||||
expect(room.toJson(), {'code': 'ABCD', 'relayScope': 'scope-digest', 'lastUsed': 1700000000000});
|
||||
});
|
||||
|
||||
test('RecentRoom requires relay scope when decoding', () {
|
||||
expect(() => RecentRoom.fromJson({'code': 'ABCD', 'lastUsed': 1700000000000}), throwsA(anything));
|
||||
});
|
||||
|
||||
test('RemoteCommand keeps compact protocol keys and unknown fallback', () {
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:plezy/profiles/plex_home_service.dart';
|
||||
import 'package:plezy/profiles/profile.dart';
|
||||
import 'package:plezy/profiles/profile_connection_registry.dart';
|
||||
import 'package:plezy/profiles/profile_registry.dart';
|
||||
import 'package:plezy/providers/companion_remote_provider.dart';
|
||||
import 'package:plezy/providers/discover_provider.dart';
|
||||
import 'package:plezy/providers/hidden_libraries_provider.dart';
|
||||
import 'package:plezy/providers/multi_server_provider.dart';
|
||||
@@ -57,11 +58,12 @@ void main() {
|
||||
final discoverProviders = <DiscoverProvider>[];
|
||||
final hiddenProviders = <HiddenLibrariesProvider>[];
|
||||
final trackerProviders = <TrackersProvider>[];
|
||||
final companionProviders = <CompanionRemoteProvider>[];
|
||||
final disposedActiveIds = <String>[];
|
||||
|
||||
addTearDown(() async {
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
await activeProfile.resetForTesting();
|
||||
activeProfile.dispose();
|
||||
multiServer.dispose();
|
||||
@@ -85,6 +87,9 @@ void main() {
|
||||
providers: [
|
||||
Provider<StorageService>.value(value: storage),
|
||||
Provider<AppDatabase>.value(value: db),
|
||||
Provider<ConnectionRegistry>.value(value: connectionRegistry),
|
||||
Provider<ProfileConnectionRegistry>.value(value: profileConnectionRegistry),
|
||||
Provider<PlexHomeService>.value(value: plexHome),
|
||||
ChangeNotifierProvider<ActiveProfileProvider>.value(value: activeProfile),
|
||||
ChangeNotifierProvider<MultiServerProvider>.value(value: multiServer),
|
||||
ChangeNotifierProvider<OfflineWatchSyncService>.value(value: offlineWatch),
|
||||
@@ -96,6 +101,7 @@ void main() {
|
||||
discoverProviders: discoverProviders,
|
||||
hiddenProviders: hiddenProviders,
|
||||
trackerProviders: trackerProviders,
|
||||
companionProviders: companionProviders,
|
||||
disposedActiveIds: disposedActiveIds,
|
||||
),
|
||||
),
|
||||
@@ -109,10 +115,12 @@ void main() {
|
||||
expect(discoverProviders.single.profileId, owner.id);
|
||||
expect(discoverProviders, hasLength(1));
|
||||
expect(hiddenProviders, hasLength(1));
|
||||
expect(companionProviders, hasLength(1));
|
||||
final ownerNavigator = profileNavigationRegistry.navigator;
|
||||
final ownerDiscover = discoverProviders.single;
|
||||
final ownerHidden = hiddenProviders.single;
|
||||
final ownerTrackers = trackerProviders.single;
|
||||
final ownerCompanion = companionProviders.single;
|
||||
await ownerHidden.ensureInitialized();
|
||||
expect(ownerHidden.profileId, owner.id);
|
||||
expect(ownerHidden.hiddenLibraryKeys, {'srv:owner'});
|
||||
@@ -134,6 +142,9 @@ void main() {
|
||||
expect(trackerProviders, hasLength(2));
|
||||
expect(trackerProviders.last, isNot(same(ownerTrackers)));
|
||||
expect(ownerTrackers.isDisposed, isTrue);
|
||||
expect(companionProviders, hasLength(2));
|
||||
expect(companionProviders.last, isNot(same(ownerCompanion)));
|
||||
expect(ownerCompanion.isDisposed, isTrue);
|
||||
await hiddenProviders.last.ensureInitialized();
|
||||
expect(hiddenProviders.last.profileId, kids.id);
|
||||
expect(hiddenProviders.last.hiddenLibraryKeys, {'srv:kids'});
|
||||
@@ -145,17 +156,27 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
expect(SystemShelfService().debugActiveOwner, isNull);
|
||||
expect(discoverProviders.last.profileId, isNull);
|
||||
|
||||
// Companion provider disposal cancels Drift-backed profile watches. Give
|
||||
// their asynchronous cancellation timers a frame before test invariants
|
||||
// are checked.
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
for (var i = 0; i < 3; i++) {
|
||||
await tester.pump(const Duration(milliseconds: 1));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
class _ProfileProbeShell extends StatefulWidget {
|
||||
const _ProfileProbeShell({
|
||||
required this.discoverProviders,
|
||||
required this.companionProviders,
|
||||
required this.hiddenProviders,
|
||||
required this.disposedActiveIds,
|
||||
required this.trackerProviders,
|
||||
});
|
||||
|
||||
final List<CompanionRemoteProvider> companionProviders;
|
||||
final List<DiscoverProvider> discoverProviders;
|
||||
final List<HiddenLibrariesProvider> hiddenProviders;
|
||||
final List<TrackersProvider> trackerProviders;
|
||||
@@ -166,6 +187,7 @@ class _ProfileProbeShell extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _ProfileProbeShellState extends State<_ProfileProbeShell> {
|
||||
CompanionRemoteProvider? _companionProvider;
|
||||
DiscoverProvider? _discoverProvider;
|
||||
HiddenLibrariesProvider? _hiddenProvider;
|
||||
TrackersProvider? _trackersProvider;
|
||||
@@ -174,6 +196,7 @@ class _ProfileProbeShellState extends State<_ProfileProbeShell> {
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_companionProvider = context.read<CompanionRemoteProvider>();
|
||||
_discoverProvider = context.read<DiscoverProvider>();
|
||||
_hiddenProvider = context.read<HiddenLibrariesProvider>();
|
||||
_trackersProvider = context.read<TrackersProvider>();
|
||||
@@ -187,6 +210,9 @@ class _ProfileProbeShellState extends State<_ProfileProbeShell> {
|
||||
if (widget.trackerProviders.isEmpty || !identical(widget.trackerProviders.last, _trackersProvider)) {
|
||||
widget.trackerProviders.add(_trackersProvider!);
|
||||
}
|
||||
if (widget.companionProviders.isEmpty || !identical(widget.companionProviders.last, _companionProvider)) {
|
||||
widget.companionProviders.add(_companionProvider!);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/connection/connection.dart';
|
||||
@@ -15,7 +17,9 @@ import 'package:plezy/profiles/profile_connection.dart';
|
||||
import 'package:plezy/profiles/profile_connection_registry.dart';
|
||||
import 'package:plezy/profiles/profile_registry.dart';
|
||||
import 'package:plezy/providers/companion_remote_provider.dart';
|
||||
import 'package:plezy/services/base_peer_service.dart';
|
||||
import 'package:plezy/services/companion_remote/companion_remote_peer_service.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';
|
||||
import 'package:plezy/services/storage_service.dart';
|
||||
|
||||
@@ -121,6 +125,325 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('CompanionRemoteProvider — reconnect ownership', () {
|
||||
test('newest overlapping connect owns the session and disposes the stale candidate once', () async {
|
||||
final firstJoinGate = Completer<void>();
|
||||
final firstDisposalGate = Completer<void>();
|
||||
final first = _FakeCompanionRemotePeerService(joinGate: firstJoinGate, disconnectGate: firstDisposalGate);
|
||||
final second = _FakeCompanionRemotePeerService();
|
||||
final factory = _FakePeerFactory([first, second]);
|
||||
final harness = await _RemoteHarness.create(factory.call);
|
||||
addTearDown(harness.close);
|
||||
|
||||
final olderConnect = harness.provider.connectToManualHost('192.0.2.20:48634');
|
||||
await first.joinStarted.future;
|
||||
final newerConnect = harness.provider.connectToManualHost('192.0.2.21:48634');
|
||||
await first.disconnectStarted.future;
|
||||
firstJoinGate.complete();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(first.disposeCalls, 1);
|
||||
|
||||
firstDisposalGate.complete();
|
||||
await second.joinStarted.future;
|
||||
await newerConnect;
|
||||
|
||||
harness.provider.sendCommand(RemoteCommandType.playPause);
|
||||
expect(harness.provider.status, RemoteSessionStatus.connected);
|
||||
expect(second.sentCommands.single.type, RemoteCommandType.playPause);
|
||||
|
||||
await olderConnect;
|
||||
|
||||
expect(harness.provider.status, RemoteSessionStatus.connected);
|
||||
expect(first.disposeCalls, 1);
|
||||
expect(first.disconnectCalls, 1);
|
||||
expect(first.hasListeners, isFalse);
|
||||
expect(second.disposeCalls, 0);
|
||||
expect(factory.created, 2);
|
||||
});
|
||||
|
||||
test('replacement disconnect reconnects while intentional predecessor teardown is blocked', () async {
|
||||
final predecessorDisposalGate = Completer<void>();
|
||||
final predecessor = _FakeCompanionRemotePeerService(disconnectGate: predecessorDisposalGate);
|
||||
final replacement = _FakeCompanionRemotePeerService();
|
||||
final factory = _FakePeerFactory([predecessor, replacement]);
|
||||
final harness = await _RemoteHarness.create(factory.call);
|
||||
addTearDown(harness.close);
|
||||
|
||||
Future<void>? predecessorTeardown;
|
||||
addTearDown(() async {
|
||||
if (!predecessorDisposalGate.isCompleted) {
|
||||
predecessorDisposalGate.complete();
|
||||
}
|
||||
final teardown = predecessorTeardown;
|
||||
if (teardown != null) await teardown;
|
||||
});
|
||||
|
||||
await harness.provider.connectToManualHost('192.0.2.22:48634');
|
||||
|
||||
var teardownCompleted = false;
|
||||
predecessorTeardown = harness.provider.leaveSession().whenComplete(() {
|
||||
teardownCompleted = true;
|
||||
});
|
||||
await predecessor.disconnectStarted.future;
|
||||
expect(teardownCompleted, isFalse);
|
||||
|
||||
await harness.provider.connectToManualHost('192.0.2.23:48634');
|
||||
expect(harness.provider.status, RemoteSessionStatus.connected);
|
||||
expect(replacement.hasListeners, isTrue);
|
||||
|
||||
final publishedStatuses = <RemoteSessionStatus>[];
|
||||
void captureStatus() => publishedStatuses.add(harness.provider.status);
|
||||
harness.provider.addListener(captureStatus);
|
||||
|
||||
predecessor.emitError(
|
||||
RemotePeerError(type: RemotePeerErrorType.connectionFailed, message: 'stale predecessor error'),
|
||||
);
|
||||
predecessor.emitDeviceDisconnected();
|
||||
|
||||
expect(publishedStatuses, isEmpty);
|
||||
expect(harness.provider.status, RemoteSessionStatus.connected);
|
||||
expect(harness.provider.session?.errorMessage, isNull);
|
||||
expect(harness.provider.reconnectAttempts, 0);
|
||||
|
||||
replacement.emitDeviceDisconnected();
|
||||
|
||||
expect(publishedStatuses, [RemoteSessionStatus.reconnecting]);
|
||||
expect(harness.provider.status, RemoteSessionStatus.reconnecting);
|
||||
expect(harness.provider.reconnectAttempts, 1);
|
||||
expect(teardownCompleted, isFalse);
|
||||
|
||||
predecessorDisposalGate.complete();
|
||||
await predecessorTeardown;
|
||||
|
||||
expect(publishedStatuses, [RemoteSessionStatus.reconnecting]);
|
||||
expect(harness.provider.status, RemoteSessionStatus.reconnecting);
|
||||
expect(harness.provider.reconnectAttempts, 1);
|
||||
|
||||
harness.provider.removeListener(captureStatus);
|
||||
await harness.provider.cancelReconnect();
|
||||
});
|
||||
|
||||
test('cancelReconnect fully tears down a running host and discovery', () async {
|
||||
final hostDisposalGate = Completer<void>();
|
||||
final host = _FakeCompanionRemotePeerService(disconnectGate: hostDisposalGate);
|
||||
final discovery = _FakeLanDiscoveryService();
|
||||
final factory = _FakePeerFactory([host]);
|
||||
final harness = await _RemoteHarness.create(factory.call, discoveryServiceFactory: () => discovery);
|
||||
addTearDown(harness.close);
|
||||
|
||||
await harness.provider.startHostServer();
|
||||
expect(harness.provider.isHostServerRunning, isTrue);
|
||||
expect(harness.provider.debugIsDiscoveryBroadcasting, isTrue);
|
||||
expect(host.hasListeners, isTrue);
|
||||
|
||||
final cancellation = harness.provider.cancelReconnect();
|
||||
await host.disconnectStarted.future;
|
||||
expect(harness.provider.debugIsDiscoveryBroadcasting, isFalse);
|
||||
expect(host.hasListeners, isFalse);
|
||||
hostDisposalGate.complete();
|
||||
await cancellation;
|
||||
|
||||
expect(harness.provider.session, isNull);
|
||||
expect(harness.provider.isHostServerRunning, isFalse);
|
||||
expect(harness.provider.debugIsDiscoveryBroadcasting, isFalse);
|
||||
expect(harness.provider.debugIsDiscoveryListening, isFalse);
|
||||
expect(discovery.stopBroadcastingCalls, 1);
|
||||
expect(discovery.stopListeningCalls, 1);
|
||||
expect(host.disposeCalls, 1);
|
||||
expect(host.disconnectCalls, 1);
|
||||
expect(host.hasListeners, isFalse);
|
||||
});
|
||||
|
||||
test('cancel during join keeps disconnected state and disposes the candidate once', () async {
|
||||
final initial = _FakeCompanionRemotePeerService();
|
||||
final joinGate = Completer<void>();
|
||||
final candidate = _FakeCompanionRemotePeerService(joinGate: joinGate);
|
||||
final factory = _FakePeerFactory([initial, candidate]);
|
||||
final harness = await _RemoteHarness.create(factory.call);
|
||||
addTearDown(harness.close);
|
||||
await harness.provider.connectToManualHost('192.0.2.10:48634');
|
||||
|
||||
final statuses = <RemoteSessionStatus>[];
|
||||
harness.provider.addListener(() => statuses.add(harness.provider.status));
|
||||
initial.emitDeviceDisconnected();
|
||||
expect(harness.provider.status, RemoteSessionStatus.reconnecting);
|
||||
|
||||
final retry = harness.provider.retryReconnectNow();
|
||||
await candidate.joinStarted.future;
|
||||
expect(candidate.hasListeners, isTrue);
|
||||
|
||||
final cancellation = harness.provider.cancelReconnect();
|
||||
expect(harness.provider.status, RemoteSessionStatus.disconnected);
|
||||
await cancellation;
|
||||
final statusCountAfterCancel = statuses.length;
|
||||
|
||||
joinGate.complete();
|
||||
await retry;
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(harness.provider.status, RemoteSessionStatus.disconnected);
|
||||
expect(statuses.skip(statusCountAfterCancel), isNot(contains(RemoteSessionStatus.connected)));
|
||||
expect(candidate.disposeCalls, 1);
|
||||
expect(candidate.disconnectCalls, 1);
|
||||
expect(candidate.hasListeners, isFalse);
|
||||
expect(factory.created, 2);
|
||||
expect(harness.provider.reconnectAttempts, 0);
|
||||
});
|
||||
|
||||
test('leave during join clears the session and rejects late commands', () async {
|
||||
final initial = _FakeCompanionRemotePeerService();
|
||||
final joinGate = Completer<void>();
|
||||
final candidate = _FakeCompanionRemotePeerService(joinGate: joinGate);
|
||||
final factory = _FakePeerFactory([initial, candidate]);
|
||||
final harness = await _RemoteHarness.create(factory.call);
|
||||
addTearDown(harness.close);
|
||||
await harness.provider.connectToManualHost('192.0.2.11:48634');
|
||||
|
||||
var deliveredCommands = 0;
|
||||
harness.provider.onCommandReceived = (_) => deliveredCommands++;
|
||||
initial.emitDeviceDisconnected();
|
||||
final retry = harness.provider.retryReconnectNow();
|
||||
await candidate.joinStarted.future;
|
||||
|
||||
await harness.provider.leaveSession();
|
||||
expect(harness.provider.session, isNull);
|
||||
expect(candidate.hasListeners, isFalse);
|
||||
candidate.emitCommand(const RemoteCommand(type: RemoteCommandType.playPause));
|
||||
joinGate.complete();
|
||||
await retry;
|
||||
|
||||
expect(harness.provider.session, isNull);
|
||||
expect(deliveredCommands, 0);
|
||||
expect(candidate.disposeCalls, 1);
|
||||
expect(factory.created, 2);
|
||||
expect(harness.provider.reconnectAttempts, 0);
|
||||
});
|
||||
|
||||
for (final action in ['cancel', 'leave']) {
|
||||
test('$action before candidate creation prevents replacement allocation', () async {
|
||||
final disconnectGate = Completer<void>();
|
||||
final initial = _FakeCompanionRemotePeerService(disconnectGate: disconnectGate);
|
||||
final replacement = _FakeCompanionRemotePeerService();
|
||||
final factory = _FakePeerFactory([initial, replacement]);
|
||||
final harness = await _RemoteHarness.create(factory.call);
|
||||
addTearDown(harness.close);
|
||||
await harness.provider.connectToManualHost('192.0.2.12:48634');
|
||||
|
||||
initial.emitDeviceDisconnected();
|
||||
final retry = harness.provider.retryReconnectNow();
|
||||
await initial.disconnectStarted.future;
|
||||
|
||||
if (action == 'cancel') {
|
||||
await harness.provider.cancelReconnect();
|
||||
} else {
|
||||
await harness.provider.leaveSession();
|
||||
}
|
||||
disconnectGate.complete();
|
||||
await retry;
|
||||
|
||||
expect(factory.created, 1);
|
||||
expect(replacement.joinStarted.isCompleted, isFalse);
|
||||
expect(initial.disposeCalls, 1);
|
||||
});
|
||||
}
|
||||
|
||||
test('dispose during join detaches listeners and prevents a late commit', () async {
|
||||
final initial = _FakeCompanionRemotePeerService();
|
||||
final joinGate = Completer<void>();
|
||||
final candidate = _FakeCompanionRemotePeerService(joinGate: joinGate);
|
||||
final factory = _FakePeerFactory([initial, candidate]);
|
||||
final harness = await _RemoteHarness.create(factory.call);
|
||||
addTearDown(harness.close);
|
||||
await harness.provider.connectToManualHost('192.0.2.13:48634');
|
||||
|
||||
var deliveredCommands = 0;
|
||||
harness.provider.onCommandReceived = (_) => deliveredCommands++;
|
||||
initial.emitDeviceDisconnected();
|
||||
final retry = harness.provider.retryReconnectNow();
|
||||
await candidate.joinStarted.future;
|
||||
|
||||
harness.provider.dispose();
|
||||
expect(harness.provider.isDisposed, isTrue);
|
||||
expect(candidate.hasListeners, isFalse);
|
||||
candidate.emitCommand(const RemoteCommand(type: RemoteCommandType.playPause));
|
||||
joinGate.complete();
|
||||
await retry;
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(harness.provider.status, isNot(RemoteSessionStatus.connected));
|
||||
expect(deliveredCommands, 0);
|
||||
expect(candidate.disposeCalls, 1);
|
||||
expect(factory.created, 2);
|
||||
});
|
||||
|
||||
test('logout invalidates a held reconnect before clearing identity', () async {
|
||||
final initial = _FakeCompanionRemotePeerService();
|
||||
final joinGate = Completer<void>();
|
||||
final candidate = _FakeCompanionRemotePeerService(joinGate: joinGate);
|
||||
final factory = _FakePeerFactory([initial, candidate]);
|
||||
final harness = await _RemoteHarness.create(factory.call);
|
||||
addTearDown(harness.close);
|
||||
await harness.provider.connectToManualHost('192.0.2.14:48634');
|
||||
|
||||
initial.emitDeviceDisconnected();
|
||||
final retry = harness.provider.retryReconnectNow();
|
||||
await candidate.joinStarted.future;
|
||||
|
||||
await harness.provider.resetForLogout();
|
||||
expect(harness.provider.session, isNull);
|
||||
expect(harness.provider.isCryptoReady, isFalse);
|
||||
expect(harness.provider.debugCryptoConnectionId, isNull);
|
||||
expect(candidate.hasListeners, isFalse);
|
||||
|
||||
joinGate.complete();
|
||||
await retry;
|
||||
|
||||
expect(harness.provider.session, isNull);
|
||||
expect(harness.provider.isCryptoReady, isFalse);
|
||||
expect(candidate.disposeCalls, 1);
|
||||
expect(factory.created, 2);
|
||||
expect(harness.provider.reconnectAttempts, 0);
|
||||
});
|
||||
|
||||
test('current reconnect failure is contained and schedules one retry', () async {
|
||||
final initial = _FakeCompanionRemotePeerService();
|
||||
final candidate = _FakeCompanionRemotePeerService(joinError: StateError('synthetic reconnect failure'));
|
||||
final factory = _FakePeerFactory([initial, candidate]);
|
||||
final harness = await _RemoteHarness.create(factory.call);
|
||||
addTearDown(harness.close);
|
||||
await harness.provider.connectToManualHost('192.0.2.15:48634');
|
||||
|
||||
initial.emitDeviceDisconnected();
|
||||
await harness.provider.retryReconnectNow();
|
||||
|
||||
expect(harness.provider.status, RemoteSessionStatus.reconnecting);
|
||||
expect(harness.provider.reconnectAttempts, 1);
|
||||
expect(candidate.disposeCalls, 1);
|
||||
expect(candidate.hasListeners, isFalse);
|
||||
|
||||
await harness.provider.cancelReconnect();
|
||||
});
|
||||
|
||||
test('successful current reconnect preserves connected behavior', () async {
|
||||
final initial = _FakeCompanionRemotePeerService();
|
||||
final candidate = _FakeCompanionRemotePeerService();
|
||||
final factory = _FakePeerFactory([initial, candidate]);
|
||||
final harness = await _RemoteHarness.create(factory.call);
|
||||
addTearDown(harness.close);
|
||||
await harness.provider.connectToManualHost('192.0.2.16:48634');
|
||||
|
||||
initial.emitDeviceDisconnected();
|
||||
await harness.provider.retryReconnectNow();
|
||||
harness.provider.sendCommand(RemoteCommandType.playPause);
|
||||
|
||||
expect(harness.provider.status, RemoteSessionStatus.connected);
|
||||
expect(harness.provider.reconnectAttempts, 0);
|
||||
expect(candidate.sentCommands.single.type, RemoteCommandType.playPause);
|
||||
expect(initial.disposeCalls, 1);
|
||||
expect(candidate.disposeCalls, 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('CompanionRemoteProvider — public API safety', () {
|
||||
test('connectToDiscoveredHost reports localized auth failure when crypto is not ready', () async {
|
||||
final p = CompanionRemoteProvider();
|
||||
@@ -546,3 +869,264 @@ PlexHomeUser _homeUser(String uuid, {required bool admin}) {
|
||||
protected: false,
|
||||
);
|
||||
}
|
||||
|
||||
class _FakePeerFactory {
|
||||
_FakePeerFactory(this.peers);
|
||||
|
||||
final List<_FakeCompanionRemotePeerService> peers;
|
||||
int created = 0;
|
||||
|
||||
CompanionRemotePeerService call() {
|
||||
if (created >= peers.length) {
|
||||
throw StateError('Unexpected peer allocation');
|
||||
}
|
||||
return peers[created++];
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeCompanionRemotePeerService extends CompanionRemotePeerService {
|
||||
_FakeCompanionRemotePeerService({this.joinGate, this.disconnectGate, this.joinError});
|
||||
|
||||
final Completer<void>? joinGate;
|
||||
final Completer<void>? disconnectGate;
|
||||
final Object? joinError;
|
||||
final Completer<void> joinStarted = Completer<void>();
|
||||
final Completer<void> disconnectStarted = Completer<void>();
|
||||
final List<RemoteCommand> sentCommands = [];
|
||||
|
||||
final StreamController<RemoteCommand> _commands = StreamController<RemoteCommand>.broadcast(sync: true);
|
||||
final StreamController<RemoteDevice> _connected = StreamController<RemoteDevice>.broadcast(sync: true);
|
||||
final StreamController<void> _disconnected = StreamController<void>.broadcast(sync: true);
|
||||
final StreamController<RemotePeerError> _errors = StreamController<RemotePeerError>.broadcast(sync: true);
|
||||
final StreamController<RemoteSessionStatus> _statuses = StreamController<RemoteSessionStatus>.broadcast(sync: true);
|
||||
|
||||
int disconnectCalls = 0;
|
||||
int disposeCalls = 0;
|
||||
bool _streamsClosed = false;
|
||||
bool _serverRunning = false;
|
||||
|
||||
@override
|
||||
bool get isServerRunning => _serverRunning;
|
||||
|
||||
bool get hasListeners =>
|
||||
_commands.hasListener ||
|
||||
_connected.hasListener ||
|
||||
_disconnected.hasListener ||
|
||||
_errors.hasListener ||
|
||||
_statuses.hasListener;
|
||||
|
||||
@override
|
||||
Stream<RemoteCommand> get onCommandReceived => _commands.stream;
|
||||
|
||||
@override
|
||||
Stream<RemoteDevice> get onDeviceConnected => _connected.stream;
|
||||
|
||||
@override
|
||||
Stream<void> get onDeviceDisconnected => _disconnected.stream;
|
||||
|
||||
@override
|
||||
Stream<RemotePeerError> get onError => _errors.stream;
|
||||
|
||||
@override
|
||||
Stream<RemoteSessionStatus> get onConnectionStateChanged => _statuses.stream;
|
||||
|
||||
@override
|
||||
String? get selectedAuthContextId => 'auth-context';
|
||||
|
||||
@override
|
||||
String? get selectedHostClientId => 'host-client';
|
||||
|
||||
@override
|
||||
Future<({List<String> addresses, int port})> createSessionForContexts(
|
||||
String deviceName,
|
||||
String platform,
|
||||
List<RemoteAuthContext> authContexts,
|
||||
) async {
|
||||
_serverRunning = true;
|
||||
return (addresses: const ['127.0.0.1:48634'], port: 48634);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> joinSessionWithContexts(
|
||||
String deviceName,
|
||||
String platform,
|
||||
String hostAddress,
|
||||
List<RemoteAuthContext> authContexts, {
|
||||
String? authContextId,
|
||||
String expectedHostClientId = '',
|
||||
}) async {
|
||||
if (!joinStarted.isCompleted) joinStarted.complete();
|
||||
final gate = joinGate;
|
||||
if (gate != null) await gate.future;
|
||||
final error = joinError;
|
||||
if (error != null) throw error;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> joinSessionRacingWithContexts(
|
||||
String deviceName,
|
||||
String platform,
|
||||
List<String> hostAddresses,
|
||||
List<RemoteAuthContext> authContexts, {
|
||||
String? authContextId,
|
||||
String expectedHostClientId = '',
|
||||
}) async {
|
||||
await joinSessionWithContexts(
|
||||
deviceName,
|
||||
platform,
|
||||
hostAddresses.first,
|
||||
authContexts,
|
||||
authContextId: authContextId,
|
||||
expectedHostClientId: expectedHostClientId,
|
||||
);
|
||||
return hostAddresses.first;
|
||||
}
|
||||
|
||||
@override
|
||||
void sendCommand(RemoteCommand command) {
|
||||
sentCommands.add(command);
|
||||
}
|
||||
|
||||
void emitDeviceDisconnected() {
|
||||
if (!_streamsClosed) _disconnected.add(null);
|
||||
}
|
||||
|
||||
void emitCommand(RemoteCommand command) {
|
||||
if (!_streamsClosed) _commands.add(command);
|
||||
}
|
||||
|
||||
void emitError(RemotePeerError error) {
|
||||
if (!_streamsClosed) _errors.add(error);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> disconnect() async {
|
||||
disconnectCalls++;
|
||||
_serverRunning = false;
|
||||
if (!disconnectStarted.isCompleted) disconnectStarted.complete();
|
||||
final gate = disconnectGate;
|
||||
if (gate != null) await gate.future;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
disposeCalls++;
|
||||
await disconnect();
|
||||
if (_streamsClosed) return;
|
||||
_streamsClosed = true;
|
||||
await Future.wait([
|
||||
_commands.close(),
|
||||
_connected.close(),
|
||||
_disconnected.close(),
|
||||
_errors.close(),
|
||||
_statuses.close(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeLanDiscoveryService extends LanDiscoveryService {
|
||||
bool _broadcasting = false;
|
||||
bool _listening = false;
|
||||
int stopBroadcastingCalls = 0;
|
||||
int stopListeningCalls = 0;
|
||||
|
||||
@override
|
||||
bool get isBroadcasting => _broadcasting;
|
||||
|
||||
@override
|
||||
bool get isListening => _listening;
|
||||
|
||||
@override
|
||||
Future<void> startBroadcastingForContexts({
|
||||
required List<RemoteAuthContext> contexts,
|
||||
required String deviceName,
|
||||
required String platform,
|
||||
required int wsPort,
|
||||
required List<String> ips,
|
||||
}) async {
|
||||
_broadcasting = true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> stopBroadcasting() async {
|
||||
stopBroadcastingCalls++;
|
||||
_broadcasting = false;
|
||||
}
|
||||
|
||||
@override
|
||||
void stopListening() {
|
||||
stopListeningCalls++;
|
||||
_listening = false;
|
||||
}
|
||||
}
|
||||
|
||||
class _RemoteHarness {
|
||||
_RemoteHarness({required this.provider, required this.database, required this.activeProfile, required this.plexHome});
|
||||
|
||||
final CompanionRemoteProvider provider;
|
||||
final AppDatabase database;
|
||||
final ActiveProfileProvider activeProfile;
|
||||
final PlexHomeService plexHome;
|
||||
bool _closed = false;
|
||||
|
||||
static Future<_RemoteHarness> create(
|
||||
CompanionRemotePeerServiceFactory peerServiceFactory, {
|
||||
LanDiscoveryServiceFactory discoveryServiceFactory = LanDiscoveryService.new,
|
||||
}) async {
|
||||
final database = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
final connections = ConnectionRegistry(database);
|
||||
final profileConnections = ProfileConnectionRegistry(database);
|
||||
final profiles = ProfileRegistry(database);
|
||||
final storage = await StorageService.getInstance();
|
||||
final plexHome = PlexHomeService(
|
||||
connections: connections,
|
||||
profileConnections: profileConnections,
|
||||
storage: storage,
|
||||
plexHomeUserFetcher: (_) async => const [],
|
||||
);
|
||||
final activeProfile = ActiveProfileProvider(
|
||||
registry: profiles,
|
||||
plexHome: plexHome,
|
||||
connections: connections,
|
||||
storage: storage,
|
||||
);
|
||||
|
||||
final account = _plexAccount('remote-account', 'remote-client');
|
||||
final profile = _localProfile('remote-profile');
|
||||
await connections.upsert(account);
|
||||
await profiles.upsert(profile);
|
||||
await profileConnections.upsert(
|
||||
ProfileConnection(profileId: profile.id, connectionId: account.id, userIdentifier: 'remote-admin'),
|
||||
makeDefault: true,
|
||||
);
|
||||
await storage.setActiveProfileId(profile.id);
|
||||
await activeProfile.initialize();
|
||||
|
||||
final provider = CompanionRemoteProvider.forTesting(
|
||||
peerServiceFactory: peerServiceFactory,
|
||||
discoveryServiceFactory: discoveryServiceFactory,
|
||||
);
|
||||
final ready = await provider.ensureCryptoReady(
|
||||
_home('remote-admin'),
|
||||
connections: connections,
|
||||
activeProfile: activeProfile,
|
||||
profileConnections: profileConnections,
|
||||
account: account,
|
||||
);
|
||||
if (!ready) {
|
||||
throw StateError('Remote test harness failed to initialize crypto');
|
||||
}
|
||||
|
||||
return _RemoteHarness(provider: provider, database: database, activeProfile: activeProfile, plexHome: plexHome);
|
||||
}
|
||||
|
||||
Future<void> close() async {
|
||||
if (_closed) return;
|
||||
_closed = true;
|
||||
if (!provider.isDisposed) provider.dispose();
|
||||
await activeProfile.resetForTesting();
|
||||
activeProfile.dispose();
|
||||
await plexHome.dispose();
|
||||
await database.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,29 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:plezy/focus/input_mode_tracker.dart';
|
||||
import 'package:plezy/i18n/strings.g.dart';
|
||||
import 'package:plezy/screens/settings/logs_screen.dart';
|
||||
import 'package:plezy/utils/app_logger.dart';
|
||||
import 'package:plezy/utils/media_server_http_client.dart';
|
||||
|
||||
void main() {
|
||||
setUpAll(() {
|
||||
LocaleSettings.setLocaleSync(AppLocale.en);
|
||||
});
|
||||
|
||||
setUp(() {
|
||||
MemoryLogOutput.clearLogs();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
MemoryLogOutput.clearLogs();
|
||||
});
|
||||
test('log upload payload preserves the header and newest complete lines', () {
|
||||
const header = 'Plezy test device\n---\n';
|
||||
const logs = 'oldest line that should be removed\nmiddle line that should be removed\nnewest 🚀 line';
|
||||
@@ -22,4 +42,108 @@ void main() {
|
||||
|
||||
expect(constrainLogUploadPayload(header: header, logs: logs, maxBytes: 128), '$header$logs');
|
||||
});
|
||||
|
||||
testWidgets('long upload capability displays and copies at narrow width', (tester) async {
|
||||
const capability = 'abcdefghijklmnopqrstuvwxy';
|
||||
String? uploadedBody;
|
||||
http.Request? uploadedRequest;
|
||||
String? clipboardText;
|
||||
|
||||
PackageInfo.setMockInitialValues(
|
||||
appName: 'Plezy',
|
||||
packageName: 'com.plezy.test',
|
||||
version: '1.2.3',
|
||||
buildNumber: '45',
|
||||
buildSignature: '',
|
||||
);
|
||||
const deviceInfoChannel = MethodChannel('dev.fluttercommunity.plus/device_info');
|
||||
final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||
messenger.setMockMethodCallHandler(deviceInfoChannel, (call) async {
|
||||
expect(call.method, 'getDeviceInfo');
|
||||
return <String, dynamic>{
|
||||
'computerName': 'test-mac',
|
||||
'hostName': 'test-mac.local',
|
||||
'arch': 'arm64',
|
||||
'model': 'Mac15,3',
|
||||
'modelName': 'Mac',
|
||||
'kernelVersion': 'test',
|
||||
'osRelease': '15.0',
|
||||
'majorVersion': 15,
|
||||
'minorVersion': 0,
|
||||
'patchVersion': 0,
|
||||
'activeCPUs': 8,
|
||||
'memorySize': 16 * 1024 * 1024 * 1024,
|
||||
'cpuFrequency': 0,
|
||||
'systemGUID': 'test-guid',
|
||||
};
|
||||
});
|
||||
messenger.setMockMethodCallHandler(SystemChannels.platform, (call) async {
|
||||
if (call.method == 'Clipboard.setData') {
|
||||
clipboardText = (call.arguments as Map<Object?, Object?>)['text'] as String?;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
addTearDown(() {
|
||||
messenger.setMockMethodCallHandler(deviceInfoChannel, null);
|
||||
messenger.setMockMethodCallHandler(SystemChannels.platform, null);
|
||||
});
|
||||
|
||||
final client = MediaServerHttpClient(
|
||||
client: MockClient((request) async {
|
||||
uploadedRequest = request;
|
||||
uploadedBody = request.body;
|
||||
return http.Response(
|
||||
jsonEncode(<String, String>{'id': capability}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}),
|
||||
);
|
||||
addTearDown(client.close);
|
||||
appLogger.i('widget upload seed');
|
||||
|
||||
tester.view.physicalSize = const Size(320, 640);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
await tester.pumpWidget(
|
||||
TranslationProvider(
|
||||
child: InputModeTracker(
|
||||
child: MaterialApp(home: LogsScreen(httpClient: client)),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byTooltip(t.logs.uploadLogs));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(find.text(capability, findRichText: true), findsOneWidget);
|
||||
expect(utf8.encode(uploadedBody!).length, lessThanOrEqualTo(maxLogUploadBytes));
|
||||
expect(uploadedRequest!.method, 'POST');
|
||||
expect(uploadedRequest!.url, Uri.parse('https://ice.plezy.app/logs'));
|
||||
final contentType = uploadedRequest!.headers.entries
|
||||
.singleWhere((entry) => entry.key.toLowerCase() == 'content-type')
|
||||
.value;
|
||||
expect(contentType, startsWith('text/plain'));
|
||||
expect(uploadedBody, contains('widget upload seed'));
|
||||
|
||||
final dialog = find.byType(AlertDialog);
|
||||
final copyButton = find.descendant(of: dialog, matching: find.byType(IconButton));
|
||||
expect(copyButton, findsOneWidget);
|
||||
await tester.tap(copyButton);
|
||||
await tester.pump();
|
||||
expect(clipboardText, capability);
|
||||
|
||||
clipboardText = null;
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft);
|
||||
await tester.pump();
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pump();
|
||||
expect(clipboardText, capability);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -226,6 +226,39 @@ void main() {
|
||||
expect(materialUpdateTile.focusNode, isNotNull);
|
||||
});
|
||||
|
||||
testWidgets('relay dialog rejects invalid bases without persisting them', (tester) async {
|
||||
final harness = await _pumpSettingsScreen(tester);
|
||||
addTearDown(() => harness.dispose(tester));
|
||||
|
||||
await tester.tap(find.text(t.settings.watchTogetherRelay));
|
||||
await _pumpUi(tester);
|
||||
await tester.enterText(find.byType(TextField), 'ws://opaque-user@relay.example.test/path?wrong=route');
|
||||
await tester.tap(find.text(t.common.save));
|
||||
await _pumpUi(tester);
|
||||
|
||||
expect(find.byType(AlertDialog), findsOneWidget);
|
||||
expect(find.text(t.settings.watchTogetherRelayInvalid), findsOneWidget);
|
||||
expect(SettingsService.instance.read(SettingsService.customRelayUrl), isNull);
|
||||
});
|
||||
|
||||
testWidgets('relay dialog saves and reopens the canonical base', (tester) async {
|
||||
final harness = await _pumpSettingsScreen(tester);
|
||||
addTearDown(() => harness.dispose(tester));
|
||||
|
||||
await tester.tap(find.text(t.settings.watchTogetherRelay));
|
||||
await _pumpUi(tester);
|
||||
await tester.enterText(find.byType(TextField), ' HTTP://Relay.Example.Test:8080/prefix/// ');
|
||||
await tester.tap(find.text(t.common.save));
|
||||
await _pumpUi(tester);
|
||||
|
||||
expect(find.byType(AlertDialog), findsNothing);
|
||||
expect(SettingsService.instance.read(SettingsService.customRelayUrl), 'http://relay.example.test:8080/prefix');
|
||||
|
||||
await tester.tap(find.text(t.settings.watchTogetherRelay));
|
||||
await _pumpUi(tester);
|
||||
expect(find.widgetWithText(TextField, 'http://relay.example.test:8080/prefix'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('folder replacement uses the provider coordinator', (tester) async {
|
||||
final selectedDirectory = Directory('${temporaryDirectory.path}/selected-downloads');
|
||||
directoryPicker.directoryPath = selectedDirectory.path;
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/i18n/strings.g.dart';
|
||||
import 'package:plezy/models/companion_remote/remote_command.dart';
|
||||
import 'package:plezy/services/companion_remote/companion_remote_peer_service.dart';
|
||||
import 'package:plezy/services/companion_remote/remote_auth_context.dart';
|
||||
import 'package:plezy/services/companion_remote/remote_auth_service.dart';
|
||||
|
||||
const _ioTimeout = Duration(seconds: 5);
|
||||
|
||||
void main() {
|
||||
test('auth precondition error uses the active locale', () async {
|
||||
@@ -17,6 +24,428 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('per-source admission rejects before upgrade and recovers exactly one slot', () async {
|
||||
final host = CompanionRemotePeerService.forTesting(maxTotalHostConnections: 4, maxHostConnectionsPerSource: 2);
|
||||
final clients = <_RawWebSocketClient>[];
|
||||
addTearDown(() => _disposeHostAndClients(host, clients));
|
||||
final port = await _startHost(host);
|
||||
|
||||
clients.add(await _RawWebSocketClient.connect(port));
|
||||
clients.add(await _RawWebSocketClient.connect(port));
|
||||
await _expectWebSocketHandshakeStatus(port, HttpStatus.tooManyRequests);
|
||||
expect(clients, everyElement(predicate<_RawWebSocketClient>((client) => client.isOpen)));
|
||||
|
||||
await clients.removeAt(0).dispose();
|
||||
clients.add(await _RawWebSocketClient.connect(port));
|
||||
await _expectWebSocketHandshakeStatus(port, HttpStatus.tooManyRequests);
|
||||
});
|
||||
|
||||
test('global admission rejects independently and releases one reservation once', () async {
|
||||
final host = CompanionRemotePeerService.forTesting(maxTotalHostConnections: 2, maxHostConnectionsPerSource: 4);
|
||||
final clients = <_RawWebSocketClient>[];
|
||||
addTearDown(() => _disposeHostAndClients(host, clients));
|
||||
final port = await _startHost(host);
|
||||
|
||||
clients.add(await _RawWebSocketClient.connect(port));
|
||||
clients.add(await _RawWebSocketClient.connect(port));
|
||||
await _expectWebSocketHandshakeStatus(port, HttpStatus.tooManyRequests);
|
||||
|
||||
await clients.removeAt(0).dispose();
|
||||
clients.add(await _RawWebSocketClient.connect(port));
|
||||
await _expectWebSocketHandshakeStatus(port, HttpStatus.tooManyRequests);
|
||||
});
|
||||
|
||||
test('authentication timeout releases capacity for a legitimate encrypted session', () async {
|
||||
final host = CompanionRemotePeerService.forTesting(
|
||||
maxTotalHostConnections: 1,
|
||||
maxHostConnectionsPerSource: 1,
|
||||
authTimeout: const Duration(milliseconds: 300),
|
||||
);
|
||||
final remote = CompanionRemotePeerService();
|
||||
final clients = <_RawWebSocketClient>[];
|
||||
addTearDown(() async {
|
||||
await remote.dispose();
|
||||
await _disposeHostAndClients(host, clients);
|
||||
});
|
||||
final port = await _startHost(host);
|
||||
|
||||
final silent = await _RawWebSocketClient.connect(port);
|
||||
clients.add(silent);
|
||||
expect(await silent.closed.timeout(_ioTimeout), 4001);
|
||||
clients.remove(silent);
|
||||
|
||||
await remote.joinSessionWithContexts(
|
||||
'Test Remote',
|
||||
'ios',
|
||||
'127.0.0.1:$port',
|
||||
[_authContext],
|
||||
authContextId: _authContext.id,
|
||||
expectedHostClientId: _authContext.clientIdentifier,
|
||||
);
|
||||
final command = host.onCommandReceived.firstWhere((event) => event.type == RemoteCommandType.play);
|
||||
remote.sendCommand(const RemoteCommand(type: RemoteCommandType.play));
|
||||
expect((await command.timeout(_ioTimeout)).type, RemoteCommandType.play);
|
||||
});
|
||||
|
||||
test('disconnect closes pending sockets and restart restores full capacity', () async {
|
||||
final host = CompanionRemotePeerService.forTesting(
|
||||
maxTotalHostConnections: 2,
|
||||
maxHostConnectionsPerSource: 2,
|
||||
authTimeout: const Duration(seconds: 1),
|
||||
);
|
||||
final clients = <_RawWebSocketClient>[];
|
||||
addTearDown(() => _disposeHostAndClients(host, clients));
|
||||
var port = await _startHost(host);
|
||||
|
||||
final firstGeneration = [await _RawWebSocketClient.connect(port), await _RawWebSocketClient.connect(port)];
|
||||
clients.addAll(firstGeneration);
|
||||
await host.disconnect();
|
||||
expect(
|
||||
await Future.wait(firstGeneration.map((client) => client.closed.timeout(_ioTimeout))),
|
||||
everyElement(isNot(4001)),
|
||||
);
|
||||
clients.removeWhere(firstGeneration.contains);
|
||||
|
||||
port = await _startHost(host);
|
||||
clients.add(await _RawWebSocketClient.connect(port));
|
||||
clients.add(await _RawWebSocketClient.connect(port));
|
||||
await _expectWebSocketHandshakeStatus(port, HttpStatus.tooManyRequests);
|
||||
});
|
||||
|
||||
test('failed HTTP upgrade and abrupt socket loss both release the only slot', () async {
|
||||
final host = CompanionRemotePeerService.forTesting(maxTotalHostConnections: 1, maxHostConnectionsPerSource: 1);
|
||||
final clients = <_RawWebSocketClient>[];
|
||||
addTearDown(() => _disposeHostAndClients(host, clients));
|
||||
final port = await _startHost(host);
|
||||
|
||||
final httpClient = HttpClient();
|
||||
addTearDown(() => httpClient.close(force: true));
|
||||
final request = await httpClient.getUrl(Uri.parse('http://127.0.0.1:$port/ws'));
|
||||
final response = await request.close();
|
||||
expect(response.statusCode, HttpStatus.badRequest);
|
||||
await response.drain<void>();
|
||||
|
||||
final afterFailedUpgrade = await _RawWebSocketClient.connect(port);
|
||||
clients.add(afterFailedUpgrade);
|
||||
await afterFailedUpgrade.dispose();
|
||||
clients.remove(afterFailedUpgrade);
|
||||
|
||||
final abrupt = await _RawUpgradedSocket.connect(port);
|
||||
addTearDown(abrupt.dispose);
|
||||
await abrupt.dispose();
|
||||
await _flushEventQueue();
|
||||
|
||||
clients.add(await _RawWebSocketClient.connect(port));
|
||||
await _expectWebSocketHandshakeStatus(port, HttpStatus.tooManyRequests);
|
||||
});
|
||||
|
||||
test('setup failure after upgrade closes the socket and releases the only slot', () async {
|
||||
var rejectSetup = true;
|
||||
final host = CompanionRemotePeerService.forTesting(
|
||||
maxTotalHostConnections: 1,
|
||||
maxHostConnectionsPerSource: 1,
|
||||
afterHostUpgrade: () {
|
||||
if (!rejectSetup) return;
|
||||
rejectSetup = false;
|
||||
throw StateError('injected admission setup failure');
|
||||
},
|
||||
);
|
||||
final clients = <_RawWebSocketClient>[];
|
||||
addTearDown(() => _disposeHostAndClients(host, clients));
|
||||
final port = await _startHost(host);
|
||||
|
||||
final rejected = await WebSocket.connect('ws://127.0.0.1:$port/ws');
|
||||
try {
|
||||
await rejected.drain<void>().timeout(_ioTimeout);
|
||||
expect(rejected.readyState, WebSocket.closed);
|
||||
} finally {
|
||||
await rejected.close();
|
||||
}
|
||||
|
||||
clients.add(await _RawWebSocketClient.connect(port));
|
||||
await _expectWebSocketHandshakeStatus(port, HttpStatus.tooManyRequests);
|
||||
});
|
||||
|
||||
test('oversized and malformed first messages close generically and always restore capacity', () async {
|
||||
final host = CompanionRemotePeerService.forTesting(
|
||||
maxTotalHostConnections: 1,
|
||||
maxHostConnectionsPerSource: 1,
|
||||
maxPreAuthMessageBytes: 256,
|
||||
maxFailedAuthAttempts: 20,
|
||||
);
|
||||
final clients = <_RawWebSocketClient>[];
|
||||
final connected = <Object>[];
|
||||
final commands = <RemoteCommand>[];
|
||||
final connectedSubscription = host.onDeviceConnected.listen(connected.add);
|
||||
final commandSubscription = host.onCommandReceived.listen(commands.add);
|
||||
addTearDown(() async {
|
||||
await connectedSubscription.cancel();
|
||||
await commandSubscription.cancel();
|
||||
await _disposeHostAndClients(host, clients);
|
||||
});
|
||||
final port = await _startHost(host);
|
||||
|
||||
final cases = <({String name, dynamic payload, int closeCode})>[
|
||||
(name: 'oversized text', payload: 'x' * 257, closeCode: 4003),
|
||||
(name: 'oversized UTF-8 text', payload: 'é' * 129, closeCode: 4003),
|
||||
(name: 'binary input', payload: <int>[1, 2, 3], closeCode: 4003),
|
||||
(name: 'invalid JSON', payload: '{', closeCode: 4003),
|
||||
(name: 'non-map JSON', payload: '[]', closeCode: 4003),
|
||||
(name: 'invalid Base64', payload: _minimalAuth(clientNonce: '%%%'), closeCode: 4003),
|
||||
(name: 'wrong nonce length', payload: _minimalAuth(clientNonce: base64Encode([1, 2])), closeCode: 4003),
|
||||
(name: 'missing fields', payload: jsonEncode({'type': 'auth'}), closeCode: 4003),
|
||||
(
|
||||
name: 'wrong field type',
|
||||
payload: jsonEncode({
|
||||
'type': 'auth',
|
||||
'authTag': 1,
|
||||
'clientNonce': base64Encode(List<int>.filled(32, 1)),
|
||||
'userUUID': 'user-1',
|
||||
'clientIdentifier': 'remote-client',
|
||||
'deviceName': 'Remote',
|
||||
'platform': 'test',
|
||||
}),
|
||||
closeCode: 4003,
|
||||
),
|
||||
(name: 'unexpected type', payload: jsonEncode({'type': 'ping'}), closeCode: 4002),
|
||||
];
|
||||
|
||||
for (final malformedCase in cases) {
|
||||
final client = await _RawWebSocketClient.connect(port);
|
||||
clients.add(client);
|
||||
client.add(malformedCase.payload);
|
||||
await client.authFailed.timeout(_ioTimeout);
|
||||
expect(await client.closed.timeout(_ioTimeout), malformedCase.closeCode, reason: malformedCase.name);
|
||||
clients.remove(client);
|
||||
}
|
||||
|
||||
final capacityProbe = await _RawWebSocketClient.connect(port);
|
||||
clients.add(capacityProbe);
|
||||
expect(capacityProbe.isOpen, isTrue);
|
||||
expect(connected, isEmpty);
|
||||
expect(commands, isEmpty);
|
||||
});
|
||||
|
||||
test('malformed first message counts once, ignores a queued valid auth, and locks out before upgrade', () async {
|
||||
final host = CompanionRemotePeerService.forTesting(
|
||||
maxTotalHostConnections: 2,
|
||||
maxHostConnectionsPerSource: 2,
|
||||
maxFailedAuthAttempts: 2,
|
||||
);
|
||||
final clients = <_RawWebSocketClient>[];
|
||||
final connected = <Object>[];
|
||||
final subscription = host.onDeviceConnected.listen(connected.add);
|
||||
addTearDown(() async {
|
||||
await subscription.cancel();
|
||||
await _disposeHostAndClients(host, clients);
|
||||
});
|
||||
final port = await _startHost(host);
|
||||
|
||||
final first = await _RawWebSocketClient.connect(port);
|
||||
clients.add(first);
|
||||
final validAuth = _validAuthMessage(first.challenge, _authContext);
|
||||
first.add('{');
|
||||
first.add(validAuth);
|
||||
await first.authFailed.timeout(_ioTimeout);
|
||||
expect(await first.closed.timeout(_ioTimeout), 4003);
|
||||
clients.remove(first);
|
||||
|
||||
final second = await _RawWebSocketClient.connect(port);
|
||||
clients.add(second);
|
||||
second.add(<int>[9]);
|
||||
await second.authFailed.timeout(_ioTimeout);
|
||||
expect(await second.closed.timeout(_ioTimeout), 4003);
|
||||
clients.remove(second);
|
||||
|
||||
await _expectWebSocketHandshakeStatus(port, HttpStatus.tooManyRequests);
|
||||
expect(connected, isEmpty);
|
||||
});
|
||||
|
||||
test('unknown context, disallowed user, and bad HMAC share the terminal auth failure path', () async {
|
||||
final host = CompanionRemotePeerService.forTesting(maxFailedAuthAttempts: 10);
|
||||
final clients = <_RawWebSocketClient>[];
|
||||
addTearDown(() => _disposeHostAndClients(host, clients));
|
||||
final port = await _startHost(host);
|
||||
|
||||
final attempts = <String Function(_RawWebSocketClient)>[
|
||||
(client) => _validAuthMessage(client.challenge, _authContext, authContextId: 'missing-context'),
|
||||
(client) => _validAuthMessage(client.challenge, _authContext, userUuid: 'disallowed-user'),
|
||||
(client) => _validAuthMessage(client.challenge, _authContext, authTag: 'invalid-tag'),
|
||||
];
|
||||
|
||||
for (final message in attempts) {
|
||||
final client = await _RawWebSocketClient.connect(port);
|
||||
clients.add(client);
|
||||
client.add(message(client));
|
||||
await client.authFailed.timeout(_ioTimeout);
|
||||
expect(await client.closed.timeout(_ioTimeout), 4003);
|
||||
clients.remove(client);
|
||||
}
|
||||
});
|
||||
|
||||
test('single-context auth remains compatible without authContextId', () async {
|
||||
final host = CompanionRemotePeerService.forTesting();
|
||||
final clients = <_RawWebSocketClient>[];
|
||||
addTearDown(() => _disposeHostAndClients(host, clients));
|
||||
final port = await _startHost(host);
|
||||
|
||||
final client = await _RawWebSocketClient.connect(port);
|
||||
clients.add(client);
|
||||
final connected = host.onDeviceConnected.first;
|
||||
client.add(_validAuthMessage(client.challenge, _authContext, includeAuthContextId: false));
|
||||
|
||||
await connected.timeout(_ioTimeout);
|
||||
expect(host.isConnected, isTrue);
|
||||
expect(host.selectedAuthContextId, _authContext.id);
|
||||
});
|
||||
|
||||
test('closed authentication race cannot commit and replacement late close cannot clear the winner', () async {
|
||||
final secondDerivationEntered = Completer<void>();
|
||||
final releaseSecondDerivation = Completer<void>();
|
||||
final secondDerivationReturned = Completer<void>();
|
||||
var derivationCount = 0;
|
||||
final host = CompanionRemotePeerService.forTesting(
|
||||
deriveSessionEncKey: (homeSecret, hostNonce, clientNonce) async {
|
||||
derivationCount++;
|
||||
if (derivationCount == 2) {
|
||||
secondDerivationEntered.complete();
|
||||
await releaseSecondDerivation.future;
|
||||
}
|
||||
final key = await RemoteAuthService.instance.deriveSessionEncKey(homeSecret, hostNonce, clientNonce);
|
||||
if (derivationCount == 2 && !secondDerivationReturned.isCompleted) {
|
||||
secondDerivationReturned.complete();
|
||||
}
|
||||
return key;
|
||||
},
|
||||
);
|
||||
final clients = <_RawWebSocketClient>[];
|
||||
final connected = <Object>[];
|
||||
final subscription = host.onDeviceConnected.listen(connected.add);
|
||||
addTearDown(() async {
|
||||
if (!releaseSecondDerivation.isCompleted) releaseSecondDerivation.complete();
|
||||
await subscription.cancel();
|
||||
await _disposeHostAndClients(host, clients);
|
||||
});
|
||||
final port = await _startHost(host);
|
||||
|
||||
final original = await _RawWebSocketClient.connect(port);
|
||||
clients.add(original);
|
||||
final originalConnected = host.onDeviceConnected.first;
|
||||
original.add(_validAuthMessage(original.challenge, _authContext, deviceName: 'Original'));
|
||||
await originalConnected.timeout(_ioTimeout);
|
||||
expect(host.isConnected, isTrue);
|
||||
|
||||
final racing = await _RawWebSocketClient.connect(port);
|
||||
clients.add(racing);
|
||||
racing.add(_validAuthMessage(racing.challenge, _authContext, deviceName: 'Closed racer'));
|
||||
await secondDerivationEntered.future.timeout(_ioTimeout);
|
||||
await racing.dispose();
|
||||
clients.remove(racing);
|
||||
releaseSecondDerivation.complete();
|
||||
await secondDerivationReturned.future.timeout(_ioTimeout);
|
||||
await _flushEventQueue();
|
||||
|
||||
expect(host.isConnected, isTrue);
|
||||
expect(original.isOpen, isTrue);
|
||||
expect(connected, hasLength(1));
|
||||
|
||||
final replacement = await _RawWebSocketClient.connect(port);
|
||||
clients.add(replacement);
|
||||
final replacementConnected = host.onDeviceConnected.first;
|
||||
replacement.add(_validAuthMessage(replacement.challenge, _authContext, deviceName: 'Replacement'));
|
||||
await replacementConnected.timeout(_ioTimeout);
|
||||
expect(await original.closed.timeout(_ioTimeout), 4004);
|
||||
clients.remove(original);
|
||||
await _flushEventQueue();
|
||||
|
||||
expect(host.isConnected, isTrue);
|
||||
expect(connected, hasLength(2));
|
||||
});
|
||||
|
||||
test('address probes release every source slot before the managed connection', () async {
|
||||
final host = CompanionRemotePeerService.forTesting(maxTotalHostConnections: 2, maxHostConnectionsPerSource: 2);
|
||||
final remote = CompanionRemotePeerService();
|
||||
addTearDown(() async {
|
||||
await remote.dispose();
|
||||
await host.dispose();
|
||||
});
|
||||
final port = await _startHost(host);
|
||||
final address = '127.0.0.1:$port';
|
||||
|
||||
final winner = await remote.joinSessionRacingWithContexts(
|
||||
'Test Remote',
|
||||
'ios',
|
||||
[address, address],
|
||||
[_authContext],
|
||||
authContextId: _authContext.id,
|
||||
expectedHostClientId: _authContext.clientIdentifier,
|
||||
);
|
||||
|
||||
expect(winner, address);
|
||||
final command = host.onCommandReceived.firstWhere((event) => event.type == RemoteCommandType.play);
|
||||
remote.sendCommand(const RemoteCommand(type: RemoteCommandType.play));
|
||||
expect((await command.timeout(_ioTimeout)).type, RemoteCommandType.play);
|
||||
});
|
||||
|
||||
test('managed join waits for every probe terminal event and listener cleanup', () async {
|
||||
final closeRequested = List.generate(2, (_) => Completer<void>());
|
||||
final listenerCleanedUp = List.generate(2, (_) => Completer<void>());
|
||||
final controllers = List.generate(2, (_) => StreamController<dynamic>(sync: true));
|
||||
var nextProbe = 0;
|
||||
final managedJoinStarted = Completer<void>();
|
||||
late final _ManagedJoinObservingPeer remote;
|
||||
remote = _ManagedJoinObservingPeer(
|
||||
raceProbeFactory: (_) {
|
||||
final index = nextProbe++;
|
||||
return (
|
||||
close: () async {
|
||||
if (!closeRequested[index].isCompleted) {
|
||||
closeRequested[index].complete();
|
||||
}
|
||||
},
|
||||
ready: Future<void>.value(),
|
||||
stream: _CancelTrackingStream(controllers[index].stream, () {
|
||||
if (!listenerCleanedUp[index].isCompleted) {
|
||||
listenerCleanedUp[index].complete();
|
||||
}
|
||||
}),
|
||||
);
|
||||
},
|
||||
onManagedJoin: () async {
|
||||
expect(listenerCleanedUp, everyElement(predicate<Completer<void>>((item) => item.isCompleted)));
|
||||
managedJoinStarted.complete();
|
||||
},
|
||||
);
|
||||
addTearDown(() async {
|
||||
for (final controller in controllers) {
|
||||
if (!controller.isClosed) await controller.close();
|
||||
}
|
||||
await remote.dispose();
|
||||
});
|
||||
|
||||
final join = remote.joinSessionRacingWithContexts(
|
||||
'Test Remote',
|
||||
'ios',
|
||||
const ['probe-a', 'probe-b'],
|
||||
[_authContext],
|
||||
);
|
||||
expect(nextProbe, 2);
|
||||
|
||||
controllers.first.add(jsonEncode({'type': 'challenge'}));
|
||||
await closeRequested.first.future.timeout(_ioTimeout);
|
||||
expect(managedJoinStarted.isCompleted, isFalse);
|
||||
|
||||
await controllers.first.close();
|
||||
await closeRequested.last.future.timeout(_ioTimeout);
|
||||
expect(listenerCleanedUp.first.isCompleted, isTrue);
|
||||
expect(managedJoinStarted.isCompleted, isFalse);
|
||||
|
||||
await controllers.last.close();
|
||||
expect(await join.timeout(_ioTimeout), 'probe-a');
|
||||
expect(listenerCleanedUp, everyElement(predicate<Completer<void>>((item) => item.isCompleted)));
|
||||
expect(managedJoinStarted.isCompleted, isTrue);
|
||||
});
|
||||
|
||||
test('host and remote dispatch encrypted commands through the same contract', () async {
|
||||
final host = CompanionRemotePeerService();
|
||||
final remote = CompanionRemotePeerService();
|
||||
@@ -25,39 +454,320 @@ void main() {
|
||||
await host.dispose();
|
||||
});
|
||||
|
||||
final context = RemoteAuthContext(
|
||||
id: 'context-1',
|
||||
backend: 'plex',
|
||||
connectionId: 'connection-1',
|
||||
homeSecret: List<int>.generate(32, (index) => index),
|
||||
discoveryKey: List<int>.generate(32, (index) => 255 - index),
|
||||
clientIdentifier: 'host-client',
|
||||
userUuid: 'user-1',
|
||||
allowedUserUuids: const ['user-1'],
|
||||
);
|
||||
|
||||
final session = await host.createSessionForContexts('Test Host', 'macos', [context]);
|
||||
final session = await host.createSessionForContexts('Test Host', 'macos', [_authContext]);
|
||||
await remote.joinSessionWithContexts(
|
||||
'Test Remote',
|
||||
'ios',
|
||||
'127.0.0.1:${session.port}',
|
||||
[context],
|
||||
authContextId: context.id,
|
||||
expectedHostClientId: context.clientIdentifier,
|
||||
[_authContext],
|
||||
authContextId: _authContext.id,
|
||||
expectedHostClientId: _authContext.clientIdentifier,
|
||||
);
|
||||
|
||||
final hostCommand = host.onCommandReceived.firstWhere((command) => command.type == RemoteCommandType.play);
|
||||
remote.sendCommand(const RemoteCommand(type: RemoteCommandType.play, data: {'source': 'remote'}));
|
||||
expect(
|
||||
await hostCommand.timeout(const Duration(seconds: 5)),
|
||||
await hostCommand.timeout(_ioTimeout),
|
||||
const RemoteCommand(type: RemoteCommandType.play, data: {'source': 'remote'}),
|
||||
);
|
||||
|
||||
final remoteCommand = remote.onCommandReceived.firstWhere((command) => command.type == RemoteCommandType.pause);
|
||||
host.sendCommand(const RemoteCommand(type: RemoteCommandType.pause, data: {'source': 'host'}));
|
||||
expect(
|
||||
await remoteCommand.timeout(const Duration(seconds: 5)),
|
||||
await remoteCommand.timeout(_ioTimeout),
|
||||
const RemoteCommand(type: RemoteCommandType.pause, data: {'source': 'host'}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
typedef _TestRaceProbeConnection = ({Future<void> Function() close, Future<void> ready, Stream<dynamic> stream});
|
||||
|
||||
class _ManagedJoinObservingPeer extends CompanionRemotePeerService {
|
||||
_ManagedJoinObservingPeer({
|
||||
required _TestRaceProbeConnection Function(Uri uri) raceProbeFactory,
|
||||
required this._onManagedJoin,
|
||||
}) : super.forTesting(raceProbeFactory: raceProbeFactory);
|
||||
|
||||
final Future<void> Function() _onManagedJoin;
|
||||
|
||||
@override
|
||||
Future<void> joinSessionWithContexts(
|
||||
String deviceName,
|
||||
String platform,
|
||||
String hostAddress,
|
||||
List<RemoteAuthContext> authContexts, {
|
||||
String? authContextId,
|
||||
String expectedHostClientId = '',
|
||||
}) {
|
||||
return _onManagedJoin();
|
||||
}
|
||||
}
|
||||
|
||||
class _CancelTrackingStream<T> extends Stream<T> {
|
||||
_CancelTrackingStream(this._delegate, this._onCancel);
|
||||
|
||||
final Stream<T> _delegate;
|
||||
final void Function() _onCancel;
|
||||
|
||||
@override
|
||||
StreamSubscription<T> listen(
|
||||
void Function(T event)? onData, {
|
||||
Function? onError,
|
||||
void Function()? onDone,
|
||||
bool? cancelOnError,
|
||||
}) {
|
||||
return _CancelTrackingSubscription(
|
||||
_delegate.listen(onData, onError: onError, onDone: onDone, cancelOnError: cancelOnError),
|
||||
_onCancel,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CancelTrackingSubscription<T> implements StreamSubscription<T> {
|
||||
_CancelTrackingSubscription(this._delegate, this._onCancel);
|
||||
|
||||
final StreamSubscription<T> _delegate;
|
||||
final void Function() _onCancel;
|
||||
|
||||
@override
|
||||
Future<void> cancel() {
|
||||
_onCancel();
|
||||
return _delegate.cancel();
|
||||
}
|
||||
|
||||
@override
|
||||
void onData(void Function(T data)? handleData) => _delegate.onData(handleData);
|
||||
|
||||
@override
|
||||
void onError(Function? handleError) => _delegate.onError(handleError);
|
||||
|
||||
@override
|
||||
void onDone(void Function()? handleDone) => _delegate.onDone(handleDone);
|
||||
|
||||
@override
|
||||
void pause([Future<void>? resumeSignal]) => _delegate.pause(resumeSignal);
|
||||
|
||||
@override
|
||||
void resume() => _delegate.resume();
|
||||
|
||||
@override
|
||||
bool get isPaused => _delegate.isPaused;
|
||||
|
||||
@override
|
||||
Future<E> asFuture<E>([E? futureValue]) => _delegate.asFuture(futureValue);
|
||||
}
|
||||
|
||||
final _authContext = RemoteAuthContext(
|
||||
id: 'context-1',
|
||||
backend: 'plex',
|
||||
connectionId: 'connection-1',
|
||||
homeSecret: List<int>.generate(32, (index) => index),
|
||||
discoveryKey: List<int>.generate(32, (index) => 255 - index),
|
||||
clientIdentifier: 'host-client',
|
||||
userUuid: 'user-1',
|
||||
allowedUserUuids: const ['user-1'],
|
||||
);
|
||||
|
||||
Future<int> _startHost(CompanionRemotePeerService host) async {
|
||||
final session = await host.createSessionForContexts('Test Host', 'macos', [_authContext]);
|
||||
return session.port;
|
||||
}
|
||||
|
||||
Future<void> _disposeHostAndClients(CompanionRemotePeerService host, List<_RawWebSocketClient> clients) async {
|
||||
for (final client in List<_RawWebSocketClient>.of(clients)) {
|
||||
await client.dispose();
|
||||
}
|
||||
clients.clear();
|
||||
await host.dispose();
|
||||
}
|
||||
|
||||
Future<void> _expectWebSocketHandshakeStatus(int port, int statusCode) async {
|
||||
await expectLater(
|
||||
WebSocket.connect('ws://127.0.0.1:$port/ws'),
|
||||
throwsA(
|
||||
isA<WebSocketException>().having((error) => error.toString(), 'message', contains('status code: $statusCode')),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _minimalAuth({required String clientNonce}) {
|
||||
return jsonEncode({
|
||||
'type': 'auth',
|
||||
'authTag': 'tag',
|
||||
'clientNonce': clientNonce,
|
||||
'userUUID': 'user-1',
|
||||
'clientIdentifier': 'remote-client',
|
||||
'deviceName': 'Remote',
|
||||
'platform': 'test',
|
||||
});
|
||||
}
|
||||
|
||||
String _validAuthMessage(
|
||||
Map<String, dynamic> challenge,
|
||||
RemoteAuthContext context, {
|
||||
String? authContextId,
|
||||
String? userUuid,
|
||||
String? authTag,
|
||||
String deviceName = 'Raw Remote',
|
||||
bool includeAuthContextId = true,
|
||||
}) {
|
||||
final hostNonce = base64Decode(challenge['nonce'] as String);
|
||||
final clientNonce = RemoteAuthService.instance.generateNonce();
|
||||
final selectedUserUuid = userUuid ?? context.userUuid;
|
||||
final computedAuthTag = RemoteAuthService.instance.computeAuthTag(
|
||||
homeSecret: context.homeSecret,
|
||||
hostNonce: hostNonce,
|
||||
clientNonce: clientNonce,
|
||||
hostClientId: context.clientIdentifier,
|
||||
userUUID: selectedUserUuid,
|
||||
clientIdentifier: 'raw-remote-client',
|
||||
deviceName: deviceName,
|
||||
platform: 'test',
|
||||
);
|
||||
return jsonEncode({
|
||||
'type': 'auth',
|
||||
if (includeAuthContextId) 'authContextId': authContextId ?? context.id,
|
||||
'clientNonce': base64Encode(clientNonce),
|
||||
'userUUID': selectedUserUuid,
|
||||
'clientIdentifier': 'raw-remote-client',
|
||||
'deviceName': deviceName,
|
||||
'platform': 'test',
|
||||
'authTag': authTag ?? computedAuthTag,
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _flushEventQueue() async {
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
}
|
||||
|
||||
class _RawWebSocketClient {
|
||||
_RawWebSocketClient._(this.socket) {
|
||||
_subscription = socket.listen(
|
||||
(data) {
|
||||
if (data is! String) return;
|
||||
try {
|
||||
final decoded = jsonDecode(data);
|
||||
if (decoded is! Map<String, dynamic>) return;
|
||||
if (decoded['type'] == 'challenge' && !_challenge.isCompleted) {
|
||||
_challengeValue = decoded;
|
||||
_challenge.complete(decoded);
|
||||
} else if (decoded['type'] == 'authFailed' && !_authFailed.isCompleted) {
|
||||
_authFailed.complete();
|
||||
}
|
||||
} catch (_) {
|
||||
// Encrypted and malformed server messages are not part of this helper.
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
if (!_closed.isCompleted) _closed.complete(socket.closeCode);
|
||||
},
|
||||
onError: (Object error, StackTrace stackTrace) {
|
||||
if (!_closed.isCompleted) _closed.completeError(error, stackTrace);
|
||||
},
|
||||
cancelOnError: true,
|
||||
);
|
||||
}
|
||||
|
||||
static Future<_RawWebSocketClient> connect(int port) async {
|
||||
final socket = await WebSocket.connect('ws://127.0.0.1:$port/ws').timeout(_ioTimeout);
|
||||
final client = _RawWebSocketClient._(socket);
|
||||
try {
|
||||
await client._challenge.future.timeout(_ioTimeout);
|
||||
return client;
|
||||
} catch (_) {
|
||||
await client.dispose();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
final WebSocket socket;
|
||||
final Completer<Map<String, dynamic>> _challenge = Completer<Map<String, dynamic>>();
|
||||
final Completer<void> _authFailed = Completer<void>();
|
||||
final Completer<int?> _closed = Completer<int?>();
|
||||
late final StreamSubscription<dynamic> _subscription;
|
||||
late Map<String, dynamic> _challengeValue;
|
||||
|
||||
Map<String, dynamic> get challenge => _challengeValue;
|
||||
Future<void> get authFailed => _authFailed.future;
|
||||
Future<int?> get closed => _closed.future;
|
||||
bool get isOpen => socket.readyState == WebSocket.open;
|
||||
|
||||
void add(dynamic data) => socket.add(data);
|
||||
|
||||
Future<void> dispose() async {
|
||||
if (!_closed.isCompleted) {
|
||||
try {
|
||||
await socket.close(WebSocketStatus.normalClosure);
|
||||
} catch (_) {
|
||||
// The peer may already have closed the transport.
|
||||
}
|
||||
}
|
||||
if (!_closed.isCompleted) {
|
||||
await _closed.future.timeout(_ioTimeout);
|
||||
}
|
||||
await _subscription.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
class _RawUpgradedSocket {
|
||||
_RawUpgradedSocket._(this.socket, this._subscription, this._done);
|
||||
|
||||
static Future<_RawUpgradedSocket> connect(int port) async {
|
||||
final socket = await Socket.connect(InternetAddress.loopbackIPv4, port).timeout(_ioTimeout);
|
||||
final responseHeaders = StringBuffer();
|
||||
final upgraded = Completer<void>();
|
||||
final done = Completer<void>();
|
||||
late final StreamSubscription<List<int>> subscription;
|
||||
subscription = socket.listen(
|
||||
(bytes) {
|
||||
responseHeaders.write(latin1.decode(bytes));
|
||||
if (!upgraded.isCompleted && responseHeaders.toString().contains('\r\n\r\n')) {
|
||||
upgraded.complete();
|
||||
}
|
||||
},
|
||||
onError: (Object error, StackTrace stackTrace) {
|
||||
if (!upgraded.isCompleted) upgraded.completeError(error, stackTrace);
|
||||
if (!done.isCompleted) done.complete();
|
||||
},
|
||||
onDone: () {
|
||||
if (!upgraded.isCompleted) {
|
||||
upgraded.completeError(StateError('Socket closed before WebSocket upgrade'));
|
||||
}
|
||||
if (!done.isCompleted) done.complete();
|
||||
},
|
||||
);
|
||||
try {
|
||||
final key = base64Encode(List<int>.generate(16, (index) => index));
|
||||
socket.write(
|
||||
'GET /ws HTTP/1.1\r\n'
|
||||
'Host: 127.0.0.1:$port\r\n'
|
||||
'Connection: Upgrade\r\n'
|
||||
'Upgrade: websocket\r\n'
|
||||
'Sec-WebSocket-Version: 13\r\n'
|
||||
'Sec-WebSocket-Key: $key\r\n'
|
||||
'\r\n',
|
||||
);
|
||||
await socket.flush();
|
||||
await upgraded.future.timeout(_ioTimeout);
|
||||
expect(responseHeaders.toString(), startsWith('HTTP/1.1 101'));
|
||||
return _RawUpgradedSocket._(socket, subscription, done);
|
||||
} catch (_) {
|
||||
socket.destroy();
|
||||
await subscription.cancel();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
final Socket socket;
|
||||
final StreamSubscription<List<int>> _subscription;
|
||||
final Completer<void> _done;
|
||||
|
||||
Future<void> dispose() async {
|
||||
if (!_done.isCompleted) {
|
||||
socket.destroy();
|
||||
await _done.future.timeout(_ioTimeout);
|
||||
}
|
||||
await _subscription.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@ void main() {
|
||||
await prefs.setString('custom_relay_url', 'https://${canaries[2]}.invalid');
|
||||
await prefs.setString('update_last_check_time', canaries[3]);
|
||||
await prefs.setString('watch_together_recent_rooms', canaries[1]);
|
||||
await prefs.setString('user_alice_watch_together_recent_rooms', canaries[1]);
|
||||
await prefs.setString('future_runtime_key', 'unknown');
|
||||
|
||||
final out = SettingsExportService.buildExportMap(prefs, currentUserUuid: 'alice');
|
||||
@@ -105,6 +106,7 @@ void main() {
|
||||
expect(exported.keys, isNot(contains('custom_relay_url')));
|
||||
expect(exported.keys, isNot(contains('update_last_check_time')));
|
||||
expect(exported.keys, isNot(contains('watch_together_recent_rooms')));
|
||||
expect(exported.keys, isNot(contains('user_alice_watch_together_recent_rooms')));
|
||||
expect(exported.keys, isNot(contains('future_runtime_key')));
|
||||
for (final canary in canaries) {
|
||||
expect(encoded, isNot(contains(canary)));
|
||||
@@ -191,6 +193,8 @@ void main() {
|
||||
'custom_download_path': {'type': 'string', 'value': '/source/device/downloads'},
|
||||
'custom_download_path_type': {'type': 'string', 'value': 'saf'},
|
||||
'crash_reporting': {'type': 'bool', 'value': true},
|
||||
'custom_relay_url': {'type': 'string', 'value': 'https://relay.example.test'},
|
||||
'watch_together_recent_rooms': {'type': 'string', 'value': '[]'},
|
||||
'unknown_future_key': {'type': 'bool', 'value': true},
|
||||
},
|
||||
},
|
||||
@@ -199,7 +203,7 @@ void main() {
|
||||
);
|
||||
|
||||
expect(result.keysImported, 4);
|
||||
expect(result.keysSkipped, 6);
|
||||
expect(result.keysSkipped, 8);
|
||||
expect(prefs.getBool('enable_hardware_decoding'), isTrue);
|
||||
expect(prefs.getDouble('default_playback_speed'), 1.0);
|
||||
expect(prefs.getStringList('user_alice_library_order'), ['movies']);
|
||||
@@ -210,6 +214,8 @@ void main() {
|
||||
expect(prefs.getString('custom_download_path'), '/target/device/downloads');
|
||||
expect(prefs.getString('custom_download_path_type'), 'file');
|
||||
expect(prefs.getBool('crash_reporting'), isFalse);
|
||||
expect(prefs.getString('custom_relay_url'), isNull);
|
||||
expect(prefs.getString('user_alice_watch_together_recent_rooms'), isNull);
|
||||
expect(prefs.getBool('unknown_future_key'), isNull);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/models/audio_quality_preset.dart';
|
||||
@@ -226,6 +228,40 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('SettingsService Watch Together relay', () {
|
||||
test('typed writes canonicalize valid bases and reject invalid replacement', () async {
|
||||
final settings = await SettingsService.getInstance();
|
||||
|
||||
await settings.write(SettingsService.customRelayUrl, ' HTTPS://Relay.Example.Test/path/// ');
|
||||
expect(settings.read(SettingsService.customRelayUrl), 'https://relay.example.test/path');
|
||||
|
||||
await expectLater(
|
||||
settings.write(SettingsService.customRelayUrl, 'ws://relay.example.test'),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(settings.read(SettingsService.customRelayUrl), 'https://relay.example.test/path');
|
||||
|
||||
await settings.write(SettingsService.customRelayUrl, ' ');
|
||||
expect(settings.read(SettingsService.customRelayUrl), isNull);
|
||||
});
|
||||
|
||||
test('startup canonicalizes valid history and removes invalid history', () async {
|
||||
resetSharedPreferencesForTest(
|
||||
initialAsync: {SettingsService.customRelayUrl.key: ' http://Relay.Example.Test:8080/prefix// '},
|
||||
);
|
||||
SettingsService.resetForTesting();
|
||||
var settings = await SettingsService.getInstance();
|
||||
expect(settings.read(SettingsService.customRelayUrl), 'http://relay.example.test:8080/prefix');
|
||||
|
||||
resetSharedPreferencesForTest(
|
||||
initialAsync: {SettingsService.customRelayUrl.key: 'https://relay.example.test/path?wrong=route'},
|
||||
);
|
||||
SettingsService.resetForTesting();
|
||||
settings = await SettingsService.getInstance();
|
||||
expect(settings.read(SettingsService.customRelayUrl), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('SettingsService listenables', () {
|
||||
test('refreshListenables updates active prefs outside the resettable surface', () async {
|
||||
final settings = await SettingsService.getInstance();
|
||||
@@ -276,4 +312,48 @@ void main() {
|
||||
expect(settings.isLibraryAllowedForTracker(TrackerService.trakt, 'server:allowed'), isTrue);
|
||||
});
|
||||
});
|
||||
group('BaseSharedPreferencesService initialization generations', () {
|
||||
test('a reset-raced initialization resolves to the replacement backend', () async {
|
||||
resetSharedPreferencesForTest(initialAsync: const {'generation_marker': 'old'});
|
||||
final firstOnInit = Completer<void>();
|
||||
final firstStarted = Completer<void>();
|
||||
var constructions = 0;
|
||||
|
||||
final raced = BaseSharedPreferencesService.initializeInstance<_GenerationTestPreferences>(() {
|
||||
final construction = constructions++;
|
||||
return _GenerationTestPreferences(
|
||||
construction,
|
||||
onInitStarted: construction == 0 ? firstStarted : null,
|
||||
onInitGate: construction == 0 ? firstOnInit : null,
|
||||
);
|
||||
});
|
||||
await firstStarted.future;
|
||||
|
||||
resetSharedPreferencesForTest(initialAsync: const {'generation_marker': 'new'});
|
||||
final replacement = await BaseSharedPreferencesService.initializeInstance<_GenerationTestPreferences>(
|
||||
() => _GenerationTestPreferences(constructions++),
|
||||
);
|
||||
firstOnInit.complete();
|
||||
final recovered = await raced;
|
||||
|
||||
expect(recovered, same(replacement));
|
||||
expect(recovered.construction, 1);
|
||||
expect(recovered.readString('generation_marker'), 'new');
|
||||
expect(constructions, 2);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class _GenerationTestPreferences extends BaseSharedPreferencesService {
|
||||
_GenerationTestPreferences(this.construction, {this.onInitStarted, this.onInitGate});
|
||||
|
||||
final int construction;
|
||||
final Completer<void>? onInitStarted;
|
||||
final Completer<void>? onInitGate;
|
||||
|
||||
@override
|
||||
Future<void> onInit() async {
|
||||
onInitStarted?.complete();
|
||||
await onInitGate?.future;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:async';
|
||||
import 'package:plezy/mpv/mpv.dart';
|
||||
import 'package:plezy/watch_together/models/sync_message.dart';
|
||||
import 'package:plezy/watch_together/services/watch_together_peer_service.dart';
|
||||
import 'package:plezy/watch_together/services/watch_together_relay_endpoint.dart';
|
||||
|
||||
/// Rich fake [Player] for Watch Together sync tests.
|
||||
///
|
||||
@@ -196,6 +197,9 @@ class FakeRelayHub {
|
||||
final Map<String, HubPeerService> _peers = {};
|
||||
|
||||
HubPeerService register(String peerId) {
|
||||
if (_peers.containsKey(peerId)) {
|
||||
throw StateError('Peer ID is already registered: $peerId');
|
||||
}
|
||||
final service = HubPeerService._(peerId, this);
|
||||
for (final existing in _peers.values) {
|
||||
existing._peerConnected.add(peerId);
|
||||
@@ -235,7 +239,7 @@ class FakeRelayHub {
|
||||
}
|
||||
|
||||
class HubPeerService extends WatchTogetherPeerService {
|
||||
HubPeerService._(this.peerId, this._hub) : super(customBaseUrl: 'http://localhost');
|
||||
HubPeerService._(this.peerId, this._hub) : super(endpoint: WatchTogetherRelayEndpoint.resolve('http://localhost'));
|
||||
|
||||
final String peerId;
|
||||
final FakeRelayHub _hub;
|
||||
|
||||
@@ -16,9 +16,11 @@ class _Harness {
|
||||
FakeAsync async, {
|
||||
ControlMode controlMode = ControlMode.hostOnly,
|
||||
HostCoordinatorCallbacks callbacks = const HostCoordinatorCallbacks(),
|
||||
Duration duration = const Duration(minutes: 45),
|
||||
bool seekable = true,
|
||||
}) {
|
||||
int nowMs() => _epochMs + async.elapsed.inMilliseconds;
|
||||
player = FakeSyncPlayer(position: const Duration(minutes: 2));
|
||||
player = FakeSyncPlayer(position: const Duration(minutes: 2), duration: duration, seekable: seekable);
|
||||
coordinator = HostPlaybackCoordinator(
|
||||
myPeerId: 'host',
|
||||
controlMode: controlMode,
|
||||
@@ -446,6 +448,132 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
test('invalid remote seeks and rates are rejected without side effects', () {
|
||||
fakeAsync((async) {
|
||||
final actions = <(String, PlaybackActionHint)>[];
|
||||
final h = _Harness(
|
||||
async,
|
||||
controlMode: ControlMode.anyone,
|
||||
callbacks: HostCoordinatorCallbacks(onRemoteAction: (peer, hint) => actions.add((peer, hint))),
|
||||
);
|
||||
h.attachForMedia(async);
|
||||
h.hostBecomesReady(async);
|
||||
final durationMs = h.player.state.duration.inMilliseconds;
|
||||
final broadcastsBefore = h.broadcasts.length;
|
||||
final seqBefore = h.last.seq;
|
||||
final anchorBefore = h.last.anchorPositionMs;
|
||||
final rateBefore = h.last.rate;
|
||||
h.player.commandLog.clear();
|
||||
|
||||
for (final targetMs in [-1, durationMs + 1]) {
|
||||
h.coordinator.onControlRequest('guest', ControlRequest(kind: ControlRequestKind.seek, positionMs: targetMs));
|
||||
}
|
||||
for (final rate in [0.25 - 0.000001, 4.0 + 0.000001, double.nan, double.infinity, double.negativeInfinity]) {
|
||||
h.coordinator.onControlRequest('guest', ControlRequest(kind: ControlRequestKind.rate, rate: rate));
|
||||
}
|
||||
async.flushMicrotasks();
|
||||
|
||||
expect(h.player.commandLog, isEmpty);
|
||||
expect(actions, isEmpty);
|
||||
expect(h.broadcasts, hasLength(broadcastsBefore));
|
||||
expect(h.last.seq, seqBefore);
|
||||
expect(h.last.anchorPositionMs, anchorBefore);
|
||||
expect(h.last.rate, rateBefore);
|
||||
h.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test('inclusive remote seek and rate boundaries are applied exactly', () {
|
||||
fakeAsync((async) {
|
||||
final actions = <(String, PlaybackActionHint)>[];
|
||||
final h = _Harness(
|
||||
async,
|
||||
controlMode: ControlMode.anyone,
|
||||
callbacks: HostCoordinatorCallbacks(onRemoteAction: (peer, hint) => actions.add((peer, hint))),
|
||||
);
|
||||
h.attachForMedia(async);
|
||||
h.hostBecomesReady(async);
|
||||
final durationMs = h.player.state.duration.inMilliseconds;
|
||||
final seqBefore = h.last.seq;
|
||||
h.player.commandLog.clear();
|
||||
|
||||
for (final targetMs in [0, durationMs]) {
|
||||
h.coordinator.onControlRequest('guest', ControlRequest(kind: ControlRequestKind.seek, positionMs: targetMs));
|
||||
async.flushMicrotasks();
|
||||
expect(h.last.anchorPositionMs, targetMs);
|
||||
expect(h.last.actorPeerId, 'guest');
|
||||
expect(h.last.actionHint, PlaybackActionHint.seek);
|
||||
}
|
||||
for (final rate in [0.25, 4.0]) {
|
||||
h.coordinator.onControlRequest('guest', ControlRequest(kind: ControlRequestKind.rate, rate: rate));
|
||||
async.flushMicrotasks();
|
||||
expect(h.last.rate, rate);
|
||||
expect(h.last.actorPeerId, 'guest');
|
||||
expect(h.last.actionHint, PlaybackActionHint.rate);
|
||||
}
|
||||
|
||||
expect(h.player.commandLog, ['seek:0', 'seek:$durationMs', 'rate:0.25', 'rate:4.0']);
|
||||
expect(h.last.seq, seqBefore + 4);
|
||||
expect(actions, [
|
||||
('guest', PlaybackActionHint.seek),
|
||||
('guest', PlaybackActionHint.seek),
|
||||
('guest', PlaybackActionHint.rate),
|
||||
('guest', PlaybackActionHint.rate),
|
||||
]);
|
||||
h.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test('remote seeks require seekability and a positive known duration', () {
|
||||
fakeAsync((async) {
|
||||
for (final config in [
|
||||
(seekable: false, duration: const Duration(minutes: 45)),
|
||||
(seekable: true, duration: Duration.zero),
|
||||
]) {
|
||||
final actions = <(String, PlaybackActionHint)>[];
|
||||
final h = _Harness(
|
||||
async,
|
||||
controlMode: ControlMode.anyone,
|
||||
duration: config.duration,
|
||||
seekable: config.seekable,
|
||||
callbacks: HostCoordinatorCallbacks(onRemoteAction: (peer, hint) => actions.add((peer, hint))),
|
||||
);
|
||||
h.attachForMedia(async);
|
||||
h.hostBecomesReady(async);
|
||||
async.elapse(Duration(milliseconds: h.last.anchorHostTimeMs - (_epochMs + async.elapsed.inMilliseconds)));
|
||||
h.player.commandLog.clear();
|
||||
actions.clear();
|
||||
final broadcastsBefore = h.broadcasts.length;
|
||||
final seqBefore = h.last.seq;
|
||||
|
||||
h.coordinator.onControlRequest(
|
||||
'guest',
|
||||
const ControlRequest(kind: ControlRequestKind.seek, positionMs: 1000),
|
||||
);
|
||||
async.flushMicrotasks();
|
||||
|
||||
expect(h.player.commandLog, isEmpty);
|
||||
expect(actions, isEmpty);
|
||||
expect(h.broadcasts, hasLength(broadcastsBefore));
|
||||
expect(h.last.seq, seqBefore);
|
||||
|
||||
h.coordinator.onControlRequest('guest', const ControlRequest(kind: ControlRequestKind.rate, rate: 0.25));
|
||||
h.coordinator.onControlRequest('guest', const ControlRequest(kind: ControlRequestKind.pause));
|
||||
h.coordinator.onControlRequest('guest', const ControlRequest(kind: ControlRequestKind.play));
|
||||
async.flushMicrotasks();
|
||||
async.elapse(Duration(milliseconds: h.last.anchorHostTimeMs - (_epochMs + async.elapsed.inMilliseconds)));
|
||||
|
||||
expect(h.player.commandLog, ['rate:0.25', 'pause', 'play']);
|
||||
expect(actions, [
|
||||
('guest', PlaybackActionHint.rate),
|
||||
('guest', PlaybackActionHint.pause),
|
||||
('guest', PlaybackActionHint.play),
|
||||
]);
|
||||
h.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('local seeks debounce into a single re-anchor broadcast', () {
|
||||
fakeAsync((async) {
|
||||
final h = _Harness(async);
|
||||
|
||||
@@ -112,10 +112,11 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('SyncMessage v2 envelope', () {
|
||||
test('join carries the protocol version', () {
|
||||
group('SyncMessage v3 envelope', () {
|
||||
test('join carries sync protocol 3', () {
|
||||
final join = SyncMessage.join(peerId: 'p', displayName: 'Name', isHost: false);
|
||||
final decoded = SyncMessage.fromJson(join.toJson());
|
||||
expect(decoded.version, 3);
|
||||
expect(decoded.version, SyncMessage.protocolVersion);
|
||||
});
|
||||
|
||||
@@ -125,7 +126,7 @@ void main() {
|
||||
expect(decoded.peerId, 'p');
|
||||
});
|
||||
|
||||
test('copyWith preserves v2 payloads', () {
|
||||
test('copyWith preserves v3 payloads', () {
|
||||
final relabeled = SyncMessage.state(fullState).copyWith(peerId: 'relay-id');
|
||||
expect(relabeled.state, fullState);
|
||||
expect(relabeled.peerId, 'relay-id');
|
||||
|
||||
@@ -2,12 +2,6 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/watch_together/primitives.dart';
|
||||
|
||||
void main() {
|
||||
test('stored room codes preserve the established host peer wire format', () {
|
||||
const persistedSessionId = 'Ab12z';
|
||||
|
||||
expect(watchTogetherHostPeerId(persistedSessionId), 'wt-AB12Z');
|
||||
});
|
||||
|
||||
test('orderedStringListsEqual preserves order and multiplicity', () {
|
||||
expect(orderedStringListsEqual(const ['a', 'b'], const ['a', 'b']), isTrue);
|
||||
expect(orderedStringListsEqual(const ['a'], const ['a', 'b']), isFalse);
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/services/settings_service.dart';
|
||||
import 'package:plezy/watch_together/models/watch_session.dart';
|
||||
import 'package:plezy/watch_together/services/recent_rooms_service.dart';
|
||||
import 'package:plezy/watch_together/services/watch_together_relay_endpoint.dart';
|
||||
|
||||
import '../test_helpers/prefs.dart';
|
||||
|
||||
void main() {
|
||||
late SettingsService settings;
|
||||
final endpointA = WatchTogetherRelayEndpoint.resolve('https://relay-a.example.test/base/');
|
||||
final endpointB = WatchTogetherRelayEndpoint.resolve('http://relay-b.example.test:8080');
|
||||
|
||||
setUp(() async {
|
||||
resetSharedPreferencesForTest();
|
||||
SettingsService.resetForTesting();
|
||||
settings = await SettingsService.getInstance();
|
||||
});
|
||||
|
||||
test('rooms are isolated by profile', () async {
|
||||
await RecentRoomsService.addOrUpdateRoom(
|
||||
'ROOM1',
|
||||
profileId: 'profile-a',
|
||||
endpoint: endpointA,
|
||||
name: 'Profile A room',
|
||||
controlMode: ControlMode.hostOnly,
|
||||
);
|
||||
|
||||
expect(RecentRoomsService.getRecentRooms(profileId: 'profile-b', endpoint: endpointA), isEmpty);
|
||||
|
||||
await RecentRoomsService.addOrUpdateRoom(
|
||||
'ROOM1',
|
||||
profileId: 'profile-b',
|
||||
endpoint: endpointA,
|
||||
name: 'Profile B room',
|
||||
controlMode: ControlMode.anyone,
|
||||
);
|
||||
|
||||
final profileA = RecentRoomsService.getRecentRooms(profileId: 'profile-a', endpoint: endpointA);
|
||||
final profileB = RecentRoomsService.getRecentRooms(profileId: 'profile-b', endpoint: endpointA);
|
||||
expect(profileA.single.name, 'Profile A room');
|
||||
expect(profileA.single.controlMode, ControlMode.hostOnly);
|
||||
expect(profileB.single.name, 'Profile B room');
|
||||
expect(profileB.single.controlMode, ControlMode.anyone);
|
||||
});
|
||||
|
||||
test('same code is independently mutable on different relay bases', () async {
|
||||
await RecentRoomsService.addOrUpdateRoom(
|
||||
'SAME1',
|
||||
profileId: 'profile-a',
|
||||
endpoint: endpointA,
|
||||
name: 'Relay A',
|
||||
controlMode: ControlMode.hostOnly,
|
||||
);
|
||||
await RecentRoomsService.addOrUpdateRoom(
|
||||
'SAME1',
|
||||
profileId: 'profile-a',
|
||||
endpoint: endpointB,
|
||||
name: 'Relay B',
|
||||
controlMode: ControlMode.anyone,
|
||||
);
|
||||
|
||||
await RecentRoomsService.renameRoom('SAME1', 'Renamed A', profileId: 'profile-a', endpoint: endpointA);
|
||||
expect(RecentRoomsService.getRecentRooms(profileId: 'profile-a', endpoint: endpointA).single.name, 'Renamed A');
|
||||
expect(RecentRoomsService.getRecentRooms(profileId: 'profile-a', endpoint: endpointB).single.name, 'Relay B');
|
||||
|
||||
await RecentRoomsService.removeRoom('SAME1', profileId: 'profile-a', endpoint: endpointA);
|
||||
expect(RecentRoomsService.getRecentRooms(profileId: 'profile-a', endpoint: endpointA), isEmpty);
|
||||
expect(RecentRoomsService.getRecentRooms(profileId: 'profile-a', endpoint: endpointB).single.code, 'SAME1');
|
||||
});
|
||||
|
||||
test('profile history remains bounded across relay scopes and deduplicates tuples', () async {
|
||||
for (var index = 0; index < 21; index++) {
|
||||
await RecentRoomsService.addOrUpdateRoom(
|
||||
'R${index.toString().padLeft(4, '0')}',
|
||||
profileId: 'profile-a',
|
||||
endpoint: index.isEven ? endpointA : endpointB,
|
||||
);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 2));
|
||||
}
|
||||
|
||||
final raw = settings.read(SettingsService.recentRoomsForProfile('profile-a'));
|
||||
final rows = jsonDecode(raw!) as List<dynamic>;
|
||||
expect(rows, hasLength(20));
|
||||
expect(rows.map((row) => (row as Map<String, dynamic>)['code']), isNot(contains('R0000')));
|
||||
|
||||
await RecentRoomsService.addOrUpdateRoom('R0020', profileId: 'profile-a', endpoint: endpointA, name: 'Updated');
|
||||
final updatedRows = jsonDecode(settings.read(SettingsService.recentRoomsForProfile('profile-a'))!) as List<dynamic>;
|
||||
expect(updatedRows, hasLength(20));
|
||||
expect(
|
||||
updatedRows.where(
|
||||
(row) =>
|
||||
(row as Map<String, dynamic>)['code'] == 'R0020' &&
|
||||
row['relayScope'] == RecentRoomsService.relayScopeFor(endpointA),
|
||||
),
|
||||
hasLength(1),
|
||||
);
|
||||
});
|
||||
|
||||
test('startup drops unattributable legacy history', () async {
|
||||
SettingsService.resetForTesting();
|
||||
resetSharedPreferencesForTest(
|
||||
initialAsync: {
|
||||
'watch_together_recent_rooms': jsonEncode([
|
||||
{'code': 'OLD01', 'lastUsed': 1},
|
||||
]),
|
||||
},
|
||||
);
|
||||
|
||||
final initialized = await SettingsService.getInstance();
|
||||
expect(initialized.prefs.containsKey('watch_together_recent_rooms'), isFalse);
|
||||
expect(RecentRoomsService.getRecentRooms(profileId: 'profile-a', endpoint: endpointA), isEmpty);
|
||||
});
|
||||
}
|
||||
@@ -209,6 +209,74 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
test('anyone-mode: invalid controls are rejected and the queue continues', () {
|
||||
fakeAsync((async) {
|
||||
final room = _Room(async, controlMode: ControlMode.anyone);
|
||||
final actions = <(String, PlaybackActionHint)>[];
|
||||
room.host.onRemoteAction = (peer, hint) => actions.add((peer, hint));
|
||||
room.hostStartsMedia();
|
||||
room.guestJoinsMedia();
|
||||
room.bothBecomeReady();
|
||||
final delay = room.lastHostState().anchorHostTimeMs - room.nowMs();
|
||||
async.elapse(Duration(milliseconds: delay + 100));
|
||||
room.hostPlayer.commandLog.clear();
|
||||
final statesBefore = room.hostService.outgoingLog
|
||||
.where((message) => message.type == SyncMessageType.state)
|
||||
.length;
|
||||
final stateBefore = room.lastHostState();
|
||||
|
||||
SyncMessage wireControl(ControlRequest request) {
|
||||
return SyncMessage.fromJson(SyncMessage.control(request, peerId: 'forged-peer').toJson());
|
||||
}
|
||||
|
||||
room.guestService.sendTo(
|
||||
'host',
|
||||
wireControl(
|
||||
ControlRequest(kind: ControlRequestKind.seek, positionMs: room.hostPlayer.state.duration.inMilliseconds + 1),
|
||||
),
|
||||
);
|
||||
room.guestService.sendTo(
|
||||
'host',
|
||||
wireControl(const ControlRequest(kind: ControlRequestKind.rate, rate: 4.000001)),
|
||||
);
|
||||
async.flushMicrotasks();
|
||||
|
||||
expect(room.hostPlayer.commandLog, isEmpty);
|
||||
expect(actions, isEmpty);
|
||||
expect(
|
||||
room.hostService.outgoingLog.where((message) => message.type == SyncMessageType.state),
|
||||
hasLength(statesBefore),
|
||||
);
|
||||
expect(room.lastHostState().seq, stateBefore.seq);
|
||||
expect(room.lastHostState().anchorPositionMs, stateBefore.anchorPositionMs);
|
||||
expect(room.lastHostState().rate, stateBefore.rate);
|
||||
|
||||
room.guestService.sendTo(
|
||||
'host',
|
||||
wireControl(const ControlRequest(kind: ControlRequestKind.seek, positionMs: 600000)),
|
||||
);
|
||||
async.flushMicrotasks();
|
||||
room.guestService.sendTo('host', wireControl(const ControlRequest(kind: ControlRequestKind.rate, rate: 0.25)));
|
||||
async.flushMicrotasks();
|
||||
|
||||
expect(room.hostPlayer.commandLog, ['seek:600000', 'rate:0.25']);
|
||||
expect(actions, [('guest', PlaybackActionHint.seek), ('guest', PlaybackActionHint.rate)]);
|
||||
final acceptedStates = room.hostService.outgoingLog
|
||||
.where((message) => message.type == SyncMessageType.state)
|
||||
.skip(statesBefore)
|
||||
.map((message) => message.state!)
|
||||
.toList();
|
||||
expect(acceptedStates, hasLength(2));
|
||||
expect(acceptedStates[0].anchorPositionMs, 600000);
|
||||
expect(acceptedStates[0].actionHint, PlaybackActionHint.seek);
|
||||
expect(acceptedStates[0].actorPeerId, 'guest');
|
||||
expect(acceptedStates[1].rate, 0.25);
|
||||
expect(acceptedStates[1].actionHint, PlaybackActionHint.rate);
|
||||
expect(acceptedStates[1].actorPeerId, 'guest');
|
||||
room.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test('guest controller starts clock-sync pings automatically', () {
|
||||
fakeAsync((async) {
|
||||
final room = _Room(async);
|
||||
@@ -221,15 +289,14 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
test('v1 peers are flagged and never gate the start', () {
|
||||
test('v2 peers are flagged and never gate the start', () {
|
||||
fakeAsync((async) {
|
||||
final needsUpdate = <String>[];
|
||||
final room = _Room(async);
|
||||
room.host.onPeerNeedsUpdate = needsUpdate.add;
|
||||
|
||||
// A legacy client joins on its own connection: its join message has no
|
||||
// version field (the relay stamps the sender id, so it must really
|
||||
// connect as itself — peerId spoofing is rewritten).
|
||||
// A sync-protocol-2 client joins on its own connection. The relay
|
||||
// stamps the sender ID, so it must really connect as itself.
|
||||
final legacyService = room.hub.register('legacy');
|
||||
legacyService.sendTo(
|
||||
'host',
|
||||
@@ -239,6 +306,7 @@ void main() {
|
||||
peerId: 'legacy',
|
||||
displayName: 'Old App',
|
||||
isHost: false,
|
||||
version: 2,
|
||||
),
|
||||
);
|
||||
async.flushMicrotasks();
|
||||
@@ -253,6 +321,34 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
test('versionless legacy peers are flagged and never gate the start', () {
|
||||
fakeAsync((async) {
|
||||
final needsUpdate = <String>[];
|
||||
final room = _Room(async);
|
||||
room.host.onPeerNeedsUpdate = needsUpdate.add;
|
||||
|
||||
final versionlessService = room.hub.register('versionless');
|
||||
versionlessService.sendTo(
|
||||
'host',
|
||||
SyncMessage(
|
||||
type: SyncMessageType.join,
|
||||
timestamp: room.nowMs(),
|
||||
peerId: 'versionless',
|
||||
displayName: 'Old App',
|
||||
isHost: false,
|
||||
),
|
||||
);
|
||||
async.flushMicrotasks();
|
||||
expect(needsUpdate, ['versionless']);
|
||||
|
||||
room.hostStartsMedia();
|
||||
room.guestJoinsMedia();
|
||||
room.bothBecomeReady();
|
||||
expect(room.lastHostState().phase, PlaybackPhase.playing);
|
||||
room.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test('guest reconnect re-requests state and the host answers directly', () {
|
||||
fakeAsync((async) {
|
||||
final room = _Room(async);
|
||||
@@ -274,6 +370,46 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
test('only relay-stamped state from the declared host reaches guest reconciliation', () {
|
||||
fakeAsync((async) {
|
||||
final room = _Room(async);
|
||||
final mediaDispatches = <String>[];
|
||||
room.guest.onMediaStateReceived = (ratingKey, serverId, title) => mediaDispatches.add(ratingKey);
|
||||
const state = PlaybackState(
|
||||
seq: 10,
|
||||
ratingKey: 'relay-authority',
|
||||
serverId: 'srv',
|
||||
mediaTitle: 'Authorized',
|
||||
phase: PlaybackPhase.loading,
|
||||
anchorPositionMs: 0,
|
||||
anchorHostTimeMs: _epochMs,
|
||||
rate: 1,
|
||||
controlMode: ControlMode.hostOnly,
|
||||
);
|
||||
final unprivileged = room.hub.register('unprivileged');
|
||||
|
||||
// The fake relay overwrites the payload claim with the connection's
|
||||
// routing ID, just like the production relay envelope parser.
|
||||
unprivileged.broadcast(SyncMessage.state(state, peerId: 'host'));
|
||||
async.flushMicrotasks();
|
||||
expect(mediaDispatches, isEmpty);
|
||||
|
||||
room.hostService.broadcast(SyncMessage.state(state, peerId: 'host'));
|
||||
async.flushMicrotasks();
|
||||
expect(mediaDispatches, ['relay-authority']);
|
||||
room.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test('fake relay rejects duplicate routing IDs instead of replacing authority', () async {
|
||||
final hub = FakeRelayHub();
|
||||
hub.register('reserved');
|
||||
|
||||
expect(() => hub.register('reserved'), throwsStateError);
|
||||
|
||||
await hub.dispose();
|
||||
});
|
||||
|
||||
group('hostExitedPlayer routing', () {
|
||||
test('rides the ordered queue: never overtakes states sent before it', () {
|
||||
fakeAsync((async) {
|
||||
|
||||
@@ -55,6 +55,20 @@ void main() {
|
||||
expect(harness.onLeaveSessionCalls, 0);
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('best-effort leave failure is handled by the overlay', (tester) async {
|
||||
final harness = _OverlayHarness(isHost: false, leaveError: StateError('release failed'));
|
||||
addTearDown(harness.dispose);
|
||||
await tester.pumpWidget(harness.build());
|
||||
|
||||
await _openLeaveConfirmation(tester, harness);
|
||||
await tester.tap(find.text(t.watchTogether.leave));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(harness.provider.leaveCalls, 1);
|
||||
expect(harness.onLeaveSessionCalls, 1);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _openLeaveConfirmation(WidgetTester tester, _OverlayHarness harness) async {
|
||||
@@ -69,7 +83,8 @@ Future<void> _openLeaveConfirmation(WidgetTester tester, _OverlayHarness harness
|
||||
}
|
||||
|
||||
class _OverlayHarness {
|
||||
_OverlayHarness({required bool isHost}) : provider = _FakeWatchTogetherProvider(isHostValue: isHost);
|
||||
_OverlayHarness({required bool isHost, Object? leaveError})
|
||||
: provider = _FakeWatchTogetherProvider(isHostValue: isHost, leaveError: leaveError);
|
||||
|
||||
static const indicatorKey = Key('watch-together-session-indicator');
|
||||
|
||||
@@ -101,9 +116,10 @@ class _OverlayHarness {
|
||||
}
|
||||
|
||||
class _FakeWatchTogetherProvider extends WatchTogetherProvider {
|
||||
_FakeWatchTogetherProvider({required this.isHostValue});
|
||||
_FakeWatchTogetherProvider({required this.isHostValue, this.leaveError});
|
||||
|
||||
final bool isHostValue;
|
||||
final Object? leaveError;
|
||||
var leaveCalls = 0;
|
||||
var _isDisposing = false;
|
||||
|
||||
@@ -127,6 +143,8 @@ class _FakeWatchTogetherProvider extends WatchTogetherProvider {
|
||||
@override
|
||||
Future<void> leaveSession() async {
|
||||
if (!_isDisposing) leaveCalls++;
|
||||
final error = leaveError;
|
||||
if (error != null) throw error;
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,256 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:fake_async/fake_async.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/media/ids.dart';
|
||||
import 'package:plezy/watch_together/services/watch_together_relay_endpoint.dart';
|
||||
import 'package:plezy/watch_together/models/sync_message.dart';
|
||||
import 'package:plezy/watch_together/models/watch_session.dart';
|
||||
import 'package:plezy/watch_together/providers/watch_together_provider.dart';
|
||||
import 'package:plezy/watch_together/services/watch_together_peer_service.dart';
|
||||
import 'package:plezy/watch_together/services/relay_protocol.g.dart';
|
||||
|
||||
import '../test_helpers/prefs.dart';
|
||||
|
||||
class _FakeWatchTogetherPeerService extends WatchTogetherPeerService {
|
||||
_FakeWatchTogetherPeerService(
|
||||
this.sequence, {
|
||||
required bool hostInitiallyConnected,
|
||||
required this.rejectDisconnectedTargets,
|
||||
this.releaseError,
|
||||
this.releaseBarrier,
|
||||
}) : _hostConnected = hostInitiallyConnected;
|
||||
|
||||
final int sequence;
|
||||
final bool rejectDisconnectedTargets;
|
||||
final Object? releaseError;
|
||||
final Future<void>? releaseBarrier;
|
||||
final _peerConnectedController = StreamController<String>.broadcast();
|
||||
final _peerDisconnectedController = StreamController<String>.broadcast();
|
||||
final _messageController = StreamController<SyncMessage>.broadcast();
|
||||
final _errorController = StreamController<PeerError>.broadcast();
|
||||
final _sessionEndedController = StreamController<void>.broadcast();
|
||||
final List<SyncMessage> broadcasts = [];
|
||||
final List<(String, SyncMessage)> directMessages = [];
|
||||
|
||||
String? _sessionId;
|
||||
String? _myPeerId;
|
||||
String? _hostPeerId;
|
||||
bool _isHost = false;
|
||||
bool _disposed = false;
|
||||
bool _didDisconnect = false;
|
||||
int releaseCalls = 0;
|
||||
bool _hostConnected;
|
||||
|
||||
@override
|
||||
Stream<String> get onPeerConnected => _peerConnectedController.stream;
|
||||
|
||||
@override
|
||||
Stream<String> get onPeerDisconnected => _peerDisconnectedController.stream;
|
||||
|
||||
@override
|
||||
Stream<SyncMessage> get onMessageReceived => _messageController.stream;
|
||||
|
||||
@override
|
||||
Stream<PeerError> get onError => _errorController.stream;
|
||||
|
||||
@override
|
||||
Stream<void> get onSessionEnded => _sessionEndedController.stream;
|
||||
|
||||
@override
|
||||
String? get sessionId => _sessionId;
|
||||
|
||||
@override
|
||||
String? get myPeerId => _myPeerId;
|
||||
|
||||
@override
|
||||
String? get hostPeerId => _hostPeerId;
|
||||
|
||||
@override
|
||||
bool get isHost => _isHost;
|
||||
|
||||
@override
|
||||
List<String> get connectedPeers => !_isHost && _sessionId != null && _hostConnected ? ['wt-$_sessionId'] : const [];
|
||||
|
||||
@override
|
||||
Future<String> createSession({String? sessionId}) {
|
||||
_sessionId = (sessionId ?? 'ROOM$sequence').toUpperCase();
|
||||
_myPeerId = 'wt-$_sessionId';
|
||||
_hostPeerId = _myPeerId;
|
||||
_isHost = true;
|
||||
return Future.value(_sessionId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> joinSession(String sessionId) {
|
||||
_sessionId = sessionId.toUpperCase();
|
||||
_myPeerId = 'guest-$sequence';
|
||||
_hostPeerId = 'wt-$_sessionId';
|
||||
_isHost = false;
|
||||
return Future.value();
|
||||
}
|
||||
|
||||
@override
|
||||
void broadcast(SyncMessage message) {
|
||||
broadcasts.add(message);
|
||||
}
|
||||
|
||||
@override
|
||||
void sendTo(String peerId, SyncMessage message) {
|
||||
if (rejectDisconnectedTargets && !connectedPeers.contains(peerId)) {
|
||||
_errorController.add(
|
||||
const PeerError(type: PeerErrorType.serverError, message: 'Peer is not in the room', serverCode: 'not_in_room'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
directMessages.add((peerId, message));
|
||||
}
|
||||
|
||||
void emitError(PeerError error) => _errorController.add(error);
|
||||
void emitPeerConnected(String peerId) {
|
||||
if (peerId == _hostPeerId) _hostConnected = true;
|
||||
_peerConnectedController.add(peerId);
|
||||
}
|
||||
|
||||
void emitPeerDisconnected(String peerId) {
|
||||
if (peerId == _hostPeerId) _hostConnected = false;
|
||||
_peerDisconnectedController.add(peerId);
|
||||
}
|
||||
|
||||
void emitSessionEnded() => _sessionEndedController.add(null);
|
||||
void emitMessage(SyncMessage message) => _messageController.add(message);
|
||||
|
||||
bool get hasRelayListeners =>
|
||||
_peerConnectedController.hasListener ||
|
||||
_peerDisconnectedController.hasListener ||
|
||||
_messageController.hasListener ||
|
||||
_errorController.hasListener ||
|
||||
_sessionEndedController.hasListener;
|
||||
|
||||
bool get isDisposed => _disposed;
|
||||
bool get didDisconnect => _didDisconnect;
|
||||
|
||||
@override
|
||||
Future<void> releaseSession() async {
|
||||
releaseCalls++;
|
||||
final barrier = releaseBarrier;
|
||||
if (barrier != null) await barrier;
|
||||
final error = releaseError;
|
||||
if (error != null) throw error;
|
||||
}
|
||||
|
||||
void reconnect() => onReconnected?.call();
|
||||
@override
|
||||
Future<void> disconnect() {
|
||||
_didDisconnect = true;
|
||||
_sessionId = null;
|
||||
_myPeerId = null;
|
||||
_hostPeerId = null;
|
||||
_isHost = false;
|
||||
_hostConnected = false;
|
||||
return Future.value();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_peerConnectedController.close();
|
||||
_peerDisconnectedController.close();
|
||||
_messageController.close();
|
||||
_errorController.close();
|
||||
_sessionEndedController.close();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class _FakePeerServiceFactory {
|
||||
_FakePeerServiceFactory({
|
||||
this.hostInitiallyConnected = true,
|
||||
this.rejectDisconnectedTargets = false,
|
||||
this.releaseError,
|
||||
this.releaseBarrier,
|
||||
});
|
||||
|
||||
final bool hostInitiallyConnected;
|
||||
final bool rejectDisconnectedTargets;
|
||||
final Object? releaseError;
|
||||
final Future<void>? releaseBarrier;
|
||||
final List<_FakeWatchTogetherPeerService> services = [];
|
||||
WatchTogetherPeerService call({WatchTogetherRelayEndpoint? endpoint}) {
|
||||
final service = _FakeWatchTogetherPeerService(
|
||||
services.length + 1,
|
||||
hostInitiallyConnected: hostInitiallyConnected,
|
||||
rejectDisconnectedTargets: rejectDisconnectedTargets,
|
||||
releaseError: releaseError,
|
||||
releaseBarrier: releaseBarrier,
|
||||
);
|
||||
services.add(service);
|
||||
return service;
|
||||
}
|
||||
}
|
||||
|
||||
PeerError _transportError([String message = 'WebSocket error: connection reset']) {
|
||||
return PeerError(type: PeerErrorType.serverError, message: message, originalError: StateError('connection reset'));
|
||||
}
|
||||
|
||||
Future<void> _flushProviderEvents() async {
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
}
|
||||
|
||||
const _providerHostId = 'relay-authoritative-host';
|
||||
|
||||
typedef _ProviderRelayHandler = void Function(WebSocket socket, Map<String, dynamic> message);
|
||||
|
||||
class _ProviderRelay {
|
||||
_ProviderRelay._(this._server);
|
||||
|
||||
final HttpServer _server;
|
||||
final List<WebSocket> _sockets = [];
|
||||
final List<Map<String, dynamic>> messages = [];
|
||||
|
||||
String get baseUrl => 'http://${_server.address.address}:${_server.port}';
|
||||
|
||||
static Future<_ProviderRelay> start(_ProviderRelayHandler handler) async {
|
||||
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
final relay = _ProviderRelay._(server);
|
||||
server.listen((request) async {
|
||||
if (request.uri.path != '/relay') {
|
||||
request.response.statusCode = HttpStatus.notFound;
|
||||
await request.response.close();
|
||||
return;
|
||||
}
|
||||
final socket = await WebSocketTransformer.upgrade(request);
|
||||
relay._sockets.add(socket);
|
||||
socket.listen((data) {
|
||||
final message = jsonDecode(data as String) as Map<String, dynamic>;
|
||||
relay.messages.add(message);
|
||||
handler(socket, message);
|
||||
});
|
||||
});
|
||||
return relay;
|
||||
}
|
||||
|
||||
void send(WebSocket socket, Map<String, dynamic> message) => socket.add(jsonEncode(message));
|
||||
|
||||
Future<void> close() async {
|
||||
for (final socket in _sockets) {
|
||||
await socket.close();
|
||||
}
|
||||
await _server.close(force: true);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('generated relay versions match the protocol specification', () {
|
||||
final spec = (jsonDecode(File('relay_protocol.json').readAsStringSync()) as Map).cast<String, dynamic>();
|
||||
expect(RelayProtocol.protocolVersion, spec['protocolVersion']);
|
||||
expect(RelayProtocol.legacyProtocolVersion, spec['legacyProtocolVersion']);
|
||||
});
|
||||
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() {
|
||||
@@ -210,6 +453,389 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('WatchTogetherProvider — reconnect recovery', () {
|
||||
test('restores only the matching host transport error and preserves session fields', () async {
|
||||
final factory = _FakePeerServiceFactory();
|
||||
final provider = WatchTogetherProvider(peerServiceFactory: factory.call);
|
||||
final observedStates = <SessionState?>[];
|
||||
provider.addListener(() => observedStates.add(provider.session?.state));
|
||||
|
||||
await provider.createSession(
|
||||
controlMode: ControlMode.anyone,
|
||||
relayEndpoint: WatchTogetherRelayEndpoint.defaultEndpoint,
|
||||
displayName: 'Host',
|
||||
sessionId: 'room1',
|
||||
mediaRatingKey: 'rating-1',
|
||||
mediaServerId: 'server-1',
|
||||
mediaTitle: 'Episode 1',
|
||||
);
|
||||
await _flushProviderEvents();
|
||||
final established = provider.session!;
|
||||
final service = factory.services.single;
|
||||
observedStates.clear();
|
||||
service.broadcasts.clear();
|
||||
|
||||
service.emitError(_transportError());
|
||||
await _flushProviderEvents();
|
||||
expect(provider.session?.state, SessionState.error);
|
||||
expect(provider.isConnected, isFalse);
|
||||
|
||||
service.reconnect();
|
||||
await _flushProviderEvents();
|
||||
|
||||
expect(observedStates, [SessionState.error, SessionState.connected]);
|
||||
expect(provider.isConnected, isTrue);
|
||||
expect(provider.session?.errorMessage, isNull);
|
||||
expect(provider.session?.sessionId, established.sessionId);
|
||||
expect(provider.session?.role, established.role);
|
||||
expect(provider.session?.controlMode, established.controlMode);
|
||||
expect(provider.session?.mediaRatingKey, established.mediaRatingKey);
|
||||
expect(provider.session?.mediaServerId, established.mediaServerId);
|
||||
expect(provider.session?.mediaTitle, established.mediaTitle);
|
||||
expect(service.broadcasts.where((message) => message.type == SyncMessageType.join), hasLength(1));
|
||||
|
||||
await provider.leaveSession();
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
test('guest waits for a reconnecting declared host before requesting state', () async {
|
||||
final factory = _FakePeerServiceFactory(hostInitiallyConnected: false, rejectDisconnectedTargets: true);
|
||||
final provider = WatchTogetherProvider(peerServiceFactory: factory.call);
|
||||
|
||||
await provider.joinSession(
|
||||
'late1',
|
||||
relayEndpoint: WatchTogetherRelayEndpoint.defaultEndpoint,
|
||||
displayName: 'Guest',
|
||||
);
|
||||
await _flushProviderEvents();
|
||||
final service = factory.services.single;
|
||||
final hostPeerId = provider.session!.hostPeerId!;
|
||||
|
||||
expect(provider.session?.state, SessionState.connected);
|
||||
expect(service.connectedPeers, isEmpty);
|
||||
expect(service.directMessages.where((entry) => entry.$2.type == SyncMessageType.requestState), isEmpty);
|
||||
|
||||
service.emitPeerConnected(hostPeerId);
|
||||
await _flushProviderEvents();
|
||||
|
||||
expect(service.connectedPeers, [hostPeerId]);
|
||||
expect(
|
||||
service.directMessages.where(
|
||||
(entry) => entry.$1 == hostPeerId && entry.$2.type == SyncMessageType.requestState,
|
||||
),
|
||||
hasLength(1),
|
||||
);
|
||||
expect(provider.session?.state, SessionState.connected);
|
||||
|
||||
await provider.leaveSession();
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
test('guest recovery re-announces and re-requests authoritative state once', () async {
|
||||
final factory = _FakePeerServiceFactory();
|
||||
final provider = WatchTogetherProvider(peerServiceFactory: factory.call);
|
||||
await provider.joinSession(
|
||||
'room2',
|
||||
relayEndpoint: WatchTogetherRelayEndpoint.defaultEndpoint,
|
||||
displayName: 'Guest',
|
||||
);
|
||||
await _flushProviderEvents();
|
||||
final service = factory.services.single;
|
||||
service.broadcasts.clear();
|
||||
service.directMessages.clear();
|
||||
|
||||
service.emitError(_transportError());
|
||||
await _flushProviderEvents();
|
||||
service.reconnect();
|
||||
await _flushProviderEvents();
|
||||
|
||||
expect(provider.session?.state, SessionState.connected);
|
||||
expect(service.broadcasts.where((message) => message.type == SyncMessageType.join), hasLength(1));
|
||||
expect(service.directMessages.where((entry) => entry.$2.type == SyncMessageType.requestState), hasLength(1));
|
||||
|
||||
await provider.leaveSession();
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
test('relay errors remain terminal when the current service reconnects', () async {
|
||||
final factory = _FakePeerServiceFactory();
|
||||
final provider = WatchTogetherProvider(peerServiceFactory: factory.call);
|
||||
final observedStates = <SessionState?>[];
|
||||
provider.addListener(() => observedStates.add(provider.session?.state));
|
||||
await provider.createSession(
|
||||
controlMode: ControlMode.hostOnly,
|
||||
relayEndpoint: WatchTogetherRelayEndpoint.defaultEndpoint,
|
||||
sessionId: 'room3',
|
||||
);
|
||||
await _flushProviderEvents();
|
||||
final service = factory.services.single;
|
||||
observedStates.clear();
|
||||
|
||||
service.emitError(
|
||||
const PeerError(type: PeerErrorType.serverError, message: 'Room was rejected', serverCode: 'room_rejected'),
|
||||
);
|
||||
await _flushProviderEvents();
|
||||
service.reconnect();
|
||||
await _flushProviderEvents();
|
||||
|
||||
expect(observedStates, [SessionState.error]);
|
||||
expect(provider.session?.state, SessionState.error);
|
||||
expect(provider.session?.errorMessage, 'Room was rejected');
|
||||
expect(provider.isConnected, isFalse);
|
||||
|
||||
await provider.leaveSession();
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
test('host-loss expiry supersedes a recoverable guest transport error', () {
|
||||
fakeAsync((async) {
|
||||
final factory = _FakePeerServiceFactory();
|
||||
final provider = WatchTogetherProvider(peerServiceFactory: factory.call);
|
||||
var joined = false;
|
||||
unawaited(
|
||||
provider
|
||||
.joinSession('room4', relayEndpoint: WatchTogetherRelayEndpoint.defaultEndpoint, displayName: 'Guest')
|
||||
.then((_) => joined = true),
|
||||
);
|
||||
async.flushMicrotasks();
|
||||
expect(joined, isTrue);
|
||||
final service = factory.services.single;
|
||||
final hostPeerId = provider.session!.hostPeerId!;
|
||||
|
||||
service.emitError(_transportError());
|
||||
async.flushMicrotasks();
|
||||
expect(provider.session?.state, SessionState.error);
|
||||
|
||||
service.emitPeerDisconnected(hostPeerId);
|
||||
async.flushMicrotasks();
|
||||
expect(provider.isWaitingForHostReconnect, isTrue);
|
||||
async.elapse(const Duration(seconds: 15));
|
||||
async.flushMicrotasks();
|
||||
expect(provider.session?.errorMessage, 'Host left the session');
|
||||
|
||||
service.reconnect();
|
||||
async.flushMicrotasks();
|
||||
expect(provider.session?.state, SessionState.error);
|
||||
expect(provider.session?.errorMessage, 'Host left the session');
|
||||
|
||||
unawaited(provider.leaveSession());
|
||||
async.flushMicrotasks();
|
||||
provider.dispose();
|
||||
async.flushMicrotasks();
|
||||
});
|
||||
});
|
||||
|
||||
test('stale service callback cannot mutate or resync a replacement session', () async {
|
||||
final factory = _FakePeerServiceFactory();
|
||||
final provider = WatchTogetherProvider(peerServiceFactory: factory.call);
|
||||
var notifications = 0;
|
||||
provider.addListener(() => notifications++);
|
||||
|
||||
await provider.createSession(
|
||||
controlMode: ControlMode.hostOnly,
|
||||
relayEndpoint: WatchTogetherRelayEndpoint.defaultEndpoint,
|
||||
sessionId: 'old1',
|
||||
);
|
||||
await _flushProviderEvents();
|
||||
final oldService = factory.services.single;
|
||||
final staleReconnect = oldService.onReconnected!;
|
||||
oldService.emitError(_transportError('old transport error'));
|
||||
await _flushProviderEvents();
|
||||
|
||||
await provider.createSession(
|
||||
controlMode: ControlMode.anyone,
|
||||
relayEndpoint: WatchTogetherRelayEndpoint.defaultEndpoint,
|
||||
sessionId: 'new1',
|
||||
);
|
||||
await _flushProviderEvents();
|
||||
final currentService = factory.services.last;
|
||||
currentService.broadcasts.clear();
|
||||
notifications = 0;
|
||||
final replacement = provider.session;
|
||||
|
||||
staleReconnect();
|
||||
await _flushProviderEvents();
|
||||
expect(provider.session, replacement);
|
||||
expect(notifications, 0);
|
||||
expect(currentService.broadcasts, isEmpty);
|
||||
|
||||
currentService.emitError(_transportError('current transport error'));
|
||||
await _flushProviderEvents();
|
||||
currentService.reconnect();
|
||||
await _flushProviderEvents();
|
||||
expect(provider.session?.state, SessionState.connected);
|
||||
expect(provider.session?.errorMessage, isNull);
|
||||
expect(currentService.broadcasts.where((message) => message.type == SyncMessageType.join), hasLength(1));
|
||||
|
||||
await provider.leaveSession();
|
||||
provider.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
group('WatchTogetherProvider — release cleanup', () {
|
||||
test('release failure is surfaced after local session teardown completes', () async {
|
||||
final releaseFailure = StateError('relay release failed');
|
||||
final factory = _FakePeerServiceFactory(releaseError: releaseFailure);
|
||||
final provider = WatchTogetherProvider(peerServiceFactory: factory.call);
|
||||
addTearDown(provider.dispose);
|
||||
|
||||
await provider.createSession(
|
||||
controlMode: ControlMode.hostOnly,
|
||||
relayEndpoint: WatchTogetherRelayEndpoint.defaultEndpoint,
|
||||
sessionId: 'fail1',
|
||||
);
|
||||
final service = factory.services.single;
|
||||
|
||||
await expectLater(provider.leaveSession(), throwsA(same(releaseFailure)));
|
||||
|
||||
expect(service.releaseCalls, 1);
|
||||
expect(service.didDisconnect, isTrue);
|
||||
expect(service.isDisposed, isTrue);
|
||||
expect(provider.session, isNull);
|
||||
expect(provider.isInSession, isFalse);
|
||||
expect(provider.participants, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('WatchTogetherProvider — relay authority', () {
|
||||
test('an empty successful probe joins the reserved room without creating', () async {
|
||||
late final _ProviderRelay relay;
|
||||
relay = await _ProviderRelay.start((socket, message) {
|
||||
if (message['type'] == 'join') {
|
||||
relay.send(socket, {
|
||||
'type': 'joined',
|
||||
'sessionId': message['sessionId'],
|
||||
'hostPeerId': _providerHostId,
|
||||
'reconnectToken': message['reconnectToken'],
|
||||
'protocolVersion': 2,
|
||||
});
|
||||
} else if (message['type'] == 'leave') {
|
||||
relay.send(socket, {
|
||||
'type': 'left',
|
||||
'sessionId': message['sessionId'],
|
||||
'peerId': message['peerId'],
|
||||
'protocolVersion': 2,
|
||||
});
|
||||
}
|
||||
});
|
||||
addTearDown(relay.close);
|
||||
final endpoint = WatchTogetherRelayEndpoint.resolve(relay.baseUrl);
|
||||
final provider = WatchTogetherProvider();
|
||||
addTearDown(() async {
|
||||
await provider.leaveSession();
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
final becameHost = await provider.enterRoom('empty1', relayEndpoint: endpoint, displayName: 'Guest');
|
||||
|
||||
expect(becameHost, isFalse);
|
||||
expect(provider.isHost, isFalse);
|
||||
expect(provider.session?.hostPeerId, _providerHostId);
|
||||
final joins = relay.messages.where((message) => message['type'] == 'join').toList();
|
||||
expect(joins, hasLength(2));
|
||||
for (final join in joins) {
|
||||
expect(join['protocolVersion'], 2);
|
||||
expect(join['reconnectToken'], matches(RegExp(r'^[A-Za-z0-9_-]{43}$')));
|
||||
}
|
||||
final leave = relay.messages.singleWhere((message) => message['type'] == 'leave');
|
||||
expect(leave['peerId'], joins.first['peerId']);
|
||||
expect(leave['reconnectToken'], joins.first['reconnectToken']);
|
||||
expect(leave['protocolVersion'], RelayProtocol.protocolVersion);
|
||||
expect(joins.last['peerId'], isNot(joins.first['peerId']));
|
||||
expect(relay.messages.map((message) => message['type']).take(3), ['join', 'leave', 'join']);
|
||||
expect(relay.messages.where((message) => message['type'] == 'create'), isEmpty);
|
||||
});
|
||||
|
||||
test('a room-not-found probe creates with relay-declared host authority', () async {
|
||||
late final _ProviderRelay relay;
|
||||
relay = await _ProviderRelay.start((socket, message) {
|
||||
if (message['type'] == 'join') {
|
||||
relay.send(socket, {'type': 'error', 'code': 'room_not_found', 'message': 'Room not found'});
|
||||
} else if (message['type'] == 'create') {
|
||||
relay.send(socket, {
|
||||
'type': 'created',
|
||||
'sessionId': message['sessionId'],
|
||||
'hostPeerId': message['peerId'],
|
||||
'reconnectToken': message['reconnectToken'],
|
||||
'protocolVersion': 2,
|
||||
});
|
||||
} else if (message['type'] == 'endSession') {
|
||||
relay.send(socket, {'type': 'ended', 'sessionId': message['sessionId'], 'protocolVersion': 2});
|
||||
}
|
||||
});
|
||||
addTearDown(relay.close);
|
||||
final endpoint = WatchTogetherRelayEndpoint.resolve(relay.baseUrl);
|
||||
final provider = WatchTogetherProvider();
|
||||
addTearDown(() async {
|
||||
await provider.leaveSession();
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
final becameHost = await provider.enterRoom('new01', relayEndpoint: endpoint, displayName: 'Host');
|
||||
|
||||
expect(becameHost, isTrue);
|
||||
expect(provider.isHost, isTrue);
|
||||
final create = relay.messages.singleWhere((message) => message['type'] == 'create');
|
||||
expect(provider.session?.hostPeerId, create['peerId']);
|
||||
expect(create['peerId'], isNot('wt-NEW01'));
|
||||
expect(create['protocolVersion'], 2);
|
||||
expect(create['reconnectToken'], matches(RegExp(r'^[A-Za-z0-9_-]{43}$')));
|
||||
});
|
||||
});
|
||||
|
||||
group('WatchTogetherProvider — terminal room lifecycle', () {
|
||||
test('relay ended notification exits immediately without release or reconnect grace', () async {
|
||||
final factory = _FakePeerServiceFactory();
|
||||
final provider = WatchTogetherProvider(peerServiceFactory: factory.call);
|
||||
var hostExitCalls = 0;
|
||||
provider.onHostExitedPlayer = () => hostExitCalls++;
|
||||
|
||||
await provider.joinSession(
|
||||
'ended1',
|
||||
relayEndpoint: WatchTogetherRelayEndpoint.defaultEndpoint,
|
||||
displayName: 'Guest',
|
||||
);
|
||||
final service = factory.services.single;
|
||||
service.emitPeerDisconnected(provider.session!.hostPeerId!);
|
||||
await _flushProviderEvents();
|
||||
expect(provider.isWaitingForHostReconnect, isTrue);
|
||||
|
||||
service.emitSessionEnded();
|
||||
await _flushProviderEvents();
|
||||
|
||||
expect(hostExitCalls, 1);
|
||||
expect(provider.session, isNull);
|
||||
expect(provider.isWaitingForHostReconnect, isFalse);
|
||||
expect(service.releaseCalls, 0);
|
||||
expect(service.didDisconnect, isTrue);
|
||||
expect(service.isDisposed, isTrue);
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
test('best-effort host-leave cleanup observes release failures', () async {
|
||||
final releaseFailure = StateError('relay release failed');
|
||||
final factory = _FakePeerServiceFactory(releaseError: releaseFailure);
|
||||
final uncaught = <Object>[];
|
||||
|
||||
await runZonedGuarded(() async {
|
||||
final provider = WatchTogetherProvider(peerServiceFactory: factory.call);
|
||||
await provider.joinSession(
|
||||
'leave2',
|
||||
relayEndpoint: WatchTogetherRelayEndpoint.defaultEndpoint,
|
||||
displayName: 'Guest',
|
||||
);
|
||||
final service = factory.services.single;
|
||||
service.emitMessage(SyncMessage.leave(peerId: provider.session!.hostPeerId!));
|
||||
await _flushProviderEvents();
|
||||
expect(provider.session, isNull);
|
||||
expect(service.releaseCalls, 1);
|
||||
provider.dispose();
|
||||
}, (error, _) => uncaught.add(error));
|
||||
|
||||
expect(uncaught, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('WatchTogetherProvider — dispose hygiene', () {
|
||||
test('participantEvents stream is closed after dispose', () async {
|
||||
final p = WatchTogetherProvider();
|
||||
@@ -223,5 +849,37 @@ void main() {
|
||||
await sub.cancel();
|
||||
expect(streamDone, isTrue);
|
||||
});
|
||||
|
||||
test('dispose detaches local listeners before relay release completes', () async {
|
||||
final releaseCompleter = Completer<void>();
|
||||
final factory = _FakePeerServiceFactory(releaseBarrier: releaseCompleter.future);
|
||||
final provider = WatchTogetherProvider(peerServiceFactory: factory.call);
|
||||
await provider.joinSession(
|
||||
'dispose1',
|
||||
relayEndpoint: WatchTogetherRelayEndpoint.defaultEndpoint,
|
||||
displayName: 'Guest',
|
||||
);
|
||||
final service = factory.services.single;
|
||||
expect(service.hasRelayListeners, isTrue);
|
||||
|
||||
provider.dispose();
|
||||
|
||||
expect(provider.session, isNull);
|
||||
expect(provider.participants, isEmpty);
|
||||
expect(provider.isWaitingForHostReconnect, isFalse);
|
||||
expect(service.hasRelayListeners, isFalse);
|
||||
expect(service.releaseCalls, 1);
|
||||
expect(service.didDisconnect, isFalse);
|
||||
|
||||
service.emitPeerConnected('late-peer');
|
||||
service.emitMessage(SyncMessage.join(peerId: 'late-peer', displayName: 'Late', isHost: false));
|
||||
await _flushProviderEvents();
|
||||
expect(provider.participants, isEmpty);
|
||||
|
||||
releaseCompleter.complete();
|
||||
await _flushProviderEvents();
|
||||
expect(service.didDisconnect, isTrue);
|
||||
expect(service.isDisposed, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user