diff --git a/src/app/api/auth/setup/route.test.ts b/src/app/api/auth/setup/route.test.ts new file mode 100644 index 0000000..b6761db --- /dev/null +++ b/src/app/api/auth/setup/route.test.ts @@ -0,0 +1,83 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("server-only", () => ({})); + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "evosci-setup-")); +const ORIGINAL_ENV = { + EVOSCIENTIST_DATA_DIR: process.env.EVOSCIENTIST_DATA_DIR, + WEBUI_AUTH_USERNAME: process.env.WEBUI_AUTH_USERNAME, + WEBUI_AUTH_PASSWORD: process.env.WEBUI_AUTH_PASSWORD, +}; + +process.env.EVOSCIENTIST_DATA_DIR = dataDir; +delete process.env.WEBUI_AUTH_USERNAME; +delete process.env.WEBUI_AUTH_PASSWORD; + +const { POST } = await import("./route"); +const { closeUserStoreForTests, verifyUserPassword } = await import( + "@/lib/server/userStore" +); + +afterAll(() => { + closeUserStoreForTests(); + for (const [key, value] of Object.entries(ORIGINAL_ENV)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + fs.rmSync(dataDir, { recursive: true, force: true }); +}); + +function setupRequest(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/auth/setup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("POST /api/auth/setup", () => { + it("rejects an invalid username with a field-scoped 400", async () => { + const res = await POST( + setupRequest({ username: "bad name!", password: "long-enough-1" }) + ); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.field).toBe("username"); + expect(typeof body.error).toBe("string"); + }); + + it("rejects a short password with a field-scoped 400", async () => { + const res = await POST( + setupRequest({ username: "admin", password: "short" }) + ); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.field).toBe("password"); + }); + + it("rejects malformed bodies with 400", async () => { + const res = await POST(setupRequest({ username: 42 })); + expect(res.status).toBe(400); + }); + + it("creates the first admin", async () => { + const res = await POST( + setupRequest({ username: "admin", password: "long-enough-1" }) + ); + expect(res.status).toBe(201); + expect(await res.json()).toEqual({ username: "admin", role: "admin" }); + expect(verifyUserPassword("admin", "long-enough-1")?.role).toBe("admin"); + }); + + it("returns 409 once any user exists", async () => { + const res = await POST( + setupRequest({ username: "second", password: "long-enough-2" }) + ); + expect(res.status).toBe(409); + expect(typeof (await res.json()).error).toBe("string"); + }); +}); diff --git a/src/app/api/auth/setup/route.ts b/src/app/api/auth/setup/route.ts new file mode 100644 index 0000000..457b1db --- /dev/null +++ b/src/app/api/auth/setup/route.ts @@ -0,0 +1,53 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { + countUsers, + createUser, + ensureBootstrapAdmin, +} from "@/lib/server/userStore"; +import { isValidPassword, isValidUsername } from "@/lib/userManagement"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const NO_STORE = { "Cache-Control": "no-store" }; + +export async function POST(request: NextRequest) { + const body = (await request.json().catch(() => null)) as { + username?: unknown; + password?: unknown; + } | null; + const username = typeof body?.username === "string" ? body.username : ""; + const password = typeof body?.password === "string" ? body.password : ""; + + ensureBootstrapAdmin(); + if (countUsers() > 0) { + return NextResponse.json( + { error: "Setup has already been completed." }, + { status: 409, headers: NO_STORE } + ); + } + if (!isValidUsername(username)) { + return NextResponse.json( + { + error: + "Usernames start with a letter or digit and may contain letters, digits, dots, underscores and hyphens (max 64).", + field: "username", + }, + { status: 400, headers: NO_STORE } + ); + } + if (!isValidPassword(password)) { + return NextResponse.json( + { + error: "Password must be between 8 and 256 characters.", + field: "password", + }, + { status: 400, headers: NO_STORE } + ); + } + createUser(username, password, "admin"); + return NextResponse.json( + { username, role: "admin" }, + { status: 201, headers: NO_STORE } + ); +}