store-rbac-bootstrap-py 0.1.0__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.
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: store-rbac-bootstrap-py
3
+ Version: 0.1.0
4
+ Summary: Optional skin-layer bootstrap for common-store RBAC: policy bootstrap (set_rbac + role-rule configs) and credential-to-context factories (JWT / API-Key / Session) for the context_provider seams of store-api / store-grpc / store-graphql
5
+ License: MIT
6
+ Keywords: py-store,rbac,bootstrap,context,jwt,skin
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: PyJWT>=2.8
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest>=8; extra == "dev"
@@ -0,0 +1,21 @@
1
+ [project]
2
+ name = "store-rbac-bootstrap-py"
3
+ version = "0.1.0"
4
+ description = "Optional skin-layer bootstrap for common-store RBAC: policy bootstrap (set_rbac + role-rule configs) and credential-to-context factories (JWT / API-Key / Session) for the context_provider seams of store-api / store-grpc / store-graphql"
5
+ requires-python = ">=3.10"
6
+ license = { text = "MIT" }
7
+ keywords = ["py-store", "rbac", "bootstrap", "context", "jwt", "skin"]
8
+ dependencies = ["PyJWT>=2.8"]
9
+
10
+ [project.optional-dependencies]
11
+ dev = ["pytest>=8"]
12
+
13
+ [build-system]
14
+ requires = ["setuptools>=68"]
15
+ build-backend = "setuptools.build_meta"
16
+
17
+ [tool.setuptools.packages.find]
18
+ where = ["src"]
19
+
20
+ [tool.pytest.ini_options]
21
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,219 @@
1
+ """store-rbac-bootstrap-py — RBAC 皮肤层插件包(第一期消费面)。
2
+
3
+ 唯一事实源:spec/00-protocol.md。零判决、零语义发明、零宿主依赖。
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import inspect
8
+ import json
9
+ import urllib.request
10
+ from pathlib import Path
11
+ from types import SimpleNamespace
12
+
13
+ import jwt as _jwt # PyJWT
14
+
15
+ _ALLOWED_KEYS = ("policy", "exemptRoles", "denyWriteRoles", "unconfiguredPolicy")
16
+ _FACADES = ("set_rbac", "set_exempt_roles", "set_deny_write_roles", "set_unconfigured_policy")
17
+
18
+
19
+ async def _call(fn, arg):
20
+ """门面调用:sync 直接调;返回 awaitable(宿主支持 async 门面时)则 await。"""
21
+ result = fn(arg)
22
+ if inspect.isawaitable(result):
23
+ result = await result
24
+ return result
25
+
26
+
27
+ def file(path): # 策略源 ①:本地文件
28
+ async def source():
29
+ return json.loads(Path(path).read_text(encoding="utf-8"))
30
+ return source
31
+
32
+
33
+ def url(u, headers=None): # 策略源 ②:远端策略分发服务
34
+ async def source():
35
+ req = urllib.request.Request(u, headers=headers or {})
36
+ with urllib.request.urlopen(req) as resp:
37
+ if not (200 <= resp.status < 300):
38
+ raise ValueError(f"策略源请求失败: {resp.status} {u}")
39
+ return json.loads(resp.read().decode("utf-8"))
40
+ return source
41
+
42
+
43
+ def db(loader): # 策略源 ③:DB / 配置中心
44
+ async def source():
45
+ result = loader()
46
+ return await result if inspect.isawaitable(result) else result
47
+ return source
48
+
49
+
50
+ def _validate(config):
51
+ """校验先于应用;任一不符即抛 ValueError(此时零门面调用)。"""
52
+ if not isinstance(config, dict):
53
+ raise ValueError("引导配置必须是 JSON 对象")
54
+ unknown = [k for k in config if k not in _ALLOWED_KEYS]
55
+ if unknown:
56
+ raise ValueError(f"引导配置含未知键: {', '.join(unknown)}")
57
+ for key in ("exemptRoles", "denyWriteRoles"):
58
+ v = config.get(key)
59
+ if v is not None and (not isinstance(v, list) or any(not isinstance(r, str) for r in v)):
60
+ raise ValueError(f"引导配置 {key} 必须是字符串数组")
61
+ p = config.get("unconfiguredPolicy")
62
+ if p is not None and p not in ("open", "closed"):
63
+ raise ValueError('引导配置 unconfiguredPolicy 只能是 "open" 或 "closed"')
64
+
65
+
66
+ class _Handle:
67
+ def __init__(self, store, source):
68
+ self._store = store
69
+ self._source = source
70
+
71
+ async def start(self):
72
+ config = await self._source() # 读取失败原样上抛(不吞错)
73
+ _validate(config) # 校验先于应用
74
+ await _call(self._store.set_exempt_roles, config.get("exemptRoles", []))
75
+ await _call(self._store.set_deny_write_roles, config.get("denyWriteRoles", []))
76
+ await _call(self._store.set_unconfigured_policy, config.get("unconfiguredPolicy", "open"))
77
+ await _call(self._store.set_rbac, config.get("policy", None)) # 策略最后生效
78
+
79
+ async def reload(self):
80
+ await _call(self._store.set_rbac, None) # 先清除,再重注入
81
+ await self.start()
82
+
83
+
84
+ def create(store, source):
85
+ if store is None:
86
+ raise ValueError("policy.create 需要 store 实例")
87
+ for name in _FACADES:
88
+ if not callable(getattr(store, name, None)):
89
+ raise ValueError(f"store 缺少门面方法: {name}")
90
+ if not callable(source):
91
+ raise ValueError("policy.create 需要 source(策略源函数)")
92
+ return _Handle(store, source)
93
+
94
+
95
+ policy = SimpleNamespace(file=file, url=url, db=db, create=create)
96
+
97
+ # ------------------------------------------------------------------ #
98
+ # identity:身份源适配(凭证 → {userId, roles} → 各皮 context_provider 接缝)
99
+ # ------------------------------------------------------------------ #
100
+
101
+
102
+ def _headers_to_bag(headers):
103
+ """普通 Mapping → {lower: 首个字符串值};非字符串丢弃。"""
104
+ bag = {}
105
+ if not headers:
106
+ return bag
107
+ for k, v in dict(headers).items():
108
+ if v is None:
109
+ continue
110
+ bag.setdefault(str(k).lower(), v if isinstance(v, str) else str(v))
111
+ return bag
112
+
113
+
114
+ def _metadata_to_bag(invocation_metadata):
115
+ """gRPC invocation_metadata((key, value) 序列 或 支持 .get 的对象)→ 同形 bag。"""
116
+ bag = {}
117
+ if not invocation_metadata:
118
+ return bag
119
+ if hasattr(invocation_metadata, "get") and not isinstance(invocation_metadata, (list, tuple)):
120
+ # 对象形态:已知键按需取(authorization / x-api-key / cookie)
121
+ for name in ("authorization", "x-api-key", "cookie"):
122
+ v = invocation_metadata.get(name)
123
+ if v:
124
+ bag[name] = v if isinstance(v, str) else str(v)
125
+ return bag
126
+ for item in invocation_metadata: # (key, value) 序列形态
127
+ k, v = item[0], item[1]
128
+ bag.setdefault(str(k).lower(), v if isinstance(v, str) else str(v))
129
+ return bag
130
+
131
+
132
+ def _parse_cookie(cookie_header, name):
133
+ for part in (cookie_header or "").split(";"):
134
+ if "=" in part:
135
+ k, _, v = part.partition("=")
136
+ if k.strip() == name:
137
+ return v.strip()
138
+ return None
139
+
140
+
141
+ def jwt(secret, algorithm="HS256", header="authorization",
142
+ user_id_claim="sub", roles_claim="roles"):
143
+ if not isinstance(secret, str) or not secret:
144
+ raise ValueError("identity.jwt 需要非空 secret")
145
+ if algorithm not in ("HS256", "HS384", "HS512"):
146
+ raise ValueError(f"identity.jwt 首期仅支持 HS 系算法(收到 {algorithm})")
147
+
148
+ async def decode(bag):
149
+ raw = bag.get(header.lower())
150
+ if not raw:
151
+ return None # 无凭证 → None(显式清除)
152
+ token = raw[7:] if raw.startswith("Bearer ") else raw
153
+ try:
154
+ payload = _jwt.decode(token, secret, algorithms=[algorithm])
155
+ except Exception as exc: # 验签失败 / 过期 → 普通错误
156
+ raise ValueError(f"无效的 JWT 凭证: {exc}") from None
157
+ user_id = payload.get(user_id_claim)
158
+ if not isinstance(user_id, str) or not user_id:
159
+ raise ValueError("JWT 缺非空 userId 声明")
160
+ roles = payload.get(roles_claim)
161
+ return {
162
+ "userId": user_id,
163
+ "roles": [r for r in roles if isinstance(r, str)] if isinstance(roles, list) else [],
164
+ }
165
+
166
+ return decode
167
+
168
+
169
+ def api_key(header="x-api-key", lookup=None):
170
+ if not callable(lookup):
171
+ raise ValueError("identity.api_key 需要 lookup 回调")
172
+
173
+ async def decode(bag):
174
+ key = bag.get(header.lower())
175
+ if not key:
176
+ return None
177
+ ctx = lookup(key)
178
+ if inspect.isawaitable(ctx):
179
+ ctx = await ctx
180
+ return None if ctx is None else ctx
181
+
182
+ return decode
183
+
184
+
185
+ def session(cookie="sid", lookup=None):
186
+ if not callable(lookup):
187
+ raise ValueError("identity.session 需要 lookup 回调")
188
+
189
+ async def decode(bag):
190
+ sid = _parse_cookie(bag.get("cookie") or "", cookie)
191
+ if not sid:
192
+ return None
193
+ ctx = lookup(sid)
194
+ if inspect.isawaitable(ctx):
195
+ ctx = await ctx
196
+ return None if ctx is None else ctx
197
+
198
+ return decode
199
+
200
+
201
+ def adapt(skin, decode):
202
+ if not callable(decode):
203
+ raise ValueError("identity.adapt 需要解码器函数")
204
+ if skin == "store-api":
205
+ async def provider(request):
206
+ return await decode(_headers_to_bag(getattr(request, "headers", None)))
207
+ return provider
208
+ if skin == "store-grpc":
209
+ async def provider(invocation_metadata):
210
+ return await decode(_metadata_to_bag(invocation_metadata))
211
+ return provider
212
+ if skin == "store-graphql":
213
+ async def provider(request):
214
+ return await decode(_headers_to_bag(getattr(request, "headers", None)))
215
+ return provider
216
+ raise ValueError(f"identity.adapt 未知皮名: {skin}")
217
+
218
+
219
+ identity = SimpleNamespace(jwt=jwt, api_key=api_key, session=session, adapt=adapt)
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: store-rbac-bootstrap-py
3
+ Version: 0.1.0
4
+ Summary: Optional skin-layer bootstrap for common-store RBAC: policy bootstrap (set_rbac + role-rule configs) and credential-to-context factories (JWT / API-Key / Session) for the context_provider seams of store-api / store-grpc / store-graphql
5
+ License: MIT
6
+ Keywords: py-store,rbac,bootstrap,context,jwt,skin
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: PyJWT>=2.8
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest>=8; extra == "dev"
@@ -0,0 +1,10 @@
1
+ pyproject.toml
2
+ src/store_rbac_bootstrap/__init__.py
3
+ src/store_rbac_bootstrap_py.egg-info/PKG-INFO
4
+ src/store_rbac_bootstrap_py.egg-info/SOURCES.txt
5
+ src/store_rbac_bootstrap_py.egg-info/dependency_links.txt
6
+ src/store_rbac_bootstrap_py.egg-info/requires.txt
7
+ src/store_rbac_bootstrap_py.egg-info/top_level.txt
8
+ tests/test_conformance.py
9
+ tests/test_identity.py
10
+ tests/test_policy.py
@@ -0,0 +1,4 @@
1
+ PyJWT>=2.8
2
+
3
+ [dev]
4
+ pytest>=8
@@ -0,0 +1,119 @@
1
+ """conformance 用例(加载与 node 同一份 conformance/cases.json;期望值不复制)。"""
2
+ import asyncio
3
+ import json
4
+ from pathlib import Path
5
+ from types import SimpleNamespace
6
+
7
+ import jwt as pyjwt
8
+ import pytest
9
+
10
+ from store_rbac_bootstrap import policy, identity
11
+ from mock_store import MockStore
12
+
13
+ CASES = json.loads((Path(__file__).parents[2] / "conformance" / "cases.json").read_text(encoding="utf-8"))
14
+ SECRET = "conformance-secret"
15
+
16
+ # 双端门面名映射(camelCase → snake_case),供调用序列断言取 py 分支
17
+ _FACADE_MAP = dict(zip(CASES["facades"]["node"], CASES["facades"]["py"]))
18
+
19
+
20
+ def run(coro):
21
+ return asyncio.run(coro)
22
+
23
+
24
+ def source_of(cfg):
25
+ async def source():
26
+ return cfg
27
+ return source
28
+
29
+
30
+ def translate(expected_calls):
31
+ return [(_FACADE_MAP[name], value) for name, value in expected_calls]
32
+
33
+
34
+ def test_conformance_config_full():
35
+ store = MockStore()
36
+ run(policy.create(store, source_of(CASES["config"]["full"]["input"])).start())
37
+ assert store.calls == translate(CASES["config"]["full"]["expected_calls"])
38
+
39
+
40
+ def test_conformance_config_defaults():
41
+ store = MockStore()
42
+ run(policy.create(store, source_of(CASES["config"]["defaults"]["input"])).start())
43
+ assert store.calls == translate(CASES["config"]["defaults"]["expected_calls"])
44
+
45
+
46
+ def test_conformance_invalid_configs_raise_with_zero_calls():
47
+ entries = [
48
+ ("unknown_key", CASES["config"]["unknown_key"]),
49
+ ("bad_unconfigured_policy", CASES["config"]["bad_unconfigured_policy"]),
50
+ ("bad_exempt_roles", CASES["config"]["bad_exempt_roles"]),
51
+ ]
52
+ for name, c in entries:
53
+ store = MockStore()
54
+ handle = policy.create(store, source_of(c["input"]))
55
+ with pytest.raises(ValueError) as ei:
56
+ run(handle.start())
57
+ if c.get("error_contains"):
58
+ assert c["error_contains"] in str(ei.value), f"{name} 消息应含 {c['error_contains']}"
59
+ assert len(store.calls) == c["expected_call_count"], f"{name} 应零门面调用"
60
+
61
+
62
+ def test_conformance_reload_clear_first():
63
+ store = MockStore()
64
+ handle = policy.create(store, source_of({}))
65
+ run(handle.start())
66
+ before = len(store.calls)
67
+ run(handle.reload())
68
+ assert store.calls[before] == tuple(CASES["reload"]["expected_first_call"]["py"])
69
+
70
+
71
+ def test_conformance_null_clear():
72
+ async def null_lookup(_):
73
+ return None
74
+
75
+ decoders = {
76
+ "jwt": identity.jwt(secret=SECRET),
77
+ "api_key": identity.api_key(lookup=null_lookup),
78
+ "session": identity.session(lookup=null_lookup),
79
+ }
80
+ for entry in CASES["identity"]["null_clear"]:
81
+ assert run(decoders[entry["kind"]](entry["bag"])) is None, f"{entry['kind']} 应严格 None"
82
+
83
+
84
+ def test_conformance_jwt_payload_and_roles_default():
85
+ decode = identity.jwt(secret=SECRET)
86
+ p = CASES["identity"]["jwt_payload"]
87
+ t1 = pyjwt.encode(p["claims"], SECRET, algorithm="HS256")
88
+ assert run(decode({"authorization": f"Bearer {t1}"})) == p["expected"]
89
+ r = CASES["identity"]["jwt_roles_default"]
90
+ t2 = pyjwt.encode(r["claims"], SECRET, algorithm="HS256")
91
+ assert run(decode({"authorization": t2})) == r["expected"]
92
+
93
+
94
+ def test_conformance_invalid_is_plain_error_not_permission():
95
+ decode = identity.jwt(secret=SECRET)
96
+ bad = pyjwt.encode({"sub": "u"}, "other-secret", algorithm="HS256")
97
+ with pytest.raises(ValueError) as ei:
98
+ run(decode({"authorization": bad}))
99
+ assert type(ei.value).__name__ == CASES["identity"]["invalid"]["error_class"]["py"]
100
+ assert not isinstance(ei.value, PermissionError)
101
+
102
+
103
+ def test_conformance_bag_lowercase():
104
+ headers = CASES["identity"]["bag_lowercase"]["headers"]
105
+ expected_key = CASES["identity"]["bag_lowercase"]["expected_bag_key"]
106
+ captured = {}
107
+
108
+ async def spy(bag):
109
+ captured.update(bag)
110
+ return None
111
+
112
+ provider = identity.adapt("store-api", spy)
113
+ run(provider(SimpleNamespace(headers=headers)))
114
+ assert expected_key in captured
115
+
116
+
117
+ def test_conformance_unknown_skin_raises():
118
+ with pytest.raises(ValueError):
119
+ identity.adapt(CASES["identity"]["unknown_skin"]["skin"], lambda bag: None)
@@ -0,0 +1,134 @@
1
+ """identity 用例(与 02 node/test/identity.test.js 的 T11–T21 逐场景对拍)。"""
2
+ import asyncio
3
+ from types import SimpleNamespace
4
+
5
+ import jwt as pyjwt
6
+ import pytest
7
+
8
+ from store_rbac_bootstrap import identity
9
+
10
+
11
+ def run(coro):
12
+ return asyncio.run(coro)
13
+
14
+
15
+ def test_T11_jwt_no_credential_returns_none():
16
+ decode = identity.jwt(secret="s")
17
+ assert run(decode({})) is None
18
+ assert run(decode({"cookie": "x=1"})) is None
19
+
20
+
21
+ def test_T12_jwt_valid_payload_and_roles_default():
22
+ decode = identity.jwt(secret="s")
23
+ token = pyjwt.encode({"sub": "u1", "roles": ["editor"]}, "s", algorithm="HS256")
24
+ assert run(decode({"authorization": f"Bearer {token}"})) == {"userId": "u1", "roles": ["editor"]}
25
+ no_roles = pyjwt.encode({"sub": "u2"}, "s", algorithm="HS256")
26
+ assert run(decode({"authorization": no_roles})) == {"userId": "u2", "roles": []}
27
+
28
+
29
+ def test_T13_jwt_invalid_raises_plain_error():
30
+ decode = identity.jwt(secret="s")
31
+ wrong = pyjwt.encode({"sub": "u"}, "other", algorithm="HS256")
32
+ with pytest.raises(ValueError) as ei:
33
+ run(decode({"authorization": wrong}))
34
+ assert not isinstance(ei.value, PermissionError)
35
+ expired = pyjwt.encode({"sub": "u", "exp": 1}, "s", algorithm="HS256")
36
+ with pytest.raises(ValueError) as ei2:
37
+ run(decode({"authorization": expired}))
38
+ assert not isinstance(ei2.value, PermissionError)
39
+
40
+
41
+ def test_T14_jwt_construction_errors():
42
+ with pytest.raises(ValueError, match="secret"):
43
+ identity.jwt(secret="")
44
+ with pytest.raises(ValueError, match="HS"):
45
+ identity.jwt(secret="s", algorithm="RS256")
46
+
47
+
48
+ def test_T15_api_key_three_states_and_passthrough():
49
+ async def lookup(k):
50
+ return {"userId": "u", "roles": []} if k == "good" else None
51
+
52
+ decode = identity.api_key(lookup=lookup)
53
+ assert run(decode({})) is None
54
+ assert run(decode({"x-api-key": "good"})) == {"userId": "u", "roles": []}
55
+ assert run(decode({"x-api-key": "bad"})) is None
56
+
57
+ class PermissionError(Exception):
58
+ pass
59
+
60
+ def boom(k):
61
+ raise PermissionError("no")
62
+
63
+ decode2 = identity.api_key(lookup=boom)
64
+ with pytest.raises(PermissionError):
65
+ run(decode2({"x-api-key": "k"}))
66
+
67
+
68
+ def test_T16_session_cookie_parsing():
69
+ async def lookup(sid):
70
+ return {"userId": "u", "roles": []} if sid == "s1" else None
71
+
72
+ decode = identity.session(lookup=lookup)
73
+ assert run(decode({})) is None
74
+ assert run(decode({"cookie": "sid=s1"})) == {"userId": "u", "roles": []}
75
+ assert run(decode({"cookie": "other=1; sid=s1; z=2"})) == {"userId": "u", "roles": []}
76
+ assert run(decode({"cookie": "sid=nope"})) is None
77
+
78
+
79
+ def test_T17_adapt_store_api_lowercase_bag():
80
+ captured = {}
81
+
82
+ async def decode(bag):
83
+ captured.update(bag)
84
+ return {"userId": "u", "roles": []}
85
+
86
+ provider = identity.adapt("store-api", decode)
87
+ ctx = run(provider(SimpleNamespace(headers={"Authorization": "Bearer t"})))
88
+ assert captured == {"authorization": "Bearer t"}
89
+ assert ctx == {"userId": "u", "roles": []}
90
+
91
+
92
+ def test_T18_adapt_store_grpc_two_shapes():
93
+ captured = {}
94
+
95
+ async def decode(bag):
96
+ captured.clear()
97
+ captured.update(bag)
98
+ return None
99
+
100
+ provider = identity.adapt("store-grpc", decode)
101
+ run(provider([("authorization", "Bearer t")])) # (key, value) 序列形态
102
+ assert captured == {"authorization": "Bearer t"}
103
+
104
+ class MD: # .get 对象形态
105
+ def get(self, name):
106
+ return {"authorization": "Bearer z"}.get(name)
107
+
108
+ run(provider(MD()))
109
+ assert captured == {"authorization": "Bearer z"}
110
+
111
+
112
+ def test_T19_adapt_store_graphql_headers():
113
+ captured = {}
114
+
115
+ async def decode(bag):
116
+ captured.update(bag)
117
+ return None
118
+
119
+ provider = identity.adapt("store-graphql", decode)
120
+ run(provider(SimpleNamespace(headers={"Authorization": "Bearer t"})))
121
+ assert captured == {"authorization": "Bearer t"}
122
+
123
+
124
+ def test_T20_adapt_unknown_skin_raises():
125
+ with pytest.raises(ValueError, match="未知皮名"):
126
+ identity.adapt("unknown", lambda b: None)
127
+ with pytest.raises(ValueError, match="解码器"):
128
+ identity.adapt("store-api", None)
129
+
130
+
131
+ def test_T21_adapt_null_clear_semantics():
132
+ provider = identity.adapt("store-api", identity.jwt(secret="s"))
133
+ assert run(provider(SimpleNamespace(headers={}))) is None
134
+ assert run(provider(SimpleNamespace())) is None
@@ -0,0 +1,130 @@
1
+ """policy 用例(与 02 node/test/policy.test.js 的 T1–T10 逐场景对拍)。"""
2
+ import asyncio
3
+ import json
4
+
5
+ import pytest
6
+
7
+ from store_rbac_bootstrap import policy
8
+ from mock_store import MockStore
9
+
10
+
11
+ def run(coro):
12
+ return asyncio.run(coro)
13
+
14
+
15
+ def make_source(cfg):
16
+ async def source():
17
+ return cfg
18
+ return source
19
+
20
+
21
+ def test_T1_full_config_strict_order():
22
+ store = MockStore()
23
+ cfg = {
24
+ "policy": {"mode": "overlay", "roles": {}, "grants": []},
25
+ "exemptRoles": ["a"],
26
+ "denyWriteRoles": ["g"],
27
+ "unconfiguredPolicy": "closed",
28
+ }
29
+ handle = policy.create(store, make_source(cfg))
30
+ run(handle.start())
31
+ assert store.calls == [
32
+ ("set_exempt_roles", ["a"]),
33
+ ("set_deny_write_roles", ["g"]),
34
+ ("set_unconfigured_policy", "closed"),
35
+ ("set_rbac", cfg["policy"]),
36
+ ]
37
+
38
+
39
+ def test_T2_empty_config_defaults():
40
+ store = MockStore()
41
+ handle = policy.create(store, make_source({}))
42
+ run(handle.start())
43
+ assert store.calls == [
44
+ ("set_exempt_roles", []),
45
+ ("set_deny_write_roles", []),
46
+ ("set_unconfigured_policy", "open"),
47
+ ("set_rbac", None),
48
+ ]
49
+
50
+
51
+ def test_T3_unknown_top_key_no_facade_call():
52
+ store = MockStore()
53
+ handle = policy.create(store, make_source({"foo": 1}))
54
+ with pytest.raises(ValueError, match="foo"):
55
+ run(handle.start())
56
+ assert store.calls == []
57
+
58
+
59
+ def test_T4_bad_unconfigured_policy_no_facade_call():
60
+ store = MockStore()
61
+ handle = policy.create(store, make_source({"unconfiguredPolicy": "x"}))
62
+ with pytest.raises(ValueError, match="unconfiguredPolicy"):
63
+ run(handle.start())
64
+ assert store.calls == []
65
+
66
+
67
+ def test_T5_bad_exempt_roles_no_facade_call():
68
+ store = MockStore()
69
+ handle = policy.create(store, make_source({"exemptRoles": "a"}))
70
+ with pytest.raises(ValueError, match="exemptRoles"):
71
+ run(handle.start())
72
+ assert store.calls == []
73
+
74
+
75
+ def test_T6_source_error_propagates_no_facade_call():
76
+ store = MockStore()
77
+
78
+ async def boom():
79
+ raise RuntimeError("boom")
80
+
81
+ handle = policy.create(store, boom)
82
+ with pytest.raises(RuntimeError, match="boom"):
83
+ run(handle.start())
84
+ assert store.calls == []
85
+
86
+
87
+ def test_T7_policy_file(tmp_path):
88
+ p = tmp_path / "cfg.json"
89
+ p.write_text(json.dumps({"exemptRoles": ["x"]}), encoding="utf-8")
90
+ store = MockStore()
91
+ handle = policy.create(store, policy.file(str(p)))
92
+ run(handle.start())
93
+ assert store.calls == [
94
+ ("set_exempt_roles", ["x"]),
95
+ ("set_deny_write_roles", []),
96
+ ("set_unconfigured_policy", "open"),
97
+ ("set_rbac", None),
98
+ ]
99
+ with pytest.raises(Exception):
100
+ run(policy.file(str(p) + ".nope")())
101
+
102
+
103
+ def test_T8_reload_clear_first():
104
+ store = MockStore()
105
+ handle = policy.create(store, make_source({"exemptRoles": ["a"]}))
106
+ run(handle.start())
107
+ before = len(store.calls) # 4
108
+ run(handle.reload())
109
+ assert store.calls[before] == ("set_rbac", None) # 清除前置
110
+ assert len(store.calls) == before + 1 + 4
111
+
112
+
113
+ def test_T9_missing_facade_raises():
114
+ class S: # 缺 set_deny_write_roles
115
+ def set_rbac(self, v):
116
+ pass
117
+
118
+ def set_exempt_roles(self, v):
119
+ pass
120
+
121
+ def set_unconfigured_policy(self, v):
122
+ pass
123
+
124
+ with pytest.raises(ValueError, match="set_deny_write_roles"):
125
+ policy.create(S(), make_source({}))
126
+
127
+
128
+ def test_T10_source_not_callable_raises():
129
+ with pytest.raises(ValueError, match="source"):
130
+ policy.create(MockStore(), 123)