diff --git a/src/__tests__/useCatalog.test.js b/src/__tests__/useCatalog.test.js index 10d28e8..39dea44 100644 --- a/src/__tests__/useCatalog.test.js +++ b/src/__tests__/useCatalog.test.js @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' -import { useCatalog } from '../composables/useCatalog' +import { useCatalog, applyClientFilters } from '../composables/useCatalog' // Minimal IndexedDB mock via fake-indexeddb shim. `idb` resolves its backing // store from the global `indexedDB`; jsdom does not provide one. @@ -17,11 +17,12 @@ describe('useCatalog — cross-source catalog composable (Plan C §4)', () => { vi.restoreAllMocks() }) - it('listEntries builds a filter query string and populates entries', async () => { + it('listEntries reads the backend Page{items} shape and only sends server-honored params', async () => { globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ - entries: [ + // Backend returns a Page object, not { entries }. + items: [ { kind: 'lab', name: 'demo-lab', @@ -29,19 +30,26 @@ describe('useCatalog — cross-source catalog composable (Plan C §4)', () => { path: 'labs/demo', }, ], - source_sha: 'abc123', + total: 1, + offset: 0, + limit: 100, }), }) const { entries, listEntries } = useCatalog() - const out = await listEntries({ kind: ['lab'], source: 'src-a', q: 'demo' }) + const out = await listEntries({ kind: 'lab', source_id: 'src-a', tag: 'web' }) expect(globalThis.fetch).toHaveBeenCalledTimes(1) const url = globalThis.fetch.mock.calls[0][0] expect(url).toMatch(/^\/v1\/catalog\/entries\?/) + // Aligned param names that the backend actually honors. expect(url).toContain('kind=lab') - expect(url).toContain('source=src-a') - expect(url).toContain('q=demo') + expect(url).toContain('source_id=src-a') + expect(url).toContain('tag=web') + // Legacy / unsupported param names must NOT be sent. + expect(url).not.toContain('source=src-a') + expect(url).not.toMatch(/[?&]tags=/) + expect(url).not.toMatch(/[?&](os|difficulty|q)=/) expect(out).toHaveLength(1) expect(entries.value).toHaveLength(1) @@ -86,3 +94,55 @@ describe('useCatalog — cross-source catalog composable (Plan C §4)', () => { expect(url).toContain('gamenets/ctf/range42.yaml') }) }) + +describe('applyClientFilters — presentation-side refinement', () => { + const entries = [ + { kind: 'lab', name: 'Web Recon', source_id: 'src-a', path: 'labs/web', os: 'ubuntu', difficulty: 'easy', tags: ['web', 'recon'] }, + { kind: 'container', name: 'SQLi Box', source_id: 'src-b', path: 'cve/web/sqli', os: 'debian', difficulty: 'hard', tags: ['web', 'sqli'] }, + { kind: 'ansible_role', name: 'Install Wazuh', source_id: 'src-a', path: 'roles/wazuh', os: '', difficulty: '', tags: ['monitoring'] }, + ] + + it('returns all entries when no filters are given', () => { + expect(applyClientFilters(entries)).toHaveLength(3) + expect(applyClientFilters(entries, {})).toHaveLength(3) + }) + + it('filters by multiple kinds (OR within kinds)', () => { + const out = applyClientFilters(entries, { kinds: ['lab', 'ansible_role'] }) + expect(out.map((e) => e.name)).toEqual(['Web Recon', 'Install Wazuh']) + }) + + it('filters by multiple sources (OR within sources)', () => { + const out = applyClientFilters(entries, { sources: ['src-b'] }) + expect(out.map((e) => e.name)).toEqual(['SQLi Box']) + }) + + it('requires ALL tags to match (AND across tags)', () => { + expect(applyClientFilters(entries, { tags: ['web'] }).map((e) => e.name)).toEqual([ + 'Web Recon', + 'SQLi Box', + ]) + expect(applyClientFilters(entries, { tags: ['web', 'sqli'] }).map((e) => e.name)).toEqual([ + 'SQLi Box', + ]) + }) + + it('filters by os and difficulty case-insensitively', () => { + expect(applyClientFilters(entries, { os: 'UBUNTU' }).map((e) => e.name)).toEqual(['Web Recon']) + expect(applyClientFilters(entries, { difficulty: 'Hard' }).map((e) => e.name)).toEqual([ + 'SQLi Box', + ]) + }) + + it('free-text q matches name, description, and path', () => { + expect(applyClientFilters(entries, { q: 'sqli' }).map((e) => e.name)).toEqual(['SQLi Box']) + expect(applyClientFilters(entries, { q: 'roles/' }).map((e) => e.name)).toEqual([ + 'Install Wazuh', + ]) + }) + + it('combines client filters (AND across dimensions)', () => { + const out = applyClientFilters(entries, { kinds: ['lab', 'container'], tags: ['web'], os: 'debian' }) + expect(out.map((e) => e.name)).toEqual(['SQLi Box']) + }) +}) diff --git a/src/__tests__/views/CatalogList.test.js b/src/__tests__/views/CatalogList.test.js new file mode 100644 index 0000000..ba60dad --- /dev/null +++ b/src/__tests__/views/CatalogList.test.js @@ -0,0 +1,116 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { createRouter, createMemoryHistory } from 'vue-router' +import { createPinia, setActivePinia } from 'pinia' +import { createI18n } from 'vue-i18n' +import 'fake-indexeddb/auto' +import CatalogList from '@/views/CatalogList.vue' +import { useInventoryStore } from '@/stores/inventoryStore' +import catalogEn from '@/locales/en/catalog.json' +import commonEn from '@/locales/en/common.json' +import sourcesEn from '@/locales/en/sources.json' + +// Two entries from the SAME source but DIFFERENT kinds, so that kind filtering +// is exercised in isolation from source filtering. +const PAGE = { + items: [ + { kind: 'lab', name: 'Web Recon', source_id: 'src-a', path: 'labs/web', tags: ['web'] }, + { kind: 'container', name: 'SQLi Box', source_id: 'src-a', path: 'cve/web/sqli', tags: ['web', 'sqli'] }, + ], + total: 2, + offset: 0, + limit: 500, +} + +function makeI18n() { + return createI18n({ + legacy: false, + locale: 'en', + fallbackLocale: 'en', + messages: { en: { catalog: catalogEn, common: commonEn, sources: sourcesEn } }, + }) +} + +function makeRouter() { + return createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', name: 'home', component: { template: '
' } }, + { path: '/sources', name: 'sources', component: { template: '
' } }, + { path: '/catalog/:source/:entry', name: 'catalog-entry', component: { template: '
' } }, + { path: '/project/:id', name: 'project', component: { template: '
' } }, + ], + }) +} + +async function mountList() { + const pinia = createPinia() + setActivePinia(pinia) + const inv = useInventoryStore() + inv.addSource({ id: 'src-a', provider: 'gitlab', base_url: 'https://gl.example', auth: { kind: 'none' }, repos: [] }) + + const wrapper = mount(CatalogList, { + global: { plugins: [pinia, makeI18n(), makeRouter()] }, + }) + await flushPromises() + return wrapper +} + +function gridKinds(wrapper) { + return wrapper + .findAll('[data-testid="catalog-grid"] article[data-kind]') + .map((el) => el.attributes('data-kind')) +} + +function kindButton(wrapper, label) { + return wrapper.findAll('button').find((b) => b.text() === label) +} + +describe('CatalogList — filter wiring (regression guard for server-narrowing bug)', () => { + beforeEach(() => { + // The jsdom localStorage mock persists across tests; clear it so each test + // starts with a fresh inventory store (addSource throws on a duplicate id). + localStorage.clear() + globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => PAGE }) + }) + afterEach(() => { + vi.restoreAllMocks() + }) + + it('renders all fetched entries and fetches the full set once (no per-filter refetch)', async () => { + const wrapper = await mountList() + expect(gridKinds(wrapper)).toEqual(['lab', 'container']) + // The grid fetches once on mount; the request carries no narrowing filter + // params (only a limit), since all filtering is client-side. + expect(globalThis.fetch).toHaveBeenCalledTimes(1) + const url = globalThis.fetch.mock.calls[0][0] + expect(url).toContain('/v1/catalog/entries') + expect(url).not.toMatch(/[?&]kind=/) + expect(url).not.toMatch(/[?&]source_id=/) + }) + + it('narrows to a single kind, then WIDENS to multiple without a refetch (the bug)', async () => { + const wrapper = await mountList() + + await kindButton(wrapper, 'lab').trigger('click') + await flushPromises() + expect(gridKinds(wrapper)).toEqual(['lab']) + + // Widening the selection must surface the second kind from the already- + // fetched superset — the previous server-narrowing design dropped it here. + await kindButton(wrapper, 'container').trigger('click') + await flushPromises() + expect(gridKinds(wrapper)).toEqual(['lab', 'container']) + + // Still only the initial fetch — filtering never hit the network again. + expect(globalThis.fetch).toHaveBeenCalledTimes(1) + }) + + it('applies the free-text search client-side over the fetched set', async () => { + const wrapper = await mountList() + await wrapper.find('input[type="search"]').setValue('sqli') + await flushPromises() + expect(gridKinds(wrapper)).toEqual(['container']) + expect(globalThis.fetch).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/components/ui/CatalogTile.vue b/src/components/ui/CatalogTile.vue index 52e283f..11bf8cb 100644 --- a/src/components/ui/CatalogTile.vue +++ b/src/components/ui/CatalogTile.vue @@ -18,10 +18,17 @@ const kindBadgeClass = computed(() => { return 'badge-secondary' case 'component': return 'badge-accent' + case 'container': + return 'badge-info' + case 'ansible_role': + return 'badge-warning' default: return 'badge-ghost' } }) + +// Render `ansible_role` as a readable label without changing the underlying value. +const kindLabel = computed(() => String(props.entry?.kind ?? '').replace(/_/g, ' '))