dsh-dbhub-live 5.0.1 → 5.2.0-dev.3

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.
Files changed (50) hide show
  1. package/AGENTS.md +95 -209
  2. package/README.en.md +251 -169
  3. package/README.md +251 -169
  4. package/doc/ARCHITECTURE.md +108 -0
  5. package/doc/CLIENT.md +124 -0
  6. package/doc/DESENSITIZE.md +56 -0
  7. package/doc/DEVELOPMENT.md +139 -0
  8. package/doc/IMPACT-MATRIX.md +36 -0
  9. package/doc/RELEASE.md +93 -0
  10. package/doc/REQUIREMENTS.md +177 -105
  11. package/doc/images/chat-query.png +0 -0
  12. package/doc/images/row-editor.png +0 -0
  13. package/doc/images/settings-page.png +0 -0
  14. package/lib/adhoc.mjs +412 -34
  15. package/lib/capability.mjs +157 -0
  16. package/lib/client.js +1243 -80
  17. package/lib/config.mjs +370 -21
  18. package/lib/i18n.mjs +119 -4
  19. package/lib/index.mjs +401 -43
  20. package/lib/mcp.mjs +496 -391
  21. package/lib/meta.mjs +267 -0
  22. package/lib/options.mjs +25 -5
  23. package/lib/policy.mjs +92 -0
  24. package/lib/toml.mjs +188 -0
  25. package/lib/tools.mjs +1093 -827
  26. package/lib/update.mjs +244 -0
  27. package/package.json +34 -10
  28. package/scripts/desensitize-check.mjs +10 -3
  29. package/scripts/desensitize.config.mjs +255 -237
  30. package/scripts/docs-check.mjs +214 -0
  31. package/scripts/release-preflight.mjs +180 -0
  32. package/scripts/render-check.mjs +1366 -843
  33. package/test/adhoc-toml.test.mjs +365 -0
  34. package/test/bridge.test.mjs +227 -185
  35. package/test/capability.test.mjs +231 -0
  36. package/test/client-format.test.mjs +515 -352
  37. package/test/configure-options.test.mjs +244 -0
  38. package/test/configure-ssh.test.mjs +232 -0
  39. package/test/copy-from.test.mjs +155 -0
  40. package/test/env-options.test.mjs +456 -0
  41. package/test/meta.test.mjs +234 -0
  42. package/test/options.test.mjs +160 -123
  43. package/test/policy.test.mjs +132 -0
  44. package/test/row-cache.test.mjs +237 -138
  45. package/test/ssh-probe.test.mjs +834 -0
  46. package/test/test-op-fastpath.test.mjs +208 -0
  47. package/test/toml.test.mjs +352 -0
  48. package/test/update.test.mjs +270 -0
  49. package/test/util.test.mjs +17 -0
  50. package/test/zero-knowledge.test.mjs +120 -78
@@ -0,0 +1,834 @@
1
+ // dsh-dbhub-live: the SSH-layer probe and the failure-attribution helpers.
2
+ //
3
+ // 5.1.0-dev.3 answers "is it the tunnel or the database?" with five pieces, all
4
+ // pinned here (with the measured dbhub 1.4.0 stderr as the fixture):
5
+ // · `diagnosticTail` keeps the informative END of dbhub's output — the fatal
6
+ // line is printed LAST while the banner comes first, and the ~10 ` at …`
7
+ // stack frames that follow it are dropped, so the line NAMING the reason is
8
+ // what survives the 600-character cap;
9
+ // · `missingSshFields` says WHAT is missing, and `failureLayerOf` /
10
+ // `failureLayerLabel` attribute a failure to the plugin, the SSH layer or the
11
+ // database step (and to nothing when no tunnel is set);
12
+ // · `probeSshTunnel` validates ONLY the tunnel: a loopback TCP connect to the
13
+ // bastion port, the local private-key file for key auth, and then a real
14
+ // dbhub connect THROUGH the tunnel to `127.0.0.1:1` (a port that cannot be
15
+ // listening). The evidence is dbhub's own error text: naming the forwarded
16
+ // target without an authentication/handshake wording proves the tunnel
17
+ // carried the connection (`tunnel`), an SSH-layer signature means the tunnel
18
+ // itself is broken (`ssh`), and neither means the answer is honestly
19
+ // `unknown` rather than a guess;
20
+ // · `mergeSshOptions` (lib/config.mjs) implements the browser path's "blank
21
+ // secret = keep the stored value" rule (the UI cannot echo a secret back);
22
+ // · `scrubSecrets` (lib/config.mjs) collapses the WHOLE userinfo of every
23
+ // URL-shaped connection string, because dbhub's banner masks only the
24
+ // password and prints the user name verbatim.
25
+ //
26
+ // `handleTestOp` (lib/index.mjs) is NOT exported, so its `{op:'test', kind:'ssh'}`
27
+ // branch is covered INDIRECTLY: `apply()` wires the HTTP bridge over a fake ctx,
28
+ // the op goes in through `POST /op`, and the report comes back in the published
29
+ // `testResult` field.
30
+ //
31
+ // The fake dbhub never answers MCP, so `runAdhoc`/`probeSshTunnel` resolve a
32
+ // failure by design — the assertions are about how the canned stderr is
33
+ // attributed, never about a dbhub reply.
34
+ //
35
+ // Test isolation: config.mjs derives the storage dir from DSH_HOME at import
36
+ // time, so point it at a temp dir BEFORE importing.
37
+
38
+ import test from 'node:test'
39
+ import assert from 'node:assert/strict'
40
+ import { createServer } from 'node:net'
41
+ import { mkdtempSync, rmSync, existsSync } from 'node:fs'
42
+ import { tmpdir } from 'node:os'
43
+ import { join } from 'node:path'
44
+
45
+ const home = mkdtempSync(join(tmpdir(), 'dsh-dbhub-sshprobe-'))
46
+ process.env.DSH_HOME = home
47
+ // runtime.mjs prefers a mise-managed dbhub discovered through MISE_DATA_DIR;
48
+ // drop it so the fake executable is what every call resolves.
49
+ delete process.env.MISE_DATA_DIR
50
+
51
+ const adhoc = await import('../lib/adhoc.mjs')
52
+ const cfg = await import('../lib/config.mjs')
53
+ const state = await import('../lib/state.mjs')
54
+ const index = await import('../lib/index.mjs')
55
+ const { BRIDGE_ROUTES } = await import('../lib/bridge.mjs')
56
+ const { currentT } = await import('../lib/i18n.mjs')
57
+
58
+ // ── fixtures (loopback / RFC 5737 hosts and CHANGE_ME placeholders only) ────
59
+
60
+ const DSN = 'mysql://user:CHANGE_ME@127.0.0.1:3306/app'
61
+ /** A tunnelled descriptor: `conn.ssh` is what turns attribution on. */
62
+ const SSH_CONN = {
63
+ dsn: DSN,
64
+ ro: false,
65
+ ssh: { host: '192.0.2.10', port: 22, user: 'ops', auth: 'password', password: 'CHANGE_ME' },
66
+ }
67
+ const STORED_PASSWORD = { host: '192.0.2.10', port: 22, user: 'ops', auth: 'password', password: 'CHANGE_ME' }
68
+
69
+ // MEASURED dbhub 1.4.0 stderr (sanitized: absolute paths and the build hash are
70
+ // low-entropy placeholders). A tunnel probe against a closed target, exactly as
71
+ // dbhub prints it: the fatal line, ~10 ` at …` frames, then the tunnel-error
72
+ // property dbhub sets on that error object (named verbatim in the fixture
73
+ // below). Both the attribution and the tail have to survive this shape; dbhub
74
+ // masks the password itself with eight stars, but the user name is printed
75
+ // verbatim.
76
+ const DBHUB_SSH_CAPTURE = [
77
+ 'Configuration source: dbhub-1234-1-abc123.toml',
78
+ 'Connecting to 1 database source(s)...',
79
+ ' - default: mysql://probe:********@127.0.0.1:1/probe',
80
+ 'Fatal error: Error: SSH connection error: connect ECONNREFUSED 127.0.0.1:1',
81
+ ' at Client.onError (file:///opt/dbhub/dist/chunk-ABCDEF.js:399:16)',
82
+ ' at Client.emit (node:events:514:28)',
83
+ ' at Socket.<anonymous> (/opt/dbhub/node_modules/ssh2/lib/client.js:805:12)',
84
+ ' at Socket.emit (node:events:514:28)',
85
+ ' at process.processTicksAndRejections (node:internal/process/task_queues:90:21) {',
86
+ ' __dbhubSSHTunnelError: true',
87
+ '}',
88
+ ].join('\n')
89
+ const STORED_KEY = { host: '192.0.2.10', port: 2222, user: 'ops', auth: 'key', keyPath: '~/.ssh/id_ed25519', passphrase: 'CHANGE_ME' }
90
+ const PLUGIN_FAIL = '启动临时 dbhub 失败'
91
+
92
+ // MEASURED dbhub 1.4.0 stderr for a tunnel that DID come up: the forwarded target
93
+ // closed the connection, so the driver reports a DATABASE-protocol error and
94
+ // dbhub tears the tunnel down ("SSH tunnel closed"). Nothing here names the
95
+ // forwarded target, which is exactly why a target-name-only rule answered
96
+ // "cannot classify" for a perfectly good tunnel.
97
+ const DBHUB_TUNNEL_UP_CAPTURE = [
98
+ 'Configuration source: dbhub-1234-2-def456.toml',
99
+ 'Connecting to 1 database source(s)...',
100
+ ' - default: mysql://probe:********@127.0.0.1:1/probe',
101
+ 'SSH tunnel closed',
102
+ 'Fatal error: Error: Connection lost: The server closed the connection.',
103
+ ' at Query.query (/opt/dbhub/dist/mysql-ABCDEF.js:135:38)',
104
+ ' at process.processTicksAndRejections (node:internal/process/task_queues:90:21) {',
105
+ ' fatal: true,',
106
+ " code: 'PROTOCOL_CONNECTION_LOST'",
107
+ '}',
108
+ ].join('\n')
109
+
110
+ // A fresh DSH_HOME has no credentials.json, so state.mjs starts enabled; make
111
+ // that explicit so every probe below really reaches its dbhub step.
112
+ if (!state.isEnabled()) state.setEnabled(true)
113
+
114
+ test.after(async () => {
115
+ for (const server of [...liveServers]) await server.close()
116
+ rmSync(home, { recursive: true, force: true })
117
+ })
118
+
119
+ // ── fake host services ─────────────────────────────────────────────────────
120
+
121
+ /**
122
+ * A recording stand-in for the host `subprocess` service. It never runs a real
123
+ * binary: `spawn` records the exact spec (`runAdhoc` awaits `waitForExit`) and
124
+ * the canned `stderr` is emitted the moment the MCP client subscribes — which
125
+ * is how `runAdhoc` builds its "[dbhub stderr] …" detail. `spawnThrows` models
126
+ * the plugin-side failure path ("启动临时 dbhub 失败: …").
127
+ */
128
+ function recorder(opts = {}) {
129
+ const specs = []
130
+ return {
131
+ specs,
132
+ subprocess: {
133
+ resolveExecutable: async (name) => '/fake/bin/' + name,
134
+ spawn(spec) {
135
+ specs.push(spec)
136
+ if (opts.spawnThrows) throw new Error(String(opts.spawnThrows))
137
+ return {
138
+ stdin: { write() {} },
139
+ stdout: { on() {} },
140
+ stderr: {
141
+ on(event, handler) {
142
+ if (event === 'data' && opts.stderr) handler(opts.stderr)
143
+ },
144
+ },
145
+ done: Promise.resolve({ exitCode: 1 }),
146
+ terminate() {},
147
+ waitForExit: async () => ({ exitCode: 1 }),
148
+ }
149
+ },
150
+ },
151
+ }
152
+ }
153
+
154
+ // ── real loopback listeners ────────────────────────────────────────────────
155
+ //
156
+ // `tcpReachable` is tested for real. Every server opened here is tracked and
157
+ // closed in `test.after` as well as at the end of its own test, so a leaked
158
+ // listener can never keep this file from exiting.
159
+
160
+ const liveServers = new Set()
161
+
162
+ /** Start a throwaway loopback server on an ephemeral port. */
163
+ function listenOnce() {
164
+ return new Promise((resolve, reject) => {
165
+ const sockets = new Set()
166
+ const server = createServer((socket) => {
167
+ // The probe connects and destroys immediately: an ECONNRESET on the
168
+ // server side must never surface as an unhandled 'error' event.
169
+ socket.on('error', () => {})
170
+ sockets.add(socket)
171
+ socket.on('close', () => sockets.delete(socket))
172
+ })
173
+ server.once('error', reject)
174
+ server.listen(0, '127.0.0.1', () => {
175
+ const handle = {
176
+ port: server.address().port,
177
+ close: () =>
178
+ new Promise((done) => {
179
+ for (const socket of sockets) {
180
+ try {
181
+ socket.destroy()
182
+ } catch (e) {
183
+ /* already gone */
184
+ }
185
+ }
186
+ server.close(() => {
187
+ liveServers.delete(handle)
188
+ done()
189
+ })
190
+ }),
191
+ }
192
+ liveServers.add(handle)
193
+ resolve(handle)
194
+ })
195
+ })
196
+ }
197
+
198
+ /** A port that was just bound and released: connecting to it must fail. */
199
+ async function closedPort() {
200
+ const server = await listenOnce()
201
+ const port = server.port
202
+ await server.close()
203
+ return port
204
+ }
205
+
206
+ // ── diagnosticTail ─────────────────────────────────────────────────────────
207
+
208
+ test('diagnosticTail keeps the END of a diagnostic and trims blank edges', () => {
209
+ const banner = '\n Configuration source: file \r\nConnecting to 1 database source… \n\n\nfatal: access denied\n\n'
210
+ // \r dropped, trailing spaces trimmed per line, leading/trailing blank lines
211
+ // removed — the interior line structure (and indentation) survives.
212
+ assert.equal(
213
+ adhoc.diagnosticTail(banner),
214
+ ' Configuration source: file\nConnecting to 1 database source…\n\n\nfatal: access denied',
215
+ )
216
+
217
+ assert.equal(adhoc.diagnosticTail(''), '')
218
+ assert.equal(adhoc.diagnosticTail(undefined), '')
219
+ assert.equal(adhoc.diagnosticTail(null), '')
220
+ assert.equal(adhoc.diagnosticTail(' \n\n \n'), '', 'whitespace-only output collapses to nothing')
221
+ assert.equal(adhoc.diagnosticTail('one line'), 'one line')
222
+ })
223
+
224
+ test('diagnosticTail drops stack frames so the reason survives the cap', () => {
225
+ // Measured on dbhub 1.4.0: a plain tail-of-N-chars keeps the ~10 ` at …`
226
+ // frames and cuts the very line that names the reason.
227
+ const tail = adhoc.diagnosticTail(DBHUB_SSH_CAPTURE)
228
+ assert.ok(tail.includes('SSH connection error'), tail)
229
+ assert.ok(tail.includes('__dbhubSSHTunnelError: true'), tail, 'the trailing marker survives')
230
+ assert.equal(/\n\s*at /.test(tail), false, 'no stack frame survives')
231
+ assert.equal(tail.includes('.js:'), false, 'frame paths go too')
232
+
233
+ // Dropped frames do not eat the cap.
234
+ const padded = ' at Client.onError (file:///opt/dbhub/dist/chunk-ABCDEF.js:399:16)\n'.repeat(40) + DBHUB_SSH_CAPTURE
235
+ const capped = adhoc.diagnosticTail(padded)
236
+ assert.ok(capped.length <= adhoc.DIAG_TAIL_MAX + 1, 'still capped')
237
+ assert.ok(capped.includes('SSH connection error'))
238
+ assert.equal(/\n\s*at /.test(capped), false)
239
+ })
240
+
241
+ test('diagnosticTail cuts from the FRONT once the text exceeds the cap', () => {
242
+ const long = 'A'.repeat(700) + 'END'
243
+ const cut = adhoc.diagnosticTail(long, 100)
244
+ assert.equal(cut, '…' + long.slice(-100))
245
+ assert.equal(cut.length, 101, 'one leading ellipsis plus at most `max` characters')
246
+ assert.ok(cut.startsWith('…'), 'a cut is marked on the side that was dropped')
247
+ assert.ok(cut.endsWith('END'), 'the informative END is what is kept')
248
+
249
+ // The default cap is DIAG_TAIL_MAX, and a non-positive/invalid max means "default".
250
+ const big = Array.from({ length: 100 }, (_, i) => 'line ' + i).join('\n')
251
+ assert.ok(big.length > adhoc.DIAG_TAIL_MAX)
252
+ const defaultCut = adhoc.diagnosticTail(big)
253
+ assert.ok(defaultCut.startsWith('…'), 'a cut is marked on the side that was dropped')
254
+ assert.ok(defaultCut.endsWith('line 99'), 'the informative END is what is kept')
255
+ assert.ok(defaultCut.length <= adhoc.DIAG_TAIL_MAX + 1)
256
+ // The cut snaps to a line boundary: a dangling half-line reads as corruption.
257
+ assert.match(defaultCut.split('\n')[0], /^…line \d+$/)
258
+ assert.equal(adhoc.diagnosticTail(big, 0), defaultCut)
259
+ assert.equal(adhoc.diagnosticTail(big, Number.NaN), defaultCut)
260
+ })
261
+
262
+ test('the probe constants describe a closed loopback target', () => {
263
+ assert.equal(adhoc.DIAG_TAIL_MAX, 600)
264
+ assert.equal(adhoc.SSH_PROBE_DSN, 'mysql://probe:CHANGE_ME@127.0.0.1:1/probe')
265
+ assert.equal(adhoc.SSH_TCP_TIMEOUT_MS, 5000)
266
+ assert.equal(adhoc.SSH_PROBE_DSN.startsWith('mysql://probe:'), true)
267
+ assert.equal(adhoc.SSH_PROBE_DSN.includes('127.0.0.1:1/'), true, 'port 1 cannot be listening')
268
+ })
269
+
270
+ // ── failure-layer attribution ──────────────────────────────────────────────
271
+
272
+ test('failureLayerOf is internal for a plugin-side failure, ssh/db for a tunnel', () => {
273
+ // Plugin-side messages win even when a tunnel is configured: they were
274
+ // produced before dbhub could even try the tunnel.
275
+ for (const text of [
276
+ PLUGIN_FAIL + ': spawn refused',
277
+ '生成临时 dbhub 配置失败: bad template',
278
+ '无法获取 dbhub: ENOENT',
279
+ 'dsh-dbhub-live 已禁用:所有 dbhub 工具暂不可用。',
280
+ ]) {
281
+ assert.equal(adhoc.failureLayerOf(text, SSH_CONN), 'internal', text)
282
+ assert.equal(adhoc.failureLayerOf(text, DSN), 'internal', text)
283
+ }
284
+
285
+ // Every signature the SSH layer is recognized by.
286
+ for (const signature of [
287
+ 'All configured authentication methods failed',
288
+ 'authentication methods failed',
289
+ 'Cannot parse privateKey',
290
+ 'Encrypted private key detected',
291
+ 'a passphrase is required (no passphrase given)',
292
+ 'handshake failed',
293
+ 'ssh_host is invalid',
294
+ 'ssh_user is invalid',
295
+ 'SSH tunnel requires a password',
296
+ 'proxy jump is not supported',
297
+ 'the jump host is unreachable',
298
+ 'ssh2: stream error',
299
+ ]) {
300
+ assert.equal(adhoc.failureLayerOf(signature, SSH_CONN), 'ssh', signature)
301
+ }
302
+
303
+ // No SSH signature = the failure happened after the tunnel was up.
304
+ assert.equal(adhoc.failureLayerOf('connect ECONNREFUSED 127.0.0.1:1', SSH_CONN), 'db')
305
+ assert.equal(adhoc.failureLayerOf('Access denied for user', SSH_CONN), 'db')
306
+ assert.equal(adhoc.failureLayerOf('', SSH_CONN), 'db', 'a silent tunnel failure is still attributed to a layer')
307
+ assert.equal(adhoc.failureLayerOf(undefined, SSH_CONN), 'db')
308
+
309
+ // No tunnel: no attribution at all, whatever the text says.
310
+ assert.equal(adhoc.failureLayerOf('All configured authentication methods failed', DSN), '')
311
+ assert.equal(adhoc.failureLayerOf('connect ECONNREFUSED 127.0.0.1:1', { dsn: DSN, ro: true, ssh: null }), '')
312
+ assert.equal(adhoc.failureLayerOf('connect ECONNREFUSED 127.0.0.1:1', undefined), '')
313
+
314
+ // The measured dbhub 1.4.0 capture: dbhub's own error carries
315
+ // `__dbhubSSHTunnelError: true`, so this IS the SSH layer — and with no
316
+ // tunnel configured there is nothing to attribute.
317
+ assert.equal(adhoc.failureLayerOf(DBHUB_SSH_CAPTURE, { dsn: adhoc.SSH_PROBE_DSN, ssh: SSH_CONN.ssh }), 'ssh')
318
+ assert.equal(adhoc.failureLayerOf(DBHUB_SSH_CAPTURE, { dsn: adhoc.SSH_PROBE_DSN }), '')
319
+ })
320
+
321
+ test('probeSshTunnel agrees with failureLayerOf on the measured capture (no split verdict)', async () => {
322
+ // Regression: the capture's BANNER line names the forwarded target
323
+ // (`- default: mysql://…@127.0.0.1:1/probe`) in EVERY run, so a
324
+ // "names the target ⇒ tunnel is up" rule consulted first turned a real SSH
325
+ // failure into "your tunnel works". The SSH signature must win.
326
+ const server = await listenOnce()
327
+ try {
328
+ const { subprocess } = recorder({ stderr: DBHUB_SSH_CAPTURE })
329
+ const block = { host: '127.0.0.1', port: server.port, user: 'ops', auth: 'password', password: 'CHANGE_ME' }
330
+ const res = await adhoc.probeSshTunnel(subprocess, block)
331
+ assert.equal(res.ok, false, res.message)
332
+ assert.equal(res.layer, 'ssh', res.message)
333
+ assert.equal(adhoc.failureLayerOf(DBHUB_SSH_CAPTURE, { dsn: adhoc.SSH_PROBE_DSN, ssh: block }), 'ssh')
334
+ assert.ok(res.message.includes('SSH connection error'), 'the reason is carried through')
335
+ } finally {
336
+ await server.close()
337
+ }
338
+ })
339
+
340
+ test('failureLayerLabel is a localized prefix, empty when nothing is known', () => {
341
+ assert.equal(adhoc.failureLayerLabel('connect ECONNREFUSED 127.0.0.1:1', SSH_CONN), currentT('result.layerDb'))
342
+ assert.equal(adhoc.failureLayerLabel('All configured authentication methods failed', SSH_CONN), currentT('result.layerSsh'))
343
+ assert.equal(adhoc.failureLayerLabel(PLUGIN_FAIL + ': x', SSH_CONN), currentT('result.layerInternal'))
344
+ assert.equal(adhoc.failureLayerLabel('connect ECONNREFUSED 127.0.0.1:1', DSN), '', 'no tunnel, no prefix')
345
+ for (const key of ['result.layerSsh', 'result.layerDb', 'result.layerInternal']) {
346
+ assert.ok(currentT(key).length > 0, `${key} must be localized`)
347
+ }
348
+ })
349
+
350
+ // ── tcpReachable ───────────────────────────────────────────────────────────
351
+
352
+ test('tcpReachable succeeds against a live loopback listener and fails once closed', async () => {
353
+ const server = await listenOnce()
354
+ try {
355
+ assert.deepEqual(await adhoc.tcpReachable('127.0.0.1', server.port), { ok: true, code: '' })
356
+ } finally {
357
+ await server.close()
358
+ }
359
+ const miss = await adhoc.tcpReachable('127.0.0.1', server.port)
360
+ assert.equal(miss.ok, false)
361
+ assert.equal(miss.code, 'ECONNREFUSED', 'a released loopback port refuses the connect')
362
+ assert.equal(typeof miss.code, 'string')
363
+ })
364
+
365
+ // ── probeSshTunnel: one case per layer ─────────────────────────────────────
366
+
367
+ test('probeSshTunnel: an unusable block is settled as `config` without any spawn', async () => {
368
+ const { subprocess, specs } = recorder()
369
+ const blocks = [
370
+ undefined,
371
+ null,
372
+ {},
373
+ { host: '127.0.0.1' },
374
+ { host: '127.0.0.1', user: 'ops' },
375
+ { host: '127.0.0.1', user: 'ops', auth: 'password' },
376
+ { host: '127.0.0.1', user: 'ops', auth: 'key', keyPath: 'x', proxyJump: 'a:22,b:22' },
377
+ ]
378
+ for (const bad of blocks) {
379
+ const res = await adhoc.probeSshTunnel(subprocess, bad)
380
+ assert.equal(res.ok, false)
381
+ assert.equal(res.layer, 'config')
382
+ // The answer names WHAT is missing instead of a generic "incomplete" — or, for
383
+ // a complete-but-multi-hop block, the reason normalize actually rejected.
384
+ const missing = adhoc.missingSshFields(bad)
385
+ assert.equal(res.message, missing
386
+ ? currentT('result.sshTestBadConfig', { missing })
387
+ : currentT('result.sshTestMultiHop'))
388
+ assert.equal(res.message.includes('{missing}'), false, 'the placeholder is always resolved')
389
+ // Never a dangling "missing ." sentence.
390
+ assert.equal(res.message.includes('缺少 。'), false, res.message)
391
+ }
392
+
393
+ // …and the list itself, spelled out field by field.
394
+ const list = (...keys) => keys.map((key) => currentT(key)).join('、')
395
+ assert.equal(adhoc.missingSshFields({}), list('field.host', 'field.user', 'field.authKind'))
396
+ assert.equal(adhoc.missingSshFields(null), list('field.host', 'field.user', 'field.authKind'))
397
+ assert.equal(adhoc.missingSshFields({ host: '127.0.0.1' }), list('field.user', 'field.authKind'))
398
+ assert.equal(adhoc.missingSshFields({ host: '127.0.0.1', user: 'ops', auth: 'key' }), list('field.keyPath'))
399
+ assert.equal(adhoc.missingSshFields({ host: '127.0.0.1', user: 'ops', auth: 'password' }), list('field.password'))
400
+ assert.equal(
401
+ adhoc.missingSshFields({ host: '127.0.0.1', user: 'ops', auth: 'password', password: 'CHANGE_ME' }),
402
+ '',
403
+ 'a complete block is missing nothing',
404
+ )
405
+
406
+ assert.equal(specs.length, 0, 'an unusable ssh block never reaches dbhub')
407
+ })
408
+
409
+ test('probeSshTunnel: a closed bastion port is settled as `reach`', async () => {
410
+ const port = await closedPort()
411
+ const { subprocess, specs } = recorder()
412
+ const res = await adhoc.probeSshTunnel(subprocess, { host: '127.0.0.1', port, user: 'ops', auth: 'password', password: 'CHANGE_ME' })
413
+ const code = (await adhoc.tcpReachable('127.0.0.1', port)).code
414
+
415
+ assert.equal(res.ok, false)
416
+ assert.equal(res.layer, 'reach')
417
+ assert.equal(res.message, currentT('result.sshTestUnreachable', { host: '127.0.0.1', port: String(port), code }))
418
+ assert.ok(res.message.includes('127.0.0.1'))
419
+ assert.ok(res.message.includes(String(port)))
420
+ assert.equal(specs.length, 0, 'an unreachable bastion is answered before dbhub is spawned')
421
+ })
422
+
423
+ test('probeSshTunnel: key auth checks the local private-key file (`key`)', async () => {
424
+ const server = await listenOnce()
425
+ const keyDir = mkdtempSync(join(tmpdir(), 'dsh-dbhub-nokey-'))
426
+ try {
427
+ // The bastion port is REACHABLE, so this must get past step 1; the key file
428
+ // deliberately does not exist under the temp dir (never a real user path).
429
+ const keyPath = join(keyDir, 'id_ed25519')
430
+ const { subprocess, specs } = recorder()
431
+ const res = await adhoc.probeSshTunnel(subprocess, { host: '127.0.0.1', port: server.port, user: 'ops', auth: 'key', keyPath })
432
+
433
+ assert.equal(res.ok, false)
434
+ assert.equal(res.layer, 'key')
435
+ assert.equal(res.message, currentT('result.sshTestKeyMissing', { path: keyPath }))
436
+ assert.ok(res.message.includes('id_ed25519'))
437
+ assert.equal(specs.length, 0, 'a missing key is reported without spawning dbhub')
438
+ } finally {
439
+ rmSync(keyDir, { recursive: true, force: true })
440
+ await server.close()
441
+ }
442
+ })
443
+
444
+ test('probeSshTunnel: a blank key path falls back to the placeholder default', async () => {
445
+ // The field's placeholder promises `~/.ssh/id_ed25519`; a key-auth block with no
446
+ // path must therefore TRY that file instead of being rejected as incomplete.
447
+ const server = await listenOnce()
448
+ try {
449
+ const { subprocess, specs } = recorder({ stderr: 'Error: All configured authentication methods failed' })
450
+ const res = await adhoc.probeSshTunnel(subprocess, { host: '127.0.0.1', port: server.port, user: 'ops', auth: 'key' })
451
+
452
+ // Either the default file is missing on this machine (reported as `key` with
453
+ // the default path named) or it exists and dbhub is really attempted.
454
+ const defaultExists = existsSync(cfg.expandHome(cfg.DEFAULT_SSH_KEY_PATH))
455
+ if (defaultExists) {
456
+ assert.equal(res.layer, 'ssh', res.message)
457
+ assert.equal(specs.length, 1, 'the default key got as far as a real dbhub attempt')
458
+ } else {
459
+ assert.equal(res.layer, 'key', res.message)
460
+ assert.equal(specs.length, 0)
461
+ }
462
+ assert.ok(res.message.includes(cfg.DEFAULT_SSH_KEY_PATH), 'the path that was tried is named: ' + res.message)
463
+ // …and the report says the default was used, so nobody hunts for a typo.
464
+ assert.ok(res.message.includes(currentT('result.sshTestDefaultKey', { path: cfg.DEFAULT_SSH_KEY_PATH })), res.message)
465
+ // The command layer merges BEFORE the probe, so the note must survive that:
466
+ // `defaultKeyNoteOf(what the card sent, what will be tried)`.
467
+ const note = currentT('result.sshTestDefaultKey', { path: cfg.DEFAULT_SSH_KEY_PATH })
468
+ assert.equal(adhoc.defaultKeyNoteOf({ auth: 'key' }, { auth: 'key', keyPath: cfg.DEFAULT_SSH_KEY_PATH }), note)
469
+ assert.equal(adhoc.defaultKeyNoteOf(null, { auth: 'key', keyPath: cfg.DEFAULT_SSH_KEY_PATH }), note)
470
+ // A named or stored path is never announced as a fallback.
471
+ assert.equal(adhoc.defaultKeyNoteOf({ auth: 'key', keyPath: '~/.ssh/other' }, { auth: 'key', keyPath: '~/.ssh/other' }), '')
472
+ assert.equal(adhoc.defaultKeyNoteOf({ auth: 'key' }, { auth: 'key', keyPath: '~/.ssh/stored' }), '')
473
+ assert.equal(adhoc.defaultKeyNoteOf({ auth: 'password', password: 'x' }, { auth: 'password', password: 'x' }), '')
474
+ } finally {
475
+ await server.close()
476
+ }
477
+ })
478
+
479
+ test('probeSshTunnel: an SSH signature from dbhub is `ssh`', async () => {
480
+ const server = await listenOnce()
481
+ try {
482
+ const signature = 'Error: All configured authentication methods failed'
483
+ const { subprocess, specs } = recorder({ stderr: signature })
484
+ const res = await adhoc.probeSshTunnel(subprocess, {
485
+ host: '127.0.0.1', port: server.port, user: 'ops', auth: 'password', password: 'CHANGE_ME',
486
+ })
487
+
488
+ assert.equal(specs.length, 1, 'the SSH failure came from a spawned dbhub')
489
+ assert.equal(specs[0].argv.join(' ').includes('--config'), true, 'a tunnel always uses the one-shot TOML shape')
490
+ assert.equal(specs[0].env.DSH_DBHUB_SEC_SSH_PASSWORD, 'CHANGE_ME')
491
+ assert.equal(res.ok, false)
492
+ assert.equal(res.layer, 'ssh')
493
+ const prefix = currentT('result.sshTestAuthFail', { host: '127.0.0.1', port: String(server.port), msg: '' })
494
+ assert.ok(res.message.startsWith(prefix), res.message)
495
+ assert.ok(res.message.includes(signature), 'the dbhub tail is kept for the user')
496
+ assert.equal(res.message.includes('CHANGE_ME'), false, 'no tunnel secret in the report')
497
+ } finally {
498
+ await server.close()
499
+ }
500
+ })
501
+
502
+ test('probeSshTunnel: a database-step failure proves the tunnel (`tunnel`)', async () => {
503
+ const server = await listenOnce()
504
+ try {
505
+ const { subprocess, specs } = recorder({ stderr: 'failed to connect: connect ECONNREFUSED 127.0.0.1:1' })
506
+ const res = await adhoc.probeSshTunnel(subprocess, {
507
+ host: '127.0.0.1', port: server.port, user: 'ops', auth: 'password', password: 'CHANGE_ME',
508
+ })
509
+
510
+ assert.equal(specs.length, 1)
511
+ assert.equal(res.ok, true, 'the probe proves the SSH layer, never the database behind it')
512
+ assert.equal(res.layer, 'tunnel')
513
+ assert.equal(res.message, currentT('result.sshTestOk', { host: '127.0.0.1', port: String(server.port) }))
514
+ } finally {
515
+ await server.close()
516
+ }
517
+ })
518
+
519
+ test('probeSshTunnel: a DATABASE-protocol failure with no target named is still `tunnel`', async () => {
520
+ // Regression for a real user report: the tunnel opened, the forwarded target
521
+ // closed the connection, and dbhub said only
522
+ // `Connection lost … PROTOCOL_CONNECTION_LOST` + `SSH tunnel closed` — never the
523
+ // forwarded `127.0.0.1:1`. The old rule answered "cannot classify", which told
524
+ // the user nothing even though the SSH layer was demonstrably fine.
525
+ const server = await listenOnce()
526
+ try {
527
+ const { subprocess } = recorder({ stderr: DBHUB_TUNNEL_UP_CAPTURE })
528
+ const res = await adhoc.probeSshTunnel(subprocess, {
529
+ host: '127.0.0.1', port: server.port, user: 'ops', auth: 'password', password: 'CHANGE_ME',
530
+ })
531
+
532
+ assert.equal(res.ok, true, res.message)
533
+ assert.equal(res.layer, 'tunnel')
534
+ assert.ok(res.message.includes('SSH 隧道可用'), res.message)
535
+ } finally {
536
+ await server.close()
537
+ }
538
+ })
539
+
540
+ test('probeSshTunnel: an unclassifiable failure is `unknown`, never guessed', async () => {
541
+ const server = await listenOnce()
542
+ try {
543
+ // No SSH signature and no mention of the forwarded `127.0.0.1:1`: nothing is
544
+ // proven either way, and the honest answer is "could not classify".
545
+ const { subprocess, specs } = recorder({ stderr: 'dbhub stopped for an unknown reason' })
546
+ const res = await adhoc.probeSshTunnel(subprocess, {
547
+ host: '127.0.0.1', port: server.port, user: 'ops', auth: 'password', password: 'CHANGE_ME',
548
+ })
549
+
550
+ assert.equal(specs.length, 1)
551
+ assert.equal(res.ok, false, 'neither the tunnel nor the database step is proven')
552
+ assert.equal(res.layer, 'unknown')
553
+ const prefix = currentT('result.sshTestUnknown', { host: '127.0.0.1', port: String(server.port), msg: '' })
554
+ assert.ok(res.message.startsWith(prefix), res.message)
555
+ assert.ok(res.message.includes('dbhub stopped for an unknown reason'), 'the raw tail is included')
556
+ } finally {
557
+ await server.close()
558
+ }
559
+ })
560
+
561
+ test('probeSshTunnel: a plugin-side spawn failure is `internal`', async () => {
562
+ const server = await listenOnce()
563
+ try {
564
+ const { subprocess, specs } = recorder({ spawnThrows: 'spawn refused' })
565
+ const res = await adhoc.probeSshTunnel(subprocess, {
566
+ host: '127.0.0.1', port: server.port, user: 'ops', auth: 'password', password: 'CHANGE_ME',
567
+ })
568
+
569
+ assert.equal(specs.length, 1, 'the spawn was attempted and refused')
570
+ assert.equal(res.ok, false)
571
+ assert.equal(res.layer, 'internal')
572
+ assert.ok(res.message.includes(currentT('result.sshTestInternal', { msg: '' })), res.message)
573
+ assert.ok(res.message.includes(PLUGIN_FAIL + ': spawn refused'), res.message)
574
+ } finally {
575
+ await server.close()
576
+ }
577
+ })
578
+
579
+ // ── mergeSshOptions: "blank secret = keep the stored value" ────────────────
580
+
581
+ test('mergeSshOptions keeps the stored password when the form sends a blank one', () => {
582
+ // The card cannot echo a secret back, so a user editing only the host — or
583
+ // pressing "test the tunnel" on a saved row — sends no/blank password.
584
+ const blank = cfg.mergeSshOptions({ host: '127.0.0.1', user: 'ops', auth: 'password', password: '' }, STORED_PASSWORD)
585
+ assert.equal(blank.host, '127.0.0.1', 'non-secret fields overwrite')
586
+ assert.equal(blank.port, 22, 'a blank port falls back to the stored one')
587
+ assert.equal(blank.user, 'ops')
588
+ assert.equal(blank.password, 'CHANGE_ME', 'the stored secret is kept')
589
+
590
+ const omitted = cfg.mergeSshOptions({ host: '127.0.0.1', user: 'ops', auth: 'password' }, STORED_PASSWORD)
591
+ assert.equal(omitted.password, 'CHANGE_ME', 'an omitted secret key behaves like a blank one')
592
+
593
+ // Whatever the merge produced must still be a usable block.
594
+ assert.deepEqual(cfg.normalizeSshOptions(blank), {
595
+ host: '127.0.0.1', port: 22, user: 'ops', auth: 'password', password: 'CHANGE_ME',
596
+ })
597
+ })
598
+
599
+ test('mergeSshOptions falls back to the stored value for every blank non-secret field', () => {
600
+ const merged = cfg.mergeSshOptions({ host: '', port: '', user: '', auth: 'password' }, STORED_PASSWORD)
601
+ assert.equal(merged.host, STORED_PASSWORD.host)
602
+ assert.equal(merged.port, STORED_PASSWORD.port)
603
+ assert.equal(merged.user, STORED_PASSWORD.user)
604
+ assert.equal(merged.password, 'CHANGE_ME')
605
+ assert.deepEqual(cfg.normalizeSshOptions(merged), cfg.normalizeSshOptions(STORED_PASSWORD))
606
+ })
607
+
608
+ test('mergeSshOptions drops the other kind\'s secret when the auth kind switches', () => {
609
+ // key → password: the passphrase AND the key path go away.
610
+ const toPassword = cfg.mergeSshOptions(
611
+ { host: '192.0.2.10', user: 'ops', auth: 'password', password: 'CHANGE_ME' },
612
+ STORED_KEY,
613
+ )
614
+ assert.equal('keyPath' in toPassword, false, 'the key path must not survive the switch')
615
+ assert.equal('passphrase' in toPassword, false, 'the passphrase must not survive the switch')
616
+ assert.equal(toPassword.password, 'CHANGE_ME')
617
+ assert.deepEqual(cfg.normalizeSshOptions(toPassword), {
618
+ host: '192.0.2.10', port: 2222, user: 'ops', auth: 'password', password: 'CHANGE_ME',
619
+ })
620
+
621
+ // password → key: the password goes away, and the new key path is required.
622
+ const toKey = cfg.mergeSshOptions(
623
+ { host: '192.0.2.10', user: 'ops', auth: 'key', keyPath: '~/.ssh/id_ed25519' },
624
+ STORED_PASSWORD,
625
+ )
626
+ assert.equal('password' in toKey, false, 'the password must not survive the switch')
627
+ assert.equal(toKey.keyPath, '~/.ssh/id_ed25519')
628
+ assert.equal('passphrase' in toKey, false, 'no passphrase was given, none is invented')
629
+ assert.deepEqual(cfg.normalizeSshOptions(toKey), {
630
+ host: '192.0.2.10', port: 22, user: 'ops', auth: 'key', keyPath: '~/.ssh/id_ed25519',
631
+ })
632
+
633
+ // The same kind keeps its secret; a switch with nothing stored invents none.
634
+ const sameKind = cfg.mergeSshOptions({ host: '192.0.2.10', user: 'ops', auth: 'key' }, STORED_KEY)
635
+ assert.equal(sameKind.keyPath, '~/.ssh/id_ed25519')
636
+ assert.equal(sameKind.passphrase, 'CHANGE_ME')
637
+ assert.equal('password' in cfg.mergeSshOptions({ host: '192.0.2.10', user: 'ops', auth: 'password' }, null), false)
638
+ assert.equal(cfg.normalizeSshOptions(cfg.mergeSshOptions({ host: '192.0.2.10', user: 'ops', auth: 'password' }, null)), null)
639
+ })
640
+
641
+ test('mergeSshOptions passes a non-object through and keeps a single ProxyJump hop', () => {
642
+ assert.equal(cfg.mergeSshOptions(null, STORED_PASSWORD), null)
643
+ assert.equal(cfg.mergeSshOptions('nope', STORED_PASSWORD), 'nope')
644
+ assert.deepEqual(cfg.mergeSshOptions([], STORED_PASSWORD), [])
645
+
646
+ const jumped = cfg.mergeSshOptions(
647
+ { host: '192.0.2.10', user: 'ops', auth: 'password', proxyJump: 'bastion.example.com:22' },
648
+ STORED_PASSWORD,
649
+ )
650
+ assert.equal(jumped.proxyJump, 'bastion.example.com:22')
651
+ assert.equal(cfg.normalizeSshOptions(jumped).proxyJump, 'bastion.example.com:22')
652
+ })
653
+
654
+ test('normalizeSshOptions: defaults, control-character stripping and hard rejects', () => {
655
+ const base = { host: '127.0.0.1', user: 'ops', auth: 'password', password: 'p' }
656
+ assert.deepEqual(cfg.normalizeSshOptions(base), { host: '127.0.0.1', port: 22, user: 'ops', auth: 'password', password: 'p' })
657
+ assert.equal(cfg.normalizeSshOptions({ ...base, port: 'abc' }).port, 22, 'a non-numeric port falls back to 22')
658
+ assert.equal(cfg.normalizeSshOptions({ ...base, port: 70000 }).port, 22, 'an out-of-range port falls back to 22')
659
+ assert.equal(cfg.normalizeSshOptions({ ...base, port: '2222' }).port, 2222)
660
+ assert.equal(
661
+ cfg.normalizeSshOptions({ ...base, host: '127.0.0.1\u0000\u001b', user: 'o\u0007ps' }).user,
662
+ 'ops',
663
+ 'control characters are stripped, not stored',
664
+ )
665
+
666
+ for (const bad of [
667
+ null,
668
+ undefined,
669
+ 'nope',
670
+ [],
671
+ { user: 'ops', auth: 'password', password: 'p' },
672
+ { host: '127.0.0.1', auth: 'password', password: 'p' },
673
+ { host: '127.0.0.1', user: 'ops', auth: 'agent', password: 'p' },
674
+ { host: '127.0.0.1', user: 'ops', auth: 'password' },
675
+ { host: '127.0.0.1', user: 'ops', auth: 'key' },
676
+ ]) {
677
+ assert.equal(cfg.normalizeSshOptions(bad), null, JSON.stringify(bad))
678
+ }
679
+ })
680
+
681
+ // ── scrubSecrets ───────────────────────────────────────────────────────────
682
+
683
+ test('scrubSecrets collapses the whole userinfo of a URL-shaped connection string', () => {
684
+ const out = cfg.scrubSecrets('mysql://root:secret.pw@127.0.0.1:3306/app', 'mysql://root:secret.pw@127.0.0.1:3306/app')
685
+ assert.equal(out, 'mysql://****:****@127.0.0.1:3306/app')
686
+ assert.equal(out.includes('root'), false, 'the user name is a secret too')
687
+ assert.equal(out.includes('secret.pw'), false)
688
+ assert.ok(out.includes('://****:****@'))
689
+
690
+ // dbhub's own banner masks only the password (`mysql://root:****@host:3306/db`)
691
+ // — that exact text is what lands in a connection-test diagnostic.
692
+ const banner = cfg.scrubSecrets('Configuration source: mysql://root:****@127.0.0.1:3306/app', undefined)
693
+ assert.equal(banner, 'Configuration source: mysql://****:****@127.0.0.1:3306/app')
694
+ assert.equal(banner.includes('root'), false)
695
+
696
+ // The measured capture: dbhub masks the password itself (eight stars) but
697
+ // prints the user name, so the whole userinfo has to collapse. The DATABASE
698
+ // name is metadata and legitimately survives.
699
+ const scrubbed = cfg.scrubSecrets(DBHUB_SSH_CAPTURE, adhoc.SSH_PROBE_DSN)
700
+ assert.ok(scrubbed.includes(' - default: mysql://****:****@127.0.0.1:1/probe'), scrubbed)
701
+ assert.equal(/probe:/.test(scrubbed), false, 'the user name is gone (the database name stays)')
702
+ assert.ok(scrubbed.includes('SSH connection error'), 'the reason is untouched')
703
+ })
704
+
705
+ test('scrubSecrets strips a bare userinfo and leaves a metadata label alone', () => {
706
+ const bare = cfg.scrubSecrets('mysql://ops@127.0.0.1:3306/app', undefined)
707
+ assert.equal(bare, 'mysql://****:****@127.0.0.1:3306/app')
708
+ assert.equal(bare.includes('ops'), false)
709
+
710
+ // A DSN without userinfo is a plain metadata label: it must pass through
711
+ // byte for byte (it is exactly the `connLabel` the model already sees).
712
+ const label = 'mysql://127.0.0.1:3306/app'
713
+ assert.equal(cfg.scrubSecrets(label, label), label)
714
+ assert.equal(cfg.scrubSecrets(label, undefined), label)
715
+ assert.equal(cfg.scrubSecrets('postgres://127.0.0.1:5432/app', undefined), 'postgres://127.0.0.1:5432/app')
716
+ })
717
+
718
+ test('scrubSecrets still masks password tokens and every extra secret', () => {
719
+ assert.equal(cfg.scrubSecrets('password=secret.pw and passwd:CHANGE_ME', undefined), 'password=**** and passwd:****')
720
+ assert.equal(cfg.scrubSecrets('pwd=CHANGE_ME', undefined), 'pwd=****')
721
+ assert.equal(cfg.scrubSecrets('mysql://root:secret.pw@127.0.0.1:3306/app', undefined), 'mysql://****:****@127.0.0.1:3306/app')
722
+
723
+ // extraSecrets carries what a generated TOML put in the child environment
724
+ // (the SSH login password / key passphrase).
725
+ const text = 'tunnel login used CHANGE_ME for user ops'
726
+ assert.equal(cfg.scrubSecrets(text, undefined, ['CHANGE_ME']), 'tunnel login used **** for user ops')
727
+ assert.equal(cfg.scrubSecrets(text, undefined, ['', null, undefined]), text, 'empty extras are ignored')
728
+ })
729
+
730
+ // ── the handleTestOp SSH branch (indirect: it is not exported) ──────────────
731
+
732
+ /**
733
+ * The bare minimum `apply(ctx, config)` needs to wire BOTH channels: a tool
734
+ * registry, a workspace registry, one `inject(['settings'])` scope (the legacy
735
+ * namespace) and one `inject(['connection'])` scope whose `fetch.register`
736
+ * records the HTTP routes.
737
+ */
738
+ function hostCtx({ subprocess, workspaces }) {
739
+ const tools = []
740
+ const routes = []
741
+ const settings = {
742
+ register: () => ({ replace: async () => {}, watch: () => () => {} }),
743
+ describe: () => [],
744
+ }
745
+ const scopeFor = (names) => {
746
+ const scope = { effect: (fn) => { try { fn() } catch (e) { /* contained */ } return () => {} } }
747
+ if (names.includes('settings')) scope.settings = settings
748
+ if (names.includes('connection')) scope.connection = { fetch: { register: (route) => { routes.push(route) } } }
749
+ return scope
750
+ }
751
+ const ctx = {
752
+ tools: { register: (def) => { tools.push(def); return () => {} } },
753
+ get(name) {
754
+ if (name === 'subprocess') return subprocess
755
+ if (name === 'workspaceRegistry') return { list: async () => workspaces }
756
+ if (name === 'fs') return { resolve: async () => { throw new Error('no .env') } }
757
+ return undefined
758
+ },
759
+ inject: (names, callback) => { callback(scopeFor(names)); return () => {} },
760
+ effect: (fn) => { try { fn() } catch (e) { /* contained */ } return () => {} },
761
+ on: () => {},
762
+ }
763
+ return { ctx, tools, routes }
764
+ }
765
+
766
+ /** POST one card command through the bridge route and decode the reply. */
767
+ async function postOp(route, op) {
768
+ const response = await route.fetch(new Request('http://127.0.0.1' + BRIDGE_ROUTES.op, {
769
+ method: 'POST',
770
+ headers: { 'content-type': 'application/json' },
771
+ body: JSON.stringify({ op }),
772
+ }))
773
+ assert.equal(response.status, 200)
774
+ return response.json()
775
+ }
776
+
777
+ test('the {op:"test", kind:"ssh"} branch merges the stored secret and reports via testResult', async () => {
778
+ const WS = 'D:/work/probe'
779
+ assert.equal(typeof index.handleTestOp, 'undefined', 'handleTestOp is internal — this is the indirect path')
780
+
781
+ // The saved row carries the secret; the "form" echoes no password back.
782
+ const port = await closedPort()
783
+ cfg.setWorkspaceEnv(WS, 'default', DSN, 'user')
784
+ cfg.setWorkspaceEnv(WS, 'tunnel', DSN, 'user', {
785
+ ssh: { host: '127.0.0.1', port, user: 'ops', auth: 'password', password: 'CHANGE_ME' },
786
+ })
787
+ cfg.setWorkspaceEnv(WS, 'broken', DSN, 'user')
788
+
789
+ const { subprocess } = recorder()
790
+ const { ctx, tools, routes } = hostCtx({ subprocess, workspaces: [{ path: WS, title: 'probe' }] })
791
+ await index.apply(ctx, undefined)
792
+ assert.equal(tools.length, 4, 'apply still registers the four constant tools')
793
+ assert.equal(routes.length, 4, 'apply still wires the four bridge routes')
794
+ const opRoute = routes.find((route) => route.path === BRIDGE_ROUTES.op)
795
+ assert.ok(opRoute, 'the /op route must be registered')
796
+
797
+ // (a) A saved row whose stored password is not echoed back: the merge makes
798
+ // the block usable, so the probe gets past `config` and reaches the network.
799
+ const first = await postOp(opRoute, {
800
+ op: 'test',
801
+ kind: 'ssh',
802
+ workspace: WS,
803
+ env: 'tunnel',
804
+ nonce: 'probe-1',
805
+ ssh: { host: '127.0.0.1', port, user: 'ops', auth: 'password' },
806
+ })
807
+ assert.deepEqual(first.patches, [], 'an SSH-layer test changes no rows')
808
+ const report = JSON.parse(first.value.testResult)
809
+ assert.equal(report.nonce, 'probe-1', 'the report is addressed to the nonce the card sent')
810
+ assert.equal(report.ok, false)
811
+ const code = (await adhoc.tcpReachable('127.0.0.1', port)).code
812
+ assert.equal(
813
+ report.message,
814
+ currentT('result.sshTestUnreachable', { host: '127.0.0.1', port: String(port), code }),
815
+ 'a `config` answer here would mean the stored secret was NOT merged',
816
+ )
817
+ assert.equal(report.message.includes('CHANGE_ME'), false, 'the report never echoes the secret')
818
+
819
+ // (b) An incomplete block with no saved ssh row: the branch reports the
820
+ // localized "incomplete configuration" answer (naming the missing fields)
821
+ // instead of probing. The merge turned `{host}` into `{host, port, user:'',
822
+ // auth:''}`, so what is missing is the user and the auth kind.
823
+ const second = await postOp(opRoute, {
824
+ op: 'test', kind: 'ssh', workspace: WS, env: 'broken', nonce: 'probe-2', ssh: { host: '127.0.0.1' },
825
+ })
826
+ const broken = JSON.parse(second.value.testResult)
827
+ assert.equal(broken.nonce, 'probe-2')
828
+ assert.equal(broken.ok, false)
829
+ assert.equal(
830
+ broken.message,
831
+ currentT('result.sshTestBadConfig', { missing: currentT('field.user') + '、' + currentT('field.authKind') }),
832
+ )
833
+ assert.deepEqual(second.patches, [])
834
+ })