POST /uploads/presign:场景/类型/大小三重校验 + 可审计 object_key 布局 +
SDK 隔离在 CosSigner Protocol 后(FR-SYS-03,T-P1-27)。
实现:packages/storage/schemas.py(PresignRequest/PresignResponse)、
packages/storage/service.py(CosSigner Protocol / QcloudCosSigner /
StorageService.build_presign,bucket 四别名映射到 cos_bucket_*,
object_key 写死 {scene}/{user_id}/{yyyyMMdd}/{uuid4hex}.{ext})、
apps/api/routers/uploads.py(POST /presign,CurrentUserId 认证 + ok 信封)。
测试:tests/unit/test_upload_presign.py 16 例全部经 FakeSigner 注入,
零网络、零 SDK client 构造;覆盖 key 布局逐段断言、三重校验统一 40010、
bucket 别名映射、白名单字符串解析(空串/空格容错)、配置错误分支、
schema 层 size>=1。门禁全绿:ruff check/format、mypy(宽松 48 文件 +
domain strict 7 文件)、lint-imports 4 契约、pytest 185 passed
(基线 169 + 新增 16)。
偏差说明:
1) SDK 实际无 qcloud_cos.method 模块,Method 按官方文档传字符串 "PUT";
get_presigned_url 参数名为 Expired(非任务文档写的 Expire);配置类
导出名为 CosConfig(非 CosS3Config)。按文档 §3 "以实际 SDK 签名为准"
处理,已用假密钥端到端冒烟验证(纯本地签名计算,零网络)。
2) build_presign 增加可选 keyword-only 参数 now(默认 None=当前 UTC),
供单测注入固定时钟(对齐 DailyBudgetGuard 的 clock 注入惯例),
不改变文档签名的调用兼容性。
3) 两处防御分支文档未规定,均配了单测:场景 bucket 别名非法报
CONFIG_ERROR 而非 40010(配置错误不应让客户端背 4xx);COS 密钥为空
在 SDK 构造前报 CONFIG_ERROR,避免未处理的裸 500。
229 lines
8.3 KiB
Python
229 lines
8.3 KiB
Python
"""COS 直传预签名(FR-SYS-03 / T-P1-27)。
|
|
|
|
全部用 FakeSigner 注入,零网络、零 SDK client 构造。object_key 布局
|
|
(场景/用户/日期/uuid)是 COS 侧前缀审计与生命周期规则的基础,逐段精确
|
|
断言;场景/类型/大小三重校验共用 40010,逐条锁死。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from pydantic import ValidationError
|
|
|
|
from packages.shared.config import Settings
|
|
from packages.shared.errors import AppError, ErrorCode
|
|
from packages.storage.schemas import PresignRequest
|
|
from packages.storage.service import StorageService
|
|
|
|
FIXED_NOW = datetime(2026, 8, 14, 1, 2, 3, tzinfo=UTC)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class FakeSigner:
|
|
"""捕获签发参数并回放可预测 URL,替代 QcloudCosSigner(零网络)。"""
|
|
|
|
calls: list[dict[str, Any]] = field(default_factory=list)
|
|
|
|
def presign_put(self, bucket: str, key: str, expires: int, content_type: str) -> str:
|
|
self.calls.append(
|
|
{"bucket": bucket, "key": key, "expires": expires, "content_type": content_type}
|
|
)
|
|
return f"https://cos.fake/{bucket}/{key}?sign=stub"
|
|
|
|
|
|
def make_settings(**overrides: Any) -> Settings:
|
|
"""显式 kwargs 构造,测试不受 .env 与宿主环境变量影响。
|
|
|
|
upload_scenes 定义了 alias 且未开启 populate_by_name,必须按
|
|
``UPLOAD_SCENES_JSON`` 传入——按字段名传会被静默忽略。
|
|
"""
|
|
options: dict[str, Any] = {
|
|
"_env_file": None,
|
|
"cos_bucket_temp": "meida-temp-test",
|
|
"cos_bucket_user": "meida-user-test",
|
|
"cos_bucket_kb": "meida-kb-test",
|
|
"cos_bucket_report": "meida-report-test",
|
|
"cos_presign_expire_seconds": 321,
|
|
"upload_max_bytes": 1024,
|
|
"upload_allowed_types": "image/jpeg,image/png,image/webp",
|
|
"UPLOAD_SCENES_JSON": {"report_photo": {"bucket": "temp"}, "avatar": {"bucket": "temp"}},
|
|
}
|
|
options.update(overrides)
|
|
return Settings(**options)
|
|
|
|
|
|
def make_service(**overrides: Any) -> tuple[StorageService, FakeSigner]:
|
|
signer = FakeSigner()
|
|
return StorageService(make_settings(**overrides), signer), signer
|
|
|
|
|
|
def test_valid_request_builds_auditable_key_layout() -> None:
|
|
service, signer = make_service()
|
|
response = service.build_presign(
|
|
7,
|
|
PresignRequest(scene="report_photo", content_type="image/jpeg", size_bytes=100),
|
|
now=FIXED_NOW,
|
|
)
|
|
|
|
# key 布局:{scene}/{user_id}/{yyyyMMdd}/{uuid4hex}.{ext}(写死,改动需过安全评审)
|
|
prefix = "report_photo/7/20260814/"
|
|
assert response.object_key.startswith(prefix)
|
|
filename = response.object_key.removeprefix(prefix)
|
|
stem, ext = filename.split(".")
|
|
assert ext == "jpg"
|
|
assert len(stem) == 32
|
|
assert all(char in "0123456789abcdef" for char in stem)
|
|
|
|
# 回显与签发参数
|
|
assert (
|
|
response.upload_url == f"https://cos.fake/meida-temp-test/{response.object_key}?sign=stub"
|
|
)
|
|
assert response.expires_in == 321
|
|
assert response.max_bytes == 1024
|
|
assert response.content_type == "image/jpeg"
|
|
assert signer.calls == [
|
|
{
|
|
"bucket": "meida-temp-test",
|
|
"key": response.object_key,
|
|
"expires": 321,
|
|
"content_type": "image/jpeg",
|
|
}
|
|
]
|
|
|
|
|
|
def test_two_presigns_for_the_same_request_never_share_a_key() -> None:
|
|
service, _ = make_service()
|
|
request = PresignRequest(scene="avatar", content_type="image/jpeg", size_bytes=1)
|
|
first = service.build_presign(1, request, now=FIXED_NOW)
|
|
second = service.build_presign(1, request, now=FIXED_NOW)
|
|
assert first.object_key != second.object_key # uuid 防猜
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("body", "message"),
|
|
[
|
|
(
|
|
PresignRequest(scene="evil", content_type="image/jpeg", size_bytes=1),
|
|
"不支持的上传场景",
|
|
),
|
|
(PresignRequest(scene="report_photo", content_type="application/pdf", size_bytes=1), None),
|
|
(PresignRequest(scene="report_photo", content_type="image/jpeg", size_bytes=1025), None),
|
|
],
|
|
)
|
|
def test_scene_type_size_rejections_share_error_code_40010(
|
|
body: PresignRequest, message: str | None
|
|
) -> None:
|
|
service, signer = make_service()
|
|
with pytest.raises(AppError) as excinfo:
|
|
service.build_presign(1, body, now=FIXED_NOW)
|
|
assert excinfo.value.code is ErrorCode.UPLOAD_REJECTED
|
|
if message is not None:
|
|
assert excinfo.value.message == message
|
|
assert signer.calls == [] # 拒绝路径绝不触发签名
|
|
|
|
|
|
def test_size_equal_to_limit_is_allowed() -> None:
|
|
service, signer = make_service()
|
|
service.build_presign(
|
|
1,
|
|
PresignRequest(scene="avatar", content_type="image/webp", size_bytes=1024),
|
|
now=FIXED_NOW,
|
|
)
|
|
assert signer.calls[0]["key"].endswith(".webp")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("alias", "expected_bucket"),
|
|
[
|
|
("temp", "meida-temp-test"),
|
|
("user", "meida-user-test"),
|
|
("kb", "meida-kb-test"),
|
|
("report", "meida-report-test"),
|
|
],
|
|
)
|
|
def test_bucket_alias_maps_to_the_matching_settings_field(alias: str, expected_bucket: str) -> None:
|
|
service, signer = make_service(UPLOAD_SCENES_JSON={"report_photo": {"bucket": alias}})
|
|
service.build_presign(
|
|
7,
|
|
PresignRequest(scene="report_photo", content_type="image/png", size_bytes=1),
|
|
now=FIXED_NOW,
|
|
)
|
|
assert signer.calls[0]["bucket"] == expected_bucket
|
|
|
|
|
|
def test_allowed_types_string_tolerates_whitespace() -> None:
|
|
service, _ = make_service(upload_allowed_types=" image/jpeg , image/png ")
|
|
response = service.build_presign(
|
|
1,
|
|
PresignRequest(scene="avatar", content_type="image/png", size_bytes=1),
|
|
now=FIXED_NOW,
|
|
)
|
|
assert response.object_key.endswith(".png")
|
|
with pytest.raises(AppError) as excinfo:
|
|
service.build_presign(
|
|
1,
|
|
PresignRequest(scene="avatar", content_type="image/webp", size_bytes=1),
|
|
now=FIXED_NOW,
|
|
)
|
|
assert excinfo.value.code is ErrorCode.UPLOAD_REJECTED
|
|
|
|
|
|
def test_empty_allowed_types_rejects_everything() -> None:
|
|
service, signer = make_service(upload_allowed_types="")
|
|
with pytest.raises(AppError) as excinfo:
|
|
service.build_presign(
|
|
1,
|
|
PresignRequest(scene="avatar", content_type="image/jpeg", size_bytes=1),
|
|
now=FIXED_NOW,
|
|
)
|
|
assert excinfo.value.code is ErrorCode.UPLOAD_REJECTED
|
|
assert signer.calls == []
|
|
|
|
|
|
def test_whitelisted_type_without_extension_mapping_is_rejected() -> None:
|
|
# 白名单被扩到扩展名映射表之外的类型(此处模拟 image/gif)时,
|
|
# 宁可按 40010 拒绝,也不能在拼 object_key 时抛 KeyError 变成 5xx。
|
|
service, signer = make_service(upload_allowed_types="image/gif")
|
|
with pytest.raises(AppError) as excinfo:
|
|
service.build_presign(
|
|
1,
|
|
PresignRequest(scene="avatar", content_type="image/gif", size_bytes=1),
|
|
now=FIXED_NOW,
|
|
)
|
|
assert excinfo.value.code is ErrorCode.UPLOAD_REJECTED
|
|
assert signer.calls == []
|
|
|
|
|
|
def test_unknown_bucket_alias_is_a_config_error_not_a_client_error() -> None:
|
|
service, signer = make_service(UPLOAD_SCENES_JSON={"avatar": {"bucket": "ghost"}})
|
|
with pytest.raises(AppError) as excinfo:
|
|
service.build_presign(
|
|
1,
|
|
PresignRequest(scene="avatar", content_type="image/jpeg", size_bytes=1),
|
|
now=FIXED_NOW,
|
|
)
|
|
assert excinfo.value.code is ErrorCode.CONFIG_ERROR
|
|
assert signer.calls == []
|
|
|
|
|
|
def test_missing_cos_credentials_is_a_config_error_before_sdk_construction() -> None:
|
|
# 未注入 signer 且密钥为空:守卫须在 SDK 构造前抛出(零 SDK 接触、零网络),
|
|
# 本地未配 COS 四件套时得到结构化 CONFIG_ERROR 而非未处理 500。
|
|
service = StorageService(make_settings(), None)
|
|
with pytest.raises(AppError) as excinfo:
|
|
service.build_presign(
|
|
1,
|
|
PresignRequest(scene="avatar", content_type="image/jpeg", size_bytes=1),
|
|
now=FIXED_NOW,
|
|
)
|
|
assert excinfo.value.code is ErrorCode.CONFIG_ERROR
|
|
|
|
|
|
def test_request_schema_rejects_non_positive_size() -> None:
|
|
with pytest.raises(ValidationError):
|
|
PresignRequest(scene="avatar", content_type="image/jpeg", size_bytes=0)
|