@camstack/server 0.1.3

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 (133) hide show
  1. package/.env.example +17 -0
  2. package/package.json +55 -0
  3. package/src/__tests__/addon-install-e2e.test.ts +75 -0
  4. package/src/__tests__/addon-pages-e2e.test.ts +178 -0
  5. package/src/__tests__/addon-route-session.test.ts +17 -0
  6. package/src/__tests__/addon-settings-router.spec.ts +62 -0
  7. package/src/__tests__/addon-upload.spec.ts +355 -0
  8. package/src/__tests__/agent-registry.spec.ts +162 -0
  9. package/src/__tests__/agent-status-page.spec.ts +84 -0
  10. package/src/__tests__/auth-session-cookie.test.ts +21 -0
  11. package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +23 -0
  12. package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +64 -0
  13. package/src/__tests__/cap-routers/_meta.spec.ts +200 -0
  14. package/src/__tests__/cap-routers/addon-settings.router.spec.ts +106 -0
  15. package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +142 -0
  16. package/src/__tests__/cap-routers/harness.ts +159 -0
  17. package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +119 -0
  18. package/src/__tests__/cap-routers/null-provider-guard.spec.ts +66 -0
  19. package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +135 -0
  20. package/src/__tests__/cap-routers/settings-store.router.spec.ts +247 -0
  21. package/src/__tests__/capability-e2e.test.ts +386 -0
  22. package/src/__tests__/cli-e2e.test.ts +129 -0
  23. package/src/__tests__/core-cap-bridge.spec.ts +89 -0
  24. package/src/__tests__/embedded-deps-e2e.test.ts +109 -0
  25. package/src/__tests__/event-bus-proxy-router.spec.ts +72 -0
  26. package/src/__tests__/fixtures/mock-analysis-addon-a.ts +37 -0
  27. package/src/__tests__/fixtures/mock-analysis-addon-b.ts +37 -0
  28. package/src/__tests__/fixtures/mock-log-addon.ts +37 -0
  29. package/src/__tests__/fixtures/mock-storage-addon.ts +40 -0
  30. package/src/__tests__/framework-allowlist.spec.ts +95 -0
  31. package/src/__tests__/https-e2e.test.ts +118 -0
  32. package/src/__tests__/lifecycle-e2e.test.ts +140 -0
  33. package/src/__tests__/live-events-subscription.spec.ts +150 -0
  34. package/src/__tests__/moleculer-register-node-idempotency.spec.ts +229 -0
  35. package/src/__tests__/oauth2-account-linking.spec.ts +736 -0
  36. package/src/__tests__/post-boot-restart.spec.ts +161 -0
  37. package/src/__tests__/singleton-contention.test.ts +487 -0
  38. package/src/__tests__/streaming-diagnostic.test.ts +512 -0
  39. package/src/__tests__/streaming-scale.test.ts +280 -0
  40. package/src/agent-status-page.ts +121 -0
  41. package/src/api/__tests__/addons-custom.spec.ts +134 -0
  42. package/src/api/__tests__/capabilities.router.test.ts +47 -0
  43. package/src/api/addon-upload.ts +472 -0
  44. package/src/api/addons-custom.router.ts +100 -0
  45. package/src/api/auth-whoami.ts +99 -0
  46. package/src/api/bridge-addons.router.ts +120 -0
  47. package/src/api/capabilities.router.ts +226 -0
  48. package/src/api/core/__tests__/auth-router-totp.spec.ts +256 -0
  49. package/src/api/core/addon-settings.router.ts +124 -0
  50. package/src/api/core/agents.router.ts +87 -0
  51. package/src/api/core/auth.router.ts +303 -0
  52. package/src/api/core/cap-providers.ts +993 -0
  53. package/src/api/core/capabilities.router.ts +119 -0
  54. package/src/api/core/collection-preference.ts +40 -0
  55. package/src/api/core/event-bus-proxy.router.ts +45 -0
  56. package/src/api/core/hwaccel.router.ts +81 -0
  57. package/src/api/core/live-events.router.ts +60 -0
  58. package/src/api/core/logs.router.ts +162 -0
  59. package/src/api/core/notifications.router.ts +65 -0
  60. package/src/api/core/repl.router.ts +41 -0
  61. package/src/api/core/settings-backend.router.ts +142 -0
  62. package/src/api/core/stream-probe.router.ts +57 -0
  63. package/src/api/core/system-events.router.ts +116 -0
  64. package/src/api/health/health.routes.ts +123 -0
  65. package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +52 -0
  66. package/src/api/oauth2/consent-page.ts +42 -0
  67. package/src/api/oauth2/oauth2-routes.ts +248 -0
  68. package/src/api/trpc/__tests__/scope-access-device.spec.ts +223 -0
  69. package/src/api/trpc/__tests__/scope-access.spec.ts +107 -0
  70. package/src/api/trpc/cap-mount-helpers.ts +225 -0
  71. package/src/api/trpc/core-cap-bridge.ts +152 -0
  72. package/src/api/trpc/generated-cap-mounts.ts +707 -0
  73. package/src/api/trpc/generated-cap-routers.ts +6340 -0
  74. package/src/api/trpc/scope-access.ts +110 -0
  75. package/src/api/trpc/trpc.context.ts +255 -0
  76. package/src/api/trpc/trpc.middleware.ts +140 -0
  77. package/src/api/trpc/trpc.router.ts +275 -0
  78. package/src/auth/session-cookie.ts +44 -0
  79. package/src/boot/boot-config.ts +278 -0
  80. package/src/boot/post-boot.service.ts +103 -0
  81. package/src/core/addon/__tests__/addon-registry-capability.test.ts +53 -0
  82. package/src/core/addon/addon-package.service.ts +1684 -0
  83. package/src/core/addon/addon-registry.service.ts +2926 -0
  84. package/src/core/addon/addon-search.service.ts +90 -0
  85. package/src/core/addon/addon-settings-provider.ts +276 -0
  86. package/src/core/addon/addon.tokens.ts +2 -0
  87. package/src/core/addon-bridge/addon-bridge.service.ts +125 -0
  88. package/src/core/addon-pages/addon-pages.service.spec.ts +117 -0
  89. package/src/core/addon-pages/addon-pages.service.ts +80 -0
  90. package/src/core/addon-widgets/addon-widgets.service.ts +92 -0
  91. package/src/core/agent/agent-registry.service.ts +507 -0
  92. package/src/core/auth/auth.service.spec.ts +88 -0
  93. package/src/core/auth/auth.service.ts +8 -0
  94. package/src/core/capability/capability.service.ts +57 -0
  95. package/src/core/config/config.schema.ts +3 -0
  96. package/src/core/config/config.service.spec.ts +175 -0
  97. package/src/core/config/config.service.ts +7 -0
  98. package/src/core/events/event-bus.service.spec.ts +212 -0
  99. package/src/core/events/event-bus.service.ts +85 -0
  100. package/src/core/feature/feature.service.spec.ts +96 -0
  101. package/src/core/feature/feature.service.ts +8 -0
  102. package/src/core/lifecycle/lifecycle-state-machine.spec.ts +168 -0
  103. package/src/core/lifecycle/lifecycle-state-machine.ts +3 -0
  104. package/src/core/logging/log-ring-buffer.ts +3 -0
  105. package/src/core/logging/logging.service.spec.ts +247 -0
  106. package/src/core/logging/logging.service.ts +129 -0
  107. package/src/core/logging/scoped-logger.ts +3 -0
  108. package/src/core/moleculer/moleculer.service.ts +612 -0
  109. package/src/core/network/network-quality.service.spec.ts +47 -0
  110. package/src/core/network/network-quality.service.ts +5 -0
  111. package/src/core/notification/notification-wrapper.service.ts +36 -0
  112. package/src/core/notification/toast-wrapper.service.ts +31 -0
  113. package/src/core/provider/provider.tokens.ts +1 -0
  114. package/src/core/repl/repl-engine.service.spec.ts +417 -0
  115. package/src/core/repl/repl-engine.service.ts +156 -0
  116. package/src/core/storage/fs-storage-backend.spec.ts +70 -0
  117. package/src/core/storage/fs-storage-backend.ts +3 -0
  118. package/src/core/storage/settings-store.spec.ts +213 -0
  119. package/src/core/storage/settings-store.ts +2 -0
  120. package/src/core/storage/sql-schema.spec.ts +140 -0
  121. package/src/core/storage/sql-schema.ts +3 -0
  122. package/src/core/storage/storage-location-manager.spec.ts +121 -0
  123. package/src/core/storage/storage-location-manager.ts +3 -0
  124. package/src/core/storage/storage.service.spec.ts +73 -0
  125. package/src/core/storage/storage.service.ts +3 -0
  126. package/src/core/streaming/stream-probe.service.ts +212 -0
  127. package/src/core/topology/topology-emitter.service.ts +101 -0
  128. package/src/launcher.ts +309 -0
  129. package/src/main.ts +1049 -0
  130. package/src/manual-boot.ts +322 -0
  131. package/tsconfig.build.json +8 -0
  132. package/tsconfig.json +21 -0
  133. package/vitest.config.ts +26 -0
@@ -0,0 +1,355 @@
1
+ /**
2
+ * Integration test for the `/api/addons/upload` route.
3
+ *
4
+ * Uses a real Fastify instance to exercise the multipart parser end-to-end
5
+ * with mock dependencies for the AddonBridgeService, AuthService, and
6
+ * MoleculerService. Verifies:
7
+ *
8
+ * 1. Auth enforcement (401 without token, 403 for non-admin)
9
+ * 2. File extension validation (.tgz / .tar.gz only)
10
+ * 3. Hub install path (nodeId = 'hub' or absent → AddonInstaller.installFromTgz)
11
+ * 4. Agent deploy path (nodeId = 'agent-x' → moleculer.broker.call with nodeID)
12
+ * 5. Agent failure → 502 response
13
+ */
14
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
15
+ import Fastify from 'fastify'
16
+ import type { FastifyInstance } from 'fastify'
17
+ import * as fs from 'node:fs'
18
+ import * as path from 'node:path'
19
+ import * as os from 'node:os'
20
+ import { execFileSync } from 'node:child_process'
21
+ import { registerAddonUploadRoute } from '../api/addon-upload.js'
22
+ import type { AddonBridgeService } from '../core/addon-bridge/addon-bridge.service.js'
23
+ import type { AuthService } from '../core/auth/auth.service.js'
24
+ import type { MoleculerService } from '../core/moleculer/moleculer.service.js'
25
+ import type { AddonRegistryService } from '../core/addon/addon-registry.service.js'
26
+ import type { IScopedLogger } from '@camstack/types'
27
+
28
+ /**
29
+ * Build a minimal valid addon tarball containing a `package/package.json`
30
+ * with the given name + version. `validateTarball` on the server extracts
31
+ * just that file via `tar -xzO`, so nothing else is needed for the happy
32
+ * path tests. Returns a Buffer.
33
+ */
34
+ function buildValidTarball(manifest: { name: string; version: string }): Buffer {
35
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vitest-tarball-'))
36
+ try {
37
+ fs.mkdirSync(path.join(tmpDir, 'package'), { recursive: true })
38
+ fs.writeFileSync(
39
+ path.join(tmpDir, 'package', 'package.json'),
40
+ JSON.stringify(manifest),
41
+ )
42
+ const tgz = path.join(tmpDir, 'out.tgz')
43
+ execFileSync('tar', ['-czf', tgz, '-C', tmpDir, 'package'])
44
+ return fs.readFileSync(tgz)
45
+ } finally {
46
+ fs.rmSync(tmpDir, { recursive: true, force: true })
47
+ }
48
+ }
49
+
50
+ interface MockAuthPayload {
51
+ readonly isAdmin: boolean
52
+ }
53
+
54
+ interface FormDataLike {
55
+ readonly file: { filename: string; content: Buffer }
56
+ readonly nodeId?: string
57
+ readonly addonId?: string
58
+ }
59
+
60
+ function buildMultipart(boundary: string, body: FormDataLike): Buffer {
61
+ const parts: Buffer[] = []
62
+ const push = (s: string) => parts.push(Buffer.from(s))
63
+ push(`--${boundary}\r\n`)
64
+ push(`Content-Disposition: form-data; name="file"; filename="${body.file.filename}"\r\n`)
65
+ push('Content-Type: application/gzip\r\n\r\n')
66
+ parts.push(body.file.content)
67
+ push('\r\n')
68
+ if (body.nodeId !== undefined) {
69
+ push(`--${boundary}\r\n`)
70
+ push('Content-Disposition: form-data; name="nodeId"\r\n\r\n')
71
+ push(body.nodeId)
72
+ push('\r\n')
73
+ }
74
+ if (body.addonId !== undefined) {
75
+ push(`--${boundary}\r\n`)
76
+ push('Content-Disposition: form-data; name="addonId"\r\n\r\n')
77
+ push(body.addonId)
78
+ push('\r\n')
79
+ }
80
+ push(`--${boundary}--\r\n`)
81
+ return Buffer.concat(parts)
82
+ }
83
+
84
+ function makeAuth(kind: 'admin' | 'non-admin' | 'invalid' = 'admin'): AuthService {
85
+ return {
86
+ verifyToken: vi.fn(() => {
87
+ if (kind === 'invalid') throw new Error('invalid token')
88
+ const payload: MockAuthPayload = { isAdmin: kind === 'admin' }
89
+ return payload
90
+ }),
91
+ } as unknown as AuthService
92
+ }
93
+
94
+ function makeAddonBridge(installed?: { name: string; version: string }): AddonBridgeService {
95
+ return {
96
+ getInstaller: vi.fn(() => installed === undefined ? null : ({
97
+ installFromTgz: vi.fn(async () => installed),
98
+ })),
99
+ reloadPackages: vi.fn(async () => {}),
100
+ } as unknown as AddonBridgeService
101
+ }
102
+
103
+ function makeMoleculer(call: ReturnType<typeof vi.fn>): MoleculerService {
104
+ return {
105
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- test stub: vi.fn isn't typed against the broker's internal call shape, but the runtime contract is what we exercise
106
+ broker: { call } as unknown as MoleculerService['broker'],
107
+ } as unknown as MoleculerService
108
+ }
109
+
110
+ function makeAddonRegistry(): AddonRegistryService {
111
+ return {
112
+ // Returns an empty list — no addons currently tied to any package.
113
+ // `installToHub` uses this to build `preInstallAddonIds`; an empty
114
+ // result means no background `restartAddon` calls are fired.
115
+ listAddons: vi.fn(() => []),
116
+ // Simulates a successful filesystem scan that found no new addons.
117
+ loadNewAddons: vi.fn(async () => ({ loaded: [] as string[], failed: [] as string[] })),
118
+ // Not reached in the default happy-path (preInstallAddonIds is empty),
119
+ // but stubbed for completeness and per-test overrides.
120
+ restartAddon: vi.fn(async (_id: string) => ({ success: true })),
121
+ // Returns a minimal cap-registry stub. `validateScopedTokenViaCap`
122
+ // calls `getSingleton('user-management')` — returning null means no
123
+ // singleton is mounted, so scoped-token auth is skipped. All happy-
124
+ // path tests authenticate via the JWT path (`Bearer t` → isAdmin),
125
+ // so this is the correct safe default.
126
+ getCapabilityRegistry: vi.fn(() => ({
127
+ getSingleton: vi.fn((_name: string) => null),
128
+ })),
129
+ } as unknown as AddonRegistryService
130
+ }
131
+
132
+ function makeLogger(): IScopedLogger {
133
+ return {
134
+ debug: vi.fn(),
135
+ info: vi.fn(),
136
+ warn: vi.fn(),
137
+ error: vi.fn(),
138
+ child: vi.fn(function (this: IScopedLogger) { return this }),
139
+ withTags: vi.fn(function (this: IScopedLogger) { return this }),
140
+ } as unknown as IScopedLogger
141
+ }
142
+
143
+ async function makeServer(opts: {
144
+ auth: AuthService
145
+ bridge: AddonBridgeService
146
+ moleculer: MoleculerService
147
+ addonRegistry?: AddonRegistryService
148
+ logger?: IScopedLogger
149
+ }): Promise<FastifyInstance> {
150
+ const fastify = Fastify({ logger: false })
151
+ await registerAddonUploadRoute(
152
+ fastify,
153
+ opts.bridge,
154
+ opts.auth,
155
+ opts.moleculer,
156
+ opts.addonRegistry ?? makeAddonRegistry(),
157
+ opts.logger ?? makeLogger(),
158
+ )
159
+ await fastify.ready()
160
+ return fastify
161
+ }
162
+
163
+ const TGZ_BOUNDARY = '----vitestboundary'
164
+ const VALID_TARBALL = buildValidTarball({ name: 'addon-x', version: '1.0.0' })
165
+ const INVALID_TARBALL = Buffer.from('fake-not-a-tarball')
166
+
167
+ describe('POST /api/addons/upload', () => {
168
+ let fastify: FastifyInstance | null = null
169
+
170
+ afterEach(async () => {
171
+ if (fastify) {
172
+ await fastify.close()
173
+ fastify = null
174
+ }
175
+ })
176
+
177
+ describe('auth', () => {
178
+ beforeEach(async () => {
179
+ fastify = await makeServer({
180
+ auth: makeAuth('admin'),
181
+ bridge: makeAddonBridge({ name: 'addon-x', version: '1.0.0' }),
182
+ moleculer: makeMoleculer(vi.fn()),
183
+ })
184
+ })
185
+
186
+ it('returns 401 when Authorization header is missing', async () => {
187
+ const res = await fastify!.inject({
188
+ method: 'POST',
189
+ url: '/api/addons/upload',
190
+ headers: { 'content-type': `multipart/form-data; boundary=${TGZ_BOUNDARY}` },
191
+ payload: buildMultipart(TGZ_BOUNDARY, { file: { filename: 'addon-x.tgz', content: VALID_TARBALL } }),
192
+ })
193
+ expect(res.statusCode).toBe(401)
194
+ })
195
+
196
+ it('returns 403 when token is not admin', async () => {
197
+ await fastify!.close()
198
+ fastify = await makeServer({
199
+ auth: makeAuth('non-admin'),
200
+ bridge: makeAddonBridge({ name: 'addon-x', version: '1.0.0' }),
201
+ moleculer: makeMoleculer(vi.fn()),
202
+ })
203
+ const res = await fastify.inject({
204
+ method: 'POST',
205
+ url: '/api/addons/upload',
206
+ headers: { 'content-type': `multipart/form-data; boundary=${TGZ_BOUNDARY}`, authorization: 'Bearer token' },
207
+ payload: buildMultipart(TGZ_BOUNDARY, { file: { filename: 'addon-x.tgz', content: VALID_TARBALL } }),
208
+ })
209
+ expect(res.statusCode).toBe(403)
210
+ })
211
+ })
212
+
213
+ it('returns 400 when filename is not a tarball', async () => {
214
+ fastify = await makeServer({
215
+ auth: makeAuth('admin'),
216
+ bridge: makeAddonBridge({ name: 'addon-x', version: '1.0.0' }),
217
+ moleculer: makeMoleculer(vi.fn()),
218
+ })
219
+ const res = await fastify.inject({
220
+ method: 'POST',
221
+ url: '/api/addons/upload',
222
+ headers: { 'content-type': `multipart/form-data; boundary=${TGZ_BOUNDARY}`, authorization: 'Bearer t' },
223
+ payload: buildMultipart(TGZ_BOUNDARY, { file: { filename: 'evil.exe', content: VALID_TARBALL } }),
224
+ })
225
+ expect(res.statusCode).toBe(400)
226
+ })
227
+
228
+ it('routes to hub installer when nodeId is absent', async () => {
229
+ // Hub install writes a `.install-source` marker into
230
+ // `${CAMSTACK_DATA}/addons/<addonName>/`. Point CAMSTACK_DATA at an
231
+ // isolated temp dir for the test so the side effect doesn't escape.
232
+ const tmpRoot = await import('node:fs/promises').then(fs => fs.mkdtemp('/tmp/camstack-upload-test-'))
233
+ const previous = process.env['CAMSTACK_DATA']
234
+ process.env['CAMSTACK_DATA'] = tmpRoot
235
+ const fsSync = await import('node:fs')
236
+ fsSync.mkdirSync(`${tmpRoot}/addons/addon-x`, { recursive: true })
237
+
238
+ try {
239
+ const bridge = makeAddonBridge({ name: 'addon-x', version: '2.0.0' })
240
+ fastify = await makeServer({
241
+ auth: makeAuth('admin'),
242
+ bridge,
243
+ moleculer: makeMoleculer(vi.fn()),
244
+ })
245
+ const res = await fastify.inject({
246
+ method: 'POST',
247
+ url: '/api/addons/upload',
248
+ headers: { 'content-type': `multipart/form-data; boundary=${TGZ_BOUNDARY}`, authorization: 'Bearer t' },
249
+ payload: buildMultipart(TGZ_BOUNDARY, { file: { filename: 'addon-x.tgz', content: VALID_TARBALL } }),
250
+ })
251
+ expect(res.statusCode).toBe(200)
252
+ const body = res.json() as { success: boolean; target: string; packageName: string; version: string }
253
+ expect(body).toMatchObject({ success: true, target: 'hub', packageName: 'addon-x', version: '2.0.0' })
254
+ } finally {
255
+ if (previous === undefined) delete process.env['CAMSTACK_DATA']
256
+ else process.env['CAMSTACK_DATA'] = previous
257
+ fsSync.rmSync(tmpRoot, { recursive: true, force: true })
258
+ }
259
+ })
260
+
261
+ it('routes to $agent.deploy when nodeId is provided', async () => {
262
+ const call = vi.fn(async () => ({ success: true, addonId: 'addon-x', path: '/agent/addons/addon-x' }))
263
+ fastify = await makeServer({
264
+ auth: makeAuth('admin'),
265
+ bridge: makeAddonBridge(),
266
+ moleculer: makeMoleculer(call),
267
+ })
268
+ const res = await fastify.inject({
269
+ method: 'POST',
270
+ url: '/api/addons/upload',
271
+ headers: { 'content-type': `multipart/form-data; boundary=${TGZ_BOUNDARY}`, authorization: 'Bearer t' },
272
+ payload: buildMultipart(TGZ_BOUNDARY, {
273
+ file: { filename: 'addon-x-1.2.3.tgz', content: VALID_TARBALL },
274
+ nodeId: 'agent-frigate',
275
+ addonId: 'addon-x',
276
+ }),
277
+ })
278
+ expect(res.statusCode).toBe(200)
279
+ const body = res.json() as { success: boolean; target: string; addonId: string; path: string }
280
+ expect(body.target).toBe('agent-frigate')
281
+ expect(body.addonId).toBe('addon-x')
282
+
283
+ expect(call).toHaveBeenCalledWith(
284
+ '$agent.deploy',
285
+ { addonId: 'addon-x', bundle: VALID_TARBALL },
286
+ { nodeID: 'agent-frigate', timeout: 60_000 },
287
+ )
288
+ })
289
+
290
+ it('returns 502 when $agent.deploy throws', async () => {
291
+ const call = vi.fn(async () => { throw new Error('agent unreachable') })
292
+ fastify = await makeServer({
293
+ auth: makeAuth('admin'),
294
+ bridge: makeAddonBridge(),
295
+ moleculer: makeMoleculer(call),
296
+ })
297
+ const res = await fastify.inject({
298
+ method: 'POST',
299
+ url: '/api/addons/upload',
300
+ headers: { 'content-type': `multipart/form-data; boundary=${TGZ_BOUNDARY}`, authorization: 'Bearer t' },
301
+ payload: buildMultipart(TGZ_BOUNDARY, {
302
+ file: { filename: 'addon-x.tgz', content: VALID_TARBALL },
303
+ nodeId: 'agent-frigate',
304
+ }),
305
+ })
306
+ expect(res.statusCode).toBe(502)
307
+ const body = res.json() as { error: string }
308
+ expect(body.error).toMatch(/agent unreachable/)
309
+ })
310
+
311
+ it('uses manifest.name as addonId fallback when client omits the hint', async () => {
312
+ const call = vi.fn(async () => ({ success: true, addonId: 'addon-foo' }))
313
+ const tarball = buildValidTarball({ name: 'addon-foo', version: '1.2.3' })
314
+ fastify = await makeServer({
315
+ auth: makeAuth('admin'),
316
+ bridge: makeAddonBridge(),
317
+ moleculer: makeMoleculer(call),
318
+ })
319
+ await fastify.inject({
320
+ method: 'POST',
321
+ url: '/api/addons/upload',
322
+ headers: { 'content-type': `multipart/form-data; boundary=${TGZ_BOUNDARY}`, authorization: 'Bearer t' },
323
+ payload: buildMultipart(TGZ_BOUNDARY, {
324
+ // Filename is intentionally different from manifest.name to prove
325
+ // that the server reads the manifest, not the filename.
326
+ file: { filename: 'unrelated-name-9.tgz', content: tarball },
327
+ nodeId: 'agent-frigate',
328
+ }),
329
+ })
330
+ expect(call).toHaveBeenCalledWith(
331
+ '$agent.deploy',
332
+ expect.objectContaining({ addonId: 'addon-foo' }),
333
+ expect.anything(),
334
+ )
335
+ })
336
+
337
+ it('returns 400 when the tarball has no valid package.json', async () => {
338
+ fastify = await makeServer({
339
+ auth: makeAuth('admin'),
340
+ bridge: makeAddonBridge(),
341
+ moleculer: makeMoleculer(vi.fn()),
342
+ })
343
+ const res = await fastify.inject({
344
+ method: 'POST',
345
+ url: '/api/addons/upload',
346
+ headers: { 'content-type': `multipart/form-data; boundary=${TGZ_BOUNDARY}`, authorization: 'Bearer t' },
347
+ payload: buildMultipart(TGZ_BOUNDARY, {
348
+ file: { filename: 'bogus.tgz', content: INVALID_TARBALL },
349
+ }),
350
+ })
351
+ expect(res.statusCode).toBe(400)
352
+ const body = res.json() as { error: string }
353
+ expect(body.error).toMatch(/package\.json/)
354
+ })
355
+ })
@@ -0,0 +1,162 @@
1
+ /**
2
+ * AgentRegistryService — node.connected + cold-scan event emission.
3
+ *
4
+ * Regression guard for the race between hub boot and remote agents: when
5
+ * a dev.sh cluster starts hub + agents simultaneously, agents can register
6
+ * with Moleculer BEFORE the hub's `AgentRegistryService.onModuleInit`
7
+ * wires its `$node.connected` listener. Without the cold-scan below,
8
+ * those agents never produced an `agent.online` event, so alert-center
9
+ * and admin-ui lost their lifecycle view of the cluster.
10
+ *
11
+ * These specs validate both the hot path (new connections fire the
12
+ * listener) and the cold path (already-connected nodes retroactively
13
+ * emit on init).
14
+ */
15
+ import { describe, it, expect, beforeEach } from 'vitest'
16
+ import { EventEmitter } from 'node:events'
17
+ import { AgentRegistryService } from '../core/agent/agent-registry.service.js'
18
+ import type { EventBusService } from '../core/events/event-bus.service.js'
19
+ import type { MoleculerService } from '../core/moleculer/moleculer.service.js'
20
+ import type { CapabilityService } from '../core/capability/capability.service.js'
21
+ import type { SystemEvent } from '@camstack/types'
22
+
23
+ interface CapturedEmits {
24
+ readonly events: SystemEvent[]
25
+ }
26
+
27
+ function createFakes(opts: { preExistingNodeIds?: readonly string[] } = {}) {
28
+ const emits: CapturedEmits = { events: [] }
29
+ const fakeEventBus = {
30
+ emit: (event: SystemEvent) => { emits.events.push(event) },
31
+ } as unknown as EventBusService
32
+
33
+ const localBus = new EventEmitter()
34
+ const nodeList = (opts.preExistingNodeIds ?? []).map((id) => ({ id }))
35
+ // D3: setOnAgentRegistered is called by AgentRegistryService.onModuleInit
36
+ // to wire the handshake-driven reconcile trigger. The fake captures the
37
+ // callback but does not invoke it (reconcile tests are out of scope here).
38
+ const fakeMoleculer = {
39
+ broker: {
40
+ nodeID: 'hub',
41
+ localBus,
42
+ registry: {
43
+ getNodeList: ({ onlyAvailable }: { onlyAvailable: boolean }) => {
44
+ void onlyAvailable
45
+ return nodeList
46
+ },
47
+ },
48
+ },
49
+ setOnAgentRegistered: (_cb: (nodeId: string) => void) => { /* no-op in unit tests */ },
50
+ } as unknown as MoleculerService
51
+
52
+ const fakeCapability = {} as unknown as CapabilityService
53
+ const service = new AgentRegistryService(fakeEventBus, fakeMoleculer, fakeCapability)
54
+
55
+ return { service, emits, localBus }
56
+ }
57
+
58
+ describe('AgentRegistryService — agent.online event lifecycle', () => {
59
+ let captured: CapturedEmits
60
+ let service: AgentRegistryService
61
+ let localBus: EventEmitter
62
+
63
+ describe('hot path: $node.connected fired after onModuleInit', () => {
64
+ beforeEach(() => {
65
+ const fakes = createFakes()
66
+ service = fakes.service
67
+ captured = fakes.emits
68
+ localBus = fakes.localBus
69
+ service.onModuleInit()
70
+ })
71
+
72
+ it('emits agent.online with the agentId when a new node connects', () => {
73
+ localBus.emit('$node.connected', { node: { id: 'dev-agent-0' } })
74
+ expect(captured.events).toHaveLength(1)
75
+ expect(captured.events[0]!.category).toBe('agent.online')
76
+ expect((captured.events[0]!.data as Record<string, unknown>).agentId).toBe('dev-agent-0')
77
+ })
78
+
79
+ it('emits agent.offline on $node.disconnected', () => {
80
+ localBus.emit('$node.disconnected', { node: { id: 'dev-agent-0' } })
81
+ expect(captured.events).toHaveLength(1)
82
+ expect(captured.events[0]!.category).toBe('agent.offline')
83
+ })
84
+
85
+ it('handles burst of connections without loss', () => {
86
+ for (let i = 0; i < 5; i++) {
87
+ localBus.emit('$node.connected', { node: { id: `agent-${i}` } })
88
+ }
89
+ expect(captured.events).toHaveLength(5)
90
+ expect(captured.events.map((e) => (e.data as Record<string, unknown>).agentId))
91
+ .toEqual(['agent-0', 'agent-1', 'agent-2', 'agent-3', 'agent-4'])
92
+ })
93
+ })
94
+
95
+ describe('cold path: nodes already connected before onModuleInit', () => {
96
+ it('retroactively emits agent.online for pre-existing remote agents and worker.online for sub-workers', () => {
97
+ const fakes = createFakes({
98
+ preExistingNodeIds: ['dev-agent-0', 'dev-agent-1', 'hub/benchmark'],
99
+ })
100
+ fakes.service.onModuleInit()
101
+ // Skip the self-node ('hub'). Bare ids → agents (`agent.online`);
102
+ // `hub/<x>` ids → in-process workers (`worker.online`). The split
103
+ // is intentional — see "Event system: separate WorkerOnline /
104
+ // WorkerOffline events split from agent lifecycle" in
105
+ // `agent-registry.service.ts::onModuleInit`.
106
+ const agentIds = fakes.emits.events
107
+ .filter((e) => e.category === 'agent.online')
108
+ .map((e) => (e.data as Record<string, unknown>).agentId)
109
+ const workerIds = fakes.emits.events
110
+ .filter((e) => e.category === 'worker.online')
111
+ .map((e) => (e.data as Record<string, unknown>).workerId)
112
+ expect(agentIds.sort()).toEqual(['dev-agent-0', 'dev-agent-1'])
113
+ expect(workerIds).toEqual(['hub/benchmark'])
114
+ })
115
+
116
+ it('skips the hub self-node on cold-scan', () => {
117
+ const fakes = createFakes({ preExistingNodeIds: ['hub', 'dev-agent-0'] })
118
+ fakes.service.onModuleInit()
119
+ const ids = fakes.emits.events
120
+ .filter((e) => e.category === 'agent.online')
121
+ .map((e) => (e.data as Record<string, unknown>).agentId)
122
+ expect(ids).toEqual(['dev-agent-0'])
123
+ expect(ids).not.toContain('hub')
124
+ })
125
+
126
+ it('combined cold-scan + hot-path: no duplicates, both sources produce events', () => {
127
+ const fakes = createFakes({ preExistingNodeIds: ['dev-agent-0'] })
128
+ fakes.service.onModuleInit()
129
+ // One from cold-scan.
130
+ expect(fakes.emits.events).toHaveLength(1)
131
+ // New connection fires the hot path.
132
+ fakes.localBus.emit('$node.connected', { node: { id: 'dev-agent-1' } })
133
+ expect(fakes.emits.events).toHaveLength(2)
134
+ expect((fakes.emits.events[1]!.data as Record<string, unknown>).agentId).toBe('dev-agent-1')
135
+ })
136
+ })
137
+
138
+ describe('resilience: malformed registry shape', () => {
139
+ it('does not throw if broker.registry.getNodeList is missing', () => {
140
+ const emits: CapturedEmits = { events: [] }
141
+ const fakeEventBus = { emit: (e: SystemEvent) => emits.events.push(e) } as unknown as EventBusService
142
+ const fakeMoleculer = {
143
+ broker: { nodeID: 'hub', localBus: new EventEmitter(), registry: {} },
144
+ setOnAgentRegistered: (_cb: (nodeId: string) => void) => { /* no-op */ },
145
+ } as unknown as MoleculerService
146
+ const svc = new AgentRegistryService(fakeEventBus, fakeMoleculer, {} as CapabilityService)
147
+ expect(() => svc.onModuleInit()).not.toThrow()
148
+ expect(emits.events).toHaveLength(0)
149
+ })
150
+
151
+ it('does not throw if broker.registry itself is missing', () => {
152
+ const emits: CapturedEmits = { events: [] }
153
+ const fakeEventBus = { emit: (e: SystemEvent) => emits.events.push(e) } as unknown as EventBusService
154
+ const fakeMoleculer = {
155
+ broker: { nodeID: 'hub', localBus: new EventEmitter() },
156
+ setOnAgentRegistered: (_cb: (nodeId: string) => void) => { /* no-op */ },
157
+ } as unknown as MoleculerService
158
+ const svc = new AgentRegistryService(fakeEventBus, fakeMoleculer, {} as CapabilityService)
159
+ expect(() => svc.onModuleInit()).not.toThrow()
160
+ })
161
+ })
162
+ })
@@ -0,0 +1,84 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { renderAgentStatusPage, type AgentStatusData } from '../agent-status-page'
3
+
4
+ const makeStatusData = (overrides: Partial<AgentStatusData> = {}): AgentStatusData => ({
5
+ agentId: 'agent-abc12345',
6
+ agentName: 'test-agent',
7
+ hubUrl: 'ws://localhost:4443/agent',
8
+ connected: true,
9
+ activeTaskCount: 3,
10
+ taskTypes: ['pipeline.decode', 'pipeline.detect'],
11
+ platform: 'darwin',
12
+ arch: 'arm64',
13
+ cpuCores: 8,
14
+ memoryTotalMB: 16384,
15
+ memoryFreeMB: 8192,
16
+ uptime: 3661,
17
+ ...overrides,
18
+ })
19
+
20
+ describe('renderAgentStatusPage', () => {
21
+ it('renders valid HTML', () => {
22
+ const html = renderAgentStatusPage(makeStatusData())
23
+ expect(html).toContain('<!DOCTYPE html>')
24
+ expect(html).toContain('</html>')
25
+ })
26
+
27
+ it('includes agent name and ID', () => {
28
+ const html = renderAgentStatusPage(makeStatusData())
29
+ expect(html).toContain('test-agent')
30
+ expect(html).toContain('agent-abc12345')
31
+ })
32
+
33
+ it('shows connected status with green dot', () => {
34
+ const html = renderAgentStatusPage(makeStatusData({ connected: true }))
35
+ expect(html).toContain('Connected')
36
+ expect(html).toContain('#22c55e')
37
+ })
38
+
39
+ it('shows disconnected status with red dot', () => {
40
+ const html = renderAgentStatusPage(makeStatusData({ connected: false }))
41
+ expect(html).toContain('Disconnected')
42
+ expect(html).toContain('#ef4444')
43
+ })
44
+
45
+ it('displays task types', () => {
46
+ const html = renderAgentStatusPage(makeStatusData())
47
+ expect(html).toContain('pipeline.decode')
48
+ expect(html).toContain('pipeline.detect')
49
+ })
50
+
51
+ it('shows "no task handlers" when list is empty', () => {
52
+ const html = renderAgentStatusPage(makeStatusData({ taskTypes: [] }))
53
+ expect(html).toContain('No task handlers registered')
54
+ })
55
+
56
+ it('displays hardware info', () => {
57
+ const html = renderAgentStatusPage(makeStatusData())
58
+ expect(html).toContain('darwin')
59
+ expect(html).toContain('arm64')
60
+ expect(html).toContain('8')
61
+ })
62
+
63
+ it('calculates memory usage', () => {
64
+ const html = renderAgentStatusPage(
65
+ makeStatusData({ memoryTotalMB: 16384, memoryFreeMB: 4096 }),
66
+ )
67
+ // 12288 / 16384 = 75%
68
+ expect(html).toContain('12288')
69
+ expect(html).toContain('75%')
70
+ })
71
+
72
+ it('escapes HTML in user-provided strings', () => {
73
+ const html = renderAgentStatusPage(
74
+ makeStatusData({ agentName: '<script>alert("xss")</script>' }),
75
+ )
76
+ expect(html).not.toContain('<script>')
77
+ expect(html).toContain('&lt;script&gt;')
78
+ })
79
+
80
+ it('auto-refreshes every 5 seconds', () => {
81
+ const html = renderAgentStatusPage(makeStatusData())
82
+ expect(html).toContain('http-equiv="refresh" content="5"')
83
+ })
84
+ })
@@ -0,0 +1,21 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { buildSessionCookie, clearSessionCookie, SESSION_COOKIE } from '../auth/session-cookie.js'
3
+
4
+ describe('session cookie', () => {
5
+ it('buildSessionCookie produces an httpOnly lax cookie carrying the token', () => {
6
+ const c = buildSessionCookie('jwt-abc', 3600)
7
+ expect(c.name).toBe(SESSION_COOKIE)
8
+ expect(c.value).toBe('jwt-abc')
9
+ expect(c.options.httpOnly).toBe(true)
10
+ expect(c.options.sameSite).toBe('lax')
11
+ expect(c.options.secure).toBe(true)
12
+ expect(c.options.path).toBe('/')
13
+ expect(c.options.maxAge).toBe(3600)
14
+ })
15
+
16
+ it('clearSessionCookie expires the cookie', () => {
17
+ const c = clearSessionCookie()
18
+ expect(c.name).toBe(SESSION_COOKIE)
19
+ expect(c.options.maxAge).toBe(0)
20
+ })
21
+ })
@@ -0,0 +1,23 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+ import { __resetCapUsageRegistryForTests, getCapUsageRegistry } from '@camstack/kernel'
3
+ import { buildNodesProvider } from '../../api/core/cap-providers'
4
+
5
+ describe('nodes.getCapUsageGraph provider', () => {
6
+ beforeEach(() => { __resetCapUsageRegistryForTests() })
7
+
8
+ it('returns recorded edges projected through the cap-usage registry', async () => {
9
+ const reg = getCapUsageRegistry()
10
+ const t0 = Date.now() - 5_000
11
+ reg.recordCall({ callerAddonId: 'A', providerAddonId: 'B', capName: 'foo', methodName: 'm', atMs: t0 })
12
+ reg.recordCall({ callerAddonId: 'A', providerAddonId: 'B', capName: 'foo', methodName: 'm', atMs: t0 + 1000 })
13
+
14
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
15
+ const provider = buildNodesProvider({} as any, { broker: { call: vi.fn() } } as any)
16
+ const out = await provider.getCapUsageGraph({ windowSeconds: 60 })
17
+ expect(out).toHaveLength(1)
18
+ expect(out[0]!.callerAddonId).toBe('A')
19
+ expect(out[0]!.providerAddonId).toBe('B')
20
+ expect(out[0]!.capName).toBe('foo')
21
+ expect(out[0]!.callsPerMin).toBeGreaterThan(0)
22
+ })
23
+ })