1
0
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:
jbpratt
2026-01-07 13:29:59 -06:00
committed by GitHub
parent 31e8f5ff21
commit 3c5fd96d4b
5 changed files with 173 additions and 273 deletions

View File

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

View File

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

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

View File

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

View File

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