diff --git a/package.json b/package.json index 08589fdf..4af927ae 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@internxt/sdk", "author": "Internxt ", - "version": "1.22.0", + "version": "1.22.1", "description": "An sdk for interacting with Internxt's services", "repository": { "type": "git", diff --git a/src/auth/index.ts b/src/auth/index.ts index a33e5fad..a27914ca 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -2,9 +2,10 @@ import { ApiSecurity, ApiUrl, AppDetails } from '../shared'; import { basicHeaders, headersWithToken } from '../shared/headers'; import { HttpClient } from '../shared/http/client'; import { TeamsSettings } from '../shared/types/teams'; -import { UserSettings, UUID } from '../shared/types/userSettings'; +import { UserSettings } from '../shared/types/userSettings'; import { ChangePasswordWithLinkPayload, + CompleteAccountSetupPayload, CryptoProvider, Keys, LoginDetails, @@ -13,6 +14,7 @@ import { RegisterDetails, RegisterPreCreatedUser, RegisterPreCreatedUserResponse, + RegisterResponse, SecurityDetails, Token, TwoFactorAuthQR, @@ -42,12 +44,7 @@ export class Auth { * Tries to register a new user * @param registerDetails */ - public register(registerDetails: RegisterDetails): Promise<{ - token: Token; - newToken: Token; - user: Omit & { referralCode: string }; - uuid: UUID; - }> { + public register(registerDetails: RegisterDetails): Promise { return this.client.post( '/users', { @@ -79,12 +76,7 @@ export class Auth { * Tries to register a new user without sending keys * @param registerDetails */ - public registerWithoutKeys(registerDetails: Omit): Promise<{ - token: Token; - newToken: Token; - user: Omit & { referralCode: string }; - uuid: UUID; - }> { + public registerWithoutKeys(registerDetails: Omit): Promise { const body = { name: registerDetails.name, captcha: registerDetails.captcha, @@ -159,6 +151,49 @@ export class Auth { ); } + /** + * Completes the setup of an account that was created after a payment, setting its password and keys. + * The client must be created with the captcha token in the `x-internxt-captcha` custom header. + * @param setupDetails - The setup token received by email and the user's encrypted credentials and keys + * @returns {Promise} Returns sign in token, user data and uuid. + */ + public completeAccountSetup(setupDetails: CompleteAccountSetupPayload): Promise { + return this.client.post( + 'users/pre-created-users/complete-setup', + { + token: setupDetails.token, + name: setupDetails.name, + lastname: setupDetails.lastname, + password: setupDetails.password, + mnemonic: setupDetails.mnemonic, + salt: setupDetails.salt, + keys: { + ecc: { + publicKey: setupDetails.keys.ecc.publicKey, + privateKey: setupDetails.keys.ecc.privateKeyEncrypted, + }, + kyber: { + publicKey: setupDetails.keys.kyber.publicKey, + privateKey: setupDetails.keys.kyber.privateKeyEncrypted, + }, + }, + referrer: setupDetails.referrer, + }, + this.basicHeaders(), + ); + } + + /** + * Requests a new email to complete the setup of an account that was created after a payment. + * The client must be created with the captcha token in the `x-internxt-captcha` custom header. + * + * @param {string} email - The email address associated with the account. + * @returns {Promise} - Resolves whether or not the email has a pending setup. + */ + public resendAccountSetupEmail(email: string): Promise { + return this.client.post('users/pre-created-users/setup-email', { email }, this.basicHeaders()); + } + /** * Requests account unblock email * diff --git a/src/auth/types.ts b/src/auth/types.ts index 3a3ea8fe..a10e8751 100644 --- a/src/auth/types.ts +++ b/src/auth/types.ts @@ -35,6 +35,17 @@ export interface RegisterDetails { referral?: string; } +export interface RegisterResponse { + token: Token; + newToken: Token; + user: Omit & { referralCode: string }; + uuid: UUID; +} + +export interface CompleteAccountSetupPayload extends Omit { + token: string; +} + export interface RegisterPreCreatedUser extends RegisterDetails { invitationId: string; } diff --git a/src/index.ts b/src/index.ts index 27db5973..a2009b49 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ export * from './auth'; -export { AppError } from './shared'; +export { AppError, ACCOUNT_SETUP_PENDING_ERROR_CODE, isAccountSetupPendingError } from './shared'; export * as Drive from './drive'; export * as Network from './network'; export * as Shared from './shared'; diff --git a/src/payments/checkout.ts b/src/payments/checkout.ts index e77d44f3..6ea3d9a9 100644 --- a/src/payments/checkout.ts +++ b/src/payments/checkout.ts @@ -4,6 +4,8 @@ import { basicHeaders, headersWithToken } from '../shared/headers'; import { HttpClient } from '../shared/http/client'; import { CreateCustomerPayload, + CreateCustomerWithoutAccountPayload, + CreatedCustomer, CreatePaymentIntentPayload, CreateSubscriptionPayload, CryptoCurrency, @@ -50,10 +52,7 @@ export class Checkout { captchaToken, companyVatId, metadata, - }: CreateCustomerPayload): Promise<{ - customerId: string; - token: string; - }> { + }: CreateCustomerPayload): Promise { return this.client.post( '/checkout/customer', { @@ -71,6 +70,54 @@ export class Checkout { ); } + /** + * @description Creates a customer, or gets the existing one, for someone who does not have a Drive account yet. + * The request is sent without the user authorization header; the Drive account is created once the payment succeeds. + * @param email - The email of the customer, used later to create the Drive account + * @param confirmationTokenId - The ID of the Stripe ConfirmationToken created with the payment details + * @param country - The country of the customer + * @param captchaToken - The captcha token to verify the call + * @param customerName - The name of the customer (optional) + * @param lineAddress1 - The address of the user (optional) + * @param lineAddress2 - The second address line of the user (optional) + * @param city - The city of the user (optional) + * @param postalCode - The postal code of the customer (optional) + * @param companyVatId - The VAT ID of the company (optional) + * @param metadata - Additional metadata to attach to the customer (optional) + * @returns The customer ID and the user token used to create a subscription or payment intent + */ + public createCustomerWithoutAccount({ + email, + confirmationTokenId, + customerName, + lineAddress1, + lineAddress2, + postalCode, + city, + country, + captchaToken, + companyVatId, + metadata, + }: CreateCustomerWithoutAccountPayload): Promise { + return this.client.post( + '/checkout/customer', + { + email, + confirmationTokenId, + customerName, + city, + lineAddress1, + lineAddress2, + country, + postalCode, + captchaToken, + companyVatId, + metadata, + }, + this.headers(), + ); + } + /** * @description Creates a subscription for a given customer * @param customerId - The ID of the customer diff --git a/src/payments/types/index.ts b/src/payments/types/index.ts index f4babd6a..d2bb5960 100644 --- a/src/payments/types/index.ts +++ b/src/payments/types/index.ts @@ -12,6 +12,16 @@ export interface CreateCustomerPayload { metadata?: Record; } +export interface CreateCustomerWithoutAccountPayload extends CreateCustomerPayload { + email: string; + confirmationTokenId: string; +} + +export interface CreatedCustomer { + customerId: string; + token: string; +} + export interface CreateSubscriptionPayload { customerId: string; priceId: string; diff --git a/src/shared/index.ts b/src/shared/index.ts index e585ea6c..42bdcde2 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -1,5 +1,5 @@ export * from './types/apiConnection'; -export { default as AppError } from './types/errors'; +export { default as AppError, ACCOUNT_SETUP_PENDING_ERROR_CODE, isAccountSetupPendingError } from './types/errors'; export { HttpClient } from './http/client'; export { retryWithBackoff } from './http/retryWithBackoff'; export type { RetryOptions } from './http/retryWithBackoff'; diff --git a/src/shared/types/errors.ts b/src/shared/types/errors.ts index a7c7d965..aafa76d0 100644 --- a/src/shared/types/errors.ts +++ b/src/shared/types/errors.ts @@ -47,3 +47,16 @@ export class AxiosUnknownError extends Error { Object.setPrototypeOf(this, AxiosUnknownError.prototype); } } + +export const ACCOUNT_SETUP_PENDING_ERROR_CODE = 'AccountSetupPending'; + +export function isAccountSetupPendingError(error: unknown): boolean { + if (error instanceof AppError) { + return error.code === ACCOUNT_SETUP_PENDING_ERROR_CODE; + } + if (error instanceof AxiosResponseError) { + const responseBody = error.data as { code?: unknown } | undefined; + return responseBody?.code === ACCOUNT_SETUP_PENDING_ERROR_CODE; + } + return false; +} diff --git a/test/auth/index.test.ts b/test/auth/index.test.ts index 684ca5a0..7db6e128 100644 --- a/test/auth/index.test.ts +++ b/test/auth/index.test.ts @@ -3,7 +3,20 @@ import { emptyRegisterDetails } from './registerDetails.mother'; import { basicHeaders, headersWithToken } from '../../src/shared/headers'; import { ApiSecurity, AppDetails } from '../../src/shared'; import { HttpClient } from '../../src/shared/http/client'; -import { Auth, CryptoProvider, Keys, LoginDetails, RegisterDetails, Token } from '../../src'; +import { + ACCOUNT_SETUP_PENDING_ERROR_CODE, + AppError, + Auth, + CompleteAccountSetupPayload, + CryptoProvider, + isAccountSetupPendingError, + Keys, + LoginDetails, + RegisterDetails, + Token, +} from '../../src'; +import { AxiosResponseError } from '../../src/shared/types/errors'; +import { AxiosResponse } from 'axios'; describe('# auth service tests', () => { beforeEach(() => { @@ -746,6 +759,103 @@ describe('# auth service tests', () => { }); }); + describe('Account setup after payment', () => { + const captchaToken = 'captcha-token'; + + function setupDetails(): CompleteAccountSetupPayload { + return { + token: 'setup-token', + name: 'Jane', + lastname: 'Doe', + password: 'encrypted-password', + mnemonic: 'encrypted-mnemonic', + salt: 'encrypted-salt', + keys: { + ecc: { publicKey: 'ecc-public', privateKeyEncrypted: 'ecc-private' }, + kyber: { publicKey: 'kyber-public', privateKeyEncrypted: 'kyber-private' }, + }, + referrer: 'referrer-code', + }; + } + + it('When the user completes the setup, then the token, credentials and keys go with the captcha', async () => { + const postCall = vi.spyOn(HttpClient.prototype, 'post').mockResolvedValue({}); + const { client, headers } = clientAndHeadersWithCaptcha(captchaToken); + + await client.completeAccountSetup(setupDetails()); + + expect(postCall).toHaveBeenCalledWith( + 'users/pre-created-users/complete-setup', + { + token: 'setup-token', + name: 'Jane', + lastname: 'Doe', + password: 'encrypted-password', + mnemonic: 'encrypted-mnemonic', + salt: 'encrypted-salt', + keys: { + ecc: { publicKey: 'ecc-public', privateKey: 'ecc-private' }, + kyber: { publicKey: 'kyber-public', privateKey: 'kyber-private' }, + }, + referrer: 'referrer-code', + }, + headers, + ); + expect(postCall.mock.calls[0][2]).toHaveProperty('x-internxt-captcha', captchaToken); + }); + + it('When the setup is completed, then the user gets the same session data as after signing up', async () => { + const sessionData = { token: 'token', newToken: 'new-token', user: { email: 'jane@internxt.com' }, uuid: 'uuid' }; + vi.spyOn(HttpClient.prototype, 'post').mockResolvedValue(sessionData); + const { client } = clientAndHeadersWithCaptcha(captchaToken); + + const session = await client.completeAccountSetup(setupDetails()); + + expect(session).toStrictEqual(sessionData); + }); + + it('When the user asks for the setup email again, then the email is sent with the captcha', async () => { + const postCall = vi.spyOn(HttpClient.prototype, 'post').mockResolvedValue(undefined); + const { client, headers } = clientAndHeadersWithCaptcha(captchaToken); + + await client.resendAccountSetupEmail('jane@internxt.com'); + + expect(postCall).toHaveBeenCalledWith( + 'users/pre-created-users/setup-email', + { email: 'jane@internxt.com' }, + headers, + ); + expect(postCall.mock.calls[0][2]).toHaveProperty('x-internxt-captcha', captchaToken); + }); + + it('When logging in to an account pending setup, then the error is recognised as a pending setup', async () => { + vi.spyOn(HttpClient.prototype, 'post').mockRejectedValue( + forbiddenResponseError({ message: 'The account setup is pending', code: ACCOUNT_SETUP_PENDING_ERROR_CODE }), + ); + const { client } = clientAndHeaders(); + + const loginError = await client.securityDetails('jane@internxt.com').catch((error: unknown) => error); + + expect(isAccountSetupPendingError(loginError)).toBe(true); + }); + + it('When the app error carries the pending setup code, then it is recognised as a pending setup', () => { + const appError = new AppError('The account setup is pending', 403, ACCOUNT_SETUP_PENDING_ERROR_CODE); + + expect(isAccountSetupPendingError(appError)).toBe(true); + }); + + it('When login is forbidden for another reason, then it is not recognised as a pending setup', async () => { + vi.spyOn(HttpClient.prototype, 'post').mockRejectedValue(forbiddenResponseError({ message: 'Forbidden' })); + const { client } = clientAndHeaders(); + + const loginError = await client.securityDetails('jane@internxt.com').catch((error: unknown) => error); + + expect(isAccountSetupPendingError(loginError)).toBe(false); + expect(isAccountSetupPendingError(new Error('Network error'))).toBe(false); + }); + }); + describe('-> send email unblock account', () => { it('Should call with right params & return values', async () => { // Arrange @@ -1028,3 +1138,23 @@ function clientAndHeadersWithToken( const headers = headersWithToken({ clientName, clientVersion, token }); return { client, headers }; } + +function clientAndHeadersWithCaptcha( + captchaToken: string, + clientName = 'c-name', + clientVersion = '0.1', +): { + client: Auth; + headers: object; +} { + const customHeaders = { 'x-internxt-captcha': captchaToken }; + const appDetails: AppDetails = { clientName, clientVersion, customHeaders }; + const client = Auth.client('', appDetails); + const headers = basicHeaders({ clientName, clientVersion, customHeaders }); + return { client, headers }; +} + +function forbiddenResponseError(responseBody: object): AxiosResponseError { + const response = { status: 403, data: responseBody, headers: {} } as AxiosResponse; + return new AxiosResponseError('Request failed with status code 403', 'post /auth/login', response); +} diff --git a/test/payments/checkout.test.ts b/test/payments/checkout.test.ts index d68d56ee..eaab333a 100644 --- a/test/payments/checkout.test.ts +++ b/test/payments/checkout.test.ts @@ -3,7 +3,12 @@ import { HttpClient } from '../../src/shared/http/client'; import { Checkout } from '../../src/payments'; import { ApiSecurity, AppDetails } from '../../src/shared'; import { basicHeaders, headersWithToken } from '../../src/shared/headers'; -import { CreateCustomerPayload, CreatePaymentIntentPayload, CryptoCurrency } from '../../src/payments/types'; +import { + CreateCustomerPayload, + CreateCustomerWithoutAccountPayload, + CreatePaymentIntentPayload, + CryptoCurrency, +} from '../../src/payments/types'; describe('Checkout service tests', () => { beforeEach(() => { @@ -40,6 +45,50 @@ describe('Checkout service tests', () => { }); }); + describe('Create customer for someone without an account', () => { + const customerPayload: CreateCustomerWithoutAccountPayload = { + email: 'new-user@internxt.com', + confirmationTokenId: 'ctoken_123', + customerName: 'New user', + country: 'ES', + postalCode: '46001', + captchaToken: 'captcha_token', + }; + + it('When someone without an account creates a customer, then it is sent without user authorization', async () => { + const callStub = vi.spyOn(HttpClient.prototype, 'post').mockResolvedValue({}); + const { client, headers } = clientAndHeadersWithToken({}); + + await client.createCustomerWithoutAccount(customerPayload); + + expect(callStub).toHaveBeenCalledWith('/checkout/customer', customerPayload, headers); + expect(callStub.mock.calls[0][2]).not.toHaveProperty('Authorization'); + }); + + it('When the customer is created, then the customer id and the checkout token are returned', async () => { + const createdCustomer = { customerId: 'customerId', token: 'checkout_token' }; + vi.spyOn(HttpClient.prototype, 'post').mockResolvedValue(createdCustomer); + const { client } = clientAndHeadersWithToken({}); + + const customer = await client.createCustomerWithoutAccount(customerPayload); + + expect(customer).toStrictEqual(createdCustomer); + }); + + it('When a signed-in user creates a customer, then it is authorized with their token as before', async () => { + const callStub = vi.spyOn(HttpClient.prototype, 'post').mockResolvedValue({}); + const { client, headers } = clientAndHeadersWithAuthToken({ token: 'session_token' }); + + await client.createCustomer({ country: 'ES', captchaToken: 'captcha_token' }); + + const [, body, sentHeaders] = callStub.mock.calls[0]; + expect(sentHeaders).toStrictEqual(headers); + expect(sentHeaders).toHaveProperty('Authorization', 'Bearer session_token'); + expect(body).not.toHaveProperty('email'); + expect(body).not.toHaveProperty('confirmationTokenId'); + }); + }); + describe('Create payment intent', () => { it('should call with right params & return data', async () => { // Arrange