mirror of
https://github.com/computerim/impactflow-discovery.git
synced 2026-08-27 06:00:35 +00:00
Complete Phase 1: goals, cookie auth, profile editing
Close the remaining Phase 1 DoD gaps and reconcile the browser flow with the auth layer. Goals (5 -> 7 prompts): - Add near-term (6-12mo) and long-term (3-5yr) goal prompts; collect raw text on the conversation and store AI-articulated goal summaries on the profile. Extractor articulates the person's own stated goals (mirror, not compass) and never fabricates. Alembic 003 adds the four columns. Cookie-based browser sessions (fixes frontend<->auth desync): - OAuth callback now sets httpOnly session cookies and redirects into the app instead of returning JSON. get_current_user gains a cookie fallback (X-API-Key -> Bearer -> cookie). refresh/logout read the refresh cookie and set/clear cookies. New shared auth.js (authedFetch) sends cookies and silently refreshes on 401. Static pages drop the bogus user_id and call the correct /me endpoints. Profile editing (read/edit/affirm): - PATCH /discovery/profile/me edits the prose (Ikigai summaries, overlap narrative, goals); owner-scoped, partial update, 409 when locked. Edit mode in profile.html with Save/Cancel. Also: bump default model to claude-sonnet-4-6, align ports to 8011 (OAuth redirect, CORS), add COOKIE_SECURE/POST_LOGIN_REDIRECT config, and refresh the README to match the shipped behavior. Tests: 33 passing (added cookie-auth, profile-edit, goal-extraction cases; factored a shared app_client fixture into conftest.py). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
"""Shared test fixtures.
|
||||
|
||||
`app_client` spins up the FastAPI app against an isolated temp SQLite DB with
|
||||
known auth secrets, so any test can exercise the real routes over httpx
|
||||
without touching the developer's database.
|
||||
"""
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def app_client(tmp_path, monkeypatch):
|
||||
db_path = tmp_path / "test.db"
|
||||
monkeypatch.setenv("DATABASE_URL", f"sqlite+aiosqlite:///{db_path}")
|
||||
monkeypatch.setenv("JWT_SECRET", "test-jwt-secret")
|
||||
monkeypatch.setenv("IMPACTFLOW_API_KEY", "test-api-key")
|
||||
monkeypatch.setenv("GOOGLE_CLIENT_ID", "fake-client-id")
|
||||
monkeypatch.setenv("GOOGLE_CLIENT_SECRET", "fake-client-secret")
|
||||
monkeypatch.setenv("ALLOWED_EMAIL_DOMAINS", "")
|
||||
# Plain http test transport: non-Secure cookies so the jar replays them.
|
||||
monkeypatch.setenv("COOKIE_SECURE", "false")
|
||||
|
||||
# Clear app modules so they re-read the patched env at import time.
|
||||
import importlib
|
||||
import sys
|
||||
|
||||
for mod in list(sys.modules):
|
||||
if mod.startswith("app"):
|
||||
del sys.modules[mod]
|
||||
|
||||
from app import database
|
||||
|
||||
importlib.reload(database)
|
||||
from app.database import Base, engine
|
||||
|
||||
from app.main import app
|
||||
|
||||
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 client:
|
||||
yield client
|
||||
+3
-1
@@ -3,5 +3,7 @@
|
||||
"friction": "A regional manager at my old job started quietly cutting hours for the warehouse crew right before the holidays so the numbers would look good for his bonus — guys with kids, guys who'd been there fifteen years. Everyone in the office knew and everyone kept their heads down. I couldn't do it. I pulled the timesheets, documented the pattern, and walked into the district director's office without an appointment and laid it on his desk. It got messy. The manager came after me, tried to make me look like the problem. I didn't back down once. The hours got restored. I'd do it again tomorrow. I can stomach a lot of things but I cannot stomach someone with power stepping on people who can't fight back.",
|
||||
"pull": "I'm always fixing something or building something with my hands. Right now it's rebuilding the deck, before that it was getting an old motorcycle running. If I sit still too long I get restless and irritable, like there's energy I have to burn off. I also can't stop strategizing — I'll be doing dishes and find myself working out how I'd reorganize my buddy's failing business, who he needs to fire, what's actually broken. People come to me when stuff is falling apart because I don't freeze, I just start moving and figuring it out.",
|
||||
"recognition": "After the warehouse thing, one of the older guys, Reuben, pulled me aside in the parking lot. He didn't say much — he's not a talker — but he shook my hand hard and said, 'You're the only one who actually did something. The rest of them just talked.' That stuck with me more than any award would have. I've been called reliable, called a leader, but what I really care about being known for is that when it counted, I stood between my people and the thing coming at them and I didn't move.",
|
||||
"future": "I'd build a trades training outfit for kids who got written off — the ones who aren't going to college and got treated like they're stupid their whole lives. Teach them welding, electrical, plumbing, real skills nobody can take from them, and teach them they don't have to take garbage from anybody once they're good at something. Give them backbone and a paycheck. I'd want it to grow into something across the whole state. Not a charity that pats people on the head — a place that makes them strong enough to never need rescuing."
|
||||
"future": "I'd build a trades training outfit for kids who got written off — the ones who aren't going to college and got treated like they're stupid their whole lives. Teach them welding, electrical, plumbing, real skills nobody can take from them, and teach them they don't have to take garbage from anybody once they're good at something. Give them backbone and a paycheck. I'd want it to grow into something across the whole state. Not a charity that pats people on the head — a place that makes them strong enough to never need rescuing.",
|
||||
"goals_short": "In the next year I want to run a pilot — get a real workshop space, line up the tools and a couple of working tradesmen to teach, and put a first cohort of eight or ten kids through a welding track from start to certification. I want proof it works before I talk to anybody about money.",
|
||||
"goals_long": "In three to five years I want this to be a real outfit with its own building, multiple trades, and graduates actually placed in jobs across the state. I want it self-sustaining, not living grant to grant, and known well enough that the kids everybody else wrote off come looking for us."
|
||||
}
|
||||
|
||||
+3
-1
@@ -3,5 +3,7 @@
|
||||
"friction": "My old team kept making the same expensive mistake — shipping features without any real understanding of why the last three had flopped. It bothered me at a level that's hard to describe: it felt almost reckless, like flying blind on purpose. I didn't storm anyone's office. Instead I went away and built a careful analysis — pulled two years of data, mapped the actual causes, modeled what a disciplined process would have caught. Then I wrote it up and laid out the options. I'd rather understand a problem completely and present the evidence than react in the moment. Watching people act confidently on bad assumptions is the thing that unsettles me most.",
|
||||
"pull": "Left to my own devices I read constantly and go down rabbit holes — lately it's been information theory and how power grids actually balance load second to second. I take systems apart to see how they really work. I keep a sprawling notes file of questions I want to chase down. I'll tell myself I'm going to relax and then look up three hours later having taught myself something completely unrelated to my job. I need a certain amount of solitude to think, and I guard it carefully.",
|
||||
"recognition": "The moment I think about is when a senior engineer I deeply respected — someone famously stingy with praise — read an analysis I'd done and just said, 'This is exactly right, and nobody else here would have caught it.' Being recognized specifically for the depth and rigor of my thinking, for seeing what others missed, meant more to me than any general 'good job.' I don't need to be liked by everyone. I want a few people whose judgment I trust to know that I actually understand things at a level most people don't bother to reach.",
|
||||
"future": "I'd build an independent research and tooling lab focused on making complex systems legible — taking things that are genuinely hard to understand, like energy markets or supply chains, and creating models and tools that let people actually see how they work and reason about them clearly. No hype, no pressure to ship before the thinking is done. Just a small group of sharp people with the time and resources to understand important things properly and hand that understanding to the people who need it."
|
||||
"future": "I'd build an independent research and tooling lab focused on making complex systems legible — taking things that are genuinely hard to understand, like energy markets or supply chains, and creating models and tools that let people actually see how they work and reason about them clearly. No hype, no pressure to ship before the thinking is done. Just a small group of sharp people with the time and resources to understand important things properly and hand that understanding to the people who need it.",
|
||||
"goals_short": "In the next 6 to 12 months I want to pick one genuinely hard system — probably grid load balancing — and build a working model and a small tool that makes it legible, rigorous enough that someone who actually works in the field tells me it's right.",
|
||||
"goals_long": "Over three to five years I want that to grow into a small independent lab with a few sharp people and enough funding that we can take the time to understand important systems properly instead of shipping before the thinking is done."
|
||||
}
|
||||
|
||||
+3
-1
@@ -3,5 +3,7 @@
|
||||
"friction": "At my last company I poured myself into a launch — late nights, smoothing over every conflict, basically holding the team together emotionally. When it went well, my manager stood up in the all-hands and credited two other people by name and never mentioned me once. I smiled and clapped and then went to the stairwell and cried, which I'm a little embarrassed to admit. It wasn't about the title. It was that I had given so much of myself and it was like I'd been invisible the whole time. I didn't make a scene. I just started, carefully, making sure the right people privately knew what I'd actually done. Being overlooked after I've given everything is the thing that wounds me most.",
|
||||
"pull": "In my free time I'm almost always thinking about people — who's drifting and needs a check-in text, who I could introduce to who, how to make my next dinner feel warm so nobody sits there feeling like an outsider. I curate playlists for specific friends based on what they're going through. I remember everyone's hard anniversaries. My partner teases me that I run a one-woman social infrastructure. Honestly I'd rather plan a gathering that makes ten people feel loved than do almost anything else.",
|
||||
"recognition": "A few years ago the people I'd mentored at work secretly got together and wrote me letters — like a whole bound little book of them — for my birthday. One of them wrote that I was 'the first person who ever made her feel like she belonged somewhere.' I still have it in my nightstand. Being recognized for being warm or generous means infinitely more to me than being recognized for being smart or productive. I want to be known as the person who made others feel they mattered.",
|
||||
"future": "I'd build a place — physical, not an app — where people who feel alone can come and actually be received. Newcomers to a city, people after a divorce, older folks whose friends have died. Part community center, part living room. I'd train hosts whose entire job is to notice the person standing awkwardly by the wall and go pull them in. I want to spend the rest of my life making belonging something people can count on instead of something they have to get lucky to find."
|
||||
"future": "I'd build a place — physical, not an app — where people who feel alone can come and actually be received. Newcomers to a city, people after a divorce, older folks whose friends have died. Part community center, part living room. I'd train hosts whose entire job is to notice the person standing awkwardly by the wall and go pull them in. I want to spend the rest of my life making belonging something people can count on instead of something they have to get lucky to find.",
|
||||
"goals_short": "Over the next several months I want to start small and real — host a monthly gathering for people new to the city, get maybe a dozen regulars who feel like they belong, and learn what actually makes someone walk back through the door a second time.",
|
||||
"goals_long": "In a few years I want a real gathering space of my own and a handful of trained hosts who know how to notice the person standing alone. I want it to be the place people in this city think of first when they feel adrift."
|
||||
}
|
||||
|
||||
+91
-43
@@ -1,48 +1,7 @@
|
||||
"""Tests for the auth module: dual-auth dependency, JWT issue/decode,
|
||||
domain allow-list. Uses a temp SQLite DB so it doesn't touch the real one.
|
||||
domain allow-list. The `app_client` fixture (tests/conftest.py) provides an
|
||||
isolated app + temp DB so these never touch the real database.
|
||||
"""
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def app_client(tmp_path, monkeypatch):
|
||||
"""Spin up a fresh app with an isolated DB and known auth secrets."""
|
||||
db_path = tmp_path / "auth_test.db"
|
||||
monkeypatch.setenv(
|
||||
"DATABASE_URL", f"sqlite+aiosqlite:///{db_path}"
|
||||
)
|
||||
monkeypatch.setenv("JWT_SECRET", "test-jwt-secret")
|
||||
monkeypatch.setenv("IMPACTFLOW_API_KEY", "test-api-key")
|
||||
monkeypatch.setenv("GOOGLE_CLIENT_ID", "fake-client-id")
|
||||
monkeypatch.setenv("GOOGLE_CLIENT_SECRET", "fake-client-secret")
|
||||
monkeypatch.setenv("ALLOWED_EMAIL_DOMAINS", "")
|
||||
|
||||
# Re-import in a way that picks up the patched env. The simplest way is
|
||||
# to clear modules that read env at import time.
|
||||
import importlib
|
||||
import sys
|
||||
|
||||
for mod in list(sys.modules):
|
||||
if mod.startswith("app"):
|
||||
del sys.modules[mod]
|
||||
|
||||
from app import database
|
||||
importlib.reload(database)
|
||||
from app.database import Base, engine
|
||||
|
||||
from app.main import app
|
||||
|
||||
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 client:
|
||||
yield client
|
||||
|
||||
|
||||
async def test_unauthenticated_request_returns_401(app_client):
|
||||
@@ -103,6 +62,95 @@ async def test_valid_jwt_authenticates(app_client, tmp_path):
|
||||
assert r.json()["role"] == "user"
|
||||
|
||||
|
||||
async def test_cookie_access_token_authenticates(app_client):
|
||||
"""A valid access token in the httpOnly cookie authenticates the browser."""
|
||||
from datetime import datetime, timezone
|
||||
from app.auth import create_access_token
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models import User
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
db.add(User(
|
||||
id="u-cookie",
|
||||
email="cookie@example.com",
|
||||
display_name="Cookie User",
|
||||
google_id="g-cookie",
|
||||
role="user",
|
||||
created_at=datetime.now(timezone.utc),
|
||||
))
|
||||
await db.commit()
|
||||
|
||||
token = create_access_token("u-cookie", "cookie@example.com")
|
||||
r = await app_client.get("/api/me", cookies={"access_token": token})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["email"] == "cookie@example.com"
|
||||
|
||||
|
||||
async def test_refresh_via_cookie_sets_new_access_cookie(app_client):
|
||||
"""POST /api/auth/refresh with only the refresh cookie mints a new access
|
||||
token and writes it back as a cookie (no JSON body required)."""
|
||||
from datetime import datetime, timezone
|
||||
from app.auth import issue_refresh_token
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models import User
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
user = User(
|
||||
id="u-refresh",
|
||||
email="refresh@example.com",
|
||||
display_name="Refresh User",
|
||||
google_id="g-refresh",
|
||||
role="user",
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
raw_refresh = await issue_refresh_token(db, user, "pytest")
|
||||
|
||||
r = await app_client.post(
|
||||
"/api/auth/refresh", cookies={"refresh_token": raw_refresh}
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["access_token"]
|
||||
# The response re-sets the access cookie, and it actually authenticates.
|
||||
new_access = r.cookies.get("access_token")
|
||||
assert new_access
|
||||
me = await app_client.get("/api/me", cookies={"access_token": new_access})
|
||||
assert me.status_code == 200
|
||||
assert me.json()["email"] == "refresh@example.com"
|
||||
|
||||
|
||||
async def test_logout_revokes_refresh_token(app_client):
|
||||
"""Logout via the refresh cookie revokes it, so a later refresh fails."""
|
||||
from datetime import datetime, timezone
|
||||
from app.auth import issue_refresh_token
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models import User
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
user = User(
|
||||
id="u-logout",
|
||||
email="logout@example.com",
|
||||
display_name="Logout User",
|
||||
google_id="g-logout",
|
||||
role="user",
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
raw_refresh = await issue_refresh_token(db, user, "pytest")
|
||||
|
||||
out = await app_client.post(
|
||||
"/api/auth/logout", cookies={"refresh_token": raw_refresh}
|
||||
)
|
||||
assert out.status_code == 200
|
||||
|
||||
again = await app_client.post(
|
||||
"/api/auth/refresh", cookies={"refresh_token": raw_refresh}
|
||||
)
|
||||
assert again.status_code == 401
|
||||
|
||||
|
||||
async def test_oauth_login_redirects_to_google(app_client):
|
||||
r = await app_client.get(
|
||||
"/api/auth/login", follow_redirects=False
|
||||
|
||||
@@ -69,6 +69,15 @@ def make_profile_json(
|
||||
"meet. The work that fits you lets you move first and bring "
|
||||
"others with you."
|
||||
),
|
||||
"short_term_goals": (
|
||||
"Over the next year you want to get the training program off "
|
||||
"the ground and prove it works with a first cohort."
|
||||
),
|
||||
"long_term_goals": (
|
||||
"Within five years you see this grown into something "
|
||||
"statewide that makes people strong enough to never need "
|
||||
"rescuing."
|
||||
),
|
||||
"confidence": {
|
||||
"triad": "high",
|
||||
"type": "medium",
|
||||
@@ -162,6 +171,38 @@ async def test_overlap_narrative_present():
|
||||
assert result["overlap_narrative"].strip() != ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_goal_fields_extracted():
|
||||
"""Both goal horizons are returned and the goal prompts reach the model."""
|
||||
responses = load_fixture("gut_type_responses.json")
|
||||
extractor = make_extractor([make_profile_json("gut", 8, 9)])
|
||||
|
||||
result = await extractor.extract(responses)
|
||||
|
||||
assert result["short_term_goals"].strip() != ""
|
||||
assert result["long_term_goals"].strip() != ""
|
||||
# the goal responses were labelled and included in the prompt
|
||||
sent = extractor.client.messages.calls[0]["messages"][0]["content"]
|
||||
assert "Near-Term Goals (6–12 months)" in sent
|
||||
assert "Long-Term Goals (3–5 years)" in sent
|
||||
assert "first cohort" in sent # short-term goal text from the fixture
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_goal_keys_trigger_retry():
|
||||
"""A profile lacking the goal fields is treated as malformed (one retry)."""
|
||||
incomplete = json.loads(make_profile_json("head", 5, 6))
|
||||
del incomplete["short_term_goals"]
|
||||
del incomplete["long_term_goals"]
|
||||
valid = make_profile_json("head", 5, 6)
|
||||
extractor = make_extractor([json.dumps(incomplete), valid])
|
||||
|
||||
result = await extractor.extract(load_fixture("head_type_responses.json"))
|
||||
|
||||
assert result["short_term_goals"].strip() != ""
|
||||
assert len(extractor.client.messages.calls) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confidence_flags_present():
|
||||
extractor = make_extractor([make_profile_json("heart", 3, 2)])
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Tests for PATCH /discovery/profile/me (profile editing).
|
||||
|
||||
These run under the X-API-Key admin identity, editing a profile seeded for
|
||||
that user, so they don't depend on the Google OAuth flow.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
API_KEY = {"X-API-Key": "test-api-key"}
|
||||
|
||||
|
||||
async def _seed_profile(locked: bool = False) -> str:
|
||||
"""Insert a conversation + profile for the API-key admin user."""
|
||||
from app.auth import API_KEY_ADMIN_ID, ensure_api_key_admin
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models import DiscoveryConversation, DiscoveryProfile
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await ensure_api_key_admin(db)
|
||||
conv = DiscoveryConversation(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=API_KEY_ADMIN_ID,
|
||||
started_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(conv)
|
||||
await db.commit()
|
||||
|
||||
profile = DiscoveryProfile(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=API_KEY_ADMIN_ID,
|
||||
conversation_id=conv.id,
|
||||
generated_at=datetime.now(timezone.utc),
|
||||
triad="gut",
|
||||
probable_type=8,
|
||||
wing=9,
|
||||
instinctual_variant="sp",
|
||||
love_summary="orig love",
|
||||
strength_summary="orig strength",
|
||||
mission_summary="orig mission",
|
||||
vocation_summary="orig vocation",
|
||||
overlap_narrative="orig narrative",
|
||||
short_term_goals="orig short",
|
||||
long_term_goals="orig long",
|
||||
locked=locked,
|
||||
)
|
||||
db.add(profile)
|
||||
await db.commit()
|
||||
return profile.id
|
||||
|
||||
|
||||
async def test_patch_updates_prose(app_client):
|
||||
await _seed_profile()
|
||||
r = await app_client.patch(
|
||||
"/discovery/profile/me",
|
||||
headers=API_KEY,
|
||||
json={"love_summary": "new love", "overlap_narrative": "new narrative"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["love_summary"] == "new love"
|
||||
assert body["overlap_narrative"] == "new narrative"
|
||||
# Untouched prose preserved; structural inference is never editable here.
|
||||
assert body["strength_summary"] == "orig strength"
|
||||
assert body["triad"] == "gut"
|
||||
assert body["probable_type"] == 8
|
||||
|
||||
|
||||
async def test_patch_partial_does_not_clear_other_fields(app_client):
|
||||
await _seed_profile()
|
||||
r = await app_client.patch(
|
||||
"/discovery/profile/me",
|
||||
headers=API_KEY,
|
||||
json={"short_term_goals": "updated goal"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["short_term_goals"] == "updated goal"
|
||||
assert body["long_term_goals"] == "orig long"
|
||||
|
||||
|
||||
async def test_patch_locked_profile_returns_409(app_client):
|
||||
await _seed_profile(locked=True)
|
||||
r = await app_client.patch(
|
||||
"/discovery/profile/me",
|
||||
headers=API_KEY,
|
||||
json={"love_summary": "x"},
|
||||
)
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
async def test_patch_without_profile_returns_404(app_client):
|
||||
r = await app_client.patch(
|
||||
"/discovery/profile/me",
|
||||
headers=API_KEY,
|
||||
json={"love_summary": "x"},
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
async def test_patch_empty_payload_returns_400(app_client):
|
||||
await _seed_profile()
|
||||
r = await app_client.patch(
|
||||
"/discovery/profile/me", headers=API_KEY, json={}
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
async def test_patch_requires_auth(app_client):
|
||||
r = await app_client.patch(
|
||||
"/discovery/profile/me", json={"love_summary": "x"}
|
||||
)
|
||||
assert r.status_code == 401
|
||||
@@ -1,9 +1,41 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_discovery_page_has_insecure_context_uuid_fallback():
|
||||
def test_discovery_page_uses_cookie_session_not_user_id():
|
||||
"""The browser session is a server-set cookie, so the page must not mint
|
||||
or carry a client-side user id, and all calls go through authedFetch."""
|
||||
html = Path("app/static/discovery.html").read_text(encoding="utf-8")
|
||||
|
||||
assert "function createUserId()" in html
|
||||
assert "crypto.randomUUID()" not in html
|
||||
assert "getRandomValues" in html
|
||||
assert "impactflow_user_id" not in html
|
||||
assert "createUserId" not in html
|
||||
assert "user_id" not in html
|
||||
assert "authedFetch" in html
|
||||
assert '/static/auth.js' in html
|
||||
|
||||
|
||||
def test_profile_page_uses_me_endpoints_and_authed_fetch():
|
||||
"""Profile reads/confirms via the user-scoped /me routes, authenticated by
|
||||
the session cookie through authedFetch — not the old user_id-in-URL paths."""
|
||||
html = Path("app/static/profile.html").read_text(encoding="utf-8")
|
||||
|
||||
assert "/discovery/profile/me" in html
|
||||
assert "/discovery/profile/me/confirm" in html
|
||||
assert "user_id" not in html
|
||||
assert "authedFetch" in html
|
||||
|
||||
|
||||
def test_profile_page_has_edit_affordance():
|
||||
"""The read/edit/affirm DoD: an Edit control that PATCHes the profile."""
|
||||
html = Path("app/static/profile.html").read_text(encoding="utf-8")
|
||||
|
||||
assert "renderEdit" in html
|
||||
assert 'method: "PATCH"' in html
|
||||
assert "Edit my words" in html
|
||||
|
||||
|
||||
def test_auth_helper_sends_credentials_and_refreshes():
|
||||
js = Path("app/static/auth.js").read_text(encoding="utf-8")
|
||||
|
||||
assert 'credentials: "include"' in js
|
||||
assert "/api/auth/refresh" in js
|
||||
assert "/api/auth/login" in js
|
||||
|
||||
Reference in New Issue
Block a user