refactor: Align EJS engine with Handlebars.

This commit is contained in:
Ruben Verborgh 2021-07-20 19:39:28 +02:00 committed by Joachim Van Herwegen
parent 19624dc729
commit 9628fe98b8
40 changed files with 215 additions and 266 deletions

View File

@ -5,5 +5,6 @@
"EventEmitter",
"HttpErrorOptions",
"Template",
"TemplateEngine",
"ValuePreferencesArg"
]

View File

@ -62,7 +62,6 @@ Additional recipes for configuring and deploying the server can be found at [sol
| `--sparqlEndpoint, -s` | | Endpoint to call when using a SPARQL-based config. |
| `--showStackTrace, -t` | false | Whether error stack traces should be shown in responses. |
| `--podConfigJson` | `"./pod-config.json"` | JSON file to store pod configuration when using a dynamic config. |
| `--idpTemplateFolder` | `"templates/idp"` | Folder containing the templates used for IDP interactions. |
## Using the identity provider

View File

@ -12,10 +12,9 @@
"args_accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" },
"args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
"args_idpPath": "/idp",
"args_emailTemplateRenderer": {
"@type": "EjsTemplateRenderer",
"templatePath": { "@id": "urn:solid-server:default:variable:idpTemplateFolder" },
"templateFile": "./email-password-interaction/resetPasswordEmail.ejs"
"args_templateEngine": {
"@type": "EjsTemplateEngine",
"template": "$PACKAGE_ROOT/templates/idp/email-password-interaction/resetPasswordEmail.ejs"
},
"args_emailSender": { "@id": "urn:solid-server:default:EmailSender" }
},
@ -25,16 +24,20 @@
{
"comment": "Renders the Email Sent message page",
"@id": "urn:solid-server:auth:password:EmailSentRenderHandler",
"@type": "RenderEjsHandler",
"templatePath": { "@id": "urn:solid-server:default:variable:idpTemplateFolder" },
"templateFile": "./email-password-interaction/emailSent.ejs"
"@type": "TemplateHandler",
"templateEngine": {
"@type": "EjsTemplateEngine",
"template": "$PACKAGE_ROOT/templates/idp/email-password-interaction/emailSent.ejs"
}
},
{
"comment": "Renders the forgot password page",
"@id": "urn:solid-server:auth:password:ForgotPasswordRenderHandler",
"@type": "RenderEjsHandler",
"templatePath": { "@id": "urn:solid-server:default:variable:idpTemplateFolder" },
"templateFile": "./email-password-interaction/forgotPassword.ejs"
"@type": "TemplateHandler",
"templateEngine": {
"@type": "EjsTemplateEngine",
"template": "$PACKAGE_ROOT/templates/idp/email-password-interaction/forgotPassword.ejs"
}
}
]
}

View File

@ -13,9 +13,11 @@
{
"InitialInteractionHandler:_renderHandlerMap_key": "consent",
"InitialInteractionHandler:_renderHandlerMap_value": {
"@type": "RenderEjsHandler",
"templatePath": { "@id": "urn:solid-server:default:variable:idpTemplateFolder" },
"templateFile": "./email-password-interaction/confirm.ejs"
"@type": "TemplateHandler",
"templateEngine": {
"@type": "EjsTemplateEngine",
"template": "$PACKAGE_ROOT/templates/idp/email-password-interaction/confirm.ejs"
}
}
}
],

View File

@ -17,9 +17,11 @@
{
"comment": "Renders the login page",
"@id": "urn:solid-server:auth:password:LoginRenderHandler",
"@type": "RenderEjsHandler",
"templatePath": { "@id": "urn:solid-server:default:variable:idpTemplateFolder" },
"templateFile": "./email-password-interaction/login.ejs"
"@type": "TemplateHandler",
"templateEngine": {
"@type": "EjsTemplateEngine",
"template": "$PACKAGE_ROOT/templates/idp/email-password-interaction/login.ejs"
}
}
]
}

View File

@ -22,16 +22,20 @@
{
"comment": "Renders the register page",
"@id": "urn:solid-server:auth:password:RegisterRenderHandler",
"@type": "RenderEjsHandler",
"templatePath": { "@id": "urn:solid-server:default:variable:idpTemplateFolder" },
"templateFile": "./email-password-interaction/register.ejs"
"@type": "TemplateHandler",
"templateEngine": {
"@type": "EjsTemplateEngine",
"template": "$PACKAGE_ROOT/templates/idp/email-password-interaction/register.ejs"
}
},
{
"comment": "Renders the successful registration page",
"@id": "urn:solid-server:auth:password:RegisterResponseRenderHandler",
"@type": "RenderEjsHandler",
"templatePath": { "@id": "urn:solid-server:default:variable:idpTemplateFolder" },
"templateFile": "./email-password-interaction/registerResponse.ejs"
"@type": "TemplateHandler",
"templateEngine": {
"@type": "EjsTemplateEngine",
"template": "$PACKAGE_ROOT/templates/idp/email-password-interaction/registerResponse.ejs"
}
}
]
}

View File

@ -30,16 +30,20 @@
{
"comment": "Renders the reset password page",
"@id": "urn:solid-server:auth:password:ResetPasswordRenderHandler",
"@type": "RenderEjsHandler",
"templatePath": { "@id": "urn:solid-server:default:variable:idpTemplateFolder" },
"templateFile": "./email-password-interaction/resetPassword.ejs"
"@type": "TemplateHandler",
"templateEngine": {
"@type": "EjsTemplateEngine",
"template": "$PACKAGE_ROOT/templates/idp/email-password-interaction/resetPassword.ejs"
}
},
{
"comment": "Renders a generic page that says a message",
"@id": "urn:solid-server:auth:password:MessageRenderHandler",
"@type": "RenderEjsHandler",
"templatePath": { "@id": "urn:solid-server:default:variable:idpTemplateFolder" },
"templateFile": "./email-password-interaction/message.ejs"
"@type": "TemplateHandler",
"templateEngine": {
"@type": "EjsTemplateEngine",
"template": "$PACKAGE_ROOT/templates/idp/email-password-interaction/message.ejs"
}
}
]
}

View File

@ -30,7 +30,7 @@
"@type": "ErrorToTemplateConverter",
"templateEngine": {
"@type": "HandlebarsTemplateEngine",
"template": { "templateFile": "$PACKAGE_ROOT/templates/error/main.md" }
"template": "$PACKAGE_ROOT/templates/error/main.md"
},
"templatePath": "$PACKAGE_ROOT/templates/error/descriptions/",
"extension": ".md",
@ -42,7 +42,7 @@
"templateEngine": {
"@id": "urn:solid-server:default:MainTemplateEngine",
"@type": "HandlebarsTemplateEngine",
"template": { "templateFile": "$PACKAGE_ROOT/templates/main.html" }
"template": "$PACKAGE_ROOT/templates/main.html"
}
}
]

View File

@ -36,11 +36,6 @@
"comment": "Path to the JSON file used to store configuration for dynamic pods.",
"@id": "urn:solid-server:default:variable:podConfigJson",
"@type": "Variable"
},
{
"comment": "Folder containing the templates used for IDP interactions.",
"@id": "urn:solid-server:default:variable:idpTemplateFolder",
"@type": "Variable"
}
]
}

View File

@ -3,12 +3,12 @@ import urljoin from 'url-join';
import { getLoggerFor } from '../../../../logging/LogUtil';
import type { HttpResponse } from '../../../../server/HttpResponse';
import { ensureTrailingSlash } from '../../../../util/PathUtil';
import type { TemplateEngine } from '../../../../util/templates/TemplateEngine';
import type { InteractionHttpHandlerInput } from '../../InteractionHttpHandler';
import { InteractionHttpHandler } from '../../InteractionHttpHandler';
import type { EmailSender } from '../../util/EmailSender';
import { getFormDataRequestBody } from '../../util/FormDataUtil';
import type { IdpRenderHandler } from '../../util/IdpRenderHandler';
import type { TemplateRenderer } from '../../util/TemplateRenderer';
import { throwIdpInteractionError } from '../EmailPasswordUtil';
import type { AccountStore } from '../storage/AccountStore';
@ -17,7 +17,7 @@ export interface ForgotPasswordHandlerArgs {
accountStore: AccountStore;
baseUrl: string;
idpPath: string;
emailTemplateRenderer: TemplateRenderer<{ resetLink: string }>;
templateEngine: TemplateEngine<{ resetLink: string }>;
emailSender: EmailSender;
}
@ -31,7 +31,7 @@ export class ForgotPasswordHandler extends InteractionHttpHandler {
private readonly accountStore: AccountStore;
private readonly baseUrl: string;
private readonly idpPath: string;
private readonly emailTemplateRenderer: TemplateRenderer<{ resetLink: string }>;
private readonly templateEngine: TemplateEngine<{ resetLink: string }>;
private readonly emailSender: EmailSender;
public constructor(args: ForgotPasswordHandlerArgs) {
@ -40,7 +40,7 @@ export class ForgotPasswordHandler extends InteractionHttpHandler {
this.accountStore = args.accountStore;
this.baseUrl = ensureTrailingSlash(args.baseUrl);
this.idpPath = args.idpPath;
this.emailTemplateRenderer = args.emailTemplateRenderer;
this.templateEngine = args.templateEngine;
this.emailSender = args.emailSender;
}
@ -80,7 +80,7 @@ export class ForgotPasswordHandler extends InteractionHttpHandler {
private async sendResetMail(recordId: string, email: string): Promise<void> {
this.logger.info(`Sending password reset to ${email}`);
const resetLink = urljoin(this.baseUrl, this.idpPath, `resetpassword?rid=${recordId}`);
const renderedEmail = await this.emailTemplateRenderer.handleSafe({ resetLink });
const renderedEmail = await this.templateEngine.render({ resetLink });
await this.emailSender.handleSafe({
recipient: email,
subject: 'Reset your password',
@ -98,7 +98,7 @@ export class ForgotPasswordHandler extends InteractionHttpHandler {
// Send response
await this.messageRenderHandler.handleSafe({
response,
props: {
contents: {
errorMessage: '',
prefilled: {
email,

View File

@ -7,7 +7,7 @@ import type { PodManager } from '../../../../pods/PodManager';
import type { HttpHandlerInput } from '../../../../server/HttpHandler';
import { HttpHandler } from '../../../../server/HttpHandler';
import type { HttpRequest } from '../../../../server/HttpRequest';
import type { RenderHandler } from '../../../../server/util/RenderHandler';
import type { TemplateHandler } from '../../../../server/util/TemplateHandler';
import type { OwnershipValidator } from '../../../ownership/OwnershipValidator';
import { getFormDataRequestBody } from '../../util/FormDataUtil';
import { assertPassword, throwIdpInteractionError } from '../EmailPasswordUtil';
@ -43,7 +43,7 @@ export interface RegistrationHandlerArgs {
/**
* Renders the response when registration is successful.
*/
responseHandler: RenderHandler<NodeJS.Dict<any>>;
responseHandler: TemplateHandler;
}
/**
@ -83,7 +83,7 @@ export class RegistrationHandler extends HttpHandler {
private readonly ownershipValidator: OwnershipValidator;
private readonly accountStore: AccountStore;
private readonly podManager: PodManager;
private readonly responseHandler: RenderHandler<NodeJS.Dict<any>>;
private readonly responseHandler: TemplateHandler;
public constructor(args: RegistrationHandlerArgs) {
super();
@ -100,9 +100,8 @@ export class RegistrationHandler extends HttpHandler {
const result = await this.parseInput(request);
try {
const props = await this.register(result);
await this.responseHandler.handleSafe({ response, props });
const contents = await this.register(result);
await this.responseHandler.handleSafe({ response, contents });
} catch (error: unknown) {
throwIdpInteractionError(error, result.data as Record<string, string>);
}

View File

@ -2,7 +2,7 @@ import assert from 'assert';
import { getLoggerFor } from '../../../../logging/LogUtil';
import type { HttpHandlerInput } from '../../../../server/HttpHandler';
import { HttpHandler } from '../../../../server/HttpHandler';
import type { RenderHandler } from '../../../../server/util/RenderHandler';
import type { TemplateHandler } from '../../../../server/util/TemplateHandler';
import { createErrorMessage } from '../../../../util/errors/ErrorUtil';
import { getFormDataRequestBody } from '../../util/FormDataUtil';
import { assertPassword } from '../EmailPasswordUtil';
@ -12,7 +12,7 @@ import type { ResetPasswordRenderHandler } from './ResetPasswordRenderHandler';
export interface ResetPasswordHandlerArgs {
accountStore: AccountStore;
renderHandler: ResetPasswordRenderHandler;
messageRenderHandler: RenderHandler<{ message: string }>;
messageRenderHandler: TemplateHandler<{ message: string }>;
}
/**
@ -24,7 +24,7 @@ export class ResetPasswordHandler extends HttpHandler {
private readonly accountStore: AccountStore;
private readonly renderHandler: ResetPasswordRenderHandler;
private readonly messageRenderHandler: RenderHandler<{ message: string }>;
private readonly messageRenderHandler: TemplateHandler<{ message: string }>;
public constructor(args: ResetPasswordHandlerArgs) {
super();
@ -48,14 +48,14 @@ export class ResetPasswordHandler extends HttpHandler {
await this.resetPassword(recordId, password);
await this.messageRenderHandler.handleSafe({
response: input.response,
props: {
contents: {
message: 'Your password was successfully reset.',
},
});
} catch (err: unknown) {
await this.renderHandler.handleSafe({
response: input.response,
props: {
contents: {
errorMessage: createErrorMessage(err),
recordId: prefilledRecordId,
},

View File

@ -1,4 +1,4 @@
import { RenderHandler } from '../../../../server/util/RenderHandler';
import { TemplateHandler } from '../../../../server/util/TemplateHandler';
export interface ResetPasswordRenderHandlerProps {
errorMessage: string;
@ -9,4 +9,4 @@ export interface ResetPasswordRenderHandlerProps {
* A special {@link RenderHandler} for the Reset Password form
* that includes the required props for rendering the reset password form.
*/
export abstract class ResetPasswordRenderHandler extends RenderHandler<ResetPasswordRenderHandlerProps> {}
export abstract class ResetPasswordRenderHandler extends TemplateHandler<ResetPasswordRenderHandlerProps> {}

View File

@ -27,7 +27,7 @@ export class ResetPasswordViewHandler extends HttpHandler {
);
await this.renderHandler.handleSafe({
response,
props: { errorMessage: '', recordId },
contents: { errorMessage: '', recordId },
});
} catch (error: unknown) {
throwIdpInteractionError(error, {});

View File

@ -1,25 +0,0 @@
import { renderFile } from 'ejs';
import { joinFilePath } from '../../../util/PathUtil';
import { TemplateRenderer } from './TemplateRenderer';
/**
* Renders options using a given EJS template location and returns the result as a string.
* This is useful for rendering emails.
*/
export class EjsTemplateRenderer<T> extends TemplateRenderer<T> {
private readonly templatePath: string;
private readonly templateFile: string;
public constructor(templatePath: string, templateFile: string) {
super();
this.templatePath = templatePath;
this.templateFile = templateFile;
}
public async handle(options: T): Promise<string> {
return renderFile(
joinFilePath(this.templatePath, this.templateFile),
options,
);
}
}

View File

@ -1,4 +1,4 @@
import { RenderHandler } from '../../../server/util/RenderHandler';
import { TemplateHandler } from '../../../server/util/TemplateHandler';
export interface IdpRenderHandlerProps {
errorMessage?: string;
@ -9,4 +9,4 @@ export interface IdpRenderHandlerProps {
* A special Render Handler that renders an IDP form.
* Contains an error message if something was wrong and prefilled values for forms.
*/
export abstract class IdpRenderHandler extends RenderHandler<IdpRenderHandlerProps> {}
export abstract class IdpRenderHandler extends TemplateHandler<IdpRenderHandlerProps> {}

View File

@ -24,7 +24,7 @@ export class IdpRouteController extends RouterHandler {
Promise<void> {
return this.renderHandler.handleSafe({
response: input.response,
props: { errorMessage, prefilled },
contents: { errorMessage, prefilled },
});
}

View File

@ -38,7 +38,7 @@ export class InitialInteractionHandler extends InteractionHttpHandler {
await this.renderHandlerMap[name].handleSafe({
response,
props: {
contents: {
errorMessage: '',
prefilled: {},
},

View File

@ -1,6 +0,0 @@
import { AsyncHandler } from '../../../util/handlers/AsyncHandler';
/**
* Renders given options
*/
export abstract class TemplateRenderer<T> extends AsyncHandler<T, string> {}

View File

@ -36,7 +36,6 @@ export * from './identity/interaction/email-password/EmailPasswordUtil';
// Identity/Interaction/Util
export * from './identity/interaction/util/BaseEmailSender';
export * from './identity/interaction/util/EjsTemplateRenderer';
export * from './identity/interaction/util/EmailSender';
export * from './identity/interaction/util/FormDataUtil';
export * from './identity/interaction/util/IdpInteractionError';
@ -44,7 +43,6 @@ export * from './identity/interaction/util/IdpRenderHandler';
export * from './identity/interaction/util/IdpRouteController';
export * from './identity/interaction/util/InitialInteractionHandler';
export * from './identity/interaction/util/InteractionCompleter';
export * from './identity/interaction/util/TemplateRenderer';
// Identity/Interaction
export * from './identity/interaction/InteractionHttpHandler';
@ -206,9 +204,8 @@ export * from './server/middleware/StaticAssetHandler';
export * from './server/middleware/WebSocketAdvertiser';
// Server/Util
export * from './server/util/RenderEjsHandler';
export * from './server/util/RenderHandler';
export * from './server/util/RouterHandler';
export * from './server/util/TemplateHandler';
// Storage/Accessors
export * from './storage/accessors/DataAccessor';
@ -310,6 +307,7 @@ export * from './util/locking/SingleThreadedResourceLocker';
export * from './util/locking/WrappedExpiringReadWriteLocker';
// Util/Templates
export * from './util/templates/EjsTemplateEngine';
export * from './util/templates/HandlebarsTemplateEngine';
export * from './util/templates/TemplateEngine';

View File

@ -79,7 +79,6 @@ export class AppRunner {
config: { type: 'string', alias: 'c', requiresArg: true },
loggingLevel: { type: 'string', alias: 'l', default: 'info', requiresArg: true },
mainModulePath: { type: 'string', alias: 'm', requiresArg: true },
idpTemplateFolder: { type: 'string', requiresArg: true },
port: { type: 'number', alias: 'p', default: 3000, requiresArg: true },
rootFilePath: { type: 'string', alias: 'f', default: './', requiresArg: true },
showStackTrace: { type: 'boolean', alias: 't', default: false },
@ -137,8 +136,6 @@ export class AppRunner {
'urn:solid-server:default:variable:showStackTrace': params.showStackTrace,
'urn:solid-server:default:variable:podConfigJson':
this.resolveFilePath(params.podConfigJson),
'urn:solid-server:default:variable:idpTemplateFolder':
this.resolveFilePath(params.idpTemplateFolder, 'templates/idp'),
};
}
@ -166,5 +163,4 @@ export interface ConfigVariables {
sparqlEndpoint?: string;
showStackTrace?: boolean;
podConfigJson?: string;
idpTemplateFolder?: string;
}

View File

@ -1,26 +0,0 @@
import { renderFile } from 'ejs';
import { joinFilePath } from '../../util/PathUtil';
import type { HttpResponse } from '../HttpResponse';
import { RenderHandler } from './RenderHandler';
/**
* A Render Handler that uses EJS templates to render a response.
*/
export class RenderEjsHandler<T> extends RenderHandler<T> {
private readonly templatePath: string;
private readonly templateFile: string;
public constructor(templatePath: string, templateFile: string) {
super();
this.templatePath = templatePath;
this.templateFile = templateFile;
}
public async handle(input: { response: HttpResponse; props: T }): Promise<void> {
const { props, response } = input;
const renderedHtml = await renderFile(joinFilePath(this.templatePath, this.templateFile), props || {});
// eslint-disable-next-line @typescript-eslint/naming-convention
response.writeHead(200, { 'Content-Type': 'text/html' });
response.end(renderedHtml);
}
}

View File

@ -1,9 +0,0 @@
import { AsyncHandler } from '../../util/handlers/AsyncHandler';
import type { HttpResponse } from '../HttpResponse';
export interface RenderHandlerInput {}
/**
* Renders a result with the given props and sends it to the HttpResponse.
*/
export abstract class RenderHandler<T> extends AsyncHandler<{ response: HttpResponse; props: T }> {}

View File

@ -0,0 +1,26 @@
import { AsyncHandler } from '../../util/handlers/AsyncHandler';
import type { TemplateEngine } from '../../util/templates/TemplateEngine';
import type { HttpResponse } from '../HttpResponse';
import Dict = NodeJS.Dict;
/**
* A Render Handler that uses a template engine to render a response.
*/
export class TemplateHandler<T extends Dict<any> = Dict<any>>
extends AsyncHandler<{ response: HttpResponse; contents: T }> {
private readonly templateEngine: TemplateEngine;
private readonly contentType: string;
public constructor(templateEngine: TemplateEngine, contentType = 'text/html') {
super();
this.templateEngine = templateEngine;
this.contentType = contentType;
}
public async handle({ response, contents }: { response: HttpResponse; contents: T }): Promise<void> {
const rendered = await this.templateEngine.render(contents);
// eslint-disable-next-line @typescript-eslint/naming-convention
response.writeHead(200, { 'Content-Type': this.contentType });
response.end(rendered);
}
}

View File

@ -0,0 +1,28 @@
/* eslint-disable tsdoc/syntax */
// tsdoc/syntax cannot handle `@range`
import type { TemplateFunction } from 'ejs';
import { compile, render } from 'ejs';
import type { TemplateEngine, Template } from './TemplateEngine';
import { readTemplate } from './TemplateEngine';
import Dict = NodeJS.Dict;
/**
* Fills in EJS templates.
*/
export class EjsTemplateEngine<T extends Dict<any> = Dict<any>> implements TemplateEngine<T> {
private readonly applyTemplate: Promise<TemplateFunction>;
/**
* @param template - The default template @range {json}
*/
public constructor(template?: Template) {
this.applyTemplate = readTemplate(template)
.then((templateString: string): TemplateFunction => compile(templateString));
}
public async render(contents: T): Promise<string>;
public async render<TCustom = T>(contents: TCustom, template: Template): Promise<string>;
public async render<TCustom = T>(contents: TCustom, template?: Template): Promise<string> {
return template ? render(await readTemplate(template), contents) : (await this.applyTemplate)(contents);
}
}

View File

@ -1,5 +1,5 @@
/* eslint-disable tsdoc/syntax */
// tsdoc/syntax can't handle {json} parameter
// tsdoc/syntax cannot handle `@range`
import type { TemplateDelegate } from 'handlebars';
import { compile } from 'handlebars';
import type { TemplateEngine, Template } from './TemplateEngine';

View File

@ -2,7 +2,9 @@ import { promises as fsPromises } from 'fs';
import { joinFilePath, resolveAssetPath } from '../PathUtil';
import Dict = NodeJS.Dict;
export type Template = TemplateString | TemplatePath;
export type Template = TemplateFileName | TemplateString | TemplatePath;
export type TemplateFileName = string;
export interface TemplateString {
// String contents of the template
@ -38,6 +40,10 @@ export interface TemplateEngine<T extends Dict<any> = Dict<any>> {
* Reads the template and returns it as a string.
*/
export async function readTemplate(template: Template = { templateString: '' }): Promise<string> {
// The template has been passed as a filename
if (typeof template === 'string') {
return readTemplate({ templateFile: template });
}
// The template has already been given as a string
if ('templateString' in template) {
return template.templateString;

View File

@ -48,6 +48,5 @@ export function getDefaultVariables(port: number, baseUrl?: string): Record<stri
'urn:solid-server:default:variable:port': port,
'urn:solid-server:default:variable:loggingLevel': 'off',
'urn:solid-server:default:variable:showStackTrace': true,
'urn:solid-server:default:variable:idpTemplateFolder': joinFilePath(__dirname, '../../templates/idp'),
};
}

View File

@ -5,9 +5,9 @@ import {
import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore';
import type { EmailSender } from '../../../../../../src/identity/interaction/util/EmailSender';
import type { IdpRenderHandler } from '../../../../../../src/identity/interaction/util/IdpRenderHandler';
import type { TemplateRenderer } from '../../../../../../src/identity/interaction/util/TemplateRenderer';
import type { HttpRequest } from '../../../../../../src/server/HttpRequest';
import type { HttpResponse } from '../../../../../../src/server/HttpResponse';
import type { TemplateEngine } from '../../../../../../src/util/templates/TemplateEngine';
import { createPostFormRequest } from './Util';
describe('A ForgotPasswordHandler', (): void => {
@ -16,13 +16,13 @@ describe('A ForgotPasswordHandler', (): void => {
const email = 'test@test.email';
const recordId = '123456';
const html = `<a href="/base/idp/resetpassword?rid=${recordId}">Reset Password</a>`;
const renderParams = { response, props: { errorMessage: '', prefilled: { email }}};
const renderParams = { response, contents: { errorMessage: '', prefilled: { email }}};
const provider: Provider = {} as any;
let messageRenderHandler: IdpRenderHandler;
let accountStore: AccountStore;
const baseUrl = 'http://test.com/base/';
const idpPath = '/idp';
let emailTemplateRenderer: TemplateRenderer<{ resetLink: string }>;
let templateEngine: TemplateEngine<{ resetLink: string }>;
let emailSender: EmailSender;
let handler: ForgotPasswordHandler;
@ -37,8 +37,8 @@ describe('A ForgotPasswordHandler', (): void => {
generateForgotPasswordRecord: jest.fn().mockResolvedValue(recordId),
} as any;
emailTemplateRenderer = {
handleSafe: jest.fn().mockResolvedValue(html),
templateEngine = {
render: jest.fn().mockResolvedValue(html),
} as any;
emailSender = {
@ -50,7 +50,7 @@ describe('A ForgotPasswordHandler', (): void => {
accountStore,
baseUrl,
idpPath,
emailTemplateRenderer,
templateEngine,
emailSender,
});
});

View File

@ -10,7 +10,7 @@ import type { IdentifierGenerator } from '../../../../../../src/pods/generate/Id
import type { PodManager } from '../../../../../../src/pods/PodManager';
import type { HttpRequest } from '../../../../../../src/server/HttpRequest';
import type { HttpResponse } from '../../../../../../src/server/HttpResponse';
import type { RenderHandler } from '../../../../../../src/server/util/RenderHandler';
import type { TemplateHandler } from '../../../../../../src/server/util/TemplateHandler';
import { createPostFormRequest } from './Util';
describe('A RegistrationHandler', (): void => {
@ -34,7 +34,7 @@ describe('A RegistrationHandler', (): void => {
let ownershipValidator: OwnershipValidator;
let accountStore: AccountStore;
let podManager: PodManager;
let responseHandler: RenderHandler<NodeJS.Dict<any>>;
let responseHandler: TemplateHandler<NodeJS.Dict<any>>;
let handler: RegistrationHandler;
beforeEach(async(): Promise<void> => {

View File

@ -7,7 +7,7 @@ import type {
import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore';
import type { HttpRequest } from '../../../../../../src/server/HttpRequest';
import type { HttpResponse } from '../../../../../../src/server/HttpResponse';
import type { RenderHandler } from '../../../../../../src/server/util/RenderHandler';
import type { TemplateHandler } from '../../../../../../src/server/util/TemplateHandler';
import { createPostFormRequest } from './Util';
describe('A ResetPasswordHandler', (): void => {
@ -17,7 +17,7 @@ describe('A ResetPasswordHandler', (): void => {
const email = 'alice@test.email';
let accountStore: AccountStore;
let renderHandler: ResetPasswordRenderHandler;
let messageRenderHandler: RenderHandler<{ message: string }>;
let messageRenderHandler: TemplateHandler<{ message: string }>;
let handler: ResetPasswordHandler;
beforeEach(async(): Promise<void> => {
@ -47,11 +47,11 @@ describe('A ResetPasswordHandler', (): void => {
request = createPostFormRequest({});
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
expect(renderHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(renderHandler.handleSafe).toHaveBeenLastCalledWith({ response, props: { errorMessage, recordId: '' }});
expect(renderHandler.handleSafe).toHaveBeenLastCalledWith({ response, contents: { errorMessage, recordId: '' }});
request = createPostFormRequest({ recordId: [ 'a', 'b' ]});
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
expect(renderHandler.handleSafe).toHaveBeenCalledTimes(2);
expect(renderHandler.handleSafe).toHaveBeenLastCalledWith({ response, props: { errorMessage, recordId: '' }});
expect(renderHandler.handleSafe).toHaveBeenLastCalledWith({ response, contents: { errorMessage, recordId: '' }});
});
it('renders errors for invalid passwords.', async(): Promise<void> => {
@ -59,7 +59,7 @@ describe('A ResetPasswordHandler', (): void => {
request = createPostFormRequest({ recordId, password: 'password!', confirmPassword: 'otherPassword!' });
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
expect(renderHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(renderHandler.handleSafe).toHaveBeenLastCalledWith({ response, props: { errorMessage, recordId }});
expect(renderHandler.handleSafe).toHaveBeenLastCalledWith({ response, contents: { errorMessage, recordId }});
});
it('renders errors for invalid emails.', async(): Promise<void> => {
@ -68,7 +68,7 @@ describe('A ResetPasswordHandler', (): void => {
(accountStore.getForgotPasswordRecord as jest.Mock).mockResolvedValueOnce(undefined);
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
expect(renderHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(renderHandler.handleSafe).toHaveBeenLastCalledWith({ response, props: { errorMessage, recordId }});
expect(renderHandler.handleSafe).toHaveBeenLastCalledWith({ response, contents: { errorMessage, recordId }});
});
it('renders a message on success.', async(): Promise<void> => {
@ -82,7 +82,7 @@ describe('A ResetPasswordHandler', (): void => {
expect(accountStore.changePassword).toHaveBeenLastCalledWith(email, 'password!');
expect(messageRenderHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(messageRenderHandler.handleSafe)
.toHaveBeenLastCalledWith({ response, props: { message: 'Your password was successfully reset.' }});
.toHaveBeenLastCalledWith({ response, contents: { message: 'Your password was successfully reset.' }});
});
it('has a default error for non-native errors.', async(): Promise<void> => {
@ -91,6 +91,6 @@ describe('A ResetPasswordHandler', (): void => {
(accountStore.getForgotPasswordRecord as jest.Mock).mockRejectedValueOnce('not native');
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
expect(renderHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(renderHandler.handleSafe).toHaveBeenLastCalledWith({ response, props: { errorMessage, recordId }});
expect(renderHandler.handleSafe).toHaveBeenLastCalledWith({ response, contents: { errorMessage, recordId }});
});
});

View File

@ -42,7 +42,7 @@ describe('A ResetPasswordViewHandler', (): void => {
expect(renderHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(renderHandler.handleSafe).toHaveBeenLastCalledWith({
response,
props: { errorMessage: '', recordId: 'recordId' },
contents: { errorMessage: '', recordId: 'recordId' },
});
});
});

View File

@ -1,19 +0,0 @@
import { renderFile } from 'ejs';
import {
EjsTemplateRenderer,
} from '../../../../../src/identity/interaction/util/EjsTemplateRenderer';
jest.mock('ejs');
describe('An EjsTemplateRenderer', (): void => {
const templatePath = '/var/templates/';
const templateFile = 'template.ejs';
const options: Record<string, string> = { email: 'alice@test.email', webId: 'http://alice.test.com/card#me' };
const renderer = new EjsTemplateRenderer<Record<string, string>>(templatePath, templateFile);
it('renders the given file with the given options.', async(): Promise<void> => {
await expect(renderer.handle(options)).resolves.toBeUndefined();
expect(renderFile).toHaveBeenCalledTimes(1);
expect(renderFile).toHaveBeenLastCalledWith('/var/templates/template.ejs', options);
});
});

View File

@ -38,7 +38,7 @@ describe('An IdpRouteController', (): void => {
expect(renderHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(renderHandler.handleSafe).toHaveBeenLastCalledWith({
response,
props: { errorMessage: '', prefilled: {}},
contents: { errorMessage: '', prefilled: {}},
});
expect(postHandler.handleSafe).toHaveBeenCalledTimes(0);
});
@ -61,7 +61,7 @@ describe('An IdpRouteController', (): void => {
expect(renderHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(renderHandler.handleSafe).toHaveBeenLastCalledWith({
response,
props: { errorMessage: 'bad request!', prefilled: { more: 'data!' }},
contents: { errorMessage: 'bad request!', prefilled: { more: 'data!' }},
});
});
@ -74,7 +74,7 @@ describe('An IdpRouteController', (): void => {
expect(renderHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(renderHandler.handleSafe).toHaveBeenLastCalledWith({
response,
props: { errorMessage: 'Unknown error: apple!', prefilled: {}},
contents: { errorMessage: 'Unknown error: apple!', prefilled: {}},
});
});

View File

@ -35,7 +35,7 @@ describe('An InitialInteractionHandler', (): void => {
expect(map.test.handleSafe).toHaveBeenCalledTimes(1);
expect(map.test.handleSafe).toHaveBeenLastCalledWith({
response,
props: {
contents: {
errorMessage: '',
prefilled: {},
},
@ -51,7 +51,7 @@ describe('An InitialInteractionHandler', (): void => {
expect(map.test.handleSafe).toHaveBeenCalledTimes(0);
expect(map.default.handleSafe).toHaveBeenLastCalledWith({
response,
props: {
contents: {
errorMessage: '',
prefilled: {},
},

View File

@ -70,7 +70,6 @@ describe('AppRunner', (): void => {
'urn:solid-server:default:variable:loggingLevel': 'info',
'urn:solid-server:default:variable:showStackTrace': false,
'urn:solid-server:default:variable:podConfigJson': '/var/cwd/pod-config.json',
'urn:solid-server:default:variable:idpTemplateFolder': joinFilePath(__dirname, '../../../templates/idp'),
},
},
);
@ -111,7 +110,6 @@ describe('AppRunner', (): void => {
'urn:solid-server:default:variable:loggingLevel': 'info',
'urn:solid-server:default:variable:showStackTrace': false,
'urn:solid-server:default:variable:podConfigJson': '/var/cwd/pod-config.json',
'urn:solid-server:default:variable:idpTemplateFolder': joinFilePath(__dirname, '../../../templates/idp'),
},
},
);
@ -132,7 +130,6 @@ describe('AppRunner', (): void => {
'-s', 'http://localhost:5000/sparql',
'-t',
'--podConfigJson', '/different-path.json',
'--idpTemplateFolder', 'templates/idp',
],
});
@ -160,7 +157,6 @@ describe('AppRunner', (): void => {
'urn:solid-server:default:variable:sparqlEndpoint': 'http://localhost:5000/sparql',
'urn:solid-server:default:variable:showStackTrace': true,
'urn:solid-server:default:variable:podConfigJson': '/different-path.json',
'urn:solid-server:default:variable:idpTemplateFolder': '/var/cwd/templates/idp',
},
},
);
@ -179,7 +175,6 @@ describe('AppRunner', (): void => {
'--sparqlEndpoint', 'http://localhost:5000/sparql',
'--showStackTrace',
'--podConfigJson', '/different-path.json',
'--idpTemplateFolder', 'templates/idp',
],
});
@ -207,7 +202,6 @@ describe('AppRunner', (): void => {
'urn:solid-server:default:variable:sparqlEndpoint': 'http://localhost:5000/sparql',
'urn:solid-server:default:variable:showStackTrace': true,
'urn:solid-server:default:variable:podConfigJson': '/different-path.json',
'urn:solid-server:default:variable:idpTemplateFolder': '/var/cwd/templates/idp',
},
},
);
@ -226,7 +220,6 @@ describe('AppRunner', (): void => {
'-s', 'http://localhost:5000/sparql',
'-t',
'--podConfigJson', '/different-path.json',
'--idpTemplateFolder', 'templates/idp',
];
new AppRunner().runCli();
@ -255,7 +248,6 @@ describe('AppRunner', (): void => {
'urn:solid-server:default:variable:sparqlEndpoint': 'http://localhost:5000/sparql',
'urn:solid-server:default:variable:showStackTrace': true,
'urn:solid-server:default:variable:podConfigJson': '/different-path.json',
'urn:solid-server:default:variable:idpTemplateFolder': '/var/cwd/templates/idp',
},
},
);

View File

@ -1,77 +0,0 @@
import { createResponse } from 'node-mocks-http';
import { joinFilePath } from '../../../../src';
import type { HttpResponse } from '../../../../src';
import { RenderEjsHandler } from '../../../../src/server/util/RenderEjsHandler';
describe('RenderEjsHandler', (): void => {
let response: HttpResponse;
let templatePath: string;
let templateFile: string;
beforeEach((): void => {
response = createResponse() as HttpResponse;
templatePath = joinFilePath(__dirname, '../../../assets/idp');
templateFile = 'testHtml.ejs';
});
it('throws an error if the path is not valid.', async(): Promise<void> => {
const handler = new RenderEjsHandler<{ message: string }>('/bad/path', 'badFile.thing');
await expect(handler.handle({
response,
props: {
message: 'cool',
},
})).rejects.toThrow(`ENOENT: no such file or directory, open '/bad/path/badFile.thing'`);
});
it('throws an error if valid parameters were not provided.', async(): Promise<void> => {
const handler = new RenderEjsHandler<string>(templatePath, templateFile);
await expect(handler.handle({
response,
props: 'This is an invalid prop.',
})).rejects.toThrow();
});
it('successfully renders a page.', async(): Promise<void> => {
const handler = new RenderEjsHandler<{ message: string }>(templatePath, templateFile);
await handler.handle({
response,
props: {
message: 'cool',
},
});
// Cast to any because mock-response depends on express, which this project doesn't have
const testResponse = response as any;
expect(testResponse._isEndCalled()).toBe(true);
expect(testResponse._getData()).toBe('<html><body><p>cool</p></body></html>');
expect(testResponse._getStatusCode()).toBe(200);
});
it('successfully escapes html input.', async(): Promise<void> => {
const handler = new RenderEjsHandler<{ message: string }>(templatePath, templateFile);
await handler.handle({
response,
props: {
message: '<script>alert(1)</script>',
},
});
// Cast to any because mock-response depends on express, which this project doesn't have
const testResponse = response as any;
expect(testResponse._isEndCalled()).toBe(true);
expect(testResponse._getData()).toBe('<html><body><p>&lt;script&gt;alert(1)&lt;/script&gt;</p></body></html>');
expect(testResponse._getStatusCode()).toBe(200);
});
it('successfully renders when no props are needed.', async(): Promise<void> => {
const handler = new RenderEjsHandler<undefined>(templatePath, 'noPropsTestHtml.ejs');
await handler.handle({
response,
props: undefined,
});
// Cast to any because mock-response depends on express, which this project doesn't have
const testResponse = response as any;
expect(testResponse._isEndCalled()).toBe(true);
expect(testResponse._getData()).toBe('<html><body><p>secret message</p></body></html>');
expect(testResponse._getStatusCode()).toBe(200);
});
});

View File

@ -0,0 +1,30 @@
import { createResponse } from 'node-mocks-http';
import type { HttpResponse } from '../../../../src';
import { TemplateHandler } from '../../../../src/server/util/TemplateHandler';
import type { TemplateEngine } from '../../../../src/util/templates/TemplateEngine';
describe('A TemplateHandler', (): void => {
const contents = { contents: 'contents' };
let templateEngine: jest.Mocked<TemplateEngine>;
let response: HttpResponse;
beforeEach((): void => {
templateEngine = {
render: jest.fn().mockResolvedValue('rendered'),
};
response = createResponse() as HttpResponse;
});
it('renders the template in the response.', async(): Promise<void> => {
const handler = new TemplateHandler(templateEngine);
await handler.handle({ response, contents });
expect(templateEngine.render).toHaveBeenCalledTimes(1);
expect(templateEngine.render).toHaveBeenCalledWith(contents);
expect(response.getHeaders()).toHaveProperty('content-type', 'text/html');
expect((response as any)._isEndCalled()).toBe(true);
expect((response as any)._getData()).toBe('rendered');
expect((response as any)._getStatusCode()).toBe(200);
});
});

View File

@ -0,0 +1,23 @@
import { EjsTemplateEngine } from '../../../../src/util/templates/EjsTemplateEngine';
jest.mock('../../../../src/util/templates/TemplateEngine', (): any => ({
readTemplate: jest.fn(async({ templateString }): Promise<string> => `${templateString}: <%= detail %>`),
}));
describe('A EjsTemplateEngine', (): void => {
const defaultTemplate = { templateString: 'xyz' };
const contents = { detail: 'a&b' };
let templateEngine: EjsTemplateEngine;
beforeEach((): void => {
templateEngine = new EjsTemplateEngine(defaultTemplate);
});
it('uses the default template when no template was passed.', async(): Promise<void> => {
await expect(templateEngine.render(contents)).resolves.toBe('xyz: a&amp;b');
});
it('uses the passed template.', async(): Promise<void> => {
await expect(templateEngine.render(contents, { templateString: 'my' })).resolves.toBe('my: a&amp;b');
});
});

View File

@ -22,15 +22,19 @@ describe('readTemplate', (): void => {
await expect(readTemplate()).resolves.toBe('');
});
it('accepts string templates.', async(): Promise<void> => {
it('accepts a filename.', async(): Promise<void> => {
await expect(readTemplate(templateFile)).resolves.toBe('{{template}}');
});
it('accepts options with a string template.', async(): Promise<void> => {
await expect(readTemplate({ templateString: 'abc' })).resolves.toBe('abc');
});
it('accepts a filename.', async(): Promise<void> => {
it('accepts options with a filename.', async(): Promise<void> => {
await expect(readTemplate({ templateFile })).resolves.toBe('{{template}}');
});
it('accepts a filename and path.', async(): Promise<void> => {
it('accepts options with a filename and a path.', async(): Promise<void> => {
await expect(readTemplate({ templateFile, templatePath })).resolves.toBe('{{other}}');
});
});