/** * Auth Tables RLS Integration Tests * * Tests that RLS policies on accounts and sessions tables correctly * enforce user-scoped access and prevent cross-user data leakage. * * Related: #350 - Add RLS policies to auth tables with FORCE enforcement */ import { describe, it, expect, beforeAll, afterAll } from "vitest"; import { PrismaClient, Prisma } from "@prisma/client"; import { randomUUID as uuid } from "crypto"; import { runWithRlsClient, getRlsClient } from "../prisma/rls-context.provider"; const shouldRunDbIntegrationTests = process.env.RUN_DB_TESTS === "true" && Boolean(process.env.DATABASE_URL); describe.skipIf(!shouldRunDbIntegrationTests)( "Auth Tables RLS Policies (requires DATABASE_URL)", () => { let prisma: PrismaClient; const testData: { users: string[]; accounts: string[]; sessions: string[]; } = { users: [], accounts: [], sessions: [], }; beforeAll(async () => { // Skip setup if DATABASE_URL is not available if (!shouldRunDbIntegrationTests) { return; } prisma = new PrismaClient(); await prisma.$connect(); // 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 (!shouldRunDbIntegrationTests || !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; } 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; } 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; }); }); 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"); // 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 accounts; }); }); // 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"); // 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 account; }); }); // 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"); // 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) => { 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: 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()!; const updated = await client.account.update({ where: { id: account1Id }, data: { accessToken: "updated-token" }, }); return updated; }); }); expect(result.accessToken).toBe("updated-token"); }); 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: account1Id }, }); }); }); // Verify the record was actually deleted const deleted = await prisma.account.findUnique({ where: { id: account1Id } }); expect(deleted).toBeNull(); }); }); 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)`; 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); }); 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)`; return runWithRlsClient(tx, async () => { const client = getRlsClient()!; const sessions = await client.session.findMany({ where: { userId: user2Id }, }); return sessions; }); }); // Should return empty array due to RLS policy expect(result).toHaveLength(0); }); 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) => { 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: 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()!; const updated = await client.session.update({ where: { id: session1Id }, data: { ipAddress: "192.168.1.1" }, }); return updated; }); }); expect(result.ipAddress).toBe("192.168.1.1"); }); 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: session1Id }, }); }); }); // 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"); // 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 }, }); }); }); 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] }, }, }); }); }); // 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); }); }); } );