From f7d5aec4ecef10759df3c997fdcd4fc7e60bf6fe Mon Sep 17 00:00:00 2001 From: Jason Staack Date: Thu, 12 Mar 2026 22:58:51 -0500 Subject: [PATCH] feat(06-01): add config history service with TDD tests - Service queries router_config_changes JOIN router_config_diffs for timeline - Returns paginated entries with component, summary, timestamp, diff metadata - ORDER BY created_at DESC with limit/offset pagination - 4 tests covering formatting, empty results, pagination, and ordering Co-Authored-By: Claude Opus 4.6 --- .planning/ROADMAP.md | 6 +- .../app/services/config_history_service.py | 64 +++++++++ backend/tests/test_config_history_service.py | 122 ++++++++++++++++++ 3 files changed, 189 insertions(+), 3 deletions(-) create mode 100644 backend/app/services/config_history_service.py create mode 100644 backend/tests/test_config_history_service.py diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index baea1e1..a7df1a2 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -106,11 +106,11 @@ Plans: 3. GET `/api/tenants/{tid}/devices/{did}/config/{snapshot_id}/diff` returns unified diff text 4. All endpoints enforce RBAC: viewer+ can read history, operator+ required for backup trigger 5. Endpoints return proper 404 for nonexistent snapshots and 403 for unauthorized access -**Plans**: TBD +**Plans**: 2 plans Plans: -- [ ] 06-01: Config history timeline endpoint -- [ ] 06-02: Snapshot view and diff retrieval endpoints with RBAC +- [ ] 06-01-PLAN.md — Config history timeline endpoint with service, router, and tests +- [ ] 06-02-PLAN.md — Snapshot view and diff retrieval endpoints with Transit decrypt and RBAC ### Phase 7: Config History UI **Goal**: Device detail page displays a Configuration History section showing a timeline of config changes diff --git a/backend/app/services/config_history_service.py b/backend/app/services/config_history_service.py new file mode 100644 index 0000000..f60a92f --- /dev/null +++ b/backend/app/services/config_history_service.py @@ -0,0 +1,64 @@ +"""Config history timeline service. + +Provides paginated query of config change entries for a device, +joining router_config_changes with router_config_diffs to include +diff metadata (lines_added, lines_removed, snapshot_id). +""" + +import logging + +from sqlalchemy import text + +logger = logging.getLogger(__name__) + + +async def get_config_history( + device_id: str, + tenant_id: str, + session, + limit: int = 50, + offset: int = 0, +) -> list[dict]: + """Return paginated config change timeline for a device. + + Joins router_config_changes with router_config_diffs to get + diff metadata alongside each change entry. Results are ordered + by created_at DESC (newest first). + + Returns a list of dicts with: id, component, summary, created_at, + diff_id, lines_added, lines_removed, snapshot_id. + """ + result = await session.execute( + text( + "SELECT c.id, c.component, c.summary, c.created_at, " + "d.id AS diff_id, d.lines_added, d.lines_removed, " + "d.new_snapshot_id AS snapshot_id " + "FROM router_config_changes c " + "JOIN router_config_diffs d ON c.diff_id = d.id " + "WHERE c.device_id = CAST(:device_id AS uuid) " + "AND c.tenant_id = CAST(:tenant_id AS uuid) " + "ORDER BY c.created_at DESC " + "LIMIT :limit OFFSET :offset" + ), + { + "device_id": device_id, + "tenant_id": tenant_id, + "limit": limit, + "offset": offset, + }, + ) + rows = result.fetchall() + + return [ + { + "id": str(row._mapping["id"]), + "component": row._mapping["component"], + "summary": row._mapping["summary"], + "created_at": row._mapping["created_at"].isoformat(), + "diff_id": str(row._mapping["diff_id"]), + "lines_added": row._mapping["lines_added"], + "lines_removed": row._mapping["lines_removed"], + "snapshot_id": str(row._mapping["snapshot_id"]), + } + for row in rows + ] diff --git a/backend/tests/test_config_history_service.py b/backend/tests/test_config_history_service.py new file mode 100644 index 0000000..0bdfcf4 --- /dev/null +++ b/backend/tests/test_config_history_service.py @@ -0,0 +1,122 @@ +"""Tests for config history timeline service. + +Tests the get_config_history function with mocked DB sessions, +following the same AsyncMock pattern as test_config_diff_service.py. +""" + +import pytest +from unittest.mock import AsyncMock, MagicMock +from uuid import uuid4 +from datetime import datetime, timezone + + +def _make_change_row(change_id, component, summary, created_at, diff_id, lines_added, lines_removed, snapshot_id): + """Create a mock row matching the JOIN query result.""" + row = MagicMock() + row._mapping = { + "id": change_id, + "component": component, + "summary": summary, + "created_at": created_at, + "diff_id": diff_id, + "lines_added": lines_added, + "lines_removed": lines_removed, + "snapshot_id": snapshot_id, + } + return row + + +@pytest.mark.asyncio +async def test_returns_formatted_entries(): + """get_config_history returns entries with all expected fields.""" + from app.services.config_history_service import get_config_history + + device_id = str(uuid4()) + tenant_id = str(uuid4()) + change_id = uuid4() + diff_id = uuid4() + snapshot_id = uuid4() + ts = datetime(2026, 3, 12, 10, 0, 0, tzinfo=timezone.utc) + + mock_session = AsyncMock() + result_mock = MagicMock() + result_mock.fetchall.return_value = [ + _make_change_row(change_id, "ip/firewall/filter", "Added 1 rule", ts, diff_id, 3, 1, snapshot_id), + ] + mock_session.execute = AsyncMock(return_value=result_mock) + + entries = await get_config_history(device_id, tenant_id, mock_session) + + assert len(entries) == 1 + entry = entries[0] + assert entry["id"] == str(change_id) + assert entry["component"] == "ip/firewall/filter" + assert entry["summary"] == "Added 1 rule" + assert entry["created_at"] == ts.isoformat() + assert entry["diff_id"] == str(diff_id) + assert entry["lines_added"] == 3 + assert entry["lines_removed"] == 1 + assert entry["snapshot_id"] == str(snapshot_id) + + +@pytest.mark.asyncio +async def test_empty_result_returns_empty_list(): + """get_config_history returns empty list when device has no changes.""" + from app.services.config_history_service import get_config_history + + mock_session = AsyncMock() + result_mock = MagicMock() + result_mock.fetchall.return_value = [] + mock_session.execute = AsyncMock(return_value=result_mock) + + entries = await get_config_history(str(uuid4()), str(uuid4()), mock_session) + + assert entries == [] + + +@pytest.mark.asyncio +async def test_pagination_parameters_passed(): + """get_config_history passes limit and offset to the query.""" + from app.services.config_history_service import get_config_history + + mock_session = AsyncMock() + result_mock = MagicMock() + result_mock.fetchall.return_value = [] + mock_session.execute = AsyncMock(return_value=result_mock) + + await get_config_history(str(uuid4()), str(uuid4()), mock_session, limit=10, offset=20) + + # Verify the query params include limit and offset + call_args = mock_session.execute.call_args + query_params = call_args[0][1] + assert query_params["limit"] == 10 + assert query_params["offset"] == 20 + + +@pytest.mark.asyncio +async def test_ordering_desc_by_created_at(): + """get_config_history returns entries ordered by created_at DESC.""" + from app.services.config_history_service import get_config_history + + device_id = str(uuid4()) + tenant_id = str(uuid4()) + + ts_newer = datetime(2026, 3, 12, 12, 0, 0, tzinfo=timezone.utc) + ts_older = datetime(2026, 3, 12, 10, 0, 0, tzinfo=timezone.utc) + + mock_session = AsyncMock() + result_mock = MagicMock() + # Rows returned in DESC order (newest first) as SQL would return them + result_mock.fetchall.return_value = [ + _make_change_row(uuid4(), "ip/address", "Changed IP", ts_newer, uuid4(), 1, 1, uuid4()), + _make_change_row(uuid4(), "ip/firewall", "Added rule", ts_older, uuid4(), 2, 0, uuid4()), + ] + mock_session.execute = AsyncMock(return_value=result_mock) + + entries = await get_config_history(device_id, tenant_id, mock_session) + + assert len(entries) == 2 + # SQL query contains ORDER BY ... DESC; verify the query text + call_args = mock_session.execute.call_args + query_text = str(call_args[0][0]) + assert "DESC" in query_text.upper()