mirror of
https://github.com/langgenius/dify.git
synced 2026-11-11 13:51:25 +08:00
590 lines
24 KiB
Python
590 lines
24 KiB
Python
import builtins
|
|
from collections.abc import Callable
|
|
from datetime import UTC, datetime
|
|
from http.cookies import SimpleCookie
|
|
from types import SimpleNamespace
|
|
from unittest.mock import Mock, create_autospec
|
|
|
|
import pytest
|
|
from flask import Flask
|
|
from flask.views import MethodView
|
|
|
|
from controllers.console import flask_admission, wraps
|
|
from controllers.openapi import bp as openapi_bp
|
|
from controllers.openapi.oauth_device import (
|
|
DeviceApproveApi,
|
|
DeviceDenyApi,
|
|
OAuthDeviceCodeApi,
|
|
OAuthDeviceLookupApi,
|
|
OAuthDeviceTokenApi,
|
|
_error_response,
|
|
)
|
|
from libs.helper import RateLimiter
|
|
from libs.login import AccountWithTenant
|
|
from machinery.context import RequestContext
|
|
from models.account import Account, AccountStatus
|
|
from services.oauth_device_application_service import OAuthDeviceApplicationService
|
|
from services.oauth_device_contracts import (
|
|
AccessDeniedError,
|
|
AlreadyResolvedError,
|
|
ApprovalInProgressError,
|
|
ApprovalOutcomeUnknownError,
|
|
ApprovalSessionConsumedError,
|
|
AuthorizationPendingError,
|
|
DeviceApprovalContext,
|
|
DeviceAuthorization,
|
|
DeviceLookup,
|
|
DeviceMutation,
|
|
DeviceRequestContext,
|
|
DeviceSSOCompletion,
|
|
DeviceSSOInitiation,
|
|
DeviceStateLostError,
|
|
ExpiredOrUnknownError,
|
|
ExpiredTokenError,
|
|
ExternalApprovalCSRFError,
|
|
ExternalApprovalRateLimitError,
|
|
ExternalIdentityConflictError,
|
|
ExternalUserCodeMismatchError,
|
|
ExternalUserCodeNotFoundError,
|
|
InvalidApprovalSessionError,
|
|
InvalidUserCodeError,
|
|
OAuthDeviceError,
|
|
OAuthDeviceSSOConfigurationError,
|
|
OAuthDeviceSSOInitiationError,
|
|
PollTooFastError,
|
|
UnsupportedClientError,
|
|
)
|
|
|
|
"""Account-branch device-flow approve/deny under /openapi/v1."""
|
|
|
|
|
|
if not hasattr(builtins, "MethodView"):
|
|
builtins.MethodView = MethodView # type: ignore[attr-defined]
|
|
|
|
|
|
def _rule(app: Flask, path: str):
|
|
return next(r for r in app.url_map.iter_rules() if r.rule == path)
|
|
|
|
|
|
class TestDeviceApproveDenyCases:
|
|
@staticmethod
|
|
@pytest.fixture
|
|
def openapi_app() -> Flask:
|
|
app = Flask(__name__)
|
|
app.config["TESTING"] = True
|
|
app.register_blueprint(openapi_bp)
|
|
return app
|
|
|
|
@staticmethod
|
|
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
|
|
|
|
@staticmethod
|
|
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
|
|
|
|
@staticmethod
|
|
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
|
|
|
|
@staticmethod
|
|
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
|
|
|
|
@staticmethod
|
|
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
|
|
|
|
@staticmethod
|
|
def test_unknown_approval_outcome_is_retryable() -> None:
|
|
body, status = _error_response(ApprovalOutcomeUnknownError())
|
|
|
|
assert status == 503
|
|
assert body == {"error": "approval_outcome_unknown"}
|
|
|
|
|
|
"""POST /openapi/v1/oauth/device/code is the canonical RFC 8628 device
|
|
authorization endpoint.
|
|
|
|
Tests verify URL routing without invoking the handler — invoking would
|
|
require Redis, which the unit-test runtime does not initialise.
|
|
"""
|
|
|
|
|
|
if not hasattr(builtins, "MethodView"):
|
|
builtins.MethodView = MethodView # type: ignore[attr-defined]
|
|
|
|
|
|
class TestDeviceCodeCases:
|
|
@staticmethod
|
|
@pytest.fixture
|
|
def openapi_app() -> Flask:
|
|
app = Flask(__name__)
|
|
app.config["TESTING"] = True
|
|
app.register_blueprint(openapi_bp)
|
|
return app
|
|
|
|
@staticmethod
|
|
def test_openapi_route_registered(openapi_app: Flask) -> None:
|
|
rules = {r.rule for r in openapi_app.url_map.iter_rules()}
|
|
assert "/openapi/v1/oauth/device/code" in rules
|
|
|
|
@staticmethod
|
|
def test_route_dispatches_to_class(openapi_app: Flask) -> None:
|
|
rule = next(r for r in openapi_app.url_map.iter_rules() if r.rule == "/openapi/v1/oauth/device/code")
|
|
view = openapi_app.view_functions[rule.endpoint]
|
|
assert hasattr(view, "view_class")
|
|
assert view.view_class is OAuthDeviceCodeApi
|
|
|
|
@staticmethod
|
|
def test_route_accepts_post(openapi_app: Flask) -> None:
|
|
rule = next(r for r in openapi_app.url_map.iter_rules() if r.rule == "/openapi/v1/oauth/device/code")
|
|
assert rule.methods is not None
|
|
assert "POST" in rule.methods
|
|
|
|
@staticmethod
|
|
def test_known_client_ids_default_includes_difyctl() -> None:
|
|
from configs import dify_config
|
|
|
|
assert "difyctl" in dify_config.OPENAPI_KNOWN_CLIENT_IDS
|
|
|
|
|
|
"""GET /openapi/v1/oauth/device/lookup is the canonical user-code lookup."""
|
|
|
|
|
|
if not hasattr(builtins, "MethodView"):
|
|
builtins.MethodView = MethodView # type: ignore[attr-defined]
|
|
|
|
|
|
class TestDeviceLookupCases:
|
|
@staticmethod
|
|
@pytest.fixture
|
|
def openapi_app() -> Flask:
|
|
app = Flask(__name__)
|
|
app.config["TESTING"] = True
|
|
app.register_blueprint(openapi_bp)
|
|
return app
|
|
|
|
@staticmethod
|
|
def test_openapi_route_registered(openapi_app: Flask) -> None:
|
|
rules = {r.rule for r in openapi_app.url_map.iter_rules()}
|
|
assert "/openapi/v1/oauth/device/lookup" in rules
|
|
|
|
@staticmethod
|
|
def test_route_dispatches_to_class(openapi_app: Flask) -> None:
|
|
rule = next(r for r in openapi_app.url_map.iter_rules() if r.rule == "/openapi/v1/oauth/device/lookup")
|
|
view = openapi_app.view_functions[rule.endpoint]
|
|
assert hasattr(view, "view_class")
|
|
assert view.view_class is OAuthDeviceLookupApi
|
|
|
|
@staticmethod
|
|
def test_route_accepts_get(openapi_app: Flask) -> None:
|
|
rule = next(r for r in openapi_app.url_map.iter_rules() if r.rule == "/openapi/v1/oauth/device/lookup")
|
|
assert rule.methods is not None
|
|
assert "GET" in rule.methods
|
|
|
|
|
|
"""POST /openapi/v1/oauth/device/token is the canonical poll endpoint."""
|
|
|
|
|
|
if not hasattr(builtins, "MethodView"):
|
|
builtins.MethodView = MethodView # type: ignore[attr-defined]
|
|
|
|
|
|
class TestDeviceTokenCases:
|
|
@staticmethod
|
|
@pytest.fixture
|
|
def openapi_app() -> Flask:
|
|
app = Flask(__name__)
|
|
app.config["TESTING"] = True
|
|
app.register_blueprint(openapi_bp)
|
|
return app
|
|
|
|
@staticmethod
|
|
def test_openapi_route_registered(openapi_app: Flask) -> None:
|
|
rules = {r.rule for r in openapi_app.url_map.iter_rules()}
|
|
assert "/openapi/v1/oauth/device/token" in rules
|
|
|
|
@staticmethod
|
|
def test_route_dispatches_to_class(openapi_app: Flask) -> None:
|
|
rule = next(r for r in openapi_app.url_map.iter_rules() if r.rule == "/openapi/v1/oauth/device/token")
|
|
view = openapi_app.view_functions[rule.endpoint]
|
|
assert hasattr(view, "view_class")
|
|
assert view.view_class is OAuthDeviceTokenApi
|
|
|
|
|
|
"""Device-flow HTTP contracts with application services isolated at their public boundary."""
|
|
|
|
|
|
_PREFIX = "/openapi/v1/oauth/device"
|
|
_COOKIE = "device_approval_grant"
|
|
|
|
|
|
class TestOauthDeviceHttpCases:
|
|
@staticmethod
|
|
@pytest.fixture
|
|
def device_service(
|
|
openapi_app: Flask, monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None]
|
|
) -> Mock:
|
|
service = create_autospec(OAuthDeviceApplicationService, instance=True)
|
|
openapi_app.extensions["application_services"] = SimpleNamespace(
|
|
oauth_device=service,
|
|
feature_queries=SimpleNamespace(has_valid_enterprise_license=lambda: True),
|
|
)
|
|
# Supply an authenticated account while retaining the real admission/context wrapper.
|
|
account = Account(name="Ada", email="ada@example.com", status=AccountStatus.ACTIVE)
|
|
account.id = "account-1"
|
|
account_with_tenant = AccountWithTenant(account, "workspace-1")
|
|
config_overrides(
|
|
LOGIN_DISABLED=True,
|
|
ENABLE_OAUTH_BEARER=True,
|
|
CONSOLE_API_URL="https://api.example",
|
|
CONSOLE_WEB_URL="https://console.example",
|
|
)
|
|
monkeypatch.setattr(wraps, "_is_setup_completed", lambda: True)
|
|
monkeypatch.setattr(wraps, "current_account_with_tenant", lambda: account_with_tenant)
|
|
monkeypatch.setattr(flask_admission, "current_account_with_tenant", lambda: account_with_tenant)
|
|
monkeypatch.setattr(RateLimiter, "is_rate_limited", lambda *_args: False)
|
|
monkeypatch.setattr(RateLimiter, "increment_rate_limit", lambda *_args: None)
|
|
return service
|
|
|
|
@staticmethod
|
|
def test_device_code_serializes_authorization_and_passes_request_origin(
|
|
openapi_app: Flask, device_service: Mock
|
|
) -> None:
|
|
device_service.start.return_value = DeviceAuthorization(
|
|
device_code="opaque-code",
|
|
user_code="ABCD-EFGH",
|
|
verification_uri="https://console.example/device",
|
|
expires_in=900,
|
|
interval=5,
|
|
)
|
|
|
|
response = openapi_app.test_client().post(
|
|
f"{_PREFIX}/code",
|
|
json={"client_id": "difyctl", "device_label": "laptop"},
|
|
base_url="https://api.example",
|
|
environ_overrides={"REMOTE_ADDR": "192.0.2.10"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json == {
|
|
"device_code": "opaque-code",
|
|
"user_code": "ABCD-EFGH",
|
|
"verification_uri": "https://console.example/device",
|
|
"expires_in": 900,
|
|
"interval": 5,
|
|
}
|
|
device_service.start.assert_called_once_with(
|
|
client_id="difyctl",
|
|
device_label="laptop",
|
|
created_ip="192.0.2.10",
|
|
request_origin="https://api.example/",
|
|
)
|
|
|
|
@staticmethod
|
|
@pytest.mark.parametrize("path", ["code", "token", "approve", "deny", "approve-external"])
|
|
@pytest.mark.parametrize("body", ["{}", "[]", "{invalid"])
|
|
def test_invalid_device_request_never_reaches_service(
|
|
openapi_app: Flask, device_service: Mock, path: str, body: str
|
|
) -> None:
|
|
response = openapi_app.test_client().post(f"{_PREFIX}/{path}", data=body, content_type="application/json")
|
|
|
|
assert response.status_code == 400
|
|
assert device_service.mock_calls == []
|
|
|
|
@staticmethod
|
|
def test_lookup_requires_user_code(openapi_app: Flask, device_service: Mock) -> None:
|
|
response = openapi_app.test_client().get(f"{_PREFIX}/lookup")
|
|
|
|
assert response.status_code == 400
|
|
device_service.lookup.assert_not_called()
|
|
|
|
@staticmethod
|
|
def test_lookup_returns_public_validity_without_token_data(openapi_app: Flask, device_service: Mock) -> None:
|
|
device_service.lookup.return_value = DeviceLookup(valid=False, expires_in_remaining=0, client_id=None)
|
|
|
|
response = openapi_app.test_client().get(f"{_PREFIX}/lookup?user_code=ABCD-EFGH")
|
|
|
|
assert response.status_code == 200
|
|
assert response.json == {"valid": False, "expires_in_remaining": 0, "client_id": None}
|
|
device_service.lookup.assert_called_once_with(user_code="ABCD-EFGH")
|
|
|
|
@staticmethod
|
|
@pytest.mark.parametrize(
|
|
("path", "method", "error", "status", "code"),
|
|
[
|
|
("code", "start", UnsupportedClientError(), 400, "unsupported_client"),
|
|
("token", "poll", PollTooFastError(), 400, "slow_down"),
|
|
("token", "poll", ExpiredTokenError(), 400, "expired_token"),
|
|
("token", "poll", AuthorizationPendingError(), 400, "authorization_pending"),
|
|
("token", "poll", AccessDeniedError(), 400, "access_denied"),
|
|
("approve", "approve", ExpiredOrUnknownError(), 404, "expired_or_unknown"),
|
|
("approve", "approve", AlreadyResolvedError(), 409, "already_resolved"),
|
|
("approve", "approve", ApprovalInProgressError(), 409, "approve_in_progress"),
|
|
("approve", "approve", ApprovalOutcomeUnknownError(), 503, "approval_outcome_unknown"),
|
|
("deny", "deny", DeviceStateLostError(), 409, "state_lost"),
|
|
],
|
|
)
|
|
def test_device_errors_keep_oauth_response_contract(
|
|
openapi_app: Flask,
|
|
device_service: Mock,
|
|
path: str,
|
|
method: str,
|
|
error: OAuthDeviceError,
|
|
status: int,
|
|
code: str,
|
|
) -> None:
|
|
methods = {
|
|
"start": device_service.start,
|
|
"poll": device_service.poll,
|
|
"approve": device_service.approve,
|
|
"deny": device_service.deny,
|
|
}
|
|
methods[method].side_effect = error
|
|
|
|
response = openapi_app.test_client().post(
|
|
f"{_PREFIX}/{path}",
|
|
json={
|
|
"client_id": "difyctl",
|
|
"device_label": "CLI",
|
|
"device_code": "opaque-code",
|
|
"user_code": "ABCD-EFGH",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == status
|
|
assert response.json == {"error": code}
|
|
|
|
@staticmethod
|
|
@pytest.mark.parametrize("external", [False, True], ids=["account", "external"])
|
|
def test_poll_serializes_subject_specific_token_payload(
|
|
openapi_app: Flask, device_service: Mock, external: bool
|
|
) -> None:
|
|
payload: dict[str, object] = {
|
|
"token": "secret-token",
|
|
"expires_at": "2030-01-01T00:00:00Z",
|
|
"token_id": "token-1",
|
|
"subject_type": "external_sso" if external else "account",
|
|
"account": None if external else {"id": "account-1", "email": "ada@example.com", "name": "Ada"},
|
|
"workspaces": [],
|
|
"default_workspace_id": None,
|
|
}
|
|
if external:
|
|
payload.update(subject_email="external@example.com", subject_issuer="https://idp.example")
|
|
device_service.poll.return_value = payload
|
|
|
|
response = openapi_app.test_client().post(
|
|
f"{_PREFIX}/token",
|
|
json={"device_code": "opaque-code", "client_id": "difyctl"},
|
|
environ_overrides={"REMOTE_ADDR": "192.0.2.10"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json == payload
|
|
device_service.poll.assert_called_once_with(device_code="opaque-code", poll_ip="192.0.2.10")
|
|
|
|
@staticmethod
|
|
@pytest.mark.parametrize("path", ["approve", "deny"])
|
|
def test_account_decision_uses_admitted_identity(openapi_app: Flask, device_service: Mock, path: str) -> None:
|
|
method = device_service.approve if path == "approve" else device_service.deny
|
|
method.return_value = DeviceMutation(status="approved" if path == "approve" else "denied")
|
|
|
|
response = openapi_app.test_client().post(
|
|
f"{_PREFIX}/{path}",
|
|
json={"user_code": "ABCD-EFGH", "account_id": "forged", "workspace_id": "foreign"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json == {"status": method.return_value.status}
|
|
assert method.call_args.kwargs == {"user_code": "ABCD-EFGH"}
|
|
if path == "approve":
|
|
context = method.call_args.args[0]
|
|
assert isinstance(context, RequestContext)
|
|
assert context.account_id == "account-1"
|
|
assert context.active_workspace_id == "workspace-1"
|
|
|
|
@staticmethod
|
|
@pytest.mark.parametrize("path", ["approve", "deny"])
|
|
def test_disabled_bearer_feature_blocks_account_decisions(
|
|
openapi_app: Flask, device_service: Mock, config_overrides: Callable[..., None], path: str
|
|
) -> None:
|
|
config_overrides(ENABLE_OAUTH_BEARER=False)
|
|
|
|
response = openapi_app.test_client().post(f"{_PREFIX}/{path}", json={"user_code": "ABCD-EFGH"})
|
|
|
|
assert response.status_code == 503
|
|
assert device_service.mock_calls == []
|
|
|
|
@staticmethod
|
|
def test_sso_initiation_clears_old_approval_cookie(openapi_app: Flask, device_service: Mock) -> None:
|
|
device_service.initiate_sso.return_value = DeviceSSOInitiation("https://idp.example/authorize")
|
|
|
|
response = openapi_app.test_client().get(f"{_PREFIX}/sso-initiate?user_code=%20ABCD-EFGH%20")
|
|
|
|
assert response.status_code == 302
|
|
assert response.headers["Location"] == "https://idp.example/authorize"
|
|
cookie = SimpleCookie(response.headers["Set-Cookie"])[_COOKIE]
|
|
assert cookie["max-age"] == "0"
|
|
assert cookie["path"] == _PREFIX
|
|
assert device_service.initiate_sso.call_args.kwargs == {"user_code": "ABCD-EFGH"}
|
|
|
|
@staticmethod
|
|
def test_sso_initiation_requires_user_code(openapi_app: Flask, device_service: Mock) -> None:
|
|
response = openapi_app.test_client().get(f"{_PREFIX}/sso-initiate?user_code=%20")
|
|
|
|
assert response.status_code == 400
|
|
device_service.initiate_sso.assert_not_called()
|
|
|
|
@staticmethod
|
|
def test_sso_completion_sets_scoped_http_only_cookie(openapi_app: Flask, device_service: Mock) -> None:
|
|
device_service.complete_sso.return_value = DeviceSSOCompletion(
|
|
user_code="ABCD-EFGH", approval_grant="signed-grant"
|
|
)
|
|
|
|
response = openapi_app.test_client().get(f"{_PREFIX}/sso-complete?sso_assertion=signed-assertion")
|
|
|
|
assert response.status_code == 302
|
|
assert response.headers["Location"] == "/device?sso_verified=1"
|
|
cookie = SimpleCookie(response.headers["Set-Cookie"])[_COOKIE]
|
|
assert cookie.value == "signed-grant"
|
|
assert cookie["path"] == _PREFIX
|
|
assert cookie["httponly"]
|
|
assert cookie["secure"]
|
|
assert cookie["samesite"] == "Lax"
|
|
assert cookie["max-age"] == "300"
|
|
assert device_service.complete_sso.call_args.kwargs == {
|
|
"inbound_error": None,
|
|
"inbound_user_code": None,
|
|
"assertion": "signed-assertion",
|
|
}
|
|
|
|
@staticmethod
|
|
@pytest.mark.parametrize("failure", ["exception", "missing-grant", "untrusted-error"])
|
|
def test_sso_completion_failure_redirects_without_exposing_details(
|
|
openapi_app: Flask, device_service: Mock, failure: str
|
|
) -> None:
|
|
device_service.complete_sso.return_value = DeviceSSOCompletion(
|
|
error_code="private-provider-error" if failure == "untrusted-error" else None,
|
|
)
|
|
if failure == "exception":
|
|
device_service.complete_sso.side_effect = RuntimeError("private-provider-error")
|
|
|
|
response = openapi_app.test_client().get(f"{_PREFIX}/sso-complete?sso_assertion=private-assertion")
|
|
|
|
assert response.status_code == 302
|
|
assert response.headers["Location"] == "/device?sso_error=sso_failed"
|
|
assert "Set-Cookie" not in response.headers
|
|
assert b"private-" not in response.data
|
|
|
|
@staticmethod
|
|
def test_approval_context_reads_cookie_and_serializes_expiry(openapi_app: Flask, device_service: Mock) -> None:
|
|
device_service.get_approval_context.return_value = DeviceApprovalContext(
|
|
subject_email="external@example.com",
|
|
subject_issuer="https://idp.example",
|
|
user_code="ABCD-EFGH",
|
|
csrf_token="csrf-token",
|
|
expires_at=datetime(2030, 1, 1, tzinfo=UTC),
|
|
)
|
|
client = openapi_app.test_client()
|
|
client.set_cookie(_COOKIE, "signed-grant", path=_PREFIX)
|
|
|
|
response = client.get(f"{_PREFIX}/approval-context")
|
|
|
|
assert response.status_code == 200
|
|
assert response.json == {
|
|
"subject_email": "external@example.com",
|
|
"subject_issuer": "https://idp.example",
|
|
"user_code": "ABCD-EFGH",
|
|
"csrf_token": "csrf-token",
|
|
"expires_at": "2030-01-01T00:00:00Z",
|
|
}
|
|
assert device_service.get_approval_context.call_args.kwargs == {"approval_grant": "signed-grant"}
|
|
|
|
@staticmethod
|
|
def test_external_approval_uses_cookie_and_csrf_header_then_clears_cookie(
|
|
openapi_app: Flask, device_service: Mock
|
|
) -> None:
|
|
device_service.approve_external.return_value = DeviceMutation("approved")
|
|
client = openapi_app.test_client()
|
|
client.set_cookie(_COOKIE, "signed-grant", path=_PREFIX)
|
|
|
|
response = client.post(
|
|
f"{_PREFIX}/approve-external",
|
|
headers={"X-CSRF-Token": "csrf-header"},
|
|
json={"user_code": "ABCD-EFGH", "csrf_token": "forged", "approval_grant": "forged"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json == {"status": "approved"}
|
|
assert isinstance(device_service.approve_external.call_args.args[0], DeviceRequestContext)
|
|
assert device_service.approve_external.call_args.kwargs == {
|
|
"approval_grant": "signed-grant",
|
|
"csrf_token": "csrf-header",
|
|
"user_code": "ABCD-EFGH",
|
|
}
|
|
cookie = SimpleCookie(response.headers["Set-Cookie"])[_COOKIE]
|
|
assert cookie["max-age"] == "0"
|
|
assert cookie["path"] == _PREFIX
|
|
|
|
@staticmethod
|
|
@pytest.mark.parametrize(
|
|
("path", "method", "error", "status", "message"),
|
|
[
|
|
("sso-initiate", "initiate_sso", InvalidUserCodeError(), 400, "invalid_user_code"),
|
|
("sso-initiate", "initiate_sso", OAuthDeviceSSOConfigurationError(), 502, "console_api_url_unset"),
|
|
("sso-initiate", "initiate_sso", OAuthDeviceSSOInitiationError(), 502, "sso_initiate_failed"),
|
|
("approval-context", "get_approval_context", InvalidApprovalSessionError(), 401, "no_session"),
|
|
("approve-external", "approve_external", InvalidApprovalSessionError(), 401, "invalid_session"),
|
|
("approve-external", "approve_external", ExternalApprovalRateLimitError(), 429, "rate_limited"),
|
|
("approve-external", "approve_external", ExternalApprovalCSRFError(), 403, "csrf_mismatch"),
|
|
("approve-external", "approve_external", ExternalUserCodeMismatchError(), 400, "user_code_mismatch"),
|
|
("approve-external", "approve_external", ExternalUserCodeNotFoundError(), 404, "user_code_not_pending"),
|
|
("approve-external", "approve_external", AlreadyResolvedError(), 409, "user_code_not_pending"),
|
|
("approve-external", "approve_external", ApprovalInProgressError(), 409, "approve_in_progress"),
|
|
("approve-external", "approve_external", ApprovalOutcomeUnknownError(), 503, "approval_outcome_unknown"),
|
|
(
|
|
"approve-external",
|
|
"approve_external",
|
|
ExternalIdentityConflictError(),
|
|
403,
|
|
"email_belongs_to_dify_account",
|
|
),
|
|
("approve-external", "approve_external", ApprovalSessionConsumedError(), 401, "session_already_consumed"),
|
|
("approve-external", "approve_external", DeviceStateLostError(), 409, "state_lost"),
|
|
],
|
|
)
|
|
def test_sso_errors_preserve_http_status_and_approval_cookie(
|
|
openapi_app: Flask,
|
|
device_service: Mock,
|
|
path: str,
|
|
method: str,
|
|
error: OAuthDeviceError,
|
|
status: int,
|
|
message: str,
|
|
) -> None:
|
|
methods = {
|
|
"initiate_sso": device_service.initiate_sso,
|
|
"get_approval_context": device_service.get_approval_context,
|
|
"approve_external": device_service.approve_external,
|
|
}
|
|
methods[method].side_effect = error
|
|
client = openapi_app.test_client()
|
|
client.set_cookie(_COOKIE, "signed-grant", path=_PREFIX)
|
|
if path == "approve-external":
|
|
response = client.post(f"{_PREFIX}/{path}", json={"user_code": "ABCD-EFGH"})
|
|
else:
|
|
response = client.get(f"{_PREFIX}/{path}?user_code=ABCD-EFGH")
|
|
|
|
assert response.status_code == status
|
|
assert message in response.get_data(as_text=True)
|
|
assert "Set-Cookie" not in response.headers
|
|
assert "signed-grant" not in response.get_data(as_text=True)
|