@lowdefy/server-e2e 6.0.0 → 7.0.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/client/App.jsx +24 -14
- package/client/Page.jsx +53 -16
- package/client/getShownPage.js +28 -0
- package/client/getShownPage.test.mjs +52 -0
- package/lib/client/auth/Auth.jsx +30 -4
- package/lib/server/auth/{session.js → getUser.js} +8 -7
- package/lib/server/log/createLogger.js +3 -0
- package/lib/server/log/logRequest.js +1 -3
- package/lib/server/scrubSecrets.js +25 -0
- package/lowdefy/build.mjs +1 -0
- package/lowdefy/build.test.mjs +65 -0
- package/lowdefy/createCustomPluginTypesMap.mjs +3 -2
- package/lowdefy/createCustomPluginTypesMap.test.mjs +5 -0
- package/package.json +38 -39
- package/package.original.json +38 -39
- package/src/app.js +12 -5
- package/src/html/getAssets.js +3 -1
- package/src/html/getHomePath.js +29 -0
- package/src/html/renderPage.js +29 -17
- package/src/html/renderPage.test.mjs +162 -0
- package/src/html/template.js +16 -6
- package/src/index.js +23 -0
- package/src/lib/getRequestPath.js +29 -0
- package/src/middleware/apiContext.js +22 -3
- package/src/middleware/apiContext.test.mjs +120 -0
- package/src/middleware/errorHandler.js +63 -4
- package/src/middleware/errorHandler.test.mjs +402 -0
- package/src/routes/apiPage.js +14 -9
- package/src/routes/apiPage.test.mjs +92 -0
- package/src/routes/clientError.js +6 -12
- package/src/routes/e2eIdentity.js +28 -0
- package/src/routes/e2eIdentity.test.mjs +43 -0
- package/src/routes/endpoints.js +5 -3
- package/src/routes/mountPageRoutes.js +29 -0
- package/src/routes/mountPageRoutes.test.mjs +56 -0
- package/src/routes/request.js +5 -3
- package/src/routes/requestBody.test.mjs +69 -0
- package/src/routes/sessionMock.js +4 -1
- package/src/routes/usage.js +6 -2
- package/src/routes/user.js +27 -0
- package/src/routes/websocket.js +23 -3
- package/src/routes/websocket.test.mjs +73 -0
- package/vite.config.js +57 -1
- package/lib/server/apiWrapper.js +0 -73
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2020-2026 Lowdefy, Inc
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import fs from 'fs';
|
|
18
|
+
import path from 'path';
|
|
19
|
+
import { fileURLToPath } from 'url';
|
|
20
|
+
|
|
21
|
+
import { Hono } from 'hono';
|
|
22
|
+
import { jest } from '@jest/globals';
|
|
23
|
+
|
|
24
|
+
const secret = 'planted-e2e-secret';
|
|
25
|
+
process.env.LOWDEFY_SECRET_TEST = secret;
|
|
26
|
+
|
|
27
|
+
jest.unstable_mockModule('@lowdefy/api', () => ({
|
|
28
|
+
createApiContext: jest.fn(),
|
|
29
|
+
createRequestSignal: jest.fn(({ clientSignal }) => clientSignal),
|
|
30
|
+
normalizeInjectedCaller: jest.fn((user) => user),
|
|
31
|
+
}));
|
|
32
|
+
jest.unstable_mockModule('../../lib/build/appMeta.js', () => ({ default: {} }));
|
|
33
|
+
jest.unstable_mockModule('../../lib/build/config.js', () => ({ default: {} }));
|
|
34
|
+
jest.unstable_mockModule('../../lib/build/auth.js', () => ({ default: {} }));
|
|
35
|
+
jest.unstable_mockModule('../../lib/build/i18n.js', () => ({ default: {} }));
|
|
36
|
+
jest.unstable_mockModule('../../lib/server/log/createHandleError.js', () => ({
|
|
37
|
+
default: jest.fn(() => jest.fn()),
|
|
38
|
+
}));
|
|
39
|
+
jest.unstable_mockModule('../../lib/server/log/createLogger.js', () => ({
|
|
40
|
+
default: jest.fn(() => ({
|
|
41
|
+
debug: jest.fn(),
|
|
42
|
+
info: jest.fn(),
|
|
43
|
+
warn: jest.fn(),
|
|
44
|
+
error: jest.fn(),
|
|
45
|
+
})),
|
|
46
|
+
}));
|
|
47
|
+
jest.unstable_mockModule('../../lib/server/fileCache.js', () => ({ default: {} }));
|
|
48
|
+
jest.unstable_mockModule('../../lib/server/getE2eSecrets.js', () => ({
|
|
49
|
+
default: jest.fn(() => ({ TEST: process.env.LOWDEFY_SECRET_TEST })),
|
|
50
|
+
}));
|
|
51
|
+
jest.unstable_mockModule('../../lib/server/auth/getUser.js', () => ({
|
|
52
|
+
default: jest.fn(() => undefined),
|
|
53
|
+
}));
|
|
54
|
+
jest.unstable_mockModule('../../lib/server/log/logRequest.js', () => ({ default: jest.fn() }));
|
|
55
|
+
|
|
56
|
+
// apiContext statically imports the app build's plugin artifacts, which only
|
|
57
|
+
// exist in a built app (build/** is gitignored). Materialize the minimal set
|
|
58
|
+
// it needs so the import resolves; clean up only what this test created.
|
|
59
|
+
const dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
60
|
+
const pluginsDir = path.resolve(dirname, '../../build/plugins');
|
|
61
|
+
const fixtures = [
|
|
62
|
+
{ file: 'agents.js', content: 'export default {};\n' },
|
|
63
|
+
{ file: 'connections.js', content: 'export default {};\n' },
|
|
64
|
+
{ file: path.join('operators', 'server.js'), content: 'export default {};\n' },
|
|
65
|
+
{ file: path.join('operators', 'serverJsMap.js'), content: 'export default {};\n' },
|
|
66
|
+
{ file: 'steps.js', content: 'export default {};\n' },
|
|
67
|
+
{ file: 'websockets.js', content: 'export default {};\n' },
|
|
68
|
+
].map(({ file, content }) => ({ absolutePath: path.join(pluginsDir, file), content }));
|
|
69
|
+
|
|
70
|
+
const createdFixtures = [];
|
|
71
|
+
fixtures.forEach(({ absolutePath, content }) => {
|
|
72
|
+
if (!fs.existsSync(absolutePath)) {
|
|
73
|
+
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
|
|
74
|
+
fs.writeFileSync(absolutePath, content);
|
|
75
|
+
createdFixtures.push(absolutePath);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// A checkout that has built an app already holds real artifacts here, which import plugins this
|
|
80
|
+
// package does not depend on, so the stubs are also mocked over whatever is on disk.
|
|
81
|
+
fixtures.forEach(({ absolutePath }) => {
|
|
82
|
+
jest.unstable_mockModule(absolutePath, () => ({
|
|
83
|
+
default: {},
|
|
84
|
+
interpolateProperties: () => {},
|
|
85
|
+
renderEmail: () => {},
|
|
86
|
+
}));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
afterAll(() => {
|
|
90
|
+
createdFixtures.forEach((absolutePath) => fs.rmSync(absolutePath, { force: true }));
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const { default: apiContext } = await import('./apiContext.js');
|
|
94
|
+
|
|
95
|
+
function createApp() {
|
|
96
|
+
const app = new Hono();
|
|
97
|
+
app.use('*', apiContext());
|
|
98
|
+
app.get('/api/ping', (c) => {
|
|
99
|
+
const context = c.get('lowdefyContext');
|
|
100
|
+
return c.json({
|
|
101
|
+
mode: context.mode,
|
|
102
|
+
scrubbed: context.scrubSecrets(`token ${secret} end`),
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
return app;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
test('apiContext sets mode to prod', async () => {
|
|
109
|
+
const res = await createApp().request('/api/ping');
|
|
110
|
+
expect(res.status).toEqual(200);
|
|
111
|
+
const body = await res.json();
|
|
112
|
+
expect(body.mode).toEqual('prod');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('apiContext scrubSecrets redacts a planted secret', async () => {
|
|
116
|
+
const res = await createApp().request('/api/ping');
|
|
117
|
+
expect(res.status).toEqual(200);
|
|
118
|
+
const body = await res.json();
|
|
119
|
+
expect(body.scrubbed).toEqual('token [REDACTED] end');
|
|
120
|
+
});
|
|
@@ -15,20 +15,79 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { redactErrorResponse } from '@lowdefy/api';
|
|
18
|
+
import { HTTPException } from 'hono/http-exception';
|
|
18
19
|
|
|
19
20
|
// Hono routes every handler error to the app-level error handler — upstream
|
|
20
|
-
// middleware try/catch never sees them
|
|
21
|
-
//
|
|
22
|
-
// page routes get a plain 500.
|
|
21
|
+
// middleware try/catch never sees them. API routes get the serialized error
|
|
22
|
+
// JSON the old apiWrapper returned; page routes get a plain 500.
|
|
23
23
|
function createErrorHandler({ basePath = '', logger }) {
|
|
24
24
|
return async function errorHandler(error, c) {
|
|
25
|
+
const path = basePath ? c.req.path.replace(basePath, '') : c.req.path;
|
|
26
|
+
// Unauthenticated requests to protected endpoints are expected traffic -
|
|
27
|
+
// one warning line and a 401, skipping the structured error log so
|
|
28
|
+
// probing cannot flood it. Keys strictly on the error name; the 500 path
|
|
29
|
+
// below is untouched.
|
|
30
|
+
if (error.name === 'AuthenticationError') {
|
|
31
|
+
logger.warn(`Unauthenticated request: ${c.req.method} ${c.req.path}`);
|
|
32
|
+
if (path.startsWith('/api/')) {
|
|
33
|
+
return c.json({ name: error.name, message: error.message }, 401);
|
|
34
|
+
}
|
|
35
|
+
return c.text('Unauthorized', 401);
|
|
36
|
+
}
|
|
37
|
+
// An authenticated caller whose roles do not permit the resource. Expected
|
|
38
|
+
// traffic, not a fault: one warning line, no structured error log and no
|
|
39
|
+
// Sentry capture.
|
|
40
|
+
if (error.name === 'AuthorizationError') {
|
|
41
|
+
logger.warn(`Forbidden: ${c.req.method} ${c.req.path}`);
|
|
42
|
+
if (path.startsWith('/api/')) {
|
|
43
|
+
return c.json({ name: error.name, message: error.message }, 403);
|
|
44
|
+
}
|
|
45
|
+
return c.text('Forbidden', 403);
|
|
46
|
+
}
|
|
47
|
+
// An authorized caller who has not enrolled a second factor under
|
|
48
|
+
// auth.twoFactor.required. 403, not 401 - a 401 reads to the client as a dead
|
|
49
|
+
// session and bounces the user to sign-in, which is the loop the gate exists to
|
|
50
|
+
// avoid. Expected traffic under required: true, so one warning line and no
|
|
51
|
+
// Sentry capture, exactly as for AuthenticationError.
|
|
52
|
+
if (error.name === 'TwoFactorEnrolmentRequiredError') {
|
|
53
|
+
logger.warn(`Two-factor enrolment required: ${c.req.method} ${c.req.path}`);
|
|
54
|
+
if (path.startsWith('/api/')) {
|
|
55
|
+
return c.json({ name: error.name, message: error.message }, 403);
|
|
56
|
+
}
|
|
57
|
+
return c.text('Two-factor enrolment required', 403);
|
|
58
|
+
}
|
|
59
|
+
// An API client whose payload does not match the endpoint's declared
|
|
60
|
+
// payloadSchema (or any other UserError raised before a routine starts). A
|
|
61
|
+
// page's CallAPI payload the schema refuses is a ConfigError instead: the
|
|
62
|
+
// page's own config built it (validatePayload). The caller's own
|
|
63
|
+
// request is wrong, not the config or the server: a 400 with the message the
|
|
64
|
+
// caller needs, one warning line, no structured error log and no Sentry
|
|
65
|
+
// capture. A UserError raised inside a routine never gets here - it returns
|
|
66
|
+
// 200 with success: false through buildEndpointResult.
|
|
67
|
+
if (error.name === 'UserError') {
|
|
68
|
+
logger.warn(`Bad request: ${c.req.method} ${c.req.path} - ${error.message}`);
|
|
69
|
+
if (path.startsWith('/api/')) {
|
|
70
|
+
return c.json({ name: error.name, message: error.message }, 400);
|
|
71
|
+
}
|
|
72
|
+
return c.text('Bad Request', 400);
|
|
73
|
+
}
|
|
74
|
+
// A route or transport that has already decided its own HTTP answer - hono's
|
|
75
|
+
// HTTPException carries the response to send. The MCP transport refuses an
|
|
76
|
+
// unsupported protocol-version header this way (a 404 with a JSON-RPC body
|
|
77
|
+
// that the client SDK falls back on to negotiate a version both sides
|
|
78
|
+
// know). Not a fault: send its response and log one warning line, no
|
|
79
|
+
// structured error log and no Sentry capture. Turning it into a 500 would
|
|
80
|
+
// hide the status the client acts on.
|
|
81
|
+
if (error instanceof HTTPException) {
|
|
82
|
+
logger.warn(`${error.status} answered by the route: ${c.req.method} ${c.req.path}`);
|
|
83
|
+
return error.getResponse();
|
|
84
|
+
}
|
|
25
85
|
const context = c.get('lowdefyContext');
|
|
26
86
|
if (context) {
|
|
27
87
|
await context.handleError(error);
|
|
28
88
|
} else {
|
|
29
89
|
logger.error(error);
|
|
30
90
|
}
|
|
31
|
-
const path = basePath ? c.req.path.replace(basePath, '') : c.req.path;
|
|
32
91
|
if (path.startsWith('/api/')) {
|
|
33
92
|
return c.json(redactErrorResponse(context, error), 500);
|
|
34
93
|
}
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2020-2026 Lowdefy, Inc
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { Hono } from 'hono';
|
|
18
|
+
import { HTTPException } from 'hono/http-exception';
|
|
19
|
+
import { jest } from '@jest/globals';
|
|
20
|
+
|
|
21
|
+
import createErrorHandler from './errorHandler.js';
|
|
22
|
+
|
|
23
|
+
// The handler here is byte-identical to its `server-e2e` counterpart (and to
|
|
24
|
+
// `server`'s apart from the Sentry capture, which `server` tests separately).
|
|
25
|
+
// Keep the three in sync when changing any of them.
|
|
26
|
+
|
|
27
|
+
// A string that must never reach a client. It is put on `received` at every
|
|
28
|
+
// level of the cause chain, so a single JSON.stringify search over the body
|
|
29
|
+
// proves the redaction reached that level.
|
|
30
|
+
const SECRET = 'SECRET-RECEIVED-VALUE';
|
|
31
|
+
|
|
32
|
+
function createLogger() {
|
|
33
|
+
return { error: jest.fn(), warn: jest.fn() };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The error handler is only reachable through hono's app-level onError, so the
|
|
37
|
+
// handler is driven the way production drives it: a route that throws, with the
|
|
38
|
+
// lowdefyContext set by upstream middleware when there is one.
|
|
39
|
+
function createApp({ basePath = '', context, error, logger }) {
|
|
40
|
+
const app = new Hono();
|
|
41
|
+
app.use('*', async (c, next) => {
|
|
42
|
+
if (context) c.set('lowdefyContext', context);
|
|
43
|
+
await next();
|
|
44
|
+
});
|
|
45
|
+
app.all('*', () => {
|
|
46
|
+
throw error;
|
|
47
|
+
});
|
|
48
|
+
app.onError(createErrorHandler({ basePath, logger }));
|
|
49
|
+
return app;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function createAuthenticationError(message) {
|
|
53
|
+
const error = new Error(message);
|
|
54
|
+
error.name = 'AuthenticationError';
|
|
55
|
+
error.received = SECRET;
|
|
56
|
+
return error;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function createAuthorizationError(message) {
|
|
60
|
+
const error = new Error(message);
|
|
61
|
+
error.name = 'AuthorizationError';
|
|
62
|
+
error.received = SECRET;
|
|
63
|
+
return error;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function createTwoFactorEnrolmentRequiredError(message) {
|
|
67
|
+
const error = new Error(message);
|
|
68
|
+
error.name = 'TwoFactorEnrolmentRequiredError';
|
|
69
|
+
error.received = SECRET;
|
|
70
|
+
return error;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function createUserError(message) {
|
|
74
|
+
const error = new Error(message);
|
|
75
|
+
error.name = 'UserError';
|
|
76
|
+
error.received = SECRET;
|
|
77
|
+
return error;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Two Error levels, each with a stack (every Error has one) and a recognisable
|
|
81
|
+
// `received`, plus a configKey the policy keeps.
|
|
82
|
+
function createErrorWithCause() {
|
|
83
|
+
const cause = new Error('Cause message.');
|
|
84
|
+
cause.received = `${SECRET}-cause`;
|
|
85
|
+
const error = new Error('Top message.', { cause });
|
|
86
|
+
error.received = `${SECRET}-top`;
|
|
87
|
+
error.configKey = 'page:home.blocks.0';
|
|
88
|
+
return error;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
test('errorHandler returns 401 with only name and message for an AuthenticationError on an api path', async () => {
|
|
92
|
+
const logger = createLogger();
|
|
93
|
+
const context = { handleError: jest.fn() };
|
|
94
|
+
const res = await createApp({
|
|
95
|
+
context,
|
|
96
|
+
error: createAuthenticationError('Unauthenticated.'),
|
|
97
|
+
logger,
|
|
98
|
+
}).request('/api/request/getUsers');
|
|
99
|
+
|
|
100
|
+
expect(res.status).toEqual(401);
|
|
101
|
+
expect(await res.json()).toEqual({ name: 'AuthenticationError', message: 'Unauthenticated.' });
|
|
102
|
+
expect(logger.warn).toHaveBeenCalledTimes(1);
|
|
103
|
+
expect(logger.warn.mock.calls[0][0]).toMatch(/Unauthenticated request/);
|
|
104
|
+
expect(logger.error).not.toHaveBeenCalled();
|
|
105
|
+
expect(context.handleError).not.toHaveBeenCalled();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('errorHandler does not send an AuthenticationError through the redactor', async () => {
|
|
109
|
+
const res = await createApp({
|
|
110
|
+
error: createAuthenticationError('Unauthenticated.'),
|
|
111
|
+
logger: createLogger(),
|
|
112
|
+
}).request('/api/request/getUsers');
|
|
113
|
+
|
|
114
|
+
const body = await res.json();
|
|
115
|
+
expect(body['~e']).toBeUndefined();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('errorHandler returns text Unauthorized at 401 for an AuthenticationError on a page path', async () => {
|
|
119
|
+
const logger = createLogger();
|
|
120
|
+
const res = await createApp({
|
|
121
|
+
error: createAuthenticationError('Unauthenticated.'),
|
|
122
|
+
logger,
|
|
123
|
+
}).request('/home');
|
|
124
|
+
|
|
125
|
+
expect(res.status).toEqual(401);
|
|
126
|
+
expect(await res.text()).toEqual('Unauthorized');
|
|
127
|
+
expect(logger.warn).toHaveBeenCalledTimes(1);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('errorHandler returns 403 with only name and message for an AuthorizationError on an api path', async () => {
|
|
131
|
+
const logger = createLogger();
|
|
132
|
+
const context = { handleError: jest.fn() };
|
|
133
|
+
const res = await createApp({
|
|
134
|
+
context,
|
|
135
|
+
error: createAuthorizationError('Forbidden.'),
|
|
136
|
+
logger,
|
|
137
|
+
}).request('/api/request/getUsers');
|
|
138
|
+
|
|
139
|
+
expect(res.status).toEqual(403);
|
|
140
|
+
expect(await res.json()).toEqual({ name: 'AuthorizationError', message: 'Forbidden.' });
|
|
141
|
+
expect(logger.warn).toHaveBeenCalledTimes(1);
|
|
142
|
+
expect(logger.warn.mock.calls[0][0]).toMatch(/Forbidden: GET \/api\/request\/getUsers/);
|
|
143
|
+
expect(logger.error).not.toHaveBeenCalled();
|
|
144
|
+
expect(context.handleError).not.toHaveBeenCalled();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('errorHandler does not send an AuthorizationError through the redactor', async () => {
|
|
148
|
+
const res = await createApp({
|
|
149
|
+
error: createAuthorizationError('Forbidden.'),
|
|
150
|
+
logger: createLogger(),
|
|
151
|
+
}).request('/api/request/getUsers');
|
|
152
|
+
|
|
153
|
+
const body = await res.json();
|
|
154
|
+
expect(body['~e']).toBeUndefined();
|
|
155
|
+
expect(JSON.stringify(body)).not.toContain(SECRET);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('errorHandler returns text Forbidden at 403 for an AuthorizationError on a page path', async () => {
|
|
159
|
+
const logger = createLogger();
|
|
160
|
+
const res = await createApp({
|
|
161
|
+
error: createAuthorizationError('Forbidden.'),
|
|
162
|
+
logger,
|
|
163
|
+
}).request('/home');
|
|
164
|
+
|
|
165
|
+
expect(res.status).toEqual(403);
|
|
166
|
+
expect(await res.text()).toEqual('Forbidden');
|
|
167
|
+
expect(logger.warn).toHaveBeenCalledTimes(1);
|
|
168
|
+
expect(logger.error).not.toHaveBeenCalled();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('errorHandler returns 403 with only name and message for a TwoFactorEnrolmentRequiredError on an api path', async () => {
|
|
172
|
+
const logger = createLogger();
|
|
173
|
+
const context = { handleError: jest.fn() };
|
|
174
|
+
const res = await createApp({
|
|
175
|
+
context,
|
|
176
|
+
error: createTwoFactorEnrolmentRequiredError('Two-factor enrolment required.'),
|
|
177
|
+
logger,
|
|
178
|
+
}).request('/api/request/getUsers');
|
|
179
|
+
|
|
180
|
+
expect(res.status).toEqual(403);
|
|
181
|
+
expect(await res.json()).toEqual({
|
|
182
|
+
name: 'TwoFactorEnrolmentRequiredError',
|
|
183
|
+
message: 'Two-factor enrolment required.',
|
|
184
|
+
});
|
|
185
|
+
expect(logger.warn).toHaveBeenCalledTimes(1);
|
|
186
|
+
expect(logger.warn.mock.calls[0][0]).toMatch(/Two-factor enrolment required/);
|
|
187
|
+
expect(logger.error).not.toHaveBeenCalled();
|
|
188
|
+
expect(context.handleError).not.toHaveBeenCalled();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('errorHandler does not send a TwoFactorEnrolmentRequiredError through the redactor', async () => {
|
|
192
|
+
const res = await createApp({
|
|
193
|
+
error: createTwoFactorEnrolmentRequiredError('Two-factor enrolment required.'),
|
|
194
|
+
logger: createLogger(),
|
|
195
|
+
}).request('/api/request/getUsers');
|
|
196
|
+
|
|
197
|
+
const body = await res.json();
|
|
198
|
+
expect(body['~e']).toBeUndefined();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('errorHandler returns text at 403 for a TwoFactorEnrolmentRequiredError on a page path', async () => {
|
|
202
|
+
const logger = createLogger();
|
|
203
|
+
const context = { handleError: jest.fn() };
|
|
204
|
+
const res = await createApp({
|
|
205
|
+
context,
|
|
206
|
+
error: createTwoFactorEnrolmentRequiredError('Two-factor enrolment required.'),
|
|
207
|
+
logger,
|
|
208
|
+
}).request('/home');
|
|
209
|
+
|
|
210
|
+
expect(res.status).toEqual(403);
|
|
211
|
+
expect(await res.text()).toEqual('Two-factor enrolment required');
|
|
212
|
+
expect(logger.warn).toHaveBeenCalledTimes(1);
|
|
213
|
+
expect(context.handleError).not.toHaveBeenCalled();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('errorHandler returns 400 with only name and message for a UserError on an api path', async () => {
|
|
217
|
+
const logger = createLogger();
|
|
218
|
+
const context = { handleError: jest.fn() };
|
|
219
|
+
const res = await createApp({
|
|
220
|
+
context,
|
|
221
|
+
error: createUserError('Payload for endpoint "x" does not match its payloadSchema.'),
|
|
222
|
+
logger,
|
|
223
|
+
}).request('/api/endpoints/x', { method: 'POST' });
|
|
224
|
+
|
|
225
|
+
expect(res.status).toEqual(400);
|
|
226
|
+
expect(await res.json()).toEqual({
|
|
227
|
+
name: 'UserError',
|
|
228
|
+
message: 'Payload for endpoint "x" does not match its payloadSchema.',
|
|
229
|
+
});
|
|
230
|
+
expect(logger.warn).toHaveBeenCalledTimes(1);
|
|
231
|
+
expect(logger.warn.mock.calls[0][0]).toMatch(/Bad request: POST \/api\/endpoints\/x/);
|
|
232
|
+
expect(logger.error).not.toHaveBeenCalled();
|
|
233
|
+
expect(context.handleError).not.toHaveBeenCalled();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test('errorHandler does not send a UserError through the redactor', async () => {
|
|
237
|
+
const res = await createApp({
|
|
238
|
+
error: createUserError('Bad payload.'),
|
|
239
|
+
logger: createLogger(),
|
|
240
|
+
}).request('/api/endpoints/x');
|
|
241
|
+
|
|
242
|
+
const body = await res.json();
|
|
243
|
+
expect(body['~e']).toBeUndefined();
|
|
244
|
+
expect(JSON.stringify(body)).not.toContain(SECRET);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test('errorHandler returns text Bad Request at 400 for a UserError on a page path', async () => {
|
|
248
|
+
const logger = createLogger();
|
|
249
|
+
const res = await createApp({
|
|
250
|
+
error: createUserError('Bad payload.'),
|
|
251
|
+
logger,
|
|
252
|
+
}).request('/home');
|
|
253
|
+
|
|
254
|
+
expect(res.status).toEqual(400);
|
|
255
|
+
expect(await res.text()).toEqual('Bad Request');
|
|
256
|
+
expect(logger.warn).toHaveBeenCalledTimes(1);
|
|
257
|
+
expect(logger.error).not.toHaveBeenCalled();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('errorHandler sends an HTTPException response as-is with one warning and no error log', async () => {
|
|
261
|
+
const logger = createLogger();
|
|
262
|
+
const error = new HTTPException(404, {
|
|
263
|
+
res: Response.json(
|
|
264
|
+
{ jsonrpc: '2.0', error: { code: -32000, message: 'Unsupported protocol version' } },
|
|
265
|
+
{ status: 404 }
|
|
266
|
+
),
|
|
267
|
+
});
|
|
268
|
+
const res = await createApp({ error, logger }).request('/api/mcp', { method: 'POST' });
|
|
269
|
+
expect(res.status).toEqual(404);
|
|
270
|
+
expect(await res.json()).toEqual({
|
|
271
|
+
jsonrpc: '2.0',
|
|
272
|
+
error: { code: -32000, message: 'Unsupported protocol version' },
|
|
273
|
+
});
|
|
274
|
+
expect(logger.warn).toHaveBeenCalledWith('404 answered by the route: POST /api/mcp');
|
|
275
|
+
expect(logger.error).not.toHaveBeenCalled();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test('errorHandler returns 500 with the serialized error envelope on an api path', async () => {
|
|
279
|
+
const res = await createApp({
|
|
280
|
+
error: createErrorWithCause(),
|
|
281
|
+
logger: createLogger(),
|
|
282
|
+
}).request('/api/request/getUsers');
|
|
283
|
+
|
|
284
|
+
expect(res.status).toEqual(500);
|
|
285
|
+
const body = await res.json();
|
|
286
|
+
expect(body['~e'].name).toEqual('Error');
|
|
287
|
+
expect(body['~e'].message).toEqual('Something went wrong.');
|
|
288
|
+
expect(body['~e'].cause).toBeUndefined();
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test('errorHandler omits stack and received from the 500 envelope at the top level', async () => {
|
|
292
|
+
const res = await createApp({
|
|
293
|
+
error: createErrorWithCause(),
|
|
294
|
+
logger: createLogger(),
|
|
295
|
+
}).request('/api/request/getUsers');
|
|
296
|
+
|
|
297
|
+
const body = await res.json();
|
|
298
|
+
expect(body['~e'].stack).toBeUndefined();
|
|
299
|
+
expect(body['~e'].received).toBeUndefined();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test('errorHandler omits the cause chain from the 500 envelope', async () => {
|
|
303
|
+
const res = await createApp({
|
|
304
|
+
error: createErrorWithCause(),
|
|
305
|
+
logger: createLogger(),
|
|
306
|
+
}).request('/api/request/getUsers');
|
|
307
|
+
|
|
308
|
+
const body = await res.json();
|
|
309
|
+
expect(body['~e'].cause).toBeUndefined();
|
|
310
|
+
expect(JSON.stringify(body)).not.toContain(SECRET);
|
|
311
|
+
expect(JSON.stringify(body)).not.toContain('Cause message.');
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test('errorHandler keeps configKey on the 500 envelope so the fault can be located in config', async () => {
|
|
315
|
+
const res = await createApp({
|
|
316
|
+
error: createErrorWithCause(),
|
|
317
|
+
logger: createLogger(),
|
|
318
|
+
}).request('/api/request/getUsers');
|
|
319
|
+
|
|
320
|
+
const body = await res.json();
|
|
321
|
+
expect(body['~e'].configKey).toEqual('page:home.blocks.0');
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test('errorHandler returns text Internal Server Error at 500 on a page path with no payload', async () => {
|
|
325
|
+
const res = await createApp({
|
|
326
|
+
error: createErrorWithCause(),
|
|
327
|
+
logger: createLogger(),
|
|
328
|
+
}).request('/home');
|
|
329
|
+
|
|
330
|
+
expect(res.status).toEqual(500);
|
|
331
|
+
expect(await res.text()).toEqual('Internal Server Error');
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test('errorHandler awaits context.handleError before serializing the response', async () => {
|
|
335
|
+
const context = {
|
|
336
|
+
handleError: jest.fn(async (error) => {
|
|
337
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
338
|
+
error.handled = true;
|
|
339
|
+
}),
|
|
340
|
+
};
|
|
341
|
+
const error = createErrorWithCause();
|
|
342
|
+
const res = await createApp({ context, error, logger: createLogger() }).request(
|
|
343
|
+
'/api/request/getUsers'
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
expect(context.handleError).toHaveBeenCalledTimes(1);
|
|
347
|
+
expect(context.handleError).toHaveBeenCalledWith(error);
|
|
348
|
+
const body = await res.json();
|
|
349
|
+
expect(body['~e'].handled).toBe(true);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
// createHandleError sets error.handled once it has logged. The client reads it
|
|
353
|
+
// to decide whether to POST the error back for logging, so it has to survive
|
|
354
|
+
// serialization or the error is logged a second time.
|
|
355
|
+
test('errorHandler returns handled true on the 500 envelope after handleError sets it', async () => {
|
|
356
|
+
const context = {
|
|
357
|
+
handleError: jest.fn(async (error) => {
|
|
358
|
+
error.handled = true;
|
|
359
|
+
}),
|
|
360
|
+
};
|
|
361
|
+
const res = await createApp({
|
|
362
|
+
context,
|
|
363
|
+
error: createErrorWithCause(),
|
|
364
|
+
logger: createLogger(),
|
|
365
|
+
}).request('/api/request/getUsers');
|
|
366
|
+
|
|
367
|
+
const body = await res.json();
|
|
368
|
+
expect(body['~e'].handled).toBe(true);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test('errorHandler logs with logger.error when the request has no lowdefyContext', async () => {
|
|
372
|
+
const logger = createLogger();
|
|
373
|
+
const error = createErrorWithCause();
|
|
374
|
+
const res = await createApp({ error, logger }).request('/api/request/getUsers');
|
|
375
|
+
|
|
376
|
+
expect(res.status).toEqual(500);
|
|
377
|
+
expect(logger.error).toHaveBeenCalledTimes(1);
|
|
378
|
+
expect(logger.error).toHaveBeenCalledWith(error);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test('errorHandler treats a basePath prefixed api path as an api path', async () => {
|
|
382
|
+
const res = await createApp({
|
|
383
|
+
basePath: '/base',
|
|
384
|
+
error: createErrorWithCause(),
|
|
385
|
+
logger: createLogger(),
|
|
386
|
+
}).request('/base/api/request/getUsers');
|
|
387
|
+
|
|
388
|
+
expect(res.status).toEqual(500);
|
|
389
|
+
const body = await res.json();
|
|
390
|
+
expect(body['~e'].message).toEqual('Something went wrong.');
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test('errorHandler treats a basePath prefixed page path as a page path', async () => {
|
|
394
|
+
const res = await createApp({
|
|
395
|
+
basePath: '/base',
|
|
396
|
+
error: createErrorWithCause(),
|
|
397
|
+
logger: createLogger(),
|
|
398
|
+
}).request('/base/home');
|
|
399
|
+
|
|
400
|
+
expect(res.status).toEqual(500);
|
|
401
|
+
expect(await res.text()).toEqual('Internal Server Error');
|
|
402
|
+
});
|
package/src/routes/apiPage.js
CHANGED
|
@@ -16,22 +16,27 @@
|
|
|
16
16
|
|
|
17
17
|
import { getPageConfig } from '@lowdefy/api';
|
|
18
18
|
|
|
19
|
-
import
|
|
19
|
+
import lowdefyConfig from '../../lib/build/config.js';
|
|
20
|
+
import getRequestPath from '../lib/getRequestPath.js';
|
|
20
21
|
|
|
21
|
-
|
|
22
|
-
|
|
22
|
+
const basePath = lowdefyConfig.basePath ?? '';
|
|
23
|
+
|
|
24
|
+
// Page config as JSON for client-side SPA navigation, by the page's path (for a
|
|
25
|
+
// page without a path, its id). The first page load is served embedded in the
|
|
26
|
+
// HTML; subsequent navigations fetch from here.
|
|
23
27
|
async function apiPageHandler(c) {
|
|
24
28
|
const context = c.get('lowdefyContext');
|
|
25
|
-
const
|
|
29
|
+
const { path, matchedPath } = getRequestPath({ c, basePath, prefix: '/api/page/' });
|
|
26
30
|
// The client forwards its current query string on the fetch so Dynamic block
|
|
27
31
|
// resolution sees the same urlQuery as an initial HTML load.
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
32
|
+
const result = await getPageConfig(context, { path, urlQuery: c.req.query() });
|
|
33
|
+
const { pageId, pathParams } = result;
|
|
34
|
+
if (result.status !== 'ok') {
|
|
35
|
+
context.logger.info({ event: 'api_page_not_found', pageId, path });
|
|
31
36
|
return c.json({ pageConfig: null }, 404);
|
|
32
37
|
}
|
|
33
|
-
context.logger.info({ event: 'api_page_view', pageId });
|
|
34
|
-
return c.json({ pageConfig });
|
|
38
|
+
context.logger.info({ event: 'api_page_view', pageId, path });
|
|
39
|
+
return c.json({ pageId, pathParams, matchedPath, pageConfig: result.pageConfig });
|
|
35
40
|
}
|
|
36
41
|
|
|
37
42
|
export default apiPageHandler;
|