diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index d981967..beb45be 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -39,3 +39,32 @@ jobs: - name: Run tests run: python -m pytest tests -q + + # --- CASAN Verification Gate ------------------------------------- + # Ba check này là điều kiện của cổng ngày 30/08. Chạy trên MỌI PR để + # biết vi phạm ngay hôm phát sinh, thay vì dồn tới ngày cổng. + # + # Check 1 do Team Gamma sở hữu và đã có. Check 2 (Team Hoa) và Check 3 + # (Team Duy) chưa viết — bước dưới bỏ qua nếu script chưa tồn tại, để + # thêm cổng không làm đỏ CI của hai team kia. + + - name: "CASAN Check 1 — không có credential lộ (Team Gamma)" + run: | + python scripts/audit_security.py --self-test + python scripts/audit_security.py + + - name: "CASAN Check 2 — file production ≤ 400 dòng (Team Hoa)" + run: | + if [ -f scripts/check_loc.py ]; then + python scripts/check_loc.py + else + echo "scripts/check_loc.py chưa có — Team Hoa viết, hạn 30/08. Bỏ qua." + fi + + - name: "CASAN Check 3 — domain/ và application/ không import PySide6 (Team Duy)" + run: | + if [ -f scripts/check_imports.py ]; then + python scripts/check_imports.py + else + echo "scripts/check_imports.py chưa có — Team Duy viết, hạn 30/08. Bỏ qua." + fi diff --git a/application/__init__.py b/application/__init__.py new file mode 100644 index 0000000..38608be --- /dev/null +++ b/application/__init__.py @@ -0,0 +1 @@ +"""application/ — Điều phối use-case. KHÔNG import PySide6. Gọi domain + interface hạ tầng.""" diff --git a/application/conversations/__init__.py b/application/conversations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/application/model_routing/__init__.py b/application/model_routing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/application/monitoring/__init__.py b/application/monitoring/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/application/scheduling/__init__.py b/application/scheduling/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/application/settings/__init__.py b/application/settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/application/workflows/__init__.py b/application/workflows/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/application/workspaces/__init__.py b/application/workspaces/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs/refactor/GammaTeam_decisions.md b/docs/refactor/GammaTeam_decisions.md new file mode 100644 index 0000000..649f1de --- /dev/null +++ b/docs/refactor/GammaTeam_decisions.md @@ -0,0 +1,107 @@ +# Hai quyết định chờ nhóm trưởng chốt — Team Gamma + +Hai việc này không code được cho tới khi có người quyết. Cả hai đều ảnh hưởng +ra ngoài phạm vi một người, nên để đây thay vì chôn trong comment. + +Trạng thái: **chưa chốt**. Hạn: trước khi N1 bắt đầu R02-T05 (26/08). + +--- + +## Quyết định 1 — `provider_conf()` còn trả `api_key` hay không + +### Vì sao phải quyết trước khi code + +R02-T05 chuyển API key sang Keyring. Câu hỏi là sau khi chuyển, dict do +`provider_conf()` trả về **còn chứa `api_key` không**. + +Có 5 nơi đang đọc trực tiếp — đo trên `main` ngày 21/08: + +| Nơi đọc | Thuộc | +|---|---| +| `providers/anthropic.py:26` | **Team Duy** | +| `providers/openai_compat.py:36` | **Team Duy** | +| `core/image_gen.py:50` | Team Duy (routing/model) | +| `core/ext_connectors.py:98` | Team Hoa | +| `ui/ext_connector_dialog.py:87` | Team Gamma | + +Ba trong năm nằm ngoài team. Quyết một mình rồi im lặng là làm vỡ code người khác. + +### Hai đường + +**A. Giữ `api_key` trong dict, `ConfigRepository` tự lấy từ `SecretStore` rồi ghép vào** + +- 5 nơi đọc **không phải sửa dòng nào** +- Không cần báo team khác, không cần đồng bộ lịch +- Đổi lại: bí mật vẫn đi lang thang trong dict, dễ lọt vào log hoặc màn hình debug +- CASAN Check 1 vẫn PASS vì nó quét **file trên đĩa**, không quét bộ nhớ + +**B. Bỏ `api_key` khỏi dict, ai cần thì gọi `secrets.get(provider_key(name))`** + +- Sạch về nguyên tắc: bí mật chỉ xuất hiện đúng chỗ cần +- Đổi lại: **5 nơi phải sửa**, 3 trong đó phải chờ team khác xếp lịch +- Rủi ro: quên một chỗ thì mất API key lúc chạy thật, mà test có fake nên không bắt được + +### Đề xuất + +**Đường A cho sprint này, đường B ghi vào nợ kỹ thuật.** + +Lý do: mục tiêu của cổng CASAN là *không còn secret nằm trên đĩa*, và đường A +đạt được điều đó. Đường B giải quyết thêm chuyện secret trong bộ nhớ — đúng +nhưng không phải việc của 10 ngày này, và nó kéo hai team khác vào một thay đổi +họ không lên kế hoạch. + +Nếu chọn B thì **phải báo Team Duy và Team Hoa trong hôm nay**, không phải lúc +đã sửa xong. + +> Nhóm trưởng chốt: ☐ A ☐ B — ngày ____ + +--- + +## Quyết định 2 — số phận 24 checker UI + +### Vấn đề + +`tools/check_*.py` là bộ kiểm tra giao diện viết trong 2 tuần vừa rồi, hiện +**24 file**. Chúng bám vào đường dẫn cũ: + +| Import | Số chỗ | +|---|---| +| `cowork_local.config` | 34 | +| `cowork_local.app` | 16 | +| `cowork_local.state` | 22 | +| `cowork_local.ui.*` | ~12 | + +R08 dời hết những module đó sang `presentation/`. Nghĩa là **cả 24 checker chết +ngay ngày N1 đụng `config.py`** — và đó là lưới an toàn duy nhất cho phần giao +diện, vì `pytest` không kiểm giao diện (90 test hiện tại là logic). + +### Ba đường + +**A. Ai dời file thì cập nhật checker tương ứng, ngay trong PR đó** + +- Giữ được lưới suốt 10 ngày +- Tốn thêm ~15% thời gian mỗi PR +- Rủi ro: người sửa vội có thể nới lỏng phép kiểm cho nó xanh — đã xảy ra một + lần trong quá trình làm UI, khi một checker được sửa thành *không thể đỏ* + +**B. Đóng băng: bỏ khỏi CI, sửa một lượt ngày 31/08** + +- Nhanh nhất trong 10 ngày +- Đổi lại: **không có gì canh hồi quy giao diện** suốt cả sprint. Refactor là lúc + dễ vỡ giao diện nhất +- Rủi ro cuối sprint: sửa 24 file cùng lúc, không ai nhớ cái nào đo gì + +**C. Bỏ hẳn** + +Không khuyến nghị. Vứt đi hai tuần công sức kiểm chứng, và ba tài liệu refactor +không có gì thay thế cho phần giao diện. + +### Đề xuất + +**Đường A**, kèm một ràng buộc: PR nào *sửa* checker phải nói rõ trong mô tả +**sửa gì và vì sao** — để việc nới lỏng phép kiểm không lọt qua review. + +`tools/check_probes_bite.py` đã có sẵn cơ chế chứng minh checker còn cắn được; +chạy nó sau mỗi đợt sửa là bắt được ngay chuyện đó. + +> Nhóm trưởng chốt: ☐ A ☐ B ☐ C — ngày ____ diff --git a/domain/__init__.py b/domain/__init__.py new file mode 100644 index 0000000..22928ba --- /dev/null +++ b/domain/__init__.py @@ -0,0 +1 @@ +"""domain/ — Quy tắc nghiệp vụ thuần. KHÔNG import PySide6, không chạm đĩa/mạng.""" diff --git a/domain/agents/__init__.py b/domain/agents/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/domain/models/__init__.py b/domain/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/domain/security/__init__.py b/domain/security/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/domain/tasks/__init__.py b/domain/tasks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/domain/tools/__init__.py b/domain/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/domain/workflows/__init__.py b/domain/workflows/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/infrastructure/__init__.py b/infrastructure/__init__.py new file mode 100644 index 0000000..cfd9280 --- /dev/null +++ b/infrastructure/__init__.py @@ -0,0 +1 @@ +"""infrastructure/ — Chạm thế giới thật: file, keyring, HTTP, tiến trình. Cài đặt interface.""" diff --git a/infrastructure/config/__init__.py b/infrastructure/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/infrastructure/config/config_repository.py b/infrastructure/config/config_repository.py new file mode 100644 index 0000000..476c58e --- /dev/null +++ b/infrastructure/config/config_repository.py @@ -0,0 +1,104 @@ +"""Cấu hình ứng dụng — interface, chưa phải cài đặt. + +Hợp đồng số 2 của mục chung. Đây là thứ gỡ chốt lớn nhất: **156 lời gọi +``ctx.config.*`` nằm rải trong 29 file**, nên nếu N2 và N3 phải đợi +``ConfigRepository`` bản thật (R02-T02, hạn 23/08) thì hai người mất mấy ngày +đầu ngồi không. + +Danh sách thuộc tính dưới đây không bịa ra: đếm trực tiếp chỗ đang gọi trong +``core/``, ``ui/``, ``providers/`` và ``app.py`` rồi lấy những cái được dùng +thật, xếp theo số lần gọi. + +Một chỗ cố ý KHÔNG đưa vào: ``config.data`` (36 lần gọi, nhiều nhất). Đó là +đống dict thô — cho nó vào interface là bê nguyên vấn đề cũ sang kiến trúc mới. +Ai đang cần ``data`` thì mở issue để bổ sung một thuộc tính có kiểu rõ ràng. +""" +from __future__ import annotations + +from pathlib import Path +from typing import Any, Dict, Protocol, runtime_checkable + + +@runtime_checkable +class ConfigRepository(Protocol): + """Đọc/ghi cấu hình. Cài đặt thật dùng ``AtomicJsonFile`` (R02-T01/T02).""" + + # ---- provider ------------------------------------------------------ + @property + def active_provider(self) -> str: + """Tên provider đang chọn (24 lời gọi).""" + ... + + def set_active_provider(self, name: str) -> None: + ... + + def provider_conf(self, name: str | None = None) -> Dict[str, Any]: + """Cấu hình của một provider (9 lời gọi). + + CHÚ Ý — điểm còn bỏ ngỏ, xem ``docs/refactor/GammaTeam_decisions.md``: + dict này còn chứa ``api_key`` hay không là quyết định chưa chốt. Có 5 + nơi đang đọc trực tiếp, 3 trong số đó thuộc ``providers/`` của Team Duy. + """ + ... + + # ---- đường dẫn ----------------------------------------------------- + @property + def shared_dir(self) -> str: + """Thư mục dùng chung cho telemetry nhiều máy (10 lời gọi).""" + ... + + def history_dir(self) -> Path: + """Thư mục lịch sử chat của project đang chọn (7 lời gọi).""" + ... + + def cowork_output_dir(self) -> Path: + """Thư mục Cowork ghi kết quả ra (6 lời gọi).""" + ... + + # ---- giao diện ----------------------------------------------------- + @property + def theme(self) -> str: + """``"dark"`` | ``"light"`` | ``"system"`` (8 lời gọi).""" + ... + + def set_theme(self, value: str) -> None: + ... + + @property + def language(self) -> str: + """``"vi"`` | ``"en"`` | ``"ja"`` (4 lời gọi).""" + ... + + def set_language(self, value: str) -> None: + ... + + # ---- các nhóm cấu hình còn lại ------------------------------------- + @property + def routing(self) -> Dict[str, Any]: + """Cấu hình định tuyến model (7 lời gọi).""" + ... + + @property + def auth(self) -> Dict[str, Any]: + """Cấu hình đăng nhập (6 lời gọi).""" + ... + + @property + def agent_security(self) -> Dict[str, Any]: + """Chính sách an toàn cho agent (5 lời gọi).""" + ... + + @property + def tools_disabled(self) -> list[str]: + """Tool bị tắt (2 lời gọi).""" + ... + + def set_tool_enabled(self, name: str, enabled: bool) -> None: + ... + + # ---- ghi ------------------------------------------------------------ + def save(self) -> None: + """Ghi xuống đĩa. Bản thật ghi atomic — tạm + fsync + thay thế — + nên tắt máy giữa chừng không làm hỏng file (R02-T01). + """ + ... diff --git a/infrastructure/filesystem/__init__.py b/infrastructure/filesystem/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/infrastructure/mcp/__init__.py b/infrastructure/mcp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/infrastructure/persistence/__init__.py b/infrastructure/persistence/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/infrastructure/persistence/json/__init__.py b/infrastructure/persistence/json/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/infrastructure/providers/__init__.py b/infrastructure/providers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/infrastructure/sandbox/__init__.py b/infrastructure/sandbox/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/infrastructure/telemetry/__init__.py b/infrastructure/telemetry/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/platform/__init__.py b/platform/__init__.py new file mode 100644 index 0000000..1b2487c --- /dev/null +++ b/platform/__init__.py @@ -0,0 +1 @@ +"""platform/ — Adapter riêng cho Qt (clock, thread, timer).""" diff --git a/platform/qt/__init__.py b/platform/qt/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/presentation/__init__.py b/presentation/__init__.py new file mode 100644 index 0000000..c56cd8d --- /dev/null +++ b/presentation/__init__.py @@ -0,0 +1 @@ +"""presentation/ — Widget Qt. Chỉ gọi xuống application, không gọi thẳng infrastructure.""" diff --git a/presentation/chat/__init__.py b/presentation/chat/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/presentation/co4e/__init__.py b/presentation/co4e/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/presentation/dashboard/__init__.py b/presentation/dashboard/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/presentation/folder/__init__.py b/presentation/folder/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/presentation/graph/__init__.py b/presentation/graph/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/presentation/monitoring/__init__.py b/presentation/monitoring/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/presentation/scheduling/__init__.py b/presentation/scheduling/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/presentation/settings/__init__.py b/presentation/settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/presentation/shell/__init__.py b/presentation/shell/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/audit_security.py b/scripts/audit_security.py new file mode 100644 index 0000000..110cccd --- /dev/null +++ b/scripts/audit_security.py @@ -0,0 +1,204 @@ +"""CASAN Check 1 — không được có credential nào nằm phơi trong repo. + +Team Gamma chủ trì check này (hạn: 30/08). Viết sẵn từ 21/08 để chạy được liên +tục trong lúc chuyển API key sang Keyring (R02-T05), thay vì tới ngày cổng mới +chạy lần đầu rồi mới biết còn sót. + +Quét gì: + * file cấu hình đã commit: ``*.json`` ``*.jsonl`` ``*.yaml`` ``*.yml`` ``*.env`` + * mã nguồn Python — chỗ gán chuỗi cho biến tên như api_key / token / secret + +Tìm hai loại: + 1. Chuỗi có hình dạng credential thật (sk-…, ghp_…, xoxb-…, AKIA…, JWT…) + 2. Trường tên nhạy cảm mà giá trị không rỗng và không phải placeholder + +Bỏ qua: chuỗi rỗng, placeholder ("your-key-here", "changeme"…), giá trị hằng +không phải bí mật (Ollama đòi có api_key nhưng bỏ qua nội dung). + +Chạy: python scripts/audit_security.py [--json] +Mã thoát: 0 = sạch, 1 = có phát hiện. +""" +from __future__ import annotations + +import argparse +import json +import re +import sys + +# console Windows hay là cp932/cp1258; ép UTF-8 để không chết giữa báo cáo +sys.stdout.reconfigure(encoding="utf-8", errors="replace") +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent + +SKIP_DIRS = {".git", "__pycache__", "node_modules", ".venv", "venv", "build", + "dist", ".pytest_cache", ".mypy_cache", "cowork-local-gitea"} +CONFIG_SUFFIX = {".json", ".jsonl", ".yaml", ".yml", ".env"} + +# tên trường coi là nhạy cảm +SENSITIVE = re.compile( + r"(api[_-]?key|secret|token|password|passwd|client[_-]?secret|" + r"access[_-]?key|private[_-]?key|credential)", re.I) + +# hình dạng credential thật — bắt được kể cả khi tên trường vô hại +SHAPES = [ + ("OpenAI", re.compile(r"\bsk-[A-Za-z0-9_\-]{20,}")), + ("Anthropic", re.compile(r"\bsk-ant-[A-Za-z0-9_\-]{20,}")), + ("GitHub", re.compile(r"\bgh[pousr]_[A-Za-z0-9]{30,}")), + ("Slack", re.compile(r"\bxox[abprs]-[A-Za-z0-9\-]{10,}")), + ("AWS", re.compile(r"\bAKIA[0-9A-Z]{16}\b")), + ("Google", re.compile(r"\bAIza[0-9A-Za-z_\-]{35}\b")), + ("JWT", re.compile(r"\beyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\.")), + ("Private key", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")), +] + +#: Dòng có dấu này được bỏ qua — lối thoát chuẩn cho mẫu thử, tài liệu, hằng +#: đặt tên chứa "secret". Bắt buộc ghi lý do sau dấu hai chấm. +ALLOW_MARK = re.compile(r"#\s*casan:\s*allow") + +#: Giá trị là KHOÁ i18n / tên hằng, không phải bí mật. Bắt bằng hình dạng +#: "a.b.c" hoặc "a_b_c" chứ không phải bằng danh sách đen từng chữ. +LOOKS_LIKE_KEY = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$") + +#: Credential thật gần như luôn dài hơn thế này. Ngưỡng để loại dữ liệu test +#: kiểu api_key="x" — báo động giả làm cả đội thôi đọc báo cáo. +MIN_SECRET_LEN = 12 + +#: Giá trị là hằng liệt kê, không phải bí mật: mức độ cảnh báo, bật/tắt… +ENUMISH = {"warning", "warn", "error", "info", "debug", "critical", "on", "off", + "true", "false", "yes", "no", "allow", "deny", "block", "ask", + "always", "never", "auto", "default", "disabled", "enabled"} + +# giá trị vô hại — không tính là phát hiện +PLACEHOLDER = re.compile( + r"^(|ollama|none|null|changeme|your[_\- ]?(api[_\- ]?)?key([_\- ]?here)?|" + r"<[^>]*>|\{\{.*\}\}|\$\{.*\}|xxx+|\*+|placeholder|todo|example|test|dummy|" + r"sk-\.\.\.|\.\.\.)$", re.I) + +# gán chuỗi trong Python: api_key = "..." +PY_ASSIGN = re.compile( + r"""["']?(\w*(?:api[_-]?key|secret|token|password|credential)\w*)["']?\s*[:=]\s*""" + r"""["']([^"']*)["']""", re.I) + + +def _is_placeholder(value: str) -> bool: + v = value.strip() + if PLACEHOLDER.match(v) or v.lower() in ENUMISH: + return True + if LOOKS_LIKE_KEY.match(v): # "monitoring.action_secret_in_output" + return True + # quá ngắn để là credential thật + return len(v) < MIN_SECRET_LEN + + +def _walk(): + for path in REPO.rglob("*"): + if not path.is_file(): + continue + if any(part in SKIP_DIRS for part in path.parts): + continue + if path.suffix in CONFIG_SUFFIX or path.suffix == ".py": + yield path + + +def scan() -> list[dict]: + findings: list[dict] = [] + for path in _walk(): + try: + text = path.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + rel = path.relative_to(REPO).as_posix() + + for lineno, line in enumerate(text.splitlines(), 1): + if ALLOW_MARK.search(line): + continue + # 1. hình dạng credential thật + for label, pattern in SHAPES: + m = pattern.search(line) + if m: + findings.append({ + "file": rel, "line": lineno, "kind": f"{label} credential", + "evidence": m.group(0)[:12] + "…", + }) + + # 2. trường nhạy cảm có giá trị + for m in PY_ASSIGN.finditer(line): + field, value = m.group(1), m.group(2) + if not SENSITIVE.search(field) or _is_placeholder(value): + continue + findings.append({ + "file": rel, "line": lineno, + "kind": f"trường '{field}' có giá trị", + "evidence": value[:6] + "…" if len(value) > 6 else value, + }) + return findings + + +def _self_test() -> int: + """Một máy quét không tìm thấy gì chỉ có giá trị nếu chứng minh được nó + biết tìm. Cắm mẫu xấu và mẫu vô hại, xem có phân biệt đúng không.""" + import tempfile + + bad = { + "OpenAI": '"api_key": "sk-proj-abc123def456ghi789jkl012mno"', # casan: allow - mau thu cua chinh script + "GitHub": 'token = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"', # casan: allow - mau thu cua chinh script + "AWS": 'aws = "AKIAIOSFODNN7EXAMPLE"', # casan: allow - mau thu cua chinh script + "Anthropic": '"api_key": "sk-ant-api03-xxxxxxxxxxxxxxxxxxxxxx"', # casan: allow - mau thu cua chinh script + } + ok = { + "rỗng": '"api_key": ""', + "placeholder": '"api_key": "your-key-here"', + "ollama": '"api_key": "ollama"', + "test ngắn": 'api_key = "x"', + "hằng liệt kê": '"secret_in_output": "warning"', + } + global REPO + keep = REPO + passed = True + with tempfile.TemporaryDirectory() as tmp: + REPO = Path(tmp) + for label, line in {**bad, **ok}.items(): + (REPO / "probe.py").write_text(line + "\n", encoding="utf-8") + found = bool(scan()) + want = label in bad + mark = "OK " if found == want else "SAI" + if found != want: + passed = False + verb = "bắt được" if found else "bỏ qua" + print(f" [{mark}] {label:14} -> {verb}") + REPO = keep + print() + print("Tự kiểm: " + ("script phân biệt đúng." if passed + else "*** script phân biệt SAI ***")) + return 0 if passed else 1 + + +def main() -> int: + ap = argparse.ArgumentParser(description="CASAN Check 1 — quét credential lộ") + ap.add_argument("--json", action="store_true", help="in kết quả dạng JSON") + ap.add_argument("--self-test", action="store_true", + help="cắm credential giả vào file tạm, kiểm script có bắt được") + args = ap.parse_args() + + if args.self_test: + return _self_test() + + findings = scan() + if args.json: + print(json.dumps(findings, ensure_ascii=False, indent=2)) + else: + n_files = sum(1 for _ in _walk()) + print(f"CASAN Check 1 — quét {n_files} file trong {REPO.name}/") + if not findings: + print("\n0 credential lưu plaintext. PASS.") + else: + print(f"\n*** {len(findings)} phát hiện ***\n") + for f in findings: + print(f" {f['file']}:{f['line']}") + print(f" {f['kind']} — {f['evidence']}") + return 1 if findings else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/fakes/__init__.py b/tests/fakes/__init__.py new file mode 100644 index 0000000..fa043ea --- /dev/null +++ b/tests/fakes/__init__.py @@ -0,0 +1 @@ +"""Test double dùng chung cho cả 3 team — không phụ thuộc Qt.""" diff --git a/tests/fakes/fake_config.py b/tests/fakes/fake_config.py new file mode 100644 index 0000000..9d2b65f --- /dev/null +++ b/tests/fakes/fake_config.py @@ -0,0 +1,138 @@ +"""Bản giả của ConfigRepository và SecretStore — chạy trong bộ nhớ. + +Dùng để N2 (Giám sát) và N3 (Co4E) code và test ngay từ 21/08, không phải đợi +bản thật xong ngày 23/08 và 26/08. + +Không chạm đĩa, không chạm keyring, không cần Qt. Test dùng nó chạy trong vài +mili giây. +""" +from __future__ import annotations + +from pathlib import Path +from typing import Any, Dict + + +class FakeSecretStore: + """SecretStore trong bộ nhớ. + + >>> s = FakeSecretStore({"provider:openai": "sk-test"}) + >>> s.get("provider:openai") + 'sk-test' + >>> s.get("provider:chua-co") is None + True + """ + + def __init__(self, seed: Dict[str, str] | None = None): + self._items: Dict[str, str] = dict(seed or {}) + + def get(self, key: str) -> str | None: + return self._items.get(key) + + def set(self, key: str, value: str) -> None: + self._items[key] = value + + def delete(self, key: str) -> None: + self._items.pop(key, None) + + def has(self, key: str) -> bool: + return key in self._items + + +class FakeConfigRepository: + """ConfigRepository trong bộ nhớ, có sẵn giá trị mặc định hợp lý. + + Mọi thứ ghi đè được qua tham số khởi tạo, nên test dựng đúng tình huống + mình cần:: + + cfg = FakeConfigRepository(theme="light", shared_dir="/tmp/chung") + """ + + def __init__(self, *, active_provider: str = "ollama", + providers: Dict[str, Dict[str, Any]] | None = None, + shared_dir: str = "", theme: str = "dark", language: str = "vi", + routing: Dict[str, Any] | None = None, + auth: Dict[str, Any] | None = None, + agent_security: Dict[str, Any] | None = None, + tools_disabled: list[str] | None = None, + history_dir: Path | None = None, + output_dir: Path | None = None): + self._active_provider = active_provider + self._providers = providers or { + "ollama": {"base_url": "http://localhost:11434/v1", "model": "llama3"}, + "openai": {"base_url": "https://api.openai.com/v1", "model": "gpt-4o-mini"}, + } + self._shared_dir = shared_dir + self._theme = theme + self._language = language + self._routing = routing or {"mode": "off"} + self._auth = auth or {} + self._agent_security = agent_security or {"cowork_confirm_commands": True} + self._tools_disabled = list(tools_disabled or []) + self._history_dir = history_dir or Path("/fake/history") + self._output_dir = output_dir or Path("/fake/workspace") + #: số lần save() được gọi — để test khẳng định "có ghi" mà không cần đĩa + self.saves = 0 + + # ---- provider ------------------------------------------------------ + @property + def active_provider(self) -> str: + return self._active_provider + + def set_active_provider(self, name: str) -> None: + self._active_provider = name + + def provider_conf(self, name: str | None = None) -> Dict[str, Any]: + return dict(self._providers.get(name or self._active_provider, {})) + + # ---- đường dẫn ----------------------------------------------------- + @property + def shared_dir(self) -> str: + return self._shared_dir + + def history_dir(self) -> Path: + return self._history_dir + + def cowork_output_dir(self) -> Path: + return self._output_dir + + # ---- giao diện ----------------------------------------------------- + @property + def theme(self) -> str: + return self._theme + + def set_theme(self, value: str) -> None: + self._theme = value + + @property + def language(self) -> str: + return self._language + + def set_language(self, value: str) -> None: + self._language = value + + # ---- nhóm cấu hình -------------------------------------------------- + @property + def routing(self) -> Dict[str, Any]: + return self._routing + + @property + def auth(self) -> Dict[str, Any]: + return self._auth + + @property + def agent_security(self) -> Dict[str, Any]: + return self._agent_security + + @property + def tools_disabled(self) -> list[str]: + return list(self._tools_disabled) + + def set_tool_enabled(self, name: str, enabled: bool) -> None: + if enabled: + self._tools_disabled = [t for t in self._tools_disabled if t != name] + elif name not in self._tools_disabled: + self._tools_disabled.append(name) + + # ---- ghi ------------------------------------------------------------ + def save(self) -> None: + self.saves += 1 diff --git a/tests/test_contracts.py b/tests/test_contracts.py new file mode 100644 index 0000000..a76fbdb --- /dev/null +++ b/tests/test_contracts.py @@ -0,0 +1,81 @@ +"""Hợp đồng của mục chung có thật sự gỡ chốt cho N2 và N3 không. + +Đây là bài nghiệm thu, không phải test cho vui: nếu ba bài dưới đây xanh thì +hai nhánh kia code được ngay hôm nay mà không cần chờ ``ConfigRepository`` hay +``KeyringAdapter`` bản thật. +""" +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +from cowork_local.infrastructure.config.config_repository import ConfigRepository +from cowork_local.infrastructure.secrets.secret_store import SecretStore, provider_key +from cowork_local.tests.fakes.fake_config import FakeConfigRepository, FakeSecretStore + +REPO_PARENT = Path(__file__).resolve().parents[2] + + +def test_fake_config_khop_hop_dong(): + """Fake phải cài đủ interface — thiếu một hàm là hai nhánh kia gọi vào sẽ vỡ.""" + assert isinstance(FakeConfigRepository(), ConfigRepository) + + +def test_fake_secret_store_khop_hop_dong(): + assert isinstance(FakeSecretStore(), SecretStore) + + +def test_secret_store_thieu_key_thi_tra_none_chu_khong_nem_loi(): + """Thiếu API key là chuyện thường (người dùng chưa nhập), không phải sự cố.""" + store = FakeSecretStore() + assert store.get(provider_key("openai")) is None + assert store.has(provider_key("openai")) is False + store.delete(provider_key("openai")) # xoá cái không có: im lặng + + store.set(provider_key("openai"), "sk-test") + assert store.get(provider_key("openai")) == "sk-test" + assert store.has(provider_key("openai")) is True + + +def test_config_gia_ghi_nhan_save_ma_khong_cham_dia(): + cfg = FakeConfigRepository(theme="light") + assert cfg.theme == "light" + cfg.set_theme("dark") + cfg.save() + assert cfg.theme == "dark" + assert cfg.saves == 1 + + +def test_bat_duoc_tool_bi_tat(): + cfg = FakeConfigRepository(tools_disabled=["run_command"]) + assert cfg.tools_disabled == ["run_command"] + cfg.set_tool_enabled("run_command", True) + assert cfg.tools_disabled == [] + cfg.set_tool_enabled("write_file", False) + assert cfg.tools_disabled == ["write_file"] + + +def test_dung_duoc_fake_ma_khong_hề_nap_config_that(): + """Bài nghiệm thu chính của mục chung. + + N2 và N3 phải dựng được màn hình và chạy test của mình mà KHÔNG kéo theo + ``cowork_local.config`` — module nặng, đọc đĩa, và đang bị N1 viết lại. + Kiểm bằng tiến trình riêng để không dính module đã nạp sẵn ở test khác. + """ + snippet = ( + "import sys\n" + "from cowork_local.tests.fakes.fake_config import " + "FakeConfigRepository, FakeSecretStore\n" + "cfg = FakeConfigRepository(active_provider='openai')\n" + "assert cfg.provider_conf()['model'] == 'gpt-4o-mini'\n" + "assert FakeSecretStore().get('x') is None\n" + "assert 'cowork_local.config' not in sys.modules, " + "'fake keo theo config that -> van con phu thuoc'\n" + "assert 'PySide6' not in sys.modules, 'fake keo theo Qt -> test se cham'\n" + "print('OK')\n" + ) + out = subprocess.run([sys.executable, "-c", snippet], cwd=REPO_PARENT, + capture_output=True, text=True, timeout=60) + assert out.returncode == 0, out.stderr + assert "OK" in out.stdout