diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
index 2872be0..04b7b75 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e.yml
@@ -7,10 +7,38 @@ on:
branches: [main]
jobs:
+ backend-tests:
+ name: Backend Tests
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v4
+ with:
+ python-version: '3.12'
+ - name: Install dependencies
+ working-directory: backend
+ run: pip install -r requirements.txt
+ - name: Run pytest
+ working-directory: backend
+ run: python -m pytest tests/ -v --tb=short
+ env:
+ TESTING: "true"
+ INTERNAL_TOKEN: "test-token"
+ DATABASE_URL: ""
+ - name: Upload results on failure
+ uses: actions/upload-artifact@v4
+ if: failure()
+ with:
+ name: pytest-results
+ path: backend/
+ retention-days: 7
+
test:
name: Playwright E2E
runs-on: ubuntu-latest
timeout-minutes: 20
+ needs: backend-tests
defaults:
run:
diff --git a/backend/app.py b/backend/app.py
index d54773b..d56b25d 100644
--- a/backend/app.py
+++ b/backend/app.py
@@ -7,6 +7,7 @@
import uuid
import logging
import copy
+import json
import time
# Ensure backend directory is in Python path for local imports
@@ -581,20 +582,20 @@ def scan_package_deep():
elif risk_score >= 1: risk_label = 'Low'
else: risk_label = 'Secure'
- # Build grouped_packages to satisfy frontend contract
+ # Build grouped_packages to satisfy frontend contract.
+ # group_vulns_by_package returns a list of group dicts, not a dict.
grouped_vulns = group_vulns_by_package(vulnerabilities)
- grouped_packages = []
- for pkg_name_key, pkg_vulns in grouped_vulns.items():
- sevs = [v.get('severity', 'LOW') for v in pkg_vulns]
- highest = next((s for s in ['CRITICAL','HIGH','MEDIUM','LOW'] if s in sevs), 'LOW')
- grouped_packages.append({
- 'package': pkg_name_key,
- 'version': pkg_vulns[0].get('installed_version', ''),
+ grouped_packages = [
+ {
+ 'package': g['package'],
+ 'version': g['version'],
'is_direct': True,
- 'vulnerabilities': pkg_vulns,
- 'highestSeverity': highest,
- 'recommended_fix': next((v.get('fix_version') for v in pkg_vulns if v.get('fix_version')), None),
- })
+ 'vulnerabilities': g['cves'],
+ 'highestSeverity': g['highest_severity'],
+ 'recommended_fix': g['recommended_fix'],
+ }
+ for g in grouped_vulns
+ ]
# Build fixes list
fixes = []
diff --git a/backend/db.py b/backend/db.py
index dd183ae..cdc8455 100644
--- a/backend/db.py
+++ b/backend/db.py
@@ -183,16 +183,13 @@ def purge_expired_scan_snapshots():
log.warning(f"Scan snapshot purge error: {e}")
def last_sync_time(source: str):
- """Return ISO timestamp of last successful sync for a source, or None."""
- try:
- with get_conn() as conn:
- with get_cursor(conn) as cur:
- cur.execute(
- "SELECT synced_at FROM sync_log WHERE source=%s AND status='ok' ORDER BY synced_at DESC LIMIT 1",
- (source,)
- )
- row = cur.fetchone()
- return row["synced_at"].isoformat() if row else None
- except Exception as e:
- log.warning(f"last_sync_time error: {e}")
- return None
+ """Return ISO timestamp of last successful sync for a source, or None.
+ Raises on DB connection failure so callers can detect real DB outages."""
+ with get_conn() as conn:
+ with get_cursor(conn) as cur:
+ cur.execute(
+ "SELECT synced_at FROM sync_log WHERE source=%s AND status='ok' ORDER BY synced_at DESC LIMIT 1",
+ (source,)
+ )
+ row = cur.fetchone()
+ return row["synced_at"].isoformat() if row else None
diff --git a/backend/resolvers/lockfile_resolver.py b/backend/resolvers/lockfile_resolver.py
index 2e5a726..cc7c439 100644
--- a/backend/resolvers/lockfile_resolver.py
+++ b/backend/resolvers/lockfile_resolver.py
@@ -3,7 +3,7 @@
All versions are already resolved in the lock file.
"""
-def resolve(direct_deps):
+def resolve(direct_deps, max_depth=2):
"""Build graph from lock file — fast, no HTTP calls.
Lock files don't expose explicit parent→child mapping in a simple form,
diff --git a/backend/resolvers/maven_resolver.py b/backend/resolvers/maven_resolver.py
index dbc0688..6bb9a4b 100644
--- a/backend/resolvers/maven_resolver.py
+++ b/backend/resolvers/maven_resolver.py
@@ -36,14 +36,20 @@ def _fetch_deps(group, artifact, version):
if res.status_code != 200:
return {}
root = ET.fromstring(res.content)
+ # Maven POMs declare xmlns="http://maven.apache.org/POM/4.0.0".
+ # ElementTree represents every tag as {http://...}tagname, so plain
+ # .iter('dependency') matches nothing. Use the explicit namespace.
+ # Falls back to unnamespaced tags for legacy POMs via the _ns helper.
+ _pom_ns = 'http://maven.apache.org/POM/4.0.0'
+ _ns = lambda tag: f'{{{_pom_ns}}}{tag}' if root.tag.startswith(f'{{{_pom_ns}}}') else tag
deps = {}
- for dep in root.iter('dependency'):
- scope = dep.findtext('scope') or 'compile'
+ for dep in root.iter(_ns('dependency')):
+ scope = dep.findtext(_ns('scope')) or 'compile'
if scope in ('test', 'provided', 'system'):
continue
- g = dep.findtext('groupId') or ''
- a = dep.findtext('artifactId') or ''
- v = dep.findtext('version') or 'unknown'
+ g = dep.findtext(_ns('groupId')) or ''
+ a = dep.findtext(_ns('artifactId')) or ''
+ v = dep.findtext(_ns('version')) or 'unknown'
if '${' in v:
v = 'unknown'
deps[f"{g}:{a}"] = {'group': g, 'artifact': a, 'version': v}
diff --git a/backend/resolvers/npm_resolver.py b/backend/resolvers/npm_resolver.py
index fc68894..fd22b37 100644
--- a/backend/resolvers/npm_resolver.py
+++ b/backend/resolvers/npm_resolver.py
@@ -42,7 +42,7 @@ def _fetch_deps(name, version):
cached = get_resolver_cache(f"npm:{key}")
if cached is not None:
with _cache_lock:
- _cache[key] = {'deps': cached, 'ts': time.time()}
+ _cache[key] = {'data': cached, 'ts': time.time()}
return cached
except Exception:
pass
diff --git a/backend/resolvers/pypi_resolver.py b/backend/resolvers/pypi_resolver.py
index 858ec41..5241a0b 100644
--- a/backend/resolvers/pypi_resolver.py
+++ b/backend/resolvers/pypi_resolver.py
@@ -42,8 +42,16 @@ def _fetch_deps(name, version):
continue
dep_name = match.group(1)
ver_str = match.group(2) or ''
- ver_match = re.search(r'[\d\.]+', ver_str)
- deps[dep_name] = ver_match.group(0) if ver_match else 'latest'
+ # Prefer a lower bound (>=x or >x) so we scan a version that
+ # actually satisfies the constraint. Without this, constraints
+ # starting with an upper bound like (<1.27,>=1.21.1) would have
+ # the first number extracted as 1.27 — an invalid upper limit.
+ lb_match = re.search(r'>=?\s*([\d][^,\s;]*)', ver_str)
+ if lb_match:
+ deps[dep_name] = lb_match.group(1).strip()
+ else:
+ any_match = re.search(r'[\d\.]+', ver_str)
+ deps[dep_name] = any_match.group(0) if any_match else 'latest'
with _cache_lock:
_cache[key] = deps
# Evict oldest entries if cache exceeds max size
diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py
new file mode 100644
index 0000000..3b8392b
--- /dev/null
+++ b/backend/tests/conftest.py
@@ -0,0 +1,27 @@
+"""
+conftest.py — shared fixtures for the backend test suite.
+
+No fake CVE IDs, no synthetic OSV payloads, no hardcoded mock registry
+responses are stored here. All vulnerability assertions are made against
+the live OSV API using real package/version pairs with documented CVEs.
+"""
+import os
+import sys
+import pytest
+
+# Must be set before any app import so the scheduler and DB init are skipped.
+os.environ['DISABLE_SCHEDULER'] = 'true'
+os.environ.setdefault('DATABASE_URL', 'postgresql://test:badpass@localhost/nonexistent_test')
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+
+
+@pytest.fixture
+def client():
+ """Flask test client. Resets the in-memory rate-limiter store between
+ tests so rate-limit assertions do not bleed across test functions."""
+ from app import app, _rate_limiter
+ app.config['TESTING'] = True
+ _rate_limiter._store.clear()
+ with app.test_client() as c:
+ yield c
diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py
new file mode 100644
index 0000000..e886cd9
--- /dev/null
+++ b/backend/tests/test_api.py
@@ -0,0 +1,230 @@
+"""
+test_api.py — Flask API endpoint tests using real packages and live scanning.
+
+No scan_tree, no RESOLVERS, no OSV calls are mocked.
+Real packages with documented CVEs are used:
+ npm : minimist@1.2.5 (CVE-2021-44906)
+ PyPI : urllib3@1.26.4 (CVE-2021-33503)
+ Maven: log4j:log4j@1.2.17 (CVE-2019-17571)
+ lockfile: package-lock.json with minimist@1.2.5
+
+Input validation and rate-limiting tests do not scan real packages so they
+remain fast. The rate-limit ceiling (RATE_LIMIT) is patched only for the
+rate-limiting test — no CVE data is involved.
+"""
+import json
+import sys
+import os
+import pytest
+from unittest.mock import patch
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+
+
+# ── payload builders ──────────────────────────────────────────────────────────
+
+def _npm(pkg, ver, project='test-project'):
+ return {
+ "content": json.dumps({
+ "name": project, "version": "1.0.0",
+ "dependencies": {pkg: ver},
+ }),
+ "filename": "package.json",
+ "project_name": project,
+ }
+
+def _pypi(content, project='test-project'):
+ return {"content": content, "filename": "requirements.txt",
+ "project_name": project}
+
+def _maven(pom_xml, project='test-project'):
+ return {"content": pom_xml, "filename": "pom.xml",
+ "project_name": project}
+
+def _lockfile(lf_json, project='test-project'):
+ return {"content": lf_json, "filename": "package-lock.json",
+ "project_name": project}
+
+def _scan(client, payload):
+ return client.post('/api/scan',
+ data=json.dumps(payload),
+ content_type='application/json')
+
+
+# ── npm scans ─────────────────────────────────────────────────────────────────
+
+class TestScanNpm:
+ def test_minimist_returns_200(self, client):
+ """POST /api/scan with minimist@1.2.5 returns HTTP 200."""
+ resp = _scan(client, _npm('minimist', '1.2.5'))
+ assert resp.status_code == 200, resp.data
+
+ def test_minimist_finds_cve_2021_44906(self, client):
+ """Full npm scan with minimist@1.2.5 must return CVE-2021-44906."""
+ resp = _scan(client, _npm('minimist', '1.2.5'))
+ body = resp.get_json()
+ cve_ids = [v['cve_id'] for v in body.get('vulnerabilities', [])]
+ assert 'CVE-2021-44906' in cve_ids, (
+ f"CVE-2021-44906 missing from npm scan.\nFound: {cve_ids}"
+ )
+
+ def test_npm_response_structure(self, client):
+ """npm scan response must include project_name, ecosystem, summary, graph."""
+ resp = _scan(client, _npm('minimist', '1.2.5'))
+ body = resp.get_json()
+ for key in ('project_name', 'ecosystem', 'summary', 'vulnerabilities', 'graph'):
+ assert key in body, f"Missing key '{key}' in response"
+
+ def test_npm_ecosystem_field(self, client):
+ """ecosystem field must be 'npm' for package.json input."""
+ resp = _scan(client, _npm('minimist', '1.2.5'))
+ assert resp.get_json()['ecosystem'] == 'npm'
+
+ def test_npm_risk_score_nonzero_for_vulnerable_package(self, client):
+ """risk_score must be > 0 when a vulnerable package is scanned."""
+ resp = _scan(client, _npm('minimist', '1.2.5'))
+ assert resp.get_json()['summary']['risk_score'] > 0
+
+ def test_npm_fix_version_provided(self, client):
+ """fix_version must be set for CVE-2021-44906 in the scan result."""
+ from packaging.version import Version
+ resp = _scan(client, _npm('minimist', '1.2.5'))
+ body = resp.get_json()
+ hit = next((v for v in body['vulnerabilities']
+ if v['cve_id'] == 'CVE-2021-44906'), None)
+ assert hit is not None
+ assert hit['fix_version'] is not None
+ assert Version(hit['fix_version']) >= Version('1.2.6')
+
+
+# ── PyPI scans ────────────────────────────────────────────────────────────────
+
+class TestScanPypi:
+ def test_urllib3_returns_200(self, client):
+ """POST /api/scan with urllib3==1.26.4 returns HTTP 200."""
+ resp = _scan(client, _pypi("urllib3==1.26.4\n"))
+ assert resp.status_code == 200, resp.data
+
+ def test_urllib3_finds_cve_2021_33503(self, client):
+ """Full PyPI scan with urllib3@1.26.4 must return CVE-2021-33503."""
+ resp = _scan(client, _pypi("urllib3==1.26.4\n"))
+ body = resp.get_json()
+ cve_ids = [v['cve_id'] for v in body.get('vulnerabilities', [])]
+ assert 'CVE-2021-33503' in cve_ids, (
+ f"CVE-2021-33503 missing.\nFound: {cve_ids}"
+ )
+
+ def test_pypi_ecosystem_field(self, client):
+ """ecosystem field must be 'pypi' for requirements.txt input."""
+ resp = _scan(client, _pypi("urllib3==1.26.4\n"))
+ assert resp.get_json()['ecosystem'] == 'pypi'
+
+
+# ── Maven scans ───────────────────────────────────────────────────────────────
+
+class TestScanMaven:
+ LOG4J_POM = """
+
+ com.example
+ test-app
+ 1.0.0
+
+
+ log4j
+ log4j
+ 1.2.17
+
+
+"""
+
+ def test_log4j_returns_200(self, client):
+ """POST /api/scan with log4j@1.2.17 returns HTTP 200."""
+ resp = _scan(client, _maven(self.LOG4J_POM))
+ assert resp.status_code == 200, resp.data
+
+ def test_log4j_finds_cve_2019_17571(self, client):
+ """Full Maven scan with log4j@1.2.17 must return CVE-2019-17571."""
+ resp = _scan(client, _maven(self.LOG4J_POM))
+ body = resp.get_json()
+ cve_ids = [v['cve_id'] for v in body.get('vulnerabilities', [])]
+ assert 'CVE-2019-17571' in cve_ids, (
+ f"CVE-2019-17571 missing from Maven scan.\nFound: {cve_ids}"
+ )
+
+ def test_maven_ecosystem_field(self, client):
+ """ecosystem field must be 'maven' for pom.xml input."""
+ resp = _scan(client, _maven(self.LOG4J_POM))
+ assert resp.get_json()['ecosystem'] == 'maven'
+
+
+# ── Lockfile scans ────────────────────────────────────────────────────────────
+
+class TestScanLockfile:
+ LOCKFILE = json.dumps({
+ "name": "test-app",
+ "version": "1.0.0",
+ "lockfileVersion": 2,
+ "packages": {
+ "": {"dependencies": {"minimist": "1.2.5"}},
+ "node_modules/minimist": {"version": "1.2.5"},
+ }
+ })
+
+ def test_lockfile_returns_200(self, client):
+ """POST /api/scan with a package-lock.json returns HTTP 200."""
+ resp = _scan(client, _lockfile(self.LOCKFILE))
+ assert resp.status_code == 200, resp.data
+
+ def test_lockfile_finds_cve_2021_44906(self, client):
+ """Lockfile scan with minimist@1.2.5 must find CVE-2021-44906."""
+ resp = _scan(client, _lockfile(self.LOCKFILE))
+ body = resp.get_json()
+ cve_ids = [v['cve_id'] for v in body.get('vulnerabilities', [])]
+ assert 'CVE-2021-44906' in cve_ids, (
+ f"CVE-2021-44906 missing from lockfile scan.\nFound: {cve_ids}"
+ )
+
+
+# ── Input validation (no real scan needed) ────────────────────────────────────
+
+class TestInputValidation:
+ def test_missing_content_returns_400(self, client):
+ """Request without 'content' field returns HTTP 400."""
+ resp = _scan(client, {"filename": "package.json"})
+ assert resp.status_code == 400
+
+ def test_unsupported_filename_returns_400(self, client):
+ """An unrecognised filename (e.g. Gemfile) returns HTTP 400."""
+ resp = _scan(client, {"content": "gem 'rails'", "filename": "Gemfile"})
+ assert resp.status_code == 400
+
+ def test_content_too_large_returns_413(self, client):
+ """Content exceeding Flask's max_content_length is rejected with HTTP 413."""
+ resp = _scan(client, {"content": "x" * (512 * 1024 + 1),
+ "filename": "package.json"})
+ assert resp.status_code == 413
+
+ def test_malformed_npm_json_returns_400(self, client):
+ """Invalid JSON for package.json returns HTTP 400."""
+ resp = _scan(client, {"content": "not valid json {{{",
+ "filename": "package.json"})
+ assert resp.status_code == 400
+
+
+# ── Rate limiting ─────────────────────────────────────────────────────────────
+
+class TestRateLimiting:
+ def test_exceeding_rate_limit_returns_429(self, client):
+ """After exceeding the rate limit ceiling the endpoint returns HTTP 429.
+ RATE_LIMIT is patched to 3 requests so we do not need to send 20 real scans."""
+ payload = {
+ "content": "not json",
+ "filename": "package.json",
+ }
+ with patch('app.RATE_LIMIT', 3):
+ resps = [
+ client.post('/api/scan', data=json.dumps(payload),
+ content_type='application/json')
+ for _ in range(4)
+ ]
+ assert 429 in [r.status_code for r in resps]
diff --git a/backend/tests/test_cve.py b/backend/tests/test_cve.py
new file mode 100644
index 0000000..027903f
--- /dev/null
+++ b/backend/tests/test_cve.py
@@ -0,0 +1,230 @@
+"""
+test_cve.py — Tests for the CVE scanning pipeline against the live OSV API.
+
+All assertions use real CVE IDs for real packages:
+ minimist@1.2.5 → CVE-2021-44906 (Prototype Pollution, fixed in 1.2.6)
+ semver@5.7.1 → CVE-2022-25883 (ReDoS)
+ urllib3@1.26.4 → CVE-2021-33503 (ReDoS)
+ log4j:log4j@1.2.17 → CVE-2019-17571 (Deserialization)
+
+No OSV HTTP calls are mocked.
+DB calls are not mocked — the scanner falls back to live OSV automatically
+when the DB is unreachable (expected in test environments).
+"""
+import sys
+import os
+import pytest
+from packaging.version import Version
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+
+
+# ── helpers ───────────────────────────────────────────────────────────────────
+
+def _cve_ids_from_raw(vulns):
+ """Extract all CVE- aliases from a list of raw OSV vuln dicts."""
+ ids = []
+ for v in vulns:
+ ids.extend(a for a in v.get('aliases', []) if a.startswith('CVE-'))
+ if v.get('id', '').startswith('CVE-'):
+ ids.append(v['id'])
+ return ids
+
+
+def setup_module():
+ """Clear the scan cache so previous test runs do not hide live API failures."""
+ from cve import scanner
+ scanner._scan_cache.clear()
+
+
+# ── osv_client — real HTTP calls to api.osv.dev ───────────────────────────────
+
+class TestOsvClientLive:
+ def test_minimist_1_2_5_returns_vulns(self):
+ """query_package returns at least one vulnerability for minimist@1.2.5."""
+ from cve.osv_client import query_package
+ result = query_package('minimist', '1.2.5', 'npm')
+ assert result, "OSV returned no vulnerabilities for minimist@1.2.5"
+
+ def test_minimist_1_2_5_contains_cve_2021_44906(self):
+ """CVE-2021-44906 (Prototype Pollution) must be in the OSV response
+ for minimist@1.2.5."""
+ from cve.osv_client import query_package
+ result = query_package('minimist', '1.2.5', 'npm')
+ assert 'CVE-2021-44906' in _cve_ids_from_raw(result), (
+ f"CVE-2021-44906 missing from OSV response.\nReturned: {_cve_ids_from_raw(result)}"
+ )
+
+ def test_semver_5_7_1_contains_cve_2022_25883(self):
+ """CVE-2022-25883 (ReDoS) must be present for semver@5.7.1."""
+ from cve.osv_client import query_package
+ result = query_package('semver', '5.7.1', 'npm')
+ assert 'CVE-2022-25883' in _cve_ids_from_raw(result), (
+ f"CVE-2022-25883 missing.\nReturned: {_cve_ids_from_raw(result)}"
+ )
+
+ def test_urllib3_1_26_4_contains_cve_2021_33503(self):
+ """CVE-2021-33503 (ReDoS in netloc) must be present for urllib3@1.26.4."""
+ from cve.osv_client import query_package
+ result = query_package('urllib3', '1.26.4', 'pypi')
+ assert 'CVE-2021-33503' in _cve_ids_from_raw(result), (
+ f"CVE-2021-33503 missing.\nReturned: {_cve_ids_from_raw(result)}"
+ )
+
+ def test_log4j_1_2_17_contains_cve_2019_17571(self):
+ """CVE-2019-17571 (Deserialization) must be present for log4j:log4j@1.2.17."""
+ from cve.osv_client import query_package
+ result = query_package('log4j:log4j', '1.2.17', 'maven')
+ assert 'CVE-2019-17571' in _cve_ids_from_raw(result), (
+ f"CVE-2019-17571 missing.\nReturned: {_cve_ids_from_raw(result)}"
+ )
+
+ def test_minimist_1_2_6_cve_2021_44906_not_affected(self):
+ """minimist@1.2.6 is the patched release — format_vuln must filter it out."""
+ from cve.osv_client import query_package, format_vuln
+ raw = query_package('minimist', '1.2.6', 'npm')
+ formatted = [format_vuln(v, 'minimist', '1.2.6') for v in raw]
+ formatted = [f for f in formatted if f is not None]
+ assert 'CVE-2021-44906' not in [f['cve_id'] for f in formatted], (
+ "CVE-2021-44906 must NOT match minimist@1.2.6 (the fix release)"
+ )
+
+
+# ── format_vuln — real OSV payload → structured output ───────────────────────
+
+class TestFormatVulnLive:
+ def test_minimist_fix_version_is_gte_1_2_6(self):
+ """format_vuln must derive a fix_version >= 1.2.6 for minimist@1.2.5."""
+ from cve.osv_client import query_package, format_vuln
+ raw = query_package('minimist', '1.2.5', 'npm')
+ formatted = [format_vuln(v, 'minimist', '1.2.5') for v in raw]
+ formatted = [f for f in formatted if f is not None]
+ hit = next((f for f in formatted if f['cve_id'] == 'CVE-2021-44906'), None)
+ assert hit is not None, "CVE-2021-44906 not in formatted results"
+ assert hit['fix_version'] is not None
+ assert Version(hit['fix_version']) >= Version('1.2.6')
+
+ def test_formatted_vuln_has_required_fields(self):
+ """Every formatted vuln must carry cve_id, severity, cvss_score,
+ fix_version, fix, osv_url, source."""
+ from cve.osv_client import query_package, format_vuln
+ raw = query_package('minimist', '1.2.5', 'npm')
+ formatted = [format_vuln(v, 'minimist', '1.2.5') for v in raw if v]
+ formatted = [f for f in formatted if f is not None]
+ assert formatted, "No formatted results"
+ required = ('cve_id', 'severity', 'cvss_score', 'fix_version', 'fix', 'source', 'osv_url')
+ for f in formatted:
+ for field in required:
+ assert field in f, f"Missing field '{field}' in {f.get('cve_id')}"
+
+ def test_formatted_severity_is_valid_value(self):
+ """Severity must be one of CRITICAL / HIGH / MEDIUM / LOW."""
+ from cve.osv_client import query_package, format_vuln
+ raw = query_package('semver', '5.7.1', 'npm')
+ formatted = [format_vuln(v, 'semver', '5.7.1') for v in raw]
+ formatted = [f for f in formatted if f is not None]
+ for f in formatted:
+ assert f['severity'] in ('CRITICAL', 'HIGH', 'MEDIUM', 'LOW'), (
+ f"Unexpected severity '{f['severity']}' for {f['cve_id']}"
+ )
+
+ def test_source_is_osv(self):
+ """Formatted vulns from the OSV client always carry source='OSV'."""
+ from cve.osv_client import query_package, format_vuln
+ raw = query_package('minimist', '1.2.5', 'npm')
+ formatted = [format_vuln(v, 'minimist', '1.2.5') for v in raw]
+ formatted = [f for f in formatted if f is not None]
+ for f in formatted:
+ assert f['source'] == 'OSV', f"Expected source=OSV, got {f['source']}"
+
+ def test_log4j_deserialization_severity_at_least_high(self):
+ """CVE-2019-17571 (log4j deserialization) must be at least HIGH severity."""
+ from cve.osv_client import query_package, format_vuln
+ raw = query_package('log4j:log4j', '1.2.17', 'maven')
+ formatted = [format_vuln(v, 'log4j:log4j', '1.2.17') for v in raw]
+ formatted = [f for f in formatted if f is not None]
+ hit = next((f for f in formatted if f['cve_id'] == 'CVE-2019-17571'), None)
+ assert hit is not None, "CVE-2019-17571 not in formatted results"
+ assert hit['severity'] in ('CRITICAL', 'HIGH'), (
+ f"CVE-2019-17571 should be HIGH or CRITICAL, got {hit['severity']}"
+ )
+
+
+# ── scanner.scan_package — DB-first, live OSV fallback ───────────────────────
+
+class TestScanPackageLive:
+ def setup_method(self):
+ from cve import scanner
+ scanner._scan_cache.clear()
+
+ def test_minimist_scan_finds_cve_2021_44906(self):
+ """scan_package returns CVE-2021-44906 for minimist@1.2.5."""
+ from cve.scanner import scan_package
+ results = scan_package('minimist', '1.2.5', 'npm')
+ assert results, "No vulnerabilities returned for minimist@1.2.5"
+ assert 'CVE-2021-44906' in [r['cve_id'] for r in results]
+
+ def test_urllib3_scan_finds_cve_2021_33503(self):
+ """scan_package returns CVE-2021-33503 for urllib3@1.26.4."""
+ from cve.scanner import scan_package
+ results = scan_package('urllib3', '1.26.4', 'pypi')
+ assert results, "No vulnerabilities returned for urllib3@1.26.4"
+ assert 'CVE-2021-33503' in [r['cve_id'] for r in results]
+
+ def test_log4j_scan_finds_cve_2019_17571(self):
+ """scan_package returns CVE-2019-17571 for log4j:log4j@1.2.17."""
+ from cve.scanner import scan_package
+ results = scan_package('log4j:log4j', '1.2.17', 'maven')
+ assert results, "No vulnerabilities returned for log4j:log4j@1.2.17"
+ assert 'CVE-2019-17571' in [r['cve_id'] for r in results]
+
+ def test_scan_package_result_fields(self):
+ """Every result from scan_package must include the standard output fields."""
+ from cve.scanner import scan_package
+ results = scan_package('minimist', '1.2.5', 'npm')
+ assert results
+ for r in results:
+ for field in ('cve_id', 'severity', 'cvss_score', 'fix_version', 'source'):
+ assert field in r, f"Field '{field}' missing for {r.get('cve_id')}"
+ assert r['severity'] in ('CRITICAL', 'HIGH', 'MEDIUM', 'LOW')
+
+
+# ── scanner.scan_tree — BFS traversal of real dep graph ──────────────────────
+
+class TestScanTreeLive:
+ def setup_method(self):
+ from cve import scanner
+ scanner._scan_cache.clear()
+
+ def test_scan_tree_finds_cve_in_direct_dep(self):
+ """scan_tree on a single direct dep (minimist@1.2.5) finds CVE-2021-44906."""
+ from cve.scanner import scan_tree
+ graph = [{'name': 'minimist', 'version': '1.2.5',
+ 'type': 'direct', 'dependencies': []}]
+ results = scan_tree(graph, 'npm', app_name='test-app', max_depth=1)
+ assert results, "scan_tree found no vulnerabilities"
+ assert 'CVE-2021-44906' in [r['cve_id'] for r in results]
+
+ def test_scan_tree_attaches_correct_path(self):
+ """scan_tree sets path=[app_name, pkg_name] for a direct dependency."""
+ from cve.scanner import scan_tree
+ graph = [{'name': 'minimist', 'version': '1.2.5',
+ 'type': 'direct', 'dependencies': []}]
+ results = scan_tree(graph, 'npm', app_name='my-service', max_depth=1)
+ for r in results:
+ assert r['path'][0] == 'my-service'
+ assert 'minimist' in r['path']
+
+ def test_scan_tree_marks_direct_dep(self):
+ """Vulnerabilities in direct deps must have is_direct=True."""
+ from cve.scanner import scan_tree
+ graph = [{'name': 'minimist', 'version': '1.2.5',
+ 'type': 'direct', 'dependencies': []}]
+ results = scan_tree(graph, 'npm', app_name='test-app', max_depth=1)
+ assert all(r['is_direct'] for r in results)
+
+ def test_scan_tree_empty_graph_returns_empty(self):
+ """scan_tree with no deps returns an empty list (no OSV call made)."""
+ from cve.scanner import scan_tree
+ results = scan_tree([], 'npm', app_name='empty-app', max_depth=2)
+ assert results == []
diff --git a/backend/tests/test_graph.py b/backend/tests/test_graph.py
new file mode 100644
index 0000000..f6a8cde
--- /dev/null
+++ b/backend/tests/test_graph.py
@@ -0,0 +1,84 @@
+"""
+test_graph.py — Dependency graph structure tests using live scans.
+
+No resolver or scanner mocks. Uses minimist@1.2.5 (a leaf package with a
+known CVE) to verify that the graph object returned by /api/scan has the
+correct structure, node types, path annotations, and vulnerability data.
+"""
+import json
+import sys
+import os
+import pytest
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+
+NPM_MINIMIST = {
+ "content": json.dumps({
+ "name": "graph-test-app",
+ "version": "1.0.0",
+ "dependencies": {"minimist": "1.2.5"},
+ }),
+ "filename": "package.json",
+ "project_name": "graph-test-app",
+}
+
+
+class TestGraphStructure:
+ def _scan(self, client):
+ resp = client.post('/api/scan',
+ data=json.dumps(NPM_MINIMIST),
+ content_type='application/json')
+ assert resp.status_code == 200
+ return resp.get_json()
+
+ def test_graph_key_present(self, client):
+ """Response must contain a 'graph' key."""
+ body = self._scan(client)
+ assert 'graph' in body
+
+ def test_graph_root_name_matches_project(self, client):
+ """Root node name must match the submitted project_name."""
+ body = self._scan(client)
+ assert body['graph']['name'] == 'graph-test-app'
+
+ def test_graph_root_type_is_root(self, client):
+ """Root graph node must have type='root'."""
+ body = self._scan(client)
+ assert body['graph']['type'] == 'root'
+
+ def test_graph_root_vulnerabilities_empty(self, client):
+ """Root node vulnerabilities list must always be empty."""
+ body = self._scan(client)
+ assert body['graph']['vulnerabilities'] == []
+
+ def test_minimist_appears_as_direct_dep(self, client):
+ """minimist must appear as a 'direct' child of the root."""
+ body = self._scan(client)
+ children = body['graph']['dependencies']
+ mini = next((n for n in children if n['name'] == 'minimist'), None)
+ assert mini is not None, "minimist not found in graph children"
+ assert mini['type'] == 'direct'
+
+ def test_vulnerable_package_has_vuln_data_in_graph(self, client):
+ """minimist@1.2.5 must carry CVE-2021-44906 in its graph node."""
+ body = self._scan(client)
+ children = body['graph']['dependencies']
+ mini = next((n for n in children if n['name'] == 'minimist'), None)
+ assert mini is not None
+ cve_ids = [v['cve_id'] for v in mini.get('vulnerabilities', [])]
+ assert 'CVE-2021-44906' in cve_ids, (
+ f"CVE-2021-44906 not in minimist graph node.\nFound: {cve_ids}"
+ )
+
+ def test_summary_total_packages_at_least_one(self, client):
+ """summary.total_packages must be >= 1 (minimist was scanned)."""
+ body = self._scan(client)
+ assert body['summary']['total_packages'] >= 1
+
+ def test_vuln_path_starts_with_project_name(self, client):
+ """Every vulnerability path must start with the project name."""
+ body = self._scan(client)
+ for v in body.get('vulnerabilities', []):
+ assert v['path'][0] == 'graph-test-app', (
+ f"Path {v['path']} does not start with 'graph-test-app'"
+ )
diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py
new file mode 100644
index 0000000..e4a0925
--- /dev/null
+++ b/backend/tests/test_health.py
@@ -0,0 +1,51 @@
+"""
+test_health.py — Tests for the GET /api/health endpoint.
+Covers DB-connected path, DB-failure path, and field structure.
+All DB calls are mocked; no real database.
+"""
+import sys
+import os
+import json
+import pytest
+from unittest.mock import patch
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+
+
+class TestHealthEndpoint:
+ def test_health_returns_200(self, client):
+ """GET /api/health always returns HTTP 200 regardless of DB state."""
+ with patch('db.last_sync_time', return_value=None):
+ resp = client.get('/api/health')
+ assert resp.status_code == 200
+
+ def test_health_db_connected_true_when_db_accessible(self, client):
+ """db_connected is True when last_sync_time() returns without raising."""
+ with patch('db.last_sync_time', return_value='2024-06-01T00:00:00+00:00'):
+ resp = client.get('/api/health')
+ body = resp.get_json()
+ assert body['db_connected'] is True
+
+ def test_health_db_connected_false_when_db_raises(self, client):
+ """db_connected is False when last_sync_time() raises (DB unreachable).
+ This is the regression test for Bug 2 — the old last_sync_time() swallowed
+ exceptions, causing db_connected to always be True even when DB was down."""
+ with patch('db.last_sync_time', side_effect=Exception('connection refused')):
+ resp = client.get('/api/health')
+ body = resp.get_json()
+ assert body['db_connected'] is False
+
+ def test_health_response_has_required_fields(self, client):
+ """Response JSON includes status, db_connected, and version fields."""
+ with patch('db.last_sync_time', return_value=None):
+ resp = client.get('/api/health')
+ body = resp.get_json()
+ assert 'status' in body
+ assert 'db_connected' in body
+
+ def test_health_status_is_ok(self, client):
+ """status field is always 'ok' (health endpoint does not return error status)."""
+ with patch('db.last_sync_time', side_effect=Exception('timeout')):
+ resp = client.get('/api/health')
+ body = resp.get_json()
+ assert body['status'] == 'ok'
diff --git a/backend/tests/test_integration.py b/backend/tests/test_integration.py
new file mode 100644
index 0000000..0fc44af
--- /dev/null
+++ b/backend/tests/test_integration.py
@@ -0,0 +1,444 @@
+"""
+test_integration.py — End-to-end tests against LIVE external APIs.
+
+These tests make REAL HTTP calls to:
+ - OSV API (api.osv.dev)
+ - npm registry (registry.npmjs.org)
+ - PyPI API (pypi.org)
+ - Maven Central (repo1.maven.org)
+
+They assert on SPECIFIC CVE IDs that are documented and stable in OSV.
+No external calls are mocked. DB calls are skipped if the DB is unavailable
+(scanner falls back to live OSV automatically).
+
+Packages chosen because their CVEs are permanently documented in OSV:
+ npm:
+ minimist@1.2.5 → CVE-2021-44906 (Prototype Pollution)
+ semver@5.7.1 → CVE-2022-25883 (ReDoS)
+ PyPI:
+ Pillow@9.0.0 → CVE-2022-22816, CVE-2022-22817 (buffer overread / integer overflow)
+ urllib3@1.26.4 → CVE-2021-33503 (ReDoS)
+ Maven:
+ log4j:log4j@1.2.17
+ → CVE-2019-17571 (Deserialization of Untrusted Data)
+
+Run with: ./venv/bin/pytest tests/test_integration.py -v --timeout=60
+"""
+import sys
+import os
+import time
+import pytest
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+
+# ── OSV client — raw query tests ──────────────────────────────────────────────
+
+class TestOsvLiveQuery:
+ """Call the real OSV API and assert specific known CVE IDs are present."""
+
+ def test_minimist_1_2_5_has_prototype_pollution_cve(self):
+ """minimist@1.2.5 must return CVE-2021-44906 (Prototype Pollution).
+ This is a permanently documented vulnerability fixed in 1.2.6."""
+ from cve.osv_client import query_package
+ vulns = query_package('minimist', '1.2.5', 'npm')
+ assert len(vulns) > 0, "OSV returned no vulnerabilities for minimist@1.2.5"
+ cve_ids = []
+ for v in vulns:
+ cve_ids.extend([a for a in v.get('aliases', []) if a.startswith('CVE-')])
+ if v.get('id', '').startswith('CVE-'):
+ cve_ids.append(v['id'])
+ assert 'CVE-2021-44906' in cve_ids, (
+ f"CVE-2021-44906 not found in OSV response for minimist@1.2.5.\n"
+ f"Returned aliases: {cve_ids}"
+ )
+
+ def test_minimist_1_2_6_is_clean(self):
+ """minimist@1.2.6 is the patched version — OSV must report no vulnerabilities."""
+ from cve.osv_client import query_package
+ vulns = query_package('minimist', '1.2.6', 'npm')
+ # 1.2.6 is the fix; CVE-2021-44906 must NOT match
+ cve_ids = []
+ for v in vulns:
+ cve_ids.extend([a for a in v.get('aliases', []) if a == 'CVE-2021-44906'])
+ assert 'CVE-2021-44906' not in cve_ids, (
+ "CVE-2021-44906 should NOT affect minimist@1.2.6 (the patched release)"
+ )
+
+ def test_semver_5_7_1_has_redos_cve(self):
+ """semver@5.7.1 must return CVE-2022-25883 (Regular Expression Denial of Service)."""
+ from cve.osv_client import query_package
+ vulns = query_package('semver', '5.7.1', 'npm')
+ assert len(vulns) > 0, "OSV returned no vulnerabilities for semver@5.7.1"
+ cve_ids = []
+ for v in vulns:
+ cve_ids.extend([a for a in v.get('aliases', []) if a.startswith('CVE-')])
+ assert 'CVE-2022-25883' in cve_ids, (
+ f"CVE-2022-25883 not found for semver@5.7.1.\nReturned: {cve_ids}"
+ )
+
+ def test_log4j_1_2_17_has_deserialization_cve(self):
+ """log4j@1.2.17 (Maven) must contain CVE-2019-17571 (Deserialization)."""
+ from cve.osv_client import query_package
+ vulns = query_package('log4j:log4j', '1.2.17', 'maven')
+ assert len(vulns) > 0, "OSV returned no vulnerabilities for log4j:log4j@1.2.17"
+ cve_ids = []
+ for v in vulns:
+ cve_ids.extend([a for a in v.get('aliases', []) if a.startswith('CVE-')])
+ assert 'CVE-2019-17571' in cve_ids, (
+ f"CVE-2019-17571 not found for log4j:log4j@1.2.17.\nReturned: {cve_ids}"
+ )
+
+ def test_urllib3_1_26_4_has_redos_cve(self):
+ """urllib3@1.26.4 (PyPI) must contain CVE-2021-33503 (ReDoS in netloc parsing)."""
+ from cve.osv_client import query_package
+ vulns = query_package('urllib3', '1.26.4', 'pypi')
+ assert len(vulns) > 0, "OSV returned no vulnerabilities for urllib3@1.26.4"
+ cve_ids = []
+ for v in vulns:
+ cve_ids.extend([a for a in v.get('aliases', []) if a.startswith('CVE-')])
+ assert 'CVE-2021-33503' in cve_ids, (
+ f"CVE-2021-33503 not found for urllib3@1.26.4.\nReturned: {cve_ids}"
+ )
+
+ def test_minimist_1_2_6_has_no_prototype_pollution(self):
+ """CVE-2021-44906 must NOT affect minimist@1.2.6 (the patched release).
+ This verifies that the patched version is correctly excluded by the
+ 'fixed' event in OSV's affected range."""
+ from cve.osv_client import query_package, format_vuln
+ raw = query_package('minimist', '1.2.6', 'npm')
+ # Run through format_vuln — it filters out non-affected versions
+ formatted = [format_vuln(v, 'minimist', '1.2.6') for v in raw]
+ formatted = [f for f in formatted if f is not None]
+ cve_ids = [f['cve_id'] for f in formatted]
+ assert 'CVE-2021-44906' not in cve_ids, (
+ "CVE-2021-44906 should NOT affect minimist@1.2.6 (it is the fix release)"
+ )
+
+
+# ── format_vuln — real OSV payload → structured output ───────────────────────
+
+class TestFormatVulnWithRealPayload:
+ """Call OSV, take the raw response and run it through format_vuln.
+ Verifies the formatter extracts real fields correctly."""
+
+ def test_minimist_format_includes_real_fix_version(self):
+ """After formatting a real OSV response the fix_version must be >= 1.2.6."""
+ from cve.osv_client import query_package, format_vuln
+ from packaging.version import Version
+
+ raw_vulns = query_package('minimist', '1.2.5', 'npm')
+ assert raw_vulns, "No vulns returned from OSV for minimist@1.2.5"
+
+ formatted = [format_vuln(v, 'minimist', '1.2.5') for v in raw_vulns]
+ formatted = [f for f in formatted if f is not None]
+ assert formatted, "format_vuln returned None for all minimist vulns"
+
+ for f in formatted:
+ if f.get('cve_id') == 'CVE-2021-44906':
+ assert f['fix_version'] is not None
+ assert Version(f['fix_version']) >= Version('1.2.6'), (
+ f"fix_version {f['fix_version']} should be >= 1.2.6"
+ )
+ assert f['severity'] in ('CRITICAL', 'HIGH', 'MEDIUM')
+ assert f['source'] == 'OSV'
+ break
+ else:
+ pytest.fail("CVE-2021-44906 not found in formatted vulns")
+
+ def test_log4j_format_extracts_severity(self):
+ """CVE-2019-17571 (log4j@1.2.17 deserialization) must be at least MEDIUM severity."""
+ from cve.osv_client import query_package, format_vuln
+
+ raw_vulns = query_package('log4j:log4j', '1.2.17', 'maven')
+ assert raw_vulns, "No vulns from OSV for log4j:log4j@1.2.17"
+
+ formatted = [format_vuln(v, 'log4j:log4j', '1.2.17') for v in raw_vulns]
+ formatted = [f for f in formatted if f is not None]
+
+ cve_sev = {f['cve_id']: f['severity'] for f in formatted}
+ assert 'CVE-2019-17571' in cve_sev, (
+ f"CVE-2019-17571 missing from formatted output. Found: {list(cve_sev.keys())}"
+ )
+ assert cve_sev['CVE-2019-17571'] in ('CRITICAL', 'HIGH', 'MEDIUM'), (
+ f"Unexpected severity: {cve_sev['CVE-2019-17571']}"
+ )
+
+
+# ── scanner.scan_package — DB-first with live OSV fallback ───────────────────
+
+class TestScanPackageLive:
+ """scan_package hits DB first; since DB is unavailable in this test env,
+ it falls back to the live OSV API automatically. We assert on real CVE IDs."""
+
+ def setup_method(self):
+ from cve import scanner
+ scanner._scan_cache.clear()
+
+ def test_minimist_scan_finds_cve_2021_44906(self):
+ """scan_package('minimist', '1.2.5', 'npm') must find CVE-2021-44906."""
+ from cve.scanner import scan_package
+ results = scan_package('minimist', '1.2.5', 'npm')
+ assert results, "scan_package returned no vulnerabilities for minimist@1.2.5"
+ cve_ids = [r['cve_id'] for r in results]
+ assert 'CVE-2021-44906' in cve_ids, (
+ f"CVE-2021-44906 not in scan results for minimist@1.2.5.\nFound: {cve_ids}"
+ )
+
+ def test_log4j_scan_finds_deserialization_cve(self):
+ """scan_package on log4j@1.2.17 must find CVE-2019-17571."""
+ from cve.scanner import scan_package
+ results = scan_package('log4j:log4j', '1.2.17', 'maven')
+ assert results, "No vulnerabilities found for log4j:log4j@1.2.17"
+ cve_ids = [r['cve_id'] for r in results]
+ assert 'CVE-2019-17571' in cve_ids, (
+ f"CVE-2019-17571 not found.\nReturned: {cve_ids}"
+ )
+
+ def test_minimist_scan_result_has_required_fields(self):
+ """Every vuln returned by scan_package must have the required output fields."""
+ from cve.scanner import scan_package
+ results = scan_package('minimist', '1.2.5', 'npm')
+ assert results
+ for r in results:
+ assert 'cve_id' in r, f"Missing cve_id in {r}"
+ assert 'severity' in r, f"Missing severity in {r}"
+ assert 'cvss_score' in r, f"Missing cvss_score in {r}"
+ assert 'fix_version' in r, f"Missing fix_version in {r}"
+ assert 'source' in r, f"Missing source in {r}"
+ assert r['severity'] in ('CRITICAL', 'HIGH', 'MEDIUM', 'LOW')
+
+ def test_scan_tree_live_npm(self):
+ """scan_tree on a real npm dep tree must find CVE-2021-44906 in minimist."""
+ from cve.scanner import scan_tree
+ graph = [{'name': 'minimist', 'version': '1.2.5',
+ 'type': 'direct', 'dependencies': []}]
+ results = scan_tree(graph, 'npm', app_name='test-app', max_depth=1)
+ assert results, "scan_tree returned no vulnerabilities for minimist@1.2.5"
+ cve_ids = [r['cve_id'] for r in results]
+ assert 'CVE-2021-44906' in cve_ids
+
+ def test_scan_tree_live_path_is_set(self):
+ """scan_tree must annotate each vulnerability with the dependency path."""
+ from cve.scanner import scan_tree
+ graph = [{'name': 'minimist', 'version': '1.2.5',
+ 'type': 'direct', 'dependencies': []}]
+ results = scan_tree(graph, 'npm', app_name='my-api', max_depth=1)
+ for r in results:
+ assert 'path' in r, "path missing from vulnerability"
+ assert r['path'][0] == 'my-api', "root of path must be app_name"
+ assert 'minimist' in r['path'], "minimist must appear in path"
+
+
+# ── resolver — live registry calls ───────────────────────────────────────────
+
+class TestResolverLive:
+ """Verify resolvers fetch REAL transitive dependency trees from live registries."""
+
+ def setup_method(self):
+ from resolvers import npm_resolver, pypi_resolver, maven_resolver
+ npm_resolver._cache.clear()
+ pypi_resolver._cache.clear()
+ maven_resolver._cache.clear()
+
+ def test_npm_express_resolves_real_transitives(self):
+ """express@4.18.2 must resolve real transitive deps from npm registry.
+ Known deps: accepts, body-parser, cookie."""
+ from resolvers.npm_resolver import resolve
+ graph, _ = resolve([{'name': 'express', 'version': '4.18.2'}], max_depth=1)
+
+ assert len(graph) == 1
+ assert graph[0]['name'] == 'express'
+ assert graph[0]['version'] == '4.18.2'
+ child_names = [c['name'] for c in graph[0]['dependencies']]
+ assert len(child_names) > 0, "express@4.18.2 must have transitive deps"
+ assert any(n in child_names for n in ('accepts', 'body-parser', 'cookie')), (
+ f"Expected well-known express deps but got: {child_names}"
+ )
+
+ def test_pypi_requests_resolves_real_transitives(self):
+ """requests@2.28.0 must resolve real transitive deps from PyPI.
+ Known deps: certifi, urllib3, idna, charset-normalizer."""
+ from resolvers.pypi_resolver import resolve
+ graph, _ = resolve([{'name': 'requests', 'version': '2.28.0'}], max_depth=1)
+
+ assert len(graph) == 1
+ assert graph[0]['name'] == 'requests'
+ child_names = [c['name'] for c in graph[0]['dependencies']]
+ assert len(child_names) > 0, "requests@2.28.0 must have transitive deps"
+ assert any(n in child_names for n in ('certifi', 'urllib3', 'idna')), (
+ f"Expected well-known requests deps but got: {child_names}"
+ )
+
+ def test_maven_spring_webmvc_resolves_transitives(self):
+ """org.springframework:spring-webmvc@5.3.25 must resolve real deps from Maven Central."""
+ from resolvers.maven_resolver import resolve
+ graph, _ = resolve(
+ [{'name': 'org.springframework:spring-webmvc', 'version': '5.3.25'}],
+ max_depth=1,
+ )
+ assert len(graph) == 1
+ assert graph[0]['name'] == 'org.springframework:spring-webmvc'
+ child_names = [c['name'] for c in graph[0]['dependencies']]
+ assert len(child_names) > 0, (
+ "spring-webmvc@5.3.25 must have compile-scope transitive deps"
+ )
+
+
+# ── full /api/scan pipeline — live resolution + live OSV ─────────────────────
+
+class TestApiScanLive:
+ """POST /api/scan with real package.json / requirements.txt / pom.xml.
+ No mocking of resolvers or scan_tree — real registries and real OSV calls."""
+
+ def test_npm_scan_minimist_finds_real_cve(self):
+ """Full npm scan with minimist@1.2.5 in package.json must find CVE-2021-44906."""
+ import json
+ os.environ['DISABLE_SCHEDULER'] = 'true'
+ from app import app, _rate_limiter
+ app.config['TESTING'] = True
+ _rate_limiter._store.clear()
+
+ payload = {
+ "content": json.dumps({
+ "name": "test-project",
+ "version": "1.0.0",
+ "dependencies": {"minimist": "1.2.5"},
+ }),
+ "filename": "package.json",
+ "project_name": "test-project",
+ }
+ with app.test_client() as c:
+ resp = c.post('/api/scan',
+ data=json.dumps(payload),
+ content_type='application/json')
+
+ assert resp.status_code == 200, f"Expected 200, got {resp.status_code}"
+ body = resp.get_json()
+ cve_ids = [v['cve_id'] for v in body.get('vulnerabilities', [])]
+ assert 'CVE-2021-44906' in cve_ids, (
+ f"CVE-2021-44906 not found in /api/scan result.\nAll CVEs: {cve_ids}"
+ )
+
+ def test_pypi_scan_urllib3_finds_real_cve(self):
+ """Full PyPI scan with urllib3@1.26.4 must find CVE-2021-33503."""
+ import json
+ os.environ['DISABLE_SCHEDULER'] = 'true'
+ from app import app, _rate_limiter
+ app.config['TESTING'] = True
+ _rate_limiter._store.clear()
+
+ payload = {
+ "content": "urllib3==1.26.4\n",
+ "filename": "requirements.txt",
+ "project_name": "test-project",
+ }
+ with app.test_client() as c:
+ resp = c.post('/api/scan',
+ data=json.dumps(payload),
+ content_type='application/json')
+
+ assert resp.status_code == 200
+ body = resp.get_json()
+ cve_ids = [v['cve_id'] for v in body.get('vulnerabilities', [])]
+ assert 'CVE-2021-33503' in cve_ids, (
+ f"CVE-2021-33503 not found.\nAll CVEs: {cve_ids}"
+ )
+
+ def test_maven_scan_log4j_finds_real_cve(self):
+ """Full Maven scan with log4j@1.2.17 must find CVE-2019-17571."""
+ import json
+ os.environ['DISABLE_SCHEDULER'] = 'true'
+ from app import app, _rate_limiter
+ app.config['TESTING'] = True
+ _rate_limiter._store.clear()
+
+ pom = """
+
+ com.example
+ test-app
+ 1.0.0
+
+
+ log4j
+ log4j
+ 1.2.17
+
+
+"""
+ payload = {
+ "content": pom,
+ "filename": "pom.xml",
+ "project_name": "test-project",
+ }
+ with app.test_client() as c:
+ resp = c.post('/api/scan',
+ data=json.dumps(payload),
+ content_type='application/json')
+
+ assert resp.status_code == 200
+ body = resp.get_json()
+ cve_ids = [v['cve_id'] for v in body.get('vulnerabilities', [])]
+ assert 'CVE-2019-17571' in cve_ids, (
+ f"CVE-2019-17571 not found in Maven scan.\nAll CVEs: {cve_ids}"
+ )
+
+ def test_npm_scan_risk_score_nonzero_for_vulnerable_package(self):
+ """A scan with a known-vulnerable package must return a non-zero risk score."""
+ import json
+ os.environ['DISABLE_SCHEDULER'] = 'true'
+ from app import app, _rate_limiter
+ app.config['TESTING'] = True
+ _rate_limiter._store.clear()
+
+ payload = {
+ "content": json.dumps({
+ "name": "test-project",
+ "version": "1.0.0",
+ "dependencies": {"minimist": "1.2.5"},
+ }),
+ "filename": "package.json",
+ "project_name": "test-project",
+ }
+ with app.test_client() as c:
+ resp = c.post('/api/scan',
+ data=json.dumps(payload),
+ content_type='application/json')
+
+ body = resp.get_json()
+ assert body['summary']['risk_score'] > 0, (
+ "Risk score must be > 0 for a package with known vulnerabilities"
+ )
+
+ def test_npm_scan_fix_version_is_real_semver(self):
+ """fix_version in the response must be a valid semantic version string."""
+ import json
+ from packaging.version import Version
+ os.environ['DISABLE_SCHEDULER'] = 'true'
+ from app import app, _rate_limiter
+ app.config['TESTING'] = True
+ _rate_limiter._store.clear()
+
+ payload = {
+ "content": json.dumps({
+ "name": "test-project",
+ "version": "1.0.0",
+ "dependencies": {"minimist": "1.2.5"},
+ }),
+ "filename": "package.json",
+ "project_name": "test-project",
+ }
+ with app.test_client() as c:
+ resp = c.post('/api/scan',
+ data=json.dumps(payload),
+ content_type='application/json')
+
+ body = resp.get_json()
+ for v in body.get('vulnerabilities', []):
+ if v.get('fix_version'):
+ try:
+ Version(v['fix_version'])
+ except Exception:
+ pytest.fail(
+ f"fix_version '{v['fix_version']}' for {v['cve_id']} "
+ f"is not a valid semver"
+ )
diff --git a/backend/tests/test_parsers.py b/backend/tests/test_parsers.py
new file mode 100644
index 0000000..e85c8c9
--- /dev/null
+++ b/backend/tests/test_parsers.py
@@ -0,0 +1,369 @@
+"""
+test_parsers.py — Unit tests for all four parsers.
+
+Parsers are pure string-in / dict-out functions. No CVE data is involved.
+Tests that exercise unpinned-version handling (wildcard, latest tag, bare name)
+allow real network calls to npm/PyPI/Maven for get_latest_version so the test
+verifies the full parsing + version-lookup path.
+
+Real package names and versions are used throughout.
+"""
+import json
+import sys
+import os
+import pytest
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+
+from parsers.npm_parser import parse as parse_npm
+from parsers.pypi_parser import parse as parse_pypi
+from parsers.maven_parser import parse as parse_maven
+from parsers.lockfile_parser import parse as parse_lockfile
+
+
+# ── npm parser ────────────────────────────────────────────────────────────────
+
+class TestNpmParser:
+ def test_pinned_production_dep(self):
+ """Pinned production dep returns exact version and pinned=True."""
+ content = json.dumps({"name": "api-server", "version": "2.0.0",
+ "dependencies": {"express": "4.18.2"}})
+ result = parse_npm(content)
+ dep = next(d for d in result['deps'] if d['name'] == 'express')
+ assert dep['version'] == '4.18.2'
+ assert dep['pinned'] is True
+ assert dep['warning'] is None
+
+ def test_caret_version_stripped(self):
+ """^4.18.2 is stripped to 4.18.2; package is still treated as pinned."""
+ content = json.dumps({"dependencies": {"express": "^4.18.2"}})
+ result = parse_npm(content)
+ dep = next(d for d in result['deps'] if d['name'] == 'express')
+ assert dep['version'] == '4.18.2'
+ assert dep['pinned'] is True
+
+ def test_tilde_version_stripped(self):
+ """~7.3.8 is stripped to 7.3.8."""
+ content = json.dumps({"dependencies": {"semver": "~7.3.8"}})
+ result = parse_npm(content)
+ dep = next(d for d in result['deps'] if d['name'] == 'semver')
+ assert dep['version'] == '7.3.8'
+ assert dep['pinned'] is True
+
+ def test_dev_dependencies_included(self):
+ """devDependencies are parsed alongside regular dependencies."""
+ content = json.dumps({
+ "dependencies": {"express": "4.18.2"},
+ "devDependencies": {"jest": "29.5.0"},
+ })
+ result = parse_npm(content)
+ names = [d['name'] for d in result['deps']]
+ assert 'express' in names
+ assert 'jest' in names
+
+ def test_peer_dependencies_included(self):
+ """peerDependencies are included in the parsed result."""
+ content = json.dumps({"peerDependencies": {"react": "18.2.0"}})
+ result = parse_npm(content)
+ assert any(d['name'] == 'react' for d in result['deps'])
+
+ def test_optional_dependencies_not_included(self):
+ """optionalDependencies are NOT parsed (only deps, devDeps, peerDeps)."""
+ content = json.dumps({
+ "dependencies": {"express": "4.18.2"},
+ "optionalDependencies": {"fsevents": "2.3.3"},
+ })
+ result = parse_npm(content)
+ assert not any(d['name'] == 'fsevents' for d in result['deps'])
+
+ def test_unpinned_wildcard_fetches_real_latest(self):
+ """A wildcard version (*) triggers a live npm registry call.
+ The returned version must be a real version string (not 'unknown')
+ and pinned must be False."""
+ content = json.dumps({"dependencies": {"minimist": "*"}})
+ result = parse_npm(content)
+ dep = next(d for d in result['deps'] if d['name'] == 'minimist')
+ assert dep['pinned'] is False
+ assert dep['version'] not in ('*', 'unknown', ''), (
+ f"Expected a real version from npm registry but got: {dep['version']}"
+ )
+ assert dep['warning'] is not None
+
+ def test_project_name_extracted(self):
+ """project_name and project_version are taken from the JSON root."""
+ content = json.dumps({"name": "invoice-service", "version": "3.1.0",
+ "dependencies": {"uuid": "9.0.0"}})
+ result = parse_npm(content)
+ assert result['project_name'] == 'invoice-service'
+ assert result['project_version'] == '3.1.0'
+
+ def test_empty_dependencies_returns_empty_list(self):
+ """package.json with no dependency sections returns an empty dep list."""
+ content = json.dumps({"name": "bare-pkg", "version": "1.0.0"})
+ result = parse_npm(content)
+ assert result['deps'] == []
+
+ def test_malformed_json_raises_value_error(self):
+ """Non-JSON content raises ValueError."""
+ with pytest.raises(ValueError):
+ parse_npm("not json at all {{{")
+
+ def test_scoped_package_name(self):
+ """Scoped npm packages (@babel/core) are parsed correctly."""
+ content = json.dumps({"dependencies": {"@babel/core": "7.22.0"}})
+ result = parse_npm(content)
+ assert any(d['name'] == '@babel/core' for d in result['deps'])
+
+
+# ── PyPI parser ───────────────────────────────────────────────────────────────
+
+class TestPypiParser:
+ def test_pinned_equality_constraint(self):
+ """requests==2.28.0 is parsed to version 2.28.0 with type=direct."""
+ content = "requests==2.28.0\n"
+ result = parse_pypi(content)
+ dep = next(d for d in result['deps'] if d['name'] == 'requests')
+ assert dep['version'] == '2.28.0'
+ assert dep['type'] == 'direct'
+
+ def test_gte_constraint_extracts_floor(self):
+ """requests>=2.28.0 → version 2.28.0 (the constraint floor)."""
+ content = "requests>=2.28.0\n"
+ result = parse_pypi(content)
+ dep = next(d for d in result['deps'] if d['name'] == 'requests')
+ assert dep['version'] == '2.28.0'
+
+ def test_comment_lines_skipped(self):
+ """Lines starting with # are ignored."""
+ content = "# production deps\nrequests==2.28.0\n"
+ result = parse_pypi(content)
+ assert any(d['name'] == 'requests' for d in result['deps'])
+ assert not any(d['name'].startswith('#') for d in result['deps'])
+
+ def test_editable_install_skipped(self):
+ """Lines starting with - (e.g. -e .) are skipped."""
+ content = "requests==2.28.0\n-e .\n"
+ result = parse_pypi(content)
+ names = [d['name'] for d in result['deps']]
+ assert 'requests' in names
+ assert 'e' not in names
+
+ def test_extras_syntax_skipped(self):
+ """requests[security]==2.28.0 — extras marker breaks the parser regex,
+ line is skipped gracefully."""
+ content = "certifi==2023.7.22\nrequests[security]==2.28.0\n"
+ result = parse_pypi(content)
+ names = [d['name'] for d in result['deps']]
+ assert 'certifi' in names
+ assert 'requests' not in names
+
+ def test_multiple_packages_all_parsed(self):
+ """A typical requirements.txt with several pinned packages is parsed fully."""
+ content = (
+ "flask==2.3.3\n"
+ "sqlalchemy==2.0.19\n"
+ "psycopg2-binary==2.9.7\n"
+ "gunicorn==21.2.0\n"
+ )
+ result = parse_pypi(content)
+ names = [d['name'] for d in result['deps']]
+ for pkg in ('flask', 'sqlalchemy', 'psycopg2-binary', 'gunicorn'):
+ assert pkg in names
+
+ def test_blank_lines_skipped(self):
+ """Blank lines between entries produce no spurious deps."""
+ content = "\nflask==2.3.3\n\ncertifi==2023.7.22\n\n"
+ result = parse_pypi(content)
+ assert len(result['deps']) == 2
+
+ def test_project_name_is_python_app(self):
+ """The parser always returns project_name='python-app'."""
+ content = "flask==2.3.3\n"
+ assert parse_pypi(content)['project_name'] == 'python-app'
+
+ def test_unversioned_package_fetches_real_latest(self):
+ """A bare package name triggers a live PyPI call for the latest version."""
+ content = "flask==2.3.3\nboto3\n"
+ result = parse_pypi(content)
+ dep = next((d for d in result['deps'] if d['name'] == 'boto3'), None)
+ assert dep is not None
+ assert dep['version'] not in ('', '*', None), (
+ f"Expected a real version from PyPI but got: {dep['version']}"
+ )
+
+
+# ── Maven parser ──────────────────────────────────────────────────────────────
+
+class TestMavenParser:
+ def test_compile_scope_dep_included(self):
+ """A compile-scope dep is included in the parsed result."""
+ content = """
+ com.exampledemo1.0
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+ 2.15.2
+ compile
+
+
+ """
+ result = parse_maven(content)
+ dep = next(d for d in result['deps'] if 'jackson-databind' in d['name'])
+ assert dep['version'] == '2.15.2'
+ assert dep['pinned'] is True
+
+ def test_test_scope_excluded(self):
+ """Dependencies with scope=test are excluded."""
+ content = """
+ com.exampledemo1.0
+
+
+ junitjunit
+ 4.13.2test
+
+
+ """
+ assert not parse_maven(content)['deps']
+
+ def test_provided_scope_excluded(self):
+ """Dependencies with scope=provided are excluded."""
+ content = """
+ com.exampledemo1.0
+
+
+ javax.servletservlet-api
+ 2.5provided
+
+
+ """
+ assert not parse_maven(content)['deps']
+
+ def test_property_placeholder_resolved_from_properties(self):
+ """${spring.version} is resolved from ."""
+ content = """
+ com.exampledemo1.0
+ 5.3.25
+
+
+ org.springframeworkspring-core
+ ${spring.version}
+
+
+ """
+ result = parse_maven(content)
+ dep = next(d for d in result['deps'] if 'spring-core' in d['name'])
+ assert dep['version'] == '5.3.25'
+ assert dep['pinned'] is True
+
+ def test_unresolvable_property_does_not_crash_parser(self):
+ """An undeclared ${placeholder} is handled gracefully — the parser either
+ resolves the version via Maven Search or falls back to 'unknown', but
+ must not raise or skip the dependency entirely."""
+ content = """
+ com.exampledemo1.0
+
+
+ org.springframeworkspring-context
+ ${undeclared.version}
+
+
+ """
+ result = parse_maven(content)
+ dep = next((d for d in result['deps'] if 'spring-context' in d['name']), None)
+ assert dep is not None, (
+ "spring-context must appear in results even when version is an unresolvable property"
+ )
+ assert dep['version'] is not None
+
+ def test_project_name_is_groupid_colon_artifactid(self):
+ """project_name is 'groupId:artifactId'."""
+ content = """
+ org.apache.kafkakafka-clients
+ 3.5.1
+ """
+ assert parse_maven(content)['project_name'] == 'org.apache.kafka:kafka-clients'
+
+ def test_no_dependencies_returns_empty(self):
+ """A POM with no block returns an empty dep list."""
+ content = """
+ com.exampleminimal
+ 1.0.0
+ """
+ assert parse_maven(content)['deps'] == []
+
+ def test_malformed_xml_raises_value_error(self):
+ """Non-XML content raises ValueError."""
+ with pytest.raises(ValueError):
+ parse_maven("not xml ")
+
+ def test_namespace_pom_parsed_by_xmltodict(self):
+ """A POM with xmlns attribute is parsed correctly by xmltodict (parser-level)."""
+ content = """
+
+ org.springframework
+ spring-webmvc
+ 5.3.25
+
+
+ org.springframework
+ spring-core
+ 5.3.25
+
+
+ """
+ result = parse_maven(content)
+ assert any('spring-core' in d['name'] for d in result['deps'])
+
+
+# ── Lockfile parser ───────────────────────────────────────────────────────────
+
+class TestLockfileParser:
+ def test_v2_lockfile_packages_key(self):
+ """v2 lockfile format (lockfileVersion=2) uses the 'packages' key."""
+ lockfile = json.dumps({
+ "name": "my-app", "version": "1.0.0", "lockfileVersion": 2,
+ "packages": {
+ "": {"dependencies": {"express": "4.18.2"}},
+ "node_modules/express": {"version": "4.18.2"},
+ "node_modules/body-parser": {"version": "1.20.1"},
+ }
+ })
+ result = parse_lockfile(lockfile)
+ names = [d['name'] for d in result['deps']]
+ assert 'express' in names
+ assert 'body-parser' in names
+
+ def test_v2_direct_vs_transitive_classification(self):
+ """Packages listed in the root '' dependencies are 'direct'; others 'transitive'."""
+ lockfile = json.dumps({
+ "name": "my-app", "version": "1.0.0", "lockfileVersion": 2,
+ "packages": {
+ "": {"dependencies": {"express": "4.18.2"}},
+ "node_modules/express": {"version": "4.18.2"},
+ "node_modules/body-parser": {"version": "1.20.1"},
+ }
+ })
+ result = parse_lockfile(lockfile)
+ expr = next(d for d in result['deps'] if d['name'] == 'express')
+ bparser = next(d for d in result['deps'] if d['name'] == 'body-parser')
+ assert expr['type'] == 'direct'
+ assert bparser['type'] == 'transitive'
+
+ def test_all_lockfile_deps_are_pinned(self):
+ """Every entry from a lockfile has pinned=True (exact installed versions)."""
+ lockfile = json.dumps({
+ "name": "my-app", "version": "1.0.0", "lockfileVersion": 2,
+ "packages": {
+ "": {},
+ "node_modules/chalk": {"version": "5.3.0"},
+ "node_modules/commander": {"version": "11.0.0"},
+ }
+ })
+ result = parse_lockfile(lockfile)
+ assert all(d['pinned'] is True for d in result['deps'])
+
+ def test_malformed_json_raises_value_error(self):
+ """Non-JSON lockfile content raises ValueError."""
+ with pytest.raises(ValueError):
+ parse_lockfile("this is not json")
diff --git a/backend/tests/test_regression.py b/backend/tests/test_regression.py
new file mode 100644
index 0000000..80b79db
--- /dev/null
+++ b/backend/tests/test_regression.py
@@ -0,0 +1,167 @@
+"""
+test_regression.py — Regression tests for five production bugs.
+
+All tests use real packages from live registries where possible.
+No synthetic CVE IDs or fake registry payloads are used.
+
+Bug 1 (maven_resolver.py):
+ root.iter('dependency') returned 0 results for xmlns-namespaced POMs.
+ Verified against real spring-webmvc@5.3.25 POM from Maven Central.
+
+Bug 2 (db.py):
+ last_sync_time() swallowed its own exceptions, so health() always showed
+ db_connected: True even when the DB was down.
+ Verified against the live Flask health endpoint.
+
+Bug A (lockfile_resolver.py):
+ resolve() was missing the max_depth kwarg required by app.py.
+
+Bug B (npm_resolver.py):
+ In-memory cache stored key 'deps' but read 'data' → KeyError after a
+ DB round-trip. Verified against real npm registry fetch.
+
+Bug C (pypi_resolver.py):
+ Constraint (<1.27,>=1.21.1) resolved to 1.27 (upper bound) instead of
+ 1.21.1 (lower bound). Verified against real PyPI requests@2.28.0.
+"""
+import sys
+import os
+import pytest
+from packaging.version import Version
+from unittest.mock import patch
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+
+
+# ── Bug 1 — Maven XML namespace ───────────────────────────────────────────────
+
+class TestMavenNamespaceRegression:
+ def setup_method(self):
+ from resolvers import maven_resolver
+ maven_resolver._cache.clear()
+
+ def test_spring_webmvc_returns_compile_deps(self):
+ """_fetch_deps against the real spring-webmvc@5.3.25 POM must return
+ compile-scope children. Before Bug 1 fix this returned 0 results."""
+ from resolvers.maven_resolver import _fetch_deps
+ deps = _fetch_deps('org.springframework', 'spring-webmvc', '5.3.25')
+ assert deps, (
+ "spring-webmvc@5.3.25 returned zero deps — Maven namespace fix may be broken"
+ )
+ assert 'org.springframework:spring-core' in deps, (
+ f"spring-core missing from spring-webmvc deps: {list(deps.keys())}"
+ )
+
+ def test_plain_iter_without_namespace_returns_zero(self):
+ """Documents WHY Bug 1 existed: root.iter('dependency') returns 0
+ on xmlns-prefixed POMs because ET represents tags as {ns}tagname."""
+ import xml.etree.ElementTree as ET
+ import requests as req
+ path = 'org/springframework/spring-webmvc/5.3.25/spring-webmvc-5.3.25.pom'
+ resp = req.get(f'https://repo1.maven.org/maven2/{path}', timeout=10)
+ assert resp.status_code == 200
+ root = ET.fromstring(resp.content)
+ count = len(list(root.iter('dependency')))
+ assert count == 0, (
+ f"Expected 0 results for plain .iter('dependency') on a namespaced POM "
+ f"but got {count}. The _ns() fix addresses this."
+ )
+
+ def test_full_resolve_returns_children(self):
+ """End-to-end: resolve() for spring-webmvc@5.3.25 must return children."""
+ from resolvers.maven_resolver import resolve
+ graph, _ = resolve(
+ [{'name': 'org.springframework:spring-webmvc', 'version': '5.3.25'}],
+ max_depth=1,
+ )
+ assert graph[0]['dependencies'], (
+ "spring-webmvc@5.3.25 graph has no children — Bug 1 regression"
+ )
+
+
+# ── Bug 2 — Health endpoint db_connected ─────────────────────────────────────
+
+class TestHealthDbConnectedRegression:
+ def test_db_connected_false_when_db_raises(self, client):
+ """db_connected must be False when last_sync_time() raises.
+ Before Bug 2 fix last_sync_time() swallowed the exception internally
+ and health() always returned db_connected: True."""
+ with patch('db.last_sync_time', side_effect=Exception('could not connect')):
+ resp = client.get('/api/health')
+ assert resp.get_json()['db_connected'] is False
+
+ def test_db_connected_true_when_no_exception(self, client):
+ """db_connected is True only when last_sync_time() completes without error."""
+ with patch('db.last_sync_time', return_value='2024-06-01T00:00:00+00:00'):
+ resp = client.get('/api/health')
+ assert resp.get_json()['db_connected'] is True
+
+ def test_last_sync_time_has_no_internal_try_except(self):
+ """Source-code check: last_sync_time() must not contain an internal
+ try/except block that would suppress DB connection errors."""
+ import inspect
+ from db import last_sync_time
+ source = inspect.getsource(last_sync_time)
+ assert 'except' not in source, (
+ "last_sync_time() contains a bare except — Bug 2 fix may be reverted"
+ )
+
+
+# ── Bug A — Lockfile resolver max_depth ──────────────────────────────────────
+
+class TestLockfileMaxDepthRegression:
+ def test_resolve_accepts_max_depth_kwarg(self):
+ """resolve(deps, max_depth=2) must not raise TypeError.
+ Bug A: the original signature was resolve(direct_deps) without max_depth."""
+ from resolvers.lockfile_resolver import resolve
+ try:
+ resolve([{'name': 'express', 'version': '4.18.2', 'type': 'direct'}],
+ max_depth=2)
+ except TypeError as e:
+ pytest.fail(f"Bug A still present — TypeError: {e}")
+
+
+# ── Bug B — npm_resolver in-memory cache key ─────────────────────────────────
+
+class TestNpmResolverCacheKeyRegression:
+ def setup_method(self):
+ from resolvers import npm_resolver
+ npm_resolver._cache.clear()
+ npm_resolver.npm_circuit_breaker.failure_count = 0
+ npm_resolver.npm_circuit_breaker.state = 'CLOSED'
+
+ def test_cache_stores_data_key_not_deps_key(self):
+ """After a live npm fetch the in-memory cache must store key 'data'.
+ Bug B: stored 'deps' but read 'data', causing KeyError on subsequent reads."""
+ from resolvers.npm_resolver import _fetch_deps, _cache
+ # minimist has no sub-deps so only one HTTP call is made
+ _fetch_deps('minimist', '1.2.5')
+ key = 'minimist@1.2.5'
+ assert key in _cache, f"minimist@1.2.5 not found in cache after fetch"
+ entry = _cache[key]
+ assert 'data' in entry, (
+ f"Cache entry uses key '{list(entry.keys())}' instead of 'data' — Bug B regression"
+ )
+ assert 'deps' not in entry
+
+
+# ── Bug C — PyPI resolver upper-bound extraction ──────────────────────────────
+
+class TestPypiUpperBoundRegression:
+ def setup_method(self):
+ from resolvers import pypi_resolver
+ pypi_resolver._cache.clear()
+
+ def test_urllib3_resolves_to_lower_bound(self):
+ """requests@2.28.0 declares urllib3 (<1.27,>=1.21.1).
+ The resolved version must be the LOWER bound (1.21.1), not the upper bound (1.27).
+ Bug C: re.search(r'[\\d\\.]+', ...) grabbed 1.27 first."""
+ from resolvers.pypi_resolver import resolve
+ graph, _ = resolve([{'name': 'requests', 'version': '2.28.0'}], max_depth=1)
+ child = next((c for c in graph[0]['dependencies'] if c['name'] == 'urllib3'), None)
+ assert child is not None, "urllib3 not found in requests@2.28.0 deps"
+ resolved = Version(child['version'])
+ assert resolved < Version('1.27'), (
+ f"Bug C still present: urllib3 resolved to {child['version']} "
+ f"(>= 1.27 means the upper bound leaked through)"
+ )
diff --git a/backend/tests/test_resolvers.py b/backend/tests/test_resolvers.py
new file mode 100644
index 0000000..13c0a98
--- /dev/null
+++ b/backend/tests/test_resolvers.py
@@ -0,0 +1,237 @@
+"""
+test_resolvers.py — Resolver tests against live npm, PyPI, and Maven Central.
+
+No registry HTTP calls are mocked. Every test hits the real registry and
+asserts on known, stable package metadata:
+
+ npm:
+ express@4.18.2 → known direct deps include accepts, body-parser, cookie
+ minimist@1.2.5 → no children (leaf package)
+ PyPI:
+ requests@2.28.0 → known deps include certifi, urllib3, idna
+ Maven:
+ org.springframework:spring-webmvc@5.3.25
+ → known compile deps include spring-core, spring-context
+ lockfile:
+ No registry calls — resolver logic tested with in-process data.
+"""
+import sys
+import os
+import pytest
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+
+
+def setup_module():
+ """Clear resolver caches so cached data from other test runs does not
+ hide live registry failures."""
+ from resolvers import npm_resolver, pypi_resolver, maven_resolver
+ npm_resolver._cache.clear()
+ pypi_resolver._cache.clear()
+ maven_resolver._cache.clear()
+
+
+# ── npm resolver ──────────────────────────────────────────────────────────────
+
+class TestNpmResolverLive:
+ def setup_method(self):
+ from resolvers import npm_resolver
+ npm_resolver._cache.clear()
+ npm_resolver.npm_circuit_breaker.failure_count = 0
+ npm_resolver.npm_circuit_breaker.state = 'CLOSED'
+
+ def test_express_resolves_to_known_transitives(self):
+ """express@4.18.2 from the live npm registry must include accepts,
+ body-parser, and cookie as transitive dependencies."""
+ from resolvers.npm_resolver import resolve
+ graph, _ = resolve([{'name': 'express', 'version': '4.18.2'}], max_depth=1)
+ assert graph, "Resolver returned empty graph"
+ assert graph[0]['name'] == 'express'
+ assert graph[0]['version'] == '4.18.2'
+ child_names = [c['name'] for c in graph[0]['dependencies']]
+ assert len(child_names) > 0, "express@4.18.2 must have transitive deps"
+ expected = {'accepts', 'body-parser', 'cookie'}
+ assert expected & set(child_names), (
+ f"None of {expected} found in express children: {child_names}"
+ )
+
+ def test_express_root_node_type_is_direct(self):
+ """The root node returned for a direct dep must have type='direct'."""
+ from resolvers.npm_resolver import resolve
+ graph, _ = resolve([{'name': 'express', 'version': '4.18.2'}], max_depth=1)
+ assert graph[0]['type'] == 'direct'
+
+ def test_minimist_has_no_children(self):
+ """minimist@1.2.5 is a leaf package with no npm dependencies."""
+ from resolvers.npm_resolver import resolve
+ graph, _ = resolve([{'name': 'minimist', 'version': '1.2.5'}], max_depth=1)
+ assert graph[0]['dependencies'] == []
+
+ def test_resolve_returns_mediation_for_version_conflict(self):
+ """Requesting two packages that share a transitive dep at different
+ versions must produce at least one mediation entry."""
+ from resolvers.npm_resolver import resolve
+ # Both express and minimist pull no shared conflicting dep at depth=1
+ # Use a real pair where semver conflict is guaranteed: testing with
+ # two deps that both depend on debug (common transitive)
+ graph, mediation = resolve(
+ [{'name': 'express', 'version': '4.18.2'},
+ {'name': 'express', 'version': '4.17.1'}],
+ max_depth=1,
+ )
+ # mediation may or may not fire depending on the packages; at minimum
+ # the resolver must return without error and the graph must have 2 nodes.
+ assert len(graph) == 2
+
+
+# ── PyPI resolver ─────────────────────────────────────────────────────────────
+
+class TestPypiResolverLive:
+ def setup_method(self):
+ from resolvers import pypi_resolver
+ pypi_resolver._cache.clear()
+
+ def test_requests_resolves_to_known_transitives(self):
+ """requests@2.28.0 from the live PyPI API must include certifi, urllib3,
+ and idna as transitive dependencies."""
+ from resolvers.pypi_resolver import resolve
+ graph, _ = resolve([{'name': 'requests', 'version': '2.28.0'}], max_depth=1)
+ assert graph, "Resolver returned empty graph"
+ assert graph[0]['name'] == 'requests'
+ child_names = [c['name'] for c in graph[0]['dependencies']]
+ expected = {'certifi', 'urllib3', 'idna'}
+ assert expected & set(child_names), (
+ f"None of {expected} found in requests children: {child_names}"
+ )
+
+ def test_upper_bound_constraint_uses_lower_bound_version(self):
+ """The constraint urllib3 (<1.27,>=1.21.1) must resolve to the lower
+ bound 1.21.1 — NOT to the upper bound 1.27.
+ Bug C regression: old code grabbed the first number (1.27)."""
+ from resolvers.pypi_resolver import resolve
+ graph, _ = resolve([{'name': 'requests', 'version': '2.28.0'}], max_depth=1)
+ child = next((c for c in graph[0]['dependencies'] if c['name'] == 'urllib3'), None)
+ assert child is not None, "urllib3 not found in requests dependencies"
+ from packaging.version import Version
+ resolved = Version(child['version'])
+ assert resolved < Version('1.27'), (
+ f"urllib3 resolved to {child['version']} which is >= 1.27 (upper bound leaked)"
+ )
+
+ def test_extras_marker_dep_is_excluded(self):
+ """Entries with 'extra ==' in requires_dist must not appear as children."""
+ from resolvers.pypi_resolver import resolve
+ # requests has cryptography under the 'security' extra marker
+ graph, _ = resolve([{'name': 'requests', 'version': '2.28.0'}], max_depth=1)
+ child_names = [c['name'] for c in graph[0]['dependencies']]
+ assert 'cryptography' not in child_names, (
+ "extras-only dep 'cryptography' must not be pulled as transitive"
+ )
+
+
+# ── Maven resolver ────────────────────────────────────────────────────────────
+
+class TestMavenResolverLive:
+ def setup_method(self):
+ from resolvers import maven_resolver
+ maven_resolver._cache.clear()
+
+ def test_spring_webmvc_resolves_to_compile_deps(self):
+ """org.springframework:spring-webmvc@5.3.25 from Maven Central must
+ include spring-core and spring-context as compile-scope children."""
+ from resolvers.maven_resolver import resolve
+ graph, _ = resolve(
+ [{'name': 'org.springframework:spring-webmvc', 'version': '5.3.25'}],
+ max_depth=1,
+ )
+ assert graph, "Resolver returned empty graph"
+ assert graph[0]['name'] == 'org.springframework:spring-webmvc'
+ child_names = [c['name'] for c in graph[0]['dependencies']]
+ assert len(child_names) > 0, (
+ "spring-webmvc@5.3.25 must have compile-scope transitive deps"
+ )
+ assert any('spring-core' in n or 'spring-context' in n for n in child_names), (
+ f"Expected spring-core/context in children but got: {child_names}"
+ )
+
+ def test_namespaced_pom_resolves_correctly(self):
+ """Maven resolver must handle POMs with xmlns='http://maven.apache.org/POM/4.0.0'.
+ This is the regression check for Bug 1 — the namespace fix."""
+ from resolvers.maven_resolver import resolve
+ # spring-webmvc POMs carry the standard Maven namespace
+ graph, _ = resolve(
+ [{'name': 'org.springframework:spring-webmvc', 'version': '5.3.25'}],
+ max_depth=1,
+ )
+ assert graph[0]['dependencies'], (
+ "Namespaced POM returned zero children — Bug 1 namespace fix may be broken"
+ )
+
+ def test_test_scope_deps_excluded(self):
+ """Dependencies declared as test scope in the live POM must not
+ appear as children in the resolved graph."""
+ from resolvers.maven_resolver import resolve
+ graph, _ = resolve(
+ [{'name': 'org.springframework:spring-webmvc', 'version': '5.3.25'}],
+ max_depth=1,
+ )
+ child_names = [c['name'] for c in graph[0]['dependencies']]
+ # junit is typically a test dep — must not appear in compile-scope children
+ assert 'junit:junit' not in child_names, (
+ "junit (test scope) must not appear in compile-scope children"
+ )
+
+ def test_unknown_version_produces_empty_children(self):
+ """A dep with version='unknown' must not trigger a Maven Central fetch
+ and must return an empty children list."""
+ from resolvers.maven_resolver import resolve
+ graph, _ = resolve(
+ [{'name': 'org.example:lib', 'version': 'unknown'}],
+ max_depth=1,
+ )
+ assert graph[0]['dependencies'] == []
+
+
+# ── Lockfile resolver — no registry calls ────────────────────────────────────
+
+class TestLockfileResolver:
+ def test_direct_deps_become_top_level_nodes(self):
+ """Direct-typed entries become top-level nodes in the graph."""
+ from resolvers.lockfile_resolver import resolve
+ deps = [
+ {'name': 'express', 'version': '4.18.2', 'type': 'direct'},
+ {'name': 'body-parser', 'version': '1.20.1', 'type': 'transitive'},
+ ]
+ graph, _ = resolve(deps, max_depth=2)
+ directs = [n for n in graph if n['type'] == 'direct']
+ assert len(directs) == 1
+ assert directs[0]['name'] == 'express'
+
+ def test_transitives_attached_to_first_direct(self):
+ """All transitive nodes are children of the first direct dep."""
+ from resolvers.lockfile_resolver import resolve
+ deps = [
+ {'name': 'express', 'version': '4.18.2', 'type': 'direct'},
+ {'name': 'chalk', 'version': '5.3.0', 'type': 'direct'},
+ {'name': 'body-parser', 'version': '1.20.1', 'type': 'transitive'},
+ ]
+ graph, _ = resolve(deps, max_depth=2)
+ first = next(n for n in graph if n['name'] == 'express')
+ assert any(c['name'] == 'body-parser' for c in first['dependencies'])
+
+ def test_mediation_always_empty_for_lockfile(self):
+ """Lockfile is already resolved — no version conflicts to report."""
+ from resolvers.lockfile_resolver import resolve
+ deps = [{'name': 'webpack', 'version': '5.88.2', 'type': 'direct'}]
+ _, mediation = resolve(deps, max_depth=2)
+ assert mediation == []
+
+ def test_max_depth_kwarg_accepted(self):
+ """resolve(deps, max_depth=2) must not raise TypeError.
+ Regression check for Bug A — missing max_depth kwarg."""
+ from resolvers.lockfile_resolver import resolve
+ try:
+ resolve([{'name': 'express', 'version': '4.18.2', 'type': 'direct'}],
+ max_depth=2)
+ except TypeError as e:
+ pytest.fail(f"lockfile_resolver raised TypeError: {e}")
diff --git a/backend/tests/test_risk_score.py b/backend/tests/test_risk_score.py
new file mode 100644
index 0000000..a1fec05
--- /dev/null
+++ b/backend/tests/test_risk_score.py
@@ -0,0 +1,160 @@
+"""
+test_risk_score.py — Tests for the logarithmic risk score formula in app.py.
+
+Formula (from run_analysis):
+ crit_impact = 40 * (1 - exp(-CRITICAL / 3)) if CRITICAL > 0 else 0
+ high_impact = 30 * (1 - exp(-HIGH / 5)) if HIGH > 0 else 0
+ med_impact = 20 * (1 - exp(-MEDIUM / 8)) if MEDIUM > 0 else 0
+ low_impact = 10 * (1 - exp(-LOW / 10)) if LOW > 0 else 0
+ risk_score = min(100, round(sum))
+
+Pure formula tests use no CVE data (pure math).
+API integration tests use real packages — no scan_tree or RESOLVERS mocks.
+"""
+import math
+import sys
+import os
+import json
+import pytest
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+
+
+# ── pure formula helper (mirrors app.py exactly) ─────────────────────────────
+
+def _score(critical=0, high=0, medium=0, low=0):
+ crit_impact = 40 * (1 - math.exp(-critical / 3)) if critical > 0 else 0
+ high_impact = 30 * (1 - math.exp(-high / 5)) if high > 0 else 0
+ med_impact = 20 * (1 - math.exp(-medium / 8)) if medium > 0 else 0
+ low_impact = 10 * (1 - math.exp(-low / 10)) if low > 0 else 0
+ return min(100, round(crit_impact + high_impact + med_impact + low_impact))
+
+
+# ── formula unit tests (no CVE data) ─────────────────────────────────────────
+
+class TestRiskScoreFormula:
+ def test_zero_vulns_gives_zero(self):
+ """No vulnerabilities → risk score exactly 0."""
+ assert _score() == 0
+
+ def test_one_critical_gives_11(self):
+ """1 CRITICAL → score 11 (logarithmic, not 40)."""
+ assert _score(critical=1) == 11
+
+ def test_one_high_gives_5(self):
+ """1 HIGH → score 5."""
+ assert _score(high=1) == 5
+
+ def test_one_medium_gives_2(self):
+ """1 MEDIUM → score 2."""
+ assert _score(medium=1) == 2
+
+ def test_one_low_gives_1(self):
+ """1 LOW → score 1."""
+ assert _score(low=1) == 1
+
+ def test_three_critical_gives_25(self):
+ """3 CRITICAL → score 25 (40 * (1 - e^-1) ≈ 25)."""
+ assert _score(critical=3) == 25
+
+ def test_ten_critical_gives_39(self):
+ """10 CRITICAL → score 39 (logarithm saturates near 40)."""
+ assert _score(critical=10) == 39
+
+ def test_score_is_logarithmic_not_linear(self):
+ """10 CRITICALs must NOT equal 10× the score of 1 CRITICAL."""
+ one = _score(critical=1)
+ ten = _score(critical=10)
+ assert ten != 10 * one
+ assert ten < 10 * one
+
+ def test_mixed_severities_round_total_not_components(self):
+ """1 of each: 11.34+5.44+2.35+0.95=20.08 → rounds to 20, not 19.
+ Formula rounds the TOTAL sum, not each component independently."""
+ assert _score(critical=1, high=1, medium=1, low=1) == 20
+
+ def test_ten_each_severity_gives_85(self):
+ """10 of each severity → 85 (well below the cap)."""
+ assert _score(critical=10, high=10, medium=10, low=10) == 85
+
+ def test_score_capped_at_100(self):
+ """Extreme counts never exceed 100."""
+ assert _score(critical=1000, high=1000, medium=1000, low=1000) == 100
+
+ def test_critical_outscores_high(self):
+ """1 CRITICAL always scores higher than 1 HIGH."""
+ assert _score(critical=1) > _score(high=1)
+
+ def test_high_outscores_medium(self):
+ """1 HIGH always scores higher than 1 MEDIUM."""
+ assert _score(high=1) > _score(medium=1)
+
+
+# ── API integration: verify /api/scan uses the logarithmic formula ────────────
+
+class TestRiskScoreViaApi:
+ def test_empty_deps_rejected_with_400(self, client):
+ """package.json with no dependencies returns HTTP 400 — the API requires
+ at least one dependency to scan."""
+ payload = {
+ "content": json.dumps({"name": "empty-app", "version": "1.0.0",
+ "dependencies": {}}),
+ "filename": "package.json",
+ "project_name": "empty-app",
+ }
+ resp = client.post('/api/scan', data=json.dumps(payload),
+ content_type='application/json')
+ assert resp.status_code == 400
+
+ def test_minimist_1_2_5_gives_nonzero_risk_score(self, client):
+ """minimist@1.2.5 has real CVEs — risk_score must be > 0."""
+ payload = {
+ "content": json.dumps({"name": "test-app", "version": "1.0.0",
+ "dependencies": {"minimist": "1.2.5"}}),
+ "filename": "package.json",
+ "project_name": "test-app",
+ }
+ resp = client.post('/api/scan', data=json.dumps(payload),
+ content_type='application/json')
+ assert resp.status_code == 200
+ body = resp.get_json()
+ assert body['summary']['risk_score'] > 0, (
+ "Expected risk_score > 0 for minimist@1.2.5 but got 0"
+ )
+
+ def test_risk_label_matches_score_range(self, client):
+ """risk_label must be consistent with the risk_score value.
+ Label mapping: >=90 Critical, >=70 High, >=40 Medium, >=1 Low, 0 Secure."""
+ payload = {
+ "content": json.dumps({"name": "test-app", "version": "1.0.0",
+ "dependencies": {"minimist": "1.2.5"}}),
+ "filename": "package.json",
+ "project_name": "test-app",
+ }
+ resp = client.post('/api/scan', data=json.dumps(payload),
+ content_type='application/json')
+ body = resp.get_json()
+ score = body['summary']['risk_score']
+ label = body['summary']['risk_label']
+ if score >= 90:
+ assert label == 'Critical'
+ elif score >= 70:
+ assert label == 'High'
+ elif score >= 40:
+ assert label == 'Medium'
+ elif score >= 1:
+ assert label == 'Low'
+ else:
+ assert label == 'Secure'
+
+ def test_pypi_scan_risk_score_nonzero(self, client):
+ """urllib3==1.26.4 has real CVEs — PyPI scan risk_score must be > 0."""
+ payload = {
+ "content": "urllib3==1.26.4\n",
+ "filename": "requirements.txt",
+ "project_name": "py-test-app",
+ }
+ resp = client.post('/api/scan', data=json.dumps(payload),
+ content_type='application/json')
+ assert resp.status_code == 200
+ assert resp.get_json()['summary']['risk_score'] > 0