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,895 @@
1
+ """Tests for carrier connection OAuth and Webhook APIs."""
2
+
3
+ import json
4
+ from unittest.mock import patch, ANY
5
+ from django.urls import reverse
6
+ from rest_framework import status
7
+ from karrio.core.models import (
8
+ OAuthAuthorizeRequest,
9
+ WebhookRegistrationDetails,
10
+ ConfirmationDetails,
11
+ Message,
12
+ )
13
+ from karrio.server.core.tests import APITestCase
14
+ import karrio.server.providers.models as providers
15
+
16
+
17
+ class TestConnectionOAuthAuthorize(APITestCase):
18
+ """Tests for OAuth authorization flow endpoints."""
19
+
20
+ def test_oauth_authorize(self):
21
+ """Test POST /v1/connections/oauth/{carrier_name}/authorize."""
22
+ url = reverse(
23
+ "karrio.server.providers:connection-oauth-authorize",
24
+ kwargs=dict(carrier_name="teleship"),
25
+ )
26
+ data = OAUTH_AUTHORIZE_DATA
27
+
28
+ with patch("karrio.server.core.gateway.utils.identity") as mock:
29
+ mock.return_value = OAUTH_AUTHORIZE_RETURNED_VALUE
30
+ response = self.client.post(url, data)
31
+ response_data = json.loads(response.content)
32
+
33
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
34
+ self.assertDictEqual(response_data, OAUTH_AUTHORIZE_RESPONSE)
35
+
36
+ def test_oauth_authorize_error(self):
37
+ """Test OAuth authorize with configuration error."""
38
+ url = reverse(
39
+ "karrio.server.providers:connection-oauth-authorize",
40
+ kwargs=dict(carrier_name="teleship"),
41
+ )
42
+ data = {"state": "test_state", "options": {}}
43
+
44
+ with patch("karrio.server.core.gateway.utils.identity") as mock:
45
+ mock.return_value = OAUTH_AUTHORIZE_ERROR_RETURNED_VALUE
46
+ response = self.client.post(url, data)
47
+ response_data = json.loads(response.content)
48
+
49
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
50
+ self.assertDictEqual(response_data, OAUTH_AUTHORIZE_ERROR_RESPONSE)
51
+
52
+
53
+ class TestConnectionOAuthCallback(APITestCase):
54
+ """Tests for OAuth callback handling."""
55
+
56
+ def test_oauth_callback_get(self):
57
+ """Test GET /v1/connections/oauth/{carrier_name}/callback with valid credentials."""
58
+ url = reverse(
59
+ "karrio.server.providers:connection-oauth-callback",
60
+ kwargs=dict(carrier_name="teleship"),
61
+ )
62
+ query_params = (
63
+ "?code=auth_code_12345"
64
+ "&account_client_id=user_client_abc"
65
+ "&account_client_secret=user_secret_xyz"
66
+ "&state=eyJjb25uZWN0aW9uX2lkIjogIjEyMyJ9"
67
+ )
68
+
69
+ with patch("karrio.server.core.gateway.utils.identity") as mock:
70
+ mock.return_value = OAUTH_CALLBACK_RETURNED_VALUE
71
+ response = self.client.get(f"{url}{query_params}")
72
+ response_data = json.loads(response.content)
73
+
74
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
75
+ self.assertDictEqual(response_data, OAUTH_CALLBACK_RESPONSE)
76
+
77
+ def test_oauth_callback_post(self):
78
+ """Test POST /v1/connections/oauth/{carrier_name}/callback with valid credentials."""
79
+ url = reverse(
80
+ "karrio.server.providers:connection-oauth-callback",
81
+ kwargs=dict(carrier_name="teleship"),
82
+ )
83
+ query_params = (
84
+ "?code=auth_code_12345"
85
+ "&account_client_id=user_client_abc"
86
+ "&account_client_secret=user_secret_xyz"
87
+ "&state=eyJjb25uZWN0aW9uX2lkIjogIjEyMyJ9"
88
+ )
89
+
90
+ with patch("karrio.server.core.gateway.utils.identity") as mock:
91
+ mock.return_value = OAUTH_CALLBACK_RETURNED_VALUE
92
+ response = self.client.post(f"{url}{query_params}", {}, format="json")
93
+ response_data = json.loads(response.content)
94
+
95
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
96
+ self.assertDictEqual(response_data, OAUTH_CALLBACK_RESPONSE)
97
+
98
+ def test_oauth_callback_error(self):
99
+ """Test OAuth callback with missing authorization code."""
100
+ url = reverse(
101
+ "karrio.server.providers:connection-oauth-callback",
102
+ kwargs=dict(carrier_name="teleship"),
103
+ )
104
+ query_params = (
105
+ "?account_client_id=user_client_abc"
106
+ "&account_client_secret=user_secret_xyz"
107
+ )
108
+
109
+ with patch("karrio.server.core.gateway.utils.identity") as mock:
110
+ mock.return_value = OAUTH_CALLBACK_ERROR_RETURNED_VALUE
111
+ response = self.client.get(f"{url}{query_params}")
112
+ response_data = json.loads(response.content)
113
+
114
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
115
+ self.assertDictEqual(response_data, OAUTH_CALLBACK_ERROR_RESPONSE)
116
+
117
+
118
+ class TestConnectionWebhookRegister(APITestCase):
119
+ """Tests for webhook registration."""
120
+
121
+ def setUp(self):
122
+ super().setUp()
123
+ url = reverse("karrio.server.providers:carrier-connection-list")
124
+ response = self.client.post(url, TELESHIP_CONNECTION_DATA, format="json")
125
+ self.teleship_carrier_pk = json.loads(response.content)["id"]
126
+
127
+ def test_webhook_register(self):
128
+ """Test POST /v1/connections/webhook/{pk}/register."""
129
+ url = reverse(
130
+ "karrio.server.providers:connection-webhook-register",
131
+ kwargs=dict(pk=self.teleship_carrier_pk),
132
+ )
133
+ data = WEBHOOK_REGISTER_DATA
134
+
135
+ with patch("karrio.server.core.gateway.utils.identity") as mock:
136
+ mock.return_value = WEBHOOK_REGISTER_RETURNED_VALUE
137
+ response = self.client.post(url, data)
138
+ response_data = json.loads(response.content)
139
+
140
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
141
+ self.assertDictEqual(response_data, WEBHOOK_REGISTER_RESPONSE)
142
+
143
+
144
+ class TestConnectionWebhookDeregister(APITestCase):
145
+ """Tests for webhook deregistration."""
146
+
147
+ def setUp(self):
148
+ super().setUp()
149
+ url = reverse("karrio.server.providers:carrier-connection-list")
150
+ response = self.client.post(
151
+ url, TELESHIP_CONNECTION_WITH_WEBHOOK_DATA, format="json"
152
+ )
153
+ self.teleship_carrier_pk = json.loads(response.content)["id"]
154
+
155
+ def test_webhook_deregister(self):
156
+ """Test POST /v1/connections/webhook/{pk}/deregister."""
157
+ url = reverse(
158
+ "karrio.server.providers:connection-webhook-deregister",
159
+ kwargs=dict(pk=self.teleship_carrier_pk),
160
+ )
161
+
162
+ with patch("karrio.server.core.gateway.utils.identity") as mock:
163
+ mock.return_value = WEBHOOK_DEREGISTER_RETURNED_VALUE
164
+ response = self.client.post(url, {})
165
+ response_data = json.loads(response.content)
166
+
167
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
168
+ self.assertDictEqual(response_data, WEBHOOK_DEREGISTER_RESPONSE)
169
+
170
+
171
+ class TestConnectionWebhookDisconnect(APITestCase):
172
+ """Tests for force disconnecting webhook (local only)."""
173
+
174
+ def setUp(self):
175
+ super().setUp()
176
+ url = reverse("karrio.server.providers:carrier-connection-list")
177
+ response = self.client.post(
178
+ url, TELESHIP_CONNECTION_WITH_WEBHOOK_DATA, format="json"
179
+ )
180
+ self.teleship_carrier_pk = json.loads(response.content)["id"]
181
+
182
+ def test_webhook_disconnect(self):
183
+ """Test POST /v1/connections/webhook/{pk}/disconnect."""
184
+ url = reverse(
185
+ "karrio.server.providers:connection-webhook-disconnect",
186
+ kwargs=dict(pk=self.teleship_carrier_pk),
187
+ )
188
+
189
+ response = self.client.post(url, {})
190
+ response_data = json.loads(response.content)
191
+
192
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
193
+ self.assertDictEqual(response_data, WEBHOOK_DISCONNECT_RESPONSE)
194
+
195
+ # Verify the webhook config was cleared
196
+ detail_url = reverse(
197
+ "karrio.server.providers:carrier-connection-details",
198
+ kwargs=dict(pk=self.teleship_carrier_pk),
199
+ )
200
+ detail_response = self.client.get(detail_url)
201
+ detail_data = json.loads(detail_response.content)
202
+ self.assertIsNone(detail_data["config"].get("webhook_id"))
203
+ self.assertIsNone(detail_data["config"].get("webhook_secret"))
204
+
205
+
206
+ class TestConnectionWebhookEvent(APITestCase):
207
+ """Tests for inbound webhook event processing."""
208
+
209
+ def setUp(self):
210
+ super().setUp()
211
+ url = reverse("karrio.server.providers:carrier-connection-list")
212
+ response = self.client.post(
213
+ url, TELESHIP_CONNECTION_WITH_WEBHOOK_DATA, format="json"
214
+ )
215
+ self.teleship_carrier_pk = json.loads(response.content)["id"]
216
+
217
+ def test_webhook_event(self):
218
+ """Test POST /v1/connections/webhook/{pk}/events with valid event."""
219
+ url = reverse(
220
+ "karrio.server.providers:connection-webhook-event",
221
+ kwargs=dict(pk=self.teleship_carrier_pk),
222
+ )
223
+
224
+ from karrio.server.core import datatypes
225
+
226
+ with patch("karrio.server.core.gateway.utils.identity") as mock:
227
+ mock.return_value = (
228
+ datatypes.WebhookEventDetails(
229
+ carrier_name="teleship",
230
+ carrier_id="teleship_connection",
231
+ tracking_number="TELESHIP12345678901",
232
+ shipment_identifier="shp-12345",
233
+ tracking=None,
234
+ ),
235
+ [],
236
+ )
237
+ client = self.client_class()
238
+ response = client.post(url, WEBHOOK_EVENT_DATA, format="json")
239
+
240
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
241
+
242
+ def test_webhook_event_not_found(self):
243
+ """Test webhook event with non-existent connection."""
244
+ url = reverse(
245
+ "karrio.server.providers:connection-webhook-event",
246
+ kwargs=dict(pk="non-existent-pk"),
247
+ )
248
+
249
+ client = self.client_class()
250
+ response = client.post(url, WEBHOOK_EVENT_DATA, format="json")
251
+ response_data = json.loads(response.content)
252
+
253
+ self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
254
+ self.assertDictEqual(response_data, WEBHOOK_EVENT_NOT_FOUND_RESPONSE)
255
+
256
+ def test_webhook_event_signature_error(self):
257
+ """Test webhook event with invalid signature."""
258
+ url = reverse(
259
+ "karrio.server.providers:connection-webhook-event",
260
+ kwargs=dict(pk=self.teleship_carrier_pk),
261
+ )
262
+
263
+ from karrio.server.core import datatypes
264
+
265
+ with patch("karrio.server.core.gateway.utils.identity") as mock:
266
+ mock.return_value = (
267
+ None,
268
+ [
269
+ datatypes.Message(
270
+ carrier_name="teleship",
271
+ carrier_id="teleship_connection",
272
+ code="SIGNATURE_INVALID",
273
+ message="Webhook signature verification failed",
274
+ )
275
+ ],
276
+ )
277
+ client = self.client_class()
278
+ response = client.post(
279
+ url,
280
+ WEBHOOK_EVENT_DATA,
281
+ format="json",
282
+ HTTP_X_TELESHIP_SIGNATURE="invalid_signature",
283
+ )
284
+ response_data = json.loads(response.content)
285
+
286
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
287
+ self.assertDictEqual(response_data, WEBHOOK_EVENT_SIGNATURE_ERROR_RESPONSE)
288
+
289
+
290
+ class TestConnectionList(APITestCase):
291
+ """Tests for listing carrier connections (GET /v1/connections)."""
292
+
293
+ def setUp(self):
294
+ super().setUp()
295
+ self.system_carrier = providers.Carrier.objects.create(
296
+ carrier_code="usps",
297
+ carrier_id="usps_system",
298
+ test_mode=True,
299
+ active=True,
300
+ is_system=True,
301
+ created_by=None,
302
+ credentials=dict(
303
+ client_id="system_client",
304
+ client_secret="system_secret",
305
+ ),
306
+ )
307
+ # Grant the test user access to the system carrier
308
+ self.system_carrier.active_users.add(self.user)
309
+
310
+ def test_list_connections(self):
311
+ """Test GET /v1/connections returns user and system connections."""
312
+ url = reverse("karrio.server.providers:carrier-connection-list")
313
+
314
+ response = self.client.get(url)
315
+ response_data = json.loads(response.content)
316
+
317
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
318
+ self.assertIn("results", response_data)
319
+ carrier_ids = [c["carrier_id"] for c in response_data["results"]]
320
+ self.assertIn("canadapost", carrier_ids)
321
+ self.assertIn("ups_package", carrier_ids)
322
+ self.assertIn("fedex_express", carrier_ids)
323
+ self.assertIn("usps_system", carrier_ids)
324
+
325
+ def test_list_connections_filter_by_carrier_name(self):
326
+ """Test filtering connections by carrier_name."""
327
+ url = reverse("karrio.server.providers:carrier-connection-list")
328
+
329
+ response = self.client.get(f"{url}?carrier_name=canadapost")
330
+ response_data = json.loads(response.content)
331
+
332
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
333
+ self.assertEqual(len(response_data["results"]), 1)
334
+ self.assertEqual(response_data["results"][0]["carrier_name"], "canadapost")
335
+
336
+ def test_list_connections_filter_by_active(self):
337
+ """Test filtering connections by active status."""
338
+ list_url = reverse("karrio.server.providers:carrier-connection-list")
339
+ self.client.post(list_url, INACTIVE_CONNECTION_DATA, format="json")
340
+
341
+ response = self.client.get(f"{list_url}?active=true")
342
+ response_data = json.loads(response.content)
343
+
344
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
345
+ for connection in response_data["results"]:
346
+ self.assertTrue(connection["active"])
347
+
348
+ def test_system_connections_hide_credentials(self):
349
+ """Test that system connections don't expose credentials."""
350
+ url = reverse("karrio.server.providers:carrier-connection-list")
351
+
352
+ response = self.client.get(url)
353
+ response_data = json.loads(response.content)
354
+
355
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
356
+ for connection in response_data["results"]:
357
+ if connection.get("is_system"):
358
+ self.assertIsNone(connection.get("credentials"))
359
+
360
+ def test_user_connections_show_credentials(self):
361
+ """Test that user connections expose credentials to their owner."""
362
+ url = reverse("karrio.server.providers:carrier-connection-list")
363
+
364
+ response = self.client.get(url)
365
+ response_data = json.loads(response.content)
366
+
367
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
368
+ for connection in response_data["results"]:
369
+ if not connection.get("is_system"):
370
+ self.assertIsNotNone(connection.get("credentials"))
371
+
372
+
373
+ class TestConnectionCreate(APITestCase):
374
+ """Tests for creating carrier connections (POST /v1/connections)."""
375
+
376
+ def test_create_connection(self):
377
+ """Test POST /v1/connections creates a new connection."""
378
+ url = reverse("karrio.server.providers:carrier-connection-list")
379
+
380
+ response = self.client.post(url, SENDLE_CONNECTION_DATA, format="json")
381
+ response_data = json.loads(response.content)
382
+
383
+ self.assertEqual(response.status_code, status.HTTP_201_CREATED)
384
+ self.assertDictEqual(response_data, SENDLE_CONNECTION_RESPONSE)
385
+
386
+ def test_create_connection_with_config(self):
387
+ """Test creating connection with additional config."""
388
+ url = reverse("karrio.server.providers:carrier-connection-list")
389
+
390
+ response = self.client.post(
391
+ url, SENDLE_CONNECTION_WITH_CONFIG_DATA, format="json"
392
+ )
393
+ response_data = json.loads(response.content)
394
+
395
+ self.assertEqual(response.status_code, status.HTTP_201_CREATED)
396
+ self.assertDictEqual(response_data, SENDLE_CONNECTION_WITH_CONFIG_RESPONSE)
397
+
398
+ def test_create_connection_invalid_carrier(self):
399
+ """Test creating connection with invalid carrier name."""
400
+ url = reverse("karrio.server.providers:carrier-connection-list")
401
+
402
+ response = self.client.post(url, INVALID_CONNECTION_DATA, format="json")
403
+
404
+ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
405
+
406
+
407
+ class TestConnectionDetail(APITestCase):
408
+ """Tests for retrieving, updating, and deleting carrier connections."""
409
+
410
+ def test_retrieve_connection(self):
411
+ """Test GET /v1/connections/{pk} retrieves connection details."""
412
+ url = reverse(
413
+ "karrio.server.providers:carrier-connection-details",
414
+ kwargs=dict(pk=self.carrier.pk),
415
+ )
416
+
417
+ response = self.client.get(url)
418
+ response_data = json.loads(response.content)
419
+
420
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
421
+ self.assertEqual(response_data["carrier_name"], "canadapost")
422
+ self.assertEqual(response_data["carrier_id"], "canadapost")
423
+
424
+ def test_update_connection(self):
425
+ """Test PATCH /v1/connections/{pk} updates connection."""
426
+ url = reverse(
427
+ "karrio.server.providers:carrier-connection-details",
428
+ kwargs=dict(pk=self.carrier.pk),
429
+ )
430
+
431
+ response = self.client.patch(url, CONNECTION_UPDATE_DATA, format="json")
432
+ response_data = json.loads(response.content)
433
+
434
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
435
+ self.assertEqual(response_data["carrier_id"], "canadapost_updated")
436
+ self.assertFalse(response_data["active"])
437
+
438
+ def test_update_connection_credentials(self):
439
+ """Test updating connection credentials."""
440
+ url = reverse(
441
+ "karrio.server.providers:carrier-connection-details",
442
+ kwargs=dict(pk=self.carrier.pk),
443
+ )
444
+
445
+ response = self.client.patch(url, CREDENTIALS_UPDATE_DATA, format="json")
446
+ response_data = json.loads(response.content)
447
+
448
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
449
+ self.assertEqual(response_data["credentials"]["username"], "new_username")
450
+ self.assertEqual(response_data["credentials"]["password"], "new_password")
451
+
452
+ def test_delete_connection(self):
453
+ """Test DELETE /v1/connections/{pk} removes connection."""
454
+ list_url = reverse("karrio.server.providers:carrier-connection-list")
455
+ create_response = self.client.post(
456
+ list_url, SENDLE_TO_DELETE_DATA, format="json"
457
+ )
458
+ connection_pk = json.loads(create_response.content)["id"]
459
+
460
+ url = reverse(
461
+ "karrio.server.providers:carrier-connection-details",
462
+ kwargs=dict(pk=connection_pk),
463
+ )
464
+
465
+ response = self.client.delete(url)
466
+
467
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
468
+ get_response = self.client.get(url)
469
+ self.assertEqual(get_response.status_code, status.HTTP_404_NOT_FOUND)
470
+
471
+ def test_superuser_can_delete_any_connection(self):
472
+ """Test that superuser can delete any connection."""
473
+ from django.contrib.auth import get_user_model
474
+
475
+ other_user = get_user_model().objects.create_user(
476
+ "other@example.com", "password456"
477
+ )
478
+ other_connection = providers.Carrier.objects.create(
479
+ carrier_code="sendle",
480
+ carrier_id="other_user_sendle",
481
+ test_mode=True,
482
+ active=True,
483
+ created_by=other_user,
484
+ credentials=dict(sendle_id="test", api_key="test"),
485
+ )
486
+ url = reverse(
487
+ "karrio.server.providers:carrier-connection-details",
488
+ kwargs=dict(pk=other_connection.pk),
489
+ )
490
+
491
+ response = self.client.delete(url)
492
+
493
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
494
+ self.assertFalse(
495
+ providers.Carrier.objects.filter(pk=other_connection.pk).exists()
496
+ )
497
+
498
+
499
+ class TestConnectionPagination(APITestCase):
500
+ """Tests for connection list pagination."""
501
+
502
+ def setUp(self):
503
+ super().setUp()
504
+ list_url = reverse("karrio.server.providers:carrier-connection-list")
505
+ for i in range(25):
506
+ self.client.post(
507
+ list_url,
508
+ {
509
+ "carrier_name": "sendle",
510
+ "carrier_id": f"sendle_paginated_{i}",
511
+ "credentials": {"sendle_id": f"test_{i}", "api_key": f"key_{i}"},
512
+ },
513
+ format="json",
514
+ )
515
+
516
+ def test_default_pagination(self):
517
+ """Test that connections are paginated by default."""
518
+ url = reverse("karrio.server.providers:carrier-connection-list")
519
+
520
+ response = self.client.get(url)
521
+ response_data = json.loads(response.content)
522
+
523
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
524
+ self.assertIn("count", response_data)
525
+ self.assertIn("results", response_data)
526
+ self.assertLessEqual(len(response_data["results"]), 20)
527
+
528
+ def test_custom_limit(self):
529
+ """Test custom pagination limit."""
530
+ url = reverse("karrio.server.providers:carrier-connection-list")
531
+
532
+ response = self.client.get(f"{url}?limit=5")
533
+ response_data = json.loads(response.content)
534
+
535
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
536
+ self.assertEqual(len(response_data["results"]), 5)
537
+
538
+ def test_pagination_offset(self):
539
+ """Test pagination with offset."""
540
+ url = reverse("karrio.server.providers:carrier-connection-list")
541
+
542
+ response1 = self.client.get(f"{url}?limit=5")
543
+ data1 = json.loads(response1.content)
544
+
545
+ response2 = self.client.get(f"{url}?limit=5&offset=5")
546
+ data2 = json.loads(response2.content)
547
+
548
+ ids1 = {c["id"] for c in data1["results"]}
549
+ ids2 = {c["id"] for c in data2["results"]}
550
+ self.assertEqual(len(ids1.intersection(ids2)), 0)
551
+
552
+
553
+ if __name__ == "__main__":
554
+ import unittest
555
+
556
+ unittest.main()
557
+
558
+
559
+ # =============================================================================
560
+ # TEST FIXTURES
561
+ # =============================================================================
562
+
563
+ # OAuth Authorize
564
+ OAUTH_AUTHORIZE_DATA = {
565
+ "state": "eyJjb25uZWN0aW9uX2lkIjogIjEyMyJ9",
566
+ "options": {"scope": "read_accounts"},
567
+ "frontend_url": "https://app.karrio.io/oauth/callback",
568
+ }
569
+
570
+ OAUTH_AUTHORIZE_RETURNED_VALUE = (
571
+ OAuthAuthorizeRequest(
572
+ carrier_name="teleship",
573
+ authorization_url="https://api.teleship.com/oauth/authorize?clientId=test&redirectUri=https%3A%2F%2Fapi.karrio.io%2Fv1%2Fconnections%2Foauth%2Fteleship%2Fcallback&state=eyJjb25uZWN0aW9uX2lkIjogIjEyMyJ9",
574
+ meta={"scope": "read_accounts"},
575
+ ),
576
+ [],
577
+ )
578
+
579
+ OAUTH_AUTHORIZE_RESPONSE = {
580
+ "operation": "OAuth authorize",
581
+ "request": {
582
+ "carrier_name": "teleship",
583
+ "authorization_url": "https://api.teleship.com/oauth/authorize?clientId=test&redirectUri=https%3A%2F%2Fapi.karrio.io%2Fv1%2Fconnections%2Foauth%2Fteleship%2Fcallback&state=eyJjb25uZWN0aW9uX2lkIjogIjEyMyJ9",
584
+ "meta": {"scope": "read_accounts"},
585
+ },
586
+ "frontend_url": "https://app.karrio.io/oauth/callback",
587
+ "messages": [],
588
+ }
589
+
590
+ OAUTH_AUTHORIZE_ERROR_RETURNED_VALUE = (
591
+ OAuthAuthorizeRequest(
592
+ carrier_name="teleship",
593
+ authorization_url="",
594
+ meta={},
595
+ ),
596
+ [
597
+ Message(
598
+ carrier_name="teleship",
599
+ carrier_id="teleship",
600
+ code="OAUTH_CONFIG_ERROR",
601
+ message="OAuth not configured. Please set TELESHIP_OAUTH_CLIENT_ID environment variable.",
602
+ )
603
+ ],
604
+ )
605
+
606
+ OAUTH_AUTHORIZE_ERROR_RESPONSE = {
607
+ "operation": "OAuth authorize",
608
+ "request": {
609
+ "carrier_name": "teleship",
610
+ "meta": {},
611
+ },
612
+ "frontend_url": None,
613
+ "messages": [
614
+ {
615
+ "carrier_name": "teleship",
616
+ "carrier_id": "teleship",
617
+ "code": "OAUTH_CONFIG_ERROR",
618
+ "message": "OAuth not configured. Please set TELESHIP_OAUTH_CLIENT_ID environment variable.",
619
+ }
620
+ ],
621
+ }
622
+
623
+ # OAuth Callback
624
+ OAUTH_CALLBACK_RETURNED_VALUE = (
625
+ {"client_id": "user_client_abc", "client_secret": "user_secret_xyz"},
626
+ [],
627
+ )
628
+
629
+ OAUTH_CALLBACK_RESPONSE = {
630
+ "type": "oauth_callback",
631
+ "success": True,
632
+ "carrier_name": "teleship",
633
+ "credentials": {"client_id": "user_client_abc", "client_secret": "user_secret_xyz"},
634
+ "state": "eyJjb25uZWN0aW9uX2lkIjogIjEyMyJ9",
635
+ "messages": [],
636
+ }
637
+
638
+ OAUTH_CALLBACK_ERROR_RETURNED_VALUE = (
639
+ None,
640
+ [
641
+ Message(
642
+ carrier_name="teleship",
643
+ carrier_id="teleship",
644
+ code="OAUTH_CALLBACK_ERROR",
645
+ message="Missing authorization code in callback",
646
+ )
647
+ ],
648
+ )
649
+
650
+ OAUTH_CALLBACK_ERROR_RESPONSE = {
651
+ "type": "oauth_callback",
652
+ "success": False,
653
+ "carrier_name": "teleship",
654
+ "credentials": None,
655
+ "state": None,
656
+ "messages": [
657
+ {
658
+ "carrier_name": "teleship",
659
+ "carrier_id": "teleship",
660
+ "code": "OAUTH_CALLBACK_ERROR",
661
+ "message": "Missing authorization code in callback",
662
+ }
663
+ ],
664
+ }
665
+
666
+ # Teleship Connection Data
667
+ TELESHIP_CONNECTION_DATA = {
668
+ "carrier_name": "teleship",
669
+ "carrier_id": "teleship_connection",
670
+ "credentials": {
671
+ "client_id": "test_client",
672
+ "client_secret": "test_secret",
673
+ },
674
+ }
675
+
676
+ TELESHIP_CONNECTION_WITH_WEBHOOK_DATA = {
677
+ "carrier_name": "teleship",
678
+ "carrier_id": "teleship_connection",
679
+ "credentials": {
680
+ "client_id": "test_client",
681
+ "client_secret": "test_secret",
682
+ },
683
+ "config": {
684
+ "webhook_id": "WHK-12345",
685
+ "webhook_secret": "whsec_abc123xyz789",
686
+ "webhook_url": "https://api.karrio.io/v1/connections/webhook/test/events",
687
+ },
688
+ }
689
+
690
+ # Webhook Register
691
+ WEBHOOK_REGISTER_DATA = {
692
+ "webhook_url": "https://api.karrio.io/v1/connections/webhook/test/events",
693
+ "description": "Karrio webhook for tracking updates",
694
+ }
695
+
696
+ WEBHOOK_REGISTER_RETURNED_VALUE = (
697
+ WebhookRegistrationDetails(
698
+ carrier_id="teleship_connection",
699
+ carrier_name="teleship",
700
+ webhook_identifier="WHK-12345",
701
+ secret="whsec_abc123xyz789",
702
+ meta={
703
+ "url": "https://api.karrio.io/v1/connections/webhook/test/events",
704
+ "enabledEvents": ["shipment.created", "shipment.updated"],
705
+ },
706
+ ),
707
+ [],
708
+ )
709
+
710
+ WEBHOOK_REGISTER_RESPONSE = {
711
+ "operation": "Webhook registration",
712
+ "success": True,
713
+ "carrier_name": "teleship",
714
+ "carrier_id": "teleship_connection",
715
+ }
716
+
717
+ # Webhook Deregister
718
+ WEBHOOK_DEREGISTER_RETURNED_VALUE = (
719
+ ConfirmationDetails(
720
+ carrier_id="teleship_connection",
721
+ carrier_name="teleship",
722
+ success=True,
723
+ operation="webhook_deregistration",
724
+ ),
725
+ [],
726
+ )
727
+
728
+ WEBHOOK_DEREGISTER_RESPONSE = {
729
+ "operation": "Webhook deregistration",
730
+ "success": True,
731
+ "carrier_name": "teleship",
732
+ "carrier_id": "teleship_connection",
733
+ }
734
+
735
+ # Webhook Disconnect
736
+ WEBHOOK_DISCONNECT_RESPONSE = {
737
+ "operation": "Webhook disconnect",
738
+ "success": True,
739
+ "carrier_name": "teleship",
740
+ "carrier_id": ANY,
741
+ }
742
+
743
+ # Webhook Event
744
+ WEBHOOK_EVENT_DATA = {
745
+ "eventName": "shipment.updated",
746
+ "objectType": "shipment",
747
+ "objectId": "shp-12345",
748
+ "data": {
749
+ "trackingNumber": "TELESHIP12345678901",
750
+ "shipmentId": "shp-12345",
751
+ "events": [
752
+ {
753
+ "timestamp": "2025-01-17T14:30:00.000Z",
754
+ "code": "IN_TRANSIT",
755
+ "description": "Package in transit",
756
+ "location": "Chicago, IL",
757
+ }
758
+ ],
759
+ },
760
+ }
761
+
762
+
763
+ WEBHOOK_EVENT_NOT_FOUND_RESPONSE = {
764
+ "operation": "Webhook event",
765
+ "success": False,
766
+ "messages": [
767
+ {
768
+ "message": "Connection not found: non-existent-pk",
769
+ }
770
+ ],
771
+ }
772
+
773
+
774
+ WEBHOOK_EVENT_SIGNATURE_ERROR_RESPONSE = {
775
+ "operation": "Webhook event",
776
+ "success": False,
777
+ "carrier_name": "teleship",
778
+ "carrier_id": "teleship_connection",
779
+ "messages": [
780
+ {
781
+ "carrier_name": "teleship",
782
+ "carrier_id": "teleship_connection",
783
+ "code": "SIGNATURE_INVALID",
784
+ "message": "Webhook signature verification failed",
785
+ }
786
+ ],
787
+ }
788
+
789
+ WEBHOOK_EVENT_SUCCESS_RESPONSE = {
790
+ "operation": "Webhook event",
791
+ "success": True,
792
+ }
793
+
794
+ # Connection CRUD
795
+ SENDLE_CONNECTION_DATA = {
796
+ "carrier_name": "sendle",
797
+ "carrier_id": "my_sendle_connection",
798
+ "credentials": {
799
+ "sendle_id": "test_sendle_id",
800
+ "api_key": "test_api_key",
801
+ },
802
+ }
803
+
804
+ SENDLE_CONNECTION_RESPONSE = {
805
+ "id": ANY,
806
+ "object_type": "carrier-connection",
807
+ "carrier_name": "sendle",
808
+ "display_name": "Sendle",
809
+ "carrier_id": "my_sendle_connection",
810
+ "credentials": {
811
+ "sendle_id": "test_sendle_id",
812
+ "api_key": "test_api_key",
813
+ "account_country_code": None,
814
+ },
815
+ "config": {},
816
+ "metadata": {},
817
+ "active": True,
818
+ "test_mode": True,
819
+ "capabilities": ANY,
820
+ "is_system": False,
821
+ }
822
+
823
+ SENDLE_CONNECTION_WITH_CONFIG_DATA = {
824
+ "carrier_name": "sendle",
825
+ "carrier_id": "sendle_with_config",
826
+ "credentials": {
827
+ "sendle_id": "test_sendle_id",
828
+ "api_key": "test_api_key",
829
+ },
830
+ "config": {
831
+ "shipping_options": ["signature_required"],
832
+ },
833
+ }
834
+
835
+ SENDLE_CONNECTION_WITH_CONFIG_RESPONSE = {
836
+ "id": ANY,
837
+ "object_type": "carrier-connection",
838
+ "carrier_name": "sendle",
839
+ "display_name": "Sendle",
840
+ "carrier_id": "sendle_with_config",
841
+ "credentials": {
842
+ "sendle_id": "test_sendle_id",
843
+ "api_key": "test_api_key",
844
+ "account_country_code": None,
845
+ },
846
+ "config": {
847
+ "shipping_options": ["signature_required"],
848
+ },
849
+ "metadata": {},
850
+ "active": True,
851
+ "test_mode": True,
852
+ "capabilities": ANY,
853
+ "is_system": False,
854
+ }
855
+
856
+ INVALID_CONNECTION_DATA = {
857
+ "carrier_name": "invalid_carrier",
858
+ "carrier_id": "test",
859
+ "credentials": {},
860
+ }
861
+
862
+ INACTIVE_CONNECTION_DATA = {
863
+ "carrier_name": "purolator",
864
+ "carrier_id": "purolator_inactive",
865
+ "active": False,
866
+ "credentials": {
867
+ "username": "test",
868
+ "password": "test",
869
+ "account_number": "test",
870
+ "user_token": "test",
871
+ },
872
+ }
873
+
874
+ CONNECTION_UPDATE_DATA = {
875
+ "carrier_id": "canadapost_updated",
876
+ "active": False,
877
+ }
878
+
879
+ CREDENTIALS_UPDATE_DATA = {
880
+ "credentials": {
881
+ "username": "new_username",
882
+ "customer_number": "2004381",
883
+ "contract_id": "42708517",
884
+ "password": "new_password",
885
+ },
886
+ }
887
+
888
+ SENDLE_TO_DELETE_DATA = {
889
+ "carrier_name": "sendle",
890
+ "carrier_id": "sendle_to_delete",
891
+ "credentials": {
892
+ "sendle_id": "test",
893
+ "api_key": "test",
894
+ },
895
+ }