From 0b3b0b5ce876fea4e0c3b43a7fa2b019bddf99e1 Mon Sep 17 00:00:00 2001 From: GareArc Date: Mon, 27 Apr 2026 00:45:10 -0700 Subject: [PATCH] feat(api): retire legacy /v1/* and /console/api device-flow mounts (Phase F) Web and CLI consumers now hit /openapi/v1/* directly, so the dual-mount shims can go: - controllers/oauth_device_sso.py (legacy /v1/oauth/device/sso-* + /v1/device/sso-complete) - controllers/service_api/oauth.py (legacy /v1/oauth/device/*, /v1/me, /v1/oauth/authorizations/self) - controllers/console/auth/oauth_device.py (placeholder for legacy /console/api/oauth/device/{approve,deny}) - the deferred _register_legacy_console_mount() inside openapi/oauth_device.py Imports in controllers/console/__init__.py, controllers/service_api/__init__.py, and extensions/ext_blueprints.py pruned. Tests rewritten to openapi-only. --- api/controllers/console/__init__.py | 2 - api/controllers/console/auth/oauth_device.py | 8 -- api/controllers/oauth_device_sso.py | 50 ---------- api/controllers/openapi/account.py | 6 +- api/controllers/openapi/oauth_device.py | 20 +--- api/controllers/openapi/oauth_device_sso.py | 2 - api/controllers/service_api/__init__.py | 3 +- api/controllers/service_api/oauth.py | 22 ----- api/extensions/ext_blueprints.py | 9 -- .../controllers/openapi/test_account.py | 73 ++++++--------- .../openapi/test_device_approve_deny.py | 49 +++------- .../controllers/openapi/test_device_code.py | 65 ++++--------- .../controllers/openapi/test_device_lookup.py | 38 +++----- .../controllers/openapi/test_device_sso.py | 92 +++++-------------- .../controllers/openapi/test_device_token.py | 30 ++---- 15 files changed, 105 insertions(+), 364 deletions(-) delete mode 100644 api/controllers/console/auth/oauth_device.py delete mode 100644 api/controllers/oauth_device_sso.py delete mode 100644 api/controllers/service_api/oauth.py diff --git a/api/controllers/console/__init__.py b/api/controllers/console/__init__.py index 09224d7d43..980e828945 100644 --- a/api/controllers/console/__init__.py +++ b/api/controllers/console/__init__.py @@ -81,7 +81,6 @@ from .auth import ( forgot_password, login, oauth, - oauth_device, oauth_server, ) @@ -190,7 +189,6 @@ __all__ = [ "models", "notification", "oauth", - "oauth_device", "oauth_server", "ops_trace", "parameter", diff --git a/api/controllers/console/auth/oauth_device.py b/api/controllers/console/auth/oauth_device.py deleted file mode 100644 index 896af0a85a..0000000000 --- a/api/controllers/console/auth/oauth_device.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Placeholder. Legacy /console/api/oauth/device/{approve,deny} mounts -are registered from the canonical openapi handlers in -controllers/openapi/oauth_device/{approve,deny}.py. This file stays -on disk only so controllers/console/__init__.py's -`from .auth import (... oauth_device, ...)` keeps working until -Phase F retires the legacy paths and prunes that import. -""" -from __future__ import annotations diff --git a/api/controllers/oauth_device_sso.py b/api/controllers/oauth_device_sso.py deleted file mode 100644 index 76208e2793..0000000000 --- a/api/controllers/oauth_device_sso.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Legacy /v1/* mounts for SSO-branch device-flow endpoints. Canonical -handlers live in controllers/openapi/oauth_device_sso.py. This file -just re-registers them on the legacy blueprint until Phase F retires -the legacy paths entirely. - -Note: /v1/device/sso-complete (no /oauth/ in the path) is the existing -ACS callback. Its canonical home is /openapi/v1/oauth/device/sso-complete. -IdP-side ACS callback URLs need re-registration before Phase F. -""" -from __future__ import annotations - -from flask import Blueprint - -from controllers.openapi.oauth_device_sso import ( - approval_context, - approve_external, - sso_complete, - sso_initiate, -) -from libs.device_flow_security import attach_anti_framing - -bp = Blueprint("oauth_device_sso", __name__, url_prefix="/v1") -attach_anti_framing(bp) - -# Legacy /v1/* mounts — handlers live in controllers/openapi/oauth_device_sso.py. -# Removed in Phase F. -bp.add_url_rule( - "/oauth/device/sso-initiate", - endpoint="sso_initiate", - view_func=sso_initiate, - methods=["GET"], -) -bp.add_url_rule( - "/device/sso-complete", - endpoint="sso_complete", - view_func=sso_complete, - methods=["GET"], -) -bp.add_url_rule( - "/oauth/device/approval-context", - endpoint="approval_context", - view_func=approval_context, - methods=["GET"], -) -bp.add_url_rule( - "/oauth/device/approve-external", - endpoint="approve_external", - view_func=approve_external, - methods=["POST"], -) diff --git a/api/controllers/openapi/account.py b/api/controllers/openapi/account.py index 97b5fe6ae7..6ce043d49e 100644 --- a/api/controllers/openapi/account.py +++ b/api/controllers/openapi/account.py @@ -1,10 +1,6 @@ """User-scoped account endpoints. /account is the bearer-authed identity read; /account/sessions and /account/sessions/ manage -the user's active OAuth tokens (Phase C steps 11–12). - -The /account class is also registered on the legacy /v1/me path from -service_api/oauth.py until Phase F retires that mount. Likewise -/account/sessions/self is re-mounted at /v1/oauth/authorizations/self. +the user's active OAuth tokens. """ from __future__ import annotations diff --git a/api/controllers/openapi/oauth_device.py b/api/controllers/openapi/oauth_device.py index 457700aa75..48a7d7f8c4 100644 --- a/api/controllers/openapi/oauth_device.py +++ b/api/controllers/openapi/oauth_device.py @@ -10,11 +10,7 @@ sub-groups in one module: POST /oauth/device/approve POST /oauth/device/deny -The five Resource classes are also re-registered on legacy mounts: -service_api_ns at /v1/oauth/device/{code,token,lookup} (from -service_api/oauth.py) and console_ns at /console/api/oauth/device/{approve,deny} -(from the deferred _register_legacy_console_mount() at module bottom). -All legacy mounts retire in Phase F. SSO branch lives in oauth_device_sso.py. +SSO branch lives in oauth_device_sso.py. """ from __future__ import annotations @@ -369,17 +365,3 @@ def _emit_deny_audit(state) -> None: ) -# ========================================================================= -# Legacy console-side mount — deferred import breaks a cycle that would -# form between this module (imports controllers.console.wraps) and -# controllers.console.__init__ (loads .auth.oauth_device). -# ========================================================================= - - -def _register_legacy_console_mount() -> None: - from controllers.console import console_ns - console_ns.add_resource(DeviceApproveApi, "/oauth/device/approve") - console_ns.add_resource(DeviceDenyApi, "/oauth/device/deny") - - -_register_legacy_console_mount() diff --git a/api/controllers/openapi/oauth_device_sso.py b/api/controllers/openapi/oauth_device_sso.py index 9d82b6f591..1b0e55993a 100644 --- a/api/controllers/openapi/oauth_device_sso.py +++ b/api/controllers/openapi/oauth_device_sso.py @@ -8,8 +8,6 @@ EE-only. Browser flow: Function-based (raw @bp.route) rather than Resource classes because the handlers do redirects + cookie kwargs that don't fit the Resource shape. -Same handlers are also re-registered on the legacy /v1/* paths from -controllers/oauth_device_sso.py until Phase F retires the legacy mount. """ from __future__ import annotations diff --git a/api/controllers/service_api/__init__.py b/api/controllers/service_api/__init__.py index 544dfbbfef..4f7f7d9a98 100644 --- a/api/controllers/service_api/__init__.py +++ b/api/controllers/service_api/__init__.py @@ -14,7 +14,7 @@ api = ExternalApi( service_api_ns = Namespace("service_api", description="Service operations", path="/") -from . import index, oauth +from . import index from .app import ( annotation, app, @@ -54,7 +54,6 @@ __all__ = [ "message", "metadata", "models", - "oauth", "rag_pipeline_workflow", "segment", "site", diff --git a/api/controllers/service_api/oauth.py b/api/controllers/service_api/oauth.py deleted file mode 100644 index fb182d423a..0000000000 --- a/api/controllers/service_api/oauth.py +++ /dev/null @@ -1,22 +0,0 @@ -"""Legacy /v1/* mounts for the OAuth bearer + device-flow endpoints. -Canonical handlers live in controllers/openapi/. This file just -re-registers them on service_api_ns until Phase F retires the -legacy paths entirely. -""" -from __future__ import annotations - -from controllers.openapi.account import AccountApi, AccountSessionsSelfApi -from controllers.openapi.oauth_device import ( - OAuthDeviceCodeApi, - OAuthDeviceLookupApi, - OAuthDeviceTokenApi, -) -from controllers.service_api import service_api_ns - -# Legacy /v1/* mounts — handlers live in controllers/openapi/. -# Removed in Phase F. -service_api_ns.add_resource(OAuthDeviceCodeApi, "/oauth/device/code") -service_api_ns.add_resource(OAuthDeviceTokenApi, "/oauth/device/token") -service_api_ns.add_resource(OAuthDeviceLookupApi, "/oauth/device/lookup") -service_api_ns.add_resource(AccountApi, "/me") -service_api_ns.add_resource(AccountSessionsSelfApi, "/oauth/authorizations/self") diff --git a/api/extensions/ext_blueprints.py b/api/extensions/ext_blueprints.py index a3bcec15e0..92131a04ab 100644 --- a/api/extensions/ext_blueprints.py +++ b/api/extensions/ext_blueprints.py @@ -109,15 +109,6 @@ def init_app(app: DifyApp): app.register_blueprint(inner_api_bp) app.register_blueprint(mcp_bp) - # SSO-branch device-flow routes. No CORS config — these endpoints are - # user-interactive (same-origin browser traffic) and cookie-authed; - # allowing cross-origin would defeat the SameSite=Lax cookie's purpose. - # Gated on ENABLE_OAUTH_BEARER: without the bearer authenticator, tokens - # minted here cannot be validated, so skip the blueprint entirely. - if dify_config.ENABLE_OAUTH_BEARER: - from controllers.oauth_device_sso import bp as oauth_device_sso_bp - app.register_blueprint(oauth_device_sso_bp) - # Register trigger blueprint with CORS for webhook calls _apply_cors_once( trigger_bp, diff --git a/api/tests/unit_tests/controllers/openapi/test_account.py b/api/tests/unit_tests/controllers/openapi/test_account.py index ada626f89b..69ec70a4ac 100644 --- a/api/tests/unit_tests/controllers/openapi/test_account.py +++ b/api/tests/unit_tests/controllers/openapi/test_account.py @@ -1,7 +1,4 @@ -"""Phase C steps 9–10: identity + self-revoke moved to /openapi/v1/account. -Legacy /v1/me + /v1/oauth/authorizations/self stay mounted via -re-registration in service_api/oauth.py. -""" +"""User-scoped identity + session endpoints under /openapi/v1/account.""" import builtins import pytest @@ -15,17 +12,15 @@ from controllers.openapi.account import ( AccountSessionsApi, AccountSessionsSelfApi, ) -from controllers.service_api import bp as service_api_bp if not hasattr(builtins, "MethodView"): builtins.MethodView = MethodView # type: ignore[attr-defined] @pytest.fixture -def dual_app() -> Flask: +def openapi_app() -> Flask: app = Flask(__name__) app.config["TESTING"] = True - app.register_blueprint(service_api_bp) app.register_blueprint(openapi_bp) return app @@ -34,71 +29,55 @@ def _rule(app: Flask, path: str): return next(r for r in app.url_map.iter_rules() if r.rule == path) -def test_account_route_registered(dual_app: Flask): - rules = {r.rule for r in dual_app.url_map.iter_rules()} +def test_account_route_registered(openapi_app: Flask): + rules = {r.rule for r in openapi_app.url_map.iter_rules()} assert "/openapi/v1/account" in rules -def test_legacy_me_route_registered(dual_app: Flask): - rules = {r.rule for r in dual_app.url_map.iter_rules()} - assert "/v1/me" in rules +def test_account_dispatches_to_class(openapi_app: Flask): + rule = _rule(openapi_app, "/openapi/v1/account") + assert openapi_app.view_functions[rule.endpoint].view_class is AccountApi -def test_account_and_me_dispatch_to_same_class(dual_app: Flask): - new = _rule(dual_app, "/openapi/v1/account") - legacy = _rule(dual_app, "/v1/me") - assert dual_app.view_functions[new.endpoint].view_class is AccountApi - assert dual_app.view_functions[legacy.endpoint].view_class is AccountApi - - -def test_account_sessions_self_route_registered(dual_app: Flask): - rules = {r.rule for r in dual_app.url_map.iter_rules()} +def test_account_sessions_self_route_registered(openapi_app: Flask): + rules = {r.rule for r in openapi_app.url_map.iter_rules()} assert "/openapi/v1/account/sessions/self" in rules -def test_legacy_oauth_authorizations_self_route_registered(dual_app: Flask): - rules = {r.rule for r in dual_app.url_map.iter_rules()} - assert "/v1/oauth/authorizations/self" in rules +def test_sessions_self_dispatches_to_class(openapi_app: Flask): + rule = _rule(openapi_app, "/openapi/v1/account/sessions/self") + assert openapi_app.view_functions[rule.endpoint].view_class is AccountSessionsSelfApi -def test_sessions_self_paths_dispatch_to_same_class(dual_app: Flask): - new = _rule(dual_app, "/openapi/v1/account/sessions/self") - legacy = _rule(dual_app, "/v1/oauth/authorizations/self") - assert dual_app.view_functions[new.endpoint].view_class is AccountSessionsSelfApi - assert dual_app.view_functions[legacy.endpoint].view_class is AccountSessionsSelfApi - - -def test_account_methods(dual_app: Flask): - rule = _rule(dual_app, "/openapi/v1/account") +def test_account_methods(openapi_app: Flask): + rule = _rule(openapi_app, "/openapi/v1/account") assert "GET" in rule.methods -def test_sessions_self_methods(dual_app: Flask): - rule = _rule(dual_app, "/openapi/v1/account/sessions/self") +def test_sessions_self_methods(openapi_app: Flask): + rule = _rule(openapi_app, "/openapi/v1/account/sessions/self") assert "DELETE" in rule.methods -def test_sessions_list_route_registered(dual_app: Flask): - """GET /openapi/v1/account/sessions is new — no /v1/ equivalent.""" - rules = {r.rule for r in dual_app.url_map.iter_rules()} +def test_sessions_list_route_registered(openapi_app: Flask): + rules = {r.rule for r in openapi_app.url_map.iter_rules()} assert "/openapi/v1/account/sessions" in rules -def test_sessions_list_dispatches_to_sessions_api(dual_app: Flask): - rule = _rule(dual_app, "/openapi/v1/account/sessions") - assert dual_app.view_functions[rule.endpoint].view_class is AccountSessionsApi +def test_sessions_list_dispatches_to_sessions_api(openapi_app: Flask): + rule = _rule(openapi_app, "/openapi/v1/account/sessions") + assert openapi_app.view_functions[rule.endpoint].view_class is AccountSessionsApi assert "GET" in rule.methods -def test_session_by_id_route_registered(dual_app: Flask): - """DELETE /openapi/v1/account/sessions/ is new — no /v1/ equivalent.""" - rules = {r.rule for r in dual_app.url_map.iter_rules()} +def test_session_by_id_route_registered(openapi_app: Flask): + rules = {r.rule for r in openapi_app.url_map.iter_rules()} assert "/openapi/v1/account/sessions/" in rules -def test_session_by_id_dispatches_to_correct_class(dual_app: Flask): - rule = _rule(dual_app, "/openapi/v1/account/sessions/") - assert dual_app.view_functions[rule.endpoint].view_class is AccountSessionByIdApi +def test_session_by_id_dispatches_to_correct_class(openapi_app: Flask): + rule = _rule(openapi_app, "/openapi/v1/account/sessions/") + assert openapi_app.view_functions[rule.endpoint].view_class is AccountSessionByIdApi assert "DELETE" in rule.methods diff --git a/api/tests/unit_tests/controllers/openapi/test_device_approve_deny.py b/api/tests/unit_tests/controllers/openapi/test_device_approve_deny.py index 11b42e4ae0..552a8164e8 100644 --- a/api/tests/unit_tests/controllers/openapi/test_device_approve_deny.py +++ b/api/tests/unit_tests/controllers/openapi/test_device_approve_deny.py @@ -1,14 +1,10 @@ -"""Phase D steps 13-14: device-flow approve/deny lifted to /openapi/v1. -Legacy /console/api/oauth/device/{approve,deny} stays mounted via -re-registration in console/auth/oauth_device.py. -""" +"""Account-branch device-flow approve/deny under /openapi/v1.""" import builtins import pytest from flask import Flask from flask.views import MethodView -from controllers.console import bp as console_bp from controllers.openapi import bp as openapi_bp from controllers.openapi.oauth_device import DeviceApproveApi, DeviceDenyApi @@ -17,10 +13,9 @@ if not hasattr(builtins, "MethodView"): @pytest.fixture -def dual_app() -> Flask: +def openapi_app() -> Flask: app = Flask(__name__) app.config["TESTING"] = True - app.register_blueprint(console_bp) app.register_blueprint(openapi_bp) return app @@ -29,42 +24,28 @@ def _rule(app: Flask, path: str): return next(r for r in app.url_map.iter_rules() if r.rule == path) -def test_openapi_approve_route_registered(dual_app: Flask): - rules = {r.rule for r in dual_app.url_map.iter_rules()} +def test_approve_route_registered(openapi_app: Flask): + rules = {r.rule for r in openapi_app.url_map.iter_rules()} assert "/openapi/v1/oauth/device/approve" in rules -def test_legacy_console_approve_route_registered(dual_app: Flask): - rules = {r.rule for r in dual_app.url_map.iter_rules()} - assert "/console/api/oauth/device/approve" in rules - - -def test_approve_paths_dispatch_to_same_class(dual_app: Flask): - new = _rule(dual_app, "/openapi/v1/oauth/device/approve") - legacy = _rule(dual_app, "/console/api/oauth/device/approve") - assert dual_app.view_functions[new.endpoint].view_class is DeviceApproveApi - assert dual_app.view_functions[legacy.endpoint].view_class is DeviceApproveApi - - -def test_openapi_deny_route_registered(dual_app: Flask): - rules = {r.rule for r in dual_app.url_map.iter_rules()} +def test_deny_route_registered(openapi_app: Flask): + rules = {r.rule for r in openapi_app.url_map.iter_rules()} assert "/openapi/v1/oauth/device/deny" in rules -def test_legacy_console_deny_route_registered(dual_app: Flask): - rules = {r.rule for r in dual_app.url_map.iter_rules()} - assert "/console/api/oauth/device/deny" in rules +def test_approve_dispatches_to_class(openapi_app: Flask): + rule = _rule(openapi_app, "/openapi/v1/oauth/device/approve") + assert openapi_app.view_functions[rule.endpoint].view_class is DeviceApproveApi -def test_deny_paths_dispatch_to_same_class(dual_app: Flask): - new = _rule(dual_app, "/openapi/v1/oauth/device/deny") - legacy = _rule(dual_app, "/console/api/oauth/device/deny") - assert dual_app.view_functions[new.endpoint].view_class is DeviceDenyApi - assert dual_app.view_functions[legacy.endpoint].view_class is DeviceDenyApi +def test_deny_dispatches_to_class(openapi_app: Flask): + rule = _rule(openapi_app, "/openapi/v1/oauth/device/deny") + assert openapi_app.view_functions[rule.endpoint].view_class is DeviceDenyApi -def test_approve_and_deny_methods(dual_app: Flask): - approve = _rule(dual_app, "/openapi/v1/oauth/device/approve") - deny = _rule(dual_app, "/openapi/v1/oauth/device/deny") +def test_approve_and_deny_methods(openapi_app: Flask): + approve = _rule(openapi_app, "/openapi/v1/oauth/device/approve") + deny = _rule(openapi_app, "/openapi/v1/oauth/device/deny") assert "POST" in approve.methods assert "POST" in deny.methods diff --git a/api/tests/unit_tests/controllers/openapi/test_device_code.py b/api/tests/unit_tests/controllers/openapi/test_device_code.py index 79a65fa9f1..374e5b03e3 100644 --- a/api/tests/unit_tests/controllers/openapi/test_device_code.py +++ b/api/tests/unit_tests/controllers/openapi/test_device_code.py @@ -1,10 +1,8 @@ -"""Phase B step 6: POST /openapi/v1/oauth/device/code is the canonical -RFC 8628 device authorization endpoint. The legacy /v1/oauth/device/code -mount stays until Phase F; both paths must dispatch to the same class. +"""POST /openapi/v1/oauth/device/code is the canonical RFC 8628 device +authorization endpoint. -Tests verify URL routing and re-registration without invoking the -handler — invoking would require Redis, which the unit-test runtime -does not initialise. +Tests verify URL routing without invoking the handler — invoking would +require Redis, which the unit-test runtime does not initialise. """ import builtins @@ -14,65 +12,36 @@ from flask.views import MethodView from controllers.openapi import bp as openapi_bp from controllers.openapi.oauth_device import OAuthDeviceCodeApi -from controllers.service_api import bp as service_api_bp if not hasattr(builtins, "MethodView"): builtins.MethodView = MethodView # type: ignore[attr-defined] @pytest.fixture -def dual_app() -> Flask: - """Both blueprints registered, mirroring production layout.""" +def openapi_app() -> Flask: app = Flask(__name__) app.config["TESTING"] = True - app.register_blueprint(service_api_bp) app.register_blueprint(openapi_bp) return app -def test_openapi_route_registered(dual_app: Flask): - rules = {r.rule for r in dual_app.url_map.iter_rules()} +def test_openapi_route_registered(openapi_app: Flask): + rules = {r.rule for r in openapi_app.url_map.iter_rules()} assert "/openapi/v1/oauth/device/code" in rules -def test_legacy_v1_route_still_registered(dual_app: Flask): - """service_api/oauth.py re-registers the lifted class on /v1/.""" - rules = {r.rule for r in dual_app.url_map.iter_rules()} - assert "/v1/oauth/device/code" in rules - - -def test_both_paths_dispatch_to_same_class(dual_app: Flask): - """Single source of truth — no duplicated handler logic.""" - new = next( - r for r in dual_app.url_map.iter_rules() if r.rule == "/openapi/v1/oauth/device/code" +def test_route_dispatches_to_class(openapi_app: Flask): + rule = next( + r for r in openapi_app.url_map.iter_rules() if r.rule == "/openapi/v1/oauth/device/code" ) - legacy = next( - r for r in dual_app.url_map.iter_rules() if r.rule == "/v1/oauth/device/code" + assert openapi_app.view_functions[rule.endpoint].view_class is OAuthDeviceCodeApi + + +def test_route_accepts_post(openapi_app: Flask): + rule = next( + r for r in openapi_app.url_map.iter_rules() if r.rule == "/openapi/v1/oauth/device/code" ) - - new_view = dual_app.view_functions[new.endpoint] - legacy_view = dual_app.view_functions[legacy.endpoint] - # Flask-RESTX wraps Resource classes in a `view_class` attribute. - assert new_view.view_class is OAuthDeviceCodeApi - assert legacy_view.view_class is OAuthDeviceCodeApi - - -def test_route_accepts_post_and_options(dual_app: Flask): - new = next( - r for r in dual_app.url_map.iter_rules() if r.rule == "/openapi/v1/oauth/device/code" - ) - legacy = next( - r for r in dual_app.url_map.iter_rules() if r.rule == "/v1/oauth/device/code" - ) - assert "POST" in new.methods - assert "POST" in legacy.methods - - -def test_handler_class_imports_match(): - """service_api re-uses the openapi class, not a copy.""" - from controllers.service_api import oauth as service_api_oauth - - assert service_api_oauth.OAuthDeviceCodeApi is OAuthDeviceCodeApi + assert "POST" in rule.methods def test_known_client_ids_default_includes_difyctl(): diff --git a/api/tests/unit_tests/controllers/openapi/test_device_lookup.py b/api/tests/unit_tests/controllers/openapi/test_device_lookup.py index 7d1ae3b640..5a56ae5fc5 100644 --- a/api/tests/unit_tests/controllers/openapi/test_device_lookup.py +++ b/api/tests/unit_tests/controllers/openapi/test_device_lookup.py @@ -1,7 +1,4 @@ -"""Phase B step 8: GET /openapi/v1/oauth/device/lookup mounted via the -canonical class. Legacy /v1/oauth/device/lookup re-registered. Both -paths must dispatch to the same class. -""" +"""GET /openapi/v1/oauth/device/lookup is the canonical user-code lookup.""" import builtins import pytest @@ -10,44 +7,33 @@ from flask.views import MethodView from controllers.openapi import bp as openapi_bp from controllers.openapi.oauth_device import OAuthDeviceLookupApi -from controllers.service_api import bp as service_api_bp if not hasattr(builtins, "MethodView"): builtins.MethodView = MethodView # type: ignore[attr-defined] @pytest.fixture -def dual_app() -> Flask: +def openapi_app() -> Flask: app = Flask(__name__) app.config["TESTING"] = True - app.register_blueprint(service_api_bp) app.register_blueprint(openapi_bp) return app -def test_openapi_route_registered(dual_app: Flask): - rules = {r.rule for r in dual_app.url_map.iter_rules()} +def test_openapi_route_registered(openapi_app: Flask): + rules = {r.rule for r in openapi_app.url_map.iter_rules()} assert "/openapi/v1/oauth/device/lookup" in rules -def test_legacy_v1_route_registered(dual_app: Flask): - rules = {r.rule for r in dual_app.url_map.iter_rules()} - assert "/v1/oauth/device/lookup" in rules - - -def test_both_paths_dispatch_to_same_class(dual_app: Flask): - new = next( - r for r in dual_app.url_map.iter_rules() if r.rule == "/openapi/v1/oauth/device/lookup" +def test_route_dispatches_to_class(openapi_app: Flask): + rule = next( + r for r in openapi_app.url_map.iter_rules() if r.rule == "/openapi/v1/oauth/device/lookup" ) - legacy = next( - r for r in dual_app.url_map.iter_rules() if r.rule == "/v1/oauth/device/lookup" - ) - assert dual_app.view_functions[new.endpoint].view_class is OAuthDeviceLookupApi - assert dual_app.view_functions[legacy.endpoint].view_class is OAuthDeviceLookupApi + assert openapi_app.view_functions[rule.endpoint].view_class is OAuthDeviceLookupApi -def test_route_accepts_get(dual_app: Flask): - new = next( - r for r in dual_app.url_map.iter_rules() if r.rule == "/openapi/v1/oauth/device/lookup" +def test_route_accepts_get(openapi_app: Flask): + rule = next( + r for r in openapi_app.url_map.iter_rules() if r.rule == "/openapi/v1/oauth/device/lookup" ) - assert "GET" in new.methods + assert "GET" in rule.methods diff --git a/api/tests/unit_tests/controllers/openapi/test_device_sso.py b/api/tests/unit_tests/controllers/openapi/test_device_sso.py index bdd337c62b..b40e6c1689 100644 --- a/api/tests/unit_tests/controllers/openapi/test_device_sso.py +++ b/api/tests/unit_tests/controllers/openapi/test_device_sso.py @@ -1,14 +1,10 @@ -"""Phase D steps 15-16: SSO-branch device-flow endpoints lifted to -/openapi/v1/oauth/device/. Legacy /v1/* mounts stay via re-registration -in controllers/oauth_device_sso.py. -""" +"""SSO-branch device-flow endpoints under /openapi/v1/oauth/device/.""" import builtins import pytest from flask import Flask from flask.views import MethodView -from controllers.oauth_device_sso import bp as legacy_sso_bp from controllers.openapi import bp as openapi_bp from controllers.openapi.oauth_device_sso import ( approval_context, @@ -22,10 +18,9 @@ if not hasattr(builtins, "MethodView"): @pytest.fixture -def dual_app() -> Flask: +def openapi_app() -> Flask: app = Flask(__name__) app.config["TESTING"] = True - app.register_blueprint(legacy_sso_bp) app.register_blueprint(openapi_bp) return app @@ -34,88 +29,49 @@ def _rule(app: Flask, path: str): return next(r for r in app.url_map.iter_rules() if r.rule == path) -# Canonical /openapi/v1/* paths - - -def test_sso_initiate_canonical_path_registered(dual_app: Flask): - rules = {r.rule for r in dual_app.url_map.iter_rules()} +def test_sso_initiate_registered(openapi_app: Flask): + rules = {r.rule for r in openapi_app.url_map.iter_rules()} assert "/openapi/v1/oauth/device/sso-initiate" in rules -def test_sso_complete_canonical_path_registered(dual_app: Flask): - rules = {r.rule for r in dual_app.url_map.iter_rules()} +def test_sso_complete_registered(openapi_app: Flask): + rules = {r.rule for r in openapi_app.url_map.iter_rules()} assert "/openapi/v1/oauth/device/sso-complete" in rules -def test_approval_context_canonical_path_registered(dual_app: Flask): - rules = {r.rule for r in dual_app.url_map.iter_rules()} +def test_approval_context_registered(openapi_app: Flask): + rules = {r.rule for r in openapi_app.url_map.iter_rules()} assert "/openapi/v1/oauth/device/approval-context" in rules -def test_approve_external_canonical_path_registered(dual_app: Flask): - rules = {r.rule for r in dual_app.url_map.iter_rules()} +def test_approve_external_registered(openapi_app: Flask): + rules = {r.rule for r in openapi_app.url_map.iter_rules()} assert "/openapi/v1/oauth/device/approve-external" in rules -# Legacy /v1/* paths +def test_sso_initiate_dispatches_to_function(openapi_app: Flask): + rule = _rule(openapi_app, "/openapi/v1/oauth/device/sso-initiate") + assert openapi_app.view_functions[rule.endpoint] is sso_initiate -def test_sso_initiate_legacy_path_registered(dual_app: Flask): - rules = {r.rule for r in dual_app.url_map.iter_rules()} - assert "/v1/oauth/device/sso-initiate" in rules +def test_sso_complete_dispatches_to_function(openapi_app: Flask): + rule = _rule(openapi_app, "/openapi/v1/oauth/device/sso-complete") + assert openapi_app.view_functions[rule.endpoint] is sso_complete -def test_sso_complete_legacy_path_registered(dual_app: Flask): - """Legacy lived under /v1/device/, not /v1/oauth/device/.""" - rules = {r.rule for r in dual_app.url_map.iter_rules()} - assert "/v1/device/sso-complete" in rules +def test_approval_context_dispatches_to_function(openapi_app: Flask): + rule = _rule(openapi_app, "/openapi/v1/oauth/device/approval-context") + assert openapi_app.view_functions[rule.endpoint] is approval_context -def test_approval_context_legacy_path_registered(dual_app: Flask): - rules = {r.rule for r in dual_app.url_map.iter_rules()} - assert "/v1/oauth/device/approval-context" in rules - - -def test_approve_external_legacy_path_registered(dual_app: Flask): - rules = {r.rule for r in dual_app.url_map.iter_rules()} - assert "/v1/oauth/device/approve-external" in rules - - -# Both paths point at the same view function - - -def test_sso_initiate_dual_mount_same_function(dual_app: Flask): - new_rule = _rule(dual_app, "/openapi/v1/oauth/device/sso-initiate") - legacy_rule = _rule(dual_app, "/v1/oauth/device/sso-initiate") - assert dual_app.view_functions[new_rule.endpoint] is sso_initiate - assert dual_app.view_functions[legacy_rule.endpoint] is sso_initiate - - -def test_sso_complete_dual_mount_same_function(dual_app: Flask): - new_rule = _rule(dual_app, "/openapi/v1/oauth/device/sso-complete") - legacy_rule = _rule(dual_app, "/v1/device/sso-complete") - assert dual_app.view_functions[new_rule.endpoint] is sso_complete - assert dual_app.view_functions[legacy_rule.endpoint] is sso_complete - - -def test_approval_context_dual_mount_same_function(dual_app: Flask): - new_rule = _rule(dual_app, "/openapi/v1/oauth/device/approval-context") - legacy_rule = _rule(dual_app, "/v1/oauth/device/approval-context") - assert dual_app.view_functions[new_rule.endpoint] is approval_context - assert dual_app.view_functions[legacy_rule.endpoint] is approval_context - - -def test_approve_external_dual_mount_same_function(dual_app: Flask): - new_rule = _rule(dual_app, "/openapi/v1/oauth/device/approve-external") - legacy_rule = _rule(dual_app, "/v1/oauth/device/approve-external") - assert dual_app.view_functions[new_rule.endpoint] is approve_external - assert dual_app.view_functions[legacy_rule.endpoint] is approve_external +def test_approve_external_dispatches_to_function(openapi_app: Flask): + rule = _rule(openapi_app, "/openapi/v1/oauth/device/approve-external") + assert openapi_app.view_functions[rule.endpoint] is approve_external def test_sso_complete_idp_callback_url_uses_canonical_path(): - """sso_initiate hardcodes the IdP callback URL — must point to the - canonical /openapi/v1/ path so IdPs are configured against the - forward-looking ACS endpoint, not the legacy alias. + """sso_initiate hardcodes the IdP callback URL — must point at the + canonical /openapi/v1/ path so IdP-side ACS configuration matches. """ from controllers.openapi import oauth_device_sso diff --git a/api/tests/unit_tests/controllers/openapi/test_device_token.py b/api/tests/unit_tests/controllers/openapi/test_device_token.py index 31d769314b..6a9577637d 100644 --- a/api/tests/unit_tests/controllers/openapi/test_device_token.py +++ b/api/tests/unit_tests/controllers/openapi/test_device_token.py @@ -1,7 +1,4 @@ -"""Phase B step 7: POST /openapi/v1/oauth/device/token mounted via the -canonical class. Legacy /v1/oauth/device/token re-registered. Both -paths must dispatch to the same class. -""" +"""POST /openapi/v1/oauth/device/token is the canonical poll endpoint.""" import builtins import pytest @@ -10,37 +7,26 @@ from flask.views import MethodView from controllers.openapi import bp as openapi_bp from controllers.openapi.oauth_device import OAuthDeviceTokenApi -from controllers.service_api import bp as service_api_bp if not hasattr(builtins, "MethodView"): builtins.MethodView = MethodView # type: ignore[attr-defined] @pytest.fixture -def dual_app() -> Flask: +def openapi_app() -> Flask: app = Flask(__name__) app.config["TESTING"] = True - app.register_blueprint(service_api_bp) app.register_blueprint(openapi_bp) return app -def test_openapi_route_registered(dual_app: Flask): - rules = {r.rule for r in dual_app.url_map.iter_rules()} +def test_openapi_route_registered(openapi_app: Flask): + rules = {r.rule for r in openapi_app.url_map.iter_rules()} assert "/openapi/v1/oauth/device/token" in rules -def test_legacy_v1_route_registered(dual_app: Flask): - rules = {r.rule for r in dual_app.url_map.iter_rules()} - assert "/v1/oauth/device/token" in rules - - -def test_both_paths_dispatch_to_same_class(dual_app: Flask): - new = next( - r for r in dual_app.url_map.iter_rules() if r.rule == "/openapi/v1/oauth/device/token" +def test_route_dispatches_to_class(openapi_app: Flask): + rule = next( + r for r in openapi_app.url_map.iter_rules() if r.rule == "/openapi/v1/oauth/device/token" ) - legacy = next( - r for r in dual_app.url_map.iter_rules() if r.rule == "/v1/oauth/device/token" - ) - assert dual_app.view_functions[new.endpoint].view_class is OAuthDeviceTokenApi - assert dual_app.view_functions[legacy.endpoint].view_class is OAuthDeviceTokenApi + assert openapi_app.view_functions[rule.endpoint].view_class is OAuthDeviceTokenApi