From ef698b4d6048adcb73785c986e55fe6249ecbada Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Sat, 12 Sep 2026 12:29:05 -0400 Subject: [PATCH] fix(mobile): authenticate profile selection and originating cache admission Preserve authenticated negative ownership evidence, profile edit and display consumers, and independently fenced opening about snapshots. Bind profile admission to originating cache generations and exercise signed replacement and context-retirement workflows. Signed-off-by: Logan Johnson --- .../channels/channel_detail_page.dart | 3 +- .../channel_detail_page/huddle_sheet.dart | 6 +- .../features/channels/channel_directory.dart | 3 +- .../channels/channel_management_provider.dart | 35 +--- .../features/channels/channels_provider.dart | 1 + .../compose_bar/compose_bar_widget.dart | 8 +- .../channels/mentions/mention_candidates.dart | 17 +- .../mentions/mention_candidates_provider.dart | 25 +-- .../invites/invite_create_provider.dart | 12 +- .../features/profile/profile_provider.dart | 37 ++-- .../features/profile/user_profile_sheet.dart | 28 ++- mobile/lib/shared/crypto/nip_oa.dart | 53 ++++- mobile/lib/shared/crypto/signed_event.dart | 27 +++ .../mentions/agent_identity_provider.dart | 4 +- .../shared/profile/user_cache_provider.dart | 66 +++++- .../channels/channel_detail_page_test.dart | 10 +- .../channel_management_provider_test.dart | 152 ++++++++++---- .../channels/channels_provider_test.dart | 46 +++++ .../mentions/mention_candidates_test.dart | 2 +- .../profile/profile_provider_test.dart | 69 +++++-- .../profile/user_profile_sheet_test.dart | 192 ++++++++++++++++++ mobile/test/shared/crypto/nip_oa_test.dart | 37 +++- .../owner_search_generation_test.dart | 183 +++++++++++++++++ .../profile/user_cache_provider_test.dart | 96 ++++----- 24 files changed, 884 insertions(+), 228 deletions(-) create mode 100644 mobile/lib/shared/crypto/signed_event.dart create mode 100644 mobile/test/features/profile/user_profile_sheet_test.dart create mode 100644 mobile/test/shared/mentions/owner_search_generation_test.dart diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index d07cf2425b2..7d7536e567d 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -140,6 +140,7 @@ Future _subscribeToDmIdentityUpdates( }) async { final session = ref.read(relaySessionProvider.notifier); var subscriptionStatus = RelaySubscriptionStatus.retrying; + final admission = ref.read(userCacheProvider.notifier).captureAdmission(); var directLookupComplete = false; final agentPubkeys = {}; @@ -150,7 +151,7 @@ Future _subscribeToDmIdentityUpdates( void handleEvent(NostrEvent event) { if (event.kind == 0) { try { - ref.read(userCacheProvider.notifier).cacheProfileEvent(event); + admission.add(event); } catch (error) { debugPrint('[DmIdentity] invalid live profile: $error'); onFailure(); diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart b/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart index 5b0abb5766e..76f3893cc56 100644 --- a/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart +++ b/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart @@ -54,6 +54,7 @@ class _HuddleParticipantProfileUpdates extends Notifier { final participantPubkeys = ref.watch( _huddleLogicalParticipantPubkeysProvider(channelId), ); + final admission = ref.read(userCacheProvider.notifier).captureAdmission(); final subscriptionVersion = ++_subscriptionVersion; _clearSubscription(); ref.onDispose(() { @@ -65,7 +66,7 @@ class _HuddleParticipantProfileUpdates extends Notifier { ref.read(userCacheProvider.notifier).preload(participantPubkeys); if (relayState.status == SessionStatus.connected) { Future.microtask( - () => _subscribe(participantPubkeys, subscriptionVersion), + () => _subscribe(participantPubkeys, subscriptionVersion, admission), ); } return 0; @@ -74,6 +75,7 @@ class _HuddleParticipantProfileUpdates extends Notifier { Future _subscribe( List participantPubkeys, int subscriptionVersion, + ProfileAdmission admission, ) async { try { final unsubscribe = await ref @@ -86,7 +88,7 @@ class _HuddleParticipantProfileUpdates extends Notifier { ).copyWithSince(DateTime.now().millisecondsSinceEpoch ~/ 1000 - 5), (event) { if (!_isCurrent(subscriptionVersion)) return; - ref.read(userCacheProvider.notifier).cacheProfileEvent(event); + admission.add(event); state++; }, ); diff --git a/mobile/lib/features/channels/channel_directory.dart b/mobile/lib/features/channels/channel_directory.dart index 1282e5f1e14..d7cf5f08fd3 100644 --- a/mobile/lib/features/channels/channel_directory.dart +++ b/mobile/lib/features/channels/channel_directory.dart @@ -212,8 +212,7 @@ Future> _resolveDmDisplayNames( session.fetchHistory(NostrFilters.profilesBatch(dmParticipants.toList())), ); final displayNames = {}; - for (final event in profileEvents) { - if (event.kind != 0) continue; + for (final event in latestProfileEvents(profileEvents).values) { final profile = ProfileData.fromEvent(event); final label = profile.displayName?.trim().isNotEmpty == true ? profile.displayName!.trim() diff --git a/mobile/lib/features/channels/channel_management_provider.dart b/mobile/lib/features/channels/channel_management_provider.dart index 36e640b411c..aeaa16ad879 100644 --- a/mobile/lib/features/channels/channel_management_provider.dart +++ b/mobile/lib/features/channels/channel_management_provider.dart @@ -305,17 +305,7 @@ List relayMemberPubkeysFromEvents(List events) { /// Converts kind:0 events into a deduplicated, alphabetized people directory. @visibleForTesting List directoryUsersFromProfileEvents(List events) { - final latestByPubkey = {}; - for (final event in events) { - if (event.kind != 0) { - continue; - } - final pubkey = event.pubkey.toLowerCase(); - final current = latestByPubkey[pubkey]; - if (current == null || event.createdAt > current.createdAt) { - latestByPubkey[pubkey] = event; - } - } + final latestByPubkey = latestProfileEvents(events); return [ for (final event in latestByPubkey.values) @@ -325,7 +315,7 @@ List directoryUsersFromProfileEvents(List events) { displayName: profile.displayName, avatarUrl: profile.avatarUrl, nip05Handle: profile.nip05, - isAgent: verifiedOaOwnerPubkey(event.tags, event.pubkey) != null, + isAgent: verifiedOaOwnerPubkey(event) != null, ), ]..sort((a, b) { final labelComparison = a.label.toLowerCase().compareTo( @@ -382,29 +372,14 @@ final relayDirectoryUsersProvider = NostrFilters.profilesBatch(memberPubkeys), ]); final profilesByPubkey = { - for (final event in profileEvents) - event.pubkey.toLowerCase(): ProfileData.fromEvent(event), + for (final user in directoryUsersFromProfileEvents(profileEvents)) + user.pubkey: user, }; users = [ for (final pubkey in memberPubkeys) if (profilesByPubkey[pubkey] case final profile?) - DirectoryUser( - pubkey: pubkey, - displayName: profile.displayName, - avatarUrl: profile.avatarUrl, - nip05Handle: profile.nip05, - isAgent: - verifiedOaOwnerPubkey( - profileEvents - .firstWhere( - (event) => event.pubkey.toLowerCase() == pubkey, - ) - .tags, - pubkey, - ) != - null, - ) + profile else DirectoryUser(pubkey: pubkey), ]..sort((a, b) { diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index 8e724cc3a84..bf800c56866 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -7,6 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/community/community_provider.dart'; import '../../shared/push/push_presentation_cache.dart'; +import '../../shared/crypto/nip_oa.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme_provider.dart'; import '../../shared/utils/string_utils.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 dbf8b8f0fbe..012b42e0d4f 100644 --- a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart +++ b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart @@ -244,7 +244,9 @@ class ComposeBar extends HookConsumerWidget { // Preload profiles for channel members, mentionable agents, and their // owners so @mention suggestions show names ("managed by …" included). final relayAgents = ref.watch(agentDirectoryProvider).asData?.value; - final agentOwners = ref.watch(agentOwnersProvider).asData?.value; + final owners = ref.watch(agentOwnersProvider); + final agentOwners = owners.asData?.value; + final profilePubkeys = ref.read(userCacheProvider.notifier).profilePubkeys; final agentMentionLabels = _agentMentionLabels(bindings: mentionMap.value); final agentMentionLabelsKey = (agentMentionLabels.toList()..sort()).join( '\u0000', @@ -492,6 +494,8 @@ class ComposeBar extends HookConsumerWidget { sharedChannelIds: const {}, userCache: userCache, ownerByAgentPubkey: agentOwners ?? const {}, + authoritativeProfilePubkeys: profilePubkeys, + ownerSourceAvailable: !owners.isLoading && !owners.hasError, ), buildMentionCandidates( members: membersAsync.asData?.value ?? const [], @@ -502,6 +506,8 @@ class ComposeBar extends HookConsumerWidget { }, userCache: userCache, ownerByAgentPubkey: agentOwners ?? const {}, + authoritativeProfilePubkeys: profilePubkeys, + ownerSourceAvailable: !owners.isLoading && !owners.hasError, currentPubkey: currentPubkey, // Reuse ordinary search-result classification, not membership as // permission. Persisted keys/flags themselves prove no role. diff --git a/mobile/lib/features/channels/mentions/mention_candidates.dart b/mobile/lib/features/channels/mentions/mention_candidates.dart index 2187fedd73a..a2ef67dc646 100644 --- a/mobile/lib/features/channels/mentions/mention_candidates.dart +++ b/mobile/lib/features/channels/mentions/mention_candidates.dart @@ -51,9 +51,17 @@ List buildMentionCandidates({ required Set sharedChannelIds, required Map userCache, required Map ownerByAgentPubkey, + Set authoritativeProfilePubkeys = const {}, + bool ownerSourceAvailable = true, List searchResults = const [], String? currentPubkey, }) { + // Display/search profiles can outlive their admitting cache generation. + String? ownerFor(String key) => !ownerSourceAvailable + ? null + : authoritativeProfilePubkeys.contains(key) + ? userCache[key]?.ownerPubkey + : ownerByAgentPubkey[key]; final candidates = []; final seen = {}; @@ -61,7 +69,7 @@ List buildMentionCandidates({ final pk = member.pubkey.toLowerCase(); if (!seen.add(pk)) continue; final profile = userCache[pk]; - final ownerPubkey = ownerByAgentPubkey[pk] ?? profile?.ownerPubkey; + final ownerPubkey = ownerFor(pk); final isAgent = member.isBot || ownerPubkey != null; candidates.add( MentionCandidate( @@ -104,7 +112,7 @@ List buildMentionCandidates({ avatarUrl: profile?.avatarUrl, isAgent: true, isMember: false, - ownerPubkey: ownerByAgentPubkey[pk] ?? profile?.ownerPubkey, + ownerPubkey: ownerFor(pk), ), ); } @@ -113,8 +121,9 @@ List buildMentionCandidates({ for (final profile in searchResults) { final pk = profile.pubkey.toLowerCase(); if (seen.contains(pk)) continue; - final ownerPubkey = ownerByAgentPubkey[pk] ?? profile.ownerPubkey; - final isAgent = ownerPubkey != null || directoryPubkeys.contains(pk); + final ownerPubkey = ownerFor(pk); + final isAgent = + profile.isAgent || ownerPubkey != null || directoryPubkeys.contains(pk); if (isAgent) { // Mirrors desktop's `shouldHideAgentFromMentions` for non-member // agents: show only when invocable. Invocable = owned by the current diff --git a/mobile/lib/features/channels/mentions/mention_candidates_provider.dart b/mobile/lib/features/channels/mentions/mention_candidates_provider.dart index 85930a45995..338f3fdc741 100644 --- a/mobile/lib/features/channels/mentions/mention_candidates_provider.dart +++ b/mobile/lib/features/channels/mentions/mention_candidates_provider.dart @@ -22,6 +22,8 @@ const _mentionSearchDebounce = Duration(milliseconds: 250); /// keystrokes dispose the stale family member so its request never fires. final mentionUserSearchProvider = FutureProvider.autoDispose .family, String>((ref, query) async { + ref.watch(relayConfigProvider); + final admission = ref.read(userCacheProvider.notifier).captureAdmission(); final trimmed = query.trim(); if (trimmed.isEmpty) return const []; @@ -38,16 +40,11 @@ final mentionUserSearchProvider = FutureProvider.autoDispose // Keep only the latest kind:0 event per pubkey (the bridge does not // honor the `kinds` filter under search, and may return several // profile revisions — mirrors desktop's `list_user_search_results`). - final latestByPubkey = {}; - for (final event in events) { - if (event.kind != 0) continue; - final pk = event.pubkey.toLowerCase(); - final current = latestByPubkey[pk]; - if (current == null || event.createdAt > current.createdAt) { - latestByPubkey[pk] = event; - } + if (disposed || !admission.isCurrent) return const []; + final latestByPubkey = latestProfileEvents(events); + for (final event in latestByPubkey.values) { + admission.add(event); } - return [ for (final event in latestByPubkey.values) _profileFromEvent(event), ]; @@ -61,7 +58,7 @@ UserProfile _profileFromEvent(NostrEvent event) { avatarUrl: data.avatarUrl, about: data.about, nip05Handle: data.nip05, - ownerPubkey: verifiedOaOwnerPubkey(event.tags, event.pubkey), + ownerPubkey: verifiedOaOwnerPubkey(event), ); } @@ -89,7 +86,7 @@ final mentionCandidatesProvider = Provider.family final relayAgents = ref.watch(agentDirectoryProvider).asData?.value ?? const []; - final owners = ref.watch(agentOwnersProvider).asData?.value ?? const {}; + final owners = ref.watch(agentOwnersProvider); final channels = channelsAsync.asData?.value ?? const []; final userCache = ref.watch(userCacheProvider); final currentPubkey = ref.watch(currentPubkeyProvider); @@ -107,7 +104,11 @@ final mentionCandidatesProvider = Provider.family relayAgents: relayAgents, sharedChannelIds: sharedChannelIds, userCache: userCache, - ownerByAgentPubkey: owners, + ownerByAgentPubkey: owners.asData?.value ?? const {}, + ownerSourceAvailable: !owners.isLoading && !owners.hasError, + authoritativeProfilePubkeys: ref + .read(userCacheProvider.notifier) + .profilePubkeys, searchResults: searchResults, currentPubkey: currentPubkey, ); diff --git a/mobile/lib/features/invites/invite_create_provider.dart b/mobile/lib/features/invites/invite_create_provider.dart index 88c06f847bf..65415a82043 100644 --- a/mobile/lib/features/invites/invite_create_provider.dart +++ b/mobile/lib/features/invites/invite_create_provider.dart @@ -9,6 +9,7 @@ import 'package:share_plus/share_plus.dart'; import '../../shared/community/community_membership_provider.dart'; import '../../shared/utils/string_utils.dart'; +import '../../shared/crypto/nip_oa.dart'; import '../../shared/relay/relay.dart'; /// The default lifetime of a newly minted community invite link. @@ -301,17 +302,8 @@ final communityInviteActionsProvider = Provider((ref) { List _directoryUsersFromEvents( List events, ) { - final latestByPubkey = {}; - for (final event in events) { - if (event.kind != 0) continue; - final pubkey = event.pubkey.toLowerCase(); - final current = latestByPubkey[pubkey]; - if (current == null || event.createdAt > current.createdAt) { - latestByPubkey[pubkey] = event; - } - } final users = [ - for (final event in latestByPubkey.values) + for (final event in latestProfileEvents(events).values) if (ProfileData.fromEvent(event) case final profile) CommunityInviteDirectoryUser( pubkey: profile.pubkey.toLowerCase(), diff --git a/mobile/lib/features/profile/profile_provider.dart b/mobile/lib/features/profile/profile_provider.dart index 93621f7608a..b612b42c010 100644 --- a/mobile/lib/features/profile/profile_provider.dart +++ b/mobile/lib/features/profile/profile_provider.dart @@ -80,6 +80,7 @@ class ProfileNotifier extends AsyncNotifier { final pubkey = ref.watch(myPubkeyProvider); ref.watch(relaySessionProvider); final context = _ProfileWriteContext( + admission: ref.read(userCacheProvider.notifier).captureAdmission(), config: config, pubkey: pubkey, session: ref.read(relaySessionProvider.notifier), @@ -100,14 +101,14 @@ class ProfileNotifier extends AsyncNotifier { final session = context.session; final events = await session.fetchHistory(NostrFilters.profile(myPk)); - if (events.isEmpty) { + final latest = latestProfileEvents(events)[myPk.toLowerCase()]; + if (latest == null) { _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( @@ -116,9 +117,10 @@ class ProfileNotifier extends AsyncNotifier { avatarUrl: data.avatarUrl, about: data.about, nip05Handle: data.nip05, - ownerPubkey: verifiedOaOwnerPubkey(latest.tags, data.pubkey), + ownerPubkey: verifiedOaOwnerPubkey(latest), ); _requireCurrentWriteContext(context); + context.admission.add(latest); _metadata = metadata; _lastCreatedAt = latest.createdAt; _hasHydrated = true; @@ -160,6 +162,7 @@ class ProfileNotifier extends AsyncNotifier { } _ProfileWriteContext _currentWriteContext() => _ProfileWriteContext( + admission: ref.read(userCacheProvider.notifier).captureAdmission(), config: ref.read(relayConfigProvider), pubkey: ref.read(myPubkeyProvider), session: ref.read(relaySessionProvider.notifier), @@ -182,7 +185,9 @@ class ProfileNotifier extends AsyncNotifier { NostrFilters.profile(pubkey), ); _requireCurrentWriteContext(context); - final currentHead = _latestProfileEvent(currentEvents); + final currentHead = latestProfileEvents( + currentEvents, + )[pubkey.toLowerCase()]; if (_lastCreatedAt > 0 && (currentHead == null || currentHead.createdAt < _lastCreatedAt)) { throw StateError('Cannot confirm the latest profile metadata.'); @@ -216,9 +221,9 @@ class ProfileNotifier extends AsyncNotifier { if (submittedEvent == null) { throw StateError('Profile update was not signed.'); } - final verifiedHead = _latestProfileEvent( + final verifiedHead = latestProfileEvents( await session.fetchHistory(NostrFilters.profile(pubkey)), - ); + )[pubkey.toLowerCase()]; _requireCurrentWriteContext(context); if (verifiedHead?.id != submittedEvent.id) { throw StateError('Profile changed before the update could be confirmed.'); @@ -233,17 +238,18 @@ class ProfileNotifier extends AsyncNotifier { avatarUrl: _metadata['picture'] as String?, about: _metadata['about'] as String?, nip05Handle: _metadata['nip05'] as String?, - ownerPubkey: verifiedOaOwnerPubkey(submittedEvent.tags, pubkey), + ownerPubkey: verifiedOaOwnerPubkey(submittedEvent), ); state = AsyncData(profile); - ref.read(userCacheProvider.notifier).put(profile); + context.admission.add(submittedEvent); } void _requireCurrentWriteContext(_ProfileWriteContext context) { final currentConfig = ref.read(relayConfigProvider); final currentSession = ref.read(relaySessionProvider.notifier); final currentPubkey = ref.read(myPubkeyProvider); - if (currentConfig.storedOrigin != context.config.storedOrigin || + if (!context.admission.isCurrent || + currentConfig.storedOrigin != context.config.storedOrigin || currentConfig.nsec != context.config.nsec || currentPubkey != context.pubkey || !identical(currentSession, context.session)) { @@ -254,27 +260,18 @@ class ProfileNotifier extends AsyncNotifier { class _ProfileWriteContext { const _ProfileWriteContext({ + required this.admission, required this.config, required this.pubkey, required this.session, }); + final ProfileAdmission admission; final RelayConfig config; final String? pubkey; final RelaySessionNotifier session; } -NostrEvent? _latestProfileEvent(List events) { - if (events.isEmpty) return null; - return events.reduce((current, event) { - if (event.createdAt != current.createdAt) { - return event.createdAt > current.createdAt ? event : current; - } - // Match the relay replacement tie-breaker: the lowest event id wins. - return event.id.compareTo(current.id) < 0 ? event : current; - }); -} - Map _decodeProfileMetadata(NostrEvent event) { try { final decoded = jsonDecode(event.content); diff --git a/mobile/lib/features/profile/user_profile_sheet.dart b/mobile/lib/features/profile/user_profile_sheet.dart index 97af99fa00a..1bc8c78fc9c 100644 --- a/mobile/lib/features/profile/user_profile_sheet.dart +++ b/mobile/lib/features/profile/user_profile_sheet.dart @@ -7,6 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/animated_avatar.dart'; +import '../../shared/crypto/nip_oa.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; import '../../shared/utils/string_utils.dart'; @@ -51,27 +52,40 @@ class UserProfileSheet extends HookConsumerWidget { // Watch cached profile, presence, and user status. final profile = - ref.watch(userCacheProvider.select((cache) => cache[pk])) ?? + ref.watch(userCacheProvider)[pk] ?? ref.read(userCacheProvider.notifier).get(pk); final presenceMap = ref.watch(presenceCacheProvider); final presence = presenceMap[pk] ?? 'offline'; final statusCache = ref.watch(userStatusCacheProvider); final userStatus = statusCache[pk]; - // Fetch about from the user's kind:0 profile event. + final config = ref.watch(relayConfigProvider); + final cache = ref.watch(userCacheProvider.notifier); + final admission = useMemoized(cache.captureAdmission, [ + pk, + config, + cache, + cache.generation, + ]); + // Keep the independent opening snapshot, fenced by the cache's scope. final aboutFuture = useMemoized( () => ref .read(relaySessionProvider.notifier) .fetchHistory(NostrFilters.profile(pk)) .then((events) { - if (events.isEmpty) return ''; - return ProfileData.fromEvent(events.first).about ?? ''; + if (!admission.isCurrent) return ''; + final latest = latestProfileEvents(events)[pk]; + if (latest == null) return ''; + return ProfileData.fromEvent(latest).about ?? ''; }) .catchError((_) => ''), - [pk], + [pk, admission], ); - final aboutSnapshot = useFuture(aboutFuture); - final about = aboutSnapshot.data ?? profile?.about ?? ''; + final aboutSnapshot = useFuture(aboutFuture, preserveState: false); + final about = + (admission.isCurrent ? aboutSnapshot.data : null) ?? + profile?.about ?? + ''; // Ensure presence and status are tracked. useEffect(() { diff --git a/mobile/lib/shared/crypto/nip_oa.dart b/mobile/lib/shared/crypto/nip_oa.dart index f9d00ef1c59..6db92f43cea 100644 --- a/mobile/lib/shared/crypto/nip_oa.dart +++ b/mobile/lib/shared/crypto/nip_oa.dart @@ -4,6 +4,9 @@ import 'dart:typed_data'; import 'package:nostr/nostr.dart' as nostr; import 'package:pointycastle/digests/sha256.dart'; +import '../relay/nostr_models.dart'; +import 'signed_event.dart'; + /// NIP-OA (Owner Attestation) — verify the `auth` tag on a kind:0 profile /// that proves an owner key authorized an agent key. /// @@ -15,22 +18,28 @@ import 'package:pointycastle/digests/sha256.dart'; /// verified against the profile event author, so a forged or stale marker /// cannot turn a person into an agent. /// -/// Returns the owner pubkey (lowercase hex) for the first valid auth tag, -/// or null if none verifies. -String? verifiedOaOwnerPubkey(List> tags, String agentPubkey) { - final agent = agentPubkey.toLowerCase(); +/// Returns the owner only for one valid auth tag on a signed kind:0 envelope. +String? verifiedOaOwnerPubkey(NostrEvent event) { + final tags = event.tags.where((tag) => tag.isNotEmpty && tag[0] == 'auth'); + if (event.kind != 0 || tags.length != 1 || !verifySignedEvent(event)) { + return null; + } + final agent = event.pubkey; for (final tag in tags) { if (tag.length != 4 || tag[0] != 'auth') continue; - final owner = tag[1].toLowerCase(); + final owner = tag[1]; final conditions = tag[2]; final sig = tag[3]; // Self-attestation is meaningless and rejected. if (owner == agent) continue; - if (owner.length != 64 || sig.length != 128) continue; - if (!_validConditions(conditions)) continue; + if (!RegExp(r'^[0-9a-f]{64}$').hasMatch(owner) || + !RegExp(r'^[0-9a-f]{128}$').hasMatch(sig)) { + continue; + } + if (!_validConditions(conditions, event)) continue; final preimage = utf8.encode('nostr:agent-auth:$agent:$conditions'); final digest = SHA256Digest().process(Uint8List.fromList(preimage)); @@ -54,9 +63,27 @@ String? verifiedOaOwnerPubkey(List> tags, String agentPubkey) { return null; } +/// Select the latest authenticated profile, including signed revocations. +/// NIP-01 ties choose the lowest event id, independent of response order. +Map latestProfileEvents(Iterable events) { + final latest = {}; + for (final event in events.where((event) => event.kind == 0)) { + if (!verifySignedEvent(event)) continue; + final key = event.pubkey.toLowerCase(); + final previous = latest[key]; + if (previous == null || + event.createdAt > previous.createdAt || + (event.createdAt == previous.createdAt && + event.id.compareTo(previous.id) < 0)) { + latest[key] = event; + } + } + return latest; +} + /// Validate the NIP-OA `conditions` string: empty, or `&`-joined clauses of /// `kind=`, `created_at<`, or `created_at>` with canonical decimals. -bool _validConditions(String conditions) { +bool _validConditions(String conditions, NostrEvent event) { if (conditions.isEmpty) return true; if (conditions.contains(RegExp(r'\s'))) return false; @@ -67,7 +94,15 @@ bool _validConditions(String conditions) { if (match == null) return false; final value = int.tryParse(match.group(1)!); if (value == null || value > 4294967295) return false; - if (clause.startsWith('kind=') && value > 65535) return false; + if (clause.startsWith('kind=') && (value > 65535 || value != event.kind)) { + return false; + } + if (clause.startsWith('created_at<') && event.createdAt >= value) { + return false; + } + if (clause.startsWith('created_at>') && event.createdAt <= value) { + return false; + } } return true; diff --git a/mobile/lib/shared/crypto/signed_event.dart b/mobile/lib/shared/crypto/signed_event.dart new file mode 100644 index 00000000000..1b51094563f --- /dev/null +++ b/mobile/lib/shared/crypto/signed_event.dart @@ -0,0 +1,27 @@ +import 'package:nostr/nostr.dart' as nostr; + +import '../relay/nostr_models.dart'; + +/// Verify the canonical event id and author's signature without a wall-clock +/// freshness restriction. Authority readers apply their own kind/signer scope. +bool verifySignedEvent(NostrEvent event) { + if (!RegExp(r'^[0-9a-f]{64}$').hasMatch(event.pubkey) || + !RegExp(r'^[0-9a-f]{64}$').hasMatch(event.id) || + !RegExp(r'^[0-9a-f]{128}$').hasMatch(event.sig) || + event.createdAt < 0 || + event.kind < 0 || + event.kind > 65535) { + return false; + } + try { + final signed = nostr.Event.fromMap(event.toJson(), verify: false); + return signed.getEventId() == event.id && + nostr.Schnorr.verify( + publicKey: event.pubkey, + message: event.id, + signature: event.sig, + ); + } catch (_) { + return false; + } +} diff --git a/mobile/lib/shared/mentions/agent_identity_provider.dart b/mobile/lib/shared/mentions/agent_identity_provider.dart index d4d5b237ae8..1439ee584ce 100644 --- a/mobile/lib/shared/mentions/agent_identity_provider.dart +++ b/mobile/lib/shared/mentions/agent_identity_provider.dart @@ -81,8 +81,8 @@ final agentOwnersProvider = FutureProvider>((ref) async { NostrFilters.profilesBatch([for (final agent in agents) agent.pubkey]), ); final owners = {}; - for (final event in events) { - final owner = verifiedOaOwnerPubkey(event.tags, event.pubkey); + for (final event in latestProfileEvents(events).values) { + final owner = verifiedOaOwnerPubkey(event); if (owner != null) owners[event.pubkey.toLowerCase()] = owner; } return owners; diff --git a/mobile/lib/shared/profile/user_cache_provider.dart b/mobile/lib/shared/profile/user_cache_provider.dart index c974b4a055f..be49800d275 100644 --- a/mobile/lib/shared/profile/user_cache_provider.dart +++ b/mobile/lib/shared/profile/user_cache_provider.dart @@ -4,10 +4,28 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../community/community_provider.dart'; import '../crypto/nip_oa.dart'; +import '../crypto/signed_event.dart'; import '../push/push_presentation_cache.dart'; import '../relay/relay.dart'; import 'user_profile.dart'; +/// A cache-issued capability bound to the request or subscription's context. +/// Capture before starting asynchronous work; never mint one in a late callback. +class ProfileAdmission { + ProfileAdmission._(this._cache, this._config, this._generation); + final UserCacheNotifier _cache; + final RelayConfig _config; + final int _generation; + + /// Whether this originating context still owns the cache. + bool get isCurrent => _cache._admits(_config, _generation); + + /// Applies ordered evidence only while the originating context is current. + void add(NostrEvent event) { + if (isCurrent) _cache._acceptProfileEvent(event); + } +} + /// In-memory cache of user profiles, fetched in batches from the relay. /// /// Lookups requested via [get] or [preload] are coalesced into a single @@ -15,6 +33,7 @@ import 'user_profile.dart'; class UserCacheNotifier extends Notifier> { final Set _pending = {}; final Map _profileEventOrders = {}; + int _generation = 0; Timer? _batchTimer; Completer? _batchCompleter; @@ -22,6 +41,8 @@ class UserCacheNotifier extends Notifier> { Map build() { ref.watch(relayConfigProvider); _profileEventOrders.clear(); + _pending.clear(); + _generation++; ref.onDispose(() { _batchTimer?.cancel(); _batchTimer = null; @@ -40,8 +61,21 @@ class UserCacheNotifier extends Notifier> { return null; } - /// Stores a profile that was fetched or updated outside the batch loader. + /// Current cache lifetime, advanced even by same-context invalidation. + int get generation => _generation; + + /// Keys with ordered evidence, distinct from display-only seeds. + Set get profilePubkeys => _profileEventOrders.keys.toSet(); + + /// Owner projection of governing evidence only; display seeds grant nothing. + Map get profileOwners => { + for (final key in profilePubkeys) + if (state[key]?.ownerPubkey case final String owner) key: owner, + }; + + /// Seeds display data only until an ordered profile has been observed. void put(UserProfile profile) { + if (_profileEventOrders.containsKey(profile.pubkey.toLowerCase())) return; state = {...state, profile.pubkey.toLowerCase(): profile}; } @@ -72,12 +106,14 @@ class UserCacheNotifier extends Notifier> { .toSet() .toList(); if (normalized.isEmpty) return true; + final admission = captureAdmission(); try { final session = ref.read(relaySessionProvider.notifier); final events = await session.fetchHistory( NostrFilters.profilesBatch(normalized), ); final updated = Map.from(state); + if (!admission.isCurrent) return false; final updatedOrders = Map.from( _profileEventOrders, ); @@ -94,11 +130,24 @@ class UserCacheNotifier extends Notifier> { } } + /// Captures authority for profile ingress before a request/subscription starts. + ProfileAdmission captureAdmission() { + final config = ref.read(relayConfigProvider); + final _ = state; + return ProfileAdmission._(this, config, _generation); + } + + bool _admits(RelayConfig config, int generation) { + if (!ref.mounted || ref.read(relayConfigProvider) != config) return false; + final _ = state; // Resolve lazy invalidation before comparing. + return generation == _generation; + } + /// Applies a live kind:0 profile event to the cache. /// /// Surfaces that keep a participant-scoped profile subscription can use this /// to update names and avatars without discarding the rest of the cache. - void cacheProfileEvent(NostrEvent event) { + void _acceptProfileEvent(NostrEvent event) { if (event.kind != 0) return; final updated = Map.from(state); if (_cacheProfileEvent(event, updated)) state = updated; @@ -120,6 +169,7 @@ class UserCacheNotifier extends Notifier> { final completer = _batchCompleter; _batchCompleter = null; + final admission = captureAdmission(); var succeeded = false; try { final communityID = ref.read(activeCommunityProvider).value?.id; @@ -129,6 +179,7 @@ class UserCacheNotifier extends Notifier> { ); final updated = Map.from(state); + if (!admission.isCurrent) return; final updatedOrders = Map.from( _profileEventOrders, ); @@ -156,7 +207,7 @@ class UserCacheNotifier extends Notifier> { Map profiles, [ Map? orders, ]) { - if (event.kind != 0) return false; + if (event.kind != 0 || !verifySignedEvent(event)) return false; final eventOrders = orders ?? _profileEventOrders; final pubkey = event.pubkey.toLowerCase(); final current = eventOrders[pubkey]; @@ -173,7 +224,12 @@ class UserCacheNotifier extends Notifier> { } UserProfile _profileFromEvent(NostrEvent event) { - final data = ProfileData.fromEvent(event); + ProfileData data; + try { + data = ProfileData.fromEvent(event); + } catch (_) { + data = ProfileData(pubkey: event.pubkey); + } final pubkey = data.pubkey.toLowerCase(); return UserProfile( pubkey: pubkey, @@ -181,7 +237,7 @@ class UserCacheNotifier extends Notifier> { avatarUrl: data.avatarUrl, about: data.about, nip05Handle: data.nip05, - ownerPubkey: verifiedOaOwnerPubkey(event.tags, event.pubkey), + ownerPubkey: verifiedOaOwnerPubkey(event), ); } } diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 7159fb2fd1e..f3c91edb5d1 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -1,3 +1,4 @@ +import '../../shared/crypto/nip_oa_test.dart' show profile; import 'dart:async'; import 'dart:collection'; import 'dart:convert'; @@ -1031,12 +1032,11 @@ void main() { expect(find.byTooltip('Start Huddle'), findsNothing); relaySession.emitProfile( - _profileEvent( - id: 'newer-agent', - pubkey: agent.public, + profile( + agent, + [_authTag(owner, agent.public)], createdAt: 2, - name: 'Agent', - tags: [_authTag(owner, agent.public)], + content: '{"name":"Agent"}', ), ); profileRefresh.complete([ diff --git a/mobile/test/features/channels/channel_management_provider_test.dart b/mobile/test/features/channels/channel_management_provider_test.dart index 69f588622b6..a3aeb1fa400 100644 --- a/mobile/test/features/channels/channel_management_provider_test.dart +++ b/mobile/test/features/channels/channel_management_provider_test.dart @@ -1,9 +1,15 @@ +import 'package:buzz/features/profile/profile_provider.dart'; +import 'package:buzz/features/invites/invite_create_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_cache_provider.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nostr/nostr.dart' as nostr; import 'package:buzz/features/channels/channel_management_provider.dart'; import 'package:buzz/features/channels/mobile_huddle_controller.dart'; import 'package:buzz/shared/relay/relay.dart'; +import '../../shared/crypto/nip_oa_test.dart' show authTag, profile; /// Tests for [channelDetailsFromEvent]. /// @@ -13,6 +19,91 @@ import 'package:buzz/shared/relay/relay.dart'; /// also exposed on `ChannelDetails` MUST be propagated here — otherwise /// `mergeDetails` silently clears that state on the merged Channel. void main() { + test('profile consumers select signed replacements in either order', () async { + final owner = nostr.Keys.generate(); + final agent = nostr.Keys.generate(); + final owned = profile(agent, [ + authTag(owner, agent.public), + ], content: '{"name":"Owned"}'); + final revoked = profile( + agent, + [], + createdAt: 101, + content: '{"name":"Revoked"}', + ); + final tie = profile(agent, [], content: '{"name":"Tie"}'); + final advanced = profile(agent, owned.tags, createdAt: 103); + final tampered = [ + for (final timestamp in [owned.createdAt, 102]) + NostrEvent.fromJson({ + ...owned.toJson(), + 'created_at': timestamp, + 'id': '0' * 64, + 'content': + '{"name":"Forged","picture":"https://forged.example/a","nip05":"fake@forged.example"}', + }), + ]; + final invalidOa = profile(agent, [ + authTag(owner, owner.public), + ], createdAt: 102); + for (final (events, winner) in [ + ([owned], owned), + ([owned, revoked], revoked), + ([revoked, advanced], advanced), + for (final forged in tampered) ([owned, forged], owned), + ([owned, invalidOa], invalidOa), + ([owned, tie], owned.id.compareTo(tie.id) < 0 ? owned : tie), + ]) { + for (final ordered in [events, events.reversed.toList()]) { + final data = ProfileData.fromEvent(winner); + final expected = winner == owned || winner == advanced + ? owner.public + : null; + final session = _DirectoryFakeRelaySession(profileEvents: ordered); + final container = ProviderContainer.test( + overrides: [ + relayConfigProvider.overrideWith(_FixedRelayConfigNotifier.new), + myPubkeyProvider.overrideWithValue(agent.public), + relaySessionProvider.overrideWith(() => session), + agentDirectoryProvider.overrideWith( + (ref) async => [AgentDirectoryEntry(pubkey: agent.public)], + ), + ], + ); + expect( + await container.read(agentOwnersProvider.future), + expected == null ? isEmpty : {agent.public: expected}, + ); + container.listen(mentionUserSearchProvider('agent'), (_, _) {}); + final found = await container.read( + mentionUserSearchProvider('agent').future, + ); + expect(found.single.ownerPubkey, expected); + expect(found.single.displayName, data.displayName); + final directory = directoryUsersFromProfileEvents(ordered).single; + expect(directory.isAgent, expected != null); + expect(directory.displayName, data.displayName); + final ownProfile = await container.read(profileProvider.future); + expect(ownProfile?.displayName, data.displayName); + expect(ownProfile?.ownerPubkey, expected); + final invitee = await container.read( + communityInviteProfileProvider(agent.public).future, + ); + expect(invitee?.displayName, data.displayName); + final cache = container.read(userCacheProvider.notifier); + for (final event in ordered) { + cache.captureAdmission().add(event); + } + final cached = cache.state[agent.public]!; + expect(cached.displayName, data.displayName); + expect(cached.avatarUrl, data.avatarUrl); + expect(cached.nip05Handle, data.nip05); + expect(cached.ownerPubkey, expected); + container.dispose(); + } + } + }); + test('extracts unique relay members from current and legacy tags', () { final pubkeys = relayMemberPubkeysFromEvents([ NostrEvent( @@ -36,34 +127,17 @@ void main() { }); test('builds an alphabetized directory from the latest profile events', () { + final alice = nostr.Keys.generate(); + final bob = nostr.Keys.generate(); final users = directoryUsersFromProfileEvents([ - NostrEvent( - id: 'alice-old', - pubkey: 'alice', - createdAt: 10, - kind: 0, - tags: const [], - content: '{"display_name":"Zoe"}', - sig: 'sig', - ), - NostrEvent( - id: 'bob', - pubkey: 'bob', - createdAt: 20, - kind: 0, - tags: const [], - content: '{"display_name":"Bob"}', - sig: 'sig', - ), - NostrEvent( - id: 'alice-new', - pubkey: 'ALICE', + profile(alice, [], createdAt: 10, content: '{"display_name":"Zoe"}'), + profile(bob, [], createdAt: 20, content: '{"display_name":"Bob"}'), + profile( + alice, + [], createdAt: 30, - kind: 0, - tags: const [], content: '{"display_name":"Alice","picture":"https://example.com/alice.png"}', - sig: 'sig', ), NostrEvent( id: 'not-a-profile', @@ -77,7 +151,7 @@ void main() { ]); expect(users.map((user) => user.label), ['Alice', 'Bob']); - expect(users.first.pubkey, 'alice'); + expect(users.first.pubkey, alice.public); expect(users.first.avatarUrl, 'https://example.com/alice.png'); }); @@ -634,14 +708,14 @@ void main() { }); group('directory providers relay-config invalidation', () { - NostrEvent profile(String pubkey, String name) => NostrEvent( - id: '$pubkey-profile', - pubkey: pubkey, - createdAt: 1700000000, - kind: 0, - tags: const [], - content: '{"display_name":"$name"}', - sig: 'sig', + NostrEvent label(nostr.Keys keys, String name) => NostrEvent.fromJson( + nostr.Event.from( + secretKey: keys.secret, + createdAt: 1700000000, + kind: 0, + tags: const [], + content: '{"display_name":"$name"}', + ).toMap(), ); ProviderContainer buildContainer(_DirectoryFakeRelaySession session) { @@ -656,7 +730,7 @@ void main() { test('browse directory refetches when the relay config changes', () async { final session = _DirectoryFakeRelaySession( - profileEvents: [profile('alice', 'Alice')], + profileEvents: [label(nostr.Keys.generate(), 'Alice')], ); final container = buildContainer(session); addTearDown(container.dispose); @@ -678,7 +752,7 @@ void main() { // Simulate switching to a community that shares the same signing key: // session notifier instance and pubkey both survive; only the relay // config changes. - session.profileEvents = [profile('bob', 'Bob')]; + session.profileEvents = [label(nostr.Keys.generate(), 'Bob')]; container .read(relayConfigProvider.notifier) .update(baseUrl: 'http://other-community.example', nsec: null); @@ -693,7 +767,7 @@ void main() { test('search results refetch when the relay config changes', () async { final session = _DirectoryFakeRelaySession( - profileEvents: [profile('alice', 'Alice')], + profileEvents: [label(nostr.Keys.generate(), 'Alice')], ); final container = buildContainer(session); addTearDown(container.dispose); @@ -710,7 +784,7 @@ void main() { expect(firstResults.map((user) => user.label), ['Alice']); expect(session.searchQueryCount, 1); - session.profileEvents = [profile('alina', 'Alina')]; + session.profileEvents = [label(nostr.Keys.generate(), 'Alina')]; container .read(relayConfigProvider.notifier) .update(baseUrl: 'http://other-community.example', nsec: null); @@ -725,7 +799,7 @@ void main() { test('cached search families are released once unlistened', () async { final session = _DirectoryFakeRelaySession( - profileEvents: [profile('alice', 'Alice')], + profileEvents: [label(nostr.Keys.generate(), 'Alice')], ); final container = buildContainer(session); addTearDown(container.dispose); @@ -862,6 +936,6 @@ class _DirectoryFakeRelaySession extends RelaySessionNotifier { NostrFilter filter, { Duration timeout = const Duration(seconds: 8), }) async { - return const []; + return profileEvents; } } diff --git a/mobile/test/features/channels/channels_provider_test.dart b/mobile/test/features/channels/channels_provider_test.dart index 3bb1be5beb9..cca28af932e 100644 --- a/mobile/test/features/channels/channels_provider_test.dart +++ b/mobile/test/features/channels/channels_provider_test.dart @@ -1,3 +1,5 @@ +import 'package:nostr/nostr.dart' as nostr; +import '../../shared/crypto/nip_oa_test.dart' show profile; import 'dart:async'; import 'package:fake_async/fake_async.dart'; @@ -26,6 +28,48 @@ part 'channels_provider_terminal_cases.dart'; void main() { const myPk = 'me'; + test('DM names select only authenticated profile replacements', () async { + final keys = nostr.Keys.generate(); + final signed = profile(keys, [], content: '{"name":"Genuine"}'); + final tie = profile(keys, [], content: '{"name":"Tie"}'); + final forged = NostrEvent.fromJson({ + ...signed.toJson(), + 'id': '0' * 64, + 'content': '{"name":"Forged"}', + }); + final meta = _meta(id: _channelA, name: 'DM', channelType: 'dm'); + for (final events in [ + [signed, forged], + [signed, tie], + ]) { + final winner = events.last == forged || signed.id.compareTo(tie.id) < 0 + ? signed + : tie; + for (final ordered in [events, events.reversed.toList()]) { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [ + NostrEvent.fromJson({ + ...meta.toJson(), + 'tags': [ + ...meta.tags, + ['p', keys.public], + ['p', myPk], + ], + }), + ], + )..profiles = ordered; + final container = _buildContainer(session: session); + final channels = await container.read(channelsProvider.future); + expect( + channels.single.displayLabel(currentPubkey: myPk), + ProfileData.fromEvent(winner).displayName, + ); + container.dispose(); + } + } + }); + test( 'discovers open channels for a user with zero channel memberships', () async { @@ -2240,6 +2284,7 @@ class _FakeRelaySession extends RelaySessionNotifier { this.membershipFailures = 0, }); + List profiles = []; List memberships; final List>? membershipPages; final bool repeatLastMembershipPage; @@ -2471,6 +2516,7 @@ class _FakeRelaySession extends RelaySessionNotifier { Duration timeout = const Duration(seconds: 8), }) async { historyFilters.add(filter); + if (filter.kinds.contains(0)) return profiles; if (filter.kinds.contains(39002) && filter.tags['#d'] != null) { final paused = _pausedMemberCount; if (paused != null) { diff --git a/mobile/test/features/channels/mentions/mention_candidates_test.dart b/mobile/test/features/channels/mentions/mention_candidates_test.dart index 9a0b655f4bf..45313ecd50d 100644 --- a/mobile/test/features/channels/mentions/mention_candidates_test.dart +++ b/mobile/test/features/channels/mentions/mention_candidates_test.dart @@ -173,7 +173,7 @@ void main() { relayAgents: const [], sharedChannelIds: const {}, userCache: const {}, - ownerByAgentPubkey: const {}, + ownerByAgentPubkey: {ownedAgent: userPubkey}, searchResults: [ UserProfile( pubkey: ownedAgent, diff --git a/mobile/test/features/profile/profile_provider_test.dart b/mobile/test/features/profile/profile_provider_test.dart index 93c68cfa202..3db3b916e94 100644 --- a/mobile/test/features/profile/profile_provider_test.dart +++ b/mobile/test/features/profile/profile_provider_test.dart @@ -1,3 +1,4 @@ +import '../../shared/crypto/nip_oa_test.dart' as signed; import 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; @@ -16,6 +17,30 @@ import 'package:nostr/nostr.dart' as nostr; import 'package:pointycastle/digests/sha256.dart'; void main() { + test( + 'unauthenticated profile history cannot hydrate or merge metadata', + () async { + final keys = nostr.Keys.generate(); + final genuine = signed.profile(keys, [], content: '{"about":"Forged"}'); + final invalid = NostrEvent.fromJson({ + ...genuine.toJson(), + 'id': '0' * 64, + }); + final session = _ProfileRelaySession(invalid); + final container = _profileContainer(keys.nsec, session); + addTearDown(container.dispose); + + expect(await container.read(profileProvider.future), isNull); + await container + .read(profileProvider.notifier) + .updateDisplayName('Genuine'); + expect(jsonDecode(session.published.single.content), { + 'display_name': 'Genuine', + }); + expect(session.published.single.tags, isEmpty); + }, + ); + test('profile updates preserve existing kind:0 metadata', () async { final keys = nostr.Keys.generate(); final owner = nostr.Keys.generate(); @@ -39,7 +64,7 @@ void main() { 'custom': 'preserve-me', }), sig: 'sig', - ), + ).signedBy(keys), ); final container = ProviderContainer( overrides: [ @@ -93,7 +118,7 @@ void main() { 'about': 'Building Buzz', }), sig: 'sig', - ), + ).signedBy(keys), ); final container = _profileContainer(keys.nsec, relaySession); addTearDown(container.dispose); @@ -123,7 +148,7 @@ void main() { tags: const [], content: 'not-json', sig: 'sig', - ), + ).signedBy(keys), ); final container = _profileContainer(keys.nsec, relaySession); addTearDown(container.dispose); @@ -200,7 +225,7 @@ void main() { tags: const [], content: '{}', sig: 'sig', - ), + ).signedBy(keys), ], ); final container = _profileContainer(keys.nsec, relaySession); @@ -233,7 +258,7 @@ void main() { 'custom': 'initial', }), sig: 'sig', - ), + ).signedBy(keys), ]; final relaySession = _ControlledProfileRelaySession( fetch: () async => history, @@ -255,7 +280,7 @@ void main() { 'custom': 'remote', }), sig: 'sig', - ), + ).signedBy(keys), ]; await container @@ -279,6 +304,7 @@ void main() { () async { final keys = nostr.Keys.generate(); final relaySession = _LosingProfileRelaySession( + keys, NostrEvent( id: 'profile-initial', pubkey: keys.public, @@ -287,7 +313,7 @@ void main() { tags: const [], content: jsonEncode({'display_name': 'Initial'}), sig: 'sig', - ), + ).signedBy(keys), ); final container = _profileContainer(keys.nsec, relaySession); addTearDown(container.dispose); @@ -323,7 +349,7 @@ void main() { 'about': 'Initial about', }), sig: 'sig', - ), + ).signedBy(keys), ], ); final container = _profileContainer(keys.nsec, relaySession); @@ -358,7 +384,7 @@ void main() { tags: const [], content: jsonEncode({'display_name': 'Initial'}), sig: 'sig', - ); + ).signedBy(keys); final patchFetchStarted = Completer(); final patchHistory = Completer>(); var fetchCount = 0; @@ -404,7 +430,7 @@ void main() { tags: const [], content: jsonEncode({'display_name': 'Initial'}), sig: 'sig', - ); + ).signedBy(keys); final patchFetchStarted = Completer(); final patchHistory = Completer>(); final rehydration = Completer>(); @@ -470,7 +496,7 @@ void main() { tags: const [], content: jsonEncode({'display_name': 'Active'}), sig: 'sig', - ); + ).signedBy(otherKeys); var fetchCount = 0; final relaySession = _ControlledProfileRelaySession( fetch: () async { @@ -507,7 +533,7 @@ void main() { tags: const [], content: jsonEncode({'display_name': 'Stale'}), sig: 'sig', - ), + ).signedBy(keys), ]); await Future.delayed(Duration.zero); @@ -668,7 +694,8 @@ class _ControlledProfileRelaySession extends RelaySessionNotifier { } class _LosingProfileRelaySession extends RelaySessionNotifier { - _LosingProfileRelaySession(this.initial); + _LosingProfileRelaySession(this.keys, this.initial); + final nostr.Keys keys; final NostrEvent initial; final List published = []; @@ -697,7 +724,7 @@ class _LosingProfileRelaySession extends RelaySessionNotifier { tags: const [], content: jsonEncode({'display_name': 'Remote'}), sig: 'sig', - ); + ).signedBy(keys); return event; } } @@ -728,3 +755,17 @@ class _ResumedLifecycle extends AppLifecycleNotifier { @override AppLifecycleState build() => AppLifecycleState.resumed; } + +// Explicit positive-fixture signing: retain metadata, tags, kind and timestamp. +extension on NostrEvent { + NostrEvent signedBy(nostr.Keys keys) { + if (pubkey != keys.public) throw ArgumentError('Fixture signer mismatch'); + return signed.profile( + keys, + tags, + createdAt: createdAt, + kind: kind, + content: content, + ); + } +} diff --git a/mobile/test/features/profile/user_profile_sheet_test.dart b/mobile/test/features/profile/user_profile_sheet_test.dart new file mode 100644 index 00000000000..ce08f6b7afb --- /dev/null +++ b/mobile/test/features/profile/user_profile_sheet_test.dart @@ -0,0 +1,192 @@ +import 'dart:async'; + +import 'package:buzz/features/channels/channel_management_provider.dart'; +import 'package:buzz/features/profile/presence_cache_provider.dart'; +import 'package:buzz/features/profile/user_profile_sheet.dart'; +import 'package:buzz/features/profile/user_status.dart'; +import 'package:buzz/features/profile/user_status_cache_provider.dart'; +import 'package:buzz/shared/community/community_provider.dart'; +import 'package:buzz/shared/profile/user_cache_provider.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; + +import '../../shared/crypto/nip_oa_test.dart' show profile; + +void main() { + for (final cached in [true, false]) { + for (final result in ['signed', 'invalid', 'empty', 'malformed', 'error']) { + testWidgets('about opening snapshot: cached=$cached, $result', ( + tester, + ) async { + final keys = nostr.Keys.generate(); + final initial = profile( + keys, + [], + createdAt: 1, + content: '{"name":"Cached name","about":"Cached about"}', + ); + final fresh = profile( + keys, + [], + createdAt: 2, + content: + '{"name":"Fresh name","about":"Fresh about",' + '"picture":"https://fresh.example/avatar.png"}', + ); + final invalid = NostrEvent.fromJson({ + ...fresh.toJson(), + 'id': '0' * 64, + 'created_at': 3, + 'content': '{"about":"Forged about"}', + }); + final session = _SheetSession(); + final container = ProviderContainer.test( + overrides: [ + relayConfigProvider.overrideWith(_SheetConfig.new), + relaySessionProvider.overrideWith(() => session), + activeCommunityProvider.overrideWith((ref) async => null), + currentPubkeyProvider.overrideWithValue(keys.public), + presenceCacheProvider.overrideWith(_SheetPresence.new), + userStatusCacheProvider.overrideWith(_SheetStatus.new), + ], + ); + final cache = container.read(userCacheProvider.notifier); + if (cached) cache.captureAdmission().add(initial); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold(body: UserProfileSheet(pubkey: keys.public)), + ), + ), + ); + expect(session.requests, hasLength(1)); + expect( + find.text('Cached about'), + cached ? findsOneWidget : findsNothing, + ); + await tester.pump(const Duration(milliseconds: 60)); + // Existing preload/get coalescing: no new refresh of a cached identity. + expect(session.requests, hasLength(cached ? 1 : 2)); + if (!cached) { + session.requests[1].complete([initial]); + await tester.pump(); + await tester.pump(); + expect(find.text('Cached name'), findsOneWidget); + expect(find.text('Cached about'), findsOneWidget); + } + if (result == 'error') { + session.requests[0].completeError(StateError('relay failed')); + } else { + session.requests[0].complete(switch (result) { + 'signed' => [invalid, initial, fresh], + 'invalid' => [invalid], + 'malformed' => [profile(keys, [], content: 'not-json')], + _ => [], + }); + } + await tester.pump(); + await tester.pump(); + expect(find.text('Forged about'), findsNothing); + expect(find.text('Cached about'), findsNothing); + expect( + find.text('Fresh about'), + result == 'signed' ? findsOneWidget : findsNothing, + ); + expect(find.text('Cached name'), findsOneWidget); + expect(find.text('Fresh name'), findsNothing); + expect(cache.state[keys.public]?.avatarUrl, isNull); + cache.captureAdmission().add( + profile( + keys, + [], + createdAt: 4, + content: '{"name":"Live name","about":"Live about"}', + ), + ); + await tester.pump(); + expect(find.text('Live name'), findsOneWidget); + expect(find.text('Live about'), findsNothing); + expect(session.requests, hasLength(cached ? 1 : 2)); + + // Same identity, different community: don't retain a completed snapshot. + final config = container.read(relayConfigProvider.notifier); + config.update(baseUrl: 'https://b.example', nsec: null); + await tester.pump(); + expect(find.text('Fresh about'), findsNothing); + expect(find.text('Live about'), findsNothing); + expect(find.text('Live name'), findsNothing); + final b = session.requests.last; + final beforeC = session.requests.length; + config.update(baseUrl: 'https://c.example', nsec: null); + await tester.pump(); + expect(session.requests, hasLength(beforeC + 1)); + final c = session.requests.last; + b.complete([profile(keys, [], content: '{"about":"Retired B"}')]); + await tester.pump(); + expect(find.text('Retired B'), findsNothing); + expect(container.read(userCacheProvider), isEmpty); + c.complete([profile(keys, [], content: '{"about":"Current C"}')]); + await tester.pump(); + await tester.pump(); + expect(find.text('Current C'), findsOneWidget); + // Resetting the cache retires the captured generation even at the same URL. + final beforeReset = session.requests.length; + container.invalidate(userCacheProvider); + await tester.pump(); + expect(find.text('Current C'), findsNothing); + expect(session.requests, hasLength(beforeReset + 1)); + session.requests.last.complete([]); + await tester.pump(); + expect(find.text('Current C'), findsNothing); + await tester.pumpWidget(const SizedBox.shrink()); + container.dispose(); + }); + } + } +} + +class _SheetConfig extends RelayConfigNotifier { + @override + RelayConfig build() => RelayConfig(baseUrl: 'https://a.example', nsec: null); + @override + void update({required String baseUrl, String? nsec}) { + state = RelayConfig(baseUrl: baseUrl, nsec: nsec); + } +} + +class _SheetSession extends RelaySessionNotifier { + final requests = >>[]; + @override + SessionState build() => + const SessionState(status: SessionStatus.disconnected); + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) { + expect(filter.kinds, [0]); + final request = Completer>(); + requests.add(request); + return request.future; + } +} + +class _SheetPresence extends PresenceCacheNotifier { + @override + Map build() => {}; + @override + void track(List pubkeys) {} +} + +class _SheetStatus extends UserStatusCacheNotifier { + @override + Map build() => {}; + @override + void track(List pubkeys) {} +} diff --git a/mobile/test/shared/crypto/nip_oa_test.dart b/mobile/test/shared/crypto/nip_oa_test.dart index 55577fe848c..c31b985c995 100644 --- a/mobile/test/shared/crypto/nip_oa_test.dart +++ b/mobile/test/shared/crypto/nip_oa_test.dart @@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:nostr/nostr.dart' as nostr; import 'package:pointycastle/digests/sha256.dart'; import 'package:buzz/shared/crypto/nip_oa.dart'; +import 'package:buzz/shared/relay/relay.dart'; String _sha256Hex(String input) { final digest = SHA256Digest().process(Uint8List.fromList(utf8.encode(input))); @@ -21,6 +22,22 @@ List authTag( return ['auth', owner.public, conditions, sig]; } +NostrEvent profile( + nostr.Keys agent, + List> tags, { + int createdAt = 100, + int kind = 0, + String content = '{}', +}) => NostrEvent.fromJson( + nostr.Event.from( + kind: kind, + content: content, + secretKey: agent.secret, + createdAt: createdAt, + tags: tags, + ).toMap(), +); + void main() { final owner = nostr.Keys.generate(); final agent = nostr.Keys.generate(); @@ -28,7 +45,7 @@ void main() { test('returns the owner pubkey for a valid auth tag', () { final tag = authTag(owner, agent.public); expect( - verifiedOaOwnerPubkey([tag], agent.public), + verifiedOaOwnerPubkey(profile(agent, [tag])), owner.public.toLowerCase(), ); }); @@ -36,7 +53,7 @@ void main() { test('accepts valid conditions strings', () { final tag = authTag(owner, agent.public, conditions: 'kind=0'); expect( - verifiedOaOwnerPubkey([tag], agent.public), + verifiedOaOwnerPubkey(profile(agent, [tag])), owner.public.toLowerCase(), ); }); @@ -44,7 +61,7 @@ void main() { test('rejects a signature over a different agent pubkey', () { final otherAgent = nostr.Keys.generate(); final tag = authTag(owner, otherAgent.public); - expect(verifiedOaOwnerPubkey([tag], agent.public), isNull); + expect(verifiedOaOwnerPubkey(profile(agent, [tag])), isNull); }); test('rejects a tampered signature', () { @@ -55,24 +72,26 @@ void main() { 1, tampered[3][0] == '0' ? '1' : '0', ); - expect(verifiedOaOwnerPubkey([tampered], agent.public), isNull); + expect(verifiedOaOwnerPubkey(profile(agent, [tampered])), isNull); }); test('rejects self-attestation', () { final tag = authTag(agent, agent.public); - expect(verifiedOaOwnerPubkey([tag], agent.public), isNull); + expect(verifiedOaOwnerPubkey(profile(agent, [tag])), isNull); }); test('rejects malformed conditions', () { final tag = authTag(owner, agent.public, conditions: 'kind=abc'); - expect(verifiedOaOwnerPubkey([tag], agent.public), isNull); + expect(verifiedOaOwnerPubkey(profile(agent, [tag])), isNull); }); test('ignores unrelated tags', () { expect( - verifiedOaOwnerPubkey([ - ['p', owner.public], - ], agent.public), + verifiedOaOwnerPubkey( + profile(agent, [ + ['p', owner.public], + ]), + ), isNull, ); }); diff --git a/mobile/test/shared/mentions/owner_search_generation_test.dart b/mobile/test/shared/mentions/owner_search_generation_test.dart new file mode 100644 index 00000000000..4161ff8003e --- /dev/null +++ b/mobile/test/shared/mentions/owner_search_generation_test.dart @@ -0,0 +1,183 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:buzz/features/channels/channel.dart'; +import 'package:buzz/features/channels/channels_provider.dart'; +import 'package:buzz/features/channels/channel_management_provider.dart'; +import 'package:buzz/features/channels/mentions/mention_candidates_provider.dart'; +import 'package:buzz/shared/community/community.dart'; +import 'package:buzz/shared/auth/auth_provider.dart'; +import 'package:buzz/shared/community/community_provider.dart'; +import 'package:buzz/shared/community/community_storage.dart'; +import 'package:buzz/shared/mentions/agent_identity_provider.dart'; +import 'package:buzz/shared/profile/user_cache_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:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:nostr/nostr.dart' as nostr; + +import '../community/community_storage_test.dart' show FakeSecureStorage; +import '../crypto/nip_oa_test.dart' show authTag, profile; + +void main() { + for (final boundary in ['search', 'refresh', 'preload']) { + for (final transition + in boundary == 'search' + ? ['unchanged', 'community', 'account', 'ABA'] + : ['unchanged', 'community']) { + test('$boundary rejects retired $transition evidence', () async { + final owner = nostr.Keys.generate(); + final agent = nostr.Keys.generate(); + final owned = profile(agent, [ + authTag(owner, agent.public), + ], content: '{"name":"agent"}'); + final initial = Community.create( + name: 'A', + relayUrl: 'https://a.example', + nsec: owner.nsec, + ); + final next = Community.create( + name: 'B', + relayUrl: transition == 'community' + ? 'https://b.example' + : initial.relayUrl, + nsec: nostr.Keys.generate().nsec, + ); + final storage = CommunityStorage(secure: FakeSecureStorage()); + await storage.saveActiveId(initial.id); + final list = AdmissionCommunities(initial); + final entered = Completer(); + final response = Completer(); + final session = RelaySessionNotifier( + httpClient: MockClient((request) { + expect(request.url.host, 'a.example'); + entered.complete(); + return response.future; + }), + ); + final container = ProviderContainer.test( + overrides: [ + authProvider.overrideWith(AdmissionAuth.new), + communityStorageProvider.overrideWithValue(storage), + communityListProvider.overrideWith(() => list), + relaySessionProvider.overrideWith(() => session), + if (boundary == 'search') ...[ + channelsProvider.overrideWith(AdmissionChannels.new), + channelMembersProvider('probe').overrideWith((ref) async => []), + agentDirectoryProvider.overrideWith((ref) async => []), + currentPubkeyProvider.overrideWithValue(owner.public), + ], + ], + ); + await container.read(authProvider.future); + await container.read(activeCommunityProvider.future); + final cache = container.read(userCacheProvider.notifier); + final search = mentionUserSearchProvider('agent'); + Future? pending; + if (boundary == 'search') { + container.listen(search, (_, _) {}); + await entered.future; + } else { + pending = boundary == 'refresh' + ? cache.refresh([agent.public]) + : cache.preload([agent.public]); + if (boundary == 'preload') { + await Future.delayed(const Duration(milliseconds: 60)); + } + } + // Resolve the real asynchronous active-community provider, leaving its + // indirect config dependents lazy until the old HTTP acceptance runs. + for (final community in [ + if (transition != 'unchanged') next, + if (transition == 'ABA') initial, + ]) { + await storage.saveActiveId(community.id); + list.replace(community); + await container.read(activeCommunityProvider.future); + } + if (boundary == 'search') { + response.complete(http.Response(jsonEncode([owned.toJson()]), 200)); + } else { + session.debugHandleMessage(['EVENT', 'h-1', owned.toJson()]); + session.debugHandleMessage(['EOSE', 'h-1']); + await pending!; + } + if (pending != null) expect(await pending, transition == 'unchanged'); + // Drain continuations without an event-loop turn / provider refresh tick. + await drainAdmission(); + expect( + container.read(userCacheProvider.notifier).profileOwners, + transition == 'unchanged' ? {agent.public: owner.public} : isEmpty, + ); + if (boundary == 'search' && transition == 'unchanged') { + await session.debugHandleConnected(); + final candidates = mentionCandidatesProvider(( + channelId: 'probe', + query: 'agent', + )); + container.listen(candidates, (_, _) {}); + Future accepts(bool allowed) async { + await container.pump(); + await container.read(agentOwnersProvider.future); + await container.pump(); + expect( + container.read(candidates).map((c) => c.pubkey), + allowed ? [agent.public] : isEmpty, + ); + } + + final found = await container.read(search.future); + await accepts(true); + final oldAdmission = cache.captureAdmission(); + container.invalidate(userCacheProvider); + final current = container.read(userCacheProvider.notifier); + await accepts(false); + expect(oldAdmission.isCurrent, isFalse); + expect(current.profilePubkeys, isEmpty); + expect(await container.read(agentOwnersProvider.future), isEmpty); + expect(container.read(search).value, same(found)); + } + if (boundary != 'search' && transition == 'community') { + // New-context history still accepts valid authority after retirement. + final recovery = container.read(userCacheProvider.notifier).refresh([ + agent.public, + ]); + const id = 'h-2'; + session.debugHandleMessage(['EVENT', id, owned.toJson()]); + session.debugHandleMessage(['EOSE', id]); + expect(await recovery, isTrue); + expect(container.read(userCacheProvider.notifier).profileOwners, { + agent.public: owner.public, + }); + } + }); + } + } +} + +Future drainAdmission() async { + for (var i = 0; i < 20; i++) { + await Future.value(); + } +} + +class AdmissionCommunities extends CommunityListNotifier { + AdmissionCommunities(this.initial); + final Community initial; + @override + Future> build() async => [initial]; + void replace(Community community) => state = AsyncData([community]); +} + +class AdmissionAuth extends AuthNotifier { + @override + Future build() async => + const AuthState(status: AuthStatus.unauthenticated); +} + +class AdmissionChannels extends ChannelsNotifier { + @override + Future> build() async => []; +} diff --git a/mobile/test/shared/profile/user_cache_provider_test.dart b/mobile/test/shared/profile/user_cache_provider_test.dart index 9a270861076..a80782dc03e 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'; @@ -26,21 +27,22 @@ void main() { }); test('refresh queries profiles that are already cached', () async { + final agent = nostr.Keys.generate(); final session = _RecordingProfileSession(); final container = ProviderContainer( overrides: [relaySessionProvider.overrideWith(() => session)], ); addTearDown(container.dispose); final cache = container.read(userCacheProvider.notifier); - cache.cacheProfileEvent( - _profileEvent(id: 'cached-profile', createdAt: 1, name: 'Cached Human'), + cache.captureAdmission().add( + _profileEvent(keys: agent, createdAt: 1, name: 'Cached Human'), ); - final succeeded = await cache.refresh(const ['AGENT']); + final succeeded = await cache.refresh([agent.public.toUpperCase()]); expect(succeeded, isTrue); expect(session.requestedFilter?.kinds, const [0]); - expect(session.requestedFilter?.authors, const ['agent']); + expect(session.requestedFilter?.authors, [agent.public]); expect(session.requestedFilter?.limit, 1); }); @@ -56,22 +58,16 @@ void main() { final agent = nostr.Keys.generate(); final refresh = cache.refresh([agent.public]); - cache.cacheProfileEvent( + cache.captureAdmission().add( _profileEvent( - id: 'newer-agent', - pubkey: agent.public, + keys: agent, createdAt: 2, name: 'Agent', tags: [_authTag(owner, agent.public)], ), ); refreshCompleter.complete([ - _profileEvent( - id: 'older-human', - pubkey: agent.public, - createdAt: 1, - name: 'Human', - ), + _profileEvent(keys: agent, createdAt: 1, name: 'Human'), ]); expect(await refresh, isTrue); @@ -84,12 +80,7 @@ void main() { final agent = nostr.Keys.generate(); final session = _RecordingProfileSession( result: Future.value([ - _profileEvent( - id: 'newer-human', - pubkey: agent.public, - createdAt: 2, - name: 'Human', - ), + _profileEvent(keys: agent, createdAt: 2, name: 'Human'), ]), ); final container = ProviderContainer( @@ -97,10 +88,9 @@ void main() { ); addTearDown(container.dispose); final cache = container.read(userCacheProvider.notifier); - cache.cacheProfileEvent( + cache.captureAdmission().add( _profileEvent( - id: 'older-agent', - pubkey: agent.public, + keys: agent, createdAt: 1, name: 'Agent', tags: [_authTag(owner, agent.public)], @@ -108,24 +98,19 @@ void main() { ); expect(await cache.refresh([agent.public]), isTrue); + cache.put(UserProfile(pubkey: agent.public, ownerPubkey: owner.public)); expect(cache.state[agent.public]?.displayName, 'Human'); expect(cache.state[agent.public]?.ownerPubkey, isNull); }); test('non-profile history cannot poison profile order', () async { + final agent = nostr.Keys.generate(); final session = _RecordingProfileSession( results: [ Future.value([ - _profileEvent( - id: 'non-profile-newer', - createdAt: 3, - name: 'Ignored', - kind: 1, - ), - ]), - Future.value([ - _profileEvent(id: 'valid-older', createdAt: 2, name: 'Valid'), + _profileEvent(keys: agent, createdAt: 3, name: 'Ignored', kind: 1), ]), + Future.value([_profileEvent(keys: agent, createdAt: 2, name: 'Valid')]), ], ); final container = ProviderContainer( @@ -134,10 +119,10 @@ void main() { addTearDown(container.dispose); final cache = container.read(userCacheProvider.notifier); - expect(await cache.refresh(const ['agent']), isTrue); - expect(cache.state['agent'], isNull); - expect(await cache.refresh(const ['agent']), isTrue); - expect(cache.state['agent']?.displayName, 'Valid'); + expect(await cache.refresh([agent.public]), isTrue); + expect(cache.state[agent.public], isNull); + expect(await cache.refresh([agent.public]), isTrue); + expect(cache.state[agent.public]?.displayName, 'Valid'); }); test('same-second profile tie keeps the lowest event id', () { @@ -145,35 +130,36 @@ void main() { addTearDown(container.dispose); final cache = container.read(userCacheProvider.notifier); - cache.cacheProfileEvent( - _profileEvent(id: 'b', createdAt: 1, name: 'Larger ID'), - ); - cache.cacheProfileEvent( - _profileEvent(id: 'a', createdAt: 1, name: 'Lower ID'), - ); - cache.cacheProfileEvent( - _profileEvent(id: 'c', createdAt: 1, name: 'Later Larger ID'), + final agent = nostr.Keys.generate(); + final events = [ + for (final name in ['First', 'Second', 'Third']) + _profileEvent(keys: agent, createdAt: 1, name: name), + ]..sort((a, b) => a.id.compareTo(b.id)); + for (final event in [events[1], events[0], events[2]]) { + cache.captureAdmission().add(event); + } + + expect( + cache.state[agent.public]?.displayName, + ProfileData.fromEvent(events.first).displayName, ); - - expect(cache.state['agent']?.displayName, 'Lower ID'); }); } NostrEvent _profileEvent({ - required String id, + required nostr.Keys keys, required int createdAt, required String name, - String pubkey = 'agent', List> tags = const [], int kind = 0, -}) => NostrEvent( - id: id, - pubkey: pubkey, - createdAt: createdAt, - kind: kind, - tags: tags, - content: jsonEncode({'name': name}), - sig: 'sig', +}) => NostrEvent.fromJson( + nostr.Event.from( + secretKey: keys.secret, + createdAt: createdAt, + kind: kind, + tags: tags, + content: jsonEncode({'name': name}), + ).toMap(), ); List _authTag(nostr.Keys owner, String agentPubkey) {