Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions src/components/agents/config/__tests__/PolicyDataDiffers.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): 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<string, string[]>) {
// 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);
});
});
139 changes: 139 additions & 0 deletions src/components/agents/config/effective/PolicyDataAddForm.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
<template>
<div class="py-0.5">
<button
v-if="!open"
type="button"
class="text-xs text-sky-700 hover:underline dark:text-sky-300"
:data-test="`pd-add-${testKey}`"
@click="open = true"
>
<i class="pi pi-plus mr-1 text-[0.65rem]" />{{
inArray ? 'Add item' : 'Add key'
}}
</button>
<form
v-else
class="flex flex-wrap items-start gap-2"
:data-test="`pd-add-form-${testKey}`"
@submit.prevent="submit"
@keydown.esc.prevent="close"
>
<InputText
v-if="!inArray"
v-model="key"
size="small"
class="w-40 font-mono"
placeholder="key"
:aria-label="`New key in ${where}`"
data-test="pd-new-key"
/>
<label
v-if="itemType === 'boolean'"
class="inline-flex items-center gap-1.5 text-xs"
>
<input
v-model="checked"
type="checkbox"
aria-label="New value"
data-test="pd-new-value"
/>
{{ checked ? 'true' : 'false' }}
</label>
<InputText
v-else
v-model="text"
size="small"
class="min-w-32 flex-1 font-mono"
:placeholder="itemType ? `${itemType} value` : 'value'"
:inputmode="itemType === 'number' ? 'decimal' : undefined"
aria-label="New value"
data-test="pd-new-value"
/>
<SecondaryButton
size="small"
type="submit"
:disabled="!!error"
data-test="pd-add-submit"
>Add</SecondaryButton
>
<TertiaryButton size="small" type="button" @click="close"
>Cancel</TertiaryButton
>
<p
v-if="!itemType"
class="w-full text-xs text-gray-500 dark:text-slate-400"
>
A JSON value (5, true, [], {}, "text") keeps its type; anything else is
text.
</p>
<p
v-if="error && (key || text)"
class="w-full text-xs text-red-600 dark:text-red-400"
data-test="pd-add-error"
>
{{ error }}
</p>
</form>
</div>
</template>

<script setup lang="ts">
// "Add key" / "Add item" of one object / array in the structured policy_data editor. There is
// no type selector: a list whose items share a scalar type takes new items of that type;
// otherwise the value is read as a JSON literal when it parses, else as text.
import { computed, ref } from 'vue';
import InputText from '@/volt/InputText.vue';
import SecondaryButton from '@/volt/SecondaryButton.vue';
import TertiaryButton from '@/volt/TertiaryButton.vue';
import { parseNewValue, type JsonType } from '@/utils/agent-config/policy-data';
import { NULL_KEY_ERROR } from './nullKeys';

const props = defineProps<{
/** Adding to an array (no key) rather than an object. */
inArray: boolean;
/** The scalar type the new value must have (an array's items), or null. */
itemType: JsonType | null;
/** Keys already in the object (duplicates are refused). */
existing: readonly string[];
/** Human name of the container, for labels. */
where: string;
testKey: string;
}>();
const emit = defineEmits<{ add: [key: string | null, value: unknown] }>();

const open = ref(false);
const key = ref('');
const text = ref('');
const checked = ref(false);

const parsed = computed(() =>
props.itemType === 'boolean'
? { value: checked.value, error: '' }
: parseNewValue(text.value, props.itemType),
);
const error = computed(() => {
if (!props.inArray) {
if (!key.value) return 'Enter a key';
if (props.existing.includes(key.value)) return 'This key exists';
}
if (parsed.value.error) return parsed.value.error;
// RFC 7396: null at a key means "delete" in the overlay. Arrays are written whole, so their
// items may be null.
if (!props.inArray && parsed.value.value === null)
return `${NULL_KEY_ERROR}; use Remove`;
return '';
});

function close() {
open.value = false;
key.value = '';
text.value = '';
checked.value = false;
}

function submit() {
if (error.value) return;
emit('add', props.inArray ? null : key.value, parsed.value.value);
close();
}
</script>
Loading
Loading