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.
- package/.env.example +27 -0
- package/Handlers/clientsHandler.js +240 -30
- package/Handlers/groupsHandler.js +16 -1
- package/Handlers/httpApiHelper.js +87 -0
- package/Handlers/realmsHandler.js +26 -4
- package/Handlers/usersHandler.js +43 -10
- package/README.md +341 -10
- package/index.js +149 -29
- package/package.json +3 -14
- package/test/.mocharc.json +4 -0
- package/test/TESTING.md +327 -0
- package/test/config/CONFIGURATION.md +170 -0
- package/test/config/default.json +36 -0
- package/test/config/local.json.example +7 -0
- package/test/config/secrets.json.example +7 -0
- package/test/diagnostic-protocol-mappers.js +189 -0
- package/test/docker-keycloak/DEPLOYMENT_GUIDE.md +262 -0
- package/test/docker-keycloak/certs/.gitkeep +7 -0
- package/test/docker-keycloak/docker-compose-https.yml +50 -0
- package/test/docker-keycloak/docker-compose.yml +59 -0
- package/test/docker-keycloak/setup-keycloak.js +501 -0
- package/test/enableServerFeatures.js +315 -0
- package/test/helpers/config.js +218 -0
- package/test/helpers/docker-helpers.js +513 -0
- package/test/helpers/setup.js +186 -0
- package/test/package.json +18 -0
- package/test/setup.js +194 -0
- package/test/specs/authenticationManagement.test.js +224 -0
- package/test/specs/clientScopes.test.js +388 -0
- package/test/specs/clients.test.js +791 -0
- package/test/specs/components.test.js +151 -0
- package/test/specs/debugClientLibrary.test.js +88 -0
- package/test/specs/groups.test.js +362 -0
- package/test/specs/identityProviders.test.js +292 -0
- package/test/specs/realms.test.js +390 -0
- package/test/specs/roles.test.js +322 -0
- package/test/specs/users.test.js +445 -0
- package/test/testConfig.js +69 -0
- package/.mocharc.json +0 -7
- package/docker-compose.yml +0 -27
- package/test/authenticationManagement.test.js +0 -329
- package/test/clientScopes.test.js +0 -256
- package/test/clients.test.js +0 -284
- package/test/components.test.js +0 -122
- package/test/config.js +0 -137
- package/test/docker-helpers.js +0 -111
- package/test/groups.test.js +0 -284
- package/test/identityProviders.test.js +0 -197
- package/test/mocha.env.js +0 -55
- package/test/realms.test.js +0 -349
- package/test/roles.test.js +0 -215
- 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
|
+
};
|