-
-
Notifications
You must be signed in to change notification settings - Fork 50
Feature/delete account endpoint #265
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 5 commits
1a5dea6
2f60ac4
3ff845f
a771b69
9662c1f
be4980e
6449a2e
ec59247
e8fd89f
aa4ed9c
11e3c99
d175de1
171c9b3
f0999e5
932a2dc
0978b8d
1a60e2e
3345fe1
7888523
0ecb8b1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| from http import HTTPStatus | ||
| from typing import Annotated | ||
|
|
||
| from fastapi import APIRouter, Depends, HTTPException | ||
| from sqlalchemy import Connection | ||
|
|
||
| from core.errors import UserError | ||
| from database.users import UserGroup, delete_user, get_user_resource_count | ||
| from routers.dependencies import expdb_connection, fetch_user, userdb_connection | ||
| from database.users import User | ||
|
|
||
| router = APIRouter(prefix="/users", tags=["users"]) | ||
|
|
||
|
|
||
| @router.delete( | ||
| "/{user_id}", | ||
| summary="Delete a user account", | ||
| description=( | ||
| "Deletes the account of the specified user. " | ||
| "Only the account owner or an admin may perform this action. " | ||
| "Deletion is blocked if the user has uploaded any datasets, flows, or runs." | ||
| ), | ||
| ) | ||
| def delete_account( | ||
| user_id: int, | ||
| caller: Annotated[User | None, Depends(fetch_user)] = None, | ||
| user_db: Annotated[Connection, Depends(userdb_connection)] = None, | ||
| expdb: Annotated[Connection, Depends(expdb_connection)] = None, | ||
| ) -> dict: | ||
| if caller is None: | ||
| raise HTTPException( | ||
| status_code=HTTPStatus.UNAUTHORIZED, | ||
| detail={"code": str(int(UserError.NO_ACCESS)), "message": "Authentication required"}, | ||
| ) | ||
|
|
||
| is_admin = UserGroup.ADMIN in caller.groups | ||
| is_self = caller.user_id == user_id | ||
|
|
||
| if not is_admin and not is_self: | ||
| raise HTTPException( | ||
| status_code=HTTPStatus.FORBIDDEN, | ||
| detail={"code": str(int(UserError.NO_ACCESS)), "message": "No access granted"}, | ||
| ) | ||
|
|
||
| # Verify the target user exists | ||
| from database.users import get_user_id_for # noqa: PLC0415 | ||
|
|
||
| # Check user exists by querying for them directly | ||
| from sqlalchemy import text | ||
|
|
||
| existing = user_db.execute( | ||
| text("SELECT id FROM users WHERE id = :user_id"), | ||
| parameters={"user_id": user_id}, | ||
| ).one_or_none() | ||
|
|
||
| if existing is None: | ||
|
sourcery-ai[bot] marked this conversation as resolved.
Outdated
|
||
| raise HTTPException( | ||
| status_code=HTTPStatus.NOT_FOUND, | ||
| detail={"code": str(int(UserError.NOT_FOUND)), "message": "User not found"}, | ||
| ) | ||
|
|
||
| resource_count = get_user_resource_count(user_id=user_id, expdb=expdb) | ||
| if resource_count > 0: | ||
| raise HTTPException( | ||
| status_code=HTTPStatus.CONFLICT, | ||
| detail={ | ||
| "code": str(int(UserError.HAS_RESOURCES)), | ||
| "message": ( | ||
| f"User has {resource_count} resource(s). " | ||
| "Remove or transfer resources before deleting the account." | ||
| ), | ||
|
sourcery-ai[bot] marked this conversation as resolved.
Outdated
|
||
| }, | ||
| ) | ||
|
|
||
| delete_user(user_id=user_id, connection=user_db) | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
| return {"user_id": user_id, "deleted": True} | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,27 +1,60 @@ | ||
| from http import HTTPStatus | ||
|
|
||
| import pytest | ||
| from sqlalchemy import Connection | ||
|
|
||
| from database.users import User | ||
| from routers.dependencies import fetch_user | ||
| from tests.users import ADMIN_USER, OWNER_USER, SOME_USER, ApiKey | ||
|
|
||
|
|
||
| @pytest.mark.parametrize( | ||
| ("api_key", "user"), | ||
| [ | ||
| (ApiKey.ADMIN, ADMIN_USER), | ||
| (ApiKey.OWNER_USER, OWNER_USER), | ||
| (ApiKey.SOME_USER, SOME_USER), | ||
| ], | ||
| ) | ||
| def test_fetch_user(api_key: str, user: User, user_test: Connection) -> None: | ||
| db_user = fetch_user(api_key, user_data=user_test) | ||
| assert db_user is not None | ||
| assert user.user_id == db_user.user_id | ||
| assert set(user.groups) == set(db_user.groups) | ||
|
|
||
|
|
||
| def test_fetch_user_invalid_key_returns_none(user_test: Connection) -> None: | ||
| assert fetch_user(api_key=None, user_data=user_test) is None | ||
| invalid_key = "f" * 32 | ||
| assert fetch_user(api_key=invalid_key, user_data=user_test) is None | ||
| from fastapi.testclient import TestClient | ||
| from sqlalchemy import Connection, text | ||
|
|
||
| from tests.users import ApiKey | ||
|
|
||
|
|
||
| @pytest.mark.mut | ||
| def test_delete_user_self(py_api: TestClient, user_test: Connection) -> None: | ||
| """A user without resources can delete their own account.""" | ||
| # Insert a fresh disposable user | ||
| user_test.execute( | ||
| text( | ||
| "INSERT INTO users (session_hash, email, first_name, last_name, password)" | ||
| " VALUES ('aaaabbbbccccddddaaaabbbbccccdddd', '[email protected]', 'Del', 'User', 'x')", | ||
| ), | ||
| ) | ||
|
Comment on lines
+34
to
+42
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. issue (testing): Add a test case for deletion being blocked when the user owns resources (datasets/flows/runs). Currently there’s no test covering the 409 path when
This helps prevent regressions in the endpoint’s safety guarantees. |
||
| (new_id,) = user_test.execute(text("SELECT LAST_INSERT_ID()")).one() | ||
|
|
||
| response = py_api.delete(f"/users/{new_id}?api_key=aaaabbbbccccddddaaaabbbbccccdddd") | ||
| assert response.status_code == HTTPStatus.OK | ||
| assert response.json() == {"user_id": new_id, "deleted": True} | ||
|
|
||
|
|
||
| @pytest.mark.mut | ||
| def test_delete_user_as_admin(py_api: TestClient, user_test: Connection) -> None: | ||
| """An admin can delete any user without resources.""" | ||
| user_test.execute( | ||
| text( | ||
| "INSERT INTO users (session_hash, email, first_name, last_name, password)" | ||
| " VALUES ('eeeeffffaaaabbbbeeeeffffaaaabbbb', '[email protected]', 'Del2', 'User', 'x')", | ||
| ), | ||
| ) | ||
| (new_id,) = user_test.execute(text("SELECT LAST_INSERT_ID()")).one() | ||
|
|
||
| response = py_api.delete(f"/users/{new_id}?api_key={ApiKey.ADMIN}") | ||
| assert response.status_code == HTTPStatus.OK | ||
| assert response.json() == {"user_id": new_id, "deleted": True} | ||
|
|
||
|
|
||
| def test_delete_user_no_auth(py_api: TestClient) -> None: | ||
| """No API key → 401.""" | ||
| response = py_api.delete("/users/2") | ||
| assert response.status_code == HTTPStatus.UNAUTHORIZED | ||
|
|
||
|
|
||
| def test_delete_user_not_owner(py_api: TestClient) -> None: | ||
| """A non-owner non-admin user cannot delete someone else's account → 403.""" | ||
| # SOME_USER (user_id=2) tries to delete OWNER_USER (user_id=3229) | ||
| response = py_api.delete(f"/users/3229?api_key={ApiKey.SOME_USER}") | ||
| assert response.status_code == HTTPStatus.FORBIDDEN | ||
|
|
||
|
|
||
| def test_delete_user_not_found(py_api: TestClient) -> None: | ||
| """Deleting a non-existent user → 404.""" | ||
| response = py_api.delete(f"/users/99999999?api_key={ApiKey.ADMIN}") | ||
| assert response.status_code == HTTPStatus.NOT_FOUND | ||
| assert response.json()["detail"]["code"] == "120" | ||
Uh oh!
There was an error while loading. Please reload this page.