diff --git a/.changeset/validate-app-security-config.md b/.changeset/validate-app-security-config.md new file mode 100644 index 00000000000..6702c081e76 --- /dev/null +++ b/.changeset/validate-app-security-config.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': patch +--- + +Validate the selected App Security configuration before showing recovery guidance for existing review artifacts. diff --git a/packages/app/src/cli/commands/app/security/check.integration.test.ts b/packages/app/src/cli/commands/app/security/check.integration.test.ts index cdcc955ed1e..82b47f90b3a 100644 --- a/packages/app/src/cli/commands/app/security/check.integration.test.ts +++ b/packages/app/src/cli/commands/app/security/check.integration.test.ts @@ -177,6 +177,40 @@ describe('app security check command boundary', () => { }) }) + test('rejects a missing config before reporting a protected trace', async () => { + await inTemporaryDirectory(async (directory) => { + const paths = appSecurityArtifactPaths(directory) + await createApp(directory) + + const scan = await runCommand(['--path', directory, '--json', '--skip-instructions']) + expect(scan.exitCode).toBe(0) + + await writeFile(paths.findingsPath, findingsDocument(await readReviewPack(paths.reviewPath))) + const compile = await runCommand([ + '--path', + directory, + '--findings', + paths.findingsPath, + '--json', + '--skip-instructions', + ]) + expect(compile.exitCode).toBe(0) + + const refused = await runCommand([ + '--path', + directory, + '--config', + 'shopify.app.dev-dashboard.json', + '--skip-instructions', + ]) + + expect(refused.exitCode).toBe(1) + const message = errorText(refused.stderr) + expect(message).toContain("Couldn't find app configuration at") + expectMentionsPath(message, joinPath(directory, 'shopify.app.shopifyappdev-dashboardjson.toml')) + }) + }) + test('refuses a plain scan while default agent findings are pending', async () => { await inTemporaryDirectory(async (directory) => { const {nestedDirectory} = await createApp(directory) diff --git a/packages/app/src/cli/services/app-security-api.test.ts b/packages/app/src/cli/services/app-security-api.test.ts index 3f1f62bc11c..842295c5f16 100644 --- a/packages/app/src/cli/services/app-security-api.test.ts +++ b/packages/app/src/cli/services/app-security-api.test.ts @@ -619,9 +619,9 @@ describe('App Security CLI integration', () => { artifactPaths: appSecurityArtifactPaths, findingsFileExists: fileExists, readTrace, - execute: async ({appRoot, findingsPath}) => { + execute: async ({appRoot, configFileName, findingsPath}) => { const findings = findingsPath ? await loadAppSecurityFindings(findingsPath) : undefined - return executeAppSecurity({appRoot, findings}) + return executeAppSecurity({appRoot, configFileName, findings}) }, writeArtifacts: writeAppSecurityArtifacts, canPrompt: () => false, diff --git a/packages/app/src/cli/services/security-check.test.ts b/packages/app/src/cli/services/security-check.test.ts index 4ca20564199..8b5246d9244 100644 --- a/packages/app/src/cli/services/security-check.test.ts +++ b/packages/app/src/cli/services/security-check.test.ts @@ -11,6 +11,12 @@ import type {AppSecurityExecution} from './app-security-api.js' import type {AppSecurityInstructionsDestination} from './security-check.js' import type {ScanResult, TraceV3} from './app-security-engine/index.js' +vi.mock('./app-security-config.js', async (importOriginal) => { + const original = await importOriginal() + // Unit tests use a fake app directory; check.integration.test.ts covers the missing-config error. + return {...original, requireSecurityConfigFileName: original.resolveSecurityConfigFileName} +}) + const scan: ScanResult = { version: '0.1.0', timestamp: '2026-08-24T00:00:00.000Z', @@ -140,7 +146,7 @@ describe('securityCheck', () => { expect(dependencies.resolveRoot).toHaveBeenCalledWith('/tmp/unlinked-app') expect(dependencies.execute).toHaveBeenCalledWith({ appRoot: '/tmp/unlinked-app', - configName: undefined, + configFileName: 'shopify.app.toml', findingsPath: undefined, }) expect(dependencies.writeArtifacts).toHaveBeenCalledWith(scanExecution, {clean: false}) @@ -165,7 +171,7 @@ describe('securityCheck', () => { expect(dependencies.execute).toHaveBeenCalledWith({ appRoot: '/tmp/unlinked-app', - configName: 'staging', + configFileName: 'shopify.app.staging.toml', findingsPath: undefined, }) expect(dependencies.renderReport).toHaveBeenCalledWith( diff --git a/packages/app/src/cli/services/security-check.ts b/packages/app/src/cli/services/security-check.ts index a0ea627b7ef..f79ecd0ee5d 100644 --- a/packages/app/src/cli/services/security-check.ts +++ b/packages/app/src/cli/services/security-check.ts @@ -5,7 +5,7 @@ import { resolveAppSecurityRoot, } from './app-security-api.js' import {appSecurityArtifactPaths, readTrace, writeAppSecurityArtifacts} from './app-security-artifacts.js' -import {requireSecurityConfigFileName, resolveSecurityConfigFileName} from './app-security-config.js' +import {requireSecurityConfigFileName} from './app-security-config.js' import deliverAppSecurityInstructions from './app-security-instructions.js' import { formatAppSecurityCommand, @@ -49,7 +49,7 @@ interface SecurityDependencies { artifactPaths(appRoot: string): ResolvedAppSecurityArtifactPaths findingsFileExists(path: string): Promise readTrace(path: string): Promise - execute(options: {appRoot: string; configName?: string; findingsPath?: string}): Promise + execute(options: {appRoot: string; configFileName: string; findingsPath?: string}): Promise writeArtifacts( execution: AppSecurityExecution, options: WriteAppSecurityArtifactsOptions, @@ -82,13 +82,9 @@ const defaultDependencies: SecurityDependencies = { artifactPaths: appSecurityArtifactPaths, findingsFileExists: fileExists, readTrace, - execute: async ({appRoot, configName, findingsPath}) => { + execute: async ({appRoot, configFileName, findingsPath}) => { const findings = findingsPath ? await loadAppSecurityFindings(findingsPath) : undefined - return executeAppSecurity({ - appRoot, - findings, - configFileName: requireSecurityConfigFileName(appRoot, configName), - }) + return executeAppSecurity({appRoot, findings, configFileName}) }, writeArtifacts: writeAppSecurityArtifacts, canPrompt: terminalSupportsPrompting, @@ -156,14 +152,15 @@ export default async function securityCheck( dependencies: SecurityDependencies = defaultDependencies, ): Promise { const appRoot = dependencies.resolveRoot(options.directory) - const commands = resolveAppSecurityCommands(appRoot, resolveSecurityConfigFileName(appRoot, options.configName)) + const configFileName = requireSecurityConfigFileName(appRoot, options.configName) + const commands = resolveAppSecurityCommands(appRoot, configFileName) if (!options.findingsPath && !options.clean) { await assertCanStartScan(dependencies.artifactPaths(appRoot), commands, dependencies) } const execution = await dependencies.execute({ appRoot, - configName: options.configName, + configFileName, findingsPath: options.findingsPath, }) const artifacts = await dependencies.writeArtifacts(execution, {clean: options.clean})