mirror of
https://github.com/langgenius/dify.git
synced 2026-06-08 09:27:39 +08:00
feat(api,web,cli): difyctl v1.0 — OAuth device flow, /openapi/v1 auth pipeline, CLI client
This commit is contained in:
125
api/tests/integration_tests/controllers/openapi/conftest.py
Normal file
125
api/tests/integration_tests/controllers/openapi/conftest.py
Normal file
@ -0,0 +1,125 @@
|
||||
"""Shared fixtures for /openapi/v1/* integration tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import uuid
|
||||
from collections.abc import Generator
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_redis import redis_client
|
||||
from models import Account, App, OAuthAccessToken, Tenant, TenantAccountJoin
|
||||
from models.account import AccountStatus
|
||||
|
||||
|
||||
def _sha256(token: str) -> str:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def disable_enterprise(monkeypatch):
|
||||
"""Default to CE behaviour for /openapi/v1 tests. Tests that exercise the
|
||||
EE branch override this with their own monkeypatch in-test."""
|
||||
from configs import dify_config
|
||||
|
||||
monkeypatch.setattr(dify_config, "ENTERPRISE_ENABLED", False)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workspace_account(flask_app: Flask) -> Generator[tuple[Account, Tenant, TenantAccountJoin], None, None]:
|
||||
with flask_app.app_context():
|
||||
tenant = Tenant(name="t1", status="normal")
|
||||
account = Account(email="u@example.com", name="u")
|
||||
db.session.add_all([tenant, account])
|
||||
db.session.commit()
|
||||
account.status = AccountStatus.ACTIVE
|
||||
join = TenantAccountJoin(tenant_id=tenant.id, account_id=account.id, role="owner")
|
||||
db.session.add(join)
|
||||
db.session.commit()
|
||||
yield account, tenant, join
|
||||
db.session.delete(join)
|
||||
db.session.delete(account)
|
||||
db.session.delete(tenant)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app_in_workspace(flask_app: Flask, workspace_account) -> Generator[App, None, None]:
|
||||
_, tenant, _ = workspace_account
|
||||
with flask_app.app_context():
|
||||
app = App(tenant_id=tenant.id, name="a", mode="chat", status="normal", enable_site=True, enable_api=True)
|
||||
db.session.add(app)
|
||||
db.session.commit()
|
||||
yield app
|
||||
db.session.delete(app)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mint_token(flask_app: Flask):
|
||||
"""Factory fixture; tracks minted rows and deletes them on teardown so
|
||||
the auth-related test runs don't accumulate `oauth_access_tokens` rows."""
|
||||
minted: list[OAuthAccessToken] = []
|
||||
|
||||
def _mint(
|
||||
token: str,
|
||||
*,
|
||||
account_id: str | None,
|
||||
prefix: str,
|
||||
subject_email: str,
|
||||
subject_issuer: str | None,
|
||||
) -> OAuthAccessToken:
|
||||
with flask_app.app_context():
|
||||
row = OAuthAccessToken(
|
||||
token_hash=_sha256(token),
|
||||
prefix=prefix,
|
||||
account_id=account_id,
|
||||
subject_email=subject_email,
|
||||
subject_issuer=subject_issuer,
|
||||
client_id="difyctl",
|
||||
device_label="test-device",
|
||||
expires_at=datetime.now(UTC) + timedelta(hours=1),
|
||||
)
|
||||
db.session.add(row)
|
||||
db.session.commit()
|
||||
minted.append(row)
|
||||
return row
|
||||
|
||||
yield _mint
|
||||
|
||||
with flask_app.app_context():
|
||||
for row in minted:
|
||||
db.session.delete(db.session.merge(row))
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def account_token(workspace_account, mint_token) -> str:
|
||||
account, _, _ = workspace_account
|
||||
token = "dfoa_" + uuid.uuid4().hex
|
||||
mint_token(
|
||||
token,
|
||||
account_id=account.id,
|
||||
prefix="dfoa_",
|
||||
subject_email=account.email,
|
||||
subject_issuer="dify:account",
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _flush_auth_redis(flask_app: Flask) -> Generator[None, None, None]:
|
||||
def _flush():
|
||||
with flask_app.app_context():
|
||||
for k in redis_client.keys("auth:*"):
|
||||
redis_client.delete(k)
|
||||
for k in redis_client.keys("rl:*"):
|
||||
redis_client.delete(k)
|
||||
|
||||
_flush()
|
||||
yield
|
||||
_flush()
|
||||
252
api/tests/integration_tests/controllers/openapi/test_app_run.py
Normal file
252
api/tests/integration_tests/controllers/openapi/test_app_run.py
Normal file
@ -0,0 +1,252 @@
|
||||
"""Integration tests for POST /openapi/v1/apps/<id>/run."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import Generator
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from extensions.ext_database import db
|
||||
from models import App
|
||||
|
||||
|
||||
def test_run_chat_dispatches_to_chat_handler(flask_app, account_token, app_in_workspace, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def _fake_generate(*, app_model, user, args, invoke_from, streaming):
|
||||
captured["mode"] = app_model.mode
|
||||
captured["args"] = args
|
||||
captured["invoke_from"] = invoke_from
|
||||
return {
|
||||
"event": "message",
|
||||
"task_id": "t",
|
||||
"id": "m",
|
||||
"message_id": "m",
|
||||
"conversation_id": "c",
|
||||
"mode": "chat",
|
||||
"answer": "ok",
|
||||
"created_at": 0,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"controllers.openapi.app_run.AppGenerateService.generate", staticmethod(_fake_generate)
|
||||
)
|
||||
client = flask_app.test_client()
|
||||
res = client.post(
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}/run",
|
||||
json={"inputs": {}, "query": "hi", "response_mode": "blocking", "user": "spoof@x.com"},
|
||||
headers={"Authorization": f"Bearer {account_token}"},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert res.get_json()["mode"] == "chat"
|
||||
assert captured["mode"] == "chat"
|
||||
assert captured["invoke_from"] == InvokeFrom.OPENAPI
|
||||
assert "user" not in captured["args"], "server must strip body.user; identity comes from bearer"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app_with_mode(flask_app: Flask, workspace_account):
|
||||
"""Factory that creates an App row in the workspace_account tenant with
|
||||
a specified mode. Tracks rows for teardown.
|
||||
"""
|
||||
_, tenant, _ = workspace_account
|
||||
created: list[App] = []
|
||||
|
||||
def _make(mode: str) -> App:
|
||||
with flask_app.app_context():
|
||||
app = App(
|
||||
tenant_id=tenant.id,
|
||||
name=f"a-{mode}",
|
||||
mode=mode,
|
||||
status="normal",
|
||||
enable_site=True,
|
||||
enable_api=True,
|
||||
)
|
||||
db.session.add(app)
|
||||
db.session.commit()
|
||||
db.session.refresh(app)
|
||||
db.session.expunge(app)
|
||||
created.append(app)
|
||||
return app
|
||||
|
||||
yield _make
|
||||
|
||||
with flask_app.app_context():
|
||||
for app in created:
|
||||
db.session.delete(db.session.merge(app))
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def test_run_chat_without_query_returns_422(flask_app, account_token, app_in_workspace, monkeypatch):
|
||||
client = flask_app.test_client()
|
||||
res = client.post(
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}/run",
|
||||
json={"inputs": {}, "response_mode": "blocking"},
|
||||
headers={"Authorization": f"Bearer {account_token}"},
|
||||
)
|
||||
assert res.status_code == 422
|
||||
assert b"query_required_for_chat" in res.data
|
||||
|
||||
|
||||
def test_run_completion_dispatches_to_completion_handler(
|
||||
flask_app, account_token, app_with_mode, monkeypatch
|
||||
):
|
||||
app = app_with_mode("completion")
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
def _fake_generate(*, app_model, user, args, invoke_from, streaming):
|
||||
captured["mode"] = app_model.mode
|
||||
captured["args"] = args
|
||||
return {
|
||||
"event": "message",
|
||||
"task_id": "t",
|
||||
"id": "m",
|
||||
"message_id": "m",
|
||||
"mode": "completion",
|
||||
"answer": "ok",
|
||||
"created_at": 0,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"controllers.openapi.app_run.AppGenerateService.generate", staticmethod(_fake_generate)
|
||||
)
|
||||
client = flask_app.test_client()
|
||||
res = client.post(
|
||||
f"/openapi/v1/apps/{app.id}/run",
|
||||
json={"inputs": {}, "response_mode": "blocking"},
|
||||
headers={"Authorization": f"Bearer {account_token}"},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert res.get_json()["mode"] == "completion"
|
||||
assert captured["mode"] == "completion"
|
||||
|
||||
|
||||
def test_run_workflow_with_query_returns_422(flask_app, account_token, app_with_mode, monkeypatch):
|
||||
app = app_with_mode("workflow")
|
||||
client = flask_app.test_client()
|
||||
res = client.post(
|
||||
f"/openapi/v1/apps/{app.id}/run",
|
||||
json={"inputs": {}, "query": "hi", "response_mode": "blocking"},
|
||||
headers={"Authorization": f"Bearer {account_token}"},
|
||||
)
|
||||
assert res.status_code == 422
|
||||
assert b"query_not_supported_for_workflow" in res.data
|
||||
|
||||
|
||||
def test_run_workflow_no_query_dispatches_to_workflow_handler(
|
||||
flask_app, account_token, app_with_mode, monkeypatch
|
||||
):
|
||||
app = app_with_mode("workflow")
|
||||
|
||||
def _fake_generate(*, app_model, user, args, invoke_from, streaming):
|
||||
return {
|
||||
"workflow_run_id": "wfr",
|
||||
"task_id": "t",
|
||||
"data": {"id": "wf-d", "workflow_id": "wf", "status": "succeeded"},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"controllers.openapi.app_run.AppGenerateService.generate", staticmethod(_fake_generate)
|
||||
)
|
||||
client = flask_app.test_client()
|
||||
res = client.post(
|
||||
f"/openapi/v1/apps/{app.id}/run",
|
||||
json={"inputs": {}, "response_mode": "blocking"},
|
||||
headers={"Authorization": f"Bearer {account_token}"},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
body = res.get_json()
|
||||
assert body["mode"] == "workflow"
|
||||
assert body["workflow_run_id"] == "wfr"
|
||||
|
||||
|
||||
def test_run_unsupported_mode_returns_422(flask_app, account_token, app_with_mode, monkeypatch):
|
||||
app = app_with_mode("channel")
|
||||
client = flask_app.test_client()
|
||||
res = client.post(
|
||||
f"/openapi/v1/apps/{app.id}/run",
|
||||
json={"inputs": {}, "response_mode": "blocking"},
|
||||
headers={"Authorization": f"Bearer {account_token}"},
|
||||
)
|
||||
assert res.status_code == 422
|
||||
assert b"mode_not_runnable" in res.data
|
||||
|
||||
|
||||
def test_run_without_bearer_returns_401(flask_app, app_in_workspace):
|
||||
client = flask_app.test_client()
|
||||
res = client.post(
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}/run",
|
||||
json={"inputs": {}, "query": "hi"},
|
||||
)
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
def test_run_with_insufficient_scope_returns_403(
|
||||
flask_app, account_token, app_in_workspace, monkeypatch
|
||||
):
|
||||
"""Stub the authenticator to return an AuthContext with empty scopes."""
|
||||
from libs import oauth_bearer
|
||||
|
||||
real_authenticate = oauth_bearer.BearerAuthenticator.authenticate
|
||||
|
||||
def _stub_authenticate(self, token: str):
|
||||
ctx = real_authenticate(self, token)
|
||||
from dataclasses import replace
|
||||
|
||||
return replace(ctx, scopes=frozenset())
|
||||
|
||||
monkeypatch.setattr(oauth_bearer.BearerAuthenticator, "authenticate", _stub_authenticate)
|
||||
|
||||
client = flask_app.test_client()
|
||||
res = client.post(
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}/run",
|
||||
json={"inputs": {}, "query": "hi"},
|
||||
headers={"Authorization": f"Bearer {account_token}"},
|
||||
)
|
||||
assert res.status_code == 403
|
||||
|
||||
|
||||
def test_run_with_unknown_app_returns_404(flask_app, account_token):
|
||||
client = flask_app.test_client()
|
||||
res = client.post(
|
||||
f"/openapi/v1/apps/{uuid.uuid4()}/run",
|
||||
json={"inputs": {}, "query": "hi"},
|
||||
headers={"Authorization": f"Bearer {account_token}"},
|
||||
)
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
def test_run_streaming_returns_event_stream(
|
||||
flask_app, account_token, app_in_workspace, monkeypatch
|
||||
):
|
||||
def _stream() -> Generator[str, None, None]:
|
||||
yield "event: message\ndata: {\"x\": 1}\n\n"
|
||||
|
||||
monkeypatch.setattr(
|
||||
"controllers.openapi.app_run.AppGenerateService.generate",
|
||||
staticmethod(lambda **kw: _stream()),
|
||||
)
|
||||
|
||||
client = flask_app.test_client()
|
||||
res = client.post(
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}/run",
|
||||
json={"inputs": {}, "query": "hi", "response_mode": "streaming"},
|
||||
headers={"Authorization": f"Bearer {account_token}"},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert res.headers["Content-Type"].startswith("text/event-stream")
|
||||
assert b"event: message" in res.data
|
||||
|
||||
|
||||
def test_run_without_inputs_returns_422(flask_app, account_token, app_in_workspace):
|
||||
client = flask_app.test_client()
|
||||
res = client.post(
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}/run",
|
||||
json={"query": "hi"},
|
||||
headers={"Authorization": f"Bearer {account_token}"},
|
||||
)
|
||||
assert res.status_code == 422
|
||||
210
api/tests/integration_tests/controllers/openapi/test_apps.py
Normal file
210
api/tests/integration_tests/controllers/openapi/test_apps.py
Normal file
@ -0,0 +1,210 @@
|
||||
"""Integration tests for /openapi/v1/apps* read surface."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from flask.testing import FlaskClient
|
||||
|
||||
from models import App
|
||||
|
||||
|
||||
def test_apps_bare_id_route_404(test_client, app_in_workspace, account_token):
|
||||
resp = test_client.get(
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}",
|
||||
headers={"Authorization": f"Bearer {account_token}"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_apps_parameters_route_404(test_client, app_in_workspace, account_token):
|
||||
resp = test_client.get(
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}/parameters",
|
||||
headers={"Authorization": f"Bearer {account_token}"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_apps_info_route_404(test_client, app_in_workspace, account_token):
|
||||
resp = test_client.get(
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}/info",
|
||||
headers={"Authorization": f"Bearer {account_token}"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_apps_describe_returns_merged_shape(
|
||||
test_client: FlaskClient,
|
||||
app_in_workspace: App,
|
||||
account_token: str,
|
||||
):
|
||||
res = test_client.get(
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}/describe",
|
||||
headers={"Authorization": f"Bearer {account_token}"},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
body = res.json
|
||||
assert body["info"]["id"] == app_in_workspace.id
|
||||
assert body["info"]["mode"] == "chat"
|
||||
assert isinstance(body["parameters"], dict)
|
||||
|
||||
|
||||
def test_apps_describe_full_includes_input_schema(
|
||||
test_client: FlaskClient,
|
||||
app_in_workspace: App,
|
||||
account_token: str,
|
||||
):
|
||||
res = test_client.get(
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}/describe",
|
||||
headers={"Authorization": f"Bearer {account_token}"},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
body = res.json
|
||||
assert body["info"] is not None
|
||||
assert body["parameters"] is not None
|
||||
assert body["input_schema"] is not None
|
||||
assert body["input_schema"]["$schema"] == "https://json-schema.org/draft/2020-12/schema"
|
||||
|
||||
|
||||
def test_apps_describe_fields_info_only(
|
||||
test_client: FlaskClient,
|
||||
app_in_workspace: App,
|
||||
account_token: str,
|
||||
):
|
||||
res = test_client.get(
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}/describe?fields=info",
|
||||
headers={"Authorization": f"Bearer {account_token}"},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
body = res.json
|
||||
assert body["info"] is not None
|
||||
assert body["parameters"] is None
|
||||
assert body["input_schema"] is None
|
||||
|
||||
|
||||
def test_apps_describe_fields_parameters_only(
|
||||
test_client: FlaskClient,
|
||||
app_in_workspace: App,
|
||||
account_token: str,
|
||||
):
|
||||
res = test_client.get(
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}/describe?fields=parameters",
|
||||
headers={"Authorization": f"Bearer {account_token}"},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
body = res.json
|
||||
assert body["info"] is None
|
||||
assert body["parameters"] is not None
|
||||
assert body["input_schema"] is None
|
||||
|
||||
|
||||
def test_apps_describe_fields_input_schema_only(
|
||||
test_client: FlaskClient,
|
||||
app_in_workspace: App,
|
||||
account_token: str,
|
||||
):
|
||||
res = test_client.get(
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}/describe?fields=input_schema",
|
||||
headers={"Authorization": f"Bearer {account_token}"},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
body = res.json
|
||||
assert body["info"] is None
|
||||
assert body["parameters"] is None
|
||||
assert body["input_schema"] is not None
|
||||
|
||||
|
||||
def test_apps_describe_fields_combined(
|
||||
test_client: FlaskClient,
|
||||
app_in_workspace: App,
|
||||
account_token: str,
|
||||
):
|
||||
res = test_client.get(
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}/describe?fields=info,input_schema",
|
||||
headers={"Authorization": f"Bearer {account_token}"},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
body = res.json
|
||||
assert body["info"] is not None
|
||||
assert body["parameters"] is None
|
||||
assert body["input_schema"] is not None
|
||||
|
||||
|
||||
def test_apps_describe_fields_unknown_returns_422(
|
||||
test_client: FlaskClient,
|
||||
app_in_workspace: App,
|
||||
account_token: str,
|
||||
):
|
||||
res = test_client.get(
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}/describe?fields=garbage",
|
||||
headers={"Authorization": f"Bearer {account_token}"},
|
||||
)
|
||||
assert res.status_code == 422
|
||||
|
||||
|
||||
def test_apps_describe_fields_extra_param_returns_422(
|
||||
test_client: FlaskClient,
|
||||
app_in_workspace: App,
|
||||
account_token: str,
|
||||
):
|
||||
res = test_client.get(
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}/describe?fields=info&page=1",
|
||||
headers={"Authorization": f"Bearer {account_token}"},
|
||||
)
|
||||
assert res.status_code == 422
|
||||
|
||||
|
||||
def test_apps_list_returns_pagination_envelope(
|
||||
test_client: FlaskClient,
|
||||
workspace_account,
|
||||
app_in_workspace: App,
|
||||
account_token: str,
|
||||
):
|
||||
_, tenant, _ = workspace_account
|
||||
res = test_client.get(
|
||||
f"/openapi/v1/apps?workspace_id={tenant.id}&page=1&limit=20",
|
||||
headers={"Authorization": f"Bearer {account_token}"},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
body = res.json
|
||||
assert body["page"] == 1
|
||||
assert body["limit"] == 20
|
||||
assert body["total"] >= 1
|
||||
assert any(d["id"] == app_in_workspace.id for d in body["data"])
|
||||
|
||||
|
||||
def test_apps_list_requires_workspace_id(test_client: FlaskClient, account_token: str):
|
||||
res = test_client.get("/openapi/v1/apps", headers={"Authorization": f"Bearer {account_token}"})
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
def test_apps_list_tag_no_match_returns_empty_data_not_400(
|
||||
test_client: FlaskClient,
|
||||
workspace_account,
|
||||
app_in_workspace: App,
|
||||
account_token: str,
|
||||
):
|
||||
_, tenant, _ = workspace_account
|
||||
res = test_client.get(
|
||||
f"/openapi/v1/apps?workspace_id={tenant.id}&tag=nonexistent",
|
||||
headers={"Authorization": f"Bearer {account_token}"},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert res.json["data"] == []
|
||||
|
||||
|
||||
def test_account_sessions_returns_envelope(
|
||||
test_client: FlaskClient,
|
||||
account_token: str,
|
||||
):
|
||||
res = test_client.get("/openapi/v1/account/sessions", headers={"Authorization": f"Bearer {account_token}"})
|
||||
assert res.status_code == 200
|
||||
body = res.json
|
||||
# canonical envelope shape
|
||||
assert isinstance(body["data"], list)
|
||||
assert "page" in body
|
||||
assert "limit" in body
|
||||
assert "total" in body
|
||||
assert "has_more" in body
|
||||
# the bearer's own minted session must appear
|
||||
assert any(s["prefix"] == "dfoa_" for s in body["data"])
|
||||
# legacy "sessions" key must NOT appear
|
||||
assert "sessions" not in body
|
||||
127
api/tests/integration_tests/controllers/openapi/test_auth.py
Normal file
127
api/tests/integration_tests/controllers/openapi/test_auth.py
Normal file
@ -0,0 +1,127 @@
|
||||
"""Integration tests for the /openapi/v1 bearer auth surface.
|
||||
|
||||
Layer 0 (workspace membership), per-token rate limit, and read-scope (`apps:read`)
|
||||
acceptance/rejection on app-scoped routes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from flask.testing import FlaskClient
|
||||
|
||||
from extensions.ext_database import db
|
||||
from models import App, Tenant
|
||||
|
||||
|
||||
def test_info_accepts_account_bearer_with_apps_read_scope(
|
||||
test_client: FlaskClient,
|
||||
app_in_workspace: App,
|
||||
account_token: str,
|
||||
) -> None:
|
||||
res = test_client.get(
|
||||
f"/openapi/v1/apps/{app_in_workspace.id}/info",
|
||||
headers={"Authorization": f"Bearer {account_token}"},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert res.json["id"] == app_in_workspace.id
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def other_workspace_app(flask_app: Flask) -> Generator[App, None, None]:
|
||||
"""A fresh app under a *different* tenant — caller has no membership row."""
|
||||
with flask_app.app_context():
|
||||
other_tenant = Tenant(name="other", status="normal")
|
||||
db.session.add(other_tenant)
|
||||
db.session.commit()
|
||||
app = App(
|
||||
tenant_id=other_tenant.id,
|
||||
name="b",
|
||||
mode="chat",
|
||||
status="normal",
|
||||
enable_site=True,
|
||||
enable_api=True,
|
||||
)
|
||||
db.session.add(app)
|
||||
db.session.commit()
|
||||
yield app
|
||||
db.session.delete(app)
|
||||
db.session.delete(other_tenant)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def test_layer0_denies_account_bearer_without_membership(
|
||||
test_client: FlaskClient,
|
||||
account_token: str,
|
||||
other_workspace_app: App,
|
||||
) -> None:
|
||||
"""Account A bearer hitting an app under tenant B — Layer 0 denies on CE."""
|
||||
res = test_client.get(
|
||||
f"/openapi/v1/apps/{other_workspace_app.id}/info",
|
||||
headers={"Authorization": f"Bearer {account_token}"},
|
||||
)
|
||||
assert res.status_code == 403
|
||||
assert res.json.get("message") == "workspace_membership_revoked"
|
||||
|
||||
|
||||
def test_layer0_skipped_when_enterprise_enabled(
|
||||
test_client: FlaskClient,
|
||||
account_token: str,
|
||||
other_workspace_app: App,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""On EE, Layer 0 short-circuits — gateway RBAC owns tenant isolation.
|
||||
|
||||
/info uses validate_bearer + require_workspace_member inline (no
|
||||
AppAuthzCheck), so a cross-tenant bearer reaches the app lookup and
|
||||
gets 200 — gateway is expected to enforce isolation upstream.
|
||||
"""
|
||||
from configs import dify_config
|
||||
|
||||
# Override the conftest autouse default for this test only.
|
||||
monkeypatch.setattr(dify_config, "ENTERPRISE_ENABLED", True)
|
||||
|
||||
res = test_client.get(
|
||||
f"/openapi/v1/apps/{other_workspace_app.id}/info",
|
||||
headers={"Authorization": f"Bearer {account_token}"},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert res.json.get("message") != "workspace_membership_revoked"
|
||||
|
||||
|
||||
def test_rate_limit_returns_429_after_60_requests(
|
||||
test_client: FlaskClient,
|
||||
account_token: str,
|
||||
) -> None:
|
||||
"""61st sequential GET to /account on the same bearer → 429 with Retry-After."""
|
||||
headers = {"Authorization": f"Bearer {account_token}"}
|
||||
for i in range(60):
|
||||
r = test_client.get("/openapi/v1/account", headers=headers)
|
||||
assert r.status_code == 200, f"unexpected fail at i={i}"
|
||||
|
||||
r = test_client.get("/openapi/v1/account", headers=headers)
|
||||
assert r.status_code == 429
|
||||
assert r.headers.get("Retry-After"), "Retry-After header missing"
|
||||
assert int(r.headers["Retry-After"]) >= 1
|
||||
body = r.json or {}
|
||||
assert body.get("error") == "rate_limited"
|
||||
assert isinstance(body.get("retry_after_ms"), int)
|
||||
assert body["retry_after_ms"] >= 1000
|
||||
|
||||
|
||||
def test_rate_limit_bucket_shared_across_surfaces(
|
||||
test_client: FlaskClient,
|
||||
app_in_workspace: App,
|
||||
account_token: str,
|
||||
) -> None:
|
||||
"""30 calls to /account + 30 calls to /apps/<id>/info on same token → 61st 429s."""
|
||||
headers = {"Authorization": f"Bearer {account_token}"}
|
||||
for _ in range(30):
|
||||
assert test_client.get("/openapi/v1/account", headers=headers).status_code == 200
|
||||
for _ in range(30):
|
||||
assert test_client.get(f"/openapi/v1/apps/{app_in_workspace.id}/info", headers=headers).status_code == 200
|
||||
|
||||
r = test_client.get("/openapi/v1/account", headers=headers)
|
||||
assert r.status_code == 429
|
||||
Reference in New Issue
Block a user