diff --git a/src/components/agents/config/AgentConfigTab.vue b/src/components/agents/config/AgentConfigTab.vue new file mode 100644 index 00000000..0a3fa8a7 --- /dev/null +++ b/src/components/agents/config/AgentConfigTab.vue @@ -0,0 +1,288 @@ + + + diff --git a/src/components/agents/config/__tests__/AgentConfigTab.spec.ts b/src/components/agents/config/__tests__/AgentConfigTab.spec.ts new file mode 100644 index 00000000..a9625e9b --- /dev/null +++ b/src/components/agents/config/__tests__/AgentConfigTab.spec.ts @@ -0,0 +1,389 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { flushPromises, mount, enableAutoUnmount } from '@vue/test-utils'; +import type { AgentConfigApi } from '@/composables/agent-config/useAgentConfigApi'; +import { AgentConfigApiError } from '@/composables/agent-config/api-types'; +import { + configRev0, + configRev6, + configRev7, + detailFor, + instanceDetailA, + instanceIds, + instancesMixed, +} from '@/composables/agent-config/__tests__/fixtures'; +import type { Agent } from '@/types/agents'; +import { ADMIN, READER, globalWith, piniaWith } from './helpers'; + +const api = vi.hoisted(() => ({ current: null as unknown as AgentConfigApi })); +vi.mock('@/composables/agent-config/useAgentConfigApi', async () => { + const actual = await vi.importActual< + typeof import('@/composables/agent-config/useAgentConfigApi') + >('@/composables/agent-config/useAgentConfigApi'); + return { ...actual, useAgentConfigApi: () => api.current }; +}); + +import AgentConfigTab from '../AgentConfigTab.vue'; +import AgentConfigHistory from '../AgentConfigHistory.vue'; +import AgentConfigEffectiveView from '../AgentConfigEffectiveView.vue'; +import ConfigYamlViewer from '../ConfigYamlViewer.vue'; + +// 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); + +const agent: Agent = { + id: 'agent-1', + name: 'ssh agent', + isActive: true, + serviceAccountKeyCount: 1, + createdAt: '2026-09-01T00:00:00Z', + updatedAt: '2026-09-01T00:00:00Z', +}; + +function makeApi(over: Partial = {}): AgentConfigApi { + return { + getConfig: vi.fn().mockResolvedValue(configRev7), + putConfig: vi.fn(), + preview: vi.fn(), + listRevisions: vi + .fn() + .mockResolvedValue({ items: [], total: 0, totalPages: 1 }), + getRevision: vi.fn().mockResolvedValue(configRev6), + revert: vi.fn(), + listInstances: vi.fn().mockResolvedValue(instancesMixed), + getInstance: vi.fn().mockImplementation(async (_a: string, id: string) => { + if (id === instanceIds.a) return instanceDetailA; + const s = instancesMixed.items.find((i) => i.instanceId === id)!; + return detailFor(s, {}); + }), + ...over, + }; +} + +/** Switches the view through the "Configuration view" SelectButton, as a click would. */ +function setView(wrapper: ReturnType, view: string) { + wrapper + .findComponent({ name: 'SelectButton' }) + .vm.$emit('update:modelValue', view); +} + +function mountTab() { + return mount(AgentConfigTab, { + props: { agent }, + global: globalWith(piniaWith(ADMIN)), + }); +} + +describe('AgentConfigTab', () => { + beforeEach(() => { + api.current = makeApi(); + }); + + it('loads, then renders the header, picker, notice and effective view', async () => { + const wrapper = mountTab(); + expect(wrapper.find('[data-test="config-loading"]').exists()).toBe(true); + await flushPromises(); + expect(api.current.getConfig).toHaveBeenCalledWith('agent-1'); + expect(api.current.listInstances).toHaveBeenCalledWith('agent-1', { + page: 1, + limit: 25, + }); + expect(wrapper.find('[data-test="desired-revision"]').text()).toContain( + 'r7', + ); + expect(wrapper.find('[data-test="desired-revision"]').text()).toContain( + 'tighten ssh', + ); + // Default instance = first fresh reported one. + expect(api.current.getInstance).toHaveBeenCalledWith( + 'agent-1', + instanceIds.a, + ); + expect(wrapper.find('[data-test="mode-notice"]').text()).toContain('ip-a'); + expect(wrapper.find('[data-test="effective-view"]').exists()).toBe(true); + // ip-b is rejected: a problem chip in the header. + expect(wrapper.find('[data-test="problem-chip"]').text()).toContain('ip-b'); + }); + + it('shows the unsupported state on a 404', async () => { + api.current = makeApi({ + getConfig: vi.fn().mockRejectedValue( + new AgentConfigApiError({ + kind: 'unsupported', + status: 404, + message: 'x', + }), + ), + }); + const wrapper = mountTab(); + await flushPromises(); + expect(wrapper.find('[data-test="config-unsupported"]').text()).toContain( + 'does not support agent configuration', + ); + expect(wrapper.find('[data-test="config-header"]').exists()).toBe(false); + }); + + it('shows an error with Retry', async () => { + const getConfig = vi + .fn() + .mockRejectedValueOnce( + new AgentConfigApiError({ kind: 'network', message: 'offline' }), + ) + .mockResolvedValue(configRev7); + api.current = makeApi({ getConfig }); + const wrapper = mountTab(); + await flushPromises(); + expect(wrapper.find('[data-test="config-error"]').text()).toContain( + 'offline', + ); + await wrapper.find('[data-test="config-error"] button').trigger('click'); + await flushPromises(); + expect(wrapper.find('[data-test="config-header"]').exists()).toBe(true); + }); + + it('handles zero instances: header text, empty effective/file, overlay still works', async () => { + api.current = makeApi({ + listInstances: vi.fn().mockResolvedValue({ + items: [], + meta: { desiredRevision: 7, counts: {} }, + }), + }); + const wrapper = mountTab(); + await flushPromises(); + expect(wrapper.text()).toContain('No instances have connected yet.'); + expect(wrapper.find('[data-test="instance-picker"]').exists()).toBe(false); + expect(wrapper.find('[data-test="effective-empty"]').text()).toContain( + 'No configuration reported yet.', + ); + setView(wrapper, 'overlay'); + await flushPromises(); + expect(wrapper.find('[data-test="yaml-text"]').text()).toContain( + 'local-ssh-policies:v1.1.0', + ); + }); + + it('hides the picker with one instance, collapses stale instances with more', async () => { + api.current = makeApi({ + listInstances: vi.fn().mockResolvedValue({ + items: [instancesMixed.items[0]], + meta: { + ...instancesMixed.meta, + total: 1, + counts: { ...instancesMixed.meta.counts, total: 1 }, + }, + }), + }); + const one = mountTab(); + await flushPromises(); + expect(one.find('[data-test="instance-picker"]').exists()).toBe(false); + // One instance: one list request, no paging notices. + expect(api.current.listInstances).toHaveBeenCalledTimes(1); + expect(one.find('[data-test="partial-fleet"]').exists()).toBe(false); + expect(one.find('[data-test="bases-loading"]').exists()).toBe(false); + + api.current = makeApi(); + const many = mountTab(); + await flushPromises(); + expect(many.find('[data-test="instance-picker"]').exists()).toBe(true); + expect(many.find(`[data-test="pick-${instanceIds.d}"]`).exists()).toBe( + false, + ); + await many.find('[data-test="toggle-stale"]').trigger('click'); + expect(many.find(`[data-test="pick-${instanceIds.d}"]`).exists()).toBe( + true, + ); + }); + + it('past the page cap: fleet-wide header, a paged picker, and edits blocked', async () => { + const rows = Array.from({ length: 130 }, (_, i) => ({ + ...instancesMixed.items[0], + instanceId: `i${i + 1}`, + hostname: `ip-${i + 1}`, + })); + api.current = makeApi({ + listInstances: vi.fn( + async (_a: string, q: { page?: number; limit?: number } = {}) => { + const page = q.page ?? 1; + return { + items: rows.slice((page - 1) * 25, page * 25), + meta: { + ...instancesMixed.meta, + counts: { ...instancesMixed.meta.counts, total: 130 }, + page, + total: 130, + totalPages: 6, + }, + }; + }, + ), + getInstance: vi.fn(async (_a: string, id: string) => ({ + ...instanceDetailA, + instanceId: id, + })), + }); + const wrapper = mountTab(); + await flushPromises(); + expect(api.current.listInstances).toHaveBeenCalledTimes(4); + expect(wrapper.find('[data-test="partial-fleet"]').text()).toContain( + 'Showing 100 of 130 instances', + ); + expect(wrapper.find('[data-test="picker-range"]').text()).toBe( + '1–25 of 100', + ); + expect(wrapper.find('[data-test="bases-loading"]').text()).toContain( + 'Only 100 of 130 instances are loaded', + ); + // The field renders, without a pencil. + expect(wrapper.find('[data-test="field-/verbosity"]').exists()).toBe(true); + expect(wrapper.find('[data-test="edit-/verbosity"]').exists()).toBe(false); + }); + + it('selecting an instance fetches its detail and the overlay of its applied revision', async () => { + const wrapper = mountTab(); + await flushPromises(); + await wrapper.find(`[data-test="pick-${instanceIds.f}"]`).trigger('click'); + await flushPromises(); + expect(api.current.getInstance).toHaveBeenLastCalledWith( + 'agent-1', + instanceIds.f, + ); + // ip-f runs r6 while r7 is desired: provenance uses r6's overlay. + expect(api.current.getRevision).toHaveBeenCalledWith('agent-1', 6); + expect(wrapper.find('[data-test="provenance-note"]').text()).toContain( + 'r6', + ); + }); + + it('revision 0: no overlay saved, copy/download disabled', async () => { + api.current = makeApi({ getConfig: vi.fn().mockResolvedValue(configRev0) }); + const wrapper = mountTab(); + await flushPromises(); + expect(wrapper.find('[data-test="config-header"]').text()).toContain( + 'No overlay saved: agents run their local configuration.', + ); + setView(wrapper, 'overlay'); + await flushPromises(); + expect(wrapper.find('[data-test="yaml-empty"]').text()).toBe( + 'No overlay saved yet.', + ); + expect( + wrapper.find('[data-test="yaml-copy"]').attributes('disabled'), + ).toBeDefined(); + }); + + it('does not fetch without agent:read', async () => { + const wrapper = mount(AgentConfigTab, { + props: { agent }, + global: globalWith(piniaWith({ agent: [] })), + }); + await flushPromises(); + expect(api.current.getConfig).not.toHaveBeenCalled(); + expect(wrapper.find('[data-test="config-error"]').exists()).toBe(true); + }); + it('readers get no editing UI and load no other instance; editors load every reported instance in the background', async () => { + const reader = mount(AgentConfigTab, { + props: { agent }, + global: globalWith(piniaWith(READER)), + }); + await flushPromises(); + expect( + reader.find('[data-test="raw-overlay"]').attributes('disabled'), + ).toBeDefined(); + expect(reader.find('[data-test^="edit-/"]').exists()).toBe(false); + // Only the selected instance's detail (no background load for readers). + expect(api.current.getInstance).toHaveBeenCalledTimes(1); + + api.current = makeApi(); + const wrapper = mount(AgentConfigTab, { + props: { agent }, + global: globalWith(piniaWith(ADMIN)), + }); + await flushPromises(); + // The selected one plus every other instance with reportedAt (6 of 7; ip-e never + // reported); the selected one is fetched fresh, the others once. + expect(api.current.getInstance).toHaveBeenCalledTimes(6); + expect( + wrapper.find('[data-test="raw-overlay"]').attributes('disabled'), + ).toBe(undefined); + expect(wrapper.find('[data-test="edit-/verbosity"]').exists()).toBe(true); + }); + + it('History may revert only with agent:configure', async () => { + const cases: [Record, boolean][] = [ + [ADMIN, true], + [READER, false], + ]; + for (const [perms, canRevert] of cases) { + const wrapper = mount(AgentConfigTab, { + props: { agent }, + global: globalWith(piniaWith(perms)), + }); + await flushPromises(); + wrapper + .findComponent({ name: 'SelectButton' }) + .vm.$emit('update:modelValue', 'history'); + await flushPromises(); + const history = wrapper.findComponent(AgentConfigHistory); + expect(history.props('canRevert')).toBe(canRevert); + } + }); + + it('names File / Effective downloads after the shown instance', async () => { + const wrapper = mountTab(); + await flushPromises(); + expect( + wrapper.findComponent(AgentConfigEffectiveView).props('filename'), + ).toBe('ssh-agent-ip-a-effective.yaml'); + setView(wrapper, 'file'); + await flushPromises(); + expect(wrapper.findComponent(ConfigYamlViewer).props('filename')).toBe( + 'ssh-agent-ip-a-file.yaml', + ); + }); + + it('falls back to a short instance id without a hostname', async () => { + const only = { ...instancesMixed.items[0], hostname: null }; + api.current = makeApi({ + listInstances: vi + .fn() + .mockResolvedValue({ items: [only], meta: instancesMixed.meta }), + }); + const wrapper = mountTab(); + await flushPromises(); + setView(wrapper, 'file'); + await flushPromises(); + expect(wrapper.findComponent(ConfigYamlViewer).props('filename')).toBe( + `ssh-agent-${only.instanceId.slice(0, 8)}-file.yaml`, + ); + }); + + it('never renders client_secret in the File view', async () => { + api.current = makeApi({ + getInstance: vi + .fn() + .mockImplementation(async (_a: string, id: string) => { + const d = detailFor( + instancesMixed.items.find((i) => i.instanceId === id)!, + {}, + ); + d.base = { + ...d.base!, + api: { + url: 'https://x', + auth: { client_id: 'cid', client_secret: 'LEAKED' }, + }, + }; + return d; + }), + }); + const wrapper = mountTab(); + await flushPromises(); + setView(wrapper, 'file'); + await flushPromises(); + expect(wrapper.find('[data-test="yaml-text"]').text()).toContain( + 'client_id: cid', + ); + expect(wrapper.html()).not.toContain('LEAKED'); + }); +}); diff --git a/src/router/__tests__/index.spec.ts b/src/router/__tests__/index.spec.ts index cb93faa7..3632b303 100644 --- a/src/router/__tests__/index.spec.ts +++ b/src/router/__tests__/index.spec.ts @@ -46,8 +46,8 @@ describe('router', () => { }); it('gates admin routes on admin:manage so direct-URL access is blocked (BCH-1318)', () => { - const agents = router.getRoutes().find((r) => r.name === 'admin-agents'); - expect(agents?.meta.permission).toEqual({ + const groups = router.getRoutes().find((r) => r.name === 'admin-groups'); + expect(groups?.meta.permission).toEqual({ resource: 'admin', action: 'manage', }); @@ -60,6 +60,14 @@ describe('router', () => { expect(catalogCreate?.meta.permission).toBeUndefined(); }); + it('gates the agents page on agent:read (agent remote config R40)', () => { + const agents = router.getRoutes().find((r) => r.name === 'admin-agents'); + expect(agents?.meta.permission).toEqual({ + resource: 'agent', + action: 'read', + }); + }); + it('registers the dashboard suggestions review route behind auth meta', () => { const route = router .getRoutes() diff --git a/src/router/index.ts b/src/router/index.ts index b92d4837..cea6bde4 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -428,7 +428,9 @@ const authenticatedRoutes = [ component: () => import('../views/admin/AgentsView.vue'), meta: { requiresAuth: true, - permission: ADMIN_MANAGE, + // R40: readable with agent:read (viewer/auditor/contributor); keys and CRUD inside the + // page stay behind admin:manage. + permission: { resource: RESOURCES.AGENT, action: ACTIONS.READ }, }, }, { diff --git a/src/views/LeftSideNav.vue b/src/views/LeftSideNav.vue index eddd2b48..6d4f2835 100644 --- a/src/views/LeftSideNav.vue +++ b/src/views/LeftSideNav.vue @@ -208,7 +208,8 @@ const links = ref>([ { name: 'admin-agents', title: 'Agents', - permission: ADMIN_MANAGE, + // R40: agent:read, like the route. + permission: { resource: RESOURCES.AGENT, action: ACTIONS.READ }, }, // { // name: 'admin-subject-templates', diff --git a/src/views/__tests__/AgentsView.spec.ts b/src/views/__tests__/AgentsView.spec.ts index 7b9e1905..6ffff08c 100644 --- a/src/views/__tests__/AgentsView.spec.ts +++ b/src/views/__tests__/AgentsView.spec.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { flushPromises, mount } from '@vue/test-utils'; +import { flushPromises, mount, enableAutoUnmount } from '@vue/test-utils'; import { h, inject, provide, ref, shallowRef, watch } from 'vue'; import { createPinia, setActivePinia } from 'pinia'; import type { @@ -99,6 +99,54 @@ const mockLoadKeys = vi.fn().mockImplementation(async (url: string) => { return {}; }); +// Mutable permission map. The existing cases run as an admin (admin:manage + agent:*). +const perms = vi.hoisted(() => ({ + map: {} as Record, + // Simulates the permissions store: can() is optimistic until hydrate() settles. + loaded: true, + hydrate: null as null | (() => Promise), +})); +const ADMIN_PERMS = { + admin: ['manage'], + agent: ['read', 'create', 'update', 'delete', 'configure'], +}; + +vi.mock('@/composables/usePermissions', async () => { + const { computed, ref } = await import('vue'); + const constants = await vi.importActual< + typeof import('@/constants/permissions') + >('@/constants/permissions'); + return { + usePermissions: () => { + const loaded = ref(perms.loaded); + const can = (resource: string, action: string) => + !loaded.value || (perms.map[resource]?.includes(action) ?? false); + return { + can, + canManageAdmin: computed(() => can('admin', 'manage')), + loaded, + hydrate: vi.fn(async () => { + await perms.hydrate?.(); + loaded.value = true; + }), + permissionTooltip: constants.permissionTooltip, + RESOURCES: constants.RESOURCES, + ACTIONS: constants.ACTIONS, + }; + }, + }; +}); + +vi.mock('@/components/agents/config/AgentConfigTab.vue', () => ({ + // Loaded through defineAsyncComponent, which unwraps `default` of ES modules. + __esModule: true, + default: { + name: 'AgentConfigTab', + props: ['agent'], + template: '
{{ agent?.id }}
', + }, +})); + const toastAdd = vi.fn(); const confirmRequire = vi.fn(); const clipboardWriteText = vi.fn().mockResolvedValue(undefined); @@ -333,10 +381,18 @@ vi.mock('@/volt/TabPanel.vue', () => ({ import AgentsView from '../admin/AgentsView.vue'; +// 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('AgentsView', () => { beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks(); + perms.map = ADMIN_PERMS; + perms.loaded = true; + perms.hydrate = null; vi.useFakeTimers(); vi.setSystemTime(new Date('2026-04-06T00:00:00Z')); @@ -412,6 +468,14 @@ describe('AgentsView', () => { expect(wrapper.text()).toContain('client-id-1'); }); + it('the detail card clips without becoming a scroll container (sticky pending bar)', async () => { + const wrapper = mountView(); + await flushPromises(); + const card = wrapper.find('[data-test="agent-detail-card"]'); + expect(card.classes()).toContain('overflow-clip'); + expect(card.classes()).not.toContain('overflow-hidden'); + }); + it('creates an agent and refreshes the list', async () => { const wrapper = mountView(); await flushPromises(); @@ -568,4 +632,93 @@ describe('AgentsView', () => { 'Select or register an agent to manage service account keys.', ); }); + it('shows the Configuration tab with agent:read and hides it otherwise', async () => { + const wrapper = mountView(); + await flushPromises(); + expect(findButtonByText(wrapper, 'Configuration')).toBeTruthy(); + expect(wrapper.find('[data-test="config-tab"]').exists()).toBe(false); + + await findButtonByText(wrapper, 'Configuration')!.trigger('click'); + await flushPromises(); + expect(wrapper.find('[data-test="config-tab"]').text()).toBe('agent-1'); + + perms.map = { admin: ['manage'], agent: [] }; + const denied = mountView(); + await flushPromises(); + expect(findButtonByText(denied, 'Configuration')).toBeUndefined(); + }); + + it('gives a non-admin with agent:read the list and the Configuration tab only', async () => { + perms.map = { agent: ['read'] }; + const wrapper = mountView(); + await flushPromises(); + + expect(wrapper.text()).toContain('agent-one'); + expect(wrapper.text()).toContain('Review and configure agents'); + // Configuration is the default tab for non-admins. + expect(wrapper.find('[data-test="config-tab"]').text()).toBe('agent-1'); + for (const label of [ + 'Register Agent', + 'Edit', + 'Delete', + 'Manage Keys', + 'Service Account Keys', + 'Edit Agent', + 'Delete Agent', + ]) { + expect(findButtonByText(wrapper, label)).toBeUndefined(); + } + expect(wrapper.text()).not.toContain('Actions'); + expect(mockLoadKeys).not.toHaveBeenCalled(); + + // Details stay visible to everyone, without the admin buttons. + await findButtonByText(wrapper, 'Details')!.trigger('click'); + expect(wrapper.text()).toContain('Active Keys'); + expect(findButtonByText(wrapper, 'Edit Agent')).toBeUndefined(); + }); + + it('re-keys the config tab when switching agents', async () => { + perms.map = { agent: ['read'] }; + const wrapper = mountView(); + await flushPromises(); + expect(wrapper.find('[data-test="config-tab"]').text()).toBe('agent-1'); + + await wrapper.findAll('tbody tr')[1].trigger('click'); + await flushPromises(); + expect(wrapper.find('[data-test="config-tab"]').text()).toBe('agent-2'); + }); + it('never requests keys or shows admin UI to a non-admin before permissions hydrate (R40)', async () => { + perms.map = { agent: ['read'] }; + perms.loaded = false; + let release: () => void = () => undefined; + perms.hydrate = () => new Promise((r) => (release = r)); + const wrapper = mountView(); + await flushPromises(); + // Optimistic can() would say "admin" here; the page waits instead. + expect(mockLoadKeys).not.toHaveBeenCalled(); + expect(findButtonByText(wrapper, 'Register Agent')).toBeUndefined(); + expect(findButtonByText(wrapper, 'Manage Keys')).toBeUndefined(); + + release(); + await flushPromises(); + expect(mockLoadKeys).not.toHaveBeenCalled(); + expect(findButtonByText(wrapper, 'Register Agent')).toBeUndefined(); + expect(wrapper.find('[data-test="config-tab"]').exists()).toBe(true); + expect(toastAdd).not.toHaveBeenCalled(); + }); + + it('loads keys for an admin once permissions hydrate', async () => { + perms.loaded = false; + let release: () => void = () => undefined; + perms.hydrate = () => new Promise((r) => (release = r)); + const wrapper = mountView(); + await flushPromises(); + expect(mockLoadKeys).not.toHaveBeenCalled(); + release(); + await flushPromises(); + expect(mockLoadKeys).toHaveBeenCalledWith('/api/admin/agents/agent-1/keys'); + expect(findButtonByText(wrapper, 'Register Agent')).toBeTruthy(); + // Admins land on Details. + expect(wrapper.find('[data-test="config-tab"]').exists()).toBe(false); + }); }); diff --git a/src/views/__tests__/AiDiagnosticsView.spec.ts b/src/views/__tests__/AiDiagnosticsView.spec.ts index 01f63bf9..bc10ad1e 100644 --- a/src/views/__tests__/AiDiagnosticsView.spec.ts +++ b/src/views/__tests__/AiDiagnosticsView.spec.ts @@ -1,5 +1,5 @@ -import { flushPromises, mount } from '@vue/test-utils'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { flushPromises, mount, enableAutoUnmount } from '@vue/test-utils'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { nextTick } from 'vue'; import { buildAiDiagnosticsCacheHitChartData, @@ -84,6 +84,11 @@ vi.mock('@/components/charts/LineChart.vue', () => ({ import AiDiagnosticsView from '../admin/AiDiagnosticsView.vue'; +// 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() { return mount(AiDiagnosticsView, { global: { diff --git a/src/views/__tests__/LeftSideNav.spec.ts b/src/views/__tests__/LeftSideNav.spec.ts index 299f9e5b..8c619211 100644 --- a/src/views/__tests__/LeftSideNav.spec.ts +++ b/src/views/__tests__/LeftSideNav.spec.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { mount } from '@vue/test-utils'; import { createPinia, setActivePinia } from 'pinia'; import { useSidebarStore } from '@/stores/sidebar'; +import { usePermissionsStore } from '@/stores/permissions'; import LeftSideNav from '../LeftSideNav.vue'; import SideNavCategory from '@/components/navigation/SideNavCategory.vue'; @@ -422,4 +423,42 @@ describe('LeftSideNav', () => { 'hidden', ); }); + it('shows Agents (but not Groups) to a non-admin with agent:read (R40)', () => { + const sidebarStore = useSidebarStore(); + sidebarStore.open = true; + const permissions = usePermissionsStore(); + permissions.permissions = { agent: ['read'], admin: [] }; + permissions.loaded = true; + + const wrapper = mount(LeftSideNav, { + global: { + directives: { + tooltip: { + mounted: () => undefined, + }, + }, + stubs: { + SideNav: { + template: '
', + }, + SideNavCategory: { + template: + '
', + }, + SideNavLink: { + template: '', + }, + SideNavLogo: { + template: 'logo', + }, + }, + }, + }); + + const linkTexts = wrapper + .findAll('.sidenav-link') + .map((link) => link.text().trim()); + expect(linkTexts).toContain('Agents'); + expect(linkTexts).not.toContain('Groups'); + }); }); diff --git a/src/views/admin/AgentsView.vue b/src/views/admin/AgentsView.vue index a48c5c26..421baf1e 100644 --- a/src/views/admin/AgentsView.vue +++ b/src/views/admin/AgentsView.vue @@ -1,11 +1,15 @@ +
- + Manage Keys
+ + + + + @@ -457,7 +489,7 @@