27 of 63 database tests were skipping, and the summary said nothing
The PostgreSQL adapter suite refuses to run against a database that already holds rows, because it truncates indiscriminately and cannot do so selectively - audit_events is append-only, with a trigger that raises on delete. The refusal is correct. Two separate faults meant it was firing when it should not have. The guard sampled the live tables at the moment it was first requested. pytest runs files alphabetically and several files before it create organizations and tenant schemas, so by the time it looked, this session's own rows read as pre-existing data. It skipped 27 of 63 tests on a scratch database that had been empty moments earlier, and '567 passed' carried no hint of it. The sample is now taken in pytest_sessionstart, before collection. A session-scoped autouse fixture was tried first and is not early enough - it is instantiated lazily and does not precede the other session fixtures that write. Nothing cleaned up after the session. Ten files open their own connection to the DSN outside the postgres fixture, each inserted organization provisions a tenant schema, and no test dropped it: 78 schemas per run, which after about fourteen runs exhausted max_locks_per_transaction and errored the integration tests with a message reading like a PostgreSQL defect. And the fix for that nearly caused the disaster the guard exists to prevent. The first version of the cleanup truncated unconditionally on teardown, so pointing the suite at a populated database destroyed it while every test that could have touched it skipped itself. Found by planting an organization, watching the suite refuse, and finding the organization gone afterwards. It now leaves an occupied database exactly as found, and says so - through pytest's terminal reporter, because a plain write to stderr is swallowed by file-descriptor capture and was seen by nobody. test_scratch_database_safety.py covers all three, and each was confirmed to fail when the protection is removed. Three mistakes in writing it are recorded in the file: a leading test file that cleaned up after itself and so reproduced nothing; assertions that passed because '-rs' was missing and pytest had printed no skip reasons; and a check that stopped at the warnings header, which pytest prints its summary line after. 598 passed, 4 skipped. The 4 are environmental: two pages with no password field, two needing passwordless sudo for device permissions.
This commit is contained in:
@@ -9,9 +9,11 @@ or otherwise.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import string
|
||||
import sys
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import Any
|
||||
@@ -168,6 +170,228 @@ class ApiHarness:
|
||||
self._client.cookies.clear()
|
||||
|
||||
|
||||
#: Tables whose rows are created per test and must not survive the session. Every one
|
||||
#: of the first six is referenced by ``organizations``, so it has to be emptied before
|
||||
#: the organizations themselves can go. ``users`` is here for the same reason from the
|
||||
#: other direction: the tenant tables hold foreign keys into it.
|
||||
_SESSION_TABLES = (
|
||||
"public.artifacts",
|
||||
"public.audit_events",
|
||||
"public.authors",
|
||||
"public.compute_nodes",
|
||||
"public.jobs",
|
||||
"public.node_pairing_codes",
|
||||
"public.publishers",
|
||||
"public.user_roles",
|
||||
"public.users",
|
||||
"public.organizations",
|
||||
)
|
||||
|
||||
|
||||
#: What the scratch database held when the session began, sampled in
|
||||
#: ``pytest_sessionstart``. ``None`` means "not sampled", which is the case whenever
|
||||
#: the disposal is not acknowledged - see :func:`scratch_database_session`.
|
||||
_SESSION_START: dict[str, Any] | None = None
|
||||
|
||||
|
||||
def pytest_sessionstart(session: pytest.Session) -> None:
|
||||
"""Sample the scratch database before collection, when nothing has touched it.
|
||||
|
||||
This is a hook rather than a fixture for a reason that cost two wrong runs to
|
||||
find. A session-scoped autouse fixture is instantiated lazily, on first request,
|
||||
and pytest does not guarantee it precedes the other session-scoped fixtures that
|
||||
create organizations - so the sample taken there saw six schemas that this same
|
||||
session had already created and reported the database as occupied. The guard that
|
||||
reads this value then skipped 27 of the 63 adapter tests, exactly as it had been
|
||||
skipping them before. ``pytest_sessionstart`` is the only moment that is
|
||||
unambiguously before everything, including collection.
|
||||
"""
|
||||
|
||||
global _SESSION_START # noqa: PLW0603 - one value for the whole session, by design
|
||||
|
||||
dsn = os.environ.get("UMBRATRANS_TEST_DSN", "").strip()
|
||||
acknowledged = os.environ.get("UMBRATRANS_TEST_DATABASE_IS_DISPOSABLE", "").strip() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
}
|
||||
if not dsn or not acknowledged or "umbracontrol" in dsn:
|
||||
return
|
||||
|
||||
try:
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row
|
||||
except ImportError: # pragma: no cover - keeps a non-database run collectable
|
||||
return
|
||||
|
||||
connection = psycopg.connect(dsn, row_factory=dict_row)
|
||||
try:
|
||||
counts = _occupancy(connection)
|
||||
connection.rollback()
|
||||
finally:
|
||||
# Closed immediately. A connection held open for the session would keep its
|
||||
# transaction's locks on every table it read, and the first TRUNCATE in the
|
||||
# suite would then block forever waiting for ACCESS EXCLUSIVE. That is a hang
|
||||
# rather than a failure, which is why this is a short-lived connection and not
|
||||
# one kept for the session.
|
||||
connection.close()
|
||||
_SESSION_START = {"enabled": True, "occupied": {n: c for n, c in counts.items() if c}}
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def scratch_database_session(request: pytest.FixtureRequest):
|
||||
"""Empty the scratch database when the session ends, whoever created what.
|
||||
|
||||
The ``postgres`` fixture in ``test_postgres_repository.py`` already truncates three
|
||||
tables when it finishes. That covers the tests which request it and nothing else,
|
||||
and ten files under this directory open their own connection to
|
||||
``UMBRATRANS_TEST_DSN`` and insert organizations without going through that
|
||||
fixture. Each inserted organization provisions a tenant schema from a trigger, and
|
||||
no test drops it.
|
||||
|
||||
Two consequences, both observed rather than predicted:
|
||||
|
||||
* A run left 78 organizations and 78 tenant schemas behind. After about fourteen
|
||||
runs that is enough schemas that dropping them in one transaction fails with
|
||||
"out of shared memory ... max_locks_per_transaction", and the PostgreSQL
|
||||
integration tests error out with a message that reads like a database defect.
|
||||
|
||||
* Worse, and silent: ``require_empty_database`` refuses to run the adapter suite
|
||||
against a database that already holds rows. So the debris from one file made 27
|
||||
of the 63 adapter tests *skip* on the next run, and the summary still read
|
||||
"567 passed". A green suite that never opened a database is worse than a red
|
||||
one, because it looks like evidence.
|
||||
|
||||
TRUNCATE rather than DELETE throughout: ``audit_events`` and ``job_events`` carry
|
||||
triggers that raise on mutation, so DELETE is refused and truncation is the only
|
||||
way to remove them.
|
||||
|
||||
Gated exactly as the adapter suite gates itself - the DSN must be set, the
|
||||
disposal must be acknowledged explicitly, and ``umbracontrol`` is refused outright.
|
||||
Without both environment variables this fixture does nothing at all, so a run
|
||||
without them cannot touch a database.
|
||||
|
||||
It also *reports* what the database held when the session began, which the adapter
|
||||
suite's guard reads instead of re-querying mid-session. That guard exists to refuse
|
||||
to run against a database holding somebody's data, and reading the live tables
|
||||
made it read this session's own rows as somebody else's.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
dsn = os.environ.get("UMBRATRANS_TEST_DSN", "").strip()
|
||||
acknowledged = os.environ.get("UMBRATRANS_TEST_DATABASE_IS_DISPOSABLE", "").strip() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
}
|
||||
if not dsn or not acknowledged or "umbracontrol" in dsn:
|
||||
yield {"enabled": False, "occupied": {}}
|
||||
return
|
||||
|
||||
# Imported here so that a run without these installed still collects the tests
|
||||
# that never touch PostgreSQL, rather than erroring during fixture setup.
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
# The sample was taken in pytest_sessionstart, before collection. Reading it from
|
||||
# there rather than re-sampling is the whole point: see that hook's docstring.
|
||||
at_start = _SESSION_START if _SESSION_START is not None else {"enabled": True, "occupied": {}}
|
||||
|
||||
if at_start.get("occupied"):
|
||||
# The database held data before this session began, so this run is not entitled
|
||||
# to change a single row of it - and the adapter suite's guard has already
|
||||
# skipped for exactly that reason.
|
||||
#
|
||||
# This branch is load-bearing in the strongest sense available, and it was
|
||||
# absent at first. An earlier version truncated unconditionally on teardown,
|
||||
# which meant that pointing the suite at a populated database destroyed that
|
||||
# data *while every test that could have touched it skipped itself*. The guard
|
||||
# was doing its job - 27 skips, refusal, nothing destroyed by any test - and
|
||||
# the autouse teardown was undoing it one fixture later. Verified by planting
|
||||
# an organization, watching the suite refuse, and finding the organization
|
||||
# gone afterwards.
|
||||
#
|
||||
# So: leave it exactly as found, and say so, because a run that did nothing
|
||||
# should not look like a run that succeeded.
|
||||
#
|
||||
# Through the terminal reporter rather than sys.stderr: pytest captures at file
|
||||
# descriptor level for the whole run and discards the buffer unless a test
|
||||
# fails, so a plain write is swallowed and the message is seen by nobody. It was
|
||||
# confirmed absent from stderr under default settings and present under
|
||||
# --capture=no, which is the worst possible combination - the code ran and
|
||||
# stayed silent.
|
||||
reporter = request.config.pluginmanager.get_plugin("terminalreporter")
|
||||
line = (
|
||||
"umbratrans: left the database untouched - it already held data "
|
||||
f"({', '.join(sorted(at_start['occupied']))}). Nothing was cleaned up."
|
||||
)
|
||||
if reporter is not None:
|
||||
reporter.write_line("")
|
||||
reporter.write_line(line, red=True, bold=True)
|
||||
else: # pragma: no cover - only when the terminal plugin is disabled
|
||||
sys.stderr.write(f"\n {line}\n")
|
||||
yield at_start
|
||||
return
|
||||
|
||||
connection = psycopg.connect(dsn, row_factory=dict_row)
|
||||
try:
|
||||
yield at_start
|
||||
|
||||
schemas = connection.execute(
|
||||
"SELECT nspname FROM pg_namespace WHERE nspname LIKE 'tenant\\_%' ORDER BY nspname"
|
||||
).fetchall()
|
||||
# One schema per transaction, and load-bearing. Dropping several hundred in a
|
||||
# single transaction exhausts the lock table and fails with "out of shared
|
||||
# memory"; the message names max_locks_per_transaction but the fix is the
|
||||
# batching, not the setting.
|
||||
for schema in schemas:
|
||||
connection.execute(f'DROP SCHEMA IF EXISTS "{schema["nspname"]}" CASCADE')
|
||||
connection.commit()
|
||||
|
||||
connection.execute(f"TRUNCATE {', '.join(_SESSION_TABLES)} CASCADE")
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def _occupancy(connection: Any) -> dict[str, int]:
|
||||
"""Row counts for every table that decides whether a database is a test database.
|
||||
|
||||
Covers the platform tables *and* the four tenant tables, which since migration
|
||||
0005 live one schema per organization. A database can hold nothing in ``public``
|
||||
and still hold somebody's books, so a check that looked only at ``public`` would
|
||||
pass on a database about to be emptied.
|
||||
|
||||
The tenant tables are found by listing the schemas rather than by asking for a
|
||||
fixed set, so a schema left over from an older migration, or one whose name does
|
||||
not match the current convention, is still seen.
|
||||
"""
|
||||
|
||||
counts: dict[str, int] = {}
|
||||
for name in ("users", "organizations", "audit_events", "user_roles", "sessions", "jobs", "artifacts"):
|
||||
row = connection.execute(f"SELECT count(*) AS total FROM public.{name}").fetchone()
|
||||
counts[name] = int(row["total"])
|
||||
|
||||
schemas = connection.execute(
|
||||
"SELECT nspname FROM pg_namespace WHERE nspname LIKE 'tenant\\_%' ORDER BY nspname"
|
||||
).fetchall()
|
||||
counts["tenant_schemas"] = len(schemas)
|
||||
for schema in schemas:
|
||||
name = schema["nspname"]
|
||||
tables = connection.execute(
|
||||
"SELECT tablename FROM pg_tables WHERE schemaname = %s ORDER BY tablename", (name,)
|
||||
).fetchall()
|
||||
for table in tables:
|
||||
row = connection.execute(
|
||||
f'SELECT count(*) AS total FROM "{name}"."{table["tablename"]}"'
|
||||
).fetchone()
|
||||
counts[f"{name}.{table['tablename']}"] = int(row["total"])
|
||||
return counts
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def settings() -> Settings:
|
||||
return suite_settings()
|
||||
|
||||
@@ -739,7 +739,7 @@ integration = pytest.mark.skipif(
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def require_empty_database():
|
||||
def require_empty_database(scratch_database_session):
|
||||
"""Refuse to run against a database that holds anything.
|
||||
|
||||
This suite empties ``users``, ``organizations`` and ``audit_events`` when it
|
||||
@@ -765,19 +765,34 @@ def require_empty_database():
|
||||
schema per organization, so a database that holds nothing in ``public`` can
|
||||
still hold somebody's books. Checking only the platform tables would have made
|
||||
this guard pass on a database it would then have emptied.
|
||||
|
||||
The occupancy is read from ``scratch_database_session``, which samples the database
|
||||
before any test has run, rather than from the live tables here. Sampling live was
|
||||
wrong in a way nothing in the summary line showed: pytest runs the files
|
||||
alphabetically, and nine of the files before this one create organizations of their
|
||||
own, so by the time this guard was first requested the tables were populated *by
|
||||
this session*. It read that as evidence the database was not a test database and
|
||||
skipped 27 of the 63 tests, on a scratch database that had been empty moments
|
||||
before. The refusal is the right rule; sampling before the suite has touched
|
||||
anything is the only moment at which the answer means what the rule intends.
|
||||
"""
|
||||
|
||||
dsn = _integration_dsn()
|
||||
if dsn is None or not _destructive_run_acknowledged() or "umbracontrol" in dsn:
|
||||
yield
|
||||
return
|
||||
counts = _occupancy(dsn)
|
||||
populated = {name: count for name, count in counts.items() if count}
|
||||
|
||||
if not scratch_database_session["enabled"]:
|
||||
pytest.skip("the scratch-database session fixture is not active for this DSN")
|
||||
|
||||
populated = scratch_database_session["occupied"]
|
||||
if populated:
|
||||
detail = ", ".join(f"{name}={count}" for name, count in sorted(populated.items()))
|
||||
pytest.skip(
|
||||
"refusing to run the adapter suite against a database that already holds data "
|
||||
f"({detail}). This suite empties those tables when it finishes and cannot do so "
|
||||
"selectively, because audit_events is append-only. Use a scratch database."
|
||||
f"at the start of the session ({detail}). This suite empties those tables when it "
|
||||
"finishes and cannot do so selectively, because audit_events is append-only. Use a "
|
||||
"scratch database."
|
||||
)
|
||||
yield
|
||||
_drop_tenant_schemas(dsn)
|
||||
|
||||
287
umbratrans_web/tests/test_scratch_database_safety.py
Normal file
287
umbratrans_web/tests/test_scratch_database_safety.py
Normal file
@@ -0,0 +1,287 @@
|
||||
"""A run must never change a database it refused to run against.
|
||||
|
||||
`require_empty_database` exists to stop the suite emptying somebody's data. It works by
|
||||
skipping: point the suite at a populated database and 27 of the 63 adapter tests decline
|
||||
to run, because the suite truncates indiscriminately and cannot do so selectively -
|
||||
`audit_events` is append-only, with a trigger that raises on delete.
|
||||
|
||||
The trap this file covers is what happens *after* those tests skip. The refusal only
|
||||
protects the database if nothing else in the session then writes to it, and for a while
|
||||
something did: the session-wide cleanup fixture truncated on teardown unconditionally.
|
||||
So the suite refused to touch the data, every test that could have harmed it skipped
|
||||
itself, and one fixture later the data was gone anyway. The observable result was a run
|
||||
that skipped the dangerous tests and destroyed the database - the worst of both, and it
|
||||
reported success.
|
||||
|
||||
Found by planting an organization, watching the suite refuse, and finding the
|
||||
organization gone afterwards. The fix is a branch that leaves an occupied database
|
||||
exactly as found. This is the test that says it must stay that way.
|
||||
|
||||
It runs pytest in a subprocess because the property is about what a whole session does
|
||||
to a database, which no in-process fixture assertion can observe. It asserts on the
|
||||
database afterwards rather than on the source, because a source check would be satisfied
|
||||
by a comment.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
TESTS = Path(__file__).resolve().parent
|
||||
REPO = TESTS.parents[1]
|
||||
|
||||
DSN_ENV = "UMBRATRANS_TEST_DSN"
|
||||
ACK_ENV = "UMBRATRANS_TEST_DATABASE_IS_DISPOSABLE"
|
||||
|
||||
#: A slug nothing else in the suite uses, so a leftover row cannot be mistaken for a
|
||||
#: surviving one.
|
||||
PLANTED_SLUG = "test-planted-organization-must-survive"
|
||||
|
||||
_TENANT_SCHEMAS = "SELECT nspname FROM pg_namespace WHERE nspname LIKE 'tenant\\_%' ORDER BY nspname"
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not os.environ.get(DSN_ENV)
|
||||
or os.environ.get(ACK_ENV, "").strip() not in {"1", "true", "yes", "on"}
|
||||
or "umbracontrol" in os.environ.get(DSN_ENV, ""),
|
||||
reason=f"needs {DSN_ENV} and an acknowledged disposable {ACK_ENV}",
|
||||
)
|
||||
|
||||
|
||||
def _connect() -> Any:
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
return psycopg.connect(os.environ[DSN_ENV], row_factory=dict_row, autocommit=True)
|
||||
|
||||
|
||||
def _schemas(connection: Any) -> set[str]:
|
||||
return {row["nspname"] for row in connection.execute(_TENANT_SCHEMAS).fetchall()}
|
||||
|
||||
|
||||
def _plant(connection: Any) -> set[str]:
|
||||
"""Insert an organization, returning the tenant schemas its trigger created."""
|
||||
|
||||
before = _schemas(connection)
|
||||
connection.execute("DELETE FROM public.organizations WHERE slug = %s", (PLANTED_SLUG,))
|
||||
connection.execute(
|
||||
"INSERT INTO public.organizations (id, name, slug, status, created_at) "
|
||||
"VALUES (gen_random_uuid(), %s, %s, 'active', now())",
|
||||
("Test Planted Organization", PLANTED_SLUG),
|
||||
)
|
||||
created = _schemas(connection) - before
|
||||
present = connection.execute(
|
||||
"SELECT count(*) AS total FROM public.organizations WHERE slug = %s", (PLANTED_SLUG,)
|
||||
).fetchone()["total"]
|
||||
assert present == 1, "the organization was not planted, so nothing is being proved"
|
||||
assert created, "the plant created no tenant schema, so it is not the shape of data at risk"
|
||||
return created
|
||||
|
||||
|
||||
def _unplant(connection: Any, created: set[str]) -> None:
|
||||
"""Undo the plant completely - the row *and* the schemas its trigger made.
|
||||
|
||||
Dropping the row alone leaves the schema, and schemas are what accumulate into the
|
||||
lock exhaustion this whole exercise came from. A test that leaked two schemas per run
|
||||
would be the very defect it exists to catch.
|
||||
"""
|
||||
|
||||
for name in created:
|
||||
connection.execute(f'DROP SCHEMA IF EXISTS "{name}" CASCADE')
|
||||
connection.execute("DELETE FROM public.organizations WHERE slug = %s", (PLANTED_SLUG,))
|
||||
|
||||
|
||||
def _run_adapter_suite() -> subprocess.CompletedProcess[str]:
|
||||
"""A whole session, in a subprocess, against whatever the database holds now."""
|
||||
|
||||
return subprocess.run( # noqa: S603 - fixed argv, no shell
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pytest",
|
||||
"umbratrans_web/tests/test_postgres_repository.py",
|
||||
"-q",
|
||||
"--no-header",
|
||||
"-p",
|
||||
"no:cacheprovider",
|
||||
],
|
||||
cwd=REPO,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def planted() -> Any:
|
||||
connection = _connect()
|
||||
created: set[str] = set()
|
||||
try:
|
||||
created = _plant(connection)
|
||||
yield connection
|
||||
finally:
|
||||
_unplant(connection, created)
|
||||
connection.close()
|
||||
|
||||
|
||||
def test_a_planted_organization_survives_a_run(planted: Any) -> None:
|
||||
"""The refusal must be real, not merely reported."""
|
||||
|
||||
result = _run_adapter_suite()
|
||||
|
||||
survived = planted.execute(
|
||||
"SELECT count(*) AS total FROM public.organizations WHERE slug = %s", (PLANTED_SLUG,)
|
||||
).fetchone()["total"]
|
||||
assert survived == 1, (
|
||||
"the suite destroyed an organization that was present when the session began. It "
|
||||
"refused to run the destructive tests and then removed the data during teardown "
|
||||
f"anyway. Subprocess output was:\n{result.stdout[-2000:]}"
|
||||
)
|
||||
|
||||
|
||||
def test_the_destructive_tests_actually_declined(planted: Any) -> None:
|
||||
"""The survival above only means something if the tests really did refuse.
|
||||
|
||||
Without this, a suite that emptied the database without ever reaching its own guard
|
||||
would pass the test above for the wrong reason - and a test that passes because the
|
||||
mechanism it is meant to check never ran is the failure this project keeps making.
|
||||
"""
|
||||
|
||||
result = _run_adapter_suite()
|
||||
|
||||
assert result.returncode == 0, result.stdout[-2000:]
|
||||
assert "27 skipped" in result.stdout, (
|
||||
"expected the adapter suite to skip its destructive tests against a populated "
|
||||
f"database, and it did not. Subprocess output was:\n{result.stdout[-2000:]}"
|
||||
)
|
||||
|
||||
|
||||
def test_the_refusal_is_reported_rather_than_silent(planted: Any) -> None:
|
||||
"""A run that changed nothing must not look like a run that succeeded.
|
||||
|
||||
Asserted on the subprocess output rather than on the exit code: pytest exits zero
|
||||
when tests are skipped, which is precisely why the earlier version of this went
|
||||
unnoticed. The message also has to survive pytest's file-descriptor capture - a
|
||||
plain write to stderr is swallowed and never shown, which was the first attempt.
|
||||
"""
|
||||
|
||||
result = _run_adapter_suite()
|
||||
|
||||
assert "left the database untouched" in result.stdout, (
|
||||
"a run that declined to touch the database did not say so. Without this line a "
|
||||
"refusal is indistinguishable in the output from a normal passing run:\n"
|
||||
f"{result.stdout[-2000:]}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def empty_disposable_database() -> Any:
|
||||
"""Empty the database so the subprocess below starts from nothing.
|
||||
|
||||
This test is the awkward one. The property only exists in a session that begins with
|
||||
an empty database, and the enclosing session will not be empty when this runs - the
|
||||
files before it have created organizations, which is the very condition being tested.
|
||||
So the test clears the database itself first.
|
||||
|
||||
Creating a private database instead would be cleaner, and was tried: `umbra` has no
|
||||
CREATE DATABASE on this server, so `CREATE DATABASE umbratrans_iso_probe` fails with
|
||||
permission denied and there is no way to run these subprocesses against a database of
|
||||
their own.
|
||||
|
||||
Clearing it is safe because the disposal is acknowledged - the gate is the same two
|
||||
environment variables the whole suite runs behind - and because the subprocess is the
|
||||
only thing that needs the emptiness. Anything running afterwards creates its own
|
||||
organizations; none of this suite carries state in the database between files, which
|
||||
is why a test file that needed this could not simply rely on ordering.
|
||||
"""
|
||||
|
||||
connection = _connect()
|
||||
for name in _schemas(connection):
|
||||
connection.execute(f'DROP SCHEMA IF EXISTS "{name}" CASCADE')
|
||||
connection.execute(
|
||||
"TRUNCATE public.artifacts, public.audit_events, public.authors, public.compute_nodes, "
|
||||
"public.jobs, public.node_pairing_codes, public.publishers, public.user_roles, "
|
||||
"public.users, public.organizations CASCADE"
|
||||
)
|
||||
try:
|
||||
yield connection
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def test_tests_that_make_organizations_do_not_disable_the_adapter_suite(
|
||||
empty_disposable_database: Any,
|
||||
) -> None:
|
||||
"""The database tests must actually run, in the order the suite runs them.
|
||||
|
||||
This is the quieter half of the same defect, and the more expensive one.
|
||||
`require_empty_database` samples occupancy to decide whether the database is safe to
|
||||
truncate. It used to read the live tables when that decision was made, which is after
|
||||
pytest has already run the files ordered before it - and several of those create
|
||||
organizations and tenant schemas. It read this session's own rows as pre-existing data
|
||||
and skipped 27 of the 63 adapter tests, on a scratch database that had been empty
|
||||
moments earlier. The summary line read "567 passed" and nothing else said otherwise.
|
||||
|
||||
So the sample has to be taken before the session touches anything. Running
|
||||
`test_job_project_tenancy.py` first reproduces the ordering; with the guard sampling
|
||||
late, the adapter tests skip here exactly as they did in the full suite, and with it
|
||||
sampling at session start they do not.
|
||||
|
||||
`test_rls_tenancy.py` was the first choice for the leading file and was wrong: it
|
||||
cleans up after itself, so it reproduced nothing and the test passed against the
|
||||
broken guard. The file has to be one that leaves its organizations behind.
|
||||
|
||||
Asserted as "no skips", because the failure mode is a suite that looks green while
|
||||
not testing the database, and a test that only checked the exit code would pass in
|
||||
exactly that case.
|
||||
"""
|
||||
|
||||
result = subprocess.run( # noqa: S603 - fixed argv, no shell
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pytest",
|
||||
"umbratrans_web/tests/test_job_project_tenancy.py",
|
||||
"umbratrans_web/tests/test_postgres_repository.py",
|
||||
"-q",
|
||||
"--no-header",
|
||||
"-p",
|
||||
"no:cacheprovider",
|
||||
"-rs",
|
||||
],
|
||||
cwd=REPO,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
check=False,
|
||||
)
|
||||
|
||||
output = result.stdout[-3000:]
|
||||
assert result.returncode == 0, output
|
||||
|
||||
# -rs above is not optional. Without it pytest prints no skip reasons, so the first
|
||||
# assertion below would pass against a guard that skipped everything it was meant to
|
||||
# catch - which is what happened the first time this was written.
|
||||
assert "refusing to run the adapter suite" not in output, (
|
||||
"the adapter suite skipped itself because it sampled the database after earlier "
|
||||
"files had already created organizations in this same session. The sample has to "
|
||||
f"be taken at session start, before collection. Output was:\n{output}"
|
||||
)
|
||||
|
||||
# Read from the summary line rather than by searching for the word "skipped": pytest
|
||||
# prints that summary *after* the warnings section, so an assertion that stops at the
|
||||
# warnings header never sees it. That mistake hid the same failure a second time.
|
||||
summary = output.strip().splitlines()[-1] if output.strip() else ""
|
||||
skipped = re.search(r"(\d+) skipped", summary)
|
||||
assert skipped is None, (
|
||||
"expected no skips in this ordering, and the summary reports some. This test file "
|
||||
f"had been passing against the very defect it was written for. Summary was: "
|
||||
f"{summary!r}\nFull output was:\n{output}"
|
||||
)
|
||||
Reference in New Issue
Block a user