@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.
- package/package.json +6 -6
- package/src/application/application.test.ts +350 -7
- package/src/application/application.ts +537 -254
- package/src/application/multi-service-application.test.ts +15 -0
- package/src/application/multi-service-application.ts +2 -0
- package/src/application/multi-service.types.ts +7 -1
- package/src/decorators/decorators.ts +213 -0
- package/src/docs-examples.test.ts +386 -3
- package/src/exception-filters/exception-filters.test.ts +172 -0
- package/src/exception-filters/exception-filters.ts +129 -0
- package/src/exception-filters/http-exception.ts +22 -0
- package/src/exception-filters/index.ts +2 -0
- package/src/file/onebun-file.ts +8 -2
- package/src/http-guards/http-guards.test.ts +230 -0
- package/src/http-guards/http-guards.ts +173 -0
- package/src/http-guards/index.ts +1 -0
- package/src/index.ts +10 -0
- package/src/module/module.test.ts +78 -0
- package/src/module/module.ts +55 -7
- package/src/queue/docs-examples.test.ts +72 -12
- package/src/queue/index.ts +4 -0
- package/src/queue/queue-service-proxy.test.ts +82 -0
- package/src/queue/queue-service-proxy.ts +114 -0
- package/src/queue/types.ts +2 -2
- package/src/security/cors-middleware.ts +212 -0
- package/src/security/index.ts +19 -0
- package/src/security/rate-limit-middleware.ts +276 -0
- package/src/security/security-headers-middleware.ts +188 -0
- package/src/security/security.test.ts +285 -0
- package/src/testing/index.ts +1 -0
- package/src/testing/testing-module.test.ts +199 -0
- package/src/testing/testing-module.ts +252 -0
- package/src/types.ts +153 -3
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
* - docs/api/services.md
|
|
10
10
|
* - docs/api/validation.md
|
|
11
11
|
* - docs/api/websocket.md
|
|
12
|
+
* - docs/api/guards.md
|
|
13
|
+
* - docs/api/exception-filters.md
|
|
14
|
+
* - docs/api/security.md
|
|
12
15
|
* - docs/examples/basic-app.md
|
|
13
16
|
* - docs/examples/crud-api.md
|
|
14
17
|
* - docs/examples/websocket-chat.md
|
|
@@ -33,6 +36,7 @@ import type {
|
|
|
33
36
|
BeforeApplicationDestroy,
|
|
34
37
|
OnApplicationDestroy,
|
|
35
38
|
} from './';
|
|
39
|
+
import type { ExceptionFilter } from './exception-filters/exception-filters';
|
|
36
40
|
import type {
|
|
37
41
|
SseEvent,
|
|
38
42
|
SseGenerator,
|
|
@@ -41,6 +45,7 @@ import type {
|
|
|
41
45
|
MiddlewareClass,
|
|
42
46
|
OnModuleConfigure,
|
|
43
47
|
} from './types';
|
|
48
|
+
import type { HttpExecutionContext } from './types';
|
|
44
49
|
import type { ServerWebSocket } from 'bun';
|
|
45
50
|
|
|
46
51
|
import {
|
|
@@ -125,6 +130,19 @@ import {
|
|
|
125
130
|
DEFAULT_IDLE_TIMEOUT,
|
|
126
131
|
DEFAULT_SSE_HEARTBEAT_MS,
|
|
127
132
|
DEFAULT_SSE_TIMEOUT,
|
|
133
|
+
UseGuards,
|
|
134
|
+
AuthGuard,
|
|
135
|
+
RolesGuard,
|
|
136
|
+
createHttpGuard,
|
|
137
|
+
HttpExecutionContextImpl,
|
|
138
|
+
UseFilters,
|
|
139
|
+
createExceptionFilter,
|
|
140
|
+
defaultExceptionFilter,
|
|
141
|
+
HttpException,
|
|
142
|
+
CorsMiddleware,
|
|
143
|
+
RateLimitMiddleware,
|
|
144
|
+
MemoryRateLimitStore,
|
|
145
|
+
SecurityHeadersMiddleware,
|
|
128
146
|
} from './';
|
|
129
147
|
|
|
130
148
|
|
|
@@ -1096,7 +1114,7 @@ describe('Middleware API Documentation Examples (docs/api/controllers.md)', () =
|
|
|
1096
1114
|
}
|
|
1097
1115
|
}
|
|
1098
1116
|
|
|
1099
|
-
class
|
|
1117
|
+
class ExampleCorsMiddleware extends BaseMiddleware {
|
|
1100
1118
|
async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
|
|
1101
1119
|
const response = await next();
|
|
1102
1120
|
response.headers.set('Access-Control-Allow-Origin', '*');
|
|
@@ -1115,7 +1133,7 @@ describe('Middleware API Documentation Examples (docs/api/controllers.md)', () =
|
|
|
1115
1133
|
|
|
1116
1134
|
const app = new OneBunApplication(AppModule, {
|
|
1117
1135
|
port: 0,
|
|
1118
|
-
middleware: [RequestIdMiddleware,
|
|
1136
|
+
middleware: [RequestIdMiddleware, ExampleCorsMiddleware],
|
|
1119
1137
|
});
|
|
1120
1138
|
|
|
1121
1139
|
expect(app).toBeDefined();
|
|
@@ -1648,7 +1666,7 @@ describe('Lifecycle Hooks API Documentation Examples (docs/api/services.md)', ()
|
|
|
1648
1666
|
|
|
1649
1667
|
describe('Controller Lifecycle Hooks', () => {
|
|
1650
1668
|
/**
|
|
1651
|
-
* @source docs/api/
|
|
1669
|
+
* @source docs/api/services.md#lifecycle-hooks (controllers support the same hooks)
|
|
1652
1670
|
*/
|
|
1653
1671
|
it('should implement lifecycle hooks in controllers', () => {
|
|
1654
1672
|
// From docs: Controller lifecycle hooks example
|
|
@@ -1855,6 +1873,67 @@ describe('Lifecycle Hooks API Documentation Examples (docs/api/services.md)', ()
|
|
|
1855
1873
|
// Database initialized first, then cache saw database was ready
|
|
1856
1874
|
expect(initOrder).toEqual(['database', 'cache:db-ready=true']);
|
|
1857
1875
|
});
|
|
1876
|
+
|
|
1877
|
+
/**
|
|
1878
|
+
* @source docs/api/services.md#lifecycle-hooks
|
|
1879
|
+
* onModuleInit is called for services in ALL modules across the entire
|
|
1880
|
+
* import tree, not just the root module. Deeply nested modules are
|
|
1881
|
+
* initialized in depth-first order.
|
|
1882
|
+
*/
|
|
1883
|
+
it('should call onModuleInit for services across the entire module import tree', async () => {
|
|
1884
|
+
const moduleMod = await import('./module/module');
|
|
1885
|
+
const testUtils = await import('./testing/test-utils');
|
|
1886
|
+
const effectLib = await import('effect');
|
|
1887
|
+
|
|
1888
|
+
const initLog: string[] = [];
|
|
1889
|
+
|
|
1890
|
+
@Service()
|
|
1891
|
+
class AuthService extends BaseService implements OnModuleInit {
|
|
1892
|
+
async onModuleInit(): Promise<void> {
|
|
1893
|
+
initLog.push('auth');
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
@Module({
|
|
1898
|
+
providers: [AuthService],
|
|
1899
|
+
})
|
|
1900
|
+
class AuthModule {}
|
|
1901
|
+
|
|
1902
|
+
@Service()
|
|
1903
|
+
class UserService extends BaseService implements OnModuleInit {
|
|
1904
|
+
async onModuleInit(): Promise<void> {
|
|
1905
|
+
initLog.push('user');
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
@Module({
|
|
1910
|
+
imports: [AuthModule],
|
|
1911
|
+
providers: [UserService],
|
|
1912
|
+
})
|
|
1913
|
+
class UserModule {}
|
|
1914
|
+
|
|
1915
|
+
@Service()
|
|
1916
|
+
class AppService extends BaseService implements OnModuleInit {
|
|
1917
|
+
async onModuleInit(): Promise<void> {
|
|
1918
|
+
initLog.push('app');
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
@Module({
|
|
1923
|
+
imports: [UserModule],
|
|
1924
|
+
providers: [AppService],
|
|
1925
|
+
})
|
|
1926
|
+
class AppModule {}
|
|
1927
|
+
|
|
1928
|
+
const mod = new moduleMod.OneBunModule(AppModule, testUtils.makeMockLoggerLayer());
|
|
1929
|
+
await effectLib.Effect.runPromise(mod.setup() as import('effect').Effect.Effect<unknown, never, never>);
|
|
1930
|
+
|
|
1931
|
+
// All three modules' services should have onModuleInit called
|
|
1932
|
+
expect(initLog).toContain('auth');
|
|
1933
|
+
expect(initLog).toContain('user');
|
|
1934
|
+
expect(initLog).toContain('app');
|
|
1935
|
+
expect(initLog.length).toBe(3);
|
|
1936
|
+
});
|
|
1858
1937
|
});
|
|
1859
1938
|
});
|
|
1860
1939
|
|
|
@@ -2406,6 +2485,34 @@ describe('OneBunApplication (docs/api/core.md)', () => {
|
|
|
2406
2485
|
const app = new OneBunApplication(AppModule, { tracing: tracingOptions, loggerLayer: makeMockLoggerLayer() });
|
|
2407
2486
|
expect(app).toBeDefined();
|
|
2408
2487
|
});
|
|
2488
|
+
|
|
2489
|
+
/**
|
|
2490
|
+
* @source docs/api/core.md#staticapplicationoptions
|
|
2491
|
+
*/
|
|
2492
|
+
it('should accept static file serving configuration (SPA on same host)', async () => {
|
|
2493
|
+
const fs = await import('node:fs');
|
|
2494
|
+
const path = await import('node:path');
|
|
2495
|
+
const os = await import('node:os');
|
|
2496
|
+
|
|
2497
|
+
const tmpDir = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'onebun-docs-static-'));
|
|
2498
|
+
try {
|
|
2499
|
+
fs.writeFileSync(path.join(tmpDir, 'index.html'), '<!DOCTYPE html><html><body>SPA</body></html>', 'utf8');
|
|
2500
|
+
|
|
2501
|
+
@Module({ controllers: [] })
|
|
2502
|
+
class AppModule {}
|
|
2503
|
+
|
|
2504
|
+
// From docs: Static files (SPA on same host)
|
|
2505
|
+
const app = new OneBunApplication(AppModule, {
|
|
2506
|
+
loggerLayer: makeMockLoggerLayer(),
|
|
2507
|
+
static: { root: tmpDir, fallbackFile: 'index.html' },
|
|
2508
|
+
});
|
|
2509
|
+
expect(app).toBeDefined();
|
|
2510
|
+
await app.start();
|
|
2511
|
+
await app.stop();
|
|
2512
|
+
} finally {
|
|
2513
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
2514
|
+
}
|
|
2515
|
+
});
|
|
2409
2516
|
});
|
|
2410
2517
|
|
|
2411
2518
|
describe('MultiServiceApplication (docs/api/core.md)', () => {
|
|
@@ -5322,3 +5429,279 @@ describe('File Upload API Documentation (docs/api/controllers.md)', () => {
|
|
|
5322
5429
|
expect(FileController).toBeDefined();
|
|
5323
5430
|
});
|
|
5324
5431
|
});
|
|
5432
|
+
|
|
5433
|
+
// ============================================================================
|
|
5434
|
+
// docs/api/guards.md examples
|
|
5435
|
+
// ============================================================================
|
|
5436
|
+
|
|
5437
|
+
describe('docs/api/guards.md', () => {
|
|
5438
|
+
it('createHttpGuard — function-based guard returns a class constructor', () => {
|
|
5439
|
+
const apiKeyGuardClass = createHttpGuard((ctx) => {
|
|
5440
|
+
return ctx.getRequest().headers.get('x-api-key') !== null;
|
|
5441
|
+
});
|
|
5442
|
+
|
|
5443
|
+
expect(apiKeyGuardClass).toBeDefined();
|
|
5444
|
+
// createHttpGuard returns a constructor — instantiate to get the guard instance
|
|
5445
|
+
const instance = new apiKeyGuardClass();
|
|
5446
|
+
expect(typeof instance.canActivate).toBe('function');
|
|
5447
|
+
});
|
|
5448
|
+
|
|
5449
|
+
it('AuthGuard blocks request without Bearer token', async () => {
|
|
5450
|
+
const guard = new AuthGuard();
|
|
5451
|
+
const req = new Request('http://localhost/') as unknown as OneBunRequest;
|
|
5452
|
+
const ctx = new HttpExecutionContextImpl(req, 'handler', 'Controller');
|
|
5453
|
+
|
|
5454
|
+
expect(await guard.canActivate(ctx)).toBe(false);
|
|
5455
|
+
});
|
|
5456
|
+
|
|
5457
|
+
it('AuthGuard allows request with Bearer token', async () => {
|
|
5458
|
+
const guard = new AuthGuard();
|
|
5459
|
+
const req = new Request('http://localhost/', {
|
|
5460
|
+
headers: { authorization: 'Bearer my-token' },
|
|
5461
|
+
}) as unknown as OneBunRequest;
|
|
5462
|
+
const ctx = new HttpExecutionContextImpl(req, 'handler', 'Controller');
|
|
5463
|
+
|
|
5464
|
+
expect(await guard.canActivate(ctx)).toBe(true);
|
|
5465
|
+
});
|
|
5466
|
+
|
|
5467
|
+
it('RolesGuard checks x-user-roles header', async () => {
|
|
5468
|
+
const guard = new RolesGuard(['admin']);
|
|
5469
|
+
const h = new Headers();
|
|
5470
|
+
h.set('x-user-roles', 'admin,user');
|
|
5471
|
+
const req = new Request('http://localhost/', { headers: h }) as unknown as OneBunRequest;
|
|
5472
|
+
const ctx = new HttpExecutionContextImpl(req, 'handler', 'Controller');
|
|
5473
|
+
|
|
5474
|
+
expect(await guard.canActivate(ctx)).toBe(true);
|
|
5475
|
+
});
|
|
5476
|
+
|
|
5477
|
+
it('RolesGuard rejects when role not present', async () => {
|
|
5478
|
+
const guard = new RolesGuard(['admin']);
|
|
5479
|
+
const h = new Headers();
|
|
5480
|
+
h.set('x-user-roles', 'user');
|
|
5481
|
+
const req = new Request('http://localhost/', { headers: h }) as unknown as OneBunRequest;
|
|
5482
|
+
const ctx = new HttpExecutionContextImpl(req, 'handler', 'Controller');
|
|
5483
|
+
|
|
5484
|
+
expect(await guard.canActivate(ctx)).toBe(false);
|
|
5485
|
+
});
|
|
5486
|
+
|
|
5487
|
+
it('RolesGuard with custom role extractor', async () => {
|
|
5488
|
+
const guard = new RolesGuard(
|
|
5489
|
+
['admin'],
|
|
5490
|
+
(ctx) => {
|
|
5491
|
+
const raw = ctx.getRequest().headers.get('x-custom-roles');
|
|
5492
|
+
|
|
5493
|
+
return raw ? raw.split(':') : [];
|
|
5494
|
+
},
|
|
5495
|
+
);
|
|
5496
|
+
const h = new Headers();
|
|
5497
|
+
h.set('x-custom-roles', 'admin:editor');
|
|
5498
|
+
const req = new Request('http://localhost/', { headers: h }) as unknown as OneBunRequest;
|
|
5499
|
+
const ctx = new HttpExecutionContextImpl(req, 'handler', 'Controller');
|
|
5500
|
+
|
|
5501
|
+
expect(await guard.canActivate(ctx)).toBe(true);
|
|
5502
|
+
});
|
|
5503
|
+
|
|
5504
|
+
it('@UseGuards applies metadata to controller', () => {
|
|
5505
|
+
@UseGuards(AuthGuard)
|
|
5506
|
+
@Controller('/protected')
|
|
5507
|
+
class ProtectedController extends BaseController {
|
|
5508
|
+
@Get('/')
|
|
5509
|
+
index() {
|
|
5510
|
+
return { message: 'authenticated' };
|
|
5511
|
+
}
|
|
5512
|
+
}
|
|
5513
|
+
|
|
5514
|
+
expect(ProtectedController).toBeDefined();
|
|
5515
|
+
});
|
|
5516
|
+
});
|
|
5517
|
+
|
|
5518
|
+
// ============================================================================
|
|
5519
|
+
// docs/api/exception-filters.md examples
|
|
5520
|
+
// ============================================================================
|
|
5521
|
+
|
|
5522
|
+
describe('docs/api/exception-filters.md', () => {
|
|
5523
|
+
it('createExceptionFilter — function-based filter', async () => {
|
|
5524
|
+
const filter = createExceptionFilter((error, _ctx) => {
|
|
5525
|
+
if (error instanceof Error) {
|
|
5526
|
+
return new Response(JSON.stringify({ caught: error.message }), { status: 200 });
|
|
5527
|
+
}
|
|
5528
|
+
throw error;
|
|
5529
|
+
});
|
|
5530
|
+
|
|
5531
|
+
expect(filter).toBeDefined();
|
|
5532
|
+
const req = new Request('http://localhost/') as unknown as OneBunRequest;
|
|
5533
|
+
const ctx = new HttpExecutionContextImpl(req, 'handler', 'Controller');
|
|
5534
|
+
const res = await filter.catch(new Error('boom'), ctx);
|
|
5535
|
+
const body = await res.json() as { caught: string };
|
|
5536
|
+
|
|
5537
|
+
expect(body.caught).toBe('boom');
|
|
5538
|
+
});
|
|
5539
|
+
|
|
5540
|
+
it('class-based filter — re-throws unknown errors', async () => {
|
|
5541
|
+
class TypedFilter implements ExceptionFilter {
|
|
5542
|
+
catch(error: unknown, _ctx: HttpExecutionContext): Response {
|
|
5543
|
+
if (error instanceof RangeError) {
|
|
5544
|
+
return Response.json({ success: false, error: 'range' });
|
|
5545
|
+
}
|
|
5546
|
+
throw error;
|
|
5547
|
+
}
|
|
5548
|
+
}
|
|
5549
|
+
|
|
5550
|
+
const filter = new TypedFilter();
|
|
5551
|
+
const req = new Request('http://localhost/') as unknown as OneBunRequest;
|
|
5552
|
+
const ctx = new HttpExecutionContextImpl(req, 'handler', 'Controller');
|
|
5553
|
+
|
|
5554
|
+
const res = await filter.catch(new RangeError('out of range'), ctx);
|
|
5555
|
+
const body = await res.json() as { success: boolean; error: string };
|
|
5556
|
+
|
|
5557
|
+
expect(body.success).toBe(false);
|
|
5558
|
+
expect(body.error).toBe('range');
|
|
5559
|
+
});
|
|
5560
|
+
|
|
5561
|
+
it('defaultExceptionFilter handles OneBunBaseError subclass', async () => {
|
|
5562
|
+
const req = new Request('http://localhost/') as unknown as OneBunRequest;
|
|
5563
|
+
const ctx = new HttpExecutionContextImpl(req, 'handler', 'Controller');
|
|
5564
|
+
const res = await defaultExceptionFilter.catch(new NotFoundError('not found'), ctx);
|
|
5565
|
+
const body = await res.json() as { success: boolean };
|
|
5566
|
+
|
|
5567
|
+
expect(body.success).toBe(false);
|
|
5568
|
+
});
|
|
5569
|
+
|
|
5570
|
+
it('async filter resolves correctly', async () => {
|
|
5571
|
+
const asyncFilter = createExceptionFilter(async (error, _ctx) => {
|
|
5572
|
+
await Promise.resolve(); // simulate async work
|
|
5573
|
+
|
|
5574
|
+
return new Response(JSON.stringify({ async: true, msg: String(error) }));
|
|
5575
|
+
});
|
|
5576
|
+
|
|
5577
|
+
const req = new Request('http://localhost/') as unknown as OneBunRequest;
|
|
5578
|
+
const ctx = new HttpExecutionContextImpl(req, 'handler', 'Controller');
|
|
5579
|
+
const res = await asyncFilter.catch(new Error('err'), ctx);
|
|
5580
|
+
const body = await res.json() as { async: boolean };
|
|
5581
|
+
|
|
5582
|
+
expect(body.async).toBe(true);
|
|
5583
|
+
});
|
|
5584
|
+
|
|
5585
|
+
it('HttpException — carries statusCode', () => {
|
|
5586
|
+
const ex = new HttpException(400, 'Bad request');
|
|
5587
|
+
|
|
5588
|
+
expect(ex).toBeInstanceOf(Error);
|
|
5589
|
+
expect(ex.statusCode).toBe(400);
|
|
5590
|
+
expect(ex.message).toBe('Bad request');
|
|
5591
|
+
});
|
|
5592
|
+
|
|
5593
|
+
it('defaultExceptionFilter returns real HTTP status for HttpException', async () => {
|
|
5594
|
+
const req = new Request('http://localhost/') as unknown as OneBunRequest;
|
|
5595
|
+
const ctx = new HttpExecutionContextImpl(req, 'handler', 'Controller');
|
|
5596
|
+
const res = await defaultExceptionFilter.catch(
|
|
5597
|
+
new HttpException(404, 'Not found'),
|
|
5598
|
+
ctx,
|
|
5599
|
+
);
|
|
5600
|
+
|
|
5601
|
+
expect(res.status).toBe(404);
|
|
5602
|
+
const body = await res.json() as { success: boolean; error: string };
|
|
5603
|
+
expect(body.success).toBe(false);
|
|
5604
|
+
expect(body.error).toBe('Not found');
|
|
5605
|
+
});
|
|
5606
|
+
|
|
5607
|
+
it('@UseFilters applies metadata to controller', () => {
|
|
5608
|
+
const filter = createExceptionFilter((err, _ctx) => {
|
|
5609
|
+
throw err;
|
|
5610
|
+
});
|
|
5611
|
+
|
|
5612
|
+
@UseFilters(filter)
|
|
5613
|
+
@Controller('/filtered')
|
|
5614
|
+
class FilteredController extends BaseController {
|
|
5615
|
+
@Get('/')
|
|
5616
|
+
index() {
|
|
5617
|
+
return {};
|
|
5618
|
+
}
|
|
5619
|
+
}
|
|
5620
|
+
|
|
5621
|
+
expect(FilteredController).toBeDefined();
|
|
5622
|
+
});
|
|
5623
|
+
});
|
|
5624
|
+
|
|
5625
|
+
// ============================================================================
|
|
5626
|
+
// docs/api/security.md examples
|
|
5627
|
+
// ============================================================================
|
|
5628
|
+
|
|
5629
|
+
describe('docs/api/security.md', () => {
|
|
5630
|
+
it('CorsMiddleware — default wildcard origin', async () => {
|
|
5631
|
+
const mw = new CorsMiddleware();
|
|
5632
|
+
const req = new Request('http://localhost/', {
|
|
5633
|
+
headers: { origin: 'https://example.com' },
|
|
5634
|
+
}) as unknown as OneBunRequest;
|
|
5635
|
+
|
|
5636
|
+
const res = await mw.use(req, async () => new Response('ok'));
|
|
5637
|
+
|
|
5638
|
+
expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*');
|
|
5639
|
+
});
|
|
5640
|
+
|
|
5641
|
+
it('CorsMiddleware — preflight returns 204', async () => {
|
|
5642
|
+
const mw = new CorsMiddleware();
|
|
5643
|
+
const req = new Request('http://localhost/', {
|
|
5644
|
+
method: 'OPTIONS',
|
|
5645
|
+
headers: { origin: 'https://example.com' },
|
|
5646
|
+
}) as unknown as OneBunRequest;
|
|
5647
|
+
|
|
5648
|
+
const res = await mw.use(req, async () => new Response('ok'));
|
|
5649
|
+
|
|
5650
|
+
expect(res.status).toBe(204);
|
|
5651
|
+
});
|
|
5652
|
+
|
|
5653
|
+
it('CorsMiddleware.configure() factory', async () => {
|
|
5654
|
+
const configuredClass = CorsMiddleware.configure({ origin: 'https://trusted.com' });
|
|
5655
|
+
const mw = new configuredClass();
|
|
5656
|
+
const req = new Request('http://localhost/', {
|
|
5657
|
+
headers: { origin: 'https://trusted.com' },
|
|
5658
|
+
}) as unknown as OneBunRequest;
|
|
5659
|
+
|
|
5660
|
+
const res = await mw.use(req, async () => new Response('ok'));
|
|
5661
|
+
|
|
5662
|
+
expect(res.headers.get('Access-Control-Allow-Origin')).toBe('https://trusted.com');
|
|
5663
|
+
});
|
|
5664
|
+
|
|
5665
|
+
it('RateLimitMiddleware — allows below max', async () => {
|
|
5666
|
+
const store = new MemoryRateLimitStore();
|
|
5667
|
+
const mw = new RateLimitMiddleware({ max: 5, windowMs: 60_000, store });
|
|
5668
|
+
const h1 = new Headers();
|
|
5669
|
+
h1.set('x-forwarded-for', '1.2.3.4');
|
|
5670
|
+
const req = new Request('http://localhost/', { headers: h1 }) as unknown as OneBunRequest;
|
|
5671
|
+
|
|
5672
|
+
const res = await mw.use(req, async () => new Response('ok'));
|
|
5673
|
+
|
|
5674
|
+
expect(res.status).toBe(200);
|
|
5675
|
+
});
|
|
5676
|
+
|
|
5677
|
+
it('RateLimitMiddleware — returns 429 when over limit', async () => {
|
|
5678
|
+
const store = new MemoryRateLimitStore();
|
|
5679
|
+
const mw = new RateLimitMiddleware({ max: 1, windowMs: 60_000, store });
|
|
5680
|
+
const h2 = new Headers();
|
|
5681
|
+
h2.set('x-forwarded-for', '9.9.9.9');
|
|
5682
|
+
const req = new Request('http://localhost/', { headers: h2 }) as unknown as OneBunRequest;
|
|
5683
|
+
await mw.use(req, async () => new Response('ok'));
|
|
5684
|
+
const res = await mw.use(req, async () => new Response('ok'));
|
|
5685
|
+
|
|
5686
|
+
expect(res.status).toBe(429);
|
|
5687
|
+
});
|
|
5688
|
+
|
|
5689
|
+
it('SecurityHeadersMiddleware — sets X-Frame-Options', async () => {
|
|
5690
|
+
const mw = new SecurityHeadersMiddleware();
|
|
5691
|
+
const req = new Request('http://localhost/') as unknown as OneBunRequest;
|
|
5692
|
+
|
|
5693
|
+
const res = await mw.use(req, async () => new Response('ok'));
|
|
5694
|
+
|
|
5695
|
+
expect(res.headers.get('X-Frame-Options')).toBe('SAMEORIGIN');
|
|
5696
|
+
expect(res.headers.get('X-Content-Type-Options')).toBe('nosniff');
|
|
5697
|
+
});
|
|
5698
|
+
|
|
5699
|
+
it('SecurityHeadersMiddleware — disabled header is absent', async () => {
|
|
5700
|
+
const mw = new SecurityHeadersMiddleware({ strictTransportSecurity: false });
|
|
5701
|
+
const req = new Request('http://localhost/') as unknown as OneBunRequest;
|
|
5702
|
+
|
|
5703
|
+
const res = await mw.use(req, async () => new Response('ok'));
|
|
5704
|
+
|
|
5705
|
+
expect(res.headers.get('Strict-Transport-Security')).toBeNull();
|
|
5706
|
+
});
|
|
5707
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import {
|
|
2
|
+
describe,
|
|
3
|
+
expect,
|
|
4
|
+
it,
|
|
5
|
+
} from 'bun:test';
|
|
6
|
+
|
|
7
|
+
import type { OneBunRequest } from '../types';
|
|
8
|
+
|
|
9
|
+
import { NotFoundError, HttpStatusCode } from '@onebun/requests';
|
|
10
|
+
|
|
11
|
+
import { HttpExecutionContextImpl } from '../http-guards/http-guards';
|
|
12
|
+
|
|
13
|
+
import { createExceptionFilter, defaultExceptionFilter } from './exception-filters';
|
|
14
|
+
import { HttpException } from './http-exception';
|
|
15
|
+
|
|
16
|
+
// ============================================================================
|
|
17
|
+
// Helpers
|
|
18
|
+
// ============================================================================
|
|
19
|
+
|
|
20
|
+
function makeContext(): HttpExecutionContextImpl {
|
|
21
|
+
const req = new Request('http://localhost/test') as unknown as OneBunRequest;
|
|
22
|
+
|
|
23
|
+
return new HttpExecutionContextImpl(req, 'testHandler', 'TestController');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ============================================================================
|
|
27
|
+
// HttpException
|
|
28
|
+
// ============================================================================
|
|
29
|
+
|
|
30
|
+
describe('HttpException', () => {
|
|
31
|
+
it('stores statusCode and message', () => {
|
|
32
|
+
const ex = new HttpException(400, 'Bad request');
|
|
33
|
+
expect(ex).toBeInstanceOf(Error);
|
|
34
|
+
expect(ex.statusCode).toBe(400);
|
|
35
|
+
expect(ex.message).toBe('Bad request');
|
|
36
|
+
expect(ex.name).toBe('HttpException');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('works with instanceof check', () => {
|
|
40
|
+
const ex = new HttpException(404, 'Not found');
|
|
41
|
+
expect(ex instanceof HttpException).toBe(true);
|
|
42
|
+
expect(ex instanceof Error).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// ============================================================================
|
|
47
|
+
// createExceptionFilter
|
|
48
|
+
// ============================================================================
|
|
49
|
+
|
|
50
|
+
describe('createExceptionFilter', () => {
|
|
51
|
+
it('creates a filter that calls the provided function', async () => {
|
|
52
|
+
let caught: unknown;
|
|
53
|
+
const filter = createExceptionFilter((error, _ctx) => {
|
|
54
|
+
caught = error;
|
|
55
|
+
|
|
56
|
+
return new Response('handled', { status: 200 });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const err = new Error('boom');
|
|
60
|
+
const ctx = makeContext();
|
|
61
|
+
const response = await filter.catch(err, ctx);
|
|
62
|
+
|
|
63
|
+
expect(caught).toBe(err);
|
|
64
|
+
expect(response.status).toBe(200);
|
|
65
|
+
expect(await response.text()).toBe('handled');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('receives the execution context', async () => {
|
|
69
|
+
let capturedHandler = '';
|
|
70
|
+
let capturedController = '';
|
|
71
|
+
|
|
72
|
+
const filter = createExceptionFilter((_error, ctx) => {
|
|
73
|
+
capturedHandler = ctx.getHandler();
|
|
74
|
+
capturedController = ctx.getController();
|
|
75
|
+
|
|
76
|
+
return new Response('ok');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const ctx = makeContext();
|
|
80
|
+
await filter.catch(new Error('test'), ctx);
|
|
81
|
+
|
|
82
|
+
expect(capturedHandler).toBe('testHandler');
|
|
83
|
+
expect(capturedController).toBe('TestController');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('supports async filter functions', async () => {
|
|
87
|
+
const filter = createExceptionFilter(async () => {
|
|
88
|
+
await Promise.resolve();
|
|
89
|
+
|
|
90
|
+
return new Response('async', { status: 418 });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const response = await filter.catch(new Error('test'), makeContext());
|
|
94
|
+
|
|
95
|
+
expect(response.status).toBe(418);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// ============================================================================
|
|
100
|
+
// defaultExceptionFilter
|
|
101
|
+
// ============================================================================
|
|
102
|
+
|
|
103
|
+
describe('defaultExceptionFilter', () => {
|
|
104
|
+
it('returns HTTP 200 with serialised OneBunBaseError', async () => {
|
|
105
|
+
const error = new NotFoundError('Not found');
|
|
106
|
+
const response = await defaultExceptionFilter.catch(error, makeContext());
|
|
107
|
+
|
|
108
|
+
expect(response.status).toBe(HttpStatusCode.OK);
|
|
109
|
+
const body = await response.json() as { success: boolean };
|
|
110
|
+
expect(body.success).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('returns HTTP 200 with generic error details for plain Error', async () => {
|
|
114
|
+
const error = new Error('Something went wrong');
|
|
115
|
+
const response = await defaultExceptionFilter.catch(error, makeContext());
|
|
116
|
+
|
|
117
|
+
expect(response.status).toBe(HttpStatusCode.OK);
|
|
118
|
+
const body = await response.json() as { success: boolean; error: string };
|
|
119
|
+
expect(body.success).toBe(false);
|
|
120
|
+
expect(body.error).toBe('Something went wrong');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('returns HTTP 200 for non-Error values', async () => {
|
|
124
|
+
const response = await defaultExceptionFilter.catch('string error', makeContext());
|
|
125
|
+
|
|
126
|
+
expect(response.status).toBe(HttpStatusCode.OK);
|
|
127
|
+
const body = await response.json() as { success: boolean; error: string };
|
|
128
|
+
expect(body.success).toBe(false);
|
|
129
|
+
expect(body.error).toBe('string error');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('sets Content-Type to application/json', async () => {
|
|
133
|
+
const response = await defaultExceptionFilter.catch(new Error('test'), makeContext());
|
|
134
|
+
|
|
135
|
+
expect(response.headers.get('content-type')).toContain('application/json');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('returns actual HTTP status for HttpException', async () => {
|
|
139
|
+
const error = new HttpException(400, 'Validation failed');
|
|
140
|
+
const response = await defaultExceptionFilter.catch(error, makeContext());
|
|
141
|
+
expect(response.status).toBe(400);
|
|
142
|
+
const body = await response.json() as { success: boolean; error: string };
|
|
143
|
+
expect(body.success).toBe(false);
|
|
144
|
+
expect(body.error).toBe('Validation failed');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('returns 404 for HttpException with 404 status', async () => {
|
|
148
|
+
const error = new HttpException(404, 'Not found');
|
|
149
|
+
const response = await defaultExceptionFilter.catch(error, makeContext());
|
|
150
|
+
expect(response.status).toBe(404);
|
|
151
|
+
const body = await response.json() as { success: boolean; error: string };
|
|
152
|
+
expect(body.success).toBe(false);
|
|
153
|
+
expect(body.error).toBe('Not found');
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// ============================================================================
|
|
158
|
+
// Validation error via HttpException (bug reproduction)
|
|
159
|
+
// ============================================================================
|
|
160
|
+
|
|
161
|
+
describe('Validation error via HttpException (bug reproduction)', () => {
|
|
162
|
+
it('returns 400 with JSON body for validation HttpException', async () => {
|
|
163
|
+
const error = new HttpException(400, 'Parameter body validation failed: name must be a string (was missing)');
|
|
164
|
+
const response = await defaultExceptionFilter.catch(error, makeContext());
|
|
165
|
+
|
|
166
|
+
expect(response.status).toBe(400);
|
|
167
|
+
const body = await response.json() as { success: boolean; error: string; statusCode: number };
|
|
168
|
+
expect(body.success).toBe(false);
|
|
169
|
+
expect(body.error).toContain('validation failed');
|
|
170
|
+
expect(response.headers.get('content-type')).toContain('application/json');
|
|
171
|
+
});
|
|
172
|
+
});
|