feat: Implement account deletion (mark for removal) feature
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 23s
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 23s
- Added `marked_for_deletion` and `marked_for_deletion_at` fields to User model (Python and TypeScript) with serialization updates - Created POST /api/user/mark-for-deletion endpoint with JWT auth, error handling, and SSE event trigger - Blocked login and password reset for marked users; added new error codes ACCOUNT_MARKED_FOR_DELETION and ALREADY_MARKED - Updated UserProfile.vue with "Delete My Account" button, confirmation modal (email input), loading state, success/error modals, and sign-out/redirect logic - Synced error codes and model fields between backend and frontend - Added and updated backend and frontend tests to cover all flows and edge cases - All Acceptance Criteria from the spec are complete and verified
This commit is contained in:
177
backend/tests/test_user_api.py
Normal file
177
backend/tests/test_user_api.py
Normal file
@@ -0,0 +1,177 @@
|
||||
import pytest
|
||||
from datetime import datetime, timezone
|
||||
from flask import Flask
|
||||
from api.user_api import user_api
|
||||
from api.auth_api import auth_api
|
||||
from db.db import users_db
|
||||
from tinydb import Query
|
||||
import jwt
|
||||
|
||||
# Test user credentials
|
||||
TEST_EMAIL = "usertest@example.com"
|
||||
TEST_PASSWORD = "testpass123"
|
||||
MARKED_EMAIL = "marked@example.com"
|
||||
MARKED_PASSWORD = "markedpass"
|
||||
|
||||
def add_test_users():
|
||||
"""Add test users to the database."""
|
||||
# Remove if exists
|
||||
users_db.remove(Query().email == TEST_EMAIL)
|
||||
users_db.remove(Query().email == MARKED_EMAIL)
|
||||
|
||||
# Add regular test user
|
||||
users_db.insert({
|
||||
"id": "test_user_id",
|
||||
"first_name": "Test",
|
||||
"last_name": "User",
|
||||
"email": TEST_EMAIL,
|
||||
"password": TEST_PASSWORD,
|
||||
"verified": True,
|
||||
"image_id": "boy01",
|
||||
"marked_for_deletion": False,
|
||||
"marked_for_deletion_at": None
|
||||
})
|
||||
|
||||
# Add user already marked for deletion
|
||||
users_db.insert({
|
||||
"id": "marked_user_id",
|
||||
"first_name": "Marked",
|
||||
"last_name": "User",
|
||||
"email": MARKED_EMAIL,
|
||||
"password": MARKED_PASSWORD,
|
||||
"verified": True,
|
||||
"image_id": "girl01",
|
||||
"marked_for_deletion": True,
|
||||
"marked_for_deletion_at": "2024-01-15T10:30:00+00:00"
|
||||
})
|
||||
|
||||
def login_and_get_token(client, email, password):
|
||||
"""Login and extract JWT token from response."""
|
||||
resp = client.post('/login', json={"email": email, "password": password})
|
||||
assert resp.status_code == 200
|
||||
# Extract token from Set-Cookie header
|
||||
set_cookie = resp.headers.get("Set-Cookie")
|
||||
assert set_cookie and "token=" in set_cookie
|
||||
# Flask test client automatically handles cookies
|
||||
return resp
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
"""Setup Flask test client with registered blueprints."""
|
||||
app = Flask(__name__)
|
||||
app.register_blueprint(user_api)
|
||||
app.register_blueprint(auth_api)
|
||||
app.config['TESTING'] = True
|
||||
app.config['SECRET_KEY'] = 'supersecretkey'
|
||||
app.config['FRONTEND_URL'] = 'http://localhost:5173' # Needed for email_sender
|
||||
with app.test_client() as client:
|
||||
add_test_users()
|
||||
yield client
|
||||
|
||||
@pytest.fixture
|
||||
def authenticated_client(client):
|
||||
"""Setup client with authenticated user session."""
|
||||
login_and_get_token(client, TEST_EMAIL, TEST_PASSWORD)
|
||||
return client
|
||||
|
||||
@pytest.fixture
|
||||
def marked_client(client):
|
||||
"""Setup client with marked-for-deletion user session."""
|
||||
login_and_get_token(client, MARKED_EMAIL, MARKED_PASSWORD)
|
||||
return client
|
||||
|
||||
def test_mark_user_for_deletion_success(authenticated_client):
|
||||
"""Test successfully marking a user account for deletion."""
|
||||
response = authenticated_client.post('/user/mark-for-deletion', json={"email": TEST_EMAIL})
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data['success'] is True
|
||||
|
||||
# Verify database was updated
|
||||
UserQuery = Query()
|
||||
user = users_db.search(UserQuery.email == TEST_EMAIL)[0]
|
||||
assert user['marked_for_deletion'] is True
|
||||
assert user['marked_for_deletion_at'] is not None
|
||||
|
||||
# Verify timestamp is valid ISO format
|
||||
marked_at = datetime.fromisoformat(user['marked_for_deletion_at'])
|
||||
assert marked_at.tzinfo is not None
|
||||
|
||||
def test_login_for_marked_user_returns_403(client):
|
||||
"""Test that login for a marked-for-deletion user returns 403 Forbidden."""
|
||||
response = client.post('/login', json={
|
||||
"email": MARKED_EMAIL,
|
||||
"password": MARKED_PASSWORD
|
||||
})
|
||||
assert response.status_code == 403
|
||||
data = response.get_json()
|
||||
assert 'error' in data
|
||||
assert data['code'] == 'ACCOUNT_MARKED_FOR_DELETION'
|
||||
|
||||
def test_mark_for_deletion_requires_auth(client):
|
||||
"""Test that marking for deletion requires authentication."""
|
||||
response = client.post('/user/mark-for-deletion', json={"email": TEST_EMAIL})
|
||||
assert response.status_code == 401
|
||||
data = response.get_json()
|
||||
assert 'error' in data
|
||||
|
||||
def test_login_blocked_for_marked_user(client):
|
||||
"""Test that login is blocked for users marked for deletion."""
|
||||
response = client.post('/login', json={
|
||||
"email": MARKED_EMAIL,
|
||||
"password": MARKED_PASSWORD
|
||||
})
|
||||
assert response.status_code == 403
|
||||
data = response.get_json()
|
||||
assert 'error' in data
|
||||
assert data['code'] == 'ACCOUNT_MARKED_FOR_DELETION'
|
||||
|
||||
def test_login_succeeds_for_unmarked_user(client):
|
||||
"""Test that login works normally for users not marked for deletion."""
|
||||
response = client.post('/login', json={
|
||||
"email": TEST_EMAIL,
|
||||
"password": TEST_PASSWORD
|
||||
})
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert 'message' in data
|
||||
|
||||
def test_password_reset_ignored_for_marked_user(client):
|
||||
"""Test that password reset requests are silently ignored for marked users."""
|
||||
response = client.post('/request-password-reset', json={"email": MARKED_EMAIL})
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert 'message' in data
|
||||
|
||||
def test_password_reset_works_for_unmarked_user(client):
|
||||
"""Test that password reset works normally for unmarked users."""
|
||||
response = client.post('/request-password-reset', json={"email": TEST_EMAIL})
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert 'message' in data
|
||||
|
||||
def test_mark_for_deletion_updates_timestamp(authenticated_client):
|
||||
"""Test that marking for deletion sets a proper timestamp."""
|
||||
before_time = datetime.now(timezone.utc)
|
||||
|
||||
response = authenticated_client.post('/user/mark-for-deletion', json={"email": TEST_EMAIL})
|
||||
assert response.status_code == 200
|
||||
|
||||
after_time = datetime.now(timezone.utc)
|
||||
|
||||
# Verify timestamp is between before and after
|
||||
UserQuery = Query()
|
||||
user = users_db.search(UserQuery.email == TEST_EMAIL)[0]
|
||||
marked_at = datetime.fromisoformat(user['marked_for_deletion_at'])
|
||||
|
||||
assert before_time <= marked_at <= after_time
|
||||
|
||||
def test_mark_for_deletion_with_invalid_jwt(client):
|
||||
"""Test marking for deletion with invalid JWT token."""
|
||||
# Set invalid cookie manually
|
||||
client.set_cookie('token', 'invalid.jwt.token')
|
||||
|
||||
response = client.post('/user/mark-for-deletion', json={})
|
||||
assert response.status_code == 401
|
||||
data = response.get_json()
|
||||
assert 'error' in data
|
||||
Reference in New Issue
Block a user