fastapi-basekit 0.5.3__tar.gz → 0.5.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/PKG-INFO +1 -1
  2. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/beanie/repository/base.py +113 -71
  3. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/beanie/service/base.py +39 -3
  4. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/sqlalchemy/repository/base.py +34 -9
  5. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/sqlalchemy/service/base.py +10 -3
  6. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit.egg-info/PKG-INFO +1 -1
  7. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit.egg-info/SOURCES.txt +1 -0
  8. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/pyproject.toml +1 -1
  9. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_beanie_service_regression.py +73 -0
  10. fastapi_basekit-0.5.4/tests/test_owner_filter_helper.py +65 -0
  11. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_sqlalchemy_service_regression.py +73 -0
  12. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/LICENSE +0 -0
  13. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/README.md +0 -0
  14. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/__init__.py +0 -0
  15. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/__init__.py +0 -0
  16. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/beanie/__init__.py +0 -0
  17. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/beanie/controller/__init__.py +0 -0
  18. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/beanie/controller/base.py +0 -0
  19. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/beanie/repository/__init__.py +0 -0
  20. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/beanie/service/__init__.py +0 -0
  21. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/controller/__init__.py +0 -0
  22. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/controller/base.py +0 -0
  23. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/permissions/__init__.py +0 -0
  24. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/permissions/base.py +0 -0
  25. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/sqlalchemy/__init__.py +0 -0
  26. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/sqlalchemy/controller/__init__.py +0 -0
  27. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/sqlalchemy/controller/base.py +0 -0
  28. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/sqlalchemy/repository/__init__.py +0 -0
  29. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/sqlalchemy/service/__init__.py +0 -0
  30. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/sqlalchemy/session.py +0 -0
  31. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/sqlmodel/__init__.py +0 -0
  32. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/sqlmodel/controller/__init__.py +0 -0
  33. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/sqlmodel/controller/base.py +0 -0
  34. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/sqlmodel/repository/__init__.py +0 -0
  35. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/sqlmodel/repository/base.py +0 -0
  36. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/sqlmodel/service/__init__.py +0 -0
  37. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/aio/sqlmodel/service/base.py +0 -0
  38. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/cli/__init__.py +0 -0
  39. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/cli/main.py +0 -0
  40. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/exceptions/__init__.py +0 -0
  41. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/exceptions/api_exceptions.py +0 -0
  42. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/exceptions/domain.py +0 -0
  43. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/exceptions/handler.py +0 -0
  44. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/openapi.py +0 -0
  45. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/py.typed +0 -0
  46. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/schema/__init__.py +0 -0
  47. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/schema/base.py +0 -0
  48. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/schema/jwt.py +0 -0
  49. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/schema/schema.py +0 -0
  50. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/servicios/__init__.py +0 -0
  51. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/servicios/thrid/__init__.py +0 -0
  52. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/servicios/thrid/jwt.py +0 -0
  53. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/cookiecutter.json +0 -0
  54. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/hooks/post_gen_project.py +0 -0
  55. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/hooks/pre_gen_project.py +0 -0
  56. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/.env.example +0 -0
  57. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/.gitignore +0 -0
  58. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/Dockerfile +0 -0
  59. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/LICENSE +0 -0
  60. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/Makefile +0 -0
  61. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/README.md +0 -0
  62. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/alembic/env.py +0 -0
  63. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/alembic/script.py.mako +0 -0
  64. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/alembic.ini +0 -0
  65. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/__init__.py +0 -0
  66. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/__init__.py +0 -0
  67. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/v1/__init__.py +0 -0
  68. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/v1/endpoints/__init__.py +0 -0
  69. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/v1/endpoints/auth/__init__.py +0 -0
  70. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/v1/endpoints/auth/auth.py +0 -0
  71. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/v1/endpoints/user/__init__.py +0 -0
  72. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/v1/endpoints/user/user.py +0 -0
  73. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/v1/routers.py +0 -0
  74. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/config/__init__.py +0 -0
  75. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/config/database.py +0 -0
  76. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/config/settings.py +0 -0
  77. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/main.py +0 -0
  78. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/middleware/__init__.py +0 -0
  79. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/middleware/auth.py +0 -0
  80. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/middleware/permissions.py +0 -0
  81. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/models/__init__.py +0 -0
  82. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/models/auth.py +0 -0
  83. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/models/base.py +0 -0
  84. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/models/enums.py +0 -0
  85. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/models/types.py +0 -0
  86. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/permissions/__init__.py +0 -0
  87. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/permissions/user.py +0 -0
  88. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/repositories/__init__.py +0 -0
  89. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/repositories/user/__init__.py +0 -0
  90. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/repositories/user/repository.py +0 -0
  91. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/schemas/__init__.py +0 -0
  92. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/schemas/auth.py +0 -0
  93. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/schemas/base.py +0 -0
  94. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/schemas/user.py +0 -0
  95. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/scripts/__init__.py +0 -0
  96. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/scripts/init.py +0 -0
  97. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/scripts/init_admin.py +0 -0
  98. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/services/__init__.py +0 -0
  99. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/services/auth_service.py +0 -0
  100. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/services/dependency.py +0 -0
  101. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/services/user_service.py +0 -0
  102. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/utils/__init__.py +0 -0
  103. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/utils/exception_handlers.py +0 -0
  104. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/utils/security.py +0 -0
  105. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/docker-compose.yml +0 -0
  106. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/pytest.ini +0 -0
  107. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/requirements.txt +0 -0
  108. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit.egg-info/dependency_links.txt +0 -0
  109. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit.egg-info/entry_points.txt +0 -0
  110. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit.egg-info/requires.txt +0 -0
  111. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/fastapi_basekit.egg-info/top_level.txt +0 -0
  112. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/setup.cfg +0 -0
  113. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_action_param_leak.py +0 -0
  114. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_api_exceptions.py +0 -0
  115. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_base_response.py +0 -0
  116. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_base_service.py +0 -0
  117. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_beanie_aggregation_hooks.py +0 -0
  118. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_beanie_aggregation_integration.py +0 -0
  119. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_beanie_keyset_pagination.py +0 -0
  120. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_beanie_repository_regression.py +0 -0
  121. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_beanie_service_mutable_defaults.py +0 -0
  122. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_controller_auto_permissions.py +0 -0
  123. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_crud_beanie_controller.py +0 -0
  124. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_crud_controller.py +0 -0
  125. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_crud_sqlmodel_repository_service.py +0 -0
  126. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_exception_handlers.py +0 -0
  127. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_filter_operators.py +0 -0
  128. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_format_response_schema_override.py +0 -0
  129. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_generics_optional_and_cache.py +0 -0
  130. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_generics_wiring.py +0 -0
  131. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_jwt_config_robustness.py +0 -0
  132. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_jwt_service.py +0 -0
  133. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_openapi_simplify.py +0 -0
  134. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_params_classification.py +0 -0
  135. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_params_coercion_matrix.py +0 -0
  136. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_params_coercion_unit.py +0 -0
  137. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_params_http_matrix_beanie.py +0 -0
  138. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_params_http_matrix_sqlalchemy.py +0 -0
  139. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_params_rich_type_coercion.py +0 -0
  140. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_path_filter_mixin_compat.py +0 -0
  141. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_post_process_list_hook.py +0 -0
  142. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_pulbot_cbv_pattern_beanie.py +0 -0
  143. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_repo_signature_aliases.py +0 -0
  144. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_sql_queryset_override.py +0 -0
  145. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_sqlalchemy_base_service_order.py +0 -0
  146. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_sqlalchemy_repository_regression.py +0 -0
  147. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_sqlmodel_filter_operators.py +0 -0
  148. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.4}/tests/test_typed_filter_coercion.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: fastapi-basekit
3
- Version: 0.5.3
3
+ Version: 0.5.4
4
4
  Summary: Utilities and base classes for FastAPI async projects (Beanie, SQLAlchemy or SQLModel)
5
5
  Author-email: Jerson Moreno <jerson.ml820@hotmail.com>
6
6
  License: MIT
@@ -119,75 +119,60 @@ class BaseRepository(Generic[ModelT]):
119
119
  kwargs["projection"] = projection
120
120
  return kwargs
121
121
 
122
- def build_filter_query(
123
- self,
124
- search: Optional[str],
125
- search_fields: List[str],
126
- filters: dict = None,
127
- order_by: Optional[List[tuple]] = None,
128
- **kwargs,
129
- ) -> FindMany[Document]:
130
- """Versión personalizada que soporta campos Link."""
131
- exprs = []
132
-
133
- if search and search_fields:
134
- exprs.append(
135
- Or(
136
- *[
137
- RegEx(
138
- getattr(self.model, f),
139
- f".*{search}.*",
140
- options="i",
141
- )
142
- for f in search_fields
143
- ]
144
- )
145
- )
146
-
147
- # Obtener campos del modelo
122
+ def _is_link_field(self, field_name: str) -> bool:
123
+ """Verifica si un campo del modelo es de tipo Link (o Optional[Link])."""
148
124
  model_fields = (
149
125
  self.model.model_fields
150
126
  if hasattr(self.model, "model_fields")
151
127
  else {}
152
128
  )
129
+ field_info = model_fields.get(field_name)
130
+ if not field_info:
131
+ return False
153
132
 
154
- def _is_link_field(field_name: str) -> bool:
155
- """Verifica si un campo es de tipo Link."""
156
- field_info = model_fields.get(field_name)
157
- if not field_info:
158
- return False
159
-
160
- field_type = field_info.annotation
161
- origin = get_origin(field_type)
162
-
163
- # Caso directo: Link[Model]
164
- if origin is Link:
165
- return True
166
-
167
- # Caso Optional[Link[Model]] = Union[Link[Model], None]
168
- # O cualquier Union que contenga Link
169
- if origin is not None:
170
- args = get_args(field_type)
171
- for arg in args:
172
- # Verificar si el argumento es Link
173
- arg_origin = get_origin(arg)
174
- if arg_origin is Link:
175
- return True
133
+ field_type = field_info.annotation
134
+ origin = get_origin(field_type)
176
135
 
177
- return False
136
+ # Caso directo: Link[Model]
137
+ if origin is Link:
138
+ return True
178
139
 
179
- raw_filters: Dict[str, Any] = {}
140
+ # Caso Optional[Link[Model]] = Union[Link[Model], None]
141
+ # O cualquier Union que contenga Link
142
+ if origin is not None:
143
+ args = get_args(field_type)
144
+ for arg in args:
145
+ arg_origin = get_origin(arg)
146
+ if arg_origin is Link:
147
+ return True
180
148
 
181
- def _coerce_objectid(value: Any) -> Any:
182
- """str/UUID → ObjectId para queries Mongo sobre Link.id."""
183
- if isinstance(value, ObjectId):
184
- return value
185
- if isinstance(value, str):
186
- try:
187
- return ObjectId(value)
188
- except Exception:
189
- return value
149
+ return False
150
+
151
+ @staticmethod
152
+ def _coerce_objectid(value: Any) -> Any:
153
+ """str/UUID → ObjectId para queries Mongo sobre Link.id."""
154
+ if isinstance(value, ObjectId):
190
155
  return value
156
+ if isinstance(value, str):
157
+ try:
158
+ return ObjectId(value)
159
+ except Exception:
160
+ return value
161
+ return value
162
+
163
+ def _resolve_filter_query_args(self, filters: dict = None) -> list:
164
+ """Traduce un dict de `get_filters()` a los args posicionales que
165
+ acepta `Model.find()`/`Model.find_one()` — resuelve campos Link
166
+ (directos y el alias `<field>_id`), pasa claves Mongo crudas
167
+ (`"user.$id"`, `"$or"`, ...) como dict, y avisa (no falla en
168
+ silencio) si una clave no resuelve a nada del modelo.
169
+
170
+ Compartido por `build_filter_query` (listados) y `get_by_id`
171
+ (retrieve/update/delete escopeados) — mismo motor de resolución de
172
+ filtros para las dos rutas, así un `get_filters()` de seguridad
173
+ protege ambas por igual."""
174
+ exprs: list = []
175
+ raw_filters: Dict[str, Any] = {}
191
176
 
192
177
  for k, v in (filters or {}).items():
193
178
  # MongoDB-style keys (dot-notation like "user.$id" or operators like "$or")
@@ -198,8 +183,8 @@ class BaseRepository(Generic[ModelT]):
198
183
 
199
184
  if hasattr(self.model, k):
200
185
  field_attr = getattr(self.model, k)
201
- if _is_link_field(k):
202
- exprs.append(field_attr.id == _coerce_objectid(v))
186
+ if self._is_link_field(k):
187
+ exprs.append(field_attr.id == self._coerce_objectid(v))
203
188
  else:
204
189
  exprs.append(field_attr == v)
205
190
  continue
@@ -210,32 +195,59 @@ class BaseRepository(Generic[ModelT]):
210
195
  # el modelo y es un Link[X].
211
196
  if k.endswith("_id"):
212
197
  base = k[:-3]
213
- if hasattr(self.model, base) and _is_link_field(base):
198
+ if hasattr(self.model, base) and self._is_link_field(base):
214
199
  field_attr = getattr(self.model, base)
215
- exprs.append(field_attr.id == _coerce_objectid(v))
200
+ exprs.append(field_attr.id == self._coerce_objectid(v))
216
201
  continue
217
202
 
218
203
  # Clave no resoluble (typo, campo inexistente, nesting mal escrito).
219
204
  # Se descarta — pero se AVISA: si la clave venía de `get_filters`
220
205
  # (scoping por tenant/owner), su desaparición silenciosa deja el
221
- # listado sin filtrar → fuga cross-tenant (IDOR). El warning hace
222
- # visible la pérdida en logs/Sentry en vez de fallar en silencio.
206
+ # listado/retrieve SIN ese filtro. El warning hace visible la
207
+ # pérdida en logs/Sentry en vez de fallar en silencio.
223
208
  logger.warning(
224
- "build_filter_query: filtro '%s' descartado (no resuelve a "
225
- "ningún campo de %s). Si era un filtro de scoping, el listado "
226
- "queda SIN ese filtro.",
209
+ "_resolve_filter_query_args: filtro '%s' descartado (no "
210
+ "resuelve a ningún campo de %s). Si era un filtro de "
211
+ "scoping, la query queda SIN ese filtro.",
227
212
  k,
228
213
  getattr(self.model, "__name__", self.model),
229
214
  )
230
215
 
231
216
  # Raw MongoDB filters go first so Beanie processes them as a dict condition
232
- query_args: list = ([raw_filters] if raw_filters else []) + exprs
217
+ return ([raw_filters] if raw_filters else []) + exprs
218
+
219
+ def build_filter_query(
220
+ self,
221
+ search: Optional[str],
222
+ search_fields: List[str],
223
+ filters: dict = None,
224
+ order_by: Optional[List[tuple]] = None,
225
+ **kwargs,
226
+ ) -> FindMany[Document]:
227
+ """Versión personalizada que soporta campos Link."""
228
+ exprs = []
229
+
230
+ if search and search_fields:
231
+ exprs.append(
232
+ Or(
233
+ *[
234
+ RegEx(
235
+ getattr(self.model, f),
236
+ f".*{search}.*",
237
+ options="i",
238
+ )
239
+ for f in search_fields
240
+ ]
241
+ )
242
+ )
243
+
244
+ query_args: list = exprs + self._resolve_filter_query_args(filters)
233
245
  query = self.model.find(*query_args, **self._get_query_kwargs(**kwargs))
234
-
246
+
235
247
  # Apply ordering if provided
236
248
  if order_by:
237
249
  query = query.sort(order_by)
238
-
250
+
239
251
  return query
240
252
 
241
253
  async def paginate(
@@ -521,10 +533,40 @@ class BaseRepository(Generic[ModelT]):
521
533
  async def get_by_id(
522
534
  self,
523
535
  obj_id: Union[str, ObjectId],
536
+ filters: Optional[Dict[str, Any]] = None,
524
537
  **kwargs,
525
538
  ) -> Optional[ModelT]:
539
+ """`filters` (opcional) — scoping de seguridad (típicamente
540
+ `Service.get_filters()`, ej. `{"user.$id": request.state.user.id}`).
541
+ Sin `filters`, comportamiento idéntico a antes (busca por id, sin
542
+ scope) — 100% retrocompatible. Con `filters`, un id que existe pero
543
+ no matchea el scope devuelve `None` (mismo resultado que "no
544
+ encontrado"), cerrando la clase de IDOR de "conozco el id de otro
545
+ tenant y lo pido directo".
546
+
547
+ El chequeo de scope corre SIEMPRE con `fetch_links=False` (find_one
548
+ plano, sobre el documento crudo — DBRef sin resolver), sea cual sea
549
+ el `fetch_links` que pida el caller para el fetch final. Motivo:
550
+ Beanie reescribe `find(..., fetch_links=True)` a una aggregation con
551
+ `$lookup`, y un filtro con notación de punto (`"user.$id"`,
552
+ `"user._id"`, etc.) puede matchear distinto — o no matchear nada —
553
+ según si corre ANTES o DESPUÉS del `$lookup` (mismo bug ya
554
+ documentado en `find_all_production_for_user` de pulbot-backend:
555
+ "fetch_links=True puede descartar resultados silenciosamente al
556
+ filtrar por campos dict anidados"). Combinar scope-filter +
557
+ fetch_links en una sola query es frágil; separarlos lo hace
558
+ determinístico sin importar qué `get_filters()` use cada service."""
526
559
  if not isinstance(obj_id, ObjectId):
527
560
  obj_id = ObjectId(obj_id)
561
+
562
+ if filters:
563
+ scope_exprs = self._resolve_filter_query_args(filters)
564
+ scoped = await self.model.find_one(
565
+ self.model.id == obj_id, *scope_exprs, fetch_links=False
566
+ )
567
+ if not scoped:
568
+ return None
569
+
528
570
  return await self.model.find_one(
529
571
  self.model.id == obj_id,
530
572
  **self._get_query_kwargs(**kwargs),
@@ -82,9 +82,43 @@ class BaseService(Generic[ModelT]):
82
82
  filters = filters or {}
83
83
  return filters
84
84
 
85
+ def owner_filter(self, field: str, value: Any) -> Dict[str, Any]:
86
+ """Filtro de scoping por un campo `Link` (ej. ownership por
87
+ `user`), correcto tanto en `list()` como en `retrieve`/`update`/
88
+ `delete` — sin que el service tenga que conocer por qué la clave
89
+ Mongo cambia entre esos dos caminos.
90
+
91
+ Por qué existen dos claves para "lo mismo": un campo `Link[User]`
92
+ se guarda como DBRef (`{"$ref": "users", "$id": ObjectId(...)}`).
93
+ - `retrieve`/`update`/`delete` van por `get_by_id`, que SIEMPRE
94
+ resuelve el scope con `fetch_links=False` (`find_one` plano sobre
95
+ el documento crudo) — ahí el DBRef está intacto, la clave correcta
96
+ es `"<field>.$id"`.
97
+ - `list()` (y cualquier action cuyo `get_kwargs_query()` pida
98
+ `fetch_links=True`) corre `Model.find(..., fetch_links=True)`,
99
+ que Beanie reescribe a una aggregation con `$lookup` — ahí el
100
+ `$lookup` REEMPLAZA el DBRef por el subdocumento resuelto, así
101
+ que la clave correcta pasa a ser `"<field>._id"`.
102
+ Usar la clave equivocada no tira error — devuelve 0 resultados en
103
+ silencio (bug real ya encontrado usando esto a mano en
104
+ `ToolService` de pulbot-backend, 2026-08-11). Este helper decide
105
+ por vos: usalo en vez de escribir `"<field>.$id"`/`"<field>._id"`
106
+ directo en tu `get_filters()`.
107
+
108
+ ⚠️ Asume que tu action de lectura-por-id (`retrieve`/`update`/
109
+ `delete`) no fuerza `fetch_links=True` en el PROPIO chequeo de
110
+ scope — así es como quedó `get_by_id` del repo (siempre
111
+ `fetch_links=False` para el scope-check, independientemente del
112
+ `fetch_links` que pidas para el fetch final). Si tu repo está
113
+ parcheado para saltarse eso, este helper no aplica."""
114
+ suffix = "._id" if self.action == "list" else ".$id"
115
+ return {f"{field}{suffix}": value}
116
+
85
117
  async def retrieve(self, id: str) -> ModelT:
86
118
  kwargs = self.get_kwargs_query()
87
- obj = await self.repository.get_by_id(id, **kwargs)
119
+ obj = await self.repository.get_by_id(
120
+ id, filters=self.get_filters(), **kwargs
121
+ )
88
122
  if not obj:
89
123
  raise NotFoundException(f"id={id} no encontrado")
90
124
  return obj
@@ -241,7 +275,9 @@ class BaseService(Generic[ModelT]):
241
275
 
242
276
  async def update(self, id: str, data: BaseModel) -> ModelT:
243
277
  kwargs = self.get_kwargs_query()
244
- obj = await self.repository.get_by_id(id, **kwargs)
278
+ obj = await self.repository.get_by_id(
279
+ id, filters=self.get_filters(), **kwargs
280
+ )
245
281
  if not obj:
246
282
  raise NotFoundException(f"id={id} no encontrado")
247
283
  if isinstance(data, BaseModel):
@@ -250,7 +286,7 @@ class BaseService(Generic[ModelT]):
250
286
  return updated
251
287
 
252
288
  async def delete(self, id: str) -> str:
253
- obj = await self.repository.get_by_id(id)
289
+ obj = await self.repository.get_by_id(id, filters=self.get_filters())
254
290
  if not obj:
255
291
  raise NotFoundException(f"id={id} no encontrado")
256
292
  await self.repository.delete(obj)
@@ -487,16 +487,37 @@ class BaseRepository(Generic[ModelT]):
487
487
 
488
488
  return record
489
489
 
490
- async def get(self, record_id: Union[str, UUID]) -> Optional[ModelT]:
491
- """Obtiene un registro por su ID."""
490
+ async def get(
491
+ self,
492
+ record_id: Union[str, UUID],
493
+ filters: Optional[Dict[str, Any]] = None,
494
+ ) -> Optional[ModelT]:
495
+ """Obtiene un registro por su ID.
496
+
497
+ `filters` (opcional) — scoping de seguridad (típicamente
498
+ `Service.get_filters()`). Sin `filters`, usa `session.get()` (fast
499
+ path por PK) — comportamiento idéntico a antes, 100% retrocompatible.
500
+ Con `filters`, cae a un `SELECT ... WHERE id = :id AND <filtros>` —
501
+ un id que existe pero no matchea el scope devuelve `None` (mismo
502
+ resultado que "no encontrado"), cerrando la clase de IDOR de "conozco
503
+ el id de otro tenant y lo pido directo"."""
492
504
  if not self.model:
493
505
  raise ValueError("El modelo no está definido en el repositorio")
494
- return await self.session.get(self.model, record_id)
506
+ if not filters:
507
+ return await self.session.get(self.model, record_id)
508
+ conditions = [self.model.id == record_id] + self._build_conditions(
509
+ filters=filters
510
+ )
511
+ return await self._get_one(conditions=conditions)
495
512
 
496
- async def get_by_id(self, record_id: Union[str, UUID]) -> Optional[ModelT]:
513
+ async def get_by_id(
514
+ self,
515
+ record_id: Union[str, UUID],
516
+ filters: Optional[Dict[str, Any]] = None,
517
+ ) -> Optional[ModelT]:
497
518
  """Alias de `get(id)` — nombre unificado con el repo Beanie
498
519
  (`get_by_id`) para que las lecturas por id sean portables entre ORMs."""
499
- return await self.get(record_id)
520
+ return await self.get(record_id, filters=filters)
500
521
 
501
522
  async def get_by_field(
502
523
  self, field_name: str, value: Any
@@ -509,11 +530,15 @@ class BaseRepository(Generic[ModelT]):
509
530
  self,
510
531
  record_id: Union[str, UUID],
511
532
  joins: Optional[List[str]] = None,
533
+ filters: Optional[Dict[str, Any]] = None,
512
534
  ) -> Optional[ModelT]:
513
- """Obtiene un registro por ID y carga relaciones dinámicamente."""
514
- return await self._get_one(
515
- conditions=[self.model.id == record_id], joins=joins
516
- )
535
+ """Obtiene un registro por ID y carga relaciones dinámicamente.
536
+
537
+ `filters` (opcional) — mismo scoping de seguridad que `get()`."""
538
+ conditions = [self.model.id == record_id]
539
+ if filters:
540
+ conditions.extend(self._build_conditions(filters=filters))
541
+ return await self._get_one(conditions=conditions, joins=joins)
517
542
 
518
543
  async def get_by_field_with_joins(
519
544
  self,
@@ -122,9 +122,10 @@ class BaseService(Generic[ModelT]):
122
122
  if joins is None:
123
123
  joins = kwargs.get("joins")
124
124
 
125
- obj = await self.repository.get_with_joins(id, joins=joins)
125
+ filters = self.get_filters()
126
+ obj = await self.repository.get_with_joins(id, joins=joins, filters=filters)
126
127
  if not obj:
127
- obj = await self.repository.get(id)
128
+ obj = await self.repository.get(id, filters=filters)
128
129
  if not obj:
129
130
  raise NotFoundException(f"id={id} no encontrado")
130
131
  return obj
@@ -227,6 +228,12 @@ class BaseService(Generic[ModelT]):
227
228
  return created
228
229
 
229
230
  async def update(self, id: str, data: BaseModel | Dict[str, Any]) -> ModelT:
231
+ # Scoping de seguridad ANTES de tocar nada — `repository.update`
232
+ # fetchea por PK cruda internamente (sin `get_filters()`), así que
233
+ # sin este check un id de otro tenant se podía editar directo.
234
+ scoped = await self.repository.get(id, filters=self.get_filters())
235
+ if not scoped:
236
+ raise NotFoundException(f"id={id} no encontrado")
230
237
  update_data = (
231
238
  data.model_dump(exclude_unset=True)
232
239
  if isinstance(data, BaseModel)
@@ -277,7 +284,7 @@ class BaseService(Generic[ModelT]):
277
284
  return True
278
285
 
279
286
  async def delete(self, id: str) -> bool:
280
- obj = await self.repository.get(id)
287
+ obj = await self.repository.get(id, filters=self.get_filters())
281
288
  if not obj:
282
289
  raise NotFoundException(message=f"id={id} no encontrado")
283
290
  return await self.apply_delete(obj)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: fastapi-basekit
3
- Version: 0.5.3
3
+ Version: 0.5.4
4
4
  Summary: Utilities and base classes for FastAPI async projects (Beanie, SQLAlchemy or SQLModel)
5
5
  Author-email: Jerson Moreno <jerson.ml820@hotmail.com>
6
6
  License: MIT
@@ -127,6 +127,7 @@ tests/test_generics_wiring.py
127
127
  tests/test_jwt_config_robustness.py
128
128
  tests/test_jwt_service.py
129
129
  tests/test_openapi_simplify.py
130
+ tests/test_owner_filter_helper.py
130
131
  tests/test_params_classification.py
131
132
  tests/test_params_coercion_matrix.py
132
133
  tests/test_params_coercion_unit.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "fastapi-basekit"
7
- version = "0.5.3"
7
+ version = "0.5.4"
8
8
  description = "Utilities and base classes for FastAPI async projects (Beanie, SQLAlchemy or SQLModel)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -242,3 +242,76 @@ async def test_scoped_filter_merges_with_user_filter(seeded):
242
242
  svc_t1 = ScopedItemService(repository=ItemRepo(), tenant="t1")
243
243
  items, total = await svc_t1.list(filters={"active": True}, count=50)
244
244
  assert {i.name for i in items} == {"Alpha"} # t1 + active
245
+
246
+
247
+ # ---------------------------------------------------------------------------
248
+ # get_filters scoping en retrieve/update/delete (IDOR — CLAUDE.md prometía
249
+ # esto ["Si necesitas un objeto por id con scope, usa `service.retrieve(id)`"]
250
+ # pero `retrieve`/`update`/`delete` llamaban `repository.get_by_id(id)`
251
+ # directo, bypasseando `get_filters()` igual que un `get_by_id` crudo. Un
252
+ # `ScopedItemService` (tenant t2) podía leer/editar/borrar un Item de t1
253
+ # solo con adivinar/enumerar su id. Fix: `retrieve`/`update`/`delete` ahora
254
+ # pasan `filters=self.get_filters()` a `repository.get_by_id`.
255
+ # ---------------------------------------------------------------------------
256
+
257
+ @pytest.mark.asyncio
258
+ async def test_retrieve_respects_scope_own_tenant_ok(seeded):
259
+ svc_t1 = ScopedItemService(repository=ItemRepo(), tenant="t1")
260
+ got = await svc_t1.retrieve(str(seeded[0].id)) # Alpha, tenant t1
261
+ assert got.name == "Alpha"
262
+
263
+
264
+ @pytest.mark.asyncio
265
+ async def test_retrieve_cross_tenant_id_is_not_found(seeded):
266
+ """El id de Gamma (t2) existe de verdad — pedido desde un service
267
+ scopeado a t1 debe comportarse como si no existiera (404), no leakear
268
+ el registro de otro tenant."""
269
+ svc_t1 = ScopedItemService(repository=ItemRepo(), tenant="t1")
270
+ with pytest.raises(NotFoundException):
271
+ await svc_t1.retrieve(str(seeded[2].id)) # Gamma, tenant t2
272
+
273
+ # El mismo id SÍ resuelve para el tenant dueño.
274
+ svc_t2 = ScopedItemService(repository=ItemRepo(), tenant="t2")
275
+ got = await svc_t2.retrieve(str(seeded[2].id))
276
+ assert got.name == "Gamma"
277
+
278
+
279
+ @pytest.mark.asyncio
280
+ async def test_update_cross_tenant_id_is_not_found_and_not_modified(seeded):
281
+ svc_t1 = ScopedItemService(repository=ItemRepo(), tenant="t1")
282
+ with pytest.raises(NotFoundException):
283
+ await svc_t1.update(str(seeded[2].id), ItemUpdate(qty=999))
284
+
285
+ # No se modificó — lo confirma el dueño real (t2).
286
+ svc_t2 = ScopedItemService(repository=ItemRepo(), tenant="t2")
287
+ unchanged = await svc_t2.retrieve(str(seeded[2].id))
288
+ assert unchanged.qty == 9
289
+
290
+
291
+ @pytest.mark.asyncio
292
+ async def test_delete_cross_tenant_id_is_not_found_and_not_deleted(seeded):
293
+ svc_t1 = ScopedItemService(repository=ItemRepo(), tenant="t1")
294
+ with pytest.raises(NotFoundException):
295
+ await svc_t1.delete(str(seeded[2].id))
296
+
297
+ # Sigue existiendo — lo confirma el dueño real (t2).
298
+ svc_t2 = ScopedItemService(repository=ItemRepo(), tenant="t2")
299
+ still_there = await svc_t2.retrieve(str(seeded[2].id))
300
+ assert still_there.name == "Gamma"
301
+
302
+
303
+ @pytest.mark.asyncio
304
+ async def test_unscoped_service_retrieve_update_delete_unaffected(seeded):
305
+ """Retrocompatibilidad: un service SIN override de `get_filters` (el
306
+ caso común, `get_filters` devuelve `{}`) sigue viendo/editando/borrando
307
+ cualquier id — cero cambio de comportamiento para el 99% de los
308
+ services existentes que no scopean por owner."""
309
+ svc = ItemService(repository=ItemRepo())
310
+ got = await svc.retrieve(str(seeded[2].id)) # Gamma, sin scoping
311
+ assert got.name == "Gamma"
312
+
313
+ updated = await svc.update(str(seeded[2].id), ItemUpdate(qty=42))
314
+ assert updated.qty == 42
315
+
316
+ res = await svc.delete(str(seeded[2].id))
317
+ assert res == "deleted"
@@ -0,0 +1,65 @@
1
+ """`BaseService.owner_filter()` (Beanie) — helper que decide la clave Mongo
2
+ correcta para un filtro de ownership sobre un campo `Link`, según si la
3
+ query va a correr agregada (`list()`, con `fetch_links=True` → Beanie la
4
+ reescribe a `$lookup`, clave post-lookup `"<field>._id"`) o plana
5
+ (`retrieve`/`update`/`delete` vía `get_by_id`, que SIEMPRE escopea con
6
+ `fetch_links=False` → DBRef intacto, clave `"<field>.$id"`).
7
+
8
+ Caso real que motivó el helper (pulbot-backend, `ToolService`, 2026-08-11):
9
+ un service con `Link[User]` usaba la clave equivocada en `retrieve`/
10
+ `update`/`delete` (la misma que `list()`) — no tiraba error, devolvía 0
11
+ resultados en silencio → 404 en el delete de un recurso propio. Verificado
12
+ contra Mongo real (pulbot-backend, docker) tras el fix: 72/72 tests de
13
+ `app/tests/e2e/tool*` en verde.
14
+
15
+ Nota: este test es de LÓGICA PURA (sin DB) a propósito — `mongomock_motor`
16
+ (el mock que usa el resto de esta suite) no soporta queries dot-notation
17
+ sobre `DBRef` (`{"owner.$id": ...}` da 0 matches incluso contra el mismo
18
+ documento que lo tiene), así que un test contra mongomock de la mitad
19
+ `.$id` de este helper daría un falso negativo. La corrección de esa mitad
20
+ ya está verificada contra Mongo real en pulbot-backend; acá solo
21
+ caracterizamos que `owner_filter()` arma la clave correcta por `action`.
22
+ """
23
+
24
+ from types import SimpleNamespace
25
+
26
+ from fastapi_basekit.aio.beanie.service.base import BaseService
27
+
28
+
29
+ class _FakeRepo:
30
+ pass
31
+
32
+
33
+ def _service(action: str) -> BaseService:
34
+ svc = BaseService(repository=_FakeRepo(), request=None)
35
+ svc.action = action
36
+ return svc
37
+
38
+
39
+ def test_owner_filter_uses_post_lookup_key_for_list():
40
+ svc = _service("list")
41
+ assert svc.owner_filter("user", "u1") == {"user._id": "u1"}
42
+
43
+
44
+ def test_owner_filter_uses_dbref_key_for_retrieve():
45
+ svc = _service("retrieve")
46
+ assert svc.owner_filter("user", "u1") == {"user.$id": "u1"}
47
+
48
+
49
+ def test_owner_filter_uses_dbref_key_for_update():
50
+ svc = _service("update")
51
+ assert svc.owner_filter("user", "u1") == {"user.$id": "u1"}
52
+
53
+
54
+ def test_owner_filter_uses_dbref_key_for_delete():
55
+ svc = _service("delete")
56
+ assert svc.owner_filter("user", "u1") == {"user.$id": "u1"}
57
+
58
+
59
+ def test_owner_filter_defaults_to_dbref_key_for_custom_actions():
60
+ """Cualquier action que no sea "list" (ej. un endpoint custom que llama
61
+ `get_filters()` a mano) asume el camino no-agregado — es el lado
62
+ seguro: si algún día un action custom SÍ corre agregado, hay que optar
63
+ explícitamente pasando `"list"` o extendiendo el criterio, no al revés."""
64
+ svc = _service("approve")
65
+ assert svc.owner_filter("customer", "c1") == {"customer.$id": "c1"}
@@ -131,6 +131,79 @@ async def test_get_filters_scoping(seeded, session):
131
131
  assert {u.name for u in items} == {"Ana"}
132
132
 
133
133
 
134
+ # ---------------------------------------------------------------------------
135
+ # get_filters scoping en retrieve/update/delete (IDOR — mismo fix que en
136
+ # Beanie: `retrieve`/`update`/`delete` llamaban al repo por PK cruda,
137
+ # bypasseando `get_filters()`. Un service scopeado por `name="Ana"` podía
138
+ # leer/editar/borrar el registro de "Beto" solo con adivinar su id.)
139
+ # ---------------------------------------------------------------------------
140
+
141
+ def _ana_scoped_service(session):
142
+ class AnaScopedService(UserService):
143
+ def get_filters(self, filters=None):
144
+ filters = super().get_filters(filters)
145
+ filters["name"] = "Ana"
146
+ return filters
147
+
148
+ return AnaScopedService(repository=UserRepository(db=session), request=None)
149
+
150
+
151
+ @pytest.mark.asyncio
152
+ async def test_retrieve_respects_scope_own_record_ok(seeded, session):
153
+ svc = _ana_scoped_service(session)
154
+ got = await svc.retrieve(str(seeded[0].id)) # Ana
155
+ assert got.name == "Ana"
156
+
157
+
158
+ @pytest.mark.asyncio
159
+ async def test_retrieve_out_of_scope_id_is_not_found(seeded, session, service):
160
+ svc = _ana_scoped_service(session)
161
+ with pytest.raises(NotFoundException):
162
+ await svc.retrieve(str(seeded[1].id)) # Beto, existe pero no matchea el scope
163
+
164
+ # El mismo id sí resuelve para un service sin ese scope.
165
+ got = await service.retrieve(str(seeded[1].id))
166
+ assert got.name == "Beto"
167
+
168
+
169
+ @pytest.mark.asyncio
170
+ async def test_update_out_of_scope_id_is_not_found_and_not_modified(
171
+ seeded, session, service
172
+ ):
173
+ svc = _ana_scoped_service(session)
174
+ with pytest.raises(NotFoundException):
175
+ await svc.update(str(seeded[1].id), UserUpdateSchema(name="Hijacked"))
176
+
177
+ unchanged = await service.retrieve(str(seeded[1].id))
178
+ assert unchanged.name == "Beto"
179
+
180
+
181
+ @pytest.mark.asyncio
182
+ async def test_delete_out_of_scope_id_is_not_found_and_not_deleted(
183
+ seeded, session, service
184
+ ):
185
+ svc = _ana_scoped_service(session)
186
+ with pytest.raises(NotFoundException):
187
+ await svc.delete(str(seeded[1].id))
188
+
189
+ still_there = await service.retrieve(str(seeded[1].id))
190
+ assert still_there.name == "Beto"
191
+
192
+
193
+ @pytest.mark.asyncio
194
+ async def test_unscoped_service_retrieve_update_delete_unaffected(seeded, service):
195
+ """Retrocompatibilidad: sin override de `get_filters` (el caso común),
196
+ cero cambio de comportamiento."""
197
+ got = await service.retrieve(str(seeded[1].id))
198
+ assert got.name == "Beto"
199
+
200
+ updated = await service.update(str(seeded[1].id), UserUpdateSchema(name="Beto2"))
201
+ assert updated.name == "Beto2"
202
+
203
+ ok = await service.delete(str(seeded[1].id))
204
+ assert ok is True
205
+
206
+
134
207
  # ---------------------------------------------------------------------------
135
208
  # Fix issue #7: aislamiento de mutable-defaults por instancia
136
209
  # ---------------------------------------------------------------------------
File without changes