diff --git a/src/components/agents/config/AgentConfigEffectiveView.vue b/src/components/agents/config/AgentConfigEffectiveView.vue new file mode 100644 index 00000000..b5b24eda --- /dev/null +++ b/src/components/agents/config/AgentConfigEffectiveView.vue @@ -0,0 +1,190 @@ + + + diff --git a/src/components/agents/config/PluginTabs.vue b/src/components/agents/config/PluginTabs.vue new file mode 100644 index 00000000..b3027d4b --- /dev/null +++ b/src/components/agents/config/PluginTabs.vue @@ -0,0 +1,278 @@ + + + diff --git a/src/components/agents/config/__tests__/AgentConfigEffectiveView.spec.ts b/src/components/agents/config/__tests__/AgentConfigEffectiveView.spec.ts new file mode 100644 index 00000000..1f2ddd39 --- /dev/null +++ b/src/components/agents/config/__tests__/AgentConfigEffectiveView.spec.ts @@ -0,0 +1,146 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { mount, enableAutoUnmount } from '@vue/test-utils'; +import type { ConfigDoc, OverlayDoc } from '@/types/agent-config'; +import { baseConfig } from '@/composables/agent-config/__tests__/fixtures'; +import { mergePatch } from '@/utils/agent-config/merge-patch'; +import AgentConfigEffectiveView from '../AgentConfigEffectiveView.vue'; +import { globalWith, piniaWith, READER } from './helpers'; + +// PrimeVue's TabList schedules a 150 ms ink-bar update on mount and never clears it; a wrapper +// left mounted lets it fire after this file's jsdom environment is torn down +// ("HTMLElement is not defined"). Unmounting nulls its refs, so the timer becomes a no-op. +enableAutoUnmount(afterEach); + +function mountView( + overlay: OverlayDoc, + effectiveOverride?: ConfigDoc, + note: number | null = null, +) { + const effective = + effectiveOverride ?? mergePatch(baseConfig, overlay); + return mount(AgentConfigEffectiveView, { + props: { + effectiveDoc: effective, + base: baseConfig, + appliedOverlay: overlay, + appliedRevisionNote: note, + filename: 'a-effective-r7.yaml', + }, + global: globalWith(piniaWith(READER)), + }); +} + +describe('AgentConfigEffectiveView', () => { + it('renders provenance badges for file / overlay / overrides / removed', () => { + const wrapper = mountView({ + verbosity: 2, + plugins: { + 'ubuntu-packages': null, + extra: { source: 'ghcr.io/compliance-framework/extra:v1' }, + 'local-ssh': { schedule: '@hourly' }, + }, + }); + const ssh = wrapper.find('[data-test="plugin-card-local-ssh"]'); + expect(ssh.find('[data-provenance="overrides-file"]').exists()).toBe(true); + const extra = wrapper.find('[data-test="plugin-card-extra"]'); + expect(extra.find('[data-provenance="overlay"]').exists()).toBe(true); + const removed = wrapper.find('[data-test="plugin-card-ubuntu-packages"]'); + expect(removed.text()).toContain('Removed by overlay'); + expect( + removed.find('[data-provenance="removed-by-overlay"]').exists(), + ).toBe(true); + const flags = wrapper.find('[data-test="flags-summary"]'); + expect(flags.text()).toContain('Trace'); + expect(flags.find('[data-provenance="overrides-file"]').exists()).toBe( + true, + ); + }); + + it('shows plain file provenance when the applied overlay is empty, and the pending note', () => { + const wrapper = mountView({}, undefined, 6); + expect( + wrapper + .find('[data-test="plugin-card-local-ssh"] [data-provenance="file"]') + .exists(), + ).toBe(true); + expect(wrapper.find('[data-test="provenance-note"]').text()).toContain( + 'r6', + ); + }); + + it('shows the forbidden keys locked and muted (R71) and never renders client_secret', () => { + const leaky = mergePatch(baseConfig, {}) as ConfigDoc; + leaky.api = { + url: 'https://x', + auth: { client_id: 'cid', client_secret: 'SUPER-SECRET' }, + }; + const wrapper = mountView({}, leaky); + const locked = wrapper.find('[data-test="locked-keys"]'); + expect(locked.text()).toContain('api.auth.client_id'); + expect(locked.text()).toContain('cid'); + expect(locked.findAll('[data-test="lock-icon"]').length).toBe(8); + expect( + locked.find('[data-test="lock-icon"]').attributes('aria-label'), + ).toBe('Set on the agent host; can never be changed remotely'); + expect(locked.findAll('[data-state="forbidden"]').length).toBe(8); + // No pencil on a forbidden field. + expect(locked.find('[data-test^="edit-"]').exists()).toBe(false); + expect(wrapper.html()).not.toContain('SUPER-SECRET'); + }); + + it('never renders client_secret in YAML mode either', async () => { + const leaky = mergePatch(baseConfig, {}) as ConfigDoc; + leaky.api = { + url: 'https://x', + auth: { client_id: 'cid', client_secret: 'SUPER-SECRET' }, + }; + const wrapper = mountView({}, leaky); + wrapper + .findComponent({ name: 'SelectButton' }) + .vm.$emit('update:modelValue', 'yaml'); + await wrapper.vm.$nextTick(); + expect(wrapper.find('[data-test="yaml-text"]').text()).toContain( + 'client_id: cid', + ); + expect(wrapper.html()).not.toContain('SUPER-SECRET'); + }); + + it('shows the not-reported text without an effective config', () => { + const wrapper = mount(AgentConfigEffectiveView, { + props: { + effectiveDoc: null, + base: null, + appliedOverlay: null, + appliedRevisionNote: null, + filename: 'x.yaml', + }, + global: globalWith(piniaWith(READER)), + }); + expect(wrapper.find('[data-test="effective-empty"]').text()).toContain( + 'No configuration reported yet.', + ); + }); + + it('matches removed plugins by own name only (a plugin may be named "constructor")', () => { + const removedCtor = (base: ConfigDoc) => + mount(AgentConfigEffectiveView, { + props: { + effectiveDoc: mergePatch(base, CTOR_REMOVED), + base, + appliedOverlay: CTOR_REMOVED, + appliedRevisionNote: null, + filename: 'x.yaml', + }, + global: globalWith(piniaWith(READER)), + }).find('[data-test="plugin-card-constructor"]'); + // The file has it and the overlay removes it: a "Removed by overlay" card. + const withCtor = mergePatch(baseConfig, { + plugins: { constructor: { source: 'ghcr.io/x/ctor:v1' } }, + }); + expect(removedCtor(withCtor).text()).toContain('Removed by overlay'); + // The file lacks it: no card (Object#constructor is not a plugin). + expect(removedCtor(baseConfig).exists()).toBe(false); + }); +}); + +const CTOR_REMOVED: OverlayDoc = JSON.parse('{"plugins":{"constructor":null}}'); diff --git a/src/components/agents/config/__tests__/PluginLibVersion.spec.ts b/src/components/agents/config/__tests__/PluginLibVersion.spec.ts new file mode 100644 index 00000000..3c4fafd1 --- /dev/null +++ b/src/components/agents/config/__tests__/PluginLibVersion.spec.ts @@ -0,0 +1,57 @@ +// R76 on the Configuration tab: the plugin's agent library on the Effective view. +import { afterEach, describe, expect, it } from 'vitest'; +import { mount, enableAutoUnmount } from '@vue/test-utils'; +import { + SSH_SOURCE, + baseConfig, +} from '@/composables/agent-config/__tests__/fixtures'; +import type { ConfigDoc, PluginReport } from '@/types/agent-config'; +import AgentConfigEffectiveView from '../AgentConfigEffectiveView.vue'; +import { READER, globalWith, piniaWith } from './helpers'; + +// PrimeVue's TabList schedules a 150 ms ink-bar update on mount and never clears it; a wrapper +// left mounted lets it fire after this file's jsdom environment is torn down +// ("HTMLElement is not defined"). Unmounting nulls its refs, so the timer becomes a no-op. +enableAutoUnmount(afterEach); + +describe('plugin agent library badge (R76)', () => { + function view(plugins: PluginReport[] | null) { + return mount(AgentConfigEffectiveView, { + props: { + effectiveDoc: baseConfig as ConfigDoc, + base: baseConfig, + appliedOverlay: {}, + appliedRevisionNote: null, + filename: 'x.yaml', + pluginReports: plugins, + }, + global: globalWith(piniaWith(READER)), + }); + } + + it('shows the reported library, and nothing when it is unknown', () => { + const w = view([ + { name: 'local-ssh', source: SSH_SOURCE, libVersion: 'v0.1.9' }, + { name: 'ubuntu-packages', libVersion: '' }, + ]); + expect( + w + .find('[data-test="plugin-card-local-ssh"] [data-test="plugin-lib"]') + .text(), + ).toBe('agent v0.1.9'); + expect( + w + .find( + '[data-test="plugin-card-ubuntu-packages"] [data-test="plugin-lib"]', + ) + .exists(), + ).toBe(false); + }); + + it('shows nothing for agents that do not report plugins', () => { + const w = view(null); + // The card renders: only its library badge is absent. + expect(w.find('[data-test="plugin-card-local-ssh"]').exists()).toBe(true); + expect(w.find('[data-test="plugin-lib"]').exists()).toBe(false); + }); +}); diff --git a/src/components/agents/config/__tests__/PluginTabsWorkspace.spec.ts b/src/components/agents/config/__tests__/PluginTabsWorkspace.spec.ts new file mode 100644 index 00000000..2c1ce18f --- /dev/null +++ b/src/components/agents/config/__tests__/PluginTabsWorkspace.spec.ts @@ -0,0 +1,158 @@ +// The plugin tabs with a workspace: removal hints for any plugin name, "Add plugin" never +// replacing a plugin the saved overlay defines, and editing that waits for every instance file. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + enableAutoUnmount, + flushPromises, + mount, + type VueWrapper, +} from '@vue/test-utils'; +import type { AgentConfigApi } from '@/composables/agent-config/api-types'; +import type { ConfigWorkspace } from '@/composables/agent-config/useConfigWorkspace'; +import { resetAgentDrafts } from '@/composables/agent-config/draftRegistry'; +import type { AgentInstanceDetail, PluginDoc } from '@/types/agent-config'; +import { + configRev7, + instanceIds, +} from '@/composables/agent-config/__tests__/fixtures'; +import { clone } from '@/utils/agent-config/merge-patch'; +import { + ADMIN, + fakeApi, + globalWith, + piniaWith, + workspaceHost, +} from './helpers'; + +vi.mock('@/components/code-editor', () => import('./codeEditorMock')); + +import AgentConfigEffectiveView from '../AgentConfigEffectiveView.vue'; + +enableAutoUnmount(afterEach); + +async function mountView(api: AgentConfigApi) { + const out: { ws?: ConfigWorkspace } = {}; + const host = workspaceHost( + api, + AgentConfigEffectiveView, + () => ({ + effectiveDoc: out.ws!.state.selectedInstance.value?.effective ?? null, + base: out.ws!.state.selectedInstance.value?.base ?? null, + appliedOverlay: out.ws!.state.appliedOverlay.value, + appliedRevisionNote: null, + filename: 'x.yaml', + }), + out, + ); + const wrapper = mount(host, { + global: globalWith(piniaWith(ADMIN), { teleport: true }), + attachTo: document.body, + }); + await flushPromises(); + return { wrapper, ws: out.ws! }; +} + +/** ip-b's detail comes from `b` (deferred or failing); the others load at once. */ +function apiWithB(b: (d: AgentInstanceDetail) => Promise) { + const fallback = fakeApi().getInstance; + return fakeApi({ + getInstance: vi.fn(async (agentId: string, id: string) => { + const d = await fallback(agentId, id); + return id === instanceIds.b ? b(d) : d; + }), + }); +} + +const addButton = (w: VueWrapper) => w.find('[data-test="add-plugin"]'); + +describe('PluginTabs with a workspace', () => { + beforeEach(() => resetAgentDrafts()); + + it('marks the pending removal of a plugin named "constructor"', async () => { + // Typed as a plain string, so `plugins[name]` is the index signature. + const name: string = 'constructor'; + const ctor: PluginDoc = { source: 'ghcr.io/compliance-framework/ctor:v1' }; + const fallback = fakeApi().getInstance; + const { wrapper, ws } = await mountView( + fakeApi({ + getInstance: vi.fn(async (agentId: string, id: string) => { + const d = clone(await fallback(agentId, id)); + if (d.base) d.base.plugins![name] = clone(ctor); + if (d.effective) d.effective.plugins![name] = clone(ctor); + return d; + }), + }), + ); + const tab = () => wrapper.find('[data-test="plugin-tab-constructor"]'); + expect(tab().exists()).toBe(true); + ws.draft.makeAbsent('/plugins/constructor'); + await flushPromises(); + expect(tab().find('[data-test="plugin-tab-hint-removal"]').exists()).toBe( + true, + ); + }); + + it('refuses to add a plugin the saved overlay defines, and keeps its definition', async () => { + // r7 also adds `extra`; the instances do not run it yet, so it has no tab. + const saved = clone(configRev7); + saved.overlay!.plugins!.extra = { + source: 'ghcr.io/compliance-framework/plugin-extra:v1', + config: { region: 'eu-west-1' }, + policy_data: { threshold: 5 }, + }; + const { wrapper, ws } = await mountView( + fakeApi({ getConfig: vi.fn().mockResolvedValue(saved) }), + ); + expect(wrapper.find('[data-test="plugin-tab-extra"]').exists()).toBe(false); + + await addButton(wrapper).trigger('click'); + await wrapper.find('[data-test="add-plugin-name"]').setValue('extra'); + await wrapper + .find('[data-test="add-plugin-source"]') + .setValue('ghcr.io/compliance-framework/plugin-extra:v2'); + expect(wrapper.text()).toContain('A plugin with this name exists'); + await wrapper.find('[data-test="add-plugin-form"]').trigger('submit'); + await flushPromises(); + + const extra = ws.draft.overlay.value.plugins?.extra; + expect(extra?.source).toBe('ghcr.io/compliance-framework/plugin-extra:v1'); + expect(extra?.config).toEqual({ region: 'eu-west-1' }); + expect(extra?.policy_data).toEqual({ threshold: 5 }); + }); + + it('waits for every instance file before Add plugin', async () => { + let releaseB!: () => void; + const { wrapper } = await mountView( + apiWithB( + (d) => new Promise((r) => (releaseB = () => r(d))), + ), + ); + expect(wrapper.find('[data-test="bases-loading"]').exists()).toBe(true); + expect(addButton(wrapper).attributes('disabled')).toBeDefined(); + + releaseB(); + await flushPromises(); + expect(wrapper.find('[data-test="bases-loading"]').exists()).toBe(false); + expect(addButton(wrapper).attributes('disabled')).toBeUndefined(); + }); + + it('names an instance file that failed to load, and Retry loads it', async () => { + let fail = true; + const { wrapper } = await mountView( + apiWithB(async (d) => { + if (fail) throw new Error('502'); + return d; + }), + ); + const notice = wrapper.find('[data-test="bases-failed"]'); + expect(notice.exists()).toBe(true); + expect(notice.text()).toContain('ip-b'); + expect(addButton(wrapper).attributes('disabled')).toBeDefined(); + + fail = false; + await wrapper.find('[data-test="bases-retry"]').trigger('click'); + await flushPromises(); + expect(wrapper.find('[data-test="bases-failed"]').exists()).toBe(false); + expect(addButton(wrapper).attributes('disabled')).toBeUndefined(); + }); +}); diff --git a/src/components/agents/config/editor/AddPluginAction.vue b/src/components/agents/config/editor/AddPluginAction.vue new file mode 100644 index 00000000..1eb81b3a --- /dev/null +++ b/src/components/agents/config/editor/AddPluginAction.vue @@ -0,0 +1,65 @@ + + + diff --git a/src/components/agents/config/editor/AddPluginDialog.vue b/src/components/agents/config/editor/AddPluginDialog.vue new file mode 100644 index 00000000..c4d1c6b9 --- /dev/null +++ b/src/components/agents/config/editor/AddPluginDialog.vue @@ -0,0 +1,205 @@ + + + + +