mirror of
https://github.com/zitadel/zitadel.git
synced 2025-08-24 16:07:45 +00:00
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:
@@ -13,7 +13,7 @@
|
||||
<p class="events-desc cnsl-secondary-text">{{ 'DESCRIPTIONS.SETTINGS.FEATURES.DESCRIPTION' | translate }}</p>
|
||||
|
||||
<ng-template cnslHasRole [hasRole]="['iam.restrictions.write']">
|
||||
<button color="warn" (click)="resetFeatures()" mat-stroked-button>
|
||||
<button color="warn" (click)="resetFeatures()" mat-stroked-button data-e2e="reset-features-button">
|
||||
{{ 'SETTING.FEATURES.RESET' | translate }}
|
||||
</button>
|
||||
</ng-template>
|
||||
@@ -24,9 +24,9 @@
|
||||
*ngFor="let key of FEATURE_KEYS"
|
||||
[toggleStateKey]="key"
|
||||
[toggleState]="toggleStates[key]"
|
||||
(toggleChange)="saveFeatures(key, $event)"
|
||||
(toggleChange)="saveFeature(key, $event)"
|
||||
></cnsl-feature-toggle>
|
||||
<cnsl-login-v2-feature-toggle [toggleState]="toggleStates.loginV2" (toggleChanged)="saveFeatures('loginV2', $event)" />
|
||||
<cnsl-login-v2-feature-toggle [toggleState]="toggleStates.loginV2" (toggleChanged)="saveFeature('loginV2', $event)" />
|
||||
</div>
|
||||
</cnsl-card>
|
||||
</div>
|
||||
|
@@ -129,19 +129,18 @@ export class FeaturesComponent {
|
||||
);
|
||||
}
|
||||
|
||||
public async saveFeatures<TKey extends ToggleStateKeys, TValue extends ToggleStates[TKey]>(key: TKey, value: TValue) {
|
||||
const toggleStates = { ...(await firstValueFrom(this.toggleStates$)), [key]: value };
|
||||
public async saveFeature<TKey extends ToggleStateKeys, TValue extends ToggleStates[TKey]>(key: TKey, value: TValue) {
|
||||
const req: MessageInitShape<typeof SetInstanceFeaturesRequestSchema> = {};
|
||||
|
||||
const req = FEATURE_KEYS.reduce<MessageInitShape<typeof SetInstanceFeaturesRequestSchema>>((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);
|
||||
|
170
e2e/cypress/e2e/settings/features.cy.ts
Normal file
170
e2e/cypress/e2e/settings/features.cy.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
@@ -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`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
59
e2e/cypress/support/api/features.ts
Normal file
59
e2e/cypress/support/api/features.ts
Normal 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 || '',
|
||||
},
|
||||
});
|
||||
}
|
@@ -10,6 +10,7 @@ export interface API extends Token {
|
||||
oidcBaseURL: string;
|
||||
oauthBaseURL: string;
|
||||
samlBaseURL: string;
|
||||
featuresBaseURL: string;
|
||||
}
|
||||
|
||||
export interface SystemAPI extends Token {
|
||||
|
@@ -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"
|
||||
|
Reference in New Issue
Block a user