""" tests/conftest.py ----------------- Shared pytest fixtures + import-time environment setup. `config.py` calls `_require_env('SECRET_KEY')` and `_require_env('DATABASE_URL')` at *import* time, so those must be present in os.environ before `app` is imported anywhere. We set safe, throwaway values here (SQLite in-memory) so the whole suite can run with no MySQL, no Redis, no Stripe, and no real secrets. The pure-logic tests (SLA math, webhook helpers) never touch the DB. The routing-inertness test uses the in-memory SQLite engine only. Why these are ASSIGNED and not `setdefault` ------------------------------------------- `os.environ.setdefault` is a no-op when the key is already present, so running pytest from a shell that exports the deployment's variables — or under anything that loads `.env` before pytest starts — silently handed the suite the REAL configuration. Two consequences, both observed: * `MULTI_TENANT_ENABLED=true` engages Host→tenant resolution. The test client sends Host `localhost`, `resolve_tenant()` returns None, and `app/tenancy/middleware.py::_tenant_resolver` returns the "workspace not found" page with status 404 for EVERY request, before any view runs. The whole suite fails with `assert 404 == `. * `DATABASE_URL` pointing at MySQL would make the `db.drop_all()` in each module's `client` fixture run against a real database. The values below are therefore forced. Anything a developer needs to vary they can vary in the fixture, not through ambient environment. `load_dotenv()` in config.py runs later with `override=False`, so these win. """ import os import sys # ── Import-time env (must be set BEFORE `import app`) ──────────────────────── # Assigned, NOT setdefault — see the module docstring. # SECRET_KEY doubles as the JWT signing key (app/api/jwt_utils.py), and PyJWT # warns below 32 bytes for HMAC-SHA256 (RFC 7518 §3.2). Throwaway, but sized so # the API tests do not emit InsecureKeyLengthWarning on every token. os.environ['SECRET_KEY'] = 'test-secret-key-not-for-production-use-0123456789' os.environ['DATABASE_URL'] = 'sqlite:///:memory:' os.environ['MULTI_TENANT_ENABLED'] = 'false' os.environ['BILLING_ENABLED'] = 'false' # The apex-host branch of the tenant resolver runs even with the MT flag off, so # pin the base domain rather than inheriting it. 'jqc.app' is config.py's own # default and is what tests/test_landing.py asserts against; the test client's # Host ('localhost') can never match it. os.environ['TENANT_BASE_DOMAIN'] = 'jqc.app' # Control-plane vars must be absent: their presence is what lets the resolver # reach a real control DB instead of failing loudly. os.environ.pop('CONTROL_DATABASE_URL', None) os.environ.pop('CONTROL_FERNET_KEY', None) # ── Belt-and-braces guard ──────────────────────────────────────────────────── # Every module-level `client` fixture calls db.drop_all(). If anything ever # re-points DATABASE_URL at a real server, refuse to run rather than drop it. if not os.environ['DATABASE_URL'].startswith('sqlite:'): sys.exit( f"REFUSING TO RUN: DATABASE_URL is {os.environ['DATABASE_URL']!r}, not " f"SQLite. The test fixtures call db.drop_all()." ) import pytest # noqa: E402 @pytest.fixture(scope='session') def app(): """A minimal single-tenant app on in-memory SQLite (multi-tenancy inert).""" # Flask-Limiter uses in-memory storage keyed on the remote address, and this # fixture is session-scoped — so every login across the WHOLE suite shares # one counter against /auth/login's '20 per minute'. Past that the login # returns 429, the test client stays anonymous, and whatever the test does # next is redirected to the login page. The failure surfaces as an unrelated # assertion ("the edit did not apply"), only in full runs, and only once # enough tests have logged in — so it moves around as tests are added or # reordered. # # This MUST happen before create_app(). Limiter.init_app() does # `self.enabled = config.setdefault('RATELIMIT_ENABLED', self.enabled)` and # returns early when false, registering no request hooks — and `enabled` is # never consulted again at request time (flask-limiter 4.x). Setting it # afterwards is silently a no-op. Production limits are untouched. from app import limiter limiter.enabled = False from app import create_app application = create_app('default') application.config.update(TESTING=True, WTF_CSRF_ENABLED=False, SQLALCHEMY_ECHO=False) # Belt-and-braces again at the app layer: config.py could pick these up from # a source we have not anticipated. A test run must never resolve tenants. application.config.update(MULTI_TENANT_ENABLED=False, BILLING_ENABLED=False) return application @pytest.fixture def app_ctx(app): with app.app_context(): yield app