/** * Security tests for user data sandboxing * * Verifies that: * 1. Drawings cache keys are scoped by userId (prevents cross-user data leakage) * 2. Drawing CRUD operations enforce userId filtering * 3. Collection operations enforce userId filtering */ import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; import bcrypt from "bcrypt"; import { getTestPrisma, setupTestDb, } from "./testUtils"; import { PrismaClient } from "../generated/client"; let prisma: PrismaClient; // These tests verify the data isolation logic at the database query level describe("User Data Sandboxing", () => { let userA: { id: string; email: string }; let userB: { id: string; email: string }; beforeAll(async () => { setupTestDb(); prisma = getTestPrisma(); // Create two test users const hashA = await bcrypt.hash("passwordA", 10); const hashB = await bcrypt.hash("passwordB", 10); userA = await prisma.user.upsert({ where: { email: "usera@test.com" }, update: {}, create: { email: "usera@test.com", passwordHash: hashA, name: "User A", }, }); userB = await prisma.user.upsert({ where: { email: "userb@test.com" }, update: {}, create: { email: "userb@test.com", passwordHash: hashB, name: "User B", }, }); }); afterAll(async () => { await prisma.$disconnect(); }); beforeEach(async () => { await prisma.drawing.deleteMany({}); await prisma.collection.deleteMany({}); }); describe("Drawing isolation", () => { it("should not return User A's drawings when querying as User B", async () => { // Create a drawing for User A await prisma.drawing.create({ data: { name: "User A Drawing", elements: "[]", appState: "{}", userId: userA.id, }, }); // Query as User B - should get 0 results const userBDrawings = await prisma.drawing.findMany({ where: { userId: userB.id }, }); expect(userBDrawings).toHaveLength(0); }); it("should only return the owning user's drawings", async () => { // Create drawings for both users await prisma.drawing.create({ data: { name: "User A Drawing", elements: "[]", appState: "{}", userId: userA.id, }, }); await prisma.drawing.create({ data: { name: "User B Drawing", elements: "[]", appState: "{}", userId: userB.id, }, }); const userADrawings = await prisma.drawing.findMany({ where: { userId: userA.id }, }); const userBDrawings = await prisma.drawing.findMany({ where: { userId: userB.id }, }); expect(userADrawings).toHaveLength(1); expect(userADrawings[0].name).toBe("User A Drawing"); expect(userBDrawings).toHaveLength(1); expect(userBDrawings[0].name).toBe("User B Drawing"); }); it("should not allow User B to access User A's drawing by ID", async () => { const drawing = await prisma.drawing.create({ data: { name: "User A Secret Drawing", elements: "[]", appState: "{}", userId: userA.id, }, }); // Simulate the findFirst query used in GET /drawings/:id const result = await prisma.drawing.findFirst({ where: { id: drawing.id, userId: userB.id, // User B trying to access }, }); expect(result).toBeNull(); }); }); describe("Collection isolation", () => { it("should not return User A's collections when querying as User B", async () => { await prisma.collection.create({ data: { name: "User A Collection", userId: userA.id, }, }); const userBCollections = await prisma.collection.findMany({ where: { userId: userB.id }, }); expect(userBCollections).toHaveLength(0); }); it("should not allow User B to modify User A's collection", async () => { const collection = await prisma.collection.create({ data: { name: "User A Collection", userId: userA.id, }, }); // Simulate the findFirst query used in PUT /collections/:id const result = await prisma.collection.findFirst({ where: { id: collection.id, userId: userB.id, }, }); expect(result).toBeNull(); }); }); describe("Cache key user scoping", () => { it("should generate different cache keys for different users with same query params", () => { // This tests the buildDrawingsCacheKey function logic inline // The function was updated to include userId in the cache key const buildDrawingsCacheKey = (keyParts: { userId: string; searchTerm: string; collectionFilter: string; includeData: boolean; }) => JSON.stringify([ keyParts.userId, keyParts.searchTerm, keyParts.collectionFilter, keyParts.includeData ? "full" : "summary", ]); const keyA = buildDrawingsCacheKey({ userId: "user-a-id", searchTerm: "", collectionFilter: "default", includeData: false, }); const keyB = buildDrawingsCacheKey({ userId: "user-b-id", searchTerm: "", collectionFilter: "default", includeData: false, }); expect(keyA).not.toBe(keyB); }); it("should generate same cache key for same user with same query params", () => { const buildDrawingsCacheKey = (keyParts: { userId: string; searchTerm: string; collectionFilter: string; includeData: boolean; }) => JSON.stringify([ keyParts.userId, keyParts.searchTerm, keyParts.collectionFilter, keyParts.includeData ? "full" : "summary", ]); const key1 = buildDrawingsCacheKey({ userId: "same-user", searchTerm: "test", collectionFilter: "default", includeData: true, }); const key2 = buildDrawingsCacheKey({ userId: "same-user", searchTerm: "test", collectionFilter: "default", includeData: true, }); expect(key1).toBe(key2); }); }); });