diff --git a/l4d2web/routes/profile_routes.py b/l4d2web/routes/profile_routes.py index 3dacfb2..4875308 100644 --- a/l4d2web/routes/profile_routes.py +++ b/l4d2web/routes/profile_routes.py @@ -83,7 +83,9 @@ def profile_password_change() -> Response: if user is None or not verify_password(current_password, user.password_digest): return _redirect_with_error("wrong_current") user.password_digest = hash_password(new_password) - user.password_changed_at = now_utc() + # Strip tz so the marker matches what a subsequent DB read returns + # (SQLite DateTime columns don't preserve tzinfo). + user.password_changed_at = now_utc().replace(tzinfo=None) new_marker = user.password_changed_at.isoformat() session["pw_changed_at"] = new_marker diff --git a/l4d2web/tests/test_profile.py b/l4d2web/tests/test_profile.py index da1ea52..0189321 100644 --- a/l4d2web/tests/test_profile.py +++ b/l4d2web/tests/test_profile.py @@ -160,3 +160,74 @@ def test_post_password_wrong_current(app_and_user): assert response.status_code == 302 assert "/profile?error=wrong_current" in response.headers["Location"] assert _digest_of("alice") == before + + +def test_post_password_happy_path_rotates_and_keeps_current_session(app_and_user): + app, uid, marker = app_and_user + client = _logged_in_client(app, uid, marker) + before_digest = _digest_of("alice") + with session_scope() as db: + before_marker = db.scalar(select(User).where(User.id == uid)).password_changed_at + + response = _post_pw( + client, + current_password="currentpass", + new_password="newpass12", + confirm_new_password="newpass12", + ) + + assert response.status_code == 302 + assert response.headers["Location"].endswith("/profile?success=1") + + after_digest = _digest_of("alice") + assert after_digest != before_digest + with session_scope() as db: + u = db.scalar(select(User).where(User.id == uid)) + assert u.password_changed_at > before_marker + new_marker = u.password_changed_at.isoformat() + + follow = client.get("/dashboard") + assert follow.status_code == 200 + + with client.session_transaction() as sess: + assert sess["pw_changed_at"] == new_marker + + +def test_other_session_dies_after_password_change(app_and_user): + app, uid, marker = app_and_user + primary = _logged_in_client(app, uid, marker) + other = _logged_in_client(app, uid, marker) + + pre = other.get("/dashboard") + assert pre.status_code == 200 + + response = _post_pw( + primary, + current_password="currentpass", + new_password="newpass12", + confirm_new_password="newpass12", + ) + assert response.status_code == 302 + + post = other.get("/dashboard", follow_redirects=False) + assert post.status_code == 302 + assert "/login" in post.headers["Location"] + + +def test_new_password_works_for_login(app_and_user): + app, uid, marker = app_and_user + client = _logged_in_client(app, uid, marker) + _post_pw( + client, + current_password="currentpass", + new_password="newpass12", + confirm_new_password="newpass12", + ) + + fresh = app.test_client() + response = fresh.post( + "/login", + data={"username": "alice", "password": "newpass12"}, + ) + assert response.status_code == 302 + assert response.headers["Location"].endswith("/dashboard")