🧙♂️ Transform LifeRPG into The Wizard's Grimoire - Production-Ready Application
✨ Major Features Added: - Complete magical theming and rebranding from LifeRPG to The Wizard's Grimoire - Production-grade React frontend with Tailwind CSS v4 and magical aesthetics - Comprehensive analytics dashboard with Recharts integration (ScryingPortal) - Push notifications system with PWA service worker support - Drag & drop functionality using @dnd-kit for habit reordering - Social features with friends system and leaderboards - Performance optimization tools and monitoring - Mobile app enhancement with PWA installation support 🏗️ Technical Infrastructure: - Advanced service worker with offline support and background sync - Zustand state management for scalable application state - Production-ready UI component system with enhanced Button, Card, Input - Progressive Web App (PWA) with manifest and app installation - FastAPI backend with comprehensive API endpoints - Docker containerization and CI/CD pipeline setup 📱 Progressive Web App Features: - Offline functionality with intelligent caching - Push notification support for habit reminders - App installation on mobile and desktop platforms - Background sync for offline data management - Performance monitoring and optimization tools 🎨 User Experience: - Magical wizard/grimoire theming throughout application - Responsive design optimized for all device sizes - Drag & drop habit management with smooth animations - Interactive analytics with multiple chart types - Social connectivity with friends and competitive features - Comprehensive notification and performance settings 🔧 Developer Experience: - Modern development stack with Vite and React - Comprehensive testing setup and CI/CD pipelines - Code quality tools with pre-commit hooks - Docker development environment - Detailed documentation and implementation guides This represents a complete transformation from prototype to production-ready application with enterprise-grade features and magical user experience.
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
import os
|
||||
import tempfile
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
import importlib
|
||||
|
||||
# Do not import app or models at module import time; tests will set DATABASE_URL per-fixture
|
||||
|
||||
@pytest.fixture(scope='function')
|
||||
def client():
|
||||
# create a secure temporary sqlite file per test
|
||||
tf = tempfile.NamedTemporaryFile(prefix='liferpg_test_', suffix='.db', delete=False)
|
||||
tf.close()
|
||||
dbpath = tf.name
|
||||
os.environ['DATABASE_URL'] = f'sqlite:///{dbpath}'
|
||||
# import models after DATABASE_URL is set so engine binds to the test DB
|
||||
import modern.backend.models as models
|
||||
importlib.reload(models)
|
||||
# initialize DB
|
||||
models.init_db()
|
||||
# import app after models so app uses the correct models/engine
|
||||
import modern.backend.app as app
|
||||
importlib.reload(app)
|
||||
client = TestClient(app.app)
|
||||
# create admin user
|
||||
resp = client.post('/api/v1/auth/signup', json={'email':'admin@test','password':'pass'})
|
||||
# promote to admin directly in DB for tests
|
||||
db = models.SessionLocal()
|
||||
u = db.query(models.User).filter_by(email='admin@test').first()
|
||||
u.role = 'admin'
|
||||
db.commit()
|
||||
db.close()
|
||||
yield client
|
||||
# cleanup
|
||||
try:
|
||||
os.remove(dbpath)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,66 @@
|
||||
import importlib
|
||||
|
||||
def test_totp_setup_enable_and_login_with_totp(client):
|
||||
# Sign up a user with password
|
||||
r = client.post('/api/v1/auth/signup', json={'email': '2fa@example.com', 'password': 'pw'} )
|
||||
assert r.status_code == 200
|
||||
|
||||
# Begin setup
|
||||
r = client.post('/api/v1/auth/2fa/setup')
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert 'otpauth_uri' in data and 'recovery_codes' in data
|
||||
assert len(data['recovery_codes']) >= 8
|
||||
|
||||
# Extract current TOTP from secret by reading from DB
|
||||
import modern.backend.models as models
|
||||
db = models.SessionLocal()
|
||||
u = db.query(models.User).filter_by(email='2fa@example.com').first()
|
||||
assert u and u.totp_secret
|
||||
|
||||
# compute a valid code
|
||||
import pyotp
|
||||
code = pyotp.TOTP(u.totp_secret).now()
|
||||
|
||||
# Enable 2FA
|
||||
r2 = client.post('/api/v1/auth/2fa/enable', json={'code': code})
|
||||
assert r2.status_code == 200
|
||||
|
||||
# Logout to clear session
|
||||
client.post('/api/v1/auth/logout')
|
||||
|
||||
# Login must now include totp_code
|
||||
r3 = client.post('/api/v1/auth/login', json={'email': '2fa@example.com', 'password': 'pw'})
|
||||
assert r3.status_code == 401
|
||||
r4 = client.post('/api/v1/auth/login', json={'email': '2fa@example.com', 'password': 'pw', 'totp_code': code})
|
||||
assert r4.status_code == 200
|
||||
|
||||
|
||||
def test_login_with_recovery_code(client):
|
||||
# new user
|
||||
r = client.post('/api/v1/auth/signup', json={'email': '2fa2@example.com', 'password': 'pw'} )
|
||||
assert r.status_code == 200
|
||||
|
||||
# setup 2fa to generate recovery codes
|
||||
r = client.post('/api/v1/auth/2fa/setup')
|
||||
codes = r.json()['recovery_codes']
|
||||
|
||||
# enable 2fa
|
||||
import modern.backend.models as models
|
||||
db = models.SessionLocal()
|
||||
u = db.query(models.User).filter_by(email='2fa2@example.com').first()
|
||||
import pyotp
|
||||
code = pyotp.TOTP(u.totp_secret).now()
|
||||
client.post('/api/v1/auth/2fa/enable', json={'code': code})
|
||||
|
||||
# logout
|
||||
client.post('/api/v1/auth/logout')
|
||||
|
||||
# login using one recovery code
|
||||
r2 = client.post('/api/v1/auth/login', json={'email': '2fa2@example.com', 'password': 'pw', 'recovery_code': codes[0]})
|
||||
assert r2.status_code == 200
|
||||
|
||||
# recovery code should be consumed; using again should fail
|
||||
client.post('/api/v1/auth/logout')
|
||||
r3 = client.post('/api/v1/auth/login', json={'email': '2fa2@example.com', 'password': 'pw', 'recovery_code': codes[0]})
|
||||
assert r3.status_code == 401
|
||||
@@ -0,0 +1,58 @@
|
||||
import os
|
||||
import json
|
||||
import types
|
||||
|
||||
|
||||
class FakeJob:
|
||||
def __init__(self, id='job-1'):
|
||||
self.id = id
|
||||
|
||||
|
||||
class FakeQueue:
|
||||
def enqueue(self, fn, *args, **kwargs):
|
||||
return FakeJob()
|
||||
|
||||
|
||||
def test_todoist_connect_and_sync_inline(client, monkeypatch):
|
||||
# Ensure no queue so it runs inline
|
||||
import modern.backend.app as app
|
||||
monkeypatch.setattr(app, 'enqueue_adapter_sync', lambda provider, integration_id: None)
|
||||
# stub run_adapter_sync to avoid network
|
||||
monkeypatch.setattr(app, 'run_adapter_sync', lambda provider, integration_id: {"ok": True, "count": 2})
|
||||
|
||||
# create user (id assumed 1 by default DB state; but ensure)
|
||||
r = client.post('/api/v1/users', json={'email': 'u@example.com'})
|
||||
uid = r.json()['id']
|
||||
|
||||
# login to get a token cookie
|
||||
client.post('/api/v1/auth/signup', json={'email': 'u@example.com', 'password': 'pw'})
|
||||
# connect todoist (store token)
|
||||
r = client.post('/api/v1/integrations/todoist/connect', json={'user_id': uid, 'api_token': 'x'})
|
||||
assert r.status_code == 200
|
||||
integ_id = r.json()['id']
|
||||
# trigger sync (inline)
|
||||
r = client.post(f'/api/v1/integrations/{integ_id}/sync')
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data['queued'] is False and data['result']['ok'] is True
|
||||
|
||||
|
||||
def test_github_connect_and_sync_enqueued(client, monkeypatch):
|
||||
# Patch queue to simulate enqueue
|
||||
import modern.backend.app as app
|
||||
monkeypatch.setattr(app, 'enqueue_adapter_sync', lambda provider, integration_id: types.SimpleNamespace(id='job-123'))
|
||||
|
||||
# create user and login
|
||||
client.post('/api/v1/users', json={'email': 'g@example.com'})
|
||||
client.post('/api/v1/auth/signup', json={'email': 'g@example.com', 'password': 'pw'})
|
||||
|
||||
# connect github
|
||||
r = client.post('/api/v1/integrations/github/connect', json={'user_id': 1, 'token': 'pat'})
|
||||
assert r.status_code == 200
|
||||
integ_id = r.json()['id']
|
||||
|
||||
# trigger sync -> should be queued
|
||||
r = client.post(f'/api/v1/integrations/{integ_id}/sync')
|
||||
assert r.status_code == 200
|
||||
j = r.json()
|
||||
assert j['queued'] is True and 'job_id' in j
|
||||
@@ -0,0 +1,24 @@
|
||||
import json
|
||||
from modern.backend import models
|
||||
|
||||
|
||||
def test_admin_set_role_creates_changelog(client):
|
||||
# create a user to modify
|
||||
r = client.post('/api/v1/auth/signup', json={'email': 'audituser@test', 'password': 'p'})
|
||||
assert r.status_code == 200
|
||||
|
||||
# as admin (fixture provides admin session), set role
|
||||
resp = client.post('/api/v1/admin/users/2/role', json={'role': 'moderator'})
|
||||
assert resp.status_code == 200
|
||||
|
||||
# check change log was created
|
||||
db = models.SessionLocal()
|
||||
try:
|
||||
row = db.query(models.ChangeLog).order_by(models.ChangeLog.id.desc()).first()
|
||||
assert row is not None
|
||||
assert row.entity == 'user'
|
||||
assert row.action == 'set_role'
|
||||
payload = json.loads(row.payload or '{}')
|
||||
assert payload.get('role') == 'moderator'
|
||||
finally:
|
||||
db.close()
|
||||
+14
-14
@@ -1,23 +1,23 @@
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from modern.backend.app import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
def test_signup_and_login():
|
||||
resp = client.post('/api/v1/auth/signup', json={'email':'test@example.com','password':'secret'})
|
||||
def test_signup_and_login(client):
|
||||
resp = client.post('/api/v1/auth/signup', json={'email':'user1@test','password':'secret'})
|
||||
assert resp.status_code == 200
|
||||
resp = client.post('/api/v1/auth/login', json={'email':'test@example.com','password':'secret'})
|
||||
resp = client.post('/api/v1/auth/login', json={'email':'user1@test','password':'secret'})
|
||||
assert resp.status_code == 200
|
||||
assert 'session' in resp.cookies
|
||||
|
||||
|
||||
def test_admin_set_role():
|
||||
# signup admin user
|
||||
client.post('/api/v1/auth/signup', json={'email':'admin@example.com','password':'secret'})
|
||||
# set role by calling admin API directly (no auth in this simple test runner)
|
||||
# In a full test we'd log in as admin and use cookie; keep simple here
|
||||
resp = client.post('/api/v1/admin/users/1/role', json={'role':'admin'})
|
||||
# This may be protected in runtime; just assert response code is 200 or 401 depending on environment
|
||||
assert resp.status_code in (200,401,403)
|
||||
*** End Patch
|
||||
def test_admin_set_role(client):
|
||||
# Login as admin (created in fixture)
|
||||
resp = client.post('/api/v1/auth/login', json={'email':'admin@test','password':'pass'})
|
||||
assert resp.status_code == 200
|
||||
# Set role of a user (create user first)
|
||||
r = client.post('/api/v1/auth/signup', json={'email':'tochange@test','password':'p'})
|
||||
assert r.status_code == 200
|
||||
# As admin, set role
|
||||
resp = client.post('/api/v1/admin/users/2/role', json={'role':'moderator'})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json().get('role') == 'moderator'
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import json
|
||||
|
||||
|
||||
class FakeResp:
|
||||
def __init__(self, status=200, data=None, headers=None):
|
||||
self.status_code = status
|
||||
self._data = data if data is not None else []
|
||||
self.headers = headers or {}
|
||||
|
||||
def json(self):
|
||||
return self._data
|
||||
|
||||
|
||||
def _setup_integration(db, user_id: int, provider: str, token: str, config: dict = None):
|
||||
import modern.backend.models as models
|
||||
integ = models.Integration(user_id=user_id, provider=provider, external_id=None, config=json.dumps(config or {}))
|
||||
db.add(integ)
|
||||
db.flush()
|
||||
from modern.backend.crypto import encrypt_text
|
||||
db.add(models.OAuthToken(integration_id=integ.id, access_token=encrypt_text(token)))
|
||||
db.flush()
|
||||
return integ
|
||||
|
||||
|
||||
def _create_mapped_habit(db, integration_id: int, external_id: str = 'ext-1', title='Old Title'):
|
||||
import modern.backend.models as models
|
||||
h = models.Habit(user_id=1, title=title, cadence='once', notes='test', status='active')
|
||||
db.add(h)
|
||||
db.flush()
|
||||
db.add(models.IntegrationItemMap(integration_id=integration_id, external_id=external_id, entity_type='habit', entity_id=h.id))
|
||||
db.flush()
|
||||
return h
|
||||
|
||||
|
||||
def test_todoist_archive_vs_delete_full_sync(client, monkeypatch):
|
||||
import modern.backend.models as models
|
||||
import requests
|
||||
from modern.backend.adapters import ADAPTERS
|
||||
from modern.backend.config import settings
|
||||
db = models.SessionLocal()
|
||||
try:
|
||||
# Archive policy
|
||||
integ = _setup_integration(db, 1, 'todoist', 'tok', config={'todoist_full_fetch': True})
|
||||
h = _create_mapped_habit(db, integ.id, external_id='gone', title='Goner')
|
||||
monkeypatch.setattr(requests, 'get', lambda *a, **k: FakeResp(200, []))
|
||||
settings.INTEGRATION_CLOSE_MODE = 'archive'
|
||||
ADAPTERS['todoist'].sync(db=db, integration_id=integ.id)
|
||||
db.refresh(h)
|
||||
assert h.status == 'archived'
|
||||
|
||||
# Delete policy
|
||||
integ2 = _setup_integration(db, 1, 'todoist', 'tok', config={'todoist_full_fetch': True})
|
||||
h2 = _create_mapped_habit(db, integ2.id, external_id='gone2', title='Goner2')
|
||||
settings.INTEGRATION_CLOSE_MODE = 'delete'
|
||||
ADAPTERS['todoist'].sync(db=db, integration_id=integ2.id)
|
||||
assert db.query(models.Habit).filter_by(id=h2.id).first() is None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_todoist_no_delete_on_incremental(client, monkeypatch):
|
||||
import modern.backend.models as models
|
||||
import requests
|
||||
from modern.backend.adapters import ADAPTERS
|
||||
from modern.backend.config import settings
|
||||
db = models.SessionLocal()
|
||||
try:
|
||||
integ = _setup_integration(db, 1, 'todoist', 'tok', config={'todoist_full_fetch': False})
|
||||
h = _create_mapped_habit(db, integ.id, external_id='stay', title='Stay Put')
|
||||
monkeypatch.setattr(requests, 'get', lambda *a, **k: FakeResp(200, []))
|
||||
settings.INTEGRATION_CLOSE_MODE = 'delete'
|
||||
ADAPTERS['todoist'].sync(db=db, integration_id=integ.id)
|
||||
db.refresh(h)
|
||||
assert h.status == 'active'
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_github_delete_only_on_full_vs_incremental(client, monkeypatch):
|
||||
import modern.backend.models as models
|
||||
import requests
|
||||
from modern.backend.adapters import ADAPTERS
|
||||
from modern.backend.config import settings
|
||||
db = models.SessionLocal()
|
||||
try:
|
||||
# Incremental: since set -> no delete
|
||||
integ_inc = _setup_integration(db, 1, 'github', 'pat', config={'github_since': '2025-08-28T00:00:00Z'})
|
||||
h_inc = _create_mapped_habit(db, integ_inc.id, external_id='999', title='GH Stay')
|
||||
def fake_get(url, headers=None, params=None, timeout=10):
|
||||
return FakeResp(200, [], headers={})
|
||||
monkeypatch.setattr(requests, 'get', fake_get)
|
||||
settings.INTEGRATION_CLOSE_MODE = 'delete'
|
||||
ADAPTERS['github'].sync(db=db, integration_id=integ_inc.id)
|
||||
assert db.query(models.Habit).filter_by(id=h_inc.id).first() is not None
|
||||
|
||||
# Full: since not set -> delete
|
||||
integ_full = _setup_integration(db, 1, 'github', 'pat', config={})
|
||||
h_full = _create_mapped_habit(db, integ_full.id, external_id='1000', title='GH Gone')
|
||||
ADAPTERS['github'].sync(db=db, integration_id=integ_full.id)
|
||||
assert db.query(models.Habit).filter_by(id=h_full.id).first() is None
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,18 @@
|
||||
import os
|
||||
from modern.backend.notifier import send_email
|
||||
|
||||
def test_email_console_transport(monkeypatch, capsys):
|
||||
monkeypatch.setenv('LIFERPG_EMAIL_TRANSPORT', 'console')
|
||||
# Should not raise and should log event via metrics logger; we can't capture it easily
|
||||
send_email('test@example.com', 'Subj', 'Body')
|
||||
|
||||
|
||||
def test_email_disabled_transport(monkeypatch):
|
||||
monkeypatch.setenv('LIFERPG_EMAIL_TRANSPORT', 'disabled')
|
||||
send_email('nobody@example.com', 'x', 'y')
|
||||
|
||||
|
||||
def test_email_smtp_missing_host_falls_back(monkeypatch):
|
||||
monkeypatch.setenv('LIFERPG_EMAIL_TRANSPORT', 'smtp')
|
||||
# no SMTP_HOST set
|
||||
send_email('nobody@example.com', 'x', 'y')
|
||||
@@ -0,0 +1,45 @@
|
||||
def test_send_email_via_mock_smtp(monkeypatch):
|
||||
import os
|
||||
os.environ['LIFERPG_EMAIL_TRANSPORT'] = 'smtp'
|
||||
os.environ['SMTP_HOST'] = 'localhost'
|
||||
os.environ['SMTP_PORT'] = '2525'
|
||||
os.environ['SMTP_USERNAME'] = 'user'
|
||||
os.environ['SMTP_PASSWORD'] = 'pass'
|
||||
os.environ['SMTP_USE_TLS'] = 'false'
|
||||
|
||||
sent = {}
|
||||
|
||||
class FakeSMTP:
|
||||
def __init__(self, host, port, timeout=10):
|
||||
assert host == 'localhost'
|
||||
assert int(port) == 2525
|
||||
self.logged_in = False
|
||||
def starttls(self):
|
||||
# should not be called since SMTP_USE_TLS=false
|
||||
pass
|
||||
def login(self, user, pwd):
|
||||
assert user == 'user' and pwd == 'pass'
|
||||
self.logged_in = True
|
||||
def send_message(self, msg):
|
||||
sent['from'] = msg['From']
|
||||
sent['to'] = msg['To']
|
||||
sent['subject'] = msg['Subject']
|
||||
sent['body'] = msg.get_content()
|
||||
def quit(self):
|
||||
pass
|
||||
|
||||
import smtplib
|
||||
monkeypatch.setattr(smtplib, 'SMTP', FakeSMTP)
|
||||
|
||||
# Rebuild settings to pick up envs
|
||||
import importlib
|
||||
import modern.backend.config as config
|
||||
importlib.reload(config)
|
||||
# Patch config.settings inside notifier to this new instance
|
||||
import modern.backend.notifier as notifier
|
||||
notifier.settings = config.settings
|
||||
|
||||
notifier.send_email('to@example.com', 'Hello', 'Body text')
|
||||
assert sent.get('to') == 'to@example.com'
|
||||
assert sent.get('subject') == 'Hello'
|
||||
assert 'Body text' in (sent.get('body') or '')
|
||||
@@ -0,0 +1,48 @@
|
||||
import os
|
||||
import json
|
||||
import importlib
|
||||
from modern.backend import models
|
||||
|
||||
|
||||
def test_export_import_roundtrip(client):
|
||||
# Login as admin
|
||||
r = client.post('/api/v1/auth/login', json={'email': 'admin@test', 'password': 'pass'})
|
||||
assert r.status_code == 200
|
||||
|
||||
# Create some data directly in DB
|
||||
db = models.SessionLocal()
|
||||
try:
|
||||
u = models.User(email='u@test')
|
||||
db.add(u)
|
||||
db.flush()
|
||||
p = models.Project(user_id=u.id, title='P1')
|
||||
db.add(p)
|
||||
db.flush()
|
||||
h = models.Habit(user_id=u.id, project_id=p.id, title='H1')
|
||||
db.add(h)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# Export
|
||||
exp = client.get('/api/v1/admin/export')
|
||||
assert exp.status_code == 200
|
||||
ciphertext = exp.json().get('ciphertext')
|
||||
assert ciphertext
|
||||
|
||||
# Drop and recreate schema
|
||||
import modern.backend.models as m
|
||||
importlib.reload(m)
|
||||
m.Base.metadata.drop_all(bind=m.engine)
|
||||
m.Base.metadata.create_all(bind=m.engine)
|
||||
|
||||
# Import
|
||||
imp = client.post('/api/v1/admin/import', json={'ciphertext': ciphertext})
|
||||
assert imp.status_code == 200
|
||||
|
||||
# Verify user restored
|
||||
db2 = models.SessionLocal()
|
||||
try:
|
||||
assert db2.query(models.User).filter_by(email='u@test').first() is not None
|
||||
finally:
|
||||
db2.close()
|
||||
@@ -0,0 +1,32 @@
|
||||
import json
|
||||
import os
|
||||
|
||||
def test_email_hook_console_transport(client, monkeypatch):
|
||||
# Force console transport
|
||||
monkeypatch.setenv('LIFERPG_EMAIL_TRANSPORT', 'console')
|
||||
# Force inline execution (no RQ)
|
||||
monkeypatch.setattr('modern.backend.app.get_queue', lambda: None, raising=False)
|
||||
# Import models after client fixture initialized DB
|
||||
from modern.backend import models
|
||||
# Create a dummy integration with an EmailHook in post_sync
|
||||
db = models.SessionLocal()
|
||||
try:
|
||||
integ = models.Integration(user_id=1, provider='slack', external_id=None, config=json.dumps({
|
||||
'hooks': {
|
||||
'pre_sync': [],
|
||||
'post_sync': [
|
||||
{ 'type': 'email', 'to': 'ops@example.com', 'subject': 'Sync {provider}', 'body': 'count={count}', 'on': 'success' }
|
||||
]
|
||||
}
|
||||
}))
|
||||
db.add(integ)
|
||||
db.commit()
|
||||
db.refresh(integ)
|
||||
finally:
|
||||
db.close()
|
||||
# Trigger sync enqueue inline path by calling /api/v1/integrations/{id}/sync with no RQ (queue not running in tests)
|
||||
resp = client.post(f'/api/v1/integrations/{integ.id}/sync')
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data.get('queued') is False
|
||||
# We can't easily assert email delivery; success here is the inline path completed without error
|
||||
@@ -0,0 +1,33 @@
|
||||
from modern.backend import models
|
||||
|
||||
|
||||
def test_integration_delete_logs_change(client):
|
||||
# create a normal user
|
||||
r = client.post('/api/v1/auth/signup', json={'email': 'intuser@test', 'password': 'p'})
|
||||
assert r.status_code == 200
|
||||
|
||||
# find user id
|
||||
db = models.SessionLocal()
|
||||
try:
|
||||
user = db.query(models.User).filter_by(email='intuser@test').first()
|
||||
assert user is not None
|
||||
# create integration directly
|
||||
integ = models.Integration(user_id=user.id, provider='google', external_id='ext-1', config='{}')
|
||||
db.add(integ)
|
||||
db.commit()
|
||||
db.refresh(integ)
|
||||
integ_id = integ.id
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# As admin (fixture), delete the integration
|
||||
resp = client.delete(f'/api/v1/integrations/{integ_id}')
|
||||
assert resp.status_code == 200
|
||||
|
||||
# check changelog
|
||||
db = models.SessionLocal()
|
||||
try:
|
||||
cl = db.query(models.ChangeLog).filter_by(entity='integration', entity_id=integ_id, action='delete').first()
|
||||
assert cl is not None
|
||||
finally:
|
||||
db.close()
|
||||
@@ -1,10 +1,4 @@
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from modern.backend.app import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
def test_list_integrations_empty():
|
||||
def test_list_integrations_empty(client):
|
||||
resp = client.get('/api/v1/integrations')
|
||||
assert resp.status_code == 200
|
||||
*** End Patch
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
from modern.tests.conftest import client as _c
|
||||
|
||||
# Basic sanity: the client fixture creates a test DB and app startup should
|
||||
# initialize tables; this test simply uses the client to create a user.
|
||||
|
||||
def test_startup_initializes_db(client):
|
||||
r = client.post('/api/v1/auth/signup', json={'email': 'life@test', 'password': 'p'})
|
||||
assert r.status_code == 200
|
||||
@@ -0,0 +1,21 @@
|
||||
from modern.backend import models
|
||||
from modern.backend.app import _log_change
|
||||
import importlib
|
||||
|
||||
|
||||
def test_log_change_requires_db(client):
|
||||
# reload models bound to test DB
|
||||
import modern.backend.models as models
|
||||
importlib.reload(models)
|
||||
import modern.backend.app as app
|
||||
importlib.reload(app)
|
||||
|
||||
db = models.SessionLocal()
|
||||
try:
|
||||
# should add a ChangeLog record when passed a real db
|
||||
_log_change(None, 'test', None, 'action', {'a':1}, db=db)
|
||||
db.commit()
|
||||
row = db.query(models.ChangeLog).order_by(models.ChangeLog.id.desc()).first()
|
||||
assert row is not None
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,10 @@
|
||||
def test_metrics_endpoint_exposes_counters(client):
|
||||
# trigger a request so counters increment
|
||||
r1 = client.get('/health')
|
||||
assert r1.status_code == 200
|
||||
m = client.get('/metrics')
|
||||
assert m.status_code == 200
|
||||
text = m.text
|
||||
assert '# HELP http_requests_total' in text
|
||||
assert 'http_requests_total{method="GET",path="/health",status="200"}' in text
|
||||
assert '# HELP http_request_duration_seconds' in text
|
||||
@@ -0,0 +1,88 @@
|
||||
import time
|
||||
import pytest
|
||||
import importlib
|
||||
|
||||
# Do not import models or oauth at module level; tests must reload them after
|
||||
# the `client` fixture sets DATABASE_URL so engines bind to the temp DB.
|
||||
|
||||
|
||||
class DummyResp:
|
||||
def __init__(self, status_code, data):
|
||||
self.status_code = status_code
|
||||
self._data = data
|
||||
|
||||
def json(self):
|
||||
return self._data
|
||||
|
||||
|
||||
# Note: DB is initialized by the `client` fixture in tests/conftest.py
|
||||
|
||||
|
||||
def test_refresh_with_temp_session(client, monkeypatch):
|
||||
# ensure we use the test DB created by the `client` fixture
|
||||
import modern.backend.models as models
|
||||
importlib.reload(models)
|
||||
import modern.backend.oauth as oauth
|
||||
importlib.reload(oauth)
|
||||
refresh_google_token_if_needed = oauth.refresh_google_token_if_needed
|
||||
|
||||
db = models.SessionLocal()
|
||||
try:
|
||||
integration = models.Integration(user_id=1, provider='google', external_id='ext1', config='{}')
|
||||
db.add(integration)
|
||||
db.commit()
|
||||
db.refresh(integration)
|
||||
|
||||
token_row = models.OAuthToken(integration_id=integration.id, access_token='old', refresh_token='enc_refresh', scope='read', expires_at=1)
|
||||
db.add(token_row)
|
||||
db.commit()
|
||||
db.refresh(token_row)
|
||||
|
||||
def fake_post(url, data=None, timeout=10):
|
||||
return DummyResp(200, {'access_token': 'new_access', 'refresh_token': 'new_refresh', 'expires_in': 3600, 'scope': 'read'})
|
||||
|
||||
monkeypatch.setattr('modern.backend.oauth.requests.post', fake_post)
|
||||
# ensure client id/secret are set so helper proceeds
|
||||
monkeypatch.setenv('GOOGLE_CLIENT_ID', 'test_client')
|
||||
monkeypatch.setenv('GOOGLE_CLIENT_SECRET', 'test_secret')
|
||||
|
||||
# call helper with injected db (tests should pass a real session)
|
||||
new_row = refresh_google_token_if_needed(token_row, db=db)
|
||||
assert new_row is not None
|
||||
assert new_row.access_token != token_row.access_token
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_refresh_with_injected_session(client, monkeypatch):
|
||||
# ensure we use the test DB created by the `client` fixture
|
||||
import modern.backend.models as models
|
||||
importlib.reload(models)
|
||||
import modern.backend.oauth as oauth
|
||||
importlib.reload(oauth)
|
||||
refresh_google_token_if_needed = oauth.refresh_google_token_if_needed
|
||||
|
||||
db = models.SessionLocal()
|
||||
try:
|
||||
integration = models.Integration(user_id=1, provider='google', external_id='ext2', config='{}')
|
||||
db.add(integration)
|
||||
db.commit()
|
||||
db.refresh(integration)
|
||||
|
||||
token_row = models.OAuthToken(integration_id=integration.id, access_token='old2', refresh_token='enc_refresh2', scope='read', expires_at=1)
|
||||
db.add(token_row)
|
||||
db.commit()
|
||||
db.refresh(token_row)
|
||||
|
||||
def fake_post(url, data=None, timeout=10):
|
||||
return DummyResp(200, {'access_token': 'new_access2', 'refresh_token': 'new_refresh2', 'expires_in': 3600, 'scope': 'read'})
|
||||
|
||||
monkeypatch.setattr('modern.backend.oauth.requests.post', fake_post)
|
||||
monkeypatch.setenv('GOOGLE_CLIENT_ID', 'test_client')
|
||||
monkeypatch.setenv('GOOGLE_CLIENT_SECRET', 'test_secret')
|
||||
|
||||
new_row = refresh_google_token_if_needed(token_row, db=db)
|
||||
assert new_row is not None
|
||||
assert new_row.access_token != token_row.access_token
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,106 @@
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import importlib
|
||||
import pytest
|
||||
|
||||
# These tests simulate OIDC flows by overriding environment and mocking the Authlib client methods
|
||||
|
||||
@pytest.fixture
|
||||
def oidc_app_client(monkeypatch):
|
||||
# Minimal env for one provider "testidp"
|
||||
os.environ['OIDC_PROVIDERS'] = json.dumps({
|
||||
'testidp': {
|
||||
'issuer': 'https://example-idp.test',
|
||||
'client_id': 'cid',
|
||||
'client_secret': 'csec',
|
||||
'scope': 'openid email profile'
|
||||
}
|
||||
})
|
||||
os.environ['BASE_URL'] = 'http://test'
|
||||
|
||||
# Import app fresh
|
||||
import modern.backend.models as models
|
||||
importlib.reload(models)
|
||||
models.init_db()
|
||||
import modern.backend.app as app
|
||||
importlib.reload(app)
|
||||
|
||||
client = app.app.test_client() if hasattr(app.app, 'test_client') else None
|
||||
if client is None:
|
||||
from fastapi.testclient import TestClient
|
||||
client = TestClient(app.app)
|
||||
|
||||
return client
|
||||
|
||||
|
||||
def test_oidc_state_expiry(monkeypatch, oidc_app_client):
|
||||
client = oidc_app_client
|
||||
|
||||
# Mock out OAuth client to avoid real redirects and code exchange
|
||||
from modern.backend import oauth as oauth_mod
|
||||
|
||||
class DummyClient:
|
||||
async def authorize_redirect(self, request, redirect_uri, **kwargs):
|
||||
# Simulate redirect: return a dummy response
|
||||
from starlette.responses import JSONResponse
|
||||
return JSONResponse({'redirect': redirect_uri, 'state': kwargs.get('state')})
|
||||
|
||||
async def authorize_access_token(self, request, code_verifier=None):
|
||||
raise Exception('should not be called in expiry test')
|
||||
|
||||
oauth_mod._ensure_registered('testidp')
|
||||
setattr(oauth_mod.oauth, 'testidp', DummyClient())
|
||||
|
||||
# Start login to create state
|
||||
r = client.get('/api/v1/auth/oidc/testidp/login')
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
state = data['state']
|
||||
|
||||
# Expire the state by directly updating DB
|
||||
import modern.backend.models as models
|
||||
db = models.SessionLocal()
|
||||
rec = db.query(models.OIDCLoginState).filter_by(state=state).first()
|
||||
from datetime import datetime, timedelta, timezone
|
||||
rec.expires_at = datetime.now(timezone.utc) - timedelta(seconds=1)
|
||||
db.commit()
|
||||
|
||||
# Callback with expired state should 400
|
||||
r2 = client.get(f'/api/v1/auth/oidc/callback?state={state}&code=abc')
|
||||
assert r2.status_code == 400
|
||||
|
||||
|
||||
def test_oidc_callback_flow(monkeypatch, oidc_app_client):
|
||||
client = oidc_app_client
|
||||
|
||||
from modern.backend import oauth as oauth_mod
|
||||
|
||||
class DummyClient:
|
||||
async def authorize_redirect(self, request, redirect_uri, **kwargs):
|
||||
from starlette.responses import JSONResponse
|
||||
return JSONResponse({'redirect': redirect_uri, 'state': kwargs.get('state')})
|
||||
|
||||
async def authorize_access_token(self, request, code_verifier=None):
|
||||
return {'access_token': 'at', 'id_token': 'idt'}
|
||||
|
||||
async def parse_id_token(self, request, token):
|
||||
return {'email': 'oidcuser@example.com', 'name': 'OIDC User'}
|
||||
|
||||
oauth_mod._ensure_registered('testidp')
|
||||
setattr(oauth_mod.oauth, 'testidp', DummyClient())
|
||||
|
||||
# Start login, capture state
|
||||
r = client.get('/api/v1/auth/oidc/testidp/login')
|
||||
assert r.status_code == 200
|
||||
state = r.json()['state']
|
||||
|
||||
# Callback simulating IdP
|
||||
r2 = client.get(f'/api/v1/auth/oidc/callback?state={state}&code=abc')
|
||||
assert r2.status_code == 200
|
||||
# Should set session cookie
|
||||
assert 'session=' in r2.headers.get('set-cookie', '')
|
||||
|
||||
# Logout should clear cookies and attempt RP logout URL (will fallback to JSON)
|
||||
r3 = client.post('/api/v1/auth/oidc/logout')
|
||||
assert r3.status_code in (200, 307)
|
||||
@@ -0,0 +1,19 @@
|
||||
def test_public_widget_with_token(client):
|
||||
# create a user and login
|
||||
client.post('/api/v1/users', json={'email': 'pub@example.com'})
|
||||
client.post('/api/v1/auth/signup', json={'email': 'pub@example.com', 'password': 'pw'})
|
||||
|
||||
# create a token
|
||||
r = client.post('/api/v1/tokens', json={'name': 'widget'})
|
||||
assert r.status_code == 200
|
||||
tok = r.json()['token']
|
||||
|
||||
# call public status with token
|
||||
r2 = client.get(f'/api/v1/public/widgets/status?token={tok}')
|
||||
assert r2.status_code == 200
|
||||
data = r2.json()
|
||||
assert 'active_habits' in data and 'completed_last_7_days' in data and 'current_streak_days' in data
|
||||
|
||||
# invalid token should 401
|
||||
r3 = client.get('/api/v1/public/widgets/status?token=bad')
|
||||
assert r3.status_code == 401
|
||||
@@ -0,0 +1,25 @@
|
||||
from modern.backend import models
|
||||
|
||||
|
||||
def test_require_owner_or_admin_blocks_other_users(client):
|
||||
# create two users
|
||||
client.post('/api/v1/auth/signup', json={'email': 'owner@test', 'password': 'p'})
|
||||
client.post('/api/v1/auth/signup', json={'email': 'other@test', 'password': 'p'})
|
||||
|
||||
db = models.SessionLocal()
|
||||
try:
|
||||
owner = db.query(models.User).filter_by(email='owner@test').first()
|
||||
other = db.query(models.User).filter_by(email='other@test').first()
|
||||
# create integration for owner
|
||||
integ = models.Integration(user_id=owner.id, provider='google', external_id='o1', config='{}')
|
||||
db.add(integ)
|
||||
db.commit(); db.refresh(integ)
|
||||
integ_id = integ.id
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# login as 'other' and try to delete integration (should be 403)
|
||||
client.post('/api/v1/auth/login', json={'email': 'other@test', 'password': 'p'})
|
||||
resp = client.delete(f'/api/v1/integrations/{integ_id}')
|
||||
assert resp.status_code in (401, 403)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
def test_security_headers_present(client):
|
||||
r = client.get('/health')
|
||||
assert r.status_code == 200
|
||||
# core headers always present
|
||||
assert r.headers.get('X-Content-Type-Options') == 'nosniff'
|
||||
assert r.headers.get('X-Frame-Options') == 'DENY'
|
||||
assert r.headers.get('Referrer-Policy') == 'no-referrer'
|
||||
assert r.headers.get('Permissions-Policy') == 'geolocation=()'
|
||||
assert 'Content-Security-Policy' in r.headers
|
||||
|
||||
|
||||
def test_cors_preflight(client):
|
||||
# Simulate a preflight request from allowed origin
|
||||
headers = {
|
||||
'Origin': 'http://localhost:5173',
|
||||
'Access-Control-Request-Method': 'POST',
|
||||
'Access-Control-Request-Headers': 'content-type,authorization'
|
||||
}
|
||||
r = client.options('/api/v1/hello', headers=headers)
|
||||
assert r.status_code in (200, 204)
|
||||
assert r.headers.get('access-control-allow-origin') == 'http://localhost:5173'
|
||||
assert r.headers.get('access-control-allow-credentials') == 'true'
|
||||
|
||||
|
||||
def test_headers_on_other_routes(client):
|
||||
# simple GET route
|
||||
r = client.get('/api/v1/hello')
|
||||
assert r.status_code == 200
|
||||
assert r.headers.get('X-Frame-Options') == 'DENY'
|
||||
# POST route
|
||||
r2 = client.post('/api/v1/users', json={'email': 'h@test'})
|
||||
assert r2.status_code in (200, 400) # email required in some contexts; just check headers presence
|
||||
assert 'Content-Security-Policy' in r2.headers
|
||||
|
||||
|
||||
def test_request_size_limit(client):
|
||||
# Build a body > 1MiB (default limit)
|
||||
big = 'x' * (1024 * 1024 + 1)
|
||||
r = client.post('/api/v1/users', data=big, headers={'content-type': 'text/plain'})
|
||||
assert r.status_code == 413
|
||||
assert r.json().get('detail') == 'request entity too large'
|
||||
|
||||
|
||||
def test_rate_limit_basic(client):
|
||||
# Hit a lightweight endpoint enough times to trip limiter quickly by reducing RPM via env is harder in this fixture.
|
||||
# Instead, we assume default 120; we'll just assert headers exist for now and simulate close to limit by making a few calls.
|
||||
for _ in range(3):
|
||||
r = client.get('/api/v1/hello')
|
||||
assert r.status_code == 200
|
||||
assert 'X-RateLimit-Limit' in r.headers
|
||||
assert 'X-RateLimit-Remaining' in r.headers
|
||||
@@ -0,0 +1,53 @@
|
||||
from modern.backend import models
|
||||
import json
|
||||
|
||||
|
||||
def test_sync_to_habits_creates_changelog(client, monkeypatch):
|
||||
# prepare a user and an integration with a fake oauth token
|
||||
resp = client.post('/api/v1/auth/signup', json={'email': 'syncuser@test', 'password': 'p'})
|
||||
assert resp.status_code == 200
|
||||
|
||||
db = models.SessionLocal()
|
||||
try:
|
||||
user = db.query(models.User).filter_by(email='syncuser@test').first()
|
||||
integ = models.Integration(user_id=user.id, provider='google', external_id='s1', config='{}')
|
||||
db.add(integ); db.commit(); db.refresh(integ)
|
||||
integ_id = integ.id
|
||||
# add a fake OAuthToken with an encrypted dummy access token
|
||||
from modern.backend.crypto import encrypt_text
|
||||
tok = models.OAuthToken(integration_id=integ_id, access_token=encrypt_text('dummy'), refresh_token=encrypt_text('dummy'))
|
||||
db.add(tok); db.commit(); db.refresh(tok)
|
||||
tok_id = tok.id
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# monkeypatch requests.get to return a fake events payload
|
||||
class FakeResp:
|
||||
def __init__(self, data):
|
||||
self._data = data
|
||||
self.status_code = 200
|
||||
def json(self):
|
||||
return self._data
|
||||
|
||||
def fake_get(url, headers=None, params=None, timeout=None):
|
||||
return FakeResp({'items': [{'id': 'e1', 'summary': 'Test Event', 'start': {'dateTime': '2025-01-01T10:00:00Z'}}]})
|
||||
|
||||
monkeypatch.setattr('requests.get', fake_get)
|
||||
|
||||
# ensure refresh logic is bypassed by monkeypatching refresh function to return same
|
||||
# call sync endpoint
|
||||
# make sure the client is acting as admin (fixture created admin user with session)
|
||||
client.post('/api/v1/auth/login', json={'email': 'admin@test', 'password': 'pass'})
|
||||
|
||||
r = client.post(f'/api/v1/integrations/{integ_id}/sync_to_habits')
|
||||
assert r.status_code == 200
|
||||
|
||||
# check changelog
|
||||
db = models.SessionLocal()
|
||||
try:
|
||||
cl = db.query(models.ChangeLog).filter_by(entity='integration', entity_id=integ_id, action='sync_to_habits').first()
|
||||
assert cl is not None
|
||||
payload = json.loads(cl.payload or '{}')
|
||||
assert payload.get('count') == 1
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,50 @@
|
||||
import json
|
||||
|
||||
|
||||
class FakeResp:
|
||||
def __init__(self, status=200, data=None, headers=None):
|
||||
self.status_code = status
|
||||
self._data = data if data is not None else []
|
||||
self.headers = headers or {}
|
||||
|
||||
def json(self):
|
||||
return self._data
|
||||
|
||||
|
||||
def test_adapters_store_utc_z_suffix(client, monkeypatch):
|
||||
import modern.backend.models as models
|
||||
from modern.backend.adapters import ADAPTERS
|
||||
import requests
|
||||
db = models.SessionLocal()
|
||||
try:
|
||||
# Setup Todoist integration with token
|
||||
integ = models.Integration(user_id=1, provider='todoist', external_id=None, config=json.dumps({'todoist_full_fetch': False}))
|
||||
db.add(integ); db.flush()
|
||||
from modern.backend.crypto import encrypt_text
|
||||
db.add(models.OAuthToken(integration_id=integ.id, access_token=encrypt_text('tok')))
|
||||
db.flush()
|
||||
monkeypatch.setattr(requests, 'get', lambda *a, **k: FakeResp(200, []))
|
||||
ADAPTERS['todoist'].sync(db=db, integration_id=integ.id)
|
||||
db.refresh(integ)
|
||||
cfg = json.loads(integ.config or '{}')
|
||||
v = cfg.get('last_sync_at')
|
||||
assert v and isinstance(v, str) and v.endswith('Z')
|
||||
# parseable when replacing Z with +00:00
|
||||
from datetime import datetime
|
||||
datetime.fromisoformat(v.replace('Z','+00:00'))
|
||||
|
||||
# Setup GitHub integration
|
||||
g = models.Integration(user_id=1, provider='github', external_id=None, config=json.dumps({}))
|
||||
db.add(g); db.flush()
|
||||
db.add(models.OAuthToken(integration_id=g.id, access_token=encrypt_text('pat')))
|
||||
db.flush()
|
||||
monkeypatch.setattr(requests, 'get', lambda *a, **k: FakeResp(200, [], headers={}))
|
||||
ADAPTERS['github'].sync(db=db, integration_id=g.id)
|
||||
db.refresh(g)
|
||||
cfg2 = json.loads(g.config or '{}')
|
||||
v2 = cfg2.get('github_since')
|
||||
assert v2 and isinstance(v2, str) and v2.endswith('Z')
|
||||
from datetime import datetime
|
||||
datetime.fromisoformat(v2.replace('Z','+00:00'))
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,37 @@
|
||||
import pytest
|
||||
from modern.backend import models
|
||||
|
||||
|
||||
def test_create_then_fail_rolls_back(client):
|
||||
# ensure no user with this email exists
|
||||
db = models.SessionLocal()
|
||||
try:
|
||||
before = db.query(models.User).filter_by(email='rollback@test').first()
|
||||
assert before is None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
r = client.post('/api/v1/_test/create_then_fail', json={'email': 'rollback@test'})
|
||||
assert r.status_code == 500
|
||||
|
||||
db = models.SessionLocal()
|
||||
try:
|
||||
after = db.query(models.User).filter_by(email='rollback@test').first()
|
||||
assert after is None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_create_commits_when_no_error(client):
|
||||
# create via normal endpoint
|
||||
r = client.post('/api/v1/users', json={'email': 'commit@test'})
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data.get('email') == 'commit@test'
|
||||
|
||||
db = models.SessionLocal()
|
||||
try:
|
||||
user = db.query(models.User).filter_by(email='commit@test').first()
|
||||
assert user is not None
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,33 @@
|
||||
import os
|
||||
import hmac
|
||||
import hashlib
|
||||
|
||||
|
||||
class FakeJob:
|
||||
def __init__(self, id='job-1'):
|
||||
self.id = id
|
||||
|
||||
|
||||
class FakeQueue:
|
||||
def enqueue(self, fn, payload):
|
||||
return FakeJob()
|
||||
|
||||
|
||||
def test_todoist_webhook_enqueues_with_valid_signature(client, monkeypatch):
|
||||
os.environ['TODOIST_WEBHOOK_SECRET'] = 'secret'
|
||||
# Patch get_queue in app to return fake queue
|
||||
import modern.backend.app as app
|
||||
monkeypatch.setattr(app, 'get_queue', lambda: FakeQueue())
|
||||
|
||||
body = b'{"event_name":"item:added"}'
|
||||
sig = hmac.new(b'secret', body, hashlib.sha256).hexdigest()
|
||||
r = client.post('/api/v1/webhooks/todoist', data=body, headers={'X-Todoist-Hmac-SHA256': sig, 'content-type': 'application/json'})
|
||||
assert r.status_code == 200
|
||||
j = r.json()
|
||||
assert j.get('ok') is True and j.get('queued') is True and j.get('verified') is True
|
||||
|
||||
|
||||
def test_todoist_webhook_rejects_bad_signature(client):
|
||||
os.environ['TODOIST_WEBHOOK_SECRET'] = 'secret'
|
||||
r = client.post('/api/v1/webhooks/todoist', data=b'{}', headers={'X-Todoist-Hmac-SHA256': 'bad'})
|
||||
assert r.status_code == 403
|
||||
Reference in New Issue
Block a user