diff --git a/alembic/versions/0006_append_only_audit.py b/alembic/versions/0006_append_only_audit.py
new file mode 100644
index 0000000..5b2f10b
--- /dev/null
+++ b/alembic/versions/0006_append_only_audit.py
@@ -0,0 +1,109 @@
+"""Append-only, hash-chained audit_log (M3a, #107; BR-24, BR-35).
+
+Decided 28 Oct: upgrade the existing per-tenant ``audit_log`` rather than add
+an ``audit_events`` table.
+
+- New columns: ``actor``, ``tenant_schema``, ``before_value``, ``after_value``,
+ ``prev_hash``, ``row_hash``.
+- A BEFORE INSERT trigger serialises writers with an advisory lock, gives the
+ row its id under that lock (so id order is chain order), and sets
+ ``prev_hash`` to the previous row's ``row_hash``.
+- Triggers reject UPDATE, DELETE and TRUNCATE for every role, owner included.
+ The owner can still disable a trigger; the hash chain is what exposes that,
+ and M3d adds a runtime role that can't.
+- Rows written before this revision are chained in id order during upgrade.
+
+Runs per tenant, like 0003.
+
+Revision ID: 0006
+Revises: 0005
+Create Date: 2026-10-28
+"""
+
+from alembic import op
+
+from api.config import settings
+
+revision = "0006"
+down_revision = "0005"
+branch_labels = None
+depends_on = None
+
+
+def _schema() -> str:
+ return op.get_bind().dialect.identifier_preparer.quote(settings.UPLOADS_SCHEMA)
+
+
+def upgrade() -> None:
+ s = _schema()
+ t = f"{s}.audit_log"
+ op.execute(f"""ALTER TABLE {t}
+ ADD COLUMN actor TEXT,
+ ADD COLUMN tenant_schema TEXT,
+ ADD COLUMN before_value JSONB,
+ ADD COLUMN after_value JSONB,
+ ADD COLUMN prev_hash TEXT,
+ ADD COLUMN row_hash TEXT""")
+ # The hash covers every column except row_hash. Times are hashed in UTC so
+ # the result doesn't depend on the session's TimeZone.
+ op.execute(f"""
+ CREATE FUNCTION {s}.audit_row_hash(prev TEXT, r {t}) RETURNS TEXT
+ LANGUAGE sql IMMUTABLE AS $$
+ SELECT encode(sha256(convert_to(coalesce(prev, '') || jsonb_build_object(
+ 'id', r.id, 'action', r.action, 'file_id', r.file_id, 'file_name', r.file_name,
+ 'table_name', r.table_name, 'mode', r.mode,
+ 'performed_at', to_char(r.performed_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US'),
+ 'actor', r.actor, 'tenant_schema', r.tenant_schema,
+ 'before_value', r.before_value, 'after_value', r.after_value)::text, 'UTF8')), 'hex')
+ $$""")
+ # Chain the rows that already exist, oldest first.
+ op.execute(f"""DO $$
+ DECLARE r RECORD; prev TEXT := NULL;
+ BEGIN
+ FOR r IN SELECT * FROM {t} ORDER BY id LOOP
+ UPDATE {t} SET tenant_schema = TG_SCHEMA_PLACEHOLDER, prev_hash = prev
+ WHERE id = r.id;
+ SELECT {s}.audit_row_hash(prev, a) INTO prev FROM {t} a WHERE a.id = r.id;
+ UPDATE {t} SET row_hash = prev WHERE id = r.id;
+ END LOOP;
+ END $$""".replace("TG_SCHEMA_PLACEHOLDER", f"'{settings.UPLOADS_SCHEMA}'"))
+ op.execute(f"""
+ CREATE FUNCTION {s}.audit_chain() RETURNS trigger LANGUAGE plpgsql AS $$
+ BEGIN
+ PERFORM pg_advisory_xact_lock(hashtext(TG_TABLE_SCHEMA || '.audit_log'));
+ NEW.id := nextval(pg_get_serial_sequence(quote_ident(TG_TABLE_SCHEMA) || '.audit_log', 'id'));
+ NEW.tenant_schema := TG_TABLE_SCHEMA;
+ EXECUTE format('SELECT row_hash FROM %I.audit_log ORDER BY id DESC LIMIT 1', TG_TABLE_SCHEMA)
+ INTO NEW.prev_hash;
+ NEW.row_hash := {s}.audit_row_hash(NEW.prev_hash, NEW);
+ RETURN NEW;
+ END $$""")
+ op.execute(f"""
+ CREATE FUNCTION {s}.audit_reject_change() RETURNS trigger LANGUAGE plpgsql AS $$
+ BEGIN
+ RAISE EXCEPTION 'audit_log is append-only: % is not allowed', TG_OP
+ USING ERRCODE = 'insufficient_privilege';
+ END $$""")
+ # The trigger takes the id under its lock; the column default would take a second one.
+ op.execute(f"ALTER TABLE {t} ALTER COLUMN id DROP DEFAULT")
+ op.execute(f"CREATE TRIGGER audit_chain BEFORE INSERT ON {t} "
+ f"FOR EACH ROW EXECUTE FUNCTION {s}.audit_chain()")
+ op.execute(f"CREATE TRIGGER audit_no_change BEFORE UPDATE OR DELETE ON {t} "
+ f"FOR EACH ROW EXECUTE FUNCTION {s}.audit_reject_change()")
+ op.execute(f"CREATE TRIGGER audit_no_truncate BEFORE TRUNCATE ON {t} "
+ f"FOR EACH STATEMENT EXECUTE FUNCTION {s}.audit_reject_change()")
+
+
+def downgrade() -> None:
+ s = _schema()
+ t = f"{s}.audit_log"
+ for trig in ("audit_no_truncate", "audit_no_change", "audit_chain"):
+ op.execute(f"DROP TRIGGER IF EXISTS {trig} ON {t}")
+ seq = f"pg_get_serial_sequence('{t}', 'id')"
+ op.execute(f"ALTER TABLE {t} ALTER COLUMN id SET DEFAULT nextval({seq}::regclass)")
+ op.execute(f"DROP FUNCTION IF EXISTS {s}.audit_reject_change()")
+ op.execute(f"DROP FUNCTION IF EXISTS {s}.audit_chain()")
+ op.execute(f"DROP FUNCTION IF EXISTS {s}.audit_row_hash(TEXT, {t})")
+ op.execute(f"""ALTER TABLE {t}
+ DROP COLUMN actor, DROP COLUMN tenant_schema, DROP COLUMN before_value,
+ DROP COLUMN after_value, DROP COLUMN prev_hash, DROP COLUMN row_hash""")
diff --git a/api/auth.py b/api/auth.py
index fcb084c..b424cee 100644
--- a/api/auth.py
+++ b/api/auth.py
@@ -20,6 +20,7 @@
from api.config import settings
from api.db import Conn
from api.services.accounts import session_for
+from api.services.audit import acting_as
from api.tenancy import schema_for, scoped
@@ -68,10 +69,11 @@ async def tenant_scope(authorization: str = Header(default=""),
"""
if not authorization:
require_api_key(x_api_key)
- yield None
+ with acting_as("api_key"):
+ yield None
return
session = await run_in_threadpool(_live_session, bearer_token(authorization))
- with scoped(schema_for(session["tenant"])):
+ with scoped(schema_for(session["tenant"])), acting_as(session["username"]):
yield session
diff --git a/api/routers/audit_routes.py b/api/routers/audit_routes.py
index 3650304..49e7d6c 100644
--- a/api/routers/audit_routes.py
+++ b/api/routers/audit_routes.py
@@ -15,6 +15,7 @@
from api.auth import allow, tenant_scope
from api.tenancy import uploads_schema
from api.db import Conn
+from api.services.audit import verify_chain
router = APIRouter(
prefix="/api/audit",
@@ -37,7 +38,7 @@ def audit_log(limit: int = 50, offset: int = 0) -> dict:
total = cur.fetchone()[0]
cur.execute(
sql.SQL(
- "SELECT id, action, file_id, file_name, table_name, mode, performed_at "
+ "SELECT id, action, file_id, file_name, table_name, mode, performed_at, actor "
"FROM {}.audit_log ORDER BY performed_at DESC LIMIT %s OFFSET %s"
).format(sql.Identifier(uploads_schema())),
(limit, offset),
@@ -51,7 +52,17 @@ def audit_log(limit: int = 50, offset: int = 0) -> dict:
"tableName": r[4],
"mode": r[5],
"performedAt": r[6].isoformat(),
+ "actor": r[7],
}
for r in cur.fetchall()
]
return {"total": total, "limit": limit, "offset": offset, "entries": entries}
+
+
+@router.get("/verify", dependencies=[Depends(allow("read_audit"))])
+def audit_verify() -> dict:
+ """Recompute this tenant's audit hash chain (M3a, #107). ``intact`` is false
+ if any row was changed, removed or inserted out of order."""
+ with Conn() as conn:
+ with conn.cursor() as cur:
+ return verify_chain(cur)
diff --git a/api/routers/auth_routes.py b/api/routers/auth_routes.py
index baf4d14..f829f27 100644
--- a/api/routers/auth_routes.py
+++ b/api/routers/auth_routes.py
@@ -14,7 +14,7 @@
from api.db import Conn
from api import oidc
from api.services import accounts
-from api.services.audit import write_audit
+from api.services.audit import acting_as, write_audit
from api.tenancy import schema_for, scoped
router = APIRouter(prefix="/api/auth", tags=["auth"])
@@ -114,10 +114,9 @@ def reset_password(username: str, req: ResetPasswordRequest,
with conn.cursor() as cur:
result = accounts.reset_password(cur, session["tenantId"], username, req.newPassword)
if result == "ok":
- # ponytail: audit_log has file columns only; target in file_name and actor
- # in mode until M3's audit_events (#107) adds actor and object.
- with scoped(schema_for(session["tenant"])):
- write_audit(cur, "password_reset", None, username, None, f"by {session['username']}")
+ # The target user goes in file_name, the feed's object column (M3a, #107).
+ with scoped(schema_for(session["tenant"])), acting_as(session["username"]):
+ write_audit(cur, "password_reset", None, username, None, None)
conn.commit()
if result == "missing":
raise HTTPException(status_code=404, detail="No such user in this tenant")
diff --git a/api/services/audit.py b/api/services/audit.py
index 5b35d82..0910280 100644
--- a/api/services/audit.py
+++ b/api/services/audit.py
@@ -24,7 +24,10 @@
from __future__ import annotations
+import json
import logging
+from contextlib import contextmanager
+from contextvars import ContextVar
from psycopg2 import sql
@@ -37,26 +40,58 @@
# than an unqueryable string in the table.
VALID_ACTIONS = frozenset({"upload", "delete", "password_reset"})
+# Who is acting (M3a, #107). tenant_scope sets it per request: the signed-in
+# username, or "api_key" for a request made with the shared key.
+_actor: ContextVar[str | None] = ContextVar("audit_actor", default=None)
+
+
+@contextmanager
+def acting_as(actor: str):
+ token = _actor.set(actor)
+ try:
+ yield
+ finally:
+ _actor.reset(token)
+
def write_audit(cur, action: str, file_id: int | None, file_name: str | None,
- table_name: str | None, mode: str | None) -> None:
+ table_name: str | None, mode: str | None,
+ before: dict | None = None, after: dict | None = None) -> None:
"""Insert one audit row using the caller's cursor.
Use this when you already hold a transaction and want the audit row to
commit atomically with the change it describes. The caller commits.
+ The table's trigger adds the tenant schema and the hash chain (0006).
"""
if action not in VALID_ACTIONS:
raise ValueError(f"Unknown audit action {action!r}; expected one of {sorted(VALID_ACTIONS)}")
cur.execute(
sql.SQL(
"INSERT INTO {}.audit_log "
- "(action, file_id, file_name, table_name, mode) "
- "VALUES (%s, %s, %s, %s, %s)"
+ "(action, file_id, file_name, table_name, mode, actor, before_value, after_value) "
+ "VALUES (%s, %s, %s, %s, %s, %s, %s, %s)"
).format(sql.Identifier(uploads_schema())),
- (action, file_id, file_name, table_name, mode),
+ (action, file_id, file_name, table_name, mode, _actor.get(),
+ None if before is None else json.dumps(before),
+ None if after is None else json.dumps(after)),
)
+def verify_chain(cur) -> dict:
+ """Recompute the audit hash chain (M3a). Returns the row count and the
+ first row whose link or hash doesn't match, or None if the chain is intact."""
+ schema = sql.Identifier(uploads_schema())
+ cur.execute(sql.SQL(
+ "SELECT count(*), min(id) FILTER (WHERE prev_hash IS DISTINCT FROM expected_prev "
+ " OR row_hash IS DISTINCT FROM {s}.audit_row_hash(prev_hash, r)) "
+ "FROM (SELECT a AS r, a.id, a.prev_hash, a.row_hash, "
+ " lag(a.row_hash) OVER (ORDER BY a.id) AS expected_prev "
+ " FROM {s}.audit_log a) t"
+ ).format(s=schema))
+ rows, first_bad = cur.fetchone()
+ return {"rows": rows, "intact": first_bad is None, "firstBadId": first_bad}
+
+
def record_audit(action: str, file_id: int | None, file_name: str | None,
table_name: str | None, mode: str | None) -> bool:
"""Insert one audit row in its own transaction. Never raises.
diff --git a/docs/VCRM.md b/docs/VCRM.md
index 666482e..433fd08 100644
--- a/docs/VCRM.md
+++ b/docs/VCRM.md
@@ -154,7 +154,7 @@ Status as assessed on 2026-10-26 at `main` 032b522. None of these is "Verified"
| BR-32 | ❌ Not started | No code | API + e2e | M6 |
| BR-33 | ⚠️ Partial | Single-transaction load; no staging or lineage | Integration incl. failure injection | M6 |
| BR-34 | ⚠️ Partial | Total, inserted, duplicate and failed counts persisted per import (M1: #90, `tests/test_import_counts.py`); an upload where every row fails is reported as failed and saves nothing, and partial failures are shown on the files list (#123, `tests/test_failed_uploads.py`); over-wide rows reported, not dropped (M1: #88, `tests/test_ragged_rows.py`); excluded and edited rows not yet tracked (M6) | Integration | M1, M6 |
-| BR-35 | ⚠️ Partial | Export formula injection neutralised (M1: #86, `export.test.ts`); audit table still plain, no actor, no filters | DB negative tests + unit | M1, M3, M7 |
+| BR-35 | ⚠️ Partial | Export formula injection neutralised (M1: #86, `export.test.ts`). Audit log append-only (M3a, #107): triggers reject UPDATE, DELETE and TRUNCATE for every role; actor recorded; hash chain checked by `GET /api/audit/verify` (`test_audit_append_only.py`, 7 tests). Still to do: the least-privilege runtime role (M3d) and report filters (M7) | DB negative tests + unit | M1, M3, M7 |
| BR-36 | ⚠️ Partial | Done in M2:
• local accounts with lockout (#98, #103)
• OIDC (#105)
• per-tenant schemas, T&E tables included (#100, #104)
• role matrix in `docs/ROLES.md` (#101)
Tests: `tests/test_accounts.py`, `test_tenant_isolation.py`, `test_roles.py`, `test_oidc.py`, `test_password_reset.py`. Each failed with its control removed.
• frontend sign-in; the browser no longer holds the shared key (#115)
• password change, admin reset and break-glass reset (#117; `tests/test_password_reset.py`)
Open: the shared key still grants non-tenant access to scripts and services; security review (#112); real-IdP OIDC check (#113) | Cross-tenant negative tests; role matrix tests | M2 |
| BR-37 | ⚠️ Partial | No external calls found; no automated check; no offline doc (#60) | Egress-blocked run + offline dry run | M7 |
| BR-38 | ❌ Not started | No container bundle for the application yet (`infra/Dockerfile` is the schema and evals runner only) | Offline smoke test 7/7 on Windows, macOS and Linux | #128 |
diff --git a/tests/test_api_hardening.py b/tests/test_api_hardening.py
index 50e6054..2f4db56 100644
--- a/tests/test_api_hardening.py
+++ b/tests/test_api_hardening.py
@@ -209,7 +209,8 @@ def execute(self, statement, params=None):
captured["params"] = params
write_audit(RecordingCursor(), "upload", 7, "f.csv", "csv_abc", "dynamic")
- self.assertEqual(captured["params"], ("upload", 7, "f.csv", "csv_abc", "dynamic"))
+ self.assertEqual(captured["params"],
+ ("upload", 7, "f.csv", "csv_abc", "dynamic", None, None, None))
def test_file_id_is_coerced_from_the_loaders_string_form(self):
self.assertEqual(coerce_file_id("42"), 42)
diff --git a/tests/test_api_integration.py b/tests/test_api_integration.py
index 13eaabb..88f72d3 100644
--- a/tests/test_api_integration.py
+++ b/tests/test_api_integration.py
@@ -414,6 +414,17 @@ def test_audit_row_fields_match_the_upload_response(self):
self.assertEqual(str(row["fileId"]), str(body["fileId"]))
self.assertTrue(row["performedAt"], "performedAt must not be empty")
+ def test_audit_row_names_the_actor_and_the_chain_verifies(self):
+ """M3a (#107): who acted is recorded, and the hash chain checks out."""
+ up = self.client.post("/api/csv/upload",
+ json={"fileName": self.name, "content": self.content})
+ self.assertEqual(up.json()["status"], "ok", up.text)
+ row = next(e for e in self._entries_for(self.name) if e["action"] == "upload")
+ self.assertEqual(row["actor"], "api_key")
+ check = self.client.get("/api/audit/verify").json()
+ self.assertTrue(check["intact"], check)
+ self.assertGreaterEqual(check["rows"], 1)
+
def test_upload_then_delete_leaves_both_rows(self):
"""The full lifecycle. Before O-04 only the second half was recorded,
so a table could be seen to vanish with no record of its arrival."""
diff --git a/tests/test_audit_append_only.py b/tests/test_audit_append_only.py
new file mode 100644
index 0000000..de7e957
--- /dev/null
+++ b/tests/test_audit_append_only.py
@@ -0,0 +1,146 @@
+"""Append-only, hash-chained audit_log (M3a, #107; BR-24, BR-35).
+
+Integration: needs a live PostgreSQL. Fails (never skips) without one.
+Runs against a throwaway schema migrated with Alembic, never the live one.
+"""
+
+from __future__ import annotations
+
+import threading
+import unittest
+import uuid
+from pathlib import Path
+
+import psycopg2
+import pytest
+from alembic import command
+from alembic.config import Config
+
+from api.config import settings
+from api.services.audit import acting_as, verify_chain, write_audit
+
+_ALEMBIC_INI = str(Path(__file__).resolve().parent.parent / "alembic.ini")
+
+
+def _connect():
+ try:
+ return psycopg2.connect(host=settings.PG_HOST, port=settings.PG_PORT, user=settings.PG_USER,
+ password=settings.PG_PASSWORD, dbname=settings.PG_DATABASE)
+ except psycopg2.OperationalError as exc:
+ raise AssertionError(f"Database unreachable: {exc}") from None
+
+
+@pytest.mark.integration
+class AppendOnlyAudit(unittest.TestCase):
+ def setUp(self):
+ self.original = settings.UPLOADS_SCHEMA
+ settings.UPLOADS_SCHEMA = self.schema = f"audit_test_{uuid.uuid4().hex[:10]}"
+ self.cfg = Config(_ALEMBIC_INI)
+ self.cfg.attributes["scope"] = "tenant" # per-tenant tables only; leave the control schema alone
+ self.conn = _connect()
+
+ def tearDown(self):
+ self.conn.rollback()
+ with self.conn.cursor() as cur:
+ cur.execute(f'DROP SCHEMA IF EXISTS "{self.schema}" CASCADE') # DROP isn't blocked
+ self.conn.commit()
+ self.conn.close()
+ settings.UPLOADS_SCHEMA = self.original
+
+ def _write(self, n: int, actor: str = "alice", conn=None) -> None:
+ conn = conn or self.conn
+ with conn.cursor() as cur, acting_as(actor):
+ for i in range(n):
+ write_audit(cur, "upload", i, f"f{i}.csv", f"t{i}", "dynamic", after={"rows": i})
+ conn.commit()
+
+ def _verify(self) -> dict:
+ with self.conn.cursor() as cur:
+ result = verify_chain(cur)
+ self.conn.commit()
+ return result
+
+ def _sql(self, statement: str) -> None:
+ with self.conn.cursor() as cur:
+ cur.execute(statement.format(t=f'"{self.schema}".audit_log'))
+
+ def test_rows_written_before_the_upgrade_are_chained(self):
+ command.upgrade(self.cfg, "0005")
+ self._sql("INSERT INTO {t} (action, file_name) VALUES ('upload', 'old1.csv'), ('delete', 'old1.csv')")
+ self.conn.commit()
+ command.upgrade(self.cfg, "head")
+ self._write(2)
+ self.assertEqual(self._verify(), {"rows": 4, "intact": True, "firstBadId": None})
+
+ def test_update_delete_and_truncate_are_rejected(self):
+ command.upgrade(self.cfg, "head")
+ self._write(1)
+ for statement in ("UPDATE {t} SET file_name = 'x'", "DELETE FROM {t}", "TRUNCATE {t}"):
+ with self.subTest(statement=statement), self.assertRaises(psycopg2.errors.InsufficientPrivilege):
+ self._sql(statement)
+ self.conn.rollback()
+ self.assertEqual(self._verify()["rows"], 1)
+
+ def test_records_actor_tenant_and_values(self):
+ command.upgrade(self.cfg, "head")
+ self._write(1, actor="bob")
+ with self.conn.cursor() as cur:
+ cur.execute(f'SELECT actor, tenant_schema, after_value, prev_hash FROM "{self.schema}".audit_log')
+ self.assertEqual(cur.fetchone(), ("bob", self.schema, {"rows": 0}, None))
+
+ def test_a_changed_row_breaks_the_chain_at_that_row(self):
+ command.upgrade(self.cfg, "head")
+ self._write(3)
+ self.assertTrue(self._verify()["intact"])
+ # The owner can disable a trigger. The chain is what catches that.
+ self._sql("ALTER TABLE {t} DISABLE TRIGGER audit_no_change")
+ self._sql("UPDATE {t} SET actor = 'mallory' WHERE id = (SELECT id FROM {t} ORDER BY id OFFSET 1 LIMIT 1)")
+ self._sql("ALTER TABLE {t} ENABLE TRIGGER audit_no_change")
+ self.conn.commit()
+ result = self._verify()
+ self.assertFalse(result["intact"])
+ with self.conn.cursor() as cur:
+ cur.execute(f'SELECT id FROM "{self.schema}".audit_log WHERE actor = %s', ("mallory",))
+ self.assertEqual(result["firstBadId"], cur.fetchone()[0])
+
+ def test_a_removed_row_breaks_the_chain(self):
+ command.upgrade(self.cfg, "head")
+ self._write(3)
+ self._sql("ALTER TABLE {t} DISABLE TRIGGER audit_no_change")
+ self._sql("DELETE FROM {t} WHERE id = (SELECT id FROM {t} ORDER BY id OFFSET 1 LIMIT 1)")
+ self._sql("ALTER TABLE {t} ENABLE TRIGGER audit_no_change")
+ self.conn.commit()
+ self.assertFalse(self._verify()["intact"])
+
+ def test_concurrent_writers_keep_one_unbroken_chain(self):
+ command.upgrade(self.cfg, "head")
+ errors = []
+
+ def writer(name: str) -> None:
+ conn = _connect()
+ try:
+ for _ in range(15):
+ self._write(1, actor=name, conn=conn)
+ except Exception as exc: # noqa: BLE001 — reported below
+ errors.append(exc)
+ finally:
+ conn.close()
+
+ threads = [threading.Thread(target=writer, args=(f"w{i}",)) for i in range(4)]
+ for t in threads:
+ t.start()
+ for t in threads:
+ t.join()
+ self.assertEqual(errors, [])
+ self.assertEqual(self._verify(), {"rows": 60, "intact": True, "firstBadId": None})
+
+ def test_downgrade_removes_the_triggers_and_columns(self):
+ command.upgrade(self.cfg, "head")
+ self._write(1)
+ command.downgrade(self.cfg, "0005")
+ self._sql("UPDATE {t} SET file_name = 'allowed again'")
+ with self.conn.cursor() as cur:
+ cur.execute("SELECT count(*) FROM information_schema.columns "
+ "WHERE table_schema = %s AND table_name = 'audit_log' AND column_name = 'row_hash'",
+ (self.schema,))
+ self.assertEqual(cur.fetchone()[0], 0)
diff --git a/tests/test_migrations.py b/tests/test_migrations.py
index 2e35f0b..7fb54af 100644
--- a/tests/test_migrations.py
+++ b/tests/test_migrations.py
@@ -148,7 +148,7 @@ def setUp(self):
def test_bootstrap_brings_the_schema_to_head(self):
with Conn() as conn, conn.cursor() as cur:
cur.execute(f'SELECT version_num FROM "{settings.UPLOADS_SCHEMA}".alembic_version')
- self.assertEqual(cur.fetchone()[0], "0005")
+ self.assertEqual(cur.fetchone()[0], "0006")
def test_duplicate_hash_is_rejected_by_the_database(self):
insert = (f'INSERT INTO "{settings.UPLOADS_SCHEMA}".csv_files (file_name, file_hash, table_name, mode) '
diff --git a/tests/test_password_reset.py b/tests/test_password_reset.py
index 7e675fc..2ca9c14 100644
--- a/tests/test_password_reset.py
+++ b/tests/test_password_reset.py
@@ -101,9 +101,9 @@ def test_admin_reset_sets_password_unlocks_and_ends_the_users_sessions(self):
def test_reset_is_written_to_the_tenants_audit_log(self):
self.assertEqual(self._reset(self.user).status_code, 200)
with Conn() as conn, conn.cursor() as cur:
- cur.execute(sql.SQL("SELECT action, file_name, mode FROM {}.audit_log").format(
+ cur.execute(sql.SQL("SELECT action, file_name, mode, actor FROM {}.audit_log").format(
sql.Identifier(schema_for(self.tenant))))
- self.assertEqual(cur.fetchall(), [("password_reset", self.user, f"by {self.admin}")])
+ self.assertEqual(cur.fetchall(), [("password_reset", self.user, None, self.admin)])
def test_non_admin_cannot_reset(self):
user_token = self._login().json()["token"]
diff --git a/tests/test_tenant_isolation.py b/tests/test_tenant_isolation.py
index c60e80f..0af647c 100644
--- a/tests/test_tenant_isolation.py
+++ b/tests/test_tenant_isolation.py
@@ -198,7 +198,7 @@ def test_tenant_schema_is_at_head_with_its_own_registry(self):
with Conn() as conn, conn.cursor() as cur:
cur.execute(sql.SQL("SELECT version_num FROM {}.alembic_version").format(
sql.Identifier(schema_for(self.slugs["b"]))))
- self.assertEqual(cur.fetchone()[0], "0005")
+ self.assertEqual(cur.fetchone()[0], "0006")
cur.execute("SELECT table_name FROM information_schema.tables WHERE table_schema = %s",
(schema_for(self.slugs["b"]),))
self.assertTrue({"csv_files", "audit_log"} <= {r[0] for r in cur.fetchall()})