unbound-cli 1.13.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.
- package/package.json +1 -1
- package/src/api.js +217 -0
- package/src/commands/analytics.js +344 -0
- package/src/commands/chat.js +140 -31
- package/src/commands/download.js +245 -0
- package/src/index.js +14 -0
- package/src/lib/analytics-query.js +216 -0
- package/src/lib/downloads.js +343 -0
- package/src/output.js +75 -3
- package/test/analytics-columns.test.js +68 -0
- package/test/analytics-export-job.test.js +141 -0
- package/test/analytics-log-table.test.js +77 -0
- package/test/analytics-paging-footer.test.js +34 -0
- package/test/analytics-query.test.js +142 -0
- package/test/chat-json-stdout.test.js +101 -12
- package/test/chat-stream.test.js +255 -0
- package/test/download-flag-collision.test.js +52 -0
- package/test/download-query.test.js +79 -0
- package/test/download-safety.test.js +201 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const http = require('http');
|
|
4
|
+
|
|
5
|
+
// A turn can run for minutes, and the proxy in front of the API closes a
|
|
6
|
+
// connection that goes 60s without a byte. A buffered POST sends nothing until
|
|
7
|
+
// the turn ends, so every long question died at 60s with the answer already
|
|
8
|
+
// built server-side. These cover the streamed path that replaces it.
|
|
9
|
+
|
|
10
|
+
function freshModules() {
|
|
11
|
+
for (const m of ['../src/commands/chat', '../src/api', '../src/config', '../src/auth', '../src/output', '../src/chartRender']) {
|
|
12
|
+
delete require.cache[require.resolve(m)];
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Serve `handler` on a loopback port and point the api module at it.
|
|
17
|
+
async function withServer(handler, run) {
|
|
18
|
+
const server = http.createServer(handler);
|
|
19
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
20
|
+
const port = server.address().port;
|
|
21
|
+
freshModules();
|
|
22
|
+
const config = require('../src/config');
|
|
23
|
+
config.getBaseUrl = () => `http://127.0.0.1:${port}`;
|
|
24
|
+
config.getApiKey = () => 'test-key';
|
|
25
|
+
const api = require('../src/api');
|
|
26
|
+
try {
|
|
27
|
+
return await run(api);
|
|
28
|
+
} finally {
|
|
29
|
+
server.close();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function sse(event, data) {
|
|
34
|
+
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
test('postStream: reassembles frames split across chunk boundaries', async () => {
|
|
38
|
+
const frames = sse('block', { type: 'text', text: 'one' })
|
|
39
|
+
+ sse('block', { type: 'chart', title: 'two' })
|
|
40
|
+
+ sse('done', { conversation_id: 'c-1' });
|
|
41
|
+
|
|
42
|
+
// Cut mid-JSON, then between the two newlines that terminate a frame — the
|
|
43
|
+
// two ways a real socket splits a 400 KB chart block. The awaits are what
|
|
44
|
+
// force separate 'data' events; without them Node coalesces the writes.
|
|
45
|
+
const midJson = 20;
|
|
46
|
+
const midSeparator = frames.indexOf('\n\n') + 1;
|
|
47
|
+
assert.ok(midJson < midSeparator);
|
|
48
|
+
|
|
49
|
+
const events = await withServer(async (req, res) => {
|
|
50
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
|
|
51
|
+
for (const part of [frames.slice(0, midJson), frames.slice(midJson, midSeparator), frames.slice(midSeparator)]) {
|
|
52
|
+
res.write(part);
|
|
53
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
54
|
+
}
|
|
55
|
+
res.end();
|
|
56
|
+
}, async (api) => {
|
|
57
|
+
const seen = [];
|
|
58
|
+
const fallback = await api.postStream('/x', {
|
|
59
|
+
body: {}, onEvent: (event, data) => seen.push([event, data]),
|
|
60
|
+
});
|
|
61
|
+
assert.equal(fallback, null, 'a streamed reply resolves null');
|
|
62
|
+
return seen;
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
assert.deepEqual(events.map((e) => e[0]), ['block', 'block', 'done']);
|
|
66
|
+
assert.equal(events[0][1].text, 'one');
|
|
67
|
+
assert.equal(events[1][1].title, 'two');
|
|
68
|
+
assert.equal(events[2][1].conversation_id, 'c-1');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('postStream: a server that answers with JSON resolves that body instead', async () => {
|
|
72
|
+
const resp = await withServer((req, res) => {
|
|
73
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
74
|
+
res.end(JSON.stringify({ success: true, blocks: [{ type: 'text', text: 'buffered' }] }));
|
|
75
|
+
}, async (api) => {
|
|
76
|
+
let called = 0;
|
|
77
|
+
const out = await api.postStream('/x', { body: {}, onEvent: () => { called += 1; } });
|
|
78
|
+
assert.equal(called, 0, 'no frames to hand back');
|
|
79
|
+
return out;
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
assert.equal(resp.blocks[0].text, 'buffered');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('postStream: a socket cut mid-stream is reported, not read as a finished turn', async () => {
|
|
86
|
+
await withServer(async (req, res) => {
|
|
87
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
|
|
88
|
+
res.write(sse('block', { type: 'text', text: 'partial' }));
|
|
89
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
90
|
+
// What a proxy does when it gives up: headers are out, so it can only cut.
|
|
91
|
+
res.socket.destroy();
|
|
92
|
+
}, async (api) => {
|
|
93
|
+
const seen = [];
|
|
94
|
+
await assert.rejects(
|
|
95
|
+
() => api.postStream('/x', { body: {}, onEvent: (e) => seen.push(e) }),
|
|
96
|
+
/dropped before the reply finished/,
|
|
97
|
+
);
|
|
98
|
+
assert.deepEqual(seen, ['block'], 'what arrived before the cut still counts');
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('postStream: a non-2xx reply rejects with the status', async () => {
|
|
103
|
+
await withServer((req, res) => {
|
|
104
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
105
|
+
res.end(JSON.stringify({ error: 'nope' }));
|
|
106
|
+
}, async (api) => {
|
|
107
|
+
await assert.rejects(
|
|
108
|
+
() => api.postStream('/x', { body: {} }),
|
|
109
|
+
(err) => err.statusCode === 403 && err.message === 'nope',
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
function chatWithStream(postStream) {
|
|
115
|
+
freshModules();
|
|
116
|
+
const api = require('../src/api');
|
|
117
|
+
api.postStream = postStream;
|
|
118
|
+
return require('../src/commands/chat')._internals.converse;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
test('converse: assembles blocks and done into the non-streaming shape', async () => {
|
|
122
|
+
const converse = chatWithStream(async (_p, { onEvent }) => {
|
|
123
|
+
onEvent('block', { type: 'text', text: 'hi' });
|
|
124
|
+
onEvent('done', { conversation_id: 'c-1', log_id: 'l-1', truncated: false, resumed: true });
|
|
125
|
+
return null;
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const resp = await converse({ message: 'q' });
|
|
129
|
+
assert.equal(resp.success, true);
|
|
130
|
+
assert.equal(resp.blocks.length, 1);
|
|
131
|
+
assert.equal(resp.conversation_id, 'c-1');
|
|
132
|
+
assert.equal(resp.resumed, true);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('converse: sets stream on the request', async () => {
|
|
136
|
+
let sent = null;
|
|
137
|
+
const converse = chatWithStream(async (_p, { body, onEvent }) => {
|
|
138
|
+
sent = body;
|
|
139
|
+
onEvent('done', { conversation_id: 'c-1' });
|
|
140
|
+
return null;
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
await converse({ message: 'q', conversation_id: 'c-1' });
|
|
144
|
+
assert.equal(sent.stream, true);
|
|
145
|
+
assert.equal(sent.message, 'q');
|
|
146
|
+
assert.equal(sent.conversation_id, 'c-1');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('converse: an error event keeps the blocks already streamed', async () => {
|
|
150
|
+
const converse = chatWithStream(async (_p, { onEvent }) => {
|
|
151
|
+
onEvent('block', { type: 'text', text: 'built this much' });
|
|
152
|
+
onEvent('error', { error: 'The assistant is unavailable right now.' });
|
|
153
|
+
return null;
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const resp = await converse({ message: 'q' });
|
|
157
|
+
assert.equal(resp.success, false);
|
|
158
|
+
assert.equal(resp.error, 'The assistant is unavailable right now.');
|
|
159
|
+
// They are on screen already — reporting an empty turn would contradict that.
|
|
160
|
+
assert.equal(resp.blocks.length, 1);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('converse: a stream that ends without done is reported, not passed off as complete', async () => {
|
|
164
|
+
const converse = chatWithStream(async (_p, { onEvent }) => {
|
|
165
|
+
onEvent('block', { type: 'text', text: 'partial' });
|
|
166
|
+
return null;
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
await assert.rejects(() => converse({ message: 'q' }), /connection dropped/);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('converse: a server that did not stream is returned untouched', async () => {
|
|
173
|
+
const buffered = { success: true, blocks: [{ type: 'text', text: 'old server' }], conversation_id: 'c-9' };
|
|
174
|
+
const converse = chatWithStream(async () => buffered);
|
|
175
|
+
|
|
176
|
+
assert.deepEqual(await converse({ message: 'q' }), buffered);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('postStream: CRLF-delimited frames are read, not silently skipped', async () => {
|
|
180
|
+
const frames = 'event: block\r\ndata: {"type":"text","text":"crlf"}\r\n\r\n'
|
|
181
|
+
+ 'event: done\r\ndata: {"conversation_id":"c-1"}\r\n\r\n';
|
|
182
|
+
|
|
183
|
+
const events = await withServer((req, res) => {
|
|
184
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
|
|
185
|
+
res.end(frames);
|
|
186
|
+
}, async (api) => {
|
|
187
|
+
const seen = [];
|
|
188
|
+
await api.postStream('/x', { body: {}, onEvent: (e, d) => seen.push([e, d]) });
|
|
189
|
+
return seen;
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
assert.deepEqual(events.map((e) => e[0]), ['block', 'done']);
|
|
193
|
+
assert.equal(events[0][1].text, 'crlf');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('converse: a socket that fails after the closing frame keeps the turn', async () => {
|
|
197
|
+
const converse = chatWithStream(async (_p, { onEvent }) => {
|
|
198
|
+
onEvent('block', { type: 'text', text: 'built' });
|
|
199
|
+
onEvent('done', { conversation_id: 'c-1', resumed: true });
|
|
200
|
+
// Teardown failure, after the turn already reported itself finished.
|
|
201
|
+
throw new Error('The connection to example dropped before the reply finished (ECONNRESET).');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const resp = await converse({ message: 'q' });
|
|
205
|
+
assert.equal(resp.success, true);
|
|
206
|
+
assert.equal(resp.blocks.length, 1);
|
|
207
|
+
assert.equal(resp.conversation_id, 'c-1');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('converse: a socket that fails with no closing frame still reports the drop', async () => {
|
|
211
|
+
const converse = chatWithStream(async (_p, { onEvent }) => {
|
|
212
|
+
onEvent('block', { type: 'text', text: 'partial' });
|
|
213
|
+
throw new Error('The connection to example dropped before the reply finished (ECONNRESET).');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
await assert.rejects(() => converse({ message: 'q' }), /dropped before the reply finished/);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('postStream: CR-only frames are read, not collapsed into one line', async () => {
|
|
220
|
+
const frames = 'event: block\rdata: {"type":"text","text":"cr"}\r\r'
|
|
221
|
+
+ 'event: done\rdata: {"conversation_id":"c-1"}\r\r';
|
|
222
|
+
|
|
223
|
+
const events = await withServer((req, res) => {
|
|
224
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
|
|
225
|
+
res.end(frames);
|
|
226
|
+
}, async (api) => {
|
|
227
|
+
const seen = [];
|
|
228
|
+
await api.postStream('/x', { body: {}, onEvent: (e, d) => seen.push([e, d]) });
|
|
229
|
+
return seen;
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
assert.deepEqual(events.map((e) => e[0]), ['block', 'done']);
|
|
233
|
+
assert.equal(events[0][1].text, 'cr');
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test('postStream: a CRLF split across two chunks does not cut a frame in half', async () => {
|
|
237
|
+
const frame = 'event: block\r\ndata: {"type":"text","text":"split"}\r\n\r\n';
|
|
238
|
+
const cut = frame.indexOf('\r\n') + 1; // between the CR and the LF
|
|
239
|
+
|
|
240
|
+
const events = await withServer(async (req, res) => {
|
|
241
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
|
|
242
|
+
res.write(frame.slice(0, cut));
|
|
243
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
244
|
+
res.end(frame.slice(cut));
|
|
245
|
+
}, async (api) => {
|
|
246
|
+
const seen = [];
|
|
247
|
+
await api.postStream('/x', { body: {}, onEvent: (e, d) => seen.push([e, d]) });
|
|
248
|
+
return seen;
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// Naively normalising CR to LF turns the split ending into a frame boundary,
|
|
252
|
+
// and the block is silently dropped as a frame with no data.
|
|
253
|
+
assert.deepEqual(events.map((e) => e[0]), ['block']);
|
|
254
|
+
assert.equal(events[0][1].text, 'split');
|
|
255
|
+
});
|
|
@@ -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;aGF4[2Jgit 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('[2J'), '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(['[31mCommand'], [['ls']], { wide: true });
|
|
53
|
+
} finally {
|
|
54
|
+
console.log = realLog;
|
|
55
|
+
}
|
|
56
|
+
assert.ok(!lines.join('\n').includes('[31m'), '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
|
+
|