test_invalidate_all_sessions.py 3.8 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182
  1. """invalidate_all_sessions signs every user out and drops pending auth tokens.
  2. It runs once after the auth datetime columns are converted on PostgreSQL and
  3. after every backup restore, where the rows may come from older code.
  4. """
  5. import sqlite3
  6. from datetime import datetime, timedelta, timezone
  7. from unittest.mock import AsyncMock, patch
  8. import pytest
  9. from sqlalchemy import select
  10. from backend.app.core.auth import _is_token_fresh
  11. from backend.app.core.config import settings as app_settings
  12. from backend.app.core.database import invalidate_all_sessions
  13. from backend.app.models.auth_ephemeral import AuthEphemeralToken, AuthRateLimitEvent, EventType, TokenType
  14. from backend.app.models.user import User
  15. @pytest.mark.asyncio
  16. async def test_signs_every_user_out_and_drops_short_lived_auth_records(test_engine, db_session):
  17. db_session.add_all(
  18. [
  19. User(username="changed", password_hash="x", password_changed_at=datetime(2026, 1, 1)),
  20. User(username="never_changed", password_hash="x", password_changed_at=None),
  21. ]
  22. )
  23. expires = datetime.now(timezone.utc) + timedelta(hours=1)
  24. for token_type in {"revoked_jti", "media", "camera_stream", "websocket", *[t.value for t in TokenType]}:
  25. db_session.add(AuthEphemeralToken(token=f"t-{token_type}", token_type=token_type, expires_at=expires))
  26. for event_type in EventType:
  27. db_session.add(AuthRateLimitEvent(username="changed", event_type=event_type.value))
  28. await db_session.commit()
  29. before = datetime.now(timezone.utc)
  30. async with test_engine.begin() as conn:
  31. await invalidate_all_sessions(conn)
  32. db_session.expire_all()
  33. users = (await db_session.execute(select(User))).scalars().all()
  34. assert {u.username for u in users} == {"changed", "never_changed"}
  35. for user in users:
  36. # A token issued before the call is rejected, one issued after it is not.
  37. assert _is_token_fresh(int(before.timestamp()) - 1, user) is False
  38. assert _is_token_fresh(int(before.timestamp()) + 2, user) is True
  39. remaining = (await db_session.execute(select(AuthEphemeralToken.token_type))).scalars().all()
  40. assert remaining == ["revoked_jti"]
  41. assert (await db_session.execute(select(AuthRateLimitEvent))).scalars().all() == []
  42. @pytest.mark.asyncio
  43. async def test_restore_ends_by_invalidating_all_sessions(async_client, monkeypatch, tmp_path):
  44. from backend.app.api.routes.settings import create_backup_zip
  45. # Keep the restored database separate from the test engine.
  46. db_path = tmp_path / "bambuddy.db"
  47. with sqlite3.connect(db_path) as db:
  48. db.execute("CREATE TABLE backup_marker (id INTEGER PRIMARY KEY)")
  49. monkeypatch.setenv("DATA_DIR", str(tmp_path))
  50. monkeypatch.setattr(app_settings, "base_dir", tmp_path)
  51. monkeypatch.setattr(app_settings, "database_url", f"sqlite+aiosqlite:///{db_path}")
  52. zip_path, _ = await create_backup_zip(output_path=tmp_path)
  53. invalidate = AsyncMock()
  54. with (
  55. patch("backend.app.core.database.close_all_connections", new_callable=AsyncMock),
  56. patch("backend.app.core.database.reinitialize_database", new_callable=AsyncMock),
  57. patch("backend.app.core.database.init_db", new_callable=AsyncMock),
  58. patch("backend.app.core.database.invalidate_all_sessions", invalidate),
  59. patch("backend.app.services.print_scheduler.scheduler.stop"),
  60. patch("backend.app.services.smart_plug_manager.smart_plug_manager.stop_scheduler"),
  61. patch("backend.app.services.notification_service.notification_service.stop_digest_scheduler"),
  62. ):
  63. response = await async_client.post(
  64. "/api/v1/settings/restore",
  65. files={"file": ("backup.zip", zip_path.read_bytes(), "application/zip")},
  66. )
  67. assert response.status_code == 200, response.text
  68. invalidate.assert_awaited_once()