@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,109 @@
|
|
|
1
|
+
|
|
2
|
+
/**
|
|
3
|
+
* Embedded dependencies E2E — verify ffmpeg/python resolution and download.
|
|
4
|
+
*
|
|
5
|
+
* These tests check the resolution logic (PATH detection, embedded binary check)
|
|
6
|
+
* without actually downloading large binaries (unless CAMSTACK_TEST_DOWNLOAD=true).
|
|
7
|
+
*/
|
|
8
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
|
9
|
+
import * as fs from 'node:fs'
|
|
10
|
+
import * as path from 'node:path'
|
|
11
|
+
import * as os from 'node:os'
|
|
12
|
+
// Import directly from source submodules because vitest+swc doesn't resolve
|
|
13
|
+
// `export * from './deps/index.js'` barrel re-exports in the @camstack/core index.
|
|
14
|
+
import { findInPath, getPlatformInfo, buildBinaryPath } from '../../../../packages/core/src/deps/binary-downloader'
|
|
15
|
+
import { getFfmpegDownloadUrl, ensureFfmpeg } from '../../../../packages/core/src/deps/ffmpeg-downloader'
|
|
16
|
+
import { getPythonDownloadUrl, ensurePython } from '../../../../packages/core/src/deps/python-downloader'
|
|
17
|
+
import type { IScopedLogger } from '@camstack/types'
|
|
18
|
+
|
|
19
|
+
function createMockLogger(): IScopedLogger {
|
|
20
|
+
const logger: IScopedLogger = {
|
|
21
|
+
info: () => {},
|
|
22
|
+
warn: () => {},
|
|
23
|
+
error: () => {},
|
|
24
|
+
debug: () => {},
|
|
25
|
+
child: () => createMockLogger(),
|
|
26
|
+
}
|
|
27
|
+
return logger
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
const mockLogger = createMockLogger()
|
|
32
|
+
|
|
33
|
+
describe('Embedded Dependencies E2E', () => {
|
|
34
|
+
let tmpDir: string
|
|
35
|
+
|
|
36
|
+
beforeAll(() => {
|
|
37
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'camstack-deps-e2e-'))
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
afterAll(() => {
|
|
41
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('Platform detection', () => {
|
|
45
|
+
it('returns valid platform and arch', () => {
|
|
46
|
+
const info = getPlatformInfo()
|
|
47
|
+
expect(['darwin', 'linux', 'win32']).toContain(info.platform)
|
|
48
|
+
expect(['x64', 'arm64', 'arm', 'ia32']).toContain(info.arch)
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('Binary path resolution', () => {
|
|
53
|
+
it('builds correct path for deps directory', () => {
|
|
54
|
+
const p = buildBinaryPath('/data', 'ffmpeg')
|
|
55
|
+
expect(p).toBe('/data/deps/ffmpeg')
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('FFmpeg', () => {
|
|
60
|
+
it('generates valid download URL for current platform', () => {
|
|
61
|
+
const url = getFfmpegDownloadUrl(process.platform, process.arch)
|
|
62
|
+
expect(url).toMatch(/^https:\/\//)
|
|
63
|
+
expect(url.length).toBeGreaterThan(20)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('finds ffmpeg in PATH or reports not found', () => {
|
|
67
|
+
const result = findInPath('ffmpeg')
|
|
68
|
+
// Either found (returns the name) or null — both valid
|
|
69
|
+
expect(result === null || typeof result === 'string').toBe(true)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
// Only run download test if explicitly enabled (downloads ~80MB)
|
|
73
|
+
const downloadTest = process.env.CAMSTACK_TEST_DOWNLOAD === 'true' ? it : it.skip
|
|
74
|
+
downloadTest('downloads ffmpeg binary', async () => {
|
|
75
|
+
const ffmpegPath = await ensureFfmpeg(tmpDir, mockLogger)
|
|
76
|
+
expect(ffmpegPath).toBeTruthy()
|
|
77
|
+
expect(fs.existsSync(ffmpegPath)).toBe(true)
|
|
78
|
+
}, 120000)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe('Python', () => {
|
|
82
|
+
it('generates valid download URL for current platform', () => {
|
|
83
|
+
const url = getPythonDownloadUrl(process.platform, process.arch)
|
|
84
|
+
expect(url).toContain('python-headless')
|
|
85
|
+
if (process.platform === 'darwin') {
|
|
86
|
+
expect(url).toContain('universal2')
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('finds python in PATH or reports not found', () => {
|
|
91
|
+
const result = findInPath('python3') ?? findInPath('python')
|
|
92
|
+
// Either found or null — both valid
|
|
93
|
+
expect(result === null || typeof result === 'string').toBe(true)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
// Only run download test if explicitly enabled (downloads ~25MB)
|
|
97
|
+
const downloadTest = process.env.CAMSTACK_TEST_DOWNLOAD === 'true' ? it : it.skip
|
|
98
|
+
downloadTest('downloads portable Python', async () => {
|
|
99
|
+
const pythonPath = await ensurePython(tmpDir, mockLogger)
|
|
100
|
+
expect(pythonPath).toBeTruthy()
|
|
101
|
+
expect(fs.existsSync(pythonPath!)).toBe(true)
|
|
102
|
+
|
|
103
|
+
// Verify it actually runs
|
|
104
|
+
const { execFileSync } = await import('node:child_process')
|
|
105
|
+
const version = execFileSync(pythonPath!, ['--version'], { encoding: 'utf8' }).trim()
|
|
106
|
+
expect(version).toMatch(/Python 3\.12/)
|
|
107
|
+
}, 120000)
|
|
108
|
+
})
|
|
109
|
+
})
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-unsafe-assignment -- test mock typing */
|
|
2
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
3
|
+
import { createEventBusProxyRouter } from '../api/core/event-bus-proxy.router.js'
|
|
4
|
+
import { makeCtx } from './cap-routers/harness.js'
|
|
5
|
+
import type { IEventBus } from '@camstack/types'
|
|
6
|
+
|
|
7
|
+
function createMockEventBus() {
|
|
8
|
+
return {
|
|
9
|
+
emit: vi.fn(),
|
|
10
|
+
} as unknown as IEventBus & { emit: ReturnType<typeof vi.fn> }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('event-bus-proxy router', () => {
|
|
14
|
+
it('emit calls eventBus.emit with the correct shape', async () => {
|
|
15
|
+
const bus = createMockEventBus()
|
|
16
|
+
const router = createEventBusProxyRouter(bus)
|
|
17
|
+
const caller = router.createCaller(makeCtx('admin'))
|
|
18
|
+
|
|
19
|
+
await caller.emit({
|
|
20
|
+
id: 'evt-1',
|
|
21
|
+
timestamp: '2026-01-15T10:00:00.000Z',
|
|
22
|
+
source: { type: 'addon', id: 'my-addon' },
|
|
23
|
+
category: 'motion',
|
|
24
|
+
data: { zone: 'front' },
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
expect(bus.emit).toHaveBeenCalledOnce()
|
|
28
|
+
expect(bus.emit).toHaveBeenCalledWith({
|
|
29
|
+
id: 'evt-1',
|
|
30
|
+
timestamp: expect.any(Date),
|
|
31
|
+
source: { type: 'addon', id: 'my-addon' },
|
|
32
|
+
category: 'motion',
|
|
33
|
+
data: { zone: 'front' },
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('emit returns { ok: true }', async () => {
|
|
38
|
+
const bus = createMockEventBus()
|
|
39
|
+
const router = createEventBusProxyRouter(bus)
|
|
40
|
+
const caller = router.createCaller(makeCtx('admin'))
|
|
41
|
+
|
|
42
|
+
const result = await caller.emit({
|
|
43
|
+
id: 'evt-2',
|
|
44
|
+
timestamp: '2026-01-15T10:00:00.000Z',
|
|
45
|
+
source: { type: 'device', id: 'cam-1' },
|
|
46
|
+
category: 'alert',
|
|
47
|
+
data: {},
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
expect(result).toEqual({ ok: true })
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('timestamp string is converted to a Date object', async () => {
|
|
54
|
+
const bus = createMockEventBus()
|
|
55
|
+
const router = createEventBusProxyRouter(bus)
|
|
56
|
+
const caller = router.createCaller(makeCtx('admin'))
|
|
57
|
+
|
|
58
|
+
const isoString = '2026-06-20T14:30:00.000Z'
|
|
59
|
+
|
|
60
|
+
await caller.emit({
|
|
61
|
+
id: 'evt-3',
|
|
62
|
+
timestamp: isoString,
|
|
63
|
+
source: { type: 'addon', id: 'test' },
|
|
64
|
+
category: 'info',
|
|
65
|
+
data: {},
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
const emittedEvent = bus.emit.mock.calls[0][0] as { timestamp: unknown }
|
|
69
|
+
expect(emittedEvent.timestamp).toBeInstanceOf(Date)
|
|
70
|
+
expect((emittedEvent.timestamp as Date).toISOString()).toBe(isoString)
|
|
71
|
+
})
|
|
72
|
+
})
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// server/backend/src/__tests__/fixtures/mock-analysis-addon-a.ts
|
|
2
|
+
import type { ICamstackAddon, AddonDeclaration, AddonContext } from '@camstack/types'
|
|
3
|
+
|
|
4
|
+
export class MockAnalysisAddonA implements ICamstackAddon {
|
|
5
|
+
readonly manifest: AddonDeclaration = {
|
|
6
|
+
id: 'mock-analysis-a',
|
|
7
|
+
name: 'Mock Analysis A',
|
|
8
|
+
version: '1.0.0',
|
|
9
|
+
capabilities: ['object-detector'],
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
private initialized = false
|
|
13
|
+
readonly provider = { id: 'analysis-a', processFrame: async () => [] }
|
|
14
|
+
|
|
15
|
+
async initialize(_context: AddonContext) {
|
|
16
|
+
this.initialized = true
|
|
17
|
+
return [{ capability: 'object-detector', provider: this.provider }]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async shutdown(): Promise<void> {
|
|
21
|
+
this.initialized = false
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
isInitialized(): boolean {
|
|
25
|
+
return this.initialized
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
getConfigSchema() {
|
|
29
|
+
return { sections: [] }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
getConfig() {
|
|
33
|
+
return {}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async onConfigChange() {}
|
|
37
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// server/backend/src/__tests__/fixtures/mock-analysis-addon-b.ts
|
|
2
|
+
import type { ICamstackAddon, AddonDeclaration, AddonContext } from '@camstack/types'
|
|
3
|
+
|
|
4
|
+
export class MockAnalysisAddonB implements ICamstackAddon {
|
|
5
|
+
readonly manifest: AddonDeclaration = {
|
|
6
|
+
id: 'mock-analysis-b',
|
|
7
|
+
name: 'Mock Analysis B',
|
|
8
|
+
version: '1.0.0',
|
|
9
|
+
capabilities: ['object-detector'],
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
private initialized = false
|
|
13
|
+
readonly provider = { id: 'analysis-b', processFrame: async () => [] }
|
|
14
|
+
|
|
15
|
+
async initialize(_context: AddonContext) {
|
|
16
|
+
this.initialized = true
|
|
17
|
+
return [{ capability: 'object-detector', provider: this.provider }]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async shutdown(): Promise<void> {
|
|
21
|
+
this.initialized = false
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
isInitialized(): boolean {
|
|
25
|
+
return this.initialized
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
getConfigSchema() {
|
|
29
|
+
return { sections: [] }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
getConfig() {
|
|
33
|
+
return {}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async onConfigChange() {}
|
|
37
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// server/backend/src/__tests__/fixtures/mock-log-addon.ts
|
|
2
|
+
import type { ICamstackAddon, AddonDeclaration, AddonContext } from '@camstack/types'
|
|
3
|
+
|
|
4
|
+
export class MockLogAddon implements ICamstackAddon {
|
|
5
|
+
readonly manifest: AddonDeclaration = {
|
|
6
|
+
id: 'mock-log-addon',
|
|
7
|
+
name: 'Mock Log Destination',
|
|
8
|
+
version: '1.0.0',
|
|
9
|
+
capabilities: ['log-destination'],
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
private initialized = false
|
|
13
|
+
readonly provider = { id: 'mock-log', write: async () => {} }
|
|
14
|
+
|
|
15
|
+
async initialize(_context: AddonContext) {
|
|
16
|
+
this.initialized = true
|
|
17
|
+
return [{ capability: 'log-destination', provider: this.provider }]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async shutdown(): Promise<void> {
|
|
21
|
+
this.initialized = false
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
isInitialized(): boolean {
|
|
25
|
+
return this.initialized
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
getConfigSchema() {
|
|
29
|
+
return { sections: [] }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
getConfig() {
|
|
33
|
+
return {}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async onConfigChange() {}
|
|
37
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// server/backend/src/__tests__/fixtures/mock-storage-addon.ts
|
|
2
|
+
import type { ICamstackAddon, AddonDeclaration, AddonContext } from '@camstack/types'
|
|
3
|
+
|
|
4
|
+
export class MockStorageAddon implements ICamstackAddon {
|
|
5
|
+
readonly manifest: AddonDeclaration = {
|
|
6
|
+
id: 'mock-storage',
|
|
7
|
+
name: 'Mock Storage',
|
|
8
|
+
version: '1.0.0',
|
|
9
|
+
capabilities: ['storage', 'settings-store'],
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
private initialized = false
|
|
13
|
+
readonly provider = { id: 'mock-storage', type: 'mock' }
|
|
14
|
+
|
|
15
|
+
async initialize(_context: AddonContext) {
|
|
16
|
+
this.initialized = true
|
|
17
|
+
return [
|
|
18
|
+
{ capability: 'storage', provider: this.provider },
|
|
19
|
+
{ capability: 'settings-store', provider: this.provider },
|
|
20
|
+
]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async shutdown(): Promise<void> {
|
|
24
|
+
this.initialized = false
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
isInitialized(): boolean {
|
|
28
|
+
return this.initialized
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
getConfigSchema() {
|
|
32
|
+
return { sections: [] }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
getConfig() {
|
|
36
|
+
return {}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async onConfigChange() {}
|
|
40
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framework package allow-list coherence.
|
|
3
|
+
*
|
|
4
|
+
* Spec: docs/superpowers/specs/2026-05-14-framework-live-update-design.md
|
|
5
|
+
*
|
|
6
|
+
* Invariants:
|
|
7
|
+
* - Every entry in `FRAMEWORK_PACKAGE_ALLOWLIST` resolves to a workspace
|
|
8
|
+
* package whose `package.json` declares `camstack.system: true`.
|
|
9
|
+
* - Every workspace package with `camstack.system: true` appears in the
|
|
10
|
+
* allow-list — drift in either direction breaks live-update.
|
|
11
|
+
* - `isFrameworkPackage` accepts framework names and rejects everything
|
|
12
|
+
* else (sanity check on the gate the cap method uses).
|
|
13
|
+
*/
|
|
14
|
+
import { describe, it, expect } from 'vitest'
|
|
15
|
+
import * as fs from 'node:fs'
|
|
16
|
+
import * as path from 'node:path'
|
|
17
|
+
import {
|
|
18
|
+
FRAMEWORK_PACKAGE_ALLOWLIST,
|
|
19
|
+
isFrameworkPackage,
|
|
20
|
+
} from '../core/addon/addon-package.service.js'
|
|
21
|
+
|
|
22
|
+
function repoRoot(): string {
|
|
23
|
+
return path.resolve(__dirname, '..', '..', '..', '..')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface PackageJsonView {
|
|
27
|
+
readonly name: string
|
|
28
|
+
readonly system: boolean
|
|
29
|
+
readonly path: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readPackageJson(pkgJsonPath: string): PackageJsonView | null {
|
|
33
|
+
try {
|
|
34
|
+
const raw: unknown = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'))
|
|
35
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null
|
|
36
|
+
const obj = raw as Record<string, unknown>
|
|
37
|
+
const name = obj['name']
|
|
38
|
+
if (typeof name !== 'string') return null
|
|
39
|
+
const camstack = obj['camstack']
|
|
40
|
+
const isSystem = camstack !== null
|
|
41
|
+
&& typeof camstack === 'object'
|
|
42
|
+
&& !Array.isArray(camstack)
|
|
43
|
+
&& (camstack as Record<string, unknown>)['system'] === true
|
|
44
|
+
return { name, system: isSystem, path: pkgJsonPath }
|
|
45
|
+
} catch {
|
|
46
|
+
return null
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function discoverWorkspacePackages(): readonly PackageJsonView[] {
|
|
51
|
+
const packagesDir = path.join(repoRoot(), 'packages')
|
|
52
|
+
if (!fs.existsSync(packagesDir)) return []
|
|
53
|
+
const results: PackageJsonView[] = []
|
|
54
|
+
for (const entry of fs.readdirSync(packagesDir, { withFileTypes: true })) {
|
|
55
|
+
if (!entry.isDirectory()) continue
|
|
56
|
+
const pkgJson = path.join(packagesDir, entry.name, 'package.json')
|
|
57
|
+
if (!fs.existsSync(pkgJson)) continue
|
|
58
|
+
const view = readPackageJson(pkgJson)
|
|
59
|
+
if (view !== null) results.push(view)
|
|
60
|
+
}
|
|
61
|
+
return results
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe('framework package allow-list — manifest parity', () => {
|
|
65
|
+
it('every allow-listed package exists in `packages/` with camstack.system: true', () => {
|
|
66
|
+
const pkgs = discoverWorkspacePackages()
|
|
67
|
+
for (const name of FRAMEWORK_PACKAGE_ALLOWLIST) {
|
|
68
|
+
const match = pkgs.find((p) => p.name === name)
|
|
69
|
+
expect(match, `expected ${name} in packages/`).toBeDefined()
|
|
70
|
+
expect(match?.system, `${name} must declare camstack.system: true`).toBe(true)
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('every workspace package with camstack.system: true is in the allow-list', () => {
|
|
75
|
+
const systemPkgs = discoverWorkspacePackages().filter((p) => p.system)
|
|
76
|
+
const allowSet = new Set(FRAMEWORK_PACKAGE_ALLOWLIST)
|
|
77
|
+
const orphans = systemPkgs.filter((p) => !allowSet.has(p.name))
|
|
78
|
+
expect(orphans.map((p) => p.name)).toEqual([])
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe('isFrameworkPackage', () => {
|
|
83
|
+
it('returns true for every allow-listed package', () => {
|
|
84
|
+
for (const name of FRAMEWORK_PACKAGE_ALLOWLIST) {
|
|
85
|
+
expect(isFrameworkPackage(name)).toBe(true)
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('returns false for addons and unknown packages', () => {
|
|
90
|
+
expect(isFrameworkPackage('@camstack/addon-stream-broker')).toBe(false)
|
|
91
|
+
expect(isFrameworkPackage('left-pad')).toBe(false)
|
|
92
|
+
expect(isFrameworkPackage('')).toBe(false)
|
|
93
|
+
expect(isFrameworkPackage('@camstack')).toBe(false)
|
|
94
|
+
})
|
|
95
|
+
})
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTPS E2E tests — verify self-signed cert generation, HTTPS serving, and WSS agent connection.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
|
5
|
+
import * as fs from 'node:fs'
|
|
6
|
+
import * as path from 'node:path'
|
|
7
|
+
import * as os from 'node:os'
|
|
8
|
+
import * as https from 'node:https'
|
|
9
|
+
import { X509Certificate } from 'node:crypto'
|
|
10
|
+
// Import directly from source submodule because vitest+swc doesn't resolve
|
|
11
|
+
// `export * from './tls/index.js'` barrel re-exports in the @camstack/core index.
|
|
12
|
+
import { ensureTlsCert, loadTlsCert } from '../../../../packages/core/src/tls/cert-manager'
|
|
13
|
+
|
|
14
|
+
describe('HTTPS E2E', () => {
|
|
15
|
+
let tmpDir: string
|
|
16
|
+
|
|
17
|
+
beforeAll(() => {
|
|
18
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'camstack-https-e2e-'))
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
afterAll(() => {
|
|
22
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('generates a valid self-signed cert on first call', async () => {
|
|
26
|
+
const result = await ensureTlsCert(tmpDir)
|
|
27
|
+
expect(result.generated).toBe(true)
|
|
28
|
+
|
|
29
|
+
// Verify cert file is valid PEM
|
|
30
|
+
const certPem = fs.readFileSync(result.certPath, 'utf-8')
|
|
31
|
+
expect(certPem).toContain('-----BEGIN CERTIFICATE-----')
|
|
32
|
+
|
|
33
|
+
// Parse and validate
|
|
34
|
+
const x509 = new X509Certificate(certPem)
|
|
35
|
+
expect(x509.subject).toContain('CN=camstack.local')
|
|
36
|
+
|
|
37
|
+
// Check SAN includes localhost
|
|
38
|
+
const san = x509.subjectAltName ?? ''
|
|
39
|
+
expect(san).toContain('DNS:localhost')
|
|
40
|
+
expect(san).toContain('IP Address:127.0.0.1')
|
|
41
|
+
|
|
42
|
+
// Check validity (at least 1 year)
|
|
43
|
+
const validTo = new Date(x509.validTo)
|
|
44
|
+
const oneYear = new Date()
|
|
45
|
+
oneYear.setFullYear(oneYear.getFullYear() + 1)
|
|
46
|
+
expect(validTo.getTime()).toBeGreaterThan(oneYear.getTime() - 86400000)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('reuses existing cert on subsequent calls', async () => {
|
|
50
|
+
const first = await ensureTlsCert(tmpDir)
|
|
51
|
+
const second = await ensureTlsCert(tmpDir)
|
|
52
|
+
expect(second.generated).toBe(false)
|
|
53
|
+
|
|
54
|
+
const cert1 = fs.readFileSync(first.certPath, 'utf-8')
|
|
55
|
+
const cert2 = fs.readFileSync(second.certPath, 'utf-8')
|
|
56
|
+
expect(cert1).toBe(cert2)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('serves HTTPS with the generated cert', async () => {
|
|
60
|
+
const { certPath, keyPath } = await ensureTlsCert(tmpDir)
|
|
61
|
+
const { cert, key } = loadTlsCert(certPath, keyPath)
|
|
62
|
+
|
|
63
|
+
const server = https.createServer({ cert, key }, (_req, res) => {
|
|
64
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' })
|
|
65
|
+
res.end('camstack-ok')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
const port = 10000 + Math.floor(Math.random() * 50000)
|
|
69
|
+
await new Promise<void>((resolve) => server.listen(port, '127.0.0.1', resolve))
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const body = await new Promise<string>((resolve, reject) => {
|
|
73
|
+
const req = https.request(
|
|
74
|
+
{ hostname: '127.0.0.1', port, path: '/', method: 'GET', rejectUnauthorized: false },
|
|
75
|
+
(res) => {
|
|
76
|
+
let data = ''
|
|
77
|
+
res.on('data', (c) => { data += c })
|
|
78
|
+
res.on('end', () => resolve(data))
|
|
79
|
+
},
|
|
80
|
+
)
|
|
81
|
+
req.on('error', reject)
|
|
82
|
+
req.end()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
expect(body).toBe('camstack-ok')
|
|
86
|
+
} finally {
|
|
87
|
+
server.close()
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('TLS disabled falls back to HTTP', async () => {
|
|
92
|
+
// When tls.enabled = false, server should work over plain HTTP
|
|
93
|
+
// This is a config-level test, verified by checking that FastifyAdapter
|
|
94
|
+
// receives no https options when tls is disabled
|
|
95
|
+
const http = await import('node:http')
|
|
96
|
+
const server = http.createServer((_req, res) => {
|
|
97
|
+
res.writeHead(200)
|
|
98
|
+
res.end('http-ok')
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
const port = 10000 + Math.floor(Math.random() * 50000)
|
|
102
|
+
await new Promise<void>((resolve) => server.listen(port, '127.0.0.1', resolve))
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const body = await new Promise<string>((resolve, reject) => {
|
|
106
|
+
http.get(`http://127.0.0.1:${port}/`, (res) => {
|
|
107
|
+
let data = ''
|
|
108
|
+
res.on('data', (c) => { data += c })
|
|
109
|
+
res.on('end', () => resolve(data))
|
|
110
|
+
}).on('error', reject)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
expect(body).toBe('http-ok')
|
|
114
|
+
} finally {
|
|
115
|
+
server.close()
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
})
|