mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof vi.fn> }).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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user