Files
impactflow_discovery/smoke_test.py
Joel Salmon b8f176bb31 Initial commit: ImpactFlow Discovery + Google OAuth auth layer
Discovery service (pre-existing): FastAPI + async SQLAlchemy + Alembic +
SQLite + Anthropic, with a five-prompt static UI that produces an Enneagram
+ Ikigai profile.

Auth implementation (this change set) follows
Impact_Flow_Auth_Plan_OAuth.html, adapted to the discovery_conversation /
discovery_profile schema:

- app/auth.py: Google OAuth registration, JWT issue/decode, dual-auth
  dependency (Bearer JWT or X-API-Key), refresh-token hashing, domain
  allow-list, synthetic api-key-admin user
- app/tracking.py: ActivityTrackingMiddleware + log_activity helper;
  tags machine-to-machine calls source=mcp
- app/routers/auth.py: /api/auth/{login,callback,refresh,logout},
  /api/me, /api/me/{stats,sessions,sessions/{id}}
- app/routers/activity.py: /api/activity, /api/activity/summary,
  /api/admin/activity, plus prune_old_activity (90-day retention)
- app/routers/discovery.py: every route now user-scoped via the auth
  dependency; /discovery/profile/{user_id} -> /discovery/profile/me
- alembic/versions/002_add_auth.py: users, refresh_tokens, activity_log
- tests/test_auth.py: 8 tests covering 401 paths, X-API-Key admin
  resolution, JWT round-trip, admin gating, domain allow-list
- README.md: Authentication section, expanded env-var table, updated
  data-model and API-reference tables
- .env.example: new GOOGLE_*, JWT_*, IMPACTFLOW_API_KEY, CORS_*,
  ALLOWED_EMAIL_DOMAINS placeholders
- .gitignore: also exclude data/*.log

Tests: 19/19 pass (11 pre-existing + 8 new). smoke_test.py exercises the
full discovery flow under X-API-Key plus 401 paths, OAuth login redirect,
activity logging, and /api/me/stats.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:59:41 -05:00

193 lines
6.8 KiB
Python

"""In-process smoke test of the full discovery flow.
Uses httpx ASGITransport to drive the FastAPI app without a network server,
and patches DiscoveryExtractor so no real Anthropic call is made.
Run: .venv/Scripts/python.exe smoke_test.py
"""
import asyncio
import os
os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///./data/smoke.db"
os.environ.setdefault("JWT_SECRET", "smoke-jwt-secret")
os.environ.setdefault("IMPACTFLOW_API_KEY", "smoke-api-key")
from httpx import ASGITransport, AsyncClient # noqa: E402
from app import models # noqa: E402,F401
from app.database import Base, engine # noqa: E402
import app.routers.discovery as disc # noqa: E402
class FakeExtractor:
def __init__(self, api_key, model="fake"):
pass
async def extract(self, responses):
assert "friction" in responses
return {
"triad": "gut",
"probable_type": 8,
"wing": 9,
"instinctual_variant": "sp",
"instinctual_stack": "sp/so/sx",
"love_summary": "You love hands-on, high-stakes work.",
"strength_summary": "You take charge and see the whole board.",
"mission_summary": "People need someone who will act and protect.",
"vocation_summary": "You can be paid to lead and build.",
"overlap_narrative": "You come alive where action meets protection.",
"confidence": {
"triad": "high",
"type": "medium",
"variant": "medium",
"ikigai": "high",
},
"extraction_notes": "",
}
disc.DiscoveryExtractor = FakeExtractor
from app.main import app # noqa: E402
AUTH = {"X-API-Key": os.environ["IMPACTFLOW_API_KEY"]}
async def main():
# Fresh DB every run so this is deterministic.
db_path = "./data/smoke.db"
if os.path.exists(db_path):
os.remove(db_path)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
transport = ASGITransport(app=app)
async with AsyncClient(
transport=transport, base_url="http://test"
) as c:
r = await c.get("/health")
assert r.status_code == 200 and r.json() == {"status": "ok"}, r.text
print("health:", r.json())
# Auth: unauthenticated calls must 401.
r = await c.get("/api/me")
assert r.status_code == 401, r.text
print("unauth /api/me -> 401 OK")
r = await c.post("/discovery/start")
assert r.status_code == 401, r.text
print("unauth /discovery/start -> 401 OK")
# Auth: X-API-Key resolves to the synthetic admin user.
r = await c.get("/api/me", headers=AUTH)
assert r.status_code == 200, r.text
me = r.json()
assert me["role"] == "admin"
assert me["email"] == "api-key@impactflow.local"
print("authed /api/me OK ->", me["display_name"])
# Bogus JWT must 401.
r = await c.get(
"/api/me",
headers={"Authorization": "Bearer not-a-real-jwt"},
)
assert r.status_code == 401, r.text
print("bogus JWT -> 401 OK")
# /api/auth/login should redirect to Google.
r = await c.get("/api/auth/login", follow_redirects=False)
assert r.status_code in (302, 303, 307), r.text
assert "accounts.google.com" in r.headers.get("location", "")
print("OAuth login redirects to:", r.headers["location"][:60], "...")
# Discovery flow under the API-key admin user.
r = await c.post("/discovery/start", headers=AUTH)
assert r.status_code == 200, r.text
conv_id = r.json()["conversation_id"]
print("start -> conversation_id:", conv_id)
body = {
"prompt_alive": "I felt alive leading a flood rescue.",
"prompt_friction": "I confronted a manager cutting workers' hours.",
"prompt_pull": "I'm always building and fixing things.",
"prompt_recognition": "Recognized for standing up for my crew.",
"prompt_future": "I'd build a trades school for written-off kids.",
}
r = await c.put(
f"/discovery/{conv_id}/respond", json=body, headers=AUTH
)
assert r.status_code == 200, r.text
assert r.json()["status"] == "responses_saved"
print("respond:", r.json())
r = await c.get(
f"/discovery/conversation/{conv_id}", headers=AUTH
)
assert r.status_code == 200, r.text
assert r.json()["prompt_friction"] == body["prompt_friction"]
print("conversation fetched, friction stored OK")
r = await c.post(f"/discovery/{conv_id}/complete", headers=AUTH)
assert r.status_code == 200, r.text
profile = r.json()
assert profile["triad"] == "gut"
assert profile["confidence"]["ikigai"] == "high"
assert profile["locked"] is False
print(
"complete -> profile triad:",
profile["triad"],
"locked:",
profile["locked"],
)
r = await c.get("/discovery/profile/me", headers=AUTH)
assert r.status_code == 200, r.text
assert r.json()["overlap_narrative"]
print("get my profile OK")
r = await c.put("/discovery/profile/me/confirm", headers=AUTH)
assert r.status_code == 200, r.text
assert r.json()["status"] == "locked"
print("confirm:", r.json())
r = await c.get("/discovery/profile/me", headers=AUTH)
assert r.json()["locked"] is True
print("profile now locked:", r.json()["locked"])
# 404 paths
r = await c.post("/discovery/does-not-exist/complete", headers=AUTH)
assert r.status_code == 404
print("404 handling OK")
# Activity tracking — at this point we've made several authed,
# 2xx requests, so the activity log should have entries.
r = await c.get("/api/activity?limit=50", headers=AUTH)
assert r.status_code == 200, r.text
events = r.json()
assert len(events) > 0, "expected activity log entries"
assert all(e["source"] == "mcp" for e in events), (
"API key requests must be tagged source=mcp"
)
print(f"activity feed has {len(events)} entries, all source=mcp OK")
r = await c.get("/api/activity/summary?days=7", headers=AUTH)
assert r.status_code == 200, r.text
summary = r.json()
assert summary["total"] > 0
assert summary["mcp_count"] > 0
print("activity summary:", summary["total"], "events")
r = await c.get("/api/me/stats", headers=AUTH)
assert r.status_code == 200, r.text
stats = r.json()
assert stats["conversations"] == 1
assert stats["profiles"] == 1
assert stats["locked_profiles"] == 1
print("me/stats:", stats)
print("\nALL SMOKE CHECKS PASSED")
if __name__ == "__main__":
asyncio.run(main())