Complete Phase 1: policy kernel, REST service, and local smoke tooling

Implements QONTO-WP-0002 (policy-gated Qonto REST service with audit
logging, rate limiting, and credential handling) and the ADHOC-2026-07-21
follow-up (fixture-backed local smoke mode, repo classification metadata).
Marks QONTO-WP-0001/0002 and the ad-hoc workplan finished, and regenerates
WORK-RECORDS.md and the ADHOC workplan's state_hub_workstream_id via
fix-consistency.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-07-22 21:21:05 +02:00
parent eef408bb19
commit ca12843013
33 changed files with 2533 additions and 30 deletions

31
tests/fixtures/qonto/organization.json vendored Normal file
View file

@ -0,0 +1,31 @@
{
"organization": {
"name": "Binky Hedgehog GmbH",
"legal_name": "Binky Hedgehog GmbH",
"slug": "binky-hedgehog-gmbh-6923",
"legal_country": "DE",
"legal_registration_date": "2019-03-15",
"bank_accounts": [
{
"name": "Hauptkonto",
"slug": "main-account",
"currency": "EUR",
"balance": 2185.94,
"authorized_balance": 2185.94,
"iban": "DE02100100101234566810",
"main": true,
"status": "active"
},
{
"name": "Kickstart Business",
"slug": "secondary-account",
"currency": "EUR",
"balance": 0.0,
"authorized_balance": 0.0,
"iban": "DE02100100101234567038",
"main": false,
"status": "active"
}
]
}
}

64
tests/fixtures/qonto/transactions.json vendored Normal file
View file

@ -0,0 +1,64 @@
{
"transactions": [
{
"id": "tx-qonto-2026-07",
"settled_at": "2026-07-01T08:00:00Z",
"label": "Qonto",
"side": "debit",
"amount": 70.8,
"currency": "EUR",
"category": "subscription",
"operation_type": "qonto_fee",
"status": "completed",
"iban": "DE02100100101234566810"
},
{
"id": "tx-hub31-2026-06",
"settled_at": "2026-06-02T08:00:00Z",
"label": "HUB31",
"side": "debit",
"amount": 297.5,
"currency": "EUR",
"category": "other_expense",
"operation_type": "transfer",
"status": "completed",
"iban": "DE02100100101234566810"
},
{
"id": "tx-hub31-2026-05",
"settled_at": "2026-05-02T08:00:00Z",
"label": "HUB31",
"side": "debit",
"amount": 297.5,
"currency": "EUR",
"category": "other_expense",
"operation_type": "transfer",
"status": "completed",
"iban": "DE02100100101234566810"
},
{
"id": "tx-stripe-2026-06",
"settled_at": "2026-06-29T08:00:00Z",
"label": "Stripe Technology Europe Ltd",
"side": "credit",
"amount": 8.55,
"currency": "EUR",
"category": "other_income",
"operation_type": "income",
"status": "completed",
"iban": "DE02100100101234566810"
},
{
"id": "tx-old-window",
"settled_at": "2026-02-01T08:00:00Z",
"label": "Old Expense",
"side": "debit",
"amount": 12.34,
"currency": "EUR",
"category": "other_expense",
"operation_type": "income",
"status": "completed",
"iban": "DE02100100101234566810"
}
]
}

213
tests/test_api.py Normal file
View file

@ -0,0 +1,213 @@
from pathlib import Path
import httpx
from qonto_assistant.audit import AuditLogger
from qonto_assistant.config import Settings
from qonto_assistant.contracts import ActorClaims
from qonto_assistant.credentials import EnvironmentCredentialProvider
from qonto_assistant.errors import PolicyDeniedError
from qonto_assistant.policy import PolicyEngine
from qonto_assistant.qonto_client import QontoClient
from qonto_assistant.rate_limits import ConcurrencyLimiter, RateLimiter
from qonto_assistant.service import CapabilityService
POLICY_FILE = Path(__file__).resolve().parents[1] / "src" / "qonto_assistant" / "policy" / "qonto-v1.yaml"
def _settings() -> Settings:
return Settings(
service_name="qonto-assistant",
default_tenant_id="binky",
default_actor_lane="green",
required_scope="finance.qonto.read",
enforce_scope=False,
policy_file=POLICY_FILE,
qonto_base_url="https://example.test",
qonto_fixture_dir=None,
qonto_auth_mode="legacy_api_key",
qonto_organization_path="/v2/organization",
qonto_transactions_path="/v2/transactions",
qonto_timeout_seconds=1,
qonto_max_retries=0,
qonto_secret_ttl_seconds=60,
rate_limit_requests=20,
rate_limit_window_seconds=60,
max_concurrency=4,
credential_source="env",
openbao_path="tenants/binky/qonto-api",
openbao_command="bao",
openbao_timeout_seconds=5,
host="127.0.0.1",
port=8080,
)
def _claims() -> ActorClaims:
return ActorClaims(actor_id="codex", tenant_id="binky", lane="green")
def _service(monkeypatch) -> tuple[CapabilityService, list[dict[str, object]]]:
monkeypatch.setenv("API_USER", "binky-user")
monkeypatch.setenv("API_KEY", "top-secret")
events: list[dict[str, object]] = []
settings = _settings()
organization_payload = {
"organization": {
"name": "Binky Hedgehog GmbH",
"legal_name": "Binky Hedgehog GmbH",
"slug": "binky-hedgehog-gmbh-6923",
"legal_country": "DE",
"legal_registration_date": "2019-03-15",
"bank_accounts": [
{
"name": "Hauptkonto",
"slug": "main-account",
"currency": "EUR",
"balance": 2185.94,
"authorized_balance": 2185.94,
"iban": "DE02100100101234566810",
"main": True,
"status": "active",
},
{
"name": "Kickstart Business",
"slug": "secondary-account",
"currency": "EUR",
"balance": 0,
"authorized_balance": 0,
"iban": "DE02100100101234567038",
"main": False,
"status": "active",
},
],
}
}
transactions_payload = {
"transactions": [
{
"id": "tx-qonto",
"settled_at": "2026-07-01T08:00:00Z",
"label": "Qonto",
"side": "debit",
"amount": 70.8,
"currency": "EUR",
"category": "subscription",
"operation_type": "qonto_fee",
"status": "completed",
},
{
"id": "tx-hub31-1",
"settled_at": "2026-06-02T08:00:00Z",
"label": "HUB31",
"side": "debit",
"amount": 297.5,
"currency": "EUR",
"category": "other_expense",
"operation_type": "transfer",
"status": "completed",
},
{
"id": "tx-hub31-2",
"settled_at": "2026-05-02T08:00:00Z",
"label": "HUB31",
"side": "debit",
"amount": 297.5,
"currency": "EUR",
"category": "other_expense",
"operation_type": "transfer",
"status": "completed",
},
{
"id": "tx-stripe",
"settled_at": "2026-06-29T08:00:00Z",
"label": "Stripe",
"side": "credit",
"amount": 8.55,
"currency": "EUR",
"category": "other_income",
"operation_type": "income",
"status": "completed",
},
]
}
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path == "/v2/organization":
return httpx.Response(200, json=organization_payload)
if request.url.path == "/v2/transactions":
return httpx.Response(200, json=transactions_payload)
return httpx.Response(404, json={"error": "not_found"})
client = QontoClient(
base_url=settings.qonto_base_url,
organization_path=settings.qonto_organization_path,
transactions_path=settings.qonto_transactions_path,
auth_mode=settings.qonto_auth_mode,
timeout_seconds=settings.qonto_timeout_seconds,
max_retries=settings.qonto_max_retries,
credential_provider=EnvironmentCredentialProvider(),
transport=httpx.MockTransport(handler),
)
policy = PolicyEngine.from_file(
settings.policy_file,
required_scope=settings.required_scope,
enforce_scope=settings.enforce_scope,
)
service = CapabilityService(
client=client,
policy=policy,
audit_logger=AuditLogger(sink=events.append),
rate_limiter=RateLimiter(limit=20, window_seconds=60),
concurrency_limiter=ConcurrencyLimiter(limit=4),
)
return service, events
def test_accounts_contract_returns_redacted_summary(monkeypatch) -> None:
service, _ = _service(monkeypatch)
payload = service.get_accounts(claims=_claims(), request_id="req-accounts")
assert payload["organization"]["name"] == "Binky Hedgehog GmbH"
assert payload["accounts"][0]["iban_last4"] == "6810"
assert "iban" not in payload["accounts"][0]
def test_transactions_contract_denies_oversized_page_size(monkeypatch) -> None:
service, events = _service(monkeypatch)
try:
service.list_transactions(
claims=_claims(),
request_id="req-deny",
account_slug=None,
page=1,
page_size=101,
window_days=31,
status="completed",
side=None,
)
except PolicyDeniedError as exc:
assert exc.error_code == "arg_constraint"
else:
raise AssertionError("Expected policy denial")
assert events[-1]["decision"] == "deny"
assert events[-1]["deny_reason"] == "arg_constraint"
def test_snapshot_contract_returns_cost_run_rate_hints_for_90_day_window(monkeypatch) -> None:
service, events = _service(monkeypatch)
payload = service.get_snapshot(
claims=_claims(),
request_id="req-snapshot",
window_days=90,
page_size=50,
)
assert payload["summary"]["total_balance"] == 2185.94
assert payload["cost_run_rate_hints"]["recurring_debits"][0]["label"] == "HUB31"
assert any(event["capability"] == "snapshot_bundle" for event in events)

25
tests/test_audit.py Normal file
View file

@ -0,0 +1,25 @@
import logging
from qonto_assistant.audit import AuditLogger, REDACTED
def test_audit_logger_redacts_secret_fields() -> None:
events: list[dict[str, object]] = []
logger = logging.getLogger("qonto_assistant.audit.test")
logger.handlers.clear()
audit = AuditLogger(logger=logger, sink=events.append)
payload = audit.emit(
{
"authorization": "Bearer super-secret",
"api_key": "top-secret",
"nested": {"token": "child-secret"},
"capability": "org_summary",
}
)
assert payload["authorization"] == REDACTED
assert payload["api_key"] == REDACTED
assert payload["nested"]["token"] == REDACTED
assert "super-secret" not in str(events[0])
assert "top-secret" not in str(events[0])

View file

@ -0,0 +1,32 @@
from pathlib import Path
from qonto_assistant.qonto_client import FixtureQontoClient
FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures" / "qonto"
def test_fixture_client_loads_fixture_payloads() -> None:
client = FixtureQontoClient(fixture_dir=FIXTURE_DIR)
organization = client.get_organization()
assert organization["organization"]["name"] == "Binky Hedgehog GmbH"
assert len(organization["organization"]["bank_accounts"]) == 2
def test_fixture_client_filters_window_and_pagination() -> None:
client = FixtureQontoClient(fixture_dir=FIXTURE_DIR)
payload = client.list_transactions(
iban="DE02100100101234566810",
page=1,
page_size=2,
window_days=31,
status="completed",
side=None,
)
transactions = payload["transactions"]
assert len(transactions) == 2
assert transactions[0]["id"] == "tx-qonto-2026-07"
assert all(item["id"] != "tx-old-window" for item in transactions)

87
tests/test_policy.py Normal file
View file

@ -0,0 +1,87 @@
from pathlib import Path
from qonto_assistant.contracts import ActorClaims, CapabilityRequest
from qonto_assistant.policy import PolicyEngine
POLICY_FILE = Path(__file__).resolve().parents[1] / "src" / "qonto_assistant" / "policy" / "qonto-v1.yaml"
def _policy(*, enforce_scope: bool = False) -> PolicyEngine:
return PolicyEngine.from_file(
POLICY_FILE,
required_scope="finance.qonto.read",
enforce_scope=enforce_scope,
)
def _request(capability_id: str, **request_args: object) -> CapabilityRequest:
claims = ActorClaims(
actor_id="agent-1",
tenant_id="binky",
lane="green",
scopes=frozenset({"finance.qonto.read"}),
)
return CapabilityRequest(
capability_id=capability_id,
tenant_id="binky",
actor_claims=claims,
resource_scope="test",
request_args=dict(request_args),
protocol="rest",
)
def test_policy_allows_known_read_capability() -> None:
decision = _policy().decide(_request("org_summary"))
assert decision.allowed is True
assert decision.reason == "allow"
def test_policy_denies_cross_tenant_requests() -> None:
claims = ActorClaims(actor_id="agent-1", tenant_id="other", lane="green")
request = CapabilityRequest(
capability_id="org_summary",
tenant_id="binky",
actor_claims=claims,
resource_scope="accounts",
request_args={},
protocol="rest",
)
decision = _policy().decide(request)
assert decision.allowed is False
assert decision.reason == "tenant_scope"
def test_policy_denies_excessive_page_size() -> None:
decision = _policy().decide(_request("list_transactions", page=1, page_size=101, window_days=31))
assert decision.allowed is False
assert decision.reason == "arg_constraint"
def test_policy_denies_volume_cost_shaped_requests() -> None:
decision = _policy().decide(
_request("list_transactions", page=1, page_size=50, window_days=31, operation_type="card_operation")
)
assert decision.allowed is False
assert decision.reason == "volume_cost"
def test_policy_denies_credential_exfiltration_flags() -> None:
decision = _policy().decide(_request("list_transactions", page=1, page_size=50, window_days=31, include_full_iban=True))
assert decision.allowed is False
assert decision.reason == "credential_exfil"
def test_policy_enforces_scope_when_enabled() -> None:
claims = ActorClaims(actor_id="agent-1", tenant_id="binky", lane="green", scopes=frozenset())
request = CapabilityRequest(
capability_id="org_summary",
tenant_id="binky",
actor_claims=claims,
resource_scope="accounts",
request_args={},
protocol="rest",
)
decision = _policy(enforce_scope=True).decide(request)
assert decision.allowed is False
assert decision.reason == "authz_denied"

View file

@ -0,0 +1,84 @@
import httpx
from qonto_assistant.contracts import QontoCredentials
from qonto_assistant.qonto_client import QontoClient
class StubCredentialProvider:
def __init__(self) -> None:
self.invalidated = False
def get_credentials(self) -> QontoCredentials:
return QontoCredentials(api_user="binky-user", api_key="top-secret")
def invalidate(self) -> None:
self.invalidated = True
def test_client_sends_legacy_api_key_header_and_params() -> None:
provider = StubCredentialProvider()
seen: dict[str, object] = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["auth"] = request.headers["Authorization"]
seen["path"] = request.url.path
seen["params"] = dict(request.url.params)
return httpx.Response(200, json={"transactions": []})
client = QontoClient(
base_url="https://example.test",
organization_path="/v2/organization",
transactions_path="/v2/transactions",
auth_mode="legacy_api_key",
timeout_seconds=1,
max_retries=0,
credential_provider=provider,
transport=httpx.MockTransport(handler),
)
client.list_transactions(
iban="DE1234567890",
page=2,
page_size=25,
window_days=14,
status="completed",
side="debit",
)
assert seen["auth"] == "binky-user:top-secret"
assert seen["path"] == "/v2/transactions"
assert seen["params"] == {
"iban": "DE1234567890",
"current_page": "2",
"per_page": "25",
"window_days": "14",
"status": "completed",
"side": "debit",
}
def test_client_invalidates_cached_credentials_on_auth_failure() -> None:
provider = StubCredentialProvider()
def handler(_: httpx.Request) -> httpx.Response:
return httpx.Response(401, json={"error": "unauthorized"})
client = QontoClient(
base_url="https://example.test",
organization_path="/v2/organization",
transactions_path="/v2/transactions",
auth_mode="legacy_api_key",
timeout_seconds=1,
max_retries=0,
credential_provider=provider,
transport=httpx.MockTransport(handler),
)
try:
client.get_organization()
except Exception as exc: # noqa: BLE001
assert getattr(exc, "error_code", None) == "qonto_auth_failed"
else:
raise AssertionError("Expected Qonto auth failure")
assert provider.invalidated is True

View file

@ -0,0 +1,53 @@
from pathlib import Path
from qonto_assistant.audit import AuditLogger
from qonto_assistant.contracts import ActorClaims
from qonto_assistant.policy import PolicyEngine
from qonto_assistant.qonto_client import FixtureQontoClient
from qonto_assistant.rate_limits import ConcurrencyLimiter, RateLimiter
from qonto_assistant.service import CapabilityService
POLICY_FILE = Path(__file__).resolve().parents[1] / "src" / "qonto_assistant" / "policy" / "qonto-v1.yaml"
FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures" / "qonto"
def _claims() -> ActorClaims:
return ActorClaims(actor_id="codex", tenant_id="binky", lane="green")
def _service() -> CapabilityService:
return CapabilityService(
client=FixtureQontoClient(fixture_dir=FIXTURE_DIR),
policy=PolicyEngine.from_file(
POLICY_FILE,
required_scope="finance.qonto.read",
enforce_scope=False,
),
audit_logger=AuditLogger(sink=lambda _: None),
rate_limiter=RateLimiter(limit=20, window_seconds=60),
concurrency_limiter=ConcurrencyLimiter(limit=4),
)
def test_snapshot_recent_window_excludes_recurring_hint() -> None:
payload = _service().get_snapshot(
claims=_claims(),
request_id="req-snapshot-31",
window_days=31,
page_size=50,
)
assert payload["cost_run_rate_hints"]["recurring_debits"] == []
def test_snapshot_90_day_window_detects_recurring_hint() -> None:
payload = _service().get_snapshot(
claims=_claims(),
request_id="req-snapshot-90",
window_days=90,
page_size=50,
)
recurring = payload["cost_run_rate_hints"]["recurring_debits"]
assert recurring[0]["label"] == "HUB31"
assert recurring[0]["occurrences"] == 2