Files
wehub-resource-sync 7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

1543 lines
57 KiB
Python

"""
Tests for web/auth/routes.py
Tests cover:
- Login, register, and logout routes
- CSRF token endpoint
- Check auth endpoint
- Change password endpoint
- Integrity check endpoint
- Open redirect prevention
"""
from pathlib import Path
from unittest.mock import MagicMock, patch
from flask import Flask
from local_deep_research.security.password_validator import PasswordValidator
class TestGetCsrfToken:
"""Tests for /csrf-token endpoint."""
def test_returns_csrf_token(self):
"""Should return CSRF token."""
app = Flask(__name__)
app.secret_key = "test"
app.config["WTF_CSRF_ENABLED"] = True
with patch("flask_wtf.csrf.generate_csrf") as mock_csrf:
mock_csrf.return_value = "test_csrf_token_123"
from local_deep_research.web.auth.routes import auth_bp
app.register_blueprint(auth_bp)
with app.test_client() as client:
response = client.get("/auth/csrf-token")
assert response.status_code == 200
assert response.json["csrf_token"] == "test_csrf_token_123"
class TestLoginPage:
"""Tests for GET /login endpoint."""
def test_renders_login_page(self):
"""Should render login page for unauthenticated users."""
app = Flask(__name__)
app.secret_key = "test"
app.config["WTF_CSRF_ENABLED"] = False
app.template_folder = "templates" # May need adjustment
with (
patch(
"local_deep_research.web.auth.routes.load_server_config"
) as mock_config,
patch(
"local_deep_research.web.auth.routes.render_template"
) as mock_render,
):
mock_config.return_value = {"allow_registrations": True}
mock_render.return_value = "Login Page"
from local_deep_research.web.auth.routes import auth_bp
app.register_blueprint(auth_bp)
with app.test_client() as client:
client.get("/auth/login")
# Should call render_template
mock_render.assert_called()
def test_redirects_if_already_logged_in(self):
"""Should redirect to index if user already logged in."""
app = Flask(__name__)
app.secret_key = "test"
app.config["WTF_CSRF_ENABLED"] = False
@app.route("/")
def index():
return "Index"
with patch(
"local_deep_research.web.auth.routes.load_server_config"
) as mock_config:
mock_config.return_value = {"allow_registrations": True}
from local_deep_research.web.auth.routes import auth_bp
app.register_blueprint(auth_bp)
with app.test_client() as client:
with client.session_transaction() as sess:
sess["username"] = "testuser"
response = client.get("/auth/login")
assert response.status_code == 302
class TestLogin:
"""Tests for POST /login endpoint."""
def test_returns_400_without_username(self):
"""Should return 400 when username is missing."""
app = Flask(__name__)
app.secret_key = "test"
app.config["WTF_CSRF_ENABLED"] = False
with (
patch(
"local_deep_research.web.auth.routes.load_server_config"
) as mock_config,
patch(
"local_deep_research.web.auth.routes.render_template"
) as mock_render,
):
mock_config.return_value = {"allow_registrations": True}
mock_render.return_value = "Login Page"
from local_deep_research.web.auth.routes import auth_bp
app.register_blueprint(auth_bp)
with app.test_client() as client:
response = client.post(
"/auth/login",
data={"username": "", "password": "password123"},
)
assert response.status_code == 400
def test_returns_400_without_password(self):
"""Should return 400 when password is missing."""
app = Flask(__name__)
app.secret_key = "test"
app.config["WTF_CSRF_ENABLED"] = False
with (
patch(
"local_deep_research.web.auth.routes.load_server_config"
) as mock_config,
patch(
"local_deep_research.web.auth.routes.render_template"
) as mock_render,
):
mock_config.return_value = {"allow_registrations": True}
mock_render.return_value = "Login Page"
from local_deep_research.web.auth.routes import auth_bp
app.register_blueprint(auth_bp)
with app.test_client() as client:
response = client.post(
"/auth/login",
data={"username": "testuser", "password": ""},
)
assert response.status_code == 400
def test_returns_401_for_invalid_credentials(self):
"""Should return 401 for invalid credentials."""
app = Flask(__name__)
app.secret_key = "test"
app.config["WTF_CSRF_ENABLED"] = False
with (
patch(
"local_deep_research.web.auth.routes.load_server_config"
) as mock_config,
patch(
"local_deep_research.web.auth.routes.db_manager"
) as mock_db_manager,
patch(
"local_deep_research.web.auth.routes.render_template"
) as mock_render,
):
mock_config.return_value = {"allow_registrations": True}
mock_db_manager.open_user_database.return_value = None
mock_render.return_value = "Login Page"
from local_deep_research.web.auth.routes import auth_bp
app.register_blueprint(auth_bp)
with app.test_client() as client:
response = client.post(
"/auth/login",
data={"username": "testuser", "password": "wrongpassword"},
)
assert response.status_code == 401
def test_returns_503_for_database_init_failure_without_lockout(self):
"""A DatabaseInitializationError should yield 503 and bypass lockout.
Regression for the migration-failure path: the user's password is
valid (we got far enough to attempt schema init), so penalising
them with a lockout for a server-side configuration problem
(e.g. world-writable migrations dir) would be wrong.
"""
from local_deep_research.database.encrypted_db import (
DatabaseInitializationError,
)
app = Flask(__name__)
app.secret_key = "test"
app.config["WTF_CSRF_ENABLED"] = False
with (
patch(
"local_deep_research.web.auth.routes.load_server_config"
) as mock_config,
patch(
"local_deep_research.web.auth.routes.db_manager"
) as mock_db_manager,
patch(
"local_deep_research.web.auth.routes.render_template"
) as mock_render,
patch(
"local_deep_research.web.auth.routes.get_account_lockout_manager"
) as mock_lockout_factory,
):
mock_config.return_value = {"allow_registrations": True}
mock_db_manager.open_user_database.side_effect = (
DatabaseInitializationError("boom")
)
mock_render.return_value = "Login Page"
mock_lockout = mock_lockout_factory.return_value
mock_lockout.is_locked.return_value = False
from local_deep_research.web.auth.routes import auth_bp
app.register_blueprint(auth_bp)
with app.test_client() as client:
response = client.post(
"/auth/login",
data={
"username": "validuser",
"password": "correctpassword",
},
)
assert response.status_code == 503
# Critical: do not punish the user for a server-side bug.
mock_lockout.record_failure.assert_not_called()
class TestRegisterPage:
"""Tests for GET /register endpoint."""
def test_redirects_when_registrations_disabled(self):
"""Should redirect to login when registrations are disabled."""
app = Flask(__name__)
app.secret_key = "test"
app.config["WTF_CSRF_ENABLED"] = False
with patch(
"local_deep_research.web.auth.routes.load_server_config"
) as mock_config:
mock_config.return_value = {"allow_registrations": False}
from local_deep_research.web.auth.routes import auth_bp
app.register_blueprint(auth_bp)
with app.test_client() as client:
response = client.get("/auth/register")
assert response.status_code == 302
assert "login" in response.location
class TestRegister:
"""Tests for POST /register endpoint."""
def test_returns_400_for_short_username(self):
"""Should return 400 when username is too short."""
app = Flask(__name__)
app.secret_key = "test"
app.config["WTF_CSRF_ENABLED"] = False
with (
patch(
"local_deep_research.web.auth.routes.load_server_config"
) as mock_config,
patch(
"local_deep_research.web.auth.routes.render_template"
) as mock_render,
):
mock_config.return_value = {"allow_registrations": True}
mock_render.return_value = "Register Page"
from local_deep_research.web.auth.routes import auth_bp
app.register_blueprint(auth_bp)
with app.test_client() as client:
response = client.post(
"/auth/register",
data={
"username": "ab", # Too short
"password": "password123",
"confirm_password": "password123",
"acknowledge": "true",
},
)
assert response.status_code == 400
def test_returns_400_for_invalid_username_chars(self):
"""Should return 400 when username contains invalid characters."""
app = Flask(__name__)
app.secret_key = "test"
app.config["WTF_CSRF_ENABLED"] = False
with (
patch(
"local_deep_research.web.auth.routes.load_server_config"
) as mock_config,
patch(
"local_deep_research.web.auth.routes.render_template"
) as mock_render,
):
mock_config.return_value = {"allow_registrations": True}
mock_render.return_value = "Register Page"
from local_deep_research.web.auth.routes import auth_bp
app.register_blueprint(auth_bp)
with app.test_client() as client:
response = client.post(
"/auth/register",
data={
"username": "test@user!", # Invalid chars
"password": "password123",
"confirm_password": "password123",
"acknowledge": "true",
},
)
assert response.status_code == 400
def test_returns_400_for_short_password(self):
"""Should return 400 when password is too short."""
app = Flask(__name__)
app.secret_key = "test"
app.config["WTF_CSRF_ENABLED"] = False
with (
patch(
"local_deep_research.web.auth.routes.load_server_config"
) as mock_config,
patch(
"local_deep_research.web.auth.routes.render_template"
) as mock_render,
):
mock_config.return_value = {"allow_registrations": True}
mock_render.return_value = "Register Page"
from local_deep_research.web.auth.routes import auth_bp
app.register_blueprint(auth_bp)
with app.test_client() as client:
response = client.post(
"/auth/register",
data={
"username": "testuser",
"password": "short", # Too short
"confirm_password": "short",
"acknowledge": "true",
},
)
assert response.status_code == 400
def test_returns_400_for_password_mismatch(self):
"""Should return 400 when passwords don't match."""
app = Flask(__name__)
app.secret_key = "test"
app.config["WTF_CSRF_ENABLED"] = False
with (
patch(
"local_deep_research.web.auth.routes.load_server_config"
) as mock_config,
patch(
"local_deep_research.web.auth.routes.render_template"
) as mock_render,
):
mock_config.return_value = {"allow_registrations": True}
mock_render.return_value = "Register Page"
from local_deep_research.web.auth.routes import auth_bp
app.register_blueprint(auth_bp)
with app.test_client() as client:
response = client.post(
"/auth/register",
data={
"username": "testuser",
"password": "password123",
"confirm_password": "different123",
"acknowledge": "true",
},
)
assert response.status_code == 400
def test_returns_400_without_acknowledgment(self):
"""Should return 400 when acknowledgment not provided."""
app = Flask(__name__)
app.secret_key = "test"
app.config["WTF_CSRF_ENABLED"] = False
with (
patch(
"local_deep_research.web.auth.routes.load_server_config"
) as mock_config,
patch(
"local_deep_research.web.auth.routes.render_template"
) as mock_render,
):
mock_config.return_value = {"allow_registrations": True}
mock_render.return_value = "Register Page"
from local_deep_research.web.auth.routes import auth_bp
app.register_blueprint(auth_bp)
with app.test_client() as client:
response = client.post(
"/auth/register",
data={
"username": "testuser",
"password": "password123",
"confirm_password": "password123",
# No acknowledge
},
)
assert response.status_code == 400
class TestLogout:
"""Tests for /logout endpoint."""
def test_clears_session_on_logout(self):
"""Should clear session on logout."""
app = Flask(__name__)
app.secret_key = "test"
app.config["WTF_CSRF_ENABLED"] = False
with (
patch("local_deep_research.web.auth.routes.db_manager"),
patch("local_deep_research.web.auth.routes.session_manager"),
patch(
"local_deep_research.database.session_passwords.session_password_store"
),
):
from local_deep_research.web.auth.routes import auth_bp
app.register_blueprint(auth_bp)
with app.test_client() as client:
with client.session_transaction() as sess:
sess["username"] = "testuser"
sess["session_id"] = "session_123"
response = client.post("/auth/logout")
assert response.status_code == 302
with client.session_transaction() as sess:
assert "username" not in sess
def test_redirects_to_login(self):
"""Should redirect to login after logout."""
app = Flask(__name__)
app.secret_key = "test"
app.config["WTF_CSRF_ENABLED"] = False
with (
patch("local_deep_research.web.auth.routes.db_manager"),
patch("local_deep_research.web.auth.routes.session_manager"),
patch(
"local_deep_research.database.session_passwords.session_password_store"
),
):
from local_deep_research.web.auth.routes import auth_bp
app.register_blueprint(auth_bp)
with app.test_client() as client:
response = client.post("/auth/logout")
assert response.status_code == 302
assert "login" in response.location
def test_unregisters_user_from_scheduler_on_logout(self):
"""Should call scheduler.unregister_user() during logout."""
app = Flask(__name__)
app.secret_key = "test"
app.config["WTF_CSRF_ENABLED"] = False
mock_scheduler = MagicMock()
mock_scheduler.is_running = True
with (
patch("local_deep_research.web.auth.routes.db_manager"),
patch("local_deep_research.web.auth.routes.session_manager"),
patch(
"local_deep_research.database.session_passwords.session_password_store"
),
patch(
"local_deep_research.scheduler.background.get_background_job_scheduler",
return_value=mock_scheduler,
),
):
from local_deep_research.web.auth.routes import auth_bp
app.register_blueprint(auth_bp)
with app.test_client() as client:
with client.session_transaction() as sess:
sess["username"] = "testuser"
sess["session_id"] = "session_123"
client.post("/auth/logout")
mock_scheduler.unregister_user.assert_called_once_with(
"testuser"
)
class TestCheckAuth:
"""Tests for /check endpoint."""
def test_returns_authenticated_true_when_logged_in(self):
"""Should return authenticated=True when logged in."""
app = Flask(__name__)
app.secret_key = "test"
app.config["WTF_CSRF_ENABLED"] = False
from local_deep_research.web.auth.routes import auth_bp
app.register_blueprint(auth_bp)
with app.test_client() as client:
with client.session_transaction() as sess:
sess["username"] = "testuser"
response = client.get("/auth/check")
assert response.status_code == 200
assert response.json["authenticated"] is True
assert response.json["username"] == "testuser"
def test_returns_authenticated_false_when_not_logged_in(self):
"""Should return authenticated=False when not logged in."""
app = Flask(__name__)
app.secret_key = "test"
app.config["WTF_CSRF_ENABLED"] = False
from local_deep_research.web.auth.routes import auth_bp
app.register_blueprint(auth_bp)
with app.test_client() as client:
response = client.get("/auth/check")
assert response.status_code == 401
assert response.json["authenticated"] is False
class TestChangePassword:
"""Tests for /change-password endpoint."""
def test_redirects_when_not_logged_in(self):
"""Should redirect to login when not authenticated."""
app = Flask(__name__)
app.secret_key = "test"
app.config["WTF_CSRF_ENABLED"] = False
from local_deep_research.web.auth.routes import auth_bp
app.register_blueprint(auth_bp)
with app.test_client() as client:
response = client.get("/auth/change-password")
assert response.status_code == 302
assert "login" in response.location
def test_returns_400_without_current_password(self):
"""Should return 400 when current password is missing."""
app = Flask(__name__)
app.secret_key = "test"
app.config["WTF_CSRF_ENABLED"] = False
with patch(
"local_deep_research.web.auth.routes.render_template"
) as mock_render:
mock_render.return_value = "Change Password Page"
from local_deep_research.web.auth.routes import auth_bp
app.register_blueprint(auth_bp)
with app.test_client() as client:
with client.session_transaction() as sess:
sess["username"] = "testuser"
response = client.post(
"/auth/change-password",
data={
"current_password": "",
"new_password": "newpassword123",
"confirm_password": "newpassword123",
},
)
assert response.status_code == 400
def test_returns_400_when_passwords_match(self):
"""Should return 400 when new password is same as current."""
app = Flask(__name__)
app.secret_key = "test"
app.config["WTF_CSRF_ENABLED"] = False
with patch(
"local_deep_research.web.auth.routes.render_template"
) as mock_render:
mock_render.return_value = "Change Password Page"
from local_deep_research.web.auth.routes import auth_bp
app.register_blueprint(auth_bp)
with app.test_client() as client:
with client.session_transaction() as sess:
sess["username"] = "testuser"
response = client.post(
"/auth/change-password",
data={
"current_password": "samepassword123",
"new_password": "samepassword123",
"confirm_password": "samepassword123",
},
)
assert response.status_code == 400
def test_successful_password_change(self):
"""Should destroy ALL sessions and redirect to login on success."""
app = Flask(__name__)
app.secret_key = "test"
app.config["WTF_CSRF_ENABLED"] = False
with (
patch("local_deep_research.web.auth.routes.db_manager") as mock_db,
patch(
"local_deep_research.web.auth.routes.render_template"
) as mock_render,
patch(
"local_deep_research.web.auth.routes.session_manager"
) as mock_sm,
patch(
"local_deep_research.database.session_passwords.session_password_store"
) as mock_pw_store,
):
mock_db.change_password.return_value = True
mock_render.return_value = "Change Password Page"
mock_sm.destroy_all_user_sessions.return_value = 2
from local_deep_research.web.auth.routes import auth_bp
app.register_blueprint(auth_bp)
with app.test_client() as client:
with client.session_transaction() as sess:
sess["username"] = "testuser"
response = client.post(
"/auth/change-password",
data={
"current_password": "OldPass123",
"new_password": "NewStrongP4ss!",
"confirm_password": "NewStrongP4ss!",
},
follow_redirects=False,
)
assert response.status_code == 302
assert "login" in response.location
with client.session_transaction() as sess:
assert "username" not in sess
mock_db.change_password.assert_called_once_with(
"testuser", "OldPass123", "NewStrongP4ss!"
)
mock_db.close_user_database.assert_called_once_with("testuser")
# All sessions for this user should be destroyed
mock_sm.destroy_all_user_sessions.assert_called_once_with(
"testuser"
)
# All stored passwords should be cleared
mock_pw_store.clear_all_for_user.assert_called_once_with(
"testuser"
)
def test_returns_401_for_wrong_current_password(self):
"""Should return 401 when current password is incorrect."""
app = Flask(__name__)
app.secret_key = "test"
app.config["WTF_CSRF_ENABLED"] = False
with (
patch("local_deep_research.web.auth.routes.db_manager") as mock_db,
patch(
"local_deep_research.web.auth.routes.render_template"
) as mock_render,
):
mock_db.change_password.return_value = False
mock_render.return_value = "Change Password Page"
from local_deep_research.web.auth.routes import auth_bp
app.register_blueprint(auth_bp)
with app.test_client() as client:
with client.session_transaction() as sess:
sess["username"] = "testuser"
response = client.post(
"/auth/change-password",
data={
"current_password": "WrongPass123",
"new_password": "NewStrongP4ss!",
"confirm_password": "NewStrongP4ss!",
},
)
assert response.status_code == 401
mock_render.assert_called_with(
"auth/change_password.html",
password_requirements=PasswordValidator.get_requirements(),
)
def test_renders_page_when_authenticated(self):
"""Should render change password page for authenticated user."""
app = Flask(__name__)
app.secret_key = "test"
app.config["WTF_CSRF_ENABLED"] = False
with patch(
"local_deep_research.web.auth.routes.render_template"
) as mock_render:
mock_render.return_value = "Change Password Page"
from local_deep_research.web.auth.routes import auth_bp
app.register_blueprint(auth_bp)
with app.test_client() as client:
with client.session_transaction() as sess:
sess["username"] = "testuser"
response = client.get("/auth/change-password")
assert response.status_code == 200
mock_render.assert_called_with(
"auth/change_password.html",
password_requirements=PasswordValidator.get_requirements(),
)
def test_renders_template_without_missing_dashboard_route(self):
"""Should render the real template using the root dashboard route."""
template_dir = (
Path(__file__).resolve().parents[3]
/ "src"
/ "local_deep_research"
/ "web"
/ "templates"
)
app = Flask(__name__, template_folder=str(template_dir))
app.secret_key = "test"
app.config["WTF_CSRF_ENABLED"] = False
app.jinja_env.globals["vite_hmr"] = lambda: ""
app.jinja_env.globals["vite_asset"] = lambda *_args, **_kwargs: ""
app.jinja_env.globals["csrf_token"] = lambda: "test-csrf-token"
@app.route("/")
def index():
return "Index"
from local_deep_research.web.auth.routes import auth_bp
app.register_blueprint(auth_bp)
with app.test_client() as client:
with client.session_transaction() as sess:
sess["username"] = "testuser"
response = client.get("/auth/change-password")
assert response.status_code == 200
assert b'href="/"' in response.data
def test_returns_400_for_weak_new_password(self):
"""Should return 400 when new password is too weak."""
app = Flask(__name__)
app.secret_key = "test"
app.config["WTF_CSRF_ENABLED"] = False
with patch(
"local_deep_research.web.auth.routes.render_template"
) as mock_render:
mock_render.return_value = "Change Password Page"
from local_deep_research.web.auth.routes import auth_bp
app.register_blueprint(auth_bp)
with app.test_client() as client:
with client.session_transaction() as sess:
sess["username"] = "testuser"
response = client.post(
"/auth/change-password",
data={
"current_password": "OldPass123",
"new_password": "Pass1",
"confirm_password": "Pass1",
},
)
assert response.status_code == 400
def test_returns_400_for_mismatched_new_passwords(self):
"""Should return 400 when new passwords don't match."""
app = Flask(__name__)
app.secret_key = "test"
app.config["WTF_CSRF_ENABLED"] = False
with patch(
"local_deep_research.web.auth.routes.render_template"
) as mock_render:
mock_render.return_value = "Change Password Page"
from local_deep_research.web.auth.routes import auth_bp
app.register_blueprint(auth_bp)
with app.test_client() as client:
with client.session_transaction() as sess:
sess["username"] = "testuser"
response = client.post(
"/auth/change-password",
data={
"current_password": "OldPass123",
"new_password": "NewStrongP4ss!",
"confirm_password": "DifferentP4ss!",
},
)
assert response.status_code == 400
class TestIntegrityCheck:
"""Tests for /integrity-check endpoint."""
def test_returns_401_when_not_authenticated(self):
"""Should return 401 when not authenticated."""
app = Flask(__name__)
app.secret_key = "test"
app.config["WTF_CSRF_ENABLED"] = False
from local_deep_research.web.auth.routes import auth_bp
app.register_blueprint(auth_bp)
with app.test_client() as client:
response = client.get("/auth/integrity-check")
assert response.status_code == 401
def test_returns_integrity_status(self):
"""Should return integrity status for authenticated user."""
app = Flask(__name__)
app.secret_key = "test"
app.config["WTF_CSRF_ENABLED"] = False
with patch(
"local_deep_research.web.auth.routes.db_manager"
) as mock_db_manager:
mock_db_manager.check_database_integrity.return_value = True
from local_deep_research.web.auth.routes import auth_bp
app.register_blueprint(auth_bp)
with app.test_client() as client:
with client.session_transaction() as sess:
sess["username"] = "testuser"
response = client.get("/auth/integrity-check")
assert response.status_code == 200
assert response.json["username"] == "testuser"
assert response.json["integrity"] == "valid"
class TestOpenRedirectPrevention:
"""Tests for open redirect prevention in login."""
def test_blocks_external_redirect(self):
"""Should block redirect to external domain."""
app = Flask(__name__)
app.secret_key = "test"
app.config["WTF_CSRF_ENABLED"] = False
@app.route("/")
def index():
return "Index"
mock_engine = MagicMock()
mock_session = MagicMock()
mock_settings_manager = MagicMock()
mock_settings_manager.db_version_matches_package.return_value = True
with (
patch(
"local_deep_research.web.auth.routes.load_server_config"
) as mock_config,
patch(
"local_deep_research.web.auth.routes.db_manager"
) as mock_db_manager,
patch(
"local_deep_research.web.auth.routes.session_manager"
) as mock_session_manager,
patch(
"local_deep_research.web.auth.routes.auth_db_session"
) as mock_auth_db,
patch(
"local_deep_research.database.temp_auth.temp_auth_store"
) as mock_temp_auth,
patch(
"local_deep_research.database.session_passwords.session_password_store"
),
patch(
"local_deep_research.settings.manager.SettingsManager"
) as mock_settings_cls,
patch(
"local_deep_research.database.library_init.initialize_library_for_user"
),
patch(
"local_deep_research.scheduler.background.get_background_job_scheduler"
),
patch("local_deep_research.database.models.ProviderModel"),
):
mock_config.return_value = {"allow_registrations": True}
mock_db_manager.open_user_database.return_value = mock_engine
mock_db_manager.get_session.return_value = mock_session
mock_session_manager.create_session.return_value = "session_123"
# auth_db_session is only called in a background daemon thread
# (_perform_post_login_tasks), not in the synchronous login handler.
# MagicMock auto-supports context manager protocol, which is sufficient
# here since we only test HTTP redirect behavior, not DB interactions.
mock_auth_db.return_value = MagicMock()
mock_settings_cls.return_value = mock_settings_manager
mock_temp_auth.store_auth.return_value = "test_auth_token"
from local_deep_research.web.auth.routes import auth_bp
app.register_blueprint(auth_bp)
with app.test_client() as client:
response = client.post(
"/auth/login?next=https://evil.com/steal",
data={"username": "testuser", "password": "password123"},
)
# Should redirect to safe URL, not evil.com
assert response.status_code == 302
assert "evil.com" not in response.location
def test_allows_safe_relative_redirect(self):
"""Should allow safe relative redirects."""
app = Flask(__name__)
app.secret_key = "test"
app.config["WTF_CSRF_ENABLED"] = False
@app.route("/")
def index():
return "Index"
@app.route("/dashboard")
def dashboard():
return "Dashboard"
mock_engine = MagicMock()
mock_session = MagicMock()
mock_settings_manager = MagicMock()
mock_settings_manager.db_version_matches_package.return_value = True
with (
patch(
"local_deep_research.web.auth.routes.load_server_config"
) as mock_config,
patch(
"local_deep_research.web.auth.routes.db_manager"
) as mock_db_manager,
patch(
"local_deep_research.web.auth.routes.session_manager"
) as mock_session_manager,
patch(
"local_deep_research.web.auth.routes.auth_db_session"
) as mock_auth_db,
patch(
"local_deep_research.database.temp_auth.temp_auth_store"
) as mock_temp_auth,
patch(
"local_deep_research.database.session_passwords.session_password_store"
),
patch(
"local_deep_research.settings.manager.SettingsManager"
) as mock_settings_cls,
patch(
"local_deep_research.database.library_init.initialize_library_for_user"
),
patch(
"local_deep_research.scheduler.background.get_background_job_scheduler"
),
patch("local_deep_research.database.models.ProviderModel"),
):
mock_config.return_value = {"allow_registrations": True}
mock_db_manager.open_user_database.return_value = mock_engine
mock_db_manager.get_session.return_value = mock_session
mock_session_manager.create_session.return_value = "session_123"
# auth_db_session: background-thread-only, auto-CM sufficient (see first test)
mock_auth_db.return_value = MagicMock()
mock_settings_cls.return_value = mock_settings_manager
mock_temp_auth.store_auth.return_value = "test_auth_token"
from local_deep_research.web.auth.routes import auth_bp
app.register_blueprint(auth_bp)
with app.test_client() as client:
response = client.post(
"/auth/login?next=/dashboard",
data={"username": "testuser", "password": "password123"},
)
# Should redirect to dashboard
assert response.status_code == 302
assert "/dashboard" in response.location
def test_path_traversal_blocked(self):
"""Path traversal in next param is blocked by is_safe_redirect_url.
The validator detects '..' in the URL path component and rejects
it, so login falls back to the index page instead of redirecting
to the traversal target.
"""
app = Flask(__name__)
app.secret_key = "test"
app.config["WTF_CSRF_ENABLED"] = False
@app.route("/")
def index():
return "Index"
mock_engine = MagicMock()
mock_session = MagicMock()
mock_settings_manager = MagicMock()
mock_settings_manager.db_version_matches_package.return_value = True
with (
patch(
"local_deep_research.web.auth.routes.load_server_config"
) as mock_config,
patch(
"local_deep_research.web.auth.routes.db_manager"
) as mock_db_manager,
patch(
"local_deep_research.web.auth.routes.session_manager"
) as mock_session_manager,
patch(
"local_deep_research.web.auth.routes.auth_db_session"
) as mock_auth_db,
patch(
"local_deep_research.database.temp_auth.temp_auth_store"
) as mock_temp_auth,
patch(
"local_deep_research.database.session_passwords.session_password_store"
),
patch(
"local_deep_research.settings.manager.SettingsManager"
) as mock_settings_cls,
patch(
"local_deep_research.database.library_init.initialize_library_for_user"
),
patch(
"local_deep_research.scheduler.background.get_background_job_scheduler"
),
patch("local_deep_research.database.models.ProviderModel"),
):
mock_config.return_value = {"allow_registrations": True}
mock_db_manager.open_user_database.return_value = mock_engine
mock_db_manager.get_session.return_value = mock_session
mock_session_manager.create_session.return_value = "session_123"
# auth_db_session: background-thread-only, auto-CM sufficient (see first test)
mock_auth_db.return_value = MagicMock()
mock_settings_cls.return_value = mock_settings_manager
mock_temp_auth.store_auth.return_value = "test_auth_token"
from local_deep_research.web.auth.routes import auth_bp
app.register_blueprint(auth_bp)
with app.test_client() as client:
response = client.post(
"/auth/login?next=/../../../etc/passwd",
data={"username": "testuser", "password": "password123"},
)
# Path traversal is blocked by is_safe_redirect_url,
# so login succeeds but redirects to index (fallback)
assert response.status_code == 302
assert "etc/passwd" not in response.location
def test_encoded_path_traversal_blocked(self):
"""URL-encoded path traversal (%2e%2e) in next param is blocked."""
app = Flask(__name__)
app.secret_key = "test"
app.config["WTF_CSRF_ENABLED"] = False
@app.route("/")
def index():
return "Index"
mock_engine = MagicMock()
mock_session = MagicMock()
mock_settings_manager = MagicMock()
mock_settings_manager.db_version_matches_package.return_value = True
with (
patch(
"local_deep_research.web.auth.routes.load_server_config"
) as mock_config,
patch(
"local_deep_research.web.auth.routes.db_manager"
) as mock_db_manager,
patch(
"local_deep_research.web.auth.routes.session_manager"
) as mock_session_manager,
patch(
"local_deep_research.web.auth.routes.auth_db_session"
) as mock_auth_db,
patch(
"local_deep_research.database.temp_auth.temp_auth_store"
) as mock_temp_auth,
patch(
"local_deep_research.database.session_passwords.session_password_store"
),
patch(
"local_deep_research.settings.manager.SettingsManager"
) as mock_settings_cls,
patch(
"local_deep_research.database.library_init.initialize_library_for_user"
),
patch(
"local_deep_research.scheduler.background.get_background_job_scheduler"
),
patch("local_deep_research.database.models.ProviderModel"),
):
mock_config.return_value = {"allow_registrations": True}
mock_db_manager.open_user_database.return_value = mock_engine
mock_db_manager.get_session.return_value = mock_session
mock_session_manager.create_session.return_value = "session_123"
# auth_db_session: background-thread-only, auto-CM sufficient (see first test)
mock_auth_db.return_value = MagicMock()
mock_settings_cls.return_value = mock_settings_manager
mock_temp_auth.store_auth.return_value = "test_auth_token"
from local_deep_research.web.auth.routes import auth_bp
app.register_blueprint(auth_bp)
with app.test_client() as client:
response = client.post(
"/auth/login?next=%2e%2e/admin",
data={"username": "testuser", "password": "password123"},
)
# Encoded path traversal is blocked
assert response.status_code == 302
assert "admin" not in response.location
class TestRedirectPathExtraction:
"""Tests for defense-in-depth path extraction from redirect URLs.
After is_safe_redirect_url validates same-host, the code extracts only
the path+query+fragment via urljoin+urlparse. These tests verify that
absolute URLs are correctly converted to relative paths and edge cases
are handled safely.
"""
def _make_app_and_login(self, next_param):
"""Helper: create app, register auth blueprint, POST login with next param."""
app = Flask(__name__)
app.secret_key = "test"
app.config["WTF_CSRF_ENABLED"] = False
@app.route("/")
def index():
return "Index"
mock_engine = MagicMock()
mock_session = MagicMock()
mock_settings_manager = MagicMock()
mock_settings_manager.db_version_matches_package.return_value = True
with (
patch(
"local_deep_research.web.auth.routes.load_server_config"
) as mock_config,
patch(
"local_deep_research.web.auth.routes.db_manager"
) as mock_db_manager,
patch(
"local_deep_research.web.auth.routes.session_manager"
) as mock_session_manager,
patch(
"local_deep_research.web.auth.routes.auth_db_session"
) as mock_auth_db,
patch(
"local_deep_research.database.temp_auth.temp_auth_store"
) as mock_temp_auth,
patch(
"local_deep_research.database.session_passwords.session_password_store"
),
patch(
"local_deep_research.settings.manager.SettingsManager"
) as mock_settings_cls,
patch(
"local_deep_research.database.library_init.initialize_library_for_user"
),
patch(
"local_deep_research.scheduler.background.get_background_job_scheduler"
),
patch("local_deep_research.database.models.ProviderModel"),
):
mock_config.return_value = {"allow_registrations": True}
mock_db_manager.open_user_database.return_value = mock_engine
mock_db_manager.get_session.return_value = mock_session
mock_session_manager.create_session.return_value = "session_123"
# auth_db_session: background-thread-only, auto-CM sufficient (see first test)
mock_auth_db.return_value = MagicMock()
mock_settings_cls.return_value = mock_settings_manager
mock_temp_auth.store_auth.return_value = "test_auth_token"
from local_deep_research.web.auth.routes import auth_bp
app.register_blueprint(auth_bp)
with app.test_client() as client:
response = client.post(
f"/auth/login?next={next_param}",
data={"username": "testuser", "password": "password123"},
)
return response
def test_absolute_same_origin_url_extracts_path(self):
"""Absolute same-origin URL should redirect to path only."""
response = self._make_app_and_login("http://localhost/dashboard")
assert response.status_code == 302
assert response.location.endswith("/dashboard")
assert "localhost" not in response.location.split("/dashboard")[0]
def test_preserves_query_string(self):
"""Query string in next param should be preserved in redirect."""
# URL-encode the next value so &page=2 isn't split as a separate param
from urllib.parse import quote
response = self._make_app_and_login(
quote("/search?q=test&page=2", safe="")
)
assert response.status_code == 302
assert "/search?q=test&page=2" in response.location
def test_preserves_fragment(self):
"""Fragment in next param should be preserved in redirect."""
response = self._make_app_and_login("/dashboard%23section")
assert response.status_code == 302
assert "/dashboard" in response.location
def test_absolute_url_with_query_extracts_path_and_query(self):
"""Absolute URL with query should extract path+query only."""
from urllib.parse import quote
response = self._make_app_and_login(
quote("http://localhost/research?id=123&tab=sources", safe="")
)
assert response.status_code == 302
assert "/research?id=123&tab=sources" in response.location
# Must not contain the scheme/host
assert "http://localhost" not in response.location
def test_no_next_param_redirects_to_index(self):
"""Missing next param should redirect to index."""
app = Flask(__name__)
app.secret_key = "test"
app.config["WTF_CSRF_ENABLED"] = False
@app.route("/")
def index():
return "Index"
mock_engine = MagicMock()
mock_session = MagicMock()
mock_settings_manager = MagicMock()
mock_settings_manager.db_version_matches_package.return_value = True
with (
patch(
"local_deep_research.web.auth.routes.load_server_config"
) as mock_config,
patch(
"local_deep_research.web.auth.routes.db_manager"
) as mock_db_manager,
patch(
"local_deep_research.web.auth.routes.session_manager"
) as mock_session_manager,
patch(
"local_deep_research.web.auth.routes.auth_db_session"
) as mock_auth_db,
patch(
"local_deep_research.database.temp_auth.temp_auth_store"
) as mock_temp_auth,
patch(
"local_deep_research.database.session_passwords.session_password_store"
),
patch(
"local_deep_research.settings.manager.SettingsManager"
) as mock_settings_cls,
patch(
"local_deep_research.database.library_init.initialize_library_for_user"
),
patch(
"local_deep_research.scheduler.background.get_background_job_scheduler"
),
patch("local_deep_research.database.models.ProviderModel"),
):
mock_config.return_value = {"allow_registrations": True}
mock_db_manager.open_user_database.return_value = mock_engine
mock_db_manager.get_session.return_value = mock_session
mock_session_manager.create_session.return_value = "session_123"
# auth_db_session: background-thread-only, auto-CM sufficient (see first test)
mock_auth_db.return_value = MagicMock()
mock_settings_cls.return_value = mock_settings_manager
mock_temp_auth.store_auth.return_value = "test_auth_token"
from local_deep_research.web.auth.routes import auth_bp
app.register_blueprint(auth_bp)
with app.test_client() as client:
response = client.post(
"/auth/login",
data={"username": "testuser", "password": "password123"},
)
assert response.status_code == 302
assert response.location.endswith("/")
class TestPostLoginTasksPasswordForwarding:
"""Tests for _perform_post_login_tasks password forwarding."""
def test_post_login_tasks_forwards_password(self):
"""get_user_db_session must be called with both username AND password."""
with (
patch(
"local_deep_research.settings.manager.SettingsManager"
) as mock_settings_cls,
patch(
"local_deep_research.database.library_init.initialize_library_for_user"
) as mock_init_lib,
patch(
"local_deep_research.web.auth.routes.auth_db_session"
) as mock_auth_db,
patch(
"local_deep_research.scheduler.background.get_background_job_scheduler"
),
patch("local_deep_research.database.models.ProviderModel"),
patch(
"local_deep_research.database.session_context.get_user_db_session"
) as mock_get_session,
):
mock_settings_mgr = MagicMock()
mock_settings_mgr.db_version_matches_package.return_value = True
mock_settings_cls.return_value = mock_settings_mgr
mock_init_lib.return_value = {"success": True}
mock_auth_db.return_value.__enter__ = MagicMock(
return_value=MagicMock()
)
mock_auth_db.return_value.__exit__ = MagicMock(return_value=False)
# get_user_db_session is used as context manager
mock_session = MagicMock()
mock_get_session.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_get_session.return_value.__exit__ = MagicMock(
return_value=False
)
from local_deep_research.web.auth.routes import (
_perform_post_login_tasks,
)
# Call directly (synchronous — @thread_cleanup is transparent)
_perform_post_login_tasks.__wrapped__("testuser", "testpass")
# get_user_db_session should be called with (username, password)
# at least twice: once for settings migration, once for model cache
calls = mock_get_session.call_args_list
password_calls = [
c
for c in calls
if c.args == ("testuser", "testpass")
or c.kwargs.get("password") == "testpass"
]
assert len(password_calls) >= 2, (
f"Expected get_user_db_session called with password at least "
f"twice, got {len(password_calls)}: {calls}"
)
class TestRegistrationTimingAttackPrevention:
"""Tests for registration timing attack prevention.
These tests verify that the registration flow follows OWASP best practices:
- Generic error messages (prevent content-based enumeration)
- IntegrityError handling (catches race conditions with generic error + rollback)
"""
def test_generic_error_message_for_duplicate_username(self):
"""Verify duplicate username returns generic error message (OWASP requirement)."""
from unittest.mock import patch
app = Flask(__name__)
app.secret_key = "test"
app.config["WTF_CSRF_ENABLED"] = False
with (
patch(
"local_deep_research.web.auth.routes.load_server_config"
) as mock_config,
patch("local_deep_research.web.auth.routes.db_manager") as mock_db,
patch(
"local_deep_research.web.auth.routes.render_template"
) as mock_render,
patch("local_deep_research.web.auth.routes.flash") as mock_flash,
):
mock_config.return_value = {"allow_registrations": True}
mock_db.user_exists.return_value = True # User already exists
mock_db.has_encryption = True
mock_render.return_value = "Register Page"
from local_deep_research.web.auth.routes import auth_bp
app.register_blueprint(auth_bp)
with app.test_client() as client:
response = client.post(
"/auth/register",
data={
"username": "existinguser",
"password": "TestPassword123!",
"confirm_password": "TestPassword123!",
"acknowledge": "true",
},
)
assert response.status_code == 400
mock_flash.assert_any_call(
"Registration failed. Please try a different username.",
"error",
)
def test_integrity_error_returns_generic_message(self):
"""Verify IntegrityError (race condition) returns generic error (400 not 500)."""
from unittest.mock import MagicMock, patch
from sqlalchemy.exc import IntegrityError
app = Flask(__name__)
app.secret_key = "test"
app.config["WTF_CSRF_ENABLED"] = False
with (
patch(
"local_deep_research.web.auth.routes.load_server_config"
) as mock_config,
patch("local_deep_research.web.auth.routes.db_manager") as mock_db,
patch(
"local_deep_research.web.auth.routes.auth_db_session"
) as mock_auth_db,
patch(
"local_deep_research.web.auth.routes.render_template"
) as mock_render,
patch("local_deep_research.web.auth.routes.flash") as mock_flash,
):
mock_config.return_value = {"allow_registrations": True}
mock_db.user_exists.return_value = False # Passes pre-check
mock_db.has_encryption = True
mock_render.return_value = "Register Page"
# Simulate IntegrityError on commit
mock_session = MagicMock()
mock_session.add = MagicMock()
mock_session.commit = MagicMock(
side_effect=IntegrityError("statement", "params", "orig")
)
mock_session.rollback = MagicMock()
mock_session.close = MagicMock()
# auth_db_session() is used as a context manager
mock_auth_db.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_auth_db.return_value.__exit__ = MagicMock(return_value=False)
from local_deep_research.web.auth.routes import auth_bp
app.register_blueprint(auth_bp)
with app.test_client() as client:
response = client.post(
"/auth/register",
data={
"username": "racecondition_user",
"password": "TestPassword123!",
"confirm_password": "TestPassword123!",
"acknowledge": "true",
},
)
assert response.status_code == 400
mock_flash.assert_any_call(
"Registration failed. Please try a different username.",
"error",
)
mock_session.rollback.assert_called_once()
mock_session.add.assert_called_once()