Files
publisher-dashboard/apps/api-server/src/__tests__/e2e/auth.test.ts
igm 665092464a Fix all linter errors
- Remove unused biome suppression comment in completions.ts
- Remove unnecessary if condition in execute-bootstrap.test.ts
- Add eslint-disable comments for any type assertions in client.test.ts
- Add eslint-disable comments for expect().rejects patterns
- Fix template literal number expression with toString()
- Fix error handling in test-db.ts to avoid object stringify

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 17:30:00 +08:00

2189 lines
68 KiB
TypeScript

/**
* End-to-end tests for Auth procedures
*
* These tests cover ALL login scenarios from docs/initial-app.md:
*
* SIGNUP FLOWS:
* - Signup with password
* - Signup with passkey
*
* LOGIN FLOWS (from docs/initial-app.md Step 2: Authentication):
* - Has passkey → full passkey authentication flow
* - Has password + trusted device → immediate completion
* - Has password + new device → requires email confirmation
* - No password AND no passkey → polling stays pending
* - Non-existent email → anti-enumeration (fake token, returns pending)
*
* OTHER FLOWS:
* - Email verification flow
* - Password reset flow (with session revocation)
* - Logout
*
* Procedures tested:
* - auth.signup - create account with password or passkey
* - auth.createLoginRequest - first step of login flow
* - auth.loginPassword - password verification
* - auth.loginPasswordConfirm - email confirmation for untrusted devices
* - auth.loginIfRequestIsCompleted - poll for login completion
* - auth.webauthn.createRegistrationOptions - passkey registration
* - auth.webauthn.verifyRegistration - passkey registration verification
* - auth.webauthn.createAuthenticationOptions - passkey authentication
* - auth.webauthn.verifyAuthentication - passkey authentication verification
* - auth.verifyEmail - verify email token
* - auth.resendVerificationEmail - resend verification
* - auth.forgotPassword - request password reset
* - auth.resetPassword - reset with token
* - auth.logout - revoke session
*/
import type { Database } from "@reviq/db-schema";
import type { Kysely } from "kysely";
import type { APIContext } from "../../context.js";
import { beforeAll, describe, expect, test } from "bun:test";
import { call } from "@orpc/server";
import { createLoggingEmailClient } from "@reviq/emails";
import {
createTestUser,
describeE2E,
getSharedDb,
initTestDb,
TEST_RP,
uniqueTestId,
withTestTransaction,
} from "@reviq/test-helpers";
import { VirtualAuthenticator } from "@reviq/virtual-authenticator";
import { router } from "../../router.js";
import { COOKIE_NAMES } from "../../utils/cookies.js";
import { hashToken } from "../../utils/crypto.js";
import { hashPassword } from "../../utils/password.js";
/** Session expiry duration: 24 hours in milliseconds */
const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000;
/** Login request expiry: 15 minutes */
const LOGIN_REQUEST_EXPIRY_MS = 15 * 60 * 1000;
/**
* Create an API context with optional cookies
*/
function createAPIContext(
db: Kysely<Database>,
options?: {
sessionToken?: string;
loginRequestToken?: string;
deviceFingerprint?: string;
},
): APIContext {
const reqHeaders = new Headers();
const cookies: string[] = [];
if (options?.sessionToken) {
cookies.push(`${COOKIE_NAMES.SESSION_TOKEN}=${options.sessionToken}`);
}
if (options?.loginRequestToken) {
cookies.push(
`${COOKIE_NAMES.LOGIN_REQUEST_TOKEN}=${options.loginRequestToken}`,
);
}
if (options?.deviceFingerprint) {
cookies.push(
`${COOKIE_NAMES.DEVICE_FINGERPRINT}=${options.deviceFingerprint}`,
);
}
if (cookies.length > 0) {
reqHeaders.set("cookie", cookies.join("; "));
}
return {
db,
origin: TEST_RP.origin,
allowedOrigins: [...TEST_RP.allowedOrigins],
rpName: TEST_RP.rpName,
reqHeaders,
resHeaders: new Headers(),
email: {
client: createLoggingEmailClient(),
fromAddress: "test@example.com",
baseUrl: TEST_RP.origin,
},
};
}
/**
* Extract cookie value from response headers
*/
function getCookieFromResponse(
headers: Headers,
cookieName: string,
): string | null {
const setCookies = headers.getSetCookie();
for (const cookie of setCookies) {
if (cookie.startsWith(`${cookieName}=`)) {
const parts = cookie.split(";")[0]?.split("=");
const value = parts?.[1] ?? "";
// Check if it's a deletion (empty value or max-age=0)
if (cookie.includes("Max-Age=0") || value === "") {
return null;
}
return value;
}
}
return null;
}
/**
* Assert a value is not null/undefined and return it with narrowed type.
* Use this instead of non-null assertions (!) to satisfy linter.
*/
function assertDefined<T>(
value: T | null | undefined,
message = "Expected value to be defined",
): T {
if (value == null) {
throw new Error(message);
}
return value;
}
/**
* Create a session for a user and return the token
*/
async function createSession(
db: Kysely<Database>,
userId: number,
options?: { deviceId?: bigint },
): Promise<{ token: string; sessionId: number }> {
const token = `test-session-${uniqueTestId()}`;
const tokenHashValue = await hashToken(token);
const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS);
const result = await db
.insertInto("sessions")
.values({
user_id: userId,
device_id: options?.deviceId ? options.deviceId.toString() : null,
token_hash: tokenHashValue,
trusted_mode: false,
expires_at: expiresAt,
})
.returning(["id"])
.executeTakeFirstOrThrow();
return { token, sessionId: Number(result.id) };
}
/**
* Create a login request for a user
*/
async function createLoginRequest(
db: Kysely<Database>,
userId: number,
email: string,
options?: {
deviceFingerprint?: string;
completedAt?: Date | null;
expiresAt?: Date;
},
): Promise<{ token: string; id: number }> {
const token = `login_test-${uniqueTestId()}`;
const expiresAt =
options?.expiresAt ?? new Date(Date.now() + LOGIN_REQUEST_EXPIRY_MS);
const result = await db
.insertInto("login_requests")
.values({
user_id: userId,
email,
token,
device_fingerprint: options?.deviceFingerprint ?? "test-fingerprint",
expires_at: expiresAt,
completed_at: options?.completedAt ?? null,
})
.returning(["id"])
.executeTakeFirstOrThrow();
return { token, id: Number(result.id) };
}
/**
* Create a trusted device for a user
*/
async function createTrustedDevice(
db: Kysely<Database>,
userId: number,
fingerprint: string,
): Promise<bigint> {
const result = await db
.insertInto("user_devices")
.values({
user_id: userId,
device_fingerprint: fingerprint,
is_trusted: true,
user_agent: "Test Browser",
})
.returning(["id"])
.executeTakeFirstOrThrow();
return BigInt(result.id);
}
/**
* Create an email verification token
*/
async function createEmailVerification(
db: Kysely<Database>,
userId: number,
options?: { expiresAt?: Date },
): Promise<string> {
const token = `verify-${uniqueTestId()}`;
const expiresAt =
options?.expiresAt ?? new Date(Date.now() + 24 * 60 * 60 * 1000);
await db
.insertInto("email_verifications")
.values({
user_id: userId,
token,
expires_at: expiresAt,
})
.execute();
return token;
}
/**
* Create a password reset token
*/
async function createPasswordReset(
db: Kysely<Database>,
userId: number,
options?: { expiresAt?: Date; usedAt?: Date | null },
): Promise<string> {
const token = `reset-${uniqueTestId()}`;
const expiresAt = options?.expiresAt ?? new Date(Date.now() + 60 * 60 * 1000);
await db
.insertInto("password_resets")
.values({
user_id: userId,
token,
expires_at: expiresAt,
used_at: options?.usedAt ?? null,
})
.execute();
return token;
}
describeE2E("auth", () => {
// Test setup
beforeAll(async () => {
await initTestDb();
});
// =============================================================================
// auth.signup tests
// =============================================================================
describe("auth.signup", () => {
test("creates user with valid password", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const ctx = createAPIContext(db);
const result = await call(
router.auth.signup,
{ email: "newuser@example.com", password: "StrongP@ssw0rd123!" },
{ context: ctx },
);
expect(result.success).toBe(true);
// Verify user was created
const user = await db
.selectFrom("users")
.selectAll()
.where("email", "=", "newuser@example.com")
.executeTakeFirst();
expect(user).toBeDefined();
expect(user?.password_hash).not.toBeNull();
expect(user?.email_verified_at).toBeNull();
// Verify session cookie was set
const sessionToken = getCookieFromResponse(
ctx.resHeaders,
COOKIE_NAMES.SESSION_TOKEN,
);
expect(sessionToken).not.toBeNull();
// Verify session was created in DB
const sessions = await db
.selectFrom("sessions")
.selectAll()
.where("user_id", "=", assertDefined(user).id)
.execute();
expect(sessions.length).toBe(1);
// Verify email verification token was created
const verifications = await db
.selectFrom("email_verifications")
.selectAll()
.where("user_id", "=", assertDefined(user).id)
.execute();
expect(verifications.length).toBe(1);
});
});
test("normalizes email to lowercase", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const ctx = createAPIContext(db);
await call(
router.auth.signup,
{ email: "UPPERCASE@EXAMPLE.COM", password: "StrongP@ssw0rd123!" },
{ context: ctx },
);
const user = await db
.selectFrom("users")
.select(["email"])
.where("email", "=", "uppercase@example.com")
.executeTakeFirst();
expect(user).toBeDefined();
});
});
test("rejects weak password", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const ctx = createAPIContext(db);
await expect(
call(
router.auth.signup,
{ email: "weak@example.com", password: "password" },
{ context: ctx },
),
).rejects.toThrow();
});
});
test("rejects duplicate email (anti-enumeration)", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
// Create existing user
await createTestUser(db, { email: "existing@example.com" });
const ctx = createAPIContext(db);
await expect(
call(
router.auth.signup,
{ email: "existing@example.com", password: "StrongP@ssw0rd123!" },
{ context: ctx },
),
).rejects.toThrow("Unable to create account");
});
});
test("rejects signup without password or passkey", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const ctx = createAPIContext(db);
await expect(
call(
router.auth.signup,
{ email: "noauth@example.com" },
{ context: ctx },
),
).rejects.toThrow();
});
});
// Note: This test uses getSharedDb() directly (not withTestTransaction) because
// the signup procedure internally uses db.transaction(), and Kysely doesn't support
// nested transactions.
test("creates user with passkey", async () => {
const db = getSharedDb();
const authenticator = new VirtualAuthenticator({
origin: TEST_RP.origin,
});
const ctx = createAPIContext(db);
// Step 1: Create registration options
const { options, challengeId } = await call(
router.auth.webauthn.createRegistrationOptions,
{ email: "passkeyuser@example.com" },
{ context: ctx },
);
// Step 2: Create credential with virtual authenticator
const response = authenticator.createCredential(options);
// Step 3: Signup with passkey
const signupCtx = createAPIContext(db);
const result = await call(
router.auth.signup,
{
email: "passkeyuser@example.com",
passkeyInfo: { challengeId, response },
},
{ context: signupCtx },
);
expect(result.success).toBe(true);
// Verify user was created
const user = await db
.selectFrom("users")
.selectAll()
.where("email", "=", "passkeyuser@example.com")
.executeTakeFirst();
expect(user).toBeDefined();
expect(user?.password_hash).toBeNull(); // No password for passkey signup
expect(user?.email_verified_at).toBeNull();
// Verify passkey was stored
const passkeys = await db
.selectFrom("passkeys")
.selectAll()
.where("user_id", "=", assertDefined(user).id)
.execute();
expect(passkeys.length).toBe(1);
expect(passkeys[0]?.name).toBeDefined();
// Verify session cookie was set
const sessionToken = getCookieFromResponse(
signupCtx.resHeaders,
COOKIE_NAMES.SESSION_TOKEN,
);
expect(sessionToken).not.toBeNull();
// Verify webauthn challenge was deleted
const challenges = await db
.selectFrom("webauthn_challenges")
.selectAll()
.where("id", "=", challengeId.toString())
.execute();
expect(challenges.length).toBe(0);
});
test("rejects passkey signup with expired challenge", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const authenticator = new VirtualAuthenticator({
origin: TEST_RP.origin,
});
const ctx = createAPIContext(db);
// Step 1: Create registration options
const { options, challengeId } = await call(
router.auth.webauthn.createRegistrationOptions,
{ email: "expiredchallenge@example.com" },
{ context: ctx },
);
// Step 2: Create credential
const response = authenticator.createCredential(options);
// Step 3: Expire the challenge by updating created_at
await db
.updateTable("webauthn_challenges")
.set({ created_at: new Date(Date.now() - 20 * 60 * 1000) }) // 20 minutes ago
.where("id", "=", challengeId.toString())
.execute();
// Step 4: Try to signup with expired challenge
const signupCtx = createAPIContext(db);
await expect(
call(
router.auth.signup,
{
email: "expiredchallenge@example.com",
passkeyInfo: { challengeId, response },
},
{ context: signupCtx },
),
).rejects.toThrow("Registration timed out");
});
});
test("rejects passkey signup with invalid response", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const authenticator = new VirtualAuthenticator({
origin: TEST_RP.origin,
});
const ctx = createAPIContext(db);
// Step 1: Create registration options
const { options, challengeId } = await call(
router.auth.webauthn.createRegistrationOptions,
{ email: "invalidresponse@example.com" },
{ context: ctx },
);
// Step 2: Create credential
const response = authenticator.createCredential(options);
// Step 3: Tamper with the response
response.response.clientDataJSON = "dGFtcGVyZWQ"; // "tampered" in base64
// Step 4: Try to signup with invalid response
const signupCtx = createAPIContext(db);
await expect(
call(
router.auth.signup,
{
email: "invalidresponse@example.com",
passkeyInfo: { challengeId, response },
},
{ context: signupCtx },
),
).rejects.toThrow("Failed to register your device");
// Verify challenge was deleted (cleanup on error)
const challenges = await db
.selectFrom("webauthn_challenges")
.selectAll()
.where("id", "=", challengeId.toString())
.execute();
expect(challenges.length).toBe(0);
});
});
});
// =============================================================================
// auth.createLoginRequest tests
// =============================================================================
describe("auth.createLoginRequest", () => {
test("returns auth methods for existing user with password", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
await createTestUser(db, {
email: "haspassword@example.com",
passwordHash: await hashPassword("TestPassword123!"),
});
const ctx = createAPIContext(db);
const result = await call(
router.auth.createLoginRequest,
{ email: "haspassword@example.com" },
{ context: ctx },
);
expect(result.hasPassword).toBe(true);
expect(result.hasPasskey).toBe(false);
expect(result.isTrustedDevice).toBe(false);
expect(result.email).toBe("haspassword@example.com");
// Verify login request was created
const loginRequests = await db
.selectFrom("login_requests")
.selectAll()
.execute();
expect(loginRequests.length).toBe(1);
// Verify login request token cookie was set
const token = getCookieFromResponse(
ctx.resHeaders,
COOKIE_NAMES.LOGIN_REQUEST_TOKEN,
);
expect(token).not.toBeNull();
expect(token).toStartWith("login_");
});
});
test("detects trusted device", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "trusted@example.com",
passwordHash: await hashPassword("TestPassword123!"),
});
const fingerprint = "trusted-device-fp";
await createTrustedDevice(db, user.id, fingerprint);
const ctx = createAPIContext(db, { deviceFingerprint: fingerprint });
const result = await call(
router.auth.createLoginRequest,
{ email: "trusted@example.com" },
{ context: ctx },
);
expect(result.isTrustedDevice).toBe(true);
});
});
test("returns fake response for non-existent user (anti-enumeration)", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const ctx = createAPIContext(db);
const result = await call(
router.auth.createLoginRequest,
{ email: "nonexistent@example.com" },
{ context: ctx },
);
// Should return all false (same as user without any auth methods)
expect(result.hasPassword).toBe(false);
expect(result.hasPasskey).toBe(false);
expect(result.isTrustedDevice).toBe(false);
// Should still set a login request token cookie (fake one)
const token = getCookieFromResponse(
ctx.resHeaders,
COOKIE_NAMES.LOGIN_REQUEST_TOKEN,
);
expect(token).not.toBeNull();
// Should NOT create a login request in DB
const loginRequests = await db
.selectFrom("login_requests")
.selectAll()
.execute();
expect(loginRequests.length).toBe(0);
});
});
test("normalizes email to lowercase", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
await createTestUser(db, {
email: "lowercase@example.com",
passwordHash: await hashPassword("TestPassword123!"),
});
const ctx = createAPIContext(db);
const result = await call(
router.auth.createLoginRequest,
{ email: "LOWERCASE@EXAMPLE.COM" },
{ context: ctx },
);
expect(result.hasPassword).toBe(true);
});
});
test("generates device fingerprint if not present", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
await createTestUser(db, {
email: "nofingerprint@example.com",
passwordHash: await hashPassword("TestPassword123!"),
});
const ctx = createAPIContext(db); // No device fingerprint
await call(
router.auth.createLoginRequest,
{ email: "nofingerprint@example.com" },
{ context: ctx },
);
// Should set device fingerprint cookie
const fingerprint = getCookieFromResponse(
ctx.resHeaders,
COOKIE_NAMES.DEVICE_FINGERPRINT,
);
expect(fingerprint).not.toBeNull();
});
});
});
// =============================================================================
// auth.loginPassword tests
// =============================================================================
describe("auth.loginPassword", () => {
test("completes login immediately for trusted device", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "trustedlogin@example.com",
passwordHash: await hashPassword("TestPassword123!"),
});
const fingerprint = "trusted-login-fp";
await createTrustedDevice(db, user.id, fingerprint);
const { token: loginToken } = await createLoginRequest(
db,
user.id,
"trustedlogin@example.com",
{ deviceFingerprint: fingerprint },
);
const ctx = createAPIContext(db, {
loginRequestToken: loginToken,
deviceFingerprint: fingerprint,
});
const result = await call(
router.auth.loginPassword,
{ password: "TestPassword123!" },
{ context: ctx },
);
expect(result.success).toBe(true);
// Verify login request was marked as completed
const loginRequest = await db
.selectFrom("login_requests")
.select(["completed_at"])
.where("token", "=", loginToken)
.executeTakeFirst();
expect(loginRequest?.completed_at).not.toBeNull();
});
});
test("sends email for untrusted device (does not complete immediately)", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "untrustedlogin@example.com",
passwordHash: await hashPassword("TestPassword123!"),
});
const fingerprint = "untrusted-login-fp";
const { token: loginToken } = await createLoginRequest(
db,
user.id,
"untrustedlogin@example.com",
{ deviceFingerprint: fingerprint },
);
const ctx = createAPIContext(db, {
loginRequestToken: loginToken,
deviceFingerprint: fingerprint,
});
const result = await call(
router.auth.loginPassword,
{ password: "TestPassword123!" },
{ context: ctx },
);
expect(result.success).toBe(true);
// Verify login request was NOT marked as completed (needs email confirmation)
const loginRequest = await db
.selectFrom("login_requests")
.select(["completed_at"])
.where("token", "=", loginToken)
.executeTakeFirst();
expect(loginRequest?.completed_at).toBeNull();
});
});
test("rejects invalid password", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "wrongpass@example.com",
passwordHash: await hashPassword("CorrectPassword123!"),
});
const { token: loginToken } = await createLoginRequest(
db,
user.id,
"wrongpass@example.com",
);
const ctx = createAPIContext(db, { loginRequestToken: loginToken });
await expect(
call(
router.auth.loginPassword,
{ password: "WrongPassword123!" },
{ context: ctx },
),
).rejects.toThrow("Invalid email or password");
});
});
test("rejects expired login request", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "expired@example.com",
passwordHash: await hashPassword("TestPassword123!"),
});
const { token: loginToken } = await createLoginRequest(
db,
user.id,
"expired@example.com",
{ expiresAt: new Date(Date.now() - 1000) }, // Expired
);
const ctx = createAPIContext(db, { loginRequestToken: loginToken });
await expect(
call(
router.auth.loginPassword,
{ password: "TestPassword123!" },
{ context: ctx },
),
).rejects.toThrow("Login request has expired");
});
});
test("rejects when no login request token cookie", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const ctx = createAPIContext(db); // No login request token
await expect(
call(
router.auth.loginPassword,
{ password: "TestPassword123!" },
{ context: ctx },
),
).rejects.toThrow("Invalid email or password");
});
});
test("rejects fake/invalid login request token", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const ctx = createAPIContext(db, {
loginRequestToken: "fake-token-12345",
});
await expect(
call(
router.auth.loginPassword,
{ password: "TestPassword123!" },
{ context: ctx },
),
).rejects.toThrow("Invalid email or password");
});
});
test("rejects user without password set", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "nopassword@example.com",
// No password hash
});
const { token: loginToken } = await createLoginRequest(
db,
user.id,
"nopassword@example.com",
);
const ctx = createAPIContext(db, { loginRequestToken: loginToken });
await expect(
call(
router.auth.loginPassword,
{ password: "AnyPassword123!" },
{ context: ctx },
),
).rejects.toThrow("Invalid email or password");
});
});
});
// =============================================================================
// auth.loginPasswordConfirm tests
// =============================================================================
describe("auth.loginPasswordConfirm", () => {
test("marks login request as completed with valid token", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "confirm@example.com",
});
const { token: loginToken } = await createLoginRequest(
db,
user.id,
"confirm@example.com",
);
const ctx = createAPIContext(db);
const result = await call(
router.auth.loginPasswordConfirm,
{ token: loginToken },
{ context: ctx },
);
expect(result.success).toBe(true);
// Verify login request was marked as completed
const loginRequest = await db
.selectFrom("login_requests")
.select(["completed_at"])
.where("token", "=", loginToken)
.executeTakeFirst();
expect(loginRequest?.completed_at).not.toBeNull();
});
});
test("is idempotent for already completed requests", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "idempotent@example.com",
});
const { token: loginToken } = await createLoginRequest(
db,
user.id,
"idempotent@example.com",
{ completedAt: new Date() }, // Already completed
);
const ctx = createAPIContext(db);
const result = await call(
router.auth.loginPasswordConfirm,
{ token: loginToken },
{ context: ctx },
);
expect(result.success).toBe(true);
});
});
test("rejects invalid token", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const ctx = createAPIContext(db);
await expect(
call(
router.auth.loginPasswordConfirm,
{ token: "invalid-token" },
{ context: ctx },
),
).rejects.toThrow("Invalid or expired confirmation link");
});
});
test("rejects expired token", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "expiredconfirm@example.com",
});
const { token: loginToken } = await createLoginRequest(
db,
user.id,
"expiredconfirm@example.com",
{ expiresAt: new Date(Date.now() - 1000) }, // Expired
);
const ctx = createAPIContext(db);
await expect(
call(
router.auth.loginPasswordConfirm,
{ token: loginToken },
{ context: ctx },
),
).rejects.toThrow("Invalid or expired confirmation link");
});
});
});
// =============================================================================
// auth.loginIfRequestIsCompleted tests
// =============================================================================
describe("auth.loginIfRequestIsCompleted", () => {
test("returns pending for incomplete login request", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "pending@example.com",
});
const { token: loginToken } = await createLoginRequest(
db,
user.id,
"pending@example.com",
);
const ctx = createAPIContext(db, { loginRequestToken: loginToken });
const result = await call(
router.auth.loginIfRequestIsCompleted,
undefined,
{ context: ctx },
);
expect(result.status).toBe("pending");
});
});
test("returns expired for expired login request", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "expiredpoll@example.com",
});
const { token: loginToken } = await createLoginRequest(
db,
user.id,
"expiredpoll@example.com",
{ expiresAt: new Date(Date.now() - 1000) }, // Expired
);
const ctx = createAPIContext(db, { loginRequestToken: loginToken });
const result = await call(
router.auth.loginIfRequestIsCompleted,
undefined,
{ context: ctx },
);
expect(result.status).toBe("expired");
});
});
test("creates session and returns completed for completed request", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "completed@example.com",
});
const fingerprint = "completed-fp";
const { token: loginToken, id: loginRequestId } =
await createLoginRequest(db, user.id, "completed@example.com", {
deviceFingerprint: fingerprint,
completedAt: new Date(),
});
const ctx = createAPIContext(db, {
loginRequestToken: loginToken,
deviceFingerprint: fingerprint,
});
const result = await call(
router.auth.loginIfRequestIsCompleted,
undefined,
{ context: ctx },
);
expect(result.status).toBe("completed");
expect(result.redirectTo).toBe("/auth/trust-device"); // Not trusted yet
// Verify session was created
const sessions = await db
.selectFrom("sessions")
.selectAll()
.where("user_id", "=", user.id)
.execute();
expect(sessions.length).toBe(1);
expect(sessions[0]?.trusted_mode).toBe(true);
// Verify session cookie was set
const sessionToken = getCookieFromResponse(
ctx.resHeaders,
COOKIE_NAMES.SESSION_TOKEN,
);
expect(sessionToken).not.toBeNull();
// Verify login request was deleted
const loginRequest = await db
.selectFrom("login_requests")
.selectAll()
.where("id", "=", loginRequestId.toString())
.executeTakeFirst();
expect(loginRequest).toBeUndefined();
// Verify user device was created
const devices = await db
.selectFrom("user_devices")
.selectAll()
.where("user_id", "=", user.id)
.execute();
expect(devices.length).toBe(1);
});
});
test("redirects to dashboard if device is already trusted", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "alreadytrusted@example.com",
});
const fingerprint = "already-trusted-fp";
await createTrustedDevice(db, user.id, fingerprint);
const { token: loginToken } = await createLoginRequest(
db,
user.id,
"alreadytrusted@example.com",
{ deviceFingerprint: fingerprint, completedAt: new Date() },
);
const ctx = createAPIContext(db, {
loginRequestToken: loginToken,
deviceFingerprint: fingerprint,
});
const result = await call(
router.auth.loginIfRequestIsCompleted,
undefined,
{ context: ctx },
);
expect(result.status).toBe("completed");
expect(result.redirectTo).toBe("/dashboard");
});
});
test("returns pending for fake/non-existent token", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const ctx = createAPIContext(db, {
loginRequestToken: "fake-token-xyz",
});
const result = await call(
router.auth.loginIfRequestIsCompleted,
undefined,
{ context: ctx },
);
expect(result.status).toBe("pending");
});
});
test("returns pending when no cookie present", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const ctx = createAPIContext(db); // No login request token
const result = await call(
router.auth.loginIfRequestIsCompleted,
undefined,
{ context: ctx },
);
expect(result.status).toBe("pending");
});
});
test("returns pending when device fingerprint is missing", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "nofp@example.com",
});
// Create login request without device fingerprint
const token = `login_test-${uniqueTestId()}`;
await db
.insertInto("login_requests")
.values({
user_id: user.id,
email: "nofp@example.com",
token,
device_fingerprint: null, // No fingerprint
expires_at: new Date(Date.now() + LOGIN_REQUEST_EXPIRY_MS),
completed_at: new Date(),
})
.execute();
const ctx = createAPIContext(db, { loginRequestToken: token });
const result = await call(
router.auth.loginIfRequestIsCompleted,
undefined,
{ context: ctx },
);
expect(result.status).toBe("pending");
});
});
});
// =============================================================================
// auth.verifyEmail tests
// =============================================================================
describe("auth.verifyEmail", () => {
test("verifies email with valid token", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "verify@example.com",
});
const token = await createEmailVerification(db, user.id);
const ctx = createAPIContext(db);
const result = await call(
router.auth.verifyEmail,
{ token },
{ context: ctx },
);
expect(result.success).toBe(true);
// Verify user's email_verified_at was set
const updatedUser = await db
.selectFrom("users")
.select(["email_verified_at"])
.where("id", "=", user.id)
.executeTakeFirst();
expect(updatedUser?.email_verified_at).not.toBeNull();
// Verify verification record was deleted
const verifications = await db
.selectFrom("email_verifications")
.selectAll()
.where("user_id", "=", user.id)
.execute();
expect(verifications.length).toBe(0);
});
});
test("rejects invalid token", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const ctx = createAPIContext(db);
await expect(
call(
router.auth.verifyEmail,
{ token: "invalid-token" },
{ context: ctx },
),
).rejects.toThrow("Invalid or expired token");
});
});
test("rejects expired token and cleans up", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "expiredverify@example.com",
});
const token = await createEmailVerification(db, user.id, {
expiresAt: new Date(Date.now() - 1000), // Expired
});
const ctx = createAPIContext(db);
await expect(
call(router.auth.verifyEmail, { token }, { context: ctx }),
).rejects.toThrow("Invalid or expired token");
// Verify expired token was cleaned up
const verifications = await db
.selectFrom("email_verifications")
.selectAll()
.where("user_id", "=", user.id)
.execute();
expect(verifications.length).toBe(0);
});
});
});
// =============================================================================
// auth.resendVerificationEmail tests
// =============================================================================
describe("auth.resendVerificationEmail", () => {
test("creates new verification token for unverified user", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "resend@example.com",
});
const { token: sessionToken } = await createSession(db, user.id);
const ctx = createAPIContext(db, { sessionToken });
const result = await call(
router.auth.resendVerificationEmail,
undefined,
{
context: ctx,
},
);
expect(result.success).toBe(true);
// Verify new verification token was created
const verifications = await db
.selectFrom("email_verifications")
.selectAll()
.where("user_id", "=", user.id)
.execute();
expect(verifications.length).toBe(1);
});
});
test("deletes old verification tokens before creating new one", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "resendold@example.com",
});
// Create existing verification
await createEmailVerification(db, user.id);
const { token: sessionToken } = await createSession(db, user.id);
const ctx = createAPIContext(db, { sessionToken });
await call(router.auth.resendVerificationEmail, undefined, {
context: ctx,
});
// Should still have only 1 verification (old one deleted, new one created)
const verifications = await db
.selectFrom("email_verifications")
.selectAll()
.where("user_id", "=", user.id)
.execute();
expect(verifications.length).toBe(1);
});
});
test("returns success for already verified user (no-op)", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "alreadyverified@example.com",
emailVerifiedAt: new Date(),
});
const { token: sessionToken } = await createSession(db, user.id);
const ctx = createAPIContext(db, { sessionToken });
const result = await call(
router.auth.resendVerificationEmail,
undefined,
{
context: ctx,
},
);
expect(result.success).toBe(true);
// No verification token should be created
const verifications = await db
.selectFrom("email_verifications")
.selectAll()
.where("user_id", "=", user.id)
.execute();
expect(verifications.length).toBe(0);
});
});
test("requires authentication", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const ctx = createAPIContext(db); // No session
await expect(
call(router.auth.resendVerificationEmail, undefined, {
context: ctx,
}),
).rejects.toThrow();
});
});
});
// =============================================================================
// auth.forgotPassword tests
// =============================================================================
describe("auth.forgotPassword", () => {
test("creates password reset token for existing user", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "forgot@example.com",
});
const ctx = createAPIContext(db);
const result = await call(
router.auth.forgotPassword,
{ email: "forgot@example.com" },
{ context: ctx },
);
expect(result.success).toBe(true);
// Verify password reset token was created
const resets = await db
.selectFrom("password_resets")
.selectAll()
.where("user_id", "=", user.id)
.execute();
expect(resets.length).toBe(1);
});
});
test("returns success for non-existent user (anti-enumeration)", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const ctx = createAPIContext(db);
const result = await call(
router.auth.forgotPassword,
{ email: "nonexistent@example.com" },
{ context: ctx },
);
// Should still return success (anti-enumeration)
expect(result.success).toBe(true);
// No password reset should be created
const resets = await db
.selectFrom("password_resets")
.selectAll()
.execute();
expect(resets.length).toBe(0);
});
});
test("deletes existing password reset tokens before creating new one", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "forgotold@example.com",
});
// Create existing reset token
await createPasswordReset(db, user.id);
const ctx = createAPIContext(db);
await call(
router.auth.forgotPassword,
{ email: "forgotold@example.com" },
{ context: ctx },
);
// Should have only 1 reset token (old one deleted)
const resets = await db
.selectFrom("password_resets")
.selectAll()
.where("user_id", "=", user.id)
.execute();
expect(resets.length).toBe(1);
});
});
test("normalizes email to lowercase", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "forgotcase@example.com",
});
const ctx = createAPIContext(db);
await call(
router.auth.forgotPassword,
{ email: "FORGOTCASE@EXAMPLE.COM" },
{ context: ctx },
);
// Should find the user and create reset token
const resets = await db
.selectFrom("password_resets")
.selectAll()
.where("user_id", "=", user.id)
.execute();
expect(resets.length).toBe(1);
});
});
});
// =============================================================================
// auth.resetPassword tests
// =============================================================================
describe("auth.resetPassword", () => {
test("resets password with valid token", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "reset@example.com",
passwordHash: await hashPassword("OldPassword123!"),
});
const token = await createPasswordReset(db, user.id);
const ctx = createAPIContext(db);
const result = await call(
router.auth.resetPassword,
{ token, newPassword: "NewStrongP@ssw0rd!" },
{ context: ctx },
);
expect(result.success).toBe(true);
// Verify password was updated (can't directly verify hash, but check updated_at)
const updatedUser = await db
.selectFrom("users")
.select(["password_hash", "updated_at"])
.where("id", "=", user.id)
.executeTakeFirst();
expect(updatedUser?.password_hash).not.toBeNull();
// Verify reset token was marked as used
const reset = await db
.selectFrom("password_resets")
.select(["used_at"])
.where("token", "=", token)
.executeTakeFirst();
expect(reset?.used_at).not.toBeNull();
});
});
test("revokes all sessions after password reset", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "resetrevoke@example.com",
passwordHash: await hashPassword("OldPassword123!"),
});
// Create some sessions
await createSession(db, user.id);
const token = await createPasswordReset(db, user.id);
const ctx = createAPIContext(db);
await call(
router.auth.resetPassword,
{ token, newPassword: "NewStrongP@ssw0rd!" },
{ context: ctx },
);
// Verify all sessions were revoked
const sessions = await db
.selectFrom("sessions")
.select(["revoked_at"])
.where("user_id", "=", user.id)
.execute();
for (const session of sessions) {
expect(session.revoked_at).not.toBeNull();
}
});
});
test("rejects invalid token", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const ctx = createAPIContext(db);
await expect(
call(
router.auth.resetPassword,
{ token: "invalid-token", newPassword: "NewStrongP@ssw0rd!" },
{ context: ctx },
),
).rejects.toThrow("Invalid or expired reset token");
});
});
test("rejects expired token", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "resetexpired@example.com",
});
const token = await createPasswordReset(db, user.id, {
expiresAt: new Date(Date.now() - 1000), // Expired
});
const ctx = createAPIContext(db);
await expect(
call(
router.auth.resetPassword,
{ token, newPassword: "NewStrongP@ssw0rd!" },
{ context: ctx },
),
).rejects.toThrow("Reset token has expired");
});
});
test("rejects already used token", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "resetused@example.com",
});
const token = await createPasswordReset(db, user.id, {
usedAt: new Date(), // Already used
});
const ctx = createAPIContext(db);
await expect(
call(
router.auth.resetPassword,
{ token, newPassword: "NewStrongP@ssw0rd!" },
{ context: ctx },
),
).rejects.toThrow("Reset token has already been used");
});
});
test("rejects weak password", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "resetweak@example.com",
});
const token = await createPasswordReset(db, user.id);
const ctx = createAPIContext(db);
await expect(
call(
router.auth.resetPassword,
{ token, newPassword: "weak" },
{ context: ctx },
),
).rejects.toThrow();
});
});
});
// =============================================================================
// auth.logout tests
// =============================================================================
describe("auth.logout", () => {
test("revokes current session", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "logout@example.com",
});
const { token: sessionToken, sessionId } = await createSession(
db,
user.id,
);
const ctx = createAPIContext(db, { sessionToken });
const result = await call(router.auth.logout, undefined, {
context: ctx,
});
expect(result.success).toBe(true);
// Verify session was revoked
const session = await db
.selectFrom("sessions")
.select(["revoked_at"])
.where("id", "=", sessionId.toString())
.executeTakeFirst();
expect(session?.revoked_at).not.toBeNull();
// Verify session cookie was deleted
const setCookies = ctx.resHeaders.getSetCookie();
const sessionCookie = setCookies.find((c) =>
c.startsWith(`${COOKIE_NAMES.SESSION_TOKEN}=`),
);
expect(sessionCookie).toContain("Max-Age=0");
});
});
test("requires authentication", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const ctx = createAPIContext(db); // No session
await expect(
call(router.auth.logout, undefined, { context: ctx }),
).rejects.toThrow();
});
});
});
// =============================================================================
// End-to-end login scenarios from docs/initial-app.md
// =============================================================================
describe("End-to-end login scenarios", () => {
test("Scenario: Password login with trusted device (immediate completion)", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
// Setup: User with password and trusted device
const user = await createTestUser(db, {
email: "e2e-trusted@example.com",
passwordHash: await hashPassword("TestPassword123!"),
});
const fingerprint = "e2e-trusted-device";
await createTrustedDevice(db, user.id, fingerprint);
// Step 1: Create login request
const ctx1 = createAPIContext(db, { deviceFingerprint: fingerprint });
const loginRequestResult = await call(
router.auth.createLoginRequest,
{ email: "e2e-trusted@example.com" },
{ context: ctx1 },
);
expect(loginRequestResult.hasPassword).toBe(true);
expect(loginRequestResult.isTrustedDevice).toBe(true);
const loginToken = getCookieFromResponse(
ctx1.resHeaders,
COOKIE_NAMES.LOGIN_REQUEST_TOKEN,
);
// Step 2: Login with password (should complete immediately for trusted device)
const ctx2 = createAPIContext(db, {
loginRequestToken: assertDefined(loginToken),
deviceFingerprint: fingerprint,
});
await call(
router.auth.loginPassword,
{ password: "TestPassword123!" },
{ context: ctx2 },
);
// Step 3: Poll for completion
const ctx3 = createAPIContext(db, {
loginRequestToken: assertDefined(loginToken),
deviceFingerprint: fingerprint,
});
const completedResult = await call(
router.auth.loginIfRequestIsCompleted,
undefined,
{ context: ctx3 },
);
expect(completedResult.status).toBe("completed");
expect(completedResult.redirectTo).toBe("/dashboard"); // Already trusted
// Verify session was created
const sessionToken = getCookieFromResponse(
ctx3.resHeaders,
COOKIE_NAMES.SESSION_TOKEN,
);
expect(sessionToken).not.toBeNull();
});
});
test("Scenario: Password login with untrusted device (requires email confirmation)", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
// Setup: User with password but no trusted device
await createTestUser(db, {
email: "e2e-untrusted@example.com",
passwordHash: await hashPassword("TestPassword123!"),
});
const fingerprint = "e2e-untrusted-device";
// Step 1: Create login request
const ctx1 = createAPIContext(db, { deviceFingerprint: fingerprint });
const loginRequestResult = await call(
router.auth.createLoginRequest,
{ email: "e2e-untrusted@example.com" },
{ context: ctx1 },
);
expect(loginRequestResult.hasPassword).toBe(true);
expect(loginRequestResult.isTrustedDevice).toBe(false);
const loginToken = getCookieFromResponse(
ctx1.resHeaders,
COOKIE_NAMES.LOGIN_REQUEST_TOKEN,
);
// Step 2: Login with password (should NOT complete - needs email confirmation)
const ctx2 = createAPIContext(db, {
loginRequestToken: assertDefined(loginToken),
deviceFingerprint: fingerprint,
});
await call(
router.auth.loginPassword,
{ password: "TestPassword123!" },
{ context: ctx2 },
);
// Step 3: Poll should return pending (email not confirmed yet)
const ctx3 = createAPIContext(db, {
loginRequestToken: assertDefined(loginToken),
deviceFingerprint: fingerprint,
});
const pendingResult = await call(
router.auth.loginIfRequestIsCompleted,
undefined,
{ context: ctx3 },
);
expect(pendingResult.status).toBe("pending");
// Step 4: User clicks email confirmation link
const ctx4 = createAPIContext(db);
await call(
router.auth.loginPasswordConfirm,
{ token: assertDefined(loginToken) },
{ context: ctx4 },
);
// Step 5: Poll should now return completed
const ctx5 = createAPIContext(db, {
loginRequestToken: assertDefined(loginToken),
deviceFingerprint: fingerprint,
});
const completedResult = await call(
router.auth.loginIfRequestIsCompleted,
undefined,
{ context: ctx5 },
);
expect(completedResult.status).toBe("completed");
expect(completedResult.redirectTo).toBe("/auth/trust-device"); // Not yet trusted
});
});
test("Scenario: Login attempt with non-existent email (anti-enumeration)", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
// Step 1: Create login request for non-existent email
const ctx1 = createAPIContext(db);
const result = await call(
router.auth.createLoginRequest,
{ email: "doesnotexist@example.com" },
{ context: ctx1 },
);
// Should return all false (indistinguishable from user without auth methods)
expect(result.hasPassword).toBe(false);
expect(result.hasPasskey).toBe(false);
expect(result.isTrustedDevice).toBe(false);
const loginToken = getCookieFromResponse(
ctx1.resHeaders,
COOKIE_NAMES.LOGIN_REQUEST_TOKEN,
);
expect(loginToken).not.toBeNull(); // Still get a token (fake)
// Step 2: Trying to login with password should fail
const ctx2 = createAPIContext(db, {
loginRequestToken: assertDefined(loginToken),
});
await expect(
call(
router.auth.loginPassword,
{ password: "AnyPassword123!" },
{ context: ctx2 },
),
).rejects.toThrow("Invalid email or password");
// Step 3: Polling should return pending until expired
const ctx3 = createAPIContext(db, {
loginRequestToken: assertDefined(loginToken),
});
const pollResult = await call(
router.auth.loginIfRequestIsCompleted,
undefined,
{ context: ctx3 },
);
expect(pollResult.status).toBe("pending"); // Fake token - always pending
});
});
test("Scenario: Complete password reset flow", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
// Setup: User with existing password and sessions
const user = await createTestUser(db, {
email: "e2e-reset@example.com",
passwordHash: await hashPassword("OldPassword123!"),
});
await createSession(db, user.id);
await createSession(db, user.id);
// Step 1: Request password reset
const ctx1 = createAPIContext(db);
await call(
router.auth.forgotPassword,
{ email: "e2e-reset@example.com" },
{ context: ctx1 },
);
// Get the token from DB (in real flow, this would be from email)
const reset = await db
.selectFrom("password_resets")
.select(["token"])
.where("user_id", "=", user.id)
.executeTakeFirst();
// Step 2: Reset password
const ctx2 = createAPIContext(db);
await call(
router.auth.resetPassword,
{
token: assertDefined(reset).token,
newPassword: "NewSecureP@ss123!",
},
{ context: ctx2 },
);
// Verify all old sessions were revoked
const sessions = await db
.selectFrom("sessions")
.select(["revoked_at"])
.where("user_id", "=", user.id)
.execute();
for (const session of sessions) {
expect(session.revoked_at).not.toBeNull();
}
// Step 3: Login with new password should work
const ctx3 = createAPIContext(db);
await call(
router.auth.createLoginRequest,
{ email: "e2e-reset@example.com" },
{ context: ctx3 },
);
const loginToken = getCookieFromResponse(
ctx3.resHeaders,
COOKIE_NAMES.LOGIN_REQUEST_TOKEN,
);
// Mark login as completed (simulate trusted device or email confirmation)
await db
.updateTable("login_requests")
.set({ completed_at: new Date() })
.where("token", "=", assertDefined(loginToken))
.execute();
const ctx4 = createAPIContext(db, {
loginRequestToken: assertDefined(loginToken),
});
const result = await call(
router.auth.loginIfRequestIsCompleted,
undefined,
{ context: ctx4 },
);
expect(result.status).toBe("completed");
});
});
test("Scenario: Passkey login flow (full e2e)", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
// Setup: User with passkey
const user = await createTestUser(db, {
email: "e2e-passkey-login@example.com",
});
const authenticator = new VirtualAuthenticator({
origin: TEST_RP.origin,
});
const fingerprint = "e2e-passkey-device";
// Create a session for passkey registration (registration requires auth)
const { token: regSessionToken, sessionId: regSessionId } =
await createSession(db, user.id);
// Create registration options
const regOptionsCtx = createAPIContext(db, {
sessionToken: regSessionToken,
deviceFingerprint: fingerprint,
});
const { options: regOptions, challengeId: regChallengeId } = await call(
router.auth.webauthn.createRegistrationOptions,
{ email: user.email },
{ context: regOptionsCtx },
);
// Create credential with virtual authenticator
const regResponse = authenticator.createCredential(regOptions);
// Verify registration
const verifyRegCtx = createAPIContext(db, {
sessionToken: regSessionToken,
deviceFingerprint: fingerprint,
});
await call(
router.auth.webauthn.verifyRegistration,
{ challengeId: regChallengeId, response: regResponse },
{ context: verifyRegCtx },
);
// Clean up registration session
await db
.deleteFrom("sessions")
.where("id", "=", regSessionId.toString())
.execute();
// Step 1: Create login request
const ctx1 = createAPIContext(db, { deviceFingerprint: fingerprint });
const loginRequestResult = await call(
router.auth.createLoginRequest,
{ email: "e2e-passkey-login@example.com" },
{ context: ctx1 },
);
expect(loginRequestResult.hasPasskey).toBe(true);
const loginToken = getCookieFromResponse(
ctx1.resHeaders,
COOKIE_NAMES.LOGIN_REQUEST_TOKEN,
);
expect(loginToken).not.toBeNull();
// Step 2: Create authentication options
const ctx2 = createAPIContext(db, {
loginRequestToken: assertDefined(loginToken),
deviceFingerprint: fingerprint,
});
const { options: authOptions, challengeId: authChallengeId } =
await call(
router.auth.webauthn.createAuthenticationOptions,
undefined,
{ context: ctx2 },
);
expect(authOptions.allowCredentials).toHaveLength(1);
// Step 3: Authenticate with passkey
const authResponse = authenticator.getAssertion(authOptions);
const ctx3 = createAPIContext(db, {
loginRequestToken: assertDefined(loginToken),
deviceFingerprint: fingerprint,
});
await call(
router.auth.webauthn.verifyAuthentication,
{ challengeId: authChallengeId, response: authResponse },
{ context: ctx3 },
);
// Step 4: Poll for completion - should be completed now
const ctx4 = createAPIContext(db, {
loginRequestToken: assertDefined(loginToken),
deviceFingerprint: fingerprint,
});
const completedResult = await call(
router.auth.loginIfRequestIsCompleted,
undefined,
{ context: ctx4 },
);
expect(completedResult.status).toBe("completed");
// Passkey login creates a trusted session, but device is not yet trusted
// So user is redirected to trust-device screen
expect(completedResult.redirectTo).toBe("/auth/trust-device");
// Verify session was created with trusted_mode = true
const sessions = await db
.selectFrom("sessions")
.selectAll()
.where("user_id", "=", user.id)
.execute();
expect(sessions.length).toBe(1);
expect(sessions[0]?.trusted_mode).toBe(true);
// Verify session cookie was set
const sessionToken = getCookieFromResponse(
ctx4.resHeaders,
COOKIE_NAMES.SESSION_TOKEN,
);
expect(sessionToken).not.toBeNull();
});
});
test("Scenario: User with no auth methods (no password, no passkey)", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
// Setup: User without any auth methods set up
// This simulates a user who was created but never completed setup
await createTestUser(db, {
email: "e2e-no-auth@example.com",
// No password hash
});
const fingerprint = "e2e-no-auth-device";
// Step 1: Create login request
const ctx1 = createAPIContext(db, { deviceFingerprint: fingerprint });
const loginRequestResult = await call(
router.auth.createLoginRequest,
{ email: "e2e-no-auth@example.com" },
{ context: ctx1 },
);
// Should indicate no auth methods available
expect(loginRequestResult.hasPassword).toBe(false);
expect(loginRequestResult.hasPasskey).toBe(false);
expect(loginRequestResult.isTrustedDevice).toBe(false);
const loginToken = getCookieFromResponse(
ctx1.resHeaders,
COOKIE_NAMES.LOGIN_REQUEST_TOKEN,
);
expect(loginToken).not.toBeNull();
// Step 2: Poll should return pending (no way to complete login)
const ctx2 = createAPIContext(db, {
loginRequestToken: assertDefined(loginToken),
deviceFingerprint: fingerprint,
});
const pendingResult = await call(
router.auth.loginIfRequestIsCompleted,
undefined,
{ context: ctx2 },
);
expect(pendingResult.status).toBe("pending");
// According to docs: "Shows 'Check your email' but no email sent, polling will expire"
// The login request exists but can never be completed since there's no auth method
// Verify login request exists but is not completed
const loginRequest = await db
.selectFrom("login_requests")
.selectAll()
.where("token", "=", assertDefined(loginToken))
.executeTakeFirst();
expect(loginRequest).toBeDefined();
expect(loginRequest?.completed_at).toBeNull();
});
});
});
// =============================================================================
// loginRequestMiddleware tests (base.ts)
// =============================================================================
describe("loginRequestMiddleware", () => {
test("rejects request with no login request cookie", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
// No login request token in context
const ctx = createAPIContext(db);
await expect(
call(router.auth.webauthn.createAuthenticationOptions, undefined, {
context: ctx,
}),
).rejects.toThrow("No login request found");
});
});
test("rejects request with invalid login request token", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
// Invalid token that doesn't exist in DB
const ctx = createAPIContext(db, {
loginRequestToken: "invalid-login-request-token",
});
await expect(
call(router.auth.webauthn.createAuthenticationOptions, undefined, {
context: ctx,
}),
).rejects.toThrow("Login request expired or not found");
});
});
test("rejects request with expired login request", async () => {
await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, {
email: "expiredloginreq@example.com",
});
// Create an expired login request
const { token: loginToken } = await createLoginRequest(
db,
user.id,
user.email,
{ expiresAt: new Date(Date.now() - 1000) }, // Expired
);
const ctx = createAPIContext(db, { loginRequestToken: loginToken });
await expect(
call(router.auth.webauthn.createAuthenticationOptions, undefined, {
context: ctx,
}),
).rejects.toThrow("Login request expired or not found");
});
});
});
}); // Close outer describeE2E