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
871 lines
31 KiB
Python
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()
|