Skip to content

Commit fd4cfb3

Browse files
committed
fix(project): avoid password file reads in dry-run update
1 parent 15e95de commit fd4cfb3

3 files changed

Lines changed: 74 additions & 17 deletions

File tree

src/commands/project.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -668,6 +668,33 @@ describe('runUpdate', () => {
668668
expect(result.updatedFields).toContain('name');
669669
});
670670

671+
it('P7 — dry-run with --password-file does not read the filesystem', async () => {
672+
const { credentialsPath } = makeCreds();
673+
const fetchImpl = vi.fn(async () => {
674+
throw new Error('should not hit network');
675+
});
676+
const result = await runUpdate(
677+
{
678+
profile: 'default',
679+
output: 'json',
680+
debug: false,
681+
dryRun: true,
682+
projectId: 'proj_dry',
683+
passwordFile: '/tmp/definitely-not-here-testsprite',
684+
},
685+
{
686+
credentialsPath,
687+
fetchImpl: fetchImpl as unknown as typeof fetch,
688+
stdout: () => {},
689+
stderr: () => {},
690+
},
691+
);
692+
693+
expect(fetchImpl).not.toHaveBeenCalled();
694+
expect(result.id).toBe('proj_dry');
695+
expect(result.updatedFields).toContain('password');
696+
});
697+
671698
it('P7 — renders text mode with updatedFields and updatedAt', async () => {
672699
const { credentialsPath } = makeCreds();
673700
const updateResponse: CliUpdateProjectResponse = {

src/commands/project.ts

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -254,27 +254,24 @@ export async function runUpdate(
254254
throw localValidationError('--description must be at most 2000 characters');
255255
}
256256

257-
// Resolve password
258-
let password = opts.password;
259-
if (password === undefined && opts.passwordFile !== undefined) {
260-
password = readFileSync(opts.passwordFile, 'utf8').trim();
261-
}
262-
263257
// P2-7: guard --url against localhost/RFC1918/non-http(s).
264258
if (opts.targetUrl !== undefined) {
265259
assertNotLocal(opts.targetUrl);
266260
}
267261

268-
const mutableFields: Record<string, string | undefined> = {
269-
name: opts.name,
270-
targetUrl: opts.targetUrl,
271-
username: opts.username,
272-
password,
273-
description: opts.description,
274-
instruction: opts.instruction,
262+
const passwordSupplied = opts.password !== undefined || opts.passwordFile !== undefined;
263+
const mutableFields: Record<string, boolean> = {
264+
name: opts.name !== undefined,
265+
targetUrl: opts.targetUrl !== undefined,
266+
username: opts.username !== undefined,
267+
password: passwordSupplied,
268+
description: opts.description !== undefined,
269+
instruction: opts.instruction !== undefined,
275270
};
276-
const presentFields = Object.entries(mutableFields).filter(([, v]) => v !== undefined);
277-
if (presentFields.length === 0) {
271+
const presentFieldNames = Object.entries(mutableFields)
272+
.filter(([, present]) => present)
273+
.map(([field]) => field);
274+
if (presentFieldNames.length === 0) {
278275
throw localValidationError(
279276
'At least one mutable flag is required: --name, --url, --username, --password/--password-file, --description, or --instruction.',
280277
);
@@ -290,19 +287,36 @@ export async function runUpdate(
290287
}
291288
const sample: CliUpdateProjectResponse = {
292289
id: opts.projectId,
293-
updatedFields: presentFields.map(([k]) => k),
290+
updatedFields: presentFieldNames,
294291
updatedAt: '2026-05-16T00:00:00.000Z',
295292
};
296293
out.print(sample, data => renderUpdateText(data as CliUpdateProjectResponse));
297294
return sample;
298295
}
299296

297+
// Resolve password only on the real path. Dry-run must not touch the
298+
// filesystem, even when --password-file is present.
299+
let password = opts.password;
300+
if (password === undefined && opts.passwordFile !== undefined) {
301+
password = readFileSync(opts.passwordFile, 'utf8').trim();
302+
}
303+
300304
const idempotencyKey = opts.idempotencyKey ?? `cli-proj-update-${randomUUID()}`;
301305
if (opts.idempotencyKey === undefined && (opts.output === 'json' || opts.verbose || opts.debug)) {
302306
stderr(`idempotency-key: ${idempotencyKey}`);
303307
}
304308

305-
const body = Object.fromEntries(presentFields) as Record<string, string>;
309+
const bodyFields: Record<string, string | undefined> = {
310+
name: opts.name,
311+
targetUrl: opts.targetUrl,
312+
username: opts.username,
313+
password,
314+
description: opts.description,
315+
instruction: opts.instruction,
316+
};
317+
const body = Object.fromEntries(
318+
Object.entries(bodyFields).filter(([, v]) => v !== undefined),
319+
) as Record<string, string>;
306320
const client = makeClient(opts, deps);
307321
const updated = await client.patch<CliUpdateProjectResponse>(
308322
`/projects/${encodeURIComponent(opts.projectId)}`,

test/cli.subprocess.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -893,6 +893,22 @@ describe('--dry-run subprocess smoke', () => {
893893
expect(parsed.id).toBeTruthy();
894894
}, 30_000);
895895

896+
it('project update --dry-run does not read a missing --password-file', async () => {
897+
const result = await runCli([
898+
'project',
899+
'update',
900+
'proj_anything',
901+
'--password-file',
902+
'/tmp/definitely-not-here-testsprite',
903+
'--dry-run',
904+
'--output',
905+
'json',
906+
]);
907+
expect(result.exitCode).toBe(0);
908+
const parsed = JSON.parse(result.stdout) as { updatedFields: string[] };
909+
expect(parsed.updatedFields).toContain('password');
910+
}, 30_000);
911+
896912
it('test list --dry-run returns canned TestList', async () => {
897913
const result = await runCli([
898914
'test',

0 commit comments

Comments
 (0)