Files
pingu-concerts/scripts/tests/test_preprod_config.py
T

174 lines
9.2 KiB
Python

from contextlib import redirect_stdout
from io import StringIO
import os
from pathlib import Path
import shutil
import stat
import subprocess
import sys
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import preprod_config as config
class HostConfigurationTests(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.root = Path(temporary.name)
self.repo = self.root / 'pingu-concerts'
self.repo.mkdir()
self.env = self.root / 'preprod.env'
self.env.write_text('synthetic fixture only')
self.env.chmod(0o600)
self.secret = self.root / 'firebase.json'
self.secret.write_text('synthetic fixture only; not credentials')
self.secret.chmod(0o600)
self.values = {name: 'test-value' for name in config.REQUIRED}
self.values.update(PUSH_ENABLED='true', COOKIE_SECURE='true', FIREBASE_PROJECT_ID='metalcircle-30d9b',
FIREBASE_SERVICE_ACCOUNT_FILE=str(self.secret),
COMPOSE_PROJECT_NAME='pingu-concerts', DOCKER_CLI_PLUGIN_ORIGINAL_CLI_COMMAND='docker compose')
def check(self):
response = SimpleNamespace(returncode=0, stdout='\n'.join(k + '=' + v for k, v in self.values.items()))
with patch.object(config, 'invoke_compose', return_value=response):
return config.check_configuration(self.env, self.repo)
def test_valid_configuration_and_auto_compose_metadata(self):
self.assertEqual(self.check()['PUSH_ENABLED'], 'true')
def test_each_missing_or_blank_required_variable_is_named_without_values(self):
for name in config.REQUIRED:
for value in ('', ' '):
with self.subTest(name=name, value=value), patch.dict(self.values, {name: value}):
with self.assertRaisesRegex(config.PreprodError, '^required variable ' + name + ' is missing$'):
self.check()
def test_missing_env_and_missing_firebase_secret(self):
for path, expected in ((self.env, 'Environment file'), (self.secret, 'Firebase secret')):
saved = path.read_bytes()
path.unlink()
with self.assertRaisesRegex(config.PreprodError, expected + ' is missing'):
self.check()
path.write_bytes(saved)
path.chmod(0o600)
def test_permissions_symlinks_and_repository_paths_are_rejected(self):
for path in (self.env, self.secret):
path.chmod(0o644)
with self.assertRaisesRegex(config.PreprodError, 'requires permissions'):
self.check()
path.chmod(0o600)
inside = self.repo / 'private.json'
inside.write_text('fixture')
inside.chmod(0o600)
self.values['FIREBASE_SERVICE_ACCOUNT_FILE'] = str(inside)
with self.assertRaisesRegex(config.PreprodError, 'outside the repository'):
self.check()
linked = self.root / 'linked.json'
linked.symlink_to(self.secret)
self.values['FIREBASE_SERVICE_ACCOUNT_FILE'] = str(linked)
with self.assertRaisesRegex(config.PreprodError, 'not a symlink'):
self.check()
def test_wrong_owner_and_directory_fail(self):
self.values['FIREBASE_SERVICE_ACCOUNT_FILE'] = str(self.root)
with self.assertRaisesRegex(config.PreprodError, 'regular file'):
self.check()
with patch('preprod_config.os.getuid', return_value=os.getuid() + 1):
with self.assertRaisesRegex(config.PreprodError, 'deployment user'):
config.private_file(self.env, self.repo, 'Environment file')
def test_disabled_push_insecure_cookies_wrong_project_and_context_overrides_fail(self):
for name, value in (('PUSH_ENABLED', 'false'), ('COOKIE_SECURE', 'false'),
('FIREBASE_PROJECT_ID', 'other'), ('COMPOSE_PROJECT_NAME', 'other'),
('DOCKER_HOST', 'tcp://elsewhere'), ('COMPOSE_ENV_FILES', '.env')):
with self.subTest(name=name), patch.dict(self.values, {name: value}):
with self.assertRaises(config.PreprodError):
self.check()
def test_compose_parse_errors_never_expose_stderr(self):
response = SimpleNamespace(returncode=1, stderr='synthetic-secret', stdout='synthetic-secret')
with patch.object(config, 'invoke_compose', return_value=response):
with self.assertRaises(config.PreprodError) as caught:
config.check_configuration(self.env, self.repo)
self.assertNotIn('synthetic-secret', str(caught.exception))
def test_backups_are_private_keep_ten_and_preserve_unrelated_files(self):
directory = self.env.parent / 'backups'
directory.mkdir(mode=0o700)
unrelated = directory / 'keep-me'
unrelated.write_text('unrelated')
with redirect_stdout(StringIO()):
for _ in range(12):
config.backup_environment(self.env, self.repo)
backups = list(directory.glob('preprod.env.*'))
self.assertEqual(len(backups), 10)
self.assertEqual(stat.S_IMODE(directory.stat().st_mode), 0o700)
self.assertTrue(unrelated.exists())
for backup in backups:
self.assertEqual(stat.S_IMODE(backup.stat().st_mode), 0o600)
self.assertEqual(backup.read_bytes(), self.env.read_bytes())
def test_backup_refuses_insecure_directory(self):
directory = self.env.parent / 'backups'
directory.mkdir(mode=0o755)
with self.assertRaisesRegex(config.PreprodError, 'permissions 700'):
config.backup_environment(self.env, self.repo)
def test_compose_cannot_inherit_shell_secrets_or_remote_docker_context(self):
with patch.dict(os.environ, {'GITEA_TOKEN': 'stale', 'DOCKER_HOST': 'remote', 'COMPOSE_FILE': 'other'}):
cleaned = config.compose_environment()
for name in ('GITEA_TOKEN', 'DOCKER_HOST', 'COMPOSE_FILE'):
self.assertNotIn(name, cleaned)
def test_safe_logs_discard_private_text_and_request_urls(self):
output = StringIO()
with redirect_stdout(output):
config.safe_logs('web | INFO "POST /reset/synthetic-secret HTTP/1.1" 200 OK\n'
'web | private synthetic-secret\n'
'web | Gitea issue submission failed: HTTP 403\n'
'web | Push delivery failed: configuration\n')
self.assertNotIn('synthetic-secret', output.getvalue())
self.assertIn('HTTP POST 200', output.getvalue())
self.assertIn('Gitea: HTTP 403', output.getvalue())
self.assertIn('Push: configuration', output.getvalue())
@unittest.skipUnless(shutil.which('docker'), 'requires Compose CLI only; no daemon/network')
def test_real_compose_config_requires_gitea_even_without_host_helper(self):
def validate():
self.env.write_text('\n'.join(k + '=' + v for k, v in self.values.items()
if not k.startswith(('COMPOSE_', 'DOCKER_'))))
return subprocess.run(['docker', 'compose', '--env-file', str(self.env),
'-f', str(config.REPO_ROOT / 'compose.yml'),
'-f', str(config.REPO_ROOT / 'compose.preprod.yml'), 'config', '--quiet'],
env=config.compose_environment(), capture_output=True, text=True)
self.assertEqual(validate().returncode, 0)
for name in ('GITEA_URL', 'GITEA_TOKEN', 'GITEA_OWNER', 'GITEA_REPO'):
with self.subTest(name=name), patch.dict(self.values, {name: ''}):
result = validate()
self.assertNotEqual(result.returncode, 0)
self.assertIn('required variable ' + name + ' is missing', result.stderr)
@unittest.skipUnless(shutil.which('docker'), 'requires Compose CLI only; no daemon/network')
def test_real_compose_dotenv_quoting_and_no_checkout_or_shell_fallback(self):
self.values['GITEA_TOKEN'] = "'synthetic-$literal#value'"
self.env.write_text('\n'.join(k + '=' + v for k, v in self.values.items()
if not k.startswith(('COMPOSE_', 'DOCKER_'))))
def invoke(env_file, args, repo_root):
return subprocess.run(['docker', 'compose', '--env-file', str(env_file),
'-f', str(config.REPO_ROOT / 'compose.yml'),
'-f', str(config.REPO_ROOT / 'compose.preprod.yml'), *args],
env=config.compose_environment(), capture_output=True, text=True)
with patch.object(config, 'invoke_compose', side_effect=invoke):
checked = config.check_configuration(self.env, config.REPO_ROOT)
self.assertEqual(checked['GITEA_TOKEN'], 'synthetic-$literal#value')
self.env.write_text(self.env.read_text().replace("GITEA_TOKEN='synthetic-$literal#value'", ''))
with patch.dict(os.environ, {'GITEA_TOKEN': 'must-not-be-used'}):
with self.assertRaisesRegex(config.PreprodError, 'required variable GITEA_TOKEN is missing'):
config.check_configuration(self.env, config.REPO_ROOT)