From 783dfe38a0737537ffbda66f2466673eba54e87b Mon Sep 17 00:00:00 2001 From: GareArc Date: Tue, 5 May 2026 18:08:31 -0700 Subject: [PATCH] chore(test): consolidate /openapi/v1 integration fixtures - Shared conftest at tests/integration_tests/controllers/openapi/: workspace_account, app_in_workspace, mint_token (factory + tracked cleanup of OAuthAccessToken rows), account_token convenience fixture, autouse disable_enterprise (default ENTERPRISE_ENABLED=False; tests needing the EE branch override in-test), autouse _flush_auth_redis. - test_auth.py covers Layer 0 + per-token rate limit + scope on /info. other_workspace_app fixture is a generator that cleans up the second tenant + app on teardown. - test_apps.py covers the read surface: /apps list with pagination envelope, /apps/, /info, /parameters, /describe, /account/sessions envelope migration, plus dfoe_ scope rejection on apps:read routes. --- .../controllers/openapi/__init__.py | 0 .../controllers/openapi/conftest.py | 125 ++++++++++++++++ .../controllers/openapi/test_apps.py | 134 ++++++++++++++++++ .../controllers/openapi/test_auth.py | 126 ++++++++++++++++ 4 files changed, 385 insertions(+) create mode 100644 api/tests/integration_tests/controllers/openapi/__init__.py create mode 100644 api/tests/integration_tests/controllers/openapi/conftest.py create mode 100644 api/tests/integration_tests/controllers/openapi/test_apps.py create mode 100644 api/tests/integration_tests/controllers/openapi/test_auth.py diff --git a/api/tests/integration_tests/controllers/openapi/__init__.py b/api/tests/integration_tests/controllers/openapi/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/tests/integration_tests/controllers/openapi/conftest.py b/api/tests/integration_tests/controllers/openapi/conftest.py new file mode 100644 index 0000000000..66f07484a5 --- /dev/null +++ b/api/tests/integration_tests/controllers/openapi/conftest.py @@ -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_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() diff --git a/api/tests/integration_tests/controllers/openapi/test_apps.py b/api/tests/integration_tests/controllers/openapi/test_apps.py new file mode 100644 index 0000000000..abf2597901 --- /dev/null +++ b/api/tests/integration_tests/controllers/openapi/test_apps.py @@ -0,0 +1,134 @@ +"""Integration tests for /openapi/v1/apps* read surface.""" + +from __future__ import annotations + +import uuid + +from flask.testing import FlaskClient + +from models import App + + +def test_apps_get_single_returns_app_info( + test_client: FlaskClient, + app_in_workspace: App, + account_token: str, +): + res = test_client.get( + f"/openapi/v1/apps/{app_in_workspace.id}", + headers={"Authorization": f"Bearer {account_token}"}, + ) + assert res.status_code == 200 + body = res.json + assert body["id"] == app_in_workspace.id + assert body["mode"] == "chat" + + +def test_apps_get_single_rejects_external_sso( + test_client: FlaskClient, + app_in_workspace: App, + mint_token, +): + """dfoe_ tokens hold only `apps:run` — `apps:read` routes 403.""" + token = "dfoe_" + uuid.uuid4().hex + mint_token( + token, + account_id=None, + prefix="dfoe_", + subject_email="ext@example.com", + subject_issuer="https://idp.example.com", + ) + res = test_client.get( + f"/openapi/v1/apps/{app_in_workspace.id}", + headers={"Authorization": f"Bearer {token}"}, + ) + assert res.status_code == 403 + assert "insufficient_scope" in res.json.get("message", "") + + +def test_apps_parameters_returns_form_schema( + test_client: FlaskClient, + app_in_workspace: App, + account_token: str, +): + res = test_client.get( + f"/openapi/v1/apps/{app_in_workspace.id}/parameters", + headers={"Authorization": f"Bearer {account_token}"}, + ) + # Without an app_model_config, the chat app's parameters endpoint raises + # AppUnavailableError (503). Auth succeeded if status is NOT 401/403. + assert res.status_code in (200, 503) + + +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_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 diff --git a/api/tests/integration_tests/controllers/openapi/test_auth.py b/api/tests/integration_tests/controllers/openapi/test_auth.py new file mode 100644 index 0000000000..2d3c0d1827 --- /dev/null +++ b/api/tests/integration_tests/controllers/openapi/test_auth.py @@ -0,0 +1,126 @@ +"""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_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//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