feat(mobile): add section workspace migration lane

Signed-off-by: Other Brother Darryl <cee32d92756729ee0c097c5661b879c6199931cd25315c8cf398dcbf0f155cf1@buzz.block.builderlab.xyz>
This commit is contained in:
Other Brother Darryl
2026-08-13 19:35:48 -04:00
parent cca7b6178b
commit a5d665a9cb
5 changed files with 2275 additions and 30 deletions
@@ -10,6 +10,7 @@ import 'package:uuid/uuid.dart';
import '../../../shared/crypto/nip44.dart';
import '../../../shared/relay/relay.dart';
import '../../../shared/read_state/read_state_time.dart';
import 'section_workspace_sync.dart';
import 'channel_sections_storage.dart';
const _uuid = Uuid();
@@ -38,6 +39,7 @@ class ChannelSectionsManager {
final RelaySessionNotifier? _relaySession;
final SignedEventRelay? _signedEventRelay;
final bool _remoteEnabled;
final SectionWorkspaceSyncManager? _workspaceSync;
final VoidCallback _onChanged;
ChannelSectionStore _store;
@@ -51,6 +53,7 @@ class ChannelSectionsManager {
/// Base delay for the startup-sync retry backoff. Overridable in tests.
final Duration _startupRetryBaseDelay;
Timer? _startupRetryTimer;
Timer? _workspaceRetryTimer;
int _startupRetryAttempt = 0;
bool _startupFetchSucceeded = false;
Future<void>? _syncInFlight;
@@ -64,22 +67,165 @@ class ChannelSectionsManager {
required RelaySessionNotifier? relaySession,
required SignedEventRelay? signedEventRelay,
required bool remoteEnabled,
SectionWorkspaceSyncManager? workspaceSync,
required VoidCallback onChanged,
@visibleForTesting
Duration startupRetryBaseDelay = const Duration(seconds: 2),
}) : _storage = ChannelSectionsStorage(prefs),
}) : _storage = ChannelSectionsStorage(
prefs,
relayAuthority: workspaceSync?.relayStorageScope,
),
_crypto = crypto,
_relaySession = relaySession,
_signedEventRelay = signedEventRelay,
_remoteEnabled = remoteEnabled,
_workspaceSync = workspaceSync,
_onChanged = onChanged,
_startupRetryBaseDelay = startupRetryBaseDelay,
_store = ChannelSectionsStorage(prefs).read(pubkey);
_store = ChannelSectionsStorage(
prefs,
relayAuthority: workspaceSync?.relayStorageScope,
).read(pubkey);
ChannelSectionStore get store => _store;
bool applyWorkspaceProjection(SectionWorkspaceProjection projection) {
if (_disposed) return false;
try {
final key = SectionWorkspaceKeyEnvelopeCrypto(
nsec: _workspaceNsec,
ownerPubkey: projection.ownerPubkey,
).unwrap(projection.readerKeyEnvelope);
final metadata = SectionWorkspaceMetadataCrypto(key);
final sections = <ChannelSection>[];
final projectedSections = projection.sections.toList()
..sort((a, b) => a.rank.compareTo(b.rank));
for (final projected in projectedSections) {
final name = metadata.decrypt(
envelope: projected.encryptedLabel,
community: _workspaceAuthority,
ownerPubkey: projection.ownerPubkey,
sectionId: projected.id,
keyEpoch: projection.keyEpoch,
purpose: 'label',
);
final icon = projected.encryptedIcon == null
? null
: metadata.decrypt(
envelope: projected.encryptedIcon!,
community: _workspaceAuthority,
ownerPubkey: projection.ownerPubkey,
sectionId: projected.id,
keyEpoch: projection.keyEpoch,
purpose: 'icon',
);
sections.add(
ChannelSection(
id: projected.id,
name: name,
icon: icon,
order: projected.rank,
),
);
}
// Workspace state is read-only in Stage 1. Do not overwrite the local
// legacy store when rendering a verified projection; it remains the
// rollback source used to construct an import until cutover completes.
_store = ChannelSectionStore(
sections: sections,
assignments: {
for (final assignment in projection.assignments)
assignment.channelId: assignment.sectionId,
},
);
_onChanged();
return true;
} catch (error) {
debugPrint(
'[ChannelSectionsManager] workspace projection rejected: $error',
);
return false;
}
}
String get _workspaceNsec => _workspaceSync?.nsec ?? '';
String get _workspaceAuthority => _workspaceSync?.relayAuthority ?? '';
bool get _legacyMutationAllowed =>
_workspaceSync == null || _workspaceSync.legacyReadAllowed;
void stopLegacySync() {
if (_disposed) return;
_startupRetryTimer?.cancel();
_startupRetryTimer = null;
_workspaceRetryTimer?.cancel();
_workspaceRetryTimer = null;
_publishDebounce?.cancel();
_publishDebounce = null;
_unsubscribe?.call();
_unsubscribe = null;
_subscriptionGeneration++;
_startupFetchSucceeded = true;
_syncAgain = false;
}
Future<void> retryWorkspaceSubscription() async {
if (_disposed || _workspaceSync == null) return;
if (!_workspaceSync.probeCompleted) {
await _workspaceSync.probe();
if (_workspaceSync.workspaceKnown) {
stopLegacySync();
_onChanged();
return;
}
if (!_workspaceSync.probeCompleted) {
scheduleWorkspaceRetry();
return;
}
}
if (_workspaceSync.subscriptionActive) {
await _workspaceSync.retryPendingImport();
return;
}
final subscribed = await _workspaceSync.startSubscription();
if (subscribed) {
await _workspaceSync.retryPendingImport();
} else {
scheduleWorkspaceRetry();
}
}
void scheduleWorkspaceRetry() {
if (_disposed || _workspaceRetryTimer != null) return;
_workspaceRetryTimer = Timer(_startupRetryBaseDelay, () {
_workspaceRetryTimer = null;
unawaited(retryWorkspaceSubscription());
});
}
Future<void> initialize() async {
if (_disposed) return;
if (_workspaceSync != null) {
// Render the last verified projection before touching the network.
if (_workspaceSync.cache != null &&
applyWorkspaceProjection(_workspaceSync.cache!.projection)) {
_workspaceSync.markCacheVerified();
}
if (_remoteEnabled && _relaySession != null) {
await _workspaceSync.probe();
final subscribed = await _workspaceSync.startSubscription();
if (!subscribed) scheduleWorkspaceRetry();
await _workspaceSync.retryPendingImport();
}
// A verified cache or projection is authoritative. Do not even open a
// legacy subscription after cutover; the old blob is a read-only
// rollback artifact, not a second source of truth.
if (_workspaceSync.workspaceKnown) {
stopLegacySync();
_onChanged();
return;
}
}
if (!_remoteEnabled || _relaySession == null) {
_onChanged();
@@ -120,11 +266,30 @@ class ChannelSectionsManager {
}
Future<void> _runSyncWithRelay() async {
if (_workspaceSync != null && !_workspaceSync.probeCompleted) {
await _workspaceSync.probe();
await _workspaceSync.startSubscription();
if (_disposed) return;
if (_workspaceSync.workspaceKnown) {
stopLegacySync();
_onChanged();
return;
}
}
if (!_startupFetchSucceeded) {
final fetched = await _fetchAndMerge();
if (_disposed) return;
_startupFetchSucceeded = fetched;
}
// A live workspace projection can arrive while the legacy history fetch is
// in flight. Re-check the cutover boundary before opening any legacy
// subscription; otherwise a projection callback could stop a subscription
// that this continuation immediately recreates.
if (_workspaceSync?.workspaceKnown == true) {
stopLegacySync();
_onChanged();
return;
}
final subscribed = _unsubscribe != null || await _startLiveSubscription();
if (_disposed) return;
@@ -170,11 +335,14 @@ class ChannelSectionsManager {
void dispose({bool flushPending = true}) {
if (_disposed) return;
_disposed = true;
_workspaceSync?.dispose();
_subscriptionGeneration++;
_syncAgain = false;
_startupRetryTimer?.cancel();
_startupRetryTimer = null;
_workspaceRetryTimer?.cancel();
_workspaceRetryTimer = null;
final hadPending = _publishDebounce != null;
_publishDebounce?.cancel();
@@ -189,7 +357,7 @@ class ChannelSectionsManager {
}
void createSection(String name) {
if (_disposed) return;
if (_disposed || !_legacyMutationAllowed) return;
final maxOrder = _store.sections.fold<int>(
-1,
(max, s) => s.order > max ? s.order : max,
@@ -208,7 +376,7 @@ class ChannelSectionsManager {
}
void renameSection(String sectionId, String newName) {
if (_disposed) return;
if (_disposed || !_legacyMutationAllowed) return;
_store = ChannelSectionStore(
sections: [
for (final s in _store.sections)
@@ -229,7 +397,7 @@ class ChannelSectionsManager {
}
void deleteSection(String sectionId) {
if (_disposed) return;
if (_disposed || !_legacyMutationAllowed) return;
final updatedAssignments = Map<String, String>.from(_store.assignments)
..removeWhere((_, sid) => sid == sectionId);
_store = ChannelSectionStore(
@@ -244,7 +412,7 @@ class ChannelSectionsManager {
}
void moveSectionUp(String sectionId) {
if (_disposed) return;
if (_disposed || !_legacyMutationAllowed) return;
final sorted = _sortedSections();
final idx = sorted.indexWhere((s) => s.id == sectionId);
if (idx <= 0) return;
@@ -253,7 +421,7 @@ class ChannelSectionsManager {
}
void moveSectionDown(String sectionId) {
if (_disposed) return;
if (_disposed || !_legacyMutationAllowed) return;
final sorted = _sortedSections();
final idx = sorted.indexWhere((s) => s.id == sectionId);
if (idx < 0 || idx >= sorted.length - 1) return;
@@ -262,7 +430,7 @@ class ChannelSectionsManager {
}
void assignChannel(String channelId, String sectionId) {
if (_disposed) return;
if (_disposed || !_legacyMutationAllowed) return;
final updated = Map<String, String>.from(_store.assignments)
..[channelId] = sectionId;
_store = ChannelSectionStore(
@@ -274,7 +442,7 @@ class ChannelSectionsManager {
}
void unassignChannel(String channelId) {
if (_disposed) return;
if (_disposed || !_legacyMutationAllowed) return;
final updated = Map<String, String>.from(_store.assignments)
..remove(channelId);
_store = ChannelSectionStore(
@@ -286,7 +454,7 @@ class ChannelSectionsManager {
}
void markDirty() {
if (!_remoteEnabled || _disposed) return;
if (!_remoteEnabled || _disposed || !_legacyMutationAllowed) return;
_publishDebounce?.cancel();
_publishDebounce = Timer(const Duration(seconds: 5), () {
_publishDebounce = null;
@@ -311,7 +479,12 @@ class ChannelSectionsManager {
);
if (_disposed && !allowDisposed) return false;
_mergeEvents(events);
_persist();
// A workspace projection or migration marker can become authoritative
// while the legacy fetch is in flight. Never write the rendered
// workspace state back into the legacy blob after that boundary.
if (_workspaceSync == null || _workspaceSync.legacyReadAllowed) {
_persist();
}
if (!_disposed) _onChanged();
return true;
} catch (error) {
@@ -375,17 +548,54 @@ class ChannelSectionsManager {
}
void _mergeEvent(NostrEvent event) {
// Only process channel-sections d-tag events.
final dTag = event.getTagValue('d');
if (dTag != 'channel-sections') return;
if (event.pubkey != pubkey || event.kind != EventKind.readState) return;
final pendingSource =
_workspaceSync?.isPendingImportSource(event.id) ?? false;
if (_workspaceSync != null &&
!_workspaceSync.legacyReadAllowed &&
!pendingSource) {
return;
}
if (_workspaceSync?.workspaceKnown == true) return;
if (_workspaceSync?.migrationStarted == true && !pendingSource) {
return;
}
// A persisted import command is already the exact retry source. Do not
// re-render or persist the legacy event while the one-way cutover is
// pending; this keeps the workspace/import lane authoritative.
if (pendingSource) {
unawaited(_workspaceSync!.retryPendingImport());
return;
}
// Only process exactly one channel-sections d-tag event.
final dTags = event.tags
.where(
(tag) =>
tag.length == 2 && tag[0] == 'd' && tag[1] == 'channel-sections',
)
.length;
if (dTags != 1) return;
try {
final plaintext = _crypto.decrypt(event.content);
final parsed = jsonDecode(plaintext);
final parsed = parseSectionWorkspaceJson(plaintext);
if (parsed is! Map<String, dynamic>) return;
final incoming = ChannelSectionStore.fromJson(parsed);
if (_workspaceSync != null &&
_workspaceSync.probeCompleted &&
!_workspaceSync.workspaceKnown) {
unawaited(
_workspaceSync.tryImportLegacy(
event: event,
plaintext: parsed,
store: incoming,
),
);
}
// Last-write-wins: newer createdAt wins; tie-break by event ID.
final isNewer =
event.createdAt > _lastRemoteCreatedAt ||
@@ -396,7 +606,9 @@ class ChannelSectionsManager {
_lastRemoteCreatedAt = event.createdAt;
_lastRemoteEventId = event.id;
_store = incoming;
_persist();
if (_workspaceSync == null || _workspaceSync.legacyReadAllowed) {
_persist();
}
}
} catch (_) {
// Decryption failure or parse error — keep existing state.
@@ -433,12 +645,22 @@ class ChannelSectionsManager {
Future<void> _publish({bool allowDisposed = false}) async {
if ((!allowDisposed && _disposed) ||
!_remoteEnabled ||
_signedEventRelay == null) {
_signedEventRelay == null ||
(_workspaceSync != null && !_workspaceSync.legacyPublishAllowed)) {
return;
}
// Read-before-write: merge remote state before publishing
await _fetchAndMerge(allowDisposed: allowDisposed);
// Probe/subscription callbacks can discover a workspace while the legacy
// fetch is in flight. Recheck immediately before signing so migration
// never races with a legacy whole-blob write.
if ((_workspaceSync != null &&
(!_workspaceSync.legacyPublishAllowed ||
_workspaceSync.workspaceKnown)) ||
(!_remoteEnabled && !allowDisposed)) {
return;
}
// No-op suppression: skip if nothing changed
if (_isIdenticalToLastPublished()) return;
@@ -1,9 +1,12 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
import '../../../shared/relay/relay.dart';
import '../../../shared/theme/theme_provider.dart';
import '../../../shared/community/community_provider.dart';
import 'section_workspace_sync.dart';
import 'channel_sections_manager.dart';
import 'channel_sections_storage.dart';
@@ -56,8 +59,30 @@ class ChannelSectionsNotifier extends Notifier<ChannelSectionsState> {
session: ref.read(relaySessionProvider.notifier),
nsec: nsec,
);
late final ChannelSectionsManager manager;
final workspaceSync = SectionWorkspaceSyncManager(
ownerPubkey: pubkey,
nsec: nsec,
prefs: prefs,
relaySession: ref.read(relaySessionProvider.notifier),
signedEventRelay: signedRelay,
relayAuthority:
ref.read(activeCommunityProvider).value?.relayUrl ??
relayConfig.baseUrl,
onWorkspaceDiscovered: () {
if (_manager != manager) return;
manager.stopLegacySync();
_emitManagerState(manager);
},
onSubscriptionLost: () {
if (_manager != manager) return;
manager.scheduleWorkspaceRetry();
},
onProjection: (projection) {
if (_manager != manager) return false;
return manager.applyWorkspaceProjection(projection);
},
);
manager = ChannelSectionsManager(
pubkey: pubkey,
prefs: prefs,
@@ -65,6 +90,7 @@ class ChannelSectionsNotifier extends Notifier<ChannelSectionsState> {
relaySession: ref.read(relaySessionProvider.notifier),
signedEventRelay: signedRelay,
remoteEnabled: sessionState.status == SessionStatus.connected,
workspaceSync: workspaceSync,
onChanged: () => _emitManagerState(manager),
);
_manager = manager;
@@ -1,8 +1,18 @@
import 'dart:async';
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
String channelSectionsKey(String pubkey) => 'buzz.channel-sections.v1:$pubkey';
String channelSectionsKey(String pubkey, {String? relayAuthority}) {
if (relayAuthority == null || relayAuthority.isEmpty) {
return 'buzz.channel-sections.v1:$pubkey';
}
final normalized = relayAuthority
.trim()
.replaceAll(RegExp(r'/+$'), '')
.toLowerCase();
return 'buzz.channel-sections.v1:$pubkey:${Uri.encodeComponent(normalized)}';
}
class ChannelSection {
final String id;
@@ -86,31 +96,55 @@ class ChannelSectionStore {
}
class ChannelSectionsStorage {
final String? _relayAuthority;
final SharedPreferences _prefs;
ChannelSectionsStorage(this._prefs);
ChannelSectionsStorage(this._prefs, {String? relayAuthority})
: _relayAuthority = relayAuthority;
ChannelSectionStore read(String pubkey) {
final raw = _prefs.getString(channelSectionsKey(pubkey));
if (raw == null || raw.isEmpty) {
return const ChannelSectionStore();
final key = channelSectionsKey(pubkey, relayAuthority: _relayAuthority);
var raw = _prefs.getString(key);
if ((raw == null || raw.isEmpty) &&
_relayAuthority != null &&
_relayAuthority.isNotEmpty) {
final legacyKey = channelSectionsKey(pubkey);
final legacyRaw = _prefs.getString(legacyKey);
final legacyStore = _parse(legacyRaw);
if (legacyStore != null) {
final scopedPersisted = _prefs.setString(
key,
jsonEncode(legacyStore.toJson()),
);
unawaited(
scopedPersisted.then((persisted) {
if (persisted) unawaited(_prefs.remove(legacyKey));
}),
);
return legacyStore;
}
raw = legacyRaw;
}
return _parse(raw) ?? const ChannelSectionStore();
}
ChannelSectionStore? _parse(String? raw) {
if (raw == null || raw.isEmpty) return null;
try {
final parsed = jsonDecode(raw);
if (parsed is! Map<String, dynamic>) {
return const ChannelSectionStore();
}
if (parsed['version'] != 1) {
return const ChannelSectionStore();
if (parsed is! Map<String, dynamic> || parsed['version'] != 1) {
return null;
}
return ChannelSectionStore.fromJson(parsed);
} catch (_) {
return const ChannelSectionStore();
return null;
}
}
void write(String pubkey, ChannelSectionStore store) {
_prefs.setString(channelSectionsKey(pubkey), jsonEncode(store.toJson()));
_prefs.setString(
channelSectionsKey(pubkey, relayAuthority: _relayAuthority),
jsonEncode(store.toJson()),
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,330 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:buzz/features/channels/channel_sections/section_workspace_sync.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
const owner =
'1111111111111111111111111111111111111111111111111111111111111111';
const sourceEvent =
'2222222222222222222222222222222222222222222222222222222222222222';
const sourceHash =
'3333333333333333333333333333333333333333333333333333333333333333';
const sectionA = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
const sectionB = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
const channel = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc';
Map<String, dynamic> projection({int revision = 4}) => {
'version': 1,
'owner_pubkey': owner,
'revision': revision,
'layout_revision': 1,
'key_epoch': 2,
'migration': {'source_event_id': sourceEvent, 'source_hash': sourceHash},
'reader_key_envelope': 'nip44-envelope-reader-epoch-2',
'sections': [
{
'id': sectionA,
'rank': 0,
'encrypted_label': 'ciphertext-alpha',
'encrypted_icon': null,
},
{
'id': sectionB,
'rank': 1,
'encrypted_label': 'ciphertext-beta',
'encrypted_icon': 'ciphertext-icon',
},
],
'assignments': [
{'channel_id': channel, 'section_id': sectionA, 'revision': 1},
],
};
test(
'shared fixture projection cases and canonical command vectors remain exact',
() {
final fixture =
jsonDecode(
File('../docs/nips/NIP-SW.fixtures.json').readAsStringSync(),
)
as Map<String, dynamic>;
final cases = fixture['projection_cases'] as List<dynamic>;
for (final rawCase in cases) {
final item = rawCase as Map<String, dynamic>;
final incoming =
(item['projection'] as Map<String, dynamic>)['revision'] as int;
final previous = item['previous_revision'] as int?;
final expected = switch (item['expect']) {
'accept' => SectionWorkspaceRevisionAction.accept,
'refetch' => SectionWorkspaceRevisionAction.refetch,
'ignore' => SectionWorkspaceRevisionAction.ignore,
_ => throw StateError('unknown fixture expectation'),
};
expect(sectionWorkspaceRevisionAction(previous, incoming), expected);
}
final canonicalization =
fixture['canonicalization'] as Map<String, dynamic>;
final legacy =
canonicalization['legacy_plaintext'] as Map<String, dynamic>;
final legacyCanonical = legacy['canonical'] as String;
expect(sectionWorkspaceCanonicalJson(legacy['input']), legacyCanonical);
expect(sectionWorkspaceSha256Hex(legacyCanonical), legacy['sha256']);
final command =
canonicalization['import_command'] as Map<String, dynamic>;
final commandCanonical = command['canonical'] as String;
expect(
sectionWorkspaceCanonicalJson(jsonDecode(commandCanonical)),
commandCanonical,
);
expect(sectionWorkspaceSha256Hex(commandCanonical), command['sha256']);
},
);
test('strictly parses the complete shared projection case', () {
final parsed = parseSectionWorkspaceProjection(projection());
expect(parsed, isNotNull);
expect(parsed!.sections.map((section) => section.id), [sectionA, sectionB]);
expect(parsed.assignments.single.channelId, channel);
});
test(
'parses the frozen revision-zero import and rejects unknown fields and versions',
() {
final fixture =
jsonDecode(
File('../docs/nips/NIP-SW.fixtures.json').readAsStringSync(),
)
as Map<String, dynamic>;
final canonicalization =
fixture['canonicalization'] as Map<String, dynamic>;
final import = parseSectionWorkspaceImportJson(
(canonicalization['import_command']
as Map<String, dynamic>)['canonical']
as String,
);
expect(import, isNotNull);
expect(import!.actionId, 'dddddddd-dddd-4ddd-8ddd-dddddddddddd');
expect(import.sections.single.id, sectionA);
expect(import.assignments.single.sectionId, sectionA);
final unknown = import.toJson()..['extra'] = true;
expect(parseSectionWorkspaceImport(unknown), isNull);
final wrongVersion = import.toJson()..['version'] = 2;
expect(parseSectionWorkspaceImport(wrongVersion), isNull);
final nestedUnknown = import.toJson();
final firstSection = Map<String, dynamic>.from(
((nestedUnknown['sections'] as List).single as Map),
)..['extra'] = true;
nestedUnknown['sections'] = [firstSection];
expect(parseSectionWorkspaceImport(nestedUnknown), isNull);
},
);
test('canonical relay authority feeds the exact metadata AAD vector', () {
expect(
sectionWorkspaceAuthority(' wss://Relay.Example/// '),
'relay.example',
);
expect(
sectionWorkspaceAuthority('https://RELAY.EXAMPLE:443/'),
'relay.example',
);
expect(
sectionWorkspaceAuthority('wss://Relay.Example:8443/'),
'relay.example:8443',
);
final crypto = SectionWorkspaceMetadataCrypto(
Uint8List.fromList(List<int>.generate(32, (index) => index)),
);
final envelope = crypto.encrypt(
plaintext: 'Alpha',
community: sectionWorkspaceAuthority('wss://Relay.Example/'),
ownerPubkey: owner,
sectionId: sectionA,
keyEpoch: 1,
purpose: 'label',
nonce: Uint8List.fromList(List<int>.generate(12, (index) => index)),
);
expect(envelope, 'aes256gcm:AAECAwQFBgcICQoL:Bm6mc6SHvhxcEB6Q1Lc56VuJZjD7');
});
test(
'rejects unknown fields and unsupported versions at every typed level',
() {
final unknown = projection()..['extra'] = true;
expect(parseSectionWorkspaceProjection(unknown), isNull);
final nestedUnknown = projection();
final nestedMigration = Map<String, dynamic>.from(
nestedUnknown['migration'] as Map,
);
nestedMigration['extra'] = true;
nestedUnknown['migration'] = nestedMigration;
expect(parseSectionWorkspaceProjection(nestedUnknown), isNull);
final sectionUnknown = projection();
final firstSection = Map<String, dynamic>.from(
((sectionUnknown['sections'] as List).first as Map),
);
firstSection['extra'] = true;
sectionUnknown['sections'] = [
firstSection,
...(sectionUnknown['sections'] as List).skip(1),
];
expect(parseSectionWorkspaceProjection(sectionUnknown), isNull);
final assignmentUnknown = projection();
final firstAssignment = Map<String, dynamic>.from(
((assignmentUnknown['assignments'] as List).first as Map),
);
firstAssignment['extra'] = true;
assignmentUnknown['assignments'] = [
firstAssignment,
...(assignmentUnknown['assignments'] as List).skip(1),
];
expect(parseSectionWorkspaceProjection(assignmentUnknown), isNull);
final wrongVersion = projection()..['version'] = 2;
expect(parseSectionWorkspaceProjection(wrongVersion), isNull);
},
);
test('rejects malformed shape, duplicate ids, and non-permutation ranks', () {
final duplicateId = projection();
(duplicateId['sections'] as List)[1]['id'] = sectionA;
expect(parseSectionWorkspaceProjection(duplicateId), isNull);
final badRank = projection();
(badRank['sections'] as List)[1]['rank'] = 0;
expect(parseSectionWorkspaceProjection(badRank), isNull);
final unknownSection = projection();
(unknownSection['assignments'] as List).single['section_id'] = sectionB;
expect(parseSectionWorkspaceProjection(unknownSection), isNotNull);
(unknownSection['assignments'] as List).single['section_id'] =
'dddddddd-dddd-4ddd-8ddd-dddddddddddd';
expect(parseSectionWorkspaceProjection(unknownSection), isNull);
});
test('duplicate JSON keys and fractional numbers fail strict decoding', () {
final valid = jsonEncode(projection());
expect(parseSectionWorkspaceProjectionJson(valid), isNotNull);
expect(
parseSectionWorkspaceProjectionJson(
valid.replaceFirst('{"version":1', '{"version":1,"version":1'),
),
isNull,
);
expect(
parseSectionWorkspaceProjectionJson(
valid.replaceFirst('"revision":4', '"revision":4.5'),
),
isNull,
);
});
test(
'revision behavior matches accept, refetch, and ignore fixture cases',
() {
expect(
sectionWorkspaceRevisionAction(null, 4),
SectionWorkspaceRevisionAction.accept,
);
expect(
sectionWorkspaceRevisionAction(4, 6),
SectionWorkspaceRevisionAction.refetch,
);
expect(
sectionWorkspaceRevisionAction(4, 3),
SectionWorkspaceRevisionAction.ignore,
);
expect(
sectionWorkspaceRevisionAction(4, 4),
SectionWorkspaceRevisionAction.ignore,
);
},
);
test('canonicalization matches the shared legacy plaintext vector', () {
final input = {
'version': 1,
'sections': [
{'id': sectionA, 'name': 'Alpha', 'icon': 'folder', 'order': 0},
],
'assignments': {channel: sectionA},
};
const expected =
'{"assignments":{"cccccccc-cccc-4ccc-8ccc-cccccccccccc":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"},"sections":[{"icon":"folder","id":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","name":"Alpha","order":0}],"version":1}';
expect(sectionWorkspaceCanonicalJson(input), expected);
expect(
sectionWorkspaceSha256Hex(expected),
'336ba846d8a2aef9ca7de80e494201e503b64fa364ec1a629873f3fa83232d5d',
);
});
test('AES-GCM metadata vector authenticates its complete AAD', () {
final crypto = SectionWorkspaceMetadataCrypto(
Uint8List.fromList(List<int>.generate(32, (index) => index)),
);
const kwargs = {
'community': 'relay.example',
'ownerPubkey': owner,
'sectionId': sectionA,
'keyEpoch': 1,
};
final envelope = crypto.encrypt(
plaintext: 'Alpha',
community: kwargs['community']! as String,
ownerPubkey: kwargs['ownerPubkey']! as String,
sectionId: kwargs['sectionId']! as String,
keyEpoch: kwargs['keyEpoch']! as int,
purpose: 'label',
nonce: Uint8List.fromList(List<int>.generate(12, (index) => index)),
);
expect(envelope, 'aes256gcm:AAECAwQFBgcICQoL:Bm6mc6SHvhxcEB6Q1Lc56VuJZjD7');
expect(
crypto.decrypt(
envelope: envelope,
community: kwargs['community']! as String,
ownerPubkey: kwargs['ownerPubkey']! as String,
sectionId: kwargs['sectionId']! as String,
keyEpoch: kwargs['keyEpoch']! as int,
purpose: 'label',
),
'Alpha',
);
expect(
() => crypto.decrypt(
envelope: envelope,
community: kwargs['community']! as String,
ownerPubkey: kwargs['ownerPubkey']! as String,
sectionId: kwargs['sectionId']! as String,
keyEpoch: 2,
purpose: 'label',
),
throwsA(anything),
);
});
test('cache persists the last verified projection and key epoch', () async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final parsed = parseSectionWorkspaceProjection(projection())!;
final cache = SectionWorkspaceCache(
eventId: sourceEvent,
projection: parsed,
);
await writeSectionWorkspaceCache(prefs, owner, 'relay.example', cache);
final restored = readSectionWorkspaceCache(prefs, owner, 'relay.example');
expect(restored!.eventId, sourceEvent);
expect(restored.projection.revision, 4);
expect(restored.projection.keyEpoch, 2);
expect(restored.toJson()['revision'], 4);
expect(restored.toJson()['key_epoch'], 2);
});
}