diff --git a/.env.template b/.env.template index a2e16e0..c06bbc6 100644 --- a/.env.template +++ b/.env.template @@ -1,2 +1,7 @@ DRIVE_API_URL= -DRIVE_NEW_API_URL= \ No newline at end of file +DRIVE_NEW_API_URL= +PAYMENTS_API_URL= +PHOTOS_API_URL= +APP_CRYPTO_SECRET= +APP_MAGIC_IV= +APP_MAGIC_SALT= diff --git a/package.json b/package.json index efae91d..93c8cf2 100644 --- a/package.json +++ b/package.json @@ -30,17 +30,21 @@ ], "dependencies": { "@internxt/eslint-config-internxt": "^1.0.9", + "@internxt/lib": "^1.2.0", "@internxt/prettier-config": "^1.0.2", - "@internxt/sdk": "^1.4.67", + "@internxt/sdk": "^1.4.70", "@oclif/core": "^3", - "dotenv": "^16.4.1" + "dotenv": "^16.4.1", + "openpgp": "^5.11.0" }, "devDependencies": { "@oclif/prettier-config": "^0.2.1", "@oclif/test": "^3", + "@openpgp/web-stream-tools": "0.0.11-patch-0", "@types/chai": "^4", "@types/mocha": "^10", "@types/node": "^18", + "@types/sinon-chai": "^3.2.12", "chai": "^4", "eslint": "^8", "eslint-config-oclif": "^5", @@ -53,6 +57,7 @@ "oclif": "^4.4.8", "prettier": "^3.2.5", "shx": "^0.3.4", + "sinon-chai": "^3.7.0", "ts-node": "^10.9.2", "typescript": "^5.3.3" }, diff --git a/src/services/SDKManager.service.ts b/src/services/SDKManager.service.ts new file mode 100644 index 0000000..a963780 --- /dev/null +++ b/src/services/SDKManager.service.ts @@ -0,0 +1,154 @@ +import { Auth, Drive, photos } from '@internxt/sdk'; +import { Trash } from '@internxt/sdk/dist/drive'; +import { ApiSecurity, AppDetails } from '@internxt/sdk/dist/shared'; +import { ConfigService } from './config.service'; +import packageJson from '../../package.json'; + +export type SdkManagerApiSecurity = ApiSecurity & { newToken: string }; +/** + * Manages all the sdk submodules initialization + * based on the current apiSecurity details + */ +export class SdkManager { + public static readonly instance: SdkManager = new SdkManager(); + private static apiSecurity?: SdkManagerApiSecurity; + + /** + * Sets the security details needed to create SDK clients + * @param apiSecurity Security properties to be setted + **/ + public static readonly init = (apiSecurity: SdkManagerApiSecurity) => { + SdkManager.apiSecurity = apiSecurity; + }; + + /** + * Cleans the security details + **/ + public static readonly clean = () => { + SdkManager.apiSecurity = undefined; + }; + + /** + * Returns the security details needed to create SDK clients + * @param config Config object to handle error throwing when there is not apiSecurity defined + * @throws {Error} When throwErrorOnMissingCredentials is setted to true and there is not apiSecurity defined + * @returns The SDK Manager api security details + **/ + public static readonly getApiSecurity = ( + config = { throwErrorOnMissingCredentials: true }, + ): SdkManagerApiSecurity => { + if (!SdkManager.apiSecurity && config.throwErrorOnMissingCredentials) + throw new Error('Api security properties not found in SdkManager'); + + return SdkManager.apiSecurity as SdkManagerApiSecurity; + }; + + /** + * Returns the application details from package.json + * @returns The name and the version of the app from package.json + **/ + public static readonly getAppDetails = (): AppDetails => { + return { + clientName: packageJson.name, + clientVersion: packageJson.version, + }; + }; + + /** Auth SDK */ + getAuthV2() { + const DRIVE_NEW_API_URL = ConfigService.instance.get('DRIVE_NEW_API_URL'); + + const apiSecurity = SdkManager.getApiSecurity({ throwErrorOnMissingCredentials: false }); + const appDetails = SdkManager.getAppDetails(); + + return Auth.client(DRIVE_NEW_API_URL, appDetails, apiSecurity); + } + + /** Auth old client SDK */ + getAuth() { + const DRIVE_API_URL = ConfigService.instance.get('DRIVE_API_URL'); + + const apiSecurity = SdkManager.getApiSecurity({ throwErrorOnMissingCredentials: false }); + const appDetails = SdkManager.getAppDetails(); + + return Auth.client(DRIVE_API_URL, appDetails, apiSecurity); + } + + /** Payments SDK */ + getPayments() { + const PAYMENTS_API_URL = ConfigService.instance.get('PAYMENTS_API_URL'); + + const newToken = SdkManager.getApiSecurity().newToken; + const appDetails = SdkManager.getAppDetails(); + + return Drive.Payments.client(PAYMENTS_API_URL, appDetails, { + // Weird, normal accessToken doesn't work here + token: newToken, + }); + } + + /** Users SDK */ + getUsers() { + const DRIVE_API_URL = ConfigService.instance.get('DRIVE_API_URL'); + + const apiSecurity = SdkManager.getApiSecurity({ throwErrorOnMissingCredentials: false }); + const appDetails = SdkManager.getAppDetails(); + + return Drive.Users.client(DRIVE_API_URL, appDetails, apiSecurity); + } + + /** Referrals SDK */ + getReferrals() { + const DRIVE_API_URL = ConfigService.instance.get('DRIVE_API_URL'); + + const apiSecurity = SdkManager.getApiSecurity(); + const appDetails = SdkManager.getAppDetails(); + + return Drive.Referrals.client(DRIVE_API_URL, appDetails, apiSecurity); + } + + /** Storage SDK */ + getStorage() { + const DRIVE_API_URL = ConfigService.instance.get('DRIVE_API_URL'); + + const apiSecurity = SdkManager.getApiSecurity(); + const appDetails = SdkManager.getAppDetails(); + + return Drive.Storage.client(DRIVE_API_URL, appDetails, apiSecurity); + } + + /** Trash SDK */ + getTrash() { + const DRIVE_NEW_API_URL = ConfigService.instance.get('DRIVE_NEW_API_URL'); + + const newToken = SdkManager.getApiSecurity().newToken; + const appDetails = SdkManager.getAppDetails(); + + return Trash.client(DRIVE_NEW_API_URL, appDetails, { + // Weird, normal accessToken doesn't work here + token: newToken, + }); + } + + /** Photos SDK */ + getPhotos() { + const PHOTOS_API_URL = ConfigService.instance.get('PHOTOS_API_URL'); + + const newToken = SdkManager.getApiSecurity().newToken; + + return new photos.Photos(PHOTOS_API_URL, newToken); + } + + /** Share SDK */ + getShare() { + const DRIVE_NEW_API_URL = ConfigService.instance.get('DRIVE_NEW_API_URL'); + + const newToken = SdkManager.getApiSecurity().newToken; + const appDetails = SdkManager.getAppDetails(); + + return Drive.Share.client(DRIVE_NEW_API_URL, appDetails, { + // Weird, normal accessToken doesn't work here + token: newToken, + }); + } +} diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts new file mode 100644 index 0000000..15df046 --- /dev/null +++ b/src/services/auth.service.ts @@ -0,0 +1,79 @@ +import { LoginDetails } from '@internxt/sdk'; +import { UserSettings } from '@internxt/sdk/dist/shared/types/userSettings.js'; +import { SdkManager } from './SDKManager.service'; +import { KeysService } from './keys.service'; +import { CryptoService } from './crypto.service'; + +export class AuthService { + public static readonly instance: AuthService = new AuthService(); + + /** + * Login with user credentials and returns its tokens and properties + * @param email The user's email + * @param password The user's password + * @param twoFactorCode (Optional) The temporal two factor auth code + * @returns The user's properties and the tokens needed for auth + * @async + **/ + public doLogin = async ( + email: string, + password: string, + twoFactorCode?: string, + ): Promise<{ + user: UserSettings; + token: string; + newToken: string; + mnemonic: string; + }> => { + const authClient = SdkManager.instance.getAuth(); + const loginDetails: LoginDetails = { + email: email.toLowerCase(), + password: password, + tfaCode: twoFactorCode, + }; + + const data = await authClient.login(loginDetails, CryptoService.cryptoProvider); + const { user, token, newToken } = data; + const { privateKey, publicKey } = user; + + const plainPrivateKeyInBase64 = privateKey + ? Buffer.from(KeysService.instance.decryptPrivateKey(privateKey, password)).toString('base64') + : ''; + + if (privateKey) { + await KeysService.instance.assertPrivateKeyIsValid(privateKey, password); + await KeysService.instance.assertValidateKeys( + Buffer.from(plainPrivateKeyInBase64, 'base64').toString(), + Buffer.from(publicKey, 'base64').toString(), + ); + } + + const clearMnemonic = CryptoService.instance.decryptTextWithKey(user.mnemonic, password); + const clearUser = { + ...user, + mnemonic: clearMnemonic, + privateKey: plainPrivateKeyInBase64, + }; + return { + user: clearUser, + token: token, + newToken: newToken, + mnemonic: clearMnemonic, + }; + }; + + /** + * Checks from user's security details if it has enabled two factor auth + * @param email The user's email + * @throws {Error} If auth.securityDetails endpoint fails + * @returns True if user has enabled two factor auth + * @async + **/ + public is2FANeeded = async (email: string): Promise => { + const authClient = SdkManager.instance.getAuth(); + const securityDetails = await authClient.securityDetails(email).catch((error) => { + throw new Error(error.message ?? 'Login error'); + }); + return securityDetails.tfaEnabled; + }; +} diff --git a/src/services/config.service.ts b/src/services/config.service.ts index 91d3179..22e2894 100644 --- a/src/services/config.service.ts +++ b/src/services/config.service.ts @@ -2,9 +2,15 @@ import { ConfigKeys } from '../types/config.types'; export class ConfigService { public static readonly instance: ConfigService = new ConfigService(); - get(key: keyof ConfigKeys): string { + /** + * Gets the value from an environment key + * @param key The environment key to retrieve + * @throws {Error} If key is not found in process.env + * @returns The value from the environment variable + **/ + public get = (key: keyof ConfigKeys): string => { const value = process.env[key]; if (!value) throw new Error(`Config key ${key} was not found in process.env`); return value; - } + }; } diff --git a/src/services/crypto.service.ts b/src/services/crypto.service.ts new file mode 100644 index 0000000..503bc06 --- /dev/null +++ b/src/services/crypto.service.ts @@ -0,0 +1,132 @@ +import { CryptoProvider } from '@internxt/sdk'; +import { Keys, Password } from '@internxt/sdk/dist/auth'; +import crypto from 'crypto'; +import { KeysService } from './keys.service'; +import { ConfigService } from '../services/config.service'; + +export class CryptoService { + public static readonly instance: CryptoService = new CryptoService(); + + public static readonly cryptoProvider: CryptoProvider = { + encryptPasswordHash(password: Password, encryptedSalt: string): string { + const salt = CryptoService.instance.decryptText(encryptedSalt); + const hashObj = CryptoService.instance.passToHash({ password, salt }); + return CryptoService.instance.encryptText(hashObj.hash); + }, + async generateKeys(password: Password): Promise { + const { privateKeyArmoredEncrypted, publicKeyArmored, revocationCertificate } = + await KeysService.instance.generateNewKeysWithEncrypted(password); + const keys: Keys = { + privateKeyEncrypted: privateKeyArmoredEncrypted, + publicKey: publicKeyArmored, + revocationCertificate: revocationCertificate, + }; + return keys; + }, + }; + + /** + * Generates the hash for a password, if salt is provided it uses it, in other case it is generated from crypto + * @param passObject The object containing the password and an optional salt + * @returns The hashed password and the salt + **/ + public passToHash = (passObject: { salt?: string | null; password: string }): { salt: string; hash: string } => { + const salt = passObject.salt ? passObject.salt : crypto.randomBytes(128 / 8).toString('hex'); + const hash = crypto.pbkdf2Sync(passObject.password, salt, 10000, 256 / 8, 'sha256').toString('hex'); + const hashedObjetc = { + salt, + hash, + }; + return hashedObjetc; + }; + + /** + * Encrypts a plain message into an AES encrypted text using APP_CRYPTO_SECRET value from env + * @param textToEncrypt The plain text to be encrypted + * @returns The encrypted string in 'hex' encoding + **/ + public encryptText = (textToEncrypt: string): string => { + const APP_CRYPTO_SECRET = ConfigService.instance.get('APP_CRYPTO_SECRET'); + return this.encryptTextWithKey(textToEncrypt, APP_CRYPTO_SECRET); + }; + + /** + * Decrypts an AES encrypted text using APP_CRYPTO_SECRET value from env + * @param encryptedText The AES encrypted text in 'HEX' encoding + * @returns The decrypted string in 'utf8' encoding + **/ + public decryptText = (encryptedText: string): string => { + const APP_CRYPTO_SECRET = ConfigService.instance.get('APP_CRYPTO_SECRET'); + return this.decryptTextWithKey(encryptedText, APP_CRYPTO_SECRET); + }; + + /** + * Encrypts a plain message into an AES encrypted text using a secret. + * [Crypto.JS compatible]: + * First 8 bytes are reserved for 'Salted__', next 8 bytes are the salt, and the rest is aes content + * @param textToEncrypt The plain text to be encrypted + * @param secret The secret used to encrypt + * @returns The encrypted private string in 'hex' encoding + **/ + public encryptTextWithKey = (textToEncrypt: string, secret: string) => { + const salt = crypto.randomBytes(8); + const { key, iv } = this.getKeyAndIvFrom(secret, salt); + + const cipher = crypto.createCipheriv('aes-256-cbc', key, iv); + + const encrypted = Buffer.concat([cipher.update(textToEncrypt, 'utf8'), cipher.final()]); + + /* CryptoJS applies the OpenSSL format for the ciphertext, i.e. the encrypted data starts with the ASCII + encoding of 'Salted__' followed by the salt and then the ciphertext. + Therefore the beginning of the Base64 encoded ciphertext starts always with U2FsdGVkX1 + */ + const openSSLstart = Buffer.from('Salted__'); + + return Buffer.concat([openSSLstart, salt, encrypted]).toString('hex'); + }; + + /** + * Decrypts an AES encrypted text using a secret. + * [Crypto.JS compatible]: + * First 8 bytes are reserved for 'Salted__', next 8 bytes are the salt, and the rest is aes content + * @param encryptedText The AES encrypted text in 'HEX' encoding + * @param secret The secret used to encrypt + * @returns The decrypted string in 'utf8' encoding + **/ + public decryptTextWithKey = (encryptedText: string, secret: string) => { + const cypherText = Buffer.from(encryptedText, 'hex'); + + const salt = cypherText.subarray(8, 16); + const { key, iv } = this.getKeyAndIvFrom(secret, salt); + + const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv); + + const contentsToDecrypt = cypherText.subarray(16); + + return Buffer.concat([decipher.update(contentsToDecrypt), decipher.final()]).toString('utf8'); + }; + + /** + * Generates the key and the iv by transforming a secret and a salt. + * It will generate the same key and iv if the same secret and salt is used. + * This function is needed to be Crypto.JS compatible and encrypt/decrypt without errors + * @param secret The secret used to encrypt + * @param salt The salt used to encrypt + * @returns The key and the iv resulted from the secret and the salt combination + **/ + private getKeyAndIvFrom = (secret: string, salt: Buffer) => { + const TRANSFORM_ROUNDS = 3; + const password = Buffer.concat([Buffer.from(secret, 'binary'), salt]); + const md5Hashes = []; + let digest = password; + + for (let i = 0; i < TRANSFORM_ROUNDS; i++) { + md5Hashes[i] = crypto.createHash('md5').update(digest).digest(); + digest = Buffer.concat([md5Hashes[i], password]); + } + + const key = Buffer.concat([md5Hashes[0], md5Hashes[1]]); + const iv = md5Hashes[2]; + return { key, iv }; + }; +} diff --git a/src/services/keys.service.ts b/src/services/keys.service.ts new file mode 100644 index 0000000..c7dd483 --- /dev/null +++ b/src/services/keys.service.ts @@ -0,0 +1,151 @@ +import { aes } from '@internxt/lib'; +import * as openpgp from 'openpgp'; +import { ConfigService } from './config.service'; +import { + AesInit, + BadEncodedPrivateKeyError, + CorruptedEncryptedPrivateKeyError, + KeysDoNotMatchError, + WrongIterationsToEncryptPrivateKeyError, +} from '../types/keys.types'; + +export class KeysService { + public static readonly instance: KeysService = new KeysService(); + + /** + * Checks if a private key can be decrypted with a password, otherwise it throws an error + * @param privateKey The encrypted private key + * @param password The password used to encrypt the private key + * @throws {BadEncodedPrivateKeyError} If the PLAIN private key is base64 encoded (known issue introduced in the past) + * @throws {WrongIterationsToEncryptPrivateKeyError} If the ENCRYPTED private key was encrypted using the wrong iterations number (known issue introduced in the past) + * @throws {CorruptedEncryptedPrivateKeyError} If the ENCRYPTED private key is un-decryptable (corrupted) + * @async + */ + public assertPrivateKeyIsValid = async (privateKey: string, password: string): Promise => { + let privateKeyDecrypted: string | undefined; + + let badIterations = true; + try { + aes.decrypt(privateKey, password, 9999); + } catch { + badIterations = false; + } + if (badIterations === true) throw new WrongIterationsToEncryptPrivateKeyError(); + + let badEncrypted = false; + try { + privateKeyDecrypted = this.decryptPrivateKey(privateKey, password); + } catch { + badEncrypted = true; + } + + let hasValidFormat = false; + try { + if (privateKeyDecrypted !== undefined) { + hasValidFormat = await this.isValidKey(privateKeyDecrypted); + } + } catch { + /* no op */ + } + + if (badEncrypted === true) throw new CorruptedEncryptedPrivateKeyError(); + if (hasValidFormat === false) throw new BadEncodedPrivateKeyError(); + }; + + /** + * Encrypts a private key using a password + * @param privateKey The plain private key + * @param password The password to encrypt + * @returns The encrypted private key + **/ + public encryptPrivateKey = (privateKey: string, password: string): string => { + return aes.encrypt(privateKey, password, this.getAesInitFromEnv()); + }; + + /** + * Decrypts a private key using a password + * @param privateKey The encrypted private key + * @param password The password used to encrypt the private key + * @returns The decrypted private key + **/ + public decryptPrivateKey = (privateKey: string, password: string): string => { + return aes.decrypt(privateKey, password); + }; + + /** + * Checks if a message encrypted with the public key can be decrypted with a private key, otherwise it throws an error + * @param privateKey The plain private key + * @param publicKey The plain public key + * @throws {KeysDoNotMatchError} If the keys can not be used together to encrypt/decrypt a message + * @async + **/ + public assertValidateKeys = async (privateKey: string, publicKey: string): Promise => { + const publicKeyArmored = await openpgp.readKey({ armoredKey: publicKey }); + const privateKeyArmored = await openpgp.readPrivateKey({ armoredKey: privateKey }); + + const plainMessage = 'validate-keys'; + const originalText = await openpgp.createMessage({ text: plainMessage }); + const encryptedMessage = await openpgp.encrypt({ + message: originalText, + encryptionKeys: publicKeyArmored, + }); + + const decryptedMessage = ( + await openpgp.decrypt({ + message: await openpgp.readMessage({ armoredMessage: encryptedMessage }), + verificationKeys: publicKeyArmored, + decryptionKeys: privateKeyArmored, + }) + ).data; + + if (decryptedMessage !== plainMessage) { + throw new KeysDoNotMatchError(); + } + }; + + /** + * Checks if a pgp key can be read + * @param key The openpgp key to be validated + * @returns True if it can be read, false otherwise + * @async + **/ + public isValidKey = async (key: string): Promise => { + try { + await openpgp.readKey({ armoredKey: key }); + return true; + } catch { + return false; + } + }; + + /** + * Generates pgp keys adding an AES-encrypted private key property by using a password + * @param password The password for encrypting the private key + * @returns The keys { privateKeyArmored, privateKeyArmoredEncrypted, publicKeyArmored, revocationCertificate } + * @async + **/ + public generateNewKeysWithEncrypted = async (password: string) => { + const { privateKey, publicKey, revocationCertificate } = await openpgp.generateKey({ + userIDs: [{ email: 'inxt@inxt.com' }], + curve: 'ed25519', + }); + + return { + privateKeyArmored: privateKey, + privateKeyArmoredEncrypted: this.encryptPrivateKey(privateKey, password), + publicKeyArmored: Buffer.from(publicKey).toString('base64'), + revocationCertificate: Buffer.from(revocationCertificate).toString('base64'), + }; + }; + + /** + * Returns the AesInit params using ConfigService + * @returns The IV and the SALT from ConfigService + **/ + public getAesInitFromEnv = (): AesInit => { + const MAGIC_IV = ConfigService.instance.get('APP_MAGIC_IV'); + const MAGIC_SALT = ConfigService.instance.get('APP_MAGIC_SALT'); + + return { iv: MAGIC_IV, salt: MAGIC_SALT }; + }; +} diff --git a/src/types/config.types.ts b/src/types/config.types.ts index 2964370..0dc0d9a 100644 --- a/src/types/config.types.ts +++ b/src/types/config.types.ts @@ -1,4 +1,9 @@ export interface ConfigKeys { readonly DRIVE_API_URL: string; readonly DRIVE_NEW_API_URL: string; + readonly PAYMENTS_API_URL: string; + readonly PHOTOS_API_URL: string; + readonly APP_CRYPTO_SECRET: string; + readonly APP_MAGIC_IV: string; + readonly APP_MAGIC_SALT: string; } diff --git a/src/types/keys.types.ts b/src/types/keys.types.ts new file mode 100644 index 0000000..956a0da --- /dev/null +++ b/src/types/keys.types.ts @@ -0,0 +1,36 @@ +export class BadEncodedPrivateKeyError extends Error { + constructor() { + super('Private key is bad encoded'); + + Object.setPrototypeOf(this, BadEncodedPrivateKeyError.prototype); + } +} + +export class WrongIterationsToEncryptPrivateKeyError extends Error { + constructor() { + super('Private key was encrypted using the wrong iterations number'); + + Object.setPrototypeOf(this, WrongIterationsToEncryptPrivateKeyError.prototype); + } +} + +export class CorruptedEncryptedPrivateKeyError extends Error { + constructor() { + super('Private key is corrupted'); + + Object.setPrototypeOf(this, CorruptedEncryptedPrivateKeyError.prototype); + } +} + +export class KeysDoNotMatchError extends Error { + constructor() { + super('Keys do not match'); + + Object.setPrototypeOf(this, CorruptedEncryptedPrivateKeyError.prototype); + } +} + +export interface AesInit { + iv: string; + salt: string; +} diff --git a/test/fixtures/auth.fixture.ts b/test/fixtures/auth.fixture.ts new file mode 100644 index 0000000..e5b3c82 --- /dev/null +++ b/test/fixtures/auth.fixture.ts @@ -0,0 +1,27 @@ +import crypto from 'crypto'; + +export const UserFixture = { + userId: crypto.randomBytes(16).toString('hex'), + uuid: crypto.randomBytes(16).toString('hex'), + email: crypto.randomBytes(16).toString('hex'), + name: crypto.randomBytes(16).toString('hex'), + lastname: crypto.randomBytes(16).toString('hex'), + username: crypto.randomBytes(16).toString('hex'), + bridgeUser: crypto.randomBytes(16).toString('hex'), + bucket: crypto.randomBytes(16).toString('hex'), + backupsBucket: crypto.randomBytes(16).toString('hex'), + root_folder_id: crypto.randomInt(1, 9999), + sharedWorkspace: false, + credit: crypto.randomInt(1, 9999), + mnemonic: crypto.randomBytes(16).toString('hex'), + privateKey: crypto.randomBytes(16).toString('hex'), + publicKey: crypto.randomBytes(16).toString('hex'), + revocationKey: crypto.randomBytes(16).toString('hex'), + teams: false, + appSumoDetails: null, + registerCompleted: true, + hasReferralsProgram: false, + createdAt: new Date(), + avatar: crypto.randomBytes(16).toString('hex'), + emailVerified: true, +}; diff --git a/test/services/auth.service.test.ts b/test/services/auth.service.test.ts new file mode 100644 index 0000000..43bf916 --- /dev/null +++ b/test/services/auth.service.test.ts @@ -0,0 +1,100 @@ +import { expect } from 'chai'; +import sinon, { SinonSandbox } from 'sinon'; +import crypto from 'crypto'; +import { AuthService } from '../../src/services/auth.service'; +import { KeysService } from '../../src/services/keys.service'; +import { CryptoService } from '../../src/services/crypto.service'; +import { SdkManager } from '../../src/services/SDKManager.service'; +import { UserFixture } from '../fixtures/auth.fixture'; +import { Auth, LoginDetails, SecurityDetails } from '@internxt/sdk'; + +describe('Auth service', () => { + let authServiceSandbox: SinonSandbox; + + beforeEach(() => { + authServiceSandbox = sinon.createSandbox(); + }); + + afterEach(() => { + authServiceSandbox.restore(); + }); + + it('When user logs in, then login user credentials are generated', async () => { + const loginResponse = { + token: crypto.randomBytes(16).toString('hex'), + newToken: crypto.randomBytes(16).toString('hex'), + user: UserFixture, + userTeam: null, + }; + + authServiceSandbox.stub(Auth.prototype, 'login').returns(Promise.resolve(loginResponse)); + authServiceSandbox.stub(SdkManager.instance, 'getAuth').returns(Auth.prototype); + authServiceSandbox.stub(KeysService.instance, 'decryptPrivateKey').returns(loginResponse.user.privateKey); + authServiceSandbox.stub(KeysService.instance, 'assertPrivateKeyIsValid').resolves(); + authServiceSandbox.stub(KeysService.instance, 'assertValidateKeys').resolves(); + authServiceSandbox.stub(CryptoService.instance, 'decryptTextWithKey').returns(loginResponse.user.mnemonic); + + const responseLogin = await AuthService.instance.doLogin( + loginResponse.user.email, + crypto.randomBytes(16).toString('hex'), + '', + ); + const expectedResponseLogin = { + user: { ...loginResponse.user, privateKey: Buffer.from(loginResponse.user.privateKey).toString('base64') }, + token: loginResponse.token, + newToken: loginResponse.newToken, + mnemonic: loginResponse.user.mnemonic, + }; + expect(responseLogin).to.eql(expectedResponseLogin); + }); + + it('When user logs in and credentials are not correct, then an error is thrown', async () => { + const loginDetails: LoginDetails = { + email: crypto.randomBytes(16).toString('hex'), + password: crypto.randomBytes(8).toString('hex'), + tfaCode: crypto.randomInt(1, 999999).toString().padStart(6, '0'), + }; + + authServiceSandbox.stub(Auth.prototype, 'login').withArgs(loginDetails, CryptoService.cryptoProvider).rejects(); + authServiceSandbox.stub(SdkManager.instance, 'getAuth').returns(Auth.prototype); + + try { + await AuthService.instance.doLogin(loginDetails.email, loginDetails.password, loginDetails.tfaCode || ''); + expect(false).to.be.true; //should throw error + } catch { + /* no op */ + } + }); + + it('When two factor authentication property is enabled at securityDetails endpoint, then it is returned from is2FANeeded functionality', async () => { + const email = crypto.randomBytes(16).toString('hex'); + const securityDetails: SecurityDetails = { + encryptedSalt: crypto.randomBytes(16).toString('hex'), + tfaEnabled: true, + }; + + authServiceSandbox + .stub(Auth.prototype, 'securityDetails') + .withArgs(email) + .returns(Promise.resolve(securityDetails)); + authServiceSandbox.stub(SdkManager.instance, 'getAuth').returns(Auth.prototype); + + const responseLogin = await AuthService.instance.is2FANeeded(email); + + expect(responseLogin).to.equal(securityDetails.tfaEnabled); + }); + + it('When email is not correct when checking two factor authentication property, then an error is thrown', async () => { + const email = crypto.randomBytes(16).toString('hex'); + + authServiceSandbox.stub(Auth.prototype, 'securityDetails').withArgs(email).rejects(); + authServiceSandbox.stub(SdkManager.instance, 'getAuth').returns(Auth.prototype); + + try { + await AuthService.instance.is2FANeeded(email); + expect(false).to.be.true; //should throw error + } catch { + /* no op */ + } + }); +}); diff --git a/test/services/config.service.test.ts b/test/services/config.service.test.ts new file mode 100644 index 0000000..55a6c7f --- /dev/null +++ b/test/services/config.service.test.ts @@ -0,0 +1,40 @@ +import { expect } from 'chai'; +import crypto from 'crypto'; +import { ConfigService } from '../../src/services/config.service'; + +import { config } from 'dotenv'; +config(); + +const env = Object.assign({}, process.env); + +describe('Config service', () => { + beforeEach(() => { + process.env = env; + }); + + after(() => { + process.env = env; + }); + + it('When an env property is requested, then the get method return its value', async () => { + const envKey = 'APP_CRYPTO_SECRET'; + const envValue = crypto.randomBytes(8).toString('hex'); + process.env[envKey] = envValue; + + const newEnvValue = ConfigService.instance.get(envKey); + expect(newEnvValue).to.equal(envValue); + }); + + it('When an env property that do not have value is requested, then an error is thrown', async () => { + const envKey = 'APP_CRYPTO_SECRET'; + process.env = {}; + + try { + ConfigService.instance.get(envKey); + expect(false).to.be.true; //should throw error + } catch (err) { + const error = err as Error; + expect(error.message).to.equal(`Config key ${envKey} was not found in process.env`); + } + }); +}); diff --git a/test/services/crypto.service.test.ts b/test/services/crypto.service.test.ts new file mode 100644 index 0000000..7027ba8 --- /dev/null +++ b/test/services/crypto.service.test.ts @@ -0,0 +1,159 @@ +import chai, { expect } from 'chai'; +import sinon, { SinonSandbox } from 'sinon'; +import sinonChai from 'sinon-chai'; +import crypto from 'crypto'; +import { ConfigService } from '../../src/services/config.service'; +import { CryptoService } from '../../src/services/crypto.service'; +import { ConfigKeys } from '../../src/types/config.types'; +import { Keys } from '@internxt/sdk'; +import { KeysService } from '../../src/services/keys.service'; + +chai.use(sinonChai); + +describe('Crypto service', () => { + let cryptoServiceSandbox: SinonSandbox; + + beforeEach(() => { + cryptoServiceSandbox = sinon.createSandbox(); + }); + + afterEach(() => { + cryptoServiceSandbox.restore(); + }); + + it('When text is encrypted using crypto secret env, then it can be decrypted back', () => { + const envEndpoint: { key: keyof ConfigKeys; value: string } = { + key: 'APP_CRYPTO_SECRET', + value: crypto.randomBytes(16).toString('hex'), + }; + const textToEncrypt = crypto.randomBytes(16).toString('hex'); + + const spyConfigService = cryptoServiceSandbox + .stub(ConfigService.instance, 'get') + .withArgs(envEndpoint.key) + .returns(envEndpoint.value); + + const textEncrypted = CryptoService.instance.encryptText(textToEncrypt); + const textDecrypted = CryptoService.instance.decryptText(textEncrypted); + expect(textDecrypted).to.be.equal(textToEncrypt); + expect(spyConfigService).to.be.calledWith(envEndpoint.key); + }); + + it('When text is encrypted using crypto secret env, then it can be decrypted back', () => { + const envEndpoint: { key: keyof ConfigKeys; value: string } = { + key: 'APP_CRYPTO_SECRET', + value: crypto.randomBytes(16).toString('hex'), + }; + const textToEncrypt = crypto.randomBytes(16).toString('hex'); + + const spyConfigService = cryptoServiceSandbox + .stub(ConfigService.instance, 'get') + .withArgs(envEndpoint.key) + .returns(envEndpoint.value); + + const textEncrypted = CryptoService.instance.encryptText(textToEncrypt); + const textDecrypted = CryptoService.instance.decryptText(textEncrypted); + expect(textDecrypted).to.be.equal(textToEncrypt); + expect(spyConfigService).to.be.calledWith(envEndpoint.key); + }); + + it('When a password is hashed using CryptoProvider, then it is hashed correctly', () => { + const envEndpoint: { key: keyof ConfigKeys; value: string } = { + key: 'APP_CRYPTO_SECRET', + value: crypto.randomBytes(16).toString('hex'), + }; + const spyConfigService = cryptoServiceSandbox + .stub(ConfigService.instance, 'get') + .withArgs(envEndpoint.key) + .returns(envEndpoint.value); + + const password = { + value: crypto.randomBytes(16).toString('hex'), + salt: crypto.randomBytes(16).toString('hex'), + }; + + const encryptedSalt = CryptoService.instance.encryptText(password.salt); + const hashedAndEncryptedPassword = CryptoService.cryptoProvider.encryptPasswordHash(password.value, encryptedSalt); + const hashedPassword = CryptoService.instance.decryptText(hashedAndEncryptedPassword); + + const expectedHashedPassword = crypto + .pbkdf2Sync(password.value, password.salt, 10000, 256 / 8, 'sha256') + .toString('hex'); + + expect(hashedPassword).to.be.equal(expectedHashedPassword); + expect(spyConfigService).to.be.calledWith(envEndpoint.key); + }); + + it('When a password is hashed using passToHash without salt, then it is hashed with a new generated salt', () => { + const password = crypto.randomBytes(16).toString('hex'); + + const hashedPassword = CryptoService.instance.passToHash({ password }); + + expect(hashedPassword.hash.length).to.be.equal(64); + expect(hashedPassword.salt.length).to.be.equal(32); + }); + + it('When auth keys are generated using CryptoProvider, then they are generated using KeysService', async () => { + const password = crypto.randomBytes(8).toString('hex'); + const keysReturned = { + privateKeyArmored: crypto.randomBytes(16).toString('hex'), + privateKeyArmoredEncrypted: crypto.randomBytes(16).toString('hex'), + publicKeyArmored: crypto.randomBytes(16).toString('hex'), + revocationCertificate: crypto.randomBytes(16).toString('hex'), + }; + + const kerysServiceStub = cryptoServiceSandbox + .stub(KeysService.instance, 'generateNewKeysWithEncrypted') + .returns(Promise.resolve(keysReturned)); + + const expectedKeys: Keys = { + privateKeyEncrypted: keysReturned.privateKeyArmoredEncrypted, + publicKey: keysReturned.publicKeyArmored, + revocationCertificate: keysReturned.revocationCertificate, + }; + + const resultedKeys = await CryptoService.cryptoProvider.generateKeys(password); + + expect(expectedKeys).to.be.eql(resultedKeys); + expect(kerysServiceStub).to.be.calledWith(password); + }); + + it('The node:crypto works the same as CryptoJS library', () => { + /* + const password = { + value: crypto.randomBytes(16).toString('hex'), + salt: crypto.randomBytes(16).toString('hex'), + }; + const APP_CRYPTO_SECRET = crypto.randomBytes(16).toString('hex'); + + // test PBKDF2 - node:crypto equivalent password to hash + const actualPass = CryptoJS.PBKDF2(password.value, password.salt, { + keySize: 256 / 32, + iterations: 10000, + }).toString(); + const expectedPass = crypto.pbkdf2Sync(password.value, password.salt, 10000, 256 / 8, 'sha256').toString('hex'); + expect(actualPass).to.equal(expectedPass); + expect(CryptoJS.lib.WordArray.random(128 / 8).toString().length).to.equal( + crypto.randomBytes(128 / 8).toString('hex').length, + ); + + // test CryptoJS.AES - node:crypto equivalent encrypt/decrypt + const cryptoJSEncrypted = CryptoJS.enc.Base64.parse( + CryptoJS.AES.encrypt(password.value, APP_CRYPTO_SECRET).toString(), + ).toString(CryptoJS.enc.Hex); + const textdecrypted = CryptoService.instance.decryptTextWithKey(cryptoJSEncrypted, APP_CRYPTO_SECRET); + expect(password.value).to.equal(textdecrypted); + + const textencrypted = CryptoService.instance.encryptTextWithKey(password.value, APP_CRYPTO_SECRET); + const cryptoJSdecrypted = CryptoJS.AES.decrypt( + CryptoJS.enc.Hex.parse(textencrypted).toString(CryptoJS.enc.Base64), + APP_CRYPTO_SECRET, + ).toString(CryptoJS.enc.Utf8); + expect(password.value).to.equal(cryptoJSdecrypted); + + const expectedText1 = CryptoService.instance.encryptTextWithKey(password.value, APP_CRYPTO_SECRET); + const expectedText2 = CryptoService.instance.decryptTextWithKey(expectedText1, APP_CRYPTO_SECRET); + expect(password.value).to.equal(expectedText2); + */ + }); +}); diff --git a/test/services/keys.service.test.ts b/test/services/keys.service.test.ts new file mode 100644 index 0000000..d9274d3 --- /dev/null +++ b/test/services/keys.service.test.ts @@ -0,0 +1,242 @@ +import { expect } from 'chai'; +import sinon, { SinonSandbox } from 'sinon'; +import crypto from 'crypto'; +import { aes } from '@internxt/lib'; +import * as openpgp from 'openpgp'; +import { KeysService } from '../../src/services/keys.service'; +import { ConfigService } from '../../src/services/config.service'; +import { AesInit, CorruptedEncryptedPrivateKeyError } from '../../src/types/keys.types'; + +describe('Keys service', () => { + let keysServiceSandbox: SinonSandbox; + + const aesInit: AesInit = { + iv: crypto.randomBytes(16).toString('hex'), + salt: crypto.randomBytes(64).toString('hex'), + }; + + beforeEach(() => { + keysServiceSandbox = sinon.createSandbox(); + }); + + afterEach(() => { + keysServiceSandbox.restore(); + }); + + it('When public and private keys are validated, then there is no error thrown', async () => { + keysServiceSandbox.stub(openpgp, 'readKey').resolves(); + keysServiceSandbox.stub(openpgp, 'readPrivateKey').resolves(); + keysServiceSandbox.stub(openpgp, 'createMessage').resolves(); + keysServiceSandbox.stub(openpgp, 'encrypt').resolves(); + keysServiceSandbox.stub(openpgp, 'readMessage').resolves(); + keysServiceSandbox.stub(openpgp, 'decrypt').returns( + Promise.resolve({ data: 'validate-keys' } as openpgp.DecryptMessageResult & { + data: openpgp.MaybeStream; + }), + ); + + await KeysService.instance.assertValidateKeys('dontcareprivate', 'dontcarepublic'); + expect(true).to.be.true; //checks that assertValidateKeys does not throw any error + }); + + it('When public and private keys are not valid, then the validation throws an error', async () => { + try { + await KeysService.instance.assertValidateKeys('privateKey', 'publickey'); + expect(false).to.be.true; //should throw error + } catch { + /* no op */ + } + }); + + it('When keys can be used to decrypt but they are not working good to encrypt/decrypt, then the validation throws an error', async () => { + keysServiceSandbox.stub(openpgp, 'readKey').resolves(); + keysServiceSandbox.stub(openpgp, 'readPrivateKey').resolves(); + keysServiceSandbox.stub(openpgp, 'createMessage').resolves(); + keysServiceSandbox.stub(openpgp, 'encrypt').resolves(); + keysServiceSandbox.stub(openpgp, 'readMessage').resolves(); + keysServiceSandbox.stub(openpgp, 'decrypt').returns( + Promise.resolve({ data: 'bad-validation' } as openpgp.DecryptMessageResult & { + data: openpgp.MaybeStream; + }), + ); + //every dependency method resolves (no error thrown), but nothing should be encrypted/decrypted, so the result should not be valid + try { + await KeysService.instance.assertValidateKeys('dontcareprivate', 'dontcarepublic'); + expect(false).to.be.true; //should throw error + } catch (err) { + const error = err as Error; + expect(error.message).to.equal('Keys do not match'); + } + }); + + it('When encryption fails, then it throws an error', async () => { + keysServiceSandbox.stub(openpgp, 'readKey').resolves(); + keysServiceSandbox.stub(openpgp, 'readPrivateKey').resolves(); + keysServiceSandbox.stub(openpgp, 'createMessage').resolves(); + keysServiceSandbox.stub(openpgp, 'encrypt').rejects(new Error('Encryption failed')); + keysServiceSandbox.stub(openpgp, 'readMessage').resolves(); + keysServiceSandbox.stub(openpgp, 'decrypt').resolves(); + + //encrypt method throws an exception as it can not encrypt the message (something with the encryptionKeys is bad) + try { + await KeysService.instance.assertValidateKeys('dontcareprivate', 'dontcarepublic'); + expect(false).to.be.true; //should throw error + } catch (err) { + const error = err as Error; + expect(error.message).to.equal('Encryption failed'); + } + }); + + it('When decryption fails, then it throws an error', async () => { + keysServiceSandbox.stub(openpgp, 'readKey').resolves(); + keysServiceSandbox.stub(openpgp, 'readPrivateKey').resolves(); + keysServiceSandbox.stub(openpgp, 'createMessage').resolves(); + keysServiceSandbox.stub(openpgp, 'encrypt').resolves(); + keysServiceSandbox.stub(openpgp, 'readMessage').resolves(); + keysServiceSandbox.stub(openpgp, 'decrypt').rejects(new Error('Decryption failed')); + + //decrypt method throws an exception as it can not decrypt the message (something with the decryptionKeys is bad) + try { + await KeysService.instance.assertValidateKeys('dontcareprivate', 'dontcarepublic'); + expect(false).to.be.true; //should throw error + } catch (err) { + const error = err as Error; + expect(error.message).to.equal('Decryption failed'); + } + }); + + it('When private key is encrypted with a password and it is validated, then there is no error thrown', async () => { + const plainPrivateKey = crypto.randomBytes(16).toString('hex'); + const password = crypto.randomBytes(8).toString('hex'); + + const encryptedPrivateKey = aes.encrypt(plainPrivateKey, password, aesInit); + + keysServiceSandbox.stub(KeysService.instance, 'decryptPrivateKey').resolves(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + keysServiceSandbox.stub(KeysService.instance, 'isValidKey').returns(Promise.resolve(true)); + + await KeysService.instance.assertPrivateKeyIsValid(encryptedPrivateKey, password); + + expect(true).to.be.true; //checks that assertPrivateKeyIsValid does not throw any error + }); + + it('When private key is encrypted with bad iterations, then it throws a WrongIterationsToEncryptPrivateKey error', async () => { + const plainPrivateKey = crypto.randomBytes(16).toString('hex'); + const password = crypto.randomBytes(8).toString('hex'); + + const badEncryptionPrivateKey = aes.encrypt(plainPrivateKey, password, aesInit, 9999); + + keysServiceSandbox.stub(KeysService.instance, 'decryptPrivateKey').rejects(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + keysServiceSandbox.stub(KeysService.instance, 'isValidKey').returns(Promise.resolve(true)); + + try { + await KeysService.instance.assertPrivateKeyIsValid(badEncryptionPrivateKey, password); + expect(false).to.be.true; //should throw error + } catch (err) { + const error = err as Error; + expect(error.message).to.equal('Private key was encrypted using the wrong iterations number'); + } + }); + + it('When private key is badly encrypted, then it throws a CorruptedEncryptedPrivateKey error', async () => { + const plainPrivateKey = crypto.randomBytes(16).toString('hex'); + const password = crypto.randomBytes(8).toString('hex'); + + const badEncryptionPrivateKey = aes.encrypt(plainPrivateKey, password, aesInit); + + keysServiceSandbox + .stub(KeysService.instance, 'decryptPrivateKey') + .throwsException(CorruptedEncryptedPrivateKeyError); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + keysServiceSandbox.stub(KeysService.instance, 'isValidKey').returns(Promise.resolve(false)); + + try { + await KeysService.instance.assertPrivateKeyIsValid(badEncryptionPrivateKey, password); + expect(false).to.be.true; //should throw error + } catch (err) { + const error = err as Error; + expect(error.message).to.equal('Private key is corrupted'); + } + }); + + it('When private key is bad encoded, then it throws a BadEncodedPrivateKey error', async () => { + const plainPrivateKey = crypto.randomBytes(16).toString('hex'); + const password = crypto.randomBytes(8).toString('hex'); + + const badEncodedPrivateKey = aes.encrypt(plainPrivateKey, password, aesInit); + + keysServiceSandbox.stub(KeysService.instance, 'decryptPrivateKey').resolves(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + keysServiceSandbox.stub(KeysService.instance, 'isValidKey').returns(Promise.resolve(false)); + + try { + await KeysService.instance.assertPrivateKeyIsValid(badEncodedPrivateKey, password); + expect(false).to.be.true; //should throw error + } catch (err) { + const error = err as Error; + expect(error.message).to.equal('Private key is bad encoded'); + } + }); + + it('When the key is not valid, then isValid method returns false', async () => { + keysServiceSandbox.stub(openpgp, 'readKey').rejects(); + + expect(await KeysService.instance.isValidKey('key')).to.be.false; + }); + + it('When the key is valid, then isValid method returns true', async () => { + keysServiceSandbox.stub(openpgp, 'readKey').resolves(); + + expect(await KeysService.instance.isValidKey('key')).to.be.true; + }); + + it('When aes information is required, then it is read from the config service', async () => { + const configServiceInstanceStub = keysServiceSandbox.stub(ConfigService.instance, 'get'); + configServiceInstanceStub.withArgs('APP_MAGIC_IV').returns(aesInit.iv); + configServiceInstanceStub.withArgs('APP_MAGIC_SALT').returns(aesInit.salt); + + const result = KeysService.instance.getAesInitFromEnv(); + expect(result).to.eql(aesInit); + }); + + it('When message is encrypted with private key & password, then it can be decrypted using same data', async () => { + const plainPrivateKey = crypto.randomBytes(16).toString('hex'); + const password = crypto.randomBytes(8).toString('hex'); + + const configServiceInstanceStub = keysServiceSandbox.stub(ConfigService.instance, 'get'); + configServiceInstanceStub.withArgs('APP_MAGIC_IV').returns(aesInit.iv); + configServiceInstanceStub.withArgs('APP_MAGIC_SALT').returns(aesInit.salt); + + const encryptedPrivateKey = KeysService.instance.encryptPrivateKey(plainPrivateKey, password); + const decryptedPrivateKey = KeysService.instance.decryptPrivateKey(encryptedPrivateKey, password); + + expect(decryptedPrivateKey).to.equal(plainPrivateKey); + }); + + it('When new pgp keys are required, then it generates them from the openpgp library', async () => { + const pgpKeys = { + privateKey: crypto.randomBytes(16).toString('hex'), + publicKey: crypto.randomBytes(16).toString('hex'), + revocationCertificate: crypto.randomBytes(16).toString('hex'), + } as unknown as openpgp.KeyPair & { revocationCertificate: string }; + + const pgpKeysWithEncrypted = { + privateKeyArmored: pgpKeys.privateKey, + privateKeyArmoredEncrypted: crypto.randomBytes(16).toString('hex'), + publicKeyArmored: Buffer.from(String(pgpKeys.publicKey)).toString('base64'), + revocationCertificate: Buffer.from(pgpKeys.revocationCertificate).toString('base64'), + }; + + const password = crypto.randomBytes(8).toString('hex'); + + keysServiceSandbox.stub(openpgp, 'generateKey').returns(Promise.resolve(pgpKeys)); + keysServiceSandbox + .stub(KeysService.instance, 'encryptPrivateKey') + .returns(pgpKeysWithEncrypted.privateKeyArmoredEncrypted); + + const newKeys = await KeysService.instance.generateNewKeysWithEncrypted(password); + + expect(newKeys).to.eql(pgpKeysWithEncrypted); + }); +}); diff --git a/test/services/sdkmanager.service.test.ts b/test/services/sdkmanager.service.test.ts new file mode 100644 index 0000000..e34e815 --- /dev/null +++ b/test/services/sdkmanager.service.test.ts @@ -0,0 +1,295 @@ +import chai, { expect } from 'chai'; +import sinon, { SinonSandbox } from 'sinon'; +import sinonChai from 'sinon-chai'; +import crypto from 'crypto'; +import { Auth, Drive, photos } from '@internxt/sdk'; +import { Trash } from '@internxt/sdk/dist/drive'; +import { SdkManager, SdkManagerApiSecurity } from '../../src/services/SDKManager.service'; +import { ConfigKeys } from '../../src/types/config.types'; +import { ConfigService } from '../../src/services/config.service'; +import { AppDetails } from '@internxt/sdk/dist/shared'; +import packageJson from '../../package.json'; + +chai.use(sinonChai); + +describe('SDKManager service', () => { + let sdkManagerServiceSandbox: SinonSandbox; + + const apiSecurity: SdkManagerApiSecurity = { + newToken: crypto.randomBytes(16).toString('hex'), + token: crypto.randomBytes(16).toString('hex'), + }; + const appDetails: AppDetails = { + clientName: crypto.randomBytes(16).toString('hex'), + clientVersion: crypto.randomBytes(16).toString('hex'), + }; + + beforeEach(() => { + sdkManagerServiceSandbox = sinon.createSandbox(); + }); + + afterEach(() => { + sdkManagerServiceSandbox.restore(); + }); + + it('When SDKManager apiSecurity is requested, then it is returned from static property', () => { + const apiSecurity: SdkManagerApiSecurity = { + newToken: crypto.randomBytes(16).toString('hex'), + token: crypto.randomBytes(16).toString('hex'), + }; + SdkManager.init(apiSecurity); + + expect(SdkManager.getApiSecurity()).to.eql(apiSecurity); + }); + + it('When SDKManager apiSecurity is requested but it is not started, then an error is thrown', () => { + try { + SdkManager.getApiSecurity(); + expect(false).to.be.true; //should throw error + } catch { + /* no op */ + } + }); + + it('When SDKManager is cleaned, then apiSecurity property is cleaned', () => { + SdkManager.init(apiSecurity); + SdkManager.clean(); + try { + SdkManager.getApiSecurity({ throwErrorOnMissingCredentials: true }); + expect(false).to.be.true; //should throw error + } catch { + /* no op */ + } + const apiSecurityResponse = SdkManager.getApiSecurity({ throwErrorOnMissingCredentials: false }); + expect(apiSecurityResponse).to.be.undefined; + }); + + it('When getAppDetails is requested, then it is generated using packageJson values', () => { + const expectedAppdetails = { + clientName: packageJson.name, + clientVersion: packageJson.version, + }; + /*sdkManagerServiceSandbox.stub(packageJson, 'name').returns(appDetails.clientName); + sdkManagerServiceSandbox.stub(packageJson, 'version').returns(appDetails.clientVersion);*/ + + const appDetailsResponse = SdkManager.getAppDetails(); + expect(expectedAppdetails).to.eql(appDetailsResponse); + }); + + it('When AuthV2 client is requested, then it is generated using internxt sdk', () => { + const envEndpoint: { key: keyof ConfigKeys; value: string } = { + key: 'DRIVE_NEW_API_URL', + value: 'test/api', + }; + SdkManager.init(apiSecurity); + + const authClientV2 = Auth.client(envEndpoint.value, appDetails, apiSecurity); + + const spyConfigService = sdkManagerServiceSandbox + .stub(ConfigService.instance, 'get') + .withArgs(envEndpoint.key) + .returns(envEndpoint.value); + sdkManagerServiceSandbox.stub(SdkManager, 'getApiSecurity').returns(apiSecurity); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sdkManagerServiceSandbox.stub(SdkManager, 'getAppDetails').returns(appDetails); + sdkManagerServiceSandbox.stub(Auth, 'client').returns(authClientV2); + + const authV2 = SdkManager.instance.getAuthV2(); + + expect(spyConfigService).to.be.calledWith(envEndpoint.key); + expect(authV2).to.eql(authClientV2); + }); + + it('When Auth client is requested, then it is generated using internxt sdk', () => { + const envEndpoint: { key: keyof ConfigKeys; value: string } = { + key: 'DRIVE_API_URL', + value: 'test/api', + }; + SdkManager.init(apiSecurity); + + const authClient = Auth.client(envEndpoint.value, appDetails, apiSecurity); + + const spyConfigService = sdkManagerServiceSandbox + .stub(ConfigService.instance, 'get') + .withArgs(envEndpoint.key) + .returns(envEndpoint.value); + sdkManagerServiceSandbox.stub(SdkManager, 'getApiSecurity').returns(apiSecurity); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sdkManagerServiceSandbox.stub(SdkManager, 'getAppDetails').returns(appDetails); + sdkManagerServiceSandbox.stub(Auth, 'client').returns(authClient); + + const auth = SdkManager.instance.getAuth(); + + expect(spyConfigService).to.be.calledWith(envEndpoint.key); + expect(auth).to.eql(authClient); + }); + + it('When Payments client is requested, then it is generated using internxt sdk', () => { + const envEndpoint: { key: keyof ConfigKeys; value: string } = { + key: 'PAYMENTS_API_URL', + value: 'test/api', + }; + SdkManager.init(apiSecurity); + + const client = Drive.Payments.client(envEndpoint.value, appDetails, apiSecurity); + + const spyConfigService = sdkManagerServiceSandbox + .stub(ConfigService.instance, 'get') + .withArgs(envEndpoint.key) + .returns(envEndpoint.value); + sdkManagerServiceSandbox.stub(SdkManager, 'getApiSecurity').returns(apiSecurity); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sdkManagerServiceSandbox.stub(SdkManager, 'getAppDetails').returns(appDetails); + sdkManagerServiceSandbox.stub(Drive.Payments, 'client').returns(client); + + const newClient = SdkManager.instance.getPayments(); + + expect(spyConfigService).to.be.calledWith(envEndpoint.key); + expect(newClient).to.eql(client); + }); + + it('When Users client is requested, then it is generated using internxt sdk', () => { + const envEndpoint: { key: keyof ConfigKeys; value: string } = { + key: 'DRIVE_API_URL', + value: 'test/api', + }; + SdkManager.init(apiSecurity); + + const client = Drive.Users.client(envEndpoint.value, appDetails, apiSecurity); + + const spyConfigService = sdkManagerServiceSandbox + .stub(ConfigService.instance, 'get') + .withArgs(envEndpoint.key) + .returns(envEndpoint.value); + sdkManagerServiceSandbox.stub(SdkManager, 'getApiSecurity').returns(apiSecurity); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sdkManagerServiceSandbox.stub(SdkManager, 'getAppDetails').returns(appDetails); + sdkManagerServiceSandbox.stub(Drive.Users, 'client').returns(client); + + const newClient = SdkManager.instance.getUsers(); + + expect(spyConfigService).to.be.calledWith(envEndpoint.key); + expect(newClient).to.eql(client); + }); + + it('When Referrals client is requested, then it is generated using internxt sdk', () => { + const envEndpoint: { key: keyof ConfigKeys; value: string } = { + key: 'DRIVE_API_URL', + value: 'test/api', + }; + SdkManager.init(apiSecurity); + + const client = Drive.Referrals.client(envEndpoint.value, appDetails, apiSecurity); + + const spyConfigService = sdkManagerServiceSandbox + .stub(ConfigService.instance, 'get') + .withArgs(envEndpoint.key) + .returns(envEndpoint.value); + sdkManagerServiceSandbox.stub(SdkManager, 'getApiSecurity').returns(apiSecurity); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sdkManagerServiceSandbox.stub(SdkManager, 'getAppDetails').returns(appDetails); + sdkManagerServiceSandbox.stub(Drive.Referrals, 'client').returns(client); + + const newClient = SdkManager.instance.getReferrals(); + + expect(spyConfigService).to.be.calledWith(envEndpoint.key); + expect(newClient).to.eql(client); + }); + + it('When Storage client is requested, then it is generated using internxt sdk', () => { + const envEndpoint: { key: keyof ConfigKeys; value: string } = { + key: 'DRIVE_API_URL', + value: 'test/api', + }; + SdkManager.init(apiSecurity); + + const client = Drive.Storage.client(envEndpoint.value, appDetails, apiSecurity); + + const spyConfigService = sdkManagerServiceSandbox + .stub(ConfigService.instance, 'get') + .withArgs(envEndpoint.key) + .returns(envEndpoint.value); + sdkManagerServiceSandbox.stub(SdkManager, 'getApiSecurity').returns(apiSecurity); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sdkManagerServiceSandbox.stub(SdkManager, 'getAppDetails').returns(appDetails); + sdkManagerServiceSandbox.stub(Drive.Storage, 'client').returns(client); + + const newClient = SdkManager.instance.getStorage(); + + expect(spyConfigService).to.be.calledWith(envEndpoint.key); + expect(newClient).to.eql(client); + }); + + it('When Trash client is requested, then it is generated using internxt sdk', () => { + const envEndpoint: { key: keyof ConfigKeys; value: string } = { + key: 'DRIVE_NEW_API_URL', + value: 'test/api', + }; + SdkManager.init(apiSecurity); + + const client = Trash.client(envEndpoint.value, appDetails, apiSecurity); + + const spyConfigService = sdkManagerServiceSandbox + .stub(ConfigService.instance, 'get') + .withArgs(envEndpoint.key) + .returns(envEndpoint.value); + sdkManagerServiceSandbox.stub(SdkManager, 'getApiSecurity').returns(apiSecurity); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sdkManagerServiceSandbox.stub(SdkManager, 'getAppDetails').returns(appDetails); + sdkManagerServiceSandbox.stub(Trash, 'client').returns(client); + + const newClient = SdkManager.instance.getTrash(); + + expect(spyConfigService).to.be.calledWith(envEndpoint.key); + expect(newClient).to.eql(client); + }); + + it('When Photos client is requested, then it is generated using internxt sdk', () => { + const envEndpoint: { key: keyof ConfigKeys; value: string } = { + key: 'PHOTOS_API_URL', + value: 'test/api', + }; + SdkManager.init(apiSecurity); + + const client = new photos.Photos(envEndpoint.value, apiSecurity.newToken); + + const spyConfigService = sdkManagerServiceSandbox + .stub(ConfigService.instance, 'get') + .withArgs(envEndpoint.key) + .returns(envEndpoint.value); + sdkManagerServiceSandbox.stub(SdkManager, 'getApiSecurity').returns(apiSecurity); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sdkManagerServiceSandbox.stub(SdkManager, 'getAppDetails').returns(appDetails); + + sdkManagerServiceSandbox.stub(photos.Photos, 'prototype').returns(client); + + const newClient = SdkManager.instance.getPhotos(); + + expect(spyConfigService).to.be.calledWith(envEndpoint.key); + expect(newClient).to.eql(client); + }); + + it('When Share client is requested, then it is generated using internxt sdk', () => { + const envEndpoint: { key: keyof ConfigKeys; value: string } = { + key: 'DRIVE_NEW_API_URL', + value: 'test/api', + }; + SdkManager.init(apiSecurity); + + const client = Drive.Share.client(envEndpoint.value, appDetails, apiSecurity); + + const spyConfigService = sdkManagerServiceSandbox + .stub(ConfigService.instance, 'get') + .withArgs(envEndpoint.key) + .returns(envEndpoint.value); + sdkManagerServiceSandbox.stub(SdkManager, 'getApiSecurity').returns(apiSecurity); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sdkManagerServiceSandbox.stub(SdkManager, 'getAppDetails').returns(appDetails); + sdkManagerServiceSandbox.stub(Drive.Share, 'client').returns(client); + + const newClient = SdkManager.instance.getShare(); + + expect(spyConfigService).to.be.calledWith(envEndpoint.key); + expect(newClient).to.eql(client); + }); +}); diff --git a/test/tsconfig.json b/test/tsconfig.json index 95898fc..a4ac0c6 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -1,8 +1,10 @@ { "extends": "../tsconfig", "compilerOptions": { - "noEmit": true + "noEmit": true, + "rootDir": "." }, + "include": ["**/*"], "references": [ {"path": ".."} ] diff --git a/tsconfig.json b/tsconfig.json index 21e2afb..666305b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,8 +7,10 @@ "strict": true, "target": "es2022", "moduleResolution": "Node", - "composite": true + "composite": true, + "resolveJsonModule": true, + "removeComments": true, + "esModuleInterop": true, }, - "include": ["./src/**/*"] } diff --git a/yarn.lock b/yarn.lock index 0886174..cd363e0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -962,15 +962,20 @@ "@typescript-eslint/parser" "^5.4.0" eslint-config-prettier "^8.3.0" +"@internxt/lib@^1.2.0": + version "1.2.0" + resolved "https://npm.pkg.github.com/download/@internxt/lib/1.2.0/172d7929abb3dc34fda044ad9977875791f7a471#172d7929abb3dc34fda044ad9977875791f7a471" + integrity sha512-14byNxSU0S0KSc3g+BWiJb7/fcHUfqxkHQRz9fwJhAHNmgOo+Bhx13xXk07KKUzeNX1JymSGEZkVCk0AOQiM+Q== + "@internxt/prettier-config@^1.0.2": version "1.0.2" resolved "https://npm.pkg.github.com/download/@internxt/prettier-config/1.0.2/5bd220b8de76734448db5475b3e0c01f9d22c19b#5bd220b8de76734448db5475b3e0c01f9d22c19b" integrity sha512-t4HiqvCbC7XgQepwWlIaFJe3iwW7HCf6xOSU9nKTV0tiGqOPz7xMtIgLEloQrDA34Cx4PkOYBXrvFPV6RxSFAA== -"@internxt/sdk@^1.4.67": - version "1.4.67" - resolved "https://npm.pkg.github.com/download/@internxt/sdk/1.4.67/86fe22ae1a418a1fd29fd586e7dda5f391c490fb#86fe22ae1a418a1fd29fd586e7dda5f391c490fb" - integrity sha512-jiDue0BRNm/uAEiah/19EL5bXd3GKUdPmyFkzTTEQjpk+xGhUWAkOgTw+Bp6jN5KL9WqleOnKdWIiGKoKj24XQ== +"@internxt/sdk@^1.4.70": + version "1.4.70" + resolved "https://npm.pkg.github.com/download/@internxt/sdk/1.4.70/6e07263dbaa130269323d21b895ea8c0e7d1cc3c#6e07263dbaa130269323d21b895ea8c0e7d1cc3c" + integrity sha512-bqtTsYjT9kjkxFfqFVOrxlp3vlCtQdLhH9LWOF4DSIc5j/EnDh7hTKwSGo+mzMdqYYeivQB3cqrxr6QiyBMkcw== dependencies: axios "^0.24.0" query-string "^7.1.0" @@ -1047,6 +1052,11 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@mattiasbuelens/web-streams-adapter@~0.1.0": + version "0.1.0" + resolved "https://registry.yarnpkg.com/@mattiasbuelens/web-streams-adapter/-/web-streams-adapter-0.1.0.tgz#607b5a25682f4ae2741da7ba6df39302505336b3" + integrity sha512-oV4PyZfwJNtmFWhvlJLqYIX1Nn22ML8FZpS16ZUKv0hg7414xV1fjsGqxQzLT2dyK92TKxsJSwMOd7VNHAtPmA== + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz" @@ -1389,6 +1399,14 @@ dependencies: "@octokit/openapi-types" "^12.11.0" +"@openpgp/web-stream-tools@0.0.11-patch-0": + version "0.0.11-patch-0" + resolved "https://registry.yarnpkg.com/@openpgp/web-stream-tools/-/web-stream-tools-0.0.11-patch-0.tgz#c8b2ecfa62403bb10de2b33c8548bd25b14f1828" + integrity sha512-NrIF4DkCqC3WDcMDAgz17z+0Iik1fVrKuvdbjZXCnMZgYAWHpIG8CWnbp8yQRahAdF26jqCopA/qXrp8CYI2yw== + dependencies: + "@mattiasbuelens/web-streams-adapter" "~0.1.0" + web-streams-polyfill "~3.0.3" + "@sindresorhus/is@^4.0.0": version "4.6.0" resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-4.6.0.tgz#3c7c9c46e678feefe7a2e5bb609d3dbd665ffb3f" @@ -2047,6 +2065,14 @@ resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.2.tgz#31f6eec1ed7ec23f4f05608d3a2d381df041f564" integrity sha512-7aqorHYgdNO4DM36stTiGO3DvKoex9TQRwsJU6vMaFGyqpBA1MNZkz+PG3gaNUPpTAOYhT1WR7M1JyA3fbS9Cw== +"@types/sinon-chai@^3.2.12": + version "3.2.12" + resolved "https://registry.yarnpkg.com/@types/sinon-chai/-/sinon-chai-3.2.12.tgz#c7cb06bee44a534ec84f3a5534c3a3a46fd779b6" + integrity sha512-9y0Gflk3b0+NhQZ/oxGtaAJDvRywCa5sIyaVnounqLvmf93yBF4EgIRspePtkMs3Tr844nCclYMlcCNmLCvjuQ== + dependencies: + "@types/chai" "*" + "@types/sinon" "*" + "@types/sinon@*": version "10.0.6" resolved "https://registry.npmjs.org/@types/sinon/-/sinon-10.0.6.tgz" @@ -2532,6 +2558,16 @@ asap@^2.0.0: resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" integrity sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA== +asn1.js@^5.0.0: + version "5.4.1" + resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-5.4.1.tgz#11a980b84ebb91781ce35b0fdc2ee294e3783f07" + integrity sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA== + dependencies: + bn.js "^4.0.0" + inherits "^2.0.1" + minimalistic-assert "^1.0.0" + safer-buffer "^2.1.0" + assertion-error@^1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz" @@ -2612,6 +2648,11 @@ bl@^4.1.0: inherits "^2.0.4" readable-stream "^3.4.0" +bn.js@^4.0.0: + version "4.12.0" + resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.12.0.tgz#775b3f278efbb9718eec7361f483fb36fbbfea88" + integrity sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA== + bowser@^2.11.0: version "2.11.0" resolved "https://registry.yarnpkg.com/bowser/-/bowser-2.11.0.tgz#5ca3c35757a7aa5771500c70a73a9f91ef420a8f" @@ -5449,6 +5490,11 @@ min-indent@^1.0.0: resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869" integrity sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg== +minimalistic-assert@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7" + integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A== + minimatch@5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.0.1.tgz#fb9022f7528125187c92bd9e9b6366be1cf3415b" @@ -6003,6 +6049,13 @@ onetime@^6.0.0: dependencies: mimic-fn "^4.0.0" +openpgp@^5.11.0: + version "5.11.0" + resolved "https://registry.yarnpkg.com/openpgp/-/openpgp-5.11.0.tgz#cec5b285d188148f7b5201b9aceb53850cc286a2" + integrity sha512-hytHsxIPtRhuh6uAmoBUThHSwHSX3imLu7x4453T+xkVqIw49rl22MRD4KQIAQdCDoVdouejzYgcuLmMA/2OAA== + dependencies: + asn1.js "^5.0.0" + optionator@^0.9.3: version "0.9.3" resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.3.tgz#007397d44ed1872fdc6ed31360190f81814e2c64" @@ -6697,7 +6750,7 @@ safe-regex-test@^1.0.0: get-intrinsic "^1.1.3" is-regex "^1.1.4" -"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0": +"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0", safer-buffer@^2.1.0: version "2.1.2" resolved "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== @@ -6821,6 +6874,11 @@ simple-swizzle@^0.2.2: dependencies: is-arrayish "^0.3.1" +sinon-chai@^3.7.0: + version "3.7.0" + resolved "https://registry.yarnpkg.com/sinon-chai/-/sinon-chai-3.7.0.tgz#cfb7dec1c50990ed18c153f1840721cf13139783" + integrity sha512-mf5NURdUaSdnatJx3uhoBOrY9dtL19fiOtAdT1Azxg3+lNJFiuN0uzaU3xX1LeAfL17kHQhTAJgpsfhbMJMY2g== + sinon@^16.1.3: version "16.1.3" resolved "https://registry.yarnpkg.com/sinon/-/sinon-16.1.3.tgz#b760ddafe785356e2847502657b4a0da5501fba8" @@ -7532,6 +7590,11 @@ wcwidth@^1.0.1: dependencies: defaults "^1.0.3" +web-streams-polyfill@~3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.0.3.tgz#f49e487eedeca47a207c1aee41ee5578f884b42f" + integrity sha512-d2H/t0eqRNM4w2WvmTdoeIvzAUSpK7JmATB8Nr2lb7nQ9BTIJVjbQ/TRFVEh2gUH1HwclPdoPtfMoFfetXaZnA== + webidl-conversions@^3.0.0: version "3.0.1" resolved "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz"