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()})