import { test, before, after } from "node:test"; import assert from "node:assert/strict"; /** * End-to-end self-service credential flows against the mock, which enforces * the same rules a real 0.16 server does: the current password is checked, * password policy is applied, and once 2FA is on every request wants a fresh * TOTP code — except one authenticating with an app password. */ const PORT = 18797; process.env.MOCK_PORT = String(PORT); process.env.MOCK_USER = "demo@example.com"; process.env.MOCK_PASS = "demo-password"; process.env.STALWART_URL = `http://127.0.0.1:${PORT}`; process.env.APP_SECRET = "test-secret-for-account-flows"; const mock = await import("./mock/index.js"); const { createApp } = await import("./app.js"); const { parseOtpauthUrl, totpCode } = await import("./totp.js"); const app = createApp(); let cookie = ""; const HEADERS = { "content-type": "application/json", "x-requested-with": "ihasmail" }; async function call(path: string, init: RequestInit = {}): Promise<{ status: number; body: any }> { const res = await app.request(path, { ...init, headers: { ...HEADERS, ...(init.headers as Record), ...(cookie ? { cookie } : {}) }, }); const setCookie = res.headers.get("set-cookie"); if (setCookie) cookie = setCookie.split(";")[0]!; const text = await res.text(); return { status: res.status, body: text ? JSON.parse(text) : null }; } const post = (path: string, body: unknown) => call(path, { method: "POST", body: JSON.stringify(body) }); before(async () => { const res = await post("/api/auth/login", { username: "demo@example.com", password: "demo-password" }); assert.equal(res.status, 200, "login should succeed against the mock"); }); after(() => { (mock as { server?: { close(): void } }).server?.close(); }); /** * Stalwart advertises `urn:stalwart:jmap` only per-account, never in the * session-level capabilities. Looking for it at the top level alone reported * every real 0.16 server as older than 0.16 — and now that the same check * decides whether a sign-in is allowed at all, that mistake would lock * everyone out rather than merely misroute credentials. */ test("the session is accepted on a server that advertises the registry per-account", async () => { const res = await call("/api/auth/session"); assert.equal(res.status, 200); assert.equal(res.body.ihasmail.server.edition, "oss"); assert.equal(res.body.capabilities["urn:stalwart:jmap"], undefined, "not where a client would first look"); assert.ok("urn:stalwart:jmap" in res.body.primaryAccounts, "but here, as on a real server"); }); test("the registry reports an account with nothing set up yet", async () => { const res = await call("/api/account/security"); assert.equal(res.status, 200); assert.equal(res.body.otpEnabled, false); assert.deepEqual(res.body.appPasswords, []); }); test("app passwords are created, listed once with their secret, and revoked", async () => { const created = await post("/api/account/app-passwords", { description: "Thunderbird", current: "demo-password" }); assert.equal(created.status, 200); assert.match(created.body.secret, /^\$app\$/, "the server's generated secret is returned"); assert.ok(created.body.id); const list = await call("/api/account/security"); assert.equal(list.body.appPasswords.length, 1); assert.equal(list.body.appPasswords[0].description, "Thunderbird"); assert.equal(list.body.appPasswords[0].secret, undefined, "the secret is never listed again"); const revoked = await post("/api/account/app-passwords/revoke", { id: created.body.id }); assert.equal(revoked.status, 200); assert.deepEqual((await call("/api/account/security")).body.appPasswords, []); }); test("an app password needs the account password", async () => { const missing = await post("/api/account/app-passwords", { description: "Stolen" }); assert.equal(missing.status, 400); assert.equal(missing.body.error, "missing_fields"); const wrong = await post("/api/account/app-passwords", { description: "Stolen", current: "not-my-password" }); assert.equal(wrong.status, 403); assert.equal(wrong.body.error, "invalid_credentials"); assert.deepEqual((await call("/api/account/security")).body.appPasswords, [], "nothing was created"); }); test("a checked session cannot mint one through the JMAP proxy instead", async () => { // Signed in without "my own device", so the proxy reads every request. const res = await call("/api/jmap", { method: "POST", body: JSON.stringify({ using: ["urn:ietf:params:jmap:core"], methodCalls: [["x:AppPassword/set", { create: { n: { description: "Stolen" } } }, "0"]] }), }); assert.equal(res.status, 403); assert.deepEqual((await call("/api/account/security")).body.appPasswords, []); }); test("attachments are kept out of the disk cache of a device that is not the person's own", async () => { const up = await app.request("/api/upload/a1", { method: "POST", headers: { "x-requested-with": "ihasmail", "content-type": "text/plain", cookie }, body: "hello" }); assert.equal(up.status, 200); const { blobId } = (await up.json()) as { blobId: string }; const name = encodeURIComponent("Invoice_\u202Efdp.exe"); const res = await app.request(`/api/blob/a1/${blobId}/${name}?accept=text/plain`, { headers: { cookie } }); assert.equal(res.status, 200); assert.equal(res.headers.get("cache-control"), "no-store"); assert.equal(res.headers.get("content-disposition"), "attachment; filename*=UTF-8''Invoice_fdp.exe", "no direction override in the saved name"); await res.arrayBuffer(); }); test("a download passes a byte range through, for viewers that read in pieces", async () => { const up = await app.request("/api/upload/a1", { method: "POST", headers: { "x-requested-with": "ihasmail", "content-type": "text/plain", cookie }, body: "hello world" }); const { blobId } = (await up.json()) as { blobId: string }; const url = `/api/blob/a1/${blobId}/greeting.txt?accept=text/plain`; const part = await app.request(url, { headers: { cookie, range: "bytes=0-4" } }); assert.equal(part.status, 206); assert.equal(part.headers.get("content-range"), "bytes 0-4/11"); assert.equal(part.headers.get("accept-ranges"), "bytes"); assert.equal(await part.text(), "hello"); const whole = await app.request(url, { headers: { cookie } }); assert.equal(whole.status, 200); assert.equal(await whole.text(), "hello world"); const beyond = await app.request(url, { headers: { cookie, range: "bytes=50-60" } }); assert.equal(beyond.status, 416); // Anything that is not a plain byte range is not passed on. const odd = await app.request(url, { headers: { cookie, range: "items=0-4" } }); assert.equal(odd.status, 200); await odd.arrayBuffer(); }); test("upstream caches let go of sessions that have aged out", async () => { const { sweepUpstreamCaches, upstreamCacheSizes } = await import("./upstream.js"); // Signed in above, so this session has an entry. assert.ok(upstreamCacheSizes().sessions >= 1); sweepUpstreamCaches(Date.now() + 60 * 60_000); assert.deepEqual(upstreamCacheSizes(), { sessions: 0, info: 0 }); }); test("the mock refuses a contact photo given as a blob id, as Stalwart does", async () => { const jmap = (methodCalls: unknown[]) => call("/api/jmap", { method: "POST", body: JSON.stringify({ using: ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:contacts"], methodCalls }) }); const card = (media: unknown) => ({ "@type": "Card", version: "1.0", kind: "individual", name: { full: "Probe" }, addressBookIds: { ab1: true }, media }); const res = await jmap([["ContactCard/set", { accountId: "a1", create: { blob: card({ p: { "@type": "Media", kind: "photo", blobId: "b1", mediaType: "image/jpeg" } }), inline: card({ p: { "@type": "Media", kind: "photo", uri: "data:image/jpeg;base64,AA", mediaType: "image/jpeg" } }), } }, "s"]]); assert.equal(res.status, 200); const set = res.body.methodResponses[0][1]; assert.equal(set.notCreated.blob.description, "blobIds in media is not supported."); assert.deepEqual(set.notCreated.blob.properties, ["media"]); assert.ok(set.created.inline.id, "a data URI is accepted"); await jmap([["ContactCard/set", { accountId: "a1", destroy: [set.created.inline.id] }, "d"]]); }); test("an app password needs a name", async () => { const res = await post("/api/account/app-passwords", { description: " ", current: "demo-password" }); assert.equal(res.status, 400); assert.equal(res.body.error, "missing_fields"); }); test("the wrong current password is refused with the server's reason", async () => { const res = await post("/api/account/password", { current: "not-my-password", next: "a-much-longer-password" }); assert.equal(res.status, 403); assert.match(res.body.message, /Current secret is incorrect/); }); test("the server's password policy is surfaced verbatim", async () => { const res = await post("/api/account/password", { current: "demo-password", next: "short" }); assert.equal(res.status, 400); assert.match(res.body.message, /at least 8 characters/); }); test("a password unchanged from the old one is rejected before we ask upstream", async () => { const res = await post("/api/account/password", { current: "demo-password", next: "demo-password" }); assert.equal(res.status, 400); assert.equal(res.body.error, "unchanged"); }); test("changing the password keeps this session working", async () => { const res = await post("/api/account/password", { current: "demo-password", next: "a-brand-new-password" }); assert.equal(res.status, 200); // The stored credential was re-sealed, so the next proxied call still passes // upstream authentication with the new password. assert.equal((await call("/api/auth/session")).status, 200); assert.equal((await call("/api/account/security")).status, 200); }); test("enabling 2FA rejects a code the new secret did not produce", async () => { const begin = await post("/api/account/2fa/begin", {}); assert.equal(begin.status, 200); assert.match(begin.body.url, /^otpauth:\/\/totp\//); const res = await post("/api/account/2fa/enable", { url: begin.body.url, code: "000000", current: "a-brand-new-password" }); assert.equal(res.status, 400); assert.equal(res.body.code, undefined); assert.match(res.body.message, /doesn't match/); assert.equal((await call("/api/account/security")).body.otpEnabled, false, "nothing was stored"); }); test("enabling 2FA switches the session onto an app password so it survives", async () => { const begin = await post("/api/account/2fa/begin", {}); const params = parseOtpauthUrl(begin.body.url); assert.ok(params); const res = await post("/api/account/2fa/enable", { url: begin.body.url, code: totpCode(params), current: "a-brand-new-password", }); assert.equal(res.status, 200); assert.equal(res.body.sessionKept, true); const state = await call("/api/account/security"); assert.equal(state.status, 200, "the session still authenticates upstream"); assert.equal(state.body.otpEnabled, true); assert.equal(state.body.appPasswords.length, 1, "one app password was minted for this browser"); assert.match(state.body.appPasswords[0].description, /\(/, "it is named after the browser"); }); test("with 2FA on, a password change needs the current code too", async () => { const withoutCode = await post("/api/account/password", { current: "a-brand-new-password", next: "yet-another-password" }); assert.equal(withoutCode.status, 403); assert.match(withoutCode.body.message, /OTP code is required/); }); test("2FA is switched off with the password and a current code", async () => { const state = await call("/api/account/security"); assert.equal(state.body.otpEnabled, true); // The enrollment secret is known only to the client, so disabling uses a code // from the authenticator - here, the one the mock stored. const stored = (mock as { account: { otpUrl: string | null } }).account.otpUrl; const params = parseOtpauthUrl(stored!); assert.ok(params); const res = await post("/api/account/2fa/disable", { current: "a-brand-new-password", code: totpCode(params) }); assert.equal(res.status, 200); assert.equal((await call("/api/account/security")).body.otpEnabled, false); }); test("credential endpoints reject unauthenticated callers", async () => { const saved = cookie; cookie = ""; assert.equal((await call("/api/account/security")).status, 401); assert.equal((await post("/api/account/password", { current: "a", next: "b" })).status, 401); assert.equal((await post("/api/account/2fa/begin", {})).status, 401); cookie = saved; }); /** * A sign-in carrying a two-factor code that the server rejects is almost never * "wrong password". Stalwart accepts TOTP only through an OAuth flow and offers * no password grant, so the concatenated form ihasmail sends cannot work — and * saying "invalid credentials" sends the user to check a password that is fine. * * Reported as #75: 2FA sign-in failed with a bare 401 while an app password * worked, which is Stalwart's documented route and gave no hint of itself. */ test("a rejected sign-in carrying a TOTP code explains itself", async () => { const saved = cookie; cookie = ""; const res = await post("/api/auth/login", { username: "demo@example.com", password: "demo-password", totp: "123456" }); cookie = saved; assert.equal(res.status, 401); assert.equal(res.body.error, "totp_unsupported", "not the generic invalid_credentials"); assert.match(res.body.message, /app password/i, "points at the route that does work"); assert.match(res.body.message, /probably fine/i, "does not blame the password"); }); test("a rejected sign-in without a code is still a plain credential failure", async () => { // The explanation must not leak onto ordinary typos. const saved = cookie; cookie = ""; const res = await post("/api/auth/login", { username: "demo@example.com", password: "wrong" }); cookie = saved; assert.equal(res.status, 401); assert.equal(res.body.error, "invalid_credentials"); });