From 1db50e41f90987ca36b4f875eb4d747df78a05d1 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Mon, 2 Mar 2026 21:16:48 +0100 Subject: [PATCH] test: increase coverage for existing controllers --- .../auth.controller.security.test.ts | 197 ++++++++++++++++++ .../__tests__/backups.controller.test.ts | 1 + .../__tests__/repositories.controller.test.ts | 19 ++ .../__tests__/system.controller.test.ts | 62 +++++- app/test/helpers/auth.ts | 59 +++++- 5 files changed, 336 insertions(+), 2 deletions(-) create mode 100644 app/server/modules/auth/__tests__/auth.controller.security.test.ts diff --git a/app/server/modules/auth/__tests__/auth.controller.security.test.ts b/app/server/modules/auth/__tests__/auth.controller.security.test.ts new file mode 100644 index 00000000..37dc941b --- /dev/null +++ b/app/server/modules/auth/__tests__/auth.controller.security.test.ts @@ -0,0 +1,197 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import { createApp } from "~/server/app"; +import { + createTestSession, + createTestSessionWithGlobalAdmin, + createTestSessionWithOrgAdmin, + createTestSessionWithRegularMember, + getAuthHeaders, +} from "~/test/helpers/auth"; +import { account, invitation, member, organization, ssoProvider, usersTable } from "~/server/db/schema"; +import { db } from "~/server/db/db"; + +const app = createApp(); + +describe("auth controller security", () => { + beforeEach(async () => { + await db.delete(member); + await db.delete(account); + await db.delete(invitation); + await db.delete(ssoProvider); + await db.delete(organization); + await db.delete(usersTable); + }); + + describe("public endpoints - no auth required", () => { + test("GET /api/v1/auth/status should be accessible without authentication", async () => { + const res = await app.request("/api/v1/auth/status"); + expect(res.status).toBe(200); + }); + + test("GET /api/v1/auth/sso-providers should be accessible without authentication", async () => { + const res = await app.request("/api/v1/auth/sso-providers"); + expect(res.status).toBe(200); + }); + + test("GET /api/v1/auth/login-error should be accessible without authentication", async () => { + const res = await app.request("/api/v1/auth/login-error?error=test"); + expect(res.status).toBe(302); + }); + }); + + describe("org admin endpoints - require requireAuth + requireOrgAdmin", () => { + const orgAdminEndpoints = [ + { method: "GET", path: "/api/v1/auth/sso-settings" }, + { method: "DELETE", path: "/api/v1/auth/sso-providers/test-provider" }, + { method: "PATCH", path: "/api/v1/auth/sso-providers/test-provider/auto-linking" }, + { method: "DELETE", path: "/api/v1/auth/sso-invitations/test-invitation" }, + { method: "GET", path: "/api/v1/auth/org-members" }, + { method: "PATCH", path: "/api/v1/auth/org-members/test-member/role" }, + { method: "DELETE", path: "/api/v1/auth/org-members/test-member" }, + ]; + + for (const { method, path } of orgAdminEndpoints) { + test(`${method} ${path} should return 401 when unauthenticated`, async () => { + const res = await app.request(path, { method }); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Invalid or expired session"); + }); + + test(`${method} ${path} should return 403 for regular members`, async () => { + const { headers } = await createTestSessionWithRegularMember(); + const res = await app.request(path, { + method, + headers, + body: method !== "GET" && method !== "DELETE" ? JSON.stringify({}) : undefined, + }); + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.message).toBe("Forbidden"); + }); + + test(`${method} ${path} should be accessible to org admins`, async () => { + const { headers } = await createTestSessionWithOrgAdmin(); + const res = await app.request(path, { + method, + headers, + body: method !== "GET" && method !== "DELETE" ? JSON.stringify({}) : undefined, + }); + // Should not be 401 or 403 - actual response depends on endpoint logic + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + } + + describe("PATCH /api/v1/auth/sso-providers/:providerId/auto-linking specific", () => { + test("should return 400 for invalid payload", async () => { + const { headers } = await createTestSessionWithOrgAdmin(); + const res = await app.request("/api/v1/auth/sso-providers/test-provider/auto-linking", { + method: "PATCH", + headers: { + ...headers, + "Content-Type": "application/json", + }, + body: JSON.stringify({}), + }); + expect(res.status).toBe(400); + }); + }); + + describe("PATCH /api/v1/auth/org-members/:memberId/role specific", () => { + test("should return 400 for invalid payload", async () => { + const { headers } = await createTestSessionWithOrgAdmin(); + const res = await app.request("/api/v1/auth/org-members/test-member/role", { + method: "PATCH", + headers: { + ...headers, + "Content-Type": "application/json", + }, + body: JSON.stringify({}), + }); + expect(res.status).toBe(400); + }); + }); + }); + + describe("global admin endpoints - require requireAuth + requireAdmin", () => { + const adminEndpoints = [ + { method: "GET", path: "/api/v1/auth/admin-users" }, + { method: "DELETE", path: "/api/v1/auth/admin-users/test-user/accounts/test-account" }, + { method: "GET", path: "/api/v1/auth/deletion-impact/test-user" }, + ]; + + for (const { method, path } of adminEndpoints) { + test(`${method} ${path} should return 401 when unauthenticated`, async () => { + const res = await app.request(path, { method }); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Invalid or expired session"); + }); + + test(`${method} ${path} should return 403 for regular users`, async () => { + const { headers } = await createTestSession(); + const res = await app.request(path, { method, headers }); + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.message).toBe("Forbidden"); + }); + + test(`${method} ${path} should return 403 for org admins`, async () => { + const { headers } = await createTestSessionWithOrgAdmin(); + const res = await app.request(path, { method, headers }); + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.message).toBe("Forbidden"); + }); + + test(`${method} ${path} should not return 401 for global admins`, async () => { + const { headers } = await createTestSessionWithGlobalAdmin(); + const res = await app.request(path, { method, headers }); + // Should not be 401 - actual response depends on endpoint logic + expect(res.status).not.toBe(401); + }); + } + }); + + describe("invalid session handling", () => { + test("should return 401 for invalid session cookie", async () => { + const res = await app.request("/api/v1/auth/sso-settings", { + headers: getAuthHeaders("invalid-session-token"), + }); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Invalid or expired session"); + }); + + test("should return 401 when session cookie is missing", async () => { + const res = await app.request("/api/v1/auth/admin-users"); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Invalid or expired session"); + }); + }); + + describe("information disclosure", () => { + test("should not disclose org members when unauthenticated", async () => { + const res = await app.request("/api/v1/auth/org-members"); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Invalid or expired session"); + }); + + test("should not disclose SSO settings when unauthenticated", async () => { + const res = await app.request("/api/v1/auth/sso-settings"); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Invalid or expired session"); + }); + + test("should not disclose admin users when unauthenticated", async () => { + const res = await app.request("/api/v1/auth/admin-users"); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Invalid or expired session"); + }); + }); +}); diff --git a/app/server/modules/backups/__tests__/backups.controller.test.ts b/app/server/modules/backups/__tests__/backups.controller.test.ts index 89172d58..17a3755c 100644 --- a/app/server/modules/backups/__tests__/backups.controller.test.ts +++ b/app/server/modules/backups/__tests__/backups.controller.test.ts @@ -51,6 +51,7 @@ describe("backups security", () => { { method: "PUT", path: "/api/v1/backups/1/mirrors" }, { method: "GET", path: "/api/v1/backups/1/mirrors/compatibility" }, { method: "POST", path: "/api/v1/backups/reorder" }, + { method: "GET", path: "/api/v1/backups/1/progress" }, ]; for (const { method, path } of endpoints) { diff --git a/app/server/modules/repositories/__tests__/repositories.controller.test.ts b/app/server/modules/repositories/__tests__/repositories.controller.test.ts index 95c7c78e..089f92ae 100644 --- a/app/server/modules/repositories/__tests__/repositories.controller.test.ts +++ b/app/server/modules/repositories/__tests__/repositories.controller.test.ts @@ -75,13 +75,17 @@ describe("repositories security", () => { { method: "GET", path: "/api/v1/repositories/test-repo/stats" }, { method: "DELETE", path: "/api/v1/repositories/test-repo" }, { method: "GET", path: "/api/v1/repositories/test-repo/snapshots" }, + { method: "POST", path: "/api/v1/repositories/test-repo/snapshots/refresh" }, { method: "GET", path: "/api/v1/repositories/test-repo/snapshots/test-snapshot" }, { method: "GET", path: "/api/v1/repositories/test-repo/snapshots/test-snapshot/files" }, { method: "GET", path: "/api/v1/repositories/test-repo/snapshots/test-snapshot/dump" }, { method: "POST", path: "/api/v1/repositories/test-repo/restore" }, { method: "POST", path: "/api/v1/repositories/test-repo/doctor" }, + { method: "DELETE", path: "/api/v1/repositories/test-repo/doctor" }, + { method: "POST", path: "/api/v1/repositories/test-repo/unlock" }, { method: "DELETE", path: "/api/v1/repositories/test-repo/snapshots/test-snapshot" }, { method: "DELETE", path: "/api/v1/repositories/test-repo/snapshots" }, + { method: "POST", path: "/api/v1/repositories/test-repo/snapshots/tag" }, { method: "PATCH", path: "/api/v1/repositories/test-repo" }, ]; @@ -95,6 +99,21 @@ describe("repositories security", () => { } }); + describe("dev panel endpoint - requires dev panel access", () => { + test("POST /api/v1/repositories/:shortId/exec should return 401 when unauthenticated", async () => { + const res = await app.request("/api/v1/repositories/test-repo/exec", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ command: "version" }), + }); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Invalid or expired session"); + }); + }); + describe("information disclosure", () => { test("should not disclose if a repository exists when unauthenticated", async () => { const res = await app.request("/api/v1/repositories/non-existent-repo"); diff --git a/app/server/modules/system/__tests__/system.controller.test.ts b/app/server/modules/system/__tests__/system.controller.test.ts index 9bdbeb17..a1ac2e26 100644 --- a/app/server/modules/system/__tests__/system.controller.test.ts +++ b/app/server/modules/system/__tests__/system.controller.test.ts @@ -1,6 +1,6 @@ import { test, describe, expect } from "bun:test"; import { createApp } from "~/server/app"; -import { createTestSession, getAuthHeaders } from "~/test/helpers/auth"; +import { createTestSession, createTestSessionWithGlobalAdmin, getAuthHeaders } from "~/test/helpers/auth"; const app = createApp(); @@ -34,7 +34,11 @@ describe("system security", () => { describe("unauthenticated access", () => { const endpoints: { method: string; path: string }[] = [ { method: "GET", path: "/api/v1/system/info" }, + { method: "GET", path: "/api/v1/system/updates" }, + { method: "GET", path: "/api/v1/system/registration-status" }, + { method: "PUT", path: "/api/v1/system/registration-status" }, { method: "POST", path: "/api/v1/system/restic-password" }, + { method: "GET", path: "/api/v1/system/dev-panel" }, ]; for (const { method, path } of endpoints) { @@ -47,6 +51,62 @@ describe("system security", () => { } }); + describe("registration-status endpoint", () => { + test("GET /api/v1/system/registration-status should be accessible with valid session", async () => { + const { headers } = await createTestSession(); + const res = await app.request("/api/v1/system/registration-status", { headers }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(typeof body.enabled).toBe("boolean"); + }); + + test("PUT /api/v1/system/registration-status should return 403 for non-admin users", async () => { + const { headers } = await createTestSession(); + const res = await app.request("/api/v1/system/registration-status", { + method: "PUT", + headers: { + ...headers, + "Content-Type": "application/json", + }, + body: JSON.stringify({ enabled: false }), + }); + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.message).toBe("Forbidden"); + }); + + test("PUT /api/v1/system/registration-status should be accessible to global admin", async () => { + const { headers } = await createTestSessionWithGlobalAdmin(); + const res = await app.request("/api/v1/system/registration-status", { + method: "PUT", + headers: { + ...headers, + "Content-Type": "application/json", + }, + body: JSON.stringify({ enabled: false }), + }); + expect(res.status).toBe(200); + }); + }); + + describe("dev-panel endpoint", () => { + test("GET /api/v1/system/dev-panel should be accessible with valid session", async () => { + const { headers } = await createTestSession(); + const res = await app.request("/api/v1/system/dev-panel", { headers }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(typeof body.enabled).toBe("boolean"); + }); + }); + + describe("updates endpoint", () => { + test("GET /api/v1/system/updates should be accessible with valid session", async () => { + const { headers } = await createTestSession(); + const res = await app.request("/api/v1/system/updates", { headers }); + expect(res.status).toBe(200); + }); + }); + describe("input validation", () => { test("should return 400 for invalid payload on restic-password", async () => { const { headers } = await createTestSession(); diff --git a/app/test/helpers/auth.ts b/app/test/helpers/auth.ts index 5e1b78a6..aa380da4 100644 --- a/app/test/helpers/auth.ts +++ b/app/test/helpers/auth.ts @@ -1,4 +1,7 @@ import { auth } from "~/server/lib/auth"; +import { db } from "~/server/db/db"; +import { member, organization, sessionsTable, usersTable } from "~/server/db/schema"; +import { eq } from "drizzle-orm"; export const COOKIE_PREFIX = "zerobyte"; @@ -12,6 +15,12 @@ export async function createTestSession() { const ctx = await auth.$context; const user = ctx.test.createUser(); await ctx.test.saveUser(user); + + const allUsers = await db.query.usersTable.findMany(); + if (allUsers.length === 1 && allUsers[0].role === "admin") { + await db.update(usersTable).set({ role: "user" }).where(eq(usersTable.id, user.id)); + } + const { headers, session } = await ctx.test.login({ userId: user.id }); const organizationId = (session as { activeOrganizationId?: string }).activeOrganizationId ?? ""; @@ -19,7 +28,55 @@ export async function createTestSession() { return { headers: Object.fromEntries(headers.entries()) as Record, session, - user, + user: { ...user, role: "user" }, organizationId, }; } + +export async function createTestSessionWithOrgAdmin() { + const { headers, user, organizationId } = await createTestSession(); + + await db.update(member).set({ role: "admin" }).where(eq(member.userId, user.id)); + + return { headers, user, organizationId }; +} + +export async function createTestSessionWithGlobalAdmin() { + const ctx = await auth.$context; + const user = ctx.test.createUser(); + await ctx.test.saveUser(user); + + await db.update(usersTable).set({ role: "admin" }).where(eq(usersTable.id, user.id)); + + const [org] = await db + .insert(organization) + .values({ id: crypto.randomUUID(), name: "Admin Org", slug: `admin-org-${Date.now()}`, createdAt: new Date() }) + .returning(); + + await db.insert(member).values({ + id: crypto.randomUUID(), + organizationId: org.id, + userId: user.id, + role: "owner", + createdAt: new Date(), + }); + + await db.update(sessionsTable).set({ activeOrganizationId: org.id }).where(eq(sessionsTable.userId, user.id)); + + const { headers, session } = await ctx.test.login({ userId: user.id }); + + return { + headers: Object.fromEntries(headers.entries()) as Record, + session, + user, + organizationId: org.id, + }; +} + +export async function createTestSessionWithRegularMember() { + const { headers, user, organizationId } = await createTestSession(); + + await db.update(member).set({ role: "member" }).where(eq(member.userId, user.id)); + + return { headers, user, organizationId }; +}