'use strict'; const request = require('../lib/request'); const fs = require('fs').promises; const mustache = require('mustache'); const Utils = require('../lib/Utils'); const { Page } = require('../lib/Page'); const Config = require('../lib/Config'); const Definitions = require('../lib/Options/Definitions'); const UserController = require('../lib/Controllers/UserController').UserController; const { PagesRouter, pages, pageParams, pageParamHeaderPrefix, } = require('../lib/Routers/PagesRouter'); describe('Pages Router', () => { describe('basic request', () => { let config; beforeEach(async () => { config = { appId: 'test', appName: 'exampleAppname', publicServerURL: 'http://localhost:8378/1', pages: { enableRouter: true }, }; await reconfigureServer(config); }); it('responds with file content on direct page request', async () => { const urls = [ 'http://localhost:8378/1/apps/email_verification_link_invalid.html', 'http://localhost:8378/1/apps/choose_password?appId=test', 'http://localhost:8378/1/apps/email_verification_success.html', 'http://localhost:8378/1/apps/password_reset_success.html', 'http://localhost:8378/1/apps/custom_json.html', ]; for (const url of urls) { const response = await request({ url }).catch(e => e); expect(response.status).toBe(200); } }); it('can load file from custom pages path', async () => { config.pages.pagesPath = './public'; await reconfigureServer(config); const response = await request({ url: 'http://localhost:8378/1/apps/email_verification_link_invalid.html', }).catch(e => e); expect(response.status).toBe(200); }); it('can load file from custom pages endpoint', async () => { config.pages.pagesEndpoint = 'pages'; await reconfigureServer(config); const response = await request({ url: `http://localhost:8378/1/pages/email_verification_link_invalid.html`, }).catch(e => e); expect(response.status).toBe(200); }); it('responds with 404 if publicServerURL is not configured', async () => { await reconfigureServer({ appName: 'unused', pages: { enableRouter: true }, }); const urls = [ 'http://localhost:8378/1/apps/test/verify_email', 'http://localhost:8378/1/apps/choose_password?appId=test', 'http://localhost:8378/1/apps/test/request_password_reset', ]; for (const url of urls) { const response = await request({ url }).catch(e => e); expect(response.status).toBe(404); } }); it('responds with 403 access denied with invalid appId', async () => { const reqs = [ { url: 'http://localhost:8378/1/apps/invalid/verify_email', method: 'GET' }, { url: 'http://localhost:8378/1/apps/choose_password?id=invalid', method: 'GET' }, { url: 'http://localhost:8378/1/apps/invalid/request_password_reset', method: 'GET' }, { url: 'http://localhost:8378/1/apps/invalid/request_password_reset', method: 'POST' }, { url: 'http://localhost:8378/1/apps/invalid/resend_verification_email', method: 'POST' }, ]; for (const req of reqs) { const response = await request(req).catch(e => e); expect(response.status).toBe(403); } }); }); describe('AJAX requests', () => { beforeEach(async () => { await reconfigureServer({ appName: 'exampleAppname', publicServerURL: 'http://localhost:8378/1', pages: { enableRouter: true }, }); }); it('request_password_reset: responds with AJAX success', async () => { spyOn(UserController.prototype, 'updatePassword').and.callFake(() => Promise.resolve()); const res = await request({ method: 'POST', url: 'http://localhost:8378/1/apps/test/request_password_reset', body: `new_password=user1&token=43634643`, headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Requested-With': 'XMLHttpRequest', }, followRedirects: false, }).catch(e => e); expect(res.status).toBe(200); expect(res.text).toEqual('"Password successfully reset"'); }); it('request_password_reset: responds with AJAX error on missing password', async () => { try { await request({ method: 'POST', url: 'http://localhost:8378/1/apps/test/request_password_reset', body: `new_password=&token=132414`, headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Requested-With': 'XMLHttpRequest', }, followRedirects: false, }); } catch (error) { expect(error.status).not.toBe(302); expect(error.text).toEqual('{"code":201,"error":"Missing password"}'); } }); it('request_password_reset: responds with AJAX error on missing token', async () => { try { await request({ method: 'POST', url: 'http://localhost:8378/1/apps/test/request_password_reset', body: `new_password=user1&token=`, headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Requested-With': 'XMLHttpRequest', }, followRedirects: false, }); } catch (error) { expect(error.status).not.toBe(302); expect(error.text).toEqual('{"code":-1,"error":"Missing token"}'); } }); }); describe('pages', () => { let router = new PagesRouter(); let req; let config; let goToPage; let pageResponse; let redirectResponse; let readFile; let exampleLocale; const fillPlaceholders = (text, fill) => text.replace(/({{2,3}.*?}{2,3})/g, fill); async function reconfigureServerWithPagesConfig(pagesConfig) { config.pages = pagesConfig; await reconfigureServer(config); } beforeEach(async () => { router = new PagesRouter(); readFile = spyOn(fs, 'readFile').and.callThrough(); goToPage = spyOn(PagesRouter.prototype, 'goToPage').and.callThrough(); pageResponse = spyOn(PagesRouter.prototype, 'pageResponse').and.callThrough(); redirectResponse = spyOn(PagesRouter.prototype, 'redirectResponse').and.callThrough(); exampleLocale = 'de-AT'; config = { appId: 'test', appName: 'ExampleAppName', verifyUserEmails: true, emailAdapter: { sendVerificationEmail: () => Promise.resolve(), sendPasswordResetEmail: () => Promise.resolve(), sendMail: () => {}, }, publicServerURL: 'http://localhost:8378/1', pages: { enableRouter: true, enableLocalization: true, customUrls: {}, }, }; req = { method: 'GET', config, query: { locale: exampleLocale, }, }; }); describe('server options', () => { it('uses default configuration when none is set', async () => { await reconfigureServerWithPagesConfig({}); expect(Config.get(Parse.applicationId).pages.enableRouter).toBe( Definitions.PagesOptions.enableRouter.default ); expect(Config.get(Parse.applicationId).pages.enableLocalization).toBe( Definitions.PagesOptions.enableLocalization.default ); expect(Config.get(Parse.applicationId).pages.localizationJsonPath).toBe( Definitions.PagesOptions.localizationJsonPath.default ); expect(Config.get(Parse.applicationId).pages.localizationFallbackLocale).toBe( Definitions.PagesOptions.localizationFallbackLocale.default ); expect(Config.get(Parse.applicationId).pages.placeholders).toBe( Definitions.PagesOptions.placeholders.default ); expect(Config.get(Parse.applicationId).pages.forceRedirect).toBe( Definitions.PagesOptions.forceRedirect.default ); expect(Config.get(Parse.applicationId).pages.pagesPath).toBe( Definitions.PagesOptions.pagesPath.default ); expect(Config.get(Parse.applicationId).pages.pagesEndpoint).toBe( Definitions.PagesOptions.pagesEndpoint.default ); expect(Config.get(Parse.applicationId).pages.customUrls).toBe( Definitions.PagesOptions.customUrls.default ); expect(Config.get(Parse.applicationId).pages.customRoutes).toBe( Definitions.PagesOptions.customRoutes.default ); }); it('throws on invalid configuration', async () => { const options = [ [], 'a', 0, true, { enableRouter: 'a' }, { enableRouter: 0 }, { enableRouter: {} }, { enableRouter: [] }, { enableLocalization: 'a' }, { enableLocalization: 0 }, { enableLocalization: {} }, { enableLocalization: [] }, { forceRedirect: 'a' }, { forceRedirect: 0 }, { forceRedirect: {} }, { forceRedirect: [] }, { placeholders: true }, { placeholders: 'a' }, { placeholders: 0 }, { placeholders: [] }, { pagesPath: true }, { pagesPath: 0 }, { pagesPath: {} }, { pagesPath: [] }, { pagesEndpoint: true }, { pagesEndpoint: 0 }, { pagesEndpoint: {} }, { pagesEndpoint: [] }, { customUrls: true }, { customUrls: 0 }, { customUrls: 'a' }, { customUrls: [] }, { localizationJsonPath: true }, { localizationJsonPath: 0 }, { localizationJsonPath: {} }, { localizationJsonPath: [] }, { localizationFallbackLocale: true }, { localizationFallbackLocale: 0 }, { localizationFallbackLocale: {} }, { localizationFallbackLocale: [] }, { customRoutes: true }, { customRoutes: 0 }, { customRoutes: 'a' }, { customRoutes: {} }, ]; for (const option of options) { await expectAsync(reconfigureServerWithPagesConfig(option)).toBeRejected(); } }); }); describe('placeholders', () => { it('replaces placeholder in response content', async () => { await expectAsync(router.goToPage(req, pages.passwordResetLinkInvalid)).toBeResolved(); expect(readFile.calls.all()[0].returnValue).toBeDefined(); const originalContent = await readFile.calls.all()[0].returnValue; expect(originalContent).toContain('{{appName}}'); expect(pageResponse.calls.all()[0].returnValue).toBeDefined(); const replacedContent = await pageResponse.calls.all()[0].returnValue; expect(replacedContent.text).not.toContain('{{appName}}'); expect(replacedContent.text).toContain(req.config.appName); }); it('removes undefined placeholder in response content', async () => { await expectAsync(router.goToPage(req, pages.passwordReset)).toBeResolved(); expect(readFile.calls.all()[0].returnValue).toBeDefined(); const originalContent = await readFile.calls.all()[0].returnValue; expect(originalContent).toContain('{{error}}'); // There is no error placeholder value set by default, so the // {{error}} placeholder should just be removed from content expect(pageResponse.calls.all()[0].returnValue).toBeDefined(); const replacedContent = await pageResponse.calls.all()[0].returnValue; expect(replacedContent.text).not.toContain('{{error}}'); }); it('fills placeholders from config object', async () => { config.pages.enableLocalization = false; config.pages.placeholders = { title: 'setViaConfig', }; await reconfigureServer(config); const response = await request({ url: 'http://localhost:8378/1/apps/custom_json.html', followRedirects: false, method: 'GET', }); expect(response.status).toEqual(200); expect(response.text).toContain(config.pages.placeholders.title); }); it('fills placeholders from config function', async () => { config.pages.enableLocalization = false; config.pages.placeholders = () => { return { title: 'setViaConfig' }; }; await reconfigureServer(config); const response = await request({ url: 'http://localhost:8378/1/apps/custom_json.html', followRedirects: false, method: 'GET', }); expect(response.status).toEqual(200); expect(response.text).toContain(config.pages.placeholders().title); }); it('fills placeholders from config promise', async () => { config.pages.enableLocalization = false; config.pages.placeholders = async () => { return { title: 'setViaConfig' }; }; await reconfigureServer(config); const response = await request({ url: 'http://localhost:8378/1/apps/custom_json.html', followRedirects: false, method: 'GET', }); expect(response.status).toEqual(200); expect(response.text).toContain((await config.pages.placeholders()).title); }); }); describe('localization', () => { it('returns default file if localization is disabled', async () => { delete req.config.pages.enableLocalization; await expectAsync(router.goToPage(req, pages.passwordResetLinkInvalid)).toBeResolved(); expect(pageResponse.calls.all()[0].args[0]).toBeDefined(); expect(pageResponse.calls.all()[0].args[0]).not.toMatch( new RegExp(`\/de(-AT)?\/${pages.passwordResetLinkInvalid.defaultFile}`) ); }); it('returns default file if no locale is specified', async () => { delete req.query.locale; await expectAsync(router.goToPage(req, pages.passwordResetLinkInvalid)).toBeResolved(); expect(pageResponse.calls.all()[0].args[0]).toBeDefined(); expect(pageResponse.calls.all()[0].args[0]).not.toMatch( new RegExp(`\/de(-AT)?\/${pages.passwordResetLinkInvalid.defaultFile}`) ); }); it('returns custom page regardless of localization enabled', async () => { req.config.pages.customUrls = { passwordResetLinkInvalid: 'http://invalid-link.example.com', }; await expectAsync(router.goToPage(req, pages.passwordResetLinkInvalid)).toBeResolved(); expect(pageResponse).not.toHaveBeenCalled(); expect(redirectResponse.calls.all()[0].args[0]).toBe( req.config.pages.customUrls.passwordResetLinkInvalid ); }); it('returns file for locale match', async () => { await expectAsync(router.goToPage(req, pages.passwordResetLinkInvalid)).toBeResolved(); expect(pageResponse.calls.all()[0].args[0]).toBeDefined(); expect(pageResponse.calls.all()[0].args[0]).toMatch( new RegExp(`\/${req.query.locale}\/${pages.passwordResetLinkInvalid.defaultFile}`) ); }); it('returns file for language match', async () => { // Pretend no locale matching file exists spyOn(Utils, 'fileExists').and.callFake(async path => { return !path.includes( `/${req.query.locale}/${pages.passwordResetLinkInvalid.defaultFile}` ); }); await expectAsync(router.goToPage(req, pages.passwordResetLinkInvalid)).toBeResolved(); expect(pageResponse.calls.all()[0].args[0]).toBeDefined(); expect(pageResponse.calls.all()[0].args[0]).toMatch( new RegExp(`\/de\/${pages.passwordResetLinkInvalid.defaultFile}`) ); }); it('returns default file for neither locale nor language match', async () => { req.query.locale = 'yo-LO'; await expectAsync(router.goToPage(req, pages.passwordResetLinkInvalid)).toBeResolved(); expect(pageResponse.calls.all()[0].args[0]).toBeDefined(); expect(pageResponse.calls.all()[0].args[0]).not.toMatch( new RegExp(`\/yo(-LO)?\/${pages.passwordResetLinkInvalid.defaultFile}`) ); }); }); describe('localization with JSON resource', () => { let jsonPageFile; let jsonPageUrl; let jsonResource; beforeEach(async () => { jsonPageFile = 'custom_json.html'; jsonPageUrl = new URL(`${config.publicServerURL}/apps/${jsonPageFile}`); jsonResource = require('../public/custom_json.json'); config.pages.enableLocalization = true; config.pages.localizationJsonPath = './public/custom_json.json'; config.pages.localizationFallbackLocale = 'en'; await reconfigureServer(config); }); it('does not localize with JSON resource if localization is disabled', async () => { config.pages.enableLocalization = false; config.pages.localizationJsonPath = './public/custom_json.json'; config.pages.localizationFallbackLocale = 'en'; await reconfigureServer(config); const response = await request({ url: jsonPageUrl.toString(), followRedirects: false, }).catch(e => e); expect(response.status).toBe(200); expect(pageResponse.calls.all()[0].args[1]).toEqual({}); expect(pageResponse.calls.all()[0].args[2]).toEqual({}); // Ensure header contains no page params const pageParamHeaders = Object.keys(response.headers).filter(header => header.startsWith(pageParamHeaderPrefix) ); expect(pageParamHeaders.length).toBe(0); // Ensure page response does not contain any translation const flattenedJson = Utils.flattenObject(jsonResource); for (const value of Object.values(flattenedJson)) { const valueWithoutPlaceholder = fillPlaceholders(value, ''); expect(response.text).not.toContain(valueWithoutPlaceholder); } }); it('localizes static page with JSON resource and fallback locale', async () => { const response = await request({ url: jsonPageUrl.toString(), followRedirects: false, }).catch(e => e); expect(response.status).toBe(200); // Ensure page response contains translation of fallback locale const translation = jsonResource[config.pages.localizationFallbackLocale].translation; for (const value of Object.values(translation)) { const valueWithoutPlaceholder = fillPlaceholders(value, ''); expect(response.text).toContain(valueWithoutPlaceholder); } }); it('localizes static page with JSON resource and request locale', async () => { // Add locale to request URL jsonPageUrl.searchParams.set('locale', exampleLocale); const response = await request({ url: jsonPageUrl.toString(), followRedirects: false, }).catch(e => e); expect(response.status).toBe(200); // Ensure page response contains translations of request locale const translation = jsonResource[exampleLocale].translation; for (const value of Object.values(translation)) { const valueWithoutPlaceholder = fillPlaceholders(value, ''); expect(response.text).toContain(valueWithoutPlaceholder); } }); it('localizes static page with JSON resource and language matching request locale', async () => { // Add locale to request URL that has no locale match but only a language // match in the JSON resource jsonPageUrl.searchParams.set('locale', 'de-CH'); const response = await request({ url: jsonPageUrl.toString(), followRedirects: false, }).catch(e => e); expect(response.status).toBe(200); // Ensure page response contains translations of requst language const translation = jsonResource['de'].translation; for (const value of Object.values(translation)) { const valueWithoutPlaceholder = fillPlaceholders(value, ''); expect(response.text).toContain(valueWithoutPlaceholder); } }); it('localizes static page with JSON resource and fills placeholders in JSON values', async () => { // Add app ID to request URL so that the request is assigned to a Parse Server app // and placeholders within translations strings can be replaced with default page // parameters such as `appId` jsonPageUrl.searchParams.set('appId', config.appId); jsonPageUrl.searchParams.set('locale', exampleLocale); const response = await request({ url: jsonPageUrl.toString(), followRedirects: false, }).catch(e => e); expect(response.status).toBe(200); // Fill placeholders in transation let translation = jsonResource[exampleLocale].translation; translation = JSON.stringify(translation); translation = mustache.render(translation, { appName: config.appName }); translation = JSON.parse(translation); // Ensure page response contains translation of request locale for (const value of Object.values(translation)) { expect(response.text).toContain(value); } }); it('localizes feature page with JSON resource and fills placeholders in JSON values', async () => { // Fake any page to load the JSON page file spyOnProperty(Page.prototype, 'defaultFile').and.returnValue(jsonPageFile); const response = await request({ url: `http://localhost:8378/1/apps/test/request_password_reset?token=exampleToken&locale=${exampleLocale}`, followRedirects: false, }).catch(e => e); expect(response.status).toEqual(200); // Fill placeholders in transation let translation = jsonResource[exampleLocale].translation; translation = JSON.stringify(translation); translation = mustache.render(translation, { appName: config.appName }); translation = JSON.parse(translation); // Ensure page response contains translation of request locale for (const value of Object.values(translation)) { expect(response.text).toContain(value); } }); }); describe('response type', () => { it('returns a file for GET request', async () => { await expectAsync(router.goToPage(req, pages.passwordResetLinkInvalid)).toBeResolved(); expect(pageResponse).toHaveBeenCalled(); expect(redirectResponse).not.toHaveBeenCalled(); }); it('returns a redirect for POST request', async () => { req.method = 'POST'; await expectAsync(router.goToPage(req, pages.passwordResetLinkInvalid)).toBeResolved(); expect(pageResponse).not.toHaveBeenCalled(); expect(redirectResponse).toHaveBeenCalled(); }); it('returns a redirect for custom pages for GET and POST request', async () => { req.config.pages.customUrls = { passwordResetLinkInvalid: 'http://invalid-link.example.com', }; for (const method of ['GET', 'POST']) { req.method = method; await expectAsync(router.goToPage(req, pages.passwordResetLinkInvalid)).toBeResolved(); expect(pageResponse).not.toHaveBeenCalled(); expect(redirectResponse).toHaveBeenCalled(); } }); it('responds to POST request with redirect response', async () => { await reconfigureServer(config); const response = await request({ url: 'http://localhost:8378/1/apps/test/request_password_reset?token=exampleToken&locale=de-AT', followRedirects: false, method: 'POST', }); expect(response.status).toEqual(303); expect(response.headers.location).toContain( 'http://localhost:8378/1/apps/de-AT/password_reset_link_invalid.html' ); }); it('responds to GET request with content response', async () => { await reconfigureServer(config); const response = await request({ url: 'http://localhost:8378/1/apps/test/request_password_reset?token=exampleToken&locale=de-AT', followRedirects: false, method: 'GET', }); expect(response.status).toEqual(200); expect(response.text).toContain(''); }); }); describe('end-to-end tests', () => { it('localizes end-to-end for password reset: success', async () => { await reconfigureServer(config); const sendPasswordResetEmail = spyOn( config.emailAdapter, 'sendPasswordResetEmail' ).and.callThrough(); const user = new Parse.User(); user.setUsername('exampleUsername'); user.setPassword('examplePassword'); user.set('email', 'mail@example.com'); await user.signUp(); await Parse.User.requestPasswordReset(user.getEmail()); const link = sendPasswordResetEmail.calls.all()[0].args[0].link; const linkWithLocale = new URL(link); linkWithLocale.searchParams.append(pageParams.locale, exampleLocale); const linkResponse = await request({ url: linkWithLocale.toString(), followRedirects: false, }); expect(linkResponse.status).toBe(200); const appId = linkResponse.headers['x-parse-page-param-appid']; const token = linkResponse.headers['x-parse-page-param-token']; const locale = linkResponse.headers['x-parse-page-param-locale']; const publicServerUrl = linkResponse.headers['x-parse-page-param-publicserverurl']; const passwordResetPagePath = pageResponse.calls.all()[0].args[0]; expect(appId).toBeDefined(); expect(token).toBeDefined(); expect(locale).toBeDefined(); expect(publicServerUrl).toBeDefined(); expect(passwordResetPagePath).toMatch( new RegExp(`\/${exampleLocale}\/${pages.passwordReset.defaultFile}`) ); pageResponse.calls.reset(); const formUrl = `${publicServerUrl}/apps/${appId}/request_password_reset`; const formResponse = await request({ url: formUrl, method: 'POST', body: { token, locale, new_password: 'newPassword', }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, followRedirects: false, }); expect(formResponse.status).toEqual(200); expect(pageResponse.calls.all()[0].args[0]).toContain( `/${locale}/${pages.passwordResetSuccess.defaultFile}` ); }); it('localizes end-to-end for password reset: invalid link', async () => { await reconfigureServer(config); const sendPasswordResetEmail = spyOn( config.emailAdapter, 'sendPasswordResetEmail' ).and.callThrough(); const user = new Parse.User(); user.setUsername('exampleUsername'); user.setPassword('examplePassword'); user.set('email', 'mail@example.com'); await user.signUp(); await Parse.User.requestPasswordReset(user.getEmail()); const link = sendPasswordResetEmail.calls.all()[0].args[0].link; const linkWithLocale = new URL(link); linkWithLocale.searchParams.append(pageParams.locale, exampleLocale); linkWithLocale.searchParams.set(pageParams.token, 'invalidToken'); const linkResponse = await request({ url: linkWithLocale.toString(), followRedirects: false, }); expect(linkResponse.status).toBe(200); const pagePath = pageResponse.calls.all()[0].args[0]; expect(pagePath).toMatch( new RegExp(`\/${exampleLocale}\/${pages.passwordResetLinkInvalid.defaultFile}`) ); }); it_id('2845c2ea-23ba-45d2-a33f-63181d419bca')(it)('localizes end-to-end for verify email: success', async () => { await reconfigureServer(config); const sendVerificationEmail = spyOn( config.emailAdapter, 'sendVerificationEmail' ).and.callThrough(); const user = new Parse.User(); user.setUsername('exampleUsername'); user.setPassword('examplePassword'); user.set('email', 'mail@example.com'); await user.signUp(); await jasmine.timeout(); const link = sendVerificationEmail.calls.all()[0].args[0].link; const linkWithLocale = new URL(link); linkWithLocale.searchParams.append(pageParams.locale, exampleLocale); const linkResponse = await request({ url: linkWithLocale.toString(), followRedirects: false, }); expect(linkResponse.status).toBe(200); const pagePath = pageResponse.calls.all()[0].args[0]; expect(pagePath).toMatch( new RegExp(`\/${exampleLocale}\/${pages.emailVerificationSuccess.defaultFile}`) ); }); it_id('f2272b94-b4ac-474f-8e47-1ca74de136f5')(it)('localizes end-to-end for verify email: invalid verification link - link send success', async () => { await reconfigureServer(config); const sendVerificationEmail = spyOn( config.emailAdapter, 'sendVerificationEmail' ).and.callThrough(); const user = new Parse.User(); user.setUsername('exampleUsername'); user.setPassword('examplePassword'); user.set('email', 'mail@example.com'); await user.signUp(); await jasmine.timeout(); const link = sendVerificationEmail.calls.all()[0].args[0].link; const linkWithLocale = new URL(link); linkWithLocale.searchParams.append(pageParams.locale, exampleLocale); linkWithLocale.searchParams.set(pageParams.token, 'invalidToken'); const linkResponse = await request({ url: linkWithLocale.toString(), followRedirects: false, }); expect(linkResponse.status).toBe(200); const appId = linkResponse.headers['x-parse-page-param-appid']; const locale = linkResponse.headers['x-parse-page-param-locale']; const publicServerUrl = linkResponse.headers['x-parse-page-param-publicserverurl']; const invalidVerificationPagePath = pageResponse.calls.all()[0].args[0]; expect(appId).toBeDefined(); expect(locale).toBe(exampleLocale); expect(publicServerUrl).toBeDefined(); expect(invalidVerificationPagePath).toMatch( new RegExp(`\/${exampleLocale}\/${pages.emailVerificationLinkInvalid.defaultFile}`) ); const formUrl = `${publicServerUrl}/apps/${appId}/resend_verification_email`; const formResponse = await request({ url: formUrl, method: 'POST', body: { locale, username: 'exampleUsername', }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, followRedirects: false, }); expect(formResponse.status).toEqual(303); expect(formResponse.text).toContain( `/${locale}/${pages.emailVerificationSendSuccess.defaultFile}` ); }); it_id('1d46d36a-e455-4ae7-8717-e0d286e95f02')(it)('localizes end-to-end for verify email: invalid verification link - link send fail', async () => { await reconfigureServer(config); const sendVerificationEmail = spyOn( config.emailAdapter, 'sendVerificationEmail' ).and.callThrough(); const user = new Parse.User(); user.setUsername('exampleUsername'); user.setPassword('examplePassword'); user.set('email', 'mail@example.com'); await user.signUp(); await jasmine.timeout(); const link = sendVerificationEmail.calls.all()[0].args[0].link; const linkWithLocale = new URL(link); linkWithLocale.searchParams.append(pageParams.locale, exampleLocale); linkWithLocale.searchParams.set(pageParams.token, 'invalidToken'); const linkResponse = await request({ url: linkWithLocale.toString(), followRedirects: false, }); expect(linkResponse.status).toBe(200); const appId = linkResponse.headers['x-parse-page-param-appid']; const locale = linkResponse.headers['x-parse-page-param-locale']; const publicServerUrl = linkResponse.headers['x-parse-page-param-publicserverurl']; await jasmine.timeout(); const invalidVerificationPagePath = pageResponse.calls.all()[0].args[0]; expect(appId).toBeDefined(); expect(locale).toBe(exampleLocale); expect(publicServerUrl).toBeDefined(); expect(invalidVerificationPagePath).toMatch( new RegExp(`\/${exampleLocale}\/${pages.emailVerificationLinkInvalid.defaultFile}`) ); spyOn(UserController.prototype, 'resendVerificationEmail').and.callFake(() => Promise.reject('failed to resend verification email') ); const formUrl = `${publicServerUrl}/apps/${appId}/resend_verification_email`; const formResponse = await request({ url: formUrl, method: 'POST', body: { locale, username: 'exampleUsername', }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, followRedirects: false, }); expect(formResponse.status).toEqual(303); expect(formResponse.text).toContain( `/${locale}/${pages.emailVerificationSendFail.defaultFile}` ); }); it('localizes end-to-end for resend verification email: invalid link', async () => { await reconfigureServer(config); const formUrl = `${config.publicServerURL}/apps/${config.appId}/resend_verification_email`; const formResponse = await request({ url: formUrl, method: 'POST', body: { locale: exampleLocale, }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, followRedirects: false, }); expect(formResponse.status).toEqual(303); expect(formResponse.text).toContain( `/${exampleLocale}/${pages.emailVerificationLinkInvalid.defaultFile}` ); }); }); describe('failing with missing parameters', () => { it('verifyEmail: throws on missing server configuration', async () => { delete req.config; const verifyEmail = req => (() => new PagesRouter().verifyEmail(req)).bind(null); expect(verifyEmail(req)).toThrow(); }); it('resendVerificationEmail: throws on missing server configuration', async () => { delete req.config; const resendVerificationEmail = req => (() => new PagesRouter().resendVerificationEmail(req)).bind(null); expect(resendVerificationEmail(req)).toThrow(); }); it('requestResetPassword: throws on missing server configuration', async () => { delete req.config; const requestResetPassword = req => (() => new PagesRouter().requestResetPassword(req)).bind(null); expect(requestResetPassword(req)).toThrow(); }); it('resetPassword: throws on missing server configuration', async () => { delete req.config; const resetPassword = req => (() => new PagesRouter().resetPassword(req)).bind(null); expect(resetPassword(req)).toThrow(); }); it('verifyEmail: responds with invalid link on missing username', async () => { req.query.token = 'exampleToken'; req.params = {}; req.config.userController = { verifyEmail: () => Promise.reject() }; const verifyEmail = req => new PagesRouter().verifyEmail(req); await verifyEmail(req); expect(goToPage.calls.all()[0].args[1]).toBe(pages.emailVerificationLinkInvalid); }); it('resetPassword: responds with page choose password with error message on failed password update', async () => { req.body = { token: 'exampleToken', username: 'exampleUsername', new_password: 'examplePassword', }; const error = 'exampleError'; req.config.userController = { updatePassword: () => Promise.reject(error) }; const resetPassword = req => new PagesRouter().resetPassword(req); await resetPassword(req); expect(goToPage.calls.all()[0].args[1]).toBe(pages.passwordReset); expect(goToPage.calls.all()[0].args[2].error).toBe(error); }); it('resetPassword: responds with AJAX error with error message on failed password update', async () => { req.xhr = true; req.body = { token: 'exampleToken', username: 'exampleUsername', new_password: 'examplePassword', }; const error = 'exampleError'; req.config.userController = { updatePassword: () => Promise.reject(error) }; const resetPassword = req => new PagesRouter().resetPassword(req).catch(e => e); const response = await resetPassword(req); expect(response.code).toBe(Parse.Error.OTHER_CAUSE); }); }); describe('exploits', () => { it('rejects requesting file outside of pages scope with UNIX path patterns', async () => { await reconfigureServer(config); // Do not compose this URL with `new URL(...)` because that would normalize // the URL and remove path patterns; the path patterns must reach the router const url = `${config.publicServerURL}/apps/../.gitignore`; const response = await request({ url: url, followRedirects: false, }).catch(e => e); expect(response.status).toBe(404); expect(response.text).toBe('Not found.'); }); }); describe('custom route', () => { it('handles custom route with GET', async () => { config.pages.customRoutes = [ { method: 'GET', path: 'custom_page', handler: async req => { expect(req).toBeDefined(); expect(req.method).toBe('GET'); return { file: 'custom_page.html' }; }, }, ]; await reconfigureServer(config); const handlerSpy = spyOn(config.pages.customRoutes[0], 'handler').and.callThrough(); const url = `${config.publicServerURL}/apps/${config.appId}/custom_page`; const response = await request({ url: url, followRedirects: false, }).catch(e => e); expect(response.status).toBe(200); expect(response.text).toMatch(config.appName); expect(handlerSpy).toHaveBeenCalled(); }); it('handles custom route with POST', async () => { config.pages.customRoutes = [ { method: 'POST', path: 'custom_page', handler: async req => { expect(req).toBeDefined(); expect(req.method).toBe('POST'); return { file: 'custom_page.html' }; }, }, ]; const handlerSpy = spyOn(config.pages.customRoutes[0], 'handler').and.callThrough(); await reconfigureServer(config); const url = `${config.publicServerURL}/apps/${config.appId}/custom_page`; const response = await request({ url: url, followRedirects: false, method: 'POST', }).catch(e => e); expect(response.status).toBe(200); expect(response.text).toMatch(config.appName); expect(handlerSpy).toHaveBeenCalled(); }); it('handles multiple custom routes', async () => { config.pages.customRoutes = [ { method: 'GET', path: 'custom_page', handler: async req => { expect(req).toBeDefined(); expect(req.method).toBe('GET'); return { file: 'custom_page.html' }; }, }, { method: 'POST', path: 'custom_page', handler: async req => { expect(req).toBeDefined(); expect(req.method).toBe('POST'); return { file: 'custom_page.html' }; }, }, ]; const getHandlerSpy = spyOn(config.pages.customRoutes[0], 'handler').and.callThrough(); const postHandlerSpy = spyOn(config.pages.customRoutes[1], 'handler').and.callThrough(); await reconfigureServer(config); const url = `${config.publicServerURL}/apps/${config.appId}/custom_page`; const getResponse = await request({ url: url, followRedirects: false, method: 'GET', }).catch(e => e); expect(getResponse.status).toBe(200); expect(getResponse.text).toMatch(config.appName); expect(getHandlerSpy).toHaveBeenCalled(); const postResponse = await request({ url: url, followRedirects: false, method: 'POST', }).catch(e => e); expect(postResponse.status).toBe(200); expect(postResponse.text).toMatch(config.appName); expect(postHandlerSpy).toHaveBeenCalled(); }); it('handles custom route with async handler', async () => { config.pages.customRoutes = [ { method: 'GET', path: 'custom_page', handler: async req => { expect(req).toBeDefined(); expect(req.method).toBe('GET'); const file = await new Promise(resolve => setTimeout(resolve('custom_page.html'), 1000) ); return { file }; }, }, ]; await reconfigureServer(config); const handlerSpy = spyOn(config.pages.customRoutes[0], 'handler').and.callThrough(); const url = `${config.publicServerURL}/apps/${config.appId}/custom_page`; const response = await request({ url: url, followRedirects: false, }).catch(e => e); expect(response.status).toBe(200); expect(response.text).toMatch(config.appName); expect(handlerSpy).toHaveBeenCalled(); }); it('returns 404 if custom route does not return page', async () => { config.pages.customRoutes = [ { method: 'GET', path: 'custom_page', handler: async () => {}, }, ]; await reconfigureServer(config); const handlerSpy = spyOn(config.pages.customRoutes[0], 'handler').and.callThrough(); const url = `${config.publicServerURL}/apps/${config.appId}/custom_page`; const response = await request({ url: url, followRedirects: false, }).catch(e => e); expect(response.status).toBe(404); expect(response.text).toMatch('Not found'); expect(handlerSpy).toHaveBeenCalled(); }); }); describe('custom endpoint', () => { it('password reset works with custom endpoint', async () => { config.pages.pagesEndpoint = 'customEndpoint'; await reconfigureServer(config); const sendPasswordResetEmail = spyOn( config.emailAdapter, 'sendPasswordResetEmail' ).and.callThrough(); const user = new Parse.User(); user.setUsername('exampleUsername'); user.setPassword('examplePassword'); user.set('email', 'mail@example.com'); await user.signUp(); await Parse.User.requestPasswordReset(user.getEmail()); const link = sendPasswordResetEmail.calls.all()[0].args[0].link; const linkResponse = await request({ url: link, followRedirects: false, }); expect(linkResponse.status).toBe(200); const appId = linkResponse.headers['x-parse-page-param-appid']; const token = linkResponse.headers['x-parse-page-param-token']; const publicServerUrl = linkResponse.headers['x-parse-page-param-publicserverurl']; const passwordResetPagePath = pageResponse.calls.all()[0].args[0]; expect(appId).toBeDefined(); expect(token).toBeDefined(); expect(publicServerUrl).toBeDefined(); expect(passwordResetPagePath).toMatch(new RegExp(`\/${pages.passwordReset.defaultFile}`)); pageResponse.calls.reset(); const formUrl = `${publicServerUrl}/${config.pages.pagesEndpoint}/${appId}/request_password_reset`; const formResponse = await request({ url: formUrl, method: 'POST', body: { token, new_password: 'newPassword', }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, followRedirects: false, }); expect(formResponse.status).toEqual(200); expect(pageResponse.calls.all()[0].args[0]).toContain( `/${pages.passwordResetSuccess.defaultFile}` ); }); it_id('81c1c28e-5dfd-4ffb-a09b-283156c08483')(it)('email verification works with custom endpoint', async () => { config.pages.pagesEndpoint = 'customEndpoint'; await reconfigureServer(config); const sendVerificationEmail = spyOn( config.emailAdapter, 'sendVerificationEmail' ).and.callThrough(); const user = new Parse.User(); user.setUsername('exampleUsername'); user.setPassword('examplePassword'); user.set('email', 'mail@example.com'); await user.signUp(); await jasmine.timeout(); const link = sendVerificationEmail.calls.all()[0].args[0].link; const linkResponse = await request({ url: link, followRedirects: false, }); expect(linkResponse.status).toBe(200); const pagePath = pageResponse.calls.all()[0].args[0]; expect(pagePath).toMatch(new RegExp(`\/${pages.emailVerificationSuccess.defaultFile}`)); }); }); }); describe('XSS Protection', () => { beforeEach(async () => { await reconfigureServer({ appId: 'test', appName: 'exampleAppname', publicServerURL: 'http://localhost:8378/1', pages: { enableRouter: true }, }); }); it('should escape XSS payloads in token parameter', async () => { const xssPayload = '">'; const response = await request({ url: `http://localhost:8378/1/apps/choose_password?token=${encodeURIComponent(xssPayload)}&username=test&appId=test`, }); expect(response.status).toBe(200); expect(response.text).not.toContain(''); expect(response.text).toContain('"><script>'); }); it('should escape XSS in username parameter', async () => { const xssUsername = ''; const response = await request({ url: `http://localhost:8378/1/apps/choose_password?username=${encodeURIComponent(xssUsername)}&appId=test`, }); expect(response.status).toBe(200); expect(response.text).not.toContain(''); expect(response.text).toContain('<img'); }); it('should escape XSS in locale parameter', async () => { const xssLocale = '">'; const response = await request({ url: `http://localhost:8378/1/apps/choose_password?locale=${encodeURIComponent(xssLocale)}&appId=test`, }); expect(response.status).toBe(200); expect(response.text).not.toContain(''); expect(response.text).toContain('"><svg'); }); it('should handle legitimate usernames with quotes correctly', async () => { const username = "O'Brien"; const response = await request({ url: `http://localhost:8378/1/apps/choose_password?username=${encodeURIComponent(username)}&appId=test`, }); expect(response.status).toBe(200); // Should be properly escaped as HTML entity expect(response.text).toContain('O'Brien'); // Should NOT contain unescaped quote that breaks HTML expect(response.text).not.toContain('value="O\'Brien"'); }); it('should handle legitimate usernames with ampersands correctly', async () => { const username = 'Smith & Co'; const response = await request({ url: `http://localhost:8378/1/apps/choose_password?username=${encodeURIComponent(username)}&appId=test`, }); expect(response.status).toBe(200); // Should be properly escaped expect(response.text).toContain('Smith & Co'); }); }); });