feat: implement governed project portfolio
This commit is contained in:
+10
-2
@@ -23,8 +23,16 @@ class ProjectsManifestTests(unittest.TestCase):
|
||||
self.assertIn("connectors", manifest.optional_dependencies)
|
||||
self.assertIn("tickets", manifest.optional_dependencies)
|
||||
self.assertTrue(manifest.documentation)
|
||||
self.assertIsNone(manifest.route_factory)
|
||||
self.assertIsNone(manifest.frontend)
|
||||
self.assertEqual("vertical_slice", manifest.architecture.maturity)
|
||||
self.assertIn(
|
||||
"project benefit and change-impact review",
|
||||
manifest.architecture.owned_concepts,
|
||||
)
|
||||
self.assertIsNotNone(manifest.route_factory)
|
||||
self.assertEqual("@govoplan/projects-webui", manifest.frontend.package_name)
|
||||
self.assertIsNotNone(manifest.migration_spec)
|
||||
self.assertEqual("projects.registry", manifest.provides_interfaces[0].name)
|
||||
self.assertEqual(1, len(manifest.search_sources))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.external_references import ExternalObjectReference
|
||||
from govoplan_core.core.search import (
|
||||
SearchAuthorizationRequest,
|
||||
SearchBackfillRequest,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.security.module_permissions import scopes_grant_compatible
|
||||
from govoplan_projects.backend.domain import (
|
||||
ProjectDomainError,
|
||||
ProjectMembership,
|
||||
ProjectRecord,
|
||||
ProjectSubjectRef,
|
||||
)
|
||||
from govoplan_projects.backend.search_source import (
|
||||
PROVIDER_ID,
|
||||
ProjectsSearchSource,
|
||||
)
|
||||
from govoplan_projects.backend.service import (
|
||||
ADMIN_SCOPE,
|
||||
READ_SCOPE,
|
||||
WRITE_SCOPE,
|
||||
ProjectStoreError,
|
||||
create_project_object,
|
||||
get_project_object,
|
||||
list_project_objects,
|
||||
project_object_history,
|
||||
update_project_object,
|
||||
)
|
||||
|
||||
|
||||
NOW = datetime(2026, 8, 1, 10, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
class Principal:
|
||||
def __init__(
|
||||
self,
|
||||
tenant_id: str,
|
||||
account_id: str,
|
||||
*,
|
||||
scopes: tuple[str, ...] = (READ_SCOPE, WRITE_SCOPE),
|
||||
group_ids: tuple[str, ...] = (),
|
||||
) -> None:
|
||||
self.tenant_id = tenant_id
|
||||
self.account_id = account_id
|
||||
self.identity_id = f"identity-{account_id}"
|
||||
self.membership_id = f"membership-{account_id}"
|
||||
self.group_ids = frozenset(group_ids)
|
||||
self.role_ids = frozenset()
|
||||
self.function_assignment_ids = frozenset()
|
||||
self.acting_assignment_id = None
|
||||
self.service_account_id = None
|
||||
self.scopes = frozenset(scopes)
|
||||
self.user = SimpleNamespace(id=f"user-{account_id}")
|
||||
|
||||
def has(self, scope: str) -> bool:
|
||||
return scopes_grant_compatible(self.scopes, scope)
|
||||
|
||||
|
||||
class ProjectServiceTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
Base.metadata.create_all(self.engine)
|
||||
self.session = Session(self.engine, expire_on_commit=False)
|
||||
self.owner = Principal("tenant-1", "owner-1")
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
self.engine.dispose()
|
||||
|
||||
def test_lifecycle_is_versioned_idempotent_and_occ_guarded(self) -> None:
|
||||
record = project_record()
|
||||
created = create_project_object(
|
||||
self.session,
|
||||
self.owner,
|
||||
record=record,
|
||||
idempotency_key="create-project-1",
|
||||
)
|
||||
replay = create_project_object(
|
||||
self.session,
|
||||
self.owner,
|
||||
record=record,
|
||||
idempotency_key="create-project-1",
|
||||
)
|
||||
self.assertEqual(created.to_dict(), replay.to_dict())
|
||||
|
||||
updated = update_project_object(
|
||||
self.session,
|
||||
self.owner,
|
||||
object_kind="project",
|
||||
object_id="project-1",
|
||||
expected_revision=1,
|
||||
changes={"state": "proposed", "title": "Permit modernization"},
|
||||
recorded_at=NOW.replace(hour=11),
|
||||
change_reason="Submitted for approval.",
|
||||
idempotency_key="update-project-1",
|
||||
)
|
||||
self.assertEqual(2, updated.revision)
|
||||
self.assertEqual("proposed", updated.state)
|
||||
self.assertEqual(
|
||||
[2, 1],
|
||||
[
|
||||
item.revision
|
||||
for item in project_object_history(
|
||||
self.session,
|
||||
self.owner,
|
||||
object_kind="project",
|
||||
object_id="project-1",
|
||||
)
|
||||
],
|
||||
)
|
||||
with self.assertRaisesRegex(ProjectStoreError, "stale"):
|
||||
update_project_object(
|
||||
self.session,
|
||||
self.owner,
|
||||
object_kind="project",
|
||||
object_id="project-1",
|
||||
expected_revision=1,
|
||||
changes={"title": "Stale title"},
|
||||
recorded_at=NOW.replace(hour=12),
|
||||
change_reason="Stale update.",
|
||||
idempotency_key="update-project-stale",
|
||||
)
|
||||
|
||||
def test_state_machine_rejects_invalid_transition(self) -> None:
|
||||
create_project_object(
|
||||
self.session,
|
||||
self.owner,
|
||||
record=project_record(),
|
||||
idempotency_key="create-transition-project",
|
||||
)
|
||||
with self.assertRaisesRegex(ProjectDomainError, "Cannot move"):
|
||||
update_project_object(
|
||||
self.session,
|
||||
self.owner,
|
||||
object_kind="project",
|
||||
object_id="project-1",
|
||||
expected_revision=1,
|
||||
changes={"state": "completed"},
|
||||
recorded_at=NOW.replace(hour=11),
|
||||
change_reason="Invalid shortcut.",
|
||||
idempotency_key="invalid-transition",
|
||||
)
|
||||
|
||||
def test_parent_hierarchy_and_tenant_boundary_are_enforced(self) -> None:
|
||||
with self.assertRaisesRegex(ProjectStoreError, "parent"):
|
||||
create_project_object(
|
||||
self.session,
|
||||
self.owner,
|
||||
record=replace(
|
||||
project_record(),
|
||||
object_kind="milestone",
|
||||
object_id="milestone-1",
|
||||
object_key="milestone-1",
|
||||
state="planned",
|
||||
parent_kind="project",
|
||||
parent_id="missing-project",
|
||||
),
|
||||
idempotency_key="orphan-milestone",
|
||||
)
|
||||
with self.assertRaisesRegex(ProjectStoreError, "cross tenants"):
|
||||
create_project_object(
|
||||
self.session,
|
||||
Principal("tenant-2", "owner-2"),
|
||||
record=project_record(),
|
||||
idempotency_key="cross-tenant-project",
|
||||
)
|
||||
|
||||
def test_restricted_memberships_filter_lists_and_reads(self) -> None:
|
||||
record = replace(
|
||||
project_record(),
|
||||
visibility="restricted",
|
||||
memberships=(
|
||||
ProjectMembership(
|
||||
subject=ProjectSubjectRef(kind="group", id="group-reviewers"),
|
||||
role="reviewer",
|
||||
permissions=("read",),
|
||||
),
|
||||
),
|
||||
)
|
||||
create_project_object(
|
||||
self.session,
|
||||
self.owner,
|
||||
record=record,
|
||||
idempotency_key="create-restricted-project",
|
||||
)
|
||||
outsider = Principal("tenant-1", "outsider")
|
||||
reviewer = Principal(
|
||||
"tenant-1",
|
||||
"reviewer",
|
||||
group_ids=("group-reviewers",),
|
||||
)
|
||||
self.assertIsNone(
|
||||
get_project_object(
|
||||
self.session,
|
||||
outsider,
|
||||
object_kind="project",
|
||||
object_id="project-1",
|
||||
)
|
||||
)
|
||||
visible, total = list_project_objects(self.session, outsider)
|
||||
self.assertEqual(((), 0), (visible, total))
|
||||
self.assertIsNotNone(
|
||||
get_project_object(
|
||||
self.session,
|
||||
reviewer,
|
||||
object_kind="project",
|
||||
object_id="project-1",
|
||||
)
|
||||
)
|
||||
admin = Principal(
|
||||
"tenant-1",
|
||||
"admin",
|
||||
scopes=(READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE),
|
||||
)
|
||||
self.assertEqual(1, list_project_objects(self.session, admin)[1])
|
||||
|
||||
def test_search_backfill_has_acl_tokens_and_rechecks_access(self) -> None:
|
||||
create_project_object(
|
||||
self.session,
|
||||
self.owner,
|
||||
record=replace(project_record(), visibility="restricted"),
|
||||
idempotency_key="create-search-project",
|
||||
)
|
||||
source = ProjectsSearchSource()
|
||||
page = source.backfill(
|
||||
self.session,
|
||||
request=SearchBackfillRequest(
|
||||
tenant_id="tenant-1",
|
||||
provider_id=PROVIDER_ID,
|
||||
resource_type="project",
|
||||
rebuild_id="rebuild-1",
|
||||
),
|
||||
)
|
||||
self.assertTrue(page.complete)
|
||||
self.assertEqual(1, len(page.documents))
|
||||
document = page.documents[0]
|
||||
self.assertIn("account:owner-1", document.acl_tokens)
|
||||
request = SearchAuthorizationRequest(
|
||||
reference=document.reference,
|
||||
source_revision=document.source_revision,
|
||||
)
|
||||
owner_decision = source.authorize(
|
||||
self.session,
|
||||
self.owner,
|
||||
requests=(request,),
|
||||
)
|
||||
outsider_decision = source.authorize(
|
||||
self.session,
|
||||
Principal("tenant-1", "outsider"),
|
||||
requests=(request,),
|
||||
)
|
||||
self.assertTrue(owner_decision[document.reference.key])
|
||||
self.assertFalse(outsider_decision[document.reference.key])
|
||||
|
||||
|
||||
def project_record(
|
||||
*,
|
||||
object_kind: str = "project",
|
||||
) -> ProjectRecord:
|
||||
return ProjectRecord.from_mapping(
|
||||
{
|
||||
"tenant_id": "tenant-1",
|
||||
"object_kind": object_kind,
|
||||
"object_id": "project-1",
|
||||
"object_key": "permit-modernization",
|
||||
"revision": 1,
|
||||
"title": "Permit modernization",
|
||||
"state": "draft",
|
||||
"description": "Replace a fragmented permit process.",
|
||||
"visibility": "tenant",
|
||||
"recorded_at": NOW.isoformat(),
|
||||
"change_reason": "Initial planning baseline.",
|
||||
"owner": {
|
||||
"kind": "account",
|
||||
"id": "owner-1",
|
||||
"label": "Project owner",
|
||||
},
|
||||
"outcomes": [
|
||||
{
|
||||
"key": "faster-decisions",
|
||||
"title": "Faster permit decisions",
|
||||
"success_indicators": ["Median processing time"],
|
||||
}
|
||||
],
|
||||
"benefits": [
|
||||
{
|
||||
"key": "less-rework",
|
||||
"title": "Less manual rework",
|
||||
"target": "Reduce rework by 30 percent",
|
||||
}
|
||||
],
|
||||
"external_references": [
|
||||
ExternalObjectReference(
|
||||
system="openproject",
|
||||
object_type="project",
|
||||
object_id="42",
|
||||
).to_dict()
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user