mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-05 03:49:52 -05:00
39cf649f8e
Implement a complete client portal feature that allows clients to access their projects, invoices, and time entries through a dedicated portal with separate authentication. Includes password setup via email with secure token-based authentication. Client Portal Features: - Client-based authentication (separate from user accounts) - Portal access can be enabled/disabled per client - Clients can view their projects, invoices, and time entries - Clean, minimal UI without main app navigation elements - Login page styled to match main app design Password Setup Email: - Admin can send password setup emails to clients - Secure token-based password setup (24-hour expiration) - Email template with professional styling - Password setup page matching app login design - Token validation and automatic cleanup after use Email Configuration: - Email settings from admin menu are now used for sending - Database email settings persist between restarts and updates - Automatic reload of email configuration when sending emails - Database settings take precedence over environment variables - Improved error messages for email configuration issues Database Changes: - Add portal_enabled, portal_username, portal_password_hash to clients - Add password_setup_token and password_setup_token_expires to clients - Migration 047: Add client portal fields to users (legacy) - Migration 048: Add client portal credentials to clients - Migration 049: Add password setup token fields New Files: - app/routes/client_portal.py - Client portal routes and authentication - app/templates/client_portal/ - Portal templates (base, login, dashboard, etc.) - app/templates/email/client_portal_password_setup.html - Email template - migrations/versions/047-049 - Database migrations - tests/test_client_portal.py - Portal tests - docs/CLIENT_PORTAL.md - Portal documentation Modified Files: - app/models/client.py - Add portal fields and password token methods - app/routes/clients.py - Add send password email route - app/routes/client_portal.py - Portal routes with redirect handling - app/utils/email.py - Use database settings, add password setup email - app/templates/clients/edit.html - Add send email button - app/templates/components/ui.html - Support client portal breadcrumbs Security: - Secure token generation using secrets.token_urlsafe() - Password hashing with werkzeug.security - Token expiration (24 hours default) - Token cleared after successful password setup - CSRF protection on all forms
349 lines
13 KiB
Python
349 lines
13 KiB
Python
"""
|
|
Comprehensive tests for Client Portal feature.
|
|
|
|
This module tests:
|
|
- User model client portal fields and properties
|
|
- Client portal routes and access control
|
|
- Client portal data retrieval
|
|
- Admin interface for enabling/disabling portal access
|
|
"""
|
|
|
|
import pytest
|
|
from datetime import datetime, timedelta
|
|
from decimal import Decimal
|
|
from app.models import User, Client, Project, Invoice, InvoiceItem, TimeEntry
|
|
from app import db
|
|
|
|
|
|
# ============================================================================
|
|
# Model Tests
|
|
# ============================================================================
|
|
|
|
@pytest.mark.models
|
|
@pytest.mark.unit
|
|
class TestClientPortalUserModel:
|
|
"""Test User model client portal functionality"""
|
|
|
|
def test_user_client_portal_enabled_field(self, app, user):
|
|
"""Test client_portal_enabled field defaults to False"""
|
|
with app.app_context():
|
|
assert user.client_portal_enabled is False
|
|
|
|
def test_user_client_id_field(self, app, user):
|
|
"""Test client_id field defaults to None"""
|
|
with app.app_context():
|
|
assert user.client_id is None
|
|
|
|
def test_is_client_portal_user_property(self, app, user, client):
|
|
"""Test is_client_portal_user property"""
|
|
with app.app_context():
|
|
# Initially False
|
|
assert user.is_client_portal_user is False
|
|
|
|
# Enable portal but no client assigned
|
|
user.client_portal_enabled = True
|
|
assert user.is_client_portal_user is False
|
|
|
|
# Assign client
|
|
user.client_id = client.id
|
|
assert user.is_client_portal_user is True
|
|
|
|
def test_get_client_portal_data(self, app, user, client):
|
|
"""Test get_client_portal_data method"""
|
|
with app.app_context():
|
|
# No portal access
|
|
assert user.get_client_portal_data() is None
|
|
|
|
# Enable portal and assign client
|
|
user.client_portal_enabled = True
|
|
user.client_id = client.id
|
|
db.session.commit()
|
|
|
|
# Should return data structure
|
|
data = user.get_client_portal_data()
|
|
assert data is not None
|
|
assert 'client' in data
|
|
assert 'projects' in data
|
|
assert 'invoices' in data
|
|
assert 'time_entries' in data
|
|
assert data['client'] == client
|
|
|
|
def test_get_client_portal_data_with_projects(self, app, user, client):
|
|
"""Test get_client_portal_data includes projects"""
|
|
with app.app_context():
|
|
user.client_portal_enabled = True
|
|
user.client_id = client.id
|
|
|
|
# Create projects
|
|
project1 = Project(name="Project 1", client_id=client.id, status='active')
|
|
project2 = Project(name="Project 2", client_id=client.id, status='active')
|
|
project3 = Project(name="Project 3", client_id=client.id, status='inactive')
|
|
db.session.add_all([project1, project2, project3])
|
|
db.session.commit()
|
|
|
|
data = user.get_client_portal_data()
|
|
assert len(data['projects']) == 2 # Only active projects
|
|
assert project1 in data['projects']
|
|
assert project2 in data['projects']
|
|
assert project3 not in data['projects']
|
|
|
|
def test_get_client_portal_data_with_invoices(self, app, user, client):
|
|
"""Test get_client_portal_data includes invoices"""
|
|
with app.app_context():
|
|
user.client_portal_enabled = True
|
|
user.client_id = client.id
|
|
|
|
project = Project(name="Test Project", client_id=client.id)
|
|
db.session.add(project)
|
|
db.session.commit()
|
|
|
|
# Create invoices
|
|
invoice1 = Invoice(
|
|
invoice_number="INV-001",
|
|
project_id=project.id,
|
|
client_name=client.name,
|
|
client_id=client.id,
|
|
due_date=datetime.utcnow().date() + timedelta(days=30),
|
|
created_by=user.id,
|
|
total_amount=Decimal('100.00')
|
|
)
|
|
invoice2 = Invoice(
|
|
invoice_number="INV-002",
|
|
project_id=project.id,
|
|
client_name=client.name,
|
|
client_id=client.id,
|
|
due_date=datetime.utcnow().date() + timedelta(days=30),
|
|
created_by=user.id,
|
|
total_amount=Decimal('200.00')
|
|
)
|
|
db.session.add_all([invoice1, invoice2])
|
|
db.session.commit()
|
|
|
|
data = user.get_client_portal_data()
|
|
assert len(data['invoices']) == 2
|
|
assert invoice1 in data['invoices']
|
|
assert invoice2 in data['invoices']
|
|
|
|
def test_get_client_portal_data_with_time_entries(self, app, user, client):
|
|
"""Test get_client_portal_data includes time entries"""
|
|
with app.app_context():
|
|
user.client_portal_enabled = True
|
|
user.client_id = client.id
|
|
|
|
project = Project(name="Test Project", client_id=client.id)
|
|
db.session.add(project)
|
|
db.session.commit()
|
|
|
|
# Create time entries
|
|
entry1 = TimeEntry(
|
|
user_id=user.id,
|
|
project_id=project.id,
|
|
start_time=datetime.utcnow() - timedelta(hours=2),
|
|
end_time=datetime.utcnow(),
|
|
duration_seconds=7200
|
|
)
|
|
entry2 = TimeEntry(
|
|
user_id=user.id,
|
|
project_id=project.id,
|
|
start_time=datetime.utcnow() - timedelta(hours=1),
|
|
end_time=datetime.utcnow(),
|
|
duration_seconds=3600
|
|
)
|
|
db.session.add_all([entry1, entry2])
|
|
db.session.commit()
|
|
|
|
data = user.get_client_portal_data()
|
|
assert len(data['time_entries']) == 2
|
|
assert entry1 in data['time_entries']
|
|
assert entry2 in data['time_entries']
|
|
|
|
|
|
# ============================================================================
|
|
# Route Tests
|
|
# ============================================================================
|
|
|
|
@pytest.mark.routes
|
|
@pytest.mark.unit
|
|
class TestClientPortalRoutes:
|
|
"""Test client portal routes"""
|
|
|
|
def test_client_portal_dashboard_requires_access(self, app, client_fixture, user):
|
|
"""Test dashboard requires client portal access"""
|
|
with app.app_context():
|
|
# Login user without portal access
|
|
with client_fixture.session_transaction() as sess:
|
|
sess['_user_id'] = str(user.id)
|
|
|
|
response = client_fixture.get('/client-portal/dashboard')
|
|
assert response.status_code == 403
|
|
|
|
def test_client_portal_dashboard_with_access(self, app, client_fixture, user, client):
|
|
"""Test dashboard accessible with portal access"""
|
|
with app.app_context():
|
|
user.client_portal_enabled = True
|
|
user.client_id = client.id
|
|
db.session.commit()
|
|
|
|
with client_fixture.session_transaction() as sess:
|
|
sess['_user_id'] = str(user.id)
|
|
|
|
response = client_fixture.get('/client-portal/dashboard')
|
|
assert response.status_code == 200
|
|
assert b'Client Portal' in response.data
|
|
|
|
def test_client_portal_projects_route(self, app, client_fixture, user, client):
|
|
"""Test projects route"""
|
|
with app.app_context():
|
|
user.client_portal_enabled = True
|
|
user.client_id = client.id
|
|
db.session.commit()
|
|
|
|
with client_fixture.session_transaction() as sess:
|
|
sess['_user_id'] = str(user.id)
|
|
|
|
response = client_fixture.get('/client-portal/projects')
|
|
assert response.status_code == 200
|
|
|
|
def test_client_portal_invoices_route(self, app, client_fixture, user, client):
|
|
"""Test invoices route"""
|
|
with app.app_context():
|
|
user.client_portal_enabled = True
|
|
user.client_id = client.id
|
|
db.session.commit()
|
|
|
|
with client_fixture.session_transaction() as sess:
|
|
sess['_user_id'] = str(user.id)
|
|
|
|
response = client_fixture.get('/client-portal/invoices')
|
|
assert response.status_code == 200
|
|
|
|
def test_client_portal_time_entries_route(self, app, client_fixture, user, client):
|
|
"""Test time entries route"""
|
|
with app.app_context():
|
|
user.client_portal_enabled = True
|
|
user.client_id = client.id
|
|
db.session.commit()
|
|
|
|
with client_fixture.session_transaction() as sess:
|
|
sess['_user_id'] = str(user.id)
|
|
|
|
response = client_fixture.get('/client-portal/time-entries')
|
|
assert response.status_code == 200
|
|
|
|
def test_view_invoice_belongs_to_client(self, app, client_fixture, user, client):
|
|
"""Test viewing invoice requires it belongs to user's client"""
|
|
with app.app_context():
|
|
user.client_portal_enabled = True
|
|
user.client_id = client.id
|
|
|
|
# Create another client
|
|
other_client = Client(name="Other Client")
|
|
db.session.add(other_client)
|
|
|
|
project = Project(name="Test Project", client_id=client.id)
|
|
db.session.add(project)
|
|
db.session.commit()
|
|
|
|
# Create invoice for user's client
|
|
invoice = Invoice(
|
|
invoice_number="INV-001",
|
|
project_id=project.id,
|
|
client_name=client.name,
|
|
client_id=client.id,
|
|
due_date=datetime.utcnow().date() + timedelta(days=30),
|
|
created_by=user.id,
|
|
total_amount=Decimal('100.00')
|
|
)
|
|
db.session.add(invoice)
|
|
db.session.commit()
|
|
|
|
with client_fixture.session_transaction() as sess:
|
|
sess['_user_id'] = str(user.id)
|
|
|
|
# Should be able to view invoice
|
|
response = client_fixture.get(f'/client-portal/invoices/{invoice.id}')
|
|
assert response.status_code == 200
|
|
|
|
|
|
# ============================================================================
|
|
# Admin Interface Tests
|
|
# ============================================================================
|
|
|
|
@pytest.mark.routes
|
|
@pytest.mark.unit
|
|
class TestAdminClientPortalManagement:
|
|
"""Test admin interface for managing client portal access"""
|
|
|
|
def test_admin_can_enable_client_portal(self, app, admin_authenticated_client, user, client):
|
|
"""Test admin can enable client portal for user"""
|
|
with app.app_context():
|
|
response = admin_authenticated_client.post(
|
|
f'/admin/users/{user.id}/edit',
|
|
data={
|
|
'username': user.username,
|
|
'role': user.role,
|
|
'is_active': 'on' if user.is_active else '',
|
|
'client_portal_enabled': 'on',
|
|
'client_id': str(client.id),
|
|
'csrf_token': 'test-csrf-token'
|
|
},
|
|
follow_redirects=True
|
|
)
|
|
# Should redirect to users list
|
|
assert response.status_code == 200
|
|
|
|
# Verify user was updated
|
|
updated_user = User.query.get(user.id)
|
|
assert updated_user.client_portal_enabled is True
|
|
assert updated_user.client_id == client.id
|
|
|
|
def test_admin_can_disable_client_portal(self, app, admin_authenticated_client, user, client):
|
|
"""Test admin can disable client portal for user"""
|
|
with app.app_context():
|
|
# Enable portal first
|
|
user.client_portal_enabled = True
|
|
user.client_id = client.id
|
|
db.session.commit()
|
|
|
|
response = admin_authenticated_client.post(
|
|
f'/admin/users/{user.id}/edit',
|
|
data={
|
|
'username': user.username,
|
|
'role': user.role,
|
|
'is_active': 'on' if user.is_active else '',
|
|
'client_portal_enabled': '', # Not checked
|
|
'client_id': '',
|
|
'csrf_token': 'test-csrf-token'
|
|
},
|
|
follow_redirects=True
|
|
)
|
|
|
|
# Verify user was updated
|
|
updated_user = User.query.get(user.id)
|
|
assert updated_user.client_portal_enabled is False
|
|
assert updated_user.client_id is None
|
|
|
|
|
|
# ============================================================================
|
|
# Smoke Tests
|
|
# ============================================================================
|
|
|
|
@pytest.mark.smoke
|
|
@pytest.mark.unit
|
|
def test_client_portal_smoke(app, user, client):
|
|
"""Smoke test for client portal basic functionality"""
|
|
with app.app_context():
|
|
# Enable portal
|
|
user.client_portal_enabled = True
|
|
user.client_id = client.id
|
|
db.session.commit()
|
|
|
|
# Verify properties
|
|
assert user.is_client_portal_user is True
|
|
|
|
# Get portal data
|
|
data = user.get_client_portal_data()
|
|
assert data is not None
|
|
assert data['client'] == client
|
|
|