tigrbl-tests 0.3.0__py3-none-any.whl

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 (192) hide show
  1. tests/__init__.py +0 -0
  2. tests/conftest.py +285 -0
  3. tests/i9n/__init__.py +0 -0
  4. tests/i9n/test_acronym_route_name.py +16 -0
  5. tests/i9n/test_allow_anon.py +239 -0
  6. tests/i9n/test_apikey_generation.py +47 -0
  7. tests/i9n/test_authn_provider_integration.py +67 -0
  8. tests/i9n/test_bindings_integration.py +108 -0
  9. tests/i9n/test_bindings_modules.py +149 -0
  10. tests/i9n/test_bulk_docs_client.py +99 -0
  11. tests/i9n/test_core_access.py +164 -0
  12. tests/i9n/test_error_mappings.py +360 -0
  13. tests/i9n/test_field_spec_effects.py +117 -0
  14. tests/i9n/test_header_io_uvicorn.py +71 -0
  15. tests/i9n/test_healthz_methodz_hookz.py +203 -0
  16. tests/i9n/test_hook_ctx_v3_i9n.py +392 -0
  17. tests/i9n/test_hook_lifecycle.py +452 -0
  18. tests/i9n/test_iospec_attributes.py +368 -0
  19. tests/i9n/test_iospec_integration.py +181 -0
  20. tests/i9n/test_key_digest_uvicorn.py +151 -0
  21. tests/i9n/test_list_filters_optional.py +20 -0
  22. tests/i9n/test_mixins.py +534 -0
  23. tests/i9n/test_nested_path_schema_and_rpc.py +34 -0
  24. tests/i9n/test_nested_routing_depth.py +118 -0
  25. tests/i9n/test_op_ctx_alias_examples.py +62 -0
  26. tests/i9n/test_op_ctx_behavior.py +395 -0
  27. tests/i9n/test_op_ctx_core_crud_order.py +219 -0
  28. tests/i9n/test_openapi_clear_response_schema.py +35 -0
  29. tests/i9n/test_openapi_schema_examples_presence.py +81 -0
  30. tests/i9n/test_opspec_effects_i9n_test.py +193 -0
  31. tests/i9n/test_owner_tenant_policy.py +173 -0
  32. tests/i9n/test_request_extras.py +74 -0
  33. tests/i9n/test_request_extras_provider.py +27 -0
  34. tests/i9n/test_response_extras_provider.py +22 -0
  35. tests/i9n/test_rest_fallback_serialization.py +66 -0
  36. tests/i9n/test_rest_row_serialization.py +81 -0
  37. tests/i9n/test_rest_rpc_parity_v3.py +0 -0
  38. tests/i9n/test_row_result_serialization.py +84 -0
  39. tests/i9n/test_schema.py +45 -0
  40. tests/i9n/test_schema_ctx_attributes_integration.py +178 -0
  41. tests/i9n/test_schema_ctx_op_ctx_integration.py +88 -0
  42. tests/i9n/test_schema_ctx_spec_integration.py +209 -0
  43. tests/i9n/test_sqlite_attachments.py +36 -0
  44. tests/i9n/test_storage_spec_integration.py +126 -0
  45. tests/i9n/test_symmetry_parity.py +26 -0
  46. tests/i9n/test_v3_bulk_rest_endpoints.py +120 -0
  47. tests/i9n/test_v3_default_rest_ops.py +145 -0
  48. tests/i9n/test_v3_default_rpc_ops.py +234 -0
  49. tests/i9n/test_v3_opspec_attributes.py +272 -0
  50. tests/i9n/test_verb_alias_policy.py +57 -0
  51. tests/i9n/uvicorn_utils.py +43 -0
  52. tests/perf/__init__.py +0 -0
  53. tests/perf/test_collect_caching.py +42 -0
  54. tests/perf/test_hookz_performance.py +89 -0
  55. tests/perf/test_methodz_performance.py +99 -0
  56. tests/unit/__init__.py +0 -0
  57. tests/unit/decorators/test_alias_ctx_bindings.py +34 -0
  58. tests/unit/decorators/test_engine_ctx_bindings.py +57 -0
  59. tests/unit/decorators/test_hook_ctx_bindings.py +53 -0
  60. tests/unit/decorators/test_op_alias_bindings.py +39 -0
  61. tests/unit/decorators/test_op_ctx_bindings.py +82 -0
  62. tests/unit/decorators/test_response_ctx_bindings.py +32 -0
  63. tests/unit/decorators/test_schema_ctx_bindings.py +39 -0
  64. tests/unit/response_utils.py +142 -0
  65. tests/unit/runtime/atoms/test_emit_paired_post.py +27 -0
  66. tests/unit/runtime/atoms/test_emit_paired_pre.py +38 -0
  67. tests/unit/runtime/atoms/test_emit_readtime_alias.py +41 -0
  68. tests/unit/runtime/atoms/test_out_masking.py +76 -0
  69. tests/unit/runtime/atoms/test_refresh_demand.py +43 -0
  70. tests/unit/runtime/atoms/test_resolve_assemble.py +45 -0
  71. tests/unit/runtime/atoms/test_resolve_paired_gen.py +65 -0
  72. tests/unit/runtime/atoms/test_schema_collect_in.py +44 -0
  73. tests/unit/runtime/atoms/test_schema_collect_out.py +43 -0
  74. tests/unit/runtime/atoms/test_storage_to_stored.py +45 -0
  75. tests/unit/runtime/atoms/test_wire_build_in.py +13 -0
  76. tests/unit/runtime/atoms/test_wire_build_out.py +40 -0
  77. tests/unit/runtime/atoms/test_wire_dump.py +14 -0
  78. tests/unit/runtime/atoms/test_wire_validate_in.py +69 -0
  79. tests/unit/runtime/test_events_phases.py +14 -0
  80. tests/unit/test_acol_vcol_knobs.py +96 -0
  81. tests/unit/test_alias_ctx_op_alias_attributes.py +75 -0
  82. tests/unit/test_alias_ctx_op_attributes.py +61 -0
  83. tests/unit/test_api_level_set_auth.py +25 -0
  84. tests/unit/test_app_model_defaults.py +28 -0
  85. tests/unit/test_app_reexport.py +6 -0
  86. tests/unit/test_base_facade_initialize.py +71 -0
  87. tests/unit/test_build_list_params_spec_model.py +33 -0
  88. tests/unit/test_bulk_body_annotation.py +23 -0
  89. tests/unit/test_bulk_response_schema.py +153 -0
  90. tests/unit/test_colspec_map_isolation.py +19 -0
  91. tests/unit/test_column_collect_mixins.py +21 -0
  92. tests/unit/test_column_rest_rpc_results.py +298 -0
  93. tests/unit/test_column_table_orm_binding.py +51 -0
  94. tests/unit/test_config_dataclass_none.py +12 -0
  95. tests/unit/test_core_crud_bulk_ops.py +160 -0
  96. tests/unit/test_core_crud_default_ops.py +174 -0
  97. tests/unit/test_core_crud_methods.py +337 -0
  98. tests/unit/test_core_wrap_memoization.py +67 -0
  99. tests/unit/test_db_dependency.py +19 -0
  100. tests/unit/test_decorator_and_collect.py +47 -0
  101. tests/unit/test_default_tags.py +20 -0
  102. tests/unit/test_engine_spec_and_shortcuts.py +84 -0
  103. tests/unit/test_engine_usage_levels.py +36 -0
  104. tests/unit/test_field_spec_attrs.py +95 -0
  105. tests/unit/test_file_response.py +148 -0
  106. tests/unit/test_handler_step_qualname.py +30 -0
  107. tests/unit/test_hook_ctx_attributes.py +33 -0
  108. tests/unit/test_hook_ctx_binding.py +55 -0
  109. tests/unit/test_hookz_empty_phase.py +37 -0
  110. tests/unit/test_hybrid_session_run_sync.py +18 -0
  111. tests/unit/test_in_tx.py +16 -0
  112. tests/unit/test_include_models_base_prefix.py +29 -0
  113. tests/unit/test_initialize_cross_ddl.py +26 -0
  114. tests/unit/test_io_spec_attributes.py +171 -0
  115. tests/unit/test_iospec_attributes.py +90 -0
  116. tests/unit/test_iospec_effects.py +173 -0
  117. tests/unit/test_jsonrpc_id_example.py +9 -0
  118. tests/unit/test_jsonrpc_router_default_tag.py +10 -0
  119. tests/unit/test_kernel_invoke_ctx.py +14 -0
  120. tests/unit/test_kernel_opview_on_demand.py +42 -0
  121. tests/unit/test_kernel_plan_labels.py +40 -0
  122. tests/unit/test_kernelz_endpoint.py +65 -0
  123. tests/unit/test_make_column_shortcuts.py +80 -0
  124. tests/unit/test_mixins_sqlalchemy.py +13 -0
  125. tests/unit/test_op_alias.py +70 -0
  126. tests/unit/test_op_class_engine_binding.py +38 -0
  127. tests/unit/test_op_ctx_arity_paths.py +83 -0
  128. tests/unit/test_op_ctx_attributes.py +147 -0
  129. tests/unit/test_op_ctx_core_crud_integration.py +376 -0
  130. tests/unit/test_op_ctx_dynamic_attach.py +19 -0
  131. tests/unit/test_op_ctx_persist_options.py +75 -0
  132. tests/unit/test_opspec_effects.py +153 -0
  133. tests/unit/test_postgres_engine_errors.py +17 -0
  134. tests/unit/test_postgres_env_vars.py +17 -0
  135. tests/unit/test_relationship_alias_cols.py +98 -0
  136. tests/unit/test_request_body_schema.py +54 -0
  137. tests/unit/test_request_response_examples.py +169 -0
  138. tests/unit/test_resolver_precedence.py +49 -0
  139. tests/unit/test_response_alias_table_rpc.py +40 -0
  140. tests/unit/test_response_ctx_precedence.py +62 -0
  141. tests/unit/test_response_diagnostics_kernelz.py +81 -0
  142. tests/unit/test_response_html_jinja_behavior.py +116 -0
  143. tests/unit/test_response_parity.py +20 -0
  144. tests/unit/test_response_rest.py +91 -0
  145. tests/unit/test_response_rpc.py +88 -0
  146. tests/unit/test_response_template.py +30 -0
  147. tests/unit/test_response_uuid.py +58 -0
  148. tests/unit/test_rest_all_default_op_verbs.py +58 -0
  149. tests/unit/test_rest_bulk_delete_suppresses_clear.py +25 -0
  150. tests/unit/test_rest_no_schema_jsonable.py +68 -0
  151. tests/unit/test_rest_operation_id_uniqueness.py +36 -0
  152. tests/unit/test_rest_rpc_parity_default_ops.py +86 -0
  153. tests/unit/test_rest_rpc_prefixes.py +40 -0
  154. tests/unit/test_rest_rpc_symmetry.py +151 -0
  155. tests/unit/test_rpc_all_default_op_verbs.py +224 -0
  156. tests/unit/test_rpc_default_ops.py +111 -0
  157. tests/unit/test_schema_ctx_attributes.py +96 -0
  158. tests/unit/test_schema_ctx_plain_class.py +34 -0
  159. tests/unit/test_schema_spec_presence.py +36 -0
  160. tests/unit/test_schemas_binding.py +29 -0
  161. tests/unit/test_security_per_route.py +43 -0
  162. tests/unit/test_should_wire_canonical.py +62 -0
  163. tests/unit/test_spec_api.py +50 -0
  164. tests/unit/test_spec_app.py +28 -0
  165. tests/unit/test_spec_column.py +24 -0
  166. tests/unit/test_spec_engine.py +61 -0
  167. tests/unit/test_spec_field.py +16 -0
  168. tests/unit/test_spec_hook.py +35 -0
  169. tests/unit/test_spec_io.py +29 -0
  170. tests/unit/test_spec_op.py +31 -0
  171. tests/unit/test_spec_storage.py +21 -0
  172. tests/unit/test_spec_table.py +21 -0
  173. tests/unit/test_sqlite_attachments.py +57 -0
  174. tests/unit/test_storage_spec_attributes.py +78 -0
  175. tests/unit/test_sys_handler_crud.py +86 -0
  176. tests/unit/test_sys_run_rollback.py +42 -0
  177. tests/unit/test_sys_tx_async_begin.py +45 -0
  178. tests/unit/test_sys_tx_begin.py +49 -0
  179. tests/unit/test_sys_tx_commit.py +59 -0
  180. tests/unit/test_table_base_exports.py +22 -0
  181. tests/unit/test_table_collect_spec.py +41 -0
  182. tests/unit/test_table_columns_namespace.py +21 -0
  183. tests/unit/test_v3_favicon_endpoint.py +17 -0
  184. tests/unit/test_v3_healthz_endpoint.py +39 -0
  185. tests/unit/test_v3_op_alias.py +88 -0
  186. tests/unit/test_v3_op_ctx_attributes.py +99 -0
  187. tests/unit/test_v3_schemas_and_decorators.py +114 -0
  188. tests/unit/test_v3_storage_spec_attributes.py +249 -0
  189. tigrbl_tests-0.3.0.dist-info/METADATA +103 -0
  190. tigrbl_tests-0.3.0.dist-info/RECORD +192 -0
  191. tigrbl_tests-0.3.0.dist-info/WHEEL +4 -0
  192. tigrbl_tests-0.3.0.dist-info/licenses/LICENSE +201 -0
tests/__init__.py ADDED
File without changes
tests/conftest.py ADDED
@@ -0,0 +1,285 @@
1
+ import pytest
2
+ import pytest_asyncio
3
+ from tigrbl import TigrblApp, Base
4
+ from tigrbl.types import App
5
+ from tigrbl.orm.mixins import BulkCapable, GUIDPk
6
+ from tigrbl.specs import F, IO, S, acol
7
+ from tigrbl.column.storage_spec import StorageTransform
8
+ from tigrbl.schema import builder as v3_builder
9
+ from tigrbl.runtime import kernel as runtime_kernel
10
+ from tigrbl.engine.shortcuts import mem
11
+ from tigrbl.engine import resolver as _resolver
12
+ from httpx import ASGITransport, AsyncClient
13
+ from sqlalchemy import Column, ForeignKey, Integer, String
14
+ from sqlalchemy.dialects.postgresql import UUID
15
+ from sqlalchemy.ext.asyncio import AsyncSession
16
+ from sqlalchemy.orm import Mapped, Session
17
+ from typing import AsyncIterator, Iterator
18
+ import asyncio
19
+
20
+
21
+ def _reset_tigrbl_state() -> None:
22
+ """Reset shared tigrbl state between test modules and tests."""
23
+ Base.metadata.clear()
24
+ v3_builder._SchemaCache.clear()
25
+ runtime_kernel._default_kernel = runtime_kernel.Kernel()
26
+
27
+
28
+ @pytest.fixture(scope="session")
29
+ def event_loop():
30
+ # pytest-asyncio < 0.21 compatibility pattern; adjust if you use the newer plugin configs
31
+ loop = asyncio.new_event_loop()
32
+ yield loop
33
+ pending = asyncio.all_tasks(loop)
34
+ for task in pending:
35
+ task.cancel()
36
+ if pending:
37
+ loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
38
+ loop.close()
39
+
40
+
41
+ @pytest.fixture(autouse=True)
42
+ def _reset_state():
43
+ """Ensure clean metadata and caches around each test."""
44
+ _reset_tigrbl_state()
45
+ yield
46
+ _reset_tigrbl_state()
47
+
48
+
49
+ def pytest_collect_file(file_path, parent):
50
+ if file_path.suffix == ".py" and file_path.name.startswith("test_"):
51
+ _reset_tigrbl_state()
52
+ return None
53
+
54
+
55
+ def pytest_addoption(parser):
56
+ """Add command line options for database mode."""
57
+ group = parser.getgroup("database")
58
+ group.addoption(
59
+ "--db-mode",
60
+ choices=["sync", "async"],
61
+ help="Database mode to test (sync or async). If not specified, tests both modes.",
62
+ )
63
+
64
+
65
+ def pytest_generate_tests(metafunc):
66
+ """Generate test parameters for db modes."""
67
+ if "db_mode" in metafunc.fixturenames:
68
+ db_mode_option = metafunc.config.getoption("--db-mode")
69
+ if db_mode_option:
70
+ # Run only the specified mode
71
+ metafunc.parametrize("db_mode", [db_mode_option])
72
+ else:
73
+ # Run both modes by default
74
+ metafunc.parametrize("db_mode", ["sync", "async"])
75
+
76
+
77
+ @pytest.fixture
78
+ def sync_db_session():
79
+ """Provide a synchronous in-memory SQLite engine and DB session factory."""
80
+ cfg = mem(async_=False)
81
+ _resolver.set_default(cfg)
82
+ prov = _resolver.resolve_provider()
83
+ engine, maker = prov.ensure()
84
+
85
+ def get_db() -> Iterator[Session]:
86
+ with maker() as session:
87
+ yield session
88
+
89
+ try:
90
+ yield engine, get_db
91
+ finally:
92
+ engine.dispose()
93
+ _resolver.set_default(None)
94
+
95
+
96
+ @pytest_asyncio.fixture
97
+ async def async_db_session():
98
+ """Provide an asynchronous in-memory SQLite engine and DB session factory."""
99
+ cfg = mem()
100
+ _resolver.set_default(cfg)
101
+ prov = _resolver.resolve_provider()
102
+ engine, maker = prov.ensure()
103
+
104
+ async def get_db() -> AsyncIterator[AsyncSession]:
105
+ async with maker() as session:
106
+ yield session
107
+
108
+ try:
109
+ yield engine, get_db
110
+ finally:
111
+ await engine.dispose()
112
+ _resolver.set_default(None)
113
+
114
+
115
+ @pytest.fixture
116
+ def create_test_api():
117
+ """Factory fixture to create Tigrbl instances for testing individual models."""
118
+
119
+ def _create_api(model_class):
120
+ """Create Tigrbl instance with a single model for testing."""
121
+ Base.metadata.clear()
122
+ api = TigrblApp(engine=mem(async_=False))
123
+ api.include_model(model_class)
124
+ api.initialize()
125
+ return api
126
+
127
+ return _create_api
128
+
129
+
130
+ @pytest_asyncio.fixture
131
+ async def create_test_api_async():
132
+ """Factory fixture to create async Tigrbl instances for testing individual models."""
133
+
134
+ def _create_api_async(model_class):
135
+ Base.metadata.clear()
136
+ api = TigrblApp(engine=mem())
137
+ api.include_model(model_class)
138
+ return api
139
+
140
+ return _create_api_async
141
+
142
+
143
+ @pytest.fixture
144
+ def test_models():
145
+ """Factory fixture to create test model classes."""
146
+
147
+ def _create_model(name, mixins=None, extra_fields=None):
148
+ """Create a test model class with specified mixins and fields."""
149
+ if mixins is None:
150
+ mixins = (GUIDPk,)
151
+
152
+ attrs = {
153
+ "__tablename__": f"test_{name.lower()}",
154
+ "name": Column(String, nullable=False),
155
+ }
156
+
157
+ if extra_fields:
158
+ attrs.update(extra_fields)
159
+
160
+ # Create the model class dynamically
161
+ model_class = type(f"Test{name}", (Base,) + mixins, attrs)
162
+ return model_class
163
+
164
+ return _create_model
165
+
166
+
167
+ @pytest_asyncio.fixture()
168
+ async def api_client(db_mode):
169
+ """Main fixture for integration tests with Tenant and Item models."""
170
+ Base.metadata.clear()
171
+
172
+ class Tenant(Base, GUIDPk):
173
+ __tablename__ = "tenants"
174
+ name = acol(
175
+ storage=S(type_=String, nullable=False),
176
+ field=F(py_type=str),
177
+ io=IO(
178
+ in_verbs=("create", "update", "replace"),
179
+ out_verbs=("read", "list"),
180
+ mutable_verbs=("create", "update", "replace"),
181
+ filter_ops=("eq",),
182
+ ),
183
+ )
184
+
185
+ class Item(Base, GUIDPk, BulkCapable):
186
+ __tablename__ = "items"
187
+ tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=False)
188
+ name = Column(String, nullable=False)
189
+
190
+ @classmethod
191
+ def __tigrbl_nested_paths__(cls):
192
+ return "/tenant/{tenant_id}/item"
193
+
194
+ fastapi_app = App()
195
+
196
+ if db_mode == "async":
197
+ api = TigrblApp(engine=mem())
198
+ api.include_models([Tenant, Item])
199
+ await api.initialize()
200
+
201
+ else:
202
+ api = TigrblApp(engine=mem(async_=False))
203
+ api.include_models([Tenant, Item])
204
+ api.initialize()
205
+
206
+ api.mount_jsonrpc()
207
+ fastapi_app.include_router(api.router)
208
+ transport = ASGITransport(app=fastapi_app)
209
+
210
+ client = AsyncClient(transport=transport, base_url="http://test")
211
+ return client, api, Item
212
+
213
+
214
+ @pytest.fixture
215
+ def sample_tenant_data():
216
+ """Sample tenant data for testing."""
217
+ return {"name": "test-tenant"}
218
+
219
+
220
+ @pytest.fixture
221
+ def sample_item_data():
222
+ """Sample item data for testing (requires tenant_id)."""
223
+
224
+ def _create_item_data(tenant_id):
225
+ return {"tenant_id": tenant_id, "name": "test-item"}
226
+
227
+ return _create_item_data
228
+
229
+
230
+ @pytest_asyncio.fixture()
231
+ async def api_client_v3():
232
+ Base.metadata.clear()
233
+
234
+ class Widget(Base):
235
+ __tablename__ = "widgets"
236
+ __allow_unmapped__ = True
237
+
238
+ id: Mapped[int] = acol(
239
+ storage=S(type_=Integer, primary_key=True, autoincrement=True)
240
+ )
241
+ name: Mapped[str] = acol(
242
+ storage=S(type_=String, nullable=False, index=True),
243
+ field=F(required_in=("create",)),
244
+ io=IO(
245
+ in_verbs=("create", "update"),
246
+ out_verbs=("read", "list"),
247
+ ),
248
+ )
249
+ age: Mapped[int] = acol(
250
+ storage=S(type_=Integer, nullable=False, default=5),
251
+ io=IO(
252
+ in_verbs=("create", "update"),
253
+ out_verbs=("read", "list"),
254
+ ),
255
+ )
256
+ secret: Mapped[str] = acol(
257
+ storage=S(
258
+ type_=String,
259
+ nullable=False,
260
+ transform=StorageTransform(to_stored=lambda v, ctx: v.upper()),
261
+ ),
262
+ field=F(required_in=("create",)),
263
+ io=IO(in_verbs=("create",), out_verbs=("read",)),
264
+ )
265
+
266
+ __tigrbl_cols__ = {
267
+ "id": id,
268
+ "name": name,
269
+ "age": age,
270
+ "secret": secret,
271
+ }
272
+
273
+ cfg = mem()
274
+ fastapi_app = App()
275
+ api = TigrblApp(engine=cfg)
276
+ api.include_model(Widget, prefix="")
277
+ api.mount_jsonrpc()
278
+ api.attach_diagnostics()
279
+ await api.initialize()
280
+ prov = _resolver.resolve_provider()
281
+ _, session_maker = prov.ensure()
282
+ fastapi_app.include_router(api.router)
283
+ transport = ASGITransport(app=fastapi_app)
284
+ client = AsyncClient(transport=transport, base_url="http://test")
285
+ return client, api, Widget, session_maker
tests/i9n/__init__.py ADDED
File without changes
@@ -0,0 +1,16 @@
1
+ import pytest
2
+ from tigrbl import Base
3
+ from tigrbl.orm.mixins import GUIDPk
4
+ from tigrbl.types import Column, String
5
+
6
+
7
+ @pytest.mark.i9n
8
+ def test_acronym_model_route(create_test_api):
9
+ class GPGKey(Base, GUIDPk):
10
+ __tablename__ = "gpg_keys"
11
+ key = Column(String, nullable=False)
12
+
13
+ api = create_test_api(GPGKey)
14
+ paths = {route.path for route in api.router.routes}
15
+ assert "/gpgkey" in paths
16
+ assert all("g_p_g_key" not in p for p in paths)
@@ -0,0 +1,239 @@
1
+ from fastapi.testclient import TestClient
2
+ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
3
+ from tigrbl.engine import resolver as _resolver
4
+ from tigrbl.engine.shortcuts import mem
5
+ from sqlalchemy.orm import sessionmaker
6
+
7
+ from tigrbl import TigrblApp
8
+ from tigrbl.orm.mixins import GUIDPk
9
+ from tigrbl.orm.tables import Base
10
+ from tigrbl.config.constants import TIGRBL_AUTH_CONTEXT_ATTR
11
+ from tigrbl.types import (
12
+ AllowAnonProvider,
13
+ App,
14
+ AuthNProvider,
15
+ Column,
16
+ ForeignKey,
17
+ HTTPException,
18
+ PgUUID,
19
+ Request,
20
+ Security,
21
+ String,
22
+ uuid4,
23
+ )
24
+
25
+
26
+ class DummyAuth(AuthNProvider):
27
+ async def get_principal(
28
+ self,
29
+ request: Request,
30
+ creds: HTTPAuthorizationCredentials | None = Security(
31
+ HTTPBearer(auto_error=False)
32
+ ),
33
+ ):
34
+ if creds is None:
35
+ if request.method == "GET":
36
+ return None
37
+ raise HTTPException(status_code=409)
38
+ if creds.credentials != "secret":
39
+ raise HTTPException(status_code=401)
40
+ principal = {"sub": "user"}
41
+ setattr(request.state, TIGRBL_AUTH_CONTEXT_ATTR, principal)
42
+ return principal
43
+
44
+
45
+ def _build_client():
46
+ Base.metadata.clear()
47
+
48
+ class Tenant(Base, GUIDPk):
49
+ __tablename__ = "tenants"
50
+ name = Column(String, nullable=False)
51
+
52
+ class Item(Base, GUIDPk):
53
+ __tablename__ = "items"
54
+ tenant_id = Column(
55
+ PgUUID(as_uuid=True), ForeignKey("tenants.id"), nullable=False
56
+ )
57
+ name = Column(String, nullable=False)
58
+
59
+ @classmethod
60
+ def __tigrbl_allow_anon__(cls):
61
+ return {"list", "read"}
62
+
63
+ cfg = mem(async_=False)
64
+ auth = DummyAuth()
65
+ api = TigrblApp(engine=cfg)
66
+ api.set_auth(authn=auth.get_principal)
67
+ api.include_models([Tenant, Item])
68
+ api.initialize()
69
+ app = App()
70
+ app.include_router(api.router)
71
+ prov = _resolver.resolve_provider()
72
+ engine, maker = prov.ensure()
73
+ SessionLocal = sessionmaker(bind=engine, expire_on_commit=False)
74
+ client = TestClient(app)
75
+ return client, SessionLocal, Tenant, Item
76
+
77
+
78
+ def _build_client_attr():
79
+ Base.metadata.clear()
80
+
81
+ class Tenant(Base, GUIDPk):
82
+ __tablename__ = "tenants"
83
+ name = Column(String, nullable=False)
84
+
85
+ class Item(Base, GUIDPk):
86
+ __tablename__ = "items"
87
+ tenant_id = Column(
88
+ PgUUID(as_uuid=True), ForeignKey("tenants.id"), nullable=False
89
+ )
90
+ name = Column(String, nullable=False)
91
+
92
+ __tigrbl_allow_anon__ = {"list", "read"}
93
+
94
+ cfg = mem(async_=False)
95
+ auth = DummyAuth()
96
+ api = TigrblApp(engine=cfg)
97
+ api.set_auth(authn=auth.get_principal)
98
+ api.include_models([Tenant, Item])
99
+ api.initialize()
100
+ app = App()
101
+ app.include_router(api.router)
102
+ prov = _resolver.resolve_provider()
103
+ engine, maker = prov.ensure()
104
+ SessionLocal = sessionmaker(bind=engine, expire_on_commit=False)
105
+ client = TestClient(app)
106
+ return client, SessionLocal, Tenant, Item
107
+
108
+
109
+ def test_allow_anon_list_and_read():
110
+ client, SessionLocal, Tenant, Item = _build_client()
111
+ with SessionLocal() as db:
112
+ tenant = Tenant(id=uuid4(), name="acme")
113
+ db.add(tenant)
114
+ db.commit()
115
+ db.refresh(tenant)
116
+ item = Item(id=uuid4(), tenant_id=tenant.id, name="thing")
117
+ db.add(item)
118
+ db.commit()
119
+ db.refresh(item)
120
+ tid = str(tenant.id)
121
+ iid = str(item.id)
122
+ assert client.get("/item").status_code == 200
123
+ assert client.get(f"/item/{iid}").status_code == 200
124
+ # Requests without credentials are rejected for non-whitelisted routes.
125
+ payload = {"id": str(uuid4()), "tenant_id": tid, "name": "new"}
126
+ assert client.post("/item", json=payload).status_code == 409
127
+
128
+
129
+ def test_openapi_marks_anon_and_protected_routes():
130
+ client, SessionLocal, Tenant, Item = _build_client()
131
+ spec = client.get("/openapi.json").json()
132
+ anon_op = spec["paths"]["/item"]["get"].get("security")
133
+ protected_op = spec["paths"]["/item"]["post"].get("security")
134
+ assert anon_op in (None, [])
135
+ assert protected_op == [{"HTTPBearer": []}]
136
+ assert "HTTPBearer" in spec["components"]["securitySchemes"]
137
+
138
+
139
+ def _build_client_create_noauth():
140
+ Base.metadata.clear()
141
+
142
+ class Tenant(Base, GUIDPk):
143
+ __tablename__ = "tenants"
144
+ name = Column(String, nullable=False)
145
+
146
+ class Item(Base, GUIDPk, AllowAnonProvider):
147
+ __tablename__ = "items"
148
+ tenant_id = Column(
149
+ PgUUID(as_uuid=True), ForeignKey("tenants.id"), nullable=False
150
+ )
151
+ name = Column(String, nullable=False)
152
+
153
+ @classmethod
154
+ def __tigrbl_allow_anon__(cls):
155
+ return {"create", "bulk_create"}
156
+
157
+ cfg = mem(async_=False)
158
+ api = TigrblApp(engine=cfg)
159
+ api.include_models([Tenant, Item])
160
+ api.initialize()
161
+ app = App()
162
+ app.include_router(api.router)
163
+ prov = _resolver.resolve_provider()
164
+ engine, maker = prov.ensure()
165
+ SessionLocal = sessionmaker(bind=engine, expire_on_commit=False)
166
+ client = TestClient(app)
167
+ return client, SessionLocal, Tenant, Item
168
+
169
+
170
+ def _build_client_create_attr_noauth():
171
+ Base.metadata.clear()
172
+
173
+ class Tenant(Base, GUIDPk):
174
+ __tablename__ = "tenants"
175
+ name = Column(String, nullable=False)
176
+
177
+ class Item(Base, GUIDPk, AllowAnonProvider):
178
+ __tablename__ = "items"
179
+ tenant_id = Column(
180
+ PgUUID(as_uuid=True), ForeignKey("tenants.id"), nullable=False
181
+ )
182
+ name = Column(String, nullable=False)
183
+
184
+ __tigrbl_allow_anon__ = {"create", "bulk_create"}
185
+
186
+ cfg = mem(async_=False)
187
+ api = TigrblApp(engine=cfg)
188
+ api.include_models([Tenant, Item])
189
+ api.initialize()
190
+ app = App()
191
+ app.include_router(api.router)
192
+ prov = _resolver.resolve_provider()
193
+ engine, maker = prov.ensure()
194
+ SessionLocal = sessionmaker(bind=engine, expire_on_commit=False)
195
+ client = TestClient(app)
196
+ return client, SessionLocal, Tenant, Item
197
+
198
+
199
+ def test_allow_anon_create_method():
200
+ client, SessionLocal, Tenant, Item = _build_client_create_noauth()
201
+ with SessionLocal() as db:
202
+ tenant = Tenant(id=uuid4(), name="acme")
203
+ db.add(tenant)
204
+ db.commit()
205
+ db.refresh(tenant)
206
+ tid = str(tenant.id)
207
+ payload = {"id": str(uuid4()), "tenant_id": tid, "name": "one"}
208
+ assert client.post("/item", json=[payload]).status_code == 201
209
+
210
+
211
+ def test_allow_anon_create_attr_noauth():
212
+ client, SessionLocal, Tenant, Item = _build_client_create_attr_noauth()
213
+ with SessionLocal() as db:
214
+ tenant = Tenant(id=uuid4(), name="acme")
215
+ db.add(tenant)
216
+ db.commit()
217
+ db.refresh(tenant)
218
+ tid = str(tenant.id)
219
+ payload = {"id": str(uuid4()), "tenant_id": tid, "name": "one"}
220
+ assert client.post("/item", json=[payload]).status_code == 201
221
+
222
+
223
+ def test_allow_anon_list_and_read_attr():
224
+ client, SessionLocal, Tenant, Item = _build_client_attr()
225
+ with SessionLocal() as db:
226
+ tenant = Tenant(id=uuid4(), name="acme")
227
+ db.add(tenant)
228
+ db.commit()
229
+ db.refresh(tenant)
230
+ item = Item(id=uuid4(), tenant_id=tenant.id, name="thing")
231
+ db.add(item)
232
+ db.commit()
233
+ db.refresh(item)
234
+ tid = str(tenant.id)
235
+ iid = str(item.id)
236
+ assert client.get("/item").status_code == 200
237
+ assert client.get(f"/item/{iid}").status_code == 200
238
+ payload = {"id": str(uuid4()), "tenant_id": tid, "name": "new"}
239
+ assert client.post("/item", json=payload).status_code == 409
@@ -0,0 +1,47 @@
1
+ import pytest
2
+ from tigrbl.types import App, Mapped, String
3
+ from httpx import ASGITransport, AsyncClient
4
+
5
+ from tigrbl import TigrblApp
6
+ from tigrbl.orm.mixins import (
7
+ Created,
8
+ GUIDPk,
9
+ KeyDigest,
10
+ LastUsed,
11
+ ValidityWindow,
12
+ )
13
+ from tigrbl.orm.tables._base import Base
14
+ from tigrbl.specs import F, IO, S, acol
15
+
16
+
17
+ class ConcreteApiKey(Base, GUIDPk, Created, LastUsed, ValidityWindow, KeyDigest):
18
+ """Concrete table for testing API key generation."""
19
+
20
+ __abstract__ = False
21
+ __resource__ = "apikey"
22
+ __tablename__ = "apikeys_generation"
23
+
24
+ label: Mapped[str] = acol(
25
+ storage=S(String, nullable=False),
26
+ field=F(required_in=("create",), constraints={"max_length": 120}),
27
+ io=IO(in_verbs=("create",)),
28
+ )
29
+
30
+
31
+ @pytest.mark.i9n
32
+ @pytest.mark.asyncio
33
+ async def test_api_key_creation_requires_valid_payload(sync_db_session):
34
+ """Posting without required fields yields an unprocessable entity response."""
35
+ _, get_sync_db = sync_db_session
36
+
37
+ app = App()
38
+ api = TigrblApp(get_db=get_sync_db)
39
+ api.include_models([ConcreteApiKey])
40
+ api.initialize()
41
+ app.include_router(api.router)
42
+ transport = ASGITransport(app=app)
43
+
44
+ async with AsyncClient(transport=transport, base_url="http://test") as client:
45
+ res = await client.post("/apikey", json={})
46
+
47
+ assert res.status_code == 422
@@ -0,0 +1,67 @@
1
+ from tigrbl.types import HTTPException, Request, Security
2
+ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
3
+ from fastapi.testclient import TestClient
4
+ from tigrbl.engine.shortcuts import mem
5
+
6
+ from tigrbl import TigrblApp, Base, hook_ctx
7
+ from tigrbl.config.constants import TIGRBL_AUTH_CONTEXT_ATTR
8
+ from tigrbl.orm.mixins import GUIDPk
9
+ from tigrbl.types.authn_abc import AuthNProvider
10
+
11
+
12
+ class HookedAuth(AuthNProvider):
13
+ """Simple AuthN provider that records context via hooks."""
14
+
15
+ def __init__(self) -> None:
16
+ self.ctx_principal: dict | None = None
17
+
18
+ async def get_principal(
19
+ self,
20
+ request: Request,
21
+ creds: HTTPAuthorizationCredentials = Security(HTTPBearer()),
22
+ ) -> dict:
23
+ if creds.credentials != "secret":
24
+ raise HTTPException(status_code=401)
25
+ principal = {"sub": "user", "tid": "tenant"}
26
+ setattr(request.state, TIGRBL_AUTH_CONTEXT_ATTR, principal)
27
+ return principal
28
+
29
+
30
+ def _build_client_with_auth():
31
+ Base.metadata.clear()
32
+
33
+ auth = HookedAuth()
34
+
35
+ class Tenant(Base, GUIDPk):
36
+ __tablename__ = "tenants"
37
+
38
+ @hook_ctx(ops="create", phase="PRE_HANDLER")
39
+ async def capture(cls, ctx):
40
+ auth.ctx_principal = ctx.get("auth_context")
41
+
42
+ api = TigrblApp(engine=mem(async_=False))
43
+ api.set_auth(authn=auth.get_principal)
44
+ api.include_model(Tenant)
45
+ api.initialize()
46
+ return TestClient(api), auth
47
+
48
+
49
+ def test_authn_hooks_and_context_injection():
50
+ client, auth = _build_client_with_auth()
51
+
52
+ payload = {}
53
+ res = client.post(
54
+ "/tenant", json=payload, headers={"Authorization": "Bearer secret"}
55
+ )
56
+ assert res.status_code == 201
57
+ assert auth.ctx_principal == {"sub": "user", "tid": "tenant"}
58
+
59
+
60
+ def test_authn_unauthorized_errors():
61
+ client, _ = _build_client_with_auth()
62
+
63
+ assert client.get("/tenant").status_code == 403
64
+ assert (
65
+ client.get("/tenant", headers={"Authorization": "Bearer wrong"}).status_code
66
+ == 401
67
+ )