Add app credentials, account-scoped idempotency and per-app terminal notifications. Consolidate the unreleased migration baseline, preserve rule descriptions as AI context, and regenerate provider contracts with regression coverage.
118 lines
4.3 KiB
Python
118 lines
4.3 KiB
Python
from dataclasses import replace
|
|
from datetime import UTC, datetime
|
|
|
|
import pytest
|
|
|
|
from ai_platform.platform.application.app_credentials import (
|
|
AppCredentialConflict,
|
|
AppCredentialNotFound,
|
|
AppCredentialRecord,
|
|
AppCredentialUseCases,
|
|
AppFeatureForbidden,
|
|
)
|
|
from ai_platform.platform.security import digest
|
|
|
|
|
|
class Repository:
|
|
def __init__(self):
|
|
self.record = AppCredentialRecord(
|
|
"omni-local", True, True, datetime.now(UTC), datetime.now(UTC)
|
|
)
|
|
self.verifier = digest("initial-key")
|
|
self.events = []
|
|
self.error: Exception | None = None
|
|
|
|
async def list(self):
|
|
return [self.record]
|
|
|
|
async def active_app_for_verifier(self, verifier):
|
|
return self.record if self.record.active and verifier == self.verifier else None
|
|
|
|
async def create(self, app_id, verifier, lead_scoring, actor):
|
|
if self.error:
|
|
raise self.error
|
|
self.record = replace(self.record, app_id=app_id, lead_scoring=lead_scoring)
|
|
self.verifier = verifier
|
|
self.events.append(("create", actor))
|
|
|
|
async def rotate(self, app_id, verifier, actor):
|
|
if self.error:
|
|
raise self.error
|
|
self.verifier = verifier
|
|
self.record = replace(self.record, active=True)
|
|
self.events.append(("rotate", actor))
|
|
|
|
async def revoke(self, app_id, actor):
|
|
if self.error:
|
|
raise self.error
|
|
self.record = replace(self.record, active=False)
|
|
self.events.append(("revoke", actor))
|
|
return self.record
|
|
|
|
|
|
class Transaction:
|
|
def __init__(self):
|
|
self.commits = 0
|
|
self.rollbacks = 0
|
|
self.error: Exception | None = None
|
|
|
|
async def commit(self):
|
|
self.commits += 1
|
|
if self.error:
|
|
raise self.error
|
|
|
|
async def rollback(self):
|
|
self.rollbacks += 1
|
|
|
|
|
|
async def test_lifecycle_preserves_identity_and_stores_only_verifier():
|
|
repo, tx = Repository(), Transaction()
|
|
keys = iter(["created-key", "rotated-key", "reactivated-key"])
|
|
cases = AppCredentialUseCases(repo, tx.commit, tx.rollback, lambda: next(keys))
|
|
assert await cases.create("omni-local", "operator") == "created-key"
|
|
assert repo.verifier == digest("created-key")
|
|
assert await cases.authenticate("created-key") == "omni-local"
|
|
assert await cases.rotate("omni-local", "operator") == "rotated-key"
|
|
assert await cases.authenticate("created-key") is None
|
|
assert await cases.authenticate("rotated-key") == "omni-local"
|
|
assert not (await cases.revoke("omni-local", "operator")).active
|
|
assert await cases.authenticate("rotated-key") is None
|
|
await cases.rotate("omni-local", "operator")
|
|
assert await cases.authenticate("reactivated-key") == "omni-local"
|
|
assert tx.commits == 4 and tx.rollbacks == 0
|
|
assert repo.events == [(name, "operator") for name in ("create", "rotate", "revoke", "rotate")]
|
|
|
|
|
|
async def test_valid_app_without_feature_permission_is_forbidden():
|
|
repo, tx = Repository(), Transaction()
|
|
repo.record = replace(repo.record, lead_scoring=False)
|
|
cases = AppCredentialUseCases(repo, tx.commit, tx.rollback, lambda: "key")
|
|
with pytest.raises(AppFeatureForbidden):
|
|
await cases.authenticate("initial-key")
|
|
assert await cases.authenticate("incorrect-key") is None
|
|
await cases.rotate("omni-local", "operator")
|
|
assert repo.record.lead_scoring is False
|
|
assert await cases.list_apps() == [repo.record]
|
|
|
|
|
|
@pytest.mark.parametrize("operation", ["create", "rotate", "revoke"])
|
|
@pytest.mark.parametrize(
|
|
"error", [AppCredentialConflict(), AppCredentialNotFound(), RuntimeError("storage")]
|
|
)
|
|
async def test_repository_error_rolls_back(operation, error):
|
|
repo, tx = Repository(), Transaction()
|
|
repo.error = error
|
|
cases = AppCredentialUseCases(repo, tx.commit, tx.rollback, lambda: "key")
|
|
with pytest.raises(type(error)):
|
|
await getattr(cases, operation)("omni-local", "operator")
|
|
assert tx.commits == 0 and tx.rollbacks == 1
|
|
|
|
|
|
async def test_failed_commit_rolls_back():
|
|
repo, tx = Repository(), Transaction()
|
|
tx.error = RuntimeError("commit failed")
|
|
cases = AppCredentialUseCases(repo, tx.commit, tx.rollback, lambda: "key")
|
|
with pytest.raises(RuntimeError, match="commit failed"):
|
|
await cases.rotate("omni-local", "operator")
|
|
assert tx.commits == 1 and tx.rollbacks == 1
|