Files
ihasmail-inbuxa/server/src/account.test.ts
T
jcoffey-dev bb25355c23 AGPL source offer and name cleanup
Every build writes the exact source it was built from, uncommitted work and
new files included, as source.tar.gz next to the app, named after that tree.
Docker builds, which have no git, pack the build context and name it by a
hash of its files. The sign-in page and Settings > About link to it instead of
a repository that can drift.

What users, operators and packagers see no longer names the upstream server:
- interface text, in all nine catalogues, with a token-session line for
  Security;
- server messages;
- the settings, now MAIL_SERVER_URL, MAIL_SERVERS_FILE, ADMIN_URL and
  MAIL_SERVER_FOLLOW_ADVERTISED_URLS, and mail-servers.example.json;
- the Tenants notice, which is gone;
- the README, CONTRIBUTING and SECURITY.

ihasmail's own FEATURES, KNOWN-ISSUES and ROADMAP stay with public ihasmail,
and INBUXA.md is folded into the README.
2026-09-19 00:13:12 -07:00

285 lines
14 KiB
TypeScript

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 = "[email protected]";
process.env.MOCK_PASS = "demo-password";
process.env.MAIL_SERVER_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<string, string>), ...(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: "[email protected]", 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(whole.headers.get("accept-ranges"), "bytes", "advertised even though Stalwart does not, so a PDF viewer asks");
assert.equal(await whole.text(), "hello world");
// Past the end, Stalwart sends the whole file rather than a 416.
const beyond = await app.request(url, { headers: { cookie, range: "bytes=50-60" } });
assert.equal(beyond.status, 200);
assert.equal(await beyond.text(), "hello world");
// 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: "[email protected]", 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: "[email protected]", password: "wrong" });
cookie = saved;
assert.equal(res.status, 401);
assert.equal(res.body.error, "invalid_credentials");
});