@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,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
|
+
})
|