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,77 @@
1
+ const test = require('node:test');
2
+ const assert = require('node:assert');
3
+
4
+ const output = require('../src/output');
5
+
6
+ function capture(fn) {
7
+ const lines = [];
8
+ const original = console.log;
9
+ console.log = (...args) => lines.push(args.join(' '));
10
+ try {
11
+ fn();
12
+ } finally {
13
+ console.log = original;
14
+ }
15
+ return lines;
16
+ }
17
+
18
+ const HEADERS = ['Timestamp', 'User', 'Command'];
19
+ const LONG = 'rm -rf /very/long/path/that/keeps/going/and/going/and/going/forever/and/ever';
20
+
21
+ test('logTable prints a header, a rule and one line per row', () => {
22
+ const lines = capture(() =>
23
+ output.logTable(HEADERS, [['2026-08-05', 'ada', 'ls'], ['2026-08-04', 'bob', 'pwd']], { maxWidth: 120 }),
24
+ );
25
+ assert.strictEqual(lines.length, 4);
26
+ assert.ok(lines[0].includes('Timestamp'));
27
+ assert.match(lines[1], /^[─ ]+$/);
28
+ assert.ok(lines[2].includes('ada'));
29
+ assert.ok(lines[3].includes('bob'));
30
+ });
31
+
32
+ test('logTable says so instead of printing an empty frame', () => {
33
+ const lines = capture(() => output.logTable(HEADERS, []));
34
+ assert.strictEqual(lines.length, 1);
35
+ assert.match(lines[0], /No results found/);
36
+ });
37
+
38
+ test('logTable never exceeds the terminal width', () => {
39
+ const maxWidth = 60;
40
+ const lines = capture(() =>
41
+ output.logTable(HEADERS, [['2026-08-05T09:55:44Z', 'ada@example.com', LONG]], { maxWidth }),
42
+ );
43
+ for (const line of lines) {
44
+ // biome-ignore lint: strip ANSI colour codes before measuring
45
+ const plain = line.replace(/\x1b\[[0-9;]*m/g, '');
46
+ assert.ok(plain.length <= maxWidth, `line of ${plain.length} exceeded ${maxWidth}: ${plain}`);
47
+ }
48
+ });
49
+
50
+ test('logTable marks a clipped value with an ellipsis', () => {
51
+ const lines = capture(() => output.logTable(HEADERS, [['2026-08-05', 'ada', LONG]], { maxWidth: 60 }));
52
+ assert.ok(lines[2].includes('…'), 'expected the long command to be clipped');
53
+ });
54
+
55
+ test('logTable prints values in full when wide is set', () => {
56
+ const lines = capture(() => output.logTable(HEADERS, [['2026-08-05', 'ada', LONG]], { wide: true }));
57
+ assert.ok(lines[2].includes(LONG), 'wide mode must not clip');
58
+ });
59
+
60
+ test('logTable collapses newlines so one prompt cannot span many lines', () => {
61
+ const lines = capture(() =>
62
+ output.logTable(HEADERS, [['2026-08-05', 'ada', 'line one\nline two\nline three']], { wide: true }),
63
+ );
64
+ assert.strictEqual(lines.length, 3);
65
+ assert.ok(lines[2].includes('line one line two line three'));
66
+ });
67
+
68
+ test('logTable tolerates a row shorter than the header set', () => {
69
+ const lines = capture(() => output.logTable(HEADERS, [['2026-08-05']], { maxWidth: 80 }));
70
+ assert.strictEqual(lines.length, 3);
71
+ assert.ok(lines[2].includes('2026-08-05'));
72
+ });
73
+
74
+ test('logTable leaves the original table() untouched', () => {
75
+ assert.strictEqual(typeof output.table, 'function');
76
+ assert.notStrictEqual(output.table, output.logTable);
77
+ });
@@ -0,0 +1,34 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+
4
+ // The footer is how a person, and an agent reading stderr, learns whether it is
5
+ // looking at everything or at one page. Saying "all" on a later page turns a
6
+ // slice into what looks like the whole result set.
7
+
8
+ function footer({ offset, rows, total, hasMore }) {
9
+ const nextOffset = offset + rows;
10
+ if (hasMore ?? nextOffset < total) {
11
+ return `Showing ${offset + 1}-${offset + rows} of ${total.toLocaleString()}. ` +
12
+ `Page with --offset ${nextOffset}, or use --format csv to export every row.`;
13
+ }
14
+ if (offset > 0) {
15
+ return `Showing ${offset + 1}-${offset + rows} of ${total.toLocaleString()}. End of results.`;
16
+ }
17
+ return `Showing all ${total.toLocaleString()} row(s).`;
18
+ }
19
+
20
+ test('the last page of a paged read does not claim to be all of them', () => {
21
+ const line = footer({ offset: 20, rows: 10, total: 564, hasMore: false });
22
+ assert.match(line, /Showing 21-30 of 564/);
23
+ assert.doesNotMatch(line, /all/, 'a slice must never read as the whole result set');
24
+ });
25
+
26
+ test('a single page that holds everything still says all', () => {
27
+ assert.match(footer({ offset: 0, rows: 7, total: 7, hasMore: false }), /Showing all 7 row/);
28
+ });
29
+
30
+ test('a page with more behind it points at the next offset', () => {
31
+ const line = footer({ offset: 0, rows: 20, total: 564, hasMore: true });
32
+ assert.match(line, /Showing 1-20 of 564/);
33
+ assert.match(line, /--offset 20/);
34
+ });
@@ -0,0 +1,142 @@
1
+ const test = require('node:test');
2
+ const assert = require('node:assert');
3
+
4
+ const {
5
+ DEFAULT_LIMIT,
6
+ buildQuery,
7
+ buildQueryString,
8
+ parseList,
9
+ resolveFormat,
10
+ resolveWindow,
11
+ } = require('../src/lib/analytics-query');
12
+
13
+ test('parseList splits comma-separated values and drops blanks', () => {
14
+ assert.deepStrictEqual(parseList('a, b ,,c'), ['a', 'b', 'c']);
15
+ assert.deepStrictEqual(parseList(''), []);
16
+ assert.deepStrictEqual(parseList(undefined), []);
17
+ assert.deepStrictEqual(parseList(['x', ' y ']), ['x', 'y']);
18
+ });
19
+
20
+ test('resolveFormat defaults to table and honours --json', () => {
21
+ assert.strictEqual(resolveFormat({}), 'table');
22
+ assert.strictEqual(resolveFormat({ format: 'csv' }), 'csv');
23
+ assert.strictEqual(resolveFormat({ json: true }), 'json');
24
+ assert.strictEqual(resolveFormat({ json: true, format: 'json' }), 'json');
25
+ });
26
+
27
+ test('resolveFormat rejects an unknown format and a contradictory --json', () => {
28
+ assert.throws(() => resolveFormat({ format: 'xml' }), /Invalid --format/);
29
+ assert.throws(() => resolveFormat({ json: true, format: 'csv' }), /Cannot combine --json/);
30
+ });
31
+
32
+ test('resolveWindow defaults to the thirty-day preset', () => {
33
+ assert.deepStrictEqual(resolveWindow({}), { time_window: 'THIRTY_DAYS' });
34
+ });
35
+
36
+ test('resolveWindow normalises case and hyphens in a preset', () => {
37
+ assert.deepStrictEqual(resolveWindow({ timeWindow: 'seven-days' }), { time_window: 'SEVEN_DAYS' });
38
+ });
39
+
40
+ test('resolveWindow accepts a valid custom range', () => {
41
+ assert.deepStrictEqual(
42
+ resolveWindow({ start: '2026-07-01', end: '2026-07-31' }),
43
+ { start_date: '2026-07-01', end_date: '2026-07-31' },
44
+ );
45
+ });
46
+
47
+ test('resolveWindow rejects half a range, a mixed range, and a bad date', () => {
48
+ assert.throws(() => resolveWindow({ start: '2026-07-01' }), /must be used together/);
49
+ assert.throws(() => resolveWindow({ end: '2026-07-01' }), /must be used together/);
50
+ assert.throws(
51
+ () => resolveWindow({ start: '2026-07-01', end: '2026-07-31', timeWindow: 'SEVEN_DAYS' }),
52
+ /Cannot combine --time-window/,
53
+ );
54
+ assert.throws(() => resolveWindow({ start: '07/01/2026', end: '07/31/2026' }), /Expected YYYY-MM-DD/);
55
+ });
56
+
57
+ test('resolveWindow rejects an inverted range and one wider than 90 days', () => {
58
+ assert.throws(() => resolveWindow({ start: '2026-07-31', end: '2026-07-01' }), /on or before/);
59
+ assert.throws(() => resolveWindow({ start: '2026-01-01', end: '2026-07-31' }), /maximum is 90/);
60
+ });
61
+
62
+ test('resolveWindow accepts exactly 90 days but not 91', () => {
63
+ assert.ok(resolveWindow({ start: '2026-01-01', end: '2026-04-01' }));
64
+ assert.throws(() => resolveWindow({ start: '2026-01-01', end: '2026-04-02' }), /maximum is 90/);
65
+ });
66
+
67
+ test('buildQuery asks the server for json when rendering a table', () => {
68
+ const query = buildQuery({}, { format: 'table' });
69
+ assert.strictEqual(query.format, 'json');
70
+ assert.strictEqual(query.limit, DEFAULT_LIMIT);
71
+ });
72
+
73
+ test('buildQuery omits limit and offset for a csv export', () => {
74
+ const query = buildQuery({ limit: '50', offset: '10' }, { format: 'csv' });
75
+ assert.strictEqual(query.format, 'csv');
76
+ assert.strictEqual(query.limit, undefined);
77
+ assert.strictEqual(query.offset, undefined);
78
+ });
79
+
80
+ test('buildQuery omits a zero offset but keeps a real one', () => {
81
+ assert.strictEqual(buildQuery({ offset: '0' }, { format: 'json' }).offset, undefined);
82
+ assert.strictEqual(buildQuery({ offset: '40' }, { format: 'json' }).offset, 40);
83
+ });
84
+
85
+ test('buildQuery maps every filter flag onto its query key', () => {
86
+ const query = buildQuery(
87
+ {
88
+ tool: 'claude-code,cursor',
89
+ user: '4,9',
90
+ policy: '11',
91
+ family: 'git_action,delete_file',
92
+ server: 'Notion,Slack',
93
+ risk: 'high,low',
94
+ initiator: 'agent',
95
+ },
96
+ { format: 'csv' },
97
+ );
98
+
99
+ assert.deepStrictEqual(query.tool_types, ['CLAUDE_CODE', 'CURSOR']);
100
+ assert.deepStrictEqual(query.user_ids, ['4', '9']);
101
+ assert.deepStrictEqual(query.policy_ids, ['11']);
102
+ assert.deepStrictEqual(query.command_families, ['git_action', 'delete_file']);
103
+ assert.deepStrictEqual(query.server, ['Notion', 'Slack']);
104
+ assert.deepStrictEqual(query.risk_levels, ['High', 'Low']);
105
+ assert.deepStrictEqual(query.intent_attribution, ['AGENT_INITIATED']);
106
+ });
107
+
108
+ test('buildQuery leaves absent filters out of the query entirely', () => {
109
+ const query = buildQuery({}, { format: 'csv' });
110
+ for (const key of ['tool_types', 'user_ids', 'policy_ids', 'command_families', 'server', 'risk_levels']) {
111
+ assert.ok(!(key in query), `${key} should be absent`);
112
+ }
113
+ });
114
+
115
+ test('buildQuery rejects invalid risk and initiator values', () => {
116
+ assert.throws(() => buildQuery({ risk: 'Critical' }, { format: 'csv' }), /Invalid --risk/);
117
+ assert.throws(() => buildQuery({ initiator: 'robot' }, { format: 'csv' }), /Invalid --initiator/);
118
+ });
119
+
120
+ test('buildQuery rejects limits outside the allowed range', () => {
121
+ assert.throws(() => buildQuery({ limit: '0' }, { format: 'json' }), /positive integer/);
122
+ assert.throws(() => buildQuery({ limit: '1.5' }, { format: 'json' }), /positive integer/);
123
+ assert.throws(() => buildQuery({ limit: '99999' }, { format: 'json' }), /cannot exceed 1000/);
124
+ assert.throws(() => buildQuery({ offset: '-1' }, { format: 'json' }), /zero or a positive/);
125
+ });
126
+
127
+ test('buildQueryString repeats a key per value instead of collapsing it', () => {
128
+ const qs = buildQueryString({ format: 'csv', risk_levels: ['High', 'Low'], server: ['Notion'] });
129
+ const params = new URLSearchParams(qs);
130
+ assert.deepStrictEqual(params.getAll('risk_levels'), ['High', 'Low']);
131
+ assert.deepStrictEqual(params.getAll('server'), ['Notion']);
132
+ });
133
+
134
+ test('buildQueryString skips empty values', () => {
135
+ const qs = buildQueryString({ a: 'x', b: undefined, c: null, d: '', e: [] });
136
+ assert.strictEqual(qs, 'a=x');
137
+ });
138
+
139
+ test('buildQueryString url-encodes values with spaces and symbols', () => {
140
+ const qs = buildQueryString({ server: ['Claude in Chrome', 'a&b'] });
141
+ assert.deepStrictEqual(new URLSearchParams(qs).getAll('server'), ['Claude in Chrome', 'a&b']);
142
+ });
@@ -0,0 +1,52 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const { Command } = require('commander');
4
+
5
+ const download = require('../src/commands/download');
6
+
7
+ // Commander binds a repeated flag on the PARENT and the child's copy is left
8
+ // undefined. When the flag is `-o`, the export silently streams to stdout
9
+ // instead of writing the file the user named, and the command still exits 0.
10
+ // This repo has hit that once before, on `onboard mdm`.
11
+
12
+ function flagNames(command) {
13
+ return command.options.flatMap((opt) => [opt.short, opt.long].filter(Boolean));
14
+ }
15
+
16
+ function build() {
17
+ const program = new Command();
18
+ program.exitOverride();
19
+ download.register(program);
20
+ return program.commands.find((c) => c.name() === 'download');
21
+ }
22
+
23
+ test('no subcommand of `download` declares a flag its parent also declares', () => {
24
+ const group = build();
25
+ const parentFlags = new Set(flagNames(group));
26
+
27
+ for (const sub of group.commands) {
28
+ const clashing = flagNames(sub).filter((flag) => parentFlags.has(flag));
29
+ assert.deepEqual(
30
+ clashing, [],
31
+ `\`download ${sub.name()}\` re-declares ${clashing.join(', ')} from its parent; ` +
32
+ 'commander binds those on the parent and the subcommand reads undefined',
33
+ );
34
+ }
35
+ });
36
+
37
+ test('the `download` group itself takes no options, so nothing can be shadowed', () => {
38
+ // -h/--help is added by commander and is handled before any action runs.
39
+ const declared = flagNames(build()).filter((f) => f !== '-h' && f !== '--help');
40
+ assert.deepEqual(declared, [], 'keep the group option-free so subcommands own their flags');
41
+ });
42
+
43
+ test('`download` exposes the verbs an agent needs, each taking its own -o where relevant', () => {
44
+ const group = build();
45
+ const names = group.commands.map((c) => c.name()).sort();
46
+ assert.deepEqual(names, ['get', 'kinds', 'start']);
47
+
48
+ for (const verb of ['get', 'start']) {
49
+ const sub = group.commands.find((c) => c.name() === verb);
50
+ assert.ok(flagNames(sub).includes('-o'), `\`download ${verb}\` must accept -o`);
51
+ }
52
+ });
@@ -0,0 +1,79 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+
4
+ const { buildQueryString, resolveLast } = require('../src/commands/download');
5
+ const { KINDS, isJobId } = require('../src/lib/downloads');
6
+
7
+ const params = (opts) => new URLSearchParams(buildQueryString(opts));
8
+
9
+ test('repeated --filter keys are appended, not collapsed to the last one', () => {
10
+ // Every list filter on the server reads getlist(). Collapsing duplicates
11
+ // would silently export one tool instead of two, and the file still looks
12
+ // fine — which is the failure mode worth a test.
13
+ const q = params({ filter: ['tool_types=CLAUDE_CODE', 'tool_types=CURSOR'] });
14
+ assert.deepEqual(q.getAll('tool_types'), ['CLAUDE_CODE', 'CURSOR']);
15
+ });
16
+
17
+ test('--filter keeps everything after the first = so values may contain one', () => {
18
+ const q = params({ filter: ['search=a=b'] });
19
+ assert.equal(q.get('search'), 'a=b');
20
+ });
21
+
22
+ test('--filter rejects a value with no key rather than sending a blank param', () => {
23
+ assert.throws(() => buildQueryString({ filter: ['=value'] }), /Use key=value/);
24
+ assert.throws(() => buildQueryString({ filter: ['novalue'] }), /Use key=value/);
25
+ });
26
+
27
+ test('--last resolves to an absolute window the server understands', () => {
28
+ const q = params({ last: '7d' });
29
+ const start = new Date(q.get('start_date'));
30
+ const end = new Date(q.get('end_date'));
31
+ const days = (end - start) / 86400000;
32
+ assert.ok(Math.abs(days - 7) < 0.01, `expected ~7 days, got ${days}`);
33
+ });
34
+
35
+ test('--last accepts hours as well as days', () => {
36
+ const { start, end } = resolveLast('24h');
37
+ const hours = (end - start) / 3600000;
38
+ assert.ok(Math.abs(hours - 24) < 0.01, `expected ~24 hours, got ${hours}`);
39
+ });
40
+
41
+ test('--last rejects junk instead of silently exporting the default window', () => {
42
+ for (const bad of ['7', 'week', '7 days', '-3d', '0d', '']) {
43
+ assert.throws(() => resolveLast(bad), /Invalid --last|at least 1/, `"${bad}" should be rejected`);
44
+ }
45
+ });
46
+
47
+ test('--last and --start/--end together is an error, not a silent winner', () => {
48
+ assert.throws(
49
+ () => buildQueryString({ last: '7d', start: '2026-07-01', end: '2026-07-31' }),
50
+ /not both/,
51
+ );
52
+ });
53
+
54
+ test('--start without --end is rejected (a half range would widen the export)', () => {
55
+ assert.throws(() => buildQueryString({ start: '2026-07-01' }), /must be used together/);
56
+ assert.throws(() => buildQueryString({ end: '2026-07-31' }), /must be used together/);
57
+ });
58
+
59
+ test('no range at all is allowed — the server applies its own default window', () => {
60
+ assert.equal(buildQueryString({}), '');
61
+ });
62
+
63
+ test('every advertised kind names the filters it accepts', () => {
64
+ // The help text is the agent's only map of what each kind takes, so an empty
65
+ // filter list would read as "takes nothing" rather than "nobody filled it in".
66
+ for (const [kind, meta] of Object.entries(KINDS)) {
67
+ assert.ok(meta.summary && meta.summary.length > 10, `${kind} needs a summary`);
68
+ assert.ok(Array.isArray(meta.filters) && meta.filters.length > 0, `${kind} needs filters`);
69
+ }
70
+ });
71
+
72
+ test('isJobId accepts a UUID and rejects anything else', () => {
73
+ assert.ok(isJobId('4f2a1c9e-1b2c-4d5e-8f90-a1b2c3d4e5f6'));
74
+ assert.ok(isJobId('4F2A1C9E-1B2C-4D5E-8F90-A1B2C3D4E5F6'));
75
+ assert.equal(isJobId('logs-export'), false);
76
+ assert.equal(isJobId('4f2a1c9e'), false);
77
+ assert.equal(isJobId(''), false);
78
+ assert.equal(isJobId(null), false);
79
+ });
@@ -0,0 +1,201 @@
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 downloads = require('../src/lib/downloads');
10
+ const output = require('../src/output');
11
+ const { resolveLimit, MAX_LIMIT, MAX_TABLE_LIMIT } = require('../src/lib/analytics-query');
12
+
13
+ function startServer(handler) {
14
+ const server = http.createServer(handler);
15
+ return new Promise((resolve) => {
16
+ server.listen(0, '127.0.0.1', () => resolve({ server, port: server.address().port }));
17
+ });
18
+ }
19
+
20
+ function freshDir() {
21
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'dl-safety-'));
22
+ }
23
+
24
+ // Analytics cells are commands and prompts recorded from agents, so their
25
+ // contents are chosen by whoever ran the agent. Echoing them raw lets a
26
+ // recorded command repaint the screen, set the window title, or drive OSC 52.
27
+ test('logTable strips terminal control sequences out of backend cells', () => {
28
+ const lines = [];
29
+ const realLog = console.log;
30
+ console.log = (line) => lines.push(String(line));
31
+ try {
32
+ output.logTable(
33
+ ['Command'],
34
+ [[']52;c;aGF4git push]0;pwned']],
35
+ { wide: true },
36
+ );
37
+ } finally {
38
+ console.log = realLog;
39
+ }
40
+ const printed = lines.join('\n');
41
+ assert.ok(printed.includes('git push'), 'the real value must survive');
42
+ assert.ok(!printed.includes(']'), 'OSC sequences must not reach the terminal');
43
+ assert.ok(!printed.includes(''), 'CSI erase must not reach the terminal');
44
+ assert.ok(!printed.includes('pwned'), 'the title payload must be gone with its sequence');
45
+ });
46
+
47
+ test('logTable strips control sequences out of headers too', () => {
48
+ const lines = [];
49
+ const realLog = console.log;
50
+ console.log = (line) => lines.push(String(line));
51
+ try {
52
+ output.logTable(['Command'], [['ls']], { wide: true });
53
+ } finally {
54
+ console.log = realLog;
55
+ }
56
+ assert.ok(!lines.join('\n').includes(''), 'header colour codes must be stripped');
57
+ });
58
+
59
+ // The download used to stream straight into the path the user named. Opening
60
+ // that stream truncates an existing file, so a transfer that died halfway left
61
+ // the user with nothing where their file used to be.
62
+ test('a failed download leaves an existing -o file untouched', async () => {
63
+ const { server, port } = await startServer((req, res) => {
64
+ res.writeHead(200, { 'Content-Type': 'text/csv', 'Content-Length': '999' });
65
+ res.write('partial');
66
+ // Cut the connection mid-body: the client sees a truncated transfer.
67
+ res.destroy();
68
+ });
69
+ const dir = freshDir();
70
+ const target = path.join(dir, 'important.csv');
71
+ fs.writeFileSync(target, 'MY,EXISTING,DATA\n1,2,3\n');
72
+ const partial = `${target}.part-${process.pid}`;
73
+
74
+ try {
75
+ await assert.rejects(() => api.fetchToStream(`http://127.0.0.1:${port}/x`, {
76
+ transport: http,
77
+ onHeaders: () => fs.createWriteStream(partial, { flags: 'wx', mode: 0o600 }),
78
+ }));
79
+ assert.equal(
80
+ fs.readFileSync(target, 'utf8'), 'MY,EXISTING,DATA\n1,2,3\n',
81
+ 'the pre-existing file must be byte-identical after a failed download',
82
+ );
83
+ } finally {
84
+ try { fs.unlinkSync(partial); } catch { /* already gone */ }
85
+ fs.rmSync(dir, { recursive: true, force: true });
86
+ server.close();
87
+ }
88
+ });
89
+
90
+ test('the temp destination refuses to follow a symlink planted at its path', async () => {
91
+ const dir = freshDir();
92
+ const victim = path.join(dir, 'victim.txt');
93
+ const partial = path.join(dir, 'out.csv.part-1');
94
+ fs.writeFileSync(victim, 'do-not-touch\n');
95
+ fs.symlinkSync(victim, partial);
96
+ try {
97
+ assert.throws(
98
+ () => fs.createWriteStream(partial, { flags: 'wx' }).on('error', () => {}) && fs.openSync(partial, 'wx'),
99
+ (err) => err.code === 'EEXIST' || err.code === 'ELOOP',
100
+ );
101
+ assert.equal(fs.readFileSync(victim, 'utf8'), 'do-not-touch\n');
102
+ } finally {
103
+ fs.rmSync(dir, { recursive: true, force: true });
104
+ }
105
+ });
106
+
107
+ test('--limit is capped lower for the table than for json', () => {
108
+ assert.equal(resolveLimit({ limit: String(MAX_TABLE_LIMIT) }, 'table'), MAX_TABLE_LIMIT);
109
+ assert.throws(
110
+ () => resolveLimit({ limit: String(MAX_TABLE_LIMIT + 1) }, 'table'),
111
+ /cannot exceed 100 for table output/,
112
+ );
113
+ // json is parsed, not read, so mess is not a reason to cap it low.
114
+ assert.equal(resolveLimit({ limit: String(MAX_LIMIT) }, 'json'), MAX_LIMIT);
115
+ assert.throws(() => resolveLimit({ limit: String(MAX_LIMIT + 1) }, 'json'), /cannot exceed 1000/);
116
+ });
117
+
118
+ test('a download cut mid-transfer closes its file before the caller deletes it', async () => {
119
+ // The caller unlinks the .part file on rejection. On Windows that fails while
120
+ // a write handle is still open, leaving a truncated export behind.
121
+ const { server, port } = await startServer((req, res) => {
122
+ res.writeHead(200, { 'Content-Type': 'text/csv', 'Content-Length': '9999' });
123
+ res.write('partial');
124
+ // Cut it only once the client has taken the headers and opened its file.
125
+ // Destroying immediately kills the socket first, so onHeaders never runs
126
+ // and there is no writer to leak — a different case from the real one.
127
+ setTimeout(() => res.destroy(), 40);
128
+ });
129
+ const dir = freshDir();
130
+ const partial = path.join(dir, 'out.csv.part');
131
+ const stream = fs.createWriteStream(partial, { flags: 'wx', mode: 0o600 });
132
+
133
+ try {
134
+ await assert.rejects(() => api.fetchToStream(`http://127.0.0.1:${port}/x`, {
135
+ transport: http,
136
+ onHeaders: () => stream,
137
+ }));
138
+ assert.equal(stream.destroyed, true, 'the destination must be destroyed before the caller cleans up');
139
+ fs.unlinkSync(partial);
140
+ assert.equal(fs.existsSync(partial), false);
141
+ } finally {
142
+ try { fs.unlinkSync(partial); } catch { /* already gone */ }
143
+ fs.rmSync(dir, { recursive: true, force: true });
144
+ server.close();
145
+ }
146
+ });
147
+
148
+ test('a file that appears during the download is not overwritten by a server-chosen name', () => {
149
+ // The existence check runs before the transfer and the placement after it, so
150
+ // the whole download sits in between. rename() would replace whatever landed
151
+ // there; the guarantee has to be enforced at the moment of placement.
152
+ const dir = freshDir();
153
+ const target = path.join(dir, 'acme_logs.csv');
154
+ const partial = `${target}.part-test`;
155
+ fs.writeFileSync(partial, 'the,export\n1,2\n');
156
+ // Created after the check would have passed, while bytes were still arriving.
157
+ fs.writeFileSync(target, 'SOMEONE ELSE WROTE THIS\n');
158
+
159
+ try {
160
+ assert.throws(
161
+ () => downloads.placeFile(partial, target, { replace: false, name: 'acme_logs.csv' }),
162
+ /already exists here/,
163
+ );
164
+ assert.equal(fs.readFileSync(target, 'utf8'), 'SOMEONE ELSE WROTE THIS\n',
165
+ 'the file that appeared mid-download must be untouched');
166
+ } finally {
167
+ fs.rmSync(dir, { recursive: true, force: true });
168
+ }
169
+ });
170
+
171
+ test('an explicit -o still replaces, because the user named that file', () => {
172
+ const dir = freshDir();
173
+ const target = path.join(dir, 'mine.csv');
174
+ const partial = `${target}.part-test`;
175
+ fs.writeFileSync(partial, 'new\n');
176
+ fs.writeFileSync(target, 'old\n');
177
+
178
+ try {
179
+ downloads.placeFile(partial, target, { replace: true, name: 'mine.csv' });
180
+ assert.equal(fs.readFileSync(target, 'utf8'), 'new\n');
181
+ assert.equal(fs.existsSync(partial), false, 'the temp file must not be left behind');
182
+ } finally {
183
+ fs.rmSync(dir, { recursive: true, force: true });
184
+ }
185
+ });
186
+
187
+ test('the normal case places the file and removes the temp copy', () => {
188
+ const dir = freshDir();
189
+ const target = path.join(dir, 'fresh.csv');
190
+ const partial = `${target}.part-test`;
191
+ fs.writeFileSync(partial, 'a,b\n');
192
+
193
+ try {
194
+ downloads.placeFile(partial, target, { replace: false, name: 'fresh.csv' });
195
+ assert.equal(fs.readFileSync(target, 'utf8'), 'a,b\n');
196
+ assert.equal(fs.existsSync(partial), false, 'link() leaves the temp name behind unless it is unlinked');
197
+ } finally {
198
+ fs.rmSync(dir, { recursive: true, force: true });
199
+ }
200
+ });
201
+