US09-02: Write the Installation and Operations Manual (#108)
This commit was merged in pull request #108.
This commit is contained in:
178
tests/integration/test_installation_manual.py
Normal file
178
tests/integration/test_installation_manual.py
Normal file
@@ -0,0 +1,178 @@
|
||||
"""US09-02: the installation manual, checked against the thing it describes.
|
||||
|
||||
Documentation rots quietly. A setting is renamed, a command grows a flag, an exit
|
||||
code changes meaning — and the manual keeps confidently saying the old thing until
|
||||
somebody follows it into a bad afternoon. So every fact in it that the code also
|
||||
knows is compared with the code, in both directions where both directions are
|
||||
meaningful: a setting the manual invents fails, and a setting the manual forgot
|
||||
fails too.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from photo_pipeline.config import ENV_PREFIX, LEGACY_ALIASES, Config
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
MANUAL = REPO / "docs" / "installation.md"
|
||||
MAIN = REPO / "photo_pipeline" / "__main__.py"
|
||||
|
||||
TEXT = MANUAL.read_text()
|
||||
ENV_NAMES = re.compile(rf"{ENV_PREFIX}[A-Z0-9_]+")
|
||||
|
||||
# Read by docker-compose.yml, not by Config. They are configuration an installer sets,
|
||||
# so the manual documents them; they are simply not application settings.
|
||||
COMPOSITION_ONLY = {
|
||||
f"{ENV_PREFIX}IMAGE",
|
||||
f"{ENV_PREFIX}LIBRARY_HOST_PATH",
|
||||
f"{ENV_PREFIX}PUBLISH_ADDRESS",
|
||||
f"{ENV_PREFIX}UID",
|
||||
f"{ENV_PREFIX}GID",
|
||||
f"{ENV_PREFIX}ENV_FILE", # read before Config exists, so it is not a field
|
||||
}
|
||||
|
||||
|
||||
def documented_env_names() -> set[str]:
|
||||
return set(ENV_NAMES.findall(TEXT))
|
||||
|
||||
|
||||
@lru_cache
|
||||
def cli_help(*args: str) -> str:
|
||||
"""What the CLI says about itself, asked the way an operator asks."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "photo_pipeline", *args, "--help"],
|
||||
cwd=REPO,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return result.stdout
|
||||
|
||||
|
||||
@lru_cache
|
||||
def cli_commands() -> frozenset[str]:
|
||||
listed = re.search(r"\{([a-z0-9,\-]+)\}", cli_help())
|
||||
assert listed, f"the CLI listed no subcommands:\n{cli_help()}"
|
||||
return frozenset(listed.group(1).split(","))
|
||||
|
||||
|
||||
def cli_flags(command: str) -> frozenset[str]:
|
||||
return frozenset(re.findall(r"--[a-z][a-z-]+", cli_help(command)))
|
||||
|
||||
|
||||
# ── settings ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_every_setting_is_documented():
|
||||
"""A setting nobody documents is a setting nobody configures deliberately."""
|
||||
expected = {f"{ENV_PREFIX}{field.upper()}" for field in Config.model_fields}
|
||||
missing = sorted(expected - documented_env_names())
|
||||
assert missing == [], f"undocumented settings: {missing}"
|
||||
|
||||
|
||||
def test_the_manual_invents_no_settings():
|
||||
unknown = sorted(
|
||||
name
|
||||
for name in documented_env_names()
|
||||
if name.removeprefix(ENV_PREFIX).lower() not in Config.model_fields
|
||||
and name not in COMPOSITION_ONLY
|
||||
)
|
||||
assert unknown == [], f"documented but not a real setting: {unknown}"
|
||||
|
||||
|
||||
def test_the_documented_defaults_are_the_real_defaults():
|
||||
"""Spot-checked where a wrong default is actively dangerous: the bind address,
|
||||
the trust boundary, and the gate that protects an irreplaceable library."""
|
||||
assert Config.model_fields["host"].default == "127.0.0.1"
|
||||
assert "`PHOTO_PIPELINE_HOST` | `127.0.0.1`" in TEXT
|
||||
assert Config.model_fields["allowed_hosts"].default == ()
|
||||
assert Config.model_fields["require_dry_run_approval"].default is False
|
||||
assert "`PHOTO_PIPELINE_REQUIRE_DRY_RUN_APPROVAL` | `false`" in TEXT
|
||||
|
||||
|
||||
def test_every_secret_is_marked_as_one():
|
||||
for field in ("access_secret", "vision_api_key", "immich_api_key"):
|
||||
name = f"{ENV_PREFIX}{field.upper()}"
|
||||
row = next(line for line in TEXT.splitlines() if line.startswith(f"| `{name}`"))
|
||||
assert "secret" in row.lower(), f"{name} is not marked as a secret"
|
||||
|
||||
|
||||
def test_the_legacy_aliases_are_documented():
|
||||
"""An operator with an existing photo_analyzer.env needs to know it still works."""
|
||||
for alias in LEGACY_ALIASES:
|
||||
assert alias in TEXT, f"legacy alias {alias} is undocumented"
|
||||
|
||||
|
||||
# ── commands ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
# The manual is an installation and operations manual, not a command reference: these
|
||||
# are the commands an installer actually runs, and every one of them must exist.
|
||||
OPERATIONAL = ("migrate", "serve", "worker", "backup", "verify-backup", "restore", "diagnostics")
|
||||
|
||||
|
||||
def test_every_command_the_manual_tells_you_to_run_exists():
|
||||
missing = [name for name in OPERATIONAL if name not in cli_commands()]
|
||||
assert missing == [], f"the manual names commands the CLI does not have: {missing}"
|
||||
undocumented = [name for name in OPERATIONAL if not re.search(rf"\b{re.escape(name)}\b", TEXT)]
|
||||
assert undocumented == [], f"operational commands the manual omits: {undocumented}"
|
||||
|
||||
|
||||
def test_every_documented_flag_exists_on_the_command_it_is_shown_with():
|
||||
for command, flag in (
|
||||
("backup", "--reason"),
|
||||
("backup", "--keep"),
|
||||
("restore", "--into"),
|
||||
("worker", "--allow-legacy"),
|
||||
("serve", "--allow-legacy"),
|
||||
):
|
||||
assert flag in cli_flags(command), f"{command} has no {flag}"
|
||||
assert flag in TEXT, f"{flag} is undocumented"
|
||||
|
||||
|
||||
# ── exit codes ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_every_documented_exit_code_is_one_the_cli_can_return():
|
||||
documented = {int(code) for code in re.findall(r"^\| `(\d)` \|", TEXT, re.MULTILINE)}
|
||||
returned = {int(code) for code in re.findall(r"^\s+return (\d)$", MAIN.read_text(), re.MULTILINE)}
|
||||
assert documented, "no exit codes are documented"
|
||||
assert documented <= returned | {0}, f"documented but unreachable: {sorted(documented - returned)}"
|
||||
|
||||
|
||||
def test_every_refusal_exit_code_is_documented():
|
||||
"""0 is success and 1 is 'it said why'; every other code is a specific refusal an
|
||||
operator will meet at startup, and meeting an undocumented one is the worst case."""
|
||||
returned = {int(code) for code in re.findall(r"^\s+return (\d)$", MAIN.read_text(), re.MULTILINE)}
|
||||
documented = {int(code) for code in re.findall(r"^\| `(\d)` \|", TEXT, re.MULTILINE)}
|
||||
missing = sorted(code for code in returned if code not in documented and code != 0)
|
||||
assert missing == [], f"undocumented exit codes: {missing}"
|
||||
|
||||
|
||||
# ── the promises the manual makes ────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_the_manual_carries_no_credential_shaped_example():
|
||||
"""The repository's own scanner refuses these in a commit; a manual is exactly
|
||||
where a real-looking one gets copied from."""
|
||||
suspicious = re.findall(
|
||||
r"(?i)(api[_-]?key|secret|token|password)\s*[=:]\s*[\"']?([A-Za-z0-9_\-]{12,})",
|
||||
TEXT,
|
||||
)
|
||||
assert suspicious == [], f"credential-shaped example: {suspicious}"
|
||||
|
||||
|
||||
def test_the_manual_states_the_invariants_an_installer_must_not_break():
|
||||
for invariant in ("_IGNORE/", "One writer", "EXIF is verified before upload"):
|
||||
assert invariant in TEXT, f"the manual does not state: {invariant}"
|
||||
|
||||
|
||||
def test_the_manual_is_reachable_from_the_index():
|
||||
assert "installation.md" in (REPO / "docs" / "index.md").read_text()
|
||||
Reference in New Issue
Block a user