Files
learningcircuit--local-deep…/tests/security/test_login_required_boundaries.py
T
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

324 lines
11 KiB
Python

"""
Tests verifying that ``@login_required`` rejects unauthenticated requests
correctly across the URL shapes the app actually exposes.
Guards two things:
* Page routes (e.g. ``/news/``, ``/news/subscriptions``) redirect
unauthenticated callers to the login page.
* API routes — including nested blueprints like ``/news/api/...`` —
return a JSON ``401`` instead of an HTML redirect. This is the
regression case that motivated extending ``_is_api_path`` to match
``/api/`` anywhere in the path, not only as a top-level prefix.
The real news/research blueprints pull in heavy optional dependencies
(langchain, pdfplumber, etc.) and are exercised elsewhere. Here we only
care about the auth decorator's behavior at each URL shape, so we
register synthetic routes whose URL paths and decorator stacks mirror
production.
"""
from unittest.mock import patch
import pytest
from flask import Blueprint, Flask, jsonify
from local_deep_research.web.auth.decorators import login_required
@pytest.fixture
def app():
app = Flask(__name__)
app.config["SECRET_KEY"] = "test-secret-key"
app.config["WTF_CSRF_ENABLED"] = False
app.config["TESTING"] = True
# Stub auth blueprint so url_for("auth.login") works for redirects.
auth = Blueprint("auth", __name__)
@auth.route("/login")
def login():
return "Login Page"
app.register_blueprint(auth, url_prefix="/auth")
# Mirror production: news_bp at /news with a nested news_api_bp at /api,
# so the API surfaces at /news/api/... — same nesting that broke
# JSON-vs-HTML detection before _is_api_path() used a substring match.
news_bp = Blueprint("news", __name__)
news_api_bp = Blueprint("news_api", __name__, url_prefix="/api")
@news_bp.route("/")
@login_required
def news_page():
return "News"
@news_bp.route("/subscriptions")
@login_required
def subscriptions_page():
return "Subscriptions"
@news_bp.route("/subscriptions/new")
@login_required
def new_subscription_page():
return "New"
@news_bp.route("/subscriptions/<sid>/edit")
@login_required
def edit_subscription_page(sid):
return f"Edit {sid}"
@news_bp.route("/health")
def news_health():
return jsonify({"status": "ok"})
@news_api_bp.route("/categories", methods=["GET"])
@login_required
def get_categories():
return jsonify({"categories": []})
@news_api_bp.route("/subscribe", methods=["POST"])
@login_required
def subscribe():
return jsonify({"ok": True})
news_bp.register_blueprint(news_api_bp)
app.register_blueprint(news_bp, url_prefix="/news")
# research_bp registered at root, /api/config/limits is a top-level API.
research_bp = Blueprint("research", __name__)
@research_bp.route("/api/config/limits", methods=["GET"])
@login_required
def get_upload_limits():
return jsonify({"limit": 0})
app.register_blueprint(research_bp)
return app
@pytest.fixture
def client(app):
return app.test_client()
@pytest.fixture(autouse=True)
def patch_db_manager():
"""Stub the auth db_manager so we don't touch a real database.
Default is_user_connected=False so the unauthenticated-rejection
tests don't need to opt in. Tests that want to exercise the
authenticated 200 path use the `authenticated_client` fixture
below, which flips this to True and seeds the session.
"""
with patch("local_deep_research.web.auth.decorators.db_manager") as mock_dm:
mock_dm.is_user_connected.return_value = False
yield mock_dm
@pytest.fixture
def authenticated_client(client, patch_db_manager):
"""Test client that passes both auth checks in `login_required`:
a `username` in the session AND `db_manager.is_user_connected` True.
Without this, every test in the file is one-sided (rejection-only)
and a regression that breaks the *allow* path of the decorator —
e.g. the post-rejection branch swallowing valid requests — would
not be caught by any existing test.
"""
patch_db_manager.is_user_connected.return_value = True
with client.session_transaction() as sess:
sess["username"] = "test-user"
return client
class TestNewsPageRoutesRequireAuth:
"""The four news page routes added in PR #3129 must redirect to login
when the caller has no session."""
@pytest.mark.parametrize(
"path",
[
"/news/",
"/news/subscriptions",
"/news/subscriptions/new",
"/news/subscriptions/abc-123/edit",
],
)
def test_unauthenticated_page_redirects_to_login(self, client, path):
response = client.get(path)
assert response.status_code == 302
assert "/auth/login" in response.location
class TestNewsApiRoutesRequireAuth:
"""/news/api/categories must return JSON 401, not an HTML redirect.
This is the regression case for nested-blueprint API path detection."""
def test_unauthenticated_categories_returns_json_401(self, client):
response = client.get("/news/api/categories")
assert response.status_code == 401
assert response.is_json
assert response.json["error"] == "Authentication required"
# Must not be an HTML redirect to /auth/login.
location = response.headers.get("Location") or ""
assert "/auth/login" not in location
class TestResearchApiRoutesRequireAuth:
"""/api/config/limits added in PR #3129 must return JSON 401 for
unauthenticated callers."""
def test_unauthenticated_upload_limits_returns_json_401(self, client):
response = client.get("/api/config/limits")
assert response.status_code == 401
assert response.is_json
assert response.json["error"] == "Authentication required"
class TestNewsApiNonGetMethodsRequireAuth:
"""The decorator runs before the route, so it should reject POST/PUT/
DELETE the same way as GET. Probe one non-GET API endpoint to lock
that in."""
def test_unauthenticated_post_returns_json_401(self, client):
response = client.post("/news/api/subscribe", json={})
assert response.status_code == 401
assert response.is_json
assert response.json["error"] == "Authentication required"
class TestNewsHealthRouteIsPublic:
"""/news/health is intentionally exempt from @login_required (per PR
#3129). Lock that contract in."""
def test_health_does_not_require_auth(self, client):
response = client.get("/news/health")
assert response.status_code == 200
class TestAuthenticatedRequestsArePassedThrough:
"""Happy-path coverage. Without these tests, the entire file only
verifies that the decorator REJECTS — a regression where it stopped
ALLOWING valid requests would slip through. Mirrors each rejection
test class with a positive case at the same URL shape.
"""
@pytest.mark.parametrize(
"path",
[
"/news/",
"/news/subscriptions",
"/news/subscriptions/new",
"/news/subscriptions/abc-123/edit",
],
)
def test_authenticated_page_returns_200(self, authenticated_client, path):
response = authenticated_client.get(path)
assert response.status_code == 200
def test_authenticated_news_api_categories_returns_200(
self, authenticated_client
):
"""Counterpart to test_unauthenticated_categories_returns_json_401."""
response = authenticated_client.get("/news/api/categories")
assert response.status_code == 200
assert response.is_json
assert response.json == {"categories": []}
def test_authenticated_research_api_limits_returns_200(
self, authenticated_client
):
"""Counterpart to test_unauthenticated_upload_limits_returns_json_401."""
response = authenticated_client.get("/api/config/limits")
assert response.status_code == 200
assert response.is_json
assert response.json == {"limit": 0}
def test_authenticated_post_returns_200(self, authenticated_client):
"""Counterpart to test_unauthenticated_post_returns_json_401."""
response = authenticated_client.post("/news/api/subscribe", json={})
assert response.status_code == 200
assert response.is_json
assert response.json == {"ok": True}
class TestAuthenticatedButDisconnectedDb:
"""Second branch of the decorator: `username` is in session but
`db_manager.is_user_connected` is False. This happens when the
session outlives the encrypted-DB connection (server restart with
encrypted databases enabled). API paths must get JSON 401 with
a different error message ("Database connection required") and
page paths must redirect with the session cleared.
"""
@pytest.fixture
def session_without_db(self, client, patch_db_manager):
# is_user_connected stays False (autouse default), but seed the
# session so we hit the second decorator branch, not the first.
patch_db_manager.is_user_connected.return_value = False
with client.session_transaction() as sess:
sess["username"] = "test-user"
return client
def test_api_path_returns_json_401_db_required(self, session_without_db):
response = session_without_db.get("/news/api/categories")
assert response.status_code == 401
assert response.is_json
assert response.json["error"] == "Database connection required"
def test_page_path_redirects_to_login(self, session_without_db):
response = session_without_db.get("/news/subscriptions")
assert response.status_code == 302
assert "/auth/login" in response.location
class TestUnauthorized401ErrorHandler:
"""The 401 error handler in app_factory.register_error_handlers must
use the same _is_api_path detection as the decorator, so that
abort(401) on a nested API path returns JSON, not an HTML redirect."""
def _build_app(self):
from flask import Flask, abort
from local_deep_research.web.app_factory import register_error_handlers
app = Flask(__name__)
app.config["SECRET_KEY"] = "test"
# Stub auth.login so the redirect target resolves.
auth = Blueprint("auth", __name__)
@auth.route("/login")
def login():
return "Login"
app.register_blueprint(auth, url_prefix="/auth")
@app.route("/news/api/raises")
def raises_nested_api():
abort(401)
@app.route("/dashboard/raises")
def raises_page():
abort(401)
register_error_handlers(app)
return app
def test_abort_401_on_nested_api_returns_json(self):
app = self._build_app()
with app.test_client() as client:
response = client.get("/news/api/raises")
assert response.status_code == 401
assert response.is_json
assert response.json["error"] == "Authentication required"
def test_abort_401_on_page_redirects(self):
app = self._build_app()
with app.test_client() as client:
response = client.get("/dashboard/raises")
assert response.status_code == 302
assert "/auth/login" in response.location