diff --git a/backend/api/users/resources.py b/backend/api/users/resources.py index 63ec83886c..04ef839850 100644 --- a/backend/api/users/resources.py +++ b/backend/api/users/resources.py @@ -1,15 +1,18 @@ -from typing import Optional +import json +from typing import Any, Iterable, Optional, AsyncGenerator +from backend.models.postgis.user import User from databases import Database -from fastapi import APIRouter, Depends, Request, Query, Path -from fastapi.responses import JSONResponse +from fastapi import APIRouter, Depends, Request, Query, Path, HTTPException +from fastapi.responses import JSONResponse, StreamingResponse from loguru import logger - from backend.db import get_db from backend.models.dtos.user_dto import AuthUserDTO, UserSearchQuery from backend.services.project_service import ProjectService from backend.services.users.authentication_service import login_required from backend.services.users.user_service import UserService +from backend.services.users.osm_service import OSMService, OSMServiceError +from backend.db import db_connection router = APIRouter( prefix="/users", @@ -59,6 +62,55 @@ async def get_user( return user_dto +@router.delete("/{user_id}/", tags=["users"]) +async def delete_user( + user_id: int, + user: AuthUserDTO = Depends(login_required), + db: Database = Depends(get_db), +): + """ + Delete user information by id. + + - **user_id**: The id of the user to delete. + - Returns the deleted user object (primitive form) and HTTP 200 on success. + + Responses: + 200: User deleted + 401: Unauthorized - insufficient permissions + 404: User not found + 500: Internal Server Error + """ + # Only the user themself or an admin may delete + is_admin = await UserService.is_user_an_admin(user.id, db) + + if user_id != user.id and not is_admin: + + return JSONResponse( + content={ + "Error": "User not permitted", + "SubCode": "UserPermissionError", + }, + status_code=401, + ) + + try: + deleted_dto = await UserService.delete_user_by_id(user_id, user.id, db) + if deleted_dto is None: + return JSONResponse( + content={ + "Error": "User not found", + "SubCode": "UserNotFound", + }, + status_code=400, + ) + + return deleted_dto + + except Exception as exc: + logger.exception("Failed to delete user %s: %s", user_id, exc) + raise HTTPException(status_code=500, detail="Internal Server Error") + + @router.get("/") async def list_users( page: int = Query(1, description="Page of results user requested"), @@ -146,6 +198,102 @@ async def list_users( return users_dto +async def _aiter_from_sync_iterable( + iterable: Iterable[Any], +) -> AsyncGenerator[Any, None]: + for item in iterable: + yield item + + +# @router.delete("/", tags=["users"]) +async def delete_users( + user: AuthUserDTO = Depends(login_required), + db: Database = Depends(get_db), +) -> StreamingResponse: + # permission check remains the same + is_admin = await UserService.is_user_an_admin(user.id, db) + if not is_admin: + return JSONResponse( + content={"Error": "User not permitted", "SubCode": "UserPermissionError"}, + status_code=401, + ) + + async def _delete_users_gen() -> AsyncGenerator[bytes, None]: + """ + Acquire a DB connection for the lifetime of this generator so fetches + and iterations don't fail with "Connection is not acquired". + """ + deleted_users_gen = OSMService.get_deleted_users() + + async with db_connection.database.connection() as conn: + + users_iterable_or_aiter = await User.get_all_users_not_paginated(conn) + if hasattr(users_iterable_or_aiter, "__aiter__"): + users_async_iter = users_iterable_or_aiter # type: ignore + else: + users_async_iter = _aiter_from_sync_iterable(users_iterable_or_aiter) + + if deleted_users_gen is not None: + last_deleted = 0 + try: + async for user_rec in users_async_iter: + user_id = ( + user_rec.get("id") + if isinstance(user_rec, dict) + else getattr(user_rec, "id", None) + ) + if user_id is None: + continue + + try: + while last_deleted < user_id: + last_deleted = await deleted_users_gen.__anext__() + except StopAsyncIteration: + return + + if last_deleted == user_id: + deleted_dto = await UserService.delete_user_by_id( + user_id, user, conn + ) + primitive = ( + deleted_dto.to_primitive() + if hasattr(deleted_dto, "to_primitive") + else deleted_dto + ) + yield (f"\u001e{json.dumps(primitive)}\n").encode("utf-8") + finally: + if hasattr(deleted_users_gen, "aclose"): + await deleted_users_gen.aclose() + + return + + async for user_rec in users_async_iter: + user_id = ( + user_rec.get("id") + if isinstance(user_rec, dict) + else getattr(user_rec, "id", None) + ) + if user_id is None: + continue + try: + gone = await OSMService.is_osm_user_gone(user_id) + except OSMServiceError: + continue + + if gone: + deleted_dto = await UserService.delete_user_by_id( + user_id, user, conn + ) + primitive = ( + deleted_dto.to_primitive() + if hasattr(deleted_dto, "to_primitive") + else deleted_dto + ) + yield (f"\u001e{json.dumps(primitive)}\n").encode("utf-8") + + return StreamingResponse(_delete_users_gen(), media_type="application/json-seq") + + @router.get("/queries/favorites/") async def get_user_favorite_projects( request: Request, diff --git a/backend/models/postgis/application.py b/backend/models/postgis/application.py index 6922354bd1..9137dc9fbc 100644 --- a/backend/models/postgis/application.py +++ b/backend/models/postgis/application.py @@ -68,6 +68,16 @@ async def get_all_for_user(user: int, db: Database): applications_dto.applications.append(application_dto) return applications_dto + @staticmethod + async def delete_all_for_user(user_id: int, db: Database) -> None: + """ + Delete all Application rows for the given user in one async transaction. + Pass `db` (from your get_db dependency). + """ + query = 'DELETE FROM application_keys WHERE "user" = :user' + async with db.transaction(): + await db.execute(query=query, values={"user": user_id}) + def as_dto(self): app_dto = ApplicationDTO() app_dto.user = self.user diff --git a/backend/services/users/osm_service.py b/backend/services/users/osm_service.py index 7b1b044982..b6b71d7296 100644 --- a/backend/services/users/osm_service.py +++ b/backend/services/users/osm_service.py @@ -1,8 +1,12 @@ +import re +from typing import AsyncGenerator, Optional + import requests from loguru import logger from backend.config import settings from backend.models.dtos.user_dto import UserOSMDTO +import httpx class OSMServiceError(Exception): @@ -13,6 +17,51 @@ def __init__(self, message): class OSMService: + @staticmethod + async def is_osm_user_gone(user_id: int) -> bool: + """ + Async HEAD request to check OSM user status. + Returns True for 410, False for 200, raise OSMServiceError otherwise. + """ + osm_user_details_url = f"{settings.OSM_SERVER_URL}/api/0.6/user/{user_id}.json" + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.head(osm_user_details_url, follow_redirects=True) + if resp.status_code == 410: + return True + if resp.status_code == 200: + return False + # treat other statuses as an error so caller can decide + raise OSMServiceError(f"Bad response from OSM: {resp.status_code}") + + @staticmethod + def get_deleted_users() -> Optional[AsyncGenerator[int, None]]: + """ + Return an async generator yielding deleted user IDs (ascending). + If not using https://www.openstreetmap.org as OSM_SERVER_URL, return None + (matching original behaviour). + """ + if settings.OSM_SERVER_URL != "https://www.openstreetmap.org": + return None + + async def _gen() -> AsyncGenerator[int, None]: + url = "https://planet.openstreetmap.org/users_deleted/users_deleted.txt" + username_re = re.compile(r"^\s*(\d+)\s*$") + async with httpx.AsyncClient(timeout=None) as client: + async with client.stream("GET", url) as resp: + if resp.status_code != 200: + # Fail fast — caller can handle OSMServiceError + raise OSMServiceError( + f"Failed fetching deleted users: {resp.status_code}" + ) + async for line in resp.aiter_lines(): + if not line: + continue + m = username_re.fullmatch(line) + if m: + yield int(m.group(1)) + + return _gen() + @staticmethod def get_osm_details_for_user(user_id: int) -> UserOSMDTO: """ @@ -23,6 +72,8 @@ def get_osm_details_for_user(user_id: int) -> UserOSMDTO: osm_user_details_url = f"{settings.OSM_SERVER_URL}/api/0.6/user/{user_id}.json" response = requests.get(osm_user_details_url) + if response.status_code == 410: + raise OSMServiceError("User no longer exists on OSM") if response.status_code != 200: raise OSMServiceError("Bad response from OSM") diff --git a/backend/services/users/user_service.py b/backend/services/users/user_service.py index cf04af9b46..02bb60df58 100644 --- a/backend/services/users/user_service.py +++ b/backend/services/users/user_service.py @@ -1,6 +1,6 @@ import json import datetime -from typing import Optional +from typing import List, Optional from databases import Database from loguru import logger @@ -29,7 +29,7 @@ from backend.models.postgis.interests import Interest, project_interests from backend.models.postgis.mapping_level import MappingLevel from backend.models.postgis.mapping_badge import MappingBadge -from backend.models.postgis.message import MessageType +from backend.models.postgis.message import Message, MessageType from backend.models.postgis.project import Project from backend.models.postgis.statuses import ProjectStatus, TaskStatus from backend.models.postgis.task import Task, TaskHistory @@ -49,6 +49,8 @@ ) from backend.services.users.osm_service import OSMService from backend.services.mapping_levels import MappingLevelService +from fastapi import HTTPException + settings = Settings() @@ -268,6 +270,121 @@ async def get_user_dto_by_id( return await user.as_dto(request_user.username, db) + @staticmethod + async def delete_user_by_id( + user_id: int, request_user_id: int, db: Database + ) -> Optional[UserDTO]: + """ + Anonymize + clean up a user account and return the original DTO. + Permissions: user may delete themselves, or an admin may delete any user. + Everything runs inside a DB transaction for consistency. + """ + if user_id != request_user_id: + is_requester_admin = await UserService.is_user_an_admin(request_user_id, db) + if not is_requester_admin: + return None + + original_dto = await UserService.get_user_dto_by_id( + user_id, request_user_id, db + ) + + if original_dto is None: + return None + + username_replacement = f"user_{user_id}" + + try: + async with db.transaction(): + # FIXME: Should we keep user_id since that will make conversations easier to follow? + # Keep in mind that OSM uses user_ on deleted accounts. + await db.execute( + query=""" + UPDATE users + SET + city = NULL, + country = NULL, + email_address = NULL, + facebook_id = NULL, + gender = NULL, + irc_id = NULL, + is_email_verified = FALSE, + is_expert = FALSE, + linkedin_id = NULL, + name = NULL, + picture_url = NULL, + self_description_gender = NULL, + skype_id = NULL, + slack_id = NULL, + twitter_id = NULL, + username = :username + WHERE id = :user_id + """, + values={ + "username": username_replacement, + "user_id": user_id, + }, + ) + + await db.execute( + query=""" + DELETE FROM user_licenses + WHERE "user" = :user_id + """, + values={"user_id": user_id}, + ) + + await db.execute( + query=""" + DELETE FROM user_interests + WHERE user_id = :user_id + """, + values={"user_id": user_id}, + ) + + is_target_admin = await UserService.is_user_an_admin(user_id, db) + + if is_target_admin: + query = "UPDATE users SET role = :role WHERE id = :user_id" + await db.execute( + query=query, + values={"role": UserRole.MAPPER.value, "user_id": user_id}, + ) + + # Remove messages that might contain user identifying information. + # TODO detect image links and try to delete them + await db.execute( + query=""" + UPDATE project_chat + SET message = :new_message + WHERE user_id = :user_id + """, + values={ + "new_message": f"Deleted user_{user_id} message", + "user_id": user_id, + }, + ) + from backend.models.postgis.application import Application + + # Drop application keys + await Application.delete_all_for_user(user_id, db) + + # Delete all messages (AKA notifications) for the user + message_types: List[int] = [mt.value for mt in MessageType] + await Message.delete_all_messages(user_id, db, message_types) + # Leave interests, licenses, organizations, and tasks alone for now. + + original_dto = await UserService.get_user_dto_by_id( + user_id, request_user_id, db + ) + return original_dto + + except Exception as exc: + logger.exception("Failed to delete/anonymize user %s: %s", user_id, exc) + raise HTTPException( + status_code=400, + detail="Failed to delete/anonymize user", + ) + @staticmethod async def get_interests_stats(user_id: int, db: Database): # Get all projects that the user has contributed. diff --git a/frontend/src/components/deleteModal/index.js b/frontend/src/components/deleteModal/index.js index 8db54e65e8..5367f797b2 100644 --- a/frontend/src/components/deleteModal/index.js +++ b/frontend/src/components/deleteModal/index.js @@ -12,7 +12,33 @@ import { AlertIcon } from '../svgIcons'; const DeleteTrigger = forwardRef((props, ref) => ); -export function DeleteModal({ id, name, type, className, endpointURL, onDelete }: Object) { +/** + * Called when an object is deleted + * @callback onDelete + * @param success The success object + */ + +/** + * Create a delete modal + * @param {number} [id] The id of the object to delete. Ignored if className is defined. + * @param {str} [name] The name of the object (unused) + * @param {('notifications'|'comments'|'users'|'interests'|'categories')} [type] The type of the object to delete. Ignored if className is defined. + * @param {str} [className] The additional css class names + * @param {str} [endpointURL] The endpoint to call + * @param {onDelete} [onDelete] Called when the object is deleted + * @typedef {import('@formatjs/intl').MessageDescriptor} MessageDescriptor + * @param {MessageDescriptor} [message] The message to show the user + * @returns {Element} The delete modal + * @constructor + */ +export function DeleteModal({ + id, + type, + className, + endpointURL, + onDelete, + message = messages.delete, +}: Object) { const navigate = useNavigate(); const modalRef = useRef(); const token = useSelector((state) => state.auth.token); @@ -29,7 +55,7 @@ export function DeleteModal({ id, name, type, className, endpointURL, onDelete } setDeleteStatus('success'); if (type === 'notifications') { setTimeout(() => navigate(`/inbox`), 750); - } else if (type === 'comments') { + } else if (type === 'comments' || type === 'users') { setTimeout(() => { onDelete(); modalRef.current.close(); @@ -43,7 +69,7 @@ export function DeleteModal({ id, name, type, className, endpointURL, onDelete } let errorMessage = e.message; if (Object.prototype.hasOwnProperty.call(messages, errorMessage)) { - errorMessage = intl.formatMessage({...messages[e.message]}); + errorMessage = intl.formatMessage({ ...messages[e.message] }); } setDeleteStatus('failure'); @@ -55,7 +81,11 @@ export function DeleteModal({ id, name, type, className, endpointURL, onDelete } + } modal closeOnDocumentClick @@ -82,6 +112,11 @@ export function DeleteModal({ id, name, type, className, endpointURL, onDelete }

+ {type === 'users' && ( +

+ +

+ )}
diff --git a/frontend/src/components/deleteModal/messages.js b/frontend/src/components/deleteModal/messages.js index 3d563f36d4..4c25c04961 100644 --- a/frontend/src/components/deleteModal/messages.js +++ b/frontend/src/components/deleteModal/messages.js @@ -20,6 +20,10 @@ export default defineMessages({ id: 'deleteModal.status.success.teams', defaultMessage: 'Team deleted successfully.', }, + success_users: { + id: 'deleteModal.status.success.users', + defaultMessage: 'User deleted successfully.', + }, success_organisations: { id: 'deleteModal.status.success.organisations', defaultMessage: 'Organisation deleted successfully.', @@ -84,6 +88,10 @@ export default defineMessages({ id: 'deleteModal.status.failure.teams', defaultMessage: 'An error occurred when trying to delete this team.', }, + failure_users: { + id: 'deleteModal.status.failure.users', + defaultMessage: 'An error occurred when trying to delete this user.', + }, failure_comments: { id: 'deleteModal.status.failure.comments', defaultMessage: 'An error occurred when trying to delete this comment.', @@ -100,6 +108,10 @@ export default defineMessages({ id: 'deleteModal.button.delete', defaultMessage: 'Delete', }, + deleteUser: { + id: 'deleteModal.button.delete.user', + defaultMessage: 'Yes, Delete Permanently', + }, InternalServerErrorError: { id: 'deleteModal.status.failure.InternalServerErrorError', defaultMessage: @@ -145,6 +157,15 @@ export default defineMessages({ id: 'deleteModal.title.teams', defaultMessage: 'Are you sure you want to delete this team?', }, + confirmDeleteTitle_users: { + id: 'deleteModal.title.users', + defaultMessage: 'Are you sure you want to permanently delete this user?', + }, + confirmDeleteDescription_users: { + id: 'deleteModal.description.users', + defaultMessage: + 'All user information, history, and associated data will be deleted forever. This action cannot be reversed.', + }, confirmDeleteTitle_comments: { id: 'deleteModal.title.comments', defaultMessage: 'Are you sure you want to delete this comment?', @@ -163,14 +184,14 @@ export default defineMessages({ }, MAPPING_LEVEL_HAS_USERS: { id: 'deleteModal.error.levelHasUsersError', - defaultMessage: 'Some users have this level so it can\'t be deleted', + defaultMessage: "Some users have this level so it can't be deleted", }, MAPPING_LEVEL_BEGINNER: { id: 'deleteModal.error.levelBeginner', - defaultMessage: 'You can\'t delete the beginner level', + defaultMessage: "You can't delete the beginner level", }, MAPPING_BADGE_HAS_USERS: { id: 'deleteModal.error.badgeHasUsersError', - defaultMessage: 'You can\'t delete a badge that users have earned', + defaultMessage: "You can't delete a badge that users have earned", }, }); diff --git a/frontend/src/components/teamsAndOrgs/management.js b/frontend/src/components/teamsAndOrgs/management.js index ec7b9561c5..db21737e81 100644 --- a/frontend/src/components/teamsAndOrgs/management.js +++ b/frontend/src/components/teamsAndOrgs/management.js @@ -23,18 +23,33 @@ export const AddButton = () => ( ); -export const DeleteButton = ({ className, onClick, showText = true }: Object) => { +/** + * A button for deleting something + * @param {string} className Additional css classes + * @param {MouseEventHandler} onClick The action to call on click + * @param {boolean} [showText=true] true if the message should be shown + * @typedef {import('@formatjs/intl').MessageDescriptor} MessageDescriptor + * @param {MessageDescriptor} message The message to show + * @returns {Element} The delete button + * @constructor + */ +export const DeleteButton = ({ + className, + onClick, + showText = true, + message = messages.delete, +}: Object) => { const intl = useIntl(); return (
{showText && ( - + )}
diff --git a/frontend/src/components/user/forms/personalInformation.js b/frontend/src/components/user/forms/personalInformation.js index bd6a20a202..e3ac866445 100644 --- a/frontend/src/components/user/forms/personalInformation.js +++ b/frontend/src/components/user/forms/personalInformation.js @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { connect } from 'react-redux'; +import { connect, useDispatch } from 'react-redux'; import { Form, Field } from 'react-final-form'; import { Tooltip } from 'react-tooltip'; import { FormattedMessage, useIntl } from 'react-intl'; @@ -8,9 +8,10 @@ import messages from '../messages'; import { FormSubmitButton } from '../../button'; import { InfoIcon, CheckIcon, CloseIcon } from '../../svgIcons'; import { UserCountrySelect, RadioField } from '../../formInputs'; -import { pushUserDetails } from '../../../store/actions/auth'; +import { logout, pushUserDetails } from '../../../store/actions/auth'; import { fetchLocalJSONAPI } from '../../../network/genericJSONRequest'; import { ORG_CODE } from '../../../config'; +import { DeleteModal } from '../../deleteModal'; export const PROFILE_RELEVANT_FIELDS = [ 'name', @@ -75,6 +76,7 @@ const RequiredIndicator = () => *; function _PersonalInformationForm({ userDetails, token, pushUserDetails }) { const intl = useIntl(); + const dispatch = useDispatch(); const labelClasses = 'db pt3 pb2'; const fieldClasses = 'blue-dark w-100 pv2 ph2 input-reset ba br1 b--grey-light bg-transparent lh-copy'; @@ -248,7 +250,7 @@ function _PersonalInformationForm({ userDetails, token, pushUserDetails }) { } -
+
+
+ dispatch(logout())} + type="users" + message={messages?.delete} + /> +

diff --git a/frontend/src/components/user/list.js b/frontend/src/components/user/list.js index 6dee901127..1f7458bba7 100644 --- a/frontend/src/components/user/list.js +++ b/frontend/src/components/user/list.js @@ -18,6 +18,7 @@ import { nCardPlaceholders } from './usersPlaceholder'; import { OHSOME_STATS_TOPICS } from '../../config'; import { Button } from '../button'; import { ChevronUpIcon, ChevronDownIcon } from '../svgIcons'; +import { DeleteModal } from '../deleteModal'; const UserFilter = ({ filters, setFilters, updateFilters }) => { const inputRef = useRef(null); @@ -335,7 +336,7 @@ export const UsersTable = ({ filters, setFilters, levels }) => { id: 'actions', header: () => , cell: ({ row }) => ( - <> +

{userDetails.username !== row.original.username && ( { > - + +
), }, ], diff --git a/frontend/src/components/user/messages.js b/frontend/src/components/user/messages.js index 352b5bb141..d4a28d5a1f 100644 --- a/frontend/src/components/user/messages.js +++ b/frontend/src/components/user/messages.js @@ -334,6 +334,10 @@ export default defineMessages({ id: 'users.list.actions.setLevel', defaultMessage: 'Set mapper level', }, + redactUser: { + id: 'users.list.actions.redact', + defaultMessage: 'Redact user information', + }, userAttributeUpdationSuccess: { id: 'users.list.attribute.updation.success', defaultMessage: @@ -376,31 +380,41 @@ export default defineMessages({ id: 'user.user_role.options.mapper', defaultMessage: 'Mapper', }, + delete: { + id: 'user.user_role.options.delete', + defaultMessage: 'Delete User', + }, clearFilters: { id: 'user.nav.clearFilters', defaultMessage: 'Clear filters', }, - failedUdatingStats: { id: 'user.table.failedUdatingStats', defaultMessage: "Failed updating stats" }, - never: { id: 'user.table.never', defaultMessage: "Never" }, - statsUpdated: { id: 'user.table.statsUpdated', defaultMessage: "Stats updated" }, - tableActions: { id: 'user.table.actions', defaultMessage: "Actions" }, - tableLastUpdated: { id: 'user.table.lastUpdated', defaultMessage: "Last updated" }, - tableLevel: { id: 'user.table.level', defaultMessage: "Mapper level" }, - tableRole: { id: 'user.table.role', defaultMessage: "Role" }, - tableUpgrade: { id: 'user.table.upgrade', defaultMessage: "Level upgrade" }, - tableUsername: { id: 'user.table.username', defaultMessage: "Username" }, - tableCol_highway: { id: 'user.table.highway', defaultMessage: "Highways" }, - tableCol_waterway: { id: 'user.table.waterway', defaultMessage: "Waterways" }, - tableCol_building: { id: 'user.table.building', defaultMessage: "Buildings" }, - tableCol_changeset: { id: 'user.table.changeset', defaultMessage: "Changesets" }, - tableCol_poi: { id: 'user.table.poi', defaultMessage: "PoI" }, - tableApprove: { id: 'user.table.approve', defaultMessage: "Approve" }, - levelApproved: { id: 'user.levelApproved', defaultMessage: "User approved" }, - failedApprovingLevel: { id: 'user.failedApprovingLevel', defaultMessage: "Failed approving level" }, - alreadyVoted: { id: 'user.alreadyVoted', defaultMessage: "You already voted" }, - progress_changeset: { id: 'user.progress.changeset', defaultMessage: "changesets" }, - progress_waterway: { id: 'user.progress.waterway', defaultMessage: "km of waterways" }, - progress_highway: { id: 'user.progress.highway', defaultMessage: "km of highways" }, - progress_poi: { id: 'user.progress.poi', defaultMessage: "points of interest" }, - progress_building: { id: 'user.progress.building', defaultMessage: "buildings" }, + failedUdatingStats: { + id: 'user.table.failedUdatingStats', + defaultMessage: 'Failed updating stats', + }, + never: { id: 'user.table.never', defaultMessage: 'Never' }, + statsUpdated: { id: 'user.table.statsUpdated', defaultMessage: 'Stats updated' }, + tableActions: { id: 'user.table.actions', defaultMessage: 'Actions' }, + tableLastUpdated: { id: 'user.table.lastUpdated', defaultMessage: 'Last updated' }, + tableLevel: { id: 'user.table.level', defaultMessage: 'Mapper level' }, + tableRole: { id: 'user.table.role', defaultMessage: 'Role' }, + tableUpgrade: { id: 'user.table.upgrade', defaultMessage: 'Level upgrade' }, + tableUsername: { id: 'user.table.username', defaultMessage: 'Username' }, + tableCol_highway: { id: 'user.table.highway', defaultMessage: 'Highways' }, + tableCol_waterway: { id: 'user.table.waterway', defaultMessage: 'Waterways' }, + tableCol_building: { id: 'user.table.building', defaultMessage: 'Buildings' }, + tableCol_changeset: { id: 'user.table.changeset', defaultMessage: 'Changesets' }, + tableCol_poi: { id: 'user.table.poi', defaultMessage: 'PoI' }, + tableApprove: { id: 'user.table.approve', defaultMessage: 'Approve' }, + levelApproved: { id: 'user.levelApproved', defaultMessage: 'User approved' }, + failedApprovingLevel: { + id: 'user.failedApprovingLevel', + defaultMessage: 'Failed approving level', + }, + alreadyVoted: { id: 'user.alreadyVoted', defaultMessage: 'You already voted' }, + progress_changeset: { id: 'user.progress.changeset', defaultMessage: 'changesets' }, + progress_waterway: { id: 'user.progress.waterway', defaultMessage: 'km of waterways' }, + progress_highway: { id: 'user.progress.highway', defaultMessage: 'km of highways' }, + progress_poi: { id: 'user.progress.poi', defaultMessage: 'points of interest' }, + progress_building: { id: 'user.progress.building', defaultMessage: 'buildings' }, }); diff --git a/frontend/src/components/user/tests/list.test.js b/frontend/src/components/user/tests/list.test.js index 0e5498c211..b137a3bd25 100644 --- a/frontend/src/components/user/tests/list.test.js +++ b/frontend/src/components/user/tests/list.test.js @@ -40,7 +40,7 @@ describe('User list card', () => { expect(screen.getByTitle(/Ram/i)).toHaveStyle( `background-image: url(https://www.openstreetmap.org/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBNXQ2Q3c9PSIsImV4cCI6bnVsbCwicHVyIjoiYmxvYl9pZCJ9fQ==--fe41f1b2a5d6cf492a7133f15c81f105dec06ff7/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBPZ2h3Ym1jNkZISmxjMmw2WlY5MGIxOXNhVzFwZEZzSGFXbHBhUT09IiwiZXhwIjpudWxsLCJwdXIiOiJ2YXJpYXRpb24ifX0=--058ac785867b32287d598a314311e2253bd879a3/unnamed.webp)`, ); - expect(container.querySelectorAll('svg').length).toBe(4); + expect(container.querySelectorAll('svg').length).toBe(6); }); // expect(getAllByRole('listitem')).toHaveLength(2); }); diff --git a/tests/backend/integration/api/users/test_resources.py b/tests/backend/integration/api/users/test_resources.py index eee186d01e..848ec79601 100644 --- a/tests/backend/integration/api/users/test_resources.py +++ b/tests/backend/integration/api/users/test_resources.py @@ -8,6 +8,7 @@ generate_encoded_token, return_canned_user, ) +from typing import Optional TEST_USERNAME = "test_user" TEST_USER_ID = 1111111 @@ -90,11 +91,11 @@ def test_returns_404_if_user_not_found(self): @staticmethod def assert_user_detail_response( response, - user_id=TEST_USER_ID, - username=TEST_USERNAME, - email=TEST_EMAIL, - gender=None, - own_info=True, + user_id: Optional[int] = TEST_USER_ID, + username: Optional[str] = TEST_USERNAME, + email: Optional[str] = TEST_EMAIL, + gender: Optional[str] = None, + own_info: bool = True, ): assert response.status_code == 200 assert response.json["id"] == user_id @@ -553,3 +554,139 @@ def test_email_and_gender_not_returned_if_requested_by_other(self): TestUsersQueriesUsernameAPI.assert_user_detail_response( response, TEST_USER_ID, TEST_USERNAME, None, None, False ) + + def test_user_can_delete_self(self): + """Check that a user can delete (redact personal information) themselves""" + # Arrange + self.user.email_address = TEST_EMAIL + self.user.gender = UserGender.FEMALE.value + self.user.save() + # Act + response = self.client.delete( + self.url, headers={"Authorization": self.user_session_token} + ) + next_response = self.client.get( + f"/api/v2/users/{TEST_USER_ID}/", + headers={"Authorization": self.user_session_token}, + ) + # Assert + # Note that we return the deleted user information at this time + TestUsersQueriesUsernameAPI.assert_user_detail_response( + response, + TEST_USER_ID, + TEST_USERNAME, + TEST_EMAIL, + UserGender.FEMALE.name, + True, + ) + TestUsersQueriesUsernameAPI.assert_user_detail_response( + next_response, TEST_USER_ID, f"user_{TEST_USER_ID}", None, None, True + ) + + def test_other_user_cannot_delete_self(self): + """Check that another user cannot delete (redact personal information) about a different user""" + # Arrange + self.user.email_address = TEST_EMAIL + self.user.gender = UserGender.FEMALE.value + self.user.save() + user_2 = return_canned_user("user_2", 2222222) + user_2.create() + user_2_session_token = generate_encoded_token(user_2.id) + # Act + response = self.client.delete( + self.url, headers={"Authorization": user_2_session_token} + ) + # Assert + self.assertEqual(401, response.status_code) + rjson = response.json + self.assertDictEqual( + rjson, + { + "error": { + "code": 401, + "details": {}, + "message": "Authentication credentials were missing or incorrect.", + "sub_code": "UNAUTHORIZED", + } + }, + ) + + def test_other_admin_user_can_delete_self(self): + """Check that another user cannot delete (redact personal information) about a different user""" + # Arrange + self.user.email_address = TEST_EMAIL + self.user.gender = UserGender.FEMALE.value + self.user.save() + user_2 = return_canned_user("user_2", 2222222) + user_2.set_user_role(UserRole.ADMIN) + user_2.create() + user_2_session_token = generate_encoded_token(user_2.id) + # Act + response = self.client.delete( + self.url, headers={"Authorization": user_2_session_token} + ) + next_response = self.client.get( + f"/api/v2/users/{TEST_USER_ID}/", + headers={"Authorization": user_2_session_token}, + ) + # Assert + # Note that we return the deleted user information at this time + TestUsersQueriesUsernameAPI.assert_user_detail_response( + response, + TEST_USER_ID, + TEST_USERNAME, + TEST_EMAIL, + UserGender.FEMALE.name, + False, + ) + TestUsersQueriesUsernameAPI.assert_user_detail_response( + next_response, TEST_USER_ID, f"user_{TEST_USER_ID}", None, None, False + ) + + def test_admin_user_can_remove_redacted_osm_accounts(self): + """Check that an admin can redact redacted OSM accounts""" + # Arrange + self.user.email_address = TEST_EMAIL + self.user.gender = UserGender.FEMALE.value + self.user.id = 4 + self.user.save() + user_2 = return_canned_user("user_2", 2222222) + user_2.set_user_role(UserRole.ADMIN) + user_2.create() + user_2_session_token = generate_encoded_token(user_2.id) + # Act + response = self.client.delete( + "/api/v2/users/", headers={"Authorization": user_2_session_token} + ) + next_response = self.client.get( + "/api/v2/users/4/", headers={"Authorization": user_2_session_token} + ) + # Assert + self.assertEqual(200, response.status_code) + TestUsersQueriesUsernameAPI.assert_user_detail_response( + next_response, 4, "user_4", None, None, False + ) + + def test_user_cannot_remove_redacted_osm_accounts(self): + """Check that a user cannot redact redacted OSM accounts""" + # Arrange + self.user.email_address = TEST_EMAIL + self.user.gender = UserGender.FEMALE.value + self.user.id = 4 + self.user.save() + user_2 = return_canned_user("user_2", 2222222) + user_2.set_user_role(UserRole.MAPPER) + user_2.create() + user_2_session_token = generate_encoded_token(user_2.id) + # Act + response = self.client.delete( + "/api/v2/users/", headers={"Authorization": user_2_session_token} + ) + next_response = self.client.get( + "/api/v2/users/4/", headers={"Authorization": user_2_session_token} + ) + # Assert + self.assertEqual(401, response.status_code) + TestUsersQueriesUsernameAPI.assert_user_detail_response( + next_response, 4, TEST_USERNAME, None, None, False + ) diff --git a/tests/backend/integration/services/users/test_osm_service.py b/tests/backend/integration/services/users/test_osm_service.py index c504215886..17aa93e64a 100644 --- a/tests/backend/integration/services/users/test_osm_service.py +++ b/tests/backend/integration/services/users/test_osm_service.py @@ -13,3 +13,19 @@ def test_get_osm_details_for_user_returns_user_details_if_valid_user_id(self): dto = OSMService.get_osm_details_for_user(13526430) # Assert self.assertEqual(dto.account_created, "2021-06-10T01:27:18Z") + + def test_is_user_deleted(self): + self.assertTrue(OSMService.is_osm_user_gone(535043)) + self.assertFalse(OSMService.is_osm_user_gone(2078753)) + + def test_get_deleted_users(self): + # These are the first 10 deleted users on 2024-04-16. This should ensure that the test finishes quickly. + # Otherwise, it can take 6s+ (dependent upon network speed) + deleted_users = [4, 142, 593, 601, 1769, 2161, 2238, 2782, 2868] + generator = OSMService.get_deleted_users() + for deleted_user in generator: + if deleted_user in deleted_users: + deleted_users.remove(deleted_user) + if len(deleted_users) == 0: + break + self.assertEquals(0, len(deleted_users)) diff --git a/tests/backend/integration/services/users/test_user_service.py b/tests/backend/integration/services/users/test_user_service.py index c10ba6c8a0..f194b87d15 100644 --- a/tests/backend/integration/services/users/test_user_service.py +++ b/tests/backend/integration/services/users/test_user_service.py @@ -1,6 +1,7 @@ from unittest.mock import patch from backend.models.postgis.message import Message +from backend.models.postgis.statuses import UserRole, UserGender from backend.services.users.user_service import ( MappingLevel, OSMService, @@ -97,3 +98,67 @@ def test_register_user_creates_new_user(self): # Assert self.assertEqual(expected_user.username, test_user.username) self.assertEqual(expected_user.mapping_level, MappingLevel.INTERMEDIATE.value) + + @staticmethod + def add_user_identifying_information(user: User) -> User: + user.username = "Test User" + user.email_address = "test@example.com" + user.twitter_id = "@Test" + user.facebook_id = "@FBTest" + user.linkedin_id = "@LinkedIn" + user.slack_id = "@Slack" + user.skype_id = "@Skype" + user.irc_id = "IRC" + user.name = "Some name here" + user.city = "Some city" + user.country = "Some country" + user.picture_url = "https://test.com/path/to/picture.png" + user.gender = UserGender.MALE.value + user.self_description_gender = "I am male" + user.default_editor = "ID" + user.save() + return user + + def check_user_details_deleted(self, user: User, deleted: bool): + if deleted: + check = self.assertIsNone + self.assertNotEquals(UserRole.ADMIN.value, user.role) + self.assertEqual(f"user_{user.id}", user.username) + else: + self.assertNotEquals(f"user_{user.id}", user.username) + check = self.assertIsNotNone + check(user.email_address) + check(user.twitter_id) + check(user.facebook_id) + check(user.linkedin_id) + check(user.slack_id) + check(user.skype_id) + check(user.irc_id) + check(user.name) + check(user.city) + check(user.country) + check(user.picture_url) + check(user.gender) + check(user.self_description_gender) + self.assertEqual([], user.accepted_licenses) + self.assertEqual([], user.interests) + + def test_delete_user_same_user(self): + test_user = self.add_user_identifying_information(create_canned_user()) + UserService.delete_user_by_id(test_user.id, test_user.id) + self.check_user_details_deleted(User().get_by_id(test_user.id), deleted=True) + + def test_delete_user_different_user(self): + test_user = self.add_user_identifying_information(create_canned_user()) + other_user = return_canned_user("someone", test_user.id + 1) + other_user.create() + UserService.delete_user_by_id(test_user.id, other_user.id) + self.check_user_details_deleted(User().get_by_id(test_user.id), deleted=False) + + def test_delete_user_different_admin_user(self): + test_user = self.add_user_identifying_information(create_canned_user()) + other_user = return_canned_user("someone", test_user.id + 1) + other_user.set_user_role(UserRole.ADMIN) + other_user.create() + UserService.delete_user_by_id(test_user.id, other_user.id) + self.check_user_details_deleted(User().get_by_id(test_user.id), deleted=True)