From fd4cfb31de0cbdab93fca2997db5886721882d1a Mon Sep 17 00:00:00 2001 From: JerryNee Date: Sat, 27 Jun 2026 22:51:32 -0500 Subject: [PATCH] fix(project): avoid password file reads in dry-run update --- src/commands/project.test.ts | 27 ++++++++++++++++++++ src/commands/project.ts | 48 +++++++++++++++++++++++------------- test/cli.subprocess.test.ts | 16 ++++++++++++ 3 files changed, 74 insertions(+), 17 deletions(-) diff --git a/src/commands/project.test.ts b/src/commands/project.test.ts index 0037b8c..ec71fcd 100644 --- a/src/commands/project.test.ts +++ b/src/commands/project.test.ts @@ -668,6 +668,33 @@ describe('runUpdate', () => { expect(result.updatedFields).toContain('name'); }); + it('P7 — dry-run with --password-file does not read the filesystem', async () => { + const { credentialsPath } = makeCreds(); + const fetchImpl = vi.fn(async () => { + throw new Error('should not hit network'); + }); + const result = await runUpdate( + { + profile: 'default', + output: 'json', + debug: false, + dryRun: true, + projectId: 'proj_dry', + passwordFile: '/tmp/definitely-not-here-testsprite', + }, + { + credentialsPath, + fetchImpl: fetchImpl as unknown as typeof fetch, + stdout: () => {}, + stderr: () => {}, + }, + ); + + expect(fetchImpl).not.toHaveBeenCalled(); + expect(result.id).toBe('proj_dry'); + expect(result.updatedFields).toContain('password'); + }); + it('P7 — renders text mode with updatedFields and updatedAt', async () => { const { credentialsPath } = makeCreds(); const updateResponse: CliUpdateProjectResponse = { diff --git a/src/commands/project.ts b/src/commands/project.ts index 277f3ca..d0e9684 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -254,27 +254,24 @@ export async function runUpdate( throw localValidationError('--description must be at most 2000 characters'); } - // Resolve password - let password = opts.password; - if (password === undefined && opts.passwordFile !== undefined) { - password = readFileSync(opts.passwordFile, 'utf8').trim(); - } - // P2-7: guard --url against localhost/RFC1918/non-http(s). if (opts.targetUrl !== undefined) { assertNotLocal(opts.targetUrl); } - const mutableFields: Record = { - name: opts.name, - targetUrl: opts.targetUrl, - username: opts.username, - password, - description: opts.description, - instruction: opts.instruction, + const passwordSupplied = opts.password !== undefined || opts.passwordFile !== undefined; + const mutableFields: Record = { + name: opts.name !== undefined, + targetUrl: opts.targetUrl !== undefined, + username: opts.username !== undefined, + password: passwordSupplied, + description: opts.description !== undefined, + instruction: opts.instruction !== undefined, }; - const presentFields = Object.entries(mutableFields).filter(([, v]) => v !== undefined); - if (presentFields.length === 0) { + const presentFieldNames = Object.entries(mutableFields) + .filter(([, present]) => present) + .map(([field]) => field); + if (presentFieldNames.length === 0) { throw localValidationError( 'At least one mutable flag is required: --name, --url, --username, --password/--password-file, --description, or --instruction.', ); @@ -290,19 +287,36 @@ export async function runUpdate( } const sample: CliUpdateProjectResponse = { id: opts.projectId, - updatedFields: presentFields.map(([k]) => k), + updatedFields: presentFieldNames, updatedAt: '2026-05-16T00:00:00.000Z', }; out.print(sample, data => renderUpdateText(data as CliUpdateProjectResponse)); return sample; } + // Resolve password only on the real path. Dry-run must not touch the + // filesystem, even when --password-file is present. + let password = opts.password; + if (password === undefined && opts.passwordFile !== undefined) { + password = readFileSync(opts.passwordFile, 'utf8').trim(); + } + const idempotencyKey = opts.idempotencyKey ?? `cli-proj-update-${randomUUID()}`; if (opts.idempotencyKey === undefined && (opts.output === 'json' || opts.verbose || opts.debug)) { stderr(`idempotency-key: ${idempotencyKey}`); } - const body = Object.fromEntries(presentFields) as Record; + const bodyFields: Record = { + name: opts.name, + targetUrl: opts.targetUrl, + username: opts.username, + password, + description: opts.description, + instruction: opts.instruction, + }; + const body = Object.fromEntries( + Object.entries(bodyFields).filter(([, v]) => v !== undefined), + ) as Record; const client = makeClient(opts, deps); const updated = await client.patch( `/projects/${encodeURIComponent(opts.projectId)}`, diff --git a/test/cli.subprocess.test.ts b/test/cli.subprocess.test.ts index 95521a3..4b1bc8d 100644 --- a/test/cli.subprocess.test.ts +++ b/test/cli.subprocess.test.ts @@ -893,6 +893,22 @@ describe('--dry-run subprocess smoke', () => { expect(parsed.id).toBeTruthy(); }, 30_000); + it('project update --dry-run does not read a missing --password-file', async () => { + const result = await runCli([ + 'project', + 'update', + 'proj_anything', + '--password-file', + '/tmp/definitely-not-here-testsprite', + '--dry-run', + '--output', + 'json', + ]); + expect(result.exitCode).toBe(0); + const parsed = JSON.parse(result.stdout) as { updatedFields: string[] }; + expect(parsed.updatedFields).toContain('password'); + }, 30_000); + it('test list --dry-run returns canned TestList', async () => { const result = await runCli([ 'test',