diff --git a/src/components/agents/config/__tests__/PolicyDataDiffers.spec.ts b/src/components/agents/config/__tests__/PolicyDataDiffers.spec.ts new file mode 100644 index 00000000..38da7c20 --- /dev/null +++ b/src/components/agents/config/__tests__/PolicyDataDiffers.spec.ts @@ -0,0 +1,119 @@ +// policy_data lists whose value differs between the instances' files carry the same +// "differs across instances" warning as the other editors (FieldHints): a list edit writes one +// list for every instance. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + enableAutoUnmount, + flushPromises, + mount, + type VueWrapper, +} from '@vue/test-utils'; +import { resetAgentDrafts } from '@/composables/agent-config/draftRegistry'; +import type { AgentInstanceDetail, ConfigDoc } from '@/types/agent-config'; +import { + baseConfig, + configRev7, + instanceDetailA, + instanceIds, +} from '@/composables/agent-config/__tests__/fixtures'; +import { clone } from '@/utils/agent-config/merge-patch'; +import { + ADMIN, + READER, + fakeApi, + globalWith, + piniaWith, + workspaceHost, +} from './helpers'; + +vi.mock('@/components/code-editor', () => import('./codeEditorMock')); + +import PolicyDataSection from '../effective/PolicyDataSection.vue'; +import PolicyDataTree from '../effective/PolicyDataTree.vue'; + +enableAutoUnmount(afterEach); + +const PD = '/plugins/local-ssh/policy_data'; +const POLICY_DATA = { + max_auth_tries: 4, + users: ['root', 'admin'], + ports: [22], +}; + +function detailWith(policyData: Record): AgentInstanceDetail { + const base = clone(baseConfig) as ConfigDoc; + base.plugins!['local-ssh']!.policy_data = clone(policyData); + return { ...instanceDetailA, base, effective: base }; +} + +async function mountSection(perms: Record) { + // ip-b's file has another user list and another max_auth_tries; the ports agree. + const api = fakeApi({ + getConfig: vi.fn().mockResolvedValue({ ...configRev7, overlay: {} }), + getInstance: vi.fn(async (_a: string, id: string) => + id === instanceIds.b + ? detailWith({ ...POLICY_DATA, max_auth_tries: 6, users: ['root'] }) + : detailWith(POLICY_DATA), + ), + }); + const host = workspaceHost(api, PolicyDataSection, () => ({ + plugin: 'local-ssh', + reported: POLICY_DATA, + provenance: 'file', + })); + const wrapper = mount(host, { global: globalWith(piniaWith(perms)) }); + await flushPromises(); + return wrapper; +} + +const differs = (w: VueWrapper, rel: string) => + w.find(`[data-test="pd-differs-${PD}/${rel}"]`); + +describe('policy_data: lists that differ across instances', () => { + beforeEach(() => resetAgentDrafts()); + + it('warns on a list whose value differs between the files', async () => { + const w = await mountSection(ADMIN); + expect(w.find(`[data-test="pd-node-${PD}/users"]`).exists()).toBe(true); + expect(differs(w, 'users').text()).toBe('differs across instances'); + // A list the files agree on, and a scalar (written at its own pointer), carry no warning. + expect(w.find(`[data-test="pd-node-${PD}/ports"]`).exists()).toBe(true); + expect(differs(w, 'ports').exists()).toBe(false); + expect(differs(w, 'max_auth_tries').exists()).toBe(false); + }); + + it('a differing list shows the hint and no scalar value span', async () => { + const w = await mountSection(ADMIN); + expect(differs(w, 'users').exists()).toBe(true); + expect(w.find(`[data-test="pd-value-${PD}/users"]`).exists()).toBe(false); + // A scalar still renders its value. + expect(w.find(`[data-test="pd-value-${PD}/max_auth_tries"]`).exists()).toBe( + true, + ); + }); + + it('a container node renders no scalar value span', () => { + const w = mount(PolicyDataTree, { + props: { + value: { rules: { a: 1 }, list: [1, 2] }, + ptr: '/plugins/p/policy_data', + }, + global: globalWith(piniaWith(READER)), + }); + expect( + w.find('[data-test="pd-node-/plugins/p/policy_data/rules"]').exists(), + ).toBe(true); + expect( + w.find('[data-test="pd-value-/plugins/p/policy_data/rules"]').exists(), + ).toBe(false); + expect( + w.find('[data-test="pd-value-/plugins/p/policy_data/list"]').exists(), + ).toBe(false); + }); + + it('is an editing hint: readers do not see it', async () => { + const w = await mountSection(READER); + expect(w.find(`[data-test="pd-node-${PD}/users"]`).exists()).toBe(true); + expect(differs(w, 'users').exists()).toBe(false); + }); +}); diff --git a/src/components/agents/config/effective/PolicyDataAddForm.vue b/src/components/agents/config/effective/PolicyDataAddForm.vue new file mode 100644 index 00000000..155db8c0 --- /dev/null +++ b/src/components/agents/config/effective/PolicyDataAddForm.vue @@ -0,0 +1,139 @@ + + + diff --git a/src/components/agents/config/effective/PolicyDataNode.vue b/src/components/agents/config/effective/PolicyDataNode.vue new file mode 100644 index 00000000..5a1cce3e --- /dev/null +++ b/src/components/agents/config/effective/PolicyDataNode.vue @@ -0,0 +1,430 @@ + + + diff --git a/src/components/agents/config/effective/PolicyDataSection.vue b/src/components/agents/config/effective/PolicyDataSection.vue new file mode 100644 index 00000000..e3226948 --- /dev/null +++ b/src/components/agents/config/effective/PolicyDataSection.vue @@ -0,0 +1,222 @@ + + + diff --git a/src/components/agents/config/effective/PolicyDataTree.vue b/src/components/agents/config/effective/PolicyDataTree.vue new file mode 100644 index 00000000..19c803a9 --- /dev/null +++ b/src/components/agents/config/effective/PolicyDataTree.vue @@ -0,0 +1,54 @@ + + + diff --git a/src/components/agents/config/effective/policyDataContext.ts b/src/components/agents/config/effective/policyDataContext.ts new file mode 100644 index 00000000..30e2f442 --- /dev/null +++ b/src/components/agents/config/effective/policyDataContext.ts @@ -0,0 +1,27 @@ +// What the structured policy_data tree needs from its owner (PolicyDataSection): per-pointer +// edit rights and access notes, pending markers, and the edits themselves. Without a provider +// (read-only contexts) the tree only displays. + +import type { InjectionKey } from 'vue'; + +export interface PolicyDataTreeContext { + /** Whether the value at `ptr` may be edited (agent:configure and R71 access). */ + canEdit(ptr: string): boolean; + /** R71 note for `ptr` when it differs from the section's own state ('' otherwise). */ + accessNote( + ptr: string, + ): { state: 'restricted' | 'readonly'; text: string } | null; + /** The draft changes exactly this pointer (an element of a list included). */ + changed(ptr: string): boolean; + /** Undo the pending change at `ptr`. */ + revert(ptr: string): void; + /** Set the effective value at `ptr` (arrays are passed whole). */ + set(ptr: string, value: unknown): void; + /** Remove the key at `ptr`. */ + remove(ptr: string): void; + /** The instances' files differ at `ptr` (an array edit writes one list for all of them). */ + differs?(ptr: string): boolean; +} + +export const POLICY_DATA_TREE_KEY: InjectionKey = + Symbol('policy-data-tree');