签入版本
This commit is contained in:
@@ -1,66 +1,60 @@
|
||||
[
|
||||
"sys:ad:add",
|
||||
"sys:ad:batch_delete",
|
||||
"sys:ad:delete",
|
||||
"sys:ad:detail",
|
||||
"sys:ad:page",
|
||||
"sys:ad:status",
|
||||
"sys:ad:update",
|
||||
"sys:adsort:add",
|
||||
"sys:adsort:batch_delete",
|
||||
"sys:adsort:delete",
|
||||
"sys:adsort:detail",
|
||||
"sys:adsort:page",
|
||||
"sys:adsort:update",
|
||||
"sys:article:add",
|
||||
"sys:article:batchDelete",
|
||||
"sys:article:delete",
|
||||
"sys:article:detail",
|
||||
"sys:article:page",
|
||||
"sys:article:status",
|
||||
"sys:article:update",
|
||||
"sys:category:add",
|
||||
"sys:category:delete",
|
||||
"sys:category:detail",
|
||||
"sys:category:page",
|
||||
"sys:category:update",
|
||||
"sys:city:add",
|
||||
"sys:city:batch_delete",
|
||||
"sys:city:batchDelete",
|
||||
"sys:city:delete",
|
||||
"sys:city:detail",
|
||||
"sys:city:page",
|
||||
"sys:city:list",
|
||||
"sys:city:update",
|
||||
"sys:config:add",
|
||||
"sys:config:batch_delete",
|
||||
"sys:config:batchDelete",
|
||||
"sys:config:delete",
|
||||
"sys:config:detail",
|
||||
"sys:config:page",
|
||||
"sys:config:status",
|
||||
"sys:config:update",
|
||||
"sys:configitem:add",
|
||||
"sys:configitem:batch_delete",
|
||||
"sys:configitem:delete",
|
||||
"sys:configitem:detail",
|
||||
"sys:configitem:page",
|
||||
"sys:configitem:status",
|
||||
"sys:configitem:update",
|
||||
"sys:configWeb:data",
|
||||
"sys:configWeb:save",
|
||||
"sys:dept:add",
|
||||
"sys:dept:data",
|
||||
"sys:dept:delete",
|
||||
"sys:dept:detail",
|
||||
"sys:dept:list",
|
||||
"sys:dept:update",
|
||||
"sys:dict:add",
|
||||
"sys:dict:batch_delete",
|
||||
"sys:dict:batchDelete",
|
||||
"sys:dict:delete",
|
||||
"sys:dict:detail",
|
||||
"sys:dict:page",
|
||||
"sys:dict:update",
|
||||
"sys:dictitem:add",
|
||||
"sys:dictitem:batch_delete",
|
||||
"sys:dictitem:delete",
|
||||
"sys:dictitem:detail",
|
||||
"sys:dictitem:page",
|
||||
"sys:dictitem:status",
|
||||
"sys:dictitem:update",
|
||||
"sys:item:add",
|
||||
"sys:item:batch_delete",
|
||||
"sys:item:delete",
|
||||
"sys:item:detail",
|
||||
"sys:item:page",
|
||||
"sys:item:update",
|
||||
"sys:itemcate:add",
|
||||
"sys:itemcate:batch_delete",
|
||||
"sys:itemcate:delete",
|
||||
"sys:itemcate:detail",
|
||||
"sys:itemcate:page",
|
||||
"sys:itemcate:update",
|
||||
"sys:example:add",
|
||||
"sys:example:batchDelete",
|
||||
"sys:example:delete",
|
||||
"sys:example:detail",
|
||||
"sys:example:page",
|
||||
"sys:example:status",
|
||||
"sys:example:update",
|
||||
"sys:fileTemplate:add",
|
||||
"sys:fileTemplate:batchDelete",
|
||||
"sys:fileTemplate:delete",
|
||||
"sys:fileTemplate:detail",
|
||||
"sys:fileTemplate:page",
|
||||
"sys:fileTemplate:update",
|
||||
"sys:generator:batchGenerate",
|
||||
"sys:generator:detail",
|
||||
"sys:generator:generate",
|
||||
"sys:generator:page",
|
||||
"sys:job:add",
|
||||
"sys:job:delete",
|
||||
"sys:job:detail",
|
||||
@@ -71,80 +65,83 @@
|
||||
"sys:job:run_once",
|
||||
"sys:job:status",
|
||||
"sys:job:update",
|
||||
"sys:job_log:batch_delete",
|
||||
"sys:job_log:batchDelete",
|
||||
"sys:job_log:delete",
|
||||
"sys:job_log:detail",
|
||||
"sys:job_log:page",
|
||||
"sys:level:add",
|
||||
"sys:level:batch_delete",
|
||||
"sys:level:batchDelete",
|
||||
"sys:level:delete",
|
||||
"sys:level:detail",
|
||||
"sys:level:export",
|
||||
"sys:level:import",
|
||||
"sys:level:list",
|
||||
"sys:level:page",
|
||||
"sys:level:status",
|
||||
"sys:level:update",
|
||||
"sys:link:add",
|
||||
"sys:link:batch_delete",
|
||||
"sys:link:batchDelete",
|
||||
"sys:link:delete",
|
||||
"sys:link:detail",
|
||||
"sys:link:page",
|
||||
"sys:link:status",
|
||||
"sys:link:update",
|
||||
"sys:login_log:batch_delete",
|
||||
"sys:login_log:delete",
|
||||
"sys:login_log:page",
|
||||
"sys:member:add",
|
||||
"sys:member:batch_delete",
|
||||
"sys:member:delete",
|
||||
"sys:member:detail",
|
||||
"sys:member:page",
|
||||
"sys:member:status",
|
||||
"sys:member:update",
|
||||
"sys:memberlevel:add",
|
||||
"sys:memberlevel:batch_delete",
|
||||
"sys:memberlevel:delete",
|
||||
"sys:memberlevel:detail",
|
||||
"sys:memberlevel:page",
|
||||
"sys:memberlevel:update",
|
||||
"sys:loginLog:batchDelete",
|
||||
"sys:loginLog:delete",
|
||||
"sys:loginLog:detail",
|
||||
"sys:loginLog:page",
|
||||
"sys:menu:add",
|
||||
"sys:menu:delete",
|
||||
"sys:menu:detail",
|
||||
"sys:menu:list",
|
||||
"sys:menu:page",
|
||||
"sys:menu:update",
|
||||
"sys:notice:add",
|
||||
"sys:notice:batch_delete",
|
||||
"sys:notice:batchDelete",
|
||||
"sys:notice:delete",
|
||||
"sys:notice:detail",
|
||||
"sys:notice:page",
|
||||
"sys:notice:update",
|
||||
"sys:operation_log:batch_delete",
|
||||
"sys:operation_log:delete",
|
||||
"sys:operation_log:page",
|
||||
"sys:operationLog:batchDelete",
|
||||
"sys:operationLog:delete",
|
||||
"sys:operationLog:detail",
|
||||
"sys:operationLog:page",
|
||||
"sys:param:add",
|
||||
"sys:param:batchDelete",
|
||||
"sys:param:delete",
|
||||
"sys:param:detail",
|
||||
"sys:param:page",
|
||||
"sys:param:update",
|
||||
"sys:position:add",
|
||||
"sys:position:batch_delete",
|
||||
"sys:position:batchDelete",
|
||||
"sys:position:delete",
|
||||
"sys:position:detail",
|
||||
"sys:position:list",
|
||||
"sys:position:page",
|
||||
"sys:position:status",
|
||||
"sys:position:update",
|
||||
"sys:profile:updatePassword",
|
||||
"sys:role:add",
|
||||
"sys:role:batch_delete",
|
||||
"sys:role:batchDelete",
|
||||
"sys:role:delete",
|
||||
"sys:role:detail",
|
||||
"sys:role:getPermission",
|
||||
"sys:role:list",
|
||||
"sys:role:page",
|
||||
"sys:role:status",
|
||||
"sys:role:savePermission",
|
||||
"sys:role:update",
|
||||
"sys:rolemenu:index",
|
||||
"sys:rolemenu:save",
|
||||
"sys:upload:file",
|
||||
"sys:upload:image",
|
||||
"sys:user:add",
|
||||
"sys:user:batch_delete",
|
||||
"sys:user:batchDelete",
|
||||
"sys:user:delete",
|
||||
"sys:user:detail",
|
||||
"sys:user:export",
|
||||
"sys:user:import",
|
||||
"sys:user:page",
|
||||
"sys:user:resetPwd",
|
||||
"sys:user:status",
|
||||
"sys:user:update"
|
||||
"sys:user:update",
|
||||
"sys:user_role:clear",
|
||||
"sys:user_role:delete",
|
||||
"sys:user_role:list",
|
||||
"sys:user_role:set"
|
||||
]
|
||||
+168
-167
@@ -1,169 +1,170 @@
|
||||
[
|
||||
"DELETE /ad/batchDelete",
|
||||
"DELETE /ad/delete/{id}",
|
||||
"DELETE /adsort/batchDelete",
|
||||
"DELETE /adsort/delete/{id}",
|
||||
"DELETE /city/batchDelete",
|
||||
"DELETE /city/delete/{id}",
|
||||
"DELETE /config/batchDelete",
|
||||
"DELETE /config/delete/{id}",
|
||||
"DELETE /configitem/batchDelete",
|
||||
"DELETE /configitem/delete/{id}",
|
||||
"DELETE /dept/delete/{id}",
|
||||
"DELETE /dict/batchDelete",
|
||||
"DELETE /dict/delete/{id}",
|
||||
"DELETE /dictitem/batchDelete",
|
||||
"DELETE /dictitem/delete/{id}",
|
||||
"DELETE /item/batchDelete",
|
||||
"DELETE /item/delete/{id}",
|
||||
"DELETE /itemcate/batchDelete",
|
||||
"DELETE /itemcate/delete/{id}",
|
||||
"DELETE /job/delete/{id}",
|
||||
"DELETE /joblog/batchDelete",
|
||||
"DELETE /joblog/delete/{id}",
|
||||
"DELETE /level/batchDelete",
|
||||
"DELETE /level/delete/{id}",
|
||||
"DELETE /link/batchDelete",
|
||||
"DELETE /link/delete/{id}",
|
||||
"DELETE /loginlog/batchDelete",
|
||||
"DELETE /loginlog/delete/{id}",
|
||||
"DELETE /member/batchDelete",
|
||||
"DELETE /member/delete/{id}",
|
||||
"DELETE /memberlevel/batchDelete",
|
||||
"DELETE /memberlevel/delete/{id}",
|
||||
"DELETE /menu/delete/{id}",
|
||||
"DELETE /notice/batchDelete",
|
||||
"DELETE /notice/delete/{id}",
|
||||
"DELETE /operationlog/batchDelete",
|
||||
"DELETE /operationlog/delete/{id}",
|
||||
"DELETE /position/batchDelete",
|
||||
"DELETE /position/delete/{id}",
|
||||
"DELETE /role/batchDelete",
|
||||
"DELETE /role/delete/{id}",
|
||||
"DELETE /user/batchDelete",
|
||||
"DELETE /user/delete/{id}",
|
||||
"GET /ad/detail/{id}",
|
||||
"GET /ad/page",
|
||||
"GET /adsort/detail/{id}",
|
||||
"GET /adsort/getAdSortList",
|
||||
"GET /adsort/page",
|
||||
"GET /captcha",
|
||||
"GET /city/detail/{id}",
|
||||
"GET /city/getChildList/{city_code}",
|
||||
"GET /city/page",
|
||||
"GET /config/detail/{id}",
|
||||
"GET /config/page",
|
||||
"GET /config/web",
|
||||
"GET /configitem/detail/{id}",
|
||||
"GET /configitem/page",
|
||||
"GET /dept/detail/{id}",
|
||||
"GET /dept/getDeptList",
|
||||
"GET /dept/list",
|
||||
"GET /dict/detail/{id}",
|
||||
"GET /dict/page",
|
||||
"GET /dictitem/detail/{id}",
|
||||
"GET /dictitem/page",
|
||||
"GET /index/menu",
|
||||
"GET /index/user",
|
||||
"GET /item/detail/{id}",
|
||||
"GET /item/getItemList",
|
||||
"GET /item/page",
|
||||
"GET /itemcate/detail/{id}",
|
||||
"GET /itemcate/getCateList",
|
||||
"GET /itemcate/page",
|
||||
"GET /job/detail/{id}",
|
||||
"GET /job/page",
|
||||
"GET /joblog/detail/{id}",
|
||||
"GET /joblog/page",
|
||||
"GET /level/detail/{id}",
|
||||
"GET /level/getLevelList",
|
||||
"GET /level/page",
|
||||
"GET /link/detail/{id}",
|
||||
"GET /link/page",
|
||||
"GET /loginlog/page",
|
||||
"GET /member/detail/{id}",
|
||||
"GET /member/page",
|
||||
"GET /memberlevel/detail/{id}",
|
||||
"GET /memberlevel/getMemberLevelList",
|
||||
"GET /memberlevel/page",
|
||||
"GET /menu/detail/{id}",
|
||||
"GET /menu/list",
|
||||
"GET /notice/detail/{id}",
|
||||
"GET /notice/page",
|
||||
"GET /operationlog/page",
|
||||
"GET /position/detail/{id}",
|
||||
"GET /position/getPositionList",
|
||||
"GET /position/page",
|
||||
"GET /role/detail/{id}",
|
||||
"GET /role/getRoleList",
|
||||
"GET /role/page",
|
||||
"GET /rolemenu/index/{role_id}",
|
||||
"GET /user/detail/{id}",
|
||||
"GET /user/page",
|
||||
"POST /ad/add",
|
||||
"POST /adsort/add",
|
||||
"POST /city/add",
|
||||
"POST /config/add",
|
||||
"POST /configitem/add",
|
||||
"POST /dept/add",
|
||||
"POST /dict/add",
|
||||
"POST /dictitem/add",
|
||||
"POST /index/logout",
|
||||
"POST /item/add",
|
||||
"POST /itemcate/add",
|
||||
"POST /job/add",
|
||||
"POST /job/reload",
|
||||
"POST /job/runOnce/{id}",
|
||||
"POST /level/add",
|
||||
"POST /level/export",
|
||||
"POST /level/import",
|
||||
"POST /link/add",
|
||||
"POST /login",
|
||||
"POST /member/add",
|
||||
"POST /memberlevel/add",
|
||||
"POST /menu/add",
|
||||
"POST /notice/add",
|
||||
"POST /position/add",
|
||||
"POST /role/add",
|
||||
"POST /rolemenu/save",
|
||||
"POST /upload/uploadEditImage",
|
||||
"POST /upload/uploadFile",
|
||||
"POST /upload/uploadImage",
|
||||
"POST /user/add",
|
||||
"POST /user/resetPwd",
|
||||
"PUT /ad/status",
|
||||
"PUT /ad/update",
|
||||
"PUT /adsort/update",
|
||||
"PUT /city/update",
|
||||
"PUT /config/update",
|
||||
"PUT /config/webSave",
|
||||
"PUT /configitem/status",
|
||||
"PUT /configitem/update",
|
||||
"PUT /dept/update",
|
||||
"PUT /dict/update",
|
||||
"PUT /dictitem/status",
|
||||
"PUT /dictitem/update",
|
||||
"PUT /index/updatePwd",
|
||||
"PUT /index/userInfo",
|
||||
"PUT /item/update",
|
||||
"PUT /itemcate/update",
|
||||
"PUT /job/pause/{id}",
|
||||
"PUT /job/resume/{id}",
|
||||
"PUT /job/status",
|
||||
"PUT /job/update",
|
||||
"PUT /level/status",
|
||||
"PUT /level/update",
|
||||
"PUT /link/status",
|
||||
"PUT /link/update",
|
||||
"PUT /member/status",
|
||||
"PUT /member/update",
|
||||
"PUT /memberlevel/update",
|
||||
"PUT /menu/update",
|
||||
"PUT /notice/update",
|
||||
"PUT /position/status",
|
||||
"PUT /position/update",
|
||||
"PUT /role/status",
|
||||
"PUT /role/update",
|
||||
"PUT /user/status",
|
||||
"PUT /user/update"
|
||||
"DELETE /api/v1/article/batchDelete",
|
||||
"DELETE /api/v1/article/delete/{id}",
|
||||
"DELETE /api/v1/category/delete/{id}",
|
||||
"DELETE /api/v1/city/batchDelete",
|
||||
"DELETE /api/v1/city/delete/{id}",
|
||||
"DELETE /api/v1/config/batchDelete",
|
||||
"DELETE /api/v1/config/delete/{id}",
|
||||
"DELETE /api/v1/config/item/batchDelete",
|
||||
"DELETE /api/v1/config/item/delete/{id}",
|
||||
"DELETE /api/v1/dept/delete/{id}",
|
||||
"DELETE /api/v1/dict/batchDelete",
|
||||
"DELETE /api/v1/dict/delete/{id}",
|
||||
"DELETE /api/v1/dict/item/batchDelete",
|
||||
"DELETE /api/v1/dict/item/delete/{id}",
|
||||
"DELETE /api/v1/example/batchDelete",
|
||||
"DELETE /api/v1/example/delete/{id}",
|
||||
"DELETE /api/v1/file/template/batchDelete",
|
||||
"DELETE /api/v1/file/template/delete/{id}",
|
||||
"DELETE /api/v1/job/delete/{id}",
|
||||
"DELETE /api/v1/job/log/batchDelete",
|
||||
"DELETE /api/v1/job/log/delete/{id}",
|
||||
"DELETE /api/v1/level/batchDelete",
|
||||
"DELETE /api/v1/level/delete/{id}",
|
||||
"DELETE /api/v1/link/batchDelete",
|
||||
"DELETE /api/v1/link/delete/{id}",
|
||||
"DELETE /api/v1/login/log/batchDelete",
|
||||
"DELETE /api/v1/login/log/delete/{id}",
|
||||
"DELETE /api/v1/menu/delete/{id}",
|
||||
"DELETE /api/v1/notice/batchDelete",
|
||||
"DELETE /api/v1/notice/delete/{id}",
|
||||
"DELETE /api/v1/oper/log/batchDelete",
|
||||
"DELETE /api/v1/oper/log/delete/{id}",
|
||||
"DELETE /api/v1/param/batchDelete",
|
||||
"DELETE /api/v1/param/delete/{id}",
|
||||
"DELETE /api/v1/position/batchDelete",
|
||||
"DELETE /api/v1/position/delete/{id}",
|
||||
"DELETE /api/v1/role/batchDelete",
|
||||
"DELETE /api/v1/role/delete/{id}",
|
||||
"DELETE /api/v1/user/batchDelete",
|
||||
"DELETE /api/v1/user/delete/{id}",
|
||||
"DELETE /api/v1/user/role/clear/{userId}",
|
||||
"DELETE /api/v1/user/role/delete/{userId}/{roleId}",
|
||||
"GET /api/v1/article/detail/{id}",
|
||||
"GET /api/v1/article/page",
|
||||
"GET /api/v1/captcha",
|
||||
"GET /api/v1/category/detail/{id}",
|
||||
"GET /api/v1/category/getCategoryData",
|
||||
"GET /api/v1/category/list",
|
||||
"GET /api/v1/city/detail/{id}",
|
||||
"GET /api/v1/city/getCityList/{city_code}",
|
||||
"GET /api/v1/city/list",
|
||||
"GET /api/v1/config/detail/{id}",
|
||||
"GET /api/v1/config/item/detail/{id}",
|
||||
"GET /api/v1/config/item/page",
|
||||
"GET /api/v1/config/page",
|
||||
"GET /api/v1/config/web/index",
|
||||
"GET /api/v1/dept/detail/{id}",
|
||||
"GET /api/v1/dept/getDeptList",
|
||||
"GET /api/v1/dept/list",
|
||||
"GET /api/v1/dict/data/{code}",
|
||||
"GET /api/v1/dict/detail/{id}",
|
||||
"GET /api/v1/dict/item/detail/{id}",
|
||||
"GET /api/v1/dict/item/page",
|
||||
"GET /api/v1/dict/page",
|
||||
"GET /api/v1/example/detail/{id}",
|
||||
"GET /api/v1/example/page",
|
||||
"GET /api/v1/file/template/detail/{id}",
|
||||
"GET /api/v1/file/template/getTemplateByCode/{code}",
|
||||
"GET /api/v1/file/template/page",
|
||||
"GET /api/v1/generator/columns/{table_name}",
|
||||
"GET /api/v1/generator/detail/{table_name}",
|
||||
"GET /api/v1/generator/page",
|
||||
"GET /api/v1/index/getMenus",
|
||||
"GET /api/v1/index/getUser",
|
||||
"GET /api/v1/job/detail/{id}",
|
||||
"GET /api/v1/job/log/detail/{id}",
|
||||
"GET /api/v1/job/log/page",
|
||||
"GET /api/v1/job/page",
|
||||
"GET /api/v1/level/detail/{id}",
|
||||
"GET /api/v1/level/export",
|
||||
"GET /api/v1/level/list",
|
||||
"GET /api/v1/level/page",
|
||||
"GET /api/v1/link/detail/{id}",
|
||||
"GET /api/v1/link/page",
|
||||
"GET /api/v1/login/log/detail/{id}",
|
||||
"GET /api/v1/login/log/page",
|
||||
"GET /api/v1/logout",
|
||||
"GET /api/v1/menu/detail/{id}",
|
||||
"GET /api/v1/menu/list",
|
||||
"GET /api/v1/notice/detail/{id}",
|
||||
"GET /api/v1/notice/page",
|
||||
"GET /api/v1/oper/log/detail/{id}",
|
||||
"GET /api/v1/oper/log/page",
|
||||
"GET /api/v1/param/detail/{id}",
|
||||
"GET /api/v1/param/page",
|
||||
"GET /api/v1/position/detail/{id}",
|
||||
"GET /api/v1/position/list",
|
||||
"GET /api/v1/position/page",
|
||||
"GET /api/v1/role/detail/{id}",
|
||||
"GET /api/v1/role/list",
|
||||
"GET /api/v1/role/menu/list/{role_id}",
|
||||
"GET /api/v1/role/page",
|
||||
"GET /api/v1/user/detail/{id}",
|
||||
"GET /api/v1/user/export",
|
||||
"GET /api/v1/user/page",
|
||||
"GET /api/v1/user/role/list/{userId}",
|
||||
"POST /api/v1/article/add",
|
||||
"POST /api/v1/category/add",
|
||||
"POST /api/v1/city/add",
|
||||
"POST /api/v1/config/add",
|
||||
"POST /api/v1/config/item/add",
|
||||
"POST /api/v1/config/web/save",
|
||||
"POST /api/v1/dept/add",
|
||||
"POST /api/v1/dict/add",
|
||||
"POST /api/v1/dict/item/add",
|
||||
"POST /api/v1/example/add",
|
||||
"POST /api/v1/file/template/add",
|
||||
"POST /api/v1/generator/batchGenerate",
|
||||
"POST /api/v1/generator/generate",
|
||||
"POST /api/v1/job/add",
|
||||
"POST /api/v1/job/reload",
|
||||
"POST /api/v1/job/runOnce/{id}",
|
||||
"POST /api/v1/job/status",
|
||||
"POST /api/v1/level/add",
|
||||
"POST /api/v1/level/import",
|
||||
"POST /api/v1/link/add",
|
||||
"POST /api/v1/login",
|
||||
"POST /api/v1/menu/add",
|
||||
"POST /api/v1/notice/add",
|
||||
"POST /api/v1/param/add",
|
||||
"POST /api/v1/position/add",
|
||||
"POST /api/v1/role/add",
|
||||
"POST /api/v1/role/menu/save",
|
||||
"POST /api/v1/upload/uploadFile",
|
||||
"POST /api/v1/user/add",
|
||||
"POST /api/v1/user/import",
|
||||
"PUT /api/v1/article/status",
|
||||
"PUT /api/v1/article/update",
|
||||
"PUT /api/v1/category/update",
|
||||
"PUT /api/v1/city/update",
|
||||
"PUT /api/v1/config/item/status",
|
||||
"PUT /api/v1/config/item/update",
|
||||
"PUT /api/v1/config/update",
|
||||
"PUT /api/v1/dept/update",
|
||||
"PUT /api/v1/dict/item/update",
|
||||
"PUT /api/v1/dict/update",
|
||||
"PUT /api/v1/example/status",
|
||||
"PUT /api/v1/example/update",
|
||||
"PUT /api/v1/file/template/update",
|
||||
"PUT /api/v1/index/updatePassword",
|
||||
"PUT /api/v1/index/updateProfile",
|
||||
"PUT /api/v1/job/pause/{id}",
|
||||
"PUT /api/v1/job/resume/{id}",
|
||||
"PUT /api/v1/job/update",
|
||||
"PUT /api/v1/level/status",
|
||||
"PUT /api/v1/level/update",
|
||||
"PUT /api/v1/link/status",
|
||||
"PUT /api/v1/link/update",
|
||||
"PUT /api/v1/menu/update",
|
||||
"PUT /api/v1/notice/update",
|
||||
"PUT /api/v1/param/update",
|
||||
"PUT /api/v1/position/status",
|
||||
"PUT /api/v1/position/update",
|
||||
"PUT /api/v1/role/update",
|
||||
"PUT /api/v1/user/resetPwd",
|
||||
"PUT /api/v1/user/role/set",
|
||||
"PUT /api/v1/user/status",
|
||||
"PUT /api/v1/user/update"
|
||||
]
|
||||
@@ -1,24 +1,22 @@
|
||||
[
|
||||
"fastapi_ad",
|
||||
"fastapi_ad_sort",
|
||||
"fastapi_article",
|
||||
"fastapi_category",
|
||||
"fastapi_city",
|
||||
"fastapi_config",
|
||||
"fastapi_config_data",
|
||||
"fastapi_config_item",
|
||||
"fastapi_dept",
|
||||
"fastapi_dict",
|
||||
"fastapi_dict_data",
|
||||
"fastapi_item",
|
||||
"fastapi_item_cate",
|
||||
"fastapi_dict_item",
|
||||
"fastapi_file_template",
|
||||
"fastapi_job",
|
||||
"fastapi_job_log",
|
||||
"fastapi_level",
|
||||
"fastapi_link",
|
||||
"fastapi_login_log",
|
||||
"fastapi_member",
|
||||
"fastapi_member_level",
|
||||
"fastapi_menu",
|
||||
"fastapi_notice",
|
||||
"fastapi_operation_log",
|
||||
"fastapi_param",
|
||||
"fastapi_position",
|
||||
"fastapi_role",
|
||||
"fastapi_role_menu",
|
||||
|
||||
+120
-1
@@ -12,6 +12,8 @@ import json
|
||||
import os
|
||||
import re
|
||||
|
||||
from sqlalchemy import BigInteger, DateTime, Date, Float, Integer, String, Text
|
||||
|
||||
GOLDEN_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "golden")
|
||||
|
||||
PERMISSION_RE = re.compile(r'@permission_required\(\s*["\'](sys:[^"\']+)["\']')
|
||||
@@ -35,7 +37,7 @@ def collect_routes():
|
||||
def collect_permissions():
|
||||
"""静态扫描源码中的 @permission_required("sys:...") 权限串。"""
|
||||
perms = set()
|
||||
for root in ("apps", "modules"):
|
||||
for root in ("src/api/v1/endpoints", "src/modules"):
|
||||
for path in glob.glob(os.path.join(root, "**", "*.py"), recursive=True):
|
||||
with open(path, encoding="utf-8") as f:
|
||||
perms.update(PERMISSION_RE.findall(f.read()))
|
||||
@@ -64,3 +66,120 @@ def compare_snapshot(name: str, actual: list):
|
||||
f"新增: {sorted(set(actual) - set(golden))}\n"
|
||||
f"缺失: {sorted(set(golden) - set(actual))}"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 模型 ↔ SQL dump 建表结构一致性
|
||||
# ============================================================
|
||||
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
SQL_SCHEMA_PATH = os.path.join(PROJECT_ROOT, "document", "djangoadmin.fastapi.elevue.sql")
|
||||
|
||||
|
||||
def parse_sql_type(type_tail: str):
|
||||
"""从列定义中解析 SQL 类型,归一化到与模型一致的 (类型族, 长度)。
|
||||
|
||||
SQL 方言与归一化规则:
|
||||
int/int(0)/tinyint(1)/smallint/mediumint/bit(1) -> "int"
|
||||
bigint -> "bigint"
|
||||
datetime(0)/datetime(6)/timestamp -> "datetime"
|
||||
date -> "date"
|
||||
varchar(n)/char(n) -> ("varchar", n)
|
||||
text/tinytext/mediumtext/longtext -> "text"
|
||||
float/double/decimal -> "float"
|
||||
"""
|
||||
m = re.match(r"([a-z]+)(?:\((\d+)\))?", type_tail.strip().lower())
|
||||
if not m:
|
||||
raise ValueError(f"无法解析 SQL 类型: {type_tail!r}")
|
||||
name, length = m.group(1), m.group(2)
|
||||
if name in ("int", "tinyint", "smallint", "mediumint", "bit"):
|
||||
return ("int", None)
|
||||
if name == "bigint":
|
||||
return ("bigint", None)
|
||||
if name in ("datetime", "timestamp"):
|
||||
return ("datetime", None)
|
||||
if name == "date":
|
||||
return ("date", None)
|
||||
if name in ("varchar", "char"):
|
||||
return ("varchar", int(length))
|
||||
if name in ("text", "tinytext", "mediumtext", "longtext"):
|
||||
return ("text", None)
|
||||
if name in ("float", "double", "decimal"):
|
||||
return ("float", None)
|
||||
raise ValueError(f"无法解析 SQL 类型: {type_tail!r}")
|
||||
|
||||
|
||||
def collect_sql_schema():
|
||||
"""解析 SQL dump 中的 CREATE TABLE,产出 {表名: {列名: (类型族, 长度, 可空)}} 与 {表名: 主键集}。
|
||||
|
||||
兼容 Navicat 导出(Style A:`` `col` int(0) NULL DEFAULT NULL ``)与
|
||||
手写(Style B:`` `col` int NOT NULL ``)两种格式。
|
||||
"""
|
||||
with open(SQL_SCHEMA_PATH, encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
schema, pk_map = {}, {}
|
||||
for m in re.finditer(r"CREATE TABLE\s+`([^`]+)`\s*\((.*?)\)\s*ENGINE", content, re.S):
|
||||
table = m.group(1)
|
||||
columns, pks = {}, set()
|
||||
for line in m.group(2).splitlines():
|
||||
s = line.strip().rstrip(",")
|
||||
if not s or s.startswith("PRIMARY KEY"):
|
||||
if s.startswith("PRIMARY KEY"):
|
||||
pk_m = re.search(r"`([^`]+)`", s)
|
||||
if pk_m:
|
||||
pks.add(pk_m.group(1))
|
||||
continue
|
||||
if s.startswith(("KEY ", "UNIQUE KEY", "INDEX ", "FULLTEXT ")):
|
||||
continue
|
||||
col_m = re.match(r"`([^`]+)`\s+(.+)$", s)
|
||||
if not col_m:
|
||||
continue
|
||||
col, type_tail = col_m.group(1), col_m.group(2)
|
||||
# 忽略 COMMENT 子句后再判断可空,避免注释文本误含 NOT NULL
|
||||
pre = type_tail.split("COMMENT")[0]
|
||||
nullable = "NOT NULL" not in pre.upper()
|
||||
columns[col] = (*parse_sql_type(type_tail), nullable)
|
||||
schema[table] = columns
|
||||
pk_map[table] = pks
|
||||
return schema, pk_map
|
||||
|
||||
|
||||
def model_type(col):
|
||||
"""将 SQLAlchemy 列类型映射到与 parse_sql_type 相同的 (类型族, 长度)。"""
|
||||
t = col.type
|
||||
if isinstance(t, BigInteger):
|
||||
return ("bigint", None)
|
||||
if isinstance(t, Integer):
|
||||
return ("int", None)
|
||||
if isinstance(t, DateTime):
|
||||
return ("datetime", None)
|
||||
if isinstance(t, Date):
|
||||
return ("date", None)
|
||||
# 注意:Text 是 String 的子类,必须先判断 Text
|
||||
if isinstance(t, Text):
|
||||
return ("text", None)
|
||||
if isinstance(t, String):
|
||||
return ("varchar", t.length)
|
||||
if isinstance(t, Float):
|
||||
return ("float", None)
|
||||
raise ValueError(f"无法映射模型列类型: {col} ({t!r})")
|
||||
|
||||
|
||||
def collect_model_schema():
|
||||
"""通过 create_app 强制加载全部模型,从 Base.metadata 产出与 collect_sql_schema 同构的数据。"""
|
||||
from core.app import create_app
|
||||
|
||||
create_app()
|
||||
|
||||
from core.database import Base
|
||||
|
||||
schema, pk_map = {}, {}
|
||||
for table_name, table in Base.metadata.tables.items():
|
||||
columns, pks = {}, set()
|
||||
for col in table.columns:
|
||||
columns[col.name] = (*model_type(col), col.nullable)
|
||||
if col.primary_key:
|
||||
pks.add(col.name)
|
||||
schema[table_name] = columns
|
||||
pk_map[table_name] = pks
|
||||
return schema, pk_map
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""模型 ↔ SQL dump 建表结构一致性契约测试。
|
||||
|
||||
document/djangoadmin.fastapi.elevue.sql 是建表事实来源
|
||||
(scripts/init_db.py 以 SQL 为准、模型仅 backfill),因此 SQLAlchemy 模型
|
||||
metadata 必须与 SQL 建表语句保持一致,否则 ORM 读写与建表脚本会产生隐蔽漂移。
|
||||
|
||||
对比维度:表集合、列名集合、每列 (类型族, 长度, 可空)、每表主键集合。
|
||||
忽略:default/server_default、字符集、排序规则、显示宽度、索引、自增起始值。
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from tests.contract.helpers import collect_model_schema, collect_sql_schema
|
||||
|
||||
pytestmark = pytest.mark.contract
|
||||
|
||||
|
||||
def test_table_sets_match():
|
||||
sql_schema, _ = collect_sql_schema()
|
||||
model_schema, _ = collect_model_schema()
|
||||
assert set(sql_schema) == set(model_schema), (
|
||||
f"表集合不一致!\n"
|
||||
f"仅SQL存在: {sorted(set(sql_schema) - set(model_schema))}\n"
|
||||
f"仅模型存在: {sorted(set(model_schema) - set(sql_schema))}"
|
||||
)
|
||||
|
||||
|
||||
def test_columns_and_types_match():
|
||||
sql_schema, sql_pk = collect_sql_schema()
|
||||
model_schema, model_pk = collect_model_schema()
|
||||
assert set(sql_schema) == set(model_schema), "表集合不一致,先修复表集合再对比列"
|
||||
for table in sorted(sql_schema):
|
||||
sql_cols = set(sql_schema[table])
|
||||
model_cols = set(model_schema[table])
|
||||
assert sql_cols == model_cols, (
|
||||
f"[{table}] 列名不一致!\n"
|
||||
f"仅SQL存在: {sorted(sql_cols - model_cols)}\n"
|
||||
f"仅模型存在: {sorted(model_cols - sql_cols)}"
|
||||
)
|
||||
for col in sorted(sql_cols):
|
||||
assert sql_schema[table][col] == model_schema[table][col], (
|
||||
f"[{table}.{col}] 定义不一致: SQL={sql_schema[table][col]} "
|
||||
f"模型={model_schema[table][col]}"
|
||||
)
|
||||
assert sql_pk[table] == model_pk[table], (
|
||||
f"[{table}] 主键不一致: SQL={sorted(sql_pk[table])} 模型={sorted(model_pk[table])}"
|
||||
)
|
||||
@@ -7,24 +7,28 @@ pytestmark = pytest.mark.integration
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_flow(client):
|
||||
"""完整登录链路:/captcha → 解析验证码 → /login 获取 token。"""
|
||||
"""完整登录链路:/api/v1/captcha → 解析验证码 → /api/v1/login 获取 token。"""
|
||||
import redis as redis_lib
|
||||
|
||||
from config import REDIS_INDEX
|
||||
from core.config import REDIS_AUTH, REDIS_HOST, REDIS_INDEX, REDIS_PASSWORD, REDIS_PORT
|
||||
|
||||
r = await client.get("/captcha")
|
||||
r = await client.get("/api/v1/captcha")
|
||||
body = r.json()
|
||||
assert body["code"] == 0
|
||||
idkey = body.get("idkey")
|
||||
assert idkey
|
||||
|
||||
# 从 Redis 读取验证码明文
|
||||
redis_client = redis_lib.Redis(db=REDIS_INDEX)
|
||||
# 从 Redis 读取验证码明文(带认证参数,与 app 连接一致)
|
||||
redis_client = redis_lib.Redis(
|
||||
host=REDIS_HOST, port=REDIS_PORT,
|
||||
password=REDIS_PASSWORD if REDIS_AUTH else None,
|
||||
db=REDIS_INDEX,
|
||||
)
|
||||
code = redis_client.get(idkey).decode()
|
||||
|
||||
resp = await client.post("/login", json={
|
||||
resp = await client.post("/api/v1/login", json={
|
||||
"username": "admin", "password": "123456",
|
||||
"captcha": code, "idKey": idkey,
|
||||
"code": code, "key": idkey,
|
||||
})
|
||||
login = resp.json()
|
||||
assert login["code"] == 0
|
||||
@@ -34,7 +38,8 @@ async def test_login_flow(client):
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_read_endpoints(client, admin_headers):
|
||||
"""管理员 token 可访问代表性只读接口。"""
|
||||
for path in ("/user/page", "/dept/list", "/menu/list", "/index/menu", "/job/page"):
|
||||
for path in ("/api/v1/user/page", "/api/v1/dept/list", "/api/v1/menu/list",
|
||||
"/api/v1/index/menu", "/api/v1/job/page"):
|
||||
resp = await client.get(path, headers=admin_headers)
|
||||
assert resp.json()["code"] == 0, f"{path} failed"
|
||||
|
||||
@@ -42,5 +47,5 @@ async def test_admin_read_endpoints(client, admin_headers):
|
||||
@pytest.mark.asyncio
|
||||
async def test_unauthorized_blocked(client):
|
||||
"""无 token 访问受保护接口应返回 401。"""
|
||||
resp = await client.get("/user/page")
|
||||
resp = await client.get("/api/v1/user/page")
|
||||
assert resp.json()["code"] == 401
|
||||
|
||||
@@ -1,77 +1,80 @@
|
||||
"""事务与操作日志集成测试:db_session_middleware 的 commit/rollback 分支、operation_log 独立落库。
|
||||
|
||||
需真实 MySQL/Redis 运行(默认标记 integration 跳过)。
|
||||
需真实 MySQL/PostgreSQL/SQL Server/SQLite + Redis 运行(默认标记 integration 跳过)。
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import pymysql
|
||||
from sqlalchemy import text
|
||||
|
||||
from config import DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME, DB_PASSWORD
|
||||
from core.config import DB_PREFIX
|
||||
from core.database import engine
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
def _query_count(table, where, *args):
|
||||
conn = pymysql.connect(host=DB_HOST, port=int(DB_PORT), user=DB_USERNAME,
|
||||
password=DB_PASSWORD, database=DB_DATABASE)
|
||||
cur = conn.cursor()
|
||||
cur.execute(f"SELECT COUNT(*) FROM `{table}` WHERE {where}", args)
|
||||
n = cur.fetchone()[0]
|
||||
conn.close()
|
||||
return n
|
||||
def _params(*values):
|
||||
"""位置参数 → 命名参数(p1/p2/...),无参数返回 None。"""
|
||||
return {f"p{i + 1}": v for i, v in enumerate(values)} if values else None
|
||||
|
||||
|
||||
def _execute(sql, *args):
|
||||
conn = pymysql.connect(host=DB_HOST, port=int(DB_PORT), user=DB_USERNAME,
|
||||
password=DB_PASSWORD, database=DB_DATABASE)
|
||||
cur = conn.cursor()
|
||||
cur.execute(sql, args)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
def _query_count(table, where, *values):
|
||||
with engine.connect() as conn:
|
||||
return conn.execute(
|
||||
text(f"SELECT COUNT(*) FROM {table} WHERE {where}"), _params(*values)
|
||||
).scalar()
|
||||
|
||||
|
||||
_VALID_DEPT = {"name": "测试部门T", "code": "TESTT", "type": 1, "pid": 0, "sort": 99, "status": 1}
|
||||
def _execute(sql, *values):
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text(sql), _params(*values))
|
||||
|
||||
|
||||
def _table(name):
|
||||
return DB_PREFIX + name
|
||||
|
||||
|
||||
_VALID_DEPT = {"name": "测试部门T", "code": "TESTT", "type": 1, "parentId": 0, "sort": 99, "status": 1}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_commit_persists(client, admin_headers):
|
||||
"""业务成功的写操作应被 db_session_middleware 提交。"""
|
||||
r = await client.post("/dept/add", headers=admin_headers, json=_VALID_DEPT)
|
||||
r = await client.post("/api/v1/dept/add", headers=admin_headers, json=_VALID_DEPT)
|
||||
assert r.json()["code"] == 0
|
||||
assert _query_count("fastapi_dept", "name=%s", "测试部门T") >= 1
|
||||
assert _query_count(_table("dept"), "name=:p1", "测试部门T") >= 1
|
||||
# 清理测试数据
|
||||
_execute("DELETE FROM fastapi_dept WHERE name=%s", "测试部门T")
|
||||
_execute(f"DELETE FROM {_table('dept')} WHERE name=:p1", "测试部门T")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_rollback_on_validation_error(client, admin_headers):
|
||||
"""参数校验失败(code!=0)时请求事务应回滚,不产生脏数据。"""
|
||||
before = _query_count("fastapi_dept", "name=%s", "测试部门X")
|
||||
r = await client.post("/dept/add", headers=admin_headers, json={"name": "测试部门X"}) # 缺必填字段
|
||||
before = _query_count(_table("dept"), "name=:p1", "测试部门X")
|
||||
r = await client.post("/api/v1/dept/add", headers=admin_headers, json={"name": "测试部门X"}) # 缺必填字段
|
||||
assert r.json()["code"] != 0
|
||||
assert _query_count("fastapi_dept", "name=%s", "测试部门X") == before
|
||||
assert _query_count(_table("dept"), "name=:p1", "测试部门X") == before
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_operation_log_persists_on_success(client, admin_headers):
|
||||
"""成功的写操作后,operation_log 应通过独立会话落库(标题正确)。"""
|
||||
before = _query_count("fastapi_operation_log", "1=1")
|
||||
r = await client.post("/dept/add", headers=admin_headers, json=_VALID_DEPT)
|
||||
before = _query_count(_table("operation_log"), "1=1")
|
||||
r = await client.post("/api/v1/dept/add", headers=admin_headers, json=_VALID_DEPT)
|
||||
assert r.json()["code"] == 0
|
||||
after = _query_count("fastapi_operation_log", "1=1")
|
||||
after = _query_count(_table("operation_log"), "1=1")
|
||||
assert after >= before + 1, "operation_log 未新增记录"
|
||||
_execute("DELETE FROM fastapi_dept WHERE name=%s", "测试部门T")
|
||||
_execute(f"DELETE FROM {_table('dept')} WHERE name=:p1", "测试部门T")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_operation_log_persists_on_failure(client, admin_headers):
|
||||
"""业务失败的写操作也应记录 operation_log(status=1),且独立会话不受请求回滚影响。"""
|
||||
# 构造一个通过参数校验但业务失败的操作:部门编码重复(唯一性校验)
|
||||
await client.post("/dept/add", headers=admin_headers, json=_VALID_DEPT)
|
||||
before = _query_count("fastapi_operation_log", "1=1")
|
||||
await client.post("/api/v1/dept/add", headers=admin_headers, json=_VALID_DEPT)
|
||||
before = _query_count(_table("operation_log"), "1=1")
|
||||
# 再次添加同名同编码 → 业务失败
|
||||
r = await client.post("/dept/add", headers=admin_headers, json=_VALID_DEPT)
|
||||
r = await client.post("/api/v1/dept/add", headers=admin_headers, json=_VALID_DEPT)
|
||||
assert r.json()["code"] != 0
|
||||
after = _query_count("fastapi_operation_log", "1=1")
|
||||
after = _query_count(_table("operation_log"), "1=1")
|
||||
assert after >= before + 1, "业务失败的写操作未记录 operation_log"
|
||||
_execute("DELETE FROM fastapi_dept WHERE name=%s", "测试部门T")
|
||||
_execute(f"DELETE FROM {_table('dept')} WHERE name=:p1", "测试部门T")
|
||||
|
||||
@@ -0,0 +1,742 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""BUG-5 ~ BUG-9 及 H-1 ~ H-4 修复验证测试。"""
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, 'src')
|
||||
|
||||
|
||||
# ============================================================
|
||||
# BUG-5: 权限装饰器走缓存(异步 + 同步端点)
|
||||
# ============================================================
|
||||
|
||||
def _make_perm_app(monkeypatch, user_id, perm_calls, denied=False):
|
||||
from fastapi import FastAPI
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
|
||||
from core.access_decorators import permission_required
|
||||
|
||||
async def fake_get_permissions_list(uid):
|
||||
perm_calls.append(uid)
|
||||
return [] if denied else ['sys:x:list']
|
||||
|
||||
import modules.system.menu.service as menu_service
|
||||
monkeypatch.setattr(menu_service, 'get_permissions_list', fake_get_permissions_list)
|
||||
sync_db_calls = []
|
||||
monkeypatch.setattr(
|
||||
menu_service, 'get_permissions_list_sync',
|
||||
lambda uid: sync_db_calls.append(uid) or ([] if denied else ['sys:x:list']))
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
@app.middleware("http")
|
||||
async def inject_user(request: Request, call_next):
|
||||
request.state.user_id = user_id
|
||||
return await call_next(request)
|
||||
|
||||
@app.get("/sync")
|
||||
@permission_required("sys:x:list")
|
||||
def sync_ep(request: Request):
|
||||
return {"code": 0, "ok": True}
|
||||
|
||||
@app.get("/async")
|
||||
@permission_required("sys:x:list")
|
||||
async def async_ep(request: Request):
|
||||
return {"code": 0, "ok": True}
|
||||
|
||||
return app, sync_db_calls
|
||||
|
||||
|
||||
def test_perm_decorator_sync_endpoint_uses_sync_check(monkeypatch):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
calls = []
|
||||
app, sync_db_calls = _make_perm_app(monkeypatch, user_id=2, perm_calls=calls)
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/sync")
|
||||
assert resp.json()["ok"] is True
|
||||
# 同步端点直接走同步 DB 校验(不再每请求新建事件循环 / 依赖 Redis 缓存)
|
||||
assert calls == []
|
||||
assert sync_db_calls == [2]
|
||||
|
||||
|
||||
def test_perm_decorator_async_endpoint_uses_cache(monkeypatch):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
calls = []
|
||||
app, sync_db_calls = _make_perm_app(monkeypatch, user_id=2, perm_calls=calls)
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/async")
|
||||
assert resp.json()["ok"] is True
|
||||
assert calls == [2]
|
||||
assert sync_db_calls == []
|
||||
|
||||
|
||||
def test_perm_decorator_denied(monkeypatch):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
calls = []
|
||||
app, sync_db_calls = _make_perm_app(
|
||||
monkeypatch, user_id=2, perm_calls=calls, denied=True)
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/async")
|
||||
assert resp.json()["code"] == 1
|
||||
assert resp.json()["ok"] is False
|
||||
|
||||
|
||||
def test_perm_decorator_admin_bypass(monkeypatch):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
calls = []
|
||||
app, sync_db_calls = _make_perm_app(monkeypatch, user_id=1, perm_calls=calls)
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/async")
|
||||
assert resp.json()["ok"] is True
|
||||
assert calls == [] # 管理员不查权限
|
||||
|
||||
|
||||
# ============================================================
|
||||
# BUG-6: CORS 为最外层
|
||||
# ============================================================
|
||||
|
||||
def test_cors_is_outermost():
|
||||
from core.app import create_app
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
|
||||
app = create_app()
|
||||
classes = [m.cls for m in app.user_middleware]
|
||||
assert classes[0] is CORSMiddleware
|
||||
|
||||
|
||||
# ============================================================
|
||||
# BUG-7: 时间字段序列化统一(naive/aware UTC 均转本地)
|
||||
# ============================================================
|
||||
|
||||
def test_to_dict_naive_utc_matches_aware_utc():
|
||||
from datetime import datetime, timezone
|
||||
from modules.system.menu.models import Menu
|
||||
|
||||
naive_obj = Menu()
|
||||
naive_obj.create_time = datetime(2026, 1, 1, 8, 0, 0) # naive,表示 UTC 08:00
|
||||
aware_obj = Menu()
|
||||
aware_obj.create_time = datetime(2026, 1, 1, 8, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
d1 = naive_obj.to_dict()
|
||||
d2 = aware_obj.to_dict()
|
||||
# 两者应一致(本地时间),证明读回 naive 不再显示 UTC 原始值
|
||||
assert d1['create_time'] == d2['create_time']
|
||||
expected = datetime(2026, 1, 1, 8, 0, 0, tzinfo=timezone.utc) \
|
||||
.astimezone().strftime('%Y-%m-%d %H:%M:%S')
|
||||
assert d1['create_time'] == expected
|
||||
|
||||
|
||||
# ============================================================
|
||||
# BUG-8: 导出 URL 反斜杠转 / 分隔
|
||||
# ============================================================
|
||||
|
||||
def test_export_level_url_uses_forward_slashes(monkeypatch, tmp_path):
|
||||
from fastapi import FastAPI
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
import modules.system.level.service as level_svc
|
||||
import time
|
||||
|
||||
up = tmp_path / 'uploads'
|
||||
temp = up / 'temp'
|
||||
monkeypatch.setattr(level_svc, 'FASTAPI_UPLOAD_DIR', str(up))
|
||||
monkeypatch.setattr(level_svc, 'FASTAPI_TEMP_PATH', str(temp))
|
||||
monkeypatch.setattr(level_svc, 'FASTAPI_FILE_URL', 'http://img.test')
|
||||
monkeypatch.setattr(level_svc, 'get_db_session', lambda: None)
|
||||
|
||||
class FakeQuery:
|
||||
def filter(self, *a, **k):
|
||||
return self
|
||||
def order_by(self, *a, **k):
|
||||
return self
|
||||
def limit(self, *a, **k):
|
||||
return self
|
||||
def yield_per(self, *a, **k):
|
||||
return iter(()) # 无数据,跳过导出与 expunge
|
||||
|
||||
class FakeRepo:
|
||||
def filter_by(self):
|
||||
return FakeQuery()
|
||||
|
||||
monkeypatch.setattr(level_svc.level_service, 'repo', FakeRepo())
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
@app.middleware("http")
|
||||
async def inject(request: Request, call_next):
|
||||
request.state.user_id = 1
|
||||
return await call_next(request)
|
||||
|
||||
@app.get('/export')
|
||||
def export(request: Request):
|
||||
return level_svc.level_service.export_level(request)
|
||||
|
||||
with TestClient(app) as client:
|
||||
resp = client.get('/export')
|
||||
url = resp.json()['data']
|
||||
assert url.startswith('http://img.test/')
|
||||
assert '\\' not in url
|
||||
assert '/temp/' in url
|
||||
assert url.endswith('.xlsx')
|
||||
|
||||
|
||||
# ============================================================
|
||||
# BUG-9: UserForm.city/roles 由 Any 收紧为带类型校验
|
||||
# ============================================================
|
||||
|
||||
def _valid_user_form():
|
||||
from datetime import date
|
||||
return dict(
|
||||
realname='张三', nickname='张三', gender=1, avatar='a.png',
|
||||
mobile='13800000000', email='a@b.com', birthday=date(2000, 1, 1),
|
||||
deptId=1, levelId=1, positionId=1,
|
||||
city=['110000', '110100', '110101'], address='北京市',
|
||||
username='zhangsan', password='123456', status=1, sort=1, roles=[1, 2],
|
||||
)
|
||||
|
||||
|
||||
def test_user_form_city_rejects_string_and_oversize():
|
||||
from pydantic import ValidationError
|
||||
from modules.system.user.schemas import UserForm
|
||||
|
||||
base = _valid_user_form()
|
||||
# 合法数据应通过
|
||||
UserForm(**base)
|
||||
# city 传字符串应拒绝
|
||||
with pytest.raises(ValidationError):
|
||||
UserForm(**{**base, 'city': '北京市'})
|
||||
# city 元素非字符串(Pydantic v2 不隐式转 str)应拒绝
|
||||
with pytest.raises(ValidationError):
|
||||
UserForm(**{**base, 'city': [1, 2, 3]})
|
||||
# city 超过 3 个元素应拒绝
|
||||
with pytest.raises(ValidationError):
|
||||
UserForm(**{**base, 'city': ['1', '2', '3', '4']})
|
||||
|
||||
|
||||
def test_user_form_roles_rejects_string():
|
||||
from pydantic import ValidationError
|
||||
from modules.system.user.schemas import UserForm
|
||||
|
||||
base = _valid_user_form()
|
||||
# roles 传字符串应拒绝
|
||||
with pytest.raises(ValidationError):
|
||||
UserForm(**{**base, 'roles': '1,2'})
|
||||
# roles 合法整型列表应通过
|
||||
form = UserForm(**{**base, 'roles': [1, 2]})
|
||||
assert form.roles == [1, 2]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# H-1: add_user 排除 id(防批量赋值伪造)
|
||||
# ============================================================
|
||||
|
||||
def _make_request(user_id=1):
|
||||
import types
|
||||
req = types.SimpleNamespace()
|
||||
req.state = types.SimpleNamespace(user_id=user_id)
|
||||
return req
|
||||
|
||||
|
||||
def test_add_user_rejects_forged_id(sqlite_db, monkeypatch):
|
||||
import asyncio
|
||||
from modules.system.user import service as user_svc
|
||||
from modules.system.user.schemas import UserForm
|
||||
from modules.system.user.repository import user_repo
|
||||
|
||||
# 跳过头像文件落盘,直接返回原值
|
||||
monkeypatch.setattr(user_svc, 'save_file', lambda url, _dir: url)
|
||||
|
||||
# 客户端伪造 id=999,应被排除,不写入数据库
|
||||
form = UserForm(**{**_valid_user_form(), 'id': 999})
|
||||
asyncio.run(user_svc.add_user(_make_request(), form))
|
||||
|
||||
user = user_repo.get_one(username='zhangsan')
|
||||
assert user is not None
|
||||
assert user.id != 999
|
||||
assert user_repo.get_by_id(999) is None
|
||||
|
||||
|
||||
# ============================================================
|
||||
# H-2: login_required 直接查库校验用户状态
|
||||
# ============================================================
|
||||
|
||||
def _make_login_app():
|
||||
from fastapi import Depends, FastAPI
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
import api.deps as deps
|
||||
from core.exceptions import AuthorizationException
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
@app.exception_handler(AuthorizationException)
|
||||
async def _auth_exc(request: Request, exc: AuthorizationException):
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={"code": exc.code, "data": None, "msg": exc.msg, "ok": False},
|
||||
)
|
||||
|
||||
@app.get('/protected')
|
||||
async def protected(request: Request, _=Depends(deps.login_required)):
|
||||
return {"code": 0, "ok": True, "user_id": request.state.user_id}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def test_login_required_rejects_disabled_and_deleted_user(monkeypatch):
|
||||
import types
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import api.deps as deps
|
||||
import modules.system.user.repository as user_repo_mod
|
||||
|
||||
async def fake_blacklist(_token):
|
||||
return False
|
||||
monkeypatch.setattr(deps, 'is_token_blacklisted', fake_blacklist)
|
||||
|
||||
def _payload(user_id):
|
||||
return {'code': 0, 'data': {'userId': user_id, 'username': 'u', 'realname': 'r'}}
|
||||
|
||||
class FakeRepo:
|
||||
def __init__(self, users):
|
||||
self.users = users
|
||||
|
||||
def get_by_id(self, uid):
|
||||
return self.users.get(uid)
|
||||
|
||||
# 正常用户(status=1)放行
|
||||
monkeypatch.setattr(deps, 'parse_payload', lambda _t: _payload(2))
|
||||
monkeypatch.setattr(user_repo_mod, 'user_repo', FakeRepo({2: types.SimpleNamespace(status=1)}))
|
||||
with TestClient(_make_login_app()) as client:
|
||||
resp = client.get('/protected', headers={'Authorization': 'Bearer x'})
|
||||
assert resp.json()['ok'] is True
|
||||
assert resp.json()['user_id'] == 2
|
||||
|
||||
# 禁用用户(status=2)拒绝
|
||||
monkeypatch.setattr(deps, 'parse_payload', lambda _t: _payload(3))
|
||||
monkeypatch.setattr(user_repo_mod, 'user_repo', FakeRepo({3: types.SimpleNamespace(status=2)}))
|
||||
with TestClient(_make_login_app()) as client:
|
||||
resp = client.get('/protected', headers={'Authorization': 'Bearer x'})
|
||||
assert resp.json()['ok'] is False
|
||||
assert resp.json()['code'] == 401
|
||||
|
||||
# 已删除/不存在用户(查无此人)拒绝
|
||||
monkeypatch.setattr(deps, 'parse_payload', lambda _t: _payload(4))
|
||||
monkeypatch.setattr(user_repo_mod, 'user_repo', FakeRepo({}))
|
||||
with TestClient(_make_login_app()) as client:
|
||||
resp = client.get('/protected', headers={'Authorization': 'Bearer x'})
|
||||
assert resp.json()['ok'] is False
|
||||
assert resp.json()['code'] == 401
|
||||
|
||||
|
||||
# ============================================================
|
||||
# H-2b: login_required 用户有效状态缓存(命中免查库,失效即时踢出)
|
||||
# ============================================================
|
||||
|
||||
def _login_request():
|
||||
from starlette.requests import Request
|
||||
scope = {
|
||||
'type': 'http', 'method': 'GET', 'path': '/protected',
|
||||
'headers': [(b'authorization', b'Bearer x')],
|
||||
'query_string': b'', 'route': None,
|
||||
}
|
||||
return Request(scope)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_required_cache_hit_skips_db(monkeypatch):
|
||||
import fakeredis.aioredis
|
||||
import api.deps as deps
|
||||
import modules.system.user.repository as user_repo_mod
|
||||
from utils import perm_cache
|
||||
|
||||
async def _blacklist(_t):
|
||||
return False
|
||||
monkeypatch.setattr(deps, 'is_token_blacklisted', _blacklist)
|
||||
monkeypatch.setattr(deps, 'parse_payload',
|
||||
lambda _t: {'code': 0, 'data': {'userId': 2, 'username': 'u', 'realname': 'r'}})
|
||||
|
||||
fake_redis = fakeredis.aioredis.FakeRedis(decode_responses=True)
|
||||
monkeypatch.setattr(perm_cache, 'get_redis', lambda: fake_redis)
|
||||
await fake_redis.set('user:active:2', '1')
|
||||
|
||||
# 缓存命中:不应查询数据库
|
||||
class _NoDb:
|
||||
def get_by_id(self, uid):
|
||||
raise AssertionError('缓存命中时不应查询数据库')
|
||||
monkeypatch.setattr(user_repo_mod, 'user_repo', _NoDb())
|
||||
|
||||
request = _login_request()
|
||||
await deps.login_required(request)
|
||||
assert request.state.user_id == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_required_cache_inactive_rejects_without_db(monkeypatch):
|
||||
import fakeredis.aioredis
|
||||
import api.deps as deps
|
||||
import modules.system.user.repository as user_repo_mod
|
||||
from utils import perm_cache
|
||||
from core.exceptions import AuthorizationException
|
||||
|
||||
async def _blacklist(_t):
|
||||
return False
|
||||
monkeypatch.setattr(deps, 'is_token_blacklisted', _blacklist)
|
||||
monkeypatch.setattr(deps, 'parse_payload',
|
||||
lambda _t: {'code': 0, 'data': {'userId': 3, 'username': 'u', 'realname': 'r'}})
|
||||
|
||||
fake_redis = fakeredis.aioredis.FakeRedis(decode_responses=True)
|
||||
monkeypatch.setattr(perm_cache, 'get_redis', lambda: fake_redis)
|
||||
await fake_redis.set('user:active:3', '0') # 缓存已标记禁用
|
||||
|
||||
class _NoDb:
|
||||
def get_by_id(self, uid):
|
||||
raise AssertionError('缓存命中(禁用)时不应查询数据库')
|
||||
monkeypatch.setattr(user_repo_mod, 'user_repo', _NoDb())
|
||||
|
||||
with pytest.raises(AuthorizationException):
|
||||
await deps.login_required(_login_request())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_required_cache_miss_queries_db_and_caches(monkeypatch):
|
||||
import types
|
||||
import fakeredis.aioredis
|
||||
import api.deps as deps
|
||||
import modules.system.user.repository as user_repo_mod
|
||||
from utils import perm_cache
|
||||
|
||||
async def _blacklist(_t):
|
||||
return False
|
||||
monkeypatch.setattr(deps, 'is_token_blacklisted', _blacklist)
|
||||
monkeypatch.setattr(deps, 'parse_payload',
|
||||
lambda _t: {'code': 0, 'data': {'userId': 5, 'username': 'u', 'realname': 'r'}})
|
||||
|
||||
fake_redis = fakeredis.aioredis.FakeRedis(decode_responses=True)
|
||||
monkeypatch.setattr(perm_cache, 'get_redis', lambda: fake_redis)
|
||||
|
||||
class _Repo:
|
||||
def get_by_id(self, uid):
|
||||
return types.SimpleNamespace(status=1)
|
||||
monkeypatch.setattr(user_repo_mod, 'user_repo', _Repo())
|
||||
|
||||
request = _login_request()
|
||||
await deps.login_required(request)
|
||||
assert request.state.user_id == 5
|
||||
# 回源 DB 后回填缓存,供后续请求命中
|
||||
assert await fake_redis.get('user:active:5') == '1'
|
||||
|
||||
|
||||
# ============================================================
|
||||
# H-3: 上传流式读取,超限立即中断(内存 DoS 防护)
|
||||
# ============================================================
|
||||
|
||||
class _ChunkedFile:
|
||||
"""模拟异步分块读取的 UploadFile(每次 read 返回一块)。"""
|
||||
|
||||
def __init__(self, chunks):
|
||||
self.chunks = list(chunks)
|
||||
|
||||
async def read(self, size=65536):
|
||||
if not self.chunks:
|
||||
return b''
|
||||
return self.chunks.pop(0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_upload_limited_aborts_before_reading_all():
|
||||
from modules.upload.service import _read_upload_limited, _max_upload_bytes
|
||||
|
||||
limit = _max_upload_bytes() # 例如 10MB
|
||||
chunk = b'x' * 65536 # 64KB
|
||||
n_chunks = limit // 65536 + 5 # 总量必然超限
|
||||
|
||||
file = _ChunkedFile([chunk] * n_chunks)
|
||||
content, err = await _read_upload_limited(file, limit)
|
||||
|
||||
assert content is None
|
||||
body = json.loads(err.body)
|
||||
assert body['code'] == 1
|
||||
assert '超过限制' in body['msg']
|
||||
# 未读完全部块即中断,证明不是先整文件读入内存
|
||||
assert len(file.chunks) > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_upload_limited_ok_under_limit():
|
||||
from modules.upload.service import _read_upload_limited, _max_upload_bytes
|
||||
|
||||
payload = b'hello' * 100
|
||||
content, err = await _read_upload_limited(_ChunkedFile([payload]), _max_upload_bytes())
|
||||
assert err is None
|
||||
assert content == payload
|
||||
|
||||
|
||||
# ============================================================
|
||||
# H-4: set_permission 生成的权限节点默认启用(status=0)
|
||||
# ============================================================
|
||||
|
||||
def test_set_permission_creates_enabled_nodes(sqlite_db):
|
||||
from modules.system.menu.service import set_permission
|
||||
from modules.system.menu.repository import menu_repo
|
||||
|
||||
set_permission(0, [1, 5], '用户管理', '/system/user', 1, 1)
|
||||
|
||||
nodes = menu_repo.get_all(parent_id=1, type=1)
|
||||
assert len(nodes) == 2
|
||||
perms = {node.permission for node in nodes}
|
||||
assert perms == {'sys:user:page', 'sys:user:add'}
|
||||
for node in nodes:
|
||||
assert node.status == 0
|
||||
|
||||
|
||||
# ============================================================
|
||||
# H-5: set_permission 编辑菜单时复用旧按钮节点 id,role_menu 授权不失效
|
||||
# ============================================================
|
||||
|
||||
def test_set_permission_preserves_node_ids_on_reedit(sqlite_db):
|
||||
from modules.system.menu.service import set_permission
|
||||
from modules.system.menu.repository import menu_repo
|
||||
from modules.system.role_menu.models import RoleMenu
|
||||
from modules.system.role_menu.repository import rolemenu_repo
|
||||
from core.database import get_db_session
|
||||
|
||||
# 首次保存:生成 查询(1)/添加(5) 两个按钮节点
|
||||
set_permission(0, [1, 5], '用户管理', '/system/user', 1, 1)
|
||||
first = {node.sort: node.id for node in menu_repo.get_all(parent_id=1, type=1)}
|
||||
assert set(first) == {1, 5}
|
||||
|
||||
# 模拟角色已把"添加"按钮授权给角色 1
|
||||
get_db_session().add(RoleMenu(role_id=1, menu_id=first[5]))
|
||||
sqlite_db.commit()
|
||||
|
||||
# 再次编辑菜单(勾选增加 修改/10)→ 已勾选旧节点必须复用原 id
|
||||
set_permission(0, [1, 5, 10], '用户管理', '/system/user', 1, 1)
|
||||
second = {node.sort: node.id for node in menu_repo.get_all(parent_id=1, type=1)}
|
||||
assert set(second) == {1, 5, 10}
|
||||
assert second[1] == first[1] # 查询节点 id 未变
|
||||
assert second[5] == first[5] # 添加节点 id 未变 → role_menu 引用仍有效
|
||||
assert second[10] not in first.values() # 修改为新增节点
|
||||
|
||||
# role_menu 授权仍指向有效(is_delete=0)的按钮节点
|
||||
refs = rolemenu_repo.filter(RoleMenu.menu_id == first[5]).all()
|
||||
assert len(refs) == 1
|
||||
assert menu_repo.get_by_id(first[5]) is not None
|
||||
|
||||
|
||||
def test_set_permission_soft_deletes_unchecked_nodes(sqlite_db):
|
||||
from modules.system.menu.service import set_permission
|
||||
from modules.system.menu.repository import menu_repo
|
||||
|
||||
set_permission(0, [1, 5], '用户管理', '/system/user', 1, 1)
|
||||
first = {node.sort: node.id for node in menu_repo.get_all(parent_id=1, type=1)}
|
||||
assert set(first) == {1, 5}
|
||||
|
||||
# 编辑时去掉 添加(5) → 旧节点软删,不再参与鉴权
|
||||
set_permission(0, [1], '用户管理', '/system/user', 1, 1)
|
||||
remaining = menu_repo.get_all(parent_id=1, type=1)
|
||||
assert [node.sort for node in remaining] == [1]
|
||||
assert menu_repo.get_by_id(first[5]) is None # 已软删,等值查询不可见
|
||||
|
||||
|
||||
# ============================================================
|
||||
# M-1: update_user_status 变更状态后失效权限缓存
|
||||
# ============================================================
|
||||
|
||||
def test_update_user_status_invalidates_perm_cache(sqlite_db, monkeypatch):
|
||||
import asyncio
|
||||
from modules.system.user import service as user_svc
|
||||
from modules.system.user.schemas import UserStatusForm
|
||||
from modules.system.user.repository import user_repo
|
||||
|
||||
# 预置超管占位记录(id=1),使被测用户获得非超管 ID,避开超管写保护
|
||||
user_repo.create(
|
||||
username='superadmin', realname='r', nickname='n', gender=1, avatar='a',
|
||||
mobile='1', email='a@b.com', province_code='11', city_code='1101',
|
||||
district_code='110101', street_code='', address='北京市', status=1, sort=1,
|
||||
)
|
||||
user = user_repo.create(
|
||||
username='u1', realname='r', nickname='n', gender=1, avatar='a',
|
||||
mobile='1', email='a@b.com', province_code='11', city_code='1101',
|
||||
district_code='110101', street_code='', address='北京市', status=1, sort=1,
|
||||
)
|
||||
|
||||
invalidated = []
|
||||
|
||||
async def _recorder(uid):
|
||||
invalidated.append(uid)
|
||||
|
||||
monkeypatch.setattr(user_svc, 'invalidate_user_perms', _recorder)
|
||||
|
||||
resp = asyncio.run(user_svc.update_user_status(
|
||||
_make_request(), UserStatusForm(id=user.id, status=2)))
|
||||
assert json.loads(resp.body)['code'] == 0
|
||||
assert invalidated == [user.id]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# M-2: invalidate_role_perms 清理角色下所有用户缓存(DB 查询走线程池)
|
||||
# ============================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalidate_role_perms_clears_all_user_caches(sqlite_db, monkeypatch):
|
||||
import fakeredis.aioredis
|
||||
from modules.system.user_role.models import UserRole
|
||||
from core.database import get_db_session
|
||||
from utils import perm_cache
|
||||
|
||||
# 构造:角色2 → 用户5/6
|
||||
get_db_session().add_all([
|
||||
UserRole(user_id=5, role_id=2),
|
||||
UserRole(user_id=6, role_id=2),
|
||||
])
|
||||
sqlite_db.commit()
|
||||
|
||||
fake_redis = fakeredis.aioredis.FakeRedis(decode_responses=True)
|
||||
await fake_redis.set('perm:version', '0')
|
||||
await fake_redis.set('perm:list:5:0', '["sys:user:add"]')
|
||||
await fake_redis.set('perm:menu:6:0', '[]')
|
||||
await fake_redis.set('perm:list:99:0', '["sys:user:page"]') # 无关用户,不应被清
|
||||
monkeypatch.setattr(perm_cache, 'get_redis', lambda: fake_redis)
|
||||
|
||||
await perm_cache.invalidate_role_perms(2)
|
||||
|
||||
# 该角色下所有用户的权限/菜单缓存被清(DB 查询经 asyncio.to_thread 执行)
|
||||
assert await fake_redis.get('perm:list:5:0') is None
|
||||
assert await fake_redis.get('perm:menu:6:0') is None
|
||||
# 无关用户缓存不受影响
|
||||
assert await fake_redis.get('perm:list:99:0') == '["sys:user:page"]'
|
||||
|
||||
|
||||
# ============================================================
|
||||
# M-3: 失效缓存失败时 fail-closed 降级递增全局版本号,避免旧权限残留 2 小时
|
||||
# ============================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalidate_user_perms_success_no_fallback(monkeypatch):
|
||||
import fakeredis.aioredis
|
||||
from utils import perm_cache
|
||||
|
||||
fake_redis = fakeredis.aioredis.FakeRedis(decode_responses=True)
|
||||
await fake_redis.set('perm:version', '0')
|
||||
await fake_redis.set('perm:list:5:0', '["sys:user:add"]')
|
||||
await fake_redis.set('perm:menu:5:0', '[]')
|
||||
monkeypatch.setattr(perm_cache, 'get_redis', lambda: fake_redis)
|
||||
|
||||
calls = []
|
||||
|
||||
async def _incr_recorder():
|
||||
calls.append('incr')
|
||||
return 1
|
||||
|
||||
monkeypatch.setattr(perm_cache, '_incr_version', _incr_recorder)
|
||||
|
||||
await perm_cache.invalidate_user_perms(5)
|
||||
# 删除成功:不触发降级版本号递增,缓存键已删除
|
||||
assert calls == []
|
||||
assert await fake_redis.get('perm:list:5:0') is None
|
||||
assert await fake_redis.get('perm:menu:5:0') is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalidate_user_perms_failure_falls_back_to_version_bump(monkeypatch):
|
||||
from utils import perm_cache
|
||||
|
||||
def _raise(*args, **kwargs):
|
||||
raise RuntimeError('redis down')
|
||||
|
||||
calls = []
|
||||
|
||||
async def _incr_recorder():
|
||||
calls.append('incr')
|
||||
return 1
|
||||
|
||||
monkeypatch.setattr(perm_cache, 'get_redis', _raise)
|
||||
monkeypatch.setattr(perm_cache, '_incr_version', _incr_recorder)
|
||||
|
||||
await perm_cache.invalidate_user_perms(5)
|
||||
# 删除失败 → fail-closed 降级递增全局版本号,孤立旧缓存,杜绝残留
|
||||
assert calls == ['incr']
|
||||
|
||||
|
||||
# ============================================================
|
||||
# M-4: save_content 白名单清洗防存储型 XSS
|
||||
# ============================================================
|
||||
|
||||
def test_save_content_sanitizes_xss():
|
||||
from utils.rich_text import save_content
|
||||
|
||||
out = save_content('<p>x<script>alert(1)</script></p>', '标题', 'notice')
|
||||
assert '<script>' not in out # 脚本标签被剥离(文本内容残留为无害纯文本)
|
||||
|
||||
out = save_content('<a href="javascript:alert(1)">x</a>', '标题', 'notice')
|
||||
assert 'javascript:' not in out
|
||||
assert '<a>' in out
|
||||
|
||||
out = save_content('<img src="x.png" onerror="alert(1)">', '标题', 'notice')
|
||||
assert 'onerror' not in out
|
||||
|
||||
out = save_content('<div style="color:red;width:expression(alert(1))">t</div>', '标题', 'notice')
|
||||
assert 'expression' not in out
|
||||
|
||||
# 安全样式应保留
|
||||
out = save_content('<div style="color:red;text-align:center">ok</div>', '标题', 'notice')
|
||||
assert out == '<div style="color:red;text-align:center;">ok</div>'
|
||||
|
||||
|
||||
# ============================================================
|
||||
# M-5: 职级导入逐行校验,非法行跳过并返回行号原因
|
||||
# ============================================================
|
||||
|
||||
def test_import_level_skips_invalid_rows(sqlite_db, monkeypatch, tmp_path):
|
||||
import asyncio
|
||||
import json
|
||||
from openpyxl import Workbook
|
||||
|
||||
import modules.system.level.service as level_svc
|
||||
from modules.system.level.repository import level_repo
|
||||
|
||||
# 构造 Excel:表头 + 2 行合法 + 3 行非法(空名称/重复名/非法排序)
|
||||
up = tmp_path / 'uploads'
|
||||
up.mkdir(parents=True)
|
||||
wb = Workbook()
|
||||
ws = wb.worksheets[0]
|
||||
ws.append(['职级ID', '职级名称', '职级排序', '职级状态', '创建时间', '更新时间'])
|
||||
ws.append([None, '高级', 1, '正常'])
|
||||
ws.append([None, '', 2, '正常']) # 空名称
|
||||
ws.append([None, '高级', 3, '正常']) # 名称重复
|
||||
ws.append([None, '特级', 'abc', '正常']) # 排序非数字
|
||||
ws.append([None, '中级', 5, '禁用'])
|
||||
xlsx = up / 'import.xlsx'
|
||||
wb.save(xlsx)
|
||||
|
||||
monkeypatch.setattr(level_svc, 'FASTAPI_UPLOAD_DIR', str(up))
|
||||
|
||||
async def fake_upload(file, exts):
|
||||
from starlette.responses import JSONResponse
|
||||
return JSONResponse({"code": 0, "data": {"filePath": "/import.xlsx"}})
|
||||
|
||||
monkeypatch.setattr(level_svc.upload, 'upload_file', fake_upload)
|
||||
|
||||
resp = asyncio.run(level_svc.level_service.import_level(_make_request(), None))
|
||||
body = json.loads(resp.body)
|
||||
assert body['code'] == 0
|
||||
assert body['data']['success'] == 2
|
||||
assert body['data']['fail'] == 3
|
||||
assert any('第3行' in e for e in body['data']['errors'])
|
||||
assert any('第4行' in e for e in body['data']['errors'])
|
||||
assert any('第5行' in e for e in body['data']['errors'])
|
||||
assert level_repo.get_one(name='高级') is not None
|
||||
assert level_repo.get_one(name='中级') is not None
|
||||
assert level_repo.count() == 2
|
||||
@@ -0,0 +1,219 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""中间件修复冒烟测试:验证响应体重建、事务提交/回滚、操作日志捕获。
|
||||
|
||||
无需真实 MySQL/Redis:SessionLocal 与操作日志记录均被 monkeypatch。
|
||||
"""
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, 'src')
|
||||
|
||||
|
||||
class FakeSession:
|
||||
def __init__(self):
|
||||
self.commits = 0
|
||||
self.rollbacks = 0
|
||||
self.closed = False
|
||||
|
||||
def commit(self):
|
||||
self.commits += 1
|
||||
|
||||
def rollback(self):
|
||||
self.rollbacks += 1
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_session():
|
||||
return FakeSession()
|
||||
|
||||
|
||||
def _make_app(fake_session, monkeypatch, log_sink):
|
||||
from fastapi import FastAPI
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
|
||||
import middleware.db_session as mw_db
|
||||
import middleware.operation_log as mw_op
|
||||
from modules.operation_log import service as op_service
|
||||
|
||||
# 打桩 DB 会话与操作日志记录,避免真实依赖
|
||||
monkeypatch.setattr(mw_db, "SessionLocal", lambda: fake_session)
|
||||
monkeypatch.setattr(
|
||||
op_service, "record_operation_log",
|
||||
lambda request, **kw: log_sink.append(kw))
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
@app.middleware("http")
|
||||
async def inject_user(request: Request, call_next):
|
||||
request.state.user_id = 1
|
||||
request.state.username = "tester"
|
||||
return await call_next(request)
|
||||
|
||||
# 与 create_app 相同的嵌套顺序:db_session 内层,operation_log 外层
|
||||
app.add_middleware(
|
||||
BaseHTTPMiddleware, dispatch=mw_db.db_session_middleware)
|
||||
app.add_middleware(
|
||||
BaseHTTPMiddleware, dispatch=mw_op.operation_log_middleware)
|
||||
app.add_middleware(BaseHTTPMiddleware, dispatch=inject_user)
|
||||
|
||||
@app.post("/ok")
|
||||
def ok():
|
||||
return {"code": 0, "data": None, "msg": "操作成功", "ok": True}
|
||||
|
||||
@app.post("/fail")
|
||||
def fail():
|
||||
from starlette.responses import JSONResponse
|
||||
return JSONResponse(
|
||||
{"code": 1, "data": None, "msg": "业务失败", "ok": False})
|
||||
|
||||
@app.post("/boom")
|
||||
def boom():
|
||||
raise RuntimeError("unexpected error")
|
||||
|
||||
@app.get("/read")
|
||||
def read():
|
||||
return {"code": 0, "data": {"x": 1}}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def test_response_body_helpers():
|
||||
"""read_response_body / rebuild_response 基础行为。"""
|
||||
from starlette.responses import JSONResponse
|
||||
from utils.response_body import read_response_body, rebuild_response
|
||||
|
||||
# 普通 Response:body 可直接读,consumed=False
|
||||
resp = JSONResponse({"code": 0})
|
||||
body, consumed = asyncio.run(read_response_body(resp))
|
||||
assert consumed is False
|
||||
assert b'"code"' in body
|
||||
|
||||
# 流式响应:需消费 body_iterator,consumed=True
|
||||
from starlette.responses import StreamingResponse
|
||||
resp2 = StreamingResponse(iter([b'{"code": 1, "msg": "x"}']))
|
||||
body2, consumed2 = asyncio.run(read_response_body(resp2))
|
||||
assert consumed2 is True
|
||||
assert body2 == b'{"code": 1, "msg": "x"}'
|
||||
rebuilt = rebuild_response(resp2, body2)
|
||||
assert rebuilt.status_code == 200
|
||||
assert rebuilt.body == body2
|
||||
|
||||
|
||||
def test_write_ok_commits(fake_session, monkeypatch):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
log_sink = []
|
||||
app = _make_app(fake_session, monkeypatch, log_sink)
|
||||
with TestClient(app) as client:
|
||||
resp = client.post("/ok")
|
||||
assert resp.json()["code"] == 0
|
||||
assert fake_session.commits == 1
|
||||
assert fake_session.rollbacks == 0
|
||||
assert fake_session.closed
|
||||
assert len(log_sink) == 1
|
||||
assert log_sink[0]["status"] == 0
|
||||
|
||||
|
||||
def test_write_fail_rolls_back(fake_session, monkeypatch):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
log_sink = []
|
||||
app = _make_app(fake_session, monkeypatch, log_sink)
|
||||
with TestClient(app) as client:
|
||||
resp = client.post("/fail")
|
||||
# 客户端仍能收到完整响应体(重建后未被消费掉)
|
||||
assert resp.json()["code"] == 1
|
||||
assert resp.json()["msg"] == "业务失败"
|
||||
assert fake_session.commits == 0
|
||||
assert fake_session.rollbacks == 1
|
||||
assert fake_session.closed
|
||||
assert len(log_sink) == 1
|
||||
assert log_sink[0]["status"] == 1
|
||||
assert "业务失败" in log_sink[0]["error"]
|
||||
|
||||
|
||||
def test_read_does_not_touch_tx(fake_session, monkeypatch):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
log_sink = []
|
||||
app = _make_app(fake_session, monkeypatch, log_sink)
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/read")
|
||||
assert resp.json()["code"] == 0
|
||||
assert fake_session.commits == 0
|
||||
assert fake_session.rollbacks == 0
|
||||
assert len(log_sink) == 0 # GET 不记录操作日志
|
||||
|
||||
|
||||
def test_unexpected_exception_rolls_back_and_client_gets_500(fake_session, monkeypatch):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
log_sink = []
|
||||
app = _make_app(fake_session, monkeypatch, log_sink)
|
||||
with TestClient(app, raise_server_exceptions=False) as client:
|
||||
resp = client.post("/boom")
|
||||
assert resp.status_code == 500
|
||||
assert fake_session.commits == 0
|
||||
assert fake_session.rollbacks == 1
|
||||
assert len(log_sink) == 1
|
||||
assert log_sink[0]["status"] == 1
|
||||
|
||||
|
||||
def test_is_business_success_fails_closed_on_non_json():
|
||||
"""非 JSON / 空体 / 非对象响应一律视为失败,仅 JSON code==0 提交。"""
|
||||
from middleware import db_session as mw_db
|
||||
|
||||
assert mw_db._is_business_success(b'{"code":0,"msg":"ok"}') is True
|
||||
assert mw_db._is_business_success(b'{"code":1,"msg":"fail"}') is False
|
||||
# 非 JSON 2xx 响应体:无法确认业务成功 → 失败(回滚)
|
||||
assert mw_db._is_business_success(b'<html>ok</html>') is False
|
||||
# 空体(如 204)→ 失败
|
||||
assert mw_db._is_business_success(b'') is False
|
||||
# 非对象 JSON(数组)→ 失败
|
||||
assert mw_db._is_business_success(b'[1,2,3]') is False
|
||||
# 非法 JSON → 失败
|
||||
assert mw_db._is_business_success(b'not json') is False
|
||||
|
||||
|
||||
def test_http_exception_handler_unifies_404_405_format():
|
||||
"""注册核心异常处理器后,404/405 等 Starlette HTTP 异常统一为 {code:1}+200。
|
||||
|
||||
不加此处理器时,Starlette 默认 HTTPException 处理器优先级高于 Exception
|
||||
兜底,会返回非 200 的 {"detail": ...},破坏全站统一响应格式。
|
||||
"""
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from core.app import register_exception
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
@app.get("/exists")
|
||||
def exists():
|
||||
return {"code": 0, "data": None, "msg": "ok", "ok": True}
|
||||
|
||||
register_exception(app)
|
||||
|
||||
with TestClient(app) as client:
|
||||
# 404:不存在路由
|
||||
resp = client.get("/nonexistent")
|
||||
body = resp.json()
|
||||
assert resp.status_code == 200
|
||||
assert body["code"] == 1
|
||||
assert body["ok"] is False
|
||||
assert body["data"] is None
|
||||
assert body["msg"] == "Not Found"
|
||||
|
||||
# 405:方法不允许
|
||||
resp = client.post("/exists")
|
||||
body = resp.json()
|
||||
assert resp.status_code == 200
|
||||
assert body["code"] == 1
|
||||
assert body["ok"] is False
|
||||
assert body["msg"] == "Method Not Allowed"
|
||||
@@ -0,0 +1,85 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""富文本文件迁移路径修复测试:验证临时文件迁移与URL往返转换。
|
||||
|
||||
在临时目录中构建真实的 uploads/temp 结构,确认
|
||||
save_file 迁移逻辑与路径拼接正确(含 Windows 分隔符场景)。
|
||||
"""
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, 'src')
|
||||
|
||||
import utils.rich_text as rt
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_uploads(monkeypatch, tmp_path):
|
||||
"""将 rich_text 的路径常量指向 tmp 下的临时目录。"""
|
||||
up = tmp_path / 'uploads'
|
||||
temp = up / 'temp'
|
||||
temp.mkdir(parents=True)
|
||||
monkeypatch.setattr(rt, 'FASTAPI_UPLOAD_DIR', str(up))
|
||||
monkeypatch.setattr(rt, 'FASTAPI_TEMP_PATH', str(temp))
|
||||
monkeypatch.setattr(rt, 'FASTAPI_FILE_URL', 'http://img.test')
|
||||
return up, temp
|
||||
|
||||
|
||||
def test_already_in_formal_dir_returns_relative_url(fake_uploads):
|
||||
up, temp = fake_uploads
|
||||
target = up / 'article' / '20240801' / 'a.png'
|
||||
target.parent.mkdir(parents=True)
|
||||
target.write_bytes(b'x')
|
||||
|
||||
result = rt.save_file('http://img.test/article/20240801/a.png', 'article')
|
||||
assert result == '/article/20240801/a.png'
|
||||
assert target.exists() # 不迁移、不移动
|
||||
|
||||
|
||||
def test_temp_file_migrates_to_formal_dir(fake_uploads):
|
||||
import time
|
||||
up, temp = fake_uploads
|
||||
src = temp / '20240801' / 'b.png'
|
||||
src.parent.mkdir(parents=True)
|
||||
src.write_bytes(b'data')
|
||||
|
||||
result = rt.save_file('/temp/20240801/b.png', 'article')
|
||||
today = time.strftime('%Y%m%d')
|
||||
# 迁移后返回正式目录相对URL(目标日期目录为当前日期,仅迁移文件名)
|
||||
assert result == f'/article/{today}/b.png'
|
||||
assert not src.exists() # 原临时文件已移动
|
||||
dest = up / 'article' / today / 'b.png'
|
||||
assert dest.read_bytes() == b'data'
|
||||
|
||||
|
||||
def test_temp_file_migrates_from_absolute_url(fake_uploads):
|
||||
import time
|
||||
up, temp = fake_uploads
|
||||
src = temp / '20240802' / 'c.png'
|
||||
src.parent.mkdir(parents=True)
|
||||
src.write_bytes(b'abc')
|
||||
|
||||
result = rt.save_file('http://img.test/temp/20240802/c.png', 'article')
|
||||
today = time.strftime('%Y%m%d')
|
||||
assert result == f'/article/{today}/c.png'
|
||||
assert (up / 'article' / today / 'c.png').read_bytes() == b'abc'
|
||||
|
||||
|
||||
def test_remote_url_not_supported(fake_uploads):
|
||||
assert rt.save_file('https://other.host/x.png', 'article') == ''
|
||||
|
||||
|
||||
def test_empty_url_returns_empty(fake_uploads):
|
||||
assert rt.save_file('', 'article') == ''
|
||||
assert rt.save_file(None, 'article') == ''
|
||||
|
||||
|
||||
def test_disk_to_url_roundtrip(fake_uploads):
|
||||
up, temp = fake_uploads
|
||||
target = up / 'article' / '20240801' / 'd.png'
|
||||
target.parent.mkdir(parents=True)
|
||||
target.write_bytes(b'z')
|
||||
# 正式目录文件:磁盘路径 -> 相对URL -> 磁盘路径 往返一致
|
||||
disk = rt._url_to_disk_path('/article/20240801/d.png')
|
||||
assert disk == str(target)
|
||||
assert rt._disk_to_url_path(disk) == '/article/20240801/d.png'
|
||||
@@ -0,0 +1,113 @@
|
||||
"""启动期安全配置校验 fail-close + 上传白名单双层机制(内置集合 ∩ 配置)。"""
|
||||
import pytest
|
||||
|
||||
import core.config as config
|
||||
from constants.constants import BUILTIN_UPLOAD_EXTS
|
||||
|
||||
|
||||
def _set(monkeypatch, **kwargs):
|
||||
for name, value in kwargs.items():
|
||||
monkeypatch.setattr(config, name, value)
|
||||
|
||||
|
||||
def test_validate_config_passes_with_valid_values():
|
||||
# 默认 .env 为有效配置,不应抛异常
|
||||
config.validate_config()
|
||||
|
||||
|
||||
# ============ 上传白名单:有效集合 = 配置 ∩ 内置集合,未配置回落内置集合 ============
|
||||
|
||||
def test_effective_upload_exts_falls_back_to_builtin_when_empty():
|
||||
assert set(config.effective_upload_exts('')) == BUILTIN_UPLOAD_EXTS
|
||||
assert set(config.effective_upload_exts(' ')) == BUILTIN_UPLOAD_EXTS
|
||||
|
||||
|
||||
def test_effective_upload_exts_intersects_with_builtin():
|
||||
# 配置含内置集合外的危险后缀 → 交集兜底,仅保留允许项
|
||||
assert config.effective_upload_exts('.jpg,.svg,.py') == ['.jpg']
|
||||
|
||||
|
||||
def test_effective_upload_exts_empty_when_all_outside_builtin():
|
||||
assert config.effective_upload_exts('.svg,.webp,.exe') == []
|
||||
|
||||
|
||||
def test_effective_upload_exts_normalizes_case_and_space():
|
||||
assert config.effective_upload_exts('.JPG, .PNG, txt') == ['.jpg', '.png', '.txt']
|
||||
|
||||
|
||||
# ============ 启动检测 validate_config ============
|
||||
|
||||
def test_validate_config_passes_when_upload_exts_empty(monkeypatch):
|
||||
# 方案A:未配置 → 回落内置智能集合,不拒绝启动
|
||||
_set(monkeypatch, UPLOAD_ALLOWED_EXTS='')
|
||||
config.validate_config()
|
||||
|
||||
|
||||
def test_validate_config_passes_when_upload_exts_blank(monkeypatch):
|
||||
_set(monkeypatch, UPLOAD_ALLOWED_EXTS=' ')
|
||||
config.validate_config()
|
||||
|
||||
|
||||
def test_validate_config_passes_when_upload_exts_subset(monkeypatch):
|
||||
_set(monkeypatch, UPLOAD_ALLOWED_EXTS='.jpg,.png')
|
||||
config.validate_config()
|
||||
|
||||
|
||||
def test_validate_config_fails_when_upload_exts_all_outside_builtin(monkeypatch):
|
||||
# 已配置但交集为空(配置的全是不允许的后缀)→ 有效白名单为空,拒绝启动
|
||||
_set(monkeypatch, UPLOAD_ALLOWED_EXTS='.svg,.webp,.py')
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
config.validate_config()
|
||||
assert 'UPLOAD_ALLOWED_EXTS' in str(exc_info.value)
|
||||
|
||||
|
||||
def test_validate_config_fails_when_jwt_salt_empty(monkeypatch):
|
||||
_set(monkeypatch, JWT_SALT='')
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
config.validate_config()
|
||||
assert 'JWT_SALT' in str(exc_info.value)
|
||||
|
||||
|
||||
def test_validate_config_fails_when_jwt_salt_too_short(monkeypatch):
|
||||
_set(monkeypatch, JWT_SALT='short-secret-123') # 16 字节 < 32
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
config.validate_config()
|
||||
assert 'JWT_SALT' in str(exc_info.value)
|
||||
assert '32' in str(exc_info.value)
|
||||
|
||||
|
||||
def test_validate_config_reports_both_errors(monkeypatch):
|
||||
_set(monkeypatch, UPLOAD_ALLOWED_EXTS='.svg,.webp', JWT_SALT='weak')
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
config.validate_config()
|
||||
message = str(exc_info.value)
|
||||
assert 'UPLOAD_ALLOWED_EXTS' in message
|
||||
assert 'JWT_SALT' in message
|
||||
|
||||
|
||||
# ============ 凭据 fail-close:DB_PASSWORD / REDIS_PASSWORD ============
|
||||
|
||||
def test_validate_config_fails_when_db_password_empty(monkeypatch):
|
||||
_set(monkeypatch, DB_DRIVER='mysql', DB_PASSWORD='')
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
config.validate_config()
|
||||
assert 'DB_PASSWORD' in str(exc_info.value)
|
||||
|
||||
|
||||
def test_validate_config_passes_when_sqlite_without_password(monkeypatch):
|
||||
# sqlite 无需密码,不应因 DB_PASSWORD 为空而拒绝
|
||||
_set(monkeypatch, DB_DRIVER='sqlite', DB_PASSWORD='')
|
||||
config.validate_config()
|
||||
|
||||
|
||||
def test_validate_config_fails_when_redis_auth_without_password(monkeypatch):
|
||||
_set(monkeypatch, REDIS_AUTH=True, REDIS_PASSWORD='')
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
config.validate_config()
|
||||
assert 'REDIS_PASSWORD' in str(exc_info.value)
|
||||
|
||||
|
||||
def test_validate_config_passes_when_redis_no_auth_without_password(monkeypatch):
|
||||
# REDIS_AUTH=False 时无需密码
|
||||
_set(monkeypatch, REDIS_AUTH=False, REDIS_PASSWORD='')
|
||||
config.validate_config()
|
||||
@@ -0,0 +1,131 @@
|
||||
"""多数据库接入:URL 构建 / 驱动归一化 / 方言感知引擎。
|
||||
|
||||
覆盖 DB_DRIVER=mysql|postgresql|mssql|sqlite|oracle 的连接串生成、别名归一化、
|
||||
未知驱动 fail-fast,以及 sqlite 引擎构建不引入 pymysql。
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import Column, Integer, String
|
||||
from sqlalchemy.orm import declarative_base
|
||||
|
||||
import core.config as config
|
||||
import core.config.database as database
|
||||
from core.database import build_engine
|
||||
|
||||
_PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
# ============ build_database_url 各驱动 ============
|
||||
|
||||
def test_mysql_url_shape():
|
||||
url = database.build_database_url('mysql')
|
||||
assert url.startswith('mysql+pymysql://')
|
||||
assert url.endswith('?charset=utf8mb4')
|
||||
assert f'@{config.DB_HOST}:{config.DB_PORT}/{config.DB_DATABASE}' in url
|
||||
|
||||
|
||||
def test_postgresql_url_shape():
|
||||
url = database.build_database_url('postgresql')
|
||||
assert url.startswith('postgresql+psycopg://')
|
||||
assert 'charset' not in url
|
||||
assert f'@{config.DB_HOST}:{config.DB_PORT}/{config.DB_DATABASE}' in url
|
||||
|
||||
|
||||
def test_mssql_url_shape():
|
||||
url = database.build_database_url('mssql')
|
||||
assert url.startswith('mssql+pymssql://')
|
||||
assert url.endswith('?charset=utf8')
|
||||
assert f'@{config.DB_HOST}:{config.DB_PORT}/{config.DB_DATABASE}' in url
|
||||
|
||||
|
||||
def test_oracle_url_shape():
|
||||
url = database.build_database_url('oracle')
|
||||
assert url.startswith('oracle+oracledb://')
|
||||
assert 'service_name=' in url
|
||||
assert f'@{config.DB_HOST}:{config.DB_PORT}/' in url
|
||||
|
||||
|
||||
def test_sqlite_url_uses_db_database_as_path(monkeypatch):
|
||||
monkeypatch.setattr(database, 'DB_DATABASE', 'data/dev.db')
|
||||
assert database.build_database_url('sqlite') == 'sqlite:///./data/dev.db'
|
||||
# DB_DATABASE 为绝对路径时直接透传
|
||||
monkeypatch.setattr(database, 'DB_DATABASE', 'C:/apps/dev.db')
|
||||
assert database.build_database_url('sqlite') == 'sqlite:///C:/apps/dev.db'
|
||||
|
||||
|
||||
def test_sqlite_url_absolute_path():
|
||||
assert database._sqlite_url('C:/apps/dev.db') == 'sqlite:///C:/apps/dev.db'
|
||||
if os.name == 'posix':
|
||||
# POSIX 绝对路径 → sqlite:////abs/x.db(Windows 下 /abs 仅相对当前盘,属相对路径)
|
||||
assert database._sqlite_url('/abs/x.db') == 'sqlite:////abs/x.db'
|
||||
|
||||
|
||||
def test_url_quote_plus_credentials(monkeypatch):
|
||||
monkeypatch.setattr(database, 'DB_USERNAME', 'user name')
|
||||
monkeypatch.setattr(database, 'DB_PASSWORD', 'p@ss/w:?')
|
||||
url = database.build_database_url('mysql')
|
||||
assert 'user+name' in url
|
||||
assert 'p%40ss%2Fw%3A%3F' in url
|
||||
|
||||
|
||||
# ============ _normalize_driver 别名 / 大小写 / 未知 ============
|
||||
|
||||
def test_normalize_driver_aliases_and_case():
|
||||
assert database._normalize_driver(' POSTGRES ') == 'postgresql'
|
||||
assert database._normalize_driver('postgres') == 'postgresql'
|
||||
assert database._normalize_driver('sqlserver') == 'mssql'
|
||||
assert database._normalize_driver('sql_server') == 'mssql'
|
||||
assert database._normalize_driver("'sqlite3'") == 'sqlite'
|
||||
assert database._normalize_driver('MySQL') == 'mysql'
|
||||
assert database._normalize_driver('oracle') == 'oracle'
|
||||
|
||||
|
||||
def test_build_database_url_unknown_driver_raises():
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
database.build_database_url('db2') # 真正不支持的驱动
|
||||
msg = str(exc_info.value)
|
||||
assert '不支持' in msg
|
||||
assert 'mysql' in msg and 'sqlite' in msg
|
||||
|
||||
|
||||
# ============ 方言感知引擎 ============
|
||||
|
||||
def test_build_engine_sqlite_smoke():
|
||||
engine = build_engine(driver='sqlite', database_url='sqlite:///:memory:')
|
||||
|
||||
TestBase = declarative_base()
|
||||
|
||||
class T(TestBase):
|
||||
__tablename__ = 't_smoke'
|
||||
id = Column(Integer, primary_key=True)
|
||||
name = Column(String(50))
|
||||
|
||||
TestBase.metadata.create_all(engine)
|
||||
with engine.begin() as conn:
|
||||
conn.execute(T.__table__.insert(), {'id': 1, 'name': 'x'})
|
||||
with engine.connect() as conn:
|
||||
row = conn.execute(T.__table__.select()).first()
|
||||
assert row.name == 'x'
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_sqlite_engine_does_not_import_pymysql():
|
||||
"""sqlite 路径构建引擎不得引入 pymysql(子进程隔离,避免会话内已有缓存)。"""
|
||||
code = (
|
||||
"import sys\n"
|
||||
"sys.path.insert(0, 'src')\n"
|
||||
"import core.database\n"
|
||||
"print('pymysql' in sys.modules)\n"
|
||||
)
|
||||
env = {**os.environ, 'DB_DRIVER': 'sqlite'}
|
||||
result = subprocess.run(
|
||||
[sys.executable, '-c', code],
|
||||
capture_output=True, text=True, cwd=_PROJECT_ROOT, env=env,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == 'False'
|
||||
@@ -0,0 +1,46 @@
|
||||
"""基础设施资源上限回归:日志文件轮转 + Redis 连接池上限。"""
|
||||
import logging
|
||||
import logging.handlers
|
||||
|
||||
import pytest
|
||||
|
||||
from core.config.redis import REDIS_MAX_CONNECTIONS
|
||||
|
||||
|
||||
def test_file_handler_is_rotating():
|
||||
from core.logger import setup_logging
|
||||
|
||||
setup_logging()
|
||||
root = logging.getLogger()
|
||||
file_handlers = [
|
||||
h for h in root.handlers
|
||||
if getattr(h, '_djangoadmin', False) and hasattr(h, 'baseFilename')
|
||||
]
|
||||
assert file_handlers, "应存在文件日志 handler"
|
||||
for h in file_handlers:
|
||||
# 文件日志必须使用 RotatingFileHandler(按大小轮转,防单文件无限增长)
|
||||
assert isinstance(h, logging.handlers.RotatingFileHandler)
|
||||
assert h.maxBytes > 0
|
||||
assert h.backupCount > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_redis_pool_has_connection_cap():
|
||||
from core.redis_client import create_redis_pool
|
||||
|
||||
redis = await create_redis_pool()
|
||||
try:
|
||||
# from_url 不发起连接,仅创建连接池;断言最大连接数已收敛(非默认 2**31)
|
||||
assert redis.connection_pool.max_connections == REDIS_MAX_CONNECTIONS
|
||||
finally:
|
||||
await redis.aclose()
|
||||
|
||||
|
||||
def test_create_sync_redis_has_connection_cap():
|
||||
from core.redis_client import create_sync_redis
|
||||
|
||||
client = create_sync_redis()
|
||||
try:
|
||||
assert client.connection_pool.max_connections == REDIS_MAX_CONNECTIONS
|
||||
finally:
|
||||
client.close()
|
||||
@@ -1,4 +1,9 @@
|
||||
"""core/jwt.py 纯函数单元测试(不依赖 Redis/DB)。"""
|
||||
"""core/jwt.py 单元测试(不依赖真实 Redis/DB)。"""
|
||||
|
||||
import logging
|
||||
import time as time_module
|
||||
|
||||
import pytest
|
||||
|
||||
from core import jwt as core_jwt
|
||||
|
||||
@@ -16,14 +21,61 @@ def test_parse_invalid_token():
|
||||
assert result["code"] == 1
|
||||
|
||||
|
||||
def test_refresh_token():
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_token():
|
||||
token = core_jwt.create_token({"userId": 1})
|
||||
result = core_jwt.refresh_token(token)
|
||||
result = await core_jwt.refresh_token(token)
|
||||
assert result["code"] == 0
|
||||
assert result["data"]["access_token"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_blacklisted_token_rejected(monkeypatch):
|
||||
"""已拉黑(注销)的令牌禁止刷新,防止黑名单轮换绕过。"""
|
||||
token = core_jwt.create_token({"userId": 1})
|
||||
|
||||
async def _blacklisted(_token):
|
||||
return True
|
||||
monkeypatch.setattr(core_jwt, "is_token_blacklisted", _blacklisted)
|
||||
|
||||
result = await core_jwt.refresh_token(token)
|
||||
assert result["code"] == 1
|
||||
assert "注销" in result["msg"]
|
||||
|
||||
|
||||
def test_get_token_expiration():
|
||||
token = core_jwt.create_token({"userId": 1}, timeout=30)
|
||||
exp = core_jwt.get_token_expiration(token)
|
||||
assert exp is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_blacklist_check_fail_open_alert_throttled(monkeypatch, caplog):
|
||||
"""Redis 异常时 fail-open 放行;告警节流:窗口内只记一条 ERROR,重复失败降级 DEBUG。"""
|
||||
# 重置节流状态,保证测试确定性
|
||||
monkeypatch.setattr(core_jwt, "_blacklist_check_last_alert", 0.0)
|
||||
|
||||
clock = {"t": 1000.0}
|
||||
monkeypatch.setattr(time_module, "monotonic", lambda: clock["t"])
|
||||
|
||||
async def call():
|
||||
return await core_jwt.is_token_blacklisted("some.token")
|
||||
|
||||
# 首次失败 → ERROR 告警 + fail-open 放行
|
||||
with caplog.at_level(logging.DEBUG, logger="core.jwt"):
|
||||
assert await call() is False
|
||||
assert any(r.levelno == logging.ERROR for r in caplog.records)
|
||||
|
||||
caplog.clear()
|
||||
# 窗口内(+30s)重复失败 → 不再产生 ERROR
|
||||
clock["t"] += 30
|
||||
with caplog.at_level(logging.DEBUG, logger="core.jwt"):
|
||||
assert await call() is False
|
||||
assert not any(r.levelno == logging.ERROR for r in caplog.records)
|
||||
|
||||
caplog.clear()
|
||||
# 超过窗口(再 +60s)→ 再次 ERROR
|
||||
clock["t"] += 60
|
||||
with caplog.at_level(logging.DEBUG, logger="core.jwt"):
|
||||
assert await call() is False
|
||||
assert any(r.levelno == logging.ERROR for r in caplog.records)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""shared/pagination.py 分页工具单元测试。"""
|
||||
|
||||
from shared.pagination import get_page_params
|
||||
from constants.pagination import get_page_params
|
||||
|
||||
|
||||
def test_defaults():
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"""core/redis_client.py incr_with_expire 原子自增工具测试。"""
|
||||
import asyncio
|
||||
|
||||
from core.redis_client import incr_with_expire, _INCR_EXPIRE_LUA
|
||||
|
||||
|
||||
class _FakeRedis:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
async def eval(self, script, numkeys, *keys_and_args):
|
||||
self.calls.append((script, numkeys, keys_and_args))
|
||||
return 1
|
||||
|
||||
|
||||
def test_incr_with_expire_evals_atomic_script():
|
||||
redis = _FakeRedis()
|
||||
assert asyncio.run(incr_with_expire(redis, 'rl:1.2.3.4', 60)) == 1
|
||||
assert len(redis.calls) == 1
|
||||
script, numkeys, args = redis.calls[0]
|
||||
assert numkeys == 1
|
||||
assert args == ('rl:1.2.3.4', 60)
|
||||
# 脚本内必须同时包含 incr 与 expire,且 expire 仅在首次创建时执行
|
||||
assert 'incr' in script and 'expire' in script
|
||||
assert 'count == 1' in script
|
||||
|
||||
|
||||
@@ -26,3 +26,26 @@ def test_failed_shape():
|
||||
assert body["code"] == 1
|
||||
assert body["ok"] is False
|
||||
assert body["msg"] == "错误信息"
|
||||
|
||||
|
||||
def test_page_shape():
|
||||
resp = R.page(data=[{"id": 1}, {"id": 2}], total=10, current=1, size=5)
|
||||
body = json.loads(resp.body)
|
||||
assert body["code"] == 0
|
||||
assert body["ok"] is True
|
||||
assert body["msg"] == "操作成功"
|
||||
d = body["data"]
|
||||
assert d["records"] == [{"id": 1}, {"id": 2}]
|
||||
assert d["total"] == 10
|
||||
assert d["size"] == 5
|
||||
assert d["current"] == 1
|
||||
assert d["pages"] == 2
|
||||
|
||||
|
||||
def test_page_empty():
|
||||
resp = R.page(data=[], total=0, current=1, size=10)
|
||||
body = json.loads(resp.body)
|
||||
d = body["data"]
|
||||
assert d["records"] == []
|
||||
assert d["total"] == 0
|
||||
assert d["pages"] == 0
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""中间件注册顺序:限流须在 DB 会话/操作日志之外(更外层),
|
||||
否则被限流的请求仍先开 DB 会话、仍写操作日志,DoS 时放大 DB 与日志负担。"""
|
||||
from core.app import create_app
|
||||
|
||||
|
||||
def _middleware_names(app):
|
||||
"""按执行顺序(列表最前 = 最外层/先执行)提取各中间件名。"""
|
||||
names = []
|
||||
for m in app.user_middleware:
|
||||
dispatch = getattr(m, 'kwargs', {}).get('dispatch')
|
||||
if dispatch is not None:
|
||||
names.append(getattr(dispatch, '__name__', str(dispatch)))
|
||||
else:
|
||||
names.append(getattr(m.cls, '__name__', str(m)))
|
||||
return names
|
||||
|
||||
|
||||
def test_rate_limit_runs_before_db_session_and_operation_log():
|
||||
names = _middleware_names(create_app())
|
||||
# 限流必须比 DB 会话 / 操作日志更外层(先执行),拒绝请求不进入 DB 与日志
|
||||
assert 'rate_limit_middleware' in names
|
||||
assert names.index('rate_limit_middleware') < names.index('db_session_middleware')
|
||||
assert names.index('rate_limit_middleware') < names.index('operation_log_middleware')
|
||||
|
||||
|
||||
def test_cors_is_outermost():
|
||||
names = _middleware_names(create_app())
|
||||
# CORS 在最外层,直接返回的响应也带 CORS 头
|
||||
assert 'CORSMiddleware' in names
|
||||
assert names.index('CORSMiddleware') == 0
|
||||
@@ -0,0 +1,120 @@
|
||||
"""middleware/operation_log.py 操作日志中间件单元测试:日志写入延迟到响应后台任务,不阻塞响应。"""
|
||||
import asyncio
|
||||
import types
|
||||
|
||||
import pytest
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
import middleware.operation_log as op_middleware
|
||||
|
||||
|
||||
def _make_request(method='PUT', path='/api/v1/demo', user_id=1):
|
||||
scope = {
|
||||
'type': 'http',
|
||||
'method': method,
|
||||
'path': path,
|
||||
'headers': [(b'user-agent', b'test-agent')],
|
||||
'query_string': b'',
|
||||
'route': types.SimpleNamespace(summary='测试操作'),
|
||||
}
|
||||
request = Request(scope)
|
||||
request.state.user_id = user_id
|
||||
return request
|
||||
|
||||
|
||||
async def _run_middleware(request):
|
||||
async def call_next(request):
|
||||
return JSONResponse(content={'code': 0, 'msg': 'ok'})
|
||||
return await op_middleware.operation_log_middleware(request, call_next)
|
||||
|
||||
|
||||
def _patch_to_thread(monkeypatch, submitted):
|
||||
"""记录提交到线程池的函数并同步执行,隔离真实线程调度。"""
|
||||
async def fake_to_thread(func, *args, **kwargs):
|
||||
submitted.append(func)
|
||||
return func(*args, **kwargs)
|
||||
monkeypatch.setattr(asyncio, 'to_thread', fake_to_thread)
|
||||
|
||||
|
||||
def test_operation_log_deferred_to_response_background(monkeypatch):
|
||||
"""日志写入挂到 response.background,响应返回后异步落库,不阻塞响应。"""
|
||||
import modules.operation_log.service as op_service
|
||||
|
||||
captured = {}
|
||||
monkeypatch.setattr(op_service, 'record_operation_log',
|
||||
lambda request, title, **kw: captured.update(kw) or None)
|
||||
|
||||
resp = asyncio.run(_run_middleware(_make_request()))
|
||||
|
||||
assert resp.status_code == 200
|
||||
# 响应上挂载后台任务(而非在响应返回前同步执行日志写入)
|
||||
assert resp.background is not None
|
||||
# 后台任务执行时调用 record_operation_log 写入日志
|
||||
asyncio.run(resp.background())
|
||||
assert captured['status'] == 0
|
||||
assert captured['consume_time'] >= 0
|
||||
assert 'ok' in captured['result'] # result 采样非空
|
||||
|
||||
|
||||
def test_operation_log_records_failure_status(monkeypatch):
|
||||
"""业务失败(code=1)在后台任务中记录 error 与 status=1。"""
|
||||
import modules.operation_log.service as op_service
|
||||
|
||||
captured = {}
|
||||
monkeypatch.setattr(op_service, 'record_operation_log',
|
||||
lambda request, title, **kw: captured.update(kw) or None)
|
||||
|
||||
async def call_next(request):
|
||||
return JSONResponse(content={'code': 1, 'msg': '权限不足'})
|
||||
|
||||
resp = asyncio.run(op_middleware.operation_log_middleware(_make_request(), call_next))
|
||||
asyncio.run(resp.background())
|
||||
assert captured['status'] == 1
|
||||
assert captured['error'] == '权限不足'
|
||||
|
||||
|
||||
def test_operation_log_exception_path_writes_via_thread_pool(monkeypatch):
|
||||
"""请求异常无响应可挂后台任务:走线程池同步写入(低频异常路径)。"""
|
||||
import modules.operation_log.service as op_service
|
||||
|
||||
submitted = []
|
||||
_patch_to_thread(monkeypatch, submitted)
|
||||
captured = {}
|
||||
monkeypatch.setattr(op_service, 'record_operation_log',
|
||||
lambda request, title, **kw: captured.update(kw) or None)
|
||||
|
||||
async def call_next(request):
|
||||
raise RuntimeError('boom')
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
asyncio.run(op_middleware.operation_log_middleware(_make_request(), call_next))
|
||||
assert submitted == [op_service.record_operation_log]
|
||||
assert captured['status'] == 1
|
||||
assert captured['error'] == 'boom'
|
||||
|
||||
|
||||
def test_operation_log_skipped_for_get_request(monkeypatch):
|
||||
import modules.operation_log.service as op_service
|
||||
|
||||
submitted = []
|
||||
_patch_to_thread(monkeypatch, submitted)
|
||||
monkeypatch.setattr(op_service, 'record_operation_log', lambda *a, **k: None)
|
||||
|
||||
resp = asyncio.run(_run_middleware(_make_request(method='GET')))
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert submitted == []
|
||||
|
||||
|
||||
def test_exclusion_uses_path_boundary_not_substring():
|
||||
"""排除匹配按路径段边界,避免子串误伤其他模块路由。"""
|
||||
# /api/v1/index/userInfo 是个人中心写操作,不应被子串 '/api/v1/index/user' 误排除
|
||||
assert op_middleware._should_log(_make_request(method='PUT', path='/api/v1/index/userInfo'))
|
||||
# 挂载前缀下的写操作应排除
|
||||
assert not op_middleware._should_log(_make_request(method='POST', path='/api/v1/login'))
|
||||
assert not op_middleware._should_log(_make_request(method='POST', path='/api/v1/upload/uploadFile'))
|
||||
assert not op_middleware._should_log(_make_request(method='DELETE', path='/api/v1/operationlog/delete/1'))
|
||||
assert not op_middleware._should_log(_make_request(method='DELETE', path='/api/v1/loginlog/batchDelete'))
|
||||
# 业务模块写操作应记录
|
||||
assert op_middleware._should_log(_make_request(method='PUT', path='/api/v1/user/update/1'))
|
||||
@@ -0,0 +1,105 @@
|
||||
"""middleware/rate_limit.py 请求限流中间件单元测试。"""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import fakeredis.aioredis
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import middleware.rate_limit as rate_limit_module
|
||||
from middleware.rate_limit import rate_limit_middleware
|
||||
|
||||
|
||||
def _make_app():
|
||||
@asynccontextmanager
|
||||
async def lifespan(app):
|
||||
# 在应用运行的事件循环内创建 FakeRedis,避免跨循环绑定问题
|
||||
app.state.redis = fakeredis.aioredis.FakeRedis(decode_responses=True)
|
||||
yield
|
||||
await app.state.redis.aclose()
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
|
||||
@app.get("/")
|
||||
async def home():
|
||||
return {"ok": True}
|
||||
|
||||
app.middleware("http")(rate_limit_middleware)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def test_rate_limit_disabled_passes(monkeypatch):
|
||||
monkeypatch.setattr(rate_limit_module, "RATE_LIMIT_ENABLED", False)
|
||||
with TestClient(_make_app()) as client:
|
||||
for _ in range(5):
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["ok"] is True
|
||||
|
||||
|
||||
async def _fake_sliding_window(redis, current_key, prev_key, window_seconds, window_index, now):
|
||||
"""fakeredis 不支持 Lua eval,用等价实现打桩滑动窗口计数/加权语义。"""
|
||||
cur = await redis.incr(current_key)
|
||||
if cur == 1:
|
||||
await redis.expire(current_key, window_seconds)
|
||||
prev = int(await redis.get(prev_key) or 0)
|
||||
elapsed = now - window_index * window_seconds
|
||||
f = elapsed / window_seconds
|
||||
f = max(0.0, min(1.0, f))
|
||||
return prev * (1 - f) + cur
|
||||
|
||||
|
||||
def test_rate_limit_exceeds_returns_business_error(monkeypatch):
|
||||
monkeypatch.setattr(rate_limit_module, "RATE_LIMIT_ENABLED", True)
|
||||
monkeypatch.setattr(rate_limit_module, "RATE_LIMIT_LIMIT", 3)
|
||||
monkeypatch.setattr(rate_limit_module, "RATE_LIMIT_WINDOW_SECONDS", 60)
|
||||
monkeypatch.setattr(rate_limit_module, "sliding_window_incr", _fake_sliding_window)
|
||||
with TestClient(_make_app()) as client:
|
||||
for _ in range(3):
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["ok"] is True
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["code"] == 1
|
||||
assert "过于频繁" in r.json()["msg"]
|
||||
|
||||
|
||||
def test_rate_limit_window_accepts_after_reset(monkeypatch):
|
||||
monkeypatch.setattr(rate_limit_module, "RATE_LIMIT_ENABLED", True)
|
||||
monkeypatch.setattr(rate_limit_module, "RATE_LIMIT_LIMIT", 2)
|
||||
monkeypatch.setattr(rate_limit_module, "RATE_LIMIT_WINDOW_SECONDS", 60)
|
||||
monkeypatch.setattr(rate_limit_module, "sliding_window_incr", _fake_sliding_window)
|
||||
with TestClient(_make_app()) as client:
|
||||
for _ in range(2):
|
||||
r = client.get("/")
|
||||
assert r.json()["ok"] is True
|
||||
r = client.get("/")
|
||||
assert r.json()["code"] == 1
|
||||
# 同一窗口内持续限流,不会因后续请求解除
|
||||
r = client.get("/")
|
||||
assert r.json()["code"] == 1
|
||||
|
||||
|
||||
def test_rate_limit_sliding_window_prevents_boundary_burst(monkeypatch):
|
||||
"""窗口边界处请求数按时间加权,不发生固定窗口的 2× 突发。"""
|
||||
monkeypatch.setattr(rate_limit_module, "RATE_LIMIT_ENABLED", True)
|
||||
monkeypatch.setattr(rate_limit_module, "RATE_LIMIT_LIMIT", 3)
|
||||
monkeypatch.setattr(rate_limit_module, "RATE_LIMIT_WINDOW_SECONDS", 100)
|
||||
monkeypatch.setattr(rate_limit_module, "sliding_window_incr", _fake_sliding_window)
|
||||
|
||||
clock = {"t": 50.0}
|
||||
monkeypatch.setattr(rate_limit_module.time, "time", lambda: clock["t"])
|
||||
|
||||
with TestClient(_make_app()) as client:
|
||||
# 窗口0(t=50):2 个请求,配额充足,放行
|
||||
for _ in range(2):
|
||||
assert client.get("/").json()["ok"] is True
|
||||
# 进入窗口1(t=150,f=0.5):上一窗口 2 个请求加权计入
|
||||
clock["t"] = 150.0
|
||||
# effective: 2*0.5+1=2 放行;2*0.5+2=3 放行;2*0.5+3=4 > 3 拒绝
|
||||
for _ in range(2):
|
||||
assert client.get("/").json()["ok"] is True
|
||||
r = client.get("/")
|
||||
assert r.json()["code"] == 1
|
||||
@@ -0,0 +1,81 @@
|
||||
"""middleware/upload_size_limit.py HTTP 层上传体积预拦截中间件单元测试。"""
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, Response
|
||||
|
||||
from middleware.upload_size_limit import upload_size_limit_middleware
|
||||
from core.config.app import UPLOAD_HTTP_MAX_SIZE_MB, UPLOAD_MAX_SIZE_MB
|
||||
|
||||
|
||||
def _make_request(method="POST", content_length=None):
|
||||
headers = []
|
||||
if content_length is not None:
|
||||
headers.append((b"content-length", str(content_length).encode()))
|
||||
scope = {
|
||||
"type": "http", "asgi": {"version": "3.0"}, "http_version": "1.1",
|
||||
"method": method, "scheme": "http", "path": "/upload/uploadFile",
|
||||
"raw_path": b"/upload/uploadFile", "query_string": b"", "root_path": "",
|
||||
"headers": headers, "client": ("127.0.0.1", 1234),
|
||||
"server": ("127.0.0.1", 8031),
|
||||
}
|
||||
return Request(scope)
|
||||
|
||||
|
||||
def _call(method="POST", content_length=None):
|
||||
request = _make_request(method=method, content_length=content_length)
|
||||
reached = {"called": False}
|
||||
|
||||
async def call_next(req):
|
||||
reached["called"] = True
|
||||
return Response(status_code=200)
|
||||
|
||||
return request, call_next, reached
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_size_middleware_rejects_oversized_body():
|
||||
# 声明超过 HTTP 阈值 → 提前拦截,业务(读取请求体)不执行
|
||||
limit = UPLOAD_HTTP_MAX_SIZE_MB * 1024 * 1024
|
||||
request, call_next, reached = _call(content_length=limit + 1)
|
||||
|
||||
resp = await upload_size_limit_middleware(request, call_next)
|
||||
|
||||
assert isinstance(resp, JSONResponse)
|
||||
body = json.loads(resp.body)
|
||||
assert body["code"] == 1
|
||||
assert "超过限制" in body["msg"]
|
||||
assert f"{UPLOAD_MAX_SIZE_MB}MB" in body["msg"]
|
||||
assert reached["called"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_size_middleware_passes_at_threshold():
|
||||
# 恰好等于 HTTP 阈值 → 放行(精确大小由业务层校验)
|
||||
limit = UPLOAD_HTTP_MAX_SIZE_MB * 1024 * 1024
|
||||
request, call_next, reached = _call(content_length=limit)
|
||||
|
||||
resp = await upload_size_limit_middleware(request, call_next)
|
||||
|
||||
assert reached["called"] is True
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_size_middleware_passes_chunked_no_length():
|
||||
# 分块传输(无 Content-Length):头部无法判定,放行交由业务层流式兜底
|
||||
request, call_next, reached = _call(content_length=None)
|
||||
|
||||
await upload_size_limit_middleware(request, call_next)
|
||||
|
||||
assert reached["called"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_size_middleware_ignores_get():
|
||||
request, call_next, reached = _call(method="GET", content_length=99999999)
|
||||
|
||||
await upload_size_limit_middleware(request, call_next)
|
||||
|
||||
assert reached["called"] is True
|
||||
@@ -0,0 +1,13 @@
|
||||
"""service 层单元测试公共 fixture:构造最小 Request 对象。"""
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_request():
|
||||
"""模拟 FastAPI Request,仅提供 service 层用到的字段。"""
|
||||
class FR:
|
||||
state = types.SimpleNamespace(user_id=1, username="tester", realname="测试员")
|
||||
query_params = {}
|
||||
return FR()
|
||||
@@ -0,0 +1,54 @@
|
||||
"""auth/service.py _record_fail:失败计数原子自增,达到上限触发锁定。"""
|
||||
import asyncio
|
||||
|
||||
import fakeredis.aioredis
|
||||
|
||||
import modules.auth.service as auth_svc
|
||||
from modules.auth.service import _record_fail
|
||||
|
||||
|
||||
async def _fake_incr_expire(redis, key, seconds):
|
||||
"""fakeredis 不支持 Lua eval,用等价非原子实现打桩真实脚本的语义。"""
|
||||
count = await redis.incr(key)
|
||||
if count == 1:
|
||||
await redis.expire(key, seconds)
|
||||
return count
|
||||
|
||||
|
||||
def test_record_fail_uses_atomic_incr_and_locks_at_limit(monkeypatch):
|
||||
monkeypatch.setattr(auth_svc, 'incr_with_expire', _fake_incr_expire)
|
||||
monkeypatch.setattr(auth_svc, 'LOGIN_FAIL_MAX', 3)
|
||||
monkeypatch.setattr(auth_svc, 'LOGIN_LOCK_SECONDS', 300)
|
||||
|
||||
async def run():
|
||||
redis = fakeredis.aioredis.FakeRedis(decode_responses=True)
|
||||
fail_key = auth_svc._fail_key('1.2.3.4', 'tester')
|
||||
lock_key = auth_svc._lock_key('1.2.3.4', 'tester')
|
||||
|
||||
for _ in range(3):
|
||||
await _record_fail(redis, '1.2.3.4', 'tester')
|
||||
|
||||
assert int(await redis.get(fail_key)) == 3
|
||||
# 失败计数 key 首次创建即带 TTL,不会永久残留
|
||||
assert await redis.ttl(fail_key) > 0
|
||||
# 达到上限后设置锁定
|
||||
assert await redis.get(lock_key) == '1'
|
||||
assert await redis.ttl(lock_key) == 300
|
||||
await redis.aclose()
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_record_fail_below_limit_no_lock(monkeypatch):
|
||||
monkeypatch.setattr(auth_svc, 'incr_with_expire', _fake_incr_expire)
|
||||
monkeypatch.setattr(auth_svc, 'LOGIN_FAIL_MAX', 5)
|
||||
monkeypatch.setattr(auth_svc, 'LOGIN_LOCK_SECONDS', 300)
|
||||
|
||||
async def run():
|
||||
redis = fakeredis.aioredis.FakeRedis(decode_responses=True)
|
||||
await _record_fail(redis, '1.2.3.4', 'tester')
|
||||
lock_key = auth_svc._lock_key('1.2.3.4', 'tester')
|
||||
assert await redis.get(lock_key) is None
|
||||
await redis.aclose()
|
||||
|
||||
asyncio.run(run())
|
||||
@@ -0,0 +1,89 @@
|
||||
"""BaseService 分页/固定过滤钩子单元测试。
|
||||
|
||||
回归:_apply_page_filters 钩子须被 _build_page_query 调用,
|
||||
否则 dict/config 项按所属字典/配置的固定过滤会失效。
|
||||
"""
|
||||
import json
|
||||
|
||||
from modules.dictionary.dict.models import Dict
|
||||
from modules.dictionary.dict_item.models import DictItem
|
||||
from modules.dictionary.dict_item.repository import dictitem_repo
|
||||
from modules.dictionary.dict_item.service import dict_item_service
|
||||
from modules.configuration.config_item.repository import configitem_repo
|
||||
from modules.configuration.config_item.service import config_item_service
|
||||
from modules.system.level.repository import level_repo
|
||||
from modules.system.level.service import level_service
|
||||
from utils import dict_util
|
||||
|
||||
|
||||
def _resp_body(resp):
|
||||
return json.loads(resp.body)
|
||||
|
||||
|
||||
def test_dict_item_page_filters_by_dict_id(fake_request, sqlite_db):
|
||||
dictitem_repo.create(name="性别男", value="1", dict_id=1, sort=1)
|
||||
dictitem_repo.create(name="性别女", value="2", dict_id=1, sort=2)
|
||||
dictitem_repo.create(name="状态启用", value="1", dict_id=2, sort=1)
|
||||
|
||||
fake_request.query_params = {"dict_id": 1}
|
||||
resp = dict_item_service.get_page(fake_request)
|
||||
body = _resp_body(resp)["data"]
|
||||
assert len(body["records"]) == 2
|
||||
assert all(row["dict_id"] == 1 for row in body["records"])
|
||||
|
||||
|
||||
def test_config_item_page_filters_by_config_id(fake_request, sqlite_db):
|
||||
configitem_repo.create(title="站点名", code="site_name", value="v", type="text", config_id=3, sort=1)
|
||||
configitem_repo.create(title="LOGO", code="logo", value="x", type="image", config_id=4, sort=1)
|
||||
|
||||
# type_name 已由数据字典驱动:种入 config_item_type 并清空进程缓存,避免取到空字典
|
||||
d = Dict(name="配置项类型", code="config_item_type", sort=1)
|
||||
sqlite_db.add(d)
|
||||
sqlite_db.flush()
|
||||
sqlite_db.add(DictItem(name="单行文本", value="text", dict_id=d.id, sort=1))
|
||||
sqlite_db.flush()
|
||||
dict_util._dict_cache.clear()
|
||||
|
||||
fake_request.query_params = {"config_id": 3}
|
||||
resp = config_item_service.get_page(fake_request)
|
||||
body = _resp_body(resp)["data"]
|
||||
assert len(body["records"]) == 1
|
||||
assert body["records"][0]["config_id"] == 3
|
||||
# 列表行序列化带 type_name,详情不含
|
||||
assert body["records"][0]["type_name"] == "单行文本"
|
||||
|
||||
|
||||
def test_page_like_eq_order(fake_request, sqlite_db):
|
||||
level_repo.create(name="初级", status=1, sort=3)
|
||||
level_repo.create(name="高级", status=1, sort=1)
|
||||
level_repo.create(name="资深", status=2, sort=2)
|
||||
|
||||
# 等值 status=1 + 排序 sort asc
|
||||
fake_request.query_params = {"status": 1}
|
||||
resp = level_service.get_page(fake_request)
|
||||
body = _resp_body(resp)["data"]
|
||||
assert [row["name"] for row in body["records"]] == ["高级", "初级"]
|
||||
|
||||
# 模糊 name 包含"级"(初级、高级匹配,资深不匹配)
|
||||
fake_request.query_params = {"name": "级"}
|
||||
resp = level_service.get_page(fake_request)
|
||||
assert _resp_body(resp)["data"]["total"] == 2
|
||||
|
||||
|
||||
def test_delete_success(fake_request, sqlite_db):
|
||||
obj = level_repo.create(name="待删除", status=1, sort=9)
|
||||
resp = level_service.delete(str(obj.id))
|
||||
body = _resp_body(resp)
|
||||
assert body["code"] == 0
|
||||
assert body["msg"] == "本次共删除1条数据"
|
||||
assert level_repo.get_by_id(obj.id) is None
|
||||
|
||||
|
||||
def test_delete_missing_id_failed(fake_request, sqlite_db):
|
||||
resp = level_service.delete("999")
|
||||
assert _resp_body(resp)["msg"] == "记录不存在"
|
||||
|
||||
|
||||
def test_delete_empty_ids_failed(fake_request, sqlite_db):
|
||||
resp = level_service.delete("")
|
||||
assert _resp_body(resp)["msg"] == "记录ID不存在"
|
||||
@@ -0,0 +1,79 @@
|
||||
"""category service 单元测试(回归删除父级时禁止有子级)。"""
|
||||
import json
|
||||
|
||||
from modules.category import service as category_service
|
||||
from modules.category.models import Category
|
||||
from modules.category.repository import category_repo
|
||||
from modules.category.schemas import CategoryForm
|
||||
|
||||
|
||||
def _resp_body(resp):
|
||||
return json.loads(resp.body)
|
||||
|
||||
|
||||
def _form(**overrides):
|
||||
data = dict(categoryName="测试分类", categoryCode="C001", parentId=0, sort=1, note=None, type=1)
|
||||
data.update(overrides)
|
||||
return CategoryForm(**data)
|
||||
|
||||
|
||||
def test_delete_parent_with_child_blocked(fake_request, sqlite_db):
|
||||
category_service.add_category(fake_request, _form())
|
||||
parent = category_repo.filter(Category.category_name == "测试分类").first()
|
||||
|
||||
category_service.add_category(
|
||||
fake_request,
|
||||
_form(categoryName="子分类", categoryCode="C001-1", parentId=parent.id),
|
||||
)
|
||||
child = category_repo.filter(Category.category_name == "子分类").first()
|
||||
|
||||
resp = category_service.delete_category(str(parent.id))
|
||||
assert _resp_body(resp)["code"] != 0
|
||||
# 父级与子级均保持生效
|
||||
assert category_repo.get_by_id(parent.id).is_delete == 0
|
||||
assert category_repo.get_by_id(child.id).is_delete == 0
|
||||
|
||||
|
||||
def test_delete_leaf_soft_deletes(fake_request, sqlite_db):
|
||||
category_service.add_category(fake_request, _form())
|
||||
leaf = category_repo.filter(Category.category_name == "测试分类").first()
|
||||
|
||||
resp = category_service.delete_category(str(leaf.id))
|
||||
assert _resp_body(resp)["code"] == 0
|
||||
assert category_repo.filter(Category.id == leaf.id).first().is_delete == 1
|
||||
|
||||
|
||||
# ============ 分类树成环检测:parent_id 存在性 + 自身/下级 校验 ============
|
||||
|
||||
def test_add_category_rejects_missing_parent(fake_request, sqlite_db):
|
||||
resp = category_service.add_category(fake_request, _form(parentId=9999))
|
||||
body = _resp_body(resp)
|
||||
assert body["code"] != 0
|
||||
assert "上级分类不存在" in body["msg"]
|
||||
|
||||
|
||||
def test_update_category_rejects_self_parent(fake_request, sqlite_db):
|
||||
category_service.add_category(fake_request, _form())
|
||||
cat = category_repo.filter(Category.category_name == "测试分类").first()
|
||||
|
||||
resp = category_service.update_category(fake_request, _form(id=cat.id, parentId=cat.id))
|
||||
body = _resp_body(resp)
|
||||
assert body["code"] != 0
|
||||
assert "自身" in body["msg"]
|
||||
# 原记录未被改动
|
||||
assert category_repo.get_by_id(cat.id).parent_id == 0
|
||||
|
||||
|
||||
def test_update_category_rejects_descendant_parent(fake_request, sqlite_db):
|
||||
category_service.add_category(fake_request, _form())
|
||||
parent = category_repo.filter(Category.category_name == "测试分类").first()
|
||||
category_service.add_category(fake_request, _form(categoryName="子分类", parentId=parent.id))
|
||||
child = category_repo.filter(Category.category_name == "子分类").first()
|
||||
|
||||
# 把父级设为自己的子级 → 成环,拒绝
|
||||
resp = category_service.update_category(
|
||||
fake_request, _form(id=parent.id, categoryName="测试分类", parentId=child.id))
|
||||
body = _resp_body(resp)
|
||||
assert body["code"] != 0
|
||||
assert "下级" in body["msg"]
|
||||
assert category_repo.get_by_id(parent.id).parent_id == 0
|
||||
@@ -0,0 +1,38 @@
|
||||
"""city service 单元测试(回归 haveChild 尾部逗号导致恒为元组/恒 truthy 的 bug)。"""
|
||||
import json
|
||||
|
||||
from modules.city import service as city_service
|
||||
from modules.city.models import City
|
||||
from modules.city.repository import city_repo
|
||||
|
||||
|
||||
def _resp_body(resp):
|
||||
return json.loads(resp.body)
|
||||
|
||||
|
||||
def test_get_city_list_have_child_is_bool(fake_request, sqlite_db):
|
||||
city_repo.create(
|
||||
city_code="100000", area_code="100000", parent_code="0", zip_code="000000",
|
||||
level=1, parent_id=0, name="北京市", short_name="北京", full_name="北京市",
|
||||
pinyin="beijing", lng="116.4", lat="39.9",
|
||||
)
|
||||
fake_request.query_params = {"parent_id": 0}
|
||||
|
||||
resp = city_service.get_city_list(fake_request)
|
||||
data = _resp_body(resp)["data"]
|
||||
assert isinstance(data, list) and len(data) == 1
|
||||
assert data[0]["haveChild"] is True
|
||||
|
||||
|
||||
def test_get_city_list_leaf_have_child_false(fake_request, sqlite_db):
|
||||
city_repo.create(
|
||||
city_code="100000", area_code="100000", parent_code="0", zip_code="000000",
|
||||
level=4, parent_id=0, name="某街道", short_name="街道", full_name="某街道",
|
||||
pinyin="jiedao", lng="116.4", lat="39.9",
|
||||
)
|
||||
fake_request.query_params = {"parent_id": 0}
|
||||
|
||||
resp = city_service.get_city_list(fake_request)
|
||||
data = _resp_body(resp)["data"]
|
||||
assert isinstance(data, list) and len(data) == 1
|
||||
assert data[0]["haveChild"] is False
|
||||
@@ -0,0 +1,198 @@
|
||||
"""删除级联防护:存在外键引用时禁止删除(dept→user、category→article、city→子级、dict/config→item)。"""
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from core import R
|
||||
from core.base_service import BaseService
|
||||
from utils.request import parse_id_list
|
||||
|
||||
from modules.system.dept.repository import dept_repo
|
||||
from modules.system.dept.service import delete_dept
|
||||
from modules.system.role.models import Role, RoleMenu
|
||||
from modules.system.user.repository import user_repo
|
||||
from modules.category.repository import category_repo
|
||||
from modules.category.service import delete_category
|
||||
from modules.article.repository import article_repo
|
||||
from modules.city.repository import city_repo
|
||||
from modules.city.service import delete_city
|
||||
from modules.dictionary.dict_item.repository import dictitem_repo
|
||||
from modules.dictionary.dict.service import dict_service
|
||||
from modules.configuration.config_item.repository import configitem_repo
|
||||
from modules.configuration.config.service import config_service
|
||||
|
||||
|
||||
class _FakeQuery:
|
||||
"""模拟 SQLAlchemy Query:仅支持 first(),返回行或 None。"""
|
||||
|
||||
def __init__(self, has_row):
|
||||
self._has_row = has_row
|
||||
|
||||
def first(self):
|
||||
return object() if self._has_row else None
|
||||
|
||||
|
||||
def _body(result):
|
||||
return json.loads(result.body)
|
||||
|
||||
|
||||
def _allow_delete(monkeypatch):
|
||||
"""放行批量删除,捕获传入的 ids 以便断言走到了该步骤。"""
|
||||
captured = {}
|
||||
|
||||
def fake_batch_delete(repo, ids):
|
||||
captured['ids'] = ids
|
||||
return R.ok(msg="本次共删除{0}条数据".format(len(parse_id_list(str(ids)))))
|
||||
|
||||
monkeypatch.setattr(BaseService, 'batch_delete_with_r', staticmethod(fake_batch_delete))
|
||||
return captured
|
||||
|
||||
|
||||
# ============ dept → user ============
|
||||
|
||||
def test_delete_dept_blocks_when_users_reference(monkeypatch):
|
||||
monkeypatch.setattr(dept_repo, 'filter', lambda *a, **k: _FakeQuery(False)) # 无下级部门
|
||||
monkeypatch.setattr(user_repo, 'filter', lambda *a, **k: _FakeQuery(True)) # 有用户引用
|
||||
result = delete_dept('1')
|
||||
assert _body(result)['ok'] is False
|
||||
assert '用户' in _body(result)['msg']
|
||||
|
||||
|
||||
def test_delete_dept_allows_when_no_references(monkeypatch):
|
||||
monkeypatch.setattr(dept_repo, 'filter', lambda *a, **k: _FakeQuery(False))
|
||||
monkeypatch.setattr(user_repo, 'filter', lambda *a, **k: _FakeQuery(False))
|
||||
captured = _allow_delete(monkeypatch)
|
||||
result = delete_dept('1,2')
|
||||
assert _body(result)['ok'] is True
|
||||
assert captured['ids'] == '1,2'
|
||||
|
||||
|
||||
# ============ category → article ============
|
||||
|
||||
def test_delete_category_blocks_when_articles_reference(monkeypatch):
|
||||
monkeypatch.setattr(category_repo, 'filter', lambda *a, **k: _FakeQuery(False)) # 无下级分类
|
||||
monkeypatch.setattr(article_repo, 'filter', lambda *a, **k: _FakeQuery(True)) # 有文章引用
|
||||
result = delete_category('1')
|
||||
assert _body(result)['ok'] is False
|
||||
assert '文章' in _body(result)['msg']
|
||||
|
||||
|
||||
def test_delete_category_allows_when_no_references(monkeypatch):
|
||||
monkeypatch.setattr(category_repo, 'filter', lambda *a, **k: _FakeQuery(False))
|
||||
monkeypatch.setattr(article_repo, 'filter', lambda *a, **k: _FakeQuery(False))
|
||||
captured = _allow_delete(monkeypatch)
|
||||
result = delete_category('1,2')
|
||||
assert _body(result)['ok'] is True
|
||||
assert captured['ids'] == '1,2'
|
||||
|
||||
|
||||
# ============ city → 子级 ============
|
||||
|
||||
def test_delete_city_blocks_when_child_exists(monkeypatch):
|
||||
monkeypatch.setattr(city_repo, 'filter', lambda *a, **k: _FakeQuery(True))
|
||||
result = delete_city('1')
|
||||
assert _body(result)['ok'] is False
|
||||
assert '下级' in _body(result)['msg']
|
||||
|
||||
|
||||
def test_delete_city_allows_when_no_children(monkeypatch):
|
||||
monkeypatch.setattr(city_repo, 'filter', lambda *a, **k: _FakeQuery(False))
|
||||
captured = _allow_delete(monkeypatch)
|
||||
result = delete_city('1,2')
|
||||
assert _body(result)['ok'] is True
|
||||
assert captured['ids'] == '1,2'
|
||||
|
||||
|
||||
# ============ dict → dict_item ============
|
||||
|
||||
def test_dict_before_delete_blocks_when_items_exist(monkeypatch):
|
||||
monkeypatch.setattr(dictitem_repo, 'filter', lambda *a, **k: _FakeQuery(True))
|
||||
err = dict_service._before_delete('1,2')
|
||||
assert err is not None
|
||||
assert '字典项' in err
|
||||
|
||||
|
||||
def test_dict_before_delete_allows_when_no_items(monkeypatch):
|
||||
monkeypatch.setattr(dictitem_repo, 'filter', lambda *a, **k: _FakeQuery(False))
|
||||
assert dict_service._before_delete('1,2') is None
|
||||
|
||||
|
||||
# ============ config → config_item ============
|
||||
|
||||
def test_config_before_delete_blocks_when_items_exist(monkeypatch):
|
||||
monkeypatch.setattr(configitem_repo, 'filter', lambda *a, **k: _FakeQuery(True))
|
||||
err = config_service._before_delete('1,2')
|
||||
assert err is not None
|
||||
assert '配置项' in err
|
||||
|
||||
|
||||
def test_config_before_delete_allows_when_no_items(monkeypatch):
|
||||
monkeypatch.setattr(configitem_repo, 'filter', lambda *a, **k: _FakeQuery(False))
|
||||
assert config_service._before_delete('1,2') is None
|
||||
|
||||
|
||||
# ============ role → user_role / role_menu 残留清理 ============
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_role_cleans_user_role_and_role_menu(sqlite_db, monkeypatch):
|
||||
from modules.system.role import service as role_service
|
||||
from modules.system.role.models import Role
|
||||
from modules.system.role_menu.models import RoleMenu
|
||||
from modules.system.user_role.models import UserRole
|
||||
from core.database import get_db_session
|
||||
|
||||
async def _noop(*args, **kwargs):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(role_service, 'invalidate_role_perms', _noop)
|
||||
|
||||
# 显式非内置 id(内置角色 1-4 禁止删除)
|
||||
role = Role(id=100, name='普通角色', code='R1', status=1, sort=1)
|
||||
role.save()
|
||||
db = get_db_session()
|
||||
db.add_all([
|
||||
UserRole(user_id=5, role_id=role.id),
|
||||
RoleMenu(role_id=role.id, menu_id=10),
|
||||
])
|
||||
db.commit()
|
||||
|
||||
resp = await role_service.delete_role(str(role.id))
|
||||
assert _body(resp)['code'] == 0
|
||||
# 关联残留已清理,角色本身软删
|
||||
assert db.query(UserRole).filter(UserRole.role_id == role.id).count() == 0
|
||||
assert db.query(RoleMenu).filter(RoleMenu.role_id == role.id).count() == 0
|
||||
assert role.is_delete == 1
|
||||
|
||||
|
||||
# ============ position / level → user 引用(与部门一致) ============
|
||||
|
||||
def test_position_before_delete_blocks_when_users_reference(monkeypatch):
|
||||
from modules.system.position.service import position_service
|
||||
|
||||
monkeypatch.setattr(user_repo, 'filter', lambda *a, **k: _FakeQuery(True))
|
||||
err = position_service._before_delete('1')
|
||||
assert err is not None
|
||||
assert '岗位' in err
|
||||
|
||||
|
||||
def test_position_before_delete_allows_when_no_references(monkeypatch):
|
||||
from modules.system.position.service import position_service
|
||||
|
||||
monkeypatch.setattr(user_repo, 'filter', lambda *a, **k: _FakeQuery(False))
|
||||
assert position_service._before_delete('1,2') is None
|
||||
|
||||
|
||||
def test_level_before_delete_blocks_when_users_reference(monkeypatch):
|
||||
from modules.system.level.service import level_service
|
||||
|
||||
monkeypatch.setattr(user_repo, 'filter', lambda *a, **k: _FakeQuery(True))
|
||||
err = level_service._before_delete('1')
|
||||
assert err is not None
|
||||
assert '职级' in err
|
||||
|
||||
|
||||
def test_level_before_delete_allows_when_no_references(monkeypatch):
|
||||
from modules.system.level.service import level_service
|
||||
|
||||
monkeypatch.setattr(user_repo, 'filter', lambda *a, **k: _FakeQuery(False))
|
||||
assert level_service._before_delete('1,2') is None
|
||||
@@ -0,0 +1,42 @@
|
||||
"""system/dept service 单元测试(回归删除父级时禁止有子级)。"""
|
||||
import json
|
||||
|
||||
from modules.system.dept import service as dept_service
|
||||
from modules.system.dept.models import Dept
|
||||
from modules.system.dept.repository import dept_repo
|
||||
from modules.system.dept.schemas import DeptForm
|
||||
|
||||
|
||||
def _resp_body(resp):
|
||||
return json.loads(resp.body)
|
||||
|
||||
|
||||
def _form(**overrides):
|
||||
data = dict(name="总公司", code="HQ", type=1, parentId=None, sort=1, note=None)
|
||||
data.update(overrides)
|
||||
return DeptForm(**data)
|
||||
|
||||
|
||||
def test_delete_parent_with_child_blocked(fake_request, sqlite_db):
|
||||
dept_service.add_dept(fake_request, _form())
|
||||
parent = dept_repo.filter(Dept.name == "总公司").first()
|
||||
|
||||
dept_service.add_dept(
|
||||
fake_request,
|
||||
_form(name="研发部", code="RD", parentId=parent.id),
|
||||
)
|
||||
child = dept_repo.filter(Dept.name == "研发部").first()
|
||||
|
||||
resp = dept_service.delete_dept(str(parent.id))
|
||||
assert _resp_body(resp)["code"] != 0
|
||||
assert dept_repo.get_by_id(parent.id).is_delete == 0
|
||||
assert dept_repo.get_by_id(child.id).is_delete == 0
|
||||
|
||||
|
||||
def test_delete_leaf_soft_deletes(fake_request, sqlite_db):
|
||||
dept_service.add_dept(fake_request, _form())
|
||||
leaf = dept_repo.filter(Dept.name == "总公司").first()
|
||||
|
||||
resp = dept_service.delete_dept(str(leaf.id))
|
||||
assert _resp_body(resp)["code"] == 0
|
||||
assert dept_repo.filter(Dept.id == leaf.id).first().is_delete == 1
|
||||
@@ -0,0 +1,101 @@
|
||||
"""字典项唯一性按所属字典隔离:同一字典下名称不可重复,不同字典同名放行。"""
|
||||
import json
|
||||
|
||||
from modules.dictionary.dict_item.repository import dictitem_repo
|
||||
from modules.dictionary.dict_item.schemas import DictItemForm
|
||||
from modules.dictionary.dict_item.service import dict_item_service
|
||||
|
||||
|
||||
class _FakeQuery:
|
||||
"""模拟 SQLAlchemy Query:记录是否调用 filter(排除自身),first() 返回行或 None。"""
|
||||
|
||||
def __init__(self, has_row=True):
|
||||
self._has_row = has_row
|
||||
self.filter_called = False
|
||||
|
||||
def filter(self, *criteria):
|
||||
self.filter_called = True
|
||||
# 排除自身后应无重复行
|
||||
return _FakeQuery(False)
|
||||
|
||||
def first(self):
|
||||
return object() if self._has_row else None
|
||||
|
||||
|
||||
def _body(result):
|
||||
return json.loads(result.body)
|
||||
|
||||
|
||||
def _set_filter_by(monkeypatch, query):
|
||||
monkeypatch.setattr(dictitem_repo, 'filter_by', lambda **kw: query)
|
||||
|
||||
|
||||
# ============ 查重范围:按 (dict_id, name) 隔离 ============
|
||||
|
||||
def test_dict_item_unique_scoped_to_dict(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_filter_by(**kw):
|
||||
captured.update(kw)
|
||||
return _FakeQuery(False)
|
||||
|
||||
monkeypatch.setattr(dictitem_repo, 'filter_by', fake_filter_by)
|
||||
data = DictItemForm(name='启用', value='1', dictId=2, sort=1)
|
||||
assert dict_item_service._check_unique(data) is None
|
||||
# 查重条件是 dict_id + name,而非仅 name 全局查重
|
||||
assert captured == {'dict_id': 2, 'name': '启用'}
|
||||
|
||||
|
||||
def test_dict_item_unique_blocks_same_name_in_same_dict(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_filter_by(**kw):
|
||||
captured.update(kw)
|
||||
return _FakeQuery(True)
|
||||
|
||||
monkeypatch.setattr(dictitem_repo, 'filter_by', fake_filter_by)
|
||||
data = DictItemForm(name='启用', value='1', dictId=2, sort=1)
|
||||
assert dict_item_service._check_unique(data) == '字典项名称不能重复'
|
||||
assert captured == {'dict_id': 2, 'name': '启用'}
|
||||
|
||||
|
||||
# ============ 编辑时排除自身 ============
|
||||
|
||||
def test_dict_item_unique_excludes_self_on_update(monkeypatch):
|
||||
query = _FakeQuery(True)
|
||||
_set_filter_by(monkeypatch, query)
|
||||
data = DictItemForm(id=5, name='启用', value='1', dictId=2, sort=1)
|
||||
# 排除自身后无重复 → 放行
|
||||
assert dict_item_service._check_unique(data, exclude_id=5) is None
|
||||
assert query.filter_called is True
|
||||
|
||||
|
||||
def test_dict_item_unique_add_does_not_exclude(monkeypatch):
|
||||
query = _FakeQuery(True)
|
||||
_set_filter_by(monkeypatch, query)
|
||||
data = DictItemForm(name='启用', value='1', dictId=2, sort=1)
|
||||
# 新增不排除自身 → 查到同名字段即判重
|
||||
assert dict_item_service._check_unique(data) == '字典项名称不能重复'
|
||||
assert query.filter_called is False
|
||||
|
||||
|
||||
# ============ add 端到端:不同字典同名可新增,同字典同名被拒 ============
|
||||
|
||||
def test_dict_item_add_allows_same_name_in_different_dict(monkeypatch, fake_request):
|
||||
_set_filter_by(monkeypatch, _FakeQuery(False))
|
||||
captured = {}
|
||||
monkeypatch.setattr(dictitem_repo, 'create', lambda **fields: captured.update(fields) or object())
|
||||
|
||||
data = DictItemForm(name='启用', value='1', dictId=2, sort=1)
|
||||
result = dict_item_service.add(fake_request, data)
|
||||
assert _body(result)['ok'] is True
|
||||
assert captured['dictId'] == 2
|
||||
|
||||
|
||||
def test_dict_item_add_blocks_same_name_in_same_dict(monkeypatch, fake_request):
|
||||
_set_filter_by(monkeypatch, _FakeQuery(True))
|
||||
|
||||
data = DictItemForm(name='启用', value='1', dictId=2, sort=1)
|
||||
result = dict_item_service.add(fake_request, data)
|
||||
assert _body(result)['ok'] is False
|
||||
assert '重复' in _body(result)['msg']
|
||||
@@ -0,0 +1,102 @@
|
||||
"""dict_util 字典读取工具:列表/映射/单值读取,进程内缓存与失效行为。"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from utils import dict_util
|
||||
from utils.dict_util import (
|
||||
get_dict_items,
|
||||
get_dict_label,
|
||||
get_dict_map,
|
||||
invalidate_dict,
|
||||
invalidate_dicts_by_ids,
|
||||
)
|
||||
|
||||
|
||||
def _clear_cache():
|
||||
dict_util._dict_cache.clear()
|
||||
|
||||
|
||||
def _rows():
|
||||
# 刻意乱序:女(sort=5)、男(sort=1)、保密(sort=10),校验按 sort+id 升序输出
|
||||
return [
|
||||
SimpleNamespace(id=2, name='女', value='2', sort=5),
|
||||
SimpleNamespace(id=1, name='男', value='1', sort=1),
|
||||
SimpleNamespace(id=3, name='保密', value='3', sort=10),
|
||||
]
|
||||
|
||||
|
||||
class _FakeQuery:
|
||||
def __init__(self, rows):
|
||||
self._rows = rows
|
||||
|
||||
def all(self):
|
||||
return self._rows
|
||||
|
||||
|
||||
def _patch_repo(monkeypatch, dict_obj):
|
||||
monkeypatch.setattr(dict_util.dict_repo, 'get_one', lambda **kw: dict_obj)
|
||||
monkeypatch.setattr(dict_util.dictitem_repo, 'filter_by', lambda **kw: _FakeQuery(_rows()))
|
||||
|
||||
|
||||
# ============ 读取 ============
|
||||
|
||||
def test_get_dict_map_maps_value_to_label(monkeypatch):
|
||||
_clear_cache()
|
||||
_patch_repo(monkeypatch, SimpleNamespace(id=1, code='gender', name='性别'))
|
||||
assert get_dict_map('gender') == {'1': '男', '2': '女', '3': '保密'}
|
||||
|
||||
|
||||
def test_get_dict_items_sorted_by_sort_then_id(monkeypatch):
|
||||
_clear_cache()
|
||||
_patch_repo(monkeypatch, SimpleNamespace(id=1, code='gender', name='性别'))
|
||||
assert [it['value'] for it in get_dict_items('gender')] == ['1', '2', '3']
|
||||
|
||||
|
||||
def test_get_dict_label_normalizes_value_to_str(monkeypatch):
|
||||
_clear_cache()
|
||||
_patch_repo(monkeypatch, SimpleNamespace(id=1, code='gender', name='性别'))
|
||||
# 用户存整数 1,字典项 value 是字符串 '1',需自动转字符串匹配
|
||||
assert get_dict_label('gender', 1) == '男'
|
||||
assert get_dict_label('gender', '2') == '女'
|
||||
assert get_dict_label('gender', 99) is None
|
||||
assert get_dict_label('gender', 99, '未知') == '未知'
|
||||
|
||||
|
||||
def test_get_dict_map_unknown_code_returns_empty(monkeypatch):
|
||||
_clear_cache()
|
||||
_patch_repo(monkeypatch, None)
|
||||
assert get_dict_items('nope') == []
|
||||
assert get_dict_map('nope') == {}
|
||||
|
||||
|
||||
# ============ 缓存 ============
|
||||
|
||||
def test_cache_hit_skips_db(monkeypatch):
|
||||
_clear_cache()
|
||||
_patch_repo(monkeypatch, SimpleNamespace(id=1, code='gender', name='性别'))
|
||||
get_dict_map('gender')
|
||||
calls = []
|
||||
monkeypatch.setattr(dict_util.dict_repo, 'get_one', lambda **kw: calls.append(1) or None)
|
||||
get_dict_map('gender') # 命中缓存,不再访问 DB
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_invalidate_dict_clears_cache(monkeypatch):
|
||||
_clear_cache()
|
||||
_patch_repo(monkeypatch, SimpleNamespace(id=1, code='gender', name='性别'))
|
||||
get_dict_map('gender')
|
||||
invalidate_dict('gender')
|
||||
assert dict_util._dict_cache == {}
|
||||
|
||||
|
||||
def test_invalidate_dicts_by_ids_clears_cached_codes(monkeypatch):
|
||||
_clear_cache()
|
||||
_patch_repo(monkeypatch, SimpleNamespace(id=1, code='gender', name='性别'))
|
||||
get_dict_map('gender') # 先填充缓存
|
||||
|
||||
class _FakeRows:
|
||||
def all(self):
|
||||
return [SimpleNamespace(code='gender')]
|
||||
|
||||
monkeypatch.setattr(dict_util.dict_repo, 'filter', lambda *criteria: _FakeRows())
|
||||
invalidate_dicts_by_ids({1})
|
||||
assert dict_util._dict_cache == {}
|
||||
@@ -0,0 +1,301 @@
|
||||
"""调度器安全相关测试:SSRF 防护 + 单实例防护 + 统一线程池。"""
|
||||
from modules.job import scheduler as sch
|
||||
|
||||
|
||||
def test_build_full_url():
|
||||
assert sch.build_full_url('/api/x') == f"http://{sch.FASTAPI_HOST}:{sch.FASTAPI_PORT}/api/x"
|
||||
assert sch.build_full_url('http://a.com/x') == 'http://a.com/x'
|
||||
assert sch.build_full_url('ftp://a.com/x') == 'ftp://a.com/x'
|
||||
|
||||
|
||||
def test_validate_http_url_rejects_internal_ips():
|
||||
for url in (
|
||||
'http://127.0.0.1:6379/', # 回环
|
||||
'http://10.0.0.1/x', # 私有A类
|
||||
'http://169.254.169.254/latest', # 云元数据
|
||||
'http://192.168.1.1/x', # 私有C类
|
||||
'http://172.16.5.5/x', # 私有B类
|
||||
):
|
||||
ok, reason = sch._validate_http_url(url)
|
||||
assert ok is False, url
|
||||
assert '禁止' in reason or '内网' in reason
|
||||
|
||||
|
||||
def test_validate_http_url_rejects_non_http_scheme():
|
||||
for url in ('ftp://a.com/x', 'file:///etc/passwd', 'javascript:alert(1)'):
|
||||
ok, reason = sch._validate_http_url(url)
|
||||
assert ok is False, url
|
||||
assert '协议' in reason
|
||||
|
||||
|
||||
def test_validate_http_url_accepts_public_ip():
|
||||
# 公网 IP 字面量,无需真实 DNS
|
||||
ok, reason = sch._validate_http_url('http://93.184.216.34/')
|
||||
assert ok is True, reason
|
||||
|
||||
|
||||
def test_execute_by_url_blocks_internal_url(monkeypatch):
|
||||
sent = []
|
||||
monkeypatch.setattr(
|
||||
sch, '_send_sync_request',
|
||||
lambda url, data, jid, pinned_ip=None: sent.append(url) or {'success': True})
|
||||
result = sch._execute_by_url(1, 'http://127.0.0.1:6379', 1)
|
||||
assert result['success'] is False
|
||||
assert '禁止' in result['error'] or '内网' in result['error']
|
||||
assert sent == [] # 未实际发起请求
|
||||
|
||||
|
||||
def test_execute_by_url_relative_self_call_allowed(monkeypatch):
|
||||
sent = []
|
||||
monkeypatch.setattr(
|
||||
sch, '_send_sync_request',
|
||||
lambda url, data, jid, pinned_ip=None: sent.append(url) or {'success': True})
|
||||
result = sch._execute_by_url(1, '/api/ping', 1)
|
||||
assert result['success'] is True
|
||||
assert sent == [f"http://{sch.FASTAPI_HOST}:{sch.FASTAPI_PORT}/api/ping"]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# SSRF DNS 重绑定防护:预解析后固定 IP 直连,杜绝二次解析域名
|
||||
# ============================================================
|
||||
|
||||
def test_resolve_and_pin_returns_pinned_ip():
|
||||
# 公网 IP 字面量,无需真实 DNS
|
||||
ok, reason, ip = sch._resolve_and_pin('http://93.184.216.34/x')
|
||||
assert ok is True, reason
|
||||
assert ip == '93.184.216.34'
|
||||
|
||||
|
||||
def test_resolve_and_pin_rejects_custom_port():
|
||||
ok, reason, ip = sch._resolve_and_pin('http://93.184.216.34:8080/x')
|
||||
assert ok is False
|
||||
assert '端口' in reason
|
||||
assert ip is None
|
||||
|
||||
|
||||
def test_resolve_and_pin_allows_80_443():
|
||||
for url in ('http://93.184.216.34:80/x', 'https://93.184.216.34:443/x'):
|
||||
ok, reason, ip = sch._resolve_and_pin(url)
|
||||
assert ok is True, reason
|
||||
assert ip == '93.184.216.34'
|
||||
|
||||
|
||||
def test_pinned_connection_pins_dns_host_keeps_real_host():
|
||||
# HTTP 与 HTTPS 连接:_dns_host 固定为预校验 IP(实际建连目标),
|
||||
# host 保持真实主机名(Host 头 / TLS SNI)——同域名只解析一次,杜绝重绑定。
|
||||
for cls, port in ((sch._PinnedHTTPConnection, 80), (sch._PinnedHTTPSConnection, 443)):
|
||||
conn = cls(host='example.com', port=port, pinned_ip='93.184.216.34')
|
||||
assert conn._dns_host == '93.184.216.34'
|
||||
assert conn.host == 'example.com'
|
||||
|
||||
|
||||
def test_send_sync_request_uses_pinned_ip_when_given(monkeypatch):
|
||||
calls = []
|
||||
|
||||
class _FakeResp:
|
||||
status_code = 200
|
||||
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
|
||||
def json(self):
|
||||
return {'ok': 1}
|
||||
|
||||
monkeypatch.setattr(
|
||||
sch, '_post_pinned',
|
||||
lambda url, data, headers, timeout, pinned_ip: calls.append((url, pinned_ip)) or _FakeResp())
|
||||
result = sch._send_sync_request('http://example.com/x', {'a': 1}, 1, '93.184.216.34')
|
||||
assert result['success'] is True
|
||||
assert calls == [('http://example.com/x', '93.184.216.34')]
|
||||
|
||||
|
||||
def test_send_sync_request_without_pinned_ip_uses_plain_post(monkeypatch):
|
||||
calls = []
|
||||
|
||||
class _FakeResp:
|
||||
status_code = 200
|
||||
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
|
||||
def json(self):
|
||||
return {'ok': 1}
|
||||
|
||||
monkeypatch.setattr(
|
||||
sch, '_post_pinned',
|
||||
lambda *a, **k: calls.append('pinned') or _FakeResp())
|
||||
monkeypatch.setattr(
|
||||
sch.requests, 'post',
|
||||
lambda url, **kw: calls.append('plain') or _FakeResp())
|
||||
|
||||
# 相对路径自调用等可信场景无固定 IP,应走常规 requests.post 而非 pinned 路径
|
||||
result = sch._send_sync_request('http://127.0.0.1:8031/api/ping', {'a': 1}, 1)
|
||||
assert result['success'] is True
|
||||
assert calls == ['plain']
|
||||
|
||||
|
||||
def test_job_single_instance_claim(monkeypatch):
|
||||
# 禁用 Redis 锁(退化进程内互斥),使进程内 set 行为可独立验证
|
||||
monkeypatch.setattr(sch, '_get_lock_redis', lambda: None)
|
||||
assert sch._try_claim_job(42) is True
|
||||
assert sch._try_claim_job(42) is False # 已占用,拒绝重入
|
||||
sch._release_job(42)
|
||||
assert sch._try_claim_job(42) is True
|
||||
sch._release_job(42)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Redis 分布式锁:跨 worker 进程单实例互斥
|
||||
# ============================================================
|
||||
|
||||
def test_try_claim_job_respects_cross_process_redis_lock(monkeypatch):
|
||||
import fakeredis
|
||||
|
||||
fake = fakeredis.FakeRedis()
|
||||
monkeypatch.setattr(sch, '_get_lock_redis', lambda: fake)
|
||||
|
||||
# 进程内重复触发仍被本地 set 拦截
|
||||
assert sch._try_claim_job(7) is True
|
||||
assert sch._try_claim_job(7) is False
|
||||
sch._release_job(7)
|
||||
assert sch._try_claim_job(7) is True
|
||||
sch._release_job(7)
|
||||
|
||||
# 模拟他进程已持锁:Redis 锁被占用 → 本进程拒绝,且本地占用已回滚
|
||||
fake.set(f'{sch._JOB_LOCK_PREFIX}9', '1', nx=True, ex=300)
|
||||
assert sch._try_claim_job(9) is False
|
||||
assert 9 not in sch._running_jobs
|
||||
# 释放后(他进程删除锁)可再次占用
|
||||
fake.delete(f'{sch._JOB_LOCK_PREFIX}9')
|
||||
assert sch._try_claim_job(9) is True
|
||||
sch._release_job(9)
|
||||
|
||||
|
||||
def test_try_claim_job_degrades_when_redis_unavailable(monkeypatch):
|
||||
# Redis 不可用 → 退化进程内互斥,不中断任务执行
|
||||
monkeypatch.setattr(sch, '_get_lock_redis', lambda: None)
|
||||
assert sch._try_claim_job(10) is True
|
||||
assert sch._try_claim_job(10) is False # 本地 set 仍生效
|
||||
sch._release_job(10)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# _handle_scheduler_update:运行中任务编辑后必须重加调度器刷新执行参数
|
||||
# ============================================================
|
||||
|
||||
def _patch_scheduler_calls(monkeypatch):
|
||||
"""打桩 job.service 中的调度器函数,记录 add/remove 调用。"""
|
||||
from modules.job import service as job_svc
|
||||
calls = {'add': [], 'remove': []}
|
||||
monkeypatch.setattr(
|
||||
job_svc, 'add_job_to_scheduler',
|
||||
lambda data: calls['add'].append(data) or True)
|
||||
monkeypatch.setattr(
|
||||
job_svc, 'remove_job_from_scheduler',
|
||||
lambda jid: calls['remove'].append(jid) or True)
|
||||
return job_svc, calls
|
||||
|
||||
|
||||
def test_handle_scheduler_update_running_edit_readds(monkeypatch):
|
||||
"""运行中任务编辑 url/params:cron 未变也需重加,否则调度器仍按旧参数执行。"""
|
||||
job_svc, calls = _patch_scheduler_calls(monkeypatch)
|
||||
ok = job_svc._handle_scheduler_update(
|
||||
{'id': 5, 'status': 1, 'url': '/api/new'}, old_status=1, old_cron='0 * * * *')
|
||||
assert ok is True
|
||||
assert calls['remove'] == []
|
||||
assert len(calls['add']) == 1
|
||||
# 重加时携带完整任务状态与有效 cron(部分更新缺 cron 时沿用旧值)
|
||||
added = calls['add'][0]
|
||||
assert added['id'] == 5
|
||||
assert added['url'] == '/api/new'
|
||||
assert added['cron_expression'] == '0 * * * *'
|
||||
|
||||
|
||||
def test_handle_scheduler_update_running_explicit_new_cron(monkeypatch):
|
||||
"""运行中任务显式改 cron:以新 cron 重加。"""
|
||||
job_svc, calls = _patch_scheduler_calls(monkeypatch)
|
||||
ok = job_svc._handle_scheduler_update(
|
||||
{'id': 5, 'status': 1, 'cron_expression': '*/5 * * * *'}, old_status=1, old_cron='0 * * * *')
|
||||
assert ok is True
|
||||
assert calls['add'][0]['cron_expression'] == '*/5 * * * *'
|
||||
|
||||
|
||||
def test_handle_scheduler_update_running_to_disabled_removes(monkeypatch):
|
||||
"""运行中 → 禁用:从调度器移除。"""
|
||||
job_svc, calls = _patch_scheduler_calls(monkeypatch)
|
||||
ok = job_svc._handle_scheduler_update(
|
||||
{'id': 5, 'status': 2}, old_status=1, old_cron='0 * * * *')
|
||||
assert ok is True
|
||||
assert calls['add'] == []
|
||||
assert calls['remove'] == [5]
|
||||
|
||||
|
||||
def test_handle_scheduler_update_disabled_to_running_readds(monkeypatch):
|
||||
"""暂停 → 启用且带 cron:重加。"""
|
||||
job_svc, calls = _patch_scheduler_calls(monkeypatch)
|
||||
ok = job_svc._handle_scheduler_update(
|
||||
{'id': 5, 'status': 1, 'cron_expression': '0 * * * *'}, old_status=2, old_cron='0 * * * *')
|
||||
assert ok is True
|
||||
assert len(calls['add']) == 1
|
||||
assert calls['remove'] == []
|
||||
|
||||
|
||||
def test_handle_scheduler_update_disabled_to_running_without_cron(monkeypatch):
|
||||
"""暂停 → 启用但无 cron:无法调度,移除(保持调度器一致性)。"""
|
||||
job_svc, calls = _patch_scheduler_calls(monkeypatch)
|
||||
ok = job_svc._handle_scheduler_update(
|
||||
{'id': 5, 'status': 1}, old_status=2, old_cron=None)
|
||||
assert ok is True
|
||||
assert calls['add'] == []
|
||||
assert calls['remove'] == [5]
|
||||
|
||||
|
||||
def test_handle_scheduler_update_no_status_change_noop(monkeypatch):
|
||||
"""保持暂停:不触发任何调度器操作。"""
|
||||
job_svc, calls = _patch_scheduler_calls(monkeypatch)
|
||||
ok = job_svc._handle_scheduler_update(
|
||||
{'id': 5, 'status': 2}, old_status=2, old_cron=None)
|
||||
assert ok is True
|
||||
assert calls['add'] == []
|
||||
assert calls['remove'] == []
|
||||
|
||||
|
||||
# ============================================================
|
||||
# _build_cron_trigger:5/6 字段 cron,6 字段首字段为秒(秒级触发)
|
||||
# ============================================================
|
||||
|
||||
def _fire_seconds_away(cron_expr):
|
||||
"""返回 (next_fire, 距 now 的秒数)。now 取本地 aware 时间,与触发器默认时区对齐。"""
|
||||
from datetime import datetime
|
||||
now = datetime.now().astimezone()
|
||||
nf = sch._build_cron_trigger(cron_expr).get_next_fire_time(None, now)
|
||||
return nf, (nf - now).total_seconds() if nf else None
|
||||
|
||||
|
||||
def test_build_cron_trigger_5_fields_minute_granularity():
|
||||
nf, delta = _fire_seconds_away('0 * * * *') # 每小时整点
|
||||
assert nf is not None
|
||||
assert 0 < delta <= 3600
|
||||
|
||||
|
||||
def test_build_cron_trigger_6_fields_second_granularity():
|
||||
nf, delta = _fire_seconds_away('*/15 * * * * *') # 每 15 秒
|
||||
assert nf is not None
|
||||
assert 0 < delta <= 15, f"秒级 cron 应 15s 内触发,实际 {delta}s"
|
||||
|
||||
|
||||
def test_build_cron_trigger_5_field_matches_from_crontab():
|
||||
"""5 字段行为与 CronTrigger.from_crontab 完全一致(向后兼容)。"""
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from datetime import datetime
|
||||
now = datetime.now().astimezone()
|
||||
t1 = sch._build_cron_trigger('0 * * * *').get_next_fire_time(None, now)
|
||||
t2 = CronTrigger.from_crontab('0 * * * *').get_next_fire_time(None, now)
|
||||
assert t1 == t2
|
||||
|
||||
|
||||
def test_build_cron_trigger_invalid_field_count_raises():
|
||||
import pytest
|
||||
for bad in ('', '* * * *', '* * * * * * *'): # 0/4/7 字段均非法
|
||||
with pytest.raises(ValueError):
|
||||
sch._build_cron_trigger(bad)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""modules/job/service.py 暂停/恢复任务:update_user 应取当前操作人,而非创建人。"""
|
||||
import json
|
||||
import types
|
||||
|
||||
from modules.job import service as job_service
|
||||
from modules.job.models import Job
|
||||
|
||||
|
||||
def _make_request(user_id=5, realname='测试用户'):
|
||||
req = types.SimpleNamespace()
|
||||
req.state = types.SimpleNamespace(user_id=user_id, realname=realname)
|
||||
return req
|
||||
|
||||
|
||||
def _make_job(sqlite_db, status=1, create_user=1):
|
||||
job = Job(job_name='任务A', status=status, cron_expression='0 * * * *',
|
||||
execute_policy=1, is_sync=0, create_user=create_user)
|
||||
job.save()
|
||||
sqlite_db.commit()
|
||||
return job
|
||||
|
||||
|
||||
def _body(resp):
|
||||
return json.loads(resp.body)
|
||||
|
||||
|
||||
def test_pause_job_sets_update_user_to_operator(monkeypatch, sqlite_db):
|
||||
monkeypatch.setattr(job_service, 'pause_job_in_scheduler', lambda jid: True)
|
||||
job = _make_job(sqlite_db, status=1, create_user=1)
|
||||
|
||||
resp = job_service.pause_job_by_id(_make_request(user_id=5, realname='张三'), job.id)
|
||||
assert _body(resp)['code'] == 0
|
||||
assert job.status == 2
|
||||
# 更新人 = 操作人姓名(张三),而非创建人(1)
|
||||
assert job.update_user == '张三'
|
||||
|
||||
|
||||
def test_resume_job_sets_update_user_to_operator(monkeypatch, sqlite_db):
|
||||
monkeypatch.setattr(job_service, 'add_job_to_scheduler', lambda data: True)
|
||||
job = _make_job(sqlite_db, status=2, create_user=1)
|
||||
|
||||
resp = job_service.resume_job_by_id(_make_request(user_id=5, realname='张三'), job.id)
|
||||
assert _body(resp)['code'] == 0
|
||||
assert job.status == 1
|
||||
assert job.update_user == '张三'
|
||||
@@ -0,0 +1,163 @@
|
||||
"""system/menu service 单元测试(async 路径,回归删除父级时禁止有子级)。
|
||||
|
||||
测试环境无 Redis:monkeypatch 掉 invalidate_menu_change,避免 get_redis() 抛异常。
|
||||
"""
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from modules.system.menu import service as menu_service
|
||||
from modules.system.menu.models import Menu
|
||||
from modules.system.menu.repository import menu_repo
|
||||
from modules.system.menu.schemas import MenuForm
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stub_perm_cache(monkeypatch):
|
||||
async def _noop():
|
||||
return None
|
||||
monkeypatch.setattr(menu_service, "invalidate_menu_change", _noop)
|
||||
|
||||
|
||||
def _resp_body(resp):
|
||||
return json.loads(resp.body)
|
||||
|
||||
|
||||
def _form(**overrides):
|
||||
data = dict(
|
||||
name="系统管理", icon=None, path="/system", component=None, parentId=0,
|
||||
type=0, target=0, permission=None, hide=0, status=1, sort=1, note=None,
|
||||
)
|
||||
data.update(overrides)
|
||||
return MenuForm(**data)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_parent_with_child_blocked(fake_request, sqlite_db):
|
||||
await menu_service.add_menu(fake_request, _form())
|
||||
parent = menu_repo.filter(Menu.name == "系统管理").first()
|
||||
|
||||
await menu_service.add_menu(
|
||||
fake_request,
|
||||
_form(name="用户管理", path="/system/user", parentId=parent.id),
|
||||
)
|
||||
child = menu_repo.filter(Menu.name == "用户管理").first()
|
||||
|
||||
resp = await menu_service.delete_menu(str(parent.id))
|
||||
body = _resp_body(resp)
|
||||
assert body["code"] != 0
|
||||
assert "下级菜单" in body["msg"]
|
||||
assert menu_repo.get_by_id(parent.id).is_delete == 0
|
||||
assert menu_repo.get_by_id(child.id).is_delete == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_leaf_soft_deletes(fake_request, sqlite_db):
|
||||
await menu_service.add_menu(fake_request, _form(name="单菜单", path="/single"))
|
||||
leaf = menu_repo.filter(Menu.name == "单菜单").first()
|
||||
|
||||
resp = await menu_service.delete_menu(str(leaf.id))
|
||||
assert _resp_body(resp)["code"] == 0
|
||||
assert menu_repo.filter(Menu.id == leaf.id).first().is_delete == 1
|
||||
|
||||
|
||||
# ============ 菜单树成环检测:update 的 parent_id 存在性 + 自身/下级 校验 ============
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_menu_rejects_missing_parent(fake_request, sqlite_db):
|
||||
await menu_service.add_menu(fake_request, _form(name="系统管理", path="/system"))
|
||||
menu = menu_repo.filter(Menu.name == "系统管理").first()
|
||||
|
||||
resp = await menu_service.update_menu(
|
||||
fake_request, _form(name="系统管理", path="/system", id=menu.id, parentId=99999))
|
||||
body = _resp_body(resp)
|
||||
assert body["code"] != 0
|
||||
assert "上级菜单不存在" in body["msg"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_menu_rejects_self_parent(fake_request, sqlite_db):
|
||||
await menu_service.add_menu(fake_request, _form(name="系统管理", path="/system"))
|
||||
menu = menu_repo.filter(Menu.name == "系统管理").first()
|
||||
|
||||
resp = await menu_service.update_menu(
|
||||
fake_request, _form(name="系统管理", path="/system", id=menu.id, parentId=menu.id))
|
||||
body = _resp_body(resp)
|
||||
assert body["code"] != 0
|
||||
assert "自身" in body["msg"]
|
||||
# 原记录未被改动
|
||||
assert menu_repo.get_by_id(menu.id).parent_id == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_menu_rejects_descendant_parent(fake_request, sqlite_db):
|
||||
await menu_service.add_menu(fake_request, _form(name="系统管理", path="/system"))
|
||||
parent = menu_repo.filter(Menu.name == "系统管理").first()
|
||||
await menu_service.add_menu(
|
||||
fake_request, _form(name="用户管理", path="/system/user", parentId=parent.id))
|
||||
child = menu_repo.filter(Menu.name == "用户管理").first()
|
||||
|
||||
# 把父级设为自己的子级 → 成环,拒绝
|
||||
resp = await menu_service.update_menu(
|
||||
fake_request, _form(name="系统管理", path="/system", id=parent.id, parentId=child.id))
|
||||
body = _resp_body(resp)
|
||||
assert body["code"] != 0
|
||||
assert "下级" in body["msg"]
|
||||
assert menu_repo.get_by_id(parent.id).parent_id == 0
|
||||
|
||||
|
||||
# ============ 多角色重复授权:权限列表 / 菜单树 distinct 去重 ============
|
||||
|
||||
def _seed_user_with_two_roles_same_menu(sqlite_db):
|
||||
"""造一个同时属于两个角色、且两角色均授予同一权限节点/菜单的用户。
|
||||
|
||||
返回的用户 id 必不为 1(先占位超管),否则会走超级管理员全量放行分支。
|
||||
"""
|
||||
from modules.system.role.models import Role
|
||||
from modules.system.role_menu.models import RoleMenu
|
||||
from modules.system.user.models import User
|
||||
from modules.system.user_role.models import UserRole
|
||||
|
||||
User(realname='超管', nickname='admin', avatar='', mobile='', email='',
|
||||
province_code='', city_code='', district_code='', street_code='', address='',
|
||||
username='admin', password='', status=1).save()
|
||||
|
||||
user = User(realname='张三', nickname='zhangsan', avatar='', mobile='', email='',
|
||||
province_code='', city_code='', district_code='', street_code='', address='',
|
||||
username='zhangsan', password='', status=1)
|
||||
user.save()
|
||||
|
||||
r1 = Role(name='角色一', code='R1', status=1, sort=1); r1.save()
|
||||
r2 = Role(name='角色二', code='R2', status=1, sort=1); r2.save()
|
||||
|
||||
perm = Menu(name='添加用户', parent_id=0, path='/system/user/add', type=1,
|
||||
permission='sys:user:add', status=0, sort=1)
|
||||
perm.save()
|
||||
menu = Menu(name='用户管理', parent_id=0, path='/system/user', component='system/user/index',
|
||||
type=0, status=0, hide=0, sort=1)
|
||||
menu.save()
|
||||
|
||||
# 两个角色授予同一权限节点与同一菜单 → 多角色 join 会产生重复行
|
||||
for role in (r1, r2):
|
||||
RoleMenu(role_id=role.id, menu_id=perm.id).save()
|
||||
RoleMenu(role_id=role.id, menu_id=menu.id).save()
|
||||
UserRole(user_id=user.id, role_id=role.id).save()
|
||||
|
||||
sqlite_db.commit()
|
||||
return user
|
||||
|
||||
|
||||
def test_permissions_list_dedup_for_multi_role(sqlite_db):
|
||||
user = _seed_user_with_two_roles_same_menu(sqlite_db)
|
||||
perms = menu_service.get_permissions_list_sync(user.id)
|
||||
# 同一权限节点经两个角色授权只出现一次
|
||||
assert perms.count('sys:user:add') == 1
|
||||
assert len(perms) == len(set(perms))
|
||||
|
||||
|
||||
def test_menu_list_dedup_for_multi_role(sqlite_db):
|
||||
user = _seed_user_with_two_roles_same_menu(sqlite_db)
|
||||
tree = menu_service.get_permission_menu_list_sync(user.id)
|
||||
# 菜单树无重复节点
|
||||
assert len(tree) == 1
|
||||
assert tree[0]['name'] == '用户管理'
|
||||
@@ -0,0 +1,44 @@
|
||||
"""notice service 单元测试(回归富文本 content 空值保护)。"""
|
||||
import json
|
||||
|
||||
from modules.notice.service import notice_service
|
||||
from modules.notice.models import Notice
|
||||
from modules.notice.repository import notice_repo
|
||||
from modules.notice.schemas import NoticeForm
|
||||
|
||||
|
||||
def _resp_body(resp):
|
||||
return json.loads(resp.body)
|
||||
|
||||
|
||||
def _form(**overrides):
|
||||
data = dict(title="测试通知", source=1, url="https://example.com", status=1, isTop=1, click=0)
|
||||
data.update(overrides)
|
||||
return NoticeForm(**data)
|
||||
|
||||
|
||||
def test_add_notice_content_none_saves_null(fake_request, sqlite_db):
|
||||
resp = notice_service.add(fake_request, _form(content=None))
|
||||
assert _resp_body(resp)["code"] == 0
|
||||
row = notice_repo.filter(Notice.title == "测试通知").first()
|
||||
assert row is not None
|
||||
assert row.content is None
|
||||
|
||||
|
||||
def test_add_notice_plain_text_stored_as_is(fake_request, sqlite_db):
|
||||
resp = notice_service.add(fake_request, _form(title="纯文本通知", content="hello world"))
|
||||
assert _resp_body(resp)["code"] == 0
|
||||
row = notice_repo.filter(Notice.title == "纯文本通知").first()
|
||||
assert row.content == "hello world"
|
||||
|
||||
|
||||
def test_update_notice_content_none_keeps_old(fake_request, sqlite_db):
|
||||
notice_service.add(fake_request, _form(title="保留旧内容", content="old-content"))
|
||||
row = notice_repo.filter(Notice.title == "保留旧内容").first()
|
||||
|
||||
resp = notice_service.update(
|
||||
fake_request,
|
||||
_form(title="保留旧内容", content=None, id=row.id),
|
||||
)
|
||||
assert _resp_body(resp)["code"] == 0
|
||||
assert notice_repo.get_by_id(row.id).content == "old-content"
|
||||
@@ -1,5 +1,6 @@
|
||||
"""shared/base_repository 与模块 repository 的单元测试(SQLite 内存库)。"""
|
||||
|
||||
from modules.system.level.models import Level
|
||||
from modules.system.level.repository import level_repo
|
||||
from modules.system.position.repository import position_repo
|
||||
|
||||
@@ -25,8 +26,8 @@ def test_level_repo_crud(sqlite_db):
|
||||
assert level_repo.update(obj.id, {"name": "资深"}) == 1
|
||||
assert level_repo.get_by_id(obj.id).name == "资深"
|
||||
|
||||
# soft delete
|
||||
assert level_repo.soft_delete([obj.id])
|
||||
# batch soft delete(数据原语,返回删除条数)
|
||||
assert level_repo.batch_delete(str(obj.id)) == 1
|
||||
assert level_repo.get_by_id(obj.id) is None
|
||||
|
||||
|
||||
@@ -34,3 +35,26 @@ def test_position_repo_paginate_empty(sqlite_db):
|
||||
items, total = position_repo.paginate(1, 10)
|
||||
assert total == 0
|
||||
assert items == []
|
||||
|
||||
|
||||
def test_repo_query_conveniences(sqlite_db):
|
||||
level_repo.create(name="甲", status=1, sort=1)
|
||||
level_repo.create(name="乙", status=1, sort=2)
|
||||
level_repo.create(name="丙", status=2, sort=3)
|
||||
|
||||
# exists_by_field / 排除自身
|
||||
assert level_repo.exists_by_field("name", "甲")
|
||||
assert not level_repo.exists_by_field("name", "不存在")
|
||||
target = level_repo.get_one(name="甲")
|
||||
assert not level_repo.exists_by_field("name", "甲", exclude_id=target.id)
|
||||
|
||||
# get_one / get_all / count / filter_by(None 值跳过 + 自动软删过滤)
|
||||
assert level_repo.get_one(name="乙").sort == 2
|
||||
assert len(level_repo.get_all(status=1)) == 2
|
||||
assert level_repo.count(status=1) == 2
|
||||
rows = level_repo.filter_by(status=1).order_by(Level.sort.asc()).all()
|
||||
assert [row.name for row in rows] == ["甲", "乙"]
|
||||
|
||||
# 软删后 count 自动排除
|
||||
level_repo.batch_delete(str(target.id))
|
||||
assert level_repo.count() == 2
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""schema 校验回归:mobile/email 格式 + click/sort 上限(防 INT 溢出 500)。"""
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from modules.system.user.schemas import UserForm, UserInfoForm
|
||||
from modules.article.schemas import ArticleForm
|
||||
from modules.notice.schemas import NoticeForm
|
||||
from modules.param.schemas import ParamForm
|
||||
from modules.file_template.schemas import FileTemplateForm
|
||||
|
||||
|
||||
def _base_user():
|
||||
return dict(
|
||||
realname='张三', nickname='张三', gender=1, avatar='a.png',
|
||||
mobile='13800000000', email='a@b.com', birthday=date(2000, 1, 1),
|
||||
deptId=1, levelId=1, positionId=1,
|
||||
city=['110000', '110100', '110101'], address='北京市',
|
||||
username='zhangsan', password='123456', status=1, sort=1, roles=[1, 2],
|
||||
)
|
||||
|
||||
|
||||
def test_user_mobile_accepts_valid_and_rejects_bad():
|
||||
UserForm(**{**_base_user(), 'mobile': '13800000000'})
|
||||
for bad in ('abc', '12345', '23800000000', '1380000000', '138000000001'):
|
||||
with pytest.raises(ValidationError):
|
||||
UserForm(**{**_base_user(), 'mobile': bad})
|
||||
|
||||
|
||||
def test_user_email_accepts_valid_and_rejects_bad():
|
||||
UserForm(**{**_base_user(), 'email': 'a@b.com'})
|
||||
for bad in ('not-an-email', 'a@b', '@b.com', 'a b@c.com'):
|
||||
with pytest.raises(ValidationError):
|
||||
UserForm(**{**_base_user(), 'email': bad})
|
||||
|
||||
|
||||
def test_user_info_form_mobile_email_format():
|
||||
# 修改个人信息表单同样校验格式
|
||||
base = dict(realname='张三', nickname='zs', gender=1, address='x')
|
||||
UserInfoForm(**{**base, 'mobile': '13800000000', 'email': 'a@b.com'})
|
||||
with pytest.raises(ValidationError):
|
||||
UserInfoForm(**{**base, 'mobile': 'abc', 'email': 'a@b.com'})
|
||||
with pytest.raises(ValidationError):
|
||||
UserInfoForm(**{**base, 'mobile': '13800000000', 'email': 'bad'})
|
||||
|
||||
|
||||
def test_article_click_overflow_rejected():
|
||||
ArticleForm(title='t', click=2147483647, sort=1)
|
||||
with pytest.raises(ValidationError):
|
||||
ArticleForm(title='t', click=2147483648, sort=1)
|
||||
|
||||
|
||||
def test_notice_click_overflow_rejected():
|
||||
NoticeForm(title='t', source=1, url='u', status=1, isTop=1, click=2147483647)
|
||||
with pytest.raises(ValidationError):
|
||||
NoticeForm(title='t', source=1, url='u', status=1, isTop=1, click=2147483648)
|
||||
|
||||
|
||||
def test_param_sort_overflow_rejected():
|
||||
ParamForm(name='n', code='c', value='v', type=0, status=1, sort=99999)
|
||||
with pytest.raises(ValidationError):
|
||||
ParamForm(name='n', code='c', value='v', type=0, status=1, sort=100000)
|
||||
|
||||
|
||||
def test_file_template_sort_overflow_rejected():
|
||||
FileTemplateForm(sort=99999)
|
||||
with pytest.raises(ValidationError):
|
||||
FileTemplateForm(sort=100000)
|
||||
@@ -0,0 +1,115 @@
|
||||
"""超管账号与内置角色写保护:user id==1 与内置角色(种子数据)禁止被管理写操作影响。"""
|
||||
import asyncio
|
||||
import json
|
||||
import types
|
||||
|
||||
from core import R
|
||||
from core.base_service import BaseService
|
||||
|
||||
import modules.system.user.service as user_service
|
||||
import modules.system.role.service as role_service
|
||||
from modules.system.role.repository import role_repo
|
||||
|
||||
|
||||
def _body(result):
|
||||
return json.loads(result.body)
|
||||
|
||||
|
||||
# ============ 超管账号(user id==1)写保护 ============
|
||||
|
||||
def test_delete_user_blocks_superadmin():
|
||||
result = asyncio.run(user_service.delete_user('1'))
|
||||
assert _body(result)['ok'] is False
|
||||
assert '超管' in _body(result)['msg']
|
||||
|
||||
|
||||
def test_delete_user_allows_normal_user(monkeypatch):
|
||||
# 屏蔽会话与批量删除,仅验证非超管不会被误拦
|
||||
class _FakeDelete:
|
||||
def delete(self, *a, **k):
|
||||
return 1
|
||||
|
||||
class _FakeQuery:
|
||||
def filter(self, *a, **k):
|
||||
return _FakeDelete()
|
||||
|
||||
monkeypatch.setattr(user_service, 'get_db_session',
|
||||
lambda: types.SimpleNamespace(query=lambda m: _FakeQuery()))
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_batch_delete(repo, ids):
|
||||
captured['ids'] = ids
|
||||
return R.ok(msg="本次共删除{0}条数据".format(1))
|
||||
|
||||
monkeypatch.setattr(BaseService, 'batch_delete_with_r', staticmethod(fake_batch_delete))
|
||||
|
||||
async def fake_invalidate(uid):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(user_service, 'invalidate_user_perms', fake_invalidate)
|
||||
|
||||
result = asyncio.run(user_service.delete_user('2'))
|
||||
assert _body(result)['ok'] is True
|
||||
assert captured['ids'] == '2'
|
||||
|
||||
|
||||
def test_update_user_blocks_superadmin(fake_request):
|
||||
result = asyncio.run(user_service.update_user(fake_request, types.SimpleNamespace(id=1)))
|
||||
assert _body(result)['ok'] is False
|
||||
assert '超管' in _body(result)['msg']
|
||||
|
||||
|
||||
def test_update_user_status_blocks_superadmin(fake_request):
|
||||
result = asyncio.run(
|
||||
user_service.update_user_status(fake_request, types.SimpleNamespace(id=1, status=2)))
|
||||
assert _body(result)['ok'] is False
|
||||
assert '超管' in _body(result)['msg']
|
||||
|
||||
|
||||
def test_reset_user_pwd_blocks_superadmin(fake_request):
|
||||
result = user_service.reset_user_pwd(fake_request, types.SimpleNamespace(id=1))
|
||||
assert _body(result)['ok'] is False
|
||||
assert '超管' in _body(result)['msg']
|
||||
|
||||
|
||||
def test_reset_user_pwd_allows_normal_user(monkeypatch, fake_request):
|
||||
monkeypatch.setattr(user_service.user_repo, 'get_by_id', lambda uid: object())
|
||||
monkeypatch.setattr(user_service.user_repo, 'update', lambda uid, fields: True)
|
||||
result = user_service.reset_user_pwd(fake_request, types.SimpleNamespace(id=2))
|
||||
assert _body(result)['ok'] is True
|
||||
|
||||
|
||||
# ============ 内置角色写保护 ============
|
||||
|
||||
def test_delete_role_blocks_builtin():
|
||||
result = asyncio.run(role_service.delete_role('1'))
|
||||
assert _body(result)['ok'] is False
|
||||
assert '内置' in _body(result)['msg']
|
||||
|
||||
|
||||
def test_delete_role_blocks_mixed_builtin():
|
||||
# 批量删除中混入内置角色 → 整体拒绝
|
||||
result = asyncio.run(role_service.delete_role('2,5'))
|
||||
assert _body(result)['ok'] is False
|
||||
assert '内置' in _body(result)['msg']
|
||||
|
||||
|
||||
def test_delete_role_allows_normal_role(monkeypatch, sqlite_db):
|
||||
class _FakeRecord:
|
||||
is_delete = 0
|
||||
|
||||
class _FakeQuery:
|
||||
def all(self):
|
||||
return [_FakeRecord()]
|
||||
|
||||
monkeypatch.setattr(role_repo, 'filter', lambda *a, **k: _FakeQuery())
|
||||
|
||||
async def fake_invalidate(rid):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(role_service, 'invalidate_role_perms', fake_invalidate)
|
||||
|
||||
# sqlite_db 提供请求级 DB 会话(delete_role 会清理 user_role/role_menu 关联)
|
||||
result = asyncio.run(role_service.delete_role('5'))
|
||||
assert _body(result)['ok'] is True
|
||||
@@ -0,0 +1,84 @@
|
||||
"""部分更新防 NULL 覆盖:编辑时未传的可选字段不得把数据库旧值清空为 NULL。"""
|
||||
import json
|
||||
|
||||
from modules.file_template.schemas import FileTemplateForm
|
||||
from modules.file_template.service import file_template_service
|
||||
from modules.city.schemas import CityForm
|
||||
from modules.city.service import update_city
|
||||
from modules.city.repository import city_repo
|
||||
from modules.system.dept.schemas import DeptForm
|
||||
from modules.system.dept.service import update_dept
|
||||
from modules.system.dept.repository import dept_repo
|
||||
from modules.article.schemas import ArticleForm
|
||||
from modules.article.service import update_article
|
||||
from modules.article.repository import article_repo
|
||||
|
||||
|
||||
def _body(result):
|
||||
return json.loads(result.body)
|
||||
|
||||
|
||||
# ============ base 层 _build_update_fields ============
|
||||
|
||||
def test_build_update_fields_excludes_none(fake_request):
|
||||
data = FileTemplateForm(id=1, name='模板', sort=5) # code/file_name/file_path/note 未传 → None
|
||||
fields = file_template_service._build_update_fields(fake_request, data)
|
||||
assert 'id' not in fields
|
||||
assert 'name' in fields
|
||||
assert 'sort' in fields
|
||||
assert 'code' not in fields
|
||||
assert 'filePath' not in fields
|
||||
assert 'update_user' in fields
|
||||
assert 'update_time' in fields
|
||||
|
||||
|
||||
# ============ city:编辑顶级时 parent_id 不被清空 ============
|
||||
|
||||
def test_update_city_preserves_parent_id_when_omitted(monkeypatch, fake_request):
|
||||
monkeypatch.setattr(city_repo, 'get_by_id', lambda uid: object())
|
||||
captured = {}
|
||||
monkeypatch.setattr(city_repo, 'update', lambda uid, fields: captured.update(fields) or True)
|
||||
|
||||
data = CityForm(
|
||||
id=1, name='省', shortName='s', pinyin='p', cityCode='010',
|
||||
areaCode='11', zipCode='100', level=1, lng='0', lat='0',
|
||||
) # parentId 未传 → None
|
||||
result = update_city(fake_request, data)
|
||||
assert _body(result)['ok'] is True
|
||||
assert 'parentId' not in captured
|
||||
|
||||
|
||||
# ============ dept:编辑顶级时 parent_id 不被清空 ============
|
||||
|
||||
def test_update_dept_preserves_parent_id_when_omitted(monkeypatch, fake_request):
|
||||
monkeypatch.setattr(dept_repo, 'exists_by_field', lambda *a, **k: False)
|
||||
monkeypatch.setattr(dept_repo, 'get_by_id', lambda uid: object())
|
||||
captured = {}
|
||||
monkeypatch.setattr(dept_repo, 'update', lambda uid, fields: captured.update(fields) or True)
|
||||
|
||||
data = DeptForm(id=1, name='部门', code='D1', type=1, sort=1) # parent_id/note 未传 → None
|
||||
result = update_dept(fake_request, data)
|
||||
assert _body(result)['ok'] is True
|
||||
assert 'parentId' not in captured
|
||||
assert 'note' not in captured
|
||||
|
||||
|
||||
# ============ article:未传的 category_id/intro/author 不被清空 ============
|
||||
|
||||
def test_update_article_preserves_optional_fields_when_omitted(monkeypatch, fake_request):
|
||||
class _OldArticle:
|
||||
cover = None
|
||||
images = None
|
||||
content = None
|
||||
|
||||
monkeypatch.setattr(article_repo, 'get_by_id', lambda uid: _OldArticle())
|
||||
captured = {}
|
||||
monkeypatch.setattr(article_repo, 'update', lambda uid, fields: captured.update(fields) or True)
|
||||
|
||||
data = ArticleForm(id=1, title='标题', status=0) # category_id/intro/author 未传 → None
|
||||
result = update_article(fake_request, data)
|
||||
assert _body(result)['ok'] is True
|
||||
assert 'title' in captured
|
||||
assert 'categoryId' not in captured
|
||||
assert 'intro' not in captured
|
||||
assert 'author' not in captured
|
||||
@@ -0,0 +1,87 @@
|
||||
"""上传安全:SVG/WebP 整体禁止上传(存储型 XSS 面 / 无可靠魔数)。"""
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from modules.upload import service as upload_service
|
||||
from modules.upload.service import _save_upload, upload_file
|
||||
|
||||
|
||||
class _FakeUpload:
|
||||
"""模拟异步分块读取的 UploadFile。"""
|
||||
|
||||
def __init__(self, filename, data=b''):
|
||||
self.filename = filename
|
||||
self._data = data
|
||||
|
||||
async def read(self, size=65536):
|
||||
data = self._data
|
||||
self._data = b''
|
||||
return data
|
||||
|
||||
|
||||
def test_upload_file_rejects_svg(monkeypatch):
|
||||
# SVG 被 BANNED_UPLOAD_EXTS 硬拒绝(即使配置放行)
|
||||
monkeypatch.setattr(upload_service, 'UPLOAD_ALLOWED_EXTS', '.svg')
|
||||
result = asyncio.run(upload_file(_FakeUpload('logo.svg', b'<svg/>')))
|
||||
body = json.loads(result.body)
|
||||
assert body['ok'] is False
|
||||
|
||||
|
||||
def test_save_upload_rejects_svg_even_if_config_allows():
|
||||
# 兜底硬拒绝:即使配置或其他入口放行 .svg,落盘前一律拒绝
|
||||
content = b'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'
|
||||
result = _save_upload(content, 'a.svg', '.svg')
|
||||
body = json.loads(result.body)
|
||||
assert body['ok'] is False
|
||||
assert 'SVG' in body['msg']
|
||||
|
||||
|
||||
def test_upload_file_rejects_webp(monkeypatch):
|
||||
# WebP 被 BANNED_UPLOAD_EXTS 硬拒绝(即使配置放行)
|
||||
monkeypatch.setattr(upload_service, 'UPLOAD_ALLOWED_EXTS', '.webp')
|
||||
result = asyncio.run(upload_file(_FakeUpload('a.webp', b'RIFF' + b'\x00' * 32)))
|
||||
body = json.loads(result.body)
|
||||
assert body['ok'] is False
|
||||
|
||||
|
||||
def test_save_upload_rejects_webp_even_if_config_allows():
|
||||
# 兜底硬拒绝:webp 魔数仅 RIFF 前缀(WAV/AVI 可冒充),落盘前一律拒绝
|
||||
result = _save_upload(b'RIFF' + b'\x00' * 32, 'a.webp', '.webp')
|
||||
body = json.loads(result.body)
|
||||
assert body['ok'] is False
|
||||
assert 'WebP' in body['msg']
|
||||
|
||||
|
||||
def test_upload_file_uses_builtin_set_when_unconfigured(monkeypatch):
|
||||
# 方案A:未配置时回落内置智能集合,白名单校验放行内置格式
|
||||
from core import R
|
||||
called = {}
|
||||
|
||||
def fake_save(content, original_name, ext):
|
||||
called['ext'] = ext
|
||||
return R.ok(msg="ok", data={})
|
||||
|
||||
monkeypatch.setattr(upload_service, 'UPLOAD_ALLOWED_EXTS', '')
|
||||
monkeypatch.setattr(upload_service, '_save_upload', fake_save)
|
||||
result = asyncio.run(upload_file(_FakeUpload('a.jpg', b'\xff\xd8\xff')))
|
||||
body = json.loads(result.body)
|
||||
assert body['ok'] is True
|
||||
assert called['ext'] == '.jpg'
|
||||
|
||||
|
||||
def test_upload_file_fail_closed_when_config_outside_builtin(monkeypatch):
|
||||
# 配置的扩展名全不在内置集合内 → 有效白名单为空 → 拒绝一切上传
|
||||
monkeypatch.setattr(upload_service, 'UPLOAD_ALLOWED_EXTS', '.svg,.webp,.exe')
|
||||
result = asyncio.run(upload_file(_FakeUpload('a.jpg', b'\xff\xd8\xff')))
|
||||
body = json.loads(result.body)
|
||||
assert body['ok'] is False
|
||||
assert '允许上传范围' in body['msg']
|
||||
|
||||
|
||||
def test_upload_file_rejects_ext_outside_effective_set(monkeypatch):
|
||||
# 配置收缩白名单后,内置集合中但不在配置内的扩展名被拒绝
|
||||
monkeypatch.setattr(upload_service, 'UPLOAD_ALLOWED_EXTS', '.jpg,.png')
|
||||
result = asyncio.run(upload_file(_FakeUpload('a.txt', b'hello')))
|
||||
body = json.loads(result.body)
|
||||
assert body['ok'] is False
|
||||
assert '文件格式' in body['msg']
|
||||
@@ -0,0 +1,27 @@
|
||||
"""scripts/init_db.py 的 Oracle 自增 SQL 生成器单测(与 migrate_db 对齐)。
|
||||
|
||||
Oracle create_all 不生成 Integer 主键自增,init_db 会补 sequence + BEFORE INSERT 触发器;
|
||||
这里验证生成的 DDL 字符串正确(与 scripts/migrate_db.py 的既有实现一致)。
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# scripts/ 下无 __init__.py,把 scripts 目录加进 sys.path 以便直接 import
|
||||
_SCRIPTS_DIR = Path(__file__).resolve().parents[3] / "scripts"
|
||||
if str(_SCRIPTS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_SCRIPTS_DIR))
|
||||
|
||||
import init_db # noqa: E402
|
||||
|
||||
|
||||
def test_oracle_sequence_sql():
|
||||
seq = init_db.oracle_sequence_sql("fastapi_user", 27)
|
||||
assert seq == "CREATE SEQUENCE fastapi_user_id_seq START WITH 27 INCREMENT BY 1 NOCACHE"
|
||||
|
||||
|
||||
def test_oracle_trigger_sql():
|
||||
trg = init_db.oracle_trigger_sql("fastapi_user")
|
||||
assert "BEFORE INSERT ON fastapi_user FOR EACH ROW" in trg
|
||||
assert "WHEN (NEW.id IS NULL)" in trg
|
||||
assert "SELECT fastapi_user_id_seq.NEXTVAL INTO :NEW.id FROM dual" in trg
|
||||
@@ -0,0 +1,263 @@
|
||||
"""
|
||||
跨库数据泵(scripts/migrate_db.py)单元测试
|
||||
==========================================
|
||||
|
||||
用 sqlite 文件同时充当「源库」和「目标库」,验证泵逻辑全链路:
|
||||
读→分块→executemany→显式 id 保真→自增延续→Text/日期/BigInteger 保真→
|
||||
软删/空时间原样→only-tables→dry-run→逐表 count 校验→建库→序列重置 SQL。
|
||||
|
||||
不依赖真实 MySQL/PostgreSQL(不挂 integration marker,pytest 默认即跑)。
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine, select, text
|
||||
|
||||
# scripts/ 下无 __init__.py,把 scripts 目录加进 sys.path 以便直接 import
|
||||
_SCRIPTS_DIR = Path(__file__).resolve().parents[3] / "scripts"
|
||||
if str(_SCRIPTS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_SCRIPTS_DIR))
|
||||
|
||||
import migrate_db
|
||||
|
||||
|
||||
def make_args(src_db: str, dst_db: str, **overrides) -> SimpleNamespace:
|
||||
"""构造 sqlite→sqlite 迁移的 CLI 参数(等价 migrate_db.build_parser().parse_args)。"""
|
||||
base = dict(
|
||||
src_driver="sqlite", src_host="", src_port=None, src_db=src_db, src_user="", src_pass="",
|
||||
dst_driver="sqlite", dst_host="", dst_port=None, dst_db=dst_db, dst_user="", dst_pass="",
|
||||
drop_target_first=False, dry_run=False, chunk_size=1000, only_tables="",
|
||||
skip_existing=False, skip_verify=False, single_transaction=False,
|
||||
)
|
||||
base.update(overrides)
|
||||
return SimpleNamespace(**base)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def loaded_metadata():
|
||||
"""一次性加载全部模型填充 Base.metadata(与 migrate_db.run 同源)。"""
|
||||
from core.app import create_app
|
||||
from core.database import Base
|
||||
create_app()
|
||||
return Base
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def two_sqlite(tmp_path, loaded_metadata):
|
||||
"""一对 sqlite 文件库:源库 + 目标库,均已按模型建好空结构。"""
|
||||
src_path = tmp_path / "src.db"
|
||||
dst_path = tmp_path / "dst.db"
|
||||
src_engine = create_engine(f"sqlite:///{src_path}")
|
||||
dst_engine = create_engine(f"sqlite:///{dst_path}")
|
||||
loaded_metadata.metadata.create_all(src_engine)
|
||||
loaded_metadata.metadata.create_all(dst_engine)
|
||||
yield src_engine, dst_engine, src_path, dst_path
|
||||
src_engine.dispose()
|
||||
dst_engine.dispose()
|
||||
|
||||
|
||||
def _table(loaded_metadata, name: str):
|
||||
return loaded_metadata.metadata.tables["fastapi_" + name]
|
||||
|
||||
|
||||
def test_make_url():
|
||||
"""URL 形态与 core.config.database.build_database_url 逐字等价(特殊字符口令编码)。"""
|
||||
assert migrate_db.make_url("mysql", "127.0.0.1", 3306, "db", "root", "p@ss") \
|
||||
== "mysql+pymysql://root:p%40ss@127.0.0.1:3306/db?charset=utf8mb4"
|
||||
assert migrate_db.make_url("postgresql", "127.0.0.1", 5432, "db", "root", "p@ss") \
|
||||
== "postgresql+psycopg://root:p%40ss@127.0.0.1:5432/db"
|
||||
assert migrate_db.make_url("mssql", "127.0.0.1", 1433, "db", "sa", "p@ss") \
|
||||
== "mssql+pymssql://sa:p%40ss@127.0.0.1:1433/db?charset=utf8"
|
||||
assert migrate_db.make_url("oracle", "127.0.0.1", 1521, "orcl", "hr", "p@ss") \
|
||||
== "oracle+oracledb://hr:p%40ss@127.0.0.1:1521/?service_name=orcl"
|
||||
assert migrate_db.make_url("sqlite", "x", None, "dev.db", "", "") == "sqlite:///./dev.db"
|
||||
|
||||
|
||||
def test_pump_preserves_data_ids_and_autoincrement(two_sqlite, loaded_metadata):
|
||||
"""泵后 id/父子引用/Text/BigInteger/软删/空时间全保真,且新插入自增延续。"""
|
||||
src_engine, dst_engine, src_path, dst_path = two_sqlite
|
||||
|
||||
with src_engine.begin() as conn:
|
||||
conn.execute(_table(loaded_metadata, "dept").insert(), [
|
||||
{"id": 1, "name": "总部", "code": "HQ", "parent_id": 0, "sort": 1},
|
||||
{"id": 2, "name": "研发部", "code": "RD", "parent_id": 1, "sort": 2},
|
||||
])
|
||||
conn.execute(_table(loaded_metadata, "user").insert(), [
|
||||
{"id": 1, "username": "admin", "password": "$2b$12$abcdefghijklmnop", "salt": "salt1",
|
||||
"realname": "管理员", "nickname": "管理员", "avatar": "", "mobile": "13800000000",
|
||||
"email": "admin@test.com", "province_code": "110000", "city_code": "110100",
|
||||
"district_code": "110101", "street_code": "", "address": "北京市", "is_delete": 0},
|
||||
{"id": 2, "username": "softdel", "password": None, "salt": None,
|
||||
"realname": "软删", "nickname": "软删", "avatar": "",
|
||||
"mobile": "13900000000", "email": "s@test.com", "province_code": "", "city_code": "",
|
||||
"district_code": "", "street_code": "", "address": "", "is_delete": 1},
|
||||
])
|
||||
# base_model 的 create_time 带 Python 默认值,Core insert 显式 None 也会被默认覆盖;
|
||||
# 用 UPDATE 强制置 NULL,验证泵能原样搬空时间列
|
||||
conn.execute(text("UPDATE fastapi_user SET create_time = NULL WHERE id = 2"))
|
||||
conn.execute(_table(loaded_metadata, "city").insert(), [
|
||||
{"id": 1, "name": "北京市", "short_name": "北京", "city_code": "010",
|
||||
"area_code": "110000", "zip_code": "100000",
|
||||
"parent_code": None, "parent_id": 0, "level": 1},
|
||||
{"id": 2, "name": "东城区", "short_name": "东城", "city_code": "010",
|
||||
"area_code": "110101", "zip_code": "100010",
|
||||
"parent_code": "1101", "parent_id": 1, "level": 2},
|
||||
])
|
||||
conn.execute(_table(loaded_metadata, "article").insert(), [
|
||||
{"id": 1, "title": "中文标题",
|
||||
"content": "第一行\n第二行 with <b>html</b> 中文", "images": "a.jpg,b.jpg"},
|
||||
])
|
||||
conn.execute(_table(loaded_metadata, "job_log").insert(), [
|
||||
{"id": 1, "job_name": "测试任务", "consume_time": 9223372036854775807}, # BIGINT 上限
|
||||
])
|
||||
|
||||
assert migrate_db.run(make_args(str(src_path), str(dst_path))) == 0
|
||||
|
||||
# 逐表 count 相等(含空表)
|
||||
for t in migrate_db.ordered_tables():
|
||||
with src_engine.connect() as src, dst_engine.connect() as dst:
|
||||
s = src.execute(text(f"SELECT COUNT(*) FROM {t.name}")).scalar()
|
||||
d = dst.execute(text(f"SELECT COUNT(*) FROM {t.name}")).scalar()
|
||||
assert s == d, t.name
|
||||
|
||||
with dst_engine.connect() as conn:
|
||||
# 目标 id 与源逐行一致 + 子树 parent_id 有效
|
||||
dept = [r for r in conn.execute(select(_table(loaded_metadata, "dept")).order_by(
|
||||
_table(loaded_metadata, "dept").c.id))]
|
||||
assert [(r.id, r.parent_id) for r in dept] == [(1, 0), (2, 1)]
|
||||
|
||||
# 软删行 + 空 create_time 原样;密码/salt 原样
|
||||
users = [r for r in conn.execute(select(_table(loaded_metadata, "user")).order_by(
|
||||
_table(loaded_metadata, "user").c.id))]
|
||||
assert users[0].password == "$2b$12$abcdefghijklmnop"
|
||||
assert users[1].is_delete == 1
|
||||
assert users[1].create_time is None
|
||||
|
||||
# Text 逐字节相等(中文/换行/HTML)
|
||||
content = conn.execute(select(_table(loaded_metadata, "article").c.content)).scalar_one()
|
||||
assert content == "第一行\n第二行 with <b>html</b> 中文"
|
||||
|
||||
# BigInteger 保真
|
||||
assert conn.execute(select(_table(loaded_metadata, "job_log").c.consume_time)).scalar_one() \
|
||||
== 9223372036854775807
|
||||
|
||||
# 目标新插 dept 自增为 MAX(id)+1(sqlite rowid 自动跟随显式插 id)
|
||||
with dst_engine.begin() as conn:
|
||||
conn.execute(_table(loaded_metadata, "dept").insert(), [{"name": "新部门", "code": "NEW", "parent_id": 0}])
|
||||
with dst_engine.connect() as conn:
|
||||
new_id = conn.execute(
|
||||
select(_table(loaded_metadata, "dept").c.id).order_by(
|
||||
_table(loaded_metadata, "dept").c.id.desc())).scalar()
|
||||
assert new_id == 3
|
||||
|
||||
|
||||
def test_chunking_roundtrip(two_sqlite, loaded_metadata):
|
||||
"""chunk 循环边界:源 2500 行 city、chunk=1000,目标 count/ids 一致。"""
|
||||
src_engine, dst_engine, src_path, dst_path = two_sqlite
|
||||
city = _table(loaded_metadata, "city")
|
||||
rows = [{"id": i, "name": f"区域{i}", "short_name": f"区域{i}", "city_code": "010",
|
||||
"area_code": f"{i:06d}", "zip_code": "100000",
|
||||
"parent_id": 0, "parent_code": None, "level": 1}
|
||||
for i in range(1, 2501)]
|
||||
with src_engine.begin() as conn:
|
||||
conn.execute(city.insert(), rows)
|
||||
|
||||
assert migrate_db.run(make_args(str(src_path), str(dst_path), chunk_size=1000)) == 0
|
||||
|
||||
with dst_engine.connect() as conn:
|
||||
assert conn.execute(text("SELECT COUNT(*) FROM fastapi_city")).scalar() == 2500
|
||||
ids = [r[0] for r in conn.execute(select(city.c.id).order_by(city.c.id))]
|
||||
assert ids == list(range(1, 2501))
|
||||
|
||||
|
||||
def test_only_tables_filter(two_sqlite, loaded_metadata):
|
||||
"""--only-tables 只泵指定表(支持全名/短名),其余表保持空。"""
|
||||
src_engine, dst_engine, src_path, dst_path = two_sqlite
|
||||
with src_engine.begin() as conn:
|
||||
conn.execute(_table(loaded_metadata, "dept").insert(), [{"id": 1, "name": "总部", "code": "HQ", "parent_id": 0}])
|
||||
conn.execute(_table(loaded_metadata, "city").insert(), [
|
||||
{"id": 1, "name": "北京市", "short_name": "北京", "city_code": "010",
|
||||
"area_code": "110000", "zip_code": "100000", "parent_id": 0}])
|
||||
conn.execute(_table(loaded_metadata, "article").insert(), [{"id": 1, "title": "x"}])
|
||||
|
||||
assert migrate_db.run(make_args(str(src_path), str(dst_path),
|
||||
only_tables="fastapi_dept,city")) == 0
|
||||
|
||||
with dst_engine.connect() as conn:
|
||||
assert conn.execute(text("SELECT COUNT(*) FROM fastapi_dept")).scalar() == 1
|
||||
assert conn.execute(text("SELECT COUNT(*) FROM fastapi_city")).scalar() == 1
|
||||
assert conn.execute(text("SELECT COUNT(*) FROM fastapi_article")).scalar() == 0
|
||||
|
||||
|
||||
def test_dry_run_does_not_touch_target(two_sqlite, loaded_metadata):
|
||||
"""--dry-run 只打印计划,目标库零写入。"""
|
||||
src_engine, dst_engine, src_path, dst_path = two_sqlite
|
||||
with src_engine.begin() as conn:
|
||||
conn.execute(_table(loaded_metadata, "dept").insert(), [{"id": 1, "name": "总部", "code": "HQ", "parent_id": 0}])
|
||||
|
||||
assert migrate_db.run(make_args(str(src_path), str(dst_path), dry_run=True)) == 0
|
||||
|
||||
with dst_engine.connect() as conn:
|
||||
assert conn.execute(text("SELECT COUNT(*) FROM fastapi_dept")).scalar() == 0
|
||||
|
||||
|
||||
def test_pg_reset_sql():
|
||||
sql = migrate_db.pg_reset_sql("fastapi_user")
|
||||
assert "setval" in sql
|
||||
assert "pg_get_serial_sequence('fastapi_user', 'id')" in sql
|
||||
assert 'FROM "fastapi_user"' in sql
|
||||
# 空表用 setval(seq, 1, false)(next=1),不能 setval(seq, 0)(PG 报 NumericValueOutOfRange)
|
||||
assert "COALESCE((SELECT MAX(id) FROM \"fastapi_user\"), 1)" in sql
|
||||
assert "(SELECT MAX(id) FROM \"fastapi_user\") IS NOT NULL" in sql
|
||||
|
||||
|
||||
def test_oracle_autoincrement_sql():
|
||||
"""Oracle 自增:sequence 从 MAX+1 起,触发器 id 为 NULL 时取 NEXTVAL。"""
|
||||
seq = migrate_db.oracle_sequence_sql("fastapi_user", 27)
|
||||
assert seq == "CREATE SEQUENCE fastapi_user_id_seq START WITH 27 INCREMENT BY 1 NOCACHE"
|
||||
trg = migrate_db.oracle_trigger_sql("fastapi_user")
|
||||
assert "BEFORE INSERT ON fastapi_user FOR EACH ROW" in trg
|
||||
assert "WHEN (NEW.id IS NULL)" in trg
|
||||
assert "SELECT fastapi_user_id_seq.NEXTVAL INTO :NEW.id FROM dual" in trg
|
||||
|
||||
|
||||
def test_mssql_reset_sql():
|
||||
assert migrate_db.mssql_reset_sql("fastapi_user", 5) == "DBCC CHECKIDENT ('fastapi_user', RESEED, 5)"
|
||||
# 空表 max_id 为 None → RESEED 0,下一 id=1
|
||||
assert migrate_db.mssql_reset_sql("fastapi_user", None) == "DBCC CHECKIDENT ('fastapi_user', RESEED, 0)"
|
||||
|
||||
|
||||
def test_create_database_if_missing_sqlite(tmp_path):
|
||||
"""sqlite 建库:父目录自动创建;drop_first 删除已存在文件。"""
|
||||
db = tmp_path / "nested" / "new.db"
|
||||
migrate_db.create_database_if_missing("sqlite", "", None, str(db), "", "", drop_first=False)
|
||||
eng = create_engine(migrate_db.make_url("sqlite", "", None, str(db), "", ""))
|
||||
with eng.connect():
|
||||
pass # 触发 sqlite 建文件
|
||||
eng.dispose()
|
||||
assert db.exists()
|
||||
|
||||
migrate_db.create_database_if_missing("sqlite", "", None, str(db), "", "", drop_first=True)
|
||||
assert not db.exists()
|
||||
|
||||
|
||||
def test_verify_counts_reports_mismatch(two_sqlite, loaded_metadata):
|
||||
"""count 校验能报出不一致的表。"""
|
||||
src_engine, dst_engine, src_path, dst_path = two_sqlite
|
||||
with src_engine.begin() as conn:
|
||||
conn.execute(_table(loaded_metadata, "dept").insert(), [{"id": 1, "name": "总部", "code": "HQ", "parent_id": 0}])
|
||||
|
||||
assert migrate_db.run(make_args(str(src_path), str(dst_path))) == 0
|
||||
|
||||
# 泵完后删一行制造不一致
|
||||
with dst_engine.begin() as conn:
|
||||
conn.execute(text("DELETE FROM fastapi_dept WHERE id = 1"))
|
||||
|
||||
with src_engine.connect() as src, dst_engine.connect() as dst:
|
||||
failed = migrate_db.verify_counts(src, dst, migrate_db.ordered_tables())
|
||||
assert any(name == "fastapi_dept" for name, _, _ in failed)
|
||||
@@ -0,0 +1,86 @@
|
||||
"""utils/captcha.py 验证码校验:原子消费(get→del→compare)杜绝重放与无限试错。
|
||||
|
||||
fakeredis 不支持 Lua eval,这里用 Python 复刻 Lua 语义(原子 get→del→compare,
|
||||
无论对错都消费验证码)作为 fake,验证业务层行为;另断言生产代码确实调用
|
||||
_CAPTCHA_VERIFY_LUA 脚本。
|
||||
"""
|
||||
import pytest
|
||||
import fakeredis.aioredis
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _captcha_redis(monkeypatch):
|
||||
from core import redis_client as rc
|
||||
|
||||
redis = fakeredis.aioredis.FakeRedis(decode_responses=True)
|
||||
|
||||
async def fake_eval(script, numkeys, key, user_input):
|
||||
# 复刻 _CAPTCHA_VERIFY_LUA 语义:原子 get→del→compare,无论对错都消费
|
||||
stored = await redis.get(key)
|
||||
if not stored:
|
||||
return 0
|
||||
await redis.delete(key)
|
||||
if str(stored).upper().strip() == str(user_input).upper().strip():
|
||||
return 1
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(redis, 'eval', fake_eval)
|
||||
monkeypatch.setattr(rc, 'get_redis', lambda: redis)
|
||||
return redis
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_captcha_correct_then_consumed(_captcha_redis):
|
||||
from utils.captcha import verify_captcha
|
||||
|
||||
await _captcha_redis.set('k1', 'Ab3X')
|
||||
# 大小写不敏感比较通过
|
||||
assert await verify_captcha('k1', 'ab3x') is True
|
||||
# 已消费:同一验证码无法再次使用(防重放)
|
||||
assert await verify_captcha('k1', 'Ab3X') is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_captcha_wrong_input_burns_captcha(_captcha_redis):
|
||||
from utils.captcha import verify_captcha
|
||||
|
||||
await _captcha_redis.set('k2', 'Ab3X')
|
||||
# 错误输入 → False,且验证码被消费(失败即失效,防无限试错)
|
||||
assert await verify_captcha('k2', 'wrong') is False
|
||||
# 随后即使用正确输入也无法通过
|
||||
assert await verify_captcha('k2', 'Ab3X') is False
|
||||
assert await _captcha_redis.get('k2') is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_captcha_missing_key(_captcha_redis):
|
||||
from utils.captcha import verify_captcha
|
||||
|
||||
assert await verify_captcha('missing', 'any') is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_captcha_empty_input(_captcha_redis):
|
||||
from utils.captcha import verify_captcha
|
||||
|
||||
assert await verify_captcha('k', '') is False
|
||||
assert await verify_captcha('', 'x') is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_captcha_uses_atomic_lua_script(_captcha_redis, monkeypatch):
|
||||
from utils.captcha import verify_captcha, _CAPTCHA_VERIFY_LUA
|
||||
|
||||
await _captcha_redis.set('k1', 'Ab3X')
|
||||
calls = []
|
||||
original = _captcha_redis.eval
|
||||
|
||||
async def spy_eval(script, numkeys, key, user_input):
|
||||
calls.append((script, numkeys))
|
||||
return await original(script, numkeys, key, user_input)
|
||||
|
||||
monkeypatch.setattr(_captcha_redis, 'eval', spy_eval)
|
||||
|
||||
assert await verify_captcha('k1', 'Ab3X') is True
|
||||
# 生产代码调用原子 Lua 脚本(numkeys=1)
|
||||
assert calls and calls[0] == (_CAPTCHA_VERIFY_LUA, 1)
|
||||
@@ -0,0 +1,51 @@
|
||||
"""utils/file.py 魔数校验:读取异常必须 fail-closed 拒绝上传,不得放行伪装文件。"""
|
||||
import io
|
||||
|
||||
from utils.file import verify_file_magic
|
||||
|
||||
|
||||
class _BrokenStream:
|
||||
"""模拟文件流读取失败(seek/read 抛异常)。"""
|
||||
|
||||
def read(self, n):
|
||||
raise OSError('模拟读取失败')
|
||||
|
||||
def seek(self, n):
|
||||
raise OSError('模拟 seek 失败')
|
||||
|
||||
|
||||
def test_verify_file_magic_fail_closed_on_read_error():
|
||||
is_valid, msg = verify_file_magic(_BrokenStream(), '.jpg')
|
||||
assert is_valid is False
|
||||
assert '校验异常' in msg
|
||||
|
||||
|
||||
def test_verify_file_magic_accepts_valid_magic():
|
||||
png_header = b'\x89PNG\r\n\x1a\n' + b'filler' * 10
|
||||
is_valid, msg = verify_file_magic(io.BytesIO(png_header), '.png')
|
||||
assert is_valid is True
|
||||
assert msg == ''
|
||||
|
||||
|
||||
def test_verify_file_magic_rejects_mismatched_content():
|
||||
# 内容与扩展名不符(JPEG 头冒充 .png)→ 拒绝
|
||||
jpeg_bytes = b'\xff\xd8\xff\xe0' + b'filler' * 10
|
||||
is_valid, msg = verify_file_magic(io.BytesIO(jpeg_bytes), '.png')
|
||||
assert is_valid is False
|
||||
assert '不匹配' in msg
|
||||
|
||||
|
||||
def test_verify_file_magic_txt_accepts_text_content():
|
||||
# 文本无固定二进制签名,可读文本(含空文件)放行
|
||||
for content in (b'hello world', b'', '你好,世界'.encode('utf-8')):
|
||||
is_valid, msg = verify_file_magic(io.BytesIO(content), '.txt')
|
||||
assert is_valid is True, content
|
||||
assert msg == ''
|
||||
|
||||
|
||||
def test_verify_file_magic_txt_rejects_binary_content():
|
||||
# 二进制内容伪装成 .txt(含 NUL 字节)→ 拒绝,防伪装文件绕过
|
||||
binary = b'\x00\x00\x00\xff' + b'filler' * 10
|
||||
is_valid, msg = verify_file_magic(io.BytesIO(binary), '.txt')
|
||||
assert is_valid is False
|
||||
assert '不匹配' in msg
|
||||
@@ -0,0 +1,39 @@
|
||||
"""utils/ip2region.py:畸形 IP 不得抛异常中断日志(内部降级返回"未知")。
|
||||
|
||||
回归场景:IP 形如 172.abc 时,int(parts[1]) 曾抛 ValueError 冒泡到
|
||||
record_login_log / record_operation_log,使日志整段丢失。
|
||||
"""
|
||||
|
||||
from utils.ip2region import _is_private_ip, get_ip_location, get_ip_region
|
||||
|
||||
|
||||
def test_is_private_ip_handles_malformed_ip():
|
||||
# 畸形 IP 不再抛 ValueError,按非内网处理(交查询侧兜底为"未知")
|
||||
assert _is_private_ip('172.abc') is False
|
||||
assert _is_private_ip('172.') is False
|
||||
assert _is_private_ip('172.a.b') is False
|
||||
|
||||
|
||||
def test_is_private_ip_private_ranges():
|
||||
assert _is_private_ip('172.16.5.5') is True
|
||||
assert _is_private_ip('172.31.5.5') is True
|
||||
assert _is_private_ip('172.32.5.5') is False # 不在 172.16/12 私有段
|
||||
assert _is_private_ip('10.1.2.3') is True
|
||||
assert _is_private_ip('192.168.1.1') is True
|
||||
assert _is_private_ip('8.8.8.8') is False
|
||||
|
||||
|
||||
def test_get_ip_location_malformed_ip_degrades_to_unknown():
|
||||
# 畸形 IP:不抛异常,降级返回"未知"(xdb 未加载时非内网 IP 也返回"未知")
|
||||
assert get_ip_location('172.abc') == '未知'
|
||||
|
||||
|
||||
def test_get_ip_region_malformed_ip_returns_unknown_dict():
|
||||
region = get_ip_region('172.abc')
|
||||
assert region['country'] == '未知'
|
||||
assert set(region) == {'country', 'province', 'city', 'isp', 'country_code'}
|
||||
|
||||
|
||||
def test_get_ip_location_private_ip_still_works():
|
||||
# 内网 IP 无需 xdb,仍正常返回"内网IP"
|
||||
assert get_ip_location('10.0.0.1') == '内网IP'
|
||||
@@ -0,0 +1,70 @@
|
||||
"""utils/ip.py 可信代理白名单:仅直连对端命中白名单才信任 X-Forwarded-For。"""
|
||||
import types
|
||||
|
||||
from utils import ip as ip_mod
|
||||
from utils.ip import get_client_ip
|
||||
|
||||
|
||||
def _make_request(client_host, xff=None):
|
||||
req = types.SimpleNamespace()
|
||||
req.client = types.SimpleNamespace(host=client_host)
|
||||
req.headers = {'X-Forwarded-For': xff} if xff else {}
|
||||
return req
|
||||
|
||||
|
||||
def test_proxy_count_zero_ignores_xff(monkeypatch):
|
||||
# TRUSTED_PROXY_COUNT=0:即使带 XFF 头也不信任,直接返回 TCP 层 IP
|
||||
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_COUNT', 0)
|
||||
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_IPS', frozenset({'127.0.0.1'}))
|
||||
assert get_client_ip(_make_request('203.0.113.9', xff='1.2.3.4')) == '203.0.113.9'
|
||||
|
||||
|
||||
def test_proxy_count_positive_but_no_whitelist_is_fail_closed(monkeypatch):
|
||||
# 配了 TRUSTED_PROXY_COUNT 但未配 TRUSTED_PROXY_IPS → 不信任 XFF(fail-closed)
|
||||
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_COUNT', 1)
|
||||
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_IPS', frozenset())
|
||||
assert get_client_ip(_make_request('203.0.113.9', xff='1.2.3.4')) == '203.0.113.9'
|
||||
|
||||
|
||||
def test_peer_not_in_whitelist_rejects_xff(monkeypatch):
|
||||
# 直连对端不是可信代理(应用被直接暴露,如 Docker bridge 映射端口)
|
||||
# → 客户端伪造的 XFF 被忽略,返回真实对端 IP
|
||||
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_COUNT', 1)
|
||||
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_IPS', frozenset({'10.0.0.1'}))
|
||||
assert get_client_ip(_make_request('203.0.113.9', xff='1.2.3.4')) == '203.0.113.9'
|
||||
|
||||
|
||||
def test_peer_in_whitelist_trusts_xff(monkeypatch):
|
||||
# 单层代理:XFF 仅含客户端 IP(由可信代理追加),跳过最右侧 1 个代理条目即客户端
|
||||
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_COUNT', 1)
|
||||
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_IPS', frozenset({'10.0.0.1'}))
|
||||
assert get_client_ip(_make_request('10.0.0.1', xff='1.2.3.4')) == '1.2.3.4'
|
||||
|
||||
|
||||
def test_peer_in_whitelist_spoofed_left_entry_ignored(monkeypatch):
|
||||
# 客户端伪造条目只会出现在 XFF 左侧(由可信代理追加的在其右侧),伪造 IP 被忽略
|
||||
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_COUNT', 1)
|
||||
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_IPS', frozenset({'10.0.0.1'}))
|
||||
assert get_client_ip(_make_request('10.0.0.1', xff='6.6.6.6, 1.2.3.4')) == '1.2.3.4'
|
||||
|
||||
|
||||
def test_peer_in_whitelist_fewer_entries_than_proxies(monkeypatch):
|
||||
# 代理条目少于层数(部分代理未追加 XFF)→ 回退到最右侧条目,避免拿到代理 IP
|
||||
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_COUNT', 2)
|
||||
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_IPS', frozenset({'10.0.0.2'}))
|
||||
assert get_client_ip(_make_request('10.0.0.2', xff='1.2.3.4')) == '1.2.3.4'
|
||||
|
||||
|
||||
def test_peer_in_whitelist_multiple_proxy_layers(monkeypatch):
|
||||
# 两层代理:XFF=[客户端, 外层代理],最内层可信代理是直连对端(不在 XFF 内),
|
||||
# 跳过最右侧 2 个代理条目取真实客户端
|
||||
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_COUNT', 2)
|
||||
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_IPS', frozenset({'10.0.0.2'}))
|
||||
assert get_client_ip(_make_request('10.0.0.2', xff='1.2.3.4, 10.0.0.1')) == '1.2.3.4'
|
||||
|
||||
|
||||
def test_no_client_falls_back_to_localhost(monkeypatch):
|
||||
req = types.SimpleNamespace(client=None, headers={})
|
||||
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_COUNT', 1)
|
||||
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_IPS', frozenset({'10.0.0.1'}))
|
||||
assert get_client_ip(req) == '127.0.0.1'
|
||||
Reference in New Issue
Block a user