From ef75f9ebdfbce92cf78cccc422b978255309ea42 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 22:41:41 +0000 Subject: [PATCH] test: add user data sandboxing security tests Co-authored-by: ZimengXiong <83783148+ZimengXiong@users.noreply.github.com> --- backend/src/__tests__/user-sandboxing.test.ts | 242 ++++++++++++++++++ 1 file changed, 242 insertions(+) create mode 100644 backend/src/__tests__/user-sandboxing.test.ts diff --git a/backend/src/__tests__/user-sandboxing.test.ts b/backend/src/__tests__/user-sandboxing.test.ts new file mode 100644 index 0000000..6e55bf7 --- /dev/null +++ b/backend/src/__tests__/user-sandboxing.test.ts @@ -0,0 +1,242 @@ +/** + * 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 { + getTestPrisma, + cleanupTestDb, + setupTestDb, + createTestDrawingPayload, +} 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 bcrypt = require("bcrypt"); + 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); + }); + }); +});