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
533 lines
18 KiB
Python
533 lines
18 KiB
Python
"""
|
|
Tests for the Flask application factory.
|
|
|
|
Tests cover:
|
|
- is_private_ip helper (re-imported from security.network_utils)
|
|
- DiskSpoolingRequest class
|
|
- create_app function
|
|
- CSRF protection
|
|
- Error handlers
|
|
- Static file serving
|
|
"""
|
|
|
|
import pytest
|
|
from unittest.mock import Mock, patch
|
|
from flask import Flask
|
|
|
|
|
|
class TestIsPrivateIp:
|
|
"""Tests for the is_private_ip helper used by SecureCookieMiddleware."""
|
|
|
|
def test_localhost_ipv4(self):
|
|
"""127.0.0.1 is private."""
|
|
from local_deep_research.security.network_utils import is_private_ip
|
|
|
|
assert is_private_ip("127.0.0.1") is True
|
|
|
|
def test_localhost_ipv6(self):
|
|
"""::1 is private."""
|
|
from local_deep_research.security.network_utils import is_private_ip
|
|
|
|
assert is_private_ip("::1") is True
|
|
|
|
def test_private_class_a(self):
|
|
"""10.x.x.x is private."""
|
|
from local_deep_research.security.network_utils import is_private_ip
|
|
|
|
assert is_private_ip("10.0.0.1") is True
|
|
assert is_private_ip("10.255.255.255") is True
|
|
|
|
def test_private_class_b(self):
|
|
"""172.16.x.x - 172.31.x.x is private."""
|
|
from local_deep_research.security.network_utils import is_private_ip
|
|
|
|
assert is_private_ip("172.16.0.1") is True
|
|
assert is_private_ip("172.31.255.255") is True
|
|
|
|
def test_private_class_c(self):
|
|
"""192.168.x.x is private."""
|
|
from local_deep_research.security.network_utils import is_private_ip
|
|
|
|
assert is_private_ip("192.168.0.1") is True
|
|
assert is_private_ip("192.168.255.255") is True
|
|
|
|
def test_public_ip(self):
|
|
"""Public IPs are not private."""
|
|
from local_deep_research.security.network_utils import is_private_ip
|
|
|
|
assert is_private_ip("8.8.8.8") is False
|
|
assert is_private_ip("1.1.1.1") is False
|
|
assert is_private_ip("142.250.190.78") is False
|
|
|
|
def test_invalid_ip(self):
|
|
"""Invalid IP returns False."""
|
|
from local_deep_research.security.network_utils import is_private_ip
|
|
|
|
assert is_private_ip("invalid") is False
|
|
assert is_private_ip("256.256.256.256") is False
|
|
assert is_private_ip("") is False
|
|
|
|
|
|
class TestDiskSpoolingRequest:
|
|
"""Tests for DiskSpoolingRequest class."""
|
|
|
|
def test_max_form_memory_size(self):
|
|
"""DiskSpoolingRequest has correct memory threshold."""
|
|
from local_deep_research.web.app_factory import DiskSpoolingRequest
|
|
|
|
# 5MB threshold
|
|
assert DiskSpoolingRequest.max_form_memory_size == 5 * 1024 * 1024
|
|
|
|
def test_inherits_from_request(self):
|
|
"""DiskSpoolingRequest inherits from Flask Request."""
|
|
from local_deep_research.web.app_factory import DiskSpoolingRequest
|
|
from flask import Request
|
|
|
|
assert issubclass(DiskSpoolingRequest, Request)
|
|
|
|
|
|
class TestCreateApp:
|
|
"""Tests for create_app function."""
|
|
|
|
def test_returns_flask_app_and_socketio(self):
|
|
"""create_app returns Flask app and SocketIO."""
|
|
from local_deep_research.web.app_factory import create_app
|
|
|
|
with patch(
|
|
"local_deep_research.web.app_factory.SocketIOService"
|
|
) as mock_socketio:
|
|
mock_socketio_instance = Mock()
|
|
mock_socketio.return_value.get_socketio.return_value = (
|
|
mock_socketio_instance
|
|
)
|
|
|
|
app, socketio = create_app()
|
|
|
|
assert isinstance(app, Flask)
|
|
assert socketio is not None
|
|
|
|
def test_csrf_protection_enabled(self):
|
|
"""CSRF protection is enabled."""
|
|
from local_deep_research.web.app_factory import create_app
|
|
|
|
with patch("local_deep_research.web.app_factory.SocketIOService"):
|
|
app, _ = create_app()
|
|
|
|
# CSRF extension should be registered
|
|
assert "csrf" in app.extensions
|
|
|
|
def test_uses_disk_spooling_request(self):
|
|
"""App uses DiskSpoolingRequest class."""
|
|
from local_deep_research.web.app_factory import (
|
|
create_app,
|
|
DiskSpoolingRequest,
|
|
)
|
|
|
|
with patch("local_deep_research.web.app_factory.SocketIOService"):
|
|
app, _ = create_app()
|
|
|
|
assert app.request_class == DiskSpoolingRequest
|
|
|
|
def test_proxy_fix_middleware(self):
|
|
"""App has ProxyFix middleware."""
|
|
from local_deep_research.web.app_factory import create_app
|
|
|
|
with patch("local_deep_research.web.app_factory.SocketIOService"):
|
|
app, _ = create_app()
|
|
|
|
# Check that wsgi_app has been wrapped
|
|
# The actual wsgi_app is wrapped multiple times
|
|
assert app.wsgi_app is not None
|
|
|
|
def test_has_static_dir_config(self):
|
|
"""App has STATIC_DIR config."""
|
|
from local_deep_research.web.app_factory import create_app
|
|
|
|
with patch("local_deep_research.web.app_factory.SocketIOService"):
|
|
app, _ = create_app()
|
|
|
|
assert "STATIC_DIR" in app.config
|
|
|
|
def test_error_handlers_registered(self):
|
|
"""Error handlers are registered."""
|
|
from local_deep_research.web.app_factory import create_app
|
|
|
|
with patch("local_deep_research.web.app_factory.SocketIOService"):
|
|
app, _ = create_app()
|
|
|
|
# Check that error handlers exist for common codes
|
|
assert app.error_handler_spec is not None
|
|
|
|
|
|
class TestAppRoutes:
|
|
"""Tests for routes registered by create_app."""
|
|
|
|
@pytest.fixture
|
|
def app(self):
|
|
"""Create test app."""
|
|
from local_deep_research.web.app_factory import create_app
|
|
|
|
with patch("local_deep_research.web.app_factory.SocketIOService"):
|
|
app, _ = create_app()
|
|
app.config["TESTING"] = True
|
|
app.config["WTF_CSRF_ENABLED"] = False
|
|
return app
|
|
|
|
@pytest.fixture
|
|
def client(self, app):
|
|
"""Create test client."""
|
|
return app.test_client()
|
|
|
|
def test_static_route_exists(self, app):
|
|
"""Static route is registered."""
|
|
# Check that the static route exists
|
|
rules = [rule.rule for rule in app.url_map.iter_rules()]
|
|
static_routes = [r for r in rules if "static" in r]
|
|
assert len(static_routes) > 0
|
|
|
|
def test_index_route_exists(self, app):
|
|
"""Index route is registered."""
|
|
rules = [rule.rule for rule in app.url_map.iter_rules()]
|
|
assert "/" in rules
|
|
|
|
def test_api_routes_registered(self, app):
|
|
"""API routes are registered."""
|
|
rules = [rule.rule for rule in app.url_map.iter_rules()]
|
|
api_routes = [r for r in rules if "/api/" in r]
|
|
assert len(api_routes) > 0
|
|
|
|
|
|
class TestSecurityHeaders:
|
|
"""Tests for security headers."""
|
|
|
|
@pytest.fixture
|
|
def app(self):
|
|
"""Create test app."""
|
|
from local_deep_research.web.app_factory import create_app
|
|
|
|
with patch("local_deep_research.web.app_factory.SocketIOService"):
|
|
app, _ = create_app()
|
|
app.config["TESTING"] = True
|
|
app.config["WTF_CSRF_ENABLED"] = False
|
|
return app
|
|
|
|
@pytest.fixture
|
|
def client(self, app):
|
|
"""Create test client."""
|
|
return app.test_client()
|
|
|
|
def test_response_has_security_headers(self, client):
|
|
"""Responses have security headers."""
|
|
# audit: PUNCHLIST reviewed 2026-05 — KEEP (ASSERT_TRUE).
|
|
response = client.get("/")
|
|
|
|
# Check for common security headers
|
|
# Content-Security-Policy or X-Content-Type-Options
|
|
headers = dict(response.headers)
|
|
# At least one security header should be present
|
|
security_headers = [
|
|
"X-Content-Type-Options",
|
|
"X-Frame-Options",
|
|
"Content-Security-Policy",
|
|
]
|
|
# Note: Security headers may not be set on all routes
|
|
# Just verify the app runs without errors and we can check headers
|
|
_ = any(h in headers for h in security_headers)
|
|
assert response is not None
|
|
|
|
|
|
class TestCsrfProtection:
|
|
"""Tests for CSRF protection."""
|
|
|
|
@pytest.fixture
|
|
def app(self):
|
|
"""Create test app."""
|
|
from local_deep_research.web.app_factory import create_app
|
|
|
|
with patch("local_deep_research.web.app_factory.SocketIOService"):
|
|
app, _ = create_app()
|
|
app.config["TESTING"] = True
|
|
return app
|
|
|
|
def test_csrf_enabled_by_default(self, app):
|
|
"""CSRF is enabled by default."""
|
|
assert "csrf" in app.extensions
|
|
|
|
def test_csrf_token_endpoint_exists(self, app):
|
|
"""CSRF token endpoint exists."""
|
|
# audit: PUNCHLIST reviewed 2026-05 — KEEP (ASSERT_TRUE).
|
|
rules = [rule.rule for rule in app.url_map.iter_rules()]
|
|
csrf_routes = [r for r in rules if "csrf" in r.lower()]
|
|
# Should have a CSRF token endpoint
|
|
assert len(csrf_routes) >= 0 # May not have explicit route
|
|
|
|
|
|
class TestRateLimiting:
|
|
"""Tests for rate limiting."""
|
|
|
|
@pytest.fixture
|
|
def app(self):
|
|
"""Create test app."""
|
|
from local_deep_research.web.app_factory import create_app
|
|
|
|
with patch("local_deep_research.web.app_factory.SocketIOService"):
|
|
app, _ = create_app()
|
|
app.config["TESTING"] = True
|
|
app.config["WTF_CSRF_ENABLED"] = False
|
|
return app
|
|
|
|
|
|
class TestErrorHandlers:
|
|
"""Tests for error handlers."""
|
|
|
|
@pytest.fixture
|
|
def app(self):
|
|
"""Create test app with error test routes."""
|
|
from local_deep_research.web.app_factory import create_app
|
|
|
|
with patch("local_deep_research.web.app_factory.SocketIOService"):
|
|
app, _ = create_app()
|
|
app.config["TESTING"] = True
|
|
app.config["WTF_CSRF_ENABLED"] = False
|
|
|
|
# Add test routes that trigger errors
|
|
@app.route("/test-500")
|
|
def trigger_500():
|
|
raise Exception("Test error")
|
|
|
|
return app
|
|
|
|
@pytest.fixture
|
|
def client(self, app):
|
|
"""Create test client."""
|
|
return app.test_client()
|
|
|
|
def test_404_returns_json_for_api(self, client):
|
|
"""404 returns JSON for API routes."""
|
|
response = client.get("/api/nonexistent-route")
|
|
|
|
# Should return 404
|
|
assert response.status_code == 404
|
|
|
|
def test_404_returns_html_for_web(self, client):
|
|
"""404 returns HTML for web routes."""
|
|
response = client.get("/nonexistent-page")
|
|
|
|
# Should return 404
|
|
assert response.status_code == 404
|
|
|
|
|
|
class TestFileUploadSecurity:
|
|
"""Tests for file upload security."""
|
|
|
|
def test_file_upload_validator_available(self):
|
|
"""FileUploadValidator is available."""
|
|
from local_deep_research.security.file_upload_validator import (
|
|
FileUploadValidator,
|
|
)
|
|
|
|
validator = FileUploadValidator()
|
|
assert validator is not None
|
|
|
|
def test_max_form_memory_prevents_memory_exhaustion(self):
|
|
"""DiskSpoolingRequest prevents memory exhaustion.
|
|
|
|
Files larger than the threshold are spooled to disk by Werkzeug
|
|
instead of being held in RAM — keeping memory bounded even though
|
|
the per-file cap (FileUploadValidator.MAX_FILE_SIZE) is large.
|
|
"""
|
|
from local_deep_research.security.file_upload_validator import (
|
|
FileUploadValidator,
|
|
)
|
|
from local_deep_research.web.app_factory import DiskSpoolingRequest
|
|
|
|
# 5MB threshold means files larger than this go to disk.
|
|
threshold = DiskSpoolingRequest.max_form_memory_size
|
|
assert threshold == 5 * 1024 * 1024 # 5MB
|
|
# Sanity: threshold must stay well below the per-file cap, otherwise
|
|
# large uploads would never spool to disk. Reference the configured
|
|
# cap directly so the bound scales when the cap is changed.
|
|
assert threshold < FileUploadValidator.MAX_FILE_SIZE
|
|
|
|
|
|
class TestSecureCookieMiddleware:
|
|
"""Tests for SecureCookieMiddleware WSGI middleware.
|
|
|
|
The middleware adds the Secure flag iff the request is HTTPS, regardless
|
|
of source IP. ProxyFix translates X-Forwarded-Proto into wsgi.url_scheme
|
|
before this middleware runs.
|
|
"""
|
|
|
|
def _make_middleware(self, testing_mode=False):
|
|
from local_deep_research.security.web_middleware import (
|
|
SecureCookieMiddleware,
|
|
)
|
|
|
|
def inner(environ, start_response):
|
|
start_response(
|
|
"200 OK", [("Set-Cookie", "session=abc; Path=/; HttpOnly")]
|
|
)
|
|
return [b""]
|
|
|
|
flask_app = Mock()
|
|
flask_app.config = {"LDR_TESTING_MODE": testing_mode}
|
|
return SecureCookieMiddleware(inner, flask_app)
|
|
|
|
def _capture_headers(self, mw, environ):
|
|
captured = {}
|
|
|
|
def start_response(status, headers, exc_info=None):
|
|
captured["headers"] = headers
|
|
|
|
list(mw(environ, start_response))
|
|
return captured["headers"]
|
|
|
|
def test_http_does_not_add_secure_regardless_of_source(self):
|
|
"""HTTP requests never get Secure, even from public IPs (#3849)."""
|
|
mw = self._make_middleware()
|
|
for remote_addr in [
|
|
"127.0.0.1",
|
|
"192.168.1.100",
|
|
"172.17.0.2",
|
|
"8.8.8.8",
|
|
# Docker Desktop NAT gateway range that triggered #3849
|
|
"172.67.130.145",
|
|
]:
|
|
headers = self._capture_headers(
|
|
mw,
|
|
{"REMOTE_ADDR": remote_addr, "wsgi.url_scheme": "http"},
|
|
)
|
|
cookie = next(v for n, v in headers if n.lower() == "set-cookie")
|
|
assert "Secure" not in cookie, (
|
|
f"HTTP from {remote_addr} should not get Secure flag"
|
|
)
|
|
|
|
def test_https_adds_secure(self):
|
|
"""HTTPS requests always get Secure."""
|
|
mw = self._make_middleware()
|
|
headers = self._capture_headers(
|
|
mw, {"REMOTE_ADDR": "8.8.8.8", "wsgi.url_scheme": "https"}
|
|
)
|
|
cookie = next(v for n, v in headers if n.lower() == "set-cookie")
|
|
assert "Secure" in cookie
|
|
|
|
def test_testing_mode_skips_secure_even_on_https(self):
|
|
"""LDR_TESTING_MODE disables the Secure flag entirely."""
|
|
mw = self._make_middleware(testing_mode=True)
|
|
headers = self._capture_headers(
|
|
mw, {"REMOTE_ADDR": "8.8.8.8", "wsgi.url_scheme": "https"}
|
|
)
|
|
cookie = next(v for n, v in headers if n.lower() == "set-cookie")
|
|
assert "Secure" not in cookie
|
|
|
|
def test_warning_fires_once_per_instance(self):
|
|
"""The HTTP-to-public-IP warning is one-shot per middleware instance."""
|
|
mw = self._make_middleware()
|
|
env = {"REMOTE_ADDR": "8.8.8.8", "wsgi.url_scheme": "http"}
|
|
with patch(
|
|
"local_deep_research.security.web_middleware.logger"
|
|
) as mock_logger:
|
|
self._capture_headers(mw, env)
|
|
self._capture_headers(mw, env)
|
|
self._capture_headers(mw, env)
|
|
warning_calls = [
|
|
c
|
|
for c in mock_logger.warning.call_args_list
|
|
if "Serving HTTP to non-private client" in c.args[0]
|
|
]
|
|
assert len(warning_calls) == 1
|
|
|
|
def test_warning_does_not_fire_for_private_ip(self):
|
|
"""No warning when end-user IP is in a private range."""
|
|
mw = self._make_middleware()
|
|
with patch(
|
|
"local_deep_research.security.web_middleware.logger"
|
|
) as mock_logger:
|
|
self._capture_headers(
|
|
mw,
|
|
{
|
|
"REMOTE_ADDR": "192.168.1.100",
|
|
"wsgi.url_scheme": "http",
|
|
},
|
|
)
|
|
warning_calls = [
|
|
c
|
|
for c in mock_logger.warning.call_args_list
|
|
if "Serving HTTP to non-private client" in c.args[0]
|
|
]
|
|
assert not warning_calls
|
|
|
|
|
|
class TestSessionConfiguration:
|
|
"""Tests for session cookie configuration."""
|
|
|
|
@pytest.fixture
|
|
def app(self):
|
|
"""Create test app."""
|
|
from local_deep_research.web.app_factory import create_app
|
|
|
|
with patch("local_deep_research.web.app_factory.SocketIOService"):
|
|
app, _ = create_app()
|
|
return app
|
|
|
|
def test_session_cookie_httponly(self, app):
|
|
"""Session cookie should have HttpOnly flag."""
|
|
assert app.config["SESSION_COOKIE_HTTPONLY"] is True
|
|
|
|
def test_session_cookie_samesite(self, app):
|
|
"""Session cookie should have SameSite=Lax."""
|
|
assert app.config["SESSION_COOKIE_SAMESITE"] == "Lax"
|
|
|
|
def test_permanent_session_lifetime(self, app):
|
|
"""Session should have 30-day lifetime."""
|
|
assert app.config["PERMANENT_SESSION_LIFETIME"] == 30 * 24 * 3600
|
|
|
|
def test_preferred_url_scheme_https(self, app):
|
|
"""Preferred URL scheme should be https."""
|
|
assert app.config["PREFERRED_URL_SCHEME"] == "https"
|
|
|
|
def test_wtf_csrf_enabled(self, app):
|
|
"""WTF CSRF should be enabled."""
|
|
assert app.config["WTF_CSRF_ENABLED"] is True
|
|
|
|
|
|
class TestBlueprintRegistration:
|
|
"""Tests for blueprint registration."""
|
|
|
|
@pytest.fixture
|
|
def app(self):
|
|
"""Create test app."""
|
|
from local_deep_research.web.app_factory import create_app
|
|
|
|
with patch("local_deep_research.web.app_factory.SocketIOService"):
|
|
app, _ = create_app()
|
|
app.config["TESTING"] = True
|
|
app.config["WTF_CSRF_ENABLED"] = False
|
|
return app
|
|
|
|
def test_auth_blueprint_registered(self, app):
|
|
"""Auth blueprint should be registered."""
|
|
rules = [rule.rule for rule in app.url_map.iter_rules()]
|
|
auth_routes = [r for r in rules if "/auth/" in r]
|
|
assert len(auth_routes) > 0
|
|
|
|
def test_research_blueprint_registered(self, app):
|
|
"""Research blueprint should be registered."""
|
|
rules = [rule.rule for rule in app.url_map.iter_rules()]
|
|
research_routes = [r for r in rules if "/research" in r]
|
|
assert len(research_routes) > 0
|
|
|
|
def test_settings_blueprint_registered(self, app):
|
|
"""Settings blueprint should be registered."""
|
|
# audit: PUNCHLIST reviewed 2026-05 — KEEP (ASSERT_TRUE).
|
|
rules = [rule.rule for rule in app.url_map.iter_rules()]
|
|
settings_routes = [r for r in rules if "/settings" in r]
|
|
assert len(settings_routes) >= 0 # May or may not exist
|
|
|
|
def test_library_blueprint_registered(self, app):
|
|
"""Library blueprint should be registered."""
|
|
# audit: PUNCHLIST reviewed 2026-05 — KEEP (ASSERT_TRUE).
|
|
rules = [rule.rule for rule in app.url_map.iter_rules()]
|
|
library_routes = [r for r in rules if "/library" in r]
|
|
assert len(library_routes) >= 0 # May or may not exist
|