@camstack/server 0.1.8 → 0.2.1
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 +9 -7
- package/src/__tests__/addon-install-e2e.test.ts +0 -1
- package/src/__tests__/addon-pages-e2e.test.ts +40 -18
- package/src/__tests__/addon-settings-router.spec.ts +6 -1
- package/src/__tests__/addon-upload.spec.ts +91 -29
- package/src/__tests__/agent-registry.spec.ts +26 -9
- package/src/__tests__/agent-status-page.spec.ts +1 -3
- package/src/__tests__/auth-session-cookie.test.ts +28 -1
- package/src/__tests__/bulk-update-coordinator.spec.ts +48 -31
- package/src/__tests__/cap-ownership-authority.spec.ts +39 -8
- package/src/__tests__/cap-providers/cap-providers-location-import.spec.ts +24 -4
- package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +17 -3
- package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +57 -11
- package/src/__tests__/cap-providers/integrations-delete-cascade.spec.ts +64 -15
- package/src/__tests__/cap-providers-bulk-update.spec.ts +27 -7
- package/src/__tests__/cap-route-adapter.spec.ts +28 -15
- package/src/__tests__/cap-routers/_meta.spec.ts +6 -7
- package/src/__tests__/cap-routers/addon-settings.router.spec.ts +19 -10
- package/src/__tests__/cap-routers/broker-routing.router.spec.ts +14 -6
- package/src/__tests__/cap-routers/cap-route-error-formatter.spec.ts +3 -1
- package/src/__tests__/cap-routers/capabilities-node.spec.ts +18 -5
- package/src/__tests__/cap-routers/device-link-overlay.spec.ts +11 -6
- package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +72 -20
- package/src/__tests__/cap-routers/harness.ts +11 -7
- package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +17 -3
- package/src/__tests__/cap-routers/null-provider-guard.spec.ts +5 -7
- package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +35 -11
- package/src/__tests__/cap-routers/settings-store.router.spec.ts +59 -15
- package/src/__tests__/capability-e2e.test.ts +9 -11
- package/src/__tests__/cli-e2e.test.ts +80 -59
- package/src/__tests__/core-cap-bridge.spec.ts +3 -1
- package/src/__tests__/dev-bootstrap-shm-ring.spec.ts +12 -2
- package/src/__tests__/device-settings-contribution-dispatch.spec.ts +61 -30
- package/src/__tests__/embedded-deps-e2e.test.ts +35 -19
- package/src/__tests__/event-bus-proxy-router.spec.ts +3 -0
- package/src/__tests__/framework-allowlist.spec.ts +5 -4
- package/src/__tests__/https-e2e.test.ts +12 -6
- package/src/__tests__/lifecycle-e2e.test.ts +60 -11
- package/src/__tests__/live-events-subscription.spec.ts +17 -18
- package/src/__tests__/moleculer/uds-readiness.spec.ts +11 -4
- package/src/__tests__/moleculer/uds-topology.spec.ts +39 -11
- package/src/__tests__/moleculer/uds-unowned-call.spec.ts +71 -17
- package/src/__tests__/moleculer-register-node-idempotency.spec.ts +16 -7
- package/src/__tests__/native-cap-route.spec.ts +42 -19
- package/src/__tests__/oauth2-account-linking.spec.ts +63 -17
- package/src/__tests__/singleton-contention.test.ts +23 -11
- package/src/__tests__/streaming-diagnostic.test.ts +156 -53
- package/src/__tests__/streaming-scale.test.ts +69 -35
- package/src/__tests__/uds-addon-call-wiring.spec.ts +6 -1
- package/src/agent-status-page.ts +4 -3
- package/src/api/__tests__/addons-custom.spec.ts +22 -8
- package/src/api/__tests__/capabilities.router.test.ts +18 -9
- package/src/api/addon-upload.ts +46 -15
- package/src/api/addons-custom.router.ts +7 -6
- package/src/api/auth-whoami.ts +3 -1
- package/src/api/bridge-addons.router.ts +3 -1
- package/src/api/capabilities.router.ts +117 -78
- package/src/api/core/__tests__/auth-router-totp.spec.ts +57 -16
- package/src/api/core/addon-settings.router.ts +4 -1
- package/src/api/core/agents.router.ts +52 -53
- package/src/api/core/auth.router.ts +55 -36
- package/src/api/core/bulk-update-coordinator.ts +25 -22
- package/src/api/core/cap-providers.ts +346 -202
- package/src/api/core/capabilities.router.ts +30 -23
- package/src/api/core/hwaccel.router.ts +37 -10
- package/src/api/core/live-events.router.ts +16 -9
- package/src/api/core/logs.router.ts +54 -25
- package/src/api/core/notifications.router.ts +2 -1
- package/src/api/core/repl.router.ts +1 -3
- package/src/api/core/settings-backend.router.ts +68 -70
- package/src/api/core/system-events.router.ts +41 -32
- package/src/api/health/health.routes.ts +7 -13
- package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +12 -2
- package/src/api/oauth2/consent-page.ts +4 -3
- package/src/api/oauth2/oauth2-routes.ts +41 -12
- package/src/api/trpc/__tests__/scope-access-device.spec.ts +68 -23
- package/src/api/trpc/__tests__/scope-access.spec.ts +8 -13
- package/src/api/trpc/__tests__/webrtc-session-ua-enrich.spec.ts +10 -2
- package/src/api/trpc/cap-mount-helpers.ts +64 -55
- package/src/api/trpc/cap-route-error-formatter.ts +17 -9
- package/src/api/trpc/core-cap-bridge.ts +3 -1
- package/src/api/trpc/generated-cap-mounts.ts +593 -351
- package/src/api/trpc/generated-cap-routers.ts +3680 -579
- package/src/api/trpc/scope-access.ts +7 -7
- package/src/api/trpc/trpc.context.ts +7 -4
- package/src/api/trpc/trpc.middleware.ts +4 -2
- package/src/api/trpc/trpc.router.ts +79 -46
- package/src/auth/session-cookie.ts +10 -0
- package/src/boot/__tests__/integration-id-backfill.spec.ts +21 -6
- package/src/boot/boot-config.ts +103 -122
- package/src/boot/post-boot.service.ts +5 -3
- package/src/core/addon/__tests__/addon-registry-capability.test.ts +12 -3
- package/src/core/addon/addon-call-gateway.ts +20 -6
- package/src/core/addon/addon-package.service.ts +183 -89
- package/src/core/addon/addon-registry.service.ts +1163 -1305
- package/src/core/addon/addon-search.service.ts +2 -1
- package/src/core/addon/addon-settings-provider.ts +27 -7
- package/src/core/addon-bridge/addon-bridge.service.ts +11 -6
- package/src/core/addon-pages/addon-pages.service.ts +3 -1
- package/src/core/addon-widgets/addon-widgets.service.ts +5 -2
- package/src/core/agent/agent-registry.service.ts +60 -38
- package/src/core/auth/auth.service.spec.ts +6 -8
- package/src/core/config/config.service.spec.ts +1 -1
- package/src/core/events/event-bus.service.spec.ts +44 -21
- package/src/core/events/event-bus.service.ts +5 -1
- package/src/core/feature/feature.service.spec.ts +4 -1
- package/src/core/lifecycle/lifecycle-state-machine.spec.ts +8 -10
- package/src/core/logging/logging.service.spec.ts +61 -21
- package/src/core/logging/logging.service.ts +12 -3
- package/src/core/moleculer/cap-call-fn.spec.ts +17 -10
- package/src/core/moleculer/cap-call-fn.ts +5 -1
- package/src/core/moleculer/cap-route-authority.ts +18 -6
- package/src/core/moleculer/moleculer.service.ts +120 -32
- package/src/core/network/network-quality.service.spec.ts +6 -1
- package/src/core/notification/notification-wrapper.service.ts +1 -3
- package/src/core/notification/toast-wrapper.service.ts +1 -5
- package/src/core/repl/repl-engine.service.spec.ts +66 -39
- package/src/core/repl/repl-engine.service.ts +11 -12
- package/src/core/storage/storage-location-manager.spec.ts +12 -3
- package/src/core/streaming/stream-probe.service.ts +22 -13
- package/src/core/topology/topology-emitter.service.ts +5 -1
- package/src/launcher.ts +14 -9
- package/src/main.ts +602 -531
- package/src/manual-boot.ts +133 -154
- package/tsconfig.json +20 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/server",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"exports": {
|
|
6
6
|
"./package.json": "./package.json",
|
|
@@ -20,7 +20,6 @@
|
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"@camstack/addon-admin-ui": "*",
|
|
22
22
|
"@camstack/addon-advanced-notifier": "*",
|
|
23
|
-
"@camstack/addon-benchmark": "*",
|
|
24
23
|
"@camstack/addon-pipeline": "*",
|
|
25
24
|
"@camstack/addon-pipeline-orchestrator": "*",
|
|
26
25
|
"@camstack/addon-post-analysis": "*",
|
|
@@ -31,25 +30,28 @@
|
|
|
31
30
|
"@camstack/types": "*",
|
|
32
31
|
"@camstack/ui-library": "*",
|
|
33
32
|
"@fastify/cookie": "^11.0.2",
|
|
34
|
-
"@fastify/multipart": "^
|
|
35
|
-
"@fastify/static": "^
|
|
33
|
+
"@fastify/multipart": "^10.0.0",
|
|
34
|
+
"@fastify/static": "^9.1.3",
|
|
35
|
+
"@trpc/client": "^11.16.0",
|
|
36
36
|
"@trpc/server": "^11.16.0",
|
|
37
37
|
"fastify": "^5",
|
|
38
38
|
"js-yaml": "^4",
|
|
39
39
|
"moleculer": "^0.15.0",
|
|
40
|
-
"
|
|
40
|
+
"superjson": "^2.2.6",
|
|
41
|
+
"tar": "6.2.1",
|
|
41
42
|
"ws": "^8.20.0",
|
|
42
43
|
"zod": "^4.3.6"
|
|
43
44
|
},
|
|
44
45
|
"devDependencies": {
|
|
45
46
|
"@swc/core": "^1.15.18",
|
|
46
47
|
"@types/js-yaml": "^4",
|
|
47
|
-
"@types/node": "^
|
|
48
|
+
"@types/node": "^24",
|
|
48
49
|
"@types/tar": "^6.1.13",
|
|
49
50
|
"@types/ws": "^8.18.1",
|
|
50
51
|
"tsx": "^4",
|
|
51
|
-
"typescript": "
|
|
52
|
+
"typescript": "~6.0.3",
|
|
52
53
|
"unplugin-swc": "^1.5.9",
|
|
54
|
+
"vite": "^8.0.11",
|
|
53
55
|
"vitest": "*"
|
|
54
56
|
}
|
|
55
57
|
}
|
|
@@ -10,7 +10,6 @@ import * as path from 'node:path'
|
|
|
10
10
|
const TEST_ADDONS_DIR = path.resolve('test-output/fresh-addons')
|
|
11
11
|
|
|
12
12
|
describe('Addon Directory Loading', () => {
|
|
13
|
-
|
|
14
13
|
beforeAll(() => {
|
|
15
14
|
// Clean slate — delete the test addons directory
|
|
16
15
|
fs.rmSync(TEST_ADDONS_DIR, { recursive: true, force: true })
|
|
@@ -18,16 +18,23 @@ import type { AddonContext, IScopedLogger, ProviderRegistration } from '@camstac
|
|
|
18
18
|
|
|
19
19
|
interface AdminUIAddonInstance {
|
|
20
20
|
readonly id: string
|
|
21
|
-
initialize(ctx: AddonContext): Promise<
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
initialize(ctx: AddonContext): Promise<
|
|
22
|
+
| ProviderRegistration[]
|
|
23
|
+
| void
|
|
24
|
+
| undefined
|
|
25
|
+
| {
|
|
26
|
+
readonly providers?: readonly ProviderRegistration[]
|
|
27
|
+
}
|
|
28
|
+
>
|
|
24
29
|
}
|
|
25
30
|
|
|
26
31
|
type AdminUIAddonCtor = new () => AdminUIAddonInstance
|
|
27
32
|
|
|
28
33
|
async function loadAdminUIAddon(): Promise<AdminUIAddonCtor | null> {
|
|
29
34
|
try {
|
|
30
|
-
const mod = await import('@camstack/addon-admin-ui/server/addon') as {
|
|
35
|
+
const mod = (await import('@camstack/addon-admin-ui/server/addon')) as {
|
|
36
|
+
AdminUIAddon?: AdminUIAddonCtor
|
|
37
|
+
}
|
|
31
38
|
return mod.AdminUIAddon ?? null
|
|
32
39
|
} catch {
|
|
33
40
|
return null
|
|
@@ -67,9 +74,10 @@ describe('Addon Pages Integration', () => {
|
|
|
67
74
|
if (result) {
|
|
68
75
|
const regs = Array.isArray(result) ? result : ((result as any).providers ?? [])
|
|
69
76
|
for (const reg of regs as ProviderRegistration[]) {
|
|
70
|
-
const capName =
|
|
71
|
-
|
|
72
|
-
|
|
77
|
+
const capName =
|
|
78
|
+
typeof reg.capability === 'string'
|
|
79
|
+
? reg.capability
|
|
80
|
+
: ((reg.capability as any)?.name ?? String(reg.capability))
|
|
73
81
|
providers.set(capName, reg.provider)
|
|
74
82
|
}
|
|
75
83
|
}
|
|
@@ -77,7 +85,6 @@ describe('Addon Pages Integration', () => {
|
|
|
77
85
|
const provider = providers.get('addon-pages') as { getPages(): unknown[] } | undefined
|
|
78
86
|
expect(provider).toBeDefined()
|
|
79
87
|
|
|
80
|
-
|
|
81
88
|
const pages = provider!.getPages()
|
|
82
89
|
expect(pages).toHaveLength(1)
|
|
83
90
|
|
|
@@ -116,17 +123,20 @@ describe('Addon Pages Integration', () => {
|
|
|
116
123
|
if (result) {
|
|
117
124
|
const regs = Array.isArray(result) ? result : ((result as any).providers ?? [])
|
|
118
125
|
for (const reg of regs as ProviderRegistration[]) {
|
|
119
|
-
const capName =
|
|
120
|
-
|
|
121
|
-
|
|
126
|
+
const capName =
|
|
127
|
+
typeof reg.capability === 'string'
|
|
128
|
+
? reg.capability
|
|
129
|
+
: ((reg.capability as any)?.name ?? String(reg.capability))
|
|
122
130
|
providers.set(capName, reg.provider)
|
|
123
131
|
}
|
|
124
132
|
}
|
|
125
133
|
|
|
126
|
-
const ui = providers.get('admin-ui') as
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
134
|
+
const ui = providers.get('admin-ui') as
|
|
135
|
+
| {
|
|
136
|
+
getStaticDir(): Promise<{ readonly staticDir: string }>
|
|
137
|
+
getVersion(): Promise<{ readonly version: string }>
|
|
138
|
+
}
|
|
139
|
+
| undefined
|
|
130
140
|
expect(ui).toBeDefined()
|
|
131
141
|
expect(typeof ui!.getStaticDir).toBe('function')
|
|
132
142
|
expect(typeof ui!.getVersion).toBe('function')
|
|
@@ -138,7 +148,12 @@ describe('Addon Pages Integration', () => {
|
|
|
138
148
|
const registry = new CapabilityRegistry(createMockLogger())
|
|
139
149
|
registry.ready()
|
|
140
150
|
|
|
141
|
-
registry.declareCapability({
|
|
151
|
+
registry.declareCapability({
|
|
152
|
+
name: 'addon-pages',
|
|
153
|
+
scope: 'system',
|
|
154
|
+
mode: 'collection',
|
|
155
|
+
methods: {},
|
|
156
|
+
})
|
|
142
157
|
|
|
143
158
|
registry.registerProvider('addon-pages', 'benchmark', {
|
|
144
159
|
id: 'benchmark',
|
|
@@ -163,14 +178,21 @@ describe('Addon Pages Integration', () => {
|
|
|
163
178
|
const registry = new CapabilityRegistry(createMockLogger())
|
|
164
179
|
registry.ready()
|
|
165
180
|
|
|
166
|
-
registry.declareCapability({
|
|
181
|
+
registry.declareCapability({
|
|
182
|
+
name: 'admin-ui',
|
|
183
|
+
scope: 'system',
|
|
184
|
+
mode: 'singleton',
|
|
185
|
+
methods: {},
|
|
186
|
+
})
|
|
167
187
|
|
|
168
188
|
registry.registerProvider('admin-ui', 'admin-ui', {
|
|
169
189
|
getStaticDir: async () => ({ staticDir: '/some/path/dist' }),
|
|
170
190
|
getVersion: async () => ({ version: '0.1.0' }),
|
|
171
191
|
})
|
|
172
192
|
|
|
173
|
-
const ui = registry.getSingleton<{ getVersion(): Promise<{ readonly version: string }> }>(
|
|
193
|
+
const ui = registry.getSingleton<{ getVersion(): Promise<{ readonly version: string }> }>(
|
|
194
|
+
'admin-ui',
|
|
195
|
+
)
|
|
174
196
|
expect(ui).toBeDefined()
|
|
175
197
|
const version = await ui!.getVersion()
|
|
176
198
|
expect(version.version).toBe('0.1.0')
|
|
@@ -48,7 +48,12 @@ describe('addon-settings router', () => {
|
|
|
48
48
|
})
|
|
49
49
|
|
|
50
50
|
it('updateDevice stores a field and getDeviceOverrides reflects it', async () => {
|
|
51
|
-
await caller.updateDevice({
|
|
51
|
+
await caller.updateDevice({
|
|
52
|
+
addonId: 'my-addon',
|
|
53
|
+
deviceId: 'cam-1',
|
|
54
|
+
field: 'resolution',
|
|
55
|
+
value: '1080p',
|
|
56
|
+
})
|
|
52
57
|
const result = await caller.getDeviceOverrides({ addonId: 'my-addon', deviceId: 'cam-1' })
|
|
53
58
|
expect(result).toEqual({ resolution: '1080p' })
|
|
54
59
|
})
|
|
@@ -36,10 +36,7 @@ function buildValidTarball(manifest: { name: string; version: string }): Buffer
|
|
|
36
36
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vitest-tarball-'))
|
|
37
37
|
try {
|
|
38
38
|
fs.mkdirSync(path.join(tmpDir, 'package'), { recursive: true })
|
|
39
|
-
fs.writeFileSync(
|
|
40
|
-
path.join(tmpDir, 'package', 'package.json'),
|
|
41
|
-
JSON.stringify(manifest),
|
|
42
|
-
)
|
|
39
|
+
fs.writeFileSync(path.join(tmpDir, 'package', 'package.json'), JSON.stringify(manifest))
|
|
43
40
|
const tgz = path.join(tmpDir, 'out.tgz')
|
|
44
41
|
execFileSync('tar', ['-czf', tgz, '-C', tmpDir, 'package'])
|
|
45
42
|
return fs.readFileSync(tgz)
|
|
@@ -94,9 +91,13 @@ function makeAuth(kind: 'admin' | 'non-admin' | 'invalid' = 'admin'): AuthServic
|
|
|
94
91
|
|
|
95
92
|
function makeAddonBridge(installed?: { name: string; version: string }): AddonBridgeService {
|
|
96
93
|
return {
|
|
97
|
-
getInstaller: vi.fn(() =>
|
|
98
|
-
|
|
99
|
-
|
|
94
|
+
getInstaller: vi.fn(() =>
|
|
95
|
+
installed === undefined
|
|
96
|
+
? null
|
|
97
|
+
: {
|
|
98
|
+
installFromTgz: vi.fn(async () => installed),
|
|
99
|
+
},
|
|
100
|
+
),
|
|
100
101
|
reloadPackages: vi.fn(async () => {}),
|
|
101
102
|
} as unknown as AddonBridgeService
|
|
102
103
|
}
|
|
@@ -144,8 +145,12 @@ function makeLogger(): IScopedLogger {
|
|
|
144
145
|
info: vi.fn(),
|
|
145
146
|
warn: vi.fn(),
|
|
146
147
|
error: vi.fn(),
|
|
147
|
-
child: vi.fn(function (this: IScopedLogger) {
|
|
148
|
-
|
|
148
|
+
child: vi.fn(function (this: IScopedLogger) {
|
|
149
|
+
return this
|
|
150
|
+
}),
|
|
151
|
+
withTags: vi.fn(function (this: IScopedLogger) {
|
|
152
|
+
return this
|
|
153
|
+
}),
|
|
149
154
|
} as unknown as IScopedLogger
|
|
150
155
|
}
|
|
151
156
|
|
|
@@ -199,7 +204,9 @@ describe('POST /api/addons/upload', () => {
|
|
|
199
204
|
method: 'POST',
|
|
200
205
|
url: '/api/addons/upload',
|
|
201
206
|
headers: { 'content-type': `multipart/form-data; boundary=${TGZ_BOUNDARY}` },
|
|
202
|
-
payload: buildMultipart(TGZ_BOUNDARY, {
|
|
207
|
+
payload: buildMultipart(TGZ_BOUNDARY, {
|
|
208
|
+
file: { filename: 'addon-x.tgz', content: VALID_TARBALL },
|
|
209
|
+
}),
|
|
203
210
|
})
|
|
204
211
|
expect(res.statusCode).toBe(401)
|
|
205
212
|
})
|
|
@@ -214,8 +221,13 @@ describe('POST /api/addons/upload', () => {
|
|
|
214
221
|
const res = await fastify.inject({
|
|
215
222
|
method: 'POST',
|
|
216
223
|
url: '/api/addons/upload',
|
|
217
|
-
headers: {
|
|
218
|
-
|
|
224
|
+
headers: {
|
|
225
|
+
'content-type': `multipart/form-data; boundary=${TGZ_BOUNDARY}`,
|
|
226
|
+
authorization: 'Bearer token',
|
|
227
|
+
},
|
|
228
|
+
payload: buildMultipart(TGZ_BOUNDARY, {
|
|
229
|
+
file: { filename: 'addon-x.tgz', content: VALID_TARBALL },
|
|
230
|
+
}),
|
|
219
231
|
})
|
|
220
232
|
expect(res.statusCode).toBe(403)
|
|
221
233
|
})
|
|
@@ -230,8 +242,13 @@ describe('POST /api/addons/upload', () => {
|
|
|
230
242
|
const res = await fastify.inject({
|
|
231
243
|
method: 'POST',
|
|
232
244
|
url: '/api/addons/upload',
|
|
233
|
-
headers: {
|
|
234
|
-
|
|
245
|
+
headers: {
|
|
246
|
+
'content-type': `multipart/form-data; boundary=${TGZ_BOUNDARY}`,
|
|
247
|
+
authorization: 'Bearer t',
|
|
248
|
+
},
|
|
249
|
+
payload: buildMultipart(TGZ_BOUNDARY, {
|
|
250
|
+
file: { filename: 'evil.exe', content: VALID_TARBALL },
|
|
251
|
+
}),
|
|
235
252
|
})
|
|
236
253
|
expect(res.statusCode).toBe(400)
|
|
237
254
|
})
|
|
@@ -240,7 +257,9 @@ describe('POST /api/addons/upload', () => {
|
|
|
240
257
|
// Hub install writes a `.install-source` marker into
|
|
241
258
|
// `${CAMSTACK_DATA}/addons/<addonName>/`. Point CAMSTACK_DATA at an
|
|
242
259
|
// isolated temp dir for the test so the side effect doesn't escape.
|
|
243
|
-
const tmpRoot = await import('node:fs/promises').then(fs =>
|
|
260
|
+
const tmpRoot = await import('node:fs/promises').then((fs) =>
|
|
261
|
+
fs.mkdtemp('/tmp/camstack-upload-test-'),
|
|
262
|
+
)
|
|
244
263
|
const previous = process.env['CAMSTACK_DATA']
|
|
245
264
|
process.env['CAMSTACK_DATA'] = tmpRoot
|
|
246
265
|
const fsSync = await import('node:fs')
|
|
@@ -256,12 +275,27 @@ describe('POST /api/addons/upload', () => {
|
|
|
256
275
|
const res = await fastify.inject({
|
|
257
276
|
method: 'POST',
|
|
258
277
|
url: '/api/addons/upload',
|
|
259
|
-
headers: {
|
|
260
|
-
|
|
278
|
+
headers: {
|
|
279
|
+
'content-type': `multipart/form-data; boundary=${TGZ_BOUNDARY}`,
|
|
280
|
+
authorization: 'Bearer t',
|
|
281
|
+
},
|
|
282
|
+
payload: buildMultipart(TGZ_BOUNDARY, {
|
|
283
|
+
file: { filename: 'addon-x.tgz', content: VALID_TARBALL },
|
|
284
|
+
}),
|
|
261
285
|
})
|
|
262
286
|
expect(res.statusCode).toBe(200)
|
|
263
|
-
const body = res.json() as {
|
|
264
|
-
|
|
287
|
+
const body = res.json() as {
|
|
288
|
+
success: boolean
|
|
289
|
+
target: string
|
|
290
|
+
packageName: string
|
|
291
|
+
version: string
|
|
292
|
+
}
|
|
293
|
+
expect(body).toMatchObject({
|
|
294
|
+
success: true,
|
|
295
|
+
target: 'hub',
|
|
296
|
+
packageName: 'addon-x',
|
|
297
|
+
version: '2.0.0',
|
|
298
|
+
})
|
|
265
299
|
} finally {
|
|
266
300
|
if (previous === undefined) delete process.env['CAMSTACK_DATA']
|
|
267
301
|
else process.env['CAMSTACK_DATA'] = previous
|
|
@@ -272,7 +306,9 @@ describe('POST /api/addons/upload', () => {
|
|
|
272
306
|
it('restarts the server (not an in-process reload) when a framework package is deployed', async () => {
|
|
273
307
|
// @camstack/core ships hub builtins that can't hot-reload in place — the
|
|
274
308
|
// upload path must call restartServer() and SKIP the per-addon restartAddon.
|
|
275
|
-
const tmpRoot = await import('node:fs/promises').then(fs =>
|
|
309
|
+
const tmpRoot = await import('node:fs/promises').then((fs) =>
|
|
310
|
+
fs.mkdtemp('/tmp/camstack-upload-fw-'),
|
|
311
|
+
)
|
|
276
312
|
const previous = process.env['CAMSTACK_DATA']
|
|
277
313
|
process.env['CAMSTACK_DATA'] = tmpRoot
|
|
278
314
|
const fsSync = await import('node:fs')
|
|
@@ -282,7 +318,9 @@ describe('POST /api/addons/upload', () => {
|
|
|
282
318
|
const restartServer = vi.fn((_requestedBy?: string) => {})
|
|
283
319
|
const restartAddon = vi.fn(async (_id: string) => ({ success: true }))
|
|
284
320
|
const registry = {
|
|
285
|
-
listAddons: vi.fn(() => [
|
|
321
|
+
listAddons: vi.fn(() => [
|
|
322
|
+
{ manifest: { id: 'sqlite-settings', packageName: '@camstack/core' } },
|
|
323
|
+
]),
|
|
286
324
|
loadNewAddons: vi.fn(async () => ({ loaded: [] as string[], failed: [] as string[] })),
|
|
287
325
|
restartAddon,
|
|
288
326
|
getCapabilityRegistry: vi.fn(() => ({ getSingleton: vi.fn((_n: string) => null) })),
|
|
@@ -299,9 +337,15 @@ describe('POST /api/addons/upload', () => {
|
|
|
299
337
|
const res = await fastify.inject({
|
|
300
338
|
method: 'POST',
|
|
301
339
|
url: '/api/addons/upload',
|
|
302
|
-
headers: {
|
|
340
|
+
headers: {
|
|
341
|
+
'content-type': `multipart/form-data; boundary=${TGZ_BOUNDARY}`,
|
|
342
|
+
authorization: 'Bearer t',
|
|
343
|
+
},
|
|
303
344
|
payload: buildMultipart(TGZ_BOUNDARY, {
|
|
304
|
-
file: {
|
|
345
|
+
file: {
|
|
346
|
+
filename: 'camstack-core-9.9.9.tgz',
|
|
347
|
+
content: buildValidTarball({ name: '@camstack/core', version: '9.9.9' }),
|
|
348
|
+
},
|
|
305
349
|
}),
|
|
306
350
|
})
|
|
307
351
|
expect(res.statusCode).toBe(200)
|
|
@@ -317,7 +361,11 @@ describe('POST /api/addons/upload', () => {
|
|
|
317
361
|
})
|
|
318
362
|
|
|
319
363
|
it('routes to $agent.deploy when nodeId is provided', async () => {
|
|
320
|
-
const call = vi.fn(async () => ({
|
|
364
|
+
const call = vi.fn(async () => ({
|
|
365
|
+
success: true,
|
|
366
|
+
addonId: 'addon-x',
|
|
367
|
+
path: '/agent/addons/addon-x',
|
|
368
|
+
}))
|
|
321
369
|
fastify = await makeServer({
|
|
322
370
|
auth: makeAuth('admin'),
|
|
323
371
|
bridge: makeAddonBridge(),
|
|
@@ -326,7 +374,10 @@ describe('POST /api/addons/upload', () => {
|
|
|
326
374
|
const res = await fastify.inject({
|
|
327
375
|
method: 'POST',
|
|
328
376
|
url: '/api/addons/upload',
|
|
329
|
-
headers: {
|
|
377
|
+
headers: {
|
|
378
|
+
'content-type': `multipart/form-data; boundary=${TGZ_BOUNDARY}`,
|
|
379
|
+
authorization: 'Bearer t',
|
|
380
|
+
},
|
|
330
381
|
payload: buildMultipart(TGZ_BOUNDARY, {
|
|
331
382
|
file: { filename: 'addon-x-1.2.3.tgz', content: VALID_TARBALL },
|
|
332
383
|
nodeId: 'agent-frigate',
|
|
@@ -346,7 +397,9 @@ describe('POST /api/addons/upload', () => {
|
|
|
346
397
|
})
|
|
347
398
|
|
|
348
399
|
it('returns 502 when $agent.deploy throws', async () => {
|
|
349
|
-
const call = vi.fn(async () => {
|
|
400
|
+
const call = vi.fn(async () => {
|
|
401
|
+
throw new Error('agent unreachable')
|
|
402
|
+
})
|
|
350
403
|
fastify = await makeServer({
|
|
351
404
|
auth: makeAuth('admin'),
|
|
352
405
|
bridge: makeAddonBridge(),
|
|
@@ -355,7 +408,10 @@ describe('POST /api/addons/upload', () => {
|
|
|
355
408
|
const res = await fastify.inject({
|
|
356
409
|
method: 'POST',
|
|
357
410
|
url: '/api/addons/upload',
|
|
358
|
-
headers: {
|
|
411
|
+
headers: {
|
|
412
|
+
'content-type': `multipart/form-data; boundary=${TGZ_BOUNDARY}`,
|
|
413
|
+
authorization: 'Bearer t',
|
|
414
|
+
},
|
|
359
415
|
payload: buildMultipart(TGZ_BOUNDARY, {
|
|
360
416
|
file: { filename: 'addon-x.tgz', content: VALID_TARBALL },
|
|
361
417
|
nodeId: 'agent-frigate',
|
|
@@ -377,7 +433,10 @@ describe('POST /api/addons/upload', () => {
|
|
|
377
433
|
await fastify.inject({
|
|
378
434
|
method: 'POST',
|
|
379
435
|
url: '/api/addons/upload',
|
|
380
|
-
headers: {
|
|
436
|
+
headers: {
|
|
437
|
+
'content-type': `multipart/form-data; boundary=${TGZ_BOUNDARY}`,
|
|
438
|
+
authorization: 'Bearer t',
|
|
439
|
+
},
|
|
381
440
|
payload: buildMultipart(TGZ_BOUNDARY, {
|
|
382
441
|
// Filename is intentionally different from manifest.name to prove
|
|
383
442
|
// that the server reads the manifest, not the filename.
|
|
@@ -401,7 +460,10 @@ describe('POST /api/addons/upload', () => {
|
|
|
401
460
|
const res = await fastify.inject({
|
|
402
461
|
method: 'POST',
|
|
403
462
|
url: '/api/addons/upload',
|
|
404
|
-
headers: {
|
|
463
|
+
headers: {
|
|
464
|
+
'content-type': `multipart/form-data; boundary=${TGZ_BOUNDARY}`,
|
|
465
|
+
authorization: 'Bearer t',
|
|
466
|
+
},
|
|
405
467
|
payload: buildMultipart(TGZ_BOUNDARY, {
|
|
406
468
|
file: { filename: 'bogus.tgz', content: INVALID_TARBALL },
|
|
407
469
|
}),
|
|
@@ -27,7 +27,9 @@ interface CapturedEmits {
|
|
|
27
27
|
function createFakes(opts: { preExistingNodeIds?: readonly string[] } = {}) {
|
|
28
28
|
const emits: CapturedEmits = { events: [] }
|
|
29
29
|
const fakeEventBus = {
|
|
30
|
-
emit: (event: SystemEvent) => {
|
|
30
|
+
emit: (event: SystemEvent) => {
|
|
31
|
+
emits.events.push(event)
|
|
32
|
+
},
|
|
31
33
|
} as unknown as EventBusService
|
|
32
34
|
|
|
33
35
|
const localBus = new EventEmitter()
|
|
@@ -46,7 +48,9 @@ function createFakes(opts: { preExistingNodeIds?: readonly string[] } = {}) {
|
|
|
46
48
|
},
|
|
47
49
|
},
|
|
48
50
|
},
|
|
49
|
-
setOnAgentRegistered: (_cb: (nodeId: string) => void) => {
|
|
51
|
+
setOnAgentRegistered: (_cb: (nodeId: string) => void) => {
|
|
52
|
+
/* no-op in unit tests */
|
|
53
|
+
},
|
|
50
54
|
} as unknown as MoleculerService
|
|
51
55
|
|
|
52
56
|
const fakeCapability = {} as unknown as CapabilityService
|
|
@@ -87,8 +91,13 @@ describe('AgentRegistryService — agent.online event lifecycle', () => {
|
|
|
87
91
|
localBus.emit('$node.connected', { node: { id: `agent-${i}` } })
|
|
88
92
|
}
|
|
89
93
|
expect(captured.events).toHaveLength(5)
|
|
90
|
-
expect(captured.events.map((e) => (e.data as Record<string, unknown>).agentId))
|
|
91
|
-
|
|
94
|
+
expect(captured.events.map((e) => (e.data as Record<string, unknown>).agentId)).toEqual([
|
|
95
|
+
'agent-0',
|
|
96
|
+
'agent-1',
|
|
97
|
+
'agent-2',
|
|
98
|
+
'agent-3',
|
|
99
|
+
'agent-4',
|
|
100
|
+
])
|
|
92
101
|
})
|
|
93
102
|
})
|
|
94
103
|
|
|
@@ -109,7 +118,7 @@ describe('AgentRegistryService — agent.online event lifecycle', () => {
|
|
|
109
118
|
const workerIds = fakes.emits.events
|
|
110
119
|
.filter((e) => e.category === 'worker.online')
|
|
111
120
|
.map((e) => (e.data as Record<string, unknown>).workerId)
|
|
112
|
-
expect(agentIds.
|
|
121
|
+
expect(agentIds.toSorted()).toEqual(['dev-agent-0', 'dev-agent-1'])
|
|
113
122
|
expect(workerIds).toEqual(['hub/benchmark'])
|
|
114
123
|
})
|
|
115
124
|
|
|
@@ -138,10 +147,14 @@ describe('AgentRegistryService — agent.online event lifecycle', () => {
|
|
|
138
147
|
describe('resilience: malformed registry shape', () => {
|
|
139
148
|
it('does not throw if broker.registry.getNodeList is missing', () => {
|
|
140
149
|
const emits: CapturedEmits = { events: [] }
|
|
141
|
-
const fakeEventBus = {
|
|
150
|
+
const fakeEventBus = {
|
|
151
|
+
emit: (e: SystemEvent) => emits.events.push(e),
|
|
152
|
+
} as unknown as EventBusService
|
|
142
153
|
const fakeMoleculer = {
|
|
143
154
|
broker: { nodeID: 'hub', localBus: new EventEmitter(), registry: {} },
|
|
144
|
-
setOnAgentRegistered: (_cb: (nodeId: string) => void) => {
|
|
155
|
+
setOnAgentRegistered: (_cb: (nodeId: string) => void) => {
|
|
156
|
+
/* no-op */
|
|
157
|
+
},
|
|
145
158
|
} as unknown as MoleculerService
|
|
146
159
|
const svc = new AgentRegistryService(fakeEventBus, fakeMoleculer, {} as CapabilityService)
|
|
147
160
|
expect(() => svc.onModuleInit()).not.toThrow()
|
|
@@ -150,10 +163,14 @@ describe('AgentRegistryService — agent.online event lifecycle', () => {
|
|
|
150
163
|
|
|
151
164
|
it('does not throw if broker.registry itself is missing', () => {
|
|
152
165
|
const emits: CapturedEmits = { events: [] }
|
|
153
|
-
const fakeEventBus = {
|
|
166
|
+
const fakeEventBus = {
|
|
167
|
+
emit: (e: SystemEvent) => emits.events.push(e),
|
|
168
|
+
} as unknown as EventBusService
|
|
154
169
|
const fakeMoleculer = {
|
|
155
170
|
broker: { nodeID: 'hub', localBus: new EventEmitter() },
|
|
156
|
-
setOnAgentRegistered: (_cb: (nodeId: string) => void) => {
|
|
171
|
+
setOnAgentRegistered: (_cb: (nodeId: string) => void) => {
|
|
172
|
+
/* no-op */
|
|
173
|
+
},
|
|
157
174
|
} as unknown as MoleculerService
|
|
158
175
|
const svc = new AgentRegistryService(fakeEventBus, fakeMoleculer, {} as CapabilityService)
|
|
159
176
|
expect(() => svc.onModuleInit()).not.toThrow()
|
|
@@ -61,9 +61,7 @@ describe('renderAgentStatusPage', () => {
|
|
|
61
61
|
})
|
|
62
62
|
|
|
63
63
|
it('calculates memory usage', () => {
|
|
64
|
-
const html = renderAgentStatusPage(
|
|
65
|
-
makeStatusData({ memoryTotalMB: 16384, memoryFreeMB: 4096 }),
|
|
66
|
-
)
|
|
64
|
+
const html = renderAgentStatusPage(makeStatusData({ memoryTotalMB: 16384, memoryFreeMB: 4096 }))
|
|
67
65
|
// 12288 / 16384 = 75%
|
|
68
66
|
expect(html).toContain('12288')
|
|
69
67
|
expect(html).toContain('75%')
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
buildSessionCookie,
|
|
4
|
+
clearSessionCookie,
|
|
5
|
+
SESSION_COOKIE,
|
|
6
|
+
isEmbedRedirectTarget,
|
|
7
|
+
} from '../auth/session-cookie.js'
|
|
3
8
|
|
|
4
9
|
describe('session cookie', () => {
|
|
5
10
|
it('buildSessionCookie produces an httpOnly lax cookie carrying the token', () => {
|
|
@@ -19,3 +24,25 @@ describe('session cookie', () => {
|
|
|
19
24
|
expect(c.options.maxAge).toBe(0)
|
|
20
25
|
})
|
|
21
26
|
})
|
|
27
|
+
|
|
28
|
+
describe('isEmbedRedirectTarget', () => {
|
|
29
|
+
it('accepts a stream-broker embed path (with query)', () => {
|
|
30
|
+
expect(isEmbedRedirectTarget('/addon/stream-broker/embed/?mode=grid')).toBe(true)
|
|
31
|
+
expect(isEmbedRedirectTarget('/addon/stream-broker/embed/?mode=player&devices=7,8')).toBe(true)
|
|
32
|
+
expect(isEmbedRedirectTarget('/addon/stream-broker/embed/')).toBe(true)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('rejects open redirects (absolute / protocol-relative / backslash)', () => {
|
|
36
|
+
expect(isEmbedRedirectTarget('https://evil.com/addon/stream-broker/embed/')).toBe(false)
|
|
37
|
+
expect(isEmbedRedirectTarget('//evil.com')).toBe(false)
|
|
38
|
+
expect(isEmbedRedirectTarget('/\\evil.com')).toBe(false)
|
|
39
|
+
expect(isEmbedRedirectTarget('http://x/addon/stream-broker/embed/')).toBe(false)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('rejects non-embed paths and path traversal', () => {
|
|
43
|
+
expect(isEmbedRedirectTarget('/addon/other/embed/')).toBe(false)
|
|
44
|
+
expect(isEmbedRedirectTarget('/etc/passwd')).toBe(false)
|
|
45
|
+
expect(isEmbedRedirectTarget('/addon/stream-broker/embed/../../secret')).toBe(false)
|
|
46
|
+
expect(isEmbedRedirectTarget('')).toBe(false)
|
|
47
|
+
})
|
|
48
|
+
})
|