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

871 lines
31 KiB
Python

"""
Test authentication routes including login, register, and logout.
"""
import os
import shutil
import tempfile
from pathlib import Path
import pytest
from local_deep_research.database.auth_db import (
dispose_auth_engine,
get_auth_db_session,
init_auth_database,
)
from local_deep_research.database.encrypted_db import db_manager
from local_deep_research.database.models.auth import User
from local_deep_research.web.app_factory import create_app
@pytest.fixture
def temp_data_dir():
"""Create a temporary data directory for testing."""
temp_dir = tempfile.mkdtemp()
yield Path(temp_dir)
shutil.rmtree(temp_dir, ignore_errors=True)
@pytest.fixture
def app(temp_data_dir, monkeypatch):
"""Create a Flask app configured for testing."""
# Override data directory
monkeypatch.setenv("LDR_DATA_DIR", str(temp_data_dir))
# Disable rate limiting for tests
monkeypatch.setenv("LDR_DISABLE_RATE_LIMITING", "true")
# Use fast KDF iterations for tests (default 256000 is too slow in CI
# after lru_cache removal from _get_key_from_password)
monkeypatch.setenv("LDR_DB_CONFIG_KDF_ITERATIONS", "1000")
# Clear database manager state
db_manager.close_all_databases()
# Reset db_manager's data directory to temp directory
db_manager.data_dir = temp_data_dir / "encrypted_databases"
db_manager.data_dir.mkdir(parents=True, exist_ok=True)
# Create app with testing config
app, _ = create_app()
app.config["TESTING"] = True
app.config["WTF_CSRF_ENABLED"] = False
app.config["SESSION_COOKIE_SECURE"] = False # For testing without HTTPS
# Initialize auth database
init_auth_database()
# Clean up any existing test users
auth_db = get_auth_db_session()
auth_db.query(User).filter(User.username.like("testuser%")).delete()
auth_db.commit()
auth_db.close()
yield app
# Cleanup after test
db_manager.close_all_databases()
dispose_auth_engine()
@pytest.fixture
def client(app):
"""Create a test client."""
return app.test_client()
class TestAuthRoutes:
"""Test authentication routes."""
def test_root_redirects_to_login(self, client):
"""Test that unauthenticated users are redirected to login."""
response = client.get("/")
assert response.status_code == 302
assert "/auth/login" in response.location
def test_login_page_loads(self, client):
"""Test that login page loads successfully."""
response = client.get("/auth/login")
assert response.status_code == 200
assert b"Local Deep Research" in response.data
assert b"Your data is encrypted" in response.data
def test_register_page_loads(self, client):
"""Test that register page loads successfully."""
response = client.get("/auth/register")
assert response.status_code == 200
assert b"Create Account" in response.data
assert b"NO way to recover your data" in response.data
def test_successful_registration(self, client):
"""Test successful user registration."""
response = client.post(
"/auth/register",
data={
"username": "testuser",
"password": "TestPass123",
"confirm_password": "TestPass123",
"acknowledge": "true",
},
follow_redirects=True,
)
assert response.status_code == 200
# Check user was created in auth database
auth_db = get_auth_db_session()
user = auth_db.query(User).filter_by(username="testuser").first()
assert user is not None
assert user.username == "testuser"
auth_db.close()
# Check user database exists
assert db_manager.user_exists("testuser")
def test_registration_validation(self, client):
"""Test registration form validation."""
# Test missing fields
response = client.post("/auth/register", data={})
assert response.status_code == 400
assert b"Username is required" in response.data
# Test username with invalid characters (special chars not allowed)
response = client.post(
"/auth/register",
data={
"username": "@invalid!user",
"password": "TestPass123",
"confirm_password": "TestPass123",
"acknowledge": "true",
},
)
assert response.status_code == 400
assert b"Username can only contain" in response.data
# Test password mismatch
response = client.post(
"/auth/register",
data={
"username": "testuser",
"password": "password1",
"confirm_password": "password2",
"acknowledge": "true",
},
)
assert response.status_code == 400
assert b"Passwords do not match" in response.data
# Test missing acknowledgment
response = client.post(
"/auth/register",
data={
"username": "testuser",
"password": "TestPass123",
"confirm_password": "TestPass123",
"acknowledge": "false",
},
)
assert response.status_code == 400
assert b"You must acknowledge" in response.data
def test_single_character_username(self, client):
"""Test that single character usernames are rejected (min 3 chars)."""
response = client.post(
"/auth/register",
data={
"username": "x",
"password": "TestPass123",
"confirm_password": "TestPass123",
"acknowledge": "true",
},
)
assert response.status_code == 400
assert b"Username must be at least 3 characters" in response.data
def test_empty_username_rejected(self, client):
"""Test that empty or whitespace-only usernames are rejected."""
# Test empty username
response = client.post(
"/auth/register",
data={
"username": "",
"password": "TestPass123",
"confirm_password": "TestPass123",
"acknowledge": "true",
},
)
assert response.status_code == 400
assert b"Username is required" in response.data
# Test whitespace-only username
response = client.post(
"/auth/register",
data={
"username": " ",
"password": "TestPass123",
"confirm_password": "TestPass123",
"acknowledge": "true",
},
)
assert response.status_code == 400
# Whitespace gets trimmed, so it's treated as empty
assert (
b"Username is required" in response.data
or b"Username can only contain" in response.data
)
def test_duplicate_username(self, client):
"""Test that duplicate usernames are rejected."""
# Register first user
client.post(
"/auth/register",
data={
"username": "testuser",
"password": "TestPass123",
"confirm_password": "TestPass123",
"acknowledge": "true",
},
)
# Logout so we can try registering again
client.post("/auth/logout")
# Try to register same username
response = client.post(
"/auth/register",
data={
"username": "testuser",
"password": "OtherPass123",
"confirm_password": "OtherPass123",
"acknowledge": "true",
},
)
assert response.status_code == 400
# Error message uses generic text to prevent account enumeration
assert (
b"Registration failed. Please try a different username"
in response.data
)
def test_orphaned_auth_row_cleaned_up_on_db_creation_failure(
self, client, monkeypatch
):
"""Registration must not leave a bricked account behind.
If ``create_user_database()`` fails after the auth-DB ``User`` row
has been committed, that orphaned row must be deleted. Otherwise the
username stays taken (``user_exists()`` blocks re-registration) while
login also fails (no encrypted DB to open) — a permanently unusable
account. Regression test for PR #3142.
"""
username = "testuser_orphan"
row_existed_at_failure = {}
def _fail_create(uname, password):
# The auth row is committed before create_user_database() runs.
# Record its presence here so the test proves the row was
# created-then-cleaned, not merely never created.
check_db = get_auth_db_session()
row_existed_at_failure["value"] = (
check_db.query(User).filter_by(username=uname).first()
is not None
)
check_db.close()
raise RuntimeError("simulated encrypted DB creation failure")
monkeypatch.setattr(db_manager, "create_user_database", _fail_create)
response = client.post(
"/auth/register",
data={
"username": username,
"password": "TestPass123",
"confirm_password": "TestPass123",
"acknowledge": "true",
},
)
# Registration reports failure to the user...
assert response.status_code == 500
assert b"Registration failed" in response.data
# ...but only after the auth row was actually committed...
assert row_existed_at_failure.get("value") is True
# ...and the orphaned row must have been cleaned up, freeing the
# username so the user can retry registration.
auth_db = get_auth_db_session()
orphan = auth_db.query(User).filter_by(username=username).first()
auth_db.close()
assert orphan is None
def test_partial_db_creation_failure_leaves_username_reusable(
self, client, monkeypatch
):
"""A real mid-flight ``create_user_database()`` failure must leave no
on-disk residue that blocks re-registration.
``create_user_database()`` writes a per-database ``.salt`` file before
the DB itself. If a later step fails and only the ``.db`` is removed,
the orphaned salt makes ``create_database_salt()`` raise
``FileExistsError`` on every retry — so the username stays permanently
un-registerable even after the auth row is cleaned up. This drives a
genuine failure *after* the salt is written (by making the first
SQLCipher connection raise) and asserts the username can be registered
afterwards. Complements the stubbed auth-row test above by exercising
the real filesystem cleanup end-to-end.
"""
if not db_manager.has_encryption:
pytest.skip(
"Salt files only exist on the encrypted (SQLCipher) path"
)
import local_deep_research.database.encrypted_db as encrypted_db
from local_deep_research.database.sqlcipher_utils import (
get_salt_file_path,
)
username = "testuser_partial"
real_create_conn = encrypted_db.create_sqlcipher_connection
calls = {"n": 0}
def _flaky_conn(*args, **kwargs):
calls["n"] += 1
if calls["n"] == 1:
# Fail the first creation attempt, AFTER the real salt file has
# already been written by create_database_salt().
raise RuntimeError("simulated SQLCipher failure")
return real_create_conn(*args, **kwargs)
monkeypatch.setattr(
encrypted_db, "create_sqlcipher_connection", _flaky_conn
)
register_data = {
"username": username,
"password": "TestPass123",
"confirm_password": "TestPass123",
"acknowledge": "true",
}
# First attempt fails mid-creation.
first = client.post("/auth/register", data=register_data)
assert first.status_code == 500
# The injected failure actually fired (not some unrelated 500).
assert calls["n"] >= 1
# The salt file (and DB) must have been cleaned up, not orphaned —
# otherwise the retry below would trip create_database_salt()'s
# FileExistsError.
salt_path = get_salt_file_path(db_manager._get_user_db_path(username))
assert not salt_path.exists()
# The username must now be genuinely reusable: a retry (which no longer
# trips the injected failure) succeeds end-to-end and actually creates
# the encrypted DB file (user_exists() only checks the auth DB).
second = client.post(
"/auth/register", data=register_data, follow_redirects=True
)
assert second.status_code == 200
assert db_manager.user_exists(username)
assert db_manager._get_user_db_path(username).exists()
def test_migration_failure_leaves_username_reusable(
self, client, monkeypatch
):
"""The *migration-path* cleanup site must also leave no on-disk residue.
``create_user_database()`` has two failure-cleanup sites: the SQLCipher
structure-creation step (exercised by the test above) and the alembic
migration step (``initialize_database``). The migration failure — e.g.
a world-writable migrations dir — is the historically realistic one
(see the "no such table: papers" / #3635 lineage). This drives a failure
there, *after* the real ``.db`` and salt have been written, and asserts
both are removed and the username is reusable — guarding the second
cleanup call site, which the SQLCipher-path test cannot reach.
"""
if not db_manager.has_encryption:
pytest.skip(
"Salt files only exist on the encrypted (SQLCipher) path"
)
import local_deep_research.database.initialize as initialize_mod
from local_deep_research.database.sqlcipher_utils import (
get_salt_file_path,
)
username = "testuser_migfail"
real_initialize = initialize_mod.initialize_database
calls = {"n": 0}
def _flaky_initialize(*args, **kwargs):
calls["n"] += 1
if calls["n"] == 1:
# Fail the first attempt at the migration step, AFTER the real
# .db + salt were created by the SQLCipher structure step.
raise RuntimeError("simulated migration failure")
return real_initialize(*args, **kwargs)
# Patch on the source module (not encrypted_db): create_user_database
# does a *function-level* `from .initialize import initialize_database`
# that re-reads the module attribute at call time, so patching
# initialize_mod.initialize_database takes effect. (A module-level
# import in encrypted_db would need the patch applied there instead.)
monkeypatch.setattr(
initialize_mod, "initialize_database", _flaky_initialize
)
register_data = {
"username": username,
"password": "TestPass123",
"confirm_password": "TestPass123",
"acknowledge": "true",
}
db_path = db_manager._get_user_db_path(username)
# First attempt fails at the migration step — after .db + salt exist.
first = client.post("/auth/register", data=register_data)
assert first.status_code == 500
assert calls["n"] >= 1 # the injected migration failure actually fired
# The migration-path cleanup must remove BOTH the .db and its salt
# (the pre-fix code unlinked only the .db).
assert not db_path.exists()
assert not get_salt_file_path(db_path).exists()
# The username is reusable: the retry (no longer failing) succeeds.
second = client.post(
"/auth/register", data=register_data, follow_redirects=True
)
assert second.status_code == 200
assert db_manager.user_exists(username)
assert db_path.exists()
def test_registration_recovers_from_orphaned_salt(self, client):
"""A pre-existing salt with no matching database must not permanently
block registration.
A create that dies before completing — a hard process kill mid-creation,
or the pre-#4934 cleanup that removed only the ``.db`` — can leave a
``.salt`` file with no ``.db``. ``create_database_salt()`` refuses to
overwrite it (``FileExistsError``), so without recovery the username is
permanently un-registerable. This simulates that orphan and asserts
registration heals it (removes the stale salt, creates fresh) and
succeeds. Fails without the salt-creation recovery (the first
registration 500s on the FileExistsError).
The 200 alone proves the DB was created and opens with the *fresh*
salt: create_user_database() runs initialize_database() against the
newly-keyed DB, which 500s if the salt/key are inconsistent. We also
assert the on-disk salt bytes changed, to prove regenerate-not-reuse.
"""
if not db_manager.has_encryption:
pytest.skip(
"Salt files only exist on the encrypted (SQLCipher) path"
)
from local_deep_research.database.sqlcipher_utils import (
create_database_salt,
get_salt_file_path,
)
username = "testuser_saltorphan"
db_path = db_manager._get_user_db_path(username)
db_path.parent.mkdir(parents=True, exist_ok=True)
# Simulate the orphan: a salt file with no matching database.
create_database_salt(db_path)
salt_path = get_salt_file_path(db_path)
assert salt_path.exists()
assert not db_path.exists()
planted_salt = salt_path.read_bytes()
# Registration must recover (remove the orphan, create fresh) and
# succeed end-to-end rather than 500 on the salt collision.
response = client.post(
"/auth/register",
data={
"username": username,
"password": "TestPass123",
"confirm_password": "TestPass123",
"acknowledge": "true",
},
follow_redirects=True,
)
assert response.status_code == 200
assert db_manager.user_exists(username)
assert db_path.exists()
# A freshly-generated salt now backs the real database — the recovery
# removed and recreated it, it did not reuse the planted orphan.
assert salt_path.exists()
assert salt_path.read_bytes() != planted_salt
def test_engine_build_failure_leaves_username_reusable(
self, client, monkeypatch
):
"""A failure building the SQLAlchemy engine — the step BETWEEN the
structure-creation and migration cleanup blocks — must still clean up
the already-created .db and salt.
Without cleanup at this step a failure here orphans the .db, and unlike
the salt path it is NOT self-healing: the retry trips
create_user_database's ``db_path.exists()`` guard and bricks the
username permanently. This injects an engine-build failure after the
structure step has written the .db + salt and asserts both are removed
and the username is reusable.
"""
if not db_manager.has_encryption:
pytest.skip(
"Salt files only exist on the encrypted (SQLCipher) path"
)
import local_deep_research.database.encrypted_db as encrypted_db
from local_deep_research.database.sqlcipher_utils import (
get_salt_file_path,
)
username = "testuser_engfail"
real_create_engine = encrypted_db.create_engine
calls = {"n": 0}
def _flaky_create_engine(*args, **kwargs):
calls["n"] += 1
if calls["n"] == 1:
# Fail the first engine build, AFTER the structure step wrote
# the real .db + salt.
raise RuntimeError("simulated engine build failure")
return real_create_engine(*args, **kwargs)
monkeypatch.setattr(encrypted_db, "create_engine", _flaky_create_engine)
register_data = {
"username": username,
"password": "TestPass123",
"confirm_password": "TestPass123",
"acknowledge": "true",
}
db_path = db_manager._get_user_db_path(username)
first = client.post("/auth/register", data=register_data)
assert first.status_code == 500
# Exactly one create_engine call fired (the F1a engine build) and it
# raised — pins the failure to that site, not some earlier 500.
assert calls["n"] == 1
# Both the .db and its salt must have been removed, not orphaned.
assert not db_path.exists()
assert not get_salt_file_path(db_path).exists()
# The username is reusable: the retry (no longer failing) succeeds.
second = client.post(
"/auth/register", data=register_data, follow_redirects=True
)
assert second.status_code == 200
assert db_manager.user_exists(username)
assert db_path.exists()
def test_post_creation_session_failure_does_not_leave_user_logged_in(
self, client, monkeypatch
):
"""If the post-creation session setup fails, the user must not be left
half-logged-in.
create_user_database() succeeds (the account is legitimately created),
but a later step in _create_user_session — storing the session password
— fails. Registration returns 500, and the Flask session must NOT retain
the auth keys: otherwise the partially-set cookie would effectively log
the user in on their next request despite the reported failure.
"""
from local_deep_research.database.session_passwords import (
session_password_store,
)
from local_deep_research.web.auth.session_manager import (
session_manager,
)
username = "testuser_sessfail"
captured = {}
def _boom(*args, **kwargs):
# store_session_password(username, session_id, password) — capture
# the server-side session_id so we can assert it was torn down.
captured["session_id"] = (
args[1] if len(args) > 1 else kwargs.get("session_id")
)
raise RuntimeError("simulated post-creation session failure")
# Fails AFTER _create_user_session has already set session_id/username/
# temp_auth_token in the cookie AND created the server-side session —
# exactly the half-logged-in window.
monkeypatch.setattr(
session_password_store, "store_session_password", _boom
)
response = client.post(
"/auth/register",
data={
"username": username,
"password": "TestPass123",
"confirm_password": "TestPass123",
"acknowledge": "true",
},
)
assert response.status_code == 500
assert captured.get("session_id") # the failing step actually ran
# 1. Cookie rolled back: no auth material survives (session_id/username
# gate login; temp_auth_token is credential-lookup material).
with client.session_transaction() as sess:
assert "session_id" not in sess
assert "username" not in sess
assert "temp_auth_token" not in sess
# 2. Server-side state torn down too (not just the cookie): the session
# and its password-store entry must be gone, or they leak.
assert session_manager.validate_session(captured["session_id"]) is None
assert (
session_password_store.get_session_password(
username, captured["session_id"]
)
is None
)
# 3. End-to-end: the user is genuinely not authenticated — a protected
# route redirects to login.
protected = client.get("/")
assert protected.status_code == 302
assert "/auth/login" in protected.location
def test_successful_login(self, client):
"""Test successful login."""
# Register user first
client.post(
"/auth/register",
data={
"username": "testuser",
"password": "TestPass123",
"confirm_password": "TestPass123",
"acknowledge": "true",
},
)
# Logout
client.post("/auth/logout")
# Login
response = client.post(
"/auth/login",
data={"username": "testuser", "password": "TestPass123"},
follow_redirects=True,
)
assert response.status_code == 200
# Check session
with client.session_transaction() as sess:
assert "username" in sess
assert sess["username"] == "testuser"
def test_invalid_login(self, client):
"""Test login with invalid credentials."""
response = client.post(
"/auth/login",
data={"username": "nonexistent", "password": "wrongpassword"},
)
assert response.status_code == 401
assert b"Invalid username or password" in response.data
@pytest.mark.skipif(
os.environ.get("CI") == "true"
or os.environ.get("GITHUB_ACTIONS") == "true",
reason="Encrypted DB registration can hit sqlcipher hmac errors in CI",
)
def test_logout(self, client):
"""Test logout functionality."""
# Register and login
reg_response = client.post(
"/auth/register",
data={
"username": "testuser",
"password": "TestPass123",
"confirm_password": "TestPass123",
"acknowledge": "true",
},
follow_redirects=False,
)
assert reg_response.status_code == 302, (
f"Registration failed with status {reg_response.status_code}"
)
# Verify logged in
with client.session_transaction() as sess:
assert sess.get("username") == "testuser"
# Logout
response = client.post("/auth/logout", follow_redirects=False)
assert response.status_code == 302
assert "/auth/login" in response.location
# Check session is cleared
with client.session_transaction() as sess:
assert "username" not in sess
@pytest.mark.skipif(
os.environ.get("CI") == "true"
or os.environ.get("GITHUB_ACTIONS") == "true",
reason="Password change with encrypted DB re-keying is complex to test in CI",
)
def test_change_password(self, client):
"""Test password change functionality."""
# Register user
client.post(
"/auth/register",
data={
"username": "testuser",
"password": "OldPass123",
"confirm_password": "OldPass123",
"acknowledge": "true",
},
)
# Change password - don't follow redirects to check status
response = client.post(
"/auth/change-password",
data={
"current_password": "OldPass123",
"new_password": "NewPass456",
"confirm_password": "NewPass456",
},
follow_redirects=False,
)
# Should redirect to login after successful password change
assert response.status_code == 302
assert "/auth/login" in response.location
# Now follow the redirect to login page
response = client.get("/auth/login")
assert response.status_code == 200
# Try to login with new password
response = client.post(
"/auth/login",
data={"username": "testuser", "password": "NewPass456"},
follow_redirects=True,
)
assert response.status_code == 200
def test_remember_me(self, client):
"""Test remember me functionality."""
# Register user
client.post(
"/auth/register",
data={
"username": "testuser",
"password": "TestPass123",
"confirm_password": "TestPass123",
"acknowledge": "true",
},
)
# Logout
client.post("/auth/logout")
# Login with remember me
client.post(
"/auth/login",
data={
"username": "testuser",
"password": "TestPass123",
"remember": "true",
},
)
with client.session_transaction() as sess:
assert sess.permanent is True
@pytest.mark.skipif(
os.environ.get("CI") == "true"
or os.environ.get("GITHUB_ACTIONS") == "true",
reason="Encrypted DB registration can hit sqlcipher hmac errors in CI",
)
def test_auth_check_endpoint(self, client):
"""Test the authentication check endpoint."""
# Not logged in
response = client.get("/auth/check")
assert response.status_code == 401
data = response.get_json()
assert data["authenticated"] is False
# Register and check
reg_response = client.post(
"/auth/register",
data={
"username": "testuser",
"password": "TestPass123",
"confirm_password": "TestPass123",
"acknowledge": "true",
},
follow_redirects=False,
)
assert reg_response.status_code == 302, (
f"Registration failed with status {reg_response.status_code}"
)
response = client.get("/auth/check")
assert response.status_code == 200
data = response.get_json()
assert data["authenticated"] is True
assert data["username"] == "testuser"
def test_blocked_registration_get(self, client, monkeypatch):
"""Test that GET register redirects when registrations are disabled."""
def mock_load_config():
return {"allow_registrations": False}
monkeypatch.setattr(
"local_deep_research.web.auth.routes.load_server_config",
mock_load_config,
)
response = client.get("/auth/register", follow_redirects=False)
assert response.status_code == 302
assert "/auth/login" in response.location
def test_blocked_registration_post(self, client, monkeypatch):
"""Test that POST register redirects and doesn't create user when disabled."""
def mock_load_config():
return {"allow_registrations": False}
monkeypatch.setattr(
"local_deep_research.web.auth.routes.load_server_config",
mock_load_config,
)
response = client.post(
"/auth/register",
data={
"username": "testuser_blocked",
"password": "TestPass123",
"confirm_password": "TestPass123",
"acknowledge": "true",
},
follow_redirects=False,
)
assert response.status_code == 302
assert "/auth/login" in response.location
# Check no user was created
auth_db = get_auth_db_session()
user = (
auth_db.query(User).filter_by(username="testuser_blocked").first()
)
assert user is None
auth_db.close()