diff --git a/Clients/e2e/assessment.spec.ts b/Clients/e2e/assessment.spec.ts new file mode 100644 index 0000000000..507ee5374c --- /dev/null +++ b/Clients/e2e/assessment.spec.ts @@ -0,0 +1,201 @@ +import { test, expect } from "./fixtures/auth.fixture"; +import AxeBuilder from "@axe-core/playwright"; + +test.describe("Assessment Tracker", () => { + test.beforeEach(async ({ authedPage: page }) => { + // Dismiss tours to avoid UI interference + await page.evaluate(() => { + localStorage.setItem("home-tour", "true"); + localStorage.setItem("compliance-tour", "true"); + localStorage.setItem("assessment-tour", "true"); + localStorage.setItem("projectFrameworks-tour", "true"); + }); + }); + + // --- Tier 0: Navigate to assessment via project view --- + + test("can reach the frameworks tab in project view", async ({ + authedPage: page, + }) => { + await page.goto("/project-view?tab=frameworks"); + await expect(page).toHaveURL(/\/project-view/); + + // Should show project content or framework-related elements + await expect( + page + .getByText(/framework/i) + .or(page.getByText(/regulation/i)) + .or(page.getByText(/control/i)) + .or(page.getByText(/assessment/i)) + .or(page.getByRole("heading")) + .first() + ).toBeVisible({ timeout: 15_000 }); + }); + + test("frameworks tab has Controls and Assessments toggle", async ({ + authedPage: page, + }) => { + await page.goto("/project-view?tab=frameworks"); + + // Look for the Controls / Assessments toggle buttons + const controlsBtn = page + .getByRole("button", { name: /controls/i }) + .or(page.getByText(/controls/i)); + const assessmentsBtn = page + .getByRole("button", { name: /assessments/i }) + .or(page.getByText(/assessments/i)); + + if ( + !(await controlsBtn.first().isVisible({ timeout: 10_000 }).catch(() => false)) + ) { + test.skip(); + return; + } + + await expect(controlsBtn.first()).toBeVisible(); + await expect(assessmentsBtn.first()).toBeVisible(); + }); + + // --- Tier 1: Assessment view content --- + + test("switching to Assessments tab shows assessment content", async ({ + authedPage: page, + }) => { + await page.goto("/project-view?tab=frameworks"); + + const assessmentsBtn = page + .getByRole("button", { name: /assessments/i }) + .or(page.getByText(/assessments/i)); + + if ( + !(await assessmentsBtn.first().isVisible({ timeout: 10_000 }).catch(() => false)) + ) { + test.skip(); + return; + } + + await assessmentsBtn.first().click(); + await page.waitForTimeout(1000); + + // Assessment view should show topic list or progress stats + const assessmentContent = page + .getByText(/assessment/i) + .or(page.getByText(/questions/i)) + .or(page.getByText(/high risk/i)) + .or(page.getByText(/conformity/i)) + .or(page.getByRole("list")); + + await expect(assessmentContent.first()).toBeVisible({ timeout: 10_000 }); + }); + + test("assessment shows progress stats", async ({ authedPage: page }) => { + await page.goto("/project-view?tab=frameworks"); + + const assessmentsBtn = page + .getByRole("button", { name: /assessments/i }) + .or(page.getByText(/assessments/i)); + + if ( + !(await assessmentsBtn.first().isVisible({ timeout: 10_000 }).catch(() => false)) + ) { + test.skip(); + return; + } + + await assessmentsBtn.first().click(); + await page.waitForTimeout(1000); + + // Should display progress information (answered/total questions) + const progressContent = page + .getByText(/questions/i) + .or(page.getByText(/answered/i)) + .or(page.getByText(/completed/i)) + .or(page.getByText(/progress/i)) + .or(page.locator('[class*="stats" i]')) + .or(page.locator('[class*="progress" i]')); + + if (await progressContent.first().isVisible().catch(() => false)) { + await expect(progressContent.first()).toBeVisible(); + } + }); + + // --- Tier 2: Topic navigation --- + + test("clicking a topic loads subtopics", async ({ authedPage: page }) => { + await page.goto("/project-view?tab=frameworks"); + + const assessmentsBtn = page + .getByRole("button", { name: /assessments/i }) + .or(page.getByText(/assessments/i)); + + if ( + !(await assessmentsBtn.first().isVisible({ timeout: 10_000 }).catch(() => false)) + ) { + test.skip(); + return; + } + + await assessmentsBtn.first().click(); + await page.waitForTimeout(1000); + + // Look for topic list items in the sidebar + const topicItems = page + .getByRole("listitem") + .or(page.locator('[class*="topic" i]')) + .or(page.locator(".MuiListItem-root")); + + if (await topicItems.first().isVisible({ timeout: 5_000 }).catch(() => false)) { + await topicItems.first().click(); + await page.waitForTimeout(1000); + + // After clicking a topic, subtopics or an accordion should appear + const subtopicContent = page + .locator('[class*="accordion" i]') + .or(page.locator(".MuiAccordion-root")) + .or(page.getByRole("button", { name: /expand/i })) + .or(page.locator('[class*="subtopic" i]')); + + if (await subtopicContent.first().isVisible().catch(() => false)) { + await expect(subtopicContent.first()).toBeVisible(); + } + } + }); + + // --- Accessibility --- + + test("assessment view has no accessibility violations", async ({ + authedPage: page, + }) => { + await page.goto("/project-view?tab=frameworks"); + await page.waitForLoadState("domcontentloaded"); + + // Switch to assessments tab if visible + const assessmentsBtn = page + .getByRole("button", { name: /assessments/i }) + .or(page.getByText(/assessments/i)); + + if (await assessmentsBtn.first().isVisible({ timeout: 10_000 }).catch(() => false)) { + await assessmentsBtn.first().click(); + await page.waitForTimeout(1000); + } + + const results = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) + .disableRules([ + "button-name", + "link-name", + "color-contrast", + "aria-command-name", + "aria-valid-attr-value", + "aria-input-field-name", + "label", + "select-name", + "scrollable-region-focusable", + "aria-progressbar-name", + "aria-prohibited-attr", + "nested-interactive", + ]) + .analyze(); + expect(results.violations).toEqual([]); + }); +}); diff --git a/Clients/e2e/auth.spec.ts b/Clients/e2e/auth.spec.ts index 16254fbf70..fd6c34c398 100644 --- a/Clients/e2e/auth.spec.ts +++ b/Clients/e2e/auth.spec.ts @@ -55,10 +55,10 @@ test.describe("Authentication", () => { await expect(page).toHaveURL(/\/forgot-password/); }); - test("register link navigates correctly", async ({ page }) => { - await page.goto("/login"); - await page.getByText("Register here").click(); - await expect(page).toHaveURL(/\/register/); + test("register route redirects to login", async ({ page }) => { + // /register now redirects to /login in single-tenant mode + await page.goto("/register"); + await expect(page).toHaveURL(/\/login/, { timeout: 10_000 }); }); test("login page has no accessibility violations", async ({ page }) => { @@ -95,12 +95,14 @@ test.describe("Authentication", () => { } // The sidebar footer has a MoreVertical icon button (no accessible name) - // next to the user name/role. Navigate from "Admin" text up to the - // user footer container and find the button sibling. + // next to the user name/role. Find the IconButton sibling of the + // user-info stack containing the "Admin" role label. const adminLabel = page.getByText("Admin", { exact: true }); await expect(adminLabel.first()).toBeVisible({ timeout: 10_000 }); - // Admin text → parent (name+role container) → grandparent (user footer) → button - const moreBtn = adminLabel.first().locator("xpath=../../button"); + // Walk up to the sidebar footer row and find the sibling button. + const moreBtn = adminLabel + .first() + .locator("xpath=ancestor::*[.//button][1]//button"); await expect(moreBtn.first()).toBeVisible({ timeout: 5_000 }); await moreBtn.first().click(); await page.waitForTimeout(1000); @@ -117,7 +119,8 @@ test.describe("Authentication", () => { // --- Registration form --- test("registration page renders form fields", async ({ page }) => { - await page.goto("/register"); + // /register redirects to /login; the user registration form lives at /user-reg + await page.goto("/user-reg"); await page.waitForLoadState("domcontentloaded"); // Verify registration form fields are present @@ -137,7 +140,7 @@ test.describe("Authentication", () => { test("registration form shows validation on empty submit", async ({ page, }) => { - await page.goto("/register"); + await page.goto("/user-reg"); await page.waitForLoadState("domcontentloaded"); // The "Get started" button should be present diff --git a/Clients/e2e/compliance-tracker.spec.ts b/Clients/e2e/compliance-tracker.spec.ts new file mode 100644 index 0000000000..4a2af62aa2 --- /dev/null +++ b/Clients/e2e/compliance-tracker.spec.ts @@ -0,0 +1,207 @@ +import { test, expect } from "./fixtures/auth.fixture"; +import AxeBuilder from "@axe-core/playwright"; + +test.describe("Compliance Tracker", () => { + test.beforeEach(async ({ authedPage: page }) => { + // Dismiss tours to avoid UI interference + await page.evaluate(() => { + localStorage.setItem("home-tour", "true"); + localStorage.setItem("compliance-tour", "true"); + localStorage.setItem("assessment-tour", "true"); + localStorage.setItem("projectFrameworks-tour", "true"); + }); + }); + + // --- Tier 0: Navigate to compliance tracker via project view --- + + test("compliance tracker renders in frameworks tab", async ({ + authedPage: page, + }) => { + await page.goto("/project-view?tab=frameworks"); + await expect(page).toHaveURL(/\/project-view/); + + // Controls tab is the default in ProjectFrameworks + // Should show compliance-related content or page structure + const content = page + .getByText(/control/i) + .or(page.getByText(/compliance/i)) + .or(page.getByText(/subcontrol/i)) + .or(page.getByText(/framework/i)) + .or(page.getByRole("heading")) + .or(page.getByRole("navigation")); + + if ( + !(await content.first().isVisible({ timeout: 15_000 }).catch(() => false)) + ) { + test.skip(); + return; + } + + await expect(content.first()).toBeVisible(); + }); + + // --- Tier 1: Progress stats --- + + test("displays compliance progress stats", async ({ + authedPage: page, + }) => { + await page.goto("/project-view?tab=frameworks"); + + // Should show subcontrol progress (e.g., "Subcontrols 5/20") + const progressContent = page + .getByText(/subcontrol/i) + .or(page.getByText(/completed/i)) + .or(page.getByText(/progress/i)) + .or(page.locator('[class*="stats" i]')) + .or(page.locator('[class*="progress" i]')); + + if ( + !(await progressContent.first().isVisible({ timeout: 10_000 }).catch(() => false)) + ) { + test.skip(); + return; + } + + await expect(progressContent.first()).toBeVisible(); + }); + + // --- Tier 1: Control categories --- + + test("shows control categories list", async ({ authedPage: page }) => { + await page.goto("/project-view?tab=frameworks"); + + // Control categories should appear as expandable tiles + const categories = page + .locator('[class*="control-category" i]') + .or(page.locator('[class*="ControlCategory" i]')) + .or(page.locator(".MuiAccordion-root")) + .or(page.getByRole("button", { name: /article/i })) + .or(page.locator('[class*="category" i]')); + + if ( + !(await categories.first().isVisible({ timeout: 10_000 }).catch(() => false)) + ) { + // May not have any categories if no framework is assigned + test.skip(); + return; + } + + await expect(categories.first()).toBeVisible(); + }); + + // --- Tier 2: Expand/collapse control category --- + + test("clicking a control category expands it", async ({ + authedPage: page, + }) => { + await page.goto("/project-view?tab=frameworks"); + + // Find expandable control category elements + const categories = page + .locator('[class*="control-category" i]') + .or(page.locator(".MuiAccordion-root")) + .or(page.locator('[class*="category" i]')); + + if ( + !(await categories.first().isVisible({ timeout: 10_000 }).catch(() => false)) + ) { + test.skip(); + return; + } + + await categories.first().click(); + await page.waitForTimeout(1000); + + // After expanding, should show controls table or control items + const controlContent = page + .getByRole("table") + .or(page.locator('[class*="controls-table" i]')) + .or(page.getByRole("row")) + .or(page.getByText(/status/i)); + + if (await controlContent.first().isVisible().catch(() => false)) { + await expect(controlContent.first()).toBeVisible(); + } + }); + + // --- Tier 2: Framework tab switching --- + + test("can switch between frameworks if multiple are assigned", async ({ + authedPage: page, + }) => { + await page.goto("/project-view?tab=frameworks"); + + // Look for framework tabs (e.g., EU AI Act, ISO 42001) + const frameworkTabs = page + .getByRole("tab") + .or(page.locator('[role="tab"]')); + + if ( + !(await frameworkTabs.first().isVisible({ timeout: 10_000 }).catch(() => false)) + ) { + test.skip(); + return; + } + + const tabCount = await frameworkTabs.count(); + if (tabCount < 2) { + // Only one framework, nothing to switch + test.skip(); + return; + } + + // Click the second tab + await frameworkTabs.nth(1).click(); + await page.waitForTimeout(1000); + + // Content should update + await expect(page.locator("body")).not.toBeEmpty(); + }); + + // --- Tier 2: Filter controls --- + + test("filter bar is available for controls", async ({ + authedPage: page, + }) => { + await page.goto("/project-view?tab=frameworks"); + + // Look for filter elements + const filterElements = page + .getByText(/filter/i) + .or(page.getByRole("combobox")) + .or(page.getByPlaceholder(/search/i)) + .or(page.locator('[class*="filter" i]')); + + if (await filterElements.first().isVisible({ timeout: 10_000 }).catch(() => false)) { + await expect(filterElements.first()).toBeVisible(); + } + }); + + // --- Accessibility --- + + test("compliance tracker has no accessibility violations", async ({ + authedPage: page, + }) => { + await page.goto("/project-view?tab=frameworks"); + await page.waitForLoadState("domcontentloaded"); + + const results = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) + .disableRules([ + "button-name", + "link-name", + "color-contrast", + "aria-command-name", + "aria-valid-attr-value", + "aria-input-field-name", + "label", + "select-name", + "scrollable-region-focusable", + "aria-progressbar-name", + "aria-prohibited-attr", + "nested-interactive", + ]) + .analyze(); + expect(results.violations).toEqual([]); + }); +}); diff --git a/Clients/e2e/fixtures/project.fixture.ts b/Clients/e2e/fixtures/project.fixture.ts index 0aa3f7d5ab..b91ef6d199 100644 --- a/Clients/e2e/fixtures/project.fixture.ts +++ b/Clients/e2e/fixtures/project.fixture.ts @@ -47,14 +47,10 @@ export const test = base.extend<{ await page.waitForTimeout(500); } - // Fill project title — the input is an unlabeled textbox under "Use case title*" - // Use the text label to locate the adjacent input - const titleInput = page - .getByText(/use case title/i) - .locator("..") - .getByRole("textbox"); - await expect(titleInput.first()).toBeVisible({ timeout: 10_000 }); - await titleInput.first().fill(projectName); + // Fill project title using the stable id from CreateProjectForm + const titleInput = page.locator("#project-title-input"); + await expect(titleInput).toBeVisible({ timeout: 10_000 }); + await titleInput.fill(projectName); // Fill Goal field (required) — unlabeled textbox under "Goal*" const goalInput = page diff --git a/Clients/e2e/plugins.spec.ts b/Clients/e2e/plugins.spec.ts index de10c4db3f..59ff31e8c3 100644 --- a/Clients/e2e/plugins.spec.ts +++ b/Clients/e2e/plugins.spec.ts @@ -6,9 +6,10 @@ test.describe("Plugins", () => { await page.goto("/plugins"); await expect(page).toHaveURL(/\/plugins/); - // Page should show plugin-related content + // Page should show plugin-related content — tighten to the page heading + // to avoid strict-mode matches across nav links, tooltips, etc. await expect( - page.getByText(/plugin/i).first() + page.getByRole("heading", { name: /^plugins$/i }).first() ).toBeVisible({ timeout: 10_000 }); }); diff --git a/Clients/e2e/public-intake-form.spec.ts b/Clients/e2e/public-intake-form.spec.ts new file mode 100644 index 0000000000..8a17bb8b1a --- /dev/null +++ b/Clients/e2e/public-intake-form.spec.ts @@ -0,0 +1,297 @@ +import { test as base, expect, type Page } from "@playwright/test"; +import AxeBuilder from "@axe-core/playwright"; + +/** + * Custom fixture for public intake form tests. + * Uses an authenticated page to first discover a valid intake form slug, + * then provides an unauthenticated page for testing the public form. + */ +const test = base.extend<{ publicFormUrl: string | null }>({ + publicFormUrl: async ({ browser }, use) => { + // Use authenticated context to discover a public form URL + const authContext = await browser.newContext({ + storageState: "e2e/.auth/user.json", + }); + const authPage = await authContext.newPage(); + + let formUrl: string | null = null; + + try { + await authPage.goto("/intake-forms", { waitUntil: "domcontentloaded" }); + + // Wait for page to load + await authPage.waitForTimeout(3000); + + // Look for a form link, share button, or public URL in the page + const publicLink = authPage + .getByRole("link", { name: /view|preview|public/i }) + .or(authPage.locator('a[href*="use-case-form-intake"]')) + .or(authPage.locator('a[href*="/intake/"]')); + + if (await publicLink.first().isVisible().catch(() => false)) { + formUrl = await publicLink.first().getAttribute("href"); + } + } catch { + // Unable to discover form URL — tests will skip + } finally { + await authContext.close(); + } + + await use(formUrl); + }, +}); + +test.describe("Public Intake Form", () => { + // --- Tier 0: Route resolution --- + + test("public form route renders form or 404", async ({ page }) => { + // Try a known test path — should render form page or not-found + await page.goto("/test-public-id/use-case-form-intake", { + waitUntil: "domcontentloaded", + }); + + // Page should render something (form, error, or 404) + await expect(page.locator("body")).not.toBeEmpty(); + + const content = page + .getByText(/form/i) + .or(page.getByText(/not found/i)) + .or(page.getByText(/expired/i)) + .or(page.getByText(/loading/i)) + .or(page.getByText(/submit/i)) + .or(page.getByRole("heading")); + + await expect(content.first()).toBeVisible({ timeout: 15_000 }); + }); + + test("legacy route format also works", async ({ page }) => { + await page.goto("/intake/test-tenant/test-form", { + waitUntil: "domcontentloaded", + }); + + await expect(page.locator("body")).not.toBeEmpty(); + + const content = page + .getByText(/form/i) + .or(page.getByText(/not found/i)) + .or(page.getByText(/expired/i)) + .or(page.getByRole("heading")); + + await expect(content.first()).toBeVisible({ timeout: 15_000 }); + }); + + // --- Tier 1: Form rendering (requires a real form) --- + + test("renders form fields when a valid form exists", async ({ + publicFormUrl, + page, + }) => { + if (!publicFormUrl) { + test.skip(); + return; + } + + await page.goto(publicFormUrl, { waitUntil: "domcontentloaded" }); + await page.waitForTimeout(2000); + + // Form should show input fields + const formFields = page + .getByRole("textbox") + .or(page.locator("input")) + .or(page.locator("select")) + .or(page.locator("textarea")); + + if ( + !(await formFields.first().isVisible({ timeout: 10_000 }).catch(() => false)) + ) { + test.skip(); + return; + } + + await expect(formFields.first()).toBeVisible(); + }); + + test("form has a submit button", async ({ publicFormUrl, page }) => { + if (!publicFormUrl) { + test.skip(); + return; + } + + await page.goto(publicFormUrl, { waitUntil: "domcontentloaded" }); + await page.waitForTimeout(2000); + + const submitBtn = page + .getByRole("button", { name: /submit/i }) + .or(page.getByRole("button", { name: /send/i })); + + if (await submitBtn.first().isVisible().catch(() => false)) { + await expect(submitBtn.first()).toBeVisible(); + } + }); + + test("form shows contact info fields (name and email)", async ({ + publicFormUrl, + page, + }) => { + if (!publicFormUrl) { + test.skip(); + return; + } + + await page.goto(publicFormUrl, { waitUntil: "domcontentloaded" }); + await page.waitForTimeout(2000); + + // Contact fields: name and email + const nameField = page + .getByPlaceholder(/name/i) + .or(page.getByRole("textbox", { name: /name/i })); + const emailField = page + .getByPlaceholder(/email/i) + .or(page.getByRole("textbox", { name: /email/i })); + + if (await nameField.first().isVisible().catch(() => false)) { + await expect(nameField.first()).toBeVisible(); + } + if (await emailField.first().isVisible().catch(() => false)) { + await expect(emailField.first()).toBeVisible(); + } + }); + + // --- Tier 2: Form validation --- + + test("submitting empty required fields shows validation errors", async ({ + publicFormUrl, + page, + }) => { + if (!publicFormUrl) { + test.skip(); + return; + } + + await page.goto(publicFormUrl, { waitUntil: "domcontentloaded" }); + await page.waitForTimeout(2000); + + // Try submitting without filling any fields + const submitBtn = page + .getByRole("button", { name: /submit/i }) + .or(page.getByRole("button", { name: /send/i })); + + if (!(await submitBtn.first().isVisible().catch(() => false))) { + test.skip(); + return; + } + + await submitBtn.first().click(); + await page.waitForTimeout(1000); + + // Should show validation error(s) + const errorMsg = page + .getByText(/required/i) + .or(page.getByText(/please/i)) + .or(page.locator('[class*="error" i]')) + .or(page.locator(".Mui-error")); + + if (await errorMsg.first().isVisible().catch(() => false)) { + await expect(errorMsg.first()).toBeVisible(); + } + }); + + // --- Tier 2: Math captcha --- + + test("math captcha is displayed on the form", async ({ + publicFormUrl, + page, + }) => { + if (!publicFormUrl) { + test.skip(); + return; + } + + await page.goto(publicFormUrl, { waitUntil: "domcontentloaded" }); + await page.waitForTimeout(2000); + + // Look for math captcha element + const captcha = page + .getByText(/\d+\s*[+\-×÷]\s*\d+/i) + .or(page.getByPlaceholder(/answer/i)) + .or(page.getByText(/captcha/i)) + .or(page.locator('[class*="captcha" i]')); + + if (await captcha.first().isVisible().catch(() => false)) { + await expect(captcha.first()).toBeVisible(); + } + }); + + // --- Tier 3: Powered by footer --- + + test("shows Powered by VerifyWise footer", async ({ + publicFormUrl, + page, + }) => { + if (!publicFormUrl) { + test.skip(); + return; + } + + await page.goto(publicFormUrl, { waitUntil: "domcontentloaded" }); + await page.waitForTimeout(2000); + + const footer = page.getByText(/powered by/i).or(page.getByText(/verifywise/i)); + + if (await footer.first().isVisible().catch(() => false)) { + await expect(footer.first()).toBeVisible(); + } + }); + + // --- Accessibility --- + + test("public form has no accessibility violations", async ({ page }) => { + await page.goto("/test-public-id/use-case-form-intake", { + waitUntil: "domcontentloaded", + }); + await page.waitForTimeout(2000); + + const results = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) + .disableRules([ + "button-name", + "link-name", + "color-contrast", + "aria-command-name", + "aria-valid-attr-value", + "label", + "select-name", + "scrollable-region-focusable", + "aria-progressbar-name", + "aria-prohibited-attr", + "nested-interactive", + ]) + .analyze(); + expect(results.violations).toEqual([]); + }); +}); + +// --- Success page --- + +test.describe("Submission Success Page", () => { + test("success page renders correctly", async ({ page }) => { + await page.goto("/test-public-id/use-case-form-intake/success", { + waitUntil: "domcontentloaded", + }); + + await expect(page.locator("body")).not.toBeEmpty(); + + // Should show success content, form-unavailable message, or redirect + const content = page + .getByText(/success/i) + .or(page.getByText(/submitted/i)) + .or(page.getByText(/thank/i)) + .or(page.getByText(/reference/i)) + .or(page.getByText(/pending/i)) + .or(page.getByText(/unavailable/i)) + .or(page.getByText(/expired/i)) + .or(page.getByRole("heading")); + + await expect(content.first()).toBeVisible({ timeout: 15_000 }); + }); +}); diff --git a/Clients/e2e/settings.spec.ts b/Clients/e2e/settings.spec.ts index b15242fabb..ee0c6e2a43 100644 --- a/Clients/e2e/settings.spec.ts +++ b/Clients/e2e/settings.spec.ts @@ -54,9 +54,7 @@ test.describe("Settings", () => { authedPage: page, }) => { await page.goto("/settings"); - const passwordTab = page - .getByRole("tab", { name: /password/i }) - .or(page.getByText(/password/i)); + const passwordTab = page.getByRole("tab").filter({ hasText: /^password$/i }); await expect(passwordTab.first()).toBeVisible({ timeout: 10_000 }); await passwordTab.first().click(); await expect(page).toHaveURL(/\/settings\/password/, { timeout: 10_000 }); @@ -66,9 +64,9 @@ test.describe("Settings", () => { authedPage: page, }) => { await page.goto("/settings"); - const orgTab = page.getByRole("tab", { name: /organization/i }); - await expect(orgTab).toBeVisible({ timeout: 10_000 }); - await orgTab.click(); + const orgTab = page.getByRole("tab").filter({ hasText: /^organization$/i }); + await expect(orgTab.first()).toBeVisible({ timeout: 10_000 }); + await orgTab.first().click(); await expect(page).toHaveURL(/\/settings\/organization/, { timeout: 10_000, }); @@ -78,11 +76,11 @@ test.describe("Settings", () => { authedPage: page, }) => { await page.goto("/settings/password"); - const profileTab = page.getByRole("tab", { name: /profile/i }); - await expect(profileTab).toBeVisible({ timeout: 10_000 }); - await profileTab.click(); + const profileTab = page.getByRole("tab").filter({ hasText: /^profile$/i }); + await expect(profileTab.first()).toBeVisible({ timeout: 10_000 }); + await profileTab.first().click(); // Should be back on /settings (profile is default) - await expect(page).toHaveURL(/\/settings/, { timeout: 10_000 }); + await expect(page).toHaveURL(/\/settings(\/?|$)/, { timeout: 10_000 }); }); // --- Tier 3: Password form fields --- diff --git a/Clients/e2e/super-admin.spec.ts b/Clients/e2e/super-admin.spec.ts new file mode 100644 index 0000000000..d48d34037c --- /dev/null +++ b/Clients/e2e/super-admin.spec.ts @@ -0,0 +1,384 @@ +import { test, expect } from "./fixtures/auth.fixture"; +import AxeBuilder from "@axe-core/playwright"; + +test.describe("Super Admin", () => { + // --- Tier 0: Page load --- + + test.describe("Organizations", () => { + test("renders the organizations page", async ({ authedPage: page }) => { + await page.goto("/super-admin"); + + // May redirect if user is not a super admin — skip in that case + if (page.url().includes("/login") || page.url() === page.context().pages()[0]?.url()) { + const isOnSuperAdmin = page.url().includes("/super-admin"); + if (!isOnSuperAdmin) { + test.skip(); + return; + } + } + + await expect( + page + .getByText(/organization/i) + .or(page.getByRole("heading")) + .first() + ).toBeVisible({ timeout: 15_000 }); + }); + + test("displays organizations table or empty state", async ({ + authedPage: page, + }) => { + await page.goto("/super-admin"); + + if (!page.url().includes("/super-admin")) { + test.skip(); + return; + } + + const content = page + .getByRole("table") + .or(page.getByText(/no.*organization/i)) + .or(page.getByText(/organization/i)) + .or(page.getByRole("heading")); + await expect(content.first()).toBeVisible({ timeout: 10_000 }); + }); + + test("search box filters organizations", async ({ authedPage: page }) => { + await page.goto("/super-admin"); + + if (!page.url().includes("/super-admin")) { + test.skip(); + return; + } + + const searchInput = page + .getByPlaceholder(/search/i) + .or(page.locator('[data-testid="search-input"]')); + + if ( + !(await searchInput.first().isVisible({ timeout: 10_000 }).catch(() => false)) + ) { + test.skip(); + return; + } + + await searchInput.first().fill("nonexistent-org-xyz"); + await page.waitForTimeout(500); + await searchInput.first().clear(); + await page.waitForTimeout(500); + }); + + test("create organization button opens modal", async ({ + authedPage: page, + }) => { + await page.goto("/super-admin"); + + if (!page.url().includes("/super-admin")) { + test.skip(); + return; + } + + const createBtn = page + .getByRole("button", { name: /create.*organization/i }) + .or(page.getByRole("button", { name: /add.*organization/i })) + .or(page.getByRole("button", { name: /new.*organization/i })); + + if (!(await createBtn.first().isVisible().catch(() => false))) { + test.skip(); + return; + } + + await createBtn.first().click(); + + // Modal should open with organization name input + const modal = page + .getByText(/create.*organization/i) + .or(page.getByText(/organization.*name/i)) + .or(page.getByRole("dialog")); + await expect(modal.first()).toBeVisible({ timeout: 5_000 }); + + await page.keyboard.press("Escape"); + }); + + test("table columns are sortable", async ({ authedPage: page }) => { + await page.goto("/super-admin"); + + if (!page.url().includes("/super-admin")) { + test.skip(); + return; + } + + // Look for sortable column headers (Name, Users, Created) + const nameHeader = page + .getByRole("columnheader", { name: /name/i }) + .or(page.getByText(/name/i).first()); + + if (await nameHeader.isVisible({ timeout: 10_000 }).catch(() => false)) { + await nameHeader.click(); + await page.waitForTimeout(500); + + // Sort indicator should appear + const sortIndicator = page + .locator('[class*="sort" i]') + .or(page.locator("svg")) + .or(page.locator('[aria-sort]')); + + // Just verify the click didn't break anything + await expect(page.locator("body")).not.toBeEmpty(); + } + }); + + test("page has no accessibility violations", async ({ + authedPage: page, + }) => { + await page.goto("/super-admin"); + + if (!page.url().includes("/super-admin")) { + test.skip(); + return; + } + + await page.waitForLoadState("domcontentloaded"); + + const results = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) + .disableRules([ + "button-name", + "link-name", + "color-contrast", + "aria-command-name", + "aria-valid-attr-value", + "label", + "select-name", + "scrollable-region-focusable", + "aria-progressbar-name", + "aria-prohibited-attr", + "nested-interactive", + ]) + .analyze(); + expect(results.violations).toEqual([]); + }); + }); + + // --- All Users --- + + test.describe("All Users", () => { + test("renders the all users page", async ({ authedPage: page }) => { + await page.goto("/super-admin/users"); + + if (!page.url().includes("/super-admin")) { + test.skip(); + return; + } + + await expect( + page + .getByText(/user/i) + .or(page.getByRole("heading")) + .first() + ).toBeVisible({ timeout: 15_000 }); + }); + + test("displays users table with columns", async ({ + authedPage: page, + }) => { + await page.goto("/super-admin/users"); + + if (!page.url().includes("/super-admin")) { + test.skip(); + return; + } + + const table = page + .getByRole("table") + .or(page.getByText(/name/i)) + .or(page.getByText(/user/i)) + .or(page.getByRole("heading")); + + if ( + !(await table.first().isVisible({ timeout: 10_000 }).catch(() => false)) + ) { + test.skip(); + return; + } + + await expect(table.first()).toBeVisible(); + }); + + test("search filters users", async ({ authedPage: page }) => { + await page.goto("/super-admin/users"); + + if (!page.url().includes("/super-admin")) { + test.skip(); + return; + } + + const searchInput = page + .getByPlaceholder(/search/i) + .or(page.locator('[data-testid="search-input"]')); + + if ( + !(await searchInput.first().isVisible({ timeout: 10_000 }).catch(() => false)) + ) { + test.skip(); + return; + } + + await searchInput.first().fill("nonexistent-user-xyz"); + await page.waitForTimeout(500); + await searchInput.first().clear(); + }); + + test("organization filter dropdown is available", async ({ + authedPage: page, + }) => { + await page.goto("/super-admin/users"); + + if (!page.url().includes("/super-admin")) { + test.skip(); + return; + } + + // Look for organization filter (Autocomplete) + const orgFilter = page + .getByRole("combobox", { name: /organization/i }) + .or(page.getByPlaceholder(/organization/i)) + .or(page.getByText(/all organizations/i)); + + if (await orgFilter.first().isVisible().catch(() => false)) { + await expect(orgFilter.first()).toBeVisible(); + } + }); + + test("role filter dropdown is available", async ({ + authedPage: page, + }) => { + await page.goto("/super-admin/users"); + + if (!page.url().includes("/super-admin")) { + test.skip(); + return; + } + + // Look for role filter (Select) + const roleFilter = page + .getByRole("combobox", { name: /role/i }) + .or(page.getByText(/all roles/i)) + .or(page.getByPlaceholder(/role/i)); + + if (await roleFilter.first().isVisible().catch(() => false)) { + await expect(roleFilter.first()).toBeVisible(); + } + }); + }); + + // --- Settings --- + + test.describe("Settings", () => { + test("renders the super admin settings page", async ({ + authedPage: page, + }) => { + await page.goto("/super-admin/settings"); + + if (!page.url().includes("/super-admin")) { + test.skip(); + return; + } + + await expect( + page + .getByText(/settings/i) + .or(page.getByText(/profile/i)) + .or(page.getByRole("heading")) + .first() + ).toBeVisible({ timeout: 15_000 }); + }); + + test("has Profile and Password tabs", async ({ authedPage: page }) => { + await page.goto("/super-admin/settings"); + + if (!page.url().includes("/super-admin")) { + test.skip(); + return; + } + + const profileTab = page + .getByRole("tab", { name: /profile/i }) + .or(page.getByText(/profile/i)); + const passwordTab = page + .getByRole("tab", { name: /password/i }) + .or(page.getByText(/password/i)); + + if (await profileTab.first().isVisible({ timeout: 10_000 }).catch(() => false)) { + await expect(profileTab.first()).toBeVisible(); + } + + if (await passwordTab.first().isVisible().catch(() => false)) { + await expect(passwordTab.first()).toBeVisible(); + } + }); + + test("clicking Password tab shows password form", async ({ + authedPage: page, + }) => { + await page.goto("/super-admin/settings"); + + if (!page.url().includes("/super-admin")) { + test.skip(); + return; + } + + const passwordTab = page + .getByRole("tab", { name: /password/i }) + .or(page.getByText(/password/i)); + + if ( + !(await passwordTab.first().isVisible({ timeout: 10_000 }).catch(() => false)) + ) { + test.skip(); + return; + } + + await passwordTab.first().click(); + await page.waitForTimeout(500); + + // Should show password input fields + const passwordField = page + .locator('input[type="password"]') + .or(page.getByPlaceholder(/password/i)); + + if (await passwordField.first().isVisible().catch(() => false)) { + await expect(passwordField.first()).toBeVisible(); + } + }); + }); + + // --- Navigation between sub-pages --- + + test("can navigate from organizations to users page", async ({ + authedPage: page, + }) => { + await page.goto("/super-admin"); + + if (!page.url().includes("/super-admin")) { + test.skip(); + return; + } + + // Look for a "Users" button in the table rows + const usersBtn = page + .getByRole("button", { name: /users/i }) + .or(page.getByRole("link", { name: /users/i })); + + if (await usersBtn.first().isVisible({ timeout: 10_000 }).catch(() => false)) { + await usersBtn.first().click(); + await page.waitForTimeout(1000); + + // Should navigate to organization users page + const isUsersPage = + page.url().includes("/users") || + page.url().includes("/super-admin"); + expect(isUsersPage).toBe(true); + } + }); +}); diff --git a/Clients/src/presentation/components/ReadOnlyBanner/index.tsx b/Clients/src/presentation/components/ReadOnlyBanner/index.tsx index 424621b0bf..110d7395f6 100644 --- a/Clients/src/presentation/components/ReadOnlyBanner/index.tsx +++ b/Clients/src/presentation/components/ReadOnlyBanner/index.tsx @@ -104,6 +104,8 @@ const ReadOnlyBanner = () => { value={activeOrganizationId} onChange={handleOrgChange} displayEmpty + aria-label="Select organization" + inputProps={{ "aria-label": "Select organization" }} IconComponent={() => (