From 42b42ce58438bb67143ec50f4af9813be6395779 Mon Sep 17 00:00:00 2001 From: nopp Date: Mon, 12 Oct 2026 19:05:05 +0700 Subject: [PATCH 1/2] fix(project): validate non-empty and non-whitespace across project env commands --- src/commands/project-env.spec.ts | 112 +++++++++++++++++++++++++++++++ src/commands/project-env.ts | 26 +++++-- 2 files changed, 134 insertions(+), 4 deletions(-) diff --git a/src/commands/project-env.spec.ts b/src/commands/project-env.spec.ts index f56b5dff..f5f775ce 100644 --- a/src/commands/project-env.spec.ts +++ b/src/commands/project-env.spec.ts @@ -2274,3 +2274,115 @@ describe('environment rename validation compatibility', () => { }); }); }); + +describe('project env input validation (empty and whitespace projectId)', () => { + it('rejects empty and whitespace project-id across all commands in live and dry-run modes', async () => { + for (const invalidProjectId of ['', ' ', '\t\n']) { + // list + await expect( + runEnvList( + { ...COMMON, projectId: invalidProjectId }, + { stdout: () => {}, stderr: () => {} }, + ), + ).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 }); + await expect( + runEnvList( + { ...COMMON, projectId: invalidProjectId, dryRun: true }, + { stdout: () => {}, stderr: () => {} }, + ), + ).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 }); + + // get + await expect( + runEnvGet( + { ...COMMON, projectId: invalidProjectId, name: 'staging' }, + { stdout: () => {}, stderr: () => {} }, + ), + ).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 }); + await expect( + runEnvGet( + { ...COMMON, projectId: invalidProjectId, name: 'staging', dryRun: true }, + { stdout: () => {}, stderr: () => {} }, + ), + ).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 }); + + // create + await expect( + runEnvCreate( + { + ...COMMON, + projectId: invalidProjectId, + name: 'staging', + url: 'https://staging.example.com', + }, + { stdout: () => {}, stderr: () => {} }, + ), + ).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 }); + await expect( + runEnvCreate( + { + ...COMMON, + projectId: invalidProjectId, + name: 'staging', + url: 'https://staging.example.com', + dryRun: true, + }, + { stdout: () => {}, stderr: () => {} }, + ), + ).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 }); + + // update + await expect( + runEnvUpdate( + { + ...COMMON, + projectId: invalidProjectId, + name: 'staging', + url: 'https://updated.example.com', + }, + { stdout: () => {}, stderr: () => {} }, + ), + ).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 }); + await expect( + runEnvUpdate( + { + ...COMMON, + projectId: invalidProjectId, + name: 'staging', + url: 'https://updated.example.com', + dryRun: true, + }, + { stdout: () => {}, stderr: () => {} }, + ), + ).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 }); + + // delete + await expect( + runEnvDelete( + { ...COMMON, projectId: invalidProjectId, name: 'staging', confirm: true }, + { stdout: () => {}, stderr: () => {} }, + ), + ).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 }); + await expect( + runEnvDelete( + { ...COMMON, projectId: invalidProjectId, name: 'staging', confirm: true, dryRun: true }, + { stdout: () => {}, stderr: () => {} }, + ), + ).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 }); + + // set-default + await expect( + runEnvSetDefault( + { ...COMMON, projectId: invalidProjectId, name: 'staging' }, + { stdout: () => {}, stderr: () => {} }, + ), + ).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 }); + await expect( + runEnvSetDefault( + { ...COMMON, projectId: invalidProjectId, name: 'staging', dryRun: true }, + { stdout: () => {}, stderr: () => {} }, + ), + ).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 }); + } + }); +}); diff --git a/src/commands/project-env.ts b/src/commands/project-env.ts index e5576d03..13eab512 100644 --- a/src/commands/project-env.ts +++ b/src/commands/project-env.ts @@ -148,6 +148,18 @@ function envPath(projectId: string, name?: string): string { return name === undefined ? base : `${base}/${encodeURIComponent(name)}`; } +/** Trim + reject an empty project id before dry-run or network dispatch. */ +function requireProjectId(raw: string | undefined): string { + const id = raw?.trim() ?? ''; + if (id.length === 0) { + throw localValidationError( + ' is required and must not be empty or whitespace-only', + '', + ); + } + return id; +} + /** Trim + reject an empty environment name; the server owns every other rule. */ function requireEnvName(raw: string | undefined, flag: string): string { const name = raw?.trim() ?? ''; @@ -499,6 +511,7 @@ export async function runEnvList( opts: EnvListOptions, deps: ProjectDeps = {}, ): Promise { + const projectId = requireProjectId(opts.projectId); const out = makeOutput(opts.output, deps); if (opts.dryRun) { emitDryRunBanner(stderrOf(deps)); @@ -514,7 +527,7 @@ export async function runEnvList( return sample; } const client = makeClient(opts, deps); - const res = await client.get(envPath(opts.projectId)); + const res = await client.get(envPath(projectId)); out.print(envListForPrint(res), data => renderEnvListText(data as CliProjectEnvListResponse)); return res; } @@ -523,6 +536,7 @@ export async function runEnvGet( opts: EnvListOptions & { name: string }, deps: ProjectDeps = {}, ): Promise { + const projectId = requireProjectId(opts.projectId); const out = makeOutput(opts.output, deps); const name = requireEnvName(opts.name, ''); if (opts.dryRun) { @@ -536,7 +550,7 @@ export async function runEnvGet( const client = makeClient(opts, deps); let res: CliProjectEnvUpdateResponse; try { - res = await client.get(envPath(opts.projectId, name)); + res = await client.get(envPath(projectId, name)); } catch (err) { if (err instanceof ApiError && err.httpStatus === 404 && !err.details.resource) throw unsupportedServer('project env get'); @@ -575,6 +589,7 @@ export async function runEnvCreate( opts: EnvCreateOptions, deps: ProjectDeps = {}, ): Promise { + const projectId = requireProjectId(opts.projectId); const out = makeOutput(opts.output, deps); const stderr = stderrOf(deps); assertIdempotencyKey(opts.idempotencyKey); @@ -725,6 +740,7 @@ export async function runEnvUpdate( opts: EnvUpdateOptions, deps: ProjectDeps = {}, ): Promise { + const projectId = requireProjectId(opts.projectId); const out = makeOutput(opts.output, deps); const stderr = stderrOf(deps); assertIdempotencyKey(opts.idempotencyKey); @@ -877,6 +893,7 @@ export async function runEnvDelete( opts: EnvDeleteOptions, deps: ProjectDeps = {}, ): Promise { + const projectId = requireProjectId(opts.projectId); const out = makeOutput(opts.output, deps); const stderr = stderrOf(deps); assertIdempotencyKey(opts.idempotencyKey); @@ -898,7 +915,7 @@ export async function runEnvDelete( const idempotencyKey = mintIdempotencyKey('delete', opts, stderr); const client = makeClient(opts, deps); - const res = await client.delete(envPath(opts.projectId, name), { + const res = await client.delete(envPath(projectId, name), { headers: { 'idempotency-key': idempotencyKey }, }); out.print(res, data => `deleted: ${(data as CliProjectEnvDeleteResponse).name}`); @@ -919,6 +936,7 @@ export async function runEnvSetDefault( opts: EnvSetDefaultOptions, deps: ProjectDeps = {}, ): Promise { + const projectId = requireProjectId(opts.projectId); const out = makeOutput(opts.output, deps); const stderr = stderrOf(deps); assertIdempotencyKey(opts.idempotencyKey); @@ -939,7 +957,7 @@ export async function runEnvSetDefault( const idempotencyKey = mintIdempotencyKey('set-default', opts, stderr); const client = makeClient(opts, deps); const res = await client.post( - `${envPath(opts.projectId, name)}/default`, + `${envPath(projectId, name)}/default`, { body: {}, headers: { 'idempotency-key': idempotencyKey } }, ); out.print(envForPrint(res), data => From 9dd1c119581802584448abe40884722070a6ae27 Mon Sep 17 00:00:00 2001 From: nopp Date: Mon, 12 Oct 2026 19:09:36 +0700 Subject: [PATCH 2/2] fix(project): invoke requireProjectId as statement to remove unused variable --- src/commands/project-env.ts | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/commands/project-env.ts b/src/commands/project-env.ts index 13eab512..0e8d32dc 100644 --- a/src/commands/project-env.ts +++ b/src/commands/project-env.ts @@ -149,7 +149,7 @@ function envPath(projectId: string, name?: string): string { } /** Trim + reject an empty project id before dry-run or network dispatch. */ -function requireProjectId(raw: string | undefined): string { +function requireProjectId(raw: string | undefined): void { const id = raw?.trim() ?? ''; if (id.length === 0) { throw localValidationError( @@ -157,7 +157,6 @@ function requireProjectId(raw: string | undefined): string { '', ); } - return id; } /** Trim + reject an empty environment name; the server owns every other rule. */ @@ -511,7 +510,7 @@ export async function runEnvList( opts: EnvListOptions, deps: ProjectDeps = {}, ): Promise { - const projectId = requireProjectId(opts.projectId); + requireProjectId(opts.projectId); const out = makeOutput(opts.output, deps); if (opts.dryRun) { emitDryRunBanner(stderrOf(deps)); @@ -527,7 +526,7 @@ export async function runEnvList( return sample; } const client = makeClient(opts, deps); - const res = await client.get(envPath(projectId)); + const res = await client.get(envPath(opts.projectId)); out.print(envListForPrint(res), data => renderEnvListText(data as CliProjectEnvListResponse)); return res; } @@ -536,7 +535,7 @@ export async function runEnvGet( opts: EnvListOptions & { name: string }, deps: ProjectDeps = {}, ): Promise { - const projectId = requireProjectId(opts.projectId); + requireProjectId(opts.projectId); const out = makeOutput(opts.output, deps); const name = requireEnvName(opts.name, ''); if (opts.dryRun) { @@ -550,7 +549,7 @@ export async function runEnvGet( const client = makeClient(opts, deps); let res: CliProjectEnvUpdateResponse; try { - res = await client.get(envPath(projectId, name)); + res = await client.get(envPath(opts.projectId, name)); } catch (err) { if (err instanceof ApiError && err.httpStatus === 404 && !err.details.resource) throw unsupportedServer('project env get'); @@ -589,7 +588,7 @@ export async function runEnvCreate( opts: EnvCreateOptions, deps: ProjectDeps = {}, ): Promise { - const projectId = requireProjectId(opts.projectId); + requireProjectId(opts.projectId); const out = makeOutput(opts.output, deps); const stderr = stderrOf(deps); assertIdempotencyKey(opts.idempotencyKey); @@ -740,7 +739,7 @@ export async function runEnvUpdate( opts: EnvUpdateOptions, deps: ProjectDeps = {}, ): Promise { - const projectId = requireProjectId(opts.projectId); + requireProjectId(opts.projectId); const out = makeOutput(opts.output, deps); const stderr = stderrOf(deps); assertIdempotencyKey(opts.idempotencyKey); @@ -893,7 +892,7 @@ export async function runEnvDelete( opts: EnvDeleteOptions, deps: ProjectDeps = {}, ): Promise { - const projectId = requireProjectId(opts.projectId); + requireProjectId(opts.projectId); const out = makeOutput(opts.output, deps); const stderr = stderrOf(deps); assertIdempotencyKey(opts.idempotencyKey); @@ -915,7 +914,7 @@ export async function runEnvDelete( const idempotencyKey = mintIdempotencyKey('delete', opts, stderr); const client = makeClient(opts, deps); - const res = await client.delete(envPath(projectId, name), { + const res = await client.delete(envPath(opts.projectId, name), { headers: { 'idempotency-key': idempotencyKey }, }); out.print(res, data => `deleted: ${(data as CliProjectEnvDeleteResponse).name}`); @@ -936,7 +935,7 @@ export async function runEnvSetDefault( opts: EnvSetDefaultOptions, deps: ProjectDeps = {}, ): Promise { - const projectId = requireProjectId(opts.projectId); + requireProjectId(opts.projectId); const out = makeOutput(opts.output, deps); const stderr = stderrOf(deps); assertIdempotencyKey(opts.idempotencyKey); @@ -957,7 +956,7 @@ export async function runEnvSetDefault( const idempotencyKey = mintIdempotencyKey('set-default', opts, stderr); const client = makeClient(opts, deps); const res = await client.post( - `${envPath(projectId, name)}/default`, + `${envPath(opts.projectId, name)}/default`, { body: {}, headers: { 'idempotency-key': idempotencyKey } }, ); out.print(envForPrint(res), data =>