Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/codeql/codeql-config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
name: "Squawk CodeQL config"

# Test code triggers production-oriented CodeQL queries as false positives
# (fixture credentials, and membership checks like `"host" in collection` that
# the URL-substring-sanitization query misreads as URL sanitization). Exclude
# test and vendored code from analysis; production source is still scanned.
paths-ignore:
- '**/tests/**'
- '**/test_*.py'
- '**/*_test.py'
- '**/*_test.go'
- '**/__tests__/**'
- '**/*.test.ts'
- '**/*.test.tsx'
- '**/*.spec.ts'
- '**/*.spec.tsx'
- '**/node_modules/**'
- '**/venv/**'
- '**/.venv/**'
10 changes: 5 additions & 5 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,17 +32,17 @@ jobs:

- name: Install dependencies for coverage
run: |
cd dns-server && pip install -r requirements.txt pytest pytest-cov
cd dns-server && pip install -r requirements.txt -r requirements-dev.txt pytest pytest-cov

- name: Run tests with coverage
run: |
# Intentionally non-gating: real aggregate coverage has not yet reached
# the 98% target here (tracked separately). Do not unmask until the
# underlying test-coverage work lands.
# Gating: dns-server coverage must stay at/above the 90% house standard.
# requirements-dev.txt (installed above) provides test-only deps such as
# `responses` that some suites import at collection time.
python3 -m pytest dns-server/tests \
--cov=dns-server/app \
--cov-report=xml:coverage.xml --cov-report=term-missing \
--cov-fail-under=98 -v --tb=short || true
--cov-fail-under=90 -v --tb=short

- name: Upload coverage to Codecov
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ jobs:
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
config-file: ./.github/codeql/codeql-config.yml

# For build-mode: autobuild (go), the init step above builds the source
# automatically. Interpreted languages (python, javascript-typescript)
Expand Down
10 changes: 5 additions & 5 deletions .github/workflows/server-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,17 +28,17 @@ jobs:

- name: Install test dependencies
run: |
cd dns-server && pip install -r requirements.txt pytest pytest-cov
cd dns-server && pip install -r requirements.txt -r requirements-dev.txt pytest pytest-cov

- name: Run tests with coverage
run: |
# Intentionally non-gating: real aggregate coverage has not yet reached
# the 98% target here (tracked separately). Do not unmask until the
# underlying test-coverage work lands.
# Gating: dns-server coverage must stay at/above the 90% house standard.
# requirements-dev.txt (installed above) provides test-only deps such
# as `responses` that some suites import at collection time.
python3 -m pytest dns-server/tests \
--cov=dns-server/app \
--cov-report=xml:coverage.xml --cov-report=term-missing \
--cov-fail-under=98 -v --tb=short || true
--cov-fail-under=90 -v --tb=short

- name: Upload coverage to Codecov
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6
Expand Down
5 changes: 4 additions & 1 deletion dns-server/app/services/cert_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -439,7 +439,10 @@ def create_server_cert(
NameOID.ORGANIZATIONAL_UNIT_NAME,
os.getenv("SERVER_OU", "DNS Server"),
),
x509.NameAttribute(NameOID.COMMON_NAME, hostname),
# X.509 CommonName is capped at 64 chars; the full hostname
# still goes in the SAN above (a long FQDN would otherwise
# raise ValueError during cert generation).
x509.NameAttribute(NameOID.COMMON_NAME, hostname[:64]),
]
)

Expand Down
4 changes: 2 additions & 2 deletions dns-server/app/services/prometheus_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import logging
from datetime import datetime, timedelta
from typing import Dict, Optional
from collections import Counter, defaultdict, deque
from collections import Counter as CollectionsCounter, defaultdict, deque
import threading
from pydal import DAL
from prometheus_client import (
Expand Down Expand Up @@ -289,7 +289,7 @@ def record_query(
# forever). Trim to the most-queried domains once we
# exceed the cap.
if len(self.top_domains) > self._MAX_TOP_DOMAINS:
trimmed = Counter(self.top_domains).most_common(
trimmed = CollectionsCounter(self.top_domains).most_common(
self._MAX_TOP_DOMAINS
)
self.top_domains = defaultdict(int, trimmed)
Expand Down
2 changes: 2 additions & 0 deletions dns-server/requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,5 @@ bandit>=1.7.5
pre-commit>=3.5.0
responses>=0.23.0
isort>=5.12.0
requests-mock>=1.11.0
psutil>=5.9.0
229 changes: 229 additions & 0 deletions dns-server/tests/test_cache_manager_coverage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
"""
Coverage tests for app/services/cache_manager.py

Covers CacheManager init success/failure, get/set/clear happy paths and
error paths, and get_stats ratio math. Redis is mocked throughout (no real
Redis/Valkey server needed) since fakeredis is not installed in this
environment.
"""
import json

import pytest
from unittest.mock import Mock, patch

from app.services.cache_manager import CacheManager


class _FakeRedis:
"""Minimal stand-in for a redis.Redis client used in tests."""

def __init__(self):
self.store = {}
self.ping_error = None
self.get_error = None
self.setex_error = None
self.keys_error = None

def ping(self):
if self.ping_error:
raise self.ping_error
return True

def get(self, key):
if self.get_error:
raise self.get_error
return self.store.get(key)

def setex(self, key, ttl, value):
if self.setex_error:
raise self.setex_error
self.store[key] = value

def keys(self, pattern):
if self.keys_error:
raise self.keys_error
prefix = pattern.rstrip("*")
return [k for k in self.store if k.startswith(prefix)]

def delete(self, *keys):
for k in keys:
self.store.pop(k, None)


class TestCacheManagerInit:
"""Constructor connects (and pings) the Redis client, failing closed."""

def test_init_success_sets_redis_client(self):
fake = _FakeRedis()
with patch("app.services.cache_manager.redis.from_url", return_value=fake):
manager = CacheManager(cache_url="redis://fake:6379")

assert manager.redis is fake
assert manager.cache_hits == 0
assert manager.cache_misses == 0

def test_init_from_url_raises_leaves_redis_none(self):
with patch(
"app.services.cache_manager.redis.from_url",
side_effect=ConnectionError("no route to host"),
):
manager = CacheManager(cache_url="redis://unreachable:6379")

assert manager.redis is None

def test_init_ping_failure_leaves_redis_none(self):
fake = _FakeRedis()
fake.ping_error = ConnectionError("refused")
with patch("app.services.cache_manager.redis.from_url", return_value=fake):
manager = CacheManager(cache_url="redis://fake:6379")

assert manager.redis is None


def _manager_with_fake_redis(fake=None):
fake = fake if fake is not None else _FakeRedis()
with patch("app.services.cache_manager.redis.from_url", return_value=fake):
manager = CacheManager(cache_url="redis://fake:6379")
return manager, fake


class TestCacheManagerGet:
@pytest.mark.asyncio
async def test_get_returns_none_when_no_redis(self):
manager, _ = _manager_with_fake_redis()
manager.redis = None

result = await manager.get("example.com", "A")

assert result is None
assert manager.cache_hits == 0
assert manager.cache_misses == 0

@pytest.mark.asyncio
async def test_get_cache_hit_increments_hits_and_parses_json(self):
manager, fake = _manager_with_fake_redis()
payload = {"Status": 0, "Answer": [{"data": "1.2.3.4"}]}
fake.store["dns:example.com:A"] = json.dumps(payload)

result = await manager.get("example.com", "A")

assert result == payload
assert manager.cache_hits == 1
assert manager.cache_misses == 0

@pytest.mark.asyncio
async def test_get_cache_miss_increments_misses(self):
manager, _ = _manager_with_fake_redis()

result = await manager.get("missing.example.com", "A")

assert result is None
assert manager.cache_hits == 0
assert manager.cache_misses == 1

@pytest.mark.asyncio
async def test_get_exception_returns_none_without_raising(self):
fake = _FakeRedis()
fake.get_error = RuntimeError("redis exploded")
manager, _ = _manager_with_fake_redis(fake)

result = await manager.get("example.com", "A")

assert result is None


class TestCacheManagerSet:
@pytest.mark.asyncio
async def test_set_is_noop_when_no_redis(self):
manager, fake = _manager_with_fake_redis()
manager.redis = None

await manager.set("example.com", "A", {"Status": 0})

assert fake.store == {}

@pytest.mark.asyncio
async def test_set_stores_serialized_result_with_ttl(self):
manager, fake = _manager_with_fake_redis()
result = {"Status": 0, "Answer": []}

await manager.set("example.com", "A", result, ttl=60)

assert json.loads(fake.store["dns:example.com:A"]) == result

@pytest.mark.asyncio
async def test_set_uses_default_ttl_from_config(self):
manager, fake = _manager_with_fake_redis()

await manager.set("example.com", "AAAA", {"Status": 0})

assert "dns:example.com:AAAA" in fake.store

@pytest.mark.asyncio
async def test_set_exception_is_swallowed(self):
fake = _FakeRedis()
fake.setex_error = RuntimeError("write failed")
manager, _ = _manager_with_fake_redis(fake)

# Must not raise.
await manager.set("example.com", "A", {"Status": 0})


class TestCacheManagerStats:
def test_get_stats_zero_hit_rate_when_no_activity(self):
manager, _ = _manager_with_fake_redis()

stats = manager.get_stats()

assert stats == {"cache_hits": 0, "cache_misses": 0, "hit_rate": 0}

def test_get_stats_computes_hit_rate(self):
manager, _ = _manager_with_fake_redis()
manager.cache_hits = 3
manager.cache_misses = 1

stats = manager.get_stats()

assert stats["cache_hits"] == 3
assert stats["cache_misses"] == 1
assert stats["hit_rate"] == pytest.approx(0.75)


class TestCacheManagerClear:
def test_clear_is_noop_when_no_redis(self):
manager, fake = _manager_with_fake_redis()
manager.redis = None
fake.store["dns:example.com:A"] = "{}"

manager.clear()

# Untouched because manager.redis was cleared before calling clear().
assert fake.store["dns:example.com:A"] == "{}"

def test_clear_deletes_matching_keys(self):
manager, fake = _manager_with_fake_redis()
fake.store["dns:example.com:A"] = "{}"
fake.store["dns:other.com:AAAA"] = "{}"
fake.store["unrelated:key"] = "keep-me"

manager.clear()

assert "dns:example.com:A" not in fake.store
assert "dns:other.com:AAAA" not in fake.store
assert fake.store["unrelated:key"] == "keep-me"

def test_clear_with_no_matching_keys_does_not_call_delete(self):
manager, fake = _manager_with_fake_redis()
fake.delete = Mock(wraps=fake.delete)

manager.clear()

fake.delete.assert_not_called()

def test_clear_exception_is_swallowed(self):
fake = _FakeRedis()
fake.keys_error = RuntimeError("scan failed")
manager, _ = _manager_with_fake_redis(fake)

# Must not raise.
manager.clear()
Loading
Loading