diff --git a/config.py b/config.py index 11ceed8..c520dbf 100644 --- a/config.py +++ b/config.py @@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.515" +SYSTEM_VERSION = "V10.516" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/docs/memory/history_logs.md b/docs/memory/history_logs.md index 637654c..eda094b 100644 --- a/docs/memory/history_logs.md +++ b/docs/memory/history_logs.md @@ -13,6 +13,7 @@ ## 📅 詳細更新日誌 (考古存檔) ### 2026-05-31:Webcrumbs 共用 UI Runtime 與市場情報 writer approval +- **V10.516 Webcrumbs host data 授權回歸測試**: 新增 Flask runtime 測試,直接重現 `DISABLE_LOGIN=true` 下 `/api/webcrumbs/marketplace-host-data` 必須回 401 且不得組裝真實 host data;同時鎖住 `X-Internal-Key` 與登入 session 可取得敏感 seed、未授權 `/webcrumbs` 只注入 `auth_required` 空狀態,避免後續改動再讓 public 診斷頁洩漏 MOMO/PChome SKU 與價差。 - **V10.515 Webcrumbs host data 硬性授權**: 發現正式環境一般 `@login_required` 可能因 `DISABLE_LOGIN=true` 放行後,為 `/api/webcrumbs/marketplace-host-data` 與 `/webcrumbs` inline seed 加上獨立授權判斷;只有登入 session 或 `X-Internal-Key` 可取得真實 SKU/價差,未授權時只回 `auth_required` 空狀態,避免 public runtime 診斷頁洩漏正式比價資料。 - **V10.514 Webcrumbs host data read-only API**: 新增登入後 `/api/webcrumbs/marketplace-host-data`,回傳與 `/webcrumbs` inline seed 相同的 MOMO/PChome exact 價差 host data contract,供 plugin、QA 與其他專案 proxy 驗證;API boundary 明確標示 `writes_database=false`、`calls_llm=false`、`fetches_external=false`,且只允許 exact / total_price / price_alert_exact 摘要。 - **V10.513 外部工具診斷 payload 模組化**: 新增 `services/external_tool_payload_service.py`,把 Metabase/Grist/Webcrumbs 診斷頁 payload 與 Webcrumbs host data 組裝從 `routes/system_public_routes.py` 移出;route 保持 HTTP glue 與 asset proxy,避免 Webcrumbs live plugin 與比價 host data 持續把公開系統 route 撐成大檔。 diff --git a/tests/test_webcrumbs_host_data_runtime_auth.py b/tests/test_webcrumbs_host_data_runtime_auth.py new file mode 100644 index 0000000..f77fbc7 --- /dev/null +++ b/tests/test_webcrumbs_host_data_runtime_auth.py @@ -0,0 +1,151 @@ +from flask import Flask + +import auth +from routes import system_public_routes as routes + + +def _make_app(monkeypatch): + app = Flask(__name__) + app.secret_key = "test-secret" + app.register_blueprint(routes.system_public_bp) + monkeypatch.setattr(auth, "DISABLE_LOGIN", True) + return app + + +def test_webcrumbs_host_data_api_blocks_when_login_disabled_without_internal_key(monkeypatch): + app = _make_app(monkeypatch) + monkeypatch.setenv("INTERNAL_API_KEY", "test-internal-key") + + called = {"value": False} + + def fail_if_called(limit=5): + called["value"] = True + return {} + + monkeypatch.setattr(routes, "build_webcrumbs_seed_data", fail_if_called) + + response = app.test_client().get("/api/webcrumbs/marketplace-host-data") + + assert response.status_code == 401 + payload = response.get_json() + assert payload["success"] is False + assert payload["error"] == "auth_required" + assert payload["boundary"]["auth_required"] is True + assert payload["boundary"]["writes_database"] is False + assert payload["boundary"]["calls_llm"] is False + assert payload["boundary"]["fetches_external"] is False + assert called["value"] is False + + +def test_webcrumbs_host_data_api_allows_internal_key_when_login_disabled(monkeypatch): + app = _make_app(monkeypatch) + monkeypatch.setenv("INTERNAL_API_KEY", "test-internal-key") + + seed_payload = { + "marketSnapshot": [ + { + "name": "SKU-1 exact price alert", + "price": 250, + "change_pct": 12.5, + "freshness_status": "price_alert_exact", + } + ], + "aiCandidate": { + "ticker": "SKU-1", + "name": "exact alert", + "thesis": "MOMO NT$300 vs PChome NT$250", + }, + "metadata": { + "source": "competitor_intel_repository", + "writes_database": False, + "calls_llm": False, + "fetches_external": False, + }, + } + captured = {} + + def fake_build_webcrumbs_seed_data(limit=5): + captured["limit"] = limit + return seed_payload + + monkeypatch.setattr(routes, "build_webcrumbs_seed_data", fake_build_webcrumbs_seed_data) + + response = app.test_client().get( + "/api/webcrumbs/marketplace-host-data?limit=99", + headers={"X-Internal-Key": "test-internal-key"}, + ) + + assert response.status_code == 200 + payload = response.get_json() + assert captured["limit"] == 8 + assert payload["success"] is True + assert payload["data"] == seed_payload + assert payload["metadata"]["source"] == "competitor_intel_repository" + assert payload["boundary"]["auth_required"] is True + assert payload["boundary"]["writes_database"] is False + assert payload["boundary"]["calls_llm"] is False + assert payload["boundary"]["fetches_external"] is False + + +def test_webcrumbs_page_uses_auth_required_seed_when_login_disabled_without_sensitive_access(monkeypatch): + app = _make_app(monkeypatch) + monkeypatch.setenv("INTERNAL_API_KEY", "test-internal-key") + + captured = {} + + def fake_build_external_tool_payload(kind, include_host_data=True): + captured["kind"] = kind + captured["include_host_data"] = include_host_data + return { + "key": kind, + "plugin_seed_data": { + "marketSnapshot": [ + { + "name": "MOMO/PChome host data requires authentication", + "freshness_status": "auth_required", + } + ], + "aiCandidate": { + "name": "MOMO/PChome host data locked", + "release_status": "blocked", + }, + }, + } + + def fake_render_template(template_name, **context): + return str(context["tool"]["plugin_seed_data"]) + + monkeypatch.setattr(routes, "build_external_tool_payload", fake_build_external_tool_payload) + monkeypatch.setattr(routes, "render_template", fake_render_template) + + response = app.test_client().get("/webcrumbs") + + body = response.get_data(as_text=True) + assert response.status_code == 200 + assert captured == {"kind": "webcrumbs", "include_host_data": False} + assert "auth_required" in body + assert "MOMO NT$" not in body + assert "PChome NT$" not in body + + +def test_webcrumbs_page_allows_sensitive_seed_for_logged_in_session(monkeypatch): + app = _make_app(monkeypatch) + + captured = {} + + def fake_build_external_tool_payload(kind, include_host_data=True): + captured["kind"] = kind + captured["include_host_data"] = include_host_data + return {"key": kind, "plugin_seed_data": {"release_status": "review_required"}} + + monkeypatch.setattr(routes, "build_external_tool_payload", fake_build_external_tool_payload) + monkeypatch.setattr(routes, "render_template", lambda template_name, **context: "ok") + + client = app.test_client() + with client.session_transaction() as session: + session["logged_in"] = True + + response = client.get("/webcrumbs") + + assert response.status_code == 200 + assert captured == {"kind": "webcrumbs", "include_host_data": True}