feat: first-start admin creation endpoint guarded by empty store

This commit is contained in:
m4
2026-08-12 22:43:31 +08:00
parent 78c98afb52
commit 2bbecf4bed
2 changed files with 136 additions and 0 deletions
+83
View File
@@ -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");
});
});
+53
View File
@@ -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 }
);
}