diff --git a/desktop/tests/e2e/forum-agent-invitation.spec.ts b/desktop/tests/e2e/forum-agent-invitation.spec.ts index b17f5fcc04f..208f1eb54fc 100644 --- a/desktop/tests/e2e/forum-agent-invitation.spec.ts +++ b/desktop/tests/e2e/forum-agent-invitation.spec.ts @@ -430,7 +430,11 @@ for (const stage of ["add", "publish"] as const) { await expect( page.getByRole("button", { name: "Remove attachment" }), ).toBeVisible(); - if (replacement !== null) + if (replacement === "") { + await page.getByTestId("message-input").press("ControlOrMeta+a"); + await page.getByTestId("message-input").press("Backspace"); + await expect(page.getByTestId("message-input")).toBeEmpty(); + } else if (replacement !== null) await page.getByTestId("message-input").fill(replacement); await navigate(1); await releaseForumGate(page); diff --git a/desktop/tests/e2e/profile-custom-emoji-status.spec.ts b/desktop/tests/e2e/profile-custom-emoji-status.spec.ts index 09060ec39ee..f713b1de788 100644 --- a/desktop/tests/e2e/profile-custom-emoji-status.spec.ts +++ b/desktop/tests/e2e/profile-custom-emoji-status.spec.ts @@ -196,8 +196,14 @@ test("set status dialog uses the desktop modal with shared status choices", asyn test("keeps an open status draft when the saved status expires", async ({ page, }) => { + // Pin the browser Date before navigation so the seeded two-second status + // cannot expire while the popover/editor setup runs, however slow the + // machine; timers, network, and rendering keep running, and the seeded + // node-side timestamps match the pinned Date exactly. + const seededAt = new Date("2026-09-10T12:00:00.000Z"); + const nowSeconds = Math.floor(seededAt.getTime() / 1_000); + await page.clock.setFixedTime(seededAt); await page.goto("/"); - const nowSeconds = Math.floor(Date.now() / 1_000); await seedMockStatus(page, { text: "Original draft", emoji: "📝", @@ -206,7 +212,22 @@ test("keeps an open status draft when the saved status expires", async ({ }); await page.getByTestId("profile-popover-set-status").click(); const dialog = page.getByTestId("set-status-dialog"); + // The editor opened on the still-saved status, not on a fresh one. + await expect(dialog.getByTestId("set-status-input")).toHaveValue( + "Original draft", + ); + await expect(dialog.getByTestId("set-status-duration")).toContainText( + "Custom", + ); + await expect(dialog.getByLabel("Choose a status emoji")).toContainText("📝"); + await expect(dialog.getByTestId("set-status-clear")).toBeVisible(); + await expect(dialog.getByText("Quick statuses", { exact: true })).toHaveCount( + 0, + ); await dialog.getByTestId("set-status-input").fill("Unsaved draft"); + // Advance the pinned Date past the deadline, then fire the pending timer. + await page.clock.setFixedTime(new Date(seededAt.getTime() + 3_000)); + await page.clock.fastForward(2_500); await expect(page.getByTestId("sidebar-profile-user-status")).toHaveCount(0, { timeout: 5_000, }); @@ -227,6 +248,38 @@ test("keeps an open status draft when the saved status expires", async ({ await expect(dialog.getByTestId("set-status-clear")).toBeVisible(); }); +test("opens a fresh editor when the saved status expired before opening", async ({ + page, +}) => { + // Causal control for the setup ordering above: the same pinned clock and + // seed, but the deadline is crossed before the editor opens. The dialog + // must then be a new-status editor — the saved-editor precondition + // assertions above would fail under this wrong ordering. + const seededAt = new Date("2026-09-10T12:00:00.000Z"); + const nowSeconds = Math.floor(seededAt.getTime() / 1_000); + await page.clock.setFixedTime(seededAt); + await page.goto("/"); + await seedMockStatus(page, { + text: "Original draft", + emoji: "📝", + expiresAt: nowSeconds + 2, + createdAt: nowSeconds, + }); + await page.clock.setFixedTime(new Date(seededAt.getTime() + 5_000)); + await page.clock.fastForward(5_000); + await expect(page.getByTestId("sidebar-profile-user-status")).toHaveCount(0); + await page.getByTestId("profile-popover-set-status").click(); + const dialog = page.getByTestId("set-status-dialog"); + await expect(dialog.getByTestId("set-status-input")).toHaveValue(""); + await expect(dialog.getByTestId("set-status-duration")).toContainText( + "Today", + ); + await expect( + dialog.getByText("Quick statuses", { exact: true }), + ).toBeVisible(); + await expect(dialog.getByTestId("set-status-clear")).toHaveCount(0); +}); + test("new statuses default to expiring at local midnight", async ({ page }) => { await page.goto("/"); await openProfilePopover(page); diff --git a/mobile/lib/features/channels/mentions/mention_candidates.dart b/mobile/lib/features/channels/mentions/mention_candidates.dart index c3501b72f23..82ecf13473f 100644 --- a/mobile/lib/features/channels/mentions/mention_candidates.dart +++ b/mobile/lib/features/channels/mentions/mention_candidates.dart @@ -57,6 +57,7 @@ List buildMentionCandidates({ required Map ownerByAgentPubkey, List searchResults = const [], String? currentPubkey, + bool directoryReady = true, }) { final candidates = []; final seen = {}; @@ -68,8 +69,10 @@ List buildMentionCandidates({ final ownerPubkey = ownerByAgentPubkey[pk] ?? profile?.ownerPubkey; final isAgent = member.isBot || ownerPubkey != null; final policy = relayAgents.where((agent) => agent.pubkey == pk).firstOrNull; - if (policy?.ownerPubkey != null && - !agentIsSharedWithUser(policy!, sharedChannelIds, currentPubkey)) { + if (ownerPubkey != null && + (!directoryReady || + policy?.ownerPubkey == null || + !agentIsSharedWithUser(policy!, sharedChannelIds, currentPubkey))) { continue; } candidates.add( @@ -98,6 +101,7 @@ List buildMentionCandidates({ } for (final agent in relayAgents) { + if (!directoryReady && agent.ownerPubkey != null) continue; final pk = agent.pubkey; if (seen.contains(pk)) continue; if (!sharedAgentPubkeys.contains(pk)) continue; @@ -135,7 +139,10 @@ List buildMentionCandidates({ final policy = relayAgents .where((agent) => agent.pubkey == pk) .firstOrNull; - if (policy?.ownerPubkey != null && !sharedAgentPubkeys.contains(pk)) { + if (ownerPubkey != null && + (!directoryReady || + policy?.ownerPubkey == null || + !sharedAgentPubkeys.contains(pk))) { continue; } if (!ownedByCurrentUser && !sharedAgentPubkeys.contains(pk)) { diff --git a/mobile/lib/features/channels/mentions/mention_candidates_provider.dart b/mobile/lib/features/channels/mentions/mention_candidates_provider.dart index 6e225b45017..1ef0b0c6d65 100644 --- a/mobile/lib/features/channels/mentions/mention_candidates_provider.dart +++ b/mobile/lib/features/channels/mentions/mention_candidates_provider.dart @@ -77,9 +77,9 @@ final mentionCandidatesProvider = Provider.family sessionStatus: sessionStatus, cachedMembers: cachedMembers, ); + final directory = ref.watch(agentDirectoryProvider); final relayAgents = - ref.watch(agentDirectoryProvider).asData?.value ?? - const []; + directory.asData?.value ?? const []; final owners = ref.watch(agentOwnersProvider).asData?.value ?? const {}; final channels = channelsAsync.asData?.value ?? const []; final userCache = ref.watch(userCacheProvider); @@ -96,6 +96,7 @@ final mentionCandidatesProvider = Provider.family final candidates = buildMentionCandidates( members: members, relayAgents: relayAgents, + directoryReady: !directory.isLoading && !directory.hasError, sharedChannelIds: sharedChannelIds, userCache: userCache, ownerByAgentPubkey: owners, diff --git a/mobile/lib/features/profile/profile_provider.dart b/mobile/lib/features/profile/profile_provider.dart index a1a283fb499..42c922e9f23 100644 --- a/mobile/lib/features/profile/profile_provider.dart +++ b/mobile/lib/features/profile/profile_provider.dart @@ -5,7 +5,6 @@ import 'dart:typed_data'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../../shared/crypto/nip_oa.dart'; import '../../shared/profile/user_cache_provider.dart'; import '../../shared/profile/user_profile.dart'; import '../../shared/relay/relay.dart'; @@ -69,9 +68,7 @@ final profileAvatarHandoffProvider = /// WebSocket. Returns null when no nsec is configured or when the user has /// not yet published a profile. class ProfileNotifier extends AsyncNotifier { - Map _metadata = {}; bool _hasHydrated = false; - int _lastCreatedAt = 0; Future _patchQueue = Future.value(); @override @@ -92,37 +89,19 @@ class ProfileNotifier extends AsyncNotifier { final myPk = context.pubkey; if (myPk == null) { _requireCurrentWriteContext(context); - _metadata = {}; - _lastCreatedAt = 0; _hasHydrated = true; return null; } final session = context.session; final events = await session.fetchHistory(NostrFilters.profile(myPk)); - if (events.isEmpty) { - _requireCurrentWriteContext(context); - _metadata = {}; - _lastCreatedAt = 0; - _hasHydrated = true; - return null; - } - final latest = _latestProfileEvent(events)!; - final metadata = _decodeProfileMetadata(latest); - final data = ProfileData.fromEvent(latest); - final profile = UserProfile( - pubkey: data.pubkey, - displayName: data.displayName, - avatarUrl: data.avatarUrl, - about: data.about, - nip05Handle: data.nip05, - ownerPubkey: verifiedOaOwnerPubkey(latest), - ); _requireCurrentWriteContext(context); - _metadata = metadata; - _lastCreatedAt = latest.createdAt; + final cache = ref.read(userCacheProvider.notifier); + for (final event in events) { + cache.cacheProfileEvent(event); + } _hasHydrated = true; - return profile; + return ref.read(userCacheProvider)[myPk.toLowerCase()]; } Future refresh() async { @@ -182,9 +161,12 @@ class ProfileNotifier extends AsyncNotifier { NostrFilters.profile(pubkey), ); _requireCurrentWriteContext(context); - final currentHead = _latestProfileEvent(currentEvents); - if (_lastCreatedAt > 0 && - (currentHead == null || currentHead.createdAt < _lastCreatedAt)) { + final cache = ref.read(userCacheProvider.notifier); + for (final event in currentEvents) { + cache.cacheProfileEvent(event); + } + final currentHead = cache.profileEvent(pubkey); + if (currentHead?.id != _latestProfileEvent(currentEvents)?.id) { throw StateError('Cannot confirm the latest profile metadata.'); } final currentMetadata = currentHead == null @@ -199,10 +181,7 @@ class ProfileNotifier extends AsyncNotifier { final relay = SignedEventRelay(session: session, nsec: context.config.nsec); final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; final currentCreatedAt = currentHead?.createdAt ?? 0; - final previousCreatedAt = currentCreatedAt > _lastCreatedAt - ? currentCreatedAt - : _lastCreatedAt; - final createdAt = now > previousCreatedAt ? now : previousCreatedAt + 1; + final createdAt = now > currentCreatedAt ? now : currentCreatedAt + 1; NostrEvent? signedEvent; await relay.submit( kind: EventKind.profile, @@ -216,27 +195,20 @@ class ProfileNotifier extends AsyncNotifier { if (submittedEvent == null) { throw StateError('Profile update was not signed.'); } - final verifiedHead = _latestProfileEvent( - await session.fetchHistory(NostrFilters.profile(pubkey)), + final verification = await session.fetchHistory( + NostrFilters.profile(pubkey), ); _requireCurrentWriteContext(context); + for (final event in verification) { + cache.cacheProfileEvent(event); + } + final verifiedHead = _latestProfileEvent(verification); if (verifiedHead?.id != submittedEvent.id) { throw StateError('Profile changed before the update could be confirmed.'); } - _metadata = nextMetadata; - _lastCreatedAt = createdAt; - final profile = UserProfile( - pubkey: pubkey, - displayName: - _metadata['display_name'] as String? ?? _metadata['name'] as String?, - avatarUrl: _metadata['picture'] as String?, - about: _metadata['about'] as String?, - nip05Handle: _metadata['nip05'] as String?, - ownerPubkey: verifiedOaOwnerPubkey(submittedEvent), - ); - state = AsyncData(profile); - ref.read(userCacheProvider.notifier).put(profile); + cache.cacheProfileEvent(submittedEvent); + state = AsyncData(ref.read(userCacheProvider)[pubkey.toLowerCase()]); } void _requireCurrentWriteContext(_ProfileWriteContext context) { diff --git a/mobile/lib/shared/mentions/agent_authorization.dart b/mobile/lib/shared/mentions/agent_authorization.dart index 1625db85803..9040dc24f85 100644 --- a/mobile/lib/shared/mentions/agent_authorization.dart +++ b/mobile/lib/shared/mentions/agent_authorization.dart @@ -2,12 +2,16 @@ part of 'agent_identity_provider.dart'; /// Fresh exact-key policy and relay-signed membership. Directory entries are /// hints; only this read may authorize agent mentions at a destination. +/// [resolveProfileOwners] optionally folds fetched profiles into an ordered, +/// session-bound cache after the scope check and returns its current verified +/// owners. Omit it for side-effect-free send-time reads. Future> readAgentAuthorization( RelaySessionNotifier session, Set requestedKeys, { required String? viewer, String? channelId, required bool Function() isCurrent, + Map Function(Iterable)? resolveProfileOwners, }) async { void check() { if (!isCurrent()) throw StateError('Agent authorization scope changed'); @@ -66,6 +70,7 @@ Future> readAgentAuthorization( runtime.where((e) => requestedKeys.contains(e.pubkey)).toList(), requestedKeys: requestedKeys, checkCurrent: check, + resolveProfileOwners: resolveProfileOwners, ); check(); final latest = {}; diff --git a/mobile/lib/shared/mentions/agent_discovery.dart b/mobile/lib/shared/mentions/agent_discovery.dart index ac4bbec15b5..8d4fe358bd7 100644 --- a/mobile/lib/shared/mentions/agent_discovery.dart +++ b/mobile/lib/shared/mentions/agent_discovery.dart @@ -110,6 +110,10 @@ class _AgentDirectoryUpdates extends Notifier { ), (event) { if (!current()) return; + if (disposed) return; + if (event.kind == 0) { + ref.read(userCacheProvider.notifier).cacheProfileEvent(event); + } if (event.kind == 5 && !_isAgentCoordinateDeletion(event)) return; changed(); }, diff --git a/mobile/lib/shared/mentions/agent_identity_provider.dart b/mobile/lib/shared/mentions/agent_identity_provider.dart index 89c1c9d75c5..472a14af2f3 100644 --- a/mobile/lib/shared/mentions/agent_identity_provider.dart +++ b/mobile/lib/shared/mentions/agent_identity_provider.dart @@ -8,6 +8,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/crypto/nip_oa.dart'; import '../../shared/crypto/signed_event.dart'; import '../../shared/relay/relay.dart'; +import '../profile/user_cache_provider.dart'; part 'agent_policy.dart'; part 'agent_authorization.dart'; @@ -92,9 +93,26 @@ final agentDirectoryProvider = FutureProvider>(( if (!current()) return []; return readAgentAuthorization( session, - {...owned, ...events.map((event) => event.pubkey)}, + { + ...owned, ...events.map((event) => event.pubkey), + // Refresh previously authenticated owners even after runtime/policy + // discovery stops returning them (for example across reconnect). + for (final profile in ref.read(userCacheProvider).values) + if (profile.ownerPubkey != null) profile.pubkey, + }, viewer: viewer, isCurrent: current, + resolveProfileOwners: (profiles) { + if (!current()) throw StateError('Profile authority scope changed'); + final cache = ref.read(userCacheProvider.notifier); + for (final profile in profiles) { + cache.cacheProfileEvent(profile); + } + return { + for (final profile in ref.read(userCacheProvider).values) + if (profile.ownerPubkey != null) profile.pubkey: profile.ownerPubkey!, + }; + }, ); }); @@ -102,18 +120,14 @@ final agentDirectoryProvider = FutureProvider>(( /// profiles. An entry exists only when the `auth` tag verifies — mirrors /// desktop's `profile_valid_oa_owner_pubkey`. final agentOwnersProvider = FutureProvider>((ref) async { - final agents = await ref.watch(agentDirectoryProvider.future); - if (agents.isEmpty) return const {}; - final session = ref.read(relaySessionProvider.notifier); - final events = await session.fetchHistory( - NostrFilters.profilesBatch([for (final agent in agents) agent.pubkey]), - ); - final owners = {}; - for (final event in latestProfileEvents(events).values) { - final owner = verifiedOaOwnerPubkey(event); - if (owner != null) owners[event.pubkey.toLowerCase()] = owner; - } - return owners; + await ref.watch(agentDirectoryProvider.future); + // The profile cache is the single ordered ownership source for lifecycle, + // provenance and mention fallbacks; never re-query a parallel owner snapshot. + final profiles = ref.watch(userCacheProvider); + return { + for (final profile in profiles.values) + if (profile.ownerPubkey != null) profile.pubkey: profile.ownerPubkey!, + }; }); /// Pubkeys currently known to represent agents across the active relay. diff --git a/mobile/lib/shared/mentions/agent_policy.dart b/mobile/lib/shared/mentions/agent_policy.dart index 9d2d57a2742..61d2fbdef56 100644 --- a/mobile/lib/shared/mentions/agent_policy.dart +++ b/mobile/lib/shared/mentions/agent_policy.dart @@ -23,11 +23,16 @@ Future> _queryAgentFilters( } /// Overlay current owner-authenticated policy onto the existing runtime -/// directory. This does not expand discovery to owner-only coordinates yet. +/// directory, optionally including exact [requestedKeys] without a runtime. +/// [resolveProfileOwners] receives snapshot profiles after [checkCurrent] and +/// returns verified owners from the caller's ordered authority cache, so an +/// older snapshot cannot undo a newer live revocation. Without the callback, +/// ownership derives solely from this fresh query; no cache writes occur. Future> resolveAgentPolicies( RelaySessionNotifier session, List runtimeEvents, { Set? requestedKeys, + Map Function(Iterable)? resolveProfileOwners, void Function()? checkCurrent, }) async { final latest = {}; @@ -44,6 +49,7 @@ Future> resolveAgentPolicies( NostrFilter(kinds: const [0], authors: [key], limit: 1), ], checkCurrent: checkCurrent), ); + checkCurrent?.call(); final owners = {}; for (final profile in profiles.values) { final owner = verifiedOaOwnerPubkey(profile); @@ -51,6 +57,12 @@ Future> resolveAgentPolicies( owners[profile.pubkey] = owner; } } + if (resolveProfileOwners != null) { + final currentOwners = resolveProfileOwners(profiles.values); + owners + ..clear() + ..addEntries(currentOwners.entries.where((e) => keys.contains(e.key))); + } checkCurrent?.call(); final policies = await _queryAgentFilters(session, [ for (final owner in owners.entries) diff --git a/mobile/lib/shared/profile/user_cache_provider.dart b/mobile/lib/shared/profile/user_cache_provider.dart index 91131c888d0..0ce0cb0c6d5 100644 --- a/mobile/lib/shared/profile/user_cache_provider.dart +++ b/mobile/lib/shared/profile/user_cache_provider.dart @@ -14,15 +14,20 @@ import 'user_profile.dart'; /// kind:0 batch query (NIP-01 `authors` filter) every 50ms. class UserCacheNotifier extends Notifier> { final Set _pending = {}; - final Map _profileEventOrders = {}; + int _generation = 0; + final Map _profileEvents = {}; Timer? _batchTimer; Completer? _batchCompleter; @override Map build() { ref.watch(relayConfigProvider); - _profileEventOrders.clear(); + ref.watch(myPubkeyProvider); + _generation++; + _pending.clear(); + _profileEvents.clear(); ref.onDispose(() { + _generation++; _batchTimer?.cancel(); _batchTimer = null; _batchCompleter?.complete(false); @@ -40,11 +45,19 @@ class UserCacheNotifier extends Notifier> { return null; } - /// Stores a profile that was fetched or updated outside the batch loader. + /// Seeds an absent profile for local display before signed evidence arrives. + /// Never replaces existing state; fetched/confirmed profiles must use + /// [cacheProfileEvent] so ownership and event metadata advance together. void put(UserProfile profile) { - state = {...state, profile.pubkey.toLowerCase(): profile}; + final pubkey = profile.pubkey.toLowerCase(); + if (state.containsKey(pubkey)) return; + state = {...state, pubkey: profile}; } + /// The governing kind:0 event, including metadata needed for profile edits. + NostrEvent? profileEvent(String pubkey) => + _profileEvents[pubkey.toLowerCase()]; + /// Preload profiles for a list of pubkeys (e.g. channel members). /// Returns whether the batch completed successfully. Future preload(List pubkeys) { @@ -72,19 +85,19 @@ class UserCacheNotifier extends Notifier> { .toSet() .toList(); if (normalized.isEmpty) return true; + final generation = _generation; try { final session = ref.read(relaySessionProvider.notifier); final events = await session.fetchHistory( NostrFilters.profilesBatch(normalized), ); + if (generation != _generation) return false; final updated = Map.from(state); - final updatedOrders = Map.from( - _profileEventOrders, - ); + final updatedOrders = Map.from(_profileEvents); for (final event in events) { _cacheProfileEvent(event, updated, updatedOrders); } - _profileEventOrders + _profileEvents ..clear() ..addAll(updatedOrders); state = updated; @@ -121,6 +134,7 @@ class UserCacheNotifier extends Notifier> { _batchCompleter = null; var succeeded = false; + final generation = _generation; try { final communityID = ref.read(activeCommunityProvider).value?.id; final session = ref.read(relaySessionProvider.notifier); @@ -128,15 +142,14 @@ class UserCacheNotifier extends Notifier> { NostrFilters.profilesBatch(pubkeys), ); + if (generation != _generation) return; final updated = Map.from(state); - final updatedOrders = Map.from( - _profileEventOrders, - ); + final updatedOrders = Map.from(_profileEvents); for (final event in events) { _cacheProfileEvent(event, updated, updatedOrders); } - _profileEventOrders + _profileEvents ..clear() ..addAll(updatedOrders); state = updated; @@ -154,21 +167,21 @@ class UserCacheNotifier extends Notifier> { bool _cacheProfileEvent( NostrEvent event, Map profiles, [ - Map? orders, + Map? orders, ]) { if (event.kind != 0) return false; - final eventOrders = orders ?? _profileEventOrders; + final eventOrders = orders ?? _profileEvents; final pubkey = event.pubkey.toLowerCase(); final current = eventOrders[pubkey]; final isNewer = current == null || event.createdAt > current.createdAt || (event.createdAt == current.createdAt && - event.id.compareTo(current.eventId) < 0); + event.id.compareTo(current.id) < 0); if (!isNewer) return false; profiles[pubkey] = _profileFromEvent(event); - eventOrders[pubkey] = (createdAt: event.createdAt, eventId: event.id); + eventOrders[pubkey] = event; return true; } diff --git a/mobile/test/features/channels/mentions/mention_candidates_test.dart b/mobile/test/features/channels/mentions/mention_candidates_test.dart index 9694d68535d..63d10937700 100644 --- a/mobile/test/features/channels/mentions/mention_candidates_test.dart +++ b/mobile/test/features/channels/mentions/mention_candidates_test.dart @@ -169,7 +169,13 @@ void main() { final ownedAgent = '2' * 64; final candidates = buildMentionCandidates( members: const [], - relayAgents: const [], + relayAgents: [ + AgentDirectoryEntry( + pubkey: ownedAgent, + ownerPubkey: userPubkey, + respondTo: 'owner-only', + ), + ], sharedChannelIds: const {}, userCache: const {}, ownerByAgentPubkey: const {}, @@ -188,6 +194,41 @@ void main() { expect(candidates.single.ownerPubkey, userPubkey); }); + test('owned members and search fail closed without ready policy', () { + final ownedAgent = '2' * 64; + for (final ready in [false, true]) { + for (final isMember in [false, true]) { + final candidates = buildMentionCandidates( + members: isMember ? [member(ownedAgent)] : [], + relayAgents: [], + directoryReady: ready, + sharedChannelIds: {}, + userCache: {}, + ownerByAgentPubkey: {ownedAgent: userPubkey}, + searchResults: isMember ? [] : [UserProfile(pubkey: ownedAgent)], + currentPubkey: userPubkey, + ); + expect(candidates, isEmpty); + } + } + final denied = buildMentionCandidates( + members: [member(ownedAgent)], + directoryReady: false, + relayAgents: [ + AgentDirectoryEntry( + pubkey: ownedAgent, + ownerPubkey: userPubkey, + respondTo: 'owner-only', + ), + ], + sharedChannelIds: {}, + userCache: {}, + ownerByAgentPubkey: {ownedAgent: userPubkey}, + currentPubkey: userPubkey, + ); + expect(denied, isEmpty); + }); + test('search results hide non-shared agents owned by someone else', () { final foreignAgent = '3' * 64; final candidates = buildMentionCandidates( diff --git a/mobile/test/features/channels/mentions/mention_readiness_test.dart b/mobile/test/features/channels/mentions/mention_readiness_test.dart new file mode 100644 index 00000000000..5a34109cf53 --- /dev/null +++ b/mobile/test/features/channels/mentions/mention_readiness_test.dart @@ -0,0 +1,80 @@ +import 'dart:async'; + +import 'package:buzz/features/channels/channel.dart'; +import 'package:buzz/features/channels/channel_management_provider.dart'; +import 'package:buzz/features/channels/channels_provider.dart'; +import 'package:buzz/features/channels/mentions/mention_candidates_provider.dart'; +import 'package:buzz/shared/mentions/agent_identity_provider.dart'; +import 'package:buzz/shared/profile/user_profile.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../../shared/mentions/agent_policy_test.dart' show PolicySession; + +class _Channels extends ChannelsNotifier { + @override + Future> build() async => []; +} + +void main() { + test( + 'production picker preserves policy loading/error instead of empty success', + () async { + final viewer = 'a' * 64; + final agent = 'b' * 64; + final human = 'c' * 64; + for (final member in [false, true]) { + final directory = Completer>(); + final c = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(() => PolicySession([])), + channelsProvider.overrideWith(_Channels.new), + currentPubkeyProvider.overrideWithValue(viewer), + agentDirectoryProvider.overrideWith((ref) => directory.future), + agentOwnersProvider.overrideWith((ref) async => {agent: viewer}), + channelMembersProvider('room').overrideWith( + (ref) async => [ + ChannelMember( + pubkey: human, + role: 'member', + joinedAt: DateTime(2024), + ), + if (member) + ChannelMember( + pubkey: agent, + role: 'bot', + joinedAt: DateTime(2024), + ), + ], + ), + mentionUserSearchProvider('').overrideWith( + (ref) async => [ + if (!member) UserProfile(pubkey: agent, ownerPubkey: viewer), + ], + ), + ], + ); + final picker = mentionCandidatesProvider(( + channelId: 'room', + query: '', + )); + final subscription = c.listen(picker, (_, _) {}); + await c.read(channelsProvider.future); + await c.read(agentOwnersProvider.future); + await c.read(channelMembersProvider('room').future); + await c.read(mentionUserSearchProvider('').future); + expect(c.read(picker).map((candidate) => candidate.pubkey), [human]); + directory.completeError(StateError('policy unavailable')); + await expectLater( + c.read(agentDirectoryProvider.future), + throwsStateError, + ); + await c.pump(); + expect(c.read(picker).map((candidate) => candidate.pubkey), [human]); + subscription.close(); + c.dispose(); + } + }, + ); +} diff --git a/mobile/test/features/profile/profile_provider_test.dart b/mobile/test/features/profile/profile_provider_test.dart index 239dc808a28..96281d302ec 100644 --- a/mobile/test/features/profile/profile_provider_test.dart +++ b/mobile/test/features/profile/profile_provider_test.dart @@ -77,6 +77,89 @@ void main() { ); }); + test( + 'late confirmed publication retains newer cache authority and metadata', + () async { + final keys = nostr.Keys.generate(); + final owner = nostr.Keys.generate(); + final initial = NostrEvent.fromJson( + nostr.Event.from( + secretKey: keys.secret, + createdAt: 1, + kind: 0, + tags: [_authTag(owner, keys.public)], + content: jsonEncode({'display_name': 'Owned'}), + ).toMap(), + ); + final session = _ProfileRelaySession(initial); + final container = _profileContainer(keys.nsec, session); + addTearDown(container.dispose); + final cache = container.read(userCacheProvider.notifier); + await container.read(profileProvider.future); + expect(cache.profileEvent(keys.public)?.id, initial.id); + NostrEvent? revoked; + session.beforeHistoryReturn = () { + if (session.published.length != 1) return; + revoked = NostrEvent.fromJson( + nostr.Event.from( + secretKey: keys.secret, + createdAt: session.published.single.createdAt + 1, + kind: 0, + tags: const [], + content: jsonEncode({'display_name': 'Revoked', 'custom': 'new'}), + ).toMap(), + ); + cache.cacheProfileEvent(revoked!); + }; + await container.read(profileProvider.notifier).updateAbout('Late'); + expect( + container.read(profileProvider).requireValue?.displayName, + 'Revoked', + ); + final submitted = session.published.single; + cache.put( + UserProfile( + pubkey: keys.public, + displayName: 'Unordered', + ownerPubkey: owner.public, + ), + ); + cache.cacheProfileEvent(submitted); + cache.cacheProfileEvent(revoked!); + expect(cache.profileEvent(keys.public)?.id, revoked!.id); + expect( + container.read(userCacheProvider)[keys.public]?.ownerPubkey, + isNull, + ); + expect( + container.read(profileProvider).requireValue?.displayName, + 'Revoked', + ); + // A stale hydration response must use the same governing event, not + // recreate detached state or lower the metadata used by the next edit. + session.beforeHistoryReturn = null; + await container.read(profileProvider.notifier).refresh(); + expect( + container.read(profileProvider).requireValue?.displayName, + 'Revoked', + ); + await expectLater( + container.read(profileProvider.notifier).updateAbout('Unsafe'), + throwsStateError, + ); + expect(session.published, hasLength(1)); + session.profile = revoked!; + await container.read(profileProvider.notifier).updateAbout('Safe'); + final next = session.published.last; + expect(next.createdAt, greaterThan(revoked!.createdAt)); + expect(next.tags, isEmpty); + expect(jsonDecode(next.content)['custom'], 'new'); + expect(cache.profileEvent(keys.public)?.id, next.id); + cache.cacheProfileEvent(revoked!); + expect(cache.profileEvent(keys.public)?.id, next.id); + }, + ); + test('clearing a display name restores the pubkey label fallback', () async { final keys = nostr.Keys.generate(); final relaySession = _ProfileRelaySession( @@ -607,7 +690,8 @@ class _MutableRelayConfigNotifier extends RelayConfigNotifier { class _ProfileRelaySession extends RelaySessionNotifier { _ProfileRelaySession(this.profile); - final NostrEvent profile; + NostrEvent profile; + void Function()? beforeHistoryReturn; final List published = []; @override @@ -617,7 +701,11 @@ class _ProfileRelaySession extends RelaySessionNotifier { Future> fetchHistory( NostrFilter filter, { Duration timeout = const Duration(seconds: 8), - }) async => [profile, ...published]; + }) async { + final result = [profile, ...published]; + beforeHistoryReturn?.call(); + return result; + } @override Future publish( diff --git a/mobile/test/shared/mentions/agent_discovery_test.dart b/mobile/test/shared/mentions/agent_discovery_test.dart index af83dd681c7..494e3c6fcad 100644 --- a/mobile/test/shared/mentions/agent_discovery_test.dart +++ b/mobile/test/shared/mentions/agent_discovery_test.dart @@ -1,6 +1,8 @@ import 'package:buzz/shared/mentions/agent_identity_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; +import 'package:buzz/shared/profile/user_cache_provider.dart'; import 'package:buzz/features/channels/mentions/mention_candidates.dart'; +import 'package:buzz/features/channels/channel_management_provider.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nostr/nostr.dart' as nostr; @@ -277,6 +279,64 @@ void main() { }, ); + test( + 'live revocation advances shared profiles and owners; replay cannot restore', + () async { + final events = [owned, policy]; + final session = _Session(events, relay.public); + final c = container(session); + final ownerSubscription = c.listen(agentOwnersProvider, (_, _) {}); + addTearDown(ownerSubscription.close); + addTearDown(c.dispose); + expect(await c.read(agentOwnersProvider.future), { + agent.public: owner.public, + }); + expect( + c.read(userCacheProvider)[agent.public]?.ownerPubkey, + owner.public, + ); + final revoked = profile(agent, [], createdAt: 101); + events.add(revoked); + session.deliver(revoked); + await Future.delayed(const Duration(milliseconds: 180)); + expect(await c.read(agentOwnersProvider.future), isEmpty); + expect(c.read(userCacheProvider)[agent.public]?.ownerPubkey, isNull); + events.remove( + revoked, + ); // stale snapshot must lose to delivered revocation + session.deliver(owned); + session.status!(RelaySubscriptionStatus.ready); + await Future.delayed(const Duration(milliseconds: 180)); + expect(await c.read(agentOwnersProvider.future), isEmpty); + expect(c.read(userCacheProvider)[agent.public]?.ownerPubkey, isNull); + // Ordinary-channel member fallback must not resurrect revoked ownership. + final choices = buildMentionCandidates( + members: [ + ChannelMember( + pubkey: agent.public, + role: 'bot', + joinedAt: DateTime(2024), + ), + ], + relayAgents: await c.read(agentDirectoryProvider.future), + sharedChannelIds: {'room'}, + userCache: c.read(userCacheProvider), + ownerByAgentPubkey: await c.read(agentOwnersProvider.future), + currentPubkey: owner.public, + ); + expect(choices.single.ownerPubkey, isNull); + final retiredCallback = session.changed!; + c.updateOverrides([ + relaySessionProvider.overrideWith(() => session), + myPubkeyProvider.overrideWithValue(relay.public), + ]); + c.read(agentDirectoryProvider); + expect(c.read(userCacheProvider), isEmpty); + retiredCallback(owned); + expect(c.read(userCacheProvider), isEmpty); + }, + ); + test( 'owned coordinates paginate equal-time pages and reject a stalled cursor', () async { diff --git a/mobile/test/shared/mentions/agent_owners_test.dart b/mobile/test/shared/mentions/agent_owners_test.dart index 7a4d26308fb..500885fcd73 100644 --- a/mobile/test/shared/mentions/agent_owners_test.dart +++ b/mobile/test/shared/mentions/agent_owners_test.dart @@ -1,6 +1,7 @@ import 'package:buzz/features/channels/channel_management_provider.dart'; import 'package:buzz/shared/mentions/agent_identity_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; +import 'package:buzz/shared/profile/user_cache_provider.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nostr/nostr.dart' as nostr; @@ -29,6 +30,10 @@ void main() { ], ); try { + final cache = container.read(userCacheProvider.notifier); + for (final event in events) { + cache.cacheProfileEvent(event); + } return await container.read(agentOwnersProvider.future); } finally { container.dispose(); diff --git a/mobile/test/shared/profile/user_cache_provider_test.dart b/mobile/test/shared/profile/user_cache_provider_test.dart index ee70f90a9ac..64d979780d4 100644 --- a/mobile/test/shared/profile/user_cache_provider_test.dart +++ b/mobile/test/shared/profile/user_cache_provider_test.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'dart:typed_data'; import 'package:buzz/shared/profile/user_cache_provider.dart'; +import 'package:buzz/shared/profile/user_profile.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -114,6 +115,40 @@ void main() { expect(cache.state[agent.public]?.ownerPubkey, isNull); }); + test( + 'retired account refresh and preload cannot write into the next cache', + () async { + for (final preload in [false, true]) { + final result = Completer>(); + final session = _RecordingProfileSession(result: result.future); + final c = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(() => session), + myPubkeyProvider.overrideWithValue('first'), + ], + ); + final cache = c.read(userCacheProvider.notifier); + final pending = preload + ? cache.preload(['agent']) + : cache.refresh(['agent']); + if (preload) { + await Future.delayed(const Duration(milliseconds: 60)); + } + c.updateOverrides([ + relaySessionProvider.overrideWith(() => session), + myPubkeyProvider.overrideWithValue('second'), + ]); + expect(c.read(userCacheProvider), isEmpty); + result.complete([ + _profileEvent(id: 'old', createdAt: 2, name: 'Old scope'), + ]); + expect(await pending, isFalse); + expect(c.read(userCacheProvider), isEmpty); + c.dispose(); + } + }, + ); + test('non-profile history cannot poison profile order', () async { final session = _RecordingProfileSession( results: [ @@ -142,6 +177,34 @@ void main() { expect(cache.state['agent']?.displayName, 'Valid'); }); + test( + 'unordered seed is first hydration only and cannot replace an event', + () { + final container = ProviderContainer(); + addTearDown(container.dispose); + final cache = container.read(userCacheProvider.notifier); + cache.put(const UserProfile(pubkey: 'AGENT', displayName: 'Local')); + expect(cache.state['agent']?.displayName, 'Local'); + expect(cache.profileEvent('agent'), isNull); + cache.cacheProfileEvent( + _profileEvent(id: 'b', createdAt: 2, name: 'New'), + ); + cache.put( + const UserProfile( + pubkey: 'agent', + displayName: 'Late', + ownerPubkey: 'owner', + ), + ); + cache.cacheProfileEvent( + _profileEvent(id: 'a', createdAt: 1, name: 'Old'), + ); + expect(cache.state['agent']?.displayName, 'New'); + expect(cache.state['agent']?.ownerPubkey, isNull); + expect(cache.profileEvent('AGENT')?.id, 'b'); + }, + ); + test('same-second profile tie keeps the lowest event id', () { final container = ProviderContainer(); addTearDown(container.dispose);