Phase 0: repo skeleton, image, compose stacks, doctor, migration runner, frontend shell
New repo for the Packet Station rebuild (Python 3.12, FastAPI, PostgreSQL 16, React, VisualKit, MFilesKit, Docker Compose on docker-1). Nothing is migrated from the M: install. - CLAUDE.md with the safety rules, README, CHANGELOG, docs (BRIEF, PLAN, SECURITY rewritten for the server with the requirement ID table, DEPLOY, stubs for FEED, POLICY, SCHEMA, GO-LIVE, PAPER-FALLBACK). - packetstation CLI: app, worker (heartbeat stub), migrate, doctor, version, healthcheck. Settings from the environment with secret files. Stack guard (test never points at VISUAL production). Security headers and CSP. Trusted proxy resolved by name. JSON logs with redaction. - Migration runner and 0001_base: settings, append-only audit_events with hash chain, ps_notify, grants for ps_app. - Multi-stage Dockerfile (ODBC Driver 18, PESCO libraries from the Gitea index only), compose base, proxy overlay for npm_default, CI overlay, postgres initdb roles, env examples, NPM proxy host notes, Gitea Actions. - Frontend: Vite, React, TypeScript, the PESCO dark theme ported with self-hosted fonts, placeholder sign-in and home. - Verified on docker-1: image builds, CI stack comes up healthy, doctor database checks pass, 77 server tests pass against PostgreSQL. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
"""Fixtures for server tests. Real PostgreSQL tests run only when TEST_PG_SUPERUSER_URL is set."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from pshelpers import PG_URL, fake_visual, make_settings
|
||||
|
||||
from packetstation.settings import Settings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dev_visual(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from packetstation import guard
|
||||
|
||||
monkeypatch.setattr(guard, "load_visual_settings", lambda: fake_visual(False))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pg_settings(tmp_path: Path) -> Iterator[Settings]:
|
||||
"""A fresh database with ps_migrate/ps_app (passwords reset), migrated to head; dropped afterwards."""
|
||||
import psycopg
|
||||
from psycopg import sql
|
||||
from psycopg.conninfo import conninfo_to_dict
|
||||
|
||||
from packetstation import db, migrate
|
||||
|
||||
assert PG_URL
|
||||
info = conninfo_to_dict(PG_URL)
|
||||
mig_pw, app_pw = secrets.token_urlsafe(18), secrets.token_urlsafe(18)
|
||||
(tmp_path / "mig").write_text(mig_pw + "\n")
|
||||
(tmp_path / "app").write_text(app_pw + "\r\n")
|
||||
name = f"ps_test_{secrets.token_hex(4)}"
|
||||
s = make_settings(
|
||||
tmp_path,
|
||||
pg_host=info.get("host", "localhost"),
|
||||
pg_port=int(info.get("port", 5432)),
|
||||
pg_database=name,
|
||||
pg_migrate_password_file=tmp_path / "mig",
|
||||
pg_app_password_file=tmp_path / "app",
|
||||
pg_superuser_url=PG_URL,
|
||||
)
|
||||
migrate.bootstrap_roles(s)
|
||||
with psycopg.connect(PG_URL, autocommit=True) as su:
|
||||
for user, pw in (("ps_migrate", mig_pw), ("ps_app", app_pw)):
|
||||
su.execute(sql.SQL("ALTER ROLE {} LOGIN PASSWORD {}").format(sql.Identifier(user), sql.Literal(pw)))
|
||||
with db.connect_as("migrate", s, autocommit=True) as conn:
|
||||
migrate.run(conn, db.MIGRATIONS_DIR)
|
||||
try:
|
||||
yield s
|
||||
finally:
|
||||
db.close_pool()
|
||||
with psycopg.connect(PG_URL, autocommit=True) as su:
|
||||
su.execute(sql.SQL("DROP DATABASE IF EXISTS {} WITH (FORCE)").format(sql.Identifier(name)))
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Helpers shared by the server tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from packetstation.settings import Settings
|
||||
|
||||
|
||||
def make_settings(tmp: Path, **over: Any) -> Settings:
|
||||
values: dict[str, Any] = {
|
||||
"_env_file": None,
|
||||
"stack": "ci",
|
||||
"trusted_proxy_host": "",
|
||||
"static_dir": tmp / "no-static",
|
||||
"assets_dir": tmp / "assets",
|
||||
"backup_dir": tmp / "no-backups",
|
||||
}
|
||||
values.update(over)
|
||||
return Settings(**values)
|
||||
|
||||
|
||||
def fake_visual(production: bool = False, explicit: bool = False) -> SimpleNamespace:
|
||||
server, database = ("vsql", "PESCOINC") if production else (r"VSQL\DEV", "PESCOINCDEV")
|
||||
return SimpleNamespace(
|
||||
is_production=production,
|
||||
server=server,
|
||||
database=database,
|
||||
model_fields_set={"server", "database"} if explicit else set(),
|
||||
)
|
||||
|
||||
|
||||
PG_URL = os.environ.get("TEST_PG_SUPERUSER_URL")
|
||||
needs_pg = pytest.mark.skipif(not PG_URL, reason="TEST_PG_SUPERUSER_URL not set (no PostgreSQL)")
|
||||
@@ -0,0 +1,117 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from pshelpers import make_settings
|
||||
|
||||
from packetstation import security
|
||||
from packetstation.main import PLACEHOLDER, create_app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path: Path, dev_visual: None) -> TestClient:
|
||||
return TestClient(create_app(make_settings(tmp_path, ps_build="1.2.3-test")))
|
||||
|
||||
|
||||
EXPECTED = {
|
||||
"strict-transport-security": "max-age=31536000; includeSubDomains",
|
||||
"x-content-type-options": "nosniff",
|
||||
"x-frame-options": "DENY",
|
||||
"referrer-policy": "no-referrer",
|
||||
"permissions-policy": "camera=(), microphone=(), geolocation=()",
|
||||
"cross-origin-opener-policy": "same-origin",
|
||||
}
|
||||
|
||||
|
||||
def _assert_headers(r) -> None: # type: ignore[no-untyped-def]
|
||||
for k, v in EXPECTED.items():
|
||||
assert r.headers.get(k) == v, k
|
||||
csp = r.headers["content-security-policy"]
|
||||
for part in (
|
||||
"default-src 'self'",
|
||||
"script-src 'self'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: blob:",
|
||||
"font-src 'self'",
|
||||
"connect-src 'self'",
|
||||
"worker-src 'self' blob:",
|
||||
"frame-ancestors 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
):
|
||||
assert part in csp
|
||||
assert csp == security.CSP
|
||||
|
||||
|
||||
def test_healthz(client: TestClient) -> None:
|
||||
r = client.get("/healthz")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"ok": True, "build": "1.2.3-test"}
|
||||
_assert_headers(r)
|
||||
assert r.headers.get("cache-control") != "no-store"
|
||||
|
||||
|
||||
def test_version(client: TestClient) -> None:
|
||||
r = client.get("/api/version")
|
||||
assert r.json() == {"build": "1.2.3-test", "stack": "ci", "site_tz": "America/Denver"}
|
||||
_assert_headers(r)
|
||||
assert r.headers["cache-control"] == "no-store"
|
||||
|
||||
|
||||
def test_db_health_503_without_database(client: TestClient) -> None:
|
||||
r = client.get("/api/db-health")
|
||||
assert r.status_code == 503
|
||||
assert r.json()["ok"] is False
|
||||
assert r.headers["cache-control"] == "no-store"
|
||||
|
||||
|
||||
def test_placeholder_when_static_missing(client: TestClient) -> None:
|
||||
for path in ("/", "/clerical/packet/12"):
|
||||
r = client.get(path)
|
||||
assert r.status_code == 200 and r.text == PLACEHOLDER
|
||||
_assert_headers(r)
|
||||
|
||||
|
||||
def test_api_and_blob_never_fall_back(client: TestClient) -> None:
|
||||
assert client.get("/api/nope").status_code == 404
|
||||
assert client.get("/_blob/abc").status_code == 404
|
||||
|
||||
|
||||
def test_static_and_spa_fallback(tmp_path: Path, dev_visual: None) -> None:
|
||||
static = tmp_path / "static"
|
||||
(static / "assets").mkdir(parents=True)
|
||||
(static / "index.html").write_text("<html>INDEX</html>")
|
||||
(static / "assets" / "app.js").write_text("console.log(1)")
|
||||
(tmp_path / "secret.txt").write_text("outside")
|
||||
c = TestClient(create_app(make_settings(tmp_path, static_dir=static)))
|
||||
assert c.get("/").text == "<html>INDEX</html>"
|
||||
assert c.get("/assets/app.js").text == "console.log(1)"
|
||||
assert c.get("/warehouse/board").text == "<html>INDEX</html>"
|
||||
r = c.get("/..%2fsecret.txt")
|
||||
assert "outside" not in r.text
|
||||
|
||||
|
||||
def test_request_log_line(client: TestClient, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
import json
|
||||
|
||||
from packetstation import logs
|
||||
|
||||
logs.setup("INFO")
|
||||
client.get("/api/version?token=abc")
|
||||
lines = [json.loads(x) for x in capsys.readouterr().out.splitlines() if '"request"' in x]
|
||||
rec = lines[-1]
|
||||
assert rec["method"] == "GET" and rec["path"] == "/api/version" and rec["status"] == 200
|
||||
assert rec["build"] == "1.2.3-test" and "ms" in rec and "ip" in rec
|
||||
assert "token" not in json.dumps(rec)
|
||||
|
||||
|
||||
def test_lazy_module_app(dev_visual: None, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
import packetstation.main as m
|
||||
|
||||
monkeypatch.setenv("STATIC_DIR", str(tmp_path / "none"))
|
||||
monkeypatch.setattr(m, "_app", None)
|
||||
app = m.app
|
||||
assert app is m.app
|
||||
assert TestClient(app).get("/healthz").json()["ok"] is True
|
||||
@@ -0,0 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta, timezone
|
||||
|
||||
from pshelpers import needs_pg
|
||||
|
||||
from packetstation import audit, db
|
||||
from packetstation.settings import Settings
|
||||
|
||||
AT = datetime(2026, 10, 15, 12, 0, 0, 123456, tzinfo=UTC)
|
||||
|
||||
|
||||
def test_canonical_is_stable_and_normalised() -> None:
|
||||
uid = uuid.uuid4()
|
||||
a = audit.canonical({"at": AT, "action": "x", "user_id": uid, "detail": {"b": 1, "a": [1, 2]}, "ip": "10.0.0.1"})
|
||||
denver = AT.astimezone(timezone(timedelta(hours=-6)))
|
||||
b = audit.canonical(
|
||||
{"detail": {"a": [1, 2], "b": 1}, "ip": "10.0.0.1/32", "user_id": str(uid), "action": "x", "at": denver}
|
||||
)
|
||||
assert a == b
|
||||
assert b'"at":"2026-10-15T12:00:00.123456+00:00"' in a
|
||||
assert b" " not in a
|
||||
|
||||
|
||||
def test_row_hash_chains() -> None:
|
||||
f1 = {"at": AT, "action": "one"}
|
||||
h1 = audit.row_hash(None, f1)
|
||||
assert h1 == hashlib.sha256(audit.canonical(f1)).digest()
|
||||
h2 = audit.row_hash(h1, {"at": AT, "action": "two"})
|
||||
assert h2 == hashlib.sha256(h1 + audit.canonical({"at": AT, "action": "two"})).digest()
|
||||
assert audit.row_hash(None, {"at": AT, "action": "two"}) != h2
|
||||
|
||||
|
||||
@needs_pg
|
||||
def test_pg_chain_verifies_and_detects_tamper(pg_settings: Settings) -> None:
|
||||
with db.connect_as("app", pg_settings, autocommit=True) as conn:
|
||||
first = audit.append(
|
||||
conn,
|
||||
action="a.one",
|
||||
user_id=uuid.uuid4(),
|
||||
detail={"n": 1, "s": "x"},
|
||||
ip="10.1.2.3",
|
||||
packet_id=5,
|
||||
subject_type="packet",
|
||||
subject_id=5,
|
||||
)
|
||||
second = audit.append(conn, action="a.two", detail=None)
|
||||
assert second.id > first.id
|
||||
prev = conn.execute("SELECT prev_hash FROM audit_events WHERE id = %s", (second.id,)).fetchone()
|
||||
assert bytes(prev[0]) == first.row_hash
|
||||
ok, n, detail = audit.verify_chain(conn)
|
||||
assert ok, detail
|
||||
assert n == 2
|
||||
# Tamper as the superuser with the trigger disabled, then verification must fail
|
||||
import psycopg
|
||||
from pshelpers import PG_URL
|
||||
from psycopg.conninfo import make_conninfo
|
||||
|
||||
assert PG_URL
|
||||
with psycopg.connect(make_conninfo(PG_URL, dbname=pg_settings.pg_database), autocommit=True) as su:
|
||||
su.execute("ALTER TABLE audit_events DISABLE TRIGGER audit_events_append_only")
|
||||
su.execute("UPDATE audit_events SET action = 'forged' WHERE id = %s", (first.id,))
|
||||
with db.connect_as("app", pg_settings) as conn:
|
||||
ok, _, detail = audit.verify_chain(conn)
|
||||
assert not ok and "row_hash" in detail
|
||||
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from packetstation import cli, worker
|
||||
|
||||
|
||||
def test_version(capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("PS_BUILD", "abc123")
|
||||
assert cli.main(["version"]) == 0
|
||||
assert "build abc123" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_worker_healthcheck(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
beat = tmp_path / "beat"
|
||||
monkeypatch.setenv("WORKER_BEAT_FILE", str(beat))
|
||||
assert cli.main(["healthcheck", "worker"]) == 1
|
||||
worker.touch(beat)
|
||||
assert cli.main(["healthcheck", "worker"]) == 0
|
||||
old = time.time() - 120
|
||||
os.utime(beat, (old, old))
|
||||
assert cli.main(["healthcheck", "worker"]) == 1
|
||||
|
||||
|
||||
def test_app_healthcheck_down() -> None:
|
||||
# Nothing listens on 8000 in tests (or something unrelated does): either way not a crash
|
||||
assert cli.main(["healthcheck", "app"]) in (0, 1)
|
||||
|
||||
|
||||
def test_worker_loop_touches_and_stops(tmp_path: Path) -> None:
|
||||
beat = tmp_path / "sub" / "beat"
|
||||
stop = threading.Event()
|
||||
t = threading.Thread(target=worker.run, args=(beat, "t"), kwargs={"stop": stop, "interval": 0.05})
|
||||
t.start()
|
||||
deadline = time.time() + 5
|
||||
while not beat.exists() and time.time() < deadline:
|
||||
time.sleep(0.01)
|
||||
stop.set()
|
||||
t.join(5)
|
||||
assert beat.exists() and not t.is_alive()
|
||||
|
||||
|
||||
def test_migrate_reports_bad_secret(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
monkeypatch.setenv("PG_MIGRATE_PASSWORD_FILE", str(tmp_path / "missing"))
|
||||
assert cli.main(["migrate"]) == 2
|
||||
assert "PG_MIGRATE_PASSWORD_FILE" in capsys.readouterr().err
|
||||
@@ -0,0 +1,108 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from pshelpers import fake_visual, make_settings, needs_pg
|
||||
|
||||
from packetstation import cli, doctor, guard
|
||||
from packetstation.settings import Settings
|
||||
|
||||
ALL = ",".join(doctor.SKIPPABLE)
|
||||
|
||||
|
||||
def test_all_skipped_json_shape(dev_visual: None, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
rc = cli.main(["doctor", "--json", "--skip", ALL])
|
||||
out = json.loads(capsys.readouterr().out)
|
||||
assert rc == 0
|
||||
assert isinstance(out, list) and out
|
||||
for item in out:
|
||||
assert set(item) == {"name", "status", "detail", "hint"}
|
||||
assert item["status"] in ("PASS", "WARN", "FAIL", "SKIP")
|
||||
by = {c["name"]: c for c in out}
|
||||
assert by["config"]["status"] == "PASS"
|
||||
assert by["config: stack guard"]["status"] == "PASS"
|
||||
assert by["tz"]["status"] == "PASS"
|
||||
for key in doctor.SKIPPABLE:
|
||||
assert by[key]["status"] == "SKIP"
|
||||
|
||||
|
||||
def test_text_report(dev_visual: None, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
assert cli.main(["doctor", "--skip", ALL]) == 0
|
||||
assert "[PASS] config" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_guard_failure_fails_doctor(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
monkeypatch.setattr(guard, "load_visual_settings", lambda: fake_visual(True, explicit=True))
|
||||
assert cli.main(["doctor", "--json", "--skip", ALL]) == 1
|
||||
by = {c["name"]: c for c in json.loads(capsys.readouterr().out)}
|
||||
assert by["config: stack guard"]["status"] == "FAIL"
|
||||
|
||||
|
||||
def test_visual_refuses_production_off_prod(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
monkeypatch.setattr(guard, "load_visual_settings", lambda: fake_visual(True, explicit=True))
|
||||
[c] = doctor.check_visual(make_settings(tmp_path, stack="test"))
|
||||
assert c.status == "FAIL" and "refusing PRODUCTION" in c.detail
|
||||
|
||||
|
||||
def test_missing_stack_fails_config(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
monkeypatch.delenv("STACK")
|
||||
assert cli.main(["doctor", "--json", "--skip", ALL]) == 1
|
||||
by = {c["name"]: c for c in json.loads(capsys.readouterr().out)}
|
||||
assert by["config"]["status"] == "FAIL" and "STACK" in by["config"]["detail"]
|
||||
|
||||
|
||||
def test_ldap_insecure_is_a_warning(
|
||||
dev_visual: None, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
monkeypatch.setenv("LDAP_ALLOW_INSECURE", "true")
|
||||
assert cli.main(["doctor", "--json", "--skip", ALL]) == 0
|
||||
out = json.loads(capsys.readouterr().out)
|
||||
assert any(c["status"] == "WARN" and "LDAP_ALLOW_INSECURE" in c["detail"] for c in out)
|
||||
|
||||
|
||||
def test_unknown_skip_value() -> None:
|
||||
with pytest.raises(SystemExit):
|
||||
doctor.parse_skip("visual,bogus")
|
||||
|
||||
|
||||
def test_volume_checks(tmp_path: Path) -> None:
|
||||
(tmp_path / "assets").mkdir()
|
||||
checks = doctor.check_volume(make_settings(tmp_path))
|
||||
assert checks[0].status in ("PASS", "WARN") and checks[1].status == "SKIP"
|
||||
(tmp_path / "bk").mkdir()
|
||||
checks = doctor.check_volume(make_settings(tmp_path, backup_dir=tmp_path / "bk"))
|
||||
assert checks[1].status == "PASS"
|
||||
checks = doctor.check_volume(make_settings(tmp_path, assets_dir=tmp_path / "missing"))
|
||||
assert checks[0].status == "FAIL"
|
||||
|
||||
|
||||
def test_tz() -> None:
|
||||
assert doctor.check_tz("America/Denver").status == "PASS"
|
||||
assert doctor.check_tz("Mars/Olympus").status == "FAIL"
|
||||
|
||||
|
||||
def test_ldap_plain_url_refused(tmp_path: Path) -> None:
|
||||
(tmp_path / "ldap").write_text("x\n")
|
||||
s = make_settings(
|
||||
tmp_path,
|
||||
ldap_url="ldap://dc1.example.test",
|
||||
ldap_bind_dn="CN=svc,DC=example,DC=test",
|
||||
ldap_password_file=tmp_path / "ldap",
|
||||
)
|
||||
[c] = doctor.check_ldap(s)
|
||||
assert c.status == "FAIL" and "without STARTTLS" in c.detail
|
||||
|
||||
|
||||
def test_parse_report() -> None:
|
||||
text = "[PASS] Settings dev / x\n[FAIL] Connect timeout\n -> check the VPN\n\n1 failed, 0 warnings"
|
||||
checks = doctor.parse_report("visual", text)
|
||||
assert [(c.name, c.status) for c in checks] == [("visual: Settings", "PASS"), ("visual: Connect", "FAIL")]
|
||||
assert checks[1].hint == "check the VPN"
|
||||
|
||||
|
||||
@needs_pg
|
||||
def test_pg_doctor_db_check(pg_settings: Settings) -> None:
|
||||
checks = doctor.check_db(pg_settings)
|
||||
assert [c.status for c in checks] == ["PASS"] * 4, [(c.name, c.detail) for c in checks]
|
||||
@@ -0,0 +1,60 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from pshelpers import fake_visual, make_settings
|
||||
from visualkit.config import Settings as VisualSettings
|
||||
|
||||
from packetstation import guard
|
||||
from packetstation.main import create_app
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stack", ["test", "ci", "dev"])
|
||||
def test_non_prod_stack_refuses_production_target(tmp_path: Path, stack: str) -> None:
|
||||
s = make_settings(tmp_path, stack=stack)
|
||||
with pytest.raises(guard.StackGuardError, match="production"):
|
||||
guard.check_stack(s, fake_visual(production=True, explicit=True))
|
||||
|
||||
|
||||
def test_non_prod_stack_with_dev_target_ok(tmp_path: Path) -> None:
|
||||
r = guard.check_stack(make_settings(tmp_path, stack="test"), fake_visual(False))
|
||||
assert not r.production and r.warnings == []
|
||||
|
||||
|
||||
def test_prod_stack_refuses_implicit_dev(tmp_path: Path) -> None:
|
||||
with pytest.raises(guard.StackGuardError, match="default to DEV"):
|
||||
guard.check_stack(make_settings(tmp_path, stack="prod"), fake_visual(False, explicit=False))
|
||||
|
||||
|
||||
def test_prod_stack_explicit_dev_warns(tmp_path: Path) -> None:
|
||||
r = guard.check_stack(make_settings(tmp_path, stack="prod"), fake_visual(False, explicit=True))
|
||||
assert r.warnings and "non-production" in r.warnings[0]
|
||||
|
||||
|
||||
def test_prod_stack_production_ok(tmp_path: Path) -> None:
|
||||
r = guard.check_stack(make_settings(tmp_path, stack="prod"), fake_visual(True, explicit=True))
|
||||
assert r.production and r.warnings == []
|
||||
|
||||
|
||||
def test_with_real_visualkit_settings(tmp_path: Path) -> None:
|
||||
# No connection is made: only VisualKit's settings object is built.
|
||||
prod = VisualSettings(_env_file=None, profile="prod")
|
||||
dev_default = VisualSettings(_env_file=None)
|
||||
assert prod.is_production and not dev_default.is_production
|
||||
with pytest.raises(guard.StackGuardError):
|
||||
guard.check_stack(make_settings(tmp_path, stack="ci"), prod)
|
||||
with pytest.raises(guard.StackGuardError):
|
||||
guard.check_stack(make_settings(tmp_path, stack="prod"), dev_default)
|
||||
assert guard.check_stack(make_settings(tmp_path, stack="prod"), prod).production
|
||||
|
||||
|
||||
def test_ldap_insecure_warns_off_prod(tmp_path: Path) -> None:
|
||||
r = guard.check_stack(make_settings(tmp_path, stack="test", ldap_allow_insecure=True), fake_visual(False))
|
||||
assert any("LDAP_ALLOW_INSECURE" in w for w in r.warnings)
|
||||
|
||||
|
||||
def test_app_refuses_to_start(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
monkeypatch.setattr(guard, "load_visual_settings", lambda: fake_visual(True, explicit=True))
|
||||
with pytest.raises(guard.StackGuardError):
|
||||
create_app(make_settings(tmp_path, stack="ci"))
|
||||
@@ -0,0 +1,188 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from pshelpers import needs_pg
|
||||
|
||||
from packetstation import db, migrate
|
||||
from packetstation.settings import Settings
|
||||
|
||||
|
||||
class FakeResult:
|
||||
def __init__(self, rows: list[tuple[Any, ...]] | None = None):
|
||||
self.rows = rows or []
|
||||
|
||||
def fetchall(self) -> list[tuple[Any, ...]]:
|
||||
return self.rows
|
||||
|
||||
def fetchone(self) -> tuple[Any, ...] | None:
|
||||
return self.rows[0] if self.rows else None
|
||||
|
||||
|
||||
class FakeConn:
|
||||
"""Records statements; keeps schema_migrations in a dict; rolls back on error."""
|
||||
|
||||
def __init__(self, fail_on: str | None = None):
|
||||
self.table: dict[str, str] = {}
|
||||
self.executed: list[str] = []
|
||||
self.fail_on = fail_on
|
||||
self._pending: dict[str, str] | None = None
|
||||
|
||||
def execute(self, query: Any, params: Any = None) -> FakeResult:
|
||||
q = str(query)
|
||||
self.executed.append(q)
|
||||
if self.fail_on and self.fail_on in q:
|
||||
raise RuntimeError("boom")
|
||||
if q.startswith("SELECT name, sha256 FROM schema_migrations"):
|
||||
return FakeResult(sorted(self.table.items()))
|
||||
if q.startswith("INSERT INTO schema_migrations"):
|
||||
target = self._pending if self._pending is not None else self.table
|
||||
target[params[0]] = params[1]
|
||||
return FakeResult()
|
||||
|
||||
@contextlib.contextmanager
|
||||
def transaction(self) -> Iterator[None]:
|
||||
self._pending = {}
|
||||
try:
|
||||
yield
|
||||
except Exception:
|
||||
self._pending = None
|
||||
raise
|
||||
self.table.update(self._pending)
|
||||
self._pending = None
|
||||
|
||||
|
||||
def write(d: Path, name: str, sql: str) -> None:
|
||||
(d / name).write_text(sql, encoding="utf-8")
|
||||
|
||||
|
||||
def test_applies_in_order_and_records_hashes(tmp_path: Path) -> None:
|
||||
write(tmp_path, "0002_two.sql", "-- two")
|
||||
write(tmp_path, "0001_one.sql", "-- one")
|
||||
write(tmp_path, "0010_ten.sql", "-- ten")
|
||||
conn = FakeConn()
|
||||
assert migrate.run(conn, tmp_path) == ["0001_one.sql", "0002_two.sql", "0010_ten.sql"]
|
||||
bodies = [q for q in conn.executed if q.startswith("-- ")]
|
||||
assert bodies == ["-- one", "-- two", "-- ten"]
|
||||
assert conn.table["0001_one.sql"] == migrate.sha256_file(tmp_path / "0001_one.sql")
|
||||
assert "CREATE TABLE IF NOT EXISTS schema_migrations" in conn.executed[1]
|
||||
assert migrate.run(conn, tmp_path) == []
|
||||
|
||||
|
||||
def test_refuses_changed_file(tmp_path: Path) -> None:
|
||||
write(tmp_path, "0001_one.sql", "-- one")
|
||||
conn = FakeConn()
|
||||
migrate.run(conn, tmp_path)
|
||||
write(tmp_path, "0001_one.sql", "-- one, edited")
|
||||
write(tmp_path, "0002_two.sql", "-- two")
|
||||
with pytest.raises(migrate.MigrationError, match="changed after it was applied"):
|
||||
migrate.run(conn, tmp_path)
|
||||
assert "0002_two.sql" not in conn.table
|
||||
assert "SELECT pg_advisory_unlock(%s)" in conn.executed[-1]
|
||||
|
||||
|
||||
def test_refuses_missing_and_out_of_order(tmp_path: Path) -> None:
|
||||
write(tmp_path, "0002_two.sql", "-- two")
|
||||
conn = FakeConn()
|
||||
migrate.run(conn, tmp_path)
|
||||
write(tmp_path, "0001_one.sql", "-- late")
|
||||
with pytest.raises(migrate.MigrationError, match="sorts before"):
|
||||
migrate.run(conn, tmp_path)
|
||||
(tmp_path / "0002_two.sql").unlink()
|
||||
with pytest.raises(migrate.MigrationError, match="missing"):
|
||||
migrate.run(conn, tmp_path)
|
||||
|
||||
|
||||
def test_failed_migration_not_recorded(tmp_path: Path) -> None:
|
||||
write(tmp_path, "0001_one.sql", "-- one")
|
||||
write(tmp_path, "0002_bad.sql", "-- bad")
|
||||
conn = FakeConn(fail_on="-- bad")
|
||||
with pytest.raises(RuntimeError):
|
||||
migrate.run(conn, tmp_path)
|
||||
assert list(conn.table) == ["0001_one.sql"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["1_x.sql", "0001-x.sql", "0001_X.sql"])
|
||||
def test_bad_names(tmp_path: Path, name: str) -> None:
|
||||
write(tmp_path, name, "")
|
||||
with pytest.raises(migrate.MigrationError, match="bad migration file name"):
|
||||
migrate.discover(tmp_path)
|
||||
|
||||
|
||||
def test_duplicate_numbers(tmp_path: Path) -> None:
|
||||
write(tmp_path, "0001_a.sql", "")
|
||||
write(tmp_path, "0001_b.sql", "")
|
||||
with pytest.raises(migrate.MigrationError, match="two migrations"):
|
||||
migrate.discover(tmp_path)
|
||||
|
||||
|
||||
def test_status(tmp_path: Path) -> None:
|
||||
write(tmp_path, "0001_one.sql", "-- one")
|
||||
conn = FakeConn()
|
||||
assert migrate.status(conn, tmp_path)[0] is False
|
||||
migrate.run(conn, tmp_path)
|
||||
assert migrate.status(conn, tmp_path) == (True, "at head (0001_one.sql)")
|
||||
|
||||
|
||||
def test_shipped_migrations_are_well_formed() -> None:
|
||||
files = migrate.discover(db.MIGRATIONS_DIR)
|
||||
assert files[0].name == "0001_base.sql"
|
||||
sql = files[0].sql()
|
||||
assert "schema_migrations (" not in sql # the runner creates it
|
||||
revoke = sql.index("REVOKE UPDATE, DELETE, TRUNCATE ON audit_events FROM ps_app")
|
||||
assert sql.index("ALTER DEFAULT PRIVILEGES") < sql.index("CREATE TABLE audit_events") < revoke
|
||||
|
||||
|
||||
def test_bootstrap_refused_on_prod(tmp_path: Path) -> None:
|
||||
s = Settings(_env_file=None, stack="prod", pg_superuser_url="postgresql://x@y/z")
|
||||
with pytest.raises(migrate.MigrationError, match="refused"):
|
||||
migrate.bootstrap_roles(s)
|
||||
with pytest.raises(migrate.MigrationError, match="PG_SUPERUSER_URL"):
|
||||
migrate.bootstrap_roles(Settings(_env_file=None, stack="dev"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- real PostgreSQL
|
||||
|
||||
|
||||
@needs_pg
|
||||
def test_pg_migrations_recorded(pg_settings: Settings) -> None:
|
||||
with db.connect_as("migrate", pg_settings, autocommit=True) as conn:
|
||||
assert migrate.run(conn, db.MIGRATIONS_DIR) == []
|
||||
rows = conn.execute("SELECT name, sha256 FROM schema_migrations ORDER BY name").fetchall()
|
||||
assert rows[0] == ("0001_base.sql", migrate.sha256_file(db.MIGRATIONS_DIR / "0001_base.sql"))
|
||||
|
||||
|
||||
@needs_pg
|
||||
def test_pg_refuses_changed_file(pg_settings: Settings, tmp_path: Path) -> None:
|
||||
d = tmp_path / "m"
|
||||
d.mkdir()
|
||||
write(d, "0001_base.sql", (db.MIGRATIONS_DIR / "0001_base.sql").read_text() + "\n-- edited\n")
|
||||
with (
|
||||
db.connect_as("migrate", pg_settings, autocommit=True) as conn,
|
||||
pytest.raises(migrate.MigrationError, match="changed"),
|
||||
):
|
||||
migrate.run(conn, d)
|
||||
|
||||
|
||||
@needs_pg
|
||||
def test_pg_app_cannot_update_or_delete_audit(pg_settings: Settings) -> None:
|
||||
import psycopg
|
||||
|
||||
from packetstation import audit
|
||||
|
||||
with db.connect_as("app", pg_settings, autocommit=True) as conn:
|
||||
audit.append(conn, action="test.event", detail={"a": 1})
|
||||
for stmt in ("UPDATE audit_events SET action = 'x'", "DELETE FROM audit_events", "TRUNCATE audit_events"):
|
||||
with pytest.raises(psycopg.errors.InsufficientPrivilege):
|
||||
conn.execute(stmt)
|
||||
conn.execute("SELECT key FROM settings").fetchall()
|
||||
conn.execute("INSERT INTO settings (key, value) VALUES ('k', '1')")
|
||||
with (
|
||||
db.connect_as("migrate", pg_settings, autocommit=True) as conn,
|
||||
pytest.raises(psycopg.errors.InsufficientPrivilege),
|
||||
):
|
||||
conn.execute("UPDATE audit_events SET action = 'x'") # trigger refuses even the owner
|
||||
@@ -0,0 +1,83 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from packetstation.proxy import TrustedProxies, TrustedProxyMiddleware, forwarded
|
||||
|
||||
PROXY = "172.18.0.5"
|
||||
|
||||
|
||||
def trusted(addresses: set[str] | None = None) -> TrustedProxies:
|
||||
t = TrustedProxies("npm_app_1", resolver=lambda _h: set(addresses or {PROXY}))
|
||||
t.refresh()
|
||||
return t
|
||||
|
||||
|
||||
def H(**kw: str) -> list[tuple[bytes, bytes]]:
|
||||
return [(k.replace("_", "-").encode(), v.encode()) for k, v in kw.items()]
|
||||
|
||||
|
||||
def test_trusted_peer_uses_first_xff_and_proto() -> None:
|
||||
ip, scheme = forwarded(PROXY, H(x_forwarded_for="10.1.2.3, 172.18.0.9", x_forwarded_proto="https"), trusted())
|
||||
assert ip == "10.1.2.3" and scheme == "https"
|
||||
|
||||
|
||||
def test_untrusted_peer_ignores_headers() -> None:
|
||||
ip, scheme = forwarded("10.9.9.9", H(x_forwarded_for="1.2.3.4", x_forwarded_proto="https"), trusted())
|
||||
assert ip == "10.9.9.9" and scheme is None
|
||||
|
||||
|
||||
def test_empty_host_trusts_nothing() -> None:
|
||||
t = TrustedProxies("")
|
||||
t.refresh()
|
||||
assert forwarded(PROXY, H(x_forwarded_for="1.2.3.4"), t) == (PROXY, None)
|
||||
|
||||
|
||||
def test_garbage_xff_and_proto_ignored() -> None:
|
||||
ip, scheme = forwarded(PROXY, H(x_forwarded_for="not-an-ip", x_forwarded_proto="gopher"), trusted())
|
||||
assert ip == PROXY and scheme is None
|
||||
|
||||
|
||||
def test_resolution_failure_keeps_last_good_set() -> None:
|
||||
calls = {"n": 0}
|
||||
|
||||
def resolver(_h: str) -> set[str]:
|
||||
calls["n"] += 1
|
||||
if calls["n"] > 1:
|
||||
raise OSError("no such host")
|
||||
return {PROXY}
|
||||
|
||||
t = TrustedProxies("npm_app_1", resolver=resolver)
|
||||
t.refresh()
|
||||
t.refresh()
|
||||
assert t.addresses == frozenset({PROXY})
|
||||
|
||||
|
||||
def test_re_resolve_picks_up_new_address() -> None:
|
||||
addrs = [{PROXY}, {"172.18.0.77"}]
|
||||
t = TrustedProxies("npm_app_1", resolver=lambda _h: addrs.pop(0))
|
||||
t.refresh()
|
||||
t.refresh()
|
||||
assert t.trusts("172.18.0.77") and not t.trusts(PROXY)
|
||||
|
||||
|
||||
def _run_mw(peer: str, headers: list[tuple[bytes, bytes]], t: TrustedProxies) -> dict[str, Any]:
|
||||
seen: dict[str, Any] = {}
|
||||
|
||||
async def app(scope: Any, receive: Any, send: Any) -> None:
|
||||
seen.update(scope)
|
||||
|
||||
scope = {"type": "http", "client": (peer, 5555), "scheme": "http", "headers": headers, "path": "/"}
|
||||
asyncio.run(TrustedProxyMiddleware(app, t)(scope, None, None)) # type: ignore[arg-type]
|
||||
return seen
|
||||
|
||||
|
||||
def test_middleware_rewrites_for_trusted_peer() -> None:
|
||||
s = _run_mw(PROXY, H(x_forwarded_for="10.1.2.3", x_forwarded_proto="https"), trusted())
|
||||
assert s["client"] == ("10.1.2.3", 5555) and s["scheme"] == "https"
|
||||
|
||||
|
||||
def test_middleware_leaves_untrusted_peer() -> None:
|
||||
s = _run_mw("10.5.5.5", H(x_forwarded_for="10.1.2.3", x_forwarded_proto="https"), trusted())
|
||||
assert s["client"] == ("10.5.5.5", 5555) and s["scheme"] == "http"
|
||||
@@ -0,0 +1,123 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from packetstation import logs
|
||||
from packetstation.settings import SecretFileError, Settings, read_secret
|
||||
|
||||
|
||||
def test_defaults_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("STACK", "TEST")
|
||||
monkeypatch.setenv("PG_PORT", "5433")
|
||||
monkeypatch.setenv("LDAP_URL", "ldaps://dc1.pesco.com ldaps://dc2.pesco.com")
|
||||
monkeypatch.setenv("LDAP_CA_BUNDLE", "")
|
||||
s = Settings()
|
||||
assert s.stack == "test"
|
||||
assert s.pg_port == 5433
|
||||
assert s.site_tz == "America/Denver"
|
||||
assert s.pg_app_user == "ps_app" and s.pg_migrate_user == "ps_migrate"
|
||||
assert s.pg_app_password_file == Path("/run/secrets/postgres_app_password")
|
||||
assert s.ldap_urls == ["ldaps://dc1.pesco.com", "ldaps://dc2.pesco.com"]
|
||||
assert s.ldap_ca_bundle is None
|
||||
assert s.ldap_user_filter == "(|(sAMAccountName={0})(userPrincipalName={0}))"
|
||||
assert s.smtp_starttls is True and s.notify_dry_run is False
|
||||
assert s.ps_build == "dev"
|
||||
|
||||
|
||||
def test_stack_required(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("STACK")
|
||||
with pytest.raises(ValidationError):
|
||||
Settings()
|
||||
|
||||
|
||||
def test_stack_rejects_unknown(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("STACK", "staging")
|
||||
with pytest.raises(ValidationError):
|
||||
Settings()
|
||||
|
||||
|
||||
def test_ps_env_file(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
env = tmp_path / "dev.env"
|
||||
env.write_text("STACK=dev\nSITE_ID=TESTSITE\n")
|
||||
monkeypatch.delenv("STACK")
|
||||
monkeypatch.setenv("PS_ENV_FILE", str(env))
|
||||
s = Settings()
|
||||
assert s.stack == "dev" and s.site_id == "TESTSITE"
|
||||
|
||||
|
||||
def test_read_secret_strips_crlf(tmp_path: Path) -> None:
|
||||
f = tmp_path / "pw"
|
||||
f.write_bytes(b"s3cr3t value\r\n\r\n")
|
||||
assert read_secret(f) == "s3cr3t value"
|
||||
|
||||
|
||||
def test_read_secret_keeps_inner_whitespace(tmp_path: Path) -> None:
|
||||
f = tmp_path / "pw"
|
||||
f.write_bytes(b" a b \n")
|
||||
assert read_secret(f) == " a b "
|
||||
|
||||
|
||||
def test_read_secret_empty_is_error(tmp_path: Path) -> None:
|
||||
f = tmp_path / "pw"
|
||||
f.write_bytes(b"\r\n")
|
||||
with pytest.raises(SecretFileError, match="empty"):
|
||||
read_secret(f)
|
||||
|
||||
|
||||
def test_read_secret_missing_names_file(tmp_path: Path) -> None:
|
||||
with pytest.raises(SecretFileError, match="nope"):
|
||||
read_secret(tmp_path / "nope", "PG_APP_PASSWORD_FILE")
|
||||
|
||||
|
||||
def test_pg_password_by_role(tmp_path: Path) -> None:
|
||||
(tmp_path / "app").write_text("apppw\n")
|
||||
(tmp_path / "mig").write_text("migpw\n")
|
||||
s = Settings(
|
||||
_env_file=None, stack="ci", pg_app_password_file=tmp_path / "app", pg_migrate_password_file=tmp_path / "mig"
|
||||
)
|
||||
assert s.pg_password("app") == "apppw"
|
||||
assert s.pg_password("migrate") == "migpw"
|
||||
|
||||
|
||||
def test_db_connect_kwargs(tmp_path: Path) -> None:
|
||||
from packetstation import db
|
||||
|
||||
(tmp_path / "app").write_text("apppw\n")
|
||||
s = Settings(_env_file=None, stack="ci", pg_app_password_file=tmp_path / "app")
|
||||
k = db.connect_kwargs("worker", s)
|
||||
assert k["user"] == "ps_app" and k["application_name"] == "ps-worker" and k["password"] == "apppw"
|
||||
assert db.connect_kwargs("app", s)["application_name"] == "ps-app"
|
||||
|
||||
|
||||
def test_ldap_insecure_refused_on_prod() -> None:
|
||||
with pytest.raises(ValidationError, match="LDAP_ALLOW_INSECURE"):
|
||||
Settings(_env_file=None, stack="prod", ldap_allow_insecure=True)
|
||||
assert Settings(_env_file=None, stack="test", ldap_allow_insecure=True).ldap_allow_insecure
|
||||
|
||||
|
||||
def test_log_level_validated() -> None:
|
||||
assert Settings(_env_file=None, stack="ci", log_level="debug").log_level == "DEBUG"
|
||||
with pytest.raises(ValidationError):
|
||||
Settings(_env_file=None, stack="ci", log_level="loud")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", ["password=hunter2", "PWD=abc;x", "connect secret=xyz now", "user=a Password=p@ss"])
|
||||
def test_redaction(text: str) -> None:
|
||||
out = logs.redact(text)
|
||||
for word in ("hunter2", "abc;x", "xyz", "p@ss"):
|
||||
assert word not in out
|
||||
assert "***" in out
|
||||
|
||||
|
||||
def test_log_lines_are_redacted_json(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
import json
|
||||
import logging
|
||||
|
||||
logs.setup("INFO")
|
||||
logging.getLogger("t").info("dsn host=x password=%s", "topsecret")
|
||||
line = capsys.readouterr().out.strip().splitlines()[-1]
|
||||
rec = json.loads(line)
|
||||
assert rec["level"] == "INFO" and "topsecret" not in line and "password=***" in rec["msg"]
|
||||
Reference in New Issue
Block a user