mirror of
https://github.com/block/buzz.git
synced 2026-11-12 04:12:21 +00:00
fix(mobile): retain authoritative live ownership and DM admission gates
Carry the immediate ROOT ownership readiness, consumer, queued-live and DM/huddle generation corrections on the authenticated profile prerequisite. Preserve the original ROOT production delta and bind its signed fixtures to the corrected parent. Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
@@ -98,6 +98,8 @@ class ChannelActionsSheet extends HookConsumerWidget {
|
||||
orElse: () => null,
|
||||
);
|
||||
final ownsOwnerAgent =
|
||||
!agentOwnersAsync.isLoading &&
|
||||
!agentOwnersAsync.hasError &&
|
||||
currentPubkey != null &&
|
||||
membersAsync.value?.any(
|
||||
(member) =>
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:flutter/rendering.dart' show ScrollDirection;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:hooks_riverpod/misc.dart' show ProviderListenable;
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||
|
||||
|
||||
@@ -7,6 +7,11 @@ const _huddleSpeakingRingSize = 112.0;
|
||||
const _huddleParticipantLabelSpace = 28.0;
|
||||
const _huddleDenseParticipantThreshold = 6;
|
||||
|
||||
/// Exposes the actual participant profile producer without starting native audio.
|
||||
@visibleForTesting
|
||||
ProviderListenable<int> debugHuddleProfileUpdates(String channelId) =>
|
||||
_huddleParticipantProfileUpdatesProvider(channelId);
|
||||
|
||||
final _huddleParticipantProfileUpdatesProvider = NotifierProvider.autoDispose
|
||||
.family<_HuddleParticipantProfileUpdates, int, String>(
|
||||
_HuddleParticipantProfileUpdates.new,
|
||||
|
||||
@@ -74,6 +74,8 @@ class ChannelDetailsPage extends HookConsumerWidget {
|
||||
orElse: () => null,
|
||||
);
|
||||
final ownsOwnerAgent =
|
||||
!agentOwnersAsync.isLoading &&
|
||||
!agentOwnersAsync.hasError &&
|
||||
resolvedCurrentPubkey != null &&
|
||||
members.any(
|
||||
(member) =>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../shared/crypto/nip_oa.dart';
|
||||
import '../profile/user_cache_provider.dart';
|
||||
import '../../shared/relay/relay.dart';
|
||||
import '../../shared/utils/string_utils.dart';
|
||||
|
||||
@@ -74,18 +75,61 @@ final agentDirectoryProvider = FutureProvider<List<AgentDirectoryEntry>>((
|
||||
/// profiles. An entry exists only when the `auth` tag verifies — mirrors
|
||||
/// desktop's `profile_valid_oa_owner_pubkey`.
|
||||
final agentOwnersProvider = FutureProvider<Map<String, String>>((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 = <String, String>{};
|
||||
for (final event in latestProfileEvents(events).values) {
|
||||
final owner = verifiedOaOwnerPubkey(event);
|
||||
if (owner != null) owners[event.pubkey.toLowerCase()] = owner;
|
||||
ref.watch(userCacheProvider);
|
||||
final cache = ref.read(userCacheProvider.notifier);
|
||||
final source = ref.watch(_agentOwnerProfilesProvider);
|
||||
final ready =
|
||||
source.asData?.value ??
|
||||
await ref.watch(_agentOwnerProfilesProvider.future);
|
||||
if (ready != true) throw StateError('Owner profile feed unavailable');
|
||||
return cache.profileOwners;
|
||||
});
|
||||
|
||||
// The live REQ carries history too; only EOSE (not acquisition timeout) is ready.
|
||||
final _agentOwnerProfilesProvider = StreamProvider<bool>((ref) async* {
|
||||
ref.watch(relayConfigProvider);
|
||||
final admission = ref.read(userCacheProvider.notifier).captureAdmission();
|
||||
if (ref.watch(relaySessionProvider).status != SessionStatus.connected) {
|
||||
yield false;
|
||||
return;
|
||||
}
|
||||
return owners;
|
||||
final availability = StreamController<bool>()..add(false);
|
||||
var disposed = false;
|
||||
void Function()? unsubscribe;
|
||||
ref.onDispose(() {
|
||||
disposed = true;
|
||||
unsubscribe?.call();
|
||||
availability.close();
|
||||
});
|
||||
final agents = await ref.watch(agentDirectoryProvider.future);
|
||||
if (disposed) return;
|
||||
if (agents.isEmpty) {
|
||||
yield true;
|
||||
return;
|
||||
}
|
||||
final session = ref.read(relaySessionProvider.notifier);
|
||||
final filter = NostrFilters.profilesBatch([
|
||||
for (final agent in agents) agent.pubkey,
|
||||
]);
|
||||
unsubscribe = await session.subscribeWithStatus(
|
||||
filter,
|
||||
(event) {
|
||||
if (!disposed) admission.add(event);
|
||||
},
|
||||
onClosed: (_) {
|
||||
if (!disposed) availability.add(false);
|
||||
},
|
||||
onStatusChanged: (status) {
|
||||
if (!disposed) {
|
||||
availability.add(status == RelaySubscriptionStatus.ready);
|
||||
}
|
||||
},
|
||||
);
|
||||
if (disposed) {
|
||||
unsubscribe();
|
||||
return;
|
||||
}
|
||||
yield* availability.stream;
|
||||
});
|
||||
|
||||
/// Pubkeys currently known to represent agents across the active relay.
|
||||
|
||||
@@ -198,7 +198,29 @@ NostrEvent _edit({
|
||||
sig: '',
|
||||
);
|
||||
|
||||
Future<Widget> admissionDmHarness(List<String> pubkeys) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
_testPrefs = await SharedPreferences.getInstance();
|
||||
return _buildTestable(
|
||||
messages: const [],
|
||||
realAdmission: true,
|
||||
channel: Channel(
|
||||
id: _channelId,
|
||||
name: 'DM',
|
||||
channelType: 'dm',
|
||||
visibility: 'private',
|
||||
description: '',
|
||||
createdBy: pubkeys.first,
|
||||
createdAt: DateTime(2025),
|
||||
memberCount: 2,
|
||||
participantPubkeys: pubkeys,
|
||||
isMember: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTestable({
|
||||
bool realAdmission = false,
|
||||
required List<NostrEvent> messages,
|
||||
List<TypingEntry> typing = const [],
|
||||
Map<String, UserProfile> users = const {},
|
||||
@@ -266,9 +288,10 @@ Widget _buildTestable({
|
||||
huddleTypingNotifier ??
|
||||
_FakeTypingNotifier(const [], channelId: _huddleChannelId),
|
||||
),
|
||||
userCacheProvider.overrideWith(
|
||||
() => userCacheNotifier ?? _FakeUserCacheNotifier(users),
|
||||
),
|
||||
if (!realAdmission)
|
||||
userCacheProvider.overrideWith(
|
||||
() => userCacheNotifier ?? _FakeUserCacheNotifier(users),
|
||||
),
|
||||
profileProvider.overrideWith(() => _FakeProfileNotifier()),
|
||||
channelsProvider.overrideWith(() => fakeChannelsNotifier),
|
||||
channelStarsProvider.overrideWith(_FakeChannelStarsNotifier.new),
|
||||
@@ -303,12 +326,14 @@ Widget _buildTestable({
|
||||
if (member.isBot) member.pubkey.toLowerCase(),
|
||||
},
|
||||
),
|
||||
agentOwnersProvider.overrideWith(
|
||||
(ref) async => loadAgentOwners?.call() ?? const <String, String>{},
|
||||
),
|
||||
agentDirectoryProvider.overrideWith(
|
||||
(ref) async => loadAgentDirectory?.call() ?? const [],
|
||||
),
|
||||
if (!realAdmission)
|
||||
agentOwnersProvider.overrideWith(
|
||||
(ref) async => loadAgentOwners?.call() ?? const <String, String>{},
|
||||
),
|
||||
if (!realAdmission)
|
||||
agentDirectoryProvider.overrideWith(
|
||||
(ref) async => loadAgentDirectory?.call() ?? const [],
|
||||
),
|
||||
if (knownAgentPubkeys != null)
|
||||
knownAgentPubkeysProvider.overrideWithValue(knownAgentPubkeys),
|
||||
if (directoryUsers != null)
|
||||
@@ -348,9 +373,10 @@ Widget _buildTestable({
|
||||
),
|
||||
mediaHttpClientProvider.overrideWithValue(mediaClient),
|
||||
],
|
||||
if (relaySessionNotifier != null ||
|
||||
(resolvedChannel.isDm &&
|
||||
resolvedChannel.participantPubkeys.toSet().length == 2))
|
||||
if (!realAdmission &&
|
||||
(relaySessionNotifier != null ||
|
||||
(resolvedChannel.isDm &&
|
||||
resolvedChannel.participantPubkeys.toSet().length == 2)))
|
||||
relaySessionProvider.overrideWith(
|
||||
() => relaySessionNotifier ?? _IdentityUpdateRelaySession(),
|
||||
),
|
||||
|
||||
@@ -70,6 +70,7 @@ void main() {
|
||||
),
|
||||
],
|
||||
);
|
||||
container.listen(agentOwnersProvider, (_, _) {});
|
||||
expect(
|
||||
await container.read(agentOwnersProvider.future),
|
||||
expected == null ? isEmpty : {agent.public: expected},
|
||||
@@ -938,4 +939,18 @@ class _DirectoryFakeRelaySession extends RelaySessionNotifier {
|
||||
}) async {
|
||||
return profileEvents;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void Function()> subscribeWithStatus(
|
||||
NostrFilter filter,
|
||||
void Function(NostrEvent) onEvent, {
|
||||
void Function(String message)? onClosed,
|
||||
void Function(RelaySubscriptionStatus status)? onStatusChanged,
|
||||
}) async {
|
||||
for (final event in profileEvents) {
|
||||
onEvent(event);
|
||||
}
|
||||
onStatusChanged?.call(RelaySubscriptionStatus.ready);
|
||||
return () {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,6 +223,7 @@ void main() {
|
||||
sharedChannelIds: {'chan-1'},
|
||||
userCache: const {},
|
||||
ownerByAgentPubkey: const {},
|
||||
ownerSourceAvailable: false,
|
||||
searchResults: [
|
||||
UserProfile(
|
||||
pubkey: agentPubkey,
|
||||
|
||||
@@ -95,4 +95,65 @@ void main() {
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
test('rejects ambiguous auth and invalid envelopes', () {
|
||||
final tag = authTag(owner, agent.public);
|
||||
final valid = profile(agent, [tag]);
|
||||
final badTags = [
|
||||
for (final duplicate in [
|
||||
tag,
|
||||
['auth'],
|
||||
['auth', 'invalid'],
|
||||
]) ...[
|
||||
[tag, duplicate],
|
||||
[duplicate, tag],
|
||||
],
|
||||
for (final index in [1, 3])
|
||||
[List<String>.from(tag)..[index] = tag[index].toUpperCase()],
|
||||
];
|
||||
for (final event in [
|
||||
for (final tags in badTags) profile(agent, tags),
|
||||
profile(agent, [tag], kind: 1),
|
||||
for (final patch in [
|
||||
{'content': 'forged'},
|
||||
{'created_at': 101},
|
||||
{'id': '0' * 64},
|
||||
{'sig': '0' * 128},
|
||||
{'pubkey': owner.public},
|
||||
])
|
||||
NostrEvent.fromJson({...valid.toJson(), ...patch}),
|
||||
]) {
|
||||
expect(verifiedOaOwnerPubkey(event), isNull);
|
||||
}
|
||||
});
|
||||
test('conditions evaluate the signed profile time, with strict bounds', () {
|
||||
const valid = [
|
||||
'',
|
||||
'kind=0',
|
||||
'created_at>99&created_at<101',
|
||||
'created_at<4294967295',
|
||||
];
|
||||
for (final conditions in [
|
||||
...valid,
|
||||
'kind=1',
|
||||
'created_at>100',
|
||||
'created_at<100',
|
||||
'kind=65536',
|
||||
'kind=00',
|
||||
'kind=+0',
|
||||
'created_at<4294967296',
|
||||
'kind=0&',
|
||||
' kind=0',
|
||||
'kind=0&kind=1',
|
||||
]) {
|
||||
expect(
|
||||
verifiedOaOwnerPubkey(
|
||||
profile(agent, [
|
||||
authTag(owner, agent.public, conditions: conditions),
|
||||
]),
|
||||
),
|
||||
valid.contains(conditions) ? owner.public : isNull,
|
||||
reason: conditions,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:buzz/features/channels/channel.dart';
|
||||
import 'package:buzz/features/channels/channel_actions_sheet.dart';
|
||||
import 'package:buzz/features/channels/mentions/mention_candidates_provider.dart';
|
||||
import 'package:buzz/shared/profile/user_cache_provider.dart';
|
||||
import 'package:buzz/shared/widgets/buzz_action_tile.dart';
|
||||
import '../../helpers/widget_helpers.dart';
|
||||
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
import '../crypto/nip_oa_test.dart' show authTag, profile;
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
@@ -8,6 +18,144 @@ import 'package:buzz/shared/mentions/agent_identity_provider.dart';
|
||||
import 'package:buzz/shared/relay/relay.dart';
|
||||
|
||||
void main() {
|
||||
final owner = nostr.Keys.generate();
|
||||
final agent = nostr.Keys.generate();
|
||||
final owned = profile(agent, [authTag(owner, agent.public)]);
|
||||
final revoked = profile(agent, [], createdAt: 101);
|
||||
// Canonical positive/revocation/tie projection is exercised through both
|
||||
// owner and search providers in channel_management_provider_test.dart.
|
||||
for (final details in [false, true]) {
|
||||
testWidgets(
|
||||
'${details ? 'Details' : 'Actions'} follows live owner authority',
|
||||
(tester) async {
|
||||
await tester.binding.setSurfaceSize(const Size(900, 2400));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
final restored = profile(agent, [
|
||||
authTag(owner, agent.public),
|
||||
], createdAt: 103);
|
||||
final invalid = profile(agent, [
|
||||
authTag(owner, owner.public),
|
||||
], createdAt: 104);
|
||||
final session = _MembershipRelaySessionNotifier(
|
||||
[owned, profile(agent, [], kind: 10100)],
|
||||
profiles: true,
|
||||
autoReady: false,
|
||||
);
|
||||
final channel = Channel(
|
||||
id: _channelId,
|
||||
name: 'proof',
|
||||
channelType: 'stream',
|
||||
visibility: 'open',
|
||||
description: '',
|
||||
createdBy: agent.public,
|
||||
createdAt: DateTime(2025),
|
||||
memberCount: 1,
|
||||
isMember: true,
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
WidgetHelpers.testable(
|
||||
overrides: [
|
||||
relaySessionProvider.overrideWith(() => session),
|
||||
relayConfigProvider.overrideWith(_FixedRelayConfig.new),
|
||||
currentPubkeyProvider.overrideWithValue(owner.public),
|
||||
channelMembersProvider(_channelId).overrideWith(
|
||||
(ref) async => [
|
||||
ChannelMember(
|
||||
pubkey: agent.public,
|
||||
role: 'owner',
|
||||
joinedAt: DateTime(2025),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
child: details
|
||||
? ChannelDetailsPage(
|
||||
channel: channel,
|
||||
currentPubkey: owner.public,
|
||||
onMemberTap: (_, _) {},
|
||||
)
|
||||
: ChannelActionsSheet(channel: channel, isUnread: false),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
final container = ProviderScope.containerOf(
|
||||
tester.element(
|
||||
find.byType(details ? ChannelDetailsPage : ChannelActionsSheet),
|
||||
),
|
||||
);
|
||||
final candidates = mentionCandidatesProvider((
|
||||
channelId: _channelId,
|
||||
query: '',
|
||||
));
|
||||
final listener = container.listen(candidates, (_, _) {});
|
||||
addTearDown(listener.close);
|
||||
await tester.pumpAndSettle();
|
||||
Future<void> controls(bool allowed) async {
|
||||
await tester.pumpAndSettle();
|
||||
if (details) {
|
||||
final edit = find.byKey(
|
||||
const ValueKey('channel-details-edit-action'),
|
||||
);
|
||||
expect(tester.widget<BuzzActionTile>(edit).isEnabled, allowed);
|
||||
} else {
|
||||
expect(
|
||||
find.text('Archive channel'),
|
||||
allowed ? findsOneWidget : findsNothing,
|
||||
);
|
||||
expect(
|
||||
find.text('Delete channel'),
|
||||
allowed ? findsOneWidget : findsNothing,
|
||||
);
|
||||
}
|
||||
expect(
|
||||
container.read(candidates).single.ownerPubkey,
|
||||
allowed ? owner.public : isNull,
|
||||
);
|
||||
}
|
||||
|
||||
// Acquisition alone is not EOSE, even with a positive cached profile.
|
||||
await controls(false);
|
||||
session.autoReady = true;
|
||||
session.setSubscriptionStatus(RelaySubscriptionStatus.ready);
|
||||
await controls(true);
|
||||
session.closeProfiles();
|
||||
await controls(false);
|
||||
expect(container.read(userCacheProvider.notifier).profileOwners, {
|
||||
agent.public: owner.public,
|
||||
});
|
||||
session._memberships[0] = revoked;
|
||||
session.connection(SessionStatus.disconnected);
|
||||
await tester.pumpAndSettle();
|
||||
session.connection(SessionStatus.connected);
|
||||
await controls(false);
|
||||
for (final event in [revoked, restored, invalid, owned]) {
|
||||
session.emit(event);
|
||||
await controls(event.id == restored.id);
|
||||
if (event.id == restored.id) {
|
||||
session.setSubscriptionStatus(RelaySubscriptionStatus.retrying);
|
||||
await controls(false);
|
||||
session.setSubscriptionStatus(RelaySubscriptionStatus.ready);
|
||||
await controls(true);
|
||||
session.connection(SessionStatus.disconnected);
|
||||
await controls(false);
|
||||
session.connection(SessionStatus.connected);
|
||||
await controls(true);
|
||||
}
|
||||
}
|
||||
final cache = container.read(userCacheProvider.notifier);
|
||||
await cache.refresh([agent.public]);
|
||||
// A new connection rebuilds the profile producer against stale history.
|
||||
session.connection(SessionStatus.disconnected);
|
||||
await tester.pumpAndSettle();
|
||||
session.connection(SessionStatus.connected);
|
||||
await tester.pumpAndSettle();
|
||||
session.emit(owned);
|
||||
await controls(false);
|
||||
expect(cache.profileOwners, isEmpty);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
},
|
||||
);
|
||||
}
|
||||
test('refreshes channel bot roles from live membership updates', () async {
|
||||
final relaySession = _MembershipRelaySessionNotifier([
|
||||
_membershipEvent(role: 'bot'),
|
||||
@@ -253,13 +401,22 @@ Future<void> _pumpEventQueue() async {
|
||||
class _MembershipRelaySessionNotifier extends RelaySessionNotifier {
|
||||
final List<NostrEvent> _memberships;
|
||||
final Object? subscribeError;
|
||||
final bool profiles;
|
||||
bool autoReady;
|
||||
final List<NostrFilter> liveFilters = [];
|
||||
final List<_LiveSubscription> _subscriptions = [];
|
||||
final Completer<void> _subscribed = Completer<void>();
|
||||
var unsubscribeCount = 0;
|
||||
var _membershipIndex = 0;
|
||||
|
||||
_MembershipRelaySessionNotifier(this._memberships, {this.subscribeError});
|
||||
_MembershipRelaySessionNotifier(
|
||||
this._memberships, {
|
||||
this.subscribeError,
|
||||
this.profiles = false,
|
||||
this.autoReady = true,
|
||||
});
|
||||
|
||||
void connection(SessionStatus status) => state = SessionState(status: status);
|
||||
|
||||
Future<void> get subscribed => _subscribed.future;
|
||||
|
||||
@@ -271,7 +428,11 @@ class _MembershipRelaySessionNotifier extends RelaySessionNotifier {
|
||||
NostrFilter filter, {
|
||||
Duration timeout = const Duration(seconds: 8),
|
||||
}) async {
|
||||
return [_memberships[_membershipIndex++]];
|
||||
return profiles
|
||||
? _memberships
|
||||
.where((event) => filter.kinds.contains(event.kind))
|
||||
.toList()
|
||||
: [_memberships[_membershipIndex++]];
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -290,7 +451,14 @@ class _MembershipRelaySessionNotifier extends RelaySessionNotifier {
|
||||
onStatusChanged,
|
||||
);
|
||||
_subscriptions.add(subscription);
|
||||
onStatusChanged(RelaySubscriptionStatus.ready);
|
||||
if (profiles) {
|
||||
for (final event in _memberships.where(
|
||||
(event) => _matches(filter, event),
|
||||
)) {
|
||||
onEvent(event);
|
||||
}
|
||||
}
|
||||
if (autoReady) onStatusChanged(RelaySubscriptionStatus.ready);
|
||||
if (!_subscribed.isCompleted) _subscribed.complete();
|
||||
return () {
|
||||
unsubscribeCount++;
|
||||
@@ -306,6 +474,14 @@ class _MembershipRelaySessionNotifier extends RelaySessionNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
void closeProfiles() {
|
||||
for (final sub in List.of(_subscriptions)) {
|
||||
if (!sub.filter.kinds.contains(0)) continue;
|
||||
sub.onClosed?.call('restricted: no longer valid');
|
||||
_subscriptions.remove(sub);
|
||||
}
|
||||
}
|
||||
|
||||
void closeSubscription(String message) {
|
||||
for (final subscription in List.of(_subscriptions)) {
|
||||
subscription.onClosed?.call(message);
|
||||
@@ -345,3 +521,8 @@ bool _matches(NostrFilter filter, NostrEvent event) {
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
class _FixedRelayConfig extends RelayConfigNotifier {
|
||||
@override
|
||||
RelayConfig build() => const RelayConfig(baseUrl: 'https://relay.invalid');
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ 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/mentions/agent_identity_provider.dart';
|
||||
import 'package:buzz/shared/relay/relay.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
@@ -22,7 +22,7 @@ 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 boundary in ['search', 'refresh', 'preload', 'live']) {
|
||||
for (final transition
|
||||
in boundary == 'search'
|
||||
? ['unchanged', 'community', 'account', 'ABA']
|
||||
@@ -79,6 +79,23 @@ void main() {
|
||||
if (boundary == 'search') {
|
||||
container.listen(search, (_, _) {});
|
||||
await entered.future;
|
||||
} else if (boundary == 'live') {
|
||||
container.read(relaySessionProvider);
|
||||
await session.debugHandleConnected();
|
||||
container.listen(agentOwnersProvider, (_, _) {});
|
||||
await drainAdmission();
|
||||
session.debugHandleMessage([
|
||||
'EVENT',
|
||||
'h-1',
|
||||
{...owned.toJson(), 'kind': 10100, 'content': '{}'},
|
||||
]);
|
||||
session.debugHandleMessage(['EOSE', 'h-1']);
|
||||
await drainAdmission();
|
||||
session.debugHandleMessage(['EOSE', 'l-2']);
|
||||
await container.pump();
|
||||
expect(await container.read(agentOwnersProvider.future), isEmpty);
|
||||
// Queue in the real session buffer, not an obsolete callback handle.
|
||||
session.debugHandleMessage(['EVENT', 'l-2', owned.toJson()]);
|
||||
} else {
|
||||
pending = boundary == 'refresh'
|
||||
? cache.refresh([agent.public])
|
||||
@@ -99,6 +116,8 @@ void main() {
|
||||
}
|
||||
if (boundary == 'search') {
|
||||
response.complete(http.Response(jsonEncode([owned.toJson()]), 200));
|
||||
} else if (boundary == 'live') {
|
||||
session.debugFlushEventBuffer();
|
||||
} else {
|
||||
session.debugHandleMessage(['EVENT', 'h-1', owned.toJson()]);
|
||||
session.debugHandleMessage(['EOSE', 'h-1']);
|
||||
@@ -137,6 +156,15 @@ void main() {
|
||||
expect(oldAdmission.isCurrent, isFalse);
|
||||
expect(current.profilePubkeys, isEmpty);
|
||||
expect(await container.read(agentOwnersProvider.future), isEmpty);
|
||||
for (final (tags, time, allowed) in [
|
||||
(<List<String>>[], 101, false),
|
||||
(owned.tags, 102, true),
|
||||
]) {
|
||||
current.captureAdmission().add(
|
||||
profile(agent, tags, createdAt: time),
|
||||
);
|
||||
await accepts(allowed);
|
||||
}
|
||||
expect(container.read(search).value, same(found));
|
||||
}
|
||||
if (boundary != 'search' && transition == 'community') {
|
||||
@@ -144,7 +172,7 @@ void main() {
|
||||
final recovery = container.read(userCacheProvider.notifier).refresh([
|
||||
agent.public,
|
||||
]);
|
||||
const id = 'h-2';
|
||||
final id = boundary == 'live' ? 'h-3' : 'h-2';
|
||||
session.debugHandleMessage(['EVENT', id, owned.toJson()]);
|
||||
session.debugHandleMessage(['EOSE', id]);
|
||||
expect(await recovery, isTrue);
|
||||
|
||||
178
mobile/test/shared/mentions/r1_dm_admission_test.dart
Normal file
178
mobile/test/shared/mentions/r1_dm_admission_test.dart
Normal file
@@ -0,0 +1,178 @@
|
||||
import 'package:buzz/features/channels/channel_detail_page.dart';
|
||||
import 'package:buzz/features/channels/channel_management_provider.dart';
|
||||
import 'package:buzz/shared/theme/theme_provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../features/channels/channel_detail_page_test.dart'
|
||||
show admissionDmHarness;
|
||||
|
||||
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/profile/user_cache_provider.dart';
|
||||
import 'package:buzz/shared/mentions/agent_identity_provider.dart';
|
||||
import 'package:buzz/shared/relay/relay.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
|
||||
import '../community/community_storage_test.dart' show FakeSecureStorage;
|
||||
import 'owner_search_generation_test.dart'
|
||||
show AdmissionCommunities, AdmissionAuth, drainAdmission;
|
||||
import '../crypto/nip_oa_test.dart' show authTag, profile;
|
||||
|
||||
void main() {
|
||||
for (final huddle in [false, true]) {
|
||||
for (final retired in [false, true]) {
|
||||
testWidgets('R1 ${huddle ? 'huddle' : 'DM'} admission retired=$retired', (
|
||||
tester,
|
||||
) async {
|
||||
final owner = nostr.Keys.generate();
|
||||
final agent = nostr.Keys.generate();
|
||||
final owned = profile(agent, [authTag(owner, agent.public)]);
|
||||
final initial = Community.create(
|
||||
name: 'A',
|
||||
relayUrl: 'https://a.example',
|
||||
nsec: owner.nsec,
|
||||
);
|
||||
final next = Community.create(
|
||||
name: 'B',
|
||||
relayUrl: 'https://b.example',
|
||||
nsec: owner.nsec,
|
||||
);
|
||||
final storage = CommunityStorage(secure: FakeSecureStorage());
|
||||
await storage.saveActiveId(initial.id);
|
||||
final list = AdmissionCommunities(initial);
|
||||
final session = RelaySessionNotifier();
|
||||
final socket = _RecordingSocket();
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final container = ProviderContainer.test(
|
||||
overrides: [
|
||||
savedPrefsProvider.overrideWithValue(prefs),
|
||||
authProvider.overrideWith(AdmissionAuth.new),
|
||||
communityStorageProvider.overrideWithValue(storage),
|
||||
communityListProvider.overrideWith(() => list),
|
||||
relaySessionProvider.overrideWith(() => session),
|
||||
channelMembersProvider('admission').overrideWith(
|
||||
(ref) async => [
|
||||
ChannelMember(
|
||||
pubkey: agent.public,
|
||||
role: 'member',
|
||||
joinedAt: DateTime(2025),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
await container.read(authProvider.future);
|
||||
await container.read(activeCommunityProvider.future);
|
||||
container.read(relaySessionProvider);
|
||||
await session.debugHandleConnected();
|
||||
session.debugAttachSocketForTest(socket);
|
||||
container.listen(agentOwnersProvider, (_, _) {});
|
||||
await drainAdmission();
|
||||
// Empty directory is a legitimate ready source, not a fake owner map.
|
||||
session.debugHandleMessage(['EOSE', socket.history.single]);
|
||||
await drainAdmission();
|
||||
expect(await container.read(agentOwnersProvider.future), isEmpty);
|
||||
if (huddle) {
|
||||
await container.read(channelMembersProvider('admission').future);
|
||||
container.listen(debugHuddleProfileUpdates('admission'), (_, _) {});
|
||||
} else {
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: await admissionDmHarness([owner.public, agent.public]),
|
||||
),
|
||||
);
|
||||
}
|
||||
await drainAdmission();
|
||||
final id = socket.profileIds(huddle).single;
|
||||
expect(id, startsWith('l-'));
|
||||
socket.ready(session);
|
||||
await drainAdmission();
|
||||
session.debugHandleMessage(['EVENT', id, owned.toJson()]);
|
||||
if (retired) {
|
||||
await storage.saveActiveId(next.id);
|
||||
list.replace(next);
|
||||
await container.read(activeCommunityProvider.future);
|
||||
}
|
||||
session.debugFlushEventBuffer();
|
||||
await drainAdmission();
|
||||
final observed = container
|
||||
.read(userCacheProvider.notifier)
|
||||
.profileOwners;
|
||||
// Keep the real consumer and widget alive across lazy invalidation.
|
||||
if (retired) {
|
||||
container.read(relaySessionProvider);
|
||||
if (huddle) container.read(debugHuddleProfileUpdates('admission'));
|
||||
await tester.pump();
|
||||
await session.debugHandleConnected();
|
||||
session.debugAttachSocketForTest(socket);
|
||||
if (huddle) container.read(debugHuddleProfileUpdates('admission'));
|
||||
container.read(agentOwnersProvider);
|
||||
await drainAdmission();
|
||||
socket.ready(session);
|
||||
await drainAdmission();
|
||||
}
|
||||
await tester.pump();
|
||||
final authority = await container.read(agentOwnersProvider.future);
|
||||
final currentDm = socket.profileIds(huddle).last;
|
||||
if (retired) expect(currentDm, isNot(id));
|
||||
socket.ready(session);
|
||||
await drainAdmission();
|
||||
final nextOwner = nostr.Keys.generate();
|
||||
final advancing = profile(agent, [
|
||||
authTag(nextOwner, agent.public),
|
||||
], createdAt: 101);
|
||||
session.debugHandleMessage(['EVENT', currentDm, advancing.toJson()]);
|
||||
session.debugFlushEventBuffer();
|
||||
await tester.pump();
|
||||
final recovery = await container.read(agentOwnersProvider.future);
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
container.dispose();
|
||||
await tester.pump(const Duration(milliseconds: 600));
|
||||
expect(recovery, {agent.public: nextOwner.public});
|
||||
expect(authority, retired ? isEmpty : {agent.public: owner.public});
|
||||
expect(observed, retired ? isEmpty : {agent.public: owner.public});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _RecordingSocket extends RelaySocket {
|
||||
_RecordingSocket()
|
||||
: super(
|
||||
wsUrl: 'wss://unused.example',
|
||||
nsec: null,
|
||||
onMessage: (_) {},
|
||||
onConnected: () {},
|
||||
onDisconnected: (_) {},
|
||||
);
|
||||
final requests = <List<dynamic>>[];
|
||||
Iterable<String> get history => requests
|
||||
.where((r) => (r[1] as String).startsWith('h-'))
|
||||
.map((r) => r[1] as String)
|
||||
.toList();
|
||||
Iterable<String> profileIds(bool huddle) => requests
|
||||
.where(
|
||||
(r) =>
|
||||
(r[2]['kinds'] as List).contains(0) &&
|
||||
(huddle
|
||||
? r[2]['limit'] == 0
|
||||
: (r[2]['kinds'] as List).contains(10100)),
|
||||
)
|
||||
.map((r) => r[1] as String);
|
||||
void ready(RelaySessionNotifier session) {
|
||||
for (final r in requests.toList()) {
|
||||
session.debugHandleMessage(['EOSE', r[1]]);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void send(List<dynamic> payload) {
|
||||
if (payload.first == 'REQ') requests.add(payload);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user