feat: Integrate ErrorHandler and remove ResponseWriter error support

This commit is contained in:
Joachim Van Herwegen 2021-06-03 16:27:43 +02:00
parent e1f95877da
commit 57d77e941d
23 changed files with 262 additions and 191 deletions

View File

@ -41,6 +41,7 @@
"@id": "urn:solid-server:default:HttpServerFactory",
"@type": "BaseHttpServerFactory",
"handler": { "@id": "urn:solid-server:default:HttpHandler" },
"options_showStackTrace": { "@id": "urn:solid-server:default:variable:showStackTrace" },
"options_https": true,
"options_key": "/path/to/server.key",
"options_cert": "/path/to/server.cert"

View File

@ -9,6 +9,7 @@
"@id": "urn:solid-server:default:HttpServerFactory",
"@type": "BaseHttpServerFactory",
"handler": { "@id": "urn:solid-server:default:HttpHandler" },
"options_showStackTrace": { "@id": "urn:solid-server:default:variable:showStackTrace" },
"options_https": true,
"options_key": "/path/to/server.key",
"options_cert": "/path/to/server.cert"

View File

@ -5,7 +5,8 @@
"comment": "Creates a server that supports HTTP requests.",
"@id": "urn:solid-server:default:ServerFactory",
"@type": "BaseHttpServerFactory",
"handler": { "@id": "urn:solid-server:default:HttpHandler" }
"handler": { "@id": "urn:solid-server:default:HttpHandler" },
"options_showStackTrace": { "@id": "urn:solid-server:default:variable:showStackTrace" }
}
]
}

View File

@ -8,7 +8,8 @@
"baseServerFactory": {
"@id": "urn:solid-server:default:HttpServerFactory",
"@type": "BaseHttpServerFactory",
"handler": { "@id": "urn:solid-server:default:HttpHandler" }
"handler": { "@id": "urn:solid-server:default:HttpHandler" },
"options_showStackTrace": { "@id": "urn:solid-server:default:variable:showStackTrace" }
},
"webSocketHandler": {
"@type": "UnsecureWebSocketsProtocol",

View File

@ -21,7 +21,8 @@
"providerFactory": { "@id": "urn:solid-server:default:IdentityProviderFactory" },
"interactionPolicy": { "@id": "urn:solid-server:auth:password:AccountInteractionPolicy" },
"interactionHttpHandler": { "@id": "urn:solid-server:auth:password:InteractionHttpHandler" },
"errorResponseWriter": { "@type": "ErrorResponseWriter" }
"errorHandler": { "@id": "urn:solid-server:default:ErrorHandler" },
"responseWriter": { "@id": "urn:solid-server:default:ResponseWriter" }
}
]
}

View File

@ -7,7 +7,8 @@
"@type": "IdentityProviderFactory",
"issuer": { "@id": "urn:solid-server:default:variable:baseUrl" },
"configurationFactory": { "@id": "urn:solid-server:default:IdpConfigurationFactory" },
"errorResponseWriter": { "@type": "ErrorResponseWriter" }
"errorHandler": { "@id": "urn:solid-server:default:ErrorHandler" },
"responseWriter": { "@id": "urn:solid-server:default:ResponseWriter" }
},
{
"comment": "Sets up the JWKS and cookie keys.",

View File

@ -0,0 +1,21 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^0.0.0/components/context.jsonld",
"@graph": [
{
"comment": "Changes an error into a valid representation to send as a response.",
"@id": "urn:solid-server:default:ErrorHandler",
"@type": "WaterfallHandler",
"handlers": [
{
"@type": "ConvertingErrorHandler",
"converter": { "@id": "urn:solid-server:default:RepresentationConverter" },
"showStackTrace": { "@id": "urn:solid-server:default:variable:showStackTrace" }
},
{
"@type": "TextErrorHandler",
"showStackTrace": { "@id": "urn:solid-server:default:variable:showStackTrace" }
}
]
}
]
}

View File

@ -4,14 +4,8 @@
{
"comment": "Writes the result to the response stream.",
"@id": "urn:solid-server:default:ResponseWriter",
"@type": "WaterfallHandler",
"handlers": [
{ "@type": "ErrorResponseWriter" },
{
"@type": "BasicResponseWriter",
"metadataWriter": { "@id": "urn:solid-server:default:MetadataWriter" }
}
]
"@type": "BasicResponseWriter",
"metadataWriter": { "@id": "urn:solid-server:default:MetadataWriter" }
}
]
}

View File

@ -1,6 +1,7 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^0.0.0/components/context.jsonld",
"import": [
"files-scs:config/ldp/handler/components/error-handler.json",
"files-scs:config/ldp/handler/components/operation-handler.json",
"files-scs:config/ldp/handler/components/request-parser.json",
"files-scs:config/ldp/handler/components/response-writer.json"
@ -15,6 +16,7 @@
"args_permissionsExtractor": { "@id": "urn:solid-server:default:PermissionsExtractor" },
"args_authorizer": { "@id": "urn:solid-server:default:Authorizer" },
"args_operationHandler": { "@id": "urn:solid-server:default:OperationHandler" },
"args_errorHandler": { "@id": "urn:solid-server:default:ErrorHandler" },
"args_responseWriter": { "@id": "urn:solid-server:default:ResponseWriter" }
}
]

View File

@ -23,7 +23,8 @@
"@type": "ChainedConverter",
"converters": [
{ "@id": "urn:solid-server:default:RdfToQuadConverter" },
{ "@id": "urn:solid-server:default:QuadToRdfConverter" }
{ "@id": "urn:solid-server:default:QuadToRdfConverter" },
{ "@type": "ErrorToQuadConverter" }
]
}
]

View File

@ -23,9 +23,15 @@
"@type": "Variable"
},
{
"comment": "The max level of log messages that should be output.",
"@id": "urn:solid-server:default:variable:loggingLevel",
"@type": "Variable"
},
{
"comment": "Whether error stack traces should be shown in the server responses.",
"@id": "urn:solid-server:default:variable:showStackTrace",
"@type": "Variable"
},
{
"comment": "Path to the JSON file used to store configuration for dynamic pods.",
"@id": "urn:solid-server:default:variable:podConfigJson",

View File

@ -6,8 +6,9 @@ import type { AnyObject,
Account,
ErrorOut } from 'oidc-provider';
import { Provider } from 'oidc-provider';
import type { ErrorHandler } from '../ldp/http/ErrorHandler';
import type { ResponseWriter } from '../ldp/http/ResponseWriter';
import type { RepresentationPreferences } from '../ldp/representation/RepresentationPreferences';
import type { ConfigurationFactory } from './configuration/ConfigurationFactory';
/**
@ -18,13 +19,15 @@ import type { ConfigurationFactory } from './configuration/ConfigurationFactory'
export class IdentityProviderFactory {
private readonly issuer: string;
private readonly configurationFactory: ConfigurationFactory;
private readonly errorResponseWriter: ResponseWriter;
private readonly errorHandler: ErrorHandler;
private readonly responseWriter: ResponseWriter;
public constructor(issuer: string, configurationFactory: ConfigurationFactory,
errorResponseWriter: ResponseWriter) {
errorHandler: ErrorHandler, responseWriter: ResponseWriter) {
this.issuer = issuer;
this.configurationFactory = configurationFactory;
this.errorResponseWriter = errorResponseWriter;
this.errorHandler = errorHandler;
this.responseWriter = responseWriter;
}
public async createProvider(interactionPolicyOptions: {
@ -81,7 +84,9 @@ export class IdentityProviderFactory {
},
renderError:
async(ctx: KoaContextWithOIDC, out: ErrorOut, error: Error): Promise<void> => {
await this.errorResponseWriter.handleSafe({ response: ctx.res, result: error });
const preferences: RepresentationPreferences = { type: { 'text/plain': 1 }};
const result = await this.errorHandler.handleSafe({ error, preferences });
await this.responseWriter.handleSafe({ response: ctx.res, result });
},
};
return new Provider(this.issuer, augmentedConfig);

View File

@ -1,9 +1,11 @@
import type { Provider } from 'oidc-provider';
import type { ErrorHandler } from '../ldp/http/ErrorHandler';
import type { ResponseWriter } from '../ldp/http/ResponseWriter';
import type { RepresentationPreferences } from '../ldp/representation/RepresentationPreferences';
import { getLoggerFor } from '../logging/LogUtil';
import type { HttpHandlerInput } from '../server/HttpHandler';
import { HttpHandler } from '../server/HttpHandler';
import { isNativeError } from '../util/errors/ErrorUtil';
import { assertNativeError, isNativeError } from '../util/errors/ErrorUtil';
import type { IdentityProviderFactory } from './IdentityProviderFactory';
import type { InteractionHttpHandler } from './interaction/InteractionHttpHandler';
import type { InteractionPolicy } from './interaction/InteractionPolicy';
@ -26,20 +28,23 @@ export class IdentityProviderHttpHandler extends HttpHandler {
private readonly providerFactory: IdentityProviderFactory;
private readonly interactionPolicy: InteractionPolicy;
private readonly interactionHttpHandler: InteractionHttpHandler;
private readonly errorResponseWriter: ResponseWriter;
private readonly errorHandler: ErrorHandler;
private readonly responseWriter: ResponseWriter;
private provider?: Provider;
public constructor(
providerFactory: IdentityProviderFactory,
interactionPolicy: InteractionPolicy,
interactionHttpHandler: InteractionHttpHandler,
errorResponseWriter: ResponseWriter,
errorHandler: ErrorHandler,
responseWriter: ResponseWriter,
) {
super();
this.providerFactory = providerFactory;
this.interactionPolicy = interactionPolicy;
this.interactionHttpHandler = interactionHttpHandler;
this.errorResponseWriter = errorResponseWriter;
this.errorHandler = errorHandler;
this.responseWriter = responseWriter;
}
/**
@ -70,11 +75,10 @@ export class IdentityProviderHttpHandler extends HttpHandler {
try {
await this.interactionHttpHandler.handle({ ...input, provider });
} catch (error: unknown) {
// ResponseWriter can only handle native errors
if (!isNativeError(error)) {
throw error;
}
await this.errorResponseWriter.handleSafe({ response: input.response, result: error });
assertNativeError(error);
const preferences: RepresentationPreferences = { type: { 'text/plain': 1 }};
const result = await this.errorHandler.handleSafe({ error, preferences });
await this.responseWriter.handleSafe({ response: input.response, result });
}
}
}

View File

@ -109,7 +109,8 @@ export * from './ldp/http/AcceptPreferenceParser';
export * from './ldp/http/BasicRequestParser';
export * from './ldp/http/BasicResponseWriter';
export * from './ldp/http/BodyParser';
export * from './ldp/http/ErrorResponseWriter';
export * from './ldp/http/ConvertingErrorHandler';
export * from './ldp/http/ErrorHandler';
export * from './ldp/http/OriginalUrlExtractor';
export * from './ldp/http/Patch';
export * from './ldp/http/PreferenceParser';
@ -119,6 +120,7 @@ export * from './ldp/http/ResponseWriter';
export * from './ldp/http/SparqlUpdateBodyParser';
export * from './ldp/http/SparqlUpdatePatch';
export * from './ldp/http/TargetExtractor';
export * from './ldp/http/TextErrorHandler';
// LDP/Operations
export * from './ldp/operations/DeleteOperationHandler';
@ -216,9 +218,10 @@ export * from './storage/accessors/SparqlDataAccessor';
export * from './storage/conversion/ChainedConverter';
export * from './storage/conversion/ConstantConverter';
export * from './storage/conversion/ContentTypeReplacer';
export * from './storage/conversion/ConversionUtil';
export * from './storage/conversion/ErrorToQuadConverter';
export * from './storage/conversion/IfNeededConverter';
export * from './storage/conversion/PassthroughConverter';
export * from './storage/conversion/ConversionUtil';
export * from './storage/conversion/QuadToRdfConverter';
export * from './storage/conversion/RdfToQuadConverter';
export * from './storage/conversion/RepresentationConverter';

View File

@ -6,7 +6,8 @@ import type { HttpHandlerInput } from '../server/HttpHandler';
import { HttpHandler } from '../server/HttpHandler';
import type { HttpRequest } from '../server/HttpRequest';
import type { HttpResponse } from '../server/HttpResponse';
import { isNativeError } from '../util/errors/ErrorUtil';
import { assertNativeError } from '../util/errors/ErrorUtil';
import type { ErrorHandler } from './http/ErrorHandler';
import type { RequestParser } from './http/RequestParser';
import type { ResponseDescription } from './http/response/ResponseDescription';
import type { ResponseWriter } from './http/ResponseWriter';
@ -14,6 +15,7 @@ import type { Operation } from './operations/Operation';
import type { OperationHandler } from './operations/OperationHandler';
import type { PermissionSet } from './permissions/PermissionSet';
import type { PermissionsExtractor } from './permissions/PermissionsExtractor';
import type { RepresentationPreferences } from './representation/RepresentationPreferences';
/**
* Collection of handlers needed for {@link AuthenticatedLdpHandler} to function.
@ -39,6 +41,10 @@ export interface AuthenticatedLdpHandlerArgs {
* Executed the operation.
*/
operationHandler: OperationHandler;
/**
* Converts errors to a serializable format.
*/
errorHandler: ErrorHandler;
/**
* Writes out the response of the operation.
*/
@ -54,6 +60,7 @@ export class AuthenticatedLdpHandler extends HttpHandler {
private readonly permissionsExtractor!: PermissionsExtractor;
private readonly authorizer!: Authorizer;
private readonly operationHandler!: OperationHandler;
private readonly errorHandler!: ErrorHandler;
private readonly responseWriter!: ResponseWriter;
private readonly logger = getLoggerFor(this);
@ -91,16 +98,16 @@ export class AuthenticatedLdpHandler extends HttpHandler {
* @returns A promise resolving when the handling is finished.
*/
public async handle(input: HttpHandlerInput): Promise<void> {
let writeData: { response: HttpResponse; result: ResponseDescription | Error };
let writeData: { response: HttpResponse; result: ResponseDescription };
try {
writeData = { response: input.response, result: await this.runHandlers(input.request) };
} catch (error: unknown) {
if (isNativeError(error)) {
writeData = { response: input.response, result: error };
} else {
throw error;
}
assertNativeError(error);
// We don't know the preferences yet at this point
const preferences: RepresentationPreferences = { type: { 'text/plain': 1 }};
const result = await this.errorHandler.handleSafe({ error, preferences });
writeData = { response: input.response, result };
}
await this.responseWriter.handleSafe(writeData);
@ -119,6 +126,20 @@ export class AuthenticatedLdpHandler extends HttpHandler {
const operation: Operation = await this.requestParser.handleSafe(request);
this.logger.verbose(`Parsed ${operation.method} operation on ${operation.target.path}`);
try {
return await this.handleOperation(request, operation);
} catch (error: unknown) {
assertNativeError(error);
return await this.errorHandler.handleSafe({ error, preferences: operation.preferences });
}
}
/**
* Handles the operation object.
* Runs all non-RequestParser handlers.
* This way the preferences can be used in case an error needs to be written.
*/
private async handleOperation(request: HttpRequest, operation: Operation): Promise<ResponseDescription> {
const credentials: Credentials = await this.credentialsExtractor.handleSafe(request);
this.logger.verbose(`Extracted credentials: ${credentials.webId}`);

View File

@ -1,32 +0,0 @@
import { getLoggerFor } from '../../logging/LogUtil';
import type { HttpResponse } from '../../server/HttpResponse';
import { isNativeError } from '../../util/errors/ErrorUtil';
import { HttpError } from '../../util/errors/HttpError';
import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
import type { ResponseDescription } from './response/ResponseDescription';
import { ResponseWriter } from './ResponseWriter';
/**
* Writes to an {@link HttpResponse} based on the incoming Error.
*/
export class ErrorResponseWriter extends ResponseWriter {
protected readonly logger = getLoggerFor(this);
public async canHandle(input: { response: HttpResponse; result: ResponseDescription | Error }): Promise<void> {
if (!isNativeError(input.result)) {
throw new NotImplementedHttpError('Only errors are supported');
}
}
public async handle(input: { response: HttpResponse; result: Error }): Promise<void> {
let code = 500;
if (HttpError.isInstance(input.result)) {
code = input.result.statusCode;
}
input.response.setHeader('content-type', 'text/plain');
input.response.writeHead(code);
input.response.end(typeof input.result.stack === 'string' ?
`${input.result.stack}\n` :
`${input.result.name}: ${input.result.message}\n`);
}
}

View File

@ -3,8 +3,7 @@ import { AsyncHandler } from '../../util/handlers/AsyncHandler';
import type { ResponseDescription } from './response/ResponseDescription';
/**
* Writes to the HttpResponse.
* Response depends on the operation result and potentially which errors was thrown.
* Writes the ResponseDescription to the HttpResponse.
*/
export abstract class ResponseWriter
extends AsyncHandler<{ response: HttpResponse; result: ResponseDescription | Error }> {}
extends AsyncHandler<{ response: HttpResponse; result: ResponseDescription }> {}

View File

@ -21,6 +21,11 @@ export interface BaseHttpServerOptions {
*/
https?: boolean;
/**
* If the error stack traces should be shown in case the HttpHandler throws one.
*/
showStackTrace?: boolean;
key?: string;
cert?: string;
@ -61,7 +66,14 @@ export class BaseHttpServerFactory implements HttpServerFactory {
this.logger.info(`Received ${request.method} request for ${request.url}`);
await this.handler.handleSafe({ request: guardStream(request), response });
} catch (error: unknown) {
const errMsg = isNativeError(error) ? `${error.name}: ${error.message}\n${error.stack}` : 'Unknown error.';
let errMsg: string;
if (!isNativeError(error)) {
errMsg = 'Unknown error.\n';
} else if (this.options.showStackTrace && error.stack) {
errMsg = `${error.stack}\n`;
} else {
errMsg = `${error.name}: ${error.message}\n`;
}
this.logger.error(errMsg);
if (response.headersSent) {
response.end();

View File

@ -2,7 +2,9 @@ import type { interactionPolicy, Configuration, KoaContextWithOIDC } from 'oidc-
import type { ConfigurationFactory } from '../../../src/identity/configuration/ConfigurationFactory';
import { IdentityProviderFactory } from '../../../src/identity/IdentityProviderFactory';
import type { InteractionPolicy } from '../../../src/identity/interaction/InteractionPolicy';
import type { ErrorHandler } from '../../../src/ldp/http/ErrorHandler';
import type { ResponseWriter } from '../../../src/ldp/http/ResponseWriter';
import type { HttpResponse } from '../../../src/server/HttpResponse';
jest.mock('oidc-provider', (): any => ({
// eslint-disable-next-line @typescript-eslint/naming-convention
@ -17,7 +19,8 @@ describe('An IdentityProviderFactory', (): void => {
};
const webId = 'http://alice.test.com/card#me';
let configuration: any;
let errorResponseWriter: ResponseWriter;
let errorHandler: ErrorHandler;
let responseWriter: ResponseWriter;
let factory: IdentityProviderFactory;
beforeEach(async(): Promise<void> => {
@ -26,11 +29,13 @@ describe('An IdentityProviderFactory', (): void => {
createConfiguration: async(): Promise<any> => configuration,
};
errorResponseWriter = {
handleSafe: jest.fn(),
errorHandler = {
handleSafe: jest.fn().mockResolvedValue({ statusCode: 500 }),
} as any;
factory = new IdentityProviderFactory(issuer, configurationFactory, errorResponseWriter);
responseWriter = { handleSafe: jest.fn() } as any;
factory = new IdentityProviderFactory(issuer, configurationFactory, errorHandler, responseWriter);
});
it('has fixed default values.', async(): Promise<void> => {
@ -65,9 +70,14 @@ describe('An IdentityProviderFactory', (): void => {
aud: 'solid',
});
await expect(result.config.renderError({ res: 'response!' }, null, 'error!')).resolves.toBeUndefined();
expect(errorResponseWriter.handleSafe).toHaveBeenCalledTimes(1);
expect(errorResponseWriter.handleSafe).toHaveBeenLastCalledWith({ response: 'response!', result: 'error!' });
// Test the renderError function
const response: HttpResponse = { } as any;
await expect(result.config.renderError({ res: response }, null, 'error!')).resolves.toBeUndefined();
expect(errorHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(errorHandler.handleSafe)
.toHaveBeenLastCalledWith({ error: 'error!', preferences: { type: { 'text/plain': 1 }}});
expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1);
expect(responseWriter.handleSafe).toHaveBeenLastCalledWith({ response, result: { statusCode: 500 }});
});
it('overwrites fields from the factory config.', async(): Promise<void> => {

View File

@ -3,6 +3,8 @@ import type { IdentityProviderFactory } from '../../../src/identity/IdentityProv
import { IdentityProviderHttpHandler } from '../../../src/identity/IdentityProviderHttpHandler';
import type { InteractionHttpHandler } from '../../../src/identity/interaction/InteractionHttpHandler';
import type { InteractionPolicy } from '../../../src/identity/interaction/InteractionPolicy';
import type { ErrorHandler } from '../../../src/ldp/http/ErrorHandler';
import type { ResponseDescription } from '../../../src/ldp/http/response/ResponseDescription';
import type { ResponseWriter } from '../../../src/ldp/http/ResponseWriter';
import type { HttpRequest } from '../../../src/server/HttpRequest';
import type { HttpResponse } from '../../../src/server/HttpResponse';
@ -16,7 +18,8 @@ describe('An IdentityProviderHttpHandler', (): void => {
url: (ctx: KoaContextWithOIDC): string => `/idp/interaction/${ctx.oidc.uid}`,
};
let interactionHttpHandler: InteractionHttpHandler;
let errorResponseWriter: ResponseWriter;
let errorHandler: ErrorHandler;
let responseWriter: ResponseWriter;
let provider: Provider;
let handler: IdentityProviderHttpHandler;
@ -34,15 +37,16 @@ describe('An IdentityProviderHttpHandler', (): void => {
handle: jest.fn(),
} as any;
errorResponseWriter = {
handleSafe: jest.fn(),
} as any;
errorHandler = { handleSafe: jest.fn() } as any;
responseWriter = { handleSafe: jest.fn() } as any;
handler = new IdentityProviderHttpHandler(
providerFactory,
idpPolicy,
interactionHttpHandler,
errorResponseWriter,
errorHandler,
responseWriter,
);
});
@ -52,7 +56,7 @@ describe('An IdentityProviderHttpHandler', (): void => {
expect(provider.callback).toHaveBeenCalledTimes(1);
expect(provider.callback).toHaveBeenLastCalledWith(request, response);
expect(interactionHttpHandler.handle).toHaveBeenCalledTimes(0);
expect(errorResponseWriter.handleSafe).toHaveBeenCalledTimes(0);
expect(responseWriter.handleSafe).toHaveBeenCalledTimes(0);
});
it('calls the interaction handler if it can handle the input.', async(): Promise<void> => {
@ -60,18 +64,22 @@ describe('An IdentityProviderHttpHandler', (): void => {
expect(provider.callback).toHaveBeenCalledTimes(0);
expect(interactionHttpHandler.handle).toHaveBeenCalledTimes(1);
expect(interactionHttpHandler.handle).toHaveBeenLastCalledWith({ request, response, provider });
expect(errorResponseWriter.handleSafe).toHaveBeenCalledTimes(0);
expect(responseWriter.handleSafe).toHaveBeenCalledTimes(0);
});
it('calls the errorResponseWriter if there was an issue with the interaction handler.', async(): Promise<void> => {
it('returns an error response if there was an issue with the interaction handler.', async(): Promise<void> => {
const error = new Error('error!');
const errorResponse: ResponseDescription = { statusCode: 500 };
(interactionHttpHandler.handle as jest.Mock).mockRejectedValueOnce(error);
(errorHandler.handleSafe as jest.Mock).mockResolvedValueOnce(errorResponse);
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
expect(provider.callback).toHaveBeenCalledTimes(0);
expect(interactionHttpHandler.handle).toHaveBeenCalledTimes(1);
expect(interactionHttpHandler.handle).toHaveBeenLastCalledWith({ request, response, provider });
expect(errorResponseWriter.handleSafe).toHaveBeenCalledTimes(1);
expect(errorResponseWriter.handleSafe).toHaveBeenLastCalledWith({ response, result: error });
expect(errorHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(errorHandler.handleSafe).toHaveBeenLastCalledWith({ error, preferences: { type: { 'text/plain': 1 }}});
expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1);
expect(responseWriter.handleSafe).toHaveBeenLastCalledWith({ response, result: errorResponse });
});
it('re-throws the error if it is not a native Error.', async(): Promise<void> => {

View File

@ -1,80 +1,117 @@
import type { CredentialsExtractor } from '../../../src/authentication/CredentialsExtractor';
import type { Authorizer } from '../../../src/authorization/Authorizer';
import type { Credentials } from '../../../src/authentication/Credentials';
import type { Authorization } from '../../../src/authorization/Authorization';
import type { AuthenticatedLdpHandlerArgs } from '../../../src/ldp/AuthenticatedLdpHandler';
import { AuthenticatedLdpHandler } from '../../../src/ldp/AuthenticatedLdpHandler';
import type { RequestParser } from '../../../src/ldp/http/RequestParser';
import type { ResponseWriter } from '../../../src/ldp/http/ResponseWriter';
import { ResetResponseDescription } from '../../../src/ldp/http/response/ResetResponseDescription';
import type { ResponseDescription } from '../../../src/ldp/http/response/ResponseDescription';
import type { Operation } from '../../../src/ldp/operations/Operation';
import type { OperationHandler } from '../../../src/ldp/operations/OperationHandler';
import type { PermissionsExtractor } from '../../../src/ldp/permissions/PermissionsExtractor';
import type { PermissionSet } from '../../../src/ldp/permissions/PermissionSet';
import type { RepresentationPreferences } from '../../../src/ldp/representation/RepresentationPreferences';
import * as LogUtil from '../../../src/logging/LogUtil';
import type { HttpRequest } from '../../../src/server/HttpRequest';
import type { HttpResponse } from '../../../src/server/HttpResponse';
import { StaticAsyncHandler } from '../../util/StaticAsyncHandler';
describe('An AuthenticatedLdpHandler', (): void => {
const request: HttpRequest = {} as any;
const response: HttpResponse = {} as any;
const preferences: RepresentationPreferences = { type: { 'text/turtle': 0.9 }};
let operation: Operation;
const credentials: Credentials = {};
const permissions: PermissionSet = { read: true, write: false, append: false, control: false };
const authorization: Authorization = { addMetadata: jest.fn() };
const result: ResponseDescription = new ResetResponseDescription();
const errorResult: ResponseDescription = { statusCode: 500 };
let args: AuthenticatedLdpHandlerArgs;
let responseFn: jest.Mock<Promise<void>, [any]>;
let handler: AuthenticatedLdpHandler;
beforeEach(async(): Promise<void> => {
const requestParser: RequestParser = new StaticAsyncHandler(true, ({ target: 'target' }) as any);
const credentialsExtractor: CredentialsExtractor = new StaticAsyncHandler(true, 'credentials' as any);
const permissionsExtractor: PermissionsExtractor = new StaticAsyncHandler(true, 'permissions' as any);
const authorizer: Authorizer = new StaticAsyncHandler(true, 'authorizer' as any);
const operationHandler: OperationHandler = new StaticAsyncHandler(true, 'operation' as any);
const responseWriter: ResponseWriter = new StaticAsyncHandler(true, 'response' as any);
responseFn = jest.fn(async(input: any): Promise<void> => {
if (!input) {
throw new Error('error');
}
});
responseWriter.canHandle = responseFn;
args = { requestParser, credentialsExtractor, permissionsExtractor, authorizer, operationHandler, responseWriter };
operation = { target: { path: 'identifier' }, method: 'GET', preferences };
args = {
requestParser: {
canHandle: jest.fn(),
handleSafe: jest.fn().mockResolvedValue(operation),
} as any,
credentialsExtractor: { handleSafe: jest.fn().mockResolvedValue(credentials) } as any,
permissionsExtractor: { handleSafe: jest.fn().mockResolvedValue(permissions) } as any,
authorizer: { handleSafe: jest.fn().mockResolvedValue(authorization) } as any,
operationHandler: { handleSafe: jest.fn().mockResolvedValue(result) } as any,
errorHandler: { handleSafe: jest.fn().mockResolvedValue(errorResult) } as any,
responseWriter: { handleSafe: jest.fn() } as any,
};
handler = new AuthenticatedLdpHandler(args);
});
it('can be created.', async(): Promise<void> => {
expect(new AuthenticatedLdpHandler(args)).toBeInstanceOf(AuthenticatedLdpHandler);
});
it('can check if it handles input.', async(): Promise<void> => {
const handler = new AuthenticatedLdpHandler(args);
it('can not handle the input if the RequestParser rejects it.', async(): Promise<void> => {
(args.requestParser.canHandle as jest.Mock).mockRejectedValueOnce(new Error('bad data!'));
await expect(handler.canHandle({ request, response })).rejects.toThrow('bad data!');
expect(args.requestParser.canHandle).toHaveBeenLastCalledWith(request);
});
await expect(handler.canHandle(
{ request: {} as HttpRequest, response: {} as HttpResponse },
)).resolves.toBeUndefined();
it('can handle the input if the RequestParser can handle it.', async(): Promise<void> => {
await expect(handler.canHandle({ request, response })).resolves.toBeUndefined();
expect(args.requestParser.canHandle).toHaveBeenCalledTimes(1);
expect(args.requestParser.canHandle).toHaveBeenLastCalledWith(request);
});
it('can handle input.', async(): Promise<void> => {
const handler = new AuthenticatedLdpHandler(args);
await expect(handler.handle({ request: 'request' as any, response: 'response' as any })).resolves.toBeUndefined();
expect(responseFn).toHaveBeenCalledTimes(1);
expect(responseFn).toHaveBeenLastCalledWith({ response: 'response', result: 'operation' as any });
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
expect(args.requestParser.handleSafe).toHaveBeenCalledTimes(1);
expect(args.requestParser.handleSafe).toHaveBeenLastCalledWith(request);
expect(args.credentialsExtractor.handleSafe).toHaveBeenCalledTimes(1);
expect(args.credentialsExtractor.handleSafe).toHaveBeenLastCalledWith(request);
expect(args.permissionsExtractor.handleSafe).toHaveBeenCalledTimes(1);
expect(args.permissionsExtractor.handleSafe).toHaveBeenLastCalledWith(operation);
expect(args.authorizer.handleSafe).toHaveBeenCalledTimes(1);
expect(args.authorizer.handleSafe)
.toHaveBeenLastCalledWith({ credentials, identifier: { path: 'identifier' }, permissions });
expect(operation.authorization).toBe(authorization);
expect(args.operationHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(args.operationHandler.handleSafe).toHaveBeenLastCalledWith(operation);
expect(args.errorHandler.handleSafe).toHaveBeenCalledTimes(0);
expect(args.responseWriter.handleSafe).toHaveBeenCalledTimes(1);
expect(args.responseWriter.handleSafe).toHaveBeenLastCalledWith({ response, result });
});
it('sends an error to the output if a handler does not support the input.', async(): Promise<void> => {
args.requestParser = new StaticAsyncHandler(false, {} as Operation);
const handler = new AuthenticatedLdpHandler(args);
it('sets preferences to text/plain in case of an error during request parsing.', async(): Promise<void> => {
const error = new Error('bad request!');
(args.requestParser.handleSafe as jest.Mock).mockRejectedValueOnce(new Error('bad request!'));
await expect(handler.handle({ request: 'request' as any, response: {} as HttpResponse })).resolves.toBeUndefined();
expect(responseFn).toHaveBeenCalledTimes(1);
expect(responseFn.mock.calls[0][0].result).toBeInstanceOf(Error);
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
expect(args.requestParser.handleSafe).toHaveBeenCalledTimes(1);
expect(args.credentialsExtractor.handleSafe).toHaveBeenCalledTimes(0);
expect(args.errorHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(args.errorHandler.handleSafe).toHaveBeenLastCalledWith({ error, preferences: { type: { 'text/plain': 1 }}});
expect(args.responseWriter.handleSafe).toHaveBeenCalledTimes(1);
expect(args.responseWriter.handleSafe).toHaveBeenLastCalledWith({ response, result: errorResult });
});
it('errors if the response writer does not support the result.', async(): Promise< void> => {
args.responseWriter = new StaticAsyncHandler(false, undefined);
const handler = new AuthenticatedLdpHandler(args);
it('sets preferences to the request preferences if they were parsed before the error.', async(): Promise<void> => {
const error = new Error('bad request!');
(args.credentialsExtractor.handleSafe as jest.Mock).mockRejectedValueOnce(new Error('bad request!'));
await expect(handler.handle({ request: 'request' as any, response: {} as HttpResponse })).rejects.toThrow(Error);
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
expect(args.requestParser.handleSafe).toHaveBeenCalledTimes(1);
expect(args.credentialsExtractor.handleSafe).toHaveBeenCalledTimes(1);
expect(args.authorizer.handleSafe).toHaveBeenCalledTimes(0);
expect(args.errorHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(args.errorHandler.handleSafe).toHaveBeenLastCalledWith({ error, preferences });
expect(args.responseWriter.handleSafe).toHaveBeenCalledTimes(1);
expect(args.responseWriter.handleSafe).toHaveBeenLastCalledWith({ response, result: errorResult });
});
it('errors an invalid object was thrown by a handler.', async(): Promise< void> => {
args.authorizer.handle = async(): Promise<any> => {
throw 'apple';
};
const handler = new AuthenticatedLdpHandler(args);
it('logs an error if authorization failed.', async(): Promise<void> => {
const logger = { verbose: jest.fn() };
const mock = jest.spyOn(LogUtil, 'getLoggerFor');
mock.mockReturnValueOnce(logger as any);
handler = new AuthenticatedLdpHandler(args);
(args.authorizer.handleSafe as jest.Mock).mockRejectedValueOnce(new Error('bad auth!'));
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
expect(logger.verbose).toHaveBeenLastCalledWith('Authorization failed: bad auth!');
await expect(handler.handle({ request: 'request' as any, response: {} as HttpResponse })).rejects.toEqual('apple');
mock.mockRestore();
});
});

View File

@ -1,51 +0,0 @@
import { EventEmitter } from 'events';
import type { MockResponse } from 'node-mocks-http';
import { createResponse } from 'node-mocks-http';
import { ErrorResponseWriter } from '../../../../src/ldp/http/ErrorResponseWriter';
import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
describe('An ErrorResponseWriter', (): void => {
const writer = new ErrorResponseWriter();
let response: MockResponse<any>;
beforeEach(async(): Promise<void> => {
response = createResponse({ eventEmitter: EventEmitter });
});
it('requires the input to be an error.', async(): Promise<void> => {
await expect(writer.canHandle({ response, result: new Error('error') }))
.resolves.toBeUndefined();
await expect(writer.canHandle({ response, result: { statusCode: 200 }}))
.rejects.toThrow(NotImplementedHttpError);
});
it('responds with 500 if an error if there is an error.', async(): Promise<void> => {
await writer.handle({ response, result: new Error('error') });
expect(response._isEndCalled()).toBeTruthy();
expect(response._getStatusCode()).toBe(500);
expect(response._getData()).toMatch('Error: error');
});
it('responds with the given statuscode if there is an HttpError.', async(): Promise<void> => {
const error = new NotImplementedHttpError('error');
await writer.handle({ response, result: error });
expect(response._isEndCalled()).toBeTruthy();
expect(response._getStatusCode()).toBe(error.statusCode);
expect(response._getData()).toMatch('NotImplementedHttpError: error');
});
it('responds with the error name and message when no stack trace is lazily generated.', async(): Promise<void> => {
const error = new Error('error');
error.stack = undefined;
await writer.handle({ response, result: error });
expect(response._isEndCalled()).toBeTruthy();
expect(response._getStatusCode()).toBe(500);
expect(response._getData()).toMatch('Error: error');
});
it('ends its response with a newline if there is an error.', async(): Promise<void> => {
await writer.handle({ response, result: new Error('error') });
expect(response._isEndCalled()).toBeTruthy();
expect(response._getData().endsWith('\n')).toBeTruthy();
});
});

View File

@ -66,12 +66,12 @@ describe('A BaseHttpServerFactory', (): void => {
await expect(request(server).get('/').expect(404)).resolves.toBeDefined();
});
it('writes an error to the HTTP response.', async(): Promise<void> => {
it('writes an error to the HTTP response without the stack trace.', async(): Promise<void> => {
handler.handleSafe.mockRejectedValueOnce(new Error('dummyError'));
const res = await request(server).get('/').expect(500);
expect(res.headers['content-type']).toBe('text/plain; charset=utf-8');
expect(res.text).toContain('dummyError');
expect(res.text).toBe('Error: dummyError\n');
});
it('does not write an error if the response had been started.', async(): Promise<void> => {
@ -91,4 +91,29 @@ describe('A BaseHttpServerFactory', (): void => {
expect(res.text).toContain('Unknown error.');
});
});
describe('with showStackTrace enabled', (): void => {
const httpOptions = {
http: true,
showStackTrace: true,
};
beforeAll(async(): Promise<void> => {
const factory = new BaseHttpServerFactory(handler, httpOptions);
server = factory.startServer(port);
});
afterAll(async(): Promise<void> => {
server.close();
});
it('throws unknown errors if its handler throw non-Error objects.', async(): Promise<void> => {
const error = new Error('dummyError');
handler.handleSafe.mockRejectedValueOnce(error);
const res = await request(server).get('/').expect(500);
expect(res.headers['content-type']).toBe('text/plain; charset=utf-8');
expect(res.text).toBe(`${error.stack}\n`);
});
});
});