feat: first-start admin creation endpoint guarded by empty store
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user