V10.516 補 Webcrumbs host data 授權回歸測試
All checks were successful
CD Pipeline / deploy (push) Successful in 1m6s

This commit is contained in:
OoO
2026-05-31 21:09:27 +08:00
parent 353465d38a
commit ea1043ef7c
3 changed files with 153 additions and 1 deletions

View File

@@ -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 # 用於模板顯示

View File

@@ -13,6 +13,7 @@
## 📅 詳細更新日誌 (考古存檔)
### 2026-05-31Webcrumbs 共用 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 撐成大檔。

View File

@@ -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}