fix(console): single feature patch (#10476)

# Which Problems Are Solved

This PR fixes an issue where all features where patched, instead of a
single one. This led to instance overrides which were not intended.
With this change, an update is executed whenever a toggle is hit, only
containing the respective feature, not all.

# How the Problems Are Solved

The console application was overriding the feature settings as an entire
request. A toggle change is now only changing the desired and targeted
feature using partial patches.

# Additional Context

Closes #10459

---------

Co-authored-by: Elio Bischof <elio@zitadel.com>
This commit is contained in:
Max Peintner
2025-08-22 09:55:31 +02:00
committed by GitHub
parent ac3a4037a7
commit d8518d48f2
7 changed files with 251 additions and 21 deletions

View File

@@ -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');
});
});
});

View File

@@ -16,6 +16,7 @@ export function apiAuth(): Cypress.Chainable<API> {
oauthBaseURL: `${backendUrl}/oauth/v2`,
oidcBaseURL: `${backendUrl}/oidc/v1`,
samlBaseURL: `${backendUrl}/saml/v2`,
featuresBaseURL: `${backendUrl}/v2/features`,
};
});
}

View File

@@ -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<string, any>) {
const body: Record<string, any> = {
[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<string, any>) {
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 || '',
},
});
}

View File

@@ -10,6 +10,7 @@ export interface API extends Token {
oidcBaseURL: string;
oauthBaseURL: string;
samlBaseURL: string;
featuresBaseURL: string;
}
export interface SystemAPI extends Token {