mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
feat: Support JSON errors
The IDP behaviour has been changed to move all error related knowledge to the IdentityProviderHttpHandler instead of managing it in the Interactionhandlers.
This commit is contained in:
parent
bbfbfbbce4
commit
cc1c3d9223
@ -1,6 +1,10 @@
|
||||
{
|
||||
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld",
|
||||
"@graph": [
|
||||
{
|
||||
"@id": "urn:solid-server:default:ErrorToJsonConverter",
|
||||
"@type": "ErrorToJsonConverter"
|
||||
},
|
||||
{
|
||||
"@id": "urn:solid-server:default:ErrorToQuadConverter",
|
||||
"@type": "ErrorToQuadConverter"
|
||||
|
@ -31,6 +31,7 @@
|
||||
{ "@id": "urn:solid-server:default:RdfToQuadConverter" },
|
||||
{ "@id": "urn:solid-server:default:QuadToRdfConverter" },
|
||||
{ "@id": "urn:solid-server:default:ContainerToTemplateConverter" },
|
||||
{ "@id": "urn:solid-server:default:ErrorToJsonConverter" },
|
||||
{ "@id": "urn:solid-server:default:ErrorToQuadConverter" },
|
||||
{ "@id": "urn:solid-server:default:ErrorToTemplateConverter" },
|
||||
{ "@id": "urn:solid-server:default:MarkdownToHtmlConverter" },
|
||||
|
@ -1,11 +1,11 @@
|
||||
import type { ErrorHandler } from '../ldp/http/ErrorHandler';
|
||||
import type { RequestParser } from '../ldp/http/RequestParser';
|
||||
import { OkResponseDescription } from '../ldp/http/response/OkResponseDescription';
|
||||
import { RedirectResponseDescription } from '../ldp/http/response/RedirectResponseDescription';
|
||||
import type { ResponseDescription } from '../ldp/http/response/ResponseDescription';
|
||||
import { ResponseDescription } from '../ldp/http/response/ResponseDescription';
|
||||
import type { ResponseWriter } from '../ldp/http/ResponseWriter';
|
||||
import type { Operation } from '../ldp/operations/Operation';
|
||||
import { BasicRepresentation } from '../ldp/representation/BasicRepresentation';
|
||||
import type { Representation } from '../ldp/representation/Representation';
|
||||
import { getLoggerFor } from '../logging/LogUtil';
|
||||
import { BaseHttpHandler } from '../server/BaseHttpHandler';
|
||||
import type { BaseHttpHandlerArgs } from '../server/BaseHttpHandler';
|
||||
@ -15,7 +15,8 @@ import type { RepresentationConverter } from '../storage/conversion/Representati
|
||||
import { APPLICATION_JSON } from '../util/ContentTypes';
|
||||
import { BadRequestHttpError } from '../util/errors/BadRequestHttpError';
|
||||
import { joinUrl, trimTrailingSlashes } from '../util/PathUtil';
|
||||
import { addTemplateMetadata } from '../util/ResourceUtil';
|
||||
import { addTemplateMetadata, cloneRepresentation } from '../util/ResourceUtil';
|
||||
import { readJsonStream } from '../util/StreamUtil';
|
||||
import type { ProviderFactory } from './configuration/ProviderFactory';
|
||||
import type { Interaction } from './interaction/email-password/handler/InteractionHandler';
|
||||
import type { InteractionRoute, TemplatedInteractionResult } from './interaction/routing/InteractionRoute';
|
||||
@ -123,6 +124,9 @@ export class IdentityProviderHttpHandler extends BaseHttpHandler {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cloning input data so it can be sent back in case of errors
|
||||
let clone: Representation | undefined;
|
||||
|
||||
// IDP handlers expect JSON data
|
||||
if (operation.body) {
|
||||
const args = {
|
||||
@ -131,9 +135,14 @@ export class IdentityProviderHttpHandler extends BaseHttpHandler {
|
||||
identifier: operation.target,
|
||||
};
|
||||
operation.body = await this.converter.handleSafe(args);
|
||||
clone = await cloneRepresentation(operation.body);
|
||||
}
|
||||
|
||||
const result = await route.handleOperation(operation, oidcInteraction);
|
||||
|
||||
// Reset the body so it can be reused when needed for output
|
||||
operation.body = clone;
|
||||
|
||||
return this.handleInteractionResult(operation, request, result, oidcInteraction);
|
||||
}
|
||||
|
||||
@ -172,9 +181,27 @@ export class IdentityProviderHttpHandler extends BaseHttpHandler {
|
||||
// Create a redirect URL with the OIDC library
|
||||
const location = await this.interactionCompleter.handleSafe({ ...result.details, request });
|
||||
responseDescription = new RedirectResponseDescription(location);
|
||||
} else if (result.type === 'error') {
|
||||
// We want to show the errors on the original page in case of html interactions, so we can't just throw them here
|
||||
const preferences = { type: { [APPLICATION_JSON]: 1 }};
|
||||
const response = await this.errorHandler.handleSafe({ error: result.error, preferences });
|
||||
const details = await readJsonStream(response.data!);
|
||||
|
||||
// Add the input data to the JSON response;
|
||||
if (operation.body) {
|
||||
details.prefilled = await readJsonStream(operation.body.data);
|
||||
|
||||
// Don't send passwords back
|
||||
delete details.prefilled.password;
|
||||
delete details.prefilled.confirmPassword;
|
||||
}
|
||||
|
||||
responseDescription =
|
||||
await this.handleResponseResult(details, operation, result.templateFiles, oidcInteraction, response.statusCode);
|
||||
} else {
|
||||
// Convert the response object to a data stream
|
||||
responseDescription = await this.handleResponseResult(result, operation, oidcInteraction);
|
||||
responseDescription =
|
||||
await this.handleResponseResult(result.details ?? {}, operation, result.templateFiles, oidcInteraction);
|
||||
}
|
||||
|
||||
return responseDescription;
|
||||
@ -184,19 +211,19 @@ export class IdentityProviderHttpHandler extends BaseHttpHandler {
|
||||
* Converts an InteractionResponseResult to a ResponseDescription by first converting to a Representation
|
||||
* and applying necessary conversions.
|
||||
*/
|
||||
private async handleResponseResult(result: TemplatedInteractionResult, operation: Operation,
|
||||
oidcInteraction?: Interaction): Promise<ResponseDescription> {
|
||||
// Convert the object to a valid JSON representation
|
||||
private async handleResponseResult(details: Record<string, any>, operation: Operation,
|
||||
templateFiles: Record<string, string>, oidcInteraction?: Interaction, statusCode = 200):
|
||||
Promise<ResponseDescription> {
|
||||
const json = {
|
||||
...details,
|
||||
apiVersion: API_VERSION,
|
||||
...result.details,
|
||||
authenticating: Boolean(oidcInteraction),
|
||||
controls: this.controls,
|
||||
};
|
||||
const representation = new BasicRepresentation(JSON.stringify(json), operation.target, APPLICATION_JSON);
|
||||
|
||||
// Template metadata is required for conversion
|
||||
for (const [ type, templateFile ] of Object.entries(result.templateFiles)) {
|
||||
for (const [ type, templateFile ] of Object.entries(templateFiles)) {
|
||||
addTemplateMetadata(representation.metadata, templateFile, type);
|
||||
}
|
||||
|
||||
@ -204,7 +231,7 @@ export class IdentityProviderHttpHandler extends BaseHttpHandler {
|
||||
const args = { representation, preferences: operation.preferences, identifier: operation.target };
|
||||
const converted = await this.converter.handleSafe(args);
|
||||
|
||||
return new OkResponseDescription(converted.metadata, converted.data);
|
||||
return new ResponseDescription(statusCode, converted.metadata, converted.data);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -1,28 +1,4 @@
|
||||
import assert from 'assert';
|
||||
import { createErrorMessage } from '../../../util/errors/ErrorUtil';
|
||||
import { HttpError } from '../../../util/errors/HttpError';
|
||||
import { IdpInteractionError } from '../util/IdpInteractionError';
|
||||
|
||||
/**
|
||||
* Throws an IdpInteractionError with contents depending on the type of input error.
|
||||
* Default status code is 500 and default error message is 'Unknown Error'.
|
||||
* @param error - Error to create an IdPInteractionError from.
|
||||
* @param prefilled - Prefilled data for IdpInteractionError.
|
||||
*/
|
||||
export function throwIdpInteractionError(error: unknown, prefilled: Record<string, string> = {}): never {
|
||||
if (IdpInteractionError.isInstance(error)) {
|
||||
if (Object.keys(prefilled).length > 0) {
|
||||
const { statusCode, message } = error;
|
||||
throw new IdpInteractionError(statusCode, message, { ...error.prefilled, ...prefilled }, { cause: error });
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
} else if (HttpError.isInstance(error)) {
|
||||
throw new IdpInteractionError(error.statusCode, error.message, prefilled, { cause: error });
|
||||
} else {
|
||||
throw new IdpInteractionError(500, createErrorMessage(error), prefilled, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that `password` is a string that matches `confirmPassword`.
|
||||
|
@ -4,7 +4,6 @@ import { ensureTrailingSlash, joinUrl } from '../../../../util/PathUtil';
|
||||
import { readJsonStream } from '../../../../util/StreamUtil';
|
||||
import type { TemplateEngine } from '../../../../util/templates/TemplateEngine';
|
||||
import type { EmailSender } from '../../util/EmailSender';
|
||||
import { throwIdpInteractionError } from '../EmailPasswordUtil';
|
||||
import type { AccountStore } from '../storage/AccountStore';
|
||||
import { InteractionHandler } from './InteractionHandler';
|
||||
import type { InteractionResponseResult, InteractionHandlerInput } from './InteractionHandler';
|
||||
@ -39,16 +38,12 @@ export class ForgotPasswordHandler extends InteractionHandler {
|
||||
}
|
||||
|
||||
public async handle({ operation }: InteractionHandlerInput): Promise<InteractionResponseResult<{ email: string }>> {
|
||||
try {
|
||||
// Validate incoming data
|
||||
const { email } = await readJsonStream(operation.body!.data);
|
||||
assert(typeof email === 'string' && email.length > 0, 'Email required');
|
||||
|
||||
await this.resetPassword(email);
|
||||
return { type: 'response', details: { email }};
|
||||
} catch (err: unknown) {
|
||||
throwIdpInteractionError(err, {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -20,7 +20,7 @@ export interface InteractionHandlerInput {
|
||||
oidcInteraction?: Interaction;
|
||||
}
|
||||
|
||||
export type InteractionHandlerResult = InteractionResponseResult | InteractionCompleteResult;
|
||||
export type InteractionHandlerResult = InteractionResponseResult | InteractionCompleteResult | InteractionErrorResult;
|
||||
|
||||
export interface InteractionResponseResult<T = NodeJS.Dict<any>> {
|
||||
type: 'response';
|
||||
@ -32,6 +32,11 @@ export interface InteractionCompleteResult {
|
||||
details: InteractionCompleterParams;
|
||||
}
|
||||
|
||||
export interface InteractionErrorResult {
|
||||
type: 'error';
|
||||
error: Error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler used for IDP interactions.
|
||||
* Only supports JSON data.
|
||||
|
@ -2,7 +2,6 @@ import assert from 'assert';
|
||||
import type { Operation } from '../../../../ldp/operations/Operation';
|
||||
import { getLoggerFor } from '../../../../logging/LogUtil';
|
||||
import { readJsonStream } from '../../../../util/StreamUtil';
|
||||
import { throwIdpInteractionError } from '../EmailPasswordUtil';
|
||||
import type { AccountStore } from '../storage/AccountStore';
|
||||
import { InteractionHandler } from './InteractionHandler';
|
||||
import type { InteractionCompleteResult, InteractionHandlerInput } from './InteractionHandler';
|
||||
@ -22,7 +21,6 @@ export class LoginHandler extends InteractionHandler {
|
||||
|
||||
public async handle({ operation }: InteractionHandlerInput): Promise<InteractionCompleteResult> {
|
||||
const { email, password, remember } = await this.parseInput(operation);
|
||||
try {
|
||||
// Try to log in, will error if email/password combination is invalid
|
||||
const webId = await this.accountStore.authenticate(email, password);
|
||||
this.logger.debug(`Logging in user ${email}`);
|
||||
@ -30,26 +28,19 @@ export class LoginHandler extends InteractionHandler {
|
||||
type: 'complete',
|
||||
details: { webId, shouldRemember: remember },
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
throwIdpInteractionError(err, { email });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses and validates the input form data.
|
||||
* Will throw an {@link IdpInteractionError} in case something is wrong.
|
||||
* Will throw an error in case something is wrong.
|
||||
* All relevant data that was correct up to that point will be prefilled.
|
||||
*/
|
||||
private async parseInput(operation: Operation): Promise<{ email: string; password: string; remember: boolean }> {
|
||||
const prefilled: Record<string, string> = {};
|
||||
try {
|
||||
const { email, password, remember } = await readJsonStream(operation.body!.data);
|
||||
assert(typeof email === 'string' && email.length > 0, 'Email required');
|
||||
prefilled.email = email;
|
||||
assert(typeof password === 'string' && password.length > 0, 'Password required');
|
||||
return { email, password, remember: Boolean(remember) };
|
||||
} catch (err: unknown) {
|
||||
throwIdpInteractionError(err, prefilled);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -8,7 +8,7 @@ import type { PodSettings } from '../../../../pods/settings/PodSettings';
|
||||
import { joinUrl } from '../../../../util/PathUtil';
|
||||
import { readJsonStream } from '../../../../util/StreamUtil';
|
||||
import type { OwnershipValidator } from '../../../ownership/OwnershipValidator';
|
||||
import { assertPassword, throwIdpInteractionError } from '../EmailPasswordUtil';
|
||||
import { assertPassword } from '../EmailPasswordUtil';
|
||||
import type { AccountStore } from '../storage/AccountStore';
|
||||
import type { InteractionResponseResult, InteractionHandlerInput } from './InteractionHandler';
|
||||
import { InteractionHandler } from './InteractionHandler';
|
||||
@ -105,15 +105,8 @@ export class RegistrationHandler extends InteractionHandler {
|
||||
public async handle({ operation }: InteractionHandlerInput):
|
||||
Promise<InteractionResponseResult<RegistrationResponse>> {
|
||||
const result = await this.parseInput(operation);
|
||||
|
||||
try {
|
||||
const details = await this.register(result);
|
||||
return { type: 'response', details };
|
||||
} catch (error: unknown) {
|
||||
// Don't expose the password field
|
||||
delete result.password;
|
||||
throwIdpInteractionError(error, result as Record<string, any>);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -188,15 +181,11 @@ export class RegistrationHandler extends InteractionHandler {
|
||||
private async parseInput(operation: Operation): Promise<ParsedInput> {
|
||||
const parsed = await readJsonStream(operation.body!.data);
|
||||
const prefilled: Record<string, string> = {};
|
||||
try {
|
||||
for (const [ key, value ] of Object.entries(parsed)) {
|
||||
assert(!Array.isArray(value), `Unexpected multiple values for ${key}.`);
|
||||
prefilled[key] = typeof value === 'string' ? value.trim() : value;
|
||||
}
|
||||
return this.validateInput(prefilled);
|
||||
} catch (err: unknown) {
|
||||
throwIdpInteractionError(err, prefilled);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -1,7 +1,7 @@
|
||||
import assert from 'assert';
|
||||
import { getLoggerFor } from '../../../../logging/LogUtil';
|
||||
import { readJsonStream } from '../../../../util/StreamUtil';
|
||||
import { assertPassword, throwIdpInteractionError } from '../EmailPasswordUtil';
|
||||
import { assertPassword } from '../EmailPasswordUtil';
|
||||
import type { AccountStore } from '../storage/AccountStore';
|
||||
import type { InteractionResponseResult, InteractionHandlerInput } from './InteractionHandler';
|
||||
import { InteractionHandler } from './InteractionHandler';
|
||||
@ -21,7 +21,6 @@ export class ResetPasswordHandler extends InteractionHandler {
|
||||
}
|
||||
|
||||
public async handle({ operation }: InteractionHandlerInput): Promise<InteractionResponseResult> {
|
||||
try {
|
||||
// Extract record ID from request URL
|
||||
const recordId = /\/([^/]+)$/u.exec(operation.target.path)?.[1];
|
||||
// Validate input data
|
||||
@ -34,9 +33,6 @@ export class ResetPasswordHandler extends InteractionHandler {
|
||||
|
||||
await this.resetPassword(recordId, password);
|
||||
return { type: 'response' };
|
||||
} catch (error: unknown) {
|
||||
throwIdpInteractionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -1,13 +1,12 @@
|
||||
import type { Operation } from '../../../ldp/operations/Operation';
|
||||
import { BadRequestHttpError } from '../../../util/errors/BadRequestHttpError';
|
||||
import { createErrorMessage } from '../../../util/errors/ErrorUtil';
|
||||
import { createErrorMessage, isError } from '../../../util/errors/ErrorUtil';
|
||||
import { InternalServerError } from '../../../util/errors/InternalServerError';
|
||||
import { trimTrailingSlashes } from '../../../util/PathUtil';
|
||||
import type {
|
||||
InteractionResponseResult,
|
||||
InteractionHandler,
|
||||
Interaction,
|
||||
} from '../email-password/handler/InteractionHandler';
|
||||
import { IdpInteractionError } from '../util/IdpInteractionError';
|
||||
import type { InteractionRoute, TemplatedInteractionResult } from './InteractionRoute';
|
||||
|
||||
/**
|
||||
@ -84,14 +83,10 @@ export class BasicInteractionRoute implements InteractionRoute {
|
||||
try {
|
||||
const result = await this.handler.handleSafe({ operation, oidcInteraction });
|
||||
return { ...result, templateFiles: this.responseTemplates };
|
||||
} catch (error: unknown) {
|
||||
// Render error in the view
|
||||
const errorMessage = createErrorMessage(error);
|
||||
const result: InteractionResponseResult = { type: 'response', details: { errorMessage }};
|
||||
if (IdpInteractionError.isInstance(error)) {
|
||||
result.details!.prefilled = error.prefilled;
|
||||
}
|
||||
return { ...result, templateFiles: this.viewTemplates };
|
||||
} catch (err: unknown) {
|
||||
const error = isError(err) ? err : new InternalServerError(createErrorMessage(err));
|
||||
// Potentially render the error in the view
|
||||
return { type: 'error', error, templateFiles: this.viewTemplates };
|
||||
}
|
||||
default:
|
||||
throw new BadRequestHttpError(`Unsupported request: ${operation.method} ${operation.target.path}`);
|
||||
|
@ -1,7 +1,7 @@
|
||||
import type { Operation } from '../../../ldp/operations/Operation';
|
||||
import type { Interaction, InteractionHandlerResult } from '../email-password/handler/InteractionHandler';
|
||||
|
||||
export type TemplatedInteractionResult = InteractionHandlerResult & {
|
||||
export type TemplatedInteractionResult<T extends InteractionHandlerResult = InteractionHandlerResult> = T & {
|
||||
templateFiles: Record<string, string>;
|
||||
};
|
||||
|
||||
|
@ -1,19 +0,0 @@
|
||||
import type { HttpErrorOptions } from '../../../util/errors/HttpError';
|
||||
import { HttpError } from '../../../util/errors/HttpError';
|
||||
|
||||
/**
|
||||
* An error made for IDP Interactions. It allows a function to set the prefilled
|
||||
* information that would be included in a response UI render.
|
||||
*/
|
||||
export class IdpInteractionError extends HttpError {
|
||||
public readonly prefilled: Record<string, string>;
|
||||
|
||||
public constructor(status: number, message: string, prefilled: Record<string, string>, options?: HttpErrorOptions) {
|
||||
super(status, 'IdpInteractionError', message, options);
|
||||
this.prefilled = prefilled;
|
||||
}
|
||||
|
||||
public static isInstance(error: unknown): error is IdpInteractionError {
|
||||
return HttpError.isInstance(error) && typeof (error as any).prefilled === 'object';
|
||||
}
|
||||
}
|
@ -48,7 +48,6 @@ export * from './identity/interaction/routing/InteractionRoute';
|
||||
// Identity/Interaction/Util
|
||||
export * from './identity/interaction/util/BaseEmailSender';
|
||||
export * from './identity/interaction/util/EmailSender';
|
||||
export * from './identity/interaction/util/IdpInteractionError';
|
||||
export * from './identity/interaction/util/InteractionCompleter';
|
||||
|
||||
// Identity/Interaction
|
||||
@ -232,6 +231,7 @@ export * from './storage/conversion/ContainerToTemplateConverter';
|
||||
export * from './storage/conversion/ContentTypeReplacer';
|
||||
export * from './storage/conversion/ConversionUtil';
|
||||
export * from './storage/conversion/DynamicJsonToTemplateConverter';
|
||||
export * from './storage/conversion/ErrorToJsonConverter';
|
||||
export * from './storage/conversion/ErrorToQuadConverter';
|
||||
export * from './storage/conversion/ErrorToTemplateConverter';
|
||||
export * from './storage/conversion/FormToJsonConverter';
|
||||
|
48
src/storage/conversion/ErrorToJsonConverter.ts
Normal file
48
src/storage/conversion/ErrorToJsonConverter.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { BasicRepresentation } from '../../ldp/representation/BasicRepresentation';
|
||||
import type { Representation } from '../../ldp/representation/Representation';
|
||||
import { APPLICATION_JSON, INTERNAL_ERROR } from '../../util/ContentTypes';
|
||||
import { HttpError } from '../../util/errors/HttpError';
|
||||
import { getSingleItem } from '../../util/StreamUtil';
|
||||
import type { RepresentationConverterArgs } from './RepresentationConverter';
|
||||
import { TypedRepresentationConverter } from './TypedRepresentationConverter';
|
||||
|
||||
/**
|
||||
* Converts an Error object to JSON by copying its fields.
|
||||
*/
|
||||
export class ErrorToJsonConverter extends TypedRepresentationConverter {
|
||||
public constructor() {
|
||||
super(INTERNAL_ERROR, APPLICATION_JSON);
|
||||
}
|
||||
|
||||
public async handle({ representation }: RepresentationConverterArgs): Promise<Representation> {
|
||||
const error = await getSingleItem(representation.data) as Error;
|
||||
|
||||
const result: Record<string, any> = {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
};
|
||||
|
||||
if (HttpError.isInstance(error)) {
|
||||
result.statusCode = error.statusCode;
|
||||
result.errorCode = error.errorCode;
|
||||
if (error.details) {
|
||||
try {
|
||||
// The details might not be serializable
|
||||
JSON.stringify(error.details);
|
||||
result.details = error.details;
|
||||
} catch {
|
||||
// Do not store the details
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result.statusCode = 500;
|
||||
}
|
||||
|
||||
if (error.stack) {
|
||||
result.stack = error.stack;
|
||||
}
|
||||
|
||||
// Update the content-type to JSON
|
||||
return new BasicRepresentation(JSON.stringify(result), representation.metadata, APPLICATION_JSON);
|
||||
}
|
||||
}
|
@ -1,9 +1,8 @@
|
||||
import arrayifyStream from 'arrayify-stream';
|
||||
import { BasicRepresentation } from '../../ldp/representation/BasicRepresentation';
|
||||
import type { Representation } from '../../ldp/representation/Representation';
|
||||
import { RepresentationMetadata } from '../../ldp/representation/RepresentationMetadata';
|
||||
import { INTERNAL_ERROR, INTERNAL_QUADS } from '../../util/ContentTypes';
|
||||
import { InternalServerError } from '../../util/errors/InternalServerError';
|
||||
import { getSingleItem } from '../../util/StreamUtil';
|
||||
import { DC, SOLID_ERROR } from '../../util/Vocabularies';
|
||||
import type { RepresentationConverterArgs } from './RepresentationConverter';
|
||||
import { TypedRepresentationConverter } from './TypedRepresentationConverter';
|
||||
@ -17,11 +16,7 @@ export class ErrorToQuadConverter extends TypedRepresentationConverter {
|
||||
}
|
||||
|
||||
public async handle({ identifier, representation }: RepresentationConverterArgs): Promise<Representation> {
|
||||
const errors = await arrayifyStream(representation.data);
|
||||
if (errors.length !== 1) {
|
||||
throw new InternalServerError('Only single errors are supported.');
|
||||
}
|
||||
const error = errors[0] as Error;
|
||||
const error = await getSingleItem(representation.data) as Error;
|
||||
|
||||
// A metadata object makes it easier to add triples due to the utility functions
|
||||
const data = new RepresentationMetadata(identifier);
|
||||
|
@ -1,11 +1,10 @@
|
||||
import assert from 'assert';
|
||||
import arrayifyStream from 'arrayify-stream';
|
||||
import { BasicRepresentation } from '../../ldp/representation/BasicRepresentation';
|
||||
import type { Representation } from '../../ldp/representation/Representation';
|
||||
import { INTERNAL_ERROR } from '../../util/ContentTypes';
|
||||
import { HttpError } from '../../util/errors/HttpError';
|
||||
import { InternalServerError } from '../../util/errors/InternalServerError';
|
||||
import { modulePathPlaceholder } from '../../util/PathUtil';
|
||||
import { getSingleItem } from '../../util/StreamUtil';
|
||||
import type { TemplateEngine } from '../../util/templates/TemplateEngine';
|
||||
import type { RepresentationConverterArgs } from './RepresentationConverter';
|
||||
import { TypedRepresentationConverter } from './TypedRepresentationConverter';
|
||||
@ -57,12 +56,7 @@ export class ErrorToTemplateConverter extends TypedRepresentationConverter {
|
||||
}
|
||||
|
||||
public async handle({ representation }: RepresentationConverterArgs): Promise<Representation> {
|
||||
// Obtain the error from the representation stream
|
||||
const errors = await arrayifyStream(representation.data);
|
||||
if (errors.length !== 1) {
|
||||
throw new InternalServerError('Only single errors are supported.');
|
||||
}
|
||||
const error = errors[0] as Error;
|
||||
const error = await getSingleItem(representation.data) as Error;
|
||||
|
||||
// Render the error description using an error-specific template
|
||||
let description: string | undefined;
|
||||
|
@ -7,6 +7,7 @@ import { Store } from 'n3';
|
||||
import pump from 'pump';
|
||||
import { getLoggerFor } from '../logging/LogUtil';
|
||||
import { isHttpRequest } from '../server/HttpRequest';
|
||||
import { InternalServerError } from './errors/InternalServerError';
|
||||
import type { Guarded } from './GuardedStream';
|
||||
import { guardStream } from './GuardedStream';
|
||||
|
||||
@ -48,6 +49,20 @@ export async function readJsonStream(stream: Readable): Promise<NodeJS.Dict<any>
|
||||
return JSON.parse(body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the stream to a single object.
|
||||
* This assumes the stream is in object mode and only contains a single element,
|
||||
* otherwise an error will be thrown.
|
||||
* @param stream - Object stream with single entry.
|
||||
*/
|
||||
export async function getSingleItem(stream: Readable): Promise<unknown> {
|
||||
const items = await arrayifyStream(stream);
|
||||
if (items.length !== 1) {
|
||||
throw new InternalServerError('Expected a stream with a single object.');
|
||||
}
|
||||
return items[0];
|
||||
}
|
||||
|
||||
// These error messages usually indicate expected behaviour so should not give a warning.
|
||||
// We compare against the error message instead of the code
|
||||
// since the second one is from an external library that does not assign an error code.
|
||||
|
@ -1,8 +1,8 @@
|
||||
<h1>Authorize</h1>
|
||||
<p>You are authorizing an application to access your Pod.</p>
|
||||
<form method="post">
|
||||
<% if (locals.errorMessage) { %>
|
||||
<p class="error"><%= errorMessage %></p>
|
||||
<% if (locals.message) { %>
|
||||
<p class="error"><%= message %></p>
|
||||
<% } %>
|
||||
|
||||
<fieldset>
|
||||
|
@ -1,7 +1,7 @@
|
||||
<h1>Forgot password</h1>
|
||||
<form method="post">
|
||||
<% if (locals.errorMessage) { %>
|
||||
<p class="error"><%= errorMessage %></p>
|
||||
<% if (locals.message) { %>
|
||||
<p class="error"><%= message %></p>
|
||||
<% } %>
|
||||
|
||||
<fieldset>
|
||||
|
@ -1,9 +1,9 @@
|
||||
<h1>Log in</h1>
|
||||
<form method="post">
|
||||
<% const safePrefilled = locals.prefilled || {}; %>
|
||||
<% prefilled = locals.prefilled || {}; %>
|
||||
|
||||
<% if (locals.errorMessage) { %>
|
||||
<p class="error"><%= errorMessage %></p>
|
||||
<% if (locals.message) { %>
|
||||
<p class="error"><%= message %></p>
|
||||
<% } %>
|
||||
|
||||
<fieldset>
|
||||
@ -11,7 +11,7 @@
|
||||
<ol>
|
||||
<li>
|
||||
<label for="email">Email</label>
|
||||
<input id="email" type="email" name="email" autofocus value="<%= safePrefilled.email || '' %>">
|
||||
<input id="email" type="email" name="email" autofocus value="<%= prefilled.email || '' %>">
|
||||
</li>
|
||||
<li>
|
||||
<label for="password">Password</label>
|
||||
|
@ -1,10 +1,10 @@
|
||||
<h1>Sign up</h1>
|
||||
<form method="post" id="mainForm">
|
||||
<% const isBlankForm = !('prefilled' in locals); %>
|
||||
<% const safePrefilled = locals.prefilled || {}; %>
|
||||
<% prefilled = locals.prefilled || {}; %>
|
||||
|
||||
<% if (locals.errorMessage) { %>
|
||||
<p class="error">Error: <%= errorMessage %></p>
|
||||
<% if (locals.message) { %>
|
||||
<p class="error">Error: <%= message %></p>
|
||||
<% } %>
|
||||
|
||||
<fieldset>
|
||||
@ -20,7 +20,7 @@
|
||||
<li class="radio">
|
||||
<label>
|
||||
<input type="radio" id="createWebIdOn" name="createWebId" value="on"<%
|
||||
if (isBlankForm || safePrefilled.createWebId) { %> checked<% } %>>
|
||||
if (isBlankForm || prefilled.createWebId) { %> checked<% } %>>
|
||||
Create a new WebID for my Pod.
|
||||
</label>
|
||||
<p id="createWebIdForm">
|
||||
@ -36,7 +36,7 @@
|
||||
<ol id="existingWebIdForm">
|
||||
<li>
|
||||
<label for="webId">Existing WebID:</label>
|
||||
<input id="webId" type="text" name="webId" value="<%= safePrefilled.webId || '' %>">
|
||||
<input id="webId" type="text" name="webId" value="<%= prefilled.webId || '' %>">
|
||||
</li>
|
||||
<li class="checkbox">
|
||||
<label>
|
||||
@ -67,7 +67,7 @@
|
||||
<ol id="createPodForm">
|
||||
<li>
|
||||
<label for="podName">Pod name:</label>
|
||||
<input id="podName" type="text" name="podName" value="<%= safePrefilled.podName || '' %>">
|
||||
<input id="podName" type="text" name="podName" value="<%= prefilled.podName || '' %>">
|
||||
</li>
|
||||
</ol>
|
||||
</li>
|
||||
@ -83,7 +83,7 @@
|
||||
<ol>
|
||||
<li>
|
||||
<label for="email">Email:</label>
|
||||
<input id="email" type="text" name="email" value="<%= safePrefilled.email || '' %>" >
|
||||
<input id="email" type="text" name="email" value="<%= prefilled.email || '' %>" >
|
||||
</li>
|
||||
</ol>
|
||||
<ol id="passwordForm">
|
||||
|
@ -1,7 +1,7 @@
|
||||
<h1>Reset password</h1>
|
||||
<form method="post">
|
||||
<% if (locals.errorMessage) { %>
|
||||
<p class="error"><%= errorMessage %></p>
|
||||
<% if (locals.message) { %>
|
||||
<p class="error"><%= message %></p>
|
||||
<% } %>
|
||||
|
||||
<fieldset>
|
||||
|
@ -114,8 +114,7 @@ describe.each(configs)('A dynamic pod server with template config %s', (template
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
// 200 due to there only being a HTML solution right now that only returns 200
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.status).toBe(409);
|
||||
await expect(res.text()).resolves.toContain(`There already is a pod at ${podUrl}`);
|
||||
});
|
||||
});
|
||||
|
@ -96,7 +96,7 @@ describe('A Solid server with IDP', (): void => {
|
||||
|
||||
it('sends the form once to receive the registration triple.', async(): Promise<void> => {
|
||||
const res = await postForm(`${baseUrl}idp/register`, formBody);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.status).toBe(400);
|
||||
registrationTriple = extractRegistrationTriple(await res.text(), webId);
|
||||
});
|
||||
|
||||
@ -231,7 +231,7 @@ describe('A Solid server with IDP', (): void => {
|
||||
await state.parseLoginPage(url);
|
||||
const formData = stringify({ email, password });
|
||||
const res = await state.fetchIdp(url, 'POST', formData, APPLICATION_X_WWW_FORM_URLENCODED);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.status).toBe(500);
|
||||
expect(await res.text()).toContain('Incorrect password');
|
||||
});
|
||||
|
||||
@ -253,7 +253,7 @@ describe('A Solid server with IDP', (): void => {
|
||||
|
||||
it('sends the form once to receive the registration triple.', async(): Promise<void> => {
|
||||
const res = await postForm(`${baseUrl}idp/register`, formBody);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.status).toBe(400);
|
||||
registrationTriple = extractRegistrationTriple(await res.text(), webId);
|
||||
});
|
||||
|
||||
|
@ -147,8 +147,7 @@ describe.each(stores)('A subdomain server with %s', (name, { storeConfig, teardo
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
// 200 due to there only being a HTML solution right now that only returns 200
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.status).toBe(409);
|
||||
await expect(res.text()).resolves.toContain(`There already is a resource at ${podUrl}`);
|
||||
});
|
||||
});
|
||||
|
@ -4,8 +4,9 @@ import type { IdentityProviderHttpHandlerArgs } from '../../../src/identity/Iden
|
||||
import { IdentityProviderHttpHandler } from '../../../src/identity/IdentityProviderHttpHandler';
|
||||
import type { InteractionRoute } from '../../../src/identity/interaction/routing/InteractionRoute';
|
||||
import type { InteractionCompleter } from '../../../src/identity/interaction/util/InteractionCompleter';
|
||||
import type { ErrorHandler } from '../../../src/ldp/http/ErrorHandler';
|
||||
import type { ErrorHandler, ErrorHandlerArgs } from '../../../src/ldp/http/ErrorHandler';
|
||||
import type { RequestParser } from '../../../src/ldp/http/RequestParser';
|
||||
import type { ResponseDescription } from '../../../src/ldp/http/response/ResponseDescription';
|
||||
import type { ResponseWriter } from '../../../src/ldp/http/ResponseWriter';
|
||||
import type { Operation } from '../../../src/ldp/operations/Operation';
|
||||
import { BasicRepresentation } from '../../../src/ldp/representation/BasicRepresentation';
|
||||
@ -19,7 +20,7 @@ import type {
|
||||
RepresentationConverterArgs,
|
||||
} from '../../../src/storage/conversion/RepresentationConverter';
|
||||
import { joinUrl } from '../../../src/util/PathUtil';
|
||||
import { readableToString } from '../../../src/util/StreamUtil';
|
||||
import { guardedStreamFrom, readableToString } from '../../../src/util/StreamUtil';
|
||||
import { CONTENT_TYPE, SOLID_HTTP, SOLID_META } from '../../../src/util/Vocabularies';
|
||||
|
||||
describe('An IdentityProviderHttpHandler', (): void => {
|
||||
@ -30,7 +31,7 @@ describe('An IdentityProviderHttpHandler', (): void => {
|
||||
const response: HttpResponse = {} as any;
|
||||
let requestParser: jest.Mocked<RequestParser>;
|
||||
let providerFactory: jest.Mocked<ProviderFactory>;
|
||||
let routes: { response: jest.Mocked<InteractionRoute>; complete: jest.Mocked<InteractionRoute> };
|
||||
let routes: Record<'response' | 'complete' | 'error', jest.Mocked<InteractionRoute>>;
|
||||
let controls: Record<string, string>;
|
||||
let interactionCompleter: jest.Mocked<InteractionCompleter>;
|
||||
let converter: jest.Mocked<RepresentationConverter>;
|
||||
@ -48,7 +49,7 @@ describe('An IdentityProviderHttpHandler', (): void => {
|
||||
method: req.method!,
|
||||
body: req.method === 'GET' ?
|
||||
undefined :
|
||||
new BasicRepresentation('', req.headers['content-type'] ?? 'text/plain'),
|
||||
new BasicRepresentation('{}', req.headers['content-type'] ?? 'text/plain'),
|
||||
preferences: { type: { 'text/html': 1 }},
|
||||
})),
|
||||
} as any;
|
||||
@ -81,6 +82,15 @@ describe('An IdentityProviderHttpHandler', (): void => {
|
||||
templateFiles: {},
|
||||
}),
|
||||
},
|
||||
error: {
|
||||
getControls: jest.fn().mockReturnValue({}),
|
||||
supportsPath: jest.fn((path: string): boolean => /^\/routeError$/u.test(path)),
|
||||
handleOperation: jest.fn().mockResolvedValue({
|
||||
type: 'error',
|
||||
error: new Error('test error'),
|
||||
templateFiles: { 'text/html': '/response' },
|
||||
}),
|
||||
},
|
||||
};
|
||||
controls = { response: 'http://test.com/idp/routeResponse' };
|
||||
|
||||
@ -95,7 +105,10 @@ describe('An IdentityProviderHttpHandler', (): void => {
|
||||
|
||||
interactionCompleter = { handleSafe: jest.fn().mockResolvedValue('http://test.com/idp/auth') } as any;
|
||||
|
||||
errorHandler = { handleSafe: jest.fn() } as any;
|
||||
errorHandler = { handleSafe: jest.fn(({ error }: ErrorHandlerArgs): ResponseDescription => ({
|
||||
statusCode: 400,
|
||||
data: guardedStreamFrom(`{ "name": "${error.name}", "message": "${error.message}" }`),
|
||||
})) } as any;
|
||||
|
||||
responseWriter = { handleSafe: jest.fn() } as any;
|
||||
|
||||
@ -139,6 +152,53 @@ describe('An IdentityProviderHttpHandler', (): void => {
|
||||
expect(result.metadata?.get(SOLID_META.template)?.value).toBe('/response');
|
||||
});
|
||||
|
||||
it('creates Representations for InteractionErrorResults.', async(): Promise<void> => {
|
||||
requestParser.handleSafe.mockResolvedValueOnce({
|
||||
target: { path: joinUrl(baseUrl, '/idp/routeError') },
|
||||
method: 'POST',
|
||||
preferences: { type: { 'text/html': 1 }},
|
||||
});
|
||||
|
||||
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
|
||||
const operation: Operation = await requestParser.handleSafe.mock.results[0].value;
|
||||
expect(routes.error.handleOperation).toHaveBeenCalledTimes(1);
|
||||
expect(routes.error.handleOperation).toHaveBeenLastCalledWith(operation, undefined);
|
||||
|
||||
expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1);
|
||||
const { response: mockResponse, result } = responseWriter.handleSafe.mock.calls[0][0];
|
||||
expect(mockResponse).toBe(response);
|
||||
expect(JSON.parse(await readableToString(result.data!)))
|
||||
.toEqual({ apiVersion, name: 'Error', message: 'test error', authenticating: false, controls });
|
||||
expect(result.statusCode).toBe(400);
|
||||
expect(result.metadata?.contentType).toBe('text/html');
|
||||
expect(result.metadata?.get(SOLID_META.template)?.value).toBe('/response');
|
||||
});
|
||||
|
||||
it('adds a prefilled field in case error requests had a body.', async(): Promise<void> => {
|
||||
requestParser.handleSafe.mockResolvedValueOnce({
|
||||
target: { path: joinUrl(baseUrl, '/idp/routeError') },
|
||||
method: 'POST',
|
||||
body: new BasicRepresentation('{ "key": "val" }', 'application/json'),
|
||||
preferences: { type: { 'text/html': 1 }},
|
||||
});
|
||||
|
||||
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
|
||||
const operation: Operation = await requestParser.handleSafe.mock.results[0].value;
|
||||
expect(routes.error.handleOperation).toHaveBeenCalledTimes(1);
|
||||
expect(routes.error.handleOperation).toHaveBeenLastCalledWith(operation, undefined);
|
||||
expect(operation.body?.metadata.contentType).toBe('application/json');
|
||||
|
||||
expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1);
|
||||
const { response: mockResponse, result } = responseWriter.handleSafe.mock.calls[0][0];
|
||||
expect(mockResponse).toBe(response);
|
||||
expect(JSON.parse(await readableToString(result.data!))).toEqual(
|
||||
{ apiVersion, name: 'Error', message: 'test error', authenticating: false, controls, prefilled: { key: 'val' }},
|
||||
);
|
||||
expect(result.statusCode).toBe(400);
|
||||
expect(result.metadata?.contentType).toBe('text/html');
|
||||
expect(result.metadata?.get(SOLID_META.template)?.value).toBe('/response');
|
||||
});
|
||||
|
||||
it('indicates to the templates if the request is part of an auth flow.', async(): Promise<void> => {
|
||||
request.url = '/idp/routeResponse';
|
||||
request.method = 'POST';
|
||||
|
@ -1,56 +1,8 @@
|
||||
import {
|
||||
assertPassword,
|
||||
throwIdpInteractionError,
|
||||
} from '../../../../../src/identity/interaction/email-password/EmailPasswordUtil';
|
||||
import { IdpInteractionError } from '../../../../../src/identity/interaction/util/IdpInteractionError';
|
||||
import { NotFoundHttpError } from '../../../../../src/util/errors/NotFoundHttpError';
|
||||
|
||||
describe('EmailPasswordUtil', (): void => {
|
||||
describe('#throwIdpInteractionError', (): void => {
|
||||
const prefilled = { test: 'data' };
|
||||
|
||||
it('copies the values of other IdpInteractionErrors.', async(): Promise<void> => {
|
||||
const error = new IdpInteractionError(404, 'Not found!', { test2: 'data2' });
|
||||
expect((): never => throwIdpInteractionError(error, prefilled)).toThrow(expect.objectContaining({
|
||||
statusCode: error.statusCode,
|
||||
message: error.message,
|
||||
prefilled: { ...error.prefilled, ...prefilled },
|
||||
}));
|
||||
});
|
||||
|
||||
it('re-throws IdpInteractionErrors if there are no new prefilled values.', async(): Promise<void> => {
|
||||
const error = new IdpInteractionError(404, 'Not found!', { test2: 'data2' });
|
||||
expect((): never => throwIdpInteractionError(error)).toThrow(error);
|
||||
});
|
||||
|
||||
it('copies status code and message for HttpErrors.', async(): Promise<void> => {
|
||||
const error = new NotFoundHttpError('Not found!');
|
||||
expect((): never => throwIdpInteractionError(error, prefilled)).toThrow(expect.objectContaining({
|
||||
statusCode: error.statusCode,
|
||||
message: error.message,
|
||||
prefilled,
|
||||
}));
|
||||
});
|
||||
|
||||
it('copies message for native Errors.', async(): Promise<void> => {
|
||||
const error = new Error('Error!');
|
||||
expect((): never => throwIdpInteractionError(error, prefilled)).toThrow(expect.objectContaining({
|
||||
statusCode: 500,
|
||||
message: error.message,
|
||||
prefilled,
|
||||
}));
|
||||
});
|
||||
|
||||
it('defaults all values in case a non-native Error object gets thrown.', async(): Promise<void> => {
|
||||
const error = 'Error!';
|
||||
expect((): never => throwIdpInteractionError(error, prefilled)).toThrow(expect.objectContaining({
|
||||
statusCode: 500,
|
||||
message: 'Unknown error: Error!',
|
||||
prefilled,
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe('#assertPassword', (): void => {
|
||||
it('validates the password against the confirmPassword.', async(): Promise<void> => {
|
||||
expect((): void => assertPassword(undefined, undefined)).toThrow('Please enter a password.');
|
||||
|
@ -26,30 +26,25 @@ describe('A LoginHandler', (): void => {
|
||||
input.operation = createPostJsonOperation({});
|
||||
let prom = handler.handle(input);
|
||||
await expect(prom).rejects.toThrow('Email required');
|
||||
await expect(prom).rejects.toThrow(expect.objectContaining({ prefilled: {}}));
|
||||
input.operation = createPostJsonOperation({ email: [ 'a', 'b' ]});
|
||||
prom = handler.handle(input);
|
||||
await expect(prom).rejects.toThrow('Email required');
|
||||
await expect(prom).rejects.toThrow(expect.objectContaining({ prefilled: { }}));
|
||||
});
|
||||
|
||||
it('errors on invalid passwords.', async(): Promise<void> => {
|
||||
input.operation = createPostJsonOperation({ email });
|
||||
let prom = handler.handle(input);
|
||||
await expect(prom).rejects.toThrow('Password required');
|
||||
await expect(prom).rejects.toThrow(expect.objectContaining({ prefilled: { email }}));
|
||||
input.operation = createPostJsonOperation({ email, password: [ 'a', 'b' ]});
|
||||
prom = handler.handle(input);
|
||||
await expect(prom).rejects.toThrow('Password required');
|
||||
await expect(prom).rejects.toThrow(expect.objectContaining({ prefilled: { email }}));
|
||||
});
|
||||
|
||||
it('throws an IdpInteractionError if there is a problem.', async(): Promise<void> => {
|
||||
it('throws an error if there is a problem.', async(): Promise<void> => {
|
||||
input.operation = createPostJsonOperation({ email, password: 'password!' });
|
||||
(storageAdapter.authenticate as jest.Mock).mockRejectedValueOnce(new Error('auth failed!'));
|
||||
const prom = handler.handle(input);
|
||||
await expect(prom).rejects.toThrow('auth failed!');
|
||||
await expect(prom).rejects.toThrow(expect.objectContaining({ prefilled: { email }}));
|
||||
});
|
||||
|
||||
it('returns an InteractionCompleteResult when done.', async(): Promise<void> => {
|
||||
|
@ -2,7 +2,6 @@ import {
|
||||
RegistrationHandler,
|
||||
} from '../../../../../../src/identity/interaction/email-password/handler/RegistrationHandler';
|
||||
import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore';
|
||||
import { IdpInteractionError } from '../../../../../../src/identity/interaction/util/IdpInteractionError';
|
||||
import type { OwnershipValidator } from '../../../../../../src/identity/ownership/OwnershipValidator';
|
||||
import type { Operation } from '../../../../../../src/ldp/operations/Operation';
|
||||
import type { ResourceIdentifier } from '../../../../../../src/ldp/representation/ResourceIdentifier';
|
||||
@ -278,20 +277,12 @@ describe('A RegistrationHandler', (): void => {
|
||||
expect(accountStore.deleteAccount).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('throws an IdpInteractionError with all data prefilled if something goes wrong.', async(): Promise<void> => {
|
||||
it('throws an error if something goes wrong.', async(): Promise<void> => {
|
||||
const params = { email, webId, podName, createPod };
|
||||
operation = createPostJsonOperation(params);
|
||||
(podManager.createPod as jest.Mock).mockRejectedValueOnce(new Error('pod error'));
|
||||
const prom = handler.handle({ operation });
|
||||
await expect(prom).rejects.toThrow('pod error');
|
||||
await expect(prom).rejects.toThrow(IdpInteractionError);
|
||||
// Using the cleaned input for prefilled
|
||||
await expect(prom).rejects.toThrow(expect.objectContaining({ prefilled: {
|
||||
...params,
|
||||
createWebId: false,
|
||||
register: false,
|
||||
createPod: true,
|
||||
}}));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -54,11 +54,4 @@ describe('A ResetPasswordHandler', (): void => {
|
||||
expect(accountStore.changePassword).toHaveBeenCalledTimes(1);
|
||||
expect(accountStore.changePassword).toHaveBeenLastCalledWith(email, 'password!');
|
||||
});
|
||||
|
||||
it('has a default error for non-native errors.', async(): Promise<void> => {
|
||||
const errorMessage = 'Unknown error: not native';
|
||||
operation = createPostJsonOperation({ password: 'password!', confirmPassword: 'password!' }, url);
|
||||
(accountStore.getForgotPasswordRecord as jest.Mock).mockRejectedValueOnce('not native');
|
||||
await expect(handler.handle({ operation })).rejects.toThrow(errorMessage);
|
||||
});
|
||||
});
|
||||
|
@ -2,8 +2,8 @@ import type {
|
||||
InteractionHandler,
|
||||
} from '../../../../../src/identity/interaction/email-password/handler/InteractionHandler';
|
||||
import { BasicInteractionRoute } from '../../../../../src/identity/interaction/routing/BasicInteractionRoute';
|
||||
import { IdpInteractionError } from '../../../../../src/identity/interaction/util/IdpInteractionError';
|
||||
import { BadRequestHttpError } from '../../../../../src/util/errors/BadRequestHttpError';
|
||||
import { InternalServerError } from '../../../../../src/util/errors/InternalServerError';
|
||||
|
||||
describe('A BasicInteractionRoute', (): void => {
|
||||
const path = '^/route$';
|
||||
@ -50,19 +50,18 @@ describe('A BasicInteractionRoute', (): void => {
|
||||
expect(handler.handleSafe).toHaveBeenLastCalledWith({ operation: { method: 'POST' }});
|
||||
});
|
||||
|
||||
it('creates a response result in case the InteractionHandler errors.', async(): Promise<void> => {
|
||||
it('creates an error result in case the InteractionHandler errors.', async(): Promise<void> => {
|
||||
const error = new Error('bad data');
|
||||
handler.handleSafe.mockRejectedValueOnce(error);
|
||||
await expect(route.handleOperation({ method: 'POST' } as any))
|
||||
.resolves.toEqual({ type: 'response', details: { errorMessage: 'bad data' }, templateFiles: viewTemplates });
|
||||
.resolves.toEqual({ type: 'error', error, templateFiles: viewTemplates });
|
||||
});
|
||||
|
||||
it('adds prefilled data in case the error is an IdpInteractionError.', async(): Promise<void> => {
|
||||
const error = new IdpInteractionError(400, 'bad data', { name: 'Alice' });
|
||||
handler.handleSafe.mockRejectedValueOnce(error);
|
||||
it('creates an internal error in case of non-native errors.', async(): Promise<void> => {
|
||||
handler.handleSafe.mockRejectedValueOnce('notAnError');
|
||||
await expect(route.handleOperation({ method: 'POST' } as any)).resolves.toEqual({
|
||||
type: 'response',
|
||||
details: { errorMessage: 'bad data', prefilled: { name: 'Alice' }},
|
||||
type: 'error',
|
||||
error: new InternalServerError('Unknown error: notAnError'),
|
||||
templateFiles: viewTemplates,
|
||||
});
|
||||
});
|
||||
|
99
test/unit/storage/conversion/ErrorToJsonConverter.test.ts
Normal file
99
test/unit/storage/conversion/ErrorToJsonConverter.test.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import { BasicRepresentation } from '../../../../src/ldp/representation/BasicRepresentation';
|
||||
import { ErrorToJsonConverter } from '../../../../src/storage/conversion/ErrorToJsonConverter';
|
||||
import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError';
|
||||
import { readJsonStream } from '../../../../src/util/StreamUtil';
|
||||
|
||||
describe('An ErrorToJsonConverter', (): void => {
|
||||
const identifier = { path: 'http://test.com/error' };
|
||||
const converter = new ErrorToJsonConverter();
|
||||
const preferences = {};
|
||||
|
||||
it('supports going from errors to json.', async(): Promise<void> => {
|
||||
await expect(converter.getInputTypes()).resolves.toEqual({ 'internal/error': 1 });
|
||||
await expect(converter.getOutputTypes()).resolves.toEqual({ 'application/json': 1 });
|
||||
});
|
||||
|
||||
it('adds all HttpError fields.', async(): Promise<void> => {
|
||||
const error = new BadRequestHttpError('error text');
|
||||
const representation = new BasicRepresentation([ error ], 'internal/error', false);
|
||||
const prom = converter.handle({ identifier, representation, preferences });
|
||||
await expect(prom).resolves.toBeDefined();
|
||||
const result = await prom;
|
||||
expect(result.binary).toBe(true);
|
||||
expect(result.metadata.contentType).toBe('application/json');
|
||||
await expect(readJsonStream(result.data)).resolves.toEqual({
|
||||
name: 'BadRequestHttpError',
|
||||
message: 'error text',
|
||||
statusCode: 400,
|
||||
errorCode: 'H400',
|
||||
stack: error.stack,
|
||||
});
|
||||
});
|
||||
|
||||
it('copies the HttpError details.', async(): Promise<void> => {
|
||||
const error = new BadRequestHttpError('error text', { details: { important: 'detail' }});
|
||||
const representation = new BasicRepresentation([ error ], 'internal/error', false);
|
||||
const prom = converter.handle({ identifier, representation, preferences });
|
||||
await expect(prom).resolves.toBeDefined();
|
||||
const result = await prom;
|
||||
expect(result.binary).toBe(true);
|
||||
expect(result.metadata.contentType).toBe('application/json');
|
||||
await expect(readJsonStream(result.data)).resolves.toEqual({
|
||||
name: 'BadRequestHttpError',
|
||||
message: 'error text',
|
||||
statusCode: 400,
|
||||
errorCode: 'H400',
|
||||
details: { important: 'detail' },
|
||||
stack: error.stack,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not copy the details if they are not serializable.', async(): Promise<void> => {
|
||||
const error = new BadRequestHttpError('error text', { details: { object: BigInt(1) }});
|
||||
const representation = new BasicRepresentation([ error ], 'internal/error', false);
|
||||
const prom = converter.handle({ identifier, representation, preferences });
|
||||
await expect(prom).resolves.toBeDefined();
|
||||
const result = await prom;
|
||||
expect(result.binary).toBe(true);
|
||||
expect(result.metadata.contentType).toBe('application/json');
|
||||
await expect(readJsonStream(result.data)).resolves.toEqual({
|
||||
name: 'BadRequestHttpError',
|
||||
message: 'error text',
|
||||
statusCode: 400,
|
||||
errorCode: 'H400',
|
||||
stack: error.stack,
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults to status code 500 for non-HTTP errors.', async(): Promise<void> => {
|
||||
const error = new Error('error text');
|
||||
const representation = new BasicRepresentation([ error ], 'internal/error', false);
|
||||
const prom = converter.handle({ identifier, representation, preferences });
|
||||
await expect(prom).resolves.toBeDefined();
|
||||
const result = await prom;
|
||||
expect(result.binary).toBe(true);
|
||||
expect(result.metadata.contentType).toBe('application/json');
|
||||
await expect(readJsonStream(result.data)).resolves.toEqual({
|
||||
name: 'Error',
|
||||
message: 'error text',
|
||||
statusCode: 500,
|
||||
stack: error.stack,
|
||||
});
|
||||
});
|
||||
|
||||
it('only adds stack if it is defined.', async(): Promise<void> => {
|
||||
const error = new Error('error text');
|
||||
delete error.stack;
|
||||
const representation = new BasicRepresentation([ error ], 'internal/error', false);
|
||||
const prom = converter.handle({ identifier, representation, preferences });
|
||||
await expect(prom).resolves.toBeDefined();
|
||||
const result = await prom;
|
||||
expect(result.binary).toBe(true);
|
||||
expect(result.metadata.contentType).toBe('application/json');
|
||||
await expect(readJsonStream(result.data)).resolves.toEqual({
|
||||
name: 'Error',
|
||||
message: 'error text',
|
||||
statusCode: 500,
|
||||
});
|
||||
});
|
||||
});
|
@ -4,7 +4,6 @@ import { DataFactory } from 'n3';
|
||||
import { BasicRepresentation } from '../../../../src/ldp/representation/BasicRepresentation';
|
||||
import { ErrorToQuadConverter } from '../../../../src/storage/conversion/ErrorToQuadConverter';
|
||||
import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError';
|
||||
import { InternalServerError } from '../../../../src/util/errors/InternalServerError';
|
||||
import { DC, SOLID_ERROR } from '../../../../src/util/Vocabularies';
|
||||
const { literal, namedNode, quad } = DataFactory;
|
||||
|
||||
@ -18,13 +17,6 @@ describe('An ErrorToQuadConverter', (): void => {
|
||||
await expect(converter.getOutputTypes()).resolves.toEqual({ 'internal/quads': 1 });
|
||||
});
|
||||
|
||||
it('does not support multiple errors.', async(): Promise<void> => {
|
||||
const representation = new BasicRepresentation([ new Error('a'), new Error('b') ], 'internal/error', false);
|
||||
const prom = converter.handle({ identifier, representation, preferences });
|
||||
await expect(prom).rejects.toThrow('Only single errors are supported.');
|
||||
await expect(prom).rejects.toThrow(InternalServerError);
|
||||
});
|
||||
|
||||
it('adds triples for all error fields.', async(): Promise<void> => {
|
||||
const error = new BadRequestHttpError('error text');
|
||||
const representation = new BasicRepresentation([ error ], 'internal/error', false);
|
||||
|
@ -1,7 +1,6 @@
|
||||
import { BasicRepresentation } from '../../../../src/ldp/representation/BasicRepresentation';
|
||||
import { ErrorToTemplateConverter } from '../../../../src/storage/conversion/ErrorToTemplateConverter';
|
||||
import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError';
|
||||
import { InternalServerError } from '../../../../src/util/errors/InternalServerError';
|
||||
import { readableToString } from '../../../../src/util/StreamUtil';
|
||||
import type { TemplateEngine } from '../../../../src/util/templates/TemplateEngine';
|
||||
|
||||
@ -29,13 +28,6 @@ describe('An ErrorToTemplateConverter', (): void => {
|
||||
await expect(converter.getOutputTypes()).resolves.toEqual({ 'text/html': 1 });
|
||||
});
|
||||
|
||||
it('does not support multiple errors.', async(): Promise<void> => {
|
||||
const representation = new BasicRepresentation([ new Error('a'), new Error('b') ], 'internal/error', false);
|
||||
const prom = converter.handle({ identifier, representation, preferences });
|
||||
await expect(prom).rejects.toThrow('Only single errors are supported.');
|
||||
await expect(prom).rejects.toThrow(InternalServerError);
|
||||
});
|
||||
|
||||
it('works with non-HTTP errors.', async(): Promise<void> => {
|
||||
const error = new Error('error text');
|
||||
const representation = new BasicRepresentation([ error ], 'internal/error', false);
|
||||
|
@ -6,7 +6,7 @@ import { getLoggerFor } from '../../../src/logging/LogUtil';
|
||||
import { isHttpRequest } from '../../../src/server/HttpRequest';
|
||||
import {
|
||||
guardedStreamFrom, pipeSafely, transformSafely,
|
||||
readableToString, readableToQuads, readJsonStream,
|
||||
readableToString, readableToQuads, readJsonStream, getSingleItem,
|
||||
} from '../../../src/util/StreamUtil';
|
||||
|
||||
jest.mock('../../../src/logging/LogUtil', (): any => {
|
||||
@ -56,6 +56,18 @@ describe('StreamUtil', (): void => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getSingleItem', (): void => {
|
||||
it('extracts a single item from the stream.', async(): Promise<void> => {
|
||||
const stream = Readable.from([ 5 ]);
|
||||
await expect(getSingleItem(stream)).resolves.toBe(5);
|
||||
});
|
||||
|
||||
it('errors if there are multiple items.', async(): Promise<void> => {
|
||||
const stream = Readable.from([ 5, 5 ]);
|
||||
await expect(getSingleItem(stream)).rejects.toThrow('Expected a stream with a single object.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#pipeSafely', (): void => {
|
||||
beforeEach(async(): Promise<void> => {
|
||||
jest.clearAllMocks();
|
||||
|
Loading…
x
Reference in New Issue
Block a user