diff --git a/shell/assets/translations/en-us.yaml b/shell/assets/translations/en-us.yaml index 1e34866448e..f0a48306fbc 100644 --- a/shell/assets/translations/en-us.yaml +++ b/shell/assets/translations/en-us.yaml @@ -162,6 +162,9 @@ generic: externalIps: External IPs internalIps: Internal IPs opensInNewTab: Opens in a new tab + autogeneratedCreated: + title: "{resource} created" + message: "{id} has been created." tabs: addItem: Add a new tab item @@ -3599,6 +3602,73 @@ import: other {# Resources} } +auditPolicy: + description: Define rules for what events should be logged. + enabled: Enabled + disabled: Disabled + active: Active + inactive: Inactive + action: + enable: Enable + disable: Disable + reasons: + PolicyNotYetActivated: Not yet activated + PolicyIsActive: Active + PolicyIsInvalid: Invalid + PolicyWasDisabled: Disabled + general: + title: General + enabled: + label: Enabled + title: Enabled + checkbox: Enables the audit log + verbosity: + label: Log Verbosity + title: Log Verbosity + level: + 0: 0 - Log request and response metadata + 1: 1 - Log request and response headers + 2: 2 - Log request body + 3: 3 - Log response body + title: Log Levels + label: Level + tooltip: Each log level is cumulative and each subsequent level logs the previous level data. Each log transaction for a request/response pair uses the same auditID value. + request: + title: Request + requestHeaders: Request Headers + requestBody: Request Body + response: + title: Response + responseHeaders: Response Headers + responseBody: Response Body + filters: + add: Add Filter + title: Filters + action: + title: Action + label: Action + allow: Allow + deny: Deny + placeholder: Allow/Deny + requestURI: + title: Request URI + label: Request URI + placeholder: e.g. /foo/.* + additionalRedactions: + title: Additional Redactions + headers: + title: Headers + label: Headers + placeholder: e.g. Cache.* + add: Add Header + paths: + title: Paths + label: Paths + placeholder: e.g. $.gitCommit + add: Add Path + error: + enableOrDisable: "{flag, select, enable {Error when enabling - {id}} disable {Error when disabling - {id}} other {Error - {id}}}" + ingress: description: Ingresses route incoming traffic from the internet to Services within the cluster based on the hostname and path specified in the request. You can expose multiple Services on the same external IP address and port. certificates: @@ -7965,6 +8035,11 @@ typeLabel: one { Resource Quota } other { Resource Quotas } } + auditlog.cattle.io.auditpolicy: |- + {count, plural, + one { Audit Log Policy } + other { Audit Log Policies } + } # pruh-mee-thee-eyes https://www.prometheus.io/docs/introduction/faq/#what-is-the-plural-of-prometheus monitoring.coreos.com.prometheus: |- {count, plural, diff --git a/shell/config/product/explorer.js b/shell/config/product/explorer.js index edbc76ae22c..6ff36c5d4ac 100644 --- a/shell/config/product/explorer.js +++ b/shell/config/product/explorer.js @@ -3,7 +3,7 @@ import { CONFIG_MAP, EVENT, NODE, SECRET, INGRESS, - WORKLOAD, WORKLOAD_TYPES, SERVICE, HPA, NETWORK_POLICY, PV, PVC, STORAGE_CLASS, POD, POD_DISRUPTION_BUDGET, LIMIT_RANGE, RESOURCE_QUOTA, + WORKLOAD, WORKLOAD_TYPES, SERVICE, HPA, NETWORK_POLICY, PV, PVC, STORAGE_CLASS, POD, POD_DISRUPTION_BUDGET, LIMIT_RANGE, RESOURCE_QUOTA, AUDIT_POLICY, MANAGEMENT, NAMESPACE, NORMAN, @@ -83,6 +83,7 @@ export function init(store) { NETWORK_POLICY, POD_DISRUPTION_BUDGET, RESOURCE_QUOTA, + AUDIT_POLICY, ], 'policy'); basicType([ @@ -118,6 +119,7 @@ export function init(store) { weightGroup('storage', 95, true); weightGroup('policy', 94, true); weightType(POD, -1, true); + weightType(AUDIT_POLICY, -1, true); // here is where we define the usage of the WORKLOAD custom list view for // all the workload types (ex: deployments, cron jobs, daemonsets, etc) @@ -152,6 +154,7 @@ export function init(store) { mapGroup('autoscaling', 'Autoscaling'); mapGroup('policy', 'Policy'); mapGroup('networking.k8s.io', 'Networking'); + mapGroup('auditlog.cattle.io', 'Policy'); mapGroup(/^(.+\.)?api(server)?.*\.k8s\.io$/, 'API'); mapGroup('rbac.authorization.k8s.io', 'RBAC'); mapGroup('admissionregistration.k8s.io', 'admission'); diff --git a/shell/config/types.js b/shell/config/types.js index 5ef7e239de1..01e3526bf89 100644 --- a/shell/config/types.js +++ b/shell/config/types.js @@ -61,6 +61,7 @@ export const POD_DISRUPTION_BUDGET = 'policy.poddisruptionbudget'; export const PV = 'persistentvolume'; export const PVC = 'persistentvolumeclaim'; export const RESOURCE_QUOTA = 'resourcequota'; +export const AUDIT_POLICY = 'auditlog.cattle.io.auditpolicy'; export const SCHEMA = 'schema'; export const SERVICE = 'service'; export const SECRET = 'secret'; diff --git a/shell/edit/auditlog.cattle.io.auditpolicy/AdditionalRedactions.vue b/shell/edit/auditlog.cattle.io.auditpolicy/AdditionalRedactions.vue new file mode 100644 index 00000000000..cdcd786fa01 --- /dev/null +++ b/shell/edit/auditlog.cattle.io.auditpolicy/AdditionalRedactions.vue @@ -0,0 +1,106 @@ + + + + + + + + + + {{ t("auditPolicy.additionalRedactions.headers.title") }} + + + + + + + + + {{ t("auditPolicy.additionalRedactions.paths.title") }} + + + + + + + + + + + + diff --git a/shell/edit/auditlog.cattle.io.auditpolicy/Filters.vue b/shell/edit/auditlog.cattle.io.auditpolicy/Filters.vue new file mode 100644 index 00000000000..dff82fb0e68 --- /dev/null +++ b/shell/edit/auditlog.cattle.io.auditpolicy/Filters.vue @@ -0,0 +1,134 @@ + + + + + + + + + + + + {{ t('auditPolicy.filters.action.title') }} + + + {{ t('auditPolicy.filters.requestURI.title') }} + + + + + + + + + + + + + + + + + + + + + diff --git a/shell/edit/auditlog.cattle.io.auditpolicy/General.vue b/shell/edit/auditlog.cattle.io.auditpolicy/General.vue new file mode 100644 index 00000000000..4311d7dfd65 --- /dev/null +++ b/shell/edit/auditlog.cattle.io.auditpolicy/General.vue @@ -0,0 +1,161 @@ + + + + + + + + {{ t("auditPolicy.general.enabled.title") }} + + + + + + + + + {{ t("auditPolicy.general.verbosity.title") }} + + {{ t("auditPolicy.general.verbosity.level.title") }} + + + + + + + + + + + {{ t("auditPolicy.general.verbosity.request.title") }} + + + + + + + + + {{ t("auditPolicy.general.verbosity.response.title") }} + + + + + + + + + + + + + diff --git a/shell/edit/auditlog.cattle.io.auditpolicy/__tests__/AdditionalRedactions.test.ts b/shell/edit/auditlog.cattle.io.auditpolicy/__tests__/AdditionalRedactions.test.ts new file mode 100644 index 00000000000..64a31b6e0b1 --- /dev/null +++ b/shell/edit/auditlog.cattle.io.auditpolicy/__tests__/AdditionalRedactions.test.ts @@ -0,0 +1,327 @@ +import { shallowMount, VueWrapper } from '@vue/test-utils'; +import AdditionalRedactions from '../AdditionalRedactions.vue'; +import { ComponentPublicInstance } from 'vue'; +import { AuditPolicy } from '@shell/edit/auditlog.cattle.io.auditpolicy/types'; + +// Mock the ID generation to have consistent snapshots +jest.mock('@shell/utils/string', () => ({ generateRandomAlphaString: () => 'test-id-123' })); + +interface AdditionalRedactionsComponent extends ComponentPublicInstance { + spec: AuditPolicy; + addRedaction: () => void; + removeRedaction: (index: number) => void; + redactionLabel: (index: number) => string; +} + +const defaultProps = { + value: { additionalRedactions: [] }, + mode: 'create' +}; + +const globalMocks = { + global: { + mocks: { + $t: (key: string) => key, + t: (key: string) => key, + $store: { + getters: { 'i18n/t': (key: string) => key }, + dispatch: jest.fn() + }, + $route: { + params: {}, + query: {} + }, + $router: { + push: jest.fn(), + replace: jest.fn() + } + }, + stubs: { + Tabbed: true, + Tab: true, + ArrayList: true + } + } +}; + +function factory(props: Record = {}, options: Record = {}): VueWrapper { + return shallowMount(AdditionalRedactions, { + props: { ...defaultProps, ...props }, + ...globalMocks, + ...options + }) as unknown as VueWrapper; +} + +describe('component: AdditionalRedactions', () => { + describe('rendering & initial state', () => { + it('should render with default props (snapshot)', () => { + const wrapper = factory(); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('should render with create mode', () => { + const wrapper = factory({ mode: 'create' }); + + expect(wrapper.exists()).toBe(true); + expect(wrapper.find('.row.mb-40').exists()).toBe(true); + }); + + it('should render with edit mode', () => { + const wrapper = factory({ mode: 'edit' }); + + expect(wrapper.exists()).toBe(true); + expect(wrapper.find('.row.mb-40').exists()).toBe(true); + }); + + it('should render with initial redactions data', () => { + const value = { + additionalRedactions: [ + { headers: ['X-Test-Header'], paths: ['/api/test'] }, + { headers: ['Authorization'], paths: ['/secure'] } + ] + }; + const wrapper = factory({ value }); + + expect(wrapper.vm.spec.additionalRedactions).toHaveLength(2); + }); + }); + + describe('props & state changes', () => { + it('should handle empty value prop gracefully', () => { + const wrapper = factory({ value: undefined }); + + expect(wrapper.exists()).toBe(true); + expect(wrapper.vm.spec.additionalRedactions).toStrictEqual([]); + }); + + it('should handle null value prop gracefully', () => { + const wrapper = factory({ value: null }); + + expect(wrapper.exists()).toBe(true); + expect(wrapper.vm.spec.additionalRedactions).toStrictEqual([]); + }); + + it('should update when mode prop changes', async() => { + const wrapper = factory({ mode: 'create' }); + + expect((wrapper.props() as any).mode).toBe('create'); + await wrapper.setProps({ mode: 'view' }); + expect((wrapper.props() as any).mode).toBe('view'); + }); + + it('should merge defaults with provided value', () => { + const value = { + additionalRedactions: [{ headers: ['Custom'], paths: ['/custom'] }], + customProp: 'test' + }; + const wrapper = factory({ value }); + + expect((wrapper.vm.spec.additionalRedactions ?? [])).toHaveLength(1); + expect((wrapper.vm.spec.additionalRedactions ?? [])[0]).toStrictEqual({ headers: ['Custom'], paths: ['/custom'] }); + expect((wrapper.vm.spec as any).customProp).toBe('test'); + }); + }); + + describe('user interaction', () => { + it('should emit update:value when addRedaction is called', () => { + const wrapper = factory(); + + wrapper.vm.addRedaction(); + + expect(wrapper.emitted('update:value')).toBeTruthy(); + const events = wrapper.emitted('update:value'); + + expect(events && events[0]).toBeTruthy(); + + const emitted = events && events[0] && events[0][0] as AuditPolicy; + + expect(emitted && emitted.additionalRedactions?.length).toBe(1); + expect(emitted && emitted.additionalRedactions?.[0]).toStrictEqual({ + headers: [], + paths: [] + }); + }); + + it('should emit update:value when removeRedaction is called', () => { + const value = { + additionalRedactions: [ + { headers: ['X-Test'], paths: ['/foo'] }, + { headers: ['Authorization'], paths: ['/bar'] } + ] + }; + const wrapper = factory({ value }); + + wrapper.vm.removeRedaction(0); + + expect(wrapper.emitted('update:value')).toBeTruthy(); + const events = wrapper.emitted('update:value'); + + expect(events && events[0]).toBeTruthy(); + + const emitted = events && events[0] && events[0][0] as AuditPolicy; + + expect(emitted && emitted.additionalRedactions?.length).toBe(1); + expect(emitted && emitted.additionalRedactions?.[0]).toStrictEqual({ + headers: ['Authorization'], + paths: ['/bar'] + }); + }); + + it('should preserve existing prop values when emitting updates', () => { + const existingValue = { someOtherProp: 'existing' }; + const wrapper = factory({ value: existingValue }); + + wrapper.vm.addRedaction(); + + const events = wrapper.emitted('update:value'); + + expect(events && events[0]).toBeTruthy(); + const emitted = events && events[0] && events[0][0] as AuditPolicy & { someOtherProp: string }; + + expect(emitted && emitted.someOtherProp).toBe('existing'); + expect(emitted && emitted.additionalRedactions).toHaveLength(1); + }); + + it('should remove correct redaction by index', () => { + const value = { + additionalRedactions: [ + { headers: ['First'], paths: ['/first'] }, + { headers: ['Second'], paths: ['/second'] }, + { headers: ['Third'], paths: ['/third'] } + ] + }; + const wrapper = factory({ value }); + + wrapper.vm.removeRedaction(1); // Remove middle item + + const events = wrapper.emitted('update:value'); + + expect(events && events[0]).toBeTruthy(); + const emitted = events && events[0] && events[0][0] as AuditPolicy; + + expect(emitted && emitted.additionalRedactions).toHaveLength(2); + expect(emitted && emitted.additionalRedactions?.[0]).toStrictEqual({ headers: ['First'], paths: ['/first'] }); + expect(emitted && emitted.additionalRedactions?.[1]).toStrictEqual({ headers: ['Third'], paths: ['/third'] }); + }); + }); + + describe('computed properties & logic', () => { + it('should return correct redactionLabel values', () => { + const wrapper = factory(); + + expect(wrapper.vm.redactionLabel(0)).toBe('Rule 1'); + expect(wrapper.vm.redactionLabel(4)).toBe('Rule 5'); + expect(wrapper.vm.redactionLabel(99)).toBe('Rule 100'); + }); + + it('should have reactive redactionLabel computed property', () => { + const wrapper = factory(); + const labelFn = wrapper.vm.redactionLabel; + + // Test that it returns a function that computes labels + expect(typeof labelFn).toBe('function'); + expect(labelFn(0)).toBe('Rule 1'); + expect(labelFn(1)).toBe('Rule 2'); + }); + + it('should initialize spec reactive ref correctly', () => { + const wrapper = factory(); + + expect(wrapper.vm.spec).toBeDefined(); + expect(Array.isArray(wrapper.vm.spec.additionalRedactions ?? [])).toBe(true); + }); + + it('should merge defaults with props correctly in spec', () => { + const value = { additionalRedactions: [{ headers: ['Test'], paths: ['/test'] }] }; + const wrapper = factory({ value }); + + expect((wrapper.vm.spec.additionalRedactions ?? [])).toHaveLength(1); + expect((wrapper.vm.spec.additionalRedactions ?? [])[0]).toStrictEqual({ + headers: ['Test'], + paths: ['/test'] + }); + }); + }); + + describe('component configuration', () => { + it('should configure Tabbed component with correct props', () => { + const wrapper = factory(); + const tabbedComponent = wrapper.findComponent({ name: 'Tabbed' }); + + expect(tabbedComponent.exists()).toBe(true); + expect(tabbedComponent.props('sideTabs')).toBe(true); + expect(tabbedComponent.props('useHash')).toBe(true); + }); + + it('should show/hide add/remove tabs based on mode', () => { + const createWrapper = factory({ mode: 'create' }); + const editWrapper = factory({ mode: 'edit' }); + const viewWrapper = factory({ mode: 'view' }); + + expect(createWrapper.findComponent({ name: 'Tabbed' }).props('showTabsAddRemove')).toBe(true); + expect(editWrapper.findComponent({ name: 'Tabbed' }).props('showTabsAddRemove')).toBe(true); + expect(viewWrapper.findComponent({ name: 'Tabbed' }).props('showTabsAddRemove')).toBe(false); + }); + + it('should bind correct event handlers to Tabbed component', () => { + const wrapper = factory(); + const tabbedComponent = wrapper.findComponent({ name: 'Tabbed' }); + + // Simulate events from Tabbed component + tabbedComponent.vm.$emit('addTab'); + expect(wrapper.emitted('update:value')).toBeTruthy(); + + const value = { additionalRedactions: [{ headers: [], paths: [] }] }; + const wrapperWithData = factory({ value }); + const tabbedWithData = wrapperWithData.findComponent({ name: 'Tabbed' }); + + tabbedWithData.vm.$emit('removeTab', 0); + expect(wrapperWithData.emitted('update:value')).toBeTruthy(); + }); + }); + + describe('edge cases', () => { + it('should handle empty additionalRedactions array', () => { + const wrapper = factory({ value: { additionalRedactions: [] } }); + + expect((wrapper.vm.spec.additionalRedactions ?? [])).toStrictEqual([]); + expect(wrapper.findAllComponents({ name: 'Tab' })).toHaveLength(0); + }); + + it('should handle missing additionalRedactions property', () => { + const wrapper = factory({ value: {} }); + + expect((wrapper.vm.spec.additionalRedactions ?? [])).toStrictEqual([]); + }); + + it('should not crash when removing from empty array', () => { + const wrapper = factory({ value: { additionalRedactions: [] } }); + + expect(() => { + wrapper.vm.removeRedaction(0); + }).not.toThrow(); + + const events = wrapper.emitted('update:value'); + + expect(events && events[0]).toBeTruthy(); + const emitted = events && events[0] && events[0][0] as AuditPolicy; + + expect(emitted && emitted.additionalRedactions).toStrictEqual([]); + }); + + it('should handle out of bounds removal index gracefully', () => { + const value = { additionalRedactions: [{ headers: ['Test'], paths: ['/test'] }] }; + const wrapper = factory({ value }); + + wrapper.vm.removeRedaction(5); // Index out of bounds + + const events = wrapper.emitted('update:value'); + + expect(events && events[0]).toBeTruthy(); + const emitted = events && events[0] && events[0][0] as AuditPolicy; + + expect(emitted && emitted.additionalRedactions).toHaveLength(1); // Should remain unchanged + }); + }); +}); diff --git a/shell/edit/auditlog.cattle.io.auditpolicy/__tests__/Filters.test.ts b/shell/edit/auditlog.cattle.io.auditpolicy/__tests__/Filters.test.ts new file mode 100644 index 00000000000..e7b29cfbc64 --- /dev/null +++ b/shell/edit/auditlog.cattle.io.auditpolicy/__tests__/Filters.test.ts @@ -0,0 +1,449 @@ +import { shallowMount, VueWrapper } from '@vue/test-utils'; +import Filters from '../Filters.vue'; +import { ComponentPublicInstance } from 'vue'; +import { AuditPolicy, FilterRule } from '@shell/edit/auditlog.cattle.io.auditpolicy/types'; + +// Mock the ID generation to have consistent snapshots +jest.mock('@shell/utils/string', () => ({ generateRandomAlphaString: () => 'test-id-123' })); + +interface FiltersComponent extends ComponentPublicInstance { + spec: AuditPolicy; + addRow: (key: 'action' | 'requestURI', filters: FilterRule[]) => void; + updateRow: (key: 'action' | 'requestURI', index: number, value: string) => void; + defaultAddValue: FilterRule; +} + +const defaultProps = { + value: { filters: [] }, + mode: 'create' +}; + +const globalMocks = { + global: { + mocks: { + $t: (key: string) => key, + t: (key: string) => key, + $store: { + getters: { 'i18n/t': (key: string) => key }, + dispatch: jest.fn() + }, + $route: { + params: {}, + query: {} + }, + $router: { + push: jest.fn(), + replace: jest.fn() + } + }, + stubs: { + ArrayList: true, + LabeledInput: true, + LabeledSelect: true + } + } +}; + +function factory(props: Record = {}, options: Record = {}): VueWrapper { + return shallowMount(Filters, { + props: { ...defaultProps, ...props }, + ...globalMocks, + ...options + }) as unknown as VueWrapper; +} + +describe('component: Filters', () => { + describe('rendering & initial state', () => { + it('should render with default props (snapshot)', () => { + const wrapper = factory(); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('should render with create mode', () => { + const wrapper = factory({ mode: 'create' }); + + expect(wrapper.exists()).toBe(true); + expect(wrapper.find('.row.mb-40').exists()).toBe(true); + }); + + it('should render with edit mode', () => { + const wrapper = factory({ mode: 'edit' }); + + expect(wrapper.exists()).toBe(true); + expect(wrapper.find('.row.mb-40').exists()).toBe(true); + }); + + it('should render with view mode', () => { + const wrapper = factory({ mode: 'view' }); + + expect(wrapper.exists()).toBe(true); + expect(wrapper.find('.row.mb-40').exists()).toBe(true); + }); + + it('should render with initial filters data', () => { + const value = { + filters: [ + { action: 'allow', requestURI: '/api/v1/pods' }, + { action: 'deny', requestURI: '/api/v1/secrets' } + ] + }; + const wrapper = factory({ value }); + + expect(wrapper.vm.spec.filters).toHaveLength(2); + }); + }); + + describe('props & state changes', () => { + it('should handle empty value prop gracefully', () => { + const wrapper = factory({ value: undefined }); + + expect(wrapper.exists()).toBe(true); + expect(wrapper.vm.spec.filters).toStrictEqual([]); + }); + + it('should handle null value prop gracefully', () => { + const wrapper = factory({ value: null }); + + expect(wrapper.exists()).toBe(true); + expect(wrapper.vm.spec.filters).toStrictEqual([]); + }); + + it('should update when mode prop changes', async() => { + const wrapper = factory({ mode: 'create' }); + + expect((wrapper.props() as any).mode).toBe('create'); + await wrapper.setProps({ mode: 'view' }); + expect((wrapper.props() as any).mode).toBe('view'); + }); + + it('should merge defaults with provided value', () => { + const value = { + filters: [{ action: 'allow', requestURI: '/custom' }], + customProp: 'test' + }; + const wrapper = factory({ value }); + + expect((wrapper.vm.spec.filters ?? [])).toHaveLength(1); + expect((wrapper.vm.spec.filters ?? [])[0]).toStrictEqual({ action: 'allow', requestURI: '/custom' }); + expect((wrapper.vm.spec as any).customProp).toBe('test'); + }); + }); + + describe('user interaction', () => { + it('should emit update:value when addRow is called', () => { + const wrapper = factory(); + const newFilters = [{ action: 'allow', requestURI: '' }]; + + wrapper.vm.addRow('action', newFilters); + + expect(wrapper.emitted('update:value')).toBeTruthy(); + const events = wrapper.emitted('update:value'); + + expect(events && events[0]).toBeTruthy(); + + const emitted = events && events[0] && events[0][0] as { filters: FilterRule[] }; + + expect(emitted && emitted.filters).toHaveLength(1); + expect(emitted && emitted.filters[0]).toStrictEqual({ + action: 'allow', + requestURI: '' + }); + }); + + it('should emit update:value when updateRow is called', () => { + const value = { + filters: [ + { action: 'allow', requestURI: '/api/v1/pods' } + ] + }; + const wrapper = factory({ value }); + + wrapper.vm.updateRow('action', 0, 'deny'); + + expect(wrapper.emitted('update:value')).toBeTruthy(); + const events = wrapper.emitted('update:value'); + + expect(events && events[0]).toBeTruthy(); + + const emitted = events && events[0] && events[0][0] as { filters: FilterRule[] }; + + expect(emitted && emitted.filters).toHaveLength(1); + expect(emitted && emitted.filters[0]).toStrictEqual({ + action: 'deny', + requestURI: '/api/v1/pods' + }); + }); + + it('should preserve existing prop values when emitting updates', () => { + const existingValue = { someOtherProp: 'existing' }; + const wrapper = factory({ value: existingValue }); + + wrapper.vm.addRow('action', [{ action: 'allow', requestURI: '' }]); + + const events = wrapper.emitted('update:value'); + + expect(events && events[0]).toBeTruthy(); + + const emitted = events && events[0] && events[0][0] as { someOtherProp?: string; filters: FilterRule[] }; + + expect(emitted && emitted.someOtherProp).toBe('existing'); + expect(emitted && emitted.filters).toHaveLength(1); + }); + + it('should update correct filter by index', () => { + const value = { + filters: [ + { action: 'allow', requestURI: '/first' }, + { action: 'deny', requestURI: '/second' }, + { action: 'allow', requestURI: '/third' } + ] + }; + const wrapper = factory({ value }); + + wrapper.vm.updateRow('requestURI', 1, '/updated'); + + const events = wrapper.emitted('update:value'); + + expect(events && events[0]).toBeTruthy(); + + const emitted = events && events[0] && events[0][0] as { filters: FilterRule[] }; + + expect(emitted && emitted.filters).toHaveLength(3); + expect(emitted && emitted.filters[0]).toStrictEqual({ action: 'allow', requestURI: '/first' }); + expect(emitted && emitted.filters[1]).toStrictEqual({ action: 'deny', requestURI: '/updated' }); + expect(emitted && emitted.filters[2]).toStrictEqual({ action: 'allow', requestURI: '/third' }); + }); + + it('should add filters and emit events correctly', () => { + const wrapper = factory(); + const expectedDefault = { action: 'allow', requestURI: '' }; + + // Add first filter + wrapper.vm.addRow('action', [expectedDefault]); + expect(wrapper.emitted('update:value')).toHaveLength(1); + + // Add second filter + wrapper.vm.addRow('action', [expectedDefault, expectedDefault]); + expect(wrapper.emitted('update:value')).toHaveLength(2); + + // Add third filter + wrapper.vm.addRow('action', [expectedDefault, expectedDefault, expectedDefault]); + expect(wrapper.emitted('update:value')).toHaveLength(3); + + // Check that each emission contains the correct filters + const emissions = wrapper.emitted('update:value'); + + expect(emissions && emissions[0] && (emissions[0][0] as any).filters).toHaveLength(1); + + expect(emissions && emissions[1] && (emissions[1][0] as any).filters).toHaveLength(2); + + expect(emissions && emissions[2] && (emissions[2][0] as any).filters).toHaveLength(3); + + expect(emissions && emissions[2] && (emissions[2][0] as any).filters[2]).toStrictEqual(expectedDefault); + }); + }); + + describe('computed properties & logic', () => { + it('should have defaultAddValue configured correctly', () => { + const wrapper = factory(); + + expect(wrapper.vm.defaultAddValue).toStrictEqual({ + action: 'allow', + requestURI: '' + }); + }); + + it('should initialize spec reactive ref correctly', () => { + const wrapper = factory(); + + expect(wrapper.vm.spec).toBeDefined(); + expect(Array.isArray(wrapper.vm.spec.filters ?? [])).toBe(true); + }); + + it('should merge defaults with props correctly in spec', () => { + const value = { filters: [{ action: 'deny', requestURI: '/test' }] }; + const wrapper = factory({ value }); + + expect((wrapper.vm.spec.filters ?? [])).toHaveLength(1); + expect((wrapper.vm.spec.filters ?? [])[0]).toStrictEqual({ + action: 'deny', + requestURI: '/test' + }); + }); + }); + + describe('component configuration', () => { + it('should configure ArrayList component with correct props', () => { + const wrapper = factory(); + const arrayListComponent = wrapper.findComponent({ name: 'ArrayList' }); + + expect(arrayListComponent.exists()).toBe(true); + expect((arrayListComponent.props() as any).mode).toBe('create'); + expect((arrayListComponent.props() as any).protip).toBe(false); + }); + + it('should pass correct mode to ArrayList component', () => { + const createWrapper = factory({ mode: 'create' }); + const editWrapper = factory({ mode: 'edit' }); + const viewWrapper = factory({ mode: 'view' }); + + expect((createWrapper.findComponent({ name: 'ArrayList' }).props() as any).mode).toBe('create'); + expect((editWrapper.findComponent({ name: 'ArrayList' }).props() as any).mode).toBe('edit'); + expect((viewWrapper.findComponent({ name: 'ArrayList' }).props() as any).mode).toBe('view'); + }); + + it('should bind correct event handlers to ArrayList component', () => { + const wrapper = factory(); + const arrayListComponent = wrapper.findComponent({ name: 'ArrayList' }); + + expect(arrayListComponent.exists()).toBe(true); + // ArrayList component handles add/remove internally + expect((arrayListComponent.props() as any).defaultAddValue).toStrictEqual({ + action: 'allow', + requestURI: '' + }); + }); + + it('should handle filter data correctly', () => { + const value = { filters: [{ action: 'allow', requestURI: '/test' }] }; + const wrapper = factory({ value }); + + // Check that the component initializes with the provided filters + expect(wrapper.vm.spec.filters).toHaveLength(1); + expect((wrapper.vm.spec.filters ?? [])[0]).toStrictEqual({ action: 'allow', requestURI: '/test' }); + + // Check that the component structure exists + expect(wrapper.find('.row.mb-40').exists()).toBe(true); + expect(wrapper.findComponent({ name: 'ArrayList' }).exists()).toBe(true); + }); + + it('should not render input components when no filters exist', () => { + const wrapper = factory({ value: { filters: [] } }); + + const labeledSelectStubs = wrapper.findAll('labeled-select-stub'); + const labeledInputStubs = wrapper.findAll('labeled-input-stub'); + + expect(labeledSelectStubs).toHaveLength(0); + expect(labeledInputStubs).toHaveLength(0); + }); + }); + + describe('filter data structure', () => { + it('should handle filter with action property', () => { + const value = { filters: [{ action: 'allow' }] }; + const wrapper = factory({ value }); + + expect((wrapper.vm.spec.filters ?? [])[0]?.action).toBe('allow'); + }); + + it('should handle filter with requestURI property', () => { + const value = { filters: [{ requestURI: '/api/v1/namespaces' }] }; + const wrapper = factory({ value }); + + expect((wrapper.vm.spec.filters ?? [])[0]?.requestURI).toBe('/api/v1/namespaces'); + }); + + it('should handle filter with both action and requestURI', () => { + const value = { filters: [{ action: 'deny', requestURI: '/api/v1/secrets' }] }; + const wrapper = factory({ value }); + + expect((wrapper.vm.spec.filters ?? [])[0]).toStrictEqual({ + action: 'deny', + requestURI: '/api/v1/secrets' + }); + }); + + it('should handle multiple filters with different configurations', () => { + const value = { + filters: [ + { action: 'allow', requestURI: '/api/v1/pods' }, + { action: 'deny' }, + { requestURI: '/api/v1/configmaps' }, + {} + ] + }; + const wrapper = factory({ value }); + + expect((wrapper.vm.spec.filters ?? [])).toHaveLength(4); + expect((wrapper.vm.spec.filters ?? [])[0]).toStrictEqual({ action: 'allow', requestURI: '/api/v1/pods' }); + expect((wrapper.vm.spec.filters ?? [])[1]).toStrictEqual({ action: 'deny' }); + expect((wrapper.vm.spec.filters ?? [])[2]).toStrictEqual({ requestURI: '/api/v1/configmaps' }); + expect((wrapper.vm.spec.filters ?? [])[3]).toStrictEqual({}); + }); + }); + + describe('edge cases', () => { + it('should handle empty filters array', () => { + const wrapper = factory({ value: { filters: [] } }); + + expect((wrapper.vm.spec.filters ?? [])).toStrictEqual([]); + expect(wrapper.findAllComponents({ name: 'Tab' })).toHaveLength(0); + }); + + it('should handle missing filters property', () => { + const wrapper = factory({ value: {} }); + + expect((wrapper.vm.spec.filters ?? [])).toStrictEqual([]); + }); + + it('should handle updateRow with empty filters array', () => { + const wrapper = factory({ value: { filters: [] } }); + + expect(() => { + wrapper.vm.updateRow('action', 0, 'allow'); + }).not.toThrow(); + + const events = wrapper.emitted('update:value'); + + expect(events && events[0]).toBeTruthy(); + + const emitted = events && events[0] && events[0][0] as { filters: FilterRule[] }; + + expect(emitted && emitted.filters).toStrictEqual([{ action: 'allow', requestURI: '' }]); + }); + + it('should handle updateRow with out of bounds index gracefully', () => { + const value = { filters: [{ action: 'allow', requestURI: '/test' }] }; + const wrapper = factory({ value }); + + expect(() => { + wrapper.vm.updateRow('action', 5, 'deny'); // Index out of bounds + }).not.toThrow(); + }); + + it('should handle invalid filter data gracefully', () => { + const value = { + filters: [ + null, + undefined, + { action: 'invalid' }, + { requestURI: '' }, + { extraProp: 'ignored' } + ] + }; + const wrapper = factory({ value }); + + expect(wrapper.vm.spec.filters).toHaveLength(5); + expect(() => wrapper.vm.addRow('action', [{ action: 'allow', requestURI: '' }])).not.toThrow(); + }); + + it('should handle filter modifications through reactive refs', () => { + const originalFilter = { action: 'allow', requestURI: '/test' }; + const value = { filters: [originalFilter] }; + const wrapper = factory({ value }); + + // Verify the filter is accessible in the component + expect((wrapper.vm.spec.filters ?? [])[0]).toBeDefined(); + expect((wrapper.vm.spec.filters ?? [])[0]?.action).toBe('allow'); + + // Modify the filter through the component + if ((wrapper.vm.spec.filters ?? [])[0]) { + (wrapper.vm.spec.filters ?? [])[0].action = 'deny'; + } + + // Verify the component state reflects the change + expect((wrapper.vm.spec.filters ?? [])[0]?.action).toBe('deny'); + }); + }); +}); diff --git a/shell/edit/auditlog.cattle.io.auditpolicy/__tests__/General.test.ts b/shell/edit/auditlog.cattle.io.auditpolicy/__tests__/General.test.ts new file mode 100644 index 00000000000..704727a7cb7 --- /dev/null +++ b/shell/edit/auditlog.cattle.io.auditpolicy/__tests__/General.test.ts @@ -0,0 +1,472 @@ +import { shallowMount, VueWrapper } from '@vue/test-utils'; +import General from '../General.vue'; +import { ComponentPublicInstance } from 'vue'; +import { AuditPolicy } from '@shell/edit/auditlog.cattle.io.auditpolicy/types'; + +interface GeneralComponent extends ComponentPublicInstance { + spec: AuditPolicy; + levelOptionsMap: Array<{ value: number; label: string }>; +} + +// Mock the ID generation to have consistent snapshots +jest.mock('@shell/utils/string', () => ({ generateRandomAlphaString: () => 'test-id-123' })); + +const defaultProps = { + value: {}, + mode: 'create' +}; + +const globalMocks = { + global: { + mocks: { + $t: (key: string) => key, + t: (key: string) => key, + $store: { + getters: { 'i18n/t': (key: string) => key }, + dispatch: jest.fn() + }, + $route: { + params: {}, + query: {} + }, + $router: { + push: jest.fn(), + replace: jest.fn() + } + }, + provide: { + store: { + getters: { 'i18n/t': (key: string) => key }, + dispatch: jest.fn() + } + }, + stubs: { + LabeledSelect: true, + Checkbox: true + } + } +}; + +function factory(props: Record = {}, options: Record = {}): VueWrapper { + return (shallowMount(General, { + props: { ...defaultProps, ...props }, + ...globalMocks, + ...options + }) as unknown) as VueWrapper; +} + +describe('component: General', () => { + describe('rendering & initial state', () => { + it('should render with default props (snapshot)', () => { + const wrapper = factory(); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('should render with create mode', () => { + const wrapper = factory({ mode: 'create' }); + + expect(wrapper.findComponent({ name: 'Checkbox' }).exists()).toBe(true); + expect(wrapper.findComponent({ name: 'LabeledSelect' }).exists()).toBe(true); + }); + + it('should render with edit mode', () => { + const wrapper = factory({ mode: 'edit' }); + + expect(wrapper.findComponent({ name: 'Checkbox' }).exists()).toBe(true); + expect(wrapper.findComponent({ name: 'LabeledSelect' }).exists()).toBe(true); + }); + + it('should render with view mode', () => { + const wrapper = factory({ mode: 'view' }); + + expect(wrapper.findComponent({ name: 'Checkbox' }).exists()).toBe(true); + expect(wrapper.findComponent({ name: 'LabeledSelect' }).exists()).toBe(true); + }); + + it('should render with initial audit policy data', () => { + const value = { + enabled: true, + verbosity: { + level: 2, + request: { headers: true, body: false }, + response: { headers: false, body: true } + } + }; + const wrapper = factory({ value }); + + expect(wrapper.vm.spec.enabled).toBe(true); + expect(wrapper.vm.spec.verbosity?.level).toBe(2); + expect(wrapper.vm.spec.verbosity?.request?.headers).toBe(true); + expect(wrapper.vm.spec.verbosity?.response?.body).toBe(true); + }); + }); + + describe('props & state changes', () => { + it('should handle empty value prop gracefully', () => { + const wrapper = factory({ value: {} }); + + expect(wrapper.vm.spec.enabled).toBe(false); + expect(wrapper.vm.spec.verbosity?.level).toBe(0); + expect(wrapper.vm.spec.verbosity?.request?.headers).toBe(false); + expect(wrapper.vm.spec.verbosity?.response?.headers).toBe(false); + }); + + it('should handle null value prop gracefully', () => { + const wrapper = factory({ value: null }); + + expect(wrapper.vm.spec.enabled).toBe(false); + expect(wrapper.vm.spec.verbosity?.level).toBe(0); + }); + + it('should accept different mode prop values', () => { + const createWrapper = factory({ mode: 'create' }); + const editWrapper = factory({ mode: 'edit' }); + const viewWrapper = factory({ mode: 'view' }); + + expect((createWrapper.props() as any).mode).toBe('create'); + expect((editWrapper.props() as any).mode).toBe('edit'); + expect((viewWrapper.props() as any).mode).toBe('view'); + }); + + it('should merge defaults with provided value', () => { + const value = { enabled: true }; + const wrapper = factory({ value }); + + expect(wrapper.vm.spec.enabled).toBe(true); + expect(wrapper.vm.spec.verbosity?.level).toBe(0); + expect(wrapper.vm.spec.verbosity?.request).toBeDefined(); + expect(wrapper.vm.spec.verbosity?.response).toBeDefined(); + }); + + it('should handle missing verbosity request object', () => { + const value = { + enabled: true, + verbosity: { level: 1 } + }; + const wrapper = factory({ value }); + + expect(wrapper.vm.spec.verbosity?.request?.headers).toBe(false); + expect(wrapper.vm.spec.verbosity?.request?.body).toBe(false); + }); + + it('should handle missing verbosity response object', () => { + const value = { + enabled: true, + verbosity: { level: 1 } + }; + const wrapper = factory({ value }); + + expect(wrapper.vm.spec.verbosity?.response?.headers).toBe(false); + expect(wrapper.vm.spec.verbosity?.response?.body).toBe(false); + }); + }); + + describe('user interaction', () => { + it('should emit update:value when enabled changes', async() => { + const wrapper = factory(); + + wrapper.vm.spec.enabled = true; + await wrapper.vm.$nextTick(); + + expect(wrapper.emitted('update:value')).toHaveLength(2); + expect((wrapper.emitted('update:value')?.[1]?.[0] as any).enabled).toBe(true); + }); + + it('should emit update:value when verbosity level changes', async() => { + const wrapper = factory(); + + wrapper.vm.spec.verbosity!.level = 2; + await wrapper.vm.$nextTick(); + + expect(wrapper.emitted('update:value')).toHaveLength(2); + expect((wrapper.emitted('update:value')?.[1]?.[0] as any).verbosity.level).toBe(2); + }); + + it('should emit update:value when request headers changes', async() => { + const wrapper = factory(); + + wrapper.vm.spec.verbosity!.request!.headers = true; + await wrapper.vm.$nextTick(); + + expect(wrapper.emitted('update:value')).toHaveLength(2); + expect((wrapper.emitted('update:value')?.[1]?.[0] as any).verbosity.request.headers).toBe(true); + }); + + it('should emit update:value when response body changes', async() => { + const wrapper = factory(); + + wrapper.vm.spec.verbosity!.response!.body = true; + await wrapper.vm.$nextTick(); + + expect(wrapper.emitted('update:value')).toHaveLength(2); + expect((wrapper.emitted('update:value')?.[1]?.[0] as any).verbosity.response.body).toBe(true); + }); + + it('should preserve existing prop values when emitting updates', async() => { + const value = { customField: 'test', enabled: false }; + const wrapper = factory({ value }); + + wrapper.vm.spec.enabled = true; + await wrapper.vm.$nextTick(); + + const emittedValue = (wrapper.emitted('update:value')?.[1]?.[0] as any); + + expect(emittedValue.customField).toBe('test'); + expect(emittedValue.enabled).toBe(true); + }); + + it('should emit complete verbosity object with all properties', async() => { + const wrapper = factory(); + + wrapper.vm.spec.verbosity!.level = 3; + wrapper.vm.spec.verbosity!.request!.headers = true; + wrapper.vm.spec.verbosity!.response!.body = true; + await wrapper.vm.$nextTick(); + + const emittedValue = (wrapper.emitted('update:value')?.[0]?.[0] as any); + + expect(emittedValue.verbosity).toStrictEqual({ + level: 3, + request: { headers: true, body: false }, + response: { headers: false, body: true } + }); + }); + }); + + describe('computed properties & logic', () => { + it('should return correct levelOptionsMap values', () => { + const wrapper = factory(); + + expect(wrapper.vm.levelOptionsMap).toHaveLength(4); + expect(wrapper.vm.levelOptionsMap[0]).toStrictEqual({ + value: 0, + label: 'auditPolicy.general.verbosity.level.0' + }); + expect(wrapper.vm.levelOptionsMap[3]).toStrictEqual({ + value: 3, + label: 'auditPolicy.general.verbosity.level.3' + }); + }); + + it('should have reactive levelOptionsMap computed property', async() => { + const wrapper = factory(); + const initialOptions = wrapper.vm.levelOptionsMap; + + expect(wrapper.vm.levelOptionsMap).toStrictEqual(initialOptions); + }); + + it('should initialize spec reactive ref correctly', () => { + const wrapper = factory(); + + expect(wrapper.vm.spec).toBeDefined(); + expect(wrapper.vm.spec.enabled).toBe(false); + expect(wrapper.vm.spec.verbosity?.level).toBe(0); + }); + + it('should merge defaults with props correctly in spec', () => { + const value = { enabled: true, verbosity: { level: 2 } }; + const wrapper = factory({ value }); + + expect(wrapper.vm.spec.enabled).toBe(true); + expect(wrapper.vm.spec.verbosity?.level).toBe(2); + expect(wrapper.vm.spec.verbosity?.request?.headers).toBe(false); + expect(wrapper.vm.spec.verbosity?.response?.body).toBe(false); + }); + }); + + describe('component configuration', () => { + it('should configure Checkbox components with correct props', () => { + const wrapper = factory({ mode: 'edit' }); + const checkboxes = wrapper.findAllComponents({ name: 'Checkbox' }); + + expect(checkboxes.length).toBeGreaterThan(0); + }); + + it('should render LabeledSelect component', () => { + const wrapper = factory({ mode: 'view' }); + const select = wrapper.findComponent({ name: 'LabeledSelect' }); + + expect(select.exists()).toBe(true); + // With stub components, we can't test actual props + expect(wrapper.vm.levelOptionsMap).toHaveLength(4); + }); + + it('should maintain verbosity structure when level is valid', async() => { + const wrapper = factory(); + + wrapper.vm.spec.verbosity!.level = 1; + await wrapper.vm.$nextTick(); + + const emittedValue = (wrapper.emitted('update:value')?.[0]?.[0] as any); + + expect(emittedValue.verbosity).toBeDefined(); + expect(emittedValue.verbosity.level).toBe(1); + expect(emittedValue.verbosity.request).toBeDefined(); + expect(emittedValue.verbosity.response).toBeDefined(); + }); + + it('should render form structure correctly', () => { + const wrapper = factory(); + + expect(wrapper.find('.row').exists()).toBe(true); + expect(wrapper.find('fieldset').exists()).toBe(true); + expect(wrapper.find('.spacer').exists()).toBe(true); + }); + }); + + describe('verbosity data structure', () => { + it('should handle verbosity with level 0', () => { + const wrapper = factory(); + + // Level 0 is the default, so check the initial state + expect(wrapper.vm.spec.verbosity?.level).toBe(0); + }); + + it('should handle verbosity with level 3', async() => { + const wrapper = factory(); + + wrapper.vm.spec.verbosity!.level = 3; + await wrapper.vm.$nextTick(); + + const emittedValue = (wrapper.emitted('update:value')?.[0]?.[0] as any); + + expect(emittedValue.verbosity.level).toBe(3); + }); + + it('should handle all request options', async() => { + const wrapper = factory(); + + wrapper.vm.spec.verbosity!.request!.headers = true; + wrapper.vm.spec.verbosity!.request!.body = true; + await wrapper.vm.$nextTick(); + + const emittedValue = (wrapper.emitted('update:value')?.[0]?.[0] as any); + + expect(emittedValue.verbosity.request.headers).toBe(true); + expect(emittedValue.verbosity.request.body).toBe(true); + }); + + it('should handle all response options', async() => { + const wrapper = factory(); + + wrapper.vm.spec.verbosity!.response!.headers = true; + wrapper.vm.spec.verbosity!.response!.body = true; + await wrapper.vm.$nextTick(); + + const emittedValue = (wrapper.emitted('update:value')?.[0]?.[0] as any); + + expect(emittedValue.verbosity.response.headers).toBe(true); + expect(emittedValue.verbosity.response.body).toBe(true); + }); + + it('should handle complete audit policy configuration', async() => { + const wrapper = factory(); + + wrapper.vm.spec.enabled = true; + wrapper.vm.spec.verbosity!.level = 2; + wrapper.vm.spec.verbosity!.request!.headers = true; + wrapper.vm.spec.verbosity!.response!.body = true; + await wrapper.vm.$nextTick(); + + const emittedValue = (wrapper.emitted('update:value')?.[1]?.[0] as any); + + expect(emittedValue).toStrictEqual({ + enabled: true, + verbosity: { + level: 2, + request: { headers: true, body: false }, + response: { headers: false, body: true } + } + }); + }); + }); + + describe('edge cases', () => { + it('should handle missing verbosity object entirely', () => { + const value = { enabled: true }; + const wrapper = factory({ value }); + + expect(wrapper.vm.spec.verbosity).toBeDefined(); + expect(wrapper.vm.spec.verbosity?.level).toBe(0); + expect(wrapper.vm.spec.verbosity?.request).toBeDefined(); + expect(wrapper.vm.spec.verbosity?.response).toBeDefined(); + }); + + it('should handle undefined enabled property', () => { + const value = { verbosity: { level: 1 } }; + const wrapper = factory({ value }); + + expect(wrapper.vm.spec.enabled).toBe(false); + }); + + it('should handle partial verbosity configuration', () => { + const value = { + verbosity: { + level: 1, + request: { headers: true } + } + }; + const wrapper = factory({ value }); + + expect(wrapper.vm.spec.verbosity?.level).toBe(1); + expect(wrapper.vm.spec.verbosity?.request?.headers).toBe(true); + // The component doesn't fill in missing properties for existing objects + expect(wrapper.vm.spec.verbosity?.request?.body).toBe(false); + expect(wrapper.vm.spec.verbosity?.response).toBeDefined(); + expect(wrapper.vm.spec.verbosity?.response?.headers).toBe(false); + expect(wrapper.vm.spec.verbosity?.response?.body).toBe(false); + }); + + it('should handle invalid verbosity data gracefully', () => { + // Use a more realistic invalid case - missing nested properties + const value = { verbosity: { level: 1 } }; // Missing request/response objects + const wrapper = factory({ value }); + + expect(wrapper.vm.spec.verbosity).toBeDefined(); + expect(wrapper.vm.spec.verbosity?.level).toBe(1); + expect(wrapper.vm.spec.verbosity?.request).toBeDefined(); + expect(wrapper.vm.spec.verbosity?.response).toBeDefined(); + }); + + it('should handle deep reactivity correctly', async() => { + const wrapper = factory(); + + // Initialization should emit the first update + expect(wrapper.emitted('update:value')).toHaveLength(1); + + // Make multiple nested changes + wrapper.vm.spec.verbosity!.request!.headers = true; + wrapper.vm.spec.verbosity!.request!.body = true; + wrapper.vm.spec.verbosity!.response!.headers = true; + await wrapper.vm.$nextTick(); + + // Second emission after changes, only one additional one + expect(wrapper.emitted('update:value')).toHaveLength(2); + + const emittedValue = (wrapper.emitted('update:value')?.[0]?.[0] as any); + + expect(emittedValue.verbosity.request.headers).toBe(true); + expect(emittedValue.verbosity.request.body).toBe(true); + expect(emittedValue.verbosity.response.headers).toBe(true); + }); + + it('should maintain object structure after verbosity restoration', async() => { + const wrapper = factory(); + + // Set level to null to trigger deletion + wrapper.vm.spec.verbosity!.level = null as any; + await wrapper.vm.$nextTick(); + + // Set level back to valid value + wrapper.vm.spec.verbosity!.level = 1; + await wrapper.vm.$nextTick(); + + const emittedValue = (wrapper.emitted('update:value')?.[1]?.[0] as any); + + expect(emittedValue.verbosity).toBeDefined(); + expect(emittedValue.verbosity.request).toBeDefined(); + expect(emittedValue.verbosity.response).toBeDefined(); + }); + }); +}); diff --git a/shell/edit/auditlog.cattle.io.auditpolicy/__tests__/__snapshots__/AdditionalRedactions.test.ts.snap b/shell/edit/auditlog.cattle.io.auditpolicy/__tests__/__snapshots__/AdditionalRedactions.test.ts.snap new file mode 100644 index 00000000000..395f81b1f38 --- /dev/null +++ b/shell/edit/auditlog.cattle.io.auditpolicy/__tests__/__snapshots__/AdditionalRedactions.test.ts.snap @@ -0,0 +1,26 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`component: AdditionalRedactions rendering & initial state should render with default props (snapshot) 1`] = ` + + + + + + + +`; diff --git a/shell/edit/auditlog.cattle.io.auditpolicy/__tests__/__snapshots__/Filters.test.ts.snap b/shell/edit/auditlog.cattle.io.auditpolicy/__tests__/__snapshots__/Filters.test.ts.snap new file mode 100644 index 00000000000..10cc08da3c7 --- /dev/null +++ b/shell/edit/auditlog.cattle.io.auditpolicy/__tests__/__snapshots__/Filters.test.ts.snap @@ -0,0 +1,39 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`component: Filters rendering & initial state should render with default props (snapshot) 1`] = ` + + + + + + + +`; diff --git a/shell/edit/auditlog.cattle.io.auditpolicy/__tests__/__snapshots__/General.test.ts.snap b/shell/edit/auditlog.cattle.io.auditpolicy/__tests__/__snapshots__/General.test.ts.snap new file mode 100644 index 00000000000..19852ff0171 --- /dev/null +++ b/shell/edit/auditlog.cattle.io.auditpolicy/__tests__/__snapshots__/General.test.ts.snap @@ -0,0 +1,160 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`component: General rendering & initial state should render with default props (snapshot) 1`] = ` + + + + + + auditPolicy.general.enabled.title + + + + + + + + + + + auditPolicy.general.verbosity.title + + + auditPolicy.general.verbosity.level.title + + + + + + + + + + + + auditPolicy.general.verbosity.request.title + + + + + + + + + + + auditPolicy.general.verbosity.response.title + + + + + + + + + + + + + +`; diff --git a/shell/edit/auditlog.cattle.io.auditpolicy/__tests__/__snapshots__/index.test.ts.snap b/shell/edit/auditlog.cattle.io.auditpolicy/__tests__/__snapshots__/index.test.ts.snap new file mode 100644 index 00000000000..3df4c459b12 --- /dev/null +++ b/shell/edit/auditlog.cattle.io.auditpolicy/__tests__/__snapshots__/index.test.ts.snap @@ -0,0 +1,29 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`component: CRUAuditPolicy (index) rendering & initial state should render with default props (snapshot) 1`] = ` + + + + + + +`; diff --git a/shell/edit/auditlog.cattle.io.auditpolicy/__tests__/index.test.ts b/shell/edit/auditlog.cattle.io.auditpolicy/__tests__/index.test.ts new file mode 100644 index 00000000000..b9bd44f1b8f --- /dev/null +++ b/shell/edit/auditlog.cattle.io.auditpolicy/__tests__/index.test.ts @@ -0,0 +1,215 @@ +import { shallowMount, VueWrapper } from '@vue/test-utils'; +import CRUAuditPolicy from '../index.vue'; +import { ComponentPublicInstance } from 'vue'; + +// Mock the ID generation to have consistent snapshots +jest.mock('@shell/utils/string', () => ({ generateRandomAlphaString: () => 'test-id-123' })); + +// Type definitions for component +interface AuditPolicyComponent extends ComponentPublicInstance { + mode: string; + value: Record; +} + +const defaultProps = { + value: { + id: 'test-policy', + type: 'auditlog.cattle.io.auditpolicy', + metadata: { name: 'test-policy' }, + spec: { enabled: false } + }, + mode: 'create' +}; + +const globalMocks = { + global: { + mocks: { + $t: (key: string) => key, + t: (key: string) => key, + $store: { + getters: { + 'i18n/t': (key: string) => key, + currentStore: () => 'cluster', + 'cluster/schemaFor': () => ({ + attributes: { namespaced: true }, + id: 'auditlog.cattle.io.auditpolicy' + }) + }, + dispatch: jest.fn() + }, + $route: { + params: {}, + query: {} + }, + $router: { + push: jest.fn(), + replace: jest.fn() + }, + $fetchState: { pending: false } + }, + provide: { + store: { + getters: { 'i18n/t': (key: string) => key }, + dispatch: jest.fn() + } + }, + stubs: { + Loading: true, + CruResource: true, + NameNsDescription: true, + Error: true, + Tabbed: true, + Tab: true, + General: true, + Filters: true, + AdditionalRedactions: true, + Labels: true + } + } +}; + +function factory(props: Record = {}, options: Record = {}): VueWrapper { + return shallowMount(CRUAuditPolicy, { + props: { ...defaultProps, ...props }, + ...globalMocks, + global: { + ...globalMocks.global, + // Prevent directive conflicts by using shallow mounting without plugins + plugins: [], + ...(options.global || {}) + }, + ...options + }) as VueWrapper; +} + +describe('component: CRUAuditPolicy (index)', () => { + describe('rendering & initial state', () => { + it('should render with default props (snapshot)', () => { + const wrapper = factory(); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('should mount successfully', () => { + const wrapper = factory(); + + expect(wrapper.exists()).toBe(true); + expect(wrapper.vm).toBeDefined(); + }); + + it('should render with different modes', () => { + const modes = ['create', 'edit', 'view']; + + modes.forEach((mode) => { + const wrapper = factory({ mode }); + + expect(wrapper.exists()).toBe(true); + expect(wrapper.vm.mode).toBe(mode); + }); + }); + }); + + describe('component initialization', () => { + it('should initialize with provided value', () => { + const wrapper = factory(); + + expect(wrapper.vm.value).toBeDefined(); + expect(wrapper.vm.value.spec).toBeDefined(); + }); + + it('should handle different value configurations', () => { + const customValue = { + id: 'custom-policy', + type: 'auditlog.cattle.io.auditpolicy', + metadata: { name: 'custom' }, + spec: { enabled: true } + }; + const wrapper = factory({ value: customValue }); + + expect(wrapper.vm.value.id).toBe('custom-policy'); + expect(wrapper.vm.value.spec.enabled).toBe(true); + }); + + it('should handle spec initialization lifecycle', () => { + const valueWithoutSpec = { + ...defaultProps.value, + spec: undefined + }; + // The component created() hook should initialize spec when missing + const wrapper = factory({ value: valueWithoutSpec }); + + // After mounting, spec should be initialized + expect(wrapper.vm.value.spec).toBeDefined(); + }); + }); + + describe('component structure', () => { + it('should have the correct component name', () => { + const wrapper = factory(); + + expect(wrapper.vm.$options.name).toBe('CRUAuditPolicy'); + }); + + it('should use CreateEditView and FormValidation mixins', () => { + const wrapper = factory(); + + // Check that mixins are applied by testing for their properties/methods + expect(typeof wrapper.vm.mode).toBe('string'); + expect(wrapper.vm.value).toBeDefined(); + }); + + it('should render main template elements', () => { + const wrapper = factory(); + + expect(wrapper.html()).toContain('cru-resource-stub'); + expect(wrapper.findComponent({ name: 'CruResource' })).toBeTruthy(); + }); + }); + + describe('props and configuration', () => { + it('should handle different modes correctly', () => { + const modes = ['create', 'edit', 'view']; + + modes.forEach((mode) => { + const wrapper = factory({ mode }); + + expect(wrapper.vm.mode).toBe(mode); + }); + }); + + it('should handle different value objects', () => { + const customValue = { + id: 'test-policy', + type: 'auditlog.cattle.io.auditpolicy', + metadata: { name: 'test' }, + spec: { enabled: true, verbosity: { level: 2 } } + }; + const wrapper = factory({ value: customValue }); + + expect(wrapper.vm.value.spec.enabled).toBe(true); + expect(wrapper.vm.value.spec.verbosity.level).toBe(2); + }); + }); + + describe('edge cases', () => { + it('should handle empty value object', () => { + const wrapper = factory({ value: {} }); + + // The created() hook initializes spec, so empty object gets spec added + expect(wrapper.vm.value).toStrictEqual({ spec: { enabled: false } }); + }); + + it('should handle component updates', async() => { + const wrapper = factory(); + + await wrapper.setProps({ + value: { + ...defaultProps.value, + spec: { enabled: true } + } + }); + + expect(wrapper.vm.value.spec.enabled).toBe(true); + }); + }); +}); diff --git a/shell/edit/auditlog.cattle.io.auditpolicy/index.vue b/shell/edit/auditlog.cattle.io.auditpolicy/index.vue new file mode 100644 index 00000000000..c203c18d808 --- /dev/null +++ b/shell/edit/auditlog.cattle.io.auditpolicy/index.vue @@ -0,0 +1,117 @@ + + + + + errors = e" + @finish="save" + @cancel="done" + > + + + + + + + + + + + + + + + + + + diff --git a/shell/edit/auditlog.cattle.io.auditpolicy/types.ts b/shell/edit/auditlog.cattle.io.auditpolicy/types.ts new file mode 100644 index 00000000000..6524a7dbe35 --- /dev/null +++ b/shell/edit/auditlog.cattle.io.auditpolicy/types.ts @@ -0,0 +1,28 @@ +// Types & Interfaces +export interface FilterRule { + action: string; + requestURI: string; +} + +export interface RedactionRule { + headers?: string[]; + paths?: string[]; +} + +export interface VerbosityDetails { + headers?: boolean; + body?: boolean; +} + +export interface Verbosity { + level: number; + request?: VerbosityDetails; + response?: VerbosityDetails; +} + +export interface AuditPolicy { + enabled?: boolean; + verbosity?: Verbosity; + filters?: FilterRule[]; + additionalRedactions?: RedactionRule[]; +} diff --git a/shell/list/auditlog.cattle.io.auditpolicy.vue b/shell/list/auditlog.cattle.io.auditpolicy.vue new file mode 100644 index 00000000000..dc2c31b4bbb --- /dev/null +++ b/shell/list/auditlog.cattle.io.auditpolicy.vue @@ -0,0 +1,63 @@ + + + + + + + + + diff --git a/shell/models/__tests__/auditlog.cattle.io.auditpolicy.test.ts b/shell/models/__tests__/auditlog.cattle.io.auditpolicy.test.ts new file mode 100644 index 00000000000..928f21d9746 --- /dev/null +++ b/shell/models/__tests__/auditlog.cattle.io.auditpolicy.test.ts @@ -0,0 +1,117 @@ +import AuditPolicy from '@shell/models/auditlog.cattle.io.auditpolicy'; + +describe('auditPolicy Model', () => { + let mockDispatch: jest.Mock; + let mockT: jest.Mock; + let auditPolicy: any; + + beforeEach(() => { + mockDispatch = jest.fn(); + mockT = jest.fn(); + + const mockResource = { + id: 'test-policy', + spec: { enabled: false }, + metadata: { name: 'test-policy' } + }; + + auditPolicy = new AuditPolicy(mockResource, { + dispatch: mockDispatch, + rootGetters: { 'i18n/t': mockT }, + getters: { schemaFor: () => ({ linkFor: jest.fn() }) } + }); + }); + + describe('enable method', () => { + it('should call enableOrDisable with "enable"', () => { + const spy = jest.spyOn(auditPolicy, 'enableOrDisable').mockImplementation(); + + auditPolicy.enable(); + + expect(spy).toHaveBeenCalledWith('enable'); + }); + }); + + describe('disable method', () => { + it('should call enableOrDisable with "disable"', () => { + const spy = jest.spyOn(auditPolicy, 'enableOrDisable').mockImplementation(); + + auditPolicy.disable(); + + expect(spy).toHaveBeenCalledWith('disable'); + }); + }); + + describe('enableOrDisable method', () => { + let mockClone: any; + + beforeEach(() => { + mockClone = { + spec: { enabled: false }, + save: jest.fn() + }; + + mockDispatch.mockImplementation((action: string) => { + if (action === 'rancher/clone') { + return Promise.resolve(mockClone); + } + + return Promise.resolve(); + }); + }); + + it('should enable policy when flag is "enable"', async() => { + mockClone.save.mockResolvedValue({}); + + await auditPolicy.enableOrDisable('enable'); + + expect(mockClone.spec.enabled).toBe(true); + expect(mockClone.save).toHaveBeenCalledWith(); + }); + + it('should disable policy when flag is "disable"', async() => { + mockClone.save.mockResolvedValue({}); + + await auditPolicy.enableOrDisable('disable'); + + expect(mockClone.spec.enabled).toBe(false); + expect(mockClone.save).toHaveBeenCalledWith(); + }); + + it('should handle save errors and show growl notification', async() => { + const saveError = new Error('Save failed'); + + mockClone.save.mockRejectedValue(saveError); + mockT.mockReturnValue('Error when enabling - test-policy'); + + await auditPolicy.enableOrDisable('enable'); + + expect(mockDispatch).toHaveBeenCalledWith('growl/fromError', { + title: 'Error when enabling - test-policy', + err: saveError, + timeout: 5000 + }, { root: true }); + }); + + it('should call translation with correct parameters', async() => { + const saveError = new Error('Save failed'); + + mockClone.save.mockRejectedValue(saveError); + + await auditPolicy.enableOrDisable('enable'); + + expect(mockT).toHaveBeenCalledWith('auditPolicy.error.enableOrDisable', { + flag: 'enable', + id: 'test-policy' + }); + }); + + it('should dispatch rancher/clone with correct parameters', async() => { + mockClone.save.mockResolvedValue({}); + + await auditPolicy.enableOrDisable('enable'); + + expect(mockDispatch).toHaveBeenCalledWith('rancher/clone', { resource: auditPolicy }, { root: true }); + }); + }); +}); diff --git a/shell/models/auditlog.cattle.io.auditpolicy.js b/shell/models/auditlog.cattle.io.auditpolicy.js new file mode 100644 index 00000000000..213a1249b2e --- /dev/null +++ b/shell/models/auditlog.cattle.io.auditpolicy.js @@ -0,0 +1,46 @@ +import { insertAt } from '@shell/utils/array'; +import SteveModel from '@shell/plugins/steve/steve-class'; + +export default class AuditPolicy extends SteveModel { + get _availableActions() { + const out = super._availableActions; + + insertAt(out, 0, { + action: 'enable', + label: this.t('auditPolicy.action.enable'), + icon: 'icon icon-play', + enabled: (this.canEdit || this.canEditYaml) && !this.spec.enabled, + bulkable: true, + weight: 2, + }); + insertAt(out, 0, { + action: 'disable', + label: this.t('auditPolicy.action.disable'), + icon: 'icon icon-pause', + enabled: (this.canEdit || this.canEditYaml) && this.spec.enabled, + bulkable: true, + weight: 1, + }); + + return out; + } + + enable() { + this.enableOrDisable('enable'); + } + + disable() { + this.enableOrDisable('disable'); + } + + async enableOrDisable(flag) { + const clone = await this.$dispatch('rancher/clone', { resource: this }, { root: true }); + + clone.spec.enabled = flag === 'enable'; + await clone.save().catch((err) => { + this.$dispatch('growl/fromError', { + title: this.t('auditPolicy.error.enableOrDisable', { flag, id: this.id }), err, timeout: 5000 + }, { root: true }); + }); + } +} diff --git a/shell/plugins/steve/__tests__/steve-class.test.ts b/shell/plugins/steve/__tests__/steve-class.test.ts index 8121dd74fd6..26dbc6f7d66 100644 --- a/shell/plugins/steve/__tests__/steve-class.test.ts +++ b/shell/plugins/steve/__tests__/steve-class.test.ts @@ -56,5 +56,172 @@ describe('class: Steve', () => { expect({ ...steve }).toStrictEqual(customResource); }); }); + + describe('method: processSaveResponse', () => { + it('should call parent processSaveResponse', () => { + const mockDispatch = jest.fn(); + const mockRootGetters = { 'i18n/t': jest.fn().mockReturnValue('Resource created: test-id') }; + const steve = new Steve(customResource, { + getters: { schemaFor: () => ({ linkFor: jest.fn() }) }, + dispatch: mockDispatch, + rootGetters: mockRootGetters, + }); + + // Mock the parent processSaveResponse method + const parentProcessSaveResponse = jest.spyOn(Object.getPrototypeOf(Object.getPrototypeOf(steve)), 'processSaveResponse'); + + const response = { _status: 200 }; + + steve.processSaveResponse(response); + + expect(parentProcessSaveResponse).toHaveBeenCalledWith(response); + }); + + it('should show growl notification for autogenerated names on 201 status', () => { + const mockDispatch = jest.fn(); + const mockT = jest.fn() + .mockReturnValueOnce('CustomResourceDefinition') + .mockReturnValueOnce('CustomResourceDefinition created') + .mockReturnValueOnce('Resource test-generated-abc123 created successfully'); + const mockRootGetters = { 'i18n/t': mockT }; + const steve = new Steve(customResource, { + getters: { schemaFor: () => ({ linkFor: jest.fn() }) }, + dispatch: mockDispatch, + rootGetters: mockRootGetters, + }); + + const response = { + _status: 201, + metadata: { generateName: 'test-generated-' }, + id: 'default/test-generated-abc123' + }; + + steve.processSaveResponse(response); + + expect(mockT).toHaveBeenCalledWith(`typeLabel."${ customResource.type }"`, { count: 1 }); + expect(mockT).toHaveBeenCalledWith('generic.autogeneratedCreated.title', { resource: 'CustomResourceDefinition' }); + expect(mockT).toHaveBeenCalledWith('generic.autogeneratedCreated.message', { id: 'test-generated-abc123' }); + expect(mockDispatch).toHaveBeenCalledWith( + 'growl/success', + { + title: 'CustomResourceDefinition created', + message: 'Resource test-generated-abc123 created successfully', + timeout: 3000 + }, + { root: true } + ); + }); + + it('should show growl notification for autogenerated names without namespace', () => { + const mockDispatch = jest.fn(); + const mockT = jest.fn() + .mockReturnValueOnce('CustomResourceDefinition') + .mockReturnValueOnce('CustomResourceDefinition created') + .mockReturnValueOnce('Resource simple-id created successfully'); + const mockRootGetters = { 'i18n/t': mockT }; + const steve = new Steve(customResource, { + getters: { schemaFor: () => ({ linkFor: jest.fn() }) }, + dispatch: mockDispatch, + rootGetters: mockRootGetters, + }); + + const response = { + _status: 201, + metadata: { generateName: 'simple-' }, + id: 'simple-id' + }; + + steve.processSaveResponse(response); + + expect(mockT).toHaveBeenCalledWith(`typeLabel."${ customResource.type }"`, { count: 1 }); + expect(mockT).toHaveBeenCalledWith('generic.autogeneratedCreated.title', { resource: 'CustomResourceDefinition' }); + expect(mockT).toHaveBeenCalledWith('generic.autogeneratedCreated.message', { id: 'simple-id' }); + expect(mockDispatch).toHaveBeenCalledWith( + 'growl/success', + { + title: 'CustomResourceDefinition created', + message: 'Resource simple-id created successfully', + timeout: 3000 + }, + { root: true } + ); + }); + + it('should not show growl notification for non-201 status', () => { + const mockDispatch = jest.fn(); + const mockT = jest.fn(); + const mockRootGetters = { 'i18n/t': mockT }; + const steve = new Steve(customResource, { + getters: { schemaFor: () => ({ linkFor: jest.fn() }) }, + dispatch: mockDispatch, + rootGetters: mockRootGetters, + }); + + const response = { + _status: 200, + metadata: { generateName: 'test-generated-' }, + id: 'default/test-generated-abc123' + }; + + steve.processSaveResponse(response); + + expect(mockT).not.toHaveBeenCalled(); + expect(mockDispatch).not.toHaveBeenCalledWith( + 'growl/success', + expect.any(Object), + { root: true } + ); + }); + + it('should not show growl notification without generateName', () => { + const mockDispatch = jest.fn(); + const mockT = jest.fn(); + const mockRootGetters = { 'i18n/t': mockT }; + const steve = new Steve(customResource, { + getters: { schemaFor: () => ({ linkFor: jest.fn() }) }, + dispatch: mockDispatch, + rootGetters: mockRootGetters, + }); + + const response = { + _status: 201, + id: 'default/test-regular-name' + }; + + steve.processSaveResponse(response); + + expect(mockT).not.toHaveBeenCalled(); + expect(mockDispatch).not.toHaveBeenCalledWith( + 'growl/success', + expect.any(Object), + { root: true } + ); + }); + + it('should not show growl notification without id', () => { + const mockDispatch = jest.fn(); + const mockT = jest.fn(); + const mockRootGetters = { 'i18n/t': mockT }; + const steve = new Steve(customResource, { + getters: { schemaFor: () => ({ linkFor: jest.fn() }) }, + dispatch: mockDispatch, + rootGetters: mockRootGetters, + }); + + const response = { + _status: 201, + metadata: { generateName: 'test-generated-' } + }; + + steve.processSaveResponse(response); + + expect(mockT).not.toHaveBeenCalled(); + expect(mockDispatch).not.toHaveBeenCalledWith( + 'growl/success', + expect.any(Object), + { root: true } + ); + }); + }); }); }); diff --git a/shell/plugins/steve/steve-class.js b/shell/plugins/steve/steve-class.js index 5452b6ed6f3..26b1af04fc1 100644 --- a/shell/plugins/steve/steve-class.js +++ b/shell/plugins/steve/steve-class.js @@ -63,4 +63,23 @@ export default class SteveModel extends HybridModel { paginationEnabled() { return this.$getters['paginationEnabled'](this.type); } + + processSaveResponse(res) { + super.processSaveResponse(res); + + // Conidtionally show the growl for autogenerated names + if (res && res._status === 201 && res.metadata?.generateName && res.id) { + // Split to remove the namespace if present (default/generated-xxx) + const nameOnly = res.id.split('/').pop(); + + // Avoid showing the growl without the ID. + if (nameOnly.length > 0) { + this.$dispatch('growl/success', { + title: this.t('generic.autogeneratedCreated.title', { resource: this.t(`typeLabel."${ this.type }"`, { count: 1 }) }), + message: this.t('generic.autogeneratedCreated.message', { id: nameOnly }), + timeout: 3000 + }, { root: true }); + } + } + } }