deploy/stack.yaml passes DATABASE_URL_FILE, AWS_ACCESS_KEY_ID_FILE, OPERATOR_PASSWORD_FILE and the provider tokens as Swarm secret paths, but the runtime only ever read the plain names. That stack could not start: the database URL and R2 credentials were absent, and operator login raised KeyError, so it returned 500 instead of the intended 503. local/secrets.py resolves every <NAME>_FILE into <NAME> before configuration is read, from the API, worker and bootstrap entrypoints. It fails closed on an unreadable or empty secret and on a name supplied both directly and as a file, because starting with a credential nobody intended is worse than not starting. Only one trailing newline is stripped, so a generated password keeps any whitespace that belongs to it, and no value reaches an error message. The stack also passed OPERATOR_USER while the Kanban authenticates by email; it now passes OPERATOR_EMAIL, matching the runtime. The release gate checked this by searching local/secrets.py for the literal "DATABASE_URL_FILE", which would pass for any file containing that string. It now loads the module and makes it resolve every secret the stack declares, and asserts it fails closed on a missing one. Four marker strings that stopped matching when R2 support landed are removed rather than left to rot; the two that still describe real blockers stay, so the gate continues to refuse a release while payment and messaging adapters are fake. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
99 lines
4.3 KiB
Python
99 lines
4.3 KiB
Python
import unittest
|
|
|
|
from .production_preflight import config_errors, source_errors
|
|
|
|
|
|
def valid_config():
|
|
digest = '1' * 64
|
|
values = {
|
|
'PRODUCTION_DEPLOY_ENABLED': 'approved',
|
|
'PRODUCTION_INPUTS_APPROVED': 'approved',
|
|
'PRODUCTION_SECURITY_REVIEW_APPROVED': 'approved',
|
|
'PRODUCTION_RESTORE_REHEARSED': 'approved',
|
|
'API_IMAGE': 'registry.example.com/dropstar/dtf-api',
|
|
'WEB_IMAGE': 'registry.example.com/dropstar/dtf-web',
|
|
'IMAGE_TAG': 'latest',
|
|
'POSTGRES_IMAGE': f'postgres@sha256:{digest}',
|
|
'CLAMAV_IMAGE': f'clamav/clamav@sha256:{digest}',
|
|
'PUBLIC_ORIGIN': 'https://dtf.example.com',
|
|
'PUBLIC_HOST': 'dtf.example.com',
|
|
'KANBAN_HOST': 'kanban-dtf.example.com',
|
|
'SITE_PORT': '8080',
|
|
'KANBAN_PORT': '8081',
|
|
'R2_ENDPOINT': 'https://account.r2.cloudflarestorage.com',
|
|
'R2_PUBLIC_ENDPOINT': 'https://account.r2.cloudflarestorage.com',
|
|
'R2_BUCKET': 'dtf-production-artwork',
|
|
'POSTGRES_DB': 'dtf',
|
|
'POSTGRES_USER': 'dtf_admin',
|
|
'APP_DB_USER': 'dtf_app',
|
|
'POSTGRES_VOLUME': 'dtf-postgres-data',
|
|
'OPERATOR_EMAIL': 'operador@example.com',
|
|
'STORAGE_QUOTA_BYTES': '53687091200',
|
|
'OWNER_UPLOAD_QUOTA_BYTES': '10737418240',
|
|
'MAX_UPLOAD_BYTES': '5368709120',
|
|
'UPLOAD_PART_BYTES': '8388608',
|
|
'MAX_PENDING_UPLOADS': '10',
|
|
'SCAN_MAX_BYTES': '134217728',
|
|
'PAYMENT_ADAPTER': 'mercado-pago',
|
|
'FREIGHT_ADAPTER': 'approved-freight',
|
|
'TINY_ADAPTER': 'tiny-olist',
|
|
'WHATSAPP_ADAPTER': 'approved-whatsapp',
|
|
}
|
|
for name in (
|
|
'DATABASE_URL_SECRET', 'DATABASE_ADMIN_URL_SECRET',
|
|
'DB_ADMIN_PASSWORD_SECRET', 'APP_DB_PASSWORD_SECRET',
|
|
'R2_ACCESS_KEY_ID_SECRET', 'R2_SECRET_ACCESS_KEY_SECRET',
|
|
'OPERATOR_PASSWORD_SECRET', 'PAYMENT_TOKEN_SECRET',
|
|
'PAYMENT_WEBHOOK_SECRET', 'TINY_TOKEN_SECRET', 'WHATSAPP_TOKEN_SECRET',
|
|
):
|
|
values[name] = 'dtf_prod_' + name.lower()
|
|
return values
|
|
|
|
|
|
class ProductionPreflightTests(unittest.TestCase):
|
|
def test_structurally_complete_metadata_passes(self):
|
|
self.assertEqual(config_errors(valid_config()), [])
|
|
|
|
def test_mutable_images_and_fake_adapters_fail(self):
|
|
values = valid_config()
|
|
values['API_IMAGE'] = 'registry.example.com/dropstar/dtf-api:latest'
|
|
values['PAYMENT_ADAPTER'] = 'fake'
|
|
errors = config_errors(values)
|
|
self.assertTrue(any('API_IMAGE' in error for error in errors))
|
|
self.assertTrue(any('PAYMENT_ADAPTER' in error for error in errors))
|
|
|
|
def test_image_tag_and_public_ports_are_validated(self):
|
|
values = valid_config()
|
|
values['IMAGE_TAG'] = 'main'
|
|
values['KANBAN_PORT'] = values['SITE_PORT']
|
|
errors = config_errors(values)
|
|
self.assertTrue(any('IMAGE_TAG' in error for error in errors))
|
|
self.assertTrue(any('must differ' in error for error in errors))
|
|
|
|
def test_approval_and_secret_name_are_enforced(self):
|
|
values = valid_config()
|
|
values['PRODUCTION_DEPLOY_ENABLED'] = 'yes'
|
|
values['DATABASE_URL_SECRET'] = '../unsafe'
|
|
errors = config_errors(values)
|
|
self.assertTrue(any('PRODUCTION_DEPLOY_ENABLED' in error for error in errors))
|
|
self.assertTrue(any('DATABASE_URL_SECRET' in error for error in errors))
|
|
|
|
def test_fake_checkout_is_explicitly_blocked(self):
|
|
errors = source_errors()
|
|
self.assertTrue(any('local/app.py remains local-only' in error for error in errors))
|
|
self.assertTrue(any('local/worker.py remains local-only' in error for error in errors))
|
|
|
|
def test_secret_reuse_and_incoherent_limits_are_rejected(self):
|
|
values = valid_config()
|
|
values['PAYMENT_TOKEN_SECRET'] = values['TINY_TOKEN_SECRET']
|
|
values['OWNER_UPLOAD_QUOTA_BYTES'] = str(int(values['STORAGE_QUOTA_BYTES']) + 1)
|
|
values['SCAN_MAX_BYTES'] = str(int(values['MAX_UPLOAD_BYTES']) + 1)
|
|
errors = config_errors(values)
|
|
self.assertTrue(any('distinct external Swarm secret' in error for error in errors))
|
|
self.assertIn('OWNER_UPLOAD_QUOTA_BYTES cannot exceed STORAGE_QUOTA_BYTES', errors)
|
|
self.assertIn('SCAN_MAX_BYTES cannot exceed MAX_UPLOAD_BYTES', errors)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|