feat: add legacy device migration flow

This commit is contained in:
QiuSW
2026-07-21 10:06:51 +08:00
parent 7b80ae3a7a
commit 1af47d043e
20 changed files with 1030 additions and 6 deletions
+179
View File
@@ -11,20 +11,27 @@ from rest_framework.test import APIClient
from apps.licensing.models import (
ClientDevice,
DeviceBindingAudit,
DeviceCredential,
DeviceSession,
LegacyMigrationGrant,
LicenseEvent,
LicenseSeat,
MigrationRequest,
SoftwareEntitlement,
SoftwarePlan,
)
from apps.licensing.services import (
LicensingError,
assign_license_seat,
confirm_migration_request,
create_legacy_migration_grant,
create_migration_request,
grant_software_entitlement,
record_device_heartbeat,
release_license_seat,
renew_software_entitlement,
revoke_software_entitlement,
register_device,
)
from apps.users.models import ApiKey, User, UserWallet
@@ -446,3 +453,175 @@ class LicenseSeatConcurrencyTests(TransactionTestCase):
self.assertEqual(sum(outcome[0] == "seat_limit_reached" for outcome in outcomes), 1)
seat = LicenseSeat.objects.get(entitlement=self.entitlement)
self.assertIn(seat.device_id, {self.first_device.pk, self.second_device.pk})
class LegacyMigrationFlowTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(
username="migration-user",
email="migration@example.com",
password="test-password",
)
self.other_user = User.objects.create_user(
username="migration-other",
email="migration-other@example.com",
password="test-password",
)
self.plan = SoftwarePlan.objects.create(
product_code=ClientDevice.ProductCode.CMSHOPEE,
name="存量迁移套餐",
duration_days=30,
price=Decimal("1.00"),
device_limit=1,
)
self.api_key, self.raw_api_key = ApiKey.create_for_user(self.user, name="legacy")
self.device, self.device_session_token = self.register_device_session()
self.client = APIClient()
def register_device_session(self):
result = register_device(
user=self.user,
api_key=self.api_key,
product_code=ClientDevice.ProductCode.CMSHOPEE,
device_id_version="v1",
device_id="migration-device-id",
public_key="migration-device-public-key",
platform=ClientDevice.Platform.WINDOWS,
client_version="0.2.0",
)
return result.device, result.session_token
def grant_migration(self):
return create_legacy_migration_grant(
user=self.user,
plan=self.plan,
reason="历史付费用户迁移",
eligibility_snapshot={"legacy_customer_id": "legacy-001"},
)
def api_headers(self):
return {
"HTTP_AUTHORIZATION": f"Bearer {self.raw_api_key}",
"HTTP_X_DEVICE_SESSION": self.device_session_token,
}
def test_request_confirm_poll_and_revoke_flow_keeps_credential_hashed(self):
self.grant_migration()
create_response = self.client.post(
reverse("api-client-migration-request-create"),
{},
format="json",
**self.api_headers(),
)
self.assertEqual(create_response.status_code, 201)
raw_credential = create_response.data["device_credential_token"]
self.assertTrue(create_response.data["confirmation_url"].endswith(create_response.data["request_id"]))
migration_request = MigrationRequest.objects.get(
request_id=create_response.data["request_id"]
)
self.assertNotEqual(migration_request.credential_token_hash, raw_credential)
pending = self.client.get(
reverse(
"api-client-migration-request-detail",
args=(migration_request.request_id,),
),
**self.api_headers(),
)
self.assertEqual(pending.status_code, 200)
self.assertEqual(pending.data["status"], MigrationRequest.Status.PENDING)
self.assertNotIn("device_credential_token", pending.data)
self.client.force_login(self.user)
confirm_url = reverse("portal-migration-confirm", args=(migration_request.request_id,))
self.assertEqual(self.client.get(confirm_url).status_code, 200)
self.assertEqual(self.client.post(confirm_url).status_code, 302)
self.assertEqual(self.client.post(confirm_url).status_code, 302)
credential = DeviceCredential.objects.get(migration_request=migration_request)
self.assertNotEqual(credential.token_hash, raw_credential)
self.assertEqual(credential.token_hash, MigrationRequest.hash_credential_token(raw_credential))
self.assertEqual(credential.seat.device_id, self.device.id)
self.assertEqual(DeviceCredential.objects.count(), 1)
self.assertEqual(
LicenseEvent.objects.filter(
action=LicenseEvent.Action.CREDENTIAL_ISSUED,
).count(),
1,
)
confirmed = self.client.get(
reverse(
"api-client-migration-request-detail",
args=(migration_request.request_id,),
),
**self.api_headers(),
)
self.assertEqual(confirmed.status_code, 200)
self.assertEqual(confirmed.data["status"], MigrationRequest.Status.CONFIRMED)
revoke = self.client.post(
reverse("portal-device-credential-revoke", args=(credential.pk,))
)
self.assertEqual(revoke.status_code, 302)
credential.refresh_from_db()
credential.seat.refresh_from_db()
self.assertIsNotNone(credential.revoked_at)
self.assertIsNone(credential.seat.device_id)
self.assertTrue(
LicenseEvent.objects.filter(
action=LicenseEvent.Action.CREDENTIAL_REVOKED,
reason="用户自助解绑设备",
).exists()
)
def test_request_requires_eligible_current_device_and_web_confirmation_same_user(self):
no_grant = self.client.post(
reverse("api-client-migration-request-create"),
{},
format="json",
**self.api_headers(),
)
self.assertEqual(no_grant.status_code, 403)
self.assertEqual(no_grant.data["error"]["code"], "migration_not_eligible")
self.grant_migration()
missing_device_session = self.client.post(
reverse("api-client-migration-request-create"),
{},
format="json",
HTTP_AUTHORIZATION=f"Bearer {self.raw_api_key}",
)
self.assertEqual(missing_device_session.status_code, 401)
self.assertEqual(missing_device_session.data["error"]["code"], "device_session_required")
created = self.client.post(
reverse("api-client-migration-request-create"),
{},
format="json",
**self.api_headers(),
)
migration_request = MigrationRequest.objects.get(request_id=created.data["request_id"])
self.client.force_login(self.other_user)
confirmation = self.client.get(
reverse("portal-migration-confirm", args=(migration_request.request_id,))
)
self.assertEqual(confirmation.status_code, 403)
self.assertFalse(DeviceCredential.objects.exists())
def test_expired_request_and_duplicate_migration_grant_are_rejected(self):
self.grant_migration()
with self.assertRaisesRegex(LicensingError, "已有此产品的迁移资格"):
self.grant_migration()
migration_request, _raw_token = create_migration_request(
user=self.user,
device=self.device,
now=timezone.now() - timedelta(minutes=20),
)
with self.assertRaisesRegex(LicensingError, "迁移请求已过期"):
confirm_migration_request(
request_id=migration_request.request_id,
user=self.user,
now=timezone.now(),
)
self.assertFalse(DeviceCredential.objects.exists())