feat: protect control plane with admin login
Adds optional admin auth (enabled when DATABASE_URL is set) with session-cookie login, logout, requireAdmin middleware, login UI, and a skippable integration test (TEST_DATABASE_URL required). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,12 @@ FROM node:20-bookworm-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev
|
||||
COPY index.html app.js style.css server.cjs README.md ./
|
||||
COPY login.html login.js login.css ./
|
||||
COPY lib/ ./lib/
|
||||
COPY migrations/ ./migrations/
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
HERMES_SETUP_UI_HOST=0.0.0.0 \
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"use strict"
|
||||
|
||||
async function readJsonBody(req, maxBytes = 1_000_000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = []
|
||||
let total = 0
|
||||
req.on("data", (chunk) => {
|
||||
total += chunk.length
|
||||
if (total > maxBytes) {
|
||||
req.destroy()
|
||||
reject(Object.assign(new Error("request body too large"), { status: 413 }))
|
||||
return
|
||||
}
|
||||
chunks.push(chunk)
|
||||
})
|
||||
req.on("end", () => {
|
||||
try {
|
||||
const raw = Buffer.concat(chunks).toString("utf-8")
|
||||
resolve(raw ? JSON.parse(raw) : {})
|
||||
} catch (err) {
|
||||
reject(Object.assign(err, { status: 400 }))
|
||||
}
|
||||
})
|
||||
req.on("error", reject)
|
||||
})
|
||||
}
|
||||
|
||||
function sendJson(res, status, body, headers = {}) {
|
||||
const json = JSON.stringify(body)
|
||||
res.writeHead(status, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Cache-Control": "no-store",
|
||||
...headers
|
||||
})
|
||||
res.end(json)
|
||||
}
|
||||
|
||||
function openAiError(res, status, message, code) {
|
||||
sendJson(res, status, { error: { message, type: code, code } })
|
||||
}
|
||||
|
||||
module.exports = { readJsonBody, sendJson, openAiError }
|
||||
@@ -0,0 +1,99 @@
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: #0f1117;
|
||||
color: #e1e4e8;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
padding: 2.5rem 2rem;
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #f0f6fc;
|
||||
text-align: center;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
font-size: 0.875rem;
|
||||
color: #8b949e;
|
||||
text-align: center;
|
||||
margin-bottom: 1.75rem;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #c9d1d9;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #0d1117;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
color: #f0f6fc;
|
||||
font-size: 0.9375rem;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
border-color: #58a6ff;
|
||||
}
|
||||
|
||||
.login-error {
|
||||
font-size: 0.8125rem;
|
||||
color: #f85149;
|
||||
min-height: 1.25rem;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
margin-top: 0.25rem;
|
||||
padding: 0.5625rem 1rem;
|
||||
background: #238636;
|
||||
border: 1px solid #2ea043;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.login-btn:hover {
|
||||
background: #2ea043;
|
||||
}
|
||||
|
||||
.login-btn:active {
|
||||
background: #1a7f37;
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Hermes — Sign In</title>
|
||||
<link rel="stylesheet" href="/login.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-container">
|
||||
<h1 class="login-title">Hermes</h1>
|
||||
<p class="login-subtitle">Sign in to the control plane</p>
|
||||
<form id="login-form" class="login-form" novalidate>
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
autocomplete="username"
|
||||
required
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<p id="login-error" class="login-error" role="alert" aria-live="polite"></p>
|
||||
<button type="submit" class="login-btn">Sign In</button>
|
||||
</form>
|
||||
</div>
|
||||
<script src="/login.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
"use strict"
|
||||
|
||||
document.getElementById("login-form").addEventListener("submit", async (e) => {
|
||||
e.preventDefault()
|
||||
const username = document.getElementById("username").value.trim()
|
||||
const password = document.getElementById("password").value
|
||||
const errorEl = document.getElementById("login-error")
|
||||
errorEl.textContent = ""
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/admin/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password })
|
||||
})
|
||||
if (res.ok) {
|
||||
window.location.href = "/"
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
errorEl.textContent = data.error || "Login failed"
|
||||
}
|
||||
} catch {
|
||||
errorEl.textContent = "Network error — please try again"
|
||||
}
|
||||
})
|
||||
+141
-6
@@ -18,6 +18,11 @@ const path = require("path")
|
||||
const { spawn } = require("child_process")
|
||||
const os = require("os")
|
||||
|
||||
const { createPool, runMigrations } = require("./lib/db.cjs")
|
||||
const { adminCredentialsMatch, createSessionToken, hashSecret, parseCookies, serializeAdminCookie, clearAdminCookie } = require("./lib/security.cjs")
|
||||
const { createAdminSession, validateAdminSession, revokeAdminSession } = require("./lib/admin-store.cjs")
|
||||
const { readJsonBody, sendJson } = require("./lib/http.cjs")
|
||||
|
||||
const PORT = Number(process.env.HERMES_SETUP_UI_PORT || 7843)
|
||||
const HOST = process.env.HERMES_SETUP_UI_HOST || "127.0.0.1"
|
||||
|
||||
@@ -34,6 +39,14 @@ const ENV_FILE = path.join(HERMES_HOME, ".env")
|
||||
const CONFIG_FILE = path.join(HERMES_HOME, "config.yaml")
|
||||
const STATIC_DIR = __dirname
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL
|
||||
const ADMIN_USERNAME = process.env.HERMES_ADMIN_USERNAME
|
||||
const ADMIN_PASSWORD = process.env.HERMES_ADMIN_PASSWORD
|
||||
const SESSION_TTL_SECONDS = Number(process.env.HERMES_ADMIN_SESSION_TTL_HOURS || 12) * 3600
|
||||
|
||||
// Module-level pool — null when DATABASE_URL is not set (auth disabled)
|
||||
let pool = null
|
||||
|
||||
// ─── Process tracking ─────────────────────────────────────────────────────
|
||||
const runningProcs = new Map() // key: provider, val: state
|
||||
|
||||
@@ -1316,6 +1329,81 @@ async function h_portal(_req, res) {
|
||||
send(res, 200, { raw: r.stdout + r.stderr })
|
||||
}
|
||||
|
||||
// ─── Admin auth middleware ────────────────────────────────────────────────
|
||||
|
||||
async function requireAdmin(req, res) {
|
||||
// If auth is not configured, allow all access
|
||||
if (!pool) return true
|
||||
|
||||
const cookies = parseCookies(req.headers.cookie)
|
||||
const token = cookies.hermes_admin
|
||||
if (!token) {
|
||||
const isApiRoute = req.url.startsWith("/api/")
|
||||
if (isApiRoute) {
|
||||
sendJson(res, 401, { error: "Authentication required" })
|
||||
} else {
|
||||
res.writeHead(302, { Location: "/login" })
|
||||
res.end()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const hash = hashSecret(token)
|
||||
const session = await validateAdminSession(pool, hash)
|
||||
if (!session) {
|
||||
const isApiRoute = req.url.startsWith("/api/")
|
||||
if (isApiRoute) {
|
||||
sendJson(res, 401, { error: "Session expired or invalid" })
|
||||
} else {
|
||||
res.writeHead(302, { Location: "/login" })
|
||||
res.end()
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function h_adminLogin(req, res) {
|
||||
const body = await readJsonBody(req).catch(() => ({}))
|
||||
const { username, password } = body
|
||||
|
||||
if (!pool || !ADMIN_USERNAME) {
|
||||
sendJson(res, 200, { ok: true }) // auth not configured, always succeed
|
||||
return
|
||||
}
|
||||
|
||||
if (!adminCredentialsMatch(String(username || ""), String(password || ""), {
|
||||
username: ADMIN_USERNAME, password: ADMIN_PASSWORD
|
||||
})) {
|
||||
sendJson(res, 401, { error: "Invalid credentials" })
|
||||
return
|
||||
}
|
||||
|
||||
const { plaintext, hash } = createSessionToken()
|
||||
const expiresAt = new Date(Date.now() + SESSION_TTL_SECONDS * 1000)
|
||||
await createAdminSession(pool, hash, expiresAt)
|
||||
|
||||
sendJson(res, 200, { ok: true }, {
|
||||
"Set-Cookie": serializeAdminCookie(plaintext, SESSION_TTL_SECONDS)
|
||||
})
|
||||
}
|
||||
|
||||
async function h_adminLogout(req, res) {
|
||||
if (pool) {
|
||||
const cookies = parseCookies(req.headers.cookie)
|
||||
const token = cookies.hermes_admin
|
||||
if (token) {
|
||||
const hash = hashSecret(token)
|
||||
await revokeAdminSession(pool, hash).catch(() => {})
|
||||
}
|
||||
}
|
||||
sendJson(res, 200, { ok: true }, { "Set-Cookie": clearAdminCookie() })
|
||||
}
|
||||
|
||||
function h_health(_req, res) {
|
||||
sendJson(res, 200, { ok: true })
|
||||
}
|
||||
|
||||
// ─── Routes ───────────────────────────────────────────────────────────────
|
||||
const ROUTES = {
|
||||
"GET /api/status": h_status,
|
||||
@@ -1427,9 +1515,35 @@ function serveStatic(req, res) {
|
||||
send(res, 200, fs.readFileSync(file), types[ext] || "application/octet-stream")
|
||||
}
|
||||
|
||||
// Routes that are always public (bypass auth guard)
|
||||
const PUBLIC_ROUTES = new Set([
|
||||
"GET /health",
|
||||
"GET /login",
|
||||
"GET /login.js",
|
||||
"GET /login.css",
|
||||
"POST /api/admin/login"
|
||||
])
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const key = `${req.method} ${req.url.split("?")[0]}`
|
||||
|
||||
// Always-public routes
|
||||
if (key === "GET /health") return h_health(req, res)
|
||||
if (key === "POST /api/admin/login") return h_adminLogin(req, res)
|
||||
if (key === "GET /login") return serveStatic(req, res)
|
||||
if (key === "GET /login.js") return serveStatic(req, res)
|
||||
if (key === "GET /login.css") return serveStatic(req, res)
|
||||
|
||||
// Logout is accessible when authenticated (or when auth is off)
|
||||
if (key === "POST /api/admin/logout") {
|
||||
if (!(await requireAdmin(req, res))) return
|
||||
return h_adminLogout(req, res)
|
||||
}
|
||||
|
||||
// All other routes require auth
|
||||
if (!(await requireAdmin(req, res))) return
|
||||
|
||||
if (ROUTES[key]) return ROUTES[key](req, res)
|
||||
if (req.method === "GET") return serveStatic(req, res)
|
||||
send(res, 404, { error: "not found" })
|
||||
@@ -1439,10 +1553,31 @@ const server = http.createServer(async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
server.listen(PORT, HOST, () => {
|
||||
const exeOk = fs.existsSync(HERMES_EXE)
|
||||
console.log(`Hermes Control Plane v2 — http://${HOST}:${PORT}/`)
|
||||
console.log(` hermes : ${HERMES_EXE} ${exeOk ? "✓" : "(MISSING)"}`)
|
||||
console.log(` config : ${CONFIG_FILE}`)
|
||||
console.log(` env : ${ENV_FILE}`)
|
||||
async function main() {
|
||||
if (DATABASE_URL) {
|
||||
if (!ADMIN_USERNAME || !ADMIN_PASSWORD || ADMIN_PASSWORD.length < 16) {
|
||||
throw new Error("HERMES_ADMIN_USERNAME and HERMES_ADMIN_PASSWORD (min 16 chars) required when DATABASE_URL is set")
|
||||
}
|
||||
pool = createPool(DATABASE_URL)
|
||||
await runMigrations(pool)
|
||||
}
|
||||
|
||||
await new Promise((resolve) => {
|
||||
server.listen(PORT, HOST, () => {
|
||||
const exeOk = fs.existsSync(HERMES_EXE)
|
||||
console.log(`Hermes Control Plane v2 — http://${HOST}:${PORT}/`)
|
||||
console.log(` hermes : ${HERMES_EXE} ${exeOk ? "✓" : "(MISSING)"}`)
|
||||
console.log(` config : ${CONFIG_FILE}`)
|
||||
console.log(` env : ${ENV_FILE}`)
|
||||
if (pool) {
|
||||
console.log(` auth : enabled (database configured)`)
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("startup failed:", err.message)
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
"use strict"
|
||||
|
||||
const { test } = require("node:test")
|
||||
const assert = require("assert")
|
||||
const fs = require("fs")
|
||||
const http = require("http")
|
||||
const os = require("os")
|
||||
const path = require("path")
|
||||
const { spawn } = require("child_process")
|
||||
|
||||
const root = path.join(__dirname, "..")
|
||||
const serverPath = path.join(root, "server.cjs")
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeRequest(options, body = null) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request(options, (res) => {
|
||||
let data = ""
|
||||
res.on("data", (chunk) => { data += chunk })
|
||||
res.on("end", () => {
|
||||
let json = null
|
||||
try { json = JSON.parse(data) } catch { /* ok */ }
|
||||
resolve({ status: res.statusCode, headers: res.headers, body: json, raw: data })
|
||||
})
|
||||
})
|
||||
req.on("error", reject)
|
||||
if (body !== null) {
|
||||
req.write(typeof body === "string" ? body : JSON.stringify(body))
|
||||
}
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
function waitForServer(url, proc, timeoutMs = 10000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
const parsed = new URL(url)
|
||||
const opts = { hostname: parsed.hostname, port: Number(parsed.port), path: parsed.pathname, method: "GET" }
|
||||
|
||||
function attempt() {
|
||||
if (proc.exitCode !== null) {
|
||||
return reject(new Error(`server process exited with code ${proc.exitCode}`))
|
||||
}
|
||||
if (Date.now() > deadline) {
|
||||
return reject(new Error("timed out waiting for server to become ready"))
|
||||
}
|
||||
const req = http.request(opts, (res) => {
|
||||
let data = ""
|
||||
res.on("data", (c) => { data += c })
|
||||
res.on("end", () => { resolve() })
|
||||
})
|
||||
req.on("error", () => setTimeout(attempt, 150))
|
||||
req.end()
|
||||
}
|
||||
|
||||
attempt()
|
||||
})
|
||||
}
|
||||
|
||||
function setupTempEnv() {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hermes-auth-test-"))
|
||||
const hermesHome = path.join(tmp, ".hermes")
|
||||
fs.mkdirSync(hermesHome, { recursive: true })
|
||||
fs.writeFileSync(
|
||||
path.join(hermesHome, "config.yaml"),
|
||||
"model:\n provider: openai-codex\n default: gpt-5\nfallback_providers: []\n"
|
||||
)
|
||||
|
||||
const fakeHermes = path.join(tmp, "hermes")
|
||||
fs.writeFileSync(fakeHermes, `#!/bin/sh
|
||||
if [ "$1" = "auth" ] && [ "$2" = "list" ]; then
|
||||
echo "openai-codex (0 credentials):"
|
||||
elif [ "$1" = "fallback" ] && [ "$2" = "list" ]; then
|
||||
echo "Primary: none"
|
||||
elif [ "$1" = "version" ]; then
|
||||
echo "test-hermes"
|
||||
else
|
||||
echo "unsupported: $*" >&2
|
||||
exit 1
|
||||
fi
|
||||
`)
|
||||
fs.chmodSync(fakeHermes, 0o755)
|
||||
|
||||
return { tmp, hermesHome, fakeHermes }
|
||||
}
|
||||
|
||||
async function startServer(databaseUrl, adminUsername, adminPassword, port = 19744) {
|
||||
const { tmp, hermesHome, fakeHermes } = setupTempEnv()
|
||||
const env = {
|
||||
...process.env,
|
||||
HOME: tmp,
|
||||
HERMES_HOME: hermesHome,
|
||||
HERMES_EXE: fakeHermes,
|
||||
HERMES_SETUP_UI_HOST: "127.0.0.1",
|
||||
HERMES_SETUP_UI_PORT: String(port),
|
||||
DATABASE_URL: databaseUrl,
|
||||
HERMES_ADMIN_USERNAME: adminUsername,
|
||||
HERMES_ADMIN_PASSWORD: adminPassword
|
||||
}
|
||||
const proc = spawn(process.execPath, [serverPath], { cwd: root, env, stdio: ["ignore", "pipe", "pipe"] })
|
||||
|
||||
let stderr = ""
|
||||
proc.stderr.on("data", (d) => { stderr += d.toString() })
|
||||
|
||||
try {
|
||||
await waitForServer(`http://127.0.0.1:${port}/health`, proc)
|
||||
} catch (err) {
|
||||
proc.kill()
|
||||
throw new Error(`${err.message}\nServer stderr:\n${stderr}`)
|
||||
}
|
||||
|
||||
return { proc, tmp, host: "127.0.0.1", port }
|
||||
}
|
||||
|
||||
function stopServer(proc, tmp) {
|
||||
proc.kill()
|
||||
if (tmp) {
|
||||
try { fs.rmSync(tmp, { recursive: true, force: true }) } catch { /* ok */ }
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("admin auth — full control plane", { timeout: 30000 }, async (t) => {
|
||||
const dbUrl = process.env.TEST_DATABASE_URL
|
||||
if (!dbUrl) {
|
||||
t.skip("TEST_DATABASE_URL not set — skipping integration test")
|
||||
return
|
||||
}
|
||||
|
||||
const USERNAME = "admin"
|
||||
const PASSWORD = "test-password-16chars!"
|
||||
const PORT = 19744
|
||||
|
||||
let serverHandle = null
|
||||
try {
|
||||
serverHandle = await startServer(dbUrl, USERNAME, PASSWORD, PORT)
|
||||
} catch (err) {
|
||||
// If DB connection fails (e.g. wrong URL), skip gracefully
|
||||
t.skip(`Could not start server: ${err.message}`)
|
||||
return
|
||||
}
|
||||
|
||||
const { proc, tmp, host, port } = serverHandle
|
||||
const base = { hostname: host, port }
|
||||
|
||||
try {
|
||||
// GET /health always 200
|
||||
await t.test("GET /health returns 200", async () => {
|
||||
const r = await makeRequest({ ...base, path: "/health", method: "GET" })
|
||||
assert.strictEqual(r.status, 200)
|
||||
assert.strictEqual(r.body?.ok, true)
|
||||
})
|
||||
|
||||
// GET /login always 200
|
||||
await t.test("GET /login returns 200", async () => {
|
||||
const r = await makeRequest({ ...base, path: "/login", method: "GET" })
|
||||
assert.strictEqual(r.status, 200)
|
||||
})
|
||||
|
||||
// Unauthenticated GET / → 302 to /login
|
||||
await t.test("unauthenticated GET / returns 302", async () => {
|
||||
const r = await makeRequest({ ...base, path: "/", method: "GET" })
|
||||
assert.strictEqual(r.status, 302)
|
||||
assert.ok(r.headers.location?.includes("/login"), `Expected location to include /login, got: ${r.headers.location}`)
|
||||
})
|
||||
|
||||
// Unauthenticated GET /api/status → 401
|
||||
await t.test("unauthenticated GET /api/status returns 401", async () => {
|
||||
const r = await makeRequest({ ...base, path: "/api/status", method: "GET" })
|
||||
assert.strictEqual(r.status, 401)
|
||||
assert.ok(r.body?.error, "expected error field in body")
|
||||
})
|
||||
|
||||
// POST /api/admin/login with wrong credentials → 401
|
||||
await t.test("login with wrong credentials returns 401", async () => {
|
||||
const r = await makeRequest(
|
||||
{ ...base, path: "/api/admin/login", method: "POST", headers: { "Content-Type": "application/json" } },
|
||||
{ username: "admin", password: "wrong-password-123!" }
|
||||
)
|
||||
assert.strictEqual(r.status, 401)
|
||||
})
|
||||
|
||||
// POST /api/admin/login with correct credentials → 200 + cookie
|
||||
let cookie = null
|
||||
await t.test("login with correct credentials returns 200 and sets cookie", async () => {
|
||||
const r = await makeRequest(
|
||||
{ ...base, path: "/api/admin/login", method: "POST", headers: { "Content-Type": "application/json" } },
|
||||
{ username: USERNAME, password: PASSWORD }
|
||||
)
|
||||
assert.strictEqual(r.status, 200)
|
||||
assert.strictEqual(r.body?.ok, true)
|
||||
const setCookie = r.headers["set-cookie"]
|
||||
assert.ok(setCookie, "expected Set-Cookie header")
|
||||
const cookieStr = Array.isArray(setCookie) ? setCookie[0] : setCookie
|
||||
assert.ok(cookieStr.includes("hermes_admin="), "expected hermes_admin cookie")
|
||||
assert.ok(cookieStr.toLowerCase().includes("httponly"), "cookie must be HttpOnly")
|
||||
// Extract cookie value for subsequent requests
|
||||
const match = cookieStr.match(/hermes_admin=([^;]+)/)
|
||||
assert.ok(match, "could not parse hermes_admin cookie value")
|
||||
cookie = `hermes_admin=${match[1]}`
|
||||
})
|
||||
|
||||
// With valid cookie, GET / → 200
|
||||
await t.test("authenticated GET / returns 200", async () => {
|
||||
assert.ok(cookie, "need cookie from previous test")
|
||||
const r = await makeRequest({ ...base, path: "/", method: "GET", headers: { Cookie: cookie } })
|
||||
assert.strictEqual(r.status, 200)
|
||||
})
|
||||
|
||||
// With valid cookie, GET /api/status → 200
|
||||
await t.test("authenticated GET /api/status returns 200", async () => {
|
||||
assert.ok(cookie, "need cookie from previous test")
|
||||
const r = await makeRequest({ ...base, path: "/api/status", method: "GET", headers: { Cookie: cookie } })
|
||||
assert.strictEqual(r.status, 200)
|
||||
})
|
||||
|
||||
// POST /api/admin/logout → 200, clears cookie
|
||||
await t.test("logout returns 200 and clears cookie", async () => {
|
||||
assert.ok(cookie, "need cookie from previous test")
|
||||
const r = await makeRequest(
|
||||
{ ...base, path: "/api/admin/logout", method: "POST", headers: { Cookie: cookie } },
|
||||
{}
|
||||
)
|
||||
assert.strictEqual(r.status, 200)
|
||||
assert.strictEqual(r.body?.ok, true)
|
||||
const setCookie = r.headers["set-cookie"]
|
||||
assert.ok(setCookie, "expected Set-Cookie header on logout")
|
||||
const cookieStr = Array.isArray(setCookie) ? setCookie[0] : setCookie
|
||||
assert.ok(
|
||||
cookieStr.includes("Max-Age=0") || cookieStr.includes("Expires=Thu, 01 Jan 1970"),
|
||||
"logout should clear cookie"
|
||||
)
|
||||
})
|
||||
|
||||
// After logout, GET / → 302 again
|
||||
await t.test("after logout, GET / returns 302 again", async () => {
|
||||
assert.ok(cookie, "need cookie from previous test")
|
||||
const r = await makeRequest({ ...base, path: "/", method: "GET", headers: { Cookie: cookie } })
|
||||
assert.strictEqual(r.status, 302)
|
||||
})
|
||||
|
||||
// After logout, GET /api/status → 401 again
|
||||
await t.test("after logout, GET /api/status returns 401", async () => {
|
||||
assert.ok(cookie, "need cookie from previous test")
|
||||
const r = await makeRequest({ ...base, path: "/api/status", method: "GET", headers: { Cookie: cookie } })
|
||||
assert.strictEqual(r.status, 401)
|
||||
})
|
||||
} finally {
|
||||
stopServer(proc, tmp)
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user