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,208 @@
1
+ // dsh-dbhub-live: the F4 connection-test fast path (lib/mcp.mjs).
2
+ //
3
+ // A card connection test must not re-walk every workspace when the answer is
4
+ // already known: the store is authoritative for a PERSISTED row, and the live
5
+ // row cache holds exactly the row the card is showing. Only when neither tier
6
+ // knows the row may the caller pay for a real walk — and a walk is expensive,
7
+ // because it spawns `mise env` for every workspace without a persisted default.
8
+ //
9
+ // The three tiers (`resolveTestDsn`) are pure; `cachedRows`/`cachedRow`/
10
+ // `hasRowCache` are the module-level accessors the caller feeds it.
11
+ //
12
+ // Test isolation: config.mjs/mcp.mjs derive the storage dir from DSH_HOME at
13
+ // import time, so point it at a temp dir BEFORE importing.
14
+
15
+ import test from 'node:test'
16
+ import assert from 'node:assert/strict'
17
+ import { mkdtempSync, rmSync } from 'node:fs'
18
+ import { tmpdir } from 'node:os'
19
+ import { join } from 'node:path'
20
+
21
+ const home = mkdtempSync(join(tmpdir(), 'dsh-dbhub-testop-'))
22
+ process.env.DSH_HOME = home
23
+
24
+ const cfg = await import('../lib/config.mjs')
25
+ const mcp = await import('../lib/mcp.mjs')
26
+
27
+ test.after(() => {
28
+ rmSync(home, { recursive: true, force: true })
29
+ })
30
+
31
+ // ── fixtures ───────────────────────────────────────────────────────────────
32
+ //
33
+ // One logical row (workspace × 'prod') is described by all three tiers; a
34
+ // second workspace has NO persisted environment, so its row can only come from
35
+ // a real walk (auto-discovery) — that is the case tier 3 exists for.
36
+
37
+ const WS = 'D:/work/fast'
38
+ const WS_AUTO = 'D:/work/auto'
39
+ const DSN_DEFAULT = 'mysql://user:CHANGE_ME@127.0.0.1:3306/app'
40
+ const DSN_PROD = 'mysql://user:CHANGE_ME@127.0.0.1:3307/app'
41
+ const DSN_AUTO = 'mysql://user:CHANGE_ME@127.0.0.1:3308/auto'
42
+ const SSH = { host: '192.0.2.10', port: 22, user: 'ops', auth: 'password', password: 'CHANGE_ME' }
43
+
44
+ // Persist through the real store API, so tier 1 reads the same row shape
45
+ // index.mjs hands to resolveTestDsn. WS_AUTO deliberately stays unpersisted.
46
+ cfg.setWorkspaceEnv(WS, 'default', DSN_DEFAULT, 'user')
47
+ cfg.setWorkspaceEnv(WS, 'prod', DSN_PROD, 'user')
48
+ cfg.setWorkspaceEnvOptions(WS, 'prod', { readOnly: true, ssh: SSH })
49
+
50
+ /** The ctx mcp.collectSources needs: a workspace registry + a .env resolver. */
51
+ const ctx = {
52
+ get(name) {
53
+ if (name === 'workspaceRegistry') {
54
+ return { list: async () => [{ path: WS, title: 'fast' }, { path: WS_AUTO, title: 'auto' }] }
55
+ }
56
+ if (name === 'fs') return { resolve: async () => { throw new Error('no .env') } }
57
+ return undefined
58
+ },
59
+ }
60
+
61
+ /** A subprocess stand-in whose `mise env` answers with an auto-discovered DSN. */
62
+ const subprocess = {
63
+ resolveExecutable: async (name) => '/fake/bin/' + name,
64
+ spawn() {
65
+ return {
66
+ stdout: { on(_event, handler) { handler(Buffer.from('DSN=' + DSN_AUTO + '\n')) } },
67
+ done: Promise.resolve({ exitCode: 0 }),
68
+ terminate() {},
69
+ }
70
+ },
71
+ }
72
+
73
+ // ── tier 1: the persisted store row ────────────────────────────────────────
74
+
75
+ test('tier 1: a persisted store row wins, options included, with no walk', () => {
76
+ const storeRows = cfg.listWorkspaceEnvironments(cfg.store)
77
+ const prod = storeRows.find((r) => r.wsPath === WS && r.env === 'prod')
78
+ assert.ok(prod, 'the fixture row must be persisted')
79
+ assert.equal(prod.dsn, DSN_PROD)
80
+
81
+ const picked = mcp.resolveTestDsn({ wsPath: WS, env: 'prod', storeRows, cachedRows: [], hasCache: false })
82
+ assert.ok(picked, 'the store alone answers — the caller never has to walk')
83
+ assert.equal(picked.from, 'store')
84
+ assert.equal(picked.dsn, DSN_PROD)
85
+ assert.equal(picked.ro, true, 'the read-only flag travels with the row')
86
+ assert.equal(picked.ssh.auth, 'password')
87
+ assert.equal(picked.ssh.password, 'CHANGE_ME')
88
+ })
89
+
90
+ // ── tier 2: the cached row the card is showing ─────────────────────────────
91
+
92
+ test('tier 2: with no store row, the already-cached row answers', () => {
93
+ const cache = [{ wsPath: WS, env: 'prod', dsn: DSN_PROD, ro: false, ssh: null }]
94
+ const picked = mcp.resolveTestDsn({ wsPath: WS, env: 'prod', storeRows: [], cachedRows: cache, hasCache: true })
95
+ assert.ok(picked)
96
+ assert.equal(picked.from, 'cache')
97
+ assert.equal(picked.dsn, DSN_PROD, 'the same target tier 1 describes')
98
+ // A cold cache is no cache: a cached array alone must not short-circuit the
99
+ // walk (the caller passes hasCache = hasRowCache()).
100
+ assert.equal(
101
+ mcp.resolveTestDsn({ wsPath: WS, env: 'prod', storeRows: [], cachedRows: cache, hasCache: false }),
102
+ null,
103
+ 'an unwalked cache must not answer',
104
+ )
105
+ })
106
+
107
+ // ── tier 3: nothing known → the caller must walk ───────────────────────────
108
+
109
+ test('tier 3: nothing known returns null, then a real walk fills the cache', async () => {
110
+ // Neither the store (never persisted) nor any completed walk knows this row.
111
+ assert.equal(
112
+ mcp.resolveTestDsn({ wsPath: WS_AUTO, env: 'default', storeRows: [], cachedRows: [], hasCache: false }),
113
+ null,
114
+ 'the caller must walk before it can answer',
115
+ )
116
+
117
+ const { rows } = await mcp.collectSources(ctx, subprocess)
118
+ assert.equal(mcp.hasRowCache(), true, 'the walk completed and armed the cache')
119
+ assert.equal(rows.some((r) => r.wsPath === WS_AUTO && r.env === 'default' && r.dsn === DSN_AUTO), true)
120
+ assert.equal(rows.some((r) => r.wsPath === WS_AUTO && r.persisted === false), true, 'the auto row is not persisted')
121
+
122
+ // The same query, now fed from the module cache, resolves the same DSN.
123
+ const walked = mcp.resolveTestDsn({
124
+ wsPath: WS_AUTO, env: 'default', storeRows: [], cachedRows: mcp.cachedRows(), hasCache: mcp.hasRowCache(),
125
+ })
126
+ assert.ok(walked)
127
+ assert.equal(walked.from, 'cache')
128
+ assert.equal(walked.dsn, DSN_AUTO)
129
+
130
+ // …and the persisted logical row resolves from the cache to the SAME DSN the
131
+ // store tier returned above: all three tiers describe one target.
132
+ const prod = mcp.resolveTestDsn({
133
+ wsPath: WS, env: 'prod', storeRows: [], cachedRows: mcp.cachedRows(), hasCache: mcp.hasRowCache(),
134
+ })
135
+ assert.ok(prod)
136
+ assert.equal(prod.from, 'cache')
137
+ assert.equal(prod.dsn, DSN_PROD)
138
+
139
+ // The accessors describe the same row, with the env name normalized.
140
+ assert.equal(mcp.cachedRow(WS, ' prod ').dsn, DSN_PROD)
141
+ assert.equal(mcp.cachedRow('D:/work/other', 'prod'), undefined)
142
+ // cachedRows() hands out a copy: a caller cannot corrupt the cache.
143
+ const copy = mcp.cachedRows()
144
+ copy.push({ wsPath: 'D:/work/injected', env: 'default', dsn: DSN_DEFAULT })
145
+ assert.equal(mcp.cachedRows().some((r) => r.wsPath === 'D:/work/injected'), false)
146
+ })
147
+
148
+ // ── env-name normalization ─────────────────────────────────────────────────
149
+
150
+ test('environment names are normalized on both sides of the match', () => {
151
+ const storeRows = [{ wsPath: WS, env: 'prod', dsn: DSN_PROD, ro: false, ssh: null }]
152
+ const spaced = mcp.resolveTestDsn({ wsPath: WS, env: ' prod ', storeRows, cachedRows: [], hasCache: false })
153
+ assert.ok(spaced, 'a row stored as prod must match a query for " prod "')
154
+ assert.equal(spaced.from, 'store')
155
+ assert.equal(spaced.dsn, DSN_PROD)
156
+
157
+ // A blank name is the reserved 'default' environment (mirrors the Host).
158
+ const blank = mcp.resolveTestDsn({
159
+ wsPath: WS, env: ' ', storeRows: [{ wsPath: WS, env: 'default', dsn: DSN_DEFAULT }], cachedRows: [], hasCache: false,
160
+ })
161
+ assert.ok(blank)
162
+ assert.equal(blank.dsn, DSN_DEFAULT)
163
+
164
+ // A cached row stored with stray whitespace matches a clean query too.
165
+ const cached = mcp.resolveTestDsn({
166
+ wsPath: WS, env: 'prod', storeRows: [], cachedRows: [{ wsPath: WS, env: ' prod ', dsn: DSN_PROD }], hasCache: true,
167
+ })
168
+ assert.ok(cached)
169
+ assert.equal(cached.from, 'cache')
170
+ assert.equal(cached.dsn, DSN_PROD)
171
+
172
+ // A different workspace path is a different target — never a match.
173
+ assert.equal(
174
+ mcp.resolveTestDsn({ wsPath: 'D:/work/other', env: 'prod', storeRows, cachedRows: [], hasCache: false }),
175
+ null,
176
+ )
177
+ // A row without a DSN cannot be probed.
178
+ assert.equal(
179
+ mcp.resolveTestDsn({ wsPath: WS, env: 'prod', storeRows: [{ wsPath: WS, env: 'prod' }], cachedRows: [], hasCache: false }),
180
+ null,
181
+ )
182
+ })
183
+
184
+ // ── the cache is per module instance ───────────────────────────────────────
185
+
186
+ test('hasRowCache/cachedRows/cachedRow are empty in a fresh mcp.mjs instance', async () => {
187
+ await mcp.collectSources(ctx, subprocess)
188
+ assert.equal(mcp.hasRowCache(), true, 'the shared instance has walked')
189
+
190
+ // A query string yields a separate module instance with its own cache, while
191
+ // config.mjs (the store) stays shared — exactly the isolation the test needs.
192
+ const fresh = await import('../lib/mcp.mjs?fresh=' + Date.now())
193
+ assert.notEqual(fresh, mcp, 'the query string must load a distinct instance')
194
+ assert.equal(fresh.hasRowCache(), false, 'no walk has completed in the fresh instance')
195
+ assert.deepEqual(fresh.cachedRows(), [], 'the fresh cache is empty')
196
+ assert.equal(fresh.cachedRow(WS, 'prod'), undefined)
197
+ assert.equal(
198
+ fresh.resolveTestDsn({ wsPath: WS, env: 'prod', storeRows: [], cachedRows: fresh.cachedRows(), hasCache: fresh.hasRowCache() }),
199
+ null,
200
+ 'a cold instance cannot answer from its cache',
201
+ )
202
+ assert.ok(
203
+ fresh.resolveTestDsn({
204
+ wsPath: WS, env: 'prod', storeRows: cfg.listWorkspaceEnvironments(cfg.store), cachedRows: [], hasCache: false,
205
+ }),
206
+ 'the shared store still answers a fresh instance',
207
+ )
208
+ })
@@ -0,0 +1,352 @@
1
+ // Unit tests for lib/toml.mjs — the ONE-SHOT dbhub TOML generator.
2
+ //
3
+ // toml.mjs imports config.mjs, which binds its storage paths (DATA_DIR → TMP_DIR)
4
+ // from DSH_HOME at import time, so DSH_HOME must point at a fresh temp dir
5
+ // BEFORE the dynamic import; the module is loaded through a unique query string
6
+ // so this file always gets an instance bound to its own temp home.
7
+ //
8
+ // Fixtures obey the desensitization convention: loopback / RFC 5737 hosts and
9
+ // CHANGE_ME-class placeholders, never a real host, account or password.
10
+
11
+ import test from 'node:test'
12
+ import assert from 'node:assert/strict'
13
+ import {
14
+ existsSync,
15
+ mkdirSync,
16
+ mkdtempSync,
17
+ readFileSync,
18
+ rmSync,
19
+ utimesSync,
20
+ writeFileSync,
21
+ } from 'node:fs'
22
+ import { tmpdir } from 'node:os'
23
+ import { isAbsolute, join, sep } from 'node:path'
24
+
25
+ const home = mkdtempSync(join(tmpdir(), 'dsh-dbhub-toml-'))
26
+ process.env.DSH_HOME = home
27
+
28
+ const toml = await import('../lib/toml.mjs?x=' + Date.now())
29
+
30
+ const {
31
+ SECRET_ENV,
32
+ TMP_DIR,
33
+ buildDbhubToml,
34
+ referencedVars,
35
+ removeTempToml,
36
+ sweepTempToml,
37
+ tomlString,
38
+ writeTempToml,
39
+ } = toml
40
+
41
+ test.after(() => {
42
+ rmSync(home, { recursive: true, force: true })
43
+ })
44
+
45
+ // ── fixtures / helpers ────────────────────────────────────────────────────
46
+
47
+ const DSN = 'mysql://u:CHANGE_ME@127.0.0.1:3306/appdb'
48
+ const SSH_PASSWORD = 'CHANGE_ME'
49
+ const SSH_PASSPHRASE = 'changeme'
50
+
51
+ /** True when the document contains this EXACT line. */
52
+ function hasLine(text, line) {
53
+ return text.split('\n').includes(line)
54
+ }
55
+
56
+ /** Every `[[tools]]` section body, in order. */
57
+ function toolBlocks(text) {
58
+ return text.split('[[tools]]').slice(1)
59
+ }
60
+
61
+ /**
62
+ * The contract of buildDbhubToml: the set of `${VAR}` names referenced by the
63
+ * text is EXACTLY the set of keys handed to the child environment — in both
64
+ * directions (no reference without a value, no dead value).
65
+ */
66
+ function assertVarSetsMatch(text, env) {
67
+ assert.deepEqual(referencedVars(text).sort(), Object.keys(env).sort())
68
+ }
69
+
70
+ // ── read-only: the `[[tools]]` whitelist trap ─────────────────────────────
71
+
72
+ test('read-only emits EXACTLY the execute_sql + search_objects pair', () => {
73
+ const { text, env } = buildDbhubToml({ dsn: DSN, ro: true })
74
+ // `[[tools]]` is a whitelist, not a patch: one entry suppresses dbhub's
75
+ // default pair, so a read-only config must list BOTH tools or search_objects
76
+ // dies with "Tool search_objects not found".
77
+ const blocks = toolBlocks(text)
78
+ assert.equal((text.match(/\[\[tools\]\]/g) || []).length, 2)
79
+ assert.equal(blocks.length, 2)
80
+ assert.match(blocks[0], /name = "execute_sql"/)
81
+ assert.match(blocks[0], /readonly = true/)
82
+ assert.match(blocks[1], /name = "search_objects"/)
83
+ assert.doesNotMatch(blocks[1], /readonly/)
84
+ // `readonly = true` appears exactly once, inside the execute_sql block only
85
+ assert.equal((text.match(/readonly = true/g) || []).length, 1)
86
+ // both tools are pinned to the single generated source
87
+ assert.equal((text.match(/source = "default"/g) || []).length, 2)
88
+ assertVarSetsMatch(text, env)
89
+ })
90
+
91
+ test('no options (ro false, ssh null) emits no [[tools]] section at all', () => {
92
+ const { text, env } = buildDbhubToml({ dsn: DSN, ro: false, ssh: null })
93
+ assert.equal(text.includes('[[tools]]'), false)
94
+ assert.equal(text.includes('readonly'), false)
95
+ assert.equal(text.split('\n')[0], '[[sources]]')
96
+ assert.ok(hasLine(text, 'id = "default"'))
97
+ assert.deepEqual(env, { [SECRET_ENV.dsn]: DSN })
98
+ // omitted options behave exactly like explicit ones (and never throw)
99
+ assert.equal(buildDbhubToml({ dsn: DSN }).text, text)
100
+ assert.equal(buildDbhubToml({ dsn: DSN, ro: undefined, ssh: undefined }).text, text)
101
+ })
102
+
103
+ test('a truthy-but-not-true ro is not read-only', () => {
104
+ // The flag is strict (`c.ro === true`): a hand-edited string must not turn
105
+ // the only-write-protection on by accident.
106
+ for (const ro of ['true', 1, {}, []]) {
107
+ const { text } = buildDbhubToml({ dsn: DSN, ro })
108
+ assert.equal(text.includes('[[tools]]'), false, 'ro=' + JSON.stringify(ro))
109
+ }
110
+ })
111
+
112
+ // ── dsn is always a placeholder ───────────────────────────────────────────
113
+
114
+ test('the dsn value never appears in the text (placeholder + child env)', () => {
115
+ const { text, env } = buildDbhubToml({ dsn: DSN })
116
+ assert.ok(hasLine(text, 'dsn = "${' + SECRET_ENV.dsn + '}"'))
117
+ assert.equal(text.includes(DSN), false)
118
+ assert.equal(text.includes('CHANGE_ME'), false) // no password half either
119
+ assert.equal(text.includes('127.0.0.1'), false) // nor the host
120
+ assert.equal(env[SECRET_ENV.dsn], DSN)
121
+ assertVarSetsMatch(text, env)
122
+ })
123
+
124
+ test('a missing dsn still leaves the variable DEFINED for the child', () => {
125
+ // dbhub keeps an undefined `${VAR}` verbatim and fails later, obscurely.
126
+ for (const conn of [undefined, null, {}, { dsn: '' }, { dsn: ' ' }, { dsn: 42 }]) {
127
+ const { text, env } = buildDbhubToml(conn)
128
+ assert.equal(text.includes('dsn ='), false)
129
+ assert.ok(hasLine(text, 'id = "default"'))
130
+ assert.equal(env[SECRET_ENV.dsn], '')
131
+ // every name the text references has a value (subset direction: this is the
132
+ // case where the env intentionally carries one more key than the text uses)
133
+ for (const name of referencedVars(text)) assert.ok(name in env, name)
134
+ }
135
+ assert.equal(buildDbhubToml().text.split('\n')[0], '[[sources]]')
136
+ })
137
+
138
+ test('referenced ${VARS} and env keys match in BOTH directions', () => {
139
+ const combos = [
140
+ { dsn: DSN },
141
+ { dsn: DSN, ro: true },
142
+ { dsn: DSN, ssh: { host: '192.0.2.11', port: 2222, user: 'deploy', auth: 'password', password: SSH_PASSWORD } },
143
+ { dsn: DSN, ssh: { host: '192.0.2.10', user: 'deploy', auth: 'key', keyPath: '~/.ssh/id_ed25519', passphrase: SSH_PASSPHRASE } },
144
+ { dsn: DSN, ro: true, ssh: { host: '192.0.2.10', port: 22, user: 'deploy', auth: 'key', keyPath: '/keys/id', proxyJump: 'deploy@192.0.2.12:22' } },
145
+ ]
146
+ for (const conn of combos) {
147
+ const { text, env } = buildDbhubToml(conn)
148
+ assertVarSetsMatch(text, env)
149
+ // direction 2 is the real one: no environment entry is dead weight
150
+ for (const key of Object.keys(env)) {
151
+ assert.ok(referencedVars(text).includes(key), key + ' is never referenced by the text')
152
+ }
153
+ }
154
+ })
155
+
156
+ // ── ssh tunnel fields ─────────────────────────────────────────────────────
157
+
158
+ test('ssh key auth emits host/port/user/key/proxy_jump and no password line', () => {
159
+ const { text, env } = buildDbhubToml({
160
+ dsn: DSN,
161
+ ssh: {
162
+ host: '192.0.2.10',
163
+ port: 2222,
164
+ user: 'deploy',
165
+ auth: 'key',
166
+ keyPath: '~/.ssh/id_ed25519',
167
+ proxyJump: 'deploy@192.0.2.12:22',
168
+ },
169
+ })
170
+ assert.ok(hasLine(text, 'ssh_host = "192.0.2.10"'))
171
+ assert.ok(hasLine(text, 'ssh_port = 2222'))
172
+ assert.ok(hasLine(text, 'ssh_user = "deploy"'))
173
+ assert.ok(hasLine(text, 'ssh_key = "~/.ssh/id_ed25519"')) // verbatim: `~` is dbhub's to expand
174
+ assert.ok(hasLine(text, 'ssh_proxy_jump = "deploy@192.0.2.12:22"'))
175
+ assert.equal(text.includes('ssh_password'), false)
176
+ assert.equal(SECRET_ENV.sshPassword in env, false)
177
+ assert.equal(text.includes('ssh_passphrase'), false)
178
+ assertVarSetsMatch(text, env)
179
+ })
180
+
181
+ test('only the ssh fields actually present are emitted', () => {
182
+ const { text } = buildDbhubToml({
183
+ dsn: DSN,
184
+ ssh: { host: '192.0.2.10', user: 'deploy', auth: 'key', keyPath: '/keys/id' },
185
+ })
186
+ assert.ok(hasLine(text, 'ssh_key = "/keys/id"'))
187
+ assert.equal(text.includes('ssh_port'), false) // no port -> no line (dbhub defaults it)
188
+ assert.equal(text.includes('ssh_proxy_jump'), false)
189
+ // a non-object ssh block is ignored entirely, never dereferenced
190
+ const junk = buildDbhubToml({ dsn: DSN, ssh: 'yes' })
191
+ assert.equal(junk.text.includes('ssh_'), false)
192
+ assert.equal(buildDbhubToml({ dsn: DSN, ssh: 42 }).text.includes('ssh_'), false)
193
+ })
194
+
195
+ test('key material passes through verbatim, including a base64-ish blob', () => {
196
+ // A path is not a secret; base64 key material and a Windows path both survive
197
+ // unchanged. The blob is real base64 of a repeated pattern (base64 of
198
+ // 'ABCDEFABCDEFABCDEF'), so it is obviously not a key AND stays low-entropy
199
+ // enough for the desensitization gate (`npm run check:secrets`).
200
+ const blob = 'QUJDREVGQUJDREVGQUJDREVG'
201
+ const blobText = buildDbhubToml({
202
+ dsn: DSN,
203
+ ssh: { host: '192.0.2.10', user: 'deploy', auth: 'key', keyPath: blob },
204
+ }).text
205
+ assert.ok(hasLine(blobText, 'ssh_key = ' + tomlString(blob)))
206
+ assert.ok(blobText.includes(blob))
207
+
208
+ const winPath = 'C:\\keys\\id_ed25519'
209
+ const winText = buildDbhubToml({
210
+ dsn: DSN,
211
+ ssh: { host: '192.0.2.10', user: 'deploy', auth: 'key', keyPath: winPath },
212
+ }).text
213
+ assert.ok(hasLine(winText, 'ssh_key = ' + tomlString(winPath)))
214
+ assert.equal(winText.includes(winPath), false) // backslashes must be escaped for TOML
215
+ })
216
+
217
+ test('ssh_password only ever appears as a `${...}` placeholder', () => {
218
+ const { text, env } = buildDbhubToml({
219
+ dsn: DSN,
220
+ ssh: {
221
+ host: '192.0.2.11',
222
+ port: 2222,
223
+ user: 'deploy',
224
+ auth: 'password',
225
+ password: SSH_PASSWORD,
226
+ proxyJump: 'deploy@192.0.2.12:22',
227
+ },
228
+ })
229
+ assert.ok(hasLine(text, 'ssh_password = "${' + SECRET_ENV.sshPassword + '}"'))
230
+ assert.equal(text.includes(SSH_PASSWORD), false, 'the real tunnel password reached the file')
231
+ assert.equal(env[SECRET_ENV.sshPassword], SSH_PASSWORD) // it rides the child env instead
232
+ assert.equal(text.includes('ssh_key'), false) // password auth never emits a key line
233
+ assert.deepEqual(referencedVars(text).sort(), [SECRET_ENV.dsn, SECRET_ENV.sshPassword].sort())
234
+ assertVarSetsMatch(text, env)
235
+ })
236
+
237
+ test('ssh_passphrase only ever appears as a `${...}` placeholder', () => {
238
+ const { text, env } = buildDbhubToml({
239
+ dsn: DSN,
240
+ ssh: {
241
+ host: '192.0.2.10',
242
+ port: 22,
243
+ user: 'deploy',
244
+ auth: 'key',
245
+ keyPath: '~/.ssh/id_ed25519',
246
+ passphrase: SSH_PASSPHRASE,
247
+ },
248
+ })
249
+ assert.ok(hasLine(text, 'ssh_passphrase = "${' + SECRET_ENV.sshPassphrase + '}"'))
250
+ assert.equal(text.includes(SSH_PASSPHRASE), false, 'the real passphrase reached the file')
251
+ assert.equal(env[SECRET_ENV.sshPassphrase], SSH_PASSPHRASE)
252
+ assert.deepEqual(referencedVars(text).sort(), [SECRET_ENV.dsn, SECRET_ENV.sshPassphrase].sort())
253
+ assertVarSetsMatch(text, env)
254
+
255
+ // no passphrase -> no placeholder and no dead env entry
256
+ const noPass = buildDbhubToml({
257
+ dsn: DSN,
258
+ ssh: { host: '192.0.2.10', port: 22, user: 'deploy', auth: 'key', keyPath: '~/.ssh/id_ed25519' },
259
+ })
260
+ assert.equal(noPass.text.includes('ssh_passphrase'), false)
261
+ assert.equal(SECRET_ENV.sshPassphrase in noPass.env, false)
262
+ assertVarSetsMatch(noPass.text, noPass.env)
263
+ })
264
+
265
+ // ── small pure helpers ────────────────────────────────────────────────────
266
+
267
+ test('referencedVars lists distinct ${VAR} names in order of appearance', () => {
268
+ assert.deepEqual(referencedVars('${B} x ${A} ${B}'), ['B', 'A'])
269
+ assert.deepEqual(referencedVars('${DSH_DBHUB_SEC_DSN}'), ['DSH_DBHUB_SEC_DSN'])
270
+ assert.deepEqual(referencedVars('$A ${} ${1BAD} ${ok} $B'), ['ok'])
271
+ assert.deepEqual(referencedVars(''), [])
272
+ assert.deepEqual(referencedVars(undefined), [])
273
+ assert.deepEqual(referencedVars(123), [])
274
+ })
275
+
276
+ test('tomlString escapes backslashes, quotes, newlines and tabs', () => {
277
+ assert.equal(tomlString('plain'), '"plain"')
278
+ assert.equal(tomlString('say "hi"'), '"say \\"hi\\""')
279
+ assert.equal(tomlString('a\\b'), '"a\\\\b"') // backslash FIRST, so `\n` stays literal
280
+ assert.equal(tomlString('l1\nl2'), '"l1\\nl2"')
281
+ assert.equal(tomlString('tab\there'), '"tab\\there"')
282
+ assert.equal(tomlString('cr\rhere'), '"cr\\rhere"')
283
+ assert.equal(tomlString('C:\\keys\\a"b'), '"C:\\\\keys\\\\a\\"b"')
284
+ // empty-ish values still produce a valid TOML string
285
+ assert.equal(tomlString(undefined), '""')
286
+ assert.equal(tomlString(null), '""')
287
+ assert.equal(tomlString(''), '""')
288
+ assert.equal(tomlString(7), '"7"')
289
+ })
290
+
291
+ // ── temp file lifecycle ───────────────────────────────────────────────────
292
+
293
+ test('writeTempToml returns an absolute path inside TMP_DIR and the file exists', () => {
294
+ const path = writeTempToml('id = "default"\n')
295
+ try {
296
+ assert.ok(isAbsolute(path))
297
+ assert.ok(path.startsWith(TMP_DIR + sep), path + ' is not under ' + TMP_DIR)
298
+ assert.ok(existsSync(path))
299
+ assert.equal(readFileSync(path, 'utf8'), 'id = "default"\n')
300
+ // writeTempToml never reuses a name
301
+ const second = writeTempToml('x')
302
+ assert.notEqual(second, path)
303
+ assert.equal(removeTempToml(second), true)
304
+ assert.equal(removeTempToml(path), true)
305
+ assert.equal(existsSync(path), false)
306
+ } finally {
307
+ removeTempToml(path)
308
+ }
309
+ })
310
+
311
+ test('removeTempToml reports true for a missing path and never throws', () => {
312
+ const missing = join(TMP_DIR, 'dbhub-no-such-config.toml')
313
+ assert.equal(existsSync(missing), false)
314
+ assert.equal(removeTempToml(missing), true)
315
+ assert.equal(removeTempToml(''), true)
316
+ assert.equal(removeTempToml(undefined), true)
317
+ })
318
+
319
+ test('sweepTempToml removes only stale matching files', () => {
320
+ mkdirSync(TMP_DIR, { recursive: true })
321
+ const stale = []
322
+ const fresh = []
323
+ for (let i = 0; i < 3; i++) stale.push(writeTempToml('stale ' + i))
324
+ for (let i = 0; i < 2; i++) fresh.push(writeTempToml('fresh ' + i))
325
+ const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000)
326
+ for (const path of stale) utimesSync(path, tenMinutesAgo, tenMinutesAgo)
327
+
328
+ // a non-matching name must never be touched …
329
+ const stranger = join(TMP_DIR, 'notes.txt')
330
+ writeFileSync(stranger, 'keep me')
331
+ // … and neither must a directory that merely looks like a config
332
+ const dirLike = join(TMP_DIR, 'dbhub-looks-like-a-config.toml')
333
+ mkdirSync(dirLike, { recursive: true })
334
+ utimesSync(dirLike, tenMinutesAgo, tenMinutesAgo)
335
+
336
+ const removed = sweepTempToml(60 * 1000)
337
+ assert.equal(removed, stale.length)
338
+ for (const path of stale) assert.equal(existsSync(path), false, path + ' should have been swept')
339
+ for (const path of fresh) assert.equal(existsSync(path), true, path + ' is in use and must survive')
340
+ assert.equal(existsSync(stranger), true)
341
+ assert.equal(readFileSync(stranger, 'utf8'), 'keep me')
342
+ assert.equal(existsSync(dirLike), true)
343
+ })
344
+
345
+ test('sweepTempToml is a no-op when the temp directory does not exist', () => {
346
+ // An unwritable/absent dir must return 0, not throw (it runs at boot).
347
+ const saved = toml.TMP_DIR
348
+ rmSync(saved, { recursive: true, force: true })
349
+ assert.equal(sweepTempToml(1), 0)
350
+ // put the directory back for the remaining tests / cleanup
351
+ mkdirSync(saved, { recursive: true })
352
+ })