diff --git a/README.md b/README.md index 7a7b5d0..2c2be70 100644 --- a/README.md +++ b/README.md @@ -46,13 +46,21 @@ accessibility evidence are recorded in ## Module Contract -The module registers two capabilities from -`govoplan_core.core.organizations`: +The module registers organization-directory capabilities from +`govoplan_core.core.organizations` and a privacy capability: - `organizations.directory` for backward-compatible direct unit/function lookup; - `organizations.hierarchyDirectory` for typed, tenant-safe, explicitly structure-scoped hierarchy and path resolution. +- `privacy.dsar.organizations` for tenant-scoped account attribution on model + instantiations and upgrades. + +The DSAR provider does not treat institutional units or functions as personal +records. It retains model-change attribution as governance evidence and excludes +global templates, opaque definitions, previews, decisions, provenance, +idempotency material, and other tenants. Identity-to-function assignments are +covered by IDM, which owns that relationship. Feature modules should consume the capability instead of importing organization ORM models. diff --git a/src/govoplan_organizations/backend/dsar_provider.py b/src/govoplan_organizations/backend/dsar_provider.py new file mode 100644 index 0000000..f02c596 --- /dev/null +++ b/src/govoplan_organizations/backend/dsar_provider.py @@ -0,0 +1,306 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from datetime import datetime, timezone + +from sqlalchemy import or_ +from sqlalchemy.orm import Session + +from govoplan_core.core.dsar import ( + DsarErasureActionRef, + DsarExecutionResultRef, + DsarRecordRef, + DsarSubjectRef, + dsar_capability_name, +) +from govoplan_organizations.backend.db.models import ( + OrganizationModelInstantiation, + OrganizationModelUpgrade, +) + + +ORGANIZATIONS_DSAR_CAPABILITY = dsar_capability_name("organizations") +_MAX_RECORDS = 5_000 + + +@dataclass(frozen=True, slots=True) +class _SubjectSelectors: + account_id: str | None + instantiation_id: str | None + upgrade_id: str | None + + +class OrganizationsDsarProvider: + provider_id = "organizations" + module_id = "organizations" + + def search_subject( + self, + session: object, + *, + tenant_id: str, + subject: DsarSubjectRef, + ) -> Sequence[DsarRecordRef]: + db = _session(session) + selectors = _subject_selectors(subject) + if selectors is None or not selectors.account_id: + return () + + records: list[DsarRecordRef] = [] + + instantiation_query = db.query(OrganizationModelInstantiation).filter( + OrganizationModelInstantiation.tenant_id == tenant_id, + OrganizationModelInstantiation.instantiated_by_account_id + == selectors.account_id, + ) + if selectors.instantiation_id: + instantiation_query = instantiation_query.filter( + OrganizationModelInstantiation.id == selectors.instantiation_id + ) + for row in _bounded_rows( + instantiation_query.order_by(OrganizationModelInstantiation.id) + ): + records.append( + _record( + "organizations_model_instantiation", + row.id, + "organization_model_governance_evidence", + "Organization model instantiation attribution", + { + "match_fields": ["instantiated_by_account_id"], + "template_id": row.template_id, + "template_version_id": row.template_version_id, + "source_definition_sha256": row.source_definition_sha256, + "status": row.status, + }, + observed_at=row.updated_at, + retention_reason=( + "Model-instantiation attribution is retained to explain which " + "reviewed institutional model became tenant-owned." + ), + ) + ) + + upgrade_query = db.query(OrganizationModelUpgrade).filter( + OrganizationModelUpgrade.tenant_id == tenant_id, + or_( + OrganizationModelUpgrade.requested_by_account_id + == selectors.account_id, + OrganizationModelUpgrade.applied_by_account_id == selectors.account_id, + OrganizationModelUpgrade.cancelled_by_account_id + == selectors.account_id, + ), + ) + if selectors.upgrade_id: + upgrade_query = upgrade_query.filter( + OrganizationModelUpgrade.id == selectors.upgrade_id + ) + for row in _bounded_rows(upgrade_query.order_by(OrganizationModelUpgrade.id)): + records.append( + _record( + "organizations_model_upgrade", + row.id, + "organization_model_governance_evidence", + "Organization model upgrade attribution", + { + "match_fields": _actor_match_fields( + row, + selectors.account_id, + ( + "requested_by_account_id", + "applied_by_account_id", + "cancelled_by_account_id", + ), + ), + "template_id": row.template_id, + "source_instantiation_id": row.source_instantiation_id, + "source_template_version_id": (row.source_template_version_id), + "target_template_version_id": (row.target_template_version_id), + "status": row.status, + "revision": row.revision, + "base_definition_sha256": row.base_definition_sha256, + "local_definition_sha256": row.local_definition_sha256, + "target_definition_sha256": row.target_definition_sha256, + "applied_at": _iso(row.applied_at), + "cancelled_at": _iso(row.cancelled_at), + }, + observed_at=row.updated_at, + retention_reason=( + "Upgrade request, application, and cancellation attribution " + "is retained as organization-model change evidence." + ), + ) + ) + + if len(records) > _MAX_RECORDS: + raise ValueError( + "Organizations DSAR result limit exceeded; narrow the subject selectors." + ) + return tuple(records) + + def plan_erasure( + self, + session: object, + *, + tenant_id: str, + subject: DsarSubjectRef, + records: Sequence[DsarRecordRef], + ) -> Sequence[DsarErasureActionRef]: + del tenant_id + _session(session) + if _subject_selectors(subject) is None: + raise ValueError("Organizations DSAR subject selectors conflict.") + actions: list[DsarErasureActionRef] = [] + for record in records: + _validate_record(record) + actions.append( + DsarErasureActionRef( + action_id=( + f"organizations:retain:{record.resource_type}:" + f"{record.resource_id}" + ), + provider_id=self.provider_id, + module_id=self.module_id, + kind="retain", + resource_type=record.resource_type, + resource_id=record.resource_id, + title=f"Retain {record.title}", + rationale=record.retention_reason + or "Organization-model governance evidence must be retained.", + executable=False, + ) + ) + return tuple(actions) + + def execute_erasure( + self, + session: object, + *, + tenant_id: str, + subject: DsarSubjectRef, + actions: Sequence[DsarErasureActionRef], + request_id: str, + ) -> Sequence[DsarExecutionResultRef]: + del tenant_id + _session(session) + if _subject_selectors(subject) is None: + raise ValueError("Organizations DSAR subject selectors conflict.") + results: list[DsarExecutionResultRef] = [] + for action in actions: + _validate_action(action) + if action.executable: + raise ValueError( + "Organizations DSAR does not publish executable erasure actions." + ) + results.append( + DsarExecutionResultRef( + action_id=action.action_id, + status="blocked", + summary=( + "Organization-model change attribution is retained as " + "institutional governance evidence." + ), + evidence={"request_id": request_id}, + ) + ) + return tuple(results) + + +def _subject_selectors(subject: DsarSubjectRef) -> _SubjectSelectors | None: + account_ids = { + value + for item in ( + subject.account_id, + subject.external_references.get("organizations.account"), + subject.external_references.get("access.account"), + ) + if (value := _normalized_id(item)) + } + if len(account_ids) > 1: + return None + return _SubjectSelectors( + account_id=next(iter(account_ids), None), + instantiation_id=_normalized_id( + subject.external_references.get("organizations.model_instantiation") + ), + upgrade_id=_normalized_id( + subject.external_references.get("organizations.model_upgrade") + ), + ) + + +def _actor_match_fields( + row: object, + account_id: str, + fields: Sequence[str], +) -> list[str]: + return [field for field in fields if getattr(row, field, None) == account_id] + + +def _validate_record(record: DsarRecordRef) -> None: + if record.provider_id != "organizations" or record.module_id != "organizations": + raise ValueError("Organizations DSAR received a foreign provider record.") + + +def _validate_action(action: DsarErasureActionRef) -> None: + if action.provider_id != "organizations" or action.module_id != "organizations": + raise ValueError("Organizations DSAR received a foreign provider action.") + + +def _record( + resource_type: str, + resource_id: str, + category: str, + title: str, + data: Mapping[str, object], + *, + observed_at: datetime | None, + retention_reason: str, +) -> DsarRecordRef: + return DsarRecordRef( + provider_id="organizations", + module_id="organizations", + resource_type=resource_type, + resource_id=resource_id, + category=category, + title=title, + data=data, + observed_at=observed_at, + immutable_evidence=True, + retention_reason=retention_reason, + source_path="/admin?section=tenant-organization-settings", + ) + + +def _session(value: object) -> Session: + if not isinstance(value, Session): + raise TypeError("Organizations DSAR provider requires a SQLAlchemy session.") + return value + + +def _bounded_rows(query: object) -> list[object]: + rows = query.limit(_MAX_RECORDS + 1).all() # type: ignore[attr-defined] + if len(rows) > _MAX_RECORDS: + raise ValueError( + "Organizations DSAR match limit exceeded; narrow the subject selectors." + ) + return rows + + +def _normalized_id(value: object) -> str | None: + if value is None: + return None + value = str(value).strip() + return value or None + + +def _iso(value: datetime | None) -> str | None: + if value is None: + return None + if value.tzinfo is None: + value = value.replace(tzinfo=timezone.utc) + return value.isoformat() + + +__all__ = ["ORGANIZATIONS_DSAR_CAPABILITY", "OrganizationsDsarProvider"] diff --git a/src/govoplan_organizations/backend/manifest.py b/src/govoplan_organizations/backend/manifest.py index e0a9a92..9467475 100644 --- a/src/govoplan_organizations/backend/manifest.py +++ b/src/govoplan_organizations/backend/manifest.py @@ -8,6 +8,7 @@ from govoplan_core.core.access import ( ) from govoplan_core.core.module_guards import persistent_table_uninstall_guard from govoplan_core.core.modules import ( + CapabilityDocumentation, DocumentationLink, DocumentationTopic, FrontendModule, @@ -29,6 +30,7 @@ from govoplan_core.core.organizations import ( from govoplan_core.core.views import ViewSurface from govoplan_core.db.base import Base from govoplan_organizations.backend.db import models as organization_models # noqa: F401 - populate metadata +from govoplan_organizations.backend.dsar_provider import ORGANIZATIONS_DSAR_CAPABILITY ORGANIZATIONS_READ_SCOPES = ( @@ -131,6 +133,13 @@ def _organization_directory(context: ModuleContext) -> object: return SqlOrganizationDirectory() +def _organizations_dsar_provider(context: ModuleContext) -> object: + del context + from govoplan_organizations.backend.dsar_provider import OrganizationsDsarProvider + + return OrganizationsDsarProvider() + + manifest = ModuleManifest( id="organizations", name="Organizations", @@ -149,6 +158,10 @@ manifest = ModuleManifest( name="organizations.hierarchy_directory", version="0.1.0", ), + ModuleInterfaceProvider( + name=ORGANIZATIONS_DSAR_CAPABILITY, + version="0.1.0", + ), ), permissions=PERMISSIONS, role_templates=ROLE_TEMPLATES, @@ -236,8 +249,72 @@ manifest = ModuleManifest( capability_factories={ CAPABILITY_ORGANIZATION_DIRECTORY: _organization_directory, CAPABILITY_ORGANIZATION_HIERARCHY_DIRECTORY: (_organization_directory), + ORGANIZATIONS_DSAR_CAPABILITY: _organizations_dsar_provider, + }, + capability_documentation={ + ORGANIZATIONS_DSAR_CAPABILITY: CapabilityDocumentation( + label="Organizations data-subject request provider", + summary=( + "Finds tenant-scoped account attribution on organization-model " + "instantiations and upgrades without exporting opaque model payloads." + ), + contract_version="0.1.0", + documentation_types=("admin",), + audience=("privacy_officer", "organization_admin", "records_manager"), + ), }, documentation=( + DocumentationTopic( + id="organizations.privacy.data-subject-requests", + title="Review Organizations data in a data-subject request", + summary=( + "Collect tenant-scoped organization-model change attribution while " + "preserving institutional governance evidence." + ), + body=( + "Organizations stores institutional units, structures, relations, and " + "functions rather than personal incumbency. IDM owns the links between " + "identities and functions and must answer for those assignments. The " + "Organizations DSAR provider therefore searches only corroborated account " + "attribution on tenant model instantiations and upgrade requests, " + "applications, or cancellations. It exports the affected template and " + "version references, status, revision, timestamps, and integrity hashes. " + "Global template authorship, unrelated institutional model objects, " + "template definitions, upgrade previews and decisions, provenance, " + "idempotency keys, request digests, opaque settings, and other tenants are " + "excluded. The attribution is retained as institutional model-change " + "evidence and the provider publishes no automatic erasure action." + ), + layer="static", + documentation_types=("admin",), + audience=( + "privacy_officer", + "organization_admin", + "records_manager", + "operator", + ), + related_modules=("access", "audit", "idm", "records"), + links=( + DocumentationLink( + label="Data-subject requests", + href="/admin?section=tenant-data-subject-requests", + kind="runtime", + ), + DocumentationLink( + label="Organizations administration", + href="/admin?section=tenant-organization-settings", + kind="runtime", + ), + ), + metadata={ + "kind": "guide", + "help_contexts": [ + "organizations.admin.tenant", + "organizations.admin.template-upgrades", + ], + }, + order=24, + ), DocumentationTopic( id="organizations.template-upgrades", title="Upgrade a tenant organization model", diff --git a/tests/test_dsar_provider.py b/tests/test_dsar_provider.py new file mode 100644 index 0000000..3310659 --- /dev/null +++ b/tests/test_dsar_provider.py @@ -0,0 +1,394 @@ +from __future__ import annotations + +import unittest +from datetime import datetime, timezone + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from govoplan_core.core.dsar import ( + DsarErasureActionRef, + DsarProvider, + DsarSubjectRef, +) +from govoplan_core.db.base import Base +from govoplan_core.privacy.dsar_workflow import ( + create_data_subject_request, + search_data_subject_request, +) +from govoplan_organizations.backend.db.models import ( + OrganizationModelInstantiation, + OrganizationModelTemplate, + OrganizationModelTemplateVersion, + OrganizationModelUpgrade, +) +from govoplan_organizations.backend.dsar_provider import ( + ORGANIZATIONS_DSAR_CAPABILITY, + OrganizationsDsarProvider, +) +from govoplan_organizations.backend.manifest import manifest + + +class _Registry: + def __init__( + self, + provider: OrganizationsDsarProvider, + *, + organizations_active: bool = True, + ) -> None: + self.provider = provider + self.organizations_active = organizations_active + + def capability_names(self): + return (ORGANIZATIONS_DSAR_CAPABILITY,) + + def capability_owner(self, name): + self._assert_capability(name) + return "organizations" + + def tenant_entitlement_resolver(self): + organizations_active = self.organizations_active + + class _Resolver: + @staticmethod + def resolve(session, tenant_id): + del session, tenant_id + return type( + "State", + (), + { + "effective_modules": ( + ("organizations",) if organizations_active else () + ) + }, + )() + + return _Resolver() + + def require_tenant_capability(self, name, session, **kwargs): + del session, kwargs + self._assert_capability(name) + return self.provider + + def manifests(self): + return (type("Manifest", (), {"id": "organizations"})(),) + + @staticmethod + def _assert_capability(name: str) -> None: + if name != ORGANIZATIONS_DSAR_CAPABILITY: + raise KeyError(name) + + +class OrganizationsDsarProviderTests(unittest.TestCase): + def setUp(self) -> None: + self.engine = create_engine("sqlite:///:memory:", future=True) + Base.metadata.create_all(bind=self.engine) + self.session = sessionmaker(bind=self.engine, future=True)() + now = datetime.now(timezone.utc) + + self.template = OrganizationModelTemplate( + id="template-1", + slug="municipality", + name="Municipality", + description="Global institutional template", + created_by_account_id="account-1", + settings={"secret": "global-template-settings-do-not-export"}, + ) + self.version_one = OrganizationModelTemplateVersion( + id="version-1", + template_id=self.template.id, + version="1.0.0", + status="published", + definition={"secret": "definition-one-do-not-export"}, + definition_sha256="a" * 64, + published_at=now, + published_by_account_id="account-1", + ) + self.version_two = OrganizationModelTemplateVersion( + id="version-2", + template_id=self.template.id, + version="2.0.0", + status="published", + definition={"secret": "definition-two-do-not-export"}, + definition_sha256="b" * 64, + published_at=now, + published_by_account_id="account-1", + ) + self.version_three = OrganizationModelTemplateVersion( + id="version-3", + template_id=self.template.id, + version="3.0.0", + status="published", + definition={"secret": "definition-three-do-not-export"}, + definition_sha256="c" * 64, + ) + self.instantiation = OrganizationModelInstantiation( + id="instantiation-1", + tenant_id="tenant-1", + template_id=self.template.id, + template_version_id=self.version_one.id, + source_definition_sha256=self.version_one.definition_sha256, + status="superseded", + instantiated_by_account_id="account-1", + object_counts={"units": 5}, + provenance={"secret": "instantiation-provenance-do-not-export"}, + ) + unrelated_instantiation = OrganizationModelInstantiation( + id="instantiation-unrelated", + tenant_id="tenant-1", + template_id=self.template.id, + template_version_id=self.version_three.id, + source_definition_sha256=self.version_three.definition_sha256, + status="applied", + instantiated_by_account_id="account-other", + object_counts={"units": 9}, + provenance={"secret": "unrelated-provenance-do-not-export"}, + ) + tenant_two_instantiation = OrganizationModelInstantiation( + id="instantiation-tenant-2", + tenant_id="tenant-2", + template_id=self.template.id, + template_version_id=self.version_one.id, + source_definition_sha256=self.version_one.definition_sha256, + status="applied", + instantiated_by_account_id="account-1", + object_counts={"units": 99}, + provenance={"secret": "other-tenant-provenance-do-not-export"}, + ) + self.upgrade = OrganizationModelUpgrade( + id="upgrade-1", + tenant_id="tenant-1", + template_id=self.template.id, + source_instantiation_id=self.instantiation.id, + source_template_version_id=self.version_one.id, + target_template_version_id=self.version_two.id, + status="applied", + revision=2, + base_definition_sha256="d" * 64, + local_definition_sha256="e" * 64, + target_definition_sha256="f" * 64, + preview={"secret": "upgrade-preview-do-not-export"}, + decisions={"secret": "upgrade-decisions-do-not-export"}, + idempotency_key="idempotency-key-do-not-export", + request_digest="1" * 64, + requested_by_account_id="account-1", + applied_by_account_id="account-other", + applied_at=now, + provenance={"secret": "upgrade-provenance-do-not-export"}, + ) + unrelated_upgrade = OrganizationModelUpgrade( + id="upgrade-unrelated", + tenant_id="tenant-1", + template_id=self.template.id, + source_instantiation_id=unrelated_instantiation.id, + source_template_version_id=self.version_three.id, + target_template_version_id=self.version_two.id, + status="cancelled", + base_definition_sha256="2" * 64, + local_definition_sha256="3" * 64, + target_definition_sha256="4" * 64, + idempotency_key="unrelated-key", + request_digest="5" * 64, + requested_by_account_id="account-other", + cancelled_by_account_id="account-other", + ) + tenant_two_upgrade = OrganizationModelUpgrade( + id="upgrade-tenant-2", + tenant_id="tenant-2", + template_id=self.template.id, + source_instantiation_id=tenant_two_instantiation.id, + source_template_version_id=self.version_one.id, + target_template_version_id=self.version_two.id, + status="previewed", + base_definition_sha256="6" * 64, + local_definition_sha256="7" * 64, + target_definition_sha256="8" * 64, + idempotency_key="tenant-two-key", + request_digest="9" * 64, + requested_by_account_id="account-1", + ) + self.session.add_all( + [ + self.template, + self.version_one, + self.version_two, + self.version_three, + self.instantiation, + unrelated_instantiation, + tenant_two_instantiation, + self.upgrade, + unrelated_upgrade, + tenant_two_upgrade, + ] + ) + self.session.commit() + self.provider = OrganizationsDsarProvider() + self.subject = DsarSubjectRef(account_id="account-1") + + def tearDown(self) -> None: + self.session.close() + self.engine.dispose() + + def test_manifest_publishes_protocol_conforming_provider(self) -> None: + provided = {item.name for item in manifest.provides_interfaces} + self.assertIn(ORGANIZATIONS_DSAR_CAPABILITY, provided) + provider = manifest.capability_factories[ORGANIZATIONS_DSAR_CAPABILITY](None) + self.assertIsInstance(provider, DsarProvider) + + def test_search_is_tenant_scoped_narrow_and_minimized(self) -> None: + records = self.provider.search_subject( + self.session, + tenant_id="tenant-1", + subject=self.subject, + ) + + self.assertEqual( + { + "organizations_model_instantiation", + "organizations_model_upgrade", + }, + {record.resource_type for record in records}, + ) + serialized = repr([record.to_dict() for record in records]) + self.assertIn("instantiation-1", serialized) + self.assertIn("upgrade-1", serialized) + excluded = ( + "global-template-settings-do-not-export", + "definition-one-do-not-export", + "instantiation-provenance-do-not-export", + "upgrade-preview-do-not-export", + "upgrade-decisions-do-not-export", + "idempotency-key-do-not-export", + "upgrade-provenance-do-not-export", + "instantiation-unrelated", + "upgrade-unrelated", + "instantiation-tenant-2", + "upgrade-tenant-2", + "other-tenant-provenance-do-not-export", + ) + for value in excluded: + self.assertNotIn(value, serialized) + + def test_conflicting_account_selectors_fail_closed(self) -> None: + records = self.provider.search_subject( + self.session, + tenant_id="tenant-1", + subject=DsarSubjectRef( + account_id="account-1", + external_references={"organizations.account": "account-other"}, + ), + ) + + self.assertEqual((), records) + + def test_plan_retains_governance_evidence_and_execution_is_non_mutating( + self, + ) -> None: + records = self.provider.search_subject( + self.session, + tenant_id="tenant-1", + subject=self.subject, + ) + actions = self.provider.plan_erasure( + self.session, + tenant_id="tenant-1", + subject=self.subject, + records=records, + ) + + self.assertEqual({"retain"}, {action.kind for action in actions}) + self.assertFalse(any(action.executable for action in actions)) + results = self.provider.execute_erasure( + self.session, + tenant_id="tenant-1", + subject=self.subject, + actions=actions, + request_id="dsar-organizations-1", + ) + self.assertEqual({"blocked"}, {result.status for result in results}) + self.assertIsNotNone( + self.session.get(OrganizationModelUpgrade, self.upgrade.id) + ) + + def test_execution_rejects_foreign_and_forged_executable_actions(self) -> None: + actions = ( + DsarErasureActionRef( + action_id="idm:delete:upgrade:upgrade-1", + provider_id="idm", + module_id="idm", + kind="delete", + resource_type="organizations_model_upgrade", + resource_id=self.upgrade.id, + title="Foreign action", + rationale="Must be rejected", + executable=True, + ), + DsarErasureActionRef( + action_id="organizations:delete:upgrade:upgrade-1", + provider_id="organizations", + module_id="organizations", + kind="delete", + resource_type="organizations_model_upgrade", + resource_id=self.upgrade.id, + title="Forged action", + rationale="Must be rejected", + executable=True, + ), + ) + for action in actions: + with self.assertRaises(ValueError): + self.provider.execute_erasure( + self.session, + tenant_id="tenant-1", + subject=self.subject, + actions=(action,), + request_id="dsar-organizations-2", + ) + + def test_core_workflow_discovers_active_and_inactive_provider(self) -> None: + request = create_data_subject_request( + self.session, + tenant_id="tenant-1", + reference="DSAR-ORGANIZATIONS-1", + request_kind="access", + subject=self.subject, + purpose="Respond to an authorized privacy request.", + legal_basis="Article 15 GDPR", + due_at=None, + requested_by_account_id="privacy-officer", + ) + self.session.commit() + search_data_subject_request( + self.session, + registry=_Registry(self.provider), + row=request, + expected_revision=1, + ) + self.assertEqual(["organizations"], request.coverage["covered_modules"]) + + disabled = create_data_subject_request( + self.session, + tenant_id="tenant-1", + reference="DSAR-ORGANIZATIONS-DISABLED", + request_kind="access", + subject=self.subject, + purpose="Verify disabled-module coverage.", + legal_basis="Article 15 GDPR", + due_at=None, + requested_by_account_id="privacy-officer", + ) + search_data_subject_request( + self.session, + registry=_Registry(self.provider, organizations_active=False), + row=disabled, + expected_revision=1, + ) + self.assertEqual(0, disabled.search_result["record_count"]) + self.assertEqual( + [ORGANIZATIONS_DSAR_CAPABILITY], + disabled.coverage["inactive_provider_capabilities"], + ) + + +if __name__ == "__main__": + unittest.main()