lambder 1.0.147 → 2.0.2
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/Readme.md +355 -410
- package/dist/Lambder.d.ts +47 -21
- package/dist/Lambder.js +79 -24
- package/dist/LambderApiContract.d.ts +10 -42
- package/dist/LambderApiContract.js +2 -22
- package/dist/LambderCaller.js +2 -2
- package/dist/LambderMSW.js +0 -4
- package/dist/LambderResolver.d.ts +16 -17
- package/dist/LambderResponseBuilder.d.ts +2 -3
- package/dist/LambderResponseBuilder.js +1 -3
- package/dist/LambderUtils.js +1 -3
- package/dist/index.d.ts +1 -1
- package/docs/LAMBDER_MSW.md +6 -6
- package/docs/TYPE_SAFE_QUICK_START.md +54 -177
- package/examples/msw-testing-example.ts +36 -33
- package/examples/secure-session-example.ts +50 -34
- package/examples/zod-chained-api-example.ts +63 -0
- package/package.json +3 -2
- package/src/Lambder.ts +124 -83
- package/src/LambderApiContract.ts +7 -50
- package/src/LambderCaller.ts +2 -2
- package/src/LambderMSW.ts +0 -7
- package/src/LambderResolver.ts +21 -24
- package/src/LambderResponseBuilder.ts +4 -7
- package/src/LambderUtils.ts +1 -3
- package/src/index.ts +0 -3
- package/tests/UNTESTED_FEATURES.md +263 -0
- package/tests/error-handling.test.ts +585 -0
- package/tests/hooks.test.ts +561 -0
- package/tests/output-type-runtime.test.ts +80 -64
- package/tests/routes.test.ts +542 -0
- package/tests/session.test.ts +38 -24
- package/tests/type-safety.test.ts +147 -97
- package/tests/use-plugin.test.ts +437 -0
- package/OUTPUT_TYPE_ENFORCEMENT_SUMMARY.md +0 -90
- package/examples/output-type-enforcement-example.ts +0 -218
- package/examples/simplified-typed-api-example.ts +0 -365
- package/examples/test-output-type-enforcement.ts +0 -101
- package/test-type-enforcement.ts +0 -111
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Routes Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for addRoute and addSessionRoute functionality including:
|
|
5
|
+
* - String path matching
|
|
6
|
+
* - Path parameter extraction
|
|
7
|
+
* - RegExp route matching
|
|
8
|
+
* - Function-based conditional routing
|
|
9
|
+
* - Session-protected routes
|
|
10
|
+
* - Route priority/ordering
|
|
11
|
+
* - Wildcard routes
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
15
|
+
import { mockClient } from 'aws-sdk-client-mock';
|
|
16
|
+
import { DynamoDBDocumentClient, GetCommand, PutCommand } from '@aws-sdk/lib-dynamodb';
|
|
17
|
+
import Lambder from '../src/Lambder.js';
|
|
18
|
+
import type { APIGatewayProxyEvent, Context } from 'aws-lambda';
|
|
19
|
+
|
|
20
|
+
// Mock DynamoDB
|
|
21
|
+
const ddbMock = mockClient(DynamoDBDocumentClient);
|
|
22
|
+
|
|
23
|
+
const createMockEvent = (path: string, method: string = 'GET', sessionToken?: string): APIGatewayProxyEvent => ({
|
|
24
|
+
body: null,
|
|
25
|
+
headers: {
|
|
26
|
+
Host: 'localhost',
|
|
27
|
+
Cookie: sessionToken ? `LMDRSESSIONTKID=${sessionToken}` : ''
|
|
28
|
+
},
|
|
29
|
+
multiValueHeaders: {},
|
|
30
|
+
httpMethod: method,
|
|
31
|
+
isBase64Encoded: false,
|
|
32
|
+
path,
|
|
33
|
+
pathParameters: null,
|
|
34
|
+
queryStringParameters: null,
|
|
35
|
+
multiValueQueryStringParameters: null,
|
|
36
|
+
stageVariables: null,
|
|
37
|
+
requestContext: {} as any,
|
|
38
|
+
resource: '',
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const createMockContext = (): Context => ({
|
|
42
|
+
callbackWaitsForEmptyEventLoop: false,
|
|
43
|
+
functionName: 'test',
|
|
44
|
+
functionVersion: '1',
|
|
45
|
+
invokedFunctionArn: 'arn',
|
|
46
|
+
memoryLimitInMB: '128',
|
|
47
|
+
awsRequestId: '123',
|
|
48
|
+
logGroupName: 'group',
|
|
49
|
+
logStreamName: 'stream',
|
|
50
|
+
getRemainingTimeInMillis: () => 1000,
|
|
51
|
+
done: () => {},
|
|
52
|
+
fail: () => {},
|
|
53
|
+
succeed: () => {},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('Routes - Basic Path Matching', () => {
|
|
57
|
+
it('should match simple string paths', async () => {
|
|
58
|
+
const lambder = new Lambder({
|
|
59
|
+
publicPath: './public',
|
|
60
|
+
apiPath: '/api'
|
|
61
|
+
})
|
|
62
|
+
.addRoute('/hello', (ctx, res) => {
|
|
63
|
+
return res.html('Hello World');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const handler = lambder.getHandler();
|
|
67
|
+
const event = createMockEvent('/hello');
|
|
68
|
+
const result = await handler(event, createMockContext());
|
|
69
|
+
|
|
70
|
+
expect(result.statusCode).toBe(200);
|
|
71
|
+
const body = Buffer.from(result.body || '', 'base64').toString();
|
|
72
|
+
expect(body).toBe('Hello World');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should not match wrong paths', async () => {
|
|
76
|
+
const lambder = new Lambder({
|
|
77
|
+
publicPath: './public',
|
|
78
|
+
apiPath: '/api'
|
|
79
|
+
})
|
|
80
|
+
.addRoute('/hello', (ctx, res) => {
|
|
81
|
+
return res.html('Hello');
|
|
82
|
+
})
|
|
83
|
+
.setRouteFallbackHandler((ctx, res) => {
|
|
84
|
+
return res.status404('Not Found');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const handler = lambder.getHandler();
|
|
88
|
+
const event = createMockEvent('/goodbye');
|
|
89
|
+
const result = await handler(event, createMockContext());
|
|
90
|
+
|
|
91
|
+
expect(result.statusCode).toBe(404);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('should match multiple routes', async () => {
|
|
95
|
+
const lambder = new Lambder({
|
|
96
|
+
publicPath: './public',
|
|
97
|
+
apiPath: '/api'
|
|
98
|
+
})
|
|
99
|
+
.addRoute('/home', (ctx, res) => {
|
|
100
|
+
return res.html('Home Page');
|
|
101
|
+
})
|
|
102
|
+
.addRoute('/about', (ctx, res) => {
|
|
103
|
+
return res.html('About Page');
|
|
104
|
+
})
|
|
105
|
+
.addRoute('/contact', (ctx, res) => {
|
|
106
|
+
return res.html('Contact Page');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const handler = lambder.getHandler();
|
|
110
|
+
|
|
111
|
+
const homeResult = await handler(createMockEvent('/home'), createMockContext());
|
|
112
|
+
expect(Buffer.from(homeResult.body || '', 'base64').toString()).toBe('Home Page');
|
|
113
|
+
|
|
114
|
+
const aboutResult = await handler(createMockEvent('/about'), createMockContext());
|
|
115
|
+
expect(Buffer.from(aboutResult.body || '', 'base64').toString()).toBe('About Page');
|
|
116
|
+
|
|
117
|
+
const contactResult = await handler(createMockEvent('/contact'), createMockContext());
|
|
118
|
+
expect(Buffer.from(contactResult.body || '', 'base64').toString()).toBe('Contact Page');
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('Routes - Path Parameters', () => {
|
|
123
|
+
it('should extract path parameters from string patterns', async () => {
|
|
124
|
+
const lambder = new Lambder({
|
|
125
|
+
publicPath: './public',
|
|
126
|
+
apiPath: '/api'
|
|
127
|
+
})
|
|
128
|
+
.addRoute('/user/:userId', (ctx, res) => {
|
|
129
|
+
return res.json({ userId: ctx.pathParams?.userId });
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const handler = lambder.getHandler();
|
|
133
|
+
const event = createMockEvent('/user/123');
|
|
134
|
+
const result = await handler(event, createMockContext());
|
|
135
|
+
|
|
136
|
+
expect(result.statusCode).toBe(200);
|
|
137
|
+
const body = JSON.parse(result.body || '{}');
|
|
138
|
+
expect(body.userId).toBe('123');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('should extract multiple path parameters', async () => {
|
|
142
|
+
const lambder = new Lambder({
|
|
143
|
+
publicPath: './public',
|
|
144
|
+
apiPath: '/api'
|
|
145
|
+
})
|
|
146
|
+
.addRoute('/users/:userId/posts/:postId', (ctx, res) => {
|
|
147
|
+
return res.json({
|
|
148
|
+
userId: ctx.pathParams?.userId,
|
|
149
|
+
postId: ctx.pathParams?.postId
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const handler = lambder.getHandler();
|
|
154
|
+
const event = createMockEvent('/users/456/posts/789');
|
|
155
|
+
const result = await handler(event, createMockContext());
|
|
156
|
+
|
|
157
|
+
const body = JSON.parse(result.body || '{}');
|
|
158
|
+
expect(body.userId).toBe('456');
|
|
159
|
+
expect(body.postId).toBe('789');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('should handle optional parameters', async () => {
|
|
163
|
+
const lambder = new Lambder({
|
|
164
|
+
publicPath: './public',
|
|
165
|
+
apiPath: '/api'
|
|
166
|
+
})
|
|
167
|
+
.addRoute('/files/:path*', (ctx, res) => {
|
|
168
|
+
return res.json({ path: ctx.pathParams?.path });
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const handler = lambder.getHandler();
|
|
172
|
+
const event = createMockEvent('/files/documents/report.pdf');
|
|
173
|
+
const result = await handler(event, createMockContext());
|
|
174
|
+
|
|
175
|
+
const body = JSON.parse(result.body || '{}');
|
|
176
|
+
expect(body.path).toBeTruthy();
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe('Routes - RegExp Matching', () => {
|
|
181
|
+
it('should match routes using RegExp', async () => {
|
|
182
|
+
const lambder = new Lambder({
|
|
183
|
+
publicPath: './public',
|
|
184
|
+
apiPath: '/api'
|
|
185
|
+
})
|
|
186
|
+
.addRoute(/^\/admin/, (ctx, res) => {
|
|
187
|
+
return res.html('Admin Area');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const handler = lambder.getHandler();
|
|
191
|
+
|
|
192
|
+
const adminResult = await handler(createMockEvent('/admin'), createMockContext());
|
|
193
|
+
expect(Buffer.from(adminResult.body || '', 'base64').toString()).toBe('Admin Area');
|
|
194
|
+
|
|
195
|
+
const adminDashResult = await handler(createMockEvent('/admin/dashboard'), createMockContext());
|
|
196
|
+
expect(Buffer.from(adminDashResult.body || '', 'base64').toString()).toBe('Admin Area');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('should extract regex match groups', async () => {
|
|
200
|
+
const lambder = new Lambder({
|
|
201
|
+
publicPath: './public',
|
|
202
|
+
apiPath: '/api'
|
|
203
|
+
})
|
|
204
|
+
.addRoute(/^\/products\/(\d+)$/, (ctx, res) => {
|
|
205
|
+
const productId = ctx.pathParams?.[1];
|
|
206
|
+
return res.json({ productId });
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const handler = lambder.getHandler();
|
|
210
|
+
const event = createMockEvent('/products/999');
|
|
211
|
+
const result = await handler(event, createMockContext());
|
|
212
|
+
|
|
213
|
+
const body = JSON.parse(result.body || '{}');
|
|
214
|
+
expect(body.productId).toBe('999');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('should support complex regex patterns', async () => {
|
|
218
|
+
const lambder = new Lambder({
|
|
219
|
+
publicPath: './public',
|
|
220
|
+
apiPath: '/api'
|
|
221
|
+
})
|
|
222
|
+
.addRoute(/^\/api\/v\d+/, (ctx, res) => {
|
|
223
|
+
return res.json({ matched: true });
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const handler = lambder.getHandler();
|
|
227
|
+
|
|
228
|
+
const v1Result = await handler(createMockEvent('/api/v1'), createMockContext());
|
|
229
|
+
expect(JSON.parse(v1Result.body || '{}').matched).toBe(true);
|
|
230
|
+
|
|
231
|
+
const v2Result = await handler(createMockEvent('/api/v2'), createMockContext());
|
|
232
|
+
expect(JSON.parse(v2Result.body || '{}').matched).toBe(true);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
describe('Routes - Function-based Conditional Routing', () => {
|
|
237
|
+
it('should match routes using custom functions', async () => {
|
|
238
|
+
const lambder = new Lambder({
|
|
239
|
+
publicPath: './public',
|
|
240
|
+
apiPath: '/api'
|
|
241
|
+
})
|
|
242
|
+
.addRoute((ctx) => ctx.path.startsWith('/custom'), (ctx, res) => {
|
|
243
|
+
return res.html('Custom Route');
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
const handler = lambder.getHandler();
|
|
247
|
+
const event = createMockEvent('/custom/anything');
|
|
248
|
+
const result = await handler(event, createMockContext());
|
|
249
|
+
|
|
250
|
+
expect(Buffer.from(result.body || '', 'base64').toString()).toBe('Custom Route');
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('should support complex conditional logic', async () => {
|
|
254
|
+
const lambder = new Lambder({
|
|
255
|
+
publicPath: './public',
|
|
256
|
+
apiPath: '/api'
|
|
257
|
+
})
|
|
258
|
+
.addRoute(
|
|
259
|
+
(ctx) => ctx.path === '/special' && ctx.get.key === 'secret',
|
|
260
|
+
(ctx, res) => {
|
|
261
|
+
return res.html('Special Access');
|
|
262
|
+
}
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
const handler = lambder.getHandler();
|
|
266
|
+
|
|
267
|
+
// Without query param
|
|
268
|
+
const event1 = createMockEvent('/special');
|
|
269
|
+
const result1 = await handler(event1, createMockContext());
|
|
270
|
+
expect(result1.statusCode).toBe(204); // Fallback
|
|
271
|
+
|
|
272
|
+
// With query param
|
|
273
|
+
const event2: APIGatewayProxyEvent = {
|
|
274
|
+
...createMockEvent('/special'),
|
|
275
|
+
queryStringParameters: { key: 'secret' }
|
|
276
|
+
};
|
|
277
|
+
const result2 = await handler(event2, createMockContext());
|
|
278
|
+
expect(Buffer.from(result2.body || '', 'base64').toString()).toBe('Special Access');
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('should access context variables in condition', async () => {
|
|
282
|
+
const lambder = new Lambder({
|
|
283
|
+
publicPath: './public',
|
|
284
|
+
apiPath: '/api'
|
|
285
|
+
})
|
|
286
|
+
.addRoute(
|
|
287
|
+
(ctx) => ctx.host === 'admin.example.com' && ctx.path === '/dashboard',
|
|
288
|
+
(ctx, res) => {
|
|
289
|
+
return res.html('Admin Dashboard');
|
|
290
|
+
}
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
const handler = lambder.getHandler();
|
|
294
|
+
|
|
295
|
+
const event: APIGatewayProxyEvent = {
|
|
296
|
+
...createMockEvent('/dashboard'),
|
|
297
|
+
headers: { Host: 'admin.example.com' }
|
|
298
|
+
};
|
|
299
|
+
const result = await handler(event, createMockContext());
|
|
300
|
+
|
|
301
|
+
expect(Buffer.from(result.body || '', 'base64').toString()).toBe('Admin Dashboard');
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
describe('Routes - Session Protected Routes', () => {
|
|
306
|
+
beforeEach(() => {
|
|
307
|
+
ddbMock.reset();
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('should protect routes with addSessionRoute', async () => {
|
|
311
|
+
const mockSession = {
|
|
312
|
+
pk: 'hash',
|
|
313
|
+
sk: 'sortkey',
|
|
314
|
+
sessionToken: 'hash:sortkey',
|
|
315
|
+
csrfToken: 'csrf-token',
|
|
316
|
+
sessionKey: 'user-123',
|
|
317
|
+
data: { userId: '123', role: 'user' },
|
|
318
|
+
createdAt: Math.floor(Date.now() / 1000),
|
|
319
|
+
expiresAt: Math.floor(Date.now() / 1000) + 3600,
|
|
320
|
+
lastAccessedAt: Math.floor(Date.now() / 1000),
|
|
321
|
+
ttlInSeconds: 3600,
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
ddbMock.on(GetCommand).resolves({ Item: mockSession });
|
|
325
|
+
ddbMock.on(PutCommand).resolves({});
|
|
326
|
+
|
|
327
|
+
const lambder = new Lambder({
|
|
328
|
+
publicPath: './public',
|
|
329
|
+
apiPath: '/api'
|
|
330
|
+
})
|
|
331
|
+
.enableDdbSession(
|
|
332
|
+
{
|
|
333
|
+
tableName: 'test-sessions',
|
|
334
|
+
tableRegion: 'us-east-1',
|
|
335
|
+
sessionSalt: 'test-salt',
|
|
336
|
+
},
|
|
337
|
+
{ partitionKey: 'pk', sortKey: 'sk' }
|
|
338
|
+
)
|
|
339
|
+
.setGlobalErrorHandler((err, ctx, res) => {
|
|
340
|
+
return res.html(`<h1>Error: ${err.message}</h1>`);
|
|
341
|
+
})
|
|
342
|
+
.addSessionRoute('/protected', (ctx, res) => {
|
|
343
|
+
return res.html(`Welcome ${ctx.session.data.userId}`);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
const handler = lambder.getHandler();
|
|
347
|
+
const event = createMockEvent('/protected', 'GET', 'hash:sortkey');
|
|
348
|
+
const result = await handler(event, createMockContext());
|
|
349
|
+
|
|
350
|
+
expect(result.statusCode).toBe(200);
|
|
351
|
+
expect(Buffer.from(result.body || '', 'base64').toString()).toContain('Welcome 123');
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('should reject access without valid session', async () => {
|
|
355
|
+
ddbMock.on(GetCommand).resolves({}); // No session found
|
|
356
|
+
|
|
357
|
+
const lambder = new Lambder({
|
|
358
|
+
publicPath: './public',
|
|
359
|
+
apiPath: '/api'
|
|
360
|
+
})
|
|
361
|
+
.enableDdbSession(
|
|
362
|
+
{
|
|
363
|
+
tableName: 'test-sessions',
|
|
364
|
+
tableRegion: 'us-east-1',
|
|
365
|
+
sessionSalt: 'test-salt',
|
|
366
|
+
},
|
|
367
|
+
{ partitionKey: 'pk', sortKey: 'sk' }
|
|
368
|
+
)
|
|
369
|
+
.addSessionRoute('/protected', (ctx, res) => {
|
|
370
|
+
return res.html('Protected');
|
|
371
|
+
})
|
|
372
|
+
.setGlobalErrorHandler((err, ctx, res) => {
|
|
373
|
+
return res.raw({ statusCode: 401, body: 'Unauthorized' });
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
const handler = lambder.getHandler();
|
|
377
|
+
const event = createMockEvent('/protected', 'GET', 'invalid-token');
|
|
378
|
+
const result = await handler(event, createMockContext());
|
|
379
|
+
|
|
380
|
+
expect(result.statusCode).toBe(401);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('should access session data in session routes', async () => {
|
|
384
|
+
const mockSession = {
|
|
385
|
+
pk: 'hash',
|
|
386
|
+
sk: 'sortkey',
|
|
387
|
+
sessionToken: 'hash:sortkey',
|
|
388
|
+
csrfToken: 'csrf-token',
|
|
389
|
+
sessionKey: 'user-456',
|
|
390
|
+
data: { userId: '456', username: 'testuser', role: 'admin' },
|
|
391
|
+
createdAt: Math.floor(Date.now() / 1000),
|
|
392
|
+
expiresAt: Math.floor(Date.now() / 1000) + 3600,
|
|
393
|
+
lastAccessedAt: Math.floor(Date.now() / 1000),
|
|
394
|
+
ttlInSeconds: 3600,
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
ddbMock.on(GetCommand).resolves({ Item: mockSession });
|
|
398
|
+
ddbMock.on(PutCommand).resolves({});
|
|
399
|
+
|
|
400
|
+
const lambder = new Lambder({
|
|
401
|
+
publicPath: './public',
|
|
402
|
+
apiPath: '/api'
|
|
403
|
+
})
|
|
404
|
+
.enableDdbSession(
|
|
405
|
+
{
|
|
406
|
+
tableName: 'test-sessions',
|
|
407
|
+
tableRegion: 'us-east-1',
|
|
408
|
+
sessionSalt: 'test-salt',
|
|
409
|
+
},
|
|
410
|
+
{ partitionKey: 'pk', sortKey: 'sk' }
|
|
411
|
+
)
|
|
412
|
+
.setGlobalErrorHandler((err, ctx, res) => {
|
|
413
|
+
return res.json({ error: err.message });
|
|
414
|
+
})
|
|
415
|
+
.addSessionRoute('/profile', (ctx, res) => {
|
|
416
|
+
return res.json({
|
|
417
|
+
sessionKey: ctx.session.sessionKey,
|
|
418
|
+
username: ctx.session.data.username,
|
|
419
|
+
role: ctx.session.data.role
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
const handler = lambder.getHandler();
|
|
424
|
+
const event = createMockEvent('/profile', 'GET', 'hash:sortkey');
|
|
425
|
+
const result = await handler(event, createMockContext());
|
|
426
|
+
|
|
427
|
+
const body = JSON.parse(result.body || '{}');
|
|
428
|
+
expect(body.sessionKey).toBe('user-456');
|
|
429
|
+
expect(body.username).toBe('testuser');
|
|
430
|
+
expect(body.role).toBe('admin');
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
describe('Routes - Priority and Ordering', () => {
|
|
435
|
+
it('should match first defined route when multiple routes match', async () => {
|
|
436
|
+
const lambder = new Lambder({
|
|
437
|
+
publicPath: './public',
|
|
438
|
+
apiPath: '/api'
|
|
439
|
+
})
|
|
440
|
+
.addRoute('/item', (ctx, res) => {
|
|
441
|
+
return res.html('Exact Match');
|
|
442
|
+
})
|
|
443
|
+
.addRoute(/^\/item/, (ctx, res) => {
|
|
444
|
+
return res.html('Regex Match');
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
const handler = lambder.getHandler();
|
|
448
|
+
const event = createMockEvent('/item');
|
|
449
|
+
const result = await handler(event, createMockContext());
|
|
450
|
+
|
|
451
|
+
// First route should win
|
|
452
|
+
expect(Buffer.from(result.body || '', 'base64').toString()).toBe('Exact Match');
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it('should respect route definition order', async () => {
|
|
456
|
+
const lambder = new Lambder({
|
|
457
|
+
publicPath: './public',
|
|
458
|
+
apiPath: '/api'
|
|
459
|
+
})
|
|
460
|
+
.addRoute('/users/admin', (ctx, res) => {
|
|
461
|
+
return res.html('Admin User');
|
|
462
|
+
})
|
|
463
|
+
.addRoute('/users/:userId', (ctx, res) => {
|
|
464
|
+
return res.html(`User ${ctx.pathParams?.userId}`);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
const handler = lambder.getHandler();
|
|
468
|
+
|
|
469
|
+
// Should match specific route first
|
|
470
|
+
const adminResult = await handler(createMockEvent('/users/admin'), createMockContext());
|
|
471
|
+
expect(Buffer.from(adminResult.body || '', 'base64').toString()).toBe('Admin User');
|
|
472
|
+
|
|
473
|
+
// Should match parameterized route
|
|
474
|
+
const userResult = await handler(createMockEvent('/users/123'), createMockContext());
|
|
475
|
+
expect(Buffer.from(userResult.body || '', 'base64').toString()).toContain('User 123');
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
describe('Routes - Wildcard and Catch-all Routes', () => {
|
|
480
|
+
it('should support wildcard routes', async () => {
|
|
481
|
+
const lambder = new Lambder({
|
|
482
|
+
publicPath: './public',
|
|
483
|
+
apiPath: '/api'
|
|
484
|
+
})
|
|
485
|
+
.addRoute('/(.*)', (ctx, res) => {
|
|
486
|
+
return res.html('Catch All');
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
const handler = lambder.getHandler();
|
|
490
|
+
|
|
491
|
+
const result1 = await handler(createMockEvent('/anything'), createMockContext());
|
|
492
|
+
expect(Buffer.from(result1.body || '', 'base64').toString()).toBe('Catch All');
|
|
493
|
+
|
|
494
|
+
const result2 = await handler(createMockEvent('/deeply/nested/path'), createMockContext());
|
|
495
|
+
expect(Buffer.from(result2.body || '', 'base64').toString()).toBe('Catch All');
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it('should use wildcard as final fallback', async () => {
|
|
499
|
+
const lambder = new Lambder({
|
|
500
|
+
publicPath: './public',
|
|
501
|
+
apiPath: '/api'
|
|
502
|
+
})
|
|
503
|
+
.addRoute('/specific', (ctx, res) => {
|
|
504
|
+
return res.html('Specific');
|
|
505
|
+
})
|
|
506
|
+
.addRoute('/(.*)', (ctx, res) => {
|
|
507
|
+
return res.html('Fallback');
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
const handler = lambder.getHandler();
|
|
511
|
+
|
|
512
|
+
const specificResult = await handler(createMockEvent('/specific'), createMockContext());
|
|
513
|
+
expect(Buffer.from(specificResult.body || '', 'base64').toString()).toBe('Specific');
|
|
514
|
+
|
|
515
|
+
const fallbackResult = await handler(createMockEvent('/anything-else'), createMockContext());
|
|
516
|
+
expect(Buffer.from(fallbackResult.body || '', 'base64').toString()).toBe('Fallback');
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
describe('Routes - Method Filtering', () => {
|
|
521
|
+
it('should only match GET requests', async () => {
|
|
522
|
+
const lambder = new Lambder({
|
|
523
|
+
publicPath: './public',
|
|
524
|
+
apiPath: '/api'
|
|
525
|
+
})
|
|
526
|
+
.addRoute('/resource', (ctx, res) => {
|
|
527
|
+
return res.html('GET Response');
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
const handler = lambder.getHandler();
|
|
531
|
+
|
|
532
|
+
// GET should work
|
|
533
|
+
const getEvent = createMockEvent('/resource', 'GET');
|
|
534
|
+
const getResult = await handler(getEvent, createMockContext());
|
|
535
|
+
expect(Buffer.from(getResult.body || '', 'base64').toString()).toBe('GET Response');
|
|
536
|
+
|
|
537
|
+
// POST should not match routes (should hit fallback)
|
|
538
|
+
const postEvent = createMockEvent('/resource', 'POST');
|
|
539
|
+
const postResult = await handler(postEvent, createMockContext());
|
|
540
|
+
expect(postResult.statusCode).toBe(204); // Default fallback
|
|
541
|
+
});
|
|
542
|
+
});
|
package/tests/session.test.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
12
12
|
import { mockClient } from 'aws-sdk-client-mock';
|
|
13
13
|
import { DynamoDBDocumentClient, GetCommand, PutCommand, DeleteCommand, QueryCommand } from '@aws-sdk/lib-dynamodb';
|
|
14
|
+
import { z } from 'zod';
|
|
14
15
|
import LambderSessionManager, { type LambderSessionContext } from '../src/LambderSessionManager.js';
|
|
15
16
|
import LambderSessionController from '../src/LambderSessionController.js';
|
|
16
17
|
import Lambder from '../src/Lambder.js';
|
|
@@ -738,7 +739,7 @@ describe('LambderSessionController', () => {
|
|
|
738
739
|
});
|
|
739
740
|
|
|
740
741
|
describe('Session Endpoint Protection', () => {
|
|
741
|
-
let lambder: Lambder<
|
|
742
|
+
let lambder: Lambder<UserSessionData>;
|
|
742
743
|
|
|
743
744
|
const createMockEvent = (path: string, method: string, sessionToken?: string, apiName?: string, payload?: any, csrfToken?: string): APIGatewayProxyEvent => {
|
|
744
745
|
const cookieHeader = sessionToken ? `sessionToken=${sessionToken}` : '';
|
|
@@ -787,24 +788,22 @@ describe('Session Endpoint Protection', () => {
|
|
|
787
788
|
publicPath: '/public',
|
|
788
789
|
apiPath: '/api',
|
|
789
790
|
ejsPath: '/views',
|
|
790
|
-
})
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
return responseBuilder.
|
|
805
|
-
}
|
|
806
|
-
return responseBuilder.html(`<h1>Error: ${err.message}</h1>`);
|
|
807
|
-
});
|
|
791
|
+
})
|
|
792
|
+
.enableDdbSession(
|
|
793
|
+
{
|
|
794
|
+
tableName: 'test-sessions',
|
|
795
|
+
tableRegion: 'us-east-1',
|
|
796
|
+
sessionSalt: 'test-salt',
|
|
797
|
+
},
|
|
798
|
+
{ partitionKey: 'pk', sortKey: 'sk' }
|
|
799
|
+
)
|
|
800
|
+
// Set up error handler to expose actual error messages for testing
|
|
801
|
+
.setGlobalErrorHandler((err, ctx, responseBuilder) => {
|
|
802
|
+
if (ctx?._otherInternal.isApiCall) {
|
|
803
|
+
return responseBuilder.api({ error: err.message });
|
|
804
|
+
}
|
|
805
|
+
return responseBuilder.html(`<h1>Error: ${err.message}</h1>`);
|
|
806
|
+
});
|
|
808
807
|
});
|
|
809
808
|
|
|
810
809
|
describe('addSessionRoute', () => {
|
|
@@ -904,7 +903,10 @@ describe('Session Endpoint Protection', () => {
|
|
|
904
903
|
it('should throw error when no session exists', async () => {
|
|
905
904
|
ddbMock.on(GetCommand).resolves({}); // No session found
|
|
906
905
|
|
|
907
|
-
lambder.addSessionApi('user.profile',
|
|
906
|
+
lambder.addSessionApi('user.profile', {
|
|
907
|
+
input: z.any(),
|
|
908
|
+
output: z.any()
|
|
909
|
+
}, async (ctx, resolver) => {
|
|
908
910
|
return resolver.api({ userId: ctx.session.data.userId });
|
|
909
911
|
});
|
|
910
912
|
|
|
@@ -937,7 +939,10 @@ describe('Session Endpoint Protection', () => {
|
|
|
937
939
|
ddbMock.on(GetCommand).resolves({ Item: mockSession });
|
|
938
940
|
ddbMock.on(PutCommand).resolves({});
|
|
939
941
|
|
|
940
|
-
lambder.addSessionApi('user.profile',
|
|
942
|
+
lambder.addSessionApi('user.profile', {
|
|
943
|
+
input: z.any(),
|
|
944
|
+
output: z.any()
|
|
945
|
+
}, async (ctx, resolver) => {
|
|
941
946
|
return resolver.api({ userId: ctx.session.data.userId });
|
|
942
947
|
});
|
|
943
948
|
|
|
@@ -973,7 +978,10 @@ describe('Session Endpoint Protection', () => {
|
|
|
973
978
|
|
|
974
979
|
ddbMock.on(GetCommand).resolves({ Item: mockSession });
|
|
975
980
|
|
|
976
|
-
lambder.addSessionApi('user.profile',
|
|
981
|
+
lambder.addSessionApi('user.profile', {
|
|
982
|
+
input: z.any(),
|
|
983
|
+
output: z.any()
|
|
984
|
+
}, async (ctx, resolver) => {
|
|
977
985
|
return resolver.api({ userId: ctx.session.data.userId });
|
|
978
986
|
});
|
|
979
987
|
|
|
@@ -1005,7 +1013,10 @@ describe('Session Endpoint Protection', () => {
|
|
|
1005
1013
|
|
|
1006
1014
|
ddbMock.on(GetCommand).resolves({ Item: mockSession });
|
|
1007
1015
|
|
|
1008
|
-
lambder.addSessionApi('user.profile',
|
|
1016
|
+
lambder.addSessionApi('user.profile', {
|
|
1017
|
+
input: z.any(),
|
|
1018
|
+
output: z.any()
|
|
1019
|
+
}, async (ctx, resolver) => {
|
|
1009
1020
|
return resolver.api({ userId: ctx.session.data.userId });
|
|
1010
1021
|
});
|
|
1011
1022
|
|
|
@@ -1038,7 +1049,10 @@ describe('Session Endpoint Protection', () => {
|
|
|
1038
1049
|
ddbMock.on(GetCommand).resolves({ Item: mockSession });
|
|
1039
1050
|
ddbMock.on(PutCommand).resolves({});
|
|
1040
1051
|
|
|
1041
|
-
lambder.addSessionApi('user.profile',
|
|
1052
|
+
lambder.addSessionApi('user.profile', {
|
|
1053
|
+
input: z.any(),
|
|
1054
|
+
output: z.any()
|
|
1055
|
+
}, async (ctx, resolver) => {
|
|
1042
1056
|
// Type test: ctx.session.data should have UserSessionData type
|
|
1043
1057
|
const userId: string = ctx.session.data.userId;
|
|
1044
1058
|
const username: string = ctx.session.data.username;
|