infrasynth-backend-kit/tests/test_security/test_workspace.py
jcv-dev 551b42eab5 feat: production-hardening pass across the kit
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
2026-09-24 10:41:21 -05:00

83 lines
3.7 KiB
Python

"""Multi-workspace login, selection, switching, and tenant-scoped API keys."""
import pytest
from django.contrib.auth import get_user_model
from django.contrib.auth.hashers import make_password
from django.test import RequestFactory
from rest_framework_simplejwt.tokens import AccessToken
from infrasynth.shared.crypto import decrypt
from infrasynth.tenancy.context import get_current_tenant
from infrasynth.tenancy.models import Tenant, TenantMembership
pytestmark = pytest.mark.django_db
UserModel = get_user_model()
LOGIN = "/api/v1/auth/login/"
SELECT = "/api/v1/auth/select-workspace/"
SWITCH = "/api/v1/auth/switch-workspace/"
CREDS = {"username": "testuser", "password": "testpass123"}
class TestLoginWorkspaceFlow:
def test_single_membership_issues_tenant_token(self, api_client, user, tenant):
resp = api_client.post(LOGIN, CREDS, format="json")
assert resp.status_code == 200
assert resp.json()["tenant"] == str(tenant.pk)
token = AccessToken(decrypt(api_client.cookies["access_token"].value))
assert token["tenant"] == str(tenant.pk)
def test_multiple_memberships_returns_picker_without_access_token(self, api_client, user, tenant):
other = Tenant.objects.create(slug="second", name="Second")
TenantMembership.objects.create(tenant=other, user=user, is_active=True)
resp = api_client.post(LOGIN, CREDS, format="json")
assert resp.status_code == 200
assert len(resp.json()["workspaces"]) == 2
assert "access_token" not in resp.cookies
def test_select_workspace_issues_bound_tokens(self, api_client, user, tenant):
other = Tenant.objects.create(slug="second", name="Second")
TenantMembership.objects.create(tenant=other, user=user, is_active=True)
assert api_client.post(LOGIN, CREDS, format="json").status_code == 200
resp = api_client.post(SELECT, {"tenantId": str(other.pk)}, format="json")
assert resp.status_code == 200
assert resp.json()["tenant"] == str(other.pk)
token = AccessToken(decrypt(api_client.cookies["access_token"].value))
assert token["tenant"] == str(other.pk)
def test_select_workspace_without_pending_session(self, api_client):
resp = api_client.post(SELECT, {"tenantId": "whatever"}, format="json")
assert resp.status_code == 401
def test_select_workspace_rejects_foreign_tenant(self, api_client, user, tenant):
other = Tenant.objects.create(slug="second", name="Second")
TenantMembership.objects.create(tenant=other, user=user, is_active=True)
api_client.post(LOGIN, CREDS, format="json")
stranger = Tenant.objects.create(slug="stranger", name="Stranger")
resp = api_client.post(SELECT, {"tenantId": str(stranger.pk)}, format="json")
assert resp.status_code == 404
def test_switch_workspace_requires_auth(self, api_client, tenant):
resp = api_client.post(SWITCH, {"tenantId": str(tenant.pk)}, format="json")
assert resp.status_code == 401
class TestAPIKeyTenantScope:
def test_api_key_authentication_binds_tenant(self, tenant):
from infrasynth.security.auth.api_keys import APIKeyAuthentication
from infrasynth.security.models import APIKey
APIKey.all_objects.create(
tenant=tenant,
name="svc",
prefix="abcd1234",
key_hash=make_password("secret"),
scopes=["read:users"],
)
request = RequestFactory().get("/", HTTP_X_API_KEY="abcd1234.secret")
user, auth = APIKeyAuthentication().authenticate(request)
assert user.tenant_id == tenant.id
assert user.scopes == ["read:users"]
assert get_current_tenant() == tenant