Files
buzz/mobile/test/features/settings/notification_toggle_test.dart
Wes 03ce924e32 fix(ci): restore green main after merge skew and new advisory (#8277)
Main CI has been red since c09730e87 (#8223). The Mobile and Security
checks fail on main, and Docker image fails because it requires a green
CI run for the same commit. Locally, the pre-push Rust lane also failed
on load-sensitive `buzz-acp` timing tests. This PR fixes all three so
main passes locally and on GitHub.

- **Mobile:** #8223 was tested against a base from before #8206, which
removed `buzzDevPushAppProfile` and app-profile selection. Its new
`notification_toggle_test.dart` still put `appProfile` in the native
grant map, so the merged tree doesn't compile. This removes the stale
field.
- **Security:** cargo-deny now reports
[RUSTSEC-2024-0449](https://rustsec.org/advisories/RUSTSEC-2024-0449)
(`unsafe-libyaml` is unmaintained), which comes in through `serde_yaml`.
It's an unmaintained notice, not a vulnerability. The `serde_yaml_ng`
fork depends on the same crate, so removing it means migrating YAML
parsing in five crates. Like the existing unmaintained entries, it goes
in `deny.toml` with a reason.
- **buzz-acp test flakes:** the `spawn_script` test helper returned
before bash was running, and the timing tests started their idle clock
right away. Under full-suite load, process startup took longer than the
idle windows. `keepalive_resets_idle_past_deadline`,
`idle_resets_on_stdout_activity` and
`acp_steer_request_omits_expected_run_id_and_carries_session_and_prompt`
failed this way across three pre-push runs. The helper now has the
script print a ready line and consumes it before returning. The
keepalive loops also use brace expansion instead of `$(seq)`, which
avoids an extra process launch before the first line. The keepalive test
now uses a 250ms idle window with a 750ms floor. Both tests still fail
if keepalives don't reset the idle clock.

### Validation
- `cargo-deny check advisories` passes with this `deny.toml` and fails
on main's.
- The `notification_toggle_test.dart` tests and `flutter analyze` pass.
- `cargo test -p buzz-acp --lib` passes all 1055 tests.
- The `acp::tests` suite passed 20 of 20 runs with 4 concurrent copies
over 5 rounds. Under the same load, the version without the
brace-expansion change failed every run.
- All pre-push hooks pass on 5dbaa4d32: rust-tests, mobile-checks,
desktop-tauri-checks, file-size-check, branch-skew.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 16:46:31 +00: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,
);