From d7a54a876d5ef139e6e991cb8435b688190fa23a Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Fri, 11 Sep 2026 23:32:52 -0700 Subject: [PATCH] fix(mobile): preserve relay scope and video cancellation across async signing Signed-off-by: Bradley Axen --- .../channels/compose_bar/helpers.dart | 5 +- .../media_viewer_page/video_viewer.dart | 30 +- .../profile/user_status_provider.dart | 6 +- mobile/lib/shared/relay/relay_session.dart | 45 ++- .../lib/shared/relay/signed_event_relay.dart | 12 +- mobile/pubspec.lock | 2 +- mobile/pubspec.yaml | 1 + .../channels/channel_detail_page_test.dart | 3 + .../channel_management_provider_test.dart | 1 + .../channels/send_message_provider_test.dart | 1 + .../video_viewer_cancellation_test.dart | 191 ++++++++++ .../profile/profile_provider_test.dart | 3 + .../profile/user_status_provider_test.dart | 1 + .../test/shared/auth/event_signer_test.dart | 1 + .../test/shared/relay/relay_session_test.dart | 15 +- .../shared/relay/signed_event_scope_test.dart | 343 ++++++++++++++++++ .../theme/community_theme_provider_test.dart | 1 + 17 files changed, 646 insertions(+), 15 deletions(-) create mode 100644 mobile/test/features/channels/video_viewer_cancellation_test.dart create mode 100644 mobile/test/shared/relay/signed_event_scope_test.dart diff --git a/mobile/lib/features/channels/compose_bar/helpers.dart b/mobile/lib/features/channels/compose_bar/helpers.dart index a5323d6fb2..4bb0e6af2f 100644 --- a/mobile/lib/features/channels/compose_bar/helpers.dart +++ b/mobile/lib/features/channels/compose_bar/helpers.dart @@ -394,6 +394,8 @@ void _sendTypingIndicator( ['e', threadHeadId, '', 'reply'], ]; + final session = ref.read(relaySessionProvider.notifier); + final lease = session.captureLease(); final event = await signEvent( kind: EventKind.typingIndicator, content: '', @@ -402,8 +404,7 @@ void _sendTypingIndicator( ); // Send directly over WebSocket — fire-and-forget, matching desktop. - final session = ref.read(relaySessionProvider.notifier); - session.sendRaw(['EVENT', event.toMap()]); + session.sendRaw(['EVENT', event.toMap()], lease: lease); } catch (_) { // Fire-and-forget — typing indicator failure is non-fatal. } diff --git a/mobile/lib/features/channels/media_viewer_page/video_viewer.dart b/mobile/lib/features/channels/media_viewer_page/video_viewer.dart index fef6fcb46f..061e6a2193 100644 --- a/mobile/lib/features/channels/media_viewer_page/video_viewer.dart +++ b/mobile/lib/features/channels/media_viewer_page/video_viewer.dart @@ -1,5 +1,11 @@ part of '../media_viewer_page.dart'; +/// Android supports authenticated range requests; other platforms use a file. +/// Overridable so widget tests can exercise the actual streaming path. +final mediaVideoStreamingSupportedProvider = Provider( + (ref) => Platform.isAndroid, +); + class MediaVideoViewerPage extends HookConsumerWidget { final String videoUrl; final String? posterUrl; @@ -52,14 +58,20 @@ class MediaVideoViewerPage extends HookConsumerWidget { // keep Android on its streaming path. iOS uses the authenticated local // copy below because AVPlayer can drop those headers after the first // request. - if (Platform.isAndroid) { + if (ref.read(mediaVideoStreamingSupportedProvider)) { VideoPlayerController? streamingController; try { + final headers = await auth.headersFor(videoUrl); + if (disposed) return; streamingController = VideoPlayerController.networkUrl( uri, - httpHeaders: await auth.headersFor(videoUrl), + httpHeaders: headers, ); await streamingController.initialize(); + if (disposed) { + await streamingController.dispose(); + return; + } await streamingController.play(); if (disposed) { await streamingController.dispose(); @@ -71,20 +83,24 @@ class MediaVideoViewerPage extends HookConsumerWidget { if (streamingController != null) { await streamingController.dispose(); } + if (disposed) return; // Fall through to the authenticated local-file path only when the // streaming controller cannot initialize. } } + VideoPlayerController? localController; try { final client = ref.read(mediaHttpClientProvider); + final headers = await auth.headersFor(videoUrl); + if (disposed) return; final requestAbort = Completer(); downloadRequestAbort.value = requestAbort; final request = http.AbortableStreamedRequest( 'GET', uri, abortTrigger: requestAbort.future, - )..headers.addAll(await auth.headersFor(videoUrl)); + )..headers.addAll(headers); late final http.StreamedResponse response; try { response = await client.send(request); @@ -145,8 +161,13 @@ class MediaVideoViewerPage extends HookConsumerWidget { return; } - final localController = VideoPlayerController.file(file); + localController = VideoPlayerController.file(file); await localController.initialize(); + if (disposed) { + await localController.dispose(); + await deleteVideoFile(); + return; + } await localController.play(); if (disposed) { await localController.dispose(); @@ -155,6 +176,7 @@ class MediaVideoViewerPage extends HookConsumerWidget { } controller.value = localController; } catch (loadError) { + await localController?.dispose(); if (!disposed) error.value = loadError.toString(); } } diff --git a/mobile/lib/features/profile/user_status_provider.dart b/mobile/lib/features/profile/user_status_provider.dart index 4e81096ec2..b8cc78642d 100644 --- a/mobile/lib/features/profile/user_status_provider.dart +++ b/mobile/lib/features/profile/user_status_provider.dart @@ -96,6 +96,8 @@ class UserStatusNotifier extends AsyncNotifier { } final privkeyHex = nostr.Nip19.decode(payload: nsec).data; + final session = ref.read(relaySessionProvider.notifier); + final lease = session.captureLease(); final event = await signEvent( kind: EventKind.userStatus, content: trimmed, @@ -103,8 +105,8 @@ class UserStatusNotifier extends AsyncNotifier { signer: LocalEventSigner(privkeyHex), ); - final session = ref.read(relaySessionProvider.notifier); - await session.publish(NostrEvent.fromJson(event.toMap())); + await session.publish(NostrEvent.fromJson(event.toMap()), lease: lease); + lease.ensureCurrent(); // Optimistic update: update own state immediately. final newStatus = (trimmed.isNotEmpty || emoji.isNotEmpty) diff --git a/mobile/lib/shared/relay/relay_session.dart b/mobile/lib/shared/relay/relay_session.dart index 983f46c976..cab56459d7 100644 --- a/mobile/lib/shared/relay/relay_session.dart +++ b/mobile/lib/shared/relay/relay_session.dart @@ -73,6 +73,24 @@ class _BufferedEvent { _BufferedEvent(this.subId, this.event); } +/// Immutable authority to finish work only in its originating session scope. +/// Capture before awaiting signing/authentication, never after it completes. +final class RelaySessionLease { + final RelaySessionNotifier _session; + final Object _scope; + + RelaySessionLease._(this._session, this._scope); + + /// Throws [RelaySessionSupersededError] if the originating scope retired. + void ensureCurrent() => _session._checkLease(this); +} + +/// An operation was cancelled because its originating relay scope retired. +class RelaySessionSupersededError extends StateError { + RelaySessionSupersededError() + : super('Relay operation cancelled: originating session scope superseded'); +} + class RelaySessionNotifier extends Notifier { RelaySessionNotifier({ http.Client? httpClient, @@ -125,6 +143,7 @@ class RelaySessionNotifier extends Notifier { bool _paused = false; bool _hasConnectedOnce = false; int _connectionGeneration = 0; + Object _scope = Object(); final Map _visibleChannelsByOwner = {}; final Map Function()> _beforePauseCallbacks = {}; bool _socketConnected = false; @@ -310,12 +329,31 @@ class RelaySessionNotifier extends Notifier { return () => _unsubscribe(subId); } + /// Capture the scope before starting asynchronous event construction. + /// Ordinary reconnects retain it; dependency rebuilds and disposal retire it. + RelaySessionLease captureLease() { + if (_disposed) throw RelaySessionSupersededError(); + return RelaySessionLease._(this, _scope); + } + + void _checkLease(RelaySessionLease lease) { + if (_disposed || + !identical(lease._session, this) || + !identical(lease._scope, _scope)) { + throw RelaySessionSupersededError(); + } + } + + /// Publish using the scope captured before signing, including after gate waits. Future publish( NostrEvent event, { + required RelaySessionLease lease, Duration timeout = const Duration(seconds: 8), }) async { + _checkLease(lease); final generation = _connectionGeneration; if (_rateLimitGate.isActive) await _rateLimitGate.wait(); + _checkLease(lease); if (!_isActiveConnection(generation) || !_socketConnected) { throw StateError('Relay session is not connected'); } @@ -338,11 +376,15 @@ class RelaySessionNotifier extends Notifier { timeout: timer, ); + // No await or consumer callback between the lease check and the socket's + // synchronous sink.add: scope replacement cannot interleave with this write. _socket?.send(['EVENT', event.toJson()]); return completer.future; } - void sendRaw(List payload) { + /// Fire-and-forget send in the scope captured before asynchronous signing. + void sendRaw(List payload, {required RelaySessionLease lease}) { + _checkLease(lease); _socket?.send(payload); } @@ -960,6 +1002,7 @@ class RelaySessionNotifier extends Notifier { void _dispose() { _disposed = true; + _scope = Object(); _beforePauseCallbacks.clear(); _connectionGeneration++; _reconnectTimer?.cancel(); diff --git a/mobile/lib/shared/relay/signed_event_relay.dart b/mobile/lib/shared/relay/signed_event_relay.dart index 2f444a2c4e..7ce5574109 100644 --- a/mobile/lib/shared/relay/signed_event_relay.dart +++ b/mobile/lib/shared/relay/signed_event_relay.dart @@ -9,8 +9,14 @@ import 'relay_session.dart'; import 'relay_socket.dart'; /// Signs and submits Nostr events through the relay WebSocket connection. +/// +/// Bound to the session scope at construction. Recreate after a dependency +/// rebuild. Signing completions in retired scopes throw +/// [RelaySessionSupersededError] without invoking [submit]'s callback or +/// publishing into the replacement scope. class SignedEventRelay { final RelaySessionNotifier _session; + final RelaySessionLease _lease; final String? _nsec; final EventSigner? _signer; @@ -18,6 +24,7 @@ class SignedEventRelay { required RelaySessionNotifier session, required String? nsec, }) : _session = session, + _lease = session.captureLease(), _nsec = nsec, _signer = null; @@ -26,6 +33,7 @@ class SignedEventRelay { required RelaySessionNotifier session, required EventSigner signer, }) : _session = session, + _lease = session.captureLease(), _signer = signer, _nsec = null; @@ -49,6 +57,7 @@ class SignedEventRelay { int? createdAt, void Function(NostrEvent event)? onSigned, }) async { + _lease.ensureCurrent(); final EventSigner signer; if (_signer case final supplied?) { signer = supplied; @@ -71,8 +80,9 @@ class SignedEventRelay { ); final nostrEvent = NostrEvent.fromJson(event.toMap()); + _lease.ensureCurrent(); onSigned?.call(nostrEvent); - return _session.publish(nostrEvent); + return _session.publish(nostrEvent, lease: _lease); } } diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 0632d4a8df..d6412a35d0 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1654,7 +1654,7 @@ packages: source: hosted version: "2.9.4" video_player_platform_interface: - dependency: transitive + dependency: "direct dev" description: name: video_player_platform_interface sha256: "57c5d73173f76d801129d0531c2774052c5a7c11ccb962f1830630decd9f24ec" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index e6806b29d1..f59b0b7c14 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -55,6 +55,7 @@ dev_dependencies: flutter_lints: ^6.0.0 fake_async: ^1.3.3 camera_platform_interface: ^2.13.1 + video_player_platform_interface: ^6.6.0 crypto: ^3.0.7 custom_lint: ^0.8.0 riverpod_lint: ^3.1.0 diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 7159fb2fd1..8ad1a273f9 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -14443,6 +14443,7 @@ class _ReconnectingRelaySession extends RelaySessionNotifier { @override Future publish( NostrEvent event, { + required RelaySessionLease lease, Duration timeout = const Duration(seconds: 8), }) async { publishedKinds.add(event.kind); @@ -14621,6 +14622,7 @@ class _ProfileSubscriptionRelaySession extends RelaySessionNotifier { @override Future publish( NostrEvent event, { + required RelaySessionLease lease, Duration timeout = const Duration(seconds: 8), }) async => event; @@ -14652,6 +14654,7 @@ class _HuddleReactionRelaySession extends RelaySessionNotifier { @override Future publish( NostrEvent event, { + required RelaySessionLease lease, Duration timeout = const Duration(seconds: 8), }) async => event; diff --git a/mobile/test/features/channels/channel_management_provider_test.dart b/mobile/test/features/channels/channel_management_provider_test.dart index 69f588622b..8340eae985 100644 --- a/mobile/test/features/channels/channel_management_provider_test.dart +++ b/mobile/test/features/channels/channel_management_provider_test.dart @@ -760,6 +760,7 @@ class _RecordingPublishRelaySession extends RelaySessionNotifier { @override Future publish( NostrEvent event, { + required RelaySessionLease lease, Duration timeout = const Duration(seconds: 8), }) async { publishedEvents.add(event); diff --git a/mobile/test/features/channels/send_message_provider_test.dart b/mobile/test/features/channels/send_message_provider_test.dart index 5766fab255..1e0f260bed 100644 --- a/mobile/test/features/channels/send_message_provider_test.dart +++ b/mobile/test/features/channels/send_message_provider_test.dart @@ -272,6 +272,7 @@ class _PendingPublishRelaySession extends RelaySessionNotifier { @override Future publish( NostrEvent event, { + required RelaySessionLease lease, Duration timeout = const Duration(seconds: 8), }) { this.event = event; diff --git a/mobile/test/features/channels/video_viewer_cancellation_test.dart b/mobile/test/features/channels/video_viewer_cancellation_test.dart new file mode 100644 index 0000000000..8cf40b47a2 --- /dev/null +++ b/mobile/test/features/channels/video_viewer_cancellation_test.dart @@ -0,0 +1,191 @@ +import 'dart:async'; + +import 'package:buzz/features/channels/media_viewer_page.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:video_player_platform_interface/video_player_platform_interface.dart'; + +class _DeferredAuth extends MediaGetAuthService { + final ready = Completer>(); + int calls = 0; + _DeferredAuth() : super(baseUrl: 'https://relay.example', nsec: null); + @override + Future> headersFor(String url) { + calls++; + return ready.future; + } +} + +class _VideoPlatform extends VideoPlayerPlatform { + final events = StreamController.broadcast(); + final sources = []; + int plays = 0; + int disposals = 0; + @override + Future init() async {} + @override + Future createWithOptions(VideoCreationOptions options) async { + sources.add(options.dataSource); + return 1; + } + + @override + Stream videoEventsFor(int playerId) => events.stream; + @override + Future dispose(int playerId) async => disposals++; + @override + Future play(int playerId) async => plays++; + @override + Future pause(int playerId) async {} + @override + Future setLooping(int playerId, bool looping) async {} + @override + Future setVolume(int playerId, double volume) async {} + @override + Future setPlaybackSpeed(int playerId, double speed) async {} + @override + Widget buildViewWithOptions(VideoViewOptions options) => const SizedBox(); + void finishInitialization() => events.add( + VideoEvent( + eventType: VideoEventType.initialized, + duration: const Duration(seconds: 10), + size: const Size(320, 180), + ), + ); +} + +void main() { + late _DeferredAuth auth; + late _VideoPlatform platform; + late VideoPlayerPlatform previousPlatform; + late int httpRequests; + + setUp(() { + previousPlatform = VideoPlayerPlatform.instance; + httpRequests = 0; + }); + tearDown(() async { + VideoPlayerPlatform.instance = previousPlatform; + await platform.events.close(); + }); + + Future open(WidgetTester tester) { + auth = _DeferredAuth(); + platform = _VideoPlatform(); + VideoPlayerPlatform.instance = platform; + return tester.pumpWidget( + ProviderScope( + overrides: [ + mediaVideoStreamingSupportedProvider.overrideWithValue( + defaultTargetPlatform == TargetPlatform.android, + ), + mediaGetAuthServiceProvider.overrideWithValue(auth), + mediaHttpClientProvider.overrideWithValue( + MockClient((_) async { + httpRequests++; + return http.Response('', 500); + }), + ), + ], + child: const MaterialApp( + home: MediaVideoViewerPage( + videoUrl: 'https://relay.example/media/video.mp4', + ), + ), + ), + ); + } + + for (final target in [TargetPlatform.android, TargetPlatform.iOS]) { + testWidgets( + '$target disposal during auth creates no player or request', + (tester) async { + await open(tester); + expect(auth.calls, 1); + await tester.pumpWidget(const SizedBox()); + auth.ready.complete({'Authorization': 'captured A auth'}); + await tester.pump(); + expect(platform.sources, isEmpty); + expect(platform.plays, 0); + expect(httpRequests, 0); + expect(tester.takeException(), isNull); + }, + variant: TargetPlatformVariant.only(target), + ); + } + + testWidgets( + 'disposal during initialization cleans up without play or fallback', + (tester) async { + await open(tester); + auth.ready.complete({'Authorization': 'captured A auth'}); + await tester.pump(); + expect(platform.sources.single.httpHeaders, { + 'Authorization': 'captured A auth', + }); + await tester.pumpWidget(const SizedBox()); + platform.finishInitialization(); + await tester.pump(); + expect(platform.plays, 0); + // Stream cancellation can complete outside the widget fake-async zone. + await tester.runAsync(() => Future.delayed(Duration.zero)); + await tester.pump(); + expect(platform.disposals, 1); + expect(httpRequests, 0); + expect(auth.calls, 1); + expect(tester.takeException(), isNull); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); + + testWidgets( + 'initialization failure after disposal does not enter fallback', + (tester) async { + await open(tester); + auth.ready.complete({}); + await tester.pump(); + await tester.pumpWidget(const SizedBox()); + platform.events.addError( + PlatformException(code: 'init', message: 'initialization failed'), + ); + await tester.pump(); + // Stream cancellation can complete outside the widget fake-async zone. + await tester.runAsync(() => Future.delayed(Duration.zero)); + await tester.pump(); + expect(platform.disposals, 1); + expect(platform.plays, 0); + expect(auth.calls, 1); + expect(httpRequests, 0); + expect(tester.takeException(), isNull); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); + + testWidgets( + 'live viewer still initializes, plays, and disposes normally', + (tester) async { + await open(tester); + auth.ready.complete({'Authorization': 'captured A auth'}); + await tester.pump(); + platform.finishInitialization(); + await tester.pump(); + expect(platform.sources, hasLength(1)); + expect(platform.plays, 1); + expect(platform.disposals, 0); + expect(httpRequests, 0); + await tester.pumpWidget(const SizedBox()); + // Stream cancellation can complete outside the widget fake-async zone. + await tester.runAsync(() => Future.delayed(Duration.zero)); + await tester.pump(); + expect(platform.disposals, 1); + expect(tester.takeException(), isNull); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); +} diff --git a/mobile/test/features/profile/profile_provider_test.dart b/mobile/test/features/profile/profile_provider_test.dart index 93c68cfa20..9ef424bd6b 100644 --- a/mobile/test/features/profile/profile_provider_test.dart +++ b/mobile/test/features/profile/profile_provider_test.dart @@ -625,6 +625,7 @@ class _ProfileRelaySession extends RelaySessionNotifier { @override Future publish( NostrEvent event, { + required RelaySessionLease lease, Duration timeout = const Duration(seconds: 8), }) async { published.add(event); @@ -660,6 +661,7 @@ class _ControlledProfileRelaySession extends RelaySessionNotifier { @override Future publish( NostrEvent event, { + required RelaySessionLease lease, Duration timeout = const Duration(seconds: 8), }) async { published.add(event); @@ -686,6 +688,7 @@ class _LosingProfileRelaySession extends RelaySessionNotifier { @override Future publish( NostrEvent event, { + required RelaySessionLease lease, Duration timeout = const Duration(seconds: 8), }) async { published.add(event); diff --git a/mobile/test/features/profile/user_status_provider_test.dart b/mobile/test/features/profile/user_status_provider_test.dart index e57d066a7a..759f95a1b8 100644 --- a/mobile/test/features/profile/user_status_provider_test.dart +++ b/mobile/test/features/profile/user_status_provider_test.dart @@ -192,6 +192,7 @@ class _RecordingRelaySession extends RelaySessionNotifier { @override Future publish( NostrEvent event, { + required RelaySessionLease lease, Duration timeout = const Duration(seconds: 8), }) async { published.add(event); diff --git a/mobile/test/shared/auth/event_signer_test.dart b/mobile/test/shared/auth/event_signer_test.dart index 95d9c34262..015ef1185f 100644 --- a/mobile/test/shared/auth/event_signer_test.dart +++ b/mobile/test/shared/auth/event_signer_test.dart @@ -27,6 +27,7 @@ final class _Session extends RelaySessionNotifier { @override Future publish( NostrEvent event, { + required RelaySessionLease lease, Duration timeout = const Duration(seconds: 8), }) async { published = event; diff --git a/mobile/test/shared/relay/relay_session_test.dart b/mobile/test/shared/relay/relay_session_test.dart index 39e81e39c3..a598546eea 100644 --- a/mobile/test/shared/relay/relay_session_test.dart +++ b/mobile/test/shared/relay/relay_session_test.dart @@ -1410,7 +1410,7 @@ void main() { final session = RelaySessionNotifier(rateLimitGate: gate); session.debugAttachSocketForTest(_RecordingRelaySocket()); - final publish = session.publish(_event()); + final publish = session.publish(_event(), lease: session.captureLease()); session.debugHandleMessage([ 'OK', 'event-1', @@ -1446,7 +1446,10 @@ void main() { final session = RelaySessionNotifier(rateLimitGate: gate); session.debugAttachSocketForTest(socket); - final firstPublish = session.publish(_event(id: 'event-a')); + final firstPublish = session.publish( + _event(id: 'event-a'), + lease: session.captureLease(), + ); session.debugHandleMessage([ 'OK', 'event-a', @@ -1457,6 +1460,7 @@ void main() { var secondSettled = false; final secondPublish = session.publish( + lease: session.captureLease(), _event(id: 'event-b'), timeout: Duration.zero, ); @@ -1505,7 +1509,10 @@ void main() { session.debugAttachSocketForTest(socket); gate.activate(4); - final publish = session.publish(_event(id: 'event-b')); + final publish = session.publish( + _event(id: 'event-b'), + lease: session.captureLease(), + ); session.debugSupersedeConnection(); gateTimers.single.fire(); @@ -1519,7 +1526,7 @@ void main() { final session = RelaySessionNotifier(rateLimitGate: gate); session.debugAttachSocketForTest(_RecordingRelaySocket()); - final publish = session.publish(_event()); + final publish = session.publish(_event(), lease: session.captureLease()); session.debugHandleMessage([ 'OK', 'event-1', diff --git a/mobile/test/shared/relay/signed_event_scope_test.dart b/mobile/test/shared/relay/signed_event_scope_test.dart new file mode 100644 index 0000000000..63533d789b --- /dev/null +++ b/mobile/test/shared/relay/signed_event_scope_test.dart @@ -0,0 +1,343 @@ +import 'dart:async'; + +import 'package:buzz/shared/auth/event_signer.dart'; +import 'package:buzz/features/profile/user_status.dart'; +import 'package:buzz/features/profile/user_status_provider.dart'; +import 'package:buzz/features/profile/user_status_cache_provider.dart'; +import 'package:buzz/shared/auth/auth_provider.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; + +class _Auth extends AuthNotifier { + @override + Future build() async => + const AuthState(status: AuthStatus.unauthenticated); +} + +class _Config extends RelayConfigNotifier { + final String nsec; + _Config(this.nsec); + @override + RelayConfig build() => RelayConfig(baseUrl: 'https://a.example', nsec: nsec); + void switchCommunity() => + state = RelayConfig(baseUrl: 'https://b.example', nsec: nsec); +} + +class _Signer implements EventSigner { + final LocalEventSigner local; + final ready = Completer(); + _Signer(this.local); + @override + String get publicKey => local.publicKey; + @override + Future sign(UnsignedEvent event) async { + await ready.future; + return local.sign(event); + } +} + +class _Socket extends RelaySocket { + final RelaySessionNotifier session; + final messages = >[]; + void Function()? afterAck; + _Socket(this.session) + : super( + wsUrl: 'wss://recording.example', + nsec: null, + onMessage: (_) {}, + onConnected: () {}, + onDisconnected: (_) {}, + ); + @override + void send(List payload) { + messages.add(payload); + if (payload.first == 'EVENT') { + session.debugHandleMessage(['OK', (payload[1] as Map)['id'], true, 'ok']); + afterAck?.call(); + } + } + + @override + void dispose() {} +} + +class _StatusCache extends UserStatusCacheNotifier { + final updates = []; + @override + Map build() => {}; + @override + void updateStatus(String pubkey, UserStatus? status) => updates.add(status); +} + +class _Harness { + final keys = nostr.Keys.generate(); + late final config = _Config(keys.nsec); + final gate = RelayRateLimitGate(); + final cache = _StatusCache(); + late final session = RelaySessionNotifier(rateLimitGate: gate); + late final container = ProviderContainer( + overrides: [ + userStatusCacheProvider.overrideWith(() => cache), + authProvider.overrideWith(_Auth.new), + relayConfigProvider.overrideWith(() => config), + relaySessionProvider.overrideWith(() => session), + ], + ); + late final a = _Socket(session); + + Future initialize() async { + addTearDown(container.dispose); + await container.read(authProvider.future); + container.read(relaySessionProvider); + session.debugAttachSocketForTest(a); + } + + _Socket switchCommunity() { + config.switchCommunity(); + container.read(relaySessionProvider); + expect(container.read(relaySessionProvider.notifier), same(session)); + final b = _Socket(session); + session.debugAttachSocketForTest(b); + return b; + } + + SignedEventRelay relay(EventSigner signer) => + SignedEventRelay.withSigner(session: session, signer: signer); +} + +Future _submit( + SignedEventRelay relay, { + void Function(NostrEvent)? onSigned, +}) => relay.submit( + kind: 40002, + content: 'belongs to A', + tags: const [ + ['h', 'channel-a'], + ], + onSigned: onSigned, +); + +void main() { + test( + 'deferred A signing cannot callback or publish on same-key community B', + () async { + final h = _Harness(); + await h.initialize(); + final signer = _Signer(LocalEventSigner(h.keys.secret)); + var callbacks = 0; + final pending = _submit(h.relay(signer), onSigned: (_) => callbacks++); + final assertion = expectLater( + pending, + throwsA(isA()), + ); + final b = h.switchCommunity(); + signer.ready.complete(); + await assertion; + expect(h.a.messages, isEmpty); + expect( + b.messages, + isEmpty, + reason: 'A event must never reach B transport', + ); + expect(callbacks, 0); + }, + ); + + test( + 'dependency invalidation cancels even before the next session read', + () async { + final h = _Harness(); + await h.initialize(); + final signer = _Signer(LocalEventSigner(h.keys.secret)); + var callbacks = 0; + final pending = _submit(h.relay(signer), onSigned: (_) => callbacks++); + final assertion = expectLater( + pending, + throwsA(isA()), + ); + h.config.switchCommunity(); + signer.ready.complete(); + await assertion; + expect(h.a.messages, isEmpty); + expect(callbacks, 0); + }, + ); + + test( + 'normal delayed signing callbacks once then publishes exact event', + () async { + final h = _Harness(); + await h.initialize(); + final signer = _Signer(LocalEventSigner(h.keys.secret)); + final callbacks = []; + final pending = _submit( + h.relay(signer), + onSigned: (event) { + expect(h.a.messages, isEmpty); + callbacks.add(event); + }, + ); + expect(callbacks, isEmpty); + signer.ready.complete(); + final result = await pending; + expect(callbacks, hasLength(1)); + expect(h.a.messages.single, ['EVENT', callbacks.single.toJson()]); + expect(result.id, callbacks.single.id); + expect(result.content, 'ok'); + }, + ); + + test('retained signed relay cannot start new work after rebuild', () async { + final h = _Harness(); + await h.initialize(); + final relay = h.relay(LocalEventSigner(h.keys.secret)); + final b = h.switchCommunity(); + await expectLater( + _submit(relay), + throwsA(isA()), + ); + expect(b.messages, isEmpty); + }); + + test( + 'callback switching scope is fenced again at production publish', + () async { + final h = _Harness(); + await h.initialize(); + late _Socket b; + await expectLater( + _submit( + h.relay(LocalEventSigner(h.keys.secret)), + onSigned: (_) => b = h.switchCommunity(), + ), + throwsA(isA()), + ); + expect(h.a.messages, isEmpty); + expect(b.messages, isEmpty); + }, + ); + + test('direct publication cannot reuse a retired or foreign lease', () async { + final h = _Harness(); + await h.initialize(); + final lease = h.session.captureLease(); + final signed = await LocalEventSigner(h.keys.secret).sign( + UnsignedEvent( + publicKey: h.keys.public, + createdAt: 1, + kind: 1, + content: '', + tags: const [], + ), + ); + final event = NostrEvent.fromJson(signed.toMap()); + final b = h.switchCommunity(); + for (final invalid in [lease, RelaySessionNotifier().captureLease()]) { + await expectLater( + h.session.publish(event, lease: invalid), + throwsA(isA()), + ); + expect( + () => h.session.sendRaw(['EVENT', event.toJson()], lease: invalid), + throwsA(isA()), + ); + } + expect(b.messages, isEmpty); + }); + + test( + 'scope retires while production publish waits on rate-limit gate', + () async { + final h = _Harness(); + await h.initialize(); + final callback = Completer(); + h.gate.activate(30); + final pending = _submit( + h.relay(LocalEventSigner(h.keys.secret)), + onSigned: (_) => callback.complete(), + ); + final assertion = expectLater( + pending, + throwsA(isA()), + ); + await callback.future; + expect(h.a.messages, isEmpty); + final b = h.switchCommunity(); + await assertion; + expect(b.messages, isEmpty); + }, + ); + + test( + 'ordinary connection supersession does not retire the scope lease', + () async { + final h = _Harness(); + await h.initialize(); + final signer = _Signer(LocalEventSigner(h.keys.secret)); + final pending = _submit(h.relay(signer)); + h.session.debugSupersedeConnection(); + signer.ready.complete(); + await pending; + expect(h.a.messages, hasLength(1)); + }, + ); + + test( + 'user status direct signer cannot publish across community switch', + () async { + final h = _Harness(); + await h.initialize(); + await h.container.read(userStatusProvider.future); + final pending = h.container + .read(userStatusProvider.notifier) + .setStatus('A status', ''); + final assertion = expectLater( + pending, + throwsA(isA()), + ); + final b = h.switchCommunity(); + await assertion; + expect(h.a.messages, isEmpty); + expect(b.messages, isEmpty); + expect(h.cache.updates, isEmpty); + }, + ); + + test( + 'user status cannot update B state/cache after an A acknowledgement', + () async { + final h = _Harness(); + await h.initialize(); + await h.container.read(userStatusProvider.future); + late _Socket b; + h.a.afterAck = () => b = h.switchCommunity(); + await expectLater( + h.container.read(userStatusProvider.notifier).setStatus('A status', ''), + throwsA(isA()), + ); + expect(h.a.messages, hasLength(1)); + expect(b.messages, isEmpty); + expect(h.cache.updates, isEmpty); + expect(await h.container.read(userStatusProvider.future), isNull); + }, + ); + + test('disposal while signing cancels without callback or send', () async { + final h = _Harness(); + await h.initialize(); + final signer = _Signer(LocalEventSigner(h.keys.secret)); + var callbacks = 0; + final pending = _submit(h.relay(signer), onSigned: (_) => callbacks++); + final assertion = expectLater( + pending, + throwsA(isA()), + ); + h.session.debugDispose(); + signer.ready.complete(); + await assertion; + expect(h.a.messages, isEmpty); + expect(callbacks, 0); + }); +} diff --git a/mobile/test/shared/theme/community_theme_provider_test.dart b/mobile/test/shared/theme/community_theme_provider_test.dart index c63356d3b8..b175ed3586 100644 --- a/mobile/test/shared/theme/community_theme_provider_test.dart +++ b/mobile/test/shared/theme/community_theme_provider_test.dart @@ -327,6 +327,7 @@ class _ThemeRelaySession extends RelaySessionNotifier { @override Future publish( NostrEvent event, { + required RelaySessionLease lease, Duration timeout = const Duration(seconds: 8), }) async { published = event;