Close the gaps between the documented contract (API-STANDARD, TENANCY, ENTITLEMENTS) and the implementation, and remove committed build artifacts. Security: - verify + process inbound webhooks (HMAC/handler verify, size limit, timestamp tolerance, idempotency via InboundEvent.external_id) - real 2FA login flow (pre-auth challenge; tokens only after verify/recovery) - wire HybridPermission into security/audit views; add API-key rotate and users/<id>/permissions|roles endpoints - tenant-scoped throttling on by default; webhook replay protection - verify MercadoPago webhook signatures - login brute-force guard, configurable password policy, real ALTCHA PoW Correctness: - apply verified billing webhooks idempotently (subscription/entitlement/ invoice/PaymentTransaction); scheduled payment lifecycle jobs - capture audit update diffs automatically; add audit retention purge - working notification retries, per-channel rate limits, log retention - pluggable virus scanner, upload-size limit, pipeline toggle - feature rollout %/environment targeting; settings-driven registrations - workflow guards (instance cap, route depth, self-assignment, clone on re-entry) - wire every previously-dead INFRASYNTH_* setting; drop truly dead ones Delivery: - README + CHANGELOG; CI format check + coverage gate - keep test media out of the tree; untrack .coverage, __pycache__, egg-info, docs/ and invoice artifacts
136 lines
5 KiB
Python
136 lines
5 KiB
Python
import pytest
|
|
from rest_framework import status
|
|
|
|
from infrasynth.audit.models import APIInteractionLog, ModelChangeLog, SecurityEvent
|
|
|
|
|
|
@pytest.fixture
|
|
def change_log(db, user, tenant):
|
|
return ModelChangeLog.objects.create(
|
|
tenant=tenant,
|
|
model_label="infrasynth_security.Role",
|
|
object_id="1",
|
|
action="create",
|
|
changes={"name": [None, "Test"]},
|
|
actor=user,
|
|
request_id="req-1",
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def api_log(db, user, tenant):
|
|
return APIInteractionLog.objects.create(
|
|
tenant=tenant,
|
|
method="GET",
|
|
path="/api/v1/features/",
|
|
status_code=200,
|
|
actor=user,
|
|
duration_ms=12,
|
|
request_id="req-2",
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def security_event(db, user, tenant):
|
|
return SecurityEvent.objects.create(
|
|
tenant=tenant,
|
|
event_type="login_failed",
|
|
actor=user,
|
|
ip_address="127.0.0.1",
|
|
metadata={"reason": "bad_password"},
|
|
request_id="req-3",
|
|
)
|
|
|
|
|
|
class TestModelChangeLogEndpoints:
|
|
def test_list_changes(self, authenticated_client, change_log):
|
|
resp = authenticated_client.get("/api/v1/audit/changes/", {"request_id": "req-1"})
|
|
assert resp.status_code == status.HTTP_200_OK
|
|
assert resp.json()["count"] == 1
|
|
assert resp.json()["results"][0]["action"] == "create"
|
|
|
|
def test_retrieve_change(self, authenticated_client, change_log):
|
|
resp = authenticated_client.get(f"/api/v1/audit/changes/{change_log.pk}/")
|
|
assert resp.status_code == status.HTTP_200_OK
|
|
assert resp.json()["changes"] == {"name": [None, "Test"]}
|
|
|
|
def test_requires_auth(self, api_client, change_log):
|
|
resp = api_client.get("/api/v1/audit/changes/")
|
|
assert resp.status_code == status.HTTP_401_UNAUTHORIZED
|
|
|
|
def test_list_does_not_allow_create(self, authenticated_client):
|
|
resp = authenticated_client.post("/api/v1/audit/changes/", {"model_label": "x"}, format="json")
|
|
assert resp.status_code in (
|
|
status.HTTP_405_METHOD_NOT_ALLOWED,
|
|
status.HTTP_403_FORBIDDEN,
|
|
)
|
|
|
|
|
|
class TestAPIInteractionLogEndpoints:
|
|
def test_list_api_logs(self, authenticated_client, api_log):
|
|
resp = authenticated_client.get("/api/v1/audit/api-logs/")
|
|
assert resp.status_code == status.HTTP_200_OK
|
|
assert resp.json()["count"] == 1
|
|
assert resp.json()["results"][0]["method"] == "GET"
|
|
|
|
def test_retrieve_api_log(self, authenticated_client, api_log):
|
|
resp = authenticated_client.get(f"/api/v1/audit/api-logs/{api_log.pk}/")
|
|
assert resp.status_code == status.HTTP_200_OK
|
|
assert resp.json()["path"] == "/api/v1/features/"
|
|
|
|
def test_requires_auth(self, api_client, api_log):
|
|
resp = api_client.get("/api/v1/audit/api-logs/")
|
|
assert resp.status_code == status.HTTP_401_UNAUTHORIZED
|
|
|
|
|
|
class TestSecurityEventEndpoints:
|
|
def test_list_security_events(self, authenticated_client, security_event):
|
|
resp = authenticated_client.get("/api/v1/audit/security-events/")
|
|
assert resp.status_code == status.HTTP_200_OK
|
|
assert resp.json()["count"] == 1
|
|
assert resp.json()["results"][0]["event_type"] == "login_failed"
|
|
|
|
def test_retrieve_security_event(self, authenticated_client, security_event):
|
|
resp = authenticated_client.get(f"/api/v1/audit/security-events/{security_event.pk}/")
|
|
assert resp.status_code == status.HTTP_200_OK
|
|
assert resp.json()["metadata"] == {"reason": "bad_password"}
|
|
|
|
def test_requires_auth(self, api_client, security_event):
|
|
resp = api_client.get("/api/v1/audit/security-events/")
|
|
assert resp.status_code == status.HTTP_401_UNAUTHORIZED
|
|
|
|
|
|
class TestFiltering:
|
|
def test_filter_by_model_label(self, authenticated_client, db, tenant):
|
|
ModelChangeLog.objects.create(
|
|
tenant=tenant,
|
|
model_label="infrasynth_security.Role",
|
|
object_id="10",
|
|
action="create",
|
|
changes={},
|
|
request_id="x1",
|
|
)
|
|
ModelChangeLog.objects.create(
|
|
tenant=tenant,
|
|
model_label="infrasynth_security.Grant",
|
|
object_id="99",
|
|
action="create",
|
|
changes={},
|
|
request_id="x2",
|
|
)
|
|
resp = authenticated_client.get("/api/v1/audit/changes/", {"model_label": "infrasynth_security.Role"})
|
|
assert resp.json()["count"] == 1
|
|
|
|
def test_filter_by_action(self, authenticated_client, change_log, tenant):
|
|
ModelChangeLog.objects.create(
|
|
tenant=tenant,
|
|
model_label="infrasynth_security.Role",
|
|
object_id="77",
|
|
action="delete",
|
|
changes={},
|
|
request_id="req-4",
|
|
)
|
|
resp = authenticated_client.get("/api/v1/audit/changes/", {"action": "create"})
|
|
assert all(r["action"] == "create" for r in resp.json()["results"])
|
|
resp = authenticated_client.get("/api/v1/audit/changes/", {"action": "delete"})
|
|
assert all(r["action"] == "delete" for r in resp.json()["results"])
|