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