diff --git a/console/src/app/components/features/features.component.html b/console/src/app/components/features/features.component.html index 30d8f629af..9ea09f172b 100644 --- a/console/src/app/components/features/features.component.html +++ b/console/src/app/components/features/features.component.html @@ -13,7 +13,7 @@

{{ 'DESCRIPTIONS.SETTINGS.FEATURES.DESCRIPTION' | translate }}

- @@ -24,9 +24,9 @@ *ngFor="let key of FEATURE_KEYS" [toggleStateKey]="key" [toggleState]="toggleStates[key]" - (toggleChange)="saveFeatures(key, $event)" + (toggleChange)="saveFeature(key, $event)" > - + diff --git a/console/src/app/components/features/features.component.ts b/console/src/app/components/features/features.component.ts index 5384bda7e5..b6624feefa 100644 --- a/console/src/app/components/features/features.component.ts +++ b/console/src/app/components/features/features.component.ts @@ -129,19 +129,18 @@ export class FeaturesComponent { ); } - public async saveFeatures(key: TKey, value: TValue) { - const toggleStates = { ...(await firstValueFrom(this.toggleStates$)), [key]: value }; + public async saveFeature(key: TKey, value: TValue) { + const req: MessageInitShape = {}; - const req = FEATURE_KEYS.reduce>((acc, key) => { - acc[key] = toggleStates[key].enabled; - return acc; - }, {}); - - // to save special flags they have to be handled here - req['loginV2'] = { - required: toggleStates.loginV2.enabled, - baseUri: toggleStates.loginV2.baseUri, - }; + // Set only the specific feature being updated + if (key === 'loginV2') { + req['loginV2'] = { + required: value.enabled, + baseUri: (value as ToggleStates['loginV2']).baseUri, + }; + } else if (FEATURE_KEYS.includes(key)) { + req[key] = value.enabled; + } try { await this.featureService.setInstanceFeatures(req); diff --git a/e2e/cypress/e2e/settings/features.cy.ts b/e2e/cypress/e2e/settings/features.cy.ts new file mode 100644 index 0000000000..fa64fd1dc3 --- /dev/null +++ b/e2e/cypress/e2e/settings/features.cy.ts @@ -0,0 +1,170 @@ +import { apiAuth } from '../../support/api/apiauth'; +import { resetInstanceFeatures } from '../../support/api/features'; +import { login, User } from '../../support/login/users'; + +describe('features settings', () => { + const featuresPath = '/instance?id=features'; + + beforeEach(() => { + cy.context().as('ctx'); + cy.visit(featuresPath); + }); + + describe('UI and Display Tests', () => { + it('should display features page with correct elements', () => { + // Check page title contains relevant text (flexible for translation issues) + cy.get('h2').should('be.visible').and('contain.text', 'Feature'); + cy.get('.events-desc').should('be.visible'); + + // Check info link + cy.get('a[href*="feature-service"]').should('be.visible').and('have.attr', 'target', '_blank'); + + // Check reset button is always present + cy.get('[data-e2e="reset-features-button"]').should('be.visible'); + }); + + it('should display feature toggles', () => { + cy.get('cnsl-card').should('be.visible'); + cy.get('.features').should('be.visible'); + + // Check that feature toggles are present + cy.get('cnsl-feature-toggle').should('have.length.greaterThan', 0); + cy.get('cnsl-login-v2-feature-toggle').should('be.visible'); + }); + + it('should maintain feature states after page reload', () => { + // Wait for features to load + cy.get('cnsl-feature-toggle').should('be.visible'); + + // Get the count of feature toggles before reload + cy.get('cnsl-feature-toggle').then(($toggles) => { + const initialCount = $toggles.length; + + // Reload page + cy.reload(); + + // Wait for features to load again + cy.get('cnsl-feature-toggle').should('be.visible'); + + // Verify that the same number of toggles are still present + cy.get('cnsl-feature-toggle').should('have.length', initialCount); + + // Verify that toggles are still functional + cy.get('cnsl-feature-toggle') + .first() + .within(() => { + cy.get('mat-button-toggle').should('be.visible'); + }); + }); + }); + + it('should handle API errors gracefully', () => { + // Intercept API calls and force them to fail + cy.intercept('POST', '**/features*', { + statusCode: 500, + body: { message: 'Internal server error' }, + }).as('featureError'); + + // Try to toggle a feature + cy.get('cnsl-feature-toggle') + .first() + .within(() => { + cy.get('mat-button-toggle').first().click(); + }); + + // Wait for the error response (optional since it might not always trigger) + cy.get('body').then(() => { + // Just verify that the page is still functional + cy.get('cnsl-feature-toggle').should('be.visible'); + }); + }); + + describe('permissions', () => { + it('should show appropriate elements for admin users', () => { + // Admin should see reset button + cy.get('[data-e2e="reset-features-button"]').should('be.visible'); + + // Admin should see all feature toggles + cy.get('cnsl-feature-toggle').should('have.length.greaterThan', 0); + }); + }); + }); + + describe('Feature Modification Tests', () => { + afterEach(() => { + // Reset features after each feature modification test to ensure clean state + apiAuth().then((api) => { + resetInstanceFeatures(api); + }); + }); + + it('should be able to toggle a feature', () => { + // Wait for features to load + cy.get('cnsl-feature-toggle').should('be.visible'); + + // Get the first feature toggle and check its initial state + cy.get('cnsl-feature-toggle') + .first() + .within(() => { + // Ensure we always trigger a state change by clicking an unchecked button + cy.get('mat-button-toggle').then(($allButtons) => { + const uncheckedButtons = $allButtons.not('.mat-button-toggle-checked'); + + if (uncheckedButtons.length > 0) { + // Click an unchecked button to enable it + const targetButton = uncheckedButtons.first(); + cy.wrap(targetButton).click(); + + // Verify the toggle reflected the new state + cy.wrap(targetButton).should('have.class', 'mat-button-toggle-checked'); + } else { + // All buttons are checked, click the first one to uncheck it + const targetButton = $allButtons.first(); + cy.wrap(targetButton).click(); + + // Verify the toggle reflected the new state (should be unchecked now) + cy.wrap(targetButton).should('not.have.class', 'mat-button-toggle-checked'); + } + }); + }); + // Check for success toast since we made a real change + cy.shouldConfirmSuccess(); + }); + + it('should reset features when reset button is clicked', () => { + // Change a feature first to have something to reset + cy.get('cnsl-feature-toggle') + .first() + .within(() => { + // Check current state and click the opposite to ensure we trigger a change + cy.get('mat-button-toggle').then(($buttons) => { + const checkedButton = $buttons.filter('.mat-button-toggle-checked'); + const uncheckedButtons = $buttons.not('.mat-button-toggle-checked'); + + if (uncheckedButtons.length > 0) { + // Click an unchecked button to enable it + cy.wrap(uncheckedButtons.first()).click(); + // Verify the change was applied + cy.wrap(uncheckedButtons.first()).should('have.class', 'mat-button-toggle-checked'); + } else { + // All buttons are checked, click the first one to uncheck it + cy.wrap(checkedButton.first()).click(); + // Verify the change was applied + cy.wrap(checkedButton.first()).should('not.have.class', 'mat-button-toggle-checked'); + } + }); + }); + // Check for success toast since we made a real change + cy.shouldConfirmSuccess(); + + // Click the reset button + cy.get('[data-e2e="reset-features-button"]').click(); + + // Check for success toast from reset operation + cy.shouldConfirmSuccess(); + + // Verify features are still loaded and functional after UI reset + cy.get('cnsl-feature-toggle').should('be.visible'); + }); + }); +}); diff --git a/e2e/cypress/support/api/apiauth.ts b/e2e/cypress/support/api/apiauth.ts index fd1720e3e1..746a5fc48f 100644 --- a/e2e/cypress/support/api/apiauth.ts +++ b/e2e/cypress/support/api/apiauth.ts @@ -16,6 +16,7 @@ export function apiAuth(): Cypress.Chainable { oauthBaseURL: `${backendUrl}/oauth/v2`, oidcBaseURL: `${backendUrl}/oidc/v1`, samlBaseURL: `${backendUrl}/saml/v2`, + featuresBaseURL: `${backendUrl}/v2/features`, }; }); } diff --git a/e2e/cypress/support/api/features.ts b/e2e/cypress/support/api/features.ts new file mode 100644 index 0000000000..409bee4968 --- /dev/null +++ b/e2e/cypress/support/api/features.ts @@ -0,0 +1,59 @@ +import { API } from './types'; + +export function getInstanceFeatures(api: API) { + return cy.request({ + method: 'GET', + url: `${api.featuresBaseURL}/instance`, + headers: { + authorization: `Bearer ${api.token}`, + }, + body: {}, + }); +} + +export function setInstanceFeature(api: API, feature: string, enabled: boolean, additionalConfig?: Record) { + const body: Record = { + [feature]: enabled, + ...additionalConfig, + }; + + return cy.request({ + method: 'PUT', + url: `${api.featuresBaseURL}/instance`, + headers: { + authorization: `Bearer ${api.token}`, + }, + body, + }); +} + +export function resetInstanceFeatures(api: API) { + return cy.request({ + method: 'DELETE', + url: `${api.featuresBaseURL}/instance`, + headers: { + authorization: `Bearer ${api.token}`, + }, + }); +} + +export function ensureFeatureState(api: API, feature: string, enabled: boolean, additionalConfig?: Record) { + return getInstanceFeatures(api).then((response) => { + const currentState = response.body?.[feature]?.enabled; + + if (currentState !== enabled) { + return setInstanceFeature(api, feature, enabled, additionalConfig); + } + + return cy.wrap(response); + }); +} + +export function ensureLoginV2FeatureState(api: API, required: boolean, baseUri?: string) { + return setInstanceFeature(api, 'loginV2', required, { + loginV2: { + required, + baseUri: baseUri || '', + }, + }); +} diff --git a/e2e/cypress/support/api/types.ts b/e2e/cypress/support/api/types.ts index 39a8094b8a..de380fc489 100644 --- a/e2e/cypress/support/api/types.ts +++ b/e2e/cypress/support/api/types.ts @@ -10,6 +10,7 @@ export interface API extends Token { oidcBaseURL: string; oauthBaseURL: string; samlBaseURL: string; + featuresBaseURL: string; } export interface SystemAPI extends Token { diff --git a/e2e/package.json b/e2e/package.json index 480aa2019b..afee683a10 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -5,13 +5,13 @@ "open": "pnpm exec cypress open", "test:e2e": "pnpm exec cypress run", "test:open:golang": "pnpm run open --", - "test:e2e:golang": "pnpm run e2e --", + "test:e2e:golang": "pnpm run test:e2e --", "test:open:golangangular": "CYPRESS_BASE_URL=http://localhost:4200 CYPRESS_BACKEND_URL=http://localhost:8080 pnpm run open --", - "test:e2e:golangangular": "CYPRESS_BASE_URL=http://localhost:4200 CYPRESS_BACKEND_URL=http://localhost:8080 pnpm run e2e --", - "test:open:angulargolang": "pnpm run open:golangangular --", - "test:e2e:angulargolang": "pnpm run e2e:golangangular --", + "test:e2e:golangangular": "CYPRESS_BASE_URL=http://localhost:4200 CYPRESS_BACKEND_URL=http://localhost:8080 pnpm run test:e2e --", + "test:open:angulargolang": "pnpm run test:open:golangangular --", + "test:e2e:angulargolang": "pnpm run test:e2e:golangangular --", "test:open:angular": "CYPRESS_BASE_URL=http://localhost:4200 CYPRESS_BACKEND_URL=http://localhost:8080 CYPRESS_WEBHOOK_HANDLER_HOST=host.docker.internal pnpm run open --", - "test:e2e:angular": "CYPRESS_BASE_URL=http://localhost:4200 CYPRESS_BACKEND_URL=http://localhost:8080 CYPRESS_WEBHOOK_HANDLER_HOST=host.docker.internal pnpm run e2e --", + "test:e2e:angular": "CYPRESS_BASE_URL=http://localhost:4200 CYPRESS_BACKEND_URL=http://localhost:8080 CYPRESS_WEBHOOK_HANDLER_HOST=host.docker.internal pnpm run test:e2e --", "lint": "prettier --check cypress", "lint:fix": "prettier --write cypress", "clean": "rm -rf .turbo node_modules" @@ -30,4 +30,4 @@ "@types/node": "^22.3.0", "cypress": "^14.5.3" } -} \ No newline at end of file +}