diff --git a/.github/workflows/ui-e2e-tests.yml b/.github/workflows/ui-e2e-tests.yml index 5daa5d4b3e..e134d1eb38 100644 --- a/.github/workflows/ui-e2e-tests.yml +++ b/.github/workflows/ui-e2e-tests.yml @@ -42,6 +42,7 @@ jobs: E2E_GITHUB_PERSONAL_ACCESS_TOKEN: ${{ secrets.E2E_GITHUB_PERSONAL_ACCESS_TOKEN }} E2E_GITHUB_ORGANIZATION: ${{ secrets.E2E_GITHUB_ORGANIZATION }} E2E_GITHUB_ORGANIZATION_ACCESS_TOKEN: ${{ secrets.E2E_GITHUB_ORGANIZATION_ACCESS_TOKEN }} + E2E_ORGANIZATION_ID: ${{ secrets.E2E_ORGANIZATION_ID }} E2E_NEW_USER_PASSWORD: ${{ secrets.E2E_NEW_USER_PASSWORD }} steps: diff --git a/ui/package.json b/ui/package.json index b8732ad1fd..39f6fd37b4 100644 --- a/ui/package.json +++ b/ui/package.json @@ -15,10 +15,10 @@ "format:check": "./node_modules/.bin/prettier --check ./app", "format:write": "./node_modules/.bin/prettier --config .prettierrc.json --write ./app", "prepare": "husky", - "test:e2e": "playwright test --project=chromium --project=sign-up --project=providers", - "test:e2e:ui": "playwright test --project=chromium --project=sign-up --project=providers --ui", - "test:e2e:debug": "playwright test --project=chromium --project=sign-up --project=providers --debug", - "test:e2e:headed": "playwright test --project=chromium --project=sign-up --project=providers --headed", + "test:e2e": "playwright test --project=chromium --project=sign-up --project=providers --project=invitations", + "test:e2e:ui": "playwright test --project=chromium --project=sign-up --project=providers --project=invitations --ui", + "test:e2e:debug": "playwright test --project=chromium --project=sign-up --project=providers --project=invitations --debug", + "test:e2e:headed": "playwright test --project=chromium --project=sign-up --project=providers --project=invitations --headed", "test:e2e:report": "playwright show-report", "test:e2e:install": "playwright install" }, diff --git a/ui/playwright.config.ts b/ui/playwright.config.ts index 78f840e125..c36bf2ed42 100644 --- a/ui/playwright.config.ts +++ b/ui/playwright.config.ts @@ -102,6 +102,12 @@ export default defineConfig({ testMatch: "providers.spec.ts", dependencies: ["admin.auth.setup"], }, + // This project runs the invitations test suite + { + name: "invitations", + testMatch: "invitations.spec.ts", + dependencies: ["admin.auth.setup"], + }, ], webServer: { diff --git a/ui/tests/invitations/invitations-page.ts b/ui/tests/invitations/invitations-page.ts new file mode 100644 index 0000000000..0bddbc99b8 --- /dev/null +++ b/ui/tests/invitations/invitations-page.ts @@ -0,0 +1,113 @@ +import { Page, Locator, expect } from "@playwright/test"; +import { BasePage } from "../base-page"; + + +export class InvitationsPage extends BasePage { + + // Page heading + readonly pageHeadingSendInvitation: Locator; + readonly pageHeadingInvitations: Locator; + + // UI elements + readonly sendInviteButton: Locator; + readonly emailInput: Locator; + readonly roleSelect: Locator; + + // Invitation details + readonly invitationDetails: Locator; + readonly shareUrl: Locator; + + + constructor(page: Page) { + super(page); + + // Page heading + this.pageHeadingInvitations = page.getByRole("heading", { name: "Invitations" }); + this.pageHeadingSendInvitation = page.getByRole("heading", { name: "Send Invitation" }); + + // Button to invite a new user + this.sendInviteButton = page.getByRole("button", { name: "Send Invitation", exact: true }); + + // Form inputs + this.emailInput = page.getByRole("textbox", { name: "Email" }); + + // Form select + this.roleSelect = page.getByRole("button", { name: /Role|Select a role/i }); + + // Form details + this.invitationDetails = page.getByRole('heading', { name: 'Invitation details' }); + + // Multiple strategies to find the share URL + this.shareUrl = page.locator('a[href*="/sign-up?invitation_token="], [data-testid="share-url"], .share-url, code, pre').first(); + } + + async goto(): Promise { + // Navigate to the invitations page + + await super.goto("/invitations"); + } + + async clickSendInviteButton(): Promise { + // Click the send invitation button + + await this.sendInviteButton.click(); + await this.waitForPageLoad(); + } + + async verifyPageLoaded(): Promise { + // Verify the invitations page is loaded + + await expect(this.pageHeadingInvitations).toBeVisible(); + await this.waitForPageLoad(); + } + + async verifyInvitePageLoaded(): Promise { + // Verify the invite page is loaded + + await expect(this.emailInput).toBeVisible(); + await expect(this.sendInviteButton).toBeVisible(); + await this.waitForPageLoad(); + } + + async fillEmail(email: string): Promise { + // Fill the email input + await this.emailInput.fill(email); + } + + async selectRole(role: string): Promise { + // Select the role option + + // Open the role dropdown + await this.roleSelect.click(); + + // Prefer ARIA role option inside listbox + const option = this.page.getByRole("option", { name: new RegExp(`^${role}$`, "i") }); + + if (await option.count()) { + await option.first().click(); + } else { + throw new Error(`Role option ${role} not found`); + } + // Ensure the combobox now shows the chosen value + await expect(this.roleSelect).toContainText(new RegExp(role, "i")); + } + + async verifyInviteDataPageLoaded(): Promise { + // Verify the invite data page is loaded + + await expect(this.invitationDetails).toBeVisible(); + await this.waitForPageLoad(); + } + + async getShareUrl(): Promise { + // Get the share url + + // Get the share url text content + const text = await this.shareUrl.textContent(); + + if (!text) { + throw new Error("Share url not found"); + } + return text; + } +} diff --git a/ui/tests/invitations/invitations.md b/ui/tests/invitations/invitations.md new file mode 100644 index 0000000000..9331d0e11c --- /dev/null +++ b/ui/tests/invitations/invitations.md @@ -0,0 +1,66 @@ +### E2E Tests: Invitations Management + +**Suite ID:** `INVITATION-E2E` +**Feature:** User Invitations. + +--- + +## Test Case: `INVITATION-E2E-001` - Invite New User and Complete Sign-Up + +**Priority:** `critical` + +**Tags:** + +- type → @e2e +- feature → @invitations +- id → @INVITATION-E2E-001 + +**Description/Objective:** Validates the full flow to invite a new user from the admin session, consume the invitation link, sign up as the invited user, authenticate, and verify the user is associated to the expected organization. + +**Preconditions:** + +- Admin authentication state available: `playwright/.auth/admin_user.json` (admin.auth.setup) +- Environment variables configured: + - `E2E_NEW_USER_PASSWORD` (password for the invited user) + - `E2E_ORGANIZATION_ID` (expected organization for membership verification) +- Application running with accessible UI/API endpoints + +### Flow Steps: + +1. Navigate to invitations page +2. Click "Send Invitation" button +3. Fill unique email address for the invite +4. Select role `e2e_admin` +5. Click "Send Invitation" to generate invitation +6. Read the generated share URL from the invitation details +7. Open a new browser context (no admin cookies) and navigate to the share URL +8. Complete sign-up with provided password and accept terms +9. Verify sign-up success (no errors) and redirect to login page +10. Log in with the newly created credentials in the new context +11. Verify successful login +12. Navigate to user profile and verify `organizationId` matches `E2E_ORGANIZATION_ID` + +### Expected Result: + +- Invitation is created and a valid share URL is provided +- Invited user can sign up successfully using the invitation link +- User is redirected to the login page after sign-up (OSS flow) +- Login succeeds with the new credentials +- User profile shows membership in the expected organization + +### Key verification points: + +- Invitations page loads and displays the heading +- Send Invitation form is visible (email + role select) +- Invitation details page shows share URL +- Sign-up page loads from invitation link and submits without errors +- Post sign-up, redirect to login is performed +- Login with the new account succeeds +- Profile page shows the expected organization id + +### Notes: + +- Test uses a fresh browser context for the invitee to avoid admin session leakage +- Email should be unique per run (the test uses a random suffix) +- Ensure `E2E_NEW_USER_PASSWORD` and `E2E_ORGANIZATION_ID` are set before execution +- The role `e2e_admin` must be available in the environment \ No newline at end of file diff --git a/ui/tests/invitations/invitations.spec.ts b/ui/tests/invitations/invitations.spec.ts new file mode 100644 index 0000000000..df04e8e99e --- /dev/null +++ b/ui/tests/invitations/invitations.spec.ts @@ -0,0 +1,105 @@ +import { test } from "@playwright/test"; +import { InvitationsPage } from "./invitations-page"; +import { makeSuffix } from "../helpers"; +import { SignUpPage } from "../sign-up/sign-up-page"; +import { SignInPage } from "../sign-in/sign-in-page"; +import { UserProfilePage } from "../profile/profile-page"; + +test.describe("New user invitation", () => { + // Invitations page object + let invitationsPage: InvitationsPage; + + // Setup before each test + test.beforeEach(async ({ page }) => { + invitationsPage = new InvitationsPage(page); + }); + + // Use admin authentication for invitations management + test.use({ storageState: "playwright/.auth/admin_user.json" }); + + test( + "should invite a new user", + { + tag: ["@critical", "@e2e", "@invitations", "@INVITATION-E2E-001"], + }, + async ({ page, browser }) => { + + // Test data from environment variables + const password = process.env.E2E_NEW_USER_PASSWORD; + const organizationId = process.env.E2E_ORGANIZATION_ID; + + // Validate required environment variables + if (!password || !organizationId) { + throw new Error( + "E2E_NEW_USER_PASSWORD or E2E_ORGANIZATION_ID environment variable is not set", + ); + } + + // Generate unique test data + const suffix = makeSuffix(10); + const uniqueEmail = `e2e+${suffix}@prowler.com`; + + // Navigate to providers page + await invitationsPage.goto(); + await invitationsPage.verifyPageLoaded(); + + // Press the invite button + await invitationsPage.clickSendInviteButton(); + await invitationsPage.verifyInvitePageLoaded(); + + // Fill the email + await invitationsPage.fillEmail(uniqueEmail); + + // Select the role option + await invitationsPage.selectRole("e2e_admin"); + + // Press the send invitation button + await invitationsPage.clickSendInviteButton(); + await invitationsPage.verifyInviteDataPageLoaded(); + + // Get the share url + const shareUrl = await invitationsPage.getShareUrl(); + + // Navigate to the share url with a new context to avoid cookies from the admin context + const inviteContext = await browser.newContext({ storageState: { cookies: [], origins: [] } }); + const signUpPage = new SignUpPage(await inviteContext.newPage()); + + // Navigate to the share url + await signUpPage.gotoInvite(shareUrl); + + // Fill and submit the sign-up form + await signUpPage.signup({ + name: `E2E User ${suffix}`, + email: uniqueEmail, + password: password, + confirmPassword: password, + acceptTerms: true, + }); + + // Verify no errors occurred during sign-up + await signUpPage.verifyNoErrors(); + + // Verify redirect to login page (OSS environment) + await signUpPage.verifyRedirectToLogin(); + + // Verify the newly created user can log in successfully with the new context + const signInPage = new SignInPage(await inviteContext.newPage()); + await signInPage.goto(); + await signInPage.login({ + email: uniqueEmail, + password: password, + }); + await signInPage.verifySuccessfulLogin(); + + // Navigate to the user profile page + const userProfilePage = new UserProfilePage(await inviteContext.newPage()); + await userProfilePage.goto(); + + // Verify if user is added to the organization + await userProfilePage.verifyOrganizationId(organizationId); + + // Close the invite context + await inviteContext.close(); + }, + ); +}); diff --git a/ui/tests/profile/profile-page.ts b/ui/tests/profile/profile-page.ts new file mode 100644 index 0000000000..444176e3a5 --- /dev/null +++ b/ui/tests/profile/profile-page.ts @@ -0,0 +1,32 @@ +import { Page, Locator, expect } from "@playwright/test"; +import { BasePage } from "../base-page"; + + +export class UserProfilePage extends BasePage { + + // Page heading + readonly pageHeadingUserProfile: Locator; + + constructor(page: Page) { + super(page); + + // Page heading + this.pageHeadingUserProfile = page.getByRole("heading", { name: "User Profile" }); + + } + + async goto(): Promise { + // Navigate to the user profile page + + await super.goto("/profile"); + } + + async verifyOrganizationId(organizationId: string): Promise { + // Verify the organization ID is visible + + await expect(this.page.getByText(organizationId)).toBeVisible(); + if (await this.page.getByText(organizationId).count() === 0) { + throw new Error(`Organization ID ${organizationId} not found`); + } + } +} diff --git a/ui/tests/providers/providers.md b/ui/tests/providers/providers.md index 3344359448..bc6457a60e 100644 --- a/ui/tests/providers/providers.md +++ b/ui/tests/providers/providers.md @@ -1,7 +1,7 @@ ### E2E Tests: AWS Provider Management **Suite ID:** `PROVIDER-E2E` -**Feature:** AWS Provider Management - Add and configure AWS cloud providers with different authentication methods +**Feature:** AWS Provider Management. --- diff --git a/ui/tests/sign-up/sign-up-page.ts b/ui/tests/sign-up/sign-up-page.ts index 30d8f4d50d..a126b60fc0 100644 --- a/ui/tests/sign-up/sign-up-page.ts +++ b/ui/tests/sign-up/sign-up-page.ts @@ -43,10 +43,19 @@ export class SignUpPage extends BasePage { } async goto(): Promise { + // Navigate to the sign up page + await super.goto("/sign-up"); } + async gotoInvite(shareUrl: string): Promise { + // Navigate to the share url + + await super.goto(shareUrl); + } async verifyPageLoaded(): Promise { + // Verify the sign up page is loaded + await expect(this.page.getByRole("heading", { name: "Sign up" })).toBeVisible(); await expect(this.emailInput).toBeVisible(); await expect(this.submitButton).toBeVisible(); @@ -54,35 +63,48 @@ export class SignUpPage extends BasePage { } async fillName(name: string): Promise { + // Fill the name input + await this.nameInput.fill(name); } async fillCompany(company?: string): Promise { + // Fill the company input + if (company) { await this.companyInput.fill(company); } } async fillEmail(email: string): Promise { + // Fill the email input + await this.emailInput.fill(email); } async fillPassword(password: string): Promise { + // Fill the password input + await this.passwordInput.fill(password); } async fillConfirmPassword(confirmPassword: string): Promise { + // Fill the confirm password input + await this.confirmPasswordInput.fill(confirmPassword); } async fillInvitationToken(token?: string | null): Promise { + // Fill the invitation token input + if (token) { await this.invitationTokenInput.fill(token); } } async acceptTermsIfPresent(accept: boolean = true): Promise { - // Only in cloud env; check presence before interacting + // Accept the terms and conditions if present + if (await this.termsCheckbox.isVisible()) { if (accept) { await this.termsCheckbox.click(); @@ -91,25 +113,32 @@ export class SignUpPage extends BasePage { } async submit(): Promise { + // Submit the sign up form + await this.submitButton.click(); } async signup(data: SignUpData): Promise { + // Fill the sign up form + await this.fillName(data.name); - await this.fillCompany(data.company); + await this.fillCompany(data.company ?? undefined); await this.fillEmail(data.email); await this.fillPassword(data.password); await this.fillConfirmPassword(data.confirmPassword); - await this.fillInvitationToken(data.invitationToken ?? undefined); await this.acceptTermsIfPresent(data.acceptTerms ?? true); await this.submit(); } async verifyRedirectToLogin(): Promise { + // Verify redirect to login page + await expect(this.page).toHaveURL("/sign-in"); } async verifyRedirectToEmailVerification(): Promise { + // Verify redirect to email verification page + await expect(this.page).toHaveURL("/email-verification"); } } diff --git a/ui/tests/sign-up/sign-up.md b/ui/tests/sign-up/sign-up.md index b13dc0e996..d82276a5f3 100644 --- a/ui/tests/sign-up/sign-up.md +++ b/ui/tests/sign-up/sign-up.md @@ -1,7 +1,7 @@ ### E2E Tests: User Sign-Up **Suite ID:** `SIGNUP-E2E` -**Feature:** New user registration flow. +**Feature:** New user registration. ---