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,456 @@
|
|
|
1
|
+
// Environment-options tests (store model v3): the read-only flag and the SSH
|
|
2
|
+
// tunnel block of one environment entry, plus the browser-side options command
|
|
3
|
+
// handler (`applyOptionsOp`).
|
|
4
|
+
//
|
|
5
|
+
// config.mjs binds its storage paths at import time and is cached by Node, so
|
|
6
|
+
// every test that wants a clean store gets a FRESH module instance through a
|
|
7
|
+
// unique query string; each instance reads the on-disk credentials.json at
|
|
8
|
+
// import. The plain (unqueried) instance is the one lib/index.mjs shares, so
|
|
9
|
+
// the handler tests seed that singleton in place.
|
|
10
|
+
//
|
|
11
|
+
// Desensitization: loopback / documentation hosts and CHANGE_ME secrets only.
|
|
12
|
+
|
|
13
|
+
import test from 'node:test'
|
|
14
|
+
import assert from 'node:assert/strict'
|
|
15
|
+
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs'
|
|
16
|
+
import { tmpdir } from 'node:os'
|
|
17
|
+
import { join } from 'node:path'
|
|
18
|
+
|
|
19
|
+
const home = mkdtempSync(join(tmpdir(), 'dsh-dbhub-envopts-'))
|
|
20
|
+
process.env.DSH_HOME = home
|
|
21
|
+
|
|
22
|
+
const storeDir = join(home, 'storages', 'dsh-dbhub-live')
|
|
23
|
+
const storePath = join(storeDir, 'credentials.json')
|
|
24
|
+
mkdirSync(storeDir, { recursive: true })
|
|
25
|
+
|
|
26
|
+
let seq = 0
|
|
27
|
+
function freshConfig() {
|
|
28
|
+
seq += 1
|
|
29
|
+
return import('../lib/config.mjs?envopts=' + seq + '-' + Date.now())
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A fresh config instance whose on-disk store starts from `doc` (undefined = empty). */
|
|
33
|
+
async function configWith(doc) {
|
|
34
|
+
if (doc === undefined) rmSync(storePath, { force: true })
|
|
35
|
+
else writeFileSync(storePath, JSON.stringify(doc, null, 2))
|
|
36
|
+
return freshConfig()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const readStore = () => JSON.parse(readFileSync(storePath, 'utf8'))
|
|
40
|
+
|
|
41
|
+
const SSH_KEY = {
|
|
42
|
+
host: 'bastion.example.com', port: 2222, user: 'deploy',
|
|
43
|
+
auth: 'key', keyPath: '/home/deploy/.ssh/id_ed25519',
|
|
44
|
+
}
|
|
45
|
+
const SSH_PASS = {
|
|
46
|
+
host: 'bastion.example.com', user: 'deploy',
|
|
47
|
+
auth: 'password', password: 'CHANGE_ME',
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The plain instance is the module instance lib/index.mjs imports, so its
|
|
51
|
+
// `store` object is the live singleton `applyOptionsOp` reads and writes.
|
|
52
|
+
const base = await import('../lib/config.mjs')
|
|
53
|
+
const index = await import('../lib/index.mjs')
|
|
54
|
+
|
|
55
|
+
/** Reset the shared singleton store to a fixture and persist it. */
|
|
56
|
+
function seedBase(fixture) {
|
|
57
|
+
for (const key of Object.keys(base.store)) delete base.store[key]
|
|
58
|
+
Object.assign(base.store, fixture || {})
|
|
59
|
+
base.saveStore(base.store)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
test.after(() => {
|
|
63
|
+
rmSync(home, { recursive: true, force: true })
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
// ── load / list (v2 file, v3 fields) ──────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
test('a v2 store document loads as not read-only and without a tunnel', async () => {
|
|
69
|
+
const c = await configWith({
|
|
70
|
+
enabled: true,
|
|
71
|
+
'D:/ws/v2': {
|
|
72
|
+
environments: {
|
|
73
|
+
default: { dsn: 'mysql://u:CHANGE_ME@127.0.0.1:3306/app', source: 'user', updatedAt: 1 },
|
|
74
|
+
prod: { dsn: 'postgres://u:CHANGE_ME@192.0.2.10:5432/app' },
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
})
|
|
78
|
+
const rows = c.listWorkspaceEnvironments(c.store)
|
|
79
|
+
assert.equal(rows.length, 2)
|
|
80
|
+
for (const row of rows) {
|
|
81
|
+
assert.equal(row.ro, false, row.env + ' must not be read-only')
|
|
82
|
+
assert.equal(row.ssh, null, row.env + ' must have no tunnel')
|
|
83
|
+
assert.equal(row.persisted, true)
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('setWorkspaceEnv persists readOnly: true and listWorkspaceEnvironments reports it', async () => {
|
|
88
|
+
const c = await configWith(undefined)
|
|
89
|
+
const name = c.setWorkspaceEnv('D:/ws/ro', 'prod', ' mysql://u:CHANGE_ME@127.0.0.1:3306/prod ', 'user', { readOnly: true })
|
|
90
|
+
assert.equal(name, 'prod')
|
|
91
|
+
const onDisk = readStore()
|
|
92
|
+
assert.equal(onDisk['D:/ws/ro'].environments.prod.readOnly, true)
|
|
93
|
+
assert.equal(onDisk['D:/ws/ro'].environments.prod.dsn, 'mysql://u:CHANGE_ME@127.0.0.1:3306/prod')
|
|
94
|
+
const row = c.listWorkspaceEnvironments(c.store).find((r) => r.wsPath === 'D:/ws/ro' && r.env === 'prod')
|
|
95
|
+
assert.equal(row.ro, true)
|
|
96
|
+
assert.equal(row.ssh, null)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
// ── preservation vs explicit clearing (D4) ────────────────────────────────
|
|
100
|
+
|
|
101
|
+
test('a DSN-only setWorkspaceEnv preserves both the read-only flag and the tunnel', async () => {
|
|
102
|
+
const c = await configWith(undefined)
|
|
103
|
+
const ws = 'D:/ws/keep'
|
|
104
|
+
c.setWorkspaceEnv(ws, 'prod', 'mysql://u:CHANGE_ME@127.0.0.1:3306/one', 'user', { readOnly: true, ssh: SSH_KEY })
|
|
105
|
+
let entry = readStore()[ws].environments.prod
|
|
106
|
+
assert.equal(entry.readOnly, true)
|
|
107
|
+
assert.equal(entry.ssh.host, 'bastion.example.com')
|
|
108
|
+
|
|
109
|
+
// The regression the DSN-only dbhub_configure path depends on: NO opts.
|
|
110
|
+
const name = c.setWorkspaceEnv(ws, 'prod', 'mysql://u:CHANGE_ME@127.0.0.1:3307/two', 'user')
|
|
111
|
+
assert.equal(name, 'prod')
|
|
112
|
+
entry = readStore()[ws].environments.prod
|
|
113
|
+
assert.equal(entry.dsn, 'mysql://u:CHANGE_ME@127.0.0.1:3307/two')
|
|
114
|
+
assert.equal(entry.readOnly, true, 'the read-only flag must survive a DSN-only update')
|
|
115
|
+
assert.equal(entry.ssh.host, 'bastion.example.com', 'the tunnel must survive a DSN-only update')
|
|
116
|
+
assert.equal(entry.ssh.port, 2222)
|
|
117
|
+
assert.equal(entry.ssh.keyPath, '/home/deploy/.ssh/id_ed25519')
|
|
118
|
+
|
|
119
|
+
const row = c.listWorkspaceEnvironments(c.store).find((r) => r.env === 'prod')
|
|
120
|
+
assert.equal(row.ro, true)
|
|
121
|
+
assert.equal(row.ssh.user, 'deploy')
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test('readOnly: false and ssh: null delete exactly one field each and keep the DSN', async () => {
|
|
125
|
+
const c = await configWith(undefined)
|
|
126
|
+
const ws = 'D:/ws/clear'
|
|
127
|
+
const dsn = 'mysql://u:CHANGE_ME@127.0.0.1:3306/keep'
|
|
128
|
+
c.setWorkspaceEnv(ws, 'prod', dsn, 'user', { readOnly: true, ssh: SSH_KEY })
|
|
129
|
+
|
|
130
|
+
c.setWorkspaceEnv(ws, 'prod', dsn, 'user', { readOnly: false })
|
|
131
|
+
let entry = readStore()[ws].environments.prod
|
|
132
|
+
assert.equal(Object.hasOwn(entry, 'readOnly'), false, 'readOnly: false removes the key')
|
|
133
|
+
assert.ok(entry.ssh, 'the tunnel is preserved when only readOnly is patched')
|
|
134
|
+
assert.equal(entry.dsn, dsn)
|
|
135
|
+
|
|
136
|
+
c.setWorkspaceEnv(ws, 'prod', dsn, 'user', { readOnly: true, ssh: SSH_PASS })
|
|
137
|
+
c.setWorkspaceEnv(ws, 'prod', dsn, 'user', { ssh: null })
|
|
138
|
+
entry = readStore()[ws].environments.prod
|
|
139
|
+
assert.equal(Object.hasOwn(entry, 'ssh'), false, 'ssh: null removes the key')
|
|
140
|
+
assert.equal(entry.readOnly, true, 'readOnly is preserved when only ssh is patched')
|
|
141
|
+
assert.equal(entry.dsn, dsn, 'the DSN survives both clears')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test('normalizeStore preserves unknown env fields next to readOnly/ssh', async () => {
|
|
145
|
+
const doc = {
|
|
146
|
+
'D:/ws/future': {
|
|
147
|
+
environments: {
|
|
148
|
+
prod: {
|
|
149
|
+
dsn: 'mysql://u:CHANGE_ME@127.0.0.1:3306/f',
|
|
150
|
+
source: 'user',
|
|
151
|
+
updatedAt: 7,
|
|
152
|
+
readOnly: true,
|
|
153
|
+
ssh: SSH_KEY,
|
|
154
|
+
futureFlag: 'kept',
|
|
155
|
+
nested: { a: 1 },
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
}
|
|
160
|
+
const c = await configWith(doc)
|
|
161
|
+
const entry = c.store['D:/ws/future'].environments.prod
|
|
162
|
+
assert.equal(entry.futureFlag, 'kept')
|
|
163
|
+
assert.deepEqual(entry.nested, { a: 1 })
|
|
164
|
+
assert.equal(entry.readOnly, true)
|
|
165
|
+
assert.equal(entry.ssh.host, 'bastion.example.com')
|
|
166
|
+
// A normalizing rewrite of the same document is lossless (forward compat).
|
|
167
|
+
const onDisk = readStore()
|
|
168
|
+
assert.deepEqual(c.normalizeStore(onDisk), onDisk)
|
|
169
|
+
assert.equal(onDisk['D:/ws/future'].environments.prod.futureFlag, 'kept')
|
|
170
|
+
assert.equal(onDisk['D:/ws/future'].environments.prod.ssh.host, 'bastion.example.com')
|
|
171
|
+
assert.equal(onDisk['D:/ws/future'].environments.prod.readOnly, true)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
// ── options-only write / rename ───────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
test('setWorkspaceEnvOptions touches only the options of an existing row', async () => {
|
|
177
|
+
const c = await configWith(undefined)
|
|
178
|
+
const ws = 'D:/ws/opts'
|
|
179
|
+
const dsn = 'postgres://u:CHANGE_ME@192.0.2.20:5432/app'
|
|
180
|
+
c.setWorkspaceEnv(ws, 'prod', dsn, 'collected')
|
|
181
|
+
c.store[ws].environments.prod.updatedAt = 1 // pin an old stamp so the refresh is observable
|
|
182
|
+
|
|
183
|
+
const name = c.setWorkspaceEnvOptions(ws, 'prod', { readOnly: true })
|
|
184
|
+
assert.equal(name, 'prod')
|
|
185
|
+
const entry = readStore()[ws].environments.prod
|
|
186
|
+
assert.equal(entry.readOnly, true)
|
|
187
|
+
assert.equal(entry.dsn, dsn, 'the DSN is not touched')
|
|
188
|
+
assert.equal(entry.source, 'collected', 'the source provenance is not touched')
|
|
189
|
+
assert.ok(entry.updatedAt > 1, 'updatedAt is refreshed')
|
|
190
|
+
assert.equal(Object.hasOwn(entry, 'ssh'), false)
|
|
191
|
+
assert.equal(c.listWorkspaceEnvironments(c.store).find((r) => r.env === 'prod').ro, true)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
test('setWorkspaceEnvOptions on a missing row returns undefined and writes nothing', async () => {
|
|
195
|
+
const c = await configWith(undefined)
|
|
196
|
+
const ws = 'D:/ws/hasrow'
|
|
197
|
+
c.setWorkspaceEnv(ws, 'prod', 'mysql://u:CHANGE_ME@127.0.0.1:3306/x', 'user')
|
|
198
|
+
const before = readFileSync(storePath, 'utf8')
|
|
199
|
+
assert.equal(c.setWorkspaceEnvOptions(ws, 'nope', { readOnly: true }), undefined)
|
|
200
|
+
assert.equal(c.setWorkspaceEnvOptions('D:/ws/absent', 'prod', { readOnly: true }), undefined)
|
|
201
|
+
assert.equal(readFileSync(storePath, 'utf8'), before, 'nothing was written')
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
test('renameWorkspaceEnv carries the options to the new name', async () => {
|
|
205
|
+
const c = await configWith(undefined)
|
|
206
|
+
const ws = 'D:/ws/rename'
|
|
207
|
+
const dsn = 'mysql://u:CHANGE_ME@127.0.0.1:3306/r'
|
|
208
|
+
c.setWorkspaceEnv(ws, 'old', dsn, 'collected', { readOnly: true, ssh: SSH_KEY })
|
|
209
|
+
assert.equal(c.renameWorkspaceEnv(ws, 'old', 'new'), 'new')
|
|
210
|
+
const envs = readStore()[ws].environments
|
|
211
|
+
assert.equal(Object.hasOwn(envs, 'old'), false)
|
|
212
|
+
assert.equal(envs.new.dsn, dsn)
|
|
213
|
+
assert.equal(envs.new.source, 'collected')
|
|
214
|
+
assert.equal(envs.new.readOnly, true)
|
|
215
|
+
assert.equal(envs.new.ssh.host, 'bastion.example.com')
|
|
216
|
+
assert.equal(envs.new.ssh.keyPath, '/home/deploy/.ssh/id_ed25519')
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
// ── normalizeSshOptions matrix ────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
test('normalizeSshOptions accepts complete key/password blocks and rejects incomplete ones', async () => {
|
|
222
|
+
const c = await configWith(undefined)
|
|
223
|
+
const normalize = (raw) => c.normalizeSshOptions(raw)
|
|
224
|
+
|
|
225
|
+
assert.deepEqual(normalize(SSH_KEY), {
|
|
226
|
+
host: 'bastion.example.com', port: 2222, user: 'deploy',
|
|
227
|
+
auth: 'key', keyPath: '/home/deploy/.ssh/id_ed25519',
|
|
228
|
+
})
|
|
229
|
+
assert.deepEqual(normalize({ ...SSH_KEY, passphrase: 'CHANGE_ME' }), {
|
|
230
|
+
host: 'bastion.example.com', port: 2222, user: 'deploy',
|
|
231
|
+
auth: 'key', keyPath: '/home/deploy/.ssh/id_ed25519', passphrase: 'CHANGE_ME',
|
|
232
|
+
})
|
|
233
|
+
assert.deepEqual(normalize(SSH_PASS), {
|
|
234
|
+
host: 'bastion.example.com', port: 22, user: 'deploy',
|
|
235
|
+
auth: 'password', password: 'CHANGE_ME',
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
const { auth, ...noAuth } = SSH_PASS
|
|
239
|
+
assert.equal(auth, 'password')
|
|
240
|
+
assert.equal(normalize({ ...SSH_PASS, host: '' }), null, 'missing host -> null')
|
|
241
|
+
assert.equal(normalize({ ...SSH_PASS, host: undefined }), null, 'missing host -> null')
|
|
242
|
+
assert.equal(normalize({ ...SSH_PASS, user: '' }), null, 'missing user -> null')
|
|
243
|
+
assert.equal(normalize({ ...SSH_PASS, user: ' ' }), null, 'blank user -> null')
|
|
244
|
+
assert.equal(normalize(noAuth), null, 'missing auth -> null')
|
|
245
|
+
assert.equal(normalize({ ...SSH_PASS, auth: 'agent' }), null, 'unknown auth -> null')
|
|
246
|
+
assert.equal(normalize({ ...SSH_KEY, auth: 'key', keyPath: '' }), null, 'key auth without keyPath -> null')
|
|
247
|
+
assert.equal(normalize({ ...SSH_KEY, keyPath: undefined }), null, 'key auth without keyPath -> null')
|
|
248
|
+
assert.equal(normalize({ ...SSH_PASS, password: '' }), null, 'password auth without password -> null')
|
|
249
|
+
assert.equal(normalize({ ...SSH_PASS, password: undefined }), null, 'password auth without password -> null')
|
|
250
|
+
assert.equal(normalize(null), null)
|
|
251
|
+
assert.equal(normalize(undefined), null)
|
|
252
|
+
assert.equal(normalize('ssh'), null)
|
|
253
|
+
assert.equal(normalize([SSH_PASS]), null)
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
test('normalizeSshOptions normalizes ports, proxy-jump hops and untrusted text', async () => {
|
|
257
|
+
const c = await configWith(undefined)
|
|
258
|
+
const normalize = (raw) => c.normalizeSshOptions({ ...SSH_PASS, ...raw })
|
|
259
|
+
|
|
260
|
+
assert.equal(normalize({ port: '2222' }).port, 2222, 'a numeric string is accepted')
|
|
261
|
+
assert.equal(normalize({ port: 2222 }).port, 2222)
|
|
262
|
+
assert.equal(normalize({ port: 1 }).port, 1)
|
|
263
|
+
assert.equal(normalize({ port: 65535 }).port, 65535)
|
|
264
|
+
assert.equal(normalize({ port: 70000 }).port, 22, 'out of range falls back to 22')
|
|
265
|
+
assert.equal(normalize({ port: 0 }).port, 22, 'out of range falls back to 22')
|
|
266
|
+
assert.equal(normalize({ port: -1 }).port, 22, 'out of range falls back to 22')
|
|
267
|
+
assert.equal(normalize({ port: 'abc' }).port, 22, 'a non-numeric port falls back to 22')
|
|
268
|
+
assert.equal(normalize({ port: '' }).port, 22)
|
|
269
|
+
assert.equal(normalize({ port: null }).port, 22)
|
|
270
|
+
|
|
271
|
+
assert.equal(normalize({ proxyJump: 'jump.example.com' }).proxyJump, 'jump.example.com')
|
|
272
|
+
assert.equal(normalize({ proxyJump: 'deploy@jump.example.com:22' }).proxyJump, 'deploy@jump.example.com:22')
|
|
273
|
+
assert.equal(normalize({ proxyJump: 'jump1.example.com, jump2.example.com' }), null, 'multi-hop is unsupported')
|
|
274
|
+
assert.equal(c.sshProxyJumpHops(''), 0)
|
|
275
|
+
assert.equal(c.sshProxyJumpHops('jump.example.com'), 1)
|
|
276
|
+
assert.equal(c.sshProxyJumpHops('a.example.com, b.example.com'), 2)
|
|
277
|
+
assert.equal(c.sshProxyJumpHops(' a.example.com , '), 1, 'blank hops do not count')
|
|
278
|
+
|
|
279
|
+
const control = normalize({ host: ' bastion\u0000.example.com\u0007 ', user: 'de\u007fploy' })
|
|
280
|
+
assert.equal(control.host, 'bastion.example.com', 'control characters are stripped and the value trimmed')
|
|
281
|
+
assert.equal(control.user, 'deploy')
|
|
282
|
+
|
|
283
|
+
assert.equal(normalize({ host: 'h'.repeat(300) }).host.length, 255, 'host is capped')
|
|
284
|
+
assert.equal(normalize({ user: 'u'.repeat(200) }).user.length, 128, 'user is capped')
|
|
285
|
+
assert.equal(normalize({ password: 'p'.repeat(600) }).password.length, 512, 'password is capped')
|
|
286
|
+
assert.equal(normalize({ proxyJump: 'j'.repeat(2000) }).proxyJump.length, 1024, 'proxyJump is capped')
|
|
287
|
+
assert.equal(
|
|
288
|
+
normalize({ proxyJump: 'j'.repeat(600) + ',' + 'k'.repeat(600) }),
|
|
289
|
+
null,
|
|
290
|
+
'a capped value that still names 2+ hops is refused',
|
|
291
|
+
)
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
// ── describeEnvOptions (secret-free view) ─────────────────────────────────
|
|
295
|
+
|
|
296
|
+
test('describeEnvOptions never leaks a password', async () => {
|
|
297
|
+
const c = await configWith(undefined)
|
|
298
|
+
const password = 'CHANGE_ME'
|
|
299
|
+
const described = c.describeEnvOptions({
|
|
300
|
+
dsn: 'mysql://u:CHANGE_ME@127.0.0.1:3306/d', readOnly: true, ssh: { ...SSH_PASS, password },
|
|
301
|
+
})
|
|
302
|
+
assert.deepEqual(described, {
|
|
303
|
+
ro: true,
|
|
304
|
+
ssh: {
|
|
305
|
+
host: 'bastion.example.com', port: 22, user: 'deploy', authKind: 'password',
|
|
306
|
+
hasPassword: true, hasPassphrase: false, keyReady: false, proxyJump: '',
|
|
307
|
+
},
|
|
308
|
+
})
|
|
309
|
+
assert.equal(JSON.stringify(described).includes(password), false)
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
test('describeEnvOptions reports key readiness without leaking the key path', async () => {
|
|
313
|
+
const c = await configWith(undefined)
|
|
314
|
+
const missing = '/nonexistent-dsh/id_ed25519'
|
|
315
|
+
const described = c.describeEnvOptions({
|
|
316
|
+
readOnly: false, ssh: { ...SSH_KEY, keyPath: missing, passphrase: 'CHANGE_ME', proxyJump: 'jump.example.com' },
|
|
317
|
+
})
|
|
318
|
+
assert.deepEqual(described, {
|
|
319
|
+
ro: false,
|
|
320
|
+
ssh: {
|
|
321
|
+
host: 'bastion.example.com', port: 2222, user: 'deploy', authKind: 'key',
|
|
322
|
+
hasPassword: false, hasPassphrase: true, keyReady: false, proxyJump: 'jump.example.com',
|
|
323
|
+
},
|
|
324
|
+
})
|
|
325
|
+
const json = JSON.stringify(described)
|
|
326
|
+
assert.equal(json.includes(missing), false, 'the key path must never cross')
|
|
327
|
+
assert.equal(json.includes('CHANGE_ME'), false, 'the passphrase must never cross')
|
|
328
|
+
|
|
329
|
+
// A key path that really exists in the temp home reports keyReady: true.
|
|
330
|
+
const keyFile = join(home, 'id_ed25519')
|
|
331
|
+
writeFileSync(keyFile, 'KEY-CHANGE_ME')
|
|
332
|
+
const ready = c.describeEnvOptions({ ssh: { ...SSH_KEY, keyPath: keyFile } })
|
|
333
|
+
assert.equal(ready.ssh.keyReady, true)
|
|
334
|
+
assert.equal(JSON.stringify(ready).includes(keyFile), false, 'even a ready path stays host-side')
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
// ── resolveWorkspaceEnvs carries the options ──────────────────────────────
|
|
338
|
+
|
|
339
|
+
test('resolveWorkspaceEnvs carries ro/ssh for persisted environments', async () => {
|
|
340
|
+
const c = await configWith(undefined)
|
|
341
|
+
const ws = 'D:/ws/resolve'
|
|
342
|
+
c.setWorkspaceEnv(ws, 'prod', 'mysql://u:CHANGE_ME@127.0.0.1:3306/p', 'user', { readOnly: true, ssh: SSH_PASS })
|
|
343
|
+
c.setWorkspaceEnv(ws, 'dev', 'mysql://u:CHANGE_ME@127.0.0.1:3306/d', 'user')
|
|
344
|
+
const subprocess = { resolveExecutable: async () => { throw new Error('no mise') } }
|
|
345
|
+
const fs = { resolve: async () => { throw new Error('no .env') } }
|
|
346
|
+
const rows = await c.resolveWorkspaceEnvs(subprocess, fs, ws, undefined)
|
|
347
|
+
assert.deepEqual(rows.map((r) => r.env).sort(), ['dev', 'prod'])
|
|
348
|
+
const prod = rows.find((r) => r.env === 'prod')
|
|
349
|
+
assert.equal(prod.source, 'persisted(user)')
|
|
350
|
+
assert.equal(prod.ro, true)
|
|
351
|
+
assert.equal(prod.ssh.host, 'bastion.example.com')
|
|
352
|
+
assert.equal(prod.ssh.auth, 'password')
|
|
353
|
+
assert.equal(prod.ssh.password, 'CHANGE_ME') // secrets stay host-side in this shape
|
|
354
|
+
const dev = rows.find((r) => r.env === 'dev')
|
|
355
|
+
assert.equal(dev.ro, false)
|
|
356
|
+
assert.equal(dev.ssh, null)
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
// ── applyOptionsOp (browser-side options command handler) ─────────────────
|
|
360
|
+
|
|
361
|
+
test('applyOptionsOp on an existing row patches options and never adds a row', async () => {
|
|
362
|
+
const ws = { path: 'D:/ws/handler', title: 'handler' }
|
|
363
|
+
seedBase({
|
|
364
|
+
[ws.path]: {
|
|
365
|
+
environments: {
|
|
366
|
+
prod: {
|
|
367
|
+
dsn: 'mysql://u:CHANGE_ME@127.0.0.1:3306/prod', source: 'user', updatedAt: 1, ssh: SSH_PASS,
|
|
368
|
+
},
|
|
369
|
+
},
|
|
370
|
+
},
|
|
371
|
+
})
|
|
372
|
+
const res = await index.applyOptionsOp(
|
|
373
|
+
{ op: 'options', env: 'prod', readOnly: true, ssh: null },
|
|
374
|
+
ws,
|
|
375
|
+
async () => { throw new Error('lookupAuto must not run for a persisted row') },
|
|
376
|
+
)
|
|
377
|
+
assert.deepEqual(res.patches, [{ op: 'options', path: ws.path, env: 'prod', ro: true, ssh: null }])
|
|
378
|
+
assert.equal(res.patches.some((p) => p.op === 'add'), false)
|
|
379
|
+
const entry = readStore()[ws.path].environments.prod
|
|
380
|
+
assert.equal(entry.readOnly, true)
|
|
381
|
+
assert.equal(Object.hasOwn(entry, 'ssh'), false, 'ssh: null cleared the stored tunnel')
|
|
382
|
+
assert.equal(entry.dsn, 'mysql://u:CHANGE_ME@127.0.0.1:3306/prod')
|
|
383
|
+
// The shared in-memory singleton the handler wrote through agrees too.
|
|
384
|
+
const row = base.listWorkspaceEnvironments(base.store).find((r) => r.wsPath === ws.path && r.env === 'prod')
|
|
385
|
+
assert.equal(row.ro, true)
|
|
386
|
+
assert.equal(row.ssh, null)
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
test('applyOptionsOp promotes an auto-discovered environment through lookupAuto', async () => {
|
|
390
|
+
const ws = { path: 'D:/ws/promote', title: 'promote' }
|
|
391
|
+
seedBase({})
|
|
392
|
+
const seen = []
|
|
393
|
+
const dsn = 'postgres://u:CHANGE_ME@192.0.2.30:5432/auto'
|
|
394
|
+
const res = await index.applyOptionsOp({ op: 'options', env: 'default', readOnly: true }, ws, async (envName) => {
|
|
395
|
+
seen.push(envName)
|
|
396
|
+
return { env: envName, dsn }
|
|
397
|
+
})
|
|
398
|
+
assert.deepEqual(seen, ['default'])
|
|
399
|
+
assert.deepEqual(res.patches, [
|
|
400
|
+
{ op: 'add', path: ws.path, title: ws.title, env: 'default', dsn, source: 'promoted', ro: true, ssh: null },
|
|
401
|
+
{ op: 'options', path: ws.path, env: 'default', ro: true, ssh: null },
|
|
402
|
+
])
|
|
403
|
+
const entry = readStore()[ws.path].environments.default
|
|
404
|
+
assert.equal(entry.dsn, dsn)
|
|
405
|
+
assert.equal(entry.source, 'promoted')
|
|
406
|
+
assert.equal(entry.readOnly, true)
|
|
407
|
+
const row = base.listWorkspaceEnvironments(base.store).find((r) => r.wsPath === ws.path && r.env === 'default')
|
|
408
|
+
assert.equal(row.persisted, true)
|
|
409
|
+
assert.equal(row.ro, true)
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
test('applyOptionsOp without a row and without an auto discovery writes nothing', async () => {
|
|
413
|
+
const ws = { path: 'D:/ws/none', title: 'none' }
|
|
414
|
+
seedBase({ [ws.path]: { environments: { other: { dsn: 'mysql://u:CHANGE_ME@127.0.0.1:3306/o' } } } })
|
|
415
|
+
const before = readFileSync(storePath, 'utf8')
|
|
416
|
+
const res = await index.applyOptionsOp({ op: 'options', env: 'default', readOnly: true }, ws, async () => undefined)
|
|
417
|
+
assert.deepEqual(res, { patches: [], error: 'needConnFirst' })
|
|
418
|
+
assert.equal(readFileSync(storePath, 'utf8'), before, 'nothing was written')
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
test('applyOptionsOp rejects an invalid ssh block before writing anything', async () => {
|
|
422
|
+
const ws = { path: 'D:/ws/badssh', title: 'badssh' }
|
|
423
|
+
seedBase({ [ws.path]: { environments: { prod: { dsn: 'mysql://u:CHANGE_ME@127.0.0.1:3306/p', source: 'user' } } } })
|
|
424
|
+
const before = readFileSync(storePath, 'utf8')
|
|
425
|
+
const res = await index.applyOptionsOp(
|
|
426
|
+
{ op: 'options', env: 'prod', ssh: { host: 'bastion.example.com', auth: 'password', password: 'CHANGE_ME' } },
|
|
427
|
+
ws,
|
|
428
|
+
async () => undefined,
|
|
429
|
+
)
|
|
430
|
+
assert.deepEqual(res, { patches: [], error: 'badSsh' })
|
|
431
|
+
assert.equal(readFileSync(storePath, 'utf8'), before, 'nothing was written')
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
test('applyOptionsOp renames and applies options in one call', async () => {
|
|
435
|
+
const ws = { path: 'D:/ws/renameop', title: 'renameop' }
|
|
436
|
+
const dsn = 'mysql://u:CHANGE_ME@127.0.0.1:3306/move'
|
|
437
|
+
seedBase({ [ws.path]: { environments: { old: { dsn, source: 'user', updatedAt: 1, ssh: SSH_KEY } } } })
|
|
438
|
+
const res = await index.applyOptionsOp(
|
|
439
|
+
{ op: 'options', env: 'old', newEnv: 'new', readOnly: true },
|
|
440
|
+
ws,
|
|
441
|
+
async () => undefined,
|
|
442
|
+
)
|
|
443
|
+
assert.deepEqual(res.patches, [
|
|
444
|
+
{ op: 'rename', path: ws.path, env: 'new', from: 'old' },
|
|
445
|
+
{
|
|
446
|
+
op: 'options', path: ws.path, env: 'new', ro: true,
|
|
447
|
+
ssh: { host: 'bastion.example.com', port: 2222, user: 'deploy', auth: 'key', keyPath: '/home/deploy/.ssh/id_ed25519' },
|
|
448
|
+
},
|
|
449
|
+
])
|
|
450
|
+
assert.equal(res.patches.some((p) => p.op === 'rename'), true)
|
|
451
|
+
const envs = readStore()[ws.path].environments
|
|
452
|
+
assert.equal(Object.hasOwn(envs, 'old'), false)
|
|
453
|
+
assert.equal(envs.new.dsn, dsn, 'the connection travels with the name')
|
|
454
|
+
assert.equal(envs.new.readOnly, true)
|
|
455
|
+
assert.equal(envs.new.ssh.host, 'bastion.example.com', 'the tunnel travels with the name')
|
|
456
|
+
})
|