From a7e369e46c88036ff81b3c135c429fb8b5f67478 Mon Sep 17 00:00:00 2001 From: Nam Pham Dinh Thanh Date: Sat, 22 Aug 2026 00:37:43 +0900 Subject: [PATCH] =?UTF-8?q?feat(infra):=20JsonConfigRepository=20=E2=80=94?= =?UTF-8?q?=20R02-T02,=20hi=E1=BB=87n=20th=E1=BB=B1c=20=C4=91=C6=B0?= =?UTF-8?q?=E1=BB=9Dng=20A=20=C4=91=C3=A3=20ch=E1=BB=91t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thay cho config.py::AppConfig. Hai khác biệt về hành vi, cả hai đều là thứ muốn có; mọi thứ còn lại giữ y nguyên vì đây là refactor. 1. Ghi qua AtomicJsonFile — mất điện giữa lúc lưu không còn làm hỏng cấu hình. Có test riêng ở tầng này chứ không chỉ dựa vào test của AtomicJsonFile. 2. Đường A (chốt 21/08): provider_conf() đọc khoá từ SecretStore rồi ghép vào dict trả về, còn set_api_key() ghi khoá vào kho và để chuỗi rỗng trên đĩa. Kết quả: 5 nơi đang đọc conf["api_key"] không sửa dòng nào — 3 trong đó thuộc providers/ của Team Duy — mà file JSON vẫn sạch để qua CASAN Check 1. Hai test riêng cho đúng hai vế đó. provider_conf() trả BẢN SAO. Nếu trả tham chiếu thì khoá vừa ghép vào sẽ lẫn ngược vào self.data rồi theo save() xuống đĩa — đúng thứ đường A phải tránh. Có test cho chuyện này. secrets=None thì lùi về hành vi cũ (khoá nằm trong file). Cần vậy để chuyển dần ở R02-T05 chứ không phải đổi một phát cả app, và để máy không có keyring vẫn chạy. Giữ nguyên có chủ đích: trộn sâu với mặc định, biến môi trường, và ms365.unlocked không bao giờ chạm đĩa — mỗi thứ một test. _deep_merge chép lại 6 dòng thay vì import từ config.py: file này phải sống được sau khi config.py biến mất. 129 test xanh (119 + 10 mới). CASAN Check 1 sạch. File mới: 188/102/86 dòng, đều dưới ngưỡng 400. Co-Authored-By: Claude Opus 5 (1M context) --- .../config/json_config_repository.py | 188 ++++++++++++++++++ tests/test_config_repository.py | 155 +++++++++++++++ 2 files changed, 343 insertions(+) create mode 100644 infrastructure/config/json_config_repository.py create mode 100644 tests/test_config_repository.py diff --git a/infrastructure/config/json_config_repository.py b/infrastructure/config/json_config_repository.py new file mode 100644 index 0000000..7340840 --- /dev/null +++ b/infrastructure/config/json_config_repository.py @@ -0,0 +1,188 @@ +"""ConfigRepository chạy trên file JSON — R02-T02. + +Thay cho ``config.py::AppConfig``. Hai khác biệt duy nhất về hành vi, cả hai +đều là thứ ta muốn: + +1. Ghi qua :class:`AtomicJsonFile` — mất điện giữa lúc lưu không còn làm hỏng + cấu hình (R02-T01). +2. API key đọc từ :class:`SecretStore` rồi **ghép vào** dict do + ``provider_conf()`` trả về — đúng đường A đã chốt 21/08 + (``docs/refactor/GammaTeam_decisions.md``). Nhờ vậy 5 nơi đang đọc + ``conf["api_key"]`` không phải sửa dòng nào, trong đó 3 nơi thuộc Team Duy. + +Mọi thứ còn lại giữ nguyên có chủ đích: trộn sâu với mặc định, đọc biến môi +trường, ``ms365.unlocked`` không bao giờ chạm đĩa. Đây là refactor — hành vi +nhìn từ ngoài phải y hệt. +""" +from __future__ import annotations + +import copy +from pathlib import Path +from typing import Any, Dict + +from ..persistence.json.atomic_json_file import AtomicJsonFile +from ..secrets.secret_store import SecretStore, provider_key + + +class JsonConfigRepository: + """Cấu hình đọc/ghi từ một file JSON, bí mật để trong ``SecretStore``. + + ``secrets`` để None nghĩa là không có kho bí mật — mọi thứ vẫn chạy, chỉ + là ``api_key`` lấy nguyên từ file như trước. Cần vậy để chuyển dần + (R02-T05) chứ không phải đổi một phát cả app. + """ + + def __init__(self, path: Path, *, secrets: SecretStore | None = None, + defaults: Dict[str, Any] | None = None, + env_overrides=None): + self._file = AtomicJsonFile(path) + self._secrets = secrets + # Lấy thẳng từ config.py để hai bên không lệch nhau trong lúc chuyển. + if defaults is None or env_overrides is None: + from ... import config as legacy + defaults = defaults if defaults is not None else legacy.DEFAULT_CONFIG + env_overrides = env_overrides or legacy._apply_env_overrides + self._defaults = defaults + self._env_overrides = env_overrides + self.data: Dict[str, Any] = self._load() + + # ---- nạp ------------------------------------------------------------ + def _load(self) -> Dict[str, Any]: + merged = copy.deepcopy(self._defaults) + stored = self._file.read(default=None) + if isinstance(stored, dict): + merged = _deep_merge(merged, stored) + merged = self._env_overrides(merged) + # Trạng thái mở khoá ms365 chỉ tồn tại lúc chạy — mỗi lần mở app đều + # bắt đầu ở trạng thái khoá, không tin giá trị đọc từ đĩa. + merged.setdefault("ms365", {})["unlocked"] = False + return merged + + def reload(self) -> None: + self.data = self._load() + + # ---- provider -------------------------------------------------------- + @property + def active_provider(self) -> str: + return self.data.get("active_provider", "") + + def set_active_provider(self, name: str) -> None: + self.data["active_provider"] = name + + def provider_conf(self, name: str | None = None) -> Dict[str, Any]: + """Cấu hình provider, có sẵn ``api_key``. + + Trả về BẢN SAO: chỗ gọi sửa dict này thì không được âm thầm ghi ngược + vào cấu hình — và quan trọng hơn, khoá vừa ghép vào không được lẫn + ngược vào ``self.data`` rồi theo ``save()`` xuống đĩa. + """ + name = name or self.active_provider + conf = dict(self.data.get("providers", {}).get(name, {})) + if self._secrets is not None: + stored = self._secrets.get(provider_key(name)) + if stored: + conf["api_key"] = stored + return conf + + def set_api_key(self, name: str, value: str) -> None: + """Lưu khoá vào kho bí mật, và xoá khỏi cấu hình trên đĩa. + + Đây là nửa còn lại của đường A: dict *đọc ra* vẫn có ``api_key``, + nhưng file JSON *trên đĩa* thì không — điều kiện để qua CASAN Check 1. + """ + if self._secrets is not None: + self._secrets.set(provider_key(name), value) + self.data.setdefault("providers", {}).setdefault(name, {})["api_key"] = "" + else: + self.data.setdefault("providers", {}).setdefault(name, {})["api_key"] = value + + # ---- đường dẫn ------------------------------------------------------- + @property + def shared_dir(self) -> str: + return self.data.get("shared_dir", "") + + def history_dir(self) -> Path: + rt = self.data.get("_project_history_dir") + if rt: + return Path(rt) + custom = (self.data.get("history", {}).get("custom_dir") or "").strip() + if custom: + return Path(custom).expanduser() + from ...config import CONFIG_DIR + return CONFIG_DIR / "history" + + def cowork_output_dir(self) -> Path: + custom = (self.data.get("cowork", {}).get("output_dir") or "").strip() + if custom: + return Path(custom).expanduser() + from ... import paths + from ...config import CONFIG_DIR + root = paths.primary_onedrive_root() + if root is not None: + return root / "CoworkLocal" / "output" + return CONFIG_DIR / "output" / "cowork" + + # ---- giao diện ------------------------------------------------------- + @property + def theme(self) -> str: + return self.data.get("theme", "dark") + + def set_theme(self, value: str) -> None: + self.data["theme"] = value + + @property + def language(self) -> str: + return self.data.get("language", "vi") + + def set_language(self, value: str) -> None: + self.data["language"] = value + + # ---- nhóm cấu hình --------------------------------------------------- + @property + def routing(self) -> Dict[str, Any]: + return self.data.setdefault("routing", {}) + + @property + def auth(self) -> Dict[str, Any]: + return self.data.setdefault("auth", {}) + + @property + def agent_security(self) -> Dict[str, Any]: + return self.data.setdefault("agent_security", {}) + + @property + def tools_disabled(self) -> list[str]: + return list(self.data.get("tools_disabled", [])) + + def set_tool_enabled(self, name: str, enabled: bool) -> None: + disabled = list(self.data.get("tools_disabled", [])) + if enabled: + disabled = [t for t in disabled if t != name] + elif name not in disabled: + disabled.append(name) + self.data["tools_disabled"] = disabled + + # ---- ghi ------------------------------------------------------------- + def save(self) -> None: + """Ghi nguyên tử. Không bao giờ để lộ trạng thái mở khoá ms365.""" + to_write = self.data + if self.data.get("ms365", {}).get("unlocked"): + to_write = copy.deepcopy(self.data) + to_write["ms365"]["unlocked"] = False + to_write.pop("_project_history_dir", None) + self._file.write(to_write) + + +def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]: + """Trộn sâu — giống hệt ``config.py::_deep_merge``. + + Không import lại từ đó vì file này phải sống được sau khi ``config.py`` + biến mất; giữ bản sao 6 dòng còn hơn giữ một sợi dây phụ thuộc. + """ + out = copy.deepcopy(base) + for key, value in (override or {}).items(): + if isinstance(value, dict) and isinstance(out.get(key), dict): + out[key] = _deep_merge(out[key], value) + else: + out[key] = value + return out diff --git a/tests/test_config_repository.py b/tests/test_config_repository.py new file mode 100644 index 0000000..70598cd --- /dev/null +++ b/tests/test_config_repository.py @@ -0,0 +1,155 @@ +"""JsonConfigRepository — R02-T02. + +Hai nhóm bài: + * **round-trip** — ghi rồi nạp lại phải ra đúng thứ đã ghi (cột nghiệm thu + của plan.md cho ngày 22-23/08) + * **đường A** — ``provider_conf()`` vẫn trả ``api_key``, nhưng file JSON + trên đĩa thì không có, để qua CASAN Check 1 +""" +from __future__ import annotations + +import json + +import pytest + +from cowork_local.infrastructure.config.config_repository import ConfigRepository +from cowork_local.infrastructure.config.json_config_repository import ( + JsonConfigRepository, +) +from cowork_local.tests.fakes.fake_config import FakeSecretStore + +DEFAULTS = { + "active_provider": "ollama", + "providers": { + "ollama": {"base_url": "http://localhost:11434/v1", "model": "llama3", + "api_key": "ollama"}, + "openai": {"base_url": "https://api.openai.com/v1", "model": "gpt-4o-mini", + "api_key": ""}, + }, + "theme": "dark", "language": "vi", "shared_dir": "", + "routing": {"mode": "off"}, "auth": {}, "agent_security": {}, + "tools_disabled": [], "history": {}, "cowork": {}, "ms365": {}, +} + + +def _repo(tmp_path, secrets=None): + return JsonConfigRepository(tmp_path / "config.json", secrets=secrets, + defaults=DEFAULTS, env_overrides=lambda d: d) + + +def test_khop_hop_dong(tmp_path): + assert isinstance(_repo(tmp_path), ConfigRepository) + + +def test_chua_co_file_thi_dung_mac_dinh(tmp_path): + cfg = _repo(tmp_path) + assert cfg.active_provider == "ollama" + assert cfg.theme == "dark" + + +def test_round_trip(tmp_path): + cfg = _repo(tmp_path) + cfg.set_theme("light") + cfg.set_language("en") + cfg.set_active_provider("openai") + cfg.set_tool_enabled("run_command", False) + cfg.save() + + lai = _repo(tmp_path) + assert lai.theme == "light" + assert lai.language == "en" + assert lai.active_provider == "openai" + assert lai.tools_disabled == ["run_command"] + + +def test_gia_tri_luu_trong_file_trum_len_mac_dinh_nhung_giu_phan_con_thieu(tmp_path): + """Trộn sâu: file cũ thiếu khoá mới thì lấy mặc định, không mất phần cũ.""" + (tmp_path / "config.json").write_text( + json.dumps({"theme": "light", "providers": {"openai": {"model": "gpt-5"}}}), + encoding="utf-8") + cfg = _repo(tmp_path) + assert cfg.theme == "light" # từ file + assert cfg.language == "vi" # từ mặc định + assert cfg.provider_conf("openai")["model"] == "gpt-5" # từ file + assert "api.openai.com" in cfg.provider_conf("openai")["base_url"] # mặc định + + +# ---- đường A: khoá vào kho bí mật, nhưng dict vẫn có ------------------------ + +def test_provider_conf_van_tra_api_key_sau_khi_chuyen_vao_kho(tmp_path): + """Điểm mấu chốt của quyết định A: 5 nơi đọc conf['api_key'] không đổi.""" + secrets = FakeSecretStore() + cfg = _repo(tmp_path, secrets) + cfg.set_api_key("openai", "sk-that-bi-mat") + + assert cfg.provider_conf("openai")["api_key"] == "sk-that-bi-mat" + + +def test_khoa_khong_bao_gio_nam_tren_dia(tmp_path): + """Điều kiện qua CASAN Check 1.""" + secrets = FakeSecretStore() + cfg = _repo(tmp_path, secrets) + cfg.set_api_key("openai", "sk-that-bi-mat") + cfg.save() + + raw = (tmp_path / "config.json").read_text(encoding="utf-8") + assert "sk-that-bi-mat" not in raw + assert secrets.get("provider:openai") == "sk-that-bi-mat" + + +def test_sua_dict_tra_ve_khong_lam_ban_cau_hinh(tmp_path): + """provider_conf trả bản sao — nếu trả tham chiếu thì khoá vừa ghép vào sẽ + lẫn ngược vào self.data rồi theo save() xuống đĩa.""" + secrets = FakeSecretStore() + cfg = _repo(tmp_path, secrets) + cfg.set_api_key("openai", "sk-bi-mat") + + conf = cfg.provider_conf("openai") + conf["model"] = "bị sửa bậy" + cfg.save() + + raw = (tmp_path / "config.json").read_text(encoding="utf-8") + assert "bị sửa bậy" not in raw + assert "sk-bi-mat" not in raw + + +def test_khong_co_kho_bi_mat_thi_van_chay_nhu_cu(tmp_path): + """Máy không có keyring: hành vi lùi về đúng như config.py hôm nay.""" + cfg = _repo(tmp_path, secrets=None) + cfg.set_api_key("openai", "sk-nam-trong-file") + cfg.save() + + assert cfg.provider_conf("openai")["api_key"] == "sk-nam-trong-file" + raw = (tmp_path / "config.json").read_text(encoding="utf-8") + assert "sk-nam-trong-file" in raw # đúng như cũ, có đánh đổi rõ ràng + + +# ---- giữ nguyên hành vi cũ -------------------------------------------------- + +def test_ms365_unlocked_khong_bao_gio_xuong_dia(tmp_path): + cfg = _repo(tmp_path) + cfg.data["ms365"]["unlocked"] = True + cfg.save() + + raw = json.loads((tmp_path / "config.json").read_text(encoding="utf-8")) + assert raw["ms365"]["unlocked"] is False + assert cfg.data["ms365"]["unlocked"] is True # trong bộ nhớ vẫn giữ + + assert _repo(tmp_path).data["ms365"]["unlocked"] is False + + +def test_ghi_hong_giua_chung_khong_lam_mat_cau_hinh(tmp_path, monkeypatch): + """Thừa hưởng từ AtomicJsonFile — kiểm lại ở tầng này cho chắc.""" + import os + + cfg = _repo(tmp_path) + cfg.set_theme("light") + cfg.save() + + monkeypatch.setattr(os, "replace", + lambda *a, **k: (_ for _ in ()).throw(OSError("mất điện"))) + cfg.set_theme("hỏng") + with pytest.raises(OSError): + cfg.save() + + assert _repo(tmp_path).theme == "light"