From 769944f4ef4952ba176f7f7711c183fb3d254759 Mon Sep 17 00:00:00 2001
From: Philippe Parage
Date: Thu, 21 May 2026 07:04:21 +0200
Subject: [PATCH] fix(catalog): align browsing client with backend entries API
The catalog grid was always empty: the client read `data.entries` but the
backend returns a Page envelope (`{ items, total, offset, limit }`), and the
filter controls were silently ignored (client sent source/tags/os/difficulty/q;
the backend only honors source_id/kind/tag/offset/limit).
- useCatalog reads `data.items`; sends only backend-honored params; adds a pure,
unit-tested `applyClientFilters` helper for presentation-side refinement.
- CatalogList fetches the full entry set once and filters entirely client-side,
so multi-select widening can never operate over a server-narrowed subset.
- CatalogTile renders container/ansible_role kind badges.
- Fix the misleading catalog error-retry button label (common.retry).
Closes #66
---
src/__tests__/useCatalog.test.js | 74 +++++++++++++--
src/__tests__/views/CatalogList.test.js | 116 ++++++++++++++++++++++++
src/components/ui/CatalogTile.vue | 9 +-
src/composables/useCatalog.ts | 108 +++++++++++++++++-----
src/locales/en/common.json | 3 +-
src/locales/fr/common.json | 3 +-
src/locales/jp/common.json | 3 +-
src/views/CatalogList.vue | 56 +++++++-----
8 files changed, 312 insertions(+), 60 deletions(-)
create mode 100644 src/__tests__/views/CatalogList.test.js
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, ' '))
@@ -38,7 +45,7 @@ const kindBadgeClass = computed(() => {
{{ entry.source_id }} · {{ entry.path }}
- {{ entry.kind }}
+ {{ kindLabel }}
diff --git a/src/composables/useCatalog.ts b/src/composables/useCatalog.ts
index 4d4dac7..4baf7f2 100644
--- a/src/composables/useCatalog.ts
+++ b/src/composables/useCatalog.ts
@@ -13,17 +13,33 @@ import { openDB, type IDBPDatabase } from 'idb'
// Types
// =============================================================================
+/**
+ * Server-side filters accepted by `GET /v1/catalog/entries`.
+ *
+ * These mirror exactly what the backend honors — `source_id`, `kind`, `tag`
+ * (singular), `offset`, `limit`. Presentation-only refinement (os, difficulty,
+ * free-text, multi-select) is applied client-side via {@link applyClientFilters}
+ * so the API client stays a faithful mirror of the backend contract.
+ */
export interface CatalogEntryFilters {
- kind?: string | string[]
- source?: string | string[]
- os?: string | string[]
- difficulty?: string | string[]
- tags?: string | string[]
- q?: string
+ kind?: string
+ source_id?: string
+ tag?: string
+ offset?: number
+ limit?: number
}
+/** Kinds the backend can emit; see range42-backend-api catalog/entries.py. */
+export type CatalogEntryKind =
+ | 'lab'
+ | 'gamenet'
+ | 'component'
+ | 'container'
+ | 'ansible_role'
+ | 'unknown'
+
export interface CatalogEntry {
- kind: 'lab' | 'gamenet' | 'component' | string
+ kind: CatalogEntryKind | string
name: string
description?: string
tags?: string[]
@@ -39,9 +55,55 @@ export interface CatalogEntry {
metadata?: Record
}
-export interface CatalogListResponse {
- entries: CatalogEntry[]
- source_sha?: string
+/** Paged response envelope returned by the backend (`app.schemas.v1.common.Page`). */
+export interface CatalogPage {
+ items: CatalogEntry[]
+ total: number
+ offset: number
+ limit: number
+}
+
+/** Presentation-side filters applied in the browser over fetched entries. */
+export interface CatalogClientFilters {
+ kinds?: string[]
+ sources?: string[]
+ tags?: string[]
+ os?: string
+ difficulty?: string
+ q?: string
+}
+
+/**
+ * Refine an already-fetched entry list in the browser. The backend only
+ * filters by a single `source_id`/`kind`/`tag`, so multi-select and the
+ * os/difficulty/free-text controls are resolved here. All dimensions combine
+ * with AND; values within `kinds`/`sources` combine with OR; every tag in
+ * `tags` must be present (AND).
+ */
+export function applyClientFilters(
+ entries: CatalogEntry[] | null | undefined,
+ filters: CatalogClientFilters = {},
+): CatalogEntry[] {
+ const { kinds, sources, tags, os, difficulty, q } = filters
+ let out = entries ?? []
+ if (kinds?.length) out = out.filter((e) => kinds.includes(e.kind))
+ if (sources?.length) out = out.filter((e) => sources.includes(e.source_id))
+ if (tags?.length) out = out.filter((e) => tags.every((tIdx) => (e.tags ?? []).includes(tIdx)))
+ if (os) {
+ const needle = os.toLowerCase()
+ out = out.filter((e) => (e.os ?? '').toLowerCase() === needle)
+ }
+ if (difficulty) {
+ const needle = difficulty.toLowerCase()
+ out = out.filter((e) => (e.difficulty ?? '').toLowerCase() === needle)
+ }
+ if (q) {
+ const needle = q.toLowerCase()
+ out = out.filter((e) =>
+ [e.name, e.description, e.path].some((s) => (s ?? '').toLowerCase().includes(needle)),
+ )
+ }
+ return out
}
// =============================================================================
@@ -71,18 +133,15 @@ function buildQueryString(filters: CatalogEntryFilters): string {
const params = new URLSearchParams()
const put = (k: string, v: unknown) => {
if (v === undefined || v === null || v === '') return
- if (Array.isArray(v)) {
- v.forEach((x) => params.append(k, String(x)))
- } else {
- params.append(k, String(v))
- }
+ params.append(k, String(v))
}
+ // Only the parameters the backend actually filters on. Sending the legacy
+ // `source`/`tags`/`os`/`difficulty`/`q` names was silently ignored server-side.
put('kind', filters.kind)
- put('source', filters.source)
- put('os', filters.os)
- put('difficulty', filters.difficulty)
- put('tags', filters.tags)
- put('q', filters.q)
+ put('source_id', filters.source_id)
+ put('tag', filters.tag)
+ put('offset', filters.offset)
+ put('limit', filters.limit)
const qs = params.toString()
return qs ? `?${qs}` : ''
}
@@ -110,13 +169,12 @@ export function useCatalog() {
if (!res.ok) {
throw new Error(`catalog list failed: ${res.status}`)
}
- const data = (await res.json()) as CatalogListResponse
- entries.value = data.entries || []
- // Cache by source_sha when available, else by filter key.
+ const data = (await res.json()) as CatalogPage
+ entries.value = data.items || []
+ // Cache by filter key (the Page envelope carries no source SHA).
try {
const db = await getDb()
- const ck = data.source_sha ? `entries:${data.source_sha}` : key
- await db.put(STORE, { entries: entries.value, ts: Date.now() }, ck)
+ await db.put(STORE, { entries: entries.value, ts: Date.now() }, key)
} catch {
/* ignore cache failures */
}
diff --git a/src/locales/en/common.json b/src/locales/en/common.json
index e79f18c..b2719b7 100644
--- a/src/locales/en/common.json
+++ b/src/locales/en/common.json
@@ -7,5 +7,6 @@
"settings": "Settings",
"close": "Close",
"add": "Add",
- "remove": "Remove"
+ "remove": "Remove",
+ "retry": "Retry"
}
diff --git a/src/locales/fr/common.json b/src/locales/fr/common.json
index 3017b4a..6624693 100644
--- a/src/locales/fr/common.json
+++ b/src/locales/fr/common.json
@@ -7,5 +7,6 @@
"settings": "Paramètres",
"close": "Fermer",
"add": "Ajouter",
- "remove": "Supprimer"
+ "remove": "Supprimer",
+ "retry": "Réessayer"
}
diff --git a/src/locales/jp/common.json b/src/locales/jp/common.json
index 5c4dbcd..590b455 100644
--- a/src/locales/jp/common.json
+++ b/src/locales/jp/common.json
@@ -7,5 +7,6 @@
"settings": "設定",
"close": "閉じる",
"add": "追加",
- "remove": "削除"
+ "remove": "削除",
+ "retry": "再試行"
}
diff --git a/src/views/CatalogList.vue b/src/views/CatalogList.vue
index 3eca700..2df1b6f 100644
--- a/src/views/CatalogList.vue
+++ b/src/views/CatalogList.vue
@@ -1,8 +1,8 @@