Files
ripe-cidr-collector/tests/test_core.py
T
ayurishchevandClaude Sonnet 5 aaf41adc79 Stage B of review fixes: daemon state lock, image build check, RIPEstat retries
Job state is changed under one RLock, the Dockerfile copies all root
modules and imports them at build time, RIPEstat requests go through a
retrying session with the sourceapp parameter (ripestat_sourceapp) and a
capped Retry-After. Adds the summary and marks review findings 5-10 fixed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 10:12:48 +03:00

143 lines
6.4 KiB
Python

import datetime
import os
import sys
import pytest
from fastapi.testclient import TestClient
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import api_server
import cidr_collector as cc
import db
from storage import StorageError, load_json
NOW = datetime.datetime.now()
@pytest.fixture
def files(tmp_path, monkeypatch):
monkeypatch.setattr(cc, "CONFIG_FILE", str(tmp_path / "config.json"))
monkeypatch.setattr(cc, "DATA_FILE", str(tmp_path / "data.json"))
monkeypatch.setattr(cc, "FQDN_DATA_FILE", str(tmp_path / "fqdn_data.json"))
monkeypatch.setattr(cc, "DB_FILE", str(tmp_path / "ripe.db"))
return tmp_path
def stored(kind="asn"):
with db.session() as conn:
return sorted(db.get_values(conn, kind))
def test_ttl_merge_and_failure_safety(files, monkeypatch):
(files / "config.json").write_text('{"asns": [1], "ttl_days": 90}')
collector = cc.CIDRCollector()
old = (NOW - datetime.timedelta(days=100)).isoformat(timespec="seconds")
with db.session() as conn:
conn.execute("INSERT INTO addresses VALUES ('asn', '1', '9.9.9.0/24', ?, ?)", (old, old))
# Ошибка источника: ничего не удаляется, даже просроченное
monkeypatch.setattr(collector, "fetch_prefixes", lambda asn: None)
collector.run_collection()
assert stored() == ["9.9.9.0/24"]
# Успешный ответ: просроченный удаляется, новый добавляется
monkeypatch.setattr(collector, "fetch_prefixes", lambda asn: ["1.1.1.0/24"])
collector.run_collection()
assert stored() == ["1.1.1.0/24"]
def test_corrupted_storage_is_preserved(files):
# Битый JSON (конфигурация)
path = files / "data.json"
path.write_text("{broken")
with pytest.raises(StorageError):
load_json(str(path), {})
assert not path.exists()
assert len(list(files.glob("data.json.corrupt-*"))) == 1
# Битая база без копий: оригинал убран в сторону, создана новая пустая база и поставлена метка пересоздания
(files / "ripe.db").write_bytes(b"this is not a sqlite database" * 100)
with db.session() as conn:
assert db.get_values(conn) == []
assert len(list(files.glob("ripe.db.corrupt-*"))) == 1
assert (files / "db_recreated.json").exists()
def test_post_schedule_auth(files, monkeypatch):
(files / "config.json").write_text('{"asns": [], "fqdns": []}')
client = TestClient(api_server.app)
body = {"type": "asn", "cron": "*/5 * * * *"}
monkeypatch.delenv("RIPE_API_TOKEN", raising=False)
assert client.post("/schedule", json=body).status_code == 503
monkeypatch.setenv("RIPE_API_TOKEN", "secret")
assert client.post("/schedule", json=body).status_code == 401
assert client.post("/schedule", json=body, headers={"X-API-Key": "wrong"}).status_code == 401
assert client.post("/schedule", json=body, headers={"X-API-Key": "secret"}).status_code == 200
assert load_json(cc.CONFIG_FILE, {})["schedule"]["asn"] == "*/5 * * * *"
assert client.get("/addresses").status_code == 200
def test_fqdn_keeps_only_global_addresses(files, monkeypatch):
answers = [(2, 1, 6, "", (address, 0)) for address in
("93.184.216.34", "127.0.0.1", "10.0.0.5", "0.0.0.0", "fe80::1%eth0", "2606:2800:220:1::1")]
monkeypatch.setattr(cc.socket, "getaddrinfo", lambda *args, **kwargs: answers)
(files / "config.json").write_text('{"fqdns": ["x.example"]}')
assert sorted(cc.FQDNCollector().resolve_fqdn("x.example")) == ["2606:2800:220:1::1", "93.184.216.34"]
# Внутренние имена: фильтр отключается, зона IPv6 отбрасывается
(files / "config.json").write_text('{"fqdns": ["x.example"], "allow_non_global_ips": true}')
everything = cc.FQDNCollector().resolve_fqdn("x.example")
assert {"127.0.0.1", "10.0.0.5", "fe80::1"} <= set(everything) and len(everything) == 6
# Глобальных адресов нет - результат пуст, как при ошибке DNS (сбор ничего не удаляет)
monkeypatch.setattr(cc.socket, "getaddrinfo", lambda *args, **kwargs: [answers[1]])
(files / "config.json").write_text('{"fqdns": ["x.example"]}')
assert cc.FQDNCollector().resolve_fqdn("x.example") == []
def test_ripestat_session_retries_and_sourceapp(files, monkeypatch):
calls = []
real_make_session = cc.make_session
class Response:
def raise_for_status(self):
pass
def json(self):
return {"data": {"prefixes": [{"prefix": "1.0.0.0/24"}]}}
class Session:
def get(self, url, params, timeout):
calls.append((params, timeout))
return Response()
def close(self):
pass
monkeypatch.setattr(cc, "make_session", lambda: Session())
assert cc.CIDRCollector().fetch_prefixes(1) == ["1.0.0.0/24"]
assert calls[0][0] == {"resource": "AS1", "sourceapp": "ripe-cidr-collector"} and calls[0][1] == cc.RIPESTAT_TIMEOUT
# Своё имя приложения из config.json (например, с контактом)
(files / "config.json").write_text('{"asns": [], "ripestat_sourceapp": "my-app admin@example.org"}')
assert cc.CIDRCollector().fetch_prefixes(2) == ["1.0.0.0/24"]
assert calls[1][0]["sourceapp"] == "my-app admin@example.org"
# После исчерпания повторов источник пропускается (None), сбор ничего не удаляет
collector = cc.CIDRCollector()
monkeypatch.setattr(collector, "sourceapp", "x")
Session.get = lambda self, url, params, timeout: (_ for _ in ()).throw(cc.requests.exceptions.RetryError("x"))
assert collector.fetch_prefixes(3) is None
# Настройка сессии: повторы на сбои и коды 429/5xx, пауза Retry-After ограничена потолком
retry = real_make_session().get_adapter("https://stat.ripe.net").max_retries
assert retry.total == cc.RIPESTAT_RETRIES and 503 in retry.status_forcelist and 429 in retry.status_forcelist
class Response: # сервер просит подождать час: пауза ограничена потолком
headers = {"Retry-After": "3600"}
assert retry.get_retry_after(Response()) == cc.MAX_RETRY_AFTER