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:
parent
4b900aa930
commit
02bb24428c
11 changed files with 688 additions and 10 deletions
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue