diff --git a/src/commands/project.test.ts b/src/commands/project.test.ts index f63928c..2653288 100644 --- a/src/commands/project.test.ts +++ b/src/commands/project.test.ts @@ -664,6 +664,39 @@ describe('runCreate', () => { ).rejects.toMatchObject({ exitCode: 5, code: 'VALIDATION_ERROR' }); expect(fetchImpl).not.toHaveBeenCalled(); }); + + it('rejects a missing --password-file with VALIDATION_ERROR (exit 5), no network', async () => { + const { credentialsPath } = makeCreds(); + const missingPasswordFile = join(mkdtempSync(join(tmpdir(), 'cli-pwfile-')), 'password.txt'); + const fetchImpl = vi.fn(async () => { + throw new Error('should not hit network - validation must fire client-side'); + }); + + await expect( + runCreate( + { + profile: 'default', + output: 'json', + debug: false, + type: 'frontend', + name: 'Password File Guard Project', + targetUrl: 'https://example.com', + passwordFile: missingPasswordFile, + }, + { + credentialsPath, + fetchImpl: fetchImpl as unknown as typeof fetch, + stdout: () => {}, + stderr: () => {}, + }, + ), + ).rejects.toMatchObject({ + exitCode: 5, + code: 'VALIDATION_ERROR', + details: expect.objectContaining({ field: 'password-file' }), + }); + expect(fetchImpl).not.toHaveBeenCalled(); + }); }); // --------------------------------------------------------------------------- @@ -790,6 +823,38 @@ describe('runUpdate', () => { ).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 }); expect(fetchImpl).not.toHaveBeenCalled(); }); + + it('rejects a missing --password-file with VALIDATION_ERROR (exit 5), no network', async () => { + const { credentialsPath } = makeCreds(); + const missingPasswordFile = join(mkdtempSync(join(tmpdir(), 'cli-pwfile-')), 'password.txt'); + const fetchImpl = vi.fn(async () => { + throw new Error('should not be called'); + }); + + await expect( + runUpdate( + { + profile: 'default', + output: 'json', + debug: false, + projectId: 'proj_abc', + passwordFile: missingPasswordFile, + }, + { + credentialsPath, + fetchImpl: fetchImpl as unknown as typeof fetch, + stdout: () => {}, + stderr: () => {}, + }, + ), + ).rejects.toMatchObject({ + code: 'VALIDATION_ERROR', + exitCode: 5, + details: expect.objectContaining({ field: 'password-file' }), + }); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + it('P7 — dry-run returns canned shape without network call', async () => { resetDryRunBannerForTesting(); const { credentialsPath } = makeCreds(); diff --git a/src/commands/project.ts b/src/commands/project.ts index ef4d5e8..d5c3c78 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -202,7 +202,7 @@ export async function runCreate( // Resolve password: flag > file > none let password = opts.password; if (password === undefined && opts.passwordFile !== undefined) { - password = readFileSync(opts.passwordFile, 'utf8').trim(); + password = readPasswordFile(opts.passwordFile); } const idempotencyKey = opts.idempotencyKey ?? `cli-proj-create-${randomUUID()}`; @@ -323,7 +323,7 @@ export async function runUpdate( // filesystem, even when --password-file is present. let password = opts.password; if (password === undefined && opts.passwordFile !== undefined) { - password = readFileSync(opts.passwordFile, 'utf8').trim(); + password = readPasswordFile(opts.passwordFile); } const idempotencyKey = opts.idempotencyKey ?? `cli-proj-update-${randomUUID()}`; @@ -629,3 +629,31 @@ function localValidationError(message: string): ApiError { }, }); } + +function passwordFileValidationError(path: string, reason: string): ApiError { + return ApiError.fromEnvelope({ + error: { + code: 'VALIDATION_ERROR', + message: 'Invalid request.', + nextAction: reason, + requestId: 'local', + details: { field: 'password-file', path, reason }, + }, + }); +} + +function readPasswordFile(path: string): string { + try { + return readFileSync(path, 'utf8').trim(); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === 'ENOENT') { + throw passwordFileValidationError(path, `file does not exist: ${path}`); + } + if (code === 'EACCES') { + throw passwordFileValidationError(path, `permission denied reading ${path}`); + } + const reason = err instanceof Error ? err.message : 'unknown error'; + throw passwordFileValidationError(path, `cannot read ${path}: ${reason}`); + } +}