Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
236 changes: 235 additions & 1 deletion src/commands/project.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
import { chmodSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
Expand All @@ -16,6 +16,7 @@ import {
runGet,
runList,
runUpdate,
MAX_SECRET_FILE_BYTES,
} from './project.js';

const PROJECT_FIXTURE: CliProject = {
Expand Down Expand Up @@ -1423,3 +1424,236 @@ describe('dogfood 2026-06-30 — whitespace-only --name is rejected (parity with
).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 });
});
});

describe('#282 — secret --*-file flags are guarded (structured error, exit 5, no raw ENOENT)', () => {
const noNetwork = () => {
throw new Error('network should not be hit');
};
const deps = (credentialsPath: string) => ({
credentialsPath,
fetchImpl: makeFetch(noNetwork),
stdout: () => {},
stderr: () => {},
});
const missingPath = () => join(mkdtempSync(join(tmpdir(), 'cli-missing-')), 'no-such-secret.txt');

it('runCredential --credential-file missing → VALIDATION_ERROR (exit 5), no network', async () => {
const { credentialsPath } = makeCreds();
await expect(
runCredential(
{
profile: 'default',
output: 'json',
debug: false,
projectId: 'p1',
authType: 'API key',
credentialFile: missingPath(),
},
deps(credentialsPath),
),
).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 });
});

it('runCredential --credential-file pointing at a directory → VALIDATION_ERROR (exit 5)', async () => {
const { credentialsPath } = makeCreds();
const dir = mkdtempSync(join(tmpdir(), 'cli-cred-dir-'));
await expect(
runCredential(
{
profile: 'default',
output: 'json',
debug: false,
projectId: 'p1',
authType: 'API key',
credentialFile: dir,
},
deps(credentialsPath),
),
).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 });
});

it('runCredential --credential-file over the size cap → PAYLOAD_TOO_LARGE (exit 5)', async () => {
const { credentialsPath } = makeCreds();
const dir = mkdtempSync(join(tmpdir(), 'cli-cred-big-'));
const bigFile = join(dir, 'big.txt');
writeFileSync(bigFile, 'a'.repeat(MAX_SECRET_FILE_BYTES + 1));
await expect(
runCredential(
{
profile: 'default',
output: 'json',
debug: false,
projectId: 'p1',
authType: 'API key',
credentialFile: bigFile,
},
deps(credentialsPath),
),
).rejects.toMatchObject({ code: 'PAYLOAD_TOO_LARGE', exitCode: 5 });
});

it('runCredential reads a valid --credential-file (trimmed) and sends it', async () => {
const { credentialsPath } = makeCreds();
const dir = mkdtempSync(join(tmpdir(), 'cli-cred-ok-'));
const credFile = join(dir, 'cred.txt');
writeFileSync(credFile, ' tok-from-file\n');
let sentBody: { credential?: string } | undefined;
const fetchImpl = makeFetch((_url, init) => {
sentBody = init.body ? JSON.parse(init.body as string) : undefined;
return { status: 200, body: { projectId: 'p1', authType: 'API key', rewroteCount: 1 } };
});
await runCredential(
{
profile: 'default',
output: 'json',
debug: false,
projectId: 'p1',
authType: 'API key',
credentialFile: credFile,
},
{ credentialsPath, fetchImpl, stdout: () => {}, stderr: () => {} },
);
// blindfold: manual — the on-disk fixture written above is " tok-from-file\n"; the guard trims it
expect(sentBody?.credential).toBe('tok-from-file');
});

it('runAutoAuth --password-file missing → VALIDATION_ERROR (exit 5), no network', async () => {
const { credentialsPath } = makeCreds();
await expect(
runAutoAuth(
{
profile: 'default',
output: 'json',
debug: false,
projectId: 'p1',
method: 'password',
inject: 'bearer',
passwordFile: missingPath(),
},
deps(credentialsPath),
),
).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 });
});

it('runAutoAuth --client-secret-file missing → VALIDATION_ERROR (exit 5), no network', async () => {
const { credentialsPath } = makeCreds();
await expect(
runAutoAuth(
{
profile: 'default',
output: 'json',
debug: false,
projectId: 'p1',
method: 'refresh_token',
inject: 'bearer',
clientSecretFile: missingPath(),
},
deps(credentialsPath),
),
).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 });
});

it('runAutoAuth --refresh-token-file missing → VALIDATION_ERROR (exit 5), no network', async () => {
const { credentialsPath } = makeCreds();
await expect(
runAutoAuth(
{
profile: 'default',
output: 'json',
debug: false,
projectId: 'p1',
method: 'refresh_token',
inject: 'bearer',
refreshTokenFile: missingPath(),
},
deps(credentialsPath),
),
).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 });
});

it('runCreate --password-file missing → VALIDATION_ERROR (exit 5), no network', async () => {
const { credentialsPath } = makeCreds();
await expect(
runCreate(
{
profile: 'default',
output: 'json',
debug: false,
type: 'frontend',
name: 'FE',
targetUrl: 'https://example.com',
username: 'u',
passwordFile: missingPath(),
},
deps(credentialsPath),
),
).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 });
});

it('runUpdate --password-file missing → VALIDATION_ERROR (exit 5), no network', async () => {
const { credentialsPath } = makeCreds();
await expect(
runUpdate(
{
profile: 'default',
output: 'json',
debug: false,
projectId: 'p1',
passwordFile: missingPath(),
},
deps(credentialsPath),
),
).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 });
});

it('runAutoAuth --dry-run with missing --password-file skips filesystem (returns sample)', async () => {
const { credentialsPath } = makeCreds();
let fetched = false;
const fetchImpl = makeFetch(() => {
fetched = true;
return { body: {} };
});
const result = await runAutoAuth(
{
profile: 'default',
output: 'json',
debug: false,
dryRun: true,
projectId: 'p1',
method: 'password',
inject: 'bearer',
passwordFile: missingPath(),
},
{ credentialsPath, fetchImpl, stdout: () => {}, stderr: () => {} },
);
expect(fetched).toBe(false);
// blindfold: manual — dry-run skips file reads; returns sample with the projectId we passed in
expect(result.projectId).toBe('p1');
});

it('runCredential --credential-file unreadable after stat → VALIDATION_ERROR (exit 5)', async () => {
if (process.getuid?.() === 0) return; // root bypasses permission checks
const { credentialsPath } = makeCreds();
const dir = mkdtempSync(join(tmpdir(), 'cli-cred-mode-'));
const f = join(dir, 'secret.txt');
writeFileSync(f, 'tok');
chmodSync(f, 0o000);
try {
await expect(
runCredential(
{
profile: 'default',
output: 'json',
debug: false,
projectId: 'p1',
authType: 'API key',
credentialFile: f,
},
deps(credentialsPath),
),
).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 });
} finally {
chmodSync(f, 0o644);
}
});
});
Loading
Loading