feat(infra): xong R02 — Settings Facade, versioning, chuyển khoá sang keyring

R02-T03 Typed Settings Facade
  Khắp nơi đang viết ctx.config.routing.get("switch_mode", "off"). Gõ sai một
  chữ thì lặng lẽ nhận mặc định, không ai biết cho tới lúc tính năng "không
  hiểu sao không chạy". ProviderSettings / RoutingSettings / SecuritySettings
  làm sai tên là lỗi ngay, và kiểu ghi rõ nên đọc là biết confirm_timeout_sec
  tính bằng giây.

  Là KHUNG NHÌN lên dict sống, không phải dataclass sao chép — sửa qua đây là
  sửa vào cấu hình, save() là xuống đĩa, khỏi sinh chuyện đồng bộ hai chiều.
  Có raw() để ai thiếu thuộc tính thì dùng tạm, đừng vòng lại config.data.

  Bắt cả trường hợp giá trị là null: file cũ hay để null, đọc ra None rồi đem
  so sánh số là vỡ.

R02-T06 Schema versioning + phục hồi
  config.json hôm nay không có số phiên bản, nên mọi thay đổi hình dạng phải
  đoán — _migrate_connectors() đoán "có khoá office nghĩa là file cũ". Giờ:
  thiếu schema_version thì coi là v1, mỗi bước là một hàm chạy tuần tự, sao
  lưu trước khi nâng, và file mới hơn app thì dùng nguyên trạng chứ không đoán
  ngược.

R02-T05 Chuyển API key sang kho bí mật
  Là bước v1→v2. Người dùng cập nhật app, mở lên, khoá cũ tự vào keyring và
  biến khỏi đĩa — có test cho đúng cảnh đó.

  Hai chỗ cố tình không làm:
    - Máy chưa có keyring: KHÔNG chuyển, giữ nguyên v1. Thà để khoá trong file
      còn hơn xoá đi rồi người dùng mất khoá mà không hiểu vì sao.
    - Giá trị "ollama" là bù nhìn (Ollama đòi có api_key nhưng bỏ qua nội
      dung), đẩy vào keyring chỉ tổ rác.

Hai chuỗi test trông giống khoá thật bị CASAN Check 1 bắt — đánh dấu
"# casan: allow" kèm lý do, đúng lối thoát đã thiết kế cho cả đội.

150 test xanh (129 + 21 mới). CASAN Check 1 sạch. File mới đều dưới 200 dòng.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nam Pham Dinh Thanh
2026-08-22 00:50:09 +09:00
co-authored by Claude Opus 5
parent a7e369e46c
commit ab0d26761f
5 changed files with 539 additions and 0 deletions
+126
View File
@@ -0,0 +1,126 @@
"""Đánh số phiên bản + chuyển API key — R02-T06 và R02-T05."""
from __future__ import annotations
import json
from cowork_local.infrastructure.config.json_config_repository import (
JsonConfigRepository,
)
from cowork_local.infrastructure.config.schema_migration import (
CURRENT_VERSION, migrate, read_version,
)
from cowork_local.tests.fakes.fake_config import FakeSecretStore
DEFAULTS = {
"active_provider": "openai",
"providers": {"openai": {"base_url": "u", "model": "m", "api_key": ""},
"ollama": {"base_url": "u", "model": "m", "api_key": "ollama"}},
"theme": "dark", "language": "vi", "ms365": {},
}
def _repo(tmp_path, secrets=None):
return JsonConfigRepository(tmp_path / "config.json", secrets=secrets,
defaults=DEFAULTS, env_overrides=lambda d: d)
def test_thieu_so_phien_ban_thi_coi_la_v1():
assert read_version({}) == 1
assert read_version({"schema_version": 2}) == 2
assert read_version({"schema_version": "hỏng"}) == 1
def test_v1_sang_v2_chuyen_khoa_vao_kho_bi_mat():
secrets = FakeSecretStore()
data = {"providers": {"openai": {"api_key": "sk-cu-nam-trong-file"}}} # casan: allow - du lieu test
out, changed = migrate(data, secrets=secrets)
assert changed is True
assert out["schema_version"] == 2
assert out["providers"]["openai"]["api_key"] == ""
assert secrets.get("provider:openai") == "sk-cu-nam-trong-file"
def test_khong_day_gia_tri_bu_nhin_cua_ollama_vao_kho():
"""Ollama đòi có api_key nhưng bỏ qua nội dung — đẩy vào keyring chỉ tổ rác."""
secrets = FakeSecretStore()
out, _ = migrate({"providers": {"ollama": {"api_key": "ollama"}}}, secrets=secrets)
assert secrets.get("provider:ollama") is None
assert out["providers"]["ollama"]["api_key"] == "ollama"
def test_khong_co_kho_bi_mat_thi_KHONG_chuyen():
"""Thà để khoá nằm nguyên trong file còn hơn xoá đi rồi người dùng mất
khoá mà không hiểu vì sao."""
data = {"providers": {"openai": {"api_key": "sk-quy-gia"}}}
out, changed = migrate(data, secrets=None)
assert changed is False
assert out["providers"]["openai"]["api_key"] == "sk-quy-gia"
assert read_version(out) == 1 # giữ v1, lần sau có keyring sẽ chuyển
def test_da_v2_thi_khong_lam_gi_them():
out, changed = migrate({"schema_version": 2}, secrets=FakeSecretStore())
assert changed is False
def test_file_moi_hon_app_thi_dung_nguyen_trang():
"""App cũ gặp file mới. Đoán ngược là cách nhanh nhất để mất dữ liệu."""
data = {"schema_version": 99, "thu_gi_do_tuong_lai": True}
out, changed = migrate(data, secrets=FakeSecretStore())
assert changed is False
assert out == data
def test_sao_luu_truoc_khi_nang_cap(tmp_path):
path = tmp_path / "config.json"
path.write_text(json.dumps({"providers": {"openai": {"api_key": "sk-x"}}}),
encoding="utf-8")
migrate(json.loads(path.read_text(encoding="utf-8")),
secrets=FakeSecretStore(), path=path)
backups = list(tmp_path.glob("*.bak"))
assert len(backups) == 1, "phải có bản sao lưu để còn đường lùi"
assert "sk-x" in backups[0].read_text(encoding="utf-8")
# ---- nối vào repository ----------------------------------------------------
def test_repository_tu_chuyen_khoa_khi_mo_file_cu(tmp_path):
"""Cảnh thật: người dùng cập nhật app, mở lên, khoá cũ tự vào keyring."""
(tmp_path / "config.json").write_text(
json.dumps({"providers": {"openai": {"api_key": "sk-tu-ban-cu"}}}), # casan: allow - du lieu test
encoding="utf-8")
secrets = FakeSecretStore()
cfg = _repo(tmp_path, secrets)
# đọc ra vẫn thấy khoá...
assert cfg.provider_conf("openai")["api_key"] == "sk-tu-ban-cu"
# ...nhưng trên đĩa thì hết
raw = (tmp_path / "config.json").read_text(encoding="utf-8")
assert "sk-tu-ban-cu" not in raw
assert json.loads(raw)["schema_version"] == CURRENT_VERSION
# và có bản sao lưu
assert len(list(tmp_path.glob("*.bak"))) == 1
def test_mo_lai_lan_hai_khong_chuyen_lai(tmp_path):
(tmp_path / "config.json").write_text(
json.dumps({"providers": {"openai": {"api_key": "sk-x"}}}), encoding="utf-8")
secrets = FakeSecretStore()
_repo(tmp_path, secrets)
so_ban_sao = len(list(tmp_path.glob("*.bak")))
_repo(tmp_path, secrets)
assert len(list(tmp_path.glob("*.bak"))) == so_ban_sao, "không nâng cấp lại"
def test_save_luon_ghi_so_phien_ban(tmp_path):
cfg = _repo(tmp_path)
cfg.save()
raw = json.loads((tmp_path / "config.json").read_text(encoding="utf-8"))
assert raw["schema_version"] == CURRENT_VERSION