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:
ayurishchevandClaude Sonnet 5 committed 2026-09-20 12:27:47 +03:00
commit a846d30872
64 files changed
+4194

No files matched your search

View File
Whitespace-only changes.
+44
View File
@@ -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
+82
View File
@@ -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"]
+79
View File
@@ -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