Files
LambdaMail/services/auth/src/router.test.ts
T
Lucas RayanandClaude Opus 5 bb12e603a3 fix(admin): dominio trocado ao criar, apelidos escondidos e quota a zero
Criar "lucas@cienciaembarcada.com.br" criava "lucas@lucasrguerra.dev.br". O
formulario partia o endereco e deitava fora a metade do dominio, e nao enviava
domain_id nenhum -- o servidor caia entao no dominio do admin com sessao
iniciada. Passa a ser parte local mais um dominio escolhido de uma lista: a
metade que decide o endereco deixa de poder ser descartada em silencio.

Apelidos de outros dominios estavam escondidos, e a culpa era minha: procurava
so no dominio onde a morada do proprio utilizador vive. Uma caixa recebe
postmaster, abuse, dmarc e tlsrpt de varios dominios ao mesmo tempo, por isso
o painel mostrava quatro onde entregavam oito. Ha um teste que falha com a
consulta antiga.

Os apelidos passam a poder ser criados e removidos dentro do modal. O dominio
sai do endereco escrito, para nao repetir a armadilha do formulario de cima.

USER, DOMAIN_ADMIN e SUPER_ADMIN eram constantes impressas no ecra, logo
ficavam em ingles em qualquer idioma. Um papel desconhecido continua a ser
mostrado tal como esta guardado, porque quem o ve precisa de saber o que a
base de dados tem.

A quota estava certa e mal mostrada. used_bytes vale 140771 -- os 137,5 KB
reais -- mas a pagina arredondava para megabytes inteiros, portanto qualquer
coisa abaixo de 512 KB lia-se "0 / 2 MB" e a barra ficava vazia, calculada
sobre os megabytes ja arredondados. Passa a formatar a partir dos bytes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 11:02:02 -03:00

1059 lines
48 KiB
TypeScript

import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { createServer, Server } from "node:http";
import crypto from "node:crypto";
// The master key has to exist before crypto.ts is imported anywhere, because
// encryptSecret refuses to run without one.
process.env.JWT_SECRET ||= "a-test-signing-secret-long-enough-for-the-guard";
process.env.LAMBDAMAIL_MASTER_KEY ||= "test-master-key-at-least-16-chars";
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL ?? "";
const hasDatabase = Boolean(process.env.TEST_DATABASE_URL);
const describeDb = hasDatabase ? describe : describe.skip;
const { handleApiRequest } = await import("./router.js");
const { hashPassword } = await import("./crypto.js");
const { query, closePool } = await import("./db.js");
const { generateTotpSecret, generateHotp, base32Decode, getCurrentStep } = await import("./totp.js");
const DOMAIN_ID = "11111111-1111-1111-1111-111111111111";
const USER_ID = "22222222-2222-2222-2222-222222222222";
const ADMIN_ID = "33333333-3333-3333-3333-333333333333";
const PASSWORD = "CorrectHorseBatteryStaple1!";
let server: Server;
let base: string;
function startServer(): Promise<void> {
return new Promise((resolve) => {
server = createServer((req, res) => {
if (handleApiRequest(req, res)) return;
res.statusCode = 404;
res.end();
});
server.listen(0, () => {
const addr = server.address();
base = `http://localhost:${typeof addr === "object" && addr ? addr.port : 0}`;
resolve();
});
});
}
async function seed(): Promise<void> {
const hash = await hashPassword(PASSWORD);
await query(`DELETE FROM domains WHERE id = $1`, [DOMAIN_ID]);
// name is CITEXT and punycode_name is VARCHAR, so the value is bound twice:
// reusing one placeholder for both makes Postgres refuse to deduce a type.
const domainName = `authtest-${DOMAIN_ID}.invalid`;
await query(
`INSERT INTO domains (id, name, punycode_name, mfa_policy) VALUES ($1, $2, $3, 'required_admins')`,
[DOMAIN_ID, domainName, domainName],
);
for (const [id, local, role] of [
[USER_ID, "user", "USER"],
[ADMIN_ID, "admin", "SUPER_ADMIN"],
] as const) {
await query(
`INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash, role)
VALUES ($1, $2, $3, $4, $5, $6)`,
[id, DOMAIN_ID, local, `${local}@authtest-${DOMAIN_ID}.invalid`, hash, role],
);
}
}
const userEmail = () => `user@authtest-${DOMAIN_ID}.invalid`;
const adminEmail = () => `admin@authtest-${DOMAIN_ID}.invalid`;
async function post(path: string, body: unknown, token?: string) {
const res = await fetch(`${base}${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(body),
});
return { status: res.status, body: await res.json().catch(() => ({})) as Record<string, never> };
}
async function put(path: string, body: unknown, token?: string) {
const res = await fetch(`${base}${path}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(body),
});
return { status: res.status, body: await res.json().catch(() => ({})) as Record<string, never> };
}
async function get(path: string, token?: string) {
const res = await fetch(`${base}${path}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
return { status: res.status, body: await res.json().catch(() => ({})) as Record<string, never> };
}
/** Enrolls a real second factor and returns the shared secret. */
async function enrollTotp(mailboxId: string, token: string): Promise<string> {
const enroll = await post("/api/v1/user/mfa/totp/enroll", {}, token);
const secret = enroll.body.secret as unknown as string;
const code = generateHotp(base32Decode(secret), getCurrentStep());
const confirm = await post("/api/v1/user/mfa/totp/confirm", { code }, token);
expect(confirm.status).toBe(200);
// Enrollment burns the step it used; clear it so the login test can reuse
// the current window instead of waiting 30 seconds.
await query(`UPDATE mfa_totp SET last_used_step = NULL WHERE mailbox_id = $1`, [mailboxId]);
return secret;
}
/**
* Returns an authenticated admin token.
*
* The second factor is reset first: once one is confirmed, the user login
* returns a challenge rather than a session, so re-enrolling through the API
* would need a code from a secret this helper does not have yet. Clearing the
* rows makes the path deterministic regardless of what earlier tests did.
*/
async function adminSession(): Promise<string> {
await query(`DELETE FROM mfa_totp WHERE mailbox_id = $1`, [ADMIN_ID]);
const userLogin = await post("/api/v1/auth/user/login", { email: adminEmail(), password: PASSWORD });
const secret = await enrollTotp(ADMIN_ID, userLogin.body.token as unknown as string);
const challenge = await post("/api/v1/auth/admin/login", { email: adminEmail(), password: PASSWORD });
const verified = await post("/api/v1/auth/mfa/verify", {
challenge_token: challenge.body.challenge_token,
code: generateHotp(base32Decode(secret), getCurrentStep()),
});
return verified.body.token as unknown as string;
}
describeDb("auth API against a real database", () => {
beforeAll(async () => {
await startServer();
await seed();
});
afterAll(async () => {
server?.close();
await query(`DELETE FROM domains WHERE id = $1`, [DOMAIN_ID]).catch(() => undefined);
await closePool();
});
// The headline regression: login used to accept any password at all.
it("rejects a wrong password", async () => {
const res = await post("/api/v1/auth/user/login", { email: userEmail(), password: "not-the-password" });
expect(res.status).toBe(401);
expect(res.body.error).toBe("INVALID_CREDENTIALS");
});
it("rejects an unknown account with the same answer as a wrong password", async () => {
const res = await post("/api/v1/auth/user/login", { email: "ghost@nowhere.invalid", password: "whatever" });
expect(res.status).toBe(401);
expect(res.body.error).toBe("INVALID_CREDENTIALS");
});
it("refuses the stored hash presented as the password", async () => {
const row = await query<{ password_hash: string }>(`SELECT password_hash FROM mailboxes WHERE id = $1`, [USER_ID]);
const res = await post("/api/v1/auth/user/login", { email: userEmail(), password: row[0].password_hash });
expect(res.status).toBe(401);
});
it("issues a session for the correct password", async () => {
const res = await post("/api/v1/auth/user/login", { email: userEmail(), password: PASSWORD });
expect(res.status).toBe(200);
expect(res.body.token).toBeDefined();
});
// The second headline regression: any non-empty MFA code used to work.
// The challenge comes from the admin sign-in, which is the surface that
// collects a second factor. The webmail no longer does - reading your own
// mail is behind your own password - so this used to obtain its challenge
// from a path that has none.
it("rejects a wrong TOTP code and accepts only the real one", { timeout: 60000 }, async () => {
const login = await post("/api/v1/auth/user/login", { email: adminEmail(), password: PASSWORD });
await query(`DELETE FROM mfa_totp WHERE mailbox_id = $1`, [ADMIN_ID]);
const secret = await enrollTotp(ADMIN_ID, login.body.token as unknown as string);
const challenge = await post("/api/v1/auth/admin/login", { email: adminEmail(), password: PASSWORD });
expect(challenge.body.mfa_required).toBe(true);
const wrong = await post("/api/v1/auth/mfa/verify", {
challenge_token: challenge.body.challenge_token,
code: "000000",
});
expect(wrong.status).toBe(401);
expect(wrong.body.error).toBe("INVALID_MFA_CODE");
const right = await post("/api/v1/auth/mfa/verify", {
challenge_token: challenge.body.challenge_token,
code: generateHotp(base32Decode(secret), getCurrentStep()),
});
expect(right.status).toBe(200);
expect(right.body.token).toBeDefined();
});
// PLAN.md F4 acceptance criterion: a /user token must be refused by
// /api/v1/admin/*.
// The browser holds the enrolment secret only in component state, so a
// reload brings the "enable" button back. Enrolling used to replace the
// stored secret on every call, which silently orphaned the entry the
// authenticator had already saved: the app kept showing valid codes for a
// secret the server no longer had, and confirmation failed forever with no
// way to tell why.
it("resumes a pending enrolment instead of replacing the secret", { timeout: 60000 }, async () => {
await query(`DELETE FROM mfa_totp WHERE mailbox_id = $1`, [USER_ID]);
const login = await post("/api/v1/auth/user/login", { email: userEmail(), password: PASSWORD });
const token = login.body.token as unknown as string;
const first = await post("/api/v1/user/mfa/totp/enroll", {}, token);
const second = await post("/api/v1/user/mfa/totp/enroll", {}, token);
expect(second.body.secret).toBe(first.body.secret);
// The code from the secret shown first must still confirm after the
// second call - that is the reload case.
const code = generateHotp(base32Decode(first.body.secret as unknown as string), getCurrentStep());
const confirm = await post("/api/v1/user/mfa/totp/confirm", { code }, token);
expect(confirm.status).toBe(200);
});
it("issues a different secret when enrolment is explicitly reset", { timeout: 60000 }, async () => {
await query(`DELETE FROM mfa_totp WHERE mailbox_id = $1`, [USER_ID]);
const login = await post("/api/v1/auth/user/login", { email: userEmail(), password: PASSWORD });
const token = login.body.token as unknown as string;
const first = await post("/api/v1/user/mfa/totp/enroll", {}, token);
const reset = await post("/api/v1/user/mfa/totp/enroll?reset=1", {}, token);
expect(reset.body.secret).not.toBe(first.body.secret);
// The URI has to carry the secret actually in force, or the QR would
// enrol something the server will not accept.
expect(reset.body.uri).toContain(reset.body.secret);
});
it("rejects a user token on an admin endpoint", async () => {
const login = await post("/api/v1/auth/user/login", { email: userEmail(), password: PASSWORD });
const token = (login.body.token ?? login.body.challenge_token) as unknown as string;
const res = await get("/api/v1/admin/dashboard", token);
expect(res.status).toBe(401);
expect(res.body.error).toBe("UNAUTHORIZED");
});
it("rejects a challenge token on a protected endpoint", async () => {
const challenge = await post("/api/v1/auth/admin/login", { email: adminEmail(), password: PASSWORD });
// The admin has no factor enrolled yet, so this is refused outright.
expect([403, 200]).toContain(challenge.status);
if (challenge.body.challenge_token) {
const res = await get("/api/v1/admin/dashboard", challenge.body.challenge_token);
expect(res.status).toBe(401);
}
});
// Refusing is right, but refusing without a way forward was a dead end: the
// console needs a second factor, and the operator had to already know that
// enrolment lives on the other surface.
it("hands back an enrolment grant instead of a dead end", async () => {
const email = `deadend@authtest-${DOMAIN_ID}.invalid`;
await query(
`INSERT INTO mailboxes (domain_id, local_part, email_address, password_hash, role)
VALUES ($1, 'deadend', $2, $3, 'SUPER_ADMIN') ON CONFLICT (domain_id, local_part) DO NOTHING`,
[DOMAIN_ID, email, await hashPassword(PASSWORD)],
);
const res = await post("/api/v1/auth/admin/login", { email, password: PASSWORD });
expect(res.status).toBe(403);
expect(res.body.error).toBe("MFA_ENROLLMENT_REQUIRED");
const grant = res.body.enrollment_token as unknown as string;
expect(grant).toBeDefined();
// The grant enrols, and the account can then reach the console.
const enroll = await post("/api/v1/user/mfa/totp/enroll", {}, grant);
expect(enroll.status).toBe(200);
const secret = enroll.body.secret as unknown as string;
const confirm = await post("/api/v1/user/mfa/totp/confirm",
{ code: generateHotp(base32Decode(secret), getCurrentStep()) }, grant);
expect(confirm.status).toBe(200);
const mailboxId = (await query<{ id: string }>(
`SELECT id FROM mailboxes WHERE email_address = $1`, [email]))[0].id;
await query(`UPDATE mfa_totp SET last_used_step = NULL WHERE mailbox_id = $1`, [mailboxId]);
const challenge = await post("/api/v1/auth/admin/login", { email, password: PASSWORD });
expect(challenge.body.mfa_required).toBe(true);
const verified = await post("/api/v1/auth/mfa/verify", {
challenge_token: challenge.body.challenge_token,
code: generateHotp(base32Decode(secret), getCurrentStep()),
});
expect(verified.status).toBe(200);
expect((await get("/api/v1/admin/dashboard", verified.body.token)).status).toBe(200);
}, 120000);
// The grant is for enrolling and nothing else.
it("refuses to use an enrolment grant as a session", async () => {
const email = `grantscope@authtest-${DOMAIN_ID}.invalid`;
await query(
`INSERT INTO mailboxes (domain_id, local_part, email_address, password_hash, role)
VALUES ($1, 'grantscope', $2, $3, 'SUPER_ADMIN') ON CONFLICT (domain_id, local_part) DO NOTHING`,
[DOMAIN_ID, email, await hashPassword(PASSWORD)],
);
const res = await post("/api/v1/auth/admin/login", { email, password: PASSWORD });
const grant = res.body.enrollment_token as unknown as string;
expect((await get("/api/v1/admin/dashboard", grant)).status).toBe(401);
expect((await get("/api/v1/user/me", grant)).status).toBe(401);
expect((await get("/api/v1/user/sessions", grant)).status).toBe(401);
expect((await get("/api/v1/user/app-passwords", grant)).status).toBe(401);
}, 90000);
it("grants the admin console after a real second factor", { timeout: 60000 }, async () => {
// Enrollment happens on the user surface, which the admin also owns. The
// existing factor is cleared first, as adminSession does: enrolling over a
// confirmed one leaves two secrets on the account and the code generated
// here is then checked against the wrong one.
await query(`DELETE FROM mfa_totp WHERE mailbox_id = $1`, [ADMIN_ID]);
const userLogin = await post("/api/v1/auth/user/login", { email: adminEmail(), password: PASSWORD });
const secret = await enrollTotp(ADMIN_ID, userLogin.body.token as unknown as string);
const challenge = await post("/api/v1/auth/admin/login", { email: adminEmail(), password: PASSWORD });
expect(challenge.body.mfa_required).toBe(true);
const verified = await post("/api/v1/auth/mfa/verify", {
challenge_token: challenge.body.challenge_token,
code: generateHotp(base32Decode(secret), getCurrentStep()),
});
expect(verified.status).toBe(200);
const dashboard = await get("/api/v1/admin/dashboard", verified.body.token);
expect(dashboard.status).toBe(200);
expect(dashboard.body).toHaveProperty("queue_depth");
// The console's sidebar reads its identity from here. It cannot use
// /api/v1/user/me, because extractToken picks the cookie from the URL
// prefix and an operator may reach /admin/login without ever having opened
// webmail. Until this route existed the sidebar printed a fixed
// "admin@lambdamail.local" with a hardcoded "SUPER_ADMIN (2FA)".
const identity = await get("/api/v1/admin/me", verified.body.token);
expect(identity.status).toBe(200);
expect(identity.body.email).toBe(adminEmail());
expect(identity.body.role).toBe("SUPER_ADMIN");
expect(identity.body.mfa_enrolled).toBe(true);
});
it("refuses /api/v1/admin/me to a user-surface token", async () => {
const login = await post("/api/v1/auth/user/login", { email: userEmail(), password: PASSWORD });
const token = (login.body.token ?? login.body.challenge_token) as unknown as string;
const res = await get("/api/v1/admin/me", token);
expect(res.status).toBe(401);
});
it("locks the account after repeated failures", async () => {
const email = `lock@authtest-${DOMAIN_ID}.invalid`;
await query(
`INSERT INTO mailboxes (domain_id, local_part, email_address, password_hash)
VALUES ($1, 'lock', $2, $3)`,
[DOMAIN_ID, email, await hashPassword(PASSWORD)],
);
for (let i = 0; i < 5; i++) {
await post("/api/v1/auth/user/login", { email, password: "wrong" });
}
// Even the correct password is refused while the lockout holds.
const res = await post("/api/v1/auth/user/login", { email, password: PASSWORD });
expect(res.status).toBe(423);
expect(res.body.error).toBe("ACCOUNT_LOCKED");
});
// Redeemed at the admin sign-in, which is where a second factor is collected
// now. The account is an admin for the same reason.
it("consumes a recovery code exactly once", { timeout: 60000 }, async () => {
const email = `rec@authtest-${DOMAIN_ID}.invalid`;
const id = crypto.randomUUID();
await query(
`INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash, role)
VALUES ($1, $2, 'rec', $3, $4, 'SUPER_ADMIN')`,
[id, DOMAIN_ID, email, await hashPassword(PASSWORD)],
);
const login = await post("/api/v1/auth/user/login", { email, password: PASSWORD });
const confirm = await post("/api/v1/user/mfa/totp/confirm", { code: "x" }, login.body.token);
expect(confirm.status).toBe(400);
const enroll = await post("/api/v1/user/mfa/totp/enroll", {}, login.body.token);
const code = generateHotp(base32Decode(enroll.body.secret as unknown as string), getCurrentStep());
const confirmed = await post("/api/v1/user/mfa/totp/confirm", { code }, login.body.token);
const recoveryCode = (confirmed.body.recovery_codes as unknown as string[])[0];
const challenge = await post("/api/v1/auth/admin/login", { email, password: PASSWORD });
const first = await post("/api/v1/auth/mfa/verify", {
challenge_token: challenge.body.challenge_token,
code: recoveryCode,
});
expect(first.status).toBe(200);
const challenge2 = await post("/api/v1/auth/admin/login", { email, password: PASSWORD });
const second = await post("/api/v1/auth/mfa/verify", {
challenge_token: challenge2.body.challenge_token,
code: recoveryCode,
});
expect(second.status).toBe(401);
});
it("creates a mailbox with its standard folders and audits it", async () => {
const admin = await adminSession();
const local = `created${Date.now()}`;
const created = await post("/api/v1/admin/mailboxes", {
domain_id: DOMAIN_ID, local_part: local, password: "a-long-enough-password", role: "USER",
}, admin);
expect(created.status).toBe(201);
// Delivery depends on these existing: a mailbox with no Junk folder used
// to fail the spam path outright, and one with no Reports folder files
// every delivered DMARC and TLS-RPT report into the inbox instead.
//
// Asserted on the name rather than the special-use role: IMAP defines no
// role for Reports, so its role is NULL and a list of roles cannot say
// whether the folder is there.
const folders = await query<{ name: string; special_use: string | null }>(
`SELECT name, special_use FROM folders WHERE mailbox_id = $1`, [created.body.id]);
expect(folders.map((f) => f.name).sort()).toEqual(
["Archive", "Drafts", "INBOX", "Junk", "Reports", "Sent", "Trash"]);
expect(folders.map((f) => f.special_use).filter(Boolean).sort()).toEqual(
["archive", "drafts", "inbox", "junk", "sent", "trash"]);
const audit = await get("/api/v1/admin/audit", admin);
const entries = audit.body as unknown as Array<{ action: string; target_id: string }>;
expect(entries.some((e) => e.action === "mailbox.create" && e.target_id === created.body.id)).toBe(true);
}, 90000);
it("refuses a weak password and a duplicate address", async () => {
const admin = await adminSession();
const weak = await post("/api/v1/admin/mailboxes",
{ domain_id: DOMAIN_ID, local_part: "weakpass", password: "short" }, admin);
expect(weak.status).toBe(400);
const dup = await post("/api/v1/admin/mailboxes",
{ domain_id: DOMAIN_ID, local_part: "user", password: "a-long-enough-password" }, admin);
expect(dup.status).toBe(409);
}, 90000);
// A DOMAIN_ADMIN naming another domain in the body must not escape its scope.
it("keeps a domain admin inside its own domain", async () => {
const otherDomain = "44444444-4444-4444-4444-444444444444";
const otherName = `other-${otherDomain}.invalid`;
await query(`DELETE FROM domains WHERE id = $1`, [otherDomain]);
await query(`INSERT INTO domains (id, name, punycode_name) VALUES ($1, $2, $3)`,
[otherDomain, otherName, otherName]);
const domainAdminEmail = `dadmin@authtest-${DOMAIN_ID}.invalid`;
await query(
`INSERT INTO mailboxes (domain_id, local_part, email_address, password_hash, role)
VALUES ($1, 'dadmin', $2, $3, 'DOMAIN_ADMIN')
ON CONFLICT (domain_id, local_part) DO NOTHING`,
[DOMAIN_ID, domainAdminEmail, await hashPassword(PASSWORD)],
);
const userLogin = await post("/api/v1/auth/user/login", { email: domainAdminEmail, password: PASSWORD });
const secret = await enrollTotp(
(await query<{ id: string }>(`SELECT id FROM mailboxes WHERE email_address = $1`, [domainAdminEmail]))[0].id,
userLogin.body.token as unknown as string);
const challenge = await post("/api/v1/auth/admin/login", { email: domainAdminEmail, password: PASSWORD });
const verified = await post("/api/v1/auth/mfa/verify", {
challenge_token: challenge.body.challenge_token,
code: generateHotp(base32Decode(secret), getCurrentStep()),
});
const token = verified.body.token as unknown as string;
const escape = await post("/api/v1/admin/mailboxes",
{ domain_id: otherDomain, local_part: "intruder", password: "a-long-enough-password" }, token);
expect(escape.status).toBe(403);
// Its own domain still works.
const own = await post("/api/v1/admin/mailboxes",
{ domain_id: DOMAIN_ID, local_part: `own${Date.now()}`, password: "a-long-enough-password" }, token);
expect(own.status).toBe(201);
await query(`DELETE FROM domains WHERE id = $1`, [otherDomain]);
}, 120000);
it("onboards a domain with the four aliases its DNS records name", async () => {
const admin = await adminSession();
const name = `onboard-${Date.now()}.test`;
const res = await post("/api/v1/admin/domains/onboard", { domain: name }, admin);
expect(res.status).toBe(200);
const aliases = await query<{ source_address: string; destination_addresses: string[] }>(
`SELECT source_address, destination_addresses FROM aliases a
JOIN domains d ON d.id = a.domain_id WHERE d.name = $1 ORDER BY a.source_address`,
[name],
);
// PLAN.md section 7.4b: publishing rua=mailto:dmarc@ without the alias
// makes the reports bounce and receivers stop sending them.
expect(aliases.map((a) => a.source_address.split("@")[0]).sort())
.toEqual(["abuse", "dmarc", "postmaster", "tlsrpt"]);
expect(aliases[0].destination_addresses[0]).toBe(adminEmail());
await query(`DELETE FROM domains WHERE name = $1`, [name]);
}, 90000);
// The destination array used to be built by string interpolation, so a
// domain name carrying a quote was injected into the statement.
it("does not let a hostile domain name reach the SQL text", async () => {
const admin = await adminSession();
for (const hostile of ["x.test'||(SELECT version())||'", "a.test; DROP TABLE aliases;--", "no-dot"]) {
const res = await post("/api/v1/admin/domains/onboard", { domain: hostile }, admin);
expect(res.status).toBe(400);
}
// The table is still there, which a successful injection would not leave.
const check = await query<{ count: string }>(`SELECT count(*)::text AS count FROM aliases`);
expect(Number(check[0].count)).toBeGreaterThanOrEqual(0);
}, 90000);
it("refuses to let a domain admin create domains", async () => {
const domainAdminEmail = `donboard@authtest-${DOMAIN_ID}.invalid`;
await query(
`INSERT INTO mailboxes (domain_id, local_part, email_address, password_hash, role)
VALUES ($1, 'donboard', $2, $3, 'DOMAIN_ADMIN') ON CONFLICT (domain_id, local_part) DO NOTHING`,
[DOMAIN_ID, domainAdminEmail, await hashPassword(PASSWORD)],
);
const mailboxId = (await query<{ id: string }>(
`SELECT id FROM mailboxes WHERE email_address = $1`, [domainAdminEmail]))[0].id;
await query(`DELETE FROM mfa_totp WHERE mailbox_id = $1`, [mailboxId]);
const userLogin = await post("/api/v1/auth/user/login", { email: domainAdminEmail, password: PASSWORD });
const secret = await enrollTotp(mailboxId, userLogin.body.token as unknown as string);
const challenge = await post("/api/v1/auth/admin/login", { email: domainAdminEmail, password: PASSWORD });
const verified = await post("/api/v1/auth/mfa/verify", {
challenge_token: challenge.body.challenge_token,
code: generateHotp(base32Decode(secret), getCurrentStep()),
});
const res = await post("/api/v1/admin/domains/onboard",
{ domain: `escape-${Date.now()}.test` }, verified.body.token);
expect(res.status).toBe(400);
}, 120000);
it("never returns the TOTP secret to an unauthenticated caller", async () => {
const res = await post("/api/v1/user/mfa/totp/enroll", {});
expect(res.status).toBe(401);
});
// A JWT carries its own validity, so revoking a session must be enforced on
// every request or "sign out this device" would change nothing.
it("stops accepting a token once its session is revoked", async () => {
const email = `revoke@authtest-${DOMAIN_ID}.invalid`;
await query(
`INSERT INTO mailboxes (domain_id, local_part, email_address, password_hash)
VALUES ($1, 'revoke', $2, $3)`,
[DOMAIN_ID, email, await hashPassword(PASSWORD)],
);
const login = await post("/api/v1/auth/user/login", { email, password: PASSWORD });
const token = login.body.token as unknown as string;
const before = await get("/api/v1/user/me", token);
expect(before.status).toBe(200);
const sessions = await get("/api/v1/user/sessions", token);
expect(sessions.status).toBe(200);
const list = sessions.body as unknown as Array<{ id: string; current: boolean }>;
expect(list.length).toBeGreaterThan(0);
expect(list.some((s) => s.current)).toBe(true);
const revoked = await fetch(`${base}/api/v1/user/sessions/${list[0].id}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
expect(revoked.status).toBe(200);
const after = await get("/api/v1/user/me", token);
expect(after.status).toBe(401);
expect(after.body.error).toBe("SESSION_REVOKED");
}, 60000);
it("changes the password only with the current one, and revokes other sessions", async () => {
const email = `pw@authtest-${DOMAIN_ID}.invalid`;
await query(
`INSERT INTO mailboxes (domain_id, local_part, email_address, password_hash)
VALUES ($1, 'pw', $2, $3)`,
[DOMAIN_ID, email, await hashPassword(PASSWORD)],
);
const first = await post("/api/v1/auth/user/login", { email, password: PASSWORD });
const second = await post("/api/v1/auth/user/login", { email, password: PASSWORD });
const wrong = await put("/api/v1/user/password",
{ current_password: "not-it", new_password: "a-brand-new-password-1" }, first.body.token);
expect(wrong.status).toBe(401);
const tooShort = await put("/api/v1/user/password",
{ current_password: PASSWORD, new_password: "short" }, first.body.token);
expect(tooShort.status).toBe(400);
const ok = await put("/api/v1/user/password",
{ current_password: PASSWORD, new_password: "a-brand-new-password-1" }, first.body.token);
expect(ok.status).toBe(200);
// The other session is gone; the one that made the change still works.
const other = await get("/api/v1/user/me", second.body.token);
expect(other.status).toBe(401);
const mine = await get("/api/v1/user/me", first.body.token);
expect(mine.status).toBe(200);
const relogin = await post("/api/v1/auth/user/login", { email, password: "a-brand-new-password-1" });
expect(relogin.status).toBe(200);
}, 90000);
it("manages user preferences, Sieve scripts, and vacation autoresponder", async () => {
// A mailbox of its own: the shared fixture has a second factor enrolled by
// an earlier test, so its login returns a challenge rather than a session
// and this depended on test order to pass.
const email = `prefs@authtest-${DOMAIN_ID}.invalid`;
await query(
`INSERT INTO mailboxes (domain_id, local_part, email_address, password_hash)
VALUES ($1, 'prefs', $2, $3) ON CONFLICT (domain_id, local_part) DO NOTHING`,
[DOMAIN_ID, email, await hashPassword(PASSWORD)],
);
const login = await post("/api/v1/auth/user/login", { email, password: PASSWORD });
const token = login.body.token as unknown as string;
expect(token).toBeDefined();
const prefs = await get("/api/v1/user/preferences", token);
expect(prefs.status).toBe(200);
const updatePrefs = await post("/api/v1/user/preferences", { signature: "-- Best Regards", auto_save_drafts: true }, token);
expect(updatePrefs.status).toBe(200);
const sieve = await get("/api/v1/user/sieve", token);
expect(sieve.status).toBe(200);
const saveVacation = await post("/api/v1/user/vacation", { enabled: true, subject: "On Holiday", message: "Back next week" }, token);
expect(saveVacation.status).toBe(200);
}, 60000);
it("handles admin bulk import, domain onboarding, DKIM rotation, TLS and Rspamd thresholds", async () => {
const admin = await adminSession();
const domainName = `newdomain-${Date.now()}.test`;
const onboard = await post("/api/v1/admin/domains/onboard", { domain: domainName }, admin);
expect(onboard.status).toBe(200);
// The response used to claim "records: 13"; nothing here touches DNS, and
// the reconciler in the protocols service is what publishes those.
expect(onboard.body.aliases).toHaveLength(4);
const reconcile = await post("/api/v1/admin/domains/reconcile", { domain_id: onboard.body.id }, admin);
expect(reconcile.status).toBe(200);
const bulk = await post("/api/v1/admin/mailboxes/bulk-import", {
rows: [{ email: `bulk1@${domainName}`, role: "USER", quota_mb: 1024 }],
}, admin);
expect(bulk.status).toBe(200);
expect(bulk.body.imported).toBe(1);
// DKIM rotation and the TLS panel are served by the protocols service,
// which owns the vault the private key is sealed with and the certificate
// watcher. They are covered by that service's own tests.
const getRspamd = await get("/api/v1/admin/rspamd/thresholds", admin);
expect(getRspamd.status).toBe(200);
const updateRspamd = await post("/api/v1/admin/rspamd/thresholds", { greylist: 4.5, add_header: 6.5, reject: 14.0 }, admin);
expect(updateRspamd.status).toBe(200);
await query(`DELETE FROM domains WHERE name = $1`, [domainName]);
}, 90000);
describe("paging, filtering and editing users", () => {
// A domain of its own, so these row counts cannot disturb the tests above
// that assert on the seeded one. Signing in costs seconds of deliberate
// password hashing, so it is done once for the whole suite.
const PAGED_DOMAIN = "55555555-5555-5555-5555-555555555555";
let admin: string;
beforeAll(async () => {
admin = await adminSession();
const hash = await hashPassword(PASSWORD);
const name = `pagedtest-${PAGED_DOMAIN}.invalid`;
await query(`DELETE FROM domains WHERE id = $1`, [PAGED_DOMAIN]);
await query(`INSERT INTO domains (id, name, punycode_name) VALUES ($1, $2, $3)`, [PAGED_DOMAIN, name, name]);
// Enough rows that one hardcoded page cannot hold them, which is the
// condition the console could not cope with at all.
for (let i = 0; i < 30; i++) {
const local = `paged${String(i).padStart(2, "0")}`;
await query(
`INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash, role, is_active)
VALUES (gen_random_uuid(), $1, $2, $3, $4, 'USER', $5)`,
[PAGED_DOMAIN, local, `${local}@${name}`, hash, i % 2 === 0],
);
}
}, 90000);
afterAll(async () => {
await query(`DELETE FROM domains WHERE id = $1`, [PAGED_DOMAIN]).catch(() => undefined);
});
it("returns one page at a time, with the total of the whole set", async () => {
const first = await get("/api/v1/admin/mailboxes?page=1&page_size=10", admin);
expect(first.status).toBe(200);
const body = first.body as unknown as { items: unknown[]; total: number; total_pages: number };
expect(body.items).toHaveLength(10);
// The total describes every row, not the ten that came back; otherwise
// the page control cannot know there is anything after this page.
expect(body.total).toBeGreaterThanOrEqual(32);
expect(body.total_pages).toBeGreaterThan(1);
});
it("gives a different page for a different page number", async () => {
const one = await get("/api/v1/admin/mailboxes?page=1&page_size=5", admin);
const two = await get("/api/v1/admin/mailboxes?page=2&page_size=5", admin);
const ids = (r: typeof one) => (r.body as unknown as { items: { id: string }[] }).items.map((i) => i.id);
expect(ids(one)).not.toEqual(ids(two));
expect(ids(one).some((id) => ids(two).includes(id))).toBe(false);
});
it("filters in the database rather than returning everything", async () => {
const res = await get("/api/v1/admin/mailboxes?search=paged1", admin);
const body = res.body as unknown as { items: { email_address: string }[]; total: number };
expect(body.total).toBe(10); // paged10..paged19
for (const item of body.items) {
expect(item.email_address).toContain("paged1");
}
});
it("filters by whether the user is enabled", async () => {
const res = await get("/api/v1/admin/mailboxes?active=false&page_size=200", admin);
const body = res.body as unknown as { items: { is_active: boolean }[] };
expect(body.items.length).toBeGreaterThan(0);
for (const item of body.items) expect(item.is_active).toBe(false);
});
// A search term is bound as a parameter. If it were interpolated, this ends
// the statement and drops the table.
it("does not let a search term reach the SQL text", async () => {
const evil = encodeURIComponent("'; DROP TABLE mailboxes; --");
const res = await get(`/api/v1/admin/mailboxes?search=${evil}`, admin);
expect(res.status).toBe(200);
expect((res.body as unknown as { total: number }).total).toBe(0);
const still = await query(`SELECT count(*)::int AS c FROM mailboxes`);
expect((still[0] as { c: number }).c).toBeGreaterThan(0);
});
it("edits a user instead of only disabling them", async () => {
const res = await fetch(`${base}/api/v1/admin/mailboxes/${USER_ID}`, {
method: "PATCH",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${admin}` },
body: JSON.stringify({ quota_bytes: 12345678, locale: "pt-BR", role: "DOMAIN_ADMIN" }),
});
expect(res.status).toBe(200);
const rows = await query<{ quota_bytes: string; locale: string; role: string }>(
`SELECT quota_bytes::text, locale, role FROM mailboxes WHERE id = $1`,
[USER_ID],
);
expect(Number(rows[0].quota_bytes)).toBe(12345678);
expect(rows[0].locale).toBe("pt-BR");
expect(rows[0].role).toBe("DOMAIN_ADMIN");
await query(`UPDATE mailboxes SET role = 'USER' WHERE id = $1`, [USER_ID]);
});
it("refuses a quota that is not a positive whole number", async () => {
const res = await fetch(`${base}/api/v1/admin/mailboxes/${USER_ID}`, {
method: "PATCH",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${admin}` },
body: JSON.stringify({ quota_bytes: -1 }),
});
expect(res.status).toBe(400);
});
it("lists the aliases that deliver to one user", async () => {
const mine = `alias-of-user@authtest-${DOMAIN_ID}.invalid`;
const other = `alias-of-nobody@authtest-${DOMAIN_ID}.invalid`;
await query(
`INSERT INTO aliases (domain_id, source_address, destination_addresses)
VALUES ($1, $2, ARRAY[$3]), ($1, $4, ARRAY['somebody-else@elsewhere.invalid'])
ON CONFLICT DO NOTHING`,
[DOMAIN_ID, mine, userEmail(), other],
);
const res = await get(`/api/v1/admin/mailboxes/${USER_ID}/aliases`, admin);
expect(res.status).toBe(200);
const items = res.body as unknown as { source_address: string }[];
expect(items.map((a) => a.source_address)).toContain(mine);
// An alias pointing somewhere else is not this user's, even though it
// belongs to the same domain.
expect(items.map((a) => a.source_address)).not.toContain(other);
});
it("runs the server checks and reports the worst thing it found", async () => {
const res = await get("/api/v1/admin/diagnostics/server", admin);
expect(res.status).toBe(200);
const body = res.body as unknown as { status: string; checks: { key: string }[] };
expect(body.checks.length).toBeGreaterThan(0);
expect(["PASS", "WARN", "FAIL", "INFO"]).toContain(body.status);
// The seeded domain has no DKIM key, which is a real finding rather than
// something the page should stay quiet about.
expect(body.checks.map((c) => c.key)).toContain("dkimMissing");
});
it("runs the checks for one user", async () => {
const res = await get(`/api/v1/admin/diagnostics/user/${USER_ID}`, admin);
expect(res.status).toBe(200);
const body = res.body as unknown as { subject: string; checks: { key: string }[] };
expect(body.subject).toBe(userEmail());
expect(body.checks.map((c) => c.key)).toContain("userActive");
});
it("refuses diagnostics for a user that does not exist", async () => {
const res = await get("/api/v1/admin/diagnostics/user/44444444-4444-4444-4444-444444444444", admin);
expect(res.status).toBe(404);
});
// One mailbox commonly receives postmaster, abuse, dmarc and tlsrpt for
// several domains at once. Scoping the lookup to the domain the user's own
// address sits in hid every alias belonging to the others.
it("lists aliases from every domain, not only the user's own", async () => {
const mine = `cross-domain-alias@pagedtest-${PAGED_DOMAIN}.invalid`;
await query(
`INSERT INTO aliases (domain_id, source_address, destination_addresses)
VALUES ($1, $2, ARRAY[$3]) ON CONFLICT DO NOTHING`,
[PAGED_DOMAIN, mine, userEmail()],
);
const res = await get(`/api/v1/admin/mailboxes/${USER_ID}/aliases`, admin);
expect(res.status).toBe(200);
const sources = (res.body as unknown as { source_address: string }[]).map((a) => a.source_address);
// USER_ID lives in DOMAIN_ID; this alias lives in PAGED_DOMAIN and
// delivers to them all the same.
expect(sources).toContain(mine);
});
it("answers 404 for a user that does not exist rather than leaking the difference", async () => {
const res = await get(`/api/v1/admin/mailboxes/44444444-4444-4444-4444-444444444444/aliases`, admin);
expect(res.status).toBe(404);
});
});
});
// Guards that need no database, so they still run in a bare checkout.
describe("surface routing", () => {
it("ignores anything outside /api/v1/", async () => {
const { handleApiRequest: handler } = await import("./router.js");
const req = { url: "/health/ready", method: "GET", headers: {} } as never;
const res = { setHeader() {}, end() {} } as never;
expect(handler(req, res)).toBe(false);
});
});
/**
* What each surface costs to open, and what a session grants once opened.
*
* Two rules made the app tiresome in a way no unit test could see. Reading your
* own mail demanded a second factor, which the mailbox does not require - only
* the console does. And a session issued at the admin sign-in carried the admin
* audience alone, so leaving the console landed on the webmail's login screen,
* asking for a password from somebody who had just proven a password and a
* second factor a moment earlier.
*/
describeDb("moving between the surfaces", () => {
const stepUpEmail = () => `stepup@authtest-${DOMAIN_ID}.invalid`;
const plainEmail = () => `plainuser@authtest-${DOMAIN_ID}.invalid`;
let stepUpId = "";
/** Reads the Set-Cookie headers, which is where the two sessions land. */
async function postForCookies(path: string, body: unknown, token?: string) {
const res = await fetch(`${base}${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(body),
});
return {
status: res.status,
cookies: res.headers.getSetCookie(),
body: (await res.json().catch(() => ({}))) as Record<string, never>,
};
}
const cookieNames = (cookies: string[]) => cookies.map((c) => c.split("=")[0]).sort();
/**
* One session per account, taken once.
*
* Every login costs an Argon2 verify, and this service deliberately bounds
* how many of those run at a time, so a test file that signs in per assertion
* spends its time queueing behind itself. A session does not expire during
* the run, so reusing it tests the same thing and leaves the budget for the
* assertions that need a fresh sign-in.
*/
let stepUpToken = "";
let plainToken = "";
let plainId = "";
beforeAll(async () => {
await startServer();
await seed();
const hash = await hashPassword(PASSWORD);
for (const [local, role] of [["stepup", "SUPER_ADMIN"], ["plainuser", "USER"]] as const) {
await query(
`INSERT INTO mailboxes (domain_id, local_part, email_address, password_hash, role)
VALUES ($1, $2, $3, $4, $5) ON CONFLICT (domain_id, local_part) DO NOTHING`,
[DOMAIN_ID, local, `${local}@authtest-${DOMAIN_ID}.invalid`, hash, role],
);
}
stepUpId = (await query<{ id: string }>(
`SELECT id FROM mailboxes WHERE email_address = $1`, [stepUpEmail()]))[0].id;
plainId = (await query<{ id: string }>(
`SELECT id FROM mailboxes WHERE email_address = $1`, [plainEmail()]))[0].id;
stepUpToken = (await post("/api/v1/auth/user/login",
{ email: stepUpEmail(), password: PASSWORD })).body.token as unknown as string;
plainToken = (await post("/api/v1/auth/user/login",
{ email: plainEmail(), password: PASSWORD })).body.token as unknown as string;
}, 120000);
afterAll(async () => {
server?.close();
await query(`DELETE FROM domains WHERE id = $1`, [DOMAIN_ID]).catch(() => undefined);
await closePool();
});
// The mailbox is one account's own mail behind that account's own password.
// Demanding a code as well meant an enrolled account could not read its mail
// without its phone.
it("opens the webmail on the password alone, even with a factor enrolled", async () => {
await enrollTotp(stepUpId, stepUpToken);
const login = await post("/api/v1/auth/user/login", { email: stepUpEmail(), password: PASSWORD });
expect(login.status).toBe(200);
expect(login.body.mfa_required).toBeUndefined();
expect(login.body.token).toBeDefined();
// And the session it hands out is one the API actually accepts.
expect((await get("/api/v1/user/me", login.body.token)).status).toBe(200);
}, 120000);
// A webmail session is still not an admin session; that is the isolation the
// whole audience split exists for.
it("does not let the webmail session open the console by itself", async () => {
expect((await get("/api/v1/admin/dashboard", stepUpToken)).status).toBe(401);
});
// The sign-in checked the password and the second factor and never the role,
// so an ordinary account that had enrolled a factor - which any account may
// do from its own settings - could sign in at /admin/login and be handed an
// admin-audience session. The step-up checks the role; this path did not.
it("refuses an ordinary account at the admin sign-in, factor or no factor", async () => {
await query(`DELETE FROM mfa_totp WHERE mailbox_id = $1`, [plainId]);
const secret = await enrollTotp(plainId, plainToken);
const attempt = await post("/api/v1/auth/admin/login", { email: plainEmail(), password: PASSWORD });
expect(attempt.status).toBe(403);
expect(attempt.body.error).toBe("FORBIDDEN");
// And no challenge came back that could be verified into one anyway.
expect(attempt.body.challenge_token).toBeUndefined();
void secret;
}, 120000);
it("refuses a step-up without a webmail session behind it", async () => {
expect((await post("/api/v1/auth/admin/step-up", { code: "123456" })).status).toBe(401);
});
it("refuses a step-up with the wrong code, and never on the password alone", async () => {
const res = await post("/api/v1/auth/admin/step-up", { code: "000000" }, stepUpToken);
expect(res.status).toBe(401);
expect(res.body.error).toBe("INVALID_MFA_CODE");
});
// The role is read from the database rather than from the token, so an
// ordinary account cannot cross over however valid its session is.
it("refuses a step-up from an account that may not open the console", async () => {
const res = await post("/api/v1/auth/admin/step-up", { code: "123456" }, plainToken);
expect(res.status).toBe(403);
});
// The point of the step-up: the second factor, and not the password, which
// the session in hand proved already.
it("crosses into the console on the second factor alone", async () => {
await query(`DELETE FROM mfa_totp WHERE mailbox_id = $1`, [stepUpId]);
const secret = await enrollTotp(stepUpId, stepUpToken);
const stepUp = await postForCookies(
"/api/v1/auth/admin/step-up",
{ code: generateHotp(base32Decode(secret), getCurrentStep()) },
stepUpToken,
);
expect(stepUp.status).toBe(200);
expect((await get("/api/v1/admin/dashboard", stepUp.body.token)).status).toBe(200);
// Both sessions come back, which is what makes leaving the console free.
expect(cookieNames(stepUp.cookies)).toEqual(["lm_admin_session", "lm_user_session"]);
}, 120000);
// Proving a password and a second factor is strictly more than the webmail
// asks for, so it must not leave the operator without a webmail session.
it("issues a webmail session at the admin sign-in too", async () => {
await query(`DELETE FROM mfa_totp WHERE mailbox_id = $1`, [stepUpId]);
const secret = await enrollTotp(stepUpId, stepUpToken);
const challenge = await post("/api/v1/auth/admin/login", { email: stepUpEmail(), password: PASSWORD });
expect(challenge.body.mfa_required).toBe(true);
const verified = await postForCookies("/api/v1/auth/mfa/verify", {
challenge_token: challenge.body.challenge_token,
code: generateHotp(base32Decode(secret), getCurrentStep()),
});
expect(verified.status).toBe(200);
expect(cookieNames(verified.cookies)).toEqual(["lm_admin_session", "lm_user_session"]);
// The webmail cookie has to be a webmail token, not the admin one copied
// across: an admin-audience token is refused by every /user route.
const userCookie = verified.cookies.find((c) => c.startsWith("lm_user_session="))!;
const userToken = userCookie.slice("lm_user_session=".length).split(";")[0];
expect((await get("/api/v1/user/me", userToken)).status).toBe(200);
}, 120000);
// Signing in twice in the same second produced a byte-identical token, which
// collided with the unique index on web_sessions.refresh_token_hash and
// answered 500 to a correct password. A double-clicked sign-in was enough.
it("survives two sign-ins in the same second", async () => {
const [first, second] = await Promise.all([
post("/api/v1/auth/user/login", { email: plainEmail(), password: PASSWORD }),
post("/api/v1/auth/user/login", { email: plainEmail(), password: PASSWORD }),
]);
expect(first.status).toBe(200);
expect(second.status).toBe(200);
expect(first.body.token).not.toBe(second.body.token);
}, 60000);
});
if (!hasDatabase) {
// eslint-disable-next-line no-console
console.warn("TEST_DATABASE_URL not set - auth API integration tests skipped");
}
// generateTotpSecret is exercised through the enrollment flow above; this
// keeps the import meaningful when the database suite is skipped.
void generateTotpSecret;