"""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