由 7 名成员并行审计 + 队长线上实测复核产出,合计约 3,600 行: - backend/audit-backend.md 后端架构与代码质量(22 条) - backend/audit-security.md 认证/授权/敏感信息(23 条) - backend/audit-testing.md 测试与可验证性(含真实命令输出) - backend/audit-requirements.md 需求覆盖对照(多租户规格错位) - frontend/audit-frontend.md 前端与移动端工程(32 条) - frontend/audit-uxui.md 交互与视觉一致性 - docker/audit-deploy.md 容器化与部署链路 - AUDIT-REPORT.md 总报告:止血项、修复路线、待决策事项、对既有结论的修正 各报告均标注级别、文件:行号、影响、建议修法,并单列未验证项与抽样边界。
47 KiB
FastapiAdmin 测试与可验证性审计(t3 / 只读审计)
- 审计人:测试工程师(team
fastapiadmin-audit,taskt3) - 工作区:
/Users/tao/workspace/FastapiAdmin - 审计范围:
backend/tests、backend/pyproject.toml的 pytest 配置、frontend/web的 vitest 与用例、frontend/app(uniapp)测试能力、仓库级 CI 配置 - 本轮约束:只读,除本报告外未修改/删除任何仓库文件;未安装依赖、未启服务、未提交 git
0. 结论摘要(TL;DR)
| 维度 | 现状 | 结论 |
|---|---|---|
| 后端 pytest 用例数 | 2 个(收集 2 / 通过 2 / 失败 0 / 跳过 0 / 退出码 0) | 能跑通,但等于没有回归网 |
| 后端被测模块 | 216 个 .py、28519 行 → 仅 /monitor/health/check/ 1 个接口被断言 |
209 个 OpenAPI 操作里 1 个有断言 |
| 历史测试规模 | 2026-07-17(commit 158c14a4)前仓库内 9 个测试文件共 238 个 test 函数;.pytest_cache 记录过 38 个文件 / 761 个 node id |
测试能力被"重构"清空,无替代物 |
| 断言有效性 | 唯一的行为测试断言的是恒为 True/0 的字段 |
该用例无法失败 |
| 夹具质量 | 手写 AsyncMock Redis(未用已声明的 fakeredis)+ 模块级 patch().start() + 无逐用例隔离 |
夹具本身会掩盖缺陷 |
| 迁移测试 | 只覆盖 SQLite 下 create_all ≡ metadata;app/alembic/versions/ 没有任何迁移文件 |
alembic upgrade 路径零覆盖 |
| 前端 web vitest | 有真实可运行用例:2 文件 / 10 用例 / 全通过 / 退出码 0 | 能力存在,但未进门禁 |
| uniapp | 无 vitest/jest、无 test script、0 个 spec |
测试能力为零 |
| CI | 仓库根 .github/ 为空且未跟踪(git ls-files .github = 0);frontend/web/.github、frontend/app/.github 不在仓库根 → GitHub 不会运行任何 workflow |
零门禁 |
一句话:后端"可运行但无验证力",前端 web 有少量真用例却无一环节强制运行,uniapp 与 CI 是空白。当前任何回归都只能靠人工点测发现。
1. 命令与真实输出
1.1 后端:cd backend && uv run pytest -q
环境:uv 0.12.10(/opt/homebrew/bin/uv),Python 3.12.12,pytest 9.0.2,pytest-asyncio 1.4.0,anyio 4.12.1,backend/.venv 已存在。
第一次(原样执行,未改任何环境变量):
$ cd backend && uv run pytest -q
error: Failed to initialize cache at `/Users/tao/.cache/uv`
Caused by: failed to open file `/Users/tao/.cache/uv/sdists-v9/.git`: Operation not permitted (os error 1)
EXIT=2
→ 失败原因是 uv 需要写 home 下的缓存目录(~/.cache/uv),在该沙箱内不可写,与项目代码无关。
第二次(把 uv 缓存重定向到 /tmp,同一命令语义):
$ cd backend && UV_CACHE_DIR=/tmp/uv-cache-test uv run pytest -q
.. [100%]
2 passed in 1.80s
EXIT=0
详细模式(收集清单 + 结果):
$ cd backend && UV_CACHE_DIR=/tmp/uv-cache-test uv run pytest -v
============================= test session starts ==============================
platform darwin -- Python 3.12.12, pytest-9.0.2, pluggy-1.6.0 -- .../backend/.venv/bin/python3
cachedir: .pytest_cache
rootdir: /Users/tao/workspace/FastapiAdmin/backend
configfile: pyproject.toml
plugins: anyio-4.12.1, asyncio-1.4.0
asyncio: mode=Mode.AUTO, debug=False, asyncio_default_fixture_loop_scope=function, ...
collecting ... collected 2 items
tests/test_main.py::test_check_health PASSED [ 50%]
tests/test_migrations.py::test_bootstrap_matches_metadata PASSED [100%]
============================== 2 passed in 2.02s ===============================
EXIT=0
真实结果汇总:收集 2、通过 2、失败 0、跳过 0、错误 0、退出码 0、耗时 ≈1.8–2.0s。
后续所有复现(探针脚本)改用
backend/.venv/bin/python与带--no-sync的形式,避免uv再次触碰锁文件(见 §7 环境说明)。
1.2 前端 web:vitest
frontend/web 的 node_modules 已就绪(vitest 4.1.7)。
$ cd frontend/web && ./node_modules/.bin/vitest run
RUN v4.1.7 /Users/tao/workspace/FastapiAdmin/frontend/web
✓ src/__tests__/smoke.spec.ts (3 tests) 24ms
stdout | src/__tests__/route-invariants.spec.ts > 静态路由 — 中间层不挂组件(深度跳级的前提) > ...
[i18n] 使用默认语言: zh
✓ src/__tests__/route-invariants.spec.ts (7 tests) 111ms
Test Files 2 passed (2)
Tests 10 passed (10)
Duration 1.05s (transform 126ms, setup 0ms, import 91ms, tests 135ms, environment 1.36s)
EXIT=0
真实结果汇总:2 个测试文件、10 个用例、全部通过、退出码 0。
1.3 未执行的项目(说明原因,避免"看起来跑过了")
| 项目 | 原因 |
|---|---|
pnpm test(web) |
pnpm 走 npm 源/网络,本轮禁止安装依赖;已直接用 node_modules/.bin/vitest |
| uniapp 任何测试 | 无测试脚本、无测试依赖、无用例,无可跑对象 |
pytest --cov |
pytest-cov / coverage 未安装(import pytest_cov → ModuleNotFoundError),且本轮不装依赖 → 无法给出行覆盖率数字,报告中的"覆盖缺口"由路由表、模块清单与代码路径证据推导 |
| 集成/端到端、压测 | 本轮不启服务、不连真实 MySQL/Redis |
2. 测试资产现状与夹具审计
2.1 分层:名义三层,实际一层
| 层 | 目录/文件 | 实际内容 |
|---|---|---|
| 单元测试 | — | 不存在(历史上有 tests/test_unit.py 47 个用例,已不在仓库) |
| 集成/API 测试 | tests/test_main.py |
1 个健康检查断言 |
| 迁移守卫 | tests/test_migrations.py |
1 个 create_all ≡ metadata(SQLite)断言 |
| 共享夹具 | tests/conftest.py(277 行) |
session 级 TestClient + 手写 Redis mock + 自建 lifespan |
conftest.py 自己声明的能力(第 1–5 行)是"模块化 API 接口测试共享 fixture",但仓库里已经没有任何调用 assert_route 的用例(grep -rn assert_route backend/tests 只命中定义处),也就是这套夹具的消费者全部被删除了。
2.2 conftest.py 逐项问题(带行号)
-
导入期副作用(L26–L46):在 import 时创建临时 SQLite 文件并改写
os.environ与全局settings;甚至用sys.path.insert兜底(L17)。任何新测试文件只要 importconftest,全进程配置即被改写。 -
临时库即业务库、用完不删(L26):
tempfile.NamedTemporaryFile(suffix=".db", delete=False).name,每次 session 生成新文件、从不清理;/tmp会持续积累。 -
无逐用例隔离:
_api_client是 session 级 TestClient(L204–208),auth_headers是 session 级(L223–232),全程共享同一个 app、同一个 SQLite 库、同一个 token。没有任何 per-test 事务回滚/清库 → 用例顺序会互相影响,且无法并行(-n)。 -
测试专用 lifespan 覆盖真实 lifespan(L173–197):只跑
init_db()+ 注入 mock redis,并用UPDATE把 admin 密码重置成admin123。真实启动路径中的init_agno_tables、redis_connect、ParamsService.init_cache、DictDataService.init_cache、SchedulerUtil.init_scheduler全部被绕过 → 启动期回归完全无覆盖;同时"改业务表数据"这个动作本身是测试污染。 -
用手写 AsyncMock 顶替 Redis,而
fakeredis才是已声明依赖(L53–161):pyproject.tomldev 组声明了fakeredis>=2.21.0,<3,但全仓库除该行外没有任何import fakeredis。手写 mock 的语义与真实 Redis 不一致:_redis_ttl恒返回 3600 或 -2(L85–86)→ TTL/续期逻辑无法验证;_redis_expire恒返回"键是否存在"且不改 TTL(L89–90)→ 过期语义假通过;_redis_keys只做前缀切割,不支持user:*:x等通配(L75–78);- 缺失被业务代码使用的方法:
incr、eval、mget、scan_iter、publish、pubsub、call(grep -rhoE '\bredis\.[a-z_]+\(' app的结果与 mock 白名单对比)。 后果 1:LoginService._check_login_rate_limit(auth/service.py:155–173)用redis.incr→ 拿到MagicMock,count > settings.LOGIN_RATE_LIMIT_MAX_ATTEMPTS抛TypeError→ 被except Exception吞掉并仅 warning → 登录限流在测试环境静默失效。 后果 2:RedisCURD.compare_and_set/unlock依赖redis.eval(redis_crud.py:170、223)→result == 1恒 False → 会话续期的 CAS 路径在测试里永远走失败分支。
-
模块级
patch(...).start()永不 stop(L161–164):redis.asyncio.Redis.from_url、SchedulerUtil.init_scheduler/shutdown被进程级替换,跨测试文件持续生效,且失败时不留痕迹。 -
assert_route把异常当通过(L268–277):try: response = test_client.request(method, path, **kwargs) except Exception: # 后端代码异常(500 等),路由存在即不计为测试失败 return ... assert response.status_code != 404, ...→ 5xx/未捕获异常被计为"路由存在",接口挂掉也能绿;默认断言只查
!= 404。当前无调用者,但它是后续补测试时最容易误用的"绿色陷阱"。 -
完全没有异步夹具:
pyproject.toml配了asyncio_mode = "auto"+asyncio_default_fixture_loop_scope = "function",但tests/内async def test_/async def fixture数量为 0。新增异步 DB 用例时若直接复用应用全局async_engine(session 级 TestClient 在独立线程/事件循环中运行),会踩"连接绑定到另一个事件循环"的坑——现有夹具没有为这件事提供任何支撑(缺 async session/事务夹具)。 -
pytest 配置过薄(
pyproject.toml:155–157):无markers、无--strict-markers、无filterwarnings = error、无timeout、无覆盖率插件与阈值、无-p no:randomly(无随机序,掩盖顺序依赖)。
2.3 是否依赖真实 MySQL / Redis
- 不依赖:
conftest强制DATABASE_TYPE=sqlite+ 临时库文件,Redis 用 AsyncMock 顶替(L28–32、L141–178)。 - 代价:
- 生产主用 MySQL/Postgres,但测试只在 SQLite 上跑 → 方言相关代码(
information_schema/pg_catalog查询、ON UPDATE CURRENT_TIMESTAMP、UNSIGNED、COLLATE、JSON 列、server_default渲染)零覆盖; app/modules/generator/gencode/crud.py:178–243的 MySQL/Postgres 物理表分页 SQL 分支只在真实方言下成立,测试永不会执行;- 真实 Redis 的 Lua 脚本(
eval)、incr、SCAN 语义无覆盖(见 §2.2.5)。
- 生产主用 MySQL/Postgres,但测试只在 SQLite 上跑 → 方言相关代码(
2.4 迁移测试的有效性
tests/test_migrations.py 两个守卫确实有意义(守卫一:布局阶段 app/alembic/versions/ 不得新增文件;守卫二:显式 autogenerate 必须零产出),但存在三个边界:
app/alembic/versions/里只有__init__.py,没有任何迁移文件。也就是说项目声明的"双轨制"(新装走create_all+stamp head,存量走alembic upgrade)目前只剩单轨:alembic upgrade head无版本可升。迁移链本身的正确性(upgrade/downgrade、数据回填、stamp基线)100% 无测试,而app/scripts/initialize.py:61–123里对空alembic_version的处理(清空、提示、跳过 stamp)也只是被间接启动过一次。- 只覆盖 SQLite 方言:注释里已承认"MySQL 方言的 DDL 渲染差异由 dev 启动时的自动迁移承担"——而 dev 启动只发生在开发者本机,CI/无本地启动场景下无人守。
- 守卫二有写仓库副作用:
command.revision(cfg, autogenerate=True)会把产物写进app/alembic/versions/再删除(L31–34)。这是本次唯一会写仓库的测试路径;若未来在只读检出或并行执行(pytest -n)中运行,会互相打架或留下残留文件。
2.5 前端 web 的 vitest:真有可运行用例 ✅
- 配置:
frontend/web/vitest.config.ts,environment: jsdom、globals: true、include: ["src/**/*.{test,spec}.{ts,js}"],并同步了__APP_INFO__与@/等别名(配置质量不错)。 - 用例 1
src/__tests__/smoke.spec.ts(3 例):断言MenuTypeEnum四个取值、代码生成枚举模块可导入。属于冒烟级别,价值低但零成本。 - 用例 2
src/__tests__/route-invariants.spec.ts(7 例):把"有 children 的路由记录不得挂 component"(Vue Router 深度跳级 + 单层 KeepAlive 的前提)固化成断言,并校验后端backend/sql/sys_menu.json中目录节点不得配component_path。这是全仓库质量最高的一组测试(带历史回归说明、有明确失败信息)。 - 缺口:
package.json有"test": "vitest run"、SKILL.md也写了pnpm test,但没有任何环节会执行它(CI 不跑、husky 的pre-commit只跑lint-staged);- 无覆盖率能力:未安装
@vitest/coverage-v8等 provider(node_modules/@vitest/不存在),vitest --coverage无法产出数据; @vue/test-utils、jsdom已安装,但src/中没有任何组件挂载测试(grep -rn "@vue/test-utils" src/无命中)→ 组件/交互层零覆盖;route-invariants.spec.ts依赖仓库相对路径读取后端sql/sys_menu.json(三个候选路径兜底)→ 前端用例与后端数据文件形成隐式耦合,脱离仓库根目录单独签出 web 会失败(首例带清晰报错,属可接受的取舍,但需要写进交接文档)。
2.6 uniapp(frontend/app):测试能力为零
-
package.json无testscript,无 vitest/jest/@vue/test-utils(只有type-check、lint)。 -
全目录 0 个
*.spec.*/*.test.*文件。 -
自带的
.github/workflows/ci.yml里的三步"测试"是:- name: Test H5 dev mode shell: bash run: | timeout 30s pnpm dev:h5 || true echo "✅ H5 dev mode test completed"|| true+ 仅打印 ✅ → 无论开发服务器是否启动成功都返回成功,不构成任何验证(且windows-latest+shell: bash下timeout行为同样可疑)。
2.7 CI 覆盖:仓库级 0
- 仓库根
.github/workflows/是空目录,且git ls-files .github返回 0 → 未被跟踪。GitHub Actions 不会在本仓库运行任何 workflow。 frontend/web/.github/workflows/ci.yml:内容认真(pnpm install --frozen-lockfile→type-check→lint→build:dev),但 (a) 目录不在仓库根,不会被触发;(b) 即使被触发,也只跑 type-check/lint/build,不跑vitest。frontend/app/.github/workflows/ci.yml:同上,且"测试"步骤是|| true空跑。- 后端:没有任何 workflow,
pytest全靠人手敲。 deploy.sh/deploy-artifacts.sh只做打包与发布,不跑测试(仅在排除列表里提到.pytest_cache)。- 仓库的成文约定与实现不符:
SKILL.md:57/67/121要求"后端uv run pytest;前端pnpm ts:check、pnpm lint、pnpm test",实际无任何自动执行点。
3. 覆盖缺口清单(含证据)
3.1 宏观证据
| 证据 | 数值 |
|---|---|
后端 Python 文件 / 行数(backend/app) |
216 个 / 28519 行 |
| OpenAPI 路径 / 操作 | 205 / 209(app.openapi() 实测;GET 79、POST 63、PUT 26、DELETE 30、PATCH 11) |
AuthPermission(...) 调用 |
187 处(另有类定义等共 212 行出现该标识符) |
| 现有 pytest 用例 | 2 |
| 有断言的接口 | 1(/monitor/health/check/) |
历史用例数(158c14a4^,2026-07-17) |
238(test_api_module_system.py 92、platform 54、task 39、monitor 19、generator 12、example 9、ai 6、common 5、test_main 2) |
历史 node id(.pytest_cache/v/cache/nodeids) |
761 个 / 38 个文件(test_codegen.py 99、test_api_module_system.py 95、test_api_module_platform.py 90、test_api_system.py 72、test_api_module_plugin.py 58、test_unit.py 47、test_permission_logic.py 28、test_integration.py 20、test_data_scope.py 5 …) |
.pytest_cache/v/cache/lastfailed(2026-09-06) |
120 个失败 id(含 test_permission_logic.py 21、test_api_module_system.py 21、test_integration.py 20) |
| 删除轨迹 | 158c14a4(2026-07-17) 删 7 个模块化 API 测试文件;d470c7eb(2026-09-06) 删 test_api_module_system.py;tests/__pycache__ 还残留 test_sse_once/test_api_system/test_routes/test_architecture 的 .pyc |
结论:测试不是"从未有",而是被两次重构清空且没有替代物;
.pytest_cache显示本地曾存在test_codegen.py(99)、test_permission_logic.py(28)、test_data_scope.py(5) 等成套用例,且当时已有 120 条失败——这些用例本身也需要重建而非"恢复"。
3.2 按模块的缺口(附只读探针实测)
探针脚本:/tmp/probe_tests.py(临时目录,不落仓库;SECRET_KEY/SQLite 环境变量隔离,不连真实 MySQL/Redis)。实测输出节选:
resolve_condition('like','a') : OK -> ['probe_tbl.id LIKE :id_1']
resolve_condition('in', []) : OK -> ['false'] # 空集合 → 恒假,合理
resolve_condition('between',[1]) : OK -> [] # 长度≠2 → 静默丢弃条件
resolve_condition('date','2026-10-04') : OK -> ['probe_tbl.id >= :id_1', 'probe_tbl.id < :id_1']
resolve_condition('month','2026-12') : OK -> ['probe_tbl.id >= :id_1', 'probe_tbl.id < :id_1']
resolve_condition('date','not-a-date') : ValueError: time data 'not-a-date' does not match format '%Y-%m-%d'
resolve_condition('unknown_op','x') : OK -> [] # 未知算子 → 无 WHERE,结果被静默放大
build_conditions unknown key : AttributeError: type object 'ProbeModel' has no attribute 'no_such_col'
JWT tampered 'a.b.c' : CustomException: 无效认证,请重新登录
JWT empty : CustomException: 认证不存在,请重新登录
PwdUtil.hash/verify : OK -> True / verify wrong -> False
camel/snake 转换、bytes2human : OK -> 'UserName' / 'user_name' / '1.0GB'
get_child_recursion(1, {1:[2],2:[1]}) : RecursionError: maximum recursion depth exceeded # 无环检测
| 模块 | 关键代码 | 缺口与风险(有据) |
|---|---|---|
| 认证 entry | app/core/dependencies.py:43–189(get_current_user / _authenticate) |
11 条 401 抛出点(grep -c "status_code=401" = 11)全部无测试:空 token、Bearer 后为空、is_refresh 令牌、缺 session_id、session 不存在、session 数据不完整、超最大存活时长、缺 user_name、user_status==1 停用、缺 user_id、每请求查库确认用户存在且未删除;滑动续期(TTL==-1 兜底、SESSION_MAX_LIFETIME_SECONDS 绝对上限、双键同时续期)同样无测试 |
| RBAC 接口权限 | app/core/dependencies.py:192–234(AuthPermission) |
超管旁路(L216)、permissions 为空即放行(L219–220)、* / *:*:* 通配放行(L222)三条"放行"逻辑与"无权限 403"分支均无测试。187 处调用 ↔ 0 个用例;任何一处语义翻转都会静默放开或锁死全部接口 |
| 数据权限 | app/core/permission.py 全文;app/utils/common_util.py:162–196 |
3 种 data_scope(SELF/DEPT_AND_CHILD/ALL)、created_by.dept_id 关系判定、UserModel 特例、hasattr(model,"created_id") 缺失模型、部门树异常时"降级为本部门"(L109–117)全部无测试;get_child_recursion 对 parent_id 成环会 RecursionError(探针实测),当前被 except Exception 兜成降级+warning → 权限范围静默收窄且只留一条 warning |
| 登录/防爆破 | auth/service.py:122–270(LoginService)、:540–649(CaptchaService) |
用户不存在 / 密码错 / 停用 三类失败与登录日志写入、限流窗口、验证码开关、ENVIRONMENT != PROD 时的 docs Referer 豁免均无测试。两处已可见问题:① 限流对 127.0.0.1/unknown/localhost 直接 return(L161–162)→ 反代/本机部署形同不限流;② "用户不存在"与"账号或密码错误"文案不同(L217/L231)→ 用户名枚举 oracle |
| 会话与令牌 | auth/service.py:349–539(create_token/refresh_token/logout)、core/redis_crud.py:147–245 |
refresh 轮换、compare_and_set CAS 语义、lock/unlock Lua 原子性、logout 需同时清 access_token:{sid} / refresh_token:{sid} / user_session:{sid} 三键(service.py:531–533),全部无测试;且现有 Redis mock 缺失 eval → CAS 路径在测试中恒失败(§2.2.5) |
| 分页/查询封装 | core/base_crud.py:104–182(count/get_list/page)、:303–387(_build_conditions/_resolve_condition/_parse_order)、utils/common_util.py:87–126(search_to_dict) |
page() 的 total/has_next/page_no(=offset//limit+1)/limit=0、软删 is_deleted 自动过滤与 include_deleted、load_columns/preload 与 COUNT 拆分的等价性,全部无测试;异常一律被包装成 CustomException("分页查询失败: ...")(L181–182)→ 非法 date(ValueError)、未知排序字段(AttributeError)、未知 search key(AttributeError)都退化成同一条"分页失败",无法区分 4xx 与 500;未知算子静默返回空条件列表 → 查询范围被放大却无报错。search_to_dict 的 _start/_end 合并、_time 列表、json_schema_extra={"q": ...} 打包是所有列表接口过滤语义的唯一入口,无测试 |
| 代码生成器 | gencode/service.py:375–449(create_table)、gencode/crud.py:148–350(系统表分页 / execute_sql)、gencode/gen_util.py(309 行纯函数)、jinja2_template_util.py(759 行模板) |
① DDL 白名单:has_create = any(isinstance(s, Create)) + 禁用 (Delete, Drop, Insert, TruncateTable, Update);sqlglot 的 Create 同时涵盖 CREATE VIEW/INDEX/DATABASE,会被放行并执行;Alter 用字符串包含判定("SET " not in upper 等)脆弱(合法的 ON DELETE SET NULL 反被拒)。② 物理表列表查询按方言走 information_schema/pg_catalog(L178–243),SQLite 测试永不触达。③ GenUtils.get_db_type/get_column_length/split_column_type/arrays_contains/replace_first/convert_class_name 等纯函数边界(tinyint(1)→boolean、int UNSIGNED、COLLATE、integer[]、timestamp without time zone、decimal(10,2)、空括号)无测试。④ 模板渲染产物语法正确性无测试 |
| 操作日志与脱敏 | core/router_class.py:33–40(_redact_sensitive)、:59–134(OperationLogRoute) |
敏感键递归脱敏(含请求体表单、响应体 → token 不得明文入库)、>2000 字符截断为"请求参数过长"、非 JSON 体兜底、后台写库失败仅告警,均无测试。这是"日志泄露 token/密码"的直接防线 |
| 中间件/异常 | core/middlewares.py、core/exceptions.py、common/response.py |
限流、XSS、请求 ID、CustomException → ResponseSchema 映射(success/code/msg/status_code 契约)、ErrorResponse 语义无测试 |
| SSE / WebSocket | core/sse_manager.py、dependencies.py:52–87(get_websocket_token 子协议解析、websocket_authenticate) |
无测试(tests/__pycache__/test_sse_once.*.pyc 说明曾经有过);WS 鉴权、子协议回显、?token= 兜底都属于易回归点 |
| AI 会话 / 存储 / 任务 | modules/ai/chat(agno)、modules/task/storage/*(S3/OBS/OSS/COS/SFTP)、modules/task/cronjob、core/ap_scheduler.py(609 行) |
0 测试;外部 SDK(boto3/esdk-obs-python/alibabacloud-oss-v2/cos-python-sdk-v5/paramiko)全部无 mock 边界测试 |
| OAuth | auth/oauth_service.py(479 行,github/gitee/wechat/qq 换 token 与取 profile) |
0 测试(需 HTTP mock);state 校验与前端跳转兜底无测试 |
| 迁移/schema | tests/test_migrations.py、app/scripts/initialize.py:50–123 |
见 §2.4:无迁移文件、仅 SQLite、无 upgrade/downgrade 链路测试 |
| 前端 web | frontend/web/src/** |
覆盖仅限枚举与路由结构;store(pinia)、utils(request/axios 拦截器、storage、crypto)、router 动态路由加载(route-loader.ts)、权限指令、组件均无测试;无组件挂载测试 |
| uniapp | frontend/app/src/** |
0 测试、0 测试基建 |
3.3 断言有效性问题(最容易被忽略的一条)
唯一的行为测试 tests/test_main.py:
response = test_client.get("/monitor/health/check/")
assert response.status_code == 200
assert body["success"] is True
assert body["code"] == 0
而 app/modules/monitor/health/controller.py:
info = await HealthService.collect(redis)
ok = info.db_status == 1 and info.redis_status == 1
return SuccessResponse(data=info, msg=f"系统健康-{settings.VERSION}" if ok else f"服务异常-{settings.VERSION}")
SuccessResponse 的 success/code 恒为 True/0(app/common/response.py:29–42)。也就是说该用例即使 DB 与 Redis 全部探活失败也会通过——它只验证了路由存在 + JSON 形状,没有验证 data.db_status/redis_status,也没验证 msg。全仓唯一的行为断言是不可失败断言。
4. 最该先补的 10 个测试点(含验证方式)
排序原则:安全相关(越权/凭据)> 影响全部接口的公共封装 > 不可逆操作(DDL/迁移)> 业务模块。
T1. 接口权限矩阵(AuthPermission)与认证失败矩阵 ✅最高优先
- 为什么:187 处
AuthPermission(...)调用、0 个用例;三条放行分支(超管 / 空权限 / 通配符)中任何一条回归都等于全站越权。 - 验证方式
- 新增
backend/tests/test_auth_permission.py(纯单元,无 DB):@pytest.mark.parametrize(("is_superuser", "user_perms", "required", "expect"), [ (True, [], ["system:user:create"], "allow"), # 超管旁路 (False, [], ["system:user:create"], "allow"), # self.permissions 为空 → 放行 (False, ["*"], ["system:user:create"], "allow"), (False, ["*:*:*"], ["system:user:create"], "allow"), (False, ["a:b:c"], ["system:user:create"], "403-errcode"), # 无权限分支 (False, [], ["x:y:z", "a:b:c"], "allow"), ]) async def test_matrix(...): with_403 = pytest.raises(CustomException) - 组装
AuthSchema(user=CoreUserSchema(id=1, is_superuser=..., ...), permissions=user_perms)直接await AuthPermission(required)(auth=...);对 403 分支断言code == RET.NO_PERMISSION.code(而非只有 HTTP 状态码)。 - 接口层(用现有
test_client夹具)对任一受保护接口断言四类 401:无Authorization、Bearer空、随机串、签名正确但 session 已删(先logout再请求)。
- 新增
- 验收:T1 里"无权限 → 403"与"超管 → 200"两类用例必须同时存在,防止只测放行不测拦截。
T2. 数据权限 Permission 的数据范围矩阵
- 为什么:这是"用户 A 看到用户 B 数据"的唯一闸门,当前零覆盖;
created_id/dept_id/created_by.dept_id三条取值路径 + 降级逻辑都很绕。 - 验证方式
- 新增
backend/tests/test_data_scope.py,用内存 SQLite(create_async_engine("sqlite+aiosqlite:///:memory:"),独立于 conftest 的全局库)建 3 部门(1 → 2 → 3)+ 3 用户 + 若干带created_id的示例行。 - 对
Permission(model, auth, db)._permission_condition()断言生成的条件类别,而不是断言数据量:cond = await Permission(UserModel, auth, db)._permission_condition() assert cond is None # is_superuser 或 DATA_SCOPE_ALL sql = str(cond.compile(compile_kwargs={"literal_binds": True})) assert "dept_id IN (1, 2, 3)" in sql or "created_id = 2" in sql - 必测分支:
data_scope=ALL → None、DEPT_AND_CHILD → dept 子树包含自身、SELF → created_id = 当前用户、模型无created_id→None、_load_dept_children抛异常 → 降级为"仅本部门"且捕获到logger.warning(用caplog)。 - 顺带固化 §3.2 的环检测缺陷:构造
parent_id自引用({1: [2], 2: [1]}),断言期望行为是"不抛RecursionError、返回有限集合"——当前会失败,正好作为修复工单的验收依据。
- 新增
- 验收:每条
data_scope至少 1 个正例 + 1 个反例;环用例先标xfail(strict=True)并附 issue 说明,修复后转正。
T3. 会话令牌全生命周期(create / refresh / logout / 滑动续期)
- 为什么:令牌是唯一凭据;
compare_and_set(CAS)+ 双键续期 + 绝对存活上限都是"并发下悄悄失效"的高危逻辑,且现有 Redis mock 缺失eval使它不可能被测到。 - 验证方式
- 先改夹具(这是本轮报告给出的必要前置):把
conftest的手写AsyncMock换成fakeredis.asyncio.FakeRedis(依赖已在 dev 组声明),或至少补齐incr/eval/mget/scan_iter/publish/pubsub/call的语义实现。 - 新增
backend/tests/test_session_lifecycle.py:create_token后断言 Redis 中同时存在access_token:{sid}/refresh_token:{sid}/user_session:{sid}三键且 TTL ≈ 配置值;refresh_token后断言旧 session 失效(返回 401)、is_refresh令牌不能直接访问业务接口;logout后断言三个键都被删除、原 token 请求返回 401(service.py:531–533少删一个键即为漏登出);- CAS:写入 key→A 后并发/串行地先改成 B,再调
compare_and_set(key, expected="A", ...),断言返回False且值仍为 B; - 滑动续期:
TOKEN_SLIDING_EXPIRE=True时把 session 值里的created_at设到SESSION_MAX_LIFETIME_SECONDS之前,断言请求被拒且键被删除(覆盖dependencies.py:125–156的三条分支)。
- 先改夹具(这是本轮报告给出的必要前置):把
- 验收:CAS 的成功与失败两条路径都必须有断言(现在测试环境恒为失败)。
T4. 登录认证与防爆破(LoginService.authenticate_user)
- 为什么:最直接的攻击面;且现有夹具必然测不到限流(
incr缺失 + 127.0.0.1 跳过)。 - 验证方式
- 新增
backend/tests/test_login.py(沿用test_client):- 三类失败:不存在用户、错误密码、
status=1停用 → 断言 HTTP 200 + 业务错误码 +msg文案;同时断言LoginLogModel落库(status=2与对应msg)——这同时覆盖了_write_login_log。 - 成功登录:断言返回
access_token、Redis 有 session、last_login被更新。 - 限流:用 fakeredis +
X-Forwarded-For(或 monkeypatchget_client_ip返回10.0.0.9)连续请求LOGIN_RATE_LIMIT_MAX_ATTEMPTS + 1次,断言第 N+1 次被拒;并单独写一条用例固化"127.0.0.1/unknown跳过限流"的既有行为,让这个安全取舍显式可见。 - 验证码:
CAPTCHA_ENABLE=True时缺captcha_key→ 明确报错;ENVIRONMENT=PROD时Referer: .../docs不再豁免。
- 三类失败:不存在用户、错误密码、
- 新增
- 验收:限流用例必须在"非本机 IP"下运行,否则等于没测。
T5. CRUDBase.page() 的分页/软删语义
- 为什么:所有列表接口都走它;
total/has_next/page_no算错会导致前端分页器错乱,软删过滤算错会导致数据泄漏。 - 验证方式
- 新增
backend/tests/test_crud_page.py:独立内存 SQLite + 一个测试模型(同时含is_deleted、created_id),插入 25 行(其中 3 行is_deleted=True)。 - 断言矩阵:
入参 期望 offset=0, limit=10total=22, page_no=1, page_size=10, has_next=True, len(items)=10offset=20, limit=10page_no=3, has_next=False, len(items)=2offset=25, limit=10items=[],has_next=Falselimit=0不抛 ZeroDivisionError,page_size回落到 10include_deleted=Truetotal=25page()与get_list()同参行集合一致(COUNT/数据两趟查询的条件等价) order_by=[{"id":"desc"}]/ 多字段顺序与预期一致 - 断言
page()抛出的异常类型是CustomException且msg可区分(不要只断言"抛异常")。
- 新增
- 验收:
limit=0与多字段排序必须入用例(前者是边界除零,后者是_parse_order的循环语义)。
T6. 查询算子与查询参数封装(含非法输入的错误语义)
- 为什么:决定了所有列表接口的过滤语义与错误码;探针已证实未知算子静默丢弃条件、非法日期抛裸
ValueError后被包装成统一的"分页查询失败"。 - 验证方式
- 新增
backend/tests/test_query_operators.py,参数化CRUDBase._resolve_condition(纯静态方法,零依赖):输入 期望 ("None", None)/("not None", None)IS NULL/IS NOT NULL,不依赖 val("like", "a")LIKE '%a%'("in", [])false()(恒假,不得退化为"无条件")("between", [1])长度非法 → 明确行为(当前 [],建议改为拒绝)("date", "2026-10-04")>= 00:00且< 次日 00:00("month", "2026-12")跨年到次年 1 月 1 日 ("date", "not-a-date")期望 CustomException(清晰 4xx),当前ValueError("unknown_op", "x")期望 抛错或忽略但记录告警,当前静默返回 []_OPERATOR_MAP8 个比较符attr.__eq__/__ne__/__gt__/...映射正确 - 同一文件覆盖
search_to_dict:xxx_start/xxx_end合并为("between", [s, e])、xxx_time列表合并、json_schema_extra={"q": "like"}打包、None值被排除。
- 新增
- 验收:非法日期这条用例的断言应指向"统一
CustomException",把"500 类错误伪装成业务错误"的问题钉住。
T7. 代码生成器 create_table 的 DDL 白名单(安全)
- 为什么:这是"用户提交 SQL → 服务端执行 DDL"的路径,白名单一旦漏判就是任意建表/建视图/建索引;当前 0 测试。
- 验证方式
- 新增
backend/tests/test_codegen_create_table.py:不连库,monkeypatch掉GenTableCRUD.execute_sql(记录被执行的语句)、check_table_exists(恒 False)、get_gen_table_by_name(恒 None),直接await GenCodeService(auth, db).create_table(sql)。SQL 输入 期望 CREATE TABLE t1 (id INT PRIMARY KEY)通过,且执行语句集合 == {该 DDL} DROP TABLE t1/INSERT/UPDATE/DELETE/TRUNCATE拒绝, msg命中"禁止的关键操作"CREATE TABLE t1 (...); DROP TABLE t1;整体拒绝(破坏性语句检测在 has_create 之后仍生效) CREATE TABLE t1 (...); ALTER TABLE t1 ADD CONSTRAINT fk FOREIGN KEY (a) REFERENCES b(id);通过 ALTER TABLE t1 DROP COLUMN c;拒绝 ALTER TABLE t1 ADD CONSTRAINT fk FOREIGN KEY (a) REFERENCES b(id) ON DELETE SET NULL;当前会误拒(含 SET)→ 用例固化预期行为,暴露字符串判定缺陷CREATE VIEW v AS SELECT 1/CREATE INDEX i ON t1(id)当前会被放行 → 用例断言必须先确定安全预期(建议归为拒绝),作为加固依据 表已存在 / 已在代码生成模块存在 拒绝, msg区分两种原因 - 断言"未执行的语句不被执行"(例如含 DROP 的多语句输入下,
execute_sql调用次数为 0)。
- 新增
- 验收:
CREATE VIEW/CREATE INDEX与ON DELETE SET NULL三条必须显式入用例,否则等于没有白名单。
T8. 代码生成元数据纯函数 + 模板渲染
- 为什么:
gen_util.py(309 行)+jinja2_template_util.py(759 行)是"生成代码给用户跑"的核心,边界输入容易产出语法错误的 Python;纯函数测试成本极低(毫秒级、零依赖)。 - 验证方式
- 新增
backend/tests/test_gen_util.py,参数化:get_db_type:tinyint(1)→boolean、tinyint→tinyint、int UNSIGNED→int、varchar(64) COLLATE utf8mb4_bin→varchar、integer[]/ARRAY[INTEGER]→array、timestamp without time zone→timestamp、""→"";get_column_length:varchar(255)→255、decimal(10,2)→10、numeric(20, 0)→20、无括号→0、varchar()→0、text→0;split_column_type:decimal(10,2)→["10","2"]、无括号→[];arrays_contains:大小写不敏感、COLLATE/UNSIGNED修饰剥离、TINYINT(1)与tinyint的基类型比较;replace_first/convert_class_name(sys_user→SysUser);StringUtil(app/utils/string_util.py)的同族转换。
- 模板:对
jinja2_template_util用最小GenTableSchema渲染一轮,断言compile(rendered, "<gen>", "exec")不抛SyntaxError(比逐字快照更稳,能挡住"生成的代码是坏的"这类真实事故);再对 1–2 个关键模板做字符级快照防意外改动。
- 新增
- 验收:模板用例的断言必须是"能编译"而非"包含某段字符串"。
T9. 操作日志脱敏与响应契约
- 为什么:这是阻止"密码/token 明文入库"的唯一防线;
_redact_sensitive是纯函数,测试成本极低。 - 验证方式
- 新增
backend/tests/test_operation_log.py:- 纯单元(无 DB):
_redact_sensitive({"Password": "x", "nested": {"access_token": "y"}, "list": [{"client_secret": "z"}]})→ 三个值均为"******",非敏感键原样保留;大小写混写(Authorization、SECRET_KEY)命中断言。 - 路由层:
test_client打/system/auth/login(含密码表单)与一个返回 token 的接口,断言OperationLogModel.request_payload不含明文密码、response_json不含access_token明文; - 长度:构造 > 2000 字符的请求体,断言
request_payload == "请求参数过长"; - 非 JSON:
Content-Type: text/plain请求,断言记录走errors="ignore"兜底且不抛异常; - 写库失败:
monkeypatch使async_db_session抛异常,断言响应仍为 200(日志失败不得影响主流程)。
- 纯单元(无 DB):
- 新增
- 验收:
Authorization/access_token两个键必须各有一条"明文不得出现"的负向断言。
T10. 迁移与 schema 一致性守卫升级
- 为什么:迁移漂移是"本地好、线上崩"的典型来源;现有守卫只覆盖 SQLite,且
versions/为空使 upgrade 路径完全没被验证。 - 验证方式(扩展现有
backend/tests/test_migrations.py,不新建平行文件)- 守卫 1/2 保留,新增守卫 3:断言
ALEMBIC_VERSION_DIR的实际状态与启动路径一致——当目录为空时,断言init_db走的确实是"create_all(不 stamp)"分支(可断言alembic_version表不存在或为空 + 捕获对应日志),避免未来悄悄变成"空库被 stamp head"。 - 守卫 4(关键):把一致性检查从 SQLite 扩到 MySQL/Postgres。注意:
MigrationContext.configure(dialect_name="mysql")+compare_metadata无法离线使用(本次实测三种方言均直接AssertionError,因为compare_metadata需要真实Connection做反射),所以分两条腿:- 离线多方言 DDL 渲染(无需数据库,可进常规 CI):先
from app import create_app; create_app()把全部模型 import 进来(否则UserModel.__table__会因sys_user_roles → sys_role外键找不到表而抛NoReferencedTableError),然后逐方言编译:用途:挡住"某字段在 MySQL 下渲染出非法/退化 DDL(如from sqlalchemy.dialects import mysql, postgresql from sqlalchemy.schema import CreateTable from app.core.base_model import MappedBase # 实测 29 张表 for name, dialect in (("mysql", mysql.dialect()), ("postgresql", postgresql.dialect())): for table_name, table in MappedBase.metadata.tables.items(): ddl = str(CreateTable(table).compile(dialect=dialect)) # 实测:sys_user → 1586/1199 字符 assert ddl.strip(), f"{name}.{table_name} 渲染为空" # 断言口径二选一:① 关键表 DDL 字符级快照;② 规则断言(如禁止 "VARCHAR" 无长度、 # 禁止 MySQL 专有修饰出现在 Postgres 渲染结果里),避免快照噪音过大。UNSIGNED、ON UPDATE、COLLATE、无长度VARCHAR)"这类只在方言上暴露的问题。 - 真连接等价检查(
@pytest.mark.integration,按需开启,仓库已有docker/mysqlcompose):连上真实 MySQL/Postgres 后with engine.connect() as conn: diffs = compare_metadata(MigrationContext.configure(conn), MappedBase.metadata) assert diffs == [], diffs # 实测 SQLite 真连接下为 0 diff
- 离线多方言 DDL 渲染(无需数据库,可进常规 CI):先
- 守卫 5:让迁移用例在只读检出下也能跑——把
command.revision(autogenerate=True)的写法改为在临时目录(tmp_path+ 覆盖script_location)中执行,消除测试写仓库的副作用(当前test_migrations.py:31–34会向app/alembic/versions/写文件再删)。 - 若后续补回迁移文件:新增一条"
alembic upgrade head从空库升到 head 后,compare_metadata零 diff"的端到端用例(这是当前最大盲区)。
- 守卫 1/2 保留,新增守卫 3:断言
- 验收:多方言断言必须真编译一次 DDL(不得只 mock
CreateTable),且不要使用MigrationContext.configure(dialect_name=...)冒充等价检查;真连接等价检查须放在有 DB 的 job / 本地显式开启;迁移用例执行后git status必须保持干净。
5. 落地顺序建议(3 个批次)
| 批次 | 内容 | 目的 | 门禁 |
|---|---|---|---|
| 第 1 批(≤2 天) | T1、T6、T8、T9(纯函数/纯单元为主,不需要新夹具)+ 重建 pytest-cov 与覆盖率基线 |
用最低成本拿到"安全 + 公共封装"的回归网 | 后端 uv run pytest 纳入 CI,覆盖率设"不回退"而非固定阈值 |
| 第 2 批(3–5 天) | 夹具改造(fakeredis 替换手写 mock、assert_route 改为对 5xx 失败、增加 async session/事务夹具、逐用例隔离)+ T3、T4、T5、T2 |
让"测试能失败",覆盖凭据与数据权限 | 新增 async 夹具后开启 filterwarnings = error;pytest -q --strict-markers |
| 第 3 批(1–2 周) | T7、T10 + 启动路径(真实 lifespan)冒烟 + 前端 vitest 扩展(request 拦截器、storage、route-loader)+ uniapp 最小 vitest 接入 | 覆盖不可逆操作与真实启动链 | 仓库根新建 .github/workflows/ci.yml:backend(ruff + pytest)+ web(type-check + lint + vitest run)+ app(type-check + lint) |
配套的"制度性"修复(与测试点同等重要,均属零成本配置):
- 在仓库根建 CI(当前为空目录,
git ls-files .github= 0):把frontend/web/.github/workflows/ci.yml的内容上提并补pnpm test;后端加uv sync --group dev && uv run pytest -q。 - 保留
frontend/web的 vitest 并扩面:它已有 10 个真用例,只要进 CI 就能立刻防住路由结构回归。 - 修正
assert_route(backend/tests/conftest.py:240–277):except Exception应转为失败(或改为显式允许 5xx 白名单),默认断言从"!= 404"升级为"status < 500"。 - 把
fakeredis用起来或从 dev 组删掉:声明了却不用,是明确的误导信号。 SKILL.md与实现对齐:文档要求pnpm test/uv run pytest,实际无执行点;要么加门禁,要么改文档。
6. 附:本次审计用到的只读命令
# 1) 后端测试(uv 缓存需重定向;详见 §1.1)
cd backend && uv run pytest -q # 原样执行:uv 缓存权限失败,exit 2
cd backend && UV_CACHE_DIR=/tmp/uv-cache-test uv run pytest -q # exit 0, 2 passed
cd backend && UV_CACHE_DIR=/tmp/uv-cache-test uv run pytest -v # 收集清单
cd backend && .venv/bin/python -m pytest -q # 等价路径(不再触碰 uv.lock)
# 2) 前端测试
cd frontend/web && ./node_modules/.bin/vitest run # exit 0, 2 files / 10 tests
# 3) 规模与路由统计(只读探针,见 /tmp/probe_tests.py)
cd backend && .venv/bin/python -c "from app import create_app; print(len(create_app().openapi()['paths']))" # 205
find backend/app -name '*.py' | wc -l ; find backend/app -name '*.py' -exec cat {} + | wc -l # 216 / 28519
# 4) 测试资产溯源
git ls-files .github # 0(根 CI 为空)
git log --diff-filter=D --name-only -- backend/tests # 删除轨迹
python3 -c "import json;print(len(json.load(open('backend/.pytest_cache/v/cache/nodeids'))))" # 761
7. 环境与合规说明(重要)
- 工作区在本次审计开始前即为脏(其他 teammate 的并行产物):
git status --porcelain显示已修改backend/app/config/setting.py、backend/pyproject.toml、backend/requirements.txt、backend/uv.lock、docker/.env.example,未跟踪backend/requirements/、backend/audit-requirements.md、docker/audit-deploy.md、deploy-artifacts.sh、.agent-teams/。其中pyproject.toml的 mtime 为 03:29(早于本会话),改动内容是把 mysql/pg 驱动从核心依赖迁到optional-dependencies。 backend/uv.lock的 mtime 被本次uv run pytest -q(未加--no-sync)刷新为 03:58。该文件的 diff 内容与上述 03:29 的pyproject.toml改动严格对应(aiomysql/asyncpg/pymysql/psycopg从核心组迁入optional-dependencies,并新增all-db组),未发现与之无关的内容变化;后续所有命令已改为backend/.venv/bin/python或--no-sync,不再触碰锁文件。此为本轮唯一可能非预期的写操作,特此披露。- 除本报告文件
backend/audit-testing.md外,未新建、修改或删除仓库内任何文件;未安装依赖、未启服务、未执行 git 提交。 backend/app/alembic/versions/、backend/tests/在审计前后内容一致(分别只有__init__.py/ 3 个.py)。- 本报告中的"探针实测"结果来自
/tmp/probe_tests.py(临时目录),仅使用内存/SQLite 与纯函数路径,未连接真实 MySQL、PostgreSQL 或 Redis。 - 未取得行覆盖率数字:
pytest-cov与@vitest/coverage-*均未安装,且本轮不安装依赖。所有"缺口"结论基于:OpenAPI 路由清单、模块清单、AuthPermission引用计数、代码路径静态审读与上述探针行为实测。