diff --git a/backend/tests/AGENTS.md b/backend/tests/AGENTS.md index 70b8b90b74..7c8c6b9e96 100644 --- a/backend/tests/AGENTS.md +++ b/backend/tests/AGENTS.md @@ -358,3 +358,10 @@ setup, after the autouse fixtures initialize runtime, to avoid the package cycle ### Web model provider presets `test_managed_provider_presets.py` covers encrypted compatibility, auto IDs, key switching, snapshot isolation and native/compatible protocols. Its HTTP double is below actual LangChain/vendor SDK serialization; do not replace these adapters with successful stubs. `test_managed_presets_live.py` requires explicit opt-in and vendor credentials, and remains skipped in CI. + +## Multi-instance harness + +Multi-instance harness tests use `support.shell.require_script_bash()` for shell +smoke checks. Keep config content and secret stability checks portable; isolate +POSIX owner-only permission assertions so Windows skips only those checks. +Nginx path expectations must include quoted-string escaping for native paths. diff --git a/backend/tests/test_dev_multi_instance_script.py b/backend/tests/test_dev_multi_instance_script.py index f3f895e635..2cd9f668e1 100644 --- a/backend/tests/test_dev_multi_instance_script.py +++ b/backend/tests/test_dev_multi_instance_script.py @@ -11,6 +11,7 @@ from __future__ import annotations import base64 import importlib.util +import json import logging import os import shutil @@ -21,6 +22,7 @@ from pathlib import Path import pytest import yaml +from support.shell import require_script_bash from app.gateway.deps import _enforce_postgres_for_multi_worker, _validate_memory_retrieval_index from deerflow.config.app_config import AppConfig @@ -361,7 +363,7 @@ def test_each_process_resolves_its_own_retrieval_index(tmp_path, monkeypatch): assert resolved[0] != resolved[1] -def test_render_config_writes_a_private_file_with_a_generated_header(tmp_path): +def test_render_config_writes_a_file_with_a_generated_header(tmp_path): out = tmp_path / "nested" / "config.yaml" notes = mi.render_config(REPO_ROOT / "config.example.yaml", out, database_url=DATABASE_URL, redis_url=REDIS_URL, state_dir=tmp_path) @@ -369,7 +371,6 @@ def test_render_config_writes_a_private_file_with_a_generated_header(tmp_path): assert isinstance(notes, list) text = out.read_text(encoding="utf-8") assert text.startswith("# Generated by scripts/dev_multi_instance.sh") - assert stat.S_IMODE(out.stat().st_mode) == 0o600, "the base config may carry model API keys" assert yaml.safe_load(text)["deployment"]["multi_instance"] is True @@ -378,7 +379,7 @@ def test_render_config_writes_a_private_file_with_a_generated_header(tmp_path): # --------------------------------------------------------------------------- -def test_secrets_file_is_private_complete_and_stable(tmp_path): +def test_secrets_file_is_complete_and_stable(tmp_path): path = tmp_path / "secrets.env" first = mi.ensure_secrets_file(path) @@ -389,11 +390,22 @@ def test_secrets_file_is_private_complete_and_stable(tmp_path): assert all(value for value in first.values()) assert len(base64.urlsafe_b64decode(first["DEER_FLOW_CREDENTIALS_KEY"])) == 32, "must be a raw Fernet key" assert first["AUTH_JWT_SECRET"] != first["DEER_FLOW_INTERNAL_AUTH_TOKEN"] - assert stat.S_IMODE(path.stat().st_mode) == 0o600 # Shell-sourceable KEY=value lines. assert sorted(line.split("=", 1)[0] for line in path.read_text(encoding="utf-8").splitlines() if line and not line.startswith("#")) == sorted(mi.SHARED_SECRET_NAMES) +@pytest.mark.skipif(os.name == "nt", reason="chmod cannot express owner-only modes on Windows") +@pytest.mark.parametrize("output_kind", ["config", "secrets"]) +def test_generated_files_have_owner_only_permissions(tmp_path, output_kind): + path = tmp_path / ("config.yaml" if output_kind == "config" else "secrets.env") + if output_kind == "config": + mi.render_config(REPO_ROOT / "config.example.yaml", path, database_url=DATABASE_URL, redis_url=REDIS_URL, state_dir=tmp_path) + else: + mi.ensure_secrets_file(path) + + assert stat.S_IMODE(path.stat().st_mode) == 0o600, "generated files may carry credentials" + + def test_nginx_conf_balances_both_gateways(tmp_path): text = mi.render_nginx_conf(listen_port=2027, gateway_ports=[8001, 8011], frontend_port=3000, state_dir=tmp_path) @@ -401,7 +413,7 @@ def test_nginx_conf_balances_both_gateways(tmp_path): assert "server 127.0.0.1:8001;" in text assert "server 127.0.0.1:8011;" in text assert "X-DeerFlow-Upstream $upstream_addr" in text - assert f'"{tmp_path / "run" / "nginx.pid"}"' in text + assert f"pid {json.dumps(str(tmp_path / 'run' / 'nginx.pid'), ensure_ascii=False)};" in text def test_nginx_conf_quotes_paths_with_whitespace(tmp_path): @@ -409,9 +421,9 @@ def test_nginx_conf_quotes_paths_with_whitespace(tmp_path): text = mi.render_nginx_conf(listen_port=2027, gateway_ports=[8001, 8011], frontend_port=3000, state_dir=state) - assert f'pid "{state / "run" / "nginx.pid"}";' in text - assert f'access_log "{state / "logs" / "nginx-access.log"}";' in text - assert f'proxy_temp_path "{state / "nginx" / "temp" / "proxy"}";' in text + assert f"pid {json.dumps(str(state / 'run' / 'nginx.pid'), ensure_ascii=False)};" in text + assert f"access_log {json.dumps(str(state / 'logs' / 'nginx-access.log'), ensure_ascii=False)};" in text + assert f"proxy_temp_path {json.dumps(str(state / 'nginx' / 'temp' / 'proxy'), ensure_ascii=False)};" in text def test_nginx_conf_rejects_paths_nginx_would_interpolate(tmp_path): @@ -511,7 +523,7 @@ def test_sse_read_enforces_its_budget_while_only_heartbeats_arrive(tmp_path): def test_shell_script_parses(): - result = subprocess.run(["bash", "-n", str(SHELL_PATH)], capture_output=True, text=True, timeout=30) + result = subprocess.run([require_script_bash(), "-n", str(SHELL_PATH)], capture_output=True, text=True, timeout=30) assert result.returncode == 0, result.stderr @@ -520,7 +532,7 @@ def test_shell_script_parses(): def test_shell_script_help_lists_every_command_without_side_effects(tmp_path, flag): env = {**os.environ, "DEERFLOW_MI_STATE_DIR": str(tmp_path / "state"), "PATH": os.environ.get("PATH", "")} - result = subprocess.run(["bash", str(SHELL_PATH), flag], capture_output=True, text=True, timeout=30, env=env) + result = subprocess.run([require_script_bash(), str(SHELL_PATH), flag], capture_output=True, text=True, timeout=30, env=env) assert result.returncode == 0, result.stderr for command in ("up", "down", "status", "logs", "check", "restart"): @@ -531,7 +543,7 @@ def test_shell_script_help_lists_every_command_without_side_effects(tmp_path, fl def test_shell_script_rejects_unknown_commands(tmp_path): env = {**os.environ, "DEERFLOW_MI_STATE_DIR": str(tmp_path / "state")} - result = subprocess.run(["bash", str(SHELL_PATH), "frobnicate"], capture_output=True, text=True, timeout=30, env=env) + result = subprocess.run([require_script_bash(), str(SHELL_PATH), "frobnicate"], capture_output=True, text=True, timeout=30, env=env) assert result.returncode != 0 assert "Unknown command" in result.stderr