diff --git a/docs/pr-auth-session/auth-session-hardening.md b/docs/pr-auth-session/auth-session-hardening.md new file mode 100644 index 0000000..fd27446 --- /dev/null +++ b/docs/pr-auth-session/auth-session-hardening.md @@ -0,0 +1,81 @@ +# Auth Session Hardening + +## Detection + +The previous backend accepted any syntactically valid login request. A user could +submit any email address and any non-empty password to `POST /login` and still be +redirected to `/dashboard`. The dashboard route was also directly accessible by +requesting `GET /dashboard` without a session. That meant the form submission +contract existed, but the backend did not yet verify identity before allowing the +protected task board page to load. + +## External guidance used + +- OWASP Authentication Cheat Sheet: authentication should verify supplied + authenticators, store passwords securely, and compare password hashes using + safe comparison functions. +- OWASP Session Management Cheat Sheet: authenticated state should be carried by + a hard-to-predict session identifier, and cookies should use protections such + as `HttpOnly` and `SameSite`. + +References: + +- https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html +- https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html + +## Implementation summary + +- Added an in-memory auth store for the prototype signup/login flow. +- Normalised email addresses before account lookup. +- Hashed passwords with Node's built-in `crypto.scrypt` before storage. +- Compared password hashes using `crypto.timingSafeEqual`. +- Reserved email addresses during signup so parallel duplicate requests cannot + both succeed. +- Ran unknown-account login attempts through a dummy password hash, keeping the + failure path closer to the wrong-password path. +- Issued a signed `taskify_session` cookie after signup/login. The cookie stores + only non-sensitive user identity fields, so dashboard access does not depend + on a process-local Map lookup after the token has been signed. +- Set session cookies with `HttpOnly`, `SameSite=Lax`, `Path=/`, and `Max-Age`. +- Avoided a public production fallback secret. Development and test use a local + fallback, while other environments generate a private per-process secret if + `SESSION_SECRET` has not been configured. A stable `SESSION_SECRET` should be + configured for deployed environments. +- Protected `GET /dashboard`; unauthenticated or tampered-session requests now + redirect to `/signup`. +- Added `POST /logout` to clear the session cookie and revoke the current + session identifier for the running process. +- Added a small `/favicon.ico` 204 response to remove browser 404 noise during + runtime checks. + +## Validation + +Automated tests cover: + +- signup redirects and sets a protected session cookie; +- login succeeds only after a matching signup; +- unknown email and wrong password return `401`; +- duplicate signup returns `409`; +- invalid email and short passwords return `400`; +- dashboard blocks unauthenticated and tampered-session requests; +- malformed cookie encoding is treated as unauthenticated rather than causing a + 500 response; +- duplicate parallel signups cannot both succeed; +- dashboard still accepts a signed session if the process-local user store is + empty; +- logout clears the browser cookie and rejects reuse of the old copied cookie in + the current process; +- dashboard renders normally with a valid session; +- favicon requests no longer produce a browser 404. + +Runtime smoke checks were also run against a local server with curl and Chrome +DevTools MCP. The signup -> dashboard flow loaded successfully, unauthenticated +dashboard requests redirected to `/signup`, wrong-password login returned `401`, +logout cleared the session, and the browser network panel showed no local 404s. + +## Known scope limit + +The store is intentionally in-memory because this coursework app does not yet +have a configured production database. It is suitable for proving the backend +authentication contract and session protection, but a permanent database-backed +user model would be needed before treating accounts as durable production data. diff --git a/src/app.js b/src/app.js index 761d71b..377f411 100644 --- a/src/app.js +++ b/src/app.js @@ -3,6 +3,17 @@ const express = require("express"); const path = require("path"); require("dotenv").config(); require("../src/db/conn"); +const { + createUser, + getUserById, + verifyCredentials, +} = require("./services/auth-store"); +const { + clearSessionCookie, + createSessionCookie, + getSession, + revokeSession, +} = require("./middleware/session"); const views_path = path.join(__dirname, "../views"); const static_path = path.join(__dirname, "../static"); const app = express(); @@ -20,6 +31,26 @@ function collectMissingFields(body, fields) { return fields.filter((field) => !isNonEmpty(body[field])); } +function asyncRoute(handler) { + return (req, res, next) => { + Promise.resolve(handler(req, res, next)).catch(next); + }; +} + +function isStrongEnoughPassword(value) { + return typeof value === "string" && value.length >= 8; +} + +function getAuthenticatedUser(req) { + const session = getSession(req); + + if (!session) { + return null; + } + + return getUserById(session.userId) || session.user || null; +} + app.use("/static", express.static(static_path)); app.use(express.json()); app.use(urlencoded({ extended: false })); @@ -36,31 +67,53 @@ app.get("/signup", (req, res) => { res.status(200).render("signup.ejs"); }); +app.get("/favicon.ico", (req, res) => { + res.status(204).end(); +}); + app.get("/privacy", (req, res) => { res.status(200).render("privacy.ejs"); }); -app.post("/signup", (req, res) => { +app.post("/signup", asyncRoute(async (req, res) => { const missingFields = collectMissingFields(req.body, [ "SignUpUsername", "SignUpEmail", "SignUpPassword", ]); + const passwordIsStrongEnough = isStrongEnoughPassword(req.body.SignUpPassword); - if (missingFields.length > 0 || !isValidEmail(req.body.SignUpEmail)) { + if (missingFields.length > 0 || !isValidEmail(req.body.SignUpEmail) || !passwordIsStrongEnough) { return res.status(400).json({ success: false, errors: { missingFields, email: isValidEmail(req.body.SignUpEmail) ? undefined : "Enter a valid email address.", + password: passwordIsStrongEnough ? undefined : "Password must be at least 8 characters.", + }, + }); + } + + const result = await createUser({ + username: req.body.SignUpUsername, + email: req.body.SignUpEmail, + password: req.body.SignUpPassword, + }); + + if (!result.ok) { + return res.status(409).json({ + success: false, + errors: { + email: "An account already exists for this email address.", }, }); } + res.setHeader("Set-Cookie", createSessionCookie(result.user)); return res.redirect(303, "/dashboard"); -}); +})); -app.post("/login", (req, res) => { +app.post("/login", asyncRoute(async (req, res) => { const missingFields = collectMissingFields(req.body, [ "LoginEmail", "LoginPassword", @@ -76,14 +129,47 @@ app.post("/login", (req, res) => { }); } + const user = await verifyCredentials(req.body.LoginEmail, req.body.LoginPassword); + + if (!user) { + return res.status(401).json({ + success: false, + errors: { + credentials: "Email or password is incorrect.", + }, + }); + } + + res.setHeader("Set-Cookie", createSessionCookie(user)); return res.redirect(303, "/dashboard"); +})); + +app.post("/logout", (req, res) => { + revokeSession(req); + res.setHeader("Set-Cookie", clearSessionCookie()); + return res.redirect(303, "/signup"); }); // In Future this dashboard will be rendered after authentication of users app.get("/dashboard", (req, res) => { + const user = getAuthenticatedUser(req); + + if (!user) { + return res.redirect(303, "/signup"); + } + + res.locals.currentUser = user; res.status(200).render("dashboard/dashboard.ejs"); }); +app.use((err, req, res, next) => { + console.error(err); + return res.status(500).json({ + success: false, + error: "Internal server error.", + }); +}); + if (require.main === module) { diff --git a/src/middleware/session.js b/src/middleware/session.js new file mode 100644 index 0000000..e848666 --- /dev/null +++ b/src/middleware/session.js @@ -0,0 +1,150 @@ +const crypto = require("crypto"); + +const SESSION_COOKIE = "taskify_session"; +const SESSION_MAX_AGE_SECONDS = 60 * 60 * 8; +const SESSION_SECRET = resolveSessionSecret(); +const revokedSessionIds = new Set(); + +function resolveSessionSecret() { + if (process.env.SESSION_SECRET) { + return process.env.SESSION_SECRET; + } + + if (!process.env.NODE_ENV || process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test") { + return "taskify-development-session-secret"; + } + + console.warn("SESSION_SECRET is not set; using a generated per-process secret for this runtime."); + return crypto.randomBytes(32).toString("hex"); +} + +function parseCookies(cookieHeader) { + return String(cookieHeader || "") + .split(";") + .map((part) => part.trim()) + .filter(Boolean) + .reduce((cookies, part) => { + const separatorIndex = part.indexOf("="); + + if (separatorIndex === -1) { + return cookies; + } + + const name = part.slice(0, separatorIndex); + const value = part.slice(separatorIndex + 1); + + try { + cookies[name] = decodeURIComponent(value); + } catch (error) { + return cookies; + } + + return cookies; + }, {}); +} + +function sign(value) { + return crypto.createHmac("sha256", SESSION_SECRET).update(value).digest("base64url"); +} + +function safeEqual(left, right) { + const leftBuffer = Buffer.from(String(left)); + const rightBuffer = Buffer.from(String(right)); + + if (leftBuffer.length !== rightBuffer.length) { + return false; + } + + return crypto.timingSafeEqual(leftBuffer, rightBuffer); +} + +function createSessionToken(user) { + const now = Math.floor(Date.now() / 1000); + const sessionId = crypto.randomUUID(); + const payload = Buffer.from( + JSON.stringify({ + sid: sessionId, + userId: user.id, + user: { + id: user.id, + username: user.username, + email: user.email, + }, + iat: now, + exp: now + SESSION_MAX_AGE_SECONDS, + }) + ).toString("base64url"); + + return `${payload}.${sign(payload)}`; +} + +function verifySessionToken(token) { + const [payload, signature] = String(token || "").split("."); + + if (!payload || !signature || !safeEqual(sign(payload), signature)) { + return null; + } + + try { + const session = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")); + const now = Math.floor(Date.now() / 1000); + + if ( + !session.sid || + !session.userId || + typeof session.exp !== "number" || + session.exp <= now || + revokedSessionIds.has(session.sid) + ) { + return null; + } + + return session; + } catch (error) { + return null; + } +} + +function getSession(req) { + const cookies = parseCookies(req.headers.cookie); + return verifySessionToken(cookies[SESSION_COOKIE]); +} + +function revokeSession(req) { + const cookies = parseCookies(req.headers.cookie); + const session = verifySessionToken(cookies[SESSION_COOKIE]); + + if (session) { + revokedSessionIds.add(session.sid); + } +} + +function createSessionCookie(user) { + const attributes = [ + `${SESSION_COOKIE}=${encodeURIComponent(createSessionToken(user))}`, + "HttpOnly", + "SameSite=Lax", + "Path=/", + `Max-Age=${SESSION_MAX_AGE_SECONDS}`, + ]; + + if (process.env.NODE_ENV === "production") { + attributes.push("Secure"); + } + + return attributes.join("; "); +} + +function clearSessionCookie() { + return `${SESSION_COOKIE}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`; +} + +module.exports = { + SESSION_COOKIE, + clearSessionCookie, + createSessionCookie, + getSession, + parseCookies, + revokeSession, + verifySessionToken, +}; diff --git a/src/services/auth-store.js b/src/services/auth-store.js new file mode 100644 index 0000000..b09c639 --- /dev/null +++ b/src/services/auth-store.js @@ -0,0 +1,109 @@ +const crypto = require("crypto"); + +const PASSWORD_KEY_LENGTH = 64; +const DUMMY_PASSWORD_HASH = `taskify-dummy-salt:${crypto + .scryptSync("taskify-dummy-password", "taskify-dummy-salt", PASSWORD_KEY_LENGTH) + .toString("hex")}`; +const usersByEmail = new Map(); +const usersById = new Map(); +const pendingEmails = new Set(); + +function normalizeEmail(email) { + return String(email || "").trim().toLowerCase(); +} + +function hashPassword(password, salt = crypto.randomBytes(16).toString("hex")) { + return new Promise((resolve, reject) => { + crypto.scrypt(String(password), salt, PASSWORD_KEY_LENGTH, (error, derivedKey) => { + if (error) { + reject(error); + return; + } + + resolve(`${salt}:${derivedKey.toString("hex")}`); + }); + }); +} + +async function verifyPassword(password, storedHash) { + const [salt, key] = String(storedHash || "").split(":"); + + if (!salt || !key) { + return false; + } + + const candidateHash = await hashPassword(password, salt); + const candidateKey = Buffer.from(candidateHash.split(":")[1], "hex"); + const storedKey = Buffer.from(key, "hex"); + + if (candidateKey.length !== storedKey.length) { + return false; + } + + return crypto.timingSafeEqual(candidateKey, storedKey); +} + +function toPublicUser(user) { + if (!user) { + return null; + } + + return { + id: user.id, + username: user.username, + email: user.email, + }; +} + +async function createUser({ username, email, password }) { + const normalizedEmail = normalizeEmail(email); + + if (usersByEmail.has(normalizedEmail) || pendingEmails.has(normalizedEmail)) { + return { ok: false, reason: "duplicate-email" }; + } + + pendingEmails.add(normalizedEmail); + + try { + const user = { + id: crypto.randomUUID(), + username: String(username || "").trim(), + email: normalizedEmail, + passwordHash: await hashPassword(password), + createdAt: new Date().toISOString(), + }; + + usersByEmail.set(normalizedEmail, user); + usersById.set(user.id, user); + + return { ok: true, user: toPublicUser(user) }; + } finally { + pendingEmails.delete(normalizedEmail); + } +} + +async function verifyCredentials(email, password) { + const user = usersByEmail.get(normalizeEmail(email)); + const passwordHash = user ? user.passwordHash : DUMMY_PASSWORD_HASH; + const matches = await verifyPassword(password, passwordHash); + + return user && matches ? toPublicUser(user) : null; +} + +function getUserById(userId) { + return toPublicUser(usersById.get(userId)); +} + +function clearUsersForTests() { + usersByEmail.clear(); + usersById.clear(); + pendingEmails.clear(); +} + +module.exports = { + clearUsersForTests, + createUser, + getUserById, + normalizeEmail, + verifyCredentials, +}; diff --git a/static/styles/partials/dashboard/dashboard-sidebar.css b/static/styles/partials/dashboard/dashboard-sidebar.css index abd4b9a..da24074 100644 --- a/static/styles/partials/dashboard/dashboard-sidebar.css +++ b/static/styles/partials/dashboard/dashboard-sidebar.css @@ -80,6 +80,30 @@ width: 250px; } +.sidebar-action-form { + width: 100%; +} + +.sidebar-action-button { + padding-left: 24px; + display: flex; + width: 100%; + justify-content: start; + color: black; + background: transparent; + border: 0; + cursor: pointer; + font: inherit; +} + +.sidebar-action-button:hover { + background-color: gainsboro; + color: #0089ff; + border-radius: 5px; + transition: all .5s ease; + width: 250px; +} + .sidebar-items ul li a[aria-current="page"] { background-color: #eef7fb; color: #0c7ea6; @@ -107,6 +131,28 @@ line-height: 60px; } +.sidebar-action-button .sidebar-icon { + display: block; + min-width: 60px; + height: 60px; + line-height: 60px; + text-align: center; + font-size: 1em; +} + +.sidebar-action-button .sidebar-name { + display: block; + padding-left: 5px; + height: 60px; + line-height: 60px; +} + +.sidebar-action-button:focus-visible { + outline: 3px solid rgba(12, 126, 166, 0.35); + outline-offset: 3px; + border-radius: 8px; +} + .bottom-section { top: 154px; } diff --git a/static/styles/partials/dashboard/navbar-dashboard.css b/static/styles/partials/dashboard/navbar-dashboard.css index f6b8fc3..c89282e 100644 --- a/static/styles/partials/dashboard/navbar-dashboard.css +++ b/static/styles/partials/dashboard/navbar-dashboard.css @@ -88,8 +88,29 @@ display: block; } +.dashboard-navbar-dropdown-form { + margin: 0; +} + +.dashboard-navbar-dropdown-action { + width: 100%; + color: black; + padding: 12px 16px; + text-decoration: none; + display: block; + text-align: left; + background: transparent; + border: 0; + cursor: pointer; + font: inherit; +} + .dashboard-navbar-dropdown a:hover {background-color: #ddd;} +.dashboard-navbar-dropdown-action:hover { + background-color: #ddd; +} + .dashboard-navbar-show {display: block;} /* .dashboard-navbar-profile-icon { height: 40px; @@ -138,6 +159,7 @@ .dashboard-navbar-search-bar button:focus-visible, .dashboard-navbar-search-bar input:focus-visible, .dashboard-navbar-dashboard-navbar-links a:focus-visible, +.dashboard-navbar-dropdown-action:focus-visible, .dashboard-navbar-dropbtn:focus-visible, .lang-switch-button:focus-visible { outline: 3px solid rgba(12, 126, 166, 0.35); diff --git a/test/auth-form-routes.test.js b/test/auth-form-routes.test.js index daac94c..2c728b1 100644 --- a/test/auth-form-routes.test.js +++ b/test/auth-form-routes.test.js @@ -1,6 +1,10 @@ const assert = require("node:assert/strict"); -const test = require("node:test"); +const { beforeEach, test } = require("node:test"); const app = require("../src/app"); +const { clearUsersForTests } = require("../src/services/auth-store"); +const { SESSION_COOKIE } = require("../src/middleware/session"); + +let emailCounter = 0; function listen(appInstance) { return new Promise((resolve) => { @@ -8,6 +12,19 @@ function listen(appInstance) { }); } +function close(server) { + return new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + + resolve(); + }); + }); +} + async function request(server, path, options = {}) { const baseUrl = `http://127.0.0.1:${server.address().port}`; @@ -19,30 +36,83 @@ async function request(server, path, options = {}) { }); } -test("signup form submission redirects to the dashboard", async () => { +function nextEmail(prefix = "alex") { + emailCounter += 1; + return `${prefix}.${emailCounter}@example.com`; +} + +function getSessionCookie(response) { + const setCookie = response.headers.get("set-cookie"); + + assert.match(setCookie, new RegExp(`^${SESSION_COOKIE}=`)); + assert.match(setCookie, /HttpOnly/); + assert.match(setCookie, /SameSite=Lax/); + + return setCookie.split(";")[0]; +} + +async function signup(server, overrides = {}) { + const body = new URLSearchParams({ + SignUpUsername: overrides.username || "Alex", + SignUpEmail: overrides.email || nextEmail(), + SignUpPassword: overrides.password || "password123", + }); + + const response = await request(server, "/signup", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body, + }); + + assert.equal(response.status, 303); + assert.equal(response.headers.get("location"), "/dashboard"); + + return getSessionCookie(response); +} + +beforeEach(() => { + clearUsersForTests(); +}); + +test("signup form submission creates a session and redirects to the dashboard", async () => { const server = await listen(app); try { - const response = await request(server, "/signup", { + await signup(server, { email: nextEmail("signup") }); + } finally { + await close(server); + } +}); + +test("login form submission verifies stored credentials before redirecting", async () => { + const server = await listen(app); + const email = nextEmail("login"); + + try { + await signup(server, { email, password: "password123" }); + + const response = await request(server, "/login", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", }, body: new URLSearchParams({ - SignUpUsername: "Alex", - SignUpEmail: "alex@example.com", - SignUpPassword: "password123", + LoginEmail: email, + LoginPassword: "password123", }), }); assert.equal(response.status, 303); assert.equal(response.headers.get("location"), "/dashboard"); + getSessionCookie(response); } finally { - server.close(); + await close(server); } }); -test("login form submission redirects to the dashboard", async () => { +test("login form submission rejects unknown accounts", async () => { const server = await listen(app); try { @@ -52,15 +122,107 @@ test("login form submission redirects to the dashboard", async () => { "Content-Type": "application/x-www-form-urlencoded", }, body: new URLSearchParams({ - LoginEmail: "alex@example.com", + LoginEmail: nextEmail("unknown"), LoginPassword: "password123", }), }); + const body = await response.json(); - assert.equal(response.status, 303); - assert.equal(response.headers.get("location"), "/dashboard"); + assert.equal(response.status, 401); + assert.equal(body.success, false); + assert.equal(body.errors.credentials, "Email or password is incorrect."); } finally { - server.close(); + await close(server); + } +}); + +test("login form submission rejects an incorrect password", async () => { + const server = await listen(app); + const email = nextEmail("wrong-password"); + + try { + await signup(server, { email, password: "password123" }); + + const response = await request(server, "/login", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + LoginEmail: email, + LoginPassword: "not-the-password", + }), + }); + const body = await response.json(); + + assert.equal(response.status, 401); + assert.equal(body.success, false); + assert.equal(body.errors.credentials, "Email or password is incorrect."); + } finally { + await close(server); + } +}); + +test("signup form submission rejects duplicate email addresses", async () => { + const server = await listen(app); + const email = nextEmail("duplicate"); + + try { + await signup(server, { email, password: "password123" }); + + const response = await request(server, "/signup", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + SignUpUsername: "Alex Again", + SignUpEmail: email.toUpperCase(), + SignUpPassword: "password123", + }), + }); + const body = await response.json(); + + assert.equal(response.status, 409); + assert.equal(body.success, false); + assert.equal(body.errors.email, "An account already exists for this email address."); + } finally { + await close(server); + } +}); + +test("concurrent signup attempts reserve duplicate email addresses", async () => { + const server = await listen(app); + const email = nextEmail("parallel-duplicate"); + const body = () => + new URLSearchParams({ + SignUpUsername: "Alex", + SignUpEmail: email, + SignUpPassword: "password123", + }); + + try { + const responses = await Promise.all([ + request(server, "/signup", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: body(), + }), + request(server, "/signup", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: body(), + }), + ]); + const statuses = responses.map((response) => response.status).sort(); + + assert.deepEqual(statuses, [303, 409]); + } finally { + await close(server); } }); @@ -85,7 +247,129 @@ test("auth form submissions reject invalid email input", async () => { assert.equal(body.success, false); assert.equal(body.errors.email, "Enter a valid email address."); } finally { - server.close(); + await close(server); + } +}); + +test("signup form submissions reject short passwords", async () => { + const server = await listen(app); + + try { + const response = await request(server, "/signup", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + SignUpUsername: "Alex", + SignUpEmail: nextEmail("short-password"), + SignUpPassword: "short", + }), + }); + const body = await response.json(); + + assert.equal(response.status, 400); + assert.equal(body.success, false); + assert.equal(body.errors.password, "Password must be at least 8 characters."); + } finally { + await close(server); + } +}); + +test("dashboard redirects unauthenticated users to the signup page", async () => { + const server = await listen(app); + + try { + const response = await request(server, "/dashboard"); + + assert.equal(response.status, 303); + assert.equal(response.headers.get("location"), "/signup"); + } finally { + await close(server); + } +}); + +test("dashboard rejects tampered session cookies", async () => { + const server = await listen(app); + + try { + const response = await request(server, "/dashboard", { + headers: { + Cookie: `${SESSION_COOKIE}=not-a-valid-session`, + }, + }); + + assert.equal(response.status, 303); + assert.equal(response.headers.get("location"), "/signup"); + } finally { + await close(server); + } +}); + +test("dashboard treats malformed cookie encoding as unauthenticated", async () => { + const server = await listen(app); + + try { + const response = await request(server, "/dashboard", { + headers: { + Cookie: `${SESSION_COOKIE}=%`, + }, + }); + + assert.equal(response.status, 303); + assert.equal(response.headers.get("location"), "/signup"); + } finally { + await close(server); + } +}); + +test("dashboard accepts a signed session without process-local user state", async () => { + const server = await listen(app); + + try { + const cookie = await signup(server, { email: nextEmail("stateless-session") }); + clearUsersForTests(); + + const response = await request(server, "/dashboard", { + headers: { + Cookie: cookie, + }, + }); + const body = await response.text(); + + assert.equal(response.status, 200); + assert.match(body, /id="board-grid"/); + } finally { + await close(server); + } +}); + +test("logout clears the browser cookie and revokes the current session", async () => { + const server = await listen(app); + + try { + const cookie = await signup(server, { email: nextEmail("logout") }); + const logoutResponse = await request(server, "/logout", { + method: "POST", + headers: { + Cookie: cookie, + }, + }); + + assert.equal(logoutResponse.status, 303); + assert.equal(logoutResponse.headers.get("location"), "/signup"); + assert.match(logoutResponse.headers.get("set-cookie"), /Max-Age=0/); + + const dashboardResponse = await request(server, "/dashboard", { + headers: { + Cookie: cookie, + }, + }); + + assert.equal(dashboardResponse.status, 303); + assert.equal(dashboardResponse.headers.get("location"), "/signup"); + } finally { + await close(server); } }); @@ -103,7 +387,7 @@ test("home page renders the language toggle script", async () => { assert.match(body, /id="cookie-banner"/); assert.match(body, /type="module" src="\/static\/js\/site\.js"/); } finally { - server.close(); + await close(server); } }); @@ -117,22 +401,41 @@ test("signup page renders bilingual auth controls", async () => { assert.equal(response.status, 200); assert.match(body, /data-i18n="signup\.title"/); assert.match(body, /data-i18n="login\.title"/); + assert.match(body, /name="auth-mode-toggle"/); assert.match(body, /data-i18n-placeholder="signup\.placeholder\.username"/); } finally { - server.close(); + await close(server); } }); -test("dashboard page renders with the i18n script and task board shell", async () => { +test("favicon requests do not create browser 404 noise", async () => { const server = await listen(app); try { - const response = await request(server, "/dashboard"); + const response = await request(server, "/favicon.ico"); + + assert.equal(response.status, 204); + } finally { + await close(server); + } +}); + +test("dashboard page renders with the i18n script and task board shell for signed-in users", async () => { + const server = await listen(app); + + try { + const cookie = await signup(server, { email: nextEmail("dashboard") }); + const response = await request(server, "/dashboard", { + headers: { + Cookie: cookie, + }, + }); const body = await response.text(); assert.equal(response.status, 200); assert.match(body, /data-i18n-document-title="document\.dashboard"/); assert.match(body, /id="board-grid"/); + assert.match(body, /method="post" action="\/logout"/); assert.match(body, /id="form-message" class="form-message" role="status" aria-live="polite"/); assert.match(body, /