diff --git a/.secrets.baseline b/.secrets.baseline index f3f7082..a80cef0 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -133,7 +133,7 @@ "filename": "backend/tests/conftest.py", "hashed_secret": "ab3eb0f868f05373c611a6c904ae319ff0772c0c", "is_verified": false, - "line_number": 37 + "line_number": 104 } ], "backend/tests/test_config.py": [ @@ -153,5 +153,5 @@ } ] }, - "generated_at": "2026-07-26T20:52:13Z" + "generated_at": "2026-07-27T15:42:59Z" } diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index a5d4e08..2865191 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,9 +1,16 @@ import os +# Must run before anything imports config/db - settings.MONGODB_URI is read +# once at import time, and backend/.env points at a real Atlas cluster. +# Tests must never be able to reach it, even by accident. +os.environ["MONGODB_URI"] = "mongodb://test-mongo-should-not-be-used.invalid:27017/procureai_test" os.environ["ANONYMIZED_TELEMETRY"] = "False" import uuid +from types import SimpleNamespace +from typing import Optional +import db as db_module import pytest from api.routes.auth import router as auth_router from auth.dependencies import get_current_user @@ -11,6 +18,66 @@ from httpx import ASGITransport, AsyncClient +class _FakeCollection: + """Minimal in-memory stand-in for an AsyncIOMotorCollection. + + Same hand-rolled style as tests/test_audit.py::_mock_db. Only + implements what crud/user.py actually calls: find_one with an + equality filter, and insert_one. + """ + + def __init__(self): + self._docs: list[dict] = [] + + async def find_one(self, filt: dict) -> Optional[dict]: + for doc in self._docs: + if all(doc.get(k) == v for k, v in filt.items()): + return doc + return None + + async def insert_one(self, doc: dict): + self._docs.append(doc) + return SimpleNamespace(inserted_id=doc.get("_id")) + + def reset(self) -> None: + self._docs.clear() + + +class _FakeDB: + def __init__(self): + self.users = _FakeCollection() + + +class _FakeMongoClient: + def __init__(self): + self.procureai = _FakeDB() + + +# db.mongo_client is a LazyProxy(_create_mongo_client) built once at db.py +# import time (see utils/lazy.py): _lazy_proxy(factory) closes over the +# factory *function object* itself in self._factory, so monkeypatching the +# module-level name db._create_mongo_client afterwards has no effect on +# the already-constructed proxy - it would still call the real factory. +# We patch the proxy instance's own _factory attribute instead. db.db +# (the AsyncIOMotorDatabase proxy) doesn't need patching separately: its +# factory (_get_db) just returns `mongo_client.procureai`, so once +# mongo_client resolves to the fake client, db resolves to the fake db. +# +# _obj caches on the proxy instance for the whole process, so this only +# actually runs once (on the first test's first db access) regardless of +# which test triggers it - per-test isolation comes from clearing the +# fake's contents, not from recreating the proxy. +_fake_mongo_client = _FakeMongoClient() + + +@pytest.fixture(autouse=True) +def fake_mongo(monkeypatch): + monkeypatch.setattr(db_module.mongo_client, "_factory", lambda: _fake_mongo_client) + _fake_mongo_client.procureai.users.reset() + yield + _fake_mongo_client.procureai.users.reset() + + def _make_app() -> FastAPI: app = FastAPI() app.include_router(auth_router) diff --git a/backend/tests/test_env_isolation.py b/backend/tests/test_env_isolation.py new file mode 100644 index 0000000..9f027d6 --- /dev/null +++ b/backend/tests/test_env_isolation.py @@ -0,0 +1,14 @@ +""" +Guard: backend/.env points at a real MongoDB Atlas cluster. conftest.py +overrides MONGODB_URI before config/db get imported, specifically so the +test suite can never write to it. This test fails loudly if that override +is ever removed or bypassed. +""" + +from config import settings + + +def test_mongodb_uri_is_never_the_real_atlas_cluster(): + uri = settings.MONGODB_URI + assert "mongodb+srv" not in uri, f"settings.MONGODB_URI looks like a real Atlas SRV URI: {uri}" + assert "7dre2" not in uri, f"settings.MONGODB_URI points at the real Atlas cluster: {uri}" diff --git a/backend/tests/test_rbac.py b/backend/tests/test_rbac.py index b7ce21b..dd12434 100644 --- a/backend/tests/test_rbac.py +++ b/backend/tests/test_rbac.py @@ -1,16 +1,22 @@ import uuid +import db as db_module import pytest from api.routes.auth import router as auth_router from auth.security import create_access_token -from config import settings from core.rbac import require_procurement_officer, require_viewer from crud.user import create_user from fastapi import Depends, FastAPI from httpx import ASGITransport, AsyncClient -from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase from schemas.user import UserCreate +# NB: don't `from db import db` here - db.db is a LazyProxy that defines +# __call__, so pytest's collector (and the anyio plugin's istestfunction +# check) probes it for a __test__ attribute while collecting this module, +# which eagerly triggers real Mongo client construction *during +# collection*, before any fixture gets a chance to patch it in. Importing +# the module and doing the attribute lookup at call time avoids that. + def _make_rbac_app() -> FastAPI: app = FastAPI() @@ -57,14 +63,13 @@ async def _register(client: AsyncClient, role: str = "viewer") -> str: assert res.status_code == 200, res.text return _extract_token(res) # Registration endpoint forces viewer; seed elevated-role users directly via crud. - db: AsyncIOMotorDatabase = AsyncIOMotorClient(settings.MONGODB_URI).procureai user_in = UserCreate.model_construct( email=email, password="TestPass123!", # pragma: allowlist secret full_name="RBAC Test", role=role, ) - user = await create_user(db, user_in) + user = await create_user(db_module.db, user_in) assert user is not None return create_access_token(subject=user["email"], role=user["role"])