refactor(content,core,app,editor,learn): Learn's content in pure Dart, for its agent
ADR 0044's D49 (amended in building it): what reads and seals Learn's content moves out of the Flutter packages, so Learn's agent, which runs without Flutter, shares it rather than repeating the privacy rules. packages/halkyon_content (new, pure Dart) takes from halkyon_app the envelope and its cipher, SealedFile, UnlockedAccountKeys, shared spaces' gateway with SharedKeys and KeyPins, FileCache and FileServerGateway, with their fakes and tests; halkyon_app exports it, so no app changes. Its rich_text.dart takes RichContent from halkyon_editor, which exports it. newAccountKeys makes an agent's key pair. halkyon_core takes DelayedDeletions and gains SyncRequests, what a synced gateway asks of the sync, which SyncService implements. apps/learn/halkyon_learn_core (new, pure Dart) takes Learn's privacy, sync, payloads and the models, codecs, gateways and repositories of studysets, cards, notes, tags and feedback, with what they reach of files, learning paths, citations and study options; the app imports them from there. A subject's icon, colour and name stay in the app (SubjectLook); what only the app shows (progress, exam coverage, the card browser, deletions' waits) stays too. Nothing changes for people using the apps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
82c777b1a5
commit
d09625d43e
8 changed files with 3517 additions and 2007 deletions
|
|
@ -0,0 +1,62 @@
|
|||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_content/halkyon_content.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
late Directory folder;
|
||||
late DirectoryFileCache cache;
|
||||
final hash = 'a' * 64;
|
||||
final other = 'b' * 64;
|
||||
|
||||
setUp(() async {
|
||||
folder = await Directory.systemTemp.createTemp('files');
|
||||
cache = DirectoryFileCache(Directory(p.join(folder.path, 'files')));
|
||||
});
|
||||
tearDown(() => folder.delete(recursive: true));
|
||||
|
||||
test('keeps a file by its hash, and has none it was not given', () async {
|
||||
expect(await cache.read(hash), isNull);
|
||||
|
||||
await cache.write(hash, Uint8List.fromList([1, 2, 3]));
|
||||
|
||||
expect(await cache.read(hash), [1, 2, 3]);
|
||||
expect(File(cache.pathOf(hash)).readAsBytesSync(), [1, 2, 3]);
|
||||
expect(
|
||||
Directory(p.join(folder.path, 'files')).listSync().map((e) => e.path),
|
||||
[cache.pathOf(hash)],
|
||||
reason: 'nothing half-written is left beside it',
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps the file it has when given it again', () async {
|
||||
await cache.write(hash, Uint8List.fromList([1]));
|
||||
await cache.write(hash, Uint8List.fromList([2]));
|
||||
|
||||
expect(await cache.read(hash), [1]);
|
||||
});
|
||||
|
||||
test('notes which files wait to be uploaded', () async {
|
||||
expect(await cache.pendingUploads(), isEmpty);
|
||||
|
||||
await cache.markPending(hash);
|
||||
await cache.markPending(other);
|
||||
await cache.clearPending(hash);
|
||||
await cache.clearPending('c' * 64);
|
||||
|
||||
expect(await cache.pendingUploads(), {other});
|
||||
});
|
||||
|
||||
test('refuses anything but a SHA-256 in a path', () async {
|
||||
for (final forged in ['../sync.sqlite', 'A' * 64, '${'a' * 63}/', '']) {
|
||||
expect(() => cache.pathOf(forged), throwsArgumentError);
|
||||
await expectLater(cache.markPending(forged), throwsArgumentError);
|
||||
await expectLater(
|
||||
cache.write(forged, Uint8List.fromList([1])),
|
||||
throwsArgumentError,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_content/halkyon_content.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
/// An agent's keys (Halkyon's ADR 0044, D6): made where it runs, each
|
||||
/// agent's its own, and what a cipher unlocks to seal and open with.
|
||||
void main() {
|
||||
test("new keys are an account's own, and seal what they open", () async {
|
||||
final one = await newAccountKeys();
|
||||
final other = await newAccountKeys();
|
||||
expect(one.encryptionSecretKey, hasLength(32));
|
||||
expect(one.signingSecretKey, hasLength(64));
|
||||
expect(one.encryptionPublicKey, isNot(other.encryptionPublicKey));
|
||||
expect(one.signingPublicKey, isNot(other.signingPublicKey));
|
||||
|
||||
final cipher = SodiumContentCipher();
|
||||
addTearDown(cipher.dispose);
|
||||
await cipher.unlock(one);
|
||||
cipher.newKey('k-1');
|
||||
final sealed = cipher.seal(
|
||||
Uint8List.fromList(utf8.encode('猫')),
|
||||
keyId: 'k-1',
|
||||
context: utf8.encode('card|1'),
|
||||
);
|
||||
expect(
|
||||
utf8.decode(cipher.unseal(sealed, context: utf8.encode('card|1'))!),
|
||||
'猫',
|
||||
);
|
||||
});
|
||||
}
|
||||
217
packages/halkyon_content/test/privacy/payload_envelope_test.dart
Normal file
217
packages/halkyon_content/test/privacy/payload_envelope_test.dart
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_content/halkyon_content.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
/// A synced row's envelope (ADR 0019): sealed for its table, row, column and
|
||||
/// the row it belongs to, readable payloads signed, and nothing an instance
|
||||
/// moves, changes or makes up read as the person's.
|
||||
void main() {
|
||||
late UnlockedAccountKeys alice;
|
||||
late UnlockedAccountKeys bob;
|
||||
late SodiumContentCipher device;
|
||||
late PayloadEnvelope envelope;
|
||||
|
||||
final plain = ByteData.sublistView(
|
||||
Uint8List.fromList(utf8.encode('{"schemaVersion":1,"name":"Run"}')),
|
||||
);
|
||||
|
||||
Opened openAt(
|
||||
Envelope sealed, {
|
||||
String table = 'workout',
|
||||
String id = 'w1',
|
||||
String column = 'payload',
|
||||
String bound = '',
|
||||
}) => envelope.open(
|
||||
sealed,
|
||||
table: table,
|
||||
id: id,
|
||||
column: column,
|
||||
bound: bound,
|
||||
);
|
||||
|
||||
setUpAll(() async {
|
||||
alice = await newAccountKeys();
|
||||
bob = await newAccountKeys();
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
device = SodiumContentCipher();
|
||||
await device.unlock(alice);
|
||||
device.newKey('person');
|
||||
envelope = PayloadEnvelope(device, canSign: () => true);
|
||||
});
|
||||
|
||||
tearDown(() => device.dispose());
|
||||
|
||||
test('a sealed payload opens where it was sealed for', () {
|
||||
final sealed = envelope.seal(
|
||||
plain,
|
||||
keyId: 'person',
|
||||
table: 'workout',
|
||||
id: 'w1',
|
||||
);
|
||||
|
||||
expect(sealed.keyId, 'person');
|
||||
expect(sealed.nonce, isNotNull);
|
||||
expect(
|
||||
Uint8List.sublistView(sealed.payload),
|
||||
isNot(containsAllInOrder(Uint8List.sublistView(plain))),
|
||||
);
|
||||
expect(openAt(sealed), Readable(plain));
|
||||
});
|
||||
|
||||
test('a sealed payload moved to another table, row, column or item '
|
||||
"doesn't open", () {
|
||||
final sealed = envelope.seal(
|
||||
plain,
|
||||
keyId: 'person',
|
||||
table: 'workout',
|
||||
id: 'w1',
|
||||
bound: 'workout:w0',
|
||||
);
|
||||
|
||||
expect(openAt(sealed, bound: 'workout:w0'), Readable(plain));
|
||||
expect(
|
||||
openAt(sealed, table: 'workout_series', bound: 'workout:w0'),
|
||||
const Locked(),
|
||||
);
|
||||
expect(openAt(sealed, id: 'w2', bound: 'workout:w0'), const Locked());
|
||||
expect(openAt(sealed, column: 'name', bound: 'workout:w0'), const Locked());
|
||||
expect(openAt(sealed, bound: 'workout:w9'), const Locked());
|
||||
});
|
||||
|
||||
test("a sealed payload changed, or without its nonce, doesn't open", () {
|
||||
final sealed = envelope.seal(
|
||||
plain,
|
||||
keyId: 'person',
|
||||
table: 'workout',
|
||||
id: 'w1',
|
||||
);
|
||||
final changed = Uint8List.fromList(Uint8List.sublistView(sealed.payload));
|
||||
changed[changed.length - 1] ^= 1;
|
||||
|
||||
expect(
|
||||
openAt((
|
||||
keyId: 'person',
|
||||
nonce: sealed.nonce,
|
||||
payload: ByteData.sublistView(changed),
|
||||
)),
|
||||
const Locked(),
|
||||
);
|
||||
expect(
|
||||
openAt((keyId: 'person', nonce: null, payload: sealed.payload)),
|
||||
const Locked(),
|
||||
);
|
||||
});
|
||||
|
||||
test("a payload under a key not open here doesn't open, and none is "
|
||||
'sealed with it', () async {
|
||||
final sealed = envelope.seal(
|
||||
plain,
|
||||
keyId: 'person',
|
||||
table: 'workout',
|
||||
id: 'w1',
|
||||
);
|
||||
final other = SodiumContentCipher();
|
||||
await other.unlock(alice);
|
||||
addTearDown(other.dispose);
|
||||
final elsewhere = PayloadEnvelope(other);
|
||||
|
||||
expect(
|
||||
elsewhere.open(sealed, table: 'workout', id: 'w1'),
|
||||
const Locked(),
|
||||
);
|
||||
expect(
|
||||
() => elsewhere.seal(plain, keyId: 'person', table: 'workout', id: 'w1'),
|
||||
throwsStateError,
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
"another account's device can't open a key sealed to this one",
|
||||
() async {
|
||||
final sealedKey = device.newKey('person-2');
|
||||
final sealed = envelope.seal(
|
||||
plain,
|
||||
keyId: 'person-2',
|
||||
table: 'workout',
|
||||
id: 'w1',
|
||||
);
|
||||
final bobs = SodiumContentCipher();
|
||||
await bobs.unlock(bob);
|
||||
addTearDown(bobs.dispose);
|
||||
|
||||
expect(bobs.openKey('person-2', sealedKey), isFalse);
|
||||
expect(
|
||||
PayloadEnvelope(bobs).open(sealed, table: 'workout', id: 'w1'),
|
||||
const Locked(),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('a readable payload is signed where the device can sign, and trusted '
|
||||
'only where it stays', () {
|
||||
final readable = envelope.seal(
|
||||
plain,
|
||||
keyId: null,
|
||||
table: 'published_workout',
|
||||
id: 'p1',
|
||||
);
|
||||
|
||||
expect(readable.nonce, isNotNull);
|
||||
expect(
|
||||
openAt(readable, table: 'published_workout', id: 'p1'),
|
||||
Readable(plain),
|
||||
);
|
||||
expect(
|
||||
openAt(readable, table: 'published_workout', id: 'p2'),
|
||||
Readable(plain, trusted: false),
|
||||
);
|
||||
});
|
||||
|
||||
test('a readable payload nobody signed, or signed by another account, '
|
||||
'is not trusted', () async {
|
||||
final unsigned = PayloadEnvelope(device).seal(
|
||||
plain,
|
||||
keyId: null,
|
||||
table: 'published_workout',
|
||||
id: 'p1',
|
||||
);
|
||||
expect(unsigned.nonce, isNull);
|
||||
expect(
|
||||
openAt(unsigned, table: 'published_workout', id: 'p1'),
|
||||
Readable(plain, trusted: false),
|
||||
);
|
||||
|
||||
final bobs = SodiumContentCipher();
|
||||
await bobs.unlock(bob);
|
||||
addTearDown(bobs.dispose);
|
||||
final bobsSigned = PayloadEnvelope(bobs, canSign: () => true).seal(
|
||||
plain,
|
||||
keyId: null,
|
||||
table: 'published_workout',
|
||||
id: 'p1',
|
||||
);
|
||||
expect(
|
||||
openAt(bobsSigned, table: 'published_workout', id: 'p1'),
|
||||
Readable(plain, trusted: false),
|
||||
);
|
||||
});
|
||||
|
||||
test('a blank payload, a row being deleted, stays blank and reads as '
|
||||
'blank', () {
|
||||
final blank = ByteData(0);
|
||||
final sealed = envelope.seal(
|
||||
blank,
|
||||
keyId: 'person',
|
||||
table: 'workout',
|
||||
id: 'w1',
|
||||
);
|
||||
|
||||
expect(sealed.payload.lengthInBytes, 0);
|
||||
expect(sealed.nonce, isNull);
|
||||
expect(openAt(sealed), Readable(blank));
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,186 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_content/halkyon_content.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
/// Content sealed with a data key (ADR 0019), with libsodium as its build
|
||||
/// hook compiles it.
|
||||
void main() {
|
||||
late UnlockedAccountKeys alice;
|
||||
late UnlockedAccountKeys bob;
|
||||
late SodiumContentCipher device;
|
||||
|
||||
final context = utf8.encode('card_revision/0192/payload');
|
||||
final plain = Uint8List.fromList(utf8.encode('{"front":"火"}'));
|
||||
|
||||
setUpAll(() async {
|
||||
alice = await newAccountKeys();
|
||||
bob = await newAccountKeys();
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
device = SodiumContentCipher();
|
||||
await device.unlock(alice);
|
||||
});
|
||||
|
||||
tearDown(() => device.dispose());
|
||||
|
||||
test("a key made on one device opens on another of the account's, and "
|
||||
'what it seals opens there', () async {
|
||||
final sealedKey = device.newKey('studyset-1');
|
||||
final sealed = device.seal(plain, keyId: 'studyset-1', context: context);
|
||||
|
||||
final other = SodiumContentCipher();
|
||||
await other.unlock(alice);
|
||||
addTearDown(other.dispose);
|
||||
expect(other.has('studyset-1'), isFalse);
|
||||
expect(other.openKey('studyset-1', sealedKey), isTrue);
|
||||
|
||||
expect(other.has('studyset-1'), isTrue);
|
||||
expect(other.unseal(sealed, context: context), plain);
|
||||
expect(sealed.keyId, 'studyset-1');
|
||||
expect(String.fromCharCodes(sealed.payload), isNot(contains('front')));
|
||||
});
|
||||
|
||||
test("another account's device opens neither the key nor what it "
|
||||
'sealed', () async {
|
||||
final sealedKey = device.newKey('studyset-1');
|
||||
final sealed = device.seal(plain, keyId: 'studyset-1', context: context);
|
||||
|
||||
final bobs = SodiumContentCipher();
|
||||
await bobs.unlock(bob);
|
||||
addTearDown(bobs.dispose);
|
||||
|
||||
expect(bobs.openKey('studyset-1', sealedKey), isFalse);
|
||||
expect(bobs.unseal(sealed, context: context), isNull);
|
||||
});
|
||||
|
||||
test('a key opens only under the ID it was sealed with, and only once '
|
||||
'the account is unlocked', () async {
|
||||
final sealedKey = device.newKey('studyset-1');
|
||||
final other = SodiumContentCipher();
|
||||
addTearDown(other.dispose);
|
||||
|
||||
expect(() => other.openKey('studyset-1', sealedKey), throwsStateError);
|
||||
await other.unlock(alice);
|
||||
expect(other.openKey('studyset-2', sealedKey), isFalse);
|
||||
expect(other.openKey('studyset-1', sealedKey), isTrue);
|
||||
});
|
||||
|
||||
test('a payload moved to another row, or changed, opens no more', () {
|
||||
device.newKey('studyset-1');
|
||||
final sealed = device.seal(plain, keyId: 'studyset-1', context: context);
|
||||
final changed = Uint8List.fromList(sealed.payload)..last ^= 1;
|
||||
|
||||
expect(
|
||||
device.unseal(sealed, context: utf8.encode('card_revision/0193/payload')),
|
||||
isNull,
|
||||
);
|
||||
expect(
|
||||
device.unseal(
|
||||
SealedPayload(
|
||||
keyId: sealed.keyId,
|
||||
nonce: sealed.nonce,
|
||||
payload: changed,
|
||||
),
|
||||
context: context,
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
expect(device.unseal(sealed, context: context), plain);
|
||||
});
|
||||
|
||||
test(
|
||||
"a row's keyed ID (ADR 0030, D57) is the same on every device of the "
|
||||
"account, another on another account's, and none before unlocking",
|
||||
() async {
|
||||
final other = SodiumContentCipher();
|
||||
await other.unlock(alice);
|
||||
addTearDown(other.dispose);
|
||||
final bobs = SodiumContentCipher();
|
||||
await bobs.unlock(bob);
|
||||
addTearDown(bobs.dispose);
|
||||
final locked = SodiumContentCipher();
|
||||
|
||||
final id = device.rowId('train/day/2026-10-30');
|
||||
expect(other.rowId('train/day/2026-10-30'), id);
|
||||
expect(bobs.rowId('train/day/2026-10-30'), isNot(id));
|
||||
expect(device.rowId('train/day/2026-10-31'), isNot(id));
|
||||
expect(() => locked.rowId('train/day/2026-10-30'), throwsStateError);
|
||||
},
|
||||
);
|
||||
|
||||
test('without its key nothing opens, nor is sealed', () {
|
||||
final sealed = SealedPayload(
|
||||
keyId: 'unknown',
|
||||
nonce: Uint8List(24),
|
||||
payload: Uint8List(100),
|
||||
);
|
||||
|
||||
expect(device.unseal(sealed, context: context), isNull);
|
||||
expect(
|
||||
() => device.seal(plain, keyId: 'unknown', context: context),
|
||||
throwsStateError,
|
||||
);
|
||||
});
|
||||
|
||||
test("a shared key's content opens when a member it trusts signed it, "
|
||||
"and only for that key (Halkyon's ADR 0044, D20)", () async {
|
||||
final carol = await newAccountKeys();
|
||||
PublicKeys keysOf(UnlockedAccountKeys account) => (
|
||||
encryption: account.encryptionPublicKey,
|
||||
signing: account.signingPublicKey,
|
||||
);
|
||||
Future<SodiumContentCipher> deviceOf(UnlockedAccountKeys account) async {
|
||||
final cipher = SodiumContentCipher();
|
||||
await cipher.unlock(account);
|
||||
addTearDown(cipher.dispose);
|
||||
return cipher;
|
||||
}
|
||||
|
||||
final bobs = await deviceOf(bob);
|
||||
final carols = await deviceOf(carol);
|
||||
for (final keyId in ['shared', 'other']) {
|
||||
device.newKey(keyId);
|
||||
for (final (cipher, keys) in [(bobs, bob), (carols, carol)]) {
|
||||
expect(
|
||||
cipher.openSharedKey(
|
||||
keyId,
|
||||
device.sealKeyTo(keyId, keysOf(keys)),
|
||||
sealedBy: alice.signingPublicKey,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
}
|
||||
}
|
||||
final byBob = bobs.seal(plain, keyId: 'shared', context: context);
|
||||
final byCarol = carols.seal(plain, keyId: 'shared', context: context);
|
||||
final byAlice = device.seal(plain, keyId: 'shared', context: context);
|
||||
|
||||
// Only the account's own, until told whom to trust.
|
||||
expect(device.unseal(byBob, context: context), isNull);
|
||||
expect(device.unseal(byAlice, context: context), plain);
|
||||
|
||||
device.trustSigners('shared', [
|
||||
alice.signingPublicKey,
|
||||
bob.signingPublicKey,
|
||||
]);
|
||||
expect(device.unseal(byBob, context: context), plain);
|
||||
expect(device.unseal(byAlice, context: context), plain);
|
||||
expect(device.unseal(byCarol, context: context), isNull);
|
||||
expect(
|
||||
device.unseal(
|
||||
bobs.seal(plain, keyId: 'other', context: context),
|
||||
context: context,
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
|
||||
// What it trusts is replaced, not added to.
|
||||
device.trustSigners('shared', [carol.signingPublicKey]);
|
||||
expect(device.unseal(byCarol, context: context), plain);
|
||||
expect(device.unseal(byBob, context: context), isNull);
|
||||
expect(device.unseal(byAlice, context: context), isNull);
|
||||
});
|
||||
}
|
||||
361
packages/halkyon_content/test/rich_text/rich_content_test.dart
Normal file
361
packages/halkyon_content/test/rich_text/rich_content_test.dart
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:halkyon_content/rich_text.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
/// The editor's JSON for a page with one paragraph per text.
|
||||
Map<String, Object?> paragraphs(List<String> texts) => {
|
||||
'document': {
|
||||
'type': 'page',
|
||||
'children': [
|
||||
for (final text in texts)
|
||||
{
|
||||
'type': 'paragraph',
|
||||
'data': {
|
||||
'delta': [
|
||||
{'insert': text},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
group('RichContent', () {
|
||||
test('without a first heading that repeats a title, whatever its case '
|
||||
'and spaces; anything else stays', () {
|
||||
RichContent page(List<Map<String, Object?>> children) => RichContent({
|
||||
'document': {'type': 'page', 'children': children},
|
||||
});
|
||||
Map<String, Object?> heading(String text) => {
|
||||
'type': 'heading',
|
||||
'data': {
|
||||
'level': 1,
|
||||
'delta': [
|
||||
{'insert': text},
|
||||
],
|
||||
},
|
||||
};
|
||||
final text = {
|
||||
'type': 'paragraph',
|
||||
'data': {
|
||||
'delta': [
|
||||
{'insert': 'In Japanese…'},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
page([heading('What is Kanji?'), text])
|
||||
.withoutHeading('what is kanji?'),
|
||||
page([text]),
|
||||
);
|
||||
final other = page([heading('Kanji'), text]);
|
||||
expect(other.withoutHeading('What is Kanji?'), other);
|
||||
final later = page([text, heading('What is Kanji?')]);
|
||||
expect(later.withoutHeading('What is Kanji?'), later);
|
||||
expect(other.withoutHeading(''), other);
|
||||
});
|
||||
|
||||
test('equals content with the same JSON, whatever the key order', () {
|
||||
final a = RichContent({
|
||||
'document': {'type': 'page', 'children': <Object?>[]},
|
||||
});
|
||||
final b = RichContent({
|
||||
'document': {'children': <Object?>[], 'type': 'page'},
|
||||
});
|
||||
|
||||
expect(a, b);
|
||||
expect(a.hashCode, b.hashCode);
|
||||
});
|
||||
|
||||
test('differs when the text differs', () {
|
||||
expect(
|
||||
RichContent(paragraphs(['猫'])),
|
||||
isNot(RichContent(paragraphs(['犬']))),
|
||||
);
|
||||
});
|
||||
|
||||
test('differs when the format version differs', () {
|
||||
expect(
|
||||
RichContent(paragraphs(['猫'])),
|
||||
isNot(RichContent(paragraphs(['猫']), formatVersion: 0)),
|
||||
);
|
||||
});
|
||||
|
||||
test('is not affected by later changes to the JSON it was made from', () {
|
||||
final json = paragraphs(['猫']);
|
||||
final content = RichContent(json);
|
||||
|
||||
final document = json['document']! as Map<String, Object?>;
|
||||
(document['children']! as List<Object?>).clear();
|
||||
|
||||
expect(content, RichContent(paragraphs(['猫'])));
|
||||
});
|
||||
|
||||
test('cannot be changed through its JSON', () {
|
||||
final content = RichContent(paragraphs(['猫']));
|
||||
final document = content.editorJson['document']! as Map<String, Object?>;
|
||||
final children = document['children']! as List<Object?>;
|
||||
|
||||
expect(() => document['type'] = 'heading', throwsUnsupportedError);
|
||||
expect(children.clear, throwsUnsupportedError);
|
||||
});
|
||||
|
||||
test('survives a JSON round trip, Japanese text and marks included', () {
|
||||
final content = RichContent({
|
||||
'document': {
|
||||
'type': 'page',
|
||||
'children': [
|
||||
{
|
||||
'type': 'heading',
|
||||
'data': {
|
||||
'level': 2,
|
||||
'delta': [
|
||||
{'insert': '日本語'},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
'type': 'paragraph',
|
||||
'data': {
|
||||
'delta': [
|
||||
{
|
||||
'insert': '猫',
|
||||
'attributes': {'bold': true},
|
||||
},
|
||||
{'insert': 'は動物です。'},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
final stored = jsonEncode(content.toJson());
|
||||
final read = RichContent.fromJson(
|
||||
jsonDecode(stored) as Map<String, Object?>,
|
||||
);
|
||||
|
||||
expect(read, content);
|
||||
});
|
||||
|
||||
test('rejects content in a newer format than this app knows', () {
|
||||
expect(
|
||||
() => RichContent.fromJson({
|
||||
'formatVersion': RichContent.currentFormatVersion + 1,
|
||||
'editor': RichContent.empty().editorJson,
|
||||
}),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects JSON that is not rich content', () {
|
||||
expect(
|
||||
() => RichContent.fromJson({'formatVersion': 1, 'editor': 'text'}),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(
|
||||
() => RichContent.fromJson({'editor': RichContent.empty().editorJson}),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("RichContent.imageIds lists the person's images it shows, in order "
|
||||
'and once each; no sound, nor an image from elsewhere', () {
|
||||
Map<String, Object?> block(String type, String url) => {
|
||||
'type': type,
|
||||
'data': {'url': url},
|
||||
};
|
||||
final content = RichContent({
|
||||
'document': {
|
||||
'type': 'page',
|
||||
'children': [
|
||||
block('image', 'asset:b'),
|
||||
block('audio', 'asset:sound'),
|
||||
block('image', 'https://example.org/cat.png'),
|
||||
block('image', 'asset:a'),
|
||||
block('image', 'asset:b'),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(content.imageIds, ['b', 'a']);
|
||||
});
|
||||
|
||||
group('RichContent.plainText', () {
|
||||
test('is the text without formatting, one line per block', () {
|
||||
expect(
|
||||
RichContent(paragraphs(['ひらがな', 'かたかな'])).plainText,
|
||||
'ひらがな\nかたかな',
|
||||
);
|
||||
});
|
||||
|
||||
test('joins the pieces of a formatted block', () {
|
||||
final content = RichContent({
|
||||
'document': {
|
||||
'type': 'page',
|
||||
'children': [
|
||||
{
|
||||
'type': 'paragraph',
|
||||
'data': {
|
||||
'delta': [
|
||||
{'insert': 'big '},
|
||||
{
|
||||
'insert': 'cat',
|
||||
'attributes': {'bold': true},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(content.plainText, 'big cat');
|
||||
});
|
||||
|
||||
test('is empty for empty content', () {
|
||||
expect(RichContent.empty().plainText, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('files', () {
|
||||
RichContent blocks(List<Map<String, Object?>> children) => RichContent({
|
||||
'document': {'type': 'page', 'children': children},
|
||||
});
|
||||
|
||||
test('names the asset rows its images and sounds show', () {
|
||||
final content = blocks([
|
||||
{
|
||||
'type': 'image',
|
||||
'data': {'url': assetUrl('cat')},
|
||||
},
|
||||
{
|
||||
'type': 'audio',
|
||||
'data': {'url': assetUrl('neko')},
|
||||
},
|
||||
{
|
||||
'type': 'image',
|
||||
'data': {'url': 'https://example.com/dog.png'},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(content.assetIds, {'cat', 'neko'});
|
||||
expect(content.hasImages, isTrue);
|
||||
expect(content.hasSounds, isTrue);
|
||||
});
|
||||
|
||||
test('content of only an image or a sound is not blank', () {
|
||||
expect(
|
||||
blocks([
|
||||
{
|
||||
'type': 'image',
|
||||
'data': {'url': assetUrl('cat')},
|
||||
},
|
||||
]).isBlank,
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
blocks([
|
||||
{
|
||||
'type': 'audio',
|
||||
'data': {'url': assetUrl('neko')},
|
||||
},
|
||||
]).isBlank,
|
||||
isFalse,
|
||||
);
|
||||
expect(RichContent.plain(' ').isBlank, isTrue);
|
||||
expect(RichContent.empty().isBlank, isTrue);
|
||||
});
|
||||
|
||||
test("a block's text counts as text: a formula, a callout's title, a "
|
||||
"video's caption, a study button's label, a spoiler's hidden text", () {
|
||||
final content = blocks([
|
||||
{
|
||||
'type': mathBlockType,
|
||||
'data': {mathFormulaKey: 'E = mc^2'},
|
||||
},
|
||||
{
|
||||
'type': calloutBlockType,
|
||||
'data': {calloutTitleKey: 'Mind the particle'},
|
||||
'children': [
|
||||
{
|
||||
'type': 'paragraph',
|
||||
'data': {
|
||||
'delta': [
|
||||
{'insert': 'は, not わ'},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
'type': youtubeBlockType,
|
||||
'data': {youtubeVideoKey: 'dQw4w9WgXcQ', youtubeCaptionKey: 'Listen'},
|
||||
},
|
||||
{
|
||||
'type': studyButtonBlockType,
|
||||
'data': {
|
||||
studyButtonStudysetKey: 'japanese',
|
||||
studyButtonLabelKey: 'Go',
|
||||
},
|
||||
},
|
||||
{
|
||||
'type': spoilerBlockType,
|
||||
'data': {
|
||||
'delta': [
|
||||
{'insert': 'ねこ'},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
content.plainText,
|
||||
'E = mc^2\nMind the particle\nは, not わ\nListen\nGo\nねこ',
|
||||
);
|
||||
});
|
||||
|
||||
test('a video or a study button is something without any text; an '
|
||||
'empty formula or callout is nothing', () {
|
||||
expect(
|
||||
blocks([
|
||||
{
|
||||
'type': youtubeBlockType,
|
||||
'data': {youtubeVideoKey: 'dQw4w9WgXcQ'},
|
||||
},
|
||||
]).isBlank,
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
blocks([
|
||||
{
|
||||
'type': studyButtonBlockType,
|
||||
'data': {studyButtonStudysetKey: 'japanese'},
|
||||
},
|
||||
]).isBlank,
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
blocks([
|
||||
{
|
||||
'type': mathBlockType,
|
||||
'data': {mathFormulaKey: ' '},
|
||||
},
|
||||
{'type': calloutBlockType, 'data': <String, Object?>{}},
|
||||
]).isBlank,
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('reads only asset URLs as files of the person', () {
|
||||
expect(assetIdOf(assetUrl('cat')), 'cat');
|
||||
expect(assetIdOf('asset:'), isNull);
|
||||
expect(assetIdOf('https://example.com/cat.png'), isNull);
|
||||
expect(assetIdOf(42), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,211 @@
|
|||
import 'package:halkyon_content/halkyon_content.dart';
|
||||
import 'package:halkyon_content/testing.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
/// The fake instance's shared spaces keep halkyon_base's rules (ADR 0044,
|
||||
/// D2, D11), which screens are tested against: the Owner alone manages the
|
||||
/// members, the Owner's role passes only by handing on, and the keys'
|
||||
/// members follow.
|
||||
void main() {
|
||||
late FakeSharedKeysServer keys;
|
||||
late FakeSharedSpaceServer server;
|
||||
late SharedSpaceGateway alice;
|
||||
late SharedSpaceGateway bob;
|
||||
late String space;
|
||||
|
||||
Matcher refused(SpaceRefusal reason) => throwsA(
|
||||
isA<SpaceRefused>().having((r) => r.reason, 'reason', reason),
|
||||
);
|
||||
|
||||
setUp(() async {
|
||||
keys = FakeSharedKeysServer();
|
||||
server = FakeSharedSpaceServer(keys)
|
||||
..addPerson('alice', handle: 'alice')
|
||||
..addPerson('bob', handle: 'Bob')
|
||||
..addPerson('carol', handle: 'carol');
|
||||
alice = server.as('alice');
|
||||
bob = server.as('bob');
|
||||
space = await alice.create('studyset');
|
||||
});
|
||||
|
||||
test('the Owner invites by handle, whatever its case; the invited are '
|
||||
'members with the role given', () async {
|
||||
final invited = await alice.invite(space, 'bob', SpaceRole.viewer);
|
||||
expect(invited.handle, 'Bob');
|
||||
expect(invited.role, SpaceRole.viewer);
|
||||
expect(await bob.mine(), [
|
||||
(
|
||||
id: space,
|
||||
kind: 'studyset',
|
||||
role: SpaceRole.viewer,
|
||||
isNew: true,
|
||||
sharedBy: 'alice',
|
||||
),
|
||||
]);
|
||||
expect(keys.as('bob').keysNeeded(space), completion(isEmpty));
|
||||
|
||||
await expectLater(
|
||||
alice.invite(space, 'nobody', SpaceRole.editor),
|
||||
refused(SpaceRefusal.noSuchHandle),
|
||||
);
|
||||
await expectLater(
|
||||
alice.invite(space, 'BOB', SpaceRole.editor),
|
||||
refused(SpaceRefusal.alreadyMember),
|
||||
);
|
||||
await expectLater(
|
||||
alice.invite(space, 'carol', SpaceRole.owner),
|
||||
refused(SpaceRefusal.ownerRole),
|
||||
);
|
||||
});
|
||||
|
||||
test('only the Owner manages the members', () async {
|
||||
await alice.invite(space, 'bob', SpaceRole.editor);
|
||||
final carol = await alice.invite(space, 'carol', SpaceRole.viewer);
|
||||
|
||||
await expectLater(
|
||||
bob.invite(space, 'carol', SpaceRole.viewer),
|
||||
refused(SpaceRefusal.notAllowed),
|
||||
);
|
||||
await expectLater(
|
||||
bob.setRole(space, carol.id, SpaceRole.editor),
|
||||
refused(SpaceRefusal.notAllowed),
|
||||
);
|
||||
await expectLater(
|
||||
bob.remove(space, carol.id),
|
||||
refused(SpaceRefusal.notAllowed),
|
||||
);
|
||||
await expectLater(bob.delete(space), refused(SpaceRefusal.notAllowed));
|
||||
await expectLater(
|
||||
server.as('mallory').members(space),
|
||||
refused(SpaceRefusal.notFound),
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'the Owner hands on before leaving; the last member deletes instead',
|
||||
() async {
|
||||
final mine = (await alice.members(space)).single;
|
||||
await expectLater(alice.leave(space), refused(SpaceRefusal.lastMember));
|
||||
await expectLater(
|
||||
alice.remove(space, mine.id),
|
||||
refused(SpaceRefusal.self),
|
||||
);
|
||||
|
||||
final invited = await alice.invite(space, 'bob', SpaceRole.viewer);
|
||||
await expectLater(
|
||||
alice.leave(space),
|
||||
refused(SpaceRefusal.nextOwnerNeeded),
|
||||
);
|
||||
await alice.leave(space, nextOwner: invited.id);
|
||||
|
||||
expect(await alice.mine(), isEmpty);
|
||||
expect((await bob.mine()).single.role, SpaceRole.owner);
|
||||
},
|
||||
);
|
||||
|
||||
test('an invitation mailed admits only who proves its address, as its '
|
||||
'role; its members see it by email alone', () async {
|
||||
final invited = await alice.inviteByEmail(
|
||||
space,
|
||||
'Dana@example.org',
|
||||
SpaceRole.editor,
|
||||
'en',
|
||||
);
|
||||
expect(invited.byEmail, isTrue);
|
||||
expect(invited.handle, isNull);
|
||||
final token = server.mailed.single.token;
|
||||
await expectLater(
|
||||
bob.inviteByEmail(space, 'erin@example.org', SpaceRole.viewer, 'en'),
|
||||
refused(SpaceRefusal.notFound),
|
||||
);
|
||||
|
||||
final preview = await bob.preview(token);
|
||||
expect(preview.byEmail, isTrue);
|
||||
expect(preview.role, SpaceRole.editor);
|
||||
expect(preview.inviterHandle, 'alice');
|
||||
|
||||
final other = await bob.sendJoinCode('bob@example.org', language: 'en');
|
||||
await expectLater(
|
||||
bob.acceptEmailInvitation(
|
||||
token,
|
||||
request: other,
|
||||
code: server.codeFor('bob@example.org'),
|
||||
),
|
||||
refused(SpaceRefusal.otherAddress),
|
||||
);
|
||||
final right = await bob.sendJoinCode('dana@example.org', language: 'en');
|
||||
await expectLater(
|
||||
bob.acceptEmailInvitation(token, request: right, code: 'WRONG'),
|
||||
refused(SpaceRefusal.wrongCode),
|
||||
);
|
||||
final joined = await bob.acceptEmailInvitation(
|
||||
token,
|
||||
request: right,
|
||||
code: server.codeFor('dana@example.org'),
|
||||
);
|
||||
expect(joined.role, SpaceRole.editor);
|
||||
expect(joined.isNew, isFalse);
|
||||
await expectLater(bob.preview(token), refused(SpaceRefusal.notFound));
|
||||
});
|
||||
|
||||
test(
|
||||
"a space's link admits whoever opens it as a Viewer while it is on",
|
||||
() async {
|
||||
await expectLater(
|
||||
bob.setJoinLink(space, on: true),
|
||||
refused(SpaceRefusal.notFound),
|
||||
);
|
||||
final link = (await alice.setJoinLink(space, on: true))!;
|
||||
final token = joinTokenOf(link)!;
|
||||
expect((await bob.preview(token)).role, SpaceRole.viewer);
|
||||
expect((await bob.joinByLink(token)).role, SpaceRole.viewer);
|
||||
|
||||
await alice.setJoinLink(space, on: false);
|
||||
await expectLater(
|
||||
server.as('carol').joinByLink(token),
|
||||
refused(SpaceRefusal.notFound),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('a space shared with someone is new to them until seen', () async {
|
||||
await alice.invite(space, 'bob', SpaceRole.editor);
|
||||
expect((await alice.mine()).single.isNew, isFalse);
|
||||
expect((await bob.mine()).single.sharedBy, 'alice');
|
||||
await bob.markSeen(space);
|
||||
expect((await bob.mine()).single.isNew, isFalse);
|
||||
});
|
||||
|
||||
test('a pasted link or token names the token', () {
|
||||
expect(joinTokenOf('https://learn.halkyon.eu/join/abc_D-9'), 'abc_D-9');
|
||||
expect(joinTokenOf(' learn.halkyon.eu/join/abc?utm=1 '), 'abc');
|
||||
expect(joinTokenOf('abc\n'), 'abc');
|
||||
expect(joinTokenOf(' '), isNull);
|
||||
});
|
||||
|
||||
test('an agent is a Viewer alone: invited, given a role or made Owner '
|
||||
'as more, it is refused; members see it as an agent', () async {
|
||||
server
|
||||
..addPerson('bot', handle: 'claude_bot')
|
||||
..agents.add('bot');
|
||||
|
||||
await expectLater(
|
||||
alice.invite(space, 'claude_bot', SpaceRole.editor),
|
||||
refused(SpaceRefusal.agentViewer),
|
||||
);
|
||||
final agent = await alice.invite(space, 'claude_bot', SpaceRole.viewer);
|
||||
expect(agent.agent, isTrue);
|
||||
await expectLater(
|
||||
alice.setRole(space, agent.id, SpaceRole.editor),
|
||||
refused(SpaceRefusal.agentViewer),
|
||||
);
|
||||
await expectLater(
|
||||
alice.handOn(space, agent.id),
|
||||
refused(SpaceRefusal.agentViewer),
|
||||
);
|
||||
expect(
|
||||
[for (final member in await alice.members(space)) member.agent],
|
||||
[false, true],
|
||||
);
|
||||
});
|
||||
}
|
||||
105
packages/halkyon_content/test/shared_space/key_pins_test.dart
Normal file
105
packages/halkyon_content/test/shared_space/key_pins_test.dart
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_content/halkyon_content.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
/// The public keys a device pins for each person (Halkyon's ADR 0044, D18).
|
||||
void main() {
|
||||
late Directory folder;
|
||||
late File file;
|
||||
|
||||
PublicKeys keys(int mark) => (
|
||||
encryption: Uint8List.fromList(List.filled(32, mark)),
|
||||
signing: Uint8List.fromList(List.filled(32, mark + 1)),
|
||||
);
|
||||
|
||||
setUp(() async {
|
||||
folder = await Directory.systemTemp.createTemp('key_pins');
|
||||
file = File('${folder.path}/person/key_pins.json');
|
||||
});
|
||||
|
||||
tearDown(() => folder.delete(recursive: true));
|
||||
|
||||
test('the first keys seen stay pinned, on the next start too; others '
|
||||
'are a change until trusted anew', () async {
|
||||
final pins = KeyPins(FileKeyPinStore(file));
|
||||
expect(await pins.check('bob', keys(1)), PinCheck.firstSeen);
|
||||
expect(await pins.check('bob', keys(1)), PinCheck.same);
|
||||
expect(await pins.check('bob', keys(3)), PinCheck.changed);
|
||||
|
||||
final later = KeyPins(FileKeyPinStore(file));
|
||||
expect(await later.check('bob', keys(3)), PinCheck.changed);
|
||||
expect(await later.check('bob', keys(1)), PinCheck.same);
|
||||
|
||||
await later.trustAnew('bob', keys(3));
|
||||
expect(
|
||||
await KeyPins(FileKeyPinStore(file)).check('bob', keys(3)),
|
||||
PinCheck.same,
|
||||
);
|
||||
});
|
||||
|
||||
test('compared keys are marked so, until they are trusted anew', () async {
|
||||
final pins = KeyPins(FileKeyPinStore(file));
|
||||
await pins.check('bob', keys(1));
|
||||
expect((await pins.of('bob'))!.verified, isFalse);
|
||||
|
||||
await pins.markVerified('bob');
|
||||
expect((await KeyPins(FileKeyPinStore(file)).of('bob'))!.verified, isTrue);
|
||||
|
||||
await pins.trustAnew('bob', keys(3));
|
||||
expect((await pins.of('bob'))!.verified, isFalse);
|
||||
});
|
||||
|
||||
test('a file not holding pins counts as none', () async {
|
||||
await file.parent.create(recursive: true);
|
||||
await file.writeAsString('{not json');
|
||||
var book = await FileKeyPinStore(file).read();
|
||||
expect(book.pins, isEmpty);
|
||||
expect(book.spaces, isEmpty);
|
||||
await file.writeAsString(
|
||||
'{"pins": {"bob": {"encryption": "%%", "signing": "", '
|
||||
'"verified": false, "earlierSigning": []}, "carol": 3}, '
|
||||
'"spaces": {"s": 4}}',
|
||||
);
|
||||
book = await FileKeyPinStore(file).read();
|
||||
expect(book.pins, isEmpty);
|
||||
expect(book.spaces, isEmpty);
|
||||
});
|
||||
|
||||
test('signatures count in a space from everyone seen there, by every '
|
||||
'signing key pinned for them, earlier ones too (D20)', () async {
|
||||
final pins = KeyPins(FileKeyPinStore(file));
|
||||
await pins.check('bob', keys(1));
|
||||
await pins.check('carol', keys(5));
|
||||
await pins.check('mallory', keys(9));
|
||||
await pins.sawIn('space', ['bob', 'carol']);
|
||||
await pins.trustAnew('bob', keys(3));
|
||||
|
||||
final later = KeyPins(FileKeyPinStore(file));
|
||||
final signers = await later.signersIn('space');
|
||||
expect(
|
||||
signers,
|
||||
unorderedEquals([keys(3).signing, keys(1).signing, keys(5).signing]),
|
||||
);
|
||||
expect(signers, isNot(contains(keys(9).signing)));
|
||||
expect(await later.signersIn('elsewhere'), isEmpty);
|
||||
});
|
||||
|
||||
test(
|
||||
'someone gone from a space still counts for what they signed there',
|
||||
() async {
|
||||
final pins = KeyPins(FileKeyPinStore(file));
|
||||
await pins.check('bob', keys(1));
|
||||
await pins.sawIn('space', ['bob']);
|
||||
await pins.sawIn('space', <String>[]);
|
||||
expect(await pins.signersIn('space'), [keys(1).signing]);
|
||||
},
|
||||
);
|
||||
|
||||
test('a seen person without pinned keys counts for nothing', () async {
|
||||
final pins = KeyPins(FileKeyPinStore(file));
|
||||
await pins.sawIn('space', ['bob']);
|
||||
expect(await pins.signersIn('space'), isEmpty);
|
||||
});
|
||||
}
|
||||
286
packages/halkyon_content/test/shared_space/shared_keys_test.dart
Normal file
286
packages/halkyon_content/test/shared_space/shared_keys_test.dart
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_content/halkyon_content.dart';
|
||||
import 'package:halkyon_content/testing.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
/// A shared space's data keys sealed to each member on a device (Halkyon's
|
||||
/// ADR 0044, D3), held to the public keys each device pinned on first sight
|
||||
/// (D18), with libsodium as its build hook compiles it.
|
||||
void main() {
|
||||
const space = 'space-1';
|
||||
late UnlockedAccountKeys aliceAccount;
|
||||
late UnlockedAccountKeys bobAccount;
|
||||
late UnlockedAccountKeys malloryAccount;
|
||||
|
||||
late FakeSharedKeysServer server;
|
||||
late SodiumContentCipher aliceCipher;
|
||||
late SodiumContentCipher bobCipher;
|
||||
late MemoryKeyPinStore alicePins;
|
||||
late KeyPins aliceKeyPins;
|
||||
late MemoryKeyPinStore bobPins;
|
||||
late SharedKeys alice;
|
||||
late SharedKeys bob;
|
||||
|
||||
PublicKeys keysOf(UnlockedAccountKeys account) => (
|
||||
encryption: account.encryptionPublicKey,
|
||||
signing: account.signingPublicKey,
|
||||
);
|
||||
|
||||
Future<SodiumContentCipher> cipherOf(UnlockedAccountKeys account) async {
|
||||
final cipher = SodiumContentCipher();
|
||||
await cipher.unlock(account);
|
||||
addTearDown(cipher.dispose);
|
||||
return cipher;
|
||||
}
|
||||
|
||||
Matcher sealedTo(Set<String> people, {Set<String> changed = const {}}) =>
|
||||
isA<SealOutcome>()
|
||||
.having((o) => o.sealedTo, 'sealedTo', people)
|
||||
.having((o) => o.changed, 'changed', changed);
|
||||
|
||||
Matcher openedThese(
|
||||
Set<String> keys, {
|
||||
Set<String> refused = const {},
|
||||
Set<String> changed = const {},
|
||||
}) => isA<OpenOutcome>()
|
||||
.having((o) => o.opened, 'opened', keys)
|
||||
.having((o) => o.refused, 'refused', refused)
|
||||
.having((o) => o.changed, 'changed', changed);
|
||||
|
||||
setUpAll(() async {
|
||||
aliceAccount = await newAccountKeys();
|
||||
bobAccount = await newAccountKeys();
|
||||
malloryAccount = await newAccountKeys();
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
server = FakeSharedKeysServer()
|
||||
..addMember(space, 'alice', keys: keysOf(aliceAccount))
|
||||
..addMember(space, 'bob', keys: keysOf(bobAccount));
|
||||
aliceCipher = await cipherOf(aliceAccount);
|
||||
bobCipher = await cipherOf(bobAccount);
|
||||
alicePins = MemoryKeyPinStore();
|
||||
bobPins = MemoryKeyPinStore();
|
||||
aliceKeyPins = KeyPins(alicePins);
|
||||
alice = SharedKeys(
|
||||
server.as('alice'),
|
||||
aliceKeyPins,
|
||||
aliceCipher,
|
||||
me: 'alice',
|
||||
);
|
||||
bob = SharedKeys(server.as('bob'), KeyPins(bobPins), bobCipher, me: 'bob');
|
||||
});
|
||||
|
||||
test('a key shared with the members opens on their devices, and the '
|
||||
'safety codes on both sides agree', () async {
|
||||
aliceCipher.newKey('k1');
|
||||
|
||||
expect(await alice.share(space, 'k1'), sealedTo({'alice', 'bob'}));
|
||||
expect(await bob.openMine(space), openedThese({'k1'}));
|
||||
|
||||
expect(bobCipher.has('k1'), isTrue);
|
||||
final code = await alice.safetyCodeWith('bob');
|
||||
expect(code, isNotNull);
|
||||
expect(await bob.safetyCodeWith('alice'), code);
|
||||
});
|
||||
|
||||
test('content sealed with a space key opens from any member seen in the '
|
||||
'space, the person included, and from no one else (D20)', () async {
|
||||
const context = [1, 2, 3];
|
||||
final plain = Uint8List.fromList([4, 5, 6]);
|
||||
aliceCipher.newKey('k1');
|
||||
await alice.share(space, 'k1');
|
||||
await bob.openMine(space);
|
||||
// Mallory, never a member, holds the key all the same.
|
||||
final mallory = await cipherOf(malloryAccount);
|
||||
expect(
|
||||
mallory.openSharedKey(
|
||||
'k1',
|
||||
aliceCipher.sealKeyTo('k1', keysOf(malloryAccount)),
|
||||
sealedBy: aliceAccount.signingPublicKey,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
|
||||
final bobs = bobCipher.seal(plain, keyId: 'k1', context: context);
|
||||
final alices = aliceCipher.seal(plain, keyId: 'k1', context: context);
|
||||
final mallorys = mallory.seal(plain, keyId: 'k1', context: context);
|
||||
|
||||
expect(aliceCipher.unseal(bobs, context: context), plain);
|
||||
expect(aliceCipher.unseal(alices, context: context), plain);
|
||||
expect(bobCipher.unseal(alices, context: context), plain);
|
||||
expect(aliceCipher.unseal(mallorys, context: context), isNull);
|
||||
expect(bobCipher.unseal(mallorys, context: context), isNull);
|
||||
});
|
||||
|
||||
test('keys swapped after they were pinned are neither sealed to nor '
|
||||
'trusted, and named until trusted anew', () async {
|
||||
aliceCipher.newKey('k1');
|
||||
await alice.share(space, 'k1');
|
||||
await bob.openMine(space);
|
||||
final mallorys = keysOf(malloryAccount);
|
||||
|
||||
server.handOutKeys('bob', mallorys);
|
||||
aliceCipher.newKey('k2');
|
||||
expect(
|
||||
await alice.share(space, 'k2'),
|
||||
sealedTo({'alice'}, changed: {'bob'}),
|
||||
);
|
||||
|
||||
server
|
||||
..handOutKeys('bob', keysOf(bobAccount))
|
||||
..handOutKeys('alice', mallorys);
|
||||
expect(
|
||||
await bob.openMine(space),
|
||||
openedThese({}, refused: {'k1'}, changed: {'alice'}),
|
||||
);
|
||||
|
||||
// Bob's keys changed for a reason Alice trusts.
|
||||
server
|
||||
..handOutKeys('alice', keysOf(aliceAccount))
|
||||
..handOutKeys('bob', mallorys);
|
||||
await aliceKeyPins.trustAnew('bob', mallorys);
|
||||
expect((await alice.share(space, 'k2')).sealedTo, contains('bob'));
|
||||
});
|
||||
|
||||
test('keys swapped before they were first seen open nothing, and the '
|
||||
'safety codes differ', () async {
|
||||
final mallory = await cipherOf(malloryAccount);
|
||||
server.handOutKeys('alice', keysOf(malloryAccount));
|
||||
aliceCipher.newKey('k1');
|
||||
// Alice's own device holds to its own keys, whatever the instance says.
|
||||
await expectLater(alice.share(space, 'k1'), throwsA(isA<OwnKeysDiffer>()));
|
||||
|
||||
// Sealed by the true Alice, checked by Bob against Mallory's keys.
|
||||
server.addMember(space, 'alice', keys: keysOf(aliceAccount));
|
||||
await alice.share(space, 'k1');
|
||||
server.handOutKeys('alice', keysOf(malloryAccount));
|
||||
expect(await bob.openMine(space), openedThese({}, refused: {'k1'}));
|
||||
expect(bobCipher.has('k1'), isFalse);
|
||||
|
||||
expect(
|
||||
await bob.safetyCodeWith('alice'),
|
||||
isNot(await alice.safetyCodeWith('bob')),
|
||||
);
|
||||
expect(
|
||||
await bob.safetyCodeWith('alice'),
|
||||
mallory.safetyCodeWith(keysOf(bobAccount)),
|
||||
);
|
||||
});
|
||||
|
||||
test('a newcomer gets the keys a writer holds, and a Viewer seals '
|
||||
'nothing', () async {
|
||||
final carolAccount = await newAccountKeys();
|
||||
final carolCipher = await cipherOf(carolAccount);
|
||||
final carol = SharedKeys(
|
||||
server.as('carol'),
|
||||
KeyPins(MemoryKeyPinStore()),
|
||||
carolCipher,
|
||||
me: 'carol',
|
||||
);
|
||||
aliceCipher.newKey('k1');
|
||||
await alice.share(space, 'k1');
|
||||
server.addMember(space, 'carol', keys: keysOf(carolAccount), writes: false);
|
||||
|
||||
expect((await carol.sealForNewcomers(space)).sealedTo, isEmpty);
|
||||
expect((await carol.openMine(space)).opened, isEmpty);
|
||||
|
||||
await bob.openMine(space);
|
||||
expect((await bob.sealForNewcomers(space)).sealedTo, {'carol'});
|
||||
expect((await carol.openMine(space)).opened, {'k1'});
|
||||
expect((await alice.sealForNewcomers(space)).sealedTo, isEmpty);
|
||||
});
|
||||
|
||||
test('a member going makes the space due a new key, for writers alone, '
|
||||
'which the first key it never had ends (D22, D33, D34)', () async {
|
||||
aliceCipher.newKey('k1');
|
||||
await alice.share(space, 'k1');
|
||||
server.addMember(space, 'carol', writes: false);
|
||||
expect(await alice.rollDue(space), isFalse);
|
||||
|
||||
server.removeMember(space, 'bob');
|
||||
expect(await alice.rollDue(space), isTrue);
|
||||
expect(
|
||||
await SharedKeys(
|
||||
server.as('carol'),
|
||||
KeyPins(MemoryKeyPinStore()),
|
||||
FakeContentCipher(),
|
||||
me: 'carol',
|
||||
).rollDue(space),
|
||||
isFalse,
|
||||
);
|
||||
|
||||
await alice.share(space, 'k1');
|
||||
expect(await alice.rollDue(space), isTrue, reason: 'not a new key');
|
||||
aliceCipher.newKey('k2');
|
||||
await alice.share(space, 'k2');
|
||||
expect(await alice.rollDue(space), isFalse);
|
||||
});
|
||||
|
||||
test("a key of a member's who left opens with the keys pinned for "
|
||||
'them', () async {
|
||||
aliceCipher.newKey('k1');
|
||||
await alice.share(space, 'k1');
|
||||
await bob.openMine(space);
|
||||
final other = await cipherOf(bobAccount);
|
||||
final again = SharedKeys(
|
||||
server.as('bob'),
|
||||
KeyPins(bobPins),
|
||||
other,
|
||||
me: 'bob',
|
||||
);
|
||||
|
||||
server.removeMember(space, 'alice');
|
||||
|
||||
expect((await again.openMine(space)).opened, {'k1'});
|
||||
});
|
||||
|
||||
test('a key sealed by someone never pinned, and no longer a member, '
|
||||
"doesn't open", () async {
|
||||
aliceCipher.newKey('k1');
|
||||
await alice.share(space, 'k1');
|
||||
server.removeMember(space, 'alice');
|
||||
|
||||
expect(await bob.openMine(space), openedThese({}, refused: {'k1'}));
|
||||
});
|
||||
|
||||
test("a member's safety code is the same on both sides, and is marked "
|
||||
'compared once the person says so (D32)', () async {
|
||||
final seen = (await alice.trustIn(space)).single;
|
||||
expect(seen.person, 'bob');
|
||||
expect(seen.changed, isFalse);
|
||||
expect(seen.compared, isFalse);
|
||||
expect(seen.code, (await bob.trustIn(space)).single.code);
|
||||
|
||||
await alice.markCompared('bob');
|
||||
expect((await alice.trustIn(space)).single.compared, isTrue);
|
||||
});
|
||||
|
||||
test('keys that changed are flagged with the code of the new ones, and '
|
||||
'only the keys whose code was compared are trusted anew', () async {
|
||||
await alice.trustIn(space);
|
||||
await alice.markCompared('bob');
|
||||
final mallorys = keysOf(malloryAccount);
|
||||
final eves = keysOf(await newAccountKeys());
|
||||
server.handOutKeys('bob', mallorys);
|
||||
|
||||
final changed = (await alice.trustIn(space)).single;
|
||||
expect(changed.changed, isTrue);
|
||||
expect(changed.compared, isFalse);
|
||||
expect(changed.code, aliceCipher.safetyCodeWith(mallorys));
|
||||
|
||||
// The instance hands out other keys again before the person trusts the
|
||||
// ones they compared: those compared are pinned, and these differ.
|
||||
server.handOutKeys('bob', eves);
|
||||
await alice.trustNewKeys('bob', changed.keys);
|
||||
final after = (await alice.trustIn(space)).single;
|
||||
expect(after.changed, isTrue);
|
||||
expect(after.code, aliceCipher.safetyCodeWith(eves));
|
||||
|
||||
server.handOutKeys('bob', mallorys);
|
||||
final trusted = (await alice.trustIn(space)).single;
|
||||
expect(trusted.changed, isFalse);
|
||||
expect(trusted.compared, isTrue);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue