@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.
- package/package.json +41 -0
- package/src/README.md +413 -0
- package/src/_generated/api.ts +62 -0
- package/src/_generated/component.ts +565 -0
- package/src/_generated/dataModel.ts +60 -0
- package/src/_generated/server.ts +161 -0
- package/src/assignments.test.ts +233 -0
- package/src/assignments.ts +222 -0
- package/src/commands.test.ts +331 -0
- package/src/commands.ts +286 -0
- package/src/component.ts +11 -0
- package/src/configPush.ts +96 -0
- package/src/convex.config.ts +5 -0
- package/src/credentials.ts +112 -0
- package/src/public.test.ts +576 -0
- package/src/public.ts +436 -0
- package/src/schema.ts +180 -0
- package/src/status.test.ts +308 -0
- package/src/status.ts +172 -0
- package/src/test.helpers.ts +399 -0
- package/src/test.setup.ts +256 -0
|
@@ -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
|
+
});
|