mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): streamline nostr identity pairing (#1974)
Signed-off-by: npub1ntr8jjcqq6gt06q5avvqttfjgpwshmra22pcmcagdnukw4ja4nqqsa9g54 <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1ntr8jjcqq6gt06q5avvqttfjgpwshmra22pcmcagdnukw4ja4nqqsa9g54 <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
co-authored by
npub1ntr8jjcqq6gt06q5avvqttfjgpwshmra22pcmcagdnukw4ja4nqqsa9g54
parent
74f3f29758
commit
1eae8575ed
@@ -1,5 +1,6 @@
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -88,6 +89,11 @@ function normalizeVerificationCode(value: string): string[] {
|
||||
.map((character) => character.trim());
|
||||
}
|
||||
|
||||
function isNostrBindRequestExpired(payload: NostrBindDeepLinkPayload): boolean {
|
||||
const expiry = new Date(payload.expiresAt).getTime();
|
||||
return Number.isNaN(expiry) || expiry <= Date.now();
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
@@ -135,6 +141,74 @@ async function returnSignedResponseToBrowser(
|
||||
}
|
||||
}
|
||||
|
||||
function SignedResponseControls({
|
||||
copyFailed,
|
||||
copyLabel,
|
||||
onCopy,
|
||||
signedResponse,
|
||||
}: {
|
||||
copyFailed: boolean;
|
||||
copyLabel: string;
|
||||
onCopy: () => void;
|
||||
signedResponse: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4" data-testid="nostr-bind-manual-fallback-content">
|
||||
<pre
|
||||
className="max-h-56 w-full overflow-auto rounded-2xl border border-border/70 bg-muted/60 p-4 text-left shadow-xs"
|
||||
data-testid="nostr-bind-signed-response"
|
||||
>
|
||||
<code className="whitespace-pre-wrap break-all font-mono text-xs leading-5 text-foreground">
|
||||
{signedResponse}
|
||||
</code>
|
||||
</pre>
|
||||
|
||||
{copyFailed ? (
|
||||
<p className="w-full rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-left text-sm text-destructive">
|
||||
{COPY_FAILURE_MESSAGE}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
aria-label={copyLabel}
|
||||
className="h-10 w-full"
|
||||
data-testid="nostr-bind-copy-response"
|
||||
onClick={onCopy}
|
||||
type="button"
|
||||
>
|
||||
<span aria-live="polite" className="sr-only">
|
||||
{copyLabel}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-grid h-5 place-items-center overflow-hidden"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
COPY_BUTTON_LABEL_CLASS,
|
||||
copyLabel === "Copy response"
|
||||
? "translate-y-0 opacity-100"
|
||||
: "-translate-y-0.5 opacity-0",
|
||||
)}
|
||||
>
|
||||
Copy response
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
COPY_BUTTON_LABEL_CLASS,
|
||||
copyLabel === "Copied"
|
||||
? "translate-y-0 opacity-100"
|
||||
: "translate-y-0.5 opacity-0",
|
||||
)}
|
||||
>
|
||||
Copied
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NostrBindConsentDialog() {
|
||||
const isPreview = isNostrBindPreviewEnabled();
|
||||
const [payload, setPayload] = React.useState<NostrBindDeepLinkPayload | null>(
|
||||
@@ -154,12 +228,15 @@ export function NostrBindConsentDialog() {
|
||||
const [hasCodeMismatch, setHasCodeMismatch] = React.useState(false);
|
||||
const [copyFailed, setCopyFailed] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [isManualFallbackOpen, setIsManualFallbackOpen] = React.useState(false);
|
||||
const codeInputRefs = React.useRef<Array<HTMLInputElement | null>>([]);
|
||||
const codeShakeRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const codeShakeAnimationRef = React.useRef<Animation | null>(null);
|
||||
const copiedTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
const autoSignAttemptRef = React.useRef<string | null>(null);
|
||||
const activeSignAttemptRef = React.useRef<symbol | null>(null);
|
||||
const systemColorScheme = useSystemColorScheme();
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const enteredVerificationCode = verificationCode.join("");
|
||||
@@ -204,13 +281,17 @@ export function NostrBindConsentDialog() {
|
||||
|
||||
const unlistenPromise = listenForNostrBindDeepLinks((nextPayload) => {
|
||||
clearCopiedState();
|
||||
autoSignAttemptRef.current = null;
|
||||
activeSignAttemptRef.current = null;
|
||||
setPayload(nextPayload);
|
||||
setIdentity(null);
|
||||
setIsSigning(false);
|
||||
setSignedResponse(null);
|
||||
setVerificationCode(createEmptyVerificationCode());
|
||||
setHasCodeMismatch(false);
|
||||
setCopyFailed(false);
|
||||
setError(null);
|
||||
setIsManualFallbackOpen(false);
|
||||
getIdentity()
|
||||
.then(setIdentity)
|
||||
.catch((error) => {
|
||||
@@ -225,22 +306,19 @@ export function NostrBindConsentDialog() {
|
||||
};
|
||||
}, [clearCopiedState, isPreview]);
|
||||
|
||||
const isExpired = React.useMemo(() => {
|
||||
if (!payload) {
|
||||
return false;
|
||||
}
|
||||
const expiry = new Date(payload.expiresAt).getTime();
|
||||
return Number.isNaN(expiry) || expiry <= Date.now();
|
||||
}, [payload]);
|
||||
const isExpired = payload !== null && isNostrBindRequestExpired(payload);
|
||||
|
||||
const resetDialog = React.useCallback(() => {
|
||||
clearCopiedState();
|
||||
autoSignAttemptRef.current = null;
|
||||
activeSignAttemptRef.current = null;
|
||||
setPayload(null);
|
||||
setSignedResponse(null);
|
||||
setVerificationCode(createEmptyVerificationCode());
|
||||
setHasCodeMismatch(false);
|
||||
setCopyFailed(false);
|
||||
setError(null);
|
||||
setIsManualFallbackOpen(false);
|
||||
setIdentity(null);
|
||||
setIsSigning(false);
|
||||
}, [clearCopiedState]);
|
||||
@@ -297,6 +375,7 @@ export function NostrBindConsentDialog() {
|
||||
(index: number, value: string) => {
|
||||
const nextDigits = value.replace(/\D/g, "");
|
||||
const next = [...verificationCode];
|
||||
autoSignAttemptRef.current = null;
|
||||
|
||||
if (!nextDigits) {
|
||||
next[index] = "";
|
||||
@@ -363,6 +442,7 @@ export function NostrBindConsentDialog() {
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
autoSignAttemptRef.current = null;
|
||||
const next = normalizeVerificationCode(pastedCode);
|
||||
setVerificationCode(next);
|
||||
if (
|
||||
@@ -402,6 +482,7 @@ export function NostrBindConsentDialog() {
|
||||
|
||||
if (event.key === "Backspace") {
|
||||
event.preventDefault();
|
||||
autoSignAttemptRef.current = null;
|
||||
const targetIndex = verificationCode[index]
|
||||
? index
|
||||
: Math.max(index - 1, 0);
|
||||
@@ -429,10 +510,10 @@ export function NostrBindConsentDialog() {
|
||||
);
|
||||
|
||||
const handleSign = React.useCallback(async () => {
|
||||
if (!payload) {
|
||||
if (activeSignAttemptRef.current || !payload) {
|
||||
return;
|
||||
}
|
||||
if (isExpired) {
|
||||
if (isNostrBindRequestExpired(payload)) {
|
||||
setError(EXPIRED_LINK_MESSAGE);
|
||||
return;
|
||||
}
|
||||
@@ -447,6 +528,8 @@ export function NostrBindConsentDialog() {
|
||||
return;
|
||||
}
|
||||
|
||||
const attempt = Symbol("nostr-bind-sign");
|
||||
activeSignAttemptRef.current = attempt;
|
||||
setIsSigning(true);
|
||||
clearCopiedState();
|
||||
setError(null);
|
||||
@@ -461,21 +544,35 @@ export function NostrBindConsentDialog() {
|
||||
origin: payload.origin,
|
||||
expiresAt: payload.expiresAt,
|
||||
});
|
||||
if (activeSignAttemptRef.current !== attempt) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSignedResponse(signed);
|
||||
if (payload.returnMode === "browser_fragment_v1" && payload.callbackUrl) {
|
||||
setError(
|
||||
await returnSignedResponseToBrowser(payload.callbackUrl, signed),
|
||||
const callbackError = await returnSignedResponseToBrowser(
|
||||
payload.callbackUrl,
|
||||
signed,
|
||||
);
|
||||
if (activeSignAttemptRef.current !== attempt) {
|
||||
return;
|
||||
}
|
||||
setError(callbackError);
|
||||
setIsManualFallbackOpen(callbackError !== null);
|
||||
}
|
||||
} catch (error) {
|
||||
setError(formatError(error) || "Failed to sign binding response.");
|
||||
if (activeSignAttemptRef.current === attempt) {
|
||||
setError(formatError(error) || "Failed to sign binding response.");
|
||||
}
|
||||
} finally {
|
||||
setIsSigning(false);
|
||||
if (activeSignAttemptRef.current === attempt) {
|
||||
activeSignAttemptRef.current = null;
|
||||
setIsSigning(false);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
clearCopiedState,
|
||||
enteredVerificationCode,
|
||||
isExpired,
|
||||
isPreview,
|
||||
isVerificationCodeComplete,
|
||||
isVerificationCodeValid,
|
||||
@@ -484,6 +581,35 @@ export function NostrBindConsentDialog() {
|
||||
verificationCode,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
!payload ||
|
||||
identity === null ||
|
||||
isExpired ||
|
||||
isSigning ||
|
||||
signedResponse !== null ||
|
||||
!isVerificationCodeValid
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const attemptKey = `${payload.challengeId}:${enteredVerificationCode}`;
|
||||
if (autoSignAttemptRef.current === attemptKey) {
|
||||
return;
|
||||
}
|
||||
autoSignAttemptRef.current = attemptKey;
|
||||
void handleSign();
|
||||
}, [
|
||||
enteredVerificationCode,
|
||||
handleSign,
|
||||
identity,
|
||||
isExpired,
|
||||
isSigning,
|
||||
isVerificationCodeValid,
|
||||
payload,
|
||||
signedResponse,
|
||||
]);
|
||||
|
||||
const handleCopyAgain = React.useCallback(async () => {
|
||||
if (!signedResponse) {
|
||||
return;
|
||||
@@ -548,7 +674,7 @@ export function NostrBindConsentDialog() {
|
||||
id="nostr-bind-description"
|
||||
>
|
||||
{payload.returnMode === "browser_fragment_v1"
|
||||
? "Buzz opened your browser to finish verification. If it did not open, copy the response below."
|
||||
? "Buzz opened your browser to finish verification."
|
||||
: "Copy the response below, then paste it into the Buzz website to finish verification."}
|
||||
</DialogPrimitive.Description>
|
||||
|
||||
@@ -558,65 +684,50 @@ export function NostrBindConsentDialog() {
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<pre
|
||||
className="mt-10 max-h-56 w-full overflow-auto rounded-2xl border border-border/70 bg-muted/60 p-4 text-left shadow-xs"
|
||||
data-testid="nostr-bind-signed-response"
|
||||
>
|
||||
<code className="whitespace-pre-wrap break-all font-mono text-xs leading-5 text-foreground">
|
||||
{signedResponse}
|
||||
</code>
|
||||
</pre>
|
||||
|
||||
{copyFailed ? (
|
||||
<p className="mt-4 w-full rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-left text-sm text-destructive">
|
||||
{COPY_FAILURE_MESSAGE}
|
||||
</p>
|
||||
) : null}
|
||||
{payload.returnMode === "browser_fragment_v1" ? (
|
||||
<details
|
||||
className="group mt-10 w-full overflow-hidden rounded-2xl border border-border/70 bg-muted/30 text-left shadow-xs"
|
||||
data-testid="nostr-bind-manual-fallback"
|
||||
onToggle={(event) =>
|
||||
setIsManualFallbackOpen(event.currentTarget.open)
|
||||
}
|
||||
open={isManualFallbackOpen}
|
||||
>
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-4 px-4 py-3 text-sm font-medium transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring [&::-webkit-details-marker]:hidden">
|
||||
<span>Pairing didn’t finish automatically?</span>
|
||||
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-150 group-open:rotate-180" />
|
||||
</summary>
|
||||
<div className="space-y-4 border-t border-border/55 p-4">
|
||||
<p className="text-sm leading-6 text-muted-foreground">
|
||||
Copy this response and paste it into the pairing page.
|
||||
</p>
|
||||
<SignedResponseControls
|
||||
copyFailed={copyFailed}
|
||||
copyLabel={finishCopyButtonLabel}
|
||||
onCopy={handleCopyAgain}
|
||||
signedResponse={signedResponse}
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
) : (
|
||||
<div className="mt-10 w-full">
|
||||
<SignedResponseControls
|
||||
copyFailed={copyFailed}
|
||||
copyLabel={finishCopyButtonLabel}
|
||||
onCopy={handleCopyAgain}
|
||||
signedResponse={signedResponse}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 flex w-full flex-col gap-3">
|
||||
<Button
|
||||
aria-label={finishCopyButtonLabel}
|
||||
className="h-10 w-full"
|
||||
data-testid="nostr-bind-copy-response"
|
||||
onClick={handleCopyAgain}
|
||||
type="button"
|
||||
>
|
||||
<span aria-live="polite" className="sr-only">
|
||||
{finishCopyButtonLabel}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-grid h-5 place-items-center overflow-hidden"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
COPY_BUTTON_LABEL_CLASS,
|
||||
finishCopyButtonLabel === "Copy response"
|
||||
? "translate-y-0 opacity-100"
|
||||
: "-translate-y-0.5 opacity-0",
|
||||
)}
|
||||
>
|
||||
Copy response
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
COPY_BUTTON_LABEL_CLASS,
|
||||
finishCopyButtonLabel === "Copied"
|
||||
? "translate-y-0 opacity-100"
|
||||
: "translate-y-0.5 opacity-0",
|
||||
)}
|
||||
>
|
||||
Copied
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
className="h-10 w-full text-muted-foreground hover:text-accent-foreground"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Close
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</OnboardingSlideTransition>
|
||||
|
||||
@@ -189,6 +189,10 @@ type E2eConfig = {
|
||||
* Linux .deb install where Tauri's updater cannot swap the binary).
|
||||
* Defaults to true for all existing tests. */
|
||||
autoUpdateSupported?: boolean;
|
||||
/** Reject `plugin:opener|open_url` to exercise browser-return fallback UI. */
|
||||
openerError?: string;
|
||||
/** Delay binding signatures so specs can exercise request supersession. */
|
||||
nostrBindSignDelayMs?: number;
|
||||
stallWebsocketSends?: boolean;
|
||||
userSearchDelayMs?: number;
|
||||
// NIP-IA gate inputs — see tests/helpers/bridge.ts:MockBridgeOptions for
|
||||
@@ -8641,6 +8645,10 @@ export function maybeInstallE2eTauriMocks() {
|
||||
origin: string;
|
||||
verificationCode: string;
|
||||
};
|
||||
const signDelayMs = activeConfig?.mock?.nostrBindSignDelayMs ?? 0;
|
||||
if (signDelayMs > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, signDelayMs));
|
||||
}
|
||||
const activeIdentity = identity ?? DEFAULT_MOCK_IDENTITY;
|
||||
return JSON.stringify({
|
||||
id: "e2e-signed-nostr-binding",
|
||||
@@ -9654,6 +9662,9 @@ export function maybeInstallE2eTauriMocks() {
|
||||
payload as Parameters<typeof sendToMockSocket>[0],
|
||||
);
|
||||
case "plugin:opener|open_url":
|
||||
if (activeConfig?.mock?.openerError) {
|
||||
throw new Error(activeConfig.mock.openerError);
|
||||
}
|
||||
openedExternalUrls.push(String((payload as { url: string | URL }).url));
|
||||
return null;
|
||||
case "get_e2e_opened_external_urls":
|
||||
|
||||
@@ -28,21 +28,7 @@ const VALID_REQUEST: NostrBindPayload = {
|
||||
version: "1",
|
||||
};
|
||||
|
||||
async function openNostrBind(
|
||||
page: Page,
|
||||
payload: NostrBindPayload = VALID_REQUEST,
|
||||
) {
|
||||
await installMockBridge(page);
|
||||
await page.goto("/");
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
typeof (
|
||||
window as Window & {
|
||||
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown;
|
||||
}
|
||||
).__BUZZ_E2E_INVOKE_MOCK_COMMAND__ === "function",
|
||||
);
|
||||
|
||||
async function emitNostrBind(page: Page, payload: NostrBindPayload) {
|
||||
await page.evaluate(async (nextPayload) => {
|
||||
const internals = (
|
||||
window as Window & {
|
||||
@@ -62,7 +48,24 @@ async function openNostrBind(
|
||||
payload: nextPayload,
|
||||
});
|
||||
}, payload);
|
||||
}
|
||||
|
||||
async function openNostrBind(
|
||||
page: Page,
|
||||
payload: NostrBindPayload = VALID_REQUEST,
|
||||
mock?: Parameters<typeof installMockBridge>[1],
|
||||
) {
|
||||
await installMockBridge(page, mock);
|
||||
await page.goto("/");
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
typeof (
|
||||
window as Window & {
|
||||
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown;
|
||||
}
|
||||
).__BUZZ_E2E_INVOKE_MOCK_COMMAND__ === "function",
|
||||
);
|
||||
await emitNostrBind(page, payload);
|
||||
await expect(page.getByTestId("nostr-bind-page")).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -150,7 +153,7 @@ async function shakeCount(page: Page): Promise<number> {
|
||||
);
|
||||
}
|
||||
|
||||
test("supports OTP entry, navigation, and paste without signing incomplete input", async ({
|
||||
test("supports OTP entry and navigation without signing incomplete input", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openNostrBind(page);
|
||||
@@ -174,42 +177,70 @@ test("supports OTP entry, navigation, and paste without signing incomplete input
|
||||
await expect(second).toHaveValue("");
|
||||
await expect(continueButton).toBeDisabled();
|
||||
await expect.poll(() => signCommandPayloads(page)).toEqual([]);
|
||||
|
||||
await pasteCode(first, VALID_REQUEST.verificationCode);
|
||||
for (const [index, digit] of [...VALID_REQUEST.verificationCode].entries()) {
|
||||
await expect(
|
||||
page.getByTestId(`nostr-bind-code-digit-${index + 1}`),
|
||||
).toHaveValue(digit);
|
||||
}
|
||||
await expect(page.getByTestId("nostr-bind-code-digit-6")).toBeFocused();
|
||||
await expect(continueButton).toBeEnabled();
|
||||
});
|
||||
|
||||
test("locks a filled sixth slot and repeats mismatch feedback without signing", async ({
|
||||
test("auto-signs exactly once when the sixth correct digit is typed", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openNostrBind(page);
|
||||
|
||||
await page.getByTestId("nostr-bind-code-digit-1").click();
|
||||
for (const digit of VALID_REQUEST.verificationCode) {
|
||||
await page.keyboard.type(digit);
|
||||
}
|
||||
|
||||
await expect(page.getByTestId("nostr-bind-finish-step")).toBeVisible();
|
||||
await expect.poll(() => signCommandPayloads(page)).toHaveLength(1);
|
||||
await page.waitForTimeout(100);
|
||||
await expect.poll(() => signCommandPayloads(page)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("discards a signature when a newer pairing request arrives", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openNostrBind(page, VALID_REQUEST, { nostrBindSignDelayMs: 150 });
|
||||
await pasteCode(
|
||||
page.getByTestId("nostr-bind-code-digit-1"),
|
||||
VALID_REQUEST.verificationCode,
|
||||
);
|
||||
await expect.poll(() => signCommandPayloads(page)).toHaveLength(1);
|
||||
|
||||
const nextRequest = {
|
||||
...VALID_REQUEST,
|
||||
challengeId: "550e8400-e29b-41d4-a716-446655440001",
|
||||
verificationCode: "654321",
|
||||
};
|
||||
await emitNostrBind(page, nextRequest);
|
||||
|
||||
await expect(page.getByTestId("nostr-bind-code-digit-1")).toHaveValue("");
|
||||
await page.waitForTimeout(200);
|
||||
await expect(page.getByTestId("nostr-bind-code-step")).toBeVisible();
|
||||
await expect(page.getByTestId("nostr-bind-finish-step")).toBeHidden();
|
||||
|
||||
await pasteCode(
|
||||
page.getByTestId("nostr-bind-code-digit-1"),
|
||||
nextRequest.verificationCode,
|
||||
);
|
||||
await expect(page.getByTestId("nostr-bind-finish-step")).toBeVisible();
|
||||
await expect.poll(() => signCommandPayloads(page)).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("repeats mismatch feedback without signing", async ({ page }) => {
|
||||
await installShakeCounter(page);
|
||||
await openNostrBind(page);
|
||||
|
||||
const first = page.getByTestId("nostr-bind-code-digit-1");
|
||||
const last = page.getByTestId("nostr-bind-code-digit-6");
|
||||
const continueButton = page.getByTestId("nostr-bind-sign-and-copy");
|
||||
|
||||
await pasteCode(first, VALID_REQUEST.verificationCode);
|
||||
await last.press("9");
|
||||
await expect(last).toHaveValue("6");
|
||||
await expect(continueButton).toBeEnabled();
|
||||
await expect.poll(() => shakeCount(page)).toBe(1);
|
||||
|
||||
await pasteCode(first, "654321");
|
||||
await expect(page.getByRole("alert")).toHaveText(
|
||||
"That code doesn't match. Check the code and try again.",
|
||||
);
|
||||
await expect(continueButton).toBeDisabled();
|
||||
await expect.poll(() => shakeCount(page)).toBe(2);
|
||||
await expect.poll(() => shakeCount(page)).toBe(1);
|
||||
|
||||
await pasteCode(first, "654321");
|
||||
await expect.poll(() => shakeCount(page)).toBe(3);
|
||||
await expect.poll(() => shakeCount(page)).toBe(2);
|
||||
await expect.poll(() => signCommandPayloads(page)).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -263,7 +294,6 @@ test("signs a valid request, shows the response, and copies it", async ({
|
||||
VALID_REQUEST.verificationCode,
|
||||
);
|
||||
|
||||
await page.getByTestId("nostr-bind-sign-and-copy").click();
|
||||
await expect(page.getByTestId("nostr-bind-finish-step")).toBeVisible();
|
||||
const response = page.getByTestId("nostr-bind-signed-response");
|
||||
await expect(response).toContainText("e2e-signed-nostr-binding");
|
||||
@@ -296,11 +326,16 @@ test("signs a valid request, shows the response, and copies it", async ({
|
||||
),
|
||||
)
|
||||
.toBe(signedResponse);
|
||||
await expect.poll(() => signCommandPayloads(page)).toHaveLength(1);
|
||||
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
await expect(page.getByTestId("nostr-bind-page")).toBeHidden();
|
||||
});
|
||||
|
||||
test("returns a signed response in the callback fragment after consent", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installClipboardStub(page, false);
|
||||
await openNostrBind(page, {
|
||||
...VALID_REQUEST,
|
||||
callbackUrl: "https://admin.example.com/buzz?source=bind#stale",
|
||||
@@ -326,11 +361,38 @@ test("returns a signed response in the callback fragment after consent", async (
|
||||
page.getByTestId("nostr-bind-code-digit-1"),
|
||||
VALID_REQUEST.verificationCode,
|
||||
);
|
||||
await page.getByTestId("nostr-bind-sign-and-copy").click();
|
||||
await expect(page.getByTestId("nostr-bind-finish-step")).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Continue in your browser" }),
|
||||
).toBeVisible();
|
||||
const manualFallback = page.getByTestId("nostr-bind-manual-fallback");
|
||||
await expect(manualFallback).not.toHaveAttribute("open", "");
|
||||
await expect(page.getByTestId("nostr-bind-signed-response")).toBeHidden();
|
||||
await expect(page.getByTestId("nostr-bind-copy-response")).toBeHidden();
|
||||
|
||||
const fallbackSummary = manualFallback.locator("summary");
|
||||
await fallbackSummary.focus();
|
||||
await fallbackSummary.press("Enter");
|
||||
await expect(manualFallback).toHaveAttribute("open", "");
|
||||
const signedResponse = page.getByTestId("nostr-bind-signed-response");
|
||||
await expect(signedResponse).toContainText("e2e-signed-nostr-binding");
|
||||
const copyResponse = page.getByTestId("nostr-bind-copy-response");
|
||||
await expect(copyResponse).toBeVisible();
|
||||
await copyResponse.click();
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
(
|
||||
window as Window & {
|
||||
__BUZZ_E2E_CLIPBOARD_TEXT__?: string;
|
||||
}
|
||||
).__BUZZ_E2E_CLIPBOARD_TEXT__,
|
||||
),
|
||||
)
|
||||
.toBe(await signedResponse.textContent());
|
||||
|
||||
const callback = await page.evaluate(() => {
|
||||
const command = (
|
||||
(
|
||||
@@ -352,6 +414,36 @@ test("returns a signed response in the callback fragment after consent", async (
|
||||
expect(callbackUrl.hash).toMatch(/^#buzz_bind=v1\.[A-Za-z0-9_-]+$/);
|
||||
});
|
||||
|
||||
test("opens the manual fallback when returning to the browser fails", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openNostrBind(
|
||||
page,
|
||||
{
|
||||
...VALID_REQUEST,
|
||||
callbackUrl: "https://admin.example.com/buzz",
|
||||
returnMode: "browser_fragment_v1",
|
||||
},
|
||||
{ openerError: "browser unavailable" },
|
||||
);
|
||||
|
||||
await pasteCode(
|
||||
page.getByTestId("nostr-bind-code-digit-1"),
|
||||
VALID_REQUEST.verificationCode,
|
||||
);
|
||||
|
||||
await expect(
|
||||
page.getByText(
|
||||
"Could not open the browser. Copy the response below to finish manually.",
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("nostr-bind-manual-fallback")).toHaveAttribute(
|
||||
"open",
|
||||
"",
|
||||
);
|
||||
await expect(page.getByTestId("nostr-bind-copy-response")).toBeVisible();
|
||||
});
|
||||
|
||||
test("keeps the signed response available when clipboard access fails", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -361,11 +453,13 @@ test("keeps the signed response available when clipboard access fails", async ({
|
||||
page.getByTestId("nostr-bind-code-digit-1"),
|
||||
VALID_REQUEST.verificationCode,
|
||||
);
|
||||
await page.getByTestId("nostr-bind-sign-and-copy").click();
|
||||
await expect(page.getByTestId("nostr-bind-finish-step")).toBeVisible();
|
||||
|
||||
await page.getByTestId("nostr-bind-copy-response").click();
|
||||
await expect(
|
||||
page.getByText("Buzz couldn't access the clipboard. Try again."),
|
||||
page
|
||||
.getByTestId("nostr-bind-manual-fallback-content")
|
||||
.getByText("Buzz couldn't access the clipboard. Try again."),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("nostr-bind-signed-response")).toContainText(
|
||||
"e2e-signed-nostr-binding",
|
||||
|
||||
@@ -206,6 +206,10 @@ type MockBridgeOptions = {
|
||||
/** Set to false to simulate a Linux .deb install where auto-update is not
|
||||
* supported. Defaults to true. See e2eBridge mock.autoUpdateSupported. */
|
||||
autoUpdateSupported?: boolean;
|
||||
/** Reject browser opener calls to exercise manual pairing fallback UI. */
|
||||
openerError?: string;
|
||||
/** Delay binding signatures so specs can exercise request supersession. */
|
||||
nostrBindSignDelayMs?: number;
|
||||
stallWebsocketSends?: boolean;
|
||||
userSearchDelayMs?: number;
|
||||
// NIP-IA gate inputs — drive the archive-button gate matrix in
|
||||
|
||||
Reference in New Issue
Block a user