IPAM Manager: API, UI-админка, журнал аудита
Backend (FastAPI, SQLAlchemy 2, Alembic, PostgreSQL 16): - организации, VRF, префиксы (дерево, использование, автоназначение), адреса, операторы связи, устройства и типы устройств; JWT, роли admin/viewer; - VRF принадлежит организации (составной FK), смена VRF у префикса переносит поддерево, имя VRF уникально в организации; - журнал аудита: поиск и фильтры, ротация (срок/количество), очистка по паролю с блокировкой, IP клиента и метаданные запроса (X-Forwarded-For только от TRUSTED_PROXIES). UI (web/, без сборки): экраны и диалоги по макетам «IPAM Manager», кликабельные строки реестров, локальные шрифты IBM Plex, собственные выпадающие списки. Окружение: docker-compose (postgres + app), миграции Alembic 0001-0004, scripts/gen_env.py, scripts/seed_demo.py, 11 автотестов (pytest). Документация: README.md и docs/changes/001-005 (планы и итоги). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
commit
a846d30872
64 files changed
+4194
No files matched your search
Whitespace-only changes.
@@ -0,0 +1,44 @@
|
||||
"""Тесты идут против приложения в контейнерах (docker compose up -d) с учётными данными из .env."""
|
||||
import pathlib
|
||||
import random
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
ENV = dict(l.split("=", 1) for l in pathlib.Path(__file__).resolve().parent.parent.joinpath(".env").read_text().split() if "=" in l)
|
||||
BASE = f"http://127.0.0.1:{ENV['APP_PORT']}/api/v1"
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def client():
|
||||
c = httpx.Client(base_url=BASE, timeout=30)
|
||||
tok = c.post("/auth/login", json={"username": ENV["ADMIN_USERNAME"], "password": ENV["ADMIN_PASSWORD"]}).json()["access_token"]
|
||||
c.headers["Authorization"] = f"Bearer {tok}"
|
||||
return c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def org(client):
|
||||
"""Временная организация с дефолтным VRF; после теста удаляется вместе с содержимым."""
|
||||
o = client.post("/organizations", json={"name": f"test-{uuid.uuid4().hex[:8]}", "inn": "".join(random.choices("0123456789", k=10))}).json()
|
||||
o["vrf_id"] = client.get("/vrfs", params={"organization_id": o["id"]}).json()["items"][0]["id"]
|
||||
yield o
|
||||
for p in client.get("/prefixes", params={"organization_id": o["id"]}).json()["items"]:
|
||||
client.delete(f"/prefixes/{p['id']}", params={"force": True})
|
||||
for d in client.get("/devices", params={"organization_id": o["id"]}).json()["items"]:
|
||||
client.delete(f"/devices/{d['id']}")
|
||||
for v in client.get("/vrfs", params={"organization_id": o["id"]}).json()["items"]:
|
||||
client.delete(f"/vrfs/{v['id']}")
|
||||
client.delete(f"/organizations/{o['id']}")
|
||||
|
||||
|
||||
DSN = f"postgresql://{ENV['POSTGRES_USER']}:{ENV['POSTGRES_PASSWORD']}@127.0.0.1:{ENV['DB_HOST_PORT']}/{ENV['POSTGRES_DB']}"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db():
|
||||
"""Прямое подключение к БД — для сценариев, недоступных через API (старые записи, временные пользователи)."""
|
||||
import psycopg
|
||||
with psycopg.connect(DSN, autocommit=True) as conn:
|
||||
yield conn
|
||||
@@ -0,0 +1,82 @@
|
||||
import httpx
|
||||
|
||||
from tests.conftest import BASE, ENV
|
||||
|
||||
|
||||
def _prefix(client, org, cidr, **kw):
|
||||
return client.post("/prefixes", json={"organization_id": org["id"], "vrf_id": org["vrf_id"], "prefix": cidr, **kw})
|
||||
|
||||
|
||||
def test_auth_required_and_login():
|
||||
anon = httpx.Client(base_url=BASE)
|
||||
assert anon.get("/prefixes").status_code == 401
|
||||
assert anon.post("/auth/login", json={"username": ENV["ADMIN_USERNAME"], "password": "wrong"}).status_code == 401
|
||||
ok = anon.post("/auth/login", json={"username": ENV["ADMIN_USERNAME"], "password": ENV["ADMIN_PASSWORD"]})
|
||||
assert ok.status_code == 200 and ok.json()["access_token"]
|
||||
|
||||
|
||||
def test_prefix_and_address_validation(client, org):
|
||||
assert _prefix(client, org, "10.201.0.0/24").status_code == 201
|
||||
assert _prefix(client, org, "10.201.0.0/24").status_code == 409 # дубль в VRF
|
||||
assert _prefix(client, org, "10.201.1.5/24").status_code == 422 # биты хоста
|
||||
pid = client.get("/prefixes", params={"organization_id": org["id"]}).json()["items"][0]["id"]
|
||||
assert client.post(f"/prefixes/{pid}/addresses", json={"address": "10.202.0.1"}).status_code == 422 # вне префикса
|
||||
|
||||
|
||||
def test_tree_utilization_and_next_free(client, org):
|
||||
parent = _prefix(client, org, "10.203.0.0/16").json()
|
||||
leaf = _prefix(client, org, "10.203.1.0/29", is_pool=True).json()
|
||||
assert leaf["parent_id"] == parent["id"]
|
||||
a = client.post(f"/prefixes/{leaf['id']}/addresses/next").json()
|
||||
assert a["address"] == "10.203.1.1"
|
||||
client.post(f"/prefixes/{leaf['id']}/addresses", json={"address": "10.203.1.2", "status": "reserved"})
|
||||
page = client.get(f"/prefixes/{leaf['id']}/addresses").json()
|
||||
assert page["summary"] == {"assigned": 1, "reserved": 1, "deprecated": 0, "free": 4, "capacity": 6}
|
||||
assert client.post(f"/prefixes/{leaf['id']}/addresses/next").json()["address"] == "10.203.1.3"
|
||||
parent = client.get(f"/prefixes/{parent['id']}").json()
|
||||
assert parent["capacity"] == 6 and parent["used"] == 2 # ёмкость родителя — по вложенным листьям
|
||||
|
||||
|
||||
def test_in_use_objects_cannot_be_deleted(client, org):
|
||||
_prefix(client, org, "10.204.0.0/24")
|
||||
assert client.delete(f"/vrfs/{org['vrf_id']}").status_code == 409
|
||||
t = client.get("/device-types").json()["items"][0]
|
||||
client.post("/devices", json={"name": "t-1.internal", "device_type_id": t["id"], "organization_id": org["id"]})
|
||||
assert client.delete(f"/device-types/{t['id']}").status_code == 409
|
||||
|
||||
|
||||
def test_vrf_name_unique_per_organization(client, org):
|
||||
other = client.post("/organizations", json={"name": f"other-{org['name']}", "inn": "".join(reversed(org["inn"]))}).json()
|
||||
try:
|
||||
assert client.post("/vrfs", json={"organization_id": org["id"], "name": "Lab"}).status_code == 201
|
||||
assert client.post("/vrfs", json={"organization_id": org["id"], "name": "lab"}).status_code == 409 # регистр не важен
|
||||
assert client.post("/vrfs", json={"organization_id": other["id"], "name": "Lab"}).status_code == 201 # в другой организации можно
|
||||
finally:
|
||||
for v in client.get("/vrfs", params={"organization_id": other["id"]}).json()["items"]:
|
||||
client.delete(f"/vrfs/{v['id']}")
|
||||
client.delete(f"/organizations/{other['id']}")
|
||||
|
||||
|
||||
def test_move_prefix_subtree_to_another_vrf(client, org):
|
||||
lab = client.post("/vrfs", json={"organization_id": org["id"], "name": "lab"}).json()
|
||||
parent = _prefix(client, org, "10.205.0.0/16").json()
|
||||
child = _prefix(client, org, "10.205.1.0/24").json()
|
||||
assert child["parent_id"] == parent["id"]
|
||||
# конфликт в целевом VRF -> 409, ничего не переехало
|
||||
client.post("/prefixes", json={"organization_id": org["id"], "vrf_id": lab["id"], "prefix": "10.205.1.0/24"})
|
||||
assert client.patch(f"/prefixes/{parent['id']}", json={"vrf_id": lab["id"]}).status_code == 409
|
||||
assert client.get(f"/prefixes/{child['id']}").json()["vrf_id"] == org["vrf_id"]
|
||||
# VRF другой организации -> 422
|
||||
foreign = client.post("/organizations", json={"name": f"f-{org['name']}", "inn": "".join(reversed(org["inn"]))}).json()
|
||||
foreign_vrf = client.get("/vrfs", params={"organization_id": foreign["id"]}).json()["items"][0]
|
||||
try:
|
||||
assert client.patch(f"/prefixes/{parent['id']}", json={"vrf_id": foreign_vrf["id"]}).status_code == 422
|
||||
finally:
|
||||
client.delete(f"/vrfs/{foreign_vrf['id']}")
|
||||
client.delete(f"/organizations/{foreign['id']}")
|
||||
# без конфликта: переезжает поддерево, родитель пересчитан
|
||||
for p in client.get("/prefixes", params={"organization_id": org["id"], "vrf_id": lab["id"]}).json()["items"]:
|
||||
client.delete(f"/prefixes/{p['id']}")
|
||||
assert client.patch(f"/prefixes/{parent['id']}", json={"vrf_id": lab["id"]}).status_code == 200
|
||||
moved = client.get(f"/prefixes/{child['id']}").json()
|
||||
assert moved["vrf_id"] == lab["id"] and moved["parent_id"] == parent["id"]
|
||||
@@ -0,0 +1,79 @@
|
||||
import ipaddress
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
|
||||
from app.request_context import parse_networks, resolve_client_ip
|
||||
from app.security import hash_password
|
||||
from tests.conftest import BASE
|
||||
|
||||
|
||||
def test_journal_search_and_filters(client, org):
|
||||
cidr = f"fd00:{uuid.uuid4().hex[:4]}:{uuid.uuid4().hex[:4]}::/64" # уникальный для запуска: журнал общий и накапливается
|
||||
client.post("/prefixes", json={"organization_id": org["id"], "vrf_id": org["vrf_id"], "prefix": cidr})
|
||||
hit = client.get("/audit", params={"q": cidr}).json()
|
||||
assert hit["total"] >= 1
|
||||
entry = hit["items"][0]
|
||||
assert entry["event_type"] == "prefix.created" and entry["actor"] == "ui:admin" and "создан" in entry["message"]
|
||||
assert client.get("/audit", params={"q": "evt_" + entry["short_id"]}).json()["total"] >= 1 # поиск по ID
|
||||
assert client.get("/audit", params={"q": cidr, "event_type": "prefix.deleted"}).json()["total"] == 0
|
||||
assert client.get("/audit", params={"q": cidr, "actor": "system"}).json()["total"] == 0
|
||||
assert client.get("/audit", params={"q": cidr, "date_from": "2999-01-01"}).json()["total"] == 0
|
||||
assert client.get(f"/audit/{entry['uid']}").json()["id"] == entry["id"]
|
||||
assert client.get("/audit", params={"q": "100%_"}).json()["total"] == 0 # спецсимволы LIKE экранируются
|
||||
|
||||
|
||||
def test_rotation_by_age_and_count(client, db):
|
||||
old = db.execute("INSERT INTO audit_log (ts, username, entity_type, entity_label, action, message) "
|
||||
"VALUES (now() - interval '200 days', 'admin', 'prefix', 'rot-old', 'created', 'ROT-OLD') RETURNING uid").fetchone()[0]
|
||||
try:
|
||||
assert client.put("/journal/settings", json={"retention_days": 30, "max_entries": 100000}).json()["deleted"] >= 1
|
||||
assert client.get(f"/audit/{old}").status_code == 404 # старая запись ушла
|
||||
total = client.get("/audit/summary").json()["total"]
|
||||
r = client.put("/journal/settings", json={"retention_days": 30, "max_entries": total - 3}).json()
|
||||
assert r["deleted"] == 5 # 4 лишних (с учётом записи о смене настроек) + место под сводную запись
|
||||
assert client.get("/audit/summary").json()["total"] == total - 3
|
||||
assert client.get("/audit", params={"event_type": "journal.rotated"}).json()["total"] >= 1
|
||||
finally:
|
||||
client.put("/journal/settings", json={"retention_days": 90, "max_entries": 100000})
|
||||
|
||||
|
||||
def test_clear_requires_password_and_locks_out(db):
|
||||
name, pw = f"tmp-{uuid.uuid4().hex[:6]}", "tmp-pass-123"
|
||||
db.execute("INSERT INTO users (username, password_hash, role, is_active) VALUES (%s, %s, 'admin', true)", (name, hash_password(pw)))
|
||||
try:
|
||||
c = httpx.Client(base_url=BASE)
|
||||
c.headers["Authorization"] = "Bearer " + c.post("/auth/login", json={"username": name, "password": pw}).json()["access_token"]
|
||||
first = c.post("/journal/clear", json={"password": "wrong"})
|
||||
assert first.status_code == 403 and first.json()["attempts_left"] == 4
|
||||
codes = [c.post("/journal/clear", json={"password": "wrong"}).status_code for _ in range(4)]
|
||||
assert codes == [403, 403, 403, 429] # пятая неудача — блокировка
|
||||
locked = c.post("/journal/clear", json={"password": pw}) # даже верный пароль при блокировке отклоняется
|
||||
assert locked.status_code == 429 and locked.json()["retry_after_seconds"] > 0
|
||||
finally:
|
||||
db.execute("DELETE FROM users WHERE username = %s", (name,))
|
||||
|
||||
|
||||
def test_client_ip_and_request_meta_recorded(client, org):
|
||||
hdr = {"User-Agent": "ipam-tests/1.0", "X-Forwarded-For": "203.0.113.9"} # подделка XFF от недоверенного пира
|
||||
cidr = f"fd00:{uuid.uuid4().hex[:4]}:{uuid.uuid4().hex[:4]}::/64"
|
||||
client.post("/prefixes", headers=hdr, json={"organization_id": org["id"], "vrf_id": org["vrf_id"], "prefix": cidr})
|
||||
entry = client.get("/audit", params={"q": cidr}).json()["items"][0]
|
||||
assert ipaddress.ip_address(entry["client_ip"])
|
||||
assert entry["client_ip"] != "203.0.113.9" # заголовок не доверенного прокси игнорируется
|
||||
assert entry["meta"]["user_agent"] == "ipam-tests/1.0" and entry["meta"]["method"] == "POST" and entry["meta"]["path"] == "/api/v1/prefixes"
|
||||
assert client.get("/audit", params={"q": cidr, "client_ip": entry["client_ip"]}).json()["total"] >= 1 # точный IP
|
||||
assert client.get("/audit", params={"q": cidr, "client_ip": "203.0.113.0/24"}).json()["total"] == 0
|
||||
assert client.get("/audit", params={"client_ip": "не-ip"}).status_code == 422
|
||||
rotated = client.get("/audit", params={"event_type": "journal.rotated", "limit": 1}).json()["items"]
|
||||
assert all(r["client_ip"] is None for r in rotated) # системные события — без IP
|
||||
|
||||
|
||||
def test_resolve_client_ip_trusts_forwarded_header_only_from_proxies():
|
||||
trusted = parse_networks("10.0.0.0/8, 192.168.1.5")
|
||||
assert resolve_client_ip("198.51.100.7", "203.0.113.9", trusted) == "198.51.100.7" # недоверенный пир: XFF игнорируется
|
||||
assert resolve_client_ip("10.1.2.3", "203.0.113.9, 10.9.9.9", trusted) == "203.0.113.9" # справа первый недоверенный
|
||||
assert resolve_client_ip("10.1.2.3", "1.1.1.1, 203.0.113.9, 10.9.9.9", trusted) == "203.0.113.9" # клиент не может «подставить» левый адрес
|
||||
assert resolve_client_ip("10.1.2.3", "мусор", trusted) == "10.1.2.3"
|
||||
assert resolve_client_ip("::ffff:192.0.2.4", None, trusted) == "192.0.2.4" # IPv4-mapped
|
||||
assert resolve_client_ip(None, None, trusted) is None
|
||||
Reference in new issue
Block a user