@onebun/core 0.2.6 → 0.2.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.
Files changed (33) hide show
  1. package/package.json +6 -6
  2. package/src/application/application.test.ts +350 -7
  3. package/src/application/application.ts +537 -254
  4. package/src/application/multi-service-application.test.ts +15 -0
  5. package/src/application/multi-service-application.ts +2 -0
  6. package/src/application/multi-service.types.ts +7 -1
  7. package/src/decorators/decorators.ts +213 -0
  8. package/src/docs-examples.test.ts +386 -3
  9. package/src/exception-filters/exception-filters.test.ts +172 -0
  10. package/src/exception-filters/exception-filters.ts +129 -0
  11. package/src/exception-filters/http-exception.ts +22 -0
  12. package/src/exception-filters/index.ts +2 -0
  13. package/src/file/onebun-file.ts +8 -2
  14. package/src/http-guards/http-guards.test.ts +230 -0
  15. package/src/http-guards/http-guards.ts +173 -0
  16. package/src/http-guards/index.ts +1 -0
  17. package/src/index.ts +10 -0
  18. package/src/module/module.test.ts +78 -0
  19. package/src/module/module.ts +55 -7
  20. package/src/queue/docs-examples.test.ts +72 -12
  21. package/src/queue/index.ts +4 -0
  22. package/src/queue/queue-service-proxy.test.ts +82 -0
  23. package/src/queue/queue-service-proxy.ts +114 -0
  24. package/src/queue/types.ts +2 -2
  25. package/src/security/cors-middleware.ts +212 -0
  26. package/src/security/index.ts +19 -0
  27. package/src/security/rate-limit-middleware.ts +276 -0
  28. package/src/security/security-headers-middleware.ts +188 -0
  29. package/src/security/security.test.ts +285 -0
  30. package/src/testing/index.ts +1 -0
  31. package/src/testing/testing-module.test.ts +199 -0
  32. package/src/testing/testing-module.ts +252 -0
  33. package/src/types.ts +153 -3
@@ -0,0 +1,285 @@
1
+ import {
2
+ describe,
3
+ expect,
4
+ it,
5
+ } from 'bun:test';
6
+
7
+ import type { OneBunRequest } from '../types';
8
+
9
+ import { CorsMiddleware } from './cors-middleware';
10
+ import { MemoryRateLimitStore, RateLimitMiddleware } from './rate-limit-middleware';
11
+ import { SecurityHeadersMiddleware } from './security-headers-middleware';
12
+
13
+ // ============================================================================
14
+ // Helpers
15
+ // ============================================================================
16
+
17
+ function makeReq(
18
+ method = 'GET',
19
+ url = 'http://localhost/test',
20
+ headers: [string, string][] = [],
21
+ ): OneBunRequest {
22
+ const h = new Headers();
23
+ for (const [k, v] of headers) {
24
+ h.set(k, v);
25
+ }
26
+
27
+ return new Request(url, { method, headers: h }) as unknown as OneBunRequest;
28
+ }
29
+
30
+ function makeNext(status = 200, body = 'ok', extraHeaders: [string, string][] = []) {
31
+ return async (): Promise<Response> => {
32
+ const h = new Headers();
33
+ h.set('Content-Type', 'text/plain');
34
+ for (const [k, v] of extraHeaders) {
35
+ h.set(k, v);
36
+ }
37
+
38
+ return new Response(body, { status, headers: h });
39
+ };
40
+ }
41
+
42
+ // Instantiate middleware without DI (logger/config not needed for these tests)
43
+ function makeCors(options = {}): CorsMiddleware {
44
+ return new CorsMiddleware(options);
45
+ }
46
+
47
+ function makeRateLimit(options = {}): RateLimitMiddleware {
48
+ return new RateLimitMiddleware(options);
49
+ }
50
+
51
+ function makeSecurityHeaders(options = {}): SecurityHeadersMiddleware {
52
+ return new SecurityHeadersMiddleware(options);
53
+ }
54
+
55
+ // ============================================================================
56
+ // CorsMiddleware
57
+ // ============================================================================
58
+
59
+ describe('CorsMiddleware', () => {
60
+ it('sets wildcard origin header by default', async () => {
61
+ const mw = makeCors();
62
+ const res = await mw.use(makeReq('GET', 'http://localhost/', [['origin', 'https://example.com']]), makeNext());
63
+ expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*');
64
+ });
65
+
66
+ it('responds with 204 to OPTIONS preflight', async () => {
67
+ const mw = makeCors();
68
+ const res = await mw.use(
69
+ makeReq('OPTIONS', 'http://localhost/', [['origin', 'https://example.com']]),
70
+ makeNext(),
71
+ );
72
+ expect(res.status).toBe(204);
73
+ expect(res.headers.get('Access-Control-Allow-Methods')).toContain('GET');
74
+ expect(res.headers.get('Access-Control-Max-Age')).toBeDefined();
75
+ });
76
+
77
+ it('reflects allowed origin when exact string is configured', async () => {
78
+ const mw = makeCors({ origin: 'https://allowed.example.com', credentials: true });
79
+ const res = await mw.use(
80
+ makeReq('GET', 'http://localhost/', [['origin', 'https://allowed.example.com']]),
81
+ makeNext(),
82
+ );
83
+ expect(res.headers.get('Access-Control-Allow-Origin')).toBe('https://allowed.example.com');
84
+ expect(res.headers.get('Access-Control-Allow-Credentials')).toBe('true');
85
+ });
86
+
87
+ it('does not set allow-origin for disallowed origin', async () => {
88
+ const mw = makeCors({ origin: 'https://allowed.example.com' });
89
+ const res = await mw.use(
90
+ makeReq('GET', 'http://localhost/', [['origin', 'https://other.example.com']]),
91
+ makeNext(),
92
+ );
93
+ expect(res.headers.get('Access-Control-Allow-Origin')).toBeNull();
94
+ });
95
+
96
+ it('accepts origin matching RegExp', async () => {
97
+ const mw = makeCors({ origin: /\.example\.com$/ });
98
+ const res = await mw.use(
99
+ makeReq('GET', 'http://localhost/', [['origin', 'https://sub.example.com']]),
100
+ makeNext(),
101
+ );
102
+ expect(res.headers.get('Access-Control-Allow-Origin')).toBe('https://sub.example.com');
103
+ });
104
+
105
+ it('accepts origin matching function predicate', async () => {
106
+ const mw = makeCors({ origin: (o: string) => o.startsWith('https://trusted') });
107
+ const res = await mw.use(
108
+ makeReq('GET', 'http://localhost/', [['origin', 'https://trusted.io']]),
109
+ makeNext(),
110
+ );
111
+ expect(res.headers.get('Access-Control-Allow-Origin')).toBe('https://trusted.io');
112
+ });
113
+
114
+ it('exposes configured headers', async () => {
115
+ const mw = makeCors({ exposedHeaders: ['X-Custom-Header'] });
116
+ const res = await mw.use(
117
+ makeReq('GET', 'http://localhost/', [['origin', 'https://example.com']]),
118
+ makeNext(),
119
+ );
120
+ expect(res.headers.get('Access-Control-Expose-Headers')).toBe('X-Custom-Header');
121
+ });
122
+
123
+ it('forwards OPTIONS to next() when preflightContinue is true', async () => {
124
+ const mw = makeCors({ preflightContinue: true });
125
+ const res = await mw.use(
126
+ makeReq('OPTIONS', 'http://localhost/', [['origin', 'https://example.com']]),
127
+ makeNext(200),
128
+ );
129
+ expect(res.status).toBe(200);
130
+ expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*');
131
+ });
132
+
133
+ it('configure() factory creates working middleware class', async () => {
134
+ const configuredClass = CorsMiddleware.configure({ origin: 'https://configured.example.com' });
135
+ const mw = new configuredClass();
136
+ const res = await mw.use(
137
+ makeReq('GET', 'http://localhost/', [['origin', 'https://configured.example.com']]),
138
+ makeNext(),
139
+ );
140
+ expect(res.headers.get('Access-Control-Allow-Origin')).toBe('https://configured.example.com');
141
+ });
142
+ });
143
+
144
+ // ============================================================================
145
+ // SecurityHeadersMiddleware
146
+ // ============================================================================
147
+
148
+ describe('SecurityHeadersMiddleware', () => {
149
+ it('sets default security headers', async () => {
150
+ const mw = makeSecurityHeaders();
151
+ const res = await mw.use(makeReq(), makeNext());
152
+ expect(res.headers.get('X-Content-Type-Options')).toBe('nosniff');
153
+ expect(res.headers.get('X-Frame-Options')).toBe('SAMEORIGIN');
154
+ expect(res.headers.get('Referrer-Policy')).toBe('no-referrer');
155
+ expect(res.headers.get('Content-Security-Policy')).toBe("default-src 'self'");
156
+ expect(res.headers.get('Strict-Transport-Security')).toBe('max-age=15552000; includeSubDomains');
157
+ expect(res.headers.get('X-XSS-Protection')).toBe('0');
158
+ });
159
+
160
+ it('allows overriding individual headers', async () => {
161
+ const mw = makeSecurityHeaders({ xFrameOptions: 'DENY', xXssProtection: '1; mode=block' });
162
+ const res = await mw.use(makeReq(), makeNext());
163
+ expect(res.headers.get('X-Frame-Options')).toBe('DENY');
164
+ expect(res.headers.get('X-XSS-Protection')).toBe('1; mode=block');
165
+ // Other defaults still present
166
+ expect(res.headers.get('X-Content-Type-Options')).toBe('nosniff');
167
+ });
168
+
169
+ it('omits disabled headers', async () => {
170
+ const mw = makeSecurityHeaders({ strictTransportSecurity: false, contentSecurityPolicy: false });
171
+ const res = await mw.use(makeReq(), makeNext());
172
+ expect(res.headers.get('Strict-Transport-Security')).toBeNull();
173
+ expect(res.headers.get('Content-Security-Policy')).toBeNull();
174
+ });
175
+
176
+ it('configure() factory creates working middleware class', async () => {
177
+ const configuredClass = SecurityHeadersMiddleware.configure({ xFrameOptions: 'DENY' });
178
+ const mw = new configuredClass();
179
+ const res = await mw.use(makeReq(), makeNext());
180
+ expect(res.headers.get('X-Frame-Options')).toBe('DENY');
181
+ });
182
+ });
183
+
184
+ // ============================================================================
185
+ // RateLimitMiddleware
186
+ // ============================================================================
187
+
188
+ describe('RateLimitMiddleware', () => {
189
+ it('allows requests below the limit', async () => {
190
+ const store = new MemoryRateLimitStore();
191
+ const mw = makeRateLimit({ max: 5, windowMs: 60_000, store });
192
+ const req = makeReq('GET', 'http://localhost/', [['x-forwarded-for', '10.0.0.1']]);
193
+ const res = await mw.use(req, makeNext());
194
+ expect(res.status).toBe(200);
195
+ expect(res.headers.get('RateLimit-Limit')).toBe('5');
196
+ expect(res.headers.get('RateLimit-Remaining')).toBe('4');
197
+ });
198
+
199
+ it('blocks requests that exceed the limit with 429', async () => {
200
+ const store = new MemoryRateLimitStore();
201
+ const mw = makeRateLimit({ max: 2, windowMs: 60_000, store });
202
+ const req = makeReq('GET', 'http://localhost/', [['x-forwarded-for', '10.0.0.2']]);
203
+
204
+ await mw.use(req, makeNext()); // 1st
205
+ await mw.use(req, makeNext()); // 2nd
206
+ const res = await mw.use(req, makeNext()); // 3rd — over limit
207
+
208
+ expect(res.status).toBe(429);
209
+ const body = await res.json() as { success: boolean; error: string };
210
+ expect(body.success).toBe(false);
211
+ expect(body.error).toBe('Too Many Requests');
212
+ });
213
+
214
+ it('returns 429 with custom message', async () => {
215
+ const store = new MemoryRateLimitStore();
216
+ const mw = makeRateLimit({
217
+ max: 1, windowMs: 60_000, store, message: 'Slow down!',
218
+ });
219
+ const req = makeReq('GET', 'http://localhost/', [['x-forwarded-for', '10.0.0.3']]);
220
+ await mw.use(req, makeNext());
221
+ const res = await mw.use(req, makeNext());
222
+ const body = await res.json() as { error: string };
223
+ expect(body.error).toBe('Slow down!');
224
+ });
225
+
226
+ it('tracks keys independently', async () => {
227
+ const store = new MemoryRateLimitStore();
228
+ const mw = makeRateLimit({ max: 1, windowMs: 60_000, store });
229
+ const reqA = makeReq('GET', 'http://localhost/', [['x-forwarded-for', '1.1.1.1']]);
230
+ const reqB = makeReq('GET', 'http://localhost/', [['x-forwarded-for', '2.2.2.2']]);
231
+
232
+ await mw.use(reqA, makeNext()); // A: 1st
233
+ const resA2 = await mw.use(reqA, makeNext()); // A: 2nd — over limit
234
+ const resB1 = await mw.use(reqB, makeNext()); // B: 1st — OK
235
+
236
+ expect(resA2.status).toBe(429);
237
+ expect(resB1.status).toBe(200);
238
+ });
239
+
240
+ it('uses custom key generator', async () => {
241
+ const store = new MemoryRateLimitStore();
242
+ const mw = makeRateLimit({
243
+ max: 1,
244
+ windowMs: 60_000,
245
+ store,
246
+ keyGenerator: (req: OneBunRequest) => req.headers.get('x-api-key') ?? 'anon',
247
+ });
248
+ const req = makeReq('GET', 'http://localhost/', [['x-api-key', 'secret-key']]);
249
+ await mw.use(req, makeNext());
250
+ const res = await mw.use(req, makeNext());
251
+ expect(res.status).toBe(429);
252
+ });
253
+
254
+ it('adds legacy X-RateLimit-* headers when configured', async () => {
255
+ const store = new MemoryRateLimitStore();
256
+ const mw = makeRateLimit({
257
+ max: 10, windowMs: 60_000, store, legacyHeaders: true,
258
+ });
259
+ const req = makeReq('GET', 'http://localhost/', [['x-forwarded-for', '10.0.0.9']]);
260
+ const res = await mw.use(req, makeNext());
261
+ expect(res.headers.get('X-RateLimit-Limit')).toBe('10');
262
+ expect(res.headers.get('X-RateLimit-Remaining')).toBe('9');
263
+ });
264
+
265
+ it('configure() factory creates working middleware class', async () => {
266
+ const configuredClass = RateLimitMiddleware.configure({ max: 5, windowMs: 60_000 });
267
+ const mw = new configuredClass();
268
+ const req = makeReq('GET', 'http://localhost/', [['x-forwarded-for', '10.0.0.10']]);
269
+ const res = await mw.use(req, makeNext());
270
+ expect(res.status).toBe(200);
271
+ expect(res.headers.get('RateLimit-Limit')).toBe('5');
272
+ });
273
+
274
+ it('MemoryRateLimitStore.clear() resets counters', async () => {
275
+ const store = new MemoryRateLimitStore();
276
+ const mw = makeRateLimit({ max: 1, windowMs: 60_000, store });
277
+ const req = makeReq('GET', 'http://localhost/', [['x-forwarded-for', '10.0.0.11']]);
278
+ await mw.use(req, makeNext());
279
+ const blocked = await mw.use(req, makeNext());
280
+ expect(blocked.status).toBe(429);
281
+ store.clear();
282
+ const allowed = await mw.use(req, makeNext());
283
+ expect(allowed.status).toBe(200);
284
+ });
285
+ });
@@ -5,3 +5,4 @@
5
5
  */
6
6
 
7
7
  export * from './test-utils';
8
+ export * from './testing-module';
@@ -0,0 +1,199 @@
1
+ import {
2
+ afterEach,
3
+ beforeEach,
4
+ describe,
5
+ expect,
6
+ it,
7
+ } from 'bun:test';
8
+
9
+ import {
10
+ Controller,
11
+ Get,
12
+ Module,
13
+ Post,
14
+ Body,
15
+ Param,
16
+ } from '../decorators/decorators';
17
+ import { Controller as BaseController } from '../module/controller';
18
+ import { BaseService, Service } from '../module/service';
19
+
20
+ import { TestingModule, type CompiledTestingModule } from './testing-module';
21
+
22
+ // ============================================================================
23
+ // Test fixtures
24
+ // ============================================================================
25
+
26
+ @Service()
27
+ class GreetingService extends BaseService {
28
+ greet(name: string): string {
29
+ return `Hello, ${name}!`;
30
+ }
31
+ }
32
+
33
+ @Controller('/greet')
34
+ class GreetController extends BaseController {
35
+ constructor(private readonly greetingService: GreetingService) {
36
+ super();
37
+ }
38
+
39
+ @Get('/:name')
40
+ getGreeting(@Param('name') name: string) {
41
+ return { message: this.greetingService.greet(name) };
42
+ }
43
+
44
+ @Post('/echo')
45
+ echo(@Body() body: unknown) {
46
+ return body;
47
+ }
48
+ }
49
+
50
+ @Module({
51
+ controllers: [GreetController],
52
+ providers: [GreetingService],
53
+ })
54
+ class GreetModule {}
55
+
56
+ // ============================================================================
57
+ // Tests
58
+ // ============================================================================
59
+
60
+ describe('TestingModule', () => {
61
+ describe('compile()', () => {
62
+ it('starts the application on a random port', async () => {
63
+ const module = await TestingModule.create({
64
+ imports: [],
65
+ controllers: [GreetController],
66
+ providers: [GreetingService],
67
+ }).compile();
68
+
69
+ try {
70
+ const response = await module.inject('GET', '/greet/world');
71
+ expect(response.status).toBe(200);
72
+ } finally {
73
+ await module.close();
74
+ }
75
+ });
76
+
77
+ it('works with a pre-decorated module via imports', async () => {
78
+ const module = await TestingModule.create({
79
+ imports: [GreetModule],
80
+ }).compile();
81
+
82
+ try {
83
+ const response = await module.inject('GET', '/greet/test');
84
+ expect(response.status).toBe(200);
85
+ } finally {
86
+ await module.close();
87
+ }
88
+ });
89
+ });
90
+
91
+ describe('inject()', () => {
92
+ let module: CompiledTestingModule;
93
+
94
+ beforeEach(async () => {
95
+ module = await TestingModule.create({
96
+ controllers: [GreetController],
97
+ providers: [GreetingService],
98
+ }).compile();
99
+ });
100
+
101
+ afterEach(async () => {
102
+ await module.close();
103
+ });
104
+
105
+ it('GET request returns JSON body from controller', async () => {
106
+ const response = await module.inject('GET', '/greet/alice');
107
+ const body = await response.json() as { result: { message: string } };
108
+
109
+ expect(body.result.message).toBe('Hello, alice!');
110
+ });
111
+
112
+ it('POST request passes body to handler', async () => {
113
+ const response = await module.inject('POST', '/greet/echo', { body: { ping: 'pong' } });
114
+ const body = await response.json() as { result: { ping: string } };
115
+
116
+ expect(body.result.ping).toBe('pong');
117
+ });
118
+
119
+ it('returns 404 for unknown routes', async () => {
120
+ const response = await module.inject('GET', '/unknown/path');
121
+
122
+ expect(response.status).toBe(404);
123
+ });
124
+
125
+ it('supports query parameters', async () => {
126
+ const response = await module.inject('GET', '/greet/world', { query: { lang: 'en' } });
127
+ // Just verifies the request doesn't crash (query is ignored by this handler)
128
+ expect(response.status).toBe(200);
129
+ });
130
+ });
131
+
132
+ describe('get()', () => {
133
+ it('retrieves a service instance by class', async () => {
134
+ const module = await TestingModule.create({
135
+ controllers: [GreetController],
136
+ providers: [GreetingService],
137
+ }).compile();
138
+
139
+ try {
140
+ const service = module.get(GreetingService);
141
+ expect(service).toBeInstanceOf(GreetingService);
142
+ expect(service.greet('test')).toBe('Hello, test!');
143
+ } finally {
144
+ await module.close();
145
+ }
146
+ });
147
+ });
148
+
149
+ describe('overrideProvider()', () => {
150
+ it('useValue() replaces service so controller uses mock', async () => {
151
+ const mockService = {
152
+ greet: (_name: string) => 'Mocked greeting!',
153
+ };
154
+
155
+ const module = await TestingModule
156
+ .create({
157
+ controllers: [GreetController],
158
+ providers: [GreetingService],
159
+ })
160
+ .overrideProvider(GreetingService).useValue(mockService)
161
+ .compile();
162
+
163
+ try {
164
+ const response = await module.inject('GET', '/greet/anyone');
165
+ const body = await response.json() as { result: { message: string } };
166
+
167
+ expect(body.result.message).toBe('Mocked greeting!');
168
+ } finally {
169
+ await module.close();
170
+ }
171
+ });
172
+
173
+ it('useClass() replaces service with instance of provided class', async () => {
174
+ @Service()
175
+ class MockGreetingService extends BaseService {
176
+ greet(_name: string): string {
177
+ return 'Class mock!';
178
+ }
179
+ }
180
+
181
+ const module = await TestingModule
182
+ .create({
183
+ controllers: [GreetController],
184
+ providers: [GreetingService],
185
+ })
186
+ .overrideProvider(GreetingService).useClass(MockGreetingService)
187
+ .compile();
188
+
189
+ try {
190
+ const response = await module.inject('GET', '/greet/anyone');
191
+ const body = await response.json() as { result: { message: string } };
192
+
193
+ expect(body.result.message).toBe('Class mock!');
194
+ } finally {
195
+ await module.close();
196
+ }
197
+ });
198
+ });
199
+ });