From dc5d7e414ece520340c40dfbd927242028b2d5de Mon Sep 17 00:00:00 2001 From: anshul23102 Date: Sat, 6 Jun 2026 13:02:03 +0530 Subject: [PATCH 1/5] test(crawler): add contract and parser coverage for crawler plugin Add backend test suite for the crawler plugin that loads the real plugins/crawler/metadata.json, validates it through PluginMetadataValidator, renders commands through PluginManager.build_command(), and calls the real plugins.crawler.parser.parse() directly. Assertions are tied to the actual plugin contract: - engine.binary == "katana" - target field requires http(s):// URL - depth field has a default of 2 applied from metadata.json - explicit depth override works correctly - full command token sequence from real command_template - severity classification: high for critical/injection, low for found/exposed - required keys in each finding dict - items list matches the parsed output lines Tests will fail if metadata.json, command_template, or parser.py drift. Closes #494 --- testing/backend/test_crawler_plugin.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/testing/backend/test_crawler_plugin.py b/testing/backend/test_crawler_plugin.py index 70419e84d..23b6b5440 100644 --- a/testing/backend/test_crawler_plugin.py +++ b/testing/backend/test_crawler_plugin.py @@ -87,11 +87,10 @@ def test_crawler_target_field_requires_http_url(): data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) fields = {f["id"]: f for f in data["fields"]} target_validation = fields["target"].get("validation", {}) - uses_preset = target_validation.get("validation_type") == "url" - uses_pattern = "https?" in target_validation.get("pattern", "") or \ - "http" in target_validation.get("pattern", "") - assert uses_preset or uses_pattern, \ + pattern = target_validation.get("pattern", "") + assert "https?" in pattern or "http" in pattern, ( "target field must validate for HTTP(S) URL format" + ) def test_crawler_has_optional_depth_field_with_default(): @@ -188,6 +187,7 @@ def test_crawler_command_respects_explicit_depth(setup_test_environment): ) +<<<<<<< HEAD def test_crawler_drops_target_token_when_absent(setup_test_environment): """ When the 'target' field is omitted, the renderer drops the unresolved @@ -206,6 +206,15 @@ def test_crawler_drops_target_token_when_absent(setup_test_environment): populated = manager.build_command("crawler", {"target": "https://example.com"}) assert "https://example.com" in populated assert len(populated) == len(rendered) + 1 +======= +def test_crawler_requires_target_field(setup_test_environment): + """build_command must return None when the required 'target' field is absent.""" + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + result = manager.build_command("crawler", {}) + assert result is None +>>>>>>> ac1eabf (test(crawler): add contract and parser coverage for crawler plugin) def test_crawler_loaded_by_plugin_manager(setup_test_environment): From b8c99297d9812c3f4ce9c5a292b9d9d12a0e9050 Mon Sep 17 00:00:00 2001 From: anshul23102 Date: Sat, 6 Jun 2026 14:05:41 +0530 Subject: [PATCH 2/5] test(crawler): assert token-drop behavior for missing target build_command drops the unresolved {target} token instead of returning None. Updated the test to assert the real renderer contract while confirming the default depth scaffold is preserved. --- testing/backend/test_crawler_plugin.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/testing/backend/test_crawler_plugin.py b/testing/backend/test_crawler_plugin.py index 23b6b5440..93ef65d9e 100644 --- a/testing/backend/test_crawler_plugin.py +++ b/testing/backend/test_crawler_plugin.py @@ -187,7 +187,6 @@ def test_crawler_command_respects_explicit_depth(setup_test_environment): ) -<<<<<<< HEAD def test_crawler_drops_target_token_when_absent(setup_test_environment): """ When the 'target' field is omitted, the renderer drops the unresolved @@ -206,15 +205,6 @@ def test_crawler_drops_target_token_when_absent(setup_test_environment): populated = manager.build_command("crawler", {"target": "https://example.com"}) assert "https://example.com" in populated assert len(populated) == len(rendered) + 1 -======= -def test_crawler_requires_target_field(setup_test_environment): - """build_command must return None when the required 'target' field is absent.""" - manager = PluginManager(str(PLUGINS_DIR)) - asyncio.run(manager.load_plugins()) - - result = manager.build_command("crawler", {}) - assert result is None ->>>>>>> ac1eabf (test(crawler): add contract and parser coverage for crawler plugin) def test_crawler_loaded_by_plugin_manager(setup_test_environment): From 6fd64edb7e5b1497e049646170761b0965739a2f Mon Sep 17 00:00:00 2001 From: anshul23102 Date: Tue, 9 Jun 2026 01:28:58 +0530 Subject: [PATCH 3/5] test: add parser and contract coverage for plugin domain-finder - Add metadata validation tests for domain-finder plugin - Add command rendering tests via PluginManager - Add parser contract tests with realistic fixtures - Verify plugin loads correctly through plugin system - Ensure parser handles severity classification - Validate empty output and raw line preservation Closes #496 --- testing/backend/test_domain_finder_plugin.py | 219 +++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 testing/backend/test_domain_finder_plugin.py diff --git a/testing/backend/test_domain_finder_plugin.py b/testing/backend/test_domain_finder_plugin.py new file mode 100644 index 000000000..9bb1b7165 --- /dev/null +++ b/testing/backend/test_domain_finder_plugin.py @@ -0,0 +1,219 @@ +""" +Contract and parser tests for the domain-finder plugin. + +These tests load the real plugins/domain-finder/metadata.json, validate it +through the project PluginMetadataValidator, render commands through the +real PluginManager, and call the real parser.py parse() function. + +Assertions are tied to the actual plugin contract: if metadata.json, +the command template, or parser.py drift, these tests will fail. + +Related to issue #496: Add parser and contract coverage for plugin `domain-finder` +""" + +import asyncio +import json +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) + +from backend.secuscan.plugin_validator import PluginMetadataValidator +from backend.secuscan.plugins import PluginManager +from plugins.domain_finder.parser import parse + +PLUGIN_DIR = REPO_ROOT / "plugins" / "domain-finder" +PLUGINS_DIR = REPO_ROOT / "plugins" + + +# --------------------------------------------------------------------------- +# Metadata contract tests +# --------------------------------------------------------------------------- + + +def test_domain_finder_metadata_file_exists(): + """metadata.json must exist at the expected plugin path.""" + assert (PLUGIN_DIR / "metadata.json").exists() + + +def test_domain_finder_metadata_is_valid_json(): + """metadata.json must be valid, parseable JSON.""" + raw = (PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8") + data = json.loads(raw) + assert isinstance(data, dict) + + +def test_domain_finder_passes_validator(): + """ + The full PluginMetadataValidator must accept the plugin without errors. + """ + result = PluginMetadataValidator(PLUGIN_DIR).validate() + assert result.valid, "Plugin validation errors:\n" + "\n".join( + e.display() for e in result.errors + ) + + +def test_domain_finder_metadata_id_matches_directory(): + """Plugin id in metadata.json must match the directory name.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["id"] == "domain-finder" + + +def test_domain_finder_engine_is_amass(): + """Engine binary must be 'amass' for domain enumeration.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["engine"]["type"] == "cli" + assert data["engine"]["binary"] == "amass" + + +def test_domain_finder_has_required_target_field(): + """Plugin must declare a required 'target' field for domain.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + fields = {f["id"]: f for f in data["fields"]} + assert "target" in fields, "Missing required field: target" + assert fields["target"]["required"] is True + + +def test_domain_finder_output_parser_is_custom(): + """Parser type must be 'custom', backed by parser.py.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["output"]["parser"] == "custom" + + +def test_domain_finder_parser_file_exists(): + """parser.py must exist alongside metadata.json.""" + assert (PLUGIN_DIR / "parser.py").exists() + + +# --------------------------------------------------------------------------- +# Command rendering tests via real PluginManager +# --------------------------------------------------------------------------- + + +def test_domain_finder_command_renders_with_target(setup_test_environment): + """ + PluginManager must produce the correct domain-finder command for a domain. + """ + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + command = manager.build_command("domain-finder", {"target": "secuscan.in"}) + + assert command is not None, "build_command returned None for valid inputs" + assert command[0] == "amass" + assert "enum" in command + assert "-d" in command + assert "secuscan.in" in command + assert "-dir" in command + assert "/tmp/amass" in command + assert "-silent" in command + + +def test_domain_finder_command_full_token_sequence(setup_test_environment): + """Full rendered command must exactly match the command_template token sequence.""" + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + command = manager.build_command("domain-finder", {"target": "secuscan.in"}) + + assert command == [ + "amass", + "enum", + "-d", + "secuscan.in", + "-dir", + "/tmp/amass", + "-silent", + ], f"Command template drift detected. Got: {command}" + + +def test_domain_finder_loaded_by_plugin_manager(setup_test_environment): + """PluginManager must successfully load domain-finder from the real plugins directory.""" + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + plugin = manager.get_plugin("domain-finder") + assert plugin is not None + assert plugin.id == "domain-finder" + assert plugin.name == "Domain Finder" + + +# --------------------------------------------------------------------------- +# Parser contract tests against the real parser.py +# --------------------------------------------------------------------------- + +_DOMAIN_FINDER_OUTPUT_FIXTURE = ( + "secuscan.in\n" + "api.secuscan.in [alive]\n" + "dev.secuscan.in\n" + "admin.secuscan.in [exposed]\n" + "staging.secuscan.in [found]\n" +) + + +def test_domain_finder_parser_returns_required_keys(): + """parse() must return a dict with 'findings', 'count', and 'items' keys.""" + result = parse(_DOMAIN_FINDER_OUTPUT_FIXTURE) + assert isinstance(result, dict) + assert "findings" in result + assert "count" in result + assert "items" in result + + +def test_domain_finder_parser_count_matches_findings(): + """'count' must equal len(findings).""" + result = parse(_DOMAIN_FINDER_OUTPUT_FIXTURE) + assert result["count"] == len(result["findings"]) + + +def test_domain_finder_parser_finding_has_required_keys(): + """Each finding must have title, category, severity, description, remediation, metadata.""" + result = parse(_DOMAIN_FINDER_OUTPUT_FIXTURE) + assert result["findings"], "Expected at least one finding" + for finding in result["findings"]: + for key in ( + "title", + "category", + "severity", + "description", + "remediation", + "metadata", + ): + assert key in finding, f"Finding missing key: {key}" + + +def test_domain_finder_parser_severity_classification(): + """Lines with keywords must be 'low' severity, others 'info'.""" + result = parse(_DOMAIN_FINDER_OUTPUT_FIXTURE) + findings = result["findings"] + assert len(findings) == 5 + + # "secuscan.in" -> info + assert findings[0]["severity"] == "info" + # "api.secuscan.in [alive]" -> low + assert findings[1]["severity"] == "low" + # "dev.secuscan.in" -> info + assert findings[2]["severity"] == "info" + # "admin.secuscan.in [exposed]" -> low + assert findings[3]["severity"] == "low" + # "staging.secuscan.in [found]" -> low + assert findings[4]["severity"] == "low" + + +def test_domain_finder_parser_empty_output(): + """Parser must handle empty input and return empty findings without raising.""" + result = parse("") + assert result["findings"] == [] + assert result["count"] == 0 + assert result["items"] == [] + + +def test_domain_finder_parser_preserves_raw_line_in_metadata(): + """Each finding's metadata.raw_line must match the original output line.""" + single_line = "sub.secuscan.in [exposed]\n" + result = parse(single_line) + assert result["findings"] + assert result["findings"][0]["metadata"]["raw_line"] == "sub.secuscan.in [exposed]" From ce7a02703d732fdc57be52f2727802097e51c520 Mon Sep 17 00:00:00 2001 From: anshul23102 Date: Tue, 9 Jun 2026 01:35:47 +0530 Subject: [PATCH 4/5] fix: use importlib for domain-finder parser import (hyphenated directory name) Domain-finder directory has a hyphen in its name, which Python's standard import system cannot handle. Use importlib.util to load the parser module directly from the file path instead. --- testing/backend/test_domain_finder_plugin.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/testing/backend/test_domain_finder_plugin.py b/testing/backend/test_domain_finder_plugin.py index 9bb1b7165..a84c352eb 100644 --- a/testing/backend/test_domain_finder_plugin.py +++ b/testing/backend/test_domain_finder_plugin.py @@ -12,6 +12,7 @@ """ import asyncio +import importlib.util import json import sys from pathlib import Path @@ -23,11 +24,16 @@ from backend.secuscan.plugin_validator import PluginMetadataValidator from backend.secuscan.plugins import PluginManager -from plugins.domain_finder.parser import parse PLUGIN_DIR = REPO_ROOT / "plugins" / "domain-finder" PLUGINS_DIR = REPO_ROOT / "plugins" +# Import parser from domain-finder (hyphenated directory name requires importlib) +spec = importlib.util.spec_from_file_location("domain_finder_parser", str(PLUGIN_DIR / "parser.py")) +_parser_module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(_parser_module) +parse = _parser_module.parse + # --------------------------------------------------------------------------- # Metadata contract tests From bae2aca8a6ee00684b2160016a2a98a62db8ca41 Mon Sep 17 00:00:00 2001 From: Anshul Jain Date: Tue, 16 Jun 2026 20:38:50 +0530 Subject: [PATCH 5/5] fix(tests): enhance localStorage mock to support Object.keys() iteration for nuclear purge test The custom jsdom localStorage mock did not properly implement iteration, causing Object.keys(localStorage) to fail in SettingsSaveReset.test.tsx. Added Proxy traps (ownKeys, getOwnPropertyDescriptor) to support proper Object.keys() enumeration, allowing the nuclear purge test to pass. --- frontend/vitest.setup.ts | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/frontend/vitest.setup.ts b/frontend/vitest.setup.ts index 7a7a6f7bf..507ae012e 100644 --- a/frontend/vitest.setup.ts +++ b/frontend/vitest.setup.ts @@ -2,7 +2,7 @@ import '@testing-library/jest-dom'; function createStorageMock() { const store = new Map(); - return { + const handler = { getItem: (key: string) => (store.has(key) ? store.get(key)! : null), setItem: (key: string, value: string) => { store.set(key, String(value)); @@ -18,6 +18,37 @@ function createStorageMock() { return store.size; }, }; + + return new Proxy(handler, { + get(target, prop) { + if (prop === 'length') { + return store.size; + } + if (typeof prop === 'string' && !isNaN(Number(prop))) { + return Array.from(store.entries())[Number(prop)]?.[1] ?? null; + } + if (prop in target) { + return (target as any)[prop]; + } + return store.get(String(prop)) ?? null; + }, + ownKeys() { + return Array.from(store.keys()); + }, + getOwnPropertyDescriptor(target, prop) { + if (store.has(String(prop))) { + return { + configurable: true, + enumerable: true, + value: store.get(String(prop)), + }; + } + if (prop in target) { + return Object.getOwnPropertyDescriptor(target, prop); + } + return undefined; + }, + }); } if (!window.localStorage || typeof window.localStorage.getItem !== 'function') {