🧙‍♂️ 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:
TLimoges33
2025-08-30 17:32:42 +00:00
committed by GitHub
parent 00ad1bd8d4
commit 7fe4ae5365
270 changed files with 46366 additions and 7824 deletions
+38
View File
@@ -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
+66
View File
@@ -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
+58
View File
@@ -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
+24
View File
@@ -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
View File
@@ -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'
+102
View File
@@ -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()
+18
View File
@@ -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')
+45
View File
@@ -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 '')
+48
View File
@@ -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
+33
View File
@@ -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 -7
View File
@@ -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
+8
View File
@@ -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
+21
View File
@@ -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()
+10
View File
@@ -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
+88
View File
@@ -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()
+106
View File
@@ -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)
+19
View File
@@ -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
+25
View File
@@ -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)
+51
View File
@@ -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
+53
View File
@@ -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()
+50
View File
@@ -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()
+37
View File
@@ -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()
+33
View File
@@ -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