diff --git a/codegen/parse-spec.ts b/codegen/parse-spec.ts index dcf96c5..d94b58b 100644 --- a/codegen/parse-spec.ts +++ b/codegen/parse-spec.ts @@ -10,9 +10,9 @@ import { schemaToTypescript, type TypeGenOptions } from './type-utils'; const CLIENT_OPTS: TypeGenOptions = { refPrefix: 'T.' }; -// The automations feature is retired; drop its endpoints and schemas from the -// generated client even while the API still serves them. -const EXCLUDED_PATH = /\/automations(\/|$)/; +// The automations and scan reports features are retired; drop their endpoints +// and schemas from the generated client even while the API still serves them. +const EXCLUDED_PATH = /\/(automations|scan_reports)(\/|$)/; const EXCLUDED_SCHEMAS = new Set([ 'Automation', 'AutomationExecution', @@ -20,6 +20,7 @@ const EXCLUDED_SCHEMAS = new Set([ 'AutomationActionExecution', 'TriggerAutomationBody', 'CreateAutomationFromTemplateBody', + 'Scan Report', ]); export function parseSpec(spec: OpenAPISpec): ParsedSpec { diff --git a/src/client/scan-reports.ts b/src/client/scan-reports.ts deleted file mode 100644 index c357c37..0000000 --- a/src/client/scan-reports.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { Config } from '../config/schema'; -import { requestJson } from './http'; - -export type ScanReportStatus = 'running' | 'ready' | 'failed'; -export type ScanRiskSeverity = 'low' | 'medium' | 'high'; - -export interface ScanReportRisk { - id?: string; - title: string; - detail: string; - severity: ScanRiskSeverity; - resourceIds: string[]; - resourceTypes: string[]; -} - -export type ScanInvestigationStatus = 'running' | 'done' | 'failed'; - -export interface ScanRiskInvestigation { - riskId: string; - threadId: string; - issueId?: string | null; - status: ScanInvestigationStatus; -} - -export interface ScanReport { - id: string; - workspaceId: string; - kind: 'cloud' | 'integration'; - provider: string; - alias: string | null; - status: ScanReportStatus; - risks: ScanReportRisk[]; - riskInvestigations?: ScanRiskInvestigation[]; - riskCount: number; - highRiskCount: number; - _html_url?: string; -} - -export interface GenerateScanReportBody { - workspaceId: string; - kind: 'cloud' | 'integration'; - provider: string; - id?: string; -} - -export interface GenerateScanReportResult { - id: string | null; - status: 'running' | 'failed'; -} - -// The scan_reports routes are deployed but marked hide:true in the OpenAPI -// spec, so the generated client never includes them — call them with literal -// paths until nominal exposes them. -export async function generateScanReport( - config: Config, - body: GenerateScanReportBody -): Promise { - return requestJson(config, { - method: 'POST', - url: '/v1/scan_reports', - body, - }); -} - -export async function getScanReport( - config: Config, - workspaceId: string, - id: string -): Promise { - return requestJson(config, { - method: 'GET', - url: `/v1/scan_reports/${encodeURIComponent(workspaceId)}/${encodeURIComponent(id)}`, - }); -} - -export interface InvestigateScanRisksBody { - workspaceId: string; - scanReportId: string; - riskIds?: string[]; -} - -export interface InvestigateScanRisksResult { - investigations: ScanRiskInvestigation[]; -} - -// Starts a background investigation per risk: each risk gets its own issue -// (origin "scan") and investigation thread, and the response carries the -// report's full investigation list including previously started ones. -export async function investigateScanRisks( - config: Config, - body: InvestigateScanRisksBody -): Promise { - return requestJson(config, { - method: 'POST', - url: '/v1/scan_reports/investigate', - body, - }); -} diff --git a/src/commands/index.ts b/src/commands/index.ts index 3aa47cb..2bd66ab 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -2,7 +2,6 @@ import { registry } from '../registry'; import { authCommands } from './auth'; import { configCommands } from './config'; import { helpCommand } from './help'; -import { scanCommand } from './scan'; import { setupCommand } from './setup'; import { mapCommand } from './map'; import { updateCommand } from './update'; @@ -49,7 +48,6 @@ export function registerAllCommands(): void { ...apiCommands, ...telemetryCommands, helpCommand, - scanCommand, setupCommand, mapCommand, updateCommand, diff --git a/src/commands/scan.ts b/src/commands/scan.ts deleted file mode 100644 index 087eaf5..0000000 --- a/src/commands/scan.ts +++ /dev/null @@ -1,501 +0,0 @@ -import type { Command } from '../command'; -import type { Config } from '../config/schema'; -import type { GlobalFlags } from '../types/flags'; -import { PolylaneAPI } from '../generated/client'; -import { - generateScanReport, - getScanReport, - investigateScanRisks, - type ScanReport, - type ScanRiskInvestigation, - type ScanRiskSeverity, -} from '../client/scan-reports'; -import { authLoginCommand } from './auth/login'; -import { tryResolveCredential } from '../auth/resolver'; -import { consoleBaseUrl } from '../auth/oauth'; -import { loadConfig } from '../config/loader'; -import { CLIError } from '../errors/base'; -import { ExitCode } from '../errors/codes'; -import { Spinner } from '../output/progress'; -import { outputJson } from '../output/json'; -import { showStatusBar } from '../output/status-bar'; -import { isInteractive, shouldUseColor } from '../utils/env'; -import { openBrowser } from '../utils/browser'; -import { - BACK, - note, - promptConfirmOrBack, - promptSelect, - promptSelectOrBack, - type PromptContext, -} from '../utils/prompt'; - -const POLL_INTERVAL_MS = 3_000; -const SCAN_TIMEOUT_MS = 180_000; -const MAX_RISK_LINES = 10; - -export interface ScanTarget { - kind: 'cloud' | 'integration'; - provider: string; - id: string; - label: string; -} - -export interface ScanRunResult { - target: ScanTarget; - status: 'ready' | 'failed' | 'timeout'; - report: ScanReport | null; - error?: string; -} - -export interface RunScanOps { - generate: (target: ScanTarget) => Promise<{ id: string | null; status: string }>; - get: (reportId: string) => Promise; - sleep?: (ms: number) => Promise; - now?: () => number; - intervalMs?: number; - timeoutMs?: number; - onSettled?: (result: ScanRunResult) => void; -} - -async function runScan(target: ScanTarget, ops: Required>, deadline: number): Promise { - let started: { id: string | null; status: string }; - try { - started = await ops.generate(target); - } catch (err) { - return { - target, - status: 'failed', - report: null, - error: err instanceof Error ? err.message : String(err), - }; - } - if (!started.id) { - return { target, status: 'failed', report: null, error: 'no matching connection' }; - } - let report: ScanReport | null = null; - while (ops.now() < deadline) { - await ops.sleep(Math.min(ops.intervalMs, deadline - ops.now())); - try { - report = await ops.get(started.id); - } catch { - continue; - } - if (report.status === 'ready') return { target, status: 'ready', report }; - if (report.status === 'failed') return { target, status: 'failed', report }; - } - return { target, status: 'timeout', report }; -} - -export async function runScans(targets: ScanTarget[], ops: RunScanOps): Promise { - const sleep = ops.sleep ?? ((ms: number): Promise => new Promise((r) => setTimeout(r, ms))); - const now = ops.now ?? Date.now; - const intervalMs = ops.intervalMs ?? POLL_INTERVAL_MS; - const timeoutMs = ops.timeoutMs ?? SCAN_TIMEOUT_MS; - const deadline = now() + timeoutMs; - return Promise.all( - targets.map(async (target) => { - const result = await runScan(target, { generate: ops.generate, get: ops.get, sleep, now, intervalMs }, deadline); - ops.onSettled?.(result); - return result; - }) - ); -} - -export interface RankedRisk { - severity: ScanRiskSeverity; - title: string; - source: string; - id?: string; - reportId: string; - reportHtmlUrl?: string; -} - -const SEVERITY_RANK: Record = { high: 0, medium: 1, low: 2 }; - -export function rankRisks(reports: ScanReport[]): RankedRisk[] { - const ranked: RankedRisk[] = []; - for (const report of reports) { - for (const risk of report.risks) { - ranked.push({ - severity: risk.severity, - title: risk.title, - source: report.alias || report.provider, - ...(risk.id ? { id: risk.id } : {}), - reportId: report.id, - ...(report._html_url ? { reportHtmlUrl: report._html_url } : {}), - }); - } - } - return ranked.sort((a, b) => (SEVERITY_RANK[a.severity] ?? 3) - (SEVERITY_RANK[b.severity] ?? 3)); -} - -function color(s: string, code: string, useColor: boolean): string { - if (!useColor) return s; - return `\x1B[${code}m${s}\x1B[0m`; -} - -const SEVERITY_COLOR: Record = { high: '1;31', medium: '33', low: '36' }; - -export function renderRiskLines( - ranked: RankedRisk[], - useColor: boolean, - limit = MAX_RISK_LINES -): string[] { - if (ranked.length === 0) { - return ['No key risks found.']; - } - const lines = [color(`Key risks (${ranked.length})`, '1', useColor)]; - for (const risk of ranked.slice(0, limit)) { - const tag = color(risk.severity.toUpperCase().padEnd(6), SEVERITY_COLOR[risk.severity] ?? '0', useColor); - lines.push(` ${tag} ${risk.title}${color(` · ${risk.source}`, '2', useColor)}`); - } - if (ranked.length > limit) { - lines.push(color(` +${ranked.length - limit} more in the console`, '2', useColor)); - } - return lines; -} - -export function scanProgressLabel( - counts: { cloud: number; integration: number }, - done: number, - total: number -): string { - const parts: string[] = []; - if (counts.cloud > 0) { - parts.push(`${counts.cloud} cloud account${counts.cloud === 1 ? '' : 's'}`); - } - if (counts.integration > 0) { - parts.push(`${counts.integration} integration${counts.integration === 1 ? '' : 's'}`); - } - const suffix = done > 0 ? ` (${done}/${total} complete)` : ''; - return `Finding issues in ${parts.join(' and ')}…${suffix}`; -} - -export function scansIndexUrl(reportHtmlUrl: string): string { - return reportHtmlUrl.replace(/\/[^/]+$/, ''); -} - -// Scan console URLs look like https://console…/{slug}/scans[/{id}]; the issue -// page lives at https://console…/{slug}/issues/{issueId} on the same slug. -export function issueConsoleUrl( - scanConsoleUrl: string | null | undefined, - issueId: string -): string | null { - if (!scanConsoleUrl) return null; - const match = scanConsoleUrl.match(/^(.*)\/scans(\/[^/]*)?$/); - if (!match) return null; - return `${match[1]}/issues/${encodeURIComponent(issueId)}`; -} - -export function riskKey(risk: Pick): string { - return `${risk.reportId}:${risk.id ?? ''}`; -} - -// Risks that already have an investigation (from an earlier run or another -// user) seed the navigator's marker state; the value is the issue id when the -// API recorded one. -export function seedInvestigations(reports: ScanReport[]): Map { - const seeded = new Map(); - for (const report of reports) { - for (const investigation of report.riskInvestigations ?? []) { - seeded.set(riskKey({ reportId: report.id, id: investigation.riskId }), investigation.issueId ?? null); - } - } - return seeded; -} - -export interface RiskNavigatorOption { - value: string; - label: string; - hint?: string; -} - -export function buildRiskNavigatorOptions( - ranked: RankedRisk[], - investigated: ReadonlySet, - useColor: boolean -): RiskNavigatorOption[] { - return ranked - .filter((risk): risk is RankedRisk & { id: string } => Boolean(risk.id)) - .map((risk) => { - const key = riskKey(risk); - const done = investigated.has(key); - const tag = color(risk.severity.toUpperCase().padEnd(6), SEVERITY_COLOR[risk.severity] ?? '0', useColor); - return { - value: key, - label: `${done ? '✔ ' : ' '}${tag} ${risk.title}`, - hint: done ? 'issue created · investigating' : risk.source, - }; - }); -} - -function resolveConsoleUrl(results: ScanRunResult[]): string | null { - const withUrl = results.filter((r) => r.report?._html_url); - if (withUrl.length === 0) return null; - const ready = withUrl.filter((r) => r.status === 'ready'); - if (ready.length === 1 && withUrl.length === 1) return ready[0]!.report!._html_url!; - return scansIndexUrl(withUrl[0]!.report!._html_url!); -} - -async function ensureSignedIn(config: Config, flags: GlobalFlags): Promise { - const credential = await tryResolveCredential(config); - if (credential) return config; - if (!isInteractive(config.nonInteractive) || config.output === 'json') { - throw new CLIError( - 'Not signed in.', - ExitCode.AUTH, - 'polylane auth login --api-key sk_xxxxx (API key)\n' + - ' polylane auth login (OAuth browser flow)\n' + - ' POLYLANE_API_KEY=sk_xxxxx (environment variable)' - ); - } - await authLoginCommand.execute(config, flags, { _: [] }); - return loadConfig(flags); -} - -async function resolveWorkspaceId(config: Config, api: PolylaneAPI): Promise { - if (config.workspaceId) return config.workspaceId; - const list = await api.workspacesList({ perPage: 100 }); - if (list.items.length === 1) return list.items[0]!.id; - if (list.items.length > 1 && isInteractive(config.nonInteractive)) { - return promptSelect( - { nonInteractive: config.nonInteractive }, - 'Workspace to check', - list.items.map((ws) => ({ value: ws.id, label: ws.name, hint: ws.id })) - ); - } - throw new CLIError( - 'No workspace set', - ExitCode.USAGE, - 'polylane workspace use (set default)\n' + - ' --workspace (one-shot)\n' + - ' POLYLANE_WORKSPACE_ID= (environment variable)\n' + - 'List workspaces with: polylane workspace list' - ); -} - -async function offerToOpen(ctx: PromptContext, url: string): Promise { - const open = await promptConfirmOrBack(ctx, 'Open the issue in your browser?', false); - if (open === true) openBrowser(url); -} - -async function runRiskNavigator( - cfg: Config, - workspaceId: string, - ranked: RankedRisk[], - seeded: Map, - scanConsoleUrl: string | null -): Promise { - const ctx: PromptContext = { nonInteractive: cfg.nonInteractive }; - const useColor = shouldUseColor(cfg.noColor); - const issueIds = new Map(seeded); - - for (;;) { - const options = buildRiskNavigatorOptions(ranked, new Set(issueIds.keys()), useColor); - if (options.length === 0) return; - const choice = await promptSelectOrBack( - ctx, - 'Investigate a risk (Enter creates an issue; Polylane investigates in the background)', - options, - 'Done' - ); - if (choice === BACK) return; - const risk = ranked.find((r) => r.id && riskKey(r) === choice); - if (!risk?.id) continue; - - if (issueIds.has(choice)) { - const knownIssueId = issueIds.get(choice) ?? null; - const url = knownIssueId - ? issueConsoleUrl(risk.reportHtmlUrl ?? scanConsoleUrl, knownIssueId) - : null; - note( - 'An issue is already open for this risk and Polylane is investigating it.' + - (url ? `\n\nView the issue in the console:\n ${url}` : ''), - 'Already under investigation' - ); - if (url) await offerToOpen(ctx, url); - continue; - } - - const spinner = new Spinner(`Creating an issue for "${risk.title}"…`); - spinner.start(); - let investigation: ScanRiskInvestigation | undefined; - try { - const res = await investigateScanRisks(cfg, { - workspaceId, - scanReportId: risk.reportId, - riskIds: [risk.id], - }); - investigation = res.investigations.find((inv) => inv.riskId === risk.id); - spinner.stop(); - } catch (err) { - spinner.stop(); - const message = err instanceof Error ? err.message : String(err); - note( - `The issue was not created (${message}).\nPick the risk again to retry, or investigate it from the console.`, - 'Nothing changed' - ); - continue; - } - - issueIds.set(choice, investigation?.issueId ?? null); - const url = investigation?.issueId - ? issueConsoleUrl(risk.reportHtmlUrl ?? scanConsoleUrl, investigation.issueId) - : null; - note( - `Issue created for "${risk.title}".\n` + - 'Polylane is investigating this risk in the background and will post what it finds on the issue. You can keep working; nothing else is needed from you.' + - (url ? `\n\nView the issue in the console:\n ${url}` : ''), - 'Investigation started' - ); - if (url) await offerToOpen(ctx, url); - } -} - -export const scanCommand: Command = { - name: 'scan', - description: 'Find key risks in your connected cloud accounts and integrations', - operationId: 'scan_reports.generate', - examples: ['polylane scan', 'polylane scan --workspace ws_xxx', 'polylane scan --output json'], - async execute(config: Config, flags: GlobalFlags, _args: Record): Promise { - const cfg = await ensureSignedIn(config, flags); - const api = new PolylaneAPI(cfg); - const workspaceId = await resolveWorkspaceId(cfg, api); - const useSpinner = !cfg.quiet && cfg.output !== 'json'; - const useColor = shouldUseColor(cfg.noColor); - const say = (line: string): void => { - if (!cfg.quiet && cfg.output !== 'json') process.stderr.write(line + '\n'); - }; - - showStatusBar(cfg); - const spinner = new Spinner("Checking what's connected…"); - if (useSpinner) spinner.start(); - - let targets: ScanTarget[]; - try { - const [cloud, integrations] = await Promise.all([ - api.cloudAccountsList(workspaceId, { perPage: 100 }), - api.integrationsList(workspaceId, { perPage: 100 }), - ]); - targets = [ - ...cloud.items.map( - (a): ScanTarget => ({ - kind: 'cloud', - provider: a.provider, - id: a.id, - label: a.alias || a.account || a.provider, - }) - ), - ...integrations.items - .filter((i) => !i.disabled) - .map( - (i): ScanTarget => ({ - kind: 'integration', - provider: i.type, - id: i.id, - label: i.name || i.type, - }) - ), - ]; - } catch (err) { - spinner.fail(); - throw err; - } - - if (targets.length === 0) { - spinner.stop(); - if (cfg.output === 'json') { - outputJson({ workspaceId, targets: 0, reports: [], risks: [], consoleUrl: null }); - return; - } - say('Nothing to check yet: no cloud accounts or integrations are connected.'); - say('Connect one with `polylane cloud connect` or `polylane integration connect`.'); - return; - } - - const counts = { - cloud: targets.filter((t) => t.kind === 'cloud').length, - integration: targets.filter((t) => t.kind === 'integration').length, - }; - let done = 0; - if (useSpinner) spinner.update(scanProgressLabel(counts, 0, targets.length)); - - const results = await runScans(targets, { - generate: (t) => - generateScanReport(cfg, { workspaceId, kind: t.kind, provider: t.provider, id: t.id }), - get: (reportId) => getScanReport(cfg, workspaceId, reportId), - onSettled: () => { - done++; - if (useSpinner) spinner.update(scanProgressLabel(counts, done, targets.length)); - }, - }); - spinner.stop(); - - const ready = results.filter((r) => r.status === 'ready'); - const failed = results.filter((r) => r.status === 'failed'); - const timedOut = results.filter((r) => r.status === 'timeout'); - const ranked = rankRisks(ready.map((r) => r.report!)); - const consoleUrl = resolveConsoleUrl(results) ?? (await scansFallbackUrl(cfg, api, workspaceId)); - - for (const f of failed) { - say(color(`The ${f.target.label} check didn't finish${f.error ? ` (${f.error})` : ''}.`, '33', useColor)); - } - if (timedOut.length > 0) { - say( - color( - `Still looking for issues in ${timedOut.length} place${timedOut.length === 1 ? '' : 's'}; view progress in the console.`, - '2', - useColor - ) - ); - } - - if (cfg.output === 'json') { - outputJson({ - workspaceId, - targets: targets.length, - reports: results.map((r) => ({ - target: r.target, - status: r.status, - ...(r.error ? { error: r.error } : {}), - report: r.report, - })), - risks: ranked, - consoleUrl, - }); - return; - } - - for (const line of renderRiskLines(ranked, useColor)) { - process.stdout.write(line + '\n'); - } - if (consoleUrl) { - process.stdout.write(`\nInvestigate: ${consoleUrl}\n`); - } - - if (!cfg.quiet && isInteractive(cfg.nonInteractive) && ranked.some((r) => r.id)) { - process.stdout.write('\n'); - await runRiskNavigator( - cfg, - workspaceId, - ranked, - seedInvestigations(ready.map((r) => r.report!)), - consoleUrl - ); - } - }, -}; - -async function scansFallbackUrl( - config: Config, - api: PolylaneAPI, - workspaceId: string -): Promise { - try { - const workspace = await api.workspacesGet(workspaceId); - return `${consoleBaseUrl(config)}/${workspace.slug}/scans`; - } catch { - return null; - } -} diff --git a/src/main.ts b/src/main.ts index 06b716b..dfac7e8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -32,7 +32,6 @@ const NO_AUTH_COMMANDS = new Set([ 'telemetry disable', 'integration catalog', 'help', - 'scan', 'setup', 'map', 'update', diff --git a/src/registry.ts b/src/registry.ts index 8ed1b70..a69d121 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -15,7 +15,6 @@ export interface ResourceGroup { } const RESOURCE_ORDER: Record = { - scan: { name: 'scan', description: 'Find key risks in your connected cloud accounts and integrations', order: 4 }, feed: { name: 'feed', description: 'Workspace activity feed (what just happened)', order: 6 }, issue: { name: 'issue', description: 'Detected issues (anomalies + alerts) and their timelines', order: 8 }, service: { name: 'service', description: 'Cloud infrastructure (nodes, logs, metrics, graph)', order: 20 }, diff --git a/test/resolve.test.ts b/test/resolve.test.ts index d52b014..0171099 100644 --- a/test/resolve.test.ts +++ b/test/resolve.test.ts @@ -58,7 +58,6 @@ describe('command resolution', () => { 'memory', 'note', 'repo', - 'scan', 'service', 'setup', 'skill', diff --git a/test/scan.test.ts b/test/scan.test.ts deleted file mode 100644 index b54e6ad..0000000 --- a/test/scan.test.ts +++ /dev/null @@ -1,383 +0,0 @@ -import { describe, it, after } from 'node:test'; -import assert from 'node:assert/strict'; -import { mkdtempSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import type { ScanTarget } from '../src/commands/scan'; -import type { ScanReport, ScanReportRisk } from '../src/client/scan-reports'; - -// Point HOME at a temp dir before importing any source module, so credential -// resolution never reads the developer's real ~/.polylane/credentials.json -// (an expiring stored token would trigger a refresh fetch inside mocked-fetch -// tests). Same pattern as signup.test.ts. -const tempHome = mkdtempSync(join(tmpdir(), 'polylane-scan-test-')); -process.env.HOME = tempHome; -after(() => rmSync(tempHome, { recursive: true, force: true })); - -const { - buildRiskNavigatorOptions, - issueConsoleUrl, - rankRisks, - renderRiskLines, - riskKey, - runScans, - scanProgressLabel, - scansIndexUrl, - seedInvestigations, -} = await import('../src/commands/scan'); -const { investigateScanRisks } = await import('../src/client/scan-reports'); -const { mockConfig } = await import('./helpers/config'); - -function risk(severity: ScanReportRisk['severity'], title: string): ScanReportRisk { - return { title, detail: '', severity, resourceIds: [], resourceTypes: [] }; -} - -function report(overrides: Partial): ScanReport { - return { - id: 'scan_report_1', - workspaceId: 'ws_1', - kind: 'cloud', - provider: 'aws', - alias: null, - status: 'ready', - risks: [], - riskCount: 0, - highRiskCount: 0, - ...overrides, - }; -} - -function target(overrides: Partial = {}): ScanTarget { - return { kind: 'cloud', provider: 'aws', id: 'acc_1', label: 'prod', ...overrides }; -} - -describe('rankRisks', () => { - it('orders high before medium before low', () => { - const ranked = rankRisks([ - report({ risks: [risk('low', 'l1'), risk('high', 'h1'), risk('medium', 'm1')] }), - report({ risks: [risk('high', 'h2')] }), - ]); - assert.deepEqual( - ranked.map((r) => r.title), - ['h1', 'h2', 'm1', 'l1'] - ); - }); - - it('labels risks with the report alias, falling back to provider', () => { - const ranked = rankRisks([ - report({ alias: 'prod', risks: [risk('high', 'a')] }), - report({ alias: null, provider: 'cloudflare', risks: [risk('low', 'b')] }), - ]); - assert.equal(ranked[0]!.source, 'prod'); - assert.equal(ranked[1]!.source, 'cloudflare'); - }); - - it('carries the risk id, report id, and report console url', () => { - const ranked = rankRisks([ - report({ - id: 'scan_report_9', - _html_url: 'https://console.polylane.com/acme/scans/scan_report_9', - risks: [{ ...risk('high', 'a'), id: 'risk_1' }, risk('low', 'b')], - }), - ]); - assert.equal(ranked[0]!.id, 'risk_1'); - assert.equal(ranked[0]!.reportId, 'scan_report_9'); - assert.equal(ranked[0]!.reportHtmlUrl, 'https://console.polylane.com/acme/scans/scan_report_9'); - assert.equal(ranked[1]!.id, undefined); - assert.equal(ranked[1]!.reportId, 'scan_report_9'); - }); -}); - -describe('issueConsoleUrl', () => { - it('rewrites a report console url to the issue page on the same slug', () => { - assert.equal( - issueConsoleUrl('https://console.polylane.com/acme/scans/scan_report_abc', 'issue_1'), - 'https://console.polylane.com/acme/issues/issue_1' - ); - }); - - it('rewrites a scans index url too', () => { - assert.equal( - issueConsoleUrl('https://console.polylane.com/acme/scans', 'issue_1'), - 'https://console.polylane.com/acme/issues/issue_1' - ); - }); - - it('returns null without a scan console url', () => { - assert.equal(issueConsoleUrl(null, 'issue_1'), null); - assert.equal(issueConsoleUrl(undefined, 'issue_1'), null); - assert.equal(issueConsoleUrl('https://console.polylane.com/acme/issues', 'issue_1'), null); - }); -}); - -describe('seedInvestigations', () => { - it('maps already-investigated risks to their issue ids', () => { - const seeded = seedInvestigations([ - report({ - id: 'scan_report_9', - riskInvestigations: [ - { riskId: 'risk_1', threadId: 'thread_1', issueId: 'issue_1', status: 'running' }, - { riskId: 'risk_2', threadId: 'thread_2', status: 'running' }, - ], - }), - report({ id: 'scan_report_10' }), - ]); - assert.equal(seeded.size, 2); - assert.equal(seeded.get('scan_report_9:risk_1'), 'issue_1'); - assert.equal(seeded.get('scan_report_9:risk_2'), null); - }); -}); - -describe('buildRiskNavigatorOptions', () => { - const ranked = rankRisks([ - report({ - id: 'scan_report_9', - alias: 'prod', - risks: [ - { ...risk('high', 'Public bucket'), id: 'risk_1' }, - { ...risk('low', 'Old key'), id: 'risk_2' }, - risk('medium', 'No id, not selectable'), - ], - }), - ]); - - it('only offers risks that have an id', () => { - const options = buildRiskNavigatorOptions(ranked, new Set(), false); - assert.deepEqual( - options.map((o) => o.value), - ['scan_report_9:risk_1', 'scan_report_9:risk_2'] - ); - }); - - it('shows the source as the hint for uninvestigated risks', () => { - const options = buildRiskNavigatorOptions(ranked, new Set(), false); - assert.equal(options[0]!.label, ' HIGH Public bucket'); - assert.equal(options[0]!.hint, 'prod'); - }); - - it('marks already-investigated risks', () => { - const options = buildRiskNavigatorOptions(ranked, new Set(['scan_report_9:risk_1']), false); - assert.equal(options[0]!.label, '✔ HIGH Public bucket'); - assert.equal(options[0]!.hint, 'issue created · investigating'); - assert.equal(options[1]!.label, ' LOW Old key'); - }); - - it('colors the severity tag when enabled', () => { - const options = buildRiskNavigatorOptions(ranked, new Set(), true); - assert.ok(options[0]!.label.includes('\x1B[1;31mHIGH \x1B[0m')); - }); -}); - -describe('riskKey', () => { - it('is stable across reports', () => { - assert.equal(riskKey({ reportId: 'scan_report_9', id: 'risk_1' }), 'scan_report_9:risk_1'); - assert.equal(riskKey({ reportId: 'scan_report_9' }), 'scan_report_9:'); - }); -}); - -describe('investigateScanRisks', () => { - it('POSTs the risk ids and unwraps the investigations envelope', async () => { - const calls: Array<{ url: string; init: { method?: string; body?: unknown } }> = []; - const origFetch = globalThis.fetch; - globalThis.fetch = ((url: unknown, init?: { method?: string; body?: unknown }) => { - calls.push({ url: String(url), init: init ?? {} }); - return Promise.resolve( - new Response( - JSON.stringify({ - message: null, - success: true, - error: null, - result: { - investigations: [ - { riskId: 'risk_1', threadId: 'thread_1', issueId: 'issue_1', status: 'running' }, - ], - }, - }), - { status: 200, headers: { 'content-type': 'application/json' } } - ) - ); - }) as typeof fetch; - try { - const result = await investigateScanRisks(mockConfig({ apiKey: 'sk_test' }), { - workspaceId: 'ws_1', - scanReportId: 'scan_report_1', - riskIds: ['risk_1'], - }); - assert.equal(calls.length, 1); - assert.equal(calls[0]!.url, 'https://api.example.test/v1/scan_reports/investigate'); - assert.equal(calls[0]!.init.method, 'POST'); - assert.deepEqual(JSON.parse(String(calls[0]!.init.body)), { - workspaceId: 'ws_1', - scanReportId: 'scan_report_1', - riskIds: ['risk_1'], - }); - assert.deepEqual(result.investigations[0], { - riskId: 'risk_1', - threadId: 'thread_1', - issueId: 'issue_1', - status: 'running', - }); - } finally { - globalThis.fetch = origFetch; - } - }); -}); - -describe('renderRiskLines', () => { - it('renders one line per risk with a severity tag', () => { - const lines = renderRiskLines( - rankRisks([report({ alias: 'prod', risks: [risk('high', 'Public bucket'), risk('low', 'Old key')] })]), - false - ); - assert.equal(lines[0], 'Key risks (2)'); - assert.equal(lines[1], ' HIGH Public bucket · prod'); - assert.equal(lines[2], ' LOW Old key · prod'); - assert.equal(lines.length, 3); - }); - - it('caps output and reports the remainder', () => { - const risks = Array.from({ length: 13 }, (_, i) => risk('medium', `r${i}`)); - const lines = renderRiskLines(rankRisks([report({ risks })]), false, 10); - assert.equal(lines.length, 12); - assert.equal(lines[11], ' +3 more in the console'); - }); - - it('renders a single line when there are no risks', () => { - assert.deepEqual(renderRiskLines([], true), ['No key risks found.']); - }); - - it('colors severity tags when enabled', () => { - const lines = renderRiskLines(rankRisks([report({ risks: [risk('high', 'x')] })]), true); - assert.ok(lines[1]!.includes('\x1B[1;31mHIGH \x1B[0m')); - }); -}); - -describe('scanProgressLabel', () => { - it('describes both target kinds and completion', () => { - assert.equal( - scanProgressLabel({ cloud: 3, integration: 2 }, 0, 5), - 'Finding issues in 3 cloud accounts and 2 integrations…' - ); - assert.equal( - scanProgressLabel({ cloud: 3, integration: 2 }, 1, 5), - 'Finding issues in 3 cloud accounts and 2 integrations… (1/5 complete)' - ); - assert.equal(scanProgressLabel({ cloud: 1, integration: 0 }, 0, 1), 'Finding issues in 1 cloud account…'); - }); -}); - -describe('scansIndexUrl', () => { - it('strips the report id from a report console URL', () => { - assert.equal( - scansIndexUrl('https://console.polylane.com/acme/scans/scan_report_abc'), - 'https://console.polylane.com/acme/scans' - ); - }); -}); - -describe('runScans', () => { - function fakeClock(): { now: () => number; sleep: (ms: number) => Promise } { - let t = 0; - return { - now: () => t, - sleep: (ms: number) => { - t += ms; - return Promise.resolve(); - }, - }; - } - - it('polls until each report leaves running', async () => { - const clock = fakeClock(); - const polls: Record = {}; - const results = await runScans([target({ id: 'acc_1' }), target({ id: 'acc_2', label: 'staging' })], { - generate: (t) => Promise.resolve({ id: `scan_${t.id}`, status: 'running' }), - get: (id) => { - polls[id] = (polls[id] ?? 0) + 1; - const done = polls[id]! >= (id === 'scan_acc_1' ? 2 : 4); - return Promise.resolve( - report({ id, status: done ? 'ready' : 'running', risks: done ? [risk('high', id)] : [] }) - ); - }, - ...clock, - intervalMs: 1000, - timeoutMs: 60_000, - }); - assert.deepEqual( - results.map((r) => r.status), - ['ready', 'ready'] - ); - assert.equal(polls['scan_acc_1'], 2); - assert.equal(polls['scan_acc_2'], 4); - }); - - it('marks a scan failed when generate returns no id', async () => { - const clock = fakeClock(); - const results = await runScans([target()], { - generate: () => Promise.resolve({ id: null, status: 'failed' }), - get: () => Promise.reject(new Error('should not poll')), - ...clock, - }); - assert.equal(results[0]!.status, 'failed'); - assert.equal(results[0]!.error, 'no matching connection'); - }); - - it('marks a scan failed when generate throws, without aborting others', async () => { - const clock = fakeClock(); - const results = await runScans([target({ id: 'bad' }), target({ id: 'good' })], { - generate: (t) => - t.id === 'bad' - ? Promise.reject(new Error('boom')) - : Promise.resolve({ id: 'scan_good', status: 'running' }), - get: () => Promise.resolve(report({ status: 'ready' })), - ...clock, - intervalMs: 1000, - }); - assert.equal(results[0]!.status, 'failed'); - assert.equal(results[0]!.error, 'boom'); - assert.equal(results[1]!.status, 'ready'); - }); - - it('times out gracefully when a scan never finishes', async () => { - const clock = fakeClock(); - const results = await runScans([target()], { - generate: () => Promise.resolve({ id: 'scan_1', status: 'running' }), - get: () => Promise.resolve(report({ status: 'running' })), - ...clock, - intervalMs: 1000, - timeoutMs: 5000, - }); - assert.equal(results[0]!.status, 'timeout'); - assert.ok(results[0]!.report); - }); - - it('keeps polling through transient get errors', async () => { - const clock = fakeClock(); - let calls = 0; - const results = await runScans([target()], { - generate: () => Promise.resolve({ id: 'scan_1', status: 'running' }), - get: () => { - calls++; - if (calls < 3) return Promise.reject(new Error('transient')); - return Promise.resolve(report({ status: 'ready' })); - }, - ...clock, - intervalMs: 1000, - timeoutMs: 60_000, - }); - assert.equal(results[0]!.status, 'ready'); - assert.equal(calls, 3); - }); - - it('reports completion via onSettled', async () => { - const clock = fakeClock(); - let settled = 0; - await runScans([target({ id: 'a' }), target({ id: 'b' })], { - generate: () => Promise.resolve({ id: null, status: 'failed' }), - get: () => Promise.reject(new Error('unused')), - onSettled: () => settled++, - ...clock, - }); - assert.equal(settled, 2); - }); -});