From 0d6599c44bd49f2551d062754ff0e870519d5c49 Mon Sep 17 00:00:00 2001 From: karrisanthoshigayatri Date: Mon, 6 Jul 2026 05:37:35 +0530 Subject: [PATCH 1/2] test: add dedicated parser unit tests for subdomain_discovery plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1436 - Add TestParserWithFixtureSampleOutput: validates fixture-based stable output, correct counts, subdomain list, and per-finding shape (title, category, severity, description, remediation, metadata keys). - Add TestParserReturnContract: verifies keys/types are consistent for all inputs. - Add TestParserEmptyAndWhitespaceInput: empty string, whitespace-only, trailing/ leading blank lines — all handled without crashing. - Add TestParserSingleSubdomain: single-line input edge case. - Add TestParserMalformedAndEdgeCaseInput: non-domain tokens, very long lines, 500-subdomain performance check — parser must not raise for any input. Tests assert against the actual parser output contract in plugins/subdomain_discovery/parser.py (metadata keys: subdomain, source, evidence, confidence). --- testing/backend/unit/test_subdomain_discovery_parser.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 testing/backend/unit/test_subdomain_discovery_parser.py diff --git a/testing/backend/unit/test_subdomain_discovery_parser.py b/testing/backend/unit/test_subdomain_discovery_parser.py new file mode 100644 index 000000000..e69de29bb From 24c394f1c7e2619791c245f1f27cff1d4320bffb Mon Sep 17 00:00:00 2001 From: karrisanthoshigayatri Date: Mon, 6 Jul 2026 18:47:56 +0530 Subject: [PATCH 2/2] test: add dedicated parser unit tests for subdomain_discovery plugin Closes #1436 - TestParserWithFixtureSampleOutput: fixture-based stable output, counts, subdomain list, and per-finding shape (title, category, severity, description, remediation, metadata: subdomain/source/evidence/confidence). - TestParserReturnContract: keys and types consistent for all inputs. - TestParserEmptyAndWhitespaceInput: empty, whitespace-only, blank lines. - TestParserSingleSubdomain: single-line and whitespace-padded inputs. - TestParserMalformedAndEdgeCaseInput: non-domain tokens, long lines, 500-subdomain performance check. --- .../unit/test_subdomain_discovery_parser.py | 194 ++++++++++++++++++ 1 file changed, 194 insertions(+) diff --git a/testing/backend/unit/test_subdomain_discovery_parser.py b/testing/backend/unit/test_subdomain_discovery_parser.py index e69de29bb..0999acfa5 100644 --- a/testing/backend/unit/test_subdomain_discovery_parser.py +++ b/testing/backend/unit/test_subdomain_discovery_parser.py @@ -0,0 +1,194 @@ +"""Dedicated parser unit tests for plugins/subdomain_discovery (issue #1436). + +Covers: + - Normal output: one or more subdomains, one per line. + - Single-subdomain output. + - Output with blank lines / surrounding whitespace. + - Empty string input. + - Whitespace-only input. + - Malformed / non-subdomain lines (parser must not crash). + - Return-value contract: keys, types, and counts are always consistent. +""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path + +import pytest + +from backend.secuscan.config import settings + +PLUGIN_ID = "subdomain_discovery" +PARSER_PATH = Path(settings.plugins_dir) / PLUGIN_ID / "parser.py" +FIXTURE_PATH = Path(__file__).parent / "fixtures" / PLUGIN_ID / "sample_output.txt" + + +def _load_parser(): + """Dynamically load the plugin's parser.py module.""" + spec = importlib.util.spec_from_file_location("subdomain_discovery_parser", PARSER_PATH) + assert spec is not None and spec.loader is not None, ( + f"Cannot load parser from {PARSER_PATH} - check plugins_dir setting" + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture(scope="module") +def parser(): + """Module-scoped fixture so the file is loaded once for all tests.""" + return _load_parser() + + +def _assert_finding_shape(finding, expected_subdomain): + """Assert that a single finding dict has the expected structure and values.""" + assert finding["title"] == f"Subdomain Discovered: {expected_subdomain}" + assert finding["category"] == "Subdomain" + assert finding["severity"] == "info" + assert isinstance(finding["description"], str) and finding["description"] + assert isinstance(finding["remediation"], str) and finding["remediation"] + meta = finding["metadata"] + assert meta["subdomain"] == expected_subdomain + assert meta["source"] == "subfinder" + assert meta["confidence"] == "high" + assert expected_subdomain in meta["evidence"] + + +class TestParserWithFixtureSampleOutput: + """Tests using the canonical sample_output.txt fixture.""" + + def test_fixture_file_exists(self): + assert FIXTURE_PATH.exists(), f"Fixture not found: {FIXTURE_PATH}" + + def test_returns_correct_count(self, parser): + result = parser.parse(FIXTURE_PATH.read_text(encoding="utf-8")) + assert result["count"] == 3 + + def test_returns_correct_subdomains_list(self, parser): + result = parser.parse(FIXTURE_PATH.read_text(encoding="utf-8")) + assert result["subdomains"] == [ + "api.secuscan.in", + "staging.secuscan.in", + "dev.secuscan.in", + ] + + def test_findings_length_matches_count(self, parser): + result = parser.parse(FIXTURE_PATH.read_text(encoding="utf-8")) + assert len(result["findings"]) == result["count"] + + def test_first_finding_has_correct_shape(self, parser): + result = parser.parse(FIXTURE_PATH.read_text(encoding="utf-8")) + _assert_finding_shape(result["findings"][0], "api.secuscan.in") + + def test_all_findings_have_correct_shape(self, parser): + result = parser.parse(FIXTURE_PATH.read_text(encoding="utf-8")) + expected = ["api.secuscan.in", "staging.secuscan.in", "dev.secuscan.in"] + for finding, subdomain in zip(result["findings"], expected): + _assert_finding_shape(finding, subdomain) + + +class TestParserReturnContract: + """Verify the return-value contract (keys and types) for all inputs.""" + + def test_result_always_has_required_keys(self, parser): + for raw in ["", " ", "api.example.com\nsub.example.com"]: + result = parser.parse(raw) + assert "findings" in result + assert "count" in result + assert "subdomains" in result + + def test_findings_is_always_a_list(self, parser): + for raw in ["", "api.example.com"]: + assert isinstance(parser.parse(raw)["findings"], list) + + def test_subdomains_is_always_a_list(self, parser): + for raw in ["", "api.example.com"]: + assert isinstance(parser.parse(raw)["subdomains"], list) + + def test_count_equals_len_subdomains(self, parser): + for raw in ["", "a.example.com", "a.example.com\nb.example.com"]: + result = parser.parse(raw) + assert result["count"] == len(result["subdomains"]) + + def test_count_equals_len_findings(self, parser): + for raw in ["", "a.example.com", "a.example.com\nb.example.com"]: + result = parser.parse(raw) + assert result["count"] == len(result["findings"]) + + +class TestParserEmptyAndWhitespaceInput: + """Parser must not crash and must return empty results for blank inputs.""" + + def test_empty_string_does_not_crash(self, parser): + assert parser.parse("") is not None + + def test_empty_string_returns_zero_findings(self, parser): + result = parser.parse("") + assert result["findings"] == [] + assert result["count"] == 0 + assert result["subdomains"] == [] + + def test_whitespace_only_does_not_crash(self, parser): + assert parser.parse(" \n \n ") is not None + + def test_whitespace_only_returns_zero_findings(self, parser): + result = parser.parse(" \n \n ") + assert result["findings"] == [] + assert result["count"] == 0 + assert result["subdomains"] == [] + + def test_trailing_newlines_are_ignored(self, parser): + result = parser.parse("api.example.com\n\n\n") + assert result["count"] == 1 + assert result["subdomains"] == ["api.example.com"] + + def test_leading_and_trailing_blank_lines_are_ignored(self, parser): + assert parser.parse("\n\napi.example.com\n\n")["count"] == 1 + + +class TestParserSingleSubdomain: + """Parser must handle a single-line input correctly.""" + + def test_single_subdomain_count(self, parser): + assert parser.parse("mail.example.com")["count"] == 1 + + def test_single_subdomain_in_list(self, parser): + assert parser.parse("mail.example.com")["subdomains"] == ["mail.example.com"] + + def test_single_subdomain_finding_shape(self, parser): + result = parser.parse("mail.example.com") + _assert_finding_shape(result["findings"][0], "mail.example.com") + + def test_single_subdomain_with_surrounding_whitespace(self, parser): + result = parser.parse(" mail.example.com ") + assert result["count"] == 1 + assert result["subdomains"] == ["mail.example.com"] + + +class TestParserMalformedAndEdgeCaseInput: + """Parser must not raise for unexpected or malformed input.""" + + def test_non_domain_lines_do_not_crash(self, parser): + assert parser.parse("not-a-domain\n!!bad!!\n123\n") is not None + + def test_non_domain_lines_still_produce_findings(self, parser): + result = parser.parse("not-a-domain\n!!bad!!\n123") + assert result["count"] == 3 + assert len(result["findings"]) == 3 + + def test_mixed_valid_and_invalid_lines_do_not_crash(self, parser): + result = parser.parse("api.example.com\n\n!!garbage!!\nsub.example.com") + assert result["count"] == 3 + + def test_very_long_single_line_does_not_crash(self, parser): + long_line = "a" * 1000 + ".example.com" + result = parser.parse(long_line) + assert result["count"] == 1 + assert result["subdomains"] == [long_line] + + def test_many_subdomains_performance(self, parser): + many = "\n".join(f"sub{i}.example.com" for i in range(500)) + result = parser.parse(many) + assert result["count"] == 500 + assert len(result["findings"]) == 500 \ No newline at end of file