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
108 lines
4.2 KiB
Python
108 lines
4.2 KiB
Python
from django.contrib.auth.hashers import make_password
|
|
from rest_framework import status
|
|
|
|
from infrasynth.security.auth.middleware import JWTAuthenticationMiddleware
|
|
from infrasynth.security.models import APIKey
|
|
|
|
|
|
class TestAPIKeyViewSet:
|
|
def test_create_returns_full_key_once(self, authenticated_client):
|
|
resp = authenticated_client.post(
|
|
"/api/v1/auth/api-keys/",
|
|
{"name": "CI Key", "scopes": ["read:users"]},
|
|
format="json",
|
|
)
|
|
assert resp.status_code == status.HTTP_201_CREATED
|
|
data = resp.json()
|
|
assert "key" in data
|
|
assert "prefix" in data
|
|
assert data["key"].startswith(data["prefix"] + ".")
|
|
|
|
def test_created_key_authenticates(self, authenticated_client):
|
|
resp = authenticated_client.post(
|
|
"/api/v1/auth/api-keys/",
|
|
{"name": "CI Key", "scopes": ["read:users"]},
|
|
format="json",
|
|
)
|
|
full_key = resp.json()["key"]
|
|
from rest_framework.test import APIClient
|
|
|
|
client = APIClient()
|
|
client.credentials(HTTP_X_API_KEY=full_key)
|
|
check = client.get("/api/v1/auth/check/")
|
|
assert check.status_code == status.HTTP_200_OK
|
|
|
|
def test_list_keys(self, authenticated_client, user):
|
|
APIKey.objects.create(
|
|
name="Old Key",
|
|
prefix="prefix01",
|
|
key_hash=make_password("secret"),
|
|
created_by=user,
|
|
)
|
|
resp = authenticated_client.get("/api/v1/auth/api-keys/")
|
|
assert resp.status_code == status.HTTP_200_OK
|
|
assert resp.json()["count"] == 1
|
|
|
|
def test_list_never_exposes_hash(self, authenticated_client, user):
|
|
APIKey.objects.create(
|
|
name="Old Key",
|
|
prefix="prefix01",
|
|
key_hash=make_password("secret"),
|
|
created_by=user,
|
|
)
|
|
resp = authenticated_client.get("/api/v1/auth/api-keys/")
|
|
result = resp.json()["results"][0]
|
|
assert "key" not in result
|
|
assert "key_hash" not in result
|
|
assert result["prefix"] == "prefix01"
|
|
|
|
def test_delete_key(self, authenticated_client, user):
|
|
key = APIKey.objects.create(
|
|
name="Old Key",
|
|
prefix="prefix01",
|
|
key_hash=make_password("secret"),
|
|
created_by=user,
|
|
)
|
|
resp = authenticated_client.delete(f"/api/v1/auth/api-keys/{key.pk}/")
|
|
assert resp.status_code == status.HTTP_204_NO_CONTENT
|
|
assert not APIKey.objects.filter(pk=key.pk).exists()
|
|
|
|
def test_rotate_issues_new_secret_and_deactivates_old(self, authenticated_client, user):
|
|
created = APIKey.objects.create(
|
|
name="Rotate Me",
|
|
prefix="rot0aaaa",
|
|
key_hash=make_password("old-secret"),
|
|
scopes=["read:users"],
|
|
created_by=user,
|
|
)
|
|
resp = authenticated_client.post(f"/api/v1/auth/api-keys/{created.pk}/rotate/", format="json")
|
|
assert resp.status_code == status.HTTP_201_CREATED
|
|
created.refresh_from_db()
|
|
assert created.is_active is False
|
|
new = APIKey.objects.get(prefix=resp.json()["prefix"])
|
|
assert new.rotated_from_id == created.pk
|
|
assert new.is_active is True
|
|
assert resp.json()["key"].startswith(new.prefix + ".")
|
|
|
|
def test_requires_auth(self, api_client):
|
|
resp = api_client.get("/api/v1/auth/api-keys/")
|
|
assert resp.status_code == status.HTTP_401_UNAUTHORIZED
|
|
|
|
|
|
class TestJWTAuthenticationMiddleware:
|
|
def test_ignores_anonymous_requests(self, rf, db):
|
|
request = rf.get("/api/v1/features/active/")
|
|
request.user = type("Anon", (), {"is_anonymous": True})()
|
|
JWTAuthenticationMiddleware(get_response=lambda r: None).process_request(request)
|
|
assert request.user.is_anonymous
|
|
|
|
def test_populates_user_from_cookie(self, rf, user):
|
|
from rest_framework_simplejwt.tokens import RefreshToken
|
|
|
|
from infrasynth.shared.crypto import encrypt
|
|
|
|
request = rf.get("/api/v1/features/active/")
|
|
request.COOKIES["access_token"] = encrypt(str(RefreshToken.for_user(user).access_token))
|
|
request.user = type("Anon", (), {"is_anonymous": True})()
|
|
JWTAuthenticationMiddleware(get_response=lambda r: None).process_request(request)
|
|
assert request.user == user
|