"""Unit tests.""" import pytest from datadog_api_client.v2.model.state import State from src import app @pytest.fixture def mock_env(monkeypatch): """Set dummy config variables for Datadog credentials and Rule ID.""" monkeypatch.setattr(app.config, "DD_API_KEY", "fake_key") monkeypatch.setattr(app.config, "DD_APP_KEY", "fake_app_key") monkeypatch.setattr(app.config, "SCORECARD_RULE_ID", "test-rule") @pytest.fixture def mock_datadog_utils(mocker): """Mock the datadog_utils module functions.""" m_srv_list = mocker.patch("src.app.list_services_with_details") m_fe_list = mocker.patch("src.app.list_frontends_with_details") m_update = mocker.patch("src.app.update_scorecard_service_outcomes_batch") m_update_non_svc = mocker.patch( "src.app.update_scorecard_non_service_outcomes" ) m_query = mocker.patch("src.app._query_secret_scan_alerts") return m_srv_list, m_fe_list, m_update, m_update_non_svc, m_query def _make_entity(name, kind="service", repo_url=None): """Helper to build a catalog entity dict with optional codeLocations.""" raw_schema = {} if repo_url: raw_schema = { "datadog": {"code_locations": [{"repository_url": repo_url}]} } return { "name": name, "kind": kind, "languages": [], "tags": [], "raw_schema": raw_schema, } # --- Tests for handler --- def test_handler_missing_creds(monkeypatch): """Ensure handler raises ValueError if credentials are missing.""" monkeypatch.setattr(app.config, "DD_API_KEY", None) with pytest.raises(ValueError, match="Missing DD_API_KEY or DD_APP_KEY"): app.handler({}, {}) def test_handler_service_passes_no_alerts(mock_env, mock_datadog_utils): """Service with a repo and no alerts should PASS.""" m_srv, m_fe, m_update, m_update_non_svc, m_query = mock_datadog_utils m_srv.return_value = [ _make_entity("my-service", "service", "https://github.com/org/repo.git") ] m_fe.return_value = [] m_query.return_value = {} # No alerts app.handler({}, {}) m_update.assert_called_once() outcomes = m_update.call_args[0][2] assert len(outcomes) == 1 assert outcomes[0]["state"] == State.PASS assert outcomes[0]["service_name"] == "my-service" m_update_non_svc.assert_called_once_with("fake_key", "fake_app_key", []) def test_handler_service_fails_with_alerts(mock_env, mock_datadog_utils): """Service with a repo that has open alerts should FAIL.""" m_srv, m_fe, m_update, m_update_non_svc, m_query = mock_datadog_utils m_srv.return_value = [ _make_entity("my-service", "service", "https://github.com/org/repo.git") ] m_fe.return_value = [] m_query.return_value = {("org", "repo"): 3} app.handler({}, {}) m_update.assert_called_once() outcomes = m_update.call_args[0][2] assert len(outcomes) == 1 assert outcomes[0]["state"] == State.FAIL assert "3 open secret scan alert(s)" in outcomes[0]["remarks"] def test_handler_skip_no_repo_url(mock_env, mock_datadog_utils): """Entity without codeLocations should get SKIP.""" m_srv, m_fe, m_update, m_update_non_svc, m_query = mock_datadog_utils m_srv.return_value = [_make_entity("no-repo-svc", "service")] m_fe.return_value = [] m_query.return_value = {} app.handler({}, {}) m_update.assert_called_once() outcomes = m_update.call_args[0][2] assert len(outcomes) == 1 assert outcomes[0]["state"] == State.SKIP assert "No repositoryURL" in outcomes[0]["remarks"] def test_handler_frontend_non_service_outcomes(mock_env, mock_datadog_utils): """Frontend entities should be recorded as non-service outcomes.""" m_srv, m_fe, m_update, m_update_non_svc, m_query = mock_datadog_utils m_srv.return_value = [] m_fe.return_value = [ _make_entity( "my-frontend", "frontend", "https://github.com/org/frontend.git" ) ] m_query.return_value = {("org", "frontend"): 1} app.handler({}, {}) m_update.assert_called_once_with("fake_key", "fake_app_key", []) m_update_non_svc.assert_called_once() outcomes = m_update_non_svc.call_args[0][2] assert len(outcomes) == 1 assert outcomes[0]["state"] == State.FAIL assert outcomes[0]["entity_reference"] == "frontend:my-frontend" def test_handler_multiple_entities_same_repo(mock_env, mock_datadog_utils): """Multiple services sharing a repo should all get the same outcome.""" m_srv, m_fe, m_update, m_update_non_svc, m_query = mock_datadog_utils m_srv.return_value = [ _make_entity("svc-a", "service", "https://github.com/org/monorepo.git"), _make_entity("svc-b", "service", "https://github.com/org/monorepo.git"), ] m_fe.return_value = [] m_query.return_value = {} app.handler({}, {}) outcomes = m_update.call_args[0][2] assert len(outcomes) == 2 assert all(o["state"] == State.PASS for o in outcomes) def test_handler_case_insensitive_repo_matching(mock_env, mock_datadog_utils): """Repo matching should be case-insensitive.""" m_srv, m_fe, m_update, m_update_non_svc, m_query = mock_datadog_utils m_srv.return_value = [ _make_entity("svc", "service", "https://github.com/Org/Repo.git"), ] m_fe.return_value = [] m_query.return_value = {("org", "repo"): 2} # lowercase keys app.handler({}, {}) outcomes = m_update.call_args[0][2] assert len(outcomes) == 1 assert outcomes[0]["state"] == State.FAIL def test_handler_non_github_repo_gets_skip(mock_env, mock_datadog_utils): """Entity with a non-GitHub repo URL should get SKIP.""" m_srv, m_fe, m_update, m_update_non_svc, m_query = mock_datadog_utils m_srv.return_value = [ _make_entity("gitlab-svc", "service", "https://gitlab.com/org/repo.git"), ] m_fe.return_value = [] m_query.return_value = {} app.handler({}, {}) outcomes = m_update.call_args[0][2] assert len(outcomes) == 1 assert outcomes[0]["state"] == State.SKIP # --- Tests for _extract_repository_url --- def test_extract_repository_url_valid(): """Extract URL from properly structured entity.""" entity = _make_entity("svc", repo_url="https://github.com/org/repo.git") assert ( app._extract_repository_url(entity) == "https://github.com/org/repo.git" ) def test_extract_repository_url_missing_code_locations(): """Return None when no codeLocations in schema.""" entity = _make_entity("svc") assert app._extract_repository_url(entity) is None def test_extract_repository_url_empty_code_locations(): """Return None when codeLocations is empty list.""" entity = { "name": "svc", "kind": "service", "raw_schema": {"datadog": {"code_locations": []}}, } assert app._extract_repository_url(entity) is None def test_extract_repository_url_no_raw_schema(): """Return None when raw_schema key is missing.""" entity = {"name": "svc", "kind": "service"} assert app._extract_repository_url(entity) is None def test_extract_repository_url_camel_case_keys(): """Extract URL when schema uses camelCase keys.""" entity = { "name": "svc", "kind": "service", "raw_schema": { "datadog": { "codeLocations": [ {"repositoryURL": "https://github.com/org/camel.git"} ] } }, } assert ( app._extract_repository_url(entity) == "https://github.com/org/camel.git" ) def test_extract_repository_url_multiple_locations_first_valid(): """Return the first location with a valid URL.""" entity = { "name": "svc", "kind": "service", "raw_schema": { "datadog": { "code_locations": [ {"repository_url": ""}, {"repository_url": "https://github.com/org/second.git"}, ] } }, } assert ( app._extract_repository_url(entity) == "https://github.com/org/second.git" ) def test_extract_repository_url_location_missing_url_key(): """Return None when locations exist but have no URL keys.""" entity = { "name": "svc", "kind": "service", "raw_schema": {"datadog": {"code_locations": [{"paths": ["src/"]}]}}, } assert app._extract_repository_url(entity) is None def test_extract_repository_url_code_locations_not_a_list(): """Return None when code_locations is not a list.""" entity = { "name": "svc", "kind": "service", "raw_schema": {"datadog": {"code_locations": "invalid"}}, } assert app._extract_repository_url(entity) is None # --- Tests for _parse_github_url --- def test_parse_github_url_standard(): """Parse standard GitHub URL with .git suffix.""" assert app._parse_github_url("https://github.com/theorchard/my-app.git") == ( "theorchard", "my-app", ) def test_parse_github_url_no_git_suffix(): """Parse GitHub URL without .git suffix.""" assert app._parse_github_url("https://github.com/org/repo") == ("org", "repo") def test_parse_github_url_non_github(): """Return None for non-GitHub URLs.""" assert app._parse_github_url("https://gitlab.com/org/repo") is None def test_parse_github_url_insufficient_path(): """Return None when path has fewer than 2 segments.""" assert app._parse_github_url("https://github.com/org") is None def test_parse_github_url_empty_string(): """Return None for empty string.""" assert app._parse_github_url("") is None def test_parse_github_url_extra_path_segments(): """Parse URL with extra path segments beyond org/repo.""" assert app._parse_github_url("https://github.com/org/repo/tree/main") == ( "org", "repo", ) def test_parse_github_url_none_input(): """Return None for None input.""" assert app._parse_github_url(None) is None # --- Tests for _record_outcome --- def test_record_outcome_service(): """Service entities go into service_outcomes list.""" svc_outcomes = [] non_svc_outcomes = [] app._record_outcome( svc_outcomes, non_svc_outcomes, True, "my-svc", "service", "rule-1", State.PASS, "All good", ) assert len(svc_outcomes) == 1 assert svc_outcomes[0]["service_name"] == "my-svc" assert non_svc_outcomes == [] def test_record_outcome_non_service(): """Non-service entities go into non_service_outcomes with entity_reference.""" svc_outcomes = [] non_svc_outcomes = [] app._record_outcome( svc_outcomes, non_svc_outcomes, False, "my-fe", "frontend", "rule-1", State.FAIL, "Has alerts", ) assert len(non_svc_outcomes) == 1 assert non_svc_outcomes[0]["entity_reference"] == "frontend:my-fe" assert svc_outcomes == [] def test_record_outcome_skip(): """SKIP state recorded correctly.""" svc_outcomes = [] non_svc_outcomes = [] app._record_outcome( svc_outcomes, non_svc_outcomes, True, "skipped-svc", "service", "rule-1", State.SKIP, "No repo", ) assert svc_outcomes[0]["state"] == State.SKIP assert svc_outcomes[0]["remarks"] == "No repo" assert svc_outcomes[0]["rule_id"] == "rule-1" # --- Tests for _query_secret_scan_alerts --- def test_query_secret_scan_alerts_no_series(mocker, monkeypatch): """Return empty dict when no metric series returned.""" monkeypatch.setattr(app.config, "METRIC_QUERY_PERIOD_SECONDS", 3600) mock_api = mocker.MagicMock() mock_response = mocker.MagicMock() mock_response.series = None mock_api.query_metrics.return_value = mock_response mocker.patch("src.app.MetricsApi", return_value=mock_api) mocker.patch("src.app.ApiClient") result = app._query_secret_scan_alerts("key", "app_key") assert result == {} def test_query_secret_scan_alerts_parses_series(mocker, monkeypatch): """Parse series with scope tags into (account, repo) -> count dict.""" monkeypatch.setattr(app.config, "METRIC_QUERY_PERIOD_SECONDS", 3600) mock_point = mocker.MagicMock() mock_point.value = [1000000.0, 5.0] series_entry = { "scope": "account:myorg,repo:myrepo", "pointlist": [mock_point], } mock_response = mocker.MagicMock() mock_response.series = [series_entry] mock_api = mocker.MagicMock() mock_api.query_metrics.return_value = mock_response mocker.patch("src.app.MetricsApi", return_value=mock_api) mocker.patch("src.app.ApiClient") result = app._query_secret_scan_alerts("key", "app_key") assert result == {("myorg", "myrepo"): 5} def test_query_secret_scan_alerts_skips_zero_values(mocker, monkeypatch): """Skip series where the last point value is zero.""" monkeypatch.setattr(app.config, "METRIC_QUERY_PERIOD_SECONDS", 3600) mock_point = mocker.MagicMock() mock_point.value = [1000000.0, 0.0] series_entry = { "scope": "account:org,repo:repo", "pointlist": [mock_point], } mock_response = mocker.MagicMock() mock_response.series = [series_entry] mock_api = mocker.MagicMock() mock_api.query_metrics.return_value = mock_response mocker.patch("src.app.MetricsApi", return_value=mock_api) mocker.patch("src.app.ApiClient") result = app._query_secret_scan_alerts("key", "app_key") assert result == {} def test_query_secret_scan_alerts_skips_missing_tags(mocker, monkeypatch): """Skip series missing account or repo tags.""" monkeypatch.setattr(app.config, "METRIC_QUERY_PERIOD_SECONDS", 3600) mock_point = mocker.MagicMock() mock_point.value = [1000000.0, 3.0] series_entry = { "scope": "account:org", # missing repo "pointlist": [mock_point], } mock_response = mocker.MagicMock() mock_response.series = [series_entry] mock_api = mocker.MagicMock() mock_api.query_metrics.return_value = mock_response mocker.patch("src.app.MetricsApi", return_value=mock_api) mocker.patch("src.app.ApiClient") result = app._query_secret_scan_alerts("key", "app_key") assert result == {} def test_query_secret_scan_alerts_lowercases_keys(mocker, monkeypatch): """Account and repo keys should be lowercased.""" monkeypatch.setattr(app.config, "METRIC_QUERY_PERIOD_SECONDS", 3600) mock_point = mocker.MagicMock() mock_point.value = [1000000.0, 2.0] series_entry = { "scope": "account:MyOrg,repo:MyRepo", "pointlist": [mock_point], } mock_response = mocker.MagicMock() mock_response.series = [series_entry] mock_api = mocker.MagicMock() mock_api.query_metrics.return_value = mock_response mocker.patch("src.app.MetricsApi", return_value=mock_api) mocker.patch("src.app.ApiClient") result = app._query_secret_scan_alerts("key", "app_key") assert result == {("myorg", "myrepo"): 2}