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
33 changes: 32 additions & 1 deletion frontend/vitest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import '@testing-library/jest-dom';

function createStorageMock() {
const store = new Map<string, string>();
return {
const handler = {
getItem: (key: string) => (store.has(key) ? store.get(key)! : null),
setItem: (key: string, value: string) => {
store.set(key, String(value));
Expand All @@ -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') {
Expand Down
7 changes: 3 additions & 4 deletions testing/backend/test_crawler_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
225 changes: 225 additions & 0 deletions testing/backend/test_domain_finder_plugin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
"""
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 importlib.util
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

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
# ---------------------------------------------------------------------------


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]"
Loading