From a1d1703b383ce05d661c758553043f65021ab5c4 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 26 Oct 2026 21:35:57 +1100 Subject: [PATCH] fix(invites): collect native consent for app invite handoff Co-authored-by: Jitter Signed-off-by: Matt Toohey --- .../onboarding/communityOnboarding.tsx | 3 + .../onboarding/ui/CommunityOnboardingFlow.tsx | 25 +- .../onboarding/ui/InviteRedeemForm.tsx | 5 +- .../onboarding/useClaimInvite.jsdom-test.mjs | 190 +++++++++++++++ .../src/features/onboarding/useClaimInvite.ts | 51 +++- desktop/tests/e2e/deep-link-invite.spec.ts | 58 +++++ mobile/ios/Runner/NativeCommunityEntry.swift | 4 +- .../channels/deep_link_dispatcher.dart | 1 + .../invites/invite_join_provider.dart | 56 ++++- .../features/invites/invite_join_sheet.dart | 73 ++++-- .../onboarding/hosted_connect_sheet.dart | 2 +- .../lib/shared/hosted/hosted_join_policy.dart | 77 +++++-- .../hosted}/hosted_join_policy_view.dart | 5 +- .../channels/deep_link_dispatcher_test.dart | 88 ++++++- .../invites/invite_join_provider_test.dart | 218 ++++++++++++------ .../invites/invite_join_sheet_test.dart | 131 +++++++++++ 16 files changed, 850 insertions(+), 137 deletions(-) create mode 100644 desktop/src/features/onboarding/useClaimInvite.jsdom-test.mjs rename mobile/lib/{features/onboarding => shared/hosted}/hosted_join_policy_view.dart (95%) diff --git a/desktop/src/features/onboarding/communityOnboarding.tsx b/desktop/src/features/onboarding/communityOnboarding.tsx index 99da68aa95..ad21c33467 100644 --- a/desktop/src/features/onboarding/communityOnboarding.tsx +++ b/desktop/src/features/onboarding/communityOnboarding.tsx @@ -15,6 +15,7 @@ export type CommunityOnboardingSource = | "deep-link-join"; export type CommunityOnboardingStage = + | "invite-consent" | "claiming" | "connecting" | "profile" @@ -61,6 +62,7 @@ export type CommunityOnboardingTransactionPatch = Partial< Pick< CommunityOnboardingTransaction, | "stage" + | "policyReceipt" | "relayUrl" | "communityId" | "previousCommunityId" @@ -106,6 +108,7 @@ function isTransaction( typeof transaction.createdAt === "string" && typeof transaction.updatedAt === "string" && [ + "invite-consent", "claiming", "connecting", "profile", diff --git a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx index cec2cd1e2a..b742edf804 100644 --- a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx @@ -30,6 +30,7 @@ import { cn } from "@/shared/lib/cn"; import { useSystemColorScheme } from "@/shared/theme/useSystemColorScheme"; import { Button } from "@/shared/ui/button"; import { Dialog, DialogContent, DialogTitle } from "@/shared/ui/dialog"; +import { InviteRedeemForm } from "./InviteRedeemForm"; import { MembershipDenied } from "./MembershipDenied"; import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome"; import { OnboardingCard, useOnboardingCardLayout } from "./OnboardingCard"; @@ -525,8 +526,28 @@ export function CommunityOnboardingFlow({ )} data-testid="community-onboarding-body" > - {transaction.stage === "claiming" || - transaction.stage === "connecting" ? ( + {transaction.stage === "invite-consent" ? ( + <> +

+ Join {transaction.communityName} +

+ + update( + { policyReceipt, stage: "claiming", error: undefined }, + transaction.id, + ) + } + /> + + ) : transaction.stage === "claiming" || + transaction.stage === "connecting" ? ( <>

diff --git a/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx b/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx index 52b9ebbd07..da5705341c 100644 --- a/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx +++ b/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx @@ -52,6 +52,7 @@ type InviteRedeemFormProps = { defaultRelayUrl?: string; error: string | null; initialValue?: string; + readOnlyInvite?: boolean; isRedeeming: boolean; onCancel: () => void; onConnect?: (relayWsUrl: string) => void; @@ -64,6 +65,7 @@ export function InviteRedeemForm({ defaultRelayUrl, error, initialValue = "", + readOnlyInvite = false, isRedeeming, onCancel, onConnect, @@ -405,7 +407,8 @@ export function InviteRedeemForm({ { + if (command === "sign_event") + return JSON.stringify({ id: "signed", tags: [] }); + throw new Error(`Unexpected command ${command}`); + }, +}; +window.__TAURI_INTERNALS__ = tauri; +globalThis.__TAURI_INTERNALS__ = tauri; + +for (const configured of [true, false]) { + test(`receipt-less invite ${configured ? "waits for native consent" : "claims a policy-free relay"}`, async () => { + localStorage.clear(); + const requests = []; + const original = globalThis.fetch; + globalThis.fetch = async (url) => { + requests.push(String(url)); + if (String(url).endsWith("/api/join-policy")) + return Response.json(configured ? { policy } : {}); + return Response.json({ + status: "joined", + community_id: "id", + host: "relay.example", + role: "member", + }); + }; + try { + startCommunityOnboarding({ + source: "deep-link-join", + relayUrl: "wss://relay.example", + inviteCode: "code", + }); + render( + React.createElement( + CommunityOnboardingProvider, + null, + React.createElement(Claim), + ), + ); + await waitFor(() => + assert.equal( + controls.transaction.stage, + configured ? "invite-consent" : "connecting", + ), + ); + assert.equal( + requests.filter((url) => url.endsWith("/claim")).length, + configured ? 0 : 1, + ); + } finally { + cleanup(); + globalThis.fetch = original; + } + }); +} + +test("rejected receipt returns to native consent and clears the stale proof", async () => { + localStorage.clear(); + const original = globalThis.fetch; + globalThis.fetch = async () => + Response.json({ error: "join_policy_required" }, { status: 403 }); + try { + startCommunityOnboarding({ + source: "deep-link-join", + relayUrl: "wss://relay.example", + inviteCode: "code", + policyReceipt: "expired", + }); + render( + React.createElement( + CommunityOnboardingProvider, + null, + React.createElement(Claim), + ), + ); + await waitFor(() => + assert.equal(controls.transaction.stage, "invite-consent"), + ); + assert.equal(controls.transaction.policyReceipt, undefined); + assert.equal(controls.transaction.error, undefined); + } finally { + cleanup(); + globalThis.fetch = original; + } +}); + +test("policy lookup failure never claims and can be retried", async () => { + localStorage.clear(); + const original = globalThis.fetch; + let failed = true; + let claims = 0; + globalThis.fetch = async (url) => { + if (String(url).endsWith("/api/join-policy")) { + if (failed) throw new Error("offline"); + return Response.json({ policy }); + } + claims++; + throw new Error("unexpected claim"); + }; + try { + startCommunityOnboarding({ + source: "deep-link-join", + relayUrl: "wss://relay.example", + inviteCode: "code", + }); + render( + React.createElement( + CommunityOnboardingProvider, + null, + React.createElement(Claim), + ), + ); + await waitFor(() => assert.equal(controls.transaction.error, "offline")); + failed = false; + await act(async () => controls.update({ error: undefined })); + await waitFor(() => + assert.equal(controls.transaction.stage, "invite-consent"), + ); + assert.equal(claims, 0); + } finally { + cleanup(); + globalThis.fetch = original; + } +}); + +test("cancelled policy discovery cannot claim after its response arrives", async () => { + localStorage.clear(); + const original = globalThis.fetch; + let resolvePolicy; + let started; + const lookupStarted = new Promise((resolve) => { + started = resolve; + }); + const response = new Promise((resolve) => { + resolvePolicy = resolve; + }); + let claims = 0; + globalThis.fetch = async (url) => { + if (String(url).endsWith("/api/join-policy")) { + started(); + return response; + } + claims++; + throw new Error("unexpected claim"); + }; + try { + startCommunityOnboarding({ + source: "deep-link-join", + relayUrl: "wss://relay.example", + inviteCode: "code", + }); + render( + React.createElement( + CommunityOnboardingProvider, + null, + React.createElement(Claim), + ), + ); + await lookupStarted; + await act(async () => controls.clear()); + await act(async () => resolvePolicy(Response.json({}))); + assert.equal(controls.transaction, null); + assert.equal(claims, 0); + } finally { + cleanup(); + globalThis.fetch = original; + } +}); diff --git a/desktop/src/features/onboarding/useClaimInvite.ts b/desktop/src/features/onboarding/useClaimInvite.ts index 3134953617..c60b933fbe 100644 --- a/desktop/src/features/onboarding/useClaimInvite.ts +++ b/desktop/src/features/onboarding/useClaimInvite.ts @@ -6,7 +6,7 @@ import { isInviteExhaustedError, isInviteExpiredError, } from "@/shared/api/inviteHelpers"; -import { claimInvite } from "@/shared/api/invites"; +import { claimInvite, getJoinPolicy } from "@/shared/api/invites"; /** * Drive the `claiming` stage after machine onboarding completes: claim the @@ -21,21 +21,48 @@ import { claimInvite } from "@/shared/api/invites"; export function useClaimInvite() { const { transaction, update } = useCommunityOnboarding(); const [isPending, setIsPending] = React.useState(false); + const currentTransaction = React.useRef(transaction); + React.useEffect(() => { + currentTransaction.current = transaction; + return () => { + currentTransaction.current = null; + }; + }, [transaction]); React.useEffect(() => { if (transaction?.stage !== "claiming" || transaction.error || isPending) { return; } setIsPending(true); - void claimInvite( - transaction.relayUrl, - transaction.inviteCode ?? "", - transaction.policyReceipt, - ) - .then(() => { - update({ stage: "connecting", error: undefined }, transaction.id); - }) - .catch((error: unknown) => + void (async () => { + const policy = !transaction.policyReceipt + ? await getJoinPolicy(transaction.relayUrl, "webview") + : null; + if (currentTransaction.current !== transaction) return; + if (policy) { + update({ stage: "invite-consent" }, transaction.id); + return; + } + await claimInvite( + transaction.relayUrl, + transaction.inviteCode ?? "", + transaction.policyReceipt, + ); + update({ stage: "connecting", error: undefined }, transaction.id); + })() + .catch((error: unknown) => { + if (currentTransaction.current !== transaction) return; + if (inviteErrorMessage(error).includes("join_policy_required")) { + update( + { + stage: "invite-consent", + policyReceipt: undefined, + error: undefined, + }, + transaction.id, + ); + return; + } update( { error: isInviteExpiredError(error) @@ -45,8 +72,8 @@ export function useClaimInvite() { : inviteErrorMessage(error), }, transaction.id, - ), - ) + ); + }) .finally(() => setIsPending(false)); }, [isPending, transaction, update]); } diff --git a/desktop/tests/e2e/deep-link-invite.spec.ts b/desktop/tests/e2e/deep-link-invite.spec.ts index 42c4f8ea9f..173ad8497e 100644 --- a/desktop/tests/e2e/deep-link-invite.spec.ts +++ b/desktop/tests/e2e/deep-link-invite.spec.ts @@ -413,3 +413,61 @@ test("persisted deep-link invite hands off to Joining after machine onboarding", // The claim was attempted and its failure surfaced with a Retry. await expect(page.getByRole("button", { name: "Retry" })).toBeVisible(); }); + +test("receipt-less app handoff collects consent before claiming", async ({ + page, +}) => { + const policy = { + version: "policy-v1", + terms_markdown: "Terms", + privacy_markdown: "Privacy", + age_attestation_required: true, + }; + let claims = 0; + await page.route("**/api/join-policy", (route) => + route.fulfill({ json: { policy } }), + ); + await page.route("**/api/invites/accept-policy", (route) => { + expect(route.request().postDataJSON()).toEqual({ + code: PENDING_JOIN_LINK.code, + policy_version: policy.version, + age_confirmed: true, + }); + return route.fulfill({ json: { receipt: "native-consent" } }); + }); + await page.route("**/api/invites/claim", (route) => { + claims++; + expect(route.request().postDataJSON()).toEqual({ + code: PENDING_JOIN_LINK.code, + policy_receipt: "native-consent", + }); + return route.fulfill({ + json: { + status: "joined", + community_id: "hive", + host: "hive.example.com", + role: "member", + }, + }); + }); + await installMockBridge(page, { + pendingCommunityDeepLinks: [PENDING_JOIN_LINK], + }); + await page.goto("/"); + const age = page.getByRole("checkbox", { + name: "I am 18 years of age or older.", + }); + await expect(age).toBeVisible(); + const accept = page.getByRole("button", { name: "Accept and redeem invite" }); + await expect(accept).toBeDisabled(); + expect(claims).toBe(0); + await age.check(); + await page + .getByRole("checkbox", { + name: /I agree to the Buzz Terms of Service and Privacy Policy/, + }) + .check(); + await accept.click(); + await expect.poll(() => claims).toBe(1); + await expect(age).toHaveCount(0); +}); diff --git a/mobile/ios/Runner/NativeCommunityEntry.swift b/mobile/ios/Runner/NativeCommunityEntry.swift index 9338c4041a..6f1448256d 100644 --- a/mobile/ios/Runner/NativeCommunityEntry.swift +++ b/mobile/ios/Runner/NativeCommunityEntry.swift @@ -321,7 +321,9 @@ private final class NativeCommunityEntryController: UIViewController, UITextFiel UIAccessibility.post(notification: .announcement, argument: error) } self.navigationController?.sheetPresentationController?.invalidateDetents() - if result["joined"] as? Bool == true { + if result["continueInFlutter"] as? Bool == true { + self.finish?(false) + } else if result["joined"] as? Bool == true { self.input.text = nil self.finish?(true) } else { completion?(result) } diff --git a/mobile/lib/features/channels/deep_link_dispatcher.dart b/mobile/lib/features/channels/deep_link_dispatcher.dart index 177163ebbd..4416c91fde 100644 --- a/mobile/lib/features/channels/deep_link_dispatcher.dart +++ b/mobile/lib/features/channels/deep_link_dispatcher.dart @@ -225,6 +225,7 @@ class _DeepLinkDispatcherState extends ConsumerState { final inviteState = ref.read(inviteJoinProvider); final status = inviteState.status; if (status == InviteJoinStatus.confirming || + status == InviteJoinStatus.error || inviteState.isStarterSetupRecovery) { final sheet = showInviteJoinSheet(navigatorContext); if (status == InviteJoinStatus.claiming && diff --git a/mobile/lib/features/invites/invite_join_provider.dart b/mobile/lib/features/invites/invite_join_provider.dart index b7a6a7731d..3ddcaac83f 100644 --- a/mobile/lib/features/invites/invite_join_provider.dart +++ b/mobile/lib/features/invites/invite_join_provider.dart @@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nostr/nostr.dart' as nostr; import '../../shared/hosted/hosted_relay_auth.dart'; +import '../../shared/hosted/hosted_join_policy.dart'; import '../../shared/hosted/community_entry_requirements.dart'; import '../../shared/auth/auth.dart'; import '../../shared/deeplink/deep_link.dart'; @@ -75,6 +76,7 @@ class InviteJoinState { final bool requiresFreshInvite; final bool isStarterSetupRecovery; final String? focusChannelId; + final HostedJoinPolicy? joinPolicy; const InviteJoinState({ this.status = InviteJoinStatus.idle, @@ -85,6 +87,7 @@ class InviteJoinState { this.requiresFreshInvite = false, this.isStarterSetupRecovery = false, this.focusChannelId, + this.joinPolicy, }); InviteJoinState copyWith({ @@ -96,7 +99,11 @@ class InviteJoinState { bool? requiresFreshInvite, bool? isStarterSetupRecovery, Object? focusChannelId = _unset, + Object? joinPolicy = _unset, }) => InviteJoinState( + joinPolicy: identical(joinPolicy, _unset) + ? this.joinPolicy + : joinPolicy as HostedJoinPolicy?, status: status ?? this.status, invite: invite ?? this.invite, host: host ?? this.host, @@ -156,9 +163,25 @@ class InviteJoinNotifier extends Notifier { host: _hostFromRelay(invite.relayUrl), communityName: Community.nameFromUrl(invite.relayUrl), ); + try { + CommunityEntryRequirements.requireSelfHosted(invite.relayUrl); + final policy = await loadJoinPolicy( + client: ref.read(inviteJoinHttpClientProvider), + origin: Uri.parse(invite.relayUrl), + allowLegacyRelay: true, + ); + if (!ref.mounted || state.invite != invite) return; + state = state.copyWith(joinPolicy: policy); + } catch (error) { + if (!ref.mounted || state.invite != invite) return; + state = state.copyWith( + status: InviteJoinStatus.error, + errorMessage: _friendlyInviteError(error), + ); + } } - Future confirmJoin() async { + Future confirmJoin({String? acceptedPolicyVersion}) async { final invite = state.invite; if (invite == null || state.requiresFreshInvite || @@ -202,11 +225,28 @@ class InviteJoinNotifier extends Notifier { return; } + final receipt = await freshJoinPolicyReceipt( + client: ref.read(inviteJoinHttpClientProvider), + origin: Uri.parse(invite.relayUrl), + code: invite.code, + allowLegacyRelay: true, + checkCurrent: () { + if (!ref.mounted || state.invite != invite) { + throw StateError('Invite changed'); + } + }, + confirm: (policy) async { + final accepted = + state.joinPolicy?.version == policy.version && + acceptedPolicyVersion == policy.version; + state = state.copyWith(joinPolicy: policy); + return accepted; + }, + ); final keys = ref.read(inviteKeyGeneratorProvider)(); final body = jsonEncode({ 'code': invite.code, - if (invite.policyReceipt != null) - 'policy_receipt': invite.policyReceipt, + 'policy_receipt': ?receipt, }); final relayUri = Uri.parse(invite.relayUrl); validateInviteRelayUri(relayUri); @@ -254,6 +294,7 @@ class InviteJoinNotifier extends Notifier { state = state.copyWith(isStarterSetupRecovery: true); await startStarterSetupRecovery(); } catch (error) { + if (!ref.mounted || state.invite != invite) return; final requiresFreshInvite = _requiresFreshInvite(error); state = state.copyWith( status: InviteJoinStatus.error, @@ -431,8 +472,8 @@ String _communityNameFromClaim(Map claim, String relayUrl) { bool _requiresFreshInvite(Object error) { final message = error.toString(); - return message.contains('join_policy_required') || - message.contains('invite_exhausted'); + return message.contains('invite_exhausted') || + error == HostedInviteFailure.exhausted; } String _friendlyInviteError(Object error) { @@ -442,8 +483,9 @@ String _friendlyInviteError(Object error) { return 'This invite has reached its use limit. Ask for a new invite.'; } if (message.contains('invite_invalid')) return 'This invite is not valid.'; - if (message.contains('join_policy_required')) { - return 'This invite approval has expired. Re-open the invite link to try again.'; + if (message.contains('join_policy_required') || + error == HostedInviteFailure.policyRequired) { + return 'Review and accept the current community policy to continue.'; } if (message.contains('SocketException') || message.contains('Connection refused') || diff --git a/mobile/lib/features/invites/invite_join_sheet.dart b/mobile/lib/features/invites/invite_join_sheet.dart index 8039ad82c7..b3882bb03f 100644 --- a/mobile/lib/features/invites/invite_join_sheet.dart +++ b/mobile/lib/features/invites/invite_join_sheet.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart'; + import '../../shared/identity_transfer/native_community_entry.dart'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:buzz/shared/theme/buzz_icons.dart'; @@ -9,13 +11,16 @@ import '../../shared/widgets/buzz_loading_indicator.dart'; import '../../shared/widgets/modal_presentation.dart'; import '../pairing/pairing_page.dart'; import 'invite_join_provider.dart'; +import '../../shared/hosted/hosted_join_policy_view.dart'; Future showInviteJoinSheet(BuildContext context) async { final container = ProviderScope.containerOf(context); final state = container.read(inviteJoinProvider); + var continueWithPolicy = false; if (defaultTargetPlatform == TargetPlatform.iOS && state.status == InviteJoinStatus.confirming && - !state.isStarterSetupRecovery) { + !state.isStarterSetupRecovery && + state.joinPolicy == null) { final joined = await NativeCommunityEntry.show( name: state.host ?? 'Unknown community', dark: Theme.of(context).brightness == Brightness.dark, @@ -31,12 +36,18 @@ Future showInviteJoinSheet(BuildContext context) async { } await container.read(inviteJoinProvider.notifier).confirmJoin(); final result = container.read(inviteJoinProvider); + if (result.joinPolicy != null && + result.status == InviteJoinStatus.error && + !result.requiresFreshInvite) { + continueWithPolicy = true; + return {'continueInFlutter': true}; + } return result.status == InviteJoinStatus.success ? {'joined': true} : {'error': result.errorMessage ?? 'Could not join. Try again.'}; }, ); - if (joined == false) return false; + if (joined == false && !continueWithPolicy) return false; if (!context.mounted) return joined; // Preserve the existing success screen and starter-channel destination. } @@ -170,6 +181,19 @@ class InviteJoinSheet extends ConsumerWidget { ), ], const SizedBox(height: Grid.lg), + if (state.joinPolicy != null && + !state.requiresFreshInvite && + !isStarterSetupRecovery && + !isClaiming) + HostedJoinPolicyView( + key: ValueKey(state.joinPolicy!.version), + policy: state.joinPolicy!, + onAccept: () => ref + .read(inviteJoinProvider.notifier) + .confirmJoin( + acceptedPolicyVersion: state.joinPolicy!.version, + ), + ), Row( children: [ Expanded( @@ -181,28 +205,31 @@ class InviteJoinSheet extends ConsumerWidget { ), ), const SizedBox(width: Grid.sm), - Expanded( - child: FilledButton.icon( - onPressed: isClaiming || state.requiresFreshInvite - ? null - : () async => ref - .read(inviteJoinProvider.notifier) - .confirmJoin(), - icon: isClaiming - ? SizedBox( - width: 16, - height: 16, - child: BuzzLoadingIndicator( - size: 16, - semanticLabel: isStarterSetupRecovery - ? 'Finishing setup' - : 'Joining community', - ), - ) - : const Icon(BuzzIcons.check), - label: Text(primaryLabel), + if (state.joinPolicy == null || + isStarterSetupRecovery || + isClaiming) + Expanded( + child: FilledButton.icon( + onPressed: isClaiming || state.requiresFreshInvite + ? null + : () async => ref + .read(inviteJoinProvider.notifier) + .confirmJoin(), + icon: isClaiming + ? SizedBox( + width: 16, + height: 16, + child: BuzzLoadingIndicator( + size: 16, + semanticLabel: isStarterSetupRecovery + ? 'Finishing setup' + : 'Joining community', + ), + ) + : const Icon(BuzzIcons.check), + label: Text(primaryLabel), + ), ), - ), ], ), ], diff --git a/mobile/lib/features/onboarding/hosted_connect_sheet.dart b/mobile/lib/features/onboarding/hosted_connect_sheet.dart index 9e0f1bb965..843f85447b 100644 --- a/mobile/lib/features/onboarding/hosted_connect_sheet.dart +++ b/mobile/lib/features/onboarding/hosted_connect_sheet.dart @@ -8,7 +8,7 @@ import '../../shared/utils/string_utils.dart'; import '../../shared/theme/grid.dart'; import '../../shared/theme/theme_extensions.dart'; import 'hosted_community_selection.dart'; -import 'hosted_join_policy_view.dart'; +import '../../shared/hosted/hosted_join_policy_view.dart'; import 'hosted_profile_setup.dart'; /// Presents the app-scoped real flow without retaining credentials or keys. diff --git a/mobile/lib/shared/hosted/hosted_join_policy.dart b/mobile/lib/shared/hosted/hosted_join_policy.dart index d91ac8dfde..278d5e3aa1 100644 --- a/mobile/lib/shared/hosted/hosted_join_policy.dart +++ b/mobile/lib/shared/hosted/hosted_join_policy.dart @@ -1,5 +1,7 @@ import 'dart:convert'; + import 'package:http/http.dart' as http; + import 'builderlab_account.dart'; import 'bounded_http_response.dart'; @@ -22,12 +24,14 @@ enum HostedInviteFailure { policyRequired, expired, exhausted, invalid } /// Reads only a bounded JSON document, never follows redirects or logs bodies. Future> readJoinResponse( http.Client client, - http.Request request, -) async { + http.Request request, { + bool allowNotFound = false, +}) async { final http.Response response; final Map data; try { response = await boundedHttpResponse(client, request); + if (allowNotFound && response.statusCode == 404) return {}; if (response.statusCode == 429 || (response.statusCode >= 500 && response.statusCode < 600)) { throw AccountFailure.unavailable; @@ -57,6 +61,7 @@ Future freshJoinPolicyReceipt({ required String code, required Future Function(HostedJoinPolicy) confirm, required void Function() checkCurrent, + bool allowLegacyRelay = false, }) async { Future> send( String method, @@ -66,7 +71,12 @@ Future freshJoinPolicyReceipt({ checkCurrent(); final request = http.Request( method, - origin.replace(scheme: 'https', path: path), + origin.replace( + scheme: origin.scheme == 'ws' || origin.scheme == 'http' + ? 'http' + : 'https', + path: path, + ), )..followRedirects = false; if (body != null) { request.headers['Content-Type'] = 'application/json'; @@ -77,23 +87,14 @@ Future freshJoinPolicyReceipt({ return data; } - final data = await send('GET', '/api/join-policy'); - final raw = data['policy']; - if (raw == null) return null; - if (raw is! Map || - raw['version'] is! String || - (raw['version'] as String).isEmpty || - raw['age_attestation_required'] is! bool || - (raw['terms_markdown'] != null && raw['terms_markdown'] is! String) || - (raw['privacy_markdown'] != null && raw['privacy_markdown'] is! String)) { - throw AccountFailure.invalidResponse; - } - final policy = HostedJoinPolicy( - version: raw['version'] as String, - ageRequired: raw['age_attestation_required'] as bool, - terms: raw['terms_markdown'] as String?, - privacy: raw['privacy_markdown'] as String?, + checkCurrent(); + final policy = await loadJoinPolicy( + client: client, + origin: origin, + allowLegacyRelay: allowLegacyRelay, ); + checkCurrent(); + if (policy == null) return null; if (!await confirm(policy)) throw HostedInviteFailure.policyRequired; checkCurrent(); final accepted = await send('POST', '/api/invites/accept-policy', { @@ -107,3 +108,41 @@ Future freshJoinPolicyReceipt({ } return receipt; } + +/// Fetch a policy without accepting it; legacy self-hosted relays may omit it. +Future loadJoinPolicy({ + required http.Client client, + required Uri origin, + bool allowLegacyRelay = false, +}) async { + final request = http.Request( + 'GET', + origin.replace( + scheme: origin.scheme == 'ws' || origin.scheme == 'http' + ? 'http' + : 'https', + path: '/api/join-policy', + ), + )..followRedirects = false; + final data = await readJoinResponse( + client, + request, + allowNotFound: allowLegacyRelay, + ); + final raw = data['policy']; + if (raw == null) return null; + if (raw is! Map || + raw['version'] is! String || + (raw['version'] as String).isEmpty || + raw['age_attestation_required'] is! bool || + (raw['terms_markdown'] != null && raw['terms_markdown'] is! String) || + (raw['privacy_markdown'] != null && raw['privacy_markdown'] is! String)) { + throw AccountFailure.invalidResponse; + } + return HostedJoinPolicy( + version: raw['version'] as String, + ageRequired: raw['age_attestation_required'] as bool, + terms: raw['terms_markdown'] as String?, + privacy: raw['privacy_markdown'] as String?, + ); +} diff --git a/mobile/lib/features/onboarding/hosted_join_policy_view.dart b/mobile/lib/shared/hosted/hosted_join_policy_view.dart similarity index 95% rename from mobile/lib/features/onboarding/hosted_join_policy_view.dart rename to mobile/lib/shared/hosted/hosted_join_policy_view.dart index 8900e49ae0..fa11764a8c 100644 --- a/mobile/lib/features/onboarding/hosted_join_policy_view.dart +++ b/mobile/lib/shared/hosted/hosted_join_policy_view.dart @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import '../../shared/hosted/hosted_join_policy.dart'; -import '../../shared/theme/grid.dart'; + +import 'hosted_join_policy.dart'; +import '../theme/grid.dart'; /// Consent is explicit and scoped to the policy version shown on this screen. class HostedJoinPolicyView extends HookWidget { diff --git a/mobile/test/features/channels/deep_link_dispatcher_test.dart b/mobile/test/features/channels/deep_link_dispatcher_test.dart index 72c9abda5e..07dbf10f66 100644 --- a/mobile/test/features/channels/deep_link_dispatcher_test.dart +++ b/mobile/test/features/channels/deep_link_dispatcher_test.dart @@ -1,4 +1,6 @@ import 'dart:async'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; import 'package:buzz/features/channels/channel.dart'; import 'package:buzz/features/channels/channels_provider.dart'; @@ -15,6 +17,45 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/community/community_storage_test.dart'; void main() { + testWidgets('policy discovery failure opens the invite retry sheet', ( + tester, + ) async { + const link = InviteDeepLink( + relayUrl: 'wss://relay.example.com', + code: 'code', + ); + final container = ProviderContainer( + overrides: [ + communityStorageProvider.overrideWithValue( + CommunityStorage(secure: FakeSecureStorage()), + ), + pendingDeepLinkProvider.overrideWith( + () => _FakePendingDeepLinkNotifier(link), + ), + inviteJoinHttpClientProvider.overrideWithValue( + MockClient((_) async => http.Response('{}', 503)), + ), + ], + ); + addTearDown(container.dispose); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const MaterialApp( + home: DeepLinkDispatcher(child: Scaffold(body: SizedBox())), + ), + ), + ); + await tester.pumpAndSettle(); + expect(container.read(inviteJoinProvider).status, InviteJoinStatus.error); + expect( + find.textContaining('Could not join this community'), + findsOneWidget, + ); + expect(find.widgetWithText(FilledButton, 'Join'), findsOneWidget); + expect(find.text('Cancel'), findsOneWidget); + }); + for (final outcome in ['handled', 'declined', 'failed']) { testWidgets('hosted handler drains queued links after $outcome setup', ( tester, @@ -47,6 +88,9 @@ void main() { await tester.pumpWidget( ProviderScope( overrides: [ + inviteJoinHttpClientProvider.overrideWithValue( + MockClient((_) async => http.Response('{}', 200)), + ), pendingDeepLinkProvider.overrideWith(() => pending), inviteKeyGeneratorProvider.overrideWithValue( () => throw StateError('must not generate'), @@ -117,6 +161,9 @@ void main() { await tester.pumpWidget( ProviderScope( overrides: [ + inviteJoinHttpClientProvider.overrideWithValue( + MockClient((_) async => http.Response('{}', 200)), + ), pendingDeepLinkProvider.overrideWith(() => pending), channelsProvider.overrideWith( () => _FakeChannelsNotifier(Future.value([_channel])), @@ -154,6 +201,9 @@ void main() { await tester.pumpWidget( ProviderScope( overrides: [ + inviteJoinHttpClientProvider.overrideWithValue( + MockClient((_) async => http.Response('{}', 200)), + ), pendingDeepLinkProvider.overrideWith( () => _FakePendingDeepLinkNotifier(link), ), @@ -190,6 +240,9 @@ void main() { await tester.pumpWidget( ProviderScope( overrides: [ + inviteJoinHttpClientProvider.overrideWithValue( + MockClient((_) async => http.Response('{}', 200)), + ), pendingDeepLinkProvider.overrideWith( () => _FakePendingDeepLinkNotifier(link), ), @@ -231,6 +284,9 @@ void main() { final container = ProviderContainer( overrides: [ + inviteJoinHttpClientProvider.overrideWithValue( + MockClient((_) async => http.Response('{}', 200)), + ), communityStorageProvider.overrideWithValue(storage), communitySnapshotWriterProvider.overrideWithValue((_) async {}), pendingDeepLinkProvider.overrideWith( @@ -291,6 +347,9 @@ void main() { ); final container = ProviderContainer( overrides: [ + inviteJoinHttpClientProvider.overrideWithValue( + MockClient((_) async => http.Response('{}', 200)), + ), communityStorageProvider.overrideWithValue(_ThrowingCommunityStorage()), pendingDeepLinkProvider.overrideWith( () => _FakePendingDeepLinkNotifier(link), @@ -331,6 +390,9 @@ void main() { final pending = _RecordingPendingDeepLinkNotifier(link); final container = ProviderContainer( overrides: [ + inviteJoinHttpClientProvider.overrideWithValue( + MockClient((_) async => http.Response('{}', 200)), + ), communityStorageProvider.overrideWithValue(storage), pendingDeepLinkProvider.overrideWith(() => pending), channelsProvider.overrideWith( @@ -364,6 +426,9 @@ void main() { ); final container = ProviderContainer( overrides: [ + inviteJoinHttpClientProvider.overrideWithValue( + MockClient((_) async => http.Response('{}', 200)), + ), pendingDeepLinkProvider.overrideWith( () => _FakePendingDeepLinkNotifier(link), ), @@ -409,6 +474,9 @@ void main() { final recovery = _DeferredInviteJoinRecovery(); final container = ProviderContainer( overrides: [ + inviteJoinHttpClientProvider.overrideWithValue( + MockClient((_) async => http.Response('{}', 200)), + ), communityStorageProvider.overrideWithValue(storage), pendingDeepLinkProvider.overrideWith( () => _FakePendingDeepLinkNotifier( @@ -483,6 +551,9 @@ void main() { final recovery = _DeferredInviteJoinRecovery(); final container = ProviderContainer( overrides: [ + inviteJoinHttpClientProvider.overrideWithValue( + MockClient((_) async => http.Response('{}', 200)), + ), communityStorageProvider.overrideWithValue(storage), pendingDeepLinkProvider.overrideWith( () => _FakePendingDeepLinkNotifier( @@ -533,6 +604,9 @@ void main() { final pending = _QueuedPendingDeepLinkNotifier([first, second]); final container = ProviderContainer( overrides: [ + inviteJoinHttpClientProvider.overrideWithValue( + MockClient((_) async => http.Response('{}', 200)), + ), communityStorageProvider.overrideWithValue( CommunityStorage(secure: FakeSecureStorage()), ), @@ -578,6 +652,9 @@ void main() { await tester.pumpWidget( ProviderScope( overrides: [ + inviteJoinHttpClientProvider.overrideWithValue( + MockClient((_) async => http.Response('{}', 200)), + ), communityStorageProvider.overrideWithValue( CommunityStorage(secure: FakeSecureStorage()), ), @@ -619,6 +696,9 @@ void main() { final inviteStorage = CommunityStorage(secure: FakeSecureStorage()); final inviteContainer = ProviderContainer( overrides: [ + inviteJoinHttpClientProvider.overrideWithValue( + MockClient((_) async => http.Response('{}', 200)), + ), communityStorageProvider.overrideWithValue(inviteStorage), pendingDeepLinkProvider.overrideWith( () => _FakePendingDeepLinkNotifier( @@ -650,6 +730,9 @@ void main() { final messageContainer = ProviderContainer( overrides: [ + inviteJoinHttpClientProvider.overrideWithValue( + MockClient((_) async => http.Response('{}', 200)), + ), pendingDeepLinkProvider.overrideWith( () => _FakePendingDeepLinkNotifier( const MessageDeepLink( @@ -691,6 +774,9 @@ void main() { ); final container = ProviderContainer( overrides: [ + inviteJoinHttpClientProvider.overrideWithValue( + MockClient((_) async => http.Response('{}', 200)), + ), pendingDeepLinkProvider.overrideWith( () => _FakePendingDeepLinkNotifier(invite), ), @@ -786,7 +872,7 @@ class _SuccessfulInviteJoinNotifier extends InviteJoinNotifier { } @override - Future confirmJoin() async { + Future confirmJoin({String? acceptedPolicyVersion}) async { state = state.copyWith( status: InviteJoinStatus.success, focusChannelId: 'welcome-everyone-id', diff --git a/mobile/test/features/invites/invite_join_provider_test.dart b/mobile/test/features/invites/invite_join_provider_test.dart index cd350b673e..3af074fc40 100644 --- a/mobile/test/features/invites/invite_join_provider_test.dart +++ b/mobile/test/features/invites/invite_join_provider_test.dart @@ -18,6 +18,80 @@ 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 = []; + 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 { @@ -32,7 +106,7 @@ void main() { return nostr.Keys.generate(); }), inviteJoinHttpClientProvider.overrideWithValue( - http_testing.MockClient((_) async { + _policyFreeClient((_) async { requests++; return http.Response('{}', 500); }), @@ -88,7 +162,7 @@ void main() { return nostr.Keys.generate(); }), inviteJoinHttpClientProvider.overrideWithValue( - http_testing.MockClient((request) async { + _policyFreeClient((request) async { claimRequests++; return http.Response('{}', 500); }), @@ -137,7 +211,7 @@ void main() { (_) => _successfulRecovery(), ), inviteJoinHttpClientProvider.overrideWithValue( - http_testing.MockClient((request) async { + _policyFreeClient((request) async { capturedRequest = request; return http.Response( jsonEncode({ @@ -411,7 +485,7 @@ void main() { _FakeInviteJoinRecovery(error: Exception('relay disconnected')), ), inviteJoinHttpClientProvider.overrideWithValue( - http_testing.MockClient((request) async { + _policyFreeClient((request) async { claimRequests++; return http.Response( jsonEncode({ @@ -455,7 +529,7 @@ void main() { (_) => _successfulRecovery(), ), inviteJoinHttpClientProvider.overrideWithValue( - http_testing.MockClient((request) async { + _policyFreeClient((request) async { claimRequests++; return http.Response('{}', 500); }), @@ -527,7 +601,7 @@ void main() { }, ); - test('join_policy_required requires a fresh link and cannot retry', () async { + 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()); @@ -536,7 +610,7 @@ void main() { communityStorageProvider.overrideWithValue(storage), inviteKeyGeneratorProvider.overrideWithValue(() => keys), inviteJoinHttpClientProvider.overrideWithValue( - http_testing.MockClient((request) async { + _policyFreeClient((request) async { attempts++; return http.Response( jsonEncode({'error': 'join_policy_required'}), @@ -561,14 +635,14 @@ void main() { final state = container.read(inviteJoinProvider); expect(state.status, InviteJoinStatus.error); - expect(state.requiresFreshInvite, isTrue); + expect(state.requiresFreshInvite, isFalse); expect( state.errorMessage, - 'This invite approval has expired. Re-open the invite link to try again.', + 'Review and accept the current community policy to continue.', ); await container.read(inviteJoinProvider.notifier).confirmJoin(); - expect(attempts, 1); + expect(attempts, 2); }); test('invite_exhausted requires a fresh invite and cannot retry', () async { @@ -580,7 +654,7 @@ void main() { communityStorageProvider.overrideWithValue(storage), inviteKeyGeneratorProvider.overrideWithValue(() => keys), inviteJoinHttpClientProvider.overrideWithValue( - http_testing.MockClient((request) async { + _policyFreeClient((request) async { attempts++; return http.Response( jsonEncode({'error': 'invite_exhausted'}), @@ -614,65 +688,66 @@ void main() { expect(attempts, 1); }); - test('failed claim can be retried and preserves policy receipt', () async { - final keys = nostr.Keys.generate(); - var attempts = 0; - final bodies = []; - 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( - http_testing.MockClient((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', + test( + 'failed claim can be retried without reusing a browser receipt', + () async { + final keys = nostr.Keys.generate(); + var attempts = 0; + final bodies = []; + final storage = CommunityStorage(secure: FakeSecureStorage()); + final auth = _RecordingAuthNotifier(); + final container = ProviderContainer( + overrides: [ + communityStorageProvider.overrideWithValue(storage), + authProvider.overrideWith(() => auth), + inviteKeyGeneratorProvider.overrideWithValue(() => keys), + inviteJoinRecoveryProvider.overrideWithValue( + (_) => _successfulRecovery(), ), - ); - await container.read(inviteJoinProvider.notifier).confirmJoin(); - expect(container.read(inviteJoinProvider).status, InviteJoinStatus.error); + 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).confirmJoin(); + 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); - expect(container.read(inviteJoinProvider).status, InviteJoinStatus.success); - expect(attempts, 2); - expect( - bodies, - everyElement( - jsonEncode({'code': 'code', 'policy_receipt': 'receipt.value'}), - ), - ); - expect(auth.authenticatedCommunities, hasLength(1)); - }); + 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(); @@ -699,7 +774,7 @@ void main() { }); }), inviteJoinHttpClientProvider.overrideWithValue( - http_testing.MockClient( + _policyFreeClient( (request) async => http.Response( jsonEncode({ 'status': 'joined', @@ -808,3 +883,10 @@ class _RecordingAuthNotifier extends AuthNotifier { ); } } + +http.Client _policyFreeClient( + Future Function(http.Request) claim, +) => http_testing.MockClient((request) async { + if (request.url.path == '/api/join-policy') return http.Response('{}', 200); + return claim(request); +}); diff --git a/mobile/test/features/invites/invite_join_sheet_test.dart b/mobile/test/features/invites/invite_join_sheet_test.dart index 876429b7d9..1744c45162 100644 --- a/mobile/test/features/invites/invite_join_sheet_test.dart +++ b/mobile/test/features/invites/invite_join_sheet_test.dart @@ -1,11 +1,122 @@ +import 'dart:async'; +import 'package:flutter/services.dart'; import 'package:buzz/features/invites/invite_join_provider.dart'; import 'package:buzz/features/invites/invite_join_sheet.dart'; import 'package:flutter/material.dart'; +import 'package:buzz/shared/hosted/hosted_join_policy.dart'; import 'package:flutter_test/flutter_test.dart'; import '../../helpers/widget_helpers.dart'; void main() { + testWidgets( + 'iOS confirmation hands a newly required policy back to Flutter', + (tester) async { + const channel = MethodChannel('buzz/community_entry'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + final dismissed = Completer(); + Map? presentation; + messenger.setMockMethodCallHandler(channel, (call) async { + presentation = call.arguments as Map; + return dismissed.future; + }); + addTearDown(() => messenger.setMockMethodCallHandler(channel, null)); + await tester.pumpWidget( + WidgetHelpers.testable( + child: const _InviteJoinSheetLauncher(), + overrides: [ + inviteJoinProvider.overrideWith(_NewPolicyInviteJoinNotifier.new), + ], + ), + ); + await tester.tap(find.text('Open invite')); + await tester.pumpAndSettle(); + expect(presentation, isNotNull); + final response = Completer>(); + messenger.handlePlatformMessage( + channel.name, + const StandardMethodCodec().encodeMethodCall( + MethodCall('join', {'session': presentation!['session']}), + ), + (data) => response.complete( + const StandardMethodCodec().decodeEnvelope(data!) as Map, + ), + ); + await tester.pump(); + expect(await response.future, {'continueInFlutter': true}); + dismissed.complete(false); + await tester.pumpAndSettle(); + expect(find.text('Accept and join'), findsOneWidget); + expect(find.text('Terms of service'), findsOneWidget); + expect(find.text('Cancel'), findsOneWidget); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + + testWidgets('exhausted policy invite has no enabled acceptance action', ( + tester, + ) async { + await tester.pumpWidget( + WidgetHelpers.testable( + child: const InviteJoinSheet(), + overrides: [ + inviteJoinProvider.overrideWith( + () => _StaticInviteJoinNotifier( + const InviteJoinState( + status: InviteJoinStatus.error, + host: 'relay.example.com', + requiresFreshInvite: true, + errorMessage: 'Ask for a new invite.', + joinPolicy: HostedJoinPolicy( + version: 'v1', + ageRequired: false, + terms: 'Terms', + ), + ), + ), + ), + ], + ), + ); + expect(find.text('Accept and join'), findsNothing); + expect(find.text('Ask for a new invite.'), findsOneWidget); + expect(find.text('Cancel'), findsOneWidget); + }); + + testWidgets( + 'policy consent requires age confirmation and exposes cancellation', + (tester) async { + await tester.pumpWidget( + WidgetHelpers.testable( + child: const InviteJoinSheet(), + overrides: [ + inviteJoinProvider.overrideWith( + () => _StaticInviteJoinNotifier( + const InviteJoinState( + status: InviteJoinStatus.confirming, + host: 'relay.example.com', + joinPolicy: HostedJoinPolicy( + version: 'v1', + ageRequired: true, + terms: 'Terms', + ), + ), + ), + ), + ], + ), + ); + final accept = find.widgetWithText(FilledButton, 'Accept and join'); + expect(tester.widget(accept).onPressed, isNull); + expect(find.text('Cancel'), findsOneWidget); + expect(find.widgetWithText(FilledButton, 'Join'), findsNothing); + await tester.tap(find.byType(CheckboxListTile)); + await tester.pump(); + expect(tester.widget(accept).onPressed, isNotNull); + }, + ); + testWidgets('recovery error is scrollable and exposes retry setup', ( tester, ) async { @@ -117,3 +228,23 @@ class _RecoveryErrorInviteJoinNotifier extends InviteJoinNotifier { isStarterSetupRecovery: true, ); } + +class _NewPolicyInviteJoinNotifier extends InviteJoinNotifier { + @override + InviteJoinState build() => const InviteJoinState( + status: InviteJoinStatus.confirming, + host: 'relay.example.com', + ); + + @override + Future confirmJoin({String? acceptedPolicyVersion}) async { + state = state.copyWith( + status: InviteJoinStatus.error, + joinPolicy: const HostedJoinPolicy( + version: 'new', + ageRequired: false, + terms: 'Terms', + ), + ); + } +}