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:
15
.github/actions/setup-quay/action.yaml
vendored
15
.github/actions/setup-quay/action.yaml
vendored
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
362
web/playwright/e2e/auth/create-account.spec.ts
Normal file
362
web/playwright/e2e/auth/create-account.spec.ts
Normal 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);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
436
web/playwright/e2e/repository/autopruning.spec.ts
Normal file
436
web/playwright/e2e/repository/autopruning.spec.ts
Normal 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();
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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});
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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")
|
||||
|
||||
153
web/playwright/utils/mailpit.ts
Normal file
153
web/playwright/utils/mailpit.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user