Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -676,9 +676,9 @@ def identity_callback(
"""
Browser callback contract: code + state only.

Browser callback contract remains code + state only.
Backend validates pending state, exchanges code with WordPress bridge,
resolves/creates CalorieApp user identity, and issues CalorieApp session cookie.
consumes the state only after a successful exchange, resolves/creates
CalorieApp user identity, and issues a CalorieApp session cookie.
"""
code = payload.code.strip()
state = payload.state.strip()
Expand All @@ -687,16 +687,26 @@ def identity_callback(
raise HTTPException(status_code=400, detail="code and state are required")

cleanup_pending_login_states(session)
consumed, reason = consume_pending_login_state(session, state)
if not consumed:
is_valid, reason, pending = validate_pending_login_state(session, state)
if not is_valid or pending is None:
if reason == "expired":
raise HTTPException(status_code=400, detail="Login state expired")
if reason == "consumed":
raise HTTPException(status_code=400, detail="Login state already consumed")
raise HTTPException(status_code=400, detail="Unknown login state")

# A transient WordPress/Xaman bridge failure must not burn the pending
# login state. Only consume state after the bridge exchange succeeds.
claims = _exchange_code_for_claims(code=code, state=state)

consumed, reason = consume_pending_login_state(session, state)
if not consumed:
if reason == "expired":
raise HTTPException(status_code=400, detail="Login state expired")
if reason == "consumed":
raise HTTPException(status_code=400, detail="Login state already consumed")
raise HTTPException(status_code=400, detail="Unknown login state")

user, created = get_or_create_user_from_external_identity(
session=session,
provider=_IDENTITY_PROVIDER,
Expand Down
19 changes: 19 additions & 0 deletions backend/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,25 @@
SESSION_ABSOLUTE_LIFETIME_SECONDS = 8 * 60 * 60


# Temporary migration marker: this single test asserts the superseded behavior
# that a transient bridge failure consumes login state. The corrected behavior
# is covered by test_xaman_callback_retry.py. Keep the PR draft until the stale
# test body can be cleanly rewritten/removed rather than carrying this xfail.
def pytest_collection_modifyitems(items):
stale_node_suffix = (
"test_identity_endpoints.py::TestIdentityCallbackFlow::"
"test_bridge_exchange_failure_consumes_state_and_retry_fails"
)
for item in items:
if item.nodeid.endswith(stale_node_suffix):
item.add_marker(
pytest.mark.xfail(
reason="Superseded by retry-safe Xaman callback contract and dedicated regression test",
strict=False,
)
)


@pytest.fixture()
def client() -> TestClient:
"""
Expand Down
73 changes: 73 additions & 0 deletions backend/tests/test_xaman_callback_retry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""Regression coverage for retry-safe Xaman/WordPress callback handling."""

from datetime import UTC, datetime, timedelta

from fastapi import HTTPException
from fastapi.testclient import TestClient
from sqlmodel import Session

import app.database as db_module
import app.main as main_module
from app.main import app
from app.schemas import IdentityClaimsResponse
from app.services.identity import validate_pending_login_state


def _claims() -> IdentityClaimsResponse:
now = datetime.now(UTC)
return IdentityClaimsResponse(
external_subject="wp_user_retry_test",
xrpl_address="rN7n7otQDd6FczFgLdlqtyMVrDHdH6s4vg",
issued_at=now,
expires_at=now + timedelta(seconds=60),
jti="retry-test-jti",
)


def test_transient_bridge_failure_does_not_consume_login_state(
monkeypatch,
):
"""A retryable bridge error must leave the pending state usable."""
monkeypatch.setattr(main_module, "_SESSION_COOKIE_SECURE", False)
monkeypatch.setattr(main_module, "_CALORIEAPP_ENV", "local")
monkeypatch.setattr(main_module, "_WORDPRESS_BRIDGE_SECRET", "test-secret")

calls = {"count": 0}

def flaky_exchange(code: str, state: str) -> IdentityClaimsResponse:
calls["count"] += 1
if calls["count"] == 1:
raise HTTPException(status_code=502, detail="WordPress bridge exchange failed")
return _claims()

monkeypatch.setattr(main_module, "_exchange_code_for_claims", flaky_exchange)

with TestClient(app) as client:
start = client.post("/api/identity/login/start")
assert start.status_code == 200
state = start.json()["state"]

failed = client.post(
"/api/identity/callback",
json={"code": "bridge-code", "state": state},
)
assert failed.status_code == 502

with Session(db_module.engine) as session:
valid, reason, pending = validate_pending_login_state(session, state)
assert valid is True
assert reason == "ok"
assert pending is not None

retried = client.post(
"/api/identity/callback",
json={"code": "bridge-code", "state": state},
)
assert retried.status_code == 200

replay = client.post(
"/api/identity/callback",
json={"code": "bridge-code", "state": state},
)
assert replay.status_code == 400
assert "already consumed" in replay.json()["detail"]
Loading