@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.
- package/.env.example +17 -0
- package/package.json +55 -0
- package/src/__tests__/addon-install-e2e.test.ts +75 -0
- package/src/__tests__/addon-pages-e2e.test.ts +178 -0
- package/src/__tests__/addon-route-session.test.ts +17 -0
- package/src/__tests__/addon-settings-router.spec.ts +62 -0
- package/src/__tests__/addon-upload.spec.ts +355 -0
- package/src/__tests__/agent-registry.spec.ts +162 -0
- package/src/__tests__/agent-status-page.spec.ts +84 -0
- package/src/__tests__/auth-session-cookie.test.ts +21 -0
- package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +23 -0
- package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +64 -0
- package/src/__tests__/cap-routers/_meta.spec.ts +200 -0
- package/src/__tests__/cap-routers/addon-settings.router.spec.ts +106 -0
- package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +142 -0
- package/src/__tests__/cap-routers/harness.ts +159 -0
- package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +119 -0
- package/src/__tests__/cap-routers/null-provider-guard.spec.ts +66 -0
- package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +135 -0
- package/src/__tests__/cap-routers/settings-store.router.spec.ts +247 -0
- package/src/__tests__/capability-e2e.test.ts +386 -0
- package/src/__tests__/cli-e2e.test.ts +129 -0
- package/src/__tests__/core-cap-bridge.spec.ts +89 -0
- package/src/__tests__/embedded-deps-e2e.test.ts +109 -0
- package/src/__tests__/event-bus-proxy-router.spec.ts +72 -0
- package/src/__tests__/fixtures/mock-analysis-addon-a.ts +37 -0
- package/src/__tests__/fixtures/mock-analysis-addon-b.ts +37 -0
- package/src/__tests__/fixtures/mock-log-addon.ts +37 -0
- package/src/__tests__/fixtures/mock-storage-addon.ts +40 -0
- package/src/__tests__/framework-allowlist.spec.ts +95 -0
- package/src/__tests__/https-e2e.test.ts +118 -0
- package/src/__tests__/lifecycle-e2e.test.ts +140 -0
- package/src/__tests__/live-events-subscription.spec.ts +150 -0
- package/src/__tests__/moleculer-register-node-idempotency.spec.ts +229 -0
- package/src/__tests__/oauth2-account-linking.spec.ts +736 -0
- package/src/__tests__/post-boot-restart.spec.ts +161 -0
- package/src/__tests__/singleton-contention.test.ts +487 -0
- package/src/__tests__/streaming-diagnostic.test.ts +512 -0
- package/src/__tests__/streaming-scale.test.ts +280 -0
- package/src/agent-status-page.ts +121 -0
- package/src/api/__tests__/addons-custom.spec.ts +134 -0
- package/src/api/__tests__/capabilities.router.test.ts +47 -0
- package/src/api/addon-upload.ts +472 -0
- package/src/api/addons-custom.router.ts +100 -0
- package/src/api/auth-whoami.ts +99 -0
- package/src/api/bridge-addons.router.ts +120 -0
- package/src/api/capabilities.router.ts +226 -0
- package/src/api/core/__tests__/auth-router-totp.spec.ts +256 -0
- package/src/api/core/addon-settings.router.ts +124 -0
- package/src/api/core/agents.router.ts +87 -0
- package/src/api/core/auth.router.ts +303 -0
- package/src/api/core/cap-providers.ts +993 -0
- package/src/api/core/capabilities.router.ts +119 -0
- package/src/api/core/collection-preference.ts +40 -0
- package/src/api/core/event-bus-proxy.router.ts +45 -0
- package/src/api/core/hwaccel.router.ts +81 -0
- package/src/api/core/live-events.router.ts +60 -0
- package/src/api/core/logs.router.ts +162 -0
- package/src/api/core/notifications.router.ts +65 -0
- package/src/api/core/repl.router.ts +41 -0
- package/src/api/core/settings-backend.router.ts +142 -0
- package/src/api/core/stream-probe.router.ts +57 -0
- package/src/api/core/system-events.router.ts +116 -0
- package/src/api/health/health.routes.ts +123 -0
- package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +52 -0
- package/src/api/oauth2/consent-page.ts +42 -0
- package/src/api/oauth2/oauth2-routes.ts +248 -0
- package/src/api/trpc/__tests__/scope-access-device.spec.ts +223 -0
- package/src/api/trpc/__tests__/scope-access.spec.ts +107 -0
- package/src/api/trpc/cap-mount-helpers.ts +225 -0
- package/src/api/trpc/core-cap-bridge.ts +152 -0
- package/src/api/trpc/generated-cap-mounts.ts +707 -0
- package/src/api/trpc/generated-cap-routers.ts +6340 -0
- package/src/api/trpc/scope-access.ts +110 -0
- package/src/api/trpc/trpc.context.ts +255 -0
- package/src/api/trpc/trpc.middleware.ts +140 -0
- package/src/api/trpc/trpc.router.ts +275 -0
- package/src/auth/session-cookie.ts +44 -0
- package/src/boot/boot-config.ts +278 -0
- package/src/boot/post-boot.service.ts +103 -0
- package/src/core/addon/__tests__/addon-registry-capability.test.ts +53 -0
- package/src/core/addon/addon-package.service.ts +1684 -0
- package/src/core/addon/addon-registry.service.ts +2926 -0
- package/src/core/addon/addon-search.service.ts +90 -0
- package/src/core/addon/addon-settings-provider.ts +276 -0
- package/src/core/addon/addon.tokens.ts +2 -0
- package/src/core/addon-bridge/addon-bridge.service.ts +125 -0
- package/src/core/addon-pages/addon-pages.service.spec.ts +117 -0
- package/src/core/addon-pages/addon-pages.service.ts +80 -0
- package/src/core/addon-widgets/addon-widgets.service.ts +92 -0
- package/src/core/agent/agent-registry.service.ts +507 -0
- package/src/core/auth/auth.service.spec.ts +88 -0
- package/src/core/auth/auth.service.ts +8 -0
- package/src/core/capability/capability.service.ts +57 -0
- package/src/core/config/config.schema.ts +3 -0
- package/src/core/config/config.service.spec.ts +175 -0
- package/src/core/config/config.service.ts +7 -0
- package/src/core/events/event-bus.service.spec.ts +212 -0
- package/src/core/events/event-bus.service.ts +85 -0
- package/src/core/feature/feature.service.spec.ts +96 -0
- package/src/core/feature/feature.service.ts +8 -0
- package/src/core/lifecycle/lifecycle-state-machine.spec.ts +168 -0
- package/src/core/lifecycle/lifecycle-state-machine.ts +3 -0
- package/src/core/logging/log-ring-buffer.ts +3 -0
- package/src/core/logging/logging.service.spec.ts +247 -0
- package/src/core/logging/logging.service.ts +129 -0
- package/src/core/logging/scoped-logger.ts +3 -0
- package/src/core/moleculer/moleculer.service.ts +612 -0
- package/src/core/network/network-quality.service.spec.ts +47 -0
- package/src/core/network/network-quality.service.ts +5 -0
- package/src/core/notification/notification-wrapper.service.ts +36 -0
- package/src/core/notification/toast-wrapper.service.ts +31 -0
- package/src/core/provider/provider.tokens.ts +1 -0
- package/src/core/repl/repl-engine.service.spec.ts +417 -0
- package/src/core/repl/repl-engine.service.ts +156 -0
- package/src/core/storage/fs-storage-backend.spec.ts +70 -0
- package/src/core/storage/fs-storage-backend.ts +3 -0
- package/src/core/storage/settings-store.spec.ts +213 -0
- package/src/core/storage/settings-store.ts +2 -0
- package/src/core/storage/sql-schema.spec.ts +140 -0
- package/src/core/storage/sql-schema.ts +3 -0
- package/src/core/storage/storage-location-manager.spec.ts +121 -0
- package/src/core/storage/storage-location-manager.ts +3 -0
- package/src/core/storage/storage.service.spec.ts +73 -0
- package/src/core/storage/storage.service.ts +3 -0
- package/src/core/streaming/stream-probe.service.ts +212 -0
- package/src/core/topology/topology-emitter.service.ts +101 -0
- package/src/launcher.ts +309 -0
- package/src/main.ts +1049 -0
- package/src/manual-boot.ts +322 -0
- package/tsconfig.build.json +8 -0
- package/tsconfig.json +21 -0
- 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('<script>')
|
|
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
|
+
})
|