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:
@@ -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:返回固定能力矩阵,绝不联网。"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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-3:AppError 的 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 设备授权轮询超时"
|
||||
|
||||
54
apps/api/tests/test_middleware_request_id.py
Normal file
54
apps/api/tests/test_middleware_request_id.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""P2:request_id 头校验——安全字符集白名单(字母数字 . _ -,1–128 位),非法则生成新 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)
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
138
apps/api/tests/test_settings_providers_atomicity.py
Normal file
138
apps/api/tests/test_settings_providers_atomicity.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""P0-1 原子性:`PUT /settings/providers` 多凭据一请求,中途失败整体回滚。
|
||||
|
||||
不变量:仓储写方法只 `flush()`,端点末尾**统一一次** `commit()`。若某条 upsert 中途
|
||||
抛错,端点绝不到达 `commit()` → 请求 session 回滚,DB 不留半更新(无部分提交)。
|
||||
|
||||
用可注入 fake:store 在第 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
|
||||
@@ -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 codegen:dimensions 改为 list[DimensionEntry](name/value/evidence 合并),
|
||||
# 替代此前两列并行裸 dict(强类型给 TS 客户端)。
|
||||
assert body["dimensions"] == [{"name": "句长", "value": "短", "evidence": ["他来了。"]}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Reference in New Issue
Block a user