keycloak-api-manager 3.2.1 → 4.0.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.
Files changed (52) hide show
  1. package/.env.example +27 -0
  2. package/Handlers/clientsHandler.js +240 -30
  3. package/Handlers/groupsHandler.js +16 -1
  4. package/Handlers/httpApiHelper.js +87 -0
  5. package/Handlers/realmsHandler.js +26 -4
  6. package/Handlers/usersHandler.js +43 -10
  7. package/README.md +341 -10
  8. package/index.js +149 -29
  9. package/package.json +3 -14
  10. package/test/.mocharc.json +4 -0
  11. package/test/TESTING.md +327 -0
  12. package/test/config/CONFIGURATION.md +170 -0
  13. package/test/config/default.json +36 -0
  14. package/test/config/local.json.example +7 -0
  15. package/test/config/secrets.json.example +7 -0
  16. package/test/diagnostic-protocol-mappers.js +189 -0
  17. package/test/docker-keycloak/DEPLOYMENT_GUIDE.md +262 -0
  18. package/test/docker-keycloak/certs/.gitkeep +7 -0
  19. package/test/docker-keycloak/docker-compose-https.yml +50 -0
  20. package/test/docker-keycloak/docker-compose.yml +59 -0
  21. package/test/docker-keycloak/setup-keycloak.js +501 -0
  22. package/test/enableServerFeatures.js +315 -0
  23. package/test/helpers/config.js +218 -0
  24. package/test/helpers/docker-helpers.js +513 -0
  25. package/test/helpers/setup.js +186 -0
  26. package/test/package.json +18 -0
  27. package/test/setup.js +194 -0
  28. package/test/specs/authenticationManagement.test.js +224 -0
  29. package/test/specs/clientScopes.test.js +388 -0
  30. package/test/specs/clients.test.js +791 -0
  31. package/test/specs/components.test.js +151 -0
  32. package/test/specs/debugClientLibrary.test.js +88 -0
  33. package/test/specs/groups.test.js +362 -0
  34. package/test/specs/identityProviders.test.js +292 -0
  35. package/test/specs/realms.test.js +390 -0
  36. package/test/specs/roles.test.js +322 -0
  37. package/test/specs/users.test.js +445 -0
  38. package/test/testConfig.js +69 -0
  39. package/.mocharc.json +0 -7
  40. package/docker-compose.yml +0 -27
  41. package/test/authenticationManagement.test.js +0 -329
  42. package/test/clientScopes.test.js +0 -256
  43. package/test/clients.test.js +0 -284
  44. package/test/components.test.js +0 -122
  45. package/test/config.js +0 -137
  46. package/test/docker-helpers.js +0 -111
  47. package/test/groups.test.js +0 -284
  48. package/test/identityProviders.test.js +0 -197
  49. package/test/mocha.env.js +0 -55
  50. package/test/realms.test.js +0 -349
  51. package/test/roles.test.js +0 -215
  52. package/test/users.test.js +0 -405
@@ -0,0 +1,513 @@
1
+ const docker = require('dockerode');
2
+ const { spawn, exec } = require('child_process');
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ /**
7
+ * Simple delay function
8
+ */
9
+ function delay(ms) {
10
+ return new Promise((resolve) => setTimeout(resolve, ms));
11
+ }
12
+
13
+ /**
14
+ * Load Docker configuration from PropertiesManager
15
+ */
16
+ function getDockerConfig() {
17
+ try {
18
+ const { conf } = require('propertiesmanager');
19
+ const keycloakConfig = conf.keycloak || {};
20
+ const dockerConfig = conf.docker || {};
21
+ const baseEnvironment = {
22
+ KC_HEALTH_ENABLED: 'true',
23
+ KC_HOSTNAME: 'localhost',
24
+ KC_SCHEME: 'http',
25
+ KC_HTTP_PORT: '8080',
26
+ KC_HOSTNAME_STRICT_HTTPS: 'false',
27
+ KC_BOOTSTRAP_ADMIN_USERNAME: keycloakConfig.adminUsername || 'admin',
28
+ KC_BOOTSTRAP_ADMIN_PASSWORD: keycloakConfig.adminPassword || 'admin',
29
+ };
30
+ const environment = {
31
+ ...baseEnvironment,
32
+ ...(dockerConfig.environment || {}),
33
+ };
34
+
35
+ return {
36
+ image: dockerConfig.image || 'quay.io/keycloak/keycloak:latest',
37
+ containerName: dockerConfig.containerName || 'keycloak-test',
38
+ portMapping: dockerConfig.portMapping || '0.0.0.0:8080:8080',
39
+ remotePort: dockerConfig.remotePort || 8080,
40
+ sshTunnelLocalPort: dockerConfig.sshTunnelLocalPort || 9999,
41
+ environment,
42
+ };
43
+ } catch (err) {
44
+ console.log('⚠ Could not load Docker config from PropertiesManager, using defaults');
45
+ return {
46
+ image: 'quay.io/keycloak/keycloak:latest',
47
+ containerName: 'keycloak-test',
48
+ portMapping: '0.0.0.0:8080:8080',
49
+ remotePort: 8080,
50
+ sshTunnelLocalPort: 9999,
51
+ environment: {
52
+ KC_HEALTH_ENABLED: 'true',
53
+ KC_HOSTNAME: 'localhost',
54
+ KC_SCHEME: 'http',
55
+ KC_HTTP_PORT: '8080',
56
+ KC_HOSTNAME_STRICT_HTTPS: 'false'
57
+ }
58
+ };
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Execute command locally or remotely via SSH (using ssh-agent for auth)
64
+ */
65
+ function executeCommandOutput(command) {
66
+ return new Promise((resolve, reject) => {
67
+ const sshHost = process.env.DOCKER_SSH_HOST;
68
+
69
+ if (!sshHost) {
70
+ // Local execution
71
+ exec(command, { maxBuffer: 10 * 1024 * 1024 }, (error, stdout, stderr) => {
72
+ if (error) {
73
+ reject(error);
74
+ } else {
75
+ resolve(stdout.trim());
76
+ }
77
+ });
78
+ } else {
79
+ // Remote SSH execution - uses SSH key directly
80
+ const sshUser = process.env.DOCKER_SSH_USER || 'smart';
81
+ const homeDir = require('os').homedir();
82
+ const keyPath = `${homeDir}/.ssh/id_ed25519`;
83
+ const sshCommand = `ssh -i ${keyPath} -o StrictHostKeyChecking=no -o PasswordAuthentication=no ${sshUser}@${sshHost} "${command}"`;
84
+
85
+ exec(sshCommand, { maxBuffer: 10 * 1024 * 1024 }, (error, stdout, stderr) => {
86
+ if (error) {
87
+ reject(error);
88
+ } else {
89
+ resolve(stdout.trim());
90
+ }
91
+ });
92
+ }
93
+ });
94
+ }
95
+
96
+ /**
97
+ * Updates local.json with Docker container configuration
98
+ */
99
+ async function updateConfigFromDocker() {
100
+ try {
101
+ const sshHost = process.env.DOCKER_SSH_HOST;
102
+ const dockerConfig = getDockerConfig();
103
+
104
+ if (sshHost) {
105
+ // Remote Docker - get config via SSH commands
106
+ console.log('📡 Reading Keycloak config from remote Docker...');
107
+
108
+ // Get container info via docker inspect using configured name
109
+ const containerInfo = await executeCommandOutput(
110
+ `docker inspect ${dockerConfig.containerName} 2>/dev/null || echo "[]"`
111
+ );
112
+
113
+ if (containerInfo === '[]' || !containerInfo) {
114
+ console.log(`⚠ ${dockerConfig.containerName} container not found on remote host`);
115
+ return;
116
+ }
117
+
118
+ const containers = JSON.parse(containerInfo);
119
+ if (containers.length === 0) {
120
+ console.log('⚠ No Keycloak container found');
121
+ return;
122
+ }
123
+
124
+ const container = containers[0];
125
+
126
+ // Extract environment variables
127
+ const env = {};
128
+ (container.Config?.Env || []).forEach((envVar) => {
129
+ const [key, value] = envVar.split('=');
130
+ env[key] = value;
131
+ });
132
+
133
+ // Get mapped port - use remote host
134
+ const portBindings = container.NetworkSettings?.Ports?.['8080/tcp'];
135
+ const hostPort = portBindings?.[0]?.HostPort || '8080';
136
+ const baseUrl = `http://${sshHost}:${hostPort}`;
137
+
138
+ // Build config object
139
+ const config = {
140
+ test: {
141
+ keycloak: {
142
+ baseUrl,
143
+ realm: 'master',
144
+ clientId: 'admin-cli',
145
+ grantType: 'password',
146
+ adminUsername: env.KC_BOOTSTRAP_ADMIN_USERNAME || env.KEYCLOAK_ADMIN || 'admin',
147
+ adminPassword: env.KC_BOOTSTRAP_ADMIN_PASSWORD || env.KEYCLOAK_ADMIN_PASSWORD || 'admin',
148
+ },
149
+ },
150
+ };
151
+
152
+ // Write to local.json
153
+ const configPath = path.join(__dirname, '../config/local.json');
154
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
155
+
156
+ console.log('✓ Updated local.json with remote Docker config:');
157
+ console.log(` Base URL: ${baseUrl}`);
158
+ console.log(` Admin User: ${config.test.keycloak.adminUsername}`);
159
+
160
+ } else {
161
+ // Local Docker - original logic
162
+ const dockerode = new docker();
163
+ const containers = await dockerode.listContainers();
164
+ const keycloakContainer = containers.find((c) => c.Names.includes('/keycloak-test'));
165
+
166
+ if (!keycloakContainer) {
167
+ console.log('⚠ Keycloak container not found, using default config');
168
+ return;
169
+ }
170
+
171
+ const container = dockerode.getContainer(keycloakContainer.Id);
172
+ const inspect = await container.inspect();
173
+
174
+ // Extract configuration from container
175
+ const env = inspect.Config.Env.reduce((acc, envVar) => {
176
+ const [key, value] = envVar.split('=');
177
+ acc[key] = value;
178
+ return acc;
179
+ }, {});
180
+
181
+ // Get mapped port
182
+ const portBindings = inspect.NetworkSettings.Ports['8080/tcp'];
183
+ const hostPort = portBindings?.[0]?.HostPort || '8080';
184
+ const hostIp = portBindings?.[0]?.HostIp || '0.0.0.0';
185
+ const baseUrl = `http://localhost:${hostPort}`;
186
+
187
+ // Build config object
188
+ const config = {
189
+ test: {
190
+ keycloak: {
191
+ baseUrl,
192
+ realm: 'master',
193
+ clientId: 'admin-cli',
194
+ grantType: 'password',
195
+ adminUsername: env.KC_BOOTSTRAP_ADMIN_USERNAME || env.KEYCLOAK_ADMIN || 'admin',
196
+ adminPassword: env.KC_BOOTSTRAP_ADMIN_PASSWORD || env.KEYCLOAK_ADMIN_PASSWORD || 'admin',
197
+ },
198
+ },
199
+ };
200
+
201
+ // Write to local.json
202
+ const configPath = path.join(__dirname, '../config/local.json');
203
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
204
+
205
+ console.log('✓ Updated local.json with Docker container config:');
206
+ console.log(` Base URL: ${baseUrl}`);
207
+ console.log(` Admin User: ${config.test.keycloak.adminUsername}`);
208
+ }
209
+ } catch (err) {
210
+ console.log(`⚠ Failed to update config from Docker: ${err.message}`);
211
+ console.log(' Using default configuration');
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Starts Docker Compose services (or runs Keycloak container if no compose)
217
+ */
218
+ async function startDocker() {
219
+ const sshHost = process.env.DOCKER_SSH_HOST;
220
+ const dockerConfig = getDockerConfig();
221
+
222
+ console.log(sshHost ? '📡 Starting Keycloak on remote host...' : 'Starting Docker Compose services...');
223
+
224
+ if (sshHost) {
225
+ // Remote Docker - use docker run instead of compose
226
+ const sshUser = process.env.DOCKER_SSH_USER || 'smart';
227
+ return new Promise((resolve, reject) => {
228
+ // Build environment variables from config
229
+ const envVars = Object.entries(dockerConfig.environment)
230
+ .map(([key, value]) => `-e "${key}=${value}"`)
231
+ .join(' ');
232
+
233
+ const commands = [
234
+ // Check if container already exists and stop it
235
+ `docker stop ${dockerConfig.containerName} 2>/dev/null || true`,
236
+ `docker rm ${dockerConfig.containerName} 2>/dev/null || true`,
237
+ // Pull latest Keycloak image
238
+ `docker pull ${dockerConfig.image}`,
239
+ // Run container with health check
240
+ `docker run -d --name ${dockerConfig.containerName} -p ${dockerConfig.portMapping} ${envVars} ${dockerConfig.image} start-dev`,
241
+ ].join(' && ');
242
+
243
+ const homeDir = require('os').homedir();
244
+ const keyPath = `${homeDir}/.ssh/id_ed25519`;
245
+ const sshCommand = `ssh -i ${keyPath} -o StrictHostKeyChecking=no -o PasswordAuthentication=no ${sshUser}@${sshHost} "${commands.replace(/"/g, '\\"')}"`;
246
+
247
+ console.log(` 🔗 Connecting to ${sshUser}@${sshHost}...`);
248
+ console.log(' ⬇️ Downloading Keycloak image & starting container...');
249
+
250
+ const ssh = spawn('sh', ['-c', sshCommand], {
251
+ stdio: 'inherit',
252
+ });
253
+
254
+ ssh.on('close', (code) => {
255
+ if (code !== 0) {
256
+ reject(new Error(`Remote docker run failed with code ${code}`));
257
+ } else {
258
+ console.log(`✓ Keycloak container started on remote host`);
259
+ resolve();
260
+ }
261
+ });
262
+
263
+ ssh.on('error', reject);
264
+ });
265
+ } else {
266
+ // Local Docker - original logic
267
+ return new Promise((resolve, reject) => {
268
+ const command = 'docker';
269
+ const args = ['compose', 'up', '-d'];
270
+
271
+ const compose = spawn(command, args, {
272
+ cwd: process.cwd(),
273
+ stdio: 'inherit',
274
+ });
275
+
276
+ compose.on('close', (code) => {
277
+ if (code !== 0) {
278
+ reject(new Error(`docker compose up failed with code ${code}`));
279
+ } else {
280
+ console.log('✓ Docker Compose services started');
281
+ resolve();
282
+ }
283
+ });
284
+
285
+ compose.on('error', reject);
286
+ });
287
+ }
288
+ }
289
+
290
+ /**
291
+ * Stops Docker Compose services (or removes Keycloak container)
292
+ */
293
+ async function stopDocker() {
294
+ const sshHost = process.env.DOCKER_SSH_HOST;
295
+ const dockerConfig = getDockerConfig();
296
+
297
+ console.log(sshHost ? '📡 Stopping Keycloak on remote host...' : 'Stopping Docker Compose services...');
298
+
299
+ if (sshHost) {
300
+ // Remote Docker - stop and remove container
301
+ const sshUser = process.env.DOCKER_SSH_USER || 'smart';
302
+ return new Promise((resolve, reject) => {
303
+ const commands = [
304
+ `docker stop ${dockerConfig.containerName} 2>/dev/null || true`,
305
+ `docker rm ${dockerConfig.containerName} 2>/dev/null || true`,
306
+ ].join(' && ');
307
+
308
+ const homeDir = require('os').homedir();
309
+ const keyPath = `${homeDir}/.ssh/id_ed25519`;
310
+ const sshCommand = `ssh -i ${keyPath} -o StrictHostKeyChecking=no -o PasswordAuthentication=no ${sshUser}@${sshHost} "${commands}"`;
311
+
312
+ console.log(` 🔗 Connecting to ${sshUser}@${sshHost}...`);
313
+
314
+ const ssh = spawn('sh', ['-c', sshCommand], {
315
+ stdio: 'inherit',
316
+ });
317
+
318
+ ssh.on('close', (code) => {
319
+ if (code !== 0) {
320
+ reject(new Error(`Remote docker stop failed with code ${code}`));
321
+ } else {
322
+ console.log('✓ Keycloak container stopped on remote host');
323
+ resolve();
324
+ }
325
+ });
326
+
327
+ ssh.on('error', reject);
328
+ });
329
+ } else {
330
+ // Local Docker - original logic
331
+ return new Promise((resolve, reject) => {
332
+ const command = 'docker';
333
+ const args = ['compose', 'down', '--volumes'];
334
+
335
+ const compose = spawn(command, args, {
336
+ cwd: process.cwd(),
337
+ stdio: 'inherit',
338
+ });
339
+
340
+ compose.on('close', (code) => {
341
+ if (code !== 0) {
342
+ reject(new Error(`docker compose down failed with code ${code}`));
343
+ } else {
344
+ console.log('✓ Docker Compose services stopped');
345
+ resolve();
346
+ }
347
+ });
348
+
349
+ compose.on('error', reject);
350
+ });
351
+ }
352
+ }
353
+
354
+ /**
355
+ * Waits for a service to be healthy
356
+ */
357
+ async function waitForHealthy(maxRetries = 30, delayMs = 2000) {
358
+ const sshHost = process.env.DOCKER_SSH_HOST;
359
+ let retries = maxRetries;
360
+
361
+ while (retries > 0) {
362
+ try {
363
+ if (sshHost) {
364
+ // Remote Docker - check health via curl on root endpoint (returns 302 redirect when ready)
365
+ const sshUser = process.env.DOCKER_SSH_USER || 'smart';
366
+ const healthCheckCmd = `curl -sf -o /dev/null -w "%{http_code}" http://localhost:8080/`;
367
+ const homeDir = require('os').homedir();
368
+ const keyPath = `${homeDir}/.ssh/id_ed25519`;
369
+ const sshCommand = `ssh -i ${keyPath} -o StrictHostKeyChecking=no -o PasswordAuthentication=no ${sshUser}@${sshHost} "${healthCheckCmd}"`;
370
+
371
+ try {
372
+ const result = await new Promise((resolve, reject) => {
373
+ exec(sshCommand, { maxBuffer: 10 * 1024 * 1024 }, (error, stdout, stderr) => {
374
+ if (error) {
375
+ reject(error);
376
+ } else {
377
+ resolve(stdout.trim());
378
+ }
379
+ });
380
+ });
381
+
382
+ // Keycloak returns 302 (redirect) when ready
383
+ if (result === '302' || result === '200') {
384
+ console.log('✓ Keycloak container is healthy');
385
+ return;
386
+ }
387
+ } catch (err) {
388
+ // Health check might fail, that's OK, we retry
389
+ }
390
+
391
+ retries--;
392
+ if (retries > 0) {
393
+ console.log(`Waiting for Keycloak to be healthy... (${retries} retries left)`);
394
+ await delay(delayMs);
395
+ }
396
+ } else {
397
+ // Local Docker - original logic
398
+ const dockerode = new docker();
399
+ const containers = await dockerode.listContainers();
400
+ const keycloakContainer = containers.find((c) => c.Names.includes('/keycloak-test'));
401
+
402
+ if (!keycloakContainer) {
403
+ throw new Error('Keycloak container not found');
404
+ }
405
+
406
+ const container = dockerode.getContainer(keycloakContainer.Id);
407
+ const inspect = await container.inspect();
408
+
409
+ if (inspect.State.Health?.Status === 'healthy') {
410
+ console.log('✓ Keycloak container is healthy');
411
+ return;
412
+ }
413
+
414
+ retries--;
415
+ if (retries > 0) {
416
+ console.log(
417
+ `Waiting for Keycloak to be healthy (${inspect.State.Health?.Status || 'unknown'})... (${retries} retries left)`
418
+ );
419
+ await delay(delayMs);
420
+ }
421
+ }
422
+ } catch (err) {
423
+ retries--;
424
+ if (retries > 0) {
425
+ console.log(`Waiting for services... (${retries} retries left)`);
426
+ await delay(delayMs);
427
+ } else {
428
+ throw err;
429
+ }
430
+ }
431
+ }
432
+
433
+ throw new Error('Service failed to become healthy in time');
434
+ }
435
+
436
+ /**
437
+ * Create SSH tunnel for remote Keycloak access via localhost
438
+ * Allows HTTP access without HTTPS enforcement
439
+ */
440
+ let sshTunnelProcess = null;
441
+
442
+ async function createSSHTunnel() {
443
+ return new Promise((resolve, reject) => {
444
+ const sshHost = process.env.DOCKER_SSH_HOST;
445
+ if (!sshHost) {
446
+ resolve(null);
447
+ return;
448
+ }
449
+
450
+ const dockerConfig = getDockerConfig();
451
+ const sshUser = process.env.DOCKER_SSH_USER || 'smart';
452
+ const localPort = dockerConfig.sshTunnelLocalPort;
453
+ const remoteHost = 'localhost';
454
+ const remotePort = 8080;
455
+ const homeDir = require('os').homedir();
456
+ const keyPath = `${homeDir}/.ssh/id_ed25519`;
457
+
458
+ const tunnelCommand = [
459
+ 'ssh',
460
+ '-i', keyPath,
461
+ '-o', 'StrictHostKeyChecking=no',
462
+ '-o', 'PasswordAuthentication=no',
463
+ '-N',
464
+ '-L', `127.0.0.1:${localPort}:${remoteHost}:${remotePort}`,
465
+ `${sshUser}@${sshHost}`
466
+ ];
467
+
468
+ console.log(`🔗 Creating SSH tunnel to ${sshHost}:${remotePort} -> 127.0.0.1:${localPort}...`);
469
+
470
+ sshTunnelProcess = spawn(tunnelCommand[0], tunnelCommand.slice(1), {
471
+ stdio: ['ignore', 'pipe', 'pipe']
472
+ });
473
+
474
+ sshTunnelProcess.on('error', (err) => {
475
+ reject(new Error(`Failed to create SSH tunnel: ${err.message}`));
476
+ });
477
+
478
+ sshTunnelProcess.stderr.on('data', (data) => {
479
+ const msg = data.toString().trim();
480
+ if (msg) console.log(`🔗 SSH tunnel: ${msg}`);
481
+ });
482
+
483
+ // Give tunnel time to establish
484
+ setTimeout(() => {
485
+ if (sshTunnelProcess.exitCode !== null) {
486
+ reject(new Error('SSH tunnel process exited unexpectedly'));
487
+ } else {
488
+ console.log(`✓ SSH tunnel established on 127.0.0.1:${localPort}`);
489
+ resolve(`127.0.0.1:${localPort}`);
490
+ }
491
+ }, 2000);
492
+ });
493
+ }
494
+
495
+ /**
496
+ * Close SSH tunnel
497
+ */
498
+ function closeSSHTunnel() {
499
+ if (sshTunnelProcess) {
500
+ sshTunnelProcess.kill('SIGTERM');
501
+ sshTunnelProcess = null;
502
+ console.log('✓ SSH tunnel closed');
503
+ }
504
+ }
505
+
506
+ module.exports = {
507
+ startDocker,
508
+ stopDocker,
509
+ waitForHealthy,
510
+ updateConfigFromDocker,
511
+ createSSHTunnel,
512
+ closeSSHTunnel,
513
+ };
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Mocha Root Setup Hook
3
+ * Orchestrates Docker container lifecycle and Keycloak initialization
4
+ */
5
+
6
+ const { startDocker, stopDocker, waitForHealthy, updateConfigFromDocker, createSSHTunnel, closeSSHTunnel } = require('./docker-helpers');
7
+ const { initializeAdminClient, setupTestRealm, cleanupTestRealm, resetConfig } = require('./config');
8
+
9
+ // Store tunnel state for cleanup
10
+ let sshTunnelUrl = null;
11
+
12
+ /**
13
+ * Attempt to connect to Keycloak, with automatic SSH tunnel retry
14
+ */
15
+ async function connectWithRetry() {
16
+ try {
17
+ console.log('Attempting Keycloak connection...');
18
+ await initializeAdminClient();
19
+ console.log('✓ Connected successfully to Keycloak\n');
20
+ } catch (err) {
21
+ // Check if connection was refused (tunnel might be needed)
22
+ if (err.message && err.message.includes('ECONNREFUSED') && err.message.includes('127.0.0.1:9998')) {
23
+ console.log('⚠ Direct connection failed (ECONNREFUSED on 127.0.0.1:9998)');
24
+ console.log(' Attempting to create SSH tunnel to smart-dell-sml.crs4.it...\n');
25
+
26
+ try {
27
+ // Create SSH tunnel
28
+ sshTunnelUrl = await createSSHTunnel();
29
+
30
+ if (sshTunnelUrl) {
31
+ console.log(`✓ SSH tunnel created: http://${sshTunnelUrl}`);
32
+
33
+ // Update config to use tunnel
34
+ const fs = require('fs');
35
+ const path = require('path');
36
+ const configPath = path.join(__dirname, '../config/local.json');
37
+
38
+ // Create or update local.json
39
+ let config = {};
40
+ if (fs.existsSync(configPath)) {
41
+ config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
42
+ }
43
+
44
+ if (!config.test) config.test = {};
45
+ if (!config.test.keycloak) config.test.keycloak = {};
46
+ config.test.keycloak.baseUrl = `http://${sshTunnelUrl}`;
47
+
48
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
49
+ console.log(`✓ Updated config to use SSH tunnel\n`);
50
+
51
+ // Reset config cache and retry connection
52
+ resetConfig();
53
+ await initializeAdminClient();
54
+ console.log('✓ Connected successfully via SSH tunnel\n');
55
+ } else {
56
+ throw new Error('SSH tunnel creation failed - returned null');
57
+ }
58
+ } catch (tunnelErr) {
59
+ console.error('✗ SSH tunnel connection failed:');
60
+ console.error(` Error: ${tunnelErr.message}`);
61
+ console.error('\nTroubleshooting:');
62
+ console.error(' 1. Verify SSH key: ~/.ssh/id_ed25519 exists');
63
+ console.error(' 2. Verify remote host: smart-dell-sml.crs4.it is reachable');
64
+ console.error(' 3. Verify remote Keycloak: running on smart@smart-dell-sml.crs4.it');
65
+ console.error(' 4. Manual tunnel alternative: ssh -L 127.0.0.1:9998:127.0.0.1:8080 smart@smart-dell-sml.crs4.it');
66
+ throw new Error(`Failed to connect to Keycloak. Direct connection (127.0.0.1:9998) failed and SSH tunnel creation also failed: ${tunnelErr.message}`);
67
+ }
68
+ } else {
69
+ // Not a connection refused error - propagate as-is
70
+ throw err;
71
+ }
72
+ }
73
+ }
74
+
75
+ // Root hook plugin for Mocha
76
+ exports.mochaHooks = {
77
+ async beforeAll() {
78
+ this.timeout(120000); // 2 minutes max for setup
79
+
80
+ console.log('\n========== TEST SETUP ==========');
81
+
82
+ if (process.env.SKIP_TEST_SETUP === 'true') {
83
+ console.log('Skipping global test setup (SKIP_TEST_SETUP=true)\n');
84
+ return;
85
+ }
86
+
87
+ // Check if using remote Keycloak (skip Docker)
88
+ const useRemoteKeycloak = process.env.USE_REMOTE_KEYCLOAK === 'true';
89
+ const useRemoteDocker = !!process.env.DOCKER_SSH_HOST;
90
+
91
+ try {
92
+ if (useRemoteKeycloak) {
93
+ console.log('Using remote Keycloak (skip Docker startup)');
94
+ console.log('Configuration from test/config/*.json files\n');
95
+ } else if (useRemoteDocker) {
96
+ console.log(`Starting Docker containers on remote host ${process.env.DOCKER_SSH_HOST}...`);
97
+
98
+ // Start Docker Compose on remote host
99
+ await startDocker();
100
+
101
+ // Wait for services to be healthy on remote host
102
+ await waitForHealthy();
103
+
104
+ // Update configuration from remote Docker container
105
+ await updateConfigFromDocker();
106
+
107
+ // Create SSH tunnel for local HTTP access (avoids HTTPS enforcement)
108
+ sshTunnelUrl = await createSSHTunnel();
109
+
110
+ // Update config to use tunnel
111
+ if (sshTunnelUrl) {
112
+ const fs = require('fs');
113
+ const path = require('path');
114
+ const configPath = path.join(__dirname, '../config/local.json');
115
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
116
+ config.test.keycloak.baseUrl = `http://${sshTunnelUrl}`;
117
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
118
+ console.log(`✓ Updated config to use SSH tunnel: http://${sshTunnelUrl}`);
119
+ }
120
+
121
+ // Reset config cache so it reloads from updated local.json
122
+ resetConfig();
123
+ } else {
124
+ console.log('Starting Docker containers locally...');
125
+
126
+ // Start Docker Compose locally
127
+ await startDocker();
128
+
129
+ // Wait for services to be healthy locally
130
+ await waitForHealthy();
131
+
132
+ // Update configuration from local Docker container
133
+ await updateConfigFromDocker();
134
+ }
135
+
136
+ // Connect to Keycloak (with automatic SSH tunnel retry if needed)
137
+ await connectWithRetry();
138
+
139
+ // Setup test realm
140
+ await setupTestRealm();
141
+
142
+ console.log('✓ Test environment ready\n');
143
+ } catch (err) {
144
+ console.error('✗ Test setup failed:', err.message);
145
+ throw err;
146
+ }
147
+ },
148
+
149
+ async afterAll() {
150
+ this.timeout(60000); // 1 minute max for teardown
151
+
152
+ console.log('\n========== TEST TEARDOWN ==========');
153
+
154
+ if (process.env.SKIP_TEST_SETUP === 'true') {
155
+ console.log('Skipping global test teardown (SKIP_TEST_SETUP=true)\n');
156
+ return;
157
+ }
158
+
159
+ const useRemoteKeycloak = process.env.USE_REMOTE_KEYCLOAK === 'true';
160
+ const useRemoteDocker = !!process.env.DOCKER_SSH_HOST;
161
+
162
+ try {
163
+ // Cleanup Keycloak test realm
164
+ await cleanupTestRealm();
165
+
166
+ // Close SSH tunnel if open
167
+ if (sshTunnelUrl) {
168
+ closeSSHTunnel();
169
+ sshTunnelUrl = null;
170
+ }
171
+
172
+ // Stop Docker Compose only if using local or remote Docker (not pre-deployed)
173
+ if (!useRemoteKeycloak && !useRemoteDocker) {
174
+ await stopDocker();
175
+ } else if (useRemoteDocker) {
176
+ // Stop Docker on remote host
177
+ await stopDocker();
178
+ }
179
+
180
+ console.log('✓ Test environment cleaned up\n');
181
+ } catch (err) {
182
+ console.error('✗ Test teardown failed:', err.message);
183
+ // Don't throw during cleanup to allow partial cleanup
184
+ }
185
+ },
186
+ };