@onebun/core 0.2.7 → 0.2.9
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 +52 -6
- package/src/application/application.ts +308 -220
- 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 +47 -7
- package/src/queue/docs-examples.test.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 +98 -0
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exception Filters
|
|
3
|
+
*
|
|
4
|
+
* Intercept and transform errors thrown by route handlers.
|
|
5
|
+
* Apply with `@UseFilters()` on controllers or individual routes,
|
|
6
|
+
* or globally via `ApplicationOptions.filters`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { HttpExecutionContext, OneBunResponse } from '../types';
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
createErrorResponse,
|
|
13
|
+
HttpStatusCode,
|
|
14
|
+
OneBunBaseError,
|
|
15
|
+
} from '@onebun/requests';
|
|
16
|
+
|
|
17
|
+
import { HttpException } from './http-exception';
|
|
18
|
+
|
|
19
|
+
// ============================================================================
|
|
20
|
+
// Interfaces
|
|
21
|
+
// ============================================================================
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Exception Filter interface — implement to handle errors thrown by route handlers.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* ```typescript
|
|
28
|
+
* class HttpExceptionFilter implements ExceptionFilter {
|
|
29
|
+
* catch(error: unknown, ctx: HttpExecutionContext): Response {
|
|
30
|
+
* const status = error instanceof OneBunBaseError ? error.code : 500;
|
|
31
|
+
* return new Response(JSON.stringify({ message: String(error) }), {
|
|
32
|
+
* status,
|
|
33
|
+
* headers: { 'Content-Type': 'application/json' },
|
|
34
|
+
* });
|
|
35
|
+
* }
|
|
36
|
+
* }
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
export interface ExceptionFilter {
|
|
40
|
+
catch(error: unknown, context: HttpExecutionContext): OneBunResponse | Promise<OneBunResponse>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ============================================================================
|
|
44
|
+
// Factory
|
|
45
|
+
// ============================================================================
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Create a custom exception filter from a plain function.
|
|
49
|
+
*
|
|
50
|
+
* @param fn - Filter function receiving the error and execution context
|
|
51
|
+
* @returns An ExceptionFilter instance
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* ```typescript
|
|
55
|
+
* const logAndForwardFilter = createExceptionFilter((error, ctx) => {
|
|
56
|
+
* console.error(`[${ctx.getController()}#${ctx.getHandler()}]`, error);
|
|
57
|
+
* return new Response('Internal Error', { status: 500 });
|
|
58
|
+
* });
|
|
59
|
+
*
|
|
60
|
+
* @UseFilters(logAndForwardFilter)
|
|
61
|
+
* @Get('/risky')
|
|
62
|
+
* riskyRoute() { ... }
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
export function createExceptionFilter(
|
|
66
|
+
fn: (error: unknown, context: HttpExecutionContext) => OneBunResponse | Promise<OneBunResponse>,
|
|
67
|
+
): ExceptionFilter {
|
|
68
|
+
return { catch: fn };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ============================================================================
|
|
72
|
+
// Default filter (wraps existing error-handling logic)
|
|
73
|
+
// ============================================================================
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Default exception filter — mirrors the built-in error handling behaviour.
|
|
77
|
+
* - `OneBunBaseError` instances are serialised with their own `toErrorResponse()`.
|
|
78
|
+
* - Every other error is converted to a generic 500 response.
|
|
79
|
+
* All responses use HTTP 200 with the standardised `ApiResponse` envelope,
|
|
80
|
+
* consistent with the rest of the framework.
|
|
81
|
+
*/
|
|
82
|
+
export const defaultExceptionFilter: ExceptionFilter = {
|
|
83
|
+
catch(error: unknown): OneBunResponse {
|
|
84
|
+
if (error instanceof HttpException) {
|
|
85
|
+
const errorResponse = createErrorResponse(
|
|
86
|
+
error.message,
|
|
87
|
+
error.statusCode,
|
|
88
|
+
error.message,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
return new Response(JSON.stringify(errorResponse), {
|
|
92
|
+
status: error.statusCode,
|
|
93
|
+
headers: {
|
|
94
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
95
|
+
'Content-Type': 'application/json',
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (error instanceof OneBunBaseError) {
|
|
101
|
+
return new Response(JSON.stringify(error.toErrorResponse()), {
|
|
102
|
+
status: HttpStatusCode.OK,
|
|
103
|
+
headers: {
|
|
104
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
105
|
+
'Content-Type': 'application/json',
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
111
|
+
const code =
|
|
112
|
+
error instanceof Error && 'code' in error
|
|
113
|
+
? Number((error as { code: unknown }).code)
|
|
114
|
+
: HttpStatusCode.INTERNAL_SERVER_ERROR;
|
|
115
|
+
|
|
116
|
+
const errorResponse = createErrorResponse(message, code, message, undefined, {
|
|
117
|
+
originalErrorName: error instanceof Error ? error.name : 'UnknownError',
|
|
118
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
return new Response(JSON.stringify(errorResponse), {
|
|
122
|
+
status: HttpStatusCode.OK,
|
|
123
|
+
headers: {
|
|
124
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
125
|
+
'Content-Type': 'application/json',
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
},
|
|
129
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP exception that carries a status code.
|
|
3
|
+
*
|
|
4
|
+
* Throw from route handlers, guards, or middleware to return
|
|
5
|
+
* a specific HTTP status. The default exception filter converts
|
|
6
|
+
* these into JSON responses with the matching status code.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```typescript
|
|
10
|
+
* throw new HttpException(404, 'User not found');
|
|
11
|
+
* // → HTTP 404 { success: false, error: "User not found", ... }
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
export class HttpException extends Error {
|
|
15
|
+
constructor(
|
|
16
|
+
public readonly statusCode: number,
|
|
17
|
+
message: string,
|
|
18
|
+
) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = 'HttpException';
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/file/onebun-file.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { HttpStatusCode } from '@onebun/requests';
|
|
2
|
+
|
|
3
|
+
import { HttpException } from '../exception-filters/http-exception';
|
|
4
|
+
|
|
1
5
|
/**
|
|
2
6
|
* OneBunFile - Unified file wrapper for file uploads
|
|
3
7
|
*
|
|
@@ -287,7 +291,8 @@ export function validateFile(
|
|
|
287
291
|
|
|
288
292
|
// Validate file size
|
|
289
293
|
if (options.maxSize !== undefined && file.size > options.maxSize) {
|
|
290
|
-
throw new
|
|
294
|
+
throw new HttpException(
|
|
295
|
+
HttpStatusCode.BAD_REQUEST,
|
|
291
296
|
`${prefix} exceeds maximum size. Got ${file.size} bytes, max is ${options.maxSize} bytes`,
|
|
292
297
|
);
|
|
293
298
|
}
|
|
@@ -296,7 +301,8 @@ export function validateFile(
|
|
|
296
301
|
if (options.mimeTypes && options.mimeTypes.length > 0) {
|
|
297
302
|
const matches = options.mimeTypes.some((pattern) => matchMimeType(file.type, pattern));
|
|
298
303
|
if (!matches) {
|
|
299
|
-
throw new
|
|
304
|
+
throw new HttpException(
|
|
305
|
+
HttpStatusCode.BAD_REQUEST,
|
|
300
306
|
`${prefix} has invalid MIME type "${file.type}". Allowed: ${options.mimeTypes.join(', ')}`,
|
|
301
307
|
);
|
|
302
308
|
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import {
|
|
2
|
+
describe,
|
|
3
|
+
expect,
|
|
4
|
+
it,
|
|
5
|
+
} from 'bun:test';
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
HttpExecutionContext,
|
|
9
|
+
HttpGuard,
|
|
10
|
+
OneBunRequest,
|
|
11
|
+
} from '../types';
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
AuthGuard,
|
|
15
|
+
createHttpGuard,
|
|
16
|
+
executeHttpGuards,
|
|
17
|
+
HttpExecutionContextImpl,
|
|
18
|
+
RolesGuard,
|
|
19
|
+
} from './http-guards';
|
|
20
|
+
|
|
21
|
+
// ============================================================================
|
|
22
|
+
// Helpers
|
|
23
|
+
// ============================================================================
|
|
24
|
+
|
|
25
|
+
function makeRequest(headers?: Headers): OneBunRequest {
|
|
26
|
+
return new Request('http://localhost/test', { headers }) as unknown as OneBunRequest;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function makeHeaders(entries: [string, string][]): Headers {
|
|
30
|
+
const h = new Headers();
|
|
31
|
+
for (const [key, value] of entries) {
|
|
32
|
+
h.set(key, value);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return h;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function makeContext(
|
|
39
|
+
headers: [string, string][] = [],
|
|
40
|
+
handler = 'testHandler',
|
|
41
|
+
controller = 'TestController',
|
|
42
|
+
): HttpExecutionContext {
|
|
43
|
+
return new HttpExecutionContextImpl(makeRequest(makeHeaders(headers)), handler, controller);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ============================================================================
|
|
47
|
+
// HttpExecutionContextImpl
|
|
48
|
+
// ============================================================================
|
|
49
|
+
|
|
50
|
+
describe('HttpExecutionContextImpl', () => {
|
|
51
|
+
it('returns request from getRequest()', () => {
|
|
52
|
+
const req = makeRequest(makeHeaders([['authorization', 'Bearer token']]));
|
|
53
|
+
const ctx = new HttpExecutionContextImpl(req, 'myHandler', 'MyController');
|
|
54
|
+
|
|
55
|
+
expect(ctx.getRequest()).toBe(req);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('returns handler name from getHandler()', () => {
|
|
59
|
+
const ctx = makeContext([], 'getUser', 'UserController');
|
|
60
|
+
|
|
61
|
+
expect(ctx.getHandler()).toBe('getUser');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('returns controller name from getController()', () => {
|
|
65
|
+
const ctx = makeContext([], 'getUser', 'UserController');
|
|
66
|
+
|
|
67
|
+
expect(ctx.getController()).toBe('UserController');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// ============================================================================
|
|
72
|
+
// executeHttpGuards
|
|
73
|
+
// ============================================================================
|
|
74
|
+
|
|
75
|
+
describe('executeHttpGuards', () => {
|
|
76
|
+
it('returns true when there are no guards', async () => {
|
|
77
|
+
const ctx = makeContext();
|
|
78
|
+
|
|
79
|
+
expect(await executeHttpGuards([], ctx)).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('returns true when all guards pass', async () => {
|
|
83
|
+
const passGuard = createHttpGuard(() => true);
|
|
84
|
+
const ctx = makeContext();
|
|
85
|
+
|
|
86
|
+
expect(await executeHttpGuards([passGuard, passGuard], ctx)).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('returns false when any guard fails', async () => {
|
|
90
|
+
const passGuard = createHttpGuard(() => true);
|
|
91
|
+
const failGuard = createHttpGuard(() => false);
|
|
92
|
+
const ctx = makeContext();
|
|
93
|
+
|
|
94
|
+
expect(await executeHttpGuards([passGuard, failGuard, passGuard], ctx)).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('short-circuits on first failing guard', async () => {
|
|
98
|
+
let secondCalled = false;
|
|
99
|
+
|
|
100
|
+
const failGuard = createHttpGuard(() => false);
|
|
101
|
+
const trackGuard = createHttpGuard(() => {
|
|
102
|
+
secondCalled = true;
|
|
103
|
+
|
|
104
|
+
return true;
|
|
105
|
+
});
|
|
106
|
+
const ctx = makeContext();
|
|
107
|
+
|
|
108
|
+
await executeHttpGuards([failGuard, trackGuard], ctx);
|
|
109
|
+
|
|
110
|
+
expect(secondCalled).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('accepts guard instances (not just class constructors)', async () => {
|
|
114
|
+
const instance: HttpGuard = { canActivate: () => true };
|
|
115
|
+
const ctx = makeContext();
|
|
116
|
+
|
|
117
|
+
expect(await executeHttpGuards([instance], ctx)).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('accepts async guards', async () => {
|
|
121
|
+
const asyncPassGuard = createHttpGuard(async () => {
|
|
122
|
+
await Promise.resolve();
|
|
123
|
+
|
|
124
|
+
return true;
|
|
125
|
+
});
|
|
126
|
+
const ctx = makeContext();
|
|
127
|
+
|
|
128
|
+
expect(await executeHttpGuards([asyncPassGuard], ctx)).toBe(true);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// ============================================================================
|
|
133
|
+
// createHttpGuard
|
|
134
|
+
// ============================================================================
|
|
135
|
+
|
|
136
|
+
describe('createHttpGuard', () => {
|
|
137
|
+
it('returns a class constructor', () => {
|
|
138
|
+
const guardClass = createHttpGuard(() => true);
|
|
139
|
+
|
|
140
|
+
expect(typeof guardClass).toBe('function');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('instantiated class calls the provided function', async () => {
|
|
144
|
+
let called = false;
|
|
145
|
+
const guardClass = createHttpGuard((ctx) => {
|
|
146
|
+
called = true;
|
|
147
|
+
|
|
148
|
+
return ctx.getHandler() === 'target';
|
|
149
|
+
});
|
|
150
|
+
const ctx = makeContext([], 'target');
|
|
151
|
+
const instance = new guardClass();
|
|
152
|
+
|
|
153
|
+
expect(await instance.canActivate(ctx)).toBe(true);
|
|
154
|
+
expect(called).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// ============================================================================
|
|
159
|
+
// AuthGuard
|
|
160
|
+
// ============================================================================
|
|
161
|
+
|
|
162
|
+
describe('AuthGuard', () => {
|
|
163
|
+
it('allows request with Bearer token', () => {
|
|
164
|
+
const guard = new AuthGuard();
|
|
165
|
+
const ctx = makeContext([['authorization', 'Bearer my-token']]);
|
|
166
|
+
|
|
167
|
+
expect(guard.canActivate(ctx)).toBe(true);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('blocks request without Authorization header', () => {
|
|
171
|
+
const guard = new AuthGuard();
|
|
172
|
+
const ctx = makeContext();
|
|
173
|
+
|
|
174
|
+
expect(guard.canActivate(ctx)).toBe(false);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('blocks request with non-Bearer Authorization header', () => {
|
|
178
|
+
const guard = new AuthGuard();
|
|
179
|
+
const ctx = makeContext([['authorization', 'Basic dXNlcjpwYXNz']]);
|
|
180
|
+
|
|
181
|
+
expect(guard.canActivate(ctx)).toBe(false);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// ============================================================================
|
|
186
|
+
// RolesGuard
|
|
187
|
+
// ============================================================================
|
|
188
|
+
|
|
189
|
+
describe('RolesGuard', () => {
|
|
190
|
+
it('allows when user has all required roles (default extractor)', () => {
|
|
191
|
+
const guard = new RolesGuard(['admin', 'editor']);
|
|
192
|
+
const headers = makeHeaders([['x-user-roles', 'admin, editor, viewer']]);
|
|
193
|
+
const ctx = new HttpExecutionContextImpl(makeRequest(headers), 'handler', 'Controller');
|
|
194
|
+
|
|
195
|
+
expect(guard.canActivate(ctx)).toBe(true);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('blocks when user is missing a required role', () => {
|
|
199
|
+
const guard = new RolesGuard(['admin']);
|
|
200
|
+
const headers = makeHeaders([['x-user-roles', 'viewer']]);
|
|
201
|
+
const ctx = new HttpExecutionContextImpl(makeRequest(headers), 'handler', 'Controller');
|
|
202
|
+
|
|
203
|
+
expect(guard.canActivate(ctx)).toBe(false);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('blocks when x-user-roles header is absent', () => {
|
|
207
|
+
const guard = new RolesGuard(['admin']);
|
|
208
|
+
const ctx = makeContext();
|
|
209
|
+
|
|
210
|
+
expect(guard.canActivate(ctx)).toBe(false);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('uses custom roles extractor when provided', () => {
|
|
214
|
+
const headers = makeHeaders([['x-roles', 'admin|user']]);
|
|
215
|
+
const guard = new RolesGuard(['admin'], (ctx) =>
|
|
216
|
+
ctx.getRequest().headers.get('x-roles')?.split('|') ?? [],
|
|
217
|
+
);
|
|
218
|
+
const ctx = new HttpExecutionContextImpl(makeRequest(headers), 'handler', 'Controller');
|
|
219
|
+
|
|
220
|
+
expect(guard.canActivate(ctx)).toBe(true);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('requires ALL roles to be present (not just one)', () => {
|
|
224
|
+
const guard = new RolesGuard(['admin', 'superuser']);
|
|
225
|
+
const headers = makeHeaders([['x-user-roles', 'admin']]);
|
|
226
|
+
const ctx = new HttpExecutionContextImpl(makeRequest(headers), 'handler', 'Controller');
|
|
227
|
+
|
|
228
|
+
expect(guard.canActivate(ctx)).toBe(false);
|
|
229
|
+
});
|
|
230
|
+
});
|