Fix: Throughput API v1 available - Details posted to Irving. POWERBI_KEY was missing from the .ENV file, so was not live.
Add: Editor now supports editing a mix's resolved formula directly, with % and kg dual entry on ingredient rows
Fix: Mix Editor should bring through correct ingredients. New resolved formula (same logic we use in Mix Calculator).
Fix: Security headers on all API responses (hardening)
Add: New mix button available on the Mix Editor.
Add: New ingredient button available on the Ingredient Editor
This commit is contained in:
2026-06-16 14:43:17 +12:00
parent 8f9a7b8193
commit 7db95e2027
46 changed files with 3805 additions and 1049 deletions
+111
View File
@@ -13,6 +13,8 @@ from app.schemas.editor import (
EditorIngredientRow,
EditorIngredientUpdate,
EditorMixFormulaRead,
EditorMixCreate,
EditorMixFormulaReplace,
EditorMixIngredientCreate,
EditorMixIngredientUpdate,
EditorMixRow,
@@ -22,9 +24,11 @@ from app.schemas.editor import (
EditorProductIngredientUpdate,
EditorProductRow,
EditorProductUpdate,
EditorResolvedMixFormula,
)
from app.services.client_access_service import has_access_level
from app.services.costing_engine import calculate_raw_material_cost, get_active_price
from app.services.mix_calculator_service import resolve_editor_mix_formula, resolve_representative_product
router = APIRouter(prefix="/api/editor", tags=["editor"])
@@ -250,6 +254,25 @@ def list_editor_mixes(
]
@router.post("/mixes", response_model=EditorMixRow, status_code=201)
def create_editor_mix(
payload: EditorMixCreate,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
mix = Mix(
tenant_id=session.tenant_id or "",
client_name=payload.client_name.strip(),
name=payload.name.strip(),
notes=payload.notes,
)
db.add(mix)
db.commit()
db.refresh(mix)
# A brand-new mix has no products yet, so it reads as Inactive (no visible products).
return _serialize_mix_row(mix, visible_count=0, product_count=0)
@router.patch("/mixes/{mix_id}", response_model=EditorMixRow)
def update_editor_mix(
mix_id: int,
@@ -377,6 +400,94 @@ def delete_editor_mix_ingredient(
return _serialize_mix_formula(mix)
@router.get("/mixes/{mix_id}/formula", response_model=EditorResolvedMixFormula)
def get_editor_mix_resolved_formula(
mix_id: int,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
"""The mix formula as the Mix Calculator reads it (product-first resolution).
This is what the Mix Editor displays, so the two surfaces show identical
ingredients and quantities. See `resolve_editor_mix_formula`.
"""
tenant_id = session.tenant_id or ""
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=tenant_id)
if mix is None:
raise HTTPException(status_code=404, detail="Mix not found")
return resolve_editor_mix_formula(db, tenant_id=tenant_id, mix=mix)
@router.put("/mixes/{mix_id}/formula", response_model=EditorResolvedMixFormula)
def replace_editor_mix_formula(
mix_id: int,
payload: EditorMixFormulaReplace,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
"""Replace a mix's whole formula in one save.
Writes back to the *same source* the Mix Calculator reads: the representative
product's own formula (`ProductIngredient`) when it has one, otherwise the
shared mix master (`MixIngredient`). Either way the calculator immediately
reflects the edit.
"""
tenant_id = session.tenant_id or ""
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=tenant_id)
if mix is None:
raise HTTPException(status_code=404, detail="Mix not found")
raw_ids = [row.raw_material_id for row in payload.rows]
if len(set(raw_ids)) != len(raw_ids):
raise HTTPException(status_code=400, detail="Each raw material can only appear once in a mix")
existing_ids = set(
db.scalars(
select(RawMaterial.id).where(RawMaterial.tenant_id == tenant_id, RawMaterial.id.in_(raw_ids))
).all()
)
missing = [raw_id for raw_id in raw_ids if raw_id not in existing_ids]
if missing:
raise HTTPException(status_code=404, detail="Raw material not found")
product = resolve_representative_product(db, tenant_id=tenant_id, mix_id=mix_id)
if product is not None and product.ingredients:
# Replace the representative product's own formula.
for ingredient in list(product.ingredients):
db.delete(ingredient)
db.flush()
for sort_order, row in enumerate(payload.rows, start=1):
db.add(
ProductIngredient(
tenant_id=tenant_id,
product_id=product.id,
raw_material_id=row.raw_material_id,
quantity_kg=row.quantity_kg,
sort_order=sort_order,
notes=row.notes,
)
)
else:
# No product-specific formula in play: edit the shared mix master, which
# is what the calculator falls back to for this mix.
for ingredient in list(mix.ingredients):
db.delete(ingredient)
db.flush()
for row in payload.rows:
db.add(
MixIngredient(
tenant_id=tenant_id,
mix_id=mix.id,
raw_material_id=row.raw_material_id,
quantity_kg=row.quantity_kg,
notes=row.notes,
)
)
db.commit()
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=tenant_id)
return resolve_editor_mix_formula(db, tenant_id=tenant_id, mix=mix)
@router.get("/products/{product_id}/ingredients", response_model=EditorProductFormulaRead)
def get_editor_product_ingredients(
product_id: int,
+78
View File
@@ -0,0 +1,78 @@
"""Read-only external data API (`/api/v1`).
A deliberately simple, API-key authenticated surface for Power BI (and any other
external reporting tool). It is intentionally separate from the cookie/JWT
session model used by the operator frontend: external tools cannot hold a
browser session, so they present a single static key instead.
Authentication: send the key either as an ``X-API-Key`` request header or an
``api_key`` query-string parameter (Power BI's Web connector supports both).
The key is configured via the ``POWERBI_API_KEY`` environment variable; when it
is blank the whole API is disabled and every request returns 503.
"""
from __future__ import annotations
import secrets
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.security_logging import log_security_event
from app.db.session import get_db
from app.models.throughput import ProductionThroughput
from app.services.throughput_service import serialize_entry
router = APIRouter(prefix="/api/v1", tags=["public-v1"])
_API_KEY_HEADER = "X-API-Key"
def require_powerbi_api_key(request: Request) -> str:
"""Authorize an external request via the static Power BI API key.
Returns the tenant the caller may read. Raises 503 when the API is not
configured, or 401 when the key is missing/incorrect.
"""
configured = settings.powerbi_api_key
if not configured:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="The data API is not configured.",
)
presented = request.headers.get(_API_KEY_HEADER) or request.query_params.get("api_key") or ""
# Constant-time comparison so the endpoint does not leak key length/contents
# through response timing.
if not presented or not secrets.compare_digest(presented, configured):
log_security_event("authz.denied", role="powerbi", reason="invalid_api_key")
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or missing API key")
return settings.powerbi_tenant_id
@router.get("/throughput")
def list_throughput(
date_from: date | None = Query(default=None, description="Only entries on/after this production date (YYYY-MM-DD)."),
date_to: date | None = Query(default=None, description="Only entries on/before this production date (YYYY-MM-DD)."),
limit: int = Query(default=5000, ge=1, le=50000),
tenant_id: str = Depends(require_powerbi_api_key),
db: Session = Depends(get_db),
):
"""Flat list of production throughput entries for Power BI.
One row per packing run, oldest first so incremental refreshes append
naturally. Each row carries the same fields the operator UI shows
(date, product, bag size, quantity, calculated kg, QA flags, staff, notes).
"""
stmt = select(ProductionThroughput).where(ProductionThroughput.tenant_id == tenant_id)
if date_from is not None:
stmt = stmt.where(ProductionThroughput.production_date >= date_from)
if date_to is not None:
stmt = stmt.where(ProductionThroughput.production_date <= date_to)
stmt = stmt.order_by(ProductionThroughput.production_date.asc(), ProductionThroughput.id.asc()).limit(limit)
return [serialize_entry(entry) for entry in db.scalars(stmt).all()]
+8
View File
@@ -58,6 +58,12 @@ class Settings:
login_rate_limit_window_seconds: int
trusted_hosts: tuple[str, ...]
docs_enabled: bool
# Static API key for the read-only Power BI / external data API (`/api/v1`).
# Blank disables the API entirely (every request returns 503).
powerbi_api_key: str
# Tenant the Power BI API reads from. Defaults to the costing client tenant
# (where internal staff store throughput), so a single key serves Irwin.
powerbi_tenant_id: str
@classmethod
def from_env(cls) -> "Settings":
@@ -98,6 +104,8 @@ class Settings:
login_rate_limit_window_seconds=int(os.getenv("LOGIN_RATE_LIMIT_WINDOW_SECONDS", "300")),
trusted_hosts=_parse_csv_env(os.getenv("TRUSTED_HOSTS", "localhost,127.0.0.1,testserver")),
docs_enabled=_env_flag("DOCS_ENABLED", default=os.getenv("APP_ENV", os.getenv("ENVIRONMENT", "development")).lower() != "production"),
powerbi_api_key=os.getenv("POWERBI_API_KEY", "").strip(),
powerbi_tenant_id=os.getenv("POWERBI_TENANT_ID", os.getenv("CLIENT_TENANT_ID", "hunter-premium-produce")).strip(),
)
settings._validate()
return settings
+8
View File
@@ -30,6 +30,7 @@ from app.api.ordering_admin import router as ordering_admin_router
from app.api.powerbi import router as powerbi_router
from app.api.product_costing import router as product_costing_router
from app.api.products import router as products_router
from app.api.public_v1 import router as public_v1_router
from app.api.raw_materials import router as raw_materials_router
from app.api.scenarios import router as scenarios_router
from app.api.throughput import router as throughput_router
@@ -209,6 +210,7 @@ app.include_router(throughput_router)
app.include_router(ordering_router)
app.include_router(ordering_admin_router)
app.include_router(powerbi_router)
app.include_router(public_v1_router)
@app.middleware("http")
@@ -270,6 +272,11 @@ async def enforce_request_limits_and_csrf(request: Request, call_next):
"script-src 'self'; "
"font-src 'self' data:; "
"connect-src 'self'; "
# PDF previews/printing load a same-origin blob: URL into an iframe.
# Without an explicit frame-src/child-src these fall back to default-src
# ('self'), which blocks blob: and breaks the in-app print dialog.
"frame-src 'self' blob:; "
"child-src 'self' blob:; "
"frame-ancestors 'self'; "
"base-uri 'self'; "
"form-action 'self'"
@@ -315,6 +322,7 @@ def root():
"scenarios": "/api/scenarios",
"operations_throughput": "/api/throughput",
"client_access": "/api/client-access",
"powerbi_throughput": "/api/v1/throughput",
"docs": "/docs",
},
}
+57
View File
@@ -32,6 +32,14 @@ class EditorProductUpdate(BaseModel):
notes: str | None = Field(default=None, max_length=2000)
class EditorMixCreate(BaseModel):
model_config = ConfigDict(extra="forbid")
client_name: str = Field(min_length=1, max_length=255)
name: str = Field(min_length=1, max_length=255)
notes: str | None = Field(default=None, max_length=2000)
class EditorMixUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -71,6 +79,55 @@ class EditorMixFormulaRead(BaseModel):
total_kg: float
class EditorResolvedMixIngredient(BaseModel):
raw_material_id: int
raw_material_name: str
quantity_kg: float
# This row's share of the mix total, matching the Mix Calculator.
mix_percentage: float
unit: str
notes: str | None
class EditorResolvedMixFormula(BaseModel):
"""A mix formula resolved the way the Mix Calculator reads it.
`source` is `product` when the numbers come from a representative product's
own formula, or `mix` when they come from the shared mix master fallback.
The PUT endpoint writes back to whichever source produced these rows.
"""
id: int
tenant_id: str
client_name: str
name: str
source: str
product_id: int | None
ingredients: list[EditorResolvedMixIngredient]
total_kg: float
class EditorMixFormulaRowInput(BaseModel):
model_config = ConfigDict(extra="forbid")
raw_material_id: int
quantity_kg: float = Field(gt=0)
notes: str | None = Field(default=None, max_length=1000)
class EditorMixFormulaReplace(BaseModel):
"""Full replacement of a mix's formula in one save.
The frontend keeps kilograms as the canonical value (percentages are an
entry aid that resolve back to kg against the total), so the API only needs
the resolved kg per row.
"""
model_config = ConfigDict(extra="forbid")
rows: list[EditorMixFormulaRowInput] = Field(min_length=1)
class EditorMixIngredientCreate(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -93,6 +93,96 @@ def _mix_calculator_option_rank(product: Product) -> tuple[int, int, float, int]
)
def resolve_representative_product(db: Session, *, tenant_id: str, mix_id: int) -> Product | None:
"""The single product the Mix Calculator surfaces for a given mix.
The calculator lists one representative product per (client, mix) and reads
its formula. The Mix Editor reuses this so it edits exactly what the
calculator shows. Preference order mirrors `build_mix_calculator_options`:
visible products that already have a product-specific formula, ranked by
`_mix_calculator_option_rank`; then any visible product; then any product.
"""
products = db.scalars(
select(Product)
.where(Product.tenant_id == tenant_id, Product.mix_id == mix_id)
.options(
selectinload(Product.ingredients).selectinload(ProductIngredient.raw_material),
selectinload(Product.mix).selectinload(Mix.ingredients).selectinload(MixIngredient.raw_material),
)
).all()
if not products:
return None
with_formula = [product for product in products if product.visible and product.ingredients]
pool = with_formula or [product for product in products if product.visible] or list(products)
return min(pool, key=_mix_calculator_option_rank)
def resolve_editor_mix_formula(db: Session, *, tenant_id: str, mix: Mix) -> dict:
"""Resolve a mix's formula the way the calculator does, for the editor.
Returns the resolved ingredient rows (with each row's share of the total as
`mix_percentage`), the total kg, and where the formula lives:
`source='product'` (a representative product's own formula) or `source='mix'`
(the shared mix master fallback). `product_id` names the product that owns the
formula when `source='product'`. The save path writes back to that same source.
"""
product = resolve_representative_product(db, tenant_id=tenant_id, mix_id=mix.id)
if product is not None and product.ingredients:
rows, total_kg = _resolved_formula_rows(product)
# Carry each ingredient's note through so saving doesn't wipe it.
notes_by_raw_material = {
ingredient.raw_material_id: ingredient.notes for ingredient in product.ingredients
}
for row in rows:
row["notes"] = notes_by_raw_material.get(row["raw_material_id"])
source = "product"
product_id = product.id
else:
# No product-specific formula: the calculator reads the shared mix master,
# so the editor shows and edits that.
rows = [
{
"raw_material_id": ingredient.raw_material_id,
"raw_material_name": ingredient.raw_material.name
if ingredient.raw_material is not None
else f"Raw material {ingredient.raw_material_id}",
"quantity_kg": ingredient.quantity_kg,
"unit": ingredient.raw_material.unit_of_measure if ingredient.raw_material is not None else "kg",
"sort_order": index,
"notes": ingredient.notes,
}
for index, ingredient in enumerate(
sorted(mix.ingredients, key=lambda item: item.raw_material.name if item.raw_material else ""),
start=1,
)
]
total_kg = round(sum(row["quantity_kg"] for row in rows), 4)
source = "mix"
product_id = product.id if product is not None else None
ingredients = [
{
"raw_material_id": row["raw_material_id"],
"raw_material_name": row["raw_material_name"],
"quantity_kg": round(row["quantity_kg"], 4),
"mix_percentage": round((row["quantity_kg"] / total_kg) * 100, 4) if total_kg > 0 else 0.0,
"unit": row["unit"],
"notes": row.get("notes"),
}
for row in rows
]
return {
"id": mix.id,
"tenant_id": mix.tenant_id,
"client_name": mix.client_name,
"name": mix.name,
"source": source,
"product_id": product_id,
"ingredients": ingredients,
"total_kg": total_kg,
}
def calculate_mix_calculator_preview(
db: Session,
*,
+102
View File
@@ -0,0 +1,102 @@
"""The Mix Editor must resolve and edit the SAME formula the Mix Calculator reads.
These cover `resolve_editor_mix_formula` / `resolve_representative_product`: a mix
whose product carries its own formula resolves to that product (not the shared
mix master), percentages are computed against the total, and the representative
product is chosen the way the calculator chooses it.
"""
from __future__ import annotations
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from app.db.session import Base
from app.models.mix import Mix, MixIngredient
from app.models.product import Product, ProductIngredient
from app.models.raw_material import RawMaterial
from app.services.mix_calculator_service import (
resolve_editor_mix_formula,
resolve_representative_product,
)
TENANT = "hunter-premium-produce"
def _session() -> Session:
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(bind=engine)
return sessionmaker(bind=engine, expire_on_commit=False)()
def _raw(db: Session, name: str) -> RawMaterial:
material = RawMaterial(tenant_id=TENANT, name=name, unit_of_measure="kg", kg_per_unit=1, status="active")
db.add(material)
db.flush()
return material
def test_resolves_product_formula_not_mix_master():
db = _session()
bayley = _raw(db, "Bayley")
filler = _raw(db, "Filler")
mix = Mix(tenant_id=TENANT, client_name="Hunter", name="Pigeon Mix")
db.add(mix)
db.flush()
# Shared mix master says something different from the product formula.
db.add(MixIngredient(tenant_id=TENANT, mix_id=mix.id, raw_material_id=bayley.id, quantity_kg=100))
db.add(MixIngredient(tenant_id=TENANT, mix_id=mix.id, raw_material_id=filler.id, quantity_kg=100))
product = Product(tenant_id=TENANT, client_name="Hunter", name="Pigeon 20kg", mix_id=mix.id, unit_of_measure="20kg bag", visible=True)
db.add(product)
db.flush()
# The calculator's real numbers live here: 787.5 / 1320.41 ~ 59.6%.
db.add(ProductIngredient(tenant_id=TENANT, product_id=product.id, raw_material_id=bayley.id, quantity_kg=787.5, sort_order=1))
db.add(ProductIngredient(tenant_id=TENANT, product_id=product.id, raw_material_id=filler.id, quantity_kg=532.91, sort_order=2))
db.commit()
formula = resolve_editor_mix_formula(db, tenant_id=TENANT, mix=mix)
assert formula["source"] == "product"
assert formula["product_id"] == product.id
assert formula["total_kg"] == 1320.41
by_name = {row["raw_material_name"]: row for row in formula["ingredients"]}
assert by_name["Bayley"]["quantity_kg"] == 787.5
# Percentage matches the worked example (787.5 / 1320.41 * 100).
assert abs(by_name["Bayley"]["mix_percentage"] - 59.6406) < 0.001
def test_falls_back_to_mix_master_when_no_product_formula():
db = _session()
maize = _raw(db, "Maize")
mix = Mix(tenant_id=TENANT, client_name="Hunter", name="Plain Mix")
db.add(mix)
db.flush()
db.add(MixIngredient(tenant_id=TENANT, mix_id=mix.id, raw_material_id=maize.id, quantity_kg=50))
db.commit()
formula = resolve_editor_mix_formula(db, tenant_id=TENANT, mix=mix)
assert formula["source"] == "mix"
assert formula["total_kg"] == 50
assert formula["ingredients"][0]["mix_percentage"] == 100.0
def test_representative_product_prefers_20kg_bag():
db = _session()
maize = _raw(db, "Maize")
mix = Mix(tenant_id=TENANT, client_name="Hunter", name="Dual Mix")
db.add(mix)
db.flush()
bulka = Product(tenant_id=TENANT, client_name="Hunter", name="Dual Bulka", mix_id=mix.id, unit_of_measure="500kg bulka", visible=True)
bag = Product(tenant_id=TENANT, client_name="Hunter", name="Dual 20kg", mix_id=mix.id, unit_of_measure="20kg bag", visible=True)
db.add_all([bulka, bag])
db.flush()
for product in (bulka, bag):
db.add(ProductIngredient(tenant_id=TENANT, product_id=product.id, raw_material_id=maize.id, quantity_kg=20, sort_order=1))
db.commit()
representative = resolve_representative_product(db, tenant_id=TENANT, mix_id=mix.id)
assert representative is not None
assert representative.unit_of_measure == "20kg bag"
+115
View File
@@ -0,0 +1,115 @@
"""Tests for the read-only external data API (`/api/v1`) used by Power BI.
Drives the real FastAPI app via TestClient against an in-memory database, and
swaps the configured API key in by replacing the module-level `settings` object
(the real one is a frozen dataclass).
"""
from __future__ import annotations
from dataclasses import replace
from datetime import date
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.api import public_v1
from app.core.config import settings
from app.db.session import Base, get_db
from app.main import app
from app.models.throughput import ProductionThroughput
API_KEY = "test-powerbi-key"
TENANT = "hunter-premium-produce"
def _seed_entry(db, *, tenant_id: str, product: str, quantity: float, when: date) -> None:
db.add(
ProductionThroughput(
tenant_id=tenant_id,
production_date=when,
product_name_snapshot=product,
bag_size=20,
quantity=quantity,
quantity_type="bags",
calculated_kg=quantity * 20,
created_by="test",
)
)
@pytest.fixture()
def db_factory():
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
TestingSession = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
def override_get_db():
db = TestingSession()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
yield TestingSession
app.dependency_overrides.clear()
def _make_client(monkeypatch, *, api_key: str) -> TestClient:
# Replace the whole settings object the router reads (frozen dataclass).
monkeypatch.setattr(public_v1, "settings", replace(settings, powerbi_api_key=api_key, powerbi_tenant_id=TENANT))
return TestClient(app)
def test_disabled_when_key_unset(monkeypatch, db_factory):
client = _make_client(monkeypatch, api_key="")
response = client.get("/api/v1/throughput", headers={"X-API-Key": "anything"})
assert response.status_code == 503
def test_rejects_missing_and_wrong_key(monkeypatch, db_factory):
client = _make_client(monkeypatch, api_key=API_KEY)
assert client.get("/api/v1/throughput").status_code == 401
assert client.get("/api/v1/throughput", headers={"X-API-Key": "nope"}).status_code == 401
def test_returns_entries_for_tenant(monkeypatch, db_factory):
db = db_factory()
_seed_entry(db, tenant_id=TENANT, product="Maize", quantity=10, when=date(2026, 1, 5))
_seed_entry(db, tenant_id=TENANT, product="Barley", quantity=5, when=date(2026, 1, 6))
# An entry in another tenant must never leak through.
_seed_entry(db, tenant_id="someone-else", product="Secret", quantity=99, when=date(2026, 1, 7))
db.commit()
db.close()
client = _make_client(monkeypatch, api_key=API_KEY)
# Header auth.
response = client.get("/api/v1/throughput", headers={"X-API-Key": API_KEY})
assert response.status_code == 200
rows = response.json()
names = [row["product_name_snapshot"] for row in rows]
assert names == ["Maize", "Barley"] # oldest first, other tenant excluded
assert rows[0]["calculated_kg"] == 200.0
# Query-string auth works too (Power BI Web connector convenience).
assert client.get(f"/api/v1/throughput?api_key={API_KEY}").status_code == 200
def test_date_filter(monkeypatch, db_factory):
db = db_factory()
_seed_entry(db, tenant_id=TENANT, product="Old", quantity=1, when=date(2026, 1, 1))
_seed_entry(db, tenant_id=TENANT, product="New", quantity=1, when=date(2026, 2, 1))
db.commit()
db.close()
client = _make_client(monkeypatch, api_key=API_KEY)
response = client.get("/api/v1/throughput?date_from=2026-01-15", headers={"X-API-Key": API_KEY})
assert [row["product_name_snapshot"] for row in response.json()] == ["New"]
+108
View File
@@ -0,0 +1,108 @@
"""Regression guards for the print/PDF Content-Security-Policy.
The in-app print dialog loads a generated PDF as a same-origin ``blob:`` URL into
an iframe and calls ``contentWindow.print()``. If the CSP omits ``frame-src`` /
``child-src`` for ``blob:`` the directive falls back to ``default-src 'self'``,
which silently blocks the frame and breaks printing for every user (regardless of
role). These tests pin the policy on both layers that emit it:
* the FastAPI security middleware (covers every API response), and
* the production nginx config (the source of the *document* CSP that actually
governs ``frame-src`` in the browser).
"""
from __future__ import annotations
import re
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from app.core.config import settings
from app.core.security import issue_token
from app.main import app
REPO_ROOT = Path(__file__).resolve().parents[2]
NGINX_CONFIGS = [REPO_ROOT / "deploy" / "nginx" / "clients.lean-101.conf"]
# Directives the print flow depends on. blob: must be framable, and that must not
# come at the cost of dropping the same-origin baseline.
REQUIRED_FRAME_SOURCES = {"'self'", "blob:"}
def _parse_csp(header: str) -> dict[str, set[str]]:
"""Parse a CSP header string into ``{directive: {sources}}``."""
directives: dict[str, set[str]] = {}
for part in header.split(";"):
tokens = part.split()
if not tokens:
continue
directives[tokens[0].lower()] = set(tokens[1:])
return directives
def _assert_blob_framing(header: str) -> None:
csp = _parse_csp(header)
# frame-src must exist and allow self + blob (no falling back to default-src).
assert "frame-src" in csp, f"frame-src missing from CSP: {header!r}"
assert REQUIRED_FRAME_SOURCES <= csp["frame-src"], (
f"frame-src must allow {REQUIRED_FRAME_SOURCES}, got {csp['frame-src']}"
)
# child-src is the Safari fallback for frame-src; keep it aligned.
assert "child-src" in csp, f"child-src missing from CSP: {header!r}"
assert "blob:" in csp["child-src"], f"child-src must allow blob:, got {csp['child-src']}"
# We only widened framing: the same-origin default must stay intact.
assert csp.get("default-src") == {"'self'"}, f"default-src weakened: {csp.get('default-src')}"
# --- Backend middleware policy ------------------------------------------------
@pytest.fixture()
def client() -> TestClient:
with TestClient(app) as test_client:
yield test_client
def test_backend_csp_allows_blob_frames(client: TestClient) -> None:
response = client.get("/health")
assert "content-security-policy" in response.headers
_assert_blob_framing(response.headers["content-security-policy"])
def test_backend_csp_present_for_all_users(client: TestClient) -> None:
"""The policy is identical for anonymous, authenticated, and rejected (401)
requests, so printing can never depend on who is signed in."""
admin_token = issue_token({"name": "Admin", "email": settings.admin_email, "role": "admin"})
responses = [
client.get("/health"), # anonymous
client.get("/api/access/me"), # the endpoint that 401s for warehouse users
client.get("/api/access/me", headers={"Authorization": f"Bearer {admin_token}"}),
]
policies = set()
for response in responses:
header = response.headers.get("content-security-policy")
assert header is not None, f"CSP missing on {response.request.url} ({response.status_code})"
_assert_blob_framing(header)
policies.add(header)
assert len(policies) == 1, "CSP must not vary by authentication state"
# --- Production document policy (nginx) ---------------------------------------
def test_nginx_csp_allows_blob_frames() -> None:
"""Every CSP the production nginx emits must allow blob framing. This guards
the *document* policy, which is what the browser enforces for the print iframe."""
csp_line = re.compile(r'Content-Security-Policy\s+"([^"]+)"', re.IGNORECASE)
for config_path in NGINX_CONFIGS:
assert config_path.exists(), f"missing nginx config: {config_path}"
text = config_path.read_text(encoding="utf-8")
policies = csp_line.findall(text)
assert policies, f"no Content-Security-Policy header found in {config_path}"
for policy in policies:
_assert_blob_framing(policy)