From 0638c191837a1aee5809bb1d434f051eb34442e3 Mon Sep 17 00:00:00 2001 From: jbpratt Date: Mon, 5 Jan 2026 11:47:29 -0600 Subject: [PATCH] test(web): migrate more tests to playwright (#4767) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(web): migrate repository-autopruning to Playwright Consolidate 17 Cypress tests into 6 Playwright tests: - policy lifecycle (create, update, delete) - policy with tag pattern filter - multiple policies lifecycle - namespace policy display in repository settings - registry policy display - error handling (load failure) Uses @feature:AUTO_PRUNE tag for automatic test skipping. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * test: enable features by default Signed-off-by: Brady Pratt * test(web): migrate create-account.cy.ts to Playwright Migrates the Create Account Cypress tests to Playwright following the MIGRATION.md guide: - Consolidates 10 Cypress tests into 6 focused Playwright tests - Uses real API calls instead of mocks - Adds data-testid attributes to CreateAccount component - Uses @feature:MAILING and @feature:QUOTA_MANAGEMENT tags to skip tests when features are not enabled - Creates custom fixtures for unauthenticated page access - Implements proper user cleanup after tests Tests: - form validation prevents invalid submissions - creates account with valid inputs and redirects - shows error for existing username - navigates to signin page via link - shows verification message (requires MAILING) - redirects to updateuser (requires QUOTA_MANAGEMENT) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 Signed-off-by: Brady Pratt * chore(dev) add Mailpit for local email testing Add Mailpit email testing server to the local development environment to enable testing of FEATURE_MAILING functionality with Playwright. Changes: - Add mailpit service to docker-compose.yaml (ports 8025/1025) - Enable FEATURE_MAILING and configure SMTP settings in config.yaml - Add mailpit utilities to Playwright fixtures (getEmails, clearInbox, waitForEmail, getEmailBody, isAvailable) Usage: podman-compose up mailpit -d # Access Web UI at http://localhost:8025 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * test(web) use mailpit helpers for email confirmation support Test updates: - "creates account and redirects to organization" now confirms email - "redirects to updateuser when user has prompts" now confirms email - Tests detect FEATURE_MAILING at runtime and adapt accordingly - Email search uses recipient address for parallel test safety 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 Signed-off-by: Brady Pratt * test(web): use mailpit for email notification test Replace mocked email authorization with real Mailpit verification in the notifications.spec.ts test. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 Signed-off-by: Brady Pratt * test(web): mock user response in cypress test this broke when mailing was enabled Signed-off-by: Brady Pratt --------- Signed-off-by: Brady Pratt Co-authored-by: Claude --- .github/actions/setup-quay/action.yaml | 15 + docker-compose.yaml | 12 + local-dev/stack/config.yaml | 13 +- web/cypress/e2e/account-settings.cy.ts | 22 + web/cypress/e2e/create-account.cy.ts | 362 --------------- .../e2e/auth/create-account.spec.ts | 362 +++++++++++++++ web/playwright/e2e/auth/logout.spec.ts | 22 +- .../e2e/repository/autopruning.spec.ts | 436 ++++++++++++++++++ .../e2e/repository/notifications.spec.ts | 58 ++- web/playwright/fixtures.ts | 10 + web/playwright/global-setup.ts | 48 ++ web/playwright/utils/mailpit.ts | 153 ++++++ .../routes/CreateAccount/CreateAccount.tsx | 5 + 13 files changed, 1116 insertions(+), 402 deletions(-) delete mode 100644 web/cypress/e2e/create-account.cy.ts create mode 100644 web/playwright/e2e/auth/create-account.spec.ts create mode 100644 web/playwright/e2e/repository/autopruning.spec.ts create mode 100644 web/playwright/utils/mailpit.ts diff --git a/.github/actions/setup-quay/action.yaml b/.github/actions/setup-quay/action.yaml index 8fa46bb04..970e06470 100644 --- a/.github/actions/setup-quay/action.yaml +++ b/.github/actions/setup-quay/action.yaml @@ -47,6 +47,11 @@ inputs: required: false default: 'false' + with-mailpit: + description: 'Start Mailpit email testing server' + required: false + default: 'true' + logs-artifact-name: description: 'Name for debug logs artifact (uploaded on failure)' required: false @@ -190,6 +195,13 @@ runs: DOCKER_USER="1001:0" docker compose up -d repomirror echo "Repomirror worker started" + - name: Start Mailpit + if: inputs.with-mailpit == 'true' + shell: bash + run: | + docker compose up -d mailpit + echo "Mailpit email server started (SMTP: localhost:1025, Web UI: localhost:8025)" + - name: Collect debug logs on failure if: failure() shell: bash @@ -209,6 +221,9 @@ runs: if [ "${{ inputs.with-repomirror }}" = "true" ]; then docker logs quay-repomirror > logs/repomirror.log 2>&1 || true fi + if [ "${{ inputs.with-mailpit }}" = "true" ]; then + docker logs quay-mailpit > logs/mailpit.log 2>&1 || true + fi - name: Upload debug logs on failure if: failure() diff --git a/docker-compose.yaml b/docker-compose.yaml index 109ddaf8a..14f0f6611 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -176,3 +176,15 @@ services: cpus: 2 command: ["bash", "-c", "cd /src/clair/cmd/clair; go run -mod vendor ."] + + # Mailpit - local email testing server + # Web UI: http://localhost:8025 + mailpit: + container_name: quay-mailpit + image: docker.io/axllent/mailpit:latest + ports: + - "8025:8025" # Web UI + - "1025:1025" # SMTP + environment: + MP_SMTP_AUTH_ACCEPT_ANY: 1 + MP_SMTP_AUTH_ALLOW_INSECURE: 1 diff --git a/local-dev/stack/config.yaml b/local-dev/stack/config.yaml index fce2ae0f2..5b49ae4cf 100644 --- a/local-dev/stack/config.yaml +++ b/local-dev/stack/config.yaml @@ -32,7 +32,7 @@ FEATURE_CHANGE_TAG_EXPIRATION: true FEATURE_DIRECT_LOGIN: true FEATURE_GARBAGE_COLLECTION: false FEATURE_IMAGE_PULL_STATS: false -FEATURE_MAILING: false +FEATURE_MAILING: true FEATURE_PARTIAL_USER_AUTOCOMPLETE: true FEATURE_REPO_MIRROR: true FEATURE_REQUIRE_TEAM_INVITE: true @@ -47,9 +47,10 @@ GITHUB_LOGIN_CONFIG: {} GITHUB_TRIGGER_CONFIG: {} GITLAB_TRIGGER_KIND: {} LOG_ARCHIVE_LOCATION: default -MAIL_DEFAULT_SENDER: admin@example.com -MAIL_PORT: 587 -MAIL_USE_TLS: true +MAIL_DEFAULT_SENDER: quay@localhost +MAIL_SERVER: quay-mailpit +MAIL_PORT: 1025 +MAIL_USE_TLS: false PREFERRED_URL_SCHEME: http REGISTRY_TITLE: Red Hat Quay REGISTRY_TITLE_SHORT: Red Hat Quay @@ -80,7 +81,9 @@ PULL_METRICS_REDIS: port: 6379 db: 1 USE_CDN: false -FEATURE_QUOTA_MANAGEMENT: false +FEATURE_QUOTA_MANAGEMENT: true +FEATURE_EDIT_QUOTA: true +FEATURE_AUTO_PRUNE: true BROWSER_API_CALLS_XHR_ONLY: False # CORS_ORIGIN: "https://stage.foo.redhat.com:1337 http://localhost:9000/" CORS_ORIGIN: diff --git a/web/cypress/e2e/account-settings.cy.ts b/web/cypress/e2e/account-settings.cy.ts index 8be733cb7..e1f12937b 100644 --- a/web/cypress/e2e/account-settings.cy.ts +++ b/web/cypress/e2e/account-settings.cy.ts @@ -66,8 +66,30 @@ describe('Account Settings Page', () => { cy.get('#org-settings-company').type('Red Hat'); cy.get('#save-org-settings').click(); + // Mock the user response after save to verify form displays saved values + // Note: When FEATURE_MAILING is enabled on the backend, email changes require + // verification before taking effect. We mock the response to test the UI behavior. + cy.intercept('GET', '/api/v1/user/', { + statusCode: 200, + body: { + username: 'user1', + email: 'good-email@redhat.com', + family_name: 'Joe Smith', + location: 'Raleigh, NC', + company: 'Red Hat', + verified: true, + anonymous: false, + organizations: [], + logins: [], + can_create_repo: true, + invoice_email: false, + invoice_email_address: null, + }, + }).as('getMockedUser'); + // refresh page and check if email is saved cy.reload(); + cy.wait('@getMockedUser'); cy.get('#org-settings-email').should('have.value', 'good-email@redhat.com'); cy.get('#org-settings-fullname').should('have.value', 'Joe Smith'); cy.get('#org-settings-location').should('have.value', 'Raleigh, NC'); diff --git a/web/cypress/e2e/create-account.cy.ts b/web/cypress/e2e/create-account.cy.ts deleted file mode 100644 index 24165fb59..000000000 --- a/web/cypress/e2e/create-account.cy.ts +++ /dev/null @@ -1,362 +0,0 @@ -/// - -describe('Create Account Page', () => { - before(() => { - cy.exec('npm run quay:seed'); - }); - - beforeEach(() => { - // Common intercepts for create account tests - cy.intercept('GET', '/csrf_token', { - body: {csrf_token: 'test-token'}, - }).as('getCsrfToken'); - - // Mock anonymous user to prevent 401 redirect to signin - cy.intercept('GET', '/api/v1/user/', { - statusCode: 200, - body: { - anonymous: true, - username: null, - }, - }).as('getAnonymousUser'); - - cy.visit('/createaccount'); - }); - - // Helper function to setup successful account creation + auto-login - const setupSuccessfulFlow = () => { - cy.intercept('POST', '/api/v1/user/', {statusCode: 200}).as('createUser'); - cy.intercept('POST', '/api/v1/signin', { - statusCode: 200, - body: {success: true}, - }).as('signinUser'); - }; - - // Helper function to setup failed account creation - const setupFailedCreation = (statusCode = 409, body = {}) => { - cy.intercept('POST', '/api/v1/user/', { - statusCode, - body, - }).as('createUserFail'); - }; - - // Helper function to setup successful creation but failed auto-login - const setupCreationWithLoginFailure = () => { - cy.intercept('POST', '/api/v1/user/', {statusCode: 200}).as('createUser'); - cy.intercept('POST', '/api/v1/signin', { - statusCode: 403, - body: {error: 'CSRF token was invalid'}, - }).as('signinUserFail'); - }; - - it('Form validation works correctly', () => { - cy.visit('/createaccount'); - - // Test empty form submission - cy.get('button[type=submit]').should('be.disabled'); - - // Test invalid username - cy.get('#username').type('ab'); // Too short - cy.contains('Username must be at least 3 characters'); - - cy.get('#username').clear().type('invalid@username'); // Invalid characters - cy.contains('Username must be at least 3 characters'); - - // Test invalid email - cy.get('#email').type('invalid-email'); - cy.contains('Please enter a valid email address'); - - // Test invalid password - cy.get('#password').type('123'); // Too short - cy.contains('Password must be at least 8 characters long'); - - // Test password mismatch - cy.get('#password').clear().type('validpassword123'); - cy.get('#confirm-password').type('differentpassword'); - cy.contains('Passwords must match'); - - // Form should still be disabled - cy.get('button[type=submit]').should('be.disabled'); - }); - - it('Successful account creation with valid inputs', () => { - const testUser = { - username: `testuser${Date.now()}`, - email: `test${Date.now()}@example.com`, - password: 'validpassword123', - }; - - setupSuccessfulFlow(); - - // Fill form with valid data - cy.get('#username').type(testUser.username); - cy.get('#email').type(testUser.email); - cy.get('#password').type(testUser.password); - cy.get('#confirm-password').type(testUser.password); - - // Form should be enabled - cy.get('button[type=submit]').should('not.be.disabled'); - - // Submit form - cy.get('button[type=submit]').click(); - - // Verify API calls were made - cy.wait('@createUser').then((interception) => { - expect(interception.request.body).to.deep.equal({ - username: testUser.username, - email: testUser.email, - password: testUser.password, - }); - }); - - cy.wait('@getCsrfToken'); - cy.wait('@signinUser').then((interception) => { - expect(interception.request.body).to.deep.equal({ - username: testUser.username, - password: testUser.password, - }); - }); - - // Should redirect to organization page after auto-login - cy.url().should('include', '/organization'); - }); - - it('Handles account creation with existing username', () => { - setupFailedCreation(409, {error_message: 'The username already exists'}); - - // Fill form with existing user data - cy.get('#username').type('user1'); // Existing user from seed data - cy.get('#email').type('test@example.com'); - cy.get('#password').type('validpassword123'); - cy.get('#confirm-password').type('validpassword123'); - - // Submit form - cy.get('button[type=submit]').click(); - - // Should show error message - cy.wait('@createUserFail'); - cy.contains('Username or email already exists'); - - // Should not redirect - cy.url().should('include', '/createaccount'); - }); - - it('Handles account creation success but auto-login failure', () => { - const testUser = { - username: `testuser${Date.now()}`, - email: `test${Date.now()}@example.com`, - password: 'validpassword123', - }; - - setupCreationWithLoginFailure(); - - // Fill form - cy.get('#username').type(testUser.username); - cy.get('#email').type(testUser.email); - cy.get('#password').type(testUser.password); - cy.get('#confirm-password').type(testUser.password); - - // Submit form - cy.get('button[type=submit]').click(); - - // Wait for calls - cy.wait('@createUser'); - cy.wait('@getCsrfToken'); - cy.wait('@signinUserFail'); - - // Should redirect to signin page with message - cy.url().should('include', '/signin'); - cy.url().should('include', 'account_created=true'); - cy.url().should('include', 'auto_login_failed=true'); - }); - - it('Navigation to signin page works', () => { - cy.visit('/createaccount'); - - // Click "Sign in" link - cy.contains('Already have an account?').parent().find('a').click(); - - // Should navigate to signin page - cy.url().should('include', '/signin'); - }); - - it('Displays proper form labels and structure', () => { - cy.visit('/createaccount'); - - // Check page title - cy.contains('Create Account'); - - // Check form fields exist with proper labels - cy.contains('Username'); - cy.get('#username').should('exist'); - - cy.contains('Email'); - cy.get('#email').should('exist'); - - cy.contains('Password'); - cy.get('#password').should('exist'); - - cy.contains('Confirm Password'); - cy.get('#confirm-password').should('exist'); - - // Check submit button - cy.get('button[type=submit]').contains('Create Account'); - - // Check signin link - cy.contains('Already have an account?'); - cy.contains('Sign in'); - }); - - it('Shows email verification message when awaiting_verification is true', () => { - const testUser = { - username: `testuser${Date.now()}`, - email: `test${Date.now()}@example.com`, - password: 'validpassword123', - }; - - // Setup account creation with awaiting_verification response - cy.intercept('POST', '/api/v1/user/', { - statusCode: 200, - body: {awaiting_verification: true}, - }).as('createUserAwaitingVerification'); - - // Setup signin intercept to verify it's NOT called - cy.intercept('POST', '/api/v1/signin', (req) => { - throw new Error('Signin should not be called when awaiting verification'); - }).as('signinShouldNotBeCalled'); - - cy.visit('/createaccount'); - - // Fill form with valid data - cy.get('#username').type(testUser.username); - cy.get('#email').type(testUser.email); - cy.get('#password').type(testUser.password); - cy.get('#confirm-password').type(testUser.password); - - // Submit form - cy.get('button[type=submit]').click(); - - // Wait for create user API call - cy.wait('@createUserAwaitingVerification').then((interception) => { - expect(interception.request.body).to.deep.equal({ - username: testUser.username, - email: testUser.email, - password: testUser.password, - }); - }); - - // Should show verification message - cy.get('[data-testid="awaiting-verification-alert"]').should('be.visible'); - cy.contains( - 'Thank you for registering! We have sent you an activation email.', - ); - cy.contains('verify your email address').should('be.visible'); - - // Form should be hidden (check visibility, not existence since display:none keeps elements in DOM) - cy.get('#username').should('not.be.visible'); - cy.get('#email').should('not.be.visible'); - cy.get('#password').should('not.be.visible'); - - // Should not redirect to organization page - cy.url().should('include', '/createaccount'); - cy.url().should('not.include', '/organization'); - - // Should show sign in link (it's outside the form when awaiting verification) - // Find the visible link that's not inside a hidden form - cy.get('a[href="/signin"]').should('be.visible'); - // The text should also be visible (rendered outside the hidden form) - cy.contains('body', 'Already have an account?').should('be.visible'); - - // Verify no auto-login was attempted - signin API should not be called - // If signin was attempted, it would have thrown an error from the intercept - cy.wait(500); // Small wait to ensure no signin API call is made - }); - - it('Redirects to updateuser page when user has prompts after account creation', () => { - const testUser = { - username: `testuser${Date.now()}`, - email: `test${Date.now()}@example.com`, - password: 'validpassword123', - }; - - setupSuccessfulFlow(); - - // Mock user API to return user with prompts (e.g., when FEATURE_QUOTA_MANAGEMENT is enabled) - cy.intercept('GET', '/api/v1/user/', { - statusCode: 200, - body: { - anonymous: false, - username: testUser.username, - email: testUser.email, - verified: true, - prompts: ['enter_name', 'enter_company'], - organizations: [], - logins: [], - }, - }).as('getUserWithPrompts'); - - cy.visit('/createaccount'); - - // Fill form with valid data - cy.get('#username').type(testUser.username); - cy.get('#email').type(testUser.email); - cy.get('#password').type(testUser.password); - cy.get('#confirm-password').type(testUser.password); - - // Submit form - cy.get('button[type=submit]').click(); - - // Wait for API calls - cy.wait('@createUser'); - cy.wait('@getCsrfToken'); - cy.wait('@signinUser'); - cy.wait('@getUserWithPrompts'); - - // Should redirect to updateuser page for profile completion - cy.url().should('include', '/updateuser'); - }); - - it('Redirects to organization page when user has no prompts after account creation', () => { - const testUser = { - username: `testuser${Date.now()}`, - email: `test${Date.now()}@example.com`, - password: 'validpassword123', - }; - - setupSuccessfulFlow(); - - // Mock user API to return user without prompts - cy.intercept('GET', '/api/v1/user/', { - statusCode: 200, - body: { - anonymous: false, - username: testUser.username, - email: testUser.email, - verified: true, - prompts: [], - organizations: [], - logins: [], - }, - }).as('getUserNoPrompts'); - - cy.visit('/createaccount'); - - // Fill form with valid data - cy.get('#username').type(testUser.username); - cy.get('#email').type(testUser.email); - cy.get('#password').type(testUser.password); - cy.get('#confirm-password').type(testUser.password); - - // Submit form - cy.get('button[type=submit]').click(); - - // Wait for API calls - cy.wait('@createUser'); - cy.wait('@getCsrfToken'); - cy.wait('@signinUser'); - cy.wait('@getUserNoPrompts'); - - // Should redirect to organization page - cy.url().should('include', '/organization'); - }); -}); diff --git a/web/playwright/e2e/auth/create-account.spec.ts b/web/playwright/e2e/auth/create-account.spec.ts new file mode 100644 index 000000000..e6f3d7d85 --- /dev/null +++ b/web/playwright/e2e/auth/create-account.spec.ts @@ -0,0 +1,362 @@ +import {test as base, expect, uniqueName, mailpit} from '../../fixtures'; +import {ApiClient} from '../../utils/api'; +import {Page} from '@playwright/test'; + +/** + * Create Account tests use unauthenticated browser contexts since they test + * the account creation flow for anonymous visitors. + * + * Tests that create users must clean them up using superuser API. + */ + +interface CreateAccountFixtures { + /** Fresh unauthenticated page for create account tests */ + createAccountPage: Page; + /** Helper to cleanup created users */ + cleanupUser: (username: string) => Promise; +} + +const test = base.extend({ + createAccountPage: async ({browser}, use) => { + // Create a fresh browser context without any authentication + const context = await browser.newContext(); + const page = await context.newPage(); + await use(page); + await page.close(); + await context.close(); + }, + + cleanupUser: async ({superuserRequest}, use) => { + const superApi = new ApiClient(superuserRequest); + const cleanup = async (username: string) => { + try { + await superApi.deleteUser(username); + } catch { + // User may already be deleted or cleanup failed - that's ok + } + }; + await use(cleanup); + }, +}); + +test.describe('Create Account Page', {tag: ['@auth']}, () => { + test('form validation prevents invalid submissions', async ({ + createAccountPage, + }) => { + await createAccountPage.goto('/createaccount'); + + // Verify page title (use heading role to avoid matching the button) + await expect( + createAccountPage.getByRole('heading', {name: 'Create Account'}), + ).toBeVisible(); + + // Verify form fields exist by checking the input elements + await expect( + createAccountPage.getByTestId('create-account-username'), + ).toBeVisible(); + await expect( + createAccountPage.getByTestId('create-account-email'), + ).toBeVisible(); + await expect( + createAccountPage.getByTestId('create-account-password'), + ).toBeVisible(); + await expect( + createAccountPage.getByTestId('create-account-confirm-password'), + ).toBeVisible(); + + // Submit button should be disabled on empty form + const submitButton = createAccountPage.getByTestId('create-account-submit'); + await expect(submitButton).toBeDisabled(); + + // Test invalid username (too short) + const usernameInput = createAccountPage.getByTestId( + 'create-account-username', + ); + await usernameInput.fill('ab'); + await expect( + createAccountPage.getByText( + 'Username must be at least 3 characters and contain only letters', + ), + ).toBeVisible(); + + // Test invalid email + const emailInput = createAccountPage.getByTestId('create-account-email'); + await emailInput.fill('invalid-email'); + await expect( + createAccountPage.getByText('Please enter a valid email address'), + ).toBeVisible(); + + // Test short password + const passwordInput = createAccountPage.getByTestId( + 'create-account-password', + ); + await passwordInput.fill('123'); + await expect( + createAccountPage.getByText( + 'Password must be at least 8 characters long', + ), + ).toBeVisible(); + + // Test password mismatch + await passwordInput.fill('validpassword123'); + const confirmPasswordInput = createAccountPage.getByTestId( + 'create-account-confirm-password', + ); + await confirmPasswordInput.fill('differentpassword'); + await expect( + createAccountPage.getByText('Passwords must match'), + ).toBeVisible(); + + // Form should still be disabled with invalid inputs + await expect(submitButton).toBeDisabled(); + + // Now fix all validation errors + await usernameInput.fill('validuser'); + await emailInput.fill('valid@example.com'); + await confirmPasswordInput.fill('validpassword123'); + + // Submit button should now be enabled + await expect(submitButton).toBeEnabled(); + }); + + test( + 'creates account with valid inputs and redirects to organization', + {tag: '@critical'}, + async ({createAccountPage, cleanupUser, quayConfig}) => { + const username = uniqueName('newuser'); + const email = `${username}@example.com`; + const password = 'validpassword123'; + const mailingEnabled = quayConfig?.features?.MAILING === true; + + await createAccountPage.goto('/createaccount'); + + // Wait for the page to be fully loaded + await expect( + createAccountPage.getByRole('heading', {name: 'Create Account'}), + ).toBeVisible(); + + // Fill form with valid data + await createAccountPage + .getByTestId('create-account-username') + .fill(username); + await createAccountPage.getByTestId('create-account-email').fill(email); + await createAccountPage + .getByTestId('create-account-password') + .fill(password); + await createAccountPage + .getByTestId('create-account-confirm-password') + .fill(password); + + // Submit form - wait for button to be enabled after validation + const submitButton = createAccountPage.getByTestId( + 'create-account-submit', + ); + await expect(submitButton).toBeEnabled({timeout: 10000}); + await submitButton.click(); + + // If FEATURE_MAILING is enabled, we need to confirm email first + if (mailingEnabled) { + // Wait for verification message to appear + await expect( + createAccountPage.getByTestId('awaiting-verification-alert'), + ).toBeVisible({timeout: 10000}); + + // Get confirmation link from email (searches by recipient address) + const confirmLink = await mailpit.waitForConfirmationLink(email); + expect(confirmLink).not.toBeNull(); + await createAccountPage.goto(confirmLink!); + } + + // Should redirect to /organization or /updateuser after successful creation + // (depends on whether user has prompts configured) + // Use longer timeout for redirect to complete + await expect(createAccountPage).toHaveURL(/\/(organization|updateuser)/, { + timeout: 15000, + }); + + // Cleanup: delete the created user + await cleanupUser(username); + }, + ); + + test('shows error for existing username', async ({ + createAccountPage, + superuserRequest, + cleanupUser, + }) => { + const username = uniqueName('existing'); + const email = `${username}@example.com`; + const password = 'validpassword123'; + + // Pre-create user via API + const superApi = new ApiClient(superuserRequest); + await superApi.createUser(username, password, email); + + await createAccountPage.goto('/createaccount'); + + // Wait for the page to be fully loaded + await expect( + createAccountPage.getByRole('heading', {name: 'Create Account'}), + ).toBeVisible(); + + // Try to create the same user via UI + await createAccountPage + .getByTestId('create-account-username') + .fill(username); + await createAccountPage.getByTestId('create-account-email').fill(email); + await createAccountPage + .getByTestId('create-account-password') + .fill(password); + await createAccountPage + .getByTestId('create-account-confirm-password') + .fill(password); + + // Submit form + const submitButton = createAccountPage.getByTestId('create-account-submit'); + await expect(submitButton).toBeEnabled({timeout: 10000}); + await submitButton.click(); + + // Should show error message (actual message from API includes prefix) + await expect( + createAccountPage.getByText('The username already exists'), + ).toBeVisible({timeout: 10000}); + + // Should not redirect - still on create account page + await expect(createAccountPage).toHaveURL(/\/createaccount/); + + // Cleanup + await cleanupUser(username); + }); + + test('navigates to signin page via link', async ({createAccountPage}) => { + await createAccountPage.goto('/createaccount'); + + // Verify "Already have an account?" text exists + await expect( + createAccountPage.getByText('Already have an account?'), + ).toBeVisible(); + + // Click the "Sign in" link + await createAccountPage.getByRole('link', {name: 'Sign in'}).click(); + + // Should navigate to signin page + await expect(createAccountPage).toHaveURL(/\/signin/); + }); + + test( + 'shows verification message when email verification required', + {tag: '@feature:MAILING'}, + async ({createAccountPage, cleanupUser}) => { + // This test only runs when FEATURE_MAILING is enabled + // When enabled, new accounts require email verification + const username = uniqueName('verifyuser'); + const email = `${username}@example.com`; + const password = 'validpassword123'; + + await createAccountPage.goto('/createaccount'); + + // Fill form with valid data + await createAccountPage + .getByTestId('create-account-username') + .fill(username); + await createAccountPage.getByTestId('create-account-email').fill(email); + await createAccountPage + .getByTestId('create-account-password') + .fill(password); + await createAccountPage + .getByTestId('create-account-confirm-password') + .fill(password); + + // Submit form + await createAccountPage.getByTestId('create-account-submit').click(); + + // Should show verification message + await expect( + createAccountPage.getByTestId('awaiting-verification-alert'), + ).toBeVisible(); + await expect( + createAccountPage.getByText( + 'Thank you for registering! We have sent you an activation email.', + ), + ).toBeVisible(); + await expect( + createAccountPage.getByText('verify your email address'), + ).toBeVisible(); + + // Form fields should be hidden + await expect( + createAccountPage.getByTestId('create-account-username'), + ).not.toBeVisible(); + await expect( + createAccountPage.getByTestId('create-account-email'), + ).not.toBeVisible(); + await expect( + createAccountPage.getByTestId('create-account-password'), + ).not.toBeVisible(); + + // Should still show sign in link + await expect( + createAccountPage.getByRole('link', {name: 'Sign in'}), + ).toBeVisible(); + + // Should not redirect + await expect(createAccountPage).toHaveURL(/\/createaccount/); + + // Cleanup + await cleanupUser(username); + }, + ); + + test( + 'redirects to updateuser when user has prompts', + {tag: '@feature:QUOTA_MANAGEMENT'}, + async ({createAccountPage, cleanupUser, quayConfig}) => { + // This test only runs when FEATURE_QUOTA_MANAGEMENT is enabled + // When enabled, new users may have prompts (enter_name, enter_company) + // that redirect them to /updateuser + const username = uniqueName('promptuser'); + const email = `${username}@example.com`; + const password = 'validpassword123'; + const mailingEnabled = quayConfig?.features?.MAILING === true; + + await createAccountPage.goto('/createaccount'); + + // Fill form with valid data + await createAccountPage + .getByTestId('create-account-username') + .fill(username); + await createAccountPage.getByTestId('create-account-email').fill(email); + await createAccountPage + .getByTestId('create-account-password') + .fill(password); + await createAccountPage + .getByTestId('create-account-confirm-password') + .fill(password); + + // Submit form + await createAccountPage.getByTestId('create-account-submit').click(); + + // If FEATURE_MAILING is enabled, we need to confirm email first + if (mailingEnabled) { + // Wait for verification message to appear + await expect( + createAccountPage.getByTestId('awaiting-verification-alert'), + ).toBeVisible({timeout: 10000}); + + // Get confirmation link from email (searches by recipient address) + const confirmLink = await mailpit.waitForConfirmationLink(email); + expect(confirmLink).not.toBeNull(); + await createAccountPage.goto(confirmLink!); + } + + // With QUOTA_MANAGEMENT enabled, user should have prompts + // and be redirected to /updateuser + await expect(createAccountPage).toHaveURL(/\/updateuser/, { + timeout: 15000, + }); + + // Cleanup + await cleanupUser(username); + }, + ); +}); diff --git a/web/playwright/e2e/auth/logout.spec.ts b/web/playwright/e2e/auth/logout.spec.ts index fef8de484..29f63f0cd 100644 --- a/web/playwright/e2e/auth/logout.spec.ts +++ b/web/playwright/e2e/auth/logout.spec.ts @@ -1,4 +1,4 @@ -import {test as base, expect, uniqueName} from '../../fixtures'; +import {test as base, expect, uniqueName, mailpit} from '../../fixtures'; import {ApiClient} from '../../utils/api'; /** @@ -26,17 +26,33 @@ const test = base.extend({ await use(username); }, - logoutPage: async ({browser, superuserRequest, logoutUsername}, use) => { + logoutPage: async ( + {browser, superuserRequest, logoutUsername, quayConfig}, + use, + ) => { const username = logoutUsername; const password = 'testpassword123'; const email = `${username}@example.com`; + const mailingEnabled = quayConfig?.features?.MAILING === true; // Create temporary user using superuser API const superApi = new ApiClient(superuserRequest); await superApi.createUser(username, password, email); - // Create new context and login as the temporary user + // Create new context for this user const context = await browser.newContext(); + + // Verify email if mailing is enabled + if (mailingEnabled) { + const confirmLink = await mailpit.waitForConfirmationLink(email); + if (confirmLink) { + const page = await context.newPage(); + await page.goto(confirmLink); + await page.close(); + } + } + + // Login as the temporary user const api = new ApiClient(context.request); await api.signIn(username, password); diff --git a/web/playwright/e2e/repository/autopruning.spec.ts b/web/playwright/e2e/repository/autopruning.spec.ts new file mode 100644 index 000000000..27b7d458e --- /dev/null +++ b/web/playwright/e2e/repository/autopruning.spec.ts @@ -0,0 +1,436 @@ +/** + * Repository Auto-Prune Policies E2E Tests + * + * Tests for repository-level auto-pruning policy management including: + * - Policy lifecycle (create, update, delete) + * - Multiple policies management + * - Tag pattern filtering + * - Namespace policy display in repository settings + * - Registry policy display + * - Error handling + * + * Requires AUTO_PRUNE feature to be enabled. + * + * Migrated from: web/cypress/e2e/repository-autopruning.cy.ts (17 tests consolidated to 6) + */ + +import {test, expect} from '../../fixtures'; +import {API_URL} from '../../utils/config'; + +test.describe( + 'Repository Auto-Prune Policies', + {tag: ['@repository', '@feature:AUTO_PRUNE']}, + () => { + test('policy lifecycle: create by tag number, update to tag age, delete', async ({ + authenticatedPage, + api, + }) => { + // Setup: Create repository (auto-cleaned) + const repo = await api.repository(undefined, 'autoprunetest'); + + // Navigate to repo settings → Auto-Prune Policies + await authenticatedPage.goto(`/repository/${repo.fullName}?tab=settings`); + await authenticatedPage + .getByText('Repository Auto-Prune Policies') + .click(); + + // Verify initial state - method should be "None" + await expect( + authenticatedPage.getByTestId('auto-prune-method'), + ).toContainText('None'); + + // CREATE: Select "By number of tags" and set value to 25 + await authenticatedPage + .getByTestId('auto-prune-method') + .selectOption('number_of_tags'); + + const tagCountInput = authenticatedPage.locator( + 'input[aria-label="number of tags"]', + ); + await expect(tagCountInput).toHaveValue('20'); + + // Use triple-click to select all, then fill new value + await tagCountInput.click({clickCount: 3}); + await tagCountInput.fill('25'); + + await authenticatedPage.getByRole('button', {name: 'Save'}).click(); + + // Verify creation success + await expect( + authenticatedPage.getByText( + 'Successfully created repository auto-prune policy', + ), + ).toBeVisible(); + await expect( + authenticatedPage.locator('input[aria-label="number of tags"]'), + ).toHaveValue('25'); + + // Wait for success message to disappear (ensures form has refetched with uuid) + await expect( + authenticatedPage.getByText( + 'Successfully created repository auto-prune policy', + ), + ).not.toBeVisible({timeout: 10000}); + + // UPDATE: Change to "By age of tags" (2 weeks) + await authenticatedPage + .getByTestId('auto-prune-method') + .selectOption('creation_date'); + await expect( + authenticatedPage.locator( + 'input[aria-label="tag creation date value"]', + ), + ).toHaveValue('7'); + + // Change to 2 weeks + await authenticatedPage + .locator('input[aria-label="tag creation date value"]') + .fill('2'); + await authenticatedPage + .locator('select[aria-label="tag creation date unit"]') + .selectOption('w'); + + await authenticatedPage.getByRole('button', {name: 'Save'}).click(); + + // Verify update success + await expect( + authenticatedPage.getByText( + 'Successfully updated repository auto-prune policy', + ), + ).toBeVisible(); + await expect( + authenticatedPage.locator( + 'input[aria-label="tag creation date value"]', + ), + ).toHaveValue('2'); + await expect( + authenticatedPage.locator( + 'select[aria-label="tag creation date unit"]', + ), + ).toContainText('weeks'); + + // Wait for success message to disappear before delete + await expect( + authenticatedPage.getByText( + 'Successfully updated repository auto-prune policy', + ), + ).not.toBeVisible({timeout: 10000}); + + // DELETE: Set method to "None" + await authenticatedPage + .getByTestId('auto-prune-method') + .selectOption('none'); + await authenticatedPage.getByRole('button', {name: 'Save'}).click(); + + // Verify deletion success + await expect( + authenticatedPage.getByText( + 'Successfully deleted repository auto-prune policy', + ), + ).toBeVisible(); + }); + + test('creates policy with tag pattern filter', async ({ + authenticatedPage, + api, + }) => { + // Setup: Create repository + const repo = await api.repository(undefined, 'autoprunefilter'); + + // Navigate to repo settings → Auto-Prune Policies + await authenticatedPage.goto(`/repository/${repo.fullName}?tab=settings`); + await authenticatedPage + .getByText('Repository Auto-Prune Policies') + .click(); + + // Select "By age of tags" + await authenticatedPage + .getByTestId('auto-prune-method') + .selectOption('creation_date'); + + // Set to 2 weeks + await authenticatedPage + .locator('input[aria-label="tag creation date value"]') + .fill('2'); + await authenticatedPage + .locator('select[aria-label="tag creation date unit"]') + .selectOption('w'); + + // Add tag pattern filter + await authenticatedPage.getByTestId('tag-pattern').fill('v1.*'); + await authenticatedPage + .locator('select[aria-label="tag pattern matches"]') + .selectOption('doesnotmatch'); + + await authenticatedPage.getByRole('button', {name: 'Save'}).click(); + + // Verify success + await expect( + authenticatedPage.getByText( + 'Successfully created repository auto-prune policy', + ), + ).toBeVisible(); + }); + + test('multiple policies lifecycle: create, update, delete', async ({ + authenticatedPage, + api, + }) => { + // Setup: Create organization and repository + const org = await api.organization('multipolicy'); + const repo = await api.repository(org.name, 'testrepo'); + + // Navigate to repo settings → Auto-Prune Policies + await authenticatedPage.goto(`/repository/${repo.fullName}?tab=settings`); + await authenticatedPage + .getByText('Repository Auto-Prune Policies') + .click(); + + // CREATE FIRST POLICY: By number of tags (25) + await authenticatedPage + .getByTestId('auto-prune-method') + .selectOption('number_of_tags'); + + // Wait for input to appear and have default value + const tagCountInput = authenticatedPage.locator( + 'input[aria-label="number of tags"]', + ); + await expect(tagCountInput).toHaveValue('20'); + + // Use triple-click to select all, then type new value + await tagCountInput.click({clickCount: 3}); + await tagCountInput.fill('25'); + + await authenticatedPage.getByRole('button', {name: 'Save'}).click(); + + await expect( + authenticatedPage.getByText( + 'Successfully created repository auto-prune policy', + ), + ).toBeVisible(); + + // Wait for success message to disappear before adding second policy + await expect( + authenticatedPage.getByText( + 'Successfully created repository auto-prune policy', + ), + ).not.toBeVisible({timeout: 10000}); + + // ADD SECOND POLICY + await authenticatedPage.getByRole('button', {name: 'Add Policy'}).click(); + await expect( + authenticatedPage.locator('#autoprune-policy-form-1'), + ).toBeVisible(); + + // CREATE SECOND POLICY: By age of tags (2 weeks) in second form + const secondForm = authenticatedPage.locator('#autoprune-policy-form-1'); + await secondForm + .getByTestId('auto-prune-method') + .selectOption('creation_date'); + await secondForm + .locator('input[aria-label="tag creation date value"]') + .fill('2'); + await secondForm + .locator('select[aria-label="tag creation date unit"]') + .selectOption('w'); + await secondForm.getByRole('button', {name: 'Save'}).click(); + + await expect( + authenticatedPage.getByText( + 'Successfully created repository auto-prune policy', + ), + ).toBeVisible(); + + // Wait for success message to disappear before update + await expect( + authenticatedPage.getByText( + 'Successfully created repository auto-prune policy', + ), + ).not.toBeVisible({timeout: 10000}); + + // UPDATE SECOND POLICY: Change to "By number of tags" + await secondForm + .getByTestId('auto-prune-method') + .selectOption('number_of_tags'); + await secondForm.getByRole('button', {name: 'Save'}).click(); + + await expect( + authenticatedPage.getByText( + 'Successfully updated repository auto-prune policy', + ), + ).toBeVisible(); + await expect( + secondForm.locator('input[aria-label="number of tags"]'), + ).toHaveValue('20'); + + // Wait for success message to disappear before delete + await expect( + authenticatedPage.getByText( + 'Successfully updated repository auto-prune policy', + ), + ).not.toBeVisible({timeout: 10000}); + + // DELETE SECOND POLICY + await secondForm.getByTestId('auto-prune-method').selectOption('none'); + await secondForm.getByRole('button', {name: 'Save'}).click(); + + await expect( + authenticatedPage.getByText( + 'Successfully deleted repository auto-prune policy', + ), + ).toBeVisible(); + await expect( + authenticatedPage.locator('#autoprune-policy-form-1'), + ).not.toBeVisible(); + + // Wait for success message to disappear before deleting first policy + await expect( + authenticatedPage.getByText( + 'Successfully deleted repository auto-prune policy', + ), + ).not.toBeVisible({timeout: 10000}); + + // DELETE FIRST POLICY + const firstForm = authenticatedPage.locator('#autoprune-policy-form-0'); + await firstForm.getByTestId('auto-prune-method').selectOption('none'); + await firstForm.getByRole('button', {name: 'Save'}).click(); + + await expect( + authenticatedPage.getByText( + 'Successfully deleted repository auto-prune policy', + ), + ).toBeVisible(); + await expect( + authenticatedPage.getByTestId('auto-prune-method'), + ).toContainText('None'); + }); + + test('shows namespace auto-prune policy in repository settings', async ({ + authenticatedPage, + api, + }) => { + // Setup: Create organization and repository + const org = await api.organization('nspolicy'); + const repo = await api.repository(org.name, 'testrepo'); + + // Navigate to organization settings → Auto-Prune Policies + await authenticatedPage.goto(`/organization/${org.name}?tab=Settings`); + await authenticatedPage.getByText('Auto-Prune Policies').click(); + + // Create namespace policy: By number of tags (25) with tag pattern + await authenticatedPage + .getByTestId('auto-prune-method') + .selectOption('number_of_tags'); + await authenticatedPage.getByTestId('tag-pattern').fill('v1.*'); + await authenticatedPage + .locator('select[aria-label="tag pattern matches"]') + .selectOption('doesnotmatch'); + await authenticatedPage + .locator('input[aria-label="number of tags"]') + .press('End'); + await authenticatedPage + .locator('input[aria-label="number of tags"]') + .press('Backspace'); + await authenticatedPage + .locator('input[aria-label="number of tags"]') + .type('5'); + await authenticatedPage.getByRole('button', {name: 'Save'}).click(); + + await expect( + authenticatedPage.getByText('Successfully created auto-prune policy'), + ).toBeVisible(); + + // Navigate to repository settings → Auto-Prune Policies + await authenticatedPage.goto(`/repository/${repo.fullName}?tab=settings`); + await authenticatedPage + .getByText('Repository Auto-Prune Policies') + .click(); + + // Verify namespace policy is displayed + await expect( + authenticatedPage.getByTestId('namespace-auto-prune-policy-heading'), + ).toContainText('Namespace Auto-Pruning Policies'); + await expect( + authenticatedPage.getByTestId('namespace-autoprune-policy-method'), + ).toContainText('Number of Tags'); + await expect( + authenticatedPage.getByTestId('namespace-autoprune-policy-value'), + ).toContainText('25'); + await expect( + authenticatedPage.getByTestId('namespace-autoprune-policy-tag-pattern'), + ).toContainText('v1.*'); + await expect( + authenticatedPage.getByTestId( + 'namespace-autoprune-policy-tag-pattern-matches', + ), + ).toContainText('does not match'); + }); + + test('shows registry auto-prune policy when configured', async ({ + authenticatedPage, + api, + quayConfig, + }) => { + // Skip if registry autoprune policy is not configured + const hasRegistryPolicy = + quayConfig?.config?.DEFAULT_NAMESPACE_AUTOPRUNE_POLICY != null; + test.skip( + !hasRegistryPolicy, + 'DEFAULT_NAMESPACE_AUTOPRUNE_POLICY not configured', + ); + + // Setup: Create organization and repository + const org = await api.organization('regpolicy'); + const repo = await api.repository(org.name, 'testrepo'); + + // Navigate to repository settings → Auto-Prune Policies + await authenticatedPage.goto(`/repository/${repo.fullName}?tab=settings`); + await authenticatedPage + .getByText('Repository Auto-Prune Policies') + .click(); + + // Verify registry policy is displayed + await expect( + authenticatedPage.getByTestId('registry-autoprune-policy-method'), + ).toBeVisible(); + await expect( + authenticatedPage.getByTestId('registry-autoprune-policy-value'), + ).toBeVisible(); + }); + + test('displays error when failing to load auto-prune policies', async ({ + authenticatedPage, + api, + }) => { + // Setup: Create repository + const repo = await api.repository(undefined, 'autoprune-error'); + + // Mock GET autoprunepolicy with 500 error + await authenticatedPage.route('**/autoprunepolicy/**', async (route) => { + if (route.request().method() === 'GET') { + await route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({error_message: 'Internal server error'}), + }); + } else { + await route.continue(); + } + }); + + // Navigate to repo settings → Auto-Prune Policies + await authenticatedPage.goto(`/repository/${repo.fullName}?tab=settings`); + await authenticatedPage + .getByText('Repository Auto-Prune Policies') + .click(); + + // Verify error message + await expect( + authenticatedPage.getByText('Unable to complete request'), + ).toBeVisible(); + await expect( + authenticatedPage.getByText(/unexpected issue occurred/i), + ).toBeVisible(); + }); + }, +); diff --git a/web/playwright/e2e/repository/notifications.spec.ts b/web/playwright/e2e/repository/notifications.spec.ts index a34d8c2ac..9cf845c2c 100644 --- a/web/playwright/e2e/repository/notifications.spec.ts +++ b/web/playwright/e2e/repository/notifications.spec.ts @@ -1,4 +1,4 @@ -import {test, expect, skipUnlessFeature} from '../../fixtures'; +import {test, expect, mailpit} from '../../fixtures'; test.describe('Repository Notifications', {tag: ['@repository']}, () => { test('renders and expands notification details', async ({ @@ -340,34 +340,11 @@ test.describe('Repository Notifications', {tag: ['@repository']}, () => { test( 'creates email notification with authorization flow', {tag: '@feature:MAILING'}, - async ({authenticatedPage, api, page}) => { + async ({authenticatedPage, api}) => { // Create test organization with repository const org = await api.organization('emailnotif'); const repo = await api.repository(org.name, 'emailrepo'); - - // Mock email authorization endpoints (email confirmation can't be automated) - let callCount = 0; - await page.route( - '**/api/v1/repository/**/authorizedemail/**', - async (route) => { - callCount++; - if (route.request().method() === 'GET') { - // First GET returns not confirmed, subsequent returns confirmed - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({confirmed: callCount > 2}), - }); - } else { - // POST to send authorization email - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({success: true}), - }); - } - }, - ); + const testEmail = 'notification-test@example.com'; // Navigate to repository settings > Events and notifications tab await authenticatedPage.goto( @@ -394,12 +371,12 @@ test.describe('Repository Notifications', {tag: ['@repository']}, () => { await authenticatedPage .getByTestId('notification-method-dropdown') .click(); - await authenticatedPage.getByText('E-mail').click(); + await authenticatedPage + .getByRole('menuitem', {name: 'Email Notification'}) + .click(); // Fill email - await authenticatedPage - .getByTestId('notification-email') - .fill('test@example.com'); + await authenticatedPage.getByTestId('notification-email').fill(testEmail); // Enter title await authenticatedPage @@ -422,8 +399,25 @@ test.describe('Repository Notifications', {tag: ['@repository']}, () => { authenticatedPage.getByText(/An email has been sent/i), ).toBeVisible(); - // Mock will return confirmed after a few calls, notification should be created - // Wait for the notification to appear in the table + // Wait for the verification email in Mailpit + const authEmail = await mailpit.waitForEmail( + (msg) => + msg.To.some((to) => to.Address === testEmail) && + msg.Subject.toLowerCase().includes('verify'), + 15000, + ); + expect(authEmail).not.toBeNull(); + + // Extract and visit the confirmation link + const confirmLink = await mailpit.extractLink(authEmail!.ID); + expect(confirmLink).not.toBeNull(); + + // Open confirmation link in a new page to avoid disrupting the polling + const confirmPage = await authenticatedPage.context().newPage(); + await confirmPage.goto(confirmLink!); + await confirmPage.close(); + + // Wait for the notification to appear in the table (UI polls for confirmation) await expect( authenticatedPage.locator('tbody', {hasText: 'Email Notification'}), ).toBeVisible({timeout: 15000}); diff --git a/web/playwright/fixtures.ts b/web/playwright/fixtures.ts index 0ffcd649f..bfbfa7999 100644 --- a/web/playwright/fixtures.ts +++ b/web/playwright/fixtures.ts @@ -691,3 +691,13 @@ export function uniqueName(prefix: string): string { .toString(36) .substring(2, 8)}`; } + +// ============================================================================ +// Mailpit: Re-export from utils for backward compatibility +// ============================================================================ + +export { + mailpit, + MailpitMessage, + MailpitMessagesResponse, +} from './utils/mailpit'; diff --git a/web/playwright/global-setup.ts b/web/playwright/global-setup.ts index dffc5485c..52458b35c 100644 --- a/web/playwright/global-setup.ts +++ b/web/playwright/global-setup.ts @@ -13,6 +13,7 @@ import {chromium, FullConfig} from '@playwright/test'; import {API_URL} from './utils/config'; import {ApiClient} from './utils/api'; +import {mailpit} from './utils/mailpit'; export const TEST_USERS = { // Admin/superuser for admin operations @@ -49,6 +50,33 @@ async function globalSetup(config: FullConfig) { // Track failures to report at the end const failures: string[] = []; + // Check if FEATURE_MAILING is enabled + let mailingEnabled = false; + try { + const configResponse = await fetch(`${API_URL}/config`); + if (configResponse.ok) { + const quayConfig = await configResponse.json(); + mailingEnabled = quayConfig?.features?.MAILING === true; + } + } catch { + console.log( + '[Global Setup] Could not fetch config, assuming mailing disabled', + ); + } + + // Check if Mailpit is available when mailing is enabled + const mailpitAvailable = mailingEnabled && (await mailpit.isAvailable()); + if (mailingEnabled) { + console.log( + `[Global Setup] FEATURE_MAILING enabled, Mailpit ${ + mailpitAvailable ? 'available' : 'NOT available' + }`, + ); + if (mailpitAvailable) { + await mailpit.clearInbox(); + } + } + // Create test users (skip if they already exist) // Each user creation requires a fresh context and CSRF token for (const [role, user] of Object.entries(TEST_USERS)) { @@ -61,6 +89,26 @@ async function globalSetup(config: FullConfig) { const api = new ApiClient(userRequest); await api.createUser(user.username, user.password, user.email); console.log(`[Global Setup] Created ${role} user: ${user.username}`); + + // Verify email if mailing is enabled and Mailpit is available + if (mailpitAvailable) { + console.log( + `[Global Setup] Verifying email for ${role} user: ${user.email}`, + ); + const confirmLink = await mailpit.waitForConfirmationLink(user.email); + if (confirmLink) { + const page = await userContext.newPage(); + await page.goto(confirmLink); + await page.close(); + console.log( + `[Global Setup] Email verified for ${role} user: ${user.username}`, + ); + } else { + console.warn( + `[Global Setup] No confirmation email found for ${user.email}`, + ); + } + } } catch (error) { const errorMessage = String(error); // User might already exist (400 error with "already exists") diff --git a/web/playwright/utils/mailpit.ts b/web/playwright/utils/mailpit.ts new file mode 100644 index 000000000..c497644ea --- /dev/null +++ b/web/playwright/utils/mailpit.ts @@ -0,0 +1,153 @@ +/** + * Mailpit: Local email testing utilities + * + * Requires mailpit to be running (docker-compose up -d mailpit). + * + * @example + * ```typescript + * import { mailpit } from './utils/mailpit'; + * + * await mailpit.clearInbox(); + * const email = await mailpit.waitForEmail(msg => msg.Subject.includes('Verify')); + * const link = await mailpit.extractLink(email.ID); + * ``` + */ + +const MAILPIT_API = 'http://localhost:8025/api/v1'; + +/** + * Email message from Mailpit API + */ +export interface MailpitMessage { + ID: string; + From: {Address: string; Name: string}; + To: {Address: string; Name: string}[]; + Subject: string; + Snippet: string; + Created: string; +} + +/** + * Response from Mailpit messages endpoint + */ +export interface MailpitMessagesResponse { + messages: MailpitMessage[]; + total: number; +} + +/** + * Mailpit utilities for testing email functionality. + */ +export const mailpit = { + /** + * Get all emails in the inbox + */ + async getEmails(): Promise { + const response = await fetch(`${MAILPIT_API}/messages`); + if (!response.ok) { + throw new Error(`Mailpit API error: ${response.status}`); + } + return response.json(); + }, + + /** + * Clear all emails from the inbox + */ + async clearInbox(): Promise { + const response = await fetch(`${MAILPIT_API}/messages`, {method: 'DELETE'}); + if (!response.ok) { + throw new Error(`Mailpit API error: ${response.status}`); + } + }, + + /** + * Wait for an email matching the predicate + * + * @param predicate - Function to match the desired email + * @param timeout - Max wait time in ms (default: 10000) + * @param interval - Poll interval in ms (default: 500) + * @returns Matching email or null if not found within timeout + */ + async waitForEmail( + predicate: (msg: MailpitMessage) => boolean, + timeout = 10000, + interval = 500, + ): Promise { + const start = Date.now(); + while (Date.now() - start < timeout) { + const {messages} = await this.getEmails(); + const found = messages.find(predicate); + if (found) return found; + await new Promise((r) => setTimeout(r, interval)); + } + return null; + }, + + /** + * Get the full body of an email + * + * @param id - Email ID from MailpitMessage.ID + * @returns Email body (plain text if available, otherwise HTML) + */ + async getEmailBody(id: string): Promise { + const response = await fetch(`${MAILPIT_API}/message/${id}`); + if (!response.ok) { + throw new Error(`Mailpit API error: ${response.status}`); + } + const data = await response.json(); + return data.Text || data.HTML; + }, + + /** + * Check if Mailpit is available + * + * @returns true if Mailpit is running and accessible + */ + async isAvailable(): Promise { + try { + const response = await fetch(`${MAILPIT_API}/messages`, { + signal: AbortSignal.timeout(1000), + }); + return response.ok; + } catch { + return false; + } + }, + + /** + * Extract a confirmation/action link from an email body + * + * @param emailId - Email ID from MailpitMessage.ID + * @param linkPattern - Regex pattern to match the link (default: URLs with code= parameter) + * @returns The extracted URL or null if not found + */ + async extractLink( + emailId: string, + linkPattern = /https?:\/\/[^\s\])]+[?&]code=[^\s\])]+/, + ): Promise { + const body = await this.getEmailBody(emailId); + const match = body.match(linkPattern); + return match ? match[0] : null; + }, + + /** + * Wait for a confirmation email and extract the confirmation link + * + * @param emailAddress - Email address to look for + * @param timeout - Max wait time in ms (default: 10000) + * @returns The confirmation URL or null if not found + */ + async waitForConfirmationLink( + emailAddress: string, + timeout = 10000, + ): Promise { + const email = await this.waitForEmail( + (msg) => + msg.To.some((to) => to.Address === emailAddress) && + msg.Subject.includes('confirm'), + timeout, + ); + if (!email) return null; + return this.extractLink(email.ID); + }, +}; diff --git a/web/src/routes/CreateAccount/CreateAccount.tsx b/web/src/routes/CreateAccount/CreateAccount.tsx index 18dc43e5f..850cb2adf 100644 --- a/web/src/routes/CreateAccount/CreateAccount.tsx +++ b/web/src/routes/CreateAccount/CreateAccount.tsx @@ -120,6 +120,7 @@ export function CreateAccount() { type="text" id="username" name="username" + data-testid="create-account-username" value={username} onChange={(_event, v) => setUsername(v)} validated={validateUsername(username)} @@ -156,6 +157,7 @@ export function CreateAccount() { type="email" id="email" name="email" + data-testid="create-account-email" value={email} onChange={(_event, v) => setEmail(v)} validated={validateEmail(email)} @@ -191,6 +193,7 @@ export function CreateAccount() { type="password" id="password" name="password" + data-testid="create-account-password" value={password} onChange={(_event, v) => setPassword(v)} validated={validatePassword(password)} @@ -226,6 +229,7 @@ export function CreateAccount() { type="password" id="confirm-password" name="confirm-password" + data-testid="create-account-confirm-password" value={confirmPassword} onChange={(_event, v) => setConfirmPassword(v)} validated={validateConfirmPassword(confirmPassword)} @@ -262,6 +266,7 @@ export function CreateAccount() { isDisabled={!isFormValid() || isLoading} isLoading={isLoading} onClick={onCreateAccountClick} + data-testid="create-account-submit" > Create Account