kin: KIN-089 При попытке добавить креды прод сервера для проекта corelock вылетает 500 Internal Server Error
This commit is contained in:
parent
e80e50ba0c
commit
4a65d90218
13 changed files with 1215 additions and 4 deletions
304
tests/test_api_attachments.py
Normal file
304
tests/test_api_attachments.py
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
"""
|
||||
KIN-090: Integration tests for task attachment API endpoints.
|
||||
|
||||
Tests cover:
|
||||
AC1 — upload saves file to {project_path}/.kin/attachments/{task_id}/
|
||||
AC3 — file available for download via GET /api/attachments/{id}/file
|
||||
AC4 — data persists in SQLite
|
||||
Integration: upload → list → verify agent context (build_context)
|
||||
"""
|
||||
|
||||
import io
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import web.api as api_module
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path):
|
||||
"""TestClient with isolated DB and a seeded project+task.
|
||||
|
||||
Project path set to tmp_path so attachment dirs are created there
|
||||
and cleaned up automatically.
|
||||
"""
|
||||
db_path = tmp_path / "test.db"
|
||||
api_module.DB_PATH = db_path
|
||||
|
||||
from web.api import app
|
||||
c = TestClient(app)
|
||||
|
||||
project_path = str(tmp_path / "myproject")
|
||||
c.post("/api/projects", json={
|
||||
"id": "prj",
|
||||
"name": "My Project",
|
||||
"path": project_path,
|
||||
})
|
||||
c.post("/api/tasks", json={"project_id": "prj", "title": "Fix login bug"})
|
||||
return c
|
||||
|
||||
|
||||
def _png_bytes() -> bytes:
|
||||
"""Minimal valid 1x1 PNG image."""
|
||||
import base64
|
||||
# 1x1 red pixel PNG (base64-encoded)
|
||||
data = (
|
||||
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
|
||||
b"\x08\x02\x00\x00\x00\x90wS\xde\x00\x00\x00\x0cIDATx\x9cc\xf8\x0f\x00"
|
||||
b"\x00\x01\x01\x00\x05\x18\xd8N\x00\x00\x00\x00IEND\xaeB`\x82"
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Upload
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_upload_attachment_returns_201(client):
|
||||
"""KIN-090: POST /api/tasks/{id}/attachments возвращает 201 и данные вложения."""
|
||||
r = client.post(
|
||||
"/api/tasks/PRJ-001/attachments",
|
||||
files={"file": ("bug.png", io.BytesIO(_png_bytes()), "image/png")},
|
||||
)
|
||||
assert r.status_code == 201
|
||||
data = r.json()
|
||||
assert data["task_id"] == "PRJ-001"
|
||||
assert data["filename"] == "bug.png"
|
||||
assert data["mime_type"] == "image/png"
|
||||
assert data["size"] == len(_png_bytes())
|
||||
assert data["id"] is not None
|
||||
|
||||
|
||||
def test_upload_attachment_saves_file_to_correct_path(client, tmp_path):
|
||||
"""KIN-090: AC1 — файл сохраняется в {project_path}/.kin/attachments/{task_id}/."""
|
||||
r = client.post(
|
||||
"/api/tasks/PRJ-001/attachments",
|
||||
files={"file": ("shot.png", io.BytesIO(_png_bytes()), "image/png")},
|
||||
)
|
||||
assert r.status_code == 201
|
||||
saved_path = Path(r.json()["path"])
|
||||
|
||||
# Path structure: <project_path>/.kin/attachments/PRJ-001/shot.png
|
||||
assert saved_path.name == "shot.png"
|
||||
assert saved_path.parent.name == "PRJ-001"
|
||||
assert saved_path.parent.parent.name == "attachments"
|
||||
assert saved_path.parent.parent.parent.name == ".kin"
|
||||
assert saved_path.exists()
|
||||
|
||||
|
||||
def test_upload_attachment_file_content_matches(client):
|
||||
"""KIN-090: содержимое сохранённого файла совпадает с загруженным."""
|
||||
content = _png_bytes()
|
||||
r = client.post(
|
||||
"/api/tasks/PRJ-001/attachments",
|
||||
files={"file": ("img.png", io.BytesIO(content), "image/png")},
|
||||
)
|
||||
assert r.status_code == 201
|
||||
saved_path = Path(r.json()["path"])
|
||||
assert saved_path.read_bytes() == content
|
||||
|
||||
|
||||
def test_upload_attachment_persists_in_sqlite(client, tmp_path):
|
||||
"""KIN-090: AC4 — запись о вложении сохраняется в SQLite и доступна через list."""
|
||||
client.post(
|
||||
"/api/tasks/PRJ-001/attachments",
|
||||
files={"file": ("db_test.png", io.BytesIO(_png_bytes()), "image/png")},
|
||||
)
|
||||
# Verify via list endpoint (reads from DB)
|
||||
r = client.get("/api/tasks/PRJ-001/attachments")
|
||||
assert r.status_code == 200
|
||||
assert any(a["filename"] == "db_test.png" for a in r.json())
|
||||
|
||||
|
||||
def test_upload_attachment_task_not_found_returns_404(client):
|
||||
"""KIN-090: 404 если задача не существует."""
|
||||
r = client.post(
|
||||
"/api/tasks/PRJ-999/attachments",
|
||||
files={"file": ("x.png", io.BytesIO(_png_bytes()), "image/png")},
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_upload_attachment_operations_project_returns_400(client, tmp_path):
|
||||
"""KIN-090: 400 для operations-проекта (нет project path)."""
|
||||
db_path = tmp_path / "test2.db"
|
||||
api_module.DB_PATH = db_path
|
||||
|
||||
from web.api import app
|
||||
c = TestClient(app)
|
||||
c.post("/api/projects", json={
|
||||
"id": "ops",
|
||||
"name": "Ops Server",
|
||||
"project_type": "operations",
|
||||
"ssh_host": "10.0.0.1",
|
||||
})
|
||||
c.post("/api/tasks", json={"project_id": "ops", "title": "Reboot server"})
|
||||
|
||||
r = c.post(
|
||||
"/api/tasks/OPS-001/attachments",
|
||||
files={"file": ("x.png", io.BytesIO(_png_bytes()), "image/png")},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_upload_oversized_file_returns_413(client):
|
||||
"""KIN-090: 413 если файл превышает 10 MB."""
|
||||
big_content = b"x" * (10 * 1024 * 1024 + 1)
|
||||
r = client.post(
|
||||
"/api/tasks/PRJ-001/attachments",
|
||||
files={"file": ("huge.png", io.BytesIO(big_content), "image/png")},
|
||||
)
|
||||
assert r.status_code == 413
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# List
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_list_attachments_empty_for_new_task(client):
|
||||
"""KIN-090: GET /api/tasks/{id}/attachments возвращает [] для задачи без вложений."""
|
||||
r = client.get("/api/tasks/PRJ-001/attachments")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == []
|
||||
|
||||
|
||||
def test_list_attachments_returns_all_uploaded(client):
|
||||
"""KIN-090: список содержит все загруженные вложения."""
|
||||
client.post("/api/tasks/PRJ-001/attachments",
|
||||
files={"file": ("a.png", io.BytesIO(_png_bytes()), "image/png")})
|
||||
client.post("/api/tasks/PRJ-001/attachments",
|
||||
files={"file": ("b.jpg", io.BytesIO(_png_bytes()), "image/jpeg")})
|
||||
|
||||
r = client.get("/api/tasks/PRJ-001/attachments")
|
||||
assert r.status_code == 200
|
||||
filenames = {a["filename"] for a in r.json()}
|
||||
assert "a.png" in filenames
|
||||
assert "b.jpg" in filenames
|
||||
|
||||
|
||||
def test_list_attachments_task_not_found_returns_404(client):
|
||||
"""KIN-090: 404 если задача не существует."""
|
||||
r = client.get("/api/tasks/PRJ-999/attachments")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Delete
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_delete_attachment_returns_204(client):
|
||||
"""KIN-090: DELETE возвращает 204."""
|
||||
r = client.post("/api/tasks/PRJ-001/attachments",
|
||||
files={"file": ("del.png", io.BytesIO(_png_bytes()), "image/png")})
|
||||
att_id = r.json()["id"]
|
||||
|
||||
r = client.delete(f"/api/tasks/PRJ-001/attachments/{att_id}")
|
||||
assert r.status_code == 204
|
||||
|
||||
|
||||
def test_delete_attachment_removes_from_list(client):
|
||||
"""KIN-090: после удаления вложение не появляется в списке."""
|
||||
r = client.post("/api/tasks/PRJ-001/attachments",
|
||||
files={"file": ("rm.png", io.BytesIO(_png_bytes()), "image/png")})
|
||||
att_id = r.json()["id"]
|
||||
client.delete(f"/api/tasks/PRJ-001/attachments/{att_id}")
|
||||
|
||||
attachments = client.get("/api/tasks/PRJ-001/attachments").json()
|
||||
assert not any(a["id"] == att_id for a in attachments)
|
||||
|
||||
|
||||
def test_delete_attachment_removes_file_from_disk(client):
|
||||
"""KIN-090: удаление вложения удаляет файл с диска."""
|
||||
r = client.post("/api/tasks/PRJ-001/attachments",
|
||||
files={"file": ("disk.png", io.BytesIO(_png_bytes()), "image/png")})
|
||||
saved_path = Path(r.json()["path"])
|
||||
att_id = r.json()["id"]
|
||||
|
||||
assert saved_path.exists()
|
||||
client.delete(f"/api/tasks/PRJ-001/attachments/{att_id}")
|
||||
assert not saved_path.exists()
|
||||
|
||||
|
||||
def test_delete_attachment_not_found_returns_404(client):
|
||||
"""KIN-090: 404 если вложение не существует."""
|
||||
r = client.delete("/api/tasks/PRJ-001/attachments/99999")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Download
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_download_attachment_file_returns_correct_content(client):
|
||||
"""KIN-090: AC3 — GET /api/attachments/{id}/file возвращает содержимое файла."""
|
||||
content = _png_bytes()
|
||||
r = client.post("/api/tasks/PRJ-001/attachments",
|
||||
files={"file": ("get.png", io.BytesIO(content), "image/png")})
|
||||
att_id = r.json()["id"]
|
||||
|
||||
r = client.get(f"/api/attachments/{att_id}/file")
|
||||
assert r.status_code == 200
|
||||
assert r.content == content
|
||||
|
||||
|
||||
def test_download_attachment_file_returns_correct_content_type(client):
|
||||
"""KIN-090: AC3 — Content-Type соответствует mime_type вложения."""
|
||||
r = client.post("/api/tasks/PRJ-001/attachments",
|
||||
files={"file": ("ct.png", io.BytesIO(_png_bytes()), "image/png")})
|
||||
att_id = r.json()["id"]
|
||||
|
||||
r = client.get(f"/api/attachments/{att_id}/file")
|
||||
assert r.status_code == 200
|
||||
assert "image/png" in r.headers["content-type"]
|
||||
|
||||
|
||||
def test_download_attachment_not_found_returns_404(client):
|
||||
"""KIN-090: 404 если вложение не существует."""
|
||||
r = client.get("/api/attachments/99999/file")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration: upload → list → agent context (AC2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_integration_upload_list_agent_context(client, tmp_path):
|
||||
"""KIN-090: Интеграционный тест: upload → list → build_context включает вложения.
|
||||
|
||||
Проверяет AC1 (путь), AC3 (доступен для скачивания), AC4 (SQLite),
|
||||
и AC2 (агенты получают вложения через build_context).
|
||||
"""
|
||||
# Step 1: Upload image
|
||||
content = _png_bytes()
|
||||
r = client.post("/api/tasks/PRJ-001/attachments",
|
||||
files={"file": ("integration.png", io.BytesIO(content), "image/png")})
|
||||
assert r.status_code == 201
|
||||
att = r.json()
|
||||
|
||||
# Step 2: AC1 — file is at correct path inside project
|
||||
saved_path = Path(att["path"])
|
||||
assert saved_path.exists()
|
||||
assert "PRJ-001" in str(saved_path)
|
||||
assert ".kin/attachments" in str(saved_path)
|
||||
|
||||
# Step 3: List confirms persistence (AC4)
|
||||
r = client.get("/api/tasks/PRJ-001/attachments")
|
||||
assert r.status_code == 200
|
||||
assert len(r.json()) == 1
|
||||
|
||||
# Step 4: Download works (AC3)
|
||||
r = client.get(f"/api/attachments/{att['id']}/file")
|
||||
assert r.status_code == 200
|
||||
assert r.content == content
|
||||
|
||||
# Step 5: AC2 — agent context includes attachment path
|
||||
from core.db import init_db
|
||||
from core.context_builder import build_context
|
||||
conn = init_db(api_module.DB_PATH)
|
||||
ctx = build_context(conn, "PRJ-001", "debugger", "prj")
|
||||
conn.close()
|
||||
|
||||
assert "attachments" in ctx
|
||||
paths = [a["path"] for a in ctx["attachments"]]
|
||||
assert att["path"] in paths
|
||||
|
|
@ -406,3 +406,70 @@ class TestPMRoutingOperations:
|
|||
ctx = build_context(ops_conn, "SRV-001", "pm", "srv")
|
||||
prompt = format_prompt(ctx, "pm", "You are PM.")
|
||||
assert "Project type: operations" in prompt
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# KIN-090: Attachments — context builder includes attachment paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAttachmentsInContext:
|
||||
"""KIN-090: AC2 — агенты получают пути к вложениям в контексте задачи."""
|
||||
|
||||
@pytest.fixture
|
||||
def conn_with_attachments(self):
|
||||
c = init_db(":memory:")
|
||||
models.create_project(c, "prj", "Project", "/tmp/prj")
|
||||
models.create_task(c, "PRJ-001", "prj", "Fix bug")
|
||||
models.create_attachment(
|
||||
c, "PRJ-001", "screenshot.png",
|
||||
"/tmp/prj/.kin/attachments/PRJ-001/screenshot.png",
|
||||
"image/png", 1024,
|
||||
)
|
||||
models.create_attachment(
|
||||
c, "PRJ-001", "mockup.jpg",
|
||||
"/tmp/prj/.kin/attachments/PRJ-001/mockup.jpg",
|
||||
"image/jpeg", 2048,
|
||||
)
|
||||
yield c
|
||||
c.close()
|
||||
|
||||
def test_build_context_includes_attachments(self, conn_with_attachments):
|
||||
"""KIN-090: AC2 — build_context включает вложения в контекст для всех ролей."""
|
||||
ctx = build_context(conn_with_attachments, "PRJ-001", "debugger", "prj")
|
||||
assert "attachments" in ctx
|
||||
assert len(ctx["attachments"]) == 2
|
||||
|
||||
def test_build_context_attachments_have_filename_and_path(self, conn_with_attachments):
|
||||
"""KIN-090: вложения в контексте содержат filename и path."""
|
||||
ctx = build_context(conn_with_attachments, "PRJ-001", "debugger", "prj")
|
||||
filenames = {a["filename"] for a in ctx["attachments"]}
|
||||
paths = {a["path"] for a in ctx["attachments"]}
|
||||
assert "screenshot.png" in filenames
|
||||
assert "mockup.jpg" in filenames
|
||||
assert "/tmp/prj/.kin/attachments/PRJ-001/screenshot.png" in paths
|
||||
|
||||
def test_build_context_no_attachments_key_when_empty(self, conn):
|
||||
"""KIN-090: ключ 'attachments' отсутствует в контексте, если вложений нет."""
|
||||
# conn fixture has no attachments
|
||||
ctx = build_context(conn, "VDOL-001", "debugger", "vdol")
|
||||
assert "attachments" not in ctx
|
||||
|
||||
def test_all_roles_get_attachments(self, conn_with_attachments):
|
||||
"""KIN-090: AC2 — все роли (debugger, pm, tester, reviewer) получают вложения."""
|
||||
for role in ("debugger", "pm", "tester", "reviewer", "backend_dev", "frontend_dev"):
|
||||
ctx = build_context(conn_with_attachments, "PRJ-001", role, "prj")
|
||||
assert "attachments" in ctx, f"Role '{role}' did not receive attachments"
|
||||
|
||||
def test_format_prompt_includes_attachments_section(self, conn_with_attachments):
|
||||
"""KIN-090: format_prompt включает секцию '## Attachments' с именами и путями."""
|
||||
ctx = build_context(conn_with_attachments, "PRJ-001", "debugger", "prj")
|
||||
prompt = format_prompt(ctx, "debugger", "You are a debugger.")
|
||||
assert "## Attachments" in prompt
|
||||
assert "screenshot.png" in prompt
|
||||
assert "/tmp/prj/.kin/attachments/PRJ-001/screenshot.png" in prompt
|
||||
|
||||
def test_format_prompt_no_attachments_section_when_none(self, conn):
|
||||
"""KIN-090: format_prompt не добавляет секцию вложений, если их нет."""
|
||||
ctx = build_context(conn, "VDOL-001", "debugger", "vdol")
|
||||
prompt = format_prompt(ctx, "debugger", "Debug this.")
|
||||
assert "## Attachments" not in prompt
|
||||
|
|
|
|||
377
tests/test_kin_089_regression.py
Normal file
377
tests/test_kin_089_regression.py
Normal file
|
|
@ -0,0 +1,377 @@
|
|||
"""Regression tests for KIN-089: 500 Internal Server Error when adding credentials.
|
||||
|
||||
Root cause: DB schema had label/login/credential columns; code expected name/username/auth_value.
|
||||
Fix: Migration in core/db.py (_migrate) renames columns label→name, login→username, credential→auth_value.
|
||||
|
||||
Acceptance criteria:
|
||||
1. Credentials can be added without error (status 201, not 500)
|
||||
2. Credentials are stored in DB (encrypted)
|
||||
3. Sysadmin task brief contains environment fields for inventory
|
||||
"""
|
||||
import sqlite3
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from core.db import init_db, _migrate
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _cols(conn: sqlite3.Connection, table: str) -> set[str]:
|
||||
return {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()}
|
||||
|
||||
|
||||
def _conn_with_old_env_schema() -> sqlite3.Connection:
|
||||
"""Creates in-memory DB with OLD project_environments schema (label/login/credential)."""
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.executescript("""
|
||||
CREATE TABLE projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT,
|
||||
status TEXT DEFAULT 'active',
|
||||
language TEXT DEFAULT 'ru',
|
||||
execution_mode TEXT NOT NULL DEFAULT 'review'
|
||||
);
|
||||
CREATE TABLE tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'pending',
|
||||
execution_mode TEXT
|
||||
);
|
||||
CREATE TABLE project_environments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
label TEXT NOT NULL,
|
||||
host TEXT NOT NULL,
|
||||
port INTEGER DEFAULT 22,
|
||||
login TEXT NOT NULL,
|
||||
auth_type TEXT NOT NULL DEFAULT 'password',
|
||||
credential TEXT,
|
||||
is_installed INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(project_id, label)
|
||||
);
|
||||
INSERT INTO projects VALUES ('corelock', 'Corelock', '/corelock', 'active', 'ru', 'review');
|
||||
INSERT INTO project_environments
|
||||
(project_id, label, host, port, login, auth_type, credential, is_installed)
|
||||
VALUES ('corelock', 'prod', '10.5.1.254', 22, 'pelmen', 'password', 'b64:c2VjcmV0', 0);
|
||||
""")
|
||||
conn.commit()
|
||||
return conn
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Migration: label/login/credential → name/username/auth_value
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestKin089Migration:
|
||||
"""Regression: _migrate renames env columns from old schema to new schema."""
|
||||
|
||||
def test_migration_renames_label_to_name(self):
|
||||
conn = _conn_with_old_env_schema()
|
||||
_migrate(conn)
|
||||
cols = _cols(conn, "project_environments")
|
||||
assert "name" in cols, "After migration, 'name' column must exist"
|
||||
assert "label" not in cols, "After migration, 'label' column must not exist"
|
||||
conn.close()
|
||||
|
||||
def test_migration_renames_login_to_username(self):
|
||||
conn = _conn_with_old_env_schema()
|
||||
_migrate(conn)
|
||||
cols = _cols(conn, "project_environments")
|
||||
assert "username" in cols, "After migration, 'username' column must exist"
|
||||
assert "login" not in cols, "After migration, 'login' column must not exist"
|
||||
conn.close()
|
||||
|
||||
def test_migration_renames_credential_to_auth_value(self):
|
||||
conn = _conn_with_old_env_schema()
|
||||
_migrate(conn)
|
||||
cols = _cols(conn, "project_environments")
|
||||
assert "auth_value" in cols, "After migration, 'auth_value' column must exist"
|
||||
assert "credential" not in cols, "After migration, 'credential' column must not exist"
|
||||
conn.close()
|
||||
|
||||
def test_migration_preserves_existing_data(self):
|
||||
"""After migration, existing env rows must be accessible with new column names."""
|
||||
conn = _conn_with_old_env_schema()
|
||||
_migrate(conn)
|
||||
row = conn.execute(
|
||||
"SELECT name, username, auth_value FROM project_environments WHERE project_id = 'corelock'"
|
||||
).fetchone()
|
||||
assert row is not None, "Existing row must survive migration"
|
||||
assert row["name"] == "prod"
|
||||
assert row["username"] == "pelmen"
|
||||
assert row["auth_value"] == "b64:c2VjcmV0"
|
||||
conn.close()
|
||||
|
||||
def test_migration_is_idempotent_on_new_schema(self):
|
||||
"""Calling _migrate on a DB that already has new schema must not fail."""
|
||||
conn = init_db(":memory:")
|
||||
before = _cols(conn, "project_environments")
|
||||
_migrate(conn)
|
||||
after = _cols(conn, "project_environments")
|
||||
assert before == after, "_migrate must not alter schema when new columns already exist"
|
||||
conn.close()
|
||||
|
||||
def test_migration_preserves_unique_constraint(self):
|
||||
"""After migration, UNIQUE(project_id, name) constraint must still work."""
|
||||
conn = _conn_with_old_env_schema()
|
||||
_migrate(conn)
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
conn.execute(
|
||||
"INSERT INTO project_environments (project_id, name, host, username) "
|
||||
"VALUES ('corelock', 'prod', '1.2.3.4', 'root')"
|
||||
)
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoint regression: POST /environments must return 201, not 500
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path):
|
||||
import web.api as api_module
|
||||
api_module.DB_PATH = tmp_path / "test.db"
|
||||
from web.api import app
|
||||
from fastapi.testclient import TestClient
|
||||
c = TestClient(app)
|
||||
c.post("/api/projects", json={"id": "corelock", "name": "Corelock", "path": "/corelock"})
|
||||
return c
|
||||
|
||||
|
||||
def test_create_environment_returns_201_not_500(client):
|
||||
"""Regression KIN-089: POST /environments must not return 500."""
|
||||
r = client.post("/api/projects/corelock/environments", json={
|
||||
"name": "prod",
|
||||
"host": "10.5.1.254",
|
||||
"username": "pelmen",
|
||||
"port": 22,
|
||||
"auth_type": "password",
|
||||
"auth_value": "s3cr3t",
|
||||
"is_installed": False,
|
||||
})
|
||||
assert r.status_code == 201, f"Expected 201, got {r.status_code}: {r.text}"
|
||||
|
||||
|
||||
def test_create_environment_missing_kin_secret_key_returns_503(tmp_path):
|
||||
"""When KIN_SECRET_KEY is not set, POST /environments must return 503, not 500.
|
||||
|
||||
503 = server misconfiguration (operator error), not 500 (code bug).
|
||||
"""
|
||||
import os
|
||||
import web.api as api_module
|
||||
api_module.DB_PATH = tmp_path / "test503.db"
|
||||
from web.api import app
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
env_without_key = {k: v for k, v in os.environ.items() if k != "KIN_SECRET_KEY"}
|
||||
with patch.dict(os.environ, env_without_key, clear=True):
|
||||
c = TestClient(app)
|
||||
c.post("/api/projects", json={"id": "corelock", "name": "Corelock", "path": "/corelock"})
|
||||
r = c.post("/api/projects/corelock/environments", json={
|
||||
"name": "prod",
|
||||
"host": "10.5.1.254",
|
||||
"username": "pelmen",
|
||||
"auth_value": "secret",
|
||||
})
|
||||
assert r.status_code == 503, (
|
||||
f"Missing KIN_SECRET_KEY must return 503 (not 500 or other), got {r.status_code}: {r.text}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC: Credentials stored in DB
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_create_environment_auth_value_encrypted_in_db(client):
|
||||
"""AC: auth_value is stored encrypted in DB, not plain text."""
|
||||
import web.api as api_module
|
||||
from core.db import init_db
|
||||
from core import models as m
|
||||
|
||||
r = client.post("/api/projects/corelock/environments", json={
|
||||
"name": "db-creds-test",
|
||||
"host": "10.5.1.254",
|
||||
"username": "pelmen",
|
||||
"auth_value": "supersecret",
|
||||
})
|
||||
assert r.status_code == 201
|
||||
env_id = r.json()["id"]
|
||||
|
||||
conn = init_db(api_module.DB_PATH)
|
||||
row = conn.execute(
|
||||
"SELECT auth_value FROM project_environments WHERE id = ?", (env_id,)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
|
||||
assert row["auth_value"] is not None, "auth_value must be stored in DB"
|
||||
assert row["auth_value"] != "supersecret", "auth_value must NOT be stored as plain text"
|
||||
|
||||
|
||||
def test_create_environment_auth_value_hidden_in_response(client):
|
||||
"""AC: auth_value is never returned in API response."""
|
||||
r = client.post("/api/projects/corelock/environments", json={
|
||||
"name": "hidden-creds",
|
||||
"host": "10.5.1.254",
|
||||
"username": "pelmen",
|
||||
"auth_value": "supersecret",
|
||||
})
|
||||
assert r.status_code == 201
|
||||
assert r.json().get("auth_value") is None, "auth_value must be None in response"
|
||||
|
||||
|
||||
def test_create_environment_stored_credential_is_decryptable(client):
|
||||
"""AC: Stored credential can be decrypted back to original value."""
|
||||
import web.api as api_module
|
||||
from core.db import init_db
|
||||
from core import models as m
|
||||
|
||||
r = client.post("/api/projects/corelock/environments", json={
|
||||
"name": "decrypt-test",
|
||||
"host": "10.5.1.254",
|
||||
"username": "pelmen",
|
||||
"auth_value": "mypassword123",
|
||||
})
|
||||
assert r.status_code == 201
|
||||
env_id = r.json()["id"]
|
||||
|
||||
conn = init_db(api_module.DB_PATH)
|
||||
row = conn.execute(
|
||||
"SELECT auth_value FROM project_environments WHERE id = ?", (env_id,)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
|
||||
decrypted = m._decrypt_auth(row["auth_value"])
|
||||
assert decrypted == "mypassword123", "Stored credential must decrypt to original value"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC: Sysadmin sees environment fields in context for inventory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_sysadmin_task_created_with_env_fields_in_brief(client):
|
||||
"""AC: When is_installed=True, sysadmin task brief contains host and username."""
|
||||
import web.api as api_module
|
||||
from core.db import init_db
|
||||
from core import models as m
|
||||
|
||||
with patch("subprocess.Popen") as mock_popen:
|
||||
mock_popen.return_value = MagicMock(pid=12345)
|
||||
r = client.post("/api/projects/corelock/environments", json={
|
||||
"name": "prod-scan",
|
||||
"host": "10.5.1.254",
|
||||
"username": "pelmen",
|
||||
"is_installed": True,
|
||||
})
|
||||
|
||||
assert r.status_code == 201
|
||||
assert "scan_task_id" in r.json(), "scan_task_id must be returned when is_installed=True"
|
||||
task_id = r.json()["scan_task_id"]
|
||||
|
||||
conn = init_db(api_module.DB_PATH)
|
||||
task = m.get_task(conn, task_id)
|
||||
conn.close()
|
||||
|
||||
assert task is not None, "Sysadmin task must be created in DB"
|
||||
assert task["assigned_role"] == "sysadmin"
|
||||
assert task["category"] == "INFRA"
|
||||
|
||||
brief = task["brief"]
|
||||
brief_str = str(brief)
|
||||
assert "10.5.1.254" in brief_str, "Sysadmin brief must contain host for inventory"
|
||||
assert "pelmen" in brief_str, "Sysadmin brief must contain username for inventory"
|
||||
|
||||
|
||||
def test_sysadmin_task_brief_is_dict_not_string(client):
|
||||
"""Sysadmin task brief must be a structured dict (not raw string) for agent parsing."""
|
||||
import web.api as api_module
|
||||
from core.db import init_db
|
||||
from core import models as m
|
||||
|
||||
with patch("subprocess.Popen") as mock_popen:
|
||||
mock_popen.return_value = MagicMock(pid=99999)
|
||||
r = client.post("/api/projects/corelock/environments", json={
|
||||
"name": "brief-type-test",
|
||||
"host": "10.5.1.1",
|
||||
"username": "root",
|
||||
"is_installed": True,
|
||||
})
|
||||
|
||||
task_id = r.json()["scan_task_id"]
|
||||
conn = init_db(api_module.DB_PATH)
|
||||
task = m.get_task(conn, task_id)
|
||||
conn.close()
|
||||
|
||||
assert isinstance(task["brief"], dict), (
|
||||
f"Sysadmin task brief must be a dict, got {type(task['brief'])}"
|
||||
)
|
||||
|
||||
|
||||
def test_post_migration_create_environment_works(tmp_path):
|
||||
"""AC: After DB migration from old schema, create_environment works end-to-end."""
|
||||
import web.api as api_module
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
# Set up DB with old schema using a file-based DB (to test init_db migration path)
|
||||
old_db_path = tmp_path / "old.db"
|
||||
conn = sqlite3.connect(str(old_db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.executescript("""
|
||||
CREATE TABLE projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT,
|
||||
status TEXT DEFAULT 'active',
|
||||
language TEXT DEFAULT 'ru',
|
||||
execution_mode TEXT NOT NULL DEFAULT 'review'
|
||||
);
|
||||
CREATE TABLE tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'pending',
|
||||
execution_mode TEXT
|
||||
);
|
||||
CREATE TABLE project_environments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
label TEXT NOT NULL,
|
||||
host TEXT NOT NULL,
|
||||
port INTEGER DEFAULT 22,
|
||||
login TEXT NOT NULL,
|
||||
auth_type TEXT NOT NULL DEFAULT 'password',
|
||||
credential TEXT,
|
||||
is_installed INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(project_id, label)
|
||||
);
|
||||
INSERT INTO projects VALUES ('corelock', 'Corelock', '/corelock', 'active', 'ru', 'review');
|
||||
""")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# Switch API to use the old DB — init_db will run _migrate on it
|
||||
api_module.DB_PATH = old_db_path
|
||||
from web.api import app
|
||||
c = TestClient(app)
|
||||
|
||||
# Trigger init_db migration by making a request
|
||||
r = c.post("/api/projects/corelock/environments", json={
|
||||
"name": "prod",
|
||||
"host": "10.5.1.254",
|
||||
"username": "pelmen",
|
||||
"auth_value": "topsecret",
|
||||
})
|
||||
assert r.status_code == 201, (
|
||||
f"After migration from old schema, create_environment must return 201, got {r.status_code}: {r.text}"
|
||||
)
|
||||
assert r.json()["name"] == "prod"
|
||||
assert r.json()["username"] == "pelmen"
|
||||
|
|
@ -646,3 +646,91 @@ def test_obsidian_vault_path_can_be_set(conn):
|
|||
models.create_project(conn, "p1", "P1", "/p1")
|
||||
updated = models.update_project(conn, "p1", obsidian_vault_path="/vault/my-notes")
|
||||
assert updated["obsidian_vault_path"] == "/vault/my-notes"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# KIN-090: Task Attachments
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def task_conn(conn):
|
||||
"""conn with seeded project and task for attachment tests."""
|
||||
models.create_project(conn, "prj", "Project", "/tmp/prj")
|
||||
models.create_task(conn, "PRJ-001", "prj", "Fix bug")
|
||||
return conn
|
||||
|
||||
|
||||
def test_create_attachment_returns_dict(task_conn):
|
||||
"""KIN-090: create_attachment возвращает dict со всеми полями."""
|
||||
att = models.create_attachment(
|
||||
task_conn, "PRJ-001", "screenshot.png",
|
||||
"/tmp/prj/.kin/attachments/PRJ-001/screenshot.png",
|
||||
"image/png", 1024,
|
||||
)
|
||||
assert att["id"] is not None
|
||||
assert att["task_id"] == "PRJ-001"
|
||||
assert att["filename"] == "screenshot.png"
|
||||
assert att["path"] == "/tmp/prj/.kin/attachments/PRJ-001/screenshot.png"
|
||||
assert att["mime_type"] == "image/png"
|
||||
assert att["size"] == 1024
|
||||
assert att["created_at"] is not None
|
||||
|
||||
|
||||
def test_create_attachment_persists_in_sqlite(task_conn):
|
||||
"""KIN-090: AC4 — данные вложения персистируются в SQLite."""
|
||||
att = models.create_attachment(
|
||||
task_conn, "PRJ-001", "bug.png",
|
||||
"/tmp/prj/.kin/attachments/PRJ-001/bug.png",
|
||||
"image/png", 512,
|
||||
)
|
||||
fetched = models.get_attachment(task_conn, att["id"])
|
||||
assert fetched is not None
|
||||
assert fetched["filename"] == "bug.png"
|
||||
assert fetched["size"] == 512
|
||||
|
||||
|
||||
def test_list_attachments_empty_for_new_task(task_conn):
|
||||
"""KIN-090: list_attachments возвращает [] для задачи без вложений."""
|
||||
result = models.list_attachments(task_conn, "PRJ-001")
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_list_attachments_returns_all_for_task(task_conn):
|
||||
"""KIN-090: list_attachments возвращает все вложения задачи."""
|
||||
models.create_attachment(task_conn, "PRJ-001", "a.png",
|
||||
"/tmp/prj/.kin/attachments/PRJ-001/a.png", "image/png", 100)
|
||||
models.create_attachment(task_conn, "PRJ-001", "b.jpg",
|
||||
"/tmp/prj/.kin/attachments/PRJ-001/b.jpg", "image/jpeg", 200)
|
||||
result = models.list_attachments(task_conn, "PRJ-001")
|
||||
assert len(result) == 2
|
||||
filenames = {a["filename"] for a in result}
|
||||
assert filenames == {"a.png", "b.jpg"}
|
||||
|
||||
|
||||
def test_list_attachments_isolated_by_task(task_conn):
|
||||
"""KIN-090: list_attachments не возвращает вложения других задач."""
|
||||
models.create_task(task_conn, "PRJ-002", "prj", "Other task")
|
||||
models.create_attachment(task_conn, "PRJ-001", "a.png",
|
||||
"/tmp/.kin/PRJ-001/a.png", "image/png", 100)
|
||||
models.create_attachment(task_conn, "PRJ-002", "b.png",
|
||||
"/tmp/.kin/PRJ-002/b.png", "image/png", 100)
|
||||
assert len(models.list_attachments(task_conn, "PRJ-001")) == 1
|
||||
assert len(models.list_attachments(task_conn, "PRJ-002")) == 1
|
||||
|
||||
|
||||
def test_get_attachment_not_found_returns_none(task_conn):
|
||||
"""KIN-090: get_attachment возвращает None если вложение не найдено."""
|
||||
assert models.get_attachment(task_conn, 99999) is None
|
||||
|
||||
|
||||
def test_delete_attachment_returns_true(task_conn):
|
||||
"""KIN-090: delete_attachment возвращает True при успешном удалении."""
|
||||
att = models.create_attachment(task_conn, "PRJ-001", "del.png",
|
||||
"/tmp/del.png", "image/png", 50)
|
||||
assert models.delete_attachment(task_conn, att["id"]) is True
|
||||
assert models.get_attachment(task_conn, att["id"]) is None
|
||||
|
||||
|
||||
def test_delete_attachment_not_found_returns_false(task_conn):
|
||||
"""KIN-090: delete_attachment возвращает False если запись не найдена."""
|
||||
assert models.delete_attachment(task_conn, 99999) is False
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue