From f86ef124c2d1a10ffac7b674b310af78d241c1fa Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 16 May 2026 11:37:28 +0800 Subject: [PATCH 1/4] fix: grant admin role to anonymous user and add DEFAULT_TOOL_VIEW env var When AUTH_ENABLED=false, the anonymous user was assigned the "user" role which lacks settings:write permission, making all settings saves return 403. Since no admin exists when auth is disabled, settings were permanently read-only. Promote the anonymous user to "admin" so the single user has full control of the instance. Also adds DEFAULT_TOOL_VIEW env var (sidebar|fullscreen) following the existing DEFAULT_THEME pattern, seeded via ensureDefaultSettings() on first boot. Closes #135 --- apps/api/src/index.ts | 1 + apps/api/src/lib/env.ts | 1 + apps/api/src/plugins/auth.ts | 4 +- apps/docs/guide/configuration.md | 1 + apps/web/src/hooks/use-auth.ts | 15 +++- docker/Dockerfile | 1 + tests/unit/api/anonymous-admin.test.ts | 108 +++++++++++++++++++++++++ tests/unit/api/utilities.test.ts | 26 ++++++ 8 files changed, 151 insertions(+), 6 deletions(-) create mode 100644 tests/unit/api/anonymous-admin.test.ts diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index c0a88b6b..9ff25d83 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -63,6 +63,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..e315e23b 100644 --- a/apps/api/src/plugins/auth.ts +++ b/apps/api/src/plugins/auth.ts @@ -847,13 +847,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/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/unit/api/anonymous-admin.test.ts b/tests/unit/api/anonymous-admin.test.ts new file mode 100644 index 00000000..d6faf1f7 --- /dev/null +++ b/tests/unit/api/anonymous-admin.test.ts @@ -0,0 +1,108 @@ +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, 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(); + }); +}); 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"); From 490686527587168af5048b940f314cb5f5830064 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 16 May 2026 12:17:56 +0800 Subject: [PATCH 2/4] fix: hide auth-dependent settings sections when auth is disabled Security, People, Teams, and Roles sections are meaningless without auth and could cause problems (e.g., creating orphan users that block admin bootstrap on re-enable). Hide them via an authRequired flag on nav items, checked alongside the existing requiredPermission filter. --- apps/web/src/components/settings/settings-dialog.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) 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 From 0d0fd760e5b84e7c895fb08892c7c20c3e7dc4ba Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 16 May 2026 12:25:28 +0800 Subject: [PATCH 3/4] fix: return admin role from session endpoint when auth is disabled The GET /api/auth/session endpoint still returned role "user" and user-level permissions when AUTH_ENABLED=false, inconsistent with the middleware which now assigns role "admin". Fix the session endpoint to match so API clients get the correct role. --- apps/api/src/plugins/auth.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/api/src/plugins/auth.ts b/apps/api/src/plugins/auth.ts index e315e23b..570bda68 100644 --- a/apps/api/src/plugins/auth.ts +++ b/apps/api/src/plugins/auth.ts @@ -297,9 +297,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, From 4c997f73e002ba84525f0e08cc2ccdd82f4f9142 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 16 May 2026 12:36:06 +0800 Subject: [PATCH 4/4] fix: seed anonymous user row in DB and add comprehensive test coverage When AUTH_ENABLED=false, seed an "anonymous" user row in the users table so API keys, pipelines, and user files don't fail with FK constraint violations. Previously, the synthetic anonymous user only existed in memory (attached by the middleware), but any DB operation referencing userId "anonymous" would violate foreign key constraints. Also adds 25 new tests covering: - Integration: ensureAnonymousUser, FK constraints, settings save, API key and pipeline operations for anonymous mode - Frontend: useAuth hook anonymous happy path (role, permissions, hasPermission, session endpoint bypass) - Frontend: settings dialog nav filtering (authRequired hides security/people/teams/roles when auth disabled) - Backend: session endpoint returns admin role when auth disabled --- apps/api/src/index.ts | 10 +- apps/api/src/plugins/auth.ts | 16 ++ tests/integration/anonymous-mode.test.ts | 200 ++++++++++++++++++ tests/unit/api/anonymous-admin.test.ts | 36 +++- tests/unit/web/anonymous-auth.test.ts | 155 ++++++++++++++ tests/unit/web/settings-nav-filtering.test.ts | 138 ++++++++++++ 6 files changed, 552 insertions(+), 3 deletions(-) create mode 100644 tests/integration/anonymous-mode.test.ts create mode 100644 tests/unit/web/anonymous-auth.test.ts create mode 100644 tests/unit/web/settings-nav-filtering.test.ts diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 9ff25d83..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() { diff --git a/apps/api/src/plugins/auth.ts b/apps/api/src/plugins/auth.ts index 570bda68..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; 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 index d6faf1f7..0f30fec1 100644 --- a/tests/unit/api/anonymous-admin.test.ts +++ b/tests/unit/api/anonymous-admin.test.ts @@ -44,7 +44,7 @@ vi.mock("../../../apps/api/src/lib/audit.js", () => ({ import Fastify from "fastify"; import { hasPermission } from "../../../apps/api/src/permissions.js"; -import { authMiddleware, getAuthUser } from "../../../apps/api/src/plugins/auth.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 () => { @@ -106,3 +106,37 @@ describe("anonymous user when AUTH_ENABLED=false", () => { 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/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); + }); + }); +});