2727_TEST_PASSWORD_HASH = PasswordHasher ().hash (_TEST_PASSWORD )
2828
2929
30+ _OPERATOR_EMAIL = "operator@local"
31+
32+
3033@pytest_asyncio .fixture
3134async def https_client (db_conn , monkeypatch ):
3235 """Like the conftest `client` fixture but with `https://test` base_url
33- so Secure cookies survive the round-trip, plus a known
34- APP_PASSWORD_HASH so /api/auth/login can succeed."""
35- monkeypatch .setenv ("APP_PASSWORD_HASH" , _TEST_PASSWORD_HASH )
36+ so Secure cookies survive the round-trip. Seeds the operator's
37+ password_hash directly in the DB so /api/auth/login can succeed."""
38+ async with db_conn .connection () as conn , conn .cursor () as cur :
39+ await cur .execute (
40+ "UPDATE users SET password_hash = %s WHERE email = %s" ,
41+ (_TEST_PASSWORD_HASH , _OPERATOR_EMAIL ),
42+ )
3643 get_settings .cache_clear ()
3744 monkeypatch .setattr (db_module , "_pool" , db_conn )
3845
@@ -66,7 +73,7 @@ async def test_oauth_full_lifecycle(https_client, db_conn):
6673
6774 # 2. Login with the test password (no TOTP yet).
6875 login = await https_client .post (
69- "/api/auth/login" , json = {"password" : _TEST_PASSWORD }
76+ "/api/auth/login" , json = {"email" : _OPERATOR_EMAIL , " password" : _TEST_PASSWORD }
7077 )
7178 assert login .status_code == 200 , login .text
7279 assert https_client .cookies .get ("study_session" )
@@ -166,20 +173,20 @@ async def test_login_rate_limit_then_success(https_client, db_conn):
166173 # 5 failed attempts: each 401 with "invalid credentials".
167174 for _ in range (5 ):
168175 bad = await https_client .post (
169- "/api/auth/login" , json = {"password" : "wrong" }
176+ "/api/auth/login" , json = {"email" : _OPERATOR_EMAIL , " password" : "wrong" }
170177 )
171178 assert bad .status_code == 401 , bad .text
172179
173180 # 6th attempt: bucket is full → 429 BEFORE reaching verify_password.
174181 capped = await https_client .post (
175- "/api/auth/login" , json = {"password" : "wrong" }
182+ "/api/auth/login" , json = {"email" : _OPERATOR_EMAIL , " password" : "wrong" }
176183 )
177184 assert capped .status_code == 429 , capped .text
178185
179186 # Even a correct password is rejected once rate-limited (the limit is
180- # checked BEFORE verify_password ).
187+ # checked BEFORE verify_password_for_user ).
181188 still_capped = await https_client .post (
182- "/api/auth/login" , json = {"password" : _TEST_PASSWORD }
189+ "/api/auth/login" , json = {"email" : _OPERATOR_EMAIL , " password" : _TEST_PASSWORD }
183190 )
184191 assert still_capped .status_code == 429
185192
@@ -190,7 +197,7 @@ async def test_login_rate_limit_then_success(https_client, db_conn):
190197
191198 # Correct password now succeeds.
192199 ok = await https_client .post (
193- "/api/auth/login" , json = {"password" : _TEST_PASSWORD }
200+ "/api/auth/login" , json = {"email" : _OPERATOR_EMAIL , " password" : _TEST_PASSWORD }
194201 )
195202 assert ok .status_code == 200 , ok .text
196203 assert https_client .cookies .get ("study_session" )
@@ -202,9 +209,9 @@ async def test_login_rate_limit_then_success(https_client, db_conn):
202209
203210@pytest .mark .asyncio
204211async def test_totp_enroll_and_login (https_client , db_conn ):
205- # 1. Login with password only (TOTP not yet enabled).
212+ # 1. Login with email + password (TOTP not yet enabled).
206213 login = await https_client .post (
207- "/api/auth/login" , json = {"password" : _TEST_PASSWORD }
214+ "/api/auth/login" , json = {"email" : _OPERATOR_EMAIL , " password" : _TEST_PASSWORD }
208215 )
209216 assert login .status_code == 200 , login .text
210217
@@ -224,25 +231,26 @@ async def test_totp_enroll_and_login(https_client, db_conn):
224231 # Drop the cookie so subsequent /login attempts are unauthenticated.
225232 https_client .cookies .clear ()
226233
227- # 4. Password without code → 401 totp_required.
234+ # 4. Email + password without code → 401 totp_required.
228235 pw_only = await https_client .post (
229- "/api/auth/login" , json = {"password" : _TEST_PASSWORD }
236+ "/api/auth/login" , json = {"email" : _OPERATOR_EMAIL , " password" : _TEST_PASSWORD }
230237 )
231238 assert pw_only .status_code == 401 , pw_only .text
232239 assert pw_only .json ()["detail" ] == "totp_required"
233240
234- # 5. Password + wrong code → 401 invalid totp.
241+ # 5. Email + password + wrong code → 401 invalid totp.
235242 bad_code = await https_client .post (
236243 "/api/auth/login" ,
237- json = {"password" : _TEST_PASSWORD , "totp_code" : "000000" },
244+ json = {"email" : _OPERATOR_EMAIL , " password" : _TEST_PASSWORD , "totp_code" : "000000" },
238245 )
239246 assert bad_code .status_code == 401 , bad_code .text
240247 assert bad_code .json ()["detail" ] == "invalid totp code"
241248
242- # 6. Password + correct code → 200, cookie set.
249+ # 6. Email + password + correct code → 200, cookie set.
243250 good = await https_client .post (
244251 "/api/auth/login" ,
245252 json = {
253+ "email" : _OPERATOR_EMAIL ,
246254 "password" : _TEST_PASSWORD ,
247255 "totp_code" : pyotp .TOTP (secret ).now (),
248256 },
@@ -253,18 +261,9 @@ async def test_totp_enroll_and_login(https_client, db_conn):
253261
254262@pytest .mark .asyncio
255263async def test_login_with_email_and_password (https_client , db_conn ):
256- """The new email+password path works against an operator-seeded user."""
257- # Operator user is seeded by Phase 1 migration with NULL password_hash.
258- # Set a password hash directly via SQL (mimicking the seed script).
259- pw_hash = PasswordHasher ().hash (_TEST_PASSWORD )
260- async with db_conn .connection () as conn , conn .cursor () as cur :
261- await cur .execute (
262- "UPDATE users SET password_hash = %s WHERE email = 'operator@local'" ,
263- (pw_hash ,),
264- )
265- # Now login with email + password
264+ """Email+password login works; password_hash is seeded by the fixture."""
266265 resp = await https_client .post ("/api/auth/login" , json = {
267- "email" : "operator@local" ,
266+ "email" : _OPERATOR_EMAIL ,
268267 "password" : _TEST_PASSWORD ,
269268 })
270269 assert resp .status_code == 200 , resp .text
0 commit comments