@onebun/core 0.2.7 → 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 +1 -1
- package/src/application/application.test.ts +6 -6
- package/src/application/application.ts +298 -210
- package/src/decorators/decorators.ts +213 -0
- package/src/docs-examples.test.ts +357 -2
- 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 +9 -0
- package/src/module/module.test.ts +78 -0
- package/src/module/module.ts +37 -7
- 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 +98 -0
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import './metadata'; // Import polyfill first
|
|
2
2
|
import { type, type Type } from 'arktype';
|
|
3
3
|
|
|
4
|
+
import type { ExceptionFilter } from '../exception-filters/exception-filters';
|
|
5
|
+
|
|
4
6
|
import {
|
|
5
7
|
type ControllerMetadata,
|
|
6
8
|
type FileUploadOptions,
|
|
7
9
|
type FilesUploadOptions,
|
|
8
10
|
HttpMethod,
|
|
11
|
+
type HttpGuard,
|
|
9
12
|
type ParamDecoratorOptions,
|
|
10
13
|
type ParamMetadata,
|
|
11
14
|
ParamType,
|
|
@@ -213,6 +216,30 @@ export function controllerDecorator(basePath: string = '') {
|
|
|
213
216
|
);
|
|
214
217
|
}
|
|
215
218
|
|
|
219
|
+
// Copy controller-level guards from original class to wrapped class
|
|
220
|
+
// This ensures @UseGuards works regardless of decorator order
|
|
221
|
+
const existingControllerGuards: (Function | HttpGuard)[] | undefined =
|
|
222
|
+
Reflect.getMetadata(HTTP_CONTROLLER_GUARDS_METADATA, target);
|
|
223
|
+
if (existingControllerGuards) {
|
|
224
|
+
Reflect.defineMetadata(
|
|
225
|
+
HTTP_CONTROLLER_GUARDS_METADATA,
|
|
226
|
+
existingControllerGuards,
|
|
227
|
+
WrappedController,
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Copy controller-level exception filters from original class to wrapped class
|
|
232
|
+
// This ensures @UseFilters works regardless of decorator order
|
|
233
|
+
const existingControllerFilters: ExceptionFilter[] | undefined =
|
|
234
|
+
Reflect.getMetadata(CONTROLLER_EXCEPTION_FILTERS_METADATA, target);
|
|
235
|
+
if (existingControllerFilters) {
|
|
236
|
+
Reflect.defineMetadata(
|
|
237
|
+
CONTROLLER_EXCEPTION_FILTERS_METADATA,
|
|
238
|
+
existingControllerFilters,
|
|
239
|
+
WrappedController,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
216
243
|
return WrappedController as T;
|
|
217
244
|
};
|
|
218
245
|
}
|
|
@@ -330,6 +357,14 @@ function createRouteDecorator(method: HttpMethod) {
|
|
|
330
357
|
const middleware: Function[] =
|
|
331
358
|
Reflect.getMetadata(MIDDLEWARE_METADATA, target, propertyKey) || [];
|
|
332
359
|
|
|
360
|
+
// Get HTTP guards metadata if exists
|
|
361
|
+
const guards: (Function | HttpGuard)[] =
|
|
362
|
+
Reflect.getMetadata(HTTP_GUARDS_METADATA, target, propertyKey) || [];
|
|
363
|
+
|
|
364
|
+
// Get exception filters metadata if exists
|
|
365
|
+
const filters: ExceptionFilter[] =
|
|
366
|
+
Reflect.getMetadata(EXCEPTION_FILTERS_METADATA, target, propertyKey) || [];
|
|
367
|
+
|
|
333
368
|
// Get response schemas metadata if exists
|
|
334
369
|
const responseSchemas: Array<{
|
|
335
370
|
statusCode: number;
|
|
@@ -343,6 +378,8 @@ function createRouteDecorator(method: HttpMethod) {
|
|
|
343
378
|
handler: propertyKey,
|
|
344
379
|
params,
|
|
345
380
|
middleware,
|
|
381
|
+
guards,
|
|
382
|
+
filters,
|
|
346
383
|
responseSchemas: responseSchemas.map((rs) => ({
|
|
347
384
|
statusCode: rs.statusCode,
|
|
348
385
|
schema: rs.schema,
|
|
@@ -679,6 +716,26 @@ export function FormField(fieldName: string, options?: ParamDecoratorOptions): P
|
|
|
679
716
|
*/
|
|
680
717
|
const CONTROLLER_MIDDLEWARE_METADATA = 'onebun:controller_middleware';
|
|
681
718
|
|
|
719
|
+
/**
|
|
720
|
+
* Metadata key for route-level HTTP guards
|
|
721
|
+
*/
|
|
722
|
+
const HTTP_GUARDS_METADATA = 'onebun:http_guards';
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* Metadata key for controller-level HTTP guards
|
|
726
|
+
*/
|
|
727
|
+
const HTTP_CONTROLLER_GUARDS_METADATA = 'onebun:controller_http_guards';
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* Metadata key for route-level exception filters
|
|
731
|
+
*/
|
|
732
|
+
const EXCEPTION_FILTERS_METADATA = 'onebun:exception_filters';
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Metadata key for controller-level exception filters
|
|
736
|
+
*/
|
|
737
|
+
const CONTROLLER_EXCEPTION_FILTERS_METADATA = 'onebun:controller_exception_filters';
|
|
738
|
+
|
|
682
739
|
/**
|
|
683
740
|
* Middleware decorator — can be applied to both controllers (class) and individual routes (method).
|
|
684
741
|
*
|
|
@@ -765,6 +822,162 @@ export function getControllerMiddleware(target: Function): Function[] {
|
|
|
765
822
|
return Reflect.getMetadata(CONTROLLER_MIDDLEWARE_METADATA, target) || [];
|
|
766
823
|
}
|
|
767
824
|
|
|
825
|
+
/**
|
|
826
|
+
* Get controller-level HTTP guards for a controller class.
|
|
827
|
+
* Returns guards registered via @UseGuards() applied to the class.
|
|
828
|
+
*
|
|
829
|
+
* @param target - Controller class (constructor)
|
|
830
|
+
* @returns Array of guard class constructors or instances
|
|
831
|
+
*/
|
|
832
|
+
export function getControllerGuards(target: Function): (Function | HttpGuard)[] {
|
|
833
|
+
return Reflect.getMetadata(HTTP_CONTROLLER_GUARDS_METADATA, target) || [];
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* Guards decorator — can be applied to both controllers (class) and individual routes (method).
|
|
838
|
+
*
|
|
839
|
+
* Pass guard **class constructors** or **instances**. Class constructors are instantiated
|
|
840
|
+
* for each request; instances are reused.
|
|
841
|
+
*
|
|
842
|
+
* Guards run after the middleware chain but before the route handler.
|
|
843
|
+
* If any guard returns false, the request is rejected with a 403 Forbidden response.
|
|
844
|
+
*
|
|
845
|
+
* Execution order: middleware → guards → handler
|
|
846
|
+
*
|
|
847
|
+
* @example Class-level (all routes)
|
|
848
|
+
* ```typescript
|
|
849
|
+
* \@Controller('/admin')
|
|
850
|
+
* \@UseGuards(AuthGuard)
|
|
851
|
+
* class AdminController extends BaseController { ... }
|
|
852
|
+
* ```
|
|
853
|
+
*
|
|
854
|
+
* @example Method-level (single route)
|
|
855
|
+
* ```typescript
|
|
856
|
+
* \@Get('/dashboard')
|
|
857
|
+
* \@UseGuards(new RolesGuard(['admin']))
|
|
858
|
+
* getDashboard() { ... }
|
|
859
|
+
* ```
|
|
860
|
+
*
|
|
861
|
+
* @example Combined (class guard + route guard)
|
|
862
|
+
* ```typescript
|
|
863
|
+
* \@Controller('/api')
|
|
864
|
+
* \@UseGuards(AuthGuard) // runs on every route
|
|
865
|
+
* class ApiController extends BaseController {
|
|
866
|
+
* \@Get('/admin')
|
|
867
|
+
* \@UseGuards(new RolesGuard(['admin'])) // runs only here, after AuthGuard
|
|
868
|
+
* getAdmin() { ... }
|
|
869
|
+
* }
|
|
870
|
+
* ```
|
|
871
|
+
*/
|
|
872
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
873
|
+
export function UseGuards(...guards: (Function | HttpGuard)[]): any {
|
|
874
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
875
|
+
return function useGuardsDecorator(...args: any[]): any {
|
|
876
|
+
// ---- Class decorator: target is a constructor function ----
|
|
877
|
+
if (args.length === 1 && typeof args[0] === 'function') {
|
|
878
|
+
const target = args[0] as Function;
|
|
879
|
+
const existing: (Function | HttpGuard)[] =
|
|
880
|
+
Reflect.getMetadata(HTTP_CONTROLLER_GUARDS_METADATA, target) || [];
|
|
881
|
+
Reflect.defineMetadata(
|
|
882
|
+
HTTP_CONTROLLER_GUARDS_METADATA,
|
|
883
|
+
[...existing, ...guards],
|
|
884
|
+
target,
|
|
885
|
+
);
|
|
886
|
+
|
|
887
|
+
return target;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// ---- Method decorator: (target, propertyKey, descriptor) ----
|
|
891
|
+
const [target, propertyKey, descriptor] = args as [
|
|
892
|
+
object,
|
|
893
|
+
string | symbol,
|
|
894
|
+
PropertyDescriptor,
|
|
895
|
+
];
|
|
896
|
+
const existingGuards: (Function | HttpGuard)[] =
|
|
897
|
+
Reflect.getMetadata(HTTP_GUARDS_METADATA, target, propertyKey) || [];
|
|
898
|
+
Reflect.defineMetadata(
|
|
899
|
+
HTTP_GUARDS_METADATA,
|
|
900
|
+
[...existingGuards, ...guards],
|
|
901
|
+
target,
|
|
902
|
+
propertyKey,
|
|
903
|
+
);
|
|
904
|
+
|
|
905
|
+
return descriptor;
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/**
|
|
910
|
+
* Get controller-level exception filters for a controller class.
|
|
911
|
+
* Returns filters registered via @UseFilters() applied to the class.
|
|
912
|
+
*
|
|
913
|
+
* @param target - Controller class (constructor)
|
|
914
|
+
* @returns Array of exception filter instances
|
|
915
|
+
*/
|
|
916
|
+
export function getControllerFilters(target: Function): ExceptionFilter[] {
|
|
917
|
+
return Reflect.getMetadata(CONTROLLER_EXCEPTION_FILTERS_METADATA, target) || [];
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* Exception Filters decorator — can be applied to both controllers (class) and individual routes (method).
|
|
922
|
+
*
|
|
923
|
+
* Pass filter **instances** (from `createExceptionFilter()` or implementing `ExceptionFilter`).
|
|
924
|
+
* Route-level filters take priority over controller-level filters.
|
|
925
|
+
*
|
|
926
|
+
* Filters run in order: controller-level first, then route-level.
|
|
927
|
+
* The first filter in the combined list catches the error.
|
|
928
|
+
* If no filters are registered, the default filter handles the error.
|
|
929
|
+
*
|
|
930
|
+
* @example Class-level (all routes)
|
|
931
|
+
* ```typescript
|
|
932
|
+
* \@Controller('/api')
|
|
933
|
+
* \@UseFilters(new HttpExceptionFilter())
|
|
934
|
+
* class ApiController extends BaseController { ... }
|
|
935
|
+
* ```
|
|
936
|
+
*
|
|
937
|
+
* @example Method-level (single route)
|
|
938
|
+
* ```typescript
|
|
939
|
+
* \@Get('/risky')
|
|
940
|
+
* \@UseFilters(createExceptionFilter((err, ctx) => new Response('Custom error', { status: 500 })))
|
|
941
|
+
* riskyRoute() { ... }
|
|
942
|
+
* ```
|
|
943
|
+
*/
|
|
944
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
945
|
+
export function UseFilters(...filters: ExceptionFilter[]): any {
|
|
946
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
947
|
+
return function useFiltersDecorator(...args: any[]): any {
|
|
948
|
+
// ---- Class decorator: target is a constructor function ----
|
|
949
|
+
if (args.length === 1 && typeof args[0] === 'function') {
|
|
950
|
+
const target = args[0] as Function;
|
|
951
|
+
const existing: ExceptionFilter[] =
|
|
952
|
+
Reflect.getMetadata(CONTROLLER_EXCEPTION_FILTERS_METADATA, target) || [];
|
|
953
|
+
Reflect.defineMetadata(
|
|
954
|
+
CONTROLLER_EXCEPTION_FILTERS_METADATA,
|
|
955
|
+
[...existing, ...filters],
|
|
956
|
+
target,
|
|
957
|
+
);
|
|
958
|
+
|
|
959
|
+
return target;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// ---- Method decorator: (target, propertyKey, descriptor) ----
|
|
963
|
+
const [target, propertyKey, descriptor] = args as [
|
|
964
|
+
object,
|
|
965
|
+
string | symbol,
|
|
966
|
+
PropertyDescriptor,
|
|
967
|
+
];
|
|
968
|
+
const existingFilters: ExceptionFilter[] =
|
|
969
|
+
Reflect.getMetadata(EXCEPTION_FILTERS_METADATA, target, propertyKey) || [];
|
|
970
|
+
Reflect.defineMetadata(
|
|
971
|
+
EXCEPTION_FILTERS_METADATA,
|
|
972
|
+
[...existingFilters, ...filters],
|
|
973
|
+
target,
|
|
974
|
+
propertyKey,
|
|
975
|
+
);
|
|
976
|
+
|
|
977
|
+
return descriptor;
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
|
|
768
981
|
/**
|
|
769
982
|
* HTTP GET decorator
|
|
770
983
|
*/
|
|
@@ -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();
|
|
@@ -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
|
|
|
@@ -5350,3 +5429,279 @@ describe('File Upload API Documentation (docs/api/controllers.md)', () => {
|
|
|
5350
5429
|
expect(FileController).toBeDefined();
|
|
5351
5430
|
});
|
|
5352
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
|
+
});
|