karrio-server-core 2025.5rc31__py3-none-any.whl → 2026.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. karrio/server/core/authentication.py +38 -20
  2. karrio/server/core/config.py +31 -0
  3. karrio/server/core/datatypes.py +30 -4
  4. karrio/server/core/dataunits.py +26 -7
  5. karrio/server/core/exceptions.py +287 -17
  6. karrio/server/core/filters.py +14 -0
  7. karrio/server/core/gateway.py +284 -11
  8. karrio/server/core/logging.py +403 -0
  9. karrio/server/core/middleware.py +104 -2
  10. karrio/server/core/models/base.py +34 -1
  11. karrio/server/core/oauth_validators.py +2 -3
  12. karrio/server/core/permissions.py +1 -2
  13. karrio/server/core/serializers.py +154 -7
  14. karrio/server/core/signals.py +22 -28
  15. karrio/server/core/telemetry.py +573 -0
  16. karrio/server/core/tests/__init__.py +27 -0
  17. karrio/server/core/{tests.py → tests/base.py} +6 -7
  18. karrio/server/core/tests/test_exception_level.py +159 -0
  19. karrio/server/core/tests/test_resource_token.py +593 -0
  20. karrio/server/core/utils.py +688 -38
  21. karrio/server/core/validators.py +144 -222
  22. karrio/server/core/views/oauth.py +13 -12
  23. karrio/server/core/views/references.py +2 -2
  24. karrio/server/iam/apps.py +1 -4
  25. karrio/server/iam/migrations/0002_setup_carrier_permission_groups.py +103 -0
  26. karrio/server/iam/migrations/0003_remove_permission_groups.py +91 -0
  27. karrio/server/iam/permissions.py +7 -134
  28. karrio/server/iam/serializers.py +9 -3
  29. karrio/server/iam/signals.py +2 -4
  30. karrio/server/providers/admin.py +1 -1
  31. karrio/server/providers/migrations/0085_populate_dhl_parcel_de_oauth_credentials.py +82 -0
  32. karrio/server/providers/migrations/0086_rename_dhl_parcel_de_customer_number_to_billing_number.py +71 -0
  33. karrio/server/providers/migrations/0087_alter_carrier_capabilities.py +38 -0
  34. karrio/server/providers/migrations/0088_servicelevel_surcharges.py +24 -0
  35. karrio/server/providers/migrations/0089_servicelevel_cost_max_volume.py +31 -0
  36. karrio/server/providers/migrations/0090_ratesheet_surcharges_servicelevel_zone_surcharge_ids.py +47 -0
  37. karrio/server/providers/migrations/0091_migrate_legacy_zones_surcharges.py +154 -0
  38. karrio/server/providers/models/carrier.py +101 -29
  39. karrio/server/providers/models/service.py +182 -125
  40. karrio/server/providers/models/sheet.py +342 -198
  41. karrio/server/providers/serializers/base.py +263 -2
  42. karrio/server/providers/signals.py +2 -4
  43. karrio/server/providers/templates/providers/oauth_callback.html +105 -0
  44. karrio/server/providers/tests/__init__.py +5 -0
  45. karrio/server/providers/tests/test_connections.py +895 -0
  46. karrio/server/providers/views/carriers.py +1 -3
  47. karrio/server/providers/views/connections.py +322 -2
  48. karrio/server/serializers/abstract.py +112 -21
  49. karrio/server/tracing/utils.py +5 -8
  50. karrio/server/user/models.py +36 -34
  51. karrio/server/user/serializers.py +1 -0
  52. {karrio_server_core-2025.5rc31.dist-info → karrio_server_core-2026.1.1.dist-info}/METADATA +2 -2
  53. {karrio_server_core-2025.5rc31.dist-info → karrio_server_core-2026.1.1.dist-info}/RECORD +55 -38
  54. karrio/server/providers/tests.py +0 -3
  55. {karrio_server_core-2025.5rc31.dist-info → karrio_server_core-2026.1.1.dist-info}/WHEEL +0 -0
  56. {karrio_server_core-2025.5rc31.dist-info → karrio_server_core-2026.1.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,593 @@
1
+ """Tests for ResourceAccessToken and /api/tokens endpoint."""
2
+
3
+ from unittest import mock
4
+ from django.test import TestCase
5
+ from django.contrib.auth import get_user_model
6
+ from rest_framework.test import APITestCase, APIClient
7
+
8
+ from karrio.server.user.models import Token
9
+ from karrio.server.core.utils import ResourceAccessToken
10
+ from karrio.server.core.tests.base import APITestCase as KarrioAPITestCase
11
+
12
+
13
+ class TestResourceAccessTokenUnit(TestCase):
14
+ """Unit tests for ResourceAccessToken class."""
15
+
16
+ def setUp(self):
17
+ self.user = get_user_model().objects.create_user(
18
+ email="test@example.com", password="testpass123"
19
+ )
20
+
21
+ def test_create_token_for_single_resource(self):
22
+ """Test creating a token for a single resource."""
23
+ token = ResourceAccessToken.for_resource(
24
+ user=self.user,
25
+ resource_type="shipment",
26
+ resource_ids=["shp_123"],
27
+ access=["label"],
28
+ format="pdf",
29
+ )
30
+
31
+ self.assertDictEqual(
32
+ {
33
+ "resource_type": token["resource_type"],
34
+ "resource_ids": token["resource_ids"],
35
+ "access": token["access"],
36
+ "format": token["format"],
37
+ },
38
+ {
39
+ "resource_type": "shipment",
40
+ "resource_ids": ["shp_123"],
41
+ "access": ["label"],
42
+ "format": "pdf",
43
+ },
44
+ )
45
+
46
+ def test_create_token_for_multiple_resources(self):
47
+ """Test creating a token for multiple resources."""
48
+ token = ResourceAccessToken.for_resource(
49
+ user=self.user,
50
+ resource_type="document",
51
+ resource_ids=["shp_1", "shp_2", "shp_3"],
52
+ access=["batch_labels"],
53
+ format="pdf",
54
+ )
55
+
56
+ self.assertDictEqual(
57
+ {
58
+ "resource_type": token["resource_type"],
59
+ "resource_ids": sorted(token["resource_ids"]),
60
+ "access": token["access"],
61
+ },
62
+ {
63
+ "resource_type": "document",
64
+ "resource_ids": ["shp_1", "shp_2", "shp_3"],
65
+ "access": ["batch_labels"],
66
+ },
67
+ )
68
+
69
+ def test_decode_valid_token(self):
70
+ """Test decoding a valid token."""
71
+ token = ResourceAccessToken.for_resource(
72
+ user=self.user,
73
+ resource_type="manifest",
74
+ resource_ids=["mnf_456"],
75
+ access=["manifest"],
76
+ )
77
+
78
+ claims = ResourceAccessToken.decode(str(token))
79
+
80
+ self.assertDictEqual(
81
+ {
82
+ "resource_type": claims["resource_type"],
83
+ "resource_ids": claims["resource_ids"],
84
+ "access": claims["access"],
85
+ },
86
+ {
87
+ "resource_type": "manifest",
88
+ "resource_ids": ["mnf_456"],
89
+ "access": ["manifest"],
90
+ },
91
+ )
92
+
93
+ def test_validate_access_success(self):
94
+ """Test successful access validation."""
95
+ token = ResourceAccessToken.for_resource(
96
+ user=self.user,
97
+ resource_type="shipment",
98
+ resource_ids=["shp_123"],
99
+ access=["label", "invoice"],
100
+ )
101
+
102
+ claims = ResourceAccessToken.validate_access(
103
+ token_string=str(token),
104
+ resource_type="shipment",
105
+ resource_id="shp_123",
106
+ access="label",
107
+ )
108
+
109
+ self.assertIsNotNone(claims)
110
+ self.assertEqual(claims["resource_type"], "shipment")
111
+
112
+ def test_validate_access_wrong_resource_type(self):
113
+ """Test validation fails for wrong resource type."""
114
+ token = ResourceAccessToken.for_resource(
115
+ user=self.user,
116
+ resource_type="shipment",
117
+ resource_ids=["shp_123"],
118
+ access=["label"],
119
+ )
120
+
121
+ with self.assertRaises(PermissionError) as context:
122
+ ResourceAccessToken.validate_access(
123
+ token_string=str(token),
124
+ resource_type="manifest",
125
+ resource_id="shp_123",
126
+ access="label",
127
+ )
128
+
129
+ self.assertIn("resource type", str(context.exception).lower())
130
+
131
+ def test_validate_access_wrong_resource_id(self):
132
+ """Test validation fails for wrong resource ID."""
133
+ token = ResourceAccessToken.for_resource(
134
+ user=self.user,
135
+ resource_type="shipment",
136
+ resource_ids=["shp_123"],
137
+ access=["label"],
138
+ )
139
+
140
+ with self.assertRaises(PermissionError) as context:
141
+ ResourceAccessToken.validate_access(
142
+ token_string=str(token),
143
+ resource_type="shipment",
144
+ resource_id="shp_999",
145
+ access="label",
146
+ )
147
+
148
+ self.assertIn("resource", str(context.exception).lower())
149
+
150
+ def test_validate_access_wrong_permission(self):
151
+ """Test validation fails for wrong access permission."""
152
+ token = ResourceAccessToken.for_resource(
153
+ user=self.user,
154
+ resource_type="shipment",
155
+ resource_ids=["shp_123"],
156
+ access=["label"],
157
+ )
158
+
159
+ with self.assertRaises(PermissionError) as context:
160
+ ResourceAccessToken.validate_access(
161
+ token_string=str(token),
162
+ resource_type="shipment",
163
+ resource_id="shp_123",
164
+ access="invoice",
165
+ )
166
+
167
+ self.assertIn("access", str(context.exception).lower())
168
+
169
+ def test_validate_batch_access_success(self):
170
+ """Test successful batch access validation."""
171
+ token = ResourceAccessToken.for_resource(
172
+ user=self.user,
173
+ resource_type="document",
174
+ resource_ids=["shp_1", "shp_2", "shp_3"],
175
+ access=["batch_labels"],
176
+ )
177
+
178
+ claims = ResourceAccessToken.validate_batch_access(
179
+ token_string=str(token),
180
+ resource_type="document",
181
+ resource_ids=["shp_1", "shp_2"],
182
+ access="batch_labels",
183
+ )
184
+
185
+ self.assertIsNotNone(claims)
186
+ self.assertEqual(claims["resource_type"], "document")
187
+
188
+ def test_validate_batch_access_missing_id(self):
189
+ """Test batch validation fails when requesting ID not in token."""
190
+ token = ResourceAccessToken.for_resource(
191
+ user=self.user,
192
+ resource_type="document",
193
+ resource_ids=["shp_1", "shp_2"],
194
+ access=["batch_labels"],
195
+ )
196
+
197
+ with self.assertRaises(PermissionError) as context:
198
+ ResourceAccessToken.validate_batch_access(
199
+ token_string=str(token),
200
+ resource_type="document",
201
+ resource_ids=["shp_1", "shp_2", "shp_3"],
202
+ access="batch_labels",
203
+ )
204
+
205
+ self.assertIn("shp_3", str(context.exception))
206
+
207
+ def test_token_with_org_id_and_test_mode(self):
208
+ """Test token includes org_id and test_mode when provided."""
209
+ token = ResourceAccessToken.for_resource(
210
+ user=self.user,
211
+ resource_type="shipment",
212
+ resource_ids=["shp_123"],
213
+ access=["label"],
214
+ org_id="org_abc",
215
+ test_mode=True,
216
+ )
217
+
218
+ claims = ResourceAccessToken.decode(str(token))
219
+
220
+ self.assertDictEqual(
221
+ {
222
+ "org_id": claims["org_id"],
223
+ "test_mode": claims["test_mode"],
224
+ },
225
+ {
226
+ "org_id": "org_abc",
227
+ "test_mode": True,
228
+ },
229
+ )
230
+
231
+
232
+ class TestResourceTokenAPI(APITestCase):
233
+ """API tests for /api/tokens endpoint."""
234
+
235
+ def setUp(self):
236
+ self.user = get_user_model().objects.create_user(
237
+ email="api_test@example.com", password="testpass123"
238
+ )
239
+ self.token = Token.objects.create(user=self.user, test_mode=True)
240
+ self.client = APIClient()
241
+ self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}")
242
+
243
+ def test_generate_shipment_label_token(self):
244
+ """Test generating a token for shipment label access."""
245
+ response = self.client.post(
246
+ "/api/tokens",
247
+ {
248
+ "resource_type": "shipment",
249
+ "resource_ids": ["shp_123"],
250
+ "access": ["label"],
251
+ "format": "pdf",
252
+ },
253
+ format="json",
254
+ )
255
+
256
+ self.assertEqual(response.status_code, 201)
257
+ self.assertDictEqual(
258
+ {
259
+ "has_token": "token" in response.data,
260
+ "has_expires_at": "expires_at" in response.data,
261
+ "has_resource_urls": "resource_urls" in response.data,
262
+ "has_shp_123_url": "shp_123" in response.data.get("resource_urls", {}),
263
+ },
264
+ {
265
+ "has_token": True,
266
+ "has_expires_at": True,
267
+ "has_resource_urls": True,
268
+ "has_shp_123_url": True,
269
+ },
270
+ )
271
+
272
+ def test_generate_batch_labels_token(self):
273
+ """Test generating a token for batch labels."""
274
+ response = self.client.post(
275
+ "/api/tokens",
276
+ {
277
+ "resource_type": "document",
278
+ "resource_ids": ["shp_1", "shp_2", "shp_3"],
279
+ "access": ["batch_labels"],
280
+ "format": "pdf",
281
+ },
282
+ format="json",
283
+ )
284
+
285
+ self.assertEqual(response.status_code, 201)
286
+ self.assertIn("batch", response.data["resource_urls"])
287
+
288
+ def test_generate_manifest_token(self):
289
+ """Test generating a token for manifest access."""
290
+ response = self.client.post(
291
+ "/api/tokens",
292
+ {
293
+ "resource_type": "manifest",
294
+ "resource_ids": ["mnf_456"],
295
+ "access": ["manifest"],
296
+ },
297
+ format="json",
298
+ )
299
+
300
+ self.assertEqual(response.status_code, 201)
301
+ self.assertIn("mnf_456", response.data["resource_urls"])
302
+
303
+ def test_generate_template_token(self):
304
+ """Test generating a token for template access."""
305
+ response = self.client.post(
306
+ "/api/tokens",
307
+ {
308
+ "resource_type": "template",
309
+ "resource_ids": ["tpl_789"],
310
+ "access": ["render"],
311
+ },
312
+ format="json",
313
+ )
314
+
315
+ self.assertEqual(response.status_code, 201)
316
+ self.assertIn("tpl_789", response.data["resource_urls"])
317
+
318
+ def test_unauthenticated_request_fails(self):
319
+ """Test that unauthenticated requests are rejected."""
320
+ self.client.credentials()
321
+ response = self.client.post(
322
+ "/api/tokens",
323
+ {
324
+ "resource_type": "shipment",
325
+ "resource_ids": ["shp_123"],
326
+ "access": ["label"],
327
+ },
328
+ format="json",
329
+ )
330
+
331
+ self.assertEqual(response.status_code, 401)
332
+
333
+ def test_invalid_resource_type_fails(self):
334
+ """Test that invalid resource type is rejected."""
335
+ response = self.client.post(
336
+ "/api/tokens",
337
+ {
338
+ "resource_type": "invalid_type",
339
+ "resource_ids": ["shp_123"],
340
+ "access": ["label"],
341
+ },
342
+ format="json",
343
+ )
344
+
345
+ self.assertEqual(response.status_code, 400)
346
+
347
+ def test_invalid_access_type_fails(self):
348
+ """Test that invalid access type is rejected."""
349
+ response = self.client.post(
350
+ "/api/tokens",
351
+ {
352
+ "resource_type": "shipment",
353
+ "resource_ids": ["shp_123"],
354
+ "access": ["invalid_access"],
355
+ },
356
+ format="json",
357
+ )
358
+
359
+ self.assertEqual(response.status_code, 400)
360
+
361
+ def test_empty_resource_ids_fails(self):
362
+ """Test that empty resource_ids is rejected."""
363
+ response = self.client.post(
364
+ "/api/tokens",
365
+ {
366
+ "resource_type": "shipment",
367
+ "resource_ids": [],
368
+ "access": ["label"],
369
+ },
370
+ format="json",
371
+ )
372
+
373
+ self.assertEqual(response.status_code, 400)
374
+
375
+ def test_custom_expiration_success(self):
376
+ """Test custom token expiration."""
377
+ response = self.client.post(
378
+ "/api/tokens",
379
+ {
380
+ "resource_type": "shipment",
381
+ "resource_ids": ["shp_123"],
382
+ "access": ["label"],
383
+ "expires_in": 600,
384
+ },
385
+ format="json",
386
+ )
387
+
388
+ self.assertEqual(response.status_code, 201)
389
+
390
+ def test_response_has_no_cache_headers(self):
391
+ """Test that token response includes cache prevention headers."""
392
+ response = self.client.post(
393
+ "/api/tokens",
394
+ {
395
+ "resource_type": "shipment",
396
+ "resource_ids": ["shp_123"],
397
+ "access": ["label"],
398
+ },
399
+ format="json",
400
+ )
401
+
402
+ self.assertEqual(response.status_code, 201)
403
+ self.assertDictEqual(
404
+ {
405
+ "cache_control": response.get("Cache-Control"),
406
+ "cdn_cache_control": response.get("CDN-Cache-Control"),
407
+ },
408
+ {
409
+ "cache_control": "no-store",
410
+ "cdn_cache_control": "no-store",
411
+ },
412
+ )
413
+
414
+
415
+ class TestDocumentDownloadWithAPIToken(KarrioAPITestCase):
416
+ """Test document download endpoints with API Token authentication.
417
+
418
+ These tests verify that document download endpoints accept API Token
419
+ authentication as an alternative to resource access tokens.
420
+ """
421
+
422
+ def setUp(self):
423
+ super().setUp()
424
+ from karrio.server.manager.models import Shipment, Address, Parcel, Manifest
425
+
426
+ # Create test addresses
427
+ self.shipper = Address.objects.create(
428
+ postal_code="E1C4Z8",
429
+ city="Moncton",
430
+ person_name="John Doe",
431
+ country_code="CA",
432
+ state_code="NB",
433
+ address_line1="125 Church St",
434
+ created_by=self.user,
435
+ )
436
+ self.recipient = Address.objects.create(
437
+ postal_code="V6M2V9",
438
+ city="Vancouver",
439
+ person_name="Jane Doe",
440
+ country_code="CA",
441
+ state_code="BC",
442
+ address_line1="5840 Oak St",
443
+ created_by=self.user,
444
+ )
445
+
446
+ # Create test parcel
447
+ self.parcel = Parcel.objects.create(
448
+ weight=1.0,
449
+ weight_unit="KG",
450
+ created_by=self.user,
451
+ )
452
+
453
+ # Create test shipment with label
454
+ self.shipment = Shipment.objects.create(
455
+ shipper=self.shipper,
456
+ recipient=self.recipient,
457
+ created_by=self.user,
458
+ test_mode=True,
459
+ status="purchased",
460
+ tracking_number="TEST123456",
461
+ label="JVBERi0xLjQKMSAwIG9iago8PAovVGl0bGUgKP7/AFQAZQBzAHQpCj4+CmVuZG9iagoyIDAgb2JqCjw8Cj4+CmVuZG9iagozIDAgb2JqCjw8Cj4+CmVuZG9iagp4cmVmCjAgNAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMTUgMDAwMDAgbiAKMDAwMDAwMDA2OCAwMDAwMCBuIAowMDAwMDAwMDg5IDAwMDAwIG4gCnRyYWlsZXIKPDwKL1NpemUgNAo+PgpzdGFydHhyZWYKMTEwCiUlRU9GCg==", # Base64 encoded minimal PDF
462
+ label_type="PDF",
463
+ )
464
+ self.shipment.parcels.set([self.parcel])
465
+
466
+ # Create address for manifest
467
+ self.manifest_address = Address.objects.create(
468
+ postal_code="E1C4Z8",
469
+ city="Moncton",
470
+ person_name="Manifest Address",
471
+ country_code="CA",
472
+ state_code="NB",
473
+ address_line1="125 Church St",
474
+ created_by=self.user,
475
+ )
476
+
477
+ # Create test manifest with document
478
+ self.manifest = Manifest.objects.create(
479
+ created_by=self.user,
480
+ test_mode=True,
481
+ address=self.manifest_address,
482
+ manifest="JVBERi0xLjQKMSAwIG9iago8PAovVGl0bGUgKP7/AFQAZQBzAHQpCj4+CmVuZG9iagoyIDAgb2JqCjw8Cj4+CmVuZG9iagozIDAgb2JqCjw8Cj4+CmVuZG9iagp4cmVmCjAgNAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMTUgMDAwMDAgbiAKMDAwMDAwMDA2OCAwMDAwMCBuIAowMDAwMDAwMDg5IDAwMDAwIG4gCnRyYWlsZXIKPDwKL1NpemUgNAo+PgpzdGFydHhyZWYKMTEwCiUlRU9GCg==", # Base64 encoded minimal PDF
483
+ manifest_carrier=self.carrier,
484
+ )
485
+
486
+ def test_shipment_label_download_with_api_token(self):
487
+ """Test that shipment label can be downloaded with API Token auth."""
488
+ # Request with API Token (no resource token needed)
489
+ response = self.client.get(
490
+ f"/v1/shipments/{self.shipment.pk}/label.pdf",
491
+ )
492
+
493
+ self.assertEqual(response.status_code, 200)
494
+ self.assertTrue(response.get("Content-Type", "").startswith("application/pdf"))
495
+
496
+ def test_shipment_label_download_without_auth_fails(self):
497
+ """Test that shipment label download fails without any authentication."""
498
+ # Clear credentials
499
+ self.client.credentials()
500
+
501
+ response = self.client.get(
502
+ f"/v1/shipments/{self.shipment.pk}/label.pdf",
503
+ )
504
+
505
+ self.assertEqual(response.status_code, 403)
506
+
507
+ def test_shipment_label_download_with_resource_token(self):
508
+ """Test that shipment label download still works with resource token."""
509
+ # Clear API credentials
510
+ self.client.credentials()
511
+
512
+ # Generate resource token first (re-authenticate for this call)
513
+ self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}")
514
+ token_response = self.client.post(
515
+ "/api/tokens",
516
+ {
517
+ "resource_type": "shipment",
518
+ "resource_ids": [self.shipment.pk],
519
+ "access": ["label"],
520
+ "format": "pdf",
521
+ },
522
+ format="json",
523
+ )
524
+ self.assertEqual(token_response.status_code, 201)
525
+ resource_token = token_response.data["token"]
526
+
527
+ # Clear credentials again and use resource token
528
+ self.client.credentials()
529
+ response = self.client.get(
530
+ f"/v1/shipments/{self.shipment.pk}/label.pdf?token={resource_token}",
531
+ )
532
+
533
+ self.assertEqual(response.status_code, 200)
534
+ self.assertTrue(response.get("Content-Type", "").startswith("application/pdf"))
535
+
536
+ def test_manifest_download_with_api_token(self):
537
+ """Test that manifest document can be downloaded with API Token auth."""
538
+ response = self.client.get(
539
+ f"/v1/manifests/{self.manifest.pk}/manifest.pdf",
540
+ )
541
+
542
+ self.assertEqual(response.status_code, 200)
543
+ self.assertTrue(response.get("Content-Type", "").startswith("application/pdf"))
544
+
545
+ def test_manifest_download_without_auth_fails(self):
546
+ """Test that manifest download fails without any authentication."""
547
+ self.client.credentials()
548
+
549
+ response = self.client.get(
550
+ f"/v1/manifests/{self.manifest.pk}/manifest.pdf",
551
+ )
552
+
553
+ self.assertEqual(response.status_code, 403)
554
+
555
+ def test_manifest_download_with_resource_token(self):
556
+ """Test that manifest download still works with resource token."""
557
+ # Clear API credentials
558
+ self.client.credentials()
559
+
560
+ # Generate resource token first
561
+ self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}")
562
+ token_response = self.client.post(
563
+ "/api/tokens",
564
+ {
565
+ "resource_type": "manifest",
566
+ "resource_ids": [self.manifest.pk],
567
+ "access": ["manifest"],
568
+ },
569
+ format="json",
570
+ )
571
+ self.assertEqual(token_response.status_code, 201)
572
+ resource_token = token_response.data["token"]
573
+
574
+ # Clear credentials and use resource token
575
+ self.client.credentials()
576
+ response = self.client.get(
577
+ f"/v1/manifests/{self.manifest.pk}/manifest.pdf?token={resource_token}",
578
+ )
579
+
580
+ self.assertEqual(response.status_code, 200)
581
+ self.assertTrue(response.get("Content-Type", "").startswith("application/pdf"))
582
+
583
+ def test_batch_labels_without_auth_fails(self):
584
+ """Test that batch labels endpoint requires authentication."""
585
+ # Clear credentials to test unauthenticated access
586
+ self.client.credentials()
587
+
588
+ response = self.client.get(
589
+ f"/documents/shipments/label.pdf?shipments={self.shipment.pk}",
590
+ )
591
+
592
+ # Should return 403 (Forbidden) when no auth is provided
593
+ self.assertEqual(response.status_code, 403)