diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index d220e8332bf..edd9f416270 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -56,7 +56,8 @@ jobs: - name: Run standard user tests if: ${{ success() || failure() }} run: | - yarn e2e:prod && yarn docker:local:stop + yarn e2e:prod + yarn docker:local:stop mkdir -p coverage-artifacts/coverage cp coverage/e2e/coverage-final.json coverage-artifacts/coverage/coverage-e2e.json env: diff --git a/cypress/e2e/po/components/sortable-table.po.ts b/cypress/e2e/po/components/sortable-table.po.ts index 6b6910e38a6..ddceb47ca89 100644 --- a/cypress/e2e/po/components/sortable-table.po.ts +++ b/cypress/e2e/po/components/sortable-table.po.ts @@ -91,10 +91,13 @@ export default class SortableTablePo extends ComponentPo { return new ListRowPo(this.rowElementWithName(name)); } - rowNames() { - return this.rowElements().find('.cluster-link').then(($els: any) => { + /** + * Get rows names. To avoid the 'no rows' on first load use `noRowsShouldNotExist` + */ + rowNames(rowNameSelector = 'td:nth-of-type(3)') { + return this.rowElements().find(rowNameSelector).then(($els: any) => { return ( - Cypress.$.makeArray($els).map((el: any) => el.innerText) + Cypress.$.makeArray($els).map((el: any) => el.innerText as string) ); }); } @@ -103,6 +106,14 @@ export default class SortableTablePo extends ComponentPo { return new ActionMenuPo(); } + noRowsShouldNotExist() { + return this.noRowsText().should('not.exist'); + } + + noRowsText() { + return this.self().find('tbody').find('.no-rows'); + } + /** * Check row element count on sortable table * @param isEmpty true if empty state expected (empty state message should display on row 1) diff --git a/cypress/e2e/po/pages/extensions.po.ts b/cypress/e2e/po/pages/extensions.po.ts index b922c80e373..90eaf716709 100644 --- a/cypress/e2e/po/pages/extensions.po.ts +++ b/cypress/e2e/po/pages/extensions.po.ts @@ -6,17 +6,18 @@ import ActionMenuPo from '@/cypress/e2e/po/components/action-menu.po'; import NameNsDescriptionPo from '@/cypress/e2e/po/components/name-ns-description.po'; import ReposListPagePo from '@/cypress/e2e/po/pages/repositories.po'; import AppClusterRepoEditPo from '@/cypress/e2e/po/edit/catalog.cattle.io.clusterrepo.po'; +import BannersPo from '~/cypress/e2e/po/components/banners.po'; -export default class ExtensionsPo extends PagePo { +export default class ExtensionsPagePo extends PagePo { static url = '/c/local/uiplugins' static goTo(): Cypress.Chainable { - return super.goTo(ExtensionsPo.url); + return super.goTo(ExtensionsPagePo.url); } extensionTabs: TabbedPo; constructor() { - super(ExtensionsPo.url); + super(ExtensionsPagePo.url); this.extensionTabs = new TabbedPo('[data-testid="extension-tabs"]'); } @@ -28,6 +29,14 @@ export default class ExtensionsPo extends PagePo { return this.self().getId('extensions-page-title').invoke('text'); } + waitForTitle() { + return this.title().should('contain', 'Extensions'); + } + + loading() { + return this.self().get('.data-loading'); + } + /** * install extensions operator */ @@ -204,6 +213,11 @@ export default class ExtensionsPo extends PagePo { return this.extensionReloadBanner().getId('extension-reload-banner-reload-btn').click(); } + // ------------------ new repos banner ------------------ + repoBanner() { + return new BannersPo('[data-testid="extensions-new-repos-banner"]', this.self()); + } + // ------------------ extension menu ------------------ private extensionMenu() { return this.self().getId('extensions-page-menu'); diff --git a/cypress/e2e/po/pages/extensions/kubewarden.po.ts b/cypress/e2e/po/pages/extensions/kubewarden.po.ts index a1f36f2db18..67bfccb3c0a 100644 --- a/cypress/e2e/po/pages/extensions/kubewarden.po.ts +++ b/cypress/e2e/po/pages/extensions/kubewarden.po.ts @@ -1,5 +1,5 @@ import PagePo from '@/cypress/e2e/po/pages/page.po'; -import ExtensionsPo from '@/cypress/e2e/po/pages/extensions.po'; +import ExtensionsPagePo from '@/cypress/e2e/po/pages/extensions.po'; export default class KubewardenExtensionPo extends PagePo { private static createPath(clusterId: string) { @@ -16,7 +16,7 @@ export default class KubewardenExtensionPo extends PagePo { /** add ui-plugin-charts repository */ addChartsRepoIfNeeded(): void { - const extensionsPo: ExtensionsPo = new ExtensionsPo(); + const extensionsPo: ExtensionsPagePo = new ExtensionsPagePo(); extensionsPo.waitForPage(); diff --git a/cypress/e2e/po/pages/home.po.ts b/cypress/e2e/po/pages/home.po.ts index d981e95da0a..b1f92b7d81e 100644 --- a/cypress/e2e/po/pages/home.po.ts +++ b/cypress/e2e/po/pages/home.po.ts @@ -57,7 +57,7 @@ export default class HomePagePo extends PagePo { return new HomeClusterListPo('[data-testid="cluster-list-container"]'); } - manangeButton() { + manageButton() { return cy.getId('cluster-management-manage-button'); } diff --git a/cypress/e2e/po/side-bars/user-menu.po.ts b/cypress/e2e/po/side-bars/user-menu.po.ts index 9e5519dce2a..2ba3736485d 100644 --- a/cypress/e2e/po/side-bars/user-menu.po.ts +++ b/cypress/e2e/po/side-bars/user-menu.po.ts @@ -28,11 +28,16 @@ export default class UserMenuPo extends ComponentPo { } /** - * Toggle user menu - * @returns + * Open the user menu + * + * Multiple clicks because sometimes just one ... isn't enough + * */ - toggle(): Cypress.Chainable { - return this.self().click(); + open(): Cypress.Chainable { + this.self().click(); + this.self().click(); + this.self().click(); + this.self().click(); } /** @@ -56,12 +61,12 @@ export default class UserMenuPo extends ComponentPo { if ($el.attr('style')?.includes('visibility: hidden')) { cy.log('User Avatar open but hidden, giving it a nudge'); - return this.toggle(); + return this.open(); } } else { cy.log('User Avatar not open, opening'); - return this.toggle(); + return this.open(); } }) .then(() => this.isOpen()); @@ -87,7 +92,7 @@ export default class UserMenuPo extends ComponentPo { * @param label * @returns */ - clickMenuItem(label: string) { + clickMenuItem(label: 'Preferences' | 'Account & API Keys' | 'Log Out') { this.ensureOpen().then(() => { return this.getMenuItems().contains(label).click(); }); diff --git a/cypress/e2e/tests/navigation/not-found-page.spec.ts b/cypress/e2e/tests/navigation/not-found-page.spec.ts index c859fcfb8cf..15aae7f12ba 100644 --- a/cypress/e2e/tests/navigation/not-found-page.spec.ts +++ b/cypress/e2e/tests/navigation/not-found-page.spec.ts @@ -1,16 +1,16 @@ import NotFoundPagePo from '@/cypress/e2e/po/pages/not-found-page.po'; -import ClusterManagerListPagePo from '~/cypress/e2e/po/pages/cluster-manager/cluster-manager-list.po'; -import { WorkloadsPodsListPagePo } from '~/cypress/e2e/po/pages/explorer/workloads-pods.po'; -import WorkloadListPagePo from '~/cypress/e2e/po/pages/explorer/workloads.po'; -import HomePagePo from '~/cypress/e2e/po/pages/home.po'; -import PagePo from '~/cypress/e2e/po/pages/page.po'; +import ClusterManagerListPagePo from '@/cypress/e2e/po/pages/cluster-manager/cluster-manager-list.po'; +import { WorkloadsPodsListPagePo } from '@/cypress/e2e/po/pages/explorer/workloads-pods.po'; +import WorkloadListPagePo from '@/cypress/e2e/po/pages/explorer/workloads.po'; +import HomePagePo from '@/cypress/e2e/po/pages/home.po'; +import PagePo from '@/cypress/e2e/po/pages/page.po'; -describe('Not found page display', () => { +describe('Not found page display', { tags: ['@adminUser', '@standardUser'] }, () => { beforeEach(() => { cy.login(); }); - it('Will show a 404 if we do not have a valid Product id on the route path', { tags: ['@adminUser', '@standardUser'] }, () => { + it('Will show a 404 if we do not have a valid Product id on the route path', () => { const notFound = new NotFoundPagePo('/c/_/bogus-product-id'); notFound.goTo(); @@ -20,7 +20,7 @@ describe('Not found page display', () => { notFound.errorMessage().contains('Product bogus-product-id not found'); }); - it('Will show a 404 if we do not have a valid Resource type on the route path', { tags: ['@adminUser', '@standardUser'] }, () => { + it('Will show a 404 if we do not have a valid Resource type on the route path', () => { const notFound = new NotFoundPagePo('/c/_/manager/bogus-resource-type'); notFound.goTo(); @@ -30,7 +30,7 @@ describe('Not found page display', () => { notFound.errorMessage().contains('Resource type bogus-resource-type not found'); }); - it('Will show a 404 if we do not have a valid Resource id on the route path', { tags: ['@adminUser', '@standardUser'] }, () => { + it('Will show a 404 if we do not have a valid Resource id on the route path', () => { const notFound = new NotFoundPagePo('/c/_/manager/provisioning.cattle.io.cluster/fleet-default/bogus-resource-id'); notFound.goTo(); @@ -40,7 +40,7 @@ describe('Not found page display', () => { notFound.errorMessage().contains('Resource provisioning.cattle.io.cluster with id fleet-default/bogus-resource-id not found, unable to display resource details'); }); - it('Will show a 404 if we do not have a valid product + resource + resource id', { tags: ['@adminUser', '@standardUser'] }, () => { + it('Will show a 404 if we do not have a valid product + resource + resource id', () => { const notFound = new NotFoundPagePo('/c/_/bogus-product-id/bogus-resource/bogus-resource-id'); notFound.goTo(); @@ -50,7 +50,7 @@ describe('Not found page display', () => { notFound.errorMessage().contains('Product bogus-product-id not found'); }); - it('Will not show a 404 if we have a valid product + resource', { tags: ['@adminUser', '@standardUser'] }, () => { + it('Will not show a 404 if we have a valid product + resource', () => { const clusterManager = new NotFoundPagePo('/c/_/manager/provisioning.cattle.io.cluster'); clusterManager.goTo(); @@ -58,7 +58,7 @@ describe('Not found page display', () => { clusterManager.errorTitle().should('not.exist'); }); - it('Will not show a 404 for a valid type from the Norman API', { tags: ['@adminUser', '@standardUser'] }, () => { + it('Will not show a 404 for a valid type from the Norman API', () => { const cloudCredCreatePage = new NotFoundPagePo('/c/_/manager/cloudCredential/create'); cloudCredCreatePage.goTo(); @@ -66,7 +66,7 @@ describe('Not found page display', () => { cloudCredCreatePage.errorTitle().should('not.exist'); }); - it('Will not show a 404 for a valid type that does not have a real schema', { tags: '@adminUser' }, () => { + it('Will not show a 404 for a valid type that does not have a real schema', () => { const workloadPage = new NotFoundPagePo('/c/local/explorer/workload'); workloadPage.goTo(); @@ -74,7 +74,7 @@ describe('Not found page display', () => { workloadPage.errorTitle().should('not.exist'); }); - it('Will not show a 404 if we have a valid product + resource and we nav to page', { tags: '@adminUser' }, () => { + it('Will not show a 404 if we have a valid product + resource and we nav to page', () => { const page = new PagePo(''); const homePage = new HomePagePo(); const notFoundPage = new NotFoundPagePo(''); diff --git a/cypress/e2e/tests/pages/cluster-manager.spec.ts b/cypress/e2e/tests/pages/cluster-manager.spec.ts index 947f701e792..8ed908754f6 100644 --- a/cypress/e2e/tests/pages/cluster-manager.spec.ts +++ b/cypress/e2e/tests/pages/cluster-manager.spec.ts @@ -120,7 +120,7 @@ describe('Cluster Manager', { tags: '@adminUser' }, () => { clusterList.sortableTable().rowElementWithName(rke2CustomName).should('exist', { timeout: 15000 }); clusterList.list().actionMenu(rke2CustomName).getMenuItem('Delete').click(); - clusterList.sortableTable().rowNames().then((rows: any) => { + clusterList.sortableTable().rowNames('.cluster-link').then((rows: any) => { const promptRemove = new PromptRemove(); promptRemove.confirm(rke2CustomName); @@ -128,7 +128,7 @@ describe('Cluster Manager', { tags: '@adminUser' }, () => { clusterList.waitForPage(); clusterList.sortableTable().checkRowCount(false, rows.length - 1); - clusterList.sortableTable().rowNames().should('not.contain', rke2CustomName); + clusterList.sortableTable().rowNames('.cluster-link').should('not.contain', rke2CustomName); }); }); }); @@ -257,7 +257,7 @@ describe('Cluster Manager', { tags: '@adminUser' }, () => { clusterList.sortableTable().bulkActionDropDownOpen(); clusterList.sortableTable().bulkActionDropDownButton('Delete').click(); - clusterList.sortableTable().rowNames().then((rows: any) => { + clusterList.sortableTable().rowNames('.cluster-link').then((rows: any) => { const promptRemove = new PromptRemove(); promptRemove.confirm(importGenericName); @@ -265,7 +265,7 @@ describe('Cluster Manager', { tags: '@adminUser' }, () => { clusterList.waitForPage(); clusterList.sortableTable().checkRowCount(false, rows.length - 1); - clusterList.sortableTable().rowNames().should('not.contain', importGenericName); + clusterList.sortableTable().rowNames('.cluster-link').should('not.contain', importGenericName); }); }); }); diff --git a/cypress/e2e/tests/pages/explorer/api/api-services.spec.ts b/cypress/e2e/tests/pages/explorer/api/api-services.spec.ts index 0be039e1006..77337191d42 100644 --- a/cypress/e2e/tests/pages/explorer/api/api-services.spec.ts +++ b/cypress/e2e/tests/pages/explorer/api/api-services.spec.ts @@ -14,7 +14,7 @@ describe('Cluster Explorer', { tags: ['@adminUser'] }, () => { apiServicesPage.waitForRequests(); }); - it('Should be able to use shift+j to select corre', () => { + it('Should be able to use shift+j to select rows and the count of selected is correct', () => { apiServicesPage.title().should('contain', 'APIServices'); const sortableTable = apiServicesPage.sortableTable(); diff --git a/cypress/e2e/tests/pages/extensions.spec.ts b/cypress/e2e/tests/pages/extensions.spec.ts index e0bf70fa608..c1d07045c29 100644 --- a/cypress/e2e/tests/pages/extensions.spec.ts +++ b/cypress/e2e/tests/pages/extensions.spec.ts @@ -1,15 +1,19 @@ -import ExtensionsPo from '@/cypress/e2e/po/pages/extensions.po'; +import ExtensionsPagePo from '@/cypress/e2e/po/pages/extensions.po'; import ReposListPagePo from '@/cypress/e2e/po/pages/repositories.po'; +import PromptRemove from '~/cypress/e2e/po/prompts/promptRemove.po'; const EXTENSION_NAME = 'clock'; -const PARTNERS_REPO_URL = 'https://github.com/rancher/partner-extensions'; +const UI_PLUGINS_PARTNERS_REPO_URL = 'https://github.com/rancher/partner-extensions'; +const UI_PLUGINS_PARTNERS_REPO_NAME = 'partner-extensions'; describe('Extensions page', { tags: '@adminUser' }, () => { before(() => { cy.login(); - ExtensionsPo.goTo(); - const extensionsPo = new ExtensionsPo(); + const extensionsPo = new ExtensionsPagePo(); + + extensionsPo.goTo(); + extensionsPo.waitForPage(); // install extensions operator if it's not installed extensionsPo.installExtensionsOperatorIfNeeded(); @@ -20,11 +24,12 @@ describe('Extensions page', { tags: '@adminUser' }, () => { beforeEach(() => { cy.login(); - ExtensionsPo.goTo(); }); it('using "Add Rancher Repositories" should add a new repository (Partners repo)', () => { - const extensionsPo = new ExtensionsPo(); + const extensionsPo = new ExtensionsPagePo(); + + extensionsPo.goTo(); // go to "add rancher repositories" extensionsPo.extensionMenuToggle(); @@ -39,11 +44,13 @@ describe('Extensions page', { tags: '@adminUser' }, () => { appRepoList.goTo(); appRepoList.waitForPage(); - appRepoList.sortableTable().rowElementWithName(PARTNERS_REPO_URL).should('exist'); + appRepoList.sortableTable().rowElementWithName(UI_PLUGINS_PARTNERS_REPO_URL).should('exist'); }); it('Should disable and enable extension support', () => { - const extensionsPo = new ExtensionsPo(); + const extensionsPo = new ExtensionsPagePo(); + + extensionsPo.goTo(); // open menu and click on disable extension support extensionsPo.extensionMenuToggle(); @@ -60,7 +67,8 @@ describe('Extensions page', { tags: '@adminUser' }, () => { // wait for operation to finish and refresh... extensionsPo.extensionTabs.checkVisible(); - ExtensionsPo.goTo(); + extensionsPo.goTo(); + extensionsPo.waitForPage(); // let's make sure all went good extensionsPo.extensionTabAvailableClick(); @@ -68,24 +76,66 @@ describe('Extensions page', { tags: '@adminUser' }, () => { }); it('New repos banner should only appear once (after dismiss should NOT appear again)', () => { - const extensionsPo = new ExtensionsPo(); + cy.getRancherResource('v3', 'setting', 'display-add-extension-repos-banner', null).then((resp: Cypress.Response) => { + const notFound = resp.status === 404; + const requiredValue = resp.body?.value === 'true'; + + if (notFound || requiredValue) { + cy.log('Good test state', '/v3/setting/display-add-extension-repos-banner', resp.status, JSON.stringify(resp?.body || {})); + } else { + cy.log('Bad test state', '/v3/setting/display-add-extension-repos-banner', resp.status, JSON.stringify(resp?.body || {})); + + return cy.setRancherResource('v3', 'setting', 'display-add-extension-repos-banner', { + ...resp.body, + value: 'true' + }); + } + }); + + const appRepoList = new ReposListPagePo('local', 'apps'); + + // Ensure that the banner should be shown (by confirming that a required repo isn't there) + appRepoList.goTo(); + appRepoList.waitForPage(); + appRepoList.sortableTable().noRowsShouldNotExist(); + appRepoList.sortableTable().rowNames().then((names) => { + if (names.includes(UI_PLUGINS_PARTNERS_REPO_NAME)) { + appRepoList.list().actionMenu(UI_PLUGINS_PARTNERS_REPO_NAME).getMenuItem('Delete').click(); + const promptRemove = new PromptRemove(); + + return promptRemove.remove(); + } + }); - extensionsPo.self().find('[data-testid="extensions-new-repos-banner-action-btn"]').click(); - extensionsPo.self().find('[data-testid="extensions-new-repos-banner"]').should('not.exist'); + // Now go to extensions (by nav, not page load....) + appRepoList.navToMenuEntry('Extensions'); + + const extensionsPo = new ExtensionsPagePo(); + + extensionsPo.waitForPage(); + extensionsPo.loading().should('not.exist'); + + extensionsPo.repoBanner().checkVisible(); + extensionsPo.repoBanner().self().find('[data-testid="extensions-new-repos-banner-action-btn"]').click(); + extensionsPo.repoBanner().checkNotExists(); // let's refresh the page to make sure it doesn't appear again... - ExtensionsPo.goTo(); - extensionsPo.title().should('contain', 'Extensions'); - extensionsPo.self().find('[data-testid="extensions-new-repos-banner"]').should('not.exist'); + extensionsPo.goTo(); + extensionsPo.waitForPage(); + extensionsPo.waitForTitle(); + extensionsPo.loading().should('not.exist'); + extensionsPo.repoBanner().checkNotExists(); }); it('Should toggle the extension details', () => { - const extensionsPo = new ExtensionsPo(); + const extensionsPo = new ExtensionsPagePo(); + + extensionsPo.goTo(); extensionsPo.extensionTabAvailableClick(); // we should be on the extensions page - extensionsPo.title().should('contain', 'Extensions'); + extensionsPo.waitForTitle(); // show extension details extensionsPo.extensionCardClick(EXTENSION_NAME); @@ -108,7 +158,9 @@ describe('Extensions page', { tags: '@adminUser' }, () => { }); it('Should install an extension', () => { - const extensionsPo = new ExtensionsPo(); + const extensionsPo = new ExtensionsPagePo(); + + extensionsPo.goTo(); extensionsPo.extensionTabAvailableClick(); @@ -132,7 +184,9 @@ describe('Extensions page', { tags: '@adminUser' }, () => { }); it('Should update an extension version', () => { - const extensionsPo = new ExtensionsPo(); + const extensionsPo = new ExtensionsPagePo(); + + extensionsPo.goTo(); extensionsPo.extensionTabInstalledClick(); @@ -151,7 +205,9 @@ describe('Extensions page', { tags: '@adminUser' }, () => { }); it('Should rollback an extension version', () => { - const extensionsPo = new ExtensionsPo(); + const extensionsPo = new ExtensionsPagePo(); + + extensionsPo.goTo(); extensionsPo.extensionTabInstalledClick(); @@ -170,7 +226,9 @@ describe('Extensions page', { tags: '@adminUser' }, () => { }); it('Should uninstall an extension', () => { - const extensionsPo = new ExtensionsPo(); + const extensionsPo = new ExtensionsPagePo(); + + extensionsPo.goTo(); extensionsPo.extensionTabInstalledClick(); diff --git a/cypress/e2e/tests/pages/extensions/kubewarden.spec.ts b/cypress/e2e/tests/pages/extensions/kubewarden.spec.ts index e285f9fec5c..134b6c87371 100644 --- a/cypress/e2e/tests/pages/extensions/kubewarden.spec.ts +++ b/cypress/e2e/tests/pages/extensions/kubewarden.spec.ts @@ -1,4 +1,4 @@ -import ExtensionsPo from '@/cypress/e2e/po/pages/extensions.po'; +import ExtensionsPagePo from '@/cypress/e2e/po/pages/extensions.po'; import { ChartsPage } from '@/cypress/e2e/po/pages/charts.po'; import ReposListPagePo from '@/cypress/e2e/po/pages/repositories.po'; import ProductNavPo from '@/cypress/e2e/po/side-bars/product-side-nav.po'; @@ -10,8 +10,8 @@ describe('Kubewarden Extension', { tags: '@adminUser' }, () => { before(() => { cy.login(); - ExtensionsPo.goTo(); - const extensionsPo = new ExtensionsPo(); + ExtensionsPagePo.goTo(); + const extensionsPo = new ExtensionsPagePo(); const kubewardenPo = new KubewardenExtensionPo(); // install extensions operator if it's not installed @@ -21,11 +21,11 @@ describe('Kubewarden Extension', { tags: '@adminUser' }, () => { beforeEach(() => { cy.login(); - ExtensionsPo.goTo(); + ExtensionsPagePo.goTo(); }); it('Should install Kubewarden extension', () => { - const extensionsPo = new ExtensionsPo(); + const extensionsPo = new ExtensionsPagePo(); extensionsPo.extensionTabAvailableClick(); @@ -83,7 +83,7 @@ describe('Kubewarden Extension', { tags: '@adminUser' }, () => { }); it('Should uninstall Kubewarden', () => { - const extensionsPo = new ExtensionsPo(); + const extensionsPo = new ExtensionsPagePo(); extensionsPo.extensionTabInstalledClick(); diff --git a/cypress/e2e/tests/pages/home.spec.ts b/cypress/e2e/tests/pages/home.spec.ts index 388b61842f0..4b2954a973c 100644 --- a/cypress/e2e/tests/pages/home.spec.ts +++ b/cypress/e2e/tests/pages/home.spec.ts @@ -16,7 +16,7 @@ describe('Home Page', () => { it('Can navigate to What\'s new page', { tags: ['@adminUser', '@standardUser'] }, () => { /** - * Verify changlog banner is hidden after clicking link + * Verify changelog banner is hidden after clicking link * Verify release notes link is valid github page */ const text: string[] = []; @@ -128,7 +128,7 @@ describe('Home Page', () => { const clusterManagerPage = new ClusterManagerListPagePo('_'); const genericCreateClusterPage = new ClusterManagerImportGenericPagePo('_'); - homePage.manangeButton().click(); + homePage.manageButton().click(); clusterManagerPage.waitForPage(); HomePagePo.goToAndWaitForGet(); diff --git a/cypress/e2e/tests/pages/login.spec.ts b/cypress/e2e/tests/pages/login.spec.ts index 98213cb913c..efcabef6178 100644 --- a/cypress/e2e/tests/pages/login.spec.ts +++ b/cypress/e2e/tests/pages/login.spec.ts @@ -1,5 +1,7 @@ import { LoginPagePo } from '@/cypress/e2e/po/pages/login-page.po'; +const successStatusCode = 200; + describe('Local authentication', { tags: ['@adminUser', '@standardUser'] }, () => { it('Log in with valid credentials', () => { LoginPagePo.goTo(); @@ -8,7 +10,11 @@ describe('Local authentication', { tags: ['@adminUser', '@standardUser'] }, () = cy.login(Cypress.env('username'), Cypress.env('password'), false); cy.wait('@loginReq').then((login) => { - expect(login.response?.statusCode).to.equal(200); + if (login.response?.statusCode !== successStatusCode) { + cy.log('Login incorrectly failed', login.response?.statusCode, login.response?.statusMessage, JSON.stringify(login.response?.body || {})); + } + expect(login.response?.statusCode).to.equal(successStatusCode); + cy.url().should('not.equal', `${ Cypress.config().baseUrl }/auth/login`); }); }); @@ -21,7 +27,11 @@ describe('Local authentication', { tags: ['@adminUser', '@standardUser'] }, () = cy.login(Cypress.env('username'), `${ Cypress.env('password') }abc`, false); cy.wait('@loginReq').then((login) => { - expect(login.response?.statusCode).to.not.equal(200); + if (login.response?.statusCode === successStatusCode) { + cy.log('Login incorrectly succeeded', login.response?.statusCode, login.response?.statusMessage, JSON.stringify(login.response?.body || {})); + } + expect(login.response?.statusCode).to.not.equal(successStatusCode); + // URL is partial as it may change based on the authentication configuration present cy.url().should('include', `${ Cypress.config().baseUrl }/auth/login`); }); diff --git a/cypress/e2e/tests/setup/rancher-setup.spec.ts b/cypress/e2e/tests/setup/rancher-setup.spec.ts index 32efa8415e7..1b85bb1cf4d 100644 --- a/cypress/e2e/tests/setup/rancher-setup.spec.ts +++ b/cypress/e2e/tests/setup/rancher-setup.spec.ts @@ -50,6 +50,14 @@ describe('Rancher setup', { tags: '@adminUser' }, () => { cy.login(); // Note: the username argument here should match the TEST_USERNAME env var used when running non-admin tests - cy.createUser('standard_user', 'user'); + cy.createUser({ + username: 'standard_user', + globalRole: { role: 'user' }, + projectRole: { + clusterId: 'local', + projectName: 'Default', + role: 'project-member', + } + }); }); }); diff --git a/cypress/globals.d.ts b/cypress/globals.d.ts index 870b185fc71..9cdd8c2d069 100644 --- a/cypress/globals.d.ts +++ b/cypress/globals.d.ts @@ -1,7 +1,24 @@ import { Verbs } from '@shell/types/api'; +import { UserPreferences } from '@shell/types/userPreferences'; type Matcher = '$' | '^' | '~' | '*' | ''; +export type CreateUserParams = { + username: string, + globalRole?: { + role: string, + }, + clusterRole?: { + clusterId: string, + role: string, + }, + projectRole?: { + clusterId: string, + projectName: string, + role: string, + } +} + // eslint-disable-next-line no-unused-vars declare namespace Cypress { interface Chainable { @@ -9,9 +26,16 @@ declare namespace Cypress { state(state: any): any; login(username?: string, password?: string, cacheSession?: boolean): Chainable; - byLabel(label: string,): Chainable; - createUser(username: string, role?: string): Chainable; + byLabel(label: string): Chainable; + + createUser(params: CreateUserParams): Chainable; setGlobalRoleBinding(userId: string, role: string): Chainable; + setClusterRoleBinding(clusterId: string, userPrincipalId: string, role: string): Chainable; + setProjectRoleBinding(clusterId: string, userPrincipalId: string, projectName: string, role: string): Chainable; + getProjectByName(clusterId: string, projectName: string): Chainable; + + getRancherResource(prefix: 'v3' | 'v1', resourceType: string, resourceId: string, expectedStatusCode: string): Chainable; + setRancherResource(prefix: 'v3' | 'v1', resourceType: string, resourceId: string, body: string): Chainable; /** * Wrapper for cy.get() to simply define the data-testid value that allows you to pass a matcher to find the element. diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts deleted file mode 100644 index c15c7d00392..00000000000 --- a/cypress/support/commands.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { LoginPagePo } from '@/cypress/e2e/po/pages/login-page.po'; -import { Matcher } from '@/cypress/support/types'; - -let token: any; - -/** - * Login local authentication, including first login and bootstrap if not cached - */ -Cypress.Commands.add('login', ( - username = Cypress.env('username'), - password = Cypress.env('password'), - cacheSession = true, -) => { - const login = () => { - cy.intercept('POST', '/v3-public/localProviders/local*').as('loginReq'); - - LoginPagePo.goTo(); // Needs to happen before the page element is created/located - const loginPage = new LoginPagePo(); - - loginPage - .checkIsCurrentPage(); - - loginPage.switchToLocal(); - - loginPage.canSubmit() - .should('eq', true); - - loginPage.username() - .set(username); - - loginPage.password() - .set(password); - - loginPage.canSubmit() - .should('eq', true); - loginPage.submit(); - - cy.wait('@loginReq').its('request.body') - .should( - 'deep.equal', - { - username, - password, - description: 'UI session', - responseType: 'cookie' - } - ); - }; - - if (cacheSession) { - (cy as any).session([username, password], login); - cy.getCookie('CSRF').then((c) => { - token = c; - }); - } else { - login(); - } -}); - -/** - * Create user via api request - */ -Cypress.Commands.add('createUser', (username, role?) => { - return cy.request({ - method: 'POST', - url: `${ Cypress.env('api') }/v3/users`, - failOnStatusCode: false, - headers: { - 'x-api-csrf': token.value, - Accept: 'application/json' - }, - body: { - type: 'user', - enabled: true, - mustChangePassword: false, - username, - password: Cypress.env('password') - } - }).then((resp) => { - if (resp.status === 422 && resp.body.message === 'Username is already in use.') { - cy.log('User already exists. Skipping user creation'); - } else { - expect(resp.status).to.eq(201); - - if (role) { - cy.setGlobalRoleBinding(resp.body.id, role); - } - } - }); -}); - -/** - * Set global role binding for user via api request - */ -Cypress.Commands.add('setGlobalRoleBinding', (userId, role) => { - return cy.request({ - method: 'POST', - url: `${ Cypress.env('api') }/v3/globalrolebindings`, - headers: { - 'x-api-csrf': token.value, - Accept: 'application/json' - }, - body: { - type: 'globalRoleBinding', - globalRoleId: role, - userId - } - }).then((resp) => { - expect(resp.status).to.eq(201); - }); -}); - -/** - * Get input field for given label - */ -Cypress.Commands.add('byLabel', (label) => { - return cy.get('.labeled-input').contains(label).siblings('input'); -}); - -/** - * Wrap the cy.find() command to simplify the selector declaration of the data-testid - */ -Cypress.Commands.add('findId', (id: string, matcher?: Matcher = '') => { - return cy.find(`[data-testid${ matcher }="${ id }"]`); -}); - -/** - * Wrap the cy.get() command to simplify the selector declaration of the data-testid - */ -Cypress.Commands.add('getId', (id: string, matcher?: Matcher = '') => { - return cy.get(`[data-testid${ matcher }="${ id }"]`); -}); - -/** - * Override user preferences to default values, allowing to pass custom preferences for a deterministic scenario - */ -// eslint-disable-next-line no-undef -Cypress.Commands.add('userPreferences', (preferences: Partial = {}) => { - return cy.intercept('/v1/userpreferences', (req) => { - req.reply({ - statusCode: 201, - body: { - data: [{ - type: 'userpreference', - data: { - 'after-login-route': '\"home\"', - cluster: 'local', - 'group-by': 'none', - 'home-page-cards': '', - 'last-namespace': 'default', - 'last-visited': '', - 'ns-by-cluster': '', - provisioner: '', - 'read-whatsnew': '', - 'seen-whatsnew': '2.x.x', - theme: '', - ...preferences, - }, - }] - }, - }); - }); -}); - -Cypress.Commands.add('requestBase64Image', (url: string) => { - return cy.request({ - url, - method: 'GET', - encoding: 'binary', - headers: { Accept: 'image/png; charset=UTF-8' }, - }) - .its('body') - .then((favicon) => { - const blob = Cypress.Blob.binaryStringToBlob(favicon); - - return Cypress.Blob.blobToBase64String(blob); - }); -}); - -Cypress.Commands.add('keyboardControls', (triggerKeys: any = {}, count = 1) => { - for (let i = 0; i < count; i++) { - cy.get('body').trigger('keydown', triggerKeys); - } -}); - -/** - * Intercept all requests and return - * @param {array} intercepts - Array of intercepts to return - * return {array} - Array of intercepted request strings - * return {string} - Intercepted request string - */ -Cypress.Commands.add('interceptAllRequests', (method = '/GET/POST/PUT/PATCH/', urls = ['/v1/*']) => { - const interceptedUrls: string[] = urls.map((cUrl, i) => { - cy.intercept(method, cUrl).as(`interceptAllRequests${ i }`); - - return `@interceptAllRequests${ i }`; - }); - - return cy.wrap(interceptedUrls); -}); - -Cypress.Commands.add('iFrame', () => { - return cy - .get('[data-testid="ember-iframe"]', { log: false }) - .its('0.contentDocument.body', { log: false }) - .should('not.be.empty') - .then((body) => cy.wrap(body)); -}); diff --git a/cypress/support/commands/commands.ts b/cypress/support/commands/commands.ts new file mode 100644 index 00000000000..23884eae0dd --- /dev/null +++ b/cypress/support/commands/commands.ts @@ -0,0 +1,52 @@ +import { Matcher } from '@/cypress/support/types'; + +/** + * Get input field for given label + */ +Cypress.Commands.add('byLabel', (label) => { + return cy.get('.labeled-input').contains(label).siblings('input'); +}); + +/** + * Wrap the cy.find() command to simplify the selector declaration of the data-testid + */ +Cypress.Commands.add('findId', (id: string, matcher?: Matcher = '') => { + return cy.find(`[data-testid${ matcher }="${ id }"]`); +}); + +/** + * Wrap the cy.get() command to simplify the selector declaration of the data-testid + */ +Cypress.Commands.add('getId', (id: string, matcher?: Matcher = '') => { + return cy.get(`[data-testid${ matcher }="${ id }"]`); +}); + +Cypress.Commands.add('keyboardControls', (triggerKeys: any = {}, count = 1) => { + for (let i = 0; i < count; i++) { + cy.get('body').trigger('keydown', triggerKeys); + } +}); + +/** + * Intercept all requests and return + * @param {array} intercepts - Array of intercepts to return + * return {array} - Array of intercepted request strings + * return {string} - Intercepted request string + */ +Cypress.Commands.add('interceptAllRequests', (method = '/GET/POST/PUT/PATCH/', urls = ['/v1/*']) => { + const interceptedUrls: string[] = urls.map((cUrl, i) => { + cy.intercept(method, cUrl).as(`interceptAllRequests${ i }`); + + return `@interceptAllRequests${ i }`; + }); + + return cy.wrap(interceptedUrls); +}); + +Cypress.Commands.add('iFrame', () => { + return cy + .get('[data-testid="ember-iframe"]', { log: false }) + .its('0.contentDocument.body', { log: false }) + .should('not.be.empty') + .then((body) => cy.wrap(body)); +}); diff --git a/cypress/support/commands/rancher-api-commands.ts b/cypress/support/commands/rancher-api-commands.ts new file mode 100644 index 00000000000..65e788459bd --- /dev/null +++ b/cypress/support/commands/rancher-api-commands.ts @@ -0,0 +1,292 @@ +import { LoginPagePo } from '@/cypress/e2e/po/pages/login-page.po'; +import { CreateUserParams } from '@/cypress/globals'; + +// This file contains commands which makes API requests to the rancher API. +// It includes the `login` command to store the `token` to use + +let token: any; + +/** + * Login local authentication, including first login and bootstrap if not cached + */ +Cypress.Commands.add('login', ( + username = Cypress.env('username'), + password = Cypress.env('password'), + cacheSession = true, +) => { + const login = () => { + cy.intercept('POST', '/v3-public/localProviders/local*').as('loginReq'); + + LoginPagePo.goTo(); // Needs to happen before the page element is created/located + const loginPage = new LoginPagePo(); + + loginPage + .checkIsCurrentPage(); + + loginPage.switchToLocal(); + + loginPage.canSubmit() + .should('eq', true); + + loginPage.username() + .set(username); + + loginPage.password() + .set(password); + + loginPage.canSubmit() + .should('eq', true); + loginPage.submit(); + + cy.wait('@loginReq').its('request.body') + .should( + 'deep.equal', + { + username, + password, + description: 'UI session', + responseType: 'cookie' + } + ); + }; + + if (cacheSession) { + (cy as any).session([username, password], login); + cy.getCookie('CSRF').then((c) => { + token = c; + }); + } else { + login(); + } +}); + +/** + * Create user via api request + */ +Cypress.Commands.add('createUser', (params: CreateUserParams) => { + const { + username, globalRole, clusterRole, projectRole + } = params; + + return cy.request({ + method: 'POST', + url: `${ Cypress.env('api') }/v3/users`, + failOnStatusCode: false, + headers: { + 'x-api-csrf': token.value, + Accept: 'application/json' + }, + body: { + type: 'user', + enabled: true, + mustChangePassword: false, + username, + password: Cypress.env('password') + } + }) + .then((resp) => { + if (resp.status === 422 && resp.body.message === 'Username is already in use.') { + cy.log('User already exists. Skipping user creation'); + + return ''; + } else { + expect(resp.status).to.eq(201); + + const userPrincipalId = resp.body.principalIds[0]; + + if (globalRole) { + return cy.setGlobalRoleBinding(resp.body.id, globalRole.role) + .then(() => { + if (clusterRole) { + const { clusterId, role } = clusterRole; + + return cy.setClusterRoleBinding(clusterId, userPrincipalId, role); + } + }) + .then(() => { + if (projectRole) { + const { clusterId, projectName, role } = projectRole; + + return cy.setProjectRoleBinding(clusterId, userPrincipalId, projectName, role); + } + }); + } + } + }); +}); + +/** + * Set global role binding for user via api request + */ +Cypress.Commands.add('setGlobalRoleBinding', (userId, role) => { + return cy.request({ + method: 'POST', + url: `${ Cypress.env('api') }/v3/globalrolebindings`, + headers: { + 'x-api-csrf': token.value, + Accept: 'application/json' + }, + body: { + type: 'globalRoleBinding', + globalRoleId: role, + userId + } + }) + .then((resp) => { + expect(resp.status).to.eq(201); + }); +}); + +/** + * Set cluster role binding for user via api request + * + */ +Cypress.Commands.add('setClusterRoleBinding', (clusterId, userPrincipalId, role) => { + return cy.request({ + method: 'POST', + url: `${ Cypress.env('api') }/v3/clusterroletemplatebindings`, + headers: { + 'x-api-csrf': token.value, + Accept: 'application/json' + }, + body: { + type: 'clusterRoleTemplateBinding', + clusterId, + roleTemplateId: role, + userPrincipalId + } + }) + .then((resp) => { + expect(resp.status).to.eq(201); + }); +}); + +/** + * Set project role binding for user via api request + * + */ +Cypress.Commands.add('setProjectRoleBinding', (clusterId, userPrincipalId, projectName, role) => { + return cy.getProjectByName(clusterId, projectName) + .then((project: any) => cy.request({ + method: 'POST', + url: `${ Cypress.env('api') }/v3/projectroletemplatebindings`, + headers: { + 'x-api-csrf': token.value, + Accept: 'application/json' + }, + body: { + type: 'projectroletemplatebinding', + roleTemplateId: role, + userPrincipalId, + projectId: project.id + } + })) + .then((resp) => { + expect(resp.status).to.eq(201); + }); +}); + +/** + * Get the project with the given name + */ +Cypress.Commands.add('getProjectByName', (clusterId, projectName) => { + return cy.request({ + method: 'GET', + url: `${ Cypress.env('api') }/v3/projects?name=${ projectName }&clusterId=${ clusterId }`, + headers: { + 'x-api-csrf': token.value, + Accept: 'application/json' + }, + }) + .then((resp) => { + expect(resp.status).to.eq(200); + expect(resp.body?.data?.length).to.eq(1); + + return resp.body.data[0]; + }); +}); + +/** + * Override user preferences to default values, allowing to pass custom preferences for a deterministic scenario + */ +// eslint-disable-next-line no-undef +Cypress.Commands.add('userPreferences', (preferences: Partial = {}) => { + return cy.intercept('/v1/userpreferences', (req) => { + req.reply({ + statusCode: 201, + body: { + data: [{ + type: 'userpreference', + data: { + 'after-login-route': '\"home\"', + cluster: 'local', + 'group-by': 'none', + 'home-page-cards': '', + 'last-namespace': 'default', + 'last-visited': '', + 'ns-by-cluster': '', + provisioner: '', + 'read-whatsnew': '', + 'seen-whatsnew': '2.x.x', + theme: '', + ...preferences, + }, + }] + }, + }); + }); +}); + +Cypress.Commands.add('requestBase64Image', (url: string) => { + return cy.request({ + url, + method: 'GET', + encoding: 'binary', + headers: { Accept: 'image/png; charset=UTF-8' }, + }) + .its('body') + .then((favicon) => { + const blob = Cypress.Blob.binaryStringToBlob(favicon); + + return Cypress.Blob.blobToBase64String(blob); + }); +}); + +/** + * Get a v3 / v1 resource + */ +Cypress.Commands.add('getRancherResource', (prefix, resourceType, resourceId, expectedStatusCode = 200) => { + return cy.request({ + method: 'GET', + url: `${ Cypress.env('api') }/${ prefix }/${ resourceType }/${ resourceId }`, + headers: { + 'x-api-csrf': token.value, + Accept: 'application/json' + }, + }) + .then((resp) => { + if (expectedStatusCode) { + expect(resp.status).to.eq(expectedStatusCode); + } + + return resp; + }); +}); + +/** + * set a v3 / v1 resource + */ +Cypress.Commands.add('setRancherResource', (prefix, resourceType, resourceId, body) => { + return cy.request({ + method: 'PUT', + url: `${ Cypress.env('api') }/${ prefix }/${ resourceType }/${ resourceId }`, + headers: { + 'x-api-csrf': token.value, + Accept: 'application/json' + }, + body + }) + .then((resp) => { + expect(resp.status).to.eq(200); + }); +}); diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index bef88307252..71a94e659f4 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -1,5 +1,6 @@ import '@cypress/code-coverage/support'; -import './commands'; +import './commands/commands'; +import './commands/rancher-api-commands.ts'; import registerCypressGrep from '@cypress/grep/src/support'; registerCypressGrep(); diff --git a/shell/pages/c/_cluster/explorer/index.vue b/shell/pages/c/_cluster/explorer/index.vue index 50783f18391..34f5bceb2b5 100644 --- a/shell/pages/c/_cluster/explorer/index.vue +++ b/shell/pages/c/_cluster/explorer/index.vue @@ -103,7 +103,7 @@ export default { `Determine etcd metrics` ); - if (this.currentCluster.isLocal) { + if (this.currentCluster.isLocal && this.$store.getters['management/schemaFor'](MANAGEMENT.NODE)) { this.$store.dispatch('management/findAll', { type: MANAGEMENT.NODE }); } } diff --git a/shell/types/userPreferences.d.ts b/shell/types/userPreferences.d.ts index a48590268e9..a3c9ee9220a 100644 --- a/shell/types/userPreferences.d.ts +++ b/shell/types/userPreferences.d.ts @@ -1,5 +1,5 @@ // eslint-disable-next-line no-unused-vars -interface UserPreferences { +export interface UserPreferences { 'after-login-route': string, cluster: string, 'group-by': string,