shop-auth.e2e-spec.ts 42 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131
  1. /* tslint:disable:no-non-null-assertion */
  2. import { OnModuleInit } from '@nestjs/common';
  3. import { ErrorCode, RegisterCustomerInput } from '@vendure/common/lib/generated-shop-types';
  4. import { pick } from '@vendure/common/lib/pick';
  5. import {
  6. AccountRegistrationEvent,
  7. EventBus,
  8. EventBusModule,
  9. IdentifierChangeEvent,
  10. IdentifierChangeRequestEvent,
  11. mergeConfig,
  12. PasswordResetEvent,
  13. PasswordValidationStrategy,
  14. RequestContext,
  15. VendurePlugin,
  16. } from '@vendure/core';
  17. import { createErrorResultGuard, createTestEnvironment, ErrorResultGuard } from '@vendure/testing';
  18. import { DocumentNode } from 'graphql';
  19. import gql from 'graphql-tag';
  20. import path from 'path';
  21. import { initialData } from '../../../e2e-common/e2e-initial-data';
  22. import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
  23. import { PasswordValidationError } from '../src/common/error/generated-graphql-shop-errors';
  24. import * as Codegen from './graphql/generated-e2e-admin-types';
  25. import { HistoryEntryType, Permission } from './graphql/generated-e2e-admin-types';
  26. import * as CodegenShop from './graphql/generated-e2e-shop-types';
  27. import { CurrentUserShopFragment } from './graphql/generated-e2e-shop-types';
  28. import {
  29. CREATE_ADMINISTRATOR,
  30. CREATE_ROLE,
  31. GET_CUSTOMER,
  32. GET_CUSTOMER_HISTORY,
  33. GET_CUSTOMER_LIST,
  34. } from './graphql/shared-definitions';
  35. import {
  36. GET_ACTIVE_CUSTOMER,
  37. REFRESH_TOKEN,
  38. REGISTER_ACCOUNT,
  39. REQUEST_PASSWORD_RESET,
  40. REQUEST_UPDATE_EMAIL_ADDRESS,
  41. RESET_PASSWORD,
  42. UPDATE_EMAIL_ADDRESS,
  43. VERIFY_EMAIL,
  44. } from './graphql/shop-definitions';
  45. let sendEmailFn: jest.Mock;
  46. /**
  47. * This mock plugin simulates an EmailPlugin which would send emails
  48. * on the registration & password reset events.
  49. */
  50. @VendurePlugin({
  51. imports: [EventBusModule],
  52. })
  53. class TestEmailPlugin implements OnModuleInit {
  54. constructor(private eventBus: EventBus) {}
  55. onModuleInit() {
  56. this.eventBus.ofType(AccountRegistrationEvent).subscribe(event => {
  57. sendEmailFn(event);
  58. });
  59. this.eventBus.ofType(PasswordResetEvent).subscribe(event => {
  60. sendEmailFn(event);
  61. });
  62. this.eventBus.ofType(IdentifierChangeRequestEvent).subscribe(event => {
  63. sendEmailFn(event);
  64. });
  65. this.eventBus.ofType(IdentifierChangeEvent).subscribe(event => {
  66. sendEmailFn(event);
  67. });
  68. }
  69. }
  70. const successErrorGuard: ErrorResultGuard<{ success: boolean }> = createErrorResultGuard(
  71. input => input.success != null,
  72. );
  73. const currentUserErrorGuard: ErrorResultGuard<CurrentUserShopFragment> = createErrorResultGuard(
  74. input => input.identifier != null,
  75. );
  76. class TestPasswordValidationStrategy implements PasswordValidationStrategy {
  77. validate(ctx: RequestContext, password: string): boolean | string {
  78. if (password === 'test') {
  79. // allow the default seed data password
  80. return true;
  81. }
  82. if (password.length < 8) {
  83. return 'Password must be more than 8 characters';
  84. }
  85. if (password === '12345678') {
  86. return `Don't use 12345678!`;
  87. }
  88. return true;
  89. }
  90. }
  91. describe('Shop auth & accounts', () => {
  92. const { server, adminClient, shopClient } = createTestEnvironment(
  93. mergeConfig(testConfig(), {
  94. plugins: [TestEmailPlugin as any],
  95. authOptions: {
  96. passwordValidationStrategy: new TestPasswordValidationStrategy(),
  97. },
  98. }),
  99. );
  100. beforeAll(async () => {
  101. await server.init({
  102. initialData,
  103. productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
  104. customerCount: 2,
  105. });
  106. await adminClient.asSuperAdmin();
  107. }, TEST_SETUP_TIMEOUT_MS);
  108. afterAll(async () => {
  109. await server.destroy();
  110. });
  111. describe('customer account creation with deferred password', () => {
  112. const password = 'password';
  113. const emailAddress = 'test1@test.com';
  114. let verificationToken: string;
  115. let newCustomerId: string;
  116. beforeEach(() => {
  117. sendEmailFn = jest.fn();
  118. });
  119. it('does not return error result on email address conflict', async () => {
  120. // To prevent account enumeration attacks
  121. const { customers } = await adminClient.query<Codegen.GetCustomerListQuery>(GET_CUSTOMER_LIST);
  122. const input: RegisterCustomerInput = {
  123. firstName: 'Duplicate',
  124. lastName: 'Person',
  125. phoneNumber: '123456',
  126. emailAddress: customers.items[0].emailAddress,
  127. };
  128. const { registerCustomerAccount } = await shopClient.query<
  129. CodegenShop.RegisterMutation,
  130. CodegenShop.RegisterMutationVariables
  131. >(REGISTER_ACCOUNT, {
  132. input,
  133. });
  134. successErrorGuard.assertSuccess(registerCustomerAccount);
  135. });
  136. it('register a new account without password', async () => {
  137. const verificationTokenPromise = getVerificationTokenPromise();
  138. const input: RegisterCustomerInput = {
  139. firstName: 'Sean',
  140. lastName: 'Tester',
  141. phoneNumber: '123456',
  142. emailAddress,
  143. };
  144. const { registerCustomerAccount } = await shopClient.query<
  145. CodegenShop.RegisterMutation,
  146. CodegenShop.RegisterMutationVariables
  147. >(REGISTER_ACCOUNT, {
  148. input,
  149. });
  150. successErrorGuard.assertSuccess(registerCustomerAccount);
  151. verificationToken = await verificationTokenPromise;
  152. expect(registerCustomerAccount.success).toBe(true);
  153. expect(sendEmailFn).toHaveBeenCalled();
  154. expect(verificationToken).toBeDefined();
  155. const { customers } = await adminClient.query<
  156. Codegen.GetCustomerListQuery,
  157. Codegen.GetCustomerListQueryVariables
  158. >(GET_CUSTOMER_LIST, {
  159. options: {
  160. filter: {
  161. emailAddress: {
  162. eq: emailAddress,
  163. },
  164. },
  165. },
  166. });
  167. expect(
  168. pick(customers.items[0], ['firstName', 'lastName', 'emailAddress', 'phoneNumber']),
  169. ).toEqual(input);
  170. });
  171. it('issues a new token if attempting to register a second time', async () => {
  172. const sendEmail = new Promise<string>(resolve => {
  173. sendEmailFn.mockImplementation((event: AccountRegistrationEvent) => {
  174. resolve(event.user.getNativeAuthenticationMethod().verificationToken!);
  175. });
  176. });
  177. const input: RegisterCustomerInput = {
  178. firstName: 'Sean',
  179. lastName: 'Tester',
  180. emailAddress,
  181. };
  182. const { registerCustomerAccount } = await shopClient.query<
  183. CodegenShop.RegisterMutation,
  184. CodegenShop.RegisterMutationVariables
  185. >(REGISTER_ACCOUNT, {
  186. input,
  187. });
  188. successErrorGuard.assertSuccess(registerCustomerAccount);
  189. const newVerificationToken = await sendEmail;
  190. expect(registerCustomerAccount.success).toBe(true);
  191. expect(sendEmailFn).toHaveBeenCalled();
  192. expect(newVerificationToken).not.toBe(verificationToken);
  193. verificationToken = newVerificationToken;
  194. });
  195. it('refreshCustomerVerification issues a new token', async () => {
  196. const sendEmail = new Promise<string>(resolve => {
  197. sendEmailFn.mockImplementation((event: AccountRegistrationEvent) => {
  198. resolve(event.user.getNativeAuthenticationMethod().verificationToken!);
  199. });
  200. });
  201. const { refreshCustomerVerification } = await shopClient.query<
  202. CodegenShop.RefreshTokenMutation,
  203. CodegenShop.RefreshTokenMutationVariables
  204. >(REFRESH_TOKEN, { emailAddress });
  205. successErrorGuard.assertSuccess(refreshCustomerVerification);
  206. const newVerificationToken = await sendEmail;
  207. expect(refreshCustomerVerification.success).toBe(true);
  208. expect(sendEmailFn).toHaveBeenCalled();
  209. expect(newVerificationToken).not.toBe(verificationToken);
  210. verificationToken = newVerificationToken;
  211. });
  212. it('refreshCustomerVerification does nothing with an unrecognized emailAddress', async () => {
  213. const { refreshCustomerVerification } = await shopClient.query<
  214. CodegenShop.RefreshTokenMutation,
  215. CodegenShop.RefreshTokenMutationVariables
  216. >(REFRESH_TOKEN, {
  217. emailAddress: 'never-been-registered@test.com',
  218. });
  219. successErrorGuard.assertSuccess(refreshCustomerVerification);
  220. await waitForSendEmailFn();
  221. expect(refreshCustomerVerification.success).toBe(true);
  222. expect(sendEmailFn).not.toHaveBeenCalled();
  223. });
  224. it('login fails before verification', async () => {
  225. const result = await shopClient.asUserWithCredentials(emailAddress, '');
  226. expect(result.errorCode).toBe(ErrorCode.INVALID_CREDENTIALS_ERROR);
  227. });
  228. it('verification fails with wrong token', async () => {
  229. const { verifyCustomerAccount } = await shopClient.query<
  230. CodegenShop.VerifyMutation,
  231. CodegenShop.VerifyMutationVariables
  232. >(VERIFY_EMAIL, {
  233. password,
  234. token: 'bad-token',
  235. });
  236. currentUserErrorGuard.assertErrorResult(verifyCustomerAccount);
  237. expect(verifyCustomerAccount.message).toBe(`Verification token not recognized`);
  238. expect(verifyCustomerAccount.errorCode).toBe(ErrorCode.VERIFICATION_TOKEN_INVALID_ERROR);
  239. });
  240. it('verification fails with no password', async () => {
  241. const { verifyCustomerAccount } = await shopClient.query<
  242. CodegenShop.VerifyMutation,
  243. CodegenShop.VerifyMutationVariables
  244. >(VERIFY_EMAIL, {
  245. token: verificationToken,
  246. });
  247. currentUserErrorGuard.assertErrorResult(verifyCustomerAccount);
  248. expect(verifyCustomerAccount.message).toBe(`A password must be provided.`);
  249. expect(verifyCustomerAccount.errorCode).toBe(ErrorCode.MISSING_PASSWORD_ERROR);
  250. });
  251. it('verification fails with invalid password', async () => {
  252. const { verifyCustomerAccount } = await shopClient.query<
  253. CodegenShop.VerifyMutation,
  254. CodegenShop.VerifyMutationVariables
  255. >(VERIFY_EMAIL, {
  256. token: verificationToken,
  257. password: '2short',
  258. });
  259. currentUserErrorGuard.assertErrorResult(verifyCustomerAccount);
  260. expect(verifyCustomerAccount.message).toBe(`Password is invalid`);
  261. expect((verifyCustomerAccount as PasswordValidationError).validationErrorMessage).toBe(
  262. `Password must be more than 8 characters`,
  263. );
  264. expect(verifyCustomerAccount.errorCode).toBe(ErrorCode.PASSWORD_VALIDATION_ERROR);
  265. });
  266. it('verification succeeds with password and correct token', async () => {
  267. const { verifyCustomerAccount } = await shopClient.query<
  268. CodegenShop.VerifyMutation,
  269. CodegenShop.VerifyMutationVariables
  270. >(VERIFY_EMAIL, {
  271. password,
  272. token: verificationToken,
  273. });
  274. currentUserErrorGuard.assertSuccess(verifyCustomerAccount);
  275. expect(verifyCustomerAccount.identifier).toBe('test1@test.com');
  276. const { activeCustomer } = await shopClient.query<CodegenShop.GetActiveCustomerQuery>(
  277. GET_ACTIVE_CUSTOMER,
  278. );
  279. newCustomerId = activeCustomer!.id;
  280. });
  281. it('registration silently fails if attempting to register an email already verified', async () => {
  282. const input: RegisterCustomerInput = {
  283. firstName: 'Dodgy',
  284. lastName: 'Hacker',
  285. emailAddress,
  286. };
  287. const { registerCustomerAccount } = await shopClient.query<
  288. CodegenShop.RegisterMutation,
  289. CodegenShop.RegisterMutationVariables
  290. >(REGISTER_ACCOUNT, {
  291. input,
  292. });
  293. successErrorGuard.assertSuccess(registerCustomerAccount);
  294. await waitForSendEmailFn();
  295. expect(registerCustomerAccount.success).toBe(true);
  296. expect(sendEmailFn).not.toHaveBeenCalled();
  297. });
  298. it('verification fails if attempted a second time', async () => {
  299. const { verifyCustomerAccount } = await shopClient.query<
  300. CodegenShop.VerifyMutation,
  301. CodegenShop.VerifyMutationVariables
  302. >(VERIFY_EMAIL, {
  303. password,
  304. token: verificationToken,
  305. });
  306. currentUserErrorGuard.assertErrorResult(verifyCustomerAccount);
  307. expect(verifyCustomerAccount.message).toBe(`Verification token not recognized`);
  308. expect(verifyCustomerAccount.errorCode).toBe(ErrorCode.VERIFICATION_TOKEN_INVALID_ERROR);
  309. });
  310. it('customer history contains entries for registration & verification', async () => {
  311. const { customer } = await adminClient.query<
  312. Codegen.GetCustomerHistoryQuery,
  313. Codegen.GetCustomerHistoryQueryVariables
  314. >(GET_CUSTOMER_HISTORY, {
  315. id: newCustomerId,
  316. });
  317. expect(customer?.history.items.map(pick(['type', 'data']))).toEqual([
  318. {
  319. type: HistoryEntryType.CUSTOMER_REGISTERED,
  320. data: {
  321. strategy: 'native',
  322. },
  323. },
  324. {
  325. // second entry because we register twice above
  326. type: HistoryEntryType.CUSTOMER_REGISTERED,
  327. data: {
  328. strategy: 'native',
  329. },
  330. },
  331. {
  332. type: HistoryEntryType.CUSTOMER_VERIFIED,
  333. data: {
  334. strategy: 'native',
  335. },
  336. },
  337. ]);
  338. });
  339. });
  340. describe('customer account creation with up-front password', () => {
  341. const password = 'password';
  342. const emailAddress = 'test2@test.com';
  343. let verificationToken: string;
  344. it('registerCustomerAccount fails with invalid password', async () => {
  345. const input: RegisterCustomerInput = {
  346. firstName: 'Lu',
  347. lastName: 'Tester',
  348. phoneNumber: '443324',
  349. emailAddress,
  350. password: '12345678',
  351. };
  352. const { registerCustomerAccount } = await shopClient.query<
  353. CodegenShop.RegisterMutation,
  354. CodegenShop.RegisterMutationVariables
  355. >(REGISTER_ACCOUNT, {
  356. input,
  357. });
  358. successErrorGuard.assertErrorResult(registerCustomerAccount);
  359. expect(registerCustomerAccount.errorCode).toBe(ErrorCode.PASSWORD_VALIDATION_ERROR);
  360. expect(registerCustomerAccount.message).toBe(`Password is invalid`);
  361. expect((registerCustomerAccount as PasswordValidationError).validationErrorMessage).toBe(
  362. `Don't use 12345678!`,
  363. );
  364. });
  365. it('register a new account with password', async () => {
  366. const verificationTokenPromise = getVerificationTokenPromise();
  367. const input: RegisterCustomerInput = {
  368. firstName: 'Lu',
  369. lastName: 'Tester',
  370. phoneNumber: '443324',
  371. emailAddress,
  372. password,
  373. };
  374. const { registerCustomerAccount } = await shopClient.query<
  375. CodegenShop.RegisterMutation,
  376. CodegenShop.RegisterMutationVariables
  377. >(REGISTER_ACCOUNT, {
  378. input,
  379. });
  380. successErrorGuard.assertSuccess(registerCustomerAccount);
  381. verificationToken = await verificationTokenPromise;
  382. expect(registerCustomerAccount.success).toBe(true);
  383. expect(sendEmailFn).toHaveBeenCalled();
  384. expect(verificationToken).toBeDefined();
  385. const { customers } = await adminClient.query<
  386. Codegen.GetCustomerListQuery,
  387. Codegen.GetCustomerListQueryVariables
  388. >(GET_CUSTOMER_LIST, {
  389. options: {
  390. filter: {
  391. emailAddress: {
  392. eq: emailAddress,
  393. },
  394. },
  395. },
  396. });
  397. expect(
  398. pick(customers.items[0], ['firstName', 'lastName', 'emailAddress', 'phoneNumber']),
  399. ).toEqual(pick(input, ['firstName', 'lastName', 'emailAddress', 'phoneNumber']));
  400. });
  401. it('login fails before verification', async () => {
  402. const result = await shopClient.asUserWithCredentials(emailAddress, password);
  403. expect(result.errorCode).toBe(ErrorCode.NOT_VERIFIED_ERROR);
  404. expect(result.message).toBe('Please verify this email address before logging in');
  405. });
  406. it('verification fails with password', async () => {
  407. const { verifyCustomerAccount } = await shopClient.query<
  408. CodegenShop.VerifyMutation,
  409. CodegenShop.VerifyMutationVariables
  410. >(VERIFY_EMAIL, {
  411. token: verificationToken,
  412. password: 'new password',
  413. });
  414. currentUserErrorGuard.assertErrorResult(verifyCustomerAccount);
  415. expect(verifyCustomerAccount.message).toBe(`A password has already been set during registration`);
  416. expect(verifyCustomerAccount.errorCode).toBe(ErrorCode.PASSWORD_ALREADY_SET_ERROR);
  417. });
  418. it('verification succeeds with no password and correct token', async () => {
  419. const { verifyCustomerAccount } = await shopClient.query<
  420. CodegenShop.VerifyMutation,
  421. CodegenShop.VerifyMutationVariables
  422. >(VERIFY_EMAIL, {
  423. token: verificationToken,
  424. });
  425. currentUserErrorGuard.assertSuccess(verifyCustomerAccount);
  426. expect(verifyCustomerAccount.identifier).toBe('test2@test.com');
  427. const { activeCustomer } = await shopClient.query<CodegenShop.GetActiveCustomerQuery>(
  428. GET_ACTIVE_CUSTOMER,
  429. );
  430. });
  431. });
  432. describe('password reset', () => {
  433. let passwordResetToken: string;
  434. let customer: Codegen.GetCustomerQuery['customer'];
  435. beforeAll(async () => {
  436. const result = await adminClient.query<
  437. Codegen.GetCustomerQuery,
  438. Codegen.GetCustomerQueryVariables
  439. >(GET_CUSTOMER, {
  440. id: 'T_1',
  441. });
  442. customer = result.customer!;
  443. });
  444. beforeEach(() => {
  445. sendEmailFn = jest.fn();
  446. });
  447. it('requestPasswordReset silently fails with invalid identifier', async () => {
  448. const { requestPasswordReset } = await shopClient.query<
  449. CodegenShop.RequestPasswordResetMutation,
  450. CodegenShop.RequestPasswordResetMutationVariables
  451. >(REQUEST_PASSWORD_RESET, {
  452. identifier: 'invalid-identifier',
  453. });
  454. successErrorGuard.assertSuccess(requestPasswordReset);
  455. await waitForSendEmailFn();
  456. expect(requestPasswordReset.success).toBe(true);
  457. expect(sendEmailFn).not.toHaveBeenCalled();
  458. expect(passwordResetToken).not.toBeDefined();
  459. });
  460. it('requestPasswordReset sends reset token', async () => {
  461. const passwordResetTokenPromise = getPasswordResetTokenPromise();
  462. const { requestPasswordReset } = await shopClient.query<
  463. CodegenShop.RequestPasswordResetMutation,
  464. CodegenShop.RequestPasswordResetMutationVariables
  465. >(REQUEST_PASSWORD_RESET, {
  466. identifier: customer!.emailAddress,
  467. });
  468. successErrorGuard.assertSuccess(requestPasswordReset);
  469. passwordResetToken = await passwordResetTokenPromise;
  470. expect(requestPasswordReset.success).toBe(true);
  471. expect(sendEmailFn).toHaveBeenCalled();
  472. expect(passwordResetToken).toBeDefined();
  473. });
  474. it('resetPassword returns error result with wrong token', async () => {
  475. const { resetPassword } = await shopClient.query<
  476. CodegenShop.ResetPasswordMutation,
  477. CodegenShop.ResetPasswordMutationVariables
  478. >(RESET_PASSWORD, {
  479. password: 'newPassword',
  480. token: 'bad-token',
  481. });
  482. currentUserErrorGuard.assertErrorResult(resetPassword);
  483. expect(resetPassword.message).toBe(`Password reset token not recognized`);
  484. expect(resetPassword.errorCode).toBe(ErrorCode.PASSWORD_RESET_TOKEN_INVALID_ERROR);
  485. });
  486. it('resetPassword fails with invalid password', async () => {
  487. const { resetPassword } = await shopClient.query<
  488. CodegenShop.ResetPasswordMutation,
  489. CodegenShop.ResetPasswordMutationVariables
  490. >(RESET_PASSWORD, {
  491. token: passwordResetToken,
  492. password: '2short',
  493. });
  494. currentUserErrorGuard.assertErrorResult(resetPassword);
  495. expect(resetPassword.message).toBe(`Password is invalid`);
  496. expect((resetPassword as PasswordValidationError).validationErrorMessage).toBe(
  497. `Password must be more than 8 characters`,
  498. );
  499. expect(resetPassword.errorCode).toBe(ErrorCode.PASSWORD_VALIDATION_ERROR);
  500. });
  501. it('resetPassword works with valid token', async () => {
  502. const { resetPassword } = await shopClient.query<
  503. CodegenShop.ResetPasswordMutation,
  504. CodegenShop.ResetPasswordMutationVariables
  505. >(RESET_PASSWORD, {
  506. token: passwordResetToken,
  507. password: 'newPassword',
  508. });
  509. currentUserErrorGuard.assertSuccess(resetPassword);
  510. expect(resetPassword.identifier).toBe(customer!.emailAddress);
  511. const loginResult = await shopClient.asUserWithCredentials(customer!.emailAddress, 'newPassword');
  512. expect(loginResult.identifier).toBe(customer!.emailAddress);
  513. });
  514. it('customer history for password reset', async () => {
  515. const result = await adminClient.query<
  516. Codegen.GetCustomerHistoryQuery,
  517. Codegen.GetCustomerHistoryQueryVariables
  518. >(GET_CUSTOMER_HISTORY, {
  519. id: customer!.id,
  520. options: {
  521. // skip CUSTOMER_ADDRESS_CREATED entry
  522. skip: 3,
  523. },
  524. });
  525. expect(result.customer?.history.items.map(pick(['type', 'data']))).toEqual([
  526. {
  527. type: HistoryEntryType.CUSTOMER_PASSWORD_RESET_REQUESTED,
  528. data: {},
  529. },
  530. {
  531. type: HistoryEntryType.CUSTOMER_PASSWORD_RESET_VERIFIED,
  532. data: {},
  533. },
  534. ]);
  535. });
  536. });
  537. describe('updating emailAddress', () => {
  538. let emailUpdateToken: string;
  539. let customer: Codegen.GetCustomerQuery['customer'];
  540. const NEW_EMAIL_ADDRESS = 'new@address.com';
  541. const PASSWORD = 'newPassword';
  542. beforeAll(async () => {
  543. const result = await adminClient.query<
  544. Codegen.GetCustomerQuery,
  545. Codegen.GetCustomerQueryVariables
  546. >(GET_CUSTOMER, {
  547. id: 'T_1',
  548. });
  549. customer = result.customer!;
  550. });
  551. beforeEach(() => {
  552. sendEmailFn = jest.fn();
  553. });
  554. it('throws if not logged in', async () => {
  555. try {
  556. await shopClient.asAnonymousUser();
  557. await shopClient.query<
  558. CodegenShop.RequestUpdateEmailAddressMutation,
  559. CodegenShop.RequestUpdateEmailAddressMutationVariables
  560. >(REQUEST_UPDATE_EMAIL_ADDRESS, {
  561. password: PASSWORD,
  562. newEmailAddress: NEW_EMAIL_ADDRESS,
  563. });
  564. fail('should have thrown');
  565. } catch (err: any) {
  566. expect(getErrorCode(err)).toBe('FORBIDDEN');
  567. }
  568. });
  569. it('return error result if password is incorrect', async () => {
  570. await shopClient.asUserWithCredentials(customer!.emailAddress, PASSWORD);
  571. const { requestUpdateCustomerEmailAddress } = await shopClient.query<
  572. CodegenShop.RequestUpdateEmailAddressMutation,
  573. CodegenShop.RequestUpdateEmailAddressMutationVariables
  574. >(REQUEST_UPDATE_EMAIL_ADDRESS, {
  575. password: 'bad password',
  576. newEmailAddress: NEW_EMAIL_ADDRESS,
  577. });
  578. successErrorGuard.assertErrorResult(requestUpdateCustomerEmailAddress);
  579. expect(requestUpdateCustomerEmailAddress.message).toBe('The provided credentials are invalid');
  580. expect(requestUpdateCustomerEmailAddress.errorCode).toBe(ErrorCode.INVALID_CREDENTIALS_ERROR);
  581. });
  582. it('return error result email address already in use', async () => {
  583. await shopClient.asUserWithCredentials(customer!.emailAddress, PASSWORD);
  584. const result = await adminClient.query<
  585. Codegen.GetCustomerQuery,
  586. Codegen.GetCustomerQueryVariables
  587. >(GET_CUSTOMER, {
  588. id: 'T_2',
  589. });
  590. const otherCustomer = result.customer!;
  591. const { requestUpdateCustomerEmailAddress } = await shopClient.query<
  592. CodegenShop.RequestUpdateEmailAddressMutation,
  593. CodegenShop.RequestUpdateEmailAddressMutationVariables
  594. >(REQUEST_UPDATE_EMAIL_ADDRESS, {
  595. password: PASSWORD,
  596. newEmailAddress: otherCustomer.emailAddress,
  597. });
  598. successErrorGuard.assertErrorResult(requestUpdateCustomerEmailAddress);
  599. expect(requestUpdateCustomerEmailAddress.message).toBe('The email address is not available.');
  600. expect(requestUpdateCustomerEmailAddress.errorCode).toBe(ErrorCode.EMAIL_ADDRESS_CONFLICT_ERROR);
  601. });
  602. it('triggers event with token', async () => {
  603. await shopClient.asUserWithCredentials(customer!.emailAddress, PASSWORD);
  604. const emailUpdateTokenPromise = getEmailUpdateTokenPromise();
  605. await shopClient.query<
  606. CodegenShop.RequestUpdateEmailAddressMutation,
  607. CodegenShop.RequestUpdateEmailAddressMutationVariables
  608. >(REQUEST_UPDATE_EMAIL_ADDRESS, {
  609. password: PASSWORD,
  610. newEmailAddress: NEW_EMAIL_ADDRESS,
  611. });
  612. const { identifierChangeToken, pendingIdentifier } = await emailUpdateTokenPromise;
  613. emailUpdateToken = identifierChangeToken!;
  614. expect(pendingIdentifier).toBe(NEW_EMAIL_ADDRESS);
  615. expect(emailUpdateToken).toBeTruthy();
  616. });
  617. it('cannot login with new email address before verification', async () => {
  618. const result = await shopClient.asUserWithCredentials(NEW_EMAIL_ADDRESS, PASSWORD);
  619. expect(result.errorCode).toBe(ErrorCode.INVALID_CREDENTIALS_ERROR);
  620. });
  621. it('return error result for bad token', async () => {
  622. const { updateCustomerEmailAddress } = await shopClient.query<
  623. CodegenShop.UpdateEmailAddressMutation,
  624. CodegenShop.UpdateEmailAddressMutationVariables
  625. >(UPDATE_EMAIL_ADDRESS, { token: 'bad token' });
  626. successErrorGuard.assertErrorResult(updateCustomerEmailAddress);
  627. expect(updateCustomerEmailAddress.message).toBe('Identifier change token not recognized');
  628. expect(updateCustomerEmailAddress.errorCode).toBe(
  629. ErrorCode.IDENTIFIER_CHANGE_TOKEN_INVALID_ERROR,
  630. );
  631. });
  632. it('verify the new email address', async () => {
  633. const { updateCustomerEmailAddress } = await shopClient.query<
  634. CodegenShop.UpdateEmailAddressMutation,
  635. CodegenShop.UpdateEmailAddressMutationVariables
  636. >(UPDATE_EMAIL_ADDRESS, { token: emailUpdateToken });
  637. successErrorGuard.assertSuccess(updateCustomerEmailAddress);
  638. expect(updateCustomerEmailAddress.success).toBe(true);
  639. // Allow for occasional race condition where the event does not
  640. // publish before the assertions are made.
  641. await new Promise(resolve => setTimeout(resolve, 10));
  642. expect(sendEmailFn).toHaveBeenCalled();
  643. expect(sendEmailFn.mock.calls[0][0] instanceof IdentifierChangeEvent).toBe(true);
  644. });
  645. it('can login with new email address after verification', async () => {
  646. await shopClient.asUserWithCredentials(NEW_EMAIL_ADDRESS, PASSWORD);
  647. const { activeCustomer } = await shopClient.query<CodegenShop.GetActiveCustomerQuery>(
  648. GET_ACTIVE_CUSTOMER,
  649. );
  650. expect(activeCustomer!.id).toBe(customer!.id);
  651. expect(activeCustomer!.emailAddress).toBe(NEW_EMAIL_ADDRESS);
  652. });
  653. it('cannot login with old email address after verification', async () => {
  654. const result = await shopClient.asUserWithCredentials(customer!.emailAddress, PASSWORD);
  655. expect(result.errorCode).toBe(ErrorCode.INVALID_CREDENTIALS_ERROR);
  656. });
  657. it('customer history for email update', async () => {
  658. const result = await adminClient.query<
  659. Codegen.GetCustomerHistoryQuery,
  660. Codegen.GetCustomerHistoryQueryVariables
  661. >(GET_CUSTOMER_HISTORY, {
  662. id: customer!.id,
  663. options: {
  664. skip: 5,
  665. },
  666. });
  667. expect(result.customer?.history.items.map(pick(['type', 'data']))).toEqual([
  668. {
  669. type: HistoryEntryType.CUSTOMER_EMAIL_UPDATE_REQUESTED,
  670. data: {
  671. newEmailAddress: 'new@address.com',
  672. oldEmailAddress: 'hayden.zieme12@hotmail.com',
  673. },
  674. },
  675. {
  676. type: HistoryEntryType.CUSTOMER_EMAIL_UPDATE_VERIFIED,
  677. data: {
  678. newEmailAddress: 'new@address.com',
  679. oldEmailAddress: 'hayden.zieme12@hotmail.com',
  680. },
  681. },
  682. ]);
  683. });
  684. });
  685. async function assertRequestAllowed<V>(operation: DocumentNode, variables?: V) {
  686. try {
  687. const status = await shopClient.queryStatus(operation, variables);
  688. expect(status).toBe(200);
  689. } catch (e: any) {
  690. const errorCode = getErrorCode(e);
  691. if (!errorCode) {
  692. fail(`Unexpected failure: ${e}`);
  693. } else {
  694. fail(`Operation should be allowed, got status ${getErrorCode(e)}`);
  695. }
  696. }
  697. }
  698. async function assertRequestForbidden<V>(operation: DocumentNode, variables: V) {
  699. try {
  700. const status = await shopClient.query(operation, variables);
  701. fail(`Should have thrown`);
  702. } catch (e: any) {
  703. expect(getErrorCode(e)).toBe('FORBIDDEN');
  704. }
  705. }
  706. function getErrorCode(err: any): string {
  707. return err.response.errors[0].extensions.code;
  708. }
  709. async function createAdministratorWithPermissions(
  710. code: string,
  711. permissions: Permission[],
  712. ): Promise<{ identifier: string; password: string }> {
  713. const roleResult = await shopClient.query<
  714. Codegen.CreateRoleMutation,
  715. Codegen.CreateRoleMutationVariables
  716. >(CREATE_ROLE, {
  717. input: {
  718. code,
  719. description: '',
  720. permissions,
  721. },
  722. });
  723. const role = roleResult.createRole;
  724. const identifier = `${code}@${Math.random().toString(16).substr(2, 8)}`;
  725. const password = `test`;
  726. const adminResult = await shopClient.query<
  727. Codegen.CreateAdministratorMutation,
  728. Codegen.CreateAdministratorMutationVariables
  729. >(CREATE_ADMINISTRATOR, {
  730. input: {
  731. emailAddress: identifier,
  732. firstName: code,
  733. lastName: 'Admin',
  734. password,
  735. roleIds: [role.id],
  736. },
  737. });
  738. const admin = adminResult.createAdministrator;
  739. return {
  740. identifier,
  741. password,
  742. };
  743. }
  744. /**
  745. * A "sleep" function which allows the sendEmailFn time to get called.
  746. */
  747. function waitForSendEmailFn() {
  748. return new Promise(resolve => setTimeout(resolve, 10));
  749. }
  750. });
  751. describe('Expiring tokens', () => {
  752. const { server, adminClient, shopClient } = createTestEnvironment(
  753. mergeConfig(testConfig(), {
  754. plugins: [TestEmailPlugin as any],
  755. authOptions: {
  756. verificationTokenDuration: '1ms',
  757. },
  758. }),
  759. );
  760. beforeAll(async () => {
  761. await server.init({
  762. initialData,
  763. productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
  764. customerCount: 1,
  765. });
  766. await adminClient.asSuperAdmin();
  767. }, TEST_SETUP_TIMEOUT_MS);
  768. beforeEach(() => {
  769. sendEmailFn = jest.fn();
  770. });
  771. afterAll(async () => {
  772. await server.destroy();
  773. });
  774. it('attempting to verify after token has expired throws', async () => {
  775. const verificationTokenPromise = getVerificationTokenPromise();
  776. const input: RegisterCustomerInput = {
  777. firstName: 'Barry',
  778. lastName: 'Wallace',
  779. emailAddress: 'barry.wallace@test.com',
  780. };
  781. const { registerCustomerAccount } = await shopClient.query<
  782. CodegenShop.RegisterMutation,
  783. CodegenShop.RegisterMutationVariables
  784. >(REGISTER_ACCOUNT, {
  785. input,
  786. });
  787. successErrorGuard.assertSuccess(registerCustomerAccount);
  788. const verificationToken = await verificationTokenPromise;
  789. expect(registerCustomerAccount.success).toBe(true);
  790. expect(sendEmailFn).toHaveBeenCalledTimes(1);
  791. expect(verificationToken).toBeDefined();
  792. await new Promise(resolve => setTimeout(resolve, 3));
  793. const { verifyCustomerAccount } = await shopClient.query<
  794. CodegenShop.VerifyMutation,
  795. CodegenShop.VerifyMutationVariables
  796. >(VERIFY_EMAIL, {
  797. password: 'test',
  798. token: verificationToken,
  799. });
  800. currentUserErrorGuard.assertErrorResult(verifyCustomerAccount);
  801. expect(verifyCustomerAccount.message).toBe(
  802. `Verification token has expired. Use refreshCustomerVerification to send a new token.`,
  803. );
  804. expect(verifyCustomerAccount.errorCode).toBe(ErrorCode.VERIFICATION_TOKEN_EXPIRED_ERROR);
  805. });
  806. it('attempting to reset password after token has expired returns error result', async () => {
  807. const { customer } = await adminClient.query<
  808. Codegen.GetCustomerQuery,
  809. Codegen.GetCustomerQueryVariables
  810. >(GET_CUSTOMER, {
  811. id: 'T_1',
  812. });
  813. const passwordResetTokenPromise = getPasswordResetTokenPromise();
  814. const { requestPasswordReset } = await shopClient.query<
  815. CodegenShop.RequestPasswordResetMutation,
  816. CodegenShop.RequestPasswordResetMutationVariables
  817. >(REQUEST_PASSWORD_RESET, {
  818. identifier: customer!.emailAddress,
  819. });
  820. successErrorGuard.assertSuccess(requestPasswordReset);
  821. const passwordResetToken = await passwordResetTokenPromise;
  822. expect(requestPasswordReset.success).toBe(true);
  823. expect(sendEmailFn).toHaveBeenCalledTimes(1);
  824. expect(passwordResetToken).toBeDefined();
  825. await new Promise(resolve => setTimeout(resolve, 3));
  826. const { resetPassword } = await shopClient.query<
  827. CodegenShop.ResetPasswordMutation,
  828. CodegenShop.ResetPasswordMutationVariables
  829. >(RESET_PASSWORD, {
  830. password: 'test',
  831. token: passwordResetToken,
  832. });
  833. currentUserErrorGuard.assertErrorResult(resetPassword);
  834. expect(resetPassword.message).toBe(`Password reset token has expired`);
  835. expect(resetPassword.errorCode).toBe(ErrorCode.PASSWORD_RESET_TOKEN_EXPIRED_ERROR);
  836. });
  837. });
  838. describe('Registration without email verification', () => {
  839. const { server, shopClient } = createTestEnvironment(
  840. mergeConfig(testConfig(), {
  841. plugins: [TestEmailPlugin as any],
  842. authOptions: {
  843. requireVerification: false,
  844. },
  845. }),
  846. );
  847. const userEmailAddress = 'glen.beardsley@test.com';
  848. beforeAll(async () => {
  849. await server.init({
  850. initialData,
  851. productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
  852. customerCount: 1,
  853. });
  854. }, TEST_SETUP_TIMEOUT_MS);
  855. beforeEach(() => {
  856. sendEmailFn = jest.fn();
  857. });
  858. afterAll(async () => {
  859. await server.destroy();
  860. });
  861. it('Returns error result if no password is provided', async () => {
  862. const input: RegisterCustomerInput = {
  863. firstName: 'Glen',
  864. lastName: 'Beardsley',
  865. emailAddress: userEmailAddress,
  866. };
  867. const { registerCustomerAccount } = await shopClient.query<
  868. CodegenShop.RegisterMutation,
  869. CodegenShop.RegisterMutationVariables
  870. >(REGISTER_ACCOUNT, {
  871. input,
  872. });
  873. successErrorGuard.assertErrorResult(registerCustomerAccount);
  874. expect(registerCustomerAccount.message).toBe('A password must be provided.');
  875. expect(registerCustomerAccount.errorCode).toBe(ErrorCode.MISSING_PASSWORD_ERROR);
  876. });
  877. it('register a new account with password', async () => {
  878. const input: RegisterCustomerInput = {
  879. firstName: 'Glen',
  880. lastName: 'Beardsley',
  881. emailAddress: userEmailAddress,
  882. password: 'test',
  883. };
  884. const { registerCustomerAccount } = await shopClient.query<
  885. CodegenShop.RegisterMutation,
  886. CodegenShop.RegisterMutationVariables
  887. >(REGISTER_ACCOUNT, {
  888. input,
  889. });
  890. successErrorGuard.assertSuccess(registerCustomerAccount);
  891. expect(registerCustomerAccount.success).toBe(true);
  892. expect(sendEmailFn).not.toHaveBeenCalled();
  893. });
  894. it('can login after registering', async () => {
  895. await shopClient.asUserWithCredentials(userEmailAddress, 'test');
  896. const result = await shopClient.query(
  897. gql`
  898. query GetMe {
  899. me {
  900. identifier
  901. }
  902. }
  903. `,
  904. );
  905. expect(result.me.identifier).toBe(userEmailAddress);
  906. });
  907. });
  908. describe('Updating email address without email verification', () => {
  909. const { server, adminClient, shopClient } = createTestEnvironment(
  910. mergeConfig(testConfig(), {
  911. plugins: [TestEmailPlugin as any],
  912. authOptions: {
  913. requireVerification: false,
  914. },
  915. }),
  916. );
  917. let customer: Codegen.GetCustomerQuery['customer'];
  918. const NEW_EMAIL_ADDRESS = 'new@address.com';
  919. beforeAll(async () => {
  920. await server.init({
  921. initialData,
  922. productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
  923. customerCount: 1,
  924. });
  925. await adminClient.asSuperAdmin();
  926. const result = await adminClient.query<Codegen.GetCustomerQuery, Codegen.GetCustomerQueryVariables>(
  927. GET_CUSTOMER,
  928. {
  929. id: 'T_1',
  930. },
  931. );
  932. customer = result.customer!;
  933. }, TEST_SETUP_TIMEOUT_MS);
  934. beforeEach(() => {
  935. sendEmailFn = jest.fn();
  936. });
  937. afterAll(async () => {
  938. await server.destroy();
  939. });
  940. it('updates email address', async () => {
  941. await shopClient.asUserWithCredentials(customer!.emailAddress, 'test');
  942. const { requestUpdateCustomerEmailAddress } = await shopClient.query<
  943. CodegenShop.RequestUpdateEmailAddressMutation,
  944. CodegenShop.RequestUpdateEmailAddressMutationVariables
  945. >(REQUEST_UPDATE_EMAIL_ADDRESS, {
  946. password: 'test',
  947. newEmailAddress: NEW_EMAIL_ADDRESS,
  948. });
  949. successErrorGuard.assertSuccess(requestUpdateCustomerEmailAddress);
  950. expect(requestUpdateCustomerEmailAddress.success).toBe(true);
  951. expect(sendEmailFn).toHaveBeenCalledTimes(1);
  952. expect(sendEmailFn.mock.calls[0][0] instanceof IdentifierChangeEvent).toBe(true);
  953. const { activeCustomer } = await shopClient.query<CodegenShop.GetActiveCustomerQuery>(
  954. GET_ACTIVE_CUSTOMER,
  955. );
  956. expect(activeCustomer!.emailAddress).toBe(NEW_EMAIL_ADDRESS);
  957. });
  958. });
  959. function getVerificationTokenPromise(): Promise<string> {
  960. return new Promise<any>(resolve => {
  961. sendEmailFn.mockImplementation((event: AccountRegistrationEvent) => {
  962. resolve(event.user.getNativeAuthenticationMethod().verificationToken);
  963. });
  964. });
  965. }
  966. function getPasswordResetTokenPromise(): Promise<string> {
  967. return new Promise<any>(resolve => {
  968. sendEmailFn.mockImplementation((event: PasswordResetEvent) => {
  969. resolve(event.user.getNativeAuthenticationMethod().passwordResetToken);
  970. });
  971. });
  972. }
  973. function getEmailUpdateTokenPromise(): Promise<{
  974. identifierChangeToken: string | null;
  975. pendingIdentifier: string | null;
  976. }> {
  977. return new Promise(resolve => {
  978. sendEmailFn.mockImplementation((event: IdentifierChangeRequestEvent) => {
  979. resolve(
  980. pick(event.user.getNativeAuthenticationMethod(), [
  981. 'identifierChangeToken',
  982. 'pendingIdentifier',
  983. ]),
  984. );
  985. });
  986. });
  987. }