diff --git a/web/cypress/e2e/update-user.cy.ts b/web/cypress/e2e/update-user.cy.ts deleted file mode 100644 index 10df25560..000000000 --- a/web/cypress/e2e/update-user.cy.ts +++ /dev/null @@ -1,272 +0,0 @@ -/// - -// 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; - }); - }); -}); diff --git a/web/playwright/MIGRATION.md b/web/playwright/MIGRATION.md index a9884a1fd..0e8b016ba 100644 --- a/web/playwright/MIGRATION.md +++ b/web/playwright/MIGRATION.md @@ -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` | | | diff --git a/web/playwright/e2e/user/update-user.spec.ts b/web/playwright/e2e/user/update-user.spec.ts new file mode 100644 index 000000000..00a891b7c --- /dev/null +++ b/web/playwright/e2e/user/update-user.spec.ts @@ -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 + } + }); + }, + ); +}); diff --git a/web/playwright/fixtures.ts b/web/playwright/fixtures.ts index df8e32853..5b841fb2d 100644 --- a/web/playwright/fixtures.ts +++ b/web/playwright/fixtures.ts @@ -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({ 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); }, diff --git a/web/src/routes/UpdateUser/UpdateUser.tsx b/web/src/routes/UpdateUser/UpdateUser.tsx index 3369d6786..99dd3eeb6 100644 --- a/web/src/routes/UpdateUser/UpdateUser.tsx +++ b/web/src/routes/UpdateUser/UpdateUser.tsx @@ -199,6 +199,7 @@ export default function UpdateUser() { handleUsernameChange(value)} validated={ @@ -236,6 +237,7 @@ export default function UpdateUser() {