diff --git a/adapters/__init__.py b/adapters/__init__.py new file mode 100644 index 0000000..c84fa5b --- /dev/null +++ b/adapters/__init__.py @@ -0,0 +1,12 @@ +"""adapters/ — Adapter riêng cho Qt (clock, thread, timer). + +Kế hoạch gốc đặt tên thư mục này là ``platform/``. Không dùng được: chạy +bất kỳ script nào từ thư mục gốc repo (``python tools/...``, +``python scripts/...``) thì ``platform/`` **che khuất module ``platform`` +của thư viện chuẩn**, và ``import keyring`` chết ngay với +``AttributeError: module 'platform' has no attribute 'system'``. +Repo có 26 script chạy đúng kiểu đó. + +Đổi tên là cách duy nhất chắc chắn — không thể bắt mọi người nhớ "đừng bao +giờ chạy python từ thư mục gốc". +""" diff --git a/platform/qt/__init__.py b/adapters/qt/__init__.py similarity index 100% rename from platform/qt/__init__.py rename to adapters/qt/__init__.py diff --git a/infrastructure/persistence/json/atomic_json_file.py b/infrastructure/persistence/json/atomic_json_file.py new file mode 100644 index 0000000..9f2a516 --- /dev/null +++ b/infrastructure/persistence/json/atomic_json_file.py @@ -0,0 +1,102 @@ +"""Ghi JSON kiểu không-hỏng-file — R02-T01. + +Vấn đề đang có: ``config.py::save()`` gọi thẳng ``path.write_text(...)``. Hàm +đó mở file, cắt cụt về 0 byte, rồi mới ghi nội dung mới. Mất điện, tắt máy, hay +process bị kill đúng khoảng giữa thì file cấu hình còn lại **rỗng hoặc ghi dở** +— và người dùng mất toàn bộ cấu hình. + +Cách làm ở đây theo đúng thứ tự bắt buộc: + +1. Ghi vào file tạm cùng thư mục (phải cùng ổ đĩa thì bước 3 mới nguyên tử) +2. ``flush()`` + ``os.fsync()`` — ép dữ liệu xuống đĩa thật, không nằm trong + bộ đệm của hệ điều hành +3. ``os.replace()`` — nguyên tử trên cả Windows lẫn POSIX + +Bất kỳ lúc nào chết giữa chừng, file đích vẫn là **bản cũ nguyên vẹn**. Không +bao giờ có trạng thái ghi dở. + +Phần đọc có chính sách phục hồi: file hỏng thì giữ lại thành ``.bad`` để còn +cứu tay, rồi trả về giá trị mặc định — hỏng cấu hình không được chặn khởi động, +đúng như ``config.py`` hiện tại đang làm. +""" +from __future__ import annotations + +import json +import os +import tempfile +from datetime import datetime +from pathlib import Path +from typing import Any + + +class AtomicJsonFile: + """Một file JSON, đọc ghi an toàn. + + >>> f = AtomicJsonFile(Path("cau_hinh.json")) + >>> f.write({"theme": "dark"}) + >>> f.read(default={}) + {'theme': 'dark'} + """ + + def __init__(self, path: Path, *, indent: int = 2): + self.path = Path(path) + self.indent = indent + + # ---- đọc ------------------------------------------------------------ + def read(self, default: Any = None) -> Any: + """Nội dung file, hoặc ``default`` nếu chưa có / hỏng. + + Không ném lỗi. File hỏng được đổi tên thành ``.bad-`` + rồi mới trả mặc định — hỏng thì cứu được, chứ đừng ghi đè im lặng. + """ + if not self.path.exists(): + return default + try: + return json.loads(self.path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError): + self._quarantine() + return default + except OSError: + # Không đọc được (khoá file, mất quyền) — KHÔNG cách ly, vì file + # có thể vẫn tốt nguyên. + return default + + def _quarantine(self) -> Path | None: + stamp = datetime.now().strftime("%Y%m%d-%H%M%S") + target = self.path.with_suffix(self.path.suffix + f".bad-{stamp}") + try: + os.replace(self.path, target) + return target + except OSError: + return None + + # ---- ghi ------------------------------------------------------------ + def write(self, data: Any) -> None: + """Ghi ``data``. Hoặc thành công trọn vẹn, hoặc file cũ còn nguyên.""" + self.path.parent.mkdir(parents=True, exist_ok=True) + text = json.dumps(data, indent=self.indent, ensure_ascii=False) + + # File tạm phải nằm CÙNG thư mục: os.replace chỉ nguyên tử trong cùng + # một hệ thống tệp. Để ở %TEMP% là có thể rơi sang ổ khác và biến + # thành copy + delete — mất luôn tính nguyên tử. + fd, tmp_name = tempfile.mkstemp( + dir=str(self.path.parent), prefix=f".{self.path.name}.", suffix=".tmp") + tmp = Path(tmp_name) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(text) + f.flush() + os.fsync(f.fileno()) # xuống đĩa thật, không chỉ vào bộ đệm + os.replace(tmp, self.path) # nguyên tử + except BaseException: + # Kể cả KeyboardInterrupt/SystemExit cũng phải dọn file tạm, đừng + # để rác .tmp nằm lại cạnh file cấu hình. + tmp.unlink(missing_ok=True) + raise + + # ---- tiện ích ------------------------------------------------------- + def exists(self) -> bool: + return self.path.exists() + + def __repr__(self) -> str: + return f"AtomicJsonFile({self.path})" diff --git a/platform/__init__.py b/platform/__init__.py deleted file mode 100644 index 1b2487c..0000000 --- a/platform/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""platform/ — Adapter riêng cho Qt (clock, thread, timer).""" diff --git a/tests/test_atomic_json.py b/tests/test_atomic_json.py new file mode 100644 index 0000000..faaa2c9 --- /dev/null +++ b/tests/test_atomic_json.py @@ -0,0 +1,105 @@ +"""AtomicJsonFile — R02-T01. Test tiêm lỗi, đúng như cột nghiệm thu của plan.md. + +Cách kiểm: cắt ngang giữa lúc ghi rồi khẳng định file cũ **còn nguyên**. Nếu +chỉ test "ghi rồi đọc lại thấy đúng" thì `path.write_text()` cũ cũng qua — mà +đó chính là thứ ta đang thay. +""" +from __future__ import annotations + +import json +import os + +import pytest + +from cowork_local.infrastructure.persistence.json.atomic_json_file import AtomicJsonFile + + +def test_ghi_roi_doc_lai(tmp_path): + f = AtomicJsonFile(tmp_path / "cau_hinh.json") + f.write({"theme": "dark", "ngôn ngữ": "vi"}) + assert f.read() == {"theme": "dark", "ngôn ngữ": "vi"} + + +def test_chua_co_file_thi_tra_mac_dinh(tmp_path): + f = AtomicJsonFile(tmp_path / "chua-ton-tai.json") + assert f.read(default={"theme": "dark"}) == {"theme": "dark"} + assert f.exists() is False + + +def test_chet_giua_luc_ghi_thi_file_cu_con_nguyen(tmp_path, monkeypatch): + """Lõi của R02-T01. + + Giả lập mất điện đúng lúc: cho ``os.replace`` ném lỗi. Đây là bước cuối + cùng, tức là dữ liệu mới đã nằm trong file tạm rồi — nếu cài đặt sai theo + kiểu ghi đè thẳng, file đích lúc này đã hỏng. + """ + path = tmp_path / "cau_hinh.json" + f = AtomicJsonFile(path) + f.write({"phiên bản": 1, "quan trọng": "đừng mất"}) + + def no_dien(*args, **kwargs): + raise OSError("mô phỏng mất điện") + + monkeypatch.setattr(os, "replace", no_dien) + with pytest.raises(OSError): + f.write({"phiên bản": 2}) + + # bản cũ phải còn y nguyên + assert f.read() == {"phiên bản": 1, "quan trọng": "đừng mất"} + + +def test_khong_de_lai_rac_tmp_khi_ghi_hong(tmp_path, monkeypatch): + path = tmp_path / "cau_hinh.json" + f = AtomicJsonFile(path) + f.write({"a": 1}) + + monkeypatch.setattr(os, "replace", lambda *a, **k: (_ for _ in ()).throw(OSError("x"))) + with pytest.raises(OSError): + f.write({"a": 2}) + + con_lai = [p.name for p in tmp_path.iterdir()] + assert con_lai == ["cau_hinh.json"], f"còn rác: {con_lai}" + + +def test_file_hong_thi_cach_ly_va_tra_mac_dinh(tmp_path): + """Hỏng cấu hình không được chặn khởi động — giữ đúng hành vi config.py + hiện tại, nhưng thêm phần giữ lại bản hỏng để còn cứu.""" + path = tmp_path / "cau_hinh.json" + path.write_text("{ đây không phải json", encoding="utf-8") + f = AtomicJsonFile(path) + + assert f.read(default={"theme": "dark"}) == {"theme": "dark"} + assert not path.exists(), "file hỏng phải được dời đi" + bad = list(tmp_path.glob("*.bad-*")) + assert len(bad) == 1, "phải giữ lại bản hỏng để cứu tay" + assert "đây không phải json" in bad[0].read_text(encoding="utf-8") + + +def test_ghi_de_nhieu_lan_van_dung(tmp_path): + f = AtomicJsonFile(tmp_path / "dem.json") + for i in range(20): + f.write({"lần": i}) + assert f.read() == {"lần": 19} + assert list(tmp_path.glob("*.tmp")) == [] + + +def test_giu_nguyen_tieng_viet_khong_escape(tmp_path): + """config.py hiện dùng ensure_ascii=False — giữ nguyên để file đọc được + bằng mắt và git diff không thành một đống \\uXXXX.""" + path = tmp_path / "vi.json" + AtomicJsonFile(path).write({"tên": "Nguyễn Văn Đức"}) + raw = path.read_text(encoding="utf-8") + assert "Nguyễn Văn Đức" in raw + assert "\\u" not in raw + + +def test_tao_thu_muc_cha_neu_chua_co(tmp_path): + f = AtomicJsonFile(tmp_path / "sâu" / "hơn" / "nữa" / "c.json") + f.write({"ok": True}) + assert f.read() == {"ok": True} + + +def test_json_ghi_ra_doc_duoc_bang_thu_vien_chuan(tmp_path): + path = tmp_path / "c.json" + AtomicJsonFile(path).write({"n": [1, 2, {"m": None}]}) + assert json.loads(path.read_text(encoding="utf-8")) == {"n": [1, 2, {"m": None}]} diff --git a/tests/test_keyring_adapter.py b/tests/test_keyring_adapter.py new file mode 100644 index 0000000..c1f8fec --- /dev/null +++ b/tests/test_keyring_adapter.py @@ -0,0 +1,93 @@ +"""KeyringAdapter — R02-T04. + +Không đụng vào keyring thật của máy chạy test: tiêm một backend giả. Test mà +ghi vào Credential Manager thật thì để lại rác trên máy người khác, và trên CI +thì không có kho nào để ghi. +""" +from __future__ import annotations + +import pytest + +from cowork_local.infrastructure.secrets.keyring_adapter import KeyringAdapter +from cowork_local.infrastructure.secrets.secret_store import SecretStore, provider_key + + +class _KeyringGia: + """Đủ giống thư viện keyring để adapter dùng được.""" + + def __init__(self, hong: bool = False): + self.kho: dict[tuple[str, str], str] = {} + self.hong = hong + + def get_password(self, service, key): + if self.hong: + raise RuntimeError("kho bí mật không phản hồi") + return self.kho.get((service, key)) + + def set_password(self, service, key, value): + if self.hong: + raise RuntimeError("kho bí mật không phản hồi") + self.kho[(service, key)] = value + + def delete_password(self, service, key): + if self.hong: + raise RuntimeError("kho bí mật không phản hồi") + del self.kho[(service, key)] + + +@pytest.fixture +def store(): + a = KeyringAdapter(service="test-cowork") + a._backend = _KeyringGia() + a._available = True + return a + + +def test_khop_hop_dong_secret_store(store): + assert isinstance(store, SecretStore) + + +def test_luu_doc_xoa(store): + k = provider_key("openai") + assert store.get(k) is None + assert store.has(k) is False + + store.set(k, "sk-that-la-bi-mat") + assert store.get(k) == "sk-that-la-bi-mat" + assert store.has(k) is True + + store.delete(k) + assert store.get(k) is None + + +def test_moi_provider_mot_khoa_rieng(store): + store.set(provider_key("openai"), "khoa-openai") + store.set(provider_key("anthropic"), "khoa-anthropic") + assert store.get(provider_key("openai")) == "khoa-openai" + assert store.get(provider_key("anthropic")) == "khoa-anthropic" + + +def test_may_khong_co_kho_thi_im_lang_chu_khong_sap(): + """Linux headless và CI không có Secret Service. App vẫn phải chạy.""" + a = KeyringAdapter(service="test-cowork") + a._backend = None + a._available = False + + assert a.available is False + assert a.get("bat-ky") is None + a.set("bat-ky", "gia-tri") # không ném lỗi + a.delete("bat-ky") # không ném lỗi + assert a.has("bat-ky") is False + + +def test_kho_loi_giua_chung_thi_khong_lam_sap_app(store): + """Keyring có thể hỏng lúc đang chạy — mất DBus, người dùng khoá máy.""" + store._backend.hong = True + + assert store.get("x") is None # nuốt lỗi, trả None + store.set("x", "y") # nuốt lỗi + store.delete("x") # nuốt lỗi + + +def test_xoa_khoa_khong_ton_tai_thi_bo_qua(store): + store.delete(provider_key("chua-bao-gio-luu")) # không ném lỗi diff --git a/tests/test_no_stdlib_shadow.py b/tests/test_no_stdlib_shadow.py new file mode 100644 index 0000000..0e03dc5 --- /dev/null +++ b/tests/test_no_stdlib_shadow.py @@ -0,0 +1,59 @@ +"""Không thư mục nào ở gốc repo được trùng tên module thư viện chuẩn. + +Bài này sinh ra từ một lỗi thật: kế hoạch refactor đặt tên một tầng là +``platform/``, và ngay khi tạo thư mục đó thì mọi script chạy từ gốc repo — +``python tools/check_*.py``, ``python scripts/audit_security.py``, 26 file tất +cả — đều nạp nhầm ``platform/`` thay cho ``platform`` của Python. ``keyring`` +chết ngay với ``AttributeError: module 'platform' has no attribute 'system'``. + +Kiểm bằng tên chứ không phải bằng cách thử import: import chỉ hỏng khi có ai +đó thật sự dùng module bị che, nên nó im lặng cho tới lúc muộn. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent + +#: Không tính: đây là thư mục dữ liệu/tài liệu, không phải package Python. +NOT_PACKAGES = {".git", ".gitea", ".vibeflow-preview", "docs", "assets", + "__pycache__", ".pytest_cache", "cowork-local-gitea", + ".cowork_history", ".cowork_local"} + + +def _top_level_packages() -> list[str]: + return [d.name for d in REPO.iterdir() + if d.is_dir() and d.name not in NOT_PACKAGES + and (d / "__init__.py").exists()] + + +def test_khong_package_nao_che_khuat_thu_vien_chuan(): + stdlib = set(sys.stdlib_module_names) + clashes = [name for name in _top_level_packages() if name in stdlib] + assert not clashes, ( + "Thư mục ở gốc repo trùng tên module thư viện chuẩn: " + + ", ".join(sorted(clashes)) + + ". Chạy script từ gốc repo sẽ nạp nhầm thư mục này. Đổi tên thư mục." + ) + + +def test_import_duoc_stdlib_khi_chay_tu_goc_repo(): + """Bài trên bắt bằng tên; bài này bắt bằng hành vi thật. + + Chạy tiến trình con với thư mục làm việc là gốc repo — đúng cách 26 script + trong ``tools/`` và ``scripts/`` được gọi. + """ + import subprocess + + snippet = ( + "import platform, json, types, io\n" + "assert 'site-packages' not in platform.__file__\n" + "assert platform.system(), 'platform.system() phải trả về tên hệ điều hành'\n" + "import keyring\n" + "print('OK')\n" + ) + out = subprocess.run([sys.executable, "-c", snippet], cwd=REPO, + capture_output=True, text=True, timeout=60) + assert out.returncode == 0, out.stderr + assert "OK" in out.stdout