diff --git a/config/util/representation-conversion/converters/form-to-json.json b/config/util/representation-conversion/converters/form-to-json.json new file mode 100644 index 000000000..173d27543 --- /dev/null +++ b/config/util/representation-conversion/converters/form-to-json.json @@ -0,0 +1,10 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Converts application/x-www-form-urlencoded to application/json.", + "@id": "urn:solid-server:default:FormToJsonConverter", + "@type": "FormToJsonConverter" + } + ] +} diff --git a/config/util/representation-conversion/default.json b/config/util/representation-conversion/default.json index a1038096c..7eec43e87 100644 --- a/config/util/representation-conversion/default.json +++ b/config/util/representation-conversion/default.json @@ -4,6 +4,7 @@ "files-scs:config/util/representation-conversion/converters/content-type-replacer.json", "files-scs:config/util/representation-conversion/converters/dynamic-json-template.json", "files-scs:config/util/representation-conversion/converters/errors.json", + "files-scs:config/util/representation-conversion/converters/form-to-json.json", "files-scs:config/util/representation-conversion/converters/markdown.json", "files-scs:config/util/representation-conversion/converters/quad-to-rdf.json", "files-scs:config/util/representation-conversion/converters/rdf-to-quad.json" @@ -32,7 +33,8 @@ { "@id": "urn:solid-server:default:ContainerToTemplateConverter" }, { "@id": "urn:solid-server:default:ErrorToQuadConverter" }, { "@id": "urn:solid-server:default:ErrorToTemplateConverter" }, - { "@id": "urn:solid-server:default:MarkdownToHtmlConverter" } + { "@id": "urn:solid-server:default:MarkdownToHtmlConverter" }, + { "@id": "urn:solid-server:default:FormToJsonConverter" } ] } ] diff --git a/src/identity/IdentityProviderHttpHandler.ts b/src/identity/IdentityProviderHttpHandler.ts index fe38a2c13..f58566360 100644 --- a/src/identity/IdentityProviderHttpHandler.ts +++ b/src/identity/IdentityProviderHttpHandler.ts @@ -158,6 +158,16 @@ export class IdentityProviderHttpHandler extends HttpHandler { return provider.callback(request, response); } + // IDP handlers expect JSON data + if (operation.body) { + const args = { + representation: operation.body, + preferences: { type: { [APPLICATION_JSON]: 1 }}, + identifier: operation.target, + }; + operation.body = await this.converter.handleSafe(args); + } + const { result, templateFiles } = await this.resolveRoute(operation, route, oidcInteraction); const responseDescription = await this.handleInteractionResult(operation, request, result, templateFiles, oidcInteraction); diff --git a/src/identity/interaction/SessionHttpHandler.ts b/src/identity/interaction/SessionHttpHandler.ts index 366bddaa6..4727476c6 100644 --- a/src/identity/interaction/SessionHttpHandler.ts +++ b/src/identity/interaction/SessionHttpHandler.ts @@ -1,7 +1,7 @@ import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; +import { readJsonStream } from '../../util/StreamUtil'; import { InteractionHandler } from './email-password/handler/InteractionHandler'; import type { InteractionCompleteResult, InteractionHandlerInput } from './email-password/handler/InteractionHandler'; -import { getFormDataRequestBody } from './util/FormDataUtil'; /** * Simple InteractionHttpHandler that sends the session accountId to the InteractionCompleter as webId. @@ -12,7 +12,7 @@ export class SessionHttpHandler extends InteractionHandler { throw new NotImplementedHttpError('Only interactions with a valid session are supported.'); } - const { remember } = await getFormDataRequestBody(operation); + const { remember } = await readJsonStream(operation.body!.data); return { type: 'complete', details: { webId: oidcInteraction.session.accountId, shouldRemember: Boolean(remember) }, diff --git a/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts b/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts index 3f6e5d82e..e8e6e7168 100644 --- a/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts +++ b/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts @@ -1,9 +1,9 @@ import assert from 'assert'; import { getLoggerFor } from '../../../../logging/LogUtil'; 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 { getFormDataRequestBody } from '../../util/FormDataUtil'; import { throwIdpInteractionError } from '../EmailPasswordUtil'; import type { AccountStore } from '../storage/AccountStore'; import { InteractionHandler } from './InteractionHandler'; @@ -41,7 +41,7 @@ export class ForgotPasswordHandler extends InteractionHandler { public async handle({ operation }: InteractionHandlerInput): Promise> { try { // Validate incoming data - const { email } = await getFormDataRequestBody(operation); + const { email } = await readJsonStream(operation.body!.data); assert(typeof email === 'string' && email.length > 0, 'Email required'); await this.resetPassword(email); diff --git a/src/identity/interaction/email-password/handler/InteractionHandler.ts b/src/identity/interaction/email-password/handler/InteractionHandler.ts index 7615a5f34..528fd3c80 100644 --- a/src/identity/interaction/email-password/handler/InteractionHandler.ts +++ b/src/identity/interaction/email-password/handler/InteractionHandler.ts @@ -1,5 +1,7 @@ import type { KoaContextWithOIDC } from 'oidc-provider'; import type { Operation } from '../../../../ldp/operations/Operation'; +import { APPLICATION_JSON } from '../../../../util/ContentTypes'; +import { NotImplementedHttpError } from '../../../../util/errors/NotImplementedHttpError'; import { AsyncHandler } from '../../../../util/handlers/AsyncHandler'; import type { InteractionCompleterParams } from '../../util/InteractionCompleter'; @@ -32,5 +34,12 @@ export interface InteractionCompleteResult { /** * Handler used for IDP interactions. + * Only supports JSON data. */ -export abstract class InteractionHandler extends AsyncHandler {} +export abstract class InteractionHandler extends AsyncHandler { + public async canHandle({ operation }: InteractionHandlerInput): Promise { + if (operation.body?.metadata.contentType !== APPLICATION_JSON) { + throw new NotImplementedHttpError('Only application/json data is supported.'); + } + } +} diff --git a/src/identity/interaction/email-password/handler/LoginHandler.ts b/src/identity/interaction/email-password/handler/LoginHandler.ts index 69d95be0e..f852260ac 100644 --- a/src/identity/interaction/email-password/handler/LoginHandler.ts +++ b/src/identity/interaction/email-password/handler/LoginHandler.ts @@ -1,7 +1,7 @@ import assert from 'assert'; import type { Operation } from '../../../../ldp/operations/Operation'; import { getLoggerFor } from '../../../../logging/LogUtil'; -import { getFormDataRequestBody } from '../../util/FormDataUtil'; +import { readJsonStream } from '../../../../util/StreamUtil'; import { throwIdpInteractionError } from '../EmailPasswordUtil'; import type { AccountStore } from '../storage/AccountStore'; import { InteractionHandler } from './InteractionHandler'; @@ -28,7 +28,7 @@ export class LoginHandler extends InteractionHandler { this.logger.debug(`Logging in user ${email}`); return { type: 'complete', - details: { webId, shouldRemember: Boolean(remember) }, + details: { webId, shouldRemember: remember }, }; } catch (err: unknown) { throwIdpInteractionError(err, { email }); @@ -43,7 +43,7 @@ export class LoginHandler extends InteractionHandler { private async parseInput(operation: Operation): Promise<{ email: string; password: string; remember: boolean }> { const prefilled: Record = {}; try { - const { email, password, remember } = await getFormDataRequestBody(operation); + 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'); diff --git a/src/identity/interaction/email-password/handler/RegistrationHandler.ts b/src/identity/interaction/email-password/handler/RegistrationHandler.ts index ed6eeee17..c0f1c0b54 100644 --- a/src/identity/interaction/email-password/handler/RegistrationHandler.ts +++ b/src/identity/interaction/email-password/handler/RegistrationHandler.ts @@ -6,8 +6,8 @@ import type { IdentifierGenerator } from '../../../../pods/generate/IdentifierGe import type { PodManager } from '../../../../pods/PodManager'; import type { PodSettings } from '../../../../pods/settings/PodSettings'; import { joinUrl } from '../../../../util/PathUtil'; +import { readJsonStream } from '../../../../util/StreamUtil'; import type { OwnershipValidator } from '../../../ownership/OwnershipValidator'; -import { getFormDataRequestBody } from '../../util/FormDataUtil'; import { assertPassword, throwIdpInteractionError } from '../EmailPasswordUtil'; import type { AccountStore } from '../storage/AccountStore'; import type { InteractionResponseResult, InteractionHandlerInput } from './InteractionHandler'; @@ -186,12 +186,12 @@ export class RegistrationHandler extends InteractionHandler { * Parses the input request into a `ParseResult`. */ private async parseInput(operation: Operation): Promise { - const parsed = await getFormDataRequestBody(operation); + const parsed = await readJsonStream(operation.body!.data); const prefilled: Record = {}; try { for (const [ key, value ] of Object.entries(parsed)) { assert(!Array.isArray(value), `Unexpected multiple values for ${key}.`); - prefilled[key] = value ? value.trim() : ''; + prefilled[key] = typeof value === 'string' ? value.trim() : value; } return this.validateInput(prefilled); } catch (err: unknown) { diff --git a/src/identity/interaction/email-password/handler/ResetPasswordHandler.ts b/src/identity/interaction/email-password/handler/ResetPasswordHandler.ts index 340b414e3..427a30308 100644 --- a/src/identity/interaction/email-password/handler/ResetPasswordHandler.ts +++ b/src/identity/interaction/email-password/handler/ResetPasswordHandler.ts @@ -1,6 +1,6 @@ import assert from 'assert'; import { getLoggerFor } from '../../../../logging/LogUtil'; -import { getFormDataRequestBody } from '../../util/FormDataUtil'; +import { readJsonStream } from '../../../../util/StreamUtil'; import { assertPassword, throwIdpInteractionError } from '../EmailPasswordUtil'; import type { AccountStore } from '../storage/AccountStore'; import type { InteractionResponseResult, InteractionHandlerInput } from './InteractionHandler'; @@ -25,7 +25,7 @@ export class ResetPasswordHandler extends InteractionHandler { // Extract record ID from request URL const recordId = /\/([^/]+)$/u.exec(operation.target.path)?.[1]; // Validate input data - const { password, confirmPassword } = await getFormDataRequestBody(operation); + const { password, confirmPassword } = await readJsonStream(operation.body!.data); assert( typeof recordId === 'string' && recordId.length > 0, 'Invalid request. Open the link from your email again', diff --git a/src/identity/interaction/util/FormDataUtil.ts b/src/identity/interaction/util/FormDataUtil.ts deleted file mode 100644 index 75869e1da..000000000 --- a/src/identity/interaction/util/FormDataUtil.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { ParsedUrlQuery } from 'querystring'; -import { parse } from 'querystring'; -import type { Operation } from '../../../ldp/operations/Operation'; -import { APPLICATION_X_WWW_FORM_URLENCODED } from '../../../util/ContentTypes'; -import { UnsupportedMediaTypeHttpError } from '../../../util/errors/UnsupportedMediaTypeHttpError'; -import { readableToString } from '../../../util/StreamUtil'; - -/** - * Takes in an operation and parses its body as 'application/x-www-form-urlencoded' - */ -export async function getFormDataRequestBody(operation: Operation): Promise { - if (operation.body?.metadata.contentType !== APPLICATION_X_WWW_FORM_URLENCODED) { - throw new UnsupportedMediaTypeHttpError(); - } - const body = await readableToString(operation.body.data); - return parse(body); -} diff --git a/src/index.ts b/src/index.ts index 9d9f0b39a..900599983 100644 --- a/src/index.ts +++ b/src/index.ts @@ -44,7 +44,6 @@ export * from './identity/interaction/email-password/EmailPasswordUtil'; // Identity/Interaction/Util export * from './identity/interaction/util/BaseEmailSender'; export * from './identity/interaction/util/EmailSender'; -export * from './identity/interaction/util/FormDataUtil'; export * from './identity/interaction/util/IdpInteractionError'; export * from './identity/interaction/util/InteractionCompleter'; @@ -230,6 +229,7 @@ export * from './storage/conversion/ConversionUtil'; export * from './storage/conversion/DynamicJsonToTemplateConverter'; export * from './storage/conversion/ErrorToQuadConverter'; export * from './storage/conversion/ErrorToTemplateConverter'; +export * from './storage/conversion/FormToJsonConverter'; export * from './storage/conversion/IfNeededConverter'; export * from './storage/conversion/MarkdownToHtmlConverter'; export * from './storage/conversion/PassthroughConverter'; diff --git a/src/storage/conversion/FormToJsonConverter.ts b/src/storage/conversion/FormToJsonConverter.ts new file mode 100644 index 000000000..87d9e7410 --- /dev/null +++ b/src/storage/conversion/FormToJsonConverter.ts @@ -0,0 +1,26 @@ +import { parse } from 'querystring'; +import { BasicRepresentation } from '../../ldp/representation/BasicRepresentation'; +import type { Representation } from '../../ldp/representation/Representation'; +import { RepresentationMetadata } from '../../ldp/representation/RepresentationMetadata'; +import { APPLICATION_JSON, APPLICATION_X_WWW_FORM_URLENCODED } from '../../util/ContentTypes'; +import { readableToString } from '../../util/StreamUtil'; +import { CONTENT_TYPE } from '../../util/Vocabularies'; +import type { RepresentationConverterArgs } from './RepresentationConverter'; +import { TypedRepresentationConverter } from './TypedRepresentationConverter'; + +/** + * Converts application/x-www-form-urlencoded data to application/json. + * Due to the nature of form data, the result will be a simple key/value JSON object. + */ +export class FormToJsonConverter extends TypedRepresentationConverter { + public constructor() { + super(APPLICATION_X_WWW_FORM_URLENCODED, APPLICATION_JSON); + } + + public async handle({ representation }: RepresentationConverterArgs): Promise { + const body = await readableToString(representation.data); + const json = JSON.stringify(parse(body)); + const metadata = new RepresentationMetadata(representation.metadata, { [CONTENT_TYPE]: APPLICATION_JSON }); + return new BasicRepresentation(json, metadata); + } +} diff --git a/src/util/StreamUtil.ts b/src/util/StreamUtil.ts index ffc3a9617..4f3d083d1 100644 --- a/src/util/StreamUtil.ts +++ b/src/util/StreamUtil.ts @@ -37,6 +37,17 @@ export async function readableToQuads(stream: Readable): Promise { return quads; } +/** + * Interprets the stream as JSON and converts it to a Dict. + * @param stream - Stream of JSON data. + * + * @returns The parsed object. + */ +export async function readJsonStream(stream: Readable): Promise> { + const body = await readableToString(stream); + return JSON.parse(body); +} + // 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. diff --git a/test/integration/DynamicPods.test.ts b/test/integration/DynamicPods.test.ts index f6dbc6b84..eb6447a38 100644 --- a/test/integration/DynamicPods.test.ts +++ b/test/integration/DynamicPods.test.ts @@ -1,5 +1,4 @@ import { mkdirSync } from 'fs'; -import { stringify } from 'querystring'; import fetch from 'cross-fetch'; import type { App } from '../../src/init/App'; import { joinFilePath } from '../../src/util/PathUtil'; @@ -56,8 +55,8 @@ describe.each(configs)('A dynamic pod server with template config %s', (template it('creates a pod with the given config.', async(): Promise => { const res = await fetch(`${baseUrl}idp/register`, { method: 'POST', - headers: { 'content-type': 'application/x-www-form-urlencoded' }, - body: stringify(settings), + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(settings), }); expect(res.status).toBe(200); await expect(res.text()).resolves.toContain(podUrl); @@ -112,8 +111,8 @@ describe.each(configs)('A dynamic pod server with template config %s', (template it('should not be able to create a pod with the same name.', async(): Promise => { const res = await fetch(`${baseUrl}idp/register`, { method: 'POST', - headers: { 'content-type': 'application/x-www-form-urlencoded' }, - body: stringify(settings), + 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); diff --git a/test/integration/Subdomains.test.ts b/test/integration/Subdomains.test.ts index 088ade3ad..98598b489 100644 --- a/test/integration/Subdomains.test.ts +++ b/test/integration/Subdomains.test.ts @@ -1,4 +1,3 @@ -import { stringify } from 'querystring'; import fetch from 'cross-fetch'; import type { App } from '../../src/init/App'; import { getPort } from '../util/Util'; @@ -85,8 +84,8 @@ describe.each(stores)('A subdomain server with %s', (name, { storeConfig, teardo it('creates pods in a subdomain.', async(): Promise => { const res = await fetch(`${baseUrl}idp/register`, { method: 'POST', - headers: { 'content-type': 'application/x-www-form-urlencoded' }, - body: stringify(settings), + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(settings), }); expect(res.status).toBe(200); await expect(res.text()).resolves.toContain(podUrl); @@ -145,8 +144,8 @@ describe.each(stores)('A subdomain server with %s', (name, { storeConfig, teardo it('should not be able to create a pod with the same name.', async(): Promise => { const res = await fetch(`${baseUrl}idp/register`, { method: 'POST', - headers: { 'content-type': 'application/x-www-form-urlencoded' }, - body: stringify(settings), + 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); diff --git a/test/unit/identity/IdentityProviderHttpHandler.test.ts b/test/unit/identity/IdentityProviderHttpHandler.test.ts index f4b1e3595..761021167 100644 --- a/test/unit/identity/IdentityProviderHttpHandler.test.ts +++ b/test/unit/identity/IdentityProviderHttpHandler.test.ts @@ -10,8 +10,10 @@ import type { ResponseWriter } from '../../../src/ldp/http/ResponseWriter'; import type { Operation } from '../../../src/ldp/operations/Operation'; import { BasicRepresentation } from '../../../src/ldp/representation/BasicRepresentation'; import type { Representation } from '../../../src/ldp/representation/Representation'; +import { RepresentationMetadata } from '../../../src/ldp/representation/RepresentationMetadata'; import type { HttpRequest } from '../../../src/server/HttpRequest'; import type { HttpResponse } from '../../../src/server/HttpResponse'; +import { getBestPreference } from '../../../src/storage/conversion/ConversionUtil'; import type { RepresentationConverter, RepresentationConverterArgs, @@ -20,7 +22,7 @@ import { BadRequestHttpError } from '../../../src/util/errors/BadRequestHttpErro import { InternalServerError } from '../../../src/util/errors/InternalServerError'; import { joinUrl } from '../../../src/util/PathUtil'; import { readableToString } from '../../../src/util/StreamUtil'; -import { SOLID_HTTP, SOLID_META } from '../../../src/util/Vocabularies'; +import { CONTENT_TYPE, SOLID_HTTP, SOLID_META } from '../../../src/util/Vocabularies'; describe('An IdentityProviderHttpHandler', (): void => { const apiVersion = '0.1'; @@ -45,7 +47,9 @@ describe('An IdentityProviderHttpHandler', (): void => { handleSafe: jest.fn(async(req: HttpRequest): Promise => ({ target: { path: joinUrl(baseUrl, req.url!) }, method: req.method!, - body: new BasicRepresentation('', req.headers['content-type'] ?? 'text/plain'), + body: req.method === 'GET' ? + undefined : + new BasicRepresentation('', req.headers['content-type'] ?? 'text/plain'), preferences: { type: { 'text/html': 1 }}, })), } as any; @@ -77,7 +81,12 @@ describe('An IdentityProviderHttpHandler', (): void => { }; converter = { - handleSafe: jest.fn((input: RepresentationConverterArgs): Representation => input.representation), + handleSafe: jest.fn((input: RepresentationConverterArgs): Representation => { + // Just find the best match; + const type = getBestPreference(input.preferences.type!, { '*/*': 1 })!; + const metadata = new RepresentationMetadata(input.representation.metadata, { [CONTENT_TYPE]: type.value }); + return new BasicRepresentation(input.representation.data, metadata); + }), } as any; interactionCompleter = { handleSafe: jest.fn().mockResolvedValue('http://test.com/idp/auth') } as any; @@ -116,7 +125,7 @@ describe('An IdentityProviderHttpHandler', (): void => { expect(JSON.parse(await readableToString(result.data!))) .toEqual({ apiVersion, errorMessage: '', prefilled: {}, authenticating: false }); expect(result.statusCode).toBe(200); - expect(result.metadata?.contentType).toBe('application/json'); + expect(result.metadata?.contentType).toBe('text/html'); expect(result.metadata?.get(SOLID_META.template)?.value).toBe(routes.response.viewTemplates['text/html']); }); @@ -124,16 +133,17 @@ describe('An IdentityProviderHttpHandler', (): void => { request.url = '/idp/routeResponse'; request.method = 'POST'; await expect(handler.handle({ request, response })).resolves.toBeUndefined(); - const operation = await requestParser.handleSafe.mock.results[0].value; + const operation: Operation = await requestParser.handleSafe.mock.results[0].value; expect(routes.response.handler.handleSafe).toHaveBeenCalledTimes(1); expect(routes.response.handler.handleSafe).toHaveBeenLastCalledWith({ operation }); + 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, key: 'val', authenticating: false }); expect(result.statusCode).toBe(200); - expect(result.metadata?.contentType).toBe('application/json'); + expect(result.metadata?.contentType).toBe('text/html'); expect(result.metadata?.get(SOLID_META.template)?.value).toBe(routes.response.responseTemplates['text/html']); }); @@ -155,10 +165,11 @@ describe('An IdentityProviderHttpHandler', (): void => { request.method = 'POST'; errorHandler.handleSafe.mockResolvedValueOnce({ statusCode: 400 }); await expect(handler.handle({ request, response })).resolves.toBeUndefined(); - const operation = await requestParser.handleSafe.mock.results[0].value; + const operation: Operation = await requestParser.handleSafe.mock.results[0].value; expect(routes.complete.handler.handleSafe).toHaveBeenCalledTimes(1); expect(routes.complete.handler.handleSafe).toHaveBeenLastCalledWith({ operation }); expect(interactionCompleter.handleSafe).toHaveBeenCalledTimes(0); + expect(operation.body?.metadata.contentType).toBe('application/json'); const error = expect.objectContaining({ message: 'This action can only be performed as part of an OIDC authentication flow.', @@ -176,9 +187,11 @@ describe('An IdentityProviderHttpHandler', (): void => { const oidcInteraction = { session: { accountId: 'account' }} as any; provider.interactionDetails.mockResolvedValueOnce(oidcInteraction); await expect(handler.handle({ request, response })).resolves.toBeUndefined(); - const operation = await requestParser.handleSafe.mock.results[0].value; + const operation: Operation = await requestParser.handleSafe.mock.results[0].value; expect(routes.complete.handler.handleSafe).toHaveBeenCalledTimes(1); expect(routes.complete.handler.handleSafe).toHaveBeenLastCalledWith({ operation, oidcInteraction }); + expect(operation.body?.metadata.contentType).toBe('application/json'); + expect(interactionCompleter.handleSafe).toHaveBeenCalledTimes(1); expect(interactionCompleter.handleSafe).toHaveBeenLastCalledWith({ request, webId: 'webId' }); const location = await interactionCompleter.handleSafe.mock.results[0].value; @@ -195,10 +208,11 @@ describe('An IdentityProviderHttpHandler', (): void => { const oidcInteraction = { prompt: { name: 'other' }}; provider.interactionDetails.mockResolvedValueOnce(oidcInteraction as any); await expect(handler.handle({ request, response })).resolves.toBeUndefined(); - const operation = await requestParser.handleSafe.mock.results[0].value; + const operation: Operation = await requestParser.handleSafe.mock.results[0].value; expect(routes.response.handler.handleSafe).toHaveBeenCalledTimes(0); expect(routes.complete.handler.handleSafe).toHaveBeenCalledTimes(1); expect(routes.complete.handler.handleSafe).toHaveBeenLastCalledWith({ operation, oidcInteraction }); + expect(operation.body?.metadata.contentType).toBe('application/json'); }); it('uses the default route for requests to the root IDP without (matching) prompt.', async(): Promise => { @@ -207,9 +221,10 @@ describe('An IdentityProviderHttpHandler', (): void => { const oidcInteraction = { prompt: { name: 'notSupported' }}; provider.interactionDetails.mockResolvedValueOnce(oidcInteraction as any); await expect(handler.handle({ request, response })).resolves.toBeUndefined(); - const operation = await requestParser.handleSafe.mock.results[0].value; + const operation: Operation = await requestParser.handleSafe.mock.results[0].value; expect(routes.response.handler.handleSafe).toHaveBeenCalledTimes(1); expect(routes.response.handler.handleSafe).toHaveBeenLastCalledWith({ operation, oidcInteraction }); + expect(operation.body?.metadata.contentType).toBe('application/json'); expect(routes.complete.handler.handleSafe).toHaveBeenCalledTimes(0); }); @@ -226,7 +241,7 @@ describe('An IdentityProviderHttpHandler', (): void => { expect(JSON.parse(await readableToString(result.data!))) .toEqual({ apiVersion, errorMessage: 'handle error', prefilled: { name: 'name' }, authenticating: false }); expect(result.statusCode).toBe(200); - expect(result.metadata?.contentType).toBe('application/json'); + expect(result.metadata?.contentType).toBe('text/html'); expect(result.metadata?.get(SOLID_META.template)?.value).toBe(routes.response.viewTemplates['text/html']); }); diff --git a/test/unit/identity/interaction/SessionHttpHandler.test.ts b/test/unit/identity/interaction/SessionHttpHandler.test.ts index 6035b4c41..a7bbc34db 100644 --- a/test/unit/identity/interaction/SessionHttpHandler.test.ts +++ b/test/unit/identity/interaction/SessionHttpHandler.test.ts @@ -1,7 +1,7 @@ import type { Interaction } from '../../../../src/identity/interaction/email-password/handler/InteractionHandler'; import { SessionHttpHandler } from '../../../../src/identity/interaction/SessionHttpHandler'; import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; -import { createPostFormOperation } from './email-password/handler/Util'; +import { createPostJsonOperation } from './email-password/handler/Util'; describe('A SessionHttpHandler', (): void => { const webId = 'http://test.com/id#me'; @@ -22,7 +22,7 @@ describe('A SessionHttpHandler', (): void => { }); it('returns an InteractionCompleteResult when done.', async(): Promise => { - const operation = createPostFormOperation({ remember: true }); + const operation = createPostJsonOperation({ remember: true }); await expect(handler.handle({ operation, oidcInteraction })).resolves.toEqual({ details: { webId, shouldRemember: true }, type: 'complete', diff --git a/test/unit/identity/interaction/email-password/handler/ForgotPasswordHandler.test.ts b/test/unit/identity/interaction/email-password/handler/ForgotPasswordHandler.test.ts index 6ed0f3d55..ec2565ef8 100644 --- a/test/unit/identity/interaction/email-password/handler/ForgotPasswordHandler.test.ts +++ b/test/unit/identity/interaction/email-password/handler/ForgotPasswordHandler.test.ts @@ -5,7 +5,7 @@ import type { AccountStore } from '../../../../../../src/identity/interaction/em import type { EmailSender } from '../../../../../../src/identity/interaction/util/EmailSender'; import type { Operation } from '../../../../../../src/ldp/operations/Operation'; import type { TemplateEngine } from '../../../../../../src/util/templates/TemplateEngine'; -import { createPostFormOperation } from './Util'; +import { createPostJsonOperation } from './Util'; describe('A ForgotPasswordHandler', (): void => { let operation: Operation; @@ -20,7 +20,7 @@ describe('A ForgotPasswordHandler', (): void => { let handler: ForgotPasswordHandler; beforeEach(async(): Promise => { - operation = createPostFormOperation({ email }); + operation = createPostJsonOperation({ email }); accountStore = { generateForgotPasswordRecord: jest.fn().mockResolvedValue(recordId), @@ -44,9 +44,9 @@ describe('A ForgotPasswordHandler', (): void => { }); it('errors on non-string emails.', async(): Promise => { - operation = createPostFormOperation({}); + operation = createPostJsonOperation({}); await expect(handler.handle({ operation })).rejects.toThrow('Email required'); - operation = createPostFormOperation({ email: [ 'email', 'email2' ]}); + operation = createPostJsonOperation({ email: [ 'email', 'email2' ]}); await expect(handler.handle({ operation })).rejects.toThrow('Email required'); }); diff --git a/test/unit/identity/interaction/email-password/handler/InteractionHandler.test.ts b/test/unit/identity/interaction/email-password/handler/InteractionHandler.test.ts new file mode 100644 index 000000000..dfff08d8e --- /dev/null +++ b/test/unit/identity/interaction/email-password/handler/InteractionHandler.test.ts @@ -0,0 +1,29 @@ +import type { + InteractionResponseResult, +} from '../../../../../../src/identity/interaction/email-password/handler/InteractionHandler'; +import { + InteractionHandler, +} from '../../../../../../src/identity/interaction/email-password/handler/InteractionHandler'; +import { BasicRepresentation } from '../../../../../../src/ldp/representation/BasicRepresentation'; +import { NotImplementedHttpError } from '../../../../../../src/util/errors/NotImplementedHttpError'; + +class SimpleInteractionHandler extends InteractionHandler { + public async handle(): Promise { + return { type: 'response' }; + } +} + +describe('An InteractionHandler', (): void => { + const handler = new SimpleInteractionHandler(); + + it('only supports JSON data.', async(): Promise => { + let representation = new BasicRepresentation('{}', 'application/json'); + await expect(handler.canHandle({ operation: { body: representation }} as any)).resolves.toBeUndefined(); + + representation = new BasicRepresentation('', 'application/x-www-form-urlencoded'); + await expect(handler.canHandle({ operation: { body: representation }} as any)) + .rejects.toThrow(NotImplementedHttpError); + + await expect(handler.canHandle({ operation: {}} as any)).rejects.toThrow(NotImplementedHttpError); + }); +}); diff --git a/test/unit/identity/interaction/email-password/handler/LoginHandler.test.ts b/test/unit/identity/interaction/email-password/handler/LoginHandler.test.ts index efd0fe9dd..24942dfbd 100644 --- a/test/unit/identity/interaction/email-password/handler/LoginHandler.test.ts +++ b/test/unit/identity/interaction/email-password/handler/LoginHandler.test.ts @@ -3,7 +3,7 @@ import type { } from '../../../../../../src/identity/interaction/email-password/handler/InteractionHandler'; import { LoginHandler } from '../../../../../../src/identity/interaction/email-password/handler/LoginHandler'; import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore'; -import { createPostFormOperation } from './Util'; +import { createPostJsonOperation } from './Util'; describe('A LoginHandler', (): void => { const webId = 'http://alice.test.com/card#me'; @@ -23,29 +23,29 @@ describe('A LoginHandler', (): void => { }); it('errors on invalid emails.', async(): Promise => { - input.operation = createPostFormOperation({}); + 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 = createPostFormOperation({ email: [ 'a', 'b' ]}); + 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 => { - input.operation = createPostFormOperation({ email }); + 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 = createPostFormOperation({ email, password: [ 'a', 'b' ]}); + 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 => { - input.operation = createPostFormOperation({ email, password: 'password!' }); + 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!'); @@ -53,7 +53,7 @@ describe('A LoginHandler', (): void => { }); it('returns an InteractionCompleteResult when done.', async(): Promise => { - input.operation = createPostFormOperation({ email, password: 'password!' }); + input.operation = createPostJsonOperation({ email, password: 'password!' }); await expect(handler.handle(input)).resolves.toEqual({ type: 'complete', details: { webId, shouldRemember: false }, diff --git a/test/unit/identity/interaction/email-password/handler/RegistrationHandler.test.ts b/test/unit/identity/interaction/email-password/handler/RegistrationHandler.test.ts index bc594af09..6301aac17 100644 --- a/test/unit/identity/interaction/email-password/handler/RegistrationHandler.test.ts +++ b/test/unit/identity/interaction/email-password/handler/RegistrationHandler.test.ts @@ -10,7 +10,7 @@ import type { IdentifierGenerator } from '../../../../../../src/pods/generate/Id import type { PodManager } from '../../../../../../src/pods/PodManager'; import type { PodSettings } from '../../../../../../src/pods/settings/PodSettings'; import { joinUrl } from '../../../../../../src/util/PathUtil'; -import { createPostFormOperation } from './Util'; +import { createPostJsonOperation } from './Util'; describe('A RegistrationHandler', (): void => { // "Correct" values for easy object creation @@ -20,10 +20,9 @@ describe('A RegistrationHandler', (): void => { const confirmPassword = password; const podName = 'alice'; const podBaseUrl = 'http://test.com/alice/'; - // Strings instead of booleans because this is form data - const createWebId = 'true'; - const register = 'true'; - const createPod = 'true'; + const createWebId = true; + const register = true; + const createPod = true; let operation: Operation; @@ -69,71 +68,71 @@ describe('A RegistrationHandler', (): void => { describe('validating data', (): void => { it('rejects array inputs.', async(): Promise => { - operation = createPostFormOperation({ mydata: [ 'a', 'b' ]}); + operation = createPostJsonOperation({ mydata: [ 'a', 'b' ]}); await expect(handler.handle({ operation })) .rejects.toThrow('Unexpected multiple values for mydata.'); }); it('errors on invalid emails.', async(): Promise => { - operation = createPostFormOperation({ email: undefined }); + operation = createPostJsonOperation({ email: undefined }); await expect(handler.handle({ operation })) .rejects.toThrow('Please enter a valid e-mail address.'); - operation = createPostFormOperation({ email: '' }); + operation = createPostJsonOperation({ email: '' }); await expect(handler.handle({ operation })) .rejects.toThrow('Please enter a valid e-mail address.'); - operation = createPostFormOperation({ email: 'invalidEmail' }); + operation = createPostJsonOperation({ email: 'invalidEmail' }); await expect(handler.handle({ operation })) .rejects.toThrow('Please enter a valid e-mail address.'); }); it('errors when a required WebID is not valid.', async(): Promise => { - operation = createPostFormOperation({ email, register, webId: undefined }); + operation = createPostJsonOperation({ email, register, webId: undefined }); await expect(handler.handle({ operation })) .rejects.toThrow('Please enter a valid WebID.'); - operation = createPostFormOperation({ email, register, webId: '' }); + operation = createPostJsonOperation({ email, register, webId: '' }); await expect(handler.handle({ operation })) .rejects.toThrow('Please enter a valid WebID.'); }); it('errors on invalid passwords when registering.', async(): Promise => { - operation = createPostFormOperation({ email, webId, password, confirmPassword: 'bad', register }); + operation = createPostJsonOperation({ email, webId, password, confirmPassword: 'bad', register }); await expect(handler.handle({ operation })) .rejects.toThrow('Your password and confirmation did not match.'); }); it('errors on invalid pod names when required.', async(): Promise => { - operation = createPostFormOperation({ email, webId, createPod, podName: undefined }); + operation = createPostJsonOperation({ email, webId, createPod, podName: undefined }); await expect(handler.handle({ operation })) .rejects.toThrow('Please specify a Pod name.'); - operation = createPostFormOperation({ email, webId, createPod, podName: ' ' }); + operation = createPostJsonOperation({ email, webId, createPod, podName: ' ' }); await expect(handler.handle({ operation })) .rejects.toThrow('Please specify a Pod name.'); - operation = createPostFormOperation({ email, webId, createWebId }); + operation = createPostJsonOperation({ email, webId, createWebId }); await expect(handler.handle({ operation })) .rejects.toThrow('Please specify a Pod name.'); }); it('errors when trying to create a WebID without registering or creating a pod.', async(): Promise => { - operation = createPostFormOperation({ email, podName, createWebId }); + operation = createPostJsonOperation({ email, podName, createWebId }); await expect(handler.handle({ operation })) .rejects.toThrow('Please enter a password.'); - operation = createPostFormOperation({ email, podName, createWebId, createPod }); + operation = createPostJsonOperation({ email, podName, createWebId, createPod }); await expect(handler.handle({ operation })) .rejects.toThrow('Please enter a password.'); - operation = createPostFormOperation({ email, podName, createWebId, createPod, register }); + operation = createPostJsonOperation({ email, podName, createWebId, createPod, register }); await expect(handler.handle({ operation })) .rejects.toThrow('Please enter a password.'); }); it('errors when no option is chosen.', async(): Promise => { - operation = createPostFormOperation({ email, webId }); + operation = createPostJsonOperation({ email, webId }); await expect(handler.handle({ operation })) .rejects.toThrow('Please register for a WebID or create a Pod.'); }); @@ -141,7 +140,7 @@ describe('A RegistrationHandler', (): void => { describe('handling data', (): void => { it('can register a user.', async(): Promise => { - operation = createPostFormOperation({ email, webId, password, confirmPassword, register }); + operation = createPostJsonOperation({ email, webId, password, confirmPassword, register }); await expect(handler.handle({ operation })).resolves.toEqual({ details: { email, @@ -168,7 +167,7 @@ describe('A RegistrationHandler', (): void => { it('can create a pod.', async(): Promise => { const params = { email, webId, podName, createPod }; - operation = createPostFormOperation(params); + operation = createPostJsonOperation(params); await expect(handler.handle({ operation })).resolves.toEqual({ details: { email, @@ -197,7 +196,7 @@ describe('A RegistrationHandler', (): void => { it('adds an oidcIssuer to the data when doing both IDP registration and pod creation.', async(): Promise => { const params = { email, webId, password, confirmPassword, podName, register, createPod }; podSettings.oidcIssuer = baseUrl; - operation = createPostFormOperation(params); + operation = createPostJsonOperation(params); await expect(handler.handle({ operation })).resolves.toEqual({ details: { email, @@ -228,7 +227,7 @@ describe('A RegistrationHandler', (): void => { it('deletes the created account if pod generation fails.', async(): Promise => { const params = { email, webId, password, confirmPassword, podName, register, createPod }; podSettings.oidcIssuer = baseUrl; - operation = createPostFormOperation(params); + operation = createPostJsonOperation(params); (podManager.createPod as jest.Mock).mockRejectedValueOnce(new Error('pod error')); await expect(handler.handle({ operation })).rejects.toThrow('pod error'); @@ -252,7 +251,7 @@ describe('A RegistrationHandler', (): void => { podSettings.webId = generatedWebID; podSettings.oidcIssuer = baseUrl; - operation = createPostFormOperation(params); + operation = createPostJsonOperation(params); await expect(handler.handle({ operation })).resolves.toEqual({ details: { email, @@ -281,7 +280,7 @@ describe('A RegistrationHandler', (): void => { it('throws an IdpInteractionError with all data prefilled if something goes wrong.', async(): Promise => { const params = { email, webId, podName, createPod }; - operation = createPostFormOperation(params); + 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'); diff --git a/test/unit/identity/interaction/email-password/handler/ResetPasswordHandler.test.ts b/test/unit/identity/interaction/email-password/handler/ResetPasswordHandler.test.ts index a917eac57..82193ad52 100644 --- a/test/unit/identity/interaction/email-password/handler/ResetPasswordHandler.test.ts +++ b/test/unit/identity/interaction/email-password/handler/ResetPasswordHandler.test.ts @@ -3,7 +3,7 @@ import { } from '../../../../../../src/identity/interaction/email-password/handler/ResetPasswordHandler'; import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore'; import type { Operation } from '../../../../../../src/ldp/operations/Operation'; -import { createPostFormOperation } from './Util'; +import { createPostJsonOperation } from './Util'; describe('A ResetPasswordHandler', (): void => { let operation: Operation; @@ -25,27 +25,27 @@ describe('A ResetPasswordHandler', (): void => { it('errors for non-string recordIds.', async(): Promise => { const errorMessage = 'Invalid request. Open the link from your email again'; - operation = createPostFormOperation({}); + operation = createPostJsonOperation({}); await expect(handler.handle({ operation })).rejects.toThrow(errorMessage); - operation = createPostFormOperation({}, ''); + operation = createPostJsonOperation({}, ''); await expect(handler.handle({ operation })).rejects.toThrow(errorMessage); }); it('errors for invalid passwords.', async(): Promise => { const errorMessage = 'Your password and confirmation did not match.'; - operation = createPostFormOperation({ password: 'password!', confirmPassword: 'otherPassword!' }, url); + operation = createPostJsonOperation({ password: 'password!', confirmPassword: 'otherPassword!' }, url); await expect(handler.handle({ operation })).rejects.toThrow(errorMessage); }); it('errors for invalid emails.', async(): Promise => { const errorMessage = 'This reset password link is no longer valid.'; - operation = createPostFormOperation({ password: 'password!', confirmPassword: 'password!' }, url); + operation = createPostJsonOperation({ password: 'password!', confirmPassword: 'password!' }, url); (accountStore.getForgotPasswordRecord as jest.Mock).mockResolvedValueOnce(undefined); await expect(handler.handle({ operation })).rejects.toThrow(errorMessage); }); it('renders a message on success.', async(): Promise => { - operation = createPostFormOperation({ password: 'password!', confirmPassword: 'password!' }, url); + operation = createPostJsonOperation({ password: 'password!', confirmPassword: 'password!' }, url); await expect(handler.handle({ operation })).resolves.toEqual({ type: 'response' }); expect(accountStore.getForgotPasswordRecord).toHaveBeenCalledTimes(1); expect(accountStore.getForgotPasswordRecord).toHaveBeenLastCalledWith(recordId); @@ -57,7 +57,7 @@ describe('A ResetPasswordHandler', (): void => { it('has a default error for non-native errors.', async(): Promise => { const errorMessage = 'Unknown error: not native'; - operation = createPostFormOperation({ password: 'password!', confirmPassword: 'password!' }, url); + operation = createPostJsonOperation({ password: 'password!', confirmPassword: 'password!' }, url); (accountStore.getForgotPasswordRecord as jest.Mock).mockRejectedValueOnce('not native'); await expect(handler.handle({ operation })).rejects.toThrow(errorMessage); }); diff --git a/test/unit/identity/interaction/email-password/handler/Util.ts b/test/unit/identity/interaction/email-password/handler/Util.ts index 65fb4de04..de1793b9d 100644 --- a/test/unit/identity/interaction/email-password/handler/Util.ts +++ b/test/unit/identity/interaction/email-password/handler/Util.ts @@ -1,18 +1,17 @@ -import { stringify } from 'querystring'; import type { Operation } from '../../../../../../src/ldp/operations/Operation'; import { BasicRepresentation } from '../../../../../../src/ldp/representation/BasicRepresentation'; /** - * Creates a mock HttpRequest which is a stream of an object encoded as application/x-www-form-urlencoded + * Creates a mock HttpRequest which is a stream of an object encoded as application/json * and a matching content-type header. * @param data - Object to encode. * @param url - URL value of the request. */ -export function createPostFormOperation(data: NodeJS.Dict, url?: string): Operation { +export function createPostJsonOperation(data: NodeJS.Dict, url?: string): Operation { return { method: 'POST', preferences: {}, target: { path: url ?? 'http://test.com/' }, - body: new BasicRepresentation(stringify(data), 'application/x-www-form-urlencoded'), + body: new BasicRepresentation(JSON.stringify(data), 'application/json'), }; } diff --git a/test/unit/identity/interaction/util/FormDataUtil.test.ts b/test/unit/identity/interaction/util/FormDataUtil.test.ts deleted file mode 100644 index 11ddf04e2..000000000 --- a/test/unit/identity/interaction/util/FormDataUtil.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { stringify } from 'querystring'; -import { - getFormDataRequestBody, -} from '../../../../../src/identity/interaction/util/FormDataUtil'; -import type { Operation } from '../../../../../src/ldp/operations/Operation'; -import { BasicRepresentation } from '../../../../../src/ldp/representation/BasicRepresentation'; -import { UnsupportedMediaTypeHttpError } from '../../../../../src/util/errors/UnsupportedMediaTypeHttpError'; - -describe('FormDataUtil', (): void => { - describe('#getFormDataRequestBody', (): void => { - it('only supports form data.', async(): Promise => { - await expect(getFormDataRequestBody({ headers: { 'content-type': 'text/turtle' }} as any)) - .rejects.toThrow(UnsupportedMediaTypeHttpError); - }); - - it('converts the body to an object.', async(): Promise => { - const data = { test: 'test!', moreTest: '!TEST!' }; - const operation: Operation = { - method: 'GET', - preferences: {}, - target: { path: '' }, - body: new BasicRepresentation(stringify(data), 'application/x-www-form-urlencoded'), - }; - await expect(getFormDataRequestBody(operation)).resolves.toEqual(data); - }); - }); -}); diff --git a/test/unit/storage/conversion/FormToJsonConverter.test.ts b/test/unit/storage/conversion/FormToJsonConverter.test.ts new file mode 100644 index 000000000..4e3ac5fde --- /dev/null +++ b/test/unit/storage/conversion/FormToJsonConverter.test.ts @@ -0,0 +1,22 @@ +import { stringify } from 'querystring'; +import { BasicRepresentation } from '../../../../src/ldp/representation/BasicRepresentation'; +import { FormToJsonConverter } from '../../../../src/storage/conversion/FormToJsonConverter'; +import { readableToString } from '../../../../src/util/StreamUtil'; + +describe('A FormToJsonConverter', (): void => { + const identifier = { path: 'http://test.com/foo' }; + const converter = new FormToJsonConverter(); + + it('supports going from form data to json.', async(): Promise => { + await expect(converter.getInputTypes()).resolves.toEqual({ 'application/x-www-form-urlencoded': 1 }); + await expect(converter.getOutputTypes()).resolves.toEqual({ 'application/json': 1 }); + }); + + it('converts form data to JSON.', async(): Promise => { + const formData = stringify({ field: 'value' }); + const representation = new BasicRepresentation(formData, 'application/x-www-form-urlencoded'); + const result = await converter.handle({ identifier, representation, preferences: {}}); + expect(result.metadata.contentType).toBe('application/json'); + expect(JSON.parse(await readableToString(result.data))).toEqual({ field: 'value' }); + }); +}); diff --git a/test/unit/util/StreamUtil.test.ts b/test/unit/util/StreamUtil.test.ts index 941720891..e71da4f00 100644 --- a/test/unit/util/StreamUtil.test.ts +++ b/test/unit/util/StreamUtil.test.ts @@ -4,8 +4,10 @@ import { Quad, NamedNode, Literal, BlankNode, Store } from 'n3'; import type { Logger } from '../../../src/logging/Logger'; import { getLoggerFor } from '../../../src/logging/LogUtil'; import { isHttpRequest } from '../../../src/server/HttpRequest'; -import { guardedStreamFrom, pipeSafely, transformSafely, - readableToString, readableToQuads } from '../../../src/util/StreamUtil'; +import { + guardedStreamFrom, pipeSafely, transformSafely, + readableToString, readableToQuads, readJsonStream, +} from '../../../src/util/StreamUtil'; jest.mock('../../../src/logging/LogUtil', (): any => { const logger: Logger = { warn: jest.fn(), log: jest.fn() } as any; @@ -47,6 +49,13 @@ describe('StreamUtil', (): void => { }); }); + describe('#readJsonStream', (): void => { + it('parses the stream as JSON.', async(): Promise => { + const stream = Readable.from('{ "key": "value" }'); + await expect(readJsonStream(stream)).resolves.toEqual({ key: 'value' }); + }); + }); + describe('#pipeSafely', (): void => { beforeEach(async(): Promise => { jest.clearAllMocks();