Users: deleting a user clears their API tokens and detaches audit rows
Some checks failed
CI / backend (push) Successful in 1m45s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s

Deleting any user who owned an API token or appeared in the audit log
hit the users FK and 500ed - the import's 'importer' account being the
guaranteed case (its PAT plus every audit row the import wrote).
Tokens are revoked outright; audit history is kept but detached
(userid NULL), so the trail survives the account.
This commit is contained in:
cproudlock
2026-07-17 10:54:47 -04:00
parent bb5308bae0
commit 0cc205d25e
2 changed files with 428 additions and 389 deletions

View File

@@ -1,389 +1,399 @@
"""User management API routes.""" """User management API routes."""
from flask import Blueprint, request from flask import Blueprint, request
from flask_jwt_extended import jwt_required, current_user from flask_jwt_extended import jwt_required, current_user
from werkzeug.security import generate_password_hash from werkzeug.security import generate_password_hash
from shopdb.extensions import db from shopdb.extensions import db
from shopdb.core.models import ( from shopdb.core.models import (
User, Role, Permission, AuditLog, full_permission_catalog) User, Role, Permission, AuditLog, full_permission_catalog)
from shopdb.utils.responses import success_response, error_response, ErrorCodes from shopdb.utils.responses import success_response, error_response, ErrorCodes
from shopdb.utils.authz import require_role from shopdb.utils.authz import require_role
users_bp = Blueprint('users', __name__) users_bp = Blueprint('users', __name__)
@users_bp.route('', methods=['GET']) @users_bp.route('', methods=['GET'])
@jwt_required() @jwt_required()
@require_role('admin') @require_role('admin')
def list_users(): def list_users():
"""List all users.""" """List all users."""
users = User.query.order_by(User.username).all() users = User.query.order_by(User.username).all()
return success_response([user_to_dict(u) for u in users]) return success_response([user_to_dict(u) for u in users])
@users_bp.route('/<int:userid>', methods=['GET']) @users_bp.route('/<int:userid>', methods=['GET'])
@jwt_required() @jwt_required()
def get_user(userid: int): def get_user(userid: int):
"""Get a single user.""" """Get a single user."""
# inline: decorators cannot express admin-or-self # inline: decorators cannot express admin-or-self
if not current_user.hasrole('admin') and current_user.userid != userid: if not current_user.hasrole('admin') and current_user.userid != userid:
return error_response(ErrorCodes.FORBIDDEN, 'Access denied', http_code=403) return error_response(ErrorCodes.FORBIDDEN, 'Access denied', http_code=403)
user = db.session.get(User, userid) user = db.session.get(User, userid)
if not user: if not user:
return error_response(ErrorCodes.NOT_FOUND, 'User not found', http_code=404) return error_response(ErrorCodes.NOT_FOUND, 'User not found', http_code=404)
return success_response(user_to_dict(user)) return success_response(user_to_dict(user))
@users_bp.route('', methods=['POST']) @users_bp.route('', methods=['POST'])
@jwt_required() @jwt_required()
@require_role('admin') @require_role('admin')
def create_user(): def create_user():
"""Create a new user.""" """Create a new user."""
data = request.get_json() data = request.get_json()
if not data: if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'Request body required') return error_response(ErrorCodes.VALIDATION_ERROR, 'Request body required')
# Validate required fields # Validate required fields
if not data.get('username'): if not data.get('username'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'Username is required') return error_response(ErrorCodes.VALIDATION_ERROR, 'Username is required')
if not data.get('email'): if not data.get('email'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'Email is required') return error_response(ErrorCodes.VALIDATION_ERROR, 'Email is required')
if not data.get('password'): if not data.get('password'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'Password is required') return error_response(ErrorCodes.VALIDATION_ERROR, 'Password is required')
# Check uniqueness # Check uniqueness
if User.query.filter_by(username=data['username']).first(): if User.query.filter_by(username=data['username']).first():
return error_response(ErrorCodes.CONFLICT, 'Username already exists', http_code=409) return error_response(ErrorCodes.CONFLICT, 'Username already exists', http_code=409)
if User.query.filter_by(email=data['email']).first(): if User.query.filter_by(email=data['email']).first():
return error_response(ErrorCodes.CONFLICT, 'Email already exists', http_code=409) return error_response(ErrorCodes.CONFLICT, 'Email already exists', http_code=409)
# Admin-created accounts are forced to change the password on first login # Admin-created accounts are forced to change the password on first login
# unless the admin explicitly opts out. # unless the admin explicitly opts out.
mustchange = data.get('mustchangepassword', True) mustchange = data.get('mustchangepassword', True)
user = User( user = User(
username=data['username'], username=data['username'],
email=data['email'], email=data['email'],
passwordhash=generate_password_hash(data['password']), passwordhash=generate_password_hash(data['password']),
firstname=data.get('firstname'), firstname=data.get('firstname'),
lastname=data.get('lastname'), lastname=data.get('lastname'),
isactive=data.get('isactive', True), isactive=data.get('isactive', True),
mustchangepassword=bool(mustchange) mustchangepassword=bool(mustchange)
) )
# Assign roles # Assign roles
role_ids = data.get('roles', []) role_ids = data.get('roles', [])
if role_ids: if role_ids:
roles = Role.query.filter(Role.roleid.in_(role_ids)).all() roles = Role.query.filter(Role.roleid.in_(role_ids)).all()
user.roles = roles user.roles = roles
db.session.add(user) db.session.add(user)
# Audit log # Audit log
AuditLog.log('created', 'User', entityname=user.username) AuditLog.log('created', 'User', entityname=user.username)
db.session.commit() db.session.commit()
# Best-effort welcome email. The account exists regardless of mail outcome; # Best-effort welcome email. The account exists regardless of mail outcome;
# a failure is surfaced as a warning in the response, never a hard error. # a failure is surfaced as a warning in the response, never a hard error.
warning = None warning = None
if data.get('sendwelcome', True) and user.email: if data.get('sendwelcome', True) and user.email:
sent = _send_welcome_email(user, data['password']) sent = _send_welcome_email(user, data['password'])
if not sent: if not sent:
warning = 'User created but the welcome email could not be sent.' warning = 'User created but the welcome email could not be sent.'
payload = user_to_dict(user) payload = user_to_dict(user)
if warning: if warning:
payload['warning'] = warning payload['warning'] = warning
return success_response(payload, message='User created', http_code=201) return success_response(payload, message='User created', http_code=201)
def _send_welcome_email(user, temp_password): def _send_welcome_email(user, temp_password):
"""Send a new-user welcome email with sign-in details. Returns True on send. """Send a new-user welcome email with sign-in details. Returns True on send.
Best-effort: any failure (including email being disabled) returns False so Best-effort: any failure (including email being disabled) returns False so
the caller can surface a soft warning without failing user creation. the caller can surface a soft warning without failing user creation.
""" """
from shopdb.core.api.settings import get_cached_settings from shopdb.core.api.settings import get_cached_settings
from shopdb.utils.mailer import render_email, send_email from shopdb.utils.mailer import render_email, send_email
settings = get_cached_settings() or {} settings = get_cached_settings() or {}
facility = settings.get('facility_name') or 'ShopDB' facility = settings.get('facility_name') or 'ShopDB'
base_url = (settings.get('site_base_url') or '').rstrip('/') base_url = (settings.get('site_base_url') or '').rstrip('/')
login_link = f'{base_url}/login' if base_url else 'the ShopDB sign-in page' login_link = f'{base_url}/login' if base_url else 'the ShopDB sign-in page'
body = ( body = (
f'<p>An account has been created for you at <strong>{facility}</strong>.</p>' f'<p>An account has been created for you at <strong>{facility}</strong>.</p>'
'<table style="border-collapse:collapse;font-size:14px;margin:12px 0;">' '<table style="border-collapse:collapse;font-size:14px;margin:12px 0;">'
f'<tr><td style="padding:4px 12px 4px 0;color:#666;">Username</td>' f'<tr><td style="padding:4px 12px 4px 0;color:#666;">Username</td>'
f'<td><strong>{user.username}</strong></td></tr>' f'<td><strong>{user.username}</strong></td></tr>'
f'<tr><td style="padding:4px 12px 4px 0;color:#666;">Temporary password</td>' f'<tr><td style="padding:4px 12px 4px 0;color:#666;">Temporary password</td>'
f'<td><code>{temp_password}</code></td></tr>' f'<td><code>{temp_password}</code></td></tr>'
'</table>' '</table>'
f'<p>Sign in at {login_link}. You will be asked to set a new password ' f'<p>Sign in at {login_link}. You will be asked to set a new password '
'the first time you log in.</p>' 'the first time you log in.</p>'
) )
html, text = render_email(f'Welcome to {facility}', body) html, text = render_email(f'Welcome to {facility}', body)
return send_email(user.email, f'Your {facility} account', html, text=text) return send_email(user.email, f'Your {facility} account', html, text=text)
@users_bp.route('/<int:userid>', methods=['PUT']) @users_bp.route('/<int:userid>', methods=['PUT'])
@jwt_required() @jwt_required()
def update_user(userid: int): def update_user(userid: int):
"""Update a user.""" """Update a user."""
# inline: decorators cannot express admin-or-self # inline: decorators cannot express admin-or-self
if not current_user.hasrole('admin') and current_user.userid != userid: if not current_user.hasrole('admin') and current_user.userid != userid:
return error_response(ErrorCodes.FORBIDDEN, 'Access denied', http_code=403) return error_response(ErrorCodes.FORBIDDEN, 'Access denied', http_code=403)
user = db.session.get(User, userid) user = db.session.get(User, userid)
if not user: if not user:
return error_response(ErrorCodes.NOT_FOUND, 'User not found', http_code=404) return error_response(ErrorCodes.NOT_FOUND, 'User not found', http_code=404)
data = request.get_json() data = request.get_json()
if not data: if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'Request body required') return error_response(ErrorCodes.VALIDATION_ERROR, 'Request body required')
changes = {} changes = {}
# Update fields # Update fields
if 'email' in data and data['email'] != user.email: if 'email' in data and data['email'] != user.email:
if User.query.filter(User.email == data['email'], User.userid != userid).first(): if User.query.filter(User.email == data['email'], User.userid != userid).first():
return error_response(ErrorCodes.CONFLICT, 'Email already in use', http_code=409) return error_response(ErrorCodes.CONFLICT, 'Email already in use', http_code=409)
changes['email'] = {'old': user.email, 'new': data['email']} changes['email'] = {'old': user.email, 'new': data['email']}
user.email = data['email'] user.email = data['email']
if 'firstname' in data: if 'firstname' in data:
if data['firstname'] != user.firstname: if data['firstname'] != user.firstname:
changes['firstname'] = {'old': user.firstname, 'new': data['firstname']} changes['firstname'] = {'old': user.firstname, 'new': data['firstname']}
user.firstname = data['firstname'] user.firstname = data['firstname']
if 'lastname' in data: if 'lastname' in data:
if data['lastname'] != user.lastname: if data['lastname'] != user.lastname:
changes['lastname'] = {'old': user.lastname, 'new': data['lastname']} changes['lastname'] = {'old': user.lastname, 'new': data['lastname']}
user.lastname = data['lastname'] user.lastname = data['lastname']
# Admin-only fields (inline: gates a subset of fields on a shared route) # Admin-only fields (inline: gates a subset of fields on a shared route)
if current_user.hasrole('admin'): if current_user.hasrole('admin'):
if 'isactive' in data: if 'isactive' in data:
if data['isactive'] != user.isactive: if data['isactive'] != user.isactive:
changes['isactive'] = {'old': user.isactive, 'new': data['isactive']} changes['isactive'] = {'old': user.isactive, 'new': data['isactive']}
user.isactive = data['isactive'] user.isactive = data['isactive']
if 'roles' in data: if 'roles' in data:
old_roles = [r.rolename for r in user.roles] old_roles = [r.rolename for r in user.roles]
roles = Role.query.filter(Role.roleid.in_(data['roles'])).all() roles = Role.query.filter(Role.roleid.in_(data['roles'])).all()
new_roles = [r.rolename for r in roles] new_roles = [r.rolename for r in roles]
if set(old_roles) != set(new_roles): if set(old_roles) != set(new_roles):
changes['roles'] = {'old': old_roles, 'new': new_roles} changes['roles'] = {'old': old_roles, 'new': new_roles}
user.roles = roles user.roles = roles
# Unlock user # Unlock user
if data.get('unlock'): if data.get('unlock'):
user.lockeduntil = None user.lockeduntil = None
user.failedlogins = 0 user.failedlogins = 0
changes['unlocked'] = {'old': True, 'new': False} changes['unlocked'] = {'old': True, 'new': False}
# Password change # Password change
if 'password' in data and data['password']: if 'password' in data and data['password']:
user.passwordhash = generate_password_hash(data['password']) user.passwordhash = generate_password_hash(data['password'])
changes['password'] = {'old': '***', 'new': '***'} changes['password'] = {'old': '***', 'new': '***'}
if changes: if changes:
AuditLog.log('updated', 'User', entityid=user.userid, entityname=user.username, changes=changes) AuditLog.log('updated', 'User', entityid=user.userid, entityname=user.username, changes=changes)
db.session.commit() db.session.commit()
return success_response(user_to_dict(user), message='User updated') return success_response(user_to_dict(user), message='User updated')
@users_bp.route('/<int:userid>', methods=['DELETE']) @users_bp.route('/<int:userid>', methods=['DELETE'])
@jwt_required() @jwt_required()
@require_role('admin') @require_role('admin')
def delete_user(userid: int): def delete_user(userid: int):
"""Delete a user.""" """Delete a user."""
if current_user.userid == userid: if current_user.userid == userid:
return error_response(ErrorCodes.VALIDATION_ERROR, 'Cannot delete your own account') return error_response(ErrorCodes.VALIDATION_ERROR, 'Cannot delete your own account')
user = db.session.get(User, userid) user = db.session.get(User, userid)
if not user: if not user:
return error_response(ErrorCodes.NOT_FOUND, 'User not found', http_code=404) return error_response(ErrorCodes.NOT_FOUND, 'User not found', http_code=404)
username = user.username username = user.username
db.session.delete(user)
# Rows that reference the user would otherwise block the delete:
AuditLog.log('deleted', 'User', entityid=userid, entityname=username) # revoke their API tokens outright, and DETACH their audit history
# (userid -> NULL) - the log rows themselves are kept, entityname and
db.session.commit() # details still tell the story.
from shopdb.core.models import ApiToken
return success_response(None, message='User deleted') ApiToken.query.filter_by(userid=userid).delete(synchronize_session=False)
AuditLog.query.filter_by(userid=userid).update(
{'userid': None}, synchronize_session=False)
# Permissions endpoints
@users_bp.route('/permissions', methods=['GET']) db.session.delete(user)
@jwt_required()
def list_permissions(): AuditLog.log('deleted', 'User', entityid=userid, entityname=username)
"""List assignable permissions grouped by category.
db.session.commit()
Driven by full_permission_catalog() (core plus ENABLED plugins) so a
disabled plugin's permissions drop out of the role grid. Roles assign by return success_response(None, message='User deleted')
name; the permissionid comes from the seeded Permission row when present.
"""
idbyname = {p.name: p.permissionid for p in Permission.query.all()} # Permissions endpoints
@users_bp.route('/permissions', methods=['GET'])
catalog = full_permission_catalog() @jwt_required()
catalog.sort(key=lambda e: (e[2], e[0])) def list_permissions():
"""List assignable permissions grouped by category.
grouped = {}
flat = [] Driven by full_permission_catalog() (core plus ENABLED plugins) so a
for name, description, category in catalog: disabled plugin's permissions drop out of the role grid. Roles assign by
entry = { name; the permissionid comes from the seeded Permission row when present.
'permissionid': idbyname.get(name), """
'name': name, idbyname = {p.name: p.permissionid for p in Permission.query.all()}
'description': description,
} catalog = full_permission_catalog()
grouped.setdefault(category, []).append(entry) catalog.sort(key=lambda e: (e[2], e[0]))
flat.append({**entry, 'category': category})
grouped = {}
return success_response({'permissions': flat, 'grouped': grouped}) flat = []
for name, description, category in catalog:
entry = {
# Roles endpoints 'permissionid': idbyname.get(name),
@users_bp.route('/roles', methods=['GET']) 'name': name,
@jwt_required() 'description': description,
def list_roles(): }
"""List all roles with their permissions.""" grouped.setdefault(category, []).append(entry)
roles = Role.query.order_by(Role.rolename).all() flat.append({**entry, 'category': category})
return success_response([{
'roleid': r.roleid, return success_response({'permissions': flat, 'grouped': grouped})
'rolename': r.rolename,
'description': r.description,
'usercount': r.users.count(), # Roles endpoints
'permissions': [p.name for p in r.permissions], @users_bp.route('/roles', methods=['GET'])
'isadmin': r.rolename == 'admin' @jwt_required()
} for r in roles]) def list_roles():
"""List all roles with their permissions."""
roles = Role.query.order_by(Role.rolename).all()
@users_bp.route('/roles', methods=['POST']) return success_response([{
@jwt_required() 'roleid': r.roleid,
@require_role('admin') 'rolename': r.rolename,
def create_role(): 'description': r.description,
"""Create a new role.""" 'usercount': r.users.count(),
data = request.get_json() 'permissions': [p.name for p in r.permissions],
if not data or not data.get('rolename'): 'isadmin': r.rolename == 'admin'
return error_response(ErrorCodes.VALIDATION_ERROR, 'Role name is required') } for r in roles])
if Role.query.filter_by(rolename=data['rolename']).first():
return error_response(ErrorCodes.CONFLICT, 'Role already exists', http_code=409) @users_bp.route('/roles', methods=['POST'])
@jwt_required()
role = Role( @require_role('admin')
rolename=data['rolename'], def create_role():
description=data.get('description') """Create a new role."""
) data = request.get_json()
if not data or not data.get('rolename'):
# Assign permissions return error_response(ErrorCodes.VALIDATION_ERROR, 'Role name is required')
if 'permissions' in data:
perms = Permission.query.filter(Permission.name.in_(data['permissions'])).all() if Role.query.filter_by(rolename=data['rolename']).first():
role.permissions = perms return error_response(ErrorCodes.CONFLICT, 'Role already exists', http_code=409)
db.session.add(role) role = Role(
rolename=data['rolename'],
AuditLog.log('created', 'Role', entityname=role.rolename) description=data.get('description')
)
db.session.commit()
# Assign permissions
return success_response({ if 'permissions' in data:
'roleid': role.roleid, perms = Permission.query.filter(Permission.name.in_(data['permissions'])).all()
'rolename': role.rolename, role.permissions = perms
'description': role.description,
'permissions': [p.name for p in role.permissions] db.session.add(role)
}, message='Role created', http_code=201)
AuditLog.log('created', 'Role', entityname=role.rolename)
@users_bp.route('/roles/<int:roleid>', methods=['PUT']) db.session.commit()
@jwt_required()
@require_role('admin') return success_response({
def update_role(roleid: int): 'roleid': role.roleid,
"""Update a role.""" 'rolename': role.rolename,
role = db.session.get(Role, roleid) 'description': role.description,
if not role: 'permissions': [p.name for p in role.permissions]
return error_response(ErrorCodes.NOT_FOUND, 'Role not found', http_code=404) }, message='Role created', http_code=201)
# Cannot modify admin role permissions
if role.rolename == 'admin' and 'permissions' in request.get_json(): @users_bp.route('/roles/<int:roleid>', methods=['PUT'])
return error_response(ErrorCodes.VALIDATION_ERROR, 'Cannot modify admin role permissions') @jwt_required()
@require_role('admin')
data = request.get_json() def update_role(roleid: int):
changes = {} """Update a role."""
role = db.session.get(Role, roleid)
if 'description' in data: if not role:
if data['description'] != role.description: return error_response(ErrorCodes.NOT_FOUND, 'Role not found', http_code=404)
changes['description'] = {'old': role.description, 'new': data['description']}
role.description = data['description'] # Cannot modify admin role permissions
if role.rolename == 'admin' and 'permissions' in request.get_json():
# Update permissions return error_response(ErrorCodes.VALIDATION_ERROR, 'Cannot modify admin role permissions')
if 'permissions' in data and role.rolename != 'admin':
old_perms = [p.name for p in role.permissions] data = request.get_json()
perms = Permission.query.filter(Permission.name.in_(data['permissions'])).all() changes = {}
new_perms = [p.name for p in perms]
if set(old_perms) != set(new_perms): if 'description' in data:
changes['permissions'] = {'old': old_perms, 'new': new_perms} if data['description'] != role.description:
role.permissions = perms changes['description'] = {'old': role.description, 'new': data['description']}
role.description = data['description']
if changes:
AuditLog.log('updated', 'Role', entityid=role.roleid, entityname=role.rolename, changes=changes) # Update permissions
if 'permissions' in data and role.rolename != 'admin':
db.session.commit() old_perms = [p.name for p in role.permissions]
perms = Permission.query.filter(Permission.name.in_(data['permissions'])).all()
return success_response({ new_perms = [p.name for p in perms]
'roleid': role.roleid, if set(old_perms) != set(new_perms):
'rolename': role.rolename, changes['permissions'] = {'old': old_perms, 'new': new_perms}
'description': role.description, role.permissions = perms
'permissions': [p.name for p in role.permissions]
}, message='Role updated') if changes:
AuditLog.log('updated', 'Role', entityid=role.roleid, entityname=role.rolename, changes=changes)
@users_bp.route('/roles/<int:roleid>', methods=['DELETE']) db.session.commit()
@jwt_required()
@require_role('admin') return success_response({
def delete_role(roleid: int): 'roleid': role.roleid,
"""Delete a role.""" 'rolename': role.rolename,
role = db.session.get(Role, roleid) 'description': role.description,
if not role: 'permissions': [p.name for p in role.permissions]
return error_response(ErrorCodes.NOT_FOUND, 'Role not found', http_code=404) }, message='Role updated')
if role.rolename == 'admin':
return error_response(ErrorCodes.VALIDATION_ERROR, 'Cannot delete the admin role') @users_bp.route('/roles/<int:roleid>', methods=['DELETE'])
@jwt_required()
if role.users.count() > 0: @require_role('admin')
return error_response(ErrorCodes.VALIDATION_ERROR, f'Role is assigned to {role.users.count()} user(s)') def delete_role(roleid: int):
"""Delete a role."""
rolename = role.rolename role = db.session.get(Role, roleid)
db.session.delete(role) if not role:
return error_response(ErrorCodes.NOT_FOUND, 'Role not found', http_code=404)
AuditLog.log('deleted', 'Role', entityid=roleid, entityname=rolename)
if role.rolename == 'admin':
db.session.commit() return error_response(ErrorCodes.VALIDATION_ERROR, 'Cannot delete the admin role')
return success_response(None, message='Role deleted') if role.users.count() > 0:
return error_response(ErrorCodes.VALIDATION_ERROR, f'Role is assigned to {role.users.count()} user(s)')
def user_to_dict(user: User) -> dict: rolename = role.rolename
"""Convert user to dict for API response.""" db.session.delete(role)
return {
'userid': user.userid, AuditLog.log('deleted', 'Role', entityid=roleid, entityname=rolename)
'username': user.username,
'email': user.email, db.session.commit()
'firstname': user.firstname,
'lastname': user.lastname, return success_response(None, message='Role deleted')
'isactive': user.isactive,
'islocked': user.islocked,
'mustchangepassword': bool(user.mustchangepassword), def user_to_dict(user: User) -> dict:
'lastlogindate': user.lastlogindate.isoformat() + 'Z' if user.lastlogindate else None, """Convert user to dict for API response."""
'failedlogins': user.failedlogins, return {
'roles': [{'roleid': r.roleid, 'rolename': r.rolename} for r in user.roles], 'userid': user.userid,
'createddate': user.createddate.isoformat() + 'Z' if user.createddate else None, 'username': user.username,
'modifieddate': user.modifieddate.isoformat() + 'Z' if user.modifieddate else None 'email': user.email,
} 'firstname': user.firstname,
'lastname': user.lastname,
'isactive': user.isactive,
'islocked': user.islocked,
'mustchangepassword': bool(user.mustchangepassword),
'lastlogindate': user.lastlogindate.isoformat() + 'Z' if user.lastlogindate else None,
'failedlogins': user.failedlogins,
'roles': [{'roleid': r.roleid, 'rolename': r.rolename} for r in user.roles],
'createddate': user.createddate.isoformat() + 'Z' if user.createddate else None,
'modifieddate': user.modifieddate.isoformat() + 'Z' if user.modifieddate else None
}

View File

@@ -0,0 +1,29 @@
def test_delete_user_with_tokens_and_audit_history(client, auth_headers, app, db):
"""Deleting a user revokes their API tokens and detaches (not deletes)
their audit rows - the importer-user case."""
from werkzeug.security import generate_password_hash
from shopdb.core.models import User, ApiToken, AuditLog
with app.app_context():
user = User(username='importer2', email='importer2@test.local',
passwordhash=generate_password_hash('x'), isactive=True)
db.session.add(user)
db.session.flush()
db.session.add(ApiToken(userid=user.userid, name='import token',
tokenprefix='deadbeef', tokenhash='x' * 64))
db.session.add(AuditLog(userid=user.userid, action='created',
entitytype='Asset', entityid=1,
entityname='imported thing'))
db.session.commit()
userid = user.userid
response = client.delete(f'/api/users/{userid}', headers=auth_headers)
assert response.status_code == 200, response.get_json()
with app.app_context():
assert db.session.get(User, userid) is None
assert ApiToken.query.filter_by(userid=userid).count() == 0
detached = AuditLog.query.filter_by(entityname='imported thing').one()
assert detached.userid is None