feat(app,learn): shared spaces' members on the device

Step 4 (e) of ADR 0044, its first part, for every app's screens.

- halkyon_app: `SharedSpaceGateway` on the module's `sharedSpace`
  endpoint (the spaces, their members and roles, inviting, handing on,
  leaving), refusals as `SpaceRefused`; `SharedKeys.trustIn` gives
  each member's safety code and whether it was compared or their keys
  changed, `markCompared`, and `trustNewKeys`, which pins the keys
  whose code was compared and never those the instance hands next.
  `FakeSharedSpaceServer` keeps the module's rules for tests, in step
  with `FakeSharedKeysServer`, which now knows members' handles.
- Learn's sharing makes its space through it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-11 14:39:12 +01:00
commit 02bb24428c
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 688 additions and 10 deletions

View file

@ -0,0 +1,99 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.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),
]);
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);
},
);
}

View file

@ -219,4 +219,43 @@ void main() {
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 crypto.newAccount('eve password')).keys);
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);
});
}