Files
buzz/mobile/test/shared/community/community_storage_test.dart
Wes 75854111b8 fix(mobile): retain invite keys and reject incomplete onboarding (#8753)
Self-hosted invite claims retain the generated identity in secure
storage before the HTTP request and reuse it after a lost response or
restart. Saving or reopening an existing destination retires the
matching journal. Sign-out/removal deletes all matching pending key
copies before removing the saved community, preserving the community for
retry if cleanup fails.

Pairing verifies that relay authentication completed before
saving/activating a destination or reporting success. On iOS 16, hosted
entry permits recovery and reuse of backed-up identities, including
binding a recovered identity with a confirmed backup receipt. Paths
requiring a new secure backup explain the iOS 17 requirement before
generating or binding a new key. Self-hosted entry remains available.

### Validation

- Initial focused entry/recovery files: 83 passed, 1 skipped. Review-fix
focused storage, community, invite and hosted files: 82 passed, 1
skipped; extended invite journey: 17 passed, 1 skipped.
- Final hosted journey extension: 20 passed, including both bound and
unbound recovery with native sealing unavailable.
- Isolated mutations removing invite retention, pairing validation,
journal removal/re-entry cleanup or restoring the blanket platform gate
fail their intended regressions. Restored controls pass.
- iOS simulator build passed on the original integration base; final
native/device acceptance remains outstanding.
- Carl and three specialist reviewers found no source-review blockers at
`323cec6ea2`. `a55a246857` adds analyzer fixes, copy cleanup and the
recovered-unbound journey coverage. Reviewers did not run tests or the
app.

- Fixed a push-hook timeout in `4564b3562a`: idle serial queues now
release completed futures, avoiding scheduler retention across widget
tests while preserving in-flight ordering and failure isolation. The
existing dispatcher journey fails promptly before the fix and passes
afterward; Carl reviewed this delta with no blockers. Focused
dispatcher/invite/storage checks passed, and all 20 hosted journeys
passed with hosted auth enabled.

- Mandatory pre-push checks passed at `4564b3562a` (scope, branch skew,
file size, mobile analysis/tests and crypto package checks); GitHub head
verified at that commit.

### Remaining acceptance

Draft pending agent/device and human acceptance. Exercise interrupted
one-use invite claim and retry across restart, sign-out then reopen
without restoring the removed identity, rejected self-hosted pairing,
iOS 16 backed-up identity recovery (including binding), and iOS 16 new
setup blocked before key creation. Synthetic network/storage boundaries
do not prove native durability or live server behavior. Hosted CI
results are not claimed here.

Optional follow-ups remain outside this patch: pruning provably
unadmitted journals, malformed-record recovery, avoiding an iOS 16
dead-end device-key selection, and pre-existing removal/push-revocation
ordering.

This PR contains three existing-path fixes. The two organization-login
fixes depend on #8700 and remain separate: its stale parent conflicts
with main in unrelated relay code, and the required branch-skew hook
prevents publishing that stack unchanged. This PR does not refresh that
parent or change deployment policy.

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
2026-10-30 10:38:24 -06:00

356 lines
11 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 = {};
bool failPendingInviteDeletes = false;
@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 {
if (failPendingInviteDeletes && key.startsWith('buzz_pending_invite_')) {
throw StateError('Secure storage deletion unavailable');
}
_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('push choices persist independently across storage reloads', () async {
final first = Community.create(
name: 'First',
relayUrl: 'https://first.example',
pubkey: 'a' * 64,
);
final second = Community.create(
name: 'Second',
relayUrl: 'https://second.example',
pubkey: 'b' * 64,
);
await storage.saveAll([first, second]);
await storage.save(first.copyWith(pushNotificationsEnabled: false));
final reloaded = CommunityStorage(secure: fakeSecure);
final communities = await reloaded.loadAll();
expect(communities.map((c) => c.pushNotificationsEnabled), [false, true]);
expect(communities.map((c) => c.pubkey), [first.pubkey, second.pubkey]);
await reloaded.save(communities.last.copyWith(name: 'Renamed'));
expect(
(await CommunityStorage(
secure: fakeSecure,
).loadAll()).map((c) => c.pushNotificationsEnabled),
[false, true],
);
});
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');
});
});
});
}