mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
refactor(core): Move REST error handling to backend services (no-changelog) (#39907)
This commit is contained in:
parent
ad90c63923
commit
760e4e81d0
|
|
@ -23,6 +23,7 @@
|
|||
"dist/**/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"@n8n/api-types": "workspace:*",
|
||||
"@n8n/backend-common": "workspace:*",
|
||||
"@n8n/config": "workspace:*",
|
||||
"@n8n/constants": "workspace:*",
|
||||
|
|
@ -31,6 +32,7 @@
|
|||
"@n8n/errors": "workspace:*",
|
||||
"@n8n/utils": "workspace:*",
|
||||
"cache-manager": "catalog:",
|
||||
"express-openapi-validator": "5.5.3",
|
||||
"ioredis": "5.3.2",
|
||||
"n8n-workflow": "workspace:*"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,149 @@
|
|||
import { BadRequest, Unauthorized } from 'express-openapi-validator/dist/framework/types';
|
||||
|
||||
import {
|
||||
BadRequestError,
|
||||
NotFoundError,
|
||||
OperationalError,
|
||||
UnexpectedError,
|
||||
UserError,
|
||||
} from '@n8n/errors';
|
||||
|
||||
import { classifyRestError, RestErrorKind, isResponseError } from '../rest-error-classifier';
|
||||
|
||||
describe('classifyRestError', () => {
|
||||
it('tags ResponseError with kind responseError and http fields', () => {
|
||||
const d = classifyRestError(new NotFoundError('missing'));
|
||||
expect(d).toEqual({
|
||||
kind: RestErrorKind.responseError,
|
||||
status: 404,
|
||||
message: 'missing',
|
||||
code: 404,
|
||||
});
|
||||
});
|
||||
|
||||
it('tags UserError without HTTP status (serializers assign status)', () => {
|
||||
const d = classifyRestError(new UserError('bad input'));
|
||||
expect(d).toEqual({
|
||||
kind: RestErrorKind.userError,
|
||||
message: 'bad input',
|
||||
});
|
||||
});
|
||||
|
||||
it('tags n8n UnexpectedError', () => {
|
||||
const d = classifyRestError(new UnexpectedError('internal bug'));
|
||||
expect(d).toEqual({
|
||||
kind: RestErrorKind.unexpectedError,
|
||||
message: 'internal bug',
|
||||
});
|
||||
});
|
||||
|
||||
it('tags OperationalError as generic serverError', () => {
|
||||
const d = classifyRestError(new OperationalError('temporarily down'));
|
||||
expect(d).toEqual({
|
||||
kind: RestErrorKind.serverError,
|
||||
message: 'temporarily down',
|
||||
});
|
||||
});
|
||||
|
||||
it('tags express-openapi-validator HttpError', () => {
|
||||
const httpError = new BadRequest({ path: '/x', message: 'schema failed' });
|
||||
const d = classifyRestError(httpError);
|
||||
expect(d).toEqual({
|
||||
kind: RestErrorKind.httpError,
|
||||
status: 400,
|
||||
message: 'schema failed',
|
||||
});
|
||||
});
|
||||
|
||||
describe('Unauthorized', () => {
|
||||
it('keeps the express-openapi-validator message when no session cookie was sent', () => {
|
||||
const unauthorizedError = new Unauthorized({
|
||||
path: '/x',
|
||||
message: "'X-N8N-API-KEY' header required",
|
||||
});
|
||||
const d = classifyRestError(unauthorizedError);
|
||||
expect(d).toEqual({
|
||||
kind: RestErrorKind.httpError,
|
||||
status: 401,
|
||||
message: "'X-N8N-API-KEY' header required",
|
||||
});
|
||||
});
|
||||
|
||||
it('replaces the api key hint with a generic message when a session cookie was sent', () => {
|
||||
const unauthorizedError = new Unauthorized({
|
||||
path: '/x',
|
||||
message: "'X-N8N-API-KEY' header required",
|
||||
});
|
||||
const d = classifyRestError(unauthorizedError, { hasSessionCookie: true });
|
||||
expect(d).toEqual({
|
||||
kind: RestErrorKind.httpError,
|
||||
status: 401,
|
||||
message: 'Unauthorized',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('does not classify an unrelated error with an HTTP error class name', () => {
|
||||
class NotFound extends Error {}
|
||||
|
||||
const d = classifyRestError(new NotFound('internal error'));
|
||||
expect(d).toEqual({
|
||||
kind: RestErrorKind.serverError,
|
||||
message: 'internal error',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['HttpError', 400],
|
||||
['NotFound', 404],
|
||||
['NotAcceptable', 406],
|
||||
['MethodNotAllowed', 405],
|
||||
['BadRequest', 400],
|
||||
['RequestEntityTooLarge', 413],
|
||||
['InternalServerError', 500],
|
||||
['UnsupportedMediaType', 415],
|
||||
['Unauthorized', 401],
|
||||
['Forbidden', 403],
|
||||
])('recognizes a validator %s error from another package instance', (className, status) => {
|
||||
const CrossPackageError = {
|
||||
[className]: class extends Error {
|
||||
readonly status = status;
|
||||
},
|
||||
}[className];
|
||||
const error = new CrossPackageError('validator error');
|
||||
|
||||
expect(classifyRestError(error)).toEqual({
|
||||
kind: RestErrorKind.httpError,
|
||||
status,
|
||||
message: 'validator error',
|
||||
});
|
||||
});
|
||||
|
||||
it('tags plain Error as serverError', () => {
|
||||
const d = classifyRestError(new Error('plain'));
|
||||
expect(d).toEqual({
|
||||
kind: RestErrorKind.serverError,
|
||||
message: 'plain',
|
||||
});
|
||||
});
|
||||
|
||||
it('matches BadRequestError fields', () => {
|
||||
const d = classifyRestError(new BadRequestError('invalid'));
|
||||
expect(d).toEqual({
|
||||
kind: RestErrorKind.responseError,
|
||||
status: 400,
|
||||
message: 'invalid',
|
||||
code: 400,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isResponseError', () => {
|
||||
it('recognizes duck-typed hook errors', () => {
|
||||
const hookError = Object.assign(new Error('hook'), {
|
||||
httpStatusCode: 403,
|
||||
errorCode: 403,
|
||||
});
|
||||
expect(isResponseError(hookError)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
import type { SourceControlledFile } from '@n8n/api-types';
|
||||
|
||||
import { ConflictError, NotFoundError, UnexpectedError, UserError } from '@n8n/errors';
|
||||
|
||||
import { classifyRestError } from '../rest-error-classifier';
|
||||
import { serializeInternalRestError, serializePublicApiError } from '../rest-error-response';
|
||||
|
||||
describe('rest-error-response', () => {
|
||||
it('serializes a ResponseError with a minimal public message', () => {
|
||||
const descriptor = classifyRestError(new NotFoundError('x'));
|
||||
expect(serializePublicApiError(descriptor)).toEqual({
|
||||
status: 404,
|
||||
body: { message: 'x' },
|
||||
});
|
||||
});
|
||||
|
||||
it('serializes a ResponseError with its code for internal requests', () => {
|
||||
const descriptor = classifyRestError(new NotFoundError('x'));
|
||||
expect(serializeInternalRestError(descriptor)).toEqual({
|
||||
status: 404,
|
||||
body: {
|
||||
code: 404,
|
||||
message: 'x',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('serializes source control conflicts in both response formats', () => {
|
||||
const conflicts: SourceControlledFile[] = [
|
||||
{
|
||||
file: 'workflows/wf-1.json',
|
||||
id: 'wf-1',
|
||||
name: 'My workflow',
|
||||
type: 'workflow',
|
||||
status: 'modified',
|
||||
location: 'local',
|
||||
conflict: true,
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
const descriptor = classifyRestError(
|
||||
new ConflictError(
|
||||
'Push blocked by conflicting files. Pass `force: true` to push anyway.',
|
||||
undefined,
|
||||
{ conflicts },
|
||||
),
|
||||
);
|
||||
|
||||
expect(serializePublicApiError(descriptor)).toEqual({
|
||||
status: 409,
|
||||
body: {
|
||||
message: expect.stringContaining('conflicting files'),
|
||||
conflicts,
|
||||
},
|
||||
});
|
||||
expect(serializeInternalRestError(descriptor)).toEqual({
|
||||
status: 409,
|
||||
body: {
|
||||
code: 409,
|
||||
message: expect.stringContaining('conflicting files'),
|
||||
meta: { conflicts },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('maps UserError to 400', () => {
|
||||
const descriptor = classifyRestError(new UserError('bad input'));
|
||||
expect(serializePublicApiError(descriptor)).toEqual({
|
||||
status: 400,
|
||||
body: { message: 'bad input' },
|
||||
});
|
||||
expect(serializeInternalRestError(descriptor)).toEqual({
|
||||
status: 400,
|
||||
body: { code: 0, message: 'bad input' },
|
||||
});
|
||||
});
|
||||
|
||||
it('sanitizes UnexpectedError for public responses', () => {
|
||||
const descriptor = classifyRestError(new UnexpectedError('secret'));
|
||||
expect(serializePublicApiError(descriptor)).toEqual({
|
||||
status: 500,
|
||||
body: { message: 'Internal server error' },
|
||||
});
|
||||
expect(serializeInternalRestError(descriptor)).toEqual({
|
||||
status: 500,
|
||||
body: { code: 0, message: 'secret' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -2,7 +2,7 @@ import { HttpError, Unauthorized } from 'express-openapi-validator/dist/framewor
|
|||
|
||||
import { ResponseError, UnexpectedError, UserError } from '@n8n/errors';
|
||||
|
||||
export const enum HttpErrorKind {
|
||||
export const enum RestErrorKind {
|
||||
responseError = 'responseError',
|
||||
userError = 'userError',
|
||||
unexpectedError = 'unexpectedError',
|
||||
|
|
@ -10,9 +10,9 @@ export const enum HttpErrorKind {
|
|||
serverError = 'serverError',
|
||||
}
|
||||
|
||||
export type HttpErrorDescriptor =
|
||||
export type RestErrorDescriptor =
|
||||
| {
|
||||
kind: HttpErrorKind.responseError;
|
||||
kind: RestErrorKind.responseError;
|
||||
status: number;
|
||||
message: string;
|
||||
code: number;
|
||||
|
|
@ -20,20 +20,20 @@ export type HttpErrorDescriptor =
|
|||
meta?: Record<string, unknown>;
|
||||
}
|
||||
| {
|
||||
kind: HttpErrorKind.userError;
|
||||
kind: RestErrorKind.userError;
|
||||
message: string;
|
||||
}
|
||||
| {
|
||||
kind: HttpErrorKind.unexpectedError;
|
||||
kind: RestErrorKind.unexpectedError;
|
||||
message: string;
|
||||
}
|
||||
| {
|
||||
kind: HttpErrorKind.httpError;
|
||||
kind: RestErrorKind.httpError;
|
||||
status: number;
|
||||
message: string;
|
||||
}
|
||||
| {
|
||||
kind: HttpErrorKind.serverError;
|
||||
kind: RestErrorKind.serverError;
|
||||
message: string;
|
||||
};
|
||||
|
||||
|
|
@ -54,18 +54,18 @@ export function isResponseError(error: Error): error is ResponseError {
|
|||
return false;
|
||||
}
|
||||
|
||||
export type HttpErrorClassifierContext = {
|
||||
export type RestErrorClassifierContext = {
|
||||
/** Whether the request carried a session cookie, regardless of whether it was valid. */
|
||||
hasSessionCookie?: boolean;
|
||||
};
|
||||
|
||||
export function classifyHttpError(
|
||||
export function classifyRestError(
|
||||
error: Error,
|
||||
context?: HttpErrorClassifierContext,
|
||||
): HttpErrorDescriptor {
|
||||
context?: RestErrorClassifierContext,
|
||||
): RestErrorDescriptor {
|
||||
if (isResponseError(error)) {
|
||||
const descriptor: HttpErrorDescriptor & { kind: HttpErrorKind.responseError } = {
|
||||
kind: HttpErrorKind.responseError,
|
||||
const descriptor: RestErrorDescriptor & { kind: RestErrorKind.responseError } = {
|
||||
kind: RestErrorKind.responseError,
|
||||
status: error.httpStatusCode,
|
||||
message: error.message ?? 'Unknown error',
|
||||
code: error.errorCode,
|
||||
|
|
@ -80,26 +80,55 @@ export function classifyHttpError(
|
|||
}
|
||||
|
||||
if (error instanceof UserError) {
|
||||
return { kind: HttpErrorKind.userError, message: error.message };
|
||||
return { kind: RestErrorKind.userError, message: error.message };
|
||||
}
|
||||
|
||||
if (error instanceof UnexpectedError) {
|
||||
return { kind: HttpErrorKind.unexpectedError, message: error.message };
|
||||
return { kind: RestErrorKind.unexpectedError, message: error.message };
|
||||
}
|
||||
|
||||
// Replacing express-openapi-validator's 'api key header required' error message
|
||||
// for requests that contained a session cookie.
|
||||
if (error instanceof Unauthorized && context?.hasSessionCookie) {
|
||||
return { kind: HttpErrorKind.httpError, status: 401, message: 'Unauthorized' };
|
||||
if (isHttpErrorInstance(error, Unauthorized, ['Unauthorized']) && context?.hasSessionCookie) {
|
||||
return { kind: RestErrorKind.httpError, status: 401, message: 'Unauthorized' };
|
||||
}
|
||||
|
||||
if (error instanceof HttpError) {
|
||||
if (
|
||||
isHttpErrorInstance(error, HttpError, [
|
||||
'HttpError',
|
||||
'NotFound',
|
||||
'NotAcceptable',
|
||||
'MethodNotAllowed',
|
||||
'BadRequest',
|
||||
'RequestEntityTooLarge',
|
||||
'InternalServerError',
|
||||
'UnsupportedMediaType',
|
||||
'Unauthorized',
|
||||
'Forbidden',
|
||||
])
|
||||
) {
|
||||
return {
|
||||
kind: HttpErrorKind.httpError,
|
||||
kind: RestErrorKind.httpError,
|
||||
status: error.status || 400,
|
||||
message: error.message || 'Bad request',
|
||||
};
|
||||
}
|
||||
|
||||
return { kind: HttpErrorKind.serverError, message: error.message ?? 'Unknown error' };
|
||||
return { kind: RestErrorKind.serverError, message: error.message ?? 'Unknown error' };
|
||||
}
|
||||
|
||||
function isHttpErrorInstance<T extends Error>(
|
||||
error: Error,
|
||||
errorClass: new (...args: never[]) => T,
|
||||
classNames: string[],
|
||||
): error is T {
|
||||
if (error instanceof errorClass) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return classNames.includes(error.constructor.name) && hasHttpStatus(error);
|
||||
}
|
||||
|
||||
function hasHttpStatus(error: Error): error is Error & { status: number } {
|
||||
return 'status' in error && typeof error.status === 'number';
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { isWorkflowPublishBlockedDetails } from '@n8n/api-types';
|
||||
|
||||
import { HttpErrorKind, type HttpErrorDescriptor } from '@/errors/http-error-classifier';
|
||||
import { RestErrorKind, type RestErrorDescriptor } from './rest-error-classifier';
|
||||
|
||||
const GENERIC_PUBLIC_MESSAGE = 'Internal server error';
|
||||
|
||||
|
|
@ -12,12 +12,12 @@ export type InternalRestErrorBody = {
|
|||
meta?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export function serializePublicApiError(descriptor: HttpErrorDescriptor): {
|
||||
export function serializePublicApiError(descriptor: RestErrorDescriptor): {
|
||||
status: number;
|
||||
body: { message: string };
|
||||
} {
|
||||
switch (descriptor.kind) {
|
||||
case HttpErrorKind.responseError: {
|
||||
case RestErrorKind.responseError: {
|
||||
const body: { message: string } & Record<string, unknown> = {
|
||||
message: descriptor.message,
|
||||
};
|
||||
|
|
@ -50,18 +50,18 @@ export function serializePublicApiError(descriptor: HttpErrorDescriptor): {
|
|||
},
|
||||
};
|
||||
}
|
||||
case HttpErrorKind.userError:
|
||||
case RestErrorKind.userError:
|
||||
return {
|
||||
status: 400,
|
||||
body: { message: descriptor.message },
|
||||
};
|
||||
case HttpErrorKind.unexpectedError:
|
||||
case HttpErrorKind.serverError:
|
||||
case RestErrorKind.unexpectedError:
|
||||
case RestErrorKind.serverError:
|
||||
return {
|
||||
status: 500,
|
||||
body: { message: GENERIC_PUBLIC_MESSAGE },
|
||||
};
|
||||
case HttpErrorKind.httpError:
|
||||
case RestErrorKind.httpError:
|
||||
return {
|
||||
status: descriptor.status,
|
||||
body: { message: descriptor.message },
|
||||
|
|
@ -69,12 +69,12 @@ export function serializePublicApiError(descriptor: HttpErrorDescriptor): {
|
|||
}
|
||||
}
|
||||
|
||||
export function serializeInternalRestError(descriptor: HttpErrorDescriptor): {
|
||||
export function serializeInternalRestError(descriptor: RestErrorDescriptor): {
|
||||
status: number;
|
||||
body: InternalRestErrorBody;
|
||||
} {
|
||||
switch (descriptor.kind) {
|
||||
case HttpErrorKind.responseError: {
|
||||
case RestErrorKind.responseError: {
|
||||
const body: InternalRestErrorBody = {
|
||||
code: descriptor.code,
|
||||
message: descriptor.message,
|
||||
|
|
@ -87,18 +87,18 @@ export function serializeInternalRestError(descriptor: HttpErrorDescriptor): {
|
|||
}
|
||||
return { status: descriptor.status, body };
|
||||
}
|
||||
case HttpErrorKind.userError:
|
||||
case RestErrorKind.userError:
|
||||
return {
|
||||
status: 400,
|
||||
body: { code: 0, message: descriptor.message },
|
||||
};
|
||||
case HttpErrorKind.unexpectedError:
|
||||
case HttpErrorKind.serverError:
|
||||
case RestErrorKind.unexpectedError:
|
||||
case RestErrorKind.serverError:
|
||||
return {
|
||||
status: 500,
|
||||
body: { code: 0, message: descriptor.message },
|
||||
};
|
||||
case HttpErrorKind.httpError:
|
||||
case RestErrorKind.httpError:
|
||||
return {
|
||||
status: descriptor.status,
|
||||
body: { code: 0, message: descriptor.message },
|
||||
|
|
@ -4,3 +4,15 @@ export { CacheService } from './services/cache/cache.service';
|
|||
export { RedisClientService } from './services/redis-client.service';
|
||||
export type { RedisClientType } from './services/redis.types';
|
||||
export { UrlService } from './services/url.service';
|
||||
export {
|
||||
classifyRestError,
|
||||
isResponseError,
|
||||
type RestErrorClassifierContext,
|
||||
type RestErrorDescriptor,
|
||||
RestErrorKind,
|
||||
} from './errors/rest-error-classifier';
|
||||
export {
|
||||
serializeInternalRestError,
|
||||
serializePublicApiError,
|
||||
type InternalRestErrorBody,
|
||||
} from './errors/rest-error-response';
|
||||
|
|
|
|||
|
|
@ -1,122 +0,0 @@
|
|||
import { BadRequest, Unauthorized } from 'express-openapi-validator/dist/framework/types';
|
||||
|
||||
import {
|
||||
BadRequestError,
|
||||
NotFoundError,
|
||||
OperationalError,
|
||||
UnexpectedError,
|
||||
UserError,
|
||||
} from '@n8n/errors';
|
||||
import { LicenseEulaRequiredError } from '@/errors/response-errors/license-eula-required.error';
|
||||
|
||||
import { classifyHttpError, HttpErrorKind, isResponseError } from '../http-error-classifier';
|
||||
|
||||
describe('classifyHttpError', () => {
|
||||
it('tags ResponseError with kind responseError and http fields', () => {
|
||||
const d = classifyHttpError(new NotFoundError('missing'));
|
||||
expect(d).toEqual({
|
||||
kind: HttpErrorKind.responseError,
|
||||
status: 404,
|
||||
message: 'missing',
|
||||
code: 404,
|
||||
});
|
||||
});
|
||||
|
||||
it('tags UserError without HTTP status (serializers assign status)', () => {
|
||||
const d = classifyHttpError(new UserError('bad input'));
|
||||
expect(d).toEqual({
|
||||
kind: HttpErrorKind.userError,
|
||||
message: 'bad input',
|
||||
});
|
||||
});
|
||||
|
||||
it('tags n8n UnexpectedError', () => {
|
||||
const d = classifyHttpError(new UnexpectedError('internal bug'));
|
||||
expect(d).toEqual({
|
||||
kind: HttpErrorKind.unexpectedError,
|
||||
message: 'internal bug',
|
||||
});
|
||||
});
|
||||
|
||||
it('tags OperationalError as generic serverError', () => {
|
||||
const d = classifyHttpError(new OperationalError('temporarily down'));
|
||||
expect(d).toEqual({
|
||||
kind: HttpErrorKind.serverError,
|
||||
message: 'temporarily down',
|
||||
});
|
||||
});
|
||||
|
||||
it('tags express-openapi-validator HttpError', () => {
|
||||
const err = new BadRequest({ path: '/x', message: 'schema failed' });
|
||||
const d = classifyHttpError(err);
|
||||
expect(d).toEqual({
|
||||
kind: HttpErrorKind.httpError,
|
||||
status: 400,
|
||||
message: 'schema failed',
|
||||
});
|
||||
});
|
||||
|
||||
describe('Unauthorized', () => {
|
||||
it('keeps the express-openapi-validator message when no session cookie was sent', () => {
|
||||
const err = new Unauthorized({ path: '/x', message: "'X-N8N-API-KEY' header required" });
|
||||
const d = classifyHttpError(err);
|
||||
expect(d).toEqual({
|
||||
kind: HttpErrorKind.httpError,
|
||||
status: 401,
|
||||
message: "'X-N8N-API-KEY' header required",
|
||||
});
|
||||
});
|
||||
|
||||
it('replaces the api key hint with a generic message when a session cookie was sent', () => {
|
||||
const err = new Unauthorized({ path: '/x', message: "'X-N8N-API-KEY' header required" });
|
||||
const d = classifyHttpError(err, { hasSessionCookie: true });
|
||||
expect(d).toEqual({
|
||||
kind: HttpErrorKind.httpError,
|
||||
status: 401,
|
||||
message: 'Unauthorized',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('tags plain Error as serverError', () => {
|
||||
const d = classifyHttpError(new Error('plain'));
|
||||
expect(d).toEqual({
|
||||
kind: HttpErrorKind.serverError,
|
||||
message: 'plain',
|
||||
});
|
||||
});
|
||||
|
||||
it('includes meta for LicenseEulaRequiredError', () => {
|
||||
const eulaUrl = 'https://n8n.io/legal/eula/';
|
||||
const d = classifyHttpError(
|
||||
new LicenseEulaRequiredError('License activation requires EULA acceptance', {
|
||||
eulaUrl,
|
||||
}),
|
||||
);
|
||||
expect(d.kind).toBe(HttpErrorKind.responseError);
|
||||
if (d.kind === HttpErrorKind.responseError) {
|
||||
expect(d.status).toBe(400);
|
||||
expect(d.meta).toEqual({ eulaUrl });
|
||||
}
|
||||
});
|
||||
|
||||
it('matches BadRequestError fields', () => {
|
||||
const d = classifyHttpError(new BadRequestError('invalid'));
|
||||
expect(d).toEqual({
|
||||
kind: HttpErrorKind.responseError,
|
||||
status: 400,
|
||||
message: 'invalid',
|
||||
code: 400,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isResponseError', () => {
|
||||
it('recognizes duck-typed hook errors', () => {
|
||||
const err = Object.assign(new Error('hook'), {
|
||||
httpStatusCode: 403,
|
||||
errorCode: 403,
|
||||
});
|
||||
expect(isResponseError(err)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import { LicenseEulaRequiredError } from '@/errors/response-errors/license-eula-required.error';
|
||||
import { classifyRestError, RestErrorKind } from '@n8n/backend-services';
|
||||
import {
|
||||
BadRequest,
|
||||
Forbidden,
|
||||
InternalServerError,
|
||||
MethodNotAllowed,
|
||||
NotAcceptable,
|
||||
NotFound,
|
||||
RequestEntityTooLarge,
|
||||
Unauthorized,
|
||||
UnsupportedMediaType,
|
||||
} from 'express-openapi-validator/dist/framework/types';
|
||||
|
||||
describe('classifyRestError', () => {
|
||||
it('includes meta for LicenseEulaRequiredError', () => {
|
||||
const eulaUrl = 'https://n8n.io/legal/eula/';
|
||||
const descriptor = classifyRestError(
|
||||
new LicenseEulaRequiredError('License activation requires EULA acceptance', {
|
||||
eulaUrl,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(descriptor.kind).toBe(RestErrorKind.responseError);
|
||||
if (descriptor.kind === RestErrorKind.responseError) {
|
||||
expect(descriptor.status).toBe(400);
|
||||
expect(descriptor.meta).toEqual({ eulaUrl });
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
[NotFound, 404],
|
||||
[NotAcceptable, 406],
|
||||
[MethodNotAllowed, 405],
|
||||
[BadRequest, 400],
|
||||
[RequestEntityTooLarge, 413],
|
||||
[InternalServerError, 500],
|
||||
[UnsupportedMediaType, 415],
|
||||
[Unauthorized, 401],
|
||||
[Forbidden, 403],
|
||||
])('classifies a %s from the CLI validator package', (ErrorClass, status) => {
|
||||
const error = new ErrorClass({ path: '/x', message: 'validator error' });
|
||||
|
||||
expect(classifyRestError(error)).toEqual({
|
||||
kind: RestErrorKind.httpError,
|
||||
status,
|
||||
message: 'validator error',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,38 +1,13 @@
|
|||
import type { SourceControlledFile } from '@n8n/api-types';
|
||||
|
||||
import { classifyHttpError } from '@/errors/http-error-classifier';
|
||||
import {
|
||||
serializeInternalRestError,
|
||||
serializePublicApiError,
|
||||
} from '@/errors/http-error-serializers';
|
||||
import { ConflictError, NotFoundError, UnexpectedError, UserError } from '@n8n/errors';
|
||||
import { classifyRestError } from '@n8n/backend-services';
|
||||
import { serializeInternalRestError, serializePublicApiError } from '@n8n/backend-services';
|
||||
import { LicenseEulaRequiredError } from '@/errors/response-errors/license-eula-required.error';
|
||||
import { WorkflowPublishBlockedError } from '@/errors/response-errors/workflow-publish-blocked.error';
|
||||
import { toImportBlockedError } from '@/modules/n8n-packages/engine/import-blocked.error';
|
||||
import { PolicyViolationError } from '@/policy/policy-violation.error';
|
||||
|
||||
describe('http-error-serializers', () => {
|
||||
it('serializePublicApiError: minimal message for ResponseError', () => {
|
||||
const descriptor = classifyHttpError(new NotFoundError('x'));
|
||||
expect(serializePublicApiError(descriptor)).toEqual({
|
||||
status: 404,
|
||||
body: { message: 'x' },
|
||||
});
|
||||
});
|
||||
|
||||
it('serializeInternalRestError: includes code for ResponseError', () => {
|
||||
const descriptor = classifyHttpError(new NotFoundError('x'));
|
||||
expect(serializeInternalRestError(descriptor)).toEqual({
|
||||
status: 404,
|
||||
body: {
|
||||
code: 404,
|
||||
message: 'x',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe('rest-error-response', () => {
|
||||
it('serializePublicApiError: does not expose internal-only response error meta', () => {
|
||||
const descriptor = classifyHttpError(
|
||||
const descriptor = classifyRestError(
|
||||
new LicenseEulaRequiredError('License activation requires EULA acceptance', {
|
||||
eulaUrl: 'https://n8n.io/legal/eula/',
|
||||
}),
|
||||
|
|
@ -52,7 +27,7 @@ describe('http-error-serializers', () => {
|
|||
});
|
||||
|
||||
it('returns review details publicly while keeping editor-only validation metadata internal', () => {
|
||||
const descriptor = classifyHttpError(
|
||||
const descriptor = classifyRestError(
|
||||
new WorkflowPublishBlockedError({
|
||||
reason: 'changes_requested',
|
||||
workflowReviewRequestId: 'review-1',
|
||||
|
|
@ -90,7 +65,7 @@ describe('http-error-serializers', () => {
|
|||
usedByWorkflows: ['wf-1'],
|
||||
},
|
||||
];
|
||||
const descriptor = classifyHttpError(toImportBlockedError(issues));
|
||||
const descriptor = classifyRestError(toImportBlockedError(issues));
|
||||
|
||||
const result = serializePublicApiError(descriptor);
|
||||
expect(result.status).toBe(422);
|
||||
|
|
@ -112,7 +87,7 @@ describe('http-error-serializers', () => {
|
|||
usedByWorkflows: ['wf-1'],
|
||||
},
|
||||
];
|
||||
const descriptor = classifyHttpError(toImportBlockedError(issues));
|
||||
const descriptor = classifyRestError(toImportBlockedError(issues));
|
||||
|
||||
const result = serializePublicApiError(descriptor);
|
||||
expect(result.status).toBe(409);
|
||||
|
|
@ -129,7 +104,7 @@ describe('http-error-serializers', () => {
|
|||
subjectType: 'nodeType',
|
||||
},
|
||||
];
|
||||
const descriptor = classifyHttpError(new PolicyViolationError([violations[0]]));
|
||||
const descriptor = classifyRestError(new PolicyViolationError([violations[0]]));
|
||||
|
||||
expect(serializePublicApiError(descriptor)).toEqual({
|
||||
status: 403,
|
||||
|
|
@ -147,68 +122,4 @@ describe('http-error-serializers', () => {
|
|||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('both serializers expose source control push conflicts on a 409', () => {
|
||||
const conflicts: SourceControlledFile[] = [
|
||||
{
|
||||
file: 'workflows/wf-1.json',
|
||||
id: 'wf-1',
|
||||
name: 'My workflow',
|
||||
type: 'workflow',
|
||||
status: 'modified',
|
||||
location: 'local',
|
||||
conflict: true,
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
const descriptor = classifyHttpError(
|
||||
new ConflictError(
|
||||
'Push blocked by conflicting files. Pass `force: true` to push anyway.',
|
||||
undefined,
|
||||
{
|
||||
conflicts,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(serializePublicApiError(descriptor)).toEqual({
|
||||
status: 409,
|
||||
body: {
|
||||
message: expect.stringContaining('conflicting files'),
|
||||
conflicts,
|
||||
},
|
||||
});
|
||||
expect(serializeInternalRestError(descriptor)).toEqual({
|
||||
status: 409,
|
||||
body: {
|
||||
code: 409,
|
||||
message: expect.stringContaining('conflicting files'),
|
||||
meta: { conflicts },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('both serializers map UserError to 400', () => {
|
||||
const descriptor = classifyHttpError(new UserError('bad input'));
|
||||
expect(serializePublicApiError(descriptor)).toEqual({
|
||||
status: 400,
|
||||
body: { message: 'bad input' },
|
||||
});
|
||||
expect(serializeInternalRestError(descriptor)).toEqual({
|
||||
status: 400,
|
||||
body: { code: 0, message: 'bad input' },
|
||||
});
|
||||
});
|
||||
|
||||
it('public sanitizes UnexpectedError; internal keeps message', () => {
|
||||
const descriptor = classifyHttpError(new UnexpectedError('secret'));
|
||||
expect(serializePublicApiError(descriptor)).toEqual({
|
||||
status: 500,
|
||||
body: { message: 'Internal server error' },
|
||||
});
|
||||
expect(serializeInternalRestError(descriptor)).toEqual({
|
||||
status: 500,
|
||||
body: { code: 0, message: 'secret' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -16,9 +16,8 @@ import { Container, Service } from '@n8n/di';
|
|||
import { OperationalError } from 'n8n-workflow';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import { classifyHttpError } from '@/errors/http-error-classifier';
|
||||
import type { PolicyActor } from '@/policy/policy-enforcement-backend';
|
||||
import { serializeInternalRestError } from '@/errors/http-error-serializers';
|
||||
import { classifyRestError, serializeInternalRestError } from '@n8n/backend-services';
|
||||
import { PolicyEnforcementService } from '@/policy/policy-enforcement.service';
|
||||
import { PolicyViolationError } from '@/policy/policy-violation.error';
|
||||
|
||||
|
|
@ -705,7 +704,7 @@ describe('PolicyDecisionService', () => {
|
|||
const error = await proxy
|
||||
.enforceWorkflowSave(saveContext, unattended)
|
||||
.catch((e: unknown) => e);
|
||||
const { status, body } = serializeInternalRestError(classifyHttpError(error as Error));
|
||||
const { status, body } = serializeInternalRestError(classifyRestError(error as Error));
|
||||
|
||||
expect(status).toBe(503);
|
||||
expect(JSON.stringify(body)).not.toContain('policy store');
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import type { PolicyViolation } from '@n8n/decorators';
|
||||
import { UserError } from 'n8n-workflow';
|
||||
|
||||
import { classifyHttpError, HttpErrorKind } from '@/errors/http-error-classifier';
|
||||
import { serializeInternalRestError } from '@/errors/http-error-serializers';
|
||||
import { classifyRestError, RestErrorKind } from '@n8n/backend-services';
|
||||
import { serializeInternalRestError } from '@n8n/backend-services';
|
||||
|
||||
import {
|
||||
isPolicyRefusal,
|
||||
|
|
@ -67,7 +67,7 @@ describe('PolicyViolationError', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('classifyHttpError', () => {
|
||||
describe('classifyRestError', () => {
|
||||
it('classifies as a responseError carrying the violations in meta', () => {
|
||||
const violations: NonEmptyViolations = [
|
||||
violation(),
|
||||
|
|
@ -75,8 +75,8 @@ describe('PolicyViolationError', () => {
|
|||
];
|
||||
const error = new PolicyViolationError(violations);
|
||||
|
||||
expect(classifyHttpError(error)).toEqual({
|
||||
kind: HttpErrorKind.responseError,
|
||||
expect(classifyRestError(error)).toEqual({
|
||||
kind: RestErrorKind.responseError,
|
||||
status: 403,
|
||||
code: 403,
|
||||
message: error.message,
|
||||
|
|
@ -85,7 +85,7 @@ describe('PolicyViolationError', () => {
|
|||
});
|
||||
|
||||
it('carries the violations into the REST response body', () => {
|
||||
const descriptor = classifyHttpError(new PolicyViolationError([violation()]));
|
||||
const descriptor = classifyRestError(new PolicyViolationError([violation()]));
|
||||
const { status, body } = serializeInternalRestError(descriptor);
|
||||
|
||||
expect(status).toBe(403);
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ function summarize(violations: NonEmptyViolations): string {
|
|||
* Thrown by every `enforce*` method when a policy blocks an action.
|
||||
*
|
||||
* `UserError` so the execution path treats a blocked run as non-retryable, plus the
|
||||
* `httpStatusCode`/`errorCode` pair `classifyHttpError` looks for so `meta` — and with it the
|
||||
* `httpStatusCode`/`errorCode` pair `classifyRestError` looks for so `meta` — and with it the
|
||||
* violations — reaches the REST body. Extending `ResponseError` would lose the first.
|
||||
*/
|
||||
export class PolicyViolationError extends UserError {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import type { Response } from 'express';
|
||||
|
||||
import type { HttpErrorClassifierContext } from '@/errors/http-error-classifier';
|
||||
import { classifyHttpError } from '@/errors/http-error-classifier';
|
||||
import { serializePublicApiError } from '@/errors/http-error-serializers';
|
||||
import {
|
||||
classifyRestError,
|
||||
serializePublicApiError,
|
||||
type RestErrorClassifierContext,
|
||||
} from '@n8n/backend-services';
|
||||
|
||||
/**
|
||||
* Maps errors from the public API stack to HTTP responses. Used by the
|
||||
|
|
@ -11,9 +13,9 @@ import { serializePublicApiError } from '@/errors/http-error-serializers';
|
|||
export function sendPublicApiErrorResponse(
|
||||
res: Response,
|
||||
error: Error,
|
||||
context?: HttpErrorClassifierContext,
|
||||
context?: RestErrorClassifierContext,
|
||||
): void {
|
||||
const descriptor = classifyHttpError(error, context);
|
||||
const descriptor = classifyRestError(error, context);
|
||||
const { status, body } = serializePublicApiError(descriptor);
|
||||
res.status(status).json(body);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
import { inDevelopment, Logger } from '@n8n/backend-common';
|
||||
import {
|
||||
classifyRestError,
|
||||
isResponseError,
|
||||
serializeInternalRestError,
|
||||
} from '@n8n/backend-services';
|
||||
import { isUniqueConstraintError, type User } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { type ReportingOptions } from '@n8n/errors';
|
||||
|
|
@ -9,9 +14,7 @@ import { FORM_TRIGGER_PATH_IDENTIFIER, NodeApiError } from 'n8n-workflow';
|
|||
import { Readable } from 'node:stream';
|
||||
import picocolors from 'picocolors';
|
||||
|
||||
import { classifyHttpError, isResponseError } from './errors/http-error-classifier';
|
||||
import { applyFormSandboxCSP } from './webhooks/webhook-response-headers';
|
||||
import { serializeInternalRestError } from './errors/http-error-serializers';
|
||||
|
||||
export function sendSuccessResponse(
|
||||
res: Response,
|
||||
|
|
@ -77,7 +80,7 @@ export function sendErrorResponse(res: Response, error: Error) {
|
|||
}
|
||||
}
|
||||
|
||||
const descriptor = classifyHttpError(error);
|
||||
const descriptor = classifyRestError(error);
|
||||
const { status, body: response } = serializeInternalRestError(descriptor);
|
||||
|
||||
if (error instanceof NodeApiError) {
|
||||
|
|
|
|||
|
|
@ -1792,6 +1792,9 @@ importers:
|
|||
|
||||
packages/@n8n/backend-services:
|
||||
dependencies:
|
||||
'@n8n/api-types':
|
||||
specifier: workspace:*
|
||||
version: link:../api-types
|
||||
'@n8n/backend-common':
|
||||
specifier: workspace:*
|
||||
version: link:../backend-common
|
||||
|
|
@ -1816,6 +1819,9 @@ importers:
|
|||
cache-manager:
|
||||
specifier: 'catalog:'
|
||||
version: 5.2.3
|
||||
express-openapi-validator:
|
||||
specifier: 5.5.3
|
||||
version: 5.5.3(express@5.2.1(supports-color@8.1.1))
|
||||
ioredis:
|
||||
specifier: 5.3.2
|
||||
version: 5.3.2(supports-color@8.1.1)
|
||||
|
|
@ -36699,6 +36705,24 @@ snapshots:
|
|||
path-to-regexp: 8.4.0
|
||||
qs: 6.15.2
|
||||
|
||||
express-openapi-validator@5.5.3(express@5.2.1(supports-color@8.1.1)):
|
||||
dependencies:
|
||||
'@apidevtools/json-schema-ref-parser': 12.0.2
|
||||
'@types/multer': 1.4.12
|
||||
ajv: 8.20.0
|
||||
ajv-draft-04: 1.0.0(ajv@8.20.0)
|
||||
ajv-formats: 3.0.1(ajv@8.20.0)
|
||||
content-type: 1.0.5
|
||||
express: 5.2.1(supports-color@8.1.1)
|
||||
json-schema-traverse: 1.0.0
|
||||
lodash.clonedeep: 4.5.0
|
||||
lodash.get: 4.4.2
|
||||
media-typer: 1.1.0
|
||||
multer: 2.3.0
|
||||
ono: 7.1.3
|
||||
path-to-regexp: 8.4.0
|
||||
qs: 6.15.2
|
||||
|
||||
express-prom-bundle@8.0.0(prom-client@15.1.3):
|
||||
dependencies:
|
||||
'@types/express': 5.0.1
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user