diff --git a/src/idp/credentials.js b/src/idp/credentials.js index d09caa0c..b74b39a6 100644 --- a/src/idp/credentials.js +++ b/src/idp/credentials.js @@ -7,9 +7,10 @@ import * as jose from 'jose'; import crypto from 'crypto'; import fs from 'fs-extra'; import path from 'path'; -import { authenticate, findByWebId, updatePassword, verifyPassword, deleteAccount } from './accounts.js'; +import { authenticate, findByUsername, findByWebId, updatePassword, verifyPassword, deleteAccount } from './accounts.js'; import { getJwks } from './keys.js'; import { getWebIdFromRequestAsync } from '../auth/token.js'; +import { accountDeletePage } from './views.js'; /** * Handle POST /idp/credentials @@ -358,39 +359,65 @@ export async function handleDeleteAccount(request, reply, options = {}) { }); } - // 5. Delete the account record + indexes + // 5. Delete via the shared helper (also handles optional pod-data purge). + // See deleteAccountAndOptionallyPurge below for the purge semantics + // and rationale (#391 pass 2 / pass 3). + const { purged } = await deleteAccountAndOptionallyPurge(request, account, purgeData); + + reply.header('Cache-Control', 'no-store'); + reply.header('Pragma', 'no-cache'); + return { + ok: true, + webid: account.webId, + purged, + }; +} + +/** + * Apply anti-clickjacking + no-store cache headers to a response. + * Used on every account-deletion HTML response (form, success, error + * re-render, disabled-message page) and on the corresponding routes. + * + * - Cache-Control: no-store — destructive form/success page should + * never sit in a shared cache; if a token gets shared via the URL + * the browser shouldn't replay the action from cache either. + * - X-Frame-Options + frame-ancestors — block clickjacking. + * Embedding this form in a hostile iframe and tricking a user into + * submitting a captured action is exactly the threat shape these + * headers exist to mitigate. + */ +export function setNoCacheClickjackHeaders(reply) { + reply.header('Cache-Control', 'no-store'); + reply.header('Pragma', 'no-cache'); + reply.header('X-Frame-Options', 'DENY'); + reply.header('Content-Security-Policy', "frame-ancestors 'none'"); +} + +/** + * Internal: delete an account record + optional pod-data purge. + * Shared between the JSON endpoint (handleDeleteAccount) and the + * form-driven endpoint (handleAccountDeleteForm in #392). + * + * Best-effort purge — fs.remove can throw, but the account is already + * gone and we want a clean signal rather than a 500. Path is derived + * from account.podName (NOT username, which createAccount lowercases — + * pod dir on disk is original case, see #391 pass 2). Belt-and- + * suspenders path-relative check rejects ../traversal and works at FS + * roots (see #391 pass 3). + * + * @param {object} request - Fastify request, used only for log access + * @param {object} account - Account record (with username, podName, webId) + * @param {boolean} purgeData - If true, also remove the pod's filesystem tree + * @returns {Promise<{purged: boolean}>} + */ +async function deleteAccountAndOptionallyPurge(request, account, purgeData) { await deleteAccount(account.id); - // 6. Optionally purge the pod's filesystem data. Mirrors the CLI - // `--purge` semantics. The path is `//`. - // - // Use account.podName, NOT account.username: createAccount normalizes - // username to lowercase (`username.toLowerCase().trim()`) but the pod - // directory on disk is created with the original case (per the input - // to handleCreatePod). On case-sensitive filesystems, deriving the - // purge path from username would either no-op (path doesn't exist) - // or hit a different directory if one exists at the lowercased name. - // Pod-name validation regex is /^[a-zA-Z0-9_-]+$/ (alphanum + dash + - // underscore; no dots, no traversal sequences) so podName is safe to - // join — defensive normalize stays as belt-and-suspenders. - // - // Best-effort: if fs.remove throws (permissions, transient FS error, - // race with another consumer), the account is already deleted and we - // shouldn't 500 over the leftover files. Log server-side and return - // purged: false so the caller knows pod data may still exist; an - // operator can finish the cleanup with a follow-up `rm -rf` or - // CLI `--purge` against the now-orphaned directory. let purged = false; if (purgeData) { const dataRoot = process.env.DATA_ROOT || './data'; const candidate = path.resolve(dataRoot, account.podName || account.username); const root = path.resolve(dataRoot); - // Belt-and-suspenders: refuse to remove anything that isn't a - // proper child of the data root. Won't trigger on registered pod - // names; protects against config drift / future bugs. Use - // path.relative so the check works when dataRoot is a filesystem - // root like `/` (where startsWith(root + path.sep) would compare - // against `//`, false-negative all valid children). const rel = path.relative(root, candidate); const isProperChild = rel && rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel); if (isProperChild) { @@ -400,20 +427,132 @@ export async function handleDeleteAccount(request, reply, options = {}) { } catch (err) { request.log.error({ err, path: candidate, username: account.username }, 'Pod data purge failed after account deletion'); - // Don't surface the raw error to the user (file paths, - // permission detail leak); response.purged signals the - // outcome. } + } else { + // Belt-and-suspenders rejection — shouldn't trigger on registered + // pod names but logging it surfaces config drift (e.g. someone + // changed dataRoot, podName field is malformed, etc.) and makes + // the "purge did not complete" UX message diagnosable from the + // operator's logs without leaking the path to the client. + request.log.warn({ path: candidate, dataRoot: root, podName: account.podName, username: account.username }, + 'Pod data purge skipped: candidate path is not a proper child of dataRoot'); } } + return { purged }; +} - reply.header('Cache-Control', 'no-store'); - reply.header('Pragma', 'no-cache'); - return { - ok: true, - webid: account.webId, - purged, - }; +/** + * Handle POST /idp/account/delete (#392) — form-driven account deletion. + * + * Public unauthenticated endpoint that takes a form-encoded body with + * username + currentPassword + confirmUsername (+ optional keepData + * opt-out checkbox; default behavior is purge-on for the leaving-user + * UX, opposite the JSON endpoint's purge-off default). Authenticates + * the user via password directly (no Bearer token round-trip), validates + * the destructive-action UX guard, then calls into the same delete + * logic as the JSON endpoint via deleteAccountAndOptionallyPurge. + * Returns HTML directly: + * - success → success page + * - any failure → the form re-rendered with an error message and the + * username field pre-filled (no redirect — single response, status + * 200 with the rendered form) + * + * GET /idp/account/delete is a separate route that just renders the + * form via accountDeletePage(); see src/idp/index.js. This handler is + * POST-only. + * + * Single-user mode: this handler short-circuits to the disabled-message + * page (matches the GET route's behavior). Same policy as the JSON + * endpoint, which 403s with the equivalent message. + * + * @param {object} request - Fastify request + * @param {object} reply - Fastify reply + * @param {object} options + * @param {boolean} [options.singleUser] - Single-user mode flag + */ +export async function handleAccountDeleteForm(request, reply, options = {}) { + // Every response from this handler is a destructive-action surface + // that re-takes the user's password — never cache, never embed. + setNoCacheClickjackHeaders(reply); + + if (options.singleUser) { + // 403 matches the GET route, /idp/register's disabled-route policy, + // and the JSON DELETE endpoint's 403 — consistent status across + // every disabled-in-single-user surface. + return reply.code(403).type('text/html').send(accountDeletePage({ singleUser: true })); + } + + // Parse form-encoded body. JSS registers a wildcard parseAs:'buffer' + // content parser (server.js:190), so request.body arrives here as a + // Buffer. We coerce to a string and parse the application/x-www-form- + // urlencoded shape via URLSearchParams. (No @fastify/formbody is + // installed; doing it inline keeps this self-contained and matches + // what handleChangePassword does for JSON.) + let body = request.body; + if (Buffer.isBuffer(body)) body = body.toString('utf-8'); + if (typeof body === 'string') { + try { + const params = new URLSearchParams(body); + body = Object.fromEntries(params); + } catch { body = {}; } + } + + const username = (body?.username || '').trim(); + const currentPassword = body?.currentPassword || ''; + const confirmUsername = (body?.confirmUsername || '').trim(); + // Form field is `keepData` (inverse of the JSON endpoint's `purgeData`) + // so the form's default is purge-on: a user who is leaving the server + // probably wants their files gone too. Checking "Keep my pod data" is + // the opt-out. JSON endpoint (DELETE /idp/account) keeps the + // explicit `purgeData` shape that matches the CLI's default-off + // semantics for operator scripts; the form intentionally diverges. + const keepData = body?.keepData === 'on' || body?.keepData === true; + const purgeData = !keepData; + + if (!username || !currentPassword || !confirmUsername) { + return reply.type('text/html').send(accountDeletePage({ + error: 'All fields are required.', + username, + })); + } + + // Destructive-action UX guard: the user must type the same string + // twice. The string-equality check is case-sensitive on the typed + // form values; that's purely the typing-it-again confirmation + // pattern (catch typos / accidental submits). Note: findByUsername() + // lowercases internally, so "Alice" + "Alice" both resolve to the + // same account record as "alice" — the case-sensitive comparison + // here doesn't gate which record gets deleted, only whether the + // user typed the same thing twice. + if (username !== confirmUsername) { + return reply.type('text/html').send(accountDeletePage({ + error: 'Confirmation does not match the username you entered.', + username, + })); + } + + // Look up + verify password without side effects. authenticate() is + // tempting (looks up + verifies in one call) but writes lastLogin on + // success — wrong shape for a destructive proof-of-possession check + // (and would fail the deletion if the account file weren't writable). + // Mirrors handleChangePassword / handleDeleteAccount which both use + // verifyPassword for the same reason. + const account = await findByUsername(username); + if (!account || !(await verifyPassword(account, currentPassword))) { + return reply.type('text/html').send(accountDeletePage({ + error: 'Username or password is incorrect.', + username, + })); + } + + const { purged } = await deleteAccountAndOptionallyPurge(request, account, purgeData); + + // If the user asked for a purge but it didn't run (fs.remove threw, + // path-relative check rejected, etc.), surface that on the success + // page. Account deletion succeeded — don't roll that back — but the + // operator may need to finish cleanup. Don't leak server paths. + const purgeFailed = purgeData && !purged; + return reply.type('text/html').send(accountDeletePage({ success: true, purgeFailed })); } /** diff --git a/src/idp/index.js b/src/idp/index.js index 740e864f..0efe72c9 100644 --- a/src/idp/index.js +++ b/src/idp/index.js @@ -24,10 +24,12 @@ import { handleCredentialsInfo, handleChangePassword, handleDeleteAccount, + handleAccountDeleteForm, + setNoCacheClickjackHeaders, } from './credentials.js'; import * as passkey from './passkey.js'; import { addTrustedIssuer } from '../auth/solid-oidc.js'; -import { landingPage } from './views.js'; +import { landingPage, accountDeletePage } from './views.js'; /** * IdP Fastify Plugin @@ -295,6 +297,36 @@ export async function idpPlugin(fastify, options) { return handleDeleteAccount(request, reply, { singleUser }); }); + // GET account-delete form (#392) - human-friendly UI for #352. Public + // unauthenticated page; auth happens at form submission via password. + // Single-user mode returns 403 to stay consistent with /idp/register's + // disabled-route policy and the JSON DELETE /idp/account endpoint + // (which also 403s in single-user mode). Body is still HTML so a + // browser visitor sees the explanation. Every response sets + // anti-clickjacking + no-store headers — destructive-action page. + fastify.get('/idp/account/delete', async (request, reply) => { + setNoCacheClickjackHeaders(reply); + if (singleUser) { + return reply.code(403).type('text/html').send(accountDeletePage({ singleUser: true })); + } + return reply.type('text/html').send(accountDeletePage({ singleUser: false })); + }); + + // POST account-delete form (#392) - processes the form submission. + // Same rate-limit as the JSON endpoint to keep the destructive-action + // surface consistent across both paths. + fastify.post('/idp/account/delete', { + config: { + rateLimit: { + max: 5, + timeWindow: '1 minute', + keyGenerator: (request) => request.ip + } + } + }, async (request, reply) => { + return handleAccountDeleteForm(request, reply, { singleUser }); + }); + // Interaction routes (our custom login/consent UI) // These bypass oidc-provider and use our handlers diff --git a/src/idp/views.js b/src/idp/views.js index f5f554e9..4bc6d9a2 100644 --- a/src/idp/views.js +++ b/src/idp/views.js @@ -533,6 +533,194 @@ export function consentPage(uid, client, params, account) { `; } +/** + * Account-deletion form HTML (#392). + * + * Public unauthenticated page (matches the existing /idp landing and + * /idp/register pattern). Auth happens at submission time: the user + * supplies username + password, which the server validates and uses as + * proof-of-possession for the delete. The "type your username again to + * confirm" field is the destructive-action UX guard. + * + * On any failure (wrong password, mismatched confirmation, etc.) the + * handler re-renders this same form in place at status 200 with an + * error message and the identifier field pre-filled — no redirect. + * + * @param {object} opts + * @param {string|null} opts.error - Error message (e.g. wrong password) to display + * @param {string} opts.username - Pre-fill the identifier field on re-render after error + * @param {boolean} opts.singleUser - When true, render a disabled message + * instead of the form. Deletion via HTTP is blocked in single-user mode + * (would brick the IdP until re-seed); operator path stays the CLI. + * @param {boolean} opts.success - When true, render the post-delete confirmation + * @param {boolean} opts.purgeFailed - When true (only on success), include a + * notice that the user requested a pod-data purge but it didn't complete. + * Account deletion still succeeded. + */ +export function accountDeletePage({ error = null, username = '', singleUser = false, success = false, purgeFailed = false } = {}) { + if (singleUser) { + return ` + + + + + + Account deletion disabled - Solid IdP + + + +
+ +

Account deletion disabled

+

This server runs in single-user mode. Deleting the single account + via HTTP would leave the server with no IdP account until re-seed, + so this endpoint is disabled.

+

The operator can still delete the account at the shell with:

+
jss account delete <username>
+ Back +
+ + + `; + } + + if (success) { + return ` + + + + + + Account deleted - Solid IdP + + + +
+ +

Account deleted

+

Your account record has been removed from this server. Future sign-ins + with this username will fail.

+

+ Note: any access tokens already issued may remain usable until they + expire — the server does not currently revoke them on account deletion. +

+ ${purgeFailed ? ` +
+ Your account was deleted, but the pod-data purge did not complete on + this server. Some files may still exist. Contact the operator to + finish the cleanup if needed. +
+ ` : ''} + Return to sign-in +
+ + + `; + } + + return ` + + + + + + Delete account - Solid IdP + + + +
+ +

Delete your account

+ +
+ This is permanent. Your account record and credentials will be + removed; future sign-ins with this username will fail. By default, your pod + data (every file you've stored, including your WebID profile document) is + also wiped — check the box below if you want to keep it. Federated references + (ActivityPub follows, Nostr relays, type indexes) cannot be retracted from + this server. +

+ + Note: access tokens already issued may remain usable until they + expire — the server does not currently revoke them on deletion. + +
+ + ${error ? `
${escapeHtml(error)}
` : ''} + +
+ + + + + + + + + +
+ + +
+ + +
+ +

+ Cancel and go back +

+
+ + + `; +} + /** * Error page HTML */ diff --git a/test/idp-delete-account.test.js b/test/idp-delete-account.test.js index f8652bd0..da8cb985 100644 --- a/test/idp-delete-account.test.js +++ b/test/idp-delete-account.test.js @@ -269,6 +269,307 @@ describe('DELETE /idp/account — self-delete', () => { }); }); +describe('GET/POST /idp/account/delete — HTML form (#392)', () => { + let server; + let baseUrl; + let originalDataRoot; + const DATA_DIR = './test-data-delete-form'; + + before(async () => { + originalDataRoot = process.env.DATA_ROOT; + await fs.remove(DATA_DIR); + await fs.ensureDir(DATA_DIR); + const port = await getAvailablePort(); + baseUrl = `http://${TEST_HOST}:${port}`; + server = createServer({ + logger: false, + root: DATA_DIR, + idp: true, + idpIssuer: baseUrl, + forceCloseConnections: true, + }); + await server.listen({ port, host: TEST_HOST }); + }); + + after(async () => { + await server.close(); + await fs.remove(DATA_DIR); + if (originalDataRoot === undefined) delete process.env.DATA_ROOT; + else process.env.DATA_ROOT = originalDataRoot; + }); + + it('GET renders the form HTML', async () => { + const res = await fetch(`${baseUrl}/idp/account/delete`); + assert.strictEqual(res.status, 200); + const ct = res.headers.get('content-type') || ''; + assert.match(ct, /text\/html/); + const html = await res.text(); + assert.match(html, /]*action="\/idp\/account\/delete"/); + assert.match(html, /name="username"/); + assert.match(html, /name="currentPassword"/); + assert.match(html, /name="confirmUsername"/); + // Form's checkbox is `keepData` (inverse of the JSON endpoint's + // purgeData) so the form's default is purge-on for the leaving-user UX. + assert.match(html, /name="keepData"/); + assert.match(html, /Delete my account permanently/); + }); + + it('GET sets anti-clickjacking + no-store headers', async () => { + // Destructive-action page (form for account deletion) — must not + // be cacheable or embeddable in an iframe. + const res = await fetch(`${baseUrl}/idp/account/delete`); + assert.strictEqual(res.status, 200); + assert.match(res.headers.get('cache-control') || '', /no-store/i); + assert.strictEqual(res.headers.get('x-frame-options'), 'DENY'); + assert.match(res.headers.get('content-security-policy') || '', /frame-ancestors 'none'/); + }); + + it('POST sets the same security headers on success and error responses', async () => { + // Error response: missing fields + const errRes = await fetch(`${baseUrl}/idp/account/delete`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ username: 'x' }), + }); + assert.match(errRes.headers.get('cache-control') || '', /no-store/i); + assert.strictEqual(errRes.headers.get('x-frame-options'), 'DENY'); + assert.match(errRes.headers.get('content-security-policy') || '', /frame-ancestors 'none'/); + + // Success response: full delete flow + const id = `lyle${Date.now()}`; + await createPod(baseUrl, id, `${id}@example.com`, 'password123'); + const okRes = await fetch(`${baseUrl}/idp/account/delete`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + username: id, + currentPassword: 'password123', + confirmUsername: id, + }), + }); + assert.strictEqual(okRes.status, 200); + assert.match(okRes.headers.get('cache-control') || '', /no-store/i); + assert.strictEqual(okRes.headers.get('x-frame-options'), 'DENY'); + assert.match(okRes.headers.get('content-security-policy') || '', /frame-ancestors 'none'/); + }); + + it('POST happy path: deletes account AND wipes pod data by default; login fails after', async () => { + // Form's default is purge-on (the user is leaving — wipe everything). + // The JSON DELETE endpoint keeps purge-off as default (matches CLI for + // programmatic / operator use); the form diverges deliberately for the + // leaving-user UX. + const id = `harry${Date.now()}`; + await createPod(baseUrl, id, `${id}@example.com`, 'password123'); + const podPath = path.join(DATA_DIR, id); + assert.strictEqual(await fs.pathExists(podPath), true, + 'pod tree should exist before deletion'); + + const formBody = new URLSearchParams({ + username: id, + currentPassword: 'password123', + confirmUsername: id, + // No keepData — defaults to purge-on + }); + const res = await fetch(`${baseUrl}/idp/account/delete`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: formBody, + }); + assert.strictEqual(res.status, 200); + const html = await res.text(); + assert.match(html, /Account deleted/); + + // Login now fails + const reLogin = await fetch(`${baseUrl}/idp/credentials`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: `${id}@example.com`, password: 'password123' }), + }); + assert.strictEqual(reLogin.status, 401); + + // Pod data also wiped (default behavior on the form) + assert.strictEqual(await fs.pathExists(podPath), false, + 'pod data should be wiped by default on the form path'); + }); + + it('POST with keepData=on opts out of the purge, account still deleted', async () => { + const id = `iris${Date.now()}`; + await createPod(baseUrl, id, `${id}@example.com`, 'password123'); + const podPath = path.join(DATA_DIR, id); + assert.strictEqual(await fs.pathExists(podPath), true); + + const formBody = new URLSearchParams({ + username: id, + currentPassword: 'password123', + confirmUsername: id, + keepData: 'on', + }); + const res = await fetch(`${baseUrl}/idp/account/delete`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: formBody, + }); + assert.strictEqual(res.status, 200); + + // Account gone + const reLogin = await fetch(`${baseUrl}/idp/credentials`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: `${id}@example.com`, password: 'password123' }), + }); + assert.strictEqual(reLogin.status, 401); + + // Pod data preserved (the user opted to keep it) + assert.strictEqual(await fs.pathExists(podPath), true, + 'keepData=on should preserve pod data even though account is deleted'); + }); + + it('POST with mismatched confirmUsername renders form with error, account untouched', async () => { + const id = `jack${Date.now()}`; + await createPod(baseUrl, id, `${id}@example.com`, 'password123'); + + const formBody = new URLSearchParams({ + username: id, + currentPassword: 'password123', + confirmUsername: 'totally-different', + }); + const res = await fetch(`${baseUrl}/idp/account/delete`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: formBody, + }); + assert.strictEqual(res.status, 200); + const html = await res.text(); + assert.match(html, /Confirmation does not match/); + // Username pre-filled in the form for retry + assert.match(html, new RegExp(`value="${id}"`)); + + // Account intact + const login = await fetch(`${baseUrl}/idp/credentials`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: `${id}@example.com`, password: 'password123' }), + }); + assert.strictEqual(login.status, 200); + }); + + it('POST with wrong password renders form with error, account untouched', async () => { + const id = `kelly${Date.now()}`; + await createPod(baseUrl, id, `${id}@example.com`, 'password123'); + + const formBody = new URLSearchParams({ + username: id, + currentPassword: 'wrong', + confirmUsername: id, + }); + const res = await fetch(`${baseUrl}/idp/account/delete`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: formBody, + }); + assert.strictEqual(res.status, 200); + const html = await res.text(); + assert.match(html, /incorrect/i); + + // Account intact + const login = await fetch(`${baseUrl}/idp/credentials`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: `${id}@example.com`, password: 'password123' }), + }); + assert.strictEqual(login.status, 200); + }); + + it('POST with missing fields renders form with error', async () => { + const formBody = new URLSearchParams({ + username: 'someone', + // currentPassword and confirmUsername omitted + }); + const res = await fetch(`${baseUrl}/idp/account/delete`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: formBody, + }); + assert.strictEqual(res.status, 200); + const html = await res.text(); + assert.match(html, /required/i); + }); +}); + +describe('GET/POST /idp/account/delete — single-user mode renders disabled message', () => { + let server; + let baseUrl; + let originalDataRoot; + let originalPassword; + const DATA_DIR = './test-data-delete-form-single'; + + before(async () => { + originalDataRoot = process.env.DATA_ROOT; + originalPassword = process.env.JSS_SINGLE_USER_PASSWORD; + process.env.JSS_SINGLE_USER_PASSWORD = 'singletest'; + await fs.remove(DATA_DIR); + await fs.ensureDir(DATA_DIR); + const port = await getAvailablePort(); + baseUrl = `http://${TEST_HOST}:${port}`; + server = createServer({ + logger: false, + root: DATA_DIR, + idp: true, + idpIssuer: baseUrl, + singleUser: true, + singleUserName: 'me', + singleUserPassword: 'singletest', + forceCloseConnections: true, + }); + await server.listen({ port, host: TEST_HOST }); + }); + + after(async () => { + await server.close(); + await fs.remove(DATA_DIR); + if (originalDataRoot === undefined) delete process.env.DATA_ROOT; + else process.env.DATA_ROOT = originalDataRoot; + if (originalPassword === undefined) delete process.env.JSS_SINGLE_USER_PASSWORD; + else process.env.JSS_SINGLE_USER_PASSWORD = originalPassword; + }); + + it('GET returns 403 with the disabled-message HTML', async () => { + // 403 keeps single-user disabled routes consistent: /idp/register, + // the JSON DELETE /idp/account, and this GET all 403 in single-user. + const res = await fetch(`${baseUrl}/idp/account/delete`); + assert.strictEqual(res.status, 403); + const html = await res.text(); + assert.match(html, /single-user mode/i); + assert.match(html, /jss account delete/); + // No form + assert.doesNotMatch(html, /]*action="\/idp\/account\/delete"/); + }); + + it('POST returns 403 with disabled-message HTML — does not delete', async () => { + const formBody = new URLSearchParams({ + username: 'me', + currentPassword: 'singletest', + confirmUsername: 'me', + }); + const res = await fetch(`${baseUrl}/idp/account/delete`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: formBody, + }); + assert.strictEqual(res.status, 403); + const html = await res.text(); + assert.match(html, /single-user mode/i); + + // Account still works + const login = await fetch(`${baseUrl}/idp/credentials`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: 'me', password: 'singletest' }), + }); + assert.strictEqual(login.status, 200); + }); +}); + describe('DELETE /idp/account — single-user mode', () => { let server; let baseUrl;