unbound-cli 1.14.0 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,343 @@
1
+ /**
2
+ * The generated-download job flow: start, poll, fetch.
3
+ *
4
+ * A CSV export is prepared by a background worker and served from object
5
+ * storage, so every caller does the same three steps. They live here so
6
+ * `unbound download` and `analytics tool-use --format csv` cannot drift apart.
7
+ */
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+
12
+ const api = require('../api');
13
+ const output = require('../output');
14
+
15
+ const DOWNLOADS_PATH = '/api/v1/downloads';
16
+ const MAX_EXPORT_ROWS = 25000;
17
+
18
+ const POLL_INTERVAL_MS = 2000;
19
+ // The worker's own soft limit is 30 minutes. Waiting slightly past it means a
20
+ // job that dies at that limit surfaces as its own failure rather than as ours.
21
+ const MAX_WAIT_MS = 1_860_000;
22
+ // How long a finished export stays fetchable, matching the server's retention.
23
+ const RETENTION_HOURS = 6;
24
+
25
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
26
+
27
+ /**
28
+ * The download kinds the server offers, for help text and tab-completion.
29
+ *
30
+ * Advisory only — an unlisted kind is still sent through, so a producer added
31
+ * to the backend is usable without waiting for a CLI release. The server is the
32
+ * authority on what exists and answers 400 for anything it does not know.
33
+ */
34
+ const KINDS = {
35
+ 'logs-export': {
36
+ summary: 'Every LLM call: prompt, model, tokens, status, user and task ID.',
37
+ filters: ['model_ids', 'application_ids', 'status_codes', 'application_owner_email',
38
+ 'application_types', 'tool_types'],
39
+ },
40
+ 'logs-tasks-export': {
41
+ summary: 'One row per task (agent session), with its thread ID.',
42
+ filters: ['model_ids', 'tool_types', 'application_owner_email', 'user_ids'],
43
+ },
44
+ 'analytics-export': {
45
+ summary: 'Tool Use rows. Needs dataset=terminal-runs|mcp-actions|unsanctioned|mcp-servers.',
46
+ filters: ['dataset', 'risk_levels', 'command_families', 'server', 'intent_attribution',
47
+ 'policy_ids', 'tool_types', 'user_ids'],
48
+ },
49
+ 'analytics-tasks-export': {
50
+ summary: 'Analytics > Tasks, optionally narrowed to one heatmap day.',
51
+ filters: ['user_ids', 'tool_types', 'date', 'tz'],
52
+ },
53
+ 'users-export': {
54
+ summary: 'Analytics > Users: per-user cost, tokens and activity.',
55
+ filters: ['user_ids', 'sort_field', 'sort_order'],
56
+ },
57
+ };
58
+
59
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
60
+
61
+ function fetchHint(jobId) {
62
+ return `Fetch it within ${RETENTION_HOURS} hours:\n unbound download get ${jobId}`;
63
+ }
64
+
65
+ function isJobId(value) {
66
+ return UUID_RE.test(String(value || '').trim());
67
+ }
68
+
69
+ // The server names the file, but it must not be able to choose where the file
70
+ // lands. Keep the basename only, so "../../.zshrc" cannot escape the cwd.
71
+ function safeFilename(name, fallback) {
72
+ const base = path.basename(String(name || '').trim());
73
+ return base && base !== '.' && base !== '..' ? base : fallback;
74
+ }
75
+
76
+ function formatBytes(bytes) {
77
+ if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
78
+ return `${Math.max(1, Math.round(bytes / 1024))} KB`;
79
+ }
80
+
81
+ // Counting rows happens synchronously inside both calls below, and on a wide
82
+ // window it measured ~4s against production. The 30s default left too little
83
+ // headroom, so an export that was merely slow to admit failed as a timeout.
84
+ const ADMISSION_TIMEOUT_MS = 120000;
85
+
86
+ /** Ask how many rows a selection holds, without starting anything. */
87
+ function checkCount(kind, queryString) {
88
+ return api.get(`${DOWNLOADS_PATH}/${kind}/count/${queryString ? `?${queryString}` : ''}`,
89
+ { timeoutMs: ADMISSION_TIMEOUT_MS });
90
+ }
91
+
92
+ /** Start a job. Resolves the job row; the file does not exist yet. */
93
+ function startJob(kind, queryString) {
94
+ return api.post(`${DOWNLOADS_PATH}/${kind}/${queryString ? `?${queryString}` : ''}`,
95
+ { timeoutMs: ADMISSION_TIMEOUT_MS });
96
+ }
97
+
98
+ /**
99
+ * Start a job at a producer's own endpoint rather than the generic one.
100
+ *
101
+ * The analytics export lives behind its own path but enqueues the same job, and
102
+ * its admission runs the same synchronous count — so it needs the same timeout.
103
+ */
104
+ function startExportAt(path) {
105
+ return api.post(path, { timeoutMs: ADMISSION_TIMEOUT_MS });
106
+ }
107
+
108
+ function getJob(jobId) {
109
+ return api.get(`${DOWNLOADS_PATH}/${jobId}/`);
110
+ }
111
+
112
+ /**
113
+ * Poll a job until it stops being queued or running.
114
+ *
115
+ * The interrupt handler is registered before the spinner's, which exits the
116
+ * process on its own SIGINT — registering after would mean never printing the
117
+ * job id, and an unreachable export is the whole reason `download get` exists.
118
+ */
119
+ async function waitForJob(jobId, title) {
120
+ // A caller with no dataset to name degrades the label to just "export".
121
+ const label = title ? `${title} export` : 'export';
122
+ let spin = null;
123
+ const onInterrupt = () => {
124
+ if (spin) spin.stop();
125
+ output.info(`The ${label} is still being prepared. ${fetchHint(jobId)}`);
126
+ process.exit(130);
127
+ };
128
+ process.on('SIGINT', onInterrupt);
129
+ // Printed before the wait, not only on interrupt: a hard kill (a script's
130
+ // timeout, a closed terminal) never runs the handler, and without the id the
131
+ // finished file is unreachable for its whole retention window.
132
+ output.info(`Export ${jobId} started.`);
133
+ spin = output.spinner(`Preparing ${label}…`);
134
+
135
+ try {
136
+ const deadline = Date.now() + MAX_WAIT_MS;
137
+ for (;;) {
138
+ const job = await getJob(jobId);
139
+
140
+ if (job.status === 'completed') {
141
+ spin.stop();
142
+ return job;
143
+ }
144
+ if (job.status === 'failed') {
145
+ throw new Error(job.error || 'The export failed while it was being prepared.');
146
+ }
147
+ if (job.status === 'expired') {
148
+ throw new Error(`This export has expired. Exports are kept for ${RETENTION_HOURS} hours.`);
149
+ }
150
+ if (Date.now() >= deadline) {
151
+ throw new Error(
152
+ `The ${label} is taking longer than expected and is still running.\n ${fetchHint(jobId)}`
153
+ );
154
+ }
155
+
156
+ const rows = Number(job.row_count) || 0;
157
+ spin.update(rows > 0 ? `Preparing ${label}… ${rows.toLocaleString()} rows` : `Preparing ${label}…`);
158
+ await sleep(POLL_INTERVAL_MS);
159
+ }
160
+ } catch (err) {
161
+ spin.stop();
162
+ throw err;
163
+ } finally {
164
+ process.removeListener('SIGINT', onInterrupt);
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Sign a URL for a finished job and stream it to disk or stdout.
170
+ *
171
+ * The presigned URL is a bearer capability for as long as it lives, so it is
172
+ * never printed, logged, or written anywhere — only piped straight through.
173
+ */
174
+ /**
175
+ * Move the finished download onto its final path.
176
+ *
177
+ * A server-chosen name is linked rather than renamed. rename() replaces
178
+ * silently, and the whole transfer sits between the existence check and this
179
+ * point, so anything created in the meantime would be overwritten. link() fails
180
+ * with EEXIST instead, which is the guarantee this is supposed to make.
181
+ *
182
+ * An explicit -o is the user naming the file, so there rename is correct.
183
+ */
184
+ function placeFile(partial, target, { replace, name }) {
185
+ if (replace) {
186
+ fs.renameSync(partial, target);
187
+ return;
188
+ }
189
+ try {
190
+ fs.linkSync(partial, target);
191
+ } catch (err) {
192
+ if (err.code === 'EEXIST') {
193
+ throw new Error(
194
+ `${name} already exists here. Pass -o <file> to choose where this export goes.`
195
+ );
196
+ }
197
+ throw err;
198
+ }
199
+ fs.unlinkSync(partial);
200
+ }
201
+
202
+ async function downloadJob(job, destinationPath, title) {
203
+ const { url, filename } = await api.get(`${DOWNLOADS_PATH}/${job.id}/url/`);
204
+ const name = safeFilename(filename || job.filename, `unbound-export-${job.id}.csv`);
205
+ const destination = destinationPath || (process.stdout.isTTY ? name : null);
206
+
207
+ // The server picks this name, so it must not land on top of anything: a
208
+ // backend could otherwise drop a file called `.zshrc` into whatever directory
209
+ // the command ran in. Checked up front only to fail before a long download;
210
+ // placeFile is what actually enforces it. An explicit -o is the user's own
211
+ // choice and may replace a file, but only once the download has succeeded.
212
+ if (destination && !destinationPath && fs.existsSync(path.resolve(destination))) {
213
+ throw new Error(
214
+ `${name} already exists here. Pass -o <file> to choose where this export goes.`
215
+ );
216
+ }
217
+
218
+ let target = null;
219
+ let partial = null;
220
+ try {
221
+ const { bytes } = await api.fetchToStream(url, {
222
+ onHeaders: () => {
223
+ if (!destination) return process.stdout;
224
+ target = path.resolve(destination);
225
+ // Written to a sibling temp file and renamed on success. Streaming
226
+ // straight to `target` would truncate an existing file the moment the
227
+ // stream opened, so a transfer that failed halfway would destroy it.
228
+ // 'wx' fails rather than following a symlink or reusing a leftover.
229
+ partial = `${target}.part-${process.pid}`;
230
+ return fs.createWriteStream(partial, { flags: 'wx', mode: 0o600 });
231
+ },
232
+ });
233
+
234
+ if (target) {
235
+ placeFile(partial, target, { replace: Boolean(destinationPath), name });
236
+ partial = null;
237
+ const rows = Number(job.row_count) || 0;
238
+ const what = title ? `${title} row(s)` : 'row(s)';
239
+ output.success(`Exported ${rows.toLocaleString()} ${what} to ${target} (${formatBytes(bytes)})`);
240
+ }
241
+ return target;
242
+ } catch (err) {
243
+ // A half-written export parses as valid CSV and reads as a complete one, so
244
+ // drop it. Only ever the temp file — the user's own file is untouched.
245
+ if (partial) {
246
+ try {
247
+ fs.unlinkSync(partial);
248
+ } catch {
249
+ // Nothing was written, or it is already gone.
250
+ }
251
+ }
252
+ throw err;
253
+ }
254
+ }
255
+
256
+ /** Start, wait, and save in one call. */
257
+ async function runExport(kind, queryString, { output: destination, title } = {}) {
258
+ const job = await startJob(kind, queryString);
259
+ const finished = await waitForJob(job.id, title);
260
+ await downloadJob(finished, destination, title);
261
+ return finished;
262
+ }
263
+
264
+ /** Fetch an already-started job, waiting first if it is still running. */
265
+ async function fetchExisting(jobId, { output: destination, title } = {}) {
266
+ if (!isJobId(jobId)) {
267
+ throw new Error(`"${jobId}" is not a valid export id. It looks like 4f2a1c9e-1b2c-4d5e-8f90-a1b2c3d4e5f6.`);
268
+ }
269
+ const job = await getJob(jobId);
270
+
271
+ if (job.status === 'failed') {
272
+ throw new Error(job.error || 'This export failed while it was being prepared.');
273
+ }
274
+ if (job.status === 'expired') {
275
+ throw new Error(`This export has expired. Exports are kept for ${RETENTION_HOURS} hours.`);
276
+ }
277
+ const ready = job.status === 'completed' ? job : await waitForJob(jobId, title);
278
+ await downloadJob(ready, destination, title);
279
+ return ready;
280
+ }
281
+
282
+ /**
283
+ * Turn a server refusal into something a human or an agent can act on.
284
+ *
285
+ * `byJobId` changes what a 404 means: looking up a job that does not exist, has
286
+ * been swept, or belongs to another organization is indistinguishable by
287
+ * design, and none of those are "the backend is too old".
288
+ */
289
+ function describeError(err, what = 'export', { byJobId = false, listing = false } = {}) {
290
+ if (err instanceof api.ApiError) {
291
+ if (err.statusCode === 404) {
292
+ if (byJobId) {
293
+ return `No export with that ID. It may have expired — exports are kept for ${RETENTION_HOURS} hours.`;
294
+ }
295
+ // Reading a table and preparing a file fail for different reasons, and
296
+ // telling someone their backend lacks background exports when they only
297
+ // asked to list rows sends them after the wrong thing.
298
+ return listing
299
+ ? `This Unbound backend does not serve ${what} yet.`
300
+ : `This Unbound backend does not serve the ${what} yet. ` +
301
+ 'It needs a backend version that includes background CSV exports.';
302
+ }
303
+ if (err.statusCode === 405) {
304
+ return `This Unbound backend is too old for this CLI: it still streams the ${what} ` +
305
+ 'instead of preparing it in the background. Upgrade the backend, or pin the CLI to 1.14.0.';
306
+ }
307
+ if (err.statusCode === 403) {
308
+ return listing
309
+ ? 'Admin role required. Analytics > Tool Use is restricted to organization admins.'
310
+ : 'Admin role required. Exporting data is restricted to organization admins.';
311
+ }
312
+ if (err.statusCode === 422) {
313
+ const rows = Number(err.body?.row_count);
314
+ const limit = Number(err.body?.limit) || MAX_EXPORT_ROWS;
315
+ if (Number.isFinite(rows) && rows > 0) {
316
+ return `This selection holds ${rows.toLocaleString()} rows, over the ${limit.toLocaleString()}-row export limit. ` +
317
+ 'Narrow the date range or add filters.';
318
+ }
319
+ }
320
+ }
321
+ return err.message;
322
+ }
323
+
324
+ module.exports = {
325
+ DOWNLOADS_PATH,
326
+ KINDS,
327
+ MAX_EXPORT_ROWS,
328
+ RETENTION_HOURS,
329
+ checkCount,
330
+ describeError,
331
+ downloadJob,
332
+ fetchExisting,
333
+ fetchHint,
334
+ formatBytes,
335
+ getJob,
336
+ isJobId,
337
+ placeFile,
338
+ runExport,
339
+ safeFilename,
340
+ startExportAt,
341
+ startJob,
342
+ waitForJob,
343
+ };
package/src/output.js CHANGED
@@ -1,3 +1,5 @@
1
+ const { sanitizeForTerminal } = require('./chartRender');
2
+
1
3
  /**
2
4
  * Output formatting utilities for the Unbound CLI.
3
5
  * Provides table, JSON, and key-value output for terminal display.
@@ -49,6 +51,70 @@ function table(rows, columns) {
49
51
  }
50
52
  }
51
53
 
54
+ const MIN_COLUMN_WIDTH = 6;
55
+
56
+ function clip(value, width) {
57
+ // Sanitize before measuring: these cells carry commands and prompts recorded
58
+ // from agents, so an OSC or CSI sequence in one would otherwise reach the
59
+ // terminal and could rewrite the title, the clipboard, or earlier output.
60
+ const text = sanitizeForTerminal(value).replace(/\s+/g, ' ').trim();
61
+ if (text.length <= width) return text;
62
+ if (width <= 1) return text.slice(0, width);
63
+ return `${text.slice(0, width - 1)}…`;
64
+ }
65
+
66
+ /**
67
+ * Render log rows as a table that fits the terminal.
68
+ *
69
+ * Unlike table(), every cell is collapsed to one line and clipped to a share of
70
+ * the available width, so a 4KB prompt or a long command cannot flood the
71
+ * screen. Pass { wide: true } to print values in full.
72
+ *
73
+ * @param {string[]} headers
74
+ * @param {Array<string[]>} rows - cells aligned to headers
75
+ * @param {{wide?: boolean, maxWidth?: number}} [opts]
76
+ */
77
+ function logTable(headers, rows, opts = {}) {
78
+ if (!rows || rows.length === 0) {
79
+ console.log(c.dim('No results found.'));
80
+ return;
81
+ }
82
+
83
+ const natural = headers.map((header, i) =>
84
+ Math.max(String(header).length, ...rows.map((row) => clip(row[i], Number.POSITIVE_INFINITY).length))
85
+ );
86
+
87
+ let widths = natural;
88
+ if (!opts.wide) {
89
+ // process.stdout.columns is undefined when piped, so fall back to the
90
+ // COLUMNS convention before the default.
91
+ const envColumns = Number.parseInt(process.env.COLUMNS, 10);
92
+ const terminalWidth =
93
+ opts.maxWidth || process.stdout.columns || (Number.isInteger(envColumns) && envColumns > 0 ? envColumns : 120);
94
+ const gutters = 2 * (headers.length - 1);
95
+ const budget = Math.max(terminalWidth - gutters, headers.length * MIN_COLUMN_WIDTH);
96
+ const total = natural.reduce((a, b) => a + b, 0);
97
+
98
+ if (total > budget) {
99
+ // Give every column its minimum first, then share what's left in
100
+ // proportion to how much each column actually wants.
101
+ const spare = Math.max(budget - headers.length * MIN_COLUMN_WIDTH, 0);
102
+ const over = natural.map((w) => Math.max(w - MIN_COLUMN_WIDTH, 0));
103
+ const overTotal = over.reduce((a, b) => a + b, 0) || 1;
104
+ widths = natural.map((w, i) =>
105
+ Math.min(w, MIN_COLUMN_WIDTH + Math.floor((over[i] / overTotal) * spare))
106
+ );
107
+ }
108
+ }
109
+
110
+ const line = (cells) =>
111
+ cells.map((cell, i) => clip(cell, widths[i]).padEnd(widths[i])).join(' ').trimEnd();
112
+
113
+ console.log(headers.map((h, i) => c.bold(c.dim(clip(h, widths[i]).padEnd(widths[i])))).join(' ').trimEnd());
114
+ console.log(widths.map((w) => '─'.repeat(w)).join(' '));
115
+ for (const row of rows) console.log(line(row));
116
+ }
117
+
52
118
  function json(data) {
53
119
  console.log(JSON.stringify(data, null, 2));
54
120
  }
@@ -80,13 +146,18 @@ function info(msg) {
80
146
  function spinner(message) {
81
147
  if (!process.stderr.isTTY) {
82
148
  console.error(message);
83
- return { stop: () => {}, succeed: (msg) => success(msg), fail: (msg) => error(msg) };
149
+ // update() stays silent when piped: a progress line reprinted every poll
150
+ // would bury the real output in a log.
151
+ return { stop: () => {}, update: () => {}, succeed: (msg) => success(msg), fail: (msg) => error(msg) };
84
152
  }
85
153
 
86
154
  const frames = ['\u280B', '\u2819', '\u2839', '\u2838', '\u283C', '\u2834', '\u2826', '\u2827', '\u2807', '\u280F'];
87
155
  let i = 0;
156
+ let text = message;
88
157
  const interval = setInterval(() => {
89
- process.stderr.write(`\r${c.cyan(frames[i++ % frames.length])} ${message}`);
158
+ // Clear to end of line first: a shorter message must not leave the tail of
159
+ // a longer one behind.
160
+ process.stderr.write(`\r\x1b[K${c.cyan(frames[i++ % frames.length])} ${text}`);
90
161
  }, 80);
91
162
 
92
163
  function clear() {
@@ -99,6 +170,7 @@ function spinner(message) {
99
170
 
100
171
  return {
101
172
  stop: clear,
173
+ update(msg) { text = msg; },
102
174
  succeed(msg) { clear(); success(msg); },
103
175
  fail(msg) { clear(); error(msg); },
104
176
  };
@@ -349,4 +421,4 @@ function multiSelect(message, options) {
349
421
  });
350
422
  }
351
423
 
352
- module.exports = { table, json, keyValue, success, error, warn, info, spinner, select, multiSelect, colors: c };
424
+ module.exports = { table, logTable, json, keyValue, success, error, warn, info, spinner, select, multiSelect, colors: c };
@@ -0,0 +1,68 @@
1
+ const test = require('node:test');
2
+ const assert = require('node:assert');
3
+
4
+ const { DATASETS, selectTableColumns } = require('../src/commands/analytics');
5
+ const { resolveFormat } = require('../src/lib/analytics-query');
6
+
7
+ const TERMINAL_COLUMNS = [
8
+ 'Timestamp', 'User', 'Email', 'AI Tool', 'Command Family', 'All Command Families',
9
+ 'Command', 'User Prompt', 'Risk Score', 'Risk Reason', 'Initiator', 'Matched Policies',
10
+ ];
11
+
12
+ test('the table view drops the wide columns that would flood a terminal', () => {
13
+ const rows = [TERMINAL_COLUMNS.map((_, i) => `v${i}`)];
14
+ const view = selectTableColumns(DATASETS.terminal, TERMINAL_COLUMNS, rows);
15
+
16
+ assert.deepStrictEqual(view.columns, DATASETS.terminal.tableColumns);
17
+ assert.ok(!view.columns.includes('User Prompt'));
18
+ assert.ok(!view.columns.includes('Risk Reason'));
19
+ assert.strictEqual(view.hidden, TERMINAL_COLUMNS.length - view.columns.length);
20
+ });
21
+
22
+ test('selected cells stay aligned with their headers', () => {
23
+ const rows = [TERMINAL_COLUMNS.map((name) => `value:${name}`)];
24
+ const view = selectTableColumns(DATASETS.terminal, TERMINAL_COLUMNS, rows);
25
+
26
+ view.columns.forEach((name, i) => {
27
+ assert.strictEqual(view.rows[0][i], `value:${name}`);
28
+ });
29
+ });
30
+
31
+ test('table columns keep the server order, not the order they are listed in', () => {
32
+ const view = selectTableColumns(DATASETS.terminal, TERMINAL_COLUMNS, []);
33
+ const serverOrder = TERMINAL_COLUMNS.filter((c) => view.columns.includes(c));
34
+ assert.deepStrictEqual(view.columns, serverOrder);
35
+ });
36
+
37
+ test('an unrecognised column set falls back to showing everything', () => {
38
+ const columns = ['Something', 'Else'];
39
+ const view = selectTableColumns(DATASETS.terminal, columns, [['a', 'b']]);
40
+ assert.deepStrictEqual(view.columns, columns);
41
+ assert.deepStrictEqual(view.rows, [['a', 'b']]);
42
+ });
43
+
44
+ test('every dataset names only columns the export actually returns', () => {
45
+ const known = {
46
+ 'terminal-runs': TERMINAL_COLUMNS,
47
+ 'mcp-actions': ['Timestamp', 'User', 'Email', 'AI Tool', 'MCP Server', 'MCP Tool',
48
+ 'Targets', 'User Prompt', 'Risk Score', 'Risk Reason', 'Initiator', 'Matched Policies'],
49
+ unsanctioned: ['Timestamp', 'User', 'Email', 'AI Tool', 'MCP Server', 'MCP Tool',
50
+ 'Status', 'Sanction Message', 'User Prompt'],
51
+ 'mcp-servers': ['MCP Server', 'Actions', 'Average Risk Score'],
52
+ };
53
+
54
+ for (const dataset of Object.values(DATASETS)) {
55
+ for (const column of dataset.tableColumns) {
56
+ assert.ok(
57
+ known[dataset.key].includes(column),
58
+ `${dataset.key} lists "${column}", which the export does not return`,
59
+ );
60
+ }
61
+ }
62
+ });
63
+
64
+ // Commander used to default --format to "table", which made --json look like a
65
+ // contradiction and broke `--json` on every subcommand.
66
+ test('--json works when --format was never passed', () => {
67
+ assert.strictEqual(resolveFormat({ json: true, format: undefined }), 'json');
68
+ });
@@ -0,0 +1,141 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const http = require('http');
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+
8
+ const api = require('../src/api');
9
+ const { describeError, safeFilename } = require('../src/lib/downloads');
10
+
11
+ // `--format csv` no longer streams from our backend: it starts a job, polls it,
12
+ // then pulls the finished file from a presigned S3 URL. That last hop is the
13
+ // part worth pinning down, because it leaves our own origin — a credential sent
14
+ // there goes to a third party, and a short file there reads as a complete CSV.
15
+
16
+ function startServer(handler) {
17
+ const server = http.createServer(handler);
18
+ return new Promise((resolve) => {
19
+ server.listen(0, '127.0.0.1', () => resolve({ server, port: server.address().port }));
20
+ });
21
+ }
22
+
23
+ function freshTmp() {
24
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'export-test-'));
25
+ return { dir, file: path.join(dir, 'out.csv') };
26
+ }
27
+
28
+ test('fetchToStream sends NO Authorization header (the API key must not reach S3)', async () => {
29
+ let seen = 'header-never-observed';
30
+ const { server, port } = await startServer((req, res) => {
31
+ seen = req.headers.authorization;
32
+ res.writeHead(200, { 'Content-Type': 'text/csv' });
33
+ res.end('a,b\n1,2\n');
34
+ });
35
+ const { dir, file } = freshTmp();
36
+ try {
37
+ await api.fetchToStream(`http://127.0.0.1:${port}/signed`, {
38
+ transport: http,
39
+ onHeaders: () => fs.createWriteStream(file),
40
+ });
41
+ assert.equal(seen, undefined, 'a presigned URL must be fetched with no credentials attached');
42
+ assert.equal(fs.readFileSync(file, 'utf8'), 'a,b\n1,2\n');
43
+ } finally {
44
+ fs.rmSync(dir, { recursive: true, force: true });
45
+ server.close();
46
+ }
47
+ });
48
+
49
+ test('fetchToStream refuses a redirect instead of following it to another host', async () => {
50
+ // A presigned URL points straight at the signing endpoint, so a Location
51
+ // header is either a misconfigured region or a response steering the client
52
+ // at a host nobody vetted. Following it would turn an export into an
53
+ // outbound request to somewhere the operator never chose.
54
+ let hops = 0;
55
+ const { server, port } = await startServer((req, res) => {
56
+ hops += 1;
57
+ res.writeHead(302, { Location: 'http://169.254.169.254/latest/meta-data/' });
58
+ res.end();
59
+ });
60
+ try {
61
+ await assert.rejects(
62
+ () => api.fetchToStream(`http://127.0.0.1:${port}/signed`, {
63
+ transport: http,
64
+ onHeaders: () => process.stdout,
65
+ }),
66
+ /redirected to somewhere unexpected/,
67
+ );
68
+ assert.equal(hops, 1, 'the redirect target must never be requested');
69
+ } finally {
70
+ server.close();
71
+ }
72
+ });
73
+
74
+ test('fetchToStream maps an expired signature (403) to a message that says what to do', async () => {
75
+ const { server, port } = await startServer((req, res) => {
76
+ res.writeHead(403, { 'Content-Type': 'application/xml' });
77
+ res.end('<Error><Code>AccessDenied</Code></Error>');
78
+ });
79
+ try {
80
+ await assert.rejects(
81
+ () => api.fetchToStream(`http://127.0.0.1:${port}/expired`, {
82
+ transport: http,
83
+ onHeaders: () => process.stdout,
84
+ }),
85
+ // S3 answers in XML, which means nothing to a CLI user.
86
+ /link has expired. Run the export again/,
87
+ );
88
+ } finally {
89
+ server.close();
90
+ }
91
+ });
92
+
93
+ test('fetchToStream rejects a non-http(s) URL rather than handing it to a transport', async () => {
94
+ await assert.rejects(
95
+ () => api.fetchToStream('file:///etc/passwd', { onHeaders: () => process.stdout }),
96
+ /Refusing to download from a file: URL/,
97
+ );
98
+ });
99
+
100
+ test('safeFilename keeps the basename so the server cannot pick where the file lands', () => {
101
+ assert.equal(safeFilename('../../.zshrc', 'fallback.csv'), '.zshrc');
102
+ assert.equal(safeFilename('/etc/passwd', 'fallback.csv'), 'passwd');
103
+ assert.equal(safeFilename('acme_logs_2026-07-01_to_2026-07-31.csv', 'f.csv'),
104
+ 'acme_logs_2026-07-01_to_2026-07-31.csv');
105
+ assert.equal(safeFilename('..', 'fallback.csv'), 'fallback.csv');
106
+ assert.equal(safeFilename('', 'fallback.csv'), 'fallback.csv');
107
+ assert.equal(safeFilename(null, 'fallback.csv'), 'fallback.csv');
108
+ });
109
+
110
+ test('an over-limit export (422) reports the real row count and how to narrow it', () => {
111
+ const err = new api.ApiError(422, {
112
+ error: 'Too many rows', row_count: 47651, limit: 25000, exceeds_limit: true,
113
+ });
114
+ const message = describeError(err, 'Terminal Runs export');
115
+ assert.match(message, /47,651 rows/);
116
+ assert.match(message, /25,000-row export limit/);
117
+ // Says what to do without naming a flag: `download` has --last, `analytics`
118
+ // has --time-window, and this one message is shown by both.
119
+ assert.match(message, /Narrow the date range or add filters/);
120
+ });
121
+
122
+ test('a backend still on the streaming export (405) says so instead of "not found"', () => {
123
+ const message = describeError(new api.ApiError(405, {}), 'Terminal Runs export');
124
+ assert.match(message, /too old for this CLI/);
125
+ });
126
+
127
+ test('describeError works with no dataset, for `download get`', () => {
128
+ const message = describeError(new api.ApiError(403, {}));
129
+ assert.match(message, /Admin role required/);
130
+ });
131
+
132
+ // A 404 means different things on the two paths, and saying the wrong one sends
133
+ // the reader off to check their backend version when the job simply expired.
134
+ test('a 404 by job ID says the export is gone, not that the backend is too old', () => {
135
+ const byId = describeError(new api.ApiError(404, {}), 'export', { byJobId: true });
136
+ assert.match(byId, /No export with that ID/);
137
+ assert.doesNotMatch(byId, /backend/);
138
+
139
+ const byKind = describeError(new api.ApiError(404, {}), 'logs-export export');
140
+ assert.match(byKind, /does not serve/);
141
+ });