phase: 24_dev_mtls_rollout
**Phase 24 final verification — all green**
- **Verified (read-only re-run, 62/62 PASS)** via `data/logs/verify-24-mtls.py`: nine sources `ok`; `/api/certs` all three `active` with `deployed_bundle_rev == bundle_rev == 2`; `agent.tls: true` + `agent.version 1.1.0` on every host; driveripper 16/16 drives live; gateway absent from `/disks` but `disks:gateway ok`; all served target URLs `https://`, no userinfo; pre-flip backup present; no transport/cert alert.
- **Criterion 1 (export):** `data/certs/{driveripper,deskwork,gateway}.bundle` re-checked byte-identical (sha256) to `GET /api/certs/<name>/bundle`, mode `0600`, under gitignored `data/`; live arms re-probed (issued→file, unknown/`issued:false`→one-line note + no file, wrong password→exit 2, nothing written).
- **Criterion 2 (`--fetch-bundle`):** 54 sandbox pins in `tests/test_deploy_scripts.py` pass — byte-identical `0640` install + TLS unit lines, refusals (unissued / 401 / non-bundle body / dead dashboard) before `need_root` with no write, token absent from token file, unit, stdout and sudoers hint.
- **Criterion 3–4:** `./scripts/dev-server.sh status` nine `ok` + bundles block; screenshots `data/logs/24-task04-{certs,disks,system}.png` exist.
- **Criterion 7:** no behavior change — `agent/` untouched, `src/` diff is a `certs.ensure_certs` docstring only; token-mode, revocation-before-dial and bundle-route tests green.
- **Tests/lint:** `bash .agents/validate.sh` → **EXIT=0**; `uv run pytest --cov --cov-fail-under=94` → **1542 passed, 15 skipped, coverage 98.94%**; `ruff check src tests agent` → All checks passed; `pyright` → 0 errors; `node --check` clean; `uv run pytest -m live --live` → **11 passed**.
- **Docs:** README.md (all 1144 lines) + AGENTS.md reviewed against the live project — every config.py env var is in README and both templates, route set / severity thresholds / test counts / coverage figure match, all repo paths, cited test names and `Dash.*`/`DashCharts.*` helpers resolve, phase-24 doc pins green. **Verified consistent — no changes needed.**
- **Notable:** left `fastapi 0.153.0 → 0.158.0` (validate reports it as advisory; no dependency was touched, rule 8 conditions the check on deps moving). Killed two abandoned `/tmp/fbtest` loopback stub processes left by task 02's manual probe; dev server unaffected.
- **Next pending phase:** `.agents/phases/todo/25_masthead_control_placement/`.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
"""Contract tests for the deployment scripts in `deploy/`.
|
||||
"""Contract tests for the scripts an operator runs: `deploy/` and `scripts/dev-*.sh`/`.py`.
|
||||
|
||||
There is no shell test framework here — and adding one would buy nothing a subprocess does
|
||||
not — but phase 10 rests on a sentence that lives in **two** files: the sudoers grant in
|
||||
@@ -8,25 +8,41 @@ silently, and the failure is an install that cannot happen on a host nobody is s
|
||||
So these tests read the file, run the script the way the operator does (unprivileged, which
|
||||
exits before the script can touch anything), and require the two lines to be the same line
|
||||
apart from the path the script was invoked through.
|
||||
|
||||
The same reasoning covers `scripts/dev-certs-export.py` and the `scripts/dev-server.sh` call that
|
||||
runs it (phase 24): the exporter is what turns the durable CA into a file an operator can `scp`,
|
||||
and the composition — *after* the readiness probe, *as* a warning line — is as much of the
|
||||
contract as the bytes it writes. So both files are run here and read here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import difflib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from http.server import ThreadingHTTPServer
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import uvicorn
|
||||
|
||||
from dashboards import certs
|
||||
from auth_contract import ADMIN_PASSWORD, FAST_PBKDF2
|
||||
from dashboards import auth, certs
|
||||
from dashboards.app import create_app
|
||||
from dashboards.config import Settings, Target
|
||||
from dashboards.store import SampleStore
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
DEPLOY = ROOT / "deploy"
|
||||
@@ -227,6 +243,56 @@ def test_no_operational_file_mentions_the_tunnel_machinery() -> None:
|
||||
assert not offenders, "tunnel-era names/ports are back:\n" + "\n".join(offenders)
|
||||
|
||||
|
||||
# --- the mTLS registration is the documented default (phase 24) ---------------------------
|
||||
|
||||
#: The three flags that make a host's **install** its registration moment: the run that installs
|
||||
#: the agent also pulls that host's bundle from the dashboard that owns the authority. The
|
||||
#: token-mode install is still supported (a host nobody has bundled keeps its bearer token), but
|
||||
#: it is the compatibility path now, and a setup file that leads with it is a recipe for a fleet
|
||||
#: of `http://` agents — which is the exact state `TODO.md:3` asked to be rid of.
|
||||
REGISTRATION_FLAGS = ("--fetch-bundle", "--cert-name", "--session")
|
||||
|
||||
#: The files that tell an operator what to type, per host.
|
||||
REGISTRATION_DOCS = ("README.md", "AGENTS.md", ".env.example", ".env.dev.example")
|
||||
|
||||
|
||||
def _host_target_lines(path: Path) -> list[str]:
|
||||
"""Every `HOST_TARGETS` assignment line in a file, comments included.
|
||||
|
||||
The commented example in `.env.example` counts, because it is the line a reader copies.
|
||||
"""
|
||||
return [line for line in path.read_text().splitlines()
|
||||
if re.search(r"^\s*#?\s*(export\s+)?HOST_TARGETS\s*=", line)]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", REGISTRATION_DOCS)
|
||||
def test_the_setup_docs_teach_registration_with_a_bundle(name: str) -> None:
|
||||
"""The per-host recipe a reader copies is the bundle fetch, not the bearer token."""
|
||||
text = (ROOT / name).read_text()
|
||||
for flag in REGISTRATION_FLAGS:
|
||||
assert flag in text, f"{name} stopped naming {flag} — the fetch is no longer documented"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", (".env.example", ".env.dev.example"))
|
||||
def test_the_template_target_urls_are_https_and_credential_free(name: str) -> None:
|
||||
"""A `HOST_TARGETS` line an operator copies is `https://` with no userinfo.
|
||||
|
||||
Pinning the *example* is what keeps this true: prose can and should still document the
|
||||
token-mode shape (it is a supported dial, `collectors.fetch_snapshot` keeps its path byte-for-
|
||||
byte), but the line that gets pasted into a real config must not seed a non-mTLS fleet. The
|
||||
empty `HOST_TARGETS=` is allowed — unconfigured is a different fact from misconfigured.
|
||||
"""
|
||||
lines = _host_target_lines(ROOT / name)
|
||||
assert lines, f"{name} lost its HOST_TARGETS documentation entirely"
|
||||
for line in lines:
|
||||
value = line.split("=", 1)[1]
|
||||
urls = re.findall(r"\w+://[^\s\"']+", value)
|
||||
if not urls:
|
||||
continue # the empty assignment
|
||||
bad = [u for u in urls if not u.startswith("https://") or "@" in u]
|
||||
assert not bad, f"{name} seeds a non-mTLS or userinfo-bearing target: {bad}"
|
||||
|
||||
|
||||
# --- shell syntax (the only gate these scripts get) -------------------------------------
|
||||
|
||||
|
||||
@@ -1544,3 +1610,727 @@ def test_a_token_mode_status_probe_stays_plain_http(tmp_path: Path) -> None:
|
||||
assert _installer_status(result.stdout).startswith("(no answer"), (
|
||||
"token mode must probe plain http — nothing listens here, and the refusal is the point"
|
||||
)
|
||||
|
||||
|
||||
# --- scripts/dev-certs-export.py (phase 24 task 01) ---------------------------------------
|
||||
|
||||
#: The dev exporter, and the dev server that calls it. Both are **run** below and **read**
|
||||
#: below: the bytes a host installs are the contract of the first, and the composition of the
|
||||
#: second (export *after* the readiness probe, failure *as* a warning) is the contract of the
|
||||
#: dev loop that has to stop producing `http://` agents.
|
||||
EXPORTER = ROOT / "scripts" / "dev-certs-export.py"
|
||||
DEV_SERVER = ROOT / "scripts" / "dev-server.sh"
|
||||
|
||||
#: The invariant-3 pair, as two configured hosts: one with a bundle in `meta`, one without.
|
||||
ISSUED_HOST = "issuedhost"
|
||||
UNISSUED_HOST = "unissuedhost"
|
||||
|
||||
|
||||
def _load_exporter() -> Any:
|
||||
"""The exporter as a module — it is a script, not a package member, so import it by path."""
|
||||
spec = importlib.util.spec_from_file_location("dev_certs_export", EXPORTER)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
@contextmanager
|
||||
def served_dashboard(tmp_path: Path) -> Iterator[tuple[str, SampleStore]]:
|
||||
"""A real `create_app(...)` on a real socket, with one host's bundle already issued.
|
||||
|
||||
uvicorn in a thread rather than `TestClient`, because the unit under test is an **HTTP
|
||||
client**: the exporter signs in over the public route and reads the session token back out
|
||||
of a `Set-Cookie` header, and a transport-level test client can pass that without ever
|
||||
serialising a header. Port 0, so nothing here can collide with a port another test bound.
|
||||
|
||||
The store is injected and closed here, and the two `HOST_TARGETS` entries point at a port
|
||||
nothing listens on: the app under test must never dial, and a dead target is what keeps
|
||||
this hermetic. Every other source is switched off explicitly, so an operator running the
|
||||
suite with `.env.dev` sourced still gets a test of two hosts and no fleet.
|
||||
"""
|
||||
db = tmp_path / "export.sqlite3"
|
||||
store = SampleStore(db)
|
||||
state = certs.ensure_certs(store)
|
||||
certs.issue_agent_cert(store, state, ISSUED_HOST, san=[ISSUED_HOST])
|
||||
settings = Settings(
|
||||
db_path=str(db),
|
||||
admin_password=ADMIN_PASSWORD,
|
||||
pbkdf2_iterations=FAST_PBKDF2,
|
||||
host_targets=(Target(name=ISSUED_HOST, url="http://127.0.0.1:1"),
|
||||
Target(name=UNISSUED_HOST, url="http://127.0.0.1:1")),
|
||||
host_interval=3600,
|
||||
host_timeout=0.2,
|
||||
gufo_url="",
|
||||
vllm_url="",
|
||||
nut_host="",
|
||||
)
|
||||
server = uvicorn.Server(uvicorn.Config(create_app(settings, store), host="127.0.0.1",
|
||||
port=0, log_level="error", lifespan="on"))
|
||||
thread = threading.Thread(target=server.run, daemon=True, name="dev-certs-export-test")
|
||||
thread.start()
|
||||
try:
|
||||
for _ in range(2_000):
|
||||
if server.started:
|
||||
break
|
||||
time.sleep(0.01)
|
||||
assert server.started, "the test dashboard never came up (see the captured stderr)"
|
||||
port = int(server.servers[0].sockets[0].getsockname()[1])
|
||||
yield f"http://127.0.0.1:{port}", store
|
||||
finally:
|
||||
server.should_exit = True
|
||||
thread.join(timeout=60)
|
||||
store.close()
|
||||
|
||||
|
||||
def _export(base: str, out_dir: Path, names: list[str], *,
|
||||
password: str | None = ADMIN_PASSWORD) -> subprocess.CompletedProcess[str]:
|
||||
"""Run the exporter exactly the way `dev-server.sh` does, as a subprocess."""
|
||||
args = [sys.executable, str(EXPORTER), "--url", base, "--out-dir", str(out_dir)]
|
||||
if password is not None:
|
||||
args += ["--password", password]
|
||||
if names:
|
||||
args += ["--names", *names]
|
||||
return _run(args)
|
||||
|
||||
|
||||
def _served_bundle(base: str, name: str) -> bytes:
|
||||
"""What `GET /api/certs/<name>/bundle` answers, fetched the way the exporter fetches it."""
|
||||
with httpx.Client(base_url=base, timeout=10.0) as client:
|
||||
login = client.post("/api/auth/login", json={"password": ADMIN_PASSWORD})
|
||||
login.raise_for_status()
|
||||
token = login.cookies[auth.SESSION_COOKIE]
|
||||
answer = client.get(f"/api/certs/{name}/bundle",
|
||||
headers={"authorization": f"Bearer {token}"})
|
||||
answer.raise_for_status()
|
||||
return answer.content
|
||||
|
||||
|
||||
def test_the_export_writes_the_issued_bundle_byte_identical_at_0600(tmp_path: Path) -> None:
|
||||
"""The task's headline: the file is the stored bundle, at a mode that keeps it private.
|
||||
|
||||
Byte-identity is compared against the **route** rather than the `meta` row because the
|
||||
question this answers is "is the file I am about to `scp` the file the dashboard issued?",
|
||||
and a bundle that re-signed between the two would make that question unanswerable.
|
||||
"""
|
||||
with served_dashboard(tmp_path) as (base, _store):
|
||||
out = tmp_path / "certs"
|
||||
result = _export(base, out, [ISSUED_HOST])
|
||||
assert result.returncode == 0, result.stderr
|
||||
path = out / f"{ISSUED_HOST}.bundle"
|
||||
assert path.is_file(), f"no bundle exported; stdout was:\n{result.stdout}"
|
||||
assert stat.S_IMODE(path.stat().st_mode) == 0o600, (
|
||||
"a bundle carries a private key; 0600 is its birth mode and stays its mode"
|
||||
)
|
||||
assert path.read_bytes() == _served_bundle(base, ISSUED_HOST)
|
||||
assert ISSUED_HOST in result.stdout and "0600" in result.stdout
|
||||
|
||||
|
||||
def test_an_unissued_host_writes_no_file_and_names_the_certs_route(tmp_path: Path) -> None:
|
||||
"""`issued: false` is a fact, not a failure: one line, no file, exit 0 (invariant 3)."""
|
||||
with served_dashboard(tmp_path) as (base, _store):
|
||||
out = tmp_path / "certs"
|
||||
result = _export(base, out, [ISSUED_HOST, UNISSUED_HOST])
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert not (out / f"{UNISSUED_HOST}.bundle").exists()
|
||||
note = next(line for line in result.stdout.splitlines() if UNISSUED_HOST in line)
|
||||
assert "no bundle issued" in note and "/certs" in note, note
|
||||
assert "!" not in note, f"an unissued host must not read as a failure: {note}"
|
||||
|
||||
|
||||
def test_a_name_outside_host_targets_is_a_note_and_not_a_crash(tmp_path: Path) -> None:
|
||||
"""The `{"configured": false}` arm: impossible from `HOST_TARGETS`, still never a crash."""
|
||||
with served_dashboard(tmp_path) as (base, _store):
|
||||
out = tmp_path / "certs"
|
||||
result = _export(base, out, ["nosuchhost"])
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert not out.exists(), "nothing was issued, so nothing — not even a directory"
|
||||
assert "HOST_TARGETS" in result.stdout
|
||||
|
||||
|
||||
def test_a_wrong_password_exits_non_zero_and_writes_nothing(tmp_path: Path) -> None:
|
||||
"""A dev server that cannot be signed in to says so loudly (and the CA stays untouched)."""
|
||||
with served_dashboard(tmp_path) as (base, _store):
|
||||
out = tmp_path / "certs"
|
||||
result = _export(base, out, [ISSUED_HOST], password="definitely-not-the-password")
|
||||
assert result.returncode == 2, f"a sign-in failure must not look like success: {result}"
|
||||
assert not out.exists()
|
||||
assert "sign-in" in result.stderr.lower()
|
||||
|
||||
|
||||
def test_no_password_at_all_is_a_refusal_and_not_a_hang(tmp_path: Path) -> None:
|
||||
"""`--password` is optional only because `$DASH_ADMIN_PASSWORD` is the dev source of it."""
|
||||
env = {key: value for key, value in os.environ.items() if key != "DASH_ADMIN_PASSWORD"}
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(EXPORTER), "--url", "http://127.0.0.1:1",
|
||||
"--out-dir", str(tmp_path / "certs"), "--names", ISSUED_HOST],
|
||||
capture_output=True, text=True, check=False, env=env, timeout=60,
|
||||
)
|
||||
assert result.returncode == 2
|
||||
assert "DASH_ADMIN_PASSWORD" in result.stderr
|
||||
assert not (tmp_path / "certs").exists()
|
||||
|
||||
|
||||
def test_no_names_at_all_is_a_quiet_zero(tmp_path: Path) -> None:
|
||||
"""A dashboard with no `HOST_TARGETS` has nothing to export and is not broken."""
|
||||
result = _export("http://127.0.0.1:1", tmp_path / "certs", [])
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "nothing to export" in result.stdout
|
||||
assert not (tmp_path / "certs").exists()
|
||||
|
||||
|
||||
def test_a_second_export_is_idempotent_and_says_unchanged(tmp_path: Path) -> None:
|
||||
"""`restart` runs this every time; a dev loop that rewrote keys on every boot would be noise."""
|
||||
with served_dashboard(tmp_path) as (base, _store):
|
||||
out = tmp_path / "certs"
|
||||
first = _export(base, out, [ISSUED_HOST])
|
||||
assert first.returncode == 0, first.stderr
|
||||
path = out / f"{ISSUED_HOST}.bundle"
|
||||
before = path.read_bytes()
|
||||
path.chmod(0o644) # somebody loosened it; the export is what puts the mode back
|
||||
second = _export(base, out, [ISSUED_HOST])
|
||||
assert second.returncode == 0, second.stderr
|
||||
assert path.read_bytes() == before, "the stored bundle must not move between exports"
|
||||
assert stat.S_IMODE(path.stat().st_mode) == 0o600, "an existing file is re-chmodded"
|
||||
assert "unchanged" in second.stdout
|
||||
|
||||
|
||||
def test_the_export_reads_the_authority_and_never_mints_anything(tmp_path: Path) -> None:
|
||||
"""The difference between `read_state` and `ensure_certs`, pinned as an observable.
|
||||
|
||||
"The dev server should generate certs" is the TODO's wording; the safe reading is *export
|
||||
what the first boot minted*. If this script ever issued, a stray `restart` would rotate a
|
||||
fleet's identities — so the serials, the revisions and the root all have to be the same
|
||||
bytes after the run as before it.
|
||||
"""
|
||||
with served_dashboard(tmp_path) as (base, store):
|
||||
def fingerprint() -> tuple[str, ...]:
|
||||
listed = certs.read_state(store)
|
||||
rows = tuple(f"{e.name}:{e.serial}:{e.bundle_rev}:{e.status}"
|
||||
for e in certs.read_agents(store))
|
||||
return (f"root:{listed.root_serial}", *rows)
|
||||
|
||||
before = fingerprint()
|
||||
assert _export(base, tmp_path / "certs",
|
||||
[ISSUED_HOST, UNISSUED_HOST, "nosuchhost"]).returncode == 0
|
||||
assert fingerprint() == before, "the exporter changed the certificate authority"
|
||||
|
||||
|
||||
def test_the_export_prints_no_key_material_and_no_password(tmp_path: Path) -> None:
|
||||
"""Invariant 6, on the one path that holds both a session token and a private key."""
|
||||
with served_dashboard(tmp_path) as (base, _store):
|
||||
result = _export(base, tmp_path / "certs", [ISSUED_HOST, UNISSUED_HOST])
|
||||
printed = result.stdout + result.stderr
|
||||
assert "BEGIN" not in printed, "a PEM block reached the terminal (and the dev log)"
|
||||
assert ADMIN_PASSWORD not in printed
|
||||
assert auth.SESSION_COOKIE not in printed, "the token itself must never be printed"
|
||||
|
||||
|
||||
def test_the_exporter_splits_names_the_way_host_targets_looks() -> None:
|
||||
"""One argument with spaces in it, three arguments, commas: all the same list."""
|
||||
module = _load_exporter()
|
||||
assert module.split_names(["alpha beta", "gamma,delta"]) == ["alpha", "beta", "gamma", "delta"]
|
||||
assert module.split_names([" alpha,,beta "]) == ["alpha", "beta"]
|
||||
assert module.split_names([]) == []
|
||||
assert module.split_names(None) == []
|
||||
|
||||
|
||||
def test_the_exporter_reads_the_cookie_the_app_sets() -> None:
|
||||
"""`auth.SESSION_COOKIE` is imported, not restated — a rename must not silently break dev."""
|
||||
module = _load_exporter()
|
||||
assert module.SESSION_COOKIE == auth.SESSION_COOKIE
|
||||
|
||||
|
||||
def test_write_bundle_refuses_a_name_that_would_leave_the_directory(tmp_path: Path) -> None:
|
||||
"""The route gates names long before this, but this is the function that picks a path."""
|
||||
module = _load_exporter()
|
||||
for bad in ("../escape", "nested/x", "/absolute", "", "."):
|
||||
with pytest.raises(module.ExportError):
|
||||
module.write_bundle(tmp_path, bad, b"secret")
|
||||
assert list(tmp_path.iterdir()) == []
|
||||
|
||||
|
||||
def test_the_bundles_only_ever_land_under_the_gitignored_data_tree() -> None:
|
||||
"""No key, bundle or PEM outside `data/`: the exporter's default and the caller's flag."""
|
||||
module = _load_exporter()
|
||||
assert module.DEFAULT_OUT_DIR == ROOT / "data" / "certs"
|
||||
gitignore = (ROOT / ".gitignore").read_text()
|
||||
assert re.search(r"^data/$", gitignore, re.MULTILINE), "data/ is the gitignored tree"
|
||||
dev_server = DEV_SERVER.read_text()
|
||||
assignments = [line for line in dev_server.splitlines() if line.startswith("CERTDIR=")]
|
||||
assert assignments == ['CERTDIR="$ROOT/data/certs"'], (
|
||||
f"the dev server must name exactly one export dir, under data/: {assignments}"
|
||||
)
|
||||
assert '--out-dir "$CERTDIR"' in dev_server, "the export must be told where it writes"
|
||||
|
||||
|
||||
def _shell_function(text: str, name: str) -> str:
|
||||
"""One function's body, so a pin can talk about *that* block and not the whole file."""
|
||||
match = re.search(rf"^{re.escape(name)}\(\) \{{$", text, re.MULTILINE)
|
||||
assert match is not None, f"{name}() is no longer a plain function in dev-server.sh"
|
||||
body = text[match.end():]
|
||||
end = body.index("\n}")
|
||||
return body[:end]
|
||||
|
||||
|
||||
def test_dev_server_exports_the_bundles_after_the_readiness_probe() -> None:
|
||||
"""The order is the whole safety of the dev loop: no bundle export against a dead server."""
|
||||
text = DEV_SERVER.read_text()
|
||||
probe = text.index("curl -sf -o /dev/null")
|
||||
call = text.index('export_bundles "$PORT"')
|
||||
assert probe < call, "the export must run only once /api/health has answered"
|
||||
assert "dev-certs-export.py" in _shell_function(text, "export_bundles")
|
||||
body = _shell_function(text, "export_bundles")
|
||||
assert "http://127.0.0.1:${port}" in body, "the exporter dials the dev server and nothing else"
|
||||
# The names are HOST_TARGETS' names, split by the caller: the exporter re-parses no config.
|
||||
assert "${entry%%=*}" in body
|
||||
|
||||
|
||||
def test_a_failed_bundle_export_never_fails_the_dev_server_start() -> None:
|
||||
"""A dev-convenience failure is a warning line: the readiness probe already proved the app."""
|
||||
body = _shell_function(DEV_SERVER.read_text(), "export_bundles")
|
||||
assert "|| echo" in body, "a failed export must print a warning rather than propagate"
|
||||
for escape in ("return 1", "exit 1", "set -e"):
|
||||
assert escape not in body, f"the export can now abort `start` ({escape})"
|
||||
|
||||
|
||||
def test_dev_server_status_prints_the_exported_bundles_block() -> None:
|
||||
"""`status` answers "is this host's bundle there yet?" without a signed-in curl."""
|
||||
text = DEV_SERVER.read_text()
|
||||
body = _shell_function(text, "bundles")
|
||||
assert "bundles exported" in body and ".bundle" in body
|
||||
assert "mode" in body, "the block shows the 0600, which is the thing worth checking"
|
||||
assert re.search(r"^ bundles ;;$", text, re.MULTILINE), "`status` must print the block"
|
||||
assert "bundles)" in text, "`bundles` is a subcommand of its own"
|
||||
|
||||
|
||||
def test_a_dashboard_that_never_answers_is_named_rather_than_guessed(tmp_path: Path) -> None:
|
||||
"""Nothing listened: the line says which URL, the exit is non-zero, nothing is written."""
|
||||
result = _export("http://127.0.0.1:1", tmp_path / "certs", [ISSUED_HOST])
|
||||
assert result.returncode == 2
|
||||
assert "no dashboard answered" in result.stderr and "127.0.0.1:1" in result.stderr
|
||||
assert not (tmp_path / "certs").exists()
|
||||
|
||||
|
||||
def test_an_unexpected_answer_on_the_bundle_route_is_not_read_as_unissued(tmp_path: Path) -> None:
|
||||
"""Only the two 404 shapes are notes. A 401 means the caller is not authorised, which is news."""
|
||||
with served_dashboard(tmp_path) as (base, _store):
|
||||
module = _load_exporter()
|
||||
with pytest.raises(module.ExportError) as raised:
|
||||
module.fetch_bundle(base, ISSUED_HOST, "not-a-session-token")
|
||||
assert "401" in str(raised.value)
|
||||
|
||||
|
||||
def test_one_host_the_route_refuses_never_stops_the_others(tmp_path: Any,
|
||||
monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The aggregation `main` owns: every name is attempted, and a failure moves the exit code."""
|
||||
module = _load_exporter()
|
||||
attempted: list[str] = []
|
||||
|
||||
def fake_fetch(base: str, name: str, token: str) -> bytes | str:
|
||||
attempted.append(name)
|
||||
if name == "bad":
|
||||
raise module.ExportError("bad: GET /api/certs/bad/bundle answered 500: boom")
|
||||
return b"bundle-bytes"
|
||||
|
||||
monkeypatch.setattr(module, "sign_in", lambda base, password: "a-token")
|
||||
monkeypatch.setattr(module, "fetch_bundle", fake_fetch)
|
||||
code = module.main(["--url", "http://127.0.0.1:1", "--password", "p",
|
||||
"--names", "good bad later", "--out-dir", str(tmp_path / "certs")])
|
||||
assert attempted == ["good", "bad", "later"], "one refused host must not end the run"
|
||||
assert code == 1, "a bundle nobody exported is a host that stays on http:// — say so"
|
||||
assert (tmp_path / "certs" / "good.bundle").is_file()
|
||||
assert not (tmp_path / "certs" / "bad.bundle").exists()
|
||||
|
||||
|
||||
# --- fetching the bundle during registration (phase 24 task 02) ---------------------------
|
||||
|
||||
#: `install-agent.sh --fetch-bundle <url> --cert-name <name> --session <token>`: the install
|
||||
#: run pulls its own bundle from the dashboard, so a host is never registered onto a token URL
|
||||
#: because an operator forgot a `curl`. The contract has three parts — the bytes that land are
|
||||
#: the bytes the route serves, a refusal writes *nothing*, and the session token is a fetch
|
||||
#: credential that appears in no artefact the run leaves behind.
|
||||
FETCH_UNISSUED = "no bundle issued for"
|
||||
FETCH_ALL_OR_NOTHING = "--fetch-bundle, --cert-name and --session go together"
|
||||
FETCH_TWO_SOURCES = "two bundle sources"
|
||||
FETCH_MODE_REFUSAL = "--fetch-bundle needs an install"
|
||||
|
||||
|
||||
def _sign_in(base: str) -> str:
|
||||
"""A real session token for the served dashboard — the credential `--session` carries."""
|
||||
with httpx.Client(base_url=base, timeout=10.0) as client:
|
||||
login = client.post("/api/auth/login", json={"password": ADMIN_PASSWORD})
|
||||
login.raise_for_status()
|
||||
return login.cookies[auth.SESSION_COOKIE]
|
||||
|
||||
|
||||
def _fetch_run(tmp_path: Path, *flags: str,
|
||||
) -> tuple[subprocess.CompletedProcess[str], Path, Path]:
|
||||
"""One sandboxed install with a sandbox `TMPDIR`, so the fetched temp file is observable.
|
||||
|
||||
The fetch writes to `${TMPDIR:-/tmp}`, so pointing TMPDIR into the sandbox is what turns
|
||||
"the fetched private key never stays behind" into an assertion instead of a hope — and it
|
||||
catches the temp file on the refusal paths too, where nothing else was written to look at.
|
||||
"""
|
||||
script, call_log, env = _sandbox_installer(tmp_path)
|
||||
tmpdir = tmp_path / "tmp"
|
||||
tmpdir.mkdir(parents=True, exist_ok=True)
|
||||
result = _install(script, {**env, "TMPDIR": str(tmpdir)}, *flags)
|
||||
return result, tmpdir, call_log
|
||||
|
||||
|
||||
def _fetch(base: str, name: str, token: str) -> list[str]:
|
||||
"""The three fetch flags, spelled once."""
|
||||
return ["--fetch-bundle", base, "--cert-name", name, "--session", token]
|
||||
|
||||
|
||||
@contextmanager
|
||||
def served_body(*, status: int = 200, body: bytes = b"wrong",
|
||||
media_type: str = "text/html") -> Iterator[str]:
|
||||
"""A loopback port that answers every GET with one canned body.
|
||||
|
||||
Only for the shapes the real route cannot produce: a proxy's 200 error page, a 200 with no
|
||||
bytes at all. The issued host, the unissued host and a refused session are all real answers
|
||||
from `served_dashboard`, and those are the legs that matter most.
|
||||
"""
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self) -> None:
|
||||
self.send_response(status)
|
||||
self.send_header("content-type", media_type)
|
||||
self.send_header("content-length", str(len(body)))
|
||||
self.end_headers()
|
||||
if body:
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, *_args: Any, **_kwargs: Any) -> None:
|
||||
pass # the installer's answer is the assertion, not this server's access log
|
||||
|
||||
httpd = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
||||
try:
|
||||
yield f"http://127.0.0.1:{httpd.server_address[1]}"
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
|
||||
def test_a_fetched_bundle_installs_byte_identical_at_0640(tmp_path: Path) -> None:
|
||||
"""The registration leg: fetch, validate, install, TLS unit — one run, no hand-carried file.
|
||||
|
||||
Byte-identity is against the same route's answer as task 01's export, because the claim the
|
||||
installer makes is the same one: the file on the host *is* the row in `meta`. Everything
|
||||
after the fetch is the existing `--cert-bundle` path, so the mode, the unit's TLS lines and
|
||||
the https hint are asserted here as unchanged rather than as new.
|
||||
"""
|
||||
with served_dashboard(tmp_path) as (base, _store):
|
||||
token = _sign_in(base)
|
||||
result, tmpdir, call_log = _fetch_run(
|
||||
tmp_path, "--bind", "10.0.0.5", "--port", "9120", *_fetch(base, ISSUED_HOST, token))
|
||||
if result.returncode != 0:
|
||||
pytest.fail(f"the fetch install failed:\n{result.stdout}\n{result.stderr}")
|
||||
|
||||
cert_path = tmp_path / "target" / "certs"
|
||||
assert cert_path.read_bytes() == _served_bundle(base, ISSUED_HOST)
|
||||
mode = stat.S_IMODE(cert_path.stat().st_mode)
|
||||
assert mode == 0o640, f"bundle mode is {oct(mode)}, expected 0o640 (group = agent user)"
|
||||
|
||||
unit = (tmp_path / "target" / "dashboards-agent.service").read_text()
|
||||
assert f"--cert-bundle {cert_path}" in unit
|
||||
assert "RuntimeDirectory=dashboards-agent" in unit
|
||||
assert 'HOST_TARGETS="hostname=https://10.0.0.5:9120"' in result.stdout
|
||||
assert f"fetched the certificate bundle for {ISSUED_HOST} from {base}" in result.stdout
|
||||
assert "chown root:dashboards-agent" in call_log.read_text()
|
||||
assert list(tmpdir.iterdir()) == [], "the fetched private key outlived the install"
|
||||
|
||||
|
||||
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
|
||||
def test_an_unissued_bundle_is_refused_before_anything_is_written(tmp_path: Path) -> None:
|
||||
"""`issued: false` is one sentence naming the fix, and the host stays exactly as it was.
|
||||
|
||||
This is the invariant-3 answer on the install path: the route says "configured, nothing
|
||||
issued", and the installer must not read that as a bundle, as a sudoers problem, or as a
|
||||
reason to create a service user. The stub log is the proof nothing privileged ran: `id -u`
|
||||
is the first privileged call a real install makes, and it is absent.
|
||||
"""
|
||||
with served_dashboard(tmp_path) as (base, _store):
|
||||
token = _sign_in(base)
|
||||
result, tmpdir, call_log = _fetch_run(
|
||||
tmp_path, "--bind", "10.0.0.5", *_fetch(base, UNISSUED_HOST, token))
|
||||
assert result.returncode == 2, f"a refused fetch keeps exit 2: {result.returncode}"
|
||||
assert result.stdout == "", "a refusal is a failure: nothing on stdout"
|
||||
assert FETCH_UNISSUED in result.stderr and UNISSUED_HOST in result.stderr
|
||||
assert "/certs" in result.stderr, "the sentence names where the certificate is issued"
|
||||
assert GRANT_PREFIX not in result.stderr, "a refusal is not a sudoers problem"
|
||||
assert not call_log.exists(), "refused before the root boundary: no privileged command ran"
|
||||
assert not (tmp_path / "target").exists(), "a refused fetch writes no files at all"
|
||||
assert list(tmpdir.iterdir()) == [], "the temp file is gone even though it was written"
|
||||
|
||||
|
||||
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
|
||||
def test_a_name_the_dashboard_does_not_dial_is_refused_with_its_own_404(tmp_path: Path) -> None:
|
||||
"""`{"configured": false}` is a third fact, and it is not read as "nothing issued".
|
||||
|
||||
The route refuses a name outside `HOST_TARGETS` before any signing, so a typo in
|
||||
`--cert-name` must say *that* — an operator who is told "no bundle issued" would go looking
|
||||
for a certificate that was never supposed to exist for a host this dashboard does not dial.
|
||||
"""
|
||||
with served_dashboard(tmp_path) as (base, _store):
|
||||
token = _sign_in(base)
|
||||
result, tmpdir, call_log = _fetch_run(
|
||||
tmp_path, "--bind", "10.0.0.5", *_fetch(base, "nosuchhost", token))
|
||||
assert result.returncode == 2
|
||||
assert "404" in result.stderr, result.stderr
|
||||
assert FETCH_UNISSUED not in result.stderr, "not configured is not the same as unissued"
|
||||
assert "HOST_TARGETS" in result.stderr, "the dashboard's own sentence is echoed"
|
||||
assert not call_log.exists()
|
||||
assert list(tmpdir.iterdir()) == []
|
||||
|
||||
|
||||
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
|
||||
def test_a_refused_session_names_the_http_status(tmp_path: Path) -> None:
|
||||
"""A stale or wrong session is a 401 on the wire, and the run says 401.
|
||||
|
||||
Not "no bundle" and not a sudoers hint: the operator's move is a fresh sign-in, and the
|
||||
only way they learn that is the status the dashboard actually answered.
|
||||
"""
|
||||
with served_dashboard(tmp_path) as (base, _store):
|
||||
result, tmpdir, call_log = _fetch_run(
|
||||
tmp_path, "--bind", "10.0.0.5", *_fetch(base, ISSUED_HOST, "not-a-session-token"))
|
||||
assert result.returncode == 2
|
||||
assert "401" in result.stderr, result.stderr
|
||||
assert "sign in required" in result.stderr, "the dashboard's own sentence is echoed"
|
||||
assert not call_log.exists()
|
||||
assert not (tmp_path / "target").exists()
|
||||
assert list(tmpdir.iterdir()) == []
|
||||
|
||||
|
||||
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
|
||||
@pytest.mark.parametrize(
|
||||
("status", "body", "reason"),
|
||||
[
|
||||
(200, b"<html><body>502 from the proxy</body></html>", "# BUNDLE-REV"),
|
||||
(200, b"", "empty body"),
|
||||
(500, b"upstream exploded", "500"),
|
||||
],
|
||||
ids=["proxy-error-page", "empty-200", "server-error"],
|
||||
)
|
||||
def test_a_body_that_is_not_a_bundle_never_becomes_an_install(
|
||||
tmp_path: Path, status: int, body: bytes, reason: str
|
||||
) -> None:
|
||||
"""The shapes the dashboard cannot answer with — and the check that catches each one.
|
||||
|
||||
A 200 with a proxy's HTML page is the dangerous one: it is a *success* to any client that
|
||||
only looks at the status, and installing it produces a unit that exits at startup. It falls
|
||||
through to the existing shape check, which is the reason one validator serves both bundle
|
||||
sources. The refusal names `--fetch-bundle` (the flag that was used), not the temp file.
|
||||
"""
|
||||
with served_body(status=status, body=body) as base:
|
||||
result, tmpdir, call_log = _fetch_run(
|
||||
tmp_path, "--bind", "10.0.0.5", *_fetch(base, ISSUED_HOST, "a-token"))
|
||||
assert result.returncode == 2, f"{status}/{len(body)} bytes must be refused"
|
||||
assert reason in result.stderr, result.stderr
|
||||
assert "--fetch-bundle" in result.stderr
|
||||
assert not call_log.exists()
|
||||
assert not (tmp_path / "target").exists()
|
||||
assert list(tmpdir.iterdir()) == []
|
||||
|
||||
|
||||
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
|
||||
def test_a_dashboard_that_never_answers_stops_the_fetch(tmp_path: Path) -> None:
|
||||
"""Nothing listening on the dashboard URL: the line names it, and no install happens."""
|
||||
result, tmpdir, call_log = _fetch_run(
|
||||
tmp_path, "--bind", "10.0.0.5",
|
||||
*_fetch("http://127.0.0.1:1", ISSUED_HOST, "a-token"))
|
||||
assert result.returncode == 2
|
||||
assert "no dashboard answered" in result.stderr and "127.0.0.1:1" in result.stderr
|
||||
assert not call_log.exists()
|
||||
assert list(tmpdir.iterdir()) == []
|
||||
|
||||
|
||||
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
|
||||
def test_the_session_token_appears_in_no_written_artefact(tmp_path: Path) -> None:
|
||||
"""Completion criterion: the token is a fetch credential, not a stored one.
|
||||
|
||||
The test signs in for a real token and then searches **every** file the run could have
|
||||
touched plus both output streams. The agent's own bearer token is expected in the token
|
||||
file and on stdout — that is a different secret, deliberately printed once — so the pin is
|
||||
specifically that this session value is nowhere.
|
||||
"""
|
||||
with served_dashboard(tmp_path) as (base, _store):
|
||||
token = _sign_in(base)
|
||||
assert token and token != ADMIN_PASSWORD
|
||||
result, _tmpdir, call_log = _fetch_run(
|
||||
tmp_path, "--bind", "10.0.0.5", "--port", "9120",
|
||||
*_fetch(base, ISSUED_HOST, token))
|
||||
assert result.returncode == 0, f"install failed:\n{result.stderr}"
|
||||
|
||||
where = tmp_path / "target"
|
||||
assert token not in (where / "token").read_text(), "the token file is the agent's, not ours"
|
||||
assert token not in (where / "certs").read_text(), "a bundle is not a place for a session"
|
||||
assert token not in (where / "dashboards-agent.service").read_text()
|
||||
assert token not in call_log.read_text(), "a privileged call must not carry it either"
|
||||
assert token not in result.stdout + result.stderr
|
||||
|
||||
hits = [str(path.relative_to(tmp_path)) for path in tmp_path.rglob("*")
|
||||
if path.is_file() and token in path.read_text(encoding="utf-8", errors="replace")]
|
||||
assert hits == [], f"the session token was written to {hits}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"flags",
|
||||
[
|
||||
["--fetch-bundle", "http://127.0.0.1:8090"],
|
||||
["--cert-name", "deskwork"],
|
||||
["--session", "a-token"],
|
||||
["--fetch-bundle", "http://127.0.0.1:8090", "--cert-name", "deskwork"],
|
||||
["--cert-name", "deskwork", "--session", "a-token"],
|
||||
],
|
||||
ids=["url", "name", "token", "url+name", "name+token"],
|
||||
)
|
||||
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
|
||||
def test_the_fetch_flags_are_all_or_nothing(tmp_path: Path, flags: list[str]) -> None:
|
||||
"""Half a fetch is a refusal, not a guess: no URL, no name, or no token means nothing dials.
|
||||
|
||||
A URL without a name has no route to fetch, and a name without a token is a 401 the
|
||||
operator would have to diagnose from a stack of unrelated output. Both are answered with
|
||||
one sentence before the root boundary and before any write — the `--caps` reasoning again.
|
||||
"""
|
||||
script, call_log, env = _sandbox_installer(tmp_path)
|
||||
result = _install(script, env, "--bind", "127.0.0.1", "--port", "9199", *flags)
|
||||
assert result.returncode == 1, "a flag mistake keeps exit 1, not the fetch's 2"
|
||||
assert result.stdout == ""
|
||||
assert FETCH_ALL_OR_NOTHING in result.stderr
|
||||
assert GRANT_PREFIX not in result.stderr, "a flag mistake is not a sudoers problem"
|
||||
assert not call_log.exists(), "refused before a single privileged command"
|
||||
assert not (tmp_path / "target").exists()
|
||||
|
||||
|
||||
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
|
||||
def test_two_bundle_sources_are_refused_rather_than_ordered(tmp_path: Path) -> None:
|
||||
"""`--fetch-bundle` beside `--cert-bundle` is a contradiction: one install, one answer.
|
||||
|
||||
"Which one wins" has no good answer here — a stale file and a fresh bundle are two
|
||||
different identities, and silently preferring either would install a certificate the
|
||||
operator did not mean to install. So the run stops.
|
||||
"""
|
||||
bundle = tmp_path / "agent.bundle"
|
||||
bundle.write_text(_fake_bundle_text(), encoding="ascii")
|
||||
script, call_log, env = _sandbox_installer(tmp_path)
|
||||
result = _install(script, env, "--bind", "127.0.0.1", "--port", "9199",
|
||||
*_fetch("http://127.0.0.1:8090", "deskwork", "a-token"),
|
||||
"--cert-bundle", str(bundle))
|
||||
assert result.returncode == 1
|
||||
assert FETCH_TWO_SOURCES in result.stderr
|
||||
assert GRANT_PREFIX not in result.stderr
|
||||
assert not call_log.exists()
|
||||
assert not (tmp_path / "target").exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["--status", "--uninstall"], ids=["status", "uninstall"])
|
||||
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
|
||||
def test_fetching_a_bundle_is_refused_outside_an_install(tmp_path: Path, mode: str) -> None:
|
||||
"""The fetch belongs to the run that installs, or a key is fetched to be thrown away.
|
||||
|
||||
`--status` answers about a port and `--uninstall` removes files; neither has a use for the
|
||||
bytes, and a silent "flags ignored" on the uninstall path would leave an operator believing
|
||||
a bundle had been refreshed.
|
||||
"""
|
||||
script, call_log, env = _sandbox_installer(tmp_path)
|
||||
result = _install(script, env, mode,
|
||||
*_fetch("http://127.0.0.1:8090", "deskwork", "a-token"))
|
||||
assert result.returncode == 1
|
||||
assert FETCH_MODE_REFUSAL in result.stderr
|
||||
assert not call_log.exists(), "and refused before the mode's own privileged work"
|
||||
|
||||
|
||||
def test_help_teaches_the_registration_fetch() -> None:
|
||||
"""The three flags and the recipe are learnable from `--help`, like every other flag here."""
|
||||
stdout = _run([BASH, str(INSTALLER), "--help"]).stdout
|
||||
assert "--fetch-bundle URL" in stdout
|
||||
assert "--cert-name NAME" in stdout
|
||||
assert "--session TOKEN" in stdout
|
||||
assert "GET <url>/api/certs/<cert-name>/bundle" in stdout
|
||||
assert "fetch credential only" in stdout, "the token's scope has to be learnable, not folklore"
|
||||
recipe = "--fetch-bundle https://dashboards.example.com --cert-name <HOST_TARGETS name>"
|
||||
assert recipe in stdout.replace("\\\n", " ").replace(" ", " "), (
|
||||
"the recipe line must be copy-pasteable from --help"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
|
||||
def test_the_sudoers_hint_never_repeats_the_session_token(tmp_path: Path) -> None:
|
||||
"""The hint quotes a command, so it quotes only the flags that are safe to re-type.
|
||||
|
||||
`need_root` ends with `sudo <this script> …` so the operator can copy it. Handing that
|
||||
string the real argv would print a bearer credential into the terminal, the ssh log and
|
||||
whoever's scrollback — and the fetch runs *before* the root boundary, so this is the one
|
||||
path where a good fetch and a refusal share a run. The fetch succeeding here is also the
|
||||
proof of the order: an unprivileged run can only reach the hint after fetching.
|
||||
"""
|
||||
with served_dashboard(tmp_path) as (base, _store):
|
||||
token = _sign_in(base)
|
||||
result = _installer_without_root("--bind", "10.0.0.5", "--port", "9120",
|
||||
*_fetch(base, ISSUED_HOST, token))
|
||||
assert result.returncode == 1
|
||||
assert _grant_lines(result.stderr), "the fetch succeeded, so what remains is the grant"
|
||||
assert f"fetched the certificate bundle for {ISSUED_HOST}" in result.stdout
|
||||
assert token not in result.stdout + result.stderr
|
||||
assert "--session" not in result.stderr, "the echoed command must not name the flag either"
|
||||
|
||||
|
||||
def test_the_session_token_is_confined_to_the_fetch() -> None:
|
||||
"""Structural pin: the install body — everything that writes — cannot see the token.
|
||||
|
||||
`unset SESSION_TOKEN` is the promise; this is the proof that the promise is enforceable
|
||||
rather than aspirational. Every file the installer writes is written inside `do_install`
|
||||
(the token file, the bundle, the unit), so if the name does not appear anywhere in that
|
||||
function or in the unit template, no artefact can carry it — the same style of pin
|
||||
`tests/test_auth.py` and `tests/test_certs.py` hold over the two modules that hold secrets.
|
||||
"""
|
||||
text = INSTALLER.read_text()
|
||||
assert 'unset SESSION_TOKEN' in text, "the fetch must forget the credential when it is done"
|
||||
assert 'FETCH_SESSION="$SESSION_TOKEN" python3' in text, (
|
||||
"the token reaches python in the environment: argv is world-readable through `ps`, so a"
|
||||
" second copy on a command line would widen the exposure instead of limiting it"
|
||||
)
|
||||
body = text.split("do_install() {", 1)[1]
|
||||
assert "SESSION_TOKEN" not in body, "nothing that writes may read the fetch credential"
|
||||
assert text.count("SESSION_TOKEN") <= 8, (
|
||||
"the name belongs to the parser, the all-or-nothing check and the fetch; every further"
|
||||
" mention is another place a bearer token could be printed or written"
|
||||
)
|
||||
|
||||
|
||||
def test_the_fetch_and_its_shape_check_both_run_before_the_root_boundary() -> None:
|
||||
"""Text pin for the order the no-partial-install promise depends on.
|
||||
|
||||
Behaviourally, a refusal as `ops` proves the refusal comes first; this proves the *shape*
|
||||
check is not quietly moved after `need_root` (where a bad fetched bundle would produce a
|
||||
sudoers hint instead of a reason, on the host where the operator can least afford it).
|
||||
"""
|
||||
body = INSTALLER.read_text().split("do_install() {", 1)[1]
|
||||
fetch = body.index("fetch_bundle\n")
|
||||
shape = body.index('validate_bundle "$BUNDLE_SRC"')
|
||||
root = body.index("need_root --bind")
|
||||
assert fetch < shape < root, "fetch, then shape, then root — in that order"
|
||||
assert 'trap cleanup_fetch EXIT' in INSTALLER.read_text(), (
|
||||
"the fetched key is cleaned up on every exit path, not only the happy one"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
|
||||
def test_a_local_bundle_still_validates_with_its_own_flag_name(tmp_path: Path) -> None:
|
||||
"""The validator grew a label, and the pre-existing path must read exactly as before.
|
||||
|
||||
`--fetch-bundle` needed the refusal to name the flag that was used; the `--cert-bundle`
|
||||
path must not start naming a flag nobody passed. That is the whole change, so this is the
|
||||
whole pin — the file-local behaviour around it is covered by the phase-15 tests.
|
||||
"""
|
||||
path = tmp_path / "revless.bundle"
|
||||
path.write_text(_fake_bundle_text().replace("# BUNDLE-REV 3\n", ""), encoding="ascii")
|
||||
result = _installer_without_root("--cert-bundle", str(path))
|
||||
assert result.returncode == 2
|
||||
assert result.stderr.startswith("--cert-bundle"), result.stderr
|
||||
assert "--fetch-bundle" not in result.stderr
|
||||
|
||||
Reference in New Issue
Block a user