mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(mobile): harden section workspace cutover
Verify legacy NIP-01 source events, strictly parse v1 legacy state, and stage projection UI updates until cache persistence succeeds. Signed-off-by: Other Brother Darryl <cee32d92756729ee0c097c5661b879c6199931cd25315c8cf398dcbf0f155cf1>
This commit is contained in:
@@ -89,8 +89,10 @@ class ChannelSectionsManager {
|
||||
|
||||
ChannelSectionStore get store => _store;
|
||||
|
||||
bool applyWorkspaceProjection(SectionWorkspaceProjection projection) {
|
||||
if (_disposed) return false;
|
||||
ChannelSectionStore? stageWorkspaceProjection(
|
||||
SectionWorkspaceProjection projection,
|
||||
) {
|
||||
if (_disposed) return null;
|
||||
try {
|
||||
final key = SectionWorkspaceKeyEnvelopeCrypto(
|
||||
nsec: _workspaceNsec,
|
||||
@@ -128,26 +130,33 @@ class ChannelSectionsManager {
|
||||
),
|
||||
);
|
||||
}
|
||||
// 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(
|
||||
return 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;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
bool commitWorkspaceProjection(ChannelSectionStore staged) {
|
||||
if (_disposed) return false;
|
||||
_store = staged;
|
||||
_onChanged();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool applyWorkspaceProjection(SectionWorkspaceProjection projection) {
|
||||
final staged = stageWorkspaceProjection(projection);
|
||||
return staged != null && commitWorkspaceProjection(staged);
|
||||
}
|
||||
|
||||
String get _workspaceNsec => _workspaceSync?.nsec ?? '';
|
||||
String get _workspaceAuthority => _workspaceSync?.relayAuthority ?? '';
|
||||
|
||||
@@ -542,13 +551,21 @@ class ChannelSectionsManager {
|
||||
|
||||
void _mergeEvents(List<NostrEvent> events) {
|
||||
for (final event in events) {
|
||||
if (event.pubkey != pubkey) continue;
|
||||
if (event.pubkey != pubkey ||
|
||||
event.kind != EventKind.readState ||
|
||||
!verifySectionWorkspaceNostrEvent(event)) {
|
||||
continue;
|
||||
}
|
||||
_mergeEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
void _mergeEvent(NostrEvent event) {
|
||||
if (event.pubkey != pubkey || event.kind != EventKind.readState) return;
|
||||
if (event.pubkey != pubkey ||
|
||||
event.kind != EventKind.readState ||
|
||||
!verifySectionWorkspaceNostrEvent(event)) {
|
||||
return;
|
||||
}
|
||||
final pendingSource =
|
||||
_workspaceSync?.isPendingImportSource(event.id) ?? false;
|
||||
if (_workspaceSync != null &&
|
||||
@@ -580,9 +597,9 @@ class ChannelSectionsManager {
|
||||
try {
|
||||
final plaintext = _crypto.decrypt(event.content);
|
||||
final parsed = parseSectionWorkspaceJson(plaintext);
|
||||
if (parsed is! Map<String, dynamic>) return;
|
||||
|
||||
final incoming = ChannelSectionStore.fromJson(parsed);
|
||||
final legacy = parseSectionWorkspaceLegacy(parsed);
|
||||
if (legacy == null) return;
|
||||
final incoming = legacy.store;
|
||||
|
||||
if (_workspaceSync != null &&
|
||||
_workspaceSync.probeCompleted &&
|
||||
@@ -590,7 +607,7 @@ class ChannelSectionsManager {
|
||||
unawaited(
|
||||
_workspaceSync.tryImportLegacy(
|
||||
event: event,
|
||||
plaintext: parsed,
|
||||
plaintext: legacy.value,
|
||||
store: incoming,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -78,9 +78,13 @@ class ChannelSectionsNotifier extends Notifier<ChannelSectionsState> {
|
||||
if (_manager != manager) return;
|
||||
manager.scheduleWorkspaceRetry();
|
||||
},
|
||||
onProjection: (projection) {
|
||||
stageProjection: (projection) {
|
||||
if (_manager != manager) return null;
|
||||
return manager.stageWorkspaceProjection(projection);
|
||||
},
|
||||
commitProjection: (staged) {
|
||||
if (_manager != manager) return false;
|
||||
return manager.applyWorkspaceProjection(projection);
|
||||
return manager.commitWorkspaceProjection(staged);
|
||||
},
|
||||
);
|
||||
manager = ChannelSectionsManager(
|
||||
|
||||
@@ -29,6 +29,35 @@ final _sectionWorkspaceUuid = RegExp(
|
||||
r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$',
|
||||
);
|
||||
final _sectionWorkspaceHex64 = RegExp(r'^[0-9a-f]{64}$');
|
||||
bool verifySectionWorkspaceNostrEvent(NostrEvent event) {
|
||||
try {
|
||||
final verifiedEvent = nostr.Event.fromJson(jsonEncode(event.toJson()));
|
||||
return verifiedEvent.id == event.id &&
|
||||
verifiedEvent.pubkey == event.pubkey &&
|
||||
verifiedEvent.createdAt == event.createdAt &&
|
||||
verifiedEvent.kind == event.kind &&
|
||||
verifiedEvent.tags.length == event.tags.length &&
|
||||
_sameSectionWorkspaceTags(verifiedEvent.tags, event.tags) &&
|
||||
verifiedEvent.content == event.content &&
|
||||
verifiedEvent.sig == event.sig;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool _sameSectionWorkspaceTags(
|
||||
List<List<String>> left,
|
||||
List<List<String>> right,
|
||||
) {
|
||||
if (left.length != right.length) return false;
|
||||
for (var index = 0; index < left.length; index++) {
|
||||
if (left[index].length != right[index].length) return false;
|
||||
for (var tagIndex = 0; tagIndex < left[index].length; tagIndex++) {
|
||||
if (left[index][tagIndex] != right[index][tagIndex]) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool _validSectionWorkspaceUuid(Object? value) =>
|
||||
value is String &&
|
||||
@@ -497,6 +526,100 @@ SectionWorkspaceProjection? parseSectionWorkspaceProjectionJson(String json) {
|
||||
}
|
||||
}
|
||||
|
||||
class SectionWorkspaceLegacyParseResult {
|
||||
final Map<String, dynamic> value;
|
||||
final ChannelSectionStore store;
|
||||
|
||||
const SectionWorkspaceLegacyParseResult({
|
||||
required this.value,
|
||||
required this.store,
|
||||
});
|
||||
}
|
||||
|
||||
SectionWorkspaceLegacyParseResult? parseSectionWorkspaceLegacy(Object? value) {
|
||||
if (value is! Map<String, dynamic> ||
|
||||
!_exactKeys(value, const ['version', 'sections', 'assignments'])) {
|
||||
return null;
|
||||
}
|
||||
final version = value['version'];
|
||||
final rawSections = value['sections'];
|
||||
final rawAssignments = value['assignments'];
|
||||
if (version is! int ||
|
||||
version != 1 ||
|
||||
rawSections is! List ||
|
||||
rawSections.length > sectionWorkspaceMaxSections ||
|
||||
rawAssignments is! Map<String, dynamic> ||
|
||||
rawAssignments.length > sectionWorkspaceMaxAssignments) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final ids = <String>{};
|
||||
final orders = <int>{};
|
||||
final sections = <ChannelSection>[];
|
||||
for (final raw in rawSections) {
|
||||
if (raw is! Map<String, dynamic> ||
|
||||
raw.keys.any(
|
||||
(key) => !const {'id', 'name', 'icon', 'order'}.contains(key),
|
||||
) ||
|
||||
raw.length < 3) {
|
||||
return null;
|
||||
}
|
||||
final id = raw['id'];
|
||||
final name = raw['name'];
|
||||
final icon = raw['icon'];
|
||||
final order = raw['order'];
|
||||
if (id is! String ||
|
||||
!_validSectionWorkspaceUuid(id) ||
|
||||
!ids.add(id) ||
|
||||
name is! String ||
|
||||
name.isEmpty ||
|
||||
utf8.encode(name).length > sectionWorkspaceMaxEncryptedMetadataBytes ||
|
||||
(icon != null && icon is! String) ||
|
||||
(icon is String &&
|
||||
utf8.encode(icon).length >
|
||||
sectionWorkspaceMaxEncryptedMetadataBytes) ||
|
||||
order is! int ||
|
||||
order < 0 ||
|
||||
order >= rawSections.length ||
|
||||
!orders.add(order)) {
|
||||
return null;
|
||||
}
|
||||
sections.add(
|
||||
ChannelSection(id: id, name: name, icon: icon as String?, order: order),
|
||||
);
|
||||
}
|
||||
if (!List.generate(
|
||||
sections.length,
|
||||
(index) => index,
|
||||
).every(orders.contains)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final assignments = <String, String>{};
|
||||
for (final entry in rawAssignments.entries) {
|
||||
final channelId = entry.key;
|
||||
final sectionId = entry.value;
|
||||
if (!_validSectionWorkspaceUuid(channelId) ||
|
||||
sectionId is! String ||
|
||||
!_validSectionWorkspaceUuid(sectionId) ||
|
||||
!ids.contains(sectionId)) {
|
||||
return null;
|
||||
}
|
||||
assignments[channelId] = sectionId;
|
||||
}
|
||||
return SectionWorkspaceLegacyParseResult(
|
||||
value: value,
|
||||
store: ChannelSectionStore(sections: sections, assignments: assignments),
|
||||
);
|
||||
}
|
||||
|
||||
SectionWorkspaceLegacyParseResult? parseSectionWorkspaceLegacyJson(
|
||||
String json,
|
||||
) {
|
||||
final value = parseSectionWorkspaceJson(json);
|
||||
return parseSectionWorkspaceLegacy(value);
|
||||
}
|
||||
|
||||
String sectionWorkspaceCanonicalJson(Object? value) {
|
||||
final out = StringBuffer();
|
||||
void write(Object? current) {
|
||||
@@ -797,9 +920,18 @@ class SectionWorkspaceSyncManager {
|
||||
final RelaySessionNotifier? relaySession;
|
||||
final SignedEventRelay? signedEventRelay;
|
||||
final Future<String?> Function()? relaySelfProvider;
|
||||
final Future<bool> Function(
|
||||
SharedPreferences prefs,
|
||||
String ownerPubkey,
|
||||
String relayUrl,
|
||||
SectionWorkspaceCache cache,
|
||||
)?
|
||||
cacheWriter;
|
||||
final String relayUrl;
|
||||
final String relayAuthority;
|
||||
final bool Function(SectionWorkspaceProjection projection)? onProjection;
|
||||
final ChannelSectionStore? Function(SectionWorkspaceProjection projection)?
|
||||
stageProjection;
|
||||
final bool Function(ChannelSectionStore staged)? commitProjection;
|
||||
final void Function()? onWorkspaceDiscovered;
|
||||
final void Function()? onSubscriptionLost;
|
||||
|
||||
@@ -826,9 +958,11 @@ class SectionWorkspaceSyncManager {
|
||||
required this.relaySession,
|
||||
required this.signedEventRelay,
|
||||
this.relaySelfProvider,
|
||||
this.cacheWriter,
|
||||
required String relayAuthority,
|
||||
this.nsec,
|
||||
this.onProjection,
|
||||
this.stageProjection,
|
||||
this.commitProjection,
|
||||
this.onWorkspaceDiscovered,
|
||||
this.onSubscriptionLost,
|
||||
}) : relayUrl = relayAuthority,
|
||||
@@ -1102,8 +1236,7 @@ class SectionWorkspaceSyncManager {
|
||||
// Relay history/live delivery is transport-authenticated, but the
|
||||
// projection is cacheable data. Verify the NIP-01 event before treating
|
||||
// it as a last-verified projection.
|
||||
final verifiedEvent = nostr.Event.fromJson(jsonEncode(event.toJson()));
|
||||
if (verifiedEvent.id != event.id) return null;
|
||||
if (!verifySectionWorkspaceNostrEvent(event)) return null;
|
||||
final projection = parseSectionWorkspaceProjectionJson(event.content);
|
||||
if (projection == null || projection.ownerPubkey != ownerPubkey) {
|
||||
return null;
|
||||
@@ -1141,12 +1274,13 @@ class SectionWorkspaceSyncManager {
|
||||
if (_cache != null && projection.revision <= _cache!.projection.revision) {
|
||||
return false;
|
||||
}
|
||||
if (onProjection == null || !onProjection!(projection)) return false;
|
||||
final staged = stageProjection?.call(projection);
|
||||
if (staged == null) return false;
|
||||
final cache = SectionWorkspaceCache(
|
||||
eventId: event.id,
|
||||
projection: projection,
|
||||
);
|
||||
final persisted = await writeSectionWorkspaceCache(
|
||||
final persisted = await (cacheWriter ?? writeSectionWorkspaceCache)(
|
||||
prefs,
|
||||
ownerPubkey,
|
||||
relayStorageScope,
|
||||
@@ -1156,6 +1290,7 @@ class SectionWorkspaceSyncManager {
|
||||
// Only a projection whose decrypted metadata and verified cache both made
|
||||
// it to durable local storage becomes authoritative. This keeps a cache
|
||||
// write failure on the compatibility side of the cutover boundary.
|
||||
if (commitProjection?.call(staged) != true) return false;
|
||||
_cache = cache;
|
||||
_workspaceKnown = true;
|
||||
_cacheVerified = true;
|
||||
@@ -1219,6 +1354,7 @@ class SectionWorkspaceSyncManager {
|
||||
_importInFlight ||
|
||||
event.pubkey != ownerPubkey ||
|
||||
event.kind != EventKind.readState ||
|
||||
!verifySectionWorkspaceNostrEvent(event) ||
|
||||
event.getTagValue('d') != 'channel-sections' ||
|
||||
event.tags
|
||||
.where(
|
||||
@@ -1234,7 +1370,9 @@ class SectionWorkspaceSyncManager {
|
||||
}
|
||||
_importInFlight = true;
|
||||
try {
|
||||
final canonical = sectionWorkspaceCanonicalJson(plaintext);
|
||||
final acceptedLegacy = parseSectionWorkspaceLegacy(plaintext);
|
||||
if (acceptedLegacy == null) return;
|
||||
final canonical = sectionWorkspaceCanonicalJson(acceptedLegacy.value);
|
||||
final sourceHash = sectionWorkspaceSha256Hex(canonical);
|
||||
final hasPendingImport =
|
||||
_pendingImportSourceEvent != null ||
|
||||
@@ -1254,7 +1392,11 @@ class SectionWorkspaceSyncManager {
|
||||
// The complete command is the durable cutover intent. Do not persist an
|
||||
// action-only marker: without the exact encrypted bytes there is
|
||||
// nothing safe to replay after restart.
|
||||
final body = await _buildImportBody(event, sourceHash, store);
|
||||
final body = await _buildImportBody(
|
||||
event,
|
||||
sourceHash,
|
||||
acceptedLegacy.store,
|
||||
);
|
||||
if (body == null || _destroyed || _workspaceKnown) return;
|
||||
if (parseSectionWorkspaceImport(body) == null) return;
|
||||
final canonicalBody = sectionWorkspaceCanonicalJson(body);
|
||||
|
||||
@@ -23,25 +23,23 @@ void main() {
|
||||
required List<Map<String, dynamic>> sections,
|
||||
Map<String, String> assignments = const {},
|
||||
required int createdAt,
|
||||
String id = 'remote-event',
|
||||
}) {
|
||||
final payload = jsonEncode({
|
||||
'version': 1,
|
||||
'sections': sections,
|
||||
'assignments': assignments,
|
||||
});
|
||||
return NostrEvent(
|
||||
id: id,
|
||||
pubkey: keychain.public,
|
||||
createdAt: createdAt,
|
||||
final event = nostr.Event.from(
|
||||
kind: EventKind.readState,
|
||||
content: crypto.encrypt(payload),
|
||||
tags: const [
|
||||
['d', 'channel-sections'],
|
||||
['t', 'channel-sections'],
|
||||
],
|
||||
content: crypto.encrypt(payload),
|
||||
sig: 'sig',
|
||||
secretKey: keychain.secret,
|
||||
createdAt: createdAt,
|
||||
);
|
||||
return NostrEvent.fromJson(event.toMap());
|
||||
}
|
||||
|
||||
ChannelSectionsManager buildManager({
|
||||
@@ -60,6 +58,47 @@ void main() {
|
||||
);
|
||||
}
|
||||
|
||||
test('rejects forged legacy event identity before merge', () async {
|
||||
await setUpEnv();
|
||||
final valid = sectionsEvent(
|
||||
sections: [
|
||||
{
|
||||
'id': 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
|
||||
'name': 'Desktop Group',
|
||||
'order': 0,
|
||||
},
|
||||
],
|
||||
createdAt: 100,
|
||||
);
|
||||
final forged = NostrEvent(
|
||||
id: 'f' * 64,
|
||||
pubkey: valid.pubkey,
|
||||
createdAt: valid.createdAt,
|
||||
kind: valid.kind,
|
||||
tags: valid.tags,
|
||||
content: valid.content,
|
||||
sig: valid.sig,
|
||||
);
|
||||
final forgedSignature = NostrEvent(
|
||||
id: valid.id,
|
||||
pubkey: valid.pubkey,
|
||||
createdAt: valid.createdAt,
|
||||
kind: valid.kind,
|
||||
tags: valid.tags,
|
||||
content: valid.content,
|
||||
sig: '0' * 128,
|
||||
);
|
||||
final relay = _RateLimitedRelaySession(
|
||||
failuresBeforeSuccess: 0,
|
||||
historyEvents: [forged, forgedSignature],
|
||||
);
|
||||
final manager = buildManager(relaySession: relay);
|
||||
await manager.initialize();
|
||||
await Future<void>.delayed(const Duration(milliseconds: 30));
|
||||
expect(manager.store.sections, isEmpty);
|
||||
manager.dispose(flushPending: false);
|
||||
});
|
||||
|
||||
test('startup fetch rejected by relay rate limit retries until the remote '
|
||||
'blob is adopted (cold-start regression)', () async {
|
||||
await setUpEnv();
|
||||
@@ -72,7 +111,11 @@ void main() {
|
||||
historyEvents: [
|
||||
sectionsEvent(
|
||||
sections: [
|
||||
{'id': 's1', 'name': 'Desktop Group', 'order': 0},
|
||||
{
|
||||
'id': 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
|
||||
'name': 'Desktop Group',
|
||||
'order': 0,
|
||||
},
|
||||
],
|
||||
createdAt: 100,
|
||||
),
|
||||
@@ -112,7 +155,11 @@ void main() {
|
||||
historyEvents: [
|
||||
sectionsEvent(
|
||||
sections: [
|
||||
{'id': 's1', 'name': 'Desktop Group', 'order': 0},
|
||||
{
|
||||
'id': 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
|
||||
'name': 'Desktop Group',
|
||||
'order': 0,
|
||||
},
|
||||
],
|
||||
createdAt: 100,
|
||||
),
|
||||
@@ -172,7 +219,11 @@ void main() {
|
||||
relay.emit(
|
||||
sectionsEvent(
|
||||
sections: [
|
||||
{'id': 's1', 'name': 'Desktop Group', 'order': 0},
|
||||
{
|
||||
'id': 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
|
||||
'name': 'Desktop Group',
|
||||
'order': 0,
|
||||
},
|
||||
],
|
||||
createdAt: 100,
|
||||
),
|
||||
|
||||
@@ -2,10 +2,26 @@ import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:buzz/features/channels/channel_sections/channel_sections_manager.dart';
|
||||
import 'package:buzz/features/channels/channel_sections/channel_sections_storage.dart';
|
||||
import 'package:buzz/features/channels/channel_sections/section_workspace_sync.dart';
|
||||
import 'package:buzz/shared/relay/relay.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class _WorkspaceHistoryRelaySession extends RelaySessionNotifier {
|
||||
_WorkspaceHistoryRelaySession(this.events);
|
||||
|
||||
final List<NostrEvent> events;
|
||||
|
||||
@override
|
||||
Future<List<NostrEvent>> fetchHistory(
|
||||
NostrFilter filter, {
|
||||
Duration timeout = const Duration(seconds: 8),
|
||||
}) async => events;
|
||||
}
|
||||
|
||||
void main() {
|
||||
const owner =
|
||||
'1111111111111111111111111111111111111111111111111111111111111111';
|
||||
@@ -250,6 +266,36 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test('legacy parser rejects unknown, version, and malformed documents', () {
|
||||
final valid = {
|
||||
'version': 1,
|
||||
'sections': [
|
||||
{'id': sectionA, 'name': 'Alpha', 'icon': null, 'order': 0},
|
||||
],
|
||||
'assignments': {channel: sectionA},
|
||||
};
|
||||
expect(parseSectionWorkspaceLegacy(valid), isNotNull);
|
||||
|
||||
expect(parseSectionWorkspaceLegacy({...valid, 'extra': true}), isNull);
|
||||
expect(parseSectionWorkspaceLegacy({...valid, 'version': 2}), isNull);
|
||||
expect(
|
||||
parseSectionWorkspaceLegacy({
|
||||
...valid,
|
||||
'sections': [
|
||||
{'id': sectionA, 'name': 'Alpha', 'icon': null, 'order': 1},
|
||||
],
|
||||
}),
|
||||
isNull,
|
||||
);
|
||||
expect(
|
||||
parseSectionWorkspaceLegacy({
|
||||
...valid,
|
||||
'assignments': {channel: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'},
|
||||
}),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('canonicalization matches the shared legacy plaintext vector', () {
|
||||
final input = {
|
||||
'version': 1,
|
||||
@@ -311,6 +357,120 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'cache write failure leaves staged projection and legacy state untouched',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final ownerKeys = nostr.Keys.generate();
|
||||
final relayKeys = nostr.Keys.generate();
|
||||
final sectionId = sectionA;
|
||||
final oldStore = {
|
||||
'version': 1,
|
||||
'sections': [
|
||||
{'id': sectionId, 'name': 'Legacy', 'order': 0},
|
||||
],
|
||||
'assignments': <String, String>{},
|
||||
};
|
||||
await prefs.setString(
|
||||
channelSectionsKey(ownerKeys.public),
|
||||
jsonEncode(oldStore),
|
||||
);
|
||||
final workspaceKey = Uint8List.fromList(
|
||||
List<int>.generate(32, (index) => index),
|
||||
);
|
||||
final envelope = SectionWorkspaceKeyEnvelopeCrypto(
|
||||
nsec: ownerKeys.nsec,
|
||||
ownerPubkey: ownerKeys.public,
|
||||
).wrap(workspaceKey);
|
||||
final metadata = SectionWorkspaceMetadataCrypto(workspaceKey);
|
||||
final projectionValue = {
|
||||
'version': 1,
|
||||
'owner_pubkey': ownerKeys.public,
|
||||
'revision': 1,
|
||||
'layout_revision': 1,
|
||||
'key_epoch': 1,
|
||||
'migration': {
|
||||
'source_event_id': sourceEvent,
|
||||
'source_hash': sourceHash,
|
||||
},
|
||||
'reader_key_envelope': envelope,
|
||||
'sections': [
|
||||
{
|
||||
'id': sectionId,
|
||||
'rank': 0,
|
||||
'encrypted_label': metadata.encrypt(
|
||||
plaintext: 'Projected',
|
||||
community: 'relay.example',
|
||||
ownerPubkey: ownerKeys.public,
|
||||
sectionId: sectionId,
|
||||
keyEpoch: 1,
|
||||
purpose: 'label',
|
||||
),
|
||||
'encrypted_icon': null,
|
||||
},
|
||||
],
|
||||
'assignments': [],
|
||||
};
|
||||
final relayEvent = nostr.Event.from(
|
||||
kind: sectionWorkspaceProjectionKind,
|
||||
content: jsonEncode(projectionValue),
|
||||
tags: [
|
||||
['d', ownerKeys.public],
|
||||
['p', ownerKeys.public],
|
||||
],
|
||||
secretKey: relayKeys.secret,
|
||||
createdAt: 1700000000,
|
||||
);
|
||||
final relaySession = _WorkspaceHistoryRelaySession([
|
||||
NostrEvent.fromJson(relayEvent.toMap()),
|
||||
]);
|
||||
var renders = 0;
|
||||
late final ChannelSectionsManager manager;
|
||||
final sync = SectionWorkspaceSyncManager(
|
||||
ownerPubkey: ownerKeys.public,
|
||||
nsec: ownerKeys.nsec,
|
||||
prefs: prefs,
|
||||
relaySession: relaySession,
|
||||
signedEventRelay: null,
|
||||
relaySelfProvider: () async => relayKeys.public,
|
||||
relayAuthority: 'wss://relay.example',
|
||||
stageProjection: (projection) =>
|
||||
manager.stageWorkspaceProjection(projection),
|
||||
commitProjection: (staged) {
|
||||
renders++;
|
||||
return manager.commitWorkspaceProjection(staged);
|
||||
},
|
||||
cacheWriter: (_, _, _, _) async => false,
|
||||
);
|
||||
manager = ChannelSectionsManager(
|
||||
pubkey: ownerKeys.public,
|
||||
prefs: prefs,
|
||||
crypto: ChannelSectionsCrypto(ownerKeys.nsec, ownerKeys.public),
|
||||
relaySession: null,
|
||||
signedEventRelay: null,
|
||||
remoteEnabled: false,
|
||||
workspaceSync: sync,
|
||||
onChanged: () => renders++,
|
||||
);
|
||||
|
||||
expect(await sync.probe(), isFalse);
|
||||
expect(manager.store.sections.single.name, 'Legacy');
|
||||
expect(renders, 0);
|
||||
expect(sync.cache, isNull);
|
||||
expect(
|
||||
prefs.getString(
|
||||
channelSectionsKey(
|
||||
ownerKeys.public,
|
||||
relayAuthority: sync.relayStorageScope,
|
||||
),
|
||||
),
|
||||
jsonEncode(oldStore),
|
||||
);
|
||||
manager.dispose(flushPending: false);
|
||||
},
|
||||
);
|
||||
|
||||
test('cache persists the last verified projection and key epoch', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
Reference in New Issue
Block a user