Files
dify/api/tests/unit_tests/controllers/openapi/test_account.py
GareArc 390f1f74db feat(api): add /openapi/v1/account/sessions endpoints (Phase C.11-12)
GET /openapi/v1/account/sessions lists the bearer's active OAuth
tokens (filtered to revoked_at IS NULL, expires_at > NOW(), token_hash
IS NOT NULL — no phantom devices). DELETE
/openapi/v1/account/sessions/<id> revokes a specific session with a
subject-match guard that returns 404 (not 403) on cross-subject so
token IDs don't leak across subjects.

Subject scoping abstracted into _subject_match(ctx): account subjects
filter by account_id; external_sso subjects filter by (email, issuer)
AND account_id IS NULL — preventing an SSO bearer from touching a
same-email account row from a federated tenant.

_revoke_token_by_id helper extracted so /sessions/self and
/sessions/<id> share the same UPDATE-where-revoked_at-IS-NULL
idempotent revoke + Redis cache invalidation.

No /v1/ equivalents — these are new endpoints (spec §Sessions list shape).

Plan: docs/superpowers/plans/2026-04-26-openapi-migration.md (in difyctl repo).
2026-04-26 23:51:55 -07:00

155 lines
5.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Phase C steps 910: 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.
"""
import builtins
import pytest
from flask import Flask
from flask.views import MethodView
from controllers.openapi import bp as openapi_bp
from controllers.openapi.account import (
AccountApi,
AccountSessionByIdApi,
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:
app = Flask(__name__)
app.config["TESTING"] = True
app.register_blueprint(service_api_bp)
app.register_blueprint(openapi_bp)
return app
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()}
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_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()}
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_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")
assert "GET" in rule.methods
def test_sessions_self_methods(dual_app: Flask):
rule = _rule(dual_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()}
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
assert "GET" in rule.methods
def test_session_by_id_route_registered(dual_app: Flask):
"""DELETE /openapi/v1/account/sessions/<id> is new — no /v1/ equivalent."""
rules = {r.rule for r in dual_app.url_map.iter_rules()}
assert "/openapi/v1/account/sessions/<string:session_id>" in rules
def test_session_by_id_dispatches_to_correct_class(dual_app: Flask):
rule = _rule(dual_app, "/openapi/v1/account/sessions/<string:session_id>")
assert dual_app.view_functions[rule.endpoint].view_class is AccountSessionByIdApi
assert "DELETE" in rule.methods
def test_subject_match_for_account_filters_by_account_id():
"""Account subject scopes queries via account_id."""
import uuid as _uuid
from controllers.openapi.account import _subject_match
from libs.oauth_bearer import AuthContext, SubjectType
aid = _uuid.uuid4()
ctx = AuthContext(
subject_type=SubjectType.ACCOUNT,
subject_email="user@example.com",
subject_issuer="dify:account",
account_id=aid,
scopes=frozenset({"full"}),
token_id=_uuid.uuid4(),
source="oauth_account",
expires_at=None,
)
clauses = _subject_match(ctx)
# One predicate, on account_id
assert len(clauses) == 1
assert "account_id" in str(clauses[0])
def test_subject_match_for_external_sso_filters_by_email_and_issuer():
"""External SSO subject scopes via (subject_email, subject_issuer)
AND account_id IS NULL — so a same-email account row from a
federated tenant cannot be revoked through an SSO bearer.
"""
import uuid as _uuid
from controllers.openapi.account import _subject_match
from libs.oauth_bearer import AuthContext, SubjectType
ctx = AuthContext(
subject_type=SubjectType.EXTERNAL_SSO,
subject_email="sso@partner.com",
subject_issuer="https://idp.partner.com",
account_id=None,
scopes=frozenset({"apps:run"}),
token_id=_uuid.uuid4(),
source="oauth_external_sso",
expires_at=None,
)
clauses = _subject_match(ctx)
assert len(clauses) == 3
rendered = " ".join(str(c) for c in clauses)
assert "subject_email" in rendered
assert "subject_issuer" in rendered
assert "account_id IS NULL" in rendered