Compare commits

..

7 Commits

Author SHA1 Message Date
igm
94b6de5970 Merge branch 'test-coverage'
Some checks failed
CI / ci (push) Has been cancelled
Add @reviq/test-helpers package with e2e tests for admin, auth, orgs, and webauthn.
Move test utilities to shared package.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 13:43:28 +08:00
igm
6fa4da1abb Fix lint errors and add ast-grep rule for countAll
- Fix template literal expressions: wrap Date.now() in String()
- Add missing afterAll import in admin.test.ts
- Fix countOwners to use countAll() without misleading <number> type
- Add ast-grep rule to prevent countAll<number>() usage
- Fix formatting issues from merge conflict resolution

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 13:40:06 +08:00
igm
92f7e1df09 Merge origin/master and migrate tests to describeE2E
- Resolve merge conflicts in auth.test.ts, me.test.ts, db/schema.sql
- Merge new loginRequestMiddleware tests into auth.test.ts describeE2E wrapper
- Merge new authMiddleware tests into me.test.ts describeE2E wrapper
- Add me.apiTokens and me.invites tests in separate describeE2E block
- Migrate admin.test.ts to use describeE2E and @reviq/test-helpers
- Migrate orgs.test.ts to use describeE2E and @reviq/test-helpers

All e2e tests now properly use the describeE2E helper which enables
SKIP_DB_TESTS environment variable support.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 13:19:29 +08:00
igm
b2fba6e150 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>
2026-01-12 13:03:41 +08:00
igm
ebc85af62c Add comprehensive e2e tests for API procedures with 100% coverage
- Add admin.test.ts: Tests for superuser operations (users, orgs, sites)
- Add orgs.test.ts: Tests for org management, members, invites, sites
- Expand me.test.ts: Add API tokens, invites, authMiddleware error paths
- Expand auth.test.ts: Add loginRequestMiddleware tests, weak password test fix

Bug fixes:
- Fix countOwners() in orgs/helpers.ts to convert PostgreSQL bigint to number
- Fix signup race condition by handling unique constraint violations gracefully

All 283 tests pass with 100% function coverage on procedures.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 12:53:19 +08:00
igm
6b8dd27898 Merge branch 'schema-sql-fix' 2026-01-12 12:41:15 +08:00
igm
848d9e9af1 Add db-dump and db-migrate scripts to strip \restrict lines
PostgreSQL 17.6+ adds random \restrict/\unrestrict tokens to pg_dump
output (CVE-2025-8714 security fix), causing schema.sql to appear
changed on every dump even when the schema hasn't changed.

These wrapper scripts run dbmate and strip the \restrict lines from
the output to keep schema.sql stable.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 12:33:20 +08:00
35 changed files with 8868 additions and 3825 deletions

View File

@@ -0,0 +1,8 @@
id: no-countall-number
language: typescript
severity: error
message: "Don't use countAll<number>() - use countAll() instead. PostgreSQL COUNT returns bigint (string), so the type annotation is misleading."
note: "Use Number() to convert the result if you need a number type."
rule:
pattern: countAll<number>()
fix: countAll()

View File

@@ -1,5 +1,13 @@
# Claude Code Notes # Claude Code Notes
## Database Scripts
Use the wrapper scripts instead of running dbmate directly:
- `./scripts/db-dump` - Dump schema without random `\restrict` tokens
- `./scripts/db-migrate` - Run migrations and dump clean schema
PostgreSQL 17.6+ adds random `\restrict`/`\unrestrict` lines to pg_dump output (CVE-2025-8714 fix), causing schema.sql to show as changed on every dump. These scripts strip those lines.
## Development Server ## Development Server
Before starting the dev server, check if it's already running: Before starting the dev server, check if it's already running:

View File

@@ -111,6 +111,8 @@ bun run dev
| `bun run lint:fix` | Fix linting issues | | `bun run lint:fix` | Fix linting issues |
| `bun run test` | Run tests | | `bun run test` | Run tests |
| `bun run db:codegen` | Generate database types | | `bun run db:codegen` | Generate database types |
| `./scripts/db-dump` | Dump database schema (strips `\restrict` lines) |
| `./scripts/db-migrate` | Run migrations (strips `\restrict` lines) |
## CLI ## CLI

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:"
} }

File diff suppressed because it is too large Load Diff

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,6 +268,7 @@ async function createPasswordReset(
return token; return token;
} }
describeE2E("auth", () => {
// Test setup // Test setup
beforeAll(async () => { beforeAll(async () => {
await initTestDb(); await initTestDb();
@@ -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
@@ -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,
@@ -1344,7 +1354,9 @@ 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();
}); });
}); });
@@ -1503,7 +1515,6 @@ describe("auth.resetPassword", () => {
// Create some sessions // Create some sessions
await createSession(db, user.id); await createSession(db, user.id);
await createSession(db, user.id);
const token = await createPasswordReset(db, user.id); const token = await createPasswordReset(db, user.id);
@@ -1875,7 +1886,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 +2005,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 },
@@ -2105,3 +2120,62 @@ describe("End-to-end login scenarios", () => {
}); });
}); });
}); });
// =============================================================================
// 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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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,6 +200,7 @@ async function authenticate(
); );
} }
describeE2E("webauthn", () => {
beforeAll(async () => { beforeAll(async () => {
await initTestDb(); await initTestDb();
}); });
@@ -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 },
@@ -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);
}); });
@@ -1194,3 +1214,4 @@ describe("passkey management", () => {
}); });
}); });
}); });
}); // Close outer describe.skipIf

View File

@@ -52,7 +52,8 @@ export async function signupWithPassword(
// Hash password // Hash password
const passwordHash = await hashPassword(password); const passwordHash = await hashPassword(password);
// Create user // Create user (handle race condition if concurrent signup with same email)
try {
const user = await db const user = await db
.insertInto("users") .insertInto("users")
.values({ .values({
@@ -63,6 +64,16 @@ export async function signupWithPassword(
.executeTakeFirstOrThrow(); .executeTakeFirstOrThrow();
return user.id; return user.id;
} catch (error) {
// Handle duplicate email (unique constraint violation)
// Use generic error to prevent email enumeration
if (error instanceof Error && error.message.includes("users_email_key")) {
throw new ORPCError("BAD_REQUEST", {
message: "Unable to create account",
});
}
throw error;
}
} }
/** /**
@@ -146,7 +157,8 @@ export async function signupWithPasskey(
}); });
} }
// Create user and passkey in a transaction // Create user and passkey in a transaction (handle race condition if concurrent signup)
try {
const result = await db.transaction().execute(async (trx) => { const result = await db.transaction().execute(async (trx) => {
// Create user // Create user
const user = await trx const user = await trx
@@ -195,6 +207,16 @@ export async function signupWithPasskey(
}); });
return result.userId; return result.userId;
} catch (error) {
// Handle duplicate email (unique constraint violation)
// Use generic error to prevent email enumeration
if (error instanceof Error && error.message.includes("users_email_key")) {
throw new ORPCError("BAD_REQUEST", {
message: "Unable to create account",
});
}
throw error;
}
} }
/** /**
@@ -241,7 +263,7 @@ export const signup = os.auth.signup.handler(async ({ input, context }) => {
); );
userId = await signupWithPasskey(context.db, email, passkeyInfo, rpInfo); userId = await signupWithPasskey(context.db, email, passkeyInfo, rpInfo);
} else { } else {
// Should never reach here due to schema validation // Unreachable - schema validation requires password or passkeyInfo
throw new ORPCError("BAD_REQUEST", { throw new ORPCError("BAD_REQUEST", {
message: "Either password or passkeyInfo is required", message: "Either password or passkeyInfo is required",
}); });

View File

@@ -115,10 +115,11 @@ export async function countOwners(
): Promise<number> { ): Promise<number> {
const result = await db const result = await db
.selectFrom("org_members") .selectFrom("org_members")
.select((eb) => eb.fn.countAll<number>().as("count")) .select((eb) => eb.fn.countAll().as("count"))
.where("org_id", "=", orgId) .where("org_id", "=", orgId)
.where("role", "=", "owner") .where("role", "=", "owner")
.executeTakeFirstOrThrow(); .executeTakeFirstOrThrow();
return result.count; // PostgreSQL COUNT returns bigint (string), convert to number
return Number(result.count);
} }

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:",
}, },
@@ -192,6 +191,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",
@@ -201,7 +218,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:",
@@ -439,6 +456,8 @@
"@reviq/frontend-utils": ["@reviq/frontend-utils@workspace:packages/frontend-utils"], "@reviq/frontend-utils": ["@reviq/frontend-utils@workspace:packages/frontend-utils"],
"@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,3 @@
\restrict F9AizESreuRieL4inRcHWWg3hyNET0FgnBDFBBBU3cZGPEpHjb591l8S2iglpap
-- 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 +1083,6 @@ ALTER TABLE ONLY public.user_devices
-- PostgreSQL database dump complete -- PostgreSQL database dump complete
-- --
\unrestrict F9AizESreuRieL4inRcHWWg3hyNET0FgnBDFBBBU3cZGPEpHjb591l8S2iglpap
-- --

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",

16
scripts/db-dump Executable file
View File

@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Wrapper for dbmate dump that strips PostgreSQL's \restrict lines.
# PostgreSQL 17.6+ adds random \restrict/\unrestrict tokens to pg_dump output
# (CVE-2025-8714 security fix), causing schema.sql to change on every dump.
set -euo pipefail
SCHEMA_FILE="${DBMATE_SCHEMA_FILE:-./db/schema.sql}"
dbmate dump "$@"
# Strip \restrict and \unrestrict lines (they start with backslash)
if [[ -f "$SCHEMA_FILE" ]]; then
grep -v '^\\' "$SCHEMA_FILE" > "${SCHEMA_FILE}.tmp"
mv "${SCHEMA_FILE}.tmp" "$SCHEMA_FILE"
fi

16
scripts/db-migrate Executable file
View File

@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Wrapper for dbmate migrate that strips PostgreSQL's \restrict lines.
# PostgreSQL 17.6+ adds random \restrict/\unrestrict tokens to pg_dump output
# (CVE-2025-8714 security fix), causing schema.sql to change on every dump.
set -euo pipefail
SCHEMA_FILE="${DBMATE_SCHEMA_FILE:-./db/schema.sql}"
dbmate migrate "$@"
# Strip \restrict and \unrestrict lines (they start with backslash)
if [[ -f "$SCHEMA_FILE" ]]; then
grep -v '^\\' "$SCHEMA_FILE" > "${SCHEMA_FILE}.tmp"
mv "${SCHEMA_FILE}.tmp" "$SCHEMA_FILE"
fi

View File

@@ -1,6 +1,6 @@
ruleDirs: ruleDirs:
- /Users/igm/proj/reviq/publisher-dashboard/.ast-grep/rules/ - .ast-grep/rules/
testConfigs: testConfigs:
- testDir: /Users/igm/proj/reviq/publisher-dashboard/.ast-grep/rule-tests/ - testDir: .ast-grep/rule-tests/
utilDirs: utilDirs:
- /Users/igm/proj/reviq/publisher-dashboard/.ast-grep/utils/ - .ast-grep/utils/

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
} }
} }