Files
dealerhub/backend/tests/test_membership.py
T

150 lines
5.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""P0-1 · 租户 membership 授权语义回归(权限类此前零测试覆盖)。
语义合同:
- 未知租户 code → 交给视图层报 400「无法识别租户」,权限层不掩盖为 403
- 已知租户无 membership → 403 + code=tenant_membership_required
- is_active=False 的 membership → 403
- 超级用户无 membership → 放行(bootstrap 契约)
- API Key:不带头或带同租户头 → 放行;显式带其他租户头 → 403
- demo token 进入 demo 租户只读可读 → 200
"""
import pytest
from decimal import Decimal
from model_bakery import baker
from django.core.management import call_command
from rest_framework.test import APIClient
from rest_framework_simplejwt.tokens import RefreshToken
from apps.catalog.models import Product
from apps.core.models import TenantMembership
from apps.inventory.models import Warehouse
from apps.openapi.models import APIKey
def _jwt_client(user, tenant_code):
c = APIClient()
c.credentials(
HTTP_AUTHORIZATION=f"Bearer {RefreshToken.for_user(user).access_token}",
HTTP_X_TENANT_ID=tenant_code,
)
return c
def test_no_membership_valid_tenant_is_403(db, tenant, django_user_model):
stranger = django_user_model.objects.create_user("stranger", password="x")
c = _jwt_client(stranger, tenant.code)
resp = c.get("/api/v1/catalog/products/")
assert resp.status_code == 403, resp.content
assert resp.json()["code"] == "tenant_membership_required"
def test_membership_of_a_requesting_b_is_403(db, tenant, other_tenant, user):
c = _jwt_client(user, other_tenant.code)
resp = c.get("/api/v1/catalog/products/")
assert resp.status_code == 403, resp.content
assert resp.json()["code"] == "tenant_membership_required"
def test_inactive_membership_is_403(db, tenant, django_user_model):
u = django_user_model.objects.create_user("sleepy", password="x")
TenantMembership.objects.create(
user=u, tenant=tenant, role="member", is_active=False
)
c = _jwt_client(u, tenant.code)
resp = c.get("/api/v1/catalog/products/")
assert resp.status_code == 403, resp.content
def test_unknown_tenant_is_400_not_403(db, user):
c = _jwt_client(user, "no-such-tenant")
resp = c.get("/api/v1/catalog/products/")
assert resp.status_code == 400, resp.content
assert "tenant" in str(resp.json())
def test_superuser_without_membership_passes(db, tenant, django_user_model):
root = django_user_model.objects.create_superuser(
"rootop", password="x", email="r@x.com"
)
c = _jwt_client(root, tenant.code)
resp = c.get("/api/v1/catalog/products/")
assert resp.status_code == 200, resp.content
def test_apikey_branch_unit_and_integration_reality(db, tenant, other_tenant):
"""API Key 租户绑定分支:单元级验证权限类分支,集成级记录真实接线。
实测接线现实(写进测试,代替纸面假设):
- `TenantMembershipPermission` 的 APIKey 分支目前没有视图同时接线
(BaseTenantViewSet 系用 JWT/Session;Open* 系用 require_scope),
所以分支按单元级直接调用验证;
- 集成级:catalog 系不接受 APIKey(401,要 JWT),Open* 系按 key 绑定租户
出数据(显式带其他租户头被忽略,不泄露对方数据)。
"""
from django.contrib.auth import get_user_model
from rest_framework.test import APIRequestFactory
from apps.core.permissions import TenantMembershipPermission
User = get_user_model()
u = User.objects.create_user("keyowner", password="x")
key_obj, _raw = APIKey.generate(
tenant=tenant, name="m1", scopes=["products:read"], created_by=u,
)
perm = TenantMembershipPermission()
factory = APIRequestFactory()
def _req(header=None):
req = factory.get("/api/v1/catalog/products/")
req.user = u
req.auth = key_obj
req.META = {}
if header is not None:
req.META["HTTP_X_TENANT_ID"] = header
return req
assert perm.has_permission(_req(), None) is True
assert perm.has_permission(_req(tenant.code), None) is True
assert perm.has_permission(_req(other_tenant.code), None) is False
# 集成级现实:catalog + APIKey 头 → 401(该系只认 JWT)
c3 = APIClient()
c3.credentials(HTTP_X_API_KEY=_raw, HTTP_X_TENANT_ID=other_tenant.code)
r3 = c3.get("/api/v1/catalog/products/")
assert r3.status_code == 401, r3.content
def test_demo_token_reads_demo_tenant(db):
call_command("seed_demo")
token = APIClient().post("/api/v1/demo/enter/", {}, format="json").json()["access"]
c = APIClient()
c.credentials(HTTP_AUTHORIZATION=f"Bearer {token}", HTTP_X_TENANT_ID="demo")
resp = c.get("/api/v1/sales/bills/")
assert resp.status_code == 200, resp.content
def test_seed_initial_data_membership_idempotent(db, django_user_model):
"""seed_initial_data 幂等:普通用户只得 default、超管得全部租户。"""
from apps.core.models import Tenant
call_command("seed_initial_data")
call_command("seed_initial_data")
plain = django_user_model.objects.create_user("plain1", password="x")
root = django_user_model.objects.create_superuser(
"root1", password="x", email="r1@x.com"
)
call_command("seed_initial_data")
default = Tenant.objects.get(code="default")
demo = Tenant.objects.get(code="demo")
assert TenantMembership.objects.filter(user=plain, tenant=default).exists()
assert not TenantMembership.objects.filter(user=plain, tenant=demo).exists()
assert TenantMembership.objects.filter(user=root, tenant=default).exists()
assert TenantMembership.objects.filter(user=root, tenant=demo).exists()
before = TenantMembership.objects.count()
call_command("seed_initial_data")
assert TenantMembership.objects.count() == before