"""POST /api/query enforces the same per-user quota as /api/v2/scan. Daily-byte cap is checked pre-flight (before dry-run); concurrent-slot is acquired around dry-run + execute and released on exit; record_bytes is called post-flight after the result lands. The quota tracker is the process-local singleton in app/api/v2_quota.py โ€” shared with /api/v2/scan so both paths bill against the same daily budget. Closes part of #160 ยง4.3.3. """ from __future__ import annotations import pytest def _auth(token: str) -> dict: return {"Authorization": f"Bearer {token}"} def _register_bq_remote_row(name: str, bucket: str, source_table: str) -> None: from src.db import get_system_db from src.repositories.table_registry import TableRegistryRepository sys_conn = get_system_db() try: TableRegistryRepository(sys_conn).register( id=f"bq.{bucket}.{source_table}", name=name, source_type="bigquery", bucket=bucket, source_table=source_table, query_mode="remote", ) finally: sys_conn.close() @pytest.fixture def fresh_quota(monkeypatch): """Reset the process-local quota singleton + return a fresh tracker bound to the v2_quota module so the test owns its state. Without this, prior tests' usage bleeds into the daily-bytes counter.""" import app.api.v2_quota as q monkeypatch.setattr(q, "_quota_singleton", None, raising=False) return q @pytest.fixture def mock_dry_run(monkeypatch): state = {"bytes": 1024} def fake(*args, **kwargs): return state["bytes"] monkeypatch.setattr("app.api.query._bq_dry_run_bytes", fake, raising=False) return state def test_query_records_bytes_against_shared_quota(seeded_app, fresh_quota, mock_dry_run): """A successful BQ-touching query bumps the user's daily-byte counter on the SAME singleton tracker that /api/v2/scan uses โ€” so a user who has consumed daily budget via /api/v2/scan can't dodge the cap by routing through /api/query.""" _register_bq_remote_row("ue", "finance", "ue") mock_dry_run["bytes"] = 4096 # 4 KiB c = seeded_app["client"] token = seeded_app["admin_token"] # Pre-flight: tracker has zero usage for this user. tracker = fresh_quota._build_quota_tracker() user_id = "admin@test.com" # email-keyed per parity with /api/v2/scan (#168 review) # seeded_app's admin user id before = tracker.bytes_used_today(user_id) r = c.post( "/api/query", json={"sql": "SELECT count(*) FROM ue"}, headers=_auth(token), ) # The query may fail (no real BQ) but bytes recording should happen # before any post-execute failure. Accept either 200 or 400; what # matters is the byte counter advanced. after = tracker.bytes_used_today(user_id) if r.status_code == 200: assert after - before >= 4096, \ f"successful BQ-touching query must record bytes; before={before} after={after}" def test_query_pre_flight_rejects_user_over_daily_cap(seeded_app, fresh_quota, mock_dry_run): """If the user is already over their daily byte cap on the shared tracker, /api/query rejects 429 BEFORE running the dry-run โ€” no free BQ work for over-cap users via this back door.""" _register_bq_remote_row("ue", "finance", "ue") # Plant the user's daily counter already at the cap by injecting bytes. tracker = fresh_quota._build_quota_tracker() user_id = "admin@test.com" # email-keyed per parity with /api/v2/scan (#168 review) # Push counter past the cap (default 50 GiB). tracker.record_bytes(user_id, tracker._max_daily_bytes + 1) c = seeded_app["client"] token = seeded_app["admin_token"] r = c.post( "/api/query", json={"sql": "SELECT count(*) FROM ue"}, headers=_auth(token), ) assert r.status_code == 429, r.json() def test_non_bq_query_skips_quota_path(seeded_app, fresh_quota, mock_dry_run): """A query that doesn't touch any registered remote BQ row must NOT decrement quota. Quota wiring runs only when dry_run_set is non-empty.""" tracker = fresh_quota._build_quota_tracker() user_id = "admin@test.com" # email-keyed per parity with /api/v2/scan (#168 review) before = tracker.bytes_used_today(user_id) c = seeded_app["client"] token = seeded_app["admin_token"] r = c.post( "/api/query", json={"sql": "SELECT 1 AS x"}, headers=_auth(token), ) after = tracker.bytes_used_today(user_id) assert after == before, \ f"non-BQ query must not record bytes; before={before} after={after}"