Destructive half of BEHAVE-INTEGRATION.md Phase 1. SessionProfile + its kd_* columns + the dialect ALTER TABLE migration helpers are deleted outright; pre-v1, the table shipped empty, no migration ceremony required (per the no-new-_migrate_-pre-v1 memory rule). DEBT-036 closes via DEBT-050 supersedure. AttackerDetail's ``observations`` field is wired to the new ``observations`` table and returns an empty list until the BEHAVE-SHELL extractor (DEBT-050 Phase 2) starts emitting. decnet/web/db/models/attackers.py — SessionProfile class deleted (~135 lines), KD_PAUSE_*/KD_START_OF_ACTION_IDLE_S module constants deleted, module docstring updated to point at the observations table. AttackerIdentity.kd_digraph_simhash is KEPT — it's the v2 federation centroid hook, not a SessionProfile field; docstring repointed to the BEHAVE primitive that will populate it. decnet/web/db/sqlmodel_repo/attackers/sessions.py — DELETED. SessionProfilesMixin dropped from the AttackersMixin MRO. decnet/web/db/repository.py — abstract upsert_session_profile + get_session_profile removed. decnet/web/db/sqlite/repository.py + mysql/repository.py — _migrate_session_profile_table helpers and their initialize() calls removed. mysql initialize() now goes attackers → column_types → admin (no session_profile step). decnet/web/db/models/__init__.py — SessionProfile re-export gone. decnet/web/db/models/attacker_intel.py — docstring cross-reference to SessionProfile.schema_version retargeted to AttackerIdentity. decnet/web/router/attackers/api_get_attacker_detail.py — adds ``observations: []`` to the response by calling ``repo.latest_observation_per_primitive(uuid)`` and projecting to a list sorted by primitive path. Empty until the extractor lands; shape matches BEHAVE-INTEGRATION.md §"AttackerDetail consumer". tests/profiler/test_session_profile.py — DELETED (56 lines). tests/db/test_base_repo.py — DummyRepo loses upsert_session_profile and get_session_profile overrides. tests/db/mysql/test_mysql_migration.py — initialize-call-order assertion updated; session_profile step removed from the expected sequence; docstring records why. tests/ttp/test_lifter_absence.py — docstring "no SessionProfile" → "no ObservationRow".
234 lines
7.9 KiB
Python
234 lines
7.9 KiB
Python
"""
|
|
Tests for MySQLRepository._migrate_column_types().
|
|
|
|
No live MySQL server required — uses an in-memory SQLite engine that exposes
|
|
the same information_schema-style query surface via a mocked connection, plus
|
|
an integration-style test using a real async engine over aiosqlite (which
|
|
ignores the TEXT/MEDIUMTEXT distinction but verifies the ALTER path is called
|
|
and idempotent).
|
|
|
|
The ALTER TABLE branch is tested via unittest.mock: we intercept the
|
|
information_schema query result and assert the correct MODIFY COLUMN
|
|
statements are issued.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from unittest.mock import AsyncMock, MagicMock, patch, call
|
|
|
|
from decnet.web.db.mysql.repository import MySQLRepository
|
|
|
|
|
|
# ── helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
def _make_repo() -> MySQLRepository:
|
|
"""Construct a MySQLRepository without touching any real DB."""
|
|
return MySQLRepository.__new__(MySQLRepository)
|
|
|
|
|
|
# ── _migrate_column_types ─────────────────────────────────────────────────────
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_migrate_column_types_issues_alter_for_text_columns():
|
|
"""When information_schema reports TEXT columns, ALTER TABLE is called for each."""
|
|
repo = _make_repo()
|
|
|
|
# Rows returned by the information_schema query: two TEXT columns found
|
|
fake_rows = [
|
|
("attackers", "commands"),
|
|
("attackers", "fingerprints"),
|
|
("state", "value"),
|
|
]
|
|
|
|
exec_results: list[str] = []
|
|
|
|
async def fake_execute(stmt):
|
|
sql = str(stmt)
|
|
if "information_schema" in sql:
|
|
result = MagicMock()
|
|
result.fetchall.return_value = fake_rows
|
|
return result
|
|
# Capture ALTER TABLE calls
|
|
exec_results.append(sql)
|
|
return MagicMock()
|
|
|
|
fake_conn = AsyncMock()
|
|
fake_conn.execute.side_effect = fake_execute
|
|
|
|
fake_ctx = AsyncMock()
|
|
fake_ctx.__aenter__ = AsyncMock(return_value=fake_conn)
|
|
fake_ctx.__aexit__ = AsyncMock(return_value=False)
|
|
|
|
repo.engine = MagicMock()
|
|
repo.engine.begin.return_value = fake_ctx
|
|
|
|
await repo._migrate_column_types()
|
|
|
|
# Three ALTER TABLE statements expected, one per TEXT column returned
|
|
assert len(exec_results) == 3
|
|
assert any("`commands` MEDIUMTEXT" in s for s in exec_results)
|
|
assert any("`fingerprints` MEDIUMTEXT" in s for s in exec_results)
|
|
assert any("`value` MEDIUMTEXT" in s for s in exec_results)
|
|
# Verify NOT NULL is preserved
|
|
assert all("NOT NULL" in s for s in exec_results)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_migrate_column_types_no_alter_when_already_mediumtext():
|
|
"""When information_schema returns no TEXT rows, no ALTER is issued."""
|
|
repo = _make_repo()
|
|
|
|
exec_results: list[str] = []
|
|
|
|
async def fake_execute(stmt):
|
|
sql = str(stmt)
|
|
if "information_schema" in sql:
|
|
result = MagicMock()
|
|
result.fetchall.return_value = [] # nothing to migrate
|
|
return result
|
|
exec_results.append(sql)
|
|
return MagicMock()
|
|
|
|
fake_conn = AsyncMock()
|
|
fake_conn.execute.side_effect = fake_execute
|
|
|
|
fake_ctx = AsyncMock()
|
|
fake_ctx.__aenter__ = AsyncMock(return_value=fake_conn)
|
|
fake_ctx.__aexit__ = AsyncMock(return_value=False)
|
|
|
|
repo.engine = MagicMock()
|
|
repo.engine.begin.return_value = fake_ctx
|
|
|
|
await repo._migrate_column_types()
|
|
|
|
assert exec_results == [], "No ALTER TABLE should be issued when columns are already MEDIUMTEXT"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_migrate_column_types_idempotent_on_repeated_calls():
|
|
"""Calling _migrate_column_types twice is safe: second call is a no-op."""
|
|
repo = _make_repo()
|
|
call_count = 0
|
|
|
|
async def fake_execute(stmt):
|
|
nonlocal call_count
|
|
sql = str(stmt)
|
|
if "information_schema" in sql:
|
|
result = MagicMock()
|
|
# First call: two TEXT columns; second call: zero (already migrated)
|
|
call_count += 1
|
|
result.fetchall.return_value = (
|
|
[("attackers", "commands")] if call_count == 1 else []
|
|
)
|
|
return result
|
|
return MagicMock()
|
|
|
|
def _make_ctx():
|
|
fake_conn = AsyncMock()
|
|
fake_conn.execute.side_effect = fake_execute
|
|
ctx = AsyncMock()
|
|
ctx.__aenter__ = AsyncMock(return_value=fake_conn)
|
|
ctx.__aexit__ = AsyncMock(return_value=False)
|
|
return ctx
|
|
|
|
repo.engine = MagicMock()
|
|
repo.engine.begin.side_effect = _make_ctx
|
|
|
|
await repo._migrate_column_types()
|
|
await repo._migrate_column_types() # second call must not raise
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_migrate_column_types_default_clause_per_column():
|
|
"""Each attacker column gets DEFAULT '[]'; state.value gets no DEFAULT."""
|
|
repo = _make_repo()
|
|
|
|
all_text_rows = [
|
|
("attackers", "commands"),
|
|
("attackers", "fingerprints"),
|
|
("attackers", "services"),
|
|
("attackers", "deckies"),
|
|
("state", "value"),
|
|
]
|
|
alter_stmts: list[str] = []
|
|
|
|
async def fake_execute(stmt):
|
|
sql = str(stmt)
|
|
if "information_schema" in sql:
|
|
result = MagicMock()
|
|
result.fetchall.return_value = all_text_rows
|
|
return result
|
|
alter_stmts.append(sql)
|
|
return MagicMock()
|
|
|
|
fake_conn = AsyncMock()
|
|
fake_conn.execute.side_effect = fake_execute
|
|
|
|
fake_ctx = AsyncMock()
|
|
fake_ctx.__aenter__ = AsyncMock(return_value=fake_conn)
|
|
fake_ctx.__aexit__ = AsyncMock(return_value=False)
|
|
|
|
repo.engine = MagicMock()
|
|
repo.engine.begin.return_value = fake_ctx
|
|
|
|
await repo._migrate_column_types()
|
|
|
|
attacker_alters = [s for s in alter_stmts if "`attackers`" in s]
|
|
state_alters = [s for s in alter_stmts if "`state`" in s]
|
|
|
|
assert len(attacker_alters) == 4
|
|
assert len(state_alters) == 1
|
|
|
|
for stmt in attacker_alters:
|
|
assert "DEFAULT '[]'" in stmt, f"Missing DEFAULT '[]' in: {stmt}"
|
|
|
|
# state.value has no DEFAULT in the schema
|
|
assert "DEFAULT" not in state_alters[0], \
|
|
f"Unexpected DEFAULT in state.value alter: {state_alters[0]}"
|
|
|
|
|
|
# ── initialize override ───────────────────────────────────────────────────────
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mysql_initialize_calls_migrate_column_types():
|
|
"""MySQLRepository.initialize() must invoke every migration helper
|
|
in the right order: attackers first, then column types, then seed
|
|
the admin user.
|
|
|
|
The legacy ``_migrate_session_profile_table`` step (DEBT-036) was
|
|
dropped when SessionProfile was deleted in favour of the
|
|
``observations`` table — see DEBT-050 / BEHAVE-INTEGRATION.md."""
|
|
repo = _make_repo()
|
|
|
|
call_order: list[str] = []
|
|
|
|
async def fake_migrate_attackers():
|
|
call_order.append("migrate_attackers")
|
|
|
|
async def fake_migrate_column_types():
|
|
call_order.append("migrate_column_types")
|
|
|
|
async def fake_ensure_admin():
|
|
call_order.append("ensure_admin")
|
|
|
|
repo._migrate_attackers_table = fake_migrate_attackers
|
|
repo._migrate_column_types = fake_migrate_column_types
|
|
repo._ensure_admin_user = fake_ensure_admin
|
|
|
|
# Stub engine.begin() so create_all is a no-op
|
|
fake_conn = AsyncMock()
|
|
fake_conn.run_sync = AsyncMock()
|
|
fake_ctx = AsyncMock()
|
|
fake_ctx.__aenter__ = AsyncMock(return_value=fake_conn)
|
|
fake_ctx.__aexit__ = AsyncMock(return_value=False)
|
|
repo.engine = MagicMock()
|
|
repo.engine.begin.return_value = fake_ctx
|
|
|
|
await repo.initialize()
|
|
|
|
assert call_order == [
|
|
"migrate_attackers",
|
|
"migrate_column_types",
|
|
"ensure_admin",
|
|
], f"Unexpected call order: {call_order}"
|