mirror of
https://github.com/quay/quay.git
synced 2026-01-26 06:21:37 +03:00
test(web): migrate update-user tests from Cypress to Playwright (#4788)
Migrate UpdateUser page tests following MIGRATION.md patterns: - Add unauthenticatedPage fixture to fixtures.ts for anonymous tests - Add data-testid attributes to UpdateUser.tsx for stable selectors - Create update-user.spec.ts with profile metadata tests - Include mailpit email verification for FEATURE_MAILING support Consolidates 7 Cypress tests to 4 Playwright tests (3 passing, 1 skipped). OAuth username confirmation tests marked TODO for future implementation. Signed-off-by: Brady Pratt <bpratt@redhat.com>
This commit is contained in:
@@ -1,272 +0,0 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
// Handle QuaySidebar config errors in tests
|
||||
Cypress.on('uncaught:exception', (err, runnable) => {
|
||||
// Ignore QuaySidebar config errors in tests
|
||||
if (err.message.includes('quay_io')) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
describe('Update User Component', () => {
|
||||
beforeEach(() => {
|
||||
cy.exec('npm run quay:seed');
|
||||
|
||||
cy.intercept('GET', '/csrf_token', {
|
||||
body: {csrf_token: 'test-csrf-token'},
|
||||
}).as('getCsrfToken');
|
||||
|
||||
cy.intercept('GET', '/config', {
|
||||
body: {
|
||||
features: {
|
||||
DIRECT_LOGIN: true,
|
||||
USERNAME_CONFIRMATION: true,
|
||||
MAILING: true,
|
||||
USER_CREATION: true,
|
||||
},
|
||||
config: {
|
||||
AUTHENTICATION_TYPE: 'Database',
|
||||
SERVER_HOSTNAME: 'localhost:8080',
|
||||
},
|
||||
external_login: [],
|
||||
registry_state: 'normal',
|
||||
},
|
||||
}).as('getConfig');
|
||||
});
|
||||
|
||||
it('redirects non-authenticated users to signin', () => {
|
||||
cy.intercept('GET', '/api/v1/user/', {
|
||||
statusCode: 401,
|
||||
body: {message: 'Unauthorized'},
|
||||
}).as('getUser');
|
||||
|
||||
cy.visit('/updateuser');
|
||||
cy.wait('@getUser');
|
||||
|
||||
cy.url().should('include', '/signin');
|
||||
});
|
||||
|
||||
it('displays username confirmation form', () => {
|
||||
cy.intercept('GET', '/api/v1/user/', {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
username: 'auto_generated_user123',
|
||||
anonymous: false,
|
||||
prompts: ['confirm_username'],
|
||||
},
|
||||
}).as('getUser');
|
||||
|
||||
cy.visit('/updateuser');
|
||||
cy.wait('@getUser');
|
||||
|
||||
cy.get('h2').should('contain.text', 'Confirm Username');
|
||||
cy.get('#username').should('have.value', 'auto_generated_user123');
|
||||
cy.get('button[type="submit"]').should('contain.text', 'Confirm Username');
|
||||
});
|
||||
|
||||
it('successfully confirms username', () => {
|
||||
cy.intercept('GET', '/api/v1/user/', {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
username: 'auto_generated_user123',
|
||||
anonymous: false,
|
||||
prompts: ['confirm_username'],
|
||||
},
|
||||
}).as('getUser');
|
||||
|
||||
cy.intercept('GET', '/api/v1/users/*', {
|
||||
statusCode: 404,
|
||||
body: {username: 'test_signin_username'},
|
||||
}).as('validateUsername');
|
||||
cy.intercept('GET', '/api/v1/organization/test_signin_username', {
|
||||
statusCode: 404,
|
||||
}).as('validateOrg');
|
||||
|
||||
cy.intercept('PUT', '/api/v1/user/', {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
username: 'test_signin_username',
|
||||
anonymous: false,
|
||||
prompts: [],
|
||||
},
|
||||
}).as('updateUser');
|
||||
|
||||
cy.visit('/updateuser');
|
||||
cy.wait('@getUser');
|
||||
|
||||
cy.get('#username').clear().type('test_signin_username');
|
||||
cy.wait(['@validateUsername', '@validateOrg']);
|
||||
cy.get('button[type="submit"]').click();
|
||||
cy.wait('@updateUser');
|
||||
|
||||
cy.url().should('not.include', '/updateuser');
|
||||
});
|
||||
|
||||
it('displays profile form for metadata prompts', () => {
|
||||
cy.intercept('GET', '/api/v1/user/', {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
username: 'user1',
|
||||
anonymous: false,
|
||||
prompts: ['enter_name'],
|
||||
},
|
||||
}).as('getUser');
|
||||
|
||||
cy.visit('/updateuser');
|
||||
cy.wait('@getUser');
|
||||
|
||||
cy.get('h2').should('contain.text', 'Tell us a bit more about yourself');
|
||||
cy.get('#given-name').should('be.visible');
|
||||
cy.get('button[type="submit"]').should('be.disabled');
|
||||
cy.get('button').should('contain.text', 'No thanks');
|
||||
});
|
||||
|
||||
it('successfully saves profile metadata', () => {
|
||||
cy.intercept('GET', '/api/v1/user/', {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
username: 'user1',
|
||||
anonymous: false,
|
||||
prompts: ['enter_name'],
|
||||
},
|
||||
}).as('getUser');
|
||||
|
||||
cy.intercept('PUT', '/api/v1/user/', {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
username: 'user1',
|
||||
anonymous: false,
|
||||
prompts: [],
|
||||
},
|
||||
}).as('updateUser');
|
||||
|
||||
cy.visit('/updateuser');
|
||||
cy.wait('@getUser');
|
||||
|
||||
cy.get('#given-name').type('John');
|
||||
cy.get('button[type="submit"]').click();
|
||||
cy.wait('@updateUser');
|
||||
|
||||
cy.url().should('not.include', '/updateuser');
|
||||
});
|
||||
|
||||
it('allows skipping profile metadata', () => {
|
||||
cy.intercept('GET', '/api/v1/user/', {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
username: 'user1',
|
||||
anonymous: false,
|
||||
prompts: ['enter_name'],
|
||||
},
|
||||
}).as('getUser');
|
||||
|
||||
cy.intercept('PUT', '/api/v1/user/', {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
username: 'user1',
|
||||
anonymous: false,
|
||||
prompts: [],
|
||||
},
|
||||
}).as('updateUser');
|
||||
|
||||
cy.visit('/updateuser');
|
||||
cy.wait('@getUser');
|
||||
|
||||
cy.get('button').contains('No thanks').click();
|
||||
cy.wait('@updateUser');
|
||||
|
||||
cy.url().should('not.include', '/updateuser');
|
||||
});
|
||||
|
||||
it('redirects to home page instead of signin after OAuth username confirmation', () => {
|
||||
// Simulate OAuth flow: localStorage contains signin page as redirect URL
|
||||
cy.window().then((win) => {
|
||||
win.localStorage.setItem(
|
||||
'quay.redirectAfterLoad',
|
||||
'http://localhost:9000/signin',
|
||||
);
|
||||
});
|
||||
|
||||
let updateCalled = false;
|
||||
|
||||
// Intercept user fetches - return different responses before and after update
|
||||
cy.intercept('GET', '/api/v1/user/', (req) => {
|
||||
if (updateCalled) {
|
||||
// After update: user has no prompts
|
||||
req.reply({
|
||||
statusCode: 200,
|
||||
body: {
|
||||
username: 'oauth_user_123',
|
||||
anonymous: false,
|
||||
prompts: [],
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Before update: user has confirm_username prompt
|
||||
req.reply({
|
||||
statusCode: 200,
|
||||
body: {
|
||||
username: 'oauth_user_123',
|
||||
anonymous: false,
|
||||
prompts: ['confirm_username'],
|
||||
},
|
||||
});
|
||||
}
|
||||
}).as('getUser');
|
||||
|
||||
// Mock API calls that home page makes (to prevent 401 redirects)
|
||||
cy.intercept('GET', '/api/v1/user/notifications', {
|
||||
statusCode: 200,
|
||||
body: {notifications: []},
|
||||
}).as('getNotifications');
|
||||
|
||||
cy.intercept('GET', '/api/v1/user/robots*', {
|
||||
statusCode: 200,
|
||||
body: {robots: []},
|
||||
}).as('getRobots');
|
||||
|
||||
cy.intercept('GET', '/api/v1/repository*', {
|
||||
statusCode: 200,
|
||||
body: {repositories: []},
|
||||
}).as('getRepositories');
|
||||
|
||||
cy.intercept('GET', '/api/v1/users/*', {
|
||||
statusCode: 404,
|
||||
body: {username: 'oauth_user_123'},
|
||||
}).as('validateUsername');
|
||||
cy.intercept('GET', '/api/v1/organization/oauth_user_123', {
|
||||
statusCode: 404,
|
||||
}).as('validateOrg');
|
||||
|
||||
cy.intercept('PUT', '/api/v1/user/', (req) => {
|
||||
updateCalled = true;
|
||||
req.reply({
|
||||
statusCode: 200,
|
||||
body: {
|
||||
username: 'oauth_user_123',
|
||||
anonymous: false,
|
||||
prompts: [],
|
||||
},
|
||||
});
|
||||
}).as('updateUser');
|
||||
|
||||
cy.visit('/updateuser');
|
||||
cy.wait('@getUser');
|
||||
|
||||
cy.get('button[type="submit"]').click();
|
||||
cy.wait('@updateUser');
|
||||
|
||||
// Should NOT redirect back to signin (the bug we're fixing)
|
||||
cy.url().should('not.include', '/signin');
|
||||
// Should NOT be on updateuser page anymore
|
||||
cy.url().should('not.include', '/updateuser');
|
||||
// Should be on an authenticated page (home or organization page)
|
||||
cy.url().should('match', /\/(organization)?$/);
|
||||
|
||||
// Verify localStorage was cleaned up
|
||||
cy.window().then((win) => {
|
||||
expect(win.localStorage.getItem('quay.redirectAfterLoad')).to.be.null;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -723,5 +723,5 @@ Track migration progress from Cypress to Playwright.
|
||||
| ⬚ | `teams-and-membership.cy.ts` | | |
|
||||
| ⬚ | `team-sync.cy.ts` | | @config:OIDC |
|
||||
| ✅ | `theme-switcher.cy.ts` | `ui/theme-switcher.spec.ts` | |
|
||||
| ⬚ | `update-user.cy.ts` | | |
|
||||
| ✅ | `update-user.cy.ts` | `user/update-user.spec.ts` | @feature:USER_METADATA, consolidated 7→3 tests (OAuth tests TODO) |
|
||||
| ⬚ | `usage-logs.cy.ts` | | |
|
||||
|
||||
152
web/playwright/e2e/user/update-user.spec.ts
Normal file
152
web/playwright/e2e/user/update-user.spec.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import {test, expect, uniqueName, mailpit} from '../../fixtures';
|
||||
import {ApiClient} from '../../utils/api';
|
||||
|
||||
test.describe('UpdateUser Page', {tag: ['@user', '@auth']}, () => {
|
||||
test(
|
||||
'redirects unauthenticated users to signin',
|
||||
{tag: '@critical'},
|
||||
async ({unauthenticatedPage}) => {
|
||||
await unauthenticatedPage.goto('/updateuser');
|
||||
|
||||
// Component checks user.anonymous and redirects to /signin
|
||||
await expect(unauthenticatedPage).toHaveURL(/\/signin/);
|
||||
},
|
||||
);
|
||||
|
||||
// TODO: Implement with real OAuth flow when OAuth testing is available
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
test.skip('does not redirect to signin after OAuth username confirmation', () => {});
|
||||
|
||||
test.describe(
|
||||
'Profile metadata form',
|
||||
{tag: '@feature:USER_METADATA'},
|
||||
() => {
|
||||
test('displays and submits profile form', async ({
|
||||
browser,
|
||||
superuserRequest,
|
||||
quayConfig,
|
||||
}) => {
|
||||
const username = uniqueName('updatetest');
|
||||
const password = 'testpassword123';
|
||||
const email = `${username}@example.com`;
|
||||
const mailingEnabled = quayConfig?.features?.MAILING === true;
|
||||
|
||||
// Create temp user via superuser API
|
||||
const superApi = new ApiClient(superuserRequest);
|
||||
await superApi.createUser(username, password, email);
|
||||
|
||||
// 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 new user
|
||||
const api = new ApiClient(context.request);
|
||||
await api.signIn(username, password);
|
||||
|
||||
const page = await context.newPage();
|
||||
await page.goto('/updateuser');
|
||||
|
||||
// If user has prompts, form should be visible
|
||||
await expect(
|
||||
page.getByRole('heading', {name: /Tell us a bit more/i}),
|
||||
).toBeVisible();
|
||||
|
||||
// Verify form fields are visible
|
||||
await expect(page.getByTestId('update-user-given-name')).toBeVisible();
|
||||
await expect(page.getByTestId('update-user-family-name')).toBeVisible();
|
||||
await expect(page.getByTestId('update-user-company')).toBeVisible();
|
||||
await expect(page.getByTestId('update-user-location')).toBeVisible();
|
||||
|
||||
// Submit button should be disabled initially (no data entered)
|
||||
await expect(
|
||||
page.getByTestId('update-user-save-details-btn'),
|
||||
).toBeDisabled();
|
||||
|
||||
// Fill given name and submit
|
||||
await page.getByTestId('update-user-given-name').fill('TestGivenName');
|
||||
await expect(
|
||||
page.getByTestId('update-user-save-details-btn'),
|
||||
).toBeEnabled();
|
||||
await page.getByTestId('update-user-save-details-btn').click();
|
||||
|
||||
// Should redirect away from updateuser
|
||||
await expect(page).not.toHaveURL(/\/updateuser/);
|
||||
|
||||
await page.close();
|
||||
await context.close();
|
||||
|
||||
// Cleanup
|
||||
try {
|
||||
await superApi.deleteUser(username);
|
||||
} catch {
|
||||
// User may already be deleted
|
||||
}
|
||||
});
|
||||
|
||||
test('skips profile metadata with No thanks button', async ({
|
||||
browser,
|
||||
superuserRequest,
|
||||
quayConfig,
|
||||
}) => {
|
||||
const username = uniqueName('skiptest');
|
||||
const password = 'testpassword123';
|
||||
const email = `${username}@example.com`;
|
||||
const mailingEnabled = quayConfig?.features?.MAILING === true;
|
||||
|
||||
// Create temp user via superuser API
|
||||
const superApi = new ApiClient(superuserRequest);
|
||||
await superApi.createUser(username, password, email);
|
||||
|
||||
// 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 new user
|
||||
const api = new ApiClient(context.request);
|
||||
await api.signIn(username, password);
|
||||
|
||||
const page = await context.newPage();
|
||||
await page.goto('/updateuser');
|
||||
|
||||
// If user has prompts, form should be visible
|
||||
await expect(
|
||||
page.getByRole('heading', {name: /Tell us a bit more/i}),
|
||||
).toBeVisible();
|
||||
|
||||
// Click "No thanks" to skip
|
||||
await page.getByTestId('update-user-skip-btn').click();
|
||||
|
||||
// Should redirect away from updateuser
|
||||
await expect(page).not.toHaveURL(/\/updateuser/);
|
||||
|
||||
await page.close();
|
||||
await context.close();
|
||||
|
||||
// Cleanup
|
||||
try {
|
||||
await superApi.deleteUser(username);
|
||||
} catch {
|
||||
// User may already be deleted
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -721,6 +721,9 @@ type TestFixtures = {
|
||||
// Pre-authenticated page as readonly user
|
||||
readonlyPage: Page;
|
||||
|
||||
// Fresh unauthenticated page for anonymous user tests
|
||||
unauthenticatedPage: Page;
|
||||
|
||||
// Pre-authenticated API request context as regular user
|
||||
authenticatedRequest: APIRequestContext;
|
||||
|
||||
@@ -867,6 +870,15 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||
await page.close();
|
||||
},
|
||||
|
||||
unauthenticatedPage: 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();
|
||||
},
|
||||
|
||||
authenticatedRequest: async ({userContext}, use) => {
|
||||
await use(userContext.request);
|
||||
},
|
||||
|
||||
@@ -199,6 +199,7 @@ export default function UpdateUser() {
|
||||
<FormGroup label="Username" fieldId="username">
|
||||
<TextInput
|
||||
id="username"
|
||||
data-testid="update-user-username"
|
||||
value={username}
|
||||
onChange={(_event, value) => handleUsernameChange(value)}
|
||||
validated={
|
||||
@@ -236,6 +237,7 @@ export default function UpdateUser() {
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
data-testid="update-user-confirm-username-btn"
|
||||
isDisabled={
|
||||
usernameState === 'existing' ||
|
||||
usernameState === 'error' ||
|
||||
@@ -269,6 +271,7 @@ export default function UpdateUser() {
|
||||
<FormGroup label="Given Name" fieldId="given-name">
|
||||
<TextInput
|
||||
id="given-name"
|
||||
data-testid="update-user-given-name"
|
||||
placeholder="Given Name"
|
||||
value={metadata.given_name}
|
||||
onChange={(_event, value) =>
|
||||
@@ -280,6 +283,7 @@ export default function UpdateUser() {
|
||||
<FormGroup label="Family Name" fieldId="family-name">
|
||||
<TextInput
|
||||
id="family-name"
|
||||
data-testid="update-user-family-name"
|
||||
placeholder="Family Name"
|
||||
value={metadata.family_name}
|
||||
onChange={(_event, value) =>
|
||||
@@ -291,6 +295,7 @@ export default function UpdateUser() {
|
||||
<FormGroup label="Company" fieldId="company">
|
||||
<TextInput
|
||||
id="company"
|
||||
data-testid="update-user-company"
|
||||
placeholder="Company name"
|
||||
value={metadata.company}
|
||||
onChange={(_event, value) =>
|
||||
@@ -302,6 +307,7 @@ export default function UpdateUser() {
|
||||
<FormGroup label="Location" fieldId="location">
|
||||
<TextInput
|
||||
id="location"
|
||||
data-testid="update-user-location"
|
||||
placeholder="Location"
|
||||
value={metadata.location}
|
||||
onChange={(_event, value) =>
|
||||
@@ -316,6 +322,7 @@ export default function UpdateUser() {
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
data-testid="update-user-save-details-btn"
|
||||
isDisabled={
|
||||
!metadata.given_name &&
|
||||
!metadata.family_name &&
|
||||
@@ -327,6 +334,7 @@ export default function UpdateUser() {
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
data-testid="update-user-skip-btn"
|
||||
onClick={() =>
|
||||
handleUpdateUser({
|
||||
company: '',
|
||||
|
||||
Reference in New Issue
Block a user