feat(admin): paginacao e filtragem no servidor, e edicao de utilizadores
As listas do painel respondiam com um LIMIT fixo e nenhum filtro: 200 caixas, 50 trabalhos na fila. Numa instalacao maior nao havia forma de chegar ao resto, e a fila so podia ser inspeccionada pela cabeca -- justamente quando esta acumulada e que alguem precisa de olhar para ela. - parsePageParams com testes proprios: pagina e tamanho tem de ser inteiros positivos, o tamanho e limitado, e um valor invalido cai no seguro em vez de chegar ao SQL como NaN, LIMIT negativo ou OFFSET fraccionario - o termo de pesquisa vai como parametro ligado, nunca interpolado; ha um teste que tenta terminar a instrucao e largar a tabela - a contagem usa o mesmo filtro da pagina, senao o numero de paginas descreve um conjunto de linhas diferente do que e mostrado - PATCH /admin/mailboxes/:id edita papel, quota, idioma, estado e liberta um bloqueio; so um super admin pode conceder super admin, ou um admin de dominio promovia-se para fora do seu proprio dominio Apelidos por utilizador: um apelido nao pertence a um utilizador, pertence ao dominio e leva uma lista de destinos que pode incluir enderecos noutros servidores, e um catch-all e do dominio inteiro. O que pode ser mostrado dentro de um utilizador sao os apelidos que entregam na caixa dele, e e isso que /admin/mailboxes/:id/aliases responde. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
136f9f339a
commit
52bf43b74f
5 files changed
+551
-14
No files matched your search
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parsePageParams, MAX_PAGE_SIZE } from "./pagination.js";
|
||||
|
||||
const at = (qs: string) => new URL(`http://x/api${qs}`);
|
||||
|
||||
describe("pagination parameters", () => {
|
||||
it("defaults to the first page when nothing is asked for", () => {
|
||||
const p = parsePageParams(at(""));
|
||||
expect(p.page).toBe(1);
|
||||
expect(p.offset).toBe(0);
|
||||
expect(p.search).toBe("");
|
||||
});
|
||||
|
||||
it("turns a page number into the right offset", () => {
|
||||
const p = parsePageParams(at("?page=3&page_size=25"));
|
||||
expect(p.pageSize).toBe(25);
|
||||
expect(p.offset).toBe(50);
|
||||
});
|
||||
|
||||
// A page size taken at face value is a denial of service against your own
|
||||
// database: one request asking for ten million rows.
|
||||
it("caps the page size", () => {
|
||||
expect(parsePageParams(at("?page_size=100000")).pageSize).toBe(MAX_PAGE_SIZE);
|
||||
});
|
||||
|
||||
// Anything that is not a sane positive integer must land on the default
|
||||
// rather than reaching SQL as NaN, a negative LIMIT, or a fractional OFFSET.
|
||||
it.each(["0", "-5", "abc", "1.5", "", "1e9", "Infinity", "NaN"])(
|
||||
"falls back to a safe page for page=%s",
|
||||
(value) => {
|
||||
const p = parsePageParams(at(`?page=${value}`));
|
||||
expect(Number.isInteger(p.page)).toBe(true);
|
||||
expect(p.page).toBeGreaterThanOrEqual(1);
|
||||
expect(Number.isInteger(p.offset)).toBe(true);
|
||||
expect(p.offset).toBeGreaterThanOrEqual(0);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["0", "-5", "abc", "1.5", "NaN"])(
|
||||
"falls back to a safe page size for page_size=%s",
|
||||
(value) => {
|
||||
const p = parsePageParams(at(`?page_size=${value}`));
|
||||
expect(Number.isInteger(p.pageSize)).toBe(true);
|
||||
expect(p.pageSize).toBeGreaterThan(0);
|
||||
expect(p.pageSize).toBeLessThanOrEqual(MAX_PAGE_SIZE);
|
||||
},
|
||||
);
|
||||
|
||||
it("trims the search term and keeps it as plain text", () => {
|
||||
// It reaches SQL as a bound parameter, so the wildcards a user types are
|
||||
// theirs to type; what must not happen is it arriving untrimmed and
|
||||
// matching nothing.
|
||||
expect(parsePageParams(at("?search=%20%20ana%20%20")).search).toBe("ana");
|
||||
});
|
||||
|
||||
it("ignores an over-long search term instead of sending it to the database", () => {
|
||||
const p = parsePageParams(at(`?search=${"a".repeat(500)}`));
|
||||
expect(p.search.length).toBeLessThanOrEqual(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Page and filter parameters, read from a URL and made safe for SQL.
|
||||
*
|
||||
* The admin lists used to answer with a hardcoded LIMIT and no filter at all,
|
||||
* so a console with more than a couple of hundred rows simply could not reach
|
||||
* the rest of them.
|
||||
*/
|
||||
|
||||
export const DEFAULT_PAGE_SIZE = 25;
|
||||
/** Asking for a million rows must not be a thing one request can do. */
|
||||
export const MAX_PAGE_SIZE = 200;
|
||||
/** Long enough for an address, short enough not to be a payload. */
|
||||
export const MAX_SEARCH_LENGTH = 200;
|
||||
|
||||
export interface PageRequest {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
offset: number;
|
||||
search: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A positive integer, or the fallback. Anything else - NaN, a float, a
|
||||
* negative, an exponent - would reach SQL as an invalid LIMIT or OFFSET.
|
||||
*/
|
||||
function positiveInt(raw: string | null, fallback: number, max?: number): number {
|
||||
if (raw === null) return fallback;
|
||||
const trimmed = raw.trim();
|
||||
if (!/^\d+$/.test(trimmed)) return fallback;
|
||||
const value = Number(trimmed);
|
||||
if (!Number.isSafeInteger(value) || value < 1) return fallback;
|
||||
return max !== undefined ? Math.min(value, max) : value;
|
||||
}
|
||||
|
||||
export function parsePageParams(url: URL): PageRequest {
|
||||
const page = positiveInt(url.searchParams.get("page"), 1);
|
||||
const pageSize = positiveInt(url.searchParams.get("page_size"), DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE);
|
||||
const search = (url.searchParams.get("search") ?? "").trim().slice(0, MAX_SEARCH_LENGTH);
|
||||
return { page, pageSize, offset: (page - 1) * pageSize, search };
|
||||
}
|
||||
|
||||
/** The envelope every paginated list answers with. */
|
||||
export function paged<T>(items: T[], total: number, p: PageRequest) {
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
page: p.page,
|
||||
page_size: p.pageSize,
|
||||
total_pages: Math.max(1, Math.ceil(total / p.pageSize)),
|
||||
};
|
||||
}
|
||||
@@ -401,16 +401,68 @@ export async function listDomains(scope: AdminScope): Promise<unknown[]> {
|
||||
);
|
||||
}
|
||||
|
||||
export async function listMailboxes(scope: AdminScope): Promise<unknown[]> {
|
||||
export interface UserListFilter {
|
||||
search: string;
|
||||
active: boolean | null;
|
||||
role: string | null;
|
||||
offset: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists users, filtered and paged in the database.
|
||||
*
|
||||
* It used to answer with a hardcoded LIMIT 200 and no filter, so an
|
||||
* installation with more users than that had no way to reach the rest, and
|
||||
* finding one meant scrolling.
|
||||
*/
|
||||
export async function listMailboxes(
|
||||
scope: AdminScope,
|
||||
filter: UserListFilter,
|
||||
): Promise<{ items: unknown[]; total: number }> {
|
||||
const { sql, params } = scopeClause(scope, "m.domain_id");
|
||||
return query(
|
||||
const where: string[] = [sql];
|
||||
const args: unknown[] = [...params];
|
||||
|
||||
if (filter.search) {
|
||||
// Bound as a parameter, never interpolated. The wildcards are added here
|
||||
// so the caller searches for a substring without having to know SQL.
|
||||
args.push(`%${filter.search}%`);
|
||||
where.push(`(m.email_address ILIKE $${args.length} OR d.name ILIKE $${args.length})`);
|
||||
}
|
||||
if (filter.active !== null) {
|
||||
args.push(filter.active);
|
||||
where.push(`m.is_active = $${args.length}`);
|
||||
}
|
||||
if (filter.role) {
|
||||
args.push(filter.role);
|
||||
where.push(`m.role = $${args.length}`);
|
||||
}
|
||||
|
||||
const clause = where.join(" AND ");
|
||||
|
||||
// Counted with the same filter, or the page count would describe a
|
||||
// different set of rows than the page itself.
|
||||
const totalRow = await queryOne<{ count: string }>(
|
||||
`SELECT count(*)::text AS count
|
||||
FROM mailboxes m JOIN domains d ON d.id = m.domain_id
|
||||
WHERE ${clause}`,
|
||||
args,
|
||||
);
|
||||
|
||||
args.push(filter.pageSize, filter.offset);
|
||||
const items = await query(
|
||||
`SELECT m.id, m.email_address, m.role, m.is_active, m.quota_bytes, m.used_bytes,
|
||||
m.locale, m.locked_until, d.name AS domain_name,
|
||||
m.locale, m.locked_until, d.name AS domain_name, m.domain_id,
|
||||
EXISTS (SELECT 1 FROM mfa_totp t WHERE t.mailbox_id = m.id AND t.status = 'CONFIRMED') AS mfa_enrolled
|
||||
FROM mailboxes m JOIN domains d ON d.id = m.domain_id
|
||||
WHERE ${sql} ORDER BY m.email_address LIMIT 200`,
|
||||
params,
|
||||
WHERE ${clause}
|
||||
ORDER BY m.email_address
|
||||
LIMIT $${args.length - 1} OFFSET $${args.length}`,
|
||||
args,
|
||||
);
|
||||
|
||||
return { items, total: Number(totalRow?.count ?? 0) };
|
||||
}
|
||||
|
||||
export async function dmarcSummary(): Promise<Record<string, unknown>> {
|
||||
@@ -435,18 +487,63 @@ export async function dmarcSummary(): Promise<Record<string, unknown>> {
|
||||
};
|
||||
}
|
||||
|
||||
export async function queueSummary(): Promise<Record<string, unknown>> {
|
||||
export interface QueueFilter {
|
||||
search: string;
|
||||
status: string | null;
|
||||
offset: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The outbound queue, filtered and paged in the database.
|
||||
*
|
||||
* It used to return the 50 most recent jobs and nothing else, so a queue that
|
||||
* had backed up could not be inspected past its own head - which is precisely
|
||||
* when someone needs to look at it.
|
||||
*/
|
||||
export async function queueSummary(filter: QueueFilter): Promise<Record<string, unknown>> {
|
||||
// The totals are of the whole queue, deliberately: they are the summary the
|
||||
// page leads with, and filtering them to the current search would make the
|
||||
// counts change as somebody types.
|
||||
const byStatus = await query<{ status: string; count: string }>(
|
||||
`SELECT status, count(*)::text AS count FROM outbound_jobs GROUP BY status`,
|
||||
);
|
||||
|
||||
const where: string[] = ["TRUE"];
|
||||
const args: unknown[] = [];
|
||||
|
||||
if (filter.search) {
|
||||
args.push(`%${filter.search}%`);
|
||||
where.push(
|
||||
`(envelope_from ILIKE $${args.length} OR envelope_to ILIKE $${args.length}` +
|
||||
` OR destination_domain ILIKE $${args.length} OR last_error ILIKE $${args.length})`,
|
||||
);
|
||||
}
|
||||
if (filter.status) {
|
||||
args.push(filter.status);
|
||||
where.push(`status = $${args.length}`);
|
||||
}
|
||||
|
||||
const clause = where.join(" AND ");
|
||||
const totalRow = await queryOne<{ count: string }>(
|
||||
`SELECT count(*)::text AS count FROM outbound_jobs WHERE ${clause}`,
|
||||
args,
|
||||
);
|
||||
|
||||
args.push(filter.pageSize, filter.offset);
|
||||
const recent = await query(
|
||||
`SELECT id, envelope_from, envelope_to, destination_domain, status, attempt,
|
||||
next_attempt_at, last_smtp_code, last_error, tls_policy_used
|
||||
FROM outbound_jobs ORDER BY created_at DESC LIMIT 50`,
|
||||
next_attempt_at, last_smtp_code, last_error, tls_policy_used, created_at
|
||||
FROM outbound_jobs WHERE ${clause}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $${args.length - 1} OFFSET $${args.length}`,
|
||||
args,
|
||||
);
|
||||
|
||||
return {
|
||||
by_status: Object.fromEntries(byStatus.map((r) => [r.status, Number(r.count)])),
|
||||
recent,
|
||||
total: Number(totalRow?.count ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -605,6 +702,72 @@ export async function setMailboxActive(scope: AdminScope, mailboxId: string, isA
|
||||
return rows.length === 1;
|
||||
}
|
||||
|
||||
export interface UserUpdate {
|
||||
role?: string;
|
||||
quotaBytes?: number;
|
||||
locale?: string;
|
||||
isActive?: boolean;
|
||||
unlock?: boolean;
|
||||
}
|
||||
|
||||
/** Roles a user may hold. Anything else is refused rather than stored. */
|
||||
export const ALLOWED_USER_ROLES = ["USER", "DOMAIN_ADMIN", "SUPER_ADMIN"] as const;
|
||||
|
||||
/**
|
||||
* Edits a user, refusing anything outside the caller's scope.
|
||||
*
|
||||
* The console could only disable or delete a user; everything else - the role,
|
||||
* the quota, the language, releasing a lockout - meant going to the database
|
||||
* by hand.
|
||||
*/
|
||||
export async function updateMailbox(
|
||||
scope: AdminScope,
|
||||
mailboxId: string,
|
||||
update: UserUpdate,
|
||||
): Promise<boolean> {
|
||||
const sets: string[] = [];
|
||||
const args: unknown[] = [mailboxId];
|
||||
|
||||
if (update.role !== undefined) {
|
||||
args.push(update.role);
|
||||
sets.push(`role = $${args.length}`);
|
||||
}
|
||||
if (update.quotaBytes !== undefined) {
|
||||
args.push(update.quotaBytes);
|
||||
sets.push(`quota_bytes = $${args.length}`);
|
||||
}
|
||||
if (update.locale !== undefined) {
|
||||
args.push(update.locale);
|
||||
sets.push(`locale = $${args.length}`);
|
||||
}
|
||||
if (update.isActive !== undefined) {
|
||||
args.push(update.isActive);
|
||||
sets.push(`is_active = $${args.length}`);
|
||||
}
|
||||
if (update.unlock) {
|
||||
// Releases a lockout from failed sign-ins without touching the password.
|
||||
sets.push(`locked_until = NULL`);
|
||||
}
|
||||
|
||||
// Nothing to change is not an error, but it must not become "UPDATE ... SET
|
||||
// WHERE", which is a syntax error rather than a no-op.
|
||||
if (sets.length === 0) return false;
|
||||
|
||||
sets.push("updated_at = NOW()");
|
||||
|
||||
let where = "id = $1";
|
||||
if (scope.role !== "SUPER_ADMIN") {
|
||||
args.push(scope.domainId);
|
||||
where += ` AND domain_id = $${args.length}`;
|
||||
}
|
||||
|
||||
const rows = await query<{ id: string }>(
|
||||
`UPDATE mailboxes SET ${sets.join(", ")} WHERE ${where} RETURNING id`,
|
||||
args,
|
||||
);
|
||||
return rows.length === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retries or cancels one queued delivery.
|
||||
*
|
||||
@@ -719,6 +882,41 @@ export async function listAliases(scope: AdminScope): Promise<unknown[]> {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The aliases that deliver to one user.
|
||||
*
|
||||
* An alias is not owned by a user: it belongs to a domain and carries a list
|
||||
* of destinations, which may include addresses on other servers, and a
|
||||
* catch-all belongs to the whole domain. What can be shown inside a user is
|
||||
* the set of aliases that land in their mailbox, which is what an operator
|
||||
* actually wants to know when looking at that person.
|
||||
*/
|
||||
export async function listAliasesForMailbox(
|
||||
scope: AdminScope,
|
||||
mailboxId: string,
|
||||
): Promise<unknown[] | null> {
|
||||
const { sql, params } = scopeClause(scope, "m.domain_id");
|
||||
const owner = await queryOne<{ email_address: string; domain_id: string }>(
|
||||
`SELECT m.email_address, m.domain_id FROM mailboxes m WHERE m.id = $${params.length + 1} AND ${sql}`,
|
||||
[...params, mailboxId],
|
||||
);
|
||||
// Out of scope and non-existent are answered the same way, so the console
|
||||
// of one domain cannot probe for ids belonging to another.
|
||||
if (!owner) return null;
|
||||
|
||||
return query(
|
||||
`SELECT a.id, a.source_address, a.destination_addresses, a.is_catch_all, a.is_active
|
||||
FROM aliases a
|
||||
WHERE a.domain_id = $1
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM unnest(a.destination_addresses) AS dest
|
||||
WHERE lower(dest) = lower($2)
|
||||
)
|
||||
ORDER BY a.source_address`,
|
||||
[owner.domain_id, owner.email_address],
|
||||
);
|
||||
}
|
||||
|
||||
export type AliasResult = { status: "OK"; id: string } | { status: "FORBIDDEN" } | { status: "DUPLICATE" } | { status: "INVALID" };
|
||||
|
||||
export async function createAlias(
|
||||
|
||||
@@ -671,6 +671,144 @@ describeDb("auth API against a real database", () => {
|
||||
});
|
||||
|
||||
// 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");
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type Surface,
|
||||
} from "./surfaceAccess.js";
|
||||
import * as repo from "./repository.js";
|
||||
import { parsePageParams, paged } from "./pagination.js";
|
||||
|
||||
const CHALLENGE_TTL_SECONDS = 300;
|
||||
// Long enough to scan a code and type one back, short enough that a token
|
||||
@@ -177,6 +178,12 @@ async function route(req: IncomingMessage, res: ServerResponse, url: string): Pr
|
||||
if (url.startsWith("/api/v1/admin/mailboxes/") && method === "DELETE") {
|
||||
return adminDeleteMailbox(req, res, session!, url.substring("/api/v1/admin/mailboxes/".length));
|
||||
}
|
||||
if (url.startsWith("/api/v1/admin/mailboxes/") && url.endsWith("/aliases") && method === "GET") {
|
||||
return adminMailboxAliases(res, session!, url.slice("/api/v1/admin/mailboxes/".length, -"/aliases".length));
|
||||
}
|
||||
if (url.startsWith("/api/v1/admin/mailboxes/") && method === "PATCH") {
|
||||
return adminUpdateMailbox(req, res, session!, url.substring("/api/v1/admin/mailboxes/".length));
|
||||
}
|
||||
if (url.startsWith("/api/v1/admin/mailboxes/") && url.endsWith("/active") && method === "PUT") {
|
||||
const id = url.slice("/api/v1/admin/mailboxes/".length, -"/active".length);
|
||||
return adminSetMailboxActive(req, res, session!, id);
|
||||
@@ -189,9 +196,9 @@ async function route(req: IncomingMessage, res: ServerResponse, url: string): Pr
|
||||
}
|
||||
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 === "/api/v1/admin/mailboxes" && method === "GET") return adminMailboxes(res, session!);
|
||||
if (url.split("?")[0] === "/api/v1/admin/mailboxes" && method === "GET") return adminMailboxes(req, res, session!);
|
||||
if (url === "/api/v1/admin/dmarc" && method === "GET") return adminDmarc(res);
|
||||
if (url === "/api/v1/admin/queue" && method === "GET") return adminQueue(res);
|
||||
if (url.split("?")[0] === "/api/v1/admin/queue" && method === "GET") return adminQueue(req, res);
|
||||
if (url === "/api/v1/admin/preflight" && method === "GET") return adminPreflight(res);
|
||||
|
||||
return sendJson(res, 404, { error: "NOT_FOUND", message: "API endpoint not found" });
|
||||
@@ -737,8 +744,75 @@ async function adminDomains(res: ServerResponse, session: SessionTokenPayload):
|
||||
sendJson(res, 200, await repo.listDomains(scopeOf(session)));
|
||||
}
|
||||
|
||||
async function adminMailboxes(res: ServerResponse, session: SessionTokenPayload): Promise<void> {
|
||||
sendJson(res, 200, await repo.listMailboxes(scopeOf(session)));
|
||||
async function adminMailboxes(req: IncomingMessage, res: ServerResponse, session: SessionTokenPayload): Promise<void> {
|
||||
const url = new URL(req.url || "/", "http://localhost");
|
||||
const page = parsePageParams(url);
|
||||
|
||||
const activeParam = url.searchParams.get("active");
|
||||
const roleParam = (url.searchParams.get("role") || "").toUpperCase();
|
||||
|
||||
const { items, total } = await repo.listMailboxes(scopeOf(session), {
|
||||
search: page.search,
|
||||
// Absent means "either"; only an explicit true/false filters.
|
||||
active: activeParam === "true" ? true : activeParam === "false" ? false : null,
|
||||
// An unrecognised role filters nothing rather than returning an empty
|
||||
// page the operator cannot explain.
|
||||
role: (repo.ALLOWED_USER_ROLES as readonly string[]).includes(roleParam) ? roleParam : null,
|
||||
offset: page.offset,
|
||||
pageSize: page.pageSize,
|
||||
});
|
||||
|
||||
sendJson(res, 200, paged(items, total, page));
|
||||
}
|
||||
|
||||
/** Edits a user: role, quota, language, enabled, and releasing a lockout. */
|
||||
async function adminUpdateMailbox(
|
||||
req: IncomingMessage, res: ServerResponse, session: SessionTokenPayload, id: string,
|
||||
): Promise<void> {
|
||||
const body = await parseJsonBody(req);
|
||||
const update: repo.UserUpdate = {};
|
||||
|
||||
if (typeof body?.role === "string") {
|
||||
const role = body.role.toUpperCase();
|
||||
if (!(repo.ALLOWED_USER_ROLES as readonly string[]).includes(role)) {
|
||||
return sendJson(res, 400, { error: "BAD_REQUEST", message: "Unknown role" });
|
||||
}
|
||||
// Only a super admin may hand out super admin, or a domain admin could
|
||||
// promote themselves out of their own domain.
|
||||
if (role === "SUPER_ADMIN" && session.role !== "SUPER_ADMIN") {
|
||||
return sendJson(res, 403, { error: "FORBIDDEN", message: "Only a super admin can grant that role" });
|
||||
}
|
||||
update.role = role;
|
||||
}
|
||||
if (typeof body?.quota_bytes === "number") {
|
||||
if (!Number.isSafeInteger(body.quota_bytes) || body.quota_bytes < 0) {
|
||||
return sendJson(res, 400, { error: "BAD_REQUEST", message: "quota_bytes must be a positive whole number" });
|
||||
}
|
||||
update.quotaBytes = body.quota_bytes;
|
||||
}
|
||||
if (typeof body?.locale === "string" && body.locale.trim()) {
|
||||
update.locale = body.locale.trim().slice(0, 16);
|
||||
}
|
||||
if (typeof body?.is_active === "boolean") update.isActive = body.is_active;
|
||||
if (body?.unlock === true) update.unlock = true;
|
||||
|
||||
const updated = await repo.updateMailbox(scopeOf(session), id, update);
|
||||
if (!updated) {
|
||||
return sendJson(res, 404, { error: "NOT_FOUND", message: "No such user, or nothing to change" });
|
||||
}
|
||||
await repo.recordAudit(session.sub, clientIp(req), "user.update", "mailbox", id, update as Record<string, unknown>);
|
||||
sendJson(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
/** The aliases that deliver to one user. */
|
||||
async function adminMailboxAliases(
|
||||
res: ServerResponse, session: SessionTokenPayload, id: string,
|
||||
): Promise<void> {
|
||||
const aliases = await repo.listAliasesForMailbox(scopeOf(session), id);
|
||||
if (aliases === null) {
|
||||
return sendJson(res, 404, { error: "NOT_FOUND", message: "No such user" });
|
||||
}
|
||||
sendJson(res, 200, aliases);
|
||||
}
|
||||
|
||||
/** The scope travels from the verified session, never from the request. */
|
||||
@@ -750,8 +824,24 @@ async function adminDmarc(res: ServerResponse): Promise<void> {
|
||||
sendJson(res, 200, await repo.dmarcSummary());
|
||||
}
|
||||
|
||||
async function adminQueue(res: ServerResponse): Promise<void> {
|
||||
sendJson(res, 200, await repo.queueSummary());
|
||||
async function adminQueue(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||
const url = new URL(req.url || "/", "http://localhost");
|
||||
const page = parsePageParams(url);
|
||||
const status = (url.searchParams.get("status") || "").toUpperCase();
|
||||
|
||||
const summary = await repo.queueSummary({
|
||||
search: page.search,
|
||||
status: status || null,
|
||||
offset: page.offset,
|
||||
pageSize: page.pageSize,
|
||||
});
|
||||
|
||||
sendJson(res, 200, {
|
||||
...summary,
|
||||
page: page.page,
|
||||
page_size: page.pageSize,
|
||||
total_pages: Math.max(1, Math.ceil(Number(summary.total ?? 0) / page.pageSize)),
|
||||
});
|
||||
}
|
||||
|
||||
async function adminPreflight(res: ServerResponse): Promise<void> {
|
||||
|
||||
Reference in new issue
Block a user