Files
buzz/mobile/test/features/invites/invite_join_provider_test.dart

893 lines
29 KiB
Dart

import 'package:buzz/shared/hosted/hosted_environment.dart';
import 'dart:async';
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart' as http_testing;
import 'package:nostr/nostr.dart' as nostr;
import 'package:pointycastle/digests/sha256.dart';
import 'package:buzz/app.dart';
import 'package:buzz/features/channels/channel.dart';
import 'package:buzz/features/invites/invite_join_provider.dart';
import 'package:buzz/shared/auth/auth.dart';
import 'package:buzz/shared/deeplink/deep_link.dart';
import '../../shared/community/community_storage_test.dart';
void main() {
test(
'receipt-less invite requires current native consent before acceptance and claim',
() async {
final storage = CommunityStorage(secure: FakeSecureStorage());
final auth = _RecordingAuthNotifier();
final requests = <String>[];
var version = 'v1';
final container = ProviderContainer(
overrides: [
communityStorageProvider.overrideWithValue(storage),
authProvider.overrideWith(() => auth),
inviteJoinRecoveryProvider.overrideWithValue(
(_) => _successfulRecovery(),
),
inviteJoinHttpClientProvider.overrideWithValue(
http_testing.MockClient((request) async {
requests.add(request.url.path);
if (request.url.path == '/api/join-policy') {
return http.Response(
jsonEncode({
'policy': {
'version': version,
'terms_markdown': 'Terms',
'age_attestation_required': true,
},
}),
200,
);
}
if (request.url.path == '/api/invites/accept-policy') {
expect(jsonDecode(request.body), {
'code': 'code',
'policy_version': version,
'age_confirmed': true,
});
return http.Response('{"receipt":"fresh"}', 200);
}
expect(jsonDecode(request.body), {
'code': 'code',
'policy_receipt': 'fresh',
});
return http.Response(
'{"status":"joined","host":"relay.example.com","role":"member"}',
200,
);
}),
),
],
);
addTearDown(container.dispose);
final notifier = container.read(inviteJoinProvider.notifier);
await notifier.prepare(
const InviteDeepLink(
relayUrl: 'wss://relay.example.com',
code: 'code',
policyReceipt: 'untrusted-browser-receipt',
),
);
expect(container.read(inviteJoinProvider).joinPolicy?.version, 'v1');
await notifier.confirmJoin();
expect(requests.where((path) => path != '/api/join-policy'), isEmpty);
version = 'v2';
await notifier.confirmJoin(acceptedPolicyVersion: 'v1');
expect(container.read(inviteJoinProvider).joinPolicy?.version, 'v2');
expect(requests.where((path) => path != '/api/join-policy'), isEmpty);
await notifier.confirmJoin(acceptedPolicyVersion: 'v2');
expect(
container.read(inviteJoinProvider).status,
InviteJoinStatus.success,
);
expect(requests.where((path) => path == '/api/invites/claim').length, 1);
},
);
test(
'legacy invite entry refuses staging before key generation or claim',
() async {
final storage = CommunityStorage(secure: FakeSecureStorage());
var generated = 0;
var requests = 0;
final container = ProviderContainer(
overrides: [
communityStorageProvider.overrideWithValue(storage),
inviteKeyGeneratorProvider.overrideWithValue(() {
generated++;
return nostr.Keys.generate();
}),
inviteJoinHttpClientProvider.overrideWithValue(
_policyFreeClient((_) async {
requests++;
return http.Response('{}', 500);
}),
),
],
);
addTearDown(container.dispose);
await container.read(communityListProvider.future);
final notifier = container.read(inviteJoinProvider.notifier);
await notifier.prepare(
const InviteDeepLink(
relayUrl: 'wss://team.communities.public.builderlabstaging.xyz',
code: 'fixture',
),
);
await notifier.confirmJoin();
expect(container.read(inviteJoinProvider).status, InviteJoinStatus.error);
expect(generated, 0);
expect(requests, 0);
expect(await storage.loadAll(), isEmpty);
},
skip: !HostedEnvironment.requiresAuth(
'team.communities.public.builderlabstaging.xyz',
),
);
for (final existingRelayUrl in [
'wss://relay.example.com',
'https://relay.example.com',
]) {
test(
'same-relay invite switches existing $existingRelayUrl before keygen or claim',
() async {
var generatedKeys = 0;
var claimRequests = 0;
final storage = CommunityStorage(secure: FakeSecureStorage());
final existing = Community(
id: 'existing-id',
name: 'Existing',
relayUrl: existingRelayUrl,
pubkey: 'old-pubkey',
nsec: 'old-nsec',
addedAt: DateTime.utc(2026),
);
await storage.save(existing);
final auth = _RecordingAuthNotifier();
final container = ProviderContainer(
overrides: [
communityStorageProvider.overrideWithValue(storage),
authProvider.overrideWith(() => auth),
inviteKeyGeneratorProvider.overrideWithValue(() {
generatedKeys++;
return nostr.Keys.generate();
}),
inviteJoinHttpClientProvider.overrideWithValue(
_policyFreeClient((request) async {
claimRequests++;
return http.Response('{}', 500);
}),
),
],
);
addTearDown(container.dispose);
await container.read(communityListProvider.future);
await container
.read(inviteJoinProvider.notifier)
.prepare(
const InviteDeepLink(
relayUrl: 'wss://relay.example.com',
code: 'code',
),
);
final state = container.read(inviteJoinProvider);
final stored = (await storage.loadAll()).single;
expect(state.status, InviteJoinStatus.switchedExisting);
expect(await storage.loadActiveId(), existing.id);
expect(stored.relayUrl, existingRelayUrl);
expect(stored.pubkey, 'old-pubkey');
expect(stored.nsec, 'old-nsec');
expect(generatedKeys, 0);
expect(claimRequests, 0);
expect(auth.authenticatedCommunities, isEmpty);
},
);
}
test(
'claim posts with freshly-generated key and stores joined community',
() async {
final keys = nostr.Keys.generate();
http.Request? capturedRequest;
final storage = CommunityStorage(secure: FakeSecureStorage());
final auth = _RecordingAuthNotifier();
final container = ProviderContainer(
overrides: [
communityStorageProvider.overrideWithValue(storage),
authProvider.overrideWith(() => auth),
inviteKeyGeneratorProvider.overrideWithValue(() => keys),
inviteJoinRecoveryProvider.overrideWithValue(
(_) => _successfulRecovery(),
),
inviteJoinHttpClientProvider.overrideWithValue(
_policyFreeClient((request) async {
capturedRequest = request;
return http.Response(
jsonEncode({
'status': 'joined',
'community_id': 'community-id',
'host': 'relay.example.com',
'role': 'member',
}),
200,
);
}),
),
],
);
addTearDown(container.dispose);
await container
.read(inviteJoinProvider.notifier)
.prepare(
const InviteDeepLink(
relayUrl: 'wss://relay.example.com',
code: 'code',
),
);
expect(
container.read(inviteJoinProvider).status,
InviteJoinStatus.confirming,
);
await container.read(inviteJoinProvider.notifier).confirmJoin();
final state = container.read(inviteJoinProvider);
expect(state.status, InviteJoinStatus.success);
expect(state.focusChannelId, 'welcome-everyone-id');
expect(capturedRequest, isNotNull);
expect(
capturedRequest!.url.toString(),
'https://relay.example.com/api/invites/claim',
);
expect(capturedRequest!.body, jsonEncode({'code': 'code'}));
final authHeader = capturedRequest!.headers['Authorization'];
expect(authHeader, startsWith('Nostr '));
expect(capturedRequest!.followRedirects, isFalse);
final encoded = authHeader!.substring('Nostr '.length);
final authEvent =
jsonDecode(
utf8.decode(base64Url.decode(base64Url.normalize(encoded))),
)
as Map<String, dynamic>;
final tags = (authEvent['tags'] as List<dynamic>)
.map((tag) => (tag as List<dynamic>).cast<String>())
.toList();
final payloadHash = SHA256Digest()
.process(capturedRequest!.bodyBytes)
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join();
expect(authEvent['kind'], 27235);
expect(tags, contains(equals(['u', capturedRequest!.url.toString()])));
expect(tags, contains(equals(['method', 'POST'])));
expect(tags, contains(equals(['payload', payloadHash])));
expect(auth.authenticatedCommunities, hasLength(1));
expect(
auth.authenticatedCommunities.single.relayUrl,
'wss://relay.example.com',
);
expect(auth.authenticatedCommunities.single.pubkey, keys.public);
expect(auth.authenticatedCommunities.single.nsec, keys.nsec);
expect(
auth.authenticatedCommunities.single.sensitiveActionPolicy,
SensitiveActionPolicy.disabledByUser,
);
},
);
test('prepare rejects an unsafe relay before showing confirmation', () async {
final container = ProviderContainer();
addTearDown(container.dispose);
await expectLater(
container
.read(inviteJoinProvider.notifier)
.prepare(
const InviteDeepLink(relayUrl: 'wss://127.0.0.1', code: 'code'),
),
throwsFormatException,
);
expect(container.read(inviteJoinProvider).status, InviteJoinStatus.idle);
});
test('starter channel ids match desktop for the same relay scope', () async {
expect(
desktopStarterChannelId(
relayHttpOrigin: 'https://relay.example.com/',
slug: 'general',
),
'9ed9563a-84d6-586d-8007-ae294a6dfdaf',
);
expect(
desktopStarterChannelId(
relayHttpOrigin: 'https://relay.example.com',
slug: 'welcome-everyone',
),
'1e288e10-2f7d-5c2c-9a9f-dee58c8daa7a',
);
});
test(
'invite recovery joins existing public general and welcome-everyone',
() async {
final joined = <String>[];
final recovery = MobileInviteJoinRecovery(
loadChannels: () async => [
_channel(id: 'general-id', name: ' General '),
_channel(id: 'welcome-id', name: 'WELCOME-EVERYONE'),
_channel(
id: 'private-welcome-id',
name: 'Welcome',
visibility: 'private',
),
],
createChannel:
({
required channelId,
required name,
required channelType,
required visibility,
description,
ttlSeconds,
}) async => throw StateError('starters already exist'),
joinChannel: (channelId) async => joined.add(channelId),
relayHttpOrigin: 'https://relay.example.com',
);
final focusChannelId = await recovery.ensureStarterChannels();
expect(joined, ['general-id', 'welcome-id']);
expect(focusChannelId, 'welcome-id');
},
);
test(
'invite recovery creates missing public starters with desktop ids',
() async {
final created = <String, Map<String, Object?>>{};
final recovery = MobileInviteJoinRecovery(
loadChannels: () async => const [],
createChannel:
({
required channelId,
required name,
required channelType,
required visibility,
description,
ttlSeconds,
}) async {
created[name] = {
'id': channelId,
'channelType': channelType,
'visibility': visibility,
'description': description,
'ttlSeconds': ttlSeconds,
};
return _channel(id: channelId, name: name, isMember: true);
},
joinChannel: (_) async => fail('creator is already a member'),
relayHttpOrigin: 'https://relay.example.com/',
);
final focusChannelId = await recovery.ensureStarterChannels();
expect(created.keys, ['general', 'welcome-everyone']);
expect(created['general'], {
'id': '9ed9563a-84d6-586d-8007-ae294a6dfdaf',
'channelType': 'stream',
'visibility': 'open',
'description': 'General conversation and community updates.',
'ttlSeconds': null,
});
expect(created['welcome-everyone'], {
'id': '1e288e10-2f7d-5c2c-9a9f-dee58c8daa7a',
'channelType': 'stream',
'visibility': 'open',
'description':
'Say hi, ask a question, or share what brought you here.',
'ttlSeconds': null,
});
expect(focusChannelId, '1e288e10-2f7d-5c2c-9a9f-dee58c8daa7a');
},
);
test(
'duplicate starter creation converges and joins relay channels',
() async {
var loadCount = 0;
final joined = <String>[];
final recovery = MobileInviteJoinRecovery(
loadChannels: () async {
loadCount++;
if (loadCount == 1) return const [];
return [
_channel(id: 'relay-general', name: 'general'),
_channel(id: 'relay-welcome', name: 'welcome-everyone'),
];
},
createChannel:
({
required channelId,
required name,
required channelType,
required visibility,
description,
ttlSeconds,
}) async => throw Exception(
'relay rejected event: duplicate: channel already exists',
),
joinChannel: (channelId) async => joined.add(channelId),
relayHttpOrigin: 'https://relay.example.com',
);
final focusChannelId = await recovery.ensureStarterChannels();
expect(loadCount, 2);
expect(joined, ['relay-general', 'relay-welcome']);
expect(focusChannelId, 'relay-welcome');
},
);
test('starter setup surfaces an unavailable starter for recovery', () async {
final joined = <String>[];
final recovery = MobileInviteJoinRecovery(
loadChannels: () async => [
_channel(id: 'welcome-id', name: 'welcome-everyone'),
],
createChannel:
({
required channelId,
required name,
required channelType,
required visibility,
description,
ttlSeconds,
}) async => throw Exception('relay unavailable'),
joinChannel: (channelId) async => joined.add(channelId),
relayHttpOrigin: 'https://relay.example.com',
);
await expectLater(
recovery.ensureStarterChannels(),
throwsA(isA<Exception>()),
);
expect(joined, isEmpty);
});
test(
'starter setup recovery survives dismissal and container recreation',
() async {
final keys = nostr.Keys.generate();
var generatedKeys = 0;
var claimRequests = 0;
final storage = CommunityStorage(secure: FakeSecureStorage());
final firstContainer = ProviderContainer(
overrides: [
communityStorageProvider.overrideWithValue(storage),
inviteKeyGeneratorProvider.overrideWithValue(() {
generatedKeys++;
return keys;
}),
inviteJoinRecoveryProvider.overrideWithValue(
(_) =>
_FakeInviteJoinRecovery(error: Exception('relay disconnected')),
),
inviteJoinHttpClientProvider.overrideWithValue(
_policyFreeClient((request) async {
claimRequests++;
return http.Response(
jsonEncode({
'status': 'joined',
'host': 'relay.example.com',
'role': 'member',
}),
200,
);
}),
),
],
);
await firstContainer
.read(inviteJoinProvider.notifier)
.prepare(
const InviteDeepLink(
relayUrl: 'wss://relay.example.com',
code: 'code',
),
);
await firstContainer.read(inviteJoinProvider.notifier).confirmJoin();
final failed = firstContainer.read(inviteJoinProvider);
expect(failed.status, InviteJoinStatus.error);
expect(failed.isStarterSetupRecovery, isTrue);
expect(generatedKeys, 1);
expect(claimRequests, 1);
expect((await storage.loadAll()).single.starterSetupIncomplete, isTrue);
firstContainer.dispose();
final secondContainer = ProviderContainer(
overrides: [
communityStorageProvider.overrideWithValue(storage),
inviteKeyGeneratorProvider.overrideWithValue(() {
generatedKeys++;
return nostr.Keys.generate();
}),
inviteJoinRecoveryProvider.overrideWithValue(
(_) => _successfulRecovery(),
),
inviteJoinHttpClientProvider.overrideWithValue(
_policyFreeClient((request) async {
claimRequests++;
return http.Response('{}', 500);
}),
),
],
);
addTearDown(secondContainer.dispose);
await secondContainer
.read(inviteJoinProvider.notifier)
.prepare(
const InviteDeepLink(
relayUrl: 'wss://relay.example.com',
code: 'code',
),
);
await secondContainer
.read(inviteJoinProvider.notifier)
.startStarterSetupRecovery();
final recovered = secondContainer.read(inviteJoinProvider);
expect(recovered.status, InviteJoinStatus.success);
expect(recovered.focusChannelId, 'welcome-everyone-id');
expect((await storage.loadAll()).single.starterSetupIncomplete, isFalse);
expect(generatedKeys, 1);
expect(claimRequests, 1);
},
);
test(
'starter recovery makes no replacement-tenant submission after a scope switch',
() async {
var isScopeCurrent = true;
final firstJoinStarted = Completer<void>();
final releaseFirstJoin = Completer<void>();
final submitted = <String>[];
final recovery = MobileInviteJoinRecovery(
loadChannels: () async => [
_channel(id: 'general-id', name: 'general'),
_channel(id: 'welcome-id', name: 'welcome-everyone'),
],
createChannel:
({
required channelId,
required name,
required channelType,
required visibility,
description,
ttlSeconds,
}) async => throw StateError('starters already exist'),
joinChannel: (channelId) async {
submitted.add(channelId);
if (channelId == 'general-id') {
firstJoinStarted.complete();
await releaseFirstJoin.future;
}
},
relayHttpOrigin: 'https://relay.example.com',
isScopeCurrent: () => isScopeCurrent,
);
final setup = recovery.ensureStarterChannels();
await firstJoinStarted.future;
isScopeCurrent = false;
releaseFirstJoin.complete();
await expectLater(setup, throwsA(isA<StateError>()));
expect(submitted, ['general-id']);
},
);
test('join_policy_required can renew consent without a fresh link', () async {
final keys = nostr.Keys.generate();
var attempts = 0;
final storage = CommunityStorage(secure: FakeSecureStorage());
final container = ProviderContainer(
overrides: [
communityStorageProvider.overrideWithValue(storage),
inviteKeyGeneratorProvider.overrideWithValue(() => keys),
inviteJoinHttpClientProvider.overrideWithValue(
_policyFreeClient((request) async {
attempts++;
return http.Response(
jsonEncode({'error': 'join_policy_required'}),
403,
);
}),
),
],
);
addTearDown(container.dispose);
await container
.read(inviteJoinProvider.notifier)
.prepare(
const InviteDeepLink(
relayUrl: 'wss://relay.example.com',
code: 'code',
policyReceipt: 'expired.receipt',
),
);
await container.read(inviteJoinProvider.notifier).confirmJoin();
final state = container.read(inviteJoinProvider);
expect(state.status, InviteJoinStatus.error);
expect(state.requiresFreshInvite, isFalse);
expect(
state.errorMessage,
'Review and accept the current community policy to continue.',
);
await container.read(inviteJoinProvider.notifier).confirmJoin();
expect(attempts, 2);
});
test('invite_exhausted requires a fresh invite and cannot retry', () async {
final keys = nostr.Keys.generate();
var attempts = 0;
final storage = CommunityStorage(secure: FakeSecureStorage());
final container = ProviderContainer(
overrides: [
communityStorageProvider.overrideWithValue(storage),
inviteKeyGeneratorProvider.overrideWithValue(() => keys),
inviteJoinHttpClientProvider.overrideWithValue(
_policyFreeClient((request) async {
attempts++;
return http.Response(
jsonEncode({'error': 'invite_exhausted'}),
403,
);
}),
),
],
);
addTearDown(container.dispose);
await container
.read(inviteJoinProvider.notifier)
.prepare(
const InviteDeepLink(
relayUrl: 'wss://relay.example.com',
code: 'v2.exhausted-secret',
),
);
await container.read(inviteJoinProvider.notifier).confirmJoin();
final state = container.read(inviteJoinProvider);
expect(state.status, InviteJoinStatus.error);
expect(state.requiresFreshInvite, isTrue);
expect(
state.errorMessage,
'This invite has reached its use limit. Ask for a new invite.',
);
await container.read(inviteJoinProvider.notifier).confirmJoin();
expect(attempts, 1);
});
test(
'failed claim can be retried without reusing a browser receipt',
() async {
final keys = nostr.Keys.generate();
var attempts = 0;
final bodies = <String>[];
final storage = CommunityStorage(secure: FakeSecureStorage());
final auth = _RecordingAuthNotifier();
final container = ProviderContainer(
overrides: [
communityStorageProvider.overrideWithValue(storage),
authProvider.overrideWith(() => auth),
inviteKeyGeneratorProvider.overrideWithValue(() => keys),
inviteJoinRecoveryProvider.overrideWithValue(
(_) => _successfulRecovery(),
),
inviteJoinHttpClientProvider.overrideWithValue(
_policyFreeClient((request) async {
attempts++;
bodies.add(request.body);
if (attempts == 1) {
return http.Response(jsonEncode({'error': 'temporary'}), 503);
}
return http.Response(
jsonEncode({
'status': 'joined',
'host': 'relay.example.com',
'role': 'member',
}),
200,
);
}),
),
],
);
addTearDown(container.dispose);
await container
.read(inviteJoinProvider.notifier)
.prepare(
const InviteDeepLink(
relayUrl: 'wss://relay.example.com',
code: 'code',
policyReceipt: 'receipt.value',
),
);
await container.read(inviteJoinProvider.notifier).confirmJoin();
expect(container.read(inviteJoinProvider).status, InviteJoinStatus.error);
await container.read(inviteJoinProvider.notifier).confirmJoin();
expect(
container.read(inviteJoinProvider).status,
InviteJoinStatus.success,
);
expect(attempts, 2);
expect(bodies, everyElement(jsonEncode({'code': 'code'})));
expect(auth.authenticatedCommunities, hasLength(1));
},
);
test('builds a fresh recovery with the second community identity', () async {
final firstKeys = nostr.Keys.generate();
final secondKeys = nostr.Keys.generate();
final scopes = <InviteJoinRecoveryScope>[];
final recoveries = <InviteJoinRecoveryScope>[];
var nextKeys = 0;
final container = ProviderContainer(
overrides: [
communityStorageProvider.overrideWithValue(
CommunityStorage(secure: FakeSecureStorage()),
),
authProvider.overrideWith(_RecordingAuthNotifier.new),
inviteKeyGeneratorProvider.overrideWithValue(() {
final keys = nextKeys == 0 ? firstKeys : secondKeys;
nextKeys++;
return keys;
}),
inviteJoinRecoveryProvider.overrideWithValue((scope) {
scopes.add(scope);
return _RecordingInviteJoinRecovery(() async {
recoveries.add(scope);
return 'welcome-everyone-id';
});
}),
inviteJoinHttpClientProvider.overrideWithValue(
_policyFreeClient(
(request) async => http.Response(
jsonEncode({
'status': 'joined',
'host': request.url.host,
'role': 'member',
}),
200,
),
),
),
],
);
addTearDown(container.dispose);
for (final invite in const [
InviteDeepLink(relayUrl: 'wss://first.example.com', code: 'first'),
InviteDeepLink(relayUrl: 'wss://second.example.com', code: 'second'),
]) {
await container.read(inviteJoinProvider.notifier).prepare(invite);
await container.read(inviteJoinProvider.notifier).confirmJoin();
expect(
container.read(inviteJoinProvider).status,
InviteJoinStatus.success,
);
}
expect(scopes.map((scope) => scope.relayHttpOrigin), [
'https://first.example.com',
'https://second.example.com',
]);
expect(scopes.map((scope) => scope.nsec), [
firstKeys.nsec,
secondKeys.nsec,
]);
expect(recoveries, hasLength(2));
expect(identical(recoveries[0], scopes[0]), isTrue);
expect(identical(recoveries[1], scopes[1]), isTrue);
expect(identical(recoveries[1], scopes[0]), isFalse);
});
}
InviteJoinRecovery _successfulRecovery() =>
const _FakeInviteJoinRecovery(focusChannelId: 'welcome-everyone-id');
class _FakeInviteJoinRecovery implements InviteJoinRecovery {
final String? focusChannelId;
final Object? error;
const _FakeInviteJoinRecovery({this.focusChannelId, this.error});
@override
Future<String?> ensureStarterChannels() async {
if (error case final failure?) throw failure;
return focusChannelId;
}
}
class _RecordingInviteJoinRecovery implements InviteJoinRecovery {
const _RecordingInviteJoinRecovery(this._ensure);
final Future<String?> Function() _ensure;
@override
Future<String?> ensureStarterChannels() => _ensure();
}
Channel _channel({
required String id,
required String name,
String visibility = 'open',
bool isMember = false,
}) => Channel(
id: id,
name: name,
channelType: 'stream',
visibility: visibility,
description: '',
createdBy: 'me',
createdAt: DateTime.utc(2026),
memberCount: isMember ? 1 : 0,
isMember: isMember,
);
class _RecordingAuthNotifier extends AuthNotifier {
final List<Community> authenticatedCommunities = [];
@override
Future<AuthState> build() async =>
const AuthState(status: AuthStatus.unauthenticated);
@override
Future<void> authenticateWithCommunity(
Community community, {
bool Function()? shouldContinue,
bool activate = true,
}) async {
if (shouldContinue != null && !shouldContinue()) return;
final storage = ref.read(communityStorageProvider);
await storage.save(community);
await storage.saveActiveId(community.id);
ref.invalidate(communityListProvider);
ref.invalidate(activeCommunityProvider);
authenticatedCommunities.add(community);
state = AsyncData(
AuthState(status: AuthStatus.authenticated, community: community),
);
}
}
http.Client _policyFreeClient(
Future<http.Response> Function(http.Request) claim,
) => http_testing.MockClient((request) async {
if (request.url.path == '/api/join-policy') return http.Response('{}', 200);
return claim(request);
});