1 Commits
Author SHA1 Message Date
zemion f309809eba feat: display scan-ready enrollment QR codes for OTP tokens 2026-10-04 12:14:26 +02:00
16 changed files with 334 additions and 30 deletions
+6
View File
@@ -4,6 +4,12 @@ All notable changes are documented here.
## Unreleased ## Unreleased
## 0.3.2 - 2026-10-04
- Added an explicit Show QR code action for the current credential and each imported token, opening a large, camera-readable enrollment QR in a keyboard-accessible dialog.
- Kept enrollment QR codes black on white in both themes, with a quiet zone, token identity and parameters, local SVG download, and visible errors when a token exceeds the encoder capacity.
- Replaced the automatic credential QR preview with deliberate display and verified displayed TOTP/HOTP enrollment codes with the independent jsQR decoder.
## 0.3.1 - 2026-09-01 ## 0.3.1 - 2026-09-01
- Corrected the packaged corresponding-source identity to name this release and its immutable source tag. - Corrected the packaged corresponding-source identity to name this release and its immutable source tag.
+2 -1
View File
@@ -8,6 +8,7 @@ Authentication material stays in the active tab. The application has no backend,
- RFC 4226 HOTP and RFC 6238 TOTP generation and bounded diagnostic verification using SHA-1, SHA-256 or SHA-512, with exact 64-bit counters and preserved leading zeroes. - RFC 4226 HOTP and RFC 6238 TOTP generation and bounded diagnostic verification using SHA-1, SHA-256 or SHA-512, with exact 64-bit counters and preserved leading zeroes.
- Strict `otpauth://` parsing/serialization, random secret generation, masked values, interoperability findings, live period display and a project-owned QR encoder. - Strict `otpauth://` parsing/serialization, random secret generation, masked values, interoperability findings, live period display and a project-owned QR encoder.
- **Show QR code** in the credential editor or beside any imported token displays a large enrollment QR for scanning with another device's authenticator. The dialog identifies the token and its parameters, supports SVG download, and closes with Escape. Displaying or downloading it reveals the token's secret; generation stays entirely in this tab.
- Auditable TOTP time travel using positive/negative period offsets or a frozen local date/time in an explicit IANA timezone, including DST-gap rejection and repeated-time disambiguation. - Auditable TOTP time travel using positive/negative period offsets or a frozen local date/time in an explicit IANA timezone, including DST-gap rejection and repeated-time disambiguation.
- Bounded clock-drift search, past/current/future OTP timelines, downloadable interoperability vectors and custom RFC 6238 T0 diagnostics. - Bounded clock-drift search, past/current/future OTP timelines, downloadable interoperability vectors and custom RFC 6238 T0 diagnostics.
- RFC 6287 OCRA-1 suite parsing and computation for counter, numeric/alphanumeric/hex challenge, PIN/password hash, session and timestamp inputs. Official RFC interoperability vectors cover SHA-1, SHA-256 and SHA-512 paths. - RFC 6287 OCRA-1 suite parsing and computation for counter, numeric/alphanumeric/hex challenge, PIN/password hash, session and timestamp inputs. Official RFC interoperability vectors cover SHA-1, SHA-256 and SHA-512 paths.
@@ -54,7 +55,7 @@ The Vite base is relative, so both `/` and nested paths work. `npm run toolbox:c
## Static deployment ## Static deployment
Serve `dist/` or the contents of `release/auth-tools-0.3.1.zip` over HTTPS. Recommended response headers for the dedicated Auth Tools origin: Serve `dist/` or the contents of `release/auth-tools-0.3.2.zip` over HTTPS. Recommended response headers for the dedicated Auth Tools origin:
```text ```text
Content-Security-Policy: default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; worker-src 'self' blob:; manifest-src 'self' Content-Security-Policy: default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; worker-src 'self' blob:; manifest-src 'self'
+2 -2
View File
@@ -1,8 +1,8 @@
# Corresponding source and provenance # Corresponding source and provenance
The corresponding source for OTP & Passkey Tools 0.3.1 is published at: The corresponding source for OTP & Passkey Tools 0.3.2 is published at:
https://git.add-ideas.de/lotobo/auth-tools/src/tag/v0.3.1 https://git.add-ideas.de/lotobo/auth-tools/src/tag/v0.3.2
Build that tag with Node.js 22 and the exact `package-lock.json`: Build that tag with Node.js 22 and the exact `package-lock.json`:
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "auth-tools", "name": "auth-tools",
"version": "0.3.1", "version": "0.3.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "auth-tools", "name": "auth-tools",
"version": "0.3.1", "version": "0.3.2",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3", "@add-ideas/toolbox-contract": "0.2.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "auth-tools", "name": "auth-tools",
"version": "0.3.1", "version": "0.3.2",
"description": "Inspect and test OTP and WebAuthn authentication locally in the browser.", "description": "Inspect and test OTP and WebAuthn authentication locally in the browser.",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"author": "Albrecht Degering", "author": "Albrecht Degering",
+6
View File
@@ -4,6 +4,12 @@ All notable changes are documented here.
## Unreleased ## Unreleased
## 0.3.2 - 2026-10-04
- Added an explicit Show QR code action for the current credential and each imported token, opening a large, camera-readable enrollment QR in a keyboard-accessible dialog.
- Kept enrollment QR codes black on white in both themes, with a quiet zone, token identity and parameters, local SVG download, and visible errors when a token exceeds the encoder capacity.
- Replaced the automatic credential QR preview with deliberate display and verified displayed TOTP/HOTP enrollment codes with the independent jsQR decoder.
## 0.3.1 - 2026-09-01 ## 0.3.1 - 2026-09-01
- Corrected the packaged corresponding-source identity to name this release and its immutable source tag. - Corrected the packaged corresponding-source identity to name this release and its immutable source tag.
+2 -1
View File
@@ -8,6 +8,7 @@ Authentication material stays in the active tab. The application has no backend,
- RFC 4226 HOTP and RFC 6238 TOTP generation and bounded diagnostic verification using SHA-1, SHA-256 or SHA-512, with exact 64-bit counters and preserved leading zeroes. - RFC 4226 HOTP and RFC 6238 TOTP generation and bounded diagnostic verification using SHA-1, SHA-256 or SHA-512, with exact 64-bit counters and preserved leading zeroes.
- Strict `otpauth://` parsing/serialization, random secret generation, masked values, interoperability findings, live period display and a project-owned QR encoder. - Strict `otpauth://` parsing/serialization, random secret generation, masked values, interoperability findings, live period display and a project-owned QR encoder.
- **Show QR code** in the credential editor or beside any imported token displays a large enrollment QR for scanning with another device's authenticator. The dialog identifies the token and its parameters, supports SVG download, and closes with Escape. Displaying or downloading it reveals the token's secret; generation stays entirely in this tab.
- Auditable TOTP time travel using positive/negative period offsets or a frozen local date/time in an explicit IANA timezone, including DST-gap rejection and repeated-time disambiguation. - Auditable TOTP time travel using positive/negative period offsets or a frozen local date/time in an explicit IANA timezone, including DST-gap rejection and repeated-time disambiguation.
- Bounded clock-drift search, past/current/future OTP timelines, downloadable interoperability vectors and custom RFC 6238 T0 diagnostics. - Bounded clock-drift search, past/current/future OTP timelines, downloadable interoperability vectors and custom RFC 6238 T0 diagnostics.
- RFC 6287 OCRA-1 suite parsing and computation for counter, numeric/alphanumeric/hex challenge, PIN/password hash, session and timestamp inputs. Official RFC interoperability vectors cover SHA-1, SHA-256 and SHA-512 paths. - RFC 6287 OCRA-1 suite parsing and computation for counter, numeric/alphanumeric/hex challenge, PIN/password hash, session and timestamp inputs. Official RFC interoperability vectors cover SHA-1, SHA-256 and SHA-512 paths.
@@ -54,7 +55,7 @@ The Vite base is relative, so both `/` and nested paths work. `npm run toolbox:c
## Static deployment ## Static deployment
Serve `dist/` or the contents of `release/auth-tools-0.3.1.zip` over HTTPS. Recommended response headers for the dedicated Auth Tools origin: Serve `dist/` or the contents of `release/auth-tools-0.3.2.zip` over HTTPS. Recommended response headers for the dedicated Auth Tools origin:
```text ```text
Content-Security-Policy: default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; worker-src 'self' blob:; manifest-src 'self' Content-Security-Policy: default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; worker-src 'self' blob:; manifest-src 'self'
+2 -2
View File
@@ -1,8 +1,8 @@
# Corresponding source and provenance # Corresponding source and provenance
The corresponding source for OTP & Passkey Tools 0.3.1 is published at: The corresponding source for OTP & Passkey Tools 0.3.2 is published at:
https://git.add-ideas.de/lotobo/auth-tools/src/tag/v0.3.1 https://git.add-ideas.de/lotobo/auth-tools/src/tag/v0.3.2
Build that tag with Node.js 22 and the exact `package-lock.json`: Build that tag with Node.js 22 and the exact `package-lock.json`:
+1 -1
View File
@@ -3,7 +3,7 @@
"schemaVersion": 1, "schemaVersion": 1,
"id": "de.add-ideas.auth-tools", "id": "de.add-ideas.auth-tools",
"name": "OTP & Passkey Tools", "name": "OTP & Passkey Tools",
"version": "0.3.1", "version": "0.3.2",
"description": "Generate, inspect and verify OTP credentials and test WebAuthn/passkey ceremonies locally in the browser.", "description": "Generate, inspect and verify OTP credentials and test WebAuthn/passkey ceremonies locally in the browser.",
"entry": "./", "entry": "./",
"icon": "./favicon.svg", "icon": "./favicon.svg",
+29 -4
View File
@@ -58,6 +58,7 @@ import {
} from "../../otp/vendor-backups"; } from "../../otp/vendor-backups";
import { exportEncryptedPskc, importEncryptedPskc } from "../../otp/pskc"; import { exportEncryptedPskc, importEncryptedPskc } from "../../otp/pskc";
import { OtpAdvancedTools } from "./OtpAdvancedTools"; import { OtpAdvancedTools } from "./OtpAdvancedTools";
import { TokenQrDialog } from "./TokenQrDialog";
type TotpTimeMode = "current" | "relative" | "absolute"; type TotpTimeMode = "current" | "relative" | "absolute";
@@ -118,6 +119,7 @@ export function OtpWorkspace() {
bytesToBase32(profile.secret), bytesToBase32(profile.secret),
); );
const [secretVisible, setSecretVisible] = useState(false); const [secretVisible, setSecretVisible] = useState(false);
const [qrProfile, setQrProfile] = useState<OtpProfile | null>(null);
const [now, setNow] = useState(() => Date.now()); const [now, setNow] = useState(() => Date.now());
const [totpTimeMode, setTotpTimeMode] = useState<TotpTimeMode>("current"); const [totpTimeMode, setTotpTimeMode] = useState<TotpTimeMode>("current");
const [periodOffset, setPeriodOffset] = useState("0"); const [periodOffset, setPeriodOffset] = useState("0");
@@ -955,7 +957,6 @@ export function OtpWorkspace() {
)} )}
{uri ? ( {uri ? (
<> <>
<div className="qr" dangerouslySetInnerHTML={{ __html: qr }} />
<label className="wide"> <label className="wide">
<span>Provisioning URI</span> <span>Provisioning URI</span>
<textarea <textarea
@@ -969,6 +970,13 @@ export function OtpWorkspace() {
/> />
</label> </label>
<div className="button-row"> <div className="button-row">
<button
type="button"
className="primary-button"
onClick={() => setQrProfile(profile)}
>
Show QR code
</button>
<button <button
type="button" type="button"
className="secondary-button" className="secondary-button"
@@ -979,6 +987,7 @@ export function OtpWorkspace() {
<button <button
type="button" type="button"
className="secondary-button" className="secondary-button"
disabled={!qr}
onClick={() => onClick={() =>
downloadText("otp-provisioning.svg", qr, "image/svg+xml") downloadText("otp-provisioning.svg", qr, "image/svg+xml")
} }
@@ -1144,9 +1153,13 @@ export function OtpWorkspace() {
</div> </div>
<div className="credential-list"> <div className="credential-list">
{imported.map((item, index) => ( {imported.map((item, index) => (
<div
className="credential-list-row"
key={`${item.issuer}-${item.account}-${index}`}
>
<button <button
type="button" type="button"
key={`${item.issuer}-${item.account}-${index}`} className="credential-select"
onClick={() => { onClick={() => {
setProfile(item); setProfile(item);
setSecretText(bytesToBase32(item.secret)); setSecretText(bytesToBase32(item.secret));
@@ -1156,10 +1169,19 @@ export function OtpWorkspace() {
<strong>{item.issuer || "No issuer"}</strong> <strong>{item.issuer || "No issuer"}</strong>
<span>{item.account}</span> <span>{item.account}</span>
<small> <small>
{item.kind.toUpperCase()} · {item.algorithm} · {item.digits}{" "} {item.kind.toUpperCase()} · {item.algorithm} ·{" "}
digits {item.digits} digits
</small> </small>
</button> </button>
<button
type="button"
className="secondary-button"
aria-label={`Show QR code for ${item.issuer ? `${item.issuer}: ` : ""}${item.account}`}
onClick={() => setQrProfile(item)}
>
Show QR code
</button>
</div>
))} ))}
</div> </div>
{warnings.length > 0 && ( {warnings.length > 0 && (
@@ -1291,6 +1313,9 @@ export function OtpWorkspace() {
</article> </article>
</div> </div>
)} )}
{qrProfile && (
<TokenQrDialog profile={qrProfile} onClose={() => setQrProfile(null)} />
)}
</section> </section>
); );
} }
+117
View File
@@ -0,0 +1,117 @@
import { useEffect, useId, useMemo, useRef } from "react";
import { serializeOtpAuth, type OtpProfile } from "../../otp/profile";
import { qrSvg } from "../../qr/encoder";
export function TokenQrDialog({
profile,
onClose,
}: {
profile: OtpProfile;
onClose: () => void;
}) {
const ref = useRef<HTMLDialogElement>(null);
const titleId = useId();
const descriptionId = useId();
const result = useMemo(() => {
try {
return { svg: qrSvg(serializeOtpAuth(profile)), error: "" };
} catch (reason) {
return {
svg: "",
error:
reason instanceof Error
? reason.message
: "Could not create QR code.",
};
}
}, [profile]);
useEffect(() => {
const dialog = ref.current;
if (!dialog) return;
if (!dialog.open) dialog.showModal();
}, []);
const download = () => {
const url = URL.createObjectURL(
new Blob([result.svg], { type: "image/svg+xml" }),
);
const link = document.createElement("a");
link.href = url;
link.download = "otp-provisioning.svg";
link.click();
setTimeout(() => URL.revokeObjectURL(url), 0);
};
return (
<dialog
ref={ref}
className="tool-dialog token-qr-dialog"
aria-labelledby={titleId}
aria-describedby={descriptionId}
onClose={onClose}
>
<div className="dialog-heading">
<div>
<p className="eyebrow">Scan with your authenticator</p>
<h2 id={titleId}>OTP token QR code</h2>
</div>
<button
type="button"
className="icon-button"
aria-label="Close QR code"
onClick={onClose}
autoFocus
>
×
</button>
</div>
<div className="dialog-body">
<p className="token-qr-account">
<strong>{profile.issuer || "No issuer"}</strong>
<span>{profile.account}</span>
</p>
<p className="hint" id={descriptionId}>
Scan this QR code on another device to add this token. It contains the
token’s secret, so anyone who scans it can generate its codes.
</p>
{result.error ? (
<p className="inline-error" role="alert">
{result.error} Use Copy URI or a text export for this token instead.
</p>
) : (
<div
className="qr token-qr-display"
dangerouslySetInnerHTML={{ __html: result.svg }}
/>
)}
<p className="hint token-qr-parameters">
{profile.kind.toUpperCase()} · {profile.algorithm} · {profile.digits}{" "}
digits ·{" "}
{profile.kind === "totp"
? `${profile.period}-second period`
: `counter ${profile.counter}`}
</p>
{profile.kind === "totp" && (profile.epoch ?? 0) !== 0 && (
<p className="hint">
This token uses a non-zero T0. Some authenticators ignore that
parameter and will generate different codes.
</p>
)}
</div>
<div className="dialog-actions">
<button
type="button"
className="secondary-button"
disabled={!result.svg}
onClick={download}
>
Download QR
</button>
<button type="button" className="primary-button" onClick={onClose}>
Done
</button>
</div>
</dialog>
);
}
+33 -3
View File
@@ -547,17 +547,26 @@ button:disabled {
max-height: 28rem; max-height: 28rem;
overflow: auto; overflow: auto;
} }
.credential-list button { .credential-list-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding-right: 1rem;
border-bottom: 1px solid var(--toolbox-border);
}
.credential-list .credential-select {
display: grid; display: grid;
flex: 1;
min-width: 0;
gap: 0.15rem; gap: 0.15rem;
padding: 0.8rem 1rem; padding: 0.8rem 1rem;
border: 0; border: 0;
border-bottom: 1px solid var(--toolbox-border);
background: transparent; background: transparent;
color: var(--toolbox-text); color: var(--toolbox-text);
text-align: left; text-align: left;
overflow-wrap: anywhere;
} }
.credential-list button:hover { .credential-list .credential-select:hover {
background: var(--toolbox-surface-soft); background: var(--toolbox-surface-soft);
} }
.credential-list span, .credential-list span,
@@ -918,6 +927,27 @@ button:disabled {
background: rgb(10 15 30 / 55%); background: rgb(10 15 30 / 55%);
backdrop-filter: blur(3px); backdrop-filter: blur(3px);
} }
.token-qr-dialog {
width: min(calc(100% - 2rem), 38rem);
max-height: calc(100dvh - 2rem);
}
.token-qr-account {
display: grid;
gap: 0.25rem;
overflow-wrap: anywhere;
}
.token-qr-display svg {
display: block;
width: min(100%, 28rem);
aspect-ratio: 1;
background: white;
border: 0;
border-radius: 0;
shape-rendering: crispEdges;
}
.token-qr-parameters {
text-align: center;
}
.dialog-heading, .dialog-heading,
.dialog-actions { .dialog-actions {
display: flex; display: flex;
+1 -1
View File
@@ -3,7 +3,7 @@
"schemaVersion": 1, "schemaVersion": 1,
"id": "de.add-ideas.auth-tools", "id": "de.add-ideas.auth-tools",
"name": "OTP & Passkey Tools", "name": "OTP & Passkey Tools",
"version": "0.3.1", "version": "0.3.2",
"description": "Generate, inspect and verify OTP credentials and test WebAuthn/passkey ceremonies locally in the browser.", "description": "Generate, inspect and verify OTP credentials and test WebAuthn/passkey ceremonies locally in the browser.",
"entry": "./", "entry": "./",
"icon": "./favicon.svg", "icon": "./favicon.svg",
+1 -1
View File
@@ -1 +1 @@
export const APPLICATION_VERSION = "0.3.1"; export const APPLICATION_VERSION = "0.3.2";
+89
View File
@@ -1,4 +1,90 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import jsQR from "jsqr";
import { bytesToBase32 } from "../../src/crypto/encoding";
import { parseOtpAuth } from "../../src/otp/profile";
test("displays scannable imported TOTP and HOTP tokens on a dark mobile screen", async ({
page,
}) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.emulateMedia({ colorScheme: "dark" });
const requests: string[] = [];
page.on("request", (request) => requests.push(request.url()));
await page.goto("/deep/nested/auth/");
await page.getByRole("button", { name: "Import & migration" }).click();
await page
.getByPlaceholder("Paste migration data. It never leaves this tab.")
.fill(
"otpauth://totp/Ex%C3%A4mple:alice%40example.test?secret=JBSWY3DPEHPK3PXP&issuer=Ex%C3%A4mple&algorithm=SHA256&digits=8&period=45\n" +
"otpauth://hotp/Other:bob?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&issuer=Other&counter=18446744073709551615",
);
await page.getByRole("button", { name: "Inspect import" }).click();
for (const account of ["Exämple: alice@example.test", "Other: bob"]) {
const trigger = page.getByRole("button", {
name: `Show QR code for ${account}`,
exact: true,
});
await trigger.click();
const dialog = page.getByRole("dialog", { name: "OTP token QR code" });
await expect(dialog).toBeVisible();
const svg = dialog.getByRole("img", { name: "OTP provisioning QR code" });
await expect(svg).toBeVisible();
const bounds = await svg.boundingBox();
expect(bounds!.width).toBeGreaterThan(250);
expect(bounds!.height).toBeCloseTo(bounds!.width, 0);
expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(390);
const pixels = await svg.evaluate(async (element) => {
const source = new XMLSerializer().serializeToString(element);
const url = URL.createObjectURL(
new Blob([source], { type: "image/svg+xml" }),
);
try {
const image = new Image();
image.src = url;
await image.decode();
const canvas = document.createElement("canvas");
canvas.width = canvas.height = 640;
const context = canvas.getContext("2d")!;
context.drawImage(image, 0, 0, 640, 640);
return Array.from(context.getImageData(0, 0, 640, 640).data);
} finally {
URL.revokeObjectURL(url);
}
});
expect(pixels.slice(0, 4)).toEqual([255, 255, 255, 255]);
const decoded = jsQR(new Uint8ClampedArray(pixels), 640, 640);
expect(decoded).not.toBeNull();
const { profile } = parseOtpAuth(decoded!.data);
if (account.startsWith("Exämple")) {
expect(profile).toMatchObject({
kind: "totp",
issuer: "Exämple",
account: "alice@example.test",
algorithm: "SHA-256",
digits: 8,
period: 45,
});
expect(bytesToBase32(profile.secret)).toBe("JBSWY3DPEHPK3PXP");
} else {
expect(profile).toMatchObject({
kind: "hotp",
issuer: "Other",
account: "bob",
counter: 18446744073709551615n,
});
expect(bytesToBase32(profile.secret)).toBe(
"GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ",
);
}
await page.keyboard.press("Escape");
await expect(dialog).toHaveCount(0);
await expect(trigger).toBeFocused();
}
expect(
requests.every((url) => new URL(url).origin === "http://127.0.0.1:4173"),
).toBe(true);
});
test("runs from a nested path and keeps authentication material local", async ({ test("runs from a nested path and keeps authentication material local", async ({
page, page,
@@ -13,7 +99,10 @@ test("runs from a nested path and keeps authentication material local", async ({
await page.getByLabel("Issuer").fill("Example"); await page.getByLabel("Issuer").fill("Example");
await page.getByLabel("Account").fill("alice@example.test"); await page.getByLabel("Account").fill("alice@example.test");
await expect(page.getByText("TOTP code")).toBeVisible(); await expect(page.getByText("TOTP code")).toBeVisible();
await page.getByRole("button", { name: "Show QR code", exact: true }).click();
await expect(page.locator(".qr svg")).toBeVisible(); await expect(page.locator(".qr svg")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByRole("dialog")).toHaveCount(0);
expect( expect(
requests.every((url) => new URL(url).origin === "http://127.0.0.1:4173"), requests.every((url) => new URL(url).origin === "http://127.0.0.1:4173"),
).toBe(true); ).toBe(true);
+29
View File
@@ -0,0 +1,29 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { TokenQrDialog } from "../../src/components/otp/TokenQrDialog";
import { parseOtpAuth } from "../../src/otp/profile";
describe("token QR display", () => {
it("reports encoder limits without displaying an empty QR or allowing download", () => {
const { profile } = parseOtpAuth(
`otpauth://totp/${"a".repeat(500)}?secret=JBSWY3DPEHPK3PXP`,
);
render(<TokenQrDialog profile={profile} onClose={vi.fn()} />);
expect(screen.getByRole("alert")).toHaveTextContent(/version 10/iu);
expect(screen.queryByRole("img")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Download QR" })).toBeDisabled();
});
it("identifies nonportable time parameters without showing the plaintext seed", () => {
const { profile } = parseOtpAuth(
"otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example&t0=100",
);
render(<TokenQrDialog profile={profile} onClose={vi.fn()} />);
expect(
screen.getByText(/Some authenticators ignore/iu),
).toBeInTheDocument();
expect(screen.getByRole("dialog").textContent).not.toContain(
"JBSWY3DPEHPK3PXP",
);
});
});