@fatagnus/remote-cmd-relay-convex 1.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.
@@ -0,0 +1,576 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import { createRelayTestConvex, RelayTestConvex } from "./test.setup";
3
+ import {
4
+ createMockRelayAssignment,
5
+ createMockCommand,
6
+ createMockConfigPush,
7
+ createMockSharedCredential,
8
+ } from "./test.helpers";
9
+ import { api } from "./_generated/api";
10
+
11
+ describe("public", () => {
12
+ let t: RelayTestConvex;
13
+
14
+ beforeEach(() => {
15
+ vi.useFakeTimers();
16
+ t = createRelayTestConvex();
17
+ });
18
+
19
+ afterEach(async () => {
20
+ await t.finishAllScheduledFunctions(vi.runAllTimers);
21
+ vi.useRealTimers();
22
+ });
23
+
24
+ describe("verifyRelay", () => {
25
+ it("returns valid result for existing enabled assignment", async () => {
26
+ await createMockRelayAssignment(t, {
27
+ apiKeyId: "valid-api-key",
28
+ machineId: "machine-123",
29
+ name: "Test Relay",
30
+ enabled: true,
31
+ });
32
+
33
+ const result = await t.query(api.public.verifyRelay, {
34
+ apiKeyId: "valid-api-key",
35
+ });
36
+
37
+ expect(result.valid).toBe(true);
38
+ if (result.valid) {
39
+ expect(result.machineId).toBe("machine-123");
40
+ expect(result.name).toBe("Test Relay");
41
+ expect(result.assignmentId).toBeDefined();
42
+ }
43
+ });
44
+
45
+ it("returns invalid for non-existent API key", async () => {
46
+ const result = await t.query(api.public.verifyRelay, {
47
+ apiKeyId: "nonexistent-key",
48
+ });
49
+
50
+ expect(result.valid).toBe(false);
51
+ if (!result.valid) {
52
+ expect(result.error).toBe("No relay assignment found for this API key");
53
+ }
54
+ });
55
+
56
+ it("returns invalid for disabled assignment", async () => {
57
+ await createMockRelayAssignment(t, {
58
+ apiKeyId: "disabled-key",
59
+ enabled: false,
60
+ });
61
+
62
+ const result = await t.query(api.public.verifyRelay, {
63
+ apiKeyId: "disabled-key",
64
+ });
65
+
66
+ expect(result.valid).toBe(false);
67
+ if (!result.valid) {
68
+ expect(result.error).toBe("Relay assignment is disabled");
69
+ }
70
+ });
71
+ });
72
+
73
+ describe("getPendingCommands", () => {
74
+ it("returns pending commands for a machine", async () => {
75
+ const machineId = "cmd-machine";
76
+
77
+ await createMockCommand(t, {
78
+ machineId,
79
+ command: "cmd1",
80
+ status: "pending",
81
+ });
82
+ await createMockCommand(t, {
83
+ machineId,
84
+ command: "cmd2",
85
+ status: "pending",
86
+ });
87
+ await createMockCommand(t, {
88
+ machineId,
89
+ command: "cmd3",
90
+ status: "completed",
91
+ });
92
+
93
+ const commands = await t.query(api.public.getPendingCommands, {
94
+ machineId,
95
+ });
96
+
97
+ expect(commands).toHaveLength(2);
98
+ expect(commands.map((c) => c.command)).toContain("cmd1");
99
+ expect(commands.map((c) => c.command)).toContain("cmd2");
100
+ });
101
+
102
+ it("returns commands with SSH target details", async () => {
103
+ const machineId = "ssh-cmd-machine";
104
+
105
+ await createMockCommand(t, {
106
+ machineId,
107
+ command: "uptime",
108
+ targetType: "ssh",
109
+ targetHost: "192.168.1.100",
110
+ targetPort: 22,
111
+ targetUsername: "admin",
112
+ status: "pending",
113
+ });
114
+
115
+ const commands = await t.query(api.public.getPendingCommands, {
116
+ machineId,
117
+ });
118
+
119
+ expect(commands).toHaveLength(1);
120
+ expect(commands[0].targetType).toBe("ssh");
121
+ expect(commands[0].targetHost).toBe("192.168.1.100");
122
+ expect(commands[0].targetUsername).toBe("admin");
123
+ });
124
+
125
+ it("returns empty array when no pending commands", async () => {
126
+ const commands = await t.query(api.public.getPendingCommands, {
127
+ machineId: "empty-machine",
128
+ });
129
+
130
+ expect(commands).toEqual([]);
131
+ });
132
+ });
133
+
134
+ describe("claimCommand", () => {
135
+ it("successfully claims a pending command", async () => {
136
+ const cmd = await createMockCommand(t, {
137
+ status: "pending",
138
+ });
139
+
140
+ const result = await t.mutation(api.public.claimCommand, {
141
+ commandId: cmd._id,
142
+ assignmentId: "relay-assignment-1",
143
+ });
144
+
145
+ expect(result.success).toBe(true);
146
+ if (result.success) {
147
+ expect(result.command._id).toBe(cmd._id);
148
+ expect(result.command.command).toBe(cmd.command);
149
+ }
150
+ });
151
+
152
+ it("fails to claim non-existent command", async () => {
153
+ // Create a command then delete it to get a valid but non-existent ID
154
+ const cmd = await createMockCommand(t, { status: "pending" });
155
+ await t.run(async (ctx) => {
156
+ await ctx.db.delete(cmd._id);
157
+ });
158
+
159
+ const result = await t.mutation(api.public.claimCommand, {
160
+ commandId: cmd._id,
161
+ assignmentId: "relay-1",
162
+ });
163
+
164
+ expect(result.success).toBe(false);
165
+ if (!result.success) {
166
+ expect(result.error).toBe("Command not found");
167
+ }
168
+ });
169
+
170
+ it("fails to claim already claimed command", async () => {
171
+ const cmd = await createMockCommand(t, {
172
+ status: "claimed",
173
+ claimedBy: "other-relay",
174
+ });
175
+
176
+ const result = await t.mutation(api.public.claimCommand, {
177
+ commandId: cmd._id,
178
+ assignmentId: "relay-1",
179
+ });
180
+
181
+ expect(result.success).toBe(false);
182
+ if (!result.success) {
183
+ expect(result.error).toBe("Command is not pending");
184
+ }
185
+ });
186
+ });
187
+
188
+ describe("submitResult", () => {
189
+ it("submits successful command result", async () => {
190
+ const cmd = await createMockCommand(t, {
191
+ status: "claimed",
192
+ });
193
+
194
+ const result = await t.mutation(api.public.submitResult, {
195
+ commandId: cmd._id,
196
+ success: true,
197
+ output: "Hello, World!",
198
+ exitCode: 0,
199
+ durationMs: 150,
200
+ });
201
+
202
+ expect(result.success).toBe(true);
203
+
204
+ // Verify command status was updated
205
+ const updated = await t.run(async (ctx) => {
206
+ return await ctx.db.get(cmd._id);
207
+ });
208
+
209
+ expect(updated?.status).toBe("completed");
210
+ expect(updated?.output).toBe("Hello, World!");
211
+ expect(updated?.exitCode).toBe(0);
212
+ });
213
+
214
+ it("submits failed command result", async () => {
215
+ const cmd = await createMockCommand(t, {
216
+ status: "claimed",
217
+ });
218
+
219
+ const result = await t.mutation(api.public.submitResult, {
220
+ commandId: cmd._id,
221
+ success: false,
222
+ stderr: "Permission denied",
223
+ exitCode: 1,
224
+ error: "Command failed",
225
+ durationMs: 50,
226
+ });
227
+
228
+ expect(result.success).toBe(true);
229
+
230
+ const updated = await t.run(async (ctx) => {
231
+ return await ctx.db.get(cmd._id);
232
+ });
233
+
234
+ expect(updated?.status).toBe("failed");
235
+ expect(updated?.stderr).toBe("Permission denied");
236
+ expect(updated?.error).toBe("Command failed");
237
+ });
238
+
239
+ it("returns failure for non-existent command", async () => {
240
+ // Create a command then delete it to get a valid but non-existent ID
241
+ const cmd = await createMockCommand(t, { status: "pending" });
242
+ await t.run(async (ctx) => {
243
+ await ctx.db.delete(cmd._id);
244
+ });
245
+
246
+ const result = await t.mutation(api.public.submitResult, {
247
+ commandId: cmd._id,
248
+ success: true,
249
+ });
250
+
251
+ expect(result.success).toBe(false);
252
+ });
253
+ });
254
+
255
+ describe("sendHeartbeat", () => {
256
+ it("updates lastSeenAt for valid API key", async () => {
257
+ await createMockRelayAssignment(t, {
258
+ apiKeyId: "heartbeat-api-key",
259
+ });
260
+
261
+ const result = await t.mutation(api.public.sendHeartbeat, {
262
+ apiKeyId: "heartbeat-api-key",
263
+ });
264
+
265
+ expect(result.success).toBe(true);
266
+
267
+ // Verify lastSeenAt was updated
268
+ const assignment = await t.run(async (ctx) => {
269
+ return await ctx.db
270
+ .query("relayAssignments")
271
+ .withIndex("by_apiKeyId", (q) => q.eq("apiKeyId", "heartbeat-api-key"))
272
+ .first();
273
+ });
274
+
275
+ expect(assignment?.lastSeenAt).toBeDefined();
276
+ });
277
+
278
+ it("returns failure for invalid API key", async () => {
279
+ const result = await t.mutation(api.public.sendHeartbeat, {
280
+ apiKeyId: "invalid-api-key",
281
+ });
282
+
283
+ expect(result.success).toBe(false);
284
+ });
285
+ });
286
+
287
+ describe("reportFullStatus", () => {
288
+ it("creates/updates relay status with capabilities and credentials", async () => {
289
+ const relayId = "full-status-relay";
290
+ const now = Date.now();
291
+
292
+ const result = await t.mutation(api.public.reportFullStatus, {
293
+ relayId,
294
+ capabilities: ["ssh", "local_cmd", "perf_metrics"],
295
+ version: "1.0.0",
296
+ hostname: "relay-host",
297
+ platform: "linux",
298
+ metrics: {
299
+ cpuPercent: 25.5,
300
+ memoryPercent: 50.0,
301
+ },
302
+ credentials: [
303
+ {
304
+ credentialName: "ssh-key-1",
305
+ credentialType: "ssh_key",
306
+ targetHost: "server1.example.com",
307
+ storageMode: "relay_only",
308
+ lastUpdatedAt: now,
309
+ },
310
+ ],
311
+ });
312
+
313
+ expect(result.success).toBe(true);
314
+ expect(result.pendingConfigPushes).toBe(0);
315
+
316
+ // Verify status was created
317
+ const status = await t.run(async (ctx) => {
318
+ return await ctx.db
319
+ .query("relayStatus")
320
+ .withIndex("by_relayId", (q) => q.eq("relayId", relayId))
321
+ .first();
322
+ });
323
+
324
+ expect(status?.capabilities).toEqual(["ssh", "local_cmd", "perf_metrics"]);
325
+ expect(status?.version).toBe("1.0.0");
326
+ expect(status?.metrics?.cpuPercent).toBe(25.5);
327
+
328
+ // Verify credential inventory was synced
329
+ const creds = await t.run(async (ctx) => {
330
+ return await ctx.db
331
+ .query("relayCredentialInventory")
332
+ .withIndex("by_relayId", (q) => q.eq("relayId", relayId))
333
+ .collect();
334
+ });
335
+
336
+ expect(creds).toHaveLength(1);
337
+ expect(creds[0].credentialName).toBe("ssh-key-1");
338
+ });
339
+
340
+ it("returns pending config push count", async () => {
341
+ const relayId = "pending-push-relay";
342
+
343
+ // Create some pending pushes
344
+ await createMockConfigPush(t, { relayId, status: "pending" });
345
+ await createMockConfigPush(t, { relayId, status: "pending" });
346
+ await createMockConfigPush(t, { relayId, status: "acked" });
347
+
348
+ const result = await t.mutation(api.public.reportFullStatus, {
349
+ relayId,
350
+ capabilities: ["local_cmd"],
351
+ credentials: [],
352
+ });
353
+
354
+ expect(result.success).toBe(true);
355
+ expect(result.pendingConfigPushes).toBe(2);
356
+ });
357
+
358
+ it("returns shared credentials count", async () => {
359
+ const relayId = "shared-creds-relay";
360
+
361
+ // Create shared credentials assigned to this relay
362
+ await createMockSharedCredential(t, {
363
+ assignedRelays: [relayId, "other-relay"],
364
+ });
365
+ await createMockSharedCredential(t, {
366
+ assignedRelays: [relayId],
367
+ });
368
+ await createMockSharedCredential(t, {
369
+ assignedRelays: ["other-relay"],
370
+ });
371
+
372
+ const result = await t.mutation(api.public.reportFullStatus, {
373
+ relayId,
374
+ capabilities: ["local_cmd"],
375
+ credentials: [],
376
+ });
377
+
378
+ expect(result.success).toBe(true);
379
+ expect(result.sharedCredentialsCount).toBe(2);
380
+ });
381
+ });
382
+
383
+ describe("getPendingConfigPushes", () => {
384
+ it("returns pending config pushes for relay", async () => {
385
+ const relayId = "config-push-relay";
386
+
387
+ await createMockConfigPush(t, {
388
+ relayId,
389
+ pushType: "credential",
390
+ status: "pending",
391
+ });
392
+ await createMockConfigPush(t, {
393
+ relayId,
394
+ pushType: "ssh_targets",
395
+ status: "pending",
396
+ });
397
+ await createMockConfigPush(t, {
398
+ relayId,
399
+ status: "acked",
400
+ });
401
+
402
+ const pushes = await t.query(api.public.getPendingConfigPushes, {
403
+ relayId,
404
+ });
405
+
406
+ expect(pushes).toHaveLength(2);
407
+ });
408
+
409
+ it("returns empty array when no pending pushes", async () => {
410
+ const pushes = await t.query(api.public.getPendingConfigPushes, {
411
+ relayId: "no-pushes-relay",
412
+ });
413
+
414
+ expect(pushes).toEqual([]);
415
+ });
416
+ });
417
+
418
+ describe("acknowledgeConfigPush", () => {
419
+ it("acknowledges push successfully", async () => {
420
+ const push = await createMockConfigPush(t, {
421
+ status: "sent",
422
+ });
423
+
424
+ const result = await t.mutation(api.public.acknowledgeConfigPush, {
425
+ pushId: push._id,
426
+ success: true,
427
+ });
428
+
429
+ expect(result.success).toBe(true);
430
+
431
+ const updated = await t.run(async (ctx) => {
432
+ return await ctx.db.get(push._id);
433
+ });
434
+
435
+ expect(updated?.status).toBe("acked");
436
+ });
437
+
438
+ it("acknowledges push with failure", async () => {
439
+ const push = await createMockConfigPush(t, {
440
+ status: "sent",
441
+ });
442
+
443
+ const result = await t.mutation(api.public.acknowledgeConfigPush, {
444
+ pushId: push._id,
445
+ success: false,
446
+ errorMessage: "Failed to apply config",
447
+ });
448
+
449
+ expect(result.success).toBe(true);
450
+
451
+ const updated = await t.run(async (ctx) => {
452
+ return await ctx.db.get(push._id);
453
+ });
454
+
455
+ expect(updated?.status).toBe("failed");
456
+ expect(updated?.errorMessage).toBe("Failed to apply config");
457
+ });
458
+
459
+ it("returns failure for non-existent push", async () => {
460
+ // Create a push then delete it to get a valid but non-existent ID
461
+ const push = await createMockConfigPush(t, { status: "pending" });
462
+ await t.run(async (ctx) => {
463
+ await ctx.db.delete(push._id);
464
+ });
465
+
466
+ const result = await t.mutation(api.public.acknowledgeConfigPush, {
467
+ pushId: push._id,
468
+ success: true,
469
+ });
470
+
471
+ expect(result.success).toBe(false);
472
+ });
473
+ });
474
+
475
+ describe("getSharedCredentials", () => {
476
+ it("returns shared credentials assigned to relay", async () => {
477
+ const relayId = "shared-creds-relay";
478
+
479
+ await createMockSharedCredential(t, {
480
+ name: "assigned-cred",
481
+ assignedRelays: [relayId],
482
+ encryptedValue: "encrypted-data",
483
+ });
484
+ await createMockSharedCredential(t, {
485
+ name: "not-assigned",
486
+ assignedRelays: ["other-relay"],
487
+ });
488
+
489
+ const creds = await t.query(api.public.getSharedCredentials, {
490
+ relayId,
491
+ });
492
+
493
+ expect(creds).toHaveLength(1);
494
+ expect(creds[0].name).toBe("assigned-cred");
495
+ expect(creds[0].encryptedValue).toBe("encrypted-data");
496
+ });
497
+
498
+ it("returns empty array when no credentials assigned", async () => {
499
+ const creds = await t.query(api.public.getSharedCredentials, {
500
+ relayId: "no-creds-relay",
501
+ });
502
+
503
+ expect(creds).toEqual([]);
504
+ });
505
+ });
506
+
507
+ describe("full relay workflow", () => {
508
+ it("complete relay lifecycle: verify -> status -> commands -> heartbeat", async () => {
509
+ // 1. Create relay assignment
510
+ const assignment = await createMockRelayAssignment(t, {
511
+ apiKeyId: "workflow-api-key",
512
+ machineId: "workflow-machine",
513
+ name: "Workflow Relay",
514
+ });
515
+
516
+ // 2. Verify relay
517
+ const verifyResult = await t.query(api.public.verifyRelay, {
518
+ apiKeyId: "workflow-api-key",
519
+ });
520
+ expect(verifyResult.valid).toBe(true);
521
+
522
+ // 3. Report full status
523
+ const statusResult = await t.mutation(api.public.reportFullStatus, {
524
+ relayId: assignment._id,
525
+ capabilities: ["local_cmd", "ssh"],
526
+ version: "1.0.0",
527
+ hostname: "workflow-host",
528
+ platform: "linux",
529
+ credentials: [],
530
+ });
531
+ expect(statusResult.success).toBe(true);
532
+
533
+ // 4. Queue a command for the machine
534
+ await createMockCommand(t, {
535
+ machineId: "workflow-machine",
536
+ command: "echo test",
537
+ status: "pending",
538
+ });
539
+
540
+ // 5. Get pending commands
541
+ const commands = await t.query(api.public.getPendingCommands, {
542
+ machineId: "workflow-machine",
543
+ });
544
+ expect(commands).toHaveLength(1);
545
+
546
+ // 6. Claim command
547
+ const claimResult = await t.mutation(api.public.claimCommand, {
548
+ commandId: commands[0]._id,
549
+ assignmentId: assignment._id,
550
+ });
551
+ expect(claimResult.success).toBe(true);
552
+
553
+ // 7. Submit result
554
+ const submitResult = await t.mutation(api.public.submitResult, {
555
+ commandId: commands[0]._id,
556
+ success: true,
557
+ output: "test",
558
+ exitCode: 0,
559
+ durationMs: 100,
560
+ });
561
+ expect(submitResult.success).toBe(true);
562
+
563
+ // 8. Send heartbeat
564
+ const heartbeatResult = await t.mutation(api.public.sendHeartbeat, {
565
+ apiKeyId: "workflow-api-key",
566
+ });
567
+ expect(heartbeatResult.success).toBe(true);
568
+
569
+ // Verify no more pending commands
570
+ const finalCommands = await t.query(api.public.getPendingCommands, {
571
+ machineId: "workflow-machine",
572
+ });
573
+ expect(finalCommands).toHaveLength(0);
574
+ });
575
+ });
576
+ });