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
324 lines
11 KiB
Python
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
|