diff --git a/src/commands/test.artifact.spec.ts b/src/commands/test.artifact.spec.ts index 07cd7a5..1200e5b 100644 --- a/src/commands/test.artifact.spec.ts +++ b/src/commands/test.artifact.spec.ts @@ -20,6 +20,7 @@ import type { CliFailureContext, CliLatestResult, CliTestStep } from './test.js' import { assertOutDirParentExists, createTestArtifactCommand, + resolveDefaultArtifactDir, runArtifactGet, runFailureGet, } from './test.js'; @@ -319,6 +320,31 @@ describe('runArtifactGet', () => { } }); + it('rejects path-like runId before auth or fetch when default --out is used', async () => { + const fetchImpl = vi.fn(); + + await expect( + runArtifactGet( + { + profile: 'default', + output: 'json', + debug: false, + runId: '../../outside', + failedOnly: false, + }, + { fetchImpl, stdout: () => {} }, + ), + ).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 }); + + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('keeps the documented default directory for path-safe runIds', () => { + expect(resolveDefaultArtifactDir(SAMPLE_RUN_ID, '/repo')).toBe( + join('/repo', '.testsprite', 'runs', SAMPLE_RUN_ID), + ); + }); + // ---- --failed-only passed through to writeBundle ---- it('passes --failed-only through to writeBundle (steps filtered to failed ± 1)', async () => { diff --git a/src/commands/test.ts b/src/commands/test.ts index a86b929..6712d44 100644 --- a/src/commands/test.ts +++ b/src/commands/test.ts @@ -6703,6 +6703,20 @@ export interface ArtifactGetResult { bundle?: WriteBundleResult; } +export function resolveDefaultArtifactDir(runId: string, cwd: string = process.cwd()): string { + requireNonEmpty('run-id', runId); + if (runId === '.' || runId === '..' || runId.includes('/') || runId.includes('\\')) { + throw localValidationError( + 'run-id', + 'must be a single path-safe segment for the default output directory; pass --out to choose a custom path', + ); + } + if (runId.includes('\0')) { + throw localValidationError('run-id', 'must not contain NUL bytes'); + } + return join(cwd, '.testsprite', 'runs', runId); +} + /** * Validate that the parent directory of `resolvedDir` exists and is a * directory. Surfaces `VALIDATION_ERROR` (exit 5) — matches the convention @@ -6752,14 +6766,13 @@ export async function runArtifactGet( deps: TestDeps = {}, ): Promise { const out = makeOutput(opts.output, deps); - const client = makeClient(opts, deps); const { runId } = opts; // Resolve output dir: explicit --out or the default .testsprite/runs// const resolvedDir = opts.out !== undefined ? resolveBundleDir(opts.out) - : join(process.cwd(), '.testsprite', 'runs', runId); + : resolveDefaultArtifactDir(runId); // --dry-run: no network, no disk write. // The client (makeClient) is already wired with createDryRunFetch() when @@ -6805,6 +6818,8 @@ export async function runArtifactGet( await assertOutDirParentExists(resolvedDir); } + const client = makeClient(opts, deps); + // Fetch the run-scoped failure bundle. const { body: context, requestId: fetchRequestId } = await client.getWithMeta( `/runs/${encodeURIComponent(runId)}/failure`,