"""Offline key-rotation helper tests; only generated dummy keys and temp files.""" import importlib.util import io import json import os from pathlib import Path import tempfile import unittest from unittest.mock import patch @unittest.skipUnless(os.name == 'posix', 'Linux descriptor/xattr protections') class KeyRotationTests(unittest.TestCase): @classmethod def setUpClass(cls): path = Path(__file__).resolve().parents[1]/'rotate-django-key.py' spec = importlib.util.spec_from_file_location('rotate_django_key', path) cls.rotation = importlib.util.module_from_spec(spec) spec.loader.exec_module(cls.rotation) def setUp(self): self.temp = tempfile.TemporaryDirectory(prefix='key-rotation-test-') self.addCleanup(self.temp.cleanup) self.root = Path(self.temp.name) self.env = self.root/'.env' self.env.write_text('dummy-old-content\n') self.env.chmod(0o640) def test_atomic_update_preserves_mode_and_user_xattr(self): os.setxattr(self.env, 'user.rotation-test', b'preserve') self.rotation.atomic_environment_update(self.env, 'dummy-old-content\n', 'dummy-new-content\n') self.assertEqual(self.env.read_text(), 'dummy-new-content\n') self.assertEqual(self.env.stat().st_mode & 0o777, 0o640) self.assertEqual(os.getxattr(self.env, 'user.rotation-test'), b'preserve') self.assertFalse(list(self.root.glob('.env.signing-key-*'))) def test_concurrent_content_change_aborts(self): with self.assertRaisesRegex(RuntimeError, 'changed concurrently'): self.rotation.atomic_environment_update(self.env, 'outdated-content', 'new') self.assertEqual(self.env.read_text(), 'dummy-old-content\n') def test_env_symlink_is_not_followed(self): target = self.root/'victim' target.write_text('unchanged') self.env.unlink() self.env.symlink_to(target) with self.assertRaises(OSError): self.rotation.atomic_environment_update(self.env, 'unchanged', 'new') self.assertEqual(target.read_text(), 'unchanged') def test_parent_symlink_is_not_followed(self): parent = self.root/'alias' parent.symlink_to(self.root, target_is_directory=True) with self.assertRaises(OSError): self.rotation.read_environment(parent/'.env') def test_staging_symlink_swap_cannot_write_or_chown_victim(self): target = self.root/'victim' target.write_text('unchanged') target.chmod(0o644) original = os.fchown def replace_staging(descriptor, uid, gid): staged, = self.root.glob('.env.signing-key-*') staged.unlink() staged.symlink_to(target) original(descriptor, uid, gid) with patch.object(self.rotation.os, 'fchown', side_effect=replace_staging): with self.assertRaisesRegex(RuntimeError, 'staging file was replaced'): self.rotation.atomic_environment_update(self.env, 'dummy-old-content\n', 'new') self.assertEqual(target.read_text(), 'unchanged') self.assertEqual(target.stat().st_mode & 0o777, 0o644) self.assertEqual(self.env.read_text(), 'dummy-old-content\n') def test_fallbacks_must_be_list_of_nonempty_strings(self): for value in ('{}', '"key"', '[1]', '[""]', 'null'): with self.subTest(value=value), self.assertRaises(ValueError): self.rotation.parse_fallbacks(value) self.assertEqual(self.rotation.parse_fallbacks('["dummy-old"]'), ['dummy-old']) def test_dotenv_json_escaping_round_trip(self): from dotenv import dotenv_values value = json.dumps(["dummy'quote\\backslash", 'dummy-old']) source = self.rotation.render_values('DJANGO_SECRET_KEY_FALLBACKS=[]\n', { 'DJANGO_SECRET_KEY_FALLBACKS': value, 'JWT_SIGNING_KEY': "dummy'quote\\backslash", }) parsed = dotenv_values(stream=io.StringIO(source)) self.assertEqual(json.loads(parsed['DJANGO_SECRET_KEY_FALLBACKS']), ["dummy'quote\\backslash", 'dummy-old']) self.assertEqual(parsed['JWT_SIGNING_KEY'], "dummy'quote\\backslash") if __name__ == '__main__': unittest.main()