From a22b79fa8c6c2db81f37953e9c0f7b168b1043d6 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 9 Sep 2026 22:29:24 -0400 Subject: [PATCH] fix(mobile): preserve SEND scope and authority at owner 7534 Signed-off-by: Logan Johnson --- mobile/lib/features/channels/compose_bar.dart | 1 + .../compose_bar/compose_bar_widget.dart | 125 ++++++---- .../channels/compose_bar/draft_lifecycle.dart | 6 +- .../channels/compose_bar/helpers.dart | 56 ++++- .../selected_mention_preparation.dart | 61 +++++ .../shared/relay/relay_evidence_clock.dart | 91 ++++++++ .../features/channels/compose_bar_test.dart | 14 +- .../classification_tests.dart | 213 ++++++++++++++++++ .../compose_bar_test/publication_tests.dart | 27 ++- 9 files changed, 518 insertions(+), 76 deletions(-) create mode 100644 mobile/lib/features/channels/compose_bar/selected_mention_preparation.dart create mode 100644 mobile/lib/shared/relay/relay_evidence_clock.dart create mode 100644 mobile/test/features/channels/compose_bar_test/classification_tests.dart diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index 726528a23e6..02610606520 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -49,6 +49,7 @@ import 'voice_note_composer_recorder.dart'; import 'voice_note_recording.dart'; part 'compose_bar/helpers.dart'; +part 'compose_bar/selected_mention_preparation.dart'; part 'compose_bar/agent_mention_labels.dart'; part 'compose_bar/markdown_editing_controller.dart'; part 'compose_bar/draft_lifecycle.dart'; diff --git a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart index e23356e2f01..a1213e895d1 100644 --- a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart +++ b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart @@ -485,50 +485,60 @@ class ComposeBar extends HookConsumerWidget { var authorizationRevision = submittedDraftRevision; final visit = authorizationVisit.value; final config = ref.read(relayConfigProvider); - final readAuthorization = ref.read(agentAuthorizationReaderProvider); + // Equivalent refreshes retain scope; destination/credentials do not. + bool isConfigScopeCurrent() { + final current = ref.read(relayConfigProvider); + return current.baseUrl == config.baseUrl && + current.nsec == config.nsec; + } + + final readSelected = ref.read( + selectedMentionAuthorizationReaderProvider, + ); + final session = ref.read(relaySessionProvider.notifier); + final observedProfiles = {}; + final observedKeys = {}; + bool profilesCurrent() => observedProfiles.entries.every((entry) { + final order = ref + .read(userCacheProvider.notifier) + .profileEventOrder(entry.key); + final event = entry.value; + return order == null || + order.createdAt < event.createdAt || + (order.createdAt == event.createdAt && + order.eventId.compareTo(event.id) >= 0); + }); bool ownsSource() => context.mounted && visit == authorizationVisit.value && - identical(config, ref.read(relayConfigProvider)); + isConfigScopeCurrent(); bool isAuthorizationCurrent() => ownsSource() && + identical(session, ref.read(relaySessionProvider.notifier)) && + currentPubkey == ref.read(currentPubkeyProvider) && + profilesCurrent() && submittedUploadGeneration == uploadGeneration.value && authorizationRevision == draftRevision.value && - identical(config, ref.read(relayConfigProvider)); + isConfigScopeCurrent(); void ensureAuthorizationCurrent() { if (!context.mounted) throw const _ComposeAuthorizationCancelled(); - if (!identical(config, ref.read(relayConfigProvider))) { - throw StateError('Community changed during authorization'); + if (!isConfigScopeCurrent()) { + throw const _ComposeCommunityChanged(); } if (submittedUploadGeneration != uploadGeneration.value || visit != authorizationVisit.value || authorizationRevision != draftRevision.value) { throw const _ComposeAuthorizationCancelled(); } + if (!identical(session, ref.read(relaySessionProvider.notifier)) || + currentPubkey != ref.read(currentPubkeyProvider) || + !profilesCurrent()) { + throw Exception('Mention evidence changed; retry the draft'); + } } checkPreparationCurrent = ensureAuthorizationCurrent; - Future authorize(Set keys, {bool prepare = false}) async { - if (keys.isEmpty) return; - ensureAuthorizationCurrent(); - try { - await authorizeAgentMentions( - readAuthorization, - keys, - currentPubkey, - channelId, - isAuthorizationCurrent, - prepare: prepare, - ); - } catch (_) { - // A stale request is cancellation, not an access decision. - ensureAuthorizationCurrent(); - rethrow; - } - ensureAuthorizationCurrent(); - } - // Resolved before any await: see // `_reportSendCancelledByCommunitySwitch`. final messenger = ScaffoldMessenger.maybeOf(context); @@ -542,15 +552,34 @@ class ComposeBar extends HookConsumerWidget { selectedMentions, '${ref.read(relayConfigProvider).baseUrl} / $channelId', ); - final intendedAgentKeys = { + final selectedKeys = outgoing.pubkeys.toSet(); + final priorAgentKeys = { for (final mention in selectedMentions) if (mention.isAgent) mention.pubkey.toLowerCase(), }; + Future> authorize( + Set keys, { + bool prepare = false, + }) => _authorizeSelectedMentions( + keys, + readSelected: readSelected, + priorAgentKeys: priorAgentKeys, + observedKeys: observedKeys, + observedProfiles: observedProfiles, + currentPubkey: currentPubkey, + channelId: channelId, + ensureAuthorizationCurrent: ensureAuthorizationCurrent, + isAuthorizationCurrent: isAuthorizationCurrent, + prepare: prepare, + ); + + final initialEvidence = await authorize(selectedKeys, prepare: true); final scan = await _scanNonMemberMentions( ref, channelId: channelId, selectedMentions: selectedMentions, currentPubkey: currentPubkey, + evidence: initialEvidence, ); ensureAuthorizationCurrent(); @@ -583,7 +612,18 @@ class ComposeBar extends HookConsumerWidget { // Agent failures stop publication; the original draft keeps its keys. Future addMentionedNonMembers() async { - final keys = intendedAgentKeys.intersection(outgoing.pubkeys.toSet()); + final keys = outgoing.pubkeys.toSet(); + Future authorizeWrite(String key, String role) async { + final evidence = await authorize(keys, prepare: true); + final fresh = evidence[key]!; + if (fresh.invitationRole != role) { + throw Exception( + 'Mention classification changed; retry invitation consent', + ); + } + return !fresh.isMember; + } + await authorize(keys, prepare: true); ensureAuthorizationCurrent(); invitationStarted.value = true; @@ -592,6 +632,7 @@ class ComposeBar extends HookConsumerWidget { scan: scan, messenger: messenger, ensureCurrent: ensureAuthorizationCurrent, + authorizeWrite: authorizeWrite, ); if (!outgoing.pubkeys.toSet().containsAll(keys)) { throw Exception( @@ -600,7 +641,7 @@ class ComposeBar extends HookConsumerWidget { } await authorize(keys); if (queuedAttachments.isEmpty || - (intendedAgentKeys.isNotEmpty || + (selectedKeys.isNotEmpty || scan.humans.isNotEmpty || scan.agentPubkeys.isNotEmpty)) { ensureAuthorizationCurrent(); @@ -639,7 +680,7 @@ class ComposeBar extends HookConsumerWidget { ); // Agent authorization is preparation, not a detached background send. final preparingAgents = - intendedAgentKeys.isNotEmpty || scan.humans.isNotEmpty; + selectedKeys.isNotEmpty || scan.humans.isNotEmpty; if (!preparingAgents) clearComposer(); final clearedDraftRevision = draftRevision.value; authorizationRevision = clearedDraftRevision; @@ -697,8 +738,11 @@ class ComposeBar extends HookConsumerWidget { } on _ComposeAuthorizationCancelled { // Keep the newer draft without displaying a false access error. } catch (error) { - if (cancellation.isCancelled) return; - if (error is StateError) { + if (cancellation.isCancelled && + error is! _ComposeCommunityChanged) { + return; + } + if (error is _ComposeCommunityChanged) { _reportSendCancelledByCommunitySwitch(messenger); } else if (context.mounted) { uploadError.value = _formatUploadError(error); @@ -735,25 +779,8 @@ class ComposeBar extends HookConsumerWidget { } }()); } catch (error) { - var communityChanged = false; - // Failed awaits need the same scope/edit classification as success. - try { - checkPreparationCurrent?.call(); - if (error is _ComposeAuthorizationCancelled) return; - } on _ComposeAuthorizationCancelled { - return; - } on StateError { - communityChanged = true; - } if (context.mounted) { - final messenger = ScaffoldMessenger.maybeOf(context); - if (communityChanged) { - _reportSendCancelledByCommunitySwitch(messenger); - } else { - messenger?.showSnackBar( - SnackBar(content: Text(_composeSendErrorMessage(error))), - ); - } + _reportComposeSendError(context, error, checkPreparationCurrent); } } finally { if (context.mounted && authorizationAttempt.value == attempt) { diff --git a/mobile/lib/features/channels/compose_bar/draft_lifecycle.dart b/mobile/lib/features/channels/compose_bar/draft_lifecycle.dart index d412288d01a..e899fef2267 100644 --- a/mobile/lib/features/channels/compose_bar/draft_lifecycle.dart +++ b/mobile/lib/features/channels/compose_bar/draft_lifecycle.dart @@ -4,6 +4,10 @@ class _ComposeAuthorizationCancelled implements Exception { const _ComposeAuthorizationCancelled(); } +class _ComposeCommunityChanged implements Exception { + const _ComposeCommunityChanged(); +} + Future _sendTextOnlyDraft({ required BuildContext context, required _MarkdownEditingController controller, @@ -59,7 +63,7 @@ Future _sendTextOnlyDraft({ delivered = true; } on _ComposeAuthorizationCancelled { restoreClearedDraft(); - } on StateError { + } on _ComposeCommunityChanged { restoreClearedDraft(); _reportSendCancelledByCommunitySwitch(messenger); } catch (error) { diff --git a/mobile/lib/features/channels/compose_bar/helpers.dart b/mobile/lib/features/channels/compose_bar/helpers.dart index 42d143c8ed0..0bd8e7395d1 100644 --- a/mobile/lib/features/channels/compose_bar/helpers.dart +++ b/mobile/lib/features/channels/compose_bar/helpers.dart @@ -1,5 +1,35 @@ part of '../compose_bar.dart'; +void _reportComposeSendError( + BuildContext context, + Object error, + VoidCallback? checkPreparationCurrent, +) { + var displayError = error; + var communityChanged = false; + // Failed awaits need the same scope/edit classification as success. + try { + checkPreparationCurrent?.call(); + if (error is _ComposeAuthorizationCancelled) return; + } on _ComposeAuthorizationCancelled { + return; + } on _ComposeCommunityChanged { + communityChanged = true; + } on Exception catch (currentError) { + displayError = currentError; + } + if (context.mounted) { + final messenger = ScaffoldMessenger.maybeOf(context); + if (communityChanged) { + _reportSendCancelledByCommunitySwitch(messenger); + } else { + messenger?.showSnackBar( + SnackBar(content: Text(_composeSendErrorMessage(displayError))), + ); + } + } +} + String _composerDraftIdentity(WidgetRef ref) => '${ref.watch(relayConfigProvider).baseUrl}' ':${ref.watch(myPubkeyProvider) ?? 'anon'}'; @@ -450,6 +480,7 @@ Future<_NonMemberAddOutcome> _addMentionedNonMembers( required bool canAddMembers, required VoidCallback ensureCurrent, required VoidCallback onAccepted, + required Future Function(String, String) authorizeWrite, }) async { final pending = [ for (final pubkey in agentPubkeys) ([pubkey], 'bot'), @@ -469,8 +500,10 @@ Future<_NonMemberAddOutcome> _addMentionedNonMembers( final notAdded = []; final errors = []; for (final (pubkeys, role) in pending) { + ensureCurrent(); + if (!await authorizeWrite(pubkeys.single, role)) continue; + ensureCurrent(); try { - ensureCurrent(); await channelActions.addMembers( channelId: channelId, pubkeys: pubkeys, @@ -480,7 +513,10 @@ Future<_NonMemberAddOutcome> _addMentionedNonMembers( ensureCurrent(); } on _ComposeAuthorizationCancelled { rethrow; + } on _ComposeCommunityChanged { + rethrow; } on StateError { + ensureCurrent(); rethrow; } catch (error) { notAdded.addAll( @@ -517,6 +553,7 @@ Future<_NonMemberMentionScan> _scanNonMemberMentions( required String channelId, required List selectedMentions, required String? currentPubkey, + required Map evidence, }) async { final none = _NonMemberMentionScan( channelId: channelId, @@ -533,9 +570,6 @@ Future<_NonMemberMentionScan> _scanNonMemberMentions( ).wait; final channel = channels.firstWhere((candidate) => candidate.id == channelId); if (channel.isDm) return none; - final memberPubkeys = { - for (final member in members) member.pubkey.toLowerCase(), - }; String? selfRole; if (currentPubkey != null) { final self = currentPubkey.toLowerCase(); @@ -552,8 +586,9 @@ Future<_NonMemberMentionScan> _scanNonMemberMentions( final seen = {}; for (final candidate in selectedMentions) { final pubkey = candidate.pubkey.toLowerCase(); - if (memberPubkeys.contains(pubkey) || !seen.add(pubkey)) continue; - if (candidate.isAgent) { + final fresh = evidence[pubkey]!; + if (fresh.isMember || !seen.add(pubkey)) continue; + if (fresh.invitationRole == 'bot') { agentPubkeys.add(pubkey); } else { humans.add(candidate); @@ -621,8 +656,7 @@ class _OutgoingMentions { case _NonMemberMentionChoice.invite: _inviteAgents = true; _invitedHumanPubkeys = [ - for (final candidate in nonMembers) - if (!candidate.isAgent) candidate.pubkey.toLowerCase(), + for (final candidate in nonMembers) candidate.pubkey.toLowerCase(), ]; case _NonMemberMentionChoice.sendWithoutInviting: demote(nonMembers.map((candidate) => candidate.pubkey)); @@ -635,15 +669,19 @@ class _OutgoingMentions { required _NonMemberMentionScan scan, required ScaffoldMessengerState? messenger, required VoidCallback ensureCurrent, + required Future Function(String, String) authorizeWrite, }) async { final outcome = await _addMentionedNonMembers( channelActions, channelId: scan.channelId, agentPubkeys: _inviteAgents ? scan.agentPubkeys : const [], - humanPubkeys: _invitedHumanPubkeys, + humanPubkeys: _invitedHumanPubkeys + .where((key) => !scan.agentPubkeys.contains(key)) + .toList(), canAddMembers: scan.canAddMembers, ensureCurrent: ensureCurrent, onAccepted: () => acceptedInvitations++, + authorizeWrite: authorizeWrite, ); if (outcome.notAdded.isNotEmpty) { throw Exception('Message not sent. ${outcome.errors.join(' ')}'); diff --git a/mobile/lib/features/channels/compose_bar/selected_mention_preparation.dart b/mobile/lib/features/channels/compose_bar/selected_mention_preparation.dart new file mode 100644 index 00000000000..f5a92ba3744 --- /dev/null +++ b/mobile/lib/features/channels/compose_bar/selected_mention_preparation.dart @@ -0,0 +1,61 @@ +part of '../compose_bar.dart'; + +// One preparation owner retains the exact evidence and denial-only taint across +// consent, individual invitations and final publication reads. +Future> _authorizeSelectedMentions( + Set keys, { + required SelectedMentionAuthorizationReader readSelected, + required Set priorAgentKeys, + required Set observedKeys, + required Map observedProfiles, + required String? currentPubkey, + required String channelId, + required VoidCallback ensureAuthorizationCurrent, + required bool Function() isAuthorizationCurrent, + bool prepare = false, +}) async { + ensureAuthorizationCurrent(); + if (keys.isEmpty) return const {}; + try { + final evidence = await readSelected( + keys, + priorAgentKeys.intersection(keys), + currentPubkey ?? '', + channelId, + isAuthorizationCurrent, + (profiles) { + for (final key in keys) { + if (observedKeys.contains(key) && + observedProfiles[key]?.id != profiles[key]?.id) { + throw Exception('Mention evidence changed; retry the draft'); + } + } + observedKeys.addAll(keys); + observedProfiles.addAll(profiles); + }, + ); + ensureAuthorizationCurrent(); + if (evidence.length != keys.length || + !evidence.keys.toSet().containsAll(keys)) { + throw Exception('Incomplete selected mention evidence'); + } + final agents = { + for (final key in keys) + if (evidence[key]!.requiresAgentAuthorization) key, + }; + priorAgentKeys.addAll(agents); + await authorizeAgentMentions( + (_, _, _, _) async => [for (final key in agents) ?evidence[key]!.agent], + agents, + currentPubkey, + channelId, + isAuthorizationCurrent, + prepare: prepare, + ); + ensureAuthorizationCurrent(); + return evidence; + } catch (_) { + ensureAuthorizationCurrent(); + rethrow; + } +} diff --git a/mobile/lib/shared/relay/relay_evidence_clock.dart b/mobile/lib/shared/relay/relay_evidence_clock.dart new file mode 100644 index 00000000000..32cbf75d064 --- /dev/null +++ b/mobile/lib/shared/relay/relay_evidence_clock.dart @@ -0,0 +1,91 @@ +import '../crypto/signed_event.dart'; +import 'nostr_models.dart'; + +typedef _Coordinate = (int, String, String?); +typedef _Order = (int, String); + +bool _newer(_Order a, _Order b) => + a.$1 > b.$1 || (a.$1 == b.$1 && a.$2.compareTo(b.$2) < 0); + +class _EvidenceCell { + _Order? latest; + bool retired = false; +} + +/// Bounded session-local observation order, not an authorization/profile cache. +/// Only signed events observed on this session can invalidate a query snapshot. +/// Eviction retires just the affected coordinate's outstanding capabilities. +class RelayEvidenceClock { + final _cells = <_Coordinate, _EvidenceCell>{}; + static const _capacity = 8192; + + _EvidenceCell _cell(_Coordinate key) { + final cell = _cells.remove(key) ?? _EvidenceCell(); + _cells[key] = cell; + while (_cells.length > _capacity) { + _cells.remove(_cells.keys.first)!.retired = true; + } + return cell; + } + + /// Retire outstanding capabilities on session rebuild/identity changes. + void clear() { + for (final cell in _cells.values) { + cell.retired = true; + } + _cells.clear(); + } + + /// Observe immediately on socket receipt, before UI batching/debouncing. + void observe(NostrEvent event) { + if (!const [0, 10100, 30177, 39002].contains(event.kind) || + !verifySignedEvent(event)) { + return; + } + final identifiers = event.kind >= 30000 + ? event.tags + .where((t) => t.length >= 2 && t[0] == 'd') + .map((t) => t[1]) + .toSet() + : {null}; + for (final identifier in identifiers) { + final cell = _cell((event.kind, event.pubkey, identifier)); + final order = (event.createdAt, event.id); + if (cell.latest == null || _newer(order, cell.latest!)) { + cell.latest = order; + } + } + } + + /// Retain a coordinate across an in-flight read. Capacity eviction fails + /// that read closed instead of making lost evidence look absent again. + bool Function() retain(int kind, String author, String? identifier) { + final cell = _cell((kind, author, identifier)); + return () => !cell.retired; + } + + /// Fence an exact query's newest head, including negative/absent evidence. + /// Unrelated authors/coordinates never invalidate this capability. Capturing + /// after query completion still compares against events observed during it. + bool Function() snapshot( + int kind, + String author, + String? identifier, + Iterable events, + ) { + final cell = _cell((kind, author, identifier)); + _Order? head; + for (final event in events) { + if (event.kind != kind || + event.pubkey != author || + (kind >= 30000 && event.getTagValue('d') != identifier)) { + continue; + } + final order = (event.createdAt, event.id); + if (head == null || _newer(order, head)) head = order; + } + return () => + !cell.retired && + (cell.latest == null || (head != null && !_newer(cell.latest!, head))); + } +} diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index 39078101d00..46680c6c3e8 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -34,6 +34,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../../shared/mentions/agent_policy_test.dart' show signed; part 'compose_bar_test/publication_tests.dart'; +part 'compose_bar_test/classification_tests.dart'; part 'compose_bar_test/send_lifecycle_tests.dart'; part 'compose_bar_test/invitation_tests.dart'; @@ -233,18 +234,6 @@ Widget _buildComposeBar({ (ref) => membersLoader?.call() ?? membersFuture ?? Future.value(members), ), - agentAuthorizationReaderProvider.overrideWithValue( - authorizationReader ?? - (keys, viewer, channel, current) async => [ - for (final key in keys) - AgentDirectoryEntry( - pubkey: key, - respondTo: 'anyone', - ownerPubkey: viewer, - channelIds: [channel], - ), - ], - ), if (relayHttpClient == null || selectedReader != null) selectedMentionAuthorizationReaderProvider.overrideWithValue( selectedReader ?? @@ -760,6 +749,7 @@ class _FakeChannelsNotifier extends ChannelsNotifier { void main() { _publicationTests(); + classificationTests(); sendLifecycleTests(); invitationTests(); TestWidgetsFlutterBinding.ensureInitialized(); diff --git a/mobile/test/features/channels/compose_bar_test/classification_tests.dart b/mobile/test/features/channels/compose_bar_test/classification_tests.dart new file mode 100644 index 00000000000..aa1dc23e038 --- /dev/null +++ b/mobile/test/features/channels/compose_bar_test/classification_tests.dart @@ -0,0 +1,213 @@ +part of '../compose_bar_test.dart'; + +void classificationTests() { + for (final mode in [ + 'equivalent config', + 'credential change', + 'relay change', + 'ordinary member', + 'ordinary invite', + 'fresh agent', + 'denied agent', + 'tainted unknown', + 'missing key', + 'consent change', + 'perwrite change', + 'revision change', + 'policy change', + 'accepted prefix', + 'accepted cancellation', + ]) { + testWidgets('fresh selected classification $mode', (tester) async { + final signer = nostr.Keys.generate(); + final key = 'a' * 64; + final savedAgent = mode == 'tainted unknown'; + final events = >[]; + final prefix = mode.startsWith('accepted'); + var reads = 0; + var accepted = false; + late TextEditingController controller; + List? sent; + final roster = [ + ChannelMember( + pubkey: key, + displayName: 'Alice', + role: 'member', + joinedAt: DateTime(2025), + ), + ]; + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(signer.nsec), + currentPubkey: signer.public, + relayConfig: () => _SwitchableRelayConfigNotifier( + RelayConfig(baseUrl: 'https://relay.example', nsec: signer.nsec), + ), + members: savedAgent ? [] : roster, + relayAgents: savedAgent ? [_testAgent(key)] : [], + channels: [_makeCurrentChannel(), _makeSharedMemberChannel()], + selectedReader: + (keys, prior, viewer, channel, current, observed) async { + expect(keys, {key}); + if (reads == 0) expect(prior, savedAgent ? {key} : isEmpty); + expect(current(), isTrue); + reads++; + if (mode == 'revision change') { + observed({ + key: NostrEvent( + id: '$reads', + pubkey: key, + createdAt: reads, + kind: 0, + tags: [], + content: '{}', + sig: '', + ), + }); + } + if (mode == 'missing key') return {}; + final agent = + mode == 'fresh agent' || + mode == 'denied agent' || + mode == 'policy change' || + (mode == 'consent change' && reads >= 2) || + (mode == 'perwrite change' && reads >= 3); + return { + key: SelectedMentionAuthorization( + savedAgent || prefix && accepted + ? SelectedMentionKind.unresolvedAgent + : agent + ? SelectedMentionKind.agent + : SelectedMentionKind.ordinary, + mode == 'ordinary member' || accepted, + agent + ? AgentDirectoryEntry( + pubkey: key, + ownerPubkey: viewer, + respondTo: + (mode == 'denied agent' || + mode == 'policy change' && reads >= 2) + ? 'nobody' + : 'anyone', + channelIds: accepted ? [channel] : [], + ) + : null, + ), + }; + }, + onSend: (_, keys, {mediaTags = const []}) async { + expect(mediaTags.where((tag) => tag.first == 'mention'), isEmpty); + sent = keys; + }, + ), + ); + final container = ProviderScope.containerOf( + tester.element(find.byType(ComposeBar)), + ); + final session = container.read(relaySessionProvider.notifier); + session.debugAttachSocketForTest( + _RecordingRelaySocket( + events, + session.debugHandleSocketMessageForTest, + onEventAcknowledged: (event) { + if (event['kind'] != 9000) return; + accepted = true; + if ([ + 'equivalent config', + 'credential change', + 'relay change', + ].contains(mode)) { + container + .read(relayConfigProvider.notifier) + .update( + baseUrl: mode == 'relay change' + ? 'https://other.example' + : 'https://relay.example', + nsec: mode == 'credential change' + ? nostr.Keys.generate().nsec + : signer.nsec, + ); + } + if (mode == 'accepted cancellation') { + controller.text = 'new draft'; + } + }, + ), + ); + await _expandComposer(tester); + await tester.enterText( + find.byType(TextField), + savedAgent ? '@hel' : '@ali', + ); + await tester.pumpAndSettle(); + await tester.tap(find.text(savedAgent ? 'Helper Bot' : 'Alice')); + await tester.pumpAndSettle(); + controller = tester.widget(find.byType(TextField)).controller!; + await tester.tap(find.byIcon(LucideIcons.arrowUp)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + if (find.text('Invite').evaluate().isNotEmpty) { + expect(events.where((event) => event['kind'] == 9000), isEmpty); + await tester.tap(find.text('Invite')); + await tester.pumpAndSettle(); + } + if (mode == 'revision change' || mode == 'policy change') { + expect(reads, 2); + } + if (mode == 'consent change' || mode == 'perwrite change') { + expect(reads, 3); + } + final succeeds = [ + 'equivalent config', + 'ordinary member', + 'ordinary invite', + 'fresh agent', + ].contains(mode); + expect(sent, succeeds ? [key] : isNull); + final writes = events.where((event) => event['kind'] == 9000).toList(); + expect( + writes, + hasLength( + [ + 'ordinary invite', + 'fresh agent', + 'equivalent config', + 'credential change', + 'relay change', + ].contains(mode) || + prefix + ? 1 + : 0, + ), + ); + if (writes.isNotEmpty) { + expect( + (writes.single['tags'] as List).where((tag) => tag[0] == 'p').single, + ['p', key], + ); + expect( + writes.single['tags'], + contains(equals(['role', mode == 'fresh agent' ? 'bot' : 'member'])), + ); + } + if (!succeeds) { + expect( + controller.text, + ['credential change', 'relay change'].contains(mode) + ? '' // The new identity owns a separate empty composer. + : mode == 'accepted cancellation' + ? 'new draft' + : savedAgent + ? '@Helper Bot ' + : '@Alice ', + ); + } + if (prefix) { + expect( + find.textContaining('1 invitation(s) completed and remain'), + findsOneWidget, + ); + } + }); + } +} diff --git a/mobile/test/features/channels/compose_bar_test/publication_tests.dart b/mobile/test/features/channels/compose_bar_test/publication_tests.dart index 6cb892cf86b..b66ce262950 100644 --- a/mobile/test/features/channels/compose_bar_test/publication_tests.dart +++ b/mobile/test/features/channels/compose_bar_test/publication_tests.dart @@ -39,7 +39,7 @@ void _publicationTests() { respondTo: mode == 'deny' || (mode == 'revoke' && reads == 2) ? 'nobody' : 'anyone', - channelIds: mode == 'removed' && reads == 2 + channelIds: mode == 'removed' && reads >= 2 ? [] : ['channel-1'], ), @@ -59,13 +59,21 @@ void _publicationTests() { ); if (mode != 'allow') { expect( - find.textContaining('Could not authorize a mentioned agent'), + find.textContaining( + mode == 'error' + ? 'unavailable' + : 'Could not authorize a mentioned agent', + ), findsOneWidget, ); } expect( reads, - const ['allow', 'revoke', 'removed'].contains(mode) ? 2 : 1, + mode == 'allow' || mode == 'removed' + ? 3 + : mode == 'revoke' + ? 2 + : 1, ); }); } @@ -116,7 +124,16 @@ void _publicationTests() { ], authorizationReader: (_, _, _, _) { reads++; - return pending.future; + return boundary == 'prompt' + ? Future.value([ + AgentDirectoryEntry( + pubkey: key, + ownerPubkey: signer.public, + respondTo: 'anyone', + channelIds: ['channel-1'], + ), + ]) + : pending.future; }, onSend: (_, _, {mediaTags = const []}) async { sent++; @@ -148,7 +165,7 @@ void _publicationTests() { } await tester.tap(find.byIcon(LucideIcons.arrowUp)); await tester.pump(); - expect(reads, boundary == 'reader' ? 1 : 0); + expect(reads, boundary == 'reader' || boundary == 'prompt' ? 1 : 0); if (boundary == 'prompt') { await tester.pump(const Duration(milliseconds: 300)); }