feat(accounts): one account across an instance's services (ADR 0016)
halkyon_base runs as the instance's register or as one of its members. The register keeps the ways in, handles and profiles; members ask it over HTTP with the instance's shared secret (instanceSecret), and open sessions of their own for its accounts, under the same account ID. - AccountsMode from the environment: INSTANCE_MEMBERS on the register, ACCOUNT_REGISTER_URL on a member. Learn always holds the register (Explore joins the profiles); Todo is a member where it is set. - AccountRegister: LocalRegister, RemoteRegister, an in-memory one in tests. Calls between services on the web server, refused without the secret (constant-time compare); a server needing it doesn't start without it. - Deleting an account deletes it everywhere: notices per member kept in the deletion's transaction (account_deletion_notice), told at once and hourly until confirmed; members prune daily whom the register no longer knows, never while it knows none of them or can't be reached. - Deploy: INSTANCE_SECRET and INSTANCE_MEMBERS in compose and .env. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
4107cc3087
commit
65c5cd740d
5 changed files with 41906 additions and 167 deletions
|
|
@ -112,9 +112,10 @@ void main() {
|
|||
'created', () async {
|
||||
final people = await AuthUser.db.count(session);
|
||||
|
||||
await expectLater(
|
||||
signInWithGitHubAccount(session, gitHubUserId: '3003'),
|
||||
throwsA(_refusal(GitHubSignInRefusal.unknownAccount)),
|
||||
// The endpoint refuses it (unknownAccount): nobody has it.
|
||||
expect(
|
||||
await accountWithGitHub(session, gitHubUserId: '3003'),
|
||||
isNull,
|
||||
);
|
||||
|
||||
expect(await AuthUser.db.count(session), people);
|
||||
|
|
@ -152,9 +153,11 @@ void main() {
|
|||
|
||||
test('signing in with a known GitHub account starts a session for that '
|
||||
'person', () async {
|
||||
final authSuccess = await signInWithGitHubAccount(
|
||||
final account = await accountWithGitHub(session, gitHubUserId: '1001');
|
||||
final authSuccess = await issueSession(
|
||||
session,
|
||||
gitHubUserId: '1001',
|
||||
account!,
|
||||
method: gitHubMethod,
|
||||
);
|
||||
|
||||
expect(authSuccess.authUserId, ben);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,740 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_base_server/base.dart';
|
||||
import 'package:halkyon_base_server/src/register/deletion_notices.dart';
|
||||
import 'package:halkyon_base_server/src/register/instance_api.dart';
|
||||
import 'package:halkyon_base_server/src/register/register_protocol.dart';
|
||||
import 'package:halkyon_base_server/src/register/remote_register.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_auth_idp_server/core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../support/fixtures.dart';
|
||||
import '../test_tools/serverpod_test_tools.dart';
|
||||
|
||||
const _secret = 'a shared secret only the instance knows';
|
||||
final _learn = Uri.parse('http://learn.test:8082');
|
||||
|
||||
/// The instance's register, in memory: what a member asks it, and what it
|
||||
/// answers.
|
||||
final class _Register implements AccountRegister {
|
||||
final accounts = <UuidValue>{};
|
||||
final addresses = <String, UuidValue>{};
|
||||
final gitHub = <String, UuidValue>{};
|
||||
final profiles = <UuidValue, Profile>{};
|
||||
final codes = <UuidValue, ({String address, String code})>{};
|
||||
final switches = <String, UuidValue>{};
|
||||
|
||||
/// The calls asked, by name, in their order.
|
||||
final asked = <String>[];
|
||||
final networks = <String>[];
|
||||
final deleted = <UuidValue>[];
|
||||
|
||||
/// What [knownAccounts] was asked about, call by call.
|
||||
final askedAbout = <Set<UuidValue>>[];
|
||||
bool offered = true;
|
||||
|
||||
/// Thrown by every call while set: the register refusing, or down.
|
||||
Exception? failing;
|
||||
|
||||
Future<T> _answer<T>(String call, T Function() answer) async {
|
||||
asked.add(call);
|
||||
if (failing case final error?) {
|
||||
throw error;
|
||||
}
|
||||
return answer();
|
||||
}
|
||||
|
||||
/// [person] as an account with the address [address].
|
||||
void add(UuidValue person, {String? address}) {
|
||||
accounts.add(person);
|
||||
if (address != null) {
|
||||
addresses[address] = person;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Profile?> profileOf(Session session, UuidValue person) =>
|
||||
_answer('profileOf', () => profiles[person]);
|
||||
|
||||
@override
|
||||
Future<Map<UuidValue, Profile>> profilesOf(
|
||||
Session session,
|
||||
Set<UuidValue> people,
|
||||
) => _answer(
|
||||
'profilesOf',
|
||||
() => {
|
||||
for (final person in people) person: ?profiles[person],
|
||||
},
|
||||
);
|
||||
|
||||
@override
|
||||
Future<Profile?> profileByHandle(Session session, String handle) => _answer(
|
||||
'profileByHandle',
|
||||
() => profiles.values
|
||||
.where((profile) => profile.handle.toLowerCase() == handle)
|
||||
.firstOrNull,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<UuidValue?> personWithHandle(Session session, String handle) =>
|
||||
_answer(
|
||||
'personWithHandle',
|
||||
() => profiles.entries
|
||||
.where((entry) => entry.value.handle.toLowerCase() == handle)
|
||||
.firstOrNull
|
||||
?.key,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<Profile> saveProfile(
|
||||
Session session,
|
||||
UuidValue person, {
|
||||
required String handle,
|
||||
required String displayName,
|
||||
required String bio,
|
||||
required List<String> links,
|
||||
}) => _answer(
|
||||
'saveProfile',
|
||||
() => profiles[person] = Profile(
|
||||
handle: handle,
|
||||
address: '$handle@learn.test',
|
||||
displayName: displayName,
|
||||
bio: bio,
|
||||
links: links,
|
||||
pageUrl: 'https://learn.test/@$handle',
|
||||
handleFixed: false,
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<ProfileRefusal?> checkHandle(
|
||||
Session session,
|
||||
UuidValue person,
|
||||
String handle,
|
||||
) => _answer('checkHandle', () => null);
|
||||
|
||||
@override
|
||||
Future<Profile> checkLinks(Session session, UuidValue person) =>
|
||||
_answer('checkLinks', () => profiles[person]!);
|
||||
|
||||
@override
|
||||
Future<Profile> setAvatar(Session session, UuidValue person, ByteData png) =>
|
||||
_answer('setAvatar', () => profiles[person]!);
|
||||
|
||||
@override
|
||||
Future<Profile> removeAvatar(Session session, UuidValue person) =>
|
||||
_answer('removeAvatar', () => profiles[person]!);
|
||||
|
||||
@override
|
||||
Future<void> removeProfile(Session session, UuidValue person) =>
|
||||
_answer('removeProfile', () => profiles.remove(person));
|
||||
|
||||
@override
|
||||
Future<void> fixHandle(Session session, UuidValue person) =>
|
||||
_answer('fixHandle', () {});
|
||||
|
||||
@override
|
||||
Future<bool> emailSignInOffered(Session session) =>
|
||||
_answer('emailSignInOffered', () => offered);
|
||||
|
||||
@override
|
||||
Future<UuidValue> sendSignInCode(
|
||||
Session session, {
|
||||
required String address,
|
||||
required String language,
|
||||
required String network,
|
||||
}) => _answer('sendSignInCode', () {
|
||||
networks.add(network);
|
||||
final request = const Uuid().v7obj();
|
||||
codes[request] = (address: address, code: 'K7QX-M2PD');
|
||||
return request;
|
||||
});
|
||||
|
||||
@override
|
||||
Future<EmailCodeAccepted> verifySignInCode(
|
||||
Session session, {
|
||||
required UuidValue person,
|
||||
required UuidValue request,
|
||||
required String code,
|
||||
}) => _answer('verifySignInCode', () {
|
||||
final sent = codes[request];
|
||||
if (sent == null || sent.code != code) {
|
||||
throw EmailSignInRefusedException(reason: EmailSignInRefusal.wrongCode);
|
||||
}
|
||||
final owner = addresses[sent.address];
|
||||
if (owner != null && owner != person) {
|
||||
final token = 'switch to ${owner.uuid}';
|
||||
switches[token] = owner;
|
||||
return EmailCodeAccepted(switchToken: token);
|
||||
}
|
||||
add(person, address: sent.address);
|
||||
return EmailCodeAccepted();
|
||||
});
|
||||
|
||||
@override
|
||||
Future<UuidValue> completeEmailSwitch(
|
||||
Session session, {
|
||||
required String switchToken,
|
||||
}) => _answer(
|
||||
'completeEmailSwitch',
|
||||
() =>
|
||||
switches.remove(switchToken) ??
|
||||
(throw EmailSignInRefusedException(
|
||||
reason: EmailSignInRefusal.codeExpired,
|
||||
)),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> linkGitHub(
|
||||
Session session, {
|
||||
required UuidValue person,
|
||||
required String gitHubUserId,
|
||||
}) => _answer('linkGitHub', () {
|
||||
accounts.add(person);
|
||||
gitHub[gitHubUserId] = person;
|
||||
});
|
||||
|
||||
@override
|
||||
Future<UuidValue?> accountWithGitHub(Session session, String gitHubUserId) =>
|
||||
_answer('accountWithGitHub', () => gitHub[gitHubUserId]);
|
||||
|
||||
@override
|
||||
Future<SignInWays> signInWaysOf(Session session, UuidValue person) => _answer(
|
||||
'signInWaysOf',
|
||||
() => SignInWays(
|
||||
gitHub: gitHub.containsValue(person),
|
||||
email: addresses.containsValue(person),
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> deleteAccount(Session session, UuidValue person) =>
|
||||
_answer('deleteAccount', () {
|
||||
deleted.add(person);
|
||||
accounts.remove(person);
|
||||
});
|
||||
|
||||
@override
|
||||
Future<Set<UuidValue>> knownAccounts(
|
||||
Session session,
|
||||
Set<UuidValue> people,
|
||||
) => _answer('knownAccounts', () {
|
||||
askedAbout.add(people);
|
||||
return people.intersection(accounts);
|
||||
});
|
||||
}
|
||||
|
||||
void main() {
|
||||
withServerpod(
|
||||
'A member of an instance',
|
||||
// Sessions issued and revoked, and deletions in transactions of their
|
||||
// own: these tests delete what they make instead.
|
||||
rollbackDatabase: RollbackDatabase.disabled,
|
||||
(sessionBuilder, endpoints) {
|
||||
late Session session;
|
||||
late Serverpod pod;
|
||||
late _Register register;
|
||||
|
||||
/// Anonymous here, signed in on her phone.
|
||||
late UuidValue anna;
|
||||
late UuidValue annasPhone;
|
||||
|
||||
/// An account of the register, identified here, signed in on his
|
||||
/// laptop.
|
||||
late UuidValue ben;
|
||||
late UuidValue bensLaptop;
|
||||
|
||||
/// Whose data the host deleted.
|
||||
late List<UuidValue> hostDeleted;
|
||||
|
||||
setUp(() async {
|
||||
hostDeleted = [];
|
||||
useBase(
|
||||
sessionBuilder,
|
||||
accounts: MemberOfRegister(_learn),
|
||||
deletePersonData: [
|
||||
(session, person, transaction) async {
|
||||
hostDeleted.add(person);
|
||||
return null;
|
||||
},
|
||||
],
|
||||
);
|
||||
session = sessionBuilder.build();
|
||||
pod = session.serverpod;
|
||||
await _deleteAll(session);
|
||||
useInstanceSecret(pod, _secret);
|
||||
register = _Register();
|
||||
AccountRegister.use(pod, register);
|
||||
anna = await newAnonymousPerson(session);
|
||||
annasPhone = await signInNewDevice(session, anna);
|
||||
ben = (await AuthServices.instance.authUsers.create(
|
||||
session,
|
||||
scopes: {identified},
|
||||
)).id;
|
||||
register.add(ben, address: 'ben@example.org');
|
||||
bensLaptop = await signInNewDevice(session, ben, scopes: {identified});
|
||||
});
|
||||
tearDown(() async {
|
||||
AccountRegister.use(pod, null);
|
||||
useInstanceSecret(pod, null);
|
||||
await _deleteAll(sessionBuilder.build());
|
||||
});
|
||||
|
||||
TestSessionBuilder asAnna() => sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
anna.uuid,
|
||||
{},
|
||||
authId: annasPhone.uuid,
|
||||
),
|
||||
);
|
||||
TestSessionBuilder asBen() => sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
ben.uuid,
|
||||
{identified},
|
||||
authId: bensLaptop.uuid,
|
||||
),
|
||||
);
|
||||
TestSessionBuilder notSignedIn() => sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.unauthenticated(),
|
||||
);
|
||||
|
||||
Future<Set<String>> scopesOf(UuidValue person) async =>
|
||||
(await AuthUser.db.findById(session, person))!.scopeNames;
|
||||
|
||||
group('signs in by asking the register', () {
|
||||
test('which mails the code, its device network passed on', () async {
|
||||
await endpoints.emailCode.sendCode(
|
||||
asAnna(),
|
||||
address: 'anna@example.org',
|
||||
language: 'de',
|
||||
);
|
||||
|
||||
expect(register.asked, ['sendSignInCode']);
|
||||
expect(register.codes.values.single.address, 'anna@example.org');
|
||||
expect(register.networks, hasLength(1));
|
||||
});
|
||||
|
||||
test('and refuses as the register refuses', () async {
|
||||
register.failing = EmailSignInRefusedException(
|
||||
reason: EmailSignInRefusal.tooManyCodes,
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
endpoints.emailCode.sendCode(
|
||||
asAnna(),
|
||||
address: 'anna@example.org',
|
||||
language: 'de',
|
||||
),
|
||||
throwsA(
|
||||
isA<EmailSignInRefusedException>().having(
|
||||
(error) => error.reason,
|
||||
'reason',
|
||||
EmailSignInRefusal.tooManyCodes,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('an address nobody has makes the person here identified, under '
|
||||
'the same ID, the register making them its account', () async {
|
||||
final request = await endpoints.emailCode.sendCode(
|
||||
asAnna(),
|
||||
address: 'anna@example.org',
|
||||
language: 'de',
|
||||
);
|
||||
|
||||
final accepted = await endpoints.emailCode.verifyCode(
|
||||
asAnna(),
|
||||
request: request,
|
||||
code: 'K7QX-M2PD',
|
||||
);
|
||||
|
||||
expect(accepted.switchToken, isNull);
|
||||
expect(register.accounts, contains(anna));
|
||||
expect(await scopesOf(anna), contains(identified.name));
|
||||
expect(
|
||||
(await ServerSideSession.db.findById(
|
||||
session,
|
||||
annasPhone,
|
||||
))!.scopeNames,
|
||||
contains(identified.name),
|
||||
);
|
||||
expect(
|
||||
await AnonymousAccount.db.count(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(anna),
|
||||
),
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('a wrong code changes nothing here', () async {
|
||||
final request = await endpoints.emailCode.sendCode(
|
||||
asAnna(),
|
||||
address: 'anna@example.org',
|
||||
language: 'de',
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
endpoints.emailCode.verifyCode(
|
||||
asAnna(),
|
||||
request: request,
|
||||
code: 'AAAA-AAAA',
|
||||
),
|
||||
throwsA(isA<EmailSignInRefusedException>()),
|
||||
);
|
||||
expect(await scopesOf(anna), isNot(contains(identified.name)));
|
||||
});
|
||||
|
||||
test("another account's address gives a switch, which signs in as "
|
||||
'that account here, made a person here under its ID the first '
|
||||
'time', () async {
|
||||
final carla = const Uuid().v7obj();
|
||||
register.add(carla, address: 'carla@example.org');
|
||||
final request = await endpoints.emailCode.sendCode(
|
||||
asAnna(),
|
||||
address: 'carla@example.org',
|
||||
language: 'en',
|
||||
);
|
||||
final accepted = await endpoints.emailCode.verifyCode(
|
||||
asAnna(),
|
||||
request: request,
|
||||
code: 'K7QX-M2PD',
|
||||
);
|
||||
expect(accepted.switchToken, isNotNull);
|
||||
expect(await scopesOf(anna), isNot(contains(identified.name)));
|
||||
expect(await AuthUser.db.findById(session, carla), isNull);
|
||||
|
||||
final signedIn = await endpoints.emailSignIn.signIn(
|
||||
notSignedIn(),
|
||||
switchToken: accepted.switchToken!,
|
||||
);
|
||||
|
||||
expect(signedIn.authUserId, carla);
|
||||
expect(await scopesOf(carla), {identified.name});
|
||||
final sessions = await ServerSideSession.db.find(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(carla),
|
||||
);
|
||||
expect(sessions.single.scopeNames, {identified.name});
|
||||
await expectLater(
|
||||
endpoints.emailSignIn.signIn(
|
||||
notSignedIn(),
|
||||
switchToken: accepted.switchToken!,
|
||||
),
|
||||
throwsA(isA<EmailSignInRefusedException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('an account signing in again is the same person here', () async {
|
||||
await issueSession(session, ben, method: 'test');
|
||||
await issueSession(session, ben, method: 'test');
|
||||
|
||||
expect(
|
||||
await AuthUser.db.count(session, where: (t) => t.id.equals(ben)),
|
||||
1,
|
||||
);
|
||||
expect(
|
||||
await ServerSideSession.db.count(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(ben),
|
||||
),
|
||||
3,
|
||||
);
|
||||
});
|
||||
|
||||
test('an account the register proved is identified here even where '
|
||||
'linking its way in was cut off', () async {
|
||||
final success = await issueSession(session, anna, method: 'test');
|
||||
|
||||
expect(success.authUserId, anna);
|
||||
expect(await scopesOf(anna), contains(identified.name));
|
||||
expect(
|
||||
(await ServerSideSession.db.findById(
|
||||
session,
|
||||
annasPhone,
|
||||
))!.scopeNames,
|
||||
contains(identified.name),
|
||||
);
|
||||
});
|
||||
|
||||
test('email sign-in is offered as the register offers it', () async {
|
||||
expect(
|
||||
(await endpoints.instance.basics(notSignedIn())).emailSignIn,
|
||||
isTrue,
|
||||
);
|
||||
register.offered = false;
|
||||
expect(
|
||||
(await endpoints.instance.basics(notSignedIn())).emailSignIn,
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('the basics still answer while the register is out of reach, '
|
||||
'without email sign-in', () async {
|
||||
register.failing = RegisterUnavailableException('test');
|
||||
|
||||
final basics = await endpoints.instance.basics(notSignedIn());
|
||||
|
||||
expect(basics.emailSignIn, isFalse);
|
||||
expect(basics.app, testApp);
|
||||
});
|
||||
|
||||
test('the ways in are those the register knows', () async {
|
||||
register.gitHub['1001'] = ben;
|
||||
|
||||
final ways = await endpoints.account.signInWays(asBen());
|
||||
|
||||
expect(ways.toJson(), SignInWays(gitHub: true, email: true).toJson());
|
||||
});
|
||||
});
|
||||
|
||||
group('profiles', () {
|
||||
test('are saved and found at the register', () async {
|
||||
final saved = await endpoints.profile.save(
|
||||
asBen(),
|
||||
'Ben',
|
||||
'Ben',
|
||||
'',
|
||||
const [],
|
||||
);
|
||||
|
||||
expect(register.profiles[ben]!.toJson(), saved.toJson());
|
||||
expect(
|
||||
(await endpoints.profiles.byHandle(asAnna(), 'ben')).toJson(),
|
||||
saved.toJson(),
|
||||
);
|
||||
expect((await profileOf(session, ben))!.toJson(), saved.toJson());
|
||||
expect(await PublicProfile.db.count(session), 0);
|
||||
});
|
||||
|
||||
test("have their page at the register's address, where this "
|
||||
"server's sends its visitors", () async {
|
||||
await endpoints.profile.save(asBen(), 'Ben', 'Ben', '', const []);
|
||||
|
||||
final page = await profilePage(
|
||||
session,
|
||||
'@ben',
|
||||
host: 'todo.test',
|
||||
);
|
||||
|
||||
expect(page.status, 308);
|
||||
expect(page.location, 'https://learn.test/@Ben');
|
||||
});
|
||||
});
|
||||
|
||||
group('deleting the account', () {
|
||||
test('of an identified person deletes it at the register, which '
|
||||
'tells every member, and here at once', () async {
|
||||
await endpoints.account.delete(asBen());
|
||||
|
||||
expect(register.deleted, [ben]);
|
||||
expect(await AuthUser.db.findById(session, ben), isNull);
|
||||
expect(
|
||||
await ServerSideSession.db.count(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(ben),
|
||||
),
|
||||
0,
|
||||
);
|
||||
expect(hostDeleted, [ben]);
|
||||
expect(await AuthUser.db.findById(session, anna), isNotNull);
|
||||
});
|
||||
|
||||
test('of an anonymous person deletes it here alone', () async {
|
||||
await endpoints.account.delete(asAnna());
|
||||
|
||||
expect(register.asked, isEmpty);
|
||||
expect(await AuthUser.db.findById(session, anna), isNull);
|
||||
expect(hostDeleted, [anna]);
|
||||
});
|
||||
|
||||
test('is refused while the register is out of reach, deleting '
|
||||
'nothing here', () async {
|
||||
register.failing = RegisterUnavailableException('test');
|
||||
|
||||
await expectLater(
|
||||
endpoints.account.delete(asBen()),
|
||||
throwsA(isA<RegisterUnavailableException>()),
|
||||
);
|
||||
expect(await AuthUser.db.findById(session, ben), isNotNull);
|
||||
expect(
|
||||
await ServerSideSession.db.count(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(ben),
|
||||
),
|
||||
1,
|
||||
);
|
||||
expect(hostDeleted, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('told by the register that an account is gone', () {
|
||||
Future<InstanceAnswer> tell(
|
||||
Object? account, {
|
||||
String? authorization = 'Bearer $_secret',
|
||||
}) => answerRegister(
|
||||
session,
|
||||
jsonEncode({'account': account}),
|
||||
authorization: authorization,
|
||||
);
|
||||
|
||||
test('deletes that person here with their sessions, and nobody '
|
||||
'else', () async {
|
||||
final answer = await tell(ben.uuid);
|
||||
|
||||
expect(answer.status, 200);
|
||||
expect(await AuthUser.db.findById(session, ben), isNull);
|
||||
expect(
|
||||
await ServerSideSession.db.count(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(ben),
|
||||
),
|
||||
0,
|
||||
);
|
||||
expect(hostDeleted, [ben]);
|
||||
expect(await AuthUser.db.findById(session, anna), isNotNull);
|
||||
expect(register.asked, isEmpty);
|
||||
});
|
||||
|
||||
test('confirms an account it never had', () async {
|
||||
expect((await tell(const Uuid().v7())).status, 200);
|
||||
expect(hostDeleted, isEmpty);
|
||||
});
|
||||
|
||||
test('refuses without the instance secret, or with a wrong one, '
|
||||
'deleting nobody', () async {
|
||||
for (final authorization in [
|
||||
null,
|
||||
'Bearer wrong',
|
||||
'Basic $_secret',
|
||||
]) {
|
||||
expect(
|
||||
(await tell(ben.uuid, authorization: authorization)).status,
|
||||
401,
|
||||
);
|
||||
}
|
||||
useInstanceSecret(pod, null);
|
||||
expect((await tell(ben.uuid)).status, 401);
|
||||
expect(await AuthUser.db.findById(session, ben), isNotNull);
|
||||
expect(hostDeleted, isEmpty);
|
||||
});
|
||||
|
||||
test('refuses what it cannot read', () async {
|
||||
for (final account in [null, 7, 'not an id']) {
|
||||
expect((await tell(account)).status, 400, reason: '$account');
|
||||
}
|
||||
expect(
|
||||
(await answerRegister(
|
||||
session,
|
||||
'not JSON',
|
||||
authorization: 'Bearer $_secret',
|
||||
)).status,
|
||||
400,
|
||||
);
|
||||
expect(await AuthUser.db.findById(session, ben), isNotNull);
|
||||
});
|
||||
|
||||
test('is answered by no register', () async {
|
||||
useBase(sessionBuilder);
|
||||
|
||||
expect((await tell(ben.uuid)).status, 404);
|
||||
expect(await AuthUser.db.findById(session, ben), isNotNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('pruning', () {
|
||||
late UuidValue carla;
|
||||
|
||||
setUp(() async {
|
||||
carla = (await AuthServices.instance.authUsers.create(
|
||||
session,
|
||||
scopes: {identified},
|
||||
)).id;
|
||||
});
|
||||
|
||||
test('deletes the identified people the register no longer knows, '
|
||||
'asking only about those', () async {
|
||||
final forgotten = await forgetPeopleTheRegisterForgot(
|
||||
session,
|
||||
batch: 1,
|
||||
);
|
||||
|
||||
expect(forgotten, 1);
|
||||
expect(await AuthUser.db.findById(session, carla), isNull);
|
||||
expect(await AuthUser.db.findById(session, ben), isNotNull);
|
||||
expect(await AuthUser.db.findById(session, anna), isNotNull);
|
||||
expect(register.askedAbout.expand((people) => people), {ben, carla});
|
||||
});
|
||||
|
||||
test('deletes nobody while the register knows none of those asked '
|
||||
'about, as a register misnamed or emptied would say', () async {
|
||||
register.accounts.clear();
|
||||
|
||||
expect(await forgetPeopleTheRegisterForgot(session), 0);
|
||||
expect(await AuthUser.db.findById(session, ben), isNotNull);
|
||||
expect(await AuthUser.db.findById(session, carla), isNotNull);
|
||||
});
|
||||
|
||||
test('deletes nobody while the register is out of reach', () async {
|
||||
register.failing = RegisterUnavailableException('test');
|
||||
|
||||
await expectLater(
|
||||
forgetPeopleTheRegisterForgot(session),
|
||||
throwsA(isA<RegisterUnavailableException>()),
|
||||
);
|
||||
expect(await AuthUser.db.findById(session, ben), isNotNull);
|
||||
expect(await AuthUser.db.findById(session, carla), isNotNull);
|
||||
});
|
||||
|
||||
test('does nothing on the register', () async {
|
||||
useBase(sessionBuilder);
|
||||
|
||||
expect(await forgetPeopleTheRegisterForgot(session), 0);
|
||||
expect(register.asked, isEmpty);
|
||||
expect(await AuthUser.db.findById(session, carla), isNotNull);
|
||||
});
|
||||
});
|
||||
|
||||
test('does not start without the secret where it would need it, and '
|
||||
'needs none alone', () {
|
||||
expect(() => checkInstanceSecret(pod), returnsNormally);
|
||||
useInstanceSecret(pod, null);
|
||||
expect(() => checkInstanceSecret(pod), throwsStateError);
|
||||
|
||||
useBase(sessionBuilder, accounts: HoldsRegister(members: [_learn]));
|
||||
expect(() => checkInstanceSecret(pod), throwsStateError);
|
||||
useBase(sessionBuilder);
|
||||
expect(() => checkInstanceSecret(pod), returnsNormally);
|
||||
});
|
||||
|
||||
test('asks the register at its address with the secret, and asks '
|
||||
'nothing without one', () {
|
||||
AccountRegister.use(pod, null);
|
||||
|
||||
expect(
|
||||
AccountRegister.of(pod),
|
||||
isA<RemoteRegister>()
|
||||
.having((remote) => remote.register, 'register', _learn)
|
||||
.having((remote) => remote.secret, 'secret', _secret),
|
||||
);
|
||||
useInstanceSecret(pod, null);
|
||||
expect(() => AccountRegister.of(pod), throwsStateError);
|
||||
expect(
|
||||
MemberNotifier.of(pod),
|
||||
isA<HttpMemberNotifier>().having(
|
||||
(notifier) => notifier.secret,
|
||||
'secret',
|
||||
isNull,
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _deleteAll(Session session) async {
|
||||
await AuthUser.db.deleteWhere(session, where: (t) => t.id.notEquals(null));
|
||||
}
|
||||
|
|
@ -0,0 +1,682 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:halkyon_base_server/base.dart';
|
||||
import 'package:halkyon_base_server/src/register/deletion_notices.dart';
|
||||
import 'package:halkyon_base_server/src/register/instance_api.dart';
|
||||
import 'package:halkyon_base_server/src/register/register_protocol.dart';
|
||||
import 'package:halkyon_base_server/src/register/remote_register.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_auth_idp_server/core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../support/fixtures.dart';
|
||||
import '../test_tools/serverpod_test_tools.dart';
|
||||
|
||||
const _secret = 'a shared secret only the instance knows';
|
||||
final _todo = Uri.parse('http://todo.test:8082');
|
||||
final _train = Uri.parse('http://train.test:8082');
|
||||
|
||||
/// Keeps every code mailed, in place of a mail service.
|
||||
final class _Mailbox implements CodeMailer {
|
||||
final sent = <({String to, String code})>[];
|
||||
|
||||
@override
|
||||
Future<void> send(
|
||||
Session session, {
|
||||
required String to,
|
||||
required String code,
|
||||
required String language,
|
||||
}) async => sent.add((to: to, code: code));
|
||||
|
||||
String codeFor(String address) =>
|
||||
sent.lastWhere((mail) => mail.to == address).code;
|
||||
}
|
||||
|
||||
/// The members as the register tells them, each answering as [answers]
|
||||
/// says (confirming by default).
|
||||
final class _Members implements MemberNotifier {
|
||||
final told = <(Uri, UuidValue)>[];
|
||||
bool Function(Uri member) answers = (_) => true;
|
||||
|
||||
@override
|
||||
Future<bool> accountGone(Uri member, UuidValue account) async {
|
||||
told.add((member, account));
|
||||
return answers(member);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
withServerpod(
|
||||
'The register answering its members',
|
||||
// Mailing codes counts attempts in transactions of their own
|
||||
// (Serverpod's rate limiter), which the test's rollback can't wrap.
|
||||
rollbackDatabase: RollbackDatabase.disabled,
|
||||
(sessionBuilder, endpoints) {
|
||||
late Session session;
|
||||
late Serverpod pod;
|
||||
late _Mailbox mailbox;
|
||||
late _Members members;
|
||||
|
||||
/// The register as a member's server asks it, over HTTP into this
|
||||
/// server's answers.
|
||||
late RemoteRegister remote;
|
||||
|
||||
/// Identified, signing in with ben@example.org and GitHub 1001.
|
||||
late UuidValue ben;
|
||||
|
||||
setUp(() async {
|
||||
useBase(sessionBuilder, accounts: HoldsRegister(members: [_todo]));
|
||||
session = sessionBuilder.build();
|
||||
pod = session.serverpod;
|
||||
await _deleteAll(session);
|
||||
useInstanceSecret(pod, _secret);
|
||||
mailbox = _Mailbox();
|
||||
EmailCodes.of(pod).mailer = mailbox;
|
||||
members = _Members();
|
||||
MemberNotifier.use(pod, members);
|
||||
remote = RemoteRegister(
|
||||
Uri.parse('http://learn.test:8082'),
|
||||
secret: _secret,
|
||||
client: () => MockClient((request) async {
|
||||
final answer = await answerMember(
|
||||
session,
|
||||
request.url.pathSegments.last,
|
||||
request.body,
|
||||
authorization: request.headers['authorization'],
|
||||
);
|
||||
return http.Response.bytes(
|
||||
utf8.encode(jsonEncode(answer.body)),
|
||||
answer.status,
|
||||
);
|
||||
}),
|
||||
);
|
||||
ben = (await AuthServices.instance.authUsers.create(
|
||||
session,
|
||||
scopes: {identified},
|
||||
)).id;
|
||||
await EmailIdentity.db.insertRow(
|
||||
session,
|
||||
EmailIdentity(
|
||||
authUserId: ben,
|
||||
addressHash: addressHash(pod, 'ben@example.org'),
|
||||
createdAt: clock.now(),
|
||||
),
|
||||
);
|
||||
await GitHubAccount.db.insertRow(
|
||||
session,
|
||||
GitHubAccount(userIdentifier: '1001', authUserId: ben),
|
||||
);
|
||||
});
|
||||
tearDown(() async {
|
||||
EmailCodes.of(pod).mailer = null;
|
||||
MemberNotifier.use(pod, null);
|
||||
useInstanceSecret(pod, null);
|
||||
await _deleteAll(sessionBuilder.build());
|
||||
});
|
||||
|
||||
Future<InstanceAnswer> ask(
|
||||
String operation,
|
||||
Map<String, Object?> arguments, {
|
||||
String? authorization = 'Bearer $_secret',
|
||||
}) => answerMember(
|
||||
session,
|
||||
operation,
|
||||
jsonEncode(arguments),
|
||||
authorization: authorization,
|
||||
);
|
||||
|
||||
Matcher refused(Object reason) => throwsA(
|
||||
isA<Exception>().having(
|
||||
(error) => refusalJson(error)?['reason'],
|
||||
'reason',
|
||||
(reason as Enum).name,
|
||||
),
|
||||
);
|
||||
|
||||
group('refuses', () {
|
||||
test('a call without the instance secret, with a wrong one or in '
|
||||
'another scheme, doing nothing', () async {
|
||||
for (final authorization in [
|
||||
null,
|
||||
'',
|
||||
'Bearer',
|
||||
'Bearer ',
|
||||
'Bearer not the secret',
|
||||
'Bearer $_secret ',
|
||||
'Basic $_secret',
|
||||
_secret,
|
||||
]) {
|
||||
final answer = await ask(RegisterOperation.deleteAccount, {
|
||||
'person': ben.uuid,
|
||||
}, authorization: authorization);
|
||||
expect(answer.status, 401, reason: authorization);
|
||||
}
|
||||
expect(await AuthUser.db.findById(session, ben), isNotNull);
|
||||
});
|
||||
|
||||
test(
|
||||
'every call on a register without a secret, whatever is sent',
|
||||
() async {
|
||||
useInstanceSecret(pod, null);
|
||||
|
||||
for (final authorization in [null, 'Bearer ', 'Bearer null']) {
|
||||
final answer = await ask(RegisterOperation.deleteAccount, {
|
||||
'person': ben.uuid,
|
||||
}, authorization: authorization);
|
||||
expect(answer.status, 401, reason: authorization);
|
||||
}
|
||||
expect(await AuthUser.db.findById(session, ben), isNotNull);
|
||||
},
|
||||
);
|
||||
|
||||
test('register calls on a member, which holds no register', () async {
|
||||
useBase(
|
||||
sessionBuilder,
|
||||
accounts: MemberOfRegister(Uri.parse('http://learn.test:8082')),
|
||||
);
|
||||
|
||||
final answer = await ask(RegisterOperation.knownAccounts, {
|
||||
'people': [ben.uuid],
|
||||
});
|
||||
|
||||
expect(answer.status, 404);
|
||||
});
|
||||
|
||||
test('what it cannot read, and operations it does not know', () async {
|
||||
for (final body in ['', 'not JSON', '[]', '"ben"', 'null']) {
|
||||
final answer = await answerMember(
|
||||
session,
|
||||
RegisterOperation.knownAccounts,
|
||||
body,
|
||||
authorization: 'Bearer $_secret',
|
||||
);
|
||||
expect(answer.status, 400, reason: body);
|
||||
}
|
||||
for (final arguments in <Map<String, Object?>>[
|
||||
{},
|
||||
{'people': 'not a list'},
|
||||
{
|
||||
'people': [1, 2],
|
||||
},
|
||||
{
|
||||
'people': ['not an id'],
|
||||
},
|
||||
]) {
|
||||
final answer = await ask(
|
||||
RegisterOperation.knownAccounts,
|
||||
arguments,
|
||||
);
|
||||
expect(answer.status, 400, reason: '$arguments');
|
||||
}
|
||||
expect(
|
||||
(await ask(RegisterOperation.deleteAccount, {
|
||||
'person': 'not an id',
|
||||
})).status,
|
||||
400,
|
||||
);
|
||||
expect((await ask('drop-everything', {})).status, 404);
|
||||
expect(await AuthUser.db.findById(session, ben), isNotNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('profiles', () {
|
||||
test('are saved, read and fixed for a member as for this server, '
|
||||
'the same handle never twice', () async {
|
||||
final anna = const Uuid().v7obj();
|
||||
|
||||
final saved = await remote.saveProfile(
|
||||
session,
|
||||
anna,
|
||||
handle: 'Anna',
|
||||
displayName: ' Anna ',
|
||||
bio: 'Learning Greek.',
|
||||
links: ['https://anna.example.org'],
|
||||
);
|
||||
|
||||
expect(saved.handle, 'Anna');
|
||||
expect(saved.displayName, 'Anna');
|
||||
expect(
|
||||
(await remote.profileOf(session, anna))!.toJson(),
|
||||
saved.toJson(),
|
||||
);
|
||||
expect((await profileOf(session, anna))!.toJson(), saved.toJson());
|
||||
expect(
|
||||
(await remote.profileByHandle(session, 'anna'))!.toJson(),
|
||||
saved.toJson(),
|
||||
);
|
||||
expect(await remote.personWithHandle(session, 'ANNA'), anna);
|
||||
expect((await remote.profilesOf(session, {anna, ben})).keys, [anna]);
|
||||
expect(await remote.checkHandle(session, anna, 'Anna'), isNull);
|
||||
expect(
|
||||
await remote.checkHandle(session, ben, 'anna'),
|
||||
ProfileRefusal.handleTaken,
|
||||
);
|
||||
await expectLater(
|
||||
remote.saveProfile(
|
||||
session,
|
||||
ben,
|
||||
handle: 'anna',
|
||||
displayName: '',
|
||||
bio: '',
|
||||
links: const [],
|
||||
),
|
||||
refused(ProfileRefusal.handleTaken),
|
||||
);
|
||||
|
||||
await remote.fixHandle(session, anna);
|
||||
|
||||
expect((await remote.profileOf(session, anna))!.handleFixed, isTrue);
|
||||
await expectLater(
|
||||
remote.removeProfile(session, anna),
|
||||
refused(ProfileRefusal.published),
|
||||
);
|
||||
});
|
||||
|
||||
test('missing are said to be so', () async {
|
||||
final nobody = const Uuid().v7obj();
|
||||
|
||||
expect(await remote.profileOf(session, nobody), isNull);
|
||||
expect(await remote.profileByHandle(session, 'nobody'), isNull);
|
||||
expect(await remote.personWithHandle(session, 'nobody'), isNull);
|
||||
expect(await remote.profilesOf(session, {nobody}), isEmpty);
|
||||
await expectLater(
|
||||
remote.removeAvatar(session, nobody),
|
||||
throwsA(isA<ProfileNotFoundException>()),
|
||||
);
|
||||
await expectLater(
|
||||
remote.checkLinks(session, nobody),
|
||||
throwsA(isA<ProfileNotFoundException>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('email sign-in', () {
|
||||
test('is offered as the register offers it', () async {
|
||||
expect(await remote.emailSignInOffered(session), isTrue);
|
||||
EmailCodes.of(pod).mailer = null;
|
||||
expect(await remote.emailSignInOffered(session), isFalse);
|
||||
});
|
||||
|
||||
test("counts the codes a member asks for by its device's network, "
|
||||
"not the member's", () async {
|
||||
for (var i = 0; i < 10; i++) {
|
||||
await remote.sendSignInCode(
|
||||
session,
|
||||
address: 'person$i@example.org',
|
||||
language: 'en',
|
||||
network: '203.0.113.7',
|
||||
);
|
||||
}
|
||||
|
||||
await expectLater(
|
||||
remote.sendSignInCode(
|
||||
session,
|
||||
address: 'person10@example.org',
|
||||
language: 'en',
|
||||
network: '203.0.113.7',
|
||||
),
|
||||
refused(EmailSignInRefusal.tooManyCodes),
|
||||
);
|
||||
await remote.sendSignInCode(
|
||||
session,
|
||||
address: 'person10@example.org',
|
||||
language: 'en',
|
||||
network: '198.51.100.4',
|
||||
);
|
||||
expect(mailbox.sent, hasLength(11));
|
||||
});
|
||||
|
||||
test("makes a member's anonymous person an account under the same "
|
||||
'ID, identified, with the address', () async {
|
||||
final annaOnTodo = const Uuid().v7obj();
|
||||
|
||||
final request = await remote.sendSignInCode(
|
||||
session,
|
||||
address: 'Anna@example.org',
|
||||
language: 'de',
|
||||
network: '203.0.113.7',
|
||||
);
|
||||
final accepted = await remote.verifySignInCode(
|
||||
session,
|
||||
person: annaOnTodo,
|
||||
request: request,
|
||||
code: mailbox.codeFor('anna@example.org'),
|
||||
);
|
||||
|
||||
expect(accepted.switchToken, isNull);
|
||||
final account = await AuthUser.db.findById(session, annaOnTodo);
|
||||
expect(account!.scopeNames, contains(identified.name));
|
||||
expect(
|
||||
(await remote.signInWaysOf(session, annaOnTodo)).toJson(),
|
||||
SignInWays(gitHub: false, email: true).toJson(),
|
||||
);
|
||||
expect(await remote.knownAccounts(session, {annaOnTodo, ben}), {
|
||||
annaOnTodo,
|
||||
ben,
|
||||
});
|
||||
});
|
||||
|
||||
test('switches to the account that has the address, once, and '
|
||||
'refuses a wrong code', () async {
|
||||
final annaOnTodo = const Uuid().v7obj();
|
||||
final request = await remote.sendSignInCode(
|
||||
session,
|
||||
address: 'ben@example.org',
|
||||
language: 'en',
|
||||
network: '203.0.113.7',
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
remote.verifySignInCode(
|
||||
session,
|
||||
person: annaOnTodo,
|
||||
request: request,
|
||||
code: 'AAAA-AAAA',
|
||||
),
|
||||
refused(EmailSignInRefusal.wrongCode),
|
||||
);
|
||||
final accepted = await remote.verifySignInCode(
|
||||
session,
|
||||
person: annaOnTodo,
|
||||
request: request,
|
||||
code: mailbox.codeFor('ben@example.org'),
|
||||
);
|
||||
|
||||
expect(accepted.switchToken, isNotNull);
|
||||
expect(await AuthUser.db.findById(session, annaOnTodo), isNull);
|
||||
expect(
|
||||
await remote.completeEmailSwitch(
|
||||
session,
|
||||
switchToken: accepted.switchToken!,
|
||||
),
|
||||
ben,
|
||||
);
|
||||
await expectLater(
|
||||
remote.completeEmailSwitch(
|
||||
session,
|
||||
switchToken: accepted.switchToken!,
|
||||
),
|
||||
refused(EmailSignInRefusal.codeExpired),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('GitHub', () {
|
||||
test("links a member's person, who becomes an account under the "
|
||||
'same ID, found by it afterwards', () async {
|
||||
final annaOnTodo = const Uuid().v7obj();
|
||||
|
||||
await remote.linkGitHub(
|
||||
session,
|
||||
person: annaOnTodo,
|
||||
gitHubUserId: '2002',
|
||||
);
|
||||
|
||||
expect(await remote.accountWithGitHub(session, '2002'), annaOnTodo);
|
||||
expect(await remote.accountWithGitHub(session, '1001'), ben);
|
||||
expect(await remote.accountWithGitHub(session, '3003'), isNull);
|
||||
expect(
|
||||
(await AuthUser.db.findById(session, annaOnTodo))!.scopeNames,
|
||||
contains(identified.name),
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
"refuses another person's GitHub account, keeping nothing",
|
||||
() async {
|
||||
final annaOnTodo = const Uuid().v7obj();
|
||||
|
||||
await expectLater(
|
||||
remote.linkGitHub(
|
||||
session,
|
||||
person: annaOnTodo,
|
||||
gitHubUserId: '1001',
|
||||
),
|
||||
refused(GitHubSignInRefusal.takenByAnotherPerson),
|
||||
);
|
||||
expect(await AuthUser.db.findById(session, annaOnTodo), isNull);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('deleting an account', () {
|
||||
test('deletes it here and tells every member, keeping no notice '
|
||||
'once they confirmed', () async {
|
||||
await remote.deleteAccount(session, ben);
|
||||
|
||||
expect(await AuthUser.db.findById(session, ben), isNull);
|
||||
expect(await EmailIdentity.db.count(session), 0);
|
||||
expect(await GitHubAccount.db.count(session), 0);
|
||||
expect(members.told, [(_todo, ben)]);
|
||||
expect(await AccountDeletionNotice.db.count(session), 0);
|
||||
expect(await remote.knownAccounts(session, {ben}), isEmpty);
|
||||
});
|
||||
|
||||
test(
|
||||
'tells a member that does not answer again until it does',
|
||||
() async {
|
||||
members.answers = (member) => member != _todo;
|
||||
|
||||
await remote.deleteAccount(session, ben);
|
||||
|
||||
expect(await AuthUser.db.findById(session, ben), isNull);
|
||||
final kept = await AccountDeletionNotice.db.find(session);
|
||||
expect(kept.single.account, ben);
|
||||
expect(kept.single.member, '$_todo');
|
||||
expect(kept.single.attempts, 1);
|
||||
expect(kept.single.lastAttemptAt, isNotNull);
|
||||
|
||||
expect(await deliverDeletionNotices(session), 0);
|
||||
expect(
|
||||
(await AccountDeletionNotice.db.find(session)).single.attempts,
|
||||
2,
|
||||
);
|
||||
|
||||
members.answers = (_) => true;
|
||||
expect(await deliverDeletionNotices(session), 1);
|
||||
expect(await AccountDeletionNotice.db.count(session), 0);
|
||||
expect(members.told, [(_todo, ben), (_todo, ben), (_todo, ben)]);
|
||||
},
|
||||
);
|
||||
|
||||
test('tells the members of an account this server no longer has, '
|
||||
'as one may still hold its data', () async {
|
||||
final gone = const Uuid().v7obj();
|
||||
|
||||
await remote.deleteAccount(session, gone);
|
||||
|
||||
expect(members.told, [(_todo, gone)]);
|
||||
});
|
||||
|
||||
test('tells nobody of an anonymous person, a member never having '
|
||||
'had them', () async {
|
||||
final anna = await newAnonymousPerson(session);
|
||||
|
||||
await remote.deleteAccount(session, anna);
|
||||
|
||||
expect(await AuthUser.db.findById(session, anna), isNull);
|
||||
expect(members.told, isEmpty);
|
||||
expect(await AccountDeletionNotice.db.count(session), 0);
|
||||
});
|
||||
|
||||
test(
|
||||
'drops the notices of a member the operator no longer names',
|
||||
() async {
|
||||
members.answers = (_) => false;
|
||||
useBase(
|
||||
sessionBuilder,
|
||||
accounts: HoldsRegister(members: [_todo, _train]),
|
||||
);
|
||||
await remote.deleteAccount(session, ben);
|
||||
expect(await AccountDeletionNotice.db.count(session), 2);
|
||||
|
||||
useBase(sessionBuilder, accounts: HoldsRegister(members: [_train]));
|
||||
await deliverDeletionNotices(session);
|
||||
|
||||
expect(
|
||||
(await AccountDeletionNotice.db.find(session)).single.member,
|
||||
'$_train',
|
||||
);
|
||||
useBase(sessionBuilder);
|
||||
await deliverDeletionNotices(session);
|
||||
expect(await AccountDeletionNotice.db.count(session), 0);
|
||||
},
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
group('A member asking the register', () {
|
||||
late List<http.Request> requests;
|
||||
|
||||
/// A register answering every request with [status] and [body].
|
||||
RemoteRegister answering(int status, String body) {
|
||||
requests = [];
|
||||
return RemoteRegister(
|
||||
Uri.parse('http://learn.test:8082'),
|
||||
secret: _secret,
|
||||
client: () => MockClient((request) async {
|
||||
requests.add(request);
|
||||
return http.Response(body, status);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
test(
|
||||
'posts JSON to the operation with the secret as its bearer token',
|
||||
() async {
|
||||
final account = const Uuid().v7obj();
|
||||
final register = answering(200, '{"account": "${account.uuid}"}');
|
||||
|
||||
expect(await register.accountWithGitHub(_noSession, '1001'), account);
|
||||
final request = requests.single;
|
||||
expect(request.method, 'POST');
|
||||
expect(
|
||||
'${request.url}',
|
||||
'http://learn.test:8082/halkyon-base/register/account-with-github',
|
||||
);
|
||||
expect(request.headers['authorization'], 'Bearer $_secret');
|
||||
expect(jsonDecode(request.body), {'gitHubUserId': '1001'});
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'fails as unavailable for an answer it cannot take, never guessing',
|
||||
() async {
|
||||
for (final (status, body) in [
|
||||
(500, '{}'),
|
||||
(401, '{"error": "secret"}'),
|
||||
(200, 'not JSON'),
|
||||
(200, '[]'),
|
||||
(409, '{"refusal": {"kind": "email", "reason": "unheardOf"}}'),
|
||||
(409, '{"refusal": "no"}'),
|
||||
(404, '{"error": "not here"}'),
|
||||
]) {
|
||||
await expectLater(
|
||||
answering(status, body).accountWithGitHub(_noSession, '1001'),
|
||||
throwsA(isA<RegisterUnavailableException>()),
|
||||
reason: '$status $body',
|
||||
);
|
||||
}
|
||||
await expectLater(
|
||||
RemoteRegister(
|
||||
Uri.parse('http://learn.test:8082'),
|
||||
secret: _secret,
|
||||
client: () => MockClient(
|
||||
(_) async => throw http.ClientException('Connection refused'),
|
||||
),
|
||||
).accountWithGitHub(_noSession, '1001'),
|
||||
throwsA(isA<RegisterUnavailableException>()),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('The register telling a member', () {
|
||||
test("posts the account to the member's path with the secret, and "
|
||||
'counts only a 200 as confirmed', () async {
|
||||
final account = const Uuid().v7obj();
|
||||
final requests = <http.Request>[];
|
||||
var status = 200;
|
||||
final notifier = HttpMemberNotifier(
|
||||
secret: _secret,
|
||||
client: MockClient((request) async {
|
||||
requests.add(request);
|
||||
return http.Response('{}', status);
|
||||
}),
|
||||
);
|
||||
|
||||
expect(await notifier.accountGone(_todo, account), isTrue);
|
||||
final request = requests.single;
|
||||
expect(request.method, 'POST');
|
||||
expect('${request.url}', 'http://todo.test:8082$accountGonePath');
|
||||
expect(request.headers['authorization'], 'Bearer $_secret');
|
||||
expect(jsonDecode(request.body), {'account': account.uuid});
|
||||
|
||||
status = 401;
|
||||
expect(await notifier.accountGone(_todo, account), isFalse);
|
||||
});
|
||||
|
||||
test('counts a failed request as not confirmed, and sends nothing '
|
||||
'without a secret', () async {
|
||||
final account = const Uuid().v7obj();
|
||||
var sent = 0;
|
||||
|
||||
expect(
|
||||
await HttpMemberNotifier(
|
||||
secret: _secret,
|
||||
client: MockClient((_) {
|
||||
sent++;
|
||||
return Future.error(http.ClientException('Connection refused'));
|
||||
}),
|
||||
).accountGone(_todo, account),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
await HttpMemberNotifier(
|
||||
secret: null,
|
||||
client: MockClient((_) async {
|
||||
sent++;
|
||||
return http.Response('{}', 200);
|
||||
}),
|
||||
).accountGone(_todo, account),
|
||||
isFalse,
|
||||
);
|
||||
expect(sent, 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// No session: [RemoteRegister] only passes it on.
|
||||
final Session _noSession = _NoSession();
|
||||
|
||||
final class _NoSession implements Session {
|
||||
@override
|
||||
Object? noSuchMethod(Invocation invocation) =>
|
||||
throw UnsupportedError('no session in this test');
|
||||
}
|
||||
|
||||
Future<void> _deleteAll(Session session) async {
|
||||
await AccountDeletionNotice.db.deleteWhere(session, where: (t) => t.id > 0);
|
||||
await PublicProfile.db.deleteWhere(session, where: (t) => t.id > 0);
|
||||
await RateLimitedRequestAttempt.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => t.id.notEquals(null),
|
||||
);
|
||||
await EmailCodeRequest.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => t.id.notEquals(null),
|
||||
);
|
||||
await SecretChallenge.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => t.id.notEquals(null),
|
||||
);
|
||||
await EmailIdentity.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => t.id.notEquals(null),
|
||||
);
|
||||
await AuthUser.db.deleteWhere(session, where: (t) => t.id.notEquals(null));
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@ void useBase(
|
|||
TestSessionBuilder sessionBuilder, {
|
||||
List<PersonDataDeletion> deletePersonData = const [],
|
||||
ProfilePageNote? profilePageNote,
|
||||
AccountsMode accounts = const HoldsRegister(),
|
||||
}) {
|
||||
final pod = sessionBuilder.build().serverpod;
|
||||
HalkyonBase.configure(
|
||||
|
|
@ -37,6 +38,7 @@ void useBase(
|
|||
minimumAppBuild: testMinimumAppBuild,
|
||||
deletePersonData: deletePersonData,
|
||||
profilePageNote: profilePageNote,
|
||||
accounts: accounts,
|
||||
),
|
||||
);
|
||||
AuthServices.set(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
import 'package:halkyon_base_server/base.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
group('The accounts mode from the environment', () {
|
||||
test('without settings, the server holds its own register, with no '
|
||||
'members', () {
|
||||
final mode = AccountsMode.fromEnvironment(const {});
|
||||
|
||||
expect(mode, isA<HoldsRegister>());
|
||||
expect((mode as HoldsRegister).members, isEmpty);
|
||||
});
|
||||
|
||||
test('the register tells the members it names, separated by commas or '
|
||||
'spaces', () {
|
||||
final mode = AccountsMode.fromEnvironment(const {
|
||||
'INSTANCE_MEMBERS': ' https://todo.example.com, http://todo:8082 ',
|
||||
});
|
||||
|
||||
expect((mode as HoldsRegister).members, [
|
||||
Uri.parse('https://todo.example.com'),
|
||||
Uri.parse('http://todo:8082'),
|
||||
]);
|
||||
});
|
||||
|
||||
test('a register named makes the server its member', () {
|
||||
final mode = AccountsMode.fromEnvironment(const {
|
||||
'ACCOUNT_REGISTER_URL': 'http://learn:8082',
|
||||
'INSTANCE_MEMBERS': 'http://ignored',
|
||||
});
|
||||
|
||||
expect(
|
||||
(mode as MemberOfRegister).register,
|
||||
Uri.parse('http://learn:8082'),
|
||||
);
|
||||
});
|
||||
|
||||
test('empty values count as unset, as Compose passes them on', () {
|
||||
expect(
|
||||
AccountsMode.fromEnvironment(const {
|
||||
'ACCOUNT_REGISTER_URL': '',
|
||||
'INSTANCE_MEMBERS': ' ',
|
||||
}),
|
||||
isA<HoldsRegister>(),
|
||||
);
|
||||
});
|
||||
|
||||
test('anything but an HTTP(S) address is refused, not guessed at', () {
|
||||
expect(
|
||||
() => AccountsMode.fromEnvironment(const {
|
||||
'ACCOUNT_REGISTER_URL': 'learn:8082',
|
||||
}),
|
||||
throwsArgumentError,
|
||||
);
|
||||
expect(
|
||||
() => AccountsMode.fromEnvironment(const {
|
||||
'INSTANCE_MEMBERS': 'ftp://todo',
|
||||
}),
|
||||
throwsArgumentError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('the shared secret is the same only when it is', () {
|
||||
expect(isInstanceSecret('s3cret', 's3cret'), isTrue);
|
||||
expect(isInstanceSecret('s3cret!', 's3cret'), isFalse);
|
||||
expect(isInstanceSecret('', 's3cret'), isFalse);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue