From 07372ac4908a368bc9fa27bf7ad768d8173369df Mon Sep 17 00:00:00 2001 From: Shubhdeep Chhabra Date: Sat, 17 Oct 2026 18:15:21 +0530 Subject: [PATCH] feat(cli): add testsprite api for authenticated raw requests to the CLI API --- DOCUMENTATION.md | 22 ++ README.md | 1 + src/commands/api.test.ts | 362 ++++++++++++++++++ src/commands/api.ts | 276 +++++++++++++ src/index.ts | 2 + src/lib/secret-file.test.ts | 16 + src/lib/secret-file.ts | 25 +- test/__snapshots__/help.snapshot.test.ts.snap | 48 +++ test/help.snapshot.test.ts | 2 + 9 files changed, 750 insertions(+), 4 deletions(-) create mode 100644 src/commands/api.test.ts create mode 100644 src/commands/api.ts diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index aac4beeb..d54e2789 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -22,6 +22,7 @@ The full reference for the TestSprite CLI: install verification, manual setup, e - [Schedules (`schedule`)](#schedules-schedule) - [CI integration (`ci init`)](#ci-integration-ci-init) - [Account & diagnostics](#account--diagnostics) + - [Raw API requests (`api`)](#raw-api-requests-api) - [Configuration](#configuration) - [Output & scripting](#output--scripting) - [Continuous integration](#continuous-integration) @@ -1157,6 +1158,27 @@ testsprite doctor && testsprite test run test_xxxxxxxx --wait Every check reuses the same helpers the real commands use, so the report reflects exactly what a subsequent command would resolve. For an org-scoped key, the report also lists `Organizations` (account-wide membership list) and `Org binding` (this key's bound organization) checks. +### Raw API requests (`api`) + +#### `testsprite api ` + +Send an authenticated request to any CLI API endpoint and print the JSON response — for an endpoint no command wraps yet, or a field a command does not show. Like `gh api`, it reuses everything the other commands use: the active profile and `--endpoint-url`, the API key, retries, `--debug` tracing (secrets redacted), the typed error envelope and the [exit codes](#exit-codes). + +```bash +testsprite api /me +testsprite api '/tests?projectId=proj_xxxxxxxx' --output json +testsprite api /runs/run_xxxxxxxx +testsprite api /schedules --method POST --input schedule.json +testsprite api /tests/test_xxxxxxxx --method PATCH --data '{"name":"Checkout"}' --dry-run +``` + +- `` — a path under the CLI API; a pasted `/api/cli/v1` prefix and a query string are fine. A full URL is refused (exit 5): the request always goes to the configured API host, so the API key is never sent anywhere else. `.`/`..` segments are refused too. +- `-X, --method ` — `GET` (default), `POST`, `PUT`, `PATCH` or `DELETE`. A request body never turns a `GET` into a write on its own: `--data`/`--input` without a write method exits 5. +- `--data ` / `--input ` — the JSON request body, inline or from a file of up to 5 MB (one or the other; reading from stdin with `--input -` is not supported). +- `--idempotency-key ` — every write sends an `Idempotency-Key`, auto-minted when omitted and printed to stderr under `--output json`, `--verbose` or `--debug`. Re-running with the same key does not apply the write twice, and the CLI can safely retry it after a network error or a `500`; reusing a key with a different body is refused (exit 6). + +The response is printed as JSON in both output modes (a `204` prints nothing). `--dry-run` prints the request that would be sent — method, path, body and idempotency key — without sending it. + ## Configuration ### Profiles & credentials diff --git a/README.md b/README.md index 55b1b4e1..2331a5b0 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,7 @@ See [the full local-testing reference](./DOCUMENTATION.md#local-frontend-testing | **Auth** | `auth status` | Resolve the active profile to its user, key, env, and scopes | | | `auth remove` | Remove the active profile from the credentials file | | | `usage` (alias `credits`) | Account pre-flight: identity, plus credit balance / plan info when the backend supplies them | +| | `api ` | Raw authenticated request to any CLI API endpoint (like `gh api`) — prints the JSON response with the CLI's auth, retries and exit codes | | **Read** | `project list` / `project get` | List projects / fetch one by id, including the default environment and environment count when the server reports them | | | `test list` / `test get` | List tests under a project / fetch one by id; `test list --env ` selects frontend status and the ENV column names its source | | | `test code get` | Print (or write) the generated test source | diff --git a/src/commands/api.test.ts b/src/commands/api.test.ts new file mode 100644 index 00000000..ea688f63 --- /dev/null +++ b/src/commands/api.test.ts @@ -0,0 +1,362 @@ +/** + * Unit tests for `testsprite api `. + */ + +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Command } from 'commander'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { writeProfile } from '../lib/credentials.js'; +import { ApiError } from '../lib/errors.js'; +import type { ApiDeps, ApiOptions } from './api.js'; +import { createApiCommand, normalizeApiPath, parseApiMethod, runApi } from './api.js'; + +interface SentRequest { + url: string; + method: string; + headers: Headers; + body: string | undefined; +} + +let dir: string; +let credentialsPath: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'testsprite-api-')); + credentialsPath = join(dir, 'credentials'); + writeProfile('default', { apiKey: 'sk-user-abc' }, { path: credentialsPath }); +}); + +/** A fetch that answers every request with `responses` in turn and records what was sent. */ +function makeServer(...responses: Array<{ status: number; body?: unknown }>) { + const sent: SentRequest[] = []; + const fetchImpl = vi.fn(async (url: string, init: RequestInit = {}) => { + sent.push({ + url, + method: init.method ?? 'GET', + headers: new Headers(init.headers), + body: typeof init.body === 'string' ? init.body : undefined, + }); + const next = responses[Math.min(sent.length, responses.length) - 1]!; + return new Response(next.body === undefined ? null : JSON.stringify(next.body), { + status: next.status, + headers: { 'content-type': 'application/json' }, + }); + }); + return { sent, fetchImpl: fetchImpl as unknown as ApiDeps['fetchImpl'] }; +} + +function run( + opts: Partial & Pick, + fetchImpl: ApiDeps['fetchImpl'], +) { + const stdout: string[] = []; + const stderr: string[] = []; + const promise = runApi( + { profile: 'default', output: 'text', debug: false, ...opts }, + { + credentialsPath, + env: {}, + fetchImpl, + stdout: line => stdout.push(line), + stderr: line => stderr.push(line), + }, + ); + return { promise, stdout, stderr }; +} + +async function rejection(promise: Promise): Promise { + const err = await promise.catch((e: unknown) => e); + expect(err).toBeInstanceOf(ApiError); + return err as ApiError; +} + +describe('normalizeApiPath', () => { + it('adds a leading slash and keeps the query string', () => { + expect(normalizeApiPath('me')).toBe('/me'); + expect(normalizeApiPath('/tests?projectId=p1&pageSize=5')).toBe( + '/tests?projectId=p1&pageSize=5', + ); + }); + + it('drops a pasted /api/cli/v1 prefix', () => { + expect(normalizeApiPath('/api/cli/v1/tests/t1')).toBe('/tests/t1'); + expect(normalizeApiPath('api/cli/v1/runs/r1')).toBe('/runs/r1'); + expect(normalizeApiPath('/api/cli/v1?x=1')).toBe('/?x=1'); + expect(normalizeApiPath('/api/cli/v10/tests')).toBe('/api/cli/v10/tests'); + }); + + it('removes line breaks from a copy-pasted endpoint', () => { + expect(normalizeApiPath('/tests/\n t1/result')).toBe('/tests/t1/result'); + }); + + it.each([ + ['https://evil.example.com/me'], + ['http://127.0.0.1:8080/me'], + ['//evil.example.com/me'], + ['\\\\evil.example.com\\me'], + ['javascript:alert(1)'], + ])('refuses the full URL %s so the API key never leaves the configured host', raw => { + expect(() => normalizeApiPath(raw)).toThrow(ApiError); + try { + normalizeApiPath(raw); + } catch (err) { + expect((err as ApiError).code).toBe('VALIDATION_ERROR'); + expect((err as ApiError).nextAction).toContain('not a full URL'); + } + }); + + it.each([ + ['/../admin'], + ['/tests/../../admin'], + ['/%2E%2E/admin'], + ['/a/./b'], + ['\\..\\admin'], + // URL parsing removes tabs, so these would otherwise become `..`. + ['/.\t./admin'], + ['/%2e\t%2e/admin'], + ])('refuses %s, which would step outside the CLI API', raw => { + expect(() => normalizeApiPath(raw)).toThrow(/Invalid request/); + }); + + it('allows dots inside a segment and in the query string', () => { + expect(normalizeApiPath('/tests/t.1?name=../x')).toBe('/tests/t.1?name=../x'); + }); + + it('refuses an empty path', () => { + expect(() => normalizeApiPath(' ')).toThrow(ApiError); + }); +}); + +describe('parseApiMethod', () => { + it('defaults to GET and accepts any case', () => { + expect(parseApiMethod(undefined)).toBe('GET'); + expect(parseApiMethod('patch')).toBe('PATCH'); + }); + + it('lists the accepted methods when the value is unknown', () => { + try { + parseApiMethod('TRACE'); + expect.unreachable(); + } catch (err) { + expect((err as ApiError).details).toMatchObject({ + field: 'method', + accepted: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], + }); + } + }); +}); + +describe('runApi', () => { + it('sends an authenticated GET to the configured API host and prints the JSON body', async () => { + const server = makeServer({ status: 200, body: { userId: 'u1' } }); + const { promise, stdout } = run({ path: '/me' }, server.fetchImpl); + + await expect(promise).resolves.toEqual({ userId: 'u1' }); + expect(server.sent).toHaveLength(1); + expect(server.sent[0]!.url).toBe('https://api.testsprite.com/api/cli/v1/me'); + expect(server.sent[0]!.method).toBe('GET'); + expect(server.sent[0]!.headers.get('x-api-key')).toBe('sk-user-abc'); + expect(server.sent[0]!.headers.get('idempotency-key')).toBeNull(); + expect(server.sent[0]!.body).toBeUndefined(); + expect(JSON.parse(stdout.join('\n'))).toEqual({ userId: 'u1' }); + }); + + it('honours --endpoint-url and keeps the query string', async () => { + const server = makeServer({ status: 200, body: { items: [] } }); + const { promise } = run( + { path: 'tests?projectId=p1', endpointUrl: 'https://api.staging.example.com' }, + server.fetchImpl, + ); + + await promise; + expect(server.sent[0]!.url).toBe( + 'https://api.staging.example.com/api/cli/v1/tests?projectId=p1', + ); + }); + + it('sends a write with its JSON body and an auto-minted idempotency key, printed in JSON mode', async () => { + const server = makeServer({ status: 200, body: { id: 't1', name: 'Checkout' } }); + const { promise, stderr } = run( + { path: '/tests/t1', method: 'PATCH', data: '{"name":"Checkout"}', output: 'json' }, + server.fetchImpl, + ); + + await promise; + const sent = server.sent[0]!; + expect(sent.method).toBe('PATCH'); + expect(JSON.parse(sent.body!)).toEqual({ name: 'Checkout' }); + expect(sent.headers.get('content-type')).toBe('application/json'); + const key = sent.headers.get('idempotency-key'); + expect(key).toMatch(/^cli-api-[0-9a-f-]{36}$/); + expect(stderr).toContain(`idempotency-key: ${key}`); + }); + + it('uses --idempotency-key as given and does not print it', async () => { + const server = makeServer({ status: 200, body: {} }); + const { promise, stderr } = run( + { path: '/schedules', method: 'POST', data: '{}', idempotencyKey: 'ci-42', output: 'json' }, + server.fetchImpl, + ); + + await promise; + expect(server.sent[0]!.headers.get('idempotency-key')).toBe('ci-42'); + expect(stderr.some(line => line.startsWith('idempotency-key:'))).toBe(false); + }); + + it('retries a write after a 500 with the same idempotency key', async () => { + const server = makeServer( + { + status: 500, + body: { error: { code: 'INTERNAL', message: 'boom', nextAction: '', requestId: 'r1' } }, + }, + { status: 200, body: { ok: true } }, + ); + const { promise } = run({ path: '/schedules', method: 'POST', data: '{}' }, server.fetchImpl); + + await expect(promise).resolves.toEqual({ ok: true }); + expect(server.sent).toHaveLength(2); + expect(server.sent[1]!.headers.get('idempotency-key')).toBe( + server.sent[0]!.headers.get('idempotency-key'), + ); + }); + + it('reads the body from --input', async () => { + const file = join(dir, 'body.json'); + // eslint-disable-next-line security/detect-non-literal-fs-filename -- fixture-owned temporary directory. + writeFileSync(file, '{\n "cron": "0 * * * *"\n}\n'); + const server = makeServer({ status: 200, body: {} }); + const { promise } = run({ path: '/schedules', method: 'POST', input: file }, server.fetchImpl); + + await promise; + expect(JSON.parse(server.sent[0]!.body!)).toEqual({ cron: '0 * * * *' }); + }); + + it('explains that --input - (stdin) is not supported instead of looking for a file named -', async () => { + const server = makeServer({ status: 200, body: {} }); + const err = await rejection( + run({ path: '/tests', method: 'POST', input: '-' }, server.fetchImpl).promise, + ); + expect(err.details).toMatchObject({ field: 'input' }); + expect(err.nextAction).toContain('cannot read the body from stdin'); + expect(server.sent).toHaveLength(0); + }); + + it('refuses an --input file over 5 MB before sending anything', async () => { + const file = join(dir, 'big.json'); + // eslint-disable-next-line security/detect-non-literal-fs-filename -- fixture-owned temporary directory. + writeFileSync(file, `{"pad":"${'x'.repeat(5 * 1024 * 1024)}"}`); + const server = makeServer({ status: 200, body: {} }); + + const err = await rejection( + run({ path: '/schedules', method: 'POST', input: file }, server.fetchImpl).promise, + ); + expect(err.code).toBe('PAYLOAD_TOO_LARGE'); + expect(err.exitCode).toBe(5); + expect(err.details).toMatchObject({ field: 'input', maxBytes: 5 * 1024 * 1024 }); + expect(server.sent).toHaveLength(0); + }); + + it('prints nothing for a 204', async () => { + const server = makeServer({ status: 204 }); + const { promise, stdout } = run({ path: '/tunnel/c1', method: 'DELETE' }, server.fetchImpl); + + await expect(promise).resolves.toBeUndefined(); + expect(stdout).toEqual([]); + }); + + it('surfaces a server error as its typed envelope and exit code', async () => { + const server = makeServer({ + status: 404, + body: { + error: { code: 'NOT_FOUND', message: 'Test not found', nextAction: '', requestId: 'r9' }, + }, + }); + const { promise, stdout } = run({ path: '/tests/nope' }, server.fetchImpl); + + const err = await rejection(promise); + expect(err.code).toBe('NOT_FOUND'); + expect(err.exitCode).toBe(4); + expect(err.requestId).toBe('r9'); + expect(stdout).toEqual([]); + }); + + it.each([ + [{ path: 'https://evil.example.com/me' }, 'path'], + [{ path: '/tests/t1', data: '{"name":"x"}' }, 'data'], + [{ path: '/me', idempotencyKey: 'k1' }, 'idempotencyKey'], + [{ path: '/tests', method: 'POST', data: '{}', input: 'body.json' }, 'data'], + [{ path: '/tests', method: 'POST', data: '{name:' }, 'data'], + [{ path: '/tests', method: 'POST', input: 'missing.json' }, 'input'], + [{ path: '/me', method: 'OPTIONS' }, 'method'], + ] as const)('refuses %o before sending anything (field %s)', async (opts, field) => { + const server = makeServer({ status: 200, body: {} }); + const err = await rejection(run(opts, server.fetchImpl).promise); + + expect(err.code).toBe('VALIDATION_ERROR'); + expect(err.exitCode).toBe(5); + expect(err.details).toMatchObject({ field }); + expect(server.sent).toHaveLength(0); + }); + + it('--dry-run prints the request it would send and sends nothing', async () => { + const server = makeServer({ status: 200, body: {} }); + const { promise, stdout, stderr } = run( + { path: '/api/cli/v1/schedules', method: 'post', data: '{"cron":"0 * * * *"}', dryRun: true }, + server.fetchImpl, + ); + + await promise; + expect(server.sent).toHaveLength(0); + const printed = JSON.parse(stdout.join('\n')) as { + dryRun: boolean; + request: Record; + }; + expect(printed.dryRun).toBe(true); + expect(printed.request).toMatchObject({ + method: 'POST', + path: '/api/cli/v1/schedules', + body: { cron: '0 * * * *' }, + }); + expect(printed.request.idempotencyKey).toMatch(/^cli-api-/); + expect(stderr.join('\n')).toContain('nothing was sent'); + }); +}); + +describe('createApiCommand wiring', () => { + it('maps the argument and flags, including -X and the global options', async () => { + const stdout: string[] = []; + const program = new Command('testsprite') + .option('--output ', 'Output format (json|text)', 'text') + .option('--dry-run', 'Skip the network', false) + .exitOverride(); + program.addCommand( + createApiCommand({ + credentialsPath, + env: {}, + stdout: line => stdout.push(line), + stderr: () => {}, + }), + ); + + await program.parseAsync( + [ + '--dry-run', + '--output', + 'json', + 'api', + 'tests/t1', + '-X', + 'delete', + '--idempotency-key', + 'k1', + ], + { from: 'user' }, + ); + expect(JSON.parse(stdout.join('\n'))).toEqual({ + dryRun: true, + request: { method: 'DELETE', path: '/api/cli/v1/tests/t1', idempotencyKey: 'k1' }, + }); + }); +}); diff --git a/src/commands/api.ts b/src/commands/api.ts new file mode 100644 index 00000000..7f51d585 --- /dev/null +++ b/src/commands/api.ts @@ -0,0 +1,276 @@ +/** + * `testsprite api ` — make an authenticated request to the TestSprite CLI + * API and print the JSON response, in the spirit of `gh api` / `sentry api`. + * + * It goes through the regular HTTP client, so profiles, `--endpoint-url`, + * retries, typed error envelopes, exit codes and `--debug` tracing (with + * secrets redacted) behave exactly as in every other command. The request + * always goes to the configured API host: a full URL is refused, so the API + * key is never sent anywhere else. + */ +import { randomUUID } from 'node:crypto'; +import { Command } from 'commander'; +import { + makeHttpClient, + parseRequestTimeoutFlag, + type CommonOptions, +} from '../lib/client-factory.js'; +import { resolveProfileName } from '../lib/config.js'; +import { localValidationError } from '../lib/errors.js'; +import type { FetchImpl } from '../lib/http.js'; +import { GLOBAL_OPTS_HINT, Output, resolveOutputMode } from '../lib/output.js'; +import { readSecretFileGuarded } from '../lib/secret-file.js'; +import { assertIdempotencyKey } from '../lib/validate.js'; + +export const API_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const; +export type ApiMethod = (typeof API_METHODS)[number]; + +/** Path prefix of the CLI API; accepted (and dropped) when pasted from the docs. */ +const API_PATH_PREFIX = '/api/cli/v1'; + +/** Cap on an `--input` body file, the same 5 MB as a `test create-batch` body. */ +const MAX_INPUT_BYTES = 5 * 1024 * 1024; + +export interface ApiOptions extends CommonOptions { + /** API path relative to the CLI API, e.g. `/tests?projectId=p1`. */ + path: string; + method?: string; + /** Inline JSON request body. */ + data?: string; + /** File holding the JSON request body. */ + input?: string; + idempotencyKey?: string; +} + +export interface ApiDeps { + env?: NodeJS.ProcessEnv; + credentialsPath?: string; + fetchImpl?: FetchImpl; + stdout?: (line: string) => void; + stderr?: (line: string) => void; +} + +/** What `--dry-run` prints instead of sending the request. */ +export interface ApiDryRunRequest { + method: ApiMethod; + path: string; + body?: unknown; + idempotencyKey?: string; +} + +/** Parse `--method` case-insensitively; GET when omitted. */ +export function parseApiMethod(raw: string | undefined): ApiMethod { + if (raw === undefined) return 'GET'; + const method = API_METHODS.find(candidate => candidate === raw.toUpperCase()); + if (method === undefined) { + throw localValidationError('method', `must be one of: ${API_METHODS.join(', ')}`, [ + ...API_METHODS, + ]); + } + return method; +} + +/** + * Normalize the `` argument to a path under the CLI API: a leading `/` is + * added, a pasted `/api/cli/v1` prefix is dropped, and a query string is kept. + * A full URL is refused (the API key must only go to the configured host), and + * so are `.` / `..` segments, which would step outside the CLI API. + */ +export function normalizeApiPath(raw: string): string { + // URL parsing drops tabs and line breaks anywhere (so `..` would become + // `..`); remove them before checking. Pasted endpoints also carry indentation. + const trimmed = raw + .replace(/[ \t]*[\r\n]+[ \t]*/g, '') + .replace(/\t/g, '') + .trim(); + if (trimmed === '') { + throw localValidationError( + 'path', + 'must be an API path such as /me or /tests?projectId=', + undefined, + 'field', + ); + } + if (/^[a-z][a-z\d+.-]*:/i.test(trimmed) || /^[/\\]{2}/.test(trimmed)) { + throw localValidationError( + 'path', + 'must be a path such as /tests, not a full URL. The CLI sends it to the API host from your ' + + 'profile or --endpoint-url and never sends your API key to another host', + undefined, + 'field', + ); + } + let path = trimmed.startsWith('/') ? trimmed : `/${trimmed}`; + if (path === API_PATH_PREFIX || /^\/api\/cli\/v1[/?#]/.test(path)) { + path = path.slice(API_PATH_PREFIX.length); + if (!path.startsWith('/')) path = `/${path}`; + } + // URL parsing treats `%2e` as `.` and `\` as `/`, so check the decoded form. + const pathname = path.split(/[?#]/, 1)[0] ?? ''; + const escapes = pathname + .split(/[/\\]/) + .map(segment => segment.replace(/%2e/gi, '.')) + .some(segment => segment === '.' || segment === '..'); + if (escapes) { + throw localValidationError( + 'path', + 'must not contain "." or ".." segments (requests stay under the CLI API)', + undefined, + 'field', + ); + } + return path; +} + +/** Read the request body from `--data` or `--input` and parse it as JSON. */ +function readRequestBody(opts: Pick): unknown { + if (opts.data !== undefined && opts.input !== undefined) { + throw localValidationError('data', 'cannot be combined with --input; pass the body one way'); + } + if (opts.input === '-') { + throw localValidationError( + 'input', + 'cannot read the body from stdin; pass a file path, or --data "$(cat body.json)"', + ); + } + const flag = opts.data !== undefined ? 'data' : 'input'; + const raw = + opts.data ?? + (opts.input !== undefined + ? readSecretFileGuarded('input', opts.input, { maxBytes: MAX_INPUT_BYTES }) + : undefined); + if (raw === undefined) return undefined; + try { + return JSON.parse(raw) as unknown; + } catch (err) { + const reason = err instanceof Error ? err.message : 'unparseable'; + throw localValidationError(flag, `must be valid JSON (${reason})`); + } +} + +export async function runApi(opts: ApiOptions, deps: ApiDeps = {}): Promise { + const stderr = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`)); + const method = parseApiMethod(opts.method); + const path = normalizeApiPath(opts.path); + const body = readRequestBody(opts); + const isWrite = method !== 'GET'; + if (!isWrite && body !== undefined) { + // Writes can create resources or start billed runs, so a body never + // silently turns a GET into a POST: the method is always explicit. + throw localValidationError( + opts.data !== undefined ? 'data' : 'input', + 'is a request body, which needs --method POST, PUT, PATCH or DELETE', + ); + } + if (!isWrite && opts.idempotencyKey !== undefined) { + throw localValidationError( + 'idempotencyKey', + 'only applies to POST, PUT, PATCH and DELETE requests', + ); + } + assertIdempotencyKey(opts.idempotencyKey); + + // Every write carries an Idempotency-Key, so the HTTP layer may safely retry + // it after a network error or a 500 and a re-run with the same key is not + // applied twice. An auto-minted key is printed so it can be reused. + const idempotencyKey = isWrite ? (opts.idempotencyKey ?? `cli-api-${randomUUID()}`) : undefined; + const out = new Output(opts.output, { stdout: deps.stdout, stderr: deps.stderr }); + // Built before the dry-run branch so a bad --endpoint-url is still caught + // offline; under --dry-run this also prints the dry-run banner. + const client = makeHttpClient(opts, deps); + + if ( + isWrite && + opts.idempotencyKey === undefined && + (opts.output === 'json' || opts.verbose || opts.debug) + ) { + stderr(`idempotency-key: ${idempotencyKey}`); + } + + if (opts.dryRun) { + // Canned samples exist only for the routes the CLI's own commands call, + // so show the request that would be sent instead of a sample response. + stderr('[note] --dry-run shows the request that would be sent; nothing was sent'); + const request: ApiDryRunRequest = { + method, + path: `${API_PATH_PREFIX}${path}`, + ...(body !== undefined ? { body } : {}), + ...(idempotencyKey !== undefined ? { idempotencyKey } : {}), + }; + out.print({ dryRun: true, request }); + return request; + } + + const result = await client.requestWithMeta(method, path, { + ...(body !== undefined ? { body } : {}), + ...(idempotencyKey !== undefined ? { headers: { 'idempotency-key': idempotencyKey } } : {}), + allowNoContent: true, + }); + // A 204 has no body to print. + if (result.body !== undefined) out.print(result.body); + return result.body; +} + +interface ApiFlagOpts { + method?: string; + data?: string; + input?: string; + idempotencyKey?: string; +} + +export function createApiCommand(deps: ApiDeps = {}): Command { + return new Command('api') + .description( + 'Make an authenticated request to the TestSprite CLI API and print the JSON response', + ) + .argument( + '', + 'API path, e.g. /me or /tests?projectId= (the /api/cli/v1 prefix is optional)', + ) + .option('-X, --method ', 'HTTP method: GET, POST, PUT, PATCH or DELETE', 'GET') + .option('--data ', 'JSON request body (needs --method POST, PUT, PATCH or DELETE)') + .option('--input ', 'read the JSON request body from a file (up to 5 MB)') + .option( + '--idempotency-key ', + 'key for safe retries of a write (1–256 chars). Auto-generated keys print to stderr in JSON, verbose or debug mode.', + ) + .addHelpText('after', GLOBAL_OPTS_HINT) + .addHelpText( + 'after', + '\nThe request goes to the API host from your profile or --endpoint-url, with your API key.\n' + + 'Only paths are accepted, never full URLs, so the key cannot be sent to another host.\n' + + 'It reuses the CLI client: retries, error envelopes and exit codes match every other command.\n' + + '\nExamples:\n' + + ' testsprite api /me\n' + + " testsprite api '/tests?projectId=' --output json\n" + + ' testsprite api /runs/\n' + + ' testsprite api /schedules --method POST --input schedule.json\n' + + ' testsprite api /tests/ --method PATCH --data \'{"name":"Checkout"}\' --dry-run\n' + + '\nExit codes:\n' + + ' 0 success\n' + + ' 3 auth error — run `testsprite setup` to configure credentials\n' + + ' 4 not found\n' + + ' 5 invalid arguments, or the server rejected the request (VALIDATION_ERROR)\n' + + ' 6 conflict, e.g. an idempotency key reused with a different body\n' + + ' 10 transport/network failure (UNAVAILABLE) — retry the command\n' + + ' other codes follow the shared exit-code table (see DOCUMENTATION.md)', + ) + .action(async (path: string, cmdOpts: ApiFlagOpts, command: Command) => { + await runApi({ ...resolveCommonOptions(command, deps.env), ...cmdOpts, path }, deps); + }); +} + +function resolveCommonOptions(command: Command, env?: NodeJS.ProcessEnv): CommonOptions { + const globals = command.optsWithGlobals() as Partial & { + requestTimeout?: string; + }; + return { + profile: resolveProfileName(globals.profile, env), + output: resolveOutputMode(globals.output), + endpointUrl: globals.endpointUrl, + debug: globals.debug ?? false, + verbose: globals.verbose ?? false, + dryRun: globals.dryRun ?? false, + requestTimeoutMs: parseRequestTimeoutFlag(globals.requestTimeout), + }; +} diff --git a/src/index.ts b/src/index.ts index 6e69222a..c7e4956e 100755 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ import { Command, CommanderError } from 'commander'; import { createAgentCommand } from './commands/agent.js'; +import { createApiCommand } from './commands/api.js'; import { createAuthCommand } from './commands/auth.js'; import { createCiCommand } from './commands/ci.js'; import { createCompletionCommand, type CompletionSpec } from './commands/completion.js'; @@ -135,6 +136,7 @@ program.addCommand(createScheduleCommand({})); program.addCommand(createTunnelCommand({})); program.addCommand(createAgentCommand({})); program.addCommand(createUsageCommand()); +program.addCommand(createApiCommand()); program.addCommand(createDoctorCommand()); program.addCommand(createCompletionCommand(() => buildCompletionSpec())); diff --git a/src/lib/secret-file.test.ts b/src/lib/secret-file.test.ts index afa5f050..0600e1c4 100644 --- a/src/lib/secret-file.test.ts +++ b/src/lib/secret-file.test.ts @@ -81,6 +81,22 @@ describe('readSecretFileGuarded', () => { expect(readSecretFileGuarded('password-file', path)).toBe(''); }); + it('refuses a file over maxBytes with PAYLOAD_TOO_LARGE, and accepts one at the cap', () => { + const path = join(tmpRoot, 'body.json'); + // eslint-disable-next-line security/detect-non-literal-fs-filename -- test fixture write into this test's own mkdtempSync-created temp dir (tmpRoot), not user input. + writeFileSync(path, '{"a":1}'); + expect(readSecretFileGuarded('input', path, { maxBytes: 7 })).toBe('{"a":1}'); + try { + readSecretFileGuarded('input', path, { maxBytes: 6 }); + expect.unreachable('should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(ApiError); + expect((err as ApiError).code).toBe('PAYLOAD_TOO_LARGE'); + expect((err as ApiError).exitCode).toBe(5); + expect((err as ApiError).details).toEqual({ field: 'input', sizeBytes: 7, maxBytes: 6 }); + } + }); + describe('missing file', () => { it('throws VALIDATION_ERROR with exit code 5', () => { const path = join(tmpRoot, 'nope.txt'); diff --git a/src/lib/secret-file.ts b/src/lib/secret-file.ts index 7a01fc49..fc49b020 100644 --- a/src/lib/secret-file.ts +++ b/src/lib/secret-file.ts @@ -17,7 +17,8 @@ * * Callers pass the flag name so the envelope names the flag the user actually * typed — one helper serves `--password-file` today and the remaining - * credential/auto-auth file flags once they are migrated. + * credential/auto-auth file flags once they are migrated. `api --input` + * reuses it for request bodies, passing an explicit size cap. * * The check (is this a regular file?) and the use (read its bytes) happen * against a single open file descriptor rather than against the path twice, @@ -26,7 +27,7 @@ */ import { closeSync, constants, fstatSync, openSync, readFileSync, statSync } from 'node:fs'; import { isAbsolute, resolve } from 'node:path'; -import { localValidationError } from './errors.js'; +import { ApiError, localValidationError } from './errors.js'; /** * Read a secret from `path`, surfacing every filesystem failure as a typed @@ -39,10 +40,15 @@ import { localValidationError } from './errors.js'; * * @param flag - Flag name without the leading dashes, e.g. `'password-file'`. * @param path - Path as supplied by the user; may be relative. + * @param options.maxBytes - Refuse a larger file before reading it. * @throws {ApiError} `VALIDATION_ERROR` when the path is missing, unreadable, - * or not a regular file. + * or not a regular file; `PAYLOAD_TOO_LARGE` when it exceeds `maxBytes`. */ -export function readSecretFileGuarded(flag: string, path: string): string { +export function readSecretFileGuarded( + flag: string, + path: string, + options: { maxBytes?: number } = {}, +): string { const absolute = isAbsolute(path) ? path : resolve(process.cwd(), path); let fd: number; @@ -95,6 +101,17 @@ export function readSecretFileGuarded(flag: string, path: string): string { if (!stat.isFile()) { throw localValidationError(flag, `not a regular file: ${path}`); } + if (options.maxBytes !== undefined && stat.size > options.maxBytes) { + throw ApiError.fromEnvelope({ + error: { + code: 'PAYLOAD_TOO_LARGE', + message: `--${flag} file exceeds the ${options.maxBytes}-byte CLI cap (${stat.size} bytes).`, + nextAction: 'Use a smaller file.', + requestId: 'local', + details: { field: flag, sizeBytes: stat.size, maxBytes: options.maxBytes }, + }, + }); + } try { // eslint-disable-next-line security/detect-non-literal-fs-filename -- `fd` is the open file descriptor from openSync() above, not a path string; the rule's non-literal-argument check does not distinguish the two, but there is no path re-resolution here for anything to race against. diff --git a/test/__snapshots__/help.snapshot.test.ts.snap b/test/__snapshots__/help.snapshot.test.ts.snap index 966acfaf..d40b5e30 100644 --- a/test/__snapshots__/help.snapshot.test.ts.snap +++ b/test/__snapshots__/help.snapshot.test.ts.snap @@ -68,6 +68,52 @@ Global options (--dry-run, --output, --profile, --endpoint-url, --request-timeou " `; +exports[`--help snapshots > api 1`] = ` +"Usage: testsprite api [options] + +Make an authenticated request to the TestSprite CLI API and print the JSON +response + +Arguments: + path API path, e.g. /me or /tests?projectId= (the + /api/cli/v1 prefix is optional) + +Options: + -X, --method HTTP method: GET, POST, PUT, PATCH or DELETE + (default: "GET") + --data JSON request body (needs --method POST, PUT, PATCH + or DELETE) + --input read the JSON request body from a file (up to 5 MB) + --idempotency-key key for safe retries of a write (1–256 chars). + Auto-generated keys print to stderr in JSON, verbose + or debug mode. + -h, --help display help for command + +Global options (--dry-run, --output, --profile, --endpoint-url, --request-timeout, --verbose, --debug): + testsprite --help + +The request goes to the API host from your profile or --endpoint-url, with your API key. +Only paths are accepted, never full URLs, so the key cannot be sent to another host. +It reuses the CLI client: retries, error envelopes and exit codes match every other command. + +Examples: + testsprite api /me + testsprite api '/tests?projectId=' --output json + testsprite api /runs/ + testsprite api /schedules --method POST --input schedule.json + testsprite api /tests/ --method PATCH --data '{"name":"Checkout"}' --dry-run + +Exit codes: + 0 success + 3 auth error — run \`testsprite setup\` to configure credentials + 4 not found + 5 invalid arguments, or the server rejected the request (VALIDATION_ERROR) + 6 conflict, e.g. an idempotency key reused with a different body + 10 transport/network failure (UNAVAILABLE) — retry the command + other codes follow the shared exit-code table (see DOCUMENTATION.md) +" +`; + exports[`--help snapshots > auth 1`] = ` "Usage: testsprite auth [options] [command] @@ -1625,6 +1671,8 @@ Commands: Kiro, Windsurf, Copilot, Codex) usage|credits Show credit balance and plan/entitlement info (proactive pre-flight before a large test run) + api [options] Make an authenticated request to the TestSprite + CLI API and print the JSON response doctor Diagnose CLI setup: version, Node, profile, endpoint, credentials, connectivity, skill completion [shell] Print a shell completion script (bash|zsh|fish) diff --git a/test/help.snapshot.test.ts b/test/help.snapshot.test.ts index 8a87524e..a6eb3d70 100644 --- a/test/help.snapshot.test.ts +++ b/test/help.snapshot.test.ts @@ -80,6 +80,8 @@ const cases: Array<[string, string[]]> = [ ['tunnel list', ['tunnel', 'list', '--help']], ['tunnel stop', ['tunnel', 'stop', '--help']], ['tunnel status', ['tunnel', 'status', '--help']], + // Raw API requests: locks the host-only and explicit-write-method wording. + ['api', ['api', '--help']], ]; describe('--help snapshots', () => {