Skip to content

Commit 92196c8

Browse files
authored
Fix malformed feature flag support (#17327)
1 parent 1c485ae commit 92196c8

4 files changed

Lines changed: 240 additions & 4 deletions

File tree

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { shallowMount } from '@vue/test-utils';
2+
3+
jest.mock('@shell/mixins/resource-fetch', () => ({
4+
__esModule: true,
5+
default: {
6+
data() {
7+
return {
8+
forceUpdateLiveAndDelayed: 0, loading: false, rows: []
9+
};
10+
},
11+
async $fetchType() {}
12+
}
13+
}));
14+
15+
// eslint-disable-next-line import/first
16+
import ManagementFeature from '@shell/list/management.cattle.io.feature.vue';
17+
18+
const createMockStore = () => ({
19+
getters: {
20+
'i18n/t': (key: string) => key,
21+
'management/schemaFor': () => ({ resourceMethods: ['PUT'] }),
22+
},
23+
dispatch: jest.fn(),
24+
});
25+
26+
const createWrapper = (rows: any[]) => {
27+
return shallowMount(ManagementFeature, {
28+
props: {
29+
resource: 'management.cattle.io.feature',
30+
schema: { id: 'management.cattle.io.feature' } as any,
31+
},
32+
data: () => ({ rows }),
33+
global: {
34+
mocks: {
35+
$store: createMockStore(),
36+
$fetchState: { pending: false },
37+
$fetchType: jest.fn(),
38+
},
39+
stubs: {
40+
// Render the cell:name slot directly so we can assert on the lock icon
41+
ResourceTable: {
42+
props: ['rows'],
43+
template: '<div><slot name="cell:name" :row="rows[0]" /></div>',
44+
},
45+
},
46+
}
47+
});
48+
};
49+
50+
describe('list/management.cattle.io.feature', () => {
51+
describe('locked icon rendering in cell:name slot', () => {
52+
it('should render the lock icon when status.lockedValue is not null', () => {
53+
const row = {
54+
metadata: { name: 'feature-a' },
55+
nameDisplay: 'feature-a',
56+
status: { lockedValue: true },
57+
};
58+
59+
const wrapper = createWrapper([row]);
60+
61+
expect(wrapper.find('i.icon-lock').exists()).toBe(true);
62+
});
63+
64+
it('should not render the lock icon when status.lockedValue is null', () => {
65+
const row = {
66+
metadata: { name: 'feature-a' },
67+
nameDisplay: 'feature-a',
68+
status: { lockedValue: null },
69+
};
70+
71+
const wrapper = createWrapper([row]);
72+
73+
expect(wrapper.find('i.icon-lock').exists()).toBe(false);
74+
});
75+
76+
it('should not throw and should not render the lock icon when status is missing (malformed feature flag)', () => {
77+
const row = {
78+
metadata: { name: 'feature-a' },
79+
nameDisplay: 'feature-a',
80+
};
81+
82+
expect(() => createWrapper([row])).not.toThrow();
83+
84+
const wrapper = createWrapper([row]);
85+
86+
expect(wrapper.find('i.icon-lock').exists()).toBe(false);
87+
});
88+
});
89+
90+
describe('filteredRows', () => {
91+
it('should filter out hidden feature flags', () => {
92+
const rows = [
93+
{ metadata: { name: 'fleet' } },
94+
{ metadata: { name: 'some-feature' } },
95+
];
96+
97+
const wrapper = createWrapper(rows);
98+
99+
const filtered = (wrapper.vm as any).filteredRows;
100+
101+
expect(filtered).toHaveLength(1);
102+
expect(filtered[0].metadata.name).toBe('some-feature');
103+
});
104+
});
105+
});

shell/list/management.cattle.io.feature.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ export default {
6969
<div class="feature-name">
7070
<div>{{ scope.row.nameDisplay }}</div>
7171
<i
72-
v-if="scope.row.status.lockedValue !== null"
72+
v-if="scope.row.status && scope.row.status.lockedValue !== null"
7373
class="icon icon-lock"
7474
/>
7575
</div>
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import Feature from '@shell/models/management.cattle.io.feature.js';
2+
import Resource from '@shell/plugins/dashboard-store/resource-class';
3+
4+
describe('class Feature', () => {
5+
const ctx = {
6+
dispatch: jest.fn(),
7+
rootGetters: { 'i18n/t': (key: string) => key },
8+
getters: { schemaFor: () => ({ linkFor: jest.fn() }) },
9+
};
10+
11+
// The parent Resource._availableActions getter depends on runtime config we don't have
12+
// in tests — stub it out so we can assert on Feature's own additions.
13+
beforeEach(() => {
14+
jest.spyOn(Resource.prototype, '_availableActions', 'get').mockReturnValue([]);
15+
});
16+
17+
afterEach(() => {
18+
jest.restoreAllMocks();
19+
});
20+
21+
describe('enabled getter', () => {
22+
it.each([
23+
[true, true],
24+
[false, false],
25+
])('should return lockedValue (%s) when status.lockedValue is not null', (lockedValue, expected) => {
26+
const feature = new Feature({
27+
spec: { value: false },
28+
status: { lockedValue, default: false }
29+
}, ctx);
30+
31+
expect(feature.enabled).toBe(expected);
32+
});
33+
34+
it('should return spec.value when lockedValue is null and spec.value is set', () => {
35+
const feature = new Feature({
36+
spec: { value: true },
37+
status: { lockedValue: null, default: false }
38+
}, ctx);
39+
40+
expect(feature.enabled).toBe(true);
41+
});
42+
43+
it('should fall back to status.default when lockedValue is null and spec.value is null', () => {
44+
const feature = new Feature({
45+
spec: { value: null },
46+
status: { lockedValue: null, default: true }
47+
}, ctx);
48+
49+
expect(feature.enabled).toBe(true);
50+
});
51+
52+
it('should not throw when status is missing (malformed feature flag)', () => {
53+
const feature = new Feature({ spec: { value: true } }, ctx);
54+
55+
expect(() => feature.enabled).not.toThrow();
56+
expect(feature.enabled).toBe(true);
57+
});
58+
});
59+
60+
describe('restartRequired getter', () => {
61+
it('should return false when status.dynamic is true', () => {
62+
const feature = new Feature({ spec: {}, status: { dynamic: true, lockedValue: null } }, ctx);
63+
64+
expect(feature.restartRequired).toBe(false);
65+
});
66+
67+
it('should return true when status.dynamic is false', () => {
68+
const feature = new Feature({ spec: {}, status: { dynamic: false, lockedValue: null } }, ctx);
69+
70+
expect(feature.restartRequired).toBe(true);
71+
});
72+
73+
it('should return true when status is missing (malformed feature flag)', () => {
74+
const feature = new Feature({ spec: {} }, ctx);
75+
76+
expect(() => feature.restartRequired).not.toThrow();
77+
expect(feature.restartRequired).toBe(true);
78+
});
79+
});
80+
81+
describe('_availableActions getter', () => {
82+
it('should disable the toggle action when lockedValue is not null', () => {
83+
const feature = new Feature({
84+
spec: { value: false },
85+
status: {
86+
lockedValue: true, default: false, dynamic: true
87+
},
88+
}, ctx);
89+
90+
jest.spyOn(feature, 'canUpdate', 'get').mockReturnValue(true);
91+
92+
const actions = feature._availableActions;
93+
94+
expect(actions[0].action).toBe('toggleFeatureFlag');
95+
expect(actions[0].enabled).toBe(false);
96+
});
97+
98+
it('should enable the toggle action when lockedValue is null and user canUpdate', () => {
99+
const feature = new Feature({
100+
spec: { value: false },
101+
status: {
102+
lockedValue: null, default: false, dynamic: true
103+
},
104+
id: 'some-feature',
105+
}, ctx);
106+
107+
jest.spyOn(feature, 'canUpdate', 'get').mockReturnValue(true);
108+
109+
const actions = feature._availableActions;
110+
111+
expect(actions[0].action).toBe('toggleFeatureFlag');
112+
expect(actions[0].enabled).toBe(true);
113+
});
114+
115+
it('should not throw and should disable the toggle action when status is missing (malformed feature flag)', () => {
116+
const feature = new Feature({
117+
spec: { value: false },
118+
id: 'some-feature',
119+
}, ctx);
120+
121+
jest.spyOn(feature, 'canUpdate', 'get').mockReturnValue(true);
122+
123+
expect(() => feature._availableActions).not.toThrow();
124+
125+
const actions = feature._availableActions;
126+
127+
expect(actions[0].action).toBe('toggleFeatureFlag');
128+
expect(actions[0].enabled).toBeFalsy();
129+
});
130+
});
131+
});

shell/models/management.cattle.io.feature.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export default class Feature extends HybridModel {
88

99
get enabled() {
1010
// If lockedValue is not null, then this is the value that the flag is locked to, so that should be used
11-
if (this.status.lockedValue !== null) {
11+
if (this.status && this.status.lockedValue !== null) {
1212
return this.status.lockedValue;
1313
}
1414

@@ -17,7 +17,7 @@ export default class Feature extends HybridModel {
1717
}
1818

1919
get restartRequired() {
20-
return !this.status.dynamic;
20+
return !this.status?.dynamic;
2121
}
2222

2323
get canYaml() {
@@ -43,7 +43,7 @@ export default class Feature extends HybridModel {
4343
// User can not disable or enable if the feature flag is locked
4444
// Note: lockedValue is the value that the feature flag is locked to, so it can be true or false
4545
// It can also be null, which indicates that the feature flag is not locked
46-
enableAction.enabled = enableAction.enabled && (this.status.lockedValue === null);
46+
enableAction.enabled = enableAction.enabled && (this.status && this.status.lockedValue === null);
4747

4848
out.unshift(enableAction);
4949

0 commit comments

Comments
 (0)