Skip to content

Commit 932a2dc

Browse files
author
Jayant Kernel
committed
Fix user delete lock restore race and tighten resource-block tests
1 parent f0999e5 commit 932a2dc

2 files changed

Lines changed: 55 additions & 48 deletions

File tree

src/routers/openml/users.py

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1+
import uuid
12
from http import HTTPStatus
23
from typing import Annotated, Any
34

45
from fastapi import APIRouter, Depends, HTTPException
5-
from sqlalchemy import Connection
6+
from sqlalchemy import Connection, text
67

78
from core.errors import UserError
89
from database.users import User, UserGroup, delete_user, get_user_resource_count
@@ -41,10 +42,6 @@ def delete_account(
4142
detail={"code": str(int(UserError.NO_ACCESS)), "message": "No access granted"},
4243
)
4344

44-
import uuid
45-
46-
from sqlalchemy import text # noqa: PLC0415
47-
4845
original = user_db.execute(
4946
text("SELECT session_hash FROM users WHERE id = :id FOR UPDATE"),
5047
parameters={"id": user_id},
@@ -56,14 +53,13 @@ def delete_account(
5653
detail={"code": str(int(UserError.NOT_FOUND)), "message": "User not found"},
5754
)
5855

59-
# Invalidate session immediately to prevent concurrent resource creation
56+
# Invalidate session while delete flow is in-progress.
6057
original_session_hash = original[0]
6158
temp_lock_hash = uuid.uuid4().hex
6259
user_db.execute(
6360
text("UPDATE users SET session_hash = :lock_hash WHERE id = :id"),
6461
parameters={"lock_hash": temp_lock_hash, "id": user_id},
6562
)
66-
user_db.commit()
6763

6864
deletion_successful = False
6965
try:
@@ -86,9 +82,16 @@ def delete_account(
8682
return {"user_id": user_id, "deleted": True}
8783
finally:
8884
if not deletion_successful:
89-
# Restore session hash if deletion did not complete successfully
85+
# Restore only if we still hold our lock value.
9086
user_db.execute(
91-
text("UPDATE users SET session_hash = :hash WHERE id = :id"),
92-
parameters={"hash": original_session_hash, "id": user_id},
87+
text(
88+
"UPDATE users SET session_hash = :hash "
89+
"WHERE id = :id AND session_hash = :lock_hash",
90+
),
91+
parameters={
92+
"hash": original_session_hash,
93+
"id": user_id,
94+
"lock_hash": temp_lock_hash,
95+
},
9396
)
9497
user_db.commit()

tests/routers/openml/users_test.py

Lines changed: 42 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,12 @@ def test_delete_user_as_admin(py_api: TestClient, user_test: Connection) -> None
6363
text("SELECT COUNT(*) FROM users WHERE id = :id"),
6464
parameters={"id": new_id},
6565
).scalar()
66+
group_count = user_test.execute(
67+
text("SELECT COUNT(*) FROM users_groups WHERE user_id = :id"),
68+
parameters={"id": new_id},
69+
).scalar()
6670
assert user_count == 0
71+
assert group_count == 0
6772

6873

6974
def test_delete_user_no_auth(py_api: TestClient) -> None:
@@ -98,53 +103,43 @@ def test_delete_user_has_resources(py_api: TestClient, user_test: Connection) ->
98103
text("SELECT COUNT(*) FROM users WHERE id = :id"),
99104
parameters={"id": target_id},
100105
).scalar()
106+
session_hash = user_test.execute(
107+
text("SELECT session_hash FROM users WHERE id = :id"),
108+
parameters={"id": target_id},
109+
).scalar()
101110
assert user_count == 1
111+
assert session_hash == ApiKey.DATASET_130_OWNER
102112

103113

104114
@pytest.mark.mut
105115
@pytest.mark.parametrize(
106-
("table_name", "column_name", "insert_sql"),
116+
"insert_sql",
107117
[
118+
"INSERT INTO dataset (uploader, name, format) VALUES (:id, 'x', 'ARFF')",
108119
(
109-
"dataset",
110-
"uploader",
111-
"INSERT INTO dataset (uploader, name, format) VALUES (:id, 'x', 'ARFF')",
112-
),
113-
(
114-
"implementation",
115-
"uploader",
116120
"INSERT INTO implementation (uploader, fullname, name, version, "
117-
"external_version, uploadDate) VALUES (:id, 'x', 'x', 1, '1', '2024-01-01')",
118-
),
119-
("run", "uploader", "INSERT INTO run (uploader, task_id, setup) VALUES (:id, 1, 1)"),
120-
(
121-
"study",
122-
"creator",
123-
"INSERT INTO study (creator, name, main_entity_type) VALUES (:id, 'x', 'run')",
124-
),
125-
(
126-
"task_study",
127-
"uploader",
128-
"INSERT INTO task_study (uploader, study_id, task_id) VALUES (:id, 14, 1)",
129-
),
130-
(
131-
"run_study",
132-
"uploader",
133-
"INSERT INTO run_study (uploader, study_id, run_id) VALUES (:id, 14, 1)",
134-
),
135-
(
136-
"dataset_tag",
137-
"uploader",
138-
"INSERT INTO dataset_tag (uploader, id, tag) VALUES (:id, 1, 'x')",
121+
"external_version, uploadDate) VALUES (:id, 'x', 'x', 1, '1', '2024-01-01')"
139122
),
123+
"INSERT INTO run (uploader, task_id, setup) VALUES (:id, 1, 1)",
124+
"INSERT INTO study (creator, name, main_entity_type) VALUES (:id, 'x', 'run')",
125+
"INSERT INTO task_study (uploader, study_id, task_id) VALUES (:id, 14, 1)",
126+
"INSERT INTO run_study (uploader, study_id, run_id) VALUES (:id, 14, 1)",
127+
"INSERT INTO dataset_tag (uploader, id, tag) VALUES (:id, 1, 'x')",
128+
],
129+
ids=[
130+
"dataset",
131+
"implementation",
132+
"run",
133+
"study",
134+
"task_study",
135+
"run_study",
136+
"dataset_tag",
140137
],
141138
)
142-
def test_delete_user_has_resources_parametrized( # noqa: PLR0913
139+
def test_delete_user_has_resources_parametrized(
143140
py_api: TestClient,
144141
user_test: Connection,
145142
expdb_test: Connection,
146-
table_name: str, # noqa: ARG001
147-
column_name: str, # noqa: ARG001
148143
insert_sql: str,
149144
) -> None:
150145
"""Verify that possessing any tracked resource blocks deletion."""
@@ -156,13 +151,22 @@ def test_delete_user_has_resources_parametrized( # noqa: PLR0913
156151
)
157152
(new_id,) = user_test.execute(text("SELECT LAST_INSERT_ID()")).one()
158153

159-
# Disable constraints temporarily to inject simple orphaned rows for testing 409
160-
expdb_test.execute(text("SET FOREIGN_KEY_CHECKS=0"))
161-
expdb_test.execute(text(insert_sql), parameters={"id": new_id})
162-
expdb_test.execute(text("SET FOREIGN_KEY_CHECKS=1"))
163-
expdb_test.commit()
154+
# Keep inserts inside rollback-scoped transaction used by the test harness.
155+
with expdb_test.begin_nested():
156+
expdb_test.execute(text("SET FOREIGN_KEY_CHECKS=0"))
157+
try:
158+
expdb_test.execute(text(insert_sql), parameters={"id": new_id})
159+
finally:
160+
expdb_test.execute(text("SET FOREIGN_KEY_CHECKS=1"))
164161

165162
response = py_api.delete(f"/users/{new_id}?api_key=eeeeffffccccddddaaaabbbbccccdddd")
166163

167164
assert response.status_code == HTTPStatus.CONFLICT
168165
assert response.json()["detail"]["code"] == "122"
166+
assert "resource(s)" in response.json()["detail"]["message"]
167+
168+
user_count = user_test.execute(
169+
text("SELECT COUNT(*) FROM users WHERE id = :id"),
170+
parameters={"id": new_id},
171+
).scalar()
172+
assert user_count == 1

0 commit comments

Comments
 (0)