diff --git a/core/context_builder.py b/core/context_builder.py index 76d13d4..41d478e 100644 --- a/core/context_builder.py +++ b/core/context_builder.py @@ -41,6 +41,11 @@ def build_context( "role": role, } + # Attachments — all roles get them so debugger sees screenshots, UX sees mockups, etc. + attachments = models.list_attachments(conn, task_id) + if attachments: + ctx["attachments"] = attachments + # If task has a revise comment, fetch the last agent output for context if task and task.get("revise_comment"): row = conn.execute( @@ -269,6 +274,14 @@ def format_prompt(context: dict, role: str, prompt_template: str | None = None) sections.append(last_output) sections.append("") + # Attachments + attachments = context.get("attachments") + if attachments: + sections.append(f"## Attachments ({len(attachments)}):") + for a in attachments: + sections.append(f"- {a['filename']}: {a['path']}") + sections.append("") + # Previous step output (pipeline chaining) prev = context.get("previous_output") if prev: diff --git a/core/db.py b/core/db.py index b190e97..8cd78e5 100644 --- a/core/db.py +++ b/core/db.py @@ -397,6 +397,37 @@ def _migrate(conn: sqlite3.Connection): """) conn.commit() + # Migrate project_environments: old schema used label/login/credential, + # new schema uses name/username/auth_value (KIN-087 column rename). + env_cols = {r[1] for r in conn.execute("PRAGMA table_info(project_environments)").fetchall()} + if "name" not in env_cols and "label" in env_cols: + conn.executescript(""" + PRAGMA foreign_keys=OFF; + CREATE TABLE project_environments_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id TEXT NOT NULL REFERENCES projects(id), + name TEXT NOT NULL, + host TEXT NOT NULL, + port INTEGER DEFAULT 22, + username TEXT NOT NULL, + auth_type TEXT NOT NULL DEFAULT 'password', + auth_value TEXT, + is_installed INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(project_id, name) + ); + INSERT INTO project_environments_new + SELECT id, project_id, label, host, port, login, auth_type, + credential, is_installed, created_at, updated_at + FROM project_environments; + DROP TABLE project_environments; + ALTER TABLE project_environments_new RENAME TO project_environments; + CREATE INDEX IF NOT EXISTS idx_environments_project ON project_environments(project_id); + PRAGMA foreign_keys=ON; + """) + conn.commit() + if "project_phases" not in existing_tables: conn.executescript(""" CREATE TABLE IF NOT EXISTS project_phases ( @@ -520,6 +551,21 @@ def _migrate(conn: sqlite3.Connection): """) conn.commit() + if "task_attachments" not in existing_tables: + conn.executescript(""" + CREATE TABLE IF NOT EXISTS task_attachments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + filename TEXT NOT NULL, + path TEXT NOT NULL, + mime_type TEXT NOT NULL, + size INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_task_attachments_task ON task_attachments(task_id); + """) + conn.commit() + # Rename legacy 'auto' → 'auto_complete' (KIN-063) conn.execute( "UPDATE projects SET execution_mode = 'auto_complete' WHERE execution_mode = 'auto'" diff --git a/core/models.py b/core/models.py index 75bdee6..4b2cac7 100644 --- a/core/models.py +++ b/core/models.py @@ -869,6 +869,55 @@ def add_chat_message( return _row_to_dict(row) +# --------------------------------------------------------------------------- +# Task Attachments (KIN-090) +# --------------------------------------------------------------------------- + +def create_attachment( + conn: sqlite3.Connection, + task_id: str, + filename: str, + path: str, + mime_type: str, + size: int, +) -> dict: + """Create a task attachment record. path must be absolute.""" + cur = conn.execute( + """INSERT INTO task_attachments (task_id, filename, path, mime_type, size) + VALUES (?, ?, ?, ?, ?)""", + (task_id, filename, path, mime_type, size), + ) + conn.commit() + row = conn.execute( + "SELECT * FROM task_attachments WHERE id = ?", (cur.lastrowid,) + ).fetchone() + return _row_to_dict(row) + + +def list_attachments(conn: sqlite3.Connection, task_id: str) -> list[dict]: + """List all attachments for a task ordered by creation time.""" + rows = conn.execute( + "SELECT * FROM task_attachments WHERE task_id = ? ORDER BY created_at", + (task_id,), + ).fetchall() + return _rows_to_list(rows) + + +def get_attachment(conn: sqlite3.Connection, attachment_id: int) -> dict | None: + """Get a single attachment by id.""" + row = conn.execute( + "SELECT * FROM task_attachments WHERE id = ?", (attachment_id,) + ).fetchone() + return _row_to_dict(row) + + +def delete_attachment(conn: sqlite3.Connection, attachment_id: int) -> bool: + """Delete attachment record. Returns True if deleted, False if not found.""" + cur = conn.execute("DELETE FROM task_attachments WHERE id = ?", (attachment_id,)) + conn.commit() + return cur.rowcount > 0 + + def get_chat_messages( conn: sqlite3.Connection, project_id: str, diff --git a/pyproject.toml b/pyproject.toml index d60cb1d..f37e1b7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "kin" version = "0.1.0" description = "Multi-agent project orchestrator" requires-python = ">=3.11" -dependencies = ["click>=8.0", "fastapi>=0.110", "uvicorn>=0.29", "cryptography>=41.0"] +dependencies = ["click>=8.0", "fastapi>=0.110", "uvicorn>=0.29", "cryptography>=41.0", "python-multipart>=0.0.9"] [project.scripts] kin = "cli.main:cli" diff --git a/tests/test_api_attachments.py b/tests/test_api_attachments.py new file mode 100644 index 0000000..b98af9f --- /dev/null +++ b/tests/test_api_attachments.py @@ -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: /.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 diff --git a/tests/test_context_builder.py b/tests/test_context_builder.py index 9c6961d..1efc1a8 100644 --- a/tests/test_context_builder.py +++ b/tests/test_context_builder.py @@ -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 diff --git a/tests/test_kin_089_regression.py b/tests/test_kin_089_regression.py new file mode 100644 index 0000000..068d16c --- /dev/null +++ b/tests/test_kin_089_regression.py @@ -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" diff --git a/tests/test_models.py b/tests/test_models.py index 32dc7cc..3a06e6d 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -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 diff --git a/web/api.py b/web/api.py index 5ff0214..34ffcc3 100644 --- a/web/api.py +++ b/web/api.py @@ -4,6 +4,7 @@ Run: uvicorn web.api:app --reload --port 8420 """ import logging +import mimetypes import shutil import subprocess import sys @@ -12,7 +13,7 @@ from pathlib import Path # Ensure project root on sys.path sys.path.insert(0, str(Path(__file__).parent.parent)) -from fastapi import FastAPI, HTTPException, Query +from fastapi import FastAPI, File, HTTPException, Query, UploadFile from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse, FileResponse, Response from fastapi.staticfiles import StaticFiles @@ -1321,6 +1322,112 @@ def get_notifications(project_id: str | None = None): return notifications +# --------------------------------------------------------------------------- +# Attachments (KIN-090) +# --------------------------------------------------------------------------- + +_MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024 # 10 MB + + +def _attachment_dir(project_path: Path, task_id: str) -> Path: + """Return (and create) {project_path}/.kin/attachments/{task_id}/.""" + d = project_path / ".kin" / "attachments" / task_id + d.mkdir(parents=True, exist_ok=True) + return d + + +@app.post("/api/tasks/{task_id}/attachments", status_code=201) +async def upload_attachment(task_id: str, file: UploadFile = File(...)): + conn = get_conn() + t = models.get_task(conn, task_id) + if not t: + conn.close() + raise HTTPException(404, f"Task '{task_id}' not found") + p = models.get_project(conn, t["project_id"]) + if not p or not p.get("path"): + conn.close() + raise HTTPException(400, "Attachments not supported for operations projects") + + # Sanitize filename: strip directory components + safe_name = Path(file.filename or "upload").name + if not safe_name: + conn.close() + raise HTTPException(400, "Invalid filename") + + att_dir = _attachment_dir(Path(p["path"]), task_id) + dest = att_dir / safe_name + + # Path traversal guard + if not dest.is_relative_to(att_dir): + conn.close() + raise HTTPException(400, "Invalid filename") + + # Read with size limit + content = await file.read(_MAX_ATTACHMENT_SIZE + 1) + if len(content) > _MAX_ATTACHMENT_SIZE: + conn.close() + raise HTTPException(413, f"File too large. Maximum size is {_MAX_ATTACHMENT_SIZE // (1024*1024)} MB") + + dest.write_bytes(content) + + mime_type = mimetypes.guess_type(safe_name)[0] or "application/octet-stream" + attachment = models.create_attachment( + conn, task_id, + filename=safe_name, + path=str(dest), + mime_type=mime_type, + size=len(content), + ) + conn.close() + return JSONResponse(attachment, status_code=201) + + +@app.get("/api/tasks/{task_id}/attachments") +def list_task_attachments(task_id: str): + conn = get_conn() + t = models.get_task(conn, task_id) + if not t: + conn.close() + raise HTTPException(404, f"Task '{task_id}' not found") + attachments = models.list_attachments(conn, task_id) + conn.close() + return attachments + + +@app.delete("/api/tasks/{task_id}/attachments/{attachment_id}", status_code=204) +def delete_task_attachment(task_id: str, attachment_id: int): + conn = get_conn() + att = models.get_attachment(conn, attachment_id) + if not att or att["task_id"] != task_id: + conn.close() + raise HTTPException(404, f"Attachment #{attachment_id} not found") + # Delete file from disk + try: + Path(att["path"]).unlink(missing_ok=True) + except Exception: + pass + models.delete_attachment(conn, attachment_id) + conn.close() + return Response(status_code=204) + + +@app.get("/api/attachments/{attachment_id}/file") +def get_attachment_file(attachment_id: int): + conn = get_conn() + att = models.get_attachment(conn, attachment_id) + conn.close() + if not att: + raise HTTPException(404, f"Attachment #{attachment_id} not found") + file_path = Path(att["path"]) + if not file_path.exists(): + raise HTTPException(404, "Attachment file not found on disk") + return FileResponse( + str(file_path), + media_type=att["mime_type"], + filename=att["filename"], + ) + + # --------------------------------------------------------------------------- # Chat (KIN-OBS-012) # --------------------------------------------------------------------------- diff --git a/web/frontend/src/api.ts b/web/frontend/src/api.ts index 5bd896c..b775b79 100644 --- a/web/frontend/src/api.ts +++ b/web/frontend/src/api.ts @@ -53,6 +53,12 @@ async function del(path: string): Promise { return res.json() } +async function postForm(path: string, body: FormData): Promise { + const res = await fetch(`${BASE}${path}`, { method: 'POST', body }) + if (!res.ok) await throwApiError(res) + return res.json() +} + export interface Project { id: string name: string @@ -270,6 +276,15 @@ export interface ChatSendResult { task?: Task | null } +export interface Attachment { + id: number + task_id: string + filename: string + mime_type: string + size: number + created_at: string +} + export const api = { projects: () => get('/projects'), project: (id: string) => get(`/projects/${id}`), @@ -339,4 +354,14 @@ export const api = { get(`/projects/${projectId}/chat?limit=${limit}`), sendChatMessage: (projectId: string, content: string) => post(`/projects/${projectId}/chat`, { content }), + uploadAttachment: (taskId: string, file: File) => { + const fd = new FormData() + fd.append('file', file) + return postForm(`/tasks/${taskId}/attachments`, fd) + }, + getAttachments: (taskId: string) => + get(`/tasks/${taskId}/attachments`), + deleteAttachment: (taskId: string, id: number) => + del(`/tasks/${taskId}/attachments/${id}`), + attachmentUrl: (id: number) => `${BASE}/attachments/${id}/file`, } diff --git a/web/frontend/src/components/AttachmentList.vue b/web/frontend/src/components/AttachmentList.vue new file mode 100644 index 0000000..c924f1a --- /dev/null +++ b/web/frontend/src/components/AttachmentList.vue @@ -0,0 +1,55 @@ + + + diff --git a/web/frontend/src/components/AttachmentUploader.vue b/web/frontend/src/components/AttachmentUploader.vue new file mode 100644 index 0000000..14b9934 --- /dev/null +++ b/web/frontend/src/components/AttachmentUploader.vue @@ -0,0 +1,62 @@ + + + diff --git a/web/frontend/src/views/TaskDetail.vue b/web/frontend/src/views/TaskDetail.vue index e94fa72..f74d382 100644 --- a/web/frontend/src/views/TaskDetail.vue +++ b/web/frontend/src/views/TaskDetail.vue @@ -1,9 +1,11 @@