From 7a35f29d490cea7ffbdc0cc5b43448e1b0ff3b4e 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 | 185 +++++++++++++++++- .../src/features/onboarding/useClaimInvite.ts | 56 ++++-- 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 | 86 +++++--- .../hosted}/hosted_join_policy_view.dart | 7 +- .../channels/deep_link_dispatcher_test.dart | 88 ++++++++- .../invites/invite_join_provider_test.dart | 177 +++++++++++++++-- .../invites/invite_join_sheet_test.dart | 131 +++++++++++++ 16 files changed, 859 insertions(+), 98 deletions(-) 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; + } +}); + for (const [sentinel, guidance] of [ [ "invite_restricted", @@ -46,6 +227,8 @@ for (const [sentinel, guidance] of [ retryObserved = resolve; }); globalThis.fetch = async (url, options) => { + // Receipt-less claims first discover the policy; this relay has none. + if (String(url).endsWith("/api/join-policy")) return Response.json({}); assert.equal(url, "https://relay.example/api/invites/claim"); assert.equal(JSON.parse(options.body).code, "same-code"); attempts++; diff --git a/desktop/src/features/onboarding/useClaimInvite.ts b/desktop/src/features/onboarding/useClaimInvite.ts index f1eed59a8e..206031c7a7 100644 --- a/desktop/src/features/onboarding/useClaimInvite.ts +++ b/desktop/src/features/onboarding/useClaimInvite.ts @@ -1,8 +1,11 @@ import * as React from "react"; import { useCommunityOnboarding } from "@/features/onboarding/communityOnboarding"; -import { inviteClaimErrorMessage } from "@/shared/api/inviteHelpers"; -import { claimInvite } from "@/shared/api/invites"; +import { + inviteClaimErrorMessage, + inviteErrorMessage, +} from "@/shared/api/inviteHelpers"; +import { claimInvite, getJoinPolicy } from "@/shared/api/invites"; /** * Drive the `claiming` stage after machine onboarding completes: claim the @@ -17,28 +20,55 @@ 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: inviteClaimErrorMessage(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 24e975b0b6..1fe1234195 100644 --- a/mobile/ios/Runner/NativeCommunityEntry.swift +++ b/mobile/ios/Runner/NativeCommunityEntry.swift @@ -323,7 +323,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 7867e29c41..16ab318f74 100644 --- a/mobile/lib/features/channels/deep_link_dispatcher.dart +++ b/mobile/lib/features/channels/deep_link_dispatcher.dart @@ -304,6 +304,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 75411095c1..cc91f057d4 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, @@ -157,9 +164,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 || @@ -204,6 +227,24 @@ 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 storage = ref.read(communityStorageProvider); final nsec = await storage.pendingInviteIdentity( invite.relayUrl, @@ -217,8 +258,7 @@ class InviteJoinNotifier extends Notifier { final keys = nostr.Keys(decodedKey.data); 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); @@ -267,6 +307,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, @@ -458,8 +499,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) { @@ -475,8 +516,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 748af4a2e7..cc575f5229 100644 --- a/mobile/lib/features/onboarding/hosted_connect_sheet.dart +++ b/mobile/lib/features/onboarding/hosted_connect_sheet.dart @@ -13,7 +13,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 19f8018b54..62dde22dfe 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'; @@ -30,12 +32,14 @@ enum HostedInviteFailure { Future> readJoinResponse( http.Client client, http.Request request, { + bool allowNotFound = false, int maxBytes = 65536, }) async { final http.Response response; final Map data; try { response = await boundedHttpResponse(client, request, maxBytes: maxBytes); + if (allowNotFound && response.statusCode == 404) return {}; if (response.statusCode == 429 || (response.statusCode >= 500 && response.statusCode < 600)) { throw AccountFailure.unavailable; @@ -67,6 +71,7 @@ Future freshJoinPolicyReceipt({ required String code, required Future Function(HostedJoinPolicy) confirm, required void Function() checkCurrent, + bool allowLegacyRelay = false, }) async { Future> send( String method, @@ -76,41 +81,30 @@ 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'; request.body = jsonEncode(body); } - // The relay permits two 256 KiB Markdown documents. Allow their JSON - // escaping (up to six bytes per input byte), plus the policy envelope. - // Receipt and claim responses retain the ordinary 64 KiB bound. - final data = await readJoinResponse( - client, - request, - maxBytes: path == '/api/join-policy' ? 4 * 1024 * 1024 : 65536, - ); + final data = await readJoinResponse(client, request); checkCurrent(); 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', { @@ -124,3 +118,45 @@ 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; + // The relay permits two 256 KiB Markdown documents. Allow their JSON + // escaping (up to six bytes per input byte), plus the policy envelope. + // Receipt and claim responses retain the ordinary 64 KiB bound. + final data = await readJoinResponse( + client, + request, + allowNotFound: allowLegacyRelay, + maxBytes: 4 * 1024 * 1024, + ); + 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 5b1c0ab23e..87783b0fbd 100644 --- a/mobile/lib/features/onboarding/hosted_join_policy_view.dart +++ b/mobile/lib/shared/hosted/hosted_join_policy_view.dart @@ -1,8 +1,9 @@ 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 '../../shared/widgets/onboarding_step_page.dart'; + +import 'hosted_join_policy.dart'; +import '../theme/grid.dart'; +import '../widgets/onboarding_step_page.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 b213b925ff..e316b51f33 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'; @@ -16,6 +18,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, @@ -48,6 +89,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'), @@ -119,6 +163,9 @@ void main() { await tester.pumpWidget( ProviderScope( overrides: [ + inviteJoinHttpClientProvider.overrideWithValue( + MockClient((_) async => http.Response('{}', 200)), + ), pendingDeepLinkProvider.overrideWith(() => pending), relaySessionProvider.overrideWith(() => session), channelsProvider.overrideWith( @@ -540,6 +587,9 @@ void main() { await tester.pumpWidget( ProviderScope( overrides: [ + inviteJoinHttpClientProvider.overrideWithValue( + MockClient((_) async => http.Response('{}', 200)), + ), pendingDeepLinkProvider.overrideWith( () => _FakePendingDeepLinkNotifier(link), ), @@ -576,6 +626,9 @@ void main() { await tester.pumpWidget( ProviderScope( overrides: [ + inviteJoinHttpClientProvider.overrideWithValue( + MockClient((_) async => http.Response('{}', 200)), + ), pendingDeepLinkProvider.overrideWith( () => _FakePendingDeepLinkNotifier(link), ), @@ -617,6 +670,9 @@ void main() { final container = ProviderContainer( overrides: [ + inviteJoinHttpClientProvider.overrideWithValue( + MockClient((_) async => http.Response('{}', 200)), + ), communityStorageProvider.overrideWithValue(storage), communitySnapshotWriterProvider.overrideWithValue((_) async {}), pendingDeepLinkProvider.overrideWith( @@ -677,6 +733,9 @@ void main() { ); final container = ProviderContainer( overrides: [ + inviteJoinHttpClientProvider.overrideWithValue( + MockClient((_) async => http.Response('{}', 200)), + ), communityStorageProvider.overrideWithValue(_ThrowingCommunityStorage()), pendingDeepLinkProvider.overrideWith( () => _FakePendingDeepLinkNotifier(link), @@ -712,6 +771,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( @@ -745,6 +807,9 @@ void main() { ); final container = ProviderContainer( overrides: [ + inviteJoinHttpClientProvider.overrideWithValue( + MockClient((_) async => http.Response('{}', 200)), + ), pendingDeepLinkProvider.overrideWith( () => _FakePendingDeepLinkNotifier(link), ), @@ -790,6 +855,9 @@ void main() { final recovery = _DeferredInviteJoinRecovery(); final container = ProviderContainer( overrides: [ + inviteJoinHttpClientProvider.overrideWithValue( + MockClient((_) async => http.Response('{}', 200)), + ), communityStorageProvider.overrideWithValue(storage), pendingDeepLinkProvider.overrideWith( () => _FakePendingDeepLinkNotifier( @@ -864,6 +932,9 @@ void main() { final recovery = _DeferredInviteJoinRecovery(); final container = ProviderContainer( overrides: [ + inviteJoinHttpClientProvider.overrideWithValue( + MockClient((_) async => http.Response('{}', 200)), + ), communityStorageProvider.overrideWithValue(storage), pendingDeepLinkProvider.overrideWith( () => _FakePendingDeepLinkNotifier( @@ -917,6 +988,9 @@ void main() { final pending = _QueuedPendingDeepLinkNotifier([first, second]); final container = ProviderContainer( overrides: [ + inviteJoinHttpClientProvider.overrideWithValue( + MockClient((_) async => http.Response('{}', 200)), + ), communityStorageProvider.overrideWithValue( CommunityStorage(secure: FakeSecureStorage()), ), @@ -962,6 +1036,9 @@ void main() { await tester.pumpWidget( ProviderScope( overrides: [ + inviteJoinHttpClientProvider.overrideWithValue( + MockClient((_) async => http.Response('{}', 200)), + ), communityStorageProvider.overrideWithValue( CommunityStorage(secure: FakeSecureStorage()), ), @@ -1003,6 +1080,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( @@ -1034,6 +1114,9 @@ void main() { final messageContainer = ProviderContainer( overrides: [ + inviteJoinHttpClientProvider.overrideWithValue( + MockClient((_) async => http.Response('{}', 200)), + ), pendingDeepLinkProvider.overrideWith( () => _FakePendingDeepLinkNotifier( const MessageDeepLink( @@ -1075,6 +1158,9 @@ void main() { ); final container = ProviderContainer( overrides: [ + inviteJoinHttpClientProvider.overrideWithValue( + MockClient((_) async => http.Response('{}', 200)), + ), pendingDeepLinkProvider.overrideWith( () => _FakePendingDeepLinkNotifier(invite), ), @@ -1170,7 +1256,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 1ea3533a84..f2be2ea38d 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'}), @@ -638,7 +712,7 @@ void main() { (_) => _successfulRecovery(), ), inviteJoinHttpClientProvider.overrideWithValue( - http_testing.MockClient((request) async { + _policyFreeClient((request) async { attempts++; expect(jsonDecode(request.body)['code'], 'same-code'); return attempts == 1 @@ -699,7 +773,7 @@ void main() { (_) => _successfulRecovery(), ), inviteJoinHttpClientProvider.overrideWithValue( - http_testing.MockClient((request) async { + _policyFreeClient((request) async { attempts++; bodies.add(request.body); final event = @@ -757,12 +831,7 @@ void main() { InviteJoinStatus.success, ); expect(attempts, 2); - expect( - bodies, - everyElement( - jsonEncode({'code': 'code', 'policy_receipt': 'receipt.value'}), - ), - ); + expect(bodies, everyElement(jsonEncode({'code': 'code'}))); expect((await storage.loadAll()).single.pubkey, claimedPubkey); expect( (await secure.readAll()).keys.where( @@ -842,6 +911,67 @@ void main() { }, ); + 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(), + ), + 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(); @@ -867,7 +997,7 @@ void main() { }); }), inviteJoinHttpClientProvider.overrideWithValue( - http_testing.MockClient( + _policyFreeClient( (request) async => http.Response( jsonEncode({ 'status': 'joined', @@ -976,3 +1106,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', + ), + ); + } +}