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:
Joachim Van Herwegen 2021-08-27 11:23:27 +02:00
parent bbfbfbbce4
commit cc1c3d9223
35 changed files with 368 additions and 273 deletions

View File

@ -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"

View File

@ -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" },

View File

@ -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);
}
/**

View File

@ -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`.

View File

@ -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');
// 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, {});
}
await this.resetPassword(email);
return { type: 'response', details: { email }};
}
/**

View File

@ -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.

View File

@ -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,34 +21,26 @@ 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}`);
return {
type: 'complete',
details: { webId, shouldRemember: remember },
};
} catch (err: unknown) {
throwIdpInteractionError(err, { email });
}
// 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}`);
return {
type: 'complete',
details: { webId, shouldRemember: remember },
};
}
/**
* 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);
}
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) };
}
}

View File

@ -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>);
}
const details = await this.register(result);
return { type: 'response', details };
}
/**
@ -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);
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);
}
/**

View File

@ -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,22 +21,18 @@ 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
const { password, confirmPassword } = await readJsonStream(operation.body!.data);
assert(
typeof recordId === 'string' && recordId.length > 0,
'Invalid request. Open the link from your email again',
);
assertPassword(password, confirmPassword);
// Extract record ID from request URL
const recordId = /\/([^/]+)$/u.exec(operation.target.path)?.[1];
// Validate input data
const { password, confirmPassword } = await readJsonStream(operation.body!.data);
assert(
typeof recordId === 'string' && recordId.length > 0,
'Invalid request. Open the link from your email again',
);
assertPassword(password, confirmPassword);
await this.resetPassword(recordId, password);
return { type: 'response' };
} catch (error: unknown) {
throwIdpInteractionError(error);
}
await this.resetPassword(recordId, password);
return { type: 'response' };
}
/**

View File

@ -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}`);

View File

@ -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>;
};

View File

@ -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';
}
}

View File

@ -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';

View 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);
}
}

View File

@ -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);

View File

@ -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;

View File

@ -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.

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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">

View File

@ -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>

View File

@ -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}`);
});
});

View File

@ -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);
});

View File

@ -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}`);
});
});

View File

@ -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';

View File

@ -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.');

View File

@ -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> => {

View File

@ -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,
}}));
});
});
});

View File

@ -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);
});
});

View File

@ -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,
});
});

View 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,
});
});
});

View File

@ -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);

View File

@ -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);

View File

@ -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();