From 800e36231586dd5c85dce942ca3cfcb241956a85 Mon Sep 17 00:00:00 2001 From: jdalton Date: Fri, 24 Jul 2026 08:57:19 -0400 Subject: [PATCH] feat(scan): read cached full-scan results with 202 polling in scan view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backports the cached scan-view behavior from main (e5fb60b11, f6741d2dd) to the v1.x line. socket scan view (default and --json non-stream) now reads pre-computed results from the immutable scan store: fetchScan hits ...full-scans/{id}?cached=true, polls on 202 ({status:'processing'}) with exponential backoff and a 10 minute deadline, then parses the 200 ndjson. The --stream --json live-stream path is unchanged. Uses the direct API via a new queryApiSafeTextWithStatus helper (which surfaces the HTTP status so the 202 poll loop can see it) — no SDK dependency, so no @socketsecurity/sdk release is required. --- src/commands/scan/fetch-scan.mts | 83 ++++++++++++++++-------- src/commands/scan/fetch-scan.test.mts | 93 +++++++++++++++++++++++++++ src/utils/api.mts | 37 +++++++++-- 3 files changed, 182 insertions(+), 31 deletions(-) create mode 100644 src/commands/scan/fetch-scan.test.mts diff --git a/src/commands/scan/fetch-scan.mts b/src/commands/scan/fetch-scan.mts index 35389c28f2..8df41bfd63 100644 --- a/src/commands/scan/fetch-scan.mts +++ b/src/commands/scan/fetch-scan.mts @@ -1,47 +1,78 @@ import { debugDir, debugFn } from '@socketsecurity/registry/lib/debug' -import { queryApiSafeText } from '../../utils/api.mts' +import { queryApiSafeTextWithStatus } from '../../utils/api.mts' import type { CResult } from '../../types.mts' import type { SocketArtifact } from '../../utils/alert/artifact.mts' +// HTTP 202 Accepted: cached results are still being computed; poll again. +const HTTP_STATUS_ACCEPTED = 202 + +export const CACHED_POLL_INITIAL_DELAY_MS = 1000 +export const CACHED_POLL_MAX_DELAY_MS = 10_000 +export const CACHED_POLL_TIMEOUT_MS = 10 * 60 * 1000 + +function sleep(ms: number): Promise { + return new Promise(resolve => { + setTimeout(resolve, ms) + }) +} + export async function fetchScan( orgSlug: string, scanId: string, ): Promise> { - const result = await queryApiSafeText( - `orgs/${orgSlug}/full-scans/${encodeURIComponent(scanId)}`, - 'a scan', - ) - - if (!result.ok) { - return result + // Serve pre-computed results from the immutable store (`?cached=true`): a + // 200 carries the ndjson body, a 202 means the server enqueued a background + // job to compute them — poll with backoff until the results are ready, so + // callers only ever observe the final scan. + const path = `orgs/${orgSlug}/full-scans/${encodeURIComponent(scanId)}?cached=true` + const deadline = Date.now() + CACHED_POLL_TIMEOUT_MS + let delayMs = CACHED_POLL_INITIAL_DELAY_MS + for (;;) { + // eslint-disable-next-line no-await-in-loop + const result = await queryApiSafeTextWithStatus(path, 'a scan') + if (!result.ok) { + return result + } + if (result.data.status !== HTTP_STATUS_ACCEPTED) { + return parseArtifactsNdjson(result.data.text) + } + if (Date.now() >= deadline) { + return { + ok: false, + message: 'Scan results not ready', + cause: `The Socket API is still computing cached results for scan ${scanId} after ${CACHED_POLL_TIMEOUT_MS / 60_000} minutes (path: ${path}). Retry in a few minutes — the server keeps computing in the background.`, + } + } + // eslint-disable-next-line no-await-in-loop + await sleep(delayMs) + delayMs = Math.min(delayMs * 2, CACHED_POLL_MAX_DELAY_MS) } +} - const jsonsString = result.data - - // This is nd-json; each line is a json object +export function parseArtifactsNdjson( + jsonsString: string, +): CResult { + // This is nd-json; each line is a json object. const lines = jsonsString.split('\n').filter(Boolean) - let ok = true - const data = lines.map(line => { + const data: SocketArtifact[] = [] + + for (let i = 0, { length } = lines; i < length; i += 1) { + const line = lines[i]! try { - return JSON.parse(line) + data.push(JSON.parse(line)) } catch (e) { - ok = false debugFn('error', 'Failed to parse scan result line as JSON') debugDir('error', { error: e, line }) - return undefined + return { + ok: false, + message: 'Invalid Socket API response', + cause: + 'The Socket API responded with at least one line that was not valid JSON. Please report if this persists.', + } } - }) as unknown as SocketArtifact[] - - if (ok) { - return { ok: true, data } } - return { - ok: false, - message: 'Invalid Socket API response', - cause: - 'The Socket API responded with at least one line that was not valid JSON. Please report if this persists.', - } + return { ok: true, data } } diff --git a/src/commands/scan/fetch-scan.test.mts b/src/commands/scan/fetch-scan.test.mts new file mode 100644 index 0000000000..7673011ca0 --- /dev/null +++ b/src/commands/scan/fetch-scan.test.mts @@ -0,0 +1,93 @@ +process.env['SOCKET_CLI_API_TOKEN'] = 'test-token' +process.env['SOCKET_CLI_API_BASE_URL'] = 'https://api.socket.dev/v0/' + +import nock from 'nock' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { fetchScan, parseArtifactsNdjson } from './fetch-scan.mts' + +// Drives the real direct-API path through nock (an external HTTP double) rather +// than stubbing owned modules. cached scan reads hit ?cached=true and poll on +// 202 until a 200 arrives. +const BASE_HOST = 'https://api.socket.dev' + +const NDJSON = + '{"type":"npm","name":"lodash","version":"4.17.21"}\n' + + '{"type":"npm","name":"react","version":"18.2.0"}\n' + +describe('fetchScan', () => { + beforeEach(() => { + nock.cleanAll() + nock.disableNetConnect() + }) + + afterEach(() => { + nock.cleanAll() + nock.enableNetConnect() + }) + + it('returns cached artifacts on a 200 cache hit', async () => { + nock(BASE_HOST) + .get('/v0/orgs/test-org/full-scans/scan-1') + .query({ cached: 'true' }) + .reply(200, NDJSON) + + const result = await fetchScan('test-org', 'scan-1') + + expect(result.ok).toBe(true) + expect((result as { data: unknown[] }).data).toEqual([ + { type: 'npm', name: 'lodash', version: '4.17.21' }, + { type: 'npm', name: 'react', version: '18.2.0' }, + ]) + }) + + it('polls on 202 until the cached result is ready', async () => { + nock(BASE_HOST) + .get('/v0/orgs/test-org/full-scans/scan-2') + .query({ cached: 'true' }) + .reply(202, { status: 'processing', id: 'scan-2' }) + nock(BASE_HOST) + .get('/v0/orgs/test-org/full-scans/scan-2') + .query({ cached: 'true' }) + .reply(200, NDJSON) + + const result = await fetchScan('test-org', 'scan-2') + + expect(result.ok).toBe(true) + expect((result as { data: unknown[] }).data).toHaveLength(2) + }) + + it('maps a 404 to a failed CResult', async () => { + nock(BASE_HOST) + .get('/v0/orgs/test-org/full-scans/missing') + .query({ cached: 'true' }) + .reply(404, { error: { message: 'Not found' } }) + + const result = await fetchScan('test-org', 'missing') + + expect(result.ok).toBe(false) + expect(result).toMatchObject({ + message: 'Socket API error', + data: { code: 404 }, + }) + }) +}) + +describe('parseArtifactsNdjson', () => { + it('parses one artifact per line, skipping blanks', () => { + const result = parseArtifactsNdjson(NDJSON) + expect(result).toEqual({ + ok: true, + data: [ + { type: 'npm', name: 'lodash', version: '4.17.21' }, + { type: 'npm', name: 'react', version: '18.2.0' }, + ], + }) + }) + + it('returns an error when a line is not valid JSON', () => { + const result = parseArtifactsNdjson('{"ok":true}\nnot-json\n') + expect(result.ok).toBe(false) + expect(result).toMatchObject({ message: 'Invalid Socket API response' }) + }) +}) diff --git a/src/utils/api.mts b/src/utils/api.mts index 564eed931d..e184e8e94b 100644 --- a/src/utils/api.mts +++ b/src/utils/api.mts @@ -461,14 +461,22 @@ async function queryApi(path: string, apiToken: string) { return result } +export type ApiTextResult = { + status: number + text: string +} + /** - * Query Socket API endpoint and return text response with error handling. + * Query a Socket API endpoint and return the HTTP status alongside the text + * body, with error handling. Unlike queryApiSafeText this surfaces the status + * on success (including 2xx statuses like 202 Accepted), so callers can drive + * status-dependent flows such as the cached-scan 202 poll loop. */ -export async function queryApiSafeText( +export async function queryApiSafeTextWithStatus( path: string, description?: string | undefined, commandPath?: string | undefined, -): Promise> { +): Promise> { const apiToken = getDefaultApiToken() if (!apiToken) { return { @@ -546,10 +554,13 @@ export async function queryApiSafeText( } try { - const data = await result.text() + const text = await result.text() return { ok: true, - data, + data: { + status: result.status, + text, + }, } } catch (e) { debugFn('error', 'Failed to read API response text') @@ -563,6 +574,22 @@ export async function queryApiSafeText( } } +/** + * Query Socket API endpoint and return text response with error handling. + */ +export async function queryApiSafeText( + path: string, + description?: string | undefined, + commandPath?: string | undefined, +): Promise> { + const result = await queryApiSafeTextWithStatus( + path, + description, + commandPath, + ) + return result.ok ? { ok: true, data: result.data.text } : result +} + /** * Query Socket API endpoint and return parsed JSON response. */