Files
homeassistant-stelloauth-addon/tests/test_addon_metadata.py
T
2026-09-24 22:05:54 +02:00

337 lines
13 KiB
Python

from __future__ import annotations
import hashlib
import os
import re
import subprocess
from pathlib import Path
import pytest
import yaml
ROOT = Path(__file__).parents[1]
REPOSITORY_URL = "https://git.radixadm.dk/dennis/homeassistant-stelloauth-addon.git"
def load_yaml(path: str) -> dict:
with (ROOT / path).open(encoding="utf-8") as handle:
value = yaml.safe_load(handle)
assert isinstance(value, dict)
return value
def test_repository_metadata() -> None:
metadata = load_yaml("repository.yaml")
assert metadata["url"] == REPOSITORY_URL
assert metadata["name"] == "Stelloauth for Home Assistant"
assert metadata["maintainer"] == "Dennis / Radix ApS"
def test_addon_contract() -> None:
config = load_yaml("stelloauth/config.yaml")
assert config["slug"] == "stelloauth"
assert config["version"] == "0.1.0"
assert config["arch"] == ["amd64", "aarch64"]
assert config["startup"] == "application"
assert config["boot"] == "auto"
assert config["init"] is True
assert config["watchdog"] == "http://[HOST]:[PORT:8080]/"
assert config["ports"] == {"8080/tcp": None}
for key in ("ingress", "host_network", "privileged", "full_access", "docker_api", "devices", "map"):
assert key not in config
def test_defaults_and_schema_are_aligned() -> None:
config = load_yaml("stelloauth/config.yaml")
assert config["options"] == {
"queue_timeout": "60s",
"rate_limit_count": 5,
"rate_limit_duration": "1h",
}
assert set(config["schema"]) == set(config["options"])
assert config["schema"]["rate_limit_count"] == "int(1,20)"
def test_repository_hostname_derivation() -> None:
repository_id = hashlib.sha1(REPOSITORY_URL.lower().encode()).hexdigest()[:8]
assert repository_id == "0031621f"
assert f"{repository_id}-stelloauth" == "0031621f-stelloauth"
def test_user_guide_documentation_contract() -> None:
documentation = (ROOT / "stelloauth/DOCS.md").read_text(encoding="utf-8")
for required_text in (
REPOSITORY_URL,
"Indstillinger → Apps → Installér app → ⋮ →",
"Installér **Stelloauth**",
"aktivér **Start ved opstart** og **Watchdog**",
"http://0031621f-stelloauth:8080/worker",
"http://192.168.1.20:8080/worker",
"Brand: Opel",
"Country: DK",
"Image: 2,571,693,650 bytes (2.571 GB decimal / 2452.56 MiB).",
"Dokumenteret Task 4-måling: 121,5 MiB.",
"Stop: cirka 9.3 sekunder.",
"approximately 4 GB free",
"Der er ikke gennemført et live MyOpel-login.",
"Login-flow RAM: not measured without real MyOpel credentials.",
"Fjernelse sker i **Indstillinger → Apps → Stelloauth → Afinstallér**.",
):
assert required_text in documentation
assert "deaktivér porttilknytningen igen" in documentation
assert "Seneste idle RAM" not in documentation
assert "Tilføjelser" not in documentation
assert "Tilføjelsesbutik" not in documentation
def test_root_readme_repository_source_and_license_facts() -> None:
readme = (ROOT / "README.md").read_text(encoding="utf-8")
for required_text in (
REPOSITORY_URL,
"Indstillinger → Apps → Installér app → ⋮ → Repositorier",
"v0.6.0",
"367d4f8c02a3b072c59142c49dffc129edc8548b",
"0.5.10",
"f04c23da285b3b3d3cf10c8f9d282e7adc1d52ce",
"CloakBrowser Binary License",
"MIT-licenseret",
):
assert required_text in readme
assert "Tilføjelsesbutik" not in readme
def test_translations_cover_every_option() -> None:
keys = set(load_yaml("stelloauth/config.yaml")["options"])
for language in ("da", "en"):
translation = load_yaml(f"stelloauth/translations/{language}.yaml")
assert set(translation["configuration"]) == keys
for entry in translation["configuration"].values():
assert set(entry) == {"name", "description"}
assert all(isinstance(value, str) and value.strip() for value in entry.values())
def test_dockerfile_uses_approved_pins_and_builds_patched_stelloauth() -> None:
dockerfile = (ROOT / "stelloauth/Dockerfile").read_text(encoding="utf-8")
assert (
"golang:1.27.1-bookworm@sha256:"
"69a7b9788769bec032d238959b61854e9ae87f57be9029ec04e9885fabf99195"
) in dockerfile
assert (
"cloakhq/cloakbrowser:0.5.10@sha256:"
"2ed5b2d047cbdde22cde7ef1a796526c716aadaa5bccbe1db5ade49282b64a76"
) in dockerfile
assert "367d4f8c02a3b072c59142c49dffc129edc8548b" in dockerfile
assert "go test ./..." in dockerfile
assert not re.search(r"^FROM\s+\S+:latest(?:\s|$)", dockerfile, re.MULTILINE)
def test_dockerfile_declares_home_assistant_runtime_contract() -> None:
dockerfile = (ROOT / "stelloauth/Dockerfile").read_text(encoding="utf-8")
for label in (
"io.hass.name",
"io.hass.description",
"io.hass.arch",
"io.hass.type",
"io.hass.version",
):
assert label in dockerfile
assert 'io.hass.type="app"' in dockerfile
assert 'io.hass.type="addon"' not in dockerfile
assert re.search(r"^EXPOSE 8080$", dockerfile, re.MULTILINE)
assert not re.search(r"^EXPOSE .*\b9222\b", dockerfile, re.MULTILINE)
assert "ENTRYPOINT []" in dockerfile
assert 'CMD ["/usr/local/bin/addon-supervisor"]' in dockerfile
def test_dockerfile_patches_parent_cloakserve_instead_of_copying_a_binary() -> None:
dockerfile = (ROOT / "stelloauth/Dockerfile").read_text(encoding="utf-8")
assert "COPY patches/cloakserve-loopback.patch" in dockerfile
for line in dockerfile.splitlines():
if line.lstrip().startswith("COPY "):
source = line.split()[1]
assert Path(source).name != "cloakserve"
def test_runtime_accepts_docker_port_unpublished_status() -> None:
runtime_test = (ROOT / "tests/test_runtime.sh").read_text(encoding="utf-8")
assert 'docker port "$container" 9222/tcp 2>/dev/null || true' in runtime_test
def test_runtime_copies_options_without_a_host_bind_mount() -> None:
runtime_test = (ROOT / "tests/test_runtime.sh").read_text(encoding="utf-8")
assert "docker create --name" in runtime_test
assert 'docker cp "$options_file" "$container:/data/options.json"' in runtime_test
assert 'docker start "$container"' in runtime_test
assert "--mount" not in runtime_test
def test_runtime_probes_service_inside_container_network_namespace() -> None:
runtime_test = (ROOT / "tests/test_runtime.sh").read_text(encoding="utf-8")
assert 'probe_root "$container"' in runtime_test
assert 'post_invalid_worker "$first_container"' in runtime_test
assert 'assert_8080_loopback_mapping "$first_container"' in runtime_test
assert 'assert_8080_loopback_mapping "$second_container"' in runtime_test
assert 'f"http://127.0.0.1:{sys.argv[1]}/"' not in runtime_test
def test_ci_builds_and_loads_only_the_amd64_test_image() -> None:
workflow = (ROOT / ".gitea/workflows/ci.yml").read_text(encoding="utf-8")
parsed = yaml.safe_load(workflow)
steps = parsed["jobs"]["validate"]["steps"]
architecture_command = next(
step["run"] for step in steps if step.get("name") == "Validate runner architecture"
)
assert architecture_command == 'test "$(uname -m)" = "x86_64"'
build_command = next(
step["run"] for step in steps if step.get("name") == "Build amd64 image"
)
assert build_command.split() == [
"docker",
"build",
"--build-arg",
"TARGETARCH=amd64",
"--build-arg",
"BUILD_ARCH=amd64",
"--tag",
"homeassistant-stelloauth-addon:test",
"stelloauth",
]
for publication_primitive in (
"--push",
"docker push",
"docker/login-action",
"docker/build-push-action",
"packages: write",
):
assert publication_primitive not in workflow
def test_ci_runs_validation_in_pinned_container() -> None:
workflow = (ROOT / ".gitea/workflows/ci.yml").read_text(encoding="utf-8")
parsed = yaml.safe_load(workflow)
steps = parsed["jobs"]["validate"]["steps"]
assert "actions/setup-python" not in workflow
assert "actions/setup-go" not in workflow
build_command = next(
step["run"] for step in steps if step.get("name") == "Build validation image"
)
assert build_command.split() == [
"docker",
"build",
"--file",
"tests/Dockerfile.ci",
"--tag",
"homeassistant-stelloauth-tests:test",
".",
]
validation_command = next(
step["run"]
for step in steps
if step.get("name") == "Validate metadata, patches, and process manager"
)
assert validation_command == "docker run --rm homeassistant-stelloauth-tests:test -q"
dockerfile = (ROOT / "tests/Dockerfile.ci").read_text(encoding="utf-8")
assert (
"golang:1.27.1-bookworm@sha256:"
"69a7b9788769bec032d238959b61854e9ae87f57be9029ec04e9885fabf99195"
) in dockerfile
assert "python3 python3-venv" in dockerfile
assert 'ENTRYPOINT ["/opt/venv/bin/pytest"]' in dockerfile
def test_patch_payloads_disable_git_whitespace_errors() -> None:
result = subprocess.run(
[
"git",
"check-attr",
"whitespace",
"--",
"stelloauth/patches/stelloauth-security.patch",
"stelloauth/patches/cloakserve-loopback.patch",
],
cwd=ROOT,
text=True,
capture_output=True,
check=True,
)
assert result.stdout.splitlines() == [
"stelloauth/patches/stelloauth-security.patch: whitespace: unset",
"stelloauth/patches/cloakserve-loopback.patch: whitespace: unset",
]
def test_runtime_rejects_every_ipv6_cdp_listener() -> None:
runtime_test = (ROOT / "tests/test_runtime.sh").read_text(encoding="utf-8")
assert 'cat /proc/net/tcp6 > "$tcp6_artifact"' in runtime_test
assert 'for table in ("/proc/net/tcp", "/proc/net/tcp6"):' in runtime_test
assert 'if table == "/proc/net/tcp6":' in runtime_test
def test_runtime_requires_process_baseline_after_cdp_close_and_zero_stopped_pid() -> None:
runtime_test = (ROOT / "tests/test_runtime.sh").read_text(encoding="utf-8")
assert 'docker top "$container" -eo pid,args' in runtime_test
for prefix in ("first", "second"):
container = f"${prefix}_container"
baseline = (
f'{prefix}_baseline="$(capture_process_baseline "{container}")"'
)
mapping_index = runtime_test.index(
f'assert_8080_loopback_mapping "{container}"'
)
ready_index = runtime_test.index(f'wait_ready "{container}"')
baseline_index = runtime_test.index(baseline)
close_index = runtime_test.index(
f'probe_and_close_cdp "{container}"', baseline_index
)
return_index = runtime_test.index(
f'assert_processes_return_to_baseline "{container}" "${prefix}_baseline"',
close_index,
)
assert mapping_index < ready_index < baseline_index < close_index < return_index
assert "{{.State.Pid}}" in runtime_test
assert '[ "$state" = "exited 0 0" ]' in runtime_test
def test_runtime_process_normalization_retains_every_unknown_wrapped_child(
tmp_path: Path,
) -> None:
runtime_test = ROOT / "tests/test_runtime.sh"
top_file = tmp_path / "docker-top.txt"
environment = os.environ.copy()
environment.update(
{
"DOCKER_HOST": "unix:///nonexistent-runtime-normalization.sock",
"SKIP_BUILD": "1",
}
)
service_lines = [
"101 /run/rosetta/rosetta /usr/local/bin/python3 python3 /usr/local/bin/addon-supervisor",
"102 /run/rosetta/rosetta /usr/local/bin/python3 python3 /usr/local/bin/cloakserve --headless=true --idle-timeout=30 --data-dir=/tmp/cloakserve",
"103 /usr/bin/qemu-x86_64-static /usr/local/bin/stelloauth",
]
def normalize(extra_line: str | None = None) -> list[str]:
lines = ["PID COMMAND", *service_lines]
if extra_line is not None:
lines.append(extra_line)
top_file.write_text("\n".join(lines) + "\n", encoding="utf-8")
completed = subprocess.run(
[str(runtime_test), "--normalize-processes", str(top_file)],
cwd=ROOT,
env=environment,
text=True,
capture_output=True,
check=False,
)
assert completed.returncode == 0, completed.stderr
return completed.stdout.splitlines()
baseline = ["addon-supervisor", "cloakserve", "stelloauth"]
assert normalize() == baseline
wrapped_children = [
"/run/rosetta/rosetta /opt/vendor/headless-shell --user-data-dir=/tmp/profile",
"/run/rosetta/rosetta /opt/vendor/browser --profile runtime-readiness",
"/run/rosetta/rosetta /opt/vendor/crashpad_handler --database=/tmp/profile",
"/run/rosetta/rosetta /opt/vendor/opaque-child --flag",
"/run/rosetta/rosetta /opt/vendor/opaque-child --parent=/usr/local/bin/cloakserve",
"/usr/bin/qemu-x86_64-static /opt/vendor/opaque-qemu-child",
]
for offset, child in enumerate(wrapped_children, start=104):
normalized = normalize(f"{offset} {child}")
assert normalized == sorted([*baseline, f"unexpected:{child}"])
assert normalized != baseline