From d74c052af3b1242235c4cf6dd261d2aa5147161a Mon Sep 17 00:00:00 2001 From: Nam Pham Dinh Thanh Date: Sat, 22 Aug 2026 21:11:42 +0900 Subject: [PATCH] =?UTF-8?q?fix(infra):=20.gitignore=20nu=E1=BB=91t=20infra?= =?UTF-8?q?structure/secrets/=20=E2=80=94=20nh=C3=A1nh=20=C4=91=E1=BB=8F?= =?UTF-8?q?=20v=E1=BB=9Bi=20m=E1=BB=8Di=20m=C3=A1y=20tr=E1=BB=AB=20m=C3=A1?= =?UTF-8?q?y=20t=C3=B4i?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dòng 31 ghi `secrets/`. Mẫu không neo, nên git bỏ qua MỌI thư mục tên secrets ở mọi độ sâu — kể cả infrastructure/secrets/ vốn là mã nguồn. Ba file ở đó chưa bao giờ lên repo. Máy tôi vẫn 150 test xanh vì pytest đọc đĩa chứ không đọc git; ai clone sạch thì đỏ 4 file ngay lúc thu thập: ModuleNotFoundError: No module named 'cowork_local.infrastructure.secrets' Hiệp phát hiện, không phải tôi. Đã dựng lại bằng clone sạch vào thư mục đặt đúng tên cowork_local để tái hiện. Neo mẫu thành /secrets/ và thêm tests/test_no_ignored_source.py — hỏi thẳng git chứ không hỏi đĩa, nên lần sau lỗi cùng hình dạng sẽ đỏ ngay trên máy người viết. Đã kiểm ngược: trả lại `secrets/` thì cả ba bài đỏ. Co-Authored-By: Claude Opus 5 --- .gitignore | 5 +- infrastructure/secrets/__init__.py | 0 infrastructure/secrets/keyring_adapter.py | 86 +++++++++++++++++++++ infrastructure/secrets/secret_store.py | 46 ++++++++++++ tests/test_no_ignored_source.py | 91 +++++++++++++++++++++++ 5 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 infrastructure/secrets/__init__.py create mode 100644 infrastructure/secrets/keyring_adapter.py create mode 100644 infrastructure/secrets/secret_store.py create mode 100644 tests/test_no_ignored_source.py diff --git a/.gitignore b/.gitignore index 182f2ee..3109d6c 100644 --- a/.gitignore +++ b/.gitignore @@ -28,7 +28,10 @@ bower_components/ .env.preview *.pem *.key -secrets/ +# Neo vào gốc repo: mẫu không neo nuốt MỌI thư mục tên secrets ở mọi độ +# sâu — nó đã âm thầm chặn infrastructure/secrets/ (mã nguồn, không phải +# bí mật) khỏi repo suốt 21-22/08. +/secrets/ credentials.json .npmrc .yarnrc diff --git a/infrastructure/secrets/__init__.py b/infrastructure/secrets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/infrastructure/secrets/keyring_adapter.py b/infrastructure/secrets/keyring_adapter.py new file mode 100644 index 0000000..be058a0 --- /dev/null +++ b/infrastructure/secrets/keyring_adapter.py @@ -0,0 +1,86 @@ +"""SecretStore chạy trên OS Keyring — R02-T04. + +Windows dùng Credential Manager, macOS dùng Keychain, Linux dùng Secret +Service. Người dùng cuối không thấy gì khác, nhưng API key thôi nằm trong +``config.json`` — đó là điều kiện để qua CASAN Check 1. + +Không phải máy nào cũng có keyring dùng được: Linux chạy headless không có +Secret Service, và CI thì gần như chắc chắn không. Nên adapter này **không bao +giờ ném lỗi** — không dùng được thì tự báo ``available = False`` và trả về +None, để tầng trên hiển thị "chưa lưu được khoá" thay vì sập cả app. +""" +from __future__ import annotations + +import logging + +log = logging.getLogger(__name__) + +#: Tên "dịch vụ" trong keyring — mọi khoá của app nằm dưới đây. +SERVICE = "cowork-local" + + +class KeyringAdapter: + """Cài đặt :class:`SecretStore` bằng thư viện ``keyring``. + + >>> store = KeyringAdapter() + >>> if store.available: + ... store.set("provider:openai", "sk-...") + """ + + def __init__(self, service: str = SERVICE): + self.service = service + self._backend = None + self._available = False + try: + import keyring + from keyring.backends.fail import Keyring as FailKeyring + + backend = keyring.get_keyring() + # backend "fail" là cái keyring trả về khi không tìm được kho nào + # dùng được — gọi vào chỉ tổ ném lỗi. + if not isinstance(backend, FailKeyring): + self._backend = keyring + self._available = True + else: + log.info("keyring không có kho khả dụng trên máy này") + except Exception as exc: # noqa: BLE001 — thiếu thư viện, thiếu DBus… + log.info("keyring không dùng được: %s", exc) + + @property + def available(self) -> bool: + """Có kho bí mật dùng được không. + + Tầng giao diện đọc cờ này để nói cho người dùng biết vì sao ô API key + không lưu được, thay vì im lặng làm mất khoá họ vừa nhập. + """ + return self._available + + # ---- SecretStore ---------------------------------------------------- + def get(self, key: str) -> str | None: + if not self._available: + return None + try: + return self._backend.get_password(self.service, key) + except Exception as exc: # noqa: BLE001 + log.warning("đọc khoá %r thất bại: %s", key, exc) + return None + + def set(self, key: str, value: str) -> None: + if not self._available: + log.warning("không lưu được %r: máy này không có kho bí mật", key) + return + try: + self._backend.set_password(self.service, key, value) + except Exception as exc: # noqa: BLE001 + log.warning("lưu khoá %r thất bại: %s", key, exc) + + def delete(self, key: str) -> None: + if not self._available: + return + try: + self._backend.delete_password(self.service, key) + except Exception: # noqa: BLE001 — xoá cái không có: bỏ qua + pass + + def has(self, key: str) -> bool: + return self.get(key) is not None diff --git a/infrastructure/secrets/secret_store.py b/infrastructure/secrets/secret_store.py new file mode 100644 index 0000000..7b8331f --- /dev/null +++ b/infrastructure/secrets/secret_store.py @@ -0,0 +1,46 @@ +"""Nơi cất credential — interface, chưa phải cài đặt. + +Hợp đồng số 1 của mục chung: chốt hôm nay để N2 và N3 code được ngay, không +phải đợi bản Keyring thật (R02-T04, hạn 26/08). + +Vì sao là interface chứ không phải hàm tiện ích: bản thật sẽ gọi OS Keyring — +chậm, có thể ném lỗi, và trong test thì không được đụng vào keyring máy thật. +Có interface thì test tiêm ``FakeSecretStore`` vào, chạy trong bộ nhớ. + +Quy ước đặt key: ``"provider:"`` cho API key của provider, ví dụ +``"provider:openai"``. Đặt sẵn để không mỗi người tự nghĩ một kiểu. +""" +from __future__ import annotations + +from typing import Protocol, runtime_checkable + + +def provider_key(name: str) -> str: + """Key chuẩn cho API key của một provider.""" + return f"provider:{name}" + + +@runtime_checkable +class SecretStore(Protocol): + """Đọc/ghi bí mật. Cài đặt thật: ``KeyringAdapter`` (R02-T04).""" + + def get(self, key: str) -> str | None: + """Giá trị của ``key``, hoặc None nếu chưa có. + + Không được ném lỗi khi thiếu key — thiếu là chuyện bình thường (người + dùng chưa nhập API key), không phải sự cố. + """ + ... + + def set(self, key: str, value: str) -> None: + """Lưu ``value``. Ghi đè nếu key đã tồn tại.""" + ... + + def delete(self, key: str) -> None: + """Xoá ``key``. Không có sẵn thì im lặng bỏ qua, không ném lỗi.""" + ... + + def has(self, key: str) -> bool: + """Có key này chưa — dùng cho màn Cài đặt hiển thị trạng thái mà không + cần đọc chính giá trị bí mật ra.""" + ... diff --git a/tests/test_no_ignored_source.py b/tests/test_no_ignored_source.py new file mode 100644 index 0000000..70946c9 --- /dev/null +++ b/tests/test_no_ignored_source.py @@ -0,0 +1,91 @@ +"""Không file mã nguồn nào được nằm ngoài repo vì `.gitignore`. + +Bài này sinh ra từ một lỗi thật, mất hai ngày mới lộ: + +``.gitignore`` dòng 31 ghi ``secrets/`` — mẫu **không neo**, nên git bỏ qua +mọi thư mục tên ``secrets`` ở mọi độ sâu, kể cả ``infrastructure/secrets/`` +vốn là **mã nguồn**. Ba file trong đó chưa bao giờ lên repo. Máy người viết +vẫn chạy 150 test xanh, nhưng ai clone sạch về thì 4 file test đỏ ngay lúc +thu thập. + +Trên máy đã có file thì không cách nào nhận ra: ``pytest`` đọc đĩa, không đọc +git. Nên phải hỏi thẳng git. +""" +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent + +#: Thư mục chứa mã nguồn của ứng dụng — file .py ở đây bắt buộc phải vào repo. +SOURCE_DIRS = ["domain", "application", "infrastructure", "presentation", + "adapters", "core", "ui", "providers", "scripts", "tools", "tests"] + + +def _git(*args: str) -> str: + out = subprocess.run(["git", *args], cwd=REPO, capture_output=True, + text=True, encoding="utf-8", errors="replace") + return out.stdout + + +def test_khong_file_py_nao_bi_gitignore_nuot(): + """File .py có trên đĩa nhưng git không thấy — vừa chưa theo dõi, vừa bị + bỏ qua. Đó chính là hình dạng của lỗi ``secrets/``.""" + existing = [] + for d in SOURCE_DIRS: + root = REPO / d + if root.is_dir(): + existing.append(d) + assert existing, "không thấy thư mục mã nguồn nào — kiểm lại SOURCE_DIRS" + + ignored = _git("ls-files", "--others", "--ignored", "--exclude-standard", + "--", *existing).splitlines() + ignored_py = [p for p in ignored + if p.endswith(".py") and "__pycache__" not in p] + + assert not ignored_py, ( + "File mã nguồn bị .gitignore nuốt — clone sạch sẽ thiếu:\n " + + "\n ".join(ignored_py) + + "\nChạy `git check-ignore -v ` để biết dòng nào gây ra." + ) + + +def test_khong_file_py_nao_bi_bo_quen_chua_theo_doi(): + """Chưa bị ignore nhưng cũng chưa `git add` — quên, không phải cố ý.""" + untracked = _git("ls-files", "--others", "--exclude-standard").splitlines() + forgotten = [p for p in untracked + if p.endswith(".py") + and p.split("/")[0] in SOURCE_DIRS + and "__pycache__" not in p] + + assert not forgotten, ( + "File mã nguồn chưa được git add — clone sạch sẽ thiếu:\n " + + "\n ".join(forgotten) + ) + + +def test_moi_module_duoc_import_deu_co_trong_repo(): + """Bắt theo hướng ngược: đi từ những gì code THỰC SỰ import. + + Hai bài trên quét theo thư mục; bài này bắt cả trường hợp file nằm ngoài + danh sách đó mà vẫn được import. + """ + tracked = set(_git("ls-files").splitlines()) + missing = [] + for d in ("domain", "application", "infrastructure", "adapters"): + root = REPO / d + if not root.is_dir(): + continue + for f in root.rglob("*.py"): + rel = f.relative_to(REPO).as_posix() + if "__pycache__" in rel: + continue + if rel not in tracked: + missing.append(rel) + + assert not missing, ( + "Module thuộc kiến trúc mới nhưng không có trong repo:\n " + + "\n ".join(missing) + )