diff --git a/application/single_app/app.py b/application/single_app/app.py index 9205a6796..989c98174 100644 --- a/application/single_app/app.py +++ b/application/single_app/app.py @@ -202,6 +202,7 @@ def register_route_blueprint(name, registrar, auth_guard=None): from background_tasks import start_background_task_threads from route_external_health import * +from route_external_public_documents import * _app_init_lock = threading.Lock() _app_initialized = False @@ -1428,6 +1429,9 @@ def finalize_m365_json_request(response): register_route_blueprint('external_health', register_route_external_health) register_route_blueprint('external_no_auth_health', register_no_auth_health) +# ------------------- External Public Document Routes (bearer-token protected) ---------- +register_route_blueprint('external_public_documents', register_route_external_public_documents) + if __name__ == '__main__': debug_mode = os.environ.get("FLASK_DEBUG", "0") == "1" use_gunicorn = os.environ.get("SIMPLECHAT_USE_GUNICORN", "0").strip().lower() in ('1', 'true', 'yes', 'on') diff --git a/application/single_app/config.py b/application/single_app/config.py index 8a472e1fb..9ee8a3bfe 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -98,7 +98,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.061" +VERSION = "0.261.062" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform diff --git a/application/single_app/route_external_health.py b/application/single_app/route_external_health.py index a36d67fef..42591c986 100644 --- a/application/single_app/route_external_health.py +++ b/application/single_app/route_external_health.py @@ -18,6 +18,13 @@ def external_health_check(): time_string = now.strftime("%Y-%m-%d %H:%M:%S") return time_string, 200 + @bp.route('/external/testaccesstoken', methods=['POST']) + @swagger_route(security=get_auth_security()) + @accesstoken_required + def external_test_access_token(): + """Confirm a bearer token is valid and carries the ExternalApi role.""" + return "Access token is valid.", 200 + def register_no_auth_health(bp): @bp.route('/external/healthcheckz', methods=['GET']) diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index 05766bff9..063a142c3 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -2,6 +2,15 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/features) and [Fixes by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/fixes). +### **(v0.261.062)** + +#### Bug Fixes + +* **External Public Document API Routes Registered Again** + * Registered the existing bearer-token protected `/external/public_documents` routes (list, upload, delete) in `app.py`, which previously defined them but never mounted them, so external bulk-loading clients received 404 responses. + * Restored the `POST /external/testaccesstoken` preflight route, protected by `@accesstoken_required` (ExternalApi role). + * (Ref: `app.py`, `route_external_health.py`, `route_external_public_documents.py`, `functional_tests/test_external_public_documents_route_registration.py`) + ### **(v0.261.061)** #### New Features diff --git a/functional_tests/route_tests/test_route_blueprint_policy_inventory.py b/functional_tests/route_tests/test_route_blueprint_policy_inventory.py index 4d322a87c..90189a522 100644 --- a/functional_tests/route_tests/test_route_blueprint_policy_inventory.py +++ b/functional_tests/route_tests/test_route_blueprint_policy_inventory.py @@ -70,6 +70,7 @@ "enhanced_citations": ("login_required", "user_required"), "external_health": (), "external_no_auth_health": (), + "external_public_documents": (), "frontend_admin_settings": ("login_required", "admin_required"), "frontend_agents": ("login_required", "user_required"), "frontend_terms_of_use": (), diff --git a/functional_tests/route_tests/test_route_unauthenticated_policy_contract.py b/functional_tests/route_tests/test_route_unauthenticated_policy_contract.py index cba53c4a5..f359af9ee 100644 --- a/functional_tests/route_tests/test_route_unauthenticated_policy_contract.py +++ b/functional_tests/route_tests/test_route_unauthenticated_policy_contract.py @@ -44,6 +44,7 @@ EXTERNAL_BEARER_PATH_PREFIXES = ( "/external/public_documents", + "/external/testaccesstoken", ) INBOUND_MCP_BEARER_PATH_PREFIXES = ( diff --git a/functional_tests/test_external_public_documents_route_registration.py b/functional_tests/test_external_public_documents_route_registration.py new file mode 100644 index 000000000..ecefe49d8 --- /dev/null +++ b/functional_tests/test_external_public_documents_route_registration.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +# test_external_public_documents_route_registration.py +""" +Functional test for external public document route registration. +Version: 0.261.062 +Implemented in: 0.261.062 + +This test ensures the bearer-token protected external public document routes +and the access token preflight route are registered, so external bulk-loading +clients keep working. +""" + +import re +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +APP_DIR = ROOT / "application" / "single_app" +APP_FILE = APP_DIR / "app.py" +HEALTH_FILE = APP_DIR / "route_external_health.py" +PUBLIC_DOCS_FILE = APP_DIR / "route_external_public_documents.py" + +BEARER_PROTECTED_ROUTES = [ + ("/external/public_documents", "GET"), + ("/external/public_documents/upload", "POST"), + ("/external/public_documents/", "DELETE"), +] + + +def read_text(path: Path) -> str: + """Return UTF-8 text for a repo file.""" + return path.read_text(encoding="utf-8") + + +def route_block(content: str, path: str, method: str) -> str: + """Return the decorator and signature lines for one route.""" + pattern = re.compile( + r"@bp\.route\('" + re.escape(path) + r"', methods=\['" + method + r"'\]\)(.*?)\n\s*def ", + re.S, + ) + match = pattern.search(content) + if not match: + raise AssertionError(f"Route {method} {path} not found") + return match.group(1) + + +def test_app_registers_external_public_documents() -> bool: + """Validate app.py imports and registers the external public document routes.""" + print("Testing external public document registration in app.py...") + + try: + app_content = read_text(APP_FILE) + required_snippets = [ + "from route_external_public_documents import *", + "register_route_blueprint('external_public_documents', register_route_external_public_documents)", + ] + for snippet in required_snippets: + if snippet not in app_content: + raise AssertionError(f"Missing in app.py: {snippet}") + + print("Passed") + return True + except Exception as ex: + print(f"Test failed: {ex}") + import traceback + traceback.print_exc() + return False + + +def test_external_routes_require_bearer_token() -> bool: + """Validate the preflight and document routes use the bearer-token decorator.""" + print("Testing bearer-token protection on external routes...") + + try: + health_content = read_text(HEALTH_FILE) + docs_content = read_text(PUBLIC_DOCS_FILE) + + preflight = route_block(health_content, "/external/testaccesstoken", "POST") + if "@accesstoken_required" not in preflight: + raise AssertionError("/external/testaccesstoken must use @accesstoken_required") + + for path, method in BEARER_PROTECTED_ROUTES: + block = route_block(docs_content, path, method) + if "@accesstoken_required" not in block: + raise AssertionError(f"{method} {path} must use @accesstoken_required") + + print("Passed") + return True + except Exception as ex: + print(f"Test failed: {ex}") + import traceback + traceback.print_exc() + return False + + +if __name__ == "__main__": + tests = [ + test_app_registers_external_public_documents, + test_external_routes_require_bearer_token, + ] + + results = [] + for test in tests: + print(f"\nRunning {test.__name__}...") + results.append(test()) + + success = all(results) + print(f"\nResults: {sum(results)}/{len(results)} tests passed") + sys.exit(0 if success else 1)