feat(security): require first-install Admin setup and authenticated login with Argon2id + HttpOnly SHA-256 sessions
This commit is contained in:
+215
-42
@@ -10,11 +10,30 @@ import { toUser, type UserRow } from "./mapping.js";
|
||||
const SESSION_COOKIE = "ps_session";
|
||||
const SESSION_TTL_MS = 30 * 24 * 3600 * 1000; // 30 days
|
||||
|
||||
// ---- password hashing ----
|
||||
export const hashPassword = (pw: string): Promise<string> => argon2.hash(pw, { type: argon2.argon2id });
|
||||
export const verifyPassword = (hash: string, pw: string): Promise<boolean> => argon2.verify(hash, pw);
|
||||
// ---- password hashing (OWASP Argon2id parameters) ----
|
||||
const ARGON2_OPTS: argon2.Options = {
|
||||
type: argon2.argon2id,
|
||||
memoryCost: 65536, // 64 MiB
|
||||
timeCost: 3,
|
||||
parallelism: 4,
|
||||
};
|
||||
|
||||
// ---- session tokens (store only the SHA-256 hash) ----
|
||||
export const hashPassword = (pw: string): Promise<string> =>
|
||||
argon2.hash(pw, ARGON2_OPTS);
|
||||
|
||||
export const verifyPassword = (hash: string, pw: string): Promise<boolean> =>
|
||||
argon2.verify(hash, pw);
|
||||
|
||||
// Precomputed dummy hash to prevent timing-based user enumeration on login
|
||||
let dummyHashPromise: Promise<string> | null = null;
|
||||
function getDummyHash(): Promise<string> {
|
||||
if (!dummyHashPromise) {
|
||||
dummyHashPromise = hashPassword("pubscript-dummy-anti-enumeration-secret");
|
||||
}
|
||||
return dummyHashPromise;
|
||||
}
|
||||
|
||||
// ---- session tokens (256-bit CSPRNG; store only the SHA-256 hash in SQLite) ----
|
||||
export function newToken(): string {
|
||||
return crypto.randomBytes(32).toString("hex");
|
||||
}
|
||||
@@ -25,11 +44,16 @@ function tokenHash(token: string): string {
|
||||
export function createSession(db: Db, userId: string): string {
|
||||
const token = newToken();
|
||||
const now = Date.now();
|
||||
db.prepare("INSERT INTO sessions (token_hash, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)").run(
|
||||
const nowIso = new Date(now).toISOString();
|
||||
// Prune expired sessions opportunistically
|
||||
db.prepare("DELETE FROM sessions WHERE expires_at <= ?").run(nowIso);
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (token_hash, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)",
|
||||
).run(
|
||||
tokenHash(token),
|
||||
userId,
|
||||
new Date(now + SESSION_TTL_MS).toISOString(),
|
||||
new Date(now).toISOString(),
|
||||
nowIso,
|
||||
);
|
||||
return token;
|
||||
}
|
||||
@@ -51,7 +75,7 @@ export function userFromToken(db: Db, token: string | null | undefined): UserRow
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
// ---- tiny in-memory fixed-window rate limiter ----
|
||||
// ---- fixed-window rate limiter ----
|
||||
export function makeRateLimiter(windowMs: number, max: number): (key: string) => boolean {
|
||||
const hits = new Map<string, { count: number; resetAt: number }>();
|
||||
return (key: string): boolean => {
|
||||
@@ -74,19 +98,48 @@ export function bearerToken(req: FastifyRequest): string | null {
|
||||
}
|
||||
|
||||
export function registerAuthHook(app: FastifyInstance): void {
|
||||
// Purge legacy auto-bootstrapped placeholder user if it is the only user in DB
|
||||
try {
|
||||
const users = app.db
|
||||
.prepare("SELECT id, email FROM users")
|
||||
.all() as Array<{ id: string; email: string }>;
|
||||
if (users.length === 1 && users[0]?.email === "admin@pubscript.local" && !process.env.VITEST) {
|
||||
const legacyId = users[0].id;
|
||||
app.db.prepare("DELETE FROM sessions WHERE user_id = ?").run(legacyId);
|
||||
app.db.prepare("DELETE FROM users WHERE id = ?").run(legacyId);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
app.addHook("onRequest", async (req) => {
|
||||
const db = app.db;
|
||||
const token = bearerToken(req) ?? (req.cookies[SESSION_COOKIE] as string | undefined) ?? null;
|
||||
const token =
|
||||
bearerToken(req) ?? (req.cookies[SESSION_COOKIE] as string | undefined) ?? null;
|
||||
const row = userFromToken(db, token);
|
||||
req.user = row ? toUser(row) : null;
|
||||
});
|
||||
}
|
||||
|
||||
export function setSessionCookie(reply: FastifyReply, token: string): void {
|
||||
function isSecureRequest(req: FastifyRequest): boolean {
|
||||
if (req.protocol === "https") return true;
|
||||
const proto = req.headers["x-forwarded-proto"];
|
||||
if (typeof proto === "string" && proto.split(",")[0]?.trim().toLowerCase() === "https") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function setSessionCookie(
|
||||
reply: FastifyReply,
|
||||
token: string,
|
||||
req?: FastifyRequest,
|
||||
): void {
|
||||
reply.setCookie(SESSION_COOKIE, token, {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: req ? isSecureRequest(req) : false,
|
||||
maxAge: SESSION_TTL_MS / 1000,
|
||||
});
|
||||
}
|
||||
@@ -100,9 +153,16 @@ export function requireAuth(req: FastifyRequest): void {
|
||||
}
|
||||
export function requireAdmin(req: FastifyRequest): void {
|
||||
requireAuth(req);
|
||||
if (req.user!.role !== "admin") throw req.server.httpErrors.create(403, { error: "forbidden" });
|
||||
if (req.user!.role !== "admin") {
|
||||
throw req.server.httpErrors.create(403, { error: "forbidden" });
|
||||
}
|
||||
}
|
||||
export function canAccessProject(db: Db, user: User, projectId: string, needWrite = false): boolean {
|
||||
export function canAccessProject(
|
||||
db: Db,
|
||||
user: User,
|
||||
projectId: string,
|
||||
needWrite = false,
|
||||
): boolean {
|
||||
const proj = db.prepare("SELECT owner_id FROM projects WHERE id = ?").get(projectId) as
|
||||
| { owner_id: string }
|
||||
| undefined;
|
||||
@@ -116,24 +176,85 @@ export function canAccessProject(db: Db, user: User, projectId: string, needWrit
|
||||
}
|
||||
|
||||
// ---- auth routes ----
|
||||
const registerSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
displayName: z.string().min(1).max(80),
|
||||
const setupSchema = z.object({
|
||||
email: z.string().trim().toLowerCase().email(),
|
||||
password: z.string().min(8).max(256),
|
||||
displayName: z.string().trim().min(1).max(80),
|
||||
});
|
||||
const loginSchema = z.object({
|
||||
email: z.string().trim().toLowerCase().email(),
|
||||
password: z.string().min(1).max(256),
|
||||
});
|
||||
const changePasswordSchema = z.object({
|
||||
currentPassword: z.string().min(1),
|
||||
newPassword: z.string().min(8).max(256),
|
||||
});
|
||||
const loginSchema = z.object({ email: z.string().email(), password: z.string() });
|
||||
|
||||
export function registerAuthRoutes(app: FastifyInstance): void {
|
||||
const authLimiter = makeRateLimiter(60_000, 10); // 10 auth attempts / min / IP
|
||||
|
||||
app.post("/api/auth/register", async (req, reply) => {
|
||||
if (!authLimiter(req.ip)) throw app.httpErrors.create(429, { error: "rate_limited" });
|
||||
const body = registerSchema.parse(req.body);
|
||||
// Check whether the instance has been initialized with an admin user and whether the caller is authenticated
|
||||
app.get("/api/auth/status", async (req) => {
|
||||
const count = (
|
||||
app.db.prepare("SELECT COUNT(*) AS n FROM users").get() as { n: number }
|
||||
).n;
|
||||
return {
|
||||
initialized: count > 0,
|
||||
authenticated: Boolean(req.user),
|
||||
user: req.user ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
// First-install administrator setup (only permitted when 0 users exist in DB)
|
||||
app.post("/api/auth/setup", async (req, reply) => {
|
||||
if (!authLimiter(req.ip)) {
|
||||
throw app.httpErrors.create(429, { error: "rate_limited" });
|
||||
}
|
||||
const db = app.db;
|
||||
const userCount = (
|
||||
db.prepare("SELECT COUNT(*) AS n FROM users").get() as { n: number }
|
||||
).n;
|
||||
if (userCount > 0) {
|
||||
throw app.httpErrors.create(403, {
|
||||
error: "already_initialized",
|
||||
message: "Administrator account has already been created. Please sign in.",
|
||||
});
|
||||
}
|
||||
|
||||
const body = setupSchema.parse(req.body);
|
||||
const id = ulid();
|
||||
const now = new Date().toISOString();
|
||||
const passHash = await hashPassword(body.password);
|
||||
|
||||
db.prepare(
|
||||
"INSERT INTO users (id, email, pass_hash, display_name, role, disabled, created_at) VALUES (?, ?, ?, ?, 'admin', 0, ?)",
|
||||
).run(id, body.email, passHash, body.displayName, now);
|
||||
db.prepare(
|
||||
"INSERT INTO audit (id, at, user_id, action, detail) VALUES (?, ?, ?, 'auth.setup', ?)",
|
||||
).run(ulid(), now, id, JSON.stringify({ email: body.email, ip: req.ip }));
|
||||
|
||||
const row = db.prepare("SELECT * FROM users WHERE id = ?").get(id) as UserRow;
|
||||
const token = createSession(db, id);
|
||||
setSessionCookie(reply, token, req);
|
||||
return { token, user: toUser(row) };
|
||||
});
|
||||
|
||||
// Register: creates first admin if DB has 0 users; otherwise requires an authenticated Admin
|
||||
app.post("/api/auth/register", async (req, reply) => {
|
||||
if (!authLimiter(req.ip)) {
|
||||
throw app.httpErrors.create(429, { error: "rate_limited" });
|
||||
}
|
||||
const db = app.db;
|
||||
const isFirst =
|
||||
(db.prepare("SELECT COUNT(*) AS n FROM users").get() as { n: number }).n === 0;
|
||||
if (!isFirst) {
|
||||
requireAdmin(req);
|
||||
}
|
||||
|
||||
const body = setupSchema.parse(req.body);
|
||||
const existing = db.prepare("SELECT id FROM users WHERE email = ?").get(body.email);
|
||||
if (existing) throw app.httpErrors.create(409, { error: "email_taken" });
|
||||
|
||||
const isFirst = (db.prepare("SELECT COUNT(*) AS n FROM users").get() as { n: number }).n === 0;
|
||||
const role = isFirst ? "admin" : "author";
|
||||
const id = ulid();
|
||||
const now = new Date().toISOString();
|
||||
@@ -141,49 +262,107 @@ export function registerAuthRoutes(app: FastifyInstance): void {
|
||||
db.prepare(
|
||||
"INSERT INTO users (id, email, pass_hash, display_name, role, disabled, created_at) VALUES (?, ?, ?, ?, ?, 0, ?)",
|
||||
).run(id, body.email, passHash, body.displayName, role, now);
|
||||
db.prepare("INSERT INTO audit (id, at, user_id, action, detail) VALUES (?, ?, ?, 'auth.register', ?)").run(
|
||||
ulid(),
|
||||
now,
|
||||
id,
|
||||
JSON.stringify({ email: body.email, first: isFirst }),
|
||||
);
|
||||
db.prepare(
|
||||
"INSERT INTO audit (id, at, user_id, action, detail) VALUES (?, ?, ?, 'auth.register', ?)",
|
||||
).run(ulid(), now, id, JSON.stringify({ email: body.email, first: isFirst }));
|
||||
|
||||
const row = db.prepare("SELECT * FROM users WHERE id = ?").get(id) as UserRow;
|
||||
const token = createSession(db, id);
|
||||
setSessionCookie(reply, token);
|
||||
setSessionCookie(reply, token, req);
|
||||
return { token, user: toUser(row) };
|
||||
});
|
||||
|
||||
app.post("/api/auth/login", async (req, reply) => {
|
||||
if (!authLimiter(req.ip)) throw app.httpErrors.create(429, { error: "rate_limited" });
|
||||
const body = loginSchema.parse(req.body);
|
||||
const rateKey = `${req.ip}:${body.email}`;
|
||||
if (!authLimiter(rateKey)) {
|
||||
throw app.httpErrors.create(429, { error: "rate_limited" });
|
||||
}
|
||||
const db = app.db;
|
||||
const row = db.prepare("SELECT * FROM users WHERE email = ?").get(body.email) as UserRow | undefined;
|
||||
if (!row || row.disabled || !(await verifyPassword(row.pass_hash, body.password))) {
|
||||
const row = db
|
||||
.prepare("SELECT * FROM users WHERE LOWER(email) = ?")
|
||||
.get(body.email) as UserRow | undefined;
|
||||
|
||||
if (!row) {
|
||||
await verifyPassword(await getDummyHash(), body.password);
|
||||
throw app.httpErrors.create(401, { error: "bad_credentials" });
|
||||
}
|
||||
|
||||
const valid = await verifyPassword(row.pass_hash, body.password);
|
||||
if (!valid || row.disabled) {
|
||||
db.prepare(
|
||||
"INSERT INTO audit (id, at, user_id, action, detail) VALUES (?, ?, ?, 'auth.login_failed', ?)",
|
||||
).run(
|
||||
ulid(),
|
||||
new Date().toISOString(),
|
||||
row.id,
|
||||
JSON.stringify({ email: body.email, ip: req.ip }),
|
||||
);
|
||||
throw app.httpErrors.create(401, { error: "bad_credentials" });
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
"INSERT INTO audit (id, at, user_id, action, detail) VALUES (?, ?, ?, 'auth.login', ?)",
|
||||
).run(
|
||||
ulid(),
|
||||
new Date().toISOString(),
|
||||
row.id,
|
||||
JSON.stringify({ email: body.email, ip: req.ip }),
|
||||
);
|
||||
|
||||
const token = createSession(db, row.id);
|
||||
setSessionCookie(reply, token);
|
||||
setSessionCookie(reply, token, req);
|
||||
return { token, user: toUser(row) };
|
||||
});
|
||||
|
||||
app.post("/api/auth/logout", async (req, reply) => {
|
||||
const token = bearerToken(req) ?? (req.cookies[SESSION_COOKIE] as string | undefined) ?? null;
|
||||
const token =
|
||||
bearerToken(req) ?? (req.cookies[SESSION_COOKIE] as string | undefined) ?? null;
|
||||
if (token) destroySession(app.db, token);
|
||||
clearSessionCookie(reply);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post("/api/auth/change-password", async (req, reply) => {
|
||||
requireAuth(req);
|
||||
const body = changePasswordSchema.parse(req.body);
|
||||
const db = app.db;
|
||||
const row = db
|
||||
.prepare("SELECT * FROM users WHERE id = ?")
|
||||
.get(req.user!.id) as UserRow | undefined;
|
||||
if (!row || !(await verifyPassword(row.pass_hash, body.currentPassword))) {
|
||||
throw app.httpErrors.create(401, { error: "bad_credentials" });
|
||||
}
|
||||
const nextHash = await hashPassword(body.newPassword);
|
||||
db.prepare("UPDATE users SET pass_hash = ? WHERE id = ?").run(nextHash, row.id);
|
||||
// Revoke all existing sessions for this user and issue a fresh one
|
||||
db.prepare("DELETE FROM sessions WHERE user_id = ?").run(row.id);
|
||||
const token = createSession(db, row.id);
|
||||
setSessionCookie(reply, token, req);
|
||||
db.prepare(
|
||||
"INSERT INTO audit (id, at, user_id, action, detail) VALUES (?, ?, ?, 'auth.password_change', ?)",
|
||||
).run(ulid(), new Date().toISOString(), row.id, JSON.stringify({ ip: req.ip }));
|
||||
return { ok: true, token };
|
||||
});
|
||||
|
||||
app.get("/api/auth/me", async (req) => {
|
||||
requireAuth(req);
|
||||
return { user: req.user };
|
||||
});
|
||||
|
||||
// Bootstrap or sign into the local admin account when no admin session is active yet.
|
||||
app.post("/api/auth/bootstrap-admin", async (_req, reply) => {
|
||||
// Test-only bootstrap helper (disabled in production once initialized)
|
||||
app.post("/api/auth/bootstrap-admin", async (req, reply) => {
|
||||
if (!process.env.VITEST && process.env.NODE_ENV !== "test") {
|
||||
throw app.httpErrors.create(403, {
|
||||
error: "bootstrap_disabled",
|
||||
message: "Automatic bootstrap is disabled. Use /api/auth/setup or /api/auth/login.",
|
||||
});
|
||||
}
|
||||
const db = app.db;
|
||||
let adminRow = db
|
||||
.prepare("SELECT * FROM users WHERE role = 'admin' AND disabled = 0 ORDER BY created_at ASC LIMIT 1")
|
||||
.prepare(
|
||||
"SELECT * FROM users WHERE role = 'admin' AND disabled = 0 ORDER BY created_at ASC LIMIT 1",
|
||||
)
|
||||
.get() as UserRow | undefined;
|
||||
|
||||
if (!adminRow) {
|
||||
@@ -195,17 +374,11 @@ export function registerAuthRoutes(app: FastifyInstance): void {
|
||||
db.prepare(
|
||||
"INSERT INTO users (id, email, pass_hash, display_name, role, disabled, created_at) VALUES (?, ?, ?, ?, 'admin', 0, ?)",
|
||||
).run(id, email, passHash, "Local Admin", now);
|
||||
db.prepare("INSERT INTO audit (id, at, user_id, action, detail) VALUES (?, ?, ?, 'admin.bootstrap', ?)").run(
|
||||
ulid(),
|
||||
now,
|
||||
id,
|
||||
JSON.stringify({ email }),
|
||||
);
|
||||
adminRow = db.prepare("SELECT * FROM users WHERE id = ?").get(id) as UserRow;
|
||||
}
|
||||
|
||||
const token = createSession(db, adminRow.id);
|
||||
setSessionCookie(reply, token);
|
||||
setSessionCookie(reply, token, req);
|
||||
return { token, user: toUser(adminRow) };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -33,7 +33,12 @@ export interface ServerConfig {
|
||||
}
|
||||
|
||||
export const config: ServerConfig = {
|
||||
dbPath: env("PUBSCRIPT_DB", "./data/pubscript.sqlite"),
|
||||
dbPath: env(
|
||||
"PUBSCRIPT_DB",
|
||||
process.env.VITEST || process.env.NODE_ENV === "test"
|
||||
? ":memory:"
|
||||
: "./data/pubscript.sqlite",
|
||||
),
|
||||
port: envInt("PORT", 8080),
|
||||
origin: env("PUBSCRIPT_ORIGIN", "http://localhost:5173"),
|
||||
adminEmail: env("PUBSCRIPT_ADMIN_EMAIL", "admin@pubscript.local"),
|
||||
|
||||
+17
-2
@@ -20,7 +20,13 @@ import websocket from "@fastify/websocket";
|
||||
import { ZodError } from "zod";
|
||||
import { config } from "./config.js";
|
||||
import { openDb, ensureAdmin } from "./db.js";
|
||||
import { hashPassword, registerAuthHook, registerAuthRoutes } from "./auth.js";
|
||||
import {
|
||||
hashPassword,
|
||||
registerAuthHook,
|
||||
registerAuthRoutes,
|
||||
requireAdmin,
|
||||
requireAuth,
|
||||
} from "./auth.js";
|
||||
import { registerRoutes } from "./routes.js";
|
||||
import { registerWs } from "./ws.js";
|
||||
import { registerAdminRoutes } from "./admin.js";
|
||||
@@ -201,6 +207,11 @@ export async function buildApp() {
|
||||
}
|
||||
|
||||
app.addHook("onRequest", async (req, reply) => {
|
||||
reply.header("X-Content-Type-Options", "nosniff");
|
||||
reply.header("X-Frame-Options", "SAMEORIGIN");
|
||||
reply.header("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
reply.header("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
|
||||
|
||||
const cfg = loadPersistedServerSysConfig();
|
||||
const forwardedHost = (req.headers["x-forwarded-host"] as string | undefined) || req.headers.host;
|
||||
if (!isRequestHostAllowed(forwardedHost, cfg.allowedHosts)) {
|
||||
@@ -322,7 +333,8 @@ export async function buildApp() {
|
||||
}
|
||||
}
|
||||
|
||||
app.get("/api/system/server-config", async () => {
|
||||
app.get("/api/system/server-config", async (req) => {
|
||||
requireAuth(req);
|
||||
const persisted = loadPersistedServerSysConfig();
|
||||
return {
|
||||
ok: true,
|
||||
@@ -342,6 +354,7 @@ export async function buildApp() {
|
||||
});
|
||||
|
||||
app.post("/api/system/server-config", async (req, reply) => {
|
||||
requireAdmin(req);
|
||||
const body = (req.body ?? {}) as Partial<PersistedServerSysConfig>;
|
||||
const current = loadPersistedServerSysConfig();
|
||||
const nextPort =
|
||||
@@ -397,6 +410,7 @@ export async function buildApp() {
|
||||
});
|
||||
|
||||
app.get("/api/system/list-dirs", async (req) => {
|
||||
requireAuth(req);
|
||||
const query = (req.query ?? {}) as { path?: string };
|
||||
const rawTarget = query.path ? expandTilde(query.path) : homedir();
|
||||
const target = existsSync(rawTarget) ? rawTarget : homedir();
|
||||
@@ -429,6 +443,7 @@ export async function buildApp() {
|
||||
});
|
||||
|
||||
app.post("/api/system/save-export", async (req, reply) => {
|
||||
requireAuth(req);
|
||||
const body = (req.body ?? {}) as {
|
||||
directory?: string;
|
||||
fileName?: string;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
process.env.PUBSCRIPT_DB = ":memory:";
|
||||
import { buildApp } from "./index.js";
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -96,4 +97,79 @@ describe("admin endpoints", () => {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("enforces first-install setup lock, login authentication, and protected system routes", async () => {
|
||||
const app = await buildApp();
|
||||
try {
|
||||
// Unauthenticated system config request must be rejected with 401
|
||||
const unauthSys = await app.inject({ method: "GET", url: "/api/system/server-config" });
|
||||
expect(unauthSys.statusCode).toBe(401);
|
||||
|
||||
// Clear any users so we test fresh first-install state
|
||||
app.db.prepare("DELETE FROM sessions").run();
|
||||
app.db.prepare("DELETE FROM project_shares").run();
|
||||
app.db.prepare("DELETE FROM projects").run();
|
||||
app.db.prepare("DELETE FROM users").run();
|
||||
|
||||
const statusBefore = await app.inject({ method: "GET", url: "/api/auth/status" });
|
||||
expect(statusBefore.statusCode).toBe(200);
|
||||
expect(statusBefore.json()).toEqual({
|
||||
initialized: false,
|
||||
authenticated: false,
|
||||
user: null,
|
||||
});
|
||||
|
||||
const setupRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/setup",
|
||||
payload: {
|
||||
email: "owner@example.com",
|
||||
displayName: "Studio Owner",
|
||||
password: "StrongPassword!2026",
|
||||
},
|
||||
});
|
||||
expect(setupRes.statusCode).toBe(200);
|
||||
const { token, user } = setupRes.json() as { token: string; user: { role: string } };
|
||||
expect(token).toBeTruthy();
|
||||
expect(user.role).toBe("admin");
|
||||
|
||||
// Second setup attempt must be forbidden once initialized
|
||||
const secondSetup = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/setup",
|
||||
payload: {
|
||||
email: "attacker@example.com",
|
||||
displayName: "Attacker",
|
||||
password: "StrongPassword!2026",
|
||||
},
|
||||
});
|
||||
expect(secondSetup.statusCode).toBe(403);
|
||||
|
||||
// Login with wrong password must fail with 401
|
||||
const badLogin = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/login",
|
||||
payload: { email: "owner@example.com", password: "wrongpassword" },
|
||||
});
|
||||
expect(badLogin.statusCode).toBe(401);
|
||||
|
||||
// Login with valid password succeeds
|
||||
const goodLogin = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/login",
|
||||
payload: { email: "owner@example.com", password: "StrongPassword!2026" },
|
||||
});
|
||||
expect(goodLogin.statusCode).toBe(200);
|
||||
|
||||
// System config works with valid auth token
|
||||
const authSys = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/system/server-config",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(authSys.statusCode).toBe(200);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user