kin: KIN-089 При попытке добавить креды прод сервера для проекта corelock вылетает 500 Internal Server Error

This commit is contained in:
Gros Frumos 2026-03-16 20:39:17 +02:00
parent e80e50ba0c
commit 4a65d90218
13 changed files with 1215 additions and 4 deletions

View 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

View file

@ -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

View 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 labelname, loginusername, credentialauth_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"

View file

@ -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