diff --git a/.github/workflows/e2e-main.yaml b/.github/workflows/e2e-main.yaml index 7792f1e3..7540bc71 100644 --- a/.github/workflows/e2e-main.yaml +++ b/.github/workflows/e2e-main.yaml @@ -92,7 +92,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 - name: Update podman v. 5.x run: | @@ -111,16 +111,19 @@ jobs: sudo apt-get update && \ sudo apt-get -y install podman; } podman version + - name: Revert unprivileged user namespace restrictions in Ubuntu 24.04 run: | # allow unprivileged user namespace sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + - name: Build Podman Desktop for E2E tests Development Mode working-directory: ./podman-desktop if: ${{ env.MODE == 'development' }} run: | pnpm install pnpm test:e2e:build + - name: Build Podman Desktop for E2E tests Production Mode working-directory: ./podman-desktop if: ${{ env.MODE == 'production' }} @@ -132,6 +135,7 @@ jobs: path=$(realpath ./dist/linux-unpacked/podman-desktop) echo "Podman Desktop built binary: $path" echo "PODMAN_DESKTOP_BINARY_PATH=$path" >> $GITHUB_ENV + - name: Execute pnpm in Red Hat Account Extension working-directory: ${{ env.REPOSITORY }} run: | @@ -140,11 +144,23 @@ jobs: echo "Version of @podman-desktop/tests-playwright to be used: $version" jq --arg version "$version" '.devDependencies."@podman-desktop/tests-playwright" = $version' package.json > package.json_tmp && mv package.json_tmp package.json pnpm install --no-frozen-lockfile + + - name: Set default browser to Chromium + run: | + # TODO: Follow up issue: https://github.com/redhat-developer/podman-desktop-redhat-account-ext/issues/727 + chromiumBrowser=$(which chromium-browser) + echo "Path to chromium: ${chromiumBrowser}" + sudo update-alternatives --install /usr/bin/x-www-browser x-www-browser $chromiumBrowser 500 + sudo update-alternatives --set x-www-browser $chromiumBrowser + - name: Run All E2E tests in Red Hat Account Extension in Development Mode working-directory: ${{ env.REPOSITORY }} if: ${{ env.MODE == 'development' }} env: PODMAN_DESKTOP_ARGS: ${{ github.workspace }}/podman-desktop + DVLPR_USERNAME: ${{ secrets.DVLPR_USERNAME }} + DVLPR_PASSWORD: ${{ secrets.DVLPR_PASSWORD }} + AUTH_E2E_TESTS: true run: pnpm test:e2e - name: Run All E2E tests in Red Hat Account Extension in Production mode @@ -152,9 +168,24 @@ jobs: if: ${{ env.MODE == 'production' }} env: PODMAN_DESKTOP_BINARY: ${{ env.PODMAN_DESKTOP_BINARY_PATH }} + DVLPR_USERNAME: ${{ secrets.DVLPR_USERNAME }} + DVLPR_PASSWORD: ${{ secrets.DVLPR_PASSWORD }} + AUTH_E2E_TESTS: true run: pnpm test:e2e - - uses: actions/upload-artifact@v4 + - name: Publish Test Report + uses: mikepenz/action-junit-report@v5 + if: always() # always run even if the previous step fails + with: + fail_on_failure: true + include_passed: true + annotate_only: true + detailed_summary: true + require_tests: true + report_paths: '**/*results.xml' + + - name: Upload test artifacts + uses: actions/upload-artifact@v4 if: always() with: name: e2e-tests diff --git a/package.json b/package.json index 1350cc27..2fb9855e 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "test": "vitest run --coverage", "test:all": "pnpm test && pnpm test:e2e", "test:e2e:setup": "xvfb-maybe --auto-servernum --server-args='-screen 0 1280x960x24' --", - "test:e2e": "cross-env E2E_TESTS=true npm run test:e2e:setup npx playwright test tests/src" + "test:e2e": "cross-env E2E_TESTS=true DEBUG=pw:channel:response,pw:channel:event npm run test:e2e:setup npx playwright test tests/src" }, "dependencies": { "@podman-desktop/api": "^1.14.1", diff --git a/tests/src/model/pages/sso-authentication-page.ts b/tests/src/model/pages/sso-authentication-page.ts new file mode 100644 index 00000000..387db330 --- /dev/null +++ b/tests/src/model/pages/sso-authentication-page.ts @@ -0,0 +1,70 @@ +/********************************************************************** + * Copyright (C) 2025 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import type { Locator, Page } from '@playwright/test'; +import test, { expect as playExpect } from '@playwright/test'; +import { BasePage } from '@podman-desktop/tests-playwright'; + +export class SSOAuthenticationProviderCardPage extends BasePage { + readonly parent: Locator; + readonly providerInformation: Locator; + readonly providerActions: Locator; + readonly signinButton: Locator; + readonly providerName: Locator; + readonly providerStatus: Locator; + readonly logoutButton: Locator; + readonly userName: Locator; + readonly signoutButton: Locator; + + constructor(page: Page) { + super(page); + this.parent = this.page.getByRole('listitem', { name: 'Red Hat SSO' }); + this.providerInformation = this.parent.getByLabel('Provider Information'); + this.providerActions = this.parent.getByLabel('Provider Actions'); + this.signinButton = this.providerActions.getByRole('button', { name: 'Sign in' }); + this.providerName = this.providerInformation.getByLabel('Provider Name'); + this.providerStatus = this.providerInformation.getByLabel('Provider Status'); + this.userName = this.providerInformation.getByLabel('Logged In Username'); + this.signoutButton = this.providerInformation.getByRole('button', { name: 'Sign out of ', exact: false }); + } + + public async signIn(): Promise { + await test.step('Perform Sign In', async () => { + console.log(`Signin Button is enabled`); + await playExpect(this.signinButton).toBeEnabled(); + console.log(`Clicking on the button...`); + await this.signinButton.click(); + console.log(`Button clicked`); + }); + } + + public async logout(): Promise { + await test.step('Perform Sign Out', async () => { + await playExpect(this.signoutButton).toBeEnabled(); + await this.signoutButton.click(); + }); + } + + public async checkUserIsLoggedIn(loggedIn = true): Promise { + await test.step(loggedIn ? 'User si logged In' : 'User is logged out', async () => { + await playExpect(this.providerStatus).toBeVisible(); + console.log(`Status text: ${await this.providerStatus.innerText()}`); + await playExpect(this.providerStatus).toContainText(loggedIn ? 'logged in' : 'logged out', { ignoreCase: true }); + }); + } +} diff --git a/tests/src/sso-extension.spec.ts b/tests/src/sso-extension.spec.ts index 2ed04f61..fe4f8624 100644 --- a/tests/src/sso-extension.spec.ts +++ b/tests/src/sso-extension.spec.ts @@ -1,5 +1,5 @@ /********************************************************************** - * Copyright (C) 2024 Red Hat, Inc. + * Copyright (C) 2024-2025 Red Hat, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,34 +16,65 @@ * SPDX-License-Identifier: Apache-2.0 ***********************************************************************/ -import type { NavigationBar } from '@podman-desktop/tests-playwright'; -import { AuthenticationPage, expect as playExpect, ExtensionCardPage, RunnerOptions, test } from '@podman-desktop/tests-playwright'; +import { execSync } from 'node:child_process'; +import path, { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { Browser, Page } from '@playwright/test'; +import type { NavigationBar} from '@podman-desktop/tests-playwright'; +import { AuthenticationPage, expect as playExpect, ExtensionCardPage, isLinux,podmanExtension, RunnerOptions, StatusBar, test, TroubleshootingPage } from '@podman-desktop/tests-playwright'; + +import { SSOAuthenticationProviderCardPage } from './model/pages/sso-authentication-page'; import { SSOExtensionPage } from './model/pages/sso-extension-page'; +import { findPageWithTitleInBrowser, getSSOUrlFromLogs, performBrowserLogin, startChromium } from './utility/auth-utils'; +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); let extensionInstalled = false; let extensionCard: ExtensionCardPage; +let ssoProvider: SSOAuthenticationProviderCardPage; +let authPage: AuthenticationPage; +const chromePort = '9222'; +let browser: Browser; const imageName = 'ghcr.io/redhat-developer/podman-desktop-redhat-account-ext:latest'; const extensionLabel = 'redhat.redhat-authentication'; const extensionLabelName = 'redhat-authentication'; -const podmanExtensionLabel = 'podman-desktop.podman'; -const podmanExtensionLabelName = 'podman'; const authProviderName = 'Red Hat SSO'; const activeExtensionStatus = 'ACTIVE'; const disabledExtensionStatus = 'DISABLED'; +const expectedAuthPageTitle = 'Log In'; const skipInstallation = process.env.SKIP_INSTALLATION ? process.env.SKIP_INSTALLATION : false; +const regex = new RegExp(/((http|https):\/\/.*$)/); +const browserOutputPath = [__dirname, '..', 'playwright', 'output', 'browser']; + +const isGHActions = process.env.GITHUB_ACTIONS === 'true'; +const AUTH_E2E_TESTS = process.env.AUTH_E2E_TESTS === 'true'; test.use({ runnerOptions: new RunnerOptions({ customFolder: 'sso-tests-pd', autoUpdate: false, autoCheckUpdates: false }), }); test.beforeAll(async ({ runner, page, welcomePage }) => { runner.setVideoAndTraceName('sso-e2e'); + process.env.KEEP_VIDEOS_ON_PASS = 'true'; await welcomePage.handleWelcomePage(true); extensionCard = new ExtensionCardPage(page, extensionLabelName, extensionLabel); + authPage = new AuthenticationPage(page); + ssoProvider = new SSOAuthenticationProviderCardPage(page); }); test.afterAll(async ({ runner }) => { - await runner.close(); + test.setTimeout(90_000); + // handle the browser closure, might be not triggered if there was a test failure/error + try { + if (browser) { + await browser.close(); + } + } catch (error: unknown) { + console.log(`Something went wrong when closing browser: ${error}`); + } finally { + await terminateExternalBrowser(); + await runner.close(); + } }); test.describe.serial('Red Hat Authentication extension verification', () => { @@ -56,16 +87,20 @@ test.describe.serial('Red Hat Authentication extension verification', () => { } }); - // we want to skip removing of the extension when we are running tests from PR check test('Uninstall previous version of sso extension', async ({ navigationBar }) => { test.skip(!extensionInstalled || !!skipInstallation); test.setTimeout(60000); await removeExtension(navigationBar); }); - test('Podman Extension is activated', async ({ navigationBar }) => { + test('Podman Extension is activated', async ({ navigationBar, page }) => { const extensions = await navigationBar.openExtensions(); - const podmanExtensionCard = await extensions.getInstalledExtension(podmanExtensionLabelName, podmanExtensionLabel); + await extensions.openInstalledTab(); + await playExpect.poll( + async () => await extensions.extensionIsInstalled(podmanExtension.extensionLabel), + { timeout: 15_000}).toBeTruthy(); + const podmanExtensionCard = new ExtensionCardPage(page, podmanExtension.extensionName, podmanExtension.extensionFullLabel); + await playExpect(podmanExtensionCard.card).toBeVisible({ timeout: 20_000 }); await podmanExtensionCard.card.scrollIntoViewIfNeeded(); await playExpect(podmanExtensionCard.status).toHaveText(activeExtensionStatus, { timeout: 20_000 }); }); @@ -73,7 +108,7 @@ test.describe.serial('Red Hat Authentication extension verification', () => { // we want to install extension from OCI image (usually using latest tag) after new code was added to the codebase // and extension was published already test('Extension can be installed using OCI image', async ({ navigationBar }) => { - test.skip(extensionInstalled && !skipInstallation); + test.skip(extensionInstalled); test.setTimeout(200000); const extensions = await navigationBar.openExtensions(); await extensions.installExtensionFromOCIImage(imageName); @@ -84,7 +119,7 @@ test.describe.serial('Red Hat Authentication extension verification', () => { test('Extension (card) is installed, present and active', async ({ navigationBar }) => { const extensions = await navigationBar.openExtensions(); await playExpect.poll(async () => - await extensions.extensionIsInstalled(extensionLabel), { timeout: 30000 }, + await extensions.extensionIsInstalled(extensionLabel), { timeout: 30_000 }, ).toBeTruthy(); const extensionCard = await extensions.getInstalledExtension(extensionLabelName, extensionLabel); await playExpect(extensionCard.status).toHaveText(activeExtensionStatus); @@ -109,12 +144,12 @@ test.describe.serial('Red Hat Authentication extension verification', () => { test('SSO provider is available in Authentication Page', async ({ navigationBar }) => { const settingsBar = await navigationBar.openSettings(); - const authPage = await settingsBar.openTabPage(AuthenticationPage); + await settingsBar.openTabPage(AuthenticationPage); await playExpect(authPage.heading).toHaveText('Authentication'); - const provider = authPage.getProvider(authProviderName); - await playExpect(provider.getByLabel('Provider Information').getByLabel('Provider Name')).toHaveText(authProviderName); - await playExpect(provider.getByLabel('Provider Information').getByLabel('Provider Status')).toHaveText('Logged out'); - await playExpect(provider.getByLabel('Provider Actions').getByRole('button')).toContainText('Sign in'); + await playExpect(ssoProvider.parent).toBeVisible(); + await playExpect(ssoProvider.providerName).toHaveText(authProviderName); + await playExpect(ssoProvider.signinButton).toBeVisible(); + await ssoProvider.checkUserIsLoggedIn(false); }); }); @@ -128,10 +163,9 @@ test.describe.serial('Red Hat Authentication extension verification', () => { await playExpect(extensionCard.status).toHaveText(disabledExtensionStatus); const settingsBar = await navigationBar.openSettings(); - const authPage = await settingsBar.openTabPage(AuthenticationPage); + await settingsBar.openTabPage(AuthenticationPage); await playExpect(authPage.heading).toHaveText('Authentication'); - const provider = authPage.getProvider(authProviderName); - await playExpect(provider).toHaveCount(0); + await playExpect(ssoProvider.parent).not.toBeVisible(); }); test('Extension can be re-enabled correctly', async ({ navigationBar }) => { @@ -143,12 +177,87 @@ test.describe.serial('Red Hat Authentication extension verification', () => { await playExpect(extensionCard.status).toHaveText(activeExtensionStatus); const settingsBar = await navigationBar.openSettings(); - const authPage = await settingsBar.openTabPage(AuthenticationPage); + await settingsBar.openTabPage(AuthenticationPage); await playExpect(authPage.heading).toHaveText('Authentication'); - await playExpect(authPage.getProvider(authProviderName)).toHaveCount(1); + await playExpect(ssoProvider.parent).toBeVisible(); }); }); + test.describe.serial('Verify authentication of the user via browser', () => { + + test.skip(!AUTH_E2E_TESTS, 'Authentication E2E tests are being skipped'); + let chromiumPage: Page | undefined; + + test('Can open authentication page in browser', async ({ navigationBar, page }) => { + test.setTimeout(120_000); + const settingsBar = await navigationBar.openSettings(); + await settingsBar.openTabPage(AuthenticationPage); + await playExpect(ssoProvider.parent).toBeVisible(); + + // start up chrome instance and return browser object + browser = await startChromium(chromePort, path.join(...browserOutputPath)); + + // open the link from PD + await page.bringToFront(); + await ssoProvider.signIn(); + await page.waitForTimeout(5000); + // get to a default page -> the sso + chromiumPage = await findPageWithTitleInBrowser(browser, expectedAuthPageTitle); + if (!chromiumPage) { + console.log(`Did not find a page in default browser, trying to open new page with proper url...`); + // try to open custom url to perform a login + // get url from podman-desktop logs + const urlMatch = await getSSOUrlFromLogs(page, regex); + if (urlMatch) { + const context = await browser.newContext(); + const newPage = await context.newPage(); + await newPage.goto(urlMatch); + await newPage.waitForURL(/sso.redhat.com/); + chromiumPage = newPage; + } else { + throw new Error('Did not find Initial SSO Login Page'); + } + } + }); + + test('User can authenticate via browser', async () => { + // Activate the browser window and perform login + playExpect(chromiumPage).toBeDefined(); + if (!chromiumPage) { + throw new Error('Chromium browser page was not initialized'); + } + await chromiumPage.bringToFront(); + console.log(`Switched to Chrome tab with title: ${await chromiumPage.title()}`); + await performBrowserLogin(chromiumPage, process.env.DVLPR_USERNAME ?? 'unknown', process.env.DVLPR_PASSWORD ?? 'unknown', path.join(...browserOutputPath)); + await chromiumPage.close(); + }); + + test('User signed in status is propagated into Podman Desktop', async ({ page, navigationBar }) => { + // activate Podman Desktop again + await page.bringToFront(); + // verify the Signed in user + const settingsBar = await navigationBar.openSettings(); + await settingsBar.openTabPage(AuthenticationPage); + await playExpect(authPage.heading).toHaveText('Authentication'); + // on linux we need to avoid issue with auth. providers store + // in case of need, refresh auth. providers store in troubleshooting + await page.screenshot({ path: join(...browserOutputPath, 'screenshots', 'back_pd_after_authentication.png'), type: 'png' }); + if (await ssoProvider.signinButton.count() >= 0) { + console.log('SignIn Button still visible, we are hitting issue with linux'); + const status = new StatusBar(page); + await status.troubleshootingButton.click(); + const troubleshooting = new TroubleshootingPage(page); + await troubleshooting.refreshStore('auth providers'); + await navigationBar.openSettings(); + await settingsBar.openTabPage(AuthenticationPage); + } + await playExpect(ssoProvider.signinButton).not.toBeVisible(); + await ssoProvider.checkUserIsLoggedIn(true); + + // TODO continue with the tests + }); + }); + test('SSO extension can be removed', async ({ navigationBar }) => { await removeExtension(navigationBar); }); @@ -161,3 +270,14 @@ async function removeExtension(navBar: NavigationBar): Promise { await extensionCard.removeExtension(); await playExpect.poll(async () => await extensions.extensionIsInstalled(extensionLabel), { timeout: 15000 }).toBeFalsy(); } + +export async function terminateExternalBrowser(): Promise { + if (isGHActions && isLinux) { + try { + // eslint-disable-next-line + execSync('pkill -o firefox'); + } catch (error: unknown) { + console.log(`Error while killing the firefox: ${error}`); + } + } +} diff --git a/tests/src/utility/auth-utils.ts b/tests/src/utility/auth-utils.ts new file mode 100644 index 00000000..8370d4ac --- /dev/null +++ b/tests/src/utility/auth-utils.ts @@ -0,0 +1,111 @@ +/********************************************************************** + * Copyright (C) 2025 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { join } from 'node:path'; + +import type { Browser, Page} from '@playwright/test'; +import { chromium, expect as playExpect } from '@playwright/test'; +import { StatusBar, TroubleshootingPage } from '@podman-desktop/tests-playwright'; + +export async function findPageWithTitleInBrowser(browser: Browser, expectedTitle: string): Promise { + let chromePage: Page | undefined; + + for (let i = 0; i < 10; i++) { + await new Promise(resolve => setTimeout(resolve, 1000)); + const pages = browser.contexts().flatMap(context => context.pages()); + const pagesTitles = await Promise.all(pages.map(async (page) => ( + { page, title: await page.title() } + ))); + + chromePage = pagesTitles.find(p => p.title.includes(expectedTitle))?.page; + if (chromePage) { + break; + } + } + + if (!chromePage) { + console.error(`No page found with title: ${expectedTitle}`); + } + return chromePage; + } + + export async function performBrowserLogin(page: Page, username: string, pass: string, path: string): Promise { + console.log(`Performing browser login...`); + await playExpect(page).toHaveTitle(/Log In/); + await playExpect(page.getByRole('heading', { name: 'Log in to your Red Hat' })).toBeVisible(); + console.log(`We are on the login RH Login page...`); + const input = page.getByRole('textbox', { name: 'Red Hat login or email' }); + await playExpect(input).toBeVisible(); + await input.fill(username); + const nextButton = page.getByRole('button', { name: 'Next' }); + await nextButton.click(); + const passInput = page.getByRole('textbox', { name: 'Password' }); + await playExpect(passInput).toBeVisible(); + await passInput.fill(pass); + const loginButton = page.getByRole('button', { name: 'Log in' }); + await playExpect(loginButton).toBeEnabled(); + await loginButton.click(); + const backButton = page.getByRole('button', { name: 'Go back to Podman Desktop' }); + await playExpect(backButton).toBeEnabled(); + await page.screenshot({ path: join(path, 'screenshots', 'after_login_in_browser.png'), type: 'png', fullPage: true }); + console.log(`Logged in, go back...`); + await backButton.click(); + await page.screenshot({ path: join(path, 'screenshots', 'after_clck_go_back.png'), type: 'png', fullPage: true }); + } + + export async function startChromium(port: string, tracesPath: string): Promise { + console.log('Starting a web server on port 9222'); + const browserLaunch = await chromium.launch({ + headless: false, + args: [`--remote-debugging-port=${port}`], + tracesDir: tracesPath, + slowMo: 200, + }); + + // hard wait + await new Promise(resolve => setTimeout(resolve, 5_000)); + // Connect to the same Chrome instance via CDP + // possible option is to use chromium.connectOverCDP(`http://localhost:${port}`); + if (!browserLaunch) { + throw new Error('Browser object was not initialized properly'); + } else { + console.log(`Browser connected: ${browserLaunch.isConnected()}`); + } + return browserLaunch; + } + + export async function getSSOUrlFromLogs(page: Page, regex: RegExp): Promise { + await new StatusBar(page).troubleshootingButton.click(); + const troublePage = new TroubleshootingPage(page); + await playExpect(troublePage.heading).toBeVisible(); + // open logs + await troublePage.openLogs(); + const logList = troublePage.tabContent.getByRole('list'); + await playExpect(logList).toBeVisible(); + const ssoLine = logList.getByRole('listitem').filter( { hasText: /\[redhat-authentication\].*openid-connect.*/ }); + await playExpect(ssoLine).toBeVisible(); + await ssoLine.scrollIntoViewIfNeeded(); + await playExpect(ssoLine).toContainText('sso.redhat.com'); + const logText = await ssoLine.innerText(); + console.log(`The whole log line with url to openid: ${logText}`); + // parse the url: + const parsedString = regex.exec(logText); + const urlMatch = parsedString ? parsedString[1] : undefined; + console.log(`Matched string: ${urlMatch}`); + return urlMatch; + }