Files
PacketStation/tests/e2e/tests/admin-security.spec.ts
T

155 lines
7.2 KiB
TypeScript

import { expect, test } from "./helpers";
import { api, closeAll } from "./shophelpers";
import type { Json } from "./whhelpers";
import { admin, badSignin, call, openAdminTab } from "./adminhelpers";
// Phase 9 Admin, Security tab, on the CI stack. admin.ann lowers the sign-in burst threshold to its floor (5) in the
// settings form; a value under the floor is refused by the form and by the API (422). Then a fresh browser makes
// six wrong-password sign-ins for dup.dina, a CI account no other scenario signs in with: the account cooldown
// starts after five, and five failures in five minutes meet the threshold. The alerts list shows the cooldown and
// the burst. The integrity panel renders after a requested integrity run. The threshold is put back at the end.
const KEY = "signin_burst_threshold";
const LABEL = "Failed sign-ins across accounts in 5 minutes that raise an alert";
const TARGET = "dup.dina";
let ORIGINAL: number | null = null;
async function settings(a: Parameters<typeof api>[0]): Promise<Json> {
return api(a, "get", "/api/admin/security/settings");
}
test.describe.serial("admin security", () => {
test.setTimeout(180_000);
test.afterAll(async ({ browser }) => {
if (ORIGINAL === null) return;
const ann = await admin(browser);
const s = await settings(ann);
if (s.values[KEY] !== ORIGINAL) {
await api(ann, "put", "/api/admin/security/settings", { values: { [KEY]: ORIGINAL }, version: s.version });
}
await closeAll(ann);
});
test("a setting changes within its bounds; a value out of bounds is refused", async ({ browser }) => {
const ann = await admin(browser);
const page = ann.page;
let s = await settings(ann);
expect(s.bounds[KEY]).toEqual([5, 200]);
ORIGINAL = Number(s.values[KEY]);
if (ORIGINAL === 5) {
// a rerun after a failed one: start from the default so the form has a change to save
ORIGINAL = Number(s.defaults[KEY] ?? 20);
s = await api(ann, "put", "/api/admin/security/settings", { values: { [KEY]: ORIGINAL }, version: s.version });
}
// the API refuses a value under the floor and one over the ceiling
for (const v of [1, 201]) {
const r = await call(ann, "put", "/api/admin/security/settings", { values: { [KEY]: v }, version: s.version });
expect(r.status, `${v}: ${JSON.stringify(r.body)}`).toBe(422);
}
expect((await settings(ann)).values[KEY]).toBe(ORIGINAL);
await openAdminTab(page, "security");
const form = page.locator("form").filter({ has: page.getByRole("heading", { name: "Settings", exact: true }) });
const input = form.getByLabel(LABEL, { exact: true });
await expect(input).toHaveValue(String(ORIGINAL));
await expect(form.getByText("Allowed 5 to 200.").first()).toBeVisible();
// out of bounds: the form refuses it and nothing is sent
let puts = 0;
page.on("request", (r) => {
if (r.url().endsWith("/api/admin/security/settings") && r.method() === "PUT") puts++;
});
await input.fill("1");
await form.getByRole("button", { name: "Save security settings", exact: true }).click();
await expect(form.getByRole("alert")).toContainText("give a whole number from 5 to 200");
expect(puts).toBe(0);
// within bounds: saved
await input.fill("5");
const saved = page.waitForResponse(
(r) => r.url().endsWith("/api/admin/security/settings") && r.request().method() === "PUT",
);
await form.getByRole("button", { name: "Save security settings", exact: true }).click();
expect((await saved).status()).toBe(200);
await expect(form.getByRole("status")).toContainText("Security settings saved.");
expect((await settings(ann)).values[KEY]).toBe(5);
await closeAll(ann);
});
test("wrong passwords for one account: the alerts list shows the cooldown and the burst", async ({ browser }) => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
const statuses: number[] = [];
for (let i = 0; i < 6; i++) statuses.push(await badSignin(page, TARGET));
// five wrong passwords reach the directory; the sixth is refused by the cooldown
expect(statuses.slice(0, 5)).toEqual([401, 401, 401, 401, 401]);
expect(statuses[5]).toBe(429);
await ctx.close();
const ann = await admin(browser);
const hit = (rows: Json[], kind: string, account?: string) =>
rows.some((r) => r.kind === kind && (account === undefined || String(r.account ?? "").toLowerCase() === account));
await expect
.poll(
async () => {
const rows = (await api(ann, "get", "/api/admin/security/alerts?hours=1")) as unknown as Json[];
return { lockout: hit(rows, "lockout", TARGET), burst: hit(rows, "signin_burst") };
},
{ timeout: 20_000 },
)
.toEqual({ lockout: true, burst: true });
const page2 = ann.page;
await openAdminTab(page2, "security");
const table = page2.getByRole("table", { name: "Security alerts" });
await expect(table).toBeVisible();
const cooldown = table.getByRole("row").filter({ hasText: "Account cooldowns" }).filter({ hasText: TARGET });
await expect(cooldown.first()).toBeVisible();
await expect(table.getByRole("row").filter({ hasText: "Sign-in bursts" }).first()).toBeVisible();
// the kind chips filter the list
const chips = page2.getByRole("group", { name: "Alert kinds" });
const burstChip = chips.getByRole("button", { name: /^Sign-in bursts/ });
await expect(burstChip).toHaveAttribute("aria-pressed", "true");
await burstChip.click();
await expect(burstChip).toHaveAttribute("aria-pressed", "false");
await expect(table.getByRole("row").filter({ hasText: "Sign-in bursts" })).toHaveCount(0);
await burstChip.click();
await expect(table.getByRole("row").filter({ hasText: "Sign-in bursts" }).first()).toBeVisible();
await closeAll(ann);
});
test("the integrity panel renders the newest run", async ({ browser }) => {
const ann = await admin(browser);
// ask the worker for a run now; the nightly schedule has not come round in CI
const req = await call(ann, "post", "/api/jobs/integrity/run", { note: "e2e" });
if (req.status === 200) {
await expect
.poll(async () => Boolean((await api(ann, "get", "/api/admin/security/integrity")).run), { timeout: 90_000 })
.toBe(true);
}
const out = await api(ann, "get", "/api/admin/security/integrity");
expect(Array.isArray(out.findings)).toBe(true);
const page = ann.page;
await openAdminTab(page, "security");
const panel = page.locator("section").filter({ has: page.getByRole("heading", { name: "Integrity check" }) });
await expect(panel).toBeVisible();
if (out.run) {
await expect(panel.getByText(/^Finished /)).toBeVisible();
const findings = (out.findings as Json[]).length;
if (findings) {
await expect(panel.getByText(new RegExp(`^${findings} findings?$`))).toBeVisible();
await expect(panel.getByRole("table", { name: "Integrity findings" })).toBeVisible();
} else {
await expect(panel.getByText(/^(Clean|Failed)$/)).toBeVisible();
}
} else {
await expect(panel.getByText("The integrity check has not run yet.", { exact: false })).toBeVisible();
}
await closeAll(ann);
});
});