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
+}