mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-18 01:38:03 +00:00
Compare commits
4 Commits
codex/part
...
codex/part
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a582f62e2 | ||
|
|
a0b3c1b436 | ||
|
|
397559b080 | ||
|
|
aa41a160fe |
126
browser_tests/tests/dialogs/partnerNodeAllowlist.spec.ts
Normal file
126
browser_tests/tests/dialogs/partnerNodeAllowlist.spec.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { ListAssetsResponse } from '@comfyorg/ingest-types'
|
||||
|
||||
import type { PartnerNodePolicyResponse } from '@/platform/workspace/api/partnerNodePolicyApi'
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { WORKSPACE_FEATURE_FLAG } from '@e2e/fixtures/data/cloudWorkspace'
|
||||
import { CloudWorkspaceMockHelper } from '@e2e/fixtures/helpers/CloudWorkspaceMockHelper'
|
||||
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
|
||||
|
||||
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
|
||||
function partnerNode(
|
||||
name: string,
|
||||
displayName: string,
|
||||
provider: string
|
||||
): ComfyNodeDef {
|
||||
return {
|
||||
name,
|
||||
display_name: displayName,
|
||||
category: `partner/image/${provider}`,
|
||||
python_module: `comfy_api_nodes.${provider.toLocaleLowerCase()}`,
|
||||
description: '',
|
||||
input: {},
|
||||
output: [],
|
||||
output_is_list: [],
|
||||
output_name: [],
|
||||
output_node: false,
|
||||
api_node: true
|
||||
}
|
||||
}
|
||||
|
||||
async function openAllowlist(page: Page) {
|
||||
await page.goto(APP_URL)
|
||||
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
|
||||
timeout: 45_000
|
||||
})
|
||||
await page
|
||||
.getByRole('button', { name: /^Settings/ })
|
||||
.first()
|
||||
.click()
|
||||
|
||||
const dialog = page.getByTestId('settings-dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
await dialog.locator('nav').getByRole('button', { name: 'Workspace' }).click()
|
||||
|
||||
const content = dialog.getByRole('main')
|
||||
await content.getByRole('tab', { name: 'Allowlist' }).click()
|
||||
await expect(
|
||||
content.getByRole('heading', { name: 'Partner nodes' })
|
||||
).toBeVisible()
|
||||
return content
|
||||
}
|
||||
|
||||
test.describe('Partner node allowlist', { tag: '@cloud' }, () => {
|
||||
test.describe.configure({ timeout: 60_000 })
|
||||
|
||||
test('saves enforcement and allowlist as one policy', async ({ page }) => {
|
||||
await new CloudWorkspaceMockHelper(page).setup()
|
||||
|
||||
await page.route('**/api/features', (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
...WORKSPACE_FEATURE_FLAG,
|
||||
partner_node_governance_enabled: true
|
||||
} satisfies RemoteConfig)
|
||||
)
|
||||
)
|
||||
await page.route('**/api/object_info', (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
FluxFill: partnerNode('FluxFill', 'Flux Fill', 'BFL'),
|
||||
FluxExpand: partnerNode('FluxExpand', 'Flux Expand', 'BFL'),
|
||||
VeoVideo: partnerNode('VeoVideo', 'Veo Video', 'Google')
|
||||
})
|
||||
)
|
||||
)
|
||||
await page.route(/\/api\/assets(?:\?.*)?$/, (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
assets: [],
|
||||
total: 0,
|
||||
has_more: false
|
||||
} satisfies ListAssetsResponse)
|
||||
)
|
||||
)
|
||||
|
||||
let policy: PartnerNodePolicyResponse = {
|
||||
enforcement_enabled: false,
|
||||
nodes: { FluxFill: false, FluxExpand: true, VeoVideo: true }
|
||||
} satisfies PartnerNodePolicyResponse
|
||||
const updates: unknown[] = []
|
||||
await page.route('**/api/workspace/partner-node-policy', (route) => {
|
||||
if (route.request().method() === 'PUT') {
|
||||
policy = route.request().postDataJSON() as PartnerNodePolicyResponse
|
||||
updates.push(policy)
|
||||
}
|
||||
return route.fulfill(jsonRoute(policy))
|
||||
})
|
||||
|
||||
const content = await openAllowlist(page)
|
||||
const fluxFill = content.getByRole('switch', { name: 'Allow Flux Fill' })
|
||||
await expect(fluxFill).not.toBeChecked()
|
||||
await expect(
|
||||
content.getByRole('switch', { name: 'Allow Flux Expand' })
|
||||
).toBeChecked()
|
||||
|
||||
await content
|
||||
.getByRole('switch', { name: 'Enforce partner node allowlist' })
|
||||
.click()
|
||||
await fluxFill.click()
|
||||
await content.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
await expect(page.getByText('Partner node policy saved')).toBeVisible()
|
||||
expect(updates).toEqual([
|
||||
{
|
||||
enforcement_enabled: true,
|
||||
nodes: { FluxFill: true, FluxExpand: true, VeoVideo: true }
|
||||
}
|
||||
] satisfies PartnerNodePolicyResponse[])
|
||||
})
|
||||
})
|
||||
@@ -2919,7 +2919,27 @@
|
||||
"tabs": {
|
||||
"dashboard": "Dashboard",
|
||||
"planCredits": "Plan & Credits",
|
||||
"membersCount": "Members ({count})"
|
||||
"membersCount": "Members ({count})",
|
||||
"allowlist": "Allowlist"
|
||||
},
|
||||
"allowlist": {
|
||||
"title": "Partner nodes",
|
||||
"description": "Choose which partner nodes are allowed when workspace enforcement is enabled.",
|
||||
"searchPlaceholder": "Search partner nodes...",
|
||||
"loading": "Loading partner node policy...",
|
||||
"loadError": "Partner node policy could not be loaded.",
|
||||
"retry": "Try again",
|
||||
"empty": "No partner nodes are available.",
|
||||
"noMatches": "No partner nodes match your search.",
|
||||
"allowedCount": "{enabled} of {total} allowed",
|
||||
"nodeToggle": "Allow {name}",
|
||||
"saved": "Partner node policy saved",
|
||||
"saveError": "Partner node policy could not be saved.",
|
||||
"enforcement": {
|
||||
"title": "Enforce partner node allowlist",
|
||||
"description": "Block partner nodes that are not explicitly allowed.",
|
||||
"toggle": "Enforce partner node allowlist"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"placeholder": "Dashboard workspace settings"
|
||||
|
||||
@@ -2,7 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
getPartnerNodePolicy,
|
||||
PartnerNodePolicyApiError
|
||||
PartnerNodePolicyApiError,
|
||||
updatePartnerNodePolicy
|
||||
} from '@/platform/workspace/api/partnerNodePolicyApi'
|
||||
|
||||
const mockFetchApi = vi.fn()
|
||||
@@ -67,4 +68,54 @@ describe('partnerNodePolicyApi', () => {
|
||||
name: 'ZodError'
|
||||
})
|
||||
})
|
||||
|
||||
it('serializes and validates a whole-policy update', async () => {
|
||||
mockFetchApi.mockResolvedValue(
|
||||
jsonResponse({
|
||||
enforcement_enabled: false,
|
||||
nodes: { AllowedNode: true, DisabledNode: true }
|
||||
})
|
||||
)
|
||||
|
||||
await expect(
|
||||
updatePartnerNodePolicy({
|
||||
enforcementEnabled: false,
|
||||
nodes: { AllowedNode: true, DisabledNode: true }
|
||||
})
|
||||
).resolves.toEqual({
|
||||
enforcementEnabled: false,
|
||||
nodes: { AllowedNode: true, DisabledNode: true }
|
||||
})
|
||||
expect(mockFetchApi).toHaveBeenCalledWith(
|
||||
'/workspace/partner-node-policy',
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
enforcement_enabled: false,
|
||||
nodes: { AllowedNode: true, DisabledNode: true }
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves update failures for the editor', async () => {
|
||||
mockFetchApi.mockResolvedValue(
|
||||
jsonResponse({}, { status: 409, statusText: 'Conflict' })
|
||||
)
|
||||
|
||||
await expect(
|
||||
updatePartnerNodePolicy({ enforcementEnabled: false, nodes: {} })
|
||||
).rejects.toEqual(new PartnerNodePolicyApiError(409, 'Conflict'))
|
||||
})
|
||||
|
||||
it('rejects malformed update responses', async () => {
|
||||
mockFetchApi.mockResolvedValue(
|
||||
jsonResponse({ enforcement_enabled: false, nodes: [] })
|
||||
)
|
||||
|
||||
await expect(
|
||||
updatePartnerNodePolicy({ enforcementEnabled: false, nodes: {} })
|
||||
).rejects.toMatchObject({ name: 'ZodError' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,11 +2,17 @@ import { z } from 'zod'
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
const PARTNER_NODE_POLICY_PATH = '/workspace/partner-node-policy'
|
||||
|
||||
const partnerNodePolicyResponseSchema = z.object({
|
||||
enforcement_enabled: z.boolean(),
|
||||
nodes: z.record(z.string(), z.boolean())
|
||||
})
|
||||
|
||||
export type PartnerNodePolicyResponse = z.infer<
|
||||
typeof partnerNodePolicyResponseSchema
|
||||
>
|
||||
|
||||
export interface PartnerNodePolicy {
|
||||
enforcementEnabled: boolean
|
||||
nodes: Readonly<Record<string, boolean>>
|
||||
@@ -22,18 +28,40 @@ export class PartnerNodePolicyApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function parsePartnerNodePolicy(data: unknown): PartnerNodePolicy {
|
||||
const policy = partnerNodePolicyResponseSchema.parse(data)
|
||||
return {
|
||||
enforcementEnabled: policy.enforcement_enabled,
|
||||
nodes: policy.nodes
|
||||
}
|
||||
}
|
||||
|
||||
function throwResponseError(response: Response): never {
|
||||
throw new PartnerNodePolicyApiError(response.status, response.statusText)
|
||||
}
|
||||
|
||||
export async function getPartnerNodePolicy(): Promise<PartnerNodePolicy | null> {
|
||||
const response = await api.fetchApi('/workspace/partner-node-policy', {
|
||||
const response = await api.fetchApi(PARTNER_NODE_POLICY_PATH, {
|
||||
cache: 'no-store'
|
||||
})
|
||||
if (response.status === 404) return null
|
||||
if (!response.ok) {
|
||||
throw new PartnerNodePolicyApiError(response.status, response.statusText)
|
||||
}
|
||||
if (!response.ok) throwResponseError(response)
|
||||
|
||||
const data = partnerNodePolicyResponseSchema.parse(await response.json())
|
||||
return {
|
||||
enforcementEnabled: data.enforcement_enabled,
|
||||
nodes: data.nodes
|
||||
}
|
||||
return parsePartnerNodePolicy(await response.json())
|
||||
}
|
||||
|
||||
export async function updatePartnerNodePolicy(
|
||||
policy: PartnerNodePolicy
|
||||
): Promise<PartnerNodePolicy> {
|
||||
const response = await api.fetchApi(PARTNER_NODE_POLICY_PATH, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
enforcement_enabled: policy.enforcementEnabled,
|
||||
nodes: policy.nodes
|
||||
})
|
||||
})
|
||||
if (!response.ok) throwResponseError(response)
|
||||
|
||||
return parsePartnerNodePolicy(await response.json())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { defineComponent, nextTick } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
import type { PartnerNodePolicy } from '@/platform/workspace/api/partnerNodePolicyApi'
|
||||
import type { PartnerNodePolicyStatus } from '@/platform/workspace/stores/partnerNodeGovernanceStore'
|
||||
|
||||
import PartnerNodeAllowlistPanel from './PartnerNodeAllowlistPanel.vue'
|
||||
|
||||
const mockSavePolicy = vi.fn()
|
||||
const mockLoadPolicy = vi.fn()
|
||||
const mockToastAdd = vi.fn()
|
||||
|
||||
const state = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
|
||||
const { ref } = require('vue') as typeof import('vue')
|
||||
return {
|
||||
governedWorkspaceId: ref<string | null>('workspace-one'),
|
||||
partnerNodes: ref([
|
||||
{ id: 'FluxFill', name: 'Flux Fill', provider: 'BFL' },
|
||||
{ id: 'FluxExpand', name: 'Flux Expand', provider: 'BFL' },
|
||||
{ id: 'VeoVideo', name: 'Veo Video', provider: 'Google' }
|
||||
]),
|
||||
policy: ref<PartnerNodePolicy | null>({
|
||||
enforcementEnabled: true,
|
||||
nodes: {
|
||||
FluxFill: false,
|
||||
FluxExpand: true,
|
||||
VeoVideo: true,
|
||||
RetiredNode: false
|
||||
}
|
||||
}),
|
||||
status: ref<PartnerNodePolicyStatus>('configured')
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('pinia', async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...(actual as object),
|
||||
storeToRefs: (store: Record<string, unknown>) => store
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/workspace/stores/partnerNodeGovernanceStore', () => ({
|
||||
usePartnerNodeGovernanceStore: () => ({
|
||||
...state,
|
||||
savePolicy: mockSavePolicy,
|
||||
loadPolicy: mockLoadPolicy
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => ({ add: mockToastAdd })
|
||||
}))
|
||||
|
||||
const ToggleSwitchStub = defineComponent({
|
||||
name: 'ToggleSwitch',
|
||||
props: {
|
||||
modelValue: Boolean,
|
||||
disabled: Boolean,
|
||||
ariaLabel: String
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<button
|
||||
role="switch"
|
||||
:aria-checked="modelValue"
|
||||
:aria-label="ariaLabel"
|
||||
:disabled="disabled"
|
||||
@click="$emit('update:modelValue', !modelValue)"
|
||||
/>
|
||||
`
|
||||
})
|
||||
|
||||
const SearchInputStub = defineComponent({
|
||||
name: 'SearchInput',
|
||||
props: {
|
||||
modelValue: { type: String, required: true },
|
||||
placeholder: String,
|
||||
disabled: Boolean
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<input
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
/>
|
||||
`
|
||||
})
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
function renderPanel() {
|
||||
return render(PartnerNodeAllowlistPanel, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
ToggleSwitch: ToggleSwitchStub,
|
||||
SearchInput: SearchInputStub
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('PartnerNodeAllowlistPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
state.governedWorkspaceId.value = 'workspace-one'
|
||||
state.partnerNodes.value = [
|
||||
{ id: 'FluxFill', name: 'Flux Fill', provider: 'BFL' },
|
||||
{ id: 'FluxExpand', name: 'Flux Expand', provider: 'BFL' },
|
||||
{ id: 'VeoVideo', name: 'Veo Video', provider: 'Google' }
|
||||
]
|
||||
state.policy.value = {
|
||||
enforcementEnabled: true,
|
||||
nodes: {
|
||||
FluxFill: false,
|
||||
FluxExpand: true,
|
||||
VeoVideo: true,
|
||||
RetiredNode: false
|
||||
}
|
||||
}
|
||||
state.status.value = 'configured'
|
||||
mockSavePolicy.mockResolvedValue(true)
|
||||
})
|
||||
|
||||
it('groups catalog nodes and reflects the configured allowlist', () => {
|
||||
renderPanel()
|
||||
|
||||
expect(screen.getByText('BFL')).toBeInTheDocument()
|
||||
expect(screen.getByText('Google')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('switch', {
|
||||
name: 'Enforce partner node allowlist'
|
||||
})
|
||||
).toBeChecked()
|
||||
expect(
|
||||
screen.getByRole('switch', { name: 'Allow Flux Fill' })
|
||||
).not.toBeChecked()
|
||||
expect(
|
||||
screen.getByRole('switch', { name: 'Allow Flux Expand' })
|
||||
).toBeChecked()
|
||||
expect(screen.getByText('1 of 2 allowed')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('saves one whole policy while preserving enforcement and hidden entries', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPanel()
|
||||
|
||||
await user.click(screen.getByRole('switch', { name: 'Allow Flux Fill' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
expect(mockSavePolicy).toHaveBeenCalledWith({
|
||||
enforcementEnabled: true,
|
||||
nodes: {
|
||||
FluxFill: true,
|
||||
FluxExpand: true,
|
||||
VeoVideo: true,
|
||||
RetiredNode: false
|
||||
}
|
||||
})
|
||||
expect(mockToastAdd).toHaveBeenCalledWith({
|
||||
severity: 'success',
|
||||
summary: 'Partner node policy saved',
|
||||
life: 2000
|
||||
})
|
||||
})
|
||||
|
||||
it('saves enforcement with the existing allowlist', async () => {
|
||||
const user = userEvent.setup()
|
||||
state.policy.value = {
|
||||
enforcementEnabled: false,
|
||||
nodes: {
|
||||
FluxFill: false,
|
||||
FluxExpand: true,
|
||||
VeoVideo: true,
|
||||
RetiredNode: false
|
||||
}
|
||||
}
|
||||
renderPanel()
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('switch', {
|
||||
name: 'Enforce partner node allowlist'
|
||||
})
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
expect(mockSavePolicy).toHaveBeenCalledWith({
|
||||
enforcementEnabled: true,
|
||||
nodes: {
|
||||
FluxFill: false,
|
||||
FluxExpand: true,
|
||||
VeoVideo: true,
|
||||
RetiredNode: false
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('treats an unconfigured workspace as allow-all draft', async () => {
|
||||
state.policy.value = null
|
||||
state.status.value = 'unconfigured'
|
||||
renderPanel()
|
||||
await nextTick()
|
||||
|
||||
expect(
|
||||
screen.getByRole('switch', {
|
||||
name: 'Enforce partner node allowlist'
|
||||
})
|
||||
).not.toBeChecked()
|
||||
const nodeToggles = screen.getAllByRole('switch', { name: /^Allow / })
|
||||
expect(nodeToggles).toHaveLength(3)
|
||||
for (const toggle of nodeToggles) {
|
||||
expect(toggle).toBeChecked()
|
||||
}
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('filters by provider and node identity', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPanel()
|
||||
|
||||
await user.type(
|
||||
screen.getByPlaceholderText('Search partner nodes...'),
|
||||
'veo'
|
||||
)
|
||||
|
||||
expect(screen.getByText('Veo Video')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Flux Fill')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Google')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers retry when the policy is unavailable', async () => {
|
||||
const user = userEvent.setup()
|
||||
state.status.value = 'unavailable'
|
||||
renderPanel()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Try again' }))
|
||||
|
||||
expect(mockLoadPolicy).toHaveBeenCalledOnce()
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(
|
||||
'Partner node policy could not be loaded.'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps draft changes and surfaces a save failure', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockSavePolicy.mockRejectedValue(new Error('Conflict'))
|
||||
renderPanel()
|
||||
|
||||
const toggle = screen.getByRole('switch', { name: 'Allow Flux Fill' })
|
||||
await user.click(toggle)
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
expect(toggle).toBeChecked()
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(
|
||||
'Partner node policy could not be saved.'
|
||||
)
|
||||
})
|
||||
|
||||
it('releases the new workspace when a previous save fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
let rejectSave: (reason?: unknown) => void = () => undefined
|
||||
mockSavePolicy.mockImplementation(
|
||||
() =>
|
||||
new Promise<boolean>((_resolve, reject) => {
|
||||
rejectSave = reject
|
||||
})
|
||||
)
|
||||
renderPanel()
|
||||
|
||||
await user.click(screen.getByRole('switch', { name: 'Allow Flux Fill' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
expect(
|
||||
screen.getByRole('switch', { name: 'Allow Flux Fill' })
|
||||
).toBeDisabled()
|
||||
|
||||
state.governedWorkspaceId.value = 'workspace-two'
|
||||
await nextTick()
|
||||
|
||||
expect(
|
||||
screen.getByRole('switch', { name: 'Allow Flux Fill' })
|
||||
).not.toBeDisabled()
|
||||
|
||||
rejectSave(new Error('Conflict'))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(
|
||||
screen.queryByText('Allowlist could not be saved.')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,300 @@
|
||||
<template>
|
||||
<div class="grow overflow-auto pt-6">
|
||||
<div
|
||||
class="flex size-full min-h-0 flex-col gap-5 rounded-2xl border border-interface-stroke p-6"
|
||||
>
|
||||
<div class="flex items-start gap-6">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="m-0 text-base font-semibold text-base-foreground">
|
||||
{{ $t('workspacePanel.allowlist.title') }}
|
||||
</h2>
|
||||
<p class="mt-1 mb-0 text-sm text-muted-foreground">
|
||||
{{ $t('workspacePanel.allowlist.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<SearchInput
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('workspacePanel.allowlist.searchPlaceholder')"
|
||||
size="lg"
|
||||
class="w-64 shrink-0"
|
||||
:disabled="!isReady"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isReady"
|
||||
class="flex min-h-14 items-center gap-4 rounded-xl border border-interface-stroke/60 px-4 py-3"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm text-base-foreground">
|
||||
{{ $t('workspacePanel.allowlist.enforcement.title') }}
|
||||
</div>
|
||||
<div class="mt-0.5 text-xs text-muted-foreground">
|
||||
{{ $t('workspacePanel.allowlist.enforcement.description') }}
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
:model-value="draftEnforcementEnabled"
|
||||
:disabled="isSaving"
|
||||
:aria-label="$t('workspacePanel.allowlist.enforcement.toggle')"
|
||||
@update:model-value="setEnforcementEnabled"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="status === 'loading'"
|
||||
role="status"
|
||||
class="flex flex-1 items-center justify-center gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<i class="icon-[lucide--loader-circle] size-4 animate-spin" />
|
||||
{{ $t('workspacePanel.allowlist.loading') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="status === 'error' || status === 'unavailable'"
|
||||
role="alert"
|
||||
class="flex flex-1 items-center justify-center gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<span>{{ $t('workspacePanel.allowlist.loadError') }}</span>
|
||||
<Button variant="muted-textonly" @click="loadPolicy">
|
||||
{{ $t('workspacePanel.allowlist.retry') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="isReady && partnerNodes.length === 0"
|
||||
class="flex flex-1 items-center justify-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('workspacePanel.allowlist.empty') }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="isReady" class="min-h-0 flex-1 overflow-y-auto">
|
||||
<div
|
||||
v-if="groups.length === 0"
|
||||
class="flex min-h-32 items-center justify-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('workspacePanel.allowlist.noMatches') }}
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<section
|
||||
v-for="group in groups"
|
||||
:key="group.provider"
|
||||
class="mb-5 last:mb-0"
|
||||
>
|
||||
<div
|
||||
class="mb-2 flex items-center justify-between text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
<span>{{ group.provider }}</span>
|
||||
<span>
|
||||
{{
|
||||
$t('workspacePanel.allowlist.allowedCount', {
|
||||
enabled: group.enabledCount,
|
||||
total: group.nodes.length
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="overflow-hidden rounded-xl border border-interface-stroke/60"
|
||||
>
|
||||
<div
|
||||
v-for="node in group.nodes"
|
||||
:key="node.id"
|
||||
class="flex min-h-14 items-center gap-4 border-b border-interface-stroke/40 px-4 last:border-b-0"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm text-base-foreground">
|
||||
{{ node.name }}
|
||||
</div>
|
||||
<div
|
||||
v-if="node.name !== node.id"
|
||||
class="truncate text-xs text-muted-foreground"
|
||||
>
|
||||
{{ node.id }}
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
:model-value="draftNodes[node.id]"
|
||||
:disabled="isSaving"
|
||||
:aria-label="
|
||||
$t('workspacePanel.allowlist.nodeToggle', {
|
||||
name: node.name
|
||||
})
|
||||
"
|
||||
@update:model-value="
|
||||
(enabled: boolean) => setNodeEnabled(node.id, enabled)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="isReady" class="flex min-h-10 items-center justify-end gap-3">
|
||||
<span v-if="saveError" role="alert" class="text-destructive text-sm">
|
||||
{{ $t('workspacePanel.allowlist.saveError') }}
|
||||
</span>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
:disabled="!hasChanges"
|
||||
:loading="isSaving"
|
||||
@click="save"
|
||||
>
|
||||
{{ $t('g.save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ToggleSwitch from 'primevue/toggleswitch'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import type { PartnerNodeCatalogItem } from '@/platform/workspace/stores/partnerNodeGovernanceStore'
|
||||
import { usePartnerNodeGovernanceStore } from '@/platform/workspace/stores/partnerNodeGovernanceStore'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
|
||||
interface PartnerNodeGroup {
|
||||
provider: string
|
||||
nodes: PartnerNodeCatalogItem[]
|
||||
enabledCount: number
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const governanceStore = usePartnerNodeGovernanceStore()
|
||||
const { governedWorkspaceId, partnerNodes, policy, status } =
|
||||
storeToRefs(governanceStore)
|
||||
const toastStore = useToastStore()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const draftNodes = ref<Record<string, boolean>>({})
|
||||
const draftEnforcementEnabled = ref(false)
|
||||
const isSaving = ref(false)
|
||||
const saveError = ref(false)
|
||||
let saveGeneration = 0
|
||||
|
||||
const isReady = computed(
|
||||
() => status.value === 'configured' || status.value === 'unconfigured'
|
||||
)
|
||||
|
||||
function originalNodeValue(nodeId: string): boolean {
|
||||
return policy.value ? policy.value.nodes[nodeId] === true : true
|
||||
}
|
||||
|
||||
function resetDraft(): void {
|
||||
draftEnforcementEnabled.value = policy.value?.enforcementEnabled ?? false
|
||||
draftNodes.value = Object.fromEntries(
|
||||
partnerNodes.value.map((node) => [node.id, originalNodeValue(node.id)])
|
||||
)
|
||||
saveError.value = false
|
||||
}
|
||||
|
||||
watch([governedWorkspaceId, partnerNodes, policy], resetDraft, {
|
||||
immediate: true
|
||||
})
|
||||
|
||||
watch(
|
||||
governedWorkspaceId,
|
||||
() => {
|
||||
saveGeneration += 1
|
||||
isSaving.value = false
|
||||
},
|
||||
{ flush: 'sync' }
|
||||
)
|
||||
|
||||
const hasChanges = computed(
|
||||
() =>
|
||||
draftEnforcementEnabled.value !==
|
||||
(policy.value?.enforcementEnabled ?? false) ||
|
||||
partnerNodes.value.some(
|
||||
(node) => draftNodes.value[node.id] !== originalNodeValue(node.id)
|
||||
)
|
||||
)
|
||||
|
||||
const groups = computed<PartnerNodeGroup[]>(() => {
|
||||
const query = searchQuery.value.trim().toLocaleLowerCase()
|
||||
const byProvider = new Map<string, PartnerNodeCatalogItem[]>()
|
||||
|
||||
for (const node of partnerNodes.value) {
|
||||
if (
|
||||
query &&
|
||||
![node.name, node.id, node.provider].some((value) =>
|
||||
value.toLocaleLowerCase().includes(query)
|
||||
)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const nodes = byProvider.get(node.provider) ?? []
|
||||
nodes.push(node)
|
||||
byProvider.set(node.provider, nodes)
|
||||
}
|
||||
|
||||
return [...byProvider.entries()]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([provider, nodes]) => {
|
||||
const sortedNodes = nodes.sort((left, right) =>
|
||||
left.name.localeCompare(right.name)
|
||||
)
|
||||
return {
|
||||
provider,
|
||||
nodes: sortedNodes,
|
||||
enabledCount: sortedNodes.filter(
|
||||
(node) => draftNodes.value[node.id] === true
|
||||
).length
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function setNodeEnabled(nodeId: string, enabled: boolean): void {
|
||||
draftNodes.value[nodeId] = enabled
|
||||
saveError.value = false
|
||||
}
|
||||
|
||||
function setEnforcementEnabled(enabled: boolean): void {
|
||||
draftEnforcementEnabled.value = enabled
|
||||
saveError.value = false
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
if (!hasChanges.value || isSaving.value) return
|
||||
|
||||
const generation = saveGeneration
|
||||
isSaving.value = true
|
||||
saveError.value = false
|
||||
try {
|
||||
const applied = await governanceStore.savePolicy({
|
||||
enforcementEnabled: draftEnforcementEnabled.value,
|
||||
nodes: {
|
||||
...(policy.value?.nodes ?? {}),
|
||||
...draftNodes.value
|
||||
}
|
||||
})
|
||||
if (!applied || generation !== saveGeneration) return
|
||||
toastStore.add({
|
||||
severity: 'success',
|
||||
summary: t('workspacePanel.allowlist.saved'),
|
||||
life: 2000
|
||||
})
|
||||
} catch {
|
||||
if (generation !== saveGeneration) return
|
||||
saveError.value = true
|
||||
} finally {
|
||||
if (generation === saveGeneration) {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loadPolicy(): void {
|
||||
void governanceStore.loadPolicy()
|
||||
}
|
||||
</script>
|
||||
@@ -1,4 +1,5 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { nextTick } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
@@ -9,12 +10,19 @@ import WorkspacePanelContent from './WorkspacePanelContent.vue'
|
||||
const mockFetchMembers = vi.fn()
|
||||
const mockFetchPendingInvites = vi.fn()
|
||||
|
||||
const { mockMembers, mockWorkspaceType } = vi.hoisted(() => {
|
||||
const {
|
||||
mockGovernedWorkspaceId,
|
||||
mockMembers,
|
||||
mockWorkspaceRole,
|
||||
mockWorkspaceType
|
||||
} = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
|
||||
const { ref } = require('vue') as typeof import('vue')
|
||||
|
||||
return {
|
||||
mockGovernedWorkspaceId: ref<string | null>('workspace-one'),
|
||||
mockMembers: ref<WorkspaceMember[]>([]),
|
||||
mockWorkspaceRole: ref<'owner' | 'member'>('owner'),
|
||||
mockWorkspaceType: ref<'personal' | 'team'>('team')
|
||||
}
|
||||
})
|
||||
@@ -41,16 +49,20 @@ vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => {
|
||||
})
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
|
||||
const { ref } = require('vue') as typeof import('vue')
|
||||
return {
|
||||
useWorkspaceUI: () => ({
|
||||
workspaceType: mockWorkspaceType,
|
||||
workspaceRole: ref('owner')
|
||||
workspaceRole: mockWorkspaceRole
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/workspace/stores/partnerNodeGovernanceStore', () => ({
|
||||
usePartnerNodeGovernanceStore: () => ({
|
||||
governedWorkspaceId: mockGovernedWorkspaceId
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workspace/components/SubscriptionPanelContentWorkspace.vue',
|
||||
() => ({
|
||||
@@ -75,6 +87,11 @@ vi.mock(
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workspace/components/dialogs/settings/PartnerNodeAllowlistPanel.vue',
|
||||
() => ({ default: { template: '<div />' } })
|
||||
)
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
@@ -94,8 +111,9 @@ function createMember(id: string): WorkspaceMember {
|
||||
}
|
||||
}
|
||||
|
||||
function renderComponent() {
|
||||
function renderComponent(defaultTab?: string) {
|
||||
return render(WorkspacePanelContent, {
|
||||
props: { defaultTab },
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: { WorkspaceProfilePic: true }
|
||||
@@ -107,6 +125,8 @@ describe('WorkspacePanelContent billing banner', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockMembers.value = []
|
||||
mockGovernedWorkspaceId.value = 'workspace-one'
|
||||
mockWorkspaceRole.value = 'owner'
|
||||
mockWorkspaceType.value = 'team'
|
||||
})
|
||||
|
||||
@@ -126,6 +146,8 @@ describe('WorkspacePanelContent members tab label', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockMembers.value = []
|
||||
mockGovernedWorkspaceId.value = 'workspace-one'
|
||||
mockWorkspaceRole.value = 'owner'
|
||||
mockWorkspaceType.value = 'team'
|
||||
})
|
||||
|
||||
@@ -156,3 +178,56 @@ describe('WorkspacePanelContent members tab label', () => {
|
||||
expect(mockFetchPendingInvites).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('WorkspacePanelContent allowlist tab', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGovernedWorkspaceId.value = 'workspace-one'
|
||||
mockWorkspaceRole.value = 'owner'
|
||||
mockWorkspaceType.value = 'team'
|
||||
})
|
||||
|
||||
it('shows the allowlist for an eligible workspace owner', () => {
|
||||
renderComponent()
|
||||
|
||||
expect(
|
||||
screen.getByRole('tab', { name: 'workspacePanel.tabs.allowlist' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the allowlist from workspace members', () => {
|
||||
mockWorkspaceRole.value = 'member'
|
||||
renderComponent()
|
||||
|
||||
expect(
|
||||
screen.queryByRole('tab', { name: 'workspacePanel.tabs.allowlist' })
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('hides the allowlist when governance is ineligible', () => {
|
||||
mockGovernedWorkspaceId.value = null
|
||||
renderComponent()
|
||||
|
||||
expect(
|
||||
screen.queryByRole('tab', { name: 'workspacePanel.tabs.allowlist' })
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to a valid tab when allowlist access is lost', async () => {
|
||||
renderComponent('allowlist')
|
||||
|
||||
expect(
|
||||
screen.getByRole('tab', { name: 'workspacePanel.tabs.allowlist' })
|
||||
).toHaveAttribute('aria-selected', 'true')
|
||||
|
||||
mockWorkspaceRole.value = 'member'
|
||||
await nextTick()
|
||||
|
||||
expect(
|
||||
screen.queryByRole('tab', { name: 'workspacePanel.tabs.allowlist' })
|
||||
).toBeNull()
|
||||
expect(
|
||||
screen.getByRole('tab', { name: 'workspacePanel.tabs.planCredits' })
|
||||
).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -39,6 +39,18 @@
|
||||
: $t('workspacePanel.members.header')
|
||||
}}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
v-if="showAllowlistTab"
|
||||
value="allowlist"
|
||||
:class="
|
||||
cn(
|
||||
tabTriggerBase,
|
||||
activeTab === 'allowlist' ? tabTriggerActive : tabTriggerInactive
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ $t('workspacePanel.tabs.allowlist') }}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<BillingStatusBanner class="mt-4" />
|
||||
@@ -49,21 +61,26 @@
|
||||
<TabsContent value="members" class="mt-4">
|
||||
<MembersPanelContent :key="workspaceRole" />
|
||||
</TabsContent>
|
||||
<TabsContent v-if="showAllowlistTab" value="allowlist" class="mt-4">
|
||||
<PartnerNodeAllowlistPanel />
|
||||
</TabsContent>
|
||||
</TabsRoot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
|
||||
|
||||
import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfilePic.vue'
|
||||
import BillingStatusBanner from '@/platform/workspace/components/dialogs/settings/BillingStatusBanner.vue'
|
||||
import MembersPanelContent from '@/platform/workspace/components/dialogs/settings/MembersPanelContent.vue'
|
||||
import PartnerNodeAllowlistPanel from '@/platform/workspace/components/dialogs/settings/PartnerNodeAllowlistPanel.vue'
|
||||
import SubscriptionPanelContentWorkspace from '@/platform/workspace/components/SubscriptionPanelContentWorkspace.vue'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { usePartnerNodeGovernanceStore } from '@/platform/workspace/stores/partnerNodeGovernanceStore'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
@@ -83,9 +100,19 @@ const { workspaceName, members } = storeToRefs(workspaceStore)
|
||||
const { fetchMembers, fetchPendingInvites } = workspaceStore
|
||||
|
||||
const { workspaceType, workspaceRole } = useWorkspaceUI()
|
||||
const { governedWorkspaceId } = storeToRefs(usePartnerNodeGovernanceStore())
|
||||
const isPersonalWorkspace = computed(() => workspaceType.value === 'personal')
|
||||
const showAllowlistTab = computed(
|
||||
() => !!governedWorkspaceId.value && workspaceRole.value === 'owner'
|
||||
)
|
||||
const activeTab = ref(defaultTab)
|
||||
|
||||
watch(showAllowlistTab, (isVisible) => {
|
||||
if (!isVisible && activeTab.value === 'allowlist') {
|
||||
activeTab.value = defaultTab === 'allowlist' ? 'plan' : defaultTab
|
||||
}
|
||||
})
|
||||
|
||||
// Per design, the tab counts members only when there is more than the owner
|
||||
const showMembersTabCount = computed(
|
||||
() => !isPersonalWorkspace.value && members.value.length > 1
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
|
||||
const mockGetPartnerNodePolicy = vi.hoisted(() => vi.fn())
|
||||
const mockUpdatePartnerNodePolicy = vi.hoisted(() => vi.fn())
|
||||
const mockFlags = vi.hoisted(() => ({
|
||||
teamWorkspacesEnabled: true,
|
||||
partnerNodeGovernanceEnabled: true
|
||||
@@ -27,7 +28,8 @@ vi.mock(
|
||||
const actual = await importOriginal<typeof PartnerNodePolicyApi>()
|
||||
return {
|
||||
...actual,
|
||||
getPartnerNodePolicy: mockGetPartnerNodePolicy
|
||||
getPartnerNodePolicy: mockGetPartnerNodePolicy,
|
||||
updatePartnerNodePolicy: mockUpdatePartnerNodePolicy
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -87,6 +89,9 @@ describe('partnerNodeGovernanceStore', () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.partnerNodeGovernanceEnabled = true
|
||||
mockGetPartnerNodePolicy.mockResolvedValue(null)
|
||||
mockUpdatePartnerNodePolicy.mockImplementation(
|
||||
async (policy: PartnerNodePolicy) => policy
|
||||
)
|
||||
activateWorkspace('workspace-one')
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
nodeDef('AllowedNode'),
|
||||
@@ -273,4 +278,110 @@ describe('partnerNodeGovernanceStore', () => {
|
||||
expect(store.governedWorkspaceId).toBe('workspace-two')
|
||||
expect(store.isNodeDisabled('DisabledNode')).toBe(false)
|
||||
})
|
||||
|
||||
it('saves and installs the validated whole policy', async () => {
|
||||
store = await createLoadedStore()
|
||||
const nextPolicy = {
|
||||
enforcementEnabled: false,
|
||||
nodes: { AllowedNode: true, DisabledNode: true }
|
||||
} satisfies PartnerNodePolicy
|
||||
|
||||
await expect(store.savePolicy(nextPolicy)).resolves.toBe(true)
|
||||
|
||||
expect(mockUpdatePartnerNodePolicy).toHaveBeenCalledWith(nextPolicy)
|
||||
expect(store.policy).toEqual(nextPolicy)
|
||||
expect(store.status).toBe('configured')
|
||||
})
|
||||
|
||||
it('does not install a save response after switching workspaces', async () => {
|
||||
store = await createLoadedStore()
|
||||
let resolveSave!: (policy: PartnerNodePolicy) => void
|
||||
mockUpdatePartnerNodePolicy.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveSave = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const save = store.savePolicy({
|
||||
enforcementEnabled: false,
|
||||
nodes: { AllowedNode: true }
|
||||
})
|
||||
activateWorkspace('workspace-two')
|
||||
await vi.waitFor(() =>
|
||||
expect(store?.governedWorkspaceId).toBe('workspace-two')
|
||||
)
|
||||
resolveSave({
|
||||
enforcementEnabled: false,
|
||||
nodes: { AllowedNode: true }
|
||||
})
|
||||
|
||||
await expect(save).resolves.toBe(false)
|
||||
await vi.waitFor(() => expect(store?.status).toBe('unconfigured'))
|
||||
expect(store.policy).toBeNull()
|
||||
})
|
||||
|
||||
it('does not install a save response after switching away and back', async () => {
|
||||
store = await createLoadedStore()
|
||||
let resolveSave!: (policy: PartnerNodePolicy) => void
|
||||
mockUpdatePartnerNodePolicy.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveSave = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const save = store.savePolicy({
|
||||
enforcementEnabled: true,
|
||||
nodes: { AllowedNode: true }
|
||||
})
|
||||
activateWorkspace('workspace-two')
|
||||
await vi.waitFor(() =>
|
||||
expect(store?.governedWorkspaceId).toBe('workspace-two')
|
||||
)
|
||||
activateWorkspace('workspace-one')
|
||||
await vi.waitFor(() =>
|
||||
expect(store?.governedWorkspaceId).toBe('workspace-one')
|
||||
)
|
||||
resolveSave({
|
||||
enforcementEnabled: true,
|
||||
nodes: { AllowedNode: true }
|
||||
})
|
||||
|
||||
await expect(save).resolves.toBe(false)
|
||||
await vi.waitFor(() => expect(store?.status).toBe('unconfigured'))
|
||||
expect(store.policy).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores a stale save rejection after switching workspaces', async () => {
|
||||
store = await createLoadedStore()
|
||||
let rejectSave!: (error: Error) => void
|
||||
mockUpdatePartnerNodePolicy.mockReturnValueOnce(
|
||||
new Promise((_, reject) => {
|
||||
rejectSave = reject
|
||||
})
|
||||
)
|
||||
|
||||
const save = store.savePolicy({
|
||||
enforcementEnabled: true,
|
||||
nodes: { AllowedNode: true }
|
||||
})
|
||||
activateWorkspace('workspace-two')
|
||||
await vi.waitFor(() =>
|
||||
expect(store?.governedWorkspaceId).toBe('workspace-two')
|
||||
)
|
||||
rejectSave(new Error('Conflict'))
|
||||
|
||||
await expect(save).resolves.toBe(false)
|
||||
expect(store.error).toBeNull()
|
||||
})
|
||||
|
||||
it('refuses to save before governance is ready', async () => {
|
||||
mockFlags.partnerNodeGovernanceEnabled = false
|
||||
store = usePartnerNodeGovernanceStore()
|
||||
await nextTick()
|
||||
|
||||
await expect(
|
||||
store.savePolicy({ enforcementEnabled: false, nodes: {} })
|
||||
).rejects.toThrow('Partner node governance is not ready')
|
||||
expect(mockUpdatePartnerNodePolicy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,7 +4,8 @@ import { computed, ref, shallowRef, watch } from 'vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import {
|
||||
getPartnerNodePolicy,
|
||||
PartnerNodePolicyApiError
|
||||
PartnerNodePolicyApiError,
|
||||
updatePartnerNodePolicy
|
||||
} from '@/platform/workspace/api/partnerNodePolicyApi'
|
||||
import type { PartnerNodePolicy } from '@/platform/workspace/api/partnerNodePolicyApi'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
@@ -36,6 +37,7 @@ export const usePartnerNodeGovernanceStore = defineStore(
|
||||
const status = ref<PartnerNodePolicyStatus>('inactive')
|
||||
const error = shallowRef<Error | null>(null)
|
||||
let loadVersion = 0
|
||||
let workspaceVersion = 0
|
||||
|
||||
const governedWorkspaceId = computed(() => {
|
||||
const workspace = workspaceStore.activeWorkspace
|
||||
@@ -124,7 +126,47 @@ export const usePartnerNodeGovernanceStore = defineStore(
|
||||
}
|
||||
}
|
||||
|
||||
watch(governedWorkspaceId, () => void loadPolicy(), { immediate: true })
|
||||
async function savePolicy(nextPolicy: PartnerNodePolicy): Promise<boolean> {
|
||||
const workspaceId = governedWorkspaceId.value
|
||||
const version = workspaceVersion
|
||||
if (!workspaceId || policyWorkspaceId.value !== workspaceId) {
|
||||
throw new Error('Partner node governance is not ready')
|
||||
}
|
||||
|
||||
let savedPolicy: PartnerNodePolicy
|
||||
try {
|
||||
savedPolicy = await updatePartnerNodePolicy(nextPolicy)
|
||||
} catch (saveError) {
|
||||
if (
|
||||
workspaceVersion !== version ||
|
||||
governedWorkspaceId.value !== workspaceId
|
||||
) {
|
||||
return false
|
||||
}
|
||||
throw saveError
|
||||
}
|
||||
if (
|
||||
workspaceVersion !== version ||
|
||||
governedWorkspaceId.value !== workspaceId
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
policy.value = savedPolicy
|
||||
policyWorkspaceId.value = workspaceId
|
||||
status.value = 'configured'
|
||||
error.value = null
|
||||
return true
|
||||
}
|
||||
|
||||
watch(
|
||||
governedWorkspaceId,
|
||||
() => {
|
||||
workspaceVersion++
|
||||
void loadPolicy()
|
||||
},
|
||||
{ immediate: true, flush: 'sync' }
|
||||
)
|
||||
|
||||
return {
|
||||
policy,
|
||||
@@ -133,7 +175,8 @@ export const usePartnerNodeGovernanceStore = defineStore(
|
||||
governedWorkspaceId,
|
||||
partnerNodes,
|
||||
isNodeDisabled,
|
||||
loadPolicy
|
||||
loadPolicy,
|
||||
savePolicy
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user