Files
light-heart-labs--dreamserver/ods/tests/test_setup_card.py
T
wehub-resource-sync 9e8f1bbeed
Dashboard / frontend (push) Failing after 0s
Dashboard / api (push) Failing after 0s
Lint PowerShell / powershell-lint (ubuntu-latest) (push) Failing after 1s
Python Lint / Lint Python with Ruff (push) Failing after 1s
ShellCheck / Lint shell scripts (push) Failing after 1s
Matrix Smoke / linux-smoke (push) Failing after 1s
Matrix Smoke / distro: cachyos (push) Failing after 15s
Matrix Smoke / distro: linux-mint-21.3 (push) Failing after 15s
Matrix Smoke / distro: debian-12 (push) Failing after 5m21s
Matrix Smoke / distro: fedora-41 (push) Failing after 4m56s
Matrix Smoke / distro: ubuntu-24.04 (push) Failing after 2m13s
Matrix Smoke / distro: rocky-9 (push) Failing after 10m39s
Matrix Smoke / distro: manjaro (push) Failing after 12m11s
Matrix Smoke / distro: opensuse-tw (push) Failing after 11m53s
Matrix Smoke / distro: archlinux (push) Failing after 20m3s
Matrix Smoke / distro: ubuntu-22.04 (push) Failing after 13m49s
Validate .env Schema / tier-1-env-validation (push) Successful in 52s
Validate .env Schema / tier-2-env-validation (push) Successful in 44s
Validate .env Schema / tier-3-env-validation (push) Successful in 52s
Validate .env Schema / tier-4-env-validation (push) Successful in 51s
Validate Extensions Catalog / Check catalog is up-to-date (push) Failing after 9m47s
Secret Scan / Scan for secrets (push) Failing after 21m4s
Validate Docker Compose / Validate Docker Compose files (push) Has been cancelled
Python Type Check / Type check with mypy (push) Has been cancelled
Validate .env Schema / tier-0-env-validation (push) Has been cancelled
Test Linux / integration-smoke (push) Has been cancelled
Lint PowerShell / powershell-lint (windows-latest) (push) Has been cancelled
Matrix Smoke / macos-smoke (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:31:33 +08:00

309 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tests for scripts/generate-setup-card.py.
These exercise the CLI end-to-end: invoke the script, verify it writes
a valid PNG, and sanity-check the WiFi-QR payload encoding. Visual
correctness is not asserted — that's eyeball-test territory.
Run with: pytest tests/test_setup_card.py
"""
import importlib.util
import subprocess
import sys
import struct
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
SCRIPT = REPO_ROOT / "scripts" / "generate-setup-card.py"
def _import_module():
"""Import the script as a module so we can unit-test its helpers."""
spec = importlib.util.spec_from_file_location("generate_setup_card", SCRIPT)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
# ---------------------------------------------------------------------------
# Helper: build_wifi_qr_payload
# ---------------------------------------------------------------------------
def test_wifi_qr_payload_basic():
mod = _import_module()
p = mod.build_wifi_qr_payload("MyNet", "secret123", "WPA")
# Order is T;S;P;H — standard.
assert p == "WIFI:T:WPA;S:MyNet;P:secret123;H:false;;"
def test_wifi_qr_payload_open_network():
mod = _import_module()
p = mod.build_wifi_qr_payload("Open", "", "WPA")
# Password segment omitted entirely when password is empty.
assert "P:" not in p
assert p.startswith("WIFI:T:nopass;S:Open;")
def test_wifi_qr_payload_nopass_ignores_empty_password_only():
mod = _import_module()
p = mod.build_wifi_qr_payload("Open", "", "nopass")
assert p == "WIFI:T:nopass;S:Open;H:false;;"
def test_wifi_qr_payload_escapes_special_chars():
mod = _import_module()
p = mod.build_wifi_qr_payload("My;Net,wifi", 'pass":word\\', "WPA")
# Per the WIFI: URI escape rules, all of \, ;, ,, :, " must be
# backslash-escaped in the value (not just the obvious separators).
assert "S:My\\;Net\\,wifi" in p
assert "P:pass\\\"\\:word\\\\" in p
# ---------------------------------------------------------------------------
# CLI invocation
# ---------------------------------------------------------------------------
def _have_pillow_and_qrcode():
try:
import PIL # noqa: F401
import qrcode # noqa: F401
return True
except ImportError:
return False
pillow_required = pytest.mark.skipif(
not _have_pillow_and_qrcode(),
reason="Pillow + qrcode not installed; setup-card requires them",
)
@pillow_required
def test_cli_writes_valid_png(tmp_path):
out = tmp_path / "card.png"
result = subprocess.run(
[
sys.executable, str(SCRIPT),
"--ssid", "ODS-Setup-TEST",
"--password", "supersecret",
"--setup-url", "http://192.168.7.1/setup",
"--device-name", "ods-test.local",
"--serial", "TEST-001",
"--output", str(out),
],
capture_output=True, text=True, timeout=30,
)
assert result.returncode == 0, result.stderr
assert out.exists()
assert out.stat().st_size > 1000 # nontrivial PNG
# PNG magic number — first 8 bytes must be 89 50 4E 47 0D 0A 1A 0A
with open(out, "rb") as f:
header = f.read(8)
assert header == b"\x89PNG\r\n\x1a\n", "output is not a valid PNG"
@pillow_required
def test_cli_writes_factory_owner_png(tmp_path):
out = tmp_path / "owner-card.png"
result = subprocess.run(
[
sys.executable, str(SCRIPT),
"--mode", "factory-owner",
"--ssid", "ODS-Setup-TEST",
"--password", "supersecret",
"--owner-url", "http://auth.ods.local/magic-link/owner-token",
"--device-name", "ods-test.local",
"--output", str(out),
],
capture_output=True, text=True, timeout=30,
)
assert result.returncode == 0, result.stderr
assert out.exists()
with open(out, "rb") as f:
assert f.read(8) == b"\x89PNG\r\n\x1a\n"
@pillow_required
def test_cli_writes_factory_owner_pdf(tmp_path):
out = tmp_path / "owner-card.pdf"
result = subprocess.run(
[
sys.executable, str(SCRIPT),
"--mode", "factory-owner",
"--ssid", "ODS-Setup-TEST",
"--password", "supersecret",
"--owner-url", "http://auth.ods.local/magic-link/owner-token",
"--output", str(out),
],
capture_output=True, text=True, timeout=30,
)
assert result.returncode == 0, result.stderr
assert out.exists()
with open(out, "rb") as f:
assert f.read(4) == b"%PDF"
@pillow_required
def test_cli_creates_parent_directory(tmp_path):
nested = tmp_path / "deep" / "subdir" / "card.png"
result = subprocess.run(
[
sys.executable, str(SCRIPT),
"--ssid", "Open",
"--security", "nopass",
"--setup-url", "http://192.168.7.1/setup",
"--output", str(nested),
],
capture_output=True, text=True, timeout=30,
)
assert result.returncode == 0, result.stderr
assert nested.exists()
@pillow_required
def test_cli_dimensions_match_4x6_at_300dpi(tmp_path):
"""The script promises 1200×1800 px at 300 DPI = 4×6 inches."""
out = tmp_path / "card.png"
subprocess.run(
[
sys.executable, str(SCRIPT),
"--ssid", "x", "--password", "y",
"--setup-url", "http://192.168.7.1/setup",
"--output", str(out),
],
check=True, capture_output=True, timeout=30,
)
# Parse PNG IHDR to read width/height without importing Pillow here.
with open(out, "rb") as f:
f.seek(16)
width, height = struct.unpack(">II", f.read(8))
assert (width, height) == (1200, 1800)
def test_cli_requires_ssid():
"""Argparse error when --ssid is missing."""
result = subprocess.run(
[sys.executable, str(SCRIPT), "--setup-url", "http://x/", "--output", "/tmp/x.png"],
capture_output=True, text=True, timeout=10,
)
assert result.returncode != 0
assert "ssid" in result.stderr.lower()
def test_cli_requires_setup_url():
result = subprocess.run(
[sys.executable, str(SCRIPT), "--ssid", "x", "--output", "/tmp/x.png"],
capture_output=True, text=True, timeout=10,
)
assert result.returncode != 0
assert "setup-url" in result.stderr.lower() or "setup_url" in result.stderr.lower()
def test_cli_requires_owner_url_in_factory_owner_mode(tmp_path):
result = subprocess.run(
[
sys.executable, str(SCRIPT),
"--mode", "factory-owner",
"--ssid", "x",
"--output", str(tmp_path / "owner.png"),
],
capture_output=True, text=True, timeout=10,
)
assert result.returncode != 0
assert "owner-url" in result.stderr.lower() or "owner_url" in result.stderr.lower()
def test_cli_help_works_without_pillow():
"""Argparse help shouldn't require Pillow; helpful when an operator is
checking flags before installing deps."""
result = subprocess.run(
[sys.executable, str(SCRIPT), "--help"],
capture_output=True, text=True, timeout=10,
)
assert result.returncode == 0
assert "ssid" in result.stdout.lower()
assert "setup-url" in result.stdout.lower()
assert "owner-url" in result.stdout.lower()
@pillow_required
def test_cli_rejects_nopass_with_password(tmp_path):
out = tmp_path / "card.png"
result = subprocess.run(
[
sys.executable, str(SCRIPT),
"--ssid", "Open",
"--password", "should-not-be-used",
"--security", "nopass",
"--setup-url", "http://192.168.7.1/setup",
"--output", str(out),
],
capture_output=True, text=True, timeout=30,
)
assert result.returncode == 2
assert "nopass" in result.stderr
assert not out.exists()
@pillow_required
def test_qr_has_four_module_quiet_zone():
mod = _import_module()
img = mod.render_qr("hello", 240)
white = (255, 255, 255)
# With border=4 on a version-1 QR, the top quiet zone is ~33 px after
# scaling to 240 px. A one-module border is only ~10 px and fails here.
for x in range(0, 240):
for y in range(0, 24):
assert img.getpixel((x, y)) == white
# ---------------------------------------------------------------------------
# Regression: 63-char WPA2 password must fit in the fallback column
# ---------------------------------------------------------------------------
@pillow_required
def test_max_length_wpa_password_fits_in_fallback(tmp_path):
"""Reviewer flagged the plaintext fallback overflowing for max-length
WPA2 passwords (63 chars). The fix auto-shrinks the mono font to fit
within the value column; this test renders such a password and inspects
the output pixels along the right margin to make sure the text didn't
bleed off the card edge.
"""
from PIL import Image # imported only when Pillow is available
# WPA2 PSK upper bound = 63 chars. Pick a worst-case glyph (wide M's).
password = "M" * 63
out = tmp_path / "card-max-password.png"
result = subprocess.run(
[
sys.executable, str(SCRIPT),
"--ssid", "ODSCard",
"--password", password,
"--setup-url", "http://192.168.7.1/setup",
"--output", str(out),
],
capture_output=True, text=True, timeout=30,
)
assert result.returncode == 0, result.stderr
# Inspect the right-edge column of the password row. Generator puts the
# password block roughly in the lower-middle of the card; the right
# margin should be pure background (no glyph pixels). Check a vertical
# strip 4 px wide along the very right edge in the password band.
img = Image.open(out).convert("RGB")
card_w, card_h = img.size
# Password row sits between ~y=1100 and y=1300 in the layout.
bg = (15, 15, 19) # COLOR_BG
for x in range(card_w - 4, card_w):
for y in range(1100, 1300):
assert img.getpixel((x, y)) == bg, (
f"Pixel at ({x},{y}) is {img.getpixel((x, y))}, "
"expected card background — password text overflowed the column."
)