- Add Dockerfile, docker-entrypoint.sh, and .dockerignore for containerized deployment - Fix middleware to exclude /api/tenants/signup from auth (P0 signup bug) - Add Playwright E2E tests (16 browser tests) and curl-based API test script (80 tests) - Add playwright config and dev dependency - Update .gitignore with proper exclusions - Add v1 milestone audit report and ISP system PRD Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
275 lines
9.7 KiB
TypeScript
275 lines
9.7 KiB
TypeScript
import { test, expect, Page } from "@playwright/test";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function loginAs(page: Page, email: string, password: string) {
|
|
await page.goto("/login");
|
|
await page.fill('input[name="email"]', email);
|
|
await page.fill('input[name="password"]', password);
|
|
await page.click('button[type="submit"]');
|
|
// Wait for redirect away from login
|
|
await page.waitForURL((url) => !url.pathname.includes("/login"), {
|
|
timeout: 10000,
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 1. Login Page
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test.describe("Login Page", () => {
|
|
test("renders login form with email and password fields", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/login");
|
|
|
|
await expect(page.locator("h1")).toContainText("NetForge");
|
|
await expect(page.locator('input[name="email"]')).toBeVisible();
|
|
await expect(page.locator('input[name="password"]')).toBeVisible();
|
|
await expect(page.locator('button[type="submit"]')).toBeVisible();
|
|
await expect(page.locator('a[href="/signup"]')).toBeVisible();
|
|
});
|
|
|
|
test("shows error on invalid credentials", async ({ page }) => {
|
|
await page.goto("/login");
|
|
await page.fill('input[name="email"]', "bad@example.com");
|
|
await page.fill('input[name="password"]', "wrongpassword");
|
|
await page.click('button[type="submit"]');
|
|
|
|
// Should stay on login page and show error
|
|
await page.waitForTimeout(2000);
|
|
const url = page.url();
|
|
expect(url).toContain("/login");
|
|
// Check for error message or error in URL
|
|
const hasError =
|
|
url.includes("error") ||
|
|
(await page.locator('[role="alert"], .text-red, .error').count()) > 0;
|
|
expect(hasError).toBe(true);
|
|
});
|
|
|
|
test("successful admin login redirects to dashboard", async ({ page }) => {
|
|
await loginAs(page, "admin@demo.com", "admin123");
|
|
|
|
// Should land on dashboard or a valid authenticated page
|
|
const url = page.url();
|
|
expect(url).not.toContain("/login");
|
|
});
|
|
|
|
test("session persists across page refresh", async ({ page }) => {
|
|
await loginAs(page, "admin@demo.com", "admin123");
|
|
|
|
// Reload the page
|
|
await page.reload();
|
|
await page.waitForLoadState("networkidle");
|
|
|
|
// Should still be on an authenticated page (not redirected to login)
|
|
const url = page.url();
|
|
expect(url).not.toContain("/login");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 2. Signup Page
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test.describe("Signup Page", () => {
|
|
test("renders signup form with all required fields", async ({ page }) => {
|
|
await page.goto("/signup");
|
|
|
|
await expect(page.locator("h1, h2").first()).toBeVisible();
|
|
// Check for key form fields
|
|
const inputs = await page.locator("input").count();
|
|
expect(inputs).toBeGreaterThanOrEqual(4); // name, email, password, confirm
|
|
await expect(page.locator('button[type="submit"]')).toBeVisible();
|
|
});
|
|
|
|
test("can register a new tenant", async ({ page }) => {
|
|
const ts = Date.now();
|
|
await page.goto("/signup");
|
|
|
|
// Fill in signup form fields
|
|
// The form has: company name, owner name, email, password, confirm password
|
|
const allInputs = page.locator("input");
|
|
const inputCount = await allInputs.count();
|
|
|
|
// Try to fill known field patterns
|
|
const companyInput = page.locator(
|
|
'input[name*="company"], input[name*="tenant"], input[name*="business"], input[placeholder*="company" i], input[placeholder*="ISP" i]'
|
|
);
|
|
if ((await companyInput.count()) > 0) {
|
|
await companyInput.first().fill(`UAT Test ISP ${ts}`);
|
|
}
|
|
|
|
const nameInputs = page.locator(
|
|
'input[name*="name"]:not([name*="company"]):not([name*="tenant"]):not([name*="business"]):not([type="email"]):not([type="password"])'
|
|
);
|
|
for (let i = 0; i < (await nameInputs.count()); i++) {
|
|
const name = await nameInputs.nth(i).getAttribute("name");
|
|
if (name?.includes("first") || name?.includes("First")) {
|
|
await nameInputs.nth(i).fill("UAT");
|
|
} else if (name?.includes("last") || name?.includes("Last")) {
|
|
await nameInputs.nth(i).fill("Tester");
|
|
} else {
|
|
await nameInputs.nth(i).fill("UAT Tester");
|
|
}
|
|
}
|
|
|
|
const emailInput = page.locator('input[type="email"], input[name="email"]');
|
|
if ((await emailInput.count()) > 0) {
|
|
await emailInput.first().fill(`uat-${ts}@test.com`);
|
|
}
|
|
|
|
const passwordInputs = page.locator('input[type="password"]');
|
|
const pwCount = await passwordInputs.count();
|
|
for (let i = 0; i < pwCount; i++) {
|
|
await passwordInputs.nth(i).fill("TestPass123!");
|
|
}
|
|
|
|
// Submit
|
|
await page.click('button[type="submit"]');
|
|
|
|
// Wait for result — should redirect to login with success or show success message
|
|
await page.waitForTimeout(3000);
|
|
const url = page.url();
|
|
const pageText = await page.textContent("body");
|
|
const success =
|
|
url.includes("registered") ||
|
|
url.includes("login") ||
|
|
url.includes("success") ||
|
|
pageText?.toLowerCase().includes("created") ||
|
|
pageText?.toLowerCase().includes("success") ||
|
|
pageText?.toLowerCase().includes("registered");
|
|
expect(success).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 3. Dashboard Page (Authenticated)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test.describe("Dashboard", () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await loginAs(page, "admin@demo.com", "admin123");
|
|
});
|
|
|
|
test("dashboard page loads", async ({ page }) => {
|
|
await page.goto("/dashboard");
|
|
await page.waitForLoadState("networkidle");
|
|
|
|
// Page should render without error
|
|
const status = page.url();
|
|
expect(status).toContain("/dashboard");
|
|
|
|
// Check for dashboard content (may be minimal since API-only was built)
|
|
const body = await page.textContent("body");
|
|
expect(body).toBeTruthy();
|
|
});
|
|
|
|
test("dashboard is not accessible when logged out", async ({ page }) => {
|
|
// Clear cookies to simulate logout
|
|
await page.context().clearCookies();
|
|
|
|
await page.goto("/dashboard");
|
|
await page.waitForLoadState("networkidle");
|
|
|
|
// Should redirect to login
|
|
const url = page.url();
|
|
expect(url).toContain("/login");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 4. Super-Admin Panel
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test.describe("Super-Admin Panel", () => {
|
|
test("super-admin can access tenant management", async ({ page }) => {
|
|
await loginAs(page, "superadmin@netforge.com", "super123");
|
|
|
|
await page.goto("/admin/tenants");
|
|
await page.waitForLoadState("networkidle");
|
|
|
|
// Should see tenant list
|
|
const body = await page.textContent("body");
|
|
expect(body).toContain("Demo ISP");
|
|
});
|
|
|
|
test("regular admin cannot access super-admin panel", async ({ page }) => {
|
|
await loginAs(page, "admin@demo.com", "admin123");
|
|
|
|
await page.goto("/admin/tenants");
|
|
await page.waitForLoadState("networkidle");
|
|
|
|
// Should be forbidden or redirected
|
|
const url = page.url();
|
|
const body = await page.textContent("body");
|
|
const blocked =
|
|
url.includes("/login") ||
|
|
url.includes("/dashboard") ||
|
|
body?.toLowerCase().includes("forbidden") ||
|
|
body?.toLowerCase().includes("denied") ||
|
|
body?.toLowerCase().includes("403");
|
|
expect(blocked).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 5. API Smoke Tests via Page Context
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test.describe("API via authenticated browser context", () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await loginAs(page, "admin@demo.com", "admin123");
|
|
});
|
|
|
|
test("GET /api/subscribers returns valid JSON", async ({ page }) => {
|
|
const response = await page.goto("/api/subscribers");
|
|
expect(response?.status()).toBe(200);
|
|
const json = await response?.json();
|
|
expect(json).toHaveProperty("subscribers");
|
|
expect(json).toHaveProperty("total");
|
|
});
|
|
|
|
test("GET /api/dashboard returns dashboard summary", async ({ page }) => {
|
|
const response = await page.goto("/api/dashboard");
|
|
expect(response?.status()).toBe(200);
|
|
const json = await response?.json();
|
|
expect(json).toHaveProperty("revenue");
|
|
expect(json).toHaveProperty("subscribers");
|
|
expect(json).toHaveProperty("cashFlow");
|
|
});
|
|
|
|
test("GET /api/service-plans returns array", async ({ page }) => {
|
|
const response = await page.goto("/api/service-plans");
|
|
expect(response?.status()).toBe(200);
|
|
const json = await response?.json();
|
|
expect(Array.isArray(json)).toBe(true);
|
|
});
|
|
|
|
test("GET /api/tickets returns tickets", async ({ page }) => {
|
|
const response = await page.goto("/api/tickets");
|
|
expect(response?.status()).toBe(200);
|
|
const json = await response?.json();
|
|
expect(json).toHaveProperty("tickets");
|
|
});
|
|
|
|
test("GET /api/zones returns array", async ({ page }) => {
|
|
const response = await page.goto("/api/zones");
|
|
expect(response?.status()).toBe(200);
|
|
const json = await response?.json();
|
|
expect(Array.isArray(json)).toBe(true);
|
|
});
|
|
|
|
test("GET /api/reports/trial-balance returns balanced books", async ({
|
|
page,
|
|
}) => {
|
|
const response = await page.goto("/api/reports/trial-balance");
|
|
expect(response?.status()).toBe(200);
|
|
const json = await response?.json();
|
|
expect(json).toHaveProperty("isBalanced");
|
|
expect(json.isBalanced).toBe(true);
|
|
});
|
|
});
|