mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
fix(invites): collect native consent for app invite handoff
Co-authored-by: Jitter <d14dfe033ef0f809866f9f984de04821b0d900d7652fd85a54776ee40ca3a68f@buzz.block.builderlab.xyz> Signed-off-by: Matt Toohey <contact@matttoohey.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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,7 +526,27 @@ export function CommunityOnboardingFlow({
|
||||
)}
|
||||
data-testid="community-onboarding-body"
|
||||
>
|
||||
{transaction.stage === "claiming" ||
|
||||
{transaction.stage === "invite-consent" ? (
|
||||
<>
|
||||
<h1 className="mb-6 text-title font-normal">
|
||||
Join {transaction.communityName}
|
||||
</h1>
|
||||
<InviteRedeemForm
|
||||
key={`${transaction.id}:${transaction.inviteCode}`}
|
||||
initialValue={`buzz://join?${new URLSearchParams({ relay: transaction.relayUrl, code: transaction.inviteCode ?? "" })}`}
|
||||
readOnlyInvite
|
||||
error={null}
|
||||
isRedeeming={false}
|
||||
onCancel={onCancel}
|
||||
onRedeem={(_relayUrl, _inviteCode, policyReceipt) =>
|
||||
update(
|
||||
{ policyReceipt, stage: "claiming", error: undefined },
|
||||
transaction.id,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : transaction.stage === "claiming" ||
|
||||
transaction.stage === "connecting" ? (
|
||||
<>
|
||||
<Users className="mx-auto h-10 w-10" />
|
||||
|
||||
@@ -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({
|
||||
<Input
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoFocus
|
||||
autoFocus={!readOnlyInvite}
|
||||
readOnly={readOnlyInvite}
|
||||
className={
|
||||
isAddCommunity
|
||||
? "h-11 rounded-xl border-input bg-muted/40 px-3 shadow-none transition-colors duration-150 ease-out hover:border-muted-foreground/40 focus-visible:border-muted-foreground/50 focus-visible:ring-0"
|
||||
|
||||
190
desktop/src/features/onboarding/useClaimInvite.jsdom-test.mjs
Normal file
190
desktop/src/features/onboarding/useClaimInvite.jsdom-test.mjs
Normal file
@@ -0,0 +1,190 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { render, waitFor, cleanup, act } from "@testing-library/react";
|
||||
import {
|
||||
CommunityOnboardingProvider,
|
||||
useCommunityOnboarding,
|
||||
startCommunityOnboarding,
|
||||
} from "./communityOnboarding.tsx";
|
||||
import { useClaimInvite } from "./useClaimInvite.ts";
|
||||
|
||||
let controls;
|
||||
function Claim() {
|
||||
controls = useCommunityOnboarding();
|
||||
useClaimInvite();
|
||||
return null;
|
||||
}
|
||||
const policy = {
|
||||
version: "v1",
|
||||
terms_markdown: "Terms",
|
||||
age_attestation_required: false,
|
||||
};
|
||||
const tauri = {
|
||||
invoke: async (command) => {
|
||||
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;
|
||||
}
|
||||
});
|
||||
@@ -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(
|
||||
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,
|
||||
)
|
||||
.then(() => {
|
||||
);
|
||||
update({ stage: "connecting", error: undefined }, transaction.id);
|
||||
})
|
||||
.catch((error: unknown) =>
|
||||
})()
|
||||
.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]);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -225,6 +225,7 @@ class _DeepLinkDispatcherState extends ConsumerState<DeepLinkDispatcher> {
|
||||
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 &&
|
||||
|
||||
@@ -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<InviteJoinState> {
|
||||
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<void> confirmJoin() async {
|
||||
Future<void> confirmJoin({String? acceptedPolicyVersion}) async {
|
||||
final invite = state.invite;
|
||||
if (invite == null ||
|
||||
state.requiresFreshInvite ||
|
||||
@@ -202,11 +225,28 @@ class InviteJoinNotifier extends Notifier<InviteJoinState> {
|
||||
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<InviteJoinState> {
|
||||
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<String, dynamic> 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') ||
|
||||
|
||||
@@ -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<bool?> 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<bool?> 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,6 +205,9 @@ class InviteJoinSheet extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.sm),
|
||||
if (state.joinPolicy == null ||
|
||||
isStarterSetupRecovery ||
|
||||
isClaiming)
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: isClaiming || state.requiresFreshInvite
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<Map<String, dynamic>> readJoinResponse(
|
||||
http.Client client,
|
||||
http.Request request,
|
||||
) async {
|
||||
http.Request request, {
|
||||
bool allowNotFound = false,
|
||||
}) async {
|
||||
final http.Response response;
|
||||
final Map<String, dynamic> 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<String?> freshJoinPolicyReceipt({
|
||||
required String code,
|
||||
required Future<bool> Function(HostedJoinPolicy) confirm,
|
||||
required void Function() checkCurrent,
|
||||
bool allowLegacyRelay = false,
|
||||
}) async {
|
||||
Future<Map<String, dynamic>> send(
|
||||
String method,
|
||||
@@ -66,7 +71,12 @@ Future<String?> 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<String?> 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<String?> freshJoinPolicyReceipt({
|
||||
}
|
||||
return receipt;
|
||||
}
|
||||
|
||||
/// Fetch a policy without accepting it; legacy self-hosted relays may omit it.
|
||||
Future<HostedJoinPolicy?> 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?,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -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<void> confirmJoin() async {
|
||||
Future<void> confirmJoin({String? acceptedPolicyVersion}) async {
|
||||
state = state.copyWith(
|
||||
status: InviteJoinStatus.success,
|
||||
focusChannelId: 'welcome-everyone-id',
|
||||
|
||||
@@ -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 = <String>[];
|
||||
var version = 'v1';
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
communityStorageProvider.overrideWithValue(storage),
|
||||
authProvider.overrideWith(() => auth),
|
||||
inviteJoinRecoveryProvider.overrideWithValue(
|
||||
(_) => _successfulRecovery(),
|
||||
),
|
||||
inviteJoinHttpClientProvider.overrideWithValue(
|
||||
http_testing.MockClient((request) async {
|
||||
requests.add(request.url.path);
|
||||
if (request.url.path == '/api/join-policy') {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'policy': {
|
||||
'version': version,
|
||||
'terms_markdown': 'Terms',
|
||||
'age_attestation_required': true,
|
||||
},
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
if (request.url.path == '/api/invites/accept-policy') {
|
||||
expect(jsonDecode(request.body), {
|
||||
'code': 'code',
|
||||
'policy_version': version,
|
||||
'age_confirmed': true,
|
||||
});
|
||||
return http.Response('{"receipt":"fresh"}', 200);
|
||||
}
|
||||
expect(jsonDecode(request.body), {
|
||||
'code': 'code',
|
||||
'policy_receipt': 'fresh',
|
||||
});
|
||||
return http.Response(
|
||||
'{"status":"joined","host":"relay.example.com","role":"member"}',
|
||||
200,
|
||||
);
|
||||
}),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
final notifier = container.read(inviteJoinProvider.notifier);
|
||||
await notifier.prepare(
|
||||
const InviteDeepLink(
|
||||
relayUrl: 'wss://relay.example.com',
|
||||
code: 'code',
|
||||
policyReceipt: 'untrusted-browser-receipt',
|
||||
),
|
||||
);
|
||||
expect(container.read(inviteJoinProvider).joinPolicy?.version, 'v1');
|
||||
await notifier.confirmJoin();
|
||||
expect(requests.where((path) => path != '/api/join-policy'), isEmpty);
|
||||
version = 'v2';
|
||||
await notifier.confirmJoin(acceptedPolicyVersion: 'v1');
|
||||
expect(container.read(inviteJoinProvider).joinPolicy?.version, 'v2');
|
||||
expect(requests.where((path) => path != '/api/join-policy'), isEmpty);
|
||||
await notifier.confirmJoin(acceptedPolicyVersion: 'v2');
|
||||
expect(
|
||||
container.read(inviteJoinProvider).status,
|
||||
InviteJoinStatus.success,
|
||||
);
|
||||
expect(requests.where((path) => path == '/api/invites/claim').length, 1);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'legacy invite entry refuses staging before key generation or claim',
|
||||
() async {
|
||||
@@ -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,7 +688,9 @@ void main() {
|
||||
expect(attempts, 1);
|
||||
});
|
||||
|
||||
test('failed claim can be retried and preserves policy receipt', () async {
|
||||
test(
|
||||
'failed claim can be retried without reusing a browser receipt',
|
||||
() async {
|
||||
final keys = nostr.Keys.generate();
|
||||
var attempts = 0;
|
||||
final bodies = <String>[];
|
||||
@@ -629,7 +705,7 @@ void main() {
|
||||
(_) => _successfulRecovery(),
|
||||
),
|
||||
inviteJoinHttpClientProvider.overrideWithValue(
|
||||
http_testing.MockClient((request) async {
|
||||
_policyFreeClient((request) async {
|
||||
attempts++;
|
||||
bodies.add(request.body);
|
||||
if (attempts == 1) {
|
||||
@@ -663,16 +739,15 @@ void main() {
|
||||
|
||||
await container.read(inviteJoinProvider.notifier).confirmJoin();
|
||||
|
||||
expect(container.read(inviteJoinProvider).status, InviteJoinStatus.success);
|
||||
expect(attempts, 2);
|
||||
expect(
|
||||
bodies,
|
||||
everyElement(
|
||||
jsonEncode({'code': 'code', 'policy_receipt': 'receipt.value'}),
|
||||
),
|
||||
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<http.Response> Function(http.Request) claim,
|
||||
) => http_testing.MockClient((request) async {
|
||||
if (request.url.path == '/api/join-policy') return http.Response('{}', 200);
|
||||
return claim(request);
|
||||
});
|
||||
|
||||
@@ -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<bool>();
|
||||
Map<dynamic, dynamic>? 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<Map<dynamic, dynamic>>();
|
||||
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<FilledButton>(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<FilledButton>(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<void> confirmJoin({String? acceptedPolicyVersion}) async {
|
||||
state = state.copyWith(
|
||||
status: InviteJoinStatus.error,
|
||||
joinPolicy: const HostedJoinPolicy(
|
||||
version: 'new',
|
||||
ageRequired: false,
|
||||
terms: 'Terms',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user