diff --git a/src/commands/testlist.spec.ts b/src/commands/testlist.spec.ts index 35d3a39b..878d4878 100644 --- a/src/commands/testlist.spec.ts +++ b/src/commands/testlist.spec.ts @@ -1053,6 +1053,112 @@ describe('testlist run — insufficient credits → exit 12 + telemetry facts', await runTestlistRun({ ...runBase, listId: 'list-1' }, { env, fetchImpl, stdout: () => {} }); expect(takeTelemetryExtras()).toEqual({ accepted: 1, conflicts: 0, deferred: 0, skipped: 0 }); }); + + describe('testlist input validation (empty and whitespace arguments)', () => { + it('get: rejects empty and whitespace list-id', async () => { + for (const invalidId of ['', ' ', '\t\n']) { + await expect(runTestlistGet({ ...base, listId: invalidId })).rejects.toThrow(ApiError); + await expect(runTestlistGet({ ...base, listId: invalidId, dryRun: true })).rejects.toThrow( + ApiError, + ); + try { + await runTestlistGet({ ...base, listId: invalidId }); + } catch (e: unknown) { + const err = e as ApiError; + expect(err.exitCode).toBe(5); + expect(err.code).toBe('VALIDATION_ERROR'); + } + } + }); + + it('delete: rejects empty and whitespace list-id', async () => { + for (const invalidId of ['', ' ', '\t\n']) { + await expect( + runTestlistDelete({ ...base, listId: invalidId, confirm: true }), + ).rejects.toThrow(ApiError); + await expect( + runTestlistDelete({ ...base, listId: invalidId, dryRun: true }), + ).rejects.toThrow(ApiError); + try { + await runTestlistDelete({ ...base, listId: invalidId, confirm: true }); + } catch (e: unknown) { + const err = e as ApiError; + expect(err.exitCode).toBe(5); + expect(err.code).toBe('VALIDATION_ERROR'); + } + } + }); + + it('update: rejects empty and whitespace list-id', async () => { + for (const invalidId of ['', ' ', '\t\n']) { + await expect( + runTestlistUpdate({ ...base, listId: invalidId, name: 'Renamed' }), + ).rejects.toThrow(ApiError); + await expect( + runTestlistUpdate({ ...base, listId: invalidId, name: 'Renamed', dryRun: true }), + ).rejects.toThrow(ApiError); + try { + await runTestlistUpdate({ ...base, listId: invalidId, name: 'Renamed' }); + } catch (e: unknown) { + const err = e as ApiError; + expect(err.exitCode).toBe(5); + expect(err.code).toBe('VALIDATION_ERROR'); + } + } + }); + + it('add: rejects empty and whitespace list-id and test-ids', async () => { + await expect(runTestlistAdd({ ...base, listId: ' ', testIds: ['test-1'] })).rejects.toThrow( + ApiError, + ); + await expect(runTestlistAdd({ ...base, listId: 'list-1', testIds: [' '] })).rejects.toThrow( + ApiError, + ); + await expect( + runTestlistAdd({ ...base, listId: 'list-1', testIds: ['test-1', ''] }), + ).rejects.toThrow(ApiError); + await expect( + runTestlistAdd({ ...base, listId: ' ', testIds: ['test-1'], dryRun: true }), + ).rejects.toThrow(ApiError); + await expect( + runTestlistAdd({ ...base, listId: 'list-1', testIds: [' '], dryRun: true }), + ).rejects.toThrow(ApiError); + }); + + it('remove: rejects empty and whitespace list-id and test-ids', async () => { + await expect( + runTestlistRemove({ ...base, listId: ' ', testIds: ['test-1'] }), + ).rejects.toThrow(ApiError); + await expect( + runTestlistRemove({ ...base, listId: 'list-1', testIds: [' '] }), + ).rejects.toThrow(ApiError); + await expect( + runTestlistRemove({ ...base, listId: 'list-1', testIds: ['test-1', ''] }), + ).rejects.toThrow(ApiError); + await expect( + runTestlistRemove({ ...base, listId: ' ', testIds: ['test-1'], dryRun: true }), + ).rejects.toThrow(ApiError); + await expect( + runTestlistRemove({ ...base, listId: 'list-1', testIds: [' '], dryRun: true }), + ).rejects.toThrow(ApiError); + }); + + it('run: rejects empty and whitespace list-id and cases', async () => { + await expect(runTestlistRun({ ...runBase, listId: ' ' })).rejects.toThrow(ApiError); + await expect(runTestlistRun({ ...runBase, listId: ' ', dryRun: true })).rejects.toThrow( + ApiError, + ); + await expect( + runTestlistRun({ ...runBase, listId: 'list-1', cases: [' '] }), + ).rejects.toThrow(ApiError); + await expect( + runTestlistRun({ ...runBase, listId: 'list-1', cases: ['valid-case', ''] }), + ).rejects.toThrow(ApiError); + await expect( + runTestlistRun({ ...runBase, listId: 'list-1', cases: [' '], dryRun: true }), + ).rejects.toThrow(ApiError); + }); + }); }); it.each([null, '2026-08-01T00:00:05.000Z'])( diff --git a/src/commands/testlist.ts b/src/commands/testlist.ts index 2a17c370..4e888914 100644 --- a/src/commands/testlist.ts +++ b/src/commands/testlist.ts @@ -148,6 +148,17 @@ function dryRunStderr(deps: TestListDeps): (line: string) => void { return deps.stderr ?? (line => process.stderr.write(`${line}\n`)); } +function assertValidListId(listId: string | undefined): void { + if (listId === undefined || listId.trim() === '') { + throw localValidationError( + 'list-id', + 'is required and must not be empty or whitespace-only', + undefined, + 'field', + ); + } +} + // Canned `--dry-run` samples (offline, no network) — the global `--dry-run` // contract promises a shape-accurate sample for every command. const DRY_RUN_LIST: CliTestList = { @@ -192,6 +203,7 @@ export async function runTestlistGet( opts: CommonOptions & { listId: string }, deps: TestListDeps = {}, ): Promise { + assertValidListId(opts.listId); const out = makeOutput(opts.output, deps); if (opts.dryRun) { emitDryRunBanner(dryRunStderr(deps)); @@ -250,6 +262,7 @@ export async function runTestlistUpdate( }, deps: TestListDeps = {}, ): Promise { + assertValidListId(opts.listId); const out = makeOutput(opts.output, deps); const hasProjectEnv = opts.projectEnv !== undefined && opts.projectEnv.length > 0; if (opts.clearProjectEnv && hasProjectEnv) { @@ -292,6 +305,7 @@ export async function runTestlistDelete( opts: CommonOptions & { listId: string; confirm?: boolean; idempotencyKey?: string }, deps: TestListDeps = {}, ): Promise { + assertValidListId(opts.listId); const out = makeOutput(opts.output, deps); // Destructive: deleting a list also deletes its schedules (and reaps their // EventBridge triggers). Require explicit --confirm — matches the CLI's @@ -330,10 +344,21 @@ async function mutateCases( opts: CommonOptions & { listId: string; testIds: string[]; idempotencyKey?: string }, deps: TestListDeps, ): Promise { + assertValidListId(opts.listId); const out = makeOutput(opts.output, deps); if (opts.testIds.length === 0) { throw localValidationError('test-id', 'at least one test id is required', undefined, 'field'); } + for (const testId of opts.testIds) { + if (!testId || testId.trim() === '') { + throw localValidationError( + 'test-id', + 'test id must not be empty or whitespace-only', + undefined, + 'field', + ); + } + } if (opts.idempotencyKey !== undefined) assertIdempotencyKey(opts.idempotencyKey); const idempotencyKey = opts.idempotencyKey ?? `cli-testlist-${verb}-${randomUUID()}`; emitIdempotencyKey(opts, deps, idempotencyKey, opts.idempotencyKey); @@ -487,6 +512,19 @@ function assertNoNotFound(resp: CliTestListRunResponse): void { * JUnit XML report (`--report junit`). testlist run is V3-only. */ function validateTestlistRunOptions(opts: RunTestlistRunOptions): void { + assertValidListId(opts.listId); + if (opts.cases !== undefined) { + for (const caseId of opts.cases) { + if (!caseId || caseId.trim() === '') { + throw localValidationError( + 'case', + 'case id must not be empty or whitespace-only', + undefined, + 'flag', + ); + } + } + } if (opts.idempotencyKey !== undefined) assertIdempotencyKey(opts.idempotencyKey); if ( !Number.isInteger(opts.maxConcurrency) ||