81 lines
3.5 KiB
Python
81 lines
3.5 KiB
Python
"""Executable completeness contract for scoped secret references."""
|
|
from __future__ import annotations
|
|
from dataclasses import fields
|
|
import pytest
|
|
from aios_v2r1.secret_scope import SecretRef
|
|
from aios_v2r1.tenant_context import ScopeError, ValidationError
|
|
|
|
def secret(**changes: object) -> SecretRef:
|
|
values: dict[str, object] = {
|
|
"secret_id": "channel-key", "tenant_id": "tenant-a", "cell_id": "cell-a",
|
|
"kind": "channel", "version": "v1",
|
|
"broker_path": "secret://tenant-a/cell-a/channel-key/v1"}
|
|
values.update(changes)
|
|
return SecretRef(**values) # type: ignore[arg-type]
|
|
|
|
@pytest.mark.parametrize("path", [
|
|
"secret://tenant-a/cell-a/channel-key/v1",
|
|
"secret://models/provider/key-v2",
|
|
"secret://runtime/cell-a/signing_key"])
|
|
def test_valid_broker_paths_are_references(path: str) -> None:
|
|
assert secret(broker_path=path).broker_path == path
|
|
|
|
@pytest.mark.parametrize("path", [
|
|
"", "vault://tenant-a/key", "secret://", "secret:///key",
|
|
"secret://tenant-a/../tenant-b/key", "secret://tenant-a//key",
|
|
"secret://tenant-a/key?version=1", "secret://tenant-a/key#fragment"])
|
|
def test_broker_path_traversal_and_malformed_paths_are_rejected(path: str) -> None:
|
|
with pytest.raises(ValidationError, match="broker path"):
|
|
secret(broker_path=path)
|
|
|
|
@pytest.mark.parametrize(("tenant_id", "cell_id", "allowed"), [
|
|
("tenant-a", "cell-a", True), ("tenant-b", "cell-a", False),
|
|
("tenant-a", "cell-b", False)])
|
|
def test_secret_scope_is_exact(tenant_id: str, cell_id: str, allowed: bool) -> None:
|
|
assert secret().can_be_used_by(tenant_id=tenant_id, cell_id=cell_id) is allowed
|
|
|
|
@pytest.mark.parametrize("kind", ["channel", "model", "tool", "runtime"])
|
|
def test_secret_kind_allowlist(kind: str) -> None:
|
|
assert secret(kind=kind).kind == kind
|
|
|
|
def test_invalid_secret_kind_is_rejected() -> None:
|
|
with pytest.raises(ValidationError, match="kind"):
|
|
secret(kind="admin")
|
|
|
|
@pytest.mark.parametrize("changes", [
|
|
{"secret_id": "../key"}, {"secret_id": " key "},
|
|
{"version": "../v2"}, {"version": ""}, {"tenant_id": "Tenant-A"},
|
|
{"cell_id": "cell/a"}])
|
|
def test_secret_identifiers_and_version_are_validated(changes: dict[str, str]) -> None:
|
|
with pytest.raises(ValidationError):
|
|
secret(**changes)
|
|
|
|
def test_reference_never_contains_a_secret_value_field() -> None:
|
|
assert "value" not in {field.name for field in fields(SecretRef)}
|
|
assert "super-secret-value" not in repr(secret())
|
|
|
|
def test_broker_resolution_uses_only_the_validated_reference() -> None:
|
|
seen: list[SecretRef] = []
|
|
class Broker:
|
|
def read_secret(self, reference: SecretRef) -> str:
|
|
seen.append(reference)
|
|
return "resolved-value"
|
|
reference = secret()
|
|
assert reference.resolve(Broker()) == "resolved-value"
|
|
assert seen == [reference]
|
|
|
|
@pytest.mark.parametrize("broker", [None, object()])
|
|
def test_missing_or_invalid_broker_fails_closed(broker: object | None) -> None:
|
|
with pytest.raises(ScopeError, match="secret broker unavailable"):
|
|
secret().resolve(broker)
|
|
|
|
def test_broker_error_is_sanitized_without_secret_leakage() -> None:
|
|
class BrokenBroker:
|
|
def read_secret(self, reference: SecretRef) -> str:
|
|
raise RuntimeError("provider leaked super-secret-value")
|
|
with pytest.raises(ScopeError, match="secret broker unavailable") as raised:
|
|
secret().resolve(BrokenBroker())
|
|
assert "super-secret-value" not in str(raised.value)
|
|
assert raised.value.__cause__ is None
|
|
assert raised.value.__context__ is None
|