mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-20 05:10:26 -05:00
6cbe869fc1
Bundles four narrow fixes surfaced by the comprehensive CI run after
v5.5.6 enabled SQLite FK enforcement and refreshed test fixtures. Each
fix is independent; bundled here only to keep PR overhead low.
1. tests/test_routes/test_api_v1_expenses_complete.py
- Add missing `User` import. `test_expense_permissions` references
`User.query.filter(...)` but `User` was never imported, causing a
NameError at test runtime.
2. tests/test_client_single_simplification.py
- `Client(...status="active")` failed with TypeError because the
Client model's __init__ does not accept `status` as a keyword
(column default already sets it to "active"). Set the attribute
after construction.
3. tests/test_utils/test_api_auth_enhanced.py
- `User(username=..., is_active=True)` failed because User.__init__
does not accept `is_active`. Set the attribute after construction.
- 9 occurrences of `app.test_request_context(remote_addr="...")`
failed with `TypeError: EnvironBuilder.__init__() got an unexpected
keyword argument 'remote_addr'`. Werkzeug removed the keyword;
replace with `environ_overrides={"REMOTE_ADDR": "..."}` which is
the supported equivalent.
4. app/routes/api_v1_time_entries.py
- DELETE /api/v1/time-entries/<id> returned 415 Unsupported Media
Type when called without an `application/json` Content-Type, even
though the body is optional (only used to capture an audit reason).
Switch to `request.get_json(silent=True)` so the endpoint accepts
DELETE requests with no body. Same change applied to no other
methods; POST/PUT continue to require explicit JSON.
The route file also picked up a black/isort pass from the project
auto-formatter; behaviour is identical to before, only whitespace and
import grouping differ.
Test plan
- pytest tests/test_routes/test_api_v1_expenses_complete.py::TestAPIExpensesComplete::test_expense_permissions
- pytest tests/test_client_single_simplification.py::test_manual_entry_shows_select_when_multiple_clients
- pytest tests/test_utils/test_api_auth_enhanced.py::TestAuthenticateToken
- pytest tests/test_routes/test_api_v1_time_entries_complete.py::TestAPITimeEntriesComplete::test_delete_time_entry_uses_service_layer
300 lines
10 KiB
Python
300 lines
10 KiB
Python
"""
|
|
Tests for enhanced API authentication with IP whitelisting and better error handling.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
pytestmark = [pytest.mark.unit, pytest.mark.utils]
|
|
|
|
from datetime import datetime, timedelta
|
|
from flask import Flask, g
|
|
from app.models import ApiToken, User
|
|
from app.utils.api_auth import (
|
|
authenticate_token,
|
|
require_api_token,
|
|
extract_token_from_request,
|
|
)
|
|
from app import db
|
|
|
|
|
|
class TestExtractToken:
|
|
"""Tests for token extraction from requests"""
|
|
|
|
def test_extract_from_bearer_header(self):
|
|
"""Test extracting token from Bearer Authorization header"""
|
|
|
|
app = Flask(__name__)
|
|
|
|
with app.test_request_context(
|
|
headers={"Authorization": "Bearer tt_testtoken123"}
|
|
):
|
|
token = extract_token_from_request()
|
|
assert token == "tt_testtoken123"
|
|
|
|
def test_extract_from_token_header(self):
|
|
"""Test extracting token from Token Authorization header"""
|
|
|
|
app = Flask(__name__)
|
|
|
|
with app.test_request_context(
|
|
headers={"Authorization": "Token tt_testtoken123"}
|
|
):
|
|
token = extract_token_from_request()
|
|
assert token == "tt_testtoken123"
|
|
|
|
def test_extract_from_api_key_header(self):
|
|
"""Test extracting token from X-API-Key header"""
|
|
|
|
app = Flask(__name__)
|
|
|
|
with app.test_request_context(headers={"X-API-Key": "tt_testtoken123"}):
|
|
token = extract_token_from_request()
|
|
assert token == "tt_testtoken123"
|
|
|
|
def test_extract_none_when_missing(self):
|
|
"""Test that None is returned when no token is present"""
|
|
|
|
app = Flask(__name__)
|
|
|
|
with app.test_request_context():
|
|
token = extract_token_from_request()
|
|
assert token is None
|
|
|
|
|
|
class TestAuthenticateToken:
|
|
"""Tests for token authentication with enhanced security"""
|
|
|
|
@pytest.fixture
|
|
def sample_user(self, app):
|
|
"""Create a sample user for testing"""
|
|
user = User(username="testuser")
|
|
user.is_active = True
|
|
db.session.add(user)
|
|
db.session.commit()
|
|
return user
|
|
|
|
@pytest.fixture
|
|
def sample_token(self, app, sample_user):
|
|
"""Create a sample API token"""
|
|
token, plain_token = ApiToken.create_token(
|
|
user_id=sample_user.id,
|
|
name="Test Token",
|
|
scopes="read:projects",
|
|
expires_days=30,
|
|
)
|
|
db.session.add(token)
|
|
db.session.commit()
|
|
return token, plain_token
|
|
|
|
def test_authenticate_valid_token(self, app, sample_user, sample_token):
|
|
"""Test authentication with valid token"""
|
|
token, plain_token = sample_token
|
|
|
|
with app.test_request_context(environ_overrides={"REMOTE_ADDR": "127.0.0.1"}):
|
|
user, api_token, error = authenticate_token(plain_token)
|
|
|
|
assert user is not None
|
|
assert api_token is not None
|
|
assert error is None
|
|
assert user.id == sample_user.id
|
|
assert api_token.id == token.id
|
|
|
|
def test_authenticate_expired_token(self, app, sample_user):
|
|
"""Test authentication with expired token"""
|
|
token, plain_token = ApiToken.create_token(
|
|
user_id=sample_user.id,
|
|
name="Expired Token",
|
|
expires_days=-1, # Expired
|
|
)
|
|
token.expires_at = datetime.utcnow() - timedelta(days=1)
|
|
db.session.add(token)
|
|
db.session.commit()
|
|
|
|
with app.test_request_context(environ_overrides={"REMOTE_ADDR": "127.0.0.1"}):
|
|
user, api_token, error = authenticate_token(plain_token)
|
|
|
|
assert user is None
|
|
assert api_token is None
|
|
assert error == "Token has expired"
|
|
|
|
def test_authenticate_revoked_token(self, app, sample_user, sample_token):
|
|
"""Test authentication with revoked token"""
|
|
token, plain_token = sample_token
|
|
token.is_active = False
|
|
db.session.commit()
|
|
|
|
with app.test_request_context(environ_overrides={"REMOTE_ADDR": "127.0.0.1"}):
|
|
user, api_token, error = authenticate_token(plain_token)
|
|
|
|
assert user is None
|
|
assert api_token is None
|
|
assert error == "Token has been revoked"
|
|
|
|
def test_authenticate_with_ip_whitelist_allowed(self, app, sample_user):
|
|
"""Test authentication with IP whitelist - allowed IP"""
|
|
token, plain_token = ApiToken.create_token(
|
|
user_id=sample_user.id, name="Whitelisted Token", scopes="read:projects"
|
|
)
|
|
token.ip_whitelist = "127.0.0.1,192.168.1.0/24"
|
|
db.session.add(token)
|
|
db.session.commit()
|
|
|
|
with app.test_request_context(environ_overrides={"REMOTE_ADDR": "127.0.0.1"}):
|
|
user, api_token, error = authenticate_token(plain_token)
|
|
|
|
assert user is not None
|
|
assert api_token is not None
|
|
assert error is None
|
|
|
|
def test_authenticate_with_ip_whitelist_denied(self, app, sample_user):
|
|
"""Test authentication with IP whitelist - denied IP"""
|
|
token, plain_token = ApiToken.create_token(
|
|
user_id=sample_user.id, name="Whitelisted Token", scopes="read:projects"
|
|
)
|
|
token.ip_whitelist = "192.168.1.0/24"
|
|
db.session.add(token)
|
|
db.session.commit()
|
|
|
|
with app.test_request_context(environ_overrides={"REMOTE_ADDR": "10.0.0.1"}):
|
|
user, api_token, error = authenticate_token(plain_token)
|
|
|
|
assert user is None
|
|
assert api_token is None
|
|
assert error == "Access denied from this IP address"
|
|
|
|
def test_authenticate_with_cidr_block(self, app, sample_user):
|
|
"""Test authentication with CIDR block in whitelist"""
|
|
token, plain_token = ApiToken.create_token(
|
|
user_id=sample_user.id, name="CIDR Token", scopes="read:projects"
|
|
)
|
|
token.ip_whitelist = "192.168.1.0/24"
|
|
db.session.add(token)
|
|
db.session.commit()
|
|
|
|
with app.test_request_context(environ_overrides={"REMOTE_ADDR": "192.168.1.100"}):
|
|
user, api_token, error = authenticate_token(plain_token)
|
|
|
|
assert user is not None
|
|
assert api_token is not None
|
|
assert error is None
|
|
|
|
def test_authenticate_invalid_token_format(self, app):
|
|
"""Test authentication with invalid token format"""
|
|
with app.test_request_context(environ_overrides={"REMOTE_ADDR": "127.0.0.1"}):
|
|
user, api_token, error = authenticate_token("invalid_token")
|
|
|
|
assert user is None
|
|
assert api_token is None
|
|
assert error == "Invalid token format"
|
|
|
|
def test_authenticate_nonexistent_token(self, app):
|
|
"""Test authentication with non-existent token"""
|
|
fake_token = "tt_" + "x" * 32
|
|
|
|
with app.test_request_context(environ_overrides={"REMOTE_ADDR": "127.0.0.1"}):
|
|
user, api_token, error = authenticate_token(fake_token)
|
|
|
|
assert user is None
|
|
assert api_token is None
|
|
assert error == "Token not found"
|
|
|
|
def test_authenticate_inactive_user(self, app, sample_token):
|
|
"""Test authentication with inactive user"""
|
|
token, plain_token = sample_token
|
|
token.user.is_active = False
|
|
db.session.commit()
|
|
|
|
with app.test_request_context(environ_overrides={"REMOTE_ADDR": "127.0.0.1"}):
|
|
user, api_token, error = authenticate_token(plain_token)
|
|
|
|
assert user is None
|
|
assert api_token is None
|
|
assert error == "User account is inactive"
|
|
|
|
|
|
class TestRequireApiToken:
|
|
"""Tests for require_api_token decorator"""
|
|
|
|
@pytest.fixture
|
|
def app_with_routes(self, app):
|
|
"""Create Flask app with test routes"""
|
|
|
|
@app.route("/test/protected")
|
|
@require_api_token("read:projects")
|
|
def protected_route():
|
|
return {"message": "success", "user_id": g.api_user.id}
|
|
|
|
@app.route("/test/protected_no_scope")
|
|
@require_api_token()
|
|
def protected_route_no_scope():
|
|
return {"message": "success"}
|
|
|
|
return app
|
|
|
|
def test_protected_route_with_valid_token(
|
|
self, app_with_routes, sample_user, sample_token
|
|
):
|
|
"""Test accessing protected route with valid token"""
|
|
token, plain_token = sample_token
|
|
|
|
with app_with_routes.test_client() as client:
|
|
response = client.get(
|
|
"/test/protected", headers={"Authorization": f"Bearer {plain_token}"}
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.get_json()
|
|
assert data["message"] == "success"
|
|
assert data["user_id"] == sample_user.id
|
|
|
|
def test_protected_route_without_token(self, app_with_routes):
|
|
"""Test accessing protected route without token"""
|
|
with app_with_routes.test_client() as client:
|
|
response = client.get("/test/protected")
|
|
|
|
assert response.status_code == 401
|
|
data = response.get_json()
|
|
assert "error" in data
|
|
assert "Authentication required" in data["error"]
|
|
|
|
def test_protected_route_with_insufficient_scope(
|
|
self, app_with_routes, sample_user
|
|
):
|
|
"""Test accessing protected route with insufficient scope"""
|
|
token, plain_token = ApiToken.create_token(
|
|
user_id=sample_user.id,
|
|
name="Limited Token",
|
|
scopes="read:time_entries", # Different scope
|
|
)
|
|
db.session.add(token)
|
|
db.session.commit()
|
|
|
|
with app_with_routes.test_client() as client:
|
|
response = client.get(
|
|
"/test/protected", headers={"Authorization": f"Bearer {plain_token}"}
|
|
)
|
|
|
|
assert response.status_code == 403
|
|
data = response.get_json()
|
|
assert "error" in data
|
|
assert "Insufficient permissions" in data["error"]
|
|
|
|
def test_protected_route_with_wildcard_scope(self, app_with_routes, sample_user):
|
|
"""Test accessing protected route with wildcard scope"""
|
|
token, plain_token = ApiToken.create_token(
|
|
user_id=sample_user.id,
|
|
name="Admin Token",
|
|
scopes="read:*", # Wildcard scope
|
|
)
|
|
db.session.add(token)
|
|
db.session.commit()
|
|
|
|
with app_with_routes.test_client() as client:
|
|
response = client.get(
|
|
"/test/protected", headers={"Authorization": f"Bearer {plain_token}"}
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.get_json()
|
|
assert data["message"] == "success"
|