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.
- package/AGENTS.md +95 -209
- package/README.en.md +251 -169
- package/README.md +251 -169
- package/doc/ARCHITECTURE.md +108 -0
- package/doc/CLIENT.md +124 -0
- package/doc/DESENSITIZE.md +56 -0
- package/doc/DEVELOPMENT.md +139 -0
- package/doc/IMPACT-MATRIX.md +36 -0
- package/doc/RELEASE.md +93 -0
- package/doc/REQUIREMENTS.md +177 -105
- package/doc/images/chat-query.png +0 -0
- package/doc/images/row-editor.png +0 -0
- package/doc/images/settings-page.png +0 -0
- package/lib/adhoc.mjs +412 -34
- package/lib/capability.mjs +157 -0
- package/lib/client.js +1243 -80
- package/lib/config.mjs +370 -21
- package/lib/i18n.mjs +119 -4
- package/lib/index.mjs +401 -43
- package/lib/mcp.mjs +496 -391
- package/lib/meta.mjs +267 -0
- package/lib/options.mjs +25 -5
- package/lib/policy.mjs +92 -0
- package/lib/toml.mjs +188 -0
- package/lib/tools.mjs +1093 -827
- package/lib/update.mjs +244 -0
- package/package.json +34 -10
- package/scripts/desensitize-check.mjs +10 -3
- package/scripts/desensitize.config.mjs +255 -237
- package/scripts/docs-check.mjs +214 -0
- package/scripts/release-preflight.mjs +180 -0
- package/scripts/render-check.mjs +1366 -843
- package/test/adhoc-toml.test.mjs +365 -0
- package/test/bridge.test.mjs +227 -185
- package/test/capability.test.mjs +231 -0
- package/test/client-format.test.mjs +515 -352
- package/test/configure-options.test.mjs +244 -0
- package/test/configure-ssh.test.mjs +232 -0
- package/test/copy-from.test.mjs +155 -0
- package/test/env-options.test.mjs +456 -0
- package/test/meta.test.mjs +234 -0
- package/test/options.test.mjs +160 -123
- package/test/policy.test.mjs +132 -0
- package/test/row-cache.test.mjs +237 -138
- package/test/ssh-probe.test.mjs +834 -0
- package/test/test-op-fastpath.test.mjs +208 -0
- package/test/toml.test.mjs +352 -0
- package/test/update.test.mjs +270 -0
- package/test/util.test.mjs +17 -0
- package/test/zero-knowledge.test.mjs +120 -78
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
// dsh-dbhub-live: spawn contract of the ONE-SHOT ad-hoc executor (lib/adhoc.mjs).
|
|
2
|
+
//
|
|
3
|
+
// Every tool call spawns a disposable `dbhub --transport stdio …`, runs one MCP
|
|
4
|
+
// call and kills it. These tests pin the two spawn shapes decided per call:
|
|
5
|
+
// · plain connection → `--dsn <dsn>` (the DSN rides argv);
|
|
6
|
+
// · read-only and/or tunnel → a one-shot generated TOML (`--config <tmp>`)
|
|
7
|
+
// whose placeholders resolve from the CHILD's
|
|
8
|
+
// environment, never from the file.
|
|
9
|
+
// Also pinned: the temp-file lifecycle (written before the spawn, deleted after
|
|
10
|
+
// the call), the probe budget tiers (strictly below the client watchdogs in
|
|
11
|
+
// lib/client.js), and dbhub's read-only rejection classifier.
|
|
12
|
+
//
|
|
13
|
+
// The fake dbhub never answers MCP, so `runAdhoc` resolves `{ ok: false }` by
|
|
14
|
+
// design — the assertions are about the SPAWNED SPEC and the files, never about
|
|
15
|
+
// a dbhub reply. Test isolation: config.mjs derives the storage dir from
|
|
16
|
+
// DSH_HOME at import time, so point it at a temp dir BEFORE importing.
|
|
17
|
+
|
|
18
|
+
import test from 'node:test'
|
|
19
|
+
import assert from 'node:assert/strict'
|
|
20
|
+
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'
|
|
21
|
+
import { tmpdir } from 'node:os'
|
|
22
|
+
import { dirname, join } from 'node:path'
|
|
23
|
+
import { fileURLToPath } from 'node:url'
|
|
24
|
+
|
|
25
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
26
|
+
const home = mkdtempSync(join(tmpdir(), 'dsh-dbhub-adhoctoml-'))
|
|
27
|
+
process.env.DSH_HOME = home
|
|
28
|
+
// runtime.mjs prefers a mise-managed dbhub discovered through MISE_DATA_DIR;
|
|
29
|
+
// drop it so the fake executable is what every call resolves.
|
|
30
|
+
delete process.env.MISE_DATA_DIR
|
|
31
|
+
|
|
32
|
+
const adhoc = await import('../lib/adhoc.mjs')
|
|
33
|
+
const state = await import('../lib/state.mjs')
|
|
34
|
+
const { disabledMessage } = await import('../lib/mcp.mjs')
|
|
35
|
+
const { DATA_DIR } = await import('../lib/config.mjs')
|
|
36
|
+
const { TMP_DIR } = await import('../lib/toml.mjs')
|
|
37
|
+
|
|
38
|
+
// A fresh DSH_HOME has no credentials.json, so state.mjs starts enabled; make
|
|
39
|
+
// that explicit so every spawn test below really reaches the spawn.
|
|
40
|
+
if (!state.isEnabled()) state.setEnabled(true)
|
|
41
|
+
|
|
42
|
+
test.after(() => {
|
|
43
|
+
rmSync(home, { recursive: true, force: true })
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
// ── fixtures (loopback / RFC 5737 hosts and CHANGE_ME placeholders only) ────
|
|
47
|
+
|
|
48
|
+
const DSN = 'mysql://user:CHANGE_ME@127.0.0.1:3306/app'
|
|
49
|
+
const SSH_PASSWORD = { host: '192.0.2.10', port: 22, user: 'ops', auth: 'password', password: 'CHANGE_ME' }
|
|
50
|
+
const SSH_KEY = { host: '192.0.2.11', port: 2222, user: 'ops', auth: 'key', keyPath: '~/.ssh/id_ed25519', passphrase: 'CHANGE_ME' }
|
|
51
|
+
|
|
52
|
+
// ── fake host services ─────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A recording stand-in for the host `subprocess` service. It never runs a real
|
|
56
|
+
* binary: `spawn` records the exact spec `runAdhoc` passed and returns a handle
|
|
57
|
+
* shaped like the real one (`createMcpClient` needs stdout.on + done; `runAdhoc`
|
|
58
|
+
* awaits waitForExit). The child never answers, so `runAdhoc` settles
|
|
59
|
+
* `{ ok: false }`.
|
|
60
|
+
*/
|
|
61
|
+
function recorder(opts = {}) {
|
|
62
|
+
const specs = []
|
|
63
|
+
const errors = []
|
|
64
|
+
const exeFor = opts.exeFor || ((name) => '/fake/bin/' + name)
|
|
65
|
+
return {
|
|
66
|
+
specs,
|
|
67
|
+
errors,
|
|
68
|
+
subprocess: {
|
|
69
|
+
resolveExecutable: async (name) => exeFor(name),
|
|
70
|
+
spawn(spec) {
|
|
71
|
+
specs.push(spec)
|
|
72
|
+
const handle = {
|
|
73
|
+
stdin: { write() {} },
|
|
74
|
+
stdout: { on() {} },
|
|
75
|
+
stderr: { on() {} },
|
|
76
|
+
done: Promise.resolve({ exitCode: 0 }),
|
|
77
|
+
terminate() {},
|
|
78
|
+
waitForExit: async () => ({ exitCode: 0 }),
|
|
79
|
+
}
|
|
80
|
+
if (opts.onSpawn) {
|
|
81
|
+
// An assertion thrown here would be swallowed by runAdhoc's own spawn
|
|
82
|
+
// try/catch (it turns any throw into "启动临时 dbhub 失败"), so record
|
|
83
|
+
// it and re-assert after the call has settled.
|
|
84
|
+
try {
|
|
85
|
+
opts.onSpawn(spec, specs.length - 1)
|
|
86
|
+
} catch (e) {
|
|
87
|
+
errors.push(e)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return handle
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The value following `flag` in a spawn argv (also handles the cmd.exe /c wrap). */
|
|
97
|
+
function argAfter(argv, flag) {
|
|
98
|
+
const at = argv.indexOf(flag)
|
|
99
|
+
if (at >= 0) return argv[at + 1]
|
|
100
|
+
const m = new RegExp(flag.replace(/-/g, '\\-') + '\\s+(?:"([^"]*)"|(\\S+))').exec(argv.join(' '))
|
|
101
|
+
return m ? (m[1] !== undefined ? m[1] : m[2]) : undefined
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const generatedTomlLeftovers = () =>
|
|
105
|
+
(existsSync(TMP_DIR) ? readdirSync(TMP_DIR) : []).filter((n) => /^dbhub-.*\.toml$/.test(n))
|
|
106
|
+
|
|
107
|
+
// ── state guard ────────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
test('a fresh DSH_HOME starts enabled (runAdhoc refuses to run otherwise)', () => {
|
|
110
|
+
assert.equal(state.isEnabled(), true)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('runAdhoc refuses to spawn while the plugin is disabled', async () => {
|
|
114
|
+
state.setEnabled(false)
|
|
115
|
+
try {
|
|
116
|
+
const { subprocess, specs } = recorder()
|
|
117
|
+
const res = await adhoc.runAdhoc(subprocess, { dsn: DSN }, 'execute_sql', { sql: 'SELECT 1' }, {})
|
|
118
|
+
assert.equal(res.ok, false)
|
|
119
|
+
assert.equal(res.text, disabledMessage())
|
|
120
|
+
assert.equal(specs.length, 0, 'nothing is spawned while the plugin is disabled')
|
|
121
|
+
} finally {
|
|
122
|
+
state.setEnabled(true)
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
// ── shape 1: plain connection → `--dsn` ────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
test('a plain connection spawns `--dsn <dsn>` and never a generated config', async () => {
|
|
129
|
+
const { subprocess, specs, errors } = recorder()
|
|
130
|
+
const res = await adhoc.runAdhoc(subprocess, { dsn: DSN, ro: false, ssh: null }, 'execute_sql', { sql: 'SELECT 1' }, {})
|
|
131
|
+
assert.deepEqual(errors, [])
|
|
132
|
+
assert.equal(specs.length, 1)
|
|
133
|
+
const spec = specs[0]
|
|
134
|
+
assert.ok(spec.argv.includes('--dsn'), `argv must carry --dsn: ${spec.argv.join(' ')}`)
|
|
135
|
+
assert.ok(spec.argv.includes(DSN), 'the real DSN rides argv on the plain path')
|
|
136
|
+
assert.equal(spec.argv.join(' ').includes('--config'), false, '--dsn and --config are mutually exclusive')
|
|
137
|
+
assert.equal(spec.cwd, DATA_DIR)
|
|
138
|
+
assert.equal(spec.env, undefined, 'a plain call adds no child environment')
|
|
139
|
+
assert.equal(res.ok, false, 'the fake dbhub never answers MCP')
|
|
140
|
+
assert.equal(typeof res.text, 'string')
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test('a bare DSN string (no options) uses the same plain shape', async () => {
|
|
144
|
+
const { subprocess, specs } = recorder()
|
|
145
|
+
await adhoc.runAdhoc(subprocess, DSN, 'execute_sql', { sql: 'SELECT 1' }, {})
|
|
146
|
+
assert.ok(specs[0].argv.includes('--dsn'))
|
|
147
|
+
assert.ok(specs[0].argv.includes(DSN))
|
|
148
|
+
assert.equal(specs[0].env, undefined)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test('a .cmd shim is wrapped by buildSpawnArgv and still carries the DSN', async () => {
|
|
152
|
+
const { subprocess, specs } = recorder({ exeFor: (name) => 'C:/fake/bin/' + name + '.cmd' })
|
|
153
|
+
await adhoc.runAdhoc(subprocess, { dsn: DSN }, 'execute_sql', { sql: 'SELECT 1' }, {})
|
|
154
|
+
const spec = specs[0]
|
|
155
|
+
if (process.platform === 'win32') {
|
|
156
|
+
assert.equal(spec.argv[0], 'cmd.exe', 'a Windows shim needs a cmd shell')
|
|
157
|
+
assert.equal(spec.argv[1], '/c')
|
|
158
|
+
} else {
|
|
159
|
+
assert.equal(spec.argv[0], 'C:/fake/bin/dbhub.cmd', 'POSIX spawns the shim directly')
|
|
160
|
+
}
|
|
161
|
+
assert.ok(spec.argv.join(' ').includes('--dsn'))
|
|
162
|
+
assert.ok(spec.argv.join(' ').includes(DSN))
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
// ── shape 2: read-only / tunnel → generated TOML ───────────────────────────
|
|
166
|
+
|
|
167
|
+
test('a read-only connection generates a --config file that exists during the spawn', async () => {
|
|
168
|
+
let pathSeen = ''
|
|
169
|
+
let textDuringSpawn = ''
|
|
170
|
+
const { subprocess, specs, errors } = recorder({
|
|
171
|
+
onSpawn: (spec) => {
|
|
172
|
+
assert.equal(spec.argv.includes('--dsn'), false, '--dsn must not be combined with --config')
|
|
173
|
+
pathSeen = argAfter(spec.argv, '--config')
|
|
174
|
+
assert.ok(pathSeen, 'the read-only shape must use --config <path>')
|
|
175
|
+
assert.equal(existsSync(pathSeen), true, 'the generated config exists while dbhub starts')
|
|
176
|
+
textDuringSpawn = readFileSync(pathSeen, 'utf8')
|
|
177
|
+
},
|
|
178
|
+
})
|
|
179
|
+
const res = await adhoc.runAdhoc(
|
|
180
|
+
subprocess, { dsn: DSN, ro: true, ssh: null }, 'search_objects', { object_type: 'table' }, {},
|
|
181
|
+
)
|
|
182
|
+
assert.deepEqual(errors, [], 'all in-spawn assertions held')
|
|
183
|
+
|
|
184
|
+
// (a) the TOML replaces dbhub's default tool pair with BOTH tools: `[[tools]]`
|
|
185
|
+
// is a whitelist, so a missing search_objects would kill that tool entirely.
|
|
186
|
+
assert.equal((textDuringSpawn.match(/\[\[tools\]\]/g) || []).length, 2, 'exactly two [[tools]] sections')
|
|
187
|
+
assert.ok(textDuringSpawn.includes('name = "search_objects"'))
|
|
188
|
+
assert.ok(textDuringSpawn.includes('name = "execute_sql"'))
|
|
189
|
+
assert.ok(/readonly\s*=\s*true/.test(textDuringSpawn))
|
|
190
|
+
|
|
191
|
+
// (b) the file holds placeholders only — no DSN, no password.
|
|
192
|
+
assert.equal(textDuringSpawn.includes(DSN), false, 'the DSN must never be written to the file')
|
|
193
|
+
assert.equal(textDuringSpawn.includes('CHANGE_ME'), false, 'no password in the file')
|
|
194
|
+
assert.ok(textDuringSpawn.includes('${DSH_DBHUB_SEC_DSN}'), 'the DSN is a placeholder')
|
|
195
|
+
|
|
196
|
+
// The real value travels in the CHILD's environment instead.
|
|
197
|
+
assert.equal(specs[0].env.DSH_DBHUB_SEC_DSN, DSN)
|
|
198
|
+
|
|
199
|
+
// The `finally` cleanup ran: the one-shot file is gone when the call settles.
|
|
200
|
+
assert.ok(pathSeen)
|
|
201
|
+
assert.equal(existsSync(pathSeen), false, 'the generated config is deleted when the call settles')
|
|
202
|
+
assert.deepEqual(generatedTomlLeftovers(), [], 'no generated config is left behind')
|
|
203
|
+
assert.equal(res.ok, false, 'the fake dbhub never answers MCP')
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
test('a tunnelled connection carries its secrets in the child env, merged over process.env', async () => {
|
|
207
|
+
let textDuringSpawn = ''
|
|
208
|
+
const { subprocess, specs, errors } = recorder({
|
|
209
|
+
onSpawn: (spec) => {
|
|
210
|
+
const path = argAfter(spec.argv, '--config')
|
|
211
|
+
textDuringSpawn = readFileSync(path, 'utf8')
|
|
212
|
+
},
|
|
213
|
+
})
|
|
214
|
+
const res = await adhoc.runAdhoc(subprocess, { dsn: DSN, ro: false, ssh: SSH_PASSWORD }, 'execute_sql', { sql: 'SELECT 1' }, {})
|
|
215
|
+
assert.deepEqual(errors, [])
|
|
216
|
+
const spec = specs[0]
|
|
217
|
+
const env = spec.env
|
|
218
|
+
assert.ok(env, 'the tunnel shape must carry a child environment')
|
|
219
|
+
|
|
220
|
+
// The two secrets, with the REAL values, in the child's environment.
|
|
221
|
+
assert.equal(env.DSH_DBHUB_SEC_DSN, DSN)
|
|
222
|
+
assert.equal(env.DSH_DBHUB_SEC_SSH_PASSWORD, 'CHANGE_ME')
|
|
223
|
+
assert.equal('DSH_DBHUB_SEC_SSH_PASSPHRASE' in env, false, 'password auth needs no passphrase')
|
|
224
|
+
|
|
225
|
+
// Merged, not replaced: every ambient entry survives, and only the two secret
|
|
226
|
+
// names are added.
|
|
227
|
+
for (const key of Object.keys(process.env)) {
|
|
228
|
+
assert.equal(key in env, true, `the ambient ${key} must survive the merge`)
|
|
229
|
+
}
|
|
230
|
+
assert.deepEqual(
|
|
231
|
+
Object.keys(env).filter((k) => !(k in process.env)).sort(),
|
|
232
|
+
['DSH_DBHUB_SEC_DSN', 'DSH_DBHUB_SEC_SSH_PASSWORD'].sort(),
|
|
233
|
+
)
|
|
234
|
+
assert.equal(env.DSH_HOME, home)
|
|
235
|
+
|
|
236
|
+
// The tunnel fields themselves stay in the file; only the secret is a placeholder.
|
|
237
|
+
assert.ok(textDuringSpawn.includes('ssh_host = "192.0.2.10"'))
|
|
238
|
+
assert.ok(textDuringSpawn.includes('ssh_user = "ops"'))
|
|
239
|
+
assert.ok(textDuringSpawn.includes('${DSH_DBHUB_SEC_SSH_PASSWORD}'))
|
|
240
|
+
assert.equal(textDuringSpawn.includes('CHANGE_ME'), false, 'the tunnel password never lands in the file')
|
|
241
|
+
assert.equal(res.ok, false)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
test('key auth carries the passphrase (never a password variable)', async () => {
|
|
245
|
+
let textDuringSpawn = ''
|
|
246
|
+
const { subprocess, specs, errors } = recorder({
|
|
247
|
+
onSpawn: (spec) => {
|
|
248
|
+
textDuringSpawn = readFileSync(argAfter(spec.argv, '--config'), 'utf8')
|
|
249
|
+
},
|
|
250
|
+
})
|
|
251
|
+
await adhoc.runAdhoc(subprocess, { dsn: DSN, ssh: SSH_KEY }, 'execute_sql', { sql: 'SELECT 1' }, {})
|
|
252
|
+
assert.deepEqual(errors, [])
|
|
253
|
+
const env = specs[0].env
|
|
254
|
+
assert.equal(env.DSH_DBHUB_SEC_DSN, DSN)
|
|
255
|
+
assert.equal(env.DSH_DBHUB_SEC_SSH_PASSPHRASE, 'CHANGE_ME')
|
|
256
|
+
assert.equal('DSH_DBHUB_SEC_SSH_PASSWORD' in env, false)
|
|
257
|
+
// A key PATH is a location, not a secret: it stays a plain TOML value.
|
|
258
|
+
assert.ok(textDuringSpawn.includes('ssh_key = "~/.ssh/id_ed25519"'))
|
|
259
|
+
assert.ok(textDuringSpawn.includes('${DSH_DBHUB_SEC_SSH_PASSPHRASE}'))
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
// ── descriptor helpers ─────────────────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
test('normalizeConn/needsToml coerce a bare DSN and keep the descriptor shape', () => {
|
|
265
|
+
assert.deepEqual(adhoc.normalizeConn(' ' + DSN + ' '), { dsn: DSN, ro: false, ssh: null })
|
|
266
|
+
assert.deepEqual(adhoc.normalizeConn(undefined), { dsn: '', ro: false, ssh: null })
|
|
267
|
+
assert.deepEqual(adhoc.normalizeConn(null), { dsn: '', ro: false, ssh: null })
|
|
268
|
+
assert.deepEqual(adhoc.normalizeConn({ dsn: DSN, ro: 'yes' }), { dsn: DSN, ro: false, ssh: null }, 'ro is strict')
|
|
269
|
+
const ssh = { host: '192.0.2.10', user: 'ops', auth: 'password', password: 'CHANGE_ME' }
|
|
270
|
+
assert.deepEqual(adhoc.normalizeConn({ dsn: DSN, ro: true, ssh }), { dsn: DSN, ro: true, ssh })
|
|
271
|
+
assert.equal(adhoc.normalizeConn({ dsn: DSN, ssh: 'nope' }).ssh, null, 'a non-object ssh block is dropped')
|
|
272
|
+
|
|
273
|
+
assert.equal(adhoc.needsToml(DSN), false)
|
|
274
|
+
assert.equal(adhoc.needsToml({ dsn: DSN }), false)
|
|
275
|
+
assert.equal(adhoc.needsToml({ dsn: DSN, ro: true }), true)
|
|
276
|
+
assert.equal(adhoc.needsToml({ dsn: DSN, ssh }), true)
|
|
277
|
+
assert.equal(adhoc.needsToml(' ' + DSN + ' '), false)
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
test('secretsOf returns the tunnel secret values and nothing for a plain connection', () => {
|
|
281
|
+
assert.deepEqual(adhoc.secretsOf(DSN), [], 'a bare DSN carries no extra secret')
|
|
282
|
+
assert.deepEqual(adhoc.secretsOf({ dsn: DSN, ro: true }), [], 'read-only adds no secret')
|
|
283
|
+
assert.deepEqual(adhoc.secretsOf({ dsn: DSN, ssh: SSH_PASSWORD }), ['CHANGE_ME'])
|
|
284
|
+
assert.deepEqual(adhoc.secretsOf({ dsn: DSN, ssh: SSH_KEY }), ['CHANGE_ME'])
|
|
285
|
+
assert.deepEqual(adhoc.secretsOf({ dsn: DSN, ssh: { auth: 'key', keyPath: '~/.ssh/id_ed25519' } }), [], 'a key path is not a secret')
|
|
286
|
+
assert.deepEqual(
|
|
287
|
+
adhoc.secretsOf({ dsn: DSN, ssh: { password: 'CHANGE_ME', passphrase: 'CHANGE_ME' } }),
|
|
288
|
+
['CHANGE_ME', 'CHANGE_ME'],
|
|
289
|
+
'password first, then passphrase',
|
|
290
|
+
)
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
// ── probe budgets vs the client watchdogs ──────────────────────────────────
|
|
294
|
+
|
|
295
|
+
test('the host probe budgets stay strictly below the client watchdogs', () => {
|
|
296
|
+
assert.equal(adhoc.PROBE_TIMEOUT_MS, 25000)
|
|
297
|
+
assert.equal(adhoc.PROBE_TIMEOUT_SSH_MS, 45000)
|
|
298
|
+
assert.equal(adhoc.hostTimeoutFor(DSN), 25000, 'a bare DSN is a plain connection')
|
|
299
|
+
assert.equal(adhoc.hostTimeoutFor({ dsn: DSN }), 25000)
|
|
300
|
+
assert.equal(adhoc.hostTimeoutFor({ dsn: DSN, ro: true }), 25000, 'read-only alone does not need the SSH tier')
|
|
301
|
+
assert.equal(adhoc.hostTimeoutFor({ dsn: DSN, ssh: SSH_PASSWORD }), 45000)
|
|
302
|
+
assert.equal(adhoc.hostTimeoutFor({ dsn: DSN, ro: true, ssh: SSH_KEY }), 45000)
|
|
303
|
+
|
|
304
|
+
// Read the browser budget straight out of the handwritten bundle: the Host
|
|
305
|
+
// must answer before the client gives up on it.
|
|
306
|
+
const clientSrc = readFileSync(join(here, '..', 'lib', 'client.js'), 'utf8')
|
|
307
|
+
const watchdog = (name) => {
|
|
308
|
+
const m = new RegExp('\\b' + name + '\\s*=\\s*(\\d+)').exec(clientSrc)
|
|
309
|
+
assert.ok(m, `lib/client.js must declare ${name}`)
|
|
310
|
+
return Number(m[1])
|
|
311
|
+
}
|
|
312
|
+
const PRETEST_WATCHDOG_MS = watchdog('PRETEST_WATCHDOG_MS')
|
|
313
|
+
const PRETEST_WATCHDOG_SSH_MS = watchdog('PRETEST_WATCHDOG_SSH_MS')
|
|
314
|
+
const TEST_WATCHDOG_MS = watchdog('TEST_WATCHDOG_MS')
|
|
315
|
+
const TEST_WATCHDOG_SSH_MS = watchdog('TEST_WATCHDOG_SSH_MS')
|
|
316
|
+
assert.ok(adhoc.PROBE_TIMEOUT_MS < PRETEST_WATCHDOG_MS, `${adhoc.PROBE_TIMEOUT_MS} < ${PRETEST_WATCHDOG_MS}`)
|
|
317
|
+
assert.ok(adhoc.PROBE_TIMEOUT_MS < TEST_WATCHDOG_MS, `${adhoc.PROBE_TIMEOUT_MS} < ${TEST_WATCHDOG_MS}`)
|
|
318
|
+
assert.ok(adhoc.PROBE_TIMEOUT_SSH_MS < PRETEST_WATCHDOG_SSH_MS, `${adhoc.PROBE_TIMEOUT_SSH_MS} < ${PRETEST_WATCHDOG_SSH_MS}`)
|
|
319
|
+
assert.ok(adhoc.PROBE_TIMEOUT_SSH_MS < TEST_WATCHDOG_SSH_MS, `${adhoc.PROBE_TIMEOUT_SSH_MS} < ${TEST_WATCHDOG_SSH_MS}`)
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
test('probeConnection caps the probe per connection tier and reports a one-line failure', async () => {
|
|
323
|
+
// The cap reaches the spawned signal through AbortSignal.timeout; capture it.
|
|
324
|
+
const originalTimeout = AbortSignal.timeout
|
|
325
|
+
const caps = []
|
|
326
|
+
AbortSignal.timeout = (ms) => {
|
|
327
|
+
caps.push(ms)
|
|
328
|
+
return originalTimeout.call(AbortSignal, ms)
|
|
329
|
+
}
|
|
330
|
+
try {
|
|
331
|
+
const plain = recorder()
|
|
332
|
+
const plainRes = await adhoc.probeConnection(plain.subprocess, { dsn: DSN })
|
|
333
|
+
const tunnel = recorder()
|
|
334
|
+
const tunnelRes = await adhoc.probeConnection(tunnel.subprocess, { dsn: DSN, ssh: SSH_PASSWORD })
|
|
335
|
+
assert.deepEqual(plain.errors, [])
|
|
336
|
+
assert.deepEqual(tunnel.errors, [])
|
|
337
|
+
assert.deepEqual(caps, [adhoc.PROBE_TIMEOUT_MS, adhoc.PROBE_TIMEOUT_SSH_MS], 'each tier gets its own host cap')
|
|
338
|
+
assert.equal(tunnel.specs[0].argv.join(' ').includes('--config'), true, 'the tunnelled probe uses the TOML shape')
|
|
339
|
+
|
|
340
|
+
for (const res of [plainRes, tunnelRes]) {
|
|
341
|
+
assert.equal(res.ok, false, 'the fake dbhub never answers')
|
|
342
|
+
assert.equal(typeof res.message, 'string')
|
|
343
|
+
assert.ok(res.message.length > 0)
|
|
344
|
+
assert.equal(res.message.includes('\n'), false, 'the report is collapsed to one short line')
|
|
345
|
+
assert.ok(res.message.includes('dbhub exited'), 'the dbhub failure surfaces in the report')
|
|
346
|
+
assert.equal(res.message.includes('CHANGE_ME'), false, 'no secret in the report')
|
|
347
|
+
}
|
|
348
|
+
} finally {
|
|
349
|
+
AbortSignal.timeout = originalTimeout
|
|
350
|
+
}
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
// ── dbhub's read-only rejection classifier ─────────────────────────────────
|
|
354
|
+
|
|
355
|
+
test('readOnlyViolation matches dbhub\'s real refusal and not an ordinary auth error', () => {
|
|
356
|
+
assert.equal(adhoc.readOnlyViolation('READONLY_VIOLATION: write statements are not allowed here'), true)
|
|
357
|
+
assert.equal(adhoc.readOnlyViolation('Read-only mode is enabled for this source'), true)
|
|
358
|
+
assert.equal(adhoc.readOnlyViolation('error: readonly_violation'), true)
|
|
359
|
+
assert.equal(adhoc.readOnlyViolation('Access denied for user \'user\'@\'127.0.0.1\' (using password: YES)'), false)
|
|
360
|
+
assert.equal(adhoc.readOnlyViolation('ER_ACCESS_DENIED_ERROR: Access denied'), false)
|
|
361
|
+
assert.equal(adhoc.readOnlyViolation('ECONNREFUSED 127.0.0.1:3306'), false)
|
|
362
|
+
assert.equal(adhoc.readOnlyViolation(''), false)
|
|
363
|
+
assert.equal(adhoc.readOnlyViolation(undefined), false)
|
|
364
|
+
assert.equal(adhoc.readOnlyViolation(null), false)
|
|
365
|
+
})
|