From f1e9879e95668848fed8fcd2acecf08b15c30826 Mon Sep 17 00:00:00 2001 From: Norair Arutshyan Date: Tue, 22 Sep 2026 13:53:56 +0100 Subject: [PATCH 1/2] feat(audit): add per-service audit-mixin coverage guard tests Auto-discovers every module under app/models/ via pkgutil instead of relying on __init__.py's explicit imports, so a model that forgets to be wired in can no longer silently skip the coverage check. Shared assertion logic now lives in shared/tests/audit_mixin_coverage.py instead of being duplicated across all four services. Co-Authored-By: Claude Sonnet 5 --- .../audit-mixin-coverage-guard/tasks.md | 12 +++++------ .../ai/tests/test_audit_mixin_coverage.py | 11 ++++++++++ .../budget/tests/test_audit_mixin_coverage.py | 13 ++++++++++++ .../chat/tests/test_audit_mixin_coverage.py | 11 ++++++++++ .../users/tests/test_audit_mixin_coverage.py | 13 ++++++++++++ shared/tests/audit_mixin_coverage.py | 21 +++++++++++++++++++ 6 files changed, 75 insertions(+), 6 deletions(-) create mode 100644 services/ai/tests/test_audit_mixin_coverage.py create mode 100644 services/budget/tests/test_audit_mixin_coverage.py create mode 100644 services/chat/tests/test_audit_mixin_coverage.py create mode 100644 services/users/tests/test_audit_mixin_coverage.py create mode 100644 shared/tests/audit_mixin_coverage.py diff --git a/openspec/changes/audit-mixin-coverage-guard/tasks.md b/openspec/changes/audit-mixin-coverage-guard/tasks.md index a17986e..865a778 100644 --- a/openspec/changes/audit-mixin-coverage-guard/tasks.md +++ b/openspec/changes/audit-mixin-coverage-guard/tasks.md @@ -2,10 +2,10 @@ One task group = one GitHub ticket = one PR, merged before the next group starts ## 1. Coverage guard tests — depends on audit-mixin-auto-population, audit-mixin-rollout-tier3, audit-mixin-rollout-tier4 all being merged -- [ ] 1.1 Confirm the full model inventory is at 100% `AuditMixin`/`AuditColumnsMixin` coverage post-Tier 4 (re-run the original survey); identify any remaining genuine exemptions. -- [ ] 1.2 Add `services/ai/tests/test_audit_mixin_coverage.py`: walk `Base.registry.mappers`, assert each mapped class inherits `AuditMixin`/`AuditColumnsMixin` or is in a documented exemption set. -- [ ] 1.3 Add the equivalent test in `services/budget/tests/`. -- [ ] 1.4 Add the equivalent test in `services/chat/tests/`. -- [ ] 1.5 Add the equivalent test in `services/users/tests/`. -- [ ] 1.6 Verify each guard test fails as expected when a temporary non-compliant model is added locally (sanity-check the assertion actually catches the failure mode, then remove the temporary model). +- [x] 1.1 Confirm the full model inventory is at 100% `AuditMixin`/`AuditColumnsMixin` coverage post-Tier 4 (re-run the original survey); identify any remaining genuine exemptions. +- [x] 1.2 Add `services/ai/tests/test_audit_mixin_coverage.py`: walk `Base.registry.mappers`, assert each mapped class inherits `AuditMixin`/`AuditColumnsMixin` or is in a documented exemption set. +- [x] 1.3 Add the equivalent test in `services/budget/tests/`. +- [x] 1.4 Add the equivalent test in `services/chat/tests/`. +- [x] 1.5 Add the equivalent test in `services/users/tests/`. +- [x] 1.6 Verify each guard test fails as expected when a temporary non-compliant model is added locally (sanity-check the assertion actually catches the failure mode, then remove the temporary model). - [ ] 1.7 Run all 4 services' test suites clean; PR merged. diff --git a/services/ai/tests/test_audit_mixin_coverage.py b/services/ai/tests/test_audit_mixin_coverage.py new file mode 100644 index 0000000..5c6af5c --- /dev/null +++ b/services/ai/tests/test_audit_mixin_coverage.py @@ -0,0 +1,11 @@ +"""Every model on this service's Base must inherit an audit mixin, or be in _EXEMPT.""" + +import app.models +from app.models.base import Base +from shared.tests.audit_mixin_coverage import assert_full_audit_mixin_coverage + +_EXEMPT: dict[str, str] = {} + + +def test_every_model_inherits_audit_mixin(): + assert_full_audit_mixin_coverage(app.models, Base, _EXEMPT) diff --git a/services/budget/tests/test_audit_mixin_coverage.py b/services/budget/tests/test_audit_mixin_coverage.py new file mode 100644 index 0000000..a3abbbd --- /dev/null +++ b/services/budget/tests/test_audit_mixin_coverage.py @@ -0,0 +1,13 @@ +"""Every model on this service's Base must inherit an audit mixin, or be in _EXEMPT.""" + +import app.models +from app.models.base import Base +from shared.tests.audit_mixin_coverage import assert_full_audit_mixin_coverage + +_EXEMPT: dict[str, str] = { + "UserProfileModel": "denormalized cache of users-service state, not independently editable", +} + + +def test_every_model_inherits_audit_mixin(): + assert_full_audit_mixin_coverage(app.models, Base, _EXEMPT) diff --git a/services/chat/tests/test_audit_mixin_coverage.py b/services/chat/tests/test_audit_mixin_coverage.py new file mode 100644 index 0000000..5c6af5c --- /dev/null +++ b/services/chat/tests/test_audit_mixin_coverage.py @@ -0,0 +1,11 @@ +"""Every model on this service's Base must inherit an audit mixin, or be in _EXEMPT.""" + +import app.models +from app.models.base import Base +from shared.tests.audit_mixin_coverage import assert_full_audit_mixin_coverage + +_EXEMPT: dict[str, str] = {} + + +def test_every_model_inherits_audit_mixin(): + assert_full_audit_mixin_coverage(app.models, Base, _EXEMPT) diff --git a/services/users/tests/test_audit_mixin_coverage.py b/services/users/tests/test_audit_mixin_coverage.py new file mode 100644 index 0000000..72db5d7 --- /dev/null +++ b/services/users/tests/test_audit_mixin_coverage.py @@ -0,0 +1,13 @@ +"""Every model on this service's Base must inherit an audit mixin, or be in _EXEMPT.""" + +import app.models +from app.models.base import Base +from shared.tests.audit_mixin_coverage import assert_full_audit_mixin_coverage + +_EXEMPT: dict[str, str] = { + "SessionModel": "system-issued auth session tied 1:1 to user_id, not a user-edited resource", +} + + +def test_every_model_inherits_audit_mixin(): + assert_full_audit_mixin_coverage(app.models, Base, _EXEMPT) diff --git a/shared/tests/audit_mixin_coverage.py b/shared/tests/audit_mixin_coverage.py new file mode 100644 index 0000000..94f6f2f --- /dev/null +++ b/shared/tests/audit_mixin_coverage.py @@ -0,0 +1,21 @@ +import importlib +import pkgutil +from types import ModuleType + +from shared.db.audit_mixin import AuditColumnsMixin + + +def assert_full_audit_mixin_coverage( + models_package: ModuleType, base, exempt: dict[str, str] +) -> None: + """Imports every module under models_package so a model missing from __init__.py can't skip this check.""" + for module_info in pkgutil.iter_modules(models_package.__path__, models_package.__name__ + "."): + importlib.import_module(module_info.name) + + missing = sorted( + mapper.class_.__name__ + for mapper in base.registry.mappers + if not issubclass(mapper.class_, AuditColumnsMixin) + and mapper.class_.__name__ not in exempt + ) + assert not missing, f"Models missing AuditMixin/AuditColumnsMixin: {missing}" From 3ce2e5e06ba7ee682fd7104247028190ff1440c2 Mon Sep 17 00:00:00 2001 From: Norair Arutshyan Date: Tue, 22 Sep 2026 13:59:25 +0100 Subject: [PATCH 2/2] fix(audit): satisfy flake8 line length in audit_mixin_coverage helper Co-Authored-By: Claude Sonnet 5 --- shared/tests/audit_mixin_coverage.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/shared/tests/audit_mixin_coverage.py b/shared/tests/audit_mixin_coverage.py index 94f6f2f..d3d988f 100644 --- a/shared/tests/audit_mixin_coverage.py +++ b/shared/tests/audit_mixin_coverage.py @@ -8,14 +8,14 @@ def assert_full_audit_mixin_coverage( models_package: ModuleType, base, exempt: dict[str, str] ) -> None: - """Imports every module under models_package so a model missing from __init__.py can't skip this check.""" - for module_info in pkgutil.iter_modules(models_package.__path__, models_package.__name__ + "."): + """Imports every module under models_package so a missing __init__.py import can't hide it.""" + prefix = models_package.__name__ + "." + for module_info in pkgutil.iter_modules(models_package.__path__, prefix): importlib.import_module(module_info.name) missing = sorted( mapper.class_.__name__ for mapper in base.registry.mappers - if not issubclass(mapper.class_, AuditColumnsMixin) - and mapper.class_.__name__ not in exempt + if not issubclass(mapper.class_, AuditColumnsMixin) and mapper.class_.__name__ not in exempt ) assert not missing, f"Models missing AuditMixin/AuditColumnsMixin: {missing}"