From b2d7a2bd6d2f5b09931c505285b7d59c9daf715e Mon Sep 17 00:00:00 2001 From: Gerald Bate Date: Sun, 6 Sep 2026 00:31:53 -0400 Subject: [PATCH 1/4] fix: validate project and test-code responses before use --- src/commands/project.test.ts | 73 ++++++++++++++++++++ src/commands/project.ts | 28 ++++---- src/commands/test.test.ts | 95 +++++++++++++++++++++++++++ src/commands/test.ts | 42 ++++++++++-- src/lib/project-response-schema.ts | 27 ++++++++ src/lib/response-schemas.code.test.ts | 41 ++++++++++++ src/lib/response-schemas.ts | 25 +++++-- test/contract/p4-schema.test.ts | 4 +- 8 files changed, 307 insertions(+), 28 deletions(-) create mode 100644 src/lib/project-response-schema.ts create mode 100644 src/lib/response-schemas.code.test.ts diff --git a/src/commands/project.test.ts b/src/commands/project.test.ts index 1dde21fc..d034b0c8 100644 --- a/src/commands/project.test.ts +++ b/src/commands/project.test.ts @@ -631,6 +631,79 @@ describe('createProjectCommand --page-size option parser', () => { }); }); +describe('project read response validation', () => { + it.each([ + ['missing name', { ...PROJECT_FIXTURE, name: undefined }], + ['invalid attribute list', { ...PROJECT_FIXTURE, testIdAttributes: 'data-testid' }], + ['invalid nullable URL', { ...PROJECT_FIXTURE, targetUrl: false }], + ])('rejects %s before printing a successful result', async (_label, body) => { + const out: string[] = []; + await expect( + runGet( + { profile: 'default', output: 'json', debug: false, projectId: PROJECT_FIXTURE.id }, + { ...makeCreds(), fetchImpl: makeFetch(() => ({ body })), stdout: line => out.push(line) }, + ), + ).rejects.toMatchObject({ code: 'INTERNAL', exitCode: 1 }); + expect(out).toEqual([]); + }); + + it.each([false, true])('validates list rows before output (single page: %s)', async single => { + const out: string[] = []; + let calls = 0; + const fetchImpl = makeFetch(() => { + calls += 1; + return { + body: + !single && calls === 1 + ? { items: [PROJECT_FIXTURE], nextToken: 'second-page' } + : { items: [{ ...PROJECT_FIXTURE, id: null }], nextToken: null }, + }; + }); + await expect( + runList( + { profile: 'default', output: 'text', debug: false, ...(single ? { pageSize: 1 } : {}) }, + { ...makeCreds(), fetchImpl, stdout: line => out.push(line) }, + ), + ).rejects.toMatchObject({ code: 'INTERNAL', exitCode: 1 }); + expect(calls).toBe(single ? 1 : 2); + expect(out).toEqual([]); + }); + + it('rejects a malformed cursor instead of returning it as a usable next page', async () => { + const out: string[] = []; + await expect( + runList( + { profile: 'default', output: 'json', debug: false, pageSize: 1 }, + { + ...makeCreds(), + fetchImpl: makeFetch(() => ({ body: { items: [PROJECT_FIXTURE], nextToken: 17 } })), + stdout: line => out.push(line), + }, + ), + ).rejects.toMatchObject({ code: 'INTERNAL', exitCode: 1 }); + expect(out).toEqual([]); + }); + + it('preserves new server fields and enum values without inventing absent optional fields', async () => { + const body = { + ...PROJECT_FIXTURE, + type: 'mobile', + createdFrom: 'import', + owner: { name: 'Team' }, + }; + const out: string[] = []; + const result = await runGet( + { profile: 'default', output: 'json', debug: false, projectId: PROJECT_FIXTURE.id }, + { ...makeCreds(), fetchImpl: makeFetch(() => ({ body })), stdout: line => out.push(line) }, + ); + expect(result).toEqual(body); + expect(JSON.parse(out[0]!)).toEqual(body); + expect(result).not.toHaveProperty('targetUrl'); + expect(result).not.toHaveProperty('testIdAttributes'); + expect(result).not.toHaveProperty('orgName'); + }); +}); + describe('runGet', () => { it('GETs /projects/{id} and prints the §6.1 fields in text mode', async () => { const { credentialsPath } = makeCreds(); diff --git a/src/commands/project.ts b/src/commands/project.ts index 0ecdda06..6e2b6771 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -15,13 +15,14 @@ import type { FetchImpl, HttpClient } from '../lib/http.js'; import { globalShutdown, type ShutdownHandle } from '../lib/interrupt.js'; import { GLOBAL_OPTS_HINT, Output, resolveOutputMode, type OutputMode } from '../lib/output.js'; import { readSecretFileGuarded } from '../lib/secret-file.js'; +import { CLI_PROJECT_LIST_SCHEMA, CLI_PROJECT_SCHEMA } from '../lib/project-response-schema.js'; import { assertNotLocal } from '../lib/target-url.js'; import { renderTextTable, resolveTextColumns, type TextTableColumn } from '../lib/text-table.js'; import { assertIdempotencyKey } from '../lib/validate.js'; import { - fetchSinglePage, paginate, validatePaginationFlags, + type FetchPageArgs, type Page, type PaginationFlags, } from '../lib/pagination.js'; @@ -155,22 +156,17 @@ export async function runList( // request — same shape AWS CLI ships. Otherwise auto-page. const useSinglePage = opts.pageSize !== undefined && opts.maxItems === undefined; + const fetchPage = async ({ pageSize, cursor }: FetchPageArgs) => + client.get>('/projects', { + query: { pageSize, cursor }, + schema: CLI_PROJECT_LIST_SCHEMA, + }); + let page: Page; if (useSinglePage) { - page = await fetchSinglePage( - client, - '/projects', - paginationFlags.pageSize!, - opts.startingToken, - ); + page = await fetchPage({ pageSize: paginationFlags.pageSize!, cursor: opts.startingToken }); } else { - page = await paginate( - async ({ pageSize, cursor }) => - client.get>('/projects', { - query: { pageSize, cursor }, - }), - paginationFlags, - ); + page = await paginate(fetchPage, paginationFlags); } out.print(page, data => { @@ -188,7 +184,9 @@ export async function runGet(opts: GetOptions, deps: ProjectDeps = {}): Promise< const out = makeOutput(opts.output, deps); const client = makeClient(opts, deps); - const project = await client.get(`/projects/${encodeURIComponent(opts.projectId)}`); + const project = await client.get(`/projects/${encodeURIComponent(opts.projectId)}`, { + schema: CLI_PROJECT_SCHEMA, + }); out.print(project, data => renderProjectText(data as CliProject)); return project; } diff --git a/src/commands/test.test.ts b/src/commands/test.test.ts index 55da144c..e35f3e3a 100644 --- a/src/commands/test.test.ts +++ b/src/commands/test.test.ts @@ -1547,6 +1547,101 @@ describe('isPresignedCodeUrl', () => { }); describe('runCodeGet', () => { + it.each(['json', 'text'] as const)( + '%s mode rejects malformed test-code responses before writing output', + async output => { + const { credentialsPath } = makeCreds(); + const stdout: string[] = []; + const fetchImpl = makeFetch(() => ({ + body: { ...TEST_CODE_INLINE, code: { unexpected: 'do not emit this body' } }, + })); + await expect( + runCodeGet( + { profile: 'default', output, debug: false, testId: 'test_fe' }, + { + credentialsPath, + fetchImpl, + stdout: line => stdout.push(line), + rawStdout: chunk => { + stdout.push(chunk); + }, + }, + ), + ).rejects.toMatchObject({ code: 'INTERNAL', exitCode: 1 }); + expect(stdout).toEqual([]); + }, + ); + + it('rejects a missing code field without replacing an existing --out file', async () => { + const { credentialsPath } = makeCreds(); + const dir = mkdtempSync(join(tmpdir(), 'cli-test-code-shape-')); + const target = join(dir, 'existing.json'); + writeFileSync(target, 'keep the original'); + const withoutCode: Record = { ...TEST_CODE_INLINE }; + delete withoutCode.code; + const fetchImpl = makeFetch(() => ({ body: withoutCode })); + await expect( + runCodeGet( + { profile: 'default', output: 'json', debug: false, testId: 'test_fe', out: target }, + { credentialsPath, fetchImpl }, + ), + ).rejects.toMatchObject({ code: 'INTERNAL', exitCode: 1 }); + expect(readFileSync(target, 'utf8')).toBe('keep the original'); + expect(readdirSync(dir)).toEqual(['existing.json']); + }); + + it('preserves additive fields and future code language/framework strings', async () => { + const { credentialsPath } = makeCreds(); + const body = { + ...TEST_CODE_INLINE, + language: 'ruby', + framework: 'rspec', + provenance: { source: 'future-server' }, + }; + const lines: string[] = []; + await runCodeGet( + { profile: 'default', output: 'json', debug: false, testId: 'test_fe' }, + { credentialsPath, fetchImpl: makeFetch(() => ({ body })), stdout: line => lines.push(line) }, + ); + expect(JSON.parse(lines.join(''))).toEqual(body); + }); + + it('routes null code to the no-code response without dereferencing it', async () => { + const { credentialsPath } = makeCreds(); + const lines: string[] = []; + await runCodeGet( + { profile: 'default', output: 'text', debug: false, testId: 'test_fe' }, + { + credentialsPath, + fetchImpl: makeFetch(() => ({ body: { ...TEST_CODE_INLINE, code: null } })), + stderr: line => lines.push(line), + rawStdout: () => { + throw new Error('must not write an absent source body'); + }, + }, + ); + expect(lines).toContain('(no code generated yet — run the test first)'); + }); + + it('rejects an invalid auto-fetched codeVersion before sending code put', async () => { + const { credentialsPath } = makeCreds(); + const dir = mkdtempSync(join(tmpdir(), 'cli-test-code-version-')); + const codeFile = join(dir, 'replacement.py'); + writeFileSync(codeFile, 'print("replacement")'); + const methods: string[] = []; + const fetchImpl = makeFetch((_url, init) => { + methods.push(init.method ?? 'GET'); + return { body: { ...TEST_CODE_INLINE, codeVersion: { unexpected: true } } }; + }); + await expect( + runCodePut( + { profile: 'default', output: 'json', debug: false, testId: 'test_fe', codeFile }, + { credentialsPath, fetchImpl, stderr: () => undefined }, + ), + ).rejects.toMatchObject({ code: 'INTERNAL', exitCode: 1 }); + expect(methods).toEqual(['GET']); + }); + it('JSON mode prints the §6.3 wire shape verbatim and skips the URL fetch', async () => { const { credentialsPath } = makeCreds(); const seen: string[] = []; diff --git a/src/commands/test.ts b/src/commands/test.ts index ec8954ad..bdf2c9b2 100644 --- a/src/commands/test.ts +++ b/src/commands/test.ts @@ -58,6 +58,7 @@ import { import { REQUEST_TIMEOUT_DEFAULT_MS, REQUEST_TIMEOUT_MAX_MS } from '../lib/http.js'; import type { FetchImpl } from '../lib/http.js'; import type { HttpClient } from '../lib/http.js'; +import { CLI_TEST_CODE_SCHEMA } from '../lib/response-schemas.js'; import { VERSION } from '../version.js'; import { GLOBAL_OPTS_HINT, Output, resolveOutputMode, type OutputMode } from '../lib/output.js'; import { @@ -253,6 +254,12 @@ export interface CliTestCode { etag?: string | null; } +/** The standalone code endpoint also serves draft and legacy rows. */ +export type CliTestCodeRead = Omit & { + framework?: CliTestCode['framework']; + code: string | null; +}; + /** §6.4 TestStep wire shape. `null` is "not known", not "absent". */ export interface CliTestStep { testId: string; @@ -4607,7 +4614,10 @@ interface CodeGetOptions extends CommonOptions { * temp file is discarded and the user's pre-existing `--out` file, if * any, is left untouched. */ -export async function runCodeGet(opts: CodeGetOptions, deps: TestDeps = {}): Promise { +export async function runCodeGet( + opts: CodeGetOptions, + deps: TestDeps = {}, +): Promise { // Dry-run: no fetch, no fs. Print the canned shape to stdout and, if // the user passed `--out`, log on stderr what would have been written. // We deliberately do NOT validate the `--out` path here in dry-run — @@ -4617,15 +4627,23 @@ export async function runCodeGet(opts: CodeGetOptions, deps: TestDeps = {}): Pro const stderr = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`)); const out = makeOutput(opts.output, deps); const client = makeClient(opts, deps); - const code = await client.get(`/tests/${encodeURIComponent(opts.testId)}/code`); + const code = await client.get( + `/tests/${encodeURIComponent(opts.testId)}/code`, + { + schema: CLI_TEST_CODE_SCHEMA, + }, + ); if (opts.out !== undefined) { - const bytes = isPresignedCodeUrl(code.code) ? '' : `${code.code.length}`; + const bytes = + code.code !== null && isPresignedCodeUrl(code.code) + ? '' + : `${code.code?.length ?? 0}`; stderr(`[dry-run] would write code body (${bytes} bytes) to ${opts.out}`); } if (opts.output === 'json') { out.print(code); } else { - await out.writeChunk(code.code); + await out.writeChunk(code.code ?? ''); } return code; } @@ -4635,13 +4653,18 @@ export async function runCodeGet(opts: CodeGetOptions, deps: TestDeps = {}): Pro const client = makeClient(opts, deps); try { - const code = await client.get(`/tests/${encodeURIComponent(opts.testId)}/code`); + const code = await client.get( + `/tests/${encodeURIComponent(opts.testId)}/code`, + { + schema: CLI_TEST_CODE_SCHEMA, + }, + ); let wroteContent = false; if (opts.output === 'json') { out.print(code); wroteContent = true; - } else if (isPresignedCodeUrl(code.code)) { + } else if (code.code !== null && isPresignedCodeUrl(code.code)) { // Text mode: dump the source body. JSON consumers want the wire // shape; humans (and agents shelling out via `> file.ts`) want // ready-to-edit code. Stream chunk-wise so a multi-MB generated @@ -4817,7 +4840,12 @@ export async function runCodePut( requireNonEmpty('expected-version', opts.expectedVersion); ifMatch = opts.expectedVersion; } else { - const fetched = await client.get(`/tests/${encodeURIComponent(opts.testId)}/code`); + const fetched = await client.get( + `/tests/${encodeURIComponent(opts.testId)}/code`, + { + schema: CLI_TEST_CODE_SCHEMA, + }, + ); const cv = fetched.codeVersion; if (cv === null || cv === undefined) { // Server hasn't stamped a codeVersion yet (legacy row). Send `*` diff --git a/src/lib/project-response-schema.ts b/src/lib/project-response-schema.ts new file mode 100644 index 00000000..266579c5 --- /dev/null +++ b/src/lib/project-response-schema.ts @@ -0,0 +1,27 @@ +import * as v from 'valibot'; +import type { CliProject } from '../commands/project.js'; +import type { Page } from './pagination.js'; + +/** + * Project read fixtures in commands/project.test.ts and test/mock-backend/fixtures.ts + * establish these core fields. Optional fields must stay absent when omitted: + * in particular, an absent targetUrl means no answer, whereas null means unset. + * Follow response-schemas.ts: preserve added fields and accept future enum strings. + */ +export const CLI_PROJECT_SCHEMA: v.GenericSchema = v.looseObject({ + id: v.string(), + name: v.string(), + type: v.custom(value => typeof value === 'string'), + createdFrom: v.custom(value => typeof value === 'string'), + createdAt: v.string(), + updatedAt: v.string(), + orgId: v.optional(v.string()), + orgName: v.optional(v.string()), + targetUrl: v.optional(v.nullable(v.string())), + testIdAttributes: v.optional(v.nullable(v.array(v.string()))), +}); + +export const CLI_PROJECT_LIST_SCHEMA: v.GenericSchema> = v.looseObject({ + items: v.array(CLI_PROJECT_SCHEMA), + nextToken: v.nullable(v.string()), +}); diff --git a/src/lib/response-schemas.code.test.ts b/src/lib/response-schemas.code.test.ts new file mode 100644 index 00000000..545c1208 --- /dev/null +++ b/src/lib/response-schemas.code.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import * as v from 'valibot'; +import { testCodeFixture, testCodeLargeFixture } from '../../test/mock-backend/fixtures.js'; +import { CLI_TEST_CODE_SCHEMA } from './response-schemas.js'; + +describe('CLI_TEST_CODE_SCHEMA', () => { + it.each([testCodeFixture, testCodeLargeFixture])('preserves a complete source fixture', body => { + expect(v.parse(CLI_TEST_CODE_SCHEMA, body)).toEqual(body); + }); + + it('accepts an omitted framework on the code-put legacy auto-fetch shape', () => { + const body = { testId: 'test_alpha', language: 'typescript', code: 'old', codeVersion: null }; + expect(v.parse(CLI_TEST_CODE_SCHEMA, body)).toEqual(body); + }); + + it('normalizes an absent legacy codeVersion to null without inventing an etag', () => { + const body = { testId: 'test_alpha', language: 'python', code: '' }; + const parsed = v.parse(CLI_TEST_CODE_SCHEMA, body); + expect(parsed.codeVersion).toBeNull(); + expect(parsed).not.toHaveProperty('etag'); + expect(parsed).not.toHaveProperty('framework'); + }); + + it('preserves explicit null etag and code from a draft row', () => { + const body = { ...testCodeFixture, code: null, etag: null }; + expect(v.parse(CLI_TEST_CODE_SCHEMA, body)).toEqual(body); + }); + + it.each([ + null, + [], + { ...testCodeFixture, testId: 7 }, + { ...testCodeFixture, language: false }, + { ...testCodeFixture, framework: [] }, + { ...testCodeFixture, code: undefined }, + { ...testCodeFixture, codeVersion: 7 }, + { ...testCodeFixture, etag: {} }, + ])('rejects an incompatible response shape', body => { + expect(v.safeParse(CLI_TEST_CODE_SCHEMA, body).success).toBe(false); + }); +}); diff --git a/src/lib/response-schemas.ts b/src/lib/response-schemas.ts index 35568cfd..2fb1ac79 100644 --- a/src/lib/response-schemas.ts +++ b/src/lib/response-schemas.ts @@ -1,13 +1,12 @@ /** - * Valibot schemas for the run-path wire shapes (issue #102). + * Valibot schemas for the run-path and test-code wire shapes (#102, #277). * * `requestWithMeta` used to return `(await response.json()) as T` with zero * runtime validation, so a drifted or partial server response surfaced as * `undefined` output or an opaque TypeError deep inside a command. These * schemas are wired (opt-in, via `RequestOptions.schema`) into the typed - * HttpClient helpers only: `triggerRun`, `triggerRunWithMeta`, `triggerRerun`, - * `triggerBatchRerun`, `triggerBatchRunFresh`, `getRun`, `listTestRuns`. - * The generic `get`/`post`/`put`/`patch`/`delete` paths stay schema-free. + * HttpClient helpers and selected command reads. Generic requests remain + * schema-free unless the caller supplies a schema. * * Resilience rules (additive server changes must never hard-fail the CLI): * @@ -47,6 +46,7 @@ import type { import type { CliTestListRunResponse } from './testlist.types.js'; import type { TunnelMintResponse, TunnelStatusResponse } from './tunnel.types.js'; import type { ConflictReason } from './conflict-reason.js'; +import type { CliTestCodeRead } from '../commands/test.js'; /** * Compile-time literal union, runtime open string. @@ -60,6 +60,23 @@ function openWireLiteral(): v.GenericSchema(value => typeof value === 'string'); } +/** + * GET /tests/{id}/code. The inline and presigned fixtures in + * test/mock-backend/fixtures.ts contain the required identity/source fields. + * The code-put auto-fetch fixtures in commands/test.test.ts omit framework; + * legacy codeVersion may be null/absent and already uses the explicit + * If-Match fallback. Keep etag absence distinct from an explicit null. + * A null code body is the draft/no-generated-code branch of runCodeGet. + */ +export const CLI_TEST_CODE_SCHEMA: v.GenericSchema = v.looseObject({ + testId: v.string(), + language: openWireLiteral(), + framework: v.optional(openWireLiteral>()), + code: v.nullable(v.string()), + codeVersion: v.nullish(v.string(), null), + etag: v.optional(v.nullable(v.string())), +}); + // --------------------------------------------------------------------------- // GET /runs/{runId} // --------------------------------------------------------------------------- diff --git a/test/contract/p4-schema.test.ts b/test/contract/p4-schema.test.ts index cedea9ca..dd6a536c 100644 --- a/test/contract/p4-schema.test.ts +++ b/test/contract/p4-schema.test.ts @@ -301,7 +301,7 @@ describe('P4 schema contract — CLI runners return §6.x shapes', () => { { credentialsPath, stdout: () => undefined }, ); validateTestCode(code); - expect(code.code.startsWith('https://')).toBe(false); + expect(code.code?.startsWith('https://')).toBe(false); }); it('runCodeGet (presigned) returns a §6.3 TestCode with https code', async () => { @@ -311,7 +311,7 @@ describe('P4 schema contract — CLI runners return §6.x shapes', () => { { credentialsPath, stdout: () => undefined }, ); validateTestCode(code); - expect(code.code.startsWith('https://')).toBe(true); + expect(code.code?.startsWith('https://')).toBe(true); }); it('runSteps returns a §6.4 TestStepList', async () => { From e5bab112d8e40af2f42da39157f0d7256bd2ab91 Mon Sep 17 00:00:00 2001 From: Gerald Bate Date: Sun, 6 Sep 2026 05:39:18 -0400 Subject: [PATCH 2/4] fix: align code read types with accepted server values --- src/commands/test.test.ts | 7 +++++-- src/commands/test.ts | 7 ++++--- src/lib/response-schemas.ts | 4 ++-- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/commands/test.test.ts b/src/commands/test.test.ts index e35f3e3a..ff44d4f4 100644 --- a/src/commands/test.test.ts +++ b/src/commands/test.test.ts @@ -8,7 +8,7 @@ import { } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest'; import type { Command } from 'commander'; import type { RunResponse } from '../lib/runs.types.js'; import { ApiError, InterruptError } from '../lib/errors.js'; @@ -1599,10 +1599,13 @@ describe('runCodeGet', () => { provenance: { source: 'future-server' }, }; const lines: string[] = []; - await runCodeGet( + const result = await runCodeGet( { profile: 'default', output: 'json', debug: false, testId: 'test_fe' }, { credentialsPath, fetchImpl: makeFetch(() => ({ body })), stdout: line => lines.push(line) }, ); + expectTypeOf(result.language).toEqualTypeOf(); + expectTypeOf(result.framework).toEqualTypeOf(); + expect(result).toEqual(body); expect(JSON.parse(lines.join(''))).toEqual(body); }); diff --git a/src/commands/test.ts b/src/commands/test.ts index bdf2c9b2..039323dc 100644 --- a/src/commands/test.ts +++ b/src/commands/test.ts @@ -254,9 +254,10 @@ export interface CliTestCode { etag?: string | null; } -/** The standalone code endpoint also serves draft and legacy rows. */ -export type CliTestCodeRead = Omit & { - framework?: CliTestCode['framework']; +/** The standalone code endpoint also serves draft, legacy, and newer server values. */ +export type CliTestCodeRead = Omit & { + language: string; + framework?: string; code: string | null; }; diff --git a/src/lib/response-schemas.ts b/src/lib/response-schemas.ts index 2fb1ac79..5910a84b 100644 --- a/src/lib/response-schemas.ts +++ b/src/lib/response-schemas.ts @@ -70,8 +70,8 @@ function openWireLiteral(): v.GenericSchema = v.looseObject({ testId: v.string(), - language: openWireLiteral(), - framework: v.optional(openWireLiteral>()), + language: v.string(), + framework: v.optional(v.string()), code: v.nullable(v.string()), codeVersion: v.nullish(v.string(), null), etag: v.optional(v.nullable(v.string())), From d94bea7cbacdd31e2bc6da0774bc449613dae269 Mon Sep 17 00:00:00 2001 From: Zeshi Du Date: Mon, 2 Nov 2026 19:29:30 -0800 Subject: [PATCH 3/4] fix(cli): validate project and test-code responses with shared schemas Move the project schemas into response-schemas.ts, give fetchSinglePage an optional schema, validate renderer-consumed project fields and nextToken, keep codeVersion as received, and check for a null or empty code before isPresignedCodeUrl on both the dry-run and real code get paths. Part of #277. Follow-up to #340. --- CHANGELOG.md | 4 +++ src/commands/project.test.ts | 33 +++++++++++++++++++ src/commands/project.ts | 12 +++++-- src/commands/test.test.ts | 45 +++++++++++++++++++++++++ src/commands/test.ts | 39 +++++++++++++--------- src/lib/pagination.ts | 3 ++ src/lib/project-response-schema.ts | 27 --------------- src/lib/response-schemas.code.test.ts | 5 +-- src/lib/response-schemas.ts | 47 ++++++++++++++++++++++++--- 9 files changed, 164 insertions(+), 51 deletions(-) delete mode 100644 src/lib/project-response-schema.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 34cac68b..354f93b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to `@testsprite/testsprite-cli` are documented here. The for ## [Unreleased] +### Fixed + +- **`project get`/`project list` and `test code get`/`code put` validate the response shape.** A malformed response now exits 1 with an `INTERNAL` "Response shape mismatch" error (request id and the first mismatched paths, never the body) instead of a raw `TypeError`, a bad body printed as a success, or a bad `If-Match` header on `code put`. Additive server fields and new project type/language/framework values still pass through unchanged, an omitted project `nextToken` counts as the last page, and a null or empty `code` is reported as "no code generated yet" (with `--out`, no file is written and an existing one is left untouched). `test code get --output json` is unchanged for legacy rows. Thanks @jerrybate-sketch (#340). + ## [0.16.0] - 2026-10-30 ### Added diff --git a/src/commands/project.test.ts b/src/commands/project.test.ts index 20652622..10847c0b 100644 --- a/src/commands/project.test.ts +++ b/src/commands/project.test.ts @@ -737,6 +737,39 @@ describe('project read response validation', () => { expect(out).toEqual([]); }); + it.each([ + ['defaultEnvironment', { defaultEnvironment: {} }], + ['environmentCount', { environmentCount: 'x' }], + ['originMode', { originMode: 1 }], + ])('rejects a wrong-typed %s before the text renderer runs', async (_field, patch) => { + const out: string[] = []; + await expect( + runGet( + { profile: 'default', output: 'text', debug: false, projectId: PROJECT_FIXTURE.id }, + { + ...makeCreds(), + fetchImpl: makeFetch(() => ({ body: { ...PROJECT_FIXTURE, ...patch } })), + stdout: line => out.push(line), + }, + ), + ).rejects.toMatchObject({ code: 'INTERNAL', exitCode: 1 }); + expect(out).toEqual([]); + }); + + it('normalizes an omitted nextToken to the terminal null cursor', async () => { + const out: string[] = []; + const result = await runList( + { profile: 'default', output: 'json', debug: false, pageSize: 1 }, + { + ...makeCreds(), + fetchImpl: makeFetch(() => ({ body: { items: [PROJECT_FIXTURE] } })), + stdout: line => out.push(line), + }, + ); + expect(result.nextToken).toBeNull(); + expect(JSON.parse(out[0]!)).toEqual({ items: [PROJECT_FIXTURE], nextToken: null }); + }); + it('preserves new server fields and enum values without inventing absent optional fields', async () => { const body = { ...PROJECT_FIXTURE, diff --git a/src/commands/project.ts b/src/commands/project.ts index 4bf4eec3..6ae5aff6 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -24,11 +24,12 @@ import { } from '../lib/local-target.js'; import { GLOBAL_OPTS_HINT, Output, resolveOutputMode, type OutputMode } from '../lib/output.js'; import { readSecretFileGuarded } from '../lib/secret-file.js'; -import { CLI_PROJECT_LIST_SCHEMA, CLI_PROJECT_SCHEMA } from '../lib/project-response-schema.js'; +import { CLI_PROJECT_LIST_SCHEMA, CLI_PROJECT_SCHEMA } from '../lib/response-schemas.js'; import { assertNotLocal } from '../lib/target-url.js'; import { renderTextTable, resolveTextColumns, type TextTableColumn } from '../lib/text-table.js'; import { assertIdempotencyKey } from '../lib/validate.js'; import { + fetchSinglePage, paginate, validatePaginationFlags, type FetchPageArgs, @@ -181,7 +182,14 @@ export async function runList( let page: Page; if (useSinglePage) { - page = await fetchPage({ pageSize: paginationFlags.pageSize!, cursor: opts.startingToken }); + page = await fetchSinglePage( + client, + '/projects', + paginationFlags.pageSize!, + opts.startingToken, + {}, + CLI_PROJECT_LIST_SCHEMA, + ); } else { page = await paginate(fetchPage, paginationFlags); } diff --git a/src/commands/test.test.ts b/src/commands/test.test.ts index 839bea68..4672b0da 100644 --- a/src/commands/test.test.ts +++ b/src/commands/test.test.ts @@ -2123,6 +2123,51 @@ describe('runCodeGet', () => { expect(lines).toContain('(no code generated yet — run the test first)'); }); + it('null code with --out refuses to write and leaves an existing file unchanged', async () => { + const { credentialsPath } = makeCreds(); + const dir = mkdtempSync(join(tmpdir(), 'cli-test-code-null-')); + const target = join(dir, 'existing.py'); + // eslint-disable-next-line security/detect-non-literal-fs-filename -- target is inside this test's own mkdtempSync directory, never user input. + writeFileSync(target, 'keep the original'); + await expect( + runCodeGet( + { profile: 'default', output: 'text', debug: false, testId: 'test_fe', out: target }, + { + credentialsPath, + fetchImpl: makeFetch(() => ({ body: { ...TEST_CODE_INLINE, code: null } })), + stderr: () => undefined, + }, + ), + ).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 }); + // eslint-disable-next-line security/detect-non-literal-fs-filename -- target is the fixture path created inside this test's own temp directory. + expect(readFileSync(target, 'utf8')).toBe('keep the original'); + // eslint-disable-next-line security/detect-non-literal-fs-filename -- dir is created by this test's mkdtempSync call, never user input. + expect(readdirSync(dir)).toEqual(['existing.py']); + }); + + it('dry-run with null code and --out reports zero bytes instead of throwing', async () => { + const { credentialsPath } = makeCreds(); + const errs: string[] = []; + await runCodeGet( + { + profile: 'default', + output: 'text', + debug: false, + dryRun: true, + testId: 'test_fe', + out: join(tmpdir(), 'cli-dry-run-never-written.py'), + }, + { + credentialsPath, + fetchImpl: makeFetch(() => ({ body: { ...TEST_CODE_INLINE, code: null } })), + stderr: line => errs.push(line), + stdout: () => undefined, + rawStdout: () => undefined, + }, + ); + expect(errs.join('\n')).toContain('would write code body (0 bytes)'); + }); + it('rejects an invalid auto-fetched codeVersion before sending code put', async () => { const { credentialsPath } = makeCreds(); const dir = mkdtempSync(join(tmpdir(), 'cli-test-code-version-')); diff --git a/src/commands/test.ts b/src/commands/test.ts index f592c00d..b7a4a9fc 100644 --- a/src/commands/test.ts +++ b/src/commands/test.ts @@ -332,10 +332,15 @@ export interface CliTestCode { } /** The standalone code endpoint also serves draft, legacy, and newer server values. */ -export type CliTestCodeRead = Omit & { +export type CliTestCodeRead = Omit< + CliTestCode, + 'language' | 'framework' | 'code' | 'codeVersion' +> & { language: string; framework?: string; code: string | null; + /** Legacy rows may omit it or send null; both mean "no version stamped yet". */ + codeVersion?: string | null; }; /** §6.4 TestStep wire shape. `null` is "not known", not "absent". */ @@ -5489,10 +5494,13 @@ export async function runCodeGet( }, ); if (opts.out !== undefined) { + // Null/empty first: isPresignedCodeUrl dereferences its argument. const bytes = - code.code !== null && isPresignedCodeUrl(code.code) - ? '' - : `${code.code?.length ?? 0}`; + code.code === null || code.code === '' + ? '0' + : isPresignedCodeUrl(code.code) + ? '' + : `${code.code.length}`; stderr(`[dry-run] would write code body (${bytes} bytes) to ${opts.out}`); } if (opts.output === 'json') { @@ -5519,18 +5527,8 @@ export async function runCodeGet( if (opts.output === 'json') { out.print(code); wroteContent = true; - } else if (code.code !== null && isPresignedCodeUrl(code.code)) { - // Text mode: dump the source body. JSON consumers want the wire - // shape; humans (and agents shelling out via `> file.ts`) want - // ready-to-edit code. Stream chunk-wise so a multi-MB generated - // suite doesn't sit in memory waiting for the full download. - // `writeChunk` awaits the rawStdout drain promise so a slow - // downstream consumer (a file on a slow disk, an NFS mount, - // or a piped `gzip`) pauses the upstream reader rather than - // letting chunks accumulate in V8's heap. - await streamPresignedBody(code.code, out, deps); - wroteContent = true; } else if (code.code === '' || code.code === null) { + // Checked before isPresignedCodeUrl, which dereferences its argument. // P2-10: draft test with no code yet — empty body would produce // silent empty stdout. Print a friendly hint to stderr instead so // the operator knows what happened, and keep exit 0 when no `--out`. @@ -5549,6 +5547,17 @@ export async function runCodeGet( } const stderrFn = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`)); stderrFn('(no code generated yet — run the test first)'); + } else if (isPresignedCodeUrl(code.code)) { + // Text mode: dump the source body. JSON consumers want the wire + // shape; humans (and agents shelling out via `> file.ts`) want + // ready-to-edit code. Stream chunk-wise so a multi-MB generated + // suite doesn't sit in memory waiting for the full download. + // `writeChunk` awaits the rawStdout drain promise so a slow + // downstream consumer (a file on a slow disk, an NFS mount, + // or a piped `gzip`) pauses the upstream reader rather than + // letting chunks accumulate in V8's heap. + await streamPresignedBody(code.code, out, deps); + wroteContent = true; } else { await out.writeChunk(code.code); wroteContent = true; diff --git a/src/lib/pagination.ts b/src/lib/pagination.ts index cae75811..e6b0026b 100644 --- a/src/lib/pagination.ts +++ b/src/lib/pagination.ts @@ -1,3 +1,4 @@ +import type * as v from 'valibot'; import type { HttpClient } from './http.js'; import { ApiError, localValidationError } from './errors.js'; @@ -153,8 +154,10 @@ export async function fetchSinglePage( pageSize: number, cursor: string | undefined, extraQuery: Record = {}, + schema?: v.GenericSchema>, ): Promise> { return client.get>(path, { query: { ...extraQuery, pageSize, cursor }, + ...(schema !== undefined ? { schema } : {}), }); } diff --git a/src/lib/project-response-schema.ts b/src/lib/project-response-schema.ts deleted file mode 100644 index 266579c5..00000000 --- a/src/lib/project-response-schema.ts +++ /dev/null @@ -1,27 +0,0 @@ -import * as v from 'valibot'; -import type { CliProject } from '../commands/project.js'; -import type { Page } from './pagination.js'; - -/** - * Project read fixtures in commands/project.test.ts and test/mock-backend/fixtures.ts - * establish these core fields. Optional fields must stay absent when omitted: - * in particular, an absent targetUrl means no answer, whereas null means unset. - * Follow response-schemas.ts: preserve added fields and accept future enum strings. - */ -export const CLI_PROJECT_SCHEMA: v.GenericSchema = v.looseObject({ - id: v.string(), - name: v.string(), - type: v.custom(value => typeof value === 'string'), - createdFrom: v.custom(value => typeof value === 'string'), - createdAt: v.string(), - updatedAt: v.string(), - orgId: v.optional(v.string()), - orgName: v.optional(v.string()), - targetUrl: v.optional(v.nullable(v.string())), - testIdAttributes: v.optional(v.nullable(v.array(v.string()))), -}); - -export const CLI_PROJECT_LIST_SCHEMA: v.GenericSchema> = v.looseObject({ - items: v.array(CLI_PROJECT_SCHEMA), - nextToken: v.nullable(v.string()), -}); diff --git a/src/lib/response-schemas.code.test.ts b/src/lib/response-schemas.code.test.ts index 545c1208..753a820a 100644 --- a/src/lib/response-schemas.code.test.ts +++ b/src/lib/response-schemas.code.test.ts @@ -13,10 +13,11 @@ describe('CLI_TEST_CODE_SCHEMA', () => { expect(v.parse(CLI_TEST_CODE_SCHEMA, body)).toEqual(body); }); - it('normalizes an absent legacy codeVersion to null without inventing an etag', () => { + it('keeps an absent legacy codeVersion absent so --output json stays byte-identical', () => { const body = { testId: 'test_alpha', language: 'python', code: '' }; const parsed = v.parse(CLI_TEST_CODE_SCHEMA, body); - expect(parsed.codeVersion).toBeNull(); + expect(parsed).toEqual(body); + expect(parsed).not.toHaveProperty('codeVersion'); expect(parsed).not.toHaveProperty('etag'); expect(parsed).not.toHaveProperty('framework'); }); diff --git a/src/lib/response-schemas.ts b/src/lib/response-schemas.ts index e0a08ab5..567e62ec 100644 --- a/src/lib/response-schemas.ts +++ b/src/lib/response-schemas.ts @@ -5,8 +5,13 @@ * runtime validation, so a drifted or partial server response surfaced as * `undefined` output or an opaque TypeError deep inside a command. These * schemas are wired (opt-in, via `RequestOptions.schema`) into the typed - * HttpClient helpers and selected command reads. Generic requests remain - * schema-free unless the caller supplies a schema. + * HttpClient helpers `triggerRun`, `triggerRunWithMeta`, `triggerRerun`, + * `triggerBatchRerun`, `triggerBatchRunFresh`, `getRun`, `listTestRuns`, + * `cancelRun`, and — for the account surfaces below — the `client.get` call + * sites that own a `/me` or usage read, plus the `client.get` call sites that + * own the `project get` / `project list` and `test code get` / `code put` + * reads. Every other generic `get`/`post`/`put`/`patch`/`delete` caller stays + * schema-free and opt-in. * * Resilience rules (additive server changes must never hard-fail the CLI): * @@ -60,7 +65,9 @@ import type { TunnelStatusResponse, } from './tunnel.types.js'; import type { ConflictReason } from './conflict-reason.js'; +import type { CliProject } from '../commands/project.js'; import type { CliTestCodeRead } from '../commands/test.js'; +import type { Page } from './pagination.js'; /** Deployment environment the bound key belongs to; open on the wire (rule 2). */ type AccountEnv = 'development' | 'staging' | 'production'; @@ -81,8 +88,9 @@ function openWireLiteral(): v.GenericSchema = v.looseObject({ @@ -90,10 +98,39 @@ export const CLI_TEST_CODE_SCHEMA: v.GenericSchema = v language: v.string(), framework: v.optional(v.string()), code: v.nullable(v.string()), - codeVersion: v.nullish(v.string(), null), + codeVersion: v.optional(v.nullable(v.string())), etag: v.optional(v.nullable(v.string())), }); +/** + * GET /projects/{id} and the rows of GET /projects. Optional fields stay + * absent when omitted: an absent `targetUrl` means "no answer", whereas + * `null` means "unset" (see `CliProject.targetUrl`). The renderer-consumed + * environment fields are typed so a drifted value cannot throw in the text + * renderer. + */ +export const CLI_PROJECT_SCHEMA: v.GenericSchema = v.looseObject({ + id: v.string(), + name: v.string(), + type: openWireLiteral(), + createdFrom: openWireLiteral(), + createdAt: v.string(), + updatedAt: v.string(), + orgId: v.optional(v.string()), + orgName: v.optional(v.string()), + targetUrl: v.optional(v.nullable(v.string())), + defaultEnvironment: v.optional(v.nullable(v.string())), + environmentCount: v.optional(v.number()), + originMode: v.optional(openWireLiteral>()), + testIdAttributes: v.optional(v.nullable(v.array(v.string()))), +}); + +/** GET /projects page; an omitted `nextToken` normalizes to the terminal `null` cursor. */ +export const CLI_PROJECT_LIST_SCHEMA: v.GenericSchema> = v.looseObject({ + items: v.array(CLI_PROJECT_SCHEMA), + nextToken: v.nullish(v.string(), null), +}); + /** * Mirrors `RunEnvironmentRef` (runs.types.ts): the environment a run resolved to. * Optional + nullable everywhere it appears: absent on an older backend, `null` From ea959b53b19de8f03e89fb6a29b40a11f8f71ea2 Mon Sep 17 00:00:00 2001 From: Zeshi Du Date: Mon, 2 Nov 2026 23:21:22 -0800 Subject: [PATCH 4/4] docs(changelog): scope the null-code --out note to text mode --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 354f93b2..f7e17ea0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to `@testsprite/testsprite-cli` are documented here. The for ### Fixed -- **`project get`/`project list` and `test code get`/`code put` validate the response shape.** A malformed response now exits 1 with an `INTERNAL` "Response shape mismatch" error (request id and the first mismatched paths, never the body) instead of a raw `TypeError`, a bad body printed as a success, or a bad `If-Match` header on `code put`. Additive server fields and new project type/language/framework values still pass through unchanged, an omitted project `nextToken` counts as the last page, and a null or empty `code` is reported as "no code generated yet" (with `--out`, no file is written and an existing one is left untouched). `test code get --output json` is unchanged for legacy rows. Thanks @jerrybate-sketch (#340). +- **`project get`/`project list` and `test code get`/`code put` validate the response shape.** A malformed response now exits 1 with an `INTERNAL` "Response shape mismatch" error (request id and the first mismatched paths, never the body) instead of a raw `TypeError`, a bad body printed as a success, or a bad `If-Match` header on `code put`. Additive server fields and new project type/language/framework values still pass through unchanged, an omitted project `nextToken` counts as the last page, and in text mode a null or empty `code` is reported as "no code generated yet" (with `--out`, no file is written and an existing one is left untouched). `test code get --output json` is unchanged for legacy rows. Thanks @jerrybate-sketch (#340). ## [0.16.0] - 2026-10-30