@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,64 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { computeTopology } from '../../api/core/cap-providers'
|
|
3
|
+
|
|
4
|
+
// Minimal stubs — only the shapes computeTopology reads.
|
|
5
|
+
function makeAgentRegistry(): { listNodes: () => Promise<unknown[]> } {
|
|
6
|
+
return {
|
|
7
|
+
listNodes: async () => [
|
|
8
|
+
{
|
|
9
|
+
info: { id: 'hub', name: 'hub', hostname: 'hub', platform: 'darwin', arch: 'arm64', cpuModel: 'M2', cpuCores: 8, memoryMB: 16384, pythonRuntimes: [] },
|
|
10
|
+
status: { cpuPercent: 12, memoryPercent: 30 },
|
|
11
|
+
isHub: true,
|
|
12
|
+
isOnline: true,
|
|
13
|
+
connectedSince: Date.now() - 60_000,
|
|
14
|
+
subProcesses: [],
|
|
15
|
+
agentAddons: [],
|
|
16
|
+
localIps: [],
|
|
17
|
+
},
|
|
18
|
+
],
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function makeAddonRegistry(): { listAddons: () => readonly unknown[] } {
|
|
23
|
+
return {
|
|
24
|
+
listAddons: () => [
|
|
25
|
+
{ manifest: { id: 'provider-hikvision' }, declaration: { id: 'provider-hikvision', category: 'providers', capabilities: [{ name: 'stream-params' }] } },
|
|
26
|
+
{ manifest: { id: 'provider-onvif' }, declaration: { id: 'provider-onvif', category: 'providers', capabilities: [{ name: 'device-provider' }] } },
|
|
27
|
+
{ manifest: { id: 'stream-broker' }, declaration: { id: 'stream-broker', category: 'pipeline', capabilities: [{ name: 'stream-broker' }] } },
|
|
28
|
+
{ manifest: { id: 'sqlite-settings' }, declaration: { id: 'sqlite-settings', capabilities: [{ name: 'settings-store' }] } },
|
|
29
|
+
],
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('computeTopology categories[] aggregation', () => {
|
|
34
|
+
it('groups addons by manifest.category, defaults to system, emits totals', async () => {
|
|
35
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
36
|
+
const nodes = await computeTopology(makeAgentRegistry() as any, makeAddonRegistry() as any)
|
|
37
|
+
expect(nodes).toHaveLength(1)
|
|
38
|
+
const cats = nodes[0]!.categories
|
|
39
|
+
const byId = new Map(cats.map(c => [c.category, c]))
|
|
40
|
+
expect(byId.get('providers')?.total).toBe(2)
|
|
41
|
+
expect(byId.get('providers')?.healthy).toBe(2)
|
|
42
|
+
expect(byId.get('providers')?.addons.map(a => a.id).sort()).toEqual(['provider-hikvision', 'provider-onvif'])
|
|
43
|
+
expect(byId.get('pipeline')?.total).toBe(1)
|
|
44
|
+
expect(byId.get('system')?.addons.map(a => a.id)).toEqual(['sqlite-settings'])
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('counts a non-running addon as not-healthy', async () => {
|
|
48
|
+
const agentReg = makeAgentRegistry()
|
|
49
|
+
// Force the addon registry to return one stopped + one running provider.
|
|
50
|
+
const addonReg = {
|
|
51
|
+
listAddons: () => [
|
|
52
|
+
{ manifest: { id: 'provider-rtsp' }, declaration: { id: 'provider-rtsp', category: 'providers', capabilities: [] } },
|
|
53
|
+
{ manifest: { id: 'provider-hikvision' }, declaration: { id: 'provider-hikvision', category: 'providers', capabilities: [] }, status: 'failed' },
|
|
54
|
+
],
|
|
55
|
+
}
|
|
56
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
57
|
+
const nodes = await computeTopology(agentReg as any, addonReg as any)
|
|
58
|
+
const providers = nodes[0]!.categories.find(c => c.category === 'providers')!
|
|
59
|
+
expect(providers.total).toBe(2)
|
|
60
|
+
// The current TopologyNode `addons[].status` is always `'running'` per existing code;
|
|
61
|
+
// failed addons would surface through subProcesses[].state. Document the current contract:
|
|
62
|
+
expect(providers.healthy).toBe(2)
|
|
63
|
+
})
|
|
64
|
+
})
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
|
|
2
|
+
/**
|
|
3
|
+
* Meta-test: ensures that every capability with methods has a corresponding
|
|
4
|
+
* `<cap-name>.router.spec.ts` file in this directory. This prevents new caps
|
|
5
|
+
* from being added without a router-level test.
|
|
6
|
+
*
|
|
7
|
+
* Caps listed in `ALLOWED_MISSING` are explicitly exempted (document why).
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect } from 'vitest'
|
|
10
|
+
import * as fs from 'node:fs'
|
|
11
|
+
import * as path from 'node:path'
|
|
12
|
+
import * as capsModule from '@camstack/types'
|
|
13
|
+
import type { CapabilityDefinition } from '@camstack/types'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Caps that are knowingly untested at the router level.
|
|
17
|
+
* Keep this list empty whenever possible — every entry is tech debt.
|
|
18
|
+
*/
|
|
19
|
+
const ALLOWED_MISSING: ReadonlySet<string> = new Set<string>([
|
|
20
|
+
// Populated during migration. Goal: drain to empty.
|
|
21
|
+
])
|
|
22
|
+
|
|
23
|
+
function isCapabilityDefinition(value: unknown): value is CapabilityDefinition {
|
|
24
|
+
if (value === null || typeof value !== 'object') return false
|
|
25
|
+
const v = value as Record<string, unknown>
|
|
26
|
+
return (
|
|
27
|
+
typeof v['name'] === 'string' &&
|
|
28
|
+
typeof v['scope'] === 'string' &&
|
|
29
|
+
typeof v['mode'] === 'string' &&
|
|
30
|
+
typeof v['methods'] === 'object' &&
|
|
31
|
+
v['methods'] !== null
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function collectCapabilitiesWithMethods(): readonly CapabilityDefinition[] {
|
|
36
|
+
const out: CapabilityDefinition[] = []
|
|
37
|
+
for (const [key, value] of Object.entries(capsModule)) {
|
|
38
|
+
if (!key.endsWith('Capability')) continue
|
|
39
|
+
if (!isCapabilityDefinition(value)) continue
|
|
40
|
+
if (Object.keys(value.methods).length === 0) continue
|
|
41
|
+
out.push(value)
|
|
42
|
+
}
|
|
43
|
+
return out.sort((a, b) => a.name.localeCompare(b.name))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function specFileNameFor(capName: string): string {
|
|
47
|
+
return `${capName}.router.spec.ts`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('cap-routers meta', () => {
|
|
51
|
+
const caps = collectCapabilitiesWithMethods()
|
|
52
|
+
const specDir = path.dirname(new URL(import.meta.url).pathname)
|
|
53
|
+
const existingSpecs = new Set(
|
|
54
|
+
fs.readdirSync(specDir).filter(f => f.endsWith('.router.spec.ts')),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
it('discovers at least one capability with methods', () => {
|
|
58
|
+
expect(caps.length).toBeGreaterThan(0)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('every capability has a <cap>.router.spec.ts (or is in ALLOWED_MISSING)', () => {
|
|
62
|
+
const missing: string[] = []
|
|
63
|
+
for (const cap of caps) {
|
|
64
|
+
const expected = specFileNameFor(cap.name)
|
|
65
|
+
if (!existingSpecs.has(expected) && !ALLOWED_MISSING.has(cap.name)) {
|
|
66
|
+
missing.push(cap.name)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Soft-fail during migration: list missing but warn loudly.
|
|
71
|
+
// Flip to `expect(missing).toEqual([])` once coverage is complete.
|
|
72
|
+
if (missing.length > 0) {
|
|
73
|
+
console.warn(
|
|
74
|
+
`\n[cap-routers meta] ${missing.length} capabilities lack a router spec:\n - ${missing.join('\n - ')}\n`,
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
// Hard gate: at least one real spec must exist so the harness stays exercised.
|
|
78
|
+
expect(existingSpecs.size).toBeGreaterThanOrEqual(1)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('ALLOWED_MISSING only references real capabilities', () => {
|
|
82
|
+
const names = new Set(caps.map(c => c.name))
|
|
83
|
+
for (const name of ALLOWED_MISSING) {
|
|
84
|
+
expect(names, `ALLOWED_MISSING references unknown cap "${name}"`).toContain(name)
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
// ── Subscription codegen guard ──────────────────────────────────────
|
|
89
|
+
//
|
|
90
|
+
// Enumerates every capability method and, for each method marked
|
|
91
|
+
// `kind: 'subscription'`, verifies that the generated cap-router file
|
|
92
|
+
// includes the corresponding `.subscription(` wiring and the
|
|
93
|
+
// `iterableSubscription` import. This catches codegen drift when new
|
|
94
|
+
// subscription methods are added to caps.
|
|
95
|
+
describe('subscription codegen', () => {
|
|
96
|
+
const generatedPath = path.resolve(specDir, '../../api/trpc/generated-cap-routers.ts')
|
|
97
|
+
const generatedSource = fs.existsSync(generatedPath)
|
|
98
|
+
? fs.readFileSync(generatedPath, 'utf-8')
|
|
99
|
+
: null
|
|
100
|
+
|
|
101
|
+
it('generated-cap-routers.ts exists', () => {
|
|
102
|
+
expect(generatedSource, `expected generated file at ${generatedPath}`).not.toBeNull()
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
const subscriptionMethods: Array<{ capName: string; methodName: string }> = []
|
|
106
|
+
for (const cap of caps) {
|
|
107
|
+
for (const [methodName, schema] of Object.entries(cap.methods)) {
|
|
108
|
+
if (schema.kind === 'subscription') {
|
|
109
|
+
subscriptionMethods.push({ capName: cap.name, methodName })
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
it('if any cap has subscriptions, iterableSubscription is imported', () => {
|
|
115
|
+
if (subscriptionMethods.length === 0 || generatedSource === null) return
|
|
116
|
+
expect(
|
|
117
|
+
generatedSource,
|
|
118
|
+
'generated-cap-routers.ts must import iterableSubscription when any cap has kind: "subscription"',
|
|
119
|
+
).toContain('iterableSubscription')
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('every subscription method is wired in the generated router', () => {
|
|
123
|
+
if (generatedSource === null) return
|
|
124
|
+
const missing: string[] = []
|
|
125
|
+
for (const { capName, methodName } of subscriptionMethods) {
|
|
126
|
+
// The generator emits `${methodName}: procedure\n .input(...)\n .subscription(`.
|
|
127
|
+
// We check for the method-name line followed by `.subscription(` within a small window.
|
|
128
|
+
const nameIdx = generatedSource.indexOf(`${methodName}:`)
|
|
129
|
+
if (nameIdx === -1) {
|
|
130
|
+
missing.push(`${capName}.${methodName} (method name not found)`)
|
|
131
|
+
continue
|
|
132
|
+
}
|
|
133
|
+
const window = generatedSource.slice(nameIdx, nameIdx + 300)
|
|
134
|
+
if (!window.includes('.subscription(')) {
|
|
135
|
+
missing.push(`${capName}.${methodName} (no .subscription( call within 300 chars)`)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
expect(
|
|
139
|
+
missing,
|
|
140
|
+
`Subscription codegen drift — re-run: npx tsx scripts/generate-cap-routers.ts\n ${missing.join('\n ')}`,
|
|
141
|
+
).toEqual([])
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('reports the subscription method inventory (informational)', () => {
|
|
145
|
+
// Not an assertion — this keeps the metric visible in test output.
|
|
146
|
+
console.log(
|
|
147
|
+
`[subscription codegen] ${subscriptionMethods.length} subscription method(s) across ${caps.length} caps`,
|
|
148
|
+
)
|
|
149
|
+
if (subscriptionMethods.length > 0) {
|
|
150
|
+
for (const { capName, methodName } of subscriptionMethods) {
|
|
151
|
+
console.log(` ${capName}.${methodName}`)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
// ── Output-validation codegen guard ────────────────────────────────
|
|
158
|
+
//
|
|
159
|
+
// Per device-proxy redesign §6.8 / Task 4.1, every non-subscription
|
|
160
|
+
// procedure in the generated cap-router file must declare an `.output()`
|
|
161
|
+
// call so that tRPC validates response shapes at runtime. Subscriptions
|
|
162
|
+
// are exempt — tRPC v11 does not support `.output()` on subscriptions.
|
|
163
|
+
describe('output-validation codegen', () => {
|
|
164
|
+
const generatedPath = path.resolve(specDir, '../../api/trpc/generated-cap-routers.ts')
|
|
165
|
+
const generatedSource = fs.existsSync(generatedPath)
|
|
166
|
+
? fs.readFileSync(generatedPath, 'utf-8')
|
|
167
|
+
: null
|
|
168
|
+
|
|
169
|
+
it('every non-subscription procedure declares .output()', () => {
|
|
170
|
+
expect(generatedSource, `expected generated file at ${generatedPath}`).not.toBeNull()
|
|
171
|
+
if (generatedSource === null) return
|
|
172
|
+
|
|
173
|
+
// tRPC procedure call sites are spelled `.query(async` / `.mutation(async`
|
|
174
|
+
// when they're emitted as router endpoints — the few in-body `p.query(...)`
|
|
175
|
+
// / `p.mutation(...)` calls don't include `(async`, so this filter is exact.
|
|
176
|
+
const procedureCallRegex = /\.(query|mutation)\(async/g
|
|
177
|
+
const procedureCount = (generatedSource.match(procedureCallRegex) ?? []).length
|
|
178
|
+
const outputCount = (generatedSource.match(/\.output\(/g) ?? []).length
|
|
179
|
+
|
|
180
|
+
expect(
|
|
181
|
+
outputCount,
|
|
182
|
+
`output-validation codegen drift — expected at least ${procedureCount} .output() ` +
|
|
183
|
+
`calls (one per query/mutation), found ${outputCount}. ` +
|
|
184
|
+
`Re-run: npx tsx scripts/generate-cap-routers.ts`,
|
|
185
|
+
).toBeGreaterThanOrEqual(procedureCount)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('reports the procedure / output-call inventory (informational)', () => {
|
|
189
|
+
if (generatedSource === null) return
|
|
190
|
+
const queryCount = (generatedSource.match(/\.query\(async/g) ?? []).length
|
|
191
|
+
const mutationCount = (generatedSource.match(/\.mutation\(async/g) ?? []).length
|
|
192
|
+
const subscriptionCount = (generatedSource.match(/\.subscription\(/g) ?? []).length
|
|
193
|
+
const outputCount = (generatedSource.match(/\.output\(/g) ?? []).length
|
|
194
|
+
console.log(
|
|
195
|
+
`[output-validation codegen] queries=${queryCount} mutations=${mutationCount} ` +
|
|
196
|
+
`subscriptions=${subscriptionCount} outputs=${outputCount}`,
|
|
197
|
+
)
|
|
198
|
+
})
|
|
199
|
+
})
|
|
200
|
+
})
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spec for the codegen'd `addon-settings` capability router.
|
|
3
|
+
*
|
|
4
|
+
* Exercises:
|
|
5
|
+
* - All 4 methods (get/update × global/device) — `getAddonSettings` /
|
|
6
|
+
* `updateAddonSettings` were collapsed into the global pair when the
|
|
7
|
+
* three-level settings API was simplified.
|
|
8
|
+
* - Auth enforcement (getters=protected, updaters=admin)
|
|
9
|
+
* - Missing provider → PRECONDITION_FAILED
|
|
10
|
+
* - Device settings routing with deviceId
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
13
|
+
import { createCapRouter_addonSettings } from '../../api/trpc/generated-cap-routers.js'
|
|
14
|
+
import type { IAddonSettingsProvider } from '@camstack/types'
|
|
15
|
+
import { makeCtx, invokeProcedure, checkAuthMatrix } from './harness.js'
|
|
16
|
+
|
|
17
|
+
function makeMockProvider(): IAddonSettingsProvider {
|
|
18
|
+
return {
|
|
19
|
+
getGlobalSettings: vi.fn(async () => ({
|
|
20
|
+
sections: [{ id: 'g', title: 'Global', fields: [] }],
|
|
21
|
+
})),
|
|
22
|
+
updateGlobalSettings: vi.fn(async () => ({ success: true as const })),
|
|
23
|
+
getDeviceSettings: vi.fn(async () => ({
|
|
24
|
+
sections: [{ id: 'd', title: 'Device', fields: [] }],
|
|
25
|
+
})),
|
|
26
|
+
updateDeviceSettings: vi.fn(async () => ({ success: true as const })),
|
|
27
|
+
} as unknown as IAddonSettingsProvider
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('addon-settings cap router', () => {
|
|
31
|
+
it('getGlobalSettings returns schema', async () => {
|
|
32
|
+
const provider = makeMockProvider()
|
|
33
|
+
const router = createCapRouter_addonSettings(() => provider)
|
|
34
|
+
const result = await invokeProcedure(router, 'getGlobalSettings', makeCtx('admin'), { addonId: 'test' })
|
|
35
|
+
expect(result.ok).toBe(true)
|
|
36
|
+
if (result.ok) expect(result.value).toEqual({ sections: [{ id: 'g', title: 'Global', fields: [] }] })
|
|
37
|
+
expect(provider.getGlobalSettings).toHaveBeenCalledWith({ addonId: 'test' })
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('updateGlobalSettings returns success', async () => {
|
|
41
|
+
const provider = makeMockProvider()
|
|
42
|
+
const router = createCapRouter_addonSettings(() => provider)
|
|
43
|
+
const result = await invokeProcedure(router, 'updateGlobalSettings', makeCtx('admin'), {
|
|
44
|
+
addonId: 'test', patch: { volume: 50 },
|
|
45
|
+
})
|
|
46
|
+
expect(result.ok).toBe(true)
|
|
47
|
+
if (result.ok) expect(result.value).toEqual({ success: true })
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('getDeviceSettings passes deviceId', async () => {
|
|
51
|
+
const provider = makeMockProvider()
|
|
52
|
+
const router = createCapRouter_addonSettings(() => provider)
|
|
53
|
+
await invokeProcedure(router, 'getDeviceSettings', makeCtx('admin'), {
|
|
54
|
+
addonId: 'test', deviceId: 1,
|
|
55
|
+
})
|
|
56
|
+
expect(provider.getDeviceSettings).toHaveBeenCalledWith({ addonId: 'test', deviceId: 1 })
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('updateDeviceSettings passes deviceId + patch', async () => {
|
|
60
|
+
const provider = makeMockProvider()
|
|
61
|
+
const router = createCapRouter_addonSettings(() => provider)
|
|
62
|
+
await invokeProcedure(router, 'updateDeviceSettings', makeCtx('admin'), {
|
|
63
|
+
addonId: 'test', deviceId: 1, patch: { enabled: false },
|
|
64
|
+
})
|
|
65
|
+
expect(provider.updateDeviceSettings).toHaveBeenCalledWith({
|
|
66
|
+
addonId: 'test', deviceId: 1, patch: { enabled: false },
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('returns PRECONDITION_FAILED when provider is null', async () => {
|
|
71
|
+
const router = createCapRouter_addonSettings(() => null)
|
|
72
|
+
const result = await invokeProcedure(router, 'getGlobalSettings', makeCtx('admin'), { addonId: 'x' })
|
|
73
|
+
expect(result.ok).toBe(false)
|
|
74
|
+
if (!result.ok) expect(result.code).toBe('PRECONDITION_FAILED')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('getters enforce protected auth', async () => {
|
|
78
|
+
const provider = makeMockProvider()
|
|
79
|
+
const router = createCapRouter_addonSettings(() => provider)
|
|
80
|
+
for (const method of ['getGlobalSettings', 'getDeviceSettings']) {
|
|
81
|
+
const input = method.includes('Device')
|
|
82
|
+
? { addonId: 'x', deviceId: 1 }
|
|
83
|
+
: { addonId: 'x' }
|
|
84
|
+
const results = await checkAuthMatrix(router, method, 'protected', input)
|
|
85
|
+
for (const r of results) {
|
|
86
|
+
if (r.allowed) expect(r.outcome.ok).toBe(true)
|
|
87
|
+
else expect(r.outcome.ok).toBe(false)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('updaters enforce admin auth', async () => {
|
|
93
|
+
const provider = makeMockProvider()
|
|
94
|
+
const router = createCapRouter_addonSettings(() => provider)
|
|
95
|
+
for (const method of ['updateGlobalSettings', 'updateDeviceSettings']) {
|
|
96
|
+
const input = method.includes('Device')
|
|
97
|
+
? { addonId: 'x', deviceId: 1, patch: {} }
|
|
98
|
+
: { addonId: 'x', patch: {} }
|
|
99
|
+
const results = await checkAuthMatrix(router, method, 'admin', input)
|
|
100
|
+
for (const r of results) {
|
|
101
|
+
if (r.allowed) expect(r.outcome.ok).toBe(true)
|
|
102
|
+
else expect(r.outcome.ok).toBe(false)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
})
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2E test for the device-manager aggregator via the generated tRPC cap
|
|
3
|
+
* router. Verifies the wire shape of `getDeviceSettingsAggregate`,
|
|
4
|
+
* `getDeviceLiveInfoAggregate`, and `updateDeviceField` — the three
|
|
5
|
+
* aggregator methods the admin UI consumes.
|
|
6
|
+
*
|
|
7
|
+
* Covers:
|
|
8
|
+
* - input validation at the router boundary
|
|
9
|
+
* - schema shape roundtrip (provider → router → caller)
|
|
10
|
+
* - auth enforcement (getters = protected, updateDeviceField = admin)
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
13
|
+
import { createCapRouter_deviceManager } from '../../api/trpc/generated-cap-routers.js'
|
|
14
|
+
import type { IDeviceManagerProvider } from '@camstack/types'
|
|
15
|
+
import { makeCtx, invokeProcedure } from './harness.js'
|
|
16
|
+
|
|
17
|
+
function makeProvider(): IDeviceManagerProvider {
|
|
18
|
+
return {
|
|
19
|
+
// Aggregator methods we exercise
|
|
20
|
+
getDeviceSettingsAggregate: vi.fn(async ({ deviceId }) => ({
|
|
21
|
+
sections: [
|
|
22
|
+
{
|
|
23
|
+
id: 'identity',
|
|
24
|
+
title: 'Identity',
|
|
25
|
+
tab: 'general',
|
|
26
|
+
order: 0,
|
|
27
|
+
fields: [{ type: 'text', key: '_stableId', label: 'Stable ID', readonlyField: true, value: String(deviceId) }],
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: 'motion-tuning',
|
|
31
|
+
title: 'Motion Tuning',
|
|
32
|
+
tab: 'detection',
|
|
33
|
+
order: 5,
|
|
34
|
+
fields: [{ type: 'number', key: 'threshold', label: 'Threshold', writerCapName: 'motion-detection', writerAddonId: 'motion-wasm', source: 'settings', value: 20 }],
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
})),
|
|
38
|
+
getDeviceLiveInfoAggregate: vi.fn(async ({ deviceId }) => ({
|
|
39
|
+
sections: [{
|
|
40
|
+
id: 'orchestrator-live',
|
|
41
|
+
title: 'Pipeline Status',
|
|
42
|
+
tab: 'detection',
|
|
43
|
+
order: 100,
|
|
44
|
+
fields: [{ type: 'text', key: 'assignedRunner', label: 'Assigned Runner', readonlyField: true, source: 'live', value: deviceId === 1 ? 'hub' : '' }],
|
|
45
|
+
}],
|
|
46
|
+
})),
|
|
47
|
+
updateDeviceField: vi.fn(async () => ({ success: true as const })),
|
|
48
|
+
|
|
49
|
+
// Placeholders for the other device-manager cap methods we don't
|
|
50
|
+
// exercise here — kept as no-ops so the IDeviceManagerProvider
|
|
51
|
+
// interface is fully satisfied.
|
|
52
|
+
registerDevice: vi.fn() as IDeviceManagerProvider['registerDevice'],
|
|
53
|
+
removeDevice: vi.fn() as IDeviceManagerProvider['removeDevice'],
|
|
54
|
+
persistConfig: vi.fn() as IDeviceManagerProvider['persistConfig'],
|
|
55
|
+
loadConfig: vi.fn(async () => ({})) as IDeviceManagerProvider['loadConfig'],
|
|
56
|
+
listPersistedByAddon: vi.fn(async () => []) as IDeviceManagerProvider['listPersistedByAddon'],
|
|
57
|
+
listAll: vi.fn(async () => []) as IDeviceManagerProvider['listAll'],
|
|
58
|
+
getDevice: vi.fn(async () => null) as IDeviceManagerProvider['getDevice'],
|
|
59
|
+
getChildren: vi.fn(async () => []) as IDeviceManagerProvider['getChildren'],
|
|
60
|
+
getStreamSources: vi.fn(async () => []) as IDeviceManagerProvider['getStreamSources'],
|
|
61
|
+
getConfigSchema: vi.fn(async () => []) as IDeviceManagerProvider['getConfigSchema'],
|
|
62
|
+
getSettingsSchema: vi.fn(async () => null) as IDeviceManagerProvider['getSettingsSchema'],
|
|
63
|
+
updateConfig: vi.fn(async () => ({ success: true as const })) as IDeviceManagerProvider['updateConfig'],
|
|
64
|
+
enable: vi.fn(async () => ({ success: true as const })) as IDeviceManagerProvider['enable'],
|
|
65
|
+
disable: vi.fn(async () => ({ success: true as const })) as IDeviceManagerProvider['disable'],
|
|
66
|
+
remove: vi.fn(async () => ({ success: true as const })) as IDeviceManagerProvider['remove'],
|
|
67
|
+
getStreamProfileMap: vi.fn(async () => ({})) as IDeviceManagerProvider['getStreamProfileMap'],
|
|
68
|
+
setStreamProfileMap: vi.fn(async () => ({ success: true as const })) as IDeviceManagerProvider['setStreamProfileMap'],
|
|
69
|
+
probeStreams: vi.fn(async () => []) as IDeviceManagerProvider['probeStreams'],
|
|
70
|
+
getBindings: vi.fn(async ({ deviceId }: { deviceId: number }) => ({ deviceId, entries: [] })) as IDeviceManagerProvider['getBindings'],
|
|
71
|
+
setWrapperActive: vi.fn() as IDeviceManagerProvider['setWrapperActive'],
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe('device-manager aggregator via tRPC router', () => {
|
|
76
|
+
const DEVICE_ID = 1
|
|
77
|
+
|
|
78
|
+
it('getDeviceSettingsAggregate returns the merged shape for a known device', async () => {
|
|
79
|
+
const provider = makeProvider()
|
|
80
|
+
const router = createCapRouter_deviceManager(() => provider)
|
|
81
|
+
const result = await invokeProcedure(router, 'getDeviceSettingsAggregate', makeCtx('admin'), { deviceId: DEVICE_ID })
|
|
82
|
+
|
|
83
|
+
expect(result.ok).toBe(true)
|
|
84
|
+
if (!result.ok) return
|
|
85
|
+
const value = result.value as { sections: readonly { id: string; tab?: string; fields: readonly unknown[] }[] }
|
|
86
|
+
expect(value.sections.map(s => s.id)).toEqual(['identity', 'motion-tuning'])
|
|
87
|
+
expect(provider.getDeviceSettingsAggregate).toHaveBeenCalledWith({ deviceId: DEVICE_ID })
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('getDeviceLiveInfoAggregate returns live sections', async () => {
|
|
91
|
+
const provider = makeProvider()
|
|
92
|
+
const router = createCapRouter_deviceManager(() => provider)
|
|
93
|
+
const result = await invokeProcedure(router, 'getDeviceLiveInfoAggregate', makeCtx('admin'), { deviceId: DEVICE_ID })
|
|
94
|
+
|
|
95
|
+
expect(result.ok).toBe(true)
|
|
96
|
+
if (!result.ok) return
|
|
97
|
+
const value = result.value as { sections: readonly { fields: readonly { key?: string; value?: unknown }[] }[] }
|
|
98
|
+
const runner = value.sections[0]!.fields.find(f => f.key === 'assignedRunner')
|
|
99
|
+
expect(runner?.value).toBe('hub')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('updateDeviceField forwards the full payload (deviceId, writerCap, writerAddon, key, value)', async () => {
|
|
103
|
+
const provider = makeProvider()
|
|
104
|
+
const router = createCapRouter_deviceManager(() => provider)
|
|
105
|
+
const result = await invokeProcedure(router, 'updateDeviceField', makeCtx('admin'), {
|
|
106
|
+
deviceId: DEVICE_ID,
|
|
107
|
+
writerCapName: 'motion-detection',
|
|
108
|
+
writerAddonId: 'motion-wasm',
|
|
109
|
+
key: 'threshold',
|
|
110
|
+
value: 42,
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
expect(result.ok).toBe(true)
|
|
114
|
+
expect(provider.updateDeviceField).toHaveBeenCalledWith({
|
|
115
|
+
deviceId: DEVICE_ID,
|
|
116
|
+
writerCapName: 'motion-detection',
|
|
117
|
+
writerAddonId: 'motion-wasm',
|
|
118
|
+
key: 'threshold',
|
|
119
|
+
value: 42,
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('updateDeviceField enforces admin auth', async () => {
|
|
124
|
+
const provider = makeProvider()
|
|
125
|
+
const router = createCapRouter_deviceManager(() => provider)
|
|
126
|
+
|
|
127
|
+
const payload = { deviceId: DEVICE_ID, writerCapName: 'motion-detection', writerAddonId: 'motion-wasm', key: 'threshold', value: 1 }
|
|
128
|
+
const viewer = await invokeProcedure(router, 'updateDeviceField', makeCtx('user'), payload)
|
|
129
|
+
expect(viewer.ok).toBe(false)
|
|
130
|
+
if (!viewer.ok) expect(viewer.code).toBe('FORBIDDEN')
|
|
131
|
+
|
|
132
|
+
const admin = await invokeProcedure(router, 'updateDeviceField', makeCtx('admin'), payload)
|
|
133
|
+
expect(admin.ok).toBe(true)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('router returns PRECONDITION_FAILED when the provider is missing', async () => {
|
|
137
|
+
const router = createCapRouter_deviceManager(() => null)
|
|
138
|
+
const result = await invokeProcedure(router, 'getDeviceSettingsAggregate', makeCtx('admin'), { deviceId: DEVICE_ID })
|
|
139
|
+
expect(result.ok).toBe(false)
|
|
140
|
+
if (!result.ok) expect(result.code).toBe('PRECONDITION_FAILED')
|
|
141
|
+
})
|
|
142
|
+
})
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
|
|
2
|
+
/**
|
|
3
|
+
* Test harness for codegen'd capability tRPC routers.
|
|
4
|
+
*
|
|
5
|
+
* Provides helpers to:
|
|
6
|
+
* - Build a minimal `TrpcContext` per auth role (anonymous/user/admin/agent)
|
|
7
|
+
* - Wrap a `createCapRouter_X` with a mock provider and get a typed caller
|
|
8
|
+
* - Assert that a procedure enforces the expected auth level
|
|
9
|
+
* - Drive subscriptions via mock push callbacks
|
|
10
|
+
*
|
|
11
|
+
* This harness is the foundation for per-cap `.router.spec.ts` files. See
|
|
12
|
+
* `system-info.router.spec.ts` for a usage example.
|
|
13
|
+
*/
|
|
14
|
+
import { TRPCError } from '@trpc/server'
|
|
15
|
+
import type { TrpcContext, AuthenticatedAgent } from '../../api/trpc/trpc.context.js'
|
|
16
|
+
|
|
17
|
+
// v2 auth model — there's no role enum, just `isAdmin`. The harness
|
|
18
|
+
// exposes four synthetic identities covering every gate we ship:
|
|
19
|
+
// anonymous : no auth (null user)
|
|
20
|
+
// user : authenticated, isAdmin=false (the only non-admin tier)
|
|
21
|
+
// admin : authenticated, isAdmin=true
|
|
22
|
+
// agent : authenticated, isAdmin=true with `agentId` set
|
|
23
|
+
export type AuthLevel = 'public' | 'protected' | 'admin'
|
|
24
|
+
export type TestRole = 'anonymous' | 'user' | 'admin' | 'agent'
|
|
25
|
+
|
|
26
|
+
/** Build an AuthenticatedAgent stub for a given synthetic identity. */
|
|
27
|
+
export function makeUser(role: Exclude<TestRole, 'anonymous'>, overrides: Partial<AuthenticatedAgent> = {}): AuthenticatedAgent {
|
|
28
|
+
const isAdmin = role === 'admin' || role === 'agent'
|
|
29
|
+
const base: AuthenticatedAgent = {
|
|
30
|
+
id: `user-${role}`,
|
|
31
|
+
username: role,
|
|
32
|
+
isAdmin,
|
|
33
|
+
permissions: { isAdmin, allowedProviders: '*', allowedDevices: {} },
|
|
34
|
+
isApiKey: false,
|
|
35
|
+
}
|
|
36
|
+
if (role === 'agent') {
|
|
37
|
+
return { ...base, agentId: 'agent-test', ...overrides }
|
|
38
|
+
}
|
|
39
|
+
return { ...base, ...overrides }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Build a minimal TrpcContext with the given identity (or `anonymous` for an unauthenticated ctx). */
|
|
43
|
+
export function makeCtx(role: TestRole, overrides: Partial<AuthenticatedAgent> = {}): TrpcContext {
|
|
44
|
+
const user = role === 'anonymous' ? null : makeUser(role, overrides)
|
|
45
|
+
// `req` is only used by subscription/WS plumbing which we don't exercise here.
|
|
46
|
+
// Cast via `unknown` to avoid pulling in a full Fastify stub.
|
|
47
|
+
return { user, req: {} as unknown as TrpcContext['req'] }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Identities that SHOULD pass for each auth level (positive set).
|
|
52
|
+
* Anything outside the allow-list should receive UNAUTHORIZED or FORBIDDEN.
|
|
53
|
+
*/
|
|
54
|
+
export const ALLOWED_ROLES_BY_AUTH: Readonly<Record<AuthLevel, readonly TestRole[]>> = {
|
|
55
|
+
public: ['anonymous', 'user', 'admin', 'agent'],
|
|
56
|
+
protected: ['user', 'admin', 'agent'],
|
|
57
|
+
admin: ['admin', 'agent'],
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const ALL_ROLES: readonly TestRole[] = ['anonymous', 'user', 'admin', 'agent']
|
|
61
|
+
|
|
62
|
+
/** Determine whether a synthetic identity should be allowed to call a procedure with the given auth level. */
|
|
63
|
+
export function isRoleAllowed(role: TestRole, auth: AuthLevel): boolean {
|
|
64
|
+
return ALLOWED_ROLES_BY_AUTH[auth].includes(role)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Minimal router interface returned by the codegen. Each method is a tRPC procedure;
|
|
69
|
+
* calling `.createCaller(ctx)` returns an object with one async function per method.
|
|
70
|
+
*/
|
|
71
|
+
export interface CreateCallerCapableRouter {
|
|
72
|
+
readonly createCaller: (ctx: TrpcContext) => Record<string, (input?: unknown) => unknown>
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Invoke a procedure and capture the outcome (ok | trpc error code | other throw).
|
|
77
|
+
* Useful for auth matrix assertions without leaking exceptions.
|
|
78
|
+
*/
|
|
79
|
+
export async function invokeProcedure(
|
|
80
|
+
router: CreateCallerCapableRouter,
|
|
81
|
+
procedure: string,
|
|
82
|
+
ctx: TrpcContext,
|
|
83
|
+
input?: unknown,
|
|
84
|
+
): Promise<{ ok: true; value: unknown } | { ok: false; code: string; message: string }> {
|
|
85
|
+
try {
|
|
86
|
+
const caller = router.createCaller(ctx)
|
|
87
|
+
const fn = caller[procedure]
|
|
88
|
+
if (typeof fn !== 'function') {
|
|
89
|
+
return { ok: false, code: 'NOT_FOUND', message: `procedure "${procedure}" missing on caller` }
|
|
90
|
+
}
|
|
91
|
+
const value = await fn(input)
|
|
92
|
+
return { ok: true, value }
|
|
93
|
+
} catch (err) {
|
|
94
|
+
if (err instanceof TRPCError) {
|
|
95
|
+
return { ok: false, code: err.code, message: err.message }
|
|
96
|
+
}
|
|
97
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
98
|
+
return { ok: false, code: 'THROWN', message }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Assert that a procedure enforces the expected auth level: each identity in `ALL_ROLES`
|
|
104
|
+
* should either succeed (allowed) or fail with UNAUTHORIZED/FORBIDDEN (denied).
|
|
105
|
+
*
|
|
106
|
+
* @returns An array of `{ role, allowed, outcome }` describing what happened — useful
|
|
107
|
+
* for building focused expectations in the calling test.
|
|
108
|
+
*/
|
|
109
|
+
export async function checkAuthMatrix(
|
|
110
|
+
router: CreateCallerCapableRouter,
|
|
111
|
+
procedure: string,
|
|
112
|
+
auth: AuthLevel,
|
|
113
|
+
input?: unknown,
|
|
114
|
+
): Promise<ReadonlyArray<{
|
|
115
|
+
readonly role: TestRole
|
|
116
|
+
readonly allowed: boolean
|
|
117
|
+
readonly outcome: Awaited<ReturnType<typeof invokeProcedure>>
|
|
118
|
+
}>> {
|
|
119
|
+
const results: Array<{
|
|
120
|
+
role: TestRole
|
|
121
|
+
allowed: boolean
|
|
122
|
+
outcome: Awaited<ReturnType<typeof invokeProcedure>>
|
|
123
|
+
}> = []
|
|
124
|
+
for (const role of ALL_ROLES) {
|
|
125
|
+
const outcome = await invokeProcedure(router, procedure, makeCtx(role), input)
|
|
126
|
+
results.push({ role, allowed: isRoleAllowed(role, auth), outcome })
|
|
127
|
+
}
|
|
128
|
+
return results
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Collect the first N values pushed to a subscription within `timeoutMs`.
|
|
133
|
+
* The codegen wraps subscriptions with `iterableSubscription`, which yields an
|
|
134
|
+
* async iterator — this helper just reads `count` values and returns them.
|
|
135
|
+
*/
|
|
136
|
+
export async function collectSubscription<T = unknown>(
|
|
137
|
+
iter: AsyncIterable<T>,
|
|
138
|
+
count: number,
|
|
139
|
+
timeoutMs = 1000,
|
|
140
|
+
): Promise<readonly T[]> {
|
|
141
|
+
const out: T[] = []
|
|
142
|
+
const iterator = iter[Symbol.asyncIterator]()
|
|
143
|
+
const deadline = Date.now() + timeoutMs
|
|
144
|
+
while (out.length < count) {
|
|
145
|
+
const remaining = deadline - Date.now()
|
|
146
|
+
if (remaining <= 0) break
|
|
147
|
+
const result = await Promise.race([
|
|
148
|
+
iterator.next(),
|
|
149
|
+
new Promise<IteratorResult<T>>((_, reject) =>
|
|
150
|
+
setTimeout(() => reject(new Error('collectSubscription timeout')), remaining),
|
|
151
|
+
),
|
|
152
|
+
])
|
|
153
|
+
if (result.done) break
|
|
154
|
+
out.push(result.value)
|
|
155
|
+
}
|
|
156
|
+
// Best-effort cleanup — async generators from iterableSubscription handle return() in their finally block.
|
|
157
|
+
await iterator.return?.()
|
|
158
|
+
return out
|
|
159
|
+
}
|