feat: Convert IDP input data to JSON

This commit is contained in:
Joachim Van Herwegen 2021-08-24 15:22:35 +02:00
parent 7f3eab0b20
commit 4f1a86dfa0
26 changed files with 226 additions and 131 deletions

View File

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

View File

@ -4,6 +4,7 @@
"files-scs:config/util/representation-conversion/converters/content-type-replacer.json", "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/dynamic-json-template.json",
"files-scs:config/util/representation-conversion/converters/errors.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/markdown.json",
"files-scs:config/util/representation-conversion/converters/quad-to-rdf.json", "files-scs:config/util/representation-conversion/converters/quad-to-rdf.json",
"files-scs:config/util/representation-conversion/converters/rdf-to-quad.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:ContainerToTemplateConverter" },
{ "@id": "urn:solid-server:default:ErrorToQuadConverter" }, { "@id": "urn:solid-server:default:ErrorToQuadConverter" },
{ "@id": "urn:solid-server:default:ErrorToTemplateConverter" }, { "@id": "urn:solid-server:default:ErrorToTemplateConverter" },
{ "@id": "urn:solid-server:default:MarkdownToHtmlConverter" } { "@id": "urn:solid-server:default:MarkdownToHtmlConverter" },
{ "@id": "urn:solid-server:default:FormToJsonConverter" }
] ]
} }
] ]

View File

@ -158,6 +158,16 @@ export class IdentityProviderHttpHandler extends HttpHandler {
return provider.callback(request, response); 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 { result, templateFiles } = await this.resolveRoute(operation, route, oidcInteraction);
const responseDescription = const responseDescription =
await this.handleInteractionResult(operation, request, result, templateFiles, oidcInteraction); await this.handleInteractionResult(operation, request, result, templateFiles, oidcInteraction);

View File

@ -1,7 +1,7 @@
import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
import { readJsonStream } from '../../util/StreamUtil';
import { InteractionHandler } from './email-password/handler/InteractionHandler'; import { InteractionHandler } from './email-password/handler/InteractionHandler';
import type { InteractionCompleteResult, InteractionHandlerInput } 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. * 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.'); throw new NotImplementedHttpError('Only interactions with a valid session are supported.');
} }
const { remember } = await getFormDataRequestBody(operation); const { remember } = await readJsonStream(operation.body!.data);
return { return {
type: 'complete', type: 'complete',
details: { webId: oidcInteraction.session.accountId, shouldRemember: Boolean(remember) }, details: { webId: oidcInteraction.session.accountId, shouldRemember: Boolean(remember) },

View File

@ -1,9 +1,9 @@
import assert from 'assert'; import assert from 'assert';
import { getLoggerFor } from '../../../../logging/LogUtil'; import { getLoggerFor } from '../../../../logging/LogUtil';
import { ensureTrailingSlash, joinUrl } from '../../../../util/PathUtil'; import { ensureTrailingSlash, joinUrl } from '../../../../util/PathUtil';
import { readJsonStream } from '../../../../util/StreamUtil';
import type { TemplateEngine } from '../../../../util/templates/TemplateEngine'; import type { TemplateEngine } from '../../../../util/templates/TemplateEngine';
import type { EmailSender } from '../../util/EmailSender'; import type { EmailSender } from '../../util/EmailSender';
import { getFormDataRequestBody } from '../../util/FormDataUtil';
import { throwIdpInteractionError } from '../EmailPasswordUtil'; import { throwIdpInteractionError } from '../EmailPasswordUtil';
import type { AccountStore } from '../storage/AccountStore'; import type { AccountStore } from '../storage/AccountStore';
import { InteractionHandler } from './InteractionHandler'; import { InteractionHandler } from './InteractionHandler';
@ -41,7 +41,7 @@ export class ForgotPasswordHandler extends InteractionHandler {
public async handle({ operation }: InteractionHandlerInput): Promise<InteractionResponseResult<{ email: string }>> { public async handle({ operation }: InteractionHandlerInput): Promise<InteractionResponseResult<{ email: string }>> {
try { try {
// Validate incoming data // Validate incoming data
const { email } = await getFormDataRequestBody(operation); const { email } = await readJsonStream(operation.body!.data);
assert(typeof email === 'string' && email.length > 0, 'Email required'); assert(typeof email === 'string' && email.length > 0, 'Email required');
await this.resetPassword(email); await this.resetPassword(email);

View File

@ -1,5 +1,7 @@
import type { KoaContextWithOIDC } from 'oidc-provider'; import type { KoaContextWithOIDC } from 'oidc-provider';
import type { Operation } from '../../../../ldp/operations/Operation'; 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 { AsyncHandler } from '../../../../util/handlers/AsyncHandler';
import type { InteractionCompleterParams } from '../../util/InteractionCompleter'; import type { InteractionCompleterParams } from '../../util/InteractionCompleter';
@ -32,5 +34,12 @@ export interface InteractionCompleteResult {
/** /**
* Handler used for IDP interactions. * Handler used for IDP interactions.
* Only supports JSON data.
*/ */
export abstract class InteractionHandler extends AsyncHandler<InteractionHandlerInput, InteractionHandlerResult> {} export abstract class InteractionHandler extends AsyncHandler<InteractionHandlerInput, InteractionHandlerResult> {
public async canHandle({ operation }: InteractionHandlerInput): Promise<void> {
if (operation.body?.metadata.contentType !== APPLICATION_JSON) {
throw new NotImplementedHttpError('Only application/json data is supported.');
}
}
}

View File

@ -1,7 +1,7 @@
import assert from 'assert'; import assert from 'assert';
import type { Operation } from '../../../../ldp/operations/Operation'; import type { Operation } from '../../../../ldp/operations/Operation';
import { getLoggerFor } from '../../../../logging/LogUtil'; import { getLoggerFor } from '../../../../logging/LogUtil';
import { getFormDataRequestBody } from '../../util/FormDataUtil'; import { readJsonStream } from '../../../../util/StreamUtil';
import { throwIdpInteractionError } from '../EmailPasswordUtil'; import { throwIdpInteractionError } from '../EmailPasswordUtil';
import type { AccountStore } from '../storage/AccountStore'; import type { AccountStore } from '../storage/AccountStore';
import { InteractionHandler } from './InteractionHandler'; import { InteractionHandler } from './InteractionHandler';
@ -28,7 +28,7 @@ export class LoginHandler extends InteractionHandler {
this.logger.debug(`Logging in user ${email}`); this.logger.debug(`Logging in user ${email}`);
return { return {
type: 'complete', type: 'complete',
details: { webId, shouldRemember: Boolean(remember) }, details: { webId, shouldRemember: remember },
}; };
} catch (err: unknown) { } catch (err: unknown) {
throwIdpInteractionError(err, { email }); throwIdpInteractionError(err, { email });
@ -43,7 +43,7 @@ export class LoginHandler extends InteractionHandler {
private async parseInput(operation: Operation): Promise<{ email: string; password: string; remember: boolean }> { private async parseInput(operation: Operation): Promise<{ email: string; password: string; remember: boolean }> {
const prefilled: Record<string, string> = {}; const prefilled: Record<string, string> = {};
try { 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'); assert(typeof email === 'string' && email.length > 0, 'Email required');
prefilled.email = email; prefilled.email = email;
assert(typeof password === 'string' && password.length > 0, 'Password required'); assert(typeof password === 'string' && password.length > 0, 'Password required');

View File

@ -6,8 +6,8 @@ import type { IdentifierGenerator } from '../../../../pods/generate/IdentifierGe
import type { PodManager } from '../../../../pods/PodManager'; import type { PodManager } from '../../../../pods/PodManager';
import type { PodSettings } from '../../../../pods/settings/PodSettings'; import type { PodSettings } from '../../../../pods/settings/PodSettings';
import { joinUrl } from '../../../../util/PathUtil'; import { joinUrl } from '../../../../util/PathUtil';
import { readJsonStream } from '../../../../util/StreamUtil';
import type { OwnershipValidator } from '../../../ownership/OwnershipValidator'; import type { OwnershipValidator } from '../../../ownership/OwnershipValidator';
import { getFormDataRequestBody } from '../../util/FormDataUtil';
import { assertPassword, throwIdpInteractionError } from '../EmailPasswordUtil'; import { assertPassword, throwIdpInteractionError } from '../EmailPasswordUtil';
import type { AccountStore } from '../storage/AccountStore'; import type { AccountStore } from '../storage/AccountStore';
import type { InteractionResponseResult, InteractionHandlerInput } from './InteractionHandler'; import type { InteractionResponseResult, InteractionHandlerInput } from './InteractionHandler';
@ -186,12 +186,12 @@ export class RegistrationHandler extends InteractionHandler {
* Parses the input request into a `ParseResult`. * Parses the input request into a `ParseResult`.
*/ */
private async parseInput(operation: Operation): Promise<ParsedInput> { private async parseInput(operation: Operation): Promise<ParsedInput> {
const parsed = await getFormDataRequestBody(operation); const parsed = await readJsonStream(operation.body!.data);
const prefilled: Record<string, string> = {}; const prefilled: Record<string, string> = {};
try { try {
for (const [ key, value ] of Object.entries(parsed)) { for (const [ key, value ] of Object.entries(parsed)) {
assert(!Array.isArray(value), `Unexpected multiple values for ${key}.`); 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); return this.validateInput(prefilled);
} catch (err: unknown) { } catch (err: unknown) {

View File

@ -1,6 +1,6 @@
import assert from 'assert'; import assert from 'assert';
import { getLoggerFor } from '../../../../logging/LogUtil'; import { getLoggerFor } from '../../../../logging/LogUtil';
import { getFormDataRequestBody } from '../../util/FormDataUtil'; import { readJsonStream } from '../../../../util/StreamUtil';
import { assertPassword, throwIdpInteractionError } from '../EmailPasswordUtil'; import { assertPassword, throwIdpInteractionError } from '../EmailPasswordUtil';
import type { AccountStore } from '../storage/AccountStore'; import type { AccountStore } from '../storage/AccountStore';
import type { InteractionResponseResult, InteractionHandlerInput } from './InteractionHandler'; import type { InteractionResponseResult, InteractionHandlerInput } from './InteractionHandler';
@ -25,7 +25,7 @@ export class ResetPasswordHandler extends InteractionHandler {
// Extract record ID from request URL // Extract record ID from request URL
const recordId = /\/([^/]+)$/u.exec(operation.target.path)?.[1]; const recordId = /\/([^/]+)$/u.exec(operation.target.path)?.[1];
// Validate input data // Validate input data
const { password, confirmPassword } = await getFormDataRequestBody(operation); const { password, confirmPassword } = await readJsonStream(operation.body!.data);
assert( assert(
typeof recordId === 'string' && recordId.length > 0, typeof recordId === 'string' && recordId.length > 0,
'Invalid request. Open the link from your email again', 'Invalid request. Open the link from your email again',

View File

@ -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<ParsedUrlQuery> {
if (operation.body?.metadata.contentType !== APPLICATION_X_WWW_FORM_URLENCODED) {
throw new UnsupportedMediaTypeHttpError();
}
const body = await readableToString(operation.body.data);
return parse(body);
}

View File

@ -44,7 +44,6 @@ export * from './identity/interaction/email-password/EmailPasswordUtil';
// Identity/Interaction/Util // Identity/Interaction/Util
export * from './identity/interaction/util/BaseEmailSender'; export * from './identity/interaction/util/BaseEmailSender';
export * from './identity/interaction/util/EmailSender'; export * from './identity/interaction/util/EmailSender';
export * from './identity/interaction/util/FormDataUtil';
export * from './identity/interaction/util/IdpInteractionError'; export * from './identity/interaction/util/IdpInteractionError';
export * from './identity/interaction/util/InteractionCompleter'; export * from './identity/interaction/util/InteractionCompleter';
@ -230,6 +229,7 @@ export * from './storage/conversion/ConversionUtil';
export * from './storage/conversion/DynamicJsonToTemplateConverter'; export * from './storage/conversion/DynamicJsonToTemplateConverter';
export * from './storage/conversion/ErrorToQuadConverter'; export * from './storage/conversion/ErrorToQuadConverter';
export * from './storage/conversion/ErrorToTemplateConverter'; export * from './storage/conversion/ErrorToTemplateConverter';
export * from './storage/conversion/FormToJsonConverter';
export * from './storage/conversion/IfNeededConverter'; export * from './storage/conversion/IfNeededConverter';
export * from './storage/conversion/MarkdownToHtmlConverter'; export * from './storage/conversion/MarkdownToHtmlConverter';
export * from './storage/conversion/PassthroughConverter'; export * from './storage/conversion/PassthroughConverter';

View File

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

View File

@ -37,6 +37,17 @@ export async function readableToQuads(stream: Readable): Promise<Store> {
return quads; 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<NodeJS.Dict<any>> {
const body = await readableToString(stream);
return JSON.parse(body);
}
// These error messages usually indicate expected behaviour so should not give a warning. // These error messages usually indicate expected behaviour so should not give a warning.
// We compare against the error message instead of the code // 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. // since the second one is from an external library that does not assign an error code.

View File

@ -1,5 +1,4 @@
import { mkdirSync } from 'fs'; import { mkdirSync } from 'fs';
import { stringify } from 'querystring';
import fetch from 'cross-fetch'; import fetch from 'cross-fetch';
import type { App } from '../../src/init/App'; import type { App } from '../../src/init/App';
import { joinFilePath } from '../../src/util/PathUtil'; 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<void> => { it('creates a pod with the given config.', async(): Promise<void> => {
const res = await fetch(`${baseUrl}idp/register`, { const res = await fetch(`${baseUrl}idp/register`, {
method: 'POST', method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' }, headers: { 'content-type': 'application/json' },
body: stringify(settings), body: JSON.stringify(settings),
}); });
expect(res.status).toBe(200); expect(res.status).toBe(200);
await expect(res.text()).resolves.toContain(podUrl); 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<void> => { it('should not be able to create a pod with the same name.', async(): Promise<void> => {
const res = await fetch(`${baseUrl}idp/register`, { const res = await fetch(`${baseUrl}idp/register`, {
method: 'POST', method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' }, headers: { 'content-type': 'application/json' },
body: stringify(settings), body: JSON.stringify(settings),
}); });
// 200 due to there only being a HTML solution right now that only returns 200 // 200 due to there only being a HTML solution right now that only returns 200
expect(res.status).toBe(200); expect(res.status).toBe(200);

View File

@ -1,4 +1,3 @@
import { stringify } from 'querystring';
import fetch from 'cross-fetch'; import fetch from 'cross-fetch';
import type { App } from '../../src/init/App'; import type { App } from '../../src/init/App';
import { getPort } from '../util/Util'; 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<void> => { it('creates pods in a subdomain.', async(): Promise<void> => {
const res = await fetch(`${baseUrl}idp/register`, { const res = await fetch(`${baseUrl}idp/register`, {
method: 'POST', method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' }, headers: { 'content-type': 'application/json' },
body: stringify(settings), body: JSON.stringify(settings),
}); });
expect(res.status).toBe(200); expect(res.status).toBe(200);
await expect(res.text()).resolves.toContain(podUrl); 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<void> => { it('should not be able to create a pod with the same name.', async(): Promise<void> => {
const res = await fetch(`${baseUrl}idp/register`, { const res = await fetch(`${baseUrl}idp/register`, {
method: 'POST', method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' }, headers: { 'content-type': 'application/json' },
body: stringify(settings), body: JSON.stringify(settings),
}); });
// 200 due to there only being a HTML solution right now that only returns 200 // 200 due to there only being a HTML solution right now that only returns 200
expect(res.status).toBe(200); expect(res.status).toBe(200);

View File

@ -10,8 +10,10 @@ import type { ResponseWriter } from '../../../src/ldp/http/ResponseWriter';
import type { Operation } from '../../../src/ldp/operations/Operation'; import type { Operation } from '../../../src/ldp/operations/Operation';
import { BasicRepresentation } from '../../../src/ldp/representation/BasicRepresentation'; import { BasicRepresentation } from '../../../src/ldp/representation/BasicRepresentation';
import type { Representation } from '../../../src/ldp/representation/Representation'; import type { Representation } from '../../../src/ldp/representation/Representation';
import { RepresentationMetadata } from '../../../src/ldp/representation/RepresentationMetadata';
import type { HttpRequest } from '../../../src/server/HttpRequest'; import type { HttpRequest } from '../../../src/server/HttpRequest';
import type { HttpResponse } from '../../../src/server/HttpResponse'; import type { HttpResponse } from '../../../src/server/HttpResponse';
import { getBestPreference } from '../../../src/storage/conversion/ConversionUtil';
import type { import type {
RepresentationConverter, RepresentationConverter,
RepresentationConverterArgs, RepresentationConverterArgs,
@ -20,7 +22,7 @@ import { BadRequestHttpError } from '../../../src/util/errors/BadRequestHttpErro
import { InternalServerError } from '../../../src/util/errors/InternalServerError'; import { InternalServerError } from '../../../src/util/errors/InternalServerError';
import { joinUrl } from '../../../src/util/PathUtil'; import { joinUrl } from '../../../src/util/PathUtil';
import { readableToString } from '../../../src/util/StreamUtil'; 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 => { describe('An IdentityProviderHttpHandler', (): void => {
const apiVersion = '0.1'; const apiVersion = '0.1';
@ -45,7 +47,9 @@ describe('An IdentityProviderHttpHandler', (): void => {
handleSafe: jest.fn(async(req: HttpRequest): Promise<Operation> => ({ handleSafe: jest.fn(async(req: HttpRequest): Promise<Operation> => ({
target: { path: joinUrl(baseUrl, req.url!) }, target: { path: joinUrl(baseUrl, req.url!) },
method: req.method!, 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 }}, preferences: { type: { 'text/html': 1 }},
})), })),
} as any; } as any;
@ -77,7 +81,12 @@ describe('An IdentityProviderHttpHandler', (): void => {
}; };
converter = { 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; } as any;
interactionCompleter = { handleSafe: jest.fn().mockResolvedValue('http://test.com/idp/auth') } 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!))) expect(JSON.parse(await readableToString(result.data!)))
.toEqual({ apiVersion, errorMessage: '', prefilled: {}, authenticating: false }); .toEqual({ apiVersion, errorMessage: '', prefilled: {}, authenticating: false });
expect(result.statusCode).toBe(200); 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']); 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.url = '/idp/routeResponse';
request.method = 'POST'; request.method = 'POST';
await expect(handler.handle({ request, response })).resolves.toBeUndefined(); 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).toHaveBeenCalledTimes(1);
expect(routes.response.handler.handleSafe).toHaveBeenLastCalledWith({ operation }); expect(routes.response.handler.handleSafe).toHaveBeenLastCalledWith({ operation });
expect(operation.body?.metadata.contentType).toBe('application/json');
expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1); expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1);
const { response: mockResponse, result } = responseWriter.handleSafe.mock.calls[0][0]; const { response: mockResponse, result } = responseWriter.handleSafe.mock.calls[0][0];
expect(mockResponse).toBe(response); expect(mockResponse).toBe(response);
expect(JSON.parse(await readableToString(result.data!))).toEqual({ apiVersion, key: 'val', authenticating: false }); expect(JSON.parse(await readableToString(result.data!))).toEqual({ apiVersion, key: 'val', authenticating: false });
expect(result.statusCode).toBe(200); 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']); 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'; request.method = 'POST';
errorHandler.handleSafe.mockResolvedValueOnce({ statusCode: 400 }); errorHandler.handleSafe.mockResolvedValueOnce({ statusCode: 400 });
await expect(handler.handle({ request, response })).resolves.toBeUndefined(); 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).toHaveBeenCalledTimes(1);
expect(routes.complete.handler.handleSafe).toHaveBeenLastCalledWith({ operation }); expect(routes.complete.handler.handleSafe).toHaveBeenLastCalledWith({ operation });
expect(interactionCompleter.handleSafe).toHaveBeenCalledTimes(0); expect(interactionCompleter.handleSafe).toHaveBeenCalledTimes(0);
expect(operation.body?.metadata.contentType).toBe('application/json');
const error = expect.objectContaining({ const error = expect.objectContaining({
message: 'This action can only be performed as part of an OIDC authentication flow.', 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; const oidcInteraction = { session: { accountId: 'account' }} as any;
provider.interactionDetails.mockResolvedValueOnce(oidcInteraction); provider.interactionDetails.mockResolvedValueOnce(oidcInteraction);
await expect(handler.handle({ request, response })).resolves.toBeUndefined(); 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).toHaveBeenCalledTimes(1);
expect(routes.complete.handler.handleSafe).toHaveBeenLastCalledWith({ operation, oidcInteraction }); expect(routes.complete.handler.handleSafe).toHaveBeenLastCalledWith({ operation, oidcInteraction });
expect(operation.body?.metadata.contentType).toBe('application/json');
expect(interactionCompleter.handleSafe).toHaveBeenCalledTimes(1); expect(interactionCompleter.handleSafe).toHaveBeenCalledTimes(1);
expect(interactionCompleter.handleSafe).toHaveBeenLastCalledWith({ request, webId: 'webId' }); expect(interactionCompleter.handleSafe).toHaveBeenLastCalledWith({ request, webId: 'webId' });
const location = await interactionCompleter.handleSafe.mock.results[0].value; const location = await interactionCompleter.handleSafe.mock.results[0].value;
@ -195,10 +208,11 @@ describe('An IdentityProviderHttpHandler', (): void => {
const oidcInteraction = { prompt: { name: 'other' }}; const oidcInteraction = { prompt: { name: 'other' }};
provider.interactionDetails.mockResolvedValueOnce(oidcInteraction as any); provider.interactionDetails.mockResolvedValueOnce(oidcInteraction as any);
await expect(handler.handle({ request, response })).resolves.toBeUndefined(); 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.response.handler.handleSafe).toHaveBeenCalledTimes(0);
expect(routes.complete.handler.handleSafe).toHaveBeenCalledTimes(1); expect(routes.complete.handler.handleSafe).toHaveBeenCalledTimes(1);
expect(routes.complete.handler.handleSafe).toHaveBeenLastCalledWith({ operation, oidcInteraction }); 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<void> => { it('uses the default route for requests to the root IDP without (matching) prompt.', async(): Promise<void> => {
@ -207,9 +221,10 @@ describe('An IdentityProviderHttpHandler', (): void => {
const oidcInteraction = { prompt: { name: 'notSupported' }}; const oidcInteraction = { prompt: { name: 'notSupported' }};
provider.interactionDetails.mockResolvedValueOnce(oidcInteraction as any); provider.interactionDetails.mockResolvedValueOnce(oidcInteraction as any);
await expect(handler.handle({ request, response })).resolves.toBeUndefined(); 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).toHaveBeenCalledTimes(1);
expect(routes.response.handler.handleSafe).toHaveBeenLastCalledWith({ operation, oidcInteraction }); expect(routes.response.handler.handleSafe).toHaveBeenLastCalledWith({ operation, oidcInteraction });
expect(operation.body?.metadata.contentType).toBe('application/json');
expect(routes.complete.handler.handleSafe).toHaveBeenCalledTimes(0); expect(routes.complete.handler.handleSafe).toHaveBeenCalledTimes(0);
}); });
@ -226,7 +241,7 @@ describe('An IdentityProviderHttpHandler', (): void => {
expect(JSON.parse(await readableToString(result.data!))) expect(JSON.parse(await readableToString(result.data!)))
.toEqual({ apiVersion, errorMessage: 'handle error', prefilled: { name: 'name' }, authenticating: false }); .toEqual({ apiVersion, errorMessage: 'handle error', prefilled: { name: 'name' }, authenticating: false });
expect(result.statusCode).toBe(200); 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']); expect(result.metadata?.get(SOLID_META.template)?.value).toBe(routes.response.viewTemplates['text/html']);
}); });

View File

@ -1,7 +1,7 @@
import type { Interaction } from '../../../../src/identity/interaction/email-password/handler/InteractionHandler'; import type { Interaction } from '../../../../src/identity/interaction/email-password/handler/InteractionHandler';
import { SessionHttpHandler } from '../../../../src/identity/interaction/SessionHttpHandler'; import { SessionHttpHandler } from '../../../../src/identity/interaction/SessionHttpHandler';
import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; 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 => { describe('A SessionHttpHandler', (): void => {
const webId = 'http://test.com/id#me'; const webId = 'http://test.com/id#me';
@ -22,7 +22,7 @@ describe('A SessionHttpHandler', (): void => {
}); });
it('returns an InteractionCompleteResult when done.', async(): Promise<void> => { it('returns an InteractionCompleteResult when done.', async(): Promise<void> => {
const operation = createPostFormOperation({ remember: true }); const operation = createPostJsonOperation({ remember: true });
await expect(handler.handle({ operation, oidcInteraction })).resolves.toEqual({ await expect(handler.handle({ operation, oidcInteraction })).resolves.toEqual({
details: { webId, shouldRemember: true }, details: { webId, shouldRemember: true },
type: 'complete', type: 'complete',

View File

@ -5,7 +5,7 @@ import type { AccountStore } from '../../../../../../src/identity/interaction/em
import type { EmailSender } from '../../../../../../src/identity/interaction/util/EmailSender'; import type { EmailSender } from '../../../../../../src/identity/interaction/util/EmailSender';
import type { Operation } from '../../../../../../src/ldp/operations/Operation'; import type { Operation } from '../../../../../../src/ldp/operations/Operation';
import type { TemplateEngine } from '../../../../../../src/util/templates/TemplateEngine'; import type { TemplateEngine } from '../../../../../../src/util/templates/TemplateEngine';
import { createPostFormOperation } from './Util'; import { createPostJsonOperation } from './Util';
describe('A ForgotPasswordHandler', (): void => { describe('A ForgotPasswordHandler', (): void => {
let operation: Operation; let operation: Operation;
@ -20,7 +20,7 @@ describe('A ForgotPasswordHandler', (): void => {
let handler: ForgotPasswordHandler; let handler: ForgotPasswordHandler;
beforeEach(async(): Promise<void> => { beforeEach(async(): Promise<void> => {
operation = createPostFormOperation({ email }); operation = createPostJsonOperation({ email });
accountStore = { accountStore = {
generateForgotPasswordRecord: jest.fn().mockResolvedValue(recordId), generateForgotPasswordRecord: jest.fn().mockResolvedValue(recordId),
@ -44,9 +44,9 @@ describe('A ForgotPasswordHandler', (): void => {
}); });
it('errors on non-string emails.', async(): Promise<void> => { it('errors on non-string emails.', async(): Promise<void> => {
operation = createPostFormOperation({}); operation = createPostJsonOperation({});
await expect(handler.handle({ operation })).rejects.toThrow('Email required'); 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'); await expect(handler.handle({ operation })).rejects.toThrow('Email required');
}); });

View File

@ -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<InteractionResponseResult> {
return { type: 'response' };
}
}
describe('An InteractionHandler', (): void => {
const handler = new SimpleInteractionHandler();
it('only supports JSON data.', async(): Promise<void> => {
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);
});
});

View File

@ -3,7 +3,7 @@ import type {
} from '../../../../../../src/identity/interaction/email-password/handler/InteractionHandler'; } from '../../../../../../src/identity/interaction/email-password/handler/InteractionHandler';
import { LoginHandler } from '../../../../../../src/identity/interaction/email-password/handler/LoginHandler'; import { LoginHandler } from '../../../../../../src/identity/interaction/email-password/handler/LoginHandler';
import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore'; import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore';
import { createPostFormOperation } from './Util'; import { createPostJsonOperation } from './Util';
describe('A LoginHandler', (): void => { describe('A LoginHandler', (): void => {
const webId = 'http://alice.test.com/card#me'; const webId = 'http://alice.test.com/card#me';
@ -23,29 +23,29 @@ describe('A LoginHandler', (): void => {
}); });
it('errors on invalid emails.', async(): Promise<void> => { it('errors on invalid emails.', async(): Promise<void> => {
input.operation = createPostFormOperation({}); input.operation = createPostJsonOperation({});
let prom = handler.handle(input); let prom = handler.handle(input);
await expect(prom).rejects.toThrow('Email required'); await expect(prom).rejects.toThrow('Email required');
await expect(prom).rejects.toThrow(expect.objectContaining({ prefilled: {}})); await expect(prom).rejects.toThrow(expect.objectContaining({ prefilled: {}}));
input.operation = createPostFormOperation({ email: [ 'a', 'b' ]}); input.operation = createPostJsonOperation({ email: [ 'a', 'b' ]});
prom = handler.handle(input); prom = handler.handle(input);
await expect(prom).rejects.toThrow('Email required'); await expect(prom).rejects.toThrow('Email required');
await expect(prom).rejects.toThrow(expect.objectContaining({ prefilled: { }})); await expect(prom).rejects.toThrow(expect.objectContaining({ prefilled: { }}));
}); });
it('errors on invalid passwords.', async(): Promise<void> => { it('errors on invalid passwords.', async(): Promise<void> => {
input.operation = createPostFormOperation({ email }); input.operation = createPostJsonOperation({ email });
let prom = handler.handle(input); let prom = handler.handle(input);
await expect(prom).rejects.toThrow('Password required'); await expect(prom).rejects.toThrow('Password required');
await expect(prom).rejects.toThrow(expect.objectContaining({ prefilled: { email }})); 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); prom = handler.handle(input);
await expect(prom).rejects.toThrow('Password required'); await expect(prom).rejects.toThrow('Password required');
await expect(prom).rejects.toThrow(expect.objectContaining({ prefilled: { email }})); await expect(prom).rejects.toThrow(expect.objectContaining({ prefilled: { email }}));
}); });
it('throws an IdpInteractionError if there is a problem.', async(): Promise<void> => { it('throws an IdpInteractionError if there is a problem.', async(): Promise<void> => {
input.operation = createPostFormOperation({ email, password: 'password!' }); input.operation = createPostJsonOperation({ email, password: 'password!' });
(storageAdapter.authenticate as jest.Mock).mockRejectedValueOnce(new Error('auth failed!')); (storageAdapter.authenticate as jest.Mock).mockRejectedValueOnce(new Error('auth failed!'));
const prom = handler.handle(input); const prom = handler.handle(input);
await expect(prom).rejects.toThrow('auth failed!'); await expect(prom).rejects.toThrow('auth failed!');
@ -53,7 +53,7 @@ describe('A LoginHandler', (): void => {
}); });
it('returns an InteractionCompleteResult when done.', async(): Promise<void> => { it('returns an InteractionCompleteResult when done.', async(): Promise<void> => {
input.operation = createPostFormOperation({ email, password: 'password!' }); input.operation = createPostJsonOperation({ email, password: 'password!' });
await expect(handler.handle(input)).resolves.toEqual({ await expect(handler.handle(input)).resolves.toEqual({
type: 'complete', type: 'complete',
details: { webId, shouldRemember: false }, details: { webId, shouldRemember: false },

View File

@ -10,7 +10,7 @@ import type { IdentifierGenerator } from '../../../../../../src/pods/generate/Id
import type { PodManager } from '../../../../../../src/pods/PodManager'; import type { PodManager } from '../../../../../../src/pods/PodManager';
import type { PodSettings } from '../../../../../../src/pods/settings/PodSettings'; import type { PodSettings } from '../../../../../../src/pods/settings/PodSettings';
import { joinUrl } from '../../../../../../src/util/PathUtil'; import { joinUrl } from '../../../../../../src/util/PathUtil';
import { createPostFormOperation } from './Util'; import { createPostJsonOperation } from './Util';
describe('A RegistrationHandler', (): void => { describe('A RegistrationHandler', (): void => {
// "Correct" values for easy object creation // "Correct" values for easy object creation
@ -20,10 +20,9 @@ describe('A RegistrationHandler', (): void => {
const confirmPassword = password; const confirmPassword = password;
const podName = 'alice'; const podName = 'alice';
const podBaseUrl = 'http://test.com/alice/'; const podBaseUrl = 'http://test.com/alice/';
// Strings instead of booleans because this is form data const createWebId = true;
const createWebId = 'true'; const register = true;
const register = 'true'; const createPod = true;
const createPod = 'true';
let operation: Operation; let operation: Operation;
@ -69,71 +68,71 @@ describe('A RegistrationHandler', (): void => {
describe('validating data', (): void => { describe('validating data', (): void => {
it('rejects array inputs.', async(): Promise<void> => { it('rejects array inputs.', async(): Promise<void> => {
operation = createPostFormOperation({ mydata: [ 'a', 'b' ]}); operation = createPostJsonOperation({ mydata: [ 'a', 'b' ]});
await expect(handler.handle({ operation })) await expect(handler.handle({ operation }))
.rejects.toThrow('Unexpected multiple values for mydata.'); .rejects.toThrow('Unexpected multiple values for mydata.');
}); });
it('errors on invalid emails.', async(): Promise<void> => { it('errors on invalid emails.', async(): Promise<void> => {
operation = createPostFormOperation({ email: undefined }); operation = createPostJsonOperation({ email: undefined });
await expect(handler.handle({ operation })) await expect(handler.handle({ operation }))
.rejects.toThrow('Please enter a valid e-mail address.'); .rejects.toThrow('Please enter a valid e-mail address.');
operation = createPostFormOperation({ email: '' }); operation = createPostJsonOperation({ email: '' });
await expect(handler.handle({ operation })) await expect(handler.handle({ operation }))
.rejects.toThrow('Please enter a valid e-mail address.'); .rejects.toThrow('Please enter a valid e-mail address.');
operation = createPostFormOperation({ email: 'invalidEmail' }); operation = createPostJsonOperation({ email: 'invalidEmail' });
await expect(handler.handle({ operation })) await expect(handler.handle({ operation }))
.rejects.toThrow('Please enter a valid e-mail address.'); .rejects.toThrow('Please enter a valid e-mail address.');
}); });
it('errors when a required WebID is not valid.', async(): Promise<void> => { it('errors when a required WebID is not valid.', async(): Promise<void> => {
operation = createPostFormOperation({ email, register, webId: undefined }); operation = createPostJsonOperation({ email, register, webId: undefined });
await expect(handler.handle({ operation })) await expect(handler.handle({ operation }))
.rejects.toThrow('Please enter a valid WebID.'); .rejects.toThrow('Please enter a valid WebID.');
operation = createPostFormOperation({ email, register, webId: '' }); operation = createPostJsonOperation({ email, register, webId: '' });
await expect(handler.handle({ operation })) await expect(handler.handle({ operation }))
.rejects.toThrow('Please enter a valid WebID.'); .rejects.toThrow('Please enter a valid WebID.');
}); });
it('errors on invalid passwords when registering.', async(): Promise<void> => { it('errors on invalid passwords when registering.', async(): Promise<void> => {
operation = createPostFormOperation({ email, webId, password, confirmPassword: 'bad', register }); operation = createPostJsonOperation({ email, webId, password, confirmPassword: 'bad', register });
await expect(handler.handle({ operation })) await expect(handler.handle({ operation }))
.rejects.toThrow('Your password and confirmation did not match.'); .rejects.toThrow('Your password and confirmation did not match.');
}); });
it('errors on invalid pod names when required.', async(): Promise<void> => { it('errors on invalid pod names when required.', async(): Promise<void> => {
operation = createPostFormOperation({ email, webId, createPod, podName: undefined }); operation = createPostJsonOperation({ email, webId, createPod, podName: undefined });
await expect(handler.handle({ operation })) await expect(handler.handle({ operation }))
.rejects.toThrow('Please specify a Pod name.'); .rejects.toThrow('Please specify a Pod name.');
operation = createPostFormOperation({ email, webId, createPod, podName: ' ' }); operation = createPostJsonOperation({ email, webId, createPod, podName: ' ' });
await expect(handler.handle({ operation })) await expect(handler.handle({ operation }))
.rejects.toThrow('Please specify a Pod name.'); .rejects.toThrow('Please specify a Pod name.');
operation = createPostFormOperation({ email, webId, createWebId }); operation = createPostJsonOperation({ email, webId, createWebId });
await expect(handler.handle({ operation })) await expect(handler.handle({ operation }))
.rejects.toThrow('Please specify a Pod name.'); .rejects.toThrow('Please specify a Pod name.');
}); });
it('errors when trying to create a WebID without registering or creating a pod.', async(): Promise<void> => { it('errors when trying to create a WebID without registering or creating a pod.', async(): Promise<void> => {
operation = createPostFormOperation({ email, podName, createWebId }); operation = createPostJsonOperation({ email, podName, createWebId });
await expect(handler.handle({ operation })) await expect(handler.handle({ operation }))
.rejects.toThrow('Please enter a password.'); .rejects.toThrow('Please enter a password.');
operation = createPostFormOperation({ email, podName, createWebId, createPod }); operation = createPostJsonOperation({ email, podName, createWebId, createPod });
await expect(handler.handle({ operation })) await expect(handler.handle({ operation }))
.rejects.toThrow('Please enter a password.'); .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 })) await expect(handler.handle({ operation }))
.rejects.toThrow('Please enter a password.'); .rejects.toThrow('Please enter a password.');
}); });
it('errors when no option is chosen.', async(): Promise<void> => { it('errors when no option is chosen.', async(): Promise<void> => {
operation = createPostFormOperation({ email, webId }); operation = createPostJsonOperation({ email, webId });
await expect(handler.handle({ operation })) await expect(handler.handle({ operation }))
.rejects.toThrow('Please register for a WebID or create a Pod.'); .rejects.toThrow('Please register for a WebID or create a Pod.');
}); });
@ -141,7 +140,7 @@ describe('A RegistrationHandler', (): void => {
describe('handling data', (): void => { describe('handling data', (): void => {
it('can register a user.', async(): Promise<void> => { it('can register a user.', async(): Promise<void> => {
operation = createPostFormOperation({ email, webId, password, confirmPassword, register }); operation = createPostJsonOperation({ email, webId, password, confirmPassword, register });
await expect(handler.handle({ operation })).resolves.toEqual({ await expect(handler.handle({ operation })).resolves.toEqual({
details: { details: {
email, email,
@ -168,7 +167,7 @@ describe('A RegistrationHandler', (): void => {
it('can create a pod.', async(): Promise<void> => { it('can create a pod.', async(): Promise<void> => {
const params = { email, webId, podName, createPod }; const params = { email, webId, podName, createPod };
operation = createPostFormOperation(params); operation = createPostJsonOperation(params);
await expect(handler.handle({ operation })).resolves.toEqual({ await expect(handler.handle({ operation })).resolves.toEqual({
details: { details: {
email, 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<void> => { it('adds an oidcIssuer to the data when doing both IDP registration and pod creation.', async(): Promise<void> => {
const params = { email, webId, password, confirmPassword, podName, register, createPod }; const params = { email, webId, password, confirmPassword, podName, register, createPod };
podSettings.oidcIssuer = baseUrl; podSettings.oidcIssuer = baseUrl;
operation = createPostFormOperation(params); operation = createPostJsonOperation(params);
await expect(handler.handle({ operation })).resolves.toEqual({ await expect(handler.handle({ operation })).resolves.toEqual({
details: { details: {
email, email,
@ -228,7 +227,7 @@ describe('A RegistrationHandler', (): void => {
it('deletes the created account if pod generation fails.', async(): Promise<void> => { it('deletes the created account if pod generation fails.', async(): Promise<void> => {
const params = { email, webId, password, confirmPassword, podName, register, createPod }; const params = { email, webId, password, confirmPassword, podName, register, createPod };
podSettings.oidcIssuer = baseUrl; podSettings.oidcIssuer = baseUrl;
operation = createPostFormOperation(params); operation = createPostJsonOperation(params);
(podManager.createPod as jest.Mock).mockRejectedValueOnce(new Error('pod error')); (podManager.createPod as jest.Mock).mockRejectedValueOnce(new Error('pod error'));
await expect(handler.handle({ operation })).rejects.toThrow('pod error'); await expect(handler.handle({ operation })).rejects.toThrow('pod error');
@ -252,7 +251,7 @@ describe('A RegistrationHandler', (): void => {
podSettings.webId = generatedWebID; podSettings.webId = generatedWebID;
podSettings.oidcIssuer = baseUrl; podSettings.oidcIssuer = baseUrl;
operation = createPostFormOperation(params); operation = createPostJsonOperation(params);
await expect(handler.handle({ operation })).resolves.toEqual({ await expect(handler.handle({ operation })).resolves.toEqual({
details: { details: {
email, email,
@ -281,7 +280,7 @@ describe('A RegistrationHandler', (): void => {
it('throws an IdpInteractionError with all data prefilled if something goes wrong.', async(): Promise<void> => { it('throws an IdpInteractionError with all data prefilled if something goes wrong.', async(): Promise<void> => {
const params = { email, webId, podName, createPod }; const params = { email, webId, podName, createPod };
operation = createPostFormOperation(params); operation = createPostJsonOperation(params);
(podManager.createPod as jest.Mock).mockRejectedValueOnce(new Error('pod error')); (podManager.createPod as jest.Mock).mockRejectedValueOnce(new Error('pod error'));
const prom = handler.handle({ operation }); const prom = handler.handle({ operation });
await expect(prom).rejects.toThrow('pod error'); await expect(prom).rejects.toThrow('pod error');

View File

@ -3,7 +3,7 @@ import {
} from '../../../../../../src/identity/interaction/email-password/handler/ResetPasswordHandler'; } from '../../../../../../src/identity/interaction/email-password/handler/ResetPasswordHandler';
import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore'; import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore';
import type { Operation } from '../../../../../../src/ldp/operations/Operation'; import type { Operation } from '../../../../../../src/ldp/operations/Operation';
import { createPostFormOperation } from './Util'; import { createPostJsonOperation } from './Util';
describe('A ResetPasswordHandler', (): void => { describe('A ResetPasswordHandler', (): void => {
let operation: Operation; let operation: Operation;
@ -25,27 +25,27 @@ describe('A ResetPasswordHandler', (): void => {
it('errors for non-string recordIds.', async(): Promise<void> => { it('errors for non-string recordIds.', async(): Promise<void> => {
const errorMessage = 'Invalid request. Open the link from your email again'; const errorMessage = 'Invalid request. Open the link from your email again';
operation = createPostFormOperation({}); operation = createPostJsonOperation({});
await expect(handler.handle({ operation })).rejects.toThrow(errorMessage); await expect(handler.handle({ operation })).rejects.toThrow(errorMessage);
operation = createPostFormOperation({}, ''); operation = createPostJsonOperation({}, '');
await expect(handler.handle({ operation })).rejects.toThrow(errorMessage); await expect(handler.handle({ operation })).rejects.toThrow(errorMessage);
}); });
it('errors for invalid passwords.', async(): Promise<void> => { it('errors for invalid passwords.', async(): Promise<void> => {
const errorMessage = 'Your password and confirmation did not match.'; 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); await expect(handler.handle({ operation })).rejects.toThrow(errorMessage);
}); });
it('errors for invalid emails.', async(): Promise<void> => { it('errors for invalid emails.', async(): Promise<void> => {
const errorMessage = 'This reset password link is no longer valid.'; 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); (accountStore.getForgotPasswordRecord as jest.Mock).mockResolvedValueOnce(undefined);
await expect(handler.handle({ operation })).rejects.toThrow(errorMessage); await expect(handler.handle({ operation })).rejects.toThrow(errorMessage);
}); });
it('renders a message on success.', async(): Promise<void> => { it('renders a message on success.', async(): Promise<void> => {
operation = createPostFormOperation({ password: 'password!', confirmPassword: 'password!' }, url); operation = createPostJsonOperation({ password: 'password!', confirmPassword: 'password!' }, url);
await expect(handler.handle({ operation })).resolves.toEqual({ type: 'response' }); await expect(handler.handle({ operation })).resolves.toEqual({ type: 'response' });
expect(accountStore.getForgotPasswordRecord).toHaveBeenCalledTimes(1); expect(accountStore.getForgotPasswordRecord).toHaveBeenCalledTimes(1);
expect(accountStore.getForgotPasswordRecord).toHaveBeenLastCalledWith(recordId); expect(accountStore.getForgotPasswordRecord).toHaveBeenLastCalledWith(recordId);
@ -57,7 +57,7 @@ describe('A ResetPasswordHandler', (): void => {
it('has a default error for non-native errors.', async(): Promise<void> => { it('has a default error for non-native errors.', async(): Promise<void> => {
const errorMessage = 'Unknown error: not native'; 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'); (accountStore.getForgotPasswordRecord as jest.Mock).mockRejectedValueOnce('not native');
await expect(handler.handle({ operation })).rejects.toThrow(errorMessage); await expect(handler.handle({ operation })).rejects.toThrow(errorMessage);
}); });

View File

@ -1,18 +1,17 @@
import { stringify } from 'querystring';
import type { Operation } from '../../../../../../src/ldp/operations/Operation'; import type { Operation } from '../../../../../../src/ldp/operations/Operation';
import { BasicRepresentation } from '../../../../../../src/ldp/representation/BasicRepresentation'; 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. * and a matching content-type header.
* @param data - Object to encode. * @param data - Object to encode.
* @param url - URL value of the request. * @param url - URL value of the request.
*/ */
export function createPostFormOperation(data: NodeJS.Dict<any>, url?: string): Operation { export function createPostJsonOperation(data: NodeJS.Dict<any>, url?: string): Operation {
return { return {
method: 'POST', method: 'POST',
preferences: {}, preferences: {},
target: { path: url ?? 'http://test.com/' }, target: { path: url ?? 'http://test.com/' },
body: new BasicRepresentation(stringify(data), 'application/x-www-form-urlencoded'), body: new BasicRepresentation(JSON.stringify(data), 'application/json'),
}; };
} }

View File

@ -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<void> => {
await expect(getFormDataRequestBody({ headers: { 'content-type': 'text/turtle' }} as any))
.rejects.toThrow(UnsupportedMediaTypeHttpError);
});
it('converts the body to an object.', async(): Promise<void> => {
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);
});
});
});

View File

@ -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<void> => {
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<void> => {
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' });
});
});

View File

@ -4,8 +4,10 @@ import { Quad, NamedNode, Literal, BlankNode, Store } from 'n3';
import type { Logger } from '../../../src/logging/Logger'; import type { Logger } from '../../../src/logging/Logger';
import { getLoggerFor } from '../../../src/logging/LogUtil'; import { getLoggerFor } from '../../../src/logging/LogUtil';
import { isHttpRequest } from '../../../src/server/HttpRequest'; import { isHttpRequest } from '../../../src/server/HttpRequest';
import { guardedStreamFrom, pipeSafely, transformSafely, import {
readableToString, readableToQuads } from '../../../src/util/StreamUtil'; guardedStreamFrom, pipeSafely, transformSafely,
readableToString, readableToQuads, readJsonStream,
} from '../../../src/util/StreamUtil';
jest.mock('../../../src/logging/LogUtil', (): any => { jest.mock('../../../src/logging/LogUtil', (): any => {
const logger: Logger = { warn: jest.fn(), log: jest.fn() } as 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<void> => {
const stream = Readable.from('{ "key": "value" }');
await expect(readJsonStream(stream)).resolves.toEqual({ key: 'value' });
});
});
describe('#pipeSafely', (): void => { describe('#pipeSafely', (): void => {
beforeEach(async(): Promise<void> => { beforeEach(async(): Promise<void> => {
jest.clearAllMocks(); jest.clearAllMocks();