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:
2026-10-15 23:08:47 -06:00
co-authored by Claude Fable 5.1
commit 0b663bb2b2
91 changed files with 11307 additions and 0 deletions
+42
View File
@@ -0,0 +1,42 @@
"""Shared test setup: a clean environment with STACK=ci and no .env files picked up."""
from __future__ import annotations
import os
from collections.abc import Iterator
import pytest
_PREFIXES = ("PS_", "PG_", "LDAP_", "SMTP_", "VISUAL_", "MFILES_")
_NAMES = {
"STACK",
"SITE_ID",
"SITE_TZ",
"PUBLIC_URL",
"TRUSTED_PROXY_HOST",
"ASSETS_DIR",
"BACKUP_DIR",
"SESSION_KEY_FILE",
"SEAL_KEY_FILE",
"STATIC_DIR",
"LOG_LEVEL",
"NOTIFY_DRY_RUN",
"WORKER_BEAT_FILE",
}
@pytest.fixture(autouse=True)
def clean_env(monkeypatch: pytest.MonkeyPatch, tmp_path_factory: pytest.TempPathFactory) -> Iterator[None]:
for key in list(os.environ):
if key.upper() in _NAMES or key.upper().startswith(_PREFIXES):
monkeypatch.delenv(key, raising=False)
nowhere = tmp_path_factory.getbasetemp() / "no-such.env"
monkeypatch.setenv("VISUAL_ENV_FILE", str(nowhere))
monkeypatch.setenv("MFILES_ENV_FILE", str(nowhere))
monkeypatch.setenv("STACK", "ci")
monkeypatch.setenv("TRUSTED_PROXY_HOST", "")
from packetstation.settings import clear_settings_cache
clear_settings_cache()
yield
clear_settings_cache()
+57
View File
@@ -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)))
+39
View File
@@ -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)")
+117
View File
@@ -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
+67
View File
@@ -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
+53
View File
@@ -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
+108
View File
@@ -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]
+60
View File
@@ -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"))
+188
View File
@@ -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
+83
View File
@@ -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"
+123
View File
@@ -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"]