- Add formatError() helper in CLI to safely handle unknown error types - Add uniqueTestId() helper for generating unique test identifiers - Replace String(id) with id.toString() for database ID conversions - Replace String(n) with n.toLocaleString() for user-facing number formatting - Fix TypeScript errors in test files (undefined checks, unused variables) - Update lint commands to include ast-grep scanning Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2183 lines
68 KiB
TypeScript
2183 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 {
|
|
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(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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
|