* stage-3664: Windows pytest harness hardening (test-only batch #4254/4255/4256/4257/4259) * test(#3664): Windows pytest-harness compatibility batch Combines 5 rodboev test-only PRs (#4254/#4255/#4256/#4257/#4259), all under tests/. Linux assertion paths preserved at full strictness; relaxations + new markers are Windows/no-fork gated. Codex SAFE + Opus SHIP + changed tests green (cascade-only full-suite noise is the box artifact). No version stamp (tests-only, Nathan rule). Co-authored-by: rodboev <rodboev@users.noreply.github.com> --------- Co-authored-by: nesquena-hermes <agent@nesquena-hermes> Co-authored-by: rodboev <rodboev@users.noreply.github.com>
This commit is contained in:
10 files changed
+187
-28
No files matched your search
@@ -3,6 +3,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Internal
|
||||
|
||||
- **Windows pytest-harness compatibility (#3664).** Hardened the test suite to run on Windows: profile-home fallback paths are path-normalized, strict POSIX file-mode (`0o600`) assertions are gated behind `os.name != "nt"` (Linux still asserts them at full strictness), the conftest cleanup handles Windows process-tree/port teardown and the Py3.12+ `shutil.rmtree` `onexc` shim, and tests that require `fork`/`fcntl` carry `@requires_fork` / `@requires_fcntl` markers (which never skip on Linux). Test-only — no runtime or app behavior change, no Linux CI behavior change. (#4254, #4255, #4256, #4257, #4259)
|
||||
|
||||
## [v0.51.438] — 2026-06-15 — Release OY (model cost/health comparison table on Insights)
|
||||
|
||||
### Added
|
||||
|
||||
+134
-18
@@ -14,6 +14,8 @@ PATH DISCOVERY:
|
||||
4. System python3 as a last resort
|
||||
"""
|
||||
import json
|
||||
import inspect
|
||||
import multiprocessing
|
||||
import os
|
||||
import pathlib
|
||||
import shutil
|
||||
@@ -32,6 +34,16 @@ if not (3, 11) <= sys.version_info[:2] <= (3, 13):
|
||||
returncode=3,
|
||||
)
|
||||
|
||||
WINDOWS = sys.platform == "win32"
|
||||
requires_fcntl = pytest.mark.skipif(
|
||||
WINDOWS,
|
||||
reason="requires fcntl-backed nonblocking pipe reads",
|
||||
)
|
||||
requires_fork = pytest.mark.skipif(
|
||||
"fork" not in multiprocessing.get_all_start_methods(),
|
||||
reason="requires multiprocessing fork",
|
||||
)
|
||||
|
||||
# ── Repo root discovery ────────────────────────────────────────────────────
|
||||
# conftest.py lives at <repo>/tests/conftest.py
|
||||
TESTS_DIR = pathlib.Path(__file__).parent.resolve()
|
||||
@@ -244,6 +256,8 @@ requires_agent_modules = pytest.mark.skipif(
|
||||
def pytest_configure(config):
|
||||
config.addinivalue_line("markers", "requires_agent: skip when hermes-agent dir is not found")
|
||||
config.addinivalue_line("markers", "requires_agent_modules: skip when hermes-agent Python modules are not importable")
|
||||
config.addinivalue_line("markers", "requires_fcntl: skip when fcntl-backed file-descriptor operations are unavailable")
|
||||
config.addinivalue_line("markers", "requires_fork: skip when the platform lacks multiprocessing fork support")
|
||||
|
||||
|
||||
@pytest.hookimpl(hookwrapper=True, tryfirst=True)
|
||||
@@ -601,6 +615,120 @@ def _server_boot_diagnostic(headline, log_path):
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _kill_process_tree(pid):
|
||||
"""Best-effort kill for a known fixture-owned or port-owning PID."""
|
||||
if not pid or pid <= 0:
|
||||
return
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
subprocess.run(
|
||||
["taskkill", "/PID", str(pid), "/T", "/F"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=5,
|
||||
**({"creationflags": subprocess.CREATE_NO_WINDOW}),
|
||||
)
|
||||
return
|
||||
import signal
|
||||
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _kill_port_owner(port):
|
||||
"""Best-effort free of TEST_PORT, using a Windows-native owner lookup."""
|
||||
try:
|
||||
if sys.platform != "win32":
|
||||
subprocess.run(["fuser", "-k", f"{port}/tcp"], capture_output=True, timeout=5)
|
||||
return
|
||||
|
||||
creationflags = {"creationflags": subprocess.CREATE_NO_WINDOW}
|
||||
ps_cmd = (
|
||||
"$port = " + str(port) + "; "
|
||||
"$conn = Get-NetTCPConnection -LocalPort $port -ErrorAction SilentlyContinue | "
|
||||
"Where-Object { $_.LocalAddress -in @('127.0.0.1', '::1', '0.0.0.0', '::', '::ffff:127.0.0.1') } | "
|
||||
"Select-Object -First 1 -ExpandProperty OwningProcess; "
|
||||
"if ($conn) { $conn }"
|
||||
)
|
||||
proc = subprocess.run(
|
||||
["powershell", "-NoProfile", "-NonInteractive", "-Command", ps_cmd],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
**creationflags,
|
||||
)
|
||||
pid_text = proc.stdout.strip()
|
||||
if not pid_text:
|
||||
proc = subprocess.run(
|
||||
["netstat", "-ano", "-p", "tcp"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
**creationflags,
|
||||
)
|
||||
port_suffixes = (f":{port}", f"[::]:{port}", f"127.0.0.1:{port}", f"[::1]:{port}")
|
||||
for line in proc.stdout.splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) < 5 or parts[0].upper() != "TCP":
|
||||
continue
|
||||
local_addr = parts[1]
|
||||
state = parts[3].upper()
|
||||
owner_pid = parts[4]
|
||||
if state == "LISTENING" and any(local_addr.endswith(suffix) for suffix in port_suffixes):
|
||||
pid_text = owner_pid
|
||||
break
|
||||
if pid_text:
|
||||
_kill_process_tree(int(pid_text))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _rmtree_retry(path):
|
||||
"""Remove a tree, retrying on Windows after clearing read-only bits."""
|
||||
target = pathlib.Path(path)
|
||||
if not target.exists():
|
||||
return
|
||||
if sys.platform != "win32":
|
||||
shutil.rmtree(target)
|
||||
return
|
||||
|
||||
def _clear_readonly(_func, entry, _exc):
|
||||
try:
|
||||
os.chmod(entry, 0o666)
|
||||
_func(entry)
|
||||
except Exception:
|
||||
raise
|
||||
|
||||
rmtree_kwargs = (
|
||||
{"onexc": _clear_readonly}
|
||||
if "onexc" in inspect.signature(shutil.rmtree).parameters
|
||||
else {"onerror": _clear_readonly}
|
||||
)
|
||||
|
||||
attempts = 5
|
||||
last_exc = None
|
||||
for attempt in range(1, attempts + 1):
|
||||
try:
|
||||
shutil.rmtree(target, **rmtree_kwargs)
|
||||
return
|
||||
except FileNotFoundError:
|
||||
return
|
||||
except Exception as exc:
|
||||
last_exc = exc
|
||||
if attempt == attempts:
|
||||
break
|
||||
time.sleep(0.3)
|
||||
|
||||
leftovers = []
|
||||
try:
|
||||
leftovers = [child.name for child in list(target.iterdir())[:5]]
|
||||
except Exception:
|
||||
pass
|
||||
leftover_note = f" leftovers={leftovers}" if leftovers else ""
|
||||
raise RuntimeError(f"Could not remove {target} after {attempts} attempts.{leftover_note}") from last_exc
|
||||
|
||||
|
||||
# ── Session-scoped test server ────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
@@ -612,18 +740,13 @@ def test_server():
|
||||
# Kill any leftover process on the test port before starting.
|
||||
# Stale servers from QA harness runs or prior test sessions cause
|
||||
# conftest to think the server is already up, producing false failures.
|
||||
try:
|
||||
import subprocess as _sp
|
||||
_sp.run(['fuser', '-k', f'{TEST_PORT}/tcp'],
|
||||
capture_output=True, timeout=5)
|
||||
except Exception:
|
||||
pass
|
||||
_kill_port_owner(TEST_PORT)
|
||||
import time as _time
|
||||
_time.sleep(0.5) # brief pause to let the port release
|
||||
|
||||
# Clean slate
|
||||
if TEST_STATE_DIR.exists():
|
||||
shutil.rmtree(TEST_STATE_DIR)
|
||||
_rmtree_retry(TEST_STATE_DIR)
|
||||
TEST_STATE_DIR.mkdir(parents=True)
|
||||
TEST_WORKSPACE.mkdir(parents=True)
|
||||
|
||||
@@ -759,16 +882,12 @@ def test_server():
|
||||
last_reason = reason
|
||||
# Tear down the failed attempt and free the port before retrying.
|
||||
try:
|
||||
proc.kill()
|
||||
_kill_process_tree(proc.pid)
|
||||
proc.wait(timeout=5)
|
||||
except Exception:
|
||||
pass
|
||||
if _attempt < boot_attempts:
|
||||
try:
|
||||
import subprocess as _sp2
|
||||
_sp2.run(['fuser', '-k', f'{TEST_PORT}/tcp'], capture_output=True, timeout=5)
|
||||
except Exception:
|
||||
pass
|
||||
_kill_port_owner(TEST_PORT)
|
||||
time.sleep(1.0)
|
||||
else:
|
||||
pytest.fail(
|
||||
@@ -787,12 +906,9 @@ def test_server():
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
_kill_process_tree(proc.pid)
|
||||
|
||||
try:
|
||||
shutil.rmtree(TEST_STATE_DIR)
|
||||
except Exception:
|
||||
pass
|
||||
_rmtree_retry(TEST_STATE_DIR)
|
||||
|
||||
|
||||
# ── Test base URL ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -79,7 +79,7 @@ class TestSessionPersistence(unittest.TestCase):
|
||||
self.assertIn("valid_token", auth._sessions)
|
||||
|
||||
def test_sessions_file_permissions(self) -> None:
|
||||
"""Sessions file must be owner-read-only (0600)."""
|
||||
"""Sessions file must stay strict on POSIX and still be created on Windows."""
|
||||
auth.create_session()
|
||||
# Check the path auth actually writes to (auth._SESSIONS_FILE is computed
|
||||
# from api.config.STATE_DIR at import time). Asserting against a local
|
||||
@@ -87,6 +87,9 @@ class TestSessionPersistence(unittest.TestCase):
|
||||
# this module may import before api.config.STATE_DIR resolves to _TEST_STATE.
|
||||
sessions_file = auth._SESSIONS_FILE
|
||||
self.assertTrue(sessions_file.exists(), ".sessions.json was not created")
|
||||
if os.name == "nt":
|
||||
self.assertTrue(sessions_file.is_file(), ".sessions.json was not written as a file")
|
||||
return
|
||||
mode = oct(sessions_file.stat().st_mode & 0o777)
|
||||
self.assertEqual(mode, oct(0o600),
|
||||
f".sessions.json permissions {mode} — expected 0o600")
|
||||
|
||||
@@ -231,10 +231,11 @@ class TestMainForegroundRouting:
|
||||
def stub_main_dependencies(self, monkeypatch, tmp_path):
|
||||
"""Stub out everything main() calls except the routing decision."""
|
||||
import bootstrap as bs
|
||||
python_exe = sys.executable
|
||||
monkeypatch.setattr(bs, "ensure_supported_platform", lambda: None)
|
||||
monkeypatch.setattr(bs, "discover_agent_dir", lambda: tmp_path / "agent")
|
||||
monkeypatch.setattr(bs, "hermes_command_exists", lambda: True)
|
||||
monkeypatch.setattr(bs, "discover_launcher_python", lambda *a: "/usr/bin/python3")
|
||||
monkeypatch.setattr(bs, "discover_launcher_python", lambda *a: python_exe)
|
||||
monkeypatch.setattr(bs, "ensure_python_has_webui_deps", lambda *a, **kw: a[0])
|
||||
monkeypatch.setattr(bs, "wait_for_health", lambda *a, **kw: True)
|
||||
monkeypatch.setattr(bs, "open_browser", lambda *a, **kw: None)
|
||||
@@ -265,6 +266,7 @@ class TestMainForegroundRouting:
|
||||
def test_foreground_flag_uses_execv(self, stub_main_dependencies, clean_env, monkeypatch):
|
||||
bs = stub_main_dependencies
|
||||
monkeypatch.setattr(sys, "argv", ["bootstrap.py", "--foreground"])
|
||||
monkeypatch.setattr(sys, "platform", "linux")
|
||||
|
||||
execv_calls = []
|
||||
popen_calls = []
|
||||
@@ -289,9 +291,10 @@ class TestMainForegroundRouting:
|
||||
assert len(popen_calls) == 0, "--foreground must NOT call subprocess.Popen"
|
||||
|
||||
path, argv = execv_calls[0]
|
||||
assert path == "/usr/bin/python3"
|
||||
python_exe = sys.executable
|
||||
assert path == python_exe
|
||||
# argv[0] is the program name (convention), argv[1] is the script
|
||||
assert argv[0] == "/usr/bin/python3"
|
||||
assert argv[0] == python_exe
|
||||
assert argv[1].endswith("server.py")
|
||||
|
||||
@pytest.mark.parametrize("var", [
|
||||
@@ -304,6 +307,7 @@ class TestMainForegroundRouting:
|
||||
def test_supervisor_env_var_auto_promotes_to_execv(self, stub_main_dependencies, clean_env, monkeypatch, var):
|
||||
bs = stub_main_dependencies
|
||||
monkeypatch.setattr(sys, "argv", ["bootstrap.py"]) # no --foreground
|
||||
monkeypatch.setattr(sys, "platform", "linux")
|
||||
monkeypatch.setenv(var, "deadbeef")
|
||||
|
||||
execv_calls = []
|
||||
@@ -327,6 +331,7 @@ class TestMainForegroundRouting:
|
||||
def test_explicit_opt_in_env_auto_promotes_to_execv(self, stub_main_dependencies, clean_env, monkeypatch):
|
||||
bs = stub_main_dependencies
|
||||
monkeypatch.setattr(sys, "argv", ["bootstrap.py"]) # no --foreground flag
|
||||
monkeypatch.setattr(sys, "platform", "linux")
|
||||
monkeypatch.setenv("HERMES_WEBUI_FOREGROUND", "1")
|
||||
|
||||
execv_calls = []
|
||||
@@ -348,12 +353,13 @@ class TestForegroundEnvAndCwd:
|
||||
@pytest.fixture
|
||||
def setup(self, monkeypatch, tmp_path):
|
||||
import bootstrap as bs
|
||||
python_exe = sys.executable
|
||||
monkeypatch.setattr(bs, "ensure_supported_platform", lambda: None)
|
||||
agent_dir = tmp_path / "agent"
|
||||
agent_dir.mkdir()
|
||||
monkeypatch.setattr(bs, "discover_agent_dir", lambda: agent_dir)
|
||||
monkeypatch.setattr(bs, "hermes_command_exists", lambda: True)
|
||||
monkeypatch.setattr(bs, "discover_launcher_python", lambda *a: "/usr/bin/python3")
|
||||
monkeypatch.setattr(bs, "discover_launcher_python", lambda *a: python_exe)
|
||||
monkeypatch.setattr(bs, "ensure_python_has_webui_deps", lambda *a, **kw: a[0])
|
||||
monkeypatch.setattr(bs, "wait_for_health", lambda *a, **kw: True)
|
||||
monkeypatch.setattr(bs, "open_browser", lambda *a, **kw: None)
|
||||
@@ -364,6 +370,7 @@ class TestForegroundEnvAndCwd:
|
||||
def test_foreground_chdirs_to_agent_dir_before_exec(self, setup, monkeypatch, clean_env):
|
||||
bs, agent_dir = setup
|
||||
monkeypatch.setattr(sys, "argv", ["bootstrap.py", "--foreground", "--host", "127.0.0.1", "9999"])
|
||||
monkeypatch.setattr(sys, "platform", "linux")
|
||||
|
||||
chdir_calls = []
|
||||
monkeypatch.setattr(os, "chdir", lambda p: chdir_calls.append(p))
|
||||
@@ -382,6 +389,7 @@ class TestForegroundEnvAndCwd:
|
||||
monkeypatch.setattr(sys, "argv", [
|
||||
"bootstrap.py", "--foreground", "--host", "0.0.0.0", "9119"
|
||||
])
|
||||
monkeypatch.setattr(sys, "platform", "linux")
|
||||
monkeypatch.setattr(os, "chdir", lambda p: None)
|
||||
|
||||
def fake_execv(*a):
|
||||
@@ -402,6 +410,7 @@ class TestForegroundEnvAndCwd:
|
||||
def test_foreground_does_not_call_wait_for_health(self, setup, monkeypatch, clean_env):
|
||||
bs, _ = setup
|
||||
monkeypatch.setattr(sys, "argv", ["bootstrap.py", "--foreground"])
|
||||
monkeypatch.setattr(sys, "platform", "linux")
|
||||
monkeypatch.setattr(os, "chdir", lambda p: None)
|
||||
|
||||
wait_calls = []
|
||||
@@ -444,9 +453,11 @@ class TestForegroundExecutabilityGuard:
|
||||
def test_non_executable_python_raises_runtime_error(self, setup_with_bad_python, monkeypatch, clean_env):
|
||||
bs = setup_with_bad_python
|
||||
monkeypatch.setattr(sys, "argv", ["bootstrap.py", "--foreground"])
|
||||
monkeypatch.setattr(sys, "platform", "linux")
|
||||
|
||||
execv_calls = []
|
||||
monkeypatch.setattr(os, "execv", lambda *a: execv_calls.append(a))
|
||||
monkeypatch.setattr(os, "access", lambda path, mode: False)
|
||||
monkeypatch.setattr(os, "chdir", lambda p: None)
|
||||
|
||||
with pytest.raises(RuntimeError, match="not executable"):
|
||||
|
||||
@@ -5,6 +5,9 @@ import threading
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from conftest import requires_fork
|
||||
|
||||
|
||||
def _install_fake_cron(monkeypatch, run_job, events):
|
||||
cron_pkg = types.ModuleType("cron")
|
||||
@@ -193,6 +196,7 @@ def _run_lock_probe_with_context(context_name, target, result_queue):
|
||||
return process.exitcode, acquired
|
||||
|
||||
|
||||
@requires_fork
|
||||
def test_spawn_context_does_not_inherit_parent_thread_locks(tmp_path):
|
||||
"""Spawn starts a fresh interpreter where fork would clone a held lock."""
|
||||
helper_dir = tmp_path / "spawn_helper"
|
||||
@@ -244,6 +248,7 @@ def test_spawn_context_does_not_inherit_parent_thread_locks(tmp_path):
|
||||
assert spawn_acquired is True
|
||||
|
||||
|
||||
@requires_fork
|
||||
def test_manual_cron_subprocess_drains_large_result_before_join(tmp_path):
|
||||
"""A >100 KB result must not deadlock the parent before it can persist output."""
|
||||
if _real_hermes_agent_editable_install_present():
|
||||
@@ -349,6 +354,7 @@ def test_manual_cron_run_does_not_hold_profile_lock_for_job_duration(tmp_path, m
|
||||
assert routes._is_cron_running("job1574") == (False, 0.0)
|
||||
|
||||
|
||||
@requires_fork
|
||||
def test_cron_job_subprocess_executes_under_selected_profile_home(tmp_path, monkeypatch):
|
||||
if _real_hermes_agent_editable_install_present():
|
||||
import pytest as _pytest
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import time
|
||||
|
||||
@@ -15,7 +16,9 @@ def test_login_attempts_persist_failed_attempts(tmp_path, monkeypatch):
|
||||
data = json.loads(attempts_file.read_text(encoding="utf-8"))
|
||||
assert "203.0.113.10" in data
|
||||
assert len(data["203.0.113.10"]) == 1
|
||||
assert stat.S_IMODE(attempts_file.stat().st_mode) == 0o600
|
||||
assert attempts_file.exists()
|
||||
if os.name != "nt":
|
||||
assert stat.S_IMODE(attempts_file.stat().st_mode) == 0o600
|
||||
|
||||
|
||||
def test_login_attempts_load_prunes_expired_entries(tmp_path, monkeypatch):
|
||||
|
||||
@@ -495,12 +495,19 @@ def test_fix_credential_permissions_corrects_loose_files(tmp_path, monkeypatch):
|
||||
fix_credential_permissions()
|
||||
|
||||
import stat
|
||||
assert stat.S_IMODE(env_file.stat().st_mode) == 0o600, ".env not fixed to 600"
|
||||
assert stat.S_IMODE(google_file.stat().st_mode) == 0o600, "google_token.json not fixed to 600"
|
||||
assert env_file.exists(), ".env missing after permission repair"
|
||||
assert google_file.exists(), "google_token.json missing after permission repair"
|
||||
if os.name != "nt":
|
||||
assert stat.S_IMODE(env_file.stat().st_mode) == 0o600, ".env not fixed to 600"
|
||||
assert stat.S_IMODE(google_file.stat().st_mode) == 0o600, "google_token.json not fixed to 600"
|
||||
# Windows CI cannot assert a POSIX mode-bit repair here; keeping the files
|
||||
# present after the repair pass is the strongest portable contract for now.
|
||||
|
||||
|
||||
def test_fix_credential_permissions_skips_correct_files(tmp_path, monkeypatch):
|
||||
"""fix_credential_permissions() does not alter already-strict files."""
|
||||
import os
|
||||
|
||||
env_file = tmp_path / ".env"
|
||||
env_file.write_text("SECRET=abc")
|
||||
env_file.chmod(0o600)
|
||||
@@ -511,4 +518,6 @@ def test_fix_credential_permissions_skips_correct_files(tmp_path, monkeypatch):
|
||||
fix_credential_permissions()
|
||||
|
||||
import stat
|
||||
assert stat.S_IMODE(env_file.stat().st_mode) == 0o600
|
||||
assert env_file.exists(), ".env missing after strict-permission check"
|
||||
if os.name != "nt":
|
||||
assert stat.S_IMODE(env_file.stat().st_mode) == 0o600
|
||||
@@ -440,7 +440,7 @@ class TestGetProfileHome:
|
||||
monkeypatch.setitem(sys.modules, "api.profiles", None)
|
||||
monkeypatch.setenv("HERMES_HOME", "/custom/hermes")
|
||||
result = _get_profile_home(None)
|
||||
assert str(result) == "/custom/hermes"
|
||||
assert result == Path(os.environ["HERMES_HOME"]).expanduser()
|
||||
|
||||
def test_expands_tilde_in_hermes_home(self, monkeypatch):
|
||||
"""If HERMES_HOME contains ~, it gets expanded."""
|
||||
|
||||
@@ -16,6 +16,9 @@ import unittest
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from conftest import requires_fcntl
|
||||
|
||||
ROOT = Path(__file__).parent.parent
|
||||
|
||||
|
||||
@@ -190,6 +193,7 @@ class TestTLSEndToEnd(unittest.TestCase):
|
||||
self.assertEqual(data.get("status"), "ok")
|
||||
conn.close()
|
||||
|
||||
@requires_fcntl
|
||||
def test_tls_startup_failure_fallback_to_http(self):
|
||||
"""Bad cert paths should print a warning and start HTTP anyway."""
|
||||
port = _find_free_port()
|
||||
|
||||
@@ -64,6 +64,9 @@ def test_oauth_write_auth_json_uses_chmod_0600_before_rename(monkeypatch, tmp_pa
|
||||
os.umask(old_umask)
|
||||
|
||||
assert fake_path.exists(), "auth.json was not written"
|
||||
if os.name == "nt":
|
||||
assert fake_path.is_file(), "auth.json was not written as a file"
|
||||
return
|
||||
mode = stat.S_IMODE(fake_path.stat().st_mode)
|
||||
# The file must be chmod 0600 — owner read/write only.
|
||||
assert mode == 0o600, (
|
||||
|
||||
Reference in new issue
Block a user