docs(monitors): document heartbeat and metric monitors; add heartbeat e2e
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { ensureOwner, signIn } from "./helpers";
|
||||
|
||||
const BASE_URL = process.env.E2E_BASE_URL ?? "http://localhost:3000";
|
||||
|
||||
test("heartbeat monitor: URL ping, /fail, header ping", async ({ page, request }) => {
|
||||
const owner = await ensureOwner(request);
|
||||
await signIn(page, owner.email, owner.password);
|
||||
|
||||
const create = await page.request.post(`${BASE_URL}/api/monitors`, {
|
||||
data: {
|
||||
name: `e2e heartbeat ${Date.now()}`,
|
||||
type: "heartbeat",
|
||||
target: { period_sec: 3600, grace_sec: 300 },
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
expect(create.status()).toBe(201);
|
||||
const monitor = await create.json();
|
||||
expect(monitor.heartbeat_token).toBeTruthy();
|
||||
|
||||
const ping = await request.get(`${BASE_URL}/public/hb/${monitor.heartbeat_token}`);
|
||||
expect(ping.status()).toBe(200);
|
||||
expect(await ping.text()).toBe("OK");
|
||||
|
||||
let got = await (await page.request.get(`${BASE_URL}/api/monitors/${monitor.monitor_id}`)).json();
|
||||
expect(got.state.status).toBe("up");
|
||||
|
||||
await new Promise((r) => setTimeout(r, 1100)); // per-token limit is 1/s
|
||||
const fail = await request.post(`${BASE_URL}/public/hb/${monitor.heartbeat_token}/fail`, { data: "disk full" });
|
||||
expect(fail.status()).toBe(200);
|
||||
|
||||
got = await (await page.request.get(`${BASE_URL}/api/monitors/${monitor.monitor_id}`)).json();
|
||||
expect(got.state.status).toBe("down");
|
||||
expect(got.state.message).toContain("disk full");
|
||||
|
||||
const incidents = await (await page.request.get(`${BASE_URL}/api/monitors/${monitor.monitor_id}/incidents`)).json();
|
||||
expect(incidents.length).toBeGreaterThan(0);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 1100));
|
||||
const headerPing = await request.post(`${BASE_URL}/public/hb`, { headers: { "X-Vantage-Token": monitor.heartbeat_token } });
|
||||
expect(headerPing.status()).toBe(200);
|
||||
got = await (await page.request.get(`${BASE_URL}/api/monitors/${monitor.monitor_id}`)).json();
|
||||
expect(got.state.status).toBe("up");
|
||||
|
||||
expect((await request.get(`${BASE_URL}/public/hb/not-a-real-token`)).status()).toBe(404);
|
||||
expect((await request.post(`${BASE_URL}/public/hb`)).status()).toBe(404);
|
||||
|
||||
await page.goto(`${BASE_URL}/monitors/${monitor.monitor_id}`);
|
||||
await expect(page.getByText(/last ping/i)).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { type APIRequestContext, type Page } from "@playwright/test";
|
||||
|
||||
export const BASE_URL = process.env.E2E_BASE_URL ?? "http://localhost:3000";
|
||||
|
||||
let ownerEmail = process.env.E2E_OWNER_EMAIL ?? "";
|
||||
let ownerPassword = process.env.E2E_OWNER_PASSWORD ?? "";
|
||||
|
||||
/** Ensures an owner account exists and returns its credentials, bootstrapping the instance if needed. */
|
||||
export async function ensureOwner(request: APIRequestContext): Promise<{ email: string; password: string }> {
|
||||
if (ownerEmail && ownerPassword) return { email: ownerEmail, password: ownerPassword };
|
||||
|
||||
const status = await request.get(`${BASE_URL}/auth/bootstrap-status`);
|
||||
const body = await status.json();
|
||||
if (!body.needs_setup) {
|
||||
throw new Error(
|
||||
"Instance is already bootstrapped and E2E_OWNER_EMAIL/E2E_OWNER_PASSWORD were not set - " +
|
||||
"cannot create the owner account this suite needs to provision fresh members per test.",
|
||||
);
|
||||
}
|
||||
|
||||
ownerEmail = `e2e-owner-${Date.now()}@vantage.test`;
|
||||
ownerPassword = "correct horse battery staple 1";
|
||||
const res = await request.post(`${BASE_URL}/auth/bootstrap`, {
|
||||
data: { instance_name: "E2E", email: ownerEmail, password: ownerPassword },
|
||||
});
|
||||
if (!res.ok()) throw new Error(`bootstrap failed: ${res.status()} ${await res.text()}`);
|
||||
return { email: ownerEmail, password: ownerPassword };
|
||||
}
|
||||
|
||||
/** Fills the login form and submits it. */
|
||||
export async function signIn(page: Page, email: string, password: string) {
|
||||
await page.goto("/login");
|
||||
await page.getByLabel("Email").fill(email);
|
||||
await page.getByLabel("Password").fill(password);
|
||||
await page.getByRole("button", { name: "Sign In" }).click();
|
||||
}
|
||||
+8
-41
@@ -1,5 +1,6 @@
|
||||
import { test, expect, type APIRequestContext, type BrowserContext, type Page } from "@playwright/test";
|
||||
import { authenticator } from "otplib";
|
||||
import { BASE_URL, ensureOwner, signIn } from "./helpers";
|
||||
|
||||
/**
|
||||
* End-to-end coverage for Task 15 of the MFA design
|
||||
@@ -17,33 +18,6 @@ import { authenticator } from "otplib";
|
||||
* at an already-bootstrapped instance's owner instead.
|
||||
*/
|
||||
|
||||
const BASE_URL = process.env.E2E_BASE_URL ?? "http://localhost:3000";
|
||||
|
||||
let ownerEmail = process.env.E2E_OWNER_EMAIL ?? "";
|
||||
let ownerPassword = process.env.E2E_OWNER_PASSWORD ?? "";
|
||||
|
||||
/** Ensures an owner account exists and returns its credentials, bootstrapping the instance if needed. */
|
||||
async function ensureOwner(request: APIRequestContext): Promise<{ email: string; password: string }> {
|
||||
if (ownerEmail && ownerPassword) return { email: ownerEmail, password: ownerPassword };
|
||||
|
||||
const status = await request.get(`${BASE_URL}/auth/bootstrap-status`);
|
||||
const body = await status.json();
|
||||
if (!body.needs_setup) {
|
||||
throw new Error(
|
||||
"Instance is already bootstrapped and E2E_OWNER_EMAIL/E2E_OWNER_PASSWORD were not set - " +
|
||||
"cannot create the owner account this suite needs to provision fresh members per test.",
|
||||
);
|
||||
}
|
||||
|
||||
ownerEmail = `e2e-owner-${Date.now()}@vantage.test`;
|
||||
ownerPassword = "correct horse battery staple 1";
|
||||
const res = await request.post(`${BASE_URL}/auth/bootstrap`, {
|
||||
data: { instance_name: "MFA E2E", email: ownerEmail, password: ownerPassword },
|
||||
});
|
||||
if (!res.ok()) throw new Error(`bootstrap failed: ${res.status()} ${await res.text()}`);
|
||||
return { email: ownerEmail, password: ownerPassword };
|
||||
}
|
||||
|
||||
/** Creates a fresh, unique local member with no MFA enrolled, using an owner session. */
|
||||
async function createMember(request: APIRequestContext): Promise<{ email: string; password: string }> {
|
||||
const owner = await ensureOwner(request);
|
||||
@@ -80,19 +54,12 @@ async function addVirtualAuthenticator(context: BrowserContext, page: Page): Pro
|
||||
return authenticatorId;
|
||||
}
|
||||
|
||||
async function fillCredentials(page: Page, email: string, password: string) {
|
||||
await page.goto("/login");
|
||||
await page.getByLabel("Email").fill(email);
|
||||
await page.getByLabel("Password").fill(password);
|
||||
await page.getByRole("button", { name: "Sign In" }).click();
|
||||
}
|
||||
|
||||
test.describe("MFA end-to-end", () => {
|
||||
test("TOTP sign-in", async ({ page, request }) => {
|
||||
const { email, password } = await createMember(request);
|
||||
|
||||
// Sign in, land on the account security page, enrol TOTP.
|
||||
await fillCredentials(page, email, password);
|
||||
await signIn(page, email, password);
|
||||
await expect(page).toHaveURL("/");
|
||||
|
||||
await page.goto("/account/security");
|
||||
@@ -111,7 +78,7 @@ test.describe("MFA end-to-end", () => {
|
||||
|
||||
// Sign out and sign back in - TOTP is now required.
|
||||
await page.request.post(`${BASE_URL}/auth/logout`);
|
||||
await fillCredentials(page, email, password);
|
||||
await signIn(page, email, password);
|
||||
|
||||
await expect(page.getByText("Enter the 6-digit code")).toBeVisible();
|
||||
const nextCode = authenticator.generate(secret.trim());
|
||||
@@ -125,7 +92,7 @@ test.describe("MFA end-to-end", () => {
|
||||
const { email, password } = await createMember(request);
|
||||
await addVirtualAuthenticator(context, page);
|
||||
|
||||
await fillCredentials(page, email, password);
|
||||
await signIn(page, email, password);
|
||||
await expect(page).toHaveURL("/");
|
||||
|
||||
await page.goto("/account/security");
|
||||
@@ -138,7 +105,7 @@ test.describe("MFA end-to-end", () => {
|
||||
await expect(page.getByText(/Added .* · Last used/)).toBeVisible();
|
||||
|
||||
await page.request.post(`${BASE_URL}/auth/logout`);
|
||||
await fillCredentials(page, email, password);
|
||||
await signIn(page, email, password);
|
||||
|
||||
await expect(page.getByText("Enter the 6-digit code")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Use passkey" }).click();
|
||||
@@ -151,7 +118,7 @@ test.describe("MFA end-to-end", () => {
|
||||
await addVirtualAuthenticator(context, page);
|
||||
|
||||
// Enrol a passkey first (typing the password once, during setup only).
|
||||
await fillCredentials(page, email, password);
|
||||
await signIn(page, email, password);
|
||||
await expect(page).toHaveURL("/");
|
||||
await page.goto("/account/security");
|
||||
await page.getByRole("button", { name: "Add a passkey" }).click();
|
||||
@@ -180,7 +147,7 @@ test.describe("MFA end-to-end", () => {
|
||||
await request.post(`${BASE_URL}/auth/logout`);
|
||||
|
||||
// The member, who has no MFA yet, must enrol before reaching the app.
|
||||
await fillCredentials(page, email, password);
|
||||
await signIn(page, email, password);
|
||||
await expect(page.getByText("This instance requires a second sign-in factor. Set one up to continue.")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Use an authenticator app" }).click();
|
||||
@@ -204,7 +171,7 @@ test.describe("MFA end-to-end", () => {
|
||||
|
||||
// Enrol TOTP so the account has a factor - not exercised in this test,
|
||||
// since sign-in itself sets StepUpAt and the window has not gone stale.
|
||||
await fillCredentials(page, email, password);
|
||||
await signIn(page, email, password);
|
||||
await page.goto("/account/security");
|
||||
await page.getByRole("button", { name: "Set up" }).click();
|
||||
await page.getByRole("button", { name: "Use an authenticator app" }).click();
|
||||
|
||||
Reference in New Issue
Block a user