simple-module-feature-flags 0.0.34__tar.gz → 0.0.36__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.
- {simple_module_feature_flags-0.0.34 → simple_module_feature_flags-0.0.36}/PKG-INFO +4 -4
- simple_module_feature_flags-0.0.36/feature_flags/deps.py +41 -0
- {simple_module_feature_flags-0.0.34 → simple_module_feature_flags-0.0.36}/feature_flags/endpoints/api.py +15 -3
- {simple_module_feature_flags-0.0.34 → simple_module_feature_flags-0.0.36}/feature_flags/endpoints/views.py +14 -4
- {simple_module_feature_flags-0.0.34 → simple_module_feature_flags-0.0.36}/feature_flags/pages/Browse.tsx +1 -1
- {simple_module_feature_flags-0.0.34 → simple_module_feature_flags-0.0.36}/package.json +3 -3
- {simple_module_feature_flags-0.0.34 → simple_module_feature_flags-0.0.36}/pyproject.toml +4 -4
- simple_module_feature_flags-0.0.36/tests/conftest.py +25 -0
- {simple_module_feature_flags-0.0.34 → simple_module_feature_flags-0.0.36}/tests/test_feature_flags_api.py +2 -0
- simple_module_feature_flags-0.0.36/tests/test_feature_flags_tenancy.py +162 -0
- simple_module_feature_flags-0.0.34/feature_flags/deps.py +0 -27
- {simple_module_feature_flags-0.0.34 → simple_module_feature_flags-0.0.36}/.gitignore +0 -0
- {simple_module_feature_flags-0.0.34 → simple_module_feature_flags-0.0.36}/LICENSE +0 -0
- {simple_module_feature_flags-0.0.34 → simple_module_feature_flags-0.0.36}/README.md +0 -0
- {simple_module_feature_flags-0.0.34 → simple_module_feature_flags-0.0.36}/feature_flags/__init__.py +0 -0
- {simple_module_feature_flags-0.0.34 → simple_module_feature_flags-0.0.36}/feature_flags/constants.py +0 -0
- {simple_module_feature_flags-0.0.34 → simple_module_feature_flags-0.0.36}/feature_flags/contracts/__init__.py +0 -0
- {simple_module_feature_flags-0.0.34 → simple_module_feature_flags-0.0.36}/feature_flags/contracts/schemas.py +0 -0
- {simple_module_feature_flags-0.0.34 → simple_module_feature_flags-0.0.36}/feature_flags/endpoints/__init__.py +0 -0
- {simple_module_feature_flags-0.0.34 → simple_module_feature_flags-0.0.36}/feature_flags/locales/en.json +0 -0
- {simple_module_feature_flags-0.0.34 → simple_module_feature_flags-0.0.36}/feature_flags/models.py +0 -0
- {simple_module_feature_flags-0.0.34 → simple_module_feature_flags-0.0.36}/feature_flags/module.py +0 -0
- {simple_module_feature_flags-0.0.34 → simple_module_feature_flags-0.0.36}/feature_flags/pages/components/FlagTable.tsx +0 -0
- {simple_module_feature_flags-0.0.34 → simple_module_feature_flags-0.0.36}/feature_flags/pages/components/ScopeCard.tsx +0 -0
- {simple_module_feature_flags-0.0.34 → simple_module_feature_flags-0.0.36}/feature_flags/pages/components/TenantPicker.tsx +0 -0
- {simple_module_feature_flags-0.0.34 → simple_module_feature_flags-0.0.36}/feature_flags/pages/components/ToggleConfirmDialog.tsx +0 -0
- {simple_module_feature_flags-0.0.34 → simple_module_feature_flags-0.0.36}/feature_flags/pages/types.ts +0 -0
- {simple_module_feature_flags-0.0.34 → simple_module_feature_flags-0.0.36}/feature_flags/py.typed +0 -0
- {simple_module_feature_flags-0.0.34 → simple_module_feature_flags-0.0.36}/feature_flags/service.py +0 -0
- {simple_module_feature_flags-0.0.34 → simple_module_feature_flags-0.0.36}/tests/test_feature_flags_audit_links.py +0 -0
- {simple_module_feature_flags-0.0.34 → simple_module_feature_flags-0.0.36}/tests/test_feature_flags_menu_section.py +0 -0
- {simple_module_feature_flags-0.0.34 → simple_module_feature_flags-0.0.36}/tests/test_feature_flags_service.py +0 -0
- {simple_module_feature_flags-0.0.34 → simple_module_feature_flags-0.0.36}/tests/test_store_outage_fallback.py +0 -0
- {simple_module_feature_flags-0.0.34 → simple_module_feature_flags-0.0.36}/tsconfig.json +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: simple_module_feature_flags
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.36
|
|
4
4
|
Summary: Simple feature-flag module with per-tenant overrides and a consumer API for simple_module
|
|
5
5
|
Project-URL: Homepage, https://github.com/antosubash/simple_module_python
|
|
6
6
|
Project-URL: Repository, https://github.com/antosubash/simple_module_python
|
|
@@ -21,9 +21,9 @@ Classifier: Topic :: Internet :: WWW/HTTP
|
|
|
21
21
|
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
22
22
|
Classifier: Typing :: Typed
|
|
23
23
|
Requires-Python: >=3.12
|
|
24
|
-
Requires-Dist: simple-module-core==0.0.
|
|
25
|
-
Requires-Dist: simple-module-db==0.0.
|
|
26
|
-
Requires-Dist: simple-module-hosting==0.0.
|
|
24
|
+
Requires-Dist: simple-module-core==0.0.36
|
|
25
|
+
Requires-Dist: simple-module-db==0.0.36
|
|
26
|
+
Requires-Dist: simple-module-hosting==0.0.36
|
|
27
27
|
Description-Content-Type: text/markdown
|
|
28
28
|
|
|
29
29
|
# simple_module_feature_flags
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""FastAPI dependencies for the feature_flags module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
from fastapi import Depends, HTTPException, Request
|
|
8
|
+
from simple_module_core.feature_flags import FeatureFlagRegistry
|
|
9
|
+
from simple_module_core.tenancy import tenant_exists
|
|
10
|
+
from simple_module_db.deps import get_db
|
|
11
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
12
|
+
|
|
13
|
+
from feature_flags.service import FeatureFlagService
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def get_feature_flag_service(
|
|
17
|
+
db: AsyncSession = Depends(get_db),
|
|
18
|
+
) -> FeatureFlagService:
|
|
19
|
+
return FeatureFlagService(db)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def get_feature_flag_registry(request: Request) -> FeatureFlagRegistry:
|
|
23
|
+
"""Return the process-wide FeatureFlagRegistry owned by the framework."""
|
|
24
|
+
return request.app.state.sm.feature_flags
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def require_known_tenant(request: Request, tenant_id: str | None = None) -> None:
|
|
28
|
+
"""404 when a tenant-scope screen names a tenant that does not exist.
|
|
29
|
+
|
|
30
|
+
Without it a typo in the URL persists an override for a tenant nobody has,
|
|
31
|
+
which nothing ever reads. The lookup goes through core's ``tenant_exists``
|
|
32
|
+
(``app.state.tenant_exists``, published by the ``tenants`` module), so this
|
|
33
|
+
module does not depend on ``tenants``; with none installed any id is
|
|
34
|
+
accepted, as before. No id (system scope) is always fine.
|
|
35
|
+
"""
|
|
36
|
+
if tenant_id and await tenant_exists(request.app, tenant_id) is False:
|
|
37
|
+
raise HTTPException(status_code=404, detail="Tenant not found")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
FeatureFlagServiceDep = Annotated[FeatureFlagService, Depends(get_feature_flag_service)]
|
|
41
|
+
FeatureFlagRegistryDep = Annotated[FeatureFlagRegistry, Depends(get_feature_flag_registry)]
|
|
@@ -12,7 +12,11 @@ from feature_flags.constants import (
|
|
|
12
12
|
SCOPE_TENANT,
|
|
13
13
|
)
|
|
14
14
|
from feature_flags.contracts.schemas import FeatureFlagView, ToggleRequest
|
|
15
|
-
from feature_flags.deps import
|
|
15
|
+
from feature_flags.deps import (
|
|
16
|
+
FeatureFlagRegistryDep,
|
|
17
|
+
FeatureFlagServiceDep,
|
|
18
|
+
require_known_tenant,
|
|
19
|
+
)
|
|
16
20
|
|
|
17
21
|
router = APIRouter()
|
|
18
22
|
|
|
@@ -87,7 +91,10 @@ async def clear_override(
|
|
|
87
91
|
@router.get(
|
|
88
92
|
"/tenant/{tenant_id}",
|
|
89
93
|
response_model=list[FeatureFlagView],
|
|
90
|
-
dependencies=[
|
|
94
|
+
dependencies=[
|
|
95
|
+
Depends(RequiresPermission(PERM_FEATURE_FLAGS_VIEW)),
|
|
96
|
+
Depends(require_known_tenant),
|
|
97
|
+
],
|
|
91
98
|
)
|
|
92
99
|
async def list_flags_for_tenant(
|
|
93
100
|
tenant_id: str,
|
|
@@ -100,7 +107,10 @@ async def list_flags_for_tenant(
|
|
|
100
107
|
@router.put(
|
|
101
108
|
"/tenant/{tenant_id}/{name}",
|
|
102
109
|
response_model=FeatureFlagView,
|
|
103
|
-
dependencies=[
|
|
110
|
+
dependencies=[
|
|
111
|
+
Depends(RequiresPermission(PERM_FEATURE_FLAGS_MANAGE)),
|
|
112
|
+
Depends(require_known_tenant),
|
|
113
|
+
],
|
|
104
114
|
)
|
|
105
115
|
async def set_tenant_override(
|
|
106
116
|
tenant_id: str,
|
|
@@ -118,6 +128,8 @@ async def set_tenant_override(
|
|
|
118
128
|
return view
|
|
119
129
|
|
|
120
130
|
|
|
131
|
+
# Clearing is not validated: an override left behind for a tenant that has
|
|
132
|
+
# since gone must stay removable.
|
|
121
133
|
@router.delete(
|
|
122
134
|
"/tenant/{tenant_id}/{name}",
|
|
123
135
|
status_code=204,
|
|
@@ -9,9 +9,9 @@ frontend doesn't need separate routes per scope.
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
from fastapi import APIRouter, Depends, Request
|
|
12
|
-
from inertia import InertiaResponse
|
|
13
12
|
from simple_module_hosting.inertia_deps import InertiaDep
|
|
14
13
|
from simple_module_hosting.permissions import RequiresPermission
|
|
14
|
+
from simple_module_inertia import InertiaResponse
|
|
15
15
|
from starlette.responses import RedirectResponse
|
|
16
16
|
|
|
17
17
|
from feature_flags.constants import (
|
|
@@ -26,7 +26,11 @@ from feature_flags.constants import (
|
|
|
26
26
|
SCOPE_TENANT,
|
|
27
27
|
SYSTEM_SCOPE_ID,
|
|
28
28
|
)
|
|
29
|
-
from feature_flags.deps import
|
|
29
|
+
from feature_flags.deps import (
|
|
30
|
+
FeatureFlagRegistryDep,
|
|
31
|
+
FeatureFlagServiceDep,
|
|
32
|
+
require_known_tenant,
|
|
33
|
+
)
|
|
30
34
|
|
|
31
35
|
router = APIRouter()
|
|
32
36
|
|
|
@@ -61,7 +65,10 @@ def _scope_args(tenant_id: str | None) -> dict[str, str]:
|
|
|
61
65
|
@router.get(
|
|
62
66
|
"/",
|
|
63
67
|
response_model=None,
|
|
64
|
-
dependencies=[
|
|
68
|
+
dependencies=[
|
|
69
|
+
Depends(RequiresPermission(PERM_FEATURE_FLAGS_VIEW)),
|
|
70
|
+
Depends(require_known_tenant),
|
|
71
|
+
],
|
|
65
72
|
)
|
|
66
73
|
async def browse(
|
|
67
74
|
request: Request,
|
|
@@ -86,7 +93,10 @@ async def browse(
|
|
|
86
93
|
@router.post(
|
|
87
94
|
"/{name}/toggle",
|
|
88
95
|
response_model=None,
|
|
89
|
-
dependencies=[
|
|
96
|
+
dependencies=[
|
|
97
|
+
Depends(RequiresPermission(PERM_FEATURE_FLAGS_MANAGE)),
|
|
98
|
+
Depends(require_known_tenant),
|
|
99
|
+
],
|
|
90
100
|
)
|
|
91
101
|
async def toggle_action(
|
|
92
102
|
name: str,
|
|
@@ -6,14 +6,14 @@
|
|
|
6
6
|
"peerDependencies": {
|
|
7
7
|
"react": "^19.0.0",
|
|
8
8
|
"react-dom": "^19.0.0",
|
|
9
|
-
"@inertiajs/react": "^
|
|
9
|
+
"@inertiajs/react": "^3.7.0",
|
|
10
10
|
"@simple-module-py/ui": "*"
|
|
11
11
|
},
|
|
12
12
|
"devDependencies": {
|
|
13
13
|
"@simple-module-py/tsconfig": "*"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"lucide-react": "^1.
|
|
17
|
-
"sonner": "^2.0.
|
|
16
|
+
"lucide-react": "^1.43.0",
|
|
17
|
+
"sonner": "^2.0.8"
|
|
18
18
|
}
|
|
19
19
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "simple_module_feature_flags"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.36"
|
|
4
4
|
description = "Simple feature-flag module with per-tenant overrides and a consumer API for simple_module"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -21,9 +21,9 @@ classifiers = [
|
|
|
21
21
|
"Typing :: Typed",
|
|
22
22
|
]
|
|
23
23
|
dependencies = [
|
|
24
|
-
"simple_module_core==0.0.
|
|
25
|
-
"simple_module_db==0.0.
|
|
26
|
-
"simple_module_hosting==0.0.
|
|
24
|
+
"simple_module_core==0.0.36",
|
|
25
|
+
"simple_module_db==0.0.36",
|
|
26
|
+
"simple_module_hosting==0.0.36",
|
|
27
27
|
]
|
|
28
28
|
|
|
29
29
|
[project.entry-points.simple_module]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Fixtures for tenant-scope flag tests, which need real tenants to name."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@pytest.fixture
|
|
9
|
+
def make_tenant(app):
|
|
10
|
+
"""``await make_tenant("acme")`` — a tenants row with that id."""
|
|
11
|
+
|
|
12
|
+
async def make(tenant_id: str) -> str:
|
|
13
|
+
from tenants.models import Tenant
|
|
14
|
+
|
|
15
|
+
async with app.state.sm.db.session_factory() as session:
|
|
16
|
+
session.add(Tenant(id=tenant_id, slug=tenant_id, name=tenant_id.title()))
|
|
17
|
+
await session.commit()
|
|
18
|
+
return tenant_id
|
|
19
|
+
|
|
20
|
+
return make
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.fixture
|
|
24
|
+
async def acme_tenant(make_tenant) -> str:
|
|
25
|
+
return await make_tenant("acme")
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import httpx
|
|
6
|
+
import pytest
|
|
6
7
|
|
|
7
8
|
|
|
8
9
|
class TestFeatureFlagsAPI:
|
|
@@ -58,6 +59,7 @@ class TestFeatureFlagsAPI:
|
|
|
58
59
|
assert "overridden" in body
|
|
59
60
|
|
|
60
61
|
|
|
62
|
+
@pytest.mark.usefixtures("acme_tenant")
|
|
61
63
|
class TestFeatureFlagsTenantAPI:
|
|
62
64
|
async def test_set_tenant_override_creates_tenant_specific_row(
|
|
63
65
|
self, authenticated_client: httpx.AsyncClient
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Feature flags under multi-tenancy (#375).
|
|
2
|
+
|
|
3
|
+
Request-time checks (``require_flag`` / ``is_flag_enabled``) already resolve
|
|
4
|
+
``request.state.tenant_id``; boot hydration is deliberately cross-tenant; the
|
|
5
|
+
admin tenant-override endpoints refuse a tenant that does not exist.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
from fastapi import Depends
|
|
12
|
+
from feature_flags.constants import PERM_FEATURE_FLAGS_MANAGE, PERM_FEATURE_FLAGS_VIEW
|
|
13
|
+
from feature_flags.service import FeatureFlagService
|
|
14
|
+
from simple_module_core.feature_flags import FeatureFlagRegistry, require_flag
|
|
15
|
+
from simple_module_core.tenancy import TenantRole, tenant_role
|
|
16
|
+
|
|
17
|
+
FLAG = "file_storage.public_uploads" # default off
|
|
18
|
+
SYSTEM = f"/api/feature_flags/{FLAG}"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def tenant_url(tenant_id: str) -> str:
|
|
22
|
+
return f"/api/feature_flags/tenant/{tenant_id}/{FLAG}"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.fixture
|
|
26
|
+
def gated(app):
|
|
27
|
+
"""``GET /__gated`` — 200 when ``FLAG`` is on for the caller's tenant, else 404."""
|
|
28
|
+
|
|
29
|
+
@app.get("/__gated", dependencies=[Depends(require_flag(FLAG))])
|
|
30
|
+
async def _gated() -> dict:
|
|
31
|
+
return {"ok": True}
|
|
32
|
+
|
|
33
|
+
return "/__gated"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def _put(client, url, enabled):
|
|
37
|
+
resp = await client.put(url, json={"enabled": enabled})
|
|
38
|
+
assert resp.status_code == 200, resp.text
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TestRequireFlagResolvesTheRequestTenant:
|
|
42
|
+
async def test_two_tenants_with_different_overrides(
|
|
43
|
+
self, authenticated_client, tenant_client, gated
|
|
44
|
+
):
|
|
45
|
+
async with tenant_client() as a, tenant_client() as b:
|
|
46
|
+
await _put(authenticated_client, tenant_url(a.tenant_id), True)
|
|
47
|
+
await _put(authenticated_client, tenant_url(b.tenant_id), False)
|
|
48
|
+
assert (await a.client.get(gated)).status_code == 200
|
|
49
|
+
assert (await b.client.get(gated)).status_code == 404
|
|
50
|
+
|
|
51
|
+
async def test_tenant_override_beats_system_in_both_directions(
|
|
52
|
+
self, authenticated_client, tenant_client, gated
|
|
53
|
+
):
|
|
54
|
+
async with tenant_client() as a, tenant_client() as b, tenant_client() as c:
|
|
55
|
+
await _put(authenticated_client, SYSTEM, True)
|
|
56
|
+
await _put(authenticated_client, tenant_url(a.tenant_id), False)
|
|
57
|
+
# system on: A overridden off, B inherits on
|
|
58
|
+
assert (await a.client.get(gated)).status_code == 404
|
|
59
|
+
assert (await b.client.get(gated)).status_code == 200
|
|
60
|
+
await _put(authenticated_client, SYSTEM, False)
|
|
61
|
+
await _put(authenticated_client, tenant_url(c.tenant_id), True)
|
|
62
|
+
# system off: C overridden on, B inherits off, A keeps its own off
|
|
63
|
+
assert (await c.client.get(gated)).status_code == 200
|
|
64
|
+
assert (await b.client.get(gated)).status_code == 404
|
|
65
|
+
|
|
66
|
+
async def test_default_applies_when_nothing_is_overridden(self, tenant_client, gated):
|
|
67
|
+
async with tenant_client() as a:
|
|
68
|
+
assert (await a.client.get(gated)).status_code == 404
|
|
69
|
+
|
|
70
|
+
async def test_clearing_a_tenant_override_falls_back_to_system(
|
|
71
|
+
self, authenticated_client, tenant_client, gated
|
|
72
|
+
):
|
|
73
|
+
async with tenant_client() as a:
|
|
74
|
+
await _put(authenticated_client, SYSTEM, True)
|
|
75
|
+
await _put(authenticated_client, tenant_url(a.tenant_id), False)
|
|
76
|
+
assert (await a.client.get(gated)).status_code == 404
|
|
77
|
+
cleared = await authenticated_client.delete(tenant_url(a.tenant_id))
|
|
78
|
+
assert cleared.status_code == 204
|
|
79
|
+
assert (await a.client.get(gated)).status_code == 200
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class TestBootHydration:
|
|
83
|
+
async def test_loads_every_tenants_overrides_with_no_tenant_bound(self, app, tenant_client):
|
|
84
|
+
assert app.state.sm.db.tenant_strict # strict: an unscoped tenant read would raise
|
|
85
|
+
async with tenant_client() as a, tenant_client() as b:
|
|
86
|
+
async with app.state.sm.db.session_factory() as db:
|
|
87
|
+
service = FeatureFlagService(db)
|
|
88
|
+
await service.set_override(FLAG, True, scope="tenant", scope_id=a.tenant_id)
|
|
89
|
+
await service.set_override(FLAG, False, scope="tenant", scope_id=b.tenant_id)
|
|
90
|
+
await db.commit()
|
|
91
|
+
|
|
92
|
+
fresh = FeatureFlagRegistry()
|
|
93
|
+
async with app.state.sm.db.session_factory() as db:
|
|
94
|
+
loaded = await FeatureFlagService(db).hydrate_registry(fresh)
|
|
95
|
+
|
|
96
|
+
assert loaded >= 2
|
|
97
|
+
assert fresh.tenant_override(FLAG, a.tenant_id) is True
|
|
98
|
+
assert fresh.tenant_override(FLAG, b.tenant_id) is False
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class TestTenantRolesNeverManageFlags:
|
|
102
|
+
@pytest.mark.parametrize("role", list(TenantRole))
|
|
103
|
+
def test_no_tenant_role_holds_flag_permissions(self, app, role):
|
|
104
|
+
mapped = set(app.state.sm.permissions.role_map[tenant_role(role)])
|
|
105
|
+
assert PERM_FEATURE_FLAGS_MANAGE not in mapped
|
|
106
|
+
assert PERM_FEATURE_FLAGS_VIEW not in mapped
|
|
107
|
+
|
|
108
|
+
@pytest.mark.parametrize("role", list(TenantRole))
|
|
109
|
+
async def test_tenant_member_cannot_reach_the_admin_endpoints(self, tenant_client, role):
|
|
110
|
+
async with tenant_client(role) as m:
|
|
111
|
+
own = tenant_url(m.tenant_id)
|
|
112
|
+
assert (await m.client.put(own, json={"enabled": True})).status_code == 403
|
|
113
|
+
assert (await m.client.delete(own)).status_code == 403
|
|
114
|
+
listing = await m.client.get(f"/api/feature_flags/tenant/{m.tenant_id}")
|
|
115
|
+
assert listing.status_code == 403
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class TestUnknownTenantIsRejected:
|
|
119
|
+
async def test_set_override_for_an_unknown_tenant_404s_and_persists_nothing(
|
|
120
|
+
self, authenticated_client, app
|
|
121
|
+
):
|
|
122
|
+
resp = await authenticated_client.put(tenant_url("no-such-tenant"), json={"enabled": True})
|
|
123
|
+
assert resp.status_code == 404
|
|
124
|
+
assert app.state.sm.feature_flags.tenant_override(FLAG, "no-such-tenant") is None
|
|
125
|
+
async with app.state.sm.db.session_factory() as db:
|
|
126
|
+
known = await FeatureFlagService(db).list_tenants_with_overrides()
|
|
127
|
+
assert "no-such-tenant" not in known
|
|
128
|
+
|
|
129
|
+
async def test_listing_an_unknown_tenant_404s(self, authenticated_client):
|
|
130
|
+
resp = await authenticated_client.get("/api/feature_flags/tenant/no-such-tenant")
|
|
131
|
+
assert resp.status_code == 404
|
|
132
|
+
|
|
133
|
+
async def test_known_tenant_is_accepted(self, authenticated_client, tenant_client):
|
|
134
|
+
async with tenant_client() as a:
|
|
135
|
+
await _put(authenticated_client, tenant_url(a.tenant_id), True)
|
|
136
|
+
listing = await authenticated_client.get(f"/api/feature_flags/tenant/{a.tenant_id}")
|
|
137
|
+
assert listing.status_code == 200
|
|
138
|
+
|
|
139
|
+
async def test_browse_view_rejects_an_unknown_tenant(self, authenticated_client):
|
|
140
|
+
resp = await authenticated_client.get("/admin/feature-flags/?tenant_id=nope")
|
|
141
|
+
assert resp.status_code == 404
|
|
142
|
+
assert (await authenticated_client.get("/admin/feature-flags/")).status_code == 200
|
|
143
|
+
|
|
144
|
+
async def test_an_orphaned_override_can_still_be_cleared(self, authenticated_client, app):
|
|
145
|
+
async with app.state.sm.db.session_factory() as db:
|
|
146
|
+
await FeatureFlagService(db).set_override(
|
|
147
|
+
FLAG, True, registry=app.state.sm.feature_flags, scope="tenant", scope_id="gone"
|
|
148
|
+
)
|
|
149
|
+
await db.commit()
|
|
150
|
+
resp = await authenticated_client.delete(tenant_url("gone"))
|
|
151
|
+
assert resp.status_code == 204
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
async def test_without_a_tenant_directory_any_id_is_accepted(authenticated_client, app):
|
|
155
|
+
"""No ``tenants`` module installed means nobody can say a tenant is unknown."""
|
|
156
|
+
saved = app.state.tenant_exists
|
|
157
|
+
del app.state.tenant_exists
|
|
158
|
+
try:
|
|
159
|
+
resp = await authenticated_client.put(tenant_url("anything"), json={"enabled": True})
|
|
160
|
+
finally:
|
|
161
|
+
app.state.tenant_exists = saved
|
|
162
|
+
assert resp.status_code == 200
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
"""FastAPI dependencies for the feature_flags module."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from typing import Annotated
|
|
6
|
-
|
|
7
|
-
from fastapi import Depends, Request
|
|
8
|
-
from simple_module_core.feature_flags import FeatureFlagRegistry
|
|
9
|
-
from simple_module_db.deps import get_db
|
|
10
|
-
from sqlalchemy.ext.asyncio import AsyncSession
|
|
11
|
-
|
|
12
|
-
from feature_flags.service import FeatureFlagService
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
async def get_feature_flag_service(
|
|
16
|
-
db: AsyncSession = Depends(get_db),
|
|
17
|
-
) -> FeatureFlagService:
|
|
18
|
-
return FeatureFlagService(db)
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def get_feature_flag_registry(request: Request) -> FeatureFlagRegistry:
|
|
22
|
-
"""Return the process-wide FeatureFlagRegistry owned by the framework."""
|
|
23
|
-
return request.app.state.sm.feature_flags
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
FeatureFlagServiceDep = Annotated[FeatureFlagService, Depends(get_feature_flag_service)]
|
|
27
|
-
FeatureFlagRegistryDep = Annotated[FeatureFlagRegistry, Depends(get_feature_flag_registry)]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_feature_flags-0.0.34 → simple_module_feature_flags-0.0.36}/feature_flags/__init__.py
RENAMED
|
File without changes
|
{simple_module_feature_flags-0.0.34 → simple_module_feature_flags-0.0.36}/feature_flags/constants.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_feature_flags-0.0.34 → simple_module_feature_flags-0.0.36}/feature_flags/models.py
RENAMED
|
File without changes
|
{simple_module_feature_flags-0.0.34 → simple_module_feature_flags-0.0.36}/feature_flags/module.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_feature_flags-0.0.34 → simple_module_feature_flags-0.0.36}/feature_flags/py.typed
RENAMED
|
File without changes
|
{simple_module_feature_flags-0.0.34 → simple_module_feature_flags-0.0.36}/feature_flags/service.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|