Add DB viewer access logging, account deletion, and forced password change

Logs a distinct db_viewer_accessed event (not just the earlier
session_created "requested" event) when an admin's browser actually
completes the hand-off into Adminer. Adds a password-confirmed
account-deletion box to Settings, relying on the existing ON DELETE
CASCADE foreign keys to clean up everything the account owns. Adds an
admin-only "require password change" flag that get_current_user
enforces server-side (403 on everything except /auth/me,
/auth/change-password, /auth/logout) - meant for handing a demo
account to someone with a known sample password.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
2026-08-05 23:41:21 -04:00
co-authored by Claude Sonnet 5
parent 3d6fe56991
commit 4ee38b6241
23 changed files with 1056 additions and 7 deletions
+19 -1
View File
@@ -15,13 +15,14 @@ from app.core.security import get_client_ip
from app.db.session import get_db from app.db.session import get_db
from app.models.user import User from app.models.user import User
from app.repositories.unban_request_repository import UnbanRequestRepository from app.repositories.unban_request_repository import UnbanRequestRepository
from app.schemas.auth import RequirePasswordChangeRequest
from app.schemas.unban import ( from app.schemas.unban import (
BanIpRequest, BanIpRequest,
IpBanResponse, IpBanResponse,
UnbanRequestPayload, UnbanRequestPayload,
UnbanRequestResponse, UnbanRequestResponse,
) )
from app.services import unban_service from app.services import auth_service, unban_service
router = APIRouter(tags=["admin"]) router = APIRouter(tags=["admin"])
@@ -89,3 +90,20 @@ async def reject_unban_request(
_admin: User = Depends(require_admin), _admin: User = Depends(require_admin),
) -> None: ) -> None:
await unban_service.reject_unban_request(db, request_id) await unban_service.reject_unban_request(db, request_id)
@router.post("/admin/users/require-password-change", status_code=status.HTTP_204_NO_CONTENT)
async def require_password_change(
request: Request,
payload: RequirePasswordChangeRequest,
settings: Settings = Depends(get_settings),
db: AsyncSession = Depends(get_db),
_admin: User = Depends(require_admin),
) -> None:
"""Flags an account (by email) so its next request is blocked
everywhere except /auth/me, /auth/change-password, and /auth/logout
until they set a new password - see app.auth.dependencies.get_current_user
and app.services.auth_service.require_password_change. Meant for handing
a demo account to someone with a known sample password."""
client_ip = get_client_ip(request, settings)
await auth_service.require_password_change(db, client_ip, payload.email)
+23
View File
@@ -14,7 +14,9 @@ from app.core.security import get_client_ip, is_localhost
from app.db.session import get_db from app.db.session import get_db
from app.models.user import LOCAL_DEV_USER_ID, User from app.models.user import LOCAL_DEV_USER_ID, User
from app.schemas.auth import ( from app.schemas.auth import (
ChangePasswordRequest,
ConfirmPasswordResetRequest, ConfirmPasswordResetRequest,
DeleteAccountRequest,
LoginRequest, LoginRequest,
LogoutRequest, LogoutRequest,
RefreshRequest, RefreshRequest,
@@ -166,3 +168,24 @@ async def me(user: User = Depends(get_current_user)) -> MeResponse:
effective_auth_mode = "local" if user.id == LOCAL_DEV_USER_ID else "jwt" effective_auth_mode = "local" if user.id == LOCAL_DEV_USER_ID else "jwt"
base = UserResponse.model_validate(user).model_dump() base = UserResponse.model_validate(user).model_dump()
return MeResponse(**base, auth_mode=effective_auth_mode) return MeResponse(**base, auth_mode=effective_auth_mode)
@router.delete("/me", status_code=status.HTTP_204_NO_CONTENT)
async def delete_account(
payload: DeleteAccountRequest,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
) -> None:
await auth_service.delete_account(db, user, payload.password)
@router.post("/change-password", response_model=TokenResponse)
async def change_password(
request: Request,
payload: ChangePasswordRequest,
settings: Settings = Depends(get_settings),
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
) -> TokenResponse:
client_ip = get_client_ip(request, settings)
return await auth_service.change_password(db, settings, client_ip, user, payload)
+19 -1
View File
@@ -68,8 +68,10 @@ async def create_db_viewer_session(
@router.get("/bootstrap") @router.get("/bootstrap")
async def bootstrap_db_viewer_session( async def bootstrap_db_viewer_session(
request: Request,
token: str = Query(...), token: str = Query(...),
settings: Settings = Depends(get_settings), settings: Settings = Depends(get_settings),
db: AsyncSession = Depends(get_db),
) -> RedirectResponse: ) -> RedirectResponse:
"""Reached only via Nginx's db.<domain> /_auth location - never called """Reached only via Nginx's db.<domain> /_auth location - never called
directly by the frontend. No `require_admin` dependency: the bootstrap directly by the frontend. No `require_admin` dependency: the bootstrap
@@ -82,8 +84,24 @@ async def bootstrap_db_viewer_session(
status_code=status.HTTP_403_FORBIDDEN, detail="Invalid or expired token" status_code=status.HTTP_403_FORBIDDEN, detail="Invalid or expired token"
) from exc ) from exc
# Logged here, not at /session mint time, because this is the point the
# admin's browser actually completes the hand-off into Adminer - minting
# a token only proves they clicked the button, not that they got in.
await UserSecurityEventRepository(db).create(
user_id=decoded.user_id,
event_type=SecurityEventType.DB_VIEWER_ACCESSED,
ip_address=get_client_ip(request, settings),
)
await db.commit()
session_token = create_db_viewer_session_token(decoded.user_id, settings) session_token = create_db_viewer_session_token(decoded.user_id, settings)
response = RedirectResponse(url="/", status_code=status.HTTP_302_FOUND) # Adminer's own driver dropdown defaults to MySQL, not Postgres - a bare
# "/" redirect leaves it selecting MySQL against a host that only speaks
# Postgres, producing a "Connection refused" error that has nothing to
# do with this app's own auth. `?pgsql=postgres` pre-selects the right
# driver and server (the internal Compose service name, same in dev and
# prod) - only the Postgres password itself is left for the admin to type.
response = RedirectResponse(url="/?pgsql=postgres", status_code=status.HTTP_302_FOUND)
response.set_cookie( response.set_cookie(
SESSION_COOKIE_NAME, SESSION_COOKIE_NAME,
session_token, session_token,
+14
View File
@@ -29,6 +29,15 @@ from app.models.user import User
from app.repositories.user_repository import UserRepository from app.repositories.user_repository import UserRepository
from app.services.auth_service import get_or_create_local_user from app.services.auth_service import get_or_create_local_user
# Everything an account with must_change_password=True can still reach -
# just enough to discover the flag (/me), fix it (/change-password), and
# bail out (/logout, which doesn't even route through get_current_user but
# is listed for clarity). Every other endpoint 403s until they change it -
# see app.services.auth_service.require_password_change.
_PASSWORD_CHANGE_EXEMPT_PATHS = frozenset(
{"/api/v1/auth/me", "/api/v1/auth/change-password", "/api/v1/auth/logout"}
)
async def get_current_user( async def get_current_user(
request: Request, request: Request,
@@ -58,6 +67,11 @@ async def get_current_user(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired access token", detail="Invalid or expired access token",
) )
if user.must_change_password and request.url.path not in _PASSWORD_CHANGE_EXEMPT_PATHS:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Password change required",
)
return user return user
+3
View File
@@ -154,6 +154,9 @@ class SecurityEventType(StrEnum):
SERVER_SECRET_UPDATED = "server_secret_updated" SERVER_SECRET_UPDATED = "server_secret_updated"
API_KEY_UPDATED = "api_key_updated" API_KEY_UPDATED = "api_key_updated"
DB_VIEWER_SESSION_CREATED = "db_viewer_session_created" DB_VIEWER_SESSION_CREATED = "db_viewer_session_created"
DB_VIEWER_ACCESSED = "db_viewer_accessed"
PASSWORD_CHANGED = "password_changed"
PASSWORD_CHANGE_REQUIRED = "password_change_required"
class ApiKeyProvider(StrEnum): class ApiKeyProvider(StrEnum):
+6
View File
@@ -42,6 +42,12 @@ class User(Base, UUIDPrimaryKeyMixin, TimestampMixin):
failed_login_count: Mapped[int] = mapped_column(Integer, default=0) failed_login_count: Mapped[int] = mapped_column(Integer, default=0)
locked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) locked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# Admin-forced reset (e.g. before handing a demo account to someone) -
# see app.auth.dependencies.get_current_user, which blocks every
# endpoint except /auth/me, /auth/change-password, and /auth/logout
# while this is true.
must_change_password: Mapped[bool] = mapped_column(Boolean, default=False)
refresh_tokens: Mapped[list[RefreshToken]] = relationship( refresh_tokens: Mapped[list[RefreshToken]] = relationship(
back_populates="user", cascade="all, delete-orphan" back_populates="user", cascade="all, delete-orphan"
) )
@@ -23,6 +23,13 @@ class UserRepository:
result = await self.db.execute(select(User.email).where(User.is_admin.is_(True))) result = await self.db.execute(select(User.email).where(User.is_admin.is_(True)))
return list(result.scalars().all()) return list(result.scalars().all())
async def delete(self, user: User) -> None:
"""Cascades (ON DELETE CASCADE, see migrations) to every row the
user owns - companies and everything under them, refresh tokens,
security events, etc. Irreversible."""
await self.db.delete(user)
await self.db.flush()
async def create( async def create(
self, self,
*, *,
+18
View File
@@ -73,6 +73,24 @@ class ConfirmPasswordResetRequest(BaseModel):
return _validate_password_strength(value) return _validate_password_strength(value)
class DeleteAccountRequest(BaseModel):
password: str = Field(min_length=1, max_length=128)
class ChangePasswordRequest(BaseModel):
current_password: str = Field(min_length=1, max_length=128)
new_password: str = Field(min_length=10, max_length=128)
@field_validator("new_password")
@classmethod
def _password_strength(cls, value: str) -> str:
return _validate_password_strength(value)
class RequirePasswordChangeRequest(BaseModel):
email: EmailStr
class SecurityEventResponse(BaseModel): class SecurityEventResponse(BaseModel):
event_type: str event_type: str
ip_address: str ip_address: str
+1
View File
@@ -14,6 +14,7 @@ class UserResponse(BaseModel):
timezone: str timezone: str
is_active: bool is_active: bool
is_admin: bool is_admin: bool
must_change_password: bool
class MeResponse(UserResponse): class MeResponse(UserResponse):
+80 -1
View File
@@ -13,7 +13,13 @@ from datetime import UTC, datetime, timedelta
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import Settings from app.core.config import Settings
from app.core.errors import AuthenticationError, ConflictError, ThrottledError, ValidationAppError from app.core.errors import (
AuthenticationError,
ConflictError,
NotFoundError,
ThrottledError,
ValidationAppError,
)
from app.core.security import ( from app.core.security import (
InvalidTokenError, InvalidTokenError,
TokenType, TokenType,
@@ -37,6 +43,7 @@ from app.repositories.user_known_ip_repository import UserKnownIpRepository
from app.repositories.user_repository import UserRepository from app.repositories.user_repository import UserRepository
from app.repositories.user_security_event_repository import UserSecurityEventRepository from app.repositories.user_security_event_repository import UserSecurityEventRepository
from app.schemas.auth import ( from app.schemas.auth import (
ChangePasswordRequest,
ConfirmPasswordResetRequest, ConfirmPasswordResetRequest,
LoginRequest, LoginRequest,
RegisterRequest, RegisterRequest,
@@ -426,3 +433,75 @@ async def list_security_events(db: AsyncSession, user_id: uuid.UUID) -> list[Use
"""The calling user's own security activity - the user-facing """The calling user's own security activity - the user-facing
counterpart to the admin-only app-wide log feed (core/logging.py).""" counterpart to the admin-only app-wide log feed (core/logging.py)."""
return await UserSecurityEventRepository(db).list_for_user(user_id) return await UserSecurityEventRepository(db).list_for_user(user_id)
async def delete_account(db: AsyncSession, user: User, password: str) -> None:
"""Permanently deletes the account and, via ON DELETE CASCADE foreign
keys (see migrations), everything it owns - companies and everything
under them, refresh tokens, security events, API keys, etc. There is no
soft-delete or recovery path. Rejected for accounts with no
password_hash (the fixed AUTH_MODE=local user) - there's nothing to
verify the caller's identity against."""
if user.password_hash is None:
raise ValidationAppError("Account deletion isn't available for this account.")
if not verify_password(password, user.password_hash):
raise AuthenticationError("Incorrect password")
await UserRepository(db).delete(user)
await db.commit()
async def change_password(
db: AsyncSession,
settings: Settings,
client_ip: str,
user: User,
payload: ChangePasswordRequest,
) -> TokenResponse:
"""Authenticated in-app password change - distinct from
confirm_password_reset (which is the emailed-code flow for someone who
can't log in at all). Also the only way to clear must_change_password,
the admin-forced-reset flag (see require_password_change below)."""
if user.password_hash is None:
raise ValidationAppError("This account doesn't use password sign-in.")
if not verify_password(payload.current_password, user.password_hash):
raise AuthenticationError("Incorrect current password")
history_repo = PasswordHistoryRepository(db)
previous_hashes = await history_repo.list_hashes_for_user(user.id)
previous_hashes.append(user.password_hash)
if any(verify_password(payload.new_password, h) for h in previous_hashes):
raise ValidationAppError("You've used this password before. Choose a different one.")
await history_repo.add(user_id=user.id, password_hash=user.password_hash)
user.password_hash = hash_password(payload.new_password)
user.must_change_password = False
# Rotate every session, including the one making this request - the
# fresh token pair returned below replaces it immediately, so the
# caller keeps working without a forced re-login.
await RefreshTokenRepository(db).revoke_all_for_user(user.id)
await UserSecurityEventRepository(db).create(
user_id=user.id, event_type=SecurityEventType.PASSWORD_CHANGED, ip_address=client_ip
)
return await _issue_token_pair(db, settings, user)
async def require_password_change(db: AsyncSession, client_ip: str, email: str) -> User:
"""Admin action: flags an account so its next request is blocked
everywhere except /auth/me, /auth/change-password, and /auth/logout
(enforced in app.auth.dependencies.get_current_user) until they set a
new password. Meant for handing a demo account to someone with a known
sample password."""
user = await UserRepository(db).get_by_email(email)
if user is None:
raise NotFoundError("No account with that email")
if user.password_hash is None:
raise ValidationAppError("This account doesn't use password sign-in.")
user.must_change_password = True
await UserSecurityEventRepository(db).create(
user_id=user.id,
event_type=SecurityEventType.PASSWORD_CHANGE_REQUIRED,
ip_address=client_ip,
)
await db.commit()
return user
@@ -0,0 +1,32 @@
"""add must_change_password to users
Revision ID: 9e6c80c11da7
Revises: c34c769afc07
Create Date: 2026-08-06 03:10:00.000000
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "9e6c80c11da7"
down_revision: str | None = "c34c769afc07"
branch_labels: Sequence[str] | str | None = None
depends_on: Sequence[str] | str | None = None
def upgrade() -> None:
op.add_column(
"users",
sa.Column(
"must_change_password", sa.Boolean(), nullable=False, server_default=sa.false()
),
)
def downgrade() -> None:
op.drop_column("users", "must_change_password")
@@ -0,0 +1,260 @@
"""Account deletion (DELETE /auth/me) and admin-forced password change
(POST /admin/users/require-password-change -> POST /auth/change-password,
enforced by app.auth.dependencies.get_current_user)."""
from __future__ import annotations
import uuid
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.enums import SecurityEventType
from app.repositories.user_repository import UserRepository
from app.repositories.user_security_event_repository import UserSecurityEventRepository
def _unique_email() -> str:
return f"user-{uuid.uuid4().hex[:12]}@example.com"
def _register_and_login(client: TestClient, password: str = "correct-horse-1") -> dict:
email = _unique_email()
client.post(
"/api/v1/auth/register",
json={"email": email, "password": password, "display_name": "T"},
)
tokens = client.post("/api/v1/auth/login", json={"email": email, "password": password}).json()
return {"email": email, "headers": {"Authorization": f"Bearer {tokens['access_token']}"}}
async def _register_admin_and_login(client: TestClient, db_session: AsyncSession) -> dict[str, str]:
email = f"admin-{uuid.uuid4().hex[:12]}@example.com"
client.post(
"/api/v1/auth/register",
json={"email": email, "password": "correct-horse-1", "display_name": "Admin"},
)
user = await UserRepository(db_session).get_by_email(email)
user.is_admin = True
await db_session.commit()
tokens = client.post(
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
).json()
return {"Authorization": f"Bearer {tokens['access_token']}"}
# --- DELETE /auth/me -------------------------------------------------------
def test_delete_account_requires_auth(client: TestClient):
resp = client.request(
"DELETE", "/api/v1/auth/me", json={"password": "whatever"}
)
assert resp.status_code == 401
def test_delete_account_rejects_wrong_password(client: TestClient):
account = _register_and_login(client)
resp = client.request(
"DELETE",
"/api/v1/auth/me",
json={"password": "not-the-right-password"},
headers=account["headers"],
)
assert resp.status_code == 401
def test_delete_account_succeeds_with_correct_password_and_logs_out_the_account(
client: TestClient,
):
account = _register_and_login(client)
resp = client.request(
"DELETE",
"/api/v1/auth/me",
json={"password": "correct-horse-1"},
headers=account["headers"],
)
assert resp.status_code == 204
# The account is gone - the same token no longer resolves to anyone.
me_resp = client.get("/api/v1/auth/me", headers=account["headers"])
assert me_resp.status_code == 401
# And a fresh login attempt with the same credentials fails too.
login_resp = client.post(
"/api/v1/auth/login",
json={"email": account["email"], "password": "correct-horse-1"},
)
assert login_resp.status_code == 401
async def test_delete_account_cascades_to_owned_data(client: TestClient, db_session: AsyncSession):
"""A company created by the account must be gone too (ON DELETE CASCADE),
not just the user row."""
account = _register_and_login(client)
create_resp = client.post(
"/api/v1/companies",
json={"name": f"Co-{uuid.uuid4().hex[:8]}", "official_website": None},
headers=account["headers"],
)
assert create_resp.status_code == 201
company_id = create_resp.json()["id"]
client.request(
"DELETE", "/api/v1/auth/me", json={"password": "correct-horse-1"}, headers=account["headers"]
)
from sqlalchemy import text
row = await db_session.execute(
text("SELECT 1 FROM companies WHERE id = :id"), {"id": company_id}
)
assert row.first() is None
# --- POST /admin/users/require-password-change -----------------------------
def test_require_password_change_requires_admin(client: TestClient):
account = _register_and_login(client)
resp = client.post(
"/api/v1/admin/users/require-password-change",
json={"email": account["email"]},
headers=account["headers"],
)
assert resp.status_code == 403
async def test_require_password_change_rejects_unknown_email(
client: TestClient, db_session: AsyncSession
):
headers = await _register_admin_and_login(client, db_session)
resp = client.post(
"/api/v1/admin/users/require-password-change",
json={"email": "[email protected]"},
headers=headers,
)
assert resp.status_code == 404
async def test_admin_flags_an_account_and_it_gets_logged_to_the_targets_activity(
client: TestClient, db_session: AsyncSession
):
"""Checked directly against the DB, not via GET /auth/security-events -
that endpoint isn't in the must-change-password exempt list, so the
just-flagged account can't reach it until they change their password
(see test_flagged_account_is_blocked_from_other_endpoints)."""
admin_headers = await _register_admin_and_login(client, db_session)
account = _register_and_login(client)
resp = client.post(
"/api/v1/admin/users/require-password-change",
json={"email": account["email"]},
headers=admin_headers,
)
assert resp.status_code == 204
user = await UserRepository(db_session).get_by_email(account["email"])
assert user.must_change_password is True
events = await UserSecurityEventRepository(db_session).list_for_user(user.id)
assert any(e.event_type == SecurityEventType.PASSWORD_CHANGE_REQUIRED for e in events)
# --- Enforcement gate: everything except me/change-password/logout 403s ----
async def test_flagged_account_is_blocked_from_other_endpoints(
client: TestClient, db_session: AsyncSession
):
admin_headers = await _register_admin_and_login(client, db_session)
account = _register_and_login(client)
client.post(
"/api/v1/admin/users/require-password-change",
json={"email": account["email"]},
headers=admin_headers,
)
blocked = client.get("/api/v1/companies", headers=account["headers"])
assert blocked.status_code == 403
still_ok = client.get("/api/v1/auth/me", headers=account["headers"])
assert still_ok.status_code == 200
assert still_ok.json()["must_change_password"] is True
async def test_change_password_clears_the_flag_and_unblocks_the_account(
client: TestClient, db_session: AsyncSession
):
admin_headers = await _register_admin_and_login(client, db_session)
account = _register_and_login(client)
client.post(
"/api/v1/admin/users/require-password-change",
json={"email": account["email"]},
headers=admin_headers,
)
change_resp = client.post(
"/api/v1/auth/change-password",
json={"current_password": "correct-horse-1", "new_password": "brand-new-horse-2"},
headers=account["headers"],
)
assert change_resp.status_code == 200
new_tokens = change_resp.json()
new_headers = {"Authorization": f"Bearer {new_tokens['access_token']}"}
me_resp = client.get("/api/v1/auth/me", headers=new_headers)
assert me_resp.status_code == 200
assert me_resp.json()["must_change_password"] is False
unblocked = client.get("/api/v1/companies", headers=new_headers)
assert unblocked.status_code == 200
# The new password actually works on a fresh login.
login_resp = client.post(
"/api/v1/auth/login",
json={"email": account["email"], "password": "brand-new-horse-2"},
)
assert login_resp.status_code == 200
def test_change_password_rejects_wrong_current_password(client: TestClient):
account = _register_and_login(client)
resp = client.post(
"/api/v1/auth/change-password",
json={"current_password": "totally-wrong", "new_password": "brand-new-horse-2"},
headers=account["headers"],
)
assert resp.status_code == 401
def test_change_password_rejects_reusing_the_current_password(client: TestClient):
account = _register_and_login(client)
resp = client.post(
"/api/v1/auth/change-password",
json={"current_password": "correct-horse-1", "new_password": "correct-horse-1"},
headers=account["headers"],
)
assert resp.status_code == 400
def test_change_password_revokes_the_old_refresh_token(client: TestClient):
email = _unique_email()
client.post(
"/api/v1/auth/register",
json={"email": email, "password": "correct-horse-1", "display_name": "T"},
)
tokens = client.post(
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
).json()
headers = {"Authorization": f"Bearer {tokens['access_token']}"}
client.post(
"/api/v1/auth/change-password",
json={"current_password": "correct-horse-1", "new_password": "brand-new-horse-2"},
headers=headers,
)
stale_refresh_resp = client.post(
"/api/v1/auth/refresh", json={"refresh_token": tokens["refresh_token"]}
)
assert stale_refresh_resp.status_code == 401
+13 -1
View File
@@ -100,12 +100,24 @@ async def test_bootstrap_with_a_valid_token_sets_a_session_cookie_and_redirects(
"/api/v1/db-viewer/bootstrap", params={"token": token}, follow_redirects=False "/api/v1/db-viewer/bootstrap", params={"token": token}, follow_redirects=False
) )
assert resp.status_code == 302 assert resp.status_code == 302
assert resp.headers["location"] == "/" assert resp.headers["location"] == "/?pgsql=postgres"
assert "db_viewer_session" in resp.headers["set-cookie"] assert "db_viewer_session" in resp.headers["set-cookie"]
assert "HttpOnly" in resp.headers["set-cookie"] assert "HttpOnly" in resp.headers["set-cookie"]
assert "Secure" in resp.headers["set-cookie"] assert "Secure" in resp.headers["set-cookie"]
async def test_bootstrap_success_is_logged_to_the_admins_account_activity(
client: TestClient, db_session: AsyncSession
):
headers = await _register_admin_and_login(client, db_session)
token = client.post("/api/v1/db-viewer/session", headers=headers).json()["token"]
client.get("/api/v1/db-viewer/bootstrap", params={"token": token}, follow_redirects=False)
events = client.get("/api/v1/auth/security-events", headers=headers).json()
assert any(e["event_type"] == "db_viewer_accessed" for e in events)
def test_bootstrap_rejects_a_garbage_token(client: TestClient): def test_bootstrap_rejects_a_garbage_token(client: TestClient):
resp = client.get( resp = client.get(
"/api/v1/db-viewer/bootstrap", params={"token": "not-a-real-token"}, follow_redirects=False "/api/v1/db-viewer/bootstrap", params={"token": "not-a-real-token"}, follow_redirects=False
+18
View File
@@ -15,6 +15,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
const logout = useLogout(); const logout = useLogout();
const requiresLogin = systemStatus ? !isLocalConvenience(systemStatus) : false; const requiresLogin = systemStatus ? !isLocalConvenience(systemStatus) : false;
const mustChangePassword = user?.must_change_password ?? false;
useEffect(() => { useEffect(() => {
if (requiresLogin && !isLoading && isError) { if (requiresLogin && !isLoading && isError) {
@@ -22,6 +23,12 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
} }
}, [requiresLogin, isLoading, isError, router]); }, [requiresLogin, isLoading, isError, router]);
useEffect(() => {
if (mustChangePassword) {
router.replace("/change-password");
}
}, [mustChangePassword, router]);
if (requiresLogin && (isLoading || isError)) { if (requiresLogin && (isLoading || isError)) {
return ( return (
<div className="flex min-h-screen items-center justify-center text-sm text-slate-500"> <div className="flex min-h-screen items-center justify-center text-sm text-slate-500">
@@ -30,6 +37,17 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
); );
} }
// Every API call except /auth/me, /auth/change-password, and /auth/logout
// 403s server-side while this is set (app/auth/dependencies.py) - this is
// just the matching frontend redirect, not the actual enforcement.
if (mustChangePassword) {
return (
<div className="flex min-h-screen items-center justify-center text-sm text-slate-500">
Redirecting
</div>
);
}
return ( return (
<div className="min-h-screen bg-slate-50"> <div className="min-h-screen bg-slate-50">
<LocalModeBanner /> <LocalModeBanner />
+64 -2
View File
@@ -28,6 +28,7 @@ import {
useCreateDbViewerSession, useCreateDbViewerSession,
useCreateIpBan, useCreateIpBan,
useCurrentUser, useCurrentUser,
useDeleteAccount,
useDeleteIpBan, useDeleteIpBan,
useIpBans, useIpBans,
useRejectUnbanRequest, useRejectUnbanRequest,
@@ -690,13 +691,14 @@ function DatabaseViewerBox() {
</div> </div>
<p className="mt-1 text-sm text-slate-500"> <p className="mt-1 text-sm text-slate-500">
Open a web-based Postgres client (Adminer) in a new tab to view and edit rows directly. Open a web-based Postgres client (Adminer) in a new tab to view and edit rows directly.
Local developers and server admins only. PostgreSQL is pre-selected - just enter the database password to sign in. Local developers
and server admins only.
</p> </p>
<div className="mt-4"> <div className="mt-4">
{isLocal ? ( {isLocal ? (
<a <a
href="http://localhost:8081" href="http://localhost:8081/?pgsql=postgres&username=ciagent"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="focus-ring inline-flex items-center gap-1.5 rounded-md bg-slate-800 px-3 py-1.5 text-xs font-semibold text-white transition-colors duration-200 hover:bg-slate-900" className="focus-ring inline-flex items-center gap-1.5 rounded-md bg-slate-800 px-3 py-1.5 text-xs font-semibold text-white transition-colors duration-200 hover:bg-slate-900"
@@ -720,6 +722,64 @@ function DatabaseViewerBox() {
); );
} }
function DeleteAccountBox() {
const { data: systemStatus } = useSystemStatus();
const [password, setPassword] = useState("");
const deleteAccount = useDeleteAccount();
const isLocal = systemStatus ? isLocalConvenience(systemStatus) : false;
// The local-dev bypass account has no password_hash at all (it never
// registers/logs in) - there's nothing to type to confirm deletion, and
// deleting it would just get silently re-created on the next request.
if (isLocal) return null;
return (
<div className="rounded-lg border border-red-200 bg-red-50 p-6">
<div className="flex items-center gap-2 text-sm font-semibold text-red-700">
<Trash2 className="h-4 w-4" aria-hidden /> Delete account
</div>
<p className="mt-1 text-sm text-red-700/80">
Permanently deletes your account and everything in it - companies, monitoring history,
reports, and API keys. This can&apos;t be undone.
</p>
<form
onSubmit={(e) => {
e.preventDefault();
deleteAccount.mutate({ password });
}}
className="mt-4 flex flex-wrap items-end gap-2"
>
<div className="min-w-[220px] flex-1">
<label htmlFor="delete-account-password" className="block text-sm font-medium text-red-700">
Confirm your password
</label>
<input
id="delete-account-password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="focus-ring mt-1 block w-full rounded-md border border-red-300 px-3 py-2 text-sm text-slate-900 shadow-sm transition-colors duration-150"
/>
</div>
<button
type="submit"
disabled={!password || deleteAccount.isPending}
className="focus-ring inline-flex shrink-0 items-center gap-1.5 rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white transition-colors duration-200 hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{deleteAccount.isPending && <Loader2 className="h-4 w-4 animate-spin" aria-hidden />}
{deleteAccount.isPending ? "Deleting…" : "Delete account"}
</button>
</form>
{deleteAccount.isError && (
<p className="mt-2 text-sm text-red-700" role="alert">
{authErrorMessage(deleteAccount.error)}
</p>
)}
</div>
);
}
export default function SettingsPage() { export default function SettingsPage() {
const { data: user } = useCurrentUser(); const { data: user } = useCurrentUser();
const { data: systemStatus } = useSystemStatus(); const { data: systemStatus } = useSystemStatus();
@@ -820,6 +880,8 @@ export default function SettingsPage() {
<LoggingBox /> <LoggingBox />
</> </>
)} )}
<DeleteAccountBox />
</div> </div>
); );
} }
+100
View File
@@ -0,0 +1,100 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { KeyRound, Loader2 } from "lucide-react";
import { z } from "zod";
import { PasswordField } from "@/components/ui/password-field";
import { PasswordStrengthMeter } from "@/components/ui/password-strength-meter";
import { authErrorMessage, useChangePassword } from "@/hooks/use-auth";
const changePasswordSchema = z.object({
currentPassword: z.string().min(1, "Enter your current password"),
newPassword: z
.string()
.min(10, "Must be at least 10 characters")
.refine((v) => /[a-zA-Z]/.test(v) && /\d/.test(v), {
message: "Must contain at least one letter and one digit",
}),
});
type ChangePasswordForm = z.infer<typeof changePasswordSchema>;
export default function ChangePasswordPage() {
const router = useRouter();
const changePassword = useChangePassword();
const {
register,
handleSubmit,
watch,
formState: { errors },
} = useForm<ChangePasswordForm>({ resolver: zodResolver(changePasswordSchema) });
const onSubmit = handleSubmit(async (values) => {
try {
await changePassword.mutateAsync({
current_password: values.currentPassword,
new_password: values.newPassword,
});
router.replace("/dashboard");
} catch {
// Surfaced via changePassword.isError below.
}
});
return (
<main className="flex min-h-screen items-center justify-center bg-slate-50 px-4 py-12">
<div className="w-full max-w-sm animate-fade-in overflow-hidden rounded-lg border border-slate-200 bg-white shadow-sm">
<div className="h-1.5 bg-gradient-to-r from-brand-500 via-brand-600 to-brand-700" />
<div className="p-8">
<div className="flex items-center gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-50 text-brand-600">
<KeyRound className="h-4.5 w-4.5" aria-hidden />
</span>
<div>
<h1 className="text-xl font-semibold text-slate-900">Change your password</h1>
<p className="text-sm text-slate-600">
An admin requires a new password before you can continue.
</p>
</div>
</div>
<form onSubmit={onSubmit} className="mt-6 space-y-4" noValidate>
<PasswordField
label="Current password"
autoComplete="current-password"
{...register("currentPassword")}
error={errors.currentPassword?.message}
/>
<div>
<PasswordField
label="New password"
autoComplete="new-password"
hint="At least 10 characters, with a letter and a digit."
{...register("newPassword")}
error={errors.newPassword?.message}
/>
<PasswordStrengthMeter password={watch("newPassword") ?? ""} />
</div>
{changePassword.isError && (
<p className="animate-fade-in text-sm text-red-600" role="alert">
{authErrorMessage(changePassword.error)}
</p>
)}
<button
type="submit"
disabled={changePassword.isPending}
className="focus-ring inline-flex w-full items-center justify-center gap-2 rounded-md bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition-colors duration-200 hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{changePassword.isPending && <Loader2 className="h-4 w-4 animate-spin" aria-hidden />}
{changePassword.isPending ? "Updating…" : "Update password"}
</button>
</form>
</div>
</div>
</main>
);
}
+29
View File
@@ -5,7 +5,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ApiError, api, clearTokens, getRefreshToken, setTokens } from "@/lib/api-client"; import { ApiError, api, clearTokens, getRefreshToken, setTokens } from "@/lib/api-client";
import type { import type {
BanIpPayload, BanIpPayload,
ChangePasswordPayload,
ConfirmPasswordResetPayload, ConfirmPasswordResetPayload,
DeleteAccountPayload,
LoginPayload, LoginPayload,
RegisterPayload, RegisterPayload,
RequestPasswordResetPayload, RequestPasswordResetPayload,
@@ -110,6 +112,33 @@ export function useLogout() {
}); });
} }
export function useDeleteAccount() {
const router = useRouter();
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: DeleteAccountPayload) => api.deleteAccount(payload),
onSuccess: () => {
// Same reasoning as useLogout: we know for certain the account is
// gone, so clear and navigate immediately rather than waiting on a
// background refetch of ["me"] to fail.
clearTokens();
queryClient.clear();
router.replace("/");
},
});
}
export function useChangePassword() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: ChangePasswordPayload) => api.changePassword(payload),
onSuccess: (tokens) => {
setTokens(tokens.access_token, tokens.refresh_token);
return queryClient.invalidateQueries({ queryKey: ["me"] });
},
});
}
export function useVerifyEmail() { export function useVerifyEmail() {
return useMutation({ return useMutation({
mutationFn: (payload: VerifyEmailPayload) => api.verifyEmail(payload), mutationFn: (payload: VerifyEmailPayload) => api.verifyEmail(payload),
+14
View File
@@ -10,8 +10,10 @@ import type {
CompanyResponse, CompanyResponse,
CompanyUpdatePayload, CompanyUpdatePayload,
ConfirmPasswordResetPayload, ConfirmPasswordResetPayload,
ChangePasswordPayload,
DashboardAnalytics, DashboardAnalytics,
DbViewerSessionResponse, DbViewerSessionResponse,
DeleteAccountPayload,
DiscoverCompanyRequest, DiscoverCompanyRequest,
DiscoveredCompanyProfile, DiscoveredCompanyProfile,
IpBan, IpBan,
@@ -205,6 +207,18 @@ export const api = {
me: () => request<MeResponse>("/api/v1/auth/me"), me: () => request<MeResponse>("/api/v1/auth/me"),
deleteAccount: (payload: DeleteAccountPayload) =>
request<void>("/api/v1/auth/me", {
method: "DELETE",
body: JSON.stringify(payload),
}),
changePassword: (payload: ChangePasswordPayload) =>
request<TokenResponse>("/api/v1/auth/change-password", {
method: "POST",
body: JSON.stringify(payload),
}),
verifyEmail: (payload: VerifyEmailPayload) => verifyEmail: (payload: VerifyEmailPayload) =>
request<void>("/api/v1/auth/verify-email", { request<void>("/api/v1/auth/verify-email", {
method: "POST", method: "POST",
+10
View File
@@ -33,6 +33,15 @@ export interface DbViewerSessionResponse {
token: string; token: string;
} }
export interface DeleteAccountPayload {
password: string;
}
export interface ChangePasswordPayload {
current_password: string;
new_password: string;
}
export interface SetSystemSecretPayload { export interface SetSystemSecretPayload {
value: string; value: string;
} }
@@ -70,6 +79,7 @@ export interface UserResponse {
timezone: string; timezone: string;
is_active: boolean; is_active: boolean;
is_admin: boolean; is_admin: boolean;
must_change_password: boolean;
} }
export interface MeResponse extends UserResponse { export interface MeResponse extends UserResponse {
+86
View File
@@ -0,0 +1,86 @@
import { screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import DashboardLayout from "@/app/(app)/layout";
import { renderWithQueryClient } from "./test-utils";
const replaceMock = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: replaceMock }),
usePathname: () => "/dashboard",
}));
function meBody(mustChangePassword: boolean) {
return {
id: "user-1",
email: "[email protected]",
display_name: "Regular User",
timezone: "America/New_York",
is_active: true,
is_admin: false,
auth_mode: "jwt",
must_change_password: mustChangePassword,
};
}
function systemStatusBody() {
return {
app_env: "development",
auth_mode: "jwt",
llm_provider: "mock",
search_provider: "mock",
sms_enabled: false,
sms_provider: "twilio",
ninjapear_configured: false,
ninjapear_credit_balance: null,
ninjapear_estimated_credits_per_company: null,
is_localhost: false,
turnstile_site_key: null,
components: [],
};
}
function mockFetchImplementation(mustChangePassword: boolean) {
return vi.fn().mockImplementation((url: string) => {
const path = url.replace("http://localhost:8000", "");
if (path === "/api/v1/auth/me") {
return Promise.resolve({ ok: true, status: 200, json: async () => meBody(mustChangePassword) });
}
if (path === "/api/v1/system/status") {
return Promise.resolve({ ok: true, status: 200, json: async () => systemStatusBody() });
}
return Promise.resolve({ ok: false, status: 404, json: async () => ({ detail: "not found" }) });
});
}
beforeEach(() => {
replaceMock.mockClear();
});
describe("DashboardLayout - must_change_password redirect", () => {
it("redirects to /change-password and withholds the page content when the flag is set", async () => {
vi.stubGlobal("fetch", mockFetchImplementation(true));
renderWithQueryClient(
<DashboardLayout>
<div>Protected dashboard content</div>
</DashboardLayout>,
);
await waitFor(() => {
expect(replaceMock).toHaveBeenCalledWith("/change-password");
});
expect(screen.queryByText(/protected dashboard content/i)).not.toBeInTheDocument();
});
it("renders normally when the flag is not set", async () => {
vi.stubGlobal("fetch", mockFetchImplementation(false));
renderWithQueryClient(
<DashboardLayout>
<div>Protected dashboard content</div>
</DashboardLayout>,
);
expect(await screen.findByText(/protected dashboard content/i)).toBeInTheDocument();
expect(replaceMock).not.toHaveBeenCalledWith("/change-password");
});
});
@@ -0,0 +1,86 @@
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import ChangePasswordPage from "@/app/change-password/page";
import { renderWithQueryClient } from "./test-utils";
const replaceMock = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: replaceMock }),
}));
beforeEach(() => {
replaceMock.mockClear();
});
describe("ChangePasswordPage", () => {
it("rejects a weak new password before submitting", async () => {
const user = userEvent.setup();
renderWithQueryClient(<ChangePasswordPage />);
await user.type(screen.getByLabelText(/current password/i), "correct-horse-1");
await user.type(screen.getByLabelText(/new password/i), "allletters");
await user.click(screen.getByRole("button", { name: /update password/i }));
await waitFor(() => {
expect(
screen.getByText(/must contain at least one letter and one digit/i),
).toBeInTheDocument();
});
});
it("submits current + new password and redirects to the dashboard on success", async () => {
const fetchMock = vi.fn().mockImplementation((url: string, init?: RequestInit) => {
const path = url.replace("http://localhost:8000", "");
if (path === "/api/v1/auth/change-password" && init?.method === "POST") {
expect(JSON.parse(init.body as string)).toEqual({
current_password: "correct-horse-1",
new_password: "brand-new-horse-2",
});
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
access_token: "new-access",
refresh_token: "new-refresh",
token_type: "bearer",
expires_in_minutes: 15,
}),
});
}
return Promise.resolve({ ok: false, status: 404, json: async () => ({ detail: "not found" }) });
});
vi.stubGlobal("fetch", fetchMock);
const user = userEvent.setup();
renderWithQueryClient(<ChangePasswordPage />);
await user.type(screen.getByLabelText(/current password/i), "correct-horse-1");
await user.type(screen.getByLabelText(/new password/i), "brand-new-horse-2");
await user.click(screen.getByRole("button", { name: /update password/i }));
await waitFor(() => {
expect(replaceMock).toHaveBeenCalledWith("/dashboard");
});
});
it("surfaces an error for the wrong current password", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: false,
status: 401,
json: async () => ({ detail: "Incorrect current password" }),
}),
);
const user = userEvent.setup();
renderWithQueryClient(<ChangePasswordPage />);
await user.type(screen.getByLabelText(/current password/i), "wrong-password-1");
await user.type(screen.getByLabelText(/new password/i), "brand-new-horse-2");
await user.click(screen.getByRole("button", { name: /update password/i }));
expect(await screen.findByText(/incorrect current password/i)).toBeInTheDocument();
});
});
@@ -4,6 +4,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import SettingsPage from "@/app/(app)/settings/page"; import SettingsPage from "@/app/(app)/settings/page";
import { renderWithQueryClient } from "./test-utils"; import { renderWithQueryClient } from "./test-utils";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
}));
const ADMIN_USER = { const ADMIN_USER = {
id: "user-1", id: "user-1",
email: "[email protected]", email: "[email protected]",
@@ -12,6 +16,7 @@ const ADMIN_USER = {
is_active: true, is_active: true,
is_admin: true, is_admin: true,
auth_mode: "jwt", auth_mode: "jwt",
must_change_password: false,
}; };
function systemStatusBody(isLocalhost: boolean, authMode: "local" | "jwt") { function systemStatusBody(isLocalhost: boolean, authMode: "local" | "jwt") {
@@ -67,7 +72,7 @@ describe("Settings - Database viewer box", () => {
renderWithQueryClient(<SettingsPage />); renderWithQueryClient(<SettingsPage />);
const link = await screen.findByRole("link", { name: /open database viewer/i }); const link = await screen.findByRole("link", { name: /open database viewer/i });
expect(link).toHaveAttribute("href", "http://localhost:8081"); expect(link).toHaveAttribute("href", "http://localhost:8081/?pgsql=postgres&username=ciagent");
expect(link).toHaveAttribute("target", "_blank"); expect(link).toHaveAttribute("target", "_blank");
}); });
@@ -0,0 +1,148 @@
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import SettingsPage from "@/app/(app)/settings/page";
import { renderWithQueryClient } from "./test-utils";
const replaceMock = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: replaceMock }),
}));
const REGULAR_USER = {
id: "user-1",
email: "[email protected]",
display_name: "Regular User",
timezone: "America/New_York",
is_active: true,
is_admin: false,
auth_mode: "jwt",
must_change_password: false,
};
const LOCAL_DEV_USER = {
id: "00000000-0000-0000-0000-000000000001",
email: "[email protected]",
display_name: "Local Developer",
timezone: "America/New_York",
is_active: true,
is_admin: true,
auth_mode: "local",
must_change_password: false,
};
function systemStatusBody(isLocalhost: boolean, authMode: "local" | "jwt") {
return {
app_env: "development",
auth_mode: authMode,
llm_provider: "mock",
search_provider: "mock",
sms_enabled: false,
sms_provider: "twilio",
ninjapear_configured: false,
ninjapear_credit_balance: null,
ninjapear_estimated_credits_per_company: null,
is_localhost: isLocalhost,
turnstile_site_key: null,
components: [],
};
}
function mockFetchImplementation(
isLocalhost: boolean,
authMode: "local" | "jwt",
meUser: typeof REGULAR_USER,
) {
return vi.fn().mockImplementation((url: string, init?: RequestInit) => {
const path = url.replace("http://localhost:8000", "");
const ok = (json: unknown, status = 200) =>
Promise.resolve({ ok: true, status, json: async () => json });
if (path === "/api/v1/auth/me") return ok(meUser);
if (path === "/api/v1/system/status") return ok(systemStatusBody(isLocalhost, authMode));
if (path === "/api/v1/notification-destinations") return ok([]);
if (path === "/api/v1/companies") return ok([]);
if (path === "/api/v1/system/logs") return ok([]);
if (path === "/api/v1/user-api-keys") return ok([]);
if (path === "/api/v1/system/secrets") return ok([]);
if (path === "/api/v1/auth/security-events") return ok([]);
if (path === "/api/v1/admin/ip-bans") return ok([]);
if (path === "/api/v1/admin/unban-requests") return ok([]);
if (path === "/api/v1/auth/me" && init?.method === "DELETE") return ok(undefined, 204);
return Promise.resolve({ ok: false, status: 404, json: async () => ({ detail: "not found" }) });
});
}
beforeEach(() => {
replaceMock.mockClear();
});
describe("Settings - Delete account box", () => {
it("renders for a regular (non-local-dev) user, disabled until a password is typed", async () => {
vi.stubGlobal("fetch", mockFetchImplementation(false, "jwt", REGULAR_USER));
renderWithQueryClient(<SettingsPage />);
const button = await screen.findByRole("button", { name: /delete account/i });
expect(button).toBeDisabled();
const user = userEvent.setup();
await user.type(screen.getByLabelText(/confirm your password/i), "correct-horse-1");
expect(button).not.toBeDisabled();
});
it("is hidden entirely for the local-dev bypass account", async () => {
vi.stubGlobal("fetch", mockFetchImplementation(true, "local", LOCAL_DEV_USER));
renderWithQueryClient(<SettingsPage />);
await screen.findByText(LOCAL_DEV_USER.email);
expect(screen.queryByRole("button", { name: /delete account/i })).not.toBeInTheDocument();
});
it("submits the password, then clears tokens and redirects home on success", async () => {
const fetchMock = vi.fn().mockImplementation((url: string, init?: RequestInit) => {
const path = url.replace("http://localhost:8000", "");
if (path === "/api/v1/auth/me" && init?.method === "DELETE") {
expect(JSON.parse(init.body as string)).toEqual({ password: "correct-horse-1" });
return Promise.resolve({ ok: true, status: 204, json: async () => undefined });
}
return mockFetchImplementation(false, "jwt", REGULAR_USER)(url, init);
});
vi.stubGlobal("fetch", fetchMock);
const user = userEvent.setup();
renderWithQueryClient(<SettingsPage />);
const button = await screen.findByRole("button", { name: /delete account/i });
await user.type(screen.getByLabelText(/confirm your password/i), "correct-horse-1");
await user.click(button);
await waitFor(() => {
expect(replaceMock).toHaveBeenCalledWith("/");
});
});
it("shows an error message when the password is wrong", async () => {
const fetchMock = vi.fn().mockImplementation((url: string, init?: RequestInit) => {
const path = url.replace("http://localhost:8000", "");
if (path === "/api/v1/auth/me" && init?.method === "DELETE") {
return Promise.resolve({
ok: false,
status: 401,
json: async () => ({ detail: "Incorrect password" }),
});
}
return mockFetchImplementation(false, "jwt", REGULAR_USER)(url, init);
});
vi.stubGlobal("fetch", fetchMock);
const user = userEvent.setup();
renderWithQueryClient(<SettingsPage />);
const button = await screen.findByRole("button", { name: /delete account/i });
await user.type(screen.getByLabelText(/confirm your password/i), "wrong-password");
await user.click(button);
expect(await screen.findByText(/incorrect password/i)).toBeInTheDocument();
});
});