Compare commits

...

2 Commits

Author SHA1 Message Date
GitHub Action
bcbc8b4fa7 [automated] Apply ESLint and Oxfmt fixes 2026-03-12 18:44:55 +00:00
CodeRabbit Fixer
d39284e16d fix: feat: add Zod schema validation for server capabilities JSON response (#9826) 2026-03-12 19:41:34 +01:00
3 changed files with 134 additions and 3 deletions

View File

@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '@/scripts/api'
import { refreshRemoteConfig } from './refreshRemoteConfig'
import { remoteConfig } from './remoteConfig'
import { remoteConfig, remoteConfigState } from './remoteConfig'
vi.mock('@/scripts/api', () => ({
api: {
@@ -16,7 +16,7 @@ vi.stubGlobal('fetch', vi.fn())
describe('refreshRemoteConfig', () => {
const mockConfig = { feature1: true, feature2: 'value' }
function mockSuccessResponse(config = mockConfig) {
function mockSuccessResponse(config: Record<string, unknown> = mockConfig) {
return {
ok: true,
json: async () => config
@@ -123,4 +123,67 @@ describe('refreshRemoteConfig', () => {
expect(window.__CONFIG__).toEqual(existingConfig)
})
})
describe('schema validation', () => {
it('accepts a valid remote config response', async () => {
const validConfig = {
team_workspaces_enabled: true,
subscription_required: false,
max_upload_size: 1024
}
vi.mocked(api.fetchApi).mockResolvedValue(
mockSuccessResponse(validConfig)
)
await refreshRemoteConfig()
expect(remoteConfig.value).toEqual(validConfig)
expect(window.__CONFIG__).toEqual(validConfig)
expect(remoteConfigState.value).toBe('authenticated')
})
it('rejects response with invalid type for boolean flag', async () => {
const invalidConfig = {
team_workspaces_enabled: 'not-a-boolean'
}
vi.mocked(api.fetchApi).mockResolvedValue(
mockSuccessResponse(invalidConfig)
)
await refreshRemoteConfig()
expect(remoteConfig.value).toEqual({})
expect(window.__CONFIG__).toEqual({})
expect(remoteConfigState.value).toBe('error')
})
it('rejects response with invalid type for number field', async () => {
const invalidConfig = {
max_upload_size: 'not-a-number'
}
vi.mocked(api.fetchApi).mockResolvedValue(
mockSuccessResponse(invalidConfig)
)
await refreshRemoteConfig()
expect(remoteConfig.value).toEqual({})
expect(window.__CONFIG__).toEqual({})
expect(remoteConfigState.value).toBe('error')
})
it('preserves unknown keys via passthrough', async () => {
const configWithExtra = {
team_workspaces_enabled: true,
some_future_flag: 'new-value'
}
vi.mocked(api.fetchApi).mockResolvedValue(
mockSuccessResponse(configWithExtra)
)
await refreshRemoteConfig()
expect(remoteConfig.value).toEqual(configWithExtra)
})
})
})

View File

@@ -1,6 +1,10 @@
import { fromZodError } from 'zod-validation-error'
import { api } from '@/scripts/api'
import { remoteConfig, remoteConfigState } from './remoteConfig'
import { remoteConfigSchema } from './remoteConfigSchema'
import type { RemoteConfig } from './types'
interface RefreshRemoteConfigOptions {
/**
@@ -30,7 +34,21 @@ export async function refreshRemoteConfig(
: await fetch('/api/features', { cache: 'no-store' })
if (response.ok) {
const config = await response.json()
const json = await response.json()
const result = remoteConfigSchema.safeParse(json)
if (!result.success) {
console.warn(
'Invalid remote config response:',
fromZodError(result.error).message
)
window.__CONFIG__ = {}
remoteConfig.value = {}
remoteConfigState.value = 'error'
return
}
const config = result.data as RemoteConfig
window.__CONFIG__ = config
remoteConfig.value = config
remoteConfigState.value = useAuth ? 'authenticated' : 'anonymous'

View File

@@ -0,0 +1,50 @@
import { z } from 'zod'
const zServerHealthAlert = z.object({
message: z.string(),
tooltip: z.string().optional(),
severity: z.enum(['info', 'warning', 'error']).optional(),
badge: z.string().optional()
})
const zFirebaseRuntimeConfig = z.object({
apiKey: z.string(),
authDomain: z.string(),
databaseURL: z.string().optional(),
projectId: z.string(),
storageBucket: z.string(),
messagingSenderId: z.string(),
appId: z.string(),
measurementId: z.string().optional()
})
export const remoteConfigSchema = z
.object({
gtm_container_id: z.string().optional(),
ga_measurement_id: z.string().optional(),
mixpanel_token: z.string().optional(),
posthog_project_token: z.string().optional(),
posthog_api_host: z.string().optional(),
posthog_config: z.record(z.unknown()).optional(),
subscription_required: z.boolean().optional(),
server_health_alert: zServerHealthAlert.optional(),
max_upload_size: z.number().optional(),
comfy_api_base_url: z.string().optional(),
comfy_platform_base_url: z.string().optional(),
firebase_config: zFirebaseRuntimeConfig.optional(),
telemetry_disabled_events: z.array(z.string()).optional(),
model_upload_button_enabled: z.boolean().optional(),
asset_rename_enabled: z.boolean().optional(),
private_models_enabled: z.boolean().optional(),
onboarding_survey_enabled: z.boolean().optional(),
linear_toggle_enabled: z.boolean().optional(),
team_workspaces_enabled: z.boolean().optional(),
user_secrets_enabled: z.boolean().optional(),
node_library_essentials_enabled: z.boolean().optional(),
free_tier_credits: z.number().optional(),
new_free_tier_subscriptions: z.boolean().optional(),
workflow_sharing_enabled: z.boolean().optional(),
comfyhub_upload_enabled: z.boolean().optional(),
comfyhub_profile_gate_enabled: z.boolean().optional()
})
.passthrough()