Files
shopdb-flask/tests/test_bundle_lock.py

232 lines
9.0 KiB
Python

"""The installer bundle lock: fail-closed behaviour, and parity between the two
implementations that read it.
There are two verifiers on purpose - bundle-lock.ps1 runs at install time on a
server where PowerShell is the only thing present, verify_bundle_lock.py runs in
the Linux builder without adding pwsh as a build dependency. Two implementations
of one rule drift. These tests run BOTH against the same fixtures and fail if
they disagree, so the drift shows up here rather than as a bundle that one of
them waves through.
The PowerShell half is skipped where pwsh is absent; the Python half always runs.
"""
import json
import re
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
INSTALLER = Path(__file__).resolve().parents[1] / 'deploy' / 'windows' / 'installer'
VERIFY_PY = INSTALLER / 'verify_bundle_lock.py'
LOCK_PS1 = INSTALLER / 'bundle-lock.ps1'
PWSH = shutil.which('pwsh') or shutil.which('powershell')
pytestmark = pytest.mark.skipif(not VERIFY_PY.exists(), reason='installer not in this tree')
def build_bundle(root):
"""A minimal bundle holding every REQUIRED payload directory."""
for name, files in (
('wheels', {'alembic-1.18.4-py3-none-any.whl': b'wheel-a',
'cffi-2.1.0-cp314-cp314-win_amd64.whl': b'wheel-b'}),
('python', {'python-3.14.6-amd64.exe': b'py'}),
('httpplatformhandler', {'httpPlatformHandler_amd64.msi': b'hph'}),
):
directory = root / name
directory.mkdir(parents=True)
for filename, content in files.items():
(directory / filename).write_bytes(content)
return root
def write_lock(bundle, lock_path):
"""Generate the lock the same way refresh-bundle-lock.ps1 does."""
sys.path.insert(0, str(INSTALLER))
import verify_bundle_lock as verifier
payloads = {}
for name, required, _what in verifier.PAYLOADS:
directory = bundle / name
if not directory.is_dir():
continue
payloads[name] = {'required': required, 'files': verifier.payload_files(str(directory))}
lock_path.write_text(json.dumps({
'schema': 1, 'generated': '2026-08-03T00:00:00Z',
'pythontag': 'cp314', 'platform': 'win_amd64', 'payloads': payloads,
}))
def check_python(bundle, lock_path):
result = subprocess.run(
[sys.executable, str(VERIFY_PY), str(bundle), str(lock_path)],
capture_output=True, text=True)
problems = [line for line in result.stdout.splitlines() if line.strip()]
# Exit status and output must agree, or a caller that checks only one of them
# gets a different answer from a caller that checks the other.
assert (result.returncode != 0) == bool(problems), result.stdout
return problems
def check_powershell(bundle, lock_path):
script = (
". '%s'; $p = Test-BundleLock -BundleRoot '%s' -Lock (Read-BundleLock '%s'); "
"if ($p) { $p -join \"`n\" }" % (LOCK_PS1, bundle, lock_path))
result = subprocess.run([PWSH, '-NoProfile', '-Command', script],
capture_output=True, text=True)
assert result.returncode == 0, result.stderr
return [line for line in result.stdout.splitlines() if line.strip()]
def check_both(bundle, lock_path):
"""Returns the Python verdict, asserting PowerShell reports the same set.
Compared as sets: both sort their output, but PowerShell's Sort-Object is
culture-aware and Python's sorted() is ordinal, so the two can order the same
findings differently. What must never differ is WHICH problems are found.
"""
problems = check_python(bundle, lock_path)
if PWSH and LOCK_PS1.exists():
assert sorted(check_powershell(bundle, lock_path)) == sorted(problems), (
'verify_bundle_lock.py and bundle-lock.ps1 disagree')
return problems
@pytest.fixture
def locked(tmp_path):
bundle = build_bundle(tmp_path / 'bundle')
lock = tmp_path / 'bundle-lock.json'
write_lock(bundle, lock)
return bundle, lock
def test_untouched_bundle_passes(locked):
bundle, lock = locked
assert check_both(bundle, lock) == []
def test_extra_file_fails(locked):
"""The gap pip's own hash checking leaves: a stale wheel nobody asked for."""
bundle, lock = locked
(bundle / 'wheels' / 'stale-0.1-py3-none-any.whl').write_bytes(b'left over')
problems = check_both(bundle, lock)
assert any('stale-0.1' in p and 'NOT in the lock' in p for p in problems)
def test_altered_file_fails(locked):
bundle, lock = locked
(bundle / 'wheels' / 'cffi-2.1.0-cp314-cp314-win_amd64.whl').write_bytes(b'swapped')
problems = check_both(bundle, lock)
assert any('does NOT match the lock' in p for p in problems)
def test_missing_file_fails(locked):
bundle, lock = locked
(bundle / 'wheels' / 'alembic-1.18.4-py3-none-any.whl').unlink()
problems = check_both(bundle, lock)
assert any('missing from the bundle' in p for p in problems)
def test_missing_required_payload_fails(locked):
bundle, lock = locked
shutil.rmtree(bundle / 'python')
problems = check_both(bundle, lock)
assert any(p.startswith('python/') for p in problems)
def test_unlocked_payload_directory_fails(locked):
"""An optional payload dropped in after the lock was made is still unreviewed."""
bundle, lock = locked
(bundle / 'mysql').mkdir()
(bundle / 'mysql' / 'mysql-8.0.46-winx64.msi').write_bytes(b'msi')
problems = check_both(bundle, lock)
assert any('mysql/ is present but is not in bundle-lock.json' in p for p in problems)
def test_absent_optional_payload_is_fine(locked):
bundle, lock = locked
assert not (bundle / 'mysql').exists()
assert check_both(bundle, lock) == []
def test_missing_lock_fails_closed(locked):
"""No lock is a failure, not a pass. The build must not fall through to 'fine'."""
bundle, lock = locked
lock.unlink()
result = subprocess.run([sys.executable, str(VERIFY_PY), str(bundle), str(lock)],
capture_output=True, text=True)
assert result.returncode != 0
assert 'no bundle-lock.json' in result.stdout
def test_malformed_lock_reports_rather_than_crashing(locked):
"""A truncated lock must produce a sentence, not a stack trace.
shopdb-install.ps1 runs under Set-StrictMode 2.0, where reading a property
that is not there throws. Both verifiers have to survive a lock missing the
section they read first.
"""
bundle, lock = locked
lock.write_text(json.dumps({'schema': 1, 'pythontag': 'cp314'}))
problems = check_both(bundle, lock)
assert problems == ['bundle-lock.json has no "payloads" section']
def test_lock_missing_files_section_is_not_a_silent_pass(locked):
"""A payload entry with no file list must not read as 'nothing expected, fine'."""
bundle, lock = locked
data = json.loads(lock.read_text())
del data['payloads']['wheels']['files']
lock.write_text(json.dumps(data))
problems = check_both(bundle, lock)
assert problems, 'an entry with no file list let every wheel through'
assert all('wheels/' in p for p in problems)
def test_wheelhouse_must_cover_requirements(locked):
"""The regression that motivated the check.
A wheelhouse assembled anywhere but Windows omits colorama - pip evaluates
sys_platform markers against the downloading machine. The lock alone cannot
catch it, because the lock records what is there, not what is needed.
"""
bundle, lock = locked
app = bundle / 'app'
app.mkdir()
(app / 'requirements.txt').write_text(
"alembic==1.18.4 \\\n --hash=sha256:abc\n"
"colorama==0.4.6 ; sys_platform == 'win32' \\\n --hash=sha256:def\n")
problems = check_both(bundle, lock)
assert any('colorama==0.4.6' in p for p in problems), problems
assert not any('alembic' in p for p in problems), 'alembic has a wheel and must not be flagged'
def test_wheelhouse_coverage_normalizes_names(locked):
"""mysql-connector-python pins as dashes and ships as mysql_connector_python."""
bundle, lock = locked
app = bundle / 'app'
app.mkdir()
(app / 'requirements.txt').write_text('mysql-connector-python==9.7.0\n')
assert any('mysql_connector_python==9.7.0' in p for p in check_both(bundle, lock))
wheel = bundle / 'wheels' / 'mysql_connector_python-9.7.0-cp314-cp314-win_amd64.whl'
wheel.write_bytes(b'connector')
write_lock(bundle, lock) # re-lock so the new wheel is not an 'extra'
assert check_both(bundle, lock) == []
def test_payload_list_matches_powershell():
"""Both files enumerate the payload directories. Same names, same required
flags, same order - a directory that is required in one and optional in the
other means the builder and the installer disagree about what may be absent.
"""
sys.path.insert(0, str(INSTALLER))
import verify_bundle_lock as verifier
declared = re.findall(r"Name\s*=\s*'([a-z]+)'\s*;\s*Required\s*=\s*\$(true|false)",
LOCK_PS1.read_text())
assert declared, 'could not find $script:BundlePayloads in bundle-lock.ps1'
assert declared == [(name, str(required).lower()) for name, required, _ in verifier.PAYLOADS]