From 290f0c503eced1d25347c166bc0f73f9daf08226 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Mon, 30 Mar 2026 14:40:14 +0200 Subject: [PATCH] Strict validation for extension and web TOMLs --- packages/app/src/cli/models/app/app.ts | 30 +++--- .../app/src/cli/models/app/loader.test.ts | 94 +++++++++++++------ .../src/cli/models/extensions/schemas.test.ts | 43 ++++++++- .../app/src/cli/models/extensions/schemas.ts | 14 +-- 4 files changed, 131 insertions(+), 50 deletions(-) diff --git a/packages/app/src/cli/models/app/app.ts b/packages/app/src/cli/models/app/app.ts index 5c6a5eab81c..22ce7c67a38 100644 --- a/packages/app/src/cli/models/app/app.ts +++ b/packages/app/src/cli/models/app/app.ts @@ -157,20 +157,22 @@ export enum WebType { const WebConfigurationAuthCallbackPathSchema = zod.preprocess(ensurePathStartsWithSlash, zod.string()) -const baseWebConfigurationSchema = zod.object({ - auth_callback_path: zod - .union([WebConfigurationAuthCallbackPathSchema, WebConfigurationAuthCallbackPathSchema.array()]) - .optional(), - webhooks_path: zod.preprocess(ensurePathStartsWithSlash, zod.string()).optional(), - port: zod.number().max(65536).min(0).optional(), - commands: zod.object({ - build: zod.string().optional(), - predev: zod.string().optional(), - dev: zod.string(), - }), - name: zod.string().optional(), - hmr_server: zod.object({http_paths: zod.string().array()}).optional(), -}) +const baseWebConfigurationSchema = zod + .object({ + auth_callback_path: zod + .union([WebConfigurationAuthCallbackPathSchema, WebConfigurationAuthCallbackPathSchema.array()]) + .optional(), + webhooks_path: zod.preprocess(ensurePathStartsWithSlash, zod.string()).optional(), + port: zod.number().max(65536).min(0).optional(), + commands: zod.object({ + build: zod.string().optional(), + predev: zod.string().optional(), + dev: zod.string(), + }), + name: zod.string().optional(), + hmr_server: zod.object({http_paths: zod.string().array()}).optional(), + }) + .strict() const webTypes = zod.enum([WebType.Frontend, WebType.Backend, WebType.Background]).default(WebType.Frontend) export const WebConfigurationSchema = zod.union([ baseWebConfigurationSchema.extend({roles: zod.array(webTypes)}), diff --git a/packages/app/src/cli/models/app/loader.test.ts b/packages/app/src/cli/models/app/loader.test.ts index 455f73c6082..78dca010779 100644 --- a/packages/app/src/cli/models/app/loader.test.ts +++ b/packages/app/src/cli/models/app/loader.test.ts @@ -30,6 +30,7 @@ import {platformAndArch} from '@shopify/cli-kit/node/os' import {zod} from '@shopify/cli-kit/node/schema' import colors from '@shopify/cli-kit/node/colors' import {showMultipleCLIWarningIfNeeded} from '@shopify/cli-kit/node/multiple-installation-warning' +import {stringifyMessage} from '@shopify/cli-kit/node/output' vi.mock('../../services/local-storage.js') vi.mock('@shopify/cli-kit/node/system') @@ -699,6 +700,28 @@ describe('load', () => { expect(app.webs.length).toBe(0) }) + test('collects an error for unrecognized keys in web TOML', async () => { + // Given + const {webDirectory} = await writeConfig(appConfiguration) + const webConfiguration = ` + type = "backend" + unknown_key = "should-not-be-here" + + [commands] + build = "build" + dev = "dev" + ` + await writeFile(joinPath(webDirectory, blocks.web.configurationName), webConfiguration) + + // When + const app = await loadTestingApp() + + // Then + expect(app.errors.isEmpty()).toBe(false) + const errors = app.errors.toJSON().map(stringifyMessage).join('\n') + expect(errors).toMatch(/Unrecognized key.*unknown_key/) + }) + test('loads the app when it has a extension with a valid configuration', async () => { // Given await writeConfig(appConfiguration) @@ -999,40 +1022,63 @@ describe('load', () => { await expect(() => loadTestingApp()).rejects.toThrowError() }) - test('loads the app when it has a function with a valid configuration', async () => { + test('collects an error for unrecognized keys in unified extension TOML', async () => { // Given await writeConfig(appConfiguration) const blockConfiguration = ` - name = "my-function" - type = "order_discounts" - api_version = "2022-07" + api_version = "2024-01" - [build] - command = "make build" - path = "dist/index.wasm" + [metaobjects] + something = "misplaced" - # extra fields not included in the schema should be ignored - [[invalid_field]] - namespace = "my-namespace" - key = "my-key" + [[extensions]] + type = "flow_action" + handle = "my-flow-action" + name = "My Flow Action" + description = "A flow action" + runtime_url = "https://example.com" ` await writeBlockConfig({ blockConfiguration, - name: 'my-function', + name: 'my-extension', }) - await mkdir(joinPath(blockPath('my-function'), 'src')) - await writeFile(joinPath(blockPath('my-function'), 'src', 'index.js'), '') + await writeFile(joinPath(blockPath('my-extension'), 'index.js'), '') // When const app = await loadTestingApp() - const myFunction = app.allExtensions[0]! // Then - expect(myFunction.configuration.name).toBe('my-function') - expect(myFunction.idEnvironmentVariableName).toBe('SHOPIFY_MY_FUNCTION_ID') - expect(myFunction.localIdentifier).toBe('my-function') - expect(myFunction.entrySourceFilePath).toContain(joinPath(blockPath('my-function'), 'src', 'index.js')) + expect(app.errors.isEmpty()).toBe(false) + const errors = app.errors.toJSON().map(stringifyMessage).join('\n') + expect(errors).toMatch(/Unrecognized key.*metaobjects/) + }) + + test('does not collect errors when unified extension TOML has only recognized keys', async () => { + // Given + await writeConfig(appConfiguration) + + const blockConfiguration = ` + api_version = "2024-01" + + [[extensions]] + type = "flow_action" + handle = "my-flow-action" + name = "My Flow Action" + description = "A flow action" + runtime_url = "https://example.com" + ` + await writeBlockConfig({ + blockConfiguration, + name: 'my-extension', + }) + await writeFile(joinPath(blockPath('my-extension'), 'index.js'), '') + + // When + const app = await loadTestingApp() + + // Then + expect(app.errors.isEmpty()).toBe(true) }) test('loads the app with a Flow trigger extension that has a full valid configuration', async () => { @@ -1054,11 +1100,6 @@ describe('load', () => { [[settings.fields]] type = "single_line_text_field" key = "your field key" - - # extra fields not included in the schema should be ignored - [[invalid_field]] - namespace = "my-namespace" - key = "my-key" ` await writeBlockConfig({ blockConfiguration, @@ -1123,11 +1164,6 @@ describe('load', () => { name = "Display name" description = "A description of my field" required = true - - # extra fields not included in the schema should be ignored - [[invalid_field]] - namespace = "my-namespace" - key = "my-key" ` await writeBlockConfig({ blockConfiguration, diff --git a/packages/app/src/cli/models/extensions/schemas.test.ts b/packages/app/src/cli/models/extensions/schemas.test.ts index e4244c98a03..ac2fbeb9bed 100644 --- a/packages/app/src/cli/models/extensions/schemas.test.ts +++ b/packages/app/src/cli/models/extensions/schemas.test.ts @@ -1,4 +1,4 @@ -import {BaseSchema, MAX_UID_LENGTH} from './schemas.js' +import {BaseSchema, MAX_UID_LENGTH, UnifiedSchema} from './schemas.js' import {describe, expect, test} from 'vitest' const validUIDTestCases = [ @@ -29,6 +29,47 @@ const invalidUIDTestCases = [ ['-----', "UID can't start or end with a hyphen"], ] +describe('UnifiedSchema', () => { + test('rejects unrecognized top-level keys', () => { + // Given + const config = { + api_version: '2024-01', + extensions: [{type: 'ui_extension', handle: 'my-ext'}], + metaobjects: {something: 'misplaced'}, + } + + // When + const result = UnifiedSchema.safeParse(config) + + // Then + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0]!.code).toBe('unrecognized_keys') + expect(result.error.issues[0]!.message).toMatch(/metaobjects/) + } + }) + + test('rejects multiple unrecognized keys', () => { + // Given + const config = { + extensions: [], + metaobjects: {}, + unknown_field: 'value', + } + + // When + const result = UnifiedSchema.safeParse(config) + + // Then + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0]!.code).toBe('unrecognized_keys') + expect(result.error.issues[0]!.message).toMatch(/metaobjects/) + expect(result.error.issues[0]!.message).toMatch(/unknown_field/) + } + }) +}) + describe('UIDSchema', () => { describe('valid UIDs', () => { test.each(validUIDTestCases)('accepts %s (%s)', (uid) => { diff --git a/packages/app/src/cli/models/extensions/schemas.ts b/packages/app/src/cli/models/extensions/schemas.ts index cab04621ec8..1aa4f822b03 100644 --- a/packages/app/src/cli/models/extensions/schemas.ts +++ b/packages/app/src/cli/models/extensions/schemas.ts @@ -124,12 +124,14 @@ export const BaseSchemaWithoutHandle = BaseSchema.omit({ handle: true, }) -export const UnifiedSchema = zod.object({ - api_version: ApiVersionSchema.optional(), - description: zod.string().optional(), - extensions: zod.array(zod.any()), - settings: SettingsSchema.optional(), -}) +export const UnifiedSchema = zod + .object({ + api_version: ApiVersionSchema.optional(), + description: zod.string().optional(), + extensions: zod.array(zod.any()), + settings: SettingsSchema.optional(), + }) + .strict() export type NewExtensionPointSchemaType = zod.infer