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
|
|
@ -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:
|
||||
|
|
|
|||
46
core/db.py
46
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'"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
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
|
||||
|
|
|
|||
109
web/api.py
109
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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -53,6 +53,12 @@ async function del<T>(path: string): Promise<T> {
|
|||
return res.json()
|
||||
}
|
||||
|
||||
async function postForm<T>(path: string, body: FormData): Promise<T> {
|
||||
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<Project[]>('/projects'),
|
||||
project: (id: string) => get<ProjectDetail>(`/projects/${id}`),
|
||||
|
|
@ -339,4 +354,14 @@ export const api = {
|
|||
get<ChatMessage[]>(`/projects/${projectId}/chat?limit=${limit}`),
|
||||
sendChatMessage: (projectId: string, content: string) =>
|
||||
post<ChatSendResult>(`/projects/${projectId}/chat`, { content }),
|
||||
uploadAttachment: (taskId: string, file: File) => {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
return postForm<Attachment>(`/tasks/${taskId}/attachments`, fd)
|
||||
},
|
||||
getAttachments: (taskId: string) =>
|
||||
get<Attachment[]>(`/tasks/${taskId}/attachments`),
|
||||
deleteAttachment: (taskId: string, id: number) =>
|
||||
del<void>(`/tasks/${taskId}/attachments/${id}`),
|
||||
attachmentUrl: (id: number) => `${BASE}/attachments/${id}/file`,
|
||||
}
|
||||
|
|
|
|||
55
web/frontend/src/components/AttachmentList.vue
Normal file
55
web/frontend/src/components/AttachmentList.vue
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { api, type Attachment } from '../api'
|
||||
|
||||
const props = defineProps<{ attachments: Attachment[]; taskId: string }>()
|
||||
const emit = defineEmits<{ deleted: [] }>()
|
||||
|
||||
const deletingId = ref<number | null>(null)
|
||||
|
||||
async function remove(id: number) {
|
||||
deletingId.value = id
|
||||
try {
|
||||
await api.deleteAttachment(props.taskId, id)
|
||||
emit('deleted')
|
||||
} catch {
|
||||
// silently ignore — parent will reload
|
||||
} finally {
|
||||
deletingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes}B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)}MB`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="attachments.length" class="flex flex-wrap gap-3 mb-3">
|
||||
<div
|
||||
v-for="att in attachments"
|
||||
:key="att.id"
|
||||
class="relative group border border-gray-700 rounded-lg overflow-hidden bg-gray-900 w-28"
|
||||
>
|
||||
<a :href="api.attachmentUrl(att.id)" target="_blank" rel="noopener">
|
||||
<img
|
||||
:src="api.attachmentUrl(att.id)"
|
||||
:alt="att.filename"
|
||||
class="w-28 h-20 object-cover block"
|
||||
/>
|
||||
</a>
|
||||
<div class="px-1.5 py-1">
|
||||
<p class="text-[10px] text-gray-400 truncate" :title="att.filename">{{ att.filename }}</p>
|
||||
<p class="text-[10px] text-gray-600">{{ formatSize(att.size) }}</p>
|
||||
</div>
|
||||
<button
|
||||
@click="remove(att.id)"
|
||||
:disabled="deletingId === att.id"
|
||||
class="absolute top-1 right-1 w-5 h-5 rounded-full bg-red-900/80 text-red-400 text-xs leading-none opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-50 flex items-center justify-center"
|
||||
title="Удалить"
|
||||
>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
62
web/frontend/src/components/AttachmentUploader.vue
Normal file
62
web/frontend/src/components/AttachmentUploader.vue
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { api } from '../api'
|
||||
|
||||
const props = defineProps<{ taskId: string }>()
|
||||
const emit = defineEmits<{ uploaded: [] }>()
|
||||
|
||||
const dragging = ref(false)
|
||||
const uploading = ref(false)
|
||||
const error = ref('')
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
async function upload(file: File) {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
error.value = 'Поддерживаются только изображения'
|
||||
return
|
||||
}
|
||||
uploading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await api.uploadAttachment(props.taskId, file)
|
||||
emit('uploaded')
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onFileChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
if (input.files?.[0]) upload(input.files[0])
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent) {
|
||||
dragging.value = false
|
||||
const file = event.dataTransfer?.files[0]
|
||||
if (file) upload(file)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="border-2 border-dashed rounded-lg p-3 text-center transition-colors cursor-pointer select-none"
|
||||
:class="dragging ? 'border-blue-500 bg-blue-950/20' : 'border-gray-700 hover:border-gray-500'"
|
||||
@dragover.prevent="dragging = true"
|
||||
@dragleave="dragging = false"
|
||||
@drop.prevent="onDrop"
|
||||
@click="fileInput?.click()"
|
||||
>
|
||||
<input ref="fileInput" type="file" accept="image/*" class="hidden" @change="onFileChange" />
|
||||
<div v-if="uploading" class="flex items-center justify-center gap-2 text-xs text-blue-400">
|
||||
<span class="inline-block w-3 h-3 border-2 border-blue-400 border-t-transparent rounded-full animate-spin"></span>
|
||||
Загрузка...
|
||||
</div>
|
||||
<div v-else class="text-xs text-gray-500">
|
||||
Перетащите изображение или <span class="text-blue-400">нажмите для выбора</span>
|
||||
</div>
|
||||
<p v-if="error" class="text-red-400 text-xs mt-1">{{ error }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,9 +1,11 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api, ApiError, type TaskFull, type PipelineStep, type PendingAction, type DeployResult } from '../api'
|
||||
import { api, ApiError, type TaskFull, type PipelineStep, type PendingAction, type DeployResult, type Attachment } from '../api'
|
||||
import Badge from '../components/Badge.vue'
|
||||
import Modal from '../components/Modal.vue'
|
||||
import AttachmentUploader from '../components/AttachmentUploader.vue'
|
||||
import AttachmentList from '../components/AttachmentList.vue'
|
||||
|
||||
const props = defineProps<{ id: string }>()
|
||||
const route = useRoute()
|
||||
|
|
@ -92,7 +94,7 @@ function stopPolling() {
|
|||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
onMounted(() => { load(); loadAttachments() })
|
||||
onUnmounted(stopPolling)
|
||||
|
||||
function statusColor(s: string) {
|
||||
|
|
@ -291,6 +293,15 @@ async function runDeploy() {
|
|||
}
|
||||
}
|
||||
|
||||
// Attachments
|
||||
const attachments = ref<Attachment[]>([])
|
||||
|
||||
async function loadAttachments() {
|
||||
try {
|
||||
attachments.value = await api.getAttachments(props.id)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Edit modal (pending tasks only)
|
||||
const showEdit = ref(false)
|
||||
const editForm = ref({ title: '', briefText: '', priority: 5, acceptanceCriteria: '' })
|
||||
|
|
@ -474,6 +485,13 @@ async function saveEdit() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Attachments -->
|
||||
<div class="mb-6">
|
||||
<h2 class="text-sm font-semibold text-gray-300 mb-2">Вложения</h2>
|
||||
<AttachmentList :attachments="attachments" :task-id="props.id" @deleted="loadAttachments" />
|
||||
<AttachmentUploader :task-id="props.id" @uploaded="loadAttachments" />
|
||||
</div>
|
||||
|
||||
<!-- Actions Bar -->
|
||||
<div class="sticky bottom-0 bg-gray-950 border-t border-gray-800 py-3 flex gap-3 -mx-6 px-6 mt-8">
|
||||
<div v-if="autoMode && (isRunning || task.status === 'review')"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue