Resolve Git Bash explicitly, and skip on a missing privilege or tool

Fifteen tests failed on this workstation and had been written off as
environmental. Thirteen of them were not.

Those thirteen ran subprocess.run(['bash', ...]). shutil.which('bash') finds
System32\bash.exe - the WSL shim - which cannot execute a Windows path and
failed every call with execvpe(/bin/bash). They now locate Git Bash from
git.exe and probe it with a trivial script before trusting it, so a box with
neither still skips rather than failing.

The other two were genuinely environmental, but were skipping nothing and
failing everything. The symlink test now skips only on WinError 1314, the
absent SeCreateSymbolicLinkPrivilege; any other OSError still fails, because a
symlink that cannot be created for some other reason is a real defect. The
server certificate test skips when openssl is not installed, which is what the
route shells out to.

Being labelled environmental is why these sat for weeks. A test that cannot run
should say so and skip; one that fails should mean something is broken.
This commit is contained in:
benjamin.baker
2026-09-20 13:23:11 -04:00
parent 0288587522
commit 3c91e7c314
3 changed files with 79 additions and 13 deletions

View File

@@ -26,6 +26,56 @@ import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
GATE = REPO_ROOT / 'scripts' / 'check-naming-and-style.sh'
def _find_bash():
"""Locate a bash that can actually run a POSIX script.
shutil.which('bash') is not enough on Windows. The WSL shim at
System32\\bash.exe is on PATH by default, satisfies which(), and then fails
every invocation with
WSL (...) ERROR: CreateProcessCommon:818: execvpe(/bin/bash) failed:
No such file or directory
when no distribution is installed - because the path handed to it is a
Windows path that means nothing inside the WSL namespace. That is the same
trap CONTRIBUTING.md documents for running the gate by hand, and it is why
these 13 tests were recorded as "environmental failures" rather than being
fixed: they were reporting the shim, not the gate.
Git Bash is preferred explicitly, then any candidate is proven by running a
trivial script through it before being trusted.
"""
candidates = []
git_bash = shutil.which('git')
if git_bash:
# <git>/cmd/git.exe -> <git>/bin/bash.exe
candidates.append(str(Path(git_bash).resolve().parent.parent / 'bin'
/ 'bash.exe'))
candidates.extend([
r'C:\Program Files\Git\bin\bash.exe',
r'C:\Program Files (x86)\Git\bin\bash.exe',
])
found = shutil.which('bash')
if found:
candidates.append(found)
for candidate in candidates:
if not candidate or not Path(candidate).exists():
continue
try:
probe = subprocess.run([candidate, '-c', 'exit 0'],
capture_output=True, text=True, timeout=30)
except (OSError, subprocess.SubprocessError):
continue
if probe.returncode == 0 and 'execvpe' not in (probe.stderr or ''):
return candidate
return None
BASH = _find_bash()
needs_bash = pytest.mark.skipif(BASH is None, reason='needs a working bash')
# Same shapes the gate matches: the name literal of a created column, positional
# or keyword, on the sa.Column( line or on the line after it, plus the target of
# a rename.
@@ -98,7 +148,7 @@ def test_no_migration_creates_a_badly_named_column():
+ '\n '.join(offenders))
@pytest.mark.skipif(shutil.which('bash') is None, reason='needs bash')
@needs_bash
def test_the_gate_fails_on_a_planted_migration_column(tmp_path):
"""Plant one, and require the gate to say so.
@@ -116,20 +166,20 @@ def test_the_gate_fails_on_a_planted_migration_column(tmp_path):
planted = versions / 'zz01_planted.py'
planted.write_text(PLANTED, encoding='utf-8')
dirty = subprocess.run(['bash', str(gate)], cwd=str(tmp_path),
dirty = subprocess.run([BASH, str(gate)], cwd=str(tmp_path),
capture_output=True, text=True)
assert dirty.returncode == 1, (
'the gate passed a snake_case migration column:\n' + dirty.stdout)
assert 'planted_column' in dirty.stdout
planted.unlink()
clean = subprocess.run(['bash', str(gate)], cwd=str(tmp_path),
clean = subprocess.run([BASH, str(gate)], cwd=str(tmp_path),
capture_output=True, text=True)
assert clean.returncode == 0, (
'the gate fails with nothing planted:\n' + clean.stdout)
@pytest.mark.skipif(shutil.which('bash') is None, reason='needs bash')
@needs_bash
def test_the_gate_fails_on_a_site_literal_in_the_agent(tmp_path):
"""agent/ is in the ADR-015 scan, and has to stay in it.
@@ -146,14 +196,14 @@ def test_the_gate_fails_on_a_site_literal_in_the_agent(tmp_path):
planted = agent / 'Probe.cs'
planted.write_text(PLANTED_AGENT, encoding='utf-8')
dirty = subprocess.run(['bash', str(gate)], cwd=str(tmp_path),
dirty = subprocess.run([BASH, str(gate)], cwd=str(tmp_path),
capture_output=True, text=True)
assert dirty.returncode == 1, (
'the gate passed a site hostname in the agent:\n' + dirty.stdout)
assert 'Probe.cs' in dirty.stdout
planted.unlink()
clean = subprocess.run(['bash', str(gate)], cwd=str(tmp_path),
clean = subprocess.run([BASH, str(gate)], cwd=str(tmp_path),
capture_output=True, text=True)
assert clean.returncode == 0, (
'the gate fails with nothing planted:\n' + clean.stdout)
@@ -224,7 +274,7 @@ def _gate_tree(tmp_path):
return gate
@pytest.mark.skipif(shutil.which('bash') is None, reason='needs bash')
@needs_bash
@pytest.mark.parametrize('shape', sorted(PLANTED_COLUMN_SHAPES))
def test_the_gate_catches_every_column_shape(tmp_path, shape):
"""Plant one shape at a time and require the gate to name it.
@@ -240,7 +290,7 @@ def test_the_gate_catches_every_column_shape(tmp_path, shape):
planted = versions / 'zz01_planted.py'
planted.write_text(_migration_module(body), encoding='utf-8')
dirty = subprocess.run(['bash', str(gate)], cwd=str(tmp_path),
dirty = subprocess.run([BASH, str(gate)], cwd=str(tmp_path),
capture_output=True, text=True)
assert dirty.returncode == 1, (
'the gate passed a {0} column:\n{1}'.format(shape, dirty.stdout))
@@ -249,7 +299,7 @@ def test_the_gate_catches_every_column_shape(tmp_path, shape):
expected, dirty.stdout))
@pytest.mark.skipif(shutil.which('bash') is None, reason='needs bash')
@needs_bash
def test_the_gate_passes_well_named_columns(tmp_path):
"""The other half: a gate that fails on everything is no better.
@@ -260,13 +310,13 @@ def test_the_gate_passes_well_named_columns(tmp_path):
gate = _gate_tree(tmp_path)
(versions / 'zz01_clean.py').write_text(
_migration_module(PLANTED_CLEAN_BODY), encoding='utf-8')
clean = subprocess.run(['bash', str(gate)], cwd=str(tmp_path),
clean = subprocess.run([BASH, str(gate)], cwd=str(tmp_path),
capture_output=True, text=True)
assert clean.returncode == 0, (
'the gate failed on lowercase concatenated names:\n' + clean.stdout)
@pytest.mark.skipif(shutil.which('bash') is None, reason='needs bash')
@needs_bash
def test_the_site_literal_scan_honours_its_include_list(tmp_path):
"""The ADR-015 scan must read the file types it declares, and no others.
@@ -289,7 +339,7 @@ def test_the_site_literal_scan_honours_its_include_list(tmp_path):
(agent / name).write_text(
'shipped to ' + PLANTED_SITE_HOST + '\n', encoding='utf-8')
result = subprocess.run(['bash', str(gate)], cwd=str(tmp_path),
result = subprocess.run([BASH, str(gate)], cwd=str(tmp_path),
capture_output=True, text=True)
assert result.returncode == 0, (
'the scan read a file type it does not declare:\n' + result.stdout)

View File

@@ -297,7 +297,19 @@ def test_import_scan_covers_symlinked_plugins(tmp_path):
(external / 'plugin.py').write_text('from shopdb.extensions import db\n')
link = root / 'zzsymlinkdemo'
try:
link.symlink_to(external, target_is_directory=True)
except OSError as error:
# WinError 1314 - SeCreateSymbolicLinkPrivilege is not held. On Windows
# that privilege belongs to Administrators, or to any account when
# Developer Mode is on, so an ordinary developer shell cannot create the
# fixture this test needs. Skip rather than fail: the scanner behaviour
# under test is real and is exercised on CI, and a red test here says
# nothing about the code. Only this specific cause is skipped - any
# other OSError still fails.
if getattr(error, 'winerror', None) == 1314:
pytest.skip('needs symlink privilege (Developer Mode or admin)')
raise
try:
scanned = {str(p) for p in _plugin_source_files()}
assert any('symlinkdemo' in s for s in scanned), (

View File

@@ -7,6 +7,8 @@ different PC is refused (ASSET_MISMATCH), the shared key is refused for that PC
(CREDENTIAL_INVALID) so the agent knows to enroll again.
"""
import shutil
import pytest
from shopdb.core.models import Asset, AssetType, AuditLog, Setting
@@ -882,6 +884,8 @@ def test_approve_refuses_an_enrolled_computer(client, db, app, keyheaders,
# -- server certificate pin --------------------------------------------------
@pytest.mark.skipif(shutil.which('openssl') is None,
reason='needs the openssl binary on PATH')
def test_server_cert_reports_the_spki_pin(client, db, auth_headers, tmp_path,
monkeypatch):
import hashlib