Files
buzz/mobile/test/features/settings/notification_toggle_test.dart
Wes 5dbaa4d32d fix(ci): restore green main after merge skew and new advisory
Main CI failed on c09730e87 for two unrelated reasons, and the local
pre-push Rust lane also tripped on a load-sensitive test.

Mobile: #8223 was tested against a base from before #8206, which
removed buzzDevPushAppProfile along with app-profile selection. Its new
notification toggle test still put `appProfile` in the native grant map,
so the merged tree did not compile. The field no longer exists, so drop
it.

Security: cargo-deny now reports RUSTSEC-2024-0449 (unsafe-libyaml is
unmaintained), pulled in through serde_yaml. This is an unmaintained
notice, not a vulnerability. Removing the crate means migrating YAML
parsing across several crates, so ignore it with a reason, like the
existing unmaintained entries.

buzz-acp: the spawn_script test helper returned before bash was running,
and timing tests start their idle clock right away. Under full-suite
load, process startup alone took longer than the idle windows, so
keepalive_resets_idle_past_deadline, idle_resets_on_stdout_activity and
the steer capture tests timed out before the script wrote anything. The
helper now has the script print a ready line and consumes it before
returning, so each clock starts once the child is executing. The two
keepalive loops use brace expansion instead of $(seq), dropping an exec
from their first gap, and the keepalive test gets a 250ms idle deadline
(750ms floor) for more headroom; without the reset it still fails.

Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
2026-10-12 10:50:11 -07:00

343 lines
13 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'package:buzz/features/settings/settings_page.dart';
import 'package:buzz/shared/auth/auth_provider.dart';
import 'package:buzz/shared/community/community.dart';
import 'package:buzz/shared/community/community_provider.dart';
import 'package:buzz/shared/community/community_storage.dart';
import 'package:buzz/shared/push/dev_push_lease.dart';
import 'package:buzz/shared/push/push_bootstrap.dart';
import 'package:buzz/shared/push/push_lease_revocation_outbox.dart';
import 'package:buzz/shared/crypto/nip44.dart';
import 'package:buzz/shared/relay/nostr_models.dart';
import 'package:buzz/shared/relay/signed_event_relay.dart';
import 'package:buzz/shared/push/push_bridge.dart';
import 'package:buzz/shared/push/push_relay_capability_provider.dart';
import 'package:buzz/shared/push/push_subscription.dart';
import 'package:buzz/shared/relay/app_lifecycle_provider.dart';
import 'package:buzz/shared/relay/relay_provider.dart';
import 'package:buzz/shared/relay/relay_session.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:shared_preferences/shared_preferences.dart';
import '../../shared/community/community_storage_test.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUpAll(() async {
final fonts = FontLoader('Inter')
..addFont(rootBundle.load('assets/fonts/InterVariable.ttf'));
await fonts.load();
});
for (final outcome in ['accepted', 'failed', 'pending', 'scaled']) {
testWidgets('off then on remains usable with $outcome cleanup', (
tester,
) async {
final semantics = tester.ensureSemantics();
// iPhone 12 mini width; use a wider screen for the 2x single-line case.
tester.view.physicalSize = Size(outcome == 'scaled' ? 430 : 375, 1800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
if (outcome == 'scaled') {
tester.platformDispatcher.textScaleFactorTestValue = 2;
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
}
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
PackageInfo.setMockInitialValues(
appName: 'Buzz',
packageName: 'buzz',
version: '1.0.0',
buildNumber: '1',
buildSignature: '',
);
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final storage = CommunityStorage(secure: FakeSecureStorage());
final subscriptions = [
BuzzPushSubscription(
filter: BuzzPushFilter(kinds: const [9], pTags: ['a' * 64]),
notificationClass: 'default',
),
];
var discoveries = 0;
final session = _Connected();
apnsDeviceToken.value = 'test-apns-token';
addTearDown(() => apnsDeviceToken.value = null);
final grant = <String, Object>{
'relayOrigin': 'wss://relay.example',
'relayPubkey': _descriptor.executorPubkey,
'installationId': 'b' * 32,
'endpointGrant': 'test-endpoint-grant',
'endpointHash': 'c' * 64,
'endpointEpoch': 1,
'generation': 1,
'expiresAt': DateTime.now().millisecondsSinceEpoch ~/ 1000 + 3600,
};
const nativePush = MethodChannel('buzz/push');
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
messenger.setMockMethodCallHandler(
nativePush,
(call) async => switch (call.method) {
'enrollPush' => grant,
'endpointGrants' => [grant],
'startRegistration' || 'syncPushSnapshot' => null,
_ => throw StateError('Unexpected native call ${call.method}'),
},
);
addTearDown(() => messenger.setMockMethodCallHandler(nativePush, null));
final community =
Community.create(
name: 'Team',
relayUrl: 'https://relay.example',
).copyWith(
nsec: nostr.Nip19.encode(
prefix: nostr.Nip19Prefix.nsec,
data: '1' * 64,
),
pushNotificationsEnabled: true,
pushLeaseInstallationId: 'd' * 32,
pushSubscriptionState: BuzzPushLeaseSubscriptionState.desired(
desired: subscriptions,
).withAccepted(subscriptions: subscriptions, generation: 7),
);
await storage.save(community);
await storage.saveActiveId(community.id);
final cleanup = Completer<void>();
final showingSettings = ValueNotifier(true);
addTearDown(showingSettings.dispose);
final snapshots = <List<Community>>[];
final tombstones = <int?>[];
await tester.pumpWidget(
ProviderScope(
overrides: [
savedPrefsProvider.overrideWithValue(prefs),
authProvider.overrideWith(_Auth.new),
communityStorageProvider.overrideWithValue(storage),
communitySnapshotWriterProvider.overrideWithValue((
communities,
) async {
snapshots.add(List.of(communities));
}),
communityPushLeaseDeactivatorProvider.overrideWithValue((
community, {
generation,
}) async {
tombstones.add(generation);
if (outcome == 'failed') throw StateError('relay unavailable');
if (outcome == 'pending') await cleanup.future;
await publishBuzzPushLeaseTombstone(
descriptor: _descriptor,
installationId: community.pushLeaseInstallationId!,
generation: generation!,
nsec: community.nsec!,
memberPubkey: pubkeyFromNsec(community.nsec)!,
submit: SignedEventRelay(
session: session,
nsec: community.nsec,
).submit,
);
}),
buzzPushLeaseRevocationStorageProvider.overrideWithValue(
BuzzPushLeaseRevocationStorage(secure: FakeSecureStorage()),
),
relaySessionProvider.overrideWith(() => session),
myPubkeyProvider.overrideWithValue(nostr.Keys('1' * 64).public),
buzzPushDescriptorFetcherProvider.overrideWithValue((_) async {
discoveries++;
return _descriptor;
}),
appLifecycleProvider.overrideWith(_Lifecycle.new),
buzzPushAuthorizationStatusReaderProvider.overrideWithValue(
() async => BuzzPushAuthorizationStatus.denied,
),
],
child: BuzzPushBootstrap(
child: MaterialApp(
theme: AppTheme.light(),
home: ValueListenableBuilder(
valueListenable: showingSettings,
builder: (_, showing, _) => showing
? SettingsPage(
profileHeader: const SizedBox.shrink(),
identityRecoveryPageBuilder: (_) =>
const SizedBox.shrink(),
)
: const SizedBox.shrink(),
),
),
),
),
);
await tester.pumpAndSettle();
final container = ProviderScope.containerOf(
tester.element(find.byType(SettingsPage)),
);
expect(
container.read(activeCommunityProvider).value?.pushNotificationsEnabled,
isTrue,
reason:
'${container.read(activeCommunityProvider)} / ${container.read(communityListProvider)}',
);
expect(session.leases.single['active'], isTrue);
// Capability discovery plus the active publication.
expect(discoveries, 2);
expect(tester.widget<Switch>(find.byType(Switch)).value, isTrue);
expect(
tester.getSemantics(
find.byKey(const ValueKey('push-notifications-setting')),
),
isSemantics(
label: 'Notifications',
hasToggledState: true,
isToggled: true,
),
);
expect(
tester.getSize(find.byType(Switch)).height,
greaterThanOrEqualTo(48),
);
// Compare rendered row bounds, not just their padding parameters.
final notificationHeight = tester
.getSize(find.byKey(const ValueKey('push-notifications-enabled')))
.height;
final statusHeight = tester
.getSize(find.byKey(const ValueKey('settings-set-status')))
.height;
expect(
notificationHeight,
statusHeight,
reason: 'single-line settings rows share a height',
);
await tester.tap(find.byType(Switch));
await tester.pumpAndSettle();
final stored = (await storage.loadAll()).single;
expect(stored.pushNotificationsEnabled, isFalse);
expect(
stored.pushSubscriptionState.pendingTombstoneGeneration,
outcome == 'failed' || outcome == 'pending'
? greaterThanOrEqualTo(9)
: isNull,
);
expect(tombstones.first, 9);
expect(
tombstones.length,
lessThanOrEqualTo(2),
reason: 'failed cleanup must not defeat the retry backoff',
);
expect(discoveries, 2);
expect(snapshots.last.single.pushNotificationsEnabled, isFalse);
final offSwitch = tester.widget<Switch>(find.byType(Switch));
expect(offSwitch.value, isFalse);
expect(offSwitch.onChanged, isNotNull);
// Closing and reopening Settings must not strand the off control either.
showingSettings.value = false;
await tester.pumpAndSettle();
showingSettings.value = true;
await tester.pumpAndSettle();
expect(tester.widget<Switch>(find.byType(Switch)).onChanged, isNotNull);
await tester.tap(find.text('Notifications'));
await tester.pumpAndSettle();
expect(tester.widget<Switch>(find.byType(Switch)).value, isTrue);
expect((await storage.loadAll()).single.pushNotificationsEnabled, isTrue);
expect(
session.leases.where((lease) => lease['active'] == true).length,
2,
reason:
're-enabling must publish an active lease before the renewal timer',
);
expect(session.leases.last['generation'], greaterThan(tombstones.last!));
if (outcome == 'pending') {
cleanup.complete();
await tester.pumpAndSettle();
expect(
(await storage.loadAll()).single.pushNotificationsEnabled,
isTrue,
);
}
final latestLease = session.leases.reduce(
(a, b) => (a['generation'] as int) > (b['generation'] as int) ? a : b,
);
expect(
latestLease['active'],
isTrue,
reason: 'a delayed old tombstone cannot supersede the new active lease',
);
expect(discoveries, 3);
// A real reconnect must still discover capability again.
(container.read(relaySessionProvider.notifier) as _Connected).setStatus(
SessionStatus.disconnected,
);
await tester.pumpAndSettle();
expect(container.read(currentRelayPushDescriptorProvider).value, isNull);
(container.read(relaySessionProvider.notifier) as _Connected).setStatus(
SessionStatus.connected,
);
await tester.pumpAndSettle();
expect(discoveries, 4);
expect(tester.widget<Switch>(find.byType(Switch)).onChanged, isNotNull);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pumpAndSettle();
semantics.dispose();
debugDefaultTargetPlatformOverride = null;
});
}
}
class _Lifecycle extends AppLifecycleNotifier {
@override
AppLifecycleState build() => AppLifecycleState.resumed;
}
class _Auth extends AuthNotifier {
@override
Future<AuthState> build() async =>
const AuthState(status: AuthStatus.unauthenticated);
}
class _Connected extends RelaySessionNotifier {
final leases = <Map<String, dynamic>>[];
@override
Future<NostrEvent> publish(
NostrEvent event, {
Duration timeout = const Duration(seconds: 8),
}) async {
expect(event.kind, buzzPushLeaseKind);
final plaintext = nip44Decrypt(
getConversationKey('2' * 64, event.pubkey),
event.content,
);
leases.add(jsonDecode(plaintext) as Map<String, dynamic>);
return event;
}
void setStatus(SessionStatus status) => state = SessionState(status: status);
@override
SessionState build() => const SessionState(status: SessionStatus.connected);
}
final _descriptor = BuzzPushLeaseDescriptor(
origin: 'wss://relay.example',
executorKeyId: 'relay-v1',
executorPubkey: nostr.Keys('2' * 64).public,
transport: 'apns',
maxLeaseTtlSeconds: 3600,
maxContentLength: 4096,
maxPlaintextLength: 4096,
maxEndpointLength: 2048,
maxStringLength: 512,
);