fix(txn+security): 仓储改 flush + 启动校验/兜底 + job.error 脱敏 + SSE 异常硬化

P0-1 SqlCredentialStore/save_draft 由自提交改 flush,端点/服务统一 commit
  (新增 CredentialStore.commit() 统一提交点;token 刷新落库显式提交);
  补多凭据一请求中途失败整体回滚集成测试。
P0-2 启动校验 _fernet(enc_key) 快速失败 + catch-all Exception → ErrorEnvelope;
  credential_enc_key 改 SecretStr。
P0-3 run_job 异常分类:AppError 存 code+message,其余存通用文案不泄 str(exc)。
P0-4 评审/正文 SSE 失败先发 error 事件,尾部 commit 包 try/except。
P1-4 max_version 加 FOR UPDATE 行锁消除 TOCTOU。
P1-5 scan_overdue 谓词下推 + 批量 UPDATE RETURNING。
P1-10 移除 OAuth user_code 日志。
P2 provider_deps 改调网关 build_adapter;accept_service Committable Protocol;
  CORS 白名单收窄;request_id 安全字符集白名单;stdlib 日志接管;读端点 404 校验;
  httpx timeout;测试用合法 Fernet key;类型化响应模型(JobResponse/DimensionEntry/
  ReviewConflictView/selling_points)+路由 ErrorEnvelope responses(供 codegen)。
This commit is contained in:
Yaojia Wang
2026-06-21 19:32:24 +02:00
parent 2282d4fd24
commit 345cc73965
37 changed files with 737 additions and 115 deletions

View File

@@ -69,6 +69,10 @@ class FakeCredentialStore:
async def upsert_routing(self, routing: StoredRouting) -> None:
self.routing[routing.tier] = routing
async def commit(self) -> None:
# 内存替身无事务commit 为 no-op写入在 upsert 时即生效)。
return None
class FakeProviderProbe:
"""实现 `ProviderProbe` Protocol返回固定能力矩阵绝不联网。"""

View File

@@ -14,6 +14,7 @@ import uuid
import httpx
import pytest
from cryptography.fernet import Fernet
from fakes_projects import (
FakeForeshadowRepo,
FakeSession,
@@ -29,7 +30,7 @@ def _make_client(
) -> tuple[httpx.AsyncClient, FakeForeshadowRepo, FakeSession]:
import os
os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44)
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
from ww_api.main import create_app
from ww_api.services.project_deps import get_foreshadow_repo
from ww_db import get_session

View File

@@ -16,6 +16,7 @@ from typing import Any
import httpx
import pytest
from cryptography.fernet import Fernet
from fakes_projects import FakeProjectRepo, FakeSession
from test_projects import _empty_memory_repos
from ww_agents import (
@@ -161,7 +162,7 @@ def _make_app(
memory: Any = None,
no_creds: bool = False,
) -> tuple[Any, FakeSession]:
os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44)
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
from ww_api.main import create_app
from ww_api.services.project_deps import (
get_character_gen_gateway,

View File

@@ -10,6 +10,7 @@ import uuid
import httpx
import pytest
from cryptography.fernet import Fernet
from fakes_projects import FakeProjectRepo
from ww_api.services.credentials import STUB_OWNER_ID
from ww_core.domain.injection_repo import InjectionOverride
@@ -124,7 +125,7 @@ def _make_client(
) -> httpx.AsyncClient:
import os
os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44)
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
from fakes_projects import FakeSession
from ww_api.main import create_app
from ww_api.services.project_deps import (

View File

@@ -134,7 +134,7 @@ async def test_run_job_failure_sets_failed_and_does_not_raise() -> None:
repo = _FakeJobRepo()
async def work(_session: AsyncSession) -> dict[str, Any]:
raise RuntimeError("extraction blew up")
raise RuntimeError("extraction blew up: secret=/internal/path")
# 异常被吞(后台任务边界),不冒泡
await run_job(
@@ -147,7 +147,25 @@ async def test_run_job_failure_sets_failed_and_does_not_raise() -> None:
assert "complete" not in repo.calls
assert "fail" in repo.calls
assert repo.status == STATUS_FAILED
assert repo.error == "extraction blew up"
# P0-3非 AppError 一律落通用文案,**绝不**回传 str(exc)(防泄露内部细节)。
assert repo.error == "任务执行失败"
assert "secret" not in (repo.error or "")
# 失败置态在一个**全新** session 里完成并 commit原事务作废
assert len(factory.sessions) == 2
assert factory.sessions[1].commits == 1
async def test_run_job_apperror_stores_code_and_safe_message() -> None:
"""P0-3AppError 的 message 是面向用户的安全文案,连同 code 一起落库。"""
from ww_shared import AppError, ErrorCode
factory = _FakeSessionFactory()
repo = _FakeJobRepo()
async def work(_session: AsyncSession) -> dict[str, Any]:
raise AppError(ErrorCode.LLM_UNAVAILABLE, "Kimi 设备授权轮询超时")
await run_job(factory, JOB_ID, work, repo_factory=lambda _s: repo)
assert repo.status == STATUS_FAILED
assert repo.error == "LLM_UNAVAILABLE: Kimi 设备授权轮询超时"

View File

@@ -0,0 +1,54 @@
"""P2request_id 头校验——安全字符集白名单(字母数字 . _ -1128 位),非法则生成新 uuid。
放行 uuid.hex / 常见 trace id / 客户端自定义短 id丢弃含空白/控制字符/换行/注入序列/
超长(>128的值防止把未经校验的客户端值原样写进日志/响应头(日志注入防护)。
"""
from __future__ import annotations
import re
from ww_api.middleware import _sanitize_request_id
_HEX_32 = re.compile(r"^[0-9a-f]{32}$")
def test_valid_hex_passthrough() -> None:
raw = "a" * 32
assert _sanitize_request_id(raw) == raw
def test_valid_uppercase_hex_passthrough() -> None:
raw = "ABCDEF0123456789"
assert _sanitize_request_id(raw) == raw
def test_none_generates_new_uuid() -> None:
out = _sanitize_request_id(None)
assert _HEX_32.match(out)
def test_short_alnum_passthrough() -> None:
# 客户端自定义短 id如 'abc123')属合法字符集 → 原样透传(与 /health 传播契约一致)。
raw = "abc123"
assert _sanitize_request_id(raw) == raw
def test_too_long_rejected() -> None:
out = _sanitize_request_id("a" * 129) # 129 位 > 128 上限 → 丢弃改生成。
assert _HEX_32.match(out)
def test_whitespace_rejected() -> None:
# 含空格(日志注入风险,非白名单字符)→ 丢弃改生成。
out = _sanitize_request_id("abc 123")
assert out != "abc 123"
assert _HEX_32.match(out)
def test_injection_rejected() -> None:
# 含非十六进制字符(注入/控制字符)→ 丢弃改生成。
out = _sanitize_request_id("../../etc/passwd\n")
assert "/" not in out
assert "\n" not in out
assert _HEX_32.match(out)

View File

@@ -14,6 +14,7 @@ import uuid
import httpx
import pytest
from cryptography.fernet import Fernet
from fakes_projects import (
FakeForeshadowRepo,
FakeOutlineReadRepo,
@@ -51,7 +52,7 @@ def _make_client(
gateway: FakeReviewGateway | None = None,
session: FakeSession | None = None,
) -> tuple[httpx.AsyncClient, FakeOutlineWriteRepo, FakeSession]:
os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44)
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
from ww_api.main import create_app
from ww_api.services.project_deps import (
get_foreshadow_repo,
@@ -142,7 +143,7 @@ async def test_generate_outline_without_credentials_maps_to_llm_unavailable() ->
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
session = FakeSession()
os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44)
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
from ww_api.main import create_app
from ww_api.services.project_deps import (
get_foreshadow_repo,
@@ -196,7 +197,7 @@ def _make_read_client(
outline_read_repo: FakeOutlineReadRepo,
) -> httpx.AsyncClient:
"""装配 GET /outline 测试客户端(注入 fake 项目 repo + 读侧 outline repo"""
os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44)
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
from ww_api.main import create_app
from ww_api.services.project_deps import get_outline_read_repo, get_project_repo
@@ -271,16 +272,19 @@ async def test_get_outline_unknown_project_404() -> None:
@pytest.mark.asyncio
async def test_foreshadow_board_filters_by_status() -> None:
repo = FakeForeshadowRepo()
pid = uuid.uuid4()
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
await repo.register(pid, code="F1", title="开放伏笔")
await repo.register(pid, code="F2", title="部分回收")
await repo.transition(pid, "F2", to_status="PARTIAL")
from ww_api.main import create_app
from ww_api.services.project_deps import get_foreshadow_repo
from ww_api.services.project_deps import get_foreshadow_repo, get_project_repo
os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44)
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
app = create_app()
app.dependency_overrides[get_foreshadow_repo] = lambda: repo
# 读端点现做 project 存在性 404 校验P2——注入有该 project 的 FakeProjectRepo。
app.dependency_overrides[get_project_repo] = lambda: project_repo
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
all_resp = await client.get(f"/projects/{pid}/foreshadow")

View File

@@ -9,6 +9,7 @@ import uuid
import httpx
import pytest
from cryptography.fernet import Fernet
from fakes_projects import FakeChapterRepo, FakeProjectRepo, FakeWriterGateway
from ww_core.domain.repositories import (
CharacterView,
@@ -95,7 +96,7 @@ def _make_client(
) -> tuple[httpx.AsyncClient, FakeProjectRepo, FakeChapterRepo, FakeWriterGateway]:
import os
os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44)
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
from ww_api.main import create_app
from ww_api.services.project_deps import (
get_chapter_repo,

View File

@@ -11,6 +11,7 @@ import uuid
import httpx
import pytest
from cryptography.fernet import Fernet
from fakes_projects import (
FakeChapterRepo,
FakeDigestAppendRepo,
@@ -42,7 +43,7 @@ def _make_client(
]:
import os
os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44)
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
from ww_api.main import create_app
from ww_api.services.project_deps import (
get_chapter_repo,

View File

@@ -12,6 +12,7 @@ import uuid
import httpx
import pytest
from cryptography.fernet import Fernet
from fakes_projects import FakeSession
from ww_core.domain.rule_repo import RuleWriteView
@@ -29,7 +30,7 @@ class _FakeRuleWriteRepo:
def _make_client() -> tuple[httpx.AsyncClient, _FakeRuleWriteRepo, FakeSession]:
import os
os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44)
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
from ww_api.main import create_app
from ww_api.services.project_deps import get_rule_write_repo
from ww_db import get_session

View File

@@ -0,0 +1,138 @@
"""P0-1 原子性:`PUT /settings/providers` 多凭据一请求,中途失败整体回滚。
不变量:仓储写方法只 `flush()`,端点末尾**统一一次** `commit()`。若某条 upsert 中途
抛错,端点绝不到达 `commit()` → 请求 session 回滚DB 不留半更新(无部分提交)。
用可注入 fakestore 在第 N 次 upsert 抛错fake session 记录 `commit()` 调用次数。
断言:① 请求返回 500兜底信封② `session.commit()` **从未被调用**(无半提交)。
"""
from __future__ import annotations
import os
import uuid
from typing import Any
import httpx
import pytest
from cryptography.fernet import Fernet
from fastapi import FastAPI
from ww_api.services.credentials import StoredRouting
class _ExplodingStore:
"""凭据 store fake前 N 次 upsert 成功,第 fail_at 次1-based抛错。
模拟「多凭据一请求」里第 K 条写入失败——验证端点不会半提交。
"""
def __init__(self, *, fail_at: int) -> None:
self._fail_at = fail_at
self._upsert_calls = 0
self.creds: dict[tuple[uuid.UUID, str], bytes] = {}
async def list_credentials(self, owner_id: uuid.UUID) -> list[Any]:
return []
async def list_routing(self) -> list[Any]:
return []
async def get_credential(self, owner_id: uuid.UUID, provider: str) -> Any:
return None
async def upsert_credential(
self, owner_id: uuid.UUID, provider: str, api_key_enc: bytes
) -> None:
self._upsert_calls += 1
if self._upsert_calls == self._fail_at:
raise RuntimeError("simulated mid-request write failure")
self.creds[(owner_id, provider)] = api_key_enc
async def upsert_oauth_credential(
self, owner_id: uuid.UUID, provider: str, oauth_enc: bytes
) -> None: # pragma: no cover - 不用于本测试
raise NotImplementedError
async def delete_credential(self, owner_id: uuid.UUID, provider: str) -> bool:
return False
async def upsert_routing(self, routing: StoredRouting) -> None:
self.creds[(uuid.UUID(int=0), routing.tier)] = b""
class _RecordingSession:
"""fake AsyncSession只记 commit/rollback 调用次数(端点统一提交的探针)。"""
def __init__(self) -> None:
self.commits = 0
self.rollbacks = 0
async def commit(self) -> None:
self.commits += 1
async def rollback(self) -> None:
self.rollbacks += 1
def _build_app(store: _ExplodingStore, session: _RecordingSession) -> FastAPI:
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
from ww_config import get_settings
get_settings.cache_clear()
from ww_api.main import create_app
from ww_api.services.provider_deps import get_credential_store
from ww_db import get_session
app = create_app()
app.dependency_overrides[get_credential_store] = lambda: store
app.dependency_overrides[get_session] = lambda: session
return app
@pytest.mark.asyncio
async def test_upsert_providers_rolls_back_on_mid_request_failure() -> None:
# Arrange第 2 条凭据写入时抛错(前一条已 flush 到 session但尚未 commit
store = _ExplodingStore(fail_at=2)
session = _RecordingSession()
app = _build_app(store, session)
payload = {
"credentials": [
{"provider": "deepseek", "api_key": "sk-a"},
{"provider": "kimi", "api_key": "sk-b"},
{"provider": "qwen", "api_key": "sk-c"},
]
}
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.put("/settings/providers", json=payload)
# Assert兜底信封catch-all → 500 INTERNAL不回显原始异常且**从未提交**(无半提交)。
assert resp.status_code == 500
assert resp.json()["error"]["code"] == "INTERNAL"
assert "simulated mid-request write failure" not in resp.text
assert session.commits == 0
@pytest.mark.asyncio
async def test_upsert_providers_commits_once_on_success() -> None:
# Arrange全部成功fail_at 远大于条数)。
store = _ExplodingStore(fail_at=99)
session = _RecordingSession()
app = _build_app(store, session)
payload = {
"credentials": [
{"provider": "deepseek", "api_key": "sk-a"},
{"provider": "kimi", "api_key": "sk-b"},
]
}
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.put("/settings/providers", json=payload)
# Assert成功路径**恰好一次** commit所有写入统一提交
assert resp.status_code == 200
assert session.commits == 1

View File

@@ -20,6 +20,7 @@ import uuid
import httpx
import pytest
from cryptography.fernet import Fernet
from fakes_projects import (
FakeJobRepo,
FakeProjectRepo,
@@ -59,7 +60,7 @@ def _app_with_overrides(
extract_gateway: object | None = None,
refine_gateway: object | None = None,
) -> FastAPI:
os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44)
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
from ww_api.main import create_app
from ww_api.services.project_deps import (
get_job_repo,
@@ -298,8 +299,9 @@ async def test_get_style_returns_latest_fingerprint() -> None:
assert resp.status_code == 200
body = resp.json()
assert body["version"] == 1
assert body["dimensions"] == {"句长": ""}
assert body["evidence"] == {"句长": ["他来了。"]}
# P2 codegendimensions 改为 list[DimensionEntry]name/value/evidence 合并),
# 替代此前两列并行裸 dict强类型给 TS 客户端)。
assert body["dimensions"] == [{"name": "句长", "value": "", "evidence": ["他来了。"]}]
@pytest.mark.asyncio