simple-module-core 0.0.33__tar.gz → 0.0.34__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/PKG-INFO +1 -1
  2. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/pyproject.toml +1 -1
  3. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/__main__.py +19 -9
  4. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/audit_links.py +19 -4
  5. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/diagnostics/_i18n.py +27 -3
  6. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/diagnostics/_runner.py +7 -5
  7. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/permissions.py +18 -0
  8. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/tests/test_audit_links.py +19 -0
  9. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/tests/test_i18n_diagnostics.py +40 -0
  10. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/tests/test_permissions.py +29 -1
  11. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/.gitignore +0 -0
  12. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/LICENSE +0 -0
  13. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/README.md +0 -0
  14. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/__init__.py +0 -0
  15. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/csp.py +0 -0
  16. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/design_packs.py +0 -0
  17. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/diagnostics/__init__.py +0 -0
  18. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/diagnostics/_coupling.py +0 -0
  19. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/diagnostics/_css.py +0 -0
  20. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/diagnostics/_inertia_api.py +0 -0
  21. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/diagnostics/_js_workspace.py +0 -0
  22. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/diagnostics/_migration.py +0 -0
  23. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/diagnostics/_module.py +0 -0
  24. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/diagnostics/_pages.py +0 -0
  25. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/diagnostics/_types.py +0 -0
  26. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/discovery.py +0 -0
  27. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/dotenv.py +0 -0
  28. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/environments.py +0 -0
  29. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/events.py +0 -0
  30. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/exceptions.py +0 -0
  31. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/feature_flags.py +0 -0
  32. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/health.py +0 -0
  33. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/i18n.py +0 -0
  34. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/menu.py +0 -0
  35. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/module.py +0 -0
  36. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/module_meta.py +0 -0
  37. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/public_routes.py +0 -0
  38. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/py.typed +0 -0
  39. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/redirect_safety.py +0 -0
  40. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/services.py +0 -0
  41. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/settings_base.py +0 -0
  42. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/setup_steps.py +0 -0
  43. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/versioning.py +0 -0
  44. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/tests/test_auth_provider_selection.py +0 -0
  45. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/tests/test_csp_registry.py +0 -0
  46. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/tests/test_css_diagnostics.py +0 -0
  47. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/tests/test_css_scanner.py +0 -0
  48. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/tests/test_design_packs.py +0 -0
  49. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/tests/test_diagnostics.py +0 -0
  50. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/tests/test_discovery.py +0 -0
  51. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/tests/test_dotenv.py +0 -0
  52. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/tests/test_environments.py +0 -0
  53. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/tests/test_events.py +0 -0
  54. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/tests/test_feature_flags.py +0 -0
  55. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/tests/test_feature_flags_decorator.py +0 -0
  56. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/tests/test_health_registry.py +0 -0
  57. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/tests/test_i18n.py +0 -0
  58. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/tests/test_menu.py +0 -0
  59. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/tests/test_module_base.py +0 -0
  60. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/tests/test_module_diagnostics.py +0 -0
  61. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/tests/test_public_routes.py +0 -0
  62. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/tests/test_redirect_safety.py +0 -0
  63. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/tests/test_services.py +0 -0
  64. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/tests/test_settings_base.py +0 -0
  65. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/tests/test_sm003_page_render.py +0 -0
  66. {simple_module_core-0.0.33 → simple_module_core-0.0.34}/tests/test_versioning.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simple_module_core
3
- Version: 0.0.33
3
+ Version: 0.0.34
4
4
  Summary: Module-system primitives for the simple_module framework — ModuleBase, discovery, diagnostics, events
5
5
  Project-URL: Homepage, https://github.com/antosubash/simple_module_python
6
6
  Project-URL: Repository, https://github.com/antosubash/simple_module_python
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_core"
3
- version = "0.0.33"
3
+ version = "0.0.34"
4
4
  description = "Module-system primitives for the simple_module framework — ModuleBase, discovery, diagnostics, events"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -7,10 +7,14 @@ Usage::
7
7
 
8
8
  Exits with status 1 if any ERROR-level diagnostics are reported.
9
9
 
10
- i18n checks are included when ``SM_I18N_SUPPORTED_LOCALES`` is set in env
11
- (or ``.env``). Host-level ``host/locales/`` and shared ``packages/ui/locales/``
12
- are picked up relative to the project root (``SM_PROJECT_ROOT``, else the
13
- directory of the discovered ``.env``, else the current working dir).
10
+ i18n checks always run. ``SM_I18N_SUPPORTED_LOCALES`` (env or ``.env``)
11
+ declares the locale set this install promises, which also enables SM013 for a
12
+ namespace that never shipped one; with it unset, each namespace is checked
13
+ against the locale files it actually has, so key drift between two shipped
14
+ translations is still caught. Host-level ``host/locales/`` and shared
15
+ ``packages/ui/locales/`` are picked up relative to the project root
16
+ (``SM_PROJECT_ROOT``, else the directory of the discovered ``.env``, else the
17
+ current working dir).
14
18
  """
15
19
 
16
20
  from __future__ import annotations
@@ -36,8 +40,14 @@ from simple_module_core.dotenv import find_env_file, parse_dotenv
36
40
  from simple_module_core.exceptions import InvalidModuleError
37
41
 
38
42
 
39
- def _load_i18n_settings_from_env() -> tuple[list[str], str] | tuple[None, None]:
40
- """Return ``(supported_locales, default_locale)`` or ``(None, None)`` if unset.
43
+ def _load_i18n_settings_from_env() -> tuple[list[str] | None, str]:
44
+ """Return ``(supported_locales, default_locale)``.
45
+
46
+ ``supported_locales`` is ``None`` when ``SM_I18N_SUPPORTED_LOCALES`` is
47
+ unset or unparseable — the diagnostic then falls back to the locale files
48
+ each namespace ships rather than running no locale checks at all. The
49
+ default locale always has a value, because a parity comparison needs to
50
+ know which side is the source of truth.
41
51
 
42
52
  Reads env vars directly to avoid a dependency on ``simple_module_hosting``.
43
53
  Honors ``.env`` by merging it into ``os.environ`` if present (pydantic-
@@ -46,9 +56,10 @@ def _load_i18n_settings_from_env() -> tuple[list[str], str] | tuple[None, None]:
46
56
  for key, value in parse_dotenv().items():
47
57
  os.environ.setdefault(key, value)
48
58
 
59
+ default = os.environ.get("SM_I18N_DEFAULT_LOCALE", "en")
49
60
  supported_raw = os.environ.get("SM_I18N_SUPPORTED_LOCALES")
50
61
  if not supported_raw:
51
- return None, None
62
+ return None, default
52
63
 
53
64
  try:
54
65
  supported = json.loads(supported_raw)
@@ -57,9 +68,8 @@ def _load_i18n_settings_from_env() -> tuple[list[str], str] | tuple[None, None]:
57
68
  supported = [s.strip() for s in supported_raw.split(",") if s.strip()]
58
69
 
59
70
  if not isinstance(supported, list) or not supported:
60
- return None, None
71
+ return None, default
61
72
 
62
- default = os.environ.get("SM_I18N_DEFAULT_LOCALE", "en")
63
73
  return supported, default
64
74
 
65
75
 
@@ -11,10 +11,15 @@ A module declares where its rows live via
11
11
  collects them into one registry at boot and stores it on
12
12
  ``app.state.sm.audit_links``.
13
13
 
14
- **The registry maps model class names to URL templates, nothing more.** It does
15
- not verify the row exists or that the reader may open it — following a link to a
16
- deleted record lands on that screen's own 404, and permissions are enforced by
17
- the target route as usual.
14
+ **A link is a route, not an authorisation.** The registry does not verify the
15
+ row exists or that the reader may open it — following a link to a deleted
16
+ record lands on that screen's own 404, and permissions are enforced by the
17
+ target route as usual.
18
+
19
+ The one exception is ``label_permission``: the *name* of a row travels in the
20
+ audit payload itself, so no downstream route ever gets the chance to refuse it.
21
+ A module whose row names disclose something it gates elsewhere says so there,
22
+ and readers without that permission see the id instead.
18
23
  """
19
24
 
20
25
  from __future__ import annotations
@@ -76,6 +81,15 @@ class AuditLink:
76
81
  a closure, and two boots would build two unequal objects out of one
77
82
  module — a registry conflict is about two modules claiming one
78
83
  entity type, which the identity fields already express.
84
+ label_permission: Permission a reader must hold before
85
+ ``label_resolver`` is *run* for them. Empty — the default — means
86
+ the name is safe for anyone allowed to read the audit trail at all.
87
+ Set it when naming the row discloses something the module gates
88
+ elsewhere: ``users`` names an account by ``full_name or email``, so
89
+ without this an ``audit_log.view`` grant is a back door onto the
90
+ user directory that ``users.manage`` guards at the front. Denied
91
+ readers still see the row, the action and the id — the audit trail
92
+ stays complete, it just stops volunteering the name.
79
93
  """
80
94
 
81
95
  entity_type: str
@@ -84,6 +98,7 @@ class AuditLink:
84
98
  label_key: str = ""
85
99
  table_name: str | None = None
86
100
  label_resolver: LabelResolver | None = field(default=None, compare=False)
101
+ label_permission: str = ""
87
102
 
88
103
  def __post_init__(self) -> None:
89
104
  if self.url_template and _ID_PLACEHOLDER not in self.url_template:
@@ -25,19 +25,28 @@ class I18nDiagnostics:
25
25
 
26
26
  def __init__(
27
27
  self,
28
- supported_locales: list[str],
28
+ supported_locales: list[str] | None,
29
29
  default_locale: str,
30
30
  extra_sources: list[tuple[str, str, Path]] | None = None,
31
31
  ) -> None:
32
32
  """Build the diagnostic.
33
33
 
34
+ ``supported_locales`` is the set of locales this install promises to
35
+ ship. ``None`` means *nobody declared one* — each namespace is then
36
+ checked against the locale files it actually has on disk. SM013 is
37
+ skipped in that mode (a locale nothing promised cannot be missing),
38
+ but SM014/SM015 still hold every shipped translation to the default
39
+ locale's key set. Without this, an install that never set
40
+ ``SM_I18N_SUPPORTED_LOCALES`` ran no locale checks at all and drift
41
+ accumulated with nothing to flag it.
42
+
34
43
  ``extra_sources`` is an optional list of ``(reporter_name, namespace,
35
44
  locale_dir)`` triples for locale directories that aren't owned by any
36
45
  ``ModuleBase`` instance — notably the host's ``host/locales/`` and
37
46
  the shared ``packages/ui/locales/``. ``reporter_name`` is used as the
38
47
  ``module_name`` field on findings for display purposes.
39
48
  """
40
- self.supported_locales = list(supported_locales)
49
+ self.supported_locales = None if supported_locales is None else list(supported_locales)
41
50
  self.default_locale = default_locale
42
51
  self.extra_sources = list(extra_sources or [])
43
52
 
@@ -55,10 +64,14 @@ class I18nDiagnostics:
55
64
  ) -> list[Diagnostic]:
56
65
  findings: list[Diagnostic] = []
57
66
  per_locale_keys: dict[str, set[str]] = {}
67
+ declared = self.supported_locales is not None
68
+ locales = self.supported_locales if declared else self._locales_on_disk(locale_dir)
58
69
 
59
- for locale in self.supported_locales:
70
+ for locale in locales or ():
60
71
  path = locale_dir / f"{locale}.json"
61
72
  if not path.is_file():
73
+ if not declared:
74
+ continue
62
75
  findings.append(
63
76
  Diagnostic(
64
77
  level=DiagnosticLevel.WARNING,
@@ -119,3 +132,14 @@ class I18nDiagnostics:
119
132
  )
120
133
  )
121
134
  return findings
135
+
136
+ @staticmethod
137
+ def _locales_on_disk(locale_dir: Path) -> list[str]:
138
+ """Locale tags a directory actually ships, from its ``<tag>.json`` files.
139
+
140
+ The default locale sorts first only by accident of the alphabet, which
141
+ does not matter: the parity comparison below looks it up by name.
142
+ """
143
+ if not locale_dir.is_dir():
144
+ return []
145
+ return sorted(path.stem for path in locale_dir.glob("*.json"))
@@ -30,18 +30,20 @@ def run_diagnostics(
30
30
  """Convenience function to run all diagnostics.
31
31
 
32
32
  When ``migration_state`` is provided, also runs migration diagnostics.
33
- When ``i18n_supported_locales`` and ``i18n_default_locale`` are provided,
34
- also runs i18n locale coverage diagnostics. ``i18n_extra_sources`` lets
35
- callers include host/ui locale dirs that aren't owned by a ``ModuleBase``.
33
+ ``i18n_default_locale`` turns on the locale checks; ``i18n_supported_locales``
34
+ is the declared locale set, and leaving it empty runs those checks against
35
+ whatever locale files are on disk instead of skipping them entirely (see
36
+ :class:`I18nDiagnostics`). ``i18n_extra_sources`` lets callers include
37
+ host/ui locale dirs that aren't owned by a ``ModuleBase``.
36
38
  """
37
39
  diagnostics = ModuleDiagnostics().run(modules)
38
40
 
39
- if i18n_supported_locales and i18n_default_locale:
41
+ if i18n_default_locale:
40
42
  from simple_module_core.diagnostics._i18n import I18nDiagnostics
41
43
 
42
44
  diagnostics.extend(
43
45
  I18nDiagnostics(
44
- supported_locales=i18n_supported_locales,
46
+ supported_locales=i18n_supported_locales or None,
45
47
  default_locale=i18n_default_locale,
46
48
  extra_sources=i18n_extra_sources,
47
49
  ).run(modules)
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from collections.abc import Collection
5
6
  from dataclasses import dataclass, field
6
7
 
7
8
  WILDCARD = "*"
@@ -26,6 +27,23 @@ def is_admin(roles: list[str] | None) -> bool:
26
27
  return bool(roles) and ADMIN_ROLE in roles
27
28
 
28
29
 
30
+ def grants(held: Collection[str], required: str) -> bool:
31
+ """Whether a principal holding *held* satisfies *required*.
32
+
33
+ One place for the wildcard rule, so code that gates something *inside* a
34
+ response — a column, a label, a card — reads the grant the same way
35
+ ``RequiresPermission`` reads it at the door. Two hand-rolled ``in`` checks
36
+ are two chances to forget that ``admin`` holds ``*`` and nothing else.
37
+
38
+ An empty *required* is satisfied by anything: callers use it to mean "no
39
+ permission gates this", which is the default for a declaration that never
40
+ named one.
41
+ """
42
+ if not required:
43
+ return True
44
+ return WILDCARD in held or required in held
45
+
46
+
29
47
  @dataclass
30
48
  class PermissionGroup:
31
49
  """A named group of related permissions (typically one per module)."""
@@ -113,6 +113,25 @@ class TestLabelResolver:
113
113
  )
114
114
  assert len(reg.all_links) == 1
115
115
 
116
+ def test_label_permission_defaults_to_ungated(self):
117
+ """Most row names disclose nothing a reader of the audit log should not
118
+ already see — a setting key, a filename — so declaring nothing means
119
+ "anyone who may read the trail may read the name"."""
120
+ assert AuditLink(entity_type="Setting", url_template="/s/{id}").label_permission == ""
121
+
122
+ def test_label_permission_is_part_of_the_claim(self):
123
+ """Unlike the resolver, it is compared: two modules disagreeing about
124
+ who may see a name is exactly the conflict the registry exists to
125
+ catch, and silently keeping the looser one would be the wrong answer."""
126
+ reg = AuditLinkRegistry()
127
+ reg.register(AuditLink(entity_type="User", url_template="/u/{id}"))
128
+ with pytest.raises(ValueError, match="Two modules claim"):
129
+ reg.register(
130
+ AuditLink(
131
+ entity_type="User", url_template="/u/{id}", label_permission="users.manage"
132
+ )
133
+ )
134
+
116
135
  def test_genuinely_conflicting_claims_still_raise(self):
117
136
  reg = AuditLinkRegistry()
118
137
  reg.register(AuditLink(entity_type="User", url_template="/a/{id}"))
@@ -99,3 +99,43 @@ def test_extra_sources_detect_key_drift(tmp_path: Path) -> None:
99
99
  ).run([])
100
100
  codes = {f.code for f in findings}
101
101
  assert "SM014" in codes
102
+
103
+
104
+ class TestUndeclaredLocaleSet:
105
+ """``supported_locales=None`` — nobody said which locales this install ships.
106
+
107
+ ``make doctor`` runs with no ``SM_I18N_SUPPORTED_LOCALES`` on a stock
108
+ checkout, and the whole diagnostic used to be skipped in that state: drift
109
+ between two catalogs that *are* both shipped went unreported because no
110
+ third file declared them. The files on disk are themselves the declaration.
111
+ """
112
+
113
+ def test_key_drift_is_reported_without_a_declared_locale_set(self, tmp_path: Path) -> None:
114
+ _write(tmp_path / "p", "en", {"a": "1"})
115
+ _write(tmp_path / "p", "es", {"a": "1", "orphan": "x"})
116
+ mod = _FakeModule("P", {"p": tmp_path / "p"})
117
+ findings = I18nDiagnostics(supported_locales=None, default_locale="en").run([mod])
118
+ assert {f.code for f in findings} == {"SM015"}
119
+
120
+ def test_a_locale_nobody_promised_is_not_reported_missing(self, tmp_path: Path) -> None:
121
+ """SM013 is a broken promise; with no declared set there is no promise."""
122
+ _write(tmp_path / "p", "en", {"a": "1"})
123
+ _write(tmp_path / "q", "en", {"a": "1"})
124
+ _write(tmp_path / "q", "es", {"a": "1"})
125
+ mod = _FakeModule("P", {"p": tmp_path / "p", "q": tmp_path / "q"})
126
+ findings = I18nDiagnostics(supported_locales=None, default_locale="en").run([mod])
127
+ assert findings == []
128
+
129
+ def test_extra_sources_are_auto_detected_too(self, tmp_path: Path) -> None:
130
+ _write(tmp_path / "ui_locales", "en", {"a": "1", "b": "2"})
131
+ _write(tmp_path / "ui_locales", "es", {"a": "1"})
132
+ findings = I18nDiagnostics(
133
+ supported_locales=None,
134
+ default_locale="en",
135
+ extra_sources=[("packages/ui", "ui", tmp_path / "ui_locales")],
136
+ ).run([])
137
+ assert [f.code for f in findings] == ["SM014"]
138
+
139
+ def test_a_missing_locale_dir_is_silently_skipped(self, tmp_path: Path) -> None:
140
+ mod = _FakeModule("P", {"p": tmp_path / "nope"})
141
+ assert I18nDiagnostics(supported_locales=None, default_locale="en").run([mod]) == []
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from simple_module_core.permissions import WILDCARD, PermissionRegistry
5
+ from simple_module_core.permissions import WILDCARD, PermissionRegistry, grants
6
6
 
7
7
 
8
8
  class TestPermissionRegistry:
@@ -113,3 +113,31 @@ class TestPermissionRegistryMapRole:
113
113
  for key, val in result.items():
114
114
  assert isinstance(key, str)
115
115
  assert isinstance(val, list)
116
+
117
+
118
+ class TestGrants:
119
+ """The wildcard rule, in one place.
120
+
121
+ Anything gating part of a *response* rather than a route reads grants
122
+ through here — a second hand-rolled ``in`` check is a second chance to
123
+ forget that ``admin`` holds ``*`` and no named permission at all.
124
+ """
125
+
126
+ async def test_a_held_permission_is_granted(self):
127
+ assert grants({"users.manage"}, "users.manage") is True
128
+
129
+ async def test_a_missing_permission_is_not(self):
130
+ assert grants({"audit_log.view"}, "users.manage") is False
131
+
132
+ async def test_the_wildcard_satisfies_anything(self):
133
+ assert grants({WILDCARD}, "users.manage") is True
134
+
135
+ async def test_an_empty_requirement_is_satisfied_by_nothing_held(self):
136
+ """Empty means "no permission gates this" — the default for a
137
+ declaration that never named one, not a requirement nobody meets."""
138
+ assert grants(set(), "") is True
139
+
140
+ async def test_it_reads_any_collection(self):
141
+ """Callers hand it whatever they have — the middleware caches a set,
142
+ a test hands a list."""
143
+ assert grants(["users.manage"], "users.manage") is True