diff --git a/package.json b/package.json index 7d1d6e2..a371d87 100644 --- a/package.json +++ b/package.json @@ -8,10 +8,10 @@ "prod": "pnpm run build -- --mode production", "build": "tsc && vite build", "build:tsgo": "tsgo && vite build", - "test": "vitest", - "test:ci": "echo tests lolol", - "test:watch": "vitest --watch", - "test:coverage": "vitest --coverage", + "test": "vitest run --maxWorkers=1 --no-file-parallelism", + "test:ci": "vitest run --maxWorkers=1 --no-file-parallelism", + "test:watch": "vitest watch --maxWorkers=1 --no-file-parallelism", + "test:coverage": "vitest run --coverage --maxWorkers=1 --no-file-parallelism", "preview": "pnpm run build && wrangler pages dev ./dist", "deploy": "pnpm run build && wrangler pages deploy ./dist", "format": "pnpm biome format", diff --git a/src/components/ColorPicker.test.tsx b/src/components/ColorPicker.test.tsx index 96fe97f..904126e 100644 --- a/src/components/ColorPicker.test.tsx +++ b/src/components/ColorPicker.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from "@testing-library/react"; +import { act, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { vi } from "vitest"; import ColorPicker from "../components/ColorPicker"; @@ -37,14 +37,29 @@ describe("ColorPicker Component", () => { }); it("displays loading state initially", async () => { + let resolveFetch: ((value: Response) => void) | undefined; + vi.spyOn(globalThis, "fetch").mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + render( , ); - // Loading text may render immediately or on next tick; wait for it + // Hold the request open so the loading state is observable. expect(await screen.findByText(/Loading/i)).toBeInTheDocument(); + + await act(async () => { + resolveFetch?.({ + ok: true, + json: async () => mockColors, + } as unknown as Response); + }); }); it("selects the first color initially after loading", async () => { diff --git a/src/components/Gallery.tsx b/src/components/Gallery.tsx index ca36a6b..23e6aea 100644 --- a/src/components/Gallery.tsx +++ b/src/components/Gallery.tsx @@ -23,7 +23,8 @@ const Gallery: React.FC = ({
{images.map((src, index) => { return ( -
= ({ loading="lazy" decoding="async" /> -
+ ); })}
diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx index edcf095..864575d 100644 --- a/src/context/AuthContext.tsx +++ b/src/context/AuthContext.tsx @@ -13,11 +13,13 @@ interface User { lastName?: string; } +type FetchUserResult = User | null; + interface AuthContextType { user: User | null; loading: boolean; setUser: (user: User | null) => void; - fetchUser: () => Promise; + fetchUser: () => Promise; } const AuthContext = createContext(null); @@ -26,19 +28,37 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); - const fetchUser = async () => { + const fetchUser = async (): Promise => { try { - const res = await fetch(`${BASE_URL}/profile`, { + const sessionRes = await fetch(`${BASE_URL}/api/auth/get-session`, { credentials: "include", }); - if (res.ok) { - const data: any = await res.json(); - setUser(data); - } else { + + if (!sessionRes.ok) { + throw new Error("Failed to fetch session"); + } + + const sessionData = await sessionRes.json(); + + if (!sessionData?.session) { setUser(null); + return null; } - } catch { + + const profileRes = await fetch(`${BASE_URL}/profile`, { + credentials: "include", + }); + + if (!profileRes.ok) { + throw new Error("Failed to fetch profile"); + } + + const data: User = await profileRes.json(); + setUser(data); + return data; + } catch (error) { setUser(null); + throw error instanceof Error ? error : new Error("Failed to fetch user"); } finally { setLoading(false); } @@ -46,7 +66,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { // biome-ignore lint/correctness/useExhaustiveDependencies: TODO: useEventEffect in 19 useEffect(() => { - fetchUser(); + fetchUser().catch(() => undefined); }, []); return ( diff --git a/src/pages/Checkout.tsx b/src/pages/Checkout.tsx index 50741f0..f7eaa50 100644 --- a/src/pages/Checkout.tsx +++ b/src/pages/Checkout.tsx @@ -57,20 +57,40 @@ function isObject(val: unknown): val is Record { return typeof val === "object" && val !== null; } -function parsePaymentIntentResponse(obj: unknown): PaymentIntentResponse | null { +function parsePaymentIntentResponse( + obj: unknown, +): PaymentIntentResponse | null { if (!isObject(obj)) return null; const record = obj as Record; - const checkout_url = typeof record.checkout_url === "string" ? record.checkout_url : undefined; - const clientSecret = typeof record.clientSecret === "string" ? record.clientSecret : undefined; - const client_secret = typeof record.client_secret === "string" ? record.client_secret : undefined; + const checkout_url = + typeof record.checkout_url === "string" ? record.checkout_url : undefined; + const clientSecret = + typeof record.clientSecret === "string" ? record.clientSecret : undefined; + const client_secret = + typeof record.client_secret === "string" ? record.client_secret : undefined; const amount = typeof record.amount === "number" ? record.amount : undefined; - const currency = typeof record.currency === "string" ? record.currency : undefined; + const currency = + typeof record.currency === "string" ? record.currency : undefined; const orderId = typeof record.orderId === "string" || typeof record.orderId === "number" ? (record.orderId as string | number) : undefined; - if (checkout_url || clientSecret || client_secret || amount || currency || orderId) - return { checkout_url, clientSecret, client_secret, amount, currency, orderId }; + if ( + checkout_url || + clientSecret || + client_secret || + amount || + currency || + orderId + ) + return { + checkout_url, + clientSecret, + client_secret, + amount, + currency, + orderId, + }; return null; } @@ -216,7 +236,9 @@ export default function Checkout() { }); if (!res.ok) { const text = await res.text(); - throw new Error(`Payment intent request failed (${res.status}): ${text}`); + throw new Error( + `Payment intent request failed (${res.status}): ${text}`, + ); } const dataJson: unknown = await res.json(); const data = parsePaymentIntentResponse(dataJson); @@ -240,7 +262,9 @@ export default function Checkout() { navigate(`/order/${data.orderId}`); } } catch (err: unknown) { - setCartError(err instanceof Error ? err.message : "Payment intent failed"); + setCartError( + err instanceof Error ? err.message : "Payment intent failed", + ); } finally { setCartLoading(false); } diff --git a/src/pages/OrderComplete.tsx b/src/pages/OrderComplete.tsx index 6010c2e..c7e172b 100644 --- a/src/pages/OrderComplete.tsx +++ b/src/pages/OrderComplete.tsx @@ -1,5 +1,5 @@ -import { Link } from "react-router-dom"; import { useEffect } from "react"; +import { Link } from "react-router-dom"; export default function OrderComplete() { useEffect(() => { @@ -7,19 +7,24 @@ export default function OrderComplete() { localStorage.removeItem("cartId"); } catch (e) { // ignore localStorage errors in some environments - console.log('Could not clear cartId from localStorage', e); + console.log("Could not clear cartId from localStorage", e); } }, []); return (
-

Thank you — your order is complete

+

+ Thank you — your order is complete +

- We received your payment. You will receive an email confirmation shortly. + We received your payment. You will receive an email confirmation + shortly.

- + Back to shop diff --git a/src/pages/Payment.tsx b/src/pages/Payment.tsx index 498ce7f..f9e8257 100644 --- a/src/pages/Payment.tsx +++ b/src/pages/Payment.tsx @@ -1,12 +1,12 @@ -import { useMemo, useState } from "react"; -import { useLocation, useNavigate } from "react-router-dom"; -import { loadStripe, type PaymentIntentResult } from "@stripe/stripe-js"; import { Elements, PaymentElement, - useStripe, useElements, + useStripe, } from "@stripe/react-stripe-js"; +import { loadStripe, type PaymentIntentResult } from "@stripe/stripe-js"; +import { useMemo, useState } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; const publishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY as string; const stripePromise = loadStripe(publishableKey); @@ -28,7 +28,7 @@ function PaymentForm() { elements, confirmParams: { // You can change return_url to an order confirmation route - return_url: window.location.origin + "/order/complete", + return_url: `${window.location.origin}/order/complete`, }, redirect: "if_required", }); @@ -69,6 +69,10 @@ export default function PaymentPage() { // Prefer router state (in-memory) to avoid exposing secrets in the URL. const state = (loc.state as { clientSecret?: string } | null) ?? null; const clientSecret = state?.clientSecret ?? q.get("client_secret"); + const options = useMemo( + () => (clientSecret ? { clientSecret } : undefined), + [clientSecret], + ); if (!publishableKey) { return ( @@ -79,13 +83,9 @@ export default function PaymentPage() { } if (!clientSecret) { - return ( -
No payment session available.
- ); + return
No payment session available.
; } - const options = useMemo(() => ({ clientSecret }), [clientSecret]); - return (
diff --git a/src/pages/Product.test.tsx b/src/pages/Product.test.tsx index 7d77ab4..adf038f 100644 --- a/src/pages/Product.test.tsx +++ b/src/pages/Product.test.tsx @@ -6,10 +6,8 @@ import { waitForElementToBeRemoved, } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { Suspense } from "react"; import { createMemoryRouter, RouterProvider } from "react-router-dom"; import { vi } from "vitest"; -import ProductPage from "./Product"; // Ensure BASE_URL/DOMAIN never point to real endpoints in tests vi.mock("../config", () => ({ @@ -44,7 +42,7 @@ vi.mock("../components/Gallery", () => ({ ), })); -vi.mock("../components/ColorPicker", () => ({ +vi.mock("../components/ColorPickerWrapper", () => ({ default: ({ filamentType }: { filamentType: string }) => (
Color Picker for {filamentType}
), @@ -78,6 +76,8 @@ vi.mock("../context/CartContext", () => ({ }), })); +const mockColorDispatch = vi.fn(); + vi.mock("../context/ColorContext", () => ({ useColorContext: () => ({ state: { @@ -86,12 +86,18 @@ vi.mock("../context/ColorContext", () => ({ isLoading: false, hasInitialized: true, }, - dispatch: vi.fn(), + dispatch: mockColorDispatch, }), })); describe("ProductPage", () => { - beforeEach(async () => { + let ProductPage: (typeof import("./Product"))["default"]; + + beforeAll(async () => { + ({ default: ProductPage } = await import("./Product")); + }); + + const renderProductPage = async () => { // ✅ Mock fetch with a complete Product response vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: true, @@ -114,11 +120,7 @@ describe("ProductPage", () => { [ { path: "/product/:id", - element: ( - Loading...
}> - - - ), + element: , }, ], { @@ -133,14 +135,18 @@ describe("ProductPage", () => { screen.queryByText(/Loading product/i), ); await screen.findByText("RC Wheels"); - }); + + return userEvent.setup(); + }; afterEach(() => { vi.restoreAllMocks(); + mockColorDispatch.mockReset(); }); - it("renders ProductPage with product details", async () => { - expect(await screen.findByText("RC Wheels")).toBeInTheDocument(); + it("renders product details and supporting controls", async () => { + await renderProductPage(); + expect(screen.getByText("$35")).toBeInTheDocument(); expect(screen.getByText("Description")).toBeInTheDocument(); expect( @@ -148,61 +154,29 @@ describe("ProductPage", () => { "This is a 12mm RC buggy wheel that will fit any modern buggy for 1/10 scale racing.", ), ).toBeInTheDocument(); - }); - - it("renders PreviewComponent in a Suspense wrapper", async () => { expect(await screen.findByTestId("preview-component")).toBeInTheDocument(); - }); - it("displays the initial filament type as PLA", async () => { - const filamentDropdown = await screen.findByTestId("filament-dropdown"); + const filamentDropdown = screen.getByTestId("filament-dropdown"); expect(filamentDropdown).toHaveValue("PLA"); + + expect(await screen.findByRole("button", { name: "Add to cart" })).toBeInTheDocument(); + expect(await screen.findByTestId("color-picker")).toHaveTextContent( + "Color Picker for PLA", + ); }); it("updates filament selection when dropdown value changes", async () => { - const user = userEvent.setup(); - - // Make sure the product is fully loaded in THIS test's async turn - await screen.findByText("RC Wheels"); + const user = await renderProductPage(); + const select = screen.getByTestId("filament-dropdown"); - const select = await screen.findByTestId("filament-dropdown"); - - // Sanity check initial value expect(select).toHaveValue("PLA"); - // Change selection using the actual option node - await user.selectOptions( - select, - screen.getByRole("option", { name: "PETG" }), - ); + await user.selectOptions(select, screen.getByRole("option", { name: "PETG" })); - // Re-assert on the SAME select after state settles - await screen.findByTestId("filament-dropdown"); // forces re-query in case of remounts await waitFor(() => expect(select).toHaveValue("PETG")); - }); - - it.skip("passes selected filament to ColorPicker", async () => { - const colorPicker = await screen.findByTestId("color-picker"); - expect(colorPicker).toHaveTextContent("Color Picker for PLA"); - }); - - it.skip("updates ColorPicker filamentType when filament selection changes", async () => { - const user = userEvent.setup(); - const filamentDropdown = await screen.findByTestId("filament-dropdown"); - await user.selectOptions( - filamentDropdown, - screen.getByRole("option", { name: "PETG" }), - ); expect(await screen.findByTestId("color-picker")).toHaveTextContent( "Color Picker for PETG", ); }); - - it("renders Add to cart button", async () => { - const addToCartButton = await screen.findByRole("button", { - name: "Add to cart", - }); - expect(addToCartButton).toBeInTheDocument(); - }); }); diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx index 0c34072..596d2cc 100644 --- a/src/pages/Profile.tsx +++ b/src/pages/Profile.tsx @@ -1,7 +1,29 @@ import { type ChangeEvent, type FormEvent, useEffect, useState } from "react"; import toast from "react-hot-toast"; import InputField from "../components/InputField"; -import { BASE_URL, DOMAIN } from "../config"; +import { BASE_URL } from "../config"; +import { base64urlToUint8Array, bufferToBase64url } from "../utils/webauthn"; + +interface PasskeyAuthenticator { + credentialID?: string; + credentialId?: string; + id?: string; +} + +interface ProfileFormData extends Profile { + shippingAddress?: string; +} + +interface ProfileUpdateErrorDetail { + message?: string; + path?: string[]; + code?: string; +} + +interface ProfileUpdateErrorResponse { + error?: string; + details?: ProfileUpdateErrorDetail[]; +} // Small internal display component for read-only profile fields const Info = ({ @@ -15,7 +37,7 @@ const Info = ({ {label} - + {value === undefined || value === null || value === "" ? ( — ) : ( @@ -27,11 +49,13 @@ const Info = ({ const Profile = () => { const [profile, setProfile] = useState(undefined); - const [authenticators, setAuthenticators] = useState([]); + const [authenticators, setAuthenticators] = useState( + [], + ); const [message, setMessage] = useState(""); // kept for passkey flows const [isEditing, setIsEditing] = useState(false); const [isSaving, setIsSaving] = useState(false); - const [form, setForm] = useState>({}); + const [form, setForm] = useState>({}); const [error, setError] = useState(""); const getProfile = async () => { @@ -43,113 +67,108 @@ const Profile = () => { const data = (await res.json()) as Profile; setProfile(data); setForm(data); - } catch (err: any) { - setError(err.message || "Failed to load profile"); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Failed to load profile"); } }; const getAuthenticators = async () => { - const res = await fetch(`${BASE_URL}/webauthn/authenticators`, { + const res = await fetch(`${BASE_URL}/api/auth/passkey/list-user-passkeys`, { credentials: "include", }); if (!res.ok) throw new Error("Failed to fetch authenticators"); - const data: any[] = await res.json(); + const data = (await res.json()) as PasskeyAuthenticator[]; setAuthenticators(data); }; const handleAddPasskey = async () => { try { setMessage("Starting passkey registration..."); - const beginRes = await fetch(`${BASE_URL}/webauthn/register/begin`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - credentials: "include", - body: JSON.stringify({ email: profile?.email }), - }); + const beginRes = await fetch( + `${BASE_URL}/api/auth/passkey/generate-register-options`, + { + method: "GET", + credentials: "include", + }, + ); if (!beginRes.ok) { - setMessage("Begin registration failed"); + const body = (await beginRes.json().catch(() => ({}))) as { + message?: string; + code?: string; + }; + setMessage( + body.message || body.code || "Failed to get registration options", + ); return; } - const options = (await beginRes.json()) as { - challenge: string; - user: { id: string; [key: string]: any }; - [key: string]: any; - }; + const options = + (await beginRes.json()) as PublicKeyCredentialCreationOptions & { + challenge: string; + user: { id: string; [key: string]: unknown }; + }; - // Convert Base64URL to Base64 - const base64ToBase64Url = (base64url: string): string => { - return base64url - .replace(/-/g, "+") - .replace(/_/g, "/") - .padEnd(base64url.length + ((4 - (base64url.length % 4)) % 4), "="); + const publicKey: PublicKeyCredentialCreationOptions = { + ...options, + challenge: base64urlToUint8Array(options.challenge).slice(0) + .buffer as ArrayBuffer, + user: { + ...options.user, + id: base64urlToUint8Array(options.user.id).slice(0) + .buffer as ArrayBuffer, + }, }; - const challengeBase64 = base64ToBase64Url(options.challenge); - - const credential = (await navigator.credentials - .create({ - publicKey: { - ...options, - challenge: Uint8Array.from(atob(challengeBase64), (c) => - c.charCodeAt(0), - ), - - user: { - ...options.user, - id: Uint8Array.from(String(options.user.id), (c) => - c.charCodeAt(0), - ), - displayName: "", - name: "", - }, - pubKeyCredParams: [ - { alg: -8, type: "public-key" }, - { alg: -7, type: "public-key" }, - { alg: -257, type: "public-key" }, - ], - // rp: { id: 'rc-store.benhalverson.dev', name: "Lulu's Raceshop" }, - rp: { id: `${DOMAIN}`, name: "Lulu's Raceshop" }, - }, - }) - .catch((err) => { - console.error("Error creating credential:", err); - setMessage("Error creating credential"); - })) as PublicKeyCredential | null; + const credential = (await navigator.credentials.create({ + publicKey, + })) as PublicKeyCredential | null; if (!credential) { setMessage("User cancelled passkey creation"); return; } + const attestationResponse = + credential.response as AuthenticatorAttestationResponse; + const credentialResponse = { id: credential.id, - rawId: btoa(String.fromCharCode(...new Uint8Array(credential.rawId))), + rawId: bufferToBase64url(credential.rawId), type: credential.type, response: { - clientDataJSON: btoa( - String.fromCharCode( - ...new Uint8Array(credential.response.clientDataJSON), - ), - ), - attestationObject: btoa( - String.fromCharCode( - ...new Uint8Array((credential.response as any).attestationObject), - ), + clientDataJSON: bufferToBase64url(attestationResponse.clientDataJSON), + attestationObject: bufferToBase64url( + attestationResponse.attestationObject, ), + transports: attestationResponse.getTransports + ? attestationResponse.getTransports() + : [], }, + clientExtensionResults: credential.getClientExtensionResults(), }; - const finishRes = await fetch(`${BASE_URL}/webauthn/register/finish`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - credentials: "include", - body: JSON.stringify(credentialResponse), - }); + const verifyPayload = { + response: credentialResponse, + credentialId: credentialResponse.id, + }; + + const finishRes = await fetch( + `${BASE_URL}/api/auth/passkey/verify-registration`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify(verifyPayload), + }, + ); if (!finishRes.ok) { - setMessage("Finish registration failed"); + const body = (await finishRes.json().catch(() => ({}))) as { + message?: string; + code?: string; + }; + setMessage(body.message || body.code || "Passkey registration failed"); return; } @@ -161,11 +180,23 @@ const Profile = () => { ); } }; - const handleRemove = async (id: string) => { - await fetch(`${BASE_URL}/webauthn/authenticators/${id}`, { - method: "DELETE", + + const handleRemove = async (passkeyId: string) => { + const res = await fetch(`${BASE_URL}/api/auth/passkey/delete-passkey`, { + method: "POST", + headers: { "Content-Type": "application/json" }, credentials: "include", + body: JSON.stringify({ id: passkeyId }), }); + + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { + message?: string; + code?: string; + }; + throw new Error(body.message || body.code || "Failed to remove passkey"); + } + await getAuthenticators(); }; @@ -201,9 +232,9 @@ const Profile = () => { ...profile, ...form, shippingAddress: - (form as any).shippingAddress || - (form as any).address || - (profile as any).shippingAddress || + form.shippingAddress || + form.address || + (profile as ProfileFormData).shippingAddress || profile.address, }; const toastId = toast.loading("Saving profile..."); @@ -218,11 +249,11 @@ const Profile = () => { if (!res.ok) { let detailsMsg = "Failed to update profile"; try { - const data: any = await res.json(); + const data = (await res.json()) as ProfileUpdateErrorResponse; if (data?.error) detailsMsg = data.error; if (data?.details && Array.isArray(data.details)) { const list = data.details - .map((d: any) => d.message || `${d.path?.join(".")}: ${d.code}`) + .map((d) => d.message || `${d.path?.join(".")}: ${d.code}`) .join("; "); if (list) detailsMsg += `: ${list}`; } @@ -238,9 +269,9 @@ const Profile = () => { setForm(updated); setIsEditing(false); toast.success("Profile updated", { id: toastId }); - } catch (err: any) { + } catch (err: unknown) { console.error("Error updating profile:", err); - const msg = err.message || "Update failed"; + const msg = err instanceof Error ? err.message : "Update failed"; setError(msg); toast.error(msg, { id: toastId }); } finally { @@ -286,10 +317,10 @@ const Profile = () => { - {(profile as any).shippingAddress && ( + {(profile as ProfileFormData).shippingAddress && ( )} @@ -334,9 +365,7 @@ const Profile = () => { {

)}
    - {authenticators.map((auth: any) => ( + {authenticators.map((auth, index) => { + const passkeyId = auth.id; + const credentialId = auth.credentialID || auth.credentialId || auth.id; + + return (
  • - - {auth.credentialId.slice(0, 18)}... + + {credentialId || "Unknown passkey"}
  • - ))} + ); + })}
diff --git a/src/pages/Signin.test.tsx b/src/pages/Signin.test.tsx new file mode 100644 index 0000000..5405d25 --- /dev/null +++ b/src/pages/Signin.test.tsx @@ -0,0 +1,211 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { createMemoryRouter, RouterProvider } from "react-router-dom"; +import { vi } from "vitest"; +import SigninPage from "./Signin"; + +vi.mock("../config", () => ({ + BASE_URL: "http://test.local", + DOMAIN: "test.local", +})); + +const mockFetchUser = vi.fn(); +vi.mock("../context/AuthContext", () => ({ + useAuth: () => ({ fetchUser: mockFetchUser }), +})); + +vi.mock("react-hot-toast", () => { + const toast = Object.assign(vi.fn(), { + loading: vi.fn(() => "toast-id"), + success: vi.fn(), + error: vi.fn(), + }); + return { default: toast }; +}); + +const mockNavigate = vi.fn(); +vi.mock("react-router-dom", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useNavigate: () => mockNavigate }; +}); + +function renderSignin() { + const router = createMemoryRouter([{ path: "/", element: }], { + initialEntries: ["/"], + }); + render(); +} + +describe("Signin – password tab", () => { + afterEach(() => { + vi.restoreAllMocks(); + mockFetchUser.mockReset(); + mockNavigate.mockReset(); + }); + + it("renders email and password inputs", () => { + renderSignin(); + expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); + }); + + it("calls /auth/signin, fetches user, and navigates on success", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + json: async () => ({}), + } as Response); + mockFetchUser.mockResolvedValueOnce({ email: "user@example.com" }); + + renderSignin(); + const user = userEvent.setup(); + + await user.type(screen.getByLabelText(/email/i), "user@example.com"); + await user.type(screen.getByLabelText(/password/i), "password123"); + await user.click( + screen.getByRole("button", { name: /sign in with password/i }), + ); + + await waitFor(() => expect(mockFetchUser).toHaveBeenCalledTimes(1)); + expect(mockNavigate).toHaveBeenCalledWith("/profile"); + }); + + it("shows backend error message on failed sign-in", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: false, + json: async () => ({ message: "Invalid credentials" }), + } as Response); + + const toast = await import("react-hot-toast"); + + renderSignin(); + const user = userEvent.setup(); + + await user.type(screen.getByLabelText(/email/i), "bad@example.com"); + await user.type(screen.getByLabelText(/password/i), "wrongpassword"); + await user.click( + screen.getByRole("button", { name: /sign in with password/i }), + ); + + await waitFor(() => + expect(toast.default.error).toHaveBeenCalledWith( + expect.stringContaining("Invalid credentials"), + expect.anything(), + ), + ); + expect(mockNavigate).not.toHaveBeenCalled(); + }); +}); + +describe("Signin – passkey tab", () => { + beforeEach(() => { + Object.defineProperty(globalThis.navigator, "credentials", { + value: { get: vi.fn() }, + configurable: true, + writable: true, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + mockFetchUser.mockReset(); + mockNavigate.mockReset(); + }); + + async function switchToPasskeyTab() { + renderSignin(); + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: /passkey/i })); + return user; + } + + it("renders the passkey button after switching tabs", async () => { + await switchToPasskeyTab(); + expect( + screen.getByRole("button", { name: /passkey login/i }), + ).toBeInTheDocument(); + }); + + it("shows error when generate-authenticate-options request fails", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: false, + json: async () => ({ message: "No passkey registered" }), + } as Response); + + const toast = await import("react-hot-toast"); + const user = await switchToPasskeyTab(); + + await user.click(screen.getByRole("button", { name: /passkey login/i })); + + await waitFor(() => + expect(toast.default.error).toHaveBeenCalledWith( + expect.stringContaining("No passkey registered"), + expect.anything(), + ), + ); + }); + + it("sends correct payload to verify-authentication and navigates on success", async () => { + const fakeOptions = { + challenge: "dGVzdC1jaGFsbGVuZ2U", + allowCredentials: [{ id: "Y3JlZC1pZA", type: "public-key" }], + timeout: 60000, + }; + + const fakeCredential = { + id: "cred-id-base64url", + rawId: new Uint8Array([1, 2, 3]).buffer, + type: "public-key", + response: { + authenticatorData: new Uint8Array([4, 5, 6]).buffer, + clientDataJSON: new Uint8Array([7, 8, 9]).buffer, + signature: new Uint8Array([10, 11, 12]).buffer, + userHandle: null, + }, + getClientExtensionResults: () => ({}), + } as unknown as PublicKeyCredential; + + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce({ + ok: true, + json: async () => fakeOptions, + } as Response) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ verified: true }), + } as Response); + + ( + navigator.credentials.get as ReturnType + ).mockResolvedValueOnce(fakeCredential); + mockFetchUser.mockResolvedValueOnce({ email: "user@example.com" }); + + const user = await switchToPasskeyTab(); + await user.click(screen.getByRole("button", { name: /passkey login/i })); + + await waitFor(() => expect(mockFetchUser).toHaveBeenCalledTimes(1)); + + // Verify the verify-authentication request body structure + const verifyCall = fetchSpy.mock.calls[1]; + expect(verifyCall[0]).toContain("/api/auth/passkey/verify-authentication"); + const body = JSON.parse(verifyCall[1]?.body as string); + + expect(body).toMatchObject({ + response: { + id: "cred-id-base64url", + rawId: "AQID", + type: "public-key", + response: { + authenticatorData: expect.any(String), + clientDataJSON: expect.any(String), + signature: expect.any(String), + userHandle: null, + }, + clientExtensionResults: {}, + }, + credentialId: "cred-id-base64url", + }); + + expect(mockNavigate).toHaveBeenCalledWith("/profile"); + }); +}); diff --git a/src/pages/Signin.tsx b/src/pages/Signin.tsx index 48910ab..541aa59 100644 --- a/src/pages/Signin.tsx +++ b/src/pages/Signin.tsx @@ -6,13 +6,13 @@ import { useNavigate } from "react-router-dom"; import { z } from "zod"; import { BASE_URL } from "../config"; import { useAuth } from "../context/AuthContext"; -import { base64urlToUint8Array, bufferToBase64 } from "../utils/webauthn"; +import { base64urlToUint8Array, bufferToBase64url } from "../utils/webauthn"; const Signin = () => { const navigate = useNavigate(); const [loading, setLoading] = useState(false); const [tab, setTab] = useState<"password" | "passkey">("password"); - const { setUser } = useAuth(); + const { fetchUser } = useAuth(); const schema = z.object({ email: z.string().email({ message: "Invalid email address" }), @@ -31,18 +31,6 @@ const Signin = () => { resolver: zodResolver(schema), }); - // For passkey, we only need email - const { - register: registerPasskey, - getValues: getValuesPasskey, - formState: { errors: errorsPasskey }, - handleSubmit: handleSubmitPasskey, - } = useForm<{ email: string }>({ - resolver: zodResolver( - z.object({ email: z.string().email({ message: "Invalid email" }) }), - ), - }); - const onSubmitPasswordLogin = async (data: FormData) => { setLoading(true); const toastId = toast.loading("Signing in..."); @@ -53,13 +41,19 @@ const Signin = () => { credentials: "include", body: JSON.stringify(data), }); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { + message?: string; + code?: string; + }; + throw new Error(body.message || body.code || "Invalid credentials"); + } + + const authenticatedUser = await fetchUser(); + if (!authenticatedUser) { + throw new Error("Session could not be confirmed"); + } - if (!res.ok) throw new Error("Invalid credentials"); - const profileRes = await fetch(`${BASE_URL}/profile`, { - credentials: "include", - }); - const profileData: any = await profileRes.json(); - setUser(profileData); toast.success("Signed in!", { id: toastId }); navigate("/profile"); } catch (err: unknown) { @@ -73,46 +67,49 @@ const Signin = () => { } }; - const handlePasskeyLogin = async (data?: { email: string }) => { - const email = data?.email || getValuesPasskey("email"); - if (!email) { - toast.error("Enter your email to continue with passkey"); - return; - } - + const handlePasskeyLogin = async () => { setLoading(true); const toastId = toast.loading("Authenticating with passkey..."); try { - const beginRes = await fetch(`${BASE_URL}/webauthn/auth/begin`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - credentials: "include", - body: JSON.stringify({ email }), - }); + const optionsRes = await fetch( + `${BASE_URL}/api/auth/passkey/generate-authenticate-options`, + { credentials: "include" }, + ); - if (!beginRes.ok) - throw new Error("User not registered or no passkey found"); + if (!optionsRes.ok) { + const body = (await optionsRes.json().catch(() => ({}))) as { + message?: string; + code?: string; + }; + throw new Error( + body.message || body.code || "Failed to get passkey options", + ); + } - const { options } = (await beginRes.json()) as { - options: PublicKeyCredentialRequestOptions; + const rawOptions = (await optionsRes.json()) as { + challenge: string; + timeout?: number; + rpId?: string; + userVerification?: UserVerificationRequirement; + allowCredentials?: Array<{ + id: string; + type: PublicKeyCredentialType; + transports?: AuthenticatorTransport[]; + }>; }; - // @ts-ignore - options.challenge = base64urlToUint8Array( - options.challenge as unknown as string, - ).buffer; - // @ts-ignore - options.allowCredentials = options.allowCredentials?.map((cred) => ({ - ...cred, - id: - typeof cred.id === "string" - ? base64urlToUint8Array(cred.id) - : cred.id, - })); + const publicKey: PublicKeyCredentialRequestOptions = { + ...rawOptions, + challenge: base64urlToUint8Array(rawOptions.challenge), + allowCredentials: rawOptions.allowCredentials?.map((cred) => ({ + ...cred, + id: base64urlToUint8Array(cred.id), + })), + }; const credential = (await navigator.credentials.get({ - publicKey: options, + publicKey, })) as PublicKeyCredential | null; if (!credential) throw new Error("User cancelled passkey login"); @@ -120,40 +117,54 @@ const Signin = () => { const authResp = credential.response as AuthenticatorAssertionResponse; const payload = { - email, + id: credential.id, + rawId: bufferToBase64url(credential.rawId), + type: credential.type, response: { - id: base64urlToUint8Array(credential.id), - rawId: bufferToBase64(credential.rawId), - type: credential.type, - response: { - authenticatorData: bufferToBase64(authResp.authenticatorData), - clientDataJSON: bufferToBase64(authResp.clientDataJSON), - signature: bufferToBase64(authResp.signature), - userHandle: authResp.userHandle - ? bufferToBase64(authResp.userHandle) - : null, - }, + authenticatorData: bufferToBase64url(authResp.authenticatorData), + clientDataJSON: bufferToBase64url(authResp.clientDataJSON), + signature: bufferToBase64url(authResp.signature), + userHandle: authResp.userHandle + ? bufferToBase64url(authResp.userHandle) + : null, }, + clientExtensionResults: credential.getClientExtensionResults(), }; - const finishRes = await fetch(`${BASE_URL}/webauthn/auth/finish`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - credentials: "include", - body: JSON.stringify(payload), - }); + const verifyPayload = { + response: payload, + credentialId: payload.id, + }; - if (!finishRes.ok) { - const result = (await finishRes.json()) as { error?: string }; - console.log("error", result); - throw new Error(result.error || "Passkey verification failed"); + const verifyRes = await fetch( + `${BASE_URL}/api/auth/passkey/verify-authentication`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify(verifyPayload), + }, + ); + + if (!verifyRes.ok) { + const body = (await verifyRes.json().catch(() => ({}))) as { + message?: string; + code?: string; + }; + throw new Error( + body.message || body.code || "Passkey verification failed", + ); + } + + const authenticatedUser = await fetchUser(); + if (!authenticatedUser) { + throw new Error("Session could not be confirmed"); } toast.success("Passkey login successful!", { id: toastId }); navigate("/profile"); } catch (err: unknown) { if (err instanceof Error) { - console.log("catch error", err); toast.error(`Login failed: ${err.message}`, { id: toastId }); } else { toast.error("Login failed", { id: toastId }); @@ -207,6 +218,7 @@ const Signin = () => { Email { Password { {/* Passkey Login Tab */} {tab === "passkey" && ( -
-
- - - {errorsPasskey.email && ( -

- {errorsPasskey.email.message} -

- )} -
+
+

+ Sign in using a passkey registered on this device or a security key. +

- +
)}
); diff --git a/src/pages/Signup.test.tsx b/src/pages/Signup.test.tsx new file mode 100644 index 0000000..469835a --- /dev/null +++ b/src/pages/Signup.test.tsx @@ -0,0 +1,129 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { createMemoryRouter, RouterProvider } from "react-router-dom"; +import { vi } from "vitest"; +import SignupPage from "./Signup"; + +vi.mock("../config", () => ({ + BASE_URL: "http://test.local", + DOMAIN: "test.local", +})); + +const mockFetchUser = vi.fn(); +vi.mock("../context/AuthContext", () => ({ + useAuth: () => ({ fetchUser: mockFetchUser }), +})); + +vi.mock("react-hot-toast", () => { + const toast = Object.assign(vi.fn(), { + loading: vi.fn(() => "toast-id"), + success: vi.fn(), + error: vi.fn(), + }); + return { default: toast }; +}); + +const mockNavigate = vi.fn(); +vi.mock("react-router-dom", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useNavigate: () => mockNavigate }; +}); + +function renderSignup() { + const router = createMemoryRouter([{ path: "/", element: }], { + initialEntries: ["/"], + }); + render(); +} + +describe("Signup", () => { + afterEach(() => { + vi.restoreAllMocks(); + mockFetchUser.mockReset(); + mockNavigate.mockReset(); + }); + + it("shows backend error message on failed signup", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: false, + json: async () => ({ message: "Email already in use" }), + } as Response); + + const toast = await import("react-hot-toast"); + + renderSignup(); + const user = userEvent.setup(); + + await user.type(screen.getByLabelText(/email/i), "user@example.com"); + await user.type(screen.getByLabelText(/password/i), "password123"); + await user.click(screen.getByRole("button", { name: /^sign up$/i })); + + await waitFor(() => + expect(toast.default.error).toHaveBeenCalledWith( + expect.stringContaining("Email already in use"), + expect.anything(), + ), + ); + }); + + it("does not show signup success when session confirmation fails", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + json: async () => ({ success: true }), + } as Response); + mockFetchUser.mockResolvedValueOnce(null); + + const toast = await import("react-hot-toast"); + + renderSignup(); + const user = userEvent.setup(); + + await user.type(screen.getByLabelText(/email/i), "user@example.com"); + await user.type(screen.getByLabelText(/password/i), "password123"); + await user.click(screen.getByRole("button", { name: /^sign up$/i })); + + await waitFor(() => + expect(toast.default.error).toHaveBeenCalledWith( + expect.stringContaining("session could not be confirmed"), + expect.anything(), + ), + ); + expect( + screen.queryByText(/add a passkey to my account/i), + ).not.toBeInTheDocument(); + }); + + it("shows backend error when register options request fails", async () => { + vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ success: true }), + } as Response) + .mockResolvedValueOnce({ + ok: false, + json: async () => ({ message: "Passkey options unavailable" }), + } as Response); + mockFetchUser.mockResolvedValueOnce({ email: "user@example.com" }); + + const toast = await import("react-hot-toast"); + + renderSignup(); + const user = userEvent.setup(); + + await user.type(screen.getByLabelText(/email/i), "user@example.com"); + await user.type(screen.getByLabelText(/password/i), "password123"); + await user.click(screen.getByRole("button", { name: /^sign up$/i })); + await screen.findByRole("button", { name: /add passkey to my account/i }); + + await user.click( + screen.getByRole("button", { name: /add passkey to my account/i }), + ); + + await waitFor(() => + expect(toast.default.error).toHaveBeenCalledWith( + expect.stringContaining("Passkey options unavailable"), + expect.anything(), + ), + ); + }); +}); diff --git a/src/pages/Signup.tsx b/src/pages/Signup.tsx index 848ee37..9bec488 100644 --- a/src/pages/Signup.tsx +++ b/src/pages/Signup.tsx @@ -1,11 +1,12 @@ -import { Tab } from "@headlessui/react"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useState } from "react"; import { useForm } from "react-hook-form"; -import { toast } from "react-hot-toast"; +import toast from "react-hot-toast"; import { useNavigate } from "react-router-dom"; import { z } from "zod"; import { BASE_URL } from "../config"; -import { base64urlToUint8Array, bufferToBase64 } from "../utils/webauthn"; +import { useAuth } from "../context/AuthContext"; +import { base64urlToUint8Array, bufferToBase64url } from "../utils/webauthn"; const schema = z.object({ email: z.email({ error: "Invalid email" }), @@ -14,22 +15,52 @@ const schema = z.object({ type SignupFormData = z.infer; +const getErrorMessage = async (response: Response, fallback: string) => { + const body = (await (async () => { + try { + if (typeof response.clone === "function") { + return await response.clone().json(); + } + + if (typeof response.json === "function") { + return await response.json(); + } + } catch { + return null; + } + + return null; + })()) as { + message?: string; + code?: string; + error?: string; + } | null; + + if (body) { + return body.message || body.error || body.code || fallback; + } + + const text = await response.text().catch(() => ""); + return text || fallback; +}; + const Signup = () => { const resolver = zodResolver(schema); const navigate = useNavigate(); + const { fetchUser } = useAuth(); + const [accountCreated, setAccountCreated] = useState(false); + const [passkeyRegistering, setPasskeyRegistering] = useState(false); const { register, handleSubmit, formState: { errors }, - getValues, - reset, } = useForm({ resolver, }); const handlePasswordSignup = async (data: SignupFormData) => { - const toastId = toast.loading("Signing up..."); + const toastId = toast.loading("Creating account..."); try { const res = await fetch(`${BASE_URL}/auth/signup`, { method: "POST", @@ -37,10 +68,17 @@ const Signup = () => { credentials: "include", body: JSON.stringify(data), }); - if (!res.ok) throw new Error("Signup failed"); + if (!res.ok) { + throw new Error(await getErrorMessage(res, "Signup failed")); + } - toast.success("Signup successful!", { id: toastId }); - navigate("/profile"); + const authenticatedUser = await fetchUser(); + if (!authenticatedUser) { + throw new Error("Account created, but session could not be confirmed"); + } + + toast.success("Account created!", { id: toastId }); + setAccountCreated(true); } catch (err: unknown) { if (err instanceof Error) { toast.error(`Error: ${err.message}`, { id: toastId }); @@ -50,74 +88,101 @@ const Signup = () => { } }; - const handlePasskeyOnlySignup = async () => { - const email = getValues("email"); - if (!email) return toast.error("Please enter an email"); - - const toastId = toast.loading("Creating passkey account..."); + const handlePasskeyRegistration = async () => { + setPasskeyRegistering(true); + const toastId = toast.loading("Registering passkey..."); try { - const res = await fetch(`${BASE_URL}/auth/signup-passkey-only`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - credentials: "include", - body: JSON.stringify({ email }), - }); - if (!res.ok) throw new Error("Failed to initiate passkey signup"); - - // Begin WebAuthn registration - const beginRes = await fetch(`${BASE_URL}/webauthn/register/begin`, { - method: "POST", - credentials: "include", - }); - if (!beginRes.ok) - throw new Error("Failed to begin WebAuthn registration"); + const optionsRes = await fetch( + `${BASE_URL}/api/auth/passkey/generate-register-options`, + { credentials: "include" }, + ); + if (!optionsRes.ok) { + throw new Error( + await getErrorMessage( + optionsRes, + "Failed to get registration options", + ), + ); + } - const options = - (await beginRes.json()) as PublicKeyCredentialCreationOptions; + const rawOptions = (await optionsRes.json()) as { + challenge: string; + user: { id: string; name: string; displayName: string }; + [key: string]: unknown; + }; - // @ts-ignore - options.challenge = base64urlToUint8Array( - options.challenge as unknown as string, - ); - // @ts-ignore - options.user.id = base64urlToUint8Array( - options.user.id as unknown as string, - ); + const options = { + ...rawOptions, + challenge: base64urlToUint8Array(rawOptions.challenge).slice(0) + .buffer as ArrayBuffer, + user: { + ...rawOptions.user, + id: base64urlToUint8Array(rawOptions.user.id).slice(0) + .buffer as ArrayBuffer, + }, + } as unknown as PublicKeyCredentialCreationOptions; const credential = (await navigator.credentials.create({ publicKey: options, - })) as PublicKeyCredential; - if (!credential) throw new Error("User cancelled credential creation"); + })) as PublicKeyCredential | null; + if (!credential) throw new Error("Passkey creation was cancelled"); - const response = credential.response as AuthenticatorAttestationResponse; + const attestationResponse = + credential.response as AuthenticatorAttestationResponse; - const formatted = { + const serialized = { id: credential.id, - rawId: bufferToBase64(credential.rawId), + rawId: bufferToBase64url(credential.rawId), type: credential.type, response: { - clientDataJSON: bufferToBase64(response.clientDataJSON), - attestationObject: bufferToBase64(response.attestationObject), + clientDataJSON: bufferToBase64url(attestationResponse.clientDataJSON), + attestationObject: bufferToBase64url( + attestationResponse.attestationObject, + ), + transports: attestationResponse.getTransports + ? attestationResponse.getTransports() + : [], }, + clientExtensionResults: credential.getClientExtensionResults(), }; - const finishRes = await fetch(`${BASE_URL}/webauthn/register/finish`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - credentials: "include", - body: JSON.stringify(formatted), - }); - if (!finishRes.ok) throw new Error("Failed to finish WebAuthn"); + const verifyPayload = { + response: serialized, + credentialId: serialized.id, + }; + + const verifyRes = await fetch( + `${BASE_URL}/api/auth/passkey/verify-registration`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify(verifyPayload), + }, + ); + if (!verifyRes.ok) { + throw new Error( + await getErrorMessage(verifyRes, "Passkey registration failed"), + ); + } + + const authenticatedUser = await fetchUser(); + if (!authenticatedUser) { + throw new Error( + "Passkey registered, but session could not be confirmed", + ); + } - toast.success("Passkey signup complete!", { id: toastId }); + toast.success("Passkey registered successfully!", { id: toastId }); navigate("/profile"); - reset(); } catch (err: unknown) { if (err instanceof Error) { toast.error(`Error: ${err.message}`, { id: toastId }); } else { toast.error("An unknown error occurred", { id: toastId }); } + } finally { + setPasskeyRegistering(false); } }; @@ -127,105 +192,71 @@ const Signup = () => { Sign Up - - - - `w-full py-2 text-sm font-medium rounded-lg focus:outline-none ${ - selected - ? "bg-blue-600 text-white" - : "bg-transparent text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-700" - }` - }> - Password - - - `w-full py-2 text-sm font-medium rounded-lg focus:outline-none ${ - selected - ? "bg-blue-600 text-white" - : "bg-transparent text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-700" - }` - }> - Passkey Only - - - - - -
-
- - - {errors.email && ( -

{errors.email.message}

- )} -
- -
- - - {errors.password && ( -

- {errors.password.message} -

- )} -
- - -
-
- - -
-
- - - {errors.email && ( -

{errors.email.message}

- )} -
- - -
-
-
-
+ {!accountCreated ? ( +
+
+ + + {errors.email && ( +

{errors.email.message}

+ )} +
+ +
+ + + {errors.password && ( +

{errors.password.message}

+ )} +
+ + +
+ ) : ( +
+

+ Your account has been created and you are signed in. Would you like + to add a passkey for faster, passwordless sign-in in the future? +

+ + +
+ )}
); }; diff --git a/src/utils/webauthn.ts b/src/utils/webauthn.ts index a062105..e62a177 100644 --- a/src/utils/webauthn.ts +++ b/src/utils/webauthn.ts @@ -9,3 +9,10 @@ export const base64urlToUint8Array = (input: string): Uint8Array => { export const bufferToBase64 = (buffer: ArrayBuffer): string => { return btoa(String.fromCharCode(...new Uint8Array(buffer))); }; + +export const bufferToBase64url = (buffer: ArrayBuffer): string => { + return btoa(String.fromCharCode(...new Uint8Array(buffer))) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, ""); +};