Проект разделен на два репозитория
This commit is contained in:
@@ -0,0 +1,431 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class InfrastructureConfigTests(unittest.TestCase):
|
||||
def test_structlog_event_is_not_passed_twice(self) -> None:
|
||||
log_methods = {"debug", "info", "warning", "error", "exception", "critical"}
|
||||
for source_path in (ROOT / "api-backend/app").rglob("*.py"):
|
||||
tree = ast.parse(source_path.read_text(encoding="utf-8"), filename=str(source_path))
|
||||
for node in ast.walk(tree):
|
||||
if (
|
||||
isinstance(node, ast.Call)
|
||||
and isinstance(node.func, ast.Attribute)
|
||||
and node.func.attr in log_methods
|
||||
and node.args
|
||||
and any(keyword.arg == "event" for keyword in node.keywords)
|
||||
):
|
||||
self.fail(f"duplicate structlog event in {source_path}:{node.lineno}")
|
||||
|
||||
def test_root_compose_uses_only_infra_fragments(self) -> None:
|
||||
compose = (ROOT / "docker-compose.yml").read_text(encoding="utf-8")
|
||||
for fragment in (
|
||||
"infra/compose/application.yml",
|
||||
"nginx/docker-compose.yml",
|
||||
"redis/docker-compose.yml",
|
||||
"observability/docker-compose.yml",
|
||||
"deployment/docker-compose.jobs.yml",
|
||||
):
|
||||
self.assertIn(fragment, compose)
|
||||
self.assertNotIn("postgres:", compose.lower())
|
||||
|
||||
def test_external_dependencies_use_dedicated_egress_network(self) -> None:
|
||||
root = (ROOT / "docker-compose.yml").read_text(encoding="utf-8")
|
||||
self.assertIn("egress:\n name: han-chat-egress", root)
|
||||
|
||||
application = (ROOT / "infra/compose/application.yml").read_text(encoding="utf-8")
|
||||
self.assertIn("networks: [backend, observability, egress]", application)
|
||||
self.assertIn("networks: [public, backend, observability, egress]", application)
|
||||
self.assertIn(
|
||||
"KEYCLOAK_YANDEX_CAPTCHA_SERVER_KEY_FILE: "
|
||||
"/run/secrets/keycloak_yandex_captcha_server_key",
|
||||
application,
|
||||
)
|
||||
self.assertEqual(
|
||||
application.count("IDGTL_SMS_API_KEY_FILE: /run/secrets/idgtl_sms_api_key"),
|
||||
1,
|
||||
)
|
||||
|
||||
jobs = (ROOT / "deployment/docker-compose.jobs.yml").read_text(encoding="utf-8")
|
||||
self.assertEqual(jobs.count("networks: [backend, egress]"), 5)
|
||||
|
||||
observability = (ROOT / "observability/docker-compose.yml").read_text(encoding="utf-8")
|
||||
self.assertIn("networks: [observability, egress]", observability)
|
||||
|
||||
redis = (ROOT / "redis/docker-compose.yml").read_text(encoding="utf-8")
|
||||
self.assertNotIn("egress", redis)
|
||||
|
||||
def test_only_nginx_fragment_publishes_ports(self) -> None:
|
||||
forbidden = (
|
||||
ROOT / "infra/compose/application.yml",
|
||||
ROOT / "api-backend/docker-compose.yml",
|
||||
ROOT / "keycloak/docker-compose.yml",
|
||||
ROOT / "redis/docker-compose.yml",
|
||||
ROOT / "observability/docker-compose.yml",
|
||||
ROOT / "deployment/docker-compose.jobs.yml",
|
||||
)
|
||||
for path in forbidden:
|
||||
self.assertNotIn("\n ports:", path.read_text(encoding="utf-8"), path)
|
||||
nginx = (ROOT / "nginx/docker-compose.yml").read_text(encoding="utf-8")
|
||||
self.assertEqual(nginx.count("\n ports:"), 1)
|
||||
self.assertIn('NGINX_HTTP_PORT:-80}:80', nginx)
|
||||
self.assertIn('NGINX_HTTPS_PORT:-443}:443', nginx)
|
||||
|
||||
def test_vm_and_nginx_security_defaults(self) -> None:
|
||||
setup = (ROOT / "deployment/scripts/setup-vm.sh").read_text(encoding="utf-8")
|
||||
env_example = (ROOT / ".env.example").read_text(encoding="utf-8")
|
||||
compose = (ROOT / "nginx/docker-compose.yml").read_text(encoding="utf-8")
|
||||
ssl_renew = (
|
||||
ROOT / "deployment/scripts/ssl-renew.sh"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn("LOCK_ACCOUNT_PASSWORDS=true", setup)
|
||||
self.assertIn('passwd --lock root', setup)
|
||||
self.assertIn('passwd --lock "$DEPLOY_USER"', setup)
|
||||
self.assertIn("X11Forwarding no", setup)
|
||||
self.assertIn("PasswordAuthentication no", setup)
|
||||
self.assertIn("NGINX_HSTS_MAX_AGE=31536000", env_example)
|
||||
self.assertIn("NGINX_HSTS_MAX_AGE:-31536000", compose)
|
||||
self.assertIn("compose kill --signal HUP nginx", ssl_renew)
|
||||
executable_ssl_renew = "\n".join(
|
||||
line
|
||||
for line in ssl_renew.splitlines()
|
||||
if not line.lstrip().startswith("#")
|
||||
)
|
||||
self.assertNotIn("nginx -s reload", executable_ssl_renew)
|
||||
|
||||
def test_nginx_internal_denies_precede_spa(self) -> None:
|
||||
site = (ROOT / "nginx/templates/site-tls.conf.template").read_text(encoding="utf-8")
|
||||
compose = (ROOT / "nginx/docker-compose.yml").read_text(encoding="utf-8")
|
||||
config = (ROOT / "nginx/nginx.conf.template").read_text(encoding="utf-8")
|
||||
entrypoint = (ROOT / "nginx/scripts/entrypoint.sh").read_text(encoding="utf-8")
|
||||
proxy_common = (ROOT / "nginx/snippets/proxy-common.conf").read_text(encoding="utf-8")
|
||||
proxy_keycloak = (ROOT / "nginx/snippets/proxy-keycloak.conf").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
internal = site.index("location ^~ /internal/")
|
||||
api = site.index("location ^~ /api/")
|
||||
frontend = site.index("include /etc/nginx/generated/frontend-location.conf")
|
||||
self.assertLess(internal, api)
|
||||
self.assertLess(api, frontend)
|
||||
self.assertIn("location = /api/v1/realtime", site)
|
||||
self.assertNotIn("message-safety:", site)
|
||||
self.assertIn("uid=0,gid=0", compose)
|
||||
self.assertIn('cap_add: ["CHOWN", "NET_BIND_SERVICE", "SETUID", "SETGID"]', compose)
|
||||
self.assertTrue(config.startswith("user nginx;\n"))
|
||||
self.assertIn("chown root:nginx \"$cache_root\"", entrypoint)
|
||||
self.assertIn("chmod 2770 \"$cache_public\"", entrypoint)
|
||||
self.assertLess(entrypoint.index("cache_root="), entrypoint.index("nginx -t"))
|
||||
self.assertIn("proxy_read_timeout 30s;", config)
|
||||
self.assertNotIn("proxy_read_timeout", proxy_common)
|
||||
self.assertNotIn("proxy_send_timeout", proxy_common)
|
||||
self.assertIn("include /etc/nginx/snippets/proxy-keycloak.conf;", site)
|
||||
self.assertIn("location = /auth/callback", site)
|
||||
self.assertIn("location ^~ /auth/resources/", site)
|
||||
self.assertIn("location ^~ /auth/realms/", site)
|
||||
self.assertIn(
|
||||
"location = /auth/realms/han-chat/protocol/openid-connect/auth", site
|
||||
)
|
||||
self.assertIn(
|
||||
"location = /auth/realms/han-chat/login-actions/authenticate", site
|
||||
)
|
||||
captcha_csp = (
|
||||
ROOT / "nginx/snippets/proxy-keycloak-captcha-csp.conf"
|
||||
).read_text(encoding="utf-8")
|
||||
self.assertIn("proxy_hide_header Content-Security-Policy", captcha_csp)
|
||||
self.assertIn("smartcaptcha.cloud.yandex.ru", captcha_csp)
|
||||
self.assertIn("yastatic.net", captcha_csp)
|
||||
for directive in ("default-src 'self'", "base-uri 'self'", "form-action 'self'"):
|
||||
self.assertIn(directive, captcha_csp)
|
||||
self.assertNotIn("browserSecurityHeaders", (
|
||||
ROOT / "keycloak/realm/han-chat-realm.json"
|
||||
).read_text(encoding="utf-8"))
|
||||
self.assertIn("location = /callbacks/idgtl/sms", site)
|
||||
self.assertIn("allow 185.203.96.7;", site)
|
||||
self.assertIn("proxy_pass http://sms_service_upstream;", site)
|
||||
self.assertIn("upstream sms_service_upstream", config)
|
||||
self.assertNotIn("security-headers.conf", proxy_keycloak)
|
||||
self.assertNotIn("X-Frame-Options", proxy_keycloak)
|
||||
|
||||
def test_redis_persistence_acl_and_no_host_port(self) -> None:
|
||||
config = (ROOT / "redis/redis.conf").read_text(encoding="utf-8")
|
||||
acl = (ROOT / "redis/users.acl.template").read_text(encoding="utf-8")
|
||||
self.assertIn("appendonly yes", config)
|
||||
self.assertIn("appendfsync everysec", config)
|
||||
self.assertIn("save 900 1", config)
|
||||
self.assertIn("user default off", acl)
|
||||
self.assertIn("~han:api:*", acl)
|
||||
self.assertIn("~han:safety:*", acl)
|
||||
|
||||
def test_otel_has_redaction_and_persistent_queue(self) -> None:
|
||||
config = (ROOT / "observability/otel-collector.yaml").read_text(encoding="utf-8")
|
||||
for forbidden_attribute in (
|
||||
"http.request.header.authorization",
|
||||
"http.request.header.cookie",
|
||||
"url.query",
|
||||
"url.full",
|
||||
"db.statement",
|
||||
"db.query.text",
|
||||
"messaging.message.body",
|
||||
"aws.s3.key",
|
||||
):
|
||||
self.assertIn(forbidden_attribute, config)
|
||||
self.assertIn("storage: file_storage", config)
|
||||
self.assertIn("retry_on_failure:", config)
|
||||
self.assertIn('insecure: "${env:OTEL_REMOTE_TLS_INSECURE}"', config)
|
||||
self.assertIn(
|
||||
"authorization: ${file:/run/secrets/otel_remote_auth_header}", config
|
||||
)
|
||||
self.assertNotIn("OTEL_REMOTE_AUTH_HEADER", config)
|
||||
self.assertIn("tail_sampling:", config)
|
||||
self.assertNotIn("probabilistic_sampler:", config)
|
||||
self.assertIn('targets: ["sms-service:8080"]', config)
|
||||
self.assertIn('targets: ["sms-worker:9464"]', config)
|
||||
self.assertIn('targets: ["keycloak:9000"]', config)
|
||||
self.assertIn("metric_relabel_configs:", config)
|
||||
compose = (ROOT / "observability/docker-compose.yml").read_text(encoding="utf-8")
|
||||
self.assertIn("OTEL_REMOTE_TLS_INSECURE: ${OTEL_REMOTE_TLS_INSECURE:-false}", compose)
|
||||
self.assertIn("networks: [observability, egress]", compose)
|
||||
self.assertNotIn("networks: [observability, backend, egress]", compose)
|
||||
application = (ROOT / "infra/compose/application.yml").read_text(encoding="utf-8")
|
||||
self.assertIn("OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME_API:-api-backend}", application)
|
||||
self.assertIn("OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME_SMS_API:-sms-service}", application)
|
||||
self.assertIn("OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME_SMS_WORKER:-sms-worker}", application)
|
||||
self.assertIn('expose: ["9464"]', application)
|
||||
for service_main in (
|
||||
ROOT / "api-backend/app/main.py",
|
||||
ROOT / "sms-service/app/main.py",
|
||||
):
|
||||
source = service_main.read_text(encoding="utf-8")
|
||||
self.assertNotIn("route=request.url.path", source)
|
||||
self.assertIn('getattr(request.scope.get("route"), "path"', source)
|
||||
worker = (ROOT / "sms-service/app/worker.py").read_text(encoding="utf-8")
|
||||
for span_name in ("sms.claim", "sms.process", "sms.provider", "sms.save_result"):
|
||||
self.assertIn(f'"{span_name}"', worker)
|
||||
|
||||
def test_settings_cli_and_workers_are_deployable(self) -> None:
|
||||
cli = ROOT / "api-backend/app/cli"
|
||||
self.assertTrue((cli / "__init__.py").is_file())
|
||||
self.assertTrue((cli / "seed_settings.py").is_file())
|
||||
self.assertTrue((cli / "validate_settings.py").is_file())
|
||||
|
||||
jobs = (ROOT / "deployment/docker-compose.jobs.yml").read_text(encoding="utf-8")
|
||||
self.assertIn("python -m app.cli.seed_settings", jobs)
|
||||
self.assertIn("python -m app.cli.validate_settings", jobs)
|
||||
|
||||
application = (ROOT / "infra/compose/application.yml").read_text(encoding="utf-8")
|
||||
self.assertIn("\n frontend-static:", application)
|
||||
self.assertIn("frontend-test-site", application)
|
||||
self.assertIn("frontend-static:/output", application)
|
||||
for service, command in (
|
||||
("sms-worker:", "han-sms-worker"),
|
||||
("delivery-worker:", "han-delivery-worker"),
|
||||
("safety-recovery-worker:", "han-safety-worker"),
|
||||
("cleanup-worker:", "han-cleanup-worker"),
|
||||
):
|
||||
self.assertIn(f"\n {service}", application)
|
||||
self.assertIn(f'command: ["{command}"]', application)
|
||||
self.assertNotIn("\n ports:", application)
|
||||
nginx = (ROOT / "nginx/docker-compose.yml").read_text(encoding="utf-8")
|
||||
self.assertIn("frontend-static: {condition: service_completed_successfully}", nginx)
|
||||
|
||||
def test_smoke_script_does_not_source_env_as_shell(self) -> None:
|
||||
smoke = (ROOT / "deployment/scripts/smoke.sh").read_text(encoding="utf-8")
|
||||
self.assertNotIn('. "./$CONFIG_FILE"', smoke)
|
||||
for variable in ("PUBLIC_HOST", "PUBLIC_WEB_URL", "KEYCLOAK_REALM"):
|
||||
self.assertIn(f"{variable}=$(env_value {variable})", smoke)
|
||||
|
||||
def test_expo_public_environment_uses_static_property_access(self) -> None:
|
||||
config = (ROOT / "frontend-test-site/src/config.ts").read_text(encoding="utf-8")
|
||||
auth = (ROOT / "frontend-test-site/src/auth.ts").read_text(encoding="utf-8")
|
||||
callback = (ROOT / "frontend-test-site/app/auth/callback.tsx").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
self.assertNotIn("process.env[name]", config)
|
||||
self.assertIn("process.env.EXPO_PUBLIC_API_BASE_URL", config)
|
||||
self.assertIn("process.env.EXPO_PUBLIC_AUTH_BASE_URL", config)
|
||||
self.assertIn('path: "auth/callback"', auth)
|
||||
self.assertIn('window.location.assign(url)', auth)
|
||||
self.assertIn("completionStarted.current", callback)
|
||||
self.assertTrue((ROOT / "frontend-test-site/app/auth/callback.tsx").is_file())
|
||||
|
||||
def test_alembic_escapes_percent_encoded_dsn_options(self) -> None:
|
||||
for relative_path in (
|
||||
"api-backend/alembic/env.py",
|
||||
"bitrix-local-app/alembic/env.py",
|
||||
"bitrix-sync/alembic/env.py",
|
||||
"sms-service/migrations/env.py",
|
||||
):
|
||||
env_script = (ROOT / relative_path).read_text(encoding="utf-8")
|
||||
self.assertIn('.replace("%", "%%")', env_script, relative_path)
|
||||
self.assertIn("create_postgres_engine", env_script, relative_path)
|
||||
sms_db = (ROOT / "sms-service/app/db.py").read_text(encoding="utf-8")
|
||||
self.assertNotIn("server_settings", sms_db)
|
||||
|
||||
def test_contact_sync_qualifies_digest_and_deduplicates_initial_map(self) -> None:
|
||||
initial = (
|
||||
ROOT / "api-backend/alembic/versions/0001_initial_han_app.py"
|
||||
).read_text(encoding="utf-8")
|
||||
fix = (
|
||||
ROOT / "api-backend/alembic/versions/0002_qualify_pgcrypto_digest.py"
|
||||
).read_text(encoding="utf-8")
|
||||
dedup_fix = (
|
||||
ROOT / "api-backend/alembic/versions/0010_contact_map_dedup.py"
|
||||
).read_text(encoding="utf-8")
|
||||
main = (ROOT / "api-backend/app/main.py").read_text(encoding="utf-8")
|
||||
self.assertIn("public.digest(", initial)
|
||||
self.assertIn("public.digest(", fix)
|
||||
self.assertIn('down_revision: str | None = "0001_initial"', fix)
|
||||
self.assertIn("IF v_task_type = 'contact.map_or_create' THEN", dedup_fix)
|
||||
self.assertIn("v_dedup := v_task_type || ':' || v_entity_id::text;", dedup_fix)
|
||||
self.assertIn(
|
||||
"NEW.phone_number IS NOT DISTINCT FROM OLD.phone_number",
|
||||
dedup_fix,
|
||||
)
|
||||
self.assertIn(
|
||||
"NEW.record_status IS NOT DISTINCT FROM OLD.record_status",
|
||||
dedup_fix,
|
||||
)
|
||||
self.assertIn('down_revision: str | None = "0009_chat_message_max"', dedup_fix)
|
||||
self.assertIn('EXPECTED_API_DB_REVISION = "0010_contact_map_dedup"', main)
|
||||
|
||||
def test_consent_audit_migration_supports_existing_and_fresh_databases(self) -> None:
|
||||
migration = (
|
||||
ROOT / "api-backend/alembic/versions/0003_consent_device_audit_context.py"
|
||||
).read_text(encoding="utf-8")
|
||||
self.assertIn("ADD COLUMN IF NOT EXISTS device_json", migration)
|
||||
self.assertIn("DROP COLUMN IF EXISTS ip", migration)
|
||||
self.assertIn('down_revision: str | None = "0002_pgcrypto_digest"', migration)
|
||||
|
||||
def test_keycloak_management_health_and_bridge_environment(self) -> None:
|
||||
standalone = (ROOT / "keycloak/docker-compose.yml").read_text(encoding="utf-8")
|
||||
self.assertIn("GET /auth/health/ready", standalone)
|
||||
|
||||
dockerfile = (ROOT / "keycloak/Dockerfile").read_text(encoding="utf-8")
|
||||
self.assertLess(
|
||||
dockerfile.index("COPY realm ./realm"),
|
||||
dockerfile.index("mvn -B -ntp clean verify"),
|
||||
)
|
||||
|
||||
application = (ROOT / "infra/compose/application.yml").read_text(encoding="utf-8")
|
||||
self.assertIn("GET /auth/health/ready", application)
|
||||
for variable in (
|
||||
"KC_DB_SCHEMA",
|
||||
"KEYCLOAK_OTP_MOCK_ENABLED",
|
||||
"KEYCLOAK_YANDEX_CAPTCHA_ENABLED",
|
||||
"KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC",
|
||||
"KEYCLOAK_SETTINGS_BRIDGE_URL",
|
||||
"KEYCLOAK_SMS_SERVICE_URL",
|
||||
):
|
||||
self.assertIn(f" {variable}:", application)
|
||||
for variable in (
|
||||
"KEYCLOAK_OTP_MOCK_CODE",
|
||||
"KEYCLOAK_YANDEX_CAPTCHA_SERVER_KEY",
|
||||
"KEYCLOAK_OTP_HMAC_KEY",
|
||||
"KEYCLOAK_SETTINGS_BRIDGE_TOKEN",
|
||||
"KEYCLOAK_SMS_SERVICE_TOKEN",
|
||||
):
|
||||
self.assertIn(f" {variable}_FILE:", application)
|
||||
self.assertIn(
|
||||
" KEYCLOAK_YANDEX_CAPTCHA_CLIENT_KEY: "
|
||||
"${KEYCLOAK_YANDEX_CAPTCHA_CLIENT_KEY:-}",
|
||||
application,
|
||||
)
|
||||
|
||||
def test_compose_secrets_do_not_enter_config_environment(self) -> None:
|
||||
compose_paths = (
|
||||
ROOT / "infra/compose/application.yml",
|
||||
ROOT / "redis/docker-compose.yml",
|
||||
ROOT / "observability/docker-compose.yml",
|
||||
ROOT / "deployment/docker-compose.jobs.yml",
|
||||
)
|
||||
combined = "\n".join(path.read_text(encoding="utf-8") for path in compose_paths)
|
||||
self.assertNotIn("env_file:", combined)
|
||||
for path in compose_paths:
|
||||
self.assertIn(
|
||||
"core: {soft: 0, hard: 0}",
|
||||
path.read_text(encoding="utf-8"),
|
||||
path,
|
||||
)
|
||||
for variable in (
|
||||
"DATABASE_URL",
|
||||
"REDIS_URL",
|
||||
"SMS_SERVICE_TOKEN",
|
||||
"KEYCLOAK_DB_PASSWORD",
|
||||
"OTEL_REMOTE_AUTH_HEADER",
|
||||
):
|
||||
self.assertNotIn(f"${{{variable}", combined)
|
||||
self.assertIn(
|
||||
"${HAN_SECRETS_DIR:-/run/han-chat/secrets}/DATABASE_URL", combined
|
||||
)
|
||||
self.assertNotIn("entrypoint: []", combined)
|
||||
|
||||
for service in (
|
||||
"api-backend",
|
||||
"sms-service",
|
||||
"message-safety",
|
||||
"bitrix-local-app",
|
||||
"bitrix-sync",
|
||||
"keycloak",
|
||||
):
|
||||
dockerfile = (ROOT / service / "Dockerfile").read_text(encoding="utf-8")
|
||||
self.assertIn("han-container-entrypoint", dockerfile, service)
|
||||
|
||||
def test_env_validator_accepts_materialized_example(self) -> None:
|
||||
example = (ROOT / ".env.example").read_text(encoding="utf-8")
|
||||
self.assertNotIn("options=-csearch_path", example)
|
||||
self.assertNotIn("currentSchema=", example)
|
||||
self.assertIn("KEYCLOAK_DB_SCHEMA=keycloak", example)
|
||||
self.assertIn("HAN_PG_PORT=5433", example)
|
||||
self.assertIn("SECRETS_SOURCE=file", example)
|
||||
self.assertNotIn("CURSOR_HMAC_SECRET=", example)
|
||||
self.assertNotIn("BITRIX_TOKEN_ENCRYPTION_KEY=", example)
|
||||
materialized = example
|
||||
materialized = materialized.replace(
|
||||
"KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=false",
|
||||
"KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=true",
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
env_file = Path(directory) / ".env"
|
||||
env_file.write_text(materialized, encoding="utf-8")
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(ROOT / "scripts/validate-env"), str(env_file)],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
def test_env_validator_keeps_captcha_server_key_out_of_config(self) -> None:
|
||||
example = (ROOT / ".env.example").read_text(encoding="utf-8")
|
||||
materialized = example.replace(
|
||||
"KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=false",
|
||||
"KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=true",
|
||||
)
|
||||
enabled_without_keys = materialized.replace(
|
||||
"KEYCLOAK_YANDEX_CAPTCHA_ENABLED=false",
|
||||
"KEYCLOAK_YANDEX_CAPTCHA_ENABLED=true",
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
env_file = Path(directory) / ".env"
|
||||
env_file.write_text(enabled_without_keys, encoding="utf-8")
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(ROOT / "scripts/validate-env"), str(env_file)],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertNotIn("KEYCLOAK_YANDEX_CAPTCHA_SERVER_KEY=", example)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,126 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import runpy
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
VALIDATOR = ROOT / "scripts/validate-env"
|
||||
|
||||
|
||||
def runtime_values() -> dict[str, str]:
|
||||
symbols = runpy.run_path(str(VALIDATOR))
|
||||
values = {key: "a" * 32 for key in symbols["REQUIRED_RUNTIME"]}
|
||||
pg_tail = "?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem"
|
||||
values.update(
|
||||
{
|
||||
"DATABASE_URL": f"postgresql+asyncpg://han_app:password@pg:5433/han_chat{pg_tail}",
|
||||
"BITRIX_DATABASE_URL": f"postgresql://bitrix:password@pg:5433/han_chat{pg_tail}",
|
||||
"BITRIX_SYNC_DATABASE_URL": f"postgresql://sync:password@pg:5433/han_chat{pg_tail}",
|
||||
"MESSAGE_SAFETY_DATABASE_URL": f"postgresql://safety:password@pg:5433/han_chat{pg_tail}",
|
||||
"SMS_DATABASE_URL": f"postgresql+asyncpg://sms:password@pg:5433/han_chat{pg_tail}",
|
||||
"KEYCLOAK_DB_URL": f"jdbc:postgresql://pg:5433/han_chat{pg_tail}",
|
||||
"REDIS_API_PASSWORD": "r" * 32,
|
||||
"REDIS_SAFETY_PASSWORD": "s" * 32,
|
||||
"REDIS_HEALTH_PASSWORD": "h" * 32,
|
||||
"REDIS_URL": f"redis://api_backend:{'r' * 32}@redis:6379/0",
|
||||
"REDIS_REALTIME_URL": f"redis://api_backend:{'r' * 32}@redis:6379/1",
|
||||
"MESSAGE_SAFETY_REDIS_URL": f"redis://message_safety:{'s' * 32}@redis:6379/2",
|
||||
"BITRIX_LOCAL_APP_INTERNAL_TOKEN": "b" * 32,
|
||||
"BITRIX_INTERNAL_API_TOKEN": "b" * 32,
|
||||
"BITRIX_API_FORWARD_TOKEN": "f" * 32,
|
||||
"BITRIX_API_INBOX_TOKEN": "f" * 32,
|
||||
"KEYCLOAK_SMS_SERVICE_TOKEN": "k" * 32,
|
||||
"SMS_SERVICE_TOKEN": "k" * 32,
|
||||
"KEYCLOAK_OTP_MOCK_CODE": "846271",
|
||||
}
|
||||
)
|
||||
return values
|
||||
|
||||
|
||||
class SecretHygieneTests(unittest.TestCase):
|
||||
def run_validator(
|
||||
self, config: Path, *args: str, environment: dict[str, str] | None = None
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
[sys.executable, str(VALIDATOR), str(config), *args],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
env=environment,
|
||||
)
|
||||
|
||||
def test_example_is_non_secret_config(self) -> None:
|
||||
example = (ROOT / ".env.example").read_text(encoding="utf-8")
|
||||
symbols = runpy.run_path(str(VALIDATOR))
|
||||
for key in symbols["FORBIDDEN_CONFIG_KEYS"]:
|
||||
self.assertNotRegex(example, rf"(?m)^{key}=")
|
||||
self.assertIn("SECRETS_SOURCE=file", example)
|
||||
|
||||
def test_config_rejects_secret_key_and_credential_url(self) -> None:
|
||||
example = (ROOT / ".env.example").read_text(encoding="utf-8")
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
config = Path(directory) / ".env"
|
||||
config.write_text(
|
||||
example
|
||||
+ "\nSMS_SERVICE_TOKEN=not-for-config\n"
|
||||
+ "EXTERNAL_URL=https://user:password@example.net/path\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
result = self.run_validator(config)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("SMS_SERVICE_TOKEN", result.stderr)
|
||||
self.assertIn("URL с credentials", result.stderr)
|
||||
self.assertNotIn("not-for-config", result.stderr)
|
||||
|
||||
def test_runtime_environment_is_validated_without_value_disclosure(self) -> None:
|
||||
values = runtime_values()
|
||||
secret_canary = "canary-never-print-" + "x" * 20
|
||||
values["CURSOR_HMAC_SECRET"] = secret_canary
|
||||
environment = {**os.environ, **values}
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
config = Path(directory) / ".env"
|
||||
config.write_text(
|
||||
(ROOT / ".env.example").read_text(encoding="utf-8").replace(
|
||||
"KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=false",
|
||||
"KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=true",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
result = self.run_validator(config, "--runtime-env", environment=environment)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertNotIn(secret_canary, result.stdout + result.stderr)
|
||||
|
||||
def test_runtime_manifest_reads_protected_files_without_value_disclosure(self) -> None:
|
||||
values = runtime_values()
|
||||
secret_canary = "manifest-canary-" + "z" * 20
|
||||
values["CURSOR_HMAC_SECRET"] = secret_canary
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
config = root / ".env"
|
||||
config.write_text(
|
||||
(ROOT / ".env.example").read_text(encoding="utf-8").replace(
|
||||
"KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=false",
|
||||
"KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=true",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
entries = []
|
||||
for index, (key, value) in enumerate(sorted(values.items())):
|
||||
path = root / f"secret-{index}"
|
||||
path.write_text(value + "\n", encoding="utf-8")
|
||||
path.chmod(0o600)
|
||||
entries.append(f"{key}={path.resolve()}")
|
||||
manifest = root / "manifest"
|
||||
manifest.write_text("\n".join(entries) + "\n", encoding="utf-8")
|
||||
result = self.run_validator(config, "--runtime-manifest", str(manifest))
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertNotIn(secret_canary, result.stdout + result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,471 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
LOADER_PATH = ROOT / "deployment" / "secrets" / "secrets_loader.py"
|
||||
LAUNCHER_PATH = ROOT / "deployment" / "secrets" / "han-secrets"
|
||||
SPEC = importlib.util.spec_from_file_location("han_secrets_loader", LOADER_PATH)
|
||||
assert SPEC and SPEC.loader
|
||||
loader = importlib.util.module_from_spec(SPEC)
|
||||
sys.modules[SPEC.name] = loader
|
||||
SPEC.loader.exec_module(loader)
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(
|
||||
self, status: int, body: bytes, headers: dict[str, str] | None = None
|
||||
) -> None:
|
||||
self.status = status
|
||||
self._body = body
|
||||
self.headers = headers or {}
|
||||
|
||||
def __enter__(self) -> "FakeResponse":
|
||||
return self
|
||||
|
||||
def __exit__(self, *args: Any) -> None:
|
||||
return None
|
||||
|
||||
def read(self, amount: int) -> bytes:
|
||||
return self._body[:amount]
|
||||
|
||||
|
||||
class FakeOpener:
|
||||
def __init__(self, outcomes: list[Any]) -> None:
|
||||
self.outcomes = outcomes
|
||||
self.requests: list[Any] = []
|
||||
|
||||
def open(self, request: Any, timeout: float) -> FakeResponse:
|
||||
self.requests.append((request, timeout))
|
||||
outcome = self.outcomes.pop(0)
|
||||
if isinstance(outcome, BaseException):
|
||||
raise outcome
|
||||
return outcome
|
||||
|
||||
|
||||
def identity_response(*, project_scoped: bool = True) -> FakeResponse:
|
||||
token: dict[str, Any] = {
|
||||
"catalog": [
|
||||
{
|
||||
"type": "secrets-manager",
|
||||
"endpoints": [
|
||||
{
|
||||
"region": "ru-test",
|
||||
"interface": "public",
|
||||
"url": "https://secrets.example",
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
if project_scoped:
|
||||
token["project"] = {"id": "project-id"}
|
||||
return FakeResponse(
|
||||
201,
|
||||
json.dumps({"token": token}).encode(),
|
||||
{"X-Subject-Token": "iam-token-do-not-log"},
|
||||
)
|
||||
|
||||
|
||||
def secret_response(value: bytes) -> FakeResponse:
|
||||
return FakeResponse(
|
||||
200, json.dumps({"value": base64.b64encode(value).decode()}).encode()
|
||||
)
|
||||
|
||||
|
||||
def selectel_config(runtime_dir: Path) -> dict[str, Any]:
|
||||
return {
|
||||
"version": 1,
|
||||
"mode": "selectel",
|
||||
"runtime_dir": str(runtime_dir),
|
||||
"http": {"timeout_seconds": 2, "retries": 1, "max_response_bytes": 4096},
|
||||
"selectel": {
|
||||
"account_id": "123456",
|
||||
"username": "reader",
|
||||
"project_name": "production",
|
||||
"region": "ru-test",
|
||||
"password_file": "selectel-password",
|
||||
},
|
||||
"secrets": {
|
||||
"DATABASE_URL": {
|
||||
"remote": "han/database-url",
|
||||
"consumers": ["api", "migration"],
|
||||
"max_bytes": 512,
|
||||
},
|
||||
"SHARED_TOKEN": {
|
||||
"remote": "han/shared-token",
|
||||
"consumers": ["api"],
|
||||
"max_bytes": 128,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def write_json(path: Path, value: dict[str, Any]) -> None:
|
||||
path.write_text(json.dumps(value), encoding="utf-8")
|
||||
|
||||
|
||||
def make_private(path: Path) -> None:
|
||||
os.chmod(path, 0o600)
|
||||
|
||||
|
||||
class SecretsLoaderTests(unittest.TestCase):
|
||||
def test_decodes_current_secret_from_nested_selectel_version(self) -> None:
|
||||
encoded = base64.b64encode(b"current-secret").decode()
|
||||
|
||||
value = loader.decode_secret(
|
||||
{"name": "DATABASE_URL", "version": {"version_id": 1, "value": encoded}},
|
||||
"DATABASE_URL",
|
||||
1024,
|
||||
)
|
||||
|
||||
self.assertEqual(value, b"current-secret")
|
||||
|
||||
def test_empty_literal_never_requires_provider_or_fallback_value(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
fallback = root / "fallback.env"
|
||||
fallback.write_text("REQUIRED_TOKEN=required-value\n", encoding="utf-8")
|
||||
make_private(fallback)
|
||||
config = {
|
||||
"version": 1,
|
||||
"mode": "file",
|
||||
"runtime_dir": str(root / "run"),
|
||||
"file": {"path": str(fallback)},
|
||||
"secrets": {
|
||||
"REQUIRED_TOKEN": {
|
||||
"remote": "provider-token",
|
||||
"consumers": ["service"],
|
||||
},
|
||||
"OPTIONAL_HEADER": {
|
||||
"literal": "",
|
||||
"consumers": ["service"],
|
||||
},
|
||||
},
|
||||
}
|
||||
config_path = root / "file.json"
|
||||
write_json(config_path, config)
|
||||
|
||||
loader.run(config_path, environ={})
|
||||
|
||||
self.assertEqual((root / "run" / "OPTIONAL_HEADER").read_bytes(), b"")
|
||||
self.assertIn(
|
||||
'OPTIONAL_HEADER=""',
|
||||
(root / "run" / "service.env").read_text(encoding="utf-8"),
|
||||
)
|
||||
|
||||
def test_launcher_uses_public_source_switch_without_exporting_values(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
runtime = root / "runtime"
|
||||
fallback = root / "fallback.env"
|
||||
fallback.write_text("TEST_SECRET=canary-secret-value\n", encoding="utf-8")
|
||||
make_private(fallback)
|
||||
public = root / ".env"
|
||||
public.write_text(
|
||||
"SECRETS_SOURCE=file\nAPP_ENV=production\n", encoding="utf-8"
|
||||
)
|
||||
loader_config = root / "production.file.json"
|
||||
write_json(
|
||||
loader_config,
|
||||
{
|
||||
"version": 1,
|
||||
"mode": "file",
|
||||
"runtime_dir": str(runtime),
|
||||
"file": {"path": str(fallback)},
|
||||
"secrets": {
|
||||
"TEST_SECRET": {
|
||||
"remote": "unused-in-file-mode",
|
||||
"consumers": ["test-service"],
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
probe = (
|
||||
"import json,os,pathlib;"
|
||||
"p=os.environ['TEST_SECRET_FILE'];"
|
||||
"print(json.dumps({'raw':os.environ.get('TEST_SECRET'),"
|
||||
"'value':pathlib.Path(p).read_text()}))"
|
||||
)
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(LAUNCHER_PATH),
|
||||
"run",
|
||||
"--config",
|
||||
str(public),
|
||||
"--loader-config",
|
||||
str(loader_config),
|
||||
"--",
|
||||
sys.executable,
|
||||
"-c",
|
||||
probe,
|
||||
],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
payload = json.loads(result.stdout)
|
||||
self.assertIsNone(payload["raw"])
|
||||
self.assertEqual(payload["value"], "canary-secret-value")
|
||||
self.assertEqual(
|
||||
(runtime / "manifest").read_text(encoding="utf-8").strip(),
|
||||
f"TEST_SECRET={(runtime / 'TEST_SECRET').resolve()}",
|
||||
)
|
||||
|
||||
def test_selectel_flow_uses_project_scope_catalog_and_per_service_files(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
credentials = root / "credentials"
|
||||
credentials.mkdir()
|
||||
(credentials / "selectel-password").write_text(
|
||||
"service-user-password\n", encoding="utf-8"
|
||||
)
|
||||
make_private(credentials / "selectel-password")
|
||||
runtime = root / "run"
|
||||
config_path = root / "config.json"
|
||||
write_json(config_path, selectel_config(runtime))
|
||||
opener = FakeOpener(
|
||||
[
|
||||
identity_response(),
|
||||
secret_response(b"postgresql://user:password@db/app"),
|
||||
secret_response(b'token with spaces and "quotes"'),
|
||||
]
|
||||
)
|
||||
|
||||
def factory(**kwargs: Any) -> Any:
|
||||
return loader.HTTPClient(
|
||||
**kwargs, opener=opener, sleeper=lambda _: None, jitter=lambda: 0
|
||||
)
|
||||
|
||||
consumers = loader.run(
|
||||
config_path,
|
||||
environ={"CREDENTIALS_DIRECTORY": str(credentials)},
|
||||
client_factory=factory,
|
||||
)
|
||||
|
||||
self.assertEqual(consumers, ["api", "migration"])
|
||||
auth_request = opener.requests[0][0]
|
||||
auth_payload = json.loads(auth_request.data)
|
||||
self.assertEqual(
|
||||
auth_payload["auth"]["scope"]["project"]["name"], "production"
|
||||
)
|
||||
self.assertEqual(
|
||||
auth_payload["auth"]["identity"]["password"]["user"]["domain"]["name"],
|
||||
"123456",
|
||||
)
|
||||
self.assertEqual(
|
||||
opener.requests[1][0].get_header("X-auth-token"),
|
||||
"iam-token-do-not-log",
|
||||
)
|
||||
self.assertEqual(
|
||||
opener.requests[1][0].full_url,
|
||||
"https://secrets.example/v1/han%2Fdatabase-url",
|
||||
)
|
||||
api_text = (runtime / "api.env").read_text(encoding="utf-8")
|
||||
migration_text = (runtime / "migration.env").read_text(encoding="utf-8")
|
||||
self.assertIn('DATABASE_URL="postgresql://user:password@db/app"', api_text)
|
||||
self.assertIn(
|
||||
'SHARED_TOKEN="token with spaces and \\"quotes\\""', api_text
|
||||
)
|
||||
self.assertNotIn("SHARED_TOKEN", migration_text)
|
||||
if os.name != "nt":
|
||||
self.assertEqual(stat.S_IMODE((runtime / "api.env").stat().st_mode), 0o600)
|
||||
self.assertEqual(stat.S_IMODE(runtime.stat().st_mode), 0o700)
|
||||
|
||||
def test_retry_uses_jitter_and_then_succeeds(self) -> None:
|
||||
opener = FakeOpener(
|
||||
[urllib.error.URLError("temporary"), FakeResponse(200, b"{}")]
|
||||
)
|
||||
sleeps: list[float] = []
|
||||
client = loader.HTTPClient(
|
||||
timeout=1,
|
||||
retries=1,
|
||||
max_response_bytes=1024,
|
||||
opener=opener,
|
||||
sleeper=sleeps.append,
|
||||
jitter=lambda: 0.25,
|
||||
)
|
||||
result = client.request(
|
||||
"GET", "https://provider.example/v1/key", expected=frozenset({200})
|
||||
)
|
||||
self.assertEqual(result.status, 200)
|
||||
self.assertEqual(sleeps, [0.1875])
|
||||
|
||||
def test_paired_canonical_names_fetch_provider_version_once(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
credentials = root / "credentials"
|
||||
credentials.mkdir()
|
||||
credential = credentials / "selectel-password"
|
||||
credential.write_text("password", encoding="utf-8")
|
||||
make_private(credential)
|
||||
config = selectel_config(root / "run")
|
||||
config["secrets"]["SHARED_TOKEN"]["remote"] = "han/database-url"
|
||||
config_path = root / "config.json"
|
||||
write_json(config_path, config)
|
||||
opener = FakeOpener([identity_response(), secret_response(b"same-value")])
|
||||
|
||||
def factory(**kwargs: Any) -> Any:
|
||||
return loader.HTTPClient(**kwargs, opener=opener)
|
||||
|
||||
loader.run(
|
||||
config_path,
|
||||
environ={"CREDENTIALS_DIRECTORY": str(credentials)},
|
||||
client_factory=factory,
|
||||
)
|
||||
self.assertEqual(len(opener.requests), 2)
|
||||
output = (root / "run" / "api.env").read_text(encoding="utf-8")
|
||||
self.assertIn('DATABASE_URL="same-value"', output)
|
||||
self.assertIn('SHARED_TOKEN="same-value"', output)
|
||||
|
||||
def test_http_error_is_redacted_and_body_is_not_read(self) -> None:
|
||||
leaked = b"postgresql://admin:secret@db/app iam-token response-body"
|
||||
|
||||
class ExplodingBody(io.BytesIO):
|
||||
def read(self, *args: Any, **kwargs: Any) -> bytes:
|
||||
raise AssertionError("HTTP error body must not be read")
|
||||
|
||||
error = urllib.error.HTTPError(
|
||||
"https://provider.example/v1/key",
|
||||
403,
|
||||
"body contains a secret",
|
||||
{},
|
||||
ExplodingBody(leaked),
|
||||
)
|
||||
client = loader.HTTPClient(
|
||||
timeout=1,
|
||||
retries=0,
|
||||
max_response_bytes=1024,
|
||||
opener=FakeOpener([error]),
|
||||
)
|
||||
with self.assertRaises(loader.LoaderError) as caught:
|
||||
client.request(
|
||||
"GET",
|
||||
"https://provider.example/v1/key",
|
||||
headers={"X-Auth-Token": "iam-token"},
|
||||
expected=frozenset({200}),
|
||||
)
|
||||
message = str(caught.exception)
|
||||
self.assertEqual(message, "provider request failed with HTTP 403")
|
||||
for forbidden in ("secret", "iam-token", "response-body", "postgresql://"):
|
||||
self.assertNotIn(forbidden, message)
|
||||
|
||||
def test_unscoped_token_fails_without_replacing_existing_output(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
credentials = root / "credentials"
|
||||
credentials.mkdir()
|
||||
(credentials / "selectel-password").write_text("password", encoding="utf-8")
|
||||
make_private(credentials / "selectel-password")
|
||||
runtime = root / "run"
|
||||
runtime.mkdir()
|
||||
existing = runtime / "api.env"
|
||||
existing.write_text('DATABASE_URL="old-value"\n', encoding="utf-8")
|
||||
config_path = root / "config.json"
|
||||
write_json(config_path, selectel_config(runtime))
|
||||
opener = FakeOpener([identity_response(project_scoped=False)])
|
||||
|
||||
def factory(**kwargs: Any) -> Any:
|
||||
return loader.HTTPClient(**kwargs, opener=opener)
|
||||
|
||||
with self.assertRaisesRegex(loader.LoaderError, "not project-scoped"):
|
||||
loader.run(
|
||||
config_path,
|
||||
environ={"CREDENTIALS_DIRECTORY": str(credentials)},
|
||||
client_factory=factory,
|
||||
)
|
||||
self.assertEqual(
|
||||
existing.read_text(encoding="utf-8"),
|
||||
'DATABASE_URL="old-value"\n',
|
||||
)
|
||||
|
||||
def test_invalid_base64_and_size_limit_fail_closed(self) -> None:
|
||||
with self.assertRaisesRegex(loader.LoaderError, "invalid base64"):
|
||||
loader.decode_secret({"value": "not-base64!"}, "TOKEN", 128)
|
||||
with self.assertRaisesRegex(loader.LoaderError, "configured limit"):
|
||||
loader.decode_secret(
|
||||
{"value": base64.b64encode(b"too-long").decode()}, "TOKEN", 3
|
||||
)
|
||||
with self.assertRaisesRegex(loader.LoaderError, "dotenv"):
|
||||
loader.decode_secret(
|
||||
{"value": base64.b64encode(b"line1\nline2").decode()}, "TOKEN", 128
|
||||
)
|
||||
|
||||
def test_file_mode_is_explicit_strict_and_narrow(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
source = root / "fallback.env"
|
||||
source.write_text(
|
||||
"# exact recovery set\n"
|
||||
'DATABASE_URL="postgresql://user:pass@db/app"\n'
|
||||
"SHARED_TOKEN='literal value'\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
make_private(source)
|
||||
runtime = root / "run"
|
||||
config = selectel_config(runtime)
|
||||
config["mode"] = "file"
|
||||
config.pop("selectel")
|
||||
config.pop("http")
|
||||
config["file"] = {"path": str(source)}
|
||||
config_path = root / "config.json"
|
||||
write_json(config_path, config)
|
||||
loader.run(config_path, environ={})
|
||||
output = (runtime / "api.env").read_text(encoding="utf-8")
|
||||
self.assertIn("literal value", output)
|
||||
self.assertNotIn("selectel", output)
|
||||
|
||||
source.write_text(
|
||||
"DATABASE_URL=ok\nSHARED_TOKEN=ok\nUNDECLARED=leak\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
with self.assertRaisesRegex(loader.LoaderError, "undeclared key"):
|
||||
loader.run(config_path, environ={})
|
||||
|
||||
def test_selectel_failure_never_falls_back_to_file_section(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
config = selectel_config(root / "run")
|
||||
config["file"] = {"path": str(root / "fallback.env")}
|
||||
config_path = root / "config.json"
|
||||
write_json(config_path, config)
|
||||
with self.assertRaisesRegex(loader.LoaderError, "forbidden"):
|
||||
loader.run(config_path, environ={})
|
||||
|
||||
def test_relative_credential_requires_systemd_directory(self) -> None:
|
||||
selectel = {"password_file": "credential"}
|
||||
with self.assertRaisesRegex(loader.LoaderError, "CREDENTIALS_DIRECTORY"):
|
||||
loader.credential_value(selectel, {})
|
||||
|
||||
def test_response_content_length_limit_is_enforced(self) -> None:
|
||||
client = loader.HTTPClient(
|
||||
timeout=1,
|
||||
retries=0,
|
||||
max_response_bytes=10,
|
||||
opener=FakeOpener(
|
||||
[FakeResponse(200, b"{}", {"Content-Length": "100"})]
|
||||
),
|
||||
)
|
||||
with self.assertRaisesRegex(loader.LoaderError, "exceeds"):
|
||||
client.request(
|
||||
"GET", "https://provider.example/v1/key", expected=frozenset({200})
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user