from __future__ import annotations import json import unittest from fastapi import HTTPException from sqlalchemy import create_engine from sqlalchemy.orm import Session from govoplan_core.auth import ApiPrincipal from govoplan_core.core.access import PrincipalRef from govoplan_core.core.change_sequence import ChangeSequenceEntry from govoplan_core.security.credential_envelopes import ( CredentialAccessContext, CredentialEnvelope, CredentialEnvelopeError, create_credential_envelope, credential_envelope_summary, credential_visible_to_context, list_managed_credential_envelopes, resolve_credential_envelope, update_credential_envelope, ) from govoplan_core.security.secrets import encrypt_secret from govoplan_core.server.credentials import _target_scope def _principal(scopes: set[str]) -> ApiPrincipal: return ApiPrincipal( principal=PrincipalRef( account_id="account-1", membership_id="user-1", tenant_id="tenant-1", scopes=frozenset(scopes), ), account=object(), user=object(), ) class CredentialEnvelopeTests(unittest.TestCase): def setUp(self) -> None: self.engine = create_engine("sqlite+pysqlite:///:memory:") ChangeSequenceEntry.__table__.create(self.engine) CredentialEnvelope.__table__.create(self.engine) def tearDown(self) -> None: self.engine.dispose() def test_inherited_credential_is_filtered_by_module_and_server(self) -> None: row = CredentialEnvelope( id="credential-1", tenant_id="tenant-1", scope_type="tenant", scope_id="tenant-1", name="Shared account", credential_kind="username_password", public_data={"username": "service@example.org"}, secret_data_encrypted=encrypt_secret(json.dumps({"password": "not-returned"})), secret_keys=["password"], allowed_modules=["mail"], allowed_server_refs=["mail:server-1"], inherit_to_lower_scopes=True, is_active=True, revision="revision-1", ) allowed = CredentialAccessContext( tenant_id="tenant-1", user_id="user-1", target_scope_type="user", target_scope_id="user-1", module_id="mail", server_ref="mail:server-1", ) wrong_module = CredentialAccessContext( tenant_id="tenant-1", user_id="user-1", target_scope_type="user", target_scope_id="user-1", module_id="calendar", server_ref="mail:server-1", ) wrong_server = CredentialAccessContext( tenant_id="tenant-1", user_id="user-1", target_scope_type="user", target_scope_id="user-1", module_id="mail", server_ref="mail:server-2", ) self.assertTrue(credential_visible_to_context(row, allowed)) self.assertFalse(credential_visible_to_context(row, wrong_module)) self.assertFalse(credential_visible_to_context(row, wrong_server)) self.assertNotIn("secret_data_encrypted", credential_envelope_summary(row)) def test_resolution_returns_secret_only_after_access_check(self) -> None: with Session(self.engine) as session: session.add( CredentialEnvelope( id="credential-1", tenant_id="tenant-1", scope_type="tenant", scope_id="tenant-1", name="Shared account", credential_kind="username_password", public_data={"username": "service@example.org"}, secret_data_encrypted=encrypt_secret(json.dumps({"password": "secret"})), secret_keys=["password"], allowed_modules=["mail"], allowed_server_refs=[], inherit_to_lower_scopes=True, is_active=True, revision="revision-1", ) ) session.commit() resolved = resolve_credential_envelope( session, credential_id="credential-1", context=CredentialAccessContext( tenant_id="tenant-1", user_id="user-1", target_scope_type="user", target_scope_id="user-1", module_id="mail", ), ) self.assertEqual(resolved.public_data["username"], "service@example.org") self.assertEqual(resolved.secret_data["password"], "secret") with self.assertRaises(CredentialEnvelopeError): resolve_credential_envelope( session, credential_id="credential-1", context=CredentialAccessContext( tenant_id="tenant-2", user_id="user-2", target_scope_type="user", target_scope_id="user-2", module_id="mail", ), ) def test_management_listing_stays_in_tenant_but_ignores_use_site_limits(self) -> None: with Session(self.engine) as session: session.add_all( [ CredentialEnvelope( id="tenant-credential", tenant_id="tenant-1", scope_type="tenant", scope_id="tenant-1", name="Tenant restricted", credential_kind="username_password", public_data={}, secret_keys=[], allowed_modules=["calendar"], allowed_server_refs=["calendar:source-1"], inherit_to_lower_scopes=True, is_active=True, revision="revision-1", ), CredentialEnvelope( id="other-credential", tenant_id="tenant-2", scope_type="tenant", scope_id="tenant-2", name="Other tenant", credential_kind="username_password", public_data={}, secret_keys=[], allowed_modules=[], allowed_server_refs=[], inherit_to_lower_scopes=False, is_active=True, revision="revision-2", ), ] ) session.commit() rows = list_managed_credential_envelopes( session, tenant_id="tenant-1", ) self.assertEqual([row.id for row in rows], ["tenant-credential"]) def test_tenant_credential_permission_cannot_manage_system_scope(self) -> None: tenant_principal = _principal({"access:credential:write"}) with self.assertRaises(HTTPException) as raised: _target_scope( tenant_principal, scope_type="system", scope_id=None, write=True, ) self.assertEqual(raised.exception.status_code, 403) system_principal = _principal({"access:system_credential:write"}) self.assertEqual( _target_scope( system_principal, scope_type="system", scope_id=None, write=True, ), (None, "system", None), ) def test_public_data_rejects_secrets_and_kind_changes_require_secret_decision(self) -> None: with Session(self.engine) as session: with self.assertRaisesRegex(CredentialEnvelopeError, "public_data"): create_credential_envelope( session, tenant_id="tenant-1", scope_type="tenant", scope_id="tenant-1", name="Unsafe", credential_kind="username_password", public_data={"nested": [{"clientSecret": "not-public"}]}, ) row = create_credential_envelope( session, tenant_id="tenant-1", scope_type="tenant", scope_id="tenant-1", name="Safe", credential_kind="username_password", public_data={"username": "service@example.org"}, secret_data={"password": "secret"}, ) with self.assertRaisesRegex(CredentialEnvelopeError, "requires replacing"): update_credential_envelope( session, row, credential_kind="token", ) if __name__ == "__main__": unittest.main()