From b29500bcf4061d3b81e6f5fff3d22b181804b262 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Tue, 20 Jan 2026 21:30:25 +0100 Subject: [PATCH] test: multi-org isolation --- app/server/__tests__/isolation.test.ts | 207 +++++++++++++++++++++++++ app/test/helpers/auth.ts | 63 +++----- 2 files changed, 230 insertions(+), 40 deletions(-) create mode 100644 app/server/__tests__/isolation.test.ts diff --git a/app/server/__tests__/isolation.test.ts b/app/server/__tests__/isolation.test.ts new file mode 100644 index 00000000..f17b3905 --- /dev/null +++ b/app/server/__tests__/isolation.test.ts @@ -0,0 +1,207 @@ +import { test, describe, expect } from "bun:test"; +import { createApp } from "~/server/app"; +import { createTestSession } from "~/test/helpers/auth"; +import { db } from "~/server/db/db"; +import { repositoriesTable, volumesTable, backupSchedulesTable } from "~/server/db/schema"; +import crypto from "node:crypto"; +import { generateShortId } from "~/server/utils/id"; + +const app = createApp(); + +describe("multi-organization isolation", () => { + test("should not be able to access repositories from another organization", async () => { + const session1 = await createTestSession(); + const session2 = await createTestSession(); + + expect(session1.organizationId).not.toBe(session2.organizationId); + + const repoId = crypto.randomUUID(); + const shortId = generateShortId(); + await db.insert(repositoriesTable).values({ + id: repoId, + shortId, + name: "Org 1 Repo", + type: "local", + config: { backend: "local", name: "org1repo", path: "/tmp/repo1" }, + organizationId: session1.organizationId, + }); + + const res = await app.request(`/api/v1/repositories/${repoId}`, { + headers: { + Cookie: `better-auth.session_token=${session2.token}`, + }, + }); + + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.message).toBe("Repository not found"); + + const resOk = await app.request(`/api/v1/repositories/${repoId}`, { + headers: { + Cookie: `better-auth.session_token=${session1.token}`, + }, + }); + expect(resOk.status).toBe(200); + }); + + test("should not list repositories from another organization", async () => { + const session1 = await createTestSession(); + const session2 = await createTestSession(); + + await db.insert(repositoriesTable).values({ + id: crypto.randomUUID(), + shortId: generateShortId(), + name: "Org 1 Repo", + type: "local", + config: { backend: "local", name: "org1repo-list", path: "/tmp/repo1" }, + organizationId: session1.organizationId, + }); + + await db.insert(repositoriesTable).values({ + id: crypto.randomUUID(), + shortId: generateShortId(), + name: "Org 2 Repo", + type: "local", + config: { backend: "local", name: "org2repo-list", path: "/tmp/repo2" }, + organizationId: session2.organizationId, + }); + + const res1 = await app.request("/api/v1/repositories", { + headers: { + Cookie: `better-auth.session_token=${session1.token}`, + }, + }); + const list1 = await res1.json(); + + expect(list1.length).toBeGreaterThanOrEqual(1); + expect(list1.some((r: any) => r.name === "Org 2 Repo")).toBe(false); + + const res2 = await app.request("/api/v1/repositories", { + headers: { + Cookie: `better-auth.session_token=${session2.token}`, + }, + }); + const list2 = await res2.json(); + expect(list2.some((r: any) => r.name === "Org 1 Repo")).toBe(false); + expect(list2.some((r: any) => r.name === "Org 2 Repo")).toBe(true); + }); + + test("should not be able to access volumes from another organization", async () => { + const session1 = await createTestSession(); + const session2 = await createTestSession(); + + const volumeId = Math.floor(Math.random() * 1000000); + await db.insert(volumesTable).values({ + id: volumeId, + shortId: generateShortId(), + name: "Org 1 Volume", + type: "directory", + config: { backend: "directory", path: "/tmp/vol1" }, + organizationId: session1.organizationId, + status: "unmounted", + }); + + const res = await app.request(`/api/v1/volumes/${volumeId}`, { + headers: { + Cookie: `better-auth.session_token=${session2.token}`, + }, + }); + + expect(res.status).toBe(404); + }); + + test("should not be able to create a backup schedule referencing resources from another organization", async () => { + const session1 = await createTestSession(); + const session2 = await createTestSession(); + + const vol1Id = Math.floor(Math.random() * 1000000); + await db.insert(volumesTable).values({ + id: vol1Id, + shortId: generateShortId(), + name: "Org 1 Volume", + type: "directory", + config: { backend: "directory", path: "/tmp/vol1" }, + organizationId: session1.organizationId, + status: "unmounted", + }); + + const repo1Id = crypto.randomUUID(); + await db.insert(repositoriesTable).values({ + id: repo1Id, + shortId: generateShortId(), + name: "Org 1 Repo", + type: "local", + config: { backend: "local", name: "org1repo", path: "/tmp/repo1" }, + organizationId: session1.organizationId, + }); + + const res = await app.request("/api/v1/backups", { + method: "POST", + headers: { + Cookie: `better-auth.session_token=${session2.token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: "Malicious Schedule", + volumeId: vol1Id, + repositoryId: repo1Id, + enabled: true, + cronExpression: "0 0 * * *", + }), + }); + + expect(res.status).toBe(404); + }); + + test("should not be able to access backup schedules from another organization", async () => { + const session1 = await createTestSession(); + const session2 = await createTestSession(); + + const vol1Id = Math.floor(Math.random() * 1000000); + await db.insert(volumesTable).values({ + id: vol1Id, + shortId: generateShortId(), + name: "Org 1 Volume", + type: "directory", + config: { backend: "directory", path: "/tmp/vol1" }, + organizationId: session1.organizationId, + status: "unmounted", + }); + const repo1Id = crypto.randomUUID(); + await db.insert(repositoriesTable).values({ + id: repo1Id, + shortId: generateShortId(), + name: "Org 1 Repo", + type: "local", + config: { backend: "local", name: "org1repo", path: "/tmp/repo1" }, + organizationId: session1.organizationId, + }); + + const [schedule] = await db + .insert(backupSchedulesTable) + .values({ + shortId: generateShortId(), + name: "Org 1 Schedule", + volumeId: vol1Id, + repositoryId: repo1Id, + cronExpression: "0 0 * * *", + organizationId: session1.organizationId, + }) + .returning(); + + const res = await app.request(`/api/v1/backups/${schedule.id}`, { + headers: { + Cookie: `better-auth.session_token=${session2.token}`, + }, + }); + + expect(res.status).toBe(404); + + const resOk = await app.request(`/api/v1/backups/${schedule.id}`, { + headers: { + Cookie: `better-auth.session_token=${session1.token}`, + }, + }); + expect(resOk.status).toBe(200); + }); +}); diff --git a/app/test/helpers/auth.ts b/app/test/helpers/auth.ts index 46479ffc..4f99a4c0 100644 --- a/app/test/helpers/auth.ts +++ b/app/test/helpers/auth.ts @@ -2,53 +2,36 @@ import { db } from "~/server/db/db"; import { sessionsTable, usersTable, account, organization, member } from "~/server/db/schema"; import { hashPassword } from "better-auth/crypto"; import { createHmac } from "node:crypto"; -import { eq } from "drizzle-orm"; export async function createTestSession() { - const [existingUser] = await db.select().from(usersTable); - - if (!existingUser) { - await db.insert(usersTable).values({ - username: "testuser", - email: "test@test.com", - name: "Test User", - id: crypto.randomUUID(), - }); - } - - const [user] = await db.select().from(usersTable); + const userId = crypto.randomUUID(); + const user = { + username: `testuser-${userId}`, + email: `${userId}@test.com`, + name: "Test User", + id: userId, + }; + await db.insert(usersTable).values(user); const token = crypto.randomUUID().replace(/-/g, ""); const sessionId = token; const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); - const [existingOrg] = await db.select().from(organization); + const orgId = crypto.randomUUID(); + await db.insert(organization).values({ + id: orgId, + name: `Org ${orgId}`, + slug: `test-org-${orgId}`, + createdAt: new Date(), + }); - let orgId: string; - - if (!existingOrg) { - orgId = crypto.randomUUID(); - await db.insert(organization).values({ - id: orgId, - name: "Test Org", - slug: "test-org", - createdAt: new Date(), - }); - } else { - orgId = existingOrg.id; - } - - const [existingMember] = await db.select().from(member).where(eq(member.userId, user.id)); - - if (!existingMember) { - await db.insert(member).values({ - id: crypto.randomUUID(), - userId: user.id, - organizationId: orgId, - role: "owner", - createdAt: new Date(), - }); - } + await db.insert(member).values({ + id: crypto.randomUUID(), + userId: user.id, + organizationId: orgId, + role: "owner", + createdAt: new Date(), + }); await db.insert(sessionsTable).values({ id: sessionId, @@ -67,7 +50,7 @@ export async function createTestSession() { .insert(account) .values({ userId: user.id, - accountId: "testuser", + accountId: user.username, password: await hashPassword("password123"), id: crypto.randomUUID(), providerId: "credentials",