From 0b0666558e81ef8b3e033e747e2c08fa0eae1983 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sat, 7 Feb 2026 17:46:59 -0600 Subject: [PATCH] fix(test): Fix DATABASE_URL environment setup for integration tests Fixes integration test failures caused by missing DATABASE_URL environment variable. Changes: - Add dotenv as dev dependency to load .env.test in vitest setup - Add .env.test to .gitignore to prevent committing test credentials - Create .env.test.example with warning comments for documentation - Add conditional test skipping when DATABASE_URL is not available - Add DATABASE_URL format validation in vitest setup - Add error handling to test cleanup to prevent silent failures - Remove filesystem path disclosure from error messages The fix allows integration tests to: - Load DATABASE_URL from .env.test locally for developers with database setup - Skip gracefully if DATABASE_URL is not available (no database running) - Connect to postgres service in CI where DATABASE_URL is explicitly provided Tests affected: auth-rls.integration.spec.ts and other integration tests requiring real database connections. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 1 + apps/api/.env.test.example | 9 + apps/api/package.json | 1 + .../api/src/auth/auth-rls.integration.spec.ts | 1007 +++++++++-------- apps/api/vitest.setup.ts | 30 + pnpm-lock.yaml | 13 +- 6 files changed, 565 insertions(+), 496 deletions(-) create mode 100644 apps/api/.env.test.example diff --git a/.gitignore b/.gitignore index aefd319..85daeb0 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ Thumbs.db # Environment .env .env.local +.env.test .env.development.local .env.test.local .env.production.local diff --git a/apps/api/.env.test.example b/apps/api/.env.test.example new file mode 100644 index 0000000..e591463 --- /dev/null +++ b/apps/api/.env.test.example @@ -0,0 +1,9 @@ +# WARNING: These are example test credentials for local integration testing. +# Copy this file to .env.test and customize the values for your local environment. +# NEVER use these credentials in any shared environment or commit .env.test to git. + +DATABASE_URL="postgresql://test:test@localhost:5432/test" +ENCRYPTION_KEY="test-encryption-key-32-characters" +JWT_SECRET="test-jwt-secret" +INSTANCE_NAME="Test Instance" +INSTANCE_URL="https://test.example.com" diff --git a/apps/api/package.json b/apps/api/package.json index e80027d..3f4009f 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -89,6 +89,7 @@ "@types/sanitize-html": "^2.16.0", "@types/supertest": "^6.0.3", "@vitest/coverage-v8": "^4.0.18", + "dotenv": "^17.2.4", "express": "^5.2.1", "prisma": "^6.19.2", "supertest": "^7.2.2", diff --git a/apps/api/src/auth/auth-rls.integration.spec.ts b/apps/api/src/auth/auth-rls.integration.spec.ts index a4daedc..cb78bbc 100644 --- a/apps/api/src/auth/auth-rls.integration.spec.ts +++ b/apps/api/src/auth/auth-rls.integration.spec.ts @@ -12,208 +12,201 @@ import { PrismaClient, Prisma } from "@prisma/client"; import { randomUUID as uuid } from "crypto"; import { runWithRlsClient, getRlsClient } from "../prisma/rls-context.provider"; -describe("Auth Tables RLS Policies", () => { - let prisma: PrismaClient; - const testData: { - users: string[]; - accounts: string[]; - sessions: string[]; - } = { - users: [], - accounts: [], - sessions: [], - }; +describe.skipIf(!process.env.DATABASE_URL)( + "Auth Tables RLS Policies (requires DATABASE_URL)", + () => { + let prisma: PrismaClient; + const testData: { + users: string[]; + accounts: string[]; + sessions: string[]; + } = { + users: [], + accounts: [], + sessions: [], + }; - beforeAll(async () => { - prisma = new PrismaClient(); - await prisma.$connect(); + beforeAll(async () => { + // Skip setup if DATABASE_URL is not available + if (!process.env.DATABASE_URL) { + return; + } - // RLS policies are bypassed for superusers - const [{ rolsuper }] = await prisma.$queryRaw<[{ rolsuper: boolean }]>` - SELECT rolsuper FROM pg_roles WHERE rolname = current_user - `; - if (rolsuper) { - throw new Error( - "Auth RLS integration tests require a non-superuser database role. " + - "See migration 20260207_add_auth_rls_policies for setup instructions." - ); - } - }); + prisma = new PrismaClient(); + await prisma.$connect(); - afterAll(async () => { - // Clean up test data - if (testData.sessions.length > 0) { - await prisma.session.deleteMany({ - where: { id: { in: testData.sessions } }, + // RLS policies are bypassed for superusers + const [{ rolsuper }] = await prisma.$queryRaw<[{ rolsuper: boolean }]>` + SELECT rolsuper FROM pg_roles WHERE rolname = current_user + `; + if (rolsuper) { + throw new Error( + "Auth RLS integration tests require a non-superuser database role. " + + "See migration 20260207_add_auth_rls_policies for setup instructions." + ); + } + }); + + afterAll(async () => { + // Skip cleanup if DATABASE_URL is not available or prisma not initialized + if (!process.env.DATABASE_URL || !prisma) { + return; + } + + try { + // Clean up test data + if (testData.sessions.length > 0) { + await prisma.session.deleteMany({ + where: { id: { in: testData.sessions } }, + }); + } + + if (testData.accounts.length > 0) { + await prisma.account.deleteMany({ + where: { id: { in: testData.accounts } }, + }); + } + + if (testData.users.length > 0) { + await prisma.user.deleteMany({ + where: { id: { in: testData.users } }, + }); + } + + await prisma.$disconnect(); + } catch (error) { + console.error( + "Test cleanup failed:", + error instanceof Error ? error.message : String(error) + ); + // Re-throw to make test failure visible + throw new Error( + "Test cleanup failed. Database may contain orphaned test data. " + + `Error: ${error instanceof Error ? error.message : String(error)}` + ); + } + }); + + async function createTestUser(email: string): Promise { + const userId = uuid(); + await prisma.user.create({ + data: { + id: userId, + email, + name: `Test User ${email}`, + authProviderId: `auth-${userId}`, + }, }); + testData.users.push(userId); + return userId; } - if (testData.accounts.length > 0) { - await prisma.account.deleteMany({ - where: { id: { in: testData.accounts } }, + async function createTestAccount(userId: string, token: string): Promise { + const accountId = uuid(); + await prisma.account.create({ + data: { + id: accountId, + userId, + accountId: `account-${accountId}`, + providerId: "test-provider", + accessToken: token, + }, }); + testData.accounts.push(accountId); + return accountId; } - if (testData.users.length > 0) { - await prisma.user.deleteMany({ - where: { id: { in: testData.users } }, + async function createTestSession(userId: string): Promise { + const sessionId = uuid(); + await prisma.session.create({ + data: { + id: sessionId, + userId, + token: `session-${sessionId}-${Date.now()}`, + expiresAt: new Date(Date.now() + 86400000), + }, }); + testData.sessions.push(sessionId); + return sessionId; } - await prisma.$disconnect(); - }); + describe("Account table RLS", () => { + it("should allow user to read their own accounts when RLS context is set", async () => { + const user1Id = await createTestUser("account-read-own@test.com"); + const account1Id = await createTestAccount(user1Id, "user1-token"); - async function createTestUser(email: string): Promise { - const userId = uuid(); - await prisma.user.create({ - data: { - id: userId, - email, - name: `Test User ${email}`, - authProviderId: `auth-${userId}`, - }, - }); - testData.users.push(userId); - return userId; - } + // Use runWithRlsClient to set RLS context + const result = await prisma.$transaction(async (tx) => { + await tx.$executeRaw`SELECT set_config('app.current_user_id', ${user1Id}::text, true)`; - async function createTestAccount(userId: string, token: string): Promise { - const accountId = uuid(); - await prisma.account.create({ - data: { - id: accountId, - userId, - accountId: `account-${accountId}`, - providerId: "test-provider", - accessToken: token, - }, - }); - testData.accounts.push(accountId); - return accountId; - } + return runWithRlsClient(tx, async () => { + const client = getRlsClient()!; + const accounts = await client.account.findMany({ + where: { userId: user1Id }, + }); - async function createTestSession(userId: string): Promise { - const sessionId = uuid(); - await prisma.session.create({ - data: { - id: sessionId, - userId, - token: `session-${sessionId}-${Date.now()}`, - expiresAt: new Date(Date.now() + 86400000), - }, - }); - testData.sessions.push(sessionId); - return sessionId; - } - - describe("Account table RLS", () => { - it("should allow user to read their own accounts when RLS context is set", async () => { - const user1Id = await createTestUser("account-read-own@test.com"); - const account1Id = await createTestAccount(user1Id, "user1-token"); - - // Use runWithRlsClient to set RLS context - const result = await prisma.$transaction(async (tx) => { - await tx.$executeRaw`SELECT set_config('app.current_user_id', ${user1Id}::text, true)`; - - return runWithRlsClient(tx, async () => { - const client = getRlsClient()!; - const accounts = await client.account.findMany({ - where: { userId: user1Id }, + return accounts; }); - - return accounts; }); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe(account1Id); + expect(result[0].accessToken).toBe("user1-token"); }); - expect(result).toHaveLength(1); - expect(result[0].id).toBe(account1Id); - expect(result[0].accessToken).toBe("user1-token"); - }); + it("should prevent user from reading other users accounts", async () => { + const user1Id = await createTestUser("account-read-self@test.com"); + const user2Id = await createTestUser("account-read-other@test.com"); + await createTestAccount(user1Id, "user1-token"); + await createTestAccount(user2Id, "user2-token"); - it("should prevent user from reading other users accounts", async () => { - const user1Id = await createTestUser("account-read-self@test.com"); - const user2Id = await createTestUser("account-read-other@test.com"); - await createTestAccount(user1Id, "user1-token"); - await createTestAccount(user2Id, "user2-token"); + // Set RLS context for user1, try to read user2's accounts + const result = await prisma.$transaction(async (tx) => { + await tx.$executeRaw`SELECT set_config('app.current_user_id', ${user1Id}::text, true)`; - // Set RLS context for user1, try to read user2's accounts - const result = await prisma.$transaction(async (tx) => { - await tx.$executeRaw`SELECT set_config('app.current_user_id', ${user1Id}::text, true)`; + return runWithRlsClient(tx, async () => { + const client = getRlsClient()!; + const accounts = await client.account.findMany({ + where: { userId: user2Id }, + }); - return runWithRlsClient(tx, async () => { - const client = getRlsClient()!; - const accounts = await client.account.findMany({ - where: { userId: user2Id }, + return accounts; }); - - return accounts; }); + + // Should return empty array due to RLS policy + expect(result).toHaveLength(0); }); - // Should return empty array due to RLS policy - expect(result).toHaveLength(0); - }); + it("should prevent direct access by ID to other users accounts", async () => { + const user1Id = await createTestUser("account-id-self@test.com"); + const user2Id = await createTestUser("account-id-other@test.com"); + await createTestAccount(user1Id, "user1-token"); + const account2Id = await createTestAccount(user2Id, "user2-token"); - it("should prevent direct access by ID to other users accounts", async () => { - const user1Id = await createTestUser("account-id-self@test.com"); - const user2Id = await createTestUser("account-id-other@test.com"); - await createTestAccount(user1Id, "user1-token"); - const account2Id = await createTestAccount(user2Id, "user2-token"); + // Set RLS context for user1, try to read user2's account by ID + const result = await prisma.$transaction(async (tx) => { + await tx.$executeRaw`SELECT set_config('app.current_user_id', ${user1Id}::text, true)`; - // Set RLS context for user1, try to read user2's account by ID - const result = await prisma.$transaction(async (tx) => { - await tx.$executeRaw`SELECT set_config('app.current_user_id', ${user1Id}::text, true)`; + return runWithRlsClient(tx, async () => { + const client = getRlsClient()!; + const account = await client.account.findUnique({ + where: { id: account2Id }, + }); - return runWithRlsClient(tx, async () => { - const client = getRlsClient()!; - const account = await client.account.findUnique({ - where: { id: account2Id }, + return account; }); - - return account; }); + + // Should return null due to RLS policy + expect(result).toBeNull(); }); - // Should return null due to RLS policy - expect(result).toBeNull(); - }); + it("should allow user to create their own accounts", async () => { + const user1Id = await createTestUser("account-create-own@test.com"); - it("should allow user to create their own accounts", async () => { - const user1Id = await createTestUser("account-create-own@test.com"); - - // Set RLS context for user1, create their own account - const result = await prisma.$transaction(async (tx) => { - await tx.$executeRaw`SELECT set_config('app.current_user_id', ${user1Id}::text, true)`; - - return runWithRlsClient(tx, async () => { - const client = getRlsClient()!; - const newAccount = await client.account.create({ - data: { - id: uuid(), - userId: user1Id, - accountId: "new-account", - providerId: "test-provider", - accessToken: "new-token", - }, - }); - - testData.accounts.push(newAccount.id); - return newAccount; - }); - }); - - expect(result).toBeDefined(); - expect(result.userId).toBe(user1Id); - expect(result.accessToken).toBe("new-token"); - }); - - it("should prevent user from creating accounts for other users", async () => { - const user1Id = await createTestUser("account-create-self@test.com"); - const user2Id = await createTestUser("account-create-other@test.com"); - - // Set RLS context for user1, try to create an account for user2 - await expect( - prisma.$transaction(async (tx) => { + // Set RLS context for user1, create their own account + const result = await prisma.$transaction(async (tx) => { await tx.$executeRaw`SELECT set_config('app.current_user_id', ${user1Id}::text, true)`; return runWithRlsClient(tx, async () => { @@ -221,214 +214,215 @@ describe("Auth Tables RLS Policies", () => { const newAccount = await client.account.create({ data: { id: uuid(), - userId: user2Id, // Trying to create for user2 while logged in as user1 - accountId: "hacked-account", + userId: user1Id, + accountId: "new-account", providerId: "test-provider", - accessToken: "hacked-token", + accessToken: "new-token", }, }); testData.accounts.push(newAccount.id); return newAccount; }); - }) - ).rejects.toThrow(); - }); - - it("should allow user to update their own accounts", async () => { - const user1Id = await createTestUser("account-update-own@test.com"); - const account1Id = await createTestAccount(user1Id, "original-token"); - - const result = await prisma.$transaction(async (tx) => { - await tx.$executeRaw`SELECT set_config('app.current_user_id', ${user1Id}::text, true)`; - - return runWithRlsClient(tx, async () => { - const client = getRlsClient()!; - const updated = await client.account.update({ - where: { id: account1Id }, - data: { accessToken: "updated-token" }, - }); - - return updated; }); + + expect(result).toBeDefined(); + expect(result.userId).toBe(user1Id); + expect(result.accessToken).toBe("new-token"); }); - expect(result.accessToken).toBe("updated-token"); - }); + it("should prevent user from creating accounts for other users", async () => { + const user1Id = await createTestUser("account-create-self@test.com"); + const user2Id = await createTestUser("account-create-other@test.com"); - it("should prevent user from updating other users accounts", async () => { - const user1Id = await createTestUser("account-update-self@test.com"); - const user2Id = await createTestUser("account-update-other@test.com"); - await createTestAccount(user1Id, "user1-token"); - const account2Id = await createTestAccount(user2Id, "user2-token"); + // Set RLS context for user1, try to create an account for user2 + await expect( + prisma.$transaction(async (tx) => { + await tx.$executeRaw`SELECT set_config('app.current_user_id', ${user1Id}::text, true)`; - // Set RLS context for user1, try to update user2's account - await expect( - prisma.$transaction(async (tx) => { + return runWithRlsClient(tx, async () => { + const client = getRlsClient()!; + const newAccount = await client.account.create({ + data: { + id: uuid(), + userId: user2Id, // Trying to create for user2 while logged in as user1 + accountId: "hacked-account", + providerId: "test-provider", + accessToken: "hacked-token", + }, + }); + + testData.accounts.push(newAccount.id); + return newAccount; + }); + }) + ).rejects.toThrow(); + }); + + it("should allow user to update their own accounts", async () => { + const user1Id = await createTestUser("account-update-own@test.com"); + const account1Id = await createTestAccount(user1Id, "original-token"); + + const result = await prisma.$transaction(async (tx) => { await tx.$executeRaw`SELECT set_config('app.current_user_id', ${user1Id}::text, true)`; return runWithRlsClient(tx, async () => { const client = getRlsClient()!; - await client.account.update({ - where: { id: account2Id }, - data: { accessToken: "hacked-token" }, + const updated = await client.account.update({ + where: { id: account1Id }, + data: { accessToken: "updated-token" }, }); + + return updated; }); - }) - ).rejects.toThrow(); - }); + }); - it("should prevent user from deleting other users accounts", async () => { - const user1Id = await createTestUser("account-delete-self@test.com"); - const user2Id = await createTestUser("account-delete-other@test.com"); - await createTestAccount(user1Id, "user1-token"); - const account2Id = await createTestAccount(user2Id, "user2-token"); + expect(result.accessToken).toBe("updated-token"); + }); - // Set RLS context for user1, try to delete user2's account - await expect( - prisma.$transaction(async (tx) => { + it("should prevent user from updating other users accounts", async () => { + const user1Id = await createTestUser("account-update-self@test.com"); + const user2Id = await createTestUser("account-update-other@test.com"); + await createTestAccount(user1Id, "user1-token"); + const account2Id = await createTestAccount(user2Id, "user2-token"); + + // Set RLS context for user1, try to update user2's account + await expect( + prisma.$transaction(async (tx) => { + await tx.$executeRaw`SELECT set_config('app.current_user_id', ${user1Id}::text, true)`; + + return runWithRlsClient(tx, async () => { + const client = getRlsClient()!; + await client.account.update({ + where: { id: account2Id }, + data: { accessToken: "hacked-token" }, + }); + }); + }) + ).rejects.toThrow(); + }); + + it("should prevent user from deleting other users accounts", async () => { + const user1Id = await createTestUser("account-delete-self@test.com"); + const user2Id = await createTestUser("account-delete-other@test.com"); + await createTestAccount(user1Id, "user1-token"); + const account2Id = await createTestAccount(user2Id, "user2-token"); + + // Set RLS context for user1, try to delete user2's account + await expect( + prisma.$transaction(async (tx) => { + await tx.$executeRaw`SELECT set_config('app.current_user_id', ${user1Id}::text, true)`; + + return runWithRlsClient(tx, async () => { + const client = getRlsClient()!; + await client.account.delete({ + where: { id: account2Id }, + }); + }); + }) + ).rejects.toThrow(); + + // Verify the record still exists and wasn't deleted + const stillExists = await prisma.account.findUnique({ where: { id: account2Id } }); + expect(stillExists).not.toBeNull(); + expect(stillExists?.userId).toBe(user2Id); + }); + + it("should allow user to delete their own accounts", async () => { + const user1Id = await createTestUser("account-delete-own@test.com"); + const account1Id = await createTestAccount(user1Id, "user1-token"); + + // Set RLS context for user1, delete their own account + await prisma.$transaction(async (tx) => { await tx.$executeRaw`SELECT set_config('app.current_user_id', ${user1Id}::text, true)`; return runWithRlsClient(tx, async () => { const client = getRlsClient()!; await client.account.delete({ - where: { id: account2Id }, + where: { id: account1Id }, }); }); - }) - ).rejects.toThrow(); + }); - // Verify the record still exists and wasn't deleted - const stillExists = await prisma.account.findUnique({ where: { id: account2Id } }); - expect(stillExists).not.toBeNull(); - expect(stillExists?.userId).toBe(user2Id); + // Verify the record was actually deleted + const deleted = await prisma.account.findUnique({ where: { id: account1Id } }); + expect(deleted).toBeNull(); + }); }); - it("should allow user to delete their own accounts", async () => { - const user1Id = await createTestUser("account-delete-own@test.com"); - const account1Id = await createTestAccount(user1Id, "user1-token"); + describe("Session table RLS", () => { + it("should allow user to read their own sessions when RLS context is set", async () => { + const user1Id = await createTestUser("session-read-own@test.com"); + const session1Id = await createTestSession(user1Id); - // Set RLS context for user1, delete their own account - await prisma.$transaction(async (tx) => { - await tx.$executeRaw`SELECT set_config('app.current_user_id', ${user1Id}::text, true)`; + const result = await prisma.$transaction(async (tx) => { + await tx.$executeRaw`SELECT set_config('app.current_user_id', ${user1Id}::text, true)`; - return runWithRlsClient(tx, async () => { - const client = getRlsClient()!; - await client.account.delete({ - where: { id: account1Id }, + return runWithRlsClient(tx, async () => { + const client = getRlsClient()!; + const sessions = await client.session.findMany({ + where: { userId: user1Id }, + }); + + return sessions; }); }); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe(session1Id); }); - // Verify the record was actually deleted - const deleted = await prisma.account.findUnique({ where: { id: account1Id } }); - expect(deleted).toBeNull(); - }); - }); + it("should prevent user from reading other users sessions", async () => { + const user1Id = await createTestUser("session-read-self@test.com"); + const user2Id = await createTestUser("session-read-other@test.com"); + await createTestSession(user1Id); + await createTestSession(user2Id); - describe("Session table RLS", () => { - it("should allow user to read their own sessions when RLS context is set", async () => { - const user1Id = await createTestUser("session-read-own@test.com"); - const session1Id = await createTestSession(user1Id); + const result = await prisma.$transaction(async (tx) => { + await tx.$executeRaw`SELECT set_config('app.current_user_id', ${user1Id}::text, true)`; - const result = await prisma.$transaction(async (tx) => { - await tx.$executeRaw`SELECT set_config('app.current_user_id', ${user1Id}::text, true)`; + return runWithRlsClient(tx, async () => { + const client = getRlsClient()!; + const sessions = await client.session.findMany({ + where: { userId: user2Id }, + }); - return runWithRlsClient(tx, async () => { - const client = getRlsClient()!; - const sessions = await client.session.findMany({ - where: { userId: user1Id }, + return sessions; }); - - return sessions; }); + + // Should return empty array due to RLS policy + expect(result).toHaveLength(0); }); - expect(result).toHaveLength(1); - expect(result[0].id).toBe(session1Id); - }); + it("should prevent direct access by ID to other users sessions", async () => { + const user1Id = await createTestUser("session-id-self@test.com"); + const user2Id = await createTestUser("session-id-other@test.com"); + await createTestSession(user1Id); + const session2Id = await createTestSession(user2Id); - it("should prevent user from reading other users sessions", async () => { - const user1Id = await createTestUser("session-read-self@test.com"); - const user2Id = await createTestUser("session-read-other@test.com"); - await createTestSession(user1Id); - await createTestSession(user2Id); + const result = await prisma.$transaction(async (tx) => { + await tx.$executeRaw`SELECT set_config('app.current_user_id', ${user1Id}::text, true)`; - const result = await prisma.$transaction(async (tx) => { - await tx.$executeRaw`SELECT set_config('app.current_user_id', ${user1Id}::text, true)`; + return runWithRlsClient(tx, async () => { + const client = getRlsClient()!; + const session = await client.session.findUnique({ + where: { id: session2Id }, + }); - return runWithRlsClient(tx, async () => { - const client = getRlsClient()!; - const sessions = await client.session.findMany({ - where: { userId: user2Id }, + return session; }); - - return sessions; }); + + // Should return null due to RLS policy + expect(result).toBeNull(); }); - // Should return empty array due to RLS policy - expect(result).toHaveLength(0); - }); + it("should allow user to create their own sessions", async () => { + const user1Id = await createTestUser("session-create-own@test.com"); - it("should prevent direct access by ID to other users sessions", async () => { - const user1Id = await createTestUser("session-id-self@test.com"); - const user2Id = await createTestUser("session-id-other@test.com"); - await createTestSession(user1Id); - const session2Id = await createTestSession(user2Id); - - const result = await prisma.$transaction(async (tx) => { - await tx.$executeRaw`SELECT set_config('app.current_user_id', ${user1Id}::text, true)`; - - return runWithRlsClient(tx, async () => { - const client = getRlsClient()!; - const session = await client.session.findUnique({ - where: { id: session2Id }, - }); - - return session; - }); - }); - - // Should return null due to RLS policy - expect(result).toBeNull(); - }); - - it("should allow user to create their own sessions", async () => { - const user1Id = await createTestUser("session-create-own@test.com"); - - // Set RLS context for user1, create their own session - const result = await prisma.$transaction(async (tx) => { - await tx.$executeRaw`SELECT set_config('app.current_user_id', ${user1Id}::text, true)`; - - return runWithRlsClient(tx, async () => { - const client = getRlsClient()!; - const newSession = await client.session.create({ - data: { - id: uuid(), - userId: user1Id, - token: `new-session-${Date.now()}`, - expiresAt: new Date(Date.now() + 86400000), - }, - }); - - testData.sessions.push(newSession.id); - return newSession; - }); - }); - - expect(result).toBeDefined(); - expect(result.userId).toBe(user1Id); - expect(result.token).toContain("new-session"); - }); - - it("should prevent user from creating sessions for other users", async () => { - const user1Id = await createTestUser("session-create-self@test.com"); - const user2Id = await createTestUser("session-create-other@test.com"); - - // Set RLS context for user1, try to create a session for user2 - await expect( - prisma.$transaction(async (tx) => { + // Set RLS context for user1, create their own session + const result = await prisma.$transaction(async (tx) => { await tx.$executeRaw`SELECT set_config('app.current_user_id', ${user1Id}::text, true)`; return runWithRlsClient(tx, async () => { @@ -436,8 +430,8 @@ describe("Auth Tables RLS Policies", () => { const newSession = await client.session.create({ data: { id: uuid(), - userId: user2Id, // Trying to create for user2 while logged in as user1 - token: `hacked-session-${Date.now()}`, + userId: user1Id, + token: `new-session-${Date.now()}`, expiresAt: new Date(Date.now() + 86400000), }, }); @@ -445,208 +439,239 @@ describe("Auth Tables RLS Policies", () => { testData.sessions.push(newSession.id); return newSession; }); - }) - ).rejects.toThrow(); - }); - - it("should allow user to update their own sessions", async () => { - const user1Id = await createTestUser("session-update-own@test.com"); - const session1Id = await createTestSession(user1Id); - - const result = await prisma.$transaction(async (tx) => { - await tx.$executeRaw`SELECT set_config('app.current_user_id', ${user1Id}::text, true)`; - - return runWithRlsClient(tx, async () => { - const client = getRlsClient()!; - const updated = await client.session.update({ - where: { id: session1Id }, - data: { ipAddress: "192.168.1.1" }, - }); - - return updated; }); + + expect(result).toBeDefined(); + expect(result.userId).toBe(user1Id); + expect(result.token).toContain("new-session"); }); - expect(result.ipAddress).toBe("192.168.1.1"); - }); + it("should prevent user from creating sessions for other users", async () => { + const user1Id = await createTestUser("session-create-self@test.com"); + const user2Id = await createTestUser("session-create-other@test.com"); - it("should prevent user from updating other users sessions", async () => { - const user1Id = await createTestUser("session-update-self@test.com"); - const user2Id = await createTestUser("session-update-other@test.com"); - await createTestSession(user1Id); - const session2Id = await createTestSession(user2Id); + // Set RLS context for user1, try to create a session for user2 + await expect( + prisma.$transaction(async (tx) => { + await tx.$executeRaw`SELECT set_config('app.current_user_id', ${user1Id}::text, true)`; - await expect( - prisma.$transaction(async (tx) => { + return runWithRlsClient(tx, async () => { + const client = getRlsClient()!; + const newSession = await client.session.create({ + data: { + id: uuid(), + userId: user2Id, // Trying to create for user2 while logged in as user1 + token: `hacked-session-${Date.now()}`, + expiresAt: new Date(Date.now() + 86400000), + }, + }); + + testData.sessions.push(newSession.id); + return newSession; + }); + }) + ).rejects.toThrow(); + }); + + it("should allow user to update their own sessions", async () => { + const user1Id = await createTestUser("session-update-own@test.com"); + const session1Id = await createTestSession(user1Id); + + const result = await prisma.$transaction(async (tx) => { await tx.$executeRaw`SELECT set_config('app.current_user_id', ${user1Id}::text, true)`; return runWithRlsClient(tx, async () => { const client = getRlsClient()!; - await client.session.update({ - where: { id: session2Id }, - data: { ipAddress: "10.0.0.1" }, + const updated = await client.session.update({ + where: { id: session1Id }, + data: { ipAddress: "192.168.1.1" }, }); + + return updated; }); - }) - ).rejects.toThrow(); - }); + }); - it("should prevent user from deleting other users sessions", async () => { - const user1Id = await createTestUser("session-delete-self@test.com"); - const user2Id = await createTestUser("session-delete-other@test.com"); - await createTestSession(user1Id); - const session2Id = await createTestSession(user2Id); + expect(result.ipAddress).toBe("192.168.1.1"); + }); - await expect( - prisma.$transaction(async (tx) => { + it("should prevent user from updating other users sessions", async () => { + const user1Id = await createTestUser("session-update-self@test.com"); + const user2Id = await createTestUser("session-update-other@test.com"); + await createTestSession(user1Id); + const session2Id = await createTestSession(user2Id); + + await expect( + prisma.$transaction(async (tx) => { + await tx.$executeRaw`SELECT set_config('app.current_user_id', ${user1Id}::text, true)`; + + return runWithRlsClient(tx, async () => { + const client = getRlsClient()!; + await client.session.update({ + where: { id: session2Id }, + data: { ipAddress: "10.0.0.1" }, + }); + }); + }) + ).rejects.toThrow(); + }); + + it("should prevent user from deleting other users sessions", async () => { + const user1Id = await createTestUser("session-delete-self@test.com"); + const user2Id = await createTestUser("session-delete-other@test.com"); + await createTestSession(user1Id); + const session2Id = await createTestSession(user2Id); + + await expect( + prisma.$transaction(async (tx) => { + await tx.$executeRaw`SELECT set_config('app.current_user_id', ${user1Id}::text, true)`; + + return runWithRlsClient(tx, async () => { + const client = getRlsClient()!; + await client.session.delete({ + where: { id: session2Id }, + }); + }); + }) + ).rejects.toThrow(); + + // Verify the record still exists and wasn't deleted + const stillExists = await prisma.session.findUnique({ where: { id: session2Id } }); + expect(stillExists).not.toBeNull(); + expect(stillExists?.userId).toBe(user2Id); + }); + + it("should allow user to delete their own sessions", async () => { + const user1Id = await createTestUser("session-delete-own@test.com"); + const session1Id = await createTestSession(user1Id); + + // Set RLS context for user1, delete their own session + await prisma.$transaction(async (tx) => { await tx.$executeRaw`SELECT set_config('app.current_user_id', ${user1Id}::text, true)`; return runWithRlsClient(tx, async () => { const client = getRlsClient()!; await client.session.delete({ - where: { id: session2Id }, + where: { id: session1Id }, }); }); - }) - ).rejects.toThrow(); + }); - // Verify the record still exists and wasn't deleted - const stillExists = await prisma.session.findUnique({ where: { id: session2Id } }); - expect(stillExists).not.toBeNull(); - expect(stillExists?.userId).toBe(user2Id); + // Verify the record was actually deleted + const deleted = await prisma.session.findUnique({ where: { id: session1Id } }); + expect(deleted).toBeNull(); + }); }); - it("should allow user to delete their own sessions", async () => { - const user1Id = await createTestUser("session-delete-own@test.com"); - const session1Id = await createTestSession(user1Id); + describe("Owner bypass policy", () => { + it("should allow table owner to access all records without RLS context", async () => { + const user1Id = await createTestUser("owner-bypass-1@test.com"); + const user2Id = await createTestUser("owner-bypass-2@test.com"); + const account1Id = await createTestAccount(user1Id, "token1"); + const account2Id = await createTestAccount(user2Id, "token2"); - // Set RLS context for user1, delete their own session - await prisma.$transaction(async (tx) => { - await tx.$executeRaw`SELECT set_config('app.current_user_id', ${user1Id}::text, true)`; + // Don't set RLS context - rely on owner bypass policy + const accounts = await prisma.account.findMany({ + where: { + id: { in: [account1Id, account2Id] }, + }, + }); - return runWithRlsClient(tx, async () => { - const client = getRlsClient()!; - await client.session.delete({ - where: { id: session1Id }, - }); + // Owner should see both accounts + expect(accounts).toHaveLength(2); + }); + + it("should allow migrations to work without RLS context", async () => { + const userId = await createTestUser("migration-test@test.com"); + + // This simulates a migration or BetterAuth internal operation + // that doesn't have RLS context set + const newAccount = await prisma.account.create({ + data: { + id: uuid(), + userId, + accountId: "migration-test-account", + providerId: "test-migration", + }, + }); + + expect(newAccount.id).toBeDefined(); + + // Clean up + await prisma.account.delete({ + where: { id: newAccount.id }, }); }); - - // Verify the record was actually deleted - const deleted = await prisma.session.findUnique({ where: { id: session1Id } }); - expect(deleted).toBeNull(); - }); - }); - - describe("Owner bypass policy", () => { - it("should allow table owner to access all records without RLS context", async () => { - const user1Id = await createTestUser("owner-bypass-1@test.com"); - const user2Id = await createTestUser("owner-bypass-2@test.com"); - const account1Id = await createTestAccount(user1Id, "token1"); - const account2Id = await createTestAccount(user2Id, "token2"); - - // Don't set RLS context - rely on owner bypass policy - const accounts = await prisma.account.findMany({ - where: { - id: { in: [account1Id, account2Id] }, - }, - }); - - // Owner should see both accounts - expect(accounts).toHaveLength(2); }); - it("should allow migrations to work without RLS context", async () => { - const userId = await createTestUser("migration-test@test.com"); + describe("RLS context isolation", () => { + it("should enforce RLS when context is set, even for table owner", async () => { + const user1Id = await createTestUser("rls-enforce-1@test.com"); + const user2Id = await createTestUser("rls-enforce-2@test.com"); + const account1Id = await createTestAccount(user1Id, "token1"); + const account2Id = await createTestAccount(user2Id, "token2"); - // This simulates a migration or BetterAuth internal operation - // that doesn't have RLS context set - const newAccount = await prisma.account.create({ - data: { - id: uuid(), - userId, - accountId: "migration-test-account", - providerId: "test-migration", - }, - }); + // With RLS context set for user1, they should only see their own account + const result = await prisma.$transaction(async (tx) => { + await tx.$executeRaw`SELECT set_config('app.current_user_id', ${user1Id}::text, true)`; - expect(newAccount.id).toBeDefined(); + return runWithRlsClient(tx, async () => { + const client = getRlsClient()!; + const accounts = await client.account.findMany({ + where: { + id: { in: [account1Id, account2Id] }, + }, + }); - // Clean up - await prisma.account.delete({ - where: { id: newAccount.id }, - }); - }); - }); - - describe("RLS context isolation", () => { - it("should enforce RLS when context is set, even for table owner", async () => { - const user1Id = await createTestUser("rls-enforce-1@test.com"); - const user2Id = await createTestUser("rls-enforce-2@test.com"); - const account1Id = await createTestAccount(user1Id, "token1"); - const account2Id = await createTestAccount(user2Id, "token2"); - - // With RLS context set for user1, they should only see their own account - const result = await prisma.$transaction(async (tx) => { - await tx.$executeRaw`SELECT set_config('app.current_user_id', ${user1Id}::text, true)`; - - return runWithRlsClient(tx, async () => { - const client = getRlsClient()!; - const accounts = await client.account.findMany({ - where: { - id: { in: [account1Id, account2Id] }, - }, - }); - - return accounts; - }); - }); - - // Should only see user1's account, not user2's - expect(result).toHaveLength(1); - expect(result[0].id).toBe(account1Id); - }); - - it("should allow different users to see only their own data in separate contexts", async () => { - const user1Id = await createTestUser("context-user1@test.com"); - const user2Id = await createTestUser("context-user2@test.com"); - const session1Id = await createTestSession(user1Id); - const session2Id = await createTestSession(user2Id); - - // User1 context - query for both sessions, but RLS should only return user1's - const user1Result = await prisma.$transaction(async (tx) => { - await tx.$executeRaw`SELECT set_config('app.current_user_id', ${user1Id}::text, true)`; - - return runWithRlsClient(tx, async () => { - const client = getRlsClient()!; - return client.session.findMany({ - where: { - id: { in: [session1Id, session2Id] }, - }, + return accounts; }); }); + + // Should only see user1's account, not user2's + expect(result).toHaveLength(1); + expect(result[0].id).toBe(account1Id); }); - // User2 context - query for both sessions, but RLS should only return user2's - const user2Result = await prisma.$transaction(async (tx) => { - await tx.$executeRaw`SELECT set_config('app.current_user_id', ${user2Id}::text, true)`; + it("should allow different users to see only their own data in separate contexts", async () => { + const user1Id = await createTestUser("context-user1@test.com"); + const user2Id = await createTestUser("context-user2@test.com"); + const session1Id = await createTestSession(user1Id); + const session2Id = await createTestSession(user2Id); - return runWithRlsClient(tx, async () => { - const client = getRlsClient()!; - return client.session.findMany({ - where: { - id: { in: [session1Id, session2Id] }, - }, + // User1 context - query for both sessions, but RLS should only return user1's + const user1Result = await prisma.$transaction(async (tx) => { + await tx.$executeRaw`SELECT set_config('app.current_user_id', ${user1Id}::text, true)`; + + return runWithRlsClient(tx, async () => { + const client = getRlsClient()!; + return client.session.findMany({ + where: { + id: { in: [session1Id, session2Id] }, + }, + }); }); }); + + // User2 context - query for both sessions, but RLS should only return user2's + const user2Result = await prisma.$transaction(async (tx) => { + await tx.$executeRaw`SELECT set_config('app.current_user_id', ${user2Id}::text, true)`; + + return runWithRlsClient(tx, async () => { + const client = getRlsClient()!; + return client.session.findMany({ + where: { + id: { in: [session1Id, session2Id] }, + }, + }); + }); + }); + + // Each user should only see their own session + expect(user1Result).toHaveLength(1); + expect(user1Result[0].id).toBe(session1Id); + + expect(user2Result).toHaveLength(1); + expect(user2Result[0].id).toBe(session2Id); }); - - // Each user should only see their own session - expect(user1Result).toHaveLength(1); - expect(user1Result[0].id).toBe(session1Id); - - expect(user2Result).toHaveLength(1); - expect(user2Result[0].id).toBe(session2Id); }); - }); -}); + } +); diff --git a/apps/api/vitest.setup.ts b/apps/api/vitest.setup.ts index fded23a..b05036a 100644 --- a/apps/api/vitest.setup.ts +++ b/apps/api/vitest.setup.ts @@ -1 +1,31 @@ import "reflect-metadata"; +import * as dotenv from "dotenv"; +import * as path from "path"; +import * as fs from "fs"; + +// Load environment variables from .env.test if it exists +// This allows local integration tests to run with a local database +// CI environments explicitly provide DATABASE_URL in the test step +const envTestPath = path.resolve(__dirname, ".env.test"); +if (fs.existsSync(envTestPath)) { + const result = dotenv.config({ path: envTestPath }); + if (result.error) { + throw new Error( + `Failed to load test environment configuration: ${result.error.message}\n` + + `Ensure .env.test exists in the api directory and is properly formatted.` + ); + } + + // Validate DATABASE_URL format if provided + if (process.env.DATABASE_URL && !process.env.DATABASE_URL.startsWith("postgresql://")) { + throw new Error( + "Invalid DATABASE_URL format in .env.test. " + + "Expected format: postgresql://user:password@host:port/database" + ); + } + + // Log only in debug mode + if (process.env.DEBUG) { + console.debug("Test environment variables loaded from .env.test"); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2cf9137..a9cc6c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -244,6 +244,9 @@ importers: '@vitest/coverage-v8': specifier: ^4.0.18 version: 4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.7)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + dotenv: + specifier: ^17.2.4 + version: 17.2.4 express: specifier: ^5.2.1 version: 5.2.1 @@ -4103,8 +4106,8 @@ packages: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} - dotenv@17.2.3: - resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} + dotenv@17.2.4: + resolution: {integrity: sha512-mudtfb4zRB4bVvdj0xRo+e6duH1csJRM8IukBqfTRvHotn9+LBXB8ynAidP9zHqoRC/fsllXgk4kCKlR21fIhw==} engines: {node: '>=12'} drizzle-orm@0.41.0: @@ -7040,7 +7043,7 @@ snapshots: c12: 3.3.3(magicast@0.3.5) chalk: 5.6.2 commander: 12.1.0 - dotenv: 17.2.3 + dotenv: 17.2.4 drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)) open: 10.2.0 pg: 8.17.2 @@ -10150,7 +10153,7 @@ snapshots: chokidar: 5.0.0 confbox: 0.2.2 defu: 6.1.4 - dotenv: 17.2.3 + dotenv: 17.2.4 exsolve: 1.0.8 giget: 2.0.0 jiti: 2.6.1 @@ -10748,7 +10751,7 @@ snapshots: dotenv@16.6.1: {} - dotenv@17.2.3: {} + dotenv@17.2.4: {} drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)): optionalDependencies: