@martel/calyx 1.4.0 → 1.6.0
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/CHANGELOG.md +14 -0
- package/package.json +1 -1
- package/src/config/config.module.ts +61 -0
- package/src/config/config.service.ts +24 -0
- package/src/config/index.ts +2 -0
- package/src/event-emitter/decorators.ts +10 -0
- package/src/event-emitter/event-emitter.module.ts +17 -0
- package/src/event-emitter/event-emitter.ts +61 -0
- package/src/event-emitter/index.ts +3 -0
- package/src/http/application.ts +336 -3
- package/src/http/factory.ts +7 -0
- package/src/index.ts +6 -0
- package/src/lifecycle/context.ts +75 -0
- package/src/lifecycle/interfaces.ts +3 -0
- package/src/microservices/client-proxy.ts +7 -0
- package/src/microservices/client-tcp.ts +127 -0
- package/src/microservices/decorators.ts +19 -0
- package/src/microservices/index.ts +6 -0
- package/src/microservices/interfaces.ts +11 -0
- package/src/microservices/microservice.ts +114 -0
- package/src/microservices/server-tcp.ts +210 -0
- package/src/schedule/cron.matcher.ts +45 -0
- package/src/schedule/decorators.ts +28 -0
- package/src/schedule/index.ts +3 -0
- package/src/schedule/schedule.module.ts +13 -0
- package/src/security/cors.middleware.ts +50 -0
- package/src/security/hashing.service.ts +12 -0
- package/src/security/helmet.middleware.ts +45 -0
- package/src/security/index.ts +3 -0
- package/src/websockets/decorators.ts +49 -0
- package/src/websockets/gateway.ts +11 -0
- package/src/websockets/index.ts +2 -0
- package/tests/config-event.test.ts +145 -0
- package/tests/microservices.test.ts +105 -0
- package/tests/rpc-ws-context.test.ts +135 -0
- package/tests/schedule.test.ts +64 -0
- package/tests/security.test.ts +89 -0
- package/tests/websockets.test.ts +125 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { CalyxResponse } from '../http/application.ts';
|
|
2
|
+
|
|
3
|
+
export interface CorsOptions {
|
|
4
|
+
origin?: string | string[] | boolean;
|
|
5
|
+
methods?: string | string[];
|
|
6
|
+
allowedHeaders?: string | string[];
|
|
7
|
+
exposedHeaders?: string | string[];
|
|
8
|
+
credentials?: boolean;
|
|
9
|
+
maxAge?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function cors(options: CorsOptions = {}) {
|
|
13
|
+
const origin = options.origin ?? '*';
|
|
14
|
+
const methods = options.methods ?? 'GET,HEAD,PUT,PATCH,POST,DELETE';
|
|
15
|
+
const allowedHeaders = options.allowedHeaders ?? '*';
|
|
16
|
+
const credentials = options.credentials ?? false;
|
|
17
|
+
const maxAge = options.maxAge;
|
|
18
|
+
|
|
19
|
+
return (req: Request, res: CalyxResponse, next: () => void) => {
|
|
20
|
+
const reqOrigin = req.headers.get('origin');
|
|
21
|
+
|
|
22
|
+
if (origin === '*') {
|
|
23
|
+
res.headers['access-control-allow-origin'] = '*';
|
|
24
|
+
} else if (typeof origin === 'string') {
|
|
25
|
+
res.headers['access-control-allow-origin'] = origin;
|
|
26
|
+
} else if (Array.isArray(origin) && reqOrigin && origin.includes(reqOrigin)) {
|
|
27
|
+
res.headers['access-control-allow-origin'] = reqOrigin;
|
|
28
|
+
} else if (origin === true && reqOrigin) {
|
|
29
|
+
res.headers['access-control-allow-origin'] = reqOrigin;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
res.headers['access-control-allow-methods'] = Array.isArray(methods) ? methods.join(',') : methods;
|
|
33
|
+
res.headers['access-control-allow-headers'] = Array.isArray(allowedHeaders) ? allowedHeaders.join(',') : allowedHeaders;
|
|
34
|
+
|
|
35
|
+
if (credentials) {
|
|
36
|
+
res.headers['access-control-allow-credentials'] = 'true';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (maxAge !== undefined) {
|
|
40
|
+
res.headers['access-control-max-age'] = String(maxAge);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (req.method === 'OPTIONS') {
|
|
44
|
+
res.status(204).send('');
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
next();
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Injectable } from '../core/decorators.ts';
|
|
2
|
+
|
|
3
|
+
@Injectable()
|
|
4
|
+
export class HashingService {
|
|
5
|
+
async hash(data: string, algorithm: 'bcrypt' | 'argon2id' | 'argon2i' | 'argon2d' = 'bcrypt'): Promise<string> {
|
|
6
|
+
return Bun.password.hash(data, algorithm);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async verify(data: string, hash: string): Promise<boolean> {
|
|
10
|
+
return Bun.password.verify(data, hash);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { CalyxResponse } from '../http/application.ts';
|
|
2
|
+
|
|
3
|
+
export interface HelmetOptions {
|
|
4
|
+
contentSecurityPolicy?: boolean | string;
|
|
5
|
+
crossOriginOpenerPolicy?: string;
|
|
6
|
+
crossOriginResourcePolicy?: string;
|
|
7
|
+
referrerPolicy?: string;
|
|
8
|
+
xContentTypeOptions?: string;
|
|
9
|
+
xDnsPrefetchControl?: string;
|
|
10
|
+
xFrameOptions?: string;
|
|
11
|
+
xXssProtection?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function helmet(options: HelmetOptions = {}) {
|
|
15
|
+
const headers: Record<string, string> = {
|
|
16
|
+
'cross-origin-opener-policy': options.crossOriginOpenerPolicy ?? 'same-origin',
|
|
17
|
+
'cross-origin-resource-policy': options.crossOriginResourcePolicy ?? 'same-origin',
|
|
18
|
+
'origin-agent-cluster': '?1',
|
|
19
|
+
'referrer-policy': options.referrerPolicy ?? 'no-referrer',
|
|
20
|
+
'strict-transport-security': 'max-age=15552000; includeSubDomains',
|
|
21
|
+
'x-content-type-options': options.xContentTypeOptions ?? 'nosniff',
|
|
22
|
+
'x-dns-prefetch-control': options.xDnsPrefetchControl ?? 'off',
|
|
23
|
+
'x-download-options': 'noopen',
|
|
24
|
+
'x-frame-options': options.xFrameOptions ?? 'SAMEORIGIN',
|
|
25
|
+
'x-permitted-cross-domain-policies': 'none',
|
|
26
|
+
'x-xss-protection': options.xXssProtection ?? '0',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const csp = options.contentSecurityPolicy;
|
|
30
|
+
if (csp !== false) {
|
|
31
|
+
headers['content-security-policy'] = typeof csp === 'string'
|
|
32
|
+
? csp
|
|
33
|
+
: "default-src 'self';base-uri 'self';font-src 'self' https: data:;form-action 'self';frame-ancestors 'self';img-src 'self' data:;object-src 'none';script-src 'self';script-src-attr 'none';style-src 'self' https: 'unsafe-inline';upgrade-insecure-requests";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const headerPairs = Object.entries(headers);
|
|
37
|
+
|
|
38
|
+
return (req: Request, res: CalyxResponse, next: () => void) => {
|
|
39
|
+
for (let i = 0; i < headerPairs.length; i++) {
|
|
40
|
+
const pair = headerPairs[i];
|
|
41
|
+
res.headers[pair[0]] = pair[1];
|
|
42
|
+
}
|
|
43
|
+
next();
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import 'reflect-metadata';
|
|
2
|
+
|
|
3
|
+
export interface GatewayMetadata {
|
|
4
|
+
port?: number;
|
|
5
|
+
namespace?: string;
|
|
6
|
+
cors?: any;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function WebSocketGateway(portOrOptions?: number | GatewayMetadata): ClassDecorator {
|
|
10
|
+
return (target) => {
|
|
11
|
+
let metadata: GatewayMetadata = {};
|
|
12
|
+
if (typeof portOrOptions === 'number') {
|
|
13
|
+
metadata.port = portOrOptions;
|
|
14
|
+
} else if (portOrOptions) {
|
|
15
|
+
metadata = portOrOptions;
|
|
16
|
+
}
|
|
17
|
+
Reflect.defineMetadata('calyx:websocket_gateway', metadata, target);
|
|
18
|
+
Reflect.defineMetadata('calyx:injectable', true, target);
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function SubscribeMessage(event: string): MethodDecorator {
|
|
23
|
+
return (target, propertyKey) => {
|
|
24
|
+
const constructor = target.constructor;
|
|
25
|
+
const existing = Reflect.getOwnMetadata('calyx:subscribe_message', constructor) || [];
|
|
26
|
+
existing.push({ event, propertyKey });
|
|
27
|
+
Reflect.defineMetadata('calyx:subscribe_message', existing, constructor);
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function MessageBody(): ParameterDecorator {
|
|
32
|
+
return (target, propertyKey, parameterIndex) => {
|
|
33
|
+
if (!propertyKey) return;
|
|
34
|
+
const constructor = target.constructor;
|
|
35
|
+
const existing = Reflect.getOwnMetadata('calyx:message_body', constructor) || [];
|
|
36
|
+
existing.push({ propertyKey, parameterIndex });
|
|
37
|
+
Reflect.defineMetadata('calyx:message_body', existing, constructor);
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function ConnectedSocket(): ParameterDecorator {
|
|
42
|
+
return (target, propertyKey, parameterIndex) => {
|
|
43
|
+
if (!propertyKey) return;
|
|
44
|
+
const constructor = target.constructor;
|
|
45
|
+
const existing = Reflect.getOwnMetadata('calyx:connected_socket', constructor) || [];
|
|
46
|
+
existing.push({ propertyKey, parameterIndex });
|
|
47
|
+
Reflect.defineMetadata('calyx:connected_socket', existing, constructor);
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
Module,
|
|
4
|
+
Controller,
|
|
5
|
+
Get,
|
|
6
|
+
Injectable,
|
|
7
|
+
CalyxFactory,
|
|
8
|
+
ConfigModule,
|
|
9
|
+
ConfigService,
|
|
10
|
+
EventEmitterModule,
|
|
11
|
+
EventEmitter,
|
|
12
|
+
OnEvent,
|
|
13
|
+
} from '../src/index.ts';
|
|
14
|
+
|
|
15
|
+
// 1. Config Test classes
|
|
16
|
+
@Injectable()
|
|
17
|
+
class ConfigTestService {
|
|
18
|
+
constructor(private readonly config: ConfigService) {}
|
|
19
|
+
|
|
20
|
+
getDbUser(): string {
|
|
21
|
+
return this.config.get('DB_USER', 'default');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
getDbPort(): number {
|
|
25
|
+
return this.config.get('DB_PORT', 5432);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
getDebugMode(): boolean {
|
|
29
|
+
return this.config.get('DEBUG_MODE', false);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 2. Event emitter test classes
|
|
34
|
+
let orderCreatedCount = 0;
|
|
35
|
+
let anyOrderCount = 0;
|
|
36
|
+
|
|
37
|
+
@Injectable()
|
|
38
|
+
class OrderService {
|
|
39
|
+
constructor(private readonly emitter: EventEmitter) {}
|
|
40
|
+
|
|
41
|
+
createOrder() {
|
|
42
|
+
this.emitter.emit('order.created', { id: 101, total: 50.0 });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
cancelOrder() {
|
|
46
|
+
this.emitter.emit('order.cancelled', { id: 101 });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
@Injectable()
|
|
51
|
+
class NotificationService {
|
|
52
|
+
@OnEvent('order.created')
|
|
53
|
+
handleOrderCreated(payload: any) {
|
|
54
|
+
orderCreatedCount++;
|
|
55
|
+
expect(payload.id).toBe(101);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
@OnEvent('order.*')
|
|
59
|
+
handleAnyOrder(payload: any) {
|
|
60
|
+
anyOrderCount++;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
@Controller('test')
|
|
65
|
+
class TestController {
|
|
66
|
+
constructor(
|
|
67
|
+
private readonly configTest: ConfigTestService,
|
|
68
|
+
private readonly orders: OrderService
|
|
69
|
+
) {}
|
|
70
|
+
|
|
71
|
+
@Get('config')
|
|
72
|
+
getConfig() {
|
|
73
|
+
return {
|
|
74
|
+
user: this.configTest.getDbUser(),
|
|
75
|
+
port: this.configTest.getDbPort(),
|
|
76
|
+
debug: this.configTest.getDebugMode(),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
@Get('trigger-event')
|
|
81
|
+
triggerEvent() {
|
|
82
|
+
this.orders.createOrder();
|
|
83
|
+
this.orders.cancelOrder();
|
|
84
|
+
return { triggered: true };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
@Module({
|
|
89
|
+
imports: [
|
|
90
|
+
ConfigModule.forRoot({
|
|
91
|
+
load: [
|
|
92
|
+
() => ({
|
|
93
|
+
DB_USER: 'calyx_admin',
|
|
94
|
+
DB_PORT: '8080',
|
|
95
|
+
DEBUG_MODE: 'true',
|
|
96
|
+
}),
|
|
97
|
+
],
|
|
98
|
+
}),
|
|
99
|
+
EventEmitterModule.forRoot(),
|
|
100
|
+
],
|
|
101
|
+
controllers: [TestController],
|
|
102
|
+
providers: [ConfigTestService, OrderService, NotificationService],
|
|
103
|
+
})
|
|
104
|
+
class AppModule {}
|
|
105
|
+
|
|
106
|
+
describe('Config and Event Modules Parity', () => {
|
|
107
|
+
let app: any;
|
|
108
|
+
let baseUrl: string;
|
|
109
|
+
const PORT = 3866;
|
|
110
|
+
|
|
111
|
+
beforeAll(async () => {
|
|
112
|
+
app = await CalyxFactory.create(AppModule);
|
|
113
|
+
await app.listen(PORT);
|
|
114
|
+
baseUrl = `http://localhost:${PORT}`;
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
afterAll(async () => {
|
|
118
|
+
await app.close();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('should resolve custom environment variables via ConfigService', async () => {
|
|
122
|
+
const res = await fetch(`${baseUrl}/test/config`);
|
|
123
|
+
expect(res.status).toBe(200);
|
|
124
|
+
const body = await res.json();
|
|
125
|
+
expect(body).toEqual({
|
|
126
|
+
user: 'calyx_admin',
|
|
127
|
+
port: 8080,
|
|
128
|
+
debug: true,
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('should trigger event and notify OnEvent listeners, including wildcards', async () => {
|
|
133
|
+
orderCreatedCount = 0;
|
|
134
|
+
anyOrderCount = 0;
|
|
135
|
+
|
|
136
|
+
const res = await fetch(`${baseUrl}/test/trigger-event`);
|
|
137
|
+
expect(res.status).toBe(200);
|
|
138
|
+
|
|
139
|
+
// Wait for event loop ticks
|
|
140
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
141
|
+
|
|
142
|
+
expect(orderCreatedCount).toBe(1);
|
|
143
|
+
expect(anyOrderCount).toBe(2); // Matches order.created and order.cancelled
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
2
|
+
import { of, Observable } from 'rxjs';
|
|
3
|
+
import {
|
|
4
|
+
Module,
|
|
5
|
+
Controller,
|
|
6
|
+
CalyxFactory,
|
|
7
|
+
MessagePattern,
|
|
8
|
+
EventPattern,
|
|
9
|
+
ClientTcp,
|
|
10
|
+
Transport,
|
|
11
|
+
} from '../src/index.ts';
|
|
12
|
+
|
|
13
|
+
let eventReceived = false;
|
|
14
|
+
let eventPayload: any = null;
|
|
15
|
+
|
|
16
|
+
@Controller()
|
|
17
|
+
class MathController {
|
|
18
|
+
@MessagePattern('sum')
|
|
19
|
+
accumulate(data: number[]): number {
|
|
20
|
+
return (data || []).reduce((a, b) => a + b, 0);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
@MessagePattern('stream')
|
|
24
|
+
streamData(data: string): Observable<string> {
|
|
25
|
+
return of(`echo1: ${data}`, `echo2: ${data}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
@MessagePattern('error_test')
|
|
29
|
+
throwError(data: any) {
|
|
30
|
+
throw new Error('This is a simulated RPC error');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@EventPattern('log_event')
|
|
34
|
+
handleLogEvent(data: any) {
|
|
35
|
+
eventReceived = true;
|
|
36
|
+
eventPayload = data;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
@Module({
|
|
41
|
+
controllers: [MathController],
|
|
42
|
+
})
|
|
43
|
+
class MicroserviceModule {}
|
|
44
|
+
|
|
45
|
+
describe('TCP Microservices (ClientTcp, ServerTcp, MessagePattern, EventPattern)', () => {
|
|
46
|
+
let server: any;
|
|
47
|
+
let client: ClientTcp;
|
|
48
|
+
const PORT = 3899;
|
|
49
|
+
|
|
50
|
+
beforeAll(async () => {
|
|
51
|
+
// 1. Create and start microservice server
|
|
52
|
+
server = await CalyxFactory.createMicroservice(MicroserviceModule, {
|
|
53
|
+
transport: Transport.TCP,
|
|
54
|
+
options: { port: PORT },
|
|
55
|
+
});
|
|
56
|
+
await server.listen();
|
|
57
|
+
|
|
58
|
+
// 2. Initialize client
|
|
59
|
+
client = new ClientTcp({ port: PORT });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
afterAll(async () => {
|
|
63
|
+
client.close();
|
|
64
|
+
await server.close();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('should exchange request-response message and calculate sum', async () => {
|
|
68
|
+
const res = await client.send<number, number[]>('sum', [1, 2, 3, 4]).toPromise();
|
|
69
|
+
expect(res).toBe(10);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('should handle streaming response from RxJS Observable', async () => {
|
|
73
|
+
const results: string[] = [];
|
|
74
|
+
await new Promise<void>((resolve, reject) => {
|
|
75
|
+
client.send<string, string>('stream', 'hello').subscribe({
|
|
76
|
+
next: (val) => results.push(val),
|
|
77
|
+
error: (err) => reject(err),
|
|
78
|
+
complete: () => resolve(),
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
expect(results).toEqual(['echo1: hello', 'echo2: hello']);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('should propagate error message from RPC back to client', async () => {
|
|
85
|
+
try {
|
|
86
|
+
await client.send('error_test', {}).toPromise();
|
|
87
|
+
expect().fail('Expected send to throw error');
|
|
88
|
+
} catch (err: any) {
|
|
89
|
+
expect(err.message).toBe('This is a simulated RPC error');
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('should dispatch event pattern messages asynchronously', async () => {
|
|
94
|
+
eventReceived = false;
|
|
95
|
+
eventPayload = null;
|
|
96
|
+
|
|
97
|
+
await client.emit('log_event', { status: 'ok', code: 200 }).toPromise();
|
|
98
|
+
|
|
99
|
+
// Wait a tick for TCP event handling
|
|
100
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
101
|
+
|
|
102
|
+
expect(eventReceived).toBe(true);
|
|
103
|
+
expect(eventPayload).toEqual({ status: 'ok', code: 200 });
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
2
|
+
import { of, Observable } from 'rxjs';
|
|
3
|
+
import {
|
|
4
|
+
Module,
|
|
5
|
+
CalyxFactory,
|
|
6
|
+
WebSocketGateway,
|
|
7
|
+
SubscribeMessage,
|
|
8
|
+
MessageBody,
|
|
9
|
+
UseGuards,
|
|
10
|
+
UseInterceptors,
|
|
11
|
+
CanActivate,
|
|
12
|
+
ExecutionContext,
|
|
13
|
+
NestInterceptor,
|
|
14
|
+
CallHandler,
|
|
15
|
+
ClientTcp,
|
|
16
|
+
Transport,
|
|
17
|
+
MessagePattern,
|
|
18
|
+
Controller,
|
|
19
|
+
} from '../src/index.ts';
|
|
20
|
+
|
|
21
|
+
class SecurityGuard implements CanActivate {
|
|
22
|
+
canActivate(context: ExecutionContext): boolean {
|
|
23
|
+
const type = context.getType();
|
|
24
|
+
if (type === 'ws') {
|
|
25
|
+
const data = context.switchToWs().getData();
|
|
26
|
+
return data.user === 'admin';
|
|
27
|
+
}
|
|
28
|
+
if (type === 'rpc') {
|
|
29
|
+
const data = context.switchToRpc().getData();
|
|
30
|
+
return data.role === 'admin';
|
|
31
|
+
}
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
class LoggingInterceptor implements NestInterceptor {
|
|
37
|
+
async intercept(context: ExecutionContext, next: CallHandler): Promise<any> {
|
|
38
|
+
const type = context.getType();
|
|
39
|
+
const result = await next.handle();
|
|
40
|
+
return { type, original: result };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
@WebSocketGateway(3915)
|
|
45
|
+
class SecureGateway {
|
|
46
|
+
@SubscribeMessage('ping')
|
|
47
|
+
@UseGuards(SecurityGuard)
|
|
48
|
+
@UseInterceptors(LoggingInterceptor)
|
|
49
|
+
handlePing(@MessageBody() data: any) {
|
|
50
|
+
return 'pong';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
@Controller()
|
|
55
|
+
class SecureController {
|
|
56
|
+
@MessagePattern('ping_rpc')
|
|
57
|
+
@UseGuards(SecurityGuard)
|
|
58
|
+
@UseInterceptors(LoggingInterceptor)
|
|
59
|
+
handlePingRpc(data: any) {
|
|
60
|
+
return 'pong_rpc';
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
@Module({
|
|
65
|
+
controllers: [SecureController],
|
|
66
|
+
providers: [SecureGateway],
|
|
67
|
+
})
|
|
68
|
+
class TestApp {}
|
|
69
|
+
|
|
70
|
+
describe('Guards and Interceptors for WS and RPC contexts', () => {
|
|
71
|
+
let wsApp: any;
|
|
72
|
+
let rpcApp: any;
|
|
73
|
+
let client: ClientTcp;
|
|
74
|
+
const PORT = 3889;
|
|
75
|
+
const RPC_PORT = 3916;
|
|
76
|
+
|
|
77
|
+
beforeAll(async () => {
|
|
78
|
+
// 1. Start standard app for HTTP/WS
|
|
79
|
+
wsApp = await CalyxFactory.create(TestApp);
|
|
80
|
+
await wsApp.listen(PORT);
|
|
81
|
+
|
|
82
|
+
// 2. Start dedicated microservice for RPC
|
|
83
|
+
rpcApp = await CalyxFactory.createMicroservice(TestApp, {
|
|
84
|
+
transport: Transport.TCP,
|
|
85
|
+
options: { port: RPC_PORT },
|
|
86
|
+
});
|
|
87
|
+
await rpcApp.listen();
|
|
88
|
+
|
|
89
|
+
client = new ClientTcp({ port: RPC_PORT });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
afterAll(async () => {
|
|
93
|
+
client.close();
|
|
94
|
+
await rpcApp.close();
|
|
95
|
+
await wsApp.close();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('should pass or deny WS events using context switching in guards/interceptors', async () => {
|
|
99
|
+
const ws = new WebSocket('ws://localhost:3915');
|
|
100
|
+
|
|
101
|
+
await new Promise<void>((resolve, reject) => {
|
|
102
|
+
ws.onopen = () => resolve();
|
|
103
|
+
ws.onerror = (err) => reject(err);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
ws.send(JSON.stringify({ event: 'ping', data: { user: 'admin' } }));
|
|
107
|
+
const successRes = await new Promise<any>((resolve) => {
|
|
108
|
+
ws.onmessage = (event) => resolve(JSON.parse(event.data));
|
|
109
|
+
});
|
|
110
|
+
expect(successRes).toEqual({ type: 'ws', original: 'pong' });
|
|
111
|
+
|
|
112
|
+
let receivedMessage = false;
|
|
113
|
+
ws.onmessage = () => {
|
|
114
|
+
receivedMessage = true;
|
|
115
|
+
};
|
|
116
|
+
ws.send(JSON.stringify({ event: 'ping', data: { user: 'guest' } }));
|
|
117
|
+
|
|
118
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
119
|
+
expect(receivedMessage).toBe(false);
|
|
120
|
+
|
|
121
|
+
ws.close();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('should pass or deny RPC messages using context switching in guards/interceptors', async () => {
|
|
125
|
+
const successRes = await client.send('ping_rpc', { role: 'admin' }).toPromise();
|
|
126
|
+
expect(successRes).toEqual({ type: 'rpc', original: 'pong_rpc' });
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
await client.send('ping_rpc', { role: 'guest' }).toPromise();
|
|
130
|
+
expect().fail('Expected send to throw error');
|
|
131
|
+
} catch (err: any) {
|
|
132
|
+
expect(err.message).toBe('Forbidden resource');
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
Module,
|
|
4
|
+
CalyxFactory,
|
|
5
|
+
ScheduleModule,
|
|
6
|
+
Cron,
|
|
7
|
+
Interval,
|
|
8
|
+
Timeout,
|
|
9
|
+
Injectable,
|
|
10
|
+
} from '../src/index.ts';
|
|
11
|
+
|
|
12
|
+
let cronCalls = 0;
|
|
13
|
+
let intervalCalls = 0;
|
|
14
|
+
let timeoutCalls = 0;
|
|
15
|
+
|
|
16
|
+
@Injectable()
|
|
17
|
+
class TaskService {
|
|
18
|
+
@Cron('*/1 * * * * *') // Every second
|
|
19
|
+
handleCron() {
|
|
20
|
+
cronCalls++;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
@Interval(100) // Every 100ms
|
|
24
|
+
handleInterval() {
|
|
25
|
+
intervalCalls++;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
@Timeout(250) // After 250ms
|
|
29
|
+
handleTimeout() {
|
|
30
|
+
timeoutCalls++;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
@Module({
|
|
35
|
+
imports: [ScheduleModule.forRoot()],
|
|
36
|
+
providers: [TaskService],
|
|
37
|
+
})
|
|
38
|
+
class TestApp {}
|
|
39
|
+
|
|
40
|
+
describe('Task Scheduler (ScheduleModule, Cron, Interval, Timeout)', () => {
|
|
41
|
+
let app: any;
|
|
42
|
+
|
|
43
|
+
beforeAll(async () => {
|
|
44
|
+
cronCalls = 0;
|
|
45
|
+
intervalCalls = 0;
|
|
46
|
+
timeoutCalls = 0;
|
|
47
|
+
|
|
48
|
+
app = await CalyxFactory.create(TestApp);
|
|
49
|
+
await app.init();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterAll(async () => {
|
|
53
|
+
await app.close();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('should fire intervals, timeouts, and cron jobs on schedule', async () => {
|
|
57
|
+
// Wait 500ms
|
|
58
|
+
await new Promise((resolve) => setTimeout(resolve, 550));
|
|
59
|
+
|
|
60
|
+
expect(cronCalls).toBeGreaterThanOrEqual(0);
|
|
61
|
+
expect(intervalCalls).toBeGreaterThan(3);
|
|
62
|
+
expect(timeoutCalls).toBe(1);
|
|
63
|
+
});
|
|
64
|
+
});
|