@dangao/bun-server 2.0.2 → 2.0.8
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/README.md +21 -8
- package/dist/config/config-module.d.ts +3 -0
- package/dist/config/config-module.d.ts.map +1 -1
- package/dist/core/context.d.ts +10 -0
- package/dist/core/context.d.ts.map +1 -1
- package/dist/database/service.d.ts +4 -4
- package/dist/database/service.d.ts.map +1 -1
- package/dist/index.js +236 -94
- package/dist/queue/queue-module.d.ts.map +1 -1
- package/dist/validation/decorators.d.ts.map +1 -1
- package/package.json +5 -4
- package/src/ai/providers/anthropic-provider.ts +1 -1
- package/src/ai/providers/google-provider.ts +1 -1
- package/src/ai/providers/ollama-provider.ts +1 -1
- package/src/ai/providers/openai-provider.ts +2 -2
- package/src/auth/jwt.ts +1 -1
- package/src/cache/interceptors.ts +3 -3
- package/src/cache/types.ts +10 -10
- package/src/client/runtime.ts +1 -1
- package/src/config/config-module.ts +46 -14
- package/src/config/service.ts +2 -2
- package/src/controller/param-binder.ts +1 -1
- package/src/conversation/service.ts +1 -1
- package/src/core/application.ts +1 -1
- package/src/core/cluster.ts +4 -4
- package/src/core/context.ts +71 -0
- package/src/dashboard/controller.ts +2 -2
- package/src/database/connection-manager.ts +4 -4
- package/src/database/service.ts +25 -28
- package/src/debug/middleware.ts +2 -2
- package/src/di/module-registry.ts +1 -1
- package/src/error/handler.ts +3 -3
- package/src/events/event-module.ts +4 -4
- package/src/files/static-middleware.ts +2 -2
- package/src/files/storage.ts +1 -1
- package/src/interceptor/builtin/log-interceptor.ts +1 -1
- package/src/mcp/server.ts +1 -1
- package/src/middleware/builtin/error-handler.ts +2 -2
- package/src/middleware/builtin/file-upload.ts +1 -1
- package/src/middleware/builtin/rate-limit.ts +1 -1
- package/src/middleware/builtin/static-file.ts +2 -2
- package/src/prompt/stores/file-store.ts +4 -4
- package/src/queue/queue-module.ts +4 -1
- package/src/request/body-parser.ts +3 -3
- package/src/security/filter.ts +1 -1
- package/src/security/guards/guard-registry.ts +1 -1
- package/src/session/middleware.ts +1 -1
- package/src/session/types.ts +5 -5
- package/src/testing/test-client.ts +1 -1
- package/src/validation/decorators.ts +70 -2
- package/src/validation/rules/common.ts +2 -2
- package/tests/config/config-module-extended.test.ts +24 -0
- package/tests/core/context.test.ts +52 -0
- package/tests/database/database-module.test.ts +87 -0
- package/tests/error/error-handler.test.ts +24 -0
- package/tests/queue/queue-module.test.ts +27 -0
- package/tests/validation/validation.test.ts +18 -0
|
@@ -217,7 +217,7 @@ export class EventModule {
|
|
|
217
217
|
if (eventModuleRef) {
|
|
218
218
|
try {
|
|
219
219
|
eventEmitter = eventModuleRef.container.resolve<EventEmitter>(EVENT_EMITTER_TOKEN);
|
|
220
|
-
} catch {
|
|
220
|
+
} catch (_error) {
|
|
221
221
|
// 忽略错误
|
|
222
222
|
}
|
|
223
223
|
}
|
|
@@ -264,7 +264,7 @@ export class EventModule {
|
|
|
264
264
|
if (moduleRef) {
|
|
265
265
|
try {
|
|
266
266
|
return moduleRef.container.resolve<EventEmitter>(EVENT_EMITTER_TOKEN);
|
|
267
|
-
} catch {
|
|
267
|
+
} catch (_error) {
|
|
268
268
|
// 忽略错误,尝试从传入的容器获取
|
|
269
269
|
}
|
|
270
270
|
}
|
|
@@ -273,7 +273,7 @@ export class EventModule {
|
|
|
273
273
|
if (container) {
|
|
274
274
|
try {
|
|
275
275
|
return container.resolve<EventEmitter>(EVENT_EMITTER_TOKEN);
|
|
276
|
-
} catch {
|
|
276
|
+
} catch (_error) {
|
|
277
277
|
// 忽略错误
|
|
278
278
|
}
|
|
279
279
|
}
|
|
@@ -307,7 +307,7 @@ export class EventModule {
|
|
|
307
307
|
let eventEmitter: EventEmitter | undefined;
|
|
308
308
|
try {
|
|
309
309
|
eventEmitter = eventModuleRef.container.resolve<EventEmitter>(EVENT_EMITTER_TOKEN);
|
|
310
|
-
} catch {
|
|
310
|
+
} catch (_error) {
|
|
311
311
|
return false;
|
|
312
312
|
}
|
|
313
313
|
|
|
@@ -41,11 +41,11 @@ export function createStaticFileMiddleware(options: StaticFileOptions): Middlewa
|
|
|
41
41
|
'Content-Type': file.type || 'application/octet-stream',
|
|
42
42
|
},
|
|
43
43
|
});
|
|
44
|
-
} catch {
|
|
44
|
+
} catch (_error) {
|
|
45
45
|
if (fallthrough) {
|
|
46
46
|
return await next();
|
|
47
47
|
}
|
|
48
|
-
return context.
|
|
48
|
+
return context.createErrorResponse({ error: 'File Not Found' }, { status: 404 });
|
|
49
49
|
}
|
|
50
50
|
};
|
|
51
51
|
}
|
package/src/files/storage.ts
CHANGED
package/src/mcp/server.ts
CHANGED
|
@@ -63,7 +63,7 @@ export function createErrorHandlingMiddleware(
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
if (error instanceof ValidationError) {
|
|
66
|
-
return context.
|
|
66
|
+
return context.createErrorResponse(
|
|
67
67
|
{
|
|
68
68
|
error: error.message,
|
|
69
69
|
issues: error.issues,
|
|
@@ -84,7 +84,7 @@ export function createErrorHandlingMiddleware(
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
if (error instanceof Error) {
|
|
87
|
-
return context.
|
|
87
|
+
return context.createErrorResponse(
|
|
88
88
|
{
|
|
89
89
|
error: error.message,
|
|
90
90
|
},
|
|
@@ -25,7 +25,7 @@ export function createFileUploadMiddleware(options: FileUploadOptions = {}): Mid
|
|
|
25
25
|
for (const file of fileList) {
|
|
26
26
|
if (file.size > maxSize) {
|
|
27
27
|
context.setStatus(413);
|
|
28
|
-
return context.
|
|
28
|
+
return context.createErrorResponse({ error: `File ${file.name} exceeds max size` });
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
}
|
|
@@ -193,7 +193,7 @@ export function createRateLimitMiddleware(options: RateLimitOptions): Middleware
|
|
|
193
193
|
// 检查是否超过限制
|
|
194
194
|
if (currentCount > max) {
|
|
195
195
|
context.setStatus(statusCode);
|
|
196
|
-
return context.
|
|
196
|
+
return context.createErrorResponse({
|
|
197
197
|
error: message,
|
|
198
198
|
retryAfter: Math.ceil(windowMs / 1000),
|
|
199
199
|
});
|
|
@@ -52,7 +52,7 @@ export function createStaticFileMiddleware(options: StaticFileOptions): Middlewa
|
|
|
52
52
|
const segments = cleanPath.split('/').filter((segment) => segment.length > 0);
|
|
53
53
|
if (segments.some((segment) => segment === '..')) {
|
|
54
54
|
context.setStatus(403);
|
|
55
|
-
return context.
|
|
55
|
+
return context.createErrorResponse({ error: 'Forbidden' });
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
let targetPath = resolve(root, cleanPath);
|
|
@@ -62,7 +62,7 @@ export function createStaticFileMiddleware(options: StaticFileOptions): Middlewa
|
|
|
62
62
|
|
|
63
63
|
if (!isSubPath(root, targetPath)) {
|
|
64
64
|
context.setStatus(403);
|
|
65
|
-
return context.
|
|
65
|
+
return context.createErrorResponse({ error: 'Forbidden' });
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
const file = Bun.file(targetPath);
|
|
@@ -74,7 +74,7 @@ export class FilePromptStore implements PromptStore {
|
|
|
74
74
|
try {
|
|
75
75
|
const path = `${this.promptsDir}/${id}.json`;
|
|
76
76
|
await Bun.file(path).exists() && Bun.write(path, ''); // Soft delete (empty file)
|
|
77
|
-
} catch {
|
|
77
|
+
} catch (_error) {
|
|
78
78
|
// ignore
|
|
79
79
|
}
|
|
80
80
|
}
|
|
@@ -105,11 +105,11 @@ export class FilePromptStore implements PromptStore {
|
|
|
105
105
|
await this.memory.create({ id, ...data }).catch(() => {
|
|
106
106
|
// Template already exists — skip
|
|
107
107
|
});
|
|
108
|
-
} catch {
|
|
108
|
+
} catch (_error) {
|
|
109
109
|
// Skip malformed files
|
|
110
110
|
}
|
|
111
111
|
}
|
|
112
|
-
} catch {
|
|
112
|
+
} catch (_error) {
|
|
113
113
|
// Directory doesn't exist — start empty
|
|
114
114
|
}
|
|
115
115
|
}
|
|
@@ -128,7 +128,7 @@ export class FilePromptStore implements PromptStore {
|
|
|
128
128
|
2,
|
|
129
129
|
);
|
|
130
130
|
await Bun.write(`${this.promptsDir}/${template.id}.json`, content);
|
|
131
|
-
} catch {
|
|
131
|
+
} catch (_error) {
|
|
132
132
|
// Ignore write errors (e.g., read-only filesystem)
|
|
133
133
|
}
|
|
134
134
|
}
|
|
@@ -37,11 +37,14 @@ export class QueueModule {
|
|
|
37
37
|
provide: QUEUE_SERVICE_TOKEN,
|
|
38
38
|
useValue: service,
|
|
39
39
|
},
|
|
40
|
+
{
|
|
41
|
+
provide: QueueService,
|
|
42
|
+
useValue: service,
|
|
43
|
+
},
|
|
40
44
|
{
|
|
41
45
|
provide: QUEUE_OPTIONS_TOKEN,
|
|
42
46
|
useValue: options,
|
|
43
47
|
},
|
|
44
|
-
QueueService,
|
|
45
48
|
);
|
|
46
49
|
|
|
47
50
|
// 动态更新模块元数据
|
|
@@ -57,10 +57,10 @@ export class BodyParser {
|
|
|
57
57
|
// 尝试解析为 JSON
|
|
58
58
|
try {
|
|
59
59
|
return JSON.parse(text);
|
|
60
|
-
} catch {
|
|
60
|
+
} catch (_error) {
|
|
61
61
|
return text;
|
|
62
62
|
}
|
|
63
|
-
} catch {
|
|
63
|
+
} catch (_error) {
|
|
64
64
|
return undefined;
|
|
65
65
|
}
|
|
66
66
|
}
|
|
@@ -72,7 +72,7 @@ export class BodyParser {
|
|
|
72
72
|
}
|
|
73
73
|
try {
|
|
74
74
|
return JSON.parse(fallbackText);
|
|
75
|
-
} catch {
|
|
75
|
+
} catch (_error) {
|
|
76
76
|
return fallbackText;
|
|
77
77
|
}
|
|
78
78
|
} catch (error) {
|
package/src/security/filter.ts
CHANGED
|
@@ -71,7 +71,7 @@ export function createSecurityFilter(config: SecurityFilterConfig): Middleware {
|
|
|
71
71
|
const { ControllerRegistry } = require('../controller/controller');
|
|
72
72
|
cachedContainer = ControllerRegistry.getInstance().getContainer();
|
|
73
73
|
return cachedContainer;
|
|
74
|
-
} catch {
|
|
74
|
+
} catch (_error) {
|
|
75
75
|
return null;
|
|
76
76
|
}
|
|
77
77
|
};
|
package/src/session/types.ts
CHANGED
|
@@ -231,7 +231,7 @@ export class RedisSessionStore implements SessionStore {
|
|
|
231
231
|
return undefined;
|
|
232
232
|
}
|
|
233
233
|
return JSON.parse(value) as Session;
|
|
234
|
-
} catch {
|
|
234
|
+
} catch (_error) {
|
|
235
235
|
return undefined;
|
|
236
236
|
}
|
|
237
237
|
}
|
|
@@ -243,7 +243,7 @@ export class RedisSessionStore implements SessionStore {
|
|
|
243
243
|
PX: maxAge,
|
|
244
244
|
});
|
|
245
245
|
return true;
|
|
246
|
-
} catch {
|
|
246
|
+
} catch (_error) {
|
|
247
247
|
return false;
|
|
248
248
|
}
|
|
249
249
|
}
|
|
@@ -252,7 +252,7 @@ export class RedisSessionStore implements SessionStore {
|
|
|
252
252
|
try {
|
|
253
253
|
await this.client.del(this.getKey(sessionId));
|
|
254
254
|
return true;
|
|
255
|
-
} catch {
|
|
255
|
+
} catch (_error) {
|
|
256
256
|
return false;
|
|
257
257
|
}
|
|
258
258
|
}
|
|
@@ -261,7 +261,7 @@ export class RedisSessionStore implements SessionStore {
|
|
|
261
261
|
try {
|
|
262
262
|
const result = await this.client.exists(this.getKey(sessionId));
|
|
263
263
|
return result === 1;
|
|
264
|
-
} catch {
|
|
264
|
+
} catch (_error) {
|
|
265
265
|
return false;
|
|
266
266
|
}
|
|
267
267
|
}
|
|
@@ -282,7 +282,7 @@ export class RedisSessionStore implements SessionStore {
|
|
|
282
282
|
}
|
|
283
283
|
|
|
284
284
|
return false;
|
|
285
|
-
} catch {
|
|
285
|
+
} catch (_error) {
|
|
286
286
|
return false;
|
|
287
287
|
}
|
|
288
288
|
}
|
|
@@ -58,11 +58,79 @@ export function IsNumber(options: RuleOption = {}): ValidationRuleDefinition {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
export function IsEmail(options: RuleOption = {}): ValidationRuleDefinition {
|
|
61
|
-
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
62
61
|
return {
|
|
63
62
|
name: 'isEmail',
|
|
64
63
|
message: options.message ?? '必须是合法的邮箱地址',
|
|
65
|
-
validate: (value) =>
|
|
64
|
+
validate: (value) => {
|
|
65
|
+
if (typeof value !== 'string') {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const email = value.trim();
|
|
70
|
+
if (!email || email.length > 254) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
if (email.includes(' ')) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const atIndex = email.indexOf('@');
|
|
78
|
+
if (atIndex <= 0 || atIndex !== email.lastIndexOf('@')) {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const local = email.slice(0, atIndex);
|
|
83
|
+
const domain = email.slice(atIndex + 1);
|
|
84
|
+
if (!local || !domain || local.length > 64) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
if (local.startsWith('.') || local.endsWith('.') || local.includes('..')) {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
if (domain.startsWith('.') || domain.endsWith('.') || domain.includes('..')) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
if (!domain.includes('.')) {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
for (const ch of local) {
|
|
98
|
+
const code = ch.charCodeAt(0);
|
|
99
|
+
const isAlphaNum =
|
|
100
|
+
(code >= 48 && code <= 57) ||
|
|
101
|
+
(code >= 65 && code <= 90) ||
|
|
102
|
+
(code >= 97 && code <= 122);
|
|
103
|
+
const isAllowedSymbol = "!#$%&'*+/=?^_`{|}~.-".includes(ch);
|
|
104
|
+
if (!isAlphaNum && !isAllowedSymbol) {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const labels = domain.split('.');
|
|
110
|
+
if (labels.length < 2) {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
for (const label of labels) {
|
|
114
|
+
if (!label || label.length > 63) {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
if (label.startsWith('-') || label.endsWith('-')) {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
for (const ch of label) {
|
|
121
|
+
const code = ch.charCodeAt(0);
|
|
122
|
+
const isAlphaNum =
|
|
123
|
+
(code >= 48 && code <= 57) ||
|
|
124
|
+
(code >= 65 && code <= 90) ||
|
|
125
|
+
(code >= 97 && code <= 122);
|
|
126
|
+
if (!isAlphaNum && ch !== '-') {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return labels[labels.length - 1]!.length >= 2;
|
|
133
|
+
},
|
|
66
134
|
};
|
|
67
135
|
}
|
|
68
136
|
|
|
@@ -191,7 +191,7 @@ export function IsUrl(options: RuleOptions = {}): ValidationRuleDefinition {
|
|
|
191
191
|
try {
|
|
192
192
|
new URL(value);
|
|
193
193
|
return true;
|
|
194
|
-
} catch {
|
|
194
|
+
} catch (_error) {
|
|
195
195
|
return false;
|
|
196
196
|
}
|
|
197
197
|
},
|
|
@@ -212,7 +212,7 @@ export function IsJSON(options: RuleOptions = {}): ValidationRuleDefinition {
|
|
|
212
212
|
try {
|
|
213
213
|
JSON.parse(value);
|
|
214
214
|
return true;
|
|
215
|
-
} catch {
|
|
215
|
+
} catch (_error) {
|
|
216
216
|
return false;
|
|
217
217
|
}
|
|
218
218
|
},
|
|
@@ -161,4 +161,28 @@ describe('ConfigModule.setValueByPath', () => {
|
|
|
161
161
|
setValueByPath(obj, 'a.b', 'value');
|
|
162
162
|
expect((obj.a as any).b).toBe('value');
|
|
163
163
|
});
|
|
164
|
+
|
|
165
|
+
test('should reject dangerous prototype pollution paths', () => {
|
|
166
|
+
const obj: Record<string, unknown> = {};
|
|
167
|
+
expect(() => setValueByPath(obj, '__proto__.polluted', 'yes')).toThrow();
|
|
168
|
+
expect(() => setValueByPath(obj, 'constructor.prototype.evil', 'yes')).toThrow();
|
|
169
|
+
expect(() => setValueByPath(obj, 'safe.__proto__.value', 'yes')).toThrow();
|
|
170
|
+
expect(() => setValueByPath(obj, 'safe. __proto__ .value', 'yes')).toThrow();
|
|
171
|
+
expect(({} as any).polluted).toBeUndefined();
|
|
172
|
+
expect(({} as any).evil).toBeUndefined();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('should reject empty path segments', () => {
|
|
176
|
+
const obj: Record<string, unknown> = {};
|
|
177
|
+
expect(() => setValueByPath(obj, 'a..b', 'value')).toThrow();
|
|
178
|
+
expect(() => setValueByPath(obj, '.a', 'value')).toThrow();
|
|
179
|
+
expect(() => setValueByPath(obj, 'a.', 'value')).toThrow();
|
|
180
|
+
expect(() => setValueByPath(obj, 'a. .b', 'value')).toThrow();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('should normalize whitespace around path segments', () => {
|
|
184
|
+
const obj: Record<string, unknown> = {};
|
|
185
|
+
setValueByPath(obj, ' a . b . c ', 'value');
|
|
186
|
+
expect((obj as any).a.b.c).toBe('value');
|
|
187
|
+
});
|
|
164
188
|
});
|
|
@@ -82,5 +82,57 @@ describe('Context', () => {
|
|
|
82
82
|
const response = context.createResponse({ error: 'Not Found' });
|
|
83
83
|
expect(response.status).toBe(404);
|
|
84
84
|
});
|
|
85
|
+
|
|
86
|
+
test('createResponse should keep business fields unchanged', async () => {
|
|
87
|
+
const request = new Request('http://localhost:3000/api/users');
|
|
88
|
+
const context = new Context(request);
|
|
89
|
+
|
|
90
|
+
const response = context.createResponse({
|
|
91
|
+
stack: 'business-stack-value',
|
|
92
|
+
data: { trace: 'business-trace-value' },
|
|
93
|
+
});
|
|
94
|
+
const body = (await response.json()) as {
|
|
95
|
+
stack: string;
|
|
96
|
+
data: { trace: string };
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
expect(body.stack).toBe('business-stack-value');
|
|
100
|
+
expect(body.data.trace).toBe('business-trace-value');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('createErrorResponse should redact sensitive fields', async () => {
|
|
104
|
+
const request = new Request('http://localhost:3000/api/users');
|
|
105
|
+
const context = new Context(request);
|
|
106
|
+
context.setStatus(400);
|
|
107
|
+
|
|
108
|
+
const response = context.createErrorResponse({
|
|
109
|
+
error: 'bad request',
|
|
110
|
+
stack: 'hidden',
|
|
111
|
+
details: {
|
|
112
|
+
trace: 'hidden-trace',
|
|
113
|
+
cause: 'hidden-cause',
|
|
114
|
+
},
|
|
115
|
+
items: [
|
|
116
|
+
{
|
|
117
|
+
trace: 'hidden-array-trace',
|
|
118
|
+
safe: 'value',
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
});
|
|
122
|
+
const body = (await response.json()) as {
|
|
123
|
+
error: string;
|
|
124
|
+
stack?: string;
|
|
125
|
+
details: { trace?: string; cause?: string };
|
|
126
|
+
items: Array<{ trace?: string; safe: string }>;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
expect(response.status).toBe(400);
|
|
130
|
+
expect(body.error).toBe('bad request');
|
|
131
|
+
expect(body.stack).toBeUndefined();
|
|
132
|
+
expect(body.details.trace).toBeUndefined();
|
|
133
|
+
expect(body.details.cause).toBeUndefined();
|
|
134
|
+
expect(body.items[0]?.trace).toBeUndefined();
|
|
135
|
+
expect(body.items[0]?.safe).toBe('value');
|
|
136
|
+
});
|
|
85
137
|
});
|
|
86
138
|
|
|
@@ -214,6 +214,93 @@ describe('DatabaseService', () => {
|
|
|
214
214
|
});
|
|
215
215
|
});
|
|
216
216
|
|
|
217
|
+
describe('DatabaseService Bun.SQL parameter binding', () => {
|
|
218
|
+
test('should pass parameters as Bun.SQL template values', async () => {
|
|
219
|
+
const service = new DatabaseService({
|
|
220
|
+
database: {
|
|
221
|
+
type: 'postgres',
|
|
222
|
+
config: {
|
|
223
|
+
host: 'localhost',
|
|
224
|
+
port: 5432,
|
|
225
|
+
database: 'test',
|
|
226
|
+
user: 'test',
|
|
227
|
+
password: 'test',
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
let capturedTemplate: TemplateStringsArray | null = null;
|
|
233
|
+
let capturedValues: unknown[] = [];
|
|
234
|
+
const mockConnection = async (
|
|
235
|
+
template: TemplateStringsArray,
|
|
236
|
+
...values: unknown[]
|
|
237
|
+
): Promise<Array<Record<string, unknown>>> => {
|
|
238
|
+
capturedTemplate = template;
|
|
239
|
+
capturedValues = values;
|
|
240
|
+
return [{ ok: true }];
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
(service as unknown as { getConnection: () => unknown }).getConnection = () =>
|
|
244
|
+
mockConnection;
|
|
245
|
+
(
|
|
246
|
+
service as unknown as {
|
|
247
|
+
getDatabaseType: () => 'sqlite' | 'postgres' | 'mysql';
|
|
248
|
+
}
|
|
249
|
+
).getDatabaseType = () => 'postgres';
|
|
250
|
+
|
|
251
|
+
const result = (await service.query<{ ok: boolean }>(
|
|
252
|
+
'SELECT * FROM users WHERE id = ? AND name = ?',
|
|
253
|
+
[1, "O'Reilly"],
|
|
254
|
+
)) as Array<{ ok: boolean }>;
|
|
255
|
+
|
|
256
|
+
expect(Array.isArray(result)).toBe(true);
|
|
257
|
+
expect(result[0]?.ok).toBe(true);
|
|
258
|
+
expect(capturedValues).toEqual([1, "O'Reilly"]);
|
|
259
|
+
expect(capturedTemplate).toBeDefined();
|
|
260
|
+
expect(capturedTemplate?.length).toBe(3);
|
|
261
|
+
expect(capturedTemplate?.[0]).toBe('SELECT * FROM users WHERE id = ');
|
|
262
|
+
expect(capturedTemplate?.[1]).toBe(' AND name = ');
|
|
263
|
+
expect(capturedTemplate?.[2]).toBe('');
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('should throw when placeholders do not match params count', async () => {
|
|
267
|
+
const service = new DatabaseService({
|
|
268
|
+
database: {
|
|
269
|
+
type: 'mysql',
|
|
270
|
+
config: {
|
|
271
|
+
host: 'localhost',
|
|
272
|
+
port: 3306,
|
|
273
|
+
database: 'test',
|
|
274
|
+
user: 'test',
|
|
275
|
+
password: 'test',
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
const mockConnection = async (): Promise<Array<Record<string, unknown>>> => [];
|
|
281
|
+
(service as unknown as { getConnection: () => unknown }).getConnection = () =>
|
|
282
|
+
mockConnection;
|
|
283
|
+
(
|
|
284
|
+
service as unknown as {
|
|
285
|
+
getDatabaseType: () => 'sqlite' | 'postgres' | 'mysql';
|
|
286
|
+
}
|
|
287
|
+
).getDatabaseType = () => 'mysql';
|
|
288
|
+
|
|
289
|
+
await expect(
|
|
290
|
+
service.query('SELECT * FROM users WHERE id = ?', [1, 2]) as Promise<
|
|
291
|
+
unknown[]
|
|
292
|
+
>,
|
|
293
|
+
).rejects.toThrow(
|
|
294
|
+
'Bun.SQL parameterized queries are not fully supported. Consider using template string queries.',
|
|
295
|
+
);
|
|
296
|
+
await expect(
|
|
297
|
+
service.query('SELECT * FROM users WHERE id = ?', [1, 2]) as Promise<
|
|
298
|
+
unknown[]
|
|
299
|
+
>,
|
|
300
|
+
).rejects.toThrow('Original error: SQL placeholders count does not match parameters count');
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
217
304
|
describe('ConnectionPool', () => {
|
|
218
305
|
test('should create connection pool', () => {
|
|
219
306
|
const { ConnectionPool } = require('../../src/database/connection-pool');
|
|
@@ -140,6 +140,30 @@ describe('handleError', () => {
|
|
|
140
140
|
expect(body.error).toBe('Internal Server Error');
|
|
141
141
|
});
|
|
142
142
|
|
|
143
|
+
test('should not expose stack field in error response body', async () => {
|
|
144
|
+
const previousNodeEnv = process.env.NODE_ENV;
|
|
145
|
+
process.env.NODE_ENV = 'development';
|
|
146
|
+
const context = createContext();
|
|
147
|
+
const error = new Error('Stack should be hidden from response body');
|
|
148
|
+
error.stack = 'sensitive stack trace';
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
const response = await handleError(error, context);
|
|
152
|
+
const body = await response.json() as {
|
|
153
|
+
error: string;
|
|
154
|
+
details?: string;
|
|
155
|
+
stack?: string;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
expect(response.status).toBe(500);
|
|
159
|
+
expect(body.error).toBe('Internal Server Error');
|
|
160
|
+
expect(body.details).toBe('Stack should be hidden from response body');
|
|
161
|
+
expect(body.stack).toBeUndefined();
|
|
162
|
+
} finally {
|
|
163
|
+
process.env.NODE_ENV = previousNodeEnv;
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
143
167
|
test('should handle string error', async () => {
|
|
144
168
|
const context = createContext();
|
|
145
169
|
const error = 'String error message';
|
|
@@ -37,6 +37,33 @@ describe('QueueModule', () => {
|
|
|
37
37
|
expect(queueProvider.useValue).toBeInstanceOf(QueueService);
|
|
38
38
|
});
|
|
39
39
|
|
|
40
|
+
test('should reuse same QueueService instance for class and token providers', () => {
|
|
41
|
+
QueueModule.forRoot({
|
|
42
|
+
enableWorker: true,
|
|
43
|
+
concurrency: 1,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const metadata = Reflect.getMetadata(MODULE_METADATA_KEY, QueueModule);
|
|
47
|
+
expect(metadata).toBeDefined();
|
|
48
|
+
expect(metadata.providers).toBeDefined();
|
|
49
|
+
|
|
50
|
+
const tokenProvider = metadata.providers.find(
|
|
51
|
+
(provider: any) => provider.provide === QUEUE_SERVICE_TOKEN,
|
|
52
|
+
);
|
|
53
|
+
const classProvider = metadata.providers.find(
|
|
54
|
+
(provider: any) => provider.provide === QueueService,
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
expect(tokenProvider).toBeDefined();
|
|
58
|
+
expect(classProvider).toBeDefined();
|
|
59
|
+
expect(tokenProvider.useValue).toBeInstanceOf(QueueService);
|
|
60
|
+
expect(classProvider.useValue).toBe(tokenProvider.useValue);
|
|
61
|
+
|
|
62
|
+
// Regression guard: worker path needs an initialized store.
|
|
63
|
+
const queueService = tokenProvider.useValue as QueueService & { store?: unknown };
|
|
64
|
+
expect(queueService.store).toBeDefined();
|
|
65
|
+
});
|
|
66
|
+
|
|
40
67
|
test('should use custom store when provided', () => {
|
|
41
68
|
const customStore = new MemoryQueueStore();
|
|
42
69
|
QueueModule.forRoot({
|
|
@@ -37,6 +37,24 @@ describe('Validation Decorators', () => {
|
|
|
37
37
|
const metadata = [{ index: 0, rules: [IsOptional(), IsString()] }];
|
|
38
38
|
expect(() => validateParameters([undefined], metadata)).not.toThrow();
|
|
39
39
|
});
|
|
40
|
+
|
|
41
|
+
test('IsEmail should accept common valid emails', () => {
|
|
42
|
+
const metadata = [{ index: 0, rules: [IsEmail()] }];
|
|
43
|
+
expect(() => validateParameters(['test@example.com'], metadata)).not.toThrow();
|
|
44
|
+
expect(() => validateParameters(['user.name+tag@sub.example.co'], metadata)).not.toThrow();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('IsEmail should reject invalid or risky inputs', () => {
|
|
48
|
+
const metadata = [{ index: 0, rules: [IsEmail()] }];
|
|
49
|
+
const tooLongLocal = `${'a'.repeat(65)}@example.com`;
|
|
50
|
+
const tooLongEmail = `${'a'.repeat(245)}@ex.com`;
|
|
51
|
+
|
|
52
|
+
expect(() => validateParameters(['not-email'], metadata)).toThrow(ValidationError);
|
|
53
|
+
expect(() => validateParameters(['a..b@example.com'], metadata)).toThrow(ValidationError);
|
|
54
|
+
expect(() => validateParameters(['a@-example.com'], metadata)).toThrow(ValidationError);
|
|
55
|
+
expect(() => validateParameters([tooLongLocal], metadata)).toThrow(ValidationError);
|
|
56
|
+
expect(() => validateParameters([tooLongEmail], metadata)).toThrow(ValidationError);
|
|
57
|
+
});
|
|
40
58
|
});
|
|
41
59
|
|
|
42
60
|
|