diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index c0a88b6b..5be873b0 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -15,7 +15,12 @@ import { buildCsp } from "./lib/csp.js"; import { ensureAiDirs, recoverInterruptedInstalls } from "./lib/feature-status.js"; import { shutdownWorkerPool } from "./lib/worker-pool.js"; import { requirePermission } from "./permissions.js"; -import { authMiddleware, authRoutes, ensureDefaultAdmin } from "./plugins/auth.js"; +import { + authMiddleware, + authRoutes, + ensureAnonymousUser, + ensureDefaultAdmin, +} from "./plugins/auth.js"; import { oidcRoutes } from "./plugins/oidc.js"; import { registerStatic } from "./plugins/static.js"; import { registerUpload } from "./plugins/upload.js"; @@ -41,9 +46,10 @@ import { userFileRoutes } from "./routes/user-files.js"; runMigrations(); console.log("Database initialized"); -// Create default admin user if no users exist and auth is enabled if (env.AUTH_ENABLED) { await ensureDefaultAdmin(); +} else { + ensureAnonymousUser(); } function ensureInstanceId() { @@ -63,6 +69,7 @@ function ensureDefaultSettings() { const defaults: Record = { defaultTheme: env.DEFAULT_THEME, defaultLocale: env.DEFAULT_LOCALE, + defaultToolView: env.DEFAULT_TOOL_VIEW, }; for (const [key, value] of Object.entries(defaults)) { const existing = db.select().from(schema.settings).where(eq(schema.settings.key, key)).get(); diff --git a/apps/api/src/lib/env.ts b/apps/api/src/lib/env.ts index 7189cb9c..c79b9f4c 100644 --- a/apps/api/src/lib/env.ts +++ b/apps/api/src/lib/env.ts @@ -27,6 +27,7 @@ const envSchema = z WORKSPACE_PATH: z.string().default("./tmp/workspace"), DEFAULT_THEME: z.enum(["light", "dark", "system"]).default("light"), DEFAULT_LOCALE: z.string().default("en"), + DEFAULT_TOOL_VIEW: z.enum(["sidebar", "fullscreen"]).default("sidebar"), CORS_ORIGIN: z.string().default(""), MAX_USERS: z.coerce.number().default(0), LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace"]).default("info"), diff --git a/apps/api/src/plugins/auth.ts b/apps/api/src/plugins/auth.ts index 9f548cd8..073a2cb6 100644 --- a/apps/api/src/plugins/auth.ts +++ b/apps/api/src/plugins/auth.ts @@ -136,6 +136,22 @@ export function createSessionToken(): string { // ── Default admin creation ───────────────────────────────────────── +export function ensureAnonymousUser(): void { + const existing = db.select().from(schema.users).where(eq(schema.users.id, "anonymous")).get(); + if (existing) return; + + db.insert(schema.users) + .values({ + id: "anonymous", + username: "anonymous", + role: "admin", + mustChangePassword: false, + authProvider: "local", + }) + .onConflictDoNothing() + .run(); +} + export async function ensureDefaultAdmin(): Promise { const existingUsers = db.select().from(schema.users).all(); if (existingUsers.length > 0) return; @@ -297,9 +313,9 @@ export async function authRoutes(app: FastifyInstance): Promise { user: { id: "anonymous", username: "anonymous", - role: "user", + role: "admin", mustChangePassword: false, - permissions: getPermissions("user"), + permissions: getPermissions("admin"), analyticsEnabled: null, analyticsConsentShownAt: null, analyticsConsentRemindAt: null, @@ -847,13 +863,11 @@ function isPublicRoute(url: string): boolean { export async function authMiddleware(app: FastifyInstance): Promise { app.addHook("preHandler", async (request: FastifyRequest, reply: FastifyReply) => { - // When auth is disabled, attach a synthetic non-admin user so tools work - // but admin-only routes (user management, settings write, etc.) stay locked if (!env.AUTH_ENABLED) { (request as FastifyRequest & { user?: AuthUser }).user = { id: "anonymous", username: "anonymous", - role: "user", + role: "admin", }; return; } diff --git a/apps/docs/guide/configuration.md b/apps/docs/guide/configuration.md index c752426d..a0c6c41e 100644 --- a/apps/docs/guide/configuration.md +++ b/apps/docs/guide/configuration.md @@ -63,6 +63,7 @@ All configuration is done through environment variables. Every variable has a se |---|---|---| | `DEFAULT_THEME` | `light` | Default theme for new sessions. `light` or `dark`. | | `DEFAULT_LOCALE` | `en` | Default interface language. | +| `DEFAULT_TOOL_VIEW` | `sidebar` | Default tool layout. `sidebar` or `fullscreen`. | ### Docker permissions diff --git a/apps/web/src/components/settings/settings-dialog.tsx b/apps/web/src/components/settings/settings-dialog.tsx index a6e58686..764acc35 100644 --- a/apps/web/src/components/settings/settings-dialog.tsx +++ b/apps/web/src/components/settings/settings-dialog.tsx @@ -63,6 +63,7 @@ interface NavItem { label: string; icon: React.ComponentType<{ className?: string }>; requiredPermission?: string; + authRequired?: boolean; } function useNavItems() { @@ -76,24 +77,27 @@ function useNavItems() { icon: Monitor, requiredPermission: "settings:write", }, - { id: "security", label: t.settings.nav.security, icon: Shield }, + { id: "security", label: t.settings.nav.security, icon: Shield, authRequired: true }, { id: "people", label: t.settings.nav.people, icon: Users, requiredPermission: "users:manage", + authRequired: true, }, { id: "teams", label: t.settings.nav.teams, icon: UsersRound, requiredPermission: "teams:manage", + authRequired: true, }, { id: "roles", label: t.settings.nav.roles, icon: Shield, requiredPermission: "users:manage", + authRequired: true, }, { id: "audit-log", @@ -118,12 +122,14 @@ function useNavItems() { export function SettingsDialog({ open, onClose }: SettingsDialogProps) { const [section, setSection] = useState
("general"); - const { hasPermission } = useAuth(); + const { hasPermission, authEnabled } = useAuth(); const { t } = useTranslation(); const NAV_ITEMS = useNavItems(); const visibleNavItems = NAV_ITEMS.filter( - (item) => !item.requiredPermission || hasPermission(item.requiredPermission), + (item) => + (!item.requiredPermission || hasPermission(item.requiredPermission)) && + (!item.authRequired || authEnabled), ); // Close on Escape diff --git a/apps/web/src/hooks/use-auth.ts b/apps/web/src/hooks/use-auth.ts index c9d01bbc..6e879871 100644 --- a/apps/web/src/hooks/use-auth.ts +++ b/apps/web/src/hooks/use-auth.ts @@ -18,12 +18,21 @@ interface AuthState { hasLocalPassword: boolean; } -const USER_PERMISSIONS = [ +const ANON_ADMIN_PERMISSIONS = [ "tools:use", "files:own", + "files:all", "apikeys:own", + "apikeys:all", "pipelines:own", + "pipelines:all", "settings:read", + "settings:write", + "users:manage", + "teams:manage", + "features:manage", + "system:health", + "audit:read", ]; export function useAuth() { @@ -58,8 +67,8 @@ export function useAuth() { authEnabled: false, isAuthenticated: true, mustChangePassword: false, - role: "user", - permissions: USER_PERMISSIONS, + role: "admin", + permissions: ANON_ADMIN_PERMISSIONS, analyticsEnabled: null, analyticsConsentShownAt: null, analyticsConsentRemindAt: null, diff --git a/docker/Dockerfile b/docker/Dockerfile index e3e50b49..f2f60317 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -255,6 +255,7 @@ ENV PORT=1349 \ U2NET_HOME=/data/ai/models/rembg \ DEFAULT_THEME=light \ DEFAULT_LOCALE=en \ + DEFAULT_TOOL_VIEW=sidebar \ FILE_MAX_AGE_HOURS=72 \ CLEANUP_INTERVAL_MINUTES=60 \ MAX_UPLOAD_SIZE_MB=0 \ diff --git a/tests/integration/anonymous-mode.test.ts b/tests/integration/anonymous-mode.test.ts new file mode 100644 index 00000000..f3de4981 --- /dev/null +++ b/tests/integration/anonymous-mode.test.ts @@ -0,0 +1,200 @@ +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { db, schema } from "../../apps/api/src/db/index.js"; +import { ensureAnonymousUser, hashPassword } from "../../apps/api/src/plugins/auth.js"; +import { buildTestApp, loginAsAdmin, type TestApp } from "./test-server.js"; + +let testApp: TestApp; +let app: TestApp["app"]; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + app = testApp.app; + adminToken = await loginAsAdmin(app); + ensureAnonymousUser(); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +describe("ensureAnonymousUser", () => { + it("creates anonymous row in the users table", () => { + const row = db.select().from(schema.users).where(eq(schema.users.id, "anonymous")).get(); + expect(row).toBeDefined(); + expect(row!.username).toBe("anonymous"); + expect(row!.role).toBe("admin"); + expect(row!.mustChangePassword).toBe(false); + }); + + it("is idempotent", () => { + ensureAnonymousUser(); + ensureAnonymousUser(); + const rows = db.select().from(schema.users).where(eq(schema.users.id, "anonymous")).all(); + expect(rows).toHaveLength(1); + }); +}); + +describe("FK constraint with anonymous userId", () => { + it("can insert an API key with userId 'anonymous'", async () => { + const keyHash = await hashPassword("si_test"); + expect(() => + db + .insert(schema.apiKeys) + .values({ + id: randomUUID(), + userId: "anonymous", + keyHash, + keyPrefix: "si_te", + name: "FK test key", + }) + .run(), + ).not.toThrow(); + }); + + it("can insert a pipeline with userId 'anonymous'", () => { + expect(() => + db + .insert(schema.pipelines) + .values({ + id: randomUUID(), + userId: "anonymous", + name: "FK test pipeline", + steps: JSON.stringify([{ toolId: "resize", settings: { width: 100 } }]), + }) + .run(), + ).not.toThrow(); + }); + + it("can insert a user file with userId 'anonymous'", () => { + expect(() => + db + .insert(schema.userFiles) + .values({ + id: randomUUID(), + userId: "anonymous", + originalName: "test.png", + storedName: "fk-test-stored.png", + mimeType: "image/png", + size: 1024, + }) + .run(), + ).not.toThrow(); + }); + + it("rejects FK violation for nonexistent userId", async () => { + const keyHash = await hashPassword("si_bad"); + expect(() => + db + .insert(schema.apiKeys) + .values({ + id: randomUUID(), + userId: "nonexistent-user-id", + keyHash, + keyPrefix: "si_ba", + name: "bad FK key", + }) + .run(), + ).toThrow(); + }); +}); + +describe("admin settings save (issue #135 regression guard)", () => { + it("admin can PUT /api/v1/settings", async () => { + const res = await app.inject({ + method: "PUT", + url: "/api/v1/settings", + headers: { authorization: `Bearer ${adminToken}` }, + payload: { defaultToolView: "fullscreen" }, + }); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body).ok).toBe(true); + }); + + it("admin can GET /api/v1/settings with saved value", async () => { + const res = await app.inject({ + method: "GET", + url: "/api/v1/settings", + headers: { authorization: `Bearer ${adminToken}` }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.settings.defaultToolView).toBe("fullscreen"); + }); + + it("user role cannot PUT /api/v1/settings", async () => { + await app.inject({ + method: "POST", + url: "/api/auth/register", + headers: { authorization: `Bearer ${adminToken}` }, + payload: { username: "anon-test-user", password: "Testpass1!", role: "user" }, + }); + const loginRes = await app.inject({ + method: "POST", + url: "/api/auth/login", + payload: { username: "anon-test-user", password: "Testpass1!" }, + }); + const userToken = JSON.parse(loginRes.body).token; + + const res = await app.inject({ + method: "PUT", + url: "/api/v1/settings", + headers: { authorization: `Bearer ${userToken}` }, + payload: { defaultToolView: "sidebar" }, + }); + expect(res.statusCode).toBe(403); + }); +}); + +describe("admin API key operations", () => { + it("admin can create and list API keys", async () => { + const createRes = await app.inject({ + method: "POST", + url: "/api/v1/api-keys", + headers: { authorization: `Bearer ${adminToken}` }, + payload: { name: "integration-test-key" }, + }); + expect(createRes.statusCode).toBe(201); + const body = JSON.parse(createRes.body); + expect(body.key).toBeDefined(); + expect(body.key.startsWith("si_")).toBe(true); + + const listRes = await app.inject({ + method: "GET", + url: "/api/v1/api-keys", + headers: { authorization: `Bearer ${adminToken}` }, + }); + expect(listRes.statusCode).toBe(200); + const listBody = JSON.parse(listRes.body); + expect(Array.isArray(listBody.apiKeys)).toBe(true); + }); +}); + +describe("admin pipeline operations", () => { + it("admin can save and list pipelines", async () => { + const saveRes = await app.inject({ + method: "POST", + url: "/api/v1/pipeline/save", + headers: { authorization: `Bearer ${adminToken}` }, + payload: { + name: "integration-test-pipeline", + steps: [{ toolId: "resize", settings: { width: 200, height: 200, fit: "cover" } }], + }, + }); + expect(saveRes.statusCode).toBe(201); + + const listRes = await app.inject({ + method: "GET", + url: "/api/v1/pipeline/list", + headers: { authorization: `Bearer ${adminToken}` }, + }); + expect(listRes.statusCode).toBe(200); + const body = JSON.parse(listRes.body); + expect(Array.isArray(body.pipelines)).toBe(true); + expect( + body.pipelines.some((p: { name: string }) => p.name === "integration-test-pipeline"), + ).toBe(true); + }); +}); diff --git a/tests/unit/api/anonymous-admin.test.ts b/tests/unit/api/anonymous-admin.test.ts new file mode 100644 index 00000000..0f30fec1 --- /dev/null +++ b/tests/unit/api/anonymous-admin.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("../../../apps/api/src/db/index.js", () => ({ + db: { + select: () => ({ + from: () => ({ + where: () => ({ get: () => null, all: () => [] }), + all: () => [], + }), + }), + insert: () => ({ + values: () => ({ onConflictDoNothing: () => ({ run: vi.fn() }), run: vi.fn() }), + }), + delete: () => ({ where: () => ({ run: vi.fn() }) }), + update: () => ({ set: () => ({ where: () => ({ run: vi.fn() }) }) }), + }, + schema: { + users: { id: {}, username: {}, role: {} }, + sessions: { id: {}, userId: {} }, + settings: { key: {} }, + apiKeys: { id: {}, userId: {}, keyPrefix: {} }, + teams: { id: {}, name: {} }, + roles: { name: {} }, + auditLog: {}, + }, +})); + +vi.mock("../../../apps/api/src/config.js", () => ({ + env: { + AUTH_ENABLED: false, + DEFAULT_USERNAME: "admin", + DEFAULT_PASSWORD: "Adminpass1", + SKIP_MUST_CHANGE_PASSWORD: false, + SESSION_DURATION_HOURS: 168, + RATE_LIMIT_PER_MIN: 10000, + LOGIN_ATTEMPT_LIMIT: 500, + MAX_USERS: 50, + }, +})); + +vi.mock("../../../apps/api/src/lib/audit.js", () => ({ + auditLog: vi.fn(), +})); + +import Fastify from "fastify"; +import { hasPermission } from "../../../apps/api/src/permissions.js"; +import { authMiddleware, authRoutes, getAuthUser } from "../../../apps/api/src/plugins/auth.js"; + +describe("anonymous user when AUTH_ENABLED=false", () => { + it("assigns admin role to anonymous user", async () => { + const app = Fastify({ logger: false }); + + await authMiddleware(app); + + let capturedUser: ReturnType = null; + app.get("/test", (request, reply) => { + capturedUser = getAuthUser(request); + reply.send({ ok: true }); + }); + + await app.ready(); + await app.inject({ method: "GET", url: "/test" }); + await app.close(); + + expect(capturedUser).not.toBeNull(); + expect(capturedUser!.role).toBe("admin"); + expect(capturedUser!.username).toBe("anonymous"); + }); + + it("anonymous admin has settings:write permission", () => { + expect(hasPermission("admin", "settings:write")).toBe(true); + }); + + it("anonymous admin has all admin permissions", () => { + const adminPerms = [ + "tools:use", + "files:own", + "files:all", + "apikeys:own", + "apikeys:all", + "pipelines:own", + "pipelines:all", + "settings:read", + "settings:write", + "users:manage", + "teams:manage", + "features:manage", + "system:health", + "audit:read", + ] as const; + + for (const perm of adminPerms) { + expect(hasPermission("admin", perm)).toBe(true); + } + }); + + it("anonymous admin can pass requirePermission('settings:write')", async () => { + const { requirePermission } = await import("../../../apps/api/src/permissions.js"); + + const user = { id: "anonymous", username: "anonymous", role: "admin" as const }; + const req = { user } as never; + const reply = { status: vi.fn().mockReturnThis(), send: vi.fn() } as never; + + const result = requirePermission("settings:write")(req, reply); + expect(result).toEqual(user); + expect((reply as { status: ReturnType }).status).not.toHaveBeenCalled(); + }); +}); + +describe("session endpoint when AUTH_ENABLED=false", () => { + it("GET /api/auth/session returns admin role", async () => { + const app = Fastify({ logger: false }); + + await authMiddleware(app); + await authRoutes(app); + await app.ready(); + + const res = await app.inject({ method: "GET", url: "/api/auth/session" }); + await app.close(); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.user.role).toBe("admin"); + expect(body.user.username).toBe("anonymous"); + expect(body.user.permissions).toContain("settings:write"); + expect(body.user.permissions).toContain("users:manage"); + }); + + it("GET /api/auth/session returns null expiresAt", async () => { + const app = Fastify({ logger: false }); + + await authMiddleware(app); + await authRoutes(app); + await app.ready(); + + const res = await app.inject({ method: "GET", url: "/api/auth/session" }); + await app.close(); + + const body = JSON.parse(res.body); + expect(body.expiresAt).toBeNull(); + }); +}); diff --git a/tests/unit/api/utilities.test.ts b/tests/unit/api/utilities.test.ts index 73ada8ea..5c110611 100644 --- a/tests/unit/api/utilities.test.ts +++ b/tests/unit/api/utilities.test.ts @@ -874,6 +874,7 @@ describe("loadEnv", () => { "WORKSPACE_PATH", "DEFAULT_THEME", "DEFAULT_LOCALE", + "DEFAULT_TOOL_VIEW", ]; for (const key of keysToClean) { delete process.env[key]; @@ -915,6 +916,7 @@ describe("loadEnv", () => { expect(typeof env.WORKSPACE_PATH).toBe("string"); expect(["light", "dark"]).toContain(env.DEFAULT_THEME); expect(typeof env.DEFAULT_LOCALE).toBe("string"); + expect(["sidebar", "fullscreen"]).toContain(env.DEFAULT_TOOL_VIEW); }); it("parses custom PORT as a number via coercion", async () => { @@ -953,6 +955,30 @@ describe("loadEnv", () => { expect(() => loadEnv()).toThrow(); }); + it("accepts DEFAULT_TOOL_VIEW=fullscreen", async () => { + process.env.DEFAULT_TOOL_VIEW = "fullscreen"; + const { loadEnv } = await import("../../../apps/api/src/lib/env.js"); + expect(loadEnv().DEFAULT_TOOL_VIEW).toBe("fullscreen"); + }); + + it("accepts DEFAULT_TOOL_VIEW=sidebar", async () => { + process.env.DEFAULT_TOOL_VIEW = "sidebar"; + const { loadEnv } = await import("../../../apps/api/src/lib/env.js"); + expect(loadEnv().DEFAULT_TOOL_VIEW).toBe("sidebar"); + }); + + it("defaults DEFAULT_TOOL_VIEW to sidebar when not set", async () => { + delete process.env.DEFAULT_TOOL_VIEW; + const { loadEnv } = await import("../../../apps/api/src/lib/env.js"); + expect(loadEnv().DEFAULT_TOOL_VIEW).toBe("sidebar"); + }); + + it("rejects DEFAULT_TOOL_VIEW with an invalid value", async () => { + process.env.DEFAULT_TOOL_VIEW = "grid"; + const { loadEnv } = await import("../../../apps/api/src/lib/env.js"); + expect(() => loadEnv()).toThrow(); + }); + it("coerces numeric strings for MAX_MEGAPIXELS", async () => { process.env.MAX_MEGAPIXELS = "50"; const { loadEnv } = await import("../../../apps/api/src/lib/env.js"); diff --git a/tests/unit/web/anonymous-auth.test.ts b/tests/unit/web/anonymous-auth.test.ts new file mode 100644 index 00000000..442b54f6 --- /dev/null +++ b/tests/unit/web/anonymous-auth.test.ts @@ -0,0 +1,155 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const fetchMock = vi.fn(); +vi.stubGlobal("fetch", fetchMock); + +vi.mock("@/stores/connection-store", () => ({ + useConnectionStore: { + subscribe: () => () => {}, + }, +})); + +vi.mock("@/lib/api", () => ({ + formatHeaders: () => new Headers(), +})); + +describe("useAuth anonymous happy path", () => { + beforeEach(() => { + fetchMock.mockReset(); + vi.resetModules(); + }); + + it("sets role to admin when authEnabled is false", async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ authEnabled: false }), + }); + + const { renderHook, act } = await import("@testing-library/react"); + const { useAuth } = await import("@/hooks/use-auth"); + + const { result } = renderHook(() => useAuth()); + + await act(async () => {}); + + expect(result.current.loading).toBe(false); + expect(result.current.authEnabled).toBe(false); + expect(result.current.isAuthenticated).toBe(true); + expect(result.current.role).toBe("admin"); + }); + + it("includes settings:write in anonymous permissions", async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ authEnabled: false }), + }); + + const { renderHook, act } = await import("@testing-library/react"); + const { useAuth } = await import("@/hooks/use-auth"); + + const { result } = renderHook(() => useAuth()); + + await act(async () => {}); + + expect(result.current.hasPermission("settings:write")).toBe(true); + expect(result.current.hasPermission("settings:read")).toBe(true); + }); + + it("includes all admin permissions in anonymous mode", async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ authEnabled: false }), + }); + + const { renderHook, act } = await import("@testing-library/react"); + const { useAuth } = await import("@/hooks/use-auth"); + + const { result } = renderHook(() => useAuth()); + + await act(async () => {}); + + const expectedPerms = [ + "tools:use", + "files:own", + "files:all", + "apikeys:own", + "apikeys:all", + "pipelines:own", + "pipelines:all", + "settings:read", + "settings:write", + "users:manage", + "teams:manage", + "features:manage", + "system:health", + "audit:read", + ]; + for (const perm of expectedPerms) { + expect(result.current.hasPermission(perm)).toBe(true); + } + }); + + it("does not call session endpoint when auth is disabled", async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ authEnabled: false }), + }); + + const { renderHook, act } = await import("@testing-library/react"); + const { useAuth } = await import("@/hooks/use-auth"); + + renderHook(() => useAuth()); + + await act(async () => {}); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0]).toBe("/api/v1/config/auth"); + }); + + it("does NOT grant admin when authEnabled is true and session fails", async () => { + fetchMock + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ authEnabled: true }), + }) + .mockResolvedValueOnce({ + ok: false, + json: async () => ({}), + }); + + const { renderHook, act } = await import("@testing-library/react"); + const { useAuth } = await import("@/hooks/use-auth"); + + const { result } = renderHook(() => useAuth()); + + await act(async () => {}); + + expect(result.current.isAuthenticated).toBe(false); + expect(result.current.role).toBeNull(); + expect(result.current.permissions).toEqual([]); + }); +}); + +describe("useAuth hasPermission", () => { + beforeEach(() => { + fetchMock.mockReset(); + vi.resetModules(); + }); + + it("returns false for permissions not in the list", async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ authEnabled: false }), + }); + + const { renderHook, act } = await import("@testing-library/react"); + const { useAuth } = await import("@/hooks/use-auth"); + + const { result } = renderHook(() => useAuth()); + + await act(async () => {}); + + expect(result.current.hasPermission("nonexistent:permission")).toBe(false); + }); +}); diff --git a/tests/unit/web/settings-nav-filtering.test.ts b/tests/unit/web/settings-nav-filtering.test.ts new file mode 100644 index 00000000..b3322d1e --- /dev/null +++ b/tests/unit/web/settings-nav-filtering.test.ts @@ -0,0 +1,138 @@ +// @vitest-environment jsdom +import { describe, expect, it, vi } from "vitest"; + +interface NavItem { + id: string; + label: string; + requiredPermission?: string; + authRequired?: boolean; +} + +const NAV_ITEMS: NavItem[] = [ + { id: "general", label: "General" }, + { id: "system", label: "System Settings", requiredPermission: "settings:write" }, + { id: "security", label: "Security", authRequired: true }, + { id: "people", label: "People", requiredPermission: "users:manage", authRequired: true }, + { id: "teams", label: "Teams", requiredPermission: "teams:manage", authRequired: true }, + { id: "roles", label: "Roles", requiredPermission: "users:manage", authRequired: true }, + { id: "audit-log", label: "Audit Log", requiredPermission: "audit:read" }, + { id: "api-keys", label: "API Keys" }, + { id: "ai-features", label: "AI Features", requiredPermission: "settings:write" }, + { id: "tools", label: "Tools" }, + { id: "analytics", label: "Product Analytics" }, + { id: "about", label: "About" }, +]; + +function filterNavItems( + items: NavItem[], + hasPermission: (p: string) => boolean, + authEnabled: boolean, +): NavItem[] { + return items.filter( + (item) => + (!item.requiredPermission || hasPermission(item.requiredPermission)) && + (!item.authRequired || authEnabled), + ); +} + +describe("settings nav filtering", () => { + const allPerms = (p: string) => true; + const userPerms = (p: string) => + ["tools:use", "files:own", "apikeys:own", "pipelines:own", "settings:read"].includes(p); + const adminPerms = (p: string) => + [ + "tools:use", + "files:own", + "files:all", + "apikeys:own", + "apikeys:all", + "pipelines:own", + "pipelines:all", + "settings:read", + "settings:write", + "users:manage", + "teams:manage", + "features:manage", + "system:health", + "audit:read", + ].includes(p); + + describe("auth disabled (anonymous admin)", () => { + it("hides security, people, teams, roles when auth is disabled", () => { + const visible = filterNavItems(NAV_ITEMS, adminPerms, false); + const ids = visible.map((i) => i.id); + expect(ids).not.toContain("security"); + expect(ids).not.toContain("people"); + expect(ids).not.toContain("teams"); + expect(ids).not.toContain("roles"); + }); + + it("shows general, system, api-keys, ai-features, tools, analytics, about", () => { + const visible = filterNavItems(NAV_ITEMS, adminPerms, false); + const ids = visible.map((i) => i.id); + expect(ids).toContain("general"); + expect(ids).toContain("system"); + expect(ids).toContain("api-keys"); + expect(ids).toContain("ai-features"); + expect(ids).toContain("tools"); + expect(ids).toContain("analytics"); + expect(ids).toContain("about"); + }); + + it("shows audit-log (not authRequired, only needs audit:read)", () => { + const visible = filterNavItems(NAV_ITEMS, adminPerms, false); + const ids = visible.map((i) => i.id); + expect(ids).toContain("audit-log"); + }); + + it("returns exactly 8 items for anonymous admin", () => { + const visible = filterNavItems(NAV_ITEMS, adminPerms, false); + expect(visible).toHaveLength(8); + }); + }); + + describe("auth enabled + admin", () => { + it("shows all 12 items for authenticated admin", () => { + const visible = filterNavItems(NAV_ITEMS, adminPerms, true); + expect(visible).toHaveLength(12); + }); + + it("includes auth-dependent sections", () => { + const visible = filterNavItems(NAV_ITEMS, adminPerms, true); + const ids = visible.map((i) => i.id); + expect(ids).toContain("security"); + expect(ids).toContain("people"); + expect(ids).toContain("teams"); + expect(ids).toContain("roles"); + }); + }); + + describe("auth enabled + user role", () => { + it("hides permission-gated sections for user role", () => { + const visible = filterNavItems(NAV_ITEMS, userPerms, true); + const ids = visible.map((i) => i.id); + expect(ids).not.toContain("system"); + expect(ids).not.toContain("people"); + expect(ids).not.toContain("teams"); + expect(ids).not.toContain("roles"); + expect(ids).not.toContain("audit-log"); + expect(ids).not.toContain("ai-features"); + }); + + it("shows general, security, api-keys, tools, analytics, about for user role", () => { + const visible = filterNavItems(NAV_ITEMS, userPerms, true); + const ids = visible.map((i) => i.id); + expect(ids).toContain("general"); + expect(ids).toContain("security"); + expect(ids).toContain("api-keys"); + expect(ids).toContain("tools"); + expect(ids).toContain("analytics"); + expect(ids).toContain("about"); + }); + + it("returns exactly 6 items for user role", () => { + const visible = filterNavItems(NAV_ITEMS, userPerms, true); + expect(visible).toHaveLength(6); + }); + }); +});