diff --git a/backend/fluksio/cloud/enroll.py b/backend/fluksio/cloud/enroll.py index 5962b7e..c55a99a 100644 --- a/backend/fluksio/cloud/enroll.py +++ b/backend/fluksio/cloud/enroll.py @@ -216,11 +216,12 @@ def adopt_member(session: Session, portal_sub: str) -> User | None: return None try: return create_remote_user(session, portal_sub, str(member["email"])) - except EnrollError: - return None - except IntegrityError: + except (EnrollError, IntegrityError): # A first page load fires several requests and the websocket at once, # all of them missing the row and all of them creating it. Whoever - # loses reads back what the winner wrote. + # loses, at the unique index or at the checks before it (the winner + # may commit while this one asks the portal), reads back what the + # winner wrote. An email another local account holds finds nothing to + # read back and stays refused. session.rollback() return session.exec(select(User).where(User.portal_sub == portal_sub)).first() diff --git a/backend/tests/test_cloud.py b/backend/tests/test_cloud.py index 9712294..56e7f97 100644 --- a/backend/tests/test_cloud.py +++ b/backend/tests/test_cloud.py @@ -644,6 +644,39 @@ def test_a_member_the_portal_vouches_for_is_adopted( assert user_from_token(db, stranger) is None +def test_a_request_that_loses_the_adoption_race_gets_the_winners_user( + enrolled: User, # noqa: ARG001 (fixture installs the enrolment) + portal_key: rsa.RSAPrivateKey, + db: Session, +) -> None: + """A first page load adopts from several requests; none of them is refused.""" + from fluksio.cloud.enroll import create_remote_user + + theirs = portal_token(portal_key, subject="portal-user-race") + + def vouch_after_the_winner_commits(*_: object, **__: object) -> Mock: + # Another request finishes adopting while this one asks the portal. + with Session(db.get_bind()) as other: + create_remote_user(other, "portal-user-race", "race@example.com") + return Mock( + status_code=200, + json=Mock( + return_value={ + "user_id": "portal-user-race", + "email": "race@example.com", + } + ), + ) + + with patch( + "fluksio.cloud.enroll.httpx.get", side_effect=vouch_after_the_winner_commits + ): + adopted = user_from_token(db, theirs) + + assert adopted is not None + assert adopted.portal_sub == "portal-user-race" + + def test_the_tunnel_opens_the_live_socket_with_a_ticket( client: TestClient, superuser_token_headers: dict[str, str] ) -> None: