Build 060.30: finalize Market Data Acquisition documentation
This commit is contained in:
@@ -1,10 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core import config as config_module
|
||||
from src.core.config import load_settings
|
||||
|
||||
|
||||
APP_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
_SECRET_VARIABLES = (
|
||||
"BOT_TOKEN",
|
||||
"BOT_TOKEN_FILE",
|
||||
"DB_PASSWORD",
|
||||
"DB_PASSWORD_FILE",
|
||||
"EXCHANGE_API_KEY",
|
||||
"EXCHANGE_API_KEY_FILE",
|
||||
"EXCHANGE_API_SECRET",
|
||||
"EXCHANGE_API_SECRET_FILE",
|
||||
)
|
||||
|
||||
_SECRET_ATTRIBUTES = (
|
||||
("BOT_TOKEN", "bot_token"),
|
||||
("DB_PASSWORD", "db_password"),
|
||||
("EXCHANGE_API_KEY", "exchange_api_key"),
|
||||
("EXCHANGE_API_SECRET", "exchange_api_secret"),
|
||||
)
|
||||
|
||||
|
||||
_TRADE_STREAM_VARIABLES = (
|
||||
"TRADE_STREAM_ENABLED",
|
||||
"TRADE_STREAM_WS_URL",
|
||||
@@ -30,6 +59,9 @@ _MARKET_DATA_STORAGE_VARIABLES = (
|
||||
def prepare_environment(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
for variable in _SECRET_VARIABLES:
|
||||
monkeypatch.delenv(variable, raising=False)
|
||||
|
||||
monkeypatch.setenv("BOT_TOKEN", "test-token")
|
||||
monkeypatch.delenv("EXCHANGE_BASE_URL", raising=False)
|
||||
monkeypatch.delenv("EXCHANGE_ENABLED", raising=False)
|
||||
@@ -389,3 +421,274 @@ def test_pool_max_size_must_not_be_smaller_than_min_size(
|
||||
match="POOL_MAX_SIZE",
|
||||
):
|
||||
load_settings()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("variable", "attribute"),
|
||||
_SECRET_ATTRIBUTES,
|
||||
)
|
||||
def test_secret_can_be_loaded_from_file(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
variable: str,
|
||||
attribute: str,
|
||||
) -> None:
|
||||
prepare_environment(monkeypatch)
|
||||
secret_file = tmp_path / f"{variable.lower()}.secret"
|
||||
secret_file.write_text("file-secret\n", encoding="utf-8")
|
||||
monkeypatch.delenv(variable, raising=False)
|
||||
monkeypatch.setenv(f"{variable}_FILE", str(secret_file))
|
||||
|
||||
settings = load_settings()
|
||||
|
||||
assert getattr(settings, attribute) == "file-secret"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("variable", "attribute"),
|
||||
_SECRET_ATTRIBUTES,
|
||||
)
|
||||
def test_direct_secret_remains_supported(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
variable: str,
|
||||
attribute: str,
|
||||
) -> None:
|
||||
prepare_environment(monkeypatch)
|
||||
monkeypatch.setenv(variable, " direct-secret ")
|
||||
|
||||
settings = load_settings()
|
||||
|
||||
assert getattr(settings, attribute) == "direct-secret"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"variable",
|
||||
tuple(variable for variable, _ in _SECRET_ATTRIBUTES),
|
||||
)
|
||||
def test_direct_and_file_secret_are_mutually_exclusive(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
variable: str,
|
||||
) -> None:
|
||||
prepare_environment(monkeypatch)
|
||||
secret_file = tmp_path / "secret"
|
||||
secret_file.write_text("file-secret\n", encoding="utf-8")
|
||||
monkeypatch.setenv(variable, "direct-secret")
|
||||
monkeypatch.setenv(f"{variable}_FILE", str(secret_file))
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match=f"{variable} and {variable}_FILE",
|
||||
):
|
||||
load_settings()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"variable",
|
||||
tuple(variable for variable, _ in _SECRET_ATTRIBUTES),
|
||||
)
|
||||
def test_secret_file_path_must_not_be_empty(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
variable: str,
|
||||
) -> None:
|
||||
prepare_environment(monkeypatch)
|
||||
monkeypatch.delenv(variable, raising=False)
|
||||
monkeypatch.setenv(f"{variable}_FILE", " ")
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match=f"{variable}_FILE must contain a non-empty file path",
|
||||
):
|
||||
load_settings()
|
||||
|
||||
|
||||
def test_secret_file_read_error_is_fail_fast(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
prepare_environment(monkeypatch)
|
||||
missing_file = tmp_path / "missing-secret"
|
||||
monkeypatch.setenv("DB_PASSWORD_FILE", str(missing_file))
|
||||
|
||||
with pytest.raises(RuntimeError) as error_info:
|
||||
load_settings()
|
||||
|
||||
assert str(error_info.value) == (
|
||||
"Unable to read secret configured by DB_PASSWORD_FILE"
|
||||
)
|
||||
assert str(missing_file) not in str(error_info.value)
|
||||
assert error_info.value.__cause__ is None
|
||||
assert error_info.value.__suppress_context__ is True
|
||||
formatted_traceback = "".join(
|
||||
traceback.format_exception(error_info.value)
|
||||
)
|
||||
assert str(missing_file) not in formatted_traceback
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"content",
|
||||
(
|
||||
"",
|
||||
"\n",
|
||||
"\r\n",
|
||||
" \n",
|
||||
),
|
||||
)
|
||||
def test_secret_file_rejects_empty_content(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
content: str,
|
||||
) -> None:
|
||||
prepare_environment(monkeypatch)
|
||||
secret_file = tmp_path / "empty-secret"
|
||||
secret_file.write_text(content, encoding="utf-8")
|
||||
monkeypatch.setenv("DB_PASSWORD_FILE", str(secret_file))
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match="DB_PASSWORD_FILE contains an empty secret",
|
||||
):
|
||||
load_settings()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("content", "expected"),
|
||||
(
|
||||
("secret\n", "secret"),
|
||||
("secret\r\n", "secret"),
|
||||
("secret\n\n", "secret\n"),
|
||||
(" secret \n", " secret "),
|
||||
),
|
||||
)
|
||||
def test_secret_file_removes_only_one_trailing_newline(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
content: str,
|
||||
expected: str,
|
||||
) -> None:
|
||||
prepare_environment(monkeypatch)
|
||||
secret_file = tmp_path / "secret"
|
||||
secret_file.write_text(content, encoding="utf-8", newline="")
|
||||
monkeypatch.setenv("DB_PASSWORD_FILE", str(secret_file))
|
||||
|
||||
settings = load_settings()
|
||||
|
||||
assert settings.db_password == expected
|
||||
|
||||
|
||||
def test_bot_token_requires_direct_or_file_secret(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
prepare_environment(monkeypatch)
|
||||
monkeypatch.delenv("BOT_TOKEN", raising=False)
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match="BOT_TOKEN or BOT_TOKEN_FILE is required",
|
||||
):
|
||||
load_settings()
|
||||
|
||||
|
||||
def test_secret_error_and_logs_do_not_contain_secret_value(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
prepare_environment(monkeypatch)
|
||||
unique_secret = "secret-never-log-06030-4b7c1a"
|
||||
secret_file = tmp_path / "bot-token"
|
||||
secret_file.write_text("file-secret\n", encoding="utf-8")
|
||||
monkeypatch.setenv("BOT_TOKEN", unique_secret)
|
||||
monkeypatch.setenv("BOT_TOKEN_FILE", str(secret_file))
|
||||
|
||||
with pytest.raises(RuntimeError) as error_info:
|
||||
load_settings()
|
||||
|
||||
assert str(error_info.value) == (
|
||||
"BOT_TOKEN and BOT_TOKEN_FILE must not be set together"
|
||||
)
|
||||
assert unique_secret not in str(error_info.value)
|
||||
assert unique_secret not in caplog.text
|
||||
|
||||
|
||||
def test_runtime_env_file_can_be_selected_before_config_import(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
runtime_env_file = tmp_path / "runtime.env"
|
||||
monkeypatch.setenv(
|
||||
"DZENTRA_RUNTIME_ENV_FILE",
|
||||
str(runtime_env_file),
|
||||
)
|
||||
|
||||
assert config_module._resolve_env_file() == runtime_env_file
|
||||
|
||||
|
||||
def test_blank_runtime_env_file_is_rejected(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("DZENTRA_RUNTIME_ENV_FILE", " ")
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match="DZENTRA_RUNTIME_ENV_FILE must contain a non-empty file path",
|
||||
):
|
||||
config_module._resolve_env_file()
|
||||
|
||||
|
||||
def test_runtime_env_file_is_loaded_at_import_and_shared_with_writer(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
runtime_env_file = tmp_path / "runtime.env"
|
||||
runtime_env_file.write_text(
|
||||
"DZENTRA_IMPORT_SENTINEL=loaded-from-runtime-file\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
environment = os.environ.copy()
|
||||
environment.pop("DZENTRA_IMPORT_SENTINEL", None)
|
||||
environment["DZENTRA_RUNTIME_ENV_FILE"] = str(runtime_env_file)
|
||||
command = (
|
||||
"import os; "
|
||||
"import src.telegram.routers; "
|
||||
"from src.core.config import ENV_FILE; "
|
||||
"import src.telegram.handlers.system as system_module; "
|
||||
"print(ENV_FILE); "
|
||||
"print(system_module.ENV_FILE); "
|
||||
"print(os.environ.get('DZENTRA_IMPORT_SENTINEL', ''))"
|
||||
)
|
||||
|
||||
completed = subprocess.run(
|
||||
[sys.executable, "-c", command],
|
||||
cwd=APP_ROOT,
|
||||
env=environment,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
assert completed.stdout.splitlines() == [
|
||||
str(runtime_env_file),
|
||||
str(runtime_env_file),
|
||||
"loaded-from-runtime-file",
|
||||
]
|
||||
|
||||
|
||||
def test_blank_runtime_env_file_fails_during_import() -> None:
|
||||
environment = os.environ.copy()
|
||||
environment["DZENTRA_RUNTIME_ENV_FILE"] = " "
|
||||
|
||||
completed = subprocess.run(
|
||||
[sys.executable, "-c", "from src.core.config import ENV_FILE"],
|
||||
cwd=APP_ROOT,
|
||||
env=environment,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
assert completed.returncode != 0
|
||||
assert (
|
||||
"DZENTRA_RUNTIME_ENV_FILE must contain a non-empty file path"
|
||||
in completed.stderr
|
||||
)
|
||||
|
||||
396
app/tests/unit/infra/test_docker_hardening.py
Normal file
396
app/tests/unit/infra/test_docker_hardening.py
Normal file
@@ -0,0 +1,396 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[4]
|
||||
DOCKERIGNORE = REPOSITORY_ROOT / ".dockerignore"
|
||||
DOCKERFILE = REPOSITORY_ROOT / "infra" / "docker" / "Dockerfile"
|
||||
REQUIREMENTS_LOCK = REPOSITORY_ROOT / "app" / "requirements.lock"
|
||||
POSTGRES_DOCKERFILE = (
|
||||
REPOSITORY_ROOT / "infra" / "docker" / "postgres" / "Dockerfile"
|
||||
)
|
||||
COMPOSE_FILE = (
|
||||
REPOSITORY_ROOT / "infra" / "compose" / "docker-compose.yml"
|
||||
)
|
||||
EXCHANGE_COMPOSE_FILE = (
|
||||
REPOSITORY_ROOT
|
||||
/ "infra"
|
||||
/ "compose"
|
||||
/ "docker-compose.exchange-auth.yml"
|
||||
)
|
||||
POSTGRES_INIT_SCRIPT = (
|
||||
REPOSITORY_ROOT
|
||||
/ "infra"
|
||||
/ "docker"
|
||||
/ "postgres"
|
||||
/ "init-application-role.sh"
|
||||
)
|
||||
POSTGRES_HEALTHCHECK_SCRIPT = (
|
||||
REPOSITORY_ROOT
|
||||
/ "infra"
|
||||
/ "docker"
|
||||
/ "postgres"
|
||||
/ "healthcheck-application-role.sh"
|
||||
)
|
||||
|
||||
PYTHON_IMAGE = (
|
||||
"python:3.12.13-slim-bookworm@sha256:"
|
||||
"d50fb7611f86d04a3b0471b46d7557818d88983fc3136726336b2a4c657aa30b"
|
||||
)
|
||||
POSTGRES_IMAGE = (
|
||||
"postgres:16.14-alpine@sha256:"
|
||||
"57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777"
|
||||
)
|
||||
|
||||
|
||||
def read_text(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def yaml_mapping_block(
|
||||
content: str,
|
||||
*,
|
||||
key: str,
|
||||
indentation: int,
|
||||
) -> str:
|
||||
lines = content.splitlines()
|
||||
marker = f"{' ' * indentation}{key}:"
|
||||
|
||||
try:
|
||||
start = lines.index(marker)
|
||||
except ValueError as error:
|
||||
raise AssertionError(f"YAML block {key!r} not found") from error
|
||||
|
||||
end = len(lines)
|
||||
for index in range(start + 1, len(lines)):
|
||||
line = lines[index]
|
||||
if not line.strip():
|
||||
continue
|
||||
current_indentation = len(line) - len(line.lstrip())
|
||||
if current_indentation <= indentation:
|
||||
end = index
|
||||
break
|
||||
|
||||
return "\n".join(lines[start:end])
|
||||
|
||||
|
||||
def test_docker_context_is_an_explicit_production_allowlist() -> None:
|
||||
content = read_text(DOCKERIGNORE)
|
||||
active_lines = tuple(
|
||||
line.strip()
|
||||
for line in content.splitlines()
|
||||
if line.strip() and not line.lstrip().startswith("#")
|
||||
)
|
||||
expected_inclusions = {
|
||||
"!.dockerignore",
|
||||
"!infra/",
|
||||
"!infra/docker/",
|
||||
"!infra/docker/Dockerfile",
|
||||
"!infra/docker/postgres/",
|
||||
"!infra/docker/postgres/Dockerfile",
|
||||
"!infra/docker/postgres/healthcheck-application-role.sh",
|
||||
"!infra/docker/postgres/init-application-role.sh",
|
||||
"!app/",
|
||||
"!app/requirements.txt",
|
||||
"!app/requirements.lock",
|
||||
"!app/src/",
|
||||
"!app/src/**",
|
||||
}
|
||||
|
||||
assert active_lines[0] == "**"
|
||||
assert {
|
||||
line
|
||||
for line in active_lines
|
||||
if line.startswith("!")
|
||||
} == expected_inclusions
|
||||
assert not any(
|
||||
line.startswith("!app/.env")
|
||||
or line.startswith("!app/.venv")
|
||||
or line.startswith("!app/tests")
|
||||
for line in active_lines
|
||||
)
|
||||
assert "app/src/**/__pycache__/" in active_lines
|
||||
assert "app/src/**/*.py[cod]" in active_lines
|
||||
assert {
|
||||
"app/src/**/.env",
|
||||
"app/src/**/.env.*",
|
||||
"app/src/**/*.pem",
|
||||
"app/src/**/*.key",
|
||||
"app/src/**/*.p12",
|
||||
"app/src/**/*.pfx",
|
||||
"app/src/**/*.log",
|
||||
"app/src/**/*.dump",
|
||||
"app/src/**/*.sqlite",
|
||||
"app/src/**/*.sqlite3",
|
||||
}.issubset(active_lines)
|
||||
|
||||
|
||||
def test_dockerfile_uses_pinned_minimal_non_root_image() -> None:
|
||||
content = read_text(DOCKERFILE)
|
||||
copy_or_add_instructions = tuple(
|
||||
line.strip()
|
||||
for line in content.splitlines()
|
||||
if line.lstrip().startswith(("COPY ", "ADD "))
|
||||
)
|
||||
|
||||
assert content.splitlines()[0] == f"FROM {PYTHON_IMAGE}"
|
||||
assert copy_or_add_instructions == (
|
||||
"COPY app/requirements.lock ./requirements.lock",
|
||||
"COPY --chown=10001:10001 app/src ./src",
|
||||
)
|
||||
assert "--require-hashes" in content
|
||||
assert "--requirement requirements.lock" in content
|
||||
assert "USER 10001:10001" in content
|
||||
assert "STOPSIGNAL SIGINT" in content
|
||||
assert "/var/lib/dzentra/runtime.env" in content
|
||||
|
||||
|
||||
def test_compose_requires_explicit_project_namespace() -> None:
|
||||
content = read_text(COMPOSE_FILE)
|
||||
|
||||
assert content.splitlines()[0] == (
|
||||
"name: ${DZENTRA_COMPOSE_PROJECT_NAME:"
|
||||
"?DZENTRA_COMPOSE_PROJECT_NAME must be set}"
|
||||
)
|
||||
|
||||
|
||||
def test_production_dependencies_have_complete_hash_lock() -> None:
|
||||
content = read_text(REQUIREMENTS_LOCK)
|
||||
package_matches = tuple(re.finditer(
|
||||
r"^([a-z0-9][a-z0-9._-]*)==([^ \\;]+)",
|
||||
content,
|
||||
re.MULTILINE,
|
||||
))
|
||||
|
||||
assert len(package_matches) == 25
|
||||
for index, package_match in enumerate(package_matches):
|
||||
block_end = (
|
||||
package_matches[index + 1].start()
|
||||
if index + 1 < len(package_matches)
|
||||
else len(content)
|
||||
)
|
||||
assert "--hash=sha256:" in content[package_match.start():block_end]
|
||||
|
||||
assert "pytest==" not in content
|
||||
assert "pyright==" not in content
|
||||
|
||||
|
||||
def test_compose_uses_pinned_internal_postgres() -> None:
|
||||
content = read_text(COMPOSE_FILE)
|
||||
postgres = yaml_mapping_block(
|
||||
content,
|
||||
key="postgres",
|
||||
indentation=2,
|
||||
)
|
||||
|
||||
postgres_dockerfile = read_text(POSTGRES_DOCKERFILE)
|
||||
|
||||
assert postgres_dockerfile.splitlines()[0] == f"FROM {POSTGRES_IMAGE}"
|
||||
assert postgres_dockerfile.count("COPY --chmod=0555") == 2
|
||||
assert "ADD " not in postgres_dockerfile
|
||||
assert "init-application-role.sh" in postgres_dockerfile
|
||||
assert "healthcheck-application-role.sh" in postgres_dockerfile
|
||||
assert "dockerfile: infra/docker/postgres/Dockerfile" in postgres
|
||||
assert "dzentra-postgres:16.14-hardened" in postgres
|
||||
assert "pull_policy: build" in postgres
|
||||
assert (
|
||||
"POSTGRES_PASSWORD_FILE: /run/secrets/postgres_admin_password"
|
||||
in postgres
|
||||
)
|
||||
assert "APP_DB_USER: ${DB_USER:-dzentra_bot}" in postgres
|
||||
assert "postgres_admin_password" in postgres
|
||||
assert "db_password" in postgres
|
||||
assert not re.search(r"^\s+POSTGRES_PASSWORD:\s", postgres, re.MULTILINE)
|
||||
assert "ports:" not in postgres
|
||||
assert "container_name:" not in postgres
|
||||
assert "privileged:" not in postgres
|
||||
assert "network_mode:" not in postgres
|
||||
assert yaml_mapping_block(
|
||||
content,
|
||||
key="networks",
|
||||
indentation=0,
|
||||
).rstrip() == (
|
||||
"networks:\n"
|
||||
" database:\n"
|
||||
" internal: true\n"
|
||||
" egress:"
|
||||
)
|
||||
assert 'restart: "no"' in postgres
|
||||
assert yaml_mapping_block(
|
||||
postgres,
|
||||
key="security_opt",
|
||||
indentation=4,
|
||||
) == " security_opt:\n - no-new-privileges:true"
|
||||
assert yaml_mapping_block(
|
||||
postgres,
|
||||
key="cap_drop",
|
||||
indentation=4,
|
||||
) == " cap_drop:\n - ALL"
|
||||
assert yaml_mapping_block(
|
||||
postgres,
|
||||
key="cap_add",
|
||||
indentation=4,
|
||||
) == (
|
||||
" cap_add:\n"
|
||||
" - CHOWN\n"
|
||||
" - DAC_OVERRIDE\n"
|
||||
" - FOWNER\n"
|
||||
" - SETGID\n"
|
||||
" - SETUID"
|
||||
)
|
||||
assert yaml_mapping_block(
|
||||
postgres,
|
||||
key="networks",
|
||||
indentation=4,
|
||||
) == " networks:\n - database"
|
||||
assert yaml_mapping_block(
|
||||
postgres,
|
||||
key="volumes",
|
||||
indentation=4,
|
||||
) == (
|
||||
" volumes:\n"
|
||||
" - dzentra_postgres_data:/var/lib/postgresql/data"
|
||||
)
|
||||
assert yaml_mapping_block(
|
||||
postgres,
|
||||
key="tmpfs",
|
||||
indentation=4,
|
||||
) == (
|
||||
" tmpfs:\n"
|
||||
" - /tmp:rw,noexec,nosuid,nodev,size=64m\n"
|
||||
" - /var/run/postgresql:rw,nosuid,nodev,size=16m"
|
||||
)
|
||||
assert "read_only: true" in postgres
|
||||
assert "POSTGRES_STOP_GRACE_PERIOD must be set" in postgres
|
||||
assert "/usr/local/bin/dzentra-postgres-healthcheck" in postgres
|
||||
|
||||
|
||||
def test_compose_does_not_inject_direct_application_secrets() -> None:
|
||||
content = read_text(COMPOSE_FILE)
|
||||
bot = yaml_mapping_block(
|
||||
content,
|
||||
key="bot",
|
||||
indentation=2,
|
||||
)
|
||||
|
||||
assert "env_file:" not in bot
|
||||
assert "privileged:" not in bot
|
||||
assert "network_mode:" not in bot
|
||||
assert "BOT_TOKEN_FILE: /run/secrets/bot_token" in bot
|
||||
assert "DB_PASSWORD_FILE: /run/secrets/db_password" in bot
|
||||
assert not re.search(r"^\s+BOT_TOKEN:\s", bot, re.MULTILINE)
|
||||
assert not re.search(r"^\s+DB_PASSWORD:\s", bot, re.MULTILINE)
|
||||
assert not re.search(r"^\s+EXCHANGE_API_KEY:\s", bot, re.MULTILINE)
|
||||
assert not re.search(
|
||||
r"^\s+EXCHANGE_API_SECRET:\s",
|
||||
bot,
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
|
||||
def test_compose_hardens_bot_and_preserves_runtime_settings() -> None:
|
||||
content = read_text(COMPOSE_FILE)
|
||||
bot = yaml_mapping_block(
|
||||
content,
|
||||
key="bot",
|
||||
indentation=2,
|
||||
)
|
||||
|
||||
assert 'user: "10001:10001"' in bot
|
||||
assert "pull_policy: build" in bot
|
||||
assert 'restart: "no"' in bot
|
||||
assert yaml_mapping_block(
|
||||
bot,
|
||||
key="security_opt",
|
||||
indentation=4,
|
||||
) == " security_opt:\n - no-new-privileges:true"
|
||||
assert yaml_mapping_block(
|
||||
bot,
|
||||
key="cap_drop",
|
||||
indentation=4,
|
||||
) == " cap_drop:\n - ALL"
|
||||
assert " cap_add:" not in bot
|
||||
assert yaml_mapping_block(
|
||||
bot,
|
||||
key="networks",
|
||||
indentation=4,
|
||||
) == " networks:\n - database\n - egress"
|
||||
assert yaml_mapping_block(
|
||||
bot,
|
||||
key="volumes",
|
||||
indentation=4,
|
||||
) == (
|
||||
" volumes:\n"
|
||||
" - dzentra_runtime_config:/var/lib/dzentra"
|
||||
)
|
||||
assert yaml_mapping_block(
|
||||
bot,
|
||||
key="tmpfs",
|
||||
indentation=4,
|
||||
) == " tmpfs:\n - /tmp:rw,noexec,nosuid,nodev,size=64m"
|
||||
assert "read_only: true" in bot
|
||||
assert "init: true" in bot
|
||||
assert "BOT_STOP_GRACE_PERIOD must be set" in bot
|
||||
assert "DZENTRA_RUNTIME_ENV_FILE: /var/lib/dzentra/runtime.env" in bot
|
||||
assert "dzentra_runtime_config:/var/lib/dzentra" in bot
|
||||
|
||||
|
||||
def test_compose_declares_file_backed_secret_sources() -> None:
|
||||
content = read_text(COMPOSE_FILE)
|
||||
|
||||
assert "POSTGRES_ADMIN_PASSWORD_SECRET_FILE must be set" in content
|
||||
assert "BOT_TOKEN_SECRET_FILE must be set" in content
|
||||
assert "DB_PASSWORD_SECRET_FILE must be set" in content
|
||||
assert "file: ${BOT_TOKEN_SECRET_FILE:" in content
|
||||
assert "file: ${DB_PASSWORD_SECRET_FILE:" in content
|
||||
|
||||
|
||||
def test_exchange_credentials_have_explicit_opt_in_override() -> None:
|
||||
content = read_text(EXCHANGE_COMPOSE_FILE)
|
||||
|
||||
assert "EXCHANGE_API_KEY_FILE: /run/secrets/exchange_api_key" in content
|
||||
assert (
|
||||
"EXCHANGE_API_SECRET_FILE: /run/secrets/exchange_api_secret"
|
||||
in content
|
||||
)
|
||||
assert "EXCHANGE_API_KEY_SOURCE_FILE must be set" in content
|
||||
assert "EXCHANGE_API_SECRET_SOURCE_FILE must be set" in content
|
||||
assert not re.search(r"^\s+EXCHANGE_API_KEY:\s", content, re.MULTILINE)
|
||||
assert not re.search(
|
||||
r"^\s+EXCHANGE_API_SECRET:\s",
|
||||
content,
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
|
||||
def test_postgres_bootstrap_separates_admin_and_application_roles() -> None:
|
||||
content = read_text(POSTGRES_INIT_SCRIPT)
|
||||
|
||||
assert 'if [ "$APP_DB_USER" = "$POSTGRES_USER" ]' in content
|
||||
assert "Пароли PostgreSQL admin и application roles должны различаться" in content
|
||||
assert "postgres_admin_password=$POSTGRES_PASSWORD" in content
|
||||
assert "read_secret_file /run/secrets/postgres_admin_password" not in content
|
||||
assert "--single-transaction" in content
|
||||
assert "--no-psqlrc" in content
|
||||
assert "\\getenv app_password DZENTRA_APP_DB_PASSWORD" in content
|
||||
assert "NOSUPERUSER NOCREATEDB" in content
|
||||
assert "NOCREATEROLE INHERIT NOREPLICATION NOBYPASSRLS" in content
|
||||
assert "ALTER DATABASE %I OWNER TO %I" in content
|
||||
assert "GRANT USAGE, CREATE ON SCHEMA public TO %I" in content
|
||||
assert "--set app_password" not in content
|
||||
|
||||
|
||||
def test_postgres_healthcheck_rejects_privileged_application_role() -> None:
|
||||
content = read_text(POSTGRES_HEALTHCHECK_SCRIPT)
|
||||
|
||||
assert "NOT rolsuper" in content
|
||||
assert "NOT rolcreatedb" in content
|
||||
assert "NOT rolcreaterole" in content
|
||||
assert "NOT rolreplication" in content
|
||||
assert "NOT rolbypassrls" in content
|
||||
assert "pg_catalog.pg_auth_members" in content
|
||||
assert "member_role.rolname = :'app_user'" in content
|
||||
assert "pg_catalog.pg_get_userbyid" in content
|
||||
assert "test \"$role_status\" = \"1\"" in content
|
||||
Reference in New Issue
Block a user