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