96 lines
4.2 KiB
Python
96 lines
4.2 KiB
Python
"""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()
|