Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions next-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/dev/types/root-params.d.ts";
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
118 changes: 118 additions & 0 deletions src/app/(public)/login/__tests__/sign-in-action-wiring.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

/**
* RUK-290 — the two `"use server"` wrappers in `login/page.tsx` actually forward
* `rememberMe`. SPEC §3.2 site 2.
*
* **Why this file exists.** Ship review mutated both wrappers to pass a literal
* `rememberMe: false` and the entire gate stayed green — 219 contract tests,
* 1466 unit tests and `tsc` all silent — while the checkbox stopped working on
* both sign-in methods at once. That is the widest silent failure in the change
* and, until this file, the only one nothing covered.
*
* SPEC §3.2 lists site 2 as "not silent" because widening a signature breaks its
* callers. True for *adding* the parameter; useless against *bypassing* it
* afterwards, which is the shape a regression actually takes. `false` satisfies
* `boolean`, so the compiler has nothing to say.
*
* Nothing else reaches these wrappers: the four `LoginPage` component tests pass
* inert stubs (`otpSignInAction={async () => ({})}`) because they are testing
* the component, not the page that composes it. The chain is proven from the
* checkbox down to `credentialsSignInAction`, and from `credentialsSignInAction`
* to the wire — this is the joint between those two halves.
*
* The page is an async server component, so it is invoked as a function and its
* returned element inspected for the props it hands down. The wrappers are then
* called directly, which is what a submitting form does.
*/

/** The shape `credentialsSignInAction` receives; only `rememberMe` is asserted. */
type SignInInput = {
kind: "otp" | "password";
email: string;
code?: string;
password?: string;
rememberMe: boolean;
next?: string;
};

const credentialsSignInAction = vi.fn<(input: SignInInput) => Promise<{ error?: string }>>();

vi.mock("@/server/auth/built-in-sign-in-actions", () => ({
credentialsSignInAction: (input: SignInInput) => credentialsSignInAction(input),
requestOtpAction: vi.fn(async () => ({})),
changeEmailAction: vi.fn(async () => {}),
}));

vi.mock("@/server/auth/password-reset-actions", () => ({
requestPasswordResetAction: vi.fn(async () => ({})),
confirmPasswordResetAction: vi.fn(async () => ({})),
abandonPasswordResetAction: vi.fn(async () => {}),
}));

vi.mock("@/server/auth/otp-nonce-cookie", () => ({
readPasswordResetBinding: vi.fn(async () => undefined),
}));

vi.mock("@/server/auth/auth-config", () => ({ signIn: vi.fn(async () => {}) }));

vi.mock("@/server/backend/auth/resolve-auth-providers", () => ({
resolveAuthProviders: vi.fn(async () => ({ ok: true, methods: [] })),
}));

// The page renders <LoginPage/>; the element's props are the subject here, so
// the component itself is replaced by a marker that keeps them inspectable.
vi.mock("@/features/auth/login-page", () => ({ LoginPage: () => null }));

const Page = (await import("@/app/(public)/login/page")).default;

type SignInProps = {
otpSignInAction: (email: string, code: string, rememberMe: boolean) => Promise<{ error?: string }>;
passwordSignInAction: (email: string, password: string, rememberMe: boolean) => Promise<{ error?: string }>;
};

/** Invokes the server component and returns the props it hands to LoginPage. */
async function signInProps(): Promise<SignInProps> {
const element = await Page({ searchParams: Promise.resolve({}) });
return (element as unknown as { props: SignInProps }).props;
}

beforeEach(() => {
credentialsSignInAction.mockReset();
credentialsSignInAction.mockResolvedValue({});
});

/** What the action actually received, read from the mock rather than the input. */
function actionArg(): SignInInput {
const [first] = credentialsSignInAction.mock.calls;
expect(first).toBeDefined();
return first![0];
}

describe("login page — the sign-in wrappers forward the remember-me choice", () => {
it.each([true, false])("passes rememberMe %s through the password wrapper", async (choice) => {
const { passwordSignInAction } = await signInProps();

await passwordSignInAction("admin@example.test", "hunter2", choice);

expect(actionArg()).toMatchObject({ kind: "password", rememberMe: choice });
});

it.each([true, false])("passes rememberMe %s through the OTP wrapper", async (choice) => {
const { otpSignInAction } = await signInProps();

await otpSignInAction("admin@example.test", "123456", choice);

expect(actionArg()).toMatchObject({ kind: "otp", rememberMe: choice });
});

it("still forwards the credentials alongside the flag", async () => {
// Guards against a wrapper that satisfies the assertions above by passing
// the flag and dropping something else.
const { passwordSignInAction } = await signInProps();

await passwordSignInAction("admin@example.test", "hunter2", true);

expect(actionArg()).toMatchObject({ email: "admin@example.test", password: "hunter2" });
});
});
8 changes: 4 additions & 4 deletions src/app/(public)/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,14 +63,14 @@ export default async function Page({
* server, and the sanitized `redirectTo` is closed over here so a client can
* never supply a destination of its own and route around `safeNext`.
*/
async function otpSignInAction(email: string, code: string) {
async function otpSignInAction(email: string, code: string, rememberMe: boolean) {
"use server";
return credentialsSignInAction({ kind: "otp", email, code, next: redirectTo });
return credentialsSignInAction({ kind: "otp", email, code, rememberMe, next: redirectTo });
}

async function passwordSignInAction(email: string, password: string) {
async function passwordSignInAction(email: string, password: string, rememberMe: boolean) {
"use server";
return credentialsSignInAction({ kind: "password", email, password, next: redirectTo });
return credentialsSignInAction({ kind: "password", email, password, rememberMe, next: redirectTo });
}

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { AcceptInvitePage } from "../accept-invite-page";
import { LoginPage } from "../login-page";

// jsdom implements no ResizeObserver, and Radix's Checkbox measures itself via
// `useSize`. Without this every test in the file dies on render rather than on
// an assertion. Inline rather than a shared helper: `src/features/**` may not
// import `@/shared/testing/**` (eslint no-restricted-imports), and that
// boundary is worth more than deduplicating six lines. Same stub as
// `src/features/settings/__tests__/timezone-card.test.tsx`.
globalThis.ResizeObserver ??= class {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;

afterEach(() => cleanup());

const noopAccept = vi.fn(async () => {});
Expand Down
12 changes: 12 additions & 0 deletions src/features/auth/__tests__/login-page-methods.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ import { afterEach, describe, expect, it } from "vitest";
import { LoginPage } from "@/features/auth/login-page";
import type { SignInMethod } from "@/domain/auth/sign-in-method";

// jsdom implements no ResizeObserver, and Radix's Checkbox measures itself via
// `useSize`. Without this every test in the file dies on render rather than on
// an assertion. Inline rather than a shared helper: `src/features/**` may not
// import `@/shared/testing/**` (eslint no-restricted-imports), and that
// boundary is worth more than deduplicating six lines. Same stub as
// `src/features/settings/__tests__/timezone-card.test.tsx`.
globalThis.ResizeObserver ??= class {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;

/**
* RUK-288 AC-1 / AC-2 / AC-11 — the login page is drawn from the backend's
* method list, and cannot lock anyone out when that list is unavailable.
Expand Down
12 changes: 12 additions & 0 deletions src/features/auth/__tests__/login-page-reset.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/re
import { LoginPage } from "@/features/auth/login-page";
import type { SignInMethod } from "@/domain/auth/sign-in-method";

// jsdom implements no ResizeObserver, and Radix's Checkbox measures itself via
// `useSize`. Without this every test in the file dies on render rather than on
// an assertion. Inline rather than a shared helper: `src/features/**` may not
// import `@/shared/testing/**` (eslint no-restricted-imports), and that
// boundary is worth more than deduplicating six lines. Same stub as
// `src/features/settings/__tests__/timezone-card.test.tsx`.
globalThis.ResizeObserver ??= class {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;

afterEach(() => cleanup());

const PASSWORD_METHOD: SignInMethod = {
Expand Down
12 changes: 12 additions & 0 deletions src/features/auth/__tests__/login-page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@ import { afterEach, describe, expect, it, vi } from "vitest";

import { LoginPage } from "../login-page";

// jsdom implements no ResizeObserver, and Radix's Checkbox measures itself via
// `useSize`. Without this every test in the file dies on render rather than on
// an assertion. Inline rather than a shared helper: `src/features/**` may not
// import `@/shared/testing/**` (eslint no-restricted-imports), and that
// boundary is worth more than deduplicating six lines. Same stub as
// `src/features/settings/__tests__/timezone-card.test.tsx`.
globalThis.ResizeObserver ??= class {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;

// This config has no global testing-library auto-cleanup, so unmount between
// tests to keep the document free of stale renders.
afterEach(() => cleanup());
Expand Down
Loading
Loading