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.
- tests/__init__.py +0 -0
- tests/conftest.py +285 -0
- tests/i9n/__init__.py +0 -0
- tests/i9n/test_acronym_route_name.py +16 -0
- tests/i9n/test_allow_anon.py +239 -0
- tests/i9n/test_apikey_generation.py +47 -0
- tests/i9n/test_authn_provider_integration.py +67 -0
- tests/i9n/test_bindings_integration.py +108 -0
- tests/i9n/test_bindings_modules.py +149 -0
- tests/i9n/test_bulk_docs_client.py +99 -0
- tests/i9n/test_core_access.py +164 -0
- tests/i9n/test_error_mappings.py +360 -0
- tests/i9n/test_field_spec_effects.py +117 -0
- tests/i9n/test_header_io_uvicorn.py +71 -0
- tests/i9n/test_healthz_methodz_hookz.py +203 -0
- tests/i9n/test_hook_ctx_v3_i9n.py +392 -0
- tests/i9n/test_hook_lifecycle.py +452 -0
- tests/i9n/test_iospec_attributes.py +368 -0
- tests/i9n/test_iospec_integration.py +181 -0
- tests/i9n/test_key_digest_uvicorn.py +151 -0
- tests/i9n/test_list_filters_optional.py +20 -0
- tests/i9n/test_mixins.py +534 -0
- tests/i9n/test_nested_path_schema_and_rpc.py +34 -0
- tests/i9n/test_nested_routing_depth.py +118 -0
- tests/i9n/test_op_ctx_alias_examples.py +62 -0
- tests/i9n/test_op_ctx_behavior.py +395 -0
- tests/i9n/test_op_ctx_core_crud_order.py +219 -0
- tests/i9n/test_openapi_clear_response_schema.py +35 -0
- tests/i9n/test_openapi_schema_examples_presence.py +81 -0
- tests/i9n/test_opspec_effects_i9n_test.py +193 -0
- tests/i9n/test_owner_tenant_policy.py +173 -0
- tests/i9n/test_request_extras.py +74 -0
- tests/i9n/test_request_extras_provider.py +27 -0
- tests/i9n/test_response_extras_provider.py +22 -0
- tests/i9n/test_rest_fallback_serialization.py +66 -0
- tests/i9n/test_rest_row_serialization.py +81 -0
- tests/i9n/test_rest_rpc_parity_v3.py +0 -0
- tests/i9n/test_row_result_serialization.py +84 -0
- tests/i9n/test_schema.py +45 -0
- tests/i9n/test_schema_ctx_attributes_integration.py +178 -0
- tests/i9n/test_schema_ctx_op_ctx_integration.py +88 -0
- tests/i9n/test_schema_ctx_spec_integration.py +209 -0
- tests/i9n/test_sqlite_attachments.py +36 -0
- tests/i9n/test_storage_spec_integration.py +126 -0
- tests/i9n/test_symmetry_parity.py +26 -0
- tests/i9n/test_v3_bulk_rest_endpoints.py +120 -0
- tests/i9n/test_v3_default_rest_ops.py +145 -0
- tests/i9n/test_v3_default_rpc_ops.py +234 -0
- tests/i9n/test_v3_opspec_attributes.py +272 -0
- tests/i9n/test_verb_alias_policy.py +57 -0
- tests/i9n/uvicorn_utils.py +43 -0
- tests/perf/__init__.py +0 -0
- tests/perf/test_collect_caching.py +42 -0
- tests/perf/test_hookz_performance.py +89 -0
- tests/perf/test_methodz_performance.py +99 -0
- tests/unit/__init__.py +0 -0
- tests/unit/decorators/test_alias_ctx_bindings.py +34 -0
- tests/unit/decorators/test_engine_ctx_bindings.py +57 -0
- tests/unit/decorators/test_hook_ctx_bindings.py +53 -0
- tests/unit/decorators/test_op_alias_bindings.py +39 -0
- tests/unit/decorators/test_op_ctx_bindings.py +82 -0
- tests/unit/decorators/test_response_ctx_bindings.py +32 -0
- tests/unit/decorators/test_schema_ctx_bindings.py +39 -0
- tests/unit/response_utils.py +142 -0
- tests/unit/runtime/atoms/test_emit_paired_post.py +27 -0
- tests/unit/runtime/atoms/test_emit_paired_pre.py +38 -0
- tests/unit/runtime/atoms/test_emit_readtime_alias.py +41 -0
- tests/unit/runtime/atoms/test_out_masking.py +76 -0
- tests/unit/runtime/atoms/test_refresh_demand.py +43 -0
- tests/unit/runtime/atoms/test_resolve_assemble.py +45 -0
- tests/unit/runtime/atoms/test_resolve_paired_gen.py +65 -0
- tests/unit/runtime/atoms/test_schema_collect_in.py +44 -0
- tests/unit/runtime/atoms/test_schema_collect_out.py +43 -0
- tests/unit/runtime/atoms/test_storage_to_stored.py +45 -0
- tests/unit/runtime/atoms/test_wire_build_in.py +13 -0
- tests/unit/runtime/atoms/test_wire_build_out.py +40 -0
- tests/unit/runtime/atoms/test_wire_dump.py +14 -0
- tests/unit/runtime/atoms/test_wire_validate_in.py +69 -0
- tests/unit/runtime/test_events_phases.py +14 -0
- tests/unit/test_acol_vcol_knobs.py +96 -0
- tests/unit/test_alias_ctx_op_alias_attributes.py +75 -0
- tests/unit/test_alias_ctx_op_attributes.py +61 -0
- tests/unit/test_api_level_set_auth.py +25 -0
- tests/unit/test_app_model_defaults.py +28 -0
- tests/unit/test_app_reexport.py +6 -0
- tests/unit/test_base_facade_initialize.py +71 -0
- tests/unit/test_build_list_params_spec_model.py +33 -0
- tests/unit/test_bulk_body_annotation.py +23 -0
- tests/unit/test_bulk_response_schema.py +153 -0
- tests/unit/test_colspec_map_isolation.py +19 -0
- tests/unit/test_column_collect_mixins.py +21 -0
- tests/unit/test_column_rest_rpc_results.py +298 -0
- tests/unit/test_column_table_orm_binding.py +51 -0
- tests/unit/test_config_dataclass_none.py +12 -0
- tests/unit/test_core_crud_bulk_ops.py +160 -0
- tests/unit/test_core_crud_default_ops.py +174 -0
- tests/unit/test_core_crud_methods.py +337 -0
- tests/unit/test_core_wrap_memoization.py +67 -0
- tests/unit/test_db_dependency.py +19 -0
- tests/unit/test_decorator_and_collect.py +47 -0
- tests/unit/test_default_tags.py +20 -0
- tests/unit/test_engine_spec_and_shortcuts.py +84 -0
- tests/unit/test_engine_usage_levels.py +36 -0
- tests/unit/test_field_spec_attrs.py +95 -0
- tests/unit/test_file_response.py +148 -0
- tests/unit/test_handler_step_qualname.py +30 -0
- tests/unit/test_hook_ctx_attributes.py +33 -0
- tests/unit/test_hook_ctx_binding.py +55 -0
- tests/unit/test_hookz_empty_phase.py +37 -0
- tests/unit/test_hybrid_session_run_sync.py +18 -0
- tests/unit/test_in_tx.py +16 -0
- tests/unit/test_include_models_base_prefix.py +29 -0
- tests/unit/test_initialize_cross_ddl.py +26 -0
- tests/unit/test_io_spec_attributes.py +171 -0
- tests/unit/test_iospec_attributes.py +90 -0
- tests/unit/test_iospec_effects.py +173 -0
- tests/unit/test_jsonrpc_id_example.py +9 -0
- tests/unit/test_jsonrpc_router_default_tag.py +10 -0
- tests/unit/test_kernel_invoke_ctx.py +14 -0
- tests/unit/test_kernel_opview_on_demand.py +42 -0
- tests/unit/test_kernel_plan_labels.py +40 -0
- tests/unit/test_kernelz_endpoint.py +65 -0
- tests/unit/test_make_column_shortcuts.py +80 -0
- tests/unit/test_mixins_sqlalchemy.py +13 -0
- tests/unit/test_op_alias.py +70 -0
- tests/unit/test_op_class_engine_binding.py +38 -0
- tests/unit/test_op_ctx_arity_paths.py +83 -0
- tests/unit/test_op_ctx_attributes.py +147 -0
- tests/unit/test_op_ctx_core_crud_integration.py +376 -0
- tests/unit/test_op_ctx_dynamic_attach.py +19 -0
- tests/unit/test_op_ctx_persist_options.py +75 -0
- tests/unit/test_opspec_effects.py +153 -0
- tests/unit/test_postgres_engine_errors.py +17 -0
- tests/unit/test_postgres_env_vars.py +17 -0
- tests/unit/test_relationship_alias_cols.py +98 -0
- tests/unit/test_request_body_schema.py +54 -0
- tests/unit/test_request_response_examples.py +169 -0
- tests/unit/test_resolver_precedence.py +49 -0
- tests/unit/test_response_alias_table_rpc.py +40 -0
- tests/unit/test_response_ctx_precedence.py +62 -0
- tests/unit/test_response_diagnostics_kernelz.py +81 -0
- tests/unit/test_response_html_jinja_behavior.py +116 -0
- tests/unit/test_response_parity.py +20 -0
- tests/unit/test_response_rest.py +91 -0
- tests/unit/test_response_rpc.py +88 -0
- tests/unit/test_response_template.py +30 -0
- tests/unit/test_response_uuid.py +58 -0
- tests/unit/test_rest_all_default_op_verbs.py +58 -0
- tests/unit/test_rest_bulk_delete_suppresses_clear.py +25 -0
- tests/unit/test_rest_no_schema_jsonable.py +68 -0
- tests/unit/test_rest_operation_id_uniqueness.py +36 -0
- tests/unit/test_rest_rpc_parity_default_ops.py +86 -0
- tests/unit/test_rest_rpc_prefixes.py +40 -0
- tests/unit/test_rest_rpc_symmetry.py +151 -0
- tests/unit/test_rpc_all_default_op_verbs.py +224 -0
- tests/unit/test_rpc_default_ops.py +111 -0
- tests/unit/test_schema_ctx_attributes.py +96 -0
- tests/unit/test_schema_ctx_plain_class.py +34 -0
- tests/unit/test_schema_spec_presence.py +36 -0
- tests/unit/test_schemas_binding.py +29 -0
- tests/unit/test_security_per_route.py +43 -0
- tests/unit/test_should_wire_canonical.py +62 -0
- tests/unit/test_spec_api.py +50 -0
- tests/unit/test_spec_app.py +28 -0
- tests/unit/test_spec_column.py +24 -0
- tests/unit/test_spec_engine.py +61 -0
- tests/unit/test_spec_field.py +16 -0
- tests/unit/test_spec_hook.py +35 -0
- tests/unit/test_spec_io.py +29 -0
- tests/unit/test_spec_op.py +31 -0
- tests/unit/test_spec_storage.py +21 -0
- tests/unit/test_spec_table.py +21 -0
- tests/unit/test_sqlite_attachments.py +57 -0
- tests/unit/test_storage_spec_attributes.py +78 -0
- tests/unit/test_sys_handler_crud.py +86 -0
- tests/unit/test_sys_run_rollback.py +42 -0
- tests/unit/test_sys_tx_async_begin.py +45 -0
- tests/unit/test_sys_tx_begin.py +49 -0
- tests/unit/test_sys_tx_commit.py +59 -0
- tests/unit/test_table_base_exports.py +22 -0
- tests/unit/test_table_collect_spec.py +41 -0
- tests/unit/test_table_columns_namespace.py +21 -0
- tests/unit/test_v3_favicon_endpoint.py +17 -0
- tests/unit/test_v3_healthz_endpoint.py +39 -0
- tests/unit/test_v3_op_alias.py +88 -0
- tests/unit/test_v3_op_ctx_attributes.py +99 -0
- tests/unit/test_v3_schemas_and_decorators.py +114 -0
- tests/unit/test_v3_storage_spec_attributes.py +249 -0
- tigrbl_tests-0.3.0.dist-info/METADATA +103 -0
- tigrbl_tests-0.3.0.dist-info/RECORD +192 -0
- tigrbl_tests-0.3.0.dist-info/WHEEL +4 -0
- 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
|
+
)
|