Add test infrastructure with coverage and DB test skipping

- Create @reviq/test-helpers package with shared test utilities
- Add describeE2E helper that auto-prefixes test names with [e2e]
- Support SKIP_DB_TESTS=1 to skip database-dependent tests
- Add unix socket support for TEST_DATABASE_URL
- Add root commands: test:unit, test:all, test:cov, test:unit:cov
- Configure bunfig.toml to exclude dist/ from coverage reports
- Clean up tsconfig.json files to remove redundant settings

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
igm
2026-01-12 13:03:41 +08:00
parent 44a480179b
commit b2fba6e150
25 changed files with 3854 additions and 3688 deletions

View File

@@ -9,9 +9,7 @@
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"lint": "eslint . --cache", "lint": "eslint . --cache",
"clean": "rm -rf dist .eslintcache", "clean": "rm -rf dist .eslintcache",
"test:e2e": "bun test src/__tests__/e2e --no-parallel --coverage", "test": "bun test src/ --no-parallel"
"test:unit": "bun test src/__tests__/unit",
"test": "bun test --coverage src/utils"
}, },
"dependencies": { "dependencies": {
"@formatjs/intl-durationformat": "^0.9.2", "@formatjs/intl-durationformat": "^0.9.2",
@@ -34,12 +32,11 @@
"devDependencies": { "devDependencies": {
"@macalinao/eslint-config": "catalog:", "@macalinao/eslint-config": "catalog:",
"@macalinao/tsconfig": "catalog:", "@macalinao/tsconfig": "catalog:",
"@reviq/test-helpers": "workspace:*",
"@reviq/virtual-authenticator": "workspace:*", "@reviq/virtual-authenticator": "workspace:*",
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@types/pg": "^8.16.0",
"@types/zxcvbn": "^4.4.5", "@types/zxcvbn": "^4.4.5",
"eslint": "catalog:", "eslint": "catalog:",
"pg": "^8.16.3",
"pino-pretty": "^13.1.3", "pino-pretty": "^13.1.3",
"typescript": "catalog:" "typescript": "catalog:"
} }

View File

@@ -41,14 +41,19 @@ import type { Kysely } from "kysely";
import type { APIContext } from "../../context.js"; import type { APIContext } from "../../context.js";
import { beforeAll, describe, expect, test } from "bun:test"; import { beforeAll, describe, expect, test } from "bun:test";
import { call } from "@orpc/server"; import { call } from "@orpc/server";
import {
createTestUser,
describeE2E,
getSharedDb,
initTestDb,
TEST_RP,
withTestTransaction,
} from "@reviq/test-helpers";
import { VirtualAuthenticator } from "@reviq/virtual-authenticator"; import { VirtualAuthenticator } from "@reviq/virtual-authenticator";
import { router } from "../../router.js"; import { router } from "../../router.js";
import { COOKIE_NAMES } from "../../utils/cookies.js"; import { COOKIE_NAMES } from "../../utils/cookies.js";
import { hashToken } from "../../utils/crypto.js"; import { hashToken } from "../../utils/crypto.js";
import { hashPassword } from "../../utils/password.js"; import { hashPassword } from "../../utils/password.js";
import { TEST_RP } from "../helpers/test-constants.js";
import { createTestUser, getSharedDb, initTestDb } from "../helpers/test-db.js";
import { withTestTransaction } from "../helpers/test-transaction.js";
/** Session expiry duration: 24 hours in milliseconds */ /** Session expiry duration: 24 hours in milliseconds */
const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000; const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000;
@@ -263,16 +268,17 @@ async function createPasswordReset(
return token; return token;
} }
// Test setup describeE2E("auth", () => {
beforeAll(async () => { // Test setup
beforeAll(async () => {
await initTestDb(); await initTestDb();
}); });
// ============================================================================= // =============================================================================
// auth.signup tests // auth.signup tests
// ============================================================================= // =============================================================================
describe("auth.signup", () => { describe("auth.signup", () => {
test("creates user with valid password", async () => { test("creates user with valid password", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const ctx = createAPIContext(db); const ctx = createAPIContext(db);
@@ -391,7 +397,9 @@ describe("auth.signup", () => {
// nested transactions. // nested transactions.
test("creates user with passkey", async () => { test("creates user with passkey", async () => {
const db = getSharedDb(); const db = getSharedDb();
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); const authenticator = new VirtualAuthenticator({
origin: TEST_RP.origin,
});
const ctx = createAPIContext(db); const ctx = createAPIContext(db);
// Step 1: Create registration options // Step 1: Create registration options
@@ -537,13 +545,13 @@ describe("auth.signup", () => {
expect(challenges.length).toBe(0); expect(challenges.length).toBe(0);
}); });
}); });
}); });
// ============================================================================= // =============================================================================
// auth.createLoginRequest tests // auth.createLoginRequest tests
// ============================================================================= // =============================================================================
describe("auth.createLoginRequest", () => { describe("auth.createLoginRequest", () => {
test("returns auth methods for existing user with password", async () => { test("returns auth methods for existing user with password", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
await createTestUser(db, { await createTestUser(db, {
@@ -671,13 +679,13 @@ describe("auth.createLoginRequest", () => {
expect(fingerprint).not.toBeNull(); expect(fingerprint).not.toBeNull();
}); });
}); });
}); });
// ============================================================================= // =============================================================================
// auth.loginPassword tests // auth.loginPassword tests
// ============================================================================= // =============================================================================
describe("auth.loginPassword", () => { describe("auth.loginPassword", () => {
test("completes login immediately for trusted device", async () => { test("completes login immediately for trusted device", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, { const user = await createTestUser(db, {
@@ -863,13 +871,13 @@ describe("auth.loginPassword", () => {
).rejects.toThrow("Invalid email or password"); ).rejects.toThrow("Invalid email or password");
}); });
}); });
}); });
// ============================================================================= // =============================================================================
// auth.loginPasswordConfirm tests // auth.loginPasswordConfirm tests
// ============================================================================= // =============================================================================
describe("auth.loginPasswordConfirm", () => { describe("auth.loginPasswordConfirm", () => {
test("marks login request as completed with valid token", async () => { test("marks login request as completed with valid token", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, { const user = await createTestUser(db, {
@@ -964,13 +972,13 @@ describe("auth.loginPasswordConfirm", () => {
).rejects.toThrow("Invalid or expired confirmation link"); ).rejects.toThrow("Invalid or expired confirmation link");
}); });
}); });
}); });
// ============================================================================= // =============================================================================
// auth.loginIfRequestIsCompleted tests // auth.loginIfRequestIsCompleted tests
// ============================================================================= // =============================================================================
describe("auth.loginIfRequestIsCompleted", () => { describe("auth.loginIfRequestIsCompleted", () => {
test("returns pending for incomplete login request", async () => { test("returns pending for incomplete login request", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, { const user = await createTestUser(db, {
@@ -1111,7 +1119,9 @@ describe("auth.loginIfRequestIsCompleted", () => {
test("returns pending for fake/non-existent token", async () => { test("returns pending for fake/non-existent token", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const ctx = createAPIContext(db, { loginRequestToken: "fake-token-xyz" }); const ctx = createAPIContext(db, {
loginRequestToken: "fake-token-xyz",
});
const result = await call( const result = await call(
router.auth.loginIfRequestIsCompleted, router.auth.loginIfRequestIsCompleted,
undefined, undefined,
@@ -1165,13 +1175,13 @@ describe("auth.loginIfRequestIsCompleted", () => {
expect(result.status).toBe("pending"); expect(result.status).toBe("pending");
}); });
}); });
}); });
// ============================================================================= // =============================================================================
// auth.verifyEmail tests // auth.verifyEmail tests
// ============================================================================= // =============================================================================
describe("auth.verifyEmail", () => { describe("auth.verifyEmail", () => {
test("verifies email with valid token", async () => { test("verifies email with valid token", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, { const user = await createTestUser(db, {
@@ -1247,13 +1257,13 @@ describe("auth.verifyEmail", () => {
expect(verifications.length).toBe(0); expect(verifications.length).toBe(0);
}); });
}); });
}); });
// ============================================================================= // =============================================================================
// auth.resendVerificationEmail tests // auth.resendVerificationEmail tests
// ============================================================================= // =============================================================================
describe("auth.resendVerificationEmail", () => { describe("auth.resendVerificationEmail", () => {
test("creates new verification token for unverified user", async () => { test("creates new verification token for unverified user", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, { const user = await createTestUser(db, {
@@ -1344,17 +1354,19 @@ describe("auth.resendVerificationEmail", () => {
const ctx = createAPIContext(db); // No session const ctx = createAPIContext(db); // No session
await expect( await expect(
call(router.auth.resendVerificationEmail, undefined, { context: ctx }), call(router.auth.resendVerificationEmail, undefined, {
context: ctx,
}),
).rejects.toThrow(); ).rejects.toThrow();
}); });
}); });
}); });
// ============================================================================= // =============================================================================
// auth.forgotPassword tests // auth.forgotPassword tests
// ============================================================================= // =============================================================================
describe("auth.forgotPassword", () => { describe("auth.forgotPassword", () => {
test("creates password reset token for existing user", async () => { test("creates password reset token for existing user", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, { const user = await createTestUser(db, {
@@ -1449,13 +1461,13 @@ describe("auth.forgotPassword", () => {
expect(resets.length).toBe(1); expect(resets.length).toBe(1);
}); });
}); });
}); });
// ============================================================================= // =============================================================================
// auth.resetPassword tests // auth.resetPassword tests
// ============================================================================= // =============================================================================
describe("auth.resetPassword", () => { describe("auth.resetPassword", () => {
test("resets password with valid token", async () => { test("resets password with valid token", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, { const user = await createTestUser(db, {
@@ -1604,13 +1616,13 @@ describe("auth.resetPassword", () => {
).rejects.toThrow(); ).rejects.toThrow();
}); });
}); });
}); });
// ============================================================================= // =============================================================================
// auth.logout tests // auth.logout tests
// ============================================================================= // =============================================================================
describe("auth.logout", () => { describe("auth.logout", () => {
test("revokes current session", async () => { test("revokes current session", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, { const user = await createTestUser(db, {
@@ -1656,13 +1668,13 @@ describe("auth.logout", () => {
).rejects.toThrow(); ).rejects.toThrow();
}); });
}); });
}); });
// ============================================================================= // =============================================================================
// End-to-end login scenarios from docs/initial-app.md // End-to-end login scenarios from docs/initial-app.md
// ============================================================================= // =============================================================================
describe("End-to-end login scenarios", () => { describe("End-to-end login scenarios", () => {
test("Scenario: Password login with trusted device (immediate completion)", async () => { test("Scenario: Password login with trusted device (immediate completion)", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
// Setup: User with password and trusted device // Setup: User with password and trusted device
@@ -1875,7 +1887,10 @@ describe("End-to-end login scenarios", () => {
const ctx2 = createAPIContext(db); const ctx2 = createAPIContext(db);
await call( await call(
router.auth.resetPassword, router.auth.resetPassword,
{ token: assertDefined(reset).token, newPassword: "NewSecureP@ss123!" }, {
token: assertDefined(reset).token,
newPassword: "NewSecureP@ss123!",
},
{ context: ctx2 }, { context: ctx2 },
); );
@@ -1991,7 +2006,8 @@ describe("End-to-end login scenarios", () => {
loginRequestToken: assertDefined(loginToken), loginRequestToken: assertDefined(loginToken),
deviceFingerprint: fingerprint, deviceFingerprint: fingerprint,
}); });
const { options: authOptions, challengeId: authChallengeId } = await call( const { options: authOptions, challengeId: authChallengeId } =
await call(
router.auth.webauthn.createAuthenticationOptions, router.auth.webauthn.createAuthenticationOptions,
undefined, undefined,
{ context: ctx2 }, { context: ctx2 },
@@ -2104,4 +2120,5 @@ describe("End-to-end login scenarios", () => {
expect(loginRequest?.completed_at).toBeNull(); expect(loginRequest?.completed_at).toBeNull();
}); });
}); });
}); });
}); // Close outer describe.skipIf

View File

@@ -23,13 +23,18 @@ import type { Kysely } from "kysely";
import type { APIContext } from "../../context.js"; import type { APIContext } from "../../context.js";
import { beforeAll, describe, expect, test } from "bun:test"; import { beforeAll, describe, expect, test } from "bun:test";
import { call } from "@orpc/server"; import { call } from "@orpc/server";
import {
createTestUser,
describeE2E,
getSharedDb,
initTestDb,
TEST_RP,
withTestTransaction,
} from "@reviq/test-helpers";
import { router } from "../../router.js"; import { router } from "../../router.js";
import { COOKIE_NAMES } from "../../utils/cookies.js"; import { COOKIE_NAMES } from "../../utils/cookies.js";
import { hashToken } from "../../utils/crypto.js"; import { hashToken } from "../../utils/crypto.js";
import { hashPassword } from "../../utils/password.js"; import { hashPassword } from "../../utils/password.js";
import { TEST_RP } from "../helpers/test-constants.js";
import { createTestUser, getSharedDb, initTestDb } from "../helpers/test-db.js";
import { withTestTransaction } from "../helpers/test-transaction.js";
/** Session expiry duration: 24 hours in milliseconds */ /** Session expiry duration: 24 hours in milliseconds */
const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000; const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000;
@@ -165,11 +170,12 @@ async function createApiToken(
return { token, name: "Test API Token" }; return { token, name: "Test API Token" };
} }
beforeAll(async () => { describeE2E("me", () => {
beforeAll(async () => {
await initTestDb(); await initTestDb();
}); });
describe("me.get", () => { describe("me.get", () => {
test("returns user profile with all fields", async () => { test("returns user profile with all fields", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, { const user = await createTestUser(db, {
@@ -259,9 +265,9 @@ describe("me.get", () => {
expect(result.isSuperuser).toBe(true); expect(result.isSuperuser).toBe(true);
}); });
}); });
}); });
describe("me.authStatus", () => { describe("me.authStatus", () => {
test("returns session auth info", async () => { test("returns session auth info", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, { const user = await createTestUser(db, {
@@ -302,9 +308,9 @@ describe("me.authStatus", () => {
} }
}); });
}); });
}); });
describe("me.setupProfile", () => { describe("me.setupProfile", () => {
test("sets up profile with required fields", async () => { test("sets up profile with required fields", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, { const user = await createTestUser(db, {
@@ -379,9 +385,9 @@ describe("me.setupProfile", () => {
expect(updated.phone_number).toBeNull(); expect(updated.phone_number).toBeNull();
}); });
}); });
}); });
describe("me.updateProfile", () => { describe("me.updateProfile", () => {
test("updates displayName only", async () => { test("updates displayName only", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, { const user = await createTestUser(db, {
@@ -505,9 +511,9 @@ describe("me.updateProfile", () => {
expect(updated.display_name).toBe("Stay Same"); expect(updated.display_name).toBe("Stay Same");
}); });
}); });
}); });
describe("me.setPassword", () => { describe("me.setPassword", () => {
test("sets password for user without password", async () => { test("sets password for user without password", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, { const user = await createTestUser(db, {
@@ -638,9 +644,9 @@ describe("me.setPassword", () => {
).rejects.toThrow(/common|top|weak|guess/i); ).rejects.toThrow(/common|top|weak|guess/i);
}); });
}); });
}); });
describe("me.delete", () => { describe("me.delete", () => {
test("deletes account with correct password", async () => { test("deletes account with correct password", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const password = "DeleteMe123!@#"; const password = "DeleteMe123!@#";
@@ -693,7 +699,11 @@ describe("me.delete", () => {
const context = createAPIContext(db, { sessionToken }); const context = createAPIContext(db, { sessionToken });
await expect( await expect(
call(router.me.delete, { password: "WrongPassword123!" }, { context }), call(
router.me.delete,
{ password: "WrongPassword123!" },
{ context },
),
).rejects.toThrow("Incorrect password"); ).rejects.toThrow("Incorrect password");
}); });
}); });
@@ -733,11 +743,11 @@ describe("me.delete", () => {
expect(tokens).toHaveLength(0); expect(tokens).toHaveLength(0);
}); });
}); });
}); });
// ===== Session Management Tests ===== // ===== Session Management Tests =====
describe("me.sessions.list", () => { describe("me.sessions.list", () => {
test("returns all sessions for user", async () => { test("returns all sessions for user", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, { const user = await createTestUser(db, {
@@ -839,9 +849,9 @@ describe("me.sessions.list", () => {
expect(session?.revokedAt).toBeNull(); expect(session?.revokedAt).toBeNull();
}); });
}); });
}); });
describe("me.sessions.revoke", () => { describe("me.sessions.revoke", () => {
test("revokes another session successfully", async () => { test("revokes another session successfully", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, { const user = await createTestUser(db, {
@@ -920,7 +930,11 @@ describe("me.sessions.revoke", () => {
const context = createAPIContext(db, { sessionToken: sessionToken1 }); const context = createAPIContext(db, { sessionToken: sessionToken1 });
await expect( await expect(
call(router.me.sessions.revoke, { sessionId: sessionId2 }, { context }), call(
router.me.sessions.revoke,
{ sessionId: sessionId2 },
{ context },
),
).rejects.toThrow("Session not found"); ).rejects.toThrow("Session not found");
}); });
}); });
@@ -939,13 +953,17 @@ describe("me.sessions.revoke", () => {
const context = createAPIContext(db, { sessionToken: sessionToken1 }); const context = createAPIContext(db, { sessionToken: sessionToken1 });
await expect( await expect(
call(router.me.sessions.revoke, { sessionId: sessionId2 }, { context }), call(
router.me.sessions.revoke,
{ sessionId: sessionId2 },
{ context },
),
).rejects.toThrow("Session not found"); ).rejects.toThrow("Session not found");
}); });
}); });
}); });
describe("me.sessions.revokeAll", () => { describe("me.sessions.revokeAll", () => {
test("revokes all sessions except current", async () => { test("revokes all sessions except current", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, { const user = await createTestUser(db, {
@@ -1007,11 +1025,11 @@ describe("me.sessions.revokeAll", () => {
expect(session.revoked_at).toBeNull(); expect(session.revoked_at).toBeNull();
}); });
}); });
}); });
// ===== Device Management Tests ===== // ===== Device Management Tests =====
describe("me.devices.getInfo", () => { describe("me.devices.getInfo", () => {
test("returns device info for current device", async () => { test("returns device info for current device", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, { const user = await createTestUser(db, {
@@ -1113,9 +1131,9 @@ describe("me.devices.getInfo", () => {
).rejects.toThrow("Device not found"); ).rejects.toThrow("Device not found");
}); });
}); });
}); });
describe("me.devices.trust", () => { describe("me.devices.trust", () => {
test("trusts current device with name", async () => { test("trusts current device with name", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, { const user = await createTestUser(db, {
@@ -1182,9 +1200,9 @@ describe("me.devices.trust", () => {
).rejects.toThrow("Device not found"); ).rejects.toThrow("Device not found");
}); });
}); });
}); });
describe("me.devices.listTrusted", () => { describe("me.devices.listTrusted", () => {
test("returns only trusted devices", async () => { test("returns only trusted devices", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, { const user = await createTestUser(db, {
@@ -1194,7 +1212,10 @@ describe("me.devices.listTrusted", () => {
// Create trusted and untrusted devices // Create trusted and untrusted devices
await createDevice(db, user.id, { isTrusted: true, name: "Trusted 1" }); await createDevice(db, user.id, { isTrusted: true, name: "Trusted 1" });
await createDevice(db, user.id, { isTrusted: true, name: "Trusted 2" }); await createDevice(db, user.id, { isTrusted: true, name: "Trusted 2" });
await createDevice(db, user.id, { isTrusted: false, name: "Untrusted" }); await createDevice(db, user.id, {
isTrusted: false,
name: "Untrusted",
});
const { token: sessionToken } = await createSession(db, user.id); const { token: sessionToken } = await createSession(db, user.id);
const context = createAPIContext(db, { sessionToken }); const context = createAPIContext(db, { sessionToken });
@@ -1261,9 +1282,9 @@ describe("me.devices.listTrusted", () => {
expect(devices[0]?.name).toBe("Unknown device"); expect(devices[0]?.name).toBe("Unknown device");
}); });
}); });
}); });
describe("me.devices.untrust", () => { describe("me.devices.untrust", () => {
test("untrusts device by ID", async () => { test("untrusts device by ID", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, { const user = await createTestUser(db, {
@@ -1327,9 +1348,9 @@ describe("me.devices.untrust", () => {
).rejects.toThrow("Device not found"); ).rejects.toThrow("Device not found");
}); });
}); });
}); });
describe("me.devices.revokeAll", () => { describe("me.devices.revokeAll", () => {
test("untrusts all devices", async () => { test("untrusts all devices", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, { const user = await createTestUser(db, {
@@ -1370,4 +1391,5 @@ describe("me.devices.revokeAll", () => {
await call(router.me.devices.revokeAll, undefined, { context }); await call(router.me.devices.revokeAll, undefined, { context });
}); });
}); });
}); });
}); // Close outer describe.skipIf

View File

@@ -12,19 +12,21 @@ import type { Kysely } from "kysely";
import type { APIContext } from "../../context.js"; import type { APIContext } from "../../context.js";
import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { call } from "@orpc/server"; import { call } from "@orpc/server";
import {
createTestUser,
describeE2E,
destroySharedDb,
getSharedDb,
initTestDb,
KNOWN_AAGUIDS,
TEST_RP,
withTestTransaction,
} from "@reviq/test-helpers";
import { VirtualAuthenticator } from "@reviq/virtual-authenticator"; import { VirtualAuthenticator } from "@reviq/virtual-authenticator";
import { router } from "../../router.js"; import { router } from "../../router.js";
import { COOKIE_NAMES } from "../../utils/cookies.js"; import { COOKIE_NAMES } from "../../utils/cookies.js";
import { hashToken } from "../../utils/crypto.js"; import { hashToken } from "../../utils/crypto.js";
import { getUserPasskeys } from "../../utils/webauthn.js"; import { getUserPasskeys } from "../../utils/webauthn.js";
import { KNOWN_AAGUIDS, TEST_RP } from "../helpers/test-constants.js";
import {
createTestUser,
destroySharedDb,
getSharedDb,
initTestDb,
} from "../helpers/test-db.js";
import { withTestTransaction } from "../helpers/test-transaction.js";
/** Session expiry duration: 24 hours in milliseconds */ /** Session expiry duration: 24 hours in milliseconds */
const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000; const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000;
@@ -198,15 +200,16 @@ async function authenticate(
); );
} }
beforeAll(async () => { describeE2E("webauthn", () => {
beforeAll(async () => {
await initTestDb(); await initTestDb();
}); });
afterAll(async () => { afterAll(async () => {
await destroySharedDb(); await destroySharedDb();
}); });
describe("registration flow", () => { describe("registration flow", () => {
test("creates registration options with challenge stored in DB via router", async () => { test("creates registration options with challenge stored in DB via router", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, { const user = await createTestUser(db, {
@@ -419,9 +422,9 @@ describe("registration flow", () => {
} }
}); });
}); });
}); });
describe("authentication flow", () => { describe("authentication flow", () => {
test("creates authentication options with user's passkeys via router", async () => { test("creates authentication options with user's passkeys via router", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, { const user = await createTestUser(db, {
@@ -483,7 +486,8 @@ describe("authentication flow", () => {
user.email, user.email,
); );
const loginCtx = createLoginRequestContext(db, loginToken); const loginCtx = createLoginRequestContext(db, loginToken);
const { options: authOptions, challengeId: authChallengeId } = await call( const { options: authOptions, challengeId: authChallengeId } =
await call(
router.auth.webauthn.createAuthenticationOptions, router.auth.webauthn.createAuthenticationOptions,
undefined, undefined,
{ context: loginCtx }, { context: loginCtx },
@@ -525,7 +529,8 @@ describe("authentication flow", () => {
user.email, user.email,
); );
const loginCtx = createLoginRequestContext(db, loginToken); const loginCtx = createLoginRequestContext(db, loginToken);
const { options: authOptions, challengeId: authChallengeId } = await call( const { options: authOptions, challengeId: authChallengeId } =
await call(
router.auth.webauthn.createAuthenticationOptions, router.auth.webauthn.createAuthenticationOptions,
undefined, undefined,
{ context: loginCtx }, { context: loginCtx },
@@ -563,7 +568,8 @@ describe("authentication flow", () => {
user.email, user.email,
); );
const loginCtx = createLoginRequestContext(db, loginToken); const loginCtx = createLoginRequestContext(db, loginToken);
const { options: authOptions, challengeId: authChallengeId } = await call( const { options: authOptions, challengeId: authChallengeId } =
await call(
router.auth.webauthn.createAuthenticationOptions, router.auth.webauthn.createAuthenticationOptions,
undefined, undefined,
{ context: loginCtx }, { context: loginCtx },
@@ -636,9 +642,9 @@ describe("authentication flow", () => {
} }
}); });
}); });
}); });
describe("security tests", () => { describe("security tests", () => {
test("rejects replayed credentials (counter check) via router", async () => { test("rejects replayed credentials (counter check) via router", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, { const user = await createTestUser(db, {
@@ -772,9 +778,9 @@ describe("security tests", () => {
} }
}); });
}); });
}); });
describe("full passkey lifecycle", () => { describe("full passkey lifecycle", () => {
test("register → authenticate → add second passkey → authenticate with either via router", async () => { test("register → authenticate → add second passkey → authenticate with either via router", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, { email: "lifecycle@test.com" }); const user = await createTestUser(db, { email: "lifecycle@test.com" });
@@ -834,9 +840,9 @@ describe("full passkey lifecycle", () => {
} }
}); });
}); });
}); });
describe("passkey management", () => { describe("passkey management", () => {
test("lists passkeys with correct data via router", async () => { test("lists passkeys with correct data via router", async () => {
await withTestTransaction(getSharedDb(), async (db) => { await withTestTransaction(getSharedDb(), async (db) => {
const user = await createTestUser(db, { const user = await createTestUser(db, {
@@ -864,7 +870,9 @@ describe("passkey management", () => {
expect(passkeys).toHaveLength(2); expect(passkeys).toHaveLength(2);
// Verify first passkey data (router returns id, name, createdAt, lastUsedAt) // Verify first passkey data (router returns id, name, createdAt, lastUsedAt)
const icloudPasskey = passkeys.find((p) => p.name === "iCloud Keychain"); const icloudPasskey = passkeys.find(
(p) => p.name === "iCloud Keychain",
);
if (!icloudPasskey) { if (!icloudPasskey) {
throw new Error("Expected iCloud Keychain passkey to exist"); throw new Error("Expected iCloud Keychain passkey to exist");
} }
@@ -1003,7 +1011,9 @@ describe("passkey management", () => {
email: "delete-with-password@test.com", email: "delete-with-password@test.com",
passwordHash: "fake-password-hash", passwordHash: "fake-password-hash",
}); });
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); const authenticator = new VirtualAuthenticator({
origin: TEST_RP.origin,
});
await registerPasskey(db, user.id, user.email, authenticator); await registerPasskey(db, user.id, user.email, authenticator);
@@ -1019,7 +1029,9 @@ describe("passkey management", () => {
await call(router.me.passkeys.delete, { passkeyId }, { context: ctx }); await call(router.me.passkeys.delete, { passkeyId }, { context: ctx });
// Verify passkey is deleted // Verify passkey is deleted
passkeys = await call(router.me.passkeys.list, undefined, { context: ctx }); passkeys = await call(router.me.passkeys.list, undefined, {
context: ctx,
});
expect(passkeys).toHaveLength(0); expect(passkeys).toHaveLength(0);
}); });
@@ -1052,7 +1064,9 @@ describe("passkey management", () => {
); );
// Verify only one passkey remains // Verify only one passkey remains
passkeys = await call(router.me.passkeys.list, undefined, { context: ctx }); passkeys = await call(router.me.passkeys.list, undefined, {
context: ctx,
});
expect(passkeys).toHaveLength(1); expect(passkeys).toHaveLength(1);
firstPasskey = expectFirst(passkeys, "Expected passkey to exist"); firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
expect(firstPasskey.id).not.toBe(firstPasskeyId); expect(firstPasskey.id).not.toBe(firstPasskeyId);
@@ -1066,7 +1080,9 @@ describe("passkey management", () => {
email: "delete-last@test.com", email: "delete-last@test.com",
// No password set // No password set
}); });
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); const authenticator = new VirtualAuthenticator({
origin: TEST_RP.origin,
});
await registerPasskey(db, user.id, user.email, authenticator); await registerPasskey(db, user.id, user.email, authenticator);
@@ -1139,9 +1155,13 @@ describe("passkey management", () => {
} }
// User2's passkey should still exist // User2's passkey should still exist
const user2PasskeysAfter = await call(router.me.passkeys.list, undefined, { const user2PasskeysAfter = await call(
router.me.passkeys.list,
undefined,
{
context: ctx2, context: ctx2,
}); },
);
expect(user2PasskeysAfter).toHaveLength(1); expect(user2PasskeysAfter).toHaveLength(1);
}); });
@@ -1193,4 +1213,5 @@ describe("passkey management", () => {
expect(firstPasskey.transports).toContain("hybrid"); expect(firstPasskey.transports).toContain("hybrid");
}); });
}); });
}); });
}); // Close outer describe.skipIf

View File

@@ -13,7 +13,7 @@
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"lint": "eslint . --cache", "lint": "eslint . --cache",
"clean": "rm -rf dist .eslintcache", "clean": "rm -rf dist .eslintcache",
"test": "bun test" "test": "bun test src/"
}, },
"dependencies": { "dependencies": {
"@noble/hashes": "^2.0.1", "@noble/hashes": "^2.0.1",

View File

@@ -35,12 +35,11 @@
"devDependencies": { "devDependencies": {
"@macalinao/eslint-config": "catalog:", "@macalinao/eslint-config": "catalog:",
"@macalinao/tsconfig": "catalog:", "@macalinao/tsconfig": "catalog:",
"@reviq/test-helpers": "workspace:*",
"@reviq/virtual-authenticator": "workspace:*", "@reviq/virtual-authenticator": "workspace:*",
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@types/pg": "^8.16.0",
"@types/zxcvbn": "^4.4.5", "@types/zxcvbn": "^4.4.5",
"eslint": "catalog:", "eslint": "catalog:",
"pg": "^8.16.3",
"pino-pretty": "^13.1.3", "pino-pretty": "^13.1.3",
"typescript": "catalog:", "typescript": "catalog:",
}, },
@@ -180,6 +179,24 @@
"typescript": "catalog:", "typescript": "catalog:",
}, },
}, },
"packages/testing/test-helpers": {
"name": "@reviq/test-helpers",
"version": "0.0.1",
"dependencies": {
"@reviq/db": "workspace:*",
"@reviq/db-schema": "workspace:*",
"kysely": "^0.28.2",
"pg": "^8.16.3",
},
"devDependencies": {
"@macalinao/eslint-config": "catalog:",
"@macalinao/tsconfig": "catalog:",
"@types/bun": "catalog:",
"@types/pg": "^8.16.0",
"eslint": "catalog:",
"typescript": "catalog:",
},
},
"packages/testing/virtual-authenticator": { "packages/testing/virtual-authenticator": {
"name": "@reviq/virtual-authenticator", "name": "@reviq/virtual-authenticator",
"version": "0.0.1", "version": "0.0.1",
@@ -189,7 +206,7 @@
"devDependencies": { "devDependencies": {
"@macalinao/eslint-config": "catalog:", "@macalinao/eslint-config": "catalog:",
"@macalinao/tsconfig": "catalog:", "@macalinao/tsconfig": "catalog:",
"@types/bun": "latest", "@types/bun": "catalog:",
"@types/node": "^25.0.3", "@types/node": "^25.0.3",
"eslint": "catalog:", "eslint": "catalog:",
"typescript": "catalog:", "typescript": "catalog:",
@@ -425,6 +442,8 @@
"@reviq/db-schema": ["@reviq/db-schema@workspace:packages/db-schema"], "@reviq/db-schema": ["@reviq/db-schema@workspace:packages/db-schema"],
"@reviq/test-helpers": ["@reviq/test-helpers@workspace:packages/testing/test-helpers"],
"@reviq/utils": ["@reviq/utils@workspace:packages/utils"], "@reviq/utils": ["@reviq/utils@workspace:packages/utils"],
"@reviq/virtual-authenticator": ["@reviq/virtual-authenticator@workspace:packages/testing/virtual-authenticator"], "@reviq/virtual-authenticator": ["@reviq/virtual-authenticator@workspace:packages/testing/virtual-authenticator"],

5
bunfig.toml Normal file
View File

@@ -0,0 +1,5 @@
[test]
coveragePathIgnorePatterns = [
"**/dist/**",
"**/node_modules/**",
]

View File

@@ -1,4 +1,4 @@
\restrict F9AizESreuRieL4inRcHWWg3hyNET0FgnBDFBBBU3cZGPEpHjb591l8S2iglpap \restrict 7omiXDURqmmr2m2jWDDMoltRzeUAT80fRWiPifpD7IpQGCLgxQNBFsA5uBgakPg
-- Dumped from database version 17.7 -- Dumped from database version 17.7
-- Dumped by pg_dump version 17.7 -- Dumped by pg_dump version 17.7
@@ -1084,7 +1084,7 @@ ALTER TABLE ONLY public.user_devices
-- PostgreSQL database dump complete -- PostgreSQL database dump complete
-- --
\unrestrict F9AizESreuRieL4inRcHWWg3hyNET0FgnBDFBBBU3cZGPEpHjb591l8S2iglpap \unrestrict 7omiXDURqmmr2m2jWDDMoltRzeUAT80fRWiPifpD7IpQGCLgxQNBFsA5uBgakPg
-- --

View File

@@ -16,6 +16,10 @@
"typecheck": "turbo typecheck", "typecheck": "turbo typecheck",
"clean": "turbo clean", "clean": "turbo clean",
"test": "turbo test", "test": "turbo test",
"test:unit": "SKIP_DB_TESTS=1 turbo test",
"test:all": "turbo test",
"test:cov": "bun test --coverage",
"test:unit:cov": "SKIP_DB_TESTS=1 bun test --coverage",
"db:codegen": "bun run --cwd packages/db-schema generate" "db:codegen": "bun run --cwd packages/db-schema generate"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -12,7 +12,7 @@
}, },
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"test": "bun test", "test": "bun test src/",
"clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache", "clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache",
"lint": "eslint . --cache" "lint": "eslint . --cache"
}, },

View File

@@ -14,7 +14,7 @@
"build": "tsc", "build": "tsc",
"clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache", "clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache",
"lint": "eslint . --cache", "lint": "eslint . --cache",
"test": "bun test" "test": "bun test src/"
}, },
"devDependencies": { "devDependencies": {
"@macalinao/eslint-config": "catalog:", "@macalinao/eslint-config": "catalog:",

View File

@@ -1,15 +1,7 @@
{ {
"extends": "@macalinao/tsconfig/tsconfig.base.json", "extends": "@macalinao/tsconfig/tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"declarationMap": true,
"composite": true,
"types": ["node"] "types": ["node"]
}, },
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]
} }

View File

@@ -1,14 +1,7 @@
{ {
"extends": "@macalinao/tsconfig/tsconfig.base.json", "extends": "@macalinao/tsconfig/tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"declarationMap": true,
"types": ["node", "bun"] "types": ["node", "bun"]
}, },
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]
} }

View File

@@ -0,0 +1,12 @@
import { configs } from "@macalinao/eslint-config";
export default [
...configs.fast,
{
languageOptions: {
parserOptions: {
tsconfigRootDir: import.meta.dirname,
},
},
},
];

View File

@@ -0,0 +1,33 @@
{
"name": "@reviq/test-helpers",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsc",
"clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache",
"lint": "eslint . --cache"
},
"dependencies": {
"@reviq/db": "workspace:*",
"@reviq/db-schema": "workspace:*",
"kysely": "^0.28.2",
"pg": "^8.16.3"
},
"devDependencies": {
"@macalinao/eslint-config": "catalog:",
"@macalinao/tsconfig": "catalog:",
"@types/bun": "catalog:",
"@types/pg": "^8.16.0",
"eslint": "catalog:",
"typescript": "catalog:"
}
}

View File

@@ -0,0 +1,18 @@
export { describeE2E, SKIP_DB_TESTS } from "./skip-db-tests.js";
export {
DEFAULT_TEST_AAGUID,
KNOWN_AAGUIDS,
TEST_RP,
} from "./test-constants.js";
export {
createTestDb,
createTestUser,
destroySharedDb,
destroyTestDb,
getSharedDb,
getTestDatabaseUrl,
initTestDb,
runMigrations,
truncateAllTables,
} from "./test-db.js";
export { withTestTransaction } from "./test-transaction.js";

View File

@@ -0,0 +1,18 @@
import { describe } from "bun:test";
/**
* Skip flag for database-dependent tests.
* Set SKIP_DB_TESTS=1 to skip e2e tests that require a database.
*/
export const SKIP_DB_TESTS: boolean = process.env.SKIP_DB_TESTS === "1";
const _describeSkipIf = describe.skipIf(SKIP_DB_TESTS);
/**
* Use for describe blocks that require database access.
* Automatically prefixes name with [e2e].
* Skips tests when SKIP_DB_TESTS=1 is set.
*/
export function describeE2E(name: string, fn: () => void): void {
_describeSkipIf(`[e2e] ${name}`, fn);
}

View File

@@ -64,20 +64,31 @@ export function getTestDatabaseUrl(): string {
} }
/** /**
* Parses a postgres URL to extract components * Parses a postgres URL to extract components.
* Supports both TCP and unix socket connections.
*
* Unix socket URL format: postgresql:///dbname?host=/var/run/postgresql
*/ */
function parsePostgresUrl(url: string): { function parsePostgresUrl(url: string): {
host: string; host: string;
port: number; port: number | undefined;
user: string; user: string;
password: string; password: string;
database: string; database: string;
} { } {
const parsed = new URL(url); const parsed = new URL(url);
// Unix socket: hostname is empty, socket path in `host` query param
const isUnixSocket = !parsed.hostname;
const socketPath = parsed.searchParams.get("host");
return { return {
host: parsed.hostname, host: isUnixSocket
port: Number.parseInt(parsed.port || "5432", 10), ? (socketPath ?? "/var/run/postgresql")
user: parsed.username, : parsed.hostname,
port: isUnixSocket ? undefined : Number.parseInt(parsed.port || "5432", 10),
// eslint-disable-next-line turbo/no-undeclared-env-vars, @typescript-eslint/prefer-nullish-coalescing -- USER is a system env var, and we want empty string to fall back
user: parsed.username || process.env.USER || "postgres",
password: parsed.password, password: parsed.password,
database: parsed.pathname.slice(1), // Remove leading / database: parsed.pathname.slice(1), // Remove leading /
}; };

View File

@@ -0,0 +1,6 @@
{
"extends": "@macalinao/tsconfig/tsconfig.base.json",
"compilerOptions": {
"types": ["bun"]
}
}

View File

@@ -3,14 +3,19 @@
"version": "0.0.1", "version": "0.0.1",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": { "exports": {
".": "./src/index.ts" ".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
}, },
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache", "clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache",
"lint": "eslint . --cache", "lint": "eslint . --cache",
"test": "bun test" "test": "bun test src/"
}, },
"dependencies": { "dependencies": {
"@simplewebauthn/types": "^12.0.0" "@simplewebauthn/types": "^12.0.0"
@@ -18,7 +23,7 @@
"devDependencies": { "devDependencies": {
"@macalinao/eslint-config": "catalog:", "@macalinao/eslint-config": "catalog:",
"@macalinao/tsconfig": "catalog:", "@macalinao/tsconfig": "catalog:",
"@types/bun": "latest", "@types/bun": "catalog:",
"@types/node": "^25.0.3", "@types/node": "^25.0.3",
"eslint": "catalog:", "eslint": "catalog:",
"typescript": "catalog:" "typescript": "catalog:"

View File

@@ -1,15 +1,7 @@
{ {
"extends": "@macalinao/tsconfig/tsconfig.base.json", "extends": "@macalinao/tsconfig/tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"declarationMap": true,
"composite": true,
"types": ["node", "bun"] "types": ["node", "bun"]
}, },
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]
} }

View File

@@ -14,7 +14,7 @@
"build": "tsc", "build": "tsc",
"clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache", "clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache",
"lint": "eslint . --cache", "lint": "eslint . --cache",
"test": "bun test" "test": "bun test src/"
}, },
"devDependencies": { "devDependencies": {
"@cloudflare/workers-types": "^4.20250529.0", "@cloudflare/workers-types": "^4.20250529.0",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://turbo.build/schema.json", "$schema": "https://turbo.build/schema.json",
"globalEnv": ["DATABASE_URL", "PORT"], "globalEnv": ["DATABASE_URL", "PORT", "TEST_DATABASE_URL"],
"tasks": { "tasks": {
"build": { "build": {
"dependsOn": ["^build"], "dependsOn": ["^build"],
@@ -33,6 +33,7 @@
"test": { "test": {
"dependsOn": ["^build"], "dependsOn": ["^build"],
"inputs": ["src/**/*.ts", "src/**/*.test.ts"], "inputs": ["src/**/*.ts", "src/**/*.test.ts"],
"env": ["SKIP_DB_TESTS", "TEST_DATABASE_URL"],
"cache": false "cache": false
} }
} }