155 lines
7.2 KiB
TypeScript
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);
|
|
});
|
|
});
|