1
0
mirror of https://github.com/quay/quay.git synced 2026-01-26 06:21:37 +03:00

test(web): migrate more tests to playwright (#4767)

* 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 <noreply@anthropic.com>

* test: enable features by default

Signed-off-by: Brady Pratt <bpratt@redhat.com>

* 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 <noreply@anthropic.com>
Signed-off-by: Brady Pratt <bpratt@redhat.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>
Signed-off-by: Brady Pratt <bpratt@redhat.com>

* 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 <noreply@anthropic.com>
Signed-off-by: Brady Pratt <bpratt@redhat.com>

* test(web): mock user response in cypress test

this broke when mailing was enabled

Signed-off-by: Brady Pratt <bpratt@redhat.com>

---------

Signed-off-by: Brady Pratt <bpratt@redhat.com>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
jbpratt
2026-01-05 11:47:29 -06:00
committed by GitHub
parent 8485956048
commit 0638c19183
13 changed files with 1116 additions and 402 deletions

View File

@@ -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()

View File

@@ -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

View File

@@ -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:

View File

@@ -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');

View File

@@ -1,362 +0,0 @@
/// <reference types="cypress" />
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');
});
});

View File

@@ -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<void>;
}
const test = base.extend<CreateAccountFixtures>({
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);
},
);
});

View File

@@ -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<LogoutTestFixtures>({
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);

View File

@@ -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();
});
},
);

View File

@@ -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});

View File

@@ -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';

View File

@@ -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")

View File

@@ -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<MailpitMessagesResponse> {
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<void> {
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<MailpitMessage | null> {
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<string> {
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<boolean> {
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<string | null> {
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<string | null> {
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);
},
};

View File

@@ -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
</Button>