chore: consolidate platform split checks
This commit is contained in:
26
tests/db_isolation.py
Normal file
26
tests/db_isolation.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
|
||||
from govoplan_core.db.session import DatabaseHandle, configure_database, get_database, reset_database, set_database
|
||||
|
||||
|
||||
@contextmanager
|
||||
def temporary_database(database_url: str) -> Iterator[DatabaseHandle]:
|
||||
try:
|
||||
previous_database = get_database()
|
||||
except RuntimeError:
|
||||
previous_database = None
|
||||
|
||||
database = configure_database(database_url)
|
||||
should_dispose = database is not previous_database
|
||||
try:
|
||||
yield database
|
||||
finally:
|
||||
if should_dispose:
|
||||
database.engine.dispose()
|
||||
if previous_database is None:
|
||||
reset_database()
|
||||
else:
|
||||
set_database(previous_database)
|
||||
@@ -6,10 +6,10 @@ import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal
|
||||
from govoplan_access.auth import ApiPrincipal, get_api_principal
|
||||
from govoplan_core.core.access import (
|
||||
ACCESS_CAPABILITY_NAMES,
|
||||
ACCESS_MODULE_ID,
|
||||
@@ -19,32 +19,41 @@ from govoplan_core.core.access import (
|
||||
CAPABILITY_ACCESS_PERMISSION_EVALUATOR,
|
||||
CAPABILITY_ACCESS_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_ACCESS_RESOURCE_ACCESS,
|
||||
CAPABILITY_ACCESS_EXPLANATION,
|
||||
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY,
|
||||
CAPABILITY_ACCESS_TENANT_PROVISIONER,
|
||||
CAPABILITY_AUDIT_SINK,
|
||||
CAPABILITY_SECURITY_SECRET_PROVIDER,
|
||||
CAPABILITY_TENANCY_TENANT_RESOLVER,
|
||||
AccessDecision,
|
||||
AccessAdministration,
|
||||
AccessDirectory,
|
||||
AccessDecisionProvenance,
|
||||
AccessExplanationService,
|
||||
AccessGovernanceMaterializer,
|
||||
AccessSemanticDirectory,
|
||||
AccessSubjectRef,
|
||||
AccountRef,
|
||||
AuditEvent,
|
||||
AuditSink,
|
||||
FunctionAssignmentRef,
|
||||
FunctionDelegationRef,
|
||||
FunctionRef,
|
||||
GovernanceTemplateMaterialization,
|
||||
GroupRef,
|
||||
IdentityRef,
|
||||
OrganizationUnitRef,
|
||||
PermissionEvaluator,
|
||||
PrincipalRef,
|
||||
PrincipalResolver,
|
||||
ResourceAccessService,
|
||||
RoleRef,
|
||||
SecretProvider,
|
||||
TenantRef,
|
||||
TenantAccessProvisioner,
|
||||
TenantOwnerCandidateRef,
|
||||
TenantResolver,
|
||||
UserRef,
|
||||
)
|
||||
from govoplan_core.security.secrets import CAPABILITY_SECURITY_SECRET_PROVIDER, SecretProvider
|
||||
from govoplan_core.core.campaigns import (
|
||||
CAPABILITY_CAMPAIGNS_ACCESS,
|
||||
CAPABILITY_CAMPAIGNS_DELIVERY_TASKS,
|
||||
@@ -62,11 +71,23 @@ from govoplan_core.core.campaigns import (
|
||||
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.db.session import configure_database, get_database
|
||||
from govoplan_core.server.app import create_app
|
||||
from govoplan_core.server.config import GovoplanServerConfig
|
||||
from govoplan_access.backend.db.models import Account, Group, User, UserGroupMembership
|
||||
from govoplan_access.backend.db.models import (
|
||||
Account,
|
||||
Function,
|
||||
FunctionAssignment,
|
||||
FunctionRoleAssignment,
|
||||
Group,
|
||||
Identity,
|
||||
IdentityAccountLink,
|
||||
OrganizationUnit,
|
||||
Role,
|
||||
User,
|
||||
UserGroupMembership,
|
||||
)
|
||||
from govoplan_tenancy.backend.db.models import Tenant
|
||||
from tests.db_isolation import temporary_database
|
||||
|
||||
|
||||
class _FakeAccessDirectory:
|
||||
@@ -108,6 +129,64 @@ class _FakeAccessDirectory:
|
||||
return subject.label
|
||||
|
||||
|
||||
class _FakeAccessSemanticDirectory(_FakeAccessDirectory):
|
||||
identity = IdentityRef(
|
||||
id="identity-1",
|
||||
display_name="Demo Person",
|
||||
primary_account_id=_FakeAccessDirectory.account.id,
|
||||
account_ids=(_FakeAccessDirectory.account.id, "account-2"),
|
||||
)
|
||||
organization_unit = OrganizationUnitRef(id="ou-1", tenant_id="tenant-1", name="Registry Office")
|
||||
function = FunctionRef(
|
||||
id="function-1",
|
||||
tenant_id="tenant-1",
|
||||
organization_unit_id=organization_unit.id,
|
||||
slug="registry-clerk",
|
||||
name="Registry Clerk",
|
||||
role_ids=("role-1",),
|
||||
delegable=True,
|
||||
act_in_place_allowed=True,
|
||||
)
|
||||
assignment = FunctionAssignmentRef(
|
||||
id="assignment-1",
|
||||
tenant_id="tenant-1",
|
||||
account_id=_FakeAccessDirectory.account.id,
|
||||
identity_id=identity.id,
|
||||
function_id=function.id,
|
||||
organization_unit_id=organization_unit.id,
|
||||
applies_to_subunits=True,
|
||||
)
|
||||
|
||||
def get_identity(self, identity_id: str) -> IdentityRef | None:
|
||||
return self.identity if identity_id == self.identity.id else None
|
||||
|
||||
def accounts_for_identity(self, identity_id: str):
|
||||
return (self.account,) if identity_id == self.identity.id else ()
|
||||
|
||||
def get_organization_unit(self, organization_unit_id: str) -> OrganizationUnitRef | None:
|
||||
return self.organization_unit if organization_unit_id == self.organization_unit.id else None
|
||||
|
||||
def organization_units_for_tenant(self, tenant_id: str):
|
||||
return (self.organization_unit,) if tenant_id == self.organization_unit.tenant_id else ()
|
||||
|
||||
def get_function(self, function_id: str) -> FunctionRef | None:
|
||||
return self.function if function_id == self.function.id else None
|
||||
|
||||
def functions_for_organization_unit(self, organization_unit_id: str, *, include_subunits: bool = False):
|
||||
del include_subunits
|
||||
return (self.function,) if organization_unit_id == self.organization_unit.id else ()
|
||||
|
||||
def get_function_assignment(self, assignment_id: str) -> FunctionAssignmentRef | None:
|
||||
return self.assignment if assignment_id == self.assignment.id else None
|
||||
|
||||
def function_assignments_for_account(self, account_id: str, *, tenant_id: str | None = None):
|
||||
if account_id != self.account.id:
|
||||
return ()
|
||||
if tenant_id is not None and tenant_id != self.assignment.tenant_id:
|
||||
return ()
|
||||
return (self.assignment,)
|
||||
|
||||
|
||||
class _FakePrincipalResolver:
|
||||
def resolve_request(self, request: object, *, session: object | None = None) -> PrincipalRef:
|
||||
del request, session
|
||||
@@ -148,6 +227,30 @@ class _FakeResourceAccessService:
|
||||
return AccessDecision(allowed=True)
|
||||
|
||||
|
||||
class _FakeAccessExplanationService:
|
||||
provenance = (
|
||||
AccessDecisionProvenance(kind="identity", id="identity-1", label="Demo Person"),
|
||||
AccessDecisionProvenance(kind="function", id="assignment-1", label="Registry Clerk", tenant_id="tenant-1"),
|
||||
AccessDecisionProvenance(kind="role", id="role-1", label="Clerk", tenant_id="tenant-1"),
|
||||
AccessDecisionProvenance(kind="right", id="files:file:read", label="Read files"),
|
||||
)
|
||||
|
||||
def explain_scope_provenance(self, principal: PrincipalRef, required_scope: str):
|
||||
del principal, required_scope
|
||||
return self.provenance
|
||||
|
||||
def explain_resource_provenance(
|
||||
self,
|
||||
principal: PrincipalRef,
|
||||
*,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
action: str,
|
||||
):
|
||||
del principal, resource_type, resource_id, action
|
||||
return self.provenance
|
||||
|
||||
|
||||
class _FakeTenantAccessProvisioner:
|
||||
def ensure_default_roles(self, session: object, tenant: object | None = None):
|
||||
del session, tenant
|
||||
@@ -311,6 +414,8 @@ class AccessContractTests(unittest.TestCase):
|
||||
self.assertEqual("access.directory", CAPABILITY_ACCESS_DIRECTORY)
|
||||
self.assertEqual("access.permissionEvaluator", CAPABILITY_ACCESS_PERMISSION_EVALUATOR)
|
||||
self.assertEqual("access.resourceAccess", CAPABILITY_ACCESS_RESOURCE_ACCESS)
|
||||
self.assertEqual("access.semanticDirectory", CAPABILITY_ACCESS_SEMANTIC_DIRECTORY)
|
||||
self.assertEqual("access.explanation", CAPABILITY_ACCESS_EXPLANATION)
|
||||
self.assertEqual("access.tenantProvisioner", CAPABILITY_ACCESS_TENANT_PROVISIONER)
|
||||
self.assertEqual("access.administration", CAPABILITY_ACCESS_ADMINISTRATION)
|
||||
self.assertEqual("access.governanceMaterializer", CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER)
|
||||
@@ -329,8 +434,13 @@ class AccessContractTests(unittest.TestCase):
|
||||
account_id="account-1",
|
||||
membership_id="user-1",
|
||||
tenant_id="tenant-1",
|
||||
identity_id="identity-1",
|
||||
scopes=frozenset({"campaigns:campaign:read"}),
|
||||
group_ids=frozenset({"group-1"}),
|
||||
role_ids=frozenset({"role-1"}),
|
||||
function_assignment_ids=frozenset({"assignment-1"}),
|
||||
delegation_ids=frozenset({"delegation-1"}),
|
||||
acting_for_account_id="account-2",
|
||||
email="demo@example.test",
|
||||
)
|
||||
role = RoleRef(id="role-1", name="Reviewer", level="tenant", tenant_id="tenant-1")
|
||||
@@ -338,15 +448,80 @@ class AccessContractTests(unittest.TestCase):
|
||||
self.assertEqual("account-1", principal.account_id)
|
||||
self.assertEqual(frozenset({"campaigns:campaign:read"}), principal.scopes)
|
||||
self.assertEqual("Reviewer", role.name)
|
||||
payload = principal.to_dict()
|
||||
self.assertEqual(["campaigns:campaign:read"], payload["scopes"])
|
||||
self.assertEqual(["group-1"], payload["group_ids"])
|
||||
self.assertEqual(["role-1"], payload["role_ids"])
|
||||
self.assertEqual(["assignment-1"], payload["function_assignment_ids"])
|
||||
self.assertEqual(["delegation-1"], payload["delegation_ids"])
|
||||
self.assertEqual("identity-1", payload["identity_id"])
|
||||
self.assertEqual("account-2", payload["acting_for_account_id"])
|
||||
self.assertEqual("session", payload["auth_method"])
|
||||
self.assertIsNone(payload["api_key_id"])
|
||||
self.assertIsNone(payload["service_account_id"])
|
||||
self.assertEqual(principal, PrincipalRef.from_mapping(payload))
|
||||
with self.assertRaises(AttributeError):
|
||||
principal.account_id = "changed" # type: ignore[misc]
|
||||
|
||||
def test_identity_function_and_delegation_refs_model_organization_scope(self) -> None:
|
||||
identity = IdentityRef(
|
||||
id="identity-1",
|
||||
display_name="Demo Person",
|
||||
primary_account_id="account-1",
|
||||
account_ids=("account-1", "account-2"),
|
||||
)
|
||||
organization_unit = OrganizationUnitRef(id="ou-1", tenant_id="tenant-1", name="Registry Office")
|
||||
function = FunctionRef(
|
||||
id="function-1",
|
||||
tenant_id="tenant-1",
|
||||
organization_unit_id=organization_unit.id,
|
||||
slug="registry-clerk",
|
||||
name="Registry Clerk",
|
||||
role_ids=("role-1",),
|
||||
delegable=True,
|
||||
act_in_place_allowed=True,
|
||||
)
|
||||
assignment = FunctionAssignmentRef(
|
||||
id="assignment-1",
|
||||
tenant_id="tenant-1",
|
||||
account_id="account-1",
|
||||
identity_id=identity.id,
|
||||
function_id=function.id,
|
||||
organization_unit_id=organization_unit.id,
|
||||
applies_to_subunits=True,
|
||||
)
|
||||
delegation = FunctionDelegationRef(
|
||||
id="delegation-1",
|
||||
tenant_id="tenant-1",
|
||||
function_assignment_id=assignment.id,
|
||||
delegator_account_id="account-1",
|
||||
delegate_account_id="account-2",
|
||||
mode="act_in_place",
|
||||
)
|
||||
provenance = AccessDecisionProvenance(
|
||||
kind="function",
|
||||
id=assignment.id,
|
||||
label=function.name,
|
||||
tenant_id="tenant-1",
|
||||
source="delegation",
|
||||
details={"organization_unit_id": organization_unit.id, "applies_to_subunits": True},
|
||||
)
|
||||
|
||||
self.assertEqual(("account-1", "account-2"), identity.account_ids)
|
||||
self.assertEqual("ou-1", function.organization_unit_id)
|
||||
self.assertTrue(assignment.applies_to_subunits)
|
||||
self.assertEqual("act_in_place", delegation.mode)
|
||||
self.assertEqual("function", provenance.to_dict()["kind"])
|
||||
self.assertEqual({"organization_unit_id": "ou-1", "applies_to_subunits": True}, provenance.to_dict()["details"])
|
||||
|
||||
def test_protocol_shapes_match_reference_implementations(self) -> None:
|
||||
self.assertIsInstance(_FakePrincipalResolver(), PrincipalResolver)
|
||||
self.assertIsInstance(_FakeTenantResolver(), TenantResolver)
|
||||
self.assertIsInstance(_FakeAccessDirectory(), AccessDirectory)
|
||||
self.assertIsInstance(_FakeAccessSemanticDirectory(), AccessSemanticDirectory)
|
||||
self.assertIsInstance(_FakePermissionEvaluator(), PermissionEvaluator)
|
||||
self.assertIsInstance(_FakeResourceAccessService(), ResourceAccessService)
|
||||
self.assertIsInstance(_FakeAccessExplanationService(), AccessExplanationService)
|
||||
self.assertIsInstance(_FakeTenantAccessProvisioner(), TenantAccessProvisioner)
|
||||
self.assertIsInstance(_FakeAccessAdministration(), AccessAdministration)
|
||||
self.assertIsInstance(_FakeAccessGovernanceMaterializer(), AccessGovernanceMaterializer)
|
||||
@@ -389,6 +564,8 @@ class AccessContractTests(unittest.TestCase):
|
||||
|
||||
def test_access_capabilities_register_and_resolve_through_platform_registry(self) -> None:
|
||||
directory = _FakeAccessDirectory()
|
||||
semantic_directory = _FakeAccessSemanticDirectory()
|
||||
explanation = _FakeAccessExplanationService()
|
||||
resolver = _FakePrincipalResolver()
|
||||
evaluator = _FakePermissionEvaluator()
|
||||
tenant_resolver = _FakeTenantResolver()
|
||||
@@ -400,6 +577,8 @@ class AccessContractTests(unittest.TestCase):
|
||||
version="test",
|
||||
capability_factories={
|
||||
CAPABILITY_ACCESS_DIRECTORY: lambda context: directory,
|
||||
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY: lambda context: semantic_directory,
|
||||
CAPABILITY_ACCESS_EXPLANATION: lambda context: explanation,
|
||||
CAPABILITY_ACCESS_PRINCIPAL_RESOLVER: lambda context: resolver,
|
||||
CAPABILITY_ACCESS_PERMISSION_EVALUATOR: lambda context: evaluator,
|
||||
CAPABILITY_ACCESS_ADMINISTRATION: lambda context: administration,
|
||||
@@ -413,6 +592,8 @@ class AccessContractTests(unittest.TestCase):
|
||||
registry.configure_capability_context(ModuleContext(registry=registry, settings=object()))
|
||||
|
||||
self.assertIs(directory, registry.require_capability(CAPABILITY_ACCESS_DIRECTORY))
|
||||
self.assertIs(semantic_directory, registry.require_capability(CAPABILITY_ACCESS_SEMANTIC_DIRECTORY))
|
||||
self.assertIs(explanation, registry.require_capability(CAPABILITY_ACCESS_EXPLANATION))
|
||||
self.assertIs(resolver, registry.require_capability(CAPABILITY_ACCESS_PRINCIPAL_RESOLVER))
|
||||
self.assertIs(evaluator, registry.require_capability(CAPABILITY_ACCESS_PERMISSION_EVALUATOR))
|
||||
self.assertIs(administration, registry.require_capability(CAPABILITY_ACCESS_ADMINISTRATION))
|
||||
@@ -422,50 +603,240 @@ class AccessContractTests(unittest.TestCase):
|
||||
def test_sql_directory_and_tenant_resolver_return_access_dtos(self) -> None:
|
||||
root = Path(tempfile.mkdtemp(prefix="govoplan-access-directory-"))
|
||||
try:
|
||||
configure_database(f"sqlite:///{root / 'directory.db'}")
|
||||
database = get_database()
|
||||
Base.metadata.create_all(bind=database.engine)
|
||||
with database.session() as session:
|
||||
tenant = Tenant(id="tenant-directory", slug="directory", name="Directory Tenant", settings={})
|
||||
account = Account(
|
||||
id="account-directory",
|
||||
email="directory@example.test",
|
||||
normalized_email="directory@example.test",
|
||||
display_name="Directory User",
|
||||
with temporary_database(f"sqlite:///{root / 'directory.db'}") as database:
|
||||
Base.metadata.create_all(bind=database.engine)
|
||||
with database.session() as session:
|
||||
tenant = Tenant(id="tenant-directory", slug="directory", name="Directory Tenant", settings={})
|
||||
account = Account(
|
||||
id="account-directory",
|
||||
email="directory@example.test",
|
||||
normalized_email="directory@example.test",
|
||||
display_name="Directory User",
|
||||
)
|
||||
user = User(
|
||||
id="user-directory",
|
||||
tenant_id=tenant.id,
|
||||
account_id=account.id,
|
||||
email=account.email,
|
||||
display_name=None,
|
||||
settings={},
|
||||
mail_profile_policy={},
|
||||
)
|
||||
group = Group(id="group-directory", tenant_id=tenant.id, slug="review", name="Review", description=None)
|
||||
membership = UserGroupMembership(tenant_id=tenant.id, user_id=user.id, group_id=group.id)
|
||||
identity = Identity(id="identity-directory", display_name="Directory Person", source="local")
|
||||
identity_link = IdentityAccountLink(
|
||||
identity_id=identity.id,
|
||||
account_id=account.id,
|
||||
is_primary=True,
|
||||
source="local",
|
||||
)
|
||||
organization_unit = OrganizationUnit(
|
||||
id="ou-directory",
|
||||
tenant_id=tenant.id,
|
||||
slug="registry",
|
||||
name="Registry",
|
||||
)
|
||||
role = Role(
|
||||
id="role-directory",
|
||||
tenant_id=tenant.id,
|
||||
slug="registry-reader",
|
||||
name="Registry Reader",
|
||||
permissions=["files:read"],
|
||||
)
|
||||
function = Function(
|
||||
id="function-directory",
|
||||
tenant_id=tenant.id,
|
||||
organization_unit_id=organization_unit.id,
|
||||
slug="registry-clerk",
|
||||
name="Registry Clerk",
|
||||
delegable=True,
|
||||
)
|
||||
function_role = FunctionRoleAssignment(tenant_id=tenant.id, function_id=function.id, role_id=role.id)
|
||||
function_assignment = FunctionAssignment(
|
||||
id="assignment-directory",
|
||||
tenant_id=tenant.id,
|
||||
account_id=account.id,
|
||||
identity_id=identity.id,
|
||||
function_id=function.id,
|
||||
organization_unit_id=organization_unit.id,
|
||||
applies_to_subunits=True,
|
||||
)
|
||||
session.add_all([
|
||||
tenant,
|
||||
account,
|
||||
user,
|
||||
group,
|
||||
membership,
|
||||
identity,
|
||||
identity_link,
|
||||
organization_unit,
|
||||
role,
|
||||
function,
|
||||
function_role,
|
||||
function_assignment,
|
||||
])
|
||||
session.commit()
|
||||
|
||||
from govoplan_access.backend.directory import SqlAccessDirectory
|
||||
from govoplan_access.backend.security.sessions import collect_user_scopes
|
||||
from govoplan_tenancy.backend.capabilities import SqlTenantResolver
|
||||
|
||||
directory = SqlAccessDirectory()
|
||||
tenant_resolver = SqlTenantResolver()
|
||||
|
||||
self.assertEqual("directory@example.test", directory.get_account("account-directory").email) # type: ignore[union-attr]
|
||||
self.assertEqual("Directory User", directory.get_user("user-directory").display_name) # type: ignore[union-attr]
|
||||
self.assertEqual(["user-directory"], [user.id for user in directory.users_for_tenant("tenant-directory")])
|
||||
self.assertEqual(["group-directory"], [group.id for group in directory.groups_for_tenant("tenant-directory")])
|
||||
self.assertEqual(["group-directory"], [group.id for group in directory.groups_for_user("user-directory", tenant_id="tenant-directory")])
|
||||
self.assertEqual("Review", directory.display_label(AccessSubjectRef(kind="group", id="group-directory", tenant_id="tenant-directory")))
|
||||
self.assertEqual("Directory Person", directory.get_identity("identity-directory").display_name) # type: ignore[union-attr]
|
||||
self.assertEqual(["account-directory"], [account.id for account in directory.accounts_for_identity("identity-directory")])
|
||||
self.assertEqual("Registry", directory.get_organization_unit("ou-directory").name) # type: ignore[union-attr]
|
||||
self.assertEqual(["role-directory"], list(directory.get_function("function-directory").role_ids)) # type: ignore[union-attr]
|
||||
self.assertEqual(
|
||||
["assignment-directory"],
|
||||
[item.id for item in directory.function_assignments_for_account("account-directory", tenant_id="tenant-directory")],
|
||||
)
|
||||
user = User(
|
||||
id="user-directory",
|
||||
tenant_id=tenant.id,
|
||||
account_id=account.id,
|
||||
email=account.email,
|
||||
display_name=None,
|
||||
settings={},
|
||||
mail_profile_policy={},
|
||||
with database.session() as session:
|
||||
user = session.get(User, "user-directory")
|
||||
self.assertIn("files:read", collect_user_scopes(session, user, include_system=False)) # type: ignore[arg-type]
|
||||
self.assertEqual("directory", tenant_resolver.get_tenant("tenant-directory").slug) # type: ignore[union-attr]
|
||||
self.assertEqual(
|
||||
"tenant-directory",
|
||||
tenant_resolver.current_tenant(
|
||||
PrincipalRef(account_id="account-directory", membership_id="user-directory", tenant_id="tenant-directory")
|
||||
).id, # type: ignore[union-attr]
|
||||
)
|
||||
group = Group(id="group-directory", tenant_id=tenant.id, slug="review", name="Review", description=None)
|
||||
membership = UserGroupMembership(tenant_id=tenant.id, user_id=user.id, group_id=group.id)
|
||||
session.add_all([tenant, account, user, group, membership])
|
||||
session.commit()
|
||||
finally:
|
||||
shutil.rmtree(root, ignore_errors=True)
|
||||
|
||||
from govoplan_access.backend.directory import SqlAccessDirectory
|
||||
from govoplan_tenancy.backend.capabilities import SqlTenantResolver
|
||||
def test_semantic_access_admin_crud_endpoints(self) -> None:
|
||||
root = Path(tempfile.mkdtemp(prefix="govoplan-access-semantic-api-"))
|
||||
try:
|
||||
with temporary_database(f"sqlite:///{root / 'semantic.db'}") as database:
|
||||
Base.metadata.create_all(bind=database.engine)
|
||||
tenant_id = "tenant-semantic"
|
||||
account_id = "account-semantic"
|
||||
delegate_account_id = "account-semantic-delegate"
|
||||
user_id = "user-semantic"
|
||||
role_id = "role-semantic"
|
||||
with database.session() as session:
|
||||
tenant = Tenant(id=tenant_id, slug="semantic", name="Semantic Tenant", settings={})
|
||||
account = Account(
|
||||
id=account_id,
|
||||
email="semantic@example.test",
|
||||
normalized_email="semantic@example.test",
|
||||
display_name="Semantic User",
|
||||
)
|
||||
delegate_account = Account(
|
||||
id=delegate_account_id,
|
||||
email="delegate@example.test",
|
||||
normalized_email="delegate@example.test",
|
||||
display_name="Delegate User",
|
||||
)
|
||||
user = User(
|
||||
id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
account_id=account_id,
|
||||
email=account.email,
|
||||
display_name=account.display_name,
|
||||
settings={},
|
||||
mail_profile_policy={},
|
||||
)
|
||||
role = Role(
|
||||
id=role_id,
|
||||
tenant_id=tenant_id,
|
||||
slug="semantic-reader",
|
||||
name="Semantic Reader",
|
||||
permissions=["files:read"],
|
||||
)
|
||||
session.add_all([tenant, account, delegate_account, user, role])
|
||||
session.commit()
|
||||
|
||||
directory = SqlAccessDirectory()
|
||||
tenant_resolver = SqlTenantResolver()
|
||||
from govoplan_access.backend.api.v1.routes import router as admin_router
|
||||
|
||||
self.assertEqual("directory@example.test", directory.get_account("account-directory").email) # type: ignore[union-attr]
|
||||
self.assertEqual("Directory User", directory.get_user("user-directory").display_name) # type: ignore[union-attr]
|
||||
self.assertEqual(["user-directory"], [user.id for user in directory.users_for_tenant("tenant-directory")])
|
||||
self.assertEqual(["group-directory"], [group.id for group in directory.groups_for_tenant("tenant-directory")])
|
||||
self.assertEqual(["group-directory"], [group.id for group in directory.groups_for_user("user-directory", tenant_id="tenant-directory")])
|
||||
self.assertEqual("Review", directory.display_label(AccessSubjectRef(kind="group", id="group-directory", tenant_id="tenant-directory")))
|
||||
self.assertEqual("directory", tenant_resolver.get_tenant("tenant-directory").slug) # type: ignore[union-attr]
|
||||
self.assertEqual(
|
||||
"tenant-directory",
|
||||
tenant_resolver.current_tenant(
|
||||
PrincipalRef(account_id="account-directory", membership_id="user-directory", tenant_id="tenant-directory")
|
||||
).id, # type: ignore[union-attr]
|
||||
)
|
||||
app = FastAPI()
|
||||
app.include_router(admin_router)
|
||||
|
||||
def principal_override() -> ApiPrincipal:
|
||||
with database.session() as session:
|
||||
account = session.get(Account, account_id)
|
||||
user = session.get(User, user_id)
|
||||
assert account is not None
|
||||
assert user is not None
|
||||
return ApiPrincipal(
|
||||
principal=PrincipalRef(
|
||||
account_id=account_id,
|
||||
membership_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
scopes=frozenset({
|
||||
"admin:roles:read",
|
||||
"admin:roles:write",
|
||||
"admin:roles:assign",
|
||||
"system:accounts:read",
|
||||
"system:accounts:update",
|
||||
}),
|
||||
),
|
||||
account=account,
|
||||
user=user,
|
||||
)
|
||||
|
||||
app.dependency_overrides[get_api_principal] = principal_override
|
||||
|
||||
with TestClient(app) as client:
|
||||
identity_response = client.post(
|
||||
"/admin/identities",
|
||||
json={"display_name": "Semantic Person", "account_ids": [account_id], "primary_account_id": account_id},
|
||||
)
|
||||
self.assertEqual(201, identity_response.status_code, identity_response.text)
|
||||
identity_id = identity_response.json()["id"]
|
||||
|
||||
unit_response = client.post(
|
||||
"/admin/organization-units",
|
||||
json={"slug": "registry", "name": "Registry"},
|
||||
)
|
||||
self.assertEqual(201, unit_response.status_code, unit_response.text)
|
||||
organization_unit_id = unit_response.json()["id"]
|
||||
|
||||
function_response = client.post(
|
||||
"/admin/functions",
|
||||
json={
|
||||
"organization_unit_id": organization_unit_id,
|
||||
"slug": "registry-clerk",
|
||||
"name": "Registry Clerk",
|
||||
"role_ids": [role_id],
|
||||
"delegable": True,
|
||||
},
|
||||
)
|
||||
self.assertEqual(201, function_response.status_code, function_response.text)
|
||||
function_id = function_response.json()["id"]
|
||||
self.assertEqual([role_id], function_response.json()["role_ids"])
|
||||
|
||||
assignment_response = client.post(
|
||||
"/admin/function-assignments",
|
||||
json={
|
||||
"account_id": account_id,
|
||||
"identity_id": identity_id,
|
||||
"function_id": function_id,
|
||||
"applies_to_subunits": True,
|
||||
},
|
||||
)
|
||||
self.assertEqual(201, assignment_response.status_code, assignment_response.text)
|
||||
assignment_id = assignment_response.json()["id"]
|
||||
self.assertTrue(assignment_response.json()["applies_to_subunits"])
|
||||
|
||||
delegation_response = client.post(
|
||||
"/admin/function-delegations",
|
||||
json={"function_assignment_id": assignment_id, "delegate_account_id": delegate_account_id},
|
||||
)
|
||||
self.assertEqual(201, delegation_response.status_code, delegation_response.text)
|
||||
self.assertEqual(delegate_account_id, delegation_response.json()["delegate_account_id"])
|
||||
|
||||
list_response = client.get(f"/admin/function-assignments?account_id={account_id}")
|
||||
self.assertEqual(200, list_response.status_code, list_response.text)
|
||||
self.assertEqual([assignment_id], [item["id"] for item in list_response.json()["assignments"]])
|
||||
finally:
|
||||
shutil.rmtree(root, ignore_errors=True)
|
||||
|
||||
@@ -532,32 +903,32 @@ class AccessContractTests(unittest.TestCase):
|
||||
lifespan=None,
|
||||
app_configurators=(),
|
||||
)
|
||||
app = create_app(config)
|
||||
with temporary_database(f"sqlite:///{root / 'test.db'}") as database:
|
||||
app = create_app(config)
|
||||
|
||||
database = get_database()
|
||||
Base.metadata.create_all(bind=database.engine)
|
||||
with database.session() as session:
|
||||
tenant = Tenant(id=tenant_id, slug="capability", name="Capability Tenant", settings={})
|
||||
account = Account(
|
||||
id=account_id,
|
||||
email="capability@example.test",
|
||||
normalized_email="capability@example.test",
|
||||
display_name="Capability User",
|
||||
)
|
||||
user = User(
|
||||
id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
account_id=account_id,
|
||||
email=account.email,
|
||||
display_name=account.display_name,
|
||||
settings={},
|
||||
mail_profile_policy={},
|
||||
)
|
||||
session.add_all([tenant, account, user])
|
||||
session.commit()
|
||||
Base.metadata.create_all(bind=database.engine)
|
||||
with database.session() as session:
|
||||
tenant = Tenant(id=tenant_id, slug="capability", name="Capability Tenant", settings={})
|
||||
account = Account(
|
||||
id=account_id,
|
||||
email="capability@example.test",
|
||||
normalized_email="capability@example.test",
|
||||
display_name="Capability User",
|
||||
)
|
||||
user = User(
|
||||
id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
account_id=account_id,
|
||||
email=account.email,
|
||||
display_name=account.display_name,
|
||||
settings={},
|
||||
mail_profile_policy={},
|
||||
)
|
||||
session.add_all([tenant, account, user])
|
||||
session.commit()
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/api/v1/capability-principal")
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/api/v1/capability-principal")
|
||||
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
self.assertEqual(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
121
tests/test_change_sequence.py
Normal file
121
tests/test_change_sequence.py
Normal file
@@ -0,0 +1,121 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from govoplan_core.core.change_sequence import (
|
||||
ChangeSequenceEntry,
|
||||
ChangeSequenceRetentionFloor,
|
||||
decode_sequence_watermark,
|
||||
encode_sequence_watermark,
|
||||
max_sequence_id,
|
||||
min_sequence_id,
|
||||
prune_sequence_entries,
|
||||
record_change,
|
||||
retained_sequence_floor,
|
||||
sequence_entries_since,
|
||||
sequence_watermark_is_expired,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
|
||||
|
||||
class ChangeSequenceTests(unittest.TestCase):
|
||||
def test_records_changes_and_returns_entries_after_watermark(self) -> None:
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
SessionLocal = sessionmaker(bind=engine)
|
||||
try:
|
||||
Base.metadata.create_all(bind=engine, tables=[ChangeSequenceEntry.__table__, ChangeSequenceRetentionFloor.__table__])
|
||||
with SessionLocal() as session:
|
||||
first = record_change(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
module_id="files",
|
||||
collection="files.assets",
|
||||
resource_type="file",
|
||||
resource_id="file-1",
|
||||
operation="created",
|
||||
)
|
||||
second = record_change(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
module_id="files",
|
||||
collection="files.assets",
|
||||
resource_type="file",
|
||||
resource_id="file-1",
|
||||
operation="updated",
|
||||
)
|
||||
session.commit()
|
||||
|
||||
self.assertEqual(encode_sequence_watermark(first.id), "seq:1")
|
||||
self.assertEqual(decode_sequence_watermark("seq:1"), 1)
|
||||
self.assertEqual(max_sequence_id(session, tenant_id="tenant-1", module_id="files"), second.id)
|
||||
self.assertEqual(min_sequence_id(session, tenant_id="tenant-1", module_id="files"), first.id)
|
||||
self.assertFalse(sequence_watermark_is_expired(session, tenant_id="tenant-1", module_id="files", since=first.id - 1))
|
||||
self.assertEqual(
|
||||
[entry.operation for entry in sequence_entries_since(session, tenant_id="tenant-1", module_id="files", since=first.id)],
|
||||
["updated"],
|
||||
)
|
||||
|
||||
session.delete(first)
|
||||
session.commit()
|
||||
self.assertFalse(sequence_watermark_is_expired(session, tenant_id="tenant-1", module_id="files", since=0))
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
def test_pruning_records_retention_floor_for_stale_watermarks(self) -> None:
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
SessionLocal = sessionmaker(bind=engine)
|
||||
try:
|
||||
Base.metadata.create_all(bind=engine, tables=[ChangeSequenceEntry.__table__, ChangeSequenceRetentionFloor.__table__])
|
||||
with SessionLocal() as session:
|
||||
first = record_change(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
module_id="files",
|
||||
collection="files.assets",
|
||||
resource_type="file",
|
||||
resource_id="file-1",
|
||||
operation="created",
|
||||
)
|
||||
second = record_change(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
module_id="files",
|
||||
collection="files.assets",
|
||||
resource_type="file",
|
||||
resource_id="file-2",
|
||||
operation="created",
|
||||
)
|
||||
session.commit()
|
||||
first_id = first.id
|
||||
second_resource_id = second.resource_id
|
||||
|
||||
deleted = prune_sequence_entries(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
module_id="files",
|
||||
collections=("files.assets",),
|
||||
before_or_equal_id=first_id,
|
||||
)
|
||||
session.commit()
|
||||
|
||||
self.assertEqual(deleted, 1)
|
||||
self.assertEqual(retained_sequence_floor(session, tenant_id="tenant-1", module_id="files", collections=("files.assets",)), first_id)
|
||||
self.assertTrue(sequence_watermark_is_expired(session, tenant_id="tenant-1", module_id="files", collections=("files.assets",), since=0))
|
||||
self.assertFalse(sequence_watermark_is_expired(session, tenant_id="tenant-1", module_id="files", collections=("files.assets",), since=first_id))
|
||||
self.assertEqual(
|
||||
[entry.resource_id for entry in sequence_entries_since(session, tenant_id="tenant-1", module_id="files", collections=("files.assets",), since=first_id)],
|
||||
[second_resource_id],
|
||||
)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
def test_rejects_invalid_watermark(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
decode_sequence_watermark("seq:not-a-number")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
103
tests/test_conditional_requests.py
Normal file
103
tests/test_conditional_requests.py
Normal file
@@ -0,0 +1,103 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from fastapi import APIRouter, Response
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.server.fastapi import create_govoplan_app
|
||||
from govoplan_core.server.platform import create_platform_router
|
||||
|
||||
|
||||
class ConditionalRequestTests(unittest.TestCase):
|
||||
def _client(self) -> TestClient:
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/json")
|
||||
def json_payload(value: str = "alpha") -> dict[str, str]:
|
||||
return {"value": value}
|
||||
|
||||
@router.get("/cookie")
|
||||
def cookie_payload(response: Response) -> dict[str, bool]:
|
||||
response.set_cookie("govoplan-test", "changed")
|
||||
return {"ok": True}
|
||||
|
||||
@router.get("/text")
|
||||
def text_payload() -> PlainTextResponse:
|
||||
return PlainTextResponse("plain")
|
||||
|
||||
app = create_govoplan_app(
|
||||
title="conditional request test",
|
||||
version="test",
|
||||
registry=PlatformRegistry(),
|
||||
api_router=router,
|
||||
)
|
||||
return TestClient(app)
|
||||
|
||||
def test_json_get_receives_private_etag_and_matching_request_returns_304(self) -> None:
|
||||
with self._client() as client:
|
||||
first = client.get("/json", headers={"X-Request-ID": "request-1"})
|
||||
self.assertEqual(200, first.status_code, first.text)
|
||||
self.assertEqual({"value": "alpha"}, first.json())
|
||||
etag = first.headers.get("etag")
|
||||
self.assertIsNotNone(etag)
|
||||
self.assertTrue(etag.startswith('W/"sha256-'), etag)
|
||||
self.assertIn("private", first.headers.get("cache-control", ""))
|
||||
self.assertIn("no-cache", first.headers.get("cache-control", ""))
|
||||
self.assertIn("authorization", first.headers.get("vary", "").lower())
|
||||
self.assertEqual("request-1", first.headers["X-Correlation-ID"])
|
||||
|
||||
second = client.get("/json", headers={"If-None-Match": etag or "", "X-Request-ID": "request-2"})
|
||||
self.assertEqual(304, second.status_code, second.text)
|
||||
self.assertEqual(b"", second.content)
|
||||
self.assertEqual(etag, second.headers.get("etag"))
|
||||
self.assertEqual("request-2", second.headers["X-Correlation-ID"])
|
||||
|
||||
def test_changed_json_body_does_not_match_previous_etag(self) -> None:
|
||||
with self._client() as client:
|
||||
first = client.get("/json?value=alpha")
|
||||
etag = first.headers.get("etag")
|
||||
self.assertIsNotNone(etag)
|
||||
|
||||
changed = client.get("/json?value=beta", headers={"If-None-Match": etag or ""})
|
||||
self.assertEqual(200, changed.status_code, changed.text)
|
||||
self.assertEqual({"value": "beta"}, changed.json())
|
||||
self.assertNotEqual(etag, changed.headers.get("etag"))
|
||||
|
||||
def test_conditional_middleware_skips_cookie_and_non_json_responses(self) -> None:
|
||||
with self._client() as client:
|
||||
cookie = client.get("/cookie")
|
||||
self.assertEqual(200, cookie.status_code, cookie.text)
|
||||
self.assertNotIn("etag", cookie.headers)
|
||||
|
||||
text = client.get("/text")
|
||||
self.assertEqual(200, text.status_code, text.text)
|
||||
self.assertNotIn("etag", text.headers)
|
||||
|
||||
def test_platform_endpoints_return_304_for_matching_if_none_match(self) -> None:
|
||||
api_router = APIRouter(prefix="/api/v1")
|
||||
api_router.include_router(create_platform_router())
|
||||
app = create_govoplan_app(
|
||||
title="conditional platform endpoint test",
|
||||
version="test",
|
||||
registry=PlatformRegistry(),
|
||||
api_router=api_router,
|
||||
)
|
||||
|
||||
with TestClient(app) as client:
|
||||
for path in ("/api/v1/platform/status", "/api/v1/platform/modules"):
|
||||
first = client.get(path)
|
||||
self.assertEqual(200, first.status_code, first.text)
|
||||
etag = first.headers.get("etag")
|
||||
self.assertIsNotNone(etag, path)
|
||||
|
||||
second = client.get(path, headers={"If-None-Match": etag or ""})
|
||||
self.assertEqual(304, second.status_code, second.text)
|
||||
self.assertEqual(b"", second.content)
|
||||
self.assertEqual(etag, second.headers.get("etag"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
116
tests/test_core_events.py
Normal file
116
tests/test_core_events.py
Normal file
@@ -0,0 +1,116 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from govoplan_core.audit.logging import audit_event, audit_operation_context
|
||||
from govoplan_core.core.events import EventBus, PlatformEvent, current_event_trace, event_context, normalize_trace_id
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.server.fastapi import create_govoplan_app
|
||||
from tests.db_isolation import temporary_database
|
||||
|
||||
|
||||
class CoreEventTests(unittest.TestCase):
|
||||
def test_event_bus_adds_trace_ids_and_propagates_causation_to_nested_events(self) -> None:
|
||||
bus = EventBus()
|
||||
seen: list[PlatformEvent] = []
|
||||
|
||||
def parent_handler(event: PlatformEvent) -> None:
|
||||
seen.append(event)
|
||||
bus.publish(PlatformEvent(type="child.ready", module_id="demo"))
|
||||
|
||||
bus.subscribe("parent.ready", parent_handler)
|
||||
bus.subscribe("child.ready", seen.append)
|
||||
|
||||
bus.publish(PlatformEvent(type="parent.ready", module_id="demo", correlation_id="corr-1"))
|
||||
|
||||
self.assertEqual(2, len(seen))
|
||||
parent, child = seen
|
||||
self.assertEqual("corr-1", parent.correlation_id)
|
||||
self.assertIsNone(parent.causation_id)
|
||||
self.assertEqual("corr-1", child.correlation_id)
|
||||
self.assertEqual(parent.event_id, child.causation_id)
|
||||
|
||||
def test_request_correlation_middleware_sets_event_context_and_response_header(self) -> None:
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/trace")
|
||||
def trace() -> dict[str, str | None]:
|
||||
active = current_event_trace()
|
||||
return {"correlation_id": active.correlation_id if active else None}
|
||||
|
||||
app = create_govoplan_app(
|
||||
title="event test",
|
||||
version="test",
|
||||
registry=PlatformRegistry(),
|
||||
api_router=router,
|
||||
)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/trace", headers={"X-Request-ID": "request-123"})
|
||||
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
self.assertEqual("request-123", response.json()["correlation_id"])
|
||||
self.assertEqual("request-123", response.headers["X-Correlation-ID"])
|
||||
|
||||
def test_audit_event_persists_trace_details_from_event_context(self) -> None:
|
||||
root = Path(tempfile.mkdtemp(prefix="govoplan-audit-trace-"))
|
||||
try:
|
||||
with temporary_database(f"sqlite:///{root / 'audit.db'}") as database:
|
||||
from govoplan_access.backend.db import models as access_models # noqa: F401
|
||||
from govoplan_admin.backend.db import models as admin_models # noqa: F401
|
||||
from govoplan_audit.backend.db import models as audit_models # noqa: F401
|
||||
from govoplan_tenancy.backend.db import models as tenancy_models # noqa: F401
|
||||
|
||||
Base.metadata.create_all(bind=database.engine)
|
||||
with database.session() as session:
|
||||
with event_context(correlation_id="corr-1", causation_id="cause-1"):
|
||||
item = audit_event(
|
||||
session,
|
||||
tenant_id=None,
|
||||
scope="system",
|
||||
action="demo.changed",
|
||||
object_type="demo",
|
||||
object_id="demo-1",
|
||||
details={"password": "secret"},
|
||||
)
|
||||
|
||||
self.assertEqual({"correlation_id": "corr-1", "causation_id": "cause-1"}, item.details["_trace"])
|
||||
self.assertEqual("<redacted>", item.details["password"])
|
||||
finally:
|
||||
shutil.rmtree(root, ignore_errors=True)
|
||||
|
||||
def test_audit_operation_context_keeps_lifecycle_ids_and_sanitizes_details(self) -> None:
|
||||
payload = audit_operation_context(
|
||||
module_id="mail",
|
||||
request_id="request-1",
|
||||
run_id="run-1",
|
||||
outcome="queued",
|
||||
trace={"correlation_id": " corr-1 ", "causation_id": "cause-1", "token": "secret"},
|
||||
options={"migrate_database": True, "api_key": "secret"},
|
||||
empty=None,
|
||||
)
|
||||
|
||||
self.assertEqual("mail", payload["module_id"])
|
||||
self.assertEqual("request-1", payload["request_id"])
|
||||
self.assertEqual("run-1", payload["run_id"])
|
||||
self.assertEqual("queued", payload["outcome"])
|
||||
self.assertEqual({"correlation_id": "corr-1", "causation_id": "cause-1"}, payload["_trace"])
|
||||
self.assertEqual("<redacted>", payload["options"]["api_key"])
|
||||
self.assertNotIn("token", payload["_trace"])
|
||||
self.assertNotIn("empty", payload)
|
||||
|
||||
def test_trace_ids_are_normalized_for_headers_and_audit_input(self) -> None:
|
||||
self.assertEqual("abc-123", normalize_trace_id(" abc-123 "))
|
||||
self.assertIsNone(normalize_trace_id("../bad"))
|
||||
self.assertIsNone(normalize_trace_id("x" * 129))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -12,10 +12,13 @@ from sqlalchemy import create_engine, inspect, text
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.db.migrations import (
|
||||
REVISION_AUTH_RBAC,
|
||||
REVISION_CORE_CHANGE_SEQUENCE,
|
||||
REVISION_FILE_FOLDERS,
|
||||
REVISION_HIERARCHICAL_SETTINGS,
|
||||
alembic_config,
|
||||
migrate_database,
|
||||
reconcile_change_sequence_retention_floor_drift,
|
||||
reconcile_namespace_table_drift,
|
||||
)
|
||||
|
||||
|
||||
@@ -28,6 +31,67 @@ def database_migration_heads(connection) -> set[str]:
|
||||
|
||||
|
||||
class DatabaseMigrationTests(unittest.TestCase):
|
||||
def test_repairs_missing_change_sequence_retention_floor(self) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="msm-change-sequence-drift-test-") as directory:
|
||||
database = Path(directory) / "change-sequence-drift.db"
|
||||
url = f"sqlite:///{database}"
|
||||
command.upgrade(alembic_config(database_url=url, enabled_modules=()), REVISION_CORE_CHANGE_SEQUENCE)
|
||||
|
||||
engine = create_engine(url)
|
||||
try:
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text("DROP TABLE core_change_sequence_retention_floor"))
|
||||
with engine.connect() as connection:
|
||||
tables = set(inspect(connection).get_table_names())
|
||||
self.assertIn("core_change_sequence", tables)
|
||||
self.assertNotIn("core_change_sequence_retention_floor", tables)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
self.assertTrue(reconcile_change_sequence_retention_floor_drift(url))
|
||||
|
||||
engine = create_engine(url)
|
||||
try:
|
||||
with engine.connect() as connection:
|
||||
inspector = inspect(connection)
|
||||
self.assertIn("core_change_sequence_retention_floor", inspector.get_table_names())
|
||||
self.assertIn(
|
||||
"ix_core_change_sequence_retention_scope",
|
||||
{index["name"] for index in inspector.get_indexes("core_change_sequence_retention_floor")},
|
||||
)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
def test_repairs_namespace_tables_when_database_was_stamped_ahead(self) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="msm-namespace-drift-test-") as directory:
|
||||
database = Path(directory) / "namespace-drift.db"
|
||||
url = f"sqlite:///{database}"
|
||||
engine = create_engine(url)
|
||||
try:
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text("CREATE TABLE accounts (id TEXT PRIMARY KEY)"))
|
||||
connection.execute(text("CREATE TABLE users (id TEXT PRIMARY KEY)"))
|
||||
connection.execute(text("CREATE TABLE governance_templates (id TEXT PRIMARY KEY)"))
|
||||
with engine.connect() as connection:
|
||||
tables = set(inspect(connection).get_table_names())
|
||||
self.assertIn("accounts", tables)
|
||||
self.assertNotIn("access_accounts", tables)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
self.assertTrue(reconcile_namespace_table_drift(url))
|
||||
|
||||
engine = create_engine(url)
|
||||
try:
|
||||
with engine.connect() as connection:
|
||||
tables = set(inspect(connection).get_table_names())
|
||||
self.assertNotIn("accounts", tables)
|
||||
self.assertIn("access_accounts", tables)
|
||||
self.assertNotIn("governance_templates", tables)
|
||||
self.assertIn("admin_governance_templates", tables)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
def test_repairs_create_all_schema_drift_and_upgrades_to_head(self) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="msm-migration-test-") as directory:
|
||||
database = Path(directory) / "legacy.db"
|
||||
@@ -72,33 +136,33 @@ class DatabaseMigrationTests(unittest.TestCase):
|
||||
job_indexes = {index["name"] for index in inspector.get_indexes("campaign_jobs")}
|
||||
attempt_columns = {column["name"] for column in inspector.get_columns("send_attempts")}
|
||||
tables = set(inspector.get_table_names())
|
||||
tenant_columns = {column["name"] for column in inspector.get_columns("tenants")}
|
||||
user_columns = {column["name"] for column in inspector.get_columns("users")}
|
||||
session_columns = {column["name"] for column in inspector.get_columns("auth_sessions")}
|
||||
group_columns = {column["name"] for column in inspector.get_columns("groups")}
|
||||
role_columns = {column["name"] for column in inspector.get_columns("roles")}
|
||||
role_indexes = {index["name"] for index in inspector.get_indexes("roles")}
|
||||
tenant_columns = {column["name"] for column in inspector.get_columns("tenancy_tenants")}
|
||||
user_columns = {column["name"] for column in inspector.get_columns("access_users")}
|
||||
session_columns = {column["name"] for column in inspector.get_columns("access_auth_sessions")}
|
||||
group_columns = {column["name"] for column in inspector.get_columns("access_groups")}
|
||||
role_columns = {column["name"] for column in inspector.get_columns("access_roles")}
|
||||
role_indexes = {index["name"] for index in inspector.get_indexes("access_roles")}
|
||||
campaign_columns = {column["name"] for column in inspector.get_columns("campaigns")}
|
||||
audit_columns = {column["name"] for column in inspector.get_columns("audit_log")}
|
||||
audit_indexes = {index["name"] for index in inspector.get_indexes("audit_log")}
|
||||
system_role_flags = {
|
||||
row["slug"]: bool(row["is_builtin"])
|
||||
for row in connection.execute(text(
|
||||
"SELECT slug, is_builtin FROM roles WHERE tenant_id IS NULL AND slug IN ('system_owner', 'system_admin', 'system_auditor')"
|
||||
"SELECT slug, is_builtin FROM access_roles WHERE tenant_id IS NULL AND slug IN ('system_owner', 'system_admin', 'system_auditor')"
|
||||
)).mappings().all()
|
||||
}
|
||||
system_settings_columns = {column["name"] for column in inspector.get_columns("system_settings")}
|
||||
governance_assignment_columns = {column["name"] for column in inspector.get_columns("governance_template_assignments")}
|
||||
system_settings_columns = {column["name"] for column in inspector.get_columns("core_system_settings")}
|
||||
governance_assignment_columns = {column["name"] for column in inspector.get_columns("admin_governance_template_assignments")}
|
||||
import_profile_columns = {column["name"] for column in inspector.get_columns("campaign_recipient_import_mapping_profiles")}
|
||||
import_profile_indexes = {index["name"] for index in inspector.get_indexes("campaign_recipient_import_mapping_profiles")}
|
||||
account_count = connection.execute(text("SELECT COUNT(*) FROM accounts")).scalar_one()
|
||||
user_count = connection.execute(text("SELECT COUNT(*) FROM users")).scalar_one()
|
||||
account_count = connection.execute(text("SELECT COUNT(*) FROM access_accounts")).scalar_one()
|
||||
user_count = connection.execute(text("SELECT COUNT(*) FROM access_users")).scalar_one()
|
||||
system_owner_count = connection.execute(text(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM system_role_assignments assignment
|
||||
JOIN roles role ON role.id = assignment.role_id
|
||||
JOIN accounts account ON account.id = assignment.account_id
|
||||
FROM access_system_role_assignments assignment
|
||||
JOIN access_roles role ON role.id = assignment.role_id
|
||||
JOIN access_accounts account ON account.id = assignment.account_id
|
||||
WHERE role.slug = 'system_owner' AND account.is_active = 1
|
||||
"""
|
||||
)).scalar_one()
|
||||
@@ -120,11 +184,11 @@ class DatabaseMigrationTests(unittest.TestCase):
|
||||
self.assertIn("ix_campaign_jobs_eml_sha256", job_indexes)
|
||||
self.assertIn("status", attempt_columns)
|
||||
self.assertIn("claim_token", attempt_columns)
|
||||
self.assertIn("accounts", tables)
|
||||
self.assertIn("system_role_assignments", tables)
|
||||
self.assertIn("system_settings", tables)
|
||||
self.assertIn("governance_templates", tables)
|
||||
self.assertIn("governance_template_assignments", tables)
|
||||
self.assertIn("access_accounts", tables)
|
||||
self.assertIn("access_system_role_assignments", tables)
|
||||
self.assertIn("core_system_settings", tables)
|
||||
self.assertIn("admin_governance_templates", tables)
|
||||
self.assertIn("admin_governance_template_assignments", tables)
|
||||
self.assertIn("campaign_shares", tables)
|
||||
self.assertIn("campaign_recipient_import_mapping_profiles", tables)
|
||||
self.assertIn("owner_user_id", campaign_columns)
|
||||
@@ -259,13 +323,13 @@ class DatabaseMigrationTests(unittest.TestCase):
|
||||
timestamps = {"created_at": "2026-06-14 12:00:00", "updated_at": "2026-06-14 12:00:00"}
|
||||
connection.execute(text(
|
||||
"""
|
||||
INSERT INTO tenants (id, slug, name, is_active, created_at, updated_at)
|
||||
INSERT INTO tenancy_tenants (id, slug, name, is_active, created_at, updated_at)
|
||||
VALUES ('tenant-1', 'legacy', 'Legacy', 1, :created_at, :updated_at)
|
||||
"""
|
||||
), timestamps)
|
||||
connection.execute(text(
|
||||
"""
|
||||
INSERT INTO users
|
||||
INSERT INTO access_users
|
||||
(id, tenant_id, email, display_name, is_active, is_tenant_admin,
|
||||
auth_provider, password_hash, last_login_at, created_at, updated_at)
|
||||
VALUES
|
||||
@@ -275,7 +339,7 @@ class DatabaseMigrationTests(unittest.TestCase):
|
||||
), timestamps)
|
||||
connection.execute(text(
|
||||
"""
|
||||
INSERT INTO auth_sessions
|
||||
INSERT INTO access_auth_sessions
|
||||
(id, tenant_id, user_id, token_hash, expires_at, last_seen_at, revoked_at,
|
||||
user_agent, ip_address, created_at, updated_at)
|
||||
VALUES
|
||||
@@ -292,37 +356,37 @@ class DatabaseMigrationTests(unittest.TestCase):
|
||||
try:
|
||||
with engine.connect() as connection:
|
||||
account = connection.execute(text(
|
||||
"SELECT id, normalized_email, password_hash FROM accounts"
|
||||
"SELECT id, normalized_email, password_hash FROM access_accounts"
|
||||
)).mappings().one()
|
||||
membership = connection.execute(text(
|
||||
"SELECT account_id FROM users WHERE id = 'user-1'"
|
||||
"SELECT account_id FROM access_users WHERE id = 'user-1'"
|
||||
)).mappings().one()
|
||||
migrated_session = connection.execute(text(
|
||||
"SELECT account_id FROM auth_sessions WHERE id = 'session-1'"
|
||||
"SELECT account_id FROM access_auth_sessions WHERE id = 'session-1'"
|
||||
)).mappings().one()
|
||||
owner_assignment = connection.execute(text(
|
||||
"""
|
||||
SELECT role.slug
|
||||
FROM user_role_assignments assignment
|
||||
JOIN roles role ON role.id = assignment.role_id
|
||||
FROM access_user_role_assignments assignment
|
||||
JOIN access_roles role ON role.id = assignment.role_id
|
||||
WHERE assignment.user_id = 'user-1'
|
||||
"""
|
||||
)).scalar_one()
|
||||
owner_permissions = connection.execute(text(
|
||||
"SELECT permissions FROM roles WHERE tenant_id = 'tenant-1' AND slug = 'owner'"
|
||||
"SELECT permissions FROM access_roles WHERE tenant_id = 'tenant-1' AND slug = 'owner'"
|
||||
)).scalar_one()
|
||||
system_assignment = connection.execute(text(
|
||||
"""
|
||||
SELECT role.slug
|
||||
FROM system_role_assignments assignment
|
||||
JOIN roles role ON role.id = assignment.role_id
|
||||
FROM access_system_role_assignments assignment
|
||||
JOIN access_roles role ON role.id = assignment.role_id
|
||||
WHERE assignment.account_id = :account_id
|
||||
"""
|
||||
), {"account_id": account["id"]}).scalar_one()
|
||||
system_role_flags = {
|
||||
row["slug"]: bool(row["is_builtin"])
|
||||
for row in connection.execute(text(
|
||||
"SELECT slug, is_builtin FROM roles WHERE tenant_id IS NULL AND slug IN ('system_owner', 'system_admin', 'system_auditor')"
|
||||
"SELECT slug, is_builtin FROM access_roles WHERE tenant_id IS NULL AND slug IN ('system_owner', 'system_admin', 'system_auditor')"
|
||||
)).mappings().all()
|
||||
}
|
||||
self.assertEqual(account["normalized_email"], "owner@example.local")
|
||||
@@ -356,7 +420,7 @@ class DatabaseMigrationTests(unittest.TestCase):
|
||||
INSERT INTO audit_log
|
||||
(id, tenant_id, user_id, api_key_id, action, object_type, object_id, details, created_at, updated_at)
|
||||
VALUES
|
||||
('audit-system', NULL, NULL, NULL, 'system_settings.updated', 'system_settings', 'system', '{}', :created_at, :updated_at),
|
||||
('audit-system', NULL, NULL, NULL, 'system_settings.updated', 'core_system_settings', 'system', '{}', :created_at, :updated_at),
|
||||
('audit-tenant', NULL, NULL, NULL, 'user.updated', 'user', 'user-1', '{}', :created_at, :updated_at)
|
||||
"""
|
||||
), timestamps)
|
||||
|
||||
@@ -18,6 +18,10 @@ class DevserverSmokeTests(unittest.TestCase):
|
||||
for key in (
|
||||
"DATABASE_URL",
|
||||
"DEV_BOOTSTRAP_ENABLED",
|
||||
"GOVOPLAN_DEV_DATABASE_BACKEND",
|
||||
"GOVOPLAN_DEV_DATABASE_URL",
|
||||
"GOVOPLAN_DEV_DATABASE_URL_PGTOOLS",
|
||||
"GOVOPLAN_DATABASE_URL_PGTOOLS",
|
||||
"GOVOPLAN_SERVER_CONFIG",
|
||||
"FILE_STORAGE_LOCAL_ROOT",
|
||||
"MOCK_MAILBOX_DIR",
|
||||
@@ -26,6 +30,7 @@ class DevserverSmokeTests(unittest.TestCase):
|
||||
env["APP_ENV"] = "dev"
|
||||
env["CELERY_ENABLED"] = "false"
|
||||
env["ENABLED_MODULES"] = "access"
|
||||
env["GOVOPLAN_DEV_DATABASE_BACKEND"] = "sqlite"
|
||||
env["PYTHONPATH"] = str(src_root) + (os.pathsep + env["PYTHONPATH"] if env.get("PYTHONPATH") else "")
|
||||
|
||||
result = subprocess.run(
|
||||
|
||||
186
tests/test_identity_organization_contracts.py
Normal file
186
tests/test_identity_organization_contracts.py
Normal file
@@ -0,0 +1,186 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from govoplan_core.core.identity import (
|
||||
CAPABILITY_IDENTITY_DIRECTORY,
|
||||
IdentityAccountLinkRef,
|
||||
IdentityDirectory,
|
||||
IdentityRef,
|
||||
)
|
||||
from govoplan_core.core.organizations import (
|
||||
CAPABILITY_ORGANIZATION_DIRECTORY,
|
||||
OrganizationDirectory,
|
||||
OrganizationFunctionAssignmentRef,
|
||||
OrganizationFunctionRef,
|
||||
OrganizationUnitRef,
|
||||
)
|
||||
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_access.backend.db.models import User # noqa: F401 - registers legacy tenancy relationship target
|
||||
from govoplan_identity.backend.db.models import Identity, IdentityAccountLink
|
||||
from govoplan_organizations.backend.db.models import (
|
||||
OrganizationFunction,
|
||||
OrganizationFunctionAssignment,
|
||||
OrganizationUnit,
|
||||
)
|
||||
from govoplan_tenancy.backend.db.models import Tenant
|
||||
from tests.db_isolation import temporary_database
|
||||
|
||||
|
||||
class _FakeIdentityDirectory:
|
||||
identity = IdentityRef(
|
||||
id="identity-1",
|
||||
display_name="Demo Person",
|
||||
primary_account_id="account-1",
|
||||
account_ids=("account-1", "account-2"),
|
||||
)
|
||||
|
||||
def get_identity(self, identity_id: str) -> IdentityRef | None:
|
||||
return self.identity if identity_id == self.identity.id else None
|
||||
|
||||
def identity_for_account(self, account_id: str) -> IdentityRef | None:
|
||||
return self.identity if account_id in self.identity.account_ids else None
|
||||
|
||||
def identities_for_accounts(self, account_ids):
|
||||
return (self.identity,) if set(account_ids) & set(self.identity.account_ids) else ()
|
||||
|
||||
def accounts_for_identity(self, identity_id: str):
|
||||
if identity_id != self.identity.id:
|
||||
return ()
|
||||
return (
|
||||
IdentityAccountLinkRef(id="link-1", identity_id=identity_id, account_id="account-1", is_primary=True),
|
||||
IdentityAccountLinkRef(id="link-2", identity_id=identity_id, account_id="account-2"),
|
||||
)
|
||||
|
||||
|
||||
class _FakeOrganizationDirectory:
|
||||
unit = OrganizationUnitRef(id="ou-1", tenant_id="tenant-1", slug="registry", name="Registry")
|
||||
function = OrganizationFunctionRef(
|
||||
id="function-1",
|
||||
tenant_id="tenant-1",
|
||||
organization_unit_id=unit.id,
|
||||
slug="registry-clerk",
|
||||
name="Registry Clerk",
|
||||
)
|
||||
assignment = OrganizationFunctionAssignmentRef(
|
||||
id="assignment-1",
|
||||
tenant_id="tenant-1",
|
||||
account_id="account-1",
|
||||
identity_id="identity-1",
|
||||
function_id=function.id,
|
||||
organization_unit_id=unit.id,
|
||||
applies_to_subunits=True,
|
||||
)
|
||||
|
||||
def get_organization_unit(self, organization_unit_id: str) -> OrganizationUnitRef | None:
|
||||
return self.unit if organization_unit_id == self.unit.id else None
|
||||
|
||||
def organization_units_for_tenant(self, tenant_id: str):
|
||||
return (self.unit,) if tenant_id == self.unit.tenant_id else ()
|
||||
|
||||
def get_function(self, function_id: str) -> OrganizationFunctionRef | None:
|
||||
return self.function if function_id == self.function.id else None
|
||||
|
||||
def functions_for_organization_unit(self, organization_unit_id: str, *, include_subunits: bool = False):
|
||||
del include_subunits
|
||||
return (self.function,) if organization_unit_id == self.unit.id else ()
|
||||
|
||||
def get_function_assignment(self, assignment_id: str) -> OrganizationFunctionAssignmentRef | None:
|
||||
return self.assignment if assignment_id == self.assignment.id else None
|
||||
|
||||
def function_assignments_for_account(self, account_id: str, *, tenant_id: str | None = None):
|
||||
if account_id != self.assignment.account_id:
|
||||
return ()
|
||||
if tenant_id is not None and tenant_id != self.assignment.tenant_id:
|
||||
return ()
|
||||
return (self.assignment,)
|
||||
|
||||
|
||||
class IdentityOrganizationContractTests(unittest.TestCase):
|
||||
def test_protocol_shapes_match_reference_implementations(self) -> None:
|
||||
self.assertIsInstance(_FakeIdentityDirectory(), IdentityDirectory)
|
||||
self.assertIsInstance(_FakeOrganizationDirectory(), OrganizationDirectory)
|
||||
|
||||
def test_capabilities_register_and_resolve_through_platform_registry(self) -> None:
|
||||
identity_directory = _FakeIdentityDirectory()
|
||||
organization_directory = _FakeOrganizationDirectory()
|
||||
manifest = ModuleManifest(
|
||||
id="directory-contract-test",
|
||||
name="Directory Contract Test",
|
||||
version="test",
|
||||
capability_factories={
|
||||
CAPABILITY_IDENTITY_DIRECTORY: lambda context: identity_directory,
|
||||
CAPABILITY_ORGANIZATION_DIRECTORY: lambda context: organization_directory,
|
||||
},
|
||||
)
|
||||
registry = PlatformRegistry()
|
||||
registry.register(manifest)
|
||||
registry.configure_capability_context(ModuleContext(registry=registry, settings=object()))
|
||||
|
||||
self.assertIs(identity_directory, registry.require_capability(CAPABILITY_IDENTITY_DIRECTORY))
|
||||
self.assertIs(organization_directory, registry.require_capability(CAPABILITY_ORGANIZATION_DIRECTORY))
|
||||
|
||||
def test_sql_identity_and_organization_directories_return_dtos(self) -> None:
|
||||
root = Path(tempfile.mkdtemp(prefix="govoplan-directory-contracts-"))
|
||||
try:
|
||||
with temporary_database(f"sqlite:///{root / 'directory.db'}") as database:
|
||||
Base.metadata.create_all(bind=database.engine)
|
||||
with database.session() as session:
|
||||
tenant = Tenant(id="tenant-directory", slug="directory", name="Directory Tenant", settings={})
|
||||
identity = Identity(id="identity-directory", display_name="Directory Person", source="local")
|
||||
link = IdentityAccountLink(
|
||||
identity_id=identity.id,
|
||||
account_id="account-directory",
|
||||
is_primary=True,
|
||||
)
|
||||
unit = OrganizationUnit(
|
||||
id="ou-directory",
|
||||
tenant_id=tenant.id,
|
||||
slug="registry",
|
||||
name="Registry",
|
||||
)
|
||||
function = OrganizationFunction(
|
||||
id="function-directory",
|
||||
tenant_id=tenant.id,
|
||||
organization_unit_id=unit.id,
|
||||
slug="registry-clerk",
|
||||
name="Registry Clerk",
|
||||
)
|
||||
assignment = OrganizationFunctionAssignment(
|
||||
id="assignment-directory",
|
||||
tenant_id=tenant.id,
|
||||
account_id="account-directory",
|
||||
identity_id=identity.id,
|
||||
function_id=function.id,
|
||||
organization_unit_id=unit.id,
|
||||
applies_to_subunits=True,
|
||||
)
|
||||
session.add_all([tenant, identity, link, unit, function, assignment])
|
||||
session.commit()
|
||||
|
||||
from govoplan_identity.backend.directory import SqlIdentityDirectory
|
||||
from govoplan_organizations.backend.directory import SqlOrganizationDirectory
|
||||
|
||||
identity_directory = SqlIdentityDirectory()
|
||||
organization_directory = SqlOrganizationDirectory()
|
||||
|
||||
self.assertEqual("Directory Person", identity_directory.get_identity("identity-directory").display_name) # type: ignore[union-attr]
|
||||
self.assertEqual("identity-directory", identity_directory.identity_for_account("account-directory").id) # type: ignore[union-attr]
|
||||
self.assertEqual(["account-directory"], [item.account_id for item in identity_directory.accounts_for_identity("identity-directory")])
|
||||
self.assertEqual("Registry", organization_directory.get_organization_unit("ou-directory").name) # type: ignore[union-attr]
|
||||
self.assertEqual("Registry Clerk", organization_directory.get_function("function-directory").name) # type: ignore[union-attr]
|
||||
self.assertEqual(
|
||||
["assignment-directory"],
|
||||
[item.id for item in organization_directory.function_assignments_for_account("account-directory", tenant_id="tenant-directory")],
|
||||
)
|
||||
finally:
|
||||
shutil.rmtree(root, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
File diff suppressed because it is too large
Load Diff
33
tests/test_pagination.py
Normal file
33
tests/test_pagination.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.pagination import (
|
||||
KeysetCursorError,
|
||||
decode_keyset_cursor,
|
||||
encode_keyset_cursor,
|
||||
keyset_query_fingerprint,
|
||||
)
|
||||
|
||||
|
||||
class PaginationCursorTests(unittest.TestCase):
|
||||
def test_keyset_cursor_round_trips_and_rejects_query_mismatch(self) -> None:
|
||||
fingerprint = keyset_query_fingerprint("audit.admin", {"scope": "system", "page_size": 25})
|
||||
cursor = encode_keyset_cursor(
|
||||
"audit.admin",
|
||||
fingerprint=fingerprint,
|
||||
values={"id": "row-1", "sort_value": "2026-01-01T00:00:00+00:00"},
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
decode_keyset_cursor("audit.admin", cursor, fingerprint=fingerprint),
|
||||
{"id": "row-1", "sort_value": "2026-01-01T00:00:00+00:00"},
|
||||
)
|
||||
with self.assertRaises(KeysetCursorError):
|
||||
decode_keyset_cursor("audit.admin", cursor, fingerprint=keyset_query_fingerprint("audit.admin", {"scope": "tenant"}))
|
||||
with self.assertRaises(KeysetCursorError):
|
||||
decode_keyset_cursor("files.list", cursor, fingerprint=fingerprint)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
125
tests/test_policy_contracts.py
Normal file
125
tests/test_policy_contracts.py
Normal file
@@ -0,0 +1,125 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.configuration_safety import (
|
||||
classify_configuration_field,
|
||||
configuration_safety_catalog,
|
||||
high_impact_configuration_fields,
|
||||
plan_configuration_change,
|
||||
ui_managed_configuration_fields_requiring_approval,
|
||||
)
|
||||
from govoplan_core.core.policy import (
|
||||
PolicyDecision,
|
||||
PolicySourceStep,
|
||||
parse_policy_source_path,
|
||||
policy_source_path,
|
||||
policy_source_step,
|
||||
)
|
||||
|
||||
|
||||
class PolicyContractTests(unittest.TestCase):
|
||||
def test_policy_source_paths_are_stable_and_round_trip(self) -> None:
|
||||
self.assertEqual(policy_source_path("system"), "system")
|
||||
self.assertEqual(policy_source_path("tenant", "tenant-1"), "tenant:tenant-1")
|
||||
self.assertEqual(policy_source_path("campaign", "campaign/with space"), "campaign:campaign%2Fwith%20space")
|
||||
|
||||
tenant_ref = parse_policy_source_path("tenant:tenant-1")
|
||||
self.assertEqual(tenant_ref.scope_type, "tenant")
|
||||
self.assertEqual(tenant_ref.scope_id, "tenant-1")
|
||||
self.assertEqual(tenant_ref.path, "tenant:tenant-1")
|
||||
|
||||
campaign_ref = parse_policy_source_path("campaign:campaign%2Fwith%20space")
|
||||
self.assertEqual(campaign_ref.scope_id, "campaign/with space")
|
||||
|
||||
def test_policy_source_step_and_decision_serialize_for_api_responses(self) -> None:
|
||||
source = policy_source_step(
|
||||
"tenant",
|
||||
"Tenant",
|
||||
"tenant-1",
|
||||
applied_fields=("allow_lower_level_limits",),
|
||||
policy={"allow_lower_level_limits": {"audit_detail_level": False}},
|
||||
)
|
||||
self.assertEqual(source["path"], "tenant:tenant-1")
|
||||
self.assertEqual(source["applied_fields"], ["allow_lower_level_limits"])
|
||||
|
||||
decision = PolicyDecision(
|
||||
allowed=False,
|
||||
reason="Parent policy locks lower-level changes.",
|
||||
source_path=(PolicySourceStep.from_mapping(source),),
|
||||
requirements=("audit_detail_level",),
|
||||
details={"blocked_fields": ["audit_detail_level"]},
|
||||
)
|
||||
payload = decision.to_dict()
|
||||
self.assertFalse(payload["allowed"])
|
||||
self.assertEqual(payload["source_path"][0]["path"], "tenant:tenant-1")
|
||||
self.assertEqual(payload["requirements"], ["audit_detail_level"])
|
||||
|
||||
def test_configuration_safety_catalog_classifies_ui_and_env_managed_fields(self) -> None:
|
||||
catalog = {item.key: item for item in configuration_safety_catalog()}
|
||||
|
||||
module_plan = catalog["module_management.install_plan"]
|
||||
self.assertTrue(module_plan.ui_managed)
|
||||
self.assertEqual(module_plan.risk, "destructive")
|
||||
self.assertTrue(module_plan.dry_run_required)
|
||||
self.assertTrue(module_plan.maintenance_required)
|
||||
self.assertTrue(module_plan.two_person_approval_required)
|
||||
self.assertTrue(module_plan.rollback_history_required)
|
||||
|
||||
connector_profiles = catalog["files.connector_profiles"]
|
||||
self.assertEqual(connector_profiles.secret_handling, "reference_only")
|
||||
self.assertTrue(connector_profiles.policy_explanation_required)
|
||||
self.assertIn("files:file:admin", connector_profiles.required_scopes)
|
||||
|
||||
database_url = catalog["DATABASE_URL"]
|
||||
self.assertFalse(database_url.ui_managed)
|
||||
self.assertEqual(database_url.secret_handling, "env_only")
|
||||
self.assertEqual(database_url.storage, "environment")
|
||||
|
||||
self.assertIs(classify_configuration_field("MASTER_KEY_B64"), catalog["MASTER_KEY_B64"])
|
||||
self.assertIsNone(classify_configuration_field("missing.setting"))
|
||||
self.assertNotIn("DATABASE_URL", {item.key for item in configuration_safety_catalog(include_env_only=False)})
|
||||
|
||||
def test_configuration_safety_helpers_surface_guardrail_sets(self) -> None:
|
||||
high_impact = {item.key for item in high_impact_configuration_fields()}
|
||||
approval = {item.key for item in ui_managed_configuration_fields_requiring_approval()}
|
||||
|
||||
self.assertIn("module_management.install_plan", high_impact)
|
||||
self.assertIn("privacy_retention_policy", approval)
|
||||
self.assertIn("files.connector_profiles", approval)
|
||||
self.assertNotIn("DATABASE_URL", approval)
|
||||
|
||||
def test_configuration_change_safety_plan_enforces_guardrails(self) -> None:
|
||||
blocked = plan_configuration_change(
|
||||
"files.connector_profiles",
|
||||
actor_scopes=("tenant:*",),
|
||||
value={"password": "plain-secret"},
|
||||
dry_run=False,
|
||||
maintenance_mode=False,
|
||||
approval_count=1,
|
||||
)
|
||||
self.assertFalse(blocked.allowed)
|
||||
self.assertIn("dry_run_required", blocked.blockers)
|
||||
self.assertIn("two_person_approval_required", blocked.blockers)
|
||||
self.assertIn("secret_reference_required", blocked.blockers)
|
||||
self.assertEqual(blocked.audit_event, "files.connector_profile.updated")
|
||||
self.assertTrue(blocked.rollback_history_required)
|
||||
|
||||
allowed = plan_configuration_change(
|
||||
"files.connector_profiles",
|
||||
actor_scopes=("tenant:*",),
|
||||
value={"secret_ref": "vault://files/smb"},
|
||||
dry_run=True,
|
||||
approval_count=2,
|
||||
)
|
||||
self.assertTrue(allowed.allowed, allowed.to_dict())
|
||||
self.assertEqual(allowed.secret_handling, "reference_only")
|
||||
|
||||
env_only = plan_configuration_change("DATABASE_URL", actor_scopes=("system:*",), value="postgresql://example")
|
||||
self.assertFalse(env_only.allowed)
|
||||
self.assertIn("deployment_managed", env_only.blockers)
|
||||
self.assertIn("env_only_secret", env_only.blockers)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
141
tests/test_release_migration_audit.py
Normal file
141
tests/test_release_migration_audit.py
Normal file
@@ -0,0 +1,141 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = ROOT / "scripts" / "release-migration-audit.py"
|
||||
|
||||
|
||||
def load_audit_module():
|
||||
spec = importlib.util.spec_from_file_location("release_migration_audit", SCRIPT)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError(f"Could not load {SCRIPT}")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class ReleaseMigrationAuditTests(unittest.TestCase):
|
||||
def test_typed_alembic_variables_are_parsed(self) -> None:
|
||||
audit = load_audit_module()
|
||||
with tempfile.TemporaryDirectory(prefix="migration-audit-test-") as directory:
|
||||
path = Path(directory) / "1234_example.py"
|
||||
path.write_text(
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from typing import Sequence, Union
|
||||
|
||||
revision: str = "1234"
|
||||
down_revision: Union[str, None] = "base"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
migration = audit.parse_migration_file("govoplan-core", path)
|
||||
|
||||
self.assertIsNotNone(migration)
|
||||
self.assertEqual(migration.revision, "1234")
|
||||
self.assertEqual(migration.down_revisions, ("base",))
|
||||
self.assertEqual(migration.branch_labels, ())
|
||||
|
||||
def test_release_baseline_matches_current_heads_in_strict_report(self) -> None:
|
||||
audit = load_audit_module()
|
||||
migrations = [
|
||||
audit.Migration("govoplan-core", Path("base.py"), "base", (), ()),
|
||||
audit.Migration("govoplan-core", Path("head.py"), "head", ("base",), ()),
|
||||
]
|
||||
report = audit.build_report(
|
||||
migrations,
|
||||
baseline={
|
||||
"version": 1,
|
||||
"releases": [
|
||||
{
|
||||
"release": "0.1.0",
|
||||
"heads": [{"owner": "govoplan-core", "revision": "head"}],
|
||||
}
|
||||
],
|
||||
},
|
||||
baseline_file=Path("docs/migration-release-baselines.json"),
|
||||
workspace_root=Path("/workspace"),
|
||||
)
|
||||
|
||||
self.assertEqual(report["current_heads"], ["head"])
|
||||
self.assertEqual(report["graph_errors"], [])
|
||||
self.assertEqual(report["strict_errors"], [])
|
||||
|
||||
def test_owner_heads_are_accepted_for_subset_installs(self) -> None:
|
||||
audit = load_audit_module()
|
||||
migrations = [
|
||||
audit.Migration("govoplan-core", Path("base.py"), "base", (), ()),
|
||||
audit.Migration("govoplan-core", Path("core_head.py"), "core-head", ("base",), ()),
|
||||
]
|
||||
report = audit.build_report(
|
||||
migrations,
|
||||
baseline={
|
||||
"version": 1,
|
||||
"releases": [
|
||||
{
|
||||
"release": "0.1.0",
|
||||
"heads": [{"owner": "govoplan-files", "revision": "files-head"}],
|
||||
"owner_heads": [{"owner": "govoplan-core", "revisions": ["core-head"]}],
|
||||
}
|
||||
],
|
||||
},
|
||||
baseline_file=Path("docs/migration-release-baselines.json"),
|
||||
workspace_root=Path("/workspace"),
|
||||
)
|
||||
|
||||
self.assertEqual(report["current_heads"], ["core-head"])
|
||||
self.assertEqual(report["strict_errors"], [])
|
||||
|
||||
def test_records_current_release_baseline(self) -> None:
|
||||
audit = load_audit_module()
|
||||
migrations = [
|
||||
audit.Migration("govoplan-core", Path("base.py"), "base", (), ()),
|
||||
audit.Migration("govoplan-core", Path("core_head.py"), "core-head", ("base",), ()),
|
||||
audit.Migration("govoplan-files", Path("files_head.py"), "files-head", ("core-head",), ()),
|
||||
]
|
||||
report = audit.build_report(
|
||||
migrations,
|
||||
baseline={"version": 1, "releases": []},
|
||||
baseline_file=Path("docs/migration-release-baselines.json"),
|
||||
workspace_root=Path("/workspace"),
|
||||
)
|
||||
|
||||
baseline = audit.record_release_baseline(
|
||||
{"version": 1, "releases": []},
|
||||
report,
|
||||
release="0.2.0",
|
||||
replace=False,
|
||||
)
|
||||
|
||||
release = baseline["releases"][0]
|
||||
self.assertEqual(release["release"], "0.2.0")
|
||||
self.assertEqual(release["squash_policy"], "reviewed-manual")
|
||||
self.assertEqual(release["heads"], [{"owner": "govoplan-files", "revision": "files-head"}])
|
||||
self.assertIn({"owner": "govoplan-core", "revisions": ["core-head"]}, release["owner_heads"])
|
||||
|
||||
def test_missing_down_revision_is_a_graph_error(self) -> None:
|
||||
audit = load_audit_module()
|
||||
migrations = [
|
||||
audit.Migration("govoplan-core", Path("head.py"), "head", ("missing",), ()),
|
||||
]
|
||||
report = audit.build_report(
|
||||
migrations,
|
||||
baseline={"version": 1, "releases": []},
|
||||
baseline_file=Path("docs/migration-release-baselines.json"),
|
||||
workspace_root=Path("/workspace"),
|
||||
)
|
||||
|
||||
self.assertIn("references missing down_revision", report["graph_errors"][0])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user