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,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
|
+
})
|