@dangao/bun-server 1.8.0 → 1.8.2

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 (62) hide show
  1. package/docs/api.md +194 -81
  2. package/docs/extensions.md +53 -0
  3. package/docs/guide.md +243 -1
  4. package/docs/microservice-config-center.md +73 -74
  5. package/docs/microservice-nacos.md +89 -90
  6. package/docs/microservice-service-registry.md +85 -86
  7. package/docs/microservice.md +142 -137
  8. package/docs/request-lifecycle.md +45 -4
  9. package/docs/symbol-interface-pattern.md +106 -106
  10. package/docs/zh/api.md +458 -18
  11. package/docs/zh/extensions.md +53 -0
  12. package/docs/zh/guide.md +251 -4
  13. package/docs/zh/microservice-config-center.md +258 -0
  14. package/docs/zh/microservice-nacos.md +346 -0
  15. package/docs/zh/microservice-service-registry.md +306 -0
  16. package/docs/zh/microservice.md +680 -0
  17. package/docs/zh/request-lifecycle.md +43 -5
  18. package/package.json +1 -1
  19. package/tests/auth/auth-decorators.test.ts +241 -0
  20. package/tests/auth/oauth2-service.test.ts +318 -0
  21. package/tests/cache/cache-decorators-extended.test.ts +272 -0
  22. package/tests/cache/cache-interceptors.test.ts +534 -0
  23. package/tests/cache/cache-service-proxy.test.ts +246 -0
  24. package/tests/cache/memory-cache-store.test.ts +155 -0
  25. package/tests/cache/redis-cache-store.test.ts +199 -0
  26. package/tests/config/config-center-integration.test.ts +334 -0
  27. package/tests/config/config-module-extended.test.ts +165 -0
  28. package/tests/controller/param-binder.test.ts +333 -0
  29. package/tests/error/error-handler.test.ts +166 -57
  30. package/tests/error/i18n-extended.test.ts +105 -0
  31. package/tests/events/event-listener-scanner.test.ts +114 -0
  32. package/tests/events/event-module.test.ts +133 -302
  33. package/tests/extensions/logger-module.test.ts +158 -0
  34. package/tests/files/file-storage.test.ts +136 -0
  35. package/tests/interceptor/base-interceptor.test.ts +605 -0
  36. package/tests/interceptor/builtin/cache-interceptor.test.ts +233 -86
  37. package/tests/interceptor/builtin/log-interceptor.test.ts +469 -0
  38. package/tests/interceptor/builtin/permission-interceptor.test.ts +219 -120
  39. package/tests/interceptor/interceptor-chain.test.ts +241 -189
  40. package/tests/interceptor/interceptor-metadata.test.ts +221 -0
  41. package/tests/microservice/circuit-breaker.test.ts +221 -0
  42. package/tests/microservice/service-client-decorators.test.ts +86 -0
  43. package/tests/microservice/service-client-interceptors.test.ts +274 -0
  44. package/tests/microservice/service-registry-decorators.test.ts +147 -0
  45. package/tests/microservice/tracer.test.ts +213 -0
  46. package/tests/microservice/tracing-collectors.test.ts +168 -0
  47. package/tests/middleware/builtin/middleware-builtin-extended.test.ts +237 -0
  48. package/tests/middleware/builtin/rate-limit.test.ts +257 -0
  49. package/tests/middleware/middleware-decorators.test.ts +222 -0
  50. package/tests/middleware/middleware-pipeline.test.ts +160 -0
  51. package/tests/queue/queue-decorators.test.ts +139 -0
  52. package/tests/queue/queue-service.test.ts +191 -0
  53. package/tests/request/body-parser-extended.test.ts +291 -0
  54. package/tests/request/request-wrapper.test.ts +319 -0
  55. package/tests/router/router-decorators.test.ts +260 -0
  56. package/tests/router/router-extended.test.ts +298 -0
  57. package/tests/security/guards/reflector.test.ts +188 -0
  58. package/tests/security/security-filter.test.ts +182 -0
  59. package/tests/security/security-module-extended.test.ts +133 -0
  60. package/tests/session/memory-session-store.test.ts +172 -0
  61. package/tests/session/session-decorators.test.ts +163 -0
  62. package/tests/swagger/ui.test.ts +212 -0
@@ -20,6 +20,10 @@ HTTP Request
20
20
  └─────────────────────────────────────┘
21
21
 
22
22
  ┌─────────────────────────────────────┐
23
+ │ 守卫 │
24
+ └─────────────────────────────────────┘
25
+
26
+ ┌─────────────────────────────────────┐
23
27
  │ 拦截器(前置) │
24
28
  └─────────────────────────────────────┘
25
29
 
@@ -167,7 +171,39 @@ class UserController {
167
171
  }
168
172
  ```
169
173
 
170
- ## 4. 拦截器(前置处理)
174
+ ## 4. 守卫
175
+
176
+ 守卫在路由匹配之后、拦截器之前执行,提供细粒度的访问控制。它们可以访问 `ExecutionContext`,提供关于当前请求的丰富信息。
177
+
178
+ ### 执行顺序
179
+
180
+ 1. **全局守卫** - 通过 `SecurityModule.forRoot({ globalGuards: [...] })` 注册
181
+ 2. **控制器守卫** - 通过 `@UseGuards()` 应用于控制器类
182
+ 3. **方法守卫** - 通过 `@UseGuards()` 应用于方法
183
+
184
+ ### 内置守卫
185
+
186
+ - `AuthGuard`:要求认证
187
+ - `OptionalAuthGuard`:可选认证
188
+ - `RolesGuard`:基于角色的授权(与 `@Roles()` 装饰器一起使用)
189
+
190
+ ### 示例
191
+
192
+ ```typescript
193
+ @Controller('/api/admin')
194
+ @UseGuards(AuthGuard, RolesGuard)
195
+ class AdminController {
196
+ @GET('/dashboard')
197
+ @Roles('admin')
198
+ public dashboard() {
199
+ return { message: '管理员仪表板' };
200
+ }
201
+ }
202
+ ```
203
+
204
+ 详细文档请参阅 [守卫](./guards.md)。
205
+
206
+ ## 5. 拦截器(前置处理)
171
207
 
172
208
  拦截器在控制器方法之前和之后运行。前置拦截器按顺序执行:
173
209
 
@@ -200,7 +236,7 @@ class ApiController {}
200
236
  - 响应转换
201
237
  - 性能监控
202
238
 
203
- ## 5. 参数绑定和验证
239
+ ## 6. 参数绑定和验证
204
240
 
205
241
  ### 参数装饰器
206
242
 
@@ -208,9 +244,11 @@ class ApiController {}
208
244
  |--------|------|------|
209
245
  | `@Param(name)` | URL 路径参数 | `/users/:id` → `@Param('id')` |
210
246
  | `@Query(name)` | 查询字符串 | `?page=1` → `@Query('page')` |
247
+ | `@QueryMap()` | 所有查询参数 | `?page=1&limit=10` → `@QueryMap()` 返回 `{ page: '1', limit: '10' }` |
211
248
  | `@Body()` | 请求体 | JSON 请求体 |
212
249
  | `@Body(name)` | 请求体属性 | `body.name` → `@Body('name')` |
213
250
  | `@Header(name)` | 请求头 | `@Header('Authorization')` |
251
+ | `@HeaderMap()` | 所有请求头 | `@HeaderMap()` 返回所有请求头作为对象 |
214
252
  | `@Context()` | 完整上下文 | 请求上下文对象 |
215
253
  | `@Session()` | 会话数据 | 会话对象 |
216
254
 
@@ -262,7 +300,7 @@ public createUser(
262
300
  }
263
301
  ```
264
302
 
265
- ## 6. 控制器方法执行
303
+ ## 7. 控制器方法执行
266
304
 
267
305
  验证通过后,使用已解析的依赖和绑定的参数调用控制器方法。
268
306
 
@@ -294,7 +332,7 @@ class UserController {
294
332
  - **void** - 空响应(204)
295
333
  - **Promise** - 异步操作
296
334
 
297
- ## 7. 拦截器(后置处理)
335
+ ## 8. 拦截器(后置处理)
298
336
 
299
337
  处理器执行后,后置拦截器按相反顺序运行:
300
338
 
@@ -318,7 +356,7 @@ class TransformInterceptor implements Interceptor {
318
356
  }
319
357
  ```
320
358
 
321
- ## 8. 异常过滤器
359
+ ## 9. 异常过滤器
322
360
 
323
361
  如果在请求生命周期中抛出任何异常,它会被异常过滤器捕获。
324
362
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dangao/bun-server",
3
- "version": "1.8.0",
3
+ "version": "1.8.2",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -0,0 +1,241 @@
1
+ import { describe, expect, test, beforeEach } from 'bun:test';
2
+ import 'reflect-metadata';
3
+
4
+ import {
5
+ Auth,
6
+ getAuthMetadata,
7
+ requiresAuth,
8
+ checkRoles,
9
+ type AuthConfig,
10
+ } from '../../src/auth/decorators';
11
+
12
+ describe('Auth Decorator', () => {
13
+ describe('@Auth', () => {
14
+ test('should set auth metadata with default options', () => {
15
+ class TestController {
16
+ @Auth()
17
+ public protectedMethod(): void {}
18
+ }
19
+
20
+ const metadata = getAuthMetadata(TestController.prototype, 'protectedMethod');
21
+ expect(metadata).toBeDefined();
22
+ expect(metadata?.required).toBe(true);
23
+ expect(metadata?.roles).toEqual([]);
24
+ expect(metadata?.allowAnonymous).toBe(false);
25
+ });
26
+
27
+ test('should set auth metadata with required=false', () => {
28
+ class TestController {
29
+ @Auth({ required: false })
30
+ public optionalAuthMethod(): void {}
31
+ }
32
+
33
+ const metadata = getAuthMetadata(TestController.prototype, 'optionalAuthMethod');
34
+ expect(metadata?.required).toBe(false);
35
+ });
36
+
37
+ test('should set auth metadata with roles', () => {
38
+ class TestController {
39
+ @Auth({ roles: ['admin', 'moderator'] })
40
+ public adminMethod(): void {}
41
+ }
42
+
43
+ const metadata = getAuthMetadata(TestController.prototype, 'adminMethod');
44
+ expect(metadata?.roles).toEqual(['admin', 'moderator']);
45
+ });
46
+
47
+ test('should set auth metadata with allowAnonymous', () => {
48
+ class TestController {
49
+ @Auth({ allowAnonymous: true })
50
+ public publicMethod(): void {}
51
+ }
52
+
53
+ const metadata = getAuthMetadata(TestController.prototype, 'publicMethod');
54
+ expect(metadata?.allowAnonymous).toBe(true);
55
+ });
56
+
57
+ test('should set all options together', () => {
58
+ const config: AuthConfig = {
59
+ required: true,
60
+ roles: ['admin'],
61
+ allowAnonymous: false,
62
+ };
63
+
64
+ class TestController {
65
+ @Auth(config)
66
+ public fullConfigMethod(): void {}
67
+ }
68
+
69
+ const metadata = getAuthMetadata(TestController.prototype, 'fullConfigMethod');
70
+ expect(metadata?.required).toBe(true);
71
+ expect(metadata?.roles).toEqual(['admin']);
72
+ expect(metadata?.allowAnonymous).toBe(false);
73
+ });
74
+
75
+ test('should handle multiple methods with different configs', () => {
76
+ class TestController {
77
+ @Auth({ roles: ['admin'] })
78
+ public adminMethod(): void {}
79
+
80
+ @Auth({ roles: ['user'] })
81
+ public userMethod(): void {}
82
+
83
+ @Auth({ allowAnonymous: true })
84
+ public publicMethod(): void {}
85
+ }
86
+
87
+ const adminMetadata = getAuthMetadata(TestController.prototype, 'adminMethod');
88
+ const userMetadata = getAuthMetadata(TestController.prototype, 'userMethod');
89
+ const publicMetadata = getAuthMetadata(TestController.prototype, 'publicMethod');
90
+
91
+ expect(adminMetadata?.roles).toEqual(['admin']);
92
+ expect(userMetadata?.roles).toEqual(['user']);
93
+ expect(publicMetadata?.allowAnonymous).toBe(true);
94
+ });
95
+ });
96
+
97
+ describe('getAuthMetadata', () => {
98
+ test('should return undefined for non-decorated method', () => {
99
+ class TestController {
100
+ public normalMethod(): void {}
101
+ }
102
+
103
+ const metadata = getAuthMetadata(TestController.prototype, 'normalMethod');
104
+ expect(metadata).toBeUndefined();
105
+ });
106
+
107
+ test('should return metadata for decorated method', () => {
108
+ class TestController {
109
+ @Auth({ roles: ['admin'] })
110
+ public protectedMethod(): void {}
111
+ }
112
+
113
+ const metadata = getAuthMetadata(TestController.prototype, 'protectedMethod');
114
+ expect(metadata).toBeDefined();
115
+ expect(metadata?.roles).toEqual(['admin']);
116
+ });
117
+ });
118
+
119
+ describe('requiresAuth', () => {
120
+ test('should return false for non-decorated method', () => {
121
+ class TestController {
122
+ public normalMethod(): void {}
123
+ }
124
+
125
+ const requires = requiresAuth(TestController.prototype, 'normalMethod');
126
+ expect(requires).toBe(false);
127
+ });
128
+
129
+ test('should return true for decorated method with default config', () => {
130
+ class TestController {
131
+ @Auth()
132
+ public protectedMethod(): void {}
133
+ }
134
+
135
+ const requires = requiresAuth(TestController.prototype, 'protectedMethod');
136
+ expect(requires).toBe(true);
137
+ });
138
+
139
+ test('should return true when required is explicitly true', () => {
140
+ class TestController {
141
+ @Auth({ required: true })
142
+ public protectedMethod(): void {}
143
+ }
144
+
145
+ const requires = requiresAuth(TestController.prototype, 'protectedMethod');
146
+ expect(requires).toBe(true);
147
+ });
148
+
149
+ test('should return false when required is false', () => {
150
+ class TestController {
151
+ @Auth({ required: false })
152
+ public optionalMethod(): void {}
153
+ }
154
+
155
+ const requires = requiresAuth(TestController.prototype, 'optionalMethod');
156
+ expect(requires).toBe(false);
157
+ });
158
+ });
159
+
160
+ describe('checkRoles', () => {
161
+ test('should return true when no roles required', () => {
162
+ class TestController {
163
+ @Auth()
164
+ public anyAuthMethod(): void {}
165
+ }
166
+
167
+ const hasAccess = checkRoles(TestController.prototype, 'anyAuthMethod', ['user']);
168
+ expect(hasAccess).toBe(true);
169
+ });
170
+
171
+ test('should return true when roles is empty array', () => {
172
+ class TestController {
173
+ @Auth({ roles: [] })
174
+ public anyAuthMethod(): void {}
175
+ }
176
+
177
+ const hasAccess = checkRoles(TestController.prototype, 'anyAuthMethod', ['user']);
178
+ expect(hasAccess).toBe(true);
179
+ });
180
+
181
+ test('should return true when user has required role', () => {
182
+ class TestController {
183
+ @Auth({ roles: ['admin'] })
184
+ public adminMethod(): void {}
185
+ }
186
+
187
+ const hasAccess = checkRoles(TestController.prototype, 'adminMethod', ['admin']);
188
+ expect(hasAccess).toBe(true);
189
+ });
190
+
191
+ test('should return true when user has one of required roles', () => {
192
+ class TestController {
193
+ @Auth({ roles: ['admin', 'moderator'] })
194
+ public modMethod(): void {}
195
+ }
196
+
197
+ const hasAccess = checkRoles(TestController.prototype, 'modMethod', ['moderator']);
198
+ expect(hasAccess).toBe(true);
199
+ });
200
+
201
+ test('should return false when user lacks required role', () => {
202
+ class TestController {
203
+ @Auth({ roles: ['admin'] })
204
+ public adminMethod(): void {}
205
+ }
206
+
207
+ const hasAccess = checkRoles(TestController.prototype, 'adminMethod', ['user']);
208
+ expect(hasAccess).toBe(false);
209
+ });
210
+
211
+ test('should return false when user has no roles', () => {
212
+ class TestController {
213
+ @Auth({ roles: ['admin'] })
214
+ public adminMethod(): void {}
215
+ }
216
+
217
+ const hasAccess = checkRoles(TestController.prototype, 'adminMethod', []);
218
+ expect(hasAccess).toBe(false);
219
+ });
220
+
221
+ test('should return true for non-decorated method', () => {
222
+ class TestController {
223
+ public normalMethod(): void {}
224
+ }
225
+
226
+ const hasAccess = checkRoles(TestController.prototype, 'normalMethod', []);
227
+ expect(hasAccess).toBe(true);
228
+ });
229
+
230
+ test('should handle default empty userRoles parameter', () => {
231
+ class TestController {
232
+ @Auth({ roles: ['admin'] })
233
+ public adminMethod(): void {}
234
+ }
235
+
236
+ // checkRoles 默认参数为空数组
237
+ const hasAccess = checkRoles(TestController.prototype, 'adminMethod');
238
+ expect(hasAccess).toBe(false);
239
+ });
240
+ });
241
+ });
@@ -0,0 +1,318 @@
1
+ import { describe, expect, test, beforeEach } from 'bun:test';
2
+ import 'reflect-metadata';
3
+
4
+ import { OAuth2Service } from '../../src/auth/oauth2';
5
+ import { JWTUtil } from '../../src/auth/jwt';
6
+ import type { OAuth2Client, UserInfo } from '../../src/auth/types';
7
+
8
+ describe('OAuth2Service', () => {
9
+ let jwtUtil: JWTUtil;
10
+ let oauth2Service: OAuth2Service;
11
+ let testClient: OAuth2Client;
12
+
13
+ beforeEach(() => {
14
+ jwtUtil = new JWTUtil({
15
+ secret: 'test-secret-key-for-oauth2-testing',
16
+ accessTokenExpiresIn: 3600,
17
+ refreshTokenExpiresIn: 86400,
18
+ });
19
+
20
+ testClient = {
21
+ clientId: 'test-client',
22
+ clientSecret: 'test-secret',
23
+ redirectUris: ['http://localhost:3000/callback'],
24
+ grantTypes: ['authorization_code'],
25
+ scopes: ['read', 'write'],
26
+ };
27
+
28
+ oauth2Service = new OAuth2Service(jwtUtil, [testClient]);
29
+ });
30
+
31
+ describe('registerClient', () => {
32
+ test('should register a new client', () => {
33
+ const newClient: OAuth2Client = {
34
+ clientId: 'new-client',
35
+ clientSecret: 'new-secret',
36
+ redirectUris: ['http://example.com/callback'],
37
+ grantTypes: ['authorization_code'],
38
+ };
39
+
40
+ oauth2Service.registerClient(newClient);
41
+
42
+ // Verify by trying to validate a request with this client
43
+ const result = oauth2Service.validateAuthorizationRequest({
44
+ clientId: 'new-client',
45
+ responseType: 'code',
46
+ redirectUri: 'http://example.com/callback',
47
+ });
48
+
49
+ expect(result.valid).toBe(true);
50
+ });
51
+ });
52
+
53
+ describe('validateAuthorizationRequest', () => {
54
+ test('should return valid for correct request', () => {
55
+ const result = oauth2Service.validateAuthorizationRequest({
56
+ clientId: 'test-client',
57
+ responseType: 'code',
58
+ redirectUri: 'http://localhost:3000/callback',
59
+ });
60
+
61
+ expect(result.valid).toBe(true);
62
+ expect(result.error).toBeUndefined();
63
+ });
64
+
65
+ test('should return invalid for unknown client', () => {
66
+ const result = oauth2Service.validateAuthorizationRequest({
67
+ clientId: 'unknown-client',
68
+ responseType: 'code',
69
+ redirectUri: 'http://localhost:3000/callback',
70
+ });
71
+
72
+ expect(result.valid).toBe(false);
73
+ expect(result.error).toBe('invalid_client');
74
+ });
75
+
76
+ test('should return invalid for unsupported response type', () => {
77
+ const result = oauth2Service.validateAuthorizationRequest({
78
+ clientId: 'test-client',
79
+ responseType: 'token' as any,
80
+ redirectUri: 'http://localhost:3000/callback',
81
+ });
82
+
83
+ expect(result.valid).toBe(false);
84
+ expect(result.error).toBe('unsupported_response_type');
85
+ });
86
+
87
+ test('should return invalid for wrong redirect uri', () => {
88
+ const result = oauth2Service.validateAuthorizationRequest({
89
+ clientId: 'test-client',
90
+ responseType: 'code',
91
+ redirectUri: 'http://evil.com/callback',
92
+ });
93
+
94
+ expect(result.valid).toBe(false);
95
+ expect(result.error).toBe('invalid_redirect_uri');
96
+ });
97
+ });
98
+
99
+ describe('generateAuthorizationCode', () => {
100
+ test('should generate a code', () => {
101
+ const code = oauth2Service.generateAuthorizationCode(
102
+ 'test-client',
103
+ 'http://localhost:3000/callback',
104
+ 'user-123',
105
+ 'read write',
106
+ );
107
+
108
+ expect(code).toBeDefined();
109
+ expect(typeof code).toBe('string');
110
+ expect(code.length).toBe(32);
111
+ });
112
+
113
+ test('should generate unique codes', () => {
114
+ const code1 = oauth2Service.generateAuthorizationCode(
115
+ 'test-client',
116
+ 'http://localhost:3000/callback',
117
+ 'user-123',
118
+ );
119
+ const code2 = oauth2Service.generateAuthorizationCode(
120
+ 'test-client',
121
+ 'http://localhost:3000/callback',
122
+ 'user-456',
123
+ );
124
+
125
+ expect(code1).not.toBe(code2);
126
+ });
127
+ });
128
+
129
+ describe('exchangeCodeForToken', () => {
130
+ test('should exchange valid code for tokens', async () => {
131
+ const code = oauth2Service.generateAuthorizationCode(
132
+ 'test-client',
133
+ 'http://localhost:3000/callback',
134
+ 'user-123',
135
+ 'read',
136
+ );
137
+
138
+ const result = await oauth2Service.exchangeCodeForToken({
139
+ grantType: 'authorization_code',
140
+ code,
141
+ clientId: 'test-client',
142
+ clientSecret: 'test-secret',
143
+ redirectUri: 'http://localhost:3000/callback',
144
+ });
145
+
146
+ expect(result).not.toBeNull();
147
+ expect(result?.accessToken).toBeDefined();
148
+ expect(result?.refreshToken).toBeDefined();
149
+ expect(result?.tokenType).toBe('Bearer');
150
+ expect(result?.scope).toBe('read');
151
+ });
152
+
153
+ test('should return null for wrong grant type', async () => {
154
+ const result = await oauth2Service.exchangeCodeForToken({
155
+ grantType: 'client_credentials' as any,
156
+ code: 'some-code',
157
+ clientId: 'test-client',
158
+ clientSecret: 'test-secret',
159
+ redirectUri: 'http://localhost:3000/callback',
160
+ });
161
+
162
+ expect(result).toBeNull();
163
+ });
164
+
165
+ test('should return null for invalid code', async () => {
166
+ const result = await oauth2Service.exchangeCodeForToken({
167
+ grantType: 'authorization_code',
168
+ code: 'invalid-code',
169
+ clientId: 'test-client',
170
+ clientSecret: 'test-secret',
171
+ redirectUri: 'http://localhost:3000/callback',
172
+ });
173
+
174
+ expect(result).toBeNull();
175
+ });
176
+
177
+ test('should return null for wrong client secret', async () => {
178
+ const code = oauth2Service.generateAuthorizationCode(
179
+ 'test-client',
180
+ 'http://localhost:3000/callback',
181
+ 'user-123',
182
+ );
183
+
184
+ const result = await oauth2Service.exchangeCodeForToken({
185
+ grantType: 'authorization_code',
186
+ code,
187
+ clientId: 'test-client',
188
+ clientSecret: 'wrong-secret',
189
+ redirectUri: 'http://localhost:3000/callback',
190
+ });
191
+
192
+ expect(result).toBeNull();
193
+ });
194
+
195
+ test('should return null for wrong redirect uri', async () => {
196
+ const code = oauth2Service.generateAuthorizationCode(
197
+ 'test-client',
198
+ 'http://localhost:3000/callback',
199
+ 'user-123',
200
+ );
201
+
202
+ const result = await oauth2Service.exchangeCodeForToken({
203
+ grantType: 'authorization_code',
204
+ code,
205
+ clientId: 'test-client',
206
+ clientSecret: 'test-secret',
207
+ redirectUri: 'http://different.com/callback',
208
+ });
209
+
210
+ expect(result).toBeNull();
211
+ });
212
+
213
+ test('should not allow code reuse', async () => {
214
+ const code = oauth2Service.generateAuthorizationCode(
215
+ 'test-client',
216
+ 'http://localhost:3000/callback',
217
+ 'user-123',
218
+ );
219
+
220
+ // First exchange should work
221
+ const result1 = await oauth2Service.exchangeCodeForToken({
222
+ grantType: 'authorization_code',
223
+ code,
224
+ clientId: 'test-client',
225
+ clientSecret: 'test-secret',
226
+ redirectUri: 'http://localhost:3000/callback',
227
+ });
228
+
229
+ expect(result1).not.toBeNull();
230
+
231
+ // Second exchange should fail
232
+ const result2 = await oauth2Service.exchangeCodeForToken({
233
+ grantType: 'authorization_code',
234
+ code,
235
+ clientId: 'test-client',
236
+ clientSecret: 'test-secret',
237
+ redirectUri: 'http://localhost:3000/callback',
238
+ });
239
+
240
+ expect(result2).toBeNull();
241
+ });
242
+ });
243
+
244
+ describe('refreshToken', () => {
245
+ test('should refresh token', async () => {
246
+ const code = oauth2Service.generateAuthorizationCode(
247
+ 'test-client',
248
+ 'http://localhost:3000/callback',
249
+ 'user-123',
250
+ );
251
+
252
+ const tokenResponse = await oauth2Service.exchangeCodeForToken({
253
+ grantType: 'authorization_code',
254
+ code,
255
+ clientId: 'test-client',
256
+ clientSecret: 'test-secret',
257
+ redirectUri: 'http://localhost:3000/callback',
258
+ });
259
+
260
+ expect(tokenResponse?.refreshToken).toBeDefined();
261
+
262
+ const refreshResult = await oauth2Service.refreshToken(tokenResponse!.refreshToken!);
263
+
264
+ expect(refreshResult).not.toBeNull();
265
+ expect(refreshResult?.accessToken).toBeDefined();
266
+ expect(refreshResult?.refreshToken).toBeDefined();
267
+ });
268
+
269
+ test('should return null for invalid refresh token', async () => {
270
+ const result = await oauth2Service.refreshToken('invalid-token');
271
+
272
+ expect(result).toBeNull();
273
+ });
274
+ });
275
+
276
+ describe('with userProvider', () => {
277
+ test('should use userProvider for token generation', async () => {
278
+ const userProvider = async (userId: string): Promise<UserInfo | null> => {
279
+ if (userId === 'user-123') {
280
+ return {
281
+ id: userId,
282
+ username: 'alice',
283
+ roles: ['admin', 'user'],
284
+ };
285
+ }
286
+ return null;
287
+ };
288
+
289
+ const serviceWithProvider = new OAuth2Service(
290
+ jwtUtil,
291
+ [testClient],
292
+ {},
293
+ userProvider,
294
+ );
295
+
296
+ const code = serviceWithProvider.generateAuthorizationCode(
297
+ 'test-client',
298
+ 'http://localhost:3000/callback',
299
+ 'user-123',
300
+ );
301
+
302
+ const result = await serviceWithProvider.exchangeCodeForToken({
303
+ grantType: 'authorization_code',
304
+ code,
305
+ clientId: 'test-client',
306
+ clientSecret: 'test-secret',
307
+ redirectUri: 'http://localhost:3000/callback',
308
+ });
309
+
310
+ expect(result).not.toBeNull();
311
+
312
+ // Verify the token contains the user info
313
+ const decoded = jwtUtil.verify(result!.accessToken);
314
+ expect(decoded?.username).toBe('alice');
315
+ expect(decoded?.roles).toContain('admin');
316
+ });
317
+ });
318
+ });