Skip to content

Commit ded59d9

Browse files
fix(project): reject blank inline passwords
1 parent 15e95de commit ded59d9

2 files changed

Lines changed: 111 additions & 11 deletions

File tree

src/commands/project.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,65 @@ describe('runCreate', () => {
484484
expect(result.type).toBe('backend');
485485
});
486486

487+
it('P6 — rejects a blank inline --password before POSTing', async () => {
488+
const { credentialsPath } = makeCreds();
489+
const fetchImpl = vi.fn(async () => {
490+
throw new Error('should not hit network — validation must fire client-side');
491+
});
492+
493+
await expect(
494+
runCreate(
495+
{
496+
profile: 'default',
497+
output: 'json',
498+
debug: false,
499+
type: 'backend',
500+
name: 'Auth Project',
501+
password: ' ',
502+
},
503+
{
504+
credentialsPath,
505+
fetchImpl: fetchImpl as unknown as typeof fetch,
506+
stdout: () => {},
507+
stderr: () => {},
508+
},
509+
),
510+
).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 });
511+
expect(fetchImpl).not.toHaveBeenCalled();
512+
});
513+
514+
it('P6 — preserves surrounding spaces in a non-empty inline --password', async () => {
515+
const { credentialsPath } = makeCreds();
516+
const sentBodies: unknown[] = [];
517+
const createdProject: CliProject = {
518+
...PROJECT_FIXTURE,
519+
id: 'proj_password_spaces',
520+
type: 'backend',
521+
name: 'Password Spaces',
522+
};
523+
const fetchImpl = (async (_input: Parameters<typeof fetch>[0], init: RequestInit = {}) => {
524+
if (init.body) sentBodies.push(JSON.parse(init.body as string) as unknown);
525+
return new Response(JSON.stringify(createdProject), {
526+
status: 200,
527+
headers: { 'content-type': 'application/json' },
528+
});
529+
}) as typeof fetch;
530+
531+
await runCreate(
532+
{
533+
profile: 'default',
534+
output: 'json',
535+
debug: false,
536+
type: 'backend',
537+
name: 'Password Spaces',
538+
password: ' keep spaces ',
539+
},
540+
{ credentialsPath, fetchImpl, stdout: () => {}, stderr: () => {} },
541+
);
542+
543+
expect((sentBodies[0] as Record<string, unknown>).password).toBe(' keep spaces ');
544+
});
545+
487546
it('P6 — dry-run returns canned shape without hitting the network', async () => {
488547
const { credentialsPath } = makeCreds();
489548
const fetchImpl = vi.fn(async () => {
@@ -641,6 +700,32 @@ describe('runUpdate', () => {
641700
expect(fetchImpl).not.toHaveBeenCalled();
642701
});
643702

703+
it('P7 — rejects an empty inline --password before PATCHing', async () => {
704+
const { credentialsPath } = makeCreds();
705+
const fetchImpl = vi.fn(async () => {
706+
throw new Error('should not hit network — validation must fire client-side');
707+
});
708+
709+
await expect(
710+
runUpdate(
711+
{
712+
profile: 'default',
713+
output: 'json',
714+
debug: false,
715+
projectId: 'proj_abc',
716+
password: '',
717+
},
718+
{
719+
credentialsPath,
720+
fetchImpl: fetchImpl as unknown as typeof fetch,
721+
stdout: () => {},
722+
stderr: () => {},
723+
},
724+
),
725+
).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 });
726+
expect(fetchImpl).not.toHaveBeenCalled();
727+
});
728+
644729
it('P7 — dry-run returns canned shape without network call', async () => {
645730
const { credentialsPath } = makeCreds();
646731
const fetchImpl = vi.fn(async () => {

src/commands/project.ts

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import type { FetchImpl } from '../lib/http.js';
1010
import type { HttpClient } from '../lib/http.js';
1111
import { GLOBAL_OPTS_HINT, Output, type OutputMode } from '../lib/output.js';
1212
import { assertNotLocal } from '../lib/target-url.js';
13-
import { assertIdempotencyKey } from '../lib/validate.js';
13+
import { assertIdempotencyKey, requireString } from '../lib/validate.js';
1414
import {
1515
fetchSinglePage,
1616
paginate,
@@ -128,6 +128,25 @@ interface CreateOptions extends CommonOptions {
128128
idempotencyKey?: string;
129129
}
130130

131+
function validateInlinePassword(password: string | undefined): void {
132+
if (password !== undefined) {
133+
requireString('password', password, { kind: 'flag' });
134+
}
135+
}
136+
137+
function resolvePassword(opts: { password?: string; passwordFile?: string }): string | undefined {
138+
validateInlinePassword(opts.password);
139+
if (opts.password !== undefined) {
140+
return opts.password;
141+
}
142+
if (opts.passwordFile !== undefined) {
143+
const password = readFileSync(opts.passwordFile, 'utf8').trim();
144+
requireString('passwordFile', password, { kind: 'flag' });
145+
return password;
146+
}
147+
return undefined;
148+
}
149+
131150
export async function runCreate(
132151
opts: CreateOptions,
133152
deps: ProjectDeps = {},
@@ -158,6 +177,8 @@ export async function runCreate(
158177
throw localValidationError('--url is required for --type frontend');
159178
}
160179

180+
validateInlinePassword(opts.password);
181+
161182
if (opts.dryRun) {
162183
const idempotencyKey = opts.idempotencyKey ?? `cli-proj-create-${randomUUID()}`;
163184
// P2-6: gate idempotency-key output behind --verbose/--debug/json (matches
@@ -182,11 +203,8 @@ export async function runCreate(
182203
return sample;
183204
}
184205

185-
// Resolve password: flag > file > none
186-
let password = opts.password;
187-
if (password === undefined && opts.passwordFile !== undefined) {
188-
password = readFileSync(opts.passwordFile, 'utf8').trim();
189-
}
206+
// Resolve password: flag > file > none.
207+
const password = resolvePassword(opts);
190208

191209
const idempotencyKey = opts.idempotencyKey ?? `cli-proj-create-${randomUUID()}`;
192210
if (opts.idempotencyKey === undefined && (opts.output === 'json' || opts.verbose || opts.debug)) {
@@ -254,11 +272,8 @@ export async function runUpdate(
254272
throw localValidationError('--description must be at most 2000 characters');
255273
}
256274

257-
// Resolve password
258-
let password = opts.password;
259-
if (password === undefined && opts.passwordFile !== undefined) {
260-
password = readFileSync(opts.passwordFile, 'utf8').trim();
261-
}
275+
// Resolve password: flag > file > none.
276+
const password = resolvePassword(opts);
262277

263278
// P2-7: guard --url against localhost/RFC1918/non-http(s).
264279
if (opts.targetUrl !== undefined) {

0 commit comments

Comments
 (0)