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
70 changes: 47 additions & 23 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,17 +200,27 @@ export async function handleResolve(c: Context<AppEnv>): Promise<Response> {
{ param: "country", field: "country", ci: true },
];

for (const { param, field, ci } of filters) {
const value = c.req.query(param) ?? qualifierFromSecID(decoded, param);
if (!value) continue;
const activeFilters: Array<{ param: string; field: string; ci: boolean; value: string }> = [];
for (const f of filters) {
const value = c.req.query(f.param) ?? qualifierFromSecID(decoded, f.param);
if (value) activeFilters.push({ ...f, value });
}

if (activeFilters.length > 0) {
const matchesAll = (entry: unknown): boolean =>
activeFilters.every(({ field, ci, value }) => {
const values = (entry as Record<string, unknown>)?.[field];
if (!Array.isArray(values)) return false;
return ci
? values.some((v) => String(v).toLowerCase() === value.toLowerCase())
: values.includes(value);
});

const matches = (entry: unknown): boolean => {
const values = (entry as Record<string, unknown>)?.[field];
if (!Array.isArray(values)) return false;
return ci
? values.some((v) => String(v).toLowerCase() === value.toLowerCase())
: values.includes(value);
};
const filterMeta: Record<string, string | number> = {};
for (const { param, value } of activeFilters) {
filterMeta[param] = value;
}
const filterDesc = activeFilters.map(({ param, value }) => `${param} "${value}"`).join(" and ");

const nested =
result.results.length === 1
Expand All @@ -219,28 +229,42 @@ export async function handleResolve(c: Context<AppEnv>): Promise<Response> {

if (Array.isArray(nested)) {
const before = nested.length;
const filtered = nested.filter(matches);
const filtered = nested.filter(matchesAll);
const single = result.results[0] as { data: Record<string, unknown> };
return c.json({
...result,
results: [{ ...single, data: { ...single.data, namespaces: filtered, namespace_count: filtered.length } }],
filter: { [param]: value, total_before_filter: before },
filter: { ...filterMeta, total_before_filter: before },
...(filtered.length === 0 && before > 0
? { message: `No namespaces with ${param} "${value}" found in this type.` }
? { message: `No namespaces with ${filterDesc} found in this type.` }
: {}),
});
}

const before = result.results.length;
const filtered = result.results.filter((r) => matches((r as { data?: unknown }).data));
return c.json({
...result,
results: filtered,
filter: { [param]: value, total_before_filter: before },
...(filtered.length === 0 && before > 0
? { message: `No namespaces with ${param} "${value}" found in this type.` }
: {}),
});
// Per-entry listings (listNamespaces, listSources) have status "found" and
// carry a subtypes array on each result's data (or have zero entries when a
// namespace has no match_nodes). Item resolutions, Level 3 source
// descriptions, and non-found responses pass through untouched.
const isPerEntryListing =
result.status === "found" &&
result.results.every((r) => Array.isArray((r as { data?: { subtypes?: unknown } }).data?.subtypes));

if (isPerEntryListing) {
const before = result.results.length;
const filtered = result.results.filter((r) => matchesAll((r as { data?: unknown }).data));
const isSourceListing = result.results.some(
(r) => typeof (r as { data?: { child_count?: unknown } }).data?.child_count === "number"
);
const emptyMsg = isSourceListing
? `No sources with ${filterDesc} found in this namespace.`
: `No namespaces with ${filterDesc} found in this type.`;
return c.json({
...result,
results: filtered,
filter: { ...filterMeta, total_before_filter: before },
...(filtered.length === 0 && before > 0 ? { message: emptyMsg } : {}),
});
}
}
return c.json(result);
} catch (err) {
Expand Down
14 changes: 12 additions & 2 deletions src/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,8 @@ export function resolve(
);
}

// Namespace-only query: list sources
if (!parsed.name) {
// Namespace-only query (or namespace/* wildcard): list sources
if (!parsed.name || parsed.name === "*") {
return listSources(query, parsed, ns);
}

Expand Down Expand Up @@ -139,15 +139,25 @@ function listSources(
parsed: ParsedSecID,
ns: RegistryNamespace
): ResolveResponse {
const country = (ns as unknown as { tags?: { country?: unknown } }).tags?.country;
const results: RegistryResult[] = ns.match_nodes.map((node) => {
const nameSlug = extractNameSlug(node);
const subtypes = new Set<string>();
const raw = (node.data as Record<string, unknown> | undefined)?.subtype;
if (Array.isArray(raw)) {
for (const v of raw) if (typeof v === "string") subtypes.add(v);
} else if (typeof raw === "string") {
subtypes.add(raw);
}
return {
secid: `secid:${parsed.type}/${parsed.namespace}/${nameSlug}`,
data: {
official_name: node.data.official_name ?? node.description,
common_name: node.data.common_name ?? null,
description: node.data.description ?? node.description,
child_count: node.children?.length ?? 0,
subtypes: [...subtypes].sort(),
...(Array.isArray(country) && country.length ? { country } : {}),
},
};
});
Expand Down
97 changes: 97 additions & 0 deletions test/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,103 @@ describe("REST API", () => {
expect(body.filter?.subtype).toBe("does-not-exist-xyzzy");
expect(body.message).toContain("does-not-exist-xyzzy");
});

it("includes subtypes on namespace source listings, supports /* wildcard, and filters by ?subtype= (#60)", async () => {
const full = await SELF.fetch(
"https://test.local/api/v1/resolve?secid=secid:methodology/first.org"
);
const fullBody = (await full.json()) as {
status: string;
results: Array<{ secid: string; data: { subtypes?: string[]; child_count?: number } }>;
};
expect(fullBody.status).toBe("found");
expect(fullBody.results.length).toBeGreaterThan(0);
for (const r of fullBody.results) {
expect(Array.isArray(r.data.subtypes)).toBe(true);
}

// Namespace /* wildcard returns the same source listing with status "found"
const wildcard = await SELF.fetch(
"https://test.local/api/v1/resolve?secid=" + encodeURIComponent("secid:methodology/first.org/*")
);
const wildcardBody = (await wildcard.json()) as typeof fullBody;
expect(wildcardBody.status).toBe("found");
expect(wildcardBody.results.map((r) => r.secid)).toEqual(fullBody.results.map((r) => r.secid));

// Filtering a namespace source listing by ?subtype=scoring returns matching sources
const filtered = await SELF.fetch(
"https://test.local/api/v1/resolve?secid=secid:methodology/first.org&subtype=scoring"
);
const filteredBody = (await filtered.json()) as {
status: string;
results: Array<{ secid: string; data: { subtypes?: string[] } }>;
filter?: { subtype?: string; total_before_filter?: number };
};
expect(filteredBody.status).toBe("found");
expect(filteredBody.filter).toEqual({
subtype: "scoring",
total_before_filter: fullBody.results.length,
});
expect(filteredBody.results.length).toBeGreaterThan(0);
for (const r of filteredBody.results) {
expect(r.data.subtypes).toContain("scoring");
}

// Filtering a namespace source listing by an unmatched subtype returns [] with a clear source message
const empty = await SELF.fetch(
"https://test.local/api/v1/resolve?secid=secid:methodology/first.org&subtype=does-not-exist-xyzzy"
);
const emptyBody = (await empty.json()) as {
results: unknown[];
filter?: { subtype?: string; total_before_filter?: number };
message?: string;
};
expect(emptyBody.results).toHaveLength(0);
expect(emptyBody.filter?.subtype).toBe("does-not-exist-xyzzy");
expect(emptyBody.message).toBe('No sources with subtype "does-not-exist-xyzzy" found in this namespace.');
});

it("leaves item resolutions and source descriptions untouched when listing filters are present (#59)", async () => {
const itemRes = await SELF.fetch(
"https://test.local/api/v1/resolve?secid=secid:advisory/mitre.org/cve%23CVE-2021-44228&subtype=scoring&country=ZZ"
);
const itemBody = (await itemRes.json()) as {
status: string;
results: Array<{ url?: string }>;
filter?: unknown;
};
expect(itemBody.status).toBe("found");
expect(itemBody.results.length).toBeGreaterThan(0);
expect(itemBody.filter).toBeUndefined();

const sourceRes = await SELF.fetch(
"https://test.local/api/v1/resolve?secid=secid:advisory/mitre.org/cve&subtype=scoring&country=ZZ"
);
const sourceBody = (await sourceRes.json()) as {
status: string;
results: Array<{ secid: string; data?: { urls?: unknown[] } }>;
filter?: unknown;
};
expect(sourceBody.status).toBe("found");
expect(sourceBody.results).toHaveLength(1);
expect(sourceBody.filter).toBeUndefined();

// Empty namespace source listing (0 match_nodes) still reports filter metadata
const emptyNsRes = await SELF.fetch(
"https://test.local/api/v1/resolve?secid=secid:entity/360.cn&country=CN"
);
const emptyNsBody = (await emptyNsRes.json()) as {
status: string;
results: unknown[];
filter?: { country?: string; total_before_filter?: number };
};
if (emptyNsBody.status === "found" && emptyNsBody.results.length === 0) {
expect(emptyNsBody.filter).toEqual({
country: "CN",
total_before_filter: 0,
});
}
});
});

describe("lang qualifier", () => {
Expand Down
58 changes: 56 additions & 2 deletions test/country-filter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,24 @@ beforeAll(async () => {
namespace_count: 4,
namespaces: [
{ namespace: "ismap.go.jp", official_name: "ISMAP", common_name: "ISMAP", source_count: 1, subtypes: [], country: ["JP"] },
{ namespace: "ipa.go.jp", official_name: "IPA", common_name: null, source_count: 1, subtypes: [], country: ["JP"] },
{ namespace: "bsi.bund.de", official_name: "BSI", common_name: null, source_count: 1, subtypes: [], country: ["DE"] },
{ namespace: "ipa.go.jp", official_name: "IPA", common_name: null, source_count: 1, subtypes: ["strategy"], country: ["JP"] },
{ namespace: "bsi.bund.de", official_name: "BSI", common_name: null, source_count: 1, subtypes: ["strategy"], country: ["DE"] },
{ namespace: "nist.gov", official_name: "NIST", common_name: "NIST", source_count: 1, subtypes: ["glossary"] },
],
child_index: [],
} as unknown as TypeIndex;
await env.secid_REGISTRY.put("secid:control", JSON.stringify(index));
await env.secid_REGISTRY.put(
"secid:control/ismap.go.jp",
JSON.stringify({
namespace: "ismap.go.jp",
type: "control",
official_name: "ISMAP",
common_name: "ISMAP",
tags: { country: ["JP"] },
match_nodes: [],
})
);
});

async function get(url: string) {
Expand Down Expand Up @@ -91,4 +102,47 @@ describe("country filter", () => {
const ns = namespacesOf(await get("/api/v1/resolve?secid=secid:control&subtype=glossary"));
expect(ns.map((n) => n.namespace)).toEqual(["nist.gov"]);
});

it("composes subtype and country filters via query params (#59)", async () => {
const body = await get("/api/v1/resolve?secid=secid:control&subtype=strategy&country=JP");
expect(namespacesOf(body).map((n) => n.namespace)).toEqual(["ipa.go.jp"]);
expect(body.filter).toEqual({
subtype: "strategy",
country: "JP",
total_before_filter: 4,
});
});

it("composes subtype and country filters via inline SecID qualifiers (#59)", async () => {
const body = await get(
"/api/v1/resolve?secid=" + encodeURIComponent("secid:control?subtype=strategy&country=de")
);
expect(namespacesOf(body).map((n) => n.namespace)).toEqual(["bsi.bund.de"]);
expect(body.filter).toEqual({
subtype: "strategy",
country: "de",
total_before_filter: 4,
});
});

it("explains when combined subtype and country filters match nothing (#59)", async () => {
const body = await get("/api/v1/resolve?secid=secid:control&subtype=glossary&country=JP");
expect(namespacesOf(body)).toHaveLength(0);
expect(body.filter).toEqual({
subtype: "glossary",
country: "JP",
total_before_filter: 4,
});
expect(body.message).toBe('No namespaces with subtype "glossary" and country "JP" found in this type.');
});

it("preserves filter metadata on an empty namespace source listing (#60)", async () => {
const body = await get("/api/v1/resolve?secid=secid:control/ismap.go.jp&country=JP");
expect(body.status).toBe("found");
expect(body.results).toEqual([]);
expect(body.filter).toEqual({
country: "JP",
total_before_filter: 0,
});
});
});
Loading