Implements space composition and inheritance features: - SpaceReference model for space-to-space references (includes, extends, links_to, composed_of) - Variable inheritance through parent chain with local override - Config inheritance with source tracking - Access control models (SpacePermission, SpaceRole, AccessLevel) - InheritanceResolver for walking parent chains - AccessControlService for permission management - ComposableSpaceService integrating all composability features - Circular reference detection for EXTENDS references - SQLite repositories for references and permissions - 57 comprehensive unit tests Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1100 lines
37 KiB
Python
1100 lines
37 KiB
Python
"""
|
|
Tests for Phase 7: Space Composability.
|
|
|
|
Tests space references, variable/config inheritance, and access control.
|
|
"""
|
|
|
|
import pytest
|
|
import tempfile
|
|
from pathlib import Path
|
|
from datetime import datetime, timedelta
|
|
|
|
from markitect.spaces import (
|
|
# Models
|
|
InformationSpace,
|
|
SpaceConfig,
|
|
SpaceMetadata,
|
|
SpaceVariable,
|
|
SpaceStatus,
|
|
# Composability Models
|
|
SpaceReference,
|
|
SpaceReferenceType,
|
|
SpacePermission,
|
|
SpaceRole,
|
|
AccessLevel,
|
|
SpaceAccess,
|
|
InheritedVariable,
|
|
InheritedConfig,
|
|
# Services
|
|
ComposableSpaceService,
|
|
InheritanceResolver,
|
|
AccessControlService,
|
|
CircularReferenceError,
|
|
# Repositories
|
|
SqliteSpaceRepository,
|
|
SqliteVariableRepository,
|
|
SqliteSpaceReferenceRepository,
|
|
SqliteAccessControlRepository,
|
|
)
|
|
|
|
|
|
# ===========================================================================
|
|
# Fixtures
|
|
# ===========================================================================
|
|
|
|
|
|
@pytest.fixture
|
|
def db_path():
|
|
"""Create a temporary database path."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
yield Path(tmpdir) / "test.db"
|
|
|
|
|
|
@pytest.fixture
|
|
def space_repo(db_path):
|
|
"""Create a space repository."""
|
|
return SqliteSpaceRepository(db_path)
|
|
|
|
|
|
@pytest.fixture
|
|
def variable_repo(db_path):
|
|
"""Create a variable repository."""
|
|
return SqliteVariableRepository(db_path)
|
|
|
|
|
|
@pytest.fixture
|
|
def reference_repo(db_path):
|
|
"""Create a reference repository."""
|
|
return SqliteSpaceReferenceRepository(db_path)
|
|
|
|
|
|
@pytest.fixture
|
|
def permission_repo(db_path):
|
|
"""Create a permission repository."""
|
|
return SqliteAccessControlRepository(db_path)
|
|
|
|
|
|
@pytest.fixture
|
|
def inheritance_resolver(space_repo, variable_repo, reference_repo):
|
|
"""Create an inheritance resolver."""
|
|
return InheritanceResolver(
|
|
space_repo=space_repo,
|
|
variable_repo=variable_repo,
|
|
reference_repo=reference_repo,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def access_service(permission_repo, space_repo, inheritance_resolver):
|
|
"""Create an access control service."""
|
|
return AccessControlService(
|
|
permission_repo=permission_repo,
|
|
space_repo=space_repo,
|
|
inheritance_resolver=inheritance_resolver,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def composable_service(space_repo, variable_repo, reference_repo, permission_repo):
|
|
"""Create a composable space service."""
|
|
return ComposableSpaceService(
|
|
space_repo=space_repo,
|
|
variable_repo=variable_repo,
|
|
reference_repo=reference_repo,
|
|
permission_repo=permission_repo,
|
|
)
|
|
|
|
|
|
# ===========================================================================
|
|
# SpaceReference Model Tests
|
|
# ===========================================================================
|
|
|
|
|
|
class TestSpaceReferenceModel:
|
|
"""Tests for SpaceReference model."""
|
|
|
|
def test_create_reference(self):
|
|
"""Test creating a space reference."""
|
|
ref = SpaceReference(
|
|
source_space_id="space-1",
|
|
target_space_id="space-2",
|
|
reference_type=SpaceReferenceType.INCLUDES,
|
|
)
|
|
assert ref.source_space_id == "space-1"
|
|
assert ref.target_space_id == "space-2"
|
|
assert ref.reference_type == SpaceReferenceType.INCLUDES
|
|
assert ref.id is not None
|
|
|
|
def test_reference_with_alias(self):
|
|
"""Test reference with alias."""
|
|
ref = SpaceReference(
|
|
source_space_id="space-1",
|
|
target_space_id="space-2",
|
|
reference_type=SpaceReferenceType.LINKS_TO,
|
|
alias="common-components",
|
|
)
|
|
assert ref.alias == "common-components"
|
|
|
|
def test_reference_to_dict(self):
|
|
"""Test serialization to dict."""
|
|
ref = SpaceReference(
|
|
source_space_id="space-1",
|
|
target_space_id="space-2",
|
|
reference_type=SpaceReferenceType.EXTENDS,
|
|
alias="base",
|
|
metadata={"priority": 1},
|
|
)
|
|
data = ref.to_dict()
|
|
assert data["source_space_id"] == "space-1"
|
|
assert data["target_space_id"] == "space-2"
|
|
assert data["reference_type"] == "extends"
|
|
assert data["alias"] == "base"
|
|
assert data["metadata"]["priority"] == 1
|
|
|
|
def test_reference_from_dict(self):
|
|
"""Test deserialization from dict."""
|
|
data = {
|
|
"id": "ref-1",
|
|
"source_space_id": "space-1",
|
|
"target_space_id": "space-2",
|
|
"reference_type": "composed_of",
|
|
"alias": "part-a",
|
|
"metadata": {"order": 1},
|
|
"created_at": "2024-01-01T00:00:00",
|
|
}
|
|
ref = SpaceReference.from_dict(data)
|
|
assert ref.id == "ref-1"
|
|
assert ref.reference_type == SpaceReferenceType.COMPOSED_OF
|
|
assert ref.alias == "part-a"
|
|
|
|
|
|
# ===========================================================================
|
|
# SpacePermission Model Tests
|
|
# ===========================================================================
|
|
|
|
|
|
class TestSpacePermissionModel:
|
|
"""Tests for SpacePermission model."""
|
|
|
|
def test_create_permission(self):
|
|
"""Test creating a permission."""
|
|
perm = SpacePermission(
|
|
space_id="space-1",
|
|
principal_type="user",
|
|
principal_id="user-1",
|
|
access_level=AccessLevel.WRITE,
|
|
)
|
|
assert perm.space_id == "space-1"
|
|
assert perm.principal_id == "user-1"
|
|
assert perm.access_level == AccessLevel.WRITE
|
|
|
|
def test_permission_with_role(self):
|
|
"""Test permission with role."""
|
|
perm = SpacePermission(
|
|
space_id="space-1",
|
|
principal_type="user",
|
|
principal_id="user-1",
|
|
access_level=AccessLevel.ADMIN,
|
|
role=SpaceRole.OWNER,
|
|
)
|
|
assert perm.role == SpaceRole.OWNER
|
|
|
|
def test_permission_expiration(self):
|
|
"""Test permission expiration check."""
|
|
# Not expired
|
|
perm = SpacePermission(
|
|
space_id="space-1",
|
|
principal_type="user",
|
|
principal_id="user-1",
|
|
access_level=AccessLevel.READ,
|
|
expires_at=datetime.now() + timedelta(days=1),
|
|
)
|
|
assert not perm.is_expired()
|
|
|
|
# Expired
|
|
perm_expired = SpacePermission(
|
|
space_id="space-1",
|
|
principal_type="user",
|
|
principal_id="user-2",
|
|
access_level=AccessLevel.READ,
|
|
expires_at=datetime.now() - timedelta(days=1),
|
|
)
|
|
assert perm_expired.is_expired()
|
|
|
|
def test_has_access(self):
|
|
"""Test has_access check."""
|
|
perm = SpacePermission(
|
|
space_id="space-1",
|
|
principal_type="user",
|
|
principal_id="user-1",
|
|
access_level=AccessLevel.WRITE,
|
|
)
|
|
assert perm.has_access(AccessLevel.READ)
|
|
assert perm.has_access(AccessLevel.WRITE)
|
|
assert not perm.has_access(AccessLevel.ADMIN)
|
|
|
|
def test_permission_to_dict(self):
|
|
"""Test serialization."""
|
|
perm = SpacePermission(
|
|
space_id="space-1",
|
|
principal_type="user",
|
|
principal_id="user-1",
|
|
access_level=AccessLevel.ADMIN,
|
|
role=SpaceRole.OWNER,
|
|
granted_by="admin",
|
|
)
|
|
data = perm.to_dict()
|
|
assert data["access_level"] == "admin"
|
|
assert data["role"] == "owner"
|
|
assert data["granted_by"] == "admin"
|
|
|
|
|
|
# ===========================================================================
|
|
# SpaceAccess Model Tests
|
|
# ===========================================================================
|
|
|
|
|
|
class TestSpaceAccessModel:
|
|
"""Tests for SpaceAccess model."""
|
|
|
|
def test_access_checks(self):
|
|
"""Test access level checks."""
|
|
access = SpaceAccess(
|
|
space_id="space-1",
|
|
principal_id="user-1",
|
|
effective_level=AccessLevel.WRITE,
|
|
)
|
|
assert access.can_read()
|
|
assert access.can_write()
|
|
assert not access.is_admin()
|
|
|
|
def test_admin_access(self):
|
|
"""Test admin access."""
|
|
access = SpaceAccess(
|
|
space_id="space-1",
|
|
principal_id="user-1",
|
|
effective_level=AccessLevel.ADMIN,
|
|
roles=[SpaceRole.OWNER],
|
|
)
|
|
assert access.can_read()
|
|
assert access.can_write()
|
|
assert access.is_admin()
|
|
|
|
def test_no_access(self):
|
|
"""Test no access."""
|
|
access = SpaceAccess(
|
|
space_id="space-1",
|
|
principal_id="user-1",
|
|
effective_level=AccessLevel.NONE,
|
|
)
|
|
assert not access.can_read()
|
|
assert not access.can_write()
|
|
assert not access.is_admin()
|
|
|
|
|
|
# ===========================================================================
|
|
# InheritedVariable Tests
|
|
# ===========================================================================
|
|
|
|
|
|
class TestInheritedVariable:
|
|
"""Tests for InheritedVariable."""
|
|
|
|
def test_local_variable(self):
|
|
"""Test local variable detection."""
|
|
var = InheritedVariable(
|
|
name="version",
|
|
value="1.0",
|
|
source_space_id="space-1",
|
|
inheritance_depth=0,
|
|
)
|
|
assert var.is_local()
|
|
|
|
def test_inherited_variable(self):
|
|
"""Test inherited variable."""
|
|
var = InheritedVariable(
|
|
name="company",
|
|
value="Acme Corp",
|
|
source_space_id="parent-space",
|
|
inheritance_depth=2,
|
|
)
|
|
assert not var.is_local()
|
|
assert var.inheritance_depth == 2
|
|
|
|
|
|
# ===========================================================================
|
|
# InheritedConfig Tests
|
|
# ===========================================================================
|
|
|
|
|
|
class TestInheritedConfig:
|
|
"""Tests for InheritedConfig."""
|
|
|
|
def test_default_config(self):
|
|
"""Test default config values."""
|
|
config = InheritedConfig()
|
|
assert config.default_variant == "hierarchical"
|
|
assert config.enable_caching is True
|
|
assert config.theme is None
|
|
|
|
def test_source_tracking(self):
|
|
"""Test config source tracking."""
|
|
config = InheritedConfig(
|
|
theme="dark",
|
|
source_spaces={"theme": "parent-space"},
|
|
)
|
|
assert config.get_source("theme") == "parent-space"
|
|
assert config.is_inherited("theme", "child-space")
|
|
assert not config.is_inherited("theme", "parent-space")
|
|
|
|
|
|
# ===========================================================================
|
|
# SpaceReferenceRepository Tests
|
|
# ===========================================================================
|
|
|
|
|
|
class TestSqliteSpaceReferenceRepository:
|
|
"""Tests for SQLite space reference repository."""
|
|
|
|
def test_add_reference(self, reference_repo):
|
|
"""Test adding a reference."""
|
|
ref = SpaceReference(
|
|
source_space_id="space-1",
|
|
target_space_id="space-2",
|
|
reference_type=SpaceReferenceType.INCLUDES,
|
|
)
|
|
saved = reference_repo.add_reference(ref)
|
|
assert saved.id == ref.id
|
|
|
|
def test_get_reference(self, reference_repo):
|
|
"""Test getting a reference by ID."""
|
|
ref = SpaceReference(
|
|
source_space_id="space-1",
|
|
target_space_id="space-2",
|
|
reference_type=SpaceReferenceType.LINKS_TO,
|
|
)
|
|
reference_repo.add_reference(ref)
|
|
|
|
found = reference_repo.get_reference(ref.id)
|
|
assert found is not None
|
|
assert found.source_space_id == "space-1"
|
|
|
|
def test_get_references_from(self, reference_repo):
|
|
"""Test getting references from a source."""
|
|
ref1 = SpaceReference(
|
|
source_space_id="space-1",
|
|
target_space_id="space-2",
|
|
reference_type=SpaceReferenceType.INCLUDES,
|
|
)
|
|
ref2 = SpaceReference(
|
|
source_space_id="space-1",
|
|
target_space_id="space-3",
|
|
reference_type=SpaceReferenceType.LINKS_TO,
|
|
)
|
|
reference_repo.add_reference(ref1)
|
|
reference_repo.add_reference(ref2)
|
|
|
|
refs = reference_repo.get_references_from("space-1")
|
|
assert len(refs) == 2
|
|
|
|
# Filter by type
|
|
refs_includes = reference_repo.get_references_from(
|
|
"space-1", SpaceReferenceType.INCLUDES
|
|
)
|
|
assert len(refs_includes) == 1
|
|
assert refs_includes[0].target_space_id == "space-2"
|
|
|
|
def test_get_references_to(self, reference_repo):
|
|
"""Test getting references to a target."""
|
|
ref1 = SpaceReference(
|
|
source_space_id="space-1",
|
|
target_space_id="space-3",
|
|
reference_type=SpaceReferenceType.INCLUDES,
|
|
)
|
|
ref2 = SpaceReference(
|
|
source_space_id="space-2",
|
|
target_space_id="space-3",
|
|
reference_type=SpaceReferenceType.LINKS_TO,
|
|
)
|
|
reference_repo.add_reference(ref1)
|
|
reference_repo.add_reference(ref2)
|
|
|
|
refs = reference_repo.get_references_to("space-3")
|
|
assert len(refs) == 2
|
|
|
|
def test_remove_reference(self, reference_repo):
|
|
"""Test removing a reference."""
|
|
ref = SpaceReference(
|
|
source_space_id="space-1",
|
|
target_space_id="space-2",
|
|
reference_type=SpaceReferenceType.INCLUDES,
|
|
)
|
|
reference_repo.add_reference(ref)
|
|
assert reference_repo.remove_reference(ref.id)
|
|
assert reference_repo.get_reference(ref.id) is None
|
|
|
|
def test_reference_exists(self, reference_repo):
|
|
"""Test checking if reference exists."""
|
|
ref = SpaceReference(
|
|
source_space_id="space-1",
|
|
target_space_id="space-2",
|
|
reference_type=SpaceReferenceType.EXTENDS,
|
|
)
|
|
reference_repo.add_reference(ref)
|
|
|
|
assert reference_repo.reference_exists(
|
|
"space-1", "space-2", SpaceReferenceType.EXTENDS
|
|
)
|
|
assert not reference_repo.reference_exists(
|
|
"space-1", "space-2", SpaceReferenceType.INCLUDES
|
|
)
|
|
|
|
|
|
# ===========================================================================
|
|
# AccessControlRepository Tests
|
|
# ===========================================================================
|
|
|
|
|
|
class TestSqliteAccessControlRepository:
|
|
"""Tests for SQLite access control repository."""
|
|
|
|
def test_grant_permission(self, permission_repo):
|
|
"""Test granting a permission."""
|
|
perm = SpacePermission(
|
|
space_id="space-1",
|
|
principal_type="user",
|
|
principal_id="user-1",
|
|
access_level=AccessLevel.WRITE,
|
|
)
|
|
saved = permission_repo.grant_permission(perm)
|
|
assert saved.space_id == "space-1"
|
|
|
|
def test_get_permission(self, permission_repo):
|
|
"""Test getting a specific permission."""
|
|
perm = SpacePermission(
|
|
space_id="space-1",
|
|
principal_type="user",
|
|
principal_id="user-1",
|
|
access_level=AccessLevel.READ,
|
|
)
|
|
permission_repo.grant_permission(perm)
|
|
|
|
found = permission_repo.get_permission("space-1", "user", "user-1")
|
|
assert found is not None
|
|
assert found.access_level == AccessLevel.READ
|
|
|
|
def test_revoke_permission(self, permission_repo):
|
|
"""Test revoking a permission."""
|
|
perm = SpacePermission(
|
|
space_id="space-1",
|
|
principal_type="user",
|
|
principal_id="user-1",
|
|
access_level=AccessLevel.ADMIN,
|
|
)
|
|
permission_repo.grant_permission(perm)
|
|
assert permission_repo.revoke_permission("space-1", "user", "user-1")
|
|
assert permission_repo.get_permission("space-1", "user", "user-1") is None
|
|
|
|
def test_get_permissions_for_space(self, permission_repo):
|
|
"""Test getting all permissions for a space."""
|
|
perm1 = SpacePermission(
|
|
space_id="space-1",
|
|
principal_type="user",
|
|
principal_id="user-1",
|
|
access_level=AccessLevel.READ,
|
|
)
|
|
perm2 = SpacePermission(
|
|
space_id="space-1",
|
|
principal_type="user",
|
|
principal_id="user-2",
|
|
access_level=AccessLevel.WRITE,
|
|
)
|
|
permission_repo.grant_permission(perm1)
|
|
permission_repo.grant_permission(perm2)
|
|
|
|
perms = permission_repo.get_permissions_for_space("space-1")
|
|
assert len(perms) == 2
|
|
|
|
def test_get_permissions_for_principal(self, permission_repo):
|
|
"""Test getting all permissions for a principal."""
|
|
perm1 = SpacePermission(
|
|
space_id="space-1",
|
|
principal_type="user",
|
|
principal_id="user-1",
|
|
access_level=AccessLevel.READ,
|
|
)
|
|
perm2 = SpacePermission(
|
|
space_id="space-2",
|
|
principal_type="user",
|
|
principal_id="user-1",
|
|
access_level=AccessLevel.ADMIN,
|
|
)
|
|
permission_repo.grant_permission(perm1)
|
|
permission_repo.grant_permission(perm2)
|
|
|
|
perms = permission_repo.get_permissions_for_principal("user", "user-1")
|
|
assert len(perms) == 2
|
|
|
|
|
|
# ===========================================================================
|
|
# InheritanceResolver Tests
|
|
# ===========================================================================
|
|
|
|
|
|
class TestInheritanceResolver:
|
|
"""Tests for inheritance resolution."""
|
|
|
|
def test_get_parent_chain_no_parent(self, inheritance_resolver, space_repo):
|
|
"""Test getting parent chain for space without parent."""
|
|
space = InformationSpace(name="root-space")
|
|
space_repo.create(space)
|
|
|
|
chain = inheritance_resolver.get_parent_chain(space.id)
|
|
assert chain == []
|
|
|
|
def test_get_parent_chain_single_parent(self, inheritance_resolver, space_repo):
|
|
"""Test getting parent chain with one parent."""
|
|
parent = InformationSpace(name="parent-space")
|
|
space_repo.create(parent)
|
|
|
|
child = InformationSpace(name="child-space", parent_space_id=parent.id)
|
|
space_repo.create(child)
|
|
|
|
chain = inheritance_resolver.get_parent_chain(child.id)
|
|
assert len(chain) == 1
|
|
assert chain[0].id == parent.id
|
|
|
|
def test_get_parent_chain_multiple_levels(self, inheritance_resolver, space_repo):
|
|
"""Test getting parent chain with multiple levels."""
|
|
grandparent = InformationSpace(name="grandparent")
|
|
space_repo.create(grandparent)
|
|
|
|
parent = InformationSpace(name="parent", parent_space_id=grandparent.id)
|
|
space_repo.create(parent)
|
|
|
|
child = InformationSpace(name="child", parent_space_id=parent.id)
|
|
space_repo.create(child)
|
|
|
|
chain = inheritance_resolver.get_parent_chain(child.id)
|
|
assert len(chain) == 2
|
|
assert chain[0].id == parent.id
|
|
assert chain[1].id == grandparent.id
|
|
|
|
def test_resolve_variable_local(self, inheritance_resolver, space_repo, variable_repo):
|
|
"""Test resolving a local variable."""
|
|
space = InformationSpace(name="test-space")
|
|
space_repo.create(space)
|
|
|
|
var = SpaceVariable(space_id=space.id, name="version", value="1.0")
|
|
variable_repo.set_variable(var)
|
|
|
|
resolved = inheritance_resolver.resolve_variable(space.id, "version")
|
|
assert resolved is not None
|
|
assert resolved.value == "1.0"
|
|
assert resolved.is_local()
|
|
|
|
def test_resolve_variable_inherited(
|
|
self, inheritance_resolver, space_repo, variable_repo
|
|
):
|
|
"""Test resolving an inherited variable."""
|
|
parent = InformationSpace(name="parent-space")
|
|
space_repo.create(parent)
|
|
|
|
child = InformationSpace(name="child-space", parent_space_id=parent.id)
|
|
space_repo.create(child)
|
|
|
|
# Set variable in parent only
|
|
var = SpaceVariable(space_id=parent.id, name="company", value="Acme")
|
|
variable_repo.set_variable(var)
|
|
|
|
resolved = inheritance_resolver.resolve_variable(child.id, "company")
|
|
assert resolved is not None
|
|
assert resolved.value == "Acme"
|
|
assert not resolved.is_local()
|
|
assert resolved.source_space_id == parent.id
|
|
|
|
def test_resolve_variable_override(
|
|
self, inheritance_resolver, space_repo, variable_repo
|
|
):
|
|
"""Test that local variable overrides inherited."""
|
|
parent = InformationSpace(name="parent-space")
|
|
space_repo.create(parent)
|
|
|
|
child = InformationSpace(name="child-space", parent_space_id=parent.id)
|
|
space_repo.create(child)
|
|
|
|
# Set in both
|
|
var_parent = SpaceVariable(space_id=parent.id, name="version", value="1.0")
|
|
variable_repo.set_variable(var_parent)
|
|
|
|
var_child = SpaceVariable(space_id=child.id, name="version", value="2.0")
|
|
variable_repo.set_variable(var_child)
|
|
|
|
resolved = inheritance_resolver.resolve_variable(child.id, "version")
|
|
assert resolved is not None
|
|
assert resolved.value == "2.0"
|
|
assert resolved.is_local()
|
|
|
|
def test_resolve_all_variables(
|
|
self, inheritance_resolver, space_repo, variable_repo
|
|
):
|
|
"""Test resolving all variables including inherited."""
|
|
parent = InformationSpace(name="parent")
|
|
space_repo.create(parent)
|
|
|
|
child = InformationSpace(name="child", parent_space_id=parent.id)
|
|
space_repo.create(child)
|
|
|
|
# Parent variables
|
|
variable_repo.set_variable(
|
|
SpaceVariable(space_id=parent.id, name="company", value="Acme")
|
|
)
|
|
variable_repo.set_variable(
|
|
SpaceVariable(space_id=parent.id, name="version", value="1.0")
|
|
)
|
|
|
|
# Child variables (one override, one new)
|
|
variable_repo.set_variable(
|
|
SpaceVariable(space_id=child.id, name="version", value="2.0")
|
|
)
|
|
variable_repo.set_variable(
|
|
SpaceVariable(space_id=child.id, name="author", value="John")
|
|
)
|
|
|
|
all_vars = inheritance_resolver.resolve_all_variables(child.id)
|
|
assert len(all_vars) == 3
|
|
assert all_vars["company"].value == "Acme"
|
|
assert not all_vars["company"].is_local()
|
|
assert all_vars["version"].value == "2.0"
|
|
assert all_vars["version"].is_local()
|
|
assert all_vars["author"].value == "John"
|
|
|
|
def test_resolve_config_inheritance(self, inheritance_resolver, space_repo):
|
|
"""Test resolving config with inheritance."""
|
|
parent = InformationSpace(
|
|
name="parent",
|
|
config=SpaceConfig(theme="dark", enable_caching=True),
|
|
)
|
|
space_repo.create(parent)
|
|
|
|
child = InformationSpace(
|
|
name="child",
|
|
parent_space_id=parent.id,
|
|
config=SpaceConfig(default_variant="flat"),
|
|
)
|
|
space_repo.create(child)
|
|
|
|
config = inheritance_resolver.resolve_config(child.id)
|
|
assert config.theme == "dark" # inherited
|
|
assert config.default_variant == "flat" # local
|
|
assert config.source_spaces["theme"] == parent.id
|
|
|
|
|
|
# ===========================================================================
|
|
# AccessControlService Tests
|
|
# ===========================================================================
|
|
|
|
|
|
class TestAccessControlService:
|
|
"""Tests for access control service."""
|
|
|
|
def test_grant_permission(self, access_service, space_repo):
|
|
"""Test granting a permission."""
|
|
space = InformationSpace(name="test-space")
|
|
space_repo.create(space)
|
|
|
|
perm = access_service.grant_permission(
|
|
space_id=space.id,
|
|
principal_type="user",
|
|
principal_id="user-1",
|
|
access_level=AccessLevel.WRITE,
|
|
)
|
|
assert perm.access_level == AccessLevel.WRITE
|
|
|
|
def test_check_access(self, access_service, space_repo):
|
|
"""Test checking access."""
|
|
space = InformationSpace(name="test-space")
|
|
space_repo.create(space)
|
|
|
|
access_service.grant_permission(
|
|
space_id=space.id,
|
|
principal_type="user",
|
|
principal_id="user-1",
|
|
access_level=AccessLevel.WRITE,
|
|
)
|
|
|
|
assert access_service.check_access(
|
|
space.id, "user", "user-1", AccessLevel.READ
|
|
)
|
|
assert access_service.check_access(
|
|
space.id, "user", "user-1", AccessLevel.WRITE
|
|
)
|
|
assert not access_service.check_access(
|
|
space.id, "user", "user-1", AccessLevel.ADMIN
|
|
)
|
|
|
|
def test_revoke_permission(self, access_service, space_repo):
|
|
"""Test revoking access."""
|
|
space = InformationSpace(name="test-space")
|
|
space_repo.create(space)
|
|
|
|
access_service.grant_permission(
|
|
space_id=space.id,
|
|
principal_type="user",
|
|
principal_id="user-1",
|
|
access_level=AccessLevel.READ,
|
|
)
|
|
|
|
assert access_service.revoke_permission(space.id, "user", "user-1")
|
|
assert not access_service.check_access(
|
|
space.id, "user", "user-1", AccessLevel.READ
|
|
)
|
|
|
|
def test_effective_access_direct(self, access_service, space_repo):
|
|
"""Test getting effective access with direct permission."""
|
|
space = InformationSpace(name="test-space")
|
|
space_repo.create(space)
|
|
|
|
access_service.grant_permission(
|
|
space_id=space.id,
|
|
principal_type="user",
|
|
principal_id="user-1",
|
|
access_level=AccessLevel.ADMIN,
|
|
role=SpaceRole.OWNER,
|
|
)
|
|
|
|
access = access_service.get_effective_access(space.id, "user", "user-1")
|
|
assert access.effective_level == AccessLevel.ADMIN
|
|
assert SpaceRole.OWNER in access.roles
|
|
|
|
def test_effective_access_inherited(self, access_service, space_repo):
|
|
"""Test getting effective access with inherited permission."""
|
|
parent = InformationSpace(name="parent")
|
|
space_repo.create(parent)
|
|
|
|
child = InformationSpace(name="child", parent_space_id=parent.id)
|
|
space_repo.create(child)
|
|
|
|
# Grant on parent only
|
|
access_service.grant_permission(
|
|
space_id=parent.id,
|
|
principal_type="user",
|
|
principal_id="user-1",
|
|
access_level=AccessLevel.READ,
|
|
)
|
|
|
|
access = access_service.get_effective_access(child.id, "user", "user-1")
|
|
assert access.effective_level == AccessLevel.READ
|
|
assert parent.id in access.inherited_from
|
|
|
|
|
|
# ===========================================================================
|
|
# ComposableSpaceService Tests
|
|
# ===========================================================================
|
|
|
|
|
|
class TestComposableSpaceService:
|
|
"""Tests for composable space service."""
|
|
|
|
def test_add_reference(self, composable_service, space_repo):
|
|
"""Test adding a space reference."""
|
|
space1 = InformationSpace(name="space-1")
|
|
space2 = InformationSpace(name="space-2")
|
|
space_repo.create(space1)
|
|
space_repo.create(space2)
|
|
|
|
ref = composable_service.add_reference(
|
|
source_space_id=space1.id,
|
|
target_space_id=space2.id,
|
|
reference_type=SpaceReferenceType.INCLUDES,
|
|
alias="shared",
|
|
)
|
|
assert ref.alias == "shared"
|
|
|
|
def test_add_reference_invalid_source(self, composable_service, space_repo):
|
|
"""Test adding reference with invalid source."""
|
|
space = InformationSpace(name="valid-space")
|
|
space_repo.create(space)
|
|
|
|
with pytest.raises(ValueError, match="Source space.*not found"):
|
|
composable_service.add_reference(
|
|
source_space_id="invalid-id",
|
|
target_space_id=space.id,
|
|
reference_type=SpaceReferenceType.LINKS_TO,
|
|
)
|
|
|
|
def test_add_reference_self_reference(self, composable_service, space_repo):
|
|
"""Test that self-reference is rejected."""
|
|
space = InformationSpace(name="test-space")
|
|
space_repo.create(space)
|
|
|
|
with pytest.raises(ValueError, match="Cannot reference self"):
|
|
composable_service.add_reference(
|
|
source_space_id=space.id,
|
|
target_space_id=space.id,
|
|
reference_type=SpaceReferenceType.INCLUDES,
|
|
)
|
|
|
|
def test_add_reference_circular_detection(self, composable_service, space_repo):
|
|
"""Test circular reference detection for EXTENDS type."""
|
|
space1 = InformationSpace(name="space-1")
|
|
space2 = InformationSpace(name="space-2")
|
|
space3 = InformationSpace(name="space-3")
|
|
space_repo.create(space1)
|
|
space_repo.create(space2)
|
|
space_repo.create(space3)
|
|
|
|
# space1 -> space2 -> space3
|
|
composable_service.add_reference(
|
|
space1.id, space2.id, SpaceReferenceType.EXTENDS
|
|
)
|
|
composable_service.add_reference(
|
|
space2.id, space3.id, SpaceReferenceType.EXTENDS
|
|
)
|
|
|
|
# space3 -> space1 would create a cycle
|
|
with pytest.raises(CircularReferenceError):
|
|
composable_service.add_reference(
|
|
space3.id, space1.id, SpaceReferenceType.EXTENDS
|
|
)
|
|
|
|
def test_remove_reference(self, composable_service, space_repo):
|
|
"""Test removing a reference."""
|
|
space1 = InformationSpace(name="space-1")
|
|
space2 = InformationSpace(name="space-2")
|
|
space_repo.create(space1)
|
|
space_repo.create(space2)
|
|
|
|
ref = composable_service.add_reference(
|
|
space1.id, space2.id, SpaceReferenceType.LINKS_TO
|
|
)
|
|
assert composable_service.remove_reference(ref.id)
|
|
|
|
def test_get_references_from_and_to(self, composable_service, space_repo):
|
|
"""Test getting references."""
|
|
space1 = InformationSpace(name="space-1")
|
|
space2 = InformationSpace(name="space-2")
|
|
space3 = InformationSpace(name="space-3")
|
|
space_repo.create(space1)
|
|
space_repo.create(space2)
|
|
space_repo.create(space3)
|
|
|
|
composable_service.add_reference(
|
|
space1.id, space2.id, SpaceReferenceType.INCLUDES
|
|
)
|
|
composable_service.add_reference(
|
|
space1.id, space3.id, SpaceReferenceType.LINKS_TO
|
|
)
|
|
|
|
refs_from = composable_service.get_references_from(space1.id)
|
|
assert len(refs_from) == 2
|
|
|
|
refs_to = composable_service.get_references_to(space2.id)
|
|
assert len(refs_to) == 1
|
|
|
|
def test_get_composed_spaces(self, composable_service, space_repo):
|
|
"""Test getting all composed spaces."""
|
|
parent = InformationSpace(name="parent")
|
|
space_repo.create(parent)
|
|
|
|
main = InformationSpace(name="main", parent_space_id=parent.id)
|
|
space_repo.create(main)
|
|
|
|
included = InformationSpace(name="included")
|
|
extended = InformationSpace(name="extended")
|
|
space_repo.create(included)
|
|
space_repo.create(extended)
|
|
|
|
composable_service.add_reference(
|
|
main.id, included.id, SpaceReferenceType.INCLUDES
|
|
)
|
|
composable_service.add_reference(
|
|
main.id, extended.id, SpaceReferenceType.EXTENDS
|
|
)
|
|
|
|
composed = composable_service.get_composed_spaces(main.id)
|
|
assert len(composed) == 3 # parent, included, extended
|
|
ids = {s.id for s in composed}
|
|
assert parent.id in ids
|
|
assert included.id in ids
|
|
assert extended.id in ids
|
|
|
|
def test_resolve_variable(self, composable_service, space_repo, variable_repo):
|
|
"""Test variable resolution via service."""
|
|
parent = InformationSpace(name="parent")
|
|
space_repo.create(parent)
|
|
|
|
child = InformationSpace(name="child", parent_space_id=parent.id)
|
|
space_repo.create(child)
|
|
|
|
variable_repo.set_variable(
|
|
SpaceVariable(space_id=parent.id, name="inherited_var", value="from parent")
|
|
)
|
|
|
|
resolved = composable_service.resolve_variable(child.id, "inherited_var")
|
|
assert resolved is not None
|
|
assert resolved.value == "from parent"
|
|
|
|
def test_resolve_config(self, composable_service, space_repo):
|
|
"""Test config resolution via service."""
|
|
space = InformationSpace(
|
|
name="test-space", config=SpaceConfig(theme="minimal")
|
|
)
|
|
space_repo.create(space)
|
|
|
|
config = composable_service.resolve_config(space.id)
|
|
assert config.theme == "minimal"
|
|
|
|
def test_grant_and_check_access(self, composable_service, space_repo):
|
|
"""Test access control via service."""
|
|
space = InformationSpace(name="test-space")
|
|
space_repo.create(space)
|
|
|
|
composable_service.grant_access(
|
|
space_id=space.id,
|
|
principal_type="user",
|
|
principal_id="user-1",
|
|
access_level=AccessLevel.WRITE,
|
|
role=SpaceRole.EDITOR,
|
|
)
|
|
|
|
assert composable_service.check_access(
|
|
space.id, "user", "user-1", AccessLevel.WRITE
|
|
)
|
|
assert not composable_service.check_access(
|
|
space.id, "user", "user-1", AccessLevel.ADMIN
|
|
)
|
|
|
|
def test_revoke_access(self, composable_service, space_repo):
|
|
"""Test revoking access via service."""
|
|
space = InformationSpace(name="test-space")
|
|
space_repo.create(space)
|
|
|
|
composable_service.grant_access(
|
|
space_id=space.id,
|
|
principal_type="user",
|
|
principal_id="user-1",
|
|
access_level=AccessLevel.READ,
|
|
)
|
|
|
|
assert composable_service.revoke_access(space.id, "user", "user-1")
|
|
assert not composable_service.check_access(
|
|
space.id, "user", "user-1", AccessLevel.READ
|
|
)
|
|
|
|
def test_get_parent_chain(self, composable_service, space_repo):
|
|
"""Test getting parent chain via service."""
|
|
grandparent = InformationSpace(name="grandparent")
|
|
space_repo.create(grandparent)
|
|
|
|
parent = InformationSpace(name="parent", parent_space_id=grandparent.id)
|
|
space_repo.create(parent)
|
|
|
|
child = InformationSpace(name="child", parent_space_id=parent.id)
|
|
space_repo.create(child)
|
|
|
|
chain = composable_service.get_parent_chain(child.id)
|
|
assert len(chain) == 2
|
|
assert chain[0].name == "parent"
|
|
assert chain[1].name == "grandparent"
|
|
|
|
def test_get_space_permissions(self, composable_service, space_repo):
|
|
"""Test getting space permissions."""
|
|
space = InformationSpace(name="test-space")
|
|
space_repo.create(space)
|
|
|
|
composable_service.grant_access(
|
|
space.id, "user", "user-1", AccessLevel.READ
|
|
)
|
|
composable_service.grant_access(
|
|
space.id, "user", "user-2", AccessLevel.WRITE
|
|
)
|
|
|
|
perms = composable_service.get_space_permissions(space.id)
|
|
assert len(perms) == 2
|
|
|
|
|
|
# ===========================================================================
|
|
# Edge Cases and Error Handling
|
|
# ===========================================================================
|
|
|
|
|
|
class TestEdgeCases:
|
|
"""Tests for edge cases and error handling."""
|
|
|
|
def test_max_inheritance_depth(self, space_repo, variable_repo):
|
|
"""Test max inheritance depth is enforced."""
|
|
resolver = InheritanceResolver(
|
|
space_repo=space_repo,
|
|
variable_repo=variable_repo,
|
|
max_depth=3,
|
|
)
|
|
|
|
# Create a chain of 5 spaces
|
|
spaces = []
|
|
for i in range(5):
|
|
parent_id = spaces[-1].id if spaces else None
|
|
space = InformationSpace(name=f"space-{i}", parent_space_id=parent_id)
|
|
space_repo.create(space)
|
|
spaces.append(space)
|
|
|
|
# Getting parent chain from the deepest should raise error
|
|
with pytest.raises(CircularReferenceError, match="Maximum inheritance depth"):
|
|
resolver.get_parent_chain(spaces[-1].id)
|
|
|
|
def test_permission_expiration_handling(self, access_service, space_repo):
|
|
"""Test that expired permissions are not counted."""
|
|
space = InformationSpace(name="test-space")
|
|
space_repo.create(space)
|
|
|
|
# Grant expired permission directly in repo
|
|
expired_perm = SpacePermission(
|
|
space_id=space.id,
|
|
principal_type="user",
|
|
principal_id="user-1",
|
|
access_level=AccessLevel.ADMIN,
|
|
expires_at=datetime.now() - timedelta(days=1),
|
|
)
|
|
access_service._permission_repo.grant_permission(expired_perm)
|
|
|
|
# Should not have access
|
|
access = access_service.get_effective_access(space.id, "user", "user-1")
|
|
assert access.effective_level == AccessLevel.NONE
|
|
|
|
def test_nonexistent_variable(self, inheritance_resolver, space_repo):
|
|
"""Test resolving nonexistent variable returns None."""
|
|
space = InformationSpace(name="test-space")
|
|
space_repo.create(space)
|
|
|
|
resolved = inheritance_resolver.resolve_variable(space.id, "nonexistent")
|
|
assert resolved is None
|
|
|
|
def test_reference_type_filtering(self, composable_service, space_repo):
|
|
"""Test filtering references by type."""
|
|
space1 = InformationSpace(name="space-1")
|
|
space2 = InformationSpace(name="space-2")
|
|
space3 = InformationSpace(name="space-3")
|
|
space_repo.create(space1)
|
|
space_repo.create(space2)
|
|
space_repo.create(space3)
|
|
|
|
composable_service.add_reference(
|
|
space1.id, space2.id, SpaceReferenceType.INCLUDES
|
|
)
|
|
composable_service.add_reference(
|
|
space1.id, space3.id, SpaceReferenceType.EXTENDS
|
|
)
|
|
|
|
# Filter by type
|
|
includes = composable_service.get_references_from(
|
|
space1.id, SpaceReferenceType.INCLUDES
|
|
)
|
|
assert len(includes) == 1
|
|
assert includes[0].target_space_id == space2.id
|
|
|
|
extends = composable_service.get_references_from(
|
|
space1.id, SpaceReferenceType.EXTENDS
|
|
)
|
|
assert len(extends) == 1
|
|
assert extends[0].target_space_id == space3.id
|