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
74 changes: 67 additions & 7 deletions src/__tests__/useCatalog.test.js
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -17,31 +17,39 @@ 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',
source_id: 'src-a',
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)
Expand Down Expand Up @@ -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'])
})
})
116 changes: 116 additions & 0 deletions src/__tests__/views/CatalogList.test.js
Original file line number Diff line number Diff line change
@@ -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: '<div/>' } },
{ path: '/sources', name: 'sources', component: { template: '<div/>' } },
{ path: '/catalog/:source/:entry', name: 'catalog-entry', component: { template: '<div/>' } },
{ path: '/project/:id', name: 'project', component: { template: '<div/>' } },
],
})
}

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)
})
})
9 changes: 8 additions & 1 deletion src/components/ui/CatalogTile.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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, ' '))
</script>

<template>
Expand All @@ -38,7 +45,7 @@ const kindBadgeClass = computed(() => {
{{ entry.source_id }} · {{ entry.path }}
</p>
</div>
<span class="badge" :class="kindBadgeClass">{{ entry.kind }}</span>
<span class="badge" :class="kindBadgeClass">{{ kindLabel }}</span>
</header>

<p v-if="entry.description" class="text-sm text-base-content/70 line-clamp-3 mt-2">
Expand Down
108 changes: 83 additions & 25 deletions src/composables/useCatalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand All @@ -39,9 +55,55 @@ export interface CatalogEntry {
metadata?: Record<string, unknown>
}

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
}

// =============================================================================
Expand Down Expand Up @@ -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}` : ''
}
Expand Down Expand Up @@ -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 */
}
Expand Down
Loading
Loading