fix: enforce API key list permissions (#40516)

This commit is contained in:
WH-2099
2026-08-12 11:44:27 +00:00
committed by GitHub
parent 6a37e1538a
commit 489772908b
15 changed files with 305 additions and 42 deletions
+7 -4
View File
@@ -3,7 +3,7 @@ from uuid import UUID
from flask import abort, request
from flask_restx import Resource
from pydantic import AliasChoices, BaseModel, Field, field_validator
from sqlalchemy import func, select
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session
from controllers.common.schema import (
@@ -484,12 +484,13 @@ def _resolve_agent_runtime_app_model(session: Session, *, tenant_id: str, agent_
return _agent_roster_service(session).get_agent_runtime_app_model(tenant_id=tenant_id, agent_id=str(agent_id))
def _agent_api_key_count(session: Session, app_id: str) -> int:
def _agent_api_key_count(session: Session, app_model: App) -> int:
return (
session.scalar(
select(func.count(ApiToken.id)).where(
or_(ApiToken.tenant_id == app_model.tenant_id, ApiToken.tenant_id.is_(None)),
ApiToken.type == ApiTokenType.APP,
ApiToken.app_id == app_id,
ApiToken.app_id == app_model.id,
)
)
or 0
@@ -521,7 +522,7 @@ def _serialize_agent_api_access(session: Session, app_model: App) -> dict:
meta_endpoint=f"{base_url}/meta",
api_rpm=app_model.api_rpm or 0,
api_rph=app_model.api_rph or 0,
api_key_count=_agent_api_key_count(session, str(app_model.id)),
api_key_count=_agent_api_key_count(session, app_model),
)
return response.model_dump(mode="json")
@@ -940,6 +941,8 @@ class AgentApiKeyListApi(BaseApiKeyListResource):
@console_ns.response(200, "Agent service API keys", console_ns.models[ApiKeyList.__name__])
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
@with_current_tenant_id
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION)
@with_session(write=False)
def get(self, session: Session, tenant_id: str, agent_id: UUID) -> dict[str, object]:
app_model = _resolve_agent_app_model(session, tenant_id=tenant_id, agent_id=agent_id)
+11 -4
View File
@@ -5,7 +5,7 @@ import flask_restx
from flask_restx import Resource
from flask_restx._http import HTTPStatus
from pydantic import field_validator
from sqlalchemy import delete, func, select
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden
@@ -89,7 +89,9 @@ class BaseApiKeyListResource(Resource):
_get_resource(resource_id, current_tenant_id, self.resource_model, session=session)
keys = session.scalars(
select(ApiToken).where(
ApiToken.type == self.resource_type, getattr(ApiToken, self.resource_id_field) == resource_id
or_(ApiToken.tenant_id == current_tenant_id, ApiToken.tenant_id.is_(None)),
ApiToken.type == self.resource_type,
getattr(ApiToken, self.resource_id_field) == resource_id,
)
).all()
return ApiKeyList.model_validate({"data": keys}, from_attributes=True)
@@ -110,7 +112,9 @@ class BaseApiKeyListResource(Resource):
current_key_count: int = (
session.scalar(
select(func.count(ApiToken.id)).where(
ApiToken.type == self.resource_type, getattr(ApiToken, self.resource_id_field) == resource_id
or_(ApiToken.tenant_id == current_tenant_id, ApiToken.tenant_id.is_(None)),
ApiToken.type == self.resource_type,
getattr(ApiToken, self.resource_id_field) == resource_id,
)
)
or 0
@@ -172,6 +176,7 @@ class BaseApiKeyResource(Resource):
key = session.scalar(
select(ApiToken)
.where(
or_(ApiToken.tenant_id == current_tenant_id, ApiToken.tenant_id.is_(None)),
getattr(ApiToken, self.resource_id_field) == resource_id,
ApiToken.type == self.resource_type,
ApiToken.id == api_key_id,
@@ -187,7 +192,7 @@ class BaseApiKeyResource(Resource):
assert key is not None # nosec - for type checker only
ApiTokenCache.delete(key.token, key.type)
session.execute(delete(ApiToken).where(ApiToken.id == api_key_id))
session.delete(key)
session.commit()
@@ -198,6 +203,8 @@ class AppApiKeyListResource(BaseApiKeyListResource):
@console_ns.doc(params={"resource_id": "App ID"})
@console_ns.response(200, "API keys retrieved successfully", console_ns.models[ApiKeyList.__name__])
@with_current_tenant_id
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION)
@agent_manage_required_for_agent_app
@with_session(write=False)
def get(self, session: Session, current_tenant_id: str, resource_id: UUID) -> dict[str, object]:
@@ -1106,6 +1106,8 @@ class DatasetApiKeyApi(Resource):
@console_ns.response(200, "API keys retrieved successfully", console_ns.models[ApiKeyList.__name__])
@setup_required
@login_required
@is_admin_or_owner_required
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_API_KEY_MANAGE, resource_required=False)
@account_initialization_required
@with_current_tenant_id
@with_session(write=False)
@@ -65,8 +65,8 @@ from controllers.console.app.message import (
)
from core.app.entities.app_invoke_entities import InvokeFrom
from models.agent import Agent, AgentConfigDraftType, AgentScope, AgentSource, AgentStatus
from models.enums import ConversationFromSource
from models.model import AppMode, Conversation, Message
from models.enums import ApiTokenType, ConversationFromSource
from models.model import ApiToken, App, AppMode, Conversation, Message
from services.entities.agent_entities import (
ComposerSavePayload,
ComposerSaveStrategy,
@@ -851,7 +851,7 @@ def test_agent_api_access_uses_agent_id_and_returns_service_api_metadata(monkeyp
api_rph=600,
)
monkeypatch.setattr(roster_controller, "_resolve_agent_app_model", lambda _session, **kwargs: app_model)
monkeypatch.setattr(roster_controller, "_agent_api_key_count", lambda _session, app_id: 2)
monkeypatch.setattr(roster_controller, "_agent_api_key_count", lambda _session, _app: 2)
monkeypatch.setattr(roster_controller, "_agent_app_access_ready", lambda _session, _app: True)
response = unwrap(AgentApiAccessApi.get)(AgentApiAccessApi(), MagicMock(), "tenant-1", agent_id)
assert response == {
@@ -873,6 +873,20 @@ def test_agent_api_access_uses_agent_id_and_returns_service_api_metadata(monkeyp
}
def test_agent_api_key_count_scopes_tenant_and_keeps_legacy_tokens(sqlite_session: Session) -> None:
app_model = cast(App, _app_detail_obj())
sqlite_session.add_all(
[
ApiToken(type=ApiTokenType.APP, token="owned", app_id=app_model.id, tenant_id=app_model.tenant_id),
ApiToken(type=ApiTokenType.APP, token="legacy", app_id=app_model.id, tenant_id=None),
ApiToken(type=ApiTokenType.APP, token="foreign", app_id=app_model.id, tenant_id="tenant-2"),
]
)
sqlite_session.commit()
assert roster_controller._agent_api_key_count(sqlite_session, app_model) == 2
def test_agent_api_status_and_key_routes_resolve_backing_app(
app: Flask, monkeypatch: pytest.MonkeyPatch, unbound_session: Session
) -> None:
@@ -889,7 +903,7 @@ def test_agent_api_status_and_key_routes_resolve_backing_app(
captured: dict[str, object] = {}
resolve_app = Mock(return_value=app_model)
monkeypatch.setattr(roster_controller, "_resolve_agent_app_model", resolve_app)
monkeypatch.setattr(roster_controller, "_agent_api_key_count", lambda _session, app_id: 1)
monkeypatch.setattr(roster_controller, "_agent_api_key_count", lambda _session, _app: 1)
monkeypatch.setattr(roster_controller, "_agent_app_access_ready", lambda _session, _app: True)
class FakeAppService:
@@ -3,14 +3,25 @@ from __future__ import annotations
import inspect
from collections.abc import Callable
from typing import cast
from unittest.mock import patch
from unittest.mock import MagicMock, patch
from uuid import UUID
import pytest
from flask import Flask
from sqlalchemy import event, select
from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden
from werkzeug.exceptions import BadRequest, Forbidden, NotFound
from controllers.console.apikey import BaseApiKeyListResource, BaseApiKeyResource
from configs import dify_config
from controllers.console.agent.roster import AgentApiKeyListApi
from controllers.console.apikey import (
AppApiKeyListResource,
BaseApiKeyListResource,
BaseApiKeyResource,
)
from controllers.console.datasets.datasets import DatasetApiKeyApi
from core.rbac import RBACPermission, RBACResourceScope
from enums import DeploymentEdition
from models import Account
from models.account import AccountStatus, TenantAccountRole
from models.enums import ApiTokenType
@@ -79,14 +90,22 @@ def test_list_api_keys_uses_injected_session_and_tenant_id(sqlite_session: Sessi
)
api_key.id = "key-1"
session.add(api_key)
session.add(
ApiToken(
type=ApiTokenType.APP,
token="foreign-app-token",
app_id="app-1",
tenant_id="tenant-2",
)
)
legacy_api_key = ApiToken(type=ApiTokenType.APP, token="legacy-app-token", app_id="app-1", tenant_id=None)
session.add(legacy_api_key)
session.commit()
result = raw_get(resource, session, "app-1", "tenant-1")
data = cast(list[dict[str, object]], result["data"])
assert len(data) == 1
assert data[0]["id"] == "key-1"
assert data[0]["token"] == "app-token"
assert {item["token"] for item in data} == {"app-token", "legacy-app-token"}
def test_create_api_key_uses_injected_session_and_tenant_id(sqlite_session: Session) -> None:
@@ -97,6 +116,13 @@ def test_create_api_key_uses_injected_session_and_tenant_id(sqlite_session: Sess
)
session = sqlite_session
_persist_app(session)
session.add_all(
[
ApiToken(type=ApiTokenType.APP, token=f"foreign-token-{index}", app_id="app-1", tenant_id="tenant-2")
for index in range(resource.max_keys)
]
)
session.commit()
commits: list[str] = []
event.listen(session, "after_commit", lambda _session: commits.append("commit"))
@@ -116,6 +142,21 @@ def test_create_api_key_uses_injected_session_and_tenant_id(sqlite_session: Sess
assert commits == ["commit"]
def test_create_api_key_counts_legacy_tokens(sqlite_session: Session) -> None:
resource = _make_list_resource()
_persist_app(sqlite_session)
sqlite_session.add_all(
[
ApiToken(type=ApiTokenType.APP, token=f"legacy-token-{index}", app_id="app-1", tenant_id=None)
for index in range(resource.max_keys)
]
)
sqlite_session.commit()
with pytest.raises(BadRequest):
resource._create_api_key("app-1", "tenant-1", session=sqlite_session)
def test_create_agent_api_key_requires_published_access(sqlite_session: Session) -> None:
resource = _make_list_resource()
session = sqlite_session
@@ -160,7 +201,7 @@ def test_delete_api_key_uses_injected_session_user_and_tenant(sqlite_session: Se
)
session = sqlite_session
_persist_app(session)
api_key = ApiToken(type=ApiTokenType.APP, token="app-token", app_id="app-1", tenant_id="tenant-1")
api_key = ApiToken(type=ApiTokenType.APP, token="app-token", app_id="app-1", tenant_id=None)
api_key.id = "key-1"
session.add(api_key)
session.commit()
@@ -182,3 +223,101 @@ def test_delete_api_key_uses_injected_session_user_and_tenant(sqlite_session: Se
assert commits == ["commit"]
assert result == ""
assert status == 204
def test_delete_api_key_rejects_foreign_tenant_token(sqlite_session: Session) -> None:
resource = _make_key_resource()
session = sqlite_session
_persist_app(session)
api_key = ApiToken(type=ApiTokenType.APP, token="foreign-token", app_id="app-1", tenant_id="tenant-2")
api_key.id = "key-1"
session.add(api_key)
session.commit()
with patch("controllers.console.apikey.ApiTokenCache.delete") as delete_cache:
with pytest.raises(NotFound):
resource._delete_api_key(
"app-1",
"key-1",
"tenant-1",
_make_account(TenantAccountRole.OWNER),
session=session,
)
delete_cache.assert_not_called()
assert session.get(ApiToken, "key-1") is api_key
def test_api_key_lists_require_matching_rbac_permission() -> None:
app = Flask(__name__)
account = _make_account(TenantAccountRole.OWNER)
api_id = UUID("00000000-0000-0000-0000-000000000001")
cases = [
(
lambda: AppApiKeyListResource().get(resource_id=api_id),
[(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION, True)],
),
(
lambda: AgentApiKeyListApi().get(agent_id=api_id),
[
(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, False),
(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION, True),
],
),
(
lambda: DatasetApiKeyApi().get(),
[(RBACResourceScope.DATASET, RBACPermission.DATASET_API_KEY_MANAGE, False)],
),
]
with (
app.test_request_context("/"),
patch.object(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD),
patch.object(dify_config, "LOGIN_DISABLED", True),
patch.object(dify_config, "RBAC_ENABLED", True),
patch("controllers.console.wraps.current_account_with_tenant", return_value=(account, "tenant-1")),
patch("controllers.common.wraps.current_account_with_tenant", return_value=(account, "tenant-1")),
patch.object(BaseApiKeyListResource, "_get_api_key_list") as get_api_key_list,
):
for invoke, expected_gates in cases:
with patch(
"controllers.common.wraps.enforce_rbac_access",
side_effect=[None] * (len(expected_gates) - 1) + [Forbidden()],
) as enforce_rbac_access:
with pytest.raises(Forbidden):
invoke()
assert [
(kwargs["resource_type"], kwargs["scene"], kwargs["resource_required"])
for _, kwargs in enforce_rbac_access.call_args_list
] == expected_gates
get_api_key_list.assert_not_called()
def test_api_key_lists_reject_legacy_read_only_members() -> None:
app = Flask(__name__)
account = _make_account(TenantAccountRole.NORMAL)
api_id = UUID("00000000-0000-0000-0000-000000000001")
current_user = MagicMock()
current_user._get_current_object.return_value = account
current_user.has_edit_permission = False
with (
app.test_request_context("/"),
patch.object(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD),
patch.object(dify_config, "LOGIN_DISABLED", True),
patch.object(dify_config, "RBAC_ENABLED", False),
patch("libs.login.current_user", current_user),
patch("controllers.console.wraps.current_account_with_tenant", return_value=(account, "tenant-1")),
patch.object(BaseApiKeyListResource, "_get_api_key_list") as get_api_key_list,
):
for invoke in (
lambda: AppApiKeyListResource().get(resource_id=api_id),
lambda: AgentApiKeyListApi().get(agent_id=api_id),
lambda: DatasetApiKeyApi().get(),
):
with pytest.raises(Forbidden):
invoke()
get_api_key_list.assert_not_called()
@@ -86,12 +86,12 @@ describe('ApiSecretKeyButton', () => {
).toBeDisabled()
})
it('keeps the current count visible without management permission', () => {
it('does not request API keys without management permission', () => {
render(<ApiSecretKeyButton appId="app-1" canManage={false} />)
expect(
screen.getByRole('button', {
name: 'appApi.apiKeyModal.apiSecretKey 2',
name: 'appApi.apiKeyModal.apiSecretKey 0',
}),
).toBeDisabled()
})
@@ -16,6 +16,11 @@ const mocks = vi.hoisted(() => ({
},
webCard: vi.fn(),
apiCard: vi.fn(),
capabilities: {
canEdit: false,
canDeploy: true,
canReleaseAndVersion: false,
},
mcpCard: vi.fn(),
triggerCard: vi.fn(),
}))
@@ -59,11 +64,7 @@ vi.mock('@/service/use-workflow', () => ({
}))
vi.mock('@/utils/permission', () => ({
getAppACLCapabilities: () => ({
canEdit: false,
canDeploy: true,
canReleaseAndVersion: false,
}),
getAppACLCapabilities: () => mocks.capabilities,
}))
vi.mock('../shared/use-access-point-actions', () => ({
@@ -119,6 +120,11 @@ describe('BuiltInAccessPoints', () => {
data: null,
isPending: false,
}
mocks.capabilities = {
canEdit: false,
canDeploy: true,
canReleaseAndVersion: false,
}
})
it('renders the unpublished state across all access point cards', () => {
@@ -129,7 +135,7 @@ describe('BuiltInAccessPoints', () => {
expect.objectContaining({ availability: 'unavailable', canDeploy: true, canEdit: false }),
)
expect(mocks.apiCard).toHaveBeenCalledWith(
expect.objectContaining({ availability: 'unavailable', canEdit: false }),
expect.objectContaining({ availability: 'unavailable', canManage: false }),
)
expect(mocks.mcpCard).toHaveBeenCalledTimes(1)
expect(mocks.triggerCard).toHaveBeenCalledWith(
@@ -163,6 +169,18 @@ describe('BuiltInAccessPoints', () => {
)
})
it('does not use edit permission to manage the Service API', () => {
mocks.capabilities = {
canEdit: true,
canDeploy: true,
canReleaseAndVersion: false,
}
render(<BuiltInAccessPoints appId="app-1" />)
expect(mocks.apiCard).toHaveBeenCalledWith(expect.objectContaining({ canManage: false }))
})
it('highlights only the targeted built-in access point card', () => {
render(<BuiltInAccessPoints appId="app-1" highlightedAccessPoint="mcp" />)
@@ -52,7 +52,7 @@ describe('ServiceApiAccessPointCard', () => {
<ServiceApiAccessPointCard
appInfo={createAppInfo(mode)}
availability="available"
canEdit
canManage
onChangeStatus={vi.fn().mockResolvedValue(undefined)}
/>,
)
@@ -69,7 +69,7 @@ describe('ServiceApiAccessPointCard', () => {
<ServiceApiAccessPointCard
appInfo={createAppInfo(AppModeEnum.WORKFLOW)}
availability="loading"
canEdit
canManage
onChangeStatus={vi.fn().mockResolvedValue(undefined)}
/>,
)
@@ -87,7 +87,7 @@ describe('ServiceApiAccessPointCard', () => {
<ServiceApiAccessPointCard
appInfo={createAppInfo(AppModeEnum.WORKFLOW, { enable_api: false })}
availability="available"
canEdit
canManage
onChangeStatus={vi.fn().mockResolvedValue(undefined)}
/>,
)
@@ -101,12 +101,26 @@ describe('ServiceApiAccessPointCard', () => {
)
})
it('disables API management without release permission', () => {
render(
<ServiceApiAccessPointCard
appInfo={createAppInfo(AppModeEnum.WORKFLOW)}
availability="available"
canManage={false}
onChangeStatus={vi.fn().mockResolvedValue(undefined)}
/>,
)
expect(screen.getByRole('button', { name: 'api-secret-keys' })).toBeDisabled()
expect(screen.getByRole('switch')).toHaveAttribute('aria-disabled', 'true')
})
it('disables API keys and external documentation when the access point is unavailable', () => {
render(
<ServiceApiAccessPointCard
appInfo={createAppInfo(AppModeEnum.WORKFLOW)}
availability="unavailable"
canEdit
canManage
onChangeStatus={vi.fn().mockResolvedValue(undefined)}
/>,
)
@@ -0,0 +1,48 @@
import { act } from '@testing-library/react'
import { renderHookWithConsoleQuery } from '@/test/console/query-data'
import { useAccessPointActions } from '../shared/use-access-point-actions'
const mocks = vi.hoisted(() => ({
onAppStateUpdate: vi.fn(() => vi.fn()),
setAppDetail: vi.fn(),
updateAppSiteStatus: vi.fn().mockResolvedValue({}),
}))
vi.mock('@langgenius/dify-ui/toast', () => ({ toast: vi.fn() }))
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: { setAppDetail: typeof mocks.setAppDetail }) => unknown) =>
selector({ setAppDetail: mocks.setAppDetail }),
}))
vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', () => ({
collaborationManager: { onAppStateUpdate: mocks.onAppStateUpdate },
}))
vi.mock('@/app/components/workflow/collaboration/core/websocket-manager', () => ({
webSocketClient: { getSocket: vi.fn() },
}))
vi.mock('@/service/apps', () => ({
fetchAppDetail: vi.fn().mockResolvedValue({}),
updateAppSiteAccessToken: vi.fn(),
updateAppSiteConfig: vi.fn(),
updateAppSiteStatus: mocks.updateAppSiteStatus,
}))
describe('useAccessPointActions', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('allows the API status to be changed independently of app editing', async () => {
const { result } = renderHookWithConsoleQuery(() => useAccessPointActions('app-1', false))
await act(() => result.current.changeApiStatus(true))
expect(mocks.updateAppSiteStatus).toHaveBeenCalledWith({
url: '/apps/app-1/api-enable',
body: { enable_api: true },
})
})
})
@@ -116,7 +116,7 @@ export function BuiltInAccessPoints({ appId, highlightedAccessPoint }: BuiltInAc
<ServiceApiAccessPointCard
appInfo={appInfo}
availability={appCardAvailability}
canEdit={capabilities.canEdit}
canManage={capabilities.canReleaseAndVersion}
onChangeStatus={actions.changeApiStatus}
highlighted={highlightedAccessPoint === 'serviceApi'}
/>
@@ -9,7 +9,7 @@ import { getBuiltInAccessUrls } from '../shared/utils'
type ServiceApiAccessPointCardProps = {
appInfo: AccessPointAppInfo
availability: AccessPointAvailability
canEdit: boolean
canManage: boolean
highlighted?: boolean
onChangeStatus: (enabled: boolean) => Promise<void>
}
@@ -17,7 +17,7 @@ type ServiceApiAccessPointCardProps = {
export function ServiceApiAccessPointCard({
appInfo,
availability,
canEdit,
canManage,
highlighted,
onChangeStatus,
}: ServiceApiAccessPointCardProps) {
@@ -29,7 +29,7 @@ export function ServiceApiAccessPointCard({
<ServiceApiCardView
apiKeyButtonProps={{
appId: appInfo.id,
canManage: canEdit,
canManage,
disabled: availability !== 'available',
}}
apiUrl={apiUrl}
@@ -37,7 +37,7 @@ export function ServiceApiAccessPointCard({
available={availability === 'available'}
status={status}
highlighted={highlighted}
switchDisabled={!canEdit}
switchDisabled={!canManage}
onEnabledChange={availability === 'available' ? onChangeStatus : undefined}
/>
)
@@ -28,7 +28,7 @@ export function ApiSecretKeyButton({
const isEnvironmentScope = Boolean(environmentId)
const apiKeysQuery = useQuery(
consoleQuery.apps.byResourceId.apiKeys.get.queryOptions({
input: isEnvironmentScope ? skipToken : { params: { resource_id: appId } },
input: isEnvironmentScope || !canManage ? skipToken : { params: { resource_id: appId } },
}),
)
const apiKeyCount = isEnvironmentScope
@@ -81,7 +81,6 @@ export function useAccessPointActions(appId: string, canEdit: boolean) {
const changeApiStatus = useCallback(
async (enabled: boolean) => {
if (!canEdit) return
const [error] = await asyncRunSafe<App>(
updateAppSiteStatus({
url: `/apps/${appId}/api-enable`,
@@ -90,7 +89,7 @@ export function useAccessPointActions(appId: string, canEdit: boolean) {
)
handleResult(error)
},
[appId, canEdit, handleResult],
[appId, handleResult],
)
const saveSiteConfig = useCallback(
@@ -1,5 +1,6 @@
import { screen } from '@testing-library/react'
import { renderWithAccountProfile as render } from '@/test/console/account-profile'
import { AppACLPermission } from '@/utils/permission'
import DevelopMain from '../index'
const mockAppDetailValue: { current: unknown } = { current: undefined }
@@ -24,8 +25,16 @@ vi.mock('@/app/components/develop/doc', () => ({
}))
vi.mock('@/app/components/develop/ApiServer', () => ({
default: ({ apiBaseUrl, appId }: { apiBaseUrl: string; appId: string }) => (
<div data-testid="api-server">
default: ({
apiBaseUrl,
appId,
canManageApiKey,
}: {
apiBaseUrl: string
appId: string
canManageApiKey: boolean
}) => (
<div data-testid="api-server" data-can-manage-api-key={canManageApiKey}>
API Server -{apiBaseUrl} -{appId}
</div>
),
@@ -104,6 +113,16 @@ describe('DevelopMain', () => {
expect(screen.getByTestId('api-server')).toHaveTextContent('app-123')
})
it.each([
[[AppACLPermission.ReleaseAndVersion], 'true'],
[[AppACLPermission.Edit], 'false'],
])('should gate API key management by release permission', (permissionKeys, canManage) => {
mockAppDetailValue.current = { ...mockAppDetail, permission_keys: permissionKeys }
render(<DevelopMain appId="app-123" />)
expect(screen.getByTestId('api-server')).toHaveAttribute('data-can-manage-api-key', canManage)
})
it('should render Doc component', () => {
render(<DevelopMain appId="app-123" />)
expect(screen.getByTestId('doc-component')).toBeInTheDocument()
+1 -1
View File
@@ -42,7 +42,7 @@ const DevelopMain = ({ appId }: IDevelopMainProps) => {
<ApiServer
apiBaseUrl={appDetail.api_base_url}
appId={appId}
canManageApiKey={appACLCapabilities.canEdit}
canManageApiKey={appACLCapabilities.canReleaseAndVersion}
/>
</div>
<div className="grow overflow-auto p-4 sm:px-10">