@adaas/a-server 0.0.29 → 0.0.31
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/dist/browser/index.d.mts +123 -69
- package/dist/browser/index.mjs +211 -69
- package/dist/browser/index.mjs.map +1 -1
- package/dist/node/controllers/A-EntityController/A-EntityController.component.d.mts +2 -5
- package/dist/node/controllers/A-EntityController/A-EntityController.component.d.ts +2 -5
- package/dist/node/controllers/A-EntityController/A-EntityController.component.js +66 -88
- package/dist/node/controllers/A-EntityController/A-EntityController.component.js.map +1 -1
- package/dist/node/controllers/A-EntityController/A-EntityController.component.mjs +67 -89
- package/dist/node/controllers/A-EntityController/A-EntityController.component.mjs.map +1 -1
- package/dist/node/controllers/A-ListingController/A-ListingController.component.js +20 -18
- package/dist/node/controllers/A-ListingController/A-ListingController.component.js.map +1 -1
- package/dist/node/controllers/A-ListingController/A-ListingController.component.mjs +20 -18
- package/dist/node/controllers/A-ListingController/A-ListingController.component.mjs.map +1 -1
- package/dist/node/controllers/A-ServerHealthMonitor/A-ServerHealthMonitor.component.d.mts +0 -2
- package/dist/node/controllers/A-ServerHealthMonitor/A-ServerHealthMonitor.component.d.ts +0 -2
- package/dist/node/controllers/A-ServerHealthMonitor/A-ServerHealthMonitor.component.js +10 -1
- package/dist/node/controllers/A-ServerHealthMonitor/A-ServerHealthMonitor.component.js.map +1 -1
- package/dist/node/controllers/A-ServerHealthMonitor/A-ServerHealthMonitor.component.mjs +10 -1
- package/dist/node/controllers/A-ServerHealthMonitor/A-ServerHealthMonitor.component.mjs.map +1 -1
- package/dist/node/index.d.mts +3 -1
- package/dist/node/index.d.ts +3 -1
- package/dist/node/index.js +14 -0
- package/dist/node/index.mjs +2 -0
- package/dist/node/lib/A-Server/A-HttpServer.container.d.mts +4 -6
- package/dist/node/lib/A-Server/A-HttpServer.container.d.ts +4 -6
- package/dist/node/lib/A-ServerController/A-ServerController.component.js +17 -4
- package/dist/node/lib/A-ServerController/A-ServerController.component.js.map +1 -1
- package/dist/node/lib/A-ServerController/A-ServerController.component.mjs +17 -4
- package/dist/node/lib/A-ServerController/A-ServerController.component.mjs.map +1 -1
- package/dist/node/lib/A-ServerEntityList/A-EntityList.entity.d.mts +52 -28
- package/dist/node/lib/A-ServerEntityList/A-EntityList.entity.d.ts +52 -28
- package/dist/node/lib/A-ServerEntityList/A-EntityList.entity.js +117 -44
- package/dist/node/lib/A-ServerEntityList/A-EntityList.entity.js.map +1 -1
- package/dist/node/lib/A-ServerEntityList/A-EntityList.entity.mjs +118 -45
- package/dist/node/lib/A-ServerEntityList/A-EntityList.entity.mjs.map +1 -1
- package/dist/node/lib/A-ServerEntityList/A-EntityList.types.d.mts +14 -6
- package/dist/node/lib/A-ServerEntityList/A-EntityList.types.d.ts +14 -6
- package/dist/node/lib/A-ServerEntityList/A-EntityList.types.js.map +1 -1
- package/dist/node/lib/A-ServerEntityList/A-EntityList.types.mjs.map +1 -1
- package/dist/node/lib/A-ServerEntityList/A-EntityListCacheState.context.d.mts +12 -0
- package/dist/node/lib/A-ServerEntityList/A-EntityListCacheState.context.d.ts +12 -0
- package/dist/node/lib/A-ServerEntityList/A-EntityListCacheState.context.js +25 -0
- package/dist/node/lib/A-ServerEntityList/A-EntityListCacheState.context.js.map +1 -0
- package/dist/node/lib/A-ServerEntityList/A-EntityListCacheState.context.mjs +24 -0
- package/dist/node/lib/A-ServerEntityList/A-EntityListCacheState.context.mjs.map +1 -0
- package/dist/node/lib/A-ServerEntityList/A-EntityListPagination.context.d.mts +18 -0
- package/dist/node/lib/A-ServerEntityList/A-EntityListPagination.context.d.ts +18 -0
- package/dist/node/lib/A-ServerEntityList/A-EntityListPagination.context.js +48 -0
- package/dist/node/lib/A-ServerEntityList/A-EntityListPagination.context.js.map +1 -0
- package/dist/node/lib/A-ServerEntityList/A-EntityListPagination.context.mjs +47 -0
- package/dist/node/lib/A-ServerEntityList/A-EntityListPagination.context.mjs.map +1 -0
- package/dist/node/lib/A-ServerLogger/A-ServerLogger.component.d.mts +6 -8
- package/dist/node/lib/A-ServerLogger/A-ServerLogger.component.d.ts +6 -8
- package/dist/node/lib/A-ServerLogger/A-ServerLogger.component.js +3 -4
- package/dist/node/lib/A-ServerLogger/A-ServerLogger.component.js.map +1 -1
- package/dist/node/lib/A-ServerLogger/A-ServerLogger.component.mjs +4 -5
- package/dist/node/lib/A-ServerLogger/A-ServerLogger.component.mjs.map +1 -1
- package/dist/node/lib/A-ServerRouter/A-ServerRouter.component.d.mts +0 -2
- package/dist/node/lib/A-ServerRouter/A-ServerRouter.component.d.ts +0 -2
- package/dist/node/middlewares/A-ServerCORS/A_ServerCORS.component.js +1 -1
- package/dist/node/middlewares/A-ServerCORS/A_ServerCORS.component.js.map +1 -1
- package/dist/node/middlewares/A-ServerCORS/A_ServerCORS.component.mjs +1 -1
- package/dist/node/middlewares/A-ServerCORS/A_ServerCORS.component.mjs.map +1 -1
- package/dist/node/repositories/A-EntityRepository/A-EntityRepository.component.d.mts +1 -0
- package/dist/node/repositories/A-EntityRepository/A-EntityRepository.component.d.ts +1 -0
- package/examples/simple-server/components/Users.repository.ts +2 -2
- package/jest.config.ts +1 -0
- package/package.json +5 -5
- package/src/controllers/A-EntityController/A-EntityController.component.ts +69 -109
- package/src/controllers/A-ListingController/A-ListingController.component.ts +22 -20
- package/src/controllers/A-ServerHealthMonitor/A-ServerHealthMonitor.component.ts +11 -1
- package/src/index.ts +2 -0
- package/src/lib/A-ServerController/A-ServerController.component.ts +17 -8
- package/src/lib/A-ServerEntityList/A-EntityList.entity.ts +159 -55
- package/src/lib/A-ServerEntityList/A-EntityList.types.ts +17 -7
- package/src/lib/A-ServerEntityList/A-EntityListCacheState.context.ts +27 -0
- package/src/lib/A-ServerEntityList/A-EntityListPagination.context.ts +48 -0
- package/src/lib/A-ServerLogger/A-ServerLogger.component.ts +3 -4
- package/src/middlewares/A-ServerCORS/A_ServerCORS.component.ts +1 -1
- package/tests/A-Server-CORS.test.ts +542 -0
- package/tests/A-Server-Entity.test.ts +205 -0
- package/tests/A-Server-Health.test.ts +89 -0
- package/tests/A-Server-Routes.test.ts +113 -0
- package/tests/A-ServerEntityList.test.ts +416 -0
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
import http from 'http';
|
|
2
|
+
import { A_Concept, A_Component, A_Inject } from '@adaas/a-concept';
|
|
3
|
+
import { A_Config, ENVConfigReader } from '@adaas/a-utils/a-config';
|
|
4
|
+
import { A_Polyfill } from '@adaas/a-utils/a-polyfill';
|
|
5
|
+
import { A_HttpServer } from '@adaas/a-server/server/A-HttpServer.container';
|
|
6
|
+
import { A_ServerRouter } from '@adaas/a-server/router/A-ServerRouter.component';
|
|
7
|
+
import { A_ServerLogger } from '@adaas/a-server/logger/A-ServerLogger.component';
|
|
8
|
+
import { A_ServerController } from '@adaas/a-server/controller/A-ServerController.component';
|
|
9
|
+
import { A_ServerHealthMonitor } from '@adaas/a-server/controllers/A-ServerHealthMonitor/A-ServerHealthMonitor.component';
|
|
10
|
+
import { A_ServerCORS } from '@adaas/a-server/middlewares/A-ServerCORS/A_ServerCORS.component';
|
|
11
|
+
import { A_Request } from '@adaas/a-server/request/A-Request.entity';
|
|
12
|
+
import { A_Response } from '@adaas/a-server/response/A-Response.entity';
|
|
13
|
+
|
|
14
|
+
jest.retryTimes(0);
|
|
15
|
+
jest.setTimeout(30_000);
|
|
16
|
+
|
|
17
|
+
const TEST_PORT = 3905;
|
|
18
|
+
|
|
19
|
+
type ResponseHeaders = Record<string, string | string[] | undefined>;
|
|
20
|
+
|
|
21
|
+
function httpRequest(
|
|
22
|
+
method: string,
|
|
23
|
+
path: string,
|
|
24
|
+
reqHeaders: Record<string, string> = {}
|
|
25
|
+
): Promise<{ status: number; body: unknown; headers: ResponseHeaders }> {
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
const options: http.RequestOptions = {
|
|
28
|
+
hostname: 'localhost',
|
|
29
|
+
port: TEST_PORT,
|
|
30
|
+
path,
|
|
31
|
+
method,
|
|
32
|
+
headers: reqHeaders,
|
|
33
|
+
};
|
|
34
|
+
const req = http.request(options, (res) => {
|
|
35
|
+
let raw = '';
|
|
36
|
+
res.on('data', (chunk: Buffer) => { raw += chunk.toString(); });
|
|
37
|
+
res.on('end', () => {
|
|
38
|
+
try {
|
|
39
|
+
resolve({ status: res.statusCode ?? 0, body: JSON.parse(raw), headers: res.headers as ResponseHeaders });
|
|
40
|
+
} catch {
|
|
41
|
+
resolve({ status: res.statusCode ?? 0, body: raw, headers: res.headers as ResponseHeaders });
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
req.on('error', reject);
|
|
46
|
+
req.end();
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Minimal controller to ensure there is a non-health route available
|
|
51
|
+
class PingController extends A_Component {
|
|
52
|
+
@A_ServerRouter.Get({
|
|
53
|
+
path: '/ping',
|
|
54
|
+
version: 'v1',
|
|
55
|
+
prefix: 'cors-test',
|
|
56
|
+
})
|
|
57
|
+
async ping(
|
|
58
|
+
@A_Inject(A_Request) _request: A_Request,
|
|
59
|
+
@A_Inject(A_Response) response: A_Response,
|
|
60
|
+
) {
|
|
61
|
+
response.add('pong', true);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe('A-ServerCORS Tests — custom config', () => {
|
|
66
|
+
let concept: A_Concept;
|
|
67
|
+
|
|
68
|
+
beforeAll(async () => {
|
|
69
|
+
const server = new A_HttpServer({
|
|
70
|
+
name: 'cors-test-server',
|
|
71
|
+
components: [
|
|
72
|
+
A_Polyfill,
|
|
73
|
+
A_ServerLogger,
|
|
74
|
+
ENVConfigReader,
|
|
75
|
+
A_ServerRouter,
|
|
76
|
+
A_ServerController,
|
|
77
|
+
A_ServerHealthMonitor,
|
|
78
|
+
A_ServerCORS,
|
|
79
|
+
PingController,
|
|
80
|
+
],
|
|
81
|
+
entities: [],
|
|
82
|
+
fragments: [
|
|
83
|
+
new A_Config({
|
|
84
|
+
variables: [
|
|
85
|
+
'A_SERVER_PORT',
|
|
86
|
+
'A_ROUTER__PARSE_PARAMS_AUTOMATICALLY',
|
|
87
|
+
'CONFIG_VERBOSE',
|
|
88
|
+
'ORIGIN',
|
|
89
|
+
'CREDENTIALS',
|
|
90
|
+
'MAX_AGE',
|
|
91
|
+
] as const,
|
|
92
|
+
defaults: {
|
|
93
|
+
A_SERVER_PORT: TEST_PORT,
|
|
94
|
+
A_ROUTER__PARSE_PARAMS_AUTOMATICALLY: true,
|
|
95
|
+
CONFIG_VERBOSE: false,
|
|
96
|
+
ORIGIN: 'https://allowed.example.com',
|
|
97
|
+
CREDENTIALS: true,
|
|
98
|
+
MAX_AGE: 600,
|
|
99
|
+
},
|
|
100
|
+
}),
|
|
101
|
+
],
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
concept = new A_Concept({
|
|
105
|
+
name: 'cors-test-concept',
|
|
106
|
+
containers: [server],
|
|
107
|
+
components: [],
|
|
108
|
+
fragments: [],
|
|
109
|
+
entities: [],
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
await concept.load();
|
|
113
|
+
await concept.start();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
afterAll(async () => {
|
|
117
|
+
await concept.stop();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('should include Access-Control-Allow-Origin on a regular GET response', async () => {
|
|
121
|
+
const { status, headers } = await httpRequest('GET', '/cors-test/v1/ping', {
|
|
122
|
+
Origin: 'https://allowed.example.com',
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
expect(status).toBe(200);
|
|
126
|
+
expect(headers['access-control-allow-origin']).toBe('https://allowed.example.com');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('should include Access-Control-Allow-Methods on a regular GET response', async () => {
|
|
130
|
+
const { headers } = await httpRequest('GET', '/cors-test/v1/ping');
|
|
131
|
+
|
|
132
|
+
expect(headers['access-control-allow-methods']).toBeDefined();
|
|
133
|
+
expect(typeof headers['access-control-allow-methods']).toBe('string');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('should include Access-Control-Allow-Headers on a regular GET response', async () => {
|
|
137
|
+
const { headers } = await httpRequest('GET', '/cors-test/v1/ping');
|
|
138
|
+
|
|
139
|
+
expect(headers['access-control-allow-headers']).toBeDefined();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('should include Access-Control-Allow-Credentials when credentials are enabled', async () => {
|
|
143
|
+
const { headers } = await httpRequest('GET', '/cors-test/v1/ping');
|
|
144
|
+
|
|
145
|
+
expect(headers['access-control-allow-credentials']).toBe('true');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('should include Access-Control-Max-Age when maxAge is configured', async () => {
|
|
149
|
+
const { headers } = await httpRequest('GET', '/cors-test/v1/ping');
|
|
150
|
+
|
|
151
|
+
expect(headers['access-control-max-age']).toBe('600');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should return 204 for OPTIONS preflight request', async () => {
|
|
155
|
+
const { status } = await httpRequest('OPTIONS', '/cors-test/v1/ping', {
|
|
156
|
+
Origin: 'https://allowed.example.com',
|
|
157
|
+
'Access-Control-Request-Method': 'GET',
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
expect(status).toBe(204);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('should include CORS headers on OPTIONS preflight response', async () => {
|
|
164
|
+
const { headers } = await httpRequest('OPTIONS', '/cors-test/v1/ping', {
|
|
165
|
+
Origin: 'https://allowed.example.com',
|
|
166
|
+
'Access-Control-Request-Method': 'POST',
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
expect(headers['access-control-allow-origin']).toBe('https://allowed.example.com');
|
|
170
|
+
expect(headers['access-control-allow-methods']).toBeDefined();
|
|
171
|
+
expect(headers['access-control-allow-headers']).toBeDefined();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('should also apply CORS headers to health endpoint', async () => {
|
|
175
|
+
const { status, headers } = await httpRequest('GET', '/health/v1/');
|
|
176
|
+
|
|
177
|
+
expect(status).toBe(200);
|
|
178
|
+
expect(headers['access-control-allow-origin']).toBe('https://allowed.example.com');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('should include CORS headers on a 404 error response', async () => {
|
|
182
|
+
const { status, headers } = await httpRequest('GET', '/does-not-exist/v1/nowhere');
|
|
183
|
+
|
|
184
|
+
expect(status).toBe(404);
|
|
185
|
+
expect(headers['access-control-allow-origin']).toBe('https://allowed.example.com');
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe('A-ServerCORS Tests — default config', () => {
|
|
190
|
+
const DEFAULT_PORT = TEST_PORT + 1;
|
|
191
|
+
let concept: A_Concept;
|
|
192
|
+
|
|
193
|
+
function httpGetDefault(path: string): Promise<{ status: number; headers: ResponseHeaders }> {
|
|
194
|
+
return new Promise((resolve, reject) => {
|
|
195
|
+
const req = http.request(
|
|
196
|
+
{ hostname: 'localhost', port: DEFAULT_PORT, path, method: 'GET' },
|
|
197
|
+
(res) => {
|
|
198
|
+
res.on('data', () => { /* drain */ });
|
|
199
|
+
res.on('end', () => resolve({ status: res.statusCode ?? 0, headers: res.headers as ResponseHeaders }));
|
|
200
|
+
}
|
|
201
|
+
);
|
|
202
|
+
req.on('error', reject);
|
|
203
|
+
req.end();
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
beforeAll(async () => {
|
|
208
|
+
const server = new A_HttpServer({
|
|
209
|
+
name: 'cors-default-test-server',
|
|
210
|
+
components: [
|
|
211
|
+
A_Polyfill,
|
|
212
|
+
A_ServerLogger,
|
|
213
|
+
ENVConfigReader,
|
|
214
|
+
A_ServerRouter,
|
|
215
|
+
A_ServerController,
|
|
216
|
+
A_ServerHealthMonitor,
|
|
217
|
+
A_ServerCORS,
|
|
218
|
+
],
|
|
219
|
+
entities: [],
|
|
220
|
+
fragments: [
|
|
221
|
+
new A_Config({
|
|
222
|
+
variables: ['A_SERVER_PORT', 'CONFIG_VERBOSE'] as const,
|
|
223
|
+
defaults: {
|
|
224
|
+
A_SERVER_PORT: DEFAULT_PORT,
|
|
225
|
+
CONFIG_VERBOSE: false,
|
|
226
|
+
},
|
|
227
|
+
}),
|
|
228
|
+
],
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
concept = new A_Concept({
|
|
232
|
+
name: 'cors-default-test-concept',
|
|
233
|
+
containers: [server],
|
|
234
|
+
components: [],
|
|
235
|
+
fragments: [],
|
|
236
|
+
entities: [],
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
await concept.load();
|
|
240
|
+
await concept.start();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
afterAll(async () => {
|
|
244
|
+
await concept.stop();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('should use wildcard origin by default', async () => {
|
|
248
|
+
const { status, headers } = await httpGetDefault('/health/v1/');
|
|
249
|
+
|
|
250
|
+
expect(status).toBe(200);
|
|
251
|
+
expect(headers['access-control-allow-origin']).toBe('*');
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('should not include credentials header when credentials are disabled by default', async () => {
|
|
255
|
+
const { headers } = await httpGetDefault('/health/v1/');
|
|
256
|
+
|
|
257
|
+
expect(headers['access-control-allow-credentials']).toBeUndefined();
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe('A-ServerCORS Tests — explicit wildcard origin', () => {
|
|
262
|
+
const WILDCARD_PORT = TEST_PORT + 2;
|
|
263
|
+
let concept: A_Concept;
|
|
264
|
+
|
|
265
|
+
function httpWildcard(
|
|
266
|
+
method: string,
|
|
267
|
+
path: string,
|
|
268
|
+
reqHeaders: Record<string, string> = {}
|
|
269
|
+
): Promise<{ status: number; headers: ResponseHeaders }> {
|
|
270
|
+
return new Promise((resolve, reject) => {
|
|
271
|
+
const req = http.request(
|
|
272
|
+
{ hostname: 'localhost', port: WILDCARD_PORT, path, method, headers: reqHeaders },
|
|
273
|
+
(res) => {
|
|
274
|
+
res.on('data', () => { /* drain */ });
|
|
275
|
+
res.on('end', () => resolve({ status: res.statusCode ?? 0, headers: res.headers as ResponseHeaders }));
|
|
276
|
+
}
|
|
277
|
+
);
|
|
278
|
+
req.on('error', reject);
|
|
279
|
+
req.end();
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
beforeAll(async () => {
|
|
284
|
+
const server = new A_HttpServer({
|
|
285
|
+
name: 'cors-wildcard-test-server',
|
|
286
|
+
components: [
|
|
287
|
+
A_Polyfill,
|
|
288
|
+
A_ServerLogger,
|
|
289
|
+
ENVConfigReader,
|
|
290
|
+
A_ServerRouter,
|
|
291
|
+
A_ServerController,
|
|
292
|
+
A_ServerHealthMonitor,
|
|
293
|
+
A_ServerCORS,
|
|
294
|
+
PingController,
|
|
295
|
+
],
|
|
296
|
+
entities: [],
|
|
297
|
+
fragments: [
|
|
298
|
+
new A_Config({
|
|
299
|
+
variables: [
|
|
300
|
+
'A_SERVER_PORT',
|
|
301
|
+
'A_ROUTER__PARSE_PARAMS_AUTOMATICALLY',
|
|
302
|
+
'CONFIG_VERBOSE',
|
|
303
|
+
'ORIGIN',
|
|
304
|
+
] as const,
|
|
305
|
+
defaults: {
|
|
306
|
+
A_SERVER_PORT: WILDCARD_PORT,
|
|
307
|
+
A_ROUTER__PARSE_PARAMS_AUTOMATICALLY: true,
|
|
308
|
+
CONFIG_VERBOSE: false,
|
|
309
|
+
ORIGIN: '*',
|
|
310
|
+
},
|
|
311
|
+
}),
|
|
312
|
+
],
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
concept = new A_Concept({
|
|
316
|
+
name: 'cors-wildcard-test-concept',
|
|
317
|
+
containers: [server],
|
|
318
|
+
components: [],
|
|
319
|
+
fragments: [],
|
|
320
|
+
entities: [],
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
await concept.load();
|
|
324
|
+
await concept.start();
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
afterAll(async () => {
|
|
328
|
+
await concept.stop();
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('should return Access-Control-Allow-Origin: * on a regular GET', async () => {
|
|
332
|
+
const { status, headers } = await httpWildcard('GET', '/cors-test/v1/ping');
|
|
333
|
+
|
|
334
|
+
expect(status).toBe(200);
|
|
335
|
+
expect(headers['access-control-allow-origin']).toBe('*');
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('should return Access-Control-Allow-Origin: * even when a specific Origin header is sent', async () => {
|
|
339
|
+
const { status, headers } = await httpWildcard('GET', '/cors-test/v1/ping', {
|
|
340
|
+
Origin: 'https://some-other-domain.com',
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
expect(status).toBe(200);
|
|
344
|
+
expect(headers['access-control-allow-origin']).toBe('*');
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it('should return 204 for OPTIONS preflight with wildcard origin', async () => {
|
|
348
|
+
const { status, headers } = await httpWildcard('OPTIONS', '/cors-test/v1/ping', {
|
|
349
|
+
Origin: 'https://any-origin.com',
|
|
350
|
+
'Access-Control-Request-Method': 'GET',
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
expect(status).toBe(204);
|
|
354
|
+
expect(headers['access-control-allow-origin']).toBe('*');
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it('should include Access-Control-Allow-Methods on wildcard preflight', async () => {
|
|
358
|
+
const { headers } = await httpWildcard('OPTIONS', '/cors-test/v1/ping', {
|
|
359
|
+
Origin: 'https://any-origin.com',
|
|
360
|
+
'Access-Control-Request-Method': 'POST',
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
expect(headers['access-control-allow-methods']).toBeDefined();
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// ---------------------------------------------------------------------------
|
|
368
|
+
// Browser cross-origin simulation
|
|
369
|
+
// Replicates the exact two-step handshake a browser performs when a page at
|
|
370
|
+
// https://app.example.com makes a fetch() to https://api.example.com:
|
|
371
|
+
// Step 1 — preflight: OPTIONS <path> (no body, browser-added headers)
|
|
372
|
+
// Step 2 — real GET: GET <path> (with Origin, custom header)
|
|
373
|
+
//
|
|
374
|
+
// The server MUST grant permission in the preflight response, and the actual
|
|
375
|
+
// response MUST also carry the CORS headers so the browser can expose it.
|
|
376
|
+
// ---------------------------------------------------------------------------
|
|
377
|
+
describe('A-ServerCORS Tests — browser cross-origin simulation', () => {
|
|
378
|
+
const BROWSER_PORT = TEST_PORT + 3;
|
|
379
|
+
const BROWSER_ORIGIN = 'https://app.example.com';
|
|
380
|
+
const CUSTOM_HEADER = 'X-Custom-Token';
|
|
381
|
+
let concept: A_Concept;
|
|
382
|
+
|
|
383
|
+
// Generic per-port request helper scoped to this suite
|
|
384
|
+
function browserRequest(
|
|
385
|
+
method: string,
|
|
386
|
+
path: string,
|
|
387
|
+
reqHeaders: Record<string, string> = {}
|
|
388
|
+
): Promise<{ status: number; body: unknown; headers: ResponseHeaders }> {
|
|
389
|
+
return new Promise((resolve, reject) => {
|
|
390
|
+
const req = http.request(
|
|
391
|
+
{
|
|
392
|
+
hostname: 'localhost',
|
|
393
|
+
port: BROWSER_PORT,
|
|
394
|
+
path,
|
|
395
|
+
method,
|
|
396
|
+
headers: reqHeaders,
|
|
397
|
+
},
|
|
398
|
+
(res) => {
|
|
399
|
+
let raw = '';
|
|
400
|
+
res.on('data', (chunk: Buffer) => { raw += chunk.toString(); });
|
|
401
|
+
res.on('end', () => {
|
|
402
|
+
try {
|
|
403
|
+
resolve({ status: res.statusCode ?? 0, body: JSON.parse(raw), headers: res.headers as ResponseHeaders });
|
|
404
|
+
} catch {
|
|
405
|
+
resolve({ status: res.statusCode ?? 0, body: raw, headers: res.headers as ResponseHeaders });
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
);
|
|
410
|
+
req.on('error', reject);
|
|
411
|
+
req.end();
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
beforeAll(async () => {
|
|
416
|
+
const server = new A_HttpServer({
|
|
417
|
+
name: 'cors-browser-sim-server',
|
|
418
|
+
components: [
|
|
419
|
+
A_Polyfill,
|
|
420
|
+
A_ServerLogger,
|
|
421
|
+
ENVConfigReader,
|
|
422
|
+
A_ServerRouter,
|
|
423
|
+
A_ServerController,
|
|
424
|
+
A_ServerHealthMonitor,
|
|
425
|
+
A_ServerCORS,
|
|
426
|
+
PingController,
|
|
427
|
+
],
|
|
428
|
+
entities: [],
|
|
429
|
+
fragments: [
|
|
430
|
+
new A_Config({
|
|
431
|
+
variables: [
|
|
432
|
+
'A_SERVER_PORT',
|
|
433
|
+
'A_ROUTER__PARSE_PARAMS_AUTOMATICALLY',
|
|
434
|
+
'CONFIG_VERBOSE',
|
|
435
|
+
'ORIGIN',
|
|
436
|
+
'HEADERS',
|
|
437
|
+
'CREDENTIALS',
|
|
438
|
+
'MAX_AGE',
|
|
439
|
+
] as const,
|
|
440
|
+
defaults: {
|
|
441
|
+
A_SERVER_PORT: BROWSER_PORT,
|
|
442
|
+
A_ROUTER__PARSE_PARAMS_AUTOMATICALLY: true,
|
|
443
|
+
CONFIG_VERBOSE: false,
|
|
444
|
+
ORIGIN: BROWSER_ORIGIN,
|
|
445
|
+
HEADERS: ['Content-Type', CUSTOM_HEADER] as any,
|
|
446
|
+
CREDENTIALS: true,
|
|
447
|
+
MAX_AGE: 86400,
|
|
448
|
+
},
|
|
449
|
+
}),
|
|
450
|
+
],
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
concept = new A_Concept({
|
|
454
|
+
name: 'cors-browser-sim-concept',
|
|
455
|
+
containers: [server],
|
|
456
|
+
components: [],
|
|
457
|
+
fragments: [],
|
|
458
|
+
entities: [],
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
await concept.load();
|
|
462
|
+
await concept.start();
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
afterAll(async () => {
|
|
466
|
+
await concept.stop();
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it('Step 1 — preflight: browser sends OPTIONS with Access-Control-Request-Method and Access-Control-Request-Headers', async () => {
|
|
470
|
+
// This is exactly what Chrome/Firefox sends before a cross-origin fetch()
|
|
471
|
+
// with a non-simple method or a custom header.
|
|
472
|
+
const { status, headers } = await browserRequest('OPTIONS', '/cors-test/v1/ping', {
|
|
473
|
+
'Origin': BROWSER_ORIGIN,
|
|
474
|
+
'Access-Control-Request-Method': 'GET',
|
|
475
|
+
'Access-Control-Request-Headers': `content-type, ${CUSTOM_HEADER.toLowerCase()}`,
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
// Browser requires 2xx (typically 204) to proceed with the real request.
|
|
479
|
+
expect(status).toBe(204);
|
|
480
|
+
|
|
481
|
+
// Origin must be echoed back exactly.
|
|
482
|
+
expect(headers['access-control-allow-origin']).toBe(BROWSER_ORIGIN);
|
|
483
|
+
|
|
484
|
+
// The requested method must be listed.
|
|
485
|
+
expect(headers['access-control-allow-methods']).toContain('GET');
|
|
486
|
+
|
|
487
|
+
// The custom header must be explicitly allowed.
|
|
488
|
+
const allowedHeaders = String(headers['access-control-allow-headers'] ?? '').toLowerCase();
|
|
489
|
+
expect(allowedHeaders).toContain(CUSTOM_HEADER.toLowerCase());
|
|
490
|
+
|
|
491
|
+
// Credentials must be allowed so the browser sends cookies/auth.
|
|
492
|
+
expect(headers['access-control-allow-credentials']).toBe('true');
|
|
493
|
+
|
|
494
|
+
// Max-age tells the browser to cache this preflight result.
|
|
495
|
+
expect(headers['access-control-max-age']).toBe('86400');
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it('Step 2 — actual request: browser sends GET with Origin after a successful preflight', async () => {
|
|
499
|
+
// After a passing preflight the browser sends the real request.
|
|
500
|
+
// The response must also carry CORS headers so the browser can read the body.
|
|
501
|
+
const { status, headers, body } = await browserRequest('GET', '/cors-test/v1/ping', {
|
|
502
|
+
'Origin': BROWSER_ORIGIN,
|
|
503
|
+
CUSTOM_HEADER: 'test-token-value',
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
expect(status).toBe(200);
|
|
507
|
+
expect(headers['access-control-allow-origin']).toBe(BROWSER_ORIGIN);
|
|
508
|
+
expect(headers['access-control-allow-credentials']).toBe('true');
|
|
509
|
+
expect((body as Record<string, unknown>).pong).toBe(true);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it('Full flow — preflight immediately followed by actual request succeeds end-to-end', async () => {
|
|
513
|
+
// Simulate the complete browser fetch() lifecycle in sequence.
|
|
514
|
+
const preflightHeaders = {
|
|
515
|
+
'Origin': BROWSER_ORIGIN,
|
|
516
|
+
'Access-Control-Request-Method': 'GET',
|
|
517
|
+
'Access-Control-Request-Headers': 'content-type',
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
const { status: preflightStatus } = await browserRequest('OPTIONS', '/cors-test/v1/ping', preflightHeaders);
|
|
521
|
+
expect(preflightStatus).toBe(204);
|
|
522
|
+
|
|
523
|
+
const { status: actualStatus, headers: actualHeaders } = await browserRequest('GET', '/cors-test/v1/ping', {
|
|
524
|
+
'Origin': BROWSER_ORIGIN,
|
|
525
|
+
'Content-Type': 'application/json',
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
expect(actualStatus).toBe(200);
|
|
529
|
+
expect(actualHeaders['access-control-allow-origin']).toBe(BROWSER_ORIGIN);
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
it('should include CORS headers on a cross-origin error response (browser can read the error body)', async () => {
|
|
533
|
+
// If the server returns 4xx/5xx, the browser can only read the error body
|
|
534
|
+
// if CORS headers are present on the error response too.
|
|
535
|
+
const { status, headers } = await browserRequest('GET', '/does-not-exist', {
|
|
536
|
+
'Origin': BROWSER_ORIGIN,
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
expect(status).toBeGreaterThanOrEqual(400);
|
|
540
|
+
expect(headers['access-control-allow-origin']).toBe(BROWSER_ORIGIN);
|
|
541
|
+
});
|
|
542
|
+
});
|