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
11 changed files with 688 additions and 10 deletions

View file

@ -1,5 +1,7 @@
import 'dart:developer';
import 'package:halkyon_app/halkyon_app.dart' show SharedSpaceGateway;
import 'package:halkyon_learn_flutter/src/core/privacy/data/privacy.dart';
import 'package:halkyon_learn_flutter/src/core/work/work_progress.dart';
import 'package:halkyon_learn_flutter/src/feature/archive/data/archive_exporter.dart';
@ -8,7 +10,6 @@ import 'package:halkyon_learn_flutter/src/feature/archive/model/import_archive.d
import 'package:halkyon_learn_flutter/src/feature/export/data/studyset_markdown.dart'
show ExportFile;
import 'package:halkyon_learn_flutter/src/feature/publish/data/publish_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/sharing/data/shared_space_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
@ -90,6 +91,9 @@ class StudysetSharing(
final ArchiveImporter _importer,
final StudysetDeletions _deletions,
) {
/// What a Learn space holds, in the module's words for it.
static const spaceKind = 'studyset';
static final _stages = WorkStages(
SharingStage.values,
weights: const {SharingStage.reading: 2, SharingStage.content: 2},
@ -116,7 +120,7 @@ class StudysetSharing(
}
abort?.check();
final space = await _spaces.create();
final space = await _spaces.create(spaceKind);
try {
await _setup.ready(space);
onProgress?.call(_stages.at(SharingStage.space, done: 1, total: 1));

View file

@ -1,4 +1,6 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart'
show SharedSpaceGateway, SpaceInfo, SpaceMember, SpaceRole;
import 'package:halkyon_app/testing.dart'
show FakeFileServerGateway, MemoryFileCache;
import 'package:halkyon_editor/halkyon_editor.dart';
@ -18,7 +20,6 @@ import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_progre
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/publish/model/publication.dart';
import 'package:halkyon_learn_flutter/src/feature/sharing/data/shared_space_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/sharing/data/studyset_sharing.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
@ -303,7 +304,8 @@ class _Spaces() implements SharedSpaceGateway {
final deleted = <String>[];
@override
Future<String> create() async {
Future<String> create(String kind) async {
expect(kind, 'studyset');
final space = 'space-${made.length + 1}';
made.add(space);
return space;
@ -311,6 +313,32 @@ class _Spaces() implements SharedSpaceGateway {
@override
Future<void> delete(String space) async => deleted.add(space);
@override
Future<List<SpaceInfo>> mine() => throw UnimplementedError();
@override
Future<List<SpaceMember>> members(String space) => throw UnimplementedError();
@override
Future<SpaceMember> invite(String space, String handle, SpaceRole role) =>
throw UnimplementedError();
@override
Future<void> setRole(String space, String member, SpaceRole role) =>
throw UnimplementedError();
@override
Future<void> remove(String space, String member) =>
throw UnimplementedError();
@override
Future<void> handOn(String space, String member) =>
throw UnimplementedError();
@override
Future<void> leave(String space, {String? nextOwner}) =>
throw UnimplementedError();
}
/// The device's side of a new space: the spaces readied and the studysets

View file

@ -186,8 +186,10 @@ export 'src/profile/widget/profile_sheet.dart';
export 'src/profile/widget/public_profile_section.dart';
export 'src/shared_space/key_pins.dart';
export 'src/shared_space/serverpod_shared_keys_gateway.dart';
export 'src/shared_space/serverpod_shared_space_gateway.dart';
export 'src/shared_space/shared_keys.dart';
export 'src/shared_space/shared_keys_gateway.dart';
export 'src/shared_space/shared_space_gateway.dart';
export 'src/simplelogin/bloc/simplelogin_cubit.dart';
export 'src/simplelogin/bloc/simplelogin_standing_cubit.dart';
export 'src/simplelogin/data/serverpod_simplelogin_gateway.dart';

View file

@ -0,0 +1,107 @@
import 'package:halkyon_app/src/shared_space/shared_space_gateway.dart';
import 'package:halkyon_base_client/halkyon_base_client.dart' as protocol;
/// [SharedSpaceGateway] on halkyon_base's `sharedSpace` endpoint.
class ServerpodSharedSpaceGateway(final protocol.Caller _base)
implements SharedSpaceGateway {
@override
Future<String> create(String kind) =>
_refusing(() async => (await _base.sharedSpace.create(kind)).id.uuid);
@override
Future<List<SpaceInfo>> mine() => _refusing(
() async => [
for (final space in await _base.sharedSpace.mine())
(id: space.id.uuid, kind: space.kind, role: _role(space.role)),
],
);
@override
Future<List<SpaceMember>> members(String space) => _refusing(
() async => [
for (final member in await _base.sharedSpace.members(_id(space)))
_member(member),
],
);
@override
Future<SpaceMember> invite(String space, String handle, SpaceRole role) =>
_refusing(
() async => _member(
await _base.sharedSpace.invite(_id(space), handle, _theirs(role)),
),
);
@override
Future<void> setRole(String space, String member, SpaceRole role) =>
_refusing(
() => _base.sharedSpace.setRole(_id(space), _id(member), _theirs(role)),
);
@override
Future<void> remove(String space, String member) =>
_refusing(() => _base.sharedSpace.remove(_id(space), _id(member)));
@override
Future<void> handOn(String space, String member) =>
_refusing(() => _base.sharedSpace.handOn(_id(space), _id(member)));
@override
Future<void> leave(String space, {String? nextOwner}) => _refusing(
() => _base.sharedSpace.leave(
_id(space),
nextOwner: nextOwner == null ? null : _id(nextOwner),
),
);
@override
Future<void> delete(String space) =>
_refusing(() => _base.sharedSpace.delete(_id(space)));
/// [call], its refusals as [SpaceRefused].
static Future<T> _refusing<T>(Future<T> Function() call) async {
try {
return await call();
} on protocol.SharedSpaceRefusedException catch (refused) {
throw SpaceRefused(switch (refused.reason) {
protocol.SharedSpaceRefusal.unavailable => SpaceRefusal.unavailable,
protocol.SharedSpaceRefusal.notAllowed => SpaceRefusal.notAllowed,
protocol.SharedSpaceRefusal.noSuchHandle => SpaceRefusal.noSuchHandle,
protocol.SharedSpaceRefusal.alreadyMember => SpaceRefusal.alreadyMember,
protocol.SharedSpaceRefusal.ownerRole => SpaceRefusal.ownerRole,
protocol.SharedSpaceRefusal.self => SpaceRefusal.self,
protocol.SharedSpaceRefusal.nextOwnerNeeded =>
SpaceRefusal.nextOwnerNeeded,
protocol.SharedSpaceRefusal.lastMember => SpaceRefusal.lastMember,
});
} on protocol.SharedSpaceNotFoundException {
throw const SpaceRefused(SpaceRefusal.notFound);
}
}
static SpaceMember _member(protocol.SharedSpaceMemberInfo member) =>
SpaceMember(
id: member.id.uuid,
handle: member.handle,
displayName: member.displayName,
role: _role(member.role),
joinedAt: member.joinedAt,
invited: member.invited,
you: member.you,
);
static SpaceRole _role(protocol.SharedSpaceRole role) => switch (role) {
protocol.SharedSpaceRole.owner => SpaceRole.owner,
protocol.SharedSpaceRole.editor => SpaceRole.editor,
protocol.SharedSpaceRole.viewer => SpaceRole.viewer,
};
static protocol.SharedSpaceRole _theirs(SpaceRole role) => switch (role) {
SpaceRole.owner => protocol.SharedSpaceRole.owner,
SpaceRole.editor => protocol.SharedSpaceRole.editor,
SpaceRole.viewer => protocol.SharedSpaceRole.viewer,
};
static protocol.UuidValue _id(String id) =>
protocol.UuidValue.withValidation(id);
}

View file

@ -36,6 +36,30 @@ typedef OpenOutcome = ({
Set<String> changed,
});
/// How this device sees another member's keys (ADR 0044, D18, D32).
typedef MemberTrust = ({
/// Their account's ID.
String person,
/// Their handle; null without a public profile.
String? handle,
/// The keys the instance hands out for them now.
PublicKeys keys,
/// The safety code of the person and them over [keys]: those pinned, or
/// where they changed, the new ones, which [SharedKeys.trustNewKeys]
/// takes once the two compared it.
String code,
/// Whether the person compared it, for the keys pinned.
bool compared,
/// Whether their keys changed since this device pinned them: nothing is
/// sealed to them or taken from them until trusted anew.
bool changed,
});
/// A shared space's data keys on the device (Halkyon's ADR 0044, D3, D18):
/// each sealed to every member's public keys, which this device pins on
/// first sight and holds to from then on, as Signal and SSH do; the
@ -137,6 +161,39 @@ class SharedKeys(
return (opened: opened, refused: refused, changed: changed);
}
/// The other members of [space] with keys, as this device sees them,
/// pinning those seen for the first time.
Future<List<MemberTrust>> trustIn(String space) async {
final members = await _checkedMembers(space);
return [
for (final member in await _gateway.memberKeys(space))
if (member.person != me &&
(members.trusted.containsKey(member.person) ||
members.changed.contains(member.person)))
(
person: member.person,
handle: member.handle,
keys: member.keys,
code: _cipher.safetyCodeWith(member.keys),
compared:
!members.changed.contains(member.person) &&
((await _pins.of(member.person))?.verified ?? false),
changed: members.changed.contains(member.person),
),
];
}
/// Marks [person]'s pinned keys compared, by their safety code.
Future<void> markCompared(String person) => _pins.markVerified(person);
/// Pins [keys] for [person], compared, in place of those before: the
/// person compared their safety code over them (`MemberTrust.code`). The
/// keys the code was shown for, never those the instance hands next.
Future<void> trustNewKeys(String person, PublicKeys keys) async {
await _pins.trustAnew(person, keys);
await _pins.markVerified(person);
}
/// The safety code of the person and [person], over the keys pinned for
/// them; null where none are.
Future<String?> safetyCodeWith(String person) async {

View file

@ -0,0 +1,115 @@
/// What a member may do in a shared space (Halkyon's ADR 0044, D2).
enum SpaceRole() {
/// Everything: manages the members, hands the space on, deletes it.
owner,
/// Changes its content.
editor,
/// Reads and studies it.
viewer;
/// Whether the role changes content: the Owner's and an Editor's.
bool get writes => this != viewer;
}
/// A shared space the person is a member of: the sync engine's space, what
/// it holds in the app's words, and their role in it.
typedef SpaceInfo = ({String id, String kind, SpaceRole role});
/// A member of a shared space, or someone invited who hasn't joined yet, as
/// the members see them: by their public profile, never by account ID.
final class const SpaceMember({
/// What names them in calls about the space: their membership, or the
/// invitation while it waits.
required final String id,
/// Their handle; null without a public profile.
required final String? handle,
/// Their display name; empty without one.
required final String displayName,
required final SpaceRole role,
/// When they joined; null while invited.
required final DateTime? joinedAt,
/// Whether the invitation waits for them to use the app (ADR 0044, D13).
required final bool invited,
/// Whether this is the person.
required final bool you,
});
/// Why a call about a shared space was refused; nothing changed.
enum SpaceRefusal() {
/// The instance keeps no shared spaces for this app.
unavailable,
/// Only the Owner does that (or a member who may invite, inviting).
notAllowed,
/// No account has that handle.
noSuchHandle,
/// They are a member or invited already.
alreadyMember,
/// The Owner's role passes only by handing the space on.
ownerRole,
/// Not on oneself: the Owner hands on or leaves instead.
self,
/// The Owner leaves only naming who takes the space on.
nextOwnerNeeded,
/// The only member doesn't leave: they delete the space.
lastMember,
/// No such space the person is in, or no such member in it.
notFound,
}
/// A call about a shared space refused for [reason].
final class const SpaceRefused(final SpaceRefusal reason) implements Exception {
@override
String toString() => 'SpaceRefused(${reason.name})';
}
/// The shared spaces the instance keeps (halkyon_base's `sharedSpace`
/// endpoint, Halkyon's ADR 0044, D1, D2, D11, D13). Each call throws
/// [SpaceRefused] where the instance refuses it. Tests replace it with a
/// fake.
abstract interface class SharedSpaceGateway() {
/// Makes a space holding [kind] (the app's word), with the person its
/// Owner; returns its id, the sync engine's space.
Future<String> create(String kind);
/// The spaces the person is a member of, with their role in each.
Future<List<SpaceInfo>> mine();
/// The members of [space] and those invited, the Owner first, then as
/// they joined.
Future<List<SpaceMember>> members(String space);
/// Invites the person with [handle] to [space] as [role]: a member at once
/// where they use the app on this instance, else invited until they do.
Future<SpaceMember> invite(String space, String handle, SpaceRole role);
/// Gives the member [member] of [space] the role [role]; the Owner's.
Future<void> setRole(String space, String member, SpaceRole role);
/// Removes the member or invitation [member] from [space]; the Owner's.
Future<void> remove(String space, String member);
/// Makes [member] the Owner of [space], and the person, its Owner, an
/// Editor.
Future<void> handOn(String space, String member);
/// Leaves [space]; its Owner names who takes it on ([nextOwner]).
Future<void> leave(String space, {String? nextOwner});
/// Deletes [space] with everything in it; its Owner's.
Future<void> delete(String space);
}

View file

@ -10,7 +10,11 @@ typedef _Sealed = ({String keyId, String to, String by, Uint8List sealed});
/// hostile instance would), the keys sealed to each, the first staying.
/// Each person reaches it through their own [as].
class FakeSharedKeysServer() {
final _members = <String, Map<String, ({PublicKeys? keys, bool writes})>>{};
final _members =
<
String,
Map<String, ({PublicKeys? keys, bool writes, String? handle})>
>{};
final _sealed =
<
String,
@ -18,13 +22,19 @@ class FakeSharedKeysServer() {
>{};
/// Makes [person] a member of [space], writing where [writes], with the
/// public keys [keys] the register hands out for them (none where null).
/// public keys [keys] the register hands out for them (none where null)
/// and their [handle].
void addMember(
String space,
String person, {
PublicKeys? keys,
bool writes = true,
}) => (_members[space] ??= {})[person] = (keys: keys, writes: writes);
String? handle,
}) => (_members[space] ??= {})[person] = (
keys: keys,
writes: writes,
handle: handle,
);
/// Ends [person]'s membership of [space], with the keys sealed to them.
void removeMember(String space, String person) {
@ -36,7 +46,11 @@ class FakeSharedKeysServer() {
void handOutKeys(String person, PublicKeys keys) {
for (final members in _members.values) {
if (members[person] case final member?) {
members[person] = (keys: keys, writes: member.writes);
members[person] = (
keys: keys,
writes: member.writes,
handle: member.handle,
);
}
}
}
@ -47,7 +61,7 @@ class FakeSharedKeysServer() {
/// The gateway as [person] reaches it.
SharedKeysGateway as(String person) => _View(this, person);
Map<String, ({PublicKeys? keys, bool writes})> _membersAs(
Map<String, ({PublicKeys? keys, bool writes, String? handle})> _membersAs(
String space,
String person,
) {
@ -66,7 +80,7 @@ class _View(final FakeSharedKeysServer _server, final String _person)
for (final MapEntry(key: person, value: member)
in _server._membersAs(space, _person).entries)
if (member.keys case final keys?)
(person: person, handle: null, keys: keys),
(person: person, handle: member.handle, keys: keys),
];
@override

View file

@ -0,0 +1,212 @@
import 'package:clock/clock.dart';
import 'package:halkyon_app/src/shared_space/shared_space_gateway.dart';
import 'package:halkyon_app/src/testing/fake_shared_keys_gateway.dart';
import 'package:halkyon_keys/halkyon_keys.dart' show PublicKeys;
typedef _Member = ({String id, String person, SpaceRole role, DateTime joined});
/// The instance's shared spaces in memory, as halkyon_base keeps them, its
/// members kept in step with [keys] (their public keys, the keys sealed to
/// them): people known by account and handle, spaces with their Owner,
/// Editors and Viewers, and the Owner's rules. Each person reaches it
/// through their own [as]. Invitations waiting (ADR 0044, D13) aren't
/// kept: everyone known here uses the app.
class FakeSharedSpaceServer(
/// The keys of the spaces' members, which this keeps in step.
final FakeSharedKeysServer keys,
) {
final _people = <String, ({String handle, PublicKeys? keys})>{};
final _spaces = <String, ({String kind, List<_Member> members})>{};
var _next = 0;
/// The spaces made, by id, in order.
Iterable<String> get spaces => _spaces.keys;
/// Makes [person] known by [handle], with their public [keys].
void addPerson(String person, {required String handle, PublicKeys? keys}) =>
_people[person] = (handle: handle, keys: keys);
/// The gateway as [person] reaches it.
SharedSpaceGateway as(String person) => _View(this, person);
String _id(String what) => '$what-${++_next}';
List<_Member> _membersAs(String space, String person) {
final members = _spaces[space]?.members;
if (members == null || !members.any((m) => m.person == person)) {
throw const SpaceRefused(SpaceRefusal.notFound);
}
return members;
}
/// The members of [space], where [person] is its Owner.
List<_Member> _asOwner(String space, String person) {
final members = _membersAs(space, person);
if (members.firstWhere((m) => m.person == person).role != SpaceRole.owner) {
throw const SpaceRefused(SpaceRefusal.notAllowed);
}
return members;
}
/// The member [id] of [members], not [person] themselves.
int _other(List<_Member> members, String id, String person) {
final at = members.indexWhere((m) => m.id == id);
if (at < 0) {
throw const SpaceRefused(SpaceRefusal.notFound);
}
if (members[at].person == person) {
throw const SpaceRefused(SpaceRefusal.self);
}
return at;
}
void _join(String space, String person, SpaceRole role) {
_spaces[space]!.members.add((
id: _id('member'),
person: person,
role: role,
joined: clock.now().toUtc(),
));
keys.addMember(
space,
person,
keys: _people[person]?.keys,
writes: role.writes,
handle: _people[person]?.handle,
);
}
void _setRole(String space, int at, SpaceRole role) {
final members = _spaces[space]!.members;
final member = members[at];
members[at] = (
id: member.id,
person: member.person,
role: role,
joined: member.joined,
);
keys.addMember(
space,
member.person,
keys: _people[member.person]?.keys,
writes: role.writes,
handle: _people[member.person]?.handle,
);
}
void _leave(String space, int at) {
final member = _spaces[space]!.members.removeAt(at);
keys.removeMember(space, member.person);
}
}
class _View(final FakeSharedSpaceServer _server, final String _person)
implements SharedSpaceGateway {
@override
Future<String> create(String kind) async {
final space = _server._id('space');
_server._spaces[space] = (kind: kind, members: []);
_server._join(space, _person, SpaceRole.owner);
return space;
}
@override
Future<List<SpaceInfo>> mine() async => [
for (final MapEntry(key: id, value: space) in _server._spaces.entries)
for (final member in space.members)
if (member.person == _person)
(id: id, kind: space.kind, role: member.role),
];
@override
Future<List<SpaceMember>> members(String space) async => [
for (final member in _server._membersAs(space, _person))
SpaceMember(
id: member.id,
handle: _server._people[member.person]?.handle,
displayName: '',
role: member.role,
joinedAt: member.joined,
invited: false,
you: member.person == _person,
),
];
@override
Future<SpaceMember> invite(
String space,
String handle,
SpaceRole role,
) async {
final current = _server._asOwner(space, _person);
if (role == SpaceRole.owner) {
throw const SpaceRefused(SpaceRefusal.ownerRole);
}
final person = [
for (final MapEntry(:key, :value) in _server._people.entries)
if (value.handle.toLowerCase() == handle.toLowerCase()) key,
].firstOrNull;
if (person == null) {
throw const SpaceRefused(SpaceRefusal.noSuchHandle);
}
if (current.any((m) => m.person == person)) {
throw const SpaceRefused(SpaceRefusal.alreadyMember);
}
_server._join(space, person, role);
return (await members(space)).last;
}
@override
Future<void> setRole(String space, String member, SpaceRole role) async {
final members = _server._asOwner(space, _person);
final at = _server._other(members, member, _person);
if (role == SpaceRole.owner) {
throw const SpaceRefused(SpaceRefusal.ownerRole);
}
_server._setRole(space, at, role);
}
@override
Future<void> remove(String space, String member) async {
final members = _server._asOwner(space, _person);
_server._leave(space, _server._other(members, member, _person));
}
@override
Future<void> handOn(String space, String member) async {
final members = _server._asOwner(space, _person);
final at = _server._other(members, member, _person);
_server
.._setRole(space, at, SpaceRole.owner)
.._setRole(
space,
members.indexWhere((m) => m.person == _person),
SpaceRole.editor,
);
}
@override
Future<void> leave(String space, {String? nextOwner}) async {
final members = _server._membersAs(space, _person);
if (members.length == 1) {
throw const SpaceRefused(SpaceRefusal.lastMember);
}
final mine = members.indexWhere((m) => m.person == _person);
if (members[mine].role == SpaceRole.owner) {
if (nextOwner == null) {
throw const SpaceRefused(SpaceRefusal.nextOwnerNeeded);
}
await handOn(space, nextOwner);
}
_server._leave(space, members.indexWhere((m) => m.person == _person));
}
@override
Future<void> delete(String space) async {
final members = _server._asOwner(space, _person);
for (final member in [...members]) {
_server.keys.removeMember(space, member.person);
}
_server._spaces.remove(space);
}
}

View file

@ -23,6 +23,7 @@ export 'src/testing/fake_password_gateway.dart';
export 'src/testing/fake_public_profile_gateway.dart';
export 'src/testing/fake_screen_guard.dart';
export 'src/testing/fake_shared_keys_gateway.dart';
export 'src/testing/fake_shared_space_gateway.dart';
export 'src/testing/fake_simplelogin_gateway.dart';
export 'src/testing/fake_speech_input.dart';
export 'src/testing/memory_account_key_store.dart';

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);
});
}