feat(admin): paginacao na interface, edicao de utilizadores e pagina de testes

- fila e utilizadores passam a pedir uma pagina de cada vez, com pesquisa e
  filtros; nada e cortado no browser, porque o que faltava nunca chegou a ser
  buscado. A pesquisa espera que se pare de escrever: cada tecla seria mais
  uma consulta, e as respostas podem chegar fora de ordem
- pageWindow e pageRange tem testes proprios: uma pagina fora do intervalo vem
  de um URL velho ou de uma lista que encolheu, e nao pode dar um paginador
  vazio ou negativo, nem dizer "76 a 100 de 82"
- editar utilizador: papel, quota, idioma, com os apelidos que entregam
  naquela caixa a mostra no mesmo painel
- "Caixas de e-mail" passa a chamar-se Utilizadores, que e o que sao

Pagina nova /admin/tests: verificacoes que o operador corre a mao, sobre o
servidor ou sobre um utilizador. Todas leem estado; nenhuma envia correio nem
altera definicoes -- uma pagina de diagnostico que mexe naquilo que
diagnostica e pior do que nao existir.

As regras de cada verificacao sao funcoes puras com testes, separadas da
recolha dos factos. Alguns casos que estes testes fixam: uma quota zero
significa sem limite, e um racio marcaria todas as caixas ilimitadas como
cheias; um bloqueio ja expirado nao bloqueia ninguem e nao deve mandar
ninguem investigar; e uma resposta de ausencia ligada com o periodo ja
passado esta ligada sem fazer nada, que e o que parece avariado a quem a pos.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Opus 5 committed 2026-08-24 09:36:35 -03:00
1 parent 52bf43b74f
commit 2b92abd185
16 files changed
+1431 -153

No files matched your search

+117
View File
@@ -0,0 +1,117 @@
import { describe, it, expect } from "vitest";
import { checkUser, checkServer, overallStatus, type UserFacts, type ServerFacts } from "./diagnostics.js";
const NOW = new Date("2026-06-15T12:00:00Z");
const user = (over: Partial<UserFacts> = {}): UserFacts => ({
email: "ana@example.test",
isActive: true,
lockedUntil: null,
quotaBytes: 1000,
usedBytes: 100,
mfaEnrolled: true,
aliasCount: 0,
sieveScriptBytes: null,
vacationEnabled: false,
vacationStart: null,
vacationEnd: null,
passwordHashPresent: true,
...over,
});
const keys = (checks: { key: string }[]) => checks.map((c) => c.key);
const statusOf = (checks: { key: string; status: string }[], key: string) =>
checks.find((c) => c.key === key)?.status;
describe("user checks", () => {
it("passes a healthy account", () => {
const checks = checkUser(user(), NOW);
expect(overallStatus(checks)).toBe("PASS");
});
it("fails a disabled account", () => {
expect(statusOf(checkUser(user({ isActive: false }), NOW), "userDisabled")).toBe("FAIL");
});
// A lockout that has already elapsed no longer blocks anyone; reporting it
// as a failure sends an operator chasing a problem that resolved itself.
it("distinguishes a spent lockout from a live one", () => {
const spent = checkUser(user({ lockedUntil: "2026-06-15T11:00:00Z" }), NOW);
expect(keys(spent)).toContain("userNotLockedOut");
const live = checkUser(user({ lockedUntil: "2026-06-15T13:00:00Z" }), NOW);
expect(statusOf(live, "userLockedOut")).toBe("FAIL");
});
// Zero means unlimited. A ratio would divide by zero and mark every
// unlimited mailbox as full.
it("does not call an unlimited mailbox full", () => {
const checks = checkUser(user({ quotaBytes: 0, usedBytes: 5_000_000 }), NOW);
expect(keys(checks)).toContain("quotaUnlimited");
expect(keys(checks)).not.toContain("quotaFull");
});
it("warns before the quota is reached and fails once it is", () => {
expect(statusOf(checkUser(user({ quotaBytes: 100, usedBytes: 95 }), NOW), "quotaNearlyFull")).toBe("WARN");
expect(statusOf(checkUser(user({ quotaBytes: 100, usedBytes: 100 }), NOW), "quotaFull")).toBe("FAIL");
});
// Switched on with a window that has closed is the state that reads as
// broken to whoever set it, so it is called out rather than shown as active.
it("notices an autoresponder whose window has passed", () => {
const checks = checkUser(
user({ vacationEnabled: true, vacationEnd: "2026-01-01T00:00:00Z" }),
NOW,
);
expect(statusOf(checks, "vacationWindowPast")).toBe("WARN");
});
it("does not report an autoresponder that is switched off", () => {
expect(keys(checkUser(user(), NOW)).some((k) => k.startsWith("vacation"))).toBe(false);
});
});
const server = (over: Partial<ServerFacts> = {}): ServerFacts => ({
domains: [{ name: "example.test", dnsStatus: "VERIFIED", daneEnabled: false }],
queueByStatus: {},
usersOverQuota: 0,
usersTotal: 3,
domainsWithoutDkim: [],
...over,
});
describe("server checks", () => {
it("passes a healthy server", () => {
expect(overallStatus(checkServer(server()))).toBe("PASS");
});
it("fails a domain whose DNS is not verified", () => {
const checks = checkServer(server({ domains: [{ name: "bad.test", dnsStatus: "ERROR", daneEnabled: false }] }));
expect(statusOf(checks, "dnsNotVerified")).toBe("FAIL");
});
// Unsigned mail from a domain that publishes DMARC is treated as a failure
// by most large receivers, so a missing key is not a warning.
it("fails a domain with no DKIM key", () => {
expect(statusOf(checkServer(server({ domainsWithoutDkim: ["example.test"] })), "dkimMissing")).toBe("FAIL");
});
it("reports frozen jobs as a failure and deferred ones as a warning", () => {
const checks = checkServer(server({ queueByStatus: { FROZEN: 2, DEFERRED: 5 } }));
expect(statusOf(checks, "queueHasFrozen")).toBe("FAIL");
expect(statusOf(checks, "queueHasDeferred")).toBe("WARN");
});
it("says so when there are no domains at all", () => {
expect(statusOf(checkServer(server({ domains: [] })), "noDomains")).toBe("WARN");
});
});
describe("overall status", () => {
it("reports the worst thing present", () => {
expect(overallStatus([{ key: "a", status: "PASS" }, { key: "b", status: "WARN" }])).toBe("WARN");
expect(overallStatus([{ key: "a", status: "WARN" }, { key: "b", status: "FAIL" }])).toBe("FAIL");
expect(overallStatus([{ key: "a", status: "INFO" }])).toBe("INFO");
expect(overallStatus([])).toBe("INFO");
});
});
+168
View File
@@ -0,0 +1,168 @@
/**
* Checks an operator can run by hand, against the whole server or one user.
*
* Each check is a pure function over facts already gathered, so the rules can
* be tested without a database and without sending mail. Gathering the facts
* is the repository's job; deciding what they mean is here.
*/
export type CheckStatus = "PASS" | "WARN" | "FAIL" | "INFO";
export interface Check {
/** Stable key: the console translates it, so no English is built here. */
key: string;
status: CheckStatus;
/** What the check looked at - an address, a domain, a count. */
subject?: string;
detail?: string;
}
export interface UserFacts {
email: string;
isActive: boolean;
lockedUntil: string | null;
quotaBytes: number;
usedBytes: number;
mfaEnrolled: boolean;
aliasCount: number;
sieveScriptBytes: number | null;
vacationEnabled: boolean;
vacationStart: string | null;
vacationEnd: string | null;
passwordHashPresent: boolean;
}
/** Quota headroom below this is worth saying out loud before mail bounces. */
export const QUOTA_WARN_RATIO = 0.9;
export function checkUser(facts: UserFacts, now: Date = new Date()): Check[] {
const checks: Check[] = [];
checks.push({
key: facts.isActive ? "userActive" : "userDisabled",
status: facts.isActive ? "PASS" : "FAIL",
subject: facts.email,
});
// A lockout in the past is spent; only a future one still blocks sign-in.
const locked = facts.lockedUntil !== null && new Date(facts.lockedUntil) > now;
checks.push({
key: locked ? "userLockedOut" : "userNotLockedOut",
status: locked ? "FAIL" : "PASS",
detail: locked ? String(facts.lockedUntil) : undefined,
});
checks.push({
key: facts.passwordHashPresent ? "userHasPassword" : "userHasNoPassword",
status: facts.passwordHashPresent ? "PASS" : "FAIL",
});
// A zero quota means unlimited here, so a ratio would divide by zero and
// report every unlimited mailbox as full.
if (facts.quotaBytes > 0) {
const ratio = facts.usedBytes / facts.quotaBytes;
checks.push({
key: ratio >= 1 ? "quotaFull" : ratio >= QUOTA_WARN_RATIO ? "quotaNearlyFull" : "quotaHealthy",
status: ratio >= 1 ? "FAIL" : ratio >= QUOTA_WARN_RATIO ? "WARN" : "PASS",
detail: `${Math.round(ratio * 100)}%`,
});
} else {
checks.push({ key: "quotaUnlimited", status: "INFO" });
}
checks.push({
key: facts.mfaEnrolled ? "mfaEnrolled" : "mfaNotEnrolled",
status: facts.mfaEnrolled ? "PASS" : "INFO",
});
checks.push({
key: "aliasesDelivering",
status: "INFO",
detail: String(facts.aliasCount),
});
if (facts.sieveScriptBytes !== null) {
checks.push({
key: "sieveScriptPresent",
status: "INFO",
detail: `${facts.sieveScriptBytes} B`,
});
}
if (facts.vacationEnabled) {
// An autoresponder whose window has already closed is switched on and
// doing nothing, which reads as broken to whoever set it.
const ended = facts.vacationEnd !== null && new Date(facts.vacationEnd) < now;
const notYet = facts.vacationStart !== null && new Date(facts.vacationStart) > now;
checks.push({
key: ended ? "vacationWindowPast" : notYet ? "vacationWindowFuture" : "vacationActive",
status: ended ? "WARN" : "INFO",
});
}
return checks;
}
export interface ServerFacts {
domains: { name: string; dnsStatus: string; daneEnabled: boolean }[];
queueByStatus: Record<string, number>;
usersOverQuota: number;
usersTotal: number;
domainsWithoutDkim: string[];
}
export function checkServer(facts: ServerFacts): Check[] {
const checks: Check[] = [];
for (const d of facts.domains) {
checks.push({
key: d.dnsStatus === "VERIFIED" ? "dnsVerified" : "dnsNotVerified",
status: d.dnsStatus === "VERIFIED" ? "PASS" : d.dnsStatus === "PENDING" ? "WARN" : "FAIL",
subject: d.name,
detail: d.dnsStatus,
});
}
if (facts.domains.length === 0) {
checks.push({ key: "noDomains", status: "WARN" });
}
for (const name of facts.domainsWithoutDkim) {
// Without a DKIM key the domain signs nothing, and most large receivers
// treat unsigned mail from a domain that publishes DMARC as a failure.
checks.push({ key: "dkimMissing", status: "FAIL", subject: name });
}
const bounced = facts.queueByStatus.BOUNCED ?? 0;
const deferred = facts.queueByStatus.DEFERRED ?? 0;
const frozen = facts.queueByStatus.FROZEN ?? 0;
checks.push({
key: bounced > 0 ? "queueHasBounces" : "queueNoBounces",
status: bounced > 0 ? "WARN" : "PASS",
detail: String(bounced),
});
checks.push({
key: deferred > 0 ? "queueHasDeferred" : "queueNoDeferred",
status: deferred > 0 ? "WARN" : "PASS",
detail: String(deferred),
});
if (frozen > 0) {
checks.push({ key: "queueHasFrozen", status: "FAIL", detail: String(frozen) });
}
checks.push({
key: facts.usersOverQuota > 0 ? "usersOverQuota" : "noUsersOverQuota",
status: facts.usersOverQuota > 0 ? "WARN" : "PASS",
detail: `${facts.usersOverQuota}/${facts.usersTotal}`,
});
return checks;
}
/** The worst status present, which is what a summary badge should show. */
export function overallStatus(checks: Check[]): CheckStatus {
if (checks.some((c) => c.status === "FAIL")) return "FAIL";
if (checks.some((c) => c.status === "WARN")) return "WARN";
if (checks.some((c) => c.status === "PASS")) return "PASS";
return "INFO";
}
+69
View File
@@ -917,6 +917,75 @@ export async function listAliasesForMailbox(
);
}
/** Everything one user's checks need, in a single pass over the database. */
export async function userDiagnosticFacts(
scope: AdminScope,
mailboxId: string,
): Promise<Record<string, unknown> | null> {
const { sql, params } = scopeClause(scope, "m.domain_id");
const row = await queryOne<Record<string, unknown>>(
`SELECT m.email_address, m.is_active, m.locked_until,
m.quota_bytes::text AS quota_bytes, m.used_bytes::text AS used_bytes,
(m.password_hash IS NOT NULL AND m.password_hash <> '') AS password_hash_present,
EXISTS (SELECT 1 FROM mfa_totp t WHERE t.mailbox_id = m.id AND t.status = 'CONFIRMED') AS mfa_enrolled,
(SELECT count(*) FROM aliases a
WHERE a.domain_id = m.domain_id
AND EXISTS (SELECT 1 FROM unnest(a.destination_addresses) d
WHERE lower(d) = lower(m.email_address)))::int AS alias_count,
(SELECT length(sc.script) FROM sieve_scripts sc
WHERE sc.mailbox_id = m.id AND sc.is_active
AND sc.name <> 'vacation-autoresponder' LIMIT 1) AS sieve_bytes,
EXISTS (SELECT 1 FROM sieve_scripts sc
WHERE sc.mailbox_id = m.id AND sc.is_active
AND sc.name = 'vacation-autoresponder') AS vacation_enabled
FROM mailboxes m
WHERE m.id = $${params.length + 1} AND ${sql}`,
[...params, mailboxId],
);
return row ?? null;
}
/** Everything the server-wide checks need. */
export async function serverDiagnosticFacts(scope: AdminScope): Promise<Record<string, unknown>> {
const { sql, params } = scopeClause(scope, "d.id");
const domains = await query<{ name: string; dns_status: string; dane_enabled: boolean }>(
`SELECT d.name, d.dns_status, d.dane_enabled FROM domains d WHERE ${sql} ORDER BY d.name`,
params,
);
const withoutDkim = await query<{ name: string }>(
`SELECT d.name FROM domains d
WHERE ${sql}
AND NOT EXISTS (SELECT 1 FROM dkim_keys k WHERE k.domain_id = d.id)
ORDER BY d.name`,
params,
);
const queue = await query<{ status: string; count: string }>(
`SELECT status, count(*)::text AS count FROM outbound_jobs GROUP BY status`,
);
const { sql: mSql, params: mParams } = scopeClause(scope, "m.domain_id");
const quota = await queryOne<{ over: string; total: string }>(
`SELECT count(*) FILTER (WHERE m.quota_bytes > 0 AND m.used_bytes >= m.quota_bytes)::text AS over,
count(*)::text AS total
FROM mailboxes m WHERE ${mSql}`,
mParams,
);
return {
domains: domains.map((d) => ({
name: d.name,
dnsStatus: d.dns_status,
daneEnabled: d.dane_enabled,
})),
domainsWithoutDkim: withoutDkim.map((d) => d.name),
queueByStatus: Object.fromEntries(queue.map((r) => [r.status, Number(r.count)])),
usersOverQuota: Number(quota?.over ?? 0),
usersTotal: Number(quota?.total ?? 0),
};
}
export type AliasResult = { status: "OK"; id: string } | { status: "FORBIDDEN" } | { status: "DUPLICATE" } | { status: "INVALID" };
export async function createAlias(
+163 -138
View File
@@ -668,147 +668,172 @@ describeDb("auth API against a real database", () => {
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);
});
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.
describeDb("paging, filtering and editing users", () => {
beforeAll(async () => {
await startServer();
await seed();
// Enough rows that a single hardcoded page cannot hold them, which is the
// condition the console could not cope with at all.
const hash = await hashPassword(PASSWORD);
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)
ON CONFLICT DO NOTHING`,
[DOMAIN_ID, local, `${local}@authtest-${DOMAIN_ID}.invalid`, hash, i % 2 === 0],
);
}
});
afterAll(async () => {
server?.close();
await query(`DELETE FROM domains WHERE id = $1`, [DOMAIN_ID]).catch(() => undefined);
await closePool();
});
it("returns one page at a time, with the total of the whole set", async () => {
const admin = await adminSession();
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 admin = await adminSession();
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 admin = await adminSession();
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 admin = await adminSession();
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 admin = await adminSession();
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 admin = await adminSession();
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 admin = await adminSession();
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 admin = await adminSession();
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("answers 404 for a user that does not exist rather than leaking the difference", async () => {
const admin = await adminSession();
const res = await get(`/api/v1/admin/mailboxes/44444444-4444-4444-4444-444444444444/aliases`, admin);
expect(res.status).toBe(404);
});
});
describe("surface routing", () => {
it("ignores anything outside /api/v1/", async () => {
const { handleApiRequest: handler } = await import("./router.js");
+51
View File
@@ -12,6 +12,7 @@ import {
} from "./surfaceAccess.js";
import * as repo from "./repository.js";
import { parsePageParams, paged } from "./pagination.js";
import { checkUser, checkServer, overallStatus } from "./diagnostics.js";
const CHALLENGE_TTL_SECONDS = 300;
// Long enough to scan a code and type one back, short enough that a token
@@ -194,6 +195,10 @@ async function route(req: IncomingMessage, res: ServerResponse, url: string): Pr
if (url.startsWith("/api/v1/admin/queue/") && url.endsWith("/cancel") && method === "POST") {
return adminQueueAction(req, res, session!, url.slice("/api/v1/admin/queue/".length, -"/cancel".length), "cancel");
}
if (url === "/api/v1/admin/diagnostics/server" && method === "GET") return adminDiagnosticsServer(res, session!);
if (url.startsWith("/api/v1/admin/diagnostics/user/") && method === "GET") {
return adminDiagnosticsUser(res, session!, url.substring("/api/v1/admin/diagnostics/user/".length));
}
if (url === "/api/v1/admin/dashboard" && method === "GET") return adminDashboard(res);
if (url === "/api/v1/admin/domains" && method === "GET") return adminDomains(res, session!);
if (url.split("?")[0] === "/api/v1/admin/mailboxes" && method === "GET") return adminMailboxes(req, res, session!);
@@ -946,6 +951,52 @@ async function adminReconcileDomain(req: IncomingMessage, res: ServerResponse, s
}
/**
* The checks an operator runs by hand from /admin/tests.
*
* They read state rather than changing it: a diagnostic page that alters the
* thing it is diagnosing is worse than no page at all.
*/
async function adminDiagnosticsServer(res: ServerResponse, session: SessionTokenPayload): Promise<void> {
const facts = await repo.serverDiagnosticFacts(scopeOf(session));
const checks = checkServer(facts as never);
sendJson(res, 200, { scope: "server", status: overallStatus(checks), checks });
}
async function adminDiagnosticsUser(
res: ServerResponse, session: SessionTokenPayload, id: string,
): Promise<void> {
const row = await repo.userDiagnosticFacts(scopeOf(session), id);
if (!row) {
return sendJson(res, 404, { error: "NOT_FOUND", message: "No such user" });
}
const checks = checkUser({
email: String(row.email_address),
isActive: Boolean(row.is_active),
lockedUntil: row.locked_until ? String(row.locked_until) : null,
// Bytes come back as text so a bigint is not rounded through a double.
quotaBytes: Number(row.quota_bytes ?? 0),
usedBytes: Number(row.used_bytes ?? 0),
mfaEnrolled: Boolean(row.mfa_enrolled),
aliasCount: Number(row.alias_count ?? 0),
sieveScriptBytes: row.sieve_bytes === null || row.sieve_bytes === undefined ? null : Number(row.sieve_bytes),
vacationEnabled: Boolean(row.vacation_enabled),
// The autoresponder is a Sieve script with no date window of its own, so
// there is no window to judge and none is invented here.
vacationStart: null,
vacationEnd: null,
passwordHashPresent: Boolean(row.password_hash_present),
});
sendJson(res, 200, {
scope: "user",
subject: String(row.email_address),
status: overallStatus(checks),
checks,
});
}
async function adminGetRspamdThresholds(res: ServerResponse): Promise<void> {
sendJson(res, 200, await repo.getRspamdThresholds());
}