Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .secrets.baseline
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand All @@ -153,5 +153,5 @@
}
]
},
"generated_at": "2026-07-26T20:52:13Z"
"generated_at": "2026-07-27T15:42:59Z"
}
67 changes: 67 additions & 0 deletions backend/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,83 @@
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
from fastapi import Depends, FastAPI
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)
Expand Down
14 changes: 14 additions & 0 deletions backend/tests/test_env_isolation.py
Original file line number Diff line number Diff line change
@@ -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}"
13 changes: 9 additions & 4 deletions backend/tests/test_rbac.py
Original file line number Diff line number Diff line change
@@ -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()
Expand Down Expand Up @@ -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"])

Expand Down
Loading