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]>
189 lines
7.0 KiB
Python
189 lines
7.0 KiB
Python
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
|