fix(infra): .gitignore nuốt infrastructure/secrets/ — nhánh đỏ với mọi máy trừ máy tôi

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 <noreply@anthropic.com>
This commit is contained in:
Nam Pham Dinh Thanh
2026-08-22 21:11:42 +09:00
co-authored by Claude Opus 5
parent 8be5ce1bab
commit d74c052af3
5 changed files with 227 additions and 1 deletions
+4 -1
View File
@@ -28,7 +28,10 @@ bower_components/
.env.preview .env.preview
*.pem *.pem
*.key *.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 credentials.json
.npmrc .npmrc
.yarnrc .yarnrc
View File
+86
View File
@@ -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
+46
View File
@@ -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:<tên>"`` 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."""
...
+91
View File
@@ -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 <file>` để 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)
)