@camstack/server 1.0.0 → 1.0.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.
Files changed (234) hide show
  1. package/{src/agent-status-page.ts → dist/agent-status-page.js} +30 -45
  2. package/dist/api/addon-upload.js +441 -0
  3. package/dist/api/addons-custom.router.js +91 -0
  4. package/dist/api/auth-whoami.js +55 -0
  5. package/dist/api/bridge-addons.router.js +109 -0
  6. package/dist/api/capabilities.router.js +229 -0
  7. package/dist/api/core/addon-settings.router.js +117 -0
  8. package/dist/api/core/agents.router.js +73 -0
  9. package/dist/api/core/auth.router.js +286 -0
  10. package/dist/api/core/bulk-update-coordinator.js +229 -0
  11. package/dist/api/core/cap-providers.js +1124 -0
  12. package/dist/api/core/capabilities.router.js +138 -0
  13. package/dist/api/core/collection-preference.js +17 -0
  14. package/dist/api/core/event-bus-proxy.router.js +45 -0
  15. package/dist/api/core/hwaccel.router.js +91 -0
  16. package/dist/api/core/live-events.router.js +61 -0
  17. package/dist/api/core/logs.router.js +172 -0
  18. package/dist/api/core/notifications.router.js +67 -0
  19. package/dist/api/core/repl.router.js +35 -0
  20. package/dist/api/core/settings-backend.router.js +121 -0
  21. package/dist/api/core/stream-probe.router.js +58 -0
  22. package/dist/api/core/system-events.router.js +100 -0
  23. package/dist/api/health/health.routes.js +68 -0
  24. package/{src/api/oauth2/consent-page.ts → dist/api/oauth2/consent-page.js} +11 -20
  25. package/dist/api/oauth2/oauth2-routes.js +219 -0
  26. package/dist/api/trpc/cap-mount-helpers.js +194 -0
  27. package/dist/api/trpc/cap-route-error-formatter.js +133 -0
  28. package/dist/api/trpc/client-ip.js +147 -0
  29. package/dist/api/trpc/core-cap-bridge.js +115 -0
  30. package/dist/api/trpc/generated-cap-mounts.js +388 -0
  31. package/dist/api/trpc/generated-cap-routers.js +7635 -0
  32. package/dist/api/trpc/scope-access.js +93 -0
  33. package/dist/api/trpc/trpc.context.js +184 -0
  34. package/dist/api/trpc/trpc.middleware.js +139 -0
  35. package/dist/api/trpc/trpc.router.js +188 -0
  36. package/dist/auth/session-cookie.js +47 -0
  37. package/dist/boot/boot-config.js +241 -0
  38. package/dist/boot/integration-id-backfill.js +76 -0
  39. package/dist/boot/post-boot.service.js +85 -0
  40. package/dist/core/addon/addon-call-gateway.js +99 -0
  41. package/dist/core/addon/addon-package.service.js +1560 -0
  42. package/dist/core/addon/addon-registry.service.js +2739 -0
  43. package/{src/core/addon/addon-row-manifest.ts → dist/core/addon/addon-row-manifest.js} +5 -5
  44. package/dist/core/addon/addon-search.service.js +62 -0
  45. package/dist/core/addon/addon-settings-provider.js +102 -0
  46. package/dist/core/addon/addon.tokens.js +5 -0
  47. package/dist/core/addon-bridge/addon-bridge.service.js +145 -0
  48. package/dist/core/addon-pages/addon-pages.service.js +107 -0
  49. package/dist/core/addon-widgets/addon-widgets.service.js +120 -0
  50. package/dist/core/agent/agent-registry.service.js +477 -0
  51. package/dist/core/auth/auth.service.js +10 -0
  52. package/dist/core/capability/capability.service.js +58 -0
  53. package/dist/core/config/config.schema.js +7 -0
  54. package/dist/core/config/config.service.js +10 -0
  55. package/dist/core/events/event-bus.service.js +83 -0
  56. package/dist/core/feature/feature.service.js +10 -0
  57. package/dist/core/lifecycle/lifecycle-state-machine.js +6 -0
  58. package/dist/core/logging/log-ring-buffer.js +6 -0
  59. package/dist/core/logging/logging.service.js +130 -0
  60. package/dist/core/logging/scoped-logger.js +6 -0
  61. package/dist/core/moleculer/cap-call-fn.js +50 -0
  62. package/dist/core/moleculer/cap-route-authority.js +122 -0
  63. package/dist/core/moleculer/moleculer.service.js +898 -0
  64. package/dist/core/network/network-quality.service.js +7 -0
  65. package/dist/core/notification/notification-wrapper.service.js +33 -0
  66. package/dist/core/notification/toast-wrapper.service.js +25 -0
  67. package/dist/core/provider/provider.tokens.js +4 -0
  68. package/dist/core/repl/repl-engine.service.js +140 -0
  69. package/dist/core/storage/fs-storage-backend.js +6 -0
  70. package/dist/core/storage/storage-location-manager.js +6 -0
  71. package/dist/core/storage/storage.service.js +7 -0
  72. package/dist/core/streaming/stream-probe.service.js +209 -0
  73. package/dist/core/topology/topology-emitter.service.js +106 -0
  74. package/dist/launcher.js +325 -0
  75. package/dist/main.js +1098 -0
  76. package/dist/manual-boot.js +227 -0
  77. package/package.json +5 -1
  78. package/src/__tests__/addon-install-e2e.test.ts +0 -74
  79. package/src/__tests__/addon-pages-e2e.test.ts +0 -200
  80. package/src/__tests__/addon-route-session.test.ts +0 -17
  81. package/src/__tests__/addon-settings-router.spec.ts +0 -67
  82. package/src/__tests__/addon-upload.spec.ts +0 -475
  83. package/src/__tests__/agent-registry.spec.ts +0 -179
  84. package/src/__tests__/agent-status-page.spec.ts +0 -82
  85. package/src/__tests__/auth-session-cookie.test.ts +0 -48
  86. package/src/__tests__/bulk-update-coordinator.spec.ts +0 -303
  87. package/src/__tests__/cap-ownership-authority.spec.ts +0 -431
  88. package/src/__tests__/cap-providers/cap-providers-location-import.spec.ts +0 -206
  89. package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +0 -37
  90. package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +0 -110
  91. package/src/__tests__/cap-providers/integrations-delete-cascade.spec.ts +0 -292
  92. package/src/__tests__/cap-providers-bulk-update.spec.ts +0 -408
  93. package/src/__tests__/cap-route-adapter.spec.ts +0 -302
  94. package/src/__tests__/cap-routers/_meta.spec.ts +0 -199
  95. package/src/__tests__/cap-routers/addon-settings.router.spec.ts +0 -115
  96. package/src/__tests__/cap-routers/broker-routing.router.spec.ts +0 -177
  97. package/src/__tests__/cap-routers/cap-route-error-formatter.spec.ts +0 -125
  98. package/src/__tests__/cap-routers/capabilities-node.spec.ts +0 -68
  99. package/src/__tests__/cap-routers/device-link-overlay.spec.ts +0 -137
  100. package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +0 -194
  101. package/src/__tests__/cap-routers/harness.ts +0 -163
  102. package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +0 -133
  103. package/src/__tests__/cap-routers/null-provider-guard.spec.ts +0 -64
  104. package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +0 -159
  105. package/src/__tests__/cap-routers/settings-store.router.spec.ts +0 -291
  106. package/src/__tests__/capability-e2e.test.ts +0 -384
  107. package/src/__tests__/cli-e2e.test.ts +0 -150
  108. package/src/__tests__/core-cap-bridge.spec.ts +0 -91
  109. package/src/__tests__/dev-bootstrap-shm-ring.spec.ts +0 -40
  110. package/src/__tests__/device-settings-contribution-dispatch.spec.ts +0 -280
  111. package/src/__tests__/embedded-deps-e2e.test.ts +0 -125
  112. package/src/__tests__/event-bus-proxy-router.spec.ts +0 -75
  113. package/src/__tests__/fixtures/mock-analysis-addon-a.ts +0 -37
  114. package/src/__tests__/fixtures/mock-analysis-addon-b.ts +0 -37
  115. package/src/__tests__/fixtures/mock-log-addon.ts +0 -37
  116. package/src/__tests__/fixtures/mock-storage-addon.ts +0 -40
  117. package/src/__tests__/framework-allowlist.spec.ts +0 -96
  118. package/src/__tests__/framework-installer-defer-restart.spec.ts +0 -165
  119. package/src/__tests__/https-e2e.test.ts +0 -124
  120. package/src/__tests__/lifecycle-e2e.test.ts +0 -189
  121. package/src/__tests__/live-events-subscription.spec.ts +0 -149
  122. package/src/__tests__/moleculer/uds-readiness.spec.ts +0 -150
  123. package/src/__tests__/moleculer/uds-topology.spec.ts +0 -418
  124. package/src/__tests__/moleculer/uds-unowned-call.spec.ts +0 -383
  125. package/src/__tests__/moleculer-register-node-idempotency.spec.ts +0 -273
  126. package/src/__tests__/native-cap-route.spec.ts +0 -427
  127. package/src/__tests__/oauth2-account-linking.spec.ts +0 -867
  128. package/src/__tests__/post-boot-restart.spec.ts +0 -161
  129. package/src/__tests__/singleton-contention.test.ts +0 -499
  130. package/src/__tests__/streaming-diagnostic.test.ts +0 -615
  131. package/src/__tests__/streaming-scale.test.ts +0 -314
  132. package/src/__tests__/uds-addon-call-wiring.spec.ts +0 -242
  133. package/src/__tests__/uds-log-ingest.spec.ts +0 -183
  134. package/src/api/__tests__/addons-custom.spec.ts +0 -148
  135. package/src/api/__tests__/capabilities.router.test.ts +0 -56
  136. package/src/api/addon-upload.ts +0 -529
  137. package/src/api/addons-custom.router.ts +0 -101
  138. package/src/api/auth-whoami.ts +0 -101
  139. package/src/api/bridge-addons.router.ts +0 -122
  140. package/src/api/capabilities.router.ts +0 -265
  141. package/src/api/core/__tests__/auth-router-totp.spec.ts +0 -297
  142. package/src/api/core/__tests__/integration-markers.spec.ts +0 -10
  143. package/src/api/core/addon-settings.router.ts +0 -127
  144. package/src/api/core/agents.router.ts +0 -86
  145. package/src/api/core/auth.router.ts +0 -322
  146. package/src/api/core/bulk-update-coordinator.ts +0 -305
  147. package/src/api/core/cap-providers.ts +0 -1339
  148. package/src/api/core/capabilities.router.ts +0 -149
  149. package/src/api/core/collection-preference.ts +0 -40
  150. package/src/api/core/event-bus-proxy.router.ts +0 -45
  151. package/src/api/core/hwaccel.router.ts +0 -108
  152. package/src/api/core/live-events.router.ts +0 -67
  153. package/src/api/core/logs.router.ts +0 -195
  154. package/src/api/core/notifications.router.ts +0 -66
  155. package/src/api/core/repl.router.ts +0 -39
  156. package/src/api/core/settings-backend.router.ts +0 -140
  157. package/src/api/core/stream-probe.router.ts +0 -57
  158. package/src/api/core/system-events.router.ts +0 -125
  159. package/src/api/health/health.routes.ts +0 -117
  160. package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +0 -62
  161. package/src/api/oauth2/oauth2-routes.ts +0 -281
  162. package/src/api/trpc/__tests__/client-ip.spec.ts +0 -146
  163. package/src/api/trpc/__tests__/scope-access-device.spec.ts +0 -268
  164. package/src/api/trpc/__tests__/scope-access.spec.ts +0 -102
  165. package/src/api/trpc/__tests__/webrtc-session-ua-enrich.spec.ts +0 -136
  166. package/src/api/trpc/cap-mount-helpers.ts +0 -245
  167. package/src/api/trpc/cap-route-error-formatter.ts +0 -171
  168. package/src/api/trpc/client-ip.ts +0 -147
  169. package/src/api/trpc/core-cap-bridge.ts +0 -154
  170. package/src/api/trpc/generated-cap-mounts.ts +0 -1240
  171. package/src/api/trpc/generated-cap-routers.ts +0 -11523
  172. package/src/api/trpc/scope-access.ts +0 -110
  173. package/src/api/trpc/trpc.context.ts +0 -258
  174. package/src/api/trpc/trpc.middleware.ts +0 -146
  175. package/src/api/trpc/trpc.router.ts +0 -389
  176. package/src/auth/session-cookie.ts +0 -54
  177. package/src/boot/__tests__/integration-id-backfill.spec.ts +0 -131
  178. package/src/boot/boot-config.ts +0 -259
  179. package/src/boot/integration-id-backfill.ts +0 -109
  180. package/src/boot/post-boot.service.ts +0 -105
  181. package/src/core/addon/__tests__/addon-registry-capability.test.ts +0 -62
  182. package/src/core/addon/__tests__/addon-row-manifest.spec.ts +0 -62
  183. package/src/core/addon/addon-call-gateway.ts +0 -171
  184. package/src/core/addon/addon-package.service.ts +0 -1787
  185. package/src/core/addon/addon-registry.service.ts +0 -3130
  186. package/src/core/addon/addon-search.service.ts +0 -91
  187. package/src/core/addon/addon-settings-provider.ts +0 -220
  188. package/src/core/addon/addon.tokens.ts +0 -2
  189. package/src/core/addon-bridge/addon-bridge.service.ts +0 -130
  190. package/src/core/addon-pages/addon-pages.service.spec.ts +0 -117
  191. package/src/core/addon-pages/addon-pages.service.ts +0 -82
  192. package/src/core/addon-widgets/addon-widgets.service.ts +0 -95
  193. package/src/core/agent/agent-registry.service.ts +0 -529
  194. package/src/core/auth/auth.service.spec.ts +0 -86
  195. package/src/core/auth/auth.service.ts +0 -8
  196. package/src/core/capability/capability.service.ts +0 -66
  197. package/src/core/config/config.schema.ts +0 -3
  198. package/src/core/config/config.service.spec.ts +0 -175
  199. package/src/core/config/config.service.ts +0 -7
  200. package/src/core/events/event-bus.service.spec.ts +0 -235
  201. package/src/core/events/event-bus.service.ts +0 -89
  202. package/src/core/feature/feature.service.spec.ts +0 -99
  203. package/src/core/feature/feature.service.ts +0 -8
  204. package/src/core/lifecycle/lifecycle-state-machine.spec.ts +0 -166
  205. package/src/core/lifecycle/lifecycle-state-machine.ts +0 -3
  206. package/src/core/logging/log-ring-buffer.ts +0 -3
  207. package/src/core/logging/logging.service.spec.ts +0 -287
  208. package/src/core/logging/logging.service.ts +0 -143
  209. package/src/core/logging/scoped-logger.ts +0 -3
  210. package/src/core/moleculer/cap-call-fn.spec.ts +0 -173
  211. package/src/core/moleculer/cap-call-fn.ts +0 -107
  212. package/src/core/moleculer/cap-route-authority.ts +0 -194
  213. package/src/core/moleculer/moleculer.service.ts +0 -1072
  214. package/src/core/network/network-quality.service.spec.ts +0 -53
  215. package/src/core/network/network-quality.service.ts +0 -5
  216. package/src/core/notification/notification-wrapper.service.ts +0 -34
  217. package/src/core/notification/toast-wrapper.service.ts +0 -27
  218. package/src/core/provider/provider.tokens.ts +0 -1
  219. package/src/core/repl/repl-engine.service.spec.ts +0 -444
  220. package/src/core/repl/repl-engine.service.ts +0 -155
  221. package/src/core/storage/fs-storage-backend.spec.ts +0 -70
  222. package/src/core/storage/fs-storage-backend.ts +0 -3
  223. package/src/core/storage/storage-location-manager.spec.ts +0 -130
  224. package/src/core/storage/storage-location-manager.ts +0 -3
  225. package/src/core/storage/storage.service.spec.ts +0 -73
  226. package/src/core/storage/storage.service.ts +0 -3
  227. package/src/core/streaming/stream-probe.service.ts +0 -221
  228. package/src/core/topology/topology-emitter.service.ts +0 -105
  229. package/src/launcher.ts +0 -314
  230. package/src/main.ts +0 -1245
  231. package/src/manual-boot.ts +0 -301
  232. package/tsconfig.build.json +0 -8
  233. package/tsconfig.json +0 -33
  234. package/vitest.config.ts +0 -26
@@ -1,867 +0,0 @@
1
- /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return, @typescript-eslint/consistent-type-assertions -- test file, mock typing */
2
- // server/backend/src/__tests__/oauth2-account-linking.spec.ts
3
- //
4
- // Integration test for the OAuth2 account-linking flow.
5
- //
6
- // Approach: in-process Fastify integration test using fastify.inject() —
7
- // no real network, no hub boot required.
8
- //
9
- // The sso-bridge stand-in is a real HMAC-JWT implementation backed by
10
- // Node's `crypto.createHmac` so access_token JWTs are genuine and their
11
- // payloads can be base64url-decoded and inspected (case 5).
12
- //
13
- import { describe, it, expect, beforeEach } from 'vitest'
14
- import Fastify from 'fastify'
15
- import cookie from '@fastify/cookie'
16
- import * as crypto from 'node:crypto'
17
- import { registerOauth2Routes } from '../api/oauth2/oauth2-routes.js'
18
- import { createOauthGrants } from '../../../../packages/core/src/builtins/local-auth/oauth-grants.js'
19
- import type { ISsoBridgeProvider } from '@camstack/types'
20
- import type {
21
- IOauthIntegrationProvider,
22
- IUserManagementProvider,
23
- TokenScope,
24
- } from '@camstack/types'
25
- import type { OauthSession } from '../../../../packages/core/src/builtins/local-auth/oauth-session-manager.js'
26
- import { SESSION_COOKIE } from '../auth/session-cookie.js'
27
-
28
- // ─── HMAC-JWT sso-bridge stand-in ────────────────────────────────────────────
29
- //
30
- // Produces genuine JWTs (header.payload.signature) whose payload segment is
31
- // standard base64url-encoded JSON — so the test can decode the access token
32
- // claims without any special tooling.
33
-
34
- const JWT_SECRET = crypto.randomBytes(32).toString('hex')
35
-
36
- function base64url(buf: Buffer): string {
37
- return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
38
- }
39
-
40
- function makeHmacJwt(payload: Record<string, unknown>, ttlSec: number): string {
41
- const header = base64url(Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })))
42
- const body = base64url(
43
- Buffer.from(
44
- JSON.stringify({
45
- ...payload,
46
- iat: Math.floor(Date.now() / 1000),
47
- exp: Math.floor(Date.now() / 1000) + ttlSec,
48
- }),
49
- ),
50
- )
51
- const sig = base64url(
52
- crypto.createHmac('sha256', JWT_SECRET).update(`${header}.${body}`).digest(),
53
- )
54
- return `${header}.${body}.${sig}`
55
- }
56
-
57
- function verifyHmacJwt(token: string): Record<string, unknown> | null {
58
- const parts = token.split('.')
59
- if (parts.length !== 3) return null
60
- const [header, body, sig] = parts as [string, string, string]
61
- const expected = base64url(
62
- crypto.createHmac('sha256', JWT_SECRET).update(`${header}.${body}`).digest(),
63
- )
64
- if (sig !== expected) return null
65
- try {
66
- const decoded = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as Record<
67
- string,
68
- unknown
69
- >
70
- if (typeof decoded['exp'] === 'number' && decoded['exp'] < Math.floor(Date.now() / 1000)) {
71
- return null
72
- }
73
- return decoded
74
- } catch {
75
- return null
76
- }
77
- }
78
-
79
- const realSsoBridge: ISsoBridgeProvider = {
80
- signBridgeToken: async ({ claims, ttlSec }) => ({
81
- token: makeHmacJwt(claims as Record<string, unknown>, ttlSec ?? 300),
82
- }),
83
- verifyBridgeToken: async ({ token }) => {
84
- const decoded = verifyHmacJwt(token)
85
- if (!decoded) return null
86
- return decoded as any
87
- },
88
- }
89
-
90
- // ─── In-memory fake OauthSessionManager ──────────────────────────────────────
91
- //
92
- // Mirrors the pattern in oauth-grants.spec.ts. The same instance is shared
93
- // between createOauthGrants and the fake user-management provider so sessions
94
- // created during token exchange are visible to listOauthSessions/revokeOauthSession.
95
-
96
- function fakeSessionManager() {
97
- const store = new Map<string, OauthSession>()
98
- let seq = 0
99
-
100
- return {
101
- store,
102
- async create(input: {
103
- userId: string
104
- username: string
105
- integrationId: string
106
- scopes: TokenScope[]
107
- }): Promise<OauthSession> {
108
- const now = Date.now()
109
- const session: OauthSession = {
110
- id: `session-${++seq}`,
111
- userId: input.userId,
112
- username: input.username,
113
- integrationId: input.integrationId,
114
- scopes: input.scopes,
115
- createdAt: now,
116
- lastUsedAt: now,
117
- revokedAt: null,
118
- }
119
- store.set(session.id, session)
120
- return session
121
- },
122
- async list(): Promise<OauthSession[]> {
123
- return Array.from(store.values())
124
- },
125
- async getById(id: string): Promise<OauthSession | null> {
126
- return store.get(id) ?? null
127
- },
128
- async markRevoked(id: string): Promise<boolean> {
129
- const existing = store.get(id)
130
- if (!existing) return false
131
- if (existing.revokedAt !== null) return true
132
- store.set(id, { ...existing, revokedAt: Date.now() })
133
- return true
134
- },
135
- async touch(id: string): Promise<void> {
136
- const existing = store.get(id)
137
- if (!existing) return
138
- store.set(id, { ...existing, lastUsedAt: Date.now() })
139
- },
140
- }
141
- }
142
-
143
- // ─── Fixed operator credentials ───────────────────────────────────────────────
144
-
145
- const OPERATOR_USER_ID = 'user-1'
146
- const OPERATOR_USERNAME = 'operator'
147
- const VALID_SESSION_TOKEN = 'valid-session-token-abc123'
148
-
149
- // ─── Alexa integration descriptor ────────────────────────────────────────────
150
-
151
- const ALEXA_DESCRIPTOR = {
152
- integrationId: 'export-alexa',
153
- displayName: 'Alexa Smart Home',
154
- requestedScopes: [
155
- {
156
- type: 'category' as const,
157
- target: 'device' as const,
158
- access: ['view', 'create'] as ('view' | 'create' | 'delete')[],
159
- },
160
- ],
161
- allowedRedirectPrefixes: ['https://cb.example/'],
162
- }
163
-
164
- const REDIRECT_URI = 'https://cb.example/oauth/callback'
165
- const STATE = 'random-state-xyz'
166
-
167
- // ─── Fake registry ────────────────────────────────────────────────────────────
168
- //
169
- // Minimal CapabilityRegistry-shaped object. Only the methods called by
170
- // oauth2-routes are needed: getCollectionEntries and getSingleton.
171
- //
172
- // The sessionManager is shared between createOauthGrants and the fake
173
- // user-management provider so sessions created during exchange are visible
174
- // to listOauthSessions/revokeOauthSession.
175
-
176
- function buildFakeRegistry(
177
- grants: ReturnType<typeof createOauthGrants>,
178
- sessionManager: ReturnType<typeof fakeSessionManager>,
179
- ): { getCollectionEntries: any; getSingleton: any } {
180
- const alexaProvider: IOauthIntegrationProvider = {
181
- getDescriptor: async () => ALEXA_DESCRIPTOR,
182
- }
183
-
184
- const userMgmt: IUserManagementProvider = {
185
- ...({} as IUserManagementProvider),
186
- oauthIssueCode: grants.oauthIssueCode.bind(grants),
187
- oauthExchangeCode: grants.oauthExchangeCode.bind(grants),
188
- oauthRefresh: grants.oauthRefresh.bind(grants),
189
- oauthVerifyAccessToken: grants.oauthVerifyAccessToken.bind(grants),
190
- listOauthSessions: async () => {
191
- const sessions = await sessionManager.list()
192
- return sessions.map((s) => ({
193
- id: s.id,
194
- userId: s.userId,
195
- username: s.username,
196
- integrationId: s.integrationId,
197
- scopes: s.scopes,
198
- createdAt: s.createdAt,
199
- lastUsedAt: s.lastUsedAt,
200
- revokedAt: s.revokedAt,
201
- }))
202
- },
203
- revokeOauthSession: async (input: { id: string }) => {
204
- const ok = await sessionManager.markRevoked(input.id)
205
- return { success: ok }
206
- },
207
- }
208
-
209
- return {
210
- getCollectionEntries: (cap: string) => {
211
- if (cap === 'oauth-integration') {
212
- return [['export-alexa-addon', alexaProvider]] as [string, IOauthIntegrationProvider][]
213
- }
214
- return []
215
- },
216
- getSingleton: (cap: string) => {
217
- if (cap === 'user-management') return userMgmt
218
- return null
219
- },
220
- }
221
- }
222
-
223
- // ─── Test setup ───────────────────────────────────────────────────────────────
224
-
225
- function buildApp(
226
- grants: ReturnType<typeof createOauthGrants>,
227
- sessionManager: ReturnType<typeof fakeSessionManager>,
228
- ) {
229
- const fastify = Fastify({ logger: false })
230
- void fastify.register(cookie)
231
-
232
- const fakeRegistry = buildFakeRegistry(grants, sessionManager)
233
-
234
- registerOauth2Routes(fastify, {
235
- getRegistry: () => fakeRegistry as any,
236
- verifyToken: (token: string) => {
237
- if (token === VALID_SESSION_TOKEN) {
238
- return { userId: OPERATOR_USER_ID, username: OPERATOR_USERNAME }
239
- }
240
- throw new Error('invalid token')
241
- },
242
- publicHubUrl: () => 'https://hub.example.com',
243
- })
244
-
245
- return fastify
246
- }
247
-
248
- // ─── Tests ────────────────────────────────────────────────────────────────────
249
-
250
- describe('OAuth2 account-linking flow', () => {
251
- let grants: ReturnType<typeof createOauthGrants>
252
- let sessionManager: ReturnType<typeof fakeSessionManager>
253
- let app: ReturnType<typeof buildApp>
254
-
255
- beforeEach(() => {
256
- // Fresh session manager + grants instance per test so state is clean.
257
- sessionManager = fakeSessionManager()
258
- grants = createOauthGrants(realSsoBridge, sessionManager as any)
259
- app = buildApp(grants, sessionManager)
260
- })
261
-
262
- // ── Case 1 ──────────────────────────────────────────────────────────────────
263
- it('GET /api/oauth2/authorize with no session cookie and Accept: text/html → 302 to /login', async () => {
264
- const url = `/api/oauth2/authorize?response_type=code&integration=export-alexa&redirect_uri=${encodeURIComponent(REDIRECT_URI)}&state=${STATE}`
265
- const res = await app.inject({
266
- method: 'GET',
267
- url,
268
- headers: { accept: 'text/html,application/xhtml+xml' },
269
- })
270
-
271
- expect(res.statusCode).toBe(302)
272
- const location = res.headers['location'] as string
273
- expect(location).toContain('/login?next=')
274
- })
275
-
276
- // ── Case 2 ──────────────────────────────────────────────────────────────────
277
- it('GET /api/oauth2/authorize with valid session cookie → 200 consent HTML containing "Alexa Smart Home"', async () => {
278
- const url = `/api/oauth2/authorize?response_type=code&integration=export-alexa&redirect_uri=${encodeURIComponent(REDIRECT_URI)}&state=${STATE}`
279
- const res = await app.inject({
280
- method: 'GET',
281
- url,
282
- headers: {
283
- accept: 'text/html,application/xhtml+xml',
284
- cookie: `${SESSION_COOKIE}=${VALID_SESSION_TOKEN}`,
285
- },
286
- })
287
-
288
- expect(res.statusCode).toBe(200)
289
- expect(res.headers['content-type']).toContain('text/html')
290
- expect(res.body).toContain('Alexa Smart Home')
291
- })
292
-
293
- // ── Case 3 ──────────────────────────────────────────────────────────────────
294
- it('POST /api/oauth2/authorize consent=allow → 302 to redirect_uri with code and state', async () => {
295
- const body = new URLSearchParams({
296
- consent: 'allow',
297
- integration: 'export-alexa',
298
- redirect_uri: REDIRECT_URI,
299
- state: STATE,
300
- response_type: 'code',
301
- }).toString()
302
-
303
- const res = await app.inject({
304
- method: 'POST',
305
- url: '/api/oauth2/authorize',
306
- headers: {
307
- 'content-type': 'application/x-www-form-urlencoded',
308
- cookie: `${SESSION_COOKIE}=${VALID_SESSION_TOKEN}`,
309
- },
310
- body,
311
- })
312
-
313
- expect(res.statusCode).toBe(302)
314
- const location = res.headers['location'] as string
315
- expect(location).toMatch(
316
- new RegExp(
317
- `^${REDIRECT_URI.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\?code=.+&state=${STATE}`,
318
- ),
319
- )
320
- })
321
-
322
- // ── Case 4 ──────────────────────────────────────────────────────────────────
323
- it('POST /api/oauth2/token authorization_code → access_token, refresh_token, expires_in: 3600, token_type: Bearer', async () => {
324
- // First issue a code via the POST /api/oauth2/authorize step.
325
- const authorizeBody = new URLSearchParams({
326
- consent: 'allow',
327
- integration: 'export-alexa',
328
- redirect_uri: REDIRECT_URI,
329
- state: STATE,
330
- response_type: 'code',
331
- }).toString()
332
-
333
- const authorizeRes = await app.inject({
334
- method: 'POST',
335
- url: '/api/oauth2/authorize',
336
- headers: {
337
- 'content-type': 'application/x-www-form-urlencoded',
338
- cookie: `${SESSION_COOKIE}=${VALID_SESSION_TOKEN}`,
339
- },
340
- body: authorizeBody,
341
- })
342
-
343
- expect(authorizeRes.statusCode).toBe(302)
344
- const location = authorizeRes.headers['location'] as string
345
- const codeMatch = location.match(/[?&]code=([^&]+)/)
346
- expect(codeMatch).not.toBeNull()
347
- const code = decodeURIComponent(codeMatch![1]!)
348
-
349
- // Now exchange the code for tokens.
350
- const tokenBody = new URLSearchParams({
351
- grant_type: 'authorization_code',
352
- code,
353
- redirect_uri: REDIRECT_URI,
354
- }).toString()
355
-
356
- const tokenRes = await app.inject({
357
- method: 'POST',
358
- url: '/api/oauth2/token',
359
- headers: { 'content-type': 'application/x-www-form-urlencoded' },
360
- body: tokenBody,
361
- })
362
-
363
- expect(tokenRes.statusCode).toBe(200)
364
- const json = tokenRes.json<{
365
- access_token: string
366
- refresh_token: string
367
- expires_in: number
368
- token_type: string
369
- }>()
370
- expect(json).toMatchObject({
371
- expires_in: 3600,
372
- token_type: 'Bearer',
373
- })
374
- expect(typeof json.access_token).toBe('string')
375
- expect(json.access_token.length).toBeGreaterThan(0)
376
- expect(typeof json.refresh_token).toBe('string')
377
- expect(json.refresh_token.length).toBeGreaterThan(0)
378
- })
379
-
380
- // ── Case 5 ──────────────────────────────────────────────────────────────────
381
- it('access_token payload contains isAdmin: false, scopes with device category, and correct username', async () => {
382
- // Issue code.
383
- const authorizeBody = new URLSearchParams({
384
- consent: 'allow',
385
- integration: 'export-alexa',
386
- redirect_uri: REDIRECT_URI,
387
- state: STATE,
388
- response_type: 'code',
389
- }).toString()
390
-
391
- const authorizeRes = await app.inject({
392
- method: 'POST',
393
- url: '/api/oauth2/authorize',
394
- headers: {
395
- 'content-type': 'application/x-www-form-urlencoded',
396
- cookie: `${SESSION_COOKIE}=${VALID_SESSION_TOKEN}`,
397
- },
398
- body: authorizeBody,
399
- })
400
-
401
- const location = authorizeRes.headers['location'] as string
402
- const codeMatch = location.match(/[?&]code=([^&]+)/)
403
- const code = decodeURIComponent(codeMatch![1]!)
404
-
405
- // Exchange code for tokens.
406
- const tokenBody = new URLSearchParams({
407
- grant_type: 'authorization_code',
408
- code,
409
- redirect_uri: REDIRECT_URI,
410
- }).toString()
411
-
412
- const tokenRes = await app.inject({
413
- method: 'POST',
414
- url: '/api/oauth2/token',
415
- headers: { 'content-type': 'application/x-www-form-urlencoded' },
416
- body: tokenBody,
417
- })
418
-
419
- const json = tokenRes.json<{ access_token: string }>()
420
- const accessToken = json.access_token
421
-
422
- // Decode the JWT middle segment (payload) — base64url → JSON.
423
- const parts = accessToken.split('.')
424
- expect(parts).toHaveLength(3)
425
- const payloadJson = Buffer.from(parts[1]!, 'base64url').toString('utf8')
426
- const payload = JSON.parse(payloadJson) as Record<string, unknown>
427
-
428
- expect(payload['isAdmin']).toBe(false)
429
- expect(payload['username']).toBe(OPERATOR_USERNAME)
430
-
431
- const scopes = payload['scopes'] as Array<Record<string, unknown>>
432
- expect(Array.isArray(scopes)).toBe(true)
433
- const deviceScope = scopes.find((s) => s['type'] === 'category' && s['target'] === 'device')
434
- expect(deviceScope).toBeDefined()
435
- })
436
-
437
- // ── Case 6 ──────────────────────────────────────────────────────────────────
438
- it('POST /api/oauth2/token with tampered code → 400 { error: "invalid_grant" }', async () => {
439
- // Issue a real code first.
440
- const authorizeBody = new URLSearchParams({
441
- consent: 'allow',
442
- integration: 'export-alexa',
443
- redirect_uri: REDIRECT_URI,
444
- state: STATE,
445
- response_type: 'code',
446
- }).toString()
447
-
448
- const authorizeRes = await app.inject({
449
- method: 'POST',
450
- url: '/api/oauth2/authorize',
451
- headers: {
452
- 'content-type': 'application/x-www-form-urlencoded',
453
- cookie: `${SESSION_COOKIE}=${VALID_SESSION_TOKEN}`,
454
- },
455
- body: authorizeBody,
456
- })
457
-
458
- const location = authorizeRes.headers['location'] as string
459
- const codeMatch = location.match(/[?&]code=([^&]+)/)
460
- const code = decodeURIComponent(codeMatch![1]!)
461
-
462
- // Tamper: mutate one character in the signature (last segment).
463
- const parts = code.split('.')
464
- const lastPart = parts[parts.length - 1]!
465
- const firstChar = lastPart[0]!
466
- const tamperedChar = firstChar === 'A' ? 'B' : 'A'
467
- parts[parts.length - 1] = tamperedChar + lastPart.slice(1)
468
- const tamperedCode = parts.join('.')
469
-
470
- const tokenBody = new URLSearchParams({
471
- grant_type: 'authorization_code',
472
- code: tamperedCode,
473
- redirect_uri: REDIRECT_URI,
474
- }).toString()
475
-
476
- const tokenRes = await app.inject({
477
- method: 'POST',
478
- url: '/api/oauth2/token',
479
- headers: { 'content-type': 'application/x-www-form-urlencoded' },
480
- body: tokenBody,
481
- })
482
-
483
- expect(tokenRes.statusCode).toBe(400)
484
- const json = tokenRes.json<{ error: string }>()
485
- expect(json.error).toBe('invalid_grant')
486
- })
487
-
488
- // ── Case 7 ──────────────────────────────────────────────────────────────────
489
- it('authenticated GET /api/oauth2/authorize with integration=bogus → 400', async () => {
490
- const url = `/api/oauth2/authorize?response_type=code&integration=bogus&redirect_uri=${encodeURIComponent(REDIRECT_URI)}&state=${STATE}`
491
- const res = await app.inject({
492
- method: 'GET',
493
- url,
494
- headers: {
495
- accept: 'text/html,application/xhtml+xml',
496
- cookie: `${SESSION_COOKIE}=${VALID_SESSION_TOKEN}`,
497
- },
498
- })
499
-
500
- expect(res.statusCode).toBe(400)
501
- })
502
-
503
- // ── Case 8 ──────────────────────────────────────────────────────────────────
504
- it('authenticated GET /api/oauth2/authorize with disallowed redirect_uri → 400, consent NOT rendered', async () => {
505
- const disallowedUri = 'https://evil.example/grab'
506
- const url = `/api/oauth2/authorize?response_type=code&integration=export-alexa&redirect_uri=${encodeURIComponent(disallowedUri)}&state=${STATE}`
507
- const res = await app.inject({
508
- method: 'GET',
509
- url,
510
- headers: {
511
- accept: 'text/html,application/xhtml+xml',
512
- cookie: `${SESSION_COOKIE}=${VALID_SESSION_TOKEN}`,
513
- },
514
- })
515
-
516
- expect(res.statusCode).toBe(400)
517
- const json = res.json<{ error: string }>()
518
- expect(json.error).toContain('redirect_uri not allowed')
519
- expect(res.body).not.toContain('Alexa Smart Home')
520
- })
521
-
522
- // ── Case 8b ─────────────────────────────────────────────────────────────────
523
- it('POST /api/oauth2/token refresh_token grant → 200 with new access_token, refresh_token, expires_in: 3600, token_type: Bearer', async () => {
524
- // Step 1: Obtain an authorization code via POST /api/oauth2/authorize.
525
- const authorizeBody = new URLSearchParams({
526
- consent: 'allow',
527
- integration: 'export-alexa',
528
- redirect_uri: REDIRECT_URI,
529
- state: STATE,
530
- response_type: 'code',
531
- }).toString()
532
-
533
- const authorizeRes = await app.inject({
534
- method: 'POST',
535
- url: '/api/oauth2/authorize',
536
- headers: {
537
- 'content-type': 'application/x-www-form-urlencoded',
538
- cookie: `${SESSION_COOKIE}=${VALID_SESSION_TOKEN}`,
539
- },
540
- body: authorizeBody,
541
- })
542
-
543
- expect(authorizeRes.statusCode).toBe(302)
544
- const location = authorizeRes.headers['location'] as string
545
- const codeMatch = location.match(/[?&]code=([^&]+)/)
546
- expect(codeMatch).not.toBeNull()
547
- const code = decodeURIComponent(codeMatch![1]!)
548
-
549
- // Step 2: Exchange the authorization code for an initial token pair.
550
- const codeTokenBody = new URLSearchParams({
551
- grant_type: 'authorization_code',
552
- code,
553
- redirect_uri: REDIRECT_URI,
554
- }).toString()
555
-
556
- const codeTokenRes = await app.inject({
557
- method: 'POST',
558
- url: '/api/oauth2/token',
559
- headers: { 'content-type': 'application/x-www-form-urlencoded' },
560
- body: codeTokenBody,
561
- })
562
-
563
- expect(codeTokenRes.statusCode).toBe(200)
564
- const initialJson = codeTokenRes.json<{ access_token: string; refresh_token: string }>()
565
- const refreshToken = initialJson.refresh_token
566
- expect(typeof refreshToken).toBe('string')
567
- expect(refreshToken.length).toBeGreaterThan(0)
568
-
569
- // Step 3: Use the refresh token to obtain a fresh token pair.
570
- const refreshBody = new URLSearchParams({
571
- grant_type: 'refresh_token',
572
- refresh_token: refreshToken,
573
- }).toString()
574
-
575
- const refreshRes = await app.inject({
576
- method: 'POST',
577
- url: '/api/oauth2/token',
578
- headers: { 'content-type': 'application/x-www-form-urlencoded' },
579
- body: refreshBody,
580
- })
581
-
582
- expect(refreshRes.statusCode).toBe(200)
583
- const refreshJson = refreshRes.json<{
584
- access_token: string
585
- refresh_token: string
586
- expires_in: number
587
- token_type: string
588
- }>()
589
- expect(refreshJson).toMatchObject({ expires_in: 3600, token_type: 'Bearer' })
590
- expect(typeof refreshJson.access_token).toBe('string')
591
- expect(refreshJson.access_token.length).toBeGreaterThan(0)
592
- expect(typeof refreshJson.refresh_token).toBe('string')
593
- expect(refreshJson.refresh_token.length).toBeGreaterThan(0)
594
-
595
- // Step 4: Decode the new access token payload and verify claims.
596
- const parts = refreshJson.access_token.split('.')
597
- expect(parts).toHaveLength(3)
598
- const payloadJson = Buffer.from(parts[1]!, 'base64url').toString('utf8')
599
- const payload = JSON.parse(payloadJson) as Record<string, unknown>
600
-
601
- expect(payload['provider']).toBe('oauth-access')
602
- expect(payload['isAdmin']).toBe(false)
603
- })
604
-
605
- // ── Case 9 ──────────────────────────────────────────────────────────────────
606
- it('authenticated POST /api/oauth2/authorize consent=allow with disallowed redirect_uri → 400, no code issued', async () => {
607
- const disallowedUri = 'https://evil.example/grab'
608
- const body = new URLSearchParams({
609
- consent: 'allow',
610
- integration: 'export-alexa',
611
- redirect_uri: disallowedUri,
612
- state: STATE,
613
- response_type: 'code',
614
- }).toString()
615
-
616
- const res = await app.inject({
617
- method: 'POST',
618
- url: '/api/oauth2/authorize',
619
- headers: {
620
- 'content-type': 'application/x-www-form-urlencoded',
621
- cookie: `${SESSION_COOKIE}=${VALID_SESSION_TOKEN}`,
622
- },
623
- body,
624
- })
625
-
626
- expect(res.statusCode).toBe(400)
627
- const json = res.json<{ error: string }>()
628
- expect(json.error).toContain('redirect_uri not allowed')
629
- // Ensure no Location header with a code was issued
630
- expect(res.headers['location']).toBeUndefined()
631
- })
632
-
633
- // ── Session registry lifecycle ───────────────────────────────────────────────
634
- //
635
- // These cases verify the end-to-end integration of OauthSessionManager with
636
- // the OAuth2 routes: exchange creates a session, list/revoke work correctly,
637
- // and revocation blocks subsequent refresh-token grants.
638
-
639
- describe('session registry lifecycle', () => {
640
- // Shared helper: run the full authorize → consent → exchange flow and
641
- // return the issued token pair plus the raw location URL.
642
- async function doFullFlow(): Promise<{
643
- accessToken: string
644
- refreshToken: string
645
- location: string
646
- }> {
647
- const authorizeBody = new URLSearchParams({
648
- consent: 'allow',
649
- integration: 'export-alexa',
650
- redirect_uri: REDIRECT_URI,
651
- state: STATE,
652
- response_type: 'code',
653
- }).toString()
654
-
655
- const authorizeRes = await app.inject({
656
- method: 'POST',
657
- url: '/api/oauth2/authorize',
658
- headers: {
659
- 'content-type': 'application/x-www-form-urlencoded',
660
- cookie: `${SESSION_COOKIE}=${VALID_SESSION_TOKEN}`,
661
- },
662
- body: authorizeBody,
663
- })
664
-
665
- expect(authorizeRes.statusCode).toBe(302)
666
- const location = authorizeRes.headers['location'] as string
667
- const codeMatch = location.match(/[?&]code=([^&]+)/)
668
- expect(codeMatch).not.toBeNull()
669
- const code = decodeURIComponent(codeMatch![1]!)
670
-
671
- const tokenBody = new URLSearchParams({
672
- grant_type: 'authorization_code',
673
- code,
674
- redirect_uri: REDIRECT_URI,
675
- }).toString()
676
-
677
- const tokenRes = await app.inject({
678
- method: 'POST',
679
- url: '/api/oauth2/token',
680
- headers: { 'content-type': 'application/x-www-form-urlencoded' },
681
- body: tokenBody,
682
- })
683
-
684
- expect(tokenRes.statusCode).toBe(200)
685
- const json = tokenRes.json<{ access_token: string; refresh_token: string }>()
686
- return { accessToken: json.access_token, refreshToken: json.refresh_token, location }
687
- }
688
-
689
- // ── Case a ────────────────────────────────────────────────────────────────
690
- it('a) listOauthSessions() returns exactly 1 session after exchange, with correct fields', async () => {
691
- await doFullFlow()
692
-
693
- const sessions = await sessionManager.list()
694
- expect(sessions).toHaveLength(1)
695
-
696
- const session = sessions[0]!
697
- expect(session.integrationId).toBe('export-alexa')
698
- expect(session.username).toBe(OPERATOR_USERNAME)
699
- expect(session.revokedAt).toBeNull()
700
-
701
- // Scopes must contain the category/device entry declared in ALEXA_DESCRIPTOR.
702
- expect(Array.isArray(session.scopes)).toBe(true)
703
- const deviceScope = session.scopes.find((s) => s.type === 'category' && s.target === 'device')
704
- expect(deviceScope).toBeDefined()
705
- })
706
-
707
- // ── Case b ────────────────────────────────────────────────────────────────
708
- it('b) issued access_token payload carries sessionId matching the listed session', async () => {
709
- const { accessToken } = await doFullFlow()
710
-
711
- const sessions = await sessionManager.list()
712
- expect(sessions).toHaveLength(1)
713
- const listedSessionId = sessions[0]!.id
714
-
715
- // Decode the JWT middle segment.
716
- const parts = accessToken.split('.')
717
- expect(parts).toHaveLength(3)
718
- const payloadJson = Buffer.from(parts[1]!, 'base64url').toString('utf8')
719
- const payload = JSON.parse(payloadJson) as Record<string, unknown>
720
-
721
- expect(payload['sessionId']).toBe(listedSessionId)
722
- })
723
-
724
- // ── Case c ────────────────────────────────────────────────────────────────
725
- it('c) revokeOauthSession({id}) returns {success:true} and session gains non-null revokedAt', async () => {
726
- await doFullFlow()
727
-
728
- const sessions = await sessionManager.list()
729
- const sessionId = sessions[0]!.id
730
-
731
- // Revoke via the fake user-management method (same as the real addon does).
732
- const revokeResult = await sessionManager
733
- .markRevoked(sessionId)
734
- .then((ok) => ({ success: ok }))
735
- expect(revokeResult).toEqual({ success: true })
736
-
737
- // The session must now carry a non-null revokedAt.
738
- const afterRevoke = await sessionManager.list()
739
- expect(afterRevoke).toHaveLength(1)
740
- expect(afterRevoke[0]!.revokedAt).not.toBeNull()
741
- })
742
-
743
- // ── Case d ────────────────────────────────────────────────────────────────
744
- it('d) POST /api/oauth2/token refresh_token after session revocation → 400 invalid_grant', async () => {
745
- const { refreshToken } = await doFullFlow()
746
-
747
- // Revoke the session.
748
- const sessions = await sessionManager.list()
749
- await sessionManager.markRevoked(sessions[0]!.id)
750
-
751
- // Attempt a refresh — must be rejected.
752
- const refreshBody = new URLSearchParams({
753
- grant_type: 'refresh_token',
754
- refresh_token: refreshToken,
755
- }).toString()
756
-
757
- const refreshRes = await app.inject({
758
- method: 'POST',
759
- url: '/api/oauth2/token',
760
- headers: { 'content-type': 'application/x-www-form-urlencoded' },
761
- body: refreshBody,
762
- })
763
-
764
- expect(refreshRes.statusCode).toBe(400)
765
- const json = refreshRes.json<{ error: string }>()
766
- expect(json.error).toBe('invalid_grant')
767
- })
768
-
769
- // ── Case e ────────────────────────────────────────────────────────────────
770
- it('e) revokeOauthSession with unknown id → {success: false}', async () => {
771
- const result = await sessionManager
772
- .markRevoked('non-existent-session-id')
773
- .then((ok) => ({ success: ok }))
774
- expect(result).toEqual({ success: false })
775
- })
776
- })
777
- })
778
-
779
- // ─── Phase A: integration-driven hubUrl claim ───────────────────────────────
780
- //
781
- // The OAuth code/token JWT carries a `hubUrl` claim the cloud Lambda routes
782
- // back on. A forked exporter addon (which can't set the hub's env) declares its
783
- // operator-selected external-access endpoint on the oauth-integration
784
- // descriptor; the authorize route must prefer it over the hub-global
785
- // publicHubUrl() (which is localhost in dev — the cause of "linking fails after
786
- // consent").
787
-
788
- describe('integration-driven hubUrl claim (Phase A public-origin bridge)', () => {
789
- const GLOBAL_FALLBACK = 'https://hub-global.example'
790
-
791
- function buildAppWithDescriptor(descriptorHubUrl?: string) {
792
- const sm = fakeSessionManager()
793
- const g = createOauthGrants(realSsoBridge, sm as any)
794
- const fastify = Fastify({ logger: false })
795
- void fastify.register(cookie)
796
-
797
- const descriptor = descriptorHubUrl
798
- ? { ...ALEXA_DESCRIPTOR, hubUrl: descriptorHubUrl }
799
- : { ...ALEXA_DESCRIPTOR }
800
- const alexaProvider: IOauthIntegrationProvider = { getDescriptor: async () => descriptor }
801
-
802
- const userMgmt: IUserManagementProvider = {
803
- ...({} as IUserManagementProvider),
804
- oauthIssueCode: g.oauthIssueCode.bind(g),
805
- oauthExchangeCode: g.oauthExchangeCode.bind(g),
806
- oauthRefresh: g.oauthRefresh.bind(g),
807
- oauthVerifyAccessToken: g.oauthVerifyAccessToken.bind(g),
808
- listOauthSessions: async () => [],
809
- revokeOauthSession: async () => ({ success: true }),
810
- }
811
-
812
- const reg = {
813
- getCollectionEntries: (cap: string) =>
814
- cap === 'oauth-integration' ? [['export-alexa-addon', alexaProvider]] : [],
815
- getSingleton: (cap: string) => (cap === 'user-management' ? userMgmt : null),
816
- }
817
-
818
- registerOauth2Routes(fastify, {
819
- getRegistry: () => reg as any,
820
- verifyToken: (token: string) => {
821
- if (token === VALID_SESSION_TOKEN)
822
- return { userId: OPERATOR_USER_ID, username: OPERATOR_USERNAME }
823
- throw new Error('invalid token')
824
- },
825
- publicHubUrl: () => GLOBAL_FALLBACK,
826
- })
827
- return fastify
828
- }
829
-
830
- async function issuedCodeHubUrl(
831
- app: ReturnType<typeof buildAppWithDescriptor>,
832
- ): Promise<unknown> {
833
- const body = new URLSearchParams({
834
- consent: 'allow',
835
- integration: 'export-alexa',
836
- redirect_uri: REDIRECT_URI,
837
- state: STATE,
838
- response_type: 'code',
839
- }).toString()
840
- const res = await app.inject({
841
- method: 'POST',
842
- url: '/api/oauth2/authorize',
843
- headers: {
844
- 'content-type': 'application/x-www-form-urlencoded',
845
- cookie: `${SESSION_COOKIE}=${VALID_SESSION_TOKEN}`,
846
- },
847
- body,
848
- })
849
- expect(res.statusCode).toBe(302)
850
- const loc = res.headers['location'] as string
851
- const code = decodeURIComponent(loc.match(/[?&]code=([^&]+)/)![1]!)
852
- const payload = JSON.parse(
853
- Buffer.from(code.split('.')[1]!, 'base64url').toString('utf8'),
854
- ) as Record<string, unknown>
855
- return payload['hubUrl']
856
- }
857
-
858
- it('bakes the integration descriptor hubUrl into the issued code', async () => {
859
- const app = buildAppWithDescriptor('https://tunnel.example.test')
860
- expect(await issuedCodeHubUrl(app)).toBe('https://tunnel.example.test')
861
- })
862
-
863
- it('falls back to publicHubUrl() when the descriptor has no hubUrl', async () => {
864
- const app = buildAppWithDescriptor()
865
- expect(await issuedCodeHubUrl(app)).toBe(GLOBAL_FALLBACK)
866
- })
867
- })