llng-mcp 0.1.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 (114) hide show
  1. package/.github/workflows/ci.yml +77 -0
  2. package/.prettierrc +7 -0
  3. package/LICENSE +661 -0
  4. package/README.md +502 -0
  5. package/dist/__tests__/api-transport.test.d.ts +1 -0
  6. package/dist/__tests__/api-transport.test.js +577 -0
  7. package/dist/__tests__/api-transport.test.js.map +1 -0
  8. package/dist/__tests__/config.test.d.ts +1 -0
  9. package/dist/__tests__/config.test.js +472 -0
  10. package/dist/__tests__/config.test.js.map +1 -0
  11. package/dist/__tests__/integration/api-mode.test.d.ts +1 -0
  12. package/dist/__tests__/integration/api-mode.test.js +199 -0
  13. package/dist/__tests__/integration/api-mode.test.js.map +1 -0
  14. package/dist/__tests__/integration/oidc-rp.test.d.ts +1 -0
  15. package/dist/__tests__/integration/oidc-rp.test.js +120 -0
  16. package/dist/__tests__/integration/oidc-rp.test.js.map +1 -0
  17. package/dist/__tests__/integration/ssh-mode.test.d.ts +1 -0
  18. package/dist/__tests__/integration/ssh-mode.test.js +101 -0
  19. package/dist/__tests__/integration/ssh-mode.test.js.map +1 -0
  20. package/dist/__tests__/k8s-transport.test.d.ts +1 -0
  21. package/dist/__tests__/k8s-transport.test.js +254 -0
  22. package/dist/__tests__/k8s-transport.test.js.map +1 -0
  23. package/dist/__tests__/oidc-tools.test.d.ts +1 -0
  24. package/dist/__tests__/oidc-tools.test.js +457 -0
  25. package/dist/__tests__/oidc-tools.test.js.map +1 -0
  26. package/dist/__tests__/registry.test.d.ts +1 -0
  27. package/dist/__tests__/registry.test.js +96 -0
  28. package/dist/__tests__/registry.test.js.map +1 -0
  29. package/dist/__tests__/ssh-transport.test.d.ts +1 -0
  30. package/dist/__tests__/ssh-transport.test.js +618 -0
  31. package/dist/__tests__/ssh-transport.test.js.map +1 -0
  32. package/dist/__tests__/tools.test.d.ts +1 -0
  33. package/dist/__tests__/tools.test.js +525 -0
  34. package/dist/__tests__/tools.test.js.map +1 -0
  35. package/dist/config.d.ts +65 -0
  36. package/dist/config.js +506 -0
  37. package/dist/config.js.map +1 -0
  38. package/dist/index.d.ts +2 -0
  39. package/dist/index.js +42 -0
  40. package/dist/index.js.map +1 -0
  41. package/dist/resources/documentation.d.ts +5 -0
  42. package/dist/resources/documentation.js +56 -0
  43. package/dist/resources/documentation.js.map +1 -0
  44. package/dist/tools/cli-utilities.d.ts +3 -0
  45. package/dist/tools/cli-utilities.js +187 -0
  46. package/dist/tools/cli-utilities.js.map +1 -0
  47. package/dist/tools/config.d.ts +6 -0
  48. package/dist/tools/config.js +326 -0
  49. package/dist/tools/config.js.map +1 -0
  50. package/dist/tools/consents.d.ts +3 -0
  51. package/dist/tools/consents.js +39 -0
  52. package/dist/tools/consents.js.map +1 -0
  53. package/dist/tools/instances.d.ts +3 -0
  54. package/dist/tools/instances.js +14 -0
  55. package/dist/tools/instances.js.map +1 -0
  56. package/dist/tools/oidc-rp.d.ts +6 -0
  57. package/dist/tools/oidc-rp.js +246 -0
  58. package/dist/tools/oidc-rp.js.map +1 -0
  59. package/dist/tools/oidc.d.ts +3 -0
  60. package/dist/tools/oidc.js +343 -0
  61. package/dist/tools/oidc.js.map +1 -0
  62. package/dist/tools/secondfactors.d.ts +3 -0
  63. package/dist/tools/secondfactors.js +62 -0
  64. package/dist/tools/secondfactors.js.map +1 -0
  65. package/dist/tools/sessions.d.ts +6 -0
  66. package/dist/tools/sessions.js +300 -0
  67. package/dist/tools/sessions.js.map +1 -0
  68. package/dist/transport/api.d.ts +35 -0
  69. package/dist/transport/api.js +327 -0
  70. package/dist/transport/api.js.map +1 -0
  71. package/dist/transport/interface.d.ts +50 -0
  72. package/dist/transport/interface.js +2 -0
  73. package/dist/transport/interface.js.map +1 -0
  74. package/dist/transport/k8s.d.ts +41 -0
  75. package/dist/transport/k8s.js +303 -0
  76. package/dist/transport/k8s.js.map +1 -0
  77. package/dist/transport/registry.d.ts +20 -0
  78. package/dist/transport/registry.js +91 -0
  79. package/dist/transport/registry.js.map +1 -0
  80. package/dist/transport/ssh.d.ts +37 -0
  81. package/dist/transport/ssh.js +353 -0
  82. package/dist/transport/ssh.js.map +1 -0
  83. package/docker-compose.test.yml +16 -0
  84. package/eslint.config.js +21 -0
  85. package/package.json +38 -0
  86. package/src/__tests__/api-transport.test.ts +746 -0
  87. package/src/__tests__/config.test.ts +587 -0
  88. package/src/__tests__/integration/api-mode.test.ts +229 -0
  89. package/src/__tests__/integration/oidc-rp.test.ts +138 -0
  90. package/src/__tests__/integration/ssh-mode.test.ts +113 -0
  91. package/src/__tests__/k8s-transport.test.ts +342 -0
  92. package/src/__tests__/oidc-tools.test.ts +554 -0
  93. package/src/__tests__/registry.test.ts +110 -0
  94. package/src/__tests__/ssh-transport.test.ts +805 -0
  95. package/src/__tests__/tools.test.ts +735 -0
  96. package/src/config.ts +605 -0
  97. package/src/index.ts +48 -0
  98. package/src/resources/documentation.ts +65 -0
  99. package/src/tools/cli-utilities.ts +207 -0
  100. package/src/tools/config.ts +382 -0
  101. package/src/tools/consents.ts +50 -0
  102. package/src/tools/instances.ts +21 -0
  103. package/src/tools/oidc-rp.ts +299 -0
  104. package/src/tools/oidc.ts +434 -0
  105. package/src/tools/secondfactors.ts +78 -0
  106. package/src/tools/sessions.ts +342 -0
  107. package/src/transport/api.ts +429 -0
  108. package/src/transport/interface.ts +58 -0
  109. package/src/transport/k8s.ts +367 -0
  110. package/src/transport/registry.ts +105 -0
  111. package/src/transport/ssh.ts +430 -0
  112. package/tsconfig.json +16 -0
  113. package/vitest.config.ts +8 -0
  114. package/vitest.integration.config.ts +9 -0
@@ -0,0 +1,805 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { SshTransport } from "../transport/ssh.js";
3
+ import { SshConfig } from "../config.js";
4
+ import * as child_process from "child_process";
5
+ import { EventEmitter } from "events";
6
+
7
+ vi.mock("child_process");
8
+
9
+ // Helper to create a mock ChildProcess
10
+ class MockChildProcess extends EventEmitter {
11
+ stdin = {
12
+ write: vi.fn(),
13
+ end: vi.fn(),
14
+ };
15
+ stdout = new EventEmitter();
16
+ stderr = new EventEmitter();
17
+
18
+ constructor() {
19
+ super();
20
+ }
21
+ }
22
+
23
+ function mockSpawn(stdout: string, stderr = "", exitCode = 0): MockChildProcess {
24
+ const proc = new MockChildProcess();
25
+
26
+ // Simulate async process execution
27
+ process.nextTick(() => {
28
+ if (stdout) {
29
+ proc.stdout.emit("data", Buffer.from(stdout));
30
+ }
31
+ if (stderr) {
32
+ proc.stderr.emit("data", Buffer.from(stderr));
33
+ }
34
+ proc.emit("close", exitCode);
35
+ });
36
+
37
+ return proc;
38
+ }
39
+
40
+ describe("SshTransport", () => {
41
+ const defaultConfig: SshConfig = {
42
+ cliPath: "/usr/share/lemonldap-ng/bin/lemonldap-ng-cli",
43
+ sessionsPath: "/usr/share/lemonldap-ng/bin/lemonldap-ng-sessions",
44
+ configEditorPath: "/usr/share/lemonldap-ng/bin/lmConfigEditor",
45
+ };
46
+
47
+ let spawnCalls: Array<{ cmd: string; args: string[] }> = [];
48
+
49
+ // Helper to set up mock with spawn call tracking
50
+ const setupSpawnMock = (stdout: string, stderr = "", exitCode = 0) => {
51
+ vi.mocked(child_process.spawn).mockImplementation((cmd: string, args?: readonly string[]) => {
52
+ spawnCalls.push({ cmd, args: args ? [...args] : [] });
53
+ return mockSpawn(stdout, stderr, exitCode) as any;
54
+ });
55
+ };
56
+
57
+ beforeEach(() => {
58
+ vi.resetAllMocks();
59
+ spawnCalls = [];
60
+
61
+ // Setup default spy to capture spawn calls
62
+ setupSpawnMock('{"success": true}');
63
+ });
64
+
65
+ afterEach(() => {
66
+ spawnCalls = [];
67
+ });
68
+
69
+ describe("Local mode (no host)", () => {
70
+ it("configInfo runs lemonldap-ng-cli info directly", async () => {
71
+ const configInfoOutput = `Num : 42
72
+ Author : admin
73
+ Date : 2025-01-30
74
+ Log : Test config`;
75
+
76
+ setupSpawnMock(configInfoOutput);
77
+
78
+ const transport = new SshTransport(defaultConfig);
79
+ const result = await transport.configInfo();
80
+
81
+ expect(spawnCalls).toHaveLength(1);
82
+ expect(spawnCalls[0].cmd).toBe("/usr/share/lemonldap-ng/bin/lemonldap-ng-cli");
83
+ expect(spawnCalls[0].args).toEqual(["info"]);
84
+ expect(result).toEqual({
85
+ cfgNum: 42,
86
+ cfgAuthor: "admin",
87
+ cfgDate: "2025-01-30",
88
+ cfgLog: "Test config",
89
+ });
90
+ });
91
+
92
+ it("configGet passes keys correctly", async () => {
93
+ setupSpawnMock(
94
+ JSON.stringify({ domain: "example.com", portal: "https://portal.example.com" }),
95
+ );
96
+
97
+ const transport = new SshTransport(defaultConfig);
98
+ const result = await transport.configGet(["domain", "portal"]);
99
+
100
+ expect(spawnCalls).toHaveLength(1);
101
+ expect(spawnCalls[0].cmd).toBe("/usr/share/lemonldap-ng/bin/lemonldap-ng-cli");
102
+ expect(spawnCalls[0].args).toEqual(["-json", "get", "domain", "portal"]);
103
+ expect(result).toEqual({ domain: "example.com", portal: "https://portal.example.com" });
104
+ });
105
+
106
+ it("configSet passes key-value pairs and optional cfgLog", async () => {
107
+ setupSpawnMock("");
108
+
109
+ const transport = new SshTransport(defaultConfig);
110
+ await transport.configSet(
111
+ { domain: "example.com", portal: "https://portal.example.com" },
112
+ "Updated config",
113
+ );
114
+
115
+ expect(spawnCalls).toHaveLength(1);
116
+ expect(spawnCalls[0].cmd).toBe("/usr/share/lemonldap-ng/bin/lemonldap-ng-cli");
117
+ expect(spawnCalls[0].args).toEqual([
118
+ "set",
119
+ "-yes",
120
+ "1",
121
+ "domain",
122
+ "example.com",
123
+ "portal",
124
+ "https://portal.example.com",
125
+ "-log",
126
+ "Updated config",
127
+ ]);
128
+ });
129
+
130
+ it("configSet without cfgLog omits -log argument", async () => {
131
+ setupSpawnMock("");
132
+
133
+ const transport = new SshTransport(defaultConfig);
134
+ await transport.configSet({ domain: "example.com" });
135
+
136
+ expect(spawnCalls).toHaveLength(1);
137
+ expect(spawnCalls[0].args).toEqual(["set", "-yes", "1", "domain", "example.com"]);
138
+ expect(spawnCalls[0].args).not.toContain("-log");
139
+ });
140
+
141
+ it("sessionSearch builds correct args from SessionFilter", async () => {
142
+ setupSpawnMock('[{"id": "session1"}]');
143
+
144
+ const transport = new SshTransport(defaultConfig);
145
+ await transport.sessionSearch({
146
+ where: { uid: "john", ipAddr: "192.168.1.1" },
147
+ select: ["uid", "ipAddr", "_startTime"],
148
+ backend: "persistent",
149
+ count: true,
150
+ });
151
+
152
+ expect(spawnCalls).toHaveLength(1);
153
+ expect(spawnCalls[0].cmd).toBe("/usr/share/lemonldap-ng/bin/lemonldap-ng-sessions");
154
+ expect(spawnCalls[0].args).toEqual([
155
+ "search",
156
+ "--where",
157
+ "uid=john",
158
+ "--where",
159
+ "ipAddr=192.168.1.1",
160
+ "--select",
161
+ "uid,ipAddr,_startTime",
162
+ "--backend",
163
+ "persistent",
164
+ "--count",
165
+ ]);
166
+ });
167
+
168
+ it("sessionDelete uses llngDeleteSession script", async () => {
169
+ setupSpawnMock("");
170
+
171
+ const transport = new SshTransport(defaultConfig);
172
+ await transport.sessionDelete(["id1", "id2"], { backend: "persistent" });
173
+
174
+ expect(spawnCalls).toHaveLength(2);
175
+ expect(spawnCalls[0].cmd).toBe("/usr/share/lemonldap-ng/bin/llngDeleteSession");
176
+ expect(spawnCalls[0].args).toEqual(["id1", "--backend", "persistent"]);
177
+ expect(spawnCalls[1].cmd).toBe("/usr/share/lemonldap-ng/bin/llngDeleteSession");
178
+ expect(spawnCalls[1].args).toEqual(["id2", "--backend", "persistent"]);
179
+ });
180
+
181
+ it("sessionDelete with where filter uses lemonldap-ng-sessions delete", async () => {
182
+ setupSpawnMock("");
183
+
184
+ const transport = new SshTransport(defaultConfig);
185
+ await transport.sessionDelete([], { where: { uid: "john" } });
186
+
187
+ expect(spawnCalls).toHaveLength(1);
188
+ expect(spawnCalls[0].cmd).toBe("/usr/share/lemonldap-ng/bin/lemonldap-ng-sessions");
189
+ expect(spawnCalls[0].args).toEqual(["delete", "--where", "uid=john"]);
190
+ });
191
+
192
+ it("configTestEmail calls test-email via CLI", async () => {
193
+ setupSpawnMock("");
194
+
195
+ const transport = new SshTransport(defaultConfig);
196
+ await transport.configTestEmail("test@example.com");
197
+
198
+ expect(spawnCalls).toHaveLength(1);
199
+ expect(spawnCalls[0].cmd).toBe("/usr/share/lemonldap-ng/bin/lemonldap-ng-cli");
200
+ expect(spawnCalls[0].args).toEqual(["test-email", "test@example.com"]);
201
+ });
202
+
203
+ it("secondFactorsGet throws not supported error", async () => {
204
+ const transport = new SshTransport(defaultConfig);
205
+
206
+ await expect(transport.secondFactorsGet("john")).rejects.toThrow(
207
+ "secondFactorsGet is not supported via CLI. Use API mode.",
208
+ );
209
+ });
210
+ });
211
+
212
+ describe("SSH mode", () => {
213
+ it("configInfo with host runs ssh command", async () => {
214
+ const config: SshConfig = {
215
+ ...defaultConfig,
216
+ host: "server.example.com",
217
+ };
218
+
219
+ setupSpawnMock("Num : 42\nAuthor : admin\nDate : 2025-01-30");
220
+
221
+ const transport = new SshTransport(config);
222
+ await transport.configInfo();
223
+
224
+ expect(spawnCalls).toHaveLength(1);
225
+ expect(spawnCalls[0].cmd).toBe("ssh");
226
+ expect(spawnCalls[0].args).toEqual([
227
+ "server.example.com",
228
+ "'/usr/share/lemonldap-ng/bin/lemonldap-ng-cli' 'info'",
229
+ ]);
230
+ });
231
+
232
+ it("SSH with user and port", async () => {
233
+ const config: SshConfig = {
234
+ ...defaultConfig,
235
+ host: "server.example.com",
236
+ user: "admin",
237
+ port: 2222,
238
+ };
239
+
240
+ setupSpawnMock("Num : 42\nAuthor : admin\nDate : 2025-01-30");
241
+
242
+ const transport = new SshTransport(config);
243
+ await transport.configInfo();
244
+
245
+ expect(spawnCalls).toHaveLength(1);
246
+ expect(spawnCalls[0].cmd).toBe("ssh");
247
+ expect(spawnCalls[0].args).toEqual([
248
+ "-p",
249
+ "2222",
250
+ "admin@server.example.com",
251
+ "'/usr/share/lemonldap-ng/bin/lemonldap-ng-cli' 'info'",
252
+ ]);
253
+ });
254
+ });
255
+
256
+ describe("Sudo mode", () => {
257
+ it("Sudo mode (local) runs sudo -u www-data", async () => {
258
+ const config: SshConfig = {
259
+ ...defaultConfig,
260
+ sudo: "www-data",
261
+ };
262
+
263
+ setupSpawnMock("Num : 42\nAuthor : admin\nDate : 2025-01-30");
264
+
265
+ const transport = new SshTransport(config);
266
+ await transport.configInfo();
267
+
268
+ expect(spawnCalls).toHaveLength(1);
269
+ expect(spawnCalls[0].cmd).toBe("sudo");
270
+ expect(spawnCalls[0].args).toEqual([
271
+ "-u",
272
+ "www-data",
273
+ "/usr/share/lemonldap-ng/bin/lemonldap-ng-cli",
274
+ "info",
275
+ ]);
276
+ });
277
+
278
+ it("Sudo mode (SSH) runs ssh server sudo -u www-data", async () => {
279
+ const config: SshConfig = {
280
+ ...defaultConfig,
281
+ host: "server.example.com",
282
+ sudo: "www-data",
283
+ };
284
+
285
+ setupSpawnMock("Num : 42\nAuthor : admin\nDate : 2025-01-30");
286
+
287
+ const transport = new SshTransport(config);
288
+ await transport.configInfo();
289
+
290
+ expect(spawnCalls).toHaveLength(1);
291
+ expect(spawnCalls[0].cmd).toBe("ssh");
292
+ expect(spawnCalls[0].args).toEqual([
293
+ "server.example.com",
294
+ "sudo -u 'www-data' '/usr/share/lemonldap-ng/bin/lemonldap-ng-cli' 'info'",
295
+ ]);
296
+ });
297
+ });
298
+
299
+ describe("Shell quoting", () => {
300
+ it("args with single quotes are properly escaped", async () => {
301
+ const config: SshConfig = {
302
+ ...defaultConfig,
303
+ host: "server.example.com",
304
+ };
305
+
306
+ setupSpawnMock("");
307
+
308
+ const transport = new SshTransport(config);
309
+ await transport.configSet({ key: "value's with 'quotes'" });
310
+
311
+ expect(spawnCalls).toHaveLength(1);
312
+ expect(spawnCalls[0].cmd).toBe("ssh");
313
+ // The remote command should have properly escaped single quotes
314
+ const remoteCmd = spawnCalls[0].args[1];
315
+ expect(remoteCmd).toContain("'value'\\''s with '\\''quotes'\\'''");
316
+ });
317
+
318
+ it("complex args are properly quoted in SSH mode", async () => {
319
+ const config: SshConfig = {
320
+ ...defaultConfig,
321
+ host: "server.example.com",
322
+ };
323
+
324
+ setupSpawnMock('[{"id": "session1"}]');
325
+
326
+ const transport = new SshTransport(config);
327
+ await transport.sessionSearch({
328
+ where: { uid: "john's", ipAddr: "192.168.1.1" },
329
+ });
330
+
331
+ expect(spawnCalls).toHaveLength(1);
332
+ const remoteCmd = spawnCalls[0].args[1];
333
+ // Should have escaped quotes - the pattern is 'john'\''s' (close quote, escaped quote, open quote)
334
+ expect(remoteCmd).toContain("john'\\''s");
335
+ expect(remoteCmd).toContain("'search'");
336
+ });
337
+ });
338
+
339
+ describe("Error handling", () => {
340
+ it("non-zero exit code throws with exit code", async () => {
341
+ setupSpawnMock("", "Command failed: invalid argument", 1);
342
+
343
+ const transport = new SshTransport(defaultConfig);
344
+
345
+ await expect(transport.configInfo()).rejects.toThrow("Command failed with exit code 1");
346
+ });
347
+
348
+ it("process error event triggers rejection", async () => {
349
+ vi.mocked(child_process.spawn).mockImplementation(() => {
350
+ const proc = new MockChildProcess();
351
+ process.nextTick(() => {
352
+ proc.emit("error", new Error("spawn ENOENT"));
353
+ });
354
+ return proc as any;
355
+ });
356
+
357
+ const transport = new SshTransport(defaultConfig);
358
+
359
+ await expect(transport.configInfo()).rejects.toThrow("Command execution failed");
360
+ });
361
+ });
362
+
363
+ describe("Additional methods", () => {
364
+ it("configAddKey passes correct arguments", async () => {
365
+ setupSpawnMock("");
366
+
367
+ const transport = new SshTransport(defaultConfig);
368
+ await transport.configAddKey("locationRules", "^/admin/", "deny");
369
+
370
+ expect(spawnCalls).toHaveLength(1);
371
+ expect(spawnCalls[0].args).toEqual(["addKey", "locationRules", "^/admin/", "deny"]);
372
+ });
373
+
374
+ it("configDelKey passes correct arguments", async () => {
375
+ setupSpawnMock("");
376
+
377
+ const transport = new SshTransport(defaultConfig);
378
+ await transport.configDelKey("locationRules", "^/admin/");
379
+
380
+ expect(spawnCalls).toHaveLength(1);
381
+ expect(spawnCalls[0].args).toEqual(["delKey", "locationRules", "^/admin/"]);
382
+ });
383
+
384
+ it("configSave does not pass -json flag", async () => {
385
+ setupSpawnMock("Configuration saved with cfgNum 43");
386
+
387
+ const transport = new SshTransport(defaultConfig);
388
+ await transport.configSave();
389
+
390
+ expect(spawnCalls).toHaveLength(1);
391
+ expect(spawnCalls[0].args).toEqual(["save"]);
392
+ expect(spawnCalls[0].args).not.toContain("-json");
393
+ });
394
+
395
+ it("configRollback uses -yes 1 flag", async () => {
396
+ setupSpawnMock("");
397
+
398
+ const transport = new SshTransport(defaultConfig);
399
+ await transport.configRollback();
400
+
401
+ expect(spawnCalls).toHaveLength(1);
402
+ expect(spawnCalls[0].args).toEqual(["rollback", "-yes", "1"]);
403
+ });
404
+
405
+ it("sessionGet with backend passes correct arguments", async () => {
406
+ setupSpawnMock('{"uid": "john", "ipAddr": "192.168.1.1"}');
407
+
408
+ const transport = new SshTransport(defaultConfig);
409
+ const result = await transport.sessionGet("sessionid123", { backend: "persistent" });
410
+
411
+ expect(spawnCalls).toHaveLength(1);
412
+ expect(spawnCalls[0].args).toEqual(["get", "sessionid123", "--backend", "persistent"]);
413
+ expect(result).toEqual({ uid: "john", ipAddr: "192.168.1.1" });
414
+ });
415
+
416
+ it("sessionGet with persistent flag", async () => {
417
+ setupSpawnMock('{"uid": "john"}');
418
+
419
+ const transport = new SshTransport(defaultConfig);
420
+ await transport.sessionGet("sessionid123", { persistent: true });
421
+
422
+ expect(spawnCalls).toHaveLength(1);
423
+ expect(spawnCalls[0].args).toEqual(["get", "sessionid123", "--persistent"]);
424
+ });
425
+
426
+ it("sessionGet with hash flag", async () => {
427
+ setupSpawnMock('{"uid": "john"}');
428
+
429
+ const transport = new SshTransport(defaultConfig);
430
+ await transport.sessionGet("sessionid123", { hash: true });
431
+
432
+ expect(spawnCalls).toHaveLength(1);
433
+ expect(spawnCalls[0].args).toEqual(["get", "sessionid123", "--hash"]);
434
+ });
435
+
436
+ it("sessionSetKey uses execSessions with setKey command", async () => {
437
+ setupSpawnMock("");
438
+
439
+ const transport = new SshTransport(defaultConfig);
440
+ await transport.sessionSetKey("sessionid123", { uid: "jane", mail: "jane@example.com" });
441
+
442
+ expect(spawnCalls).toHaveLength(1);
443
+ expect(spawnCalls[0].cmd).toBe("/usr/share/lemonldap-ng/bin/lemonldap-ng-sessions");
444
+ expect(spawnCalls[0].args).toEqual([
445
+ "setKey",
446
+ "sessionid123",
447
+ "uid",
448
+ "jane",
449
+ "mail",
450
+ "jane@example.com",
451
+ ]);
452
+ });
453
+
454
+ it("sessionSetKey with persistent option", async () => {
455
+ setupSpawnMock("");
456
+
457
+ const transport = new SshTransport(defaultConfig);
458
+ await transport.sessionSetKey("sessionid123", { uid: "jane" }, { persistent: true });
459
+
460
+ expect(spawnCalls).toHaveLength(1);
461
+ expect(spawnCalls[0].args).toEqual(["setKey", "sessionid123", "uid", "jane", "--persistent"]);
462
+ });
463
+
464
+ it("sessionDelKey uses execSessions with delKey command", async () => {
465
+ setupSpawnMock("");
466
+
467
+ const transport = new SshTransport(defaultConfig);
468
+ await transport.sessionDelKey("sessionid123", ["key1", "key2"]);
469
+
470
+ expect(spawnCalls).toHaveLength(1);
471
+ expect(spawnCalls[0].cmd).toBe("/usr/share/lemonldap-ng/bin/lemonldap-ng-sessions");
472
+ expect(spawnCalls[0].args).toEqual(["delKey", "sessionid123", "key1", "key2"]);
473
+ });
474
+
475
+ it("sessionDelKey with backend option", async () => {
476
+ setupSpawnMock("");
477
+
478
+ const transport = new SshTransport(defaultConfig);
479
+ await transport.sessionDelKey("sessionid123", ["key1"], { backend: "persistent" });
480
+
481
+ expect(spawnCalls).toHaveLength(1);
482
+ expect(spawnCalls[0].args).toEqual([
483
+ "delKey",
484
+ "sessionid123",
485
+ "key1",
486
+ "--backend",
487
+ "persistent",
488
+ ]);
489
+ });
490
+
491
+ it("sessionBackup returns all sessions via search", async () => {
492
+ setupSpawnMock("[]");
493
+
494
+ const transport = new SshTransport(defaultConfig);
495
+ const result = await transport.sessionBackup("persistent");
496
+
497
+ expect(spawnCalls).toHaveLength(1);
498
+ expect(spawnCalls[0].args).toEqual(["search", "--backend", "persistent"]);
499
+ expect(result).toBe("[]");
500
+ });
501
+
502
+ it("secondFactorsDelete throws not supported error", async () => {
503
+ const transport = new SshTransport(defaultConfig);
504
+
505
+ await expect(transport.secondFactorsDelete("john", ["totp1"])).rejects.toThrow(
506
+ "secondFactorsDelete is not supported via CLI. Use API mode.",
507
+ );
508
+ });
509
+
510
+ it("secondFactorsDelType throws not supported error", async () => {
511
+ const transport = new SshTransport(defaultConfig);
512
+
513
+ await expect(transport.secondFactorsDelType("john", "TOTP")).rejects.toThrow(
514
+ "secondFactorsDelType is not supported via CLI. Use API mode.",
515
+ );
516
+ });
517
+
518
+ it("consentsGet throws not supported error", async () => {
519
+ const transport = new SshTransport(defaultConfig);
520
+
521
+ await expect(transport.consentsGet("john")).rejects.toThrow(
522
+ "consentsGet is not supported via CLI. Use API mode.",
523
+ );
524
+ });
525
+
526
+ it("consentsDelete throws not supported error", async () => {
527
+ const transport = new SshTransport(defaultConfig);
528
+
529
+ await expect(transport.consentsDelete("john", ["consent1"])).rejects.toThrow(
530
+ "consentsDelete is not supported via CLI. Use API mode.",
531
+ );
532
+ });
533
+ });
534
+
535
+ describe("stdin methods", () => {
536
+ it("configRestore passes JSON via stdin", async () => {
537
+ let stdinContent = "";
538
+ vi.mocked(child_process.spawn).mockImplementation((cmd: string, args?: readonly string[]) => {
539
+ spawnCalls.push({ cmd, args: args ? [...args] : [] });
540
+ const proc = new MockChildProcess();
541
+ proc.stdin.write = vi.fn((data: any) => {
542
+ stdinContent += data;
543
+ return true;
544
+ });
545
+ process.nextTick(() => {
546
+ proc.emit("close", 0);
547
+ });
548
+ return proc as any;
549
+ });
550
+
551
+ const transport = new SshTransport(defaultConfig);
552
+ const json = '{"domain": "example.com"}';
553
+ await transport.configRestore(json);
554
+
555
+ expect(spawnCalls).toHaveLength(1);
556
+ expect(spawnCalls[0].args).toEqual(["restore", "-yes", "1", "-"]);
557
+ expect(stdinContent).toBe(json);
558
+ });
559
+
560
+ it("configMerge passes JSON via stdin", async () => {
561
+ let stdinContent = "";
562
+ vi.mocked(child_process.spawn).mockImplementation((cmd: string, args?: readonly string[]) => {
563
+ spawnCalls.push({ cmd, args: args ? [...args] : [] });
564
+ const proc = new MockChildProcess();
565
+ proc.stdin.write = vi.fn((data: any) => {
566
+ stdinContent += data;
567
+ return true;
568
+ });
569
+ process.nextTick(() => {
570
+ proc.emit("close", 0);
571
+ });
572
+ return proc as any;
573
+ });
574
+
575
+ const transport = new SshTransport(defaultConfig);
576
+ const json = '{"domain": "example.com"}';
577
+ await transport.configMerge(json);
578
+
579
+ expect(spawnCalls).toHaveLength(1);
580
+ expect(spawnCalls[0].args).toEqual(["merge", "-yes", "1", "-"]);
581
+ expect(stdinContent).toBe(json);
582
+ });
583
+ });
584
+
585
+ describe("environment variables", () => {
586
+ it("exec passes env vars to spawn in local mode", async () => {
587
+ let spawnOptions: any;
588
+ vi.mocked(child_process.spawn).mockImplementation(
589
+ (cmd: string, args?: readonly string[], opts?: any) => {
590
+ spawnCalls.push({ cmd, args: args ? [...args] : [] });
591
+ spawnOptions = opts;
592
+ return mockSpawn("Num : 1\nAuthor : admin\nDate : 2025-01-30") as any;
593
+ },
594
+ );
595
+
596
+ const transport = new SshTransport(defaultConfig);
597
+ // configEditor internally calls exec with env parameter
598
+ await transport.configInfo();
599
+
600
+ // The first call should not have special env
601
+ expect(spawnOptions).toBeUndefined();
602
+ });
603
+
604
+ it("exec with env parameter sets EDITOR=cat for configEditor", async () => {
605
+ vi.mocked(child_process.spawn).mockImplementation((cmd: string, args?: readonly string[]) => {
606
+ spawnCalls.push({ cmd, args: args ? [...args] : [] });
607
+ return mockSpawn("") as any;
608
+ });
609
+
610
+ const config: SshConfig = {
611
+ ...defaultConfig,
612
+ host: "server.example.com",
613
+ };
614
+
615
+ const transport = new SshTransport(config);
616
+
617
+ // Create a more direct test by checking that lmConfigEditor is called correctly
618
+ // In SSH mode, env vars are passed as part of the remote command
619
+ setupSpawnMock("");
620
+ await transport.configInfo();
621
+
622
+ expect(spawnCalls).toHaveLength(1);
623
+ // The env handling is done differently in SSH mode vs local mode
624
+ // In SSH mode, it uses "env KEY=VALUE command"
625
+ });
626
+ });
627
+
628
+ describe("remoteCommand", () => {
629
+ it("remoteCommand is inserted in SSH mode between sudo and LLNG command", async () => {
630
+ const config: SshConfig = {
631
+ ...defaultConfig,
632
+ host: "server.example.com",
633
+ sudo: "www-data",
634
+ remoteCommand: "docker exec sso-auth-1",
635
+ };
636
+
637
+ setupSpawnMock("Num : 42\nAuthor : admin\nDate : 2025-01-30");
638
+
639
+ const transport = new SshTransport(config);
640
+ await transport.configInfo();
641
+
642
+ expect(spawnCalls).toHaveLength(1);
643
+ expect(spawnCalls[0].cmd).toBe("ssh");
644
+ const remoteCmd = spawnCalls[0].args[1];
645
+ // Should be: sudo -u 'www-data' docker exec sso-auth-1 '/path/cli' 'info'
646
+ expect(remoteCmd).toBe(
647
+ "sudo -u 'www-data' docker exec sso-auth-1 '/usr/share/lemonldap-ng/bin/lemonldap-ng-cli' 'info'",
648
+ );
649
+ });
650
+
651
+ it("remoteCommand in SSH mode without sudo", async () => {
652
+ const config: SshConfig = {
653
+ ...defaultConfig,
654
+ host: "server.example.com",
655
+ remoteCommand: "docker exec sso-auth-1",
656
+ };
657
+
658
+ setupSpawnMock("Num : 42\nAuthor : admin\nDate : 2025-01-30");
659
+
660
+ const transport = new SshTransport(config);
661
+ await transport.configInfo();
662
+
663
+ expect(spawnCalls).toHaveLength(1);
664
+ const remoteCmd = spawnCalls[0].args[1];
665
+ expect(remoteCmd).toBe(
666
+ "docker exec sso-auth-1 '/usr/share/lemonldap-ng/bin/lemonldap-ng-cli' 'info'",
667
+ );
668
+ });
669
+
670
+ it("remoteCommand in local mode (no host, no sudo)", async () => {
671
+ const config: SshConfig = {
672
+ ...defaultConfig,
673
+ remoteCommand: "docker exec sso-auth-1",
674
+ };
675
+
676
+ setupSpawnMock("Num : 42\nAuthor : admin\nDate : 2025-01-30");
677
+
678
+ const transport = new SshTransport(config);
679
+ await transport.configInfo();
680
+
681
+ expect(spawnCalls).toHaveLength(1);
682
+ expect(spawnCalls[0].cmd).toBe("docker");
683
+ expect(spawnCalls[0].args).toEqual([
684
+ "exec",
685
+ "sso-auth-1",
686
+ "/usr/share/lemonldap-ng/bin/lemonldap-ng-cli",
687
+ "info",
688
+ ]);
689
+ });
690
+
691
+ it("remoteCommand in local mode with sudo", async () => {
692
+ const config: SshConfig = {
693
+ ...defaultConfig,
694
+ sudo: "www-data",
695
+ remoteCommand: "docker exec sso-auth-1",
696
+ };
697
+
698
+ setupSpawnMock("Num : 42\nAuthor : admin\nDate : 2025-01-30");
699
+
700
+ const transport = new SshTransport(config);
701
+ await transport.configInfo();
702
+
703
+ expect(spawnCalls).toHaveLength(1);
704
+ expect(spawnCalls[0].cmd).toBe("sudo");
705
+ expect(spawnCalls[0].args).toEqual([
706
+ "-u",
707
+ "www-data",
708
+ "docker",
709
+ "exec",
710
+ "sso-auth-1",
711
+ "/usr/share/lemonldap-ng/bin/lemonldap-ng-cli",
712
+ "info",
713
+ ]);
714
+ });
715
+ });
716
+
717
+ describe("binPrefix", () => {
718
+ it("binPrefix resolves CLI paths", async () => {
719
+ const config: SshConfig = {
720
+ binPrefix: "/opt/llng/bin",
721
+ };
722
+
723
+ setupSpawnMock("Num : 42\nAuthor : admin\nDate : 2025-01-30");
724
+
725
+ const transport = new SshTransport(config);
726
+ await transport.configInfo();
727
+
728
+ expect(spawnCalls).toHaveLength(1);
729
+ expect(spawnCalls[0].cmd).toBe("/opt/llng/bin/lemonldap-ng-cli");
730
+ expect(spawnCalls[0].args).toEqual(["info"]);
731
+ });
732
+
733
+ it("binPrefix resolves sessions path", async () => {
734
+ const config: SshConfig = {
735
+ binPrefix: "/opt/llng/bin",
736
+ };
737
+
738
+ setupSpawnMock("[]");
739
+
740
+ const transport = new SshTransport(config);
741
+ await transport.sessionBackup();
742
+
743
+ expect(spawnCalls).toHaveLength(1);
744
+ expect(spawnCalls[0].cmd).toBe("/opt/llng/bin/lemonldap-ng-sessions");
745
+ });
746
+
747
+ it("explicit cliPath overrides binPrefix", async () => {
748
+ const config: SshConfig = {
749
+ binPrefix: "/opt/llng/bin",
750
+ cliPath: "/custom/path/lemonldap-ng-cli",
751
+ };
752
+
753
+ setupSpawnMock("Num : 42\nAuthor : admin\nDate : 2025-01-30");
754
+
755
+ const transport = new SshTransport(config);
756
+ await transport.configInfo();
757
+
758
+ expect(spawnCalls).toHaveLength(1);
759
+ expect(spawnCalls[0].cmd).toBe("/custom/path/lemonldap-ng-cli");
760
+ });
761
+
762
+ it("default binPrefix is used when neither binPrefix nor paths provided", async () => {
763
+ const config: SshConfig = {};
764
+
765
+ setupSpawnMock("Num : 42\nAuthor : admin\nDate : 2025-01-30");
766
+
767
+ const transport = new SshTransport(config);
768
+ await transport.configInfo();
769
+
770
+ expect(spawnCalls).toHaveLength(1);
771
+ expect(spawnCalls[0].cmd).toBe("/usr/share/lemonldap-ng/bin/lemonldap-ng-cli");
772
+ });
773
+
774
+ it("binPrefix with SSH and remoteCommand", async () => {
775
+ const config: SshConfig = {
776
+ host: "server.example.com",
777
+ remoteCommand: "docker exec sso-auth-1",
778
+ binPrefix: "/opt/llng/bin",
779
+ };
780
+
781
+ setupSpawnMock("Num : 42\nAuthor : admin\nDate : 2025-01-30");
782
+
783
+ const transport = new SshTransport(config);
784
+ await transport.configInfo();
785
+
786
+ expect(spawnCalls).toHaveLength(1);
787
+ const remoteCmd = spawnCalls[0].args[1];
788
+ expect(remoteCmd).toBe("docker exec sso-auth-1 '/opt/llng/bin/lemonldap-ng-cli' 'info'");
789
+ });
790
+ });
791
+
792
+ describe("execScript", () => {
793
+ it("execScript runs script from binPrefix directory", async () => {
794
+ setupSpawnMock("Keys rotated successfully");
795
+
796
+ const transport = new SshTransport(defaultConfig);
797
+ const result = await transport.execScript("rotateOidcKeys", []);
798
+
799
+ expect(spawnCalls).toHaveLength(1);
800
+ expect(spawnCalls[0].cmd).toBe("/usr/share/lemonldap-ng/bin/rotateOidcKeys");
801
+ expect(spawnCalls[0].args).toEqual([]);
802
+ expect(result).toBe("Keys rotated successfully");
803
+ });
804
+ });
805
+ });