mirror of
https://github.com/block/buzz.git
synced 2026-10-30 14:01:37 +00:00
### What changed? Buzz mobile checks the app-store age signal at launch and hard-blocks the app behind a full-screen 18+ notice when the store says the signed-in person is under 18. No age signal (e.g. declined permission, indeterminate value, unsupported OS versions) results in no block. The age value is never persisted. It is read, used to compute one boolean, and discarded. Nothing is written to disk, and neither the age value nor the range is logged or sent to analytics. Platform notes: - **New dependency `com.google.android.play:age-signals:0.0.4`.** This is the only way to read the Play signal. Its AAR declares `minSdkVersion 23`, below the app's 24, so it forces **no minSdk bump**, and it self-declares its own consent activity, so **no AndroidManifest change** is needed. - **iOS entitlement and linking.** `DeclaredAgeRange` autolinks from the import. `Runner.entitlements` now declares `com.apple.developer.declared-age-range`, and the call remains behind `#available(iOS 26.0, *)`. ### Why? App stores now expose a declared-age range, and Buzz's Terms require users to be 18 or older. This wires the store signal to that rule with the smallest change that is defensible. Absent signals, declined or undeterminable results, and unsupported OS versions allow the app through. A transient native request failure does not become evidence that access is allowed: the app remains gated until a retry produces a result. Only an affirmative under-18 signal produces the permanent restriction screen. ### How is it tested? Added tests: - [`age_signal_provider_test`](https://github.com/block/buzz/tree/main/mobile/test/features/age_gate) covers the 17/18 boundary, open-ended ranges, no-signal, transient recovery, bounded retry exhaustion, malformed payloads, and request-once behavior - [`age_gate_app_test`](https://github.com/block/buzz/tree/main/mobile/test/features/age_gate) proves authenticated content remains unmounted while the signal is pending or retryable, and verifies the retry path - [`age_restriction_page_test`](https://github.com/block/buzz/tree/main/mobile/test/features/age_gate) asserts the block screen offers no bypass affordance - [`community_provider_test`](https://github.com/block/buzz/tree/main/mobile/test/shared/community) verifies age restriction clears push state, persists inactive leases, and retries a failed tombstone - [`AgeSignalPayloadTest`](https://github.com/block/buzz/tree/main/mobile/android/app/src/test/kotlin/xyz/block/buzz/mobile) pins the Android success payload to exactly `{status, ageUpper}` and verifies failures return the distinct retryable platform error ### Deployment note Enable the Declared Age Range capability and regenerate signing profiles carrying the entitlement. This relops/App Store Connect task is not performed by this PR. *🤖 This PR was authored [with an agent](codex://threads/019e5128-536b-73b3-8348-88651123f6da).* | Age restriction screen | | --- | | <img src="https://github.com/user-attachments/assets/5b0f48b1-df9b-4a60-9800-80b33f128535" alt="Age restriction screen" width="320"> | --------- Signed-off-by: Tom Brow <tomb@block.xyz> Co-authored-by: npub12wpjffj7q5qjsky5jvk4ldwlxmse5xll3d8gytk4wqd0c5y7jvwspg37n6 <538324a65e0501285894932d5fb5df36e19a1bff8b4e822ed5701afc509e931d@buzz.block.builderlab.xyz> Co-authored-by: npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp <58390b379a262b73ffc632f19c73e6769ef40f36c81e642b960246eff9831222@buzz.block.builderlab.xyz> Co-authored-by: Codex <noreply@openai.com>
323 lines
9.9 KiB
Dart
323 lines
9.9 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:buzz/shared/community/community.dart';
|
|
import 'package:buzz/shared/community/community_storage.dart';
|
|
import 'package:buzz/shared/push/push_subscription.dart';
|
|
|
|
/// In-memory fake that extends Fake to satisfy all FlutterSecureStorage
|
|
/// interface methods, but implements the core read/write/delete with real
|
|
/// in-memory logic.
|
|
class FakeSecureStorage extends Fake implements FlutterSecureStorage {
|
|
final Map<String, String> _data = {};
|
|
final Map<String, int> _writeCounts = {};
|
|
|
|
@override
|
|
Future<String?> read({
|
|
required String key,
|
|
AppleOptions? iOptions,
|
|
AndroidOptions? aOptions,
|
|
LinuxOptions? lOptions,
|
|
WebOptions? webOptions,
|
|
AppleOptions? mOptions,
|
|
WindowsOptions? wOptions,
|
|
}) async => _data[key];
|
|
|
|
@override
|
|
Future<void> write({
|
|
required String key,
|
|
required String? value,
|
|
AppleOptions? iOptions,
|
|
AndroidOptions? aOptions,
|
|
LinuxOptions? lOptions,
|
|
WebOptions? webOptions,
|
|
AppleOptions? mOptions,
|
|
WindowsOptions? wOptions,
|
|
}) async {
|
|
_writeCounts[key] = (_writeCounts[key] ?? 0) + 1;
|
|
if (value != null) {
|
|
_data[key] = value;
|
|
} else {
|
|
_data.remove(key);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> delete({
|
|
required String key,
|
|
AppleOptions? iOptions,
|
|
AndroidOptions? aOptions,
|
|
LinuxOptions? lOptions,
|
|
WebOptions? webOptions,
|
|
AppleOptions? mOptions,
|
|
WindowsOptions? wOptions,
|
|
}) async => _data.remove(key);
|
|
|
|
@override
|
|
Future<Map<String, String>> readAll({
|
|
AppleOptions? iOptions,
|
|
AndroidOptions? aOptions,
|
|
LinuxOptions? lOptions,
|
|
WebOptions? webOptions,
|
|
AppleOptions? mOptions,
|
|
WindowsOptions? wOptions,
|
|
}) async => Map.from(_data);
|
|
|
|
@override
|
|
Future<void> deleteAll({
|
|
AppleOptions? iOptions,
|
|
AndroidOptions? aOptions,
|
|
LinuxOptions? lOptions,
|
|
WebOptions? webOptions,
|
|
AppleOptions? mOptions,
|
|
WindowsOptions? wOptions,
|
|
}) async => _data.clear();
|
|
|
|
@override
|
|
Future<bool> containsKey({
|
|
required String key,
|
|
AppleOptions? iOptions,
|
|
AndroidOptions? aOptions,
|
|
LinuxOptions? lOptions,
|
|
WebOptions? webOptions,
|
|
AppleOptions? mOptions,
|
|
WindowsOptions? wOptions,
|
|
}) async => _data.containsKey(key);
|
|
|
|
// Convenience for setting up test data.
|
|
String? operator [](String key) => _data[key];
|
|
void operator []=(String key, String value) => _data[key] = value;
|
|
int writeCount(String key) => _writeCounts[key] ?? 0;
|
|
}
|
|
|
|
void main() {
|
|
late FakeSecureStorage fakeSecure;
|
|
late CommunityStorage storage;
|
|
|
|
setUp(() {
|
|
fakeSecure = FakeSecureStorage();
|
|
storage = CommunityStorage(secure: fakeSecure);
|
|
});
|
|
|
|
group('CommunityStorage', () {
|
|
test('loadAll returns empty list when no data', () async {
|
|
final result = await storage.loadAll();
|
|
expect(result, isEmpty);
|
|
});
|
|
|
|
test('save and loadAll round-trips a community', () async {
|
|
final ws = Community.create(
|
|
name: 'Test',
|
|
relayUrl: 'https://relay.example.com',
|
|
pubkey: 'abc123',
|
|
);
|
|
|
|
await storage.save(ws);
|
|
final loaded = await storage.loadAll();
|
|
|
|
expect(loaded, hasLength(1));
|
|
expect(loaded.first.id, ws.id);
|
|
expect(loaded.first.name, 'Test');
|
|
expect(loaded.first.relayUrl, 'https://relay.example.com');
|
|
expect(loaded.first.pubkey, 'abc123');
|
|
expect(
|
|
loaded.first.pushSubscriptionState.authority,
|
|
BuzzPushLeaseSubscriptionAuthority.desired,
|
|
);
|
|
});
|
|
|
|
test('round-trips desired push subscription state', () async {
|
|
final pubkey = 'a' * 64;
|
|
final subscriptions = buildDesiredBuzzPushSubscriptions(
|
|
myPubkey: pubkey,
|
|
channelIds: const ['123e4567-e89b-42d3-a456-426614174000'],
|
|
);
|
|
final community =
|
|
Community.create(
|
|
name: 'Push',
|
|
relayUrl: 'https://relay.example.com',
|
|
pubkey: pubkey,
|
|
).copyWith(
|
|
pushSubscriptionState: BuzzPushLeaseSubscriptionState.desired(
|
|
desired: subscriptions,
|
|
),
|
|
);
|
|
|
|
await storage.save(community);
|
|
final loaded = (await storage.loadAll()).single;
|
|
|
|
expect(
|
|
loaded.pushSubscriptionState.toJson(),
|
|
community.pushSubscriptionState.toJson(),
|
|
);
|
|
});
|
|
|
|
test('round-trips a pending push tombstone journal', () async {
|
|
final subscription = BuzzPushSubscription(
|
|
filter: BuzzPushFilter(kinds: const [9], pTags: ['a' * 64]),
|
|
notificationClass: 'default',
|
|
);
|
|
final state =
|
|
BuzzPushLeaseSubscriptionState.desired(desired: [subscription])
|
|
.withAccepted(subscriptions: [subscription], generation: 4)
|
|
.withPendingTombstone(5);
|
|
final community = Community.create(
|
|
name: 'Push',
|
|
relayUrl: 'https://relay.example.com',
|
|
).copyWith(pushSubscriptionState: state);
|
|
|
|
await storage.save(community);
|
|
final loaded = (await storage.loadAll()).single;
|
|
|
|
expect(loaded.pushSubscriptionState.toJson(), state.toJson());
|
|
expect(loaded.pushSubscriptionState.pendingTombstoneGeneration, 5);
|
|
});
|
|
|
|
test(
|
|
'migrates a disabled reserved generation into a tombstone journal',
|
|
() {
|
|
final community =
|
|
Community.create(
|
|
name: 'Push',
|
|
relayUrl: 'https://relay.example.com',
|
|
).copyWith(
|
|
pushNotificationsEnabled: false,
|
|
pushSubscriptionState:
|
|
const BuzzPushLeaseSubscriptionState.desired(
|
|
acceptedGeneration: 4,
|
|
generationCursor: 5,
|
|
),
|
|
);
|
|
final json = community.toJson();
|
|
(json['pushSubscriptionState'] as Map<String, dynamic>).remove(
|
|
'pendingTombstoneGeneration',
|
|
);
|
|
|
|
final migrated = Community.fromJson(json);
|
|
|
|
expect(migrated.pushSubscriptionState.pendingTombstoneGeneration, 5);
|
|
},
|
|
);
|
|
|
|
test('save updates existing community with same id', () async {
|
|
final ws = Community.create(
|
|
name: 'Original',
|
|
relayUrl: 'https://relay.example.com',
|
|
);
|
|
|
|
await storage.save(ws);
|
|
await storage.save(ws.copyWith(name: 'Updated'));
|
|
|
|
final loaded = await storage.loadAll();
|
|
expect(loaded, hasLength(1));
|
|
expect(loaded.first.name, 'Updated');
|
|
});
|
|
|
|
test('remove deletes a community', () async {
|
|
final ws1 = Community.create(
|
|
name: 'One',
|
|
relayUrl: 'https://one.example.com',
|
|
);
|
|
final ws2 = Community.create(
|
|
name: 'Two',
|
|
relayUrl: 'https://two.example.com',
|
|
);
|
|
|
|
await storage.save(ws1);
|
|
await storage.save(ws2);
|
|
await storage.remove(ws1.id);
|
|
|
|
final loaded = await storage.loadAll();
|
|
expect(loaded, hasLength(1));
|
|
expect(loaded.first.id, ws2.id);
|
|
});
|
|
|
|
test('active community ID persists', () async {
|
|
await storage.saveActiveId('ws-123');
|
|
final id = await storage.loadActiveId();
|
|
expect(id, 'ws-123');
|
|
});
|
|
|
|
test('clearActiveId removes active ID', () async {
|
|
await storage.saveActiveId('ws-123');
|
|
await storage.clearActiveId();
|
|
final id = await storage.loadActiveId();
|
|
expect(id, isNull);
|
|
});
|
|
|
|
group('migration', () {
|
|
test('migrates saved workspaces to communities', () async {
|
|
final legacy = Community.create(
|
|
name: 'Legacy',
|
|
relayUrl: 'https://legacy.example.com',
|
|
);
|
|
fakeSecure['buzz_workspaces'] = jsonEncode([legacy.toJson()]);
|
|
fakeSecure['buzz_active_workspace_id'] = legacy.id;
|
|
|
|
final loaded = await storage.loadAll();
|
|
|
|
expect(loaded.single.id, legacy.id);
|
|
expect(await storage.loadActiveId(), legacy.id);
|
|
expect(fakeSecure['buzz_communities'], isNotNull);
|
|
expect(fakeSecure['buzz_workspaces'], isNull);
|
|
expect(fakeSecure['buzz_active_workspace_id'], isNull);
|
|
});
|
|
|
|
test('migrates legacy keys to community on first load', () async {
|
|
fakeSecure['buzz_relay_url'] = 'https://legacy.example.com';
|
|
fakeSecure['buzz_token'] = 'legacy_token';
|
|
fakeSecure['buzz_pubkey'] = 'legacy_pub';
|
|
fakeSecure['buzz_nsec'] = 'legacy_nsec';
|
|
|
|
final loaded = await storage.loadAll();
|
|
|
|
expect(loaded, hasLength(1));
|
|
expect(loaded.first.relayUrl, 'https://legacy.example.com');
|
|
expect(loaded.first.pubkey, 'legacy_pub');
|
|
expect(loaded.first.nsec, 'legacy_nsec');
|
|
expect(loaded.first.name, isNotEmpty);
|
|
expect(
|
|
loaded.first.sensitiveActionPolicy,
|
|
SensitiveActionPolicy.disabledByUser,
|
|
);
|
|
|
|
// Legacy keys should be deleted.
|
|
expect(fakeSecure['buzz_relay_url'], isNull);
|
|
expect(fakeSecure['buzz_token'], isNull);
|
|
expect(fakeSecure['buzz_pubkey'], isNull);
|
|
expect(fakeSecure['buzz_nsec'], isNull);
|
|
|
|
// Active ID should be set.
|
|
final activeId = await storage.loadActiveId();
|
|
expect(activeId, loaded.first.id);
|
|
});
|
|
|
|
test('does not migrate when no legacy keys exist', () async {
|
|
final loaded = await storage.loadAll();
|
|
expect(loaded, isEmpty);
|
|
});
|
|
|
|
test('does not re-migrate after first load', () async {
|
|
fakeSecure['buzz_relay_url'] = 'https://legacy.example.com';
|
|
fakeSecure['buzz_token'] = 'legacy_token';
|
|
|
|
final first = await storage.loadAll();
|
|
expect(first, hasLength(1));
|
|
|
|
final second = await storage.loadAll();
|
|
expect(second, hasLength(1));
|
|
expect(second.first.id, first.first.id);
|
|
});
|
|
|
|
test('migration generates name from localhost URL', () async {
|
|
fakeSecure['buzz_relay_url'] = 'http://localhost:3000';
|
|
fakeSecure['buzz_token'] = 'tok';
|
|
|
|
final loaded = await storage.loadAll();
|
|
expect(loaded.first.name, 'Local Dev');
|
|
});
|
|
});
|
|
});
|
|
}
|