Public Access
feat: phase r1+r2 recovery + r3-0 swiftly api ingestion
R1 stabilization: pytest harness with transactional db fixture, smoke + alembic + auth + scrape + approval + swiftly tests, github actions ci yaml. Bearer-token admin auth + signed-cookie session for family ui mutations. Async POST /api/admin/scrape (BackgroundTasks, returns 202). Path canonicalization (no /list, /planned suffixes). DATABASE_URL fail-fast on empty. R2 deferred-risk spikes: live lucky california fetch (R2-A), full email+per-voter approval click round trip with single-use enforcement (R2-B, console email backend, sendgrid stub). R3-0 phase 3 redesign: replaced playwright html scraper with requests based swiftly json api client. 17 categories, ~10k products per scrape, upsert by (source, external_id). 401 surfaces actionable token-refresh message via ScrapeLog.error_message. Pre-existing defects fixed: shopping_list.py syntax error blocking app import, MealPlan.votes orphan relationship, JSONB(astext=True) invalid kwarg, missing requests dep, calorie_target schema drift, every SQLEnum needed values_callable, 0001 had empty downgrade(), seed had duplicate ingredient rows. Migrations added: 0003 grocery_item.description, 0004 family_profile. calorie_target, 0005 grocery_item.external_id + source + composite index. Verified: 31/31 pytest green, alembic upgrade->downgrade->upgrade clean, frontend npm run build clean, live scrape 9,960 grocery_item rows in 36s. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
Auth gate tests for R1-B+D.
|
||||
|
||||
Covers:
|
||||
- /api/admin/* requires bearer token (401 without, not 401 with valid).
|
||||
- Mutating routes on family routers require a session cookie.
|
||||
- /api/auth/login + /api/auth/logout round-trip with the shared password.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# Configure auth secrets BEFORE app import. conftest.py runs first and sets
|
||||
# DATABASE_URL; we layer auth env on top here.
|
||||
os.environ.setdefault("ADMIN_TOKEN", "test-admin-token")
|
||||
os.environ.setdefault("SESSION_PASSWORD", "test-family-password")
|
||||
os.environ.setdefault("SECRET_KEY", "test-secret-key-do-not-use-in-prod")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reload_settings(monkeypatch):
|
||||
"""Force ``settings`` to re-read env (test isolation)."""
|
||||
monkeypatch.setenv("ADMIN_TOKEN", "test-admin-token")
|
||||
monkeypatch.setenv("SESSION_PASSWORD", "test-family-password")
|
||||
monkeypatch.setenv("SECRET_KEY", "test-secret-key-do-not-use-in-prod")
|
||||
# Re-instantiate the singleton so dependents pick up env.
|
||||
from app import config as app_config
|
||||
|
||||
app_config.settings = app_config.Settings()
|
||||
yield
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin bearer token
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.requires_postgres
|
||||
def test_admin_requires_token(client):
|
||||
"""No bearer → 401. Valid bearer → not 401 (handler runs)."""
|
||||
r = client.post("/api/admin/scrape")
|
||||
assert r.status_code == 401, r.text
|
||||
|
||||
r = client.post(
|
||||
"/api/admin/scrape",
|
||||
headers={"Authorization": "Bearer test-admin-token"},
|
||||
)
|
||||
# Handler may 200/202/500 (Playwright not installed in CI), but NOT 401.
|
||||
assert r.status_code != 401, r.text
|
||||
|
||||
|
||||
@pytest.mark.requires_postgres
|
||||
def test_admin_logs_requires_token(client):
|
||||
r = client.get("/api/admin/logs")
|
||||
assert r.status_code == 401
|
||||
|
||||
r = client.get(
|
||||
"/api/admin/logs", headers={"Authorization": "Bearer test-admin-token"}
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session-gated mutations
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.requires_postgres
|
||||
def test_session_required_for_mutation(client):
|
||||
"""POST /api/profile/members without cookie → 401."""
|
||||
r = client.post(
|
||||
"/api/profile/members",
|
||||
json={"name": "x", "email": "x@y.z", "role": "voter"},
|
||||
)
|
||||
assert r.status_code == 401, r.text
|
||||
|
||||
|
||||
@pytest.mark.requires_postgres
|
||||
def test_session_open_for_reads(client):
|
||||
"""GET /api/profile is NOT auth-gated (reads stay open)."""
|
||||
r = client.get("/api/profile")
|
||||
# Either 200 (profile exists) or 404 (no profile yet) — never 401.
|
||||
assert r.status_code in (200, 404), r.text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Login / logout
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.requires_postgres
|
||||
def test_session_login_logout(client):
|
||||
# Wrong password
|
||||
r = client.post("/api/auth/login", json={"password": "nope"})
|
||||
assert r.status_code == 401
|
||||
|
||||
# Right password sets the cookie
|
||||
r = client.post(
|
||||
"/api/auth/login", json={"password": "test-family-password"}
|
||||
)
|
||||
assert r.status_code == 204
|
||||
assert "mp_session" in r.cookies, r.headers
|
||||
|
||||
# With cookie, mutation succeeds (or fails for non-auth reasons)
|
||||
cookie_value = r.cookies.get("mp_session")
|
||||
client.cookies.set("mp_session", cookie_value)
|
||||
r2 = client.post(
|
||||
"/api/profile/members",
|
||||
json={"name": "x", "email": "test@example.com", "role": "voter"},
|
||||
)
|
||||
# 401 means the cookie was rejected — that's the bug we're guarding.
|
||||
assert r2.status_code != 401, r2.text
|
||||
|
||||
# Logout clears the cookie
|
||||
r3 = client.post("/api/auth/logout")
|
||||
assert r3.status_code == 204
|
||||
Reference in New Issue
Block a user