fix(exchange): preserve old archives during server key rotation
All checks were successful
State Corp Backend CI/CD / Quality gate (push) Successful in 3m19s
State Corp Backend CI/CD / Build linux/amd64 images once (push) Successful in 2m54s
State Corp Backend CI/CD / Refresh and release internal main (push) Has been skipped
State Corp Backend CI/CD / Release customer main (push) Has been skipped
State Corp Backend CI/CD / Release dev (push) Successful in 54s
All checks were successful
State Corp Backend CI/CD / Quality gate (push) Successful in 3m19s
State Corp Backend CI/CD / Build linux/amd64 images once (push) Successful in 2m54s
State Corp Backend CI/CD / Refresh and release internal main (push) Has been skipped
State Corp Backend CI/CD / Release customer main (push) Has been skipped
State Corp Backend CI/CD / Release dev (push) Successful in 54s
This commit is contained in:
@@ -58,6 +58,8 @@ def build_exchange_archive(
|
||||
bin_name: str = "exchange_package_20260407.bin",
|
||||
data: dict[str, list[dict[str, object]]] | None = None,
|
||||
schema_version: int = ExchangePackageImportService.SUPPORTED_SCHEMA_VERSION,
|
||||
token: str = TEST_TOKEN,
|
||||
key_id: str = "test-shared-token",
|
||||
) -> SimpleUploadedFile:
|
||||
"""Build encrypted exchange archive compatible with import service."""
|
||||
provided_data = data or {}
|
||||
@@ -92,13 +94,13 @@ def build_exchange_archive(
|
||||
compressed_payload = zlib.compress(payload_bytes, level=9)
|
||||
nonce = b"sc-exch-0001"
|
||||
aad = ExchangePackageImportService.AAD
|
||||
raw_key = hashlib.sha256(TEST_TOKEN.encode("utf-8")).digest()
|
||||
raw_key = hashlib.sha256(token.encode("utf-8")).digest()
|
||||
encrypted_payload = AESGCM(raw_key).encrypt(nonce, compressed_payload, aad)
|
||||
|
||||
header = {
|
||||
"format": ExchangePackageImportService.BIN_FORMAT,
|
||||
"version": 1,
|
||||
"key_id": "test-shared-token",
|
||||
"key_id": key_id,
|
||||
"nonce": _b64url(nonce),
|
||||
"aad": _b64url(aad),
|
||||
"package_id": package_id,
|
||||
|
||||
95
tests/apps/exchange/test_key_rotation.py
Normal file
95
tests/apps/exchange/test_key_rotation.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""Old encrypted archives survive rotation without retaining old HTTP access."""
|
||||
|
||||
from apps.exchange.models import ExchangePackageImport
|
||||
from apps.organization.models import Organization
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
from django.utils.crypto import get_random_string
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from tests.apps.exchange.test_api import (
|
||||
TEST_TOKEN,
|
||||
build_exchange_archive,
|
||||
build_exchange_payload,
|
||||
)
|
||||
|
||||
CURRENT_TOKEN = get_random_string(32)
|
||||
CURRENT_KEY_ID = "current-test-key"
|
||||
PREVIOUS_KEY_ID = "test-shared-token"
|
||||
|
||||
|
||||
@override_settings(
|
||||
EXCHANGE_SHARED_TOKEN=CURRENT_TOKEN,
|
||||
EXCHANGE_KEY_ID=CURRENT_KEY_ID,
|
||||
EXCHANGE_PREVIOUS_SHARED_TOKEN=TEST_TOKEN,
|
||||
EXCHANGE_PREVIOUS_KEY_ID=PREVIOUS_KEY_ID,
|
||||
)
|
||||
class ExchangeKeyRotationTest(APITestCase):
|
||||
def upload(self, *, token=TEST_TOKEN, key_id=PREVIOUS_KEY_ID, header=CURRENT_TOKEN):
|
||||
return self.client.post(
|
||||
reverse("api_v1:exchange:package-upload"),
|
||||
{
|
||||
"file": build_exchange_archive(
|
||||
data=build_exchange_payload(), token=token, key_id=key_id
|
||||
)
|
||||
},
|
||||
format="multipart",
|
||||
HTTP_X_EXCHANGE_TOKEN=header,
|
||||
)
|
||||
|
||||
def test_previous_archive_with_current_auth_imports_and_deduplicates(self):
|
||||
first = self.upload()
|
||||
self.assertEqual(first.status_code, 201)
|
||||
self.assertFalse(first.data["result"]["duplicate"])
|
||||
count = Organization.objects.count()
|
||||
repeated = self.upload()
|
||||
self.assertEqual(repeated.status_code, 201)
|
||||
self.assertTrue(repeated.data["result"]["duplicate"])
|
||||
self.assertEqual(
|
||||
repeated.data["result"]["duplicate_of"],
|
||||
first.data["result"]["import_id"],
|
||||
)
|
||||
self.assertEqual(Organization.objects.count(), count)
|
||||
|
||||
def test_previous_token_never_authorizes_http(self):
|
||||
response = self.upload(header=TEST_TOKEN)
|
||||
self.assertEqual(response.status_code, 401)
|
||||
self.assertFalse(ExchangePackageImport.objects.exists())
|
||||
self.assertFalse(Organization.objects.exists())
|
||||
|
||||
def test_current_archive_uses_current_key(self):
|
||||
self.assertEqual(
|
||||
self.upload(token=CURRENT_TOKEN, key_id=CURRENT_KEY_ID).status_code, 201
|
||||
)
|
||||
|
||||
def test_current_key_retains_legacy_header_id_compatibility(self):
|
||||
for key_id in ("", "legacy-client-id"):
|
||||
with self.subTest(key_id=key_id):
|
||||
self.assertEqual(
|
||||
self.upload(token=CURRENT_TOKEN, key_id=key_id).status_code, 201
|
||||
)
|
||||
|
||||
def test_previous_key_requires_its_exact_header_id(self):
|
||||
self.assertEqual(self.upload(key_id="unknown-key").status_code, 400)
|
||||
self.assertFalse(ExchangePackageImport.objects.exists())
|
||||
|
||||
def test_previous_key_id_never_falls_back_to_current_key(self):
|
||||
self.assertEqual(self.upload(token=CURRENT_TOKEN).status_code, 400)
|
||||
self.assertFalse(ExchangePackageImport.objects.exists())
|
||||
|
||||
@override_settings(EXCHANGE_PREVIOUS_SHARED_TOKEN="", EXCHANGE_PREVIOUS_KEY_ID="")
|
||||
def test_previous_archive_requires_explicit_server_configuration(self):
|
||||
self.assertEqual(self.upload().status_code, 400)
|
||||
self.assertFalse(ExchangePackageImport.objects.exists())
|
||||
|
||||
def test_invalid_previous_configuration_fails_closed(self):
|
||||
for configuration in (
|
||||
{"EXCHANGE_PREVIOUS_SHARED_TOKEN": ""},
|
||||
{"EXCHANGE_PREVIOUS_KEY_ID": ""},
|
||||
{"EXCHANGE_PREVIOUS_KEY_ID": CURRENT_KEY_ID},
|
||||
):
|
||||
with self.subTest(configuration=tuple(configuration)):
|
||||
with override_settings(**configuration):
|
||||
response = self.upload(token=CURRENT_TOKEN, key_id=CURRENT_KEY_ID)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertFalse(ExchangePackageImport.objects.exists())
|
||||
Reference in New Issue
Block a user