test: add user data sandboxing security tests
Co-authored-by: ZimengXiong <83783148+ZimengXiong@users.noreply.github.com>
This commit is contained in:
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user