rds_ssm_connect 1.3.9 → 1.5.0

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.
@@ -2,6 +2,7 @@ name: Publish Package to npmjs
2
2
  on:
3
3
  release:
4
4
  types: [published]
5
+ workflow_dispatch:
5
6
  jobs:
6
7
  build:
7
8
  runs-on: ubuntu-latest
package/CLAUDE.md ADDED
@@ -0,0 +1,105 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Project Overview
6
+
7
+ **rds_ssm_connect** is a Node.js CLI tool that enables secure connections to AWS RDS databases through AWS Systems Manager (SSM) port forwarding via bastion hosts. The tool reads AWS profiles from the user's config, retrieves database credentials from AWS Secrets Manager, and automatically sets up port forwarding through a bastion instance.
8
+
9
+ ## Key Architecture
10
+
11
+ ### Entry Point
12
+ - `connect.js` - Main executable (shebang: `#!/usr/bin/env node`)
13
+ - Reads AWS profiles from `~/.aws/config`
14
+ - Uses `inquirer` for interactive environment selection
15
+ - Executes AWS commands via `aws-vault` wrapper
16
+ - Establishes SSM port forwarding session
17
+
18
+ ### Configuration
19
+ - `envPortMapping.js` - Environment-to-port mapping configuration
20
+ - Maps environment suffixes (dev, stage, prod, team1-5, qa1-5, etc.) to local port numbers (5433-5452)
21
+ - Defines default database name: `emr`
22
+ - Defines AWS region: `us-east-2`
23
+ - **Critical**: When adding new environments, update this mapping to assign unique ports
24
+
25
+ ### Core Flow
26
+ 1. Read AWS profiles from `~/.aws/config`
27
+ 2. Prompt user to select environment (AWS profile)
28
+ 3. Query Secrets Manager for RDS credentials (secrets starting with `rds!cluster`)
29
+ 4. Find running bastion instance (tagged with `Name=*bastion*`)
30
+ 5. Get RDS Aurora cluster endpoint (cluster IDs ending with `-rds-aurora`)
31
+ 6. Start SSM port forwarding session
32
+ 7. Display connection details for database client
33
+
34
+ ## Development Commands
35
+
36
+ ### Installation
37
+ ```bash
38
+ npm install
39
+ ```
40
+
41
+ ### Testing
42
+ ```bash
43
+ npm test
44
+ ```
45
+ Tests cover:
46
+ - AWS config parsing
47
+ - Port mapping logic
48
+ - Credentials validation
49
+ - Retry configuration validation
50
+
51
+ ### Local Testing
52
+ ```bash
53
+ node connect.js
54
+ ```
55
+
56
+ ### Global Installation (for testing as installed package)
57
+ ```bash
58
+ npm install -g .
59
+ rds_ssm_connect
60
+ ```
61
+
62
+ ### Publishing
63
+ Package is published to npm via GitHub Actions workflow (`.github/workflows/npm-publish.yml`) when a release is created.
64
+
65
+ ## Prerequisites for Running
66
+
67
+ - `aws-vault` - Required for AWS credential management
68
+ - AWS CLI - Required for AWS API calls
69
+ - Node.js (ES modules enabled via `"type": "module"` in package.json)
70
+ - Properly configured `~/.aws/config` with named profiles
71
+
72
+ ## AWS Resource Naming Conventions
73
+
74
+ The application relies on specific AWS resource naming patterns:
75
+ - **Secrets**: Must start with `rds!cluster`
76
+ - **Bastion instances**: Must be tagged with `Name=*bastion*` and in `running` state
77
+ - **RDS clusters**: DBClusterIdentifier must end with `-rds-aurora` and be in `available` state
78
+
79
+ ## Important Notes
80
+
81
+ - Port assignment is based on longest-matching environment suffix (e.g., `my-team-dev` matches `dev` → port 5433)
82
+ - The tool keeps the SSM session running until Ctrl+C is pressed
83
+ - Default port is 5432 if no environment suffix matches
84
+ - All AWS operations use the `us-east-2` region by default
85
+
86
+ ## Error Handling & Recovery
87
+
88
+ ### TargetNotConnected Recovery
89
+ The application automatically handles the race condition where a bastion instance appears running but SSM agent is not connected:
90
+
91
+ 1. Detects `TargetNotConnected` error (exit code 254)
92
+ 2. Terminates the disconnected bastion instance
93
+ 3. Waits for ASG to spin up a new instance (max 20 retries @ 15s intervals)
94
+ 4. Verifies SSM agent is online using `describe-instance-information`
95
+ 5. Retries port forwarding with new instance (max 2 retries)
96
+
97
+ ### Configuration Constants
98
+ All retry/timeout values are configurable in `RETRY_CONFIG`:
99
+ - `BASTION_WAIT_MAX_RETRIES`: 20 (time to wait for new instance)
100
+ - `BASTION_WAIT_RETRY_DELAY_MS`: 15000 (delay between instance checks)
101
+ - `PORT_FORWARDING_MAX_RETRIES`: 2 (connection retry attempts)
102
+ - `SSM_AGENT_READY_WAIT_MS`: 10000 (stabilization time after agent online)
103
+
104
+ ### Process Cleanup
105
+ The application registers handlers for `SIGINT`, `SIGTERM`, and `exit` events to properly clean up child processes (SSM sessions) to prevent zombie processes.
package/connect.js CHANGED
@@ -10,6 +10,34 @@ import { envPortMapping, REGION, TABLE_NAME } from './envPortMapping.js'
10
10
 
11
11
  const execAsync = promisify(exec)
12
12
 
13
+ // Configuration constants
14
+ const RETRY_CONFIG = {
15
+ BASTION_WAIT_MAX_RETRIES: 20,
16
+ BASTION_WAIT_RETRY_DELAY_MS: 15000,
17
+ PORT_FORWARDING_MAX_RETRIES: 2,
18
+ SSM_AGENT_READY_WAIT_MS: 10000
19
+ }
20
+
21
+ // Store active child processes for cleanup
22
+ let activeChildProcesses = []
23
+
24
+ // Handle graceful shutdown
25
+ function setupProcessCleanup () {
26
+ const cleanup = () => {
27
+ console.log('\nCleaning up active connections...')
28
+ activeChildProcesses.forEach(child => {
29
+ if (child && !child.killed) {
30
+ child.kill('SIGTERM')
31
+ }
32
+ })
33
+ process.exit(0)
34
+ }
35
+
36
+ process.on('SIGINT', cleanup)
37
+ process.on('SIGTERM', cleanup)
38
+ process.on('exit', cleanup)
39
+ }
40
+
13
41
  async function readAwsConfig () {
14
42
  const awsConfigPath = path.join(os.homedir(), '.aws', 'config')
15
43
  try {
@@ -36,7 +64,171 @@ async function runCommand (command) {
36
64
  }
37
65
  }
38
66
 
67
+ async function sleep (ms) {
68
+ return new Promise(resolve => setTimeout(resolve, ms))
69
+ }
70
+
71
+ async function terminateBastionInstance (ENV, instanceId) {
72
+ console.log(`Terminating disconnected bastion instance: ${instanceId}`)
73
+ const terminateCommand = `aws-vault exec ${ENV} -- aws ec2 terminate-instances --region ${REGION} --instance-ids ${instanceId}`
74
+ await runCommand(terminateCommand)
75
+ console.log('Bastion instance terminated. ASG will spin up a new instance...')
76
+ }
77
+
78
+ async function waitForNewBastionInstance (ENV, oldInstanceId, maxRetries = RETRY_CONFIG.BASTION_WAIT_MAX_RETRIES, retryDelay = RETRY_CONFIG.BASTION_WAIT_RETRY_DELAY_MS) {
79
+ console.log('Waiting for new bastion instance to be ready...')
80
+
81
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
82
+ console.log(`Checking for new bastion instance (attempt ${attempt}/${maxRetries})...`)
83
+
84
+ const instanceIdCommand = `aws-vault exec ${ENV} -- aws ec2 describe-instances --region ${REGION} --filters "Name=tag:Name,Values='*bastion*'" "Name=instance-state-name,Values=running" --query "Reservations[].Instances[].[InstanceId] | [0][0]" --output text`
85
+ const newInstanceId = await runCommand(instanceIdCommand)
86
+
87
+ if (newInstanceId && newInstanceId !== oldInstanceId && newInstanceId !== 'None') {
88
+ console.log(`New bastion instance found: ${newInstanceId}`)
89
+
90
+ // Verify SSM agent is ready
91
+ const isReady = await waitForSSMAgentReady(ENV, newInstanceId)
92
+ if (isReady) {
93
+ return newInstanceId
94
+ } else {
95
+ console.log('SSM agent not ready yet, will retry...')
96
+ }
97
+ }
98
+
99
+ if (attempt < maxRetries) {
100
+ await sleep(retryDelay)
101
+ }
102
+ }
103
+
104
+ return null
105
+ }
106
+
107
+ async function waitForSSMAgentReady (ENV, instanceId, maxRetries = 10, retryDelay = 3000) {
108
+ console.log('Verifying SSM agent status...')
109
+
110
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
111
+ const ssmStatusCommand = `aws-vault exec ${ENV} -- aws ssm describe-instance-information --region ${REGION} --filters "Key=InstanceIds,Values=${instanceId}" --query "InstanceInformationList[0].PingStatus" --output text`
112
+ const status = await runCommand(ssmStatusCommand)
113
+
114
+ if (status === 'Online') {
115
+ console.log('SSM agent is online and ready.')
116
+ // Additional wait for agent to stabilize
117
+ await sleep(RETRY_CONFIG.SSM_AGENT_READY_WAIT_MS)
118
+ return true
119
+ }
120
+
121
+ console.log(`SSM agent status: ${status || 'Unknown'} (attempt ${attempt}/${maxRetries})`)
122
+
123
+ if (attempt < maxRetries) {
124
+ await sleep(retryDelay)
125
+ }
126
+ }
127
+
128
+ console.log('Warning: SSM agent did not report Online status, but proceeding anyway.')
129
+ return false
130
+ }
131
+
132
+ function monitorPortForwardingSession (child) {
133
+ const state = {
134
+ stderrOutput: '',
135
+ targetNotConnectedError: false,
136
+ sessionEstablished: false
137
+ }
138
+
139
+ child.stdout.on('data', (data) => {
140
+ const output = data.toString()
141
+
142
+ // Detect when session is actually established
143
+ if (output.includes('Starting session with SessionId:')) {
144
+ state.sessionEstablished = true
145
+ console.log('Port forwarding session established.')
146
+ console.log('Press Ctrl+C to end the session.')
147
+ }
148
+
149
+ if (!output.includes('Starting session with SessionId:') && !output.includes('Port 5433 opened for sessionId')) {
150
+ console.log(output)
151
+ }
152
+ })
153
+
154
+ child.stderr.on('data', (data) => {
155
+ const errorOutput = data.toString()
156
+ state.stderrOutput += errorOutput
157
+
158
+ // Check for TargetNotConnected error
159
+ if (errorOutput.includes('TargetNotConnected') || errorOutput.includes('is not connected')) {
160
+ state.targetNotConnectedError = true
161
+ }
162
+
163
+ console.error(errorOutput)
164
+ })
165
+
166
+ return state
167
+ }
168
+
169
+ async function handleTargetNotConnectedError (ENV, instanceId, rdsEndpoint, portNumber, retryCount, maxRetries) {
170
+ console.log(`\nDetected TargetNotConnected error. Attempting recovery (retry ${retryCount + 1}/${maxRetries})...`)
171
+
172
+ // Terminate the disconnected instance
173
+ await terminateBastionInstance(ENV, instanceId)
174
+
175
+ // Wait for new instance to be ready
176
+ const newInstanceId = await waitForNewBastionInstance(ENV, instanceId)
177
+
178
+ if (!newInstanceId) {
179
+ throw new Error('Failed to find new bastion instance after waiting.')
180
+ }
181
+
182
+ // Retry with new instance
183
+ console.log('Retrying port forwarding with new bastion instance...')
184
+ return await startPortForwarding(ENV, newInstanceId, rdsEndpoint, portNumber, retryCount + 1, maxRetries)
185
+ }
186
+
187
+ function executePortForwardingCommand (ENV, instanceId, rdsEndpoint, portNumber) {
188
+ const portForwardingCommand = `aws-vault exec ${ENV} -- aws ssm start-session --target ${instanceId} --document-name AWS-StartPortForwardingSessionToRemoteHost --parameters "host=${rdsEndpoint},portNumber='5432',localPortNumber='${portNumber}'" --cli-connect-timeout 0`
189
+
190
+ console.log('Starting port forwarding session...')
191
+ const child = exec(portForwardingCommand)
192
+
193
+ // Register child process for cleanup
194
+ activeChildProcesses.push(child)
195
+
196
+ return child
197
+ }
198
+
199
+ async function startPortForwarding (ENV, instanceId, rdsEndpoint, portNumber, retryCount = 0, maxRetries = RETRY_CONFIG.PORT_FORWARDING_MAX_RETRIES) {
200
+ return new Promise((resolve, reject) => {
201
+ const child = executePortForwardingCommand(ENV, instanceId, rdsEndpoint, portNumber)
202
+ const sessionState = monitorPortForwardingSession(child)
203
+
204
+ child.on('close', async (code) => {
205
+ // Remove from active processes
206
+ activeChildProcesses = activeChildProcesses.filter(p => p !== child)
207
+
208
+ try {
209
+ // Handle TargetNotConnected error with retry
210
+ if (code === 254 && sessionState.targetNotConnectedError && retryCount < maxRetries) {
211
+ await handleTargetNotConnectedError(ENV, instanceId, rdsEndpoint, portNumber, retryCount, maxRetries)
212
+ resolve()
213
+ } else if (code !== 0) {
214
+ console.log(`Port forwarding session ended with code ${code}`)
215
+ reject(new Error(`Port forwarding failed with code ${code}`))
216
+ } else {
217
+ console.log(`Port forwarding session ended with code ${code}`)
218
+ resolve()
219
+ }
220
+ } catch (error) {
221
+ console.error('Error during recovery:', error)
222
+ reject(error)
223
+ }
224
+ })
225
+ })
226
+ }
227
+
39
228
  async function main () {
229
+ // Setup process cleanup handlers
230
+ setupProcessCleanup()
231
+
40
232
  try {
41
233
  const ENVS = await readAwsConfig()
42
234
 
@@ -69,7 +261,23 @@ async function main () {
69
261
 
70
262
  const secretsGetCommand = `aws-vault exec ${ENV} -- aws secretsmanager get-secret-value --region ${REGION} --secret-id "${SECRET_NAME}" --query SecretString --output text`
71
263
  const secretString = await runCommand(secretsGetCommand)
72
- const CREDENTIALS = JSON.parse(secretString)
264
+
265
+ if (!secretString) {
266
+ console.error('Failed to retrieve secret value from Secrets Manager.')
267
+ return
268
+ }
269
+
270
+ let CREDENTIALS
271
+ try {
272
+ CREDENTIALS = JSON.parse(secretString)
273
+ if (!CREDENTIALS.username || !CREDENTIALS.password) {
274
+ throw new Error('Missing username or password in credentials')
275
+ }
276
+ } catch (error) {
277
+ console.error('Failed to parse credentials from Secrets Manager:', error.message)
278
+ return
279
+ }
280
+
73
281
  const USERNAME = CREDENTIALS.username
74
282
  const PASSWORD = CREDENTIALS.password
75
283
 
@@ -83,7 +291,7 @@ async function main () {
83
291
  const instanceIdCommand = `aws-vault exec ${ENV} -- aws ec2 describe-instances --region ${REGION} --filters "Name=tag:Name,Values='*bastion*'" "Name=instance-state-name,Values=running" --query "Reservations[].Instances[].[InstanceId] | [0][0]" --output text`
84
292
  const INSTANCE_ID = await runCommand(instanceIdCommand)
85
293
 
86
- if (!INSTANCE_ID) {
294
+ if (!INSTANCE_ID || INSTANCE_ID === 'None') {
87
295
  console.error('Failed to find a running instance with tag Name=*bastion*.')
88
296
  return
89
297
  }
@@ -91,33 +299,12 @@ async function main () {
91
299
  const rdsEndpointCommand = `aws-vault exec ${ENV} -- aws rds describe-db-clusters --region ${REGION} --query "DBClusters[?Status=='available' && ends_with(DBClusterIdentifier, '-rds-aurora')].Endpoint | [0]" --output text`
92
300
  const RDS_ENDPOINT = await runCommand(rdsEndpointCommand)
93
301
 
94
- if (!RDS_ENDPOINT) {
302
+ if (!RDS_ENDPOINT || RDS_ENDPOINT === 'None') {
95
303
  console.error('Failed to find the RDS endpoint.')
96
304
  return
97
305
  }
98
306
 
99
- const portForwardingCommand = `aws-vault exec ${ENV} -- aws ssm start-session --target ${INSTANCE_ID} --document-name AWS-StartPortForwardingSessionToRemoteHost --parameters "host=${RDS_ENDPOINT},portNumber='5432',localPortNumber='${portNumber}'" --cli-connect-timeout 0`
100
-
101
- console.log('Starting port forwarding session...')
102
- const child = exec(portForwardingCommand)
103
-
104
- child.stdout.on('data', (data) => {
105
- const output = data.toString()
106
- if (!output.includes('Starting session with SessionId:') && !output.includes('Port 5433 opened for sessionId')) {
107
- console.log(output)
108
- }
109
- })
110
-
111
- child.stderr.on('data', (data) => {
112
- console.error(data.toString())
113
- })
114
-
115
- child.on('close', (code) => {
116
- console.log(`Port forwarding session ended with code ${code}`)
117
- })
118
-
119
- console.log('Port forwarding session established.')
120
- console.log('Press Ctrl+C to end the session.')
307
+ await startPortForwarding(ENV, INSTANCE_ID, RDS_ENDPOINT, portNumber)
121
308
  } catch (error) {
122
309
  console.error(`Error: ${error.message}`)
123
310
  console.error('Exiting due to unhandled error')
package/package.json CHANGED
@@ -1,16 +1,19 @@
1
1
  {
2
2
  "name": "rds_ssm_connect",
3
- "version": "1.3.9",
3
+ "version": "1.5.0",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@aws-sdk/client-ec2": "^3.830.0",
7
- "@aws-sdk/client-rds": "^3.830.0",
8
- "@aws-sdk/client-ssm": "^3.830.0",
9
- "glob": "^11.0.3",
10
- "inquirer": "^12.6.3",
11
- "rimraf": "^6.0.1"
6
+ "@aws-sdk/client-ec2": "^3.943.0",
7
+ "@aws-sdk/client-rds": "^3.943.0",
8
+ "@aws-sdk/client-ssm": "^3.943.0",
9
+ "glob": "^13.0.0",
10
+ "inquirer": "^13.0.2",
11
+ "rimraf": "^6.1.2"
12
12
  },
13
13
  "bin": {
14
14
  "rds_ssm_connect": "./connect.js"
15
+ },
16
+ "scripts": {
17
+ "test": "node --test test/**/*.test.js"
15
18
  }
16
19
  }
@@ -0,0 +1,185 @@
1
+ import { describe, it, before, after } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { readFile } from 'fs/promises'
4
+ import { tmpdir } from 'os'
5
+ import { join } from 'path'
6
+ import { writeFile, mkdir, rm } from 'fs/promises'
7
+
8
+ // Test AWS config parsing
9
+ describe('AWS Config Parsing', () => {
10
+ let testConfigDir
11
+
12
+ before(async () => {
13
+ testConfigDir = join(tmpdir(), `test-aws-config-${Date.now()}`)
14
+ await mkdir(testConfigDir, { recursive: true })
15
+ })
16
+
17
+ after(async () => {
18
+ await rm(testConfigDir, { recursive: true, force: true })
19
+ })
20
+
21
+ it('should parse AWS config profiles correctly', async () => {
22
+ const configContent = `
23
+ [profile dev]
24
+ region = us-east-2
25
+
26
+ [profile stage]
27
+ region = us-east-2
28
+
29
+ [profile prod]
30
+ region = us-east-1
31
+ `
32
+ const configPath = join(testConfigDir, 'config')
33
+ await writeFile(configPath, configContent)
34
+
35
+ const content = await readFile(configPath, { encoding: 'utf-8' })
36
+ const profiles = content
37
+ .split(/\r?\n/)
38
+ .filter(line => line.startsWith('[') && line.endsWith(']'))
39
+ .map(line => line.slice(1, -1))
40
+ .map(line => line.replace('profile ', '').trim())
41
+
42
+ assert.deepEqual(profiles, ['dev', 'stage', 'prod'])
43
+ })
44
+
45
+ it('should handle empty config file', async () => {
46
+ const configPath = join(testConfigDir, 'empty-config')
47
+ await writeFile(configPath, '')
48
+
49
+ const content = await readFile(configPath, { encoding: 'utf-8' })
50
+ const profiles = content
51
+ .split(/\r?\n/)
52
+ .filter(line => line.startsWith('[') && line.endsWith(']'))
53
+ .map(line => line.slice(1, -1))
54
+ .map(line => line.replace('profile ', '').trim())
55
+
56
+ assert.deepEqual(profiles, [])
57
+ })
58
+
59
+ it('should handle malformed config entries', async () => {
60
+ const configContent = `
61
+ [profile valid-env]
62
+ region = us-east-2
63
+
64
+ this is not a profile
65
+ [another-invalid
66
+
67
+ [profile another-valid]
68
+ `
69
+ const configPath = join(testConfigDir, 'malformed-config')
70
+ await writeFile(configPath, configContent)
71
+
72
+ const content = await readFile(configPath, { encoding: 'utf-8' })
73
+ const profiles = content
74
+ .split(/\r?\n/)
75
+ .filter(line => line.startsWith('[') && line.endsWith(']'))
76
+ .map(line => line.slice(1, -1))
77
+ .map(line => line.replace('profile ', '').trim())
78
+
79
+ assert.deepEqual(profiles, ['valid-env', 'another-valid'])
80
+ })
81
+ })
82
+
83
+ // Test port mapping logic
84
+ describe('Port Mapping', () => {
85
+ it('should map environment suffix to correct port', () => {
86
+ const envPortMapping = {
87
+ dev: '5433',
88
+ stage: '5434',
89
+ 'pre-prod': '5435',
90
+ prod: '5436',
91
+ team1: '5442'
92
+ }
93
+
94
+ const testCases = [
95
+ { env: 'my-project-dev', expected: '5433' },
96
+ { env: 'my-project-stage', expected: '5434' },
97
+ { env: 'my-project-prod', expected: '5436' },
98
+ { env: 'my-project-team1', expected: '5442' },
99
+ { env: 'unknown-env', expected: '5432' } // default
100
+ ]
101
+
102
+ testCases.forEach(({ env, expected }) => {
103
+ const allEnvSuffixes = Object.keys(envPortMapping).sort((a, b) => b.length - a.length)
104
+ const matchedSuffix = allEnvSuffixes.find(suffix => env.endsWith(suffix))
105
+ const portNumber = envPortMapping[matchedSuffix] || '5432'
106
+
107
+ assert.equal(portNumber, expected, `Failed for env: ${env}`)
108
+ })
109
+ })
110
+
111
+ it('should handle longest matching suffix', () => {
112
+ const envPortMapping = {
113
+ dev: '5433',
114
+ 'perf-dev': '5440'
115
+ }
116
+
117
+ const env = 'my-project-perf-dev'
118
+ const allEnvSuffixes = Object.keys(envPortMapping).sort((a, b) => b.length - a.length)
119
+ const matchedSuffix = allEnvSuffixes.find(suffix => env.endsWith(suffix))
120
+ const portNumber = envPortMapping[matchedSuffix] || '5432'
121
+
122
+ // Should match 'perf-dev' not 'dev'
123
+ assert.equal(portNumber, '5440')
124
+ })
125
+ })
126
+
127
+ // Test credentials parsing
128
+ describe('Credentials Parsing', () => {
129
+ it('should parse valid credentials JSON', () => {
130
+ const secretString = JSON.stringify({
131
+ username: 'testuser',
132
+ password: 'testpass123'
133
+ })
134
+
135
+ const credentials = JSON.parse(secretString)
136
+
137
+ assert.equal(credentials.username, 'testuser')
138
+ assert.equal(credentials.password, 'testpass123')
139
+ })
140
+
141
+ it('should detect missing username', () => {
142
+ const secretString = JSON.stringify({
143
+ password: 'testpass123'
144
+ })
145
+
146
+ const credentials = JSON.parse(secretString)
147
+
148
+ assert.ok(!credentials.username, 'Username should be missing')
149
+ })
150
+
151
+ it('should detect missing password', () => {
152
+ const secretString = JSON.stringify({
153
+ username: 'testuser'
154
+ })
155
+
156
+ const credentials = JSON.parse(secretString)
157
+
158
+ assert.ok(!credentials.password, 'Password should be missing')
159
+ })
160
+
161
+ it('should throw on malformed JSON', () => {
162
+ const secretString = '{ invalid json }'
163
+
164
+ assert.throws(() => {
165
+ JSON.parse(secretString)
166
+ }, SyntaxError)
167
+ })
168
+ })
169
+
170
+ // Test retry configuration
171
+ describe('Retry Configuration', () => {
172
+ it('should have valid retry values', () => {
173
+ const RETRY_CONFIG = {
174
+ BASTION_WAIT_MAX_RETRIES: 20,
175
+ BASTION_WAIT_RETRY_DELAY_MS: 15000,
176
+ PORT_FORWARDING_MAX_RETRIES: 2,
177
+ SSM_AGENT_READY_WAIT_MS: 10000
178
+ }
179
+
180
+ assert.ok(RETRY_CONFIG.BASTION_WAIT_MAX_RETRIES > 0, 'Max retries should be positive')
181
+ assert.ok(RETRY_CONFIG.BASTION_WAIT_RETRY_DELAY_MS > 0, 'Retry delay should be positive')
182
+ assert.ok(RETRY_CONFIG.PORT_FORWARDING_MAX_RETRIES >= 0, 'Max retries should be non-negative')
183
+ assert.ok(RETRY_CONFIG.SSM_AGENT_READY_WAIT_MS > 0, 'SSM wait should be positive')
184
+ })
185
+ })