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

+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");