diff --git a/.codebuild/buildspec.yml b/.codebuild/buildspec.yml index 390c383a..9949c9b3 100644 --- a/.codebuild/buildspec.yml +++ b/.codebuild/buildspec.yml @@ -94,6 +94,7 @@ phases: build: commands: - | + set -e; if expr "${GIT_REF_TO_DEPLOY}" : "dev" >/dev/null; then # Gets all env vars with `dev_` prefix and re-exports them without the prefix for var in "${!dev_@}"; do @@ -102,8 +103,8 @@ phases: make migrate; make deploy-lambdas-dev-testnet; - fi - - | + fi; + if expr "${GIT_REF_TO_DEPLOY}" : "master" >/dev/null; then # Gets all env vars with `testnet_` prefix and re-exports them without the prefix for var in "${!testnet_@}"; do @@ -112,8 +113,8 @@ phases: make migrate; make deploy-lambdas-testnet; - fi - - | + fi; + if expr "${GIT_REF_TO_DEPLOY}" : "v.*" >/dev/null; then # Gets all env vars with `mainnet_` prefix and re-exports them without the prefix for var in "${!mainnet_@}"; do @@ -122,5 +123,4 @@ phases: make migrate; make deploy-lambdas-mainnet; - fi - + fi; diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index 319ad3d2..819bc81b 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -2,6 +2,7 @@ name: PR Validation on: pull_request: types: [ready_for_review, synchronize] + branches: [master, dev] paths-ignore: - '**.md' push: @@ -80,3 +81,7 @@ jobs: WS_DOMAIN: ws.ci.wallet-service.hathor.network EXPLORER_SERVICE_LAMBDA_ENDPOINT: https://lambda.eu-central-1.amazonaws.com WALLET_SERVICE_LAMBDA_ENDPOINT: https://lambda.eu-central-1.amazonaws.com + - name: Upload coverage + uses: codecov/codecov-action@v3 + with: + verbose: true diff --git a/DATABASE.md b/DATABASE.md index 99728277..96f4a492 100644 --- a/DATABASE.md +++ b/DATABASE.md @@ -142,6 +142,19 @@ CREATE TABLE `transaction` ( PRIMARY KEY (`tx_id`) ); +CREATE TABLE `push_devices` ( + `device_id` varchar(256) NOT NULL, + `push_provider` enum('ios','android') NOT NULL, + `wallet_id` varchar(64) NOT NULL, + `enable_push` tinyint(1) NOT NULL DEFAULT '0', + `enable_show_amounts` tinyint(1) NOT NULL DEFAULT '0', + `enable_only_new_tx` tinyint(1) NOT NULL DEFAULT '0', + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`device_id`), + KEY `wallet_id` (`wallet_id`), + CONSTRAINT `push_devices_ibfk_1` FOREIGN KEY (`wallet_id`) REFERENCES `wallet` (`id`) +); + CREATE INDEX transaction_version_idx USING HASH ON `transaction`(`version`); CREATE INDEX tx_output_heightlock_idx USING HASH ON `tx_output`(`heightlock`); CREATE INDEX tx_output_timelock_idx USING HASH ON `tx_output`(`timelock`); diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 00000000..94f12a10 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,20 @@ +codecov: + branch: + +coverage: + status: + project: + default: + # minimum coverage ratio that the commit must meet to be considered a success + target: 88% + if_ci_failed: error + only_pulls: true + patch: + default: + # minimum coverage ratio that the commit must meet to be considered a success + target: 88% + if_ci_failed: error + only_pulls: true + +github_checks: + annotations: true diff --git a/db/migrations/20221108235926-create-pushdevices.js.js b/db/migrations/20221108235926-create-pushdevices.js.js new file mode 100644 index 00000000..ecc3a122 --- /dev/null +++ b/db/migrations/20221108235926-create-pushdevices.js.js @@ -0,0 +1,49 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('push_devices', { + device_id: { + type: Sequelize.STRING(256), + allowNull: false, + primaryKey: true, + }, + push_provider: { + type: Sequelize.ENUM(['ios', 'android']), + allowNull: false, + }, + wallet_id: { + type: Sequelize.STRING(64), + allowNull: false, + references: { + model: 'wallet', + key: 'id', + }, + }, + enable_push: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + enable_show_amounts: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + enable_only_new_tx: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + updated_at: { + type: 'TIMESTAMP', + allowNull: false, + defaultValue: Sequelize.literal('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'), + }, + }); + }, + async down(queryInterface) { + await queryInterface.dropTable('push_devices'); + }, +}; diff --git a/db/models/pushdevices.js b/db/models/pushdevices.js new file mode 100644 index 00000000..3a905a3e --- /dev/null +++ b/db/models/pushdevices.js @@ -0,0 +1,65 @@ +'use strict'; +const { Model } = require('sequelize'); + +module.exports = (sequelize, DataTypes) => { + class PushDevices extends Model { + /** + * Helper method for defining associations. + * This method is not a part of Sequelize lifecycle. + * The `models/index` file will call this method automatically. + */ + static associate(models) { + // define association here + } + } + PushDevices.init( + { + device_id: { + type: DataTypes.STRING(256), + allowNull: false, + primaryKey: true, + }, + push_provider: { + type: DataTypes.ENUM(['ios', 'android']), + allowNull: false, + }, + wallet_id: { + type: DataTypes.STRING(64), + allowNull: false, + references: { + model: 'wallet', + key: 'id', + }, + }, + enable_push: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + enable_show_amounts: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + enable_only_new_tx: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + updated_at: { + type: 'TIMESTAMP', + allowNull: false, + defaultValue: DataTypes.literal( + 'CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP' + ), + }, + }, + { + sequelize, + modelName: 'PushDevices', + tableName: 'push_devices', + timestamps: false, + }, + ); + return PushDevices; +}; diff --git a/package-lock.json b/package-lock.json index b14eb7ab..31367a31 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "hathor-wallet-service", - "version": "1.19.0-alpha", + "version": "1.21.0-alpha", "lockfileVersion": 2, "requires": true, "packages": { diff --git a/package.json b/package.json index 4acc37d4..873b54f0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hathor-wallet-service", - "version": "1.19.0-alpha", + "version": "1.21.0-alpha", "description": "", "scripts": { "postinstall": "npm dedupe", diff --git a/serverless.yml b/serverless.yml index db8c231a..50309bb7 100644 --- a/serverless.yml +++ b/serverless.yml @@ -216,6 +216,17 @@ functions: warmup: walletWarmer: enabled: true + checkAddressMineApi: + handler: src/api/addresses.checkMine + events: + - http: + path: wallet/addresses/check_mine + method: post + cors: true + authorizer: ${self:custom.authorizer.walletBearer} + warmup: + walletWarmer: + enabled: false getAddressesApi: handler: src/api/addresses.get events: @@ -448,3 +459,36 @@ functions: warmup: walletWarmer: enabled: false + pushRegister: + handler: src/api/pushRegister.register + events: + - http: + path: push/register + method: post + cors: true + authorizer: ${self:custom.authorizer.walletBearer} + warmup: + walletWarmer: + enabled: false + pushUpdate: + handler: src/api/pushUpdate.update + events: + - http: + path: push/update + method: put + cors: true + authorizer: ${self:custom.authorizer.walletBearer} + warmup: + walletWarmer: + enabled: false + pushUnregister: + handler: src/api/pushUnregister.unregister + events: + - http: + path: push/unregister + method: delete + cors: true + authorizer: ${self:custom.authorizer.walletBearer} + warmup: + walletWarmer: + enabled: false \ No newline at end of file diff --git a/src/api/addresses.ts b/src/api/addresses.ts index 8f3564eb..9bff0a4e 100644 --- a/src/api/addresses.ts +++ b/src/api/addresses.ts @@ -7,6 +7,7 @@ import 'source-map-support/register'; +import Joi from 'joi'; import { APIGatewayProxyHandler } from 'aws-lambda'; import { ApiError } from '@src/api/errors'; import { closeDbAndGetError, warmupMiddleware } from '@src/api/utils'; @@ -14,6 +15,7 @@ import { getWallet, getWalletAddresses, } from '@src/db'; +import { AddressInfo } from '@src/types'; import { closeDbConnection, getDbConnection } from '@src/utils'; import { walletIdProxyHandler } from '@src/commons'; import middy from '@middy/core'; @@ -21,6 +23,75 @@ import cors from '@middy/http-cors'; const mysql = getDbConnection(); +const checkMineBodySchema = Joi.object({ + addresses: Joi.array() + // Validate that addresses are a base58 string and exactly 34 in length + .items(Joi.string().regex(/^[A-HJ-NP-Za-km-z1-9]*$/).min(34).max(34)) + .min(1) + .max(512) // max number of addresses in a tx (256 outputs and 256 inputs) + .required(), +}); + +/* + * Check if a list of addresses belong to the caller wallet + * + * This lambda is called by API Gateway on POST /addresses/check_mine + */ +export const checkMine: APIGatewayProxyHandler = middy(walletIdProxyHandler(async (walletId, event) => { + const status = await getWallet(mysql, walletId); + + // If the wallet is not started or ready, we can skip the query on the address table + if (!status) { + return closeDbAndGetError(mysql, ApiError.WALLET_NOT_FOUND); + } + + if (!status.readyAt) { + return closeDbAndGetError(mysql, ApiError.WALLET_NOT_READY); + } + + const eventBody = (function parseBody(body) { + try { + return JSON.parse(body); + } catch (e) { + return null; + } + }(event.body)); + + const { value, error } = checkMineBodySchema.validate(eventBody, { + abortEarly: false, + convert: false, + }); + + if (error) { + const details = error.details.map((err) => ({ + message: err.message, + path: err.path, + })); + + return closeDbAndGetError(mysql, ApiError.INVALID_PAYLOAD, { details }); + } + + const sentAddresses = value.addresses; + const dbWalletAddresses: AddressInfo[] = await getWalletAddresses(mysql, walletId, sentAddresses); + const walletAddresses: Set = dbWalletAddresses.reduce((acc, { address }) => acc.add(address), new Set([])); + + await closeDbConnection(mysql); + + const addressBelongMap = sentAddresses.reduce((acc: {string: boolean}, address: string) => { + acc[address] = walletAddresses.has(address); + + return acc; + }, {}); + + return { + statusCode: 200, + body: JSON.stringify({ + success: true, + addresses: addressBelongMap, + }), + }; +})).use(cors()); + /* * Get the addresses of a wallet * diff --git a/src/api/errors.ts b/src/api/errors.ts index 10aed002..e7297433 100644 --- a/src/api/errors.ts +++ b/src/api/errors.ts @@ -34,4 +34,5 @@ export enum ApiError { TOKEN_NOT_FOUND = 'token-not-found', FORBIDDEN = 'forbidden', UNAUTHORIZED = 'unauthorized', + DEVICE_NOT_FOUND = 'device-not-found', } diff --git a/src/api/pushRegister.ts b/src/api/pushRegister.ts new file mode 100644 index 00000000..4c3af8b0 --- /dev/null +++ b/src/api/pushRegister.ts @@ -0,0 +1,75 @@ +/** + * Copyright (c) Hathor Labs and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { APIGatewayProxyHandler } from 'aws-lambda'; +import { ApiError } from '@src/api/errors'; +import { closeDbAndGetError, warmupMiddleware, pushProviderRegexPattern } from '@src/api/utils'; +import { removeAllPushDevicesByDeviceId, registerPushDevice, existsWallet } from '@src/db'; +import { getDbConnection } from '@src/utils'; +import { walletIdProxyHandler } from '@src/commons'; +import middy from '@middy/core'; +import cors from '@middy/http-cors'; +import Joi, { ValidationError } from 'joi'; +import { PushRegister } from '@src/types'; + +const mysql = getDbConnection(); + +class PushRegisterInputValidator { + static readonly bodySchema = Joi.object({ + pushProvider: Joi.string().pattern(pushProviderRegexPattern()).required(), + deviceId: Joi.string().max(256).required(), + enablePush: Joi.boolean().default(false).optional(), + enableShowAmounts: Joi.boolean().default(false).optional(), + }); + + static validate(payload: unknown): { value: PushRegister, error: ValidationError } { + return PushRegisterInputValidator.bodySchema.validate(payload, { + abortEarly: false, // We want it to return all the errors not only the first + convert: true, // We need to convert as parameters are sent on the QueryString + }) as { value: PushRegister, error: ValidationError }; + } +} + +/* + * Register a device to receive push notification. + * + * This lambda is called by API Gateway on POST /push/register + */ +export const register: APIGatewayProxyHandler = middy(walletIdProxyHandler(async (walletId, event) => { + const { value: body, error } = PushRegisterInputValidator.validate(event.body); + + if (error) { + const details = error.details.map((err) => ({ + message: err.message, + path: err.path, + })); + + return closeDbAndGetError(mysql, ApiError.INVALID_PAYLOAD, { details }); + } + + const walletExists = await existsWallet(mysql, walletId); + if (!walletExists) { + return closeDbAndGetError(mysql, ApiError.WALLET_NOT_FOUND); + } + + await removeAllPushDevicesByDeviceId(mysql, body.deviceId); + + await registerPushDevice(mysql, { + walletId, + deviceId: body.deviceId, + pushProvider: body.pushProvider, + enablePush: body.enablePush, + enableShowAmounts: body.enableShowAmounts, + }); + + return { + statusCode: 200, + body: JSON.stringify({ success: true }), + }; +})) + .use(cors()) + .use(warmupMiddleware()); diff --git a/src/api/pushUnregister.ts b/src/api/pushUnregister.ts new file mode 100644 index 00000000..f24bb07f --- /dev/null +++ b/src/api/pushUnregister.ts @@ -0,0 +1,58 @@ +/** + * Copyright (c) Hathor Labs and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { APIGatewayProxyHandler } from 'aws-lambda'; +import { ApiError } from '@src/api/errors'; +import { closeDbAndGetError } from '@src/api/utils'; +import { unregisterPushDevice } from '@src/db'; +import { getDbConnection } from '@src/utils'; +import { walletIdProxyHandler } from '@src/commons'; +import middy from '@middy/core'; +import cors from '@middy/http-cors'; +import Joi, { ValidationError } from 'joi'; +import { PushDelete } from '@src/types'; + +const mysql = getDbConnection(); + +class PushUpdateUnregisterValidator { + static readonly bodySchema = Joi.object({ + deviceId: Joi.string().max(256).required(), + }); + + static validate(payload: unknown): { value: PushDelete, error: ValidationError } { + return PushUpdateUnregisterValidator.bodySchema.validate(payload, { + abortEarly: false, // We want it to return all the errors not only the first + convert: true, // We need to convert as parameters are sent on the QueryString + }) as { value: PushDelete, error: ValidationError }; + } +} + +/* + * Unregister a device to receive push notification. + * + * This lambda is called by API Gateway on POST /push/unregister + */ +export const unregister: APIGatewayProxyHandler = middy(walletIdProxyHandler(async (walletId, event) => { + const { value: body, error } = PushUpdateUnregisterValidator.validate(event.body); + + if (error) { + const details = error.details.map((err) => ({ + message: err.message, + path: err.path, + })); + + return closeDbAndGetError(mysql, ApiError.INVALID_PAYLOAD, { details }); + } + + await unregisterPushDevice(mysql, body.deviceId, walletId); + + return { + statusCode: 200, + body: JSON.stringify({ success: true }), + }; +})) + .use(cors()); diff --git a/src/api/pushUpdate.ts b/src/api/pushUpdate.ts new file mode 100644 index 00000000..af11b191 --- /dev/null +++ b/src/api/pushUpdate.ts @@ -0,0 +1,70 @@ +/** + * Copyright (c) Hathor Labs and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { APIGatewayProxyHandler } from 'aws-lambda'; +import { ApiError } from '@src/api/errors'; +import { closeDbAndGetError } from '@src/api/utils'; +import { existsPushDevice, updatePushDevice } from '@src/db'; +import { getDbConnection } from '@src/utils'; +import { walletIdProxyHandler } from '@src/commons'; +import middy from '@middy/core'; +import cors from '@middy/http-cors'; +import Joi, { ValidationError } from 'joi'; +import { PushUpdate } from '@src/types'; + +const mysql = getDbConnection(); + +class PushUpdateInputValidator { + static readonly bodySchema = Joi.object({ + deviceId: Joi.string().max(256).required(), + enablePush: Joi.boolean().default(false).optional(), + enableShowAmounts: Joi.boolean().default(false).optional(), + }); + + static validate(payload: unknown): { value: PushUpdate, error: ValidationError } { + return PushUpdateInputValidator.bodySchema.validate(payload, { + abortEarly: false, // We want it to return all the errors not only the first + convert: true, // We need to convert as parameters are sent on the QueryString + }) as { value: PushUpdate, error: ValidationError }; + } +} + +/* + * Update a device to receive push notification. + * + * This lambda is called by API Gateway on POST /push/register + */ +export const update: APIGatewayProxyHandler = middy(walletIdProxyHandler(async (walletId, event) => { + const { value: body, error } = PushUpdateInputValidator.validate(event.body); + + if (error) { + const details = error.details.map((err) => ({ + message: err.message, + path: err.path, + })); + + return closeDbAndGetError(mysql, ApiError.INVALID_PAYLOAD, { details }); + } + + const deviceExists = await existsPushDevice(mysql, body.deviceId, walletId); + if (!deviceExists) { + return closeDbAndGetError(mysql, ApiError.DEVICE_NOT_FOUND); + } + + await updatePushDevice(mysql, { + walletId, + deviceId: body.deviceId, + enablePush: body.enablePush, + enableShowAmounts: body.enableShowAmounts, + }); + + return { + statusCode: 200, + body: JSON.stringify({ success: true }), + }; +})) + .use(cors()); diff --git a/src/api/utils.ts b/src/api/utils.ts index 05d8b76a..c754d543 100644 --- a/src/api/utils.ts +++ b/src/api/utils.ts @@ -10,7 +10,7 @@ import { ServerlessMysql } from 'serverless-mysql'; import middy from '@middy/core'; import { ApiError } from '@src/api/errors'; -import { StringMap } from '@src/types'; +import { PushProvider, StringMap } from '@src/types'; import { closeDbConnection } from '@src/utils'; export const STATUS_CODE_TABLE = { @@ -42,6 +42,7 @@ export const STATUS_CODE_TABLE = { [ApiError.ADDRESS_NOT_IN_WALLET]: 400, [ApiError.WALLET_MAX_RETRIES]: 400, [ApiError.TOKEN_NOT_FOUND]: 404, + [ApiError.DEVICE_NOT_FOUND]: 404, }; /** @@ -84,3 +85,9 @@ export const warmupMiddleware = (): middy.MiddlewareObj { + const entries = Object.values(PushProvider); + const options = entries.join('|'); + return new RegExp(`^(?:${options})$`); +}; diff --git a/src/db/index.ts b/src/db/index.ts index bd16e5f1..8cc15e5c 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -1191,16 +1191,23 @@ export const updateWalletLockedBalance = async ( * * @param mysql - Database connection * @param walletId - Wallet id + * @param filterAddresses - Optional parameter to filter addresses from the list * @returns A list of addresses and their info (index and transactions) */ -export const getWalletAddresses = async (mysql: ServerlessMysql, walletId: string): Promise => { +export const getWalletAddresses = async (mysql: ServerlessMysql, walletId: string, filterAddresses?: string[]): Promise => { const addresses: AddressInfo[] = []; + const subQuery = filterAddresses ? ` + AND \`address\` IN (?) + ` : ''; + const results: DbSelectResult = await mysql.query(` SELECT * FROM \`address\` WHERE \`wallet_id\` = ? + ${subQuery} ORDER BY \`index\` - ASC`, walletId); + ASC`, [walletId, filterAddresses]); + for (const result of results) { const address = { address: result.address as string, @@ -2664,3 +2671,146 @@ export const incrementTokensTxCount = async ( WHERE \`id\` IN (?) `, [tokenList]); }; + +/** + * Verify the existence of a device registered for a given wallet. + * + * @param mysql - Database connection + * @param deviceId - The device to verify existence + * @param walletId - The wallet linked to device + */ +export const existsPushDevice = async ( + mysql: ServerlessMysql, + deviceId: string, + walletId: string, +) : Promise => { + const [{ count }] = await mysql.query( + ` + SELECT COUNT(1) as \`count\` + FROM \`push_devices\` pd + WHERE device_id = ? + AND wallet_id = ?`, + [deviceId, walletId], + ) as unknown as Array<{count}>; + + return count > 0; +}; + +/** + * Register a device to a wallet for push notification. + * + * @param mysql - Database connection + * @param input - Input of push device register + */ +export const registerPushDevice = async ( + mysql: ServerlessMysql, + input: { + deviceId: string, + walletId: string, + pushProvider: string, + enablePush: boolean, + enableShowAmounts: boolean, + }, +) : Promise => { + await mysql.query( + ` + INSERT + INTO \`push_devices\` ( + device_id + , wallet_id + , push_provider + , enable_push + , enable_show_amounts) + VALUES (?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + updated_at = CURRENT_TIMESTAMP`, + [input.deviceId, input.walletId, input.pushProvider, input.enablePush, input.enableShowAmounts], + ); +}; + +/** + * Remove any record of push notification device given a device ID. + * + * @param mysql - Database connection + * @param deviceId - The device ID + */ +export const removeAllPushDevicesByDeviceId = async (mysql: ServerlessMysql, deviceId: string): Promise => { + await mysql.query( + ` + DELETE + FROM \`push_devices\` + WHERE + device_id = ? + `, + [deviceId], + ); +}; + +/** + * Update existing push device given a wallet. + * + * @param mysql - Database connection + * @param input - Input of push device register + */ +export const updatePushDevice = async ( + mysql: ServerlessMysql, + input: { + deviceId: string, + walletId: string, + enablePush: boolean, + enableShowAmounts: boolean, + }, +) : Promise => { + await mysql.query( + ` + UPDATE \`push_devices\` + SET enable_push = ? + , enable_show_amounts = ? + WHERE device_id = ? + AND wallet_id = ?`, + [input.enablePush, input.enableShowAmounts, input.deviceId, input.walletId], + ); +}; + +/** + * Unregister push device for a given wallet. + * + * @param mysql - Database connection + * @param deviceId - The device to unregister + * @param walletId - The wallet linked to device + */ +export const unregisterPushDevice = async ( + mysql: ServerlessMysql, + deviceId: string, + walletId: string, +) : Promise => { + await mysql.query( + ` + DELETE + FROM \`push_devices\` + WHERE device_id = ? + AND wallet_id = ?`, + [deviceId, walletId], + ); +}; + +/** +* Verify the existence of a wallet by its ID. +* +* @param mysql - Database connection +* @param walletId - The wallet linked to device +*/ +export const existsWallet = async ( + mysql: ServerlessMysql, + walletId: string, +) : Promise => { + const [{ count }] = (await mysql.query( + ` + SELECT COUNT(1) as \`count\` + FROM \`wallet\` pd + WHERE id = ?`, + [walletId], + )) as unknown as Array<{ count }>; + + return count > 0; +}; diff --git a/src/types.ts b/src/types.ts index 65ef23d7..df627519 100644 --- a/src/types.ts +++ b/src/types.ts @@ -672,3 +672,25 @@ export interface Miner { lastBlock: string; count: number; } + +export enum PushProvider { + IOS = 'ios', + ANDROID = 'android' +} + +export interface PushRegister { + pushProvider: PushProvider, + deviceId: string, + enablePush?: boolean, + enableShowAmounts?: boolean +} + +export interface PushUpdate { + deviceId: string, + enablePush?: boolean, + enableShowAmounts?: boolean +} + +export interface PushDelete { + deviceId: string, +} diff --git a/tests/api.test.ts b/tests/api.test.ts index 3cece9ce..af454316 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -1,6 +1,6 @@ import { APIGatewayProxyHandler, APIGatewayProxyResult } from 'aws-lambda'; -import { get as addressesGet } from '@src/api/addresses'; +import { get as addressesGet, checkMine } from '@src/api/addresses'; import { get as newAddressesGet } from '@src/api/newAddresses'; import { get as balancesGet } from '@src/api/balances'; import { get as txHistoryGet } from '@src/api/txhistory'; @@ -153,6 +153,99 @@ test('GET /addresses', async () => { expect(returnBody.addresses).toContainEqual({ address: ADDRESSES[1], index: 1, transactions: 0 }); }); +test('GET /addresses/check_mine', async () => { + expect.hasAssertions(); + + await addToWalletTable(mysql, [{ + id: 'my-wallet', + xpubkey: 'xpubkey', + authXpubkey: 'auth_xpubkey', + status: 'ready', + maxGap: 5, + createdAt: 10000, + readyAt: 10001, + }]); + + await addToAddressTable(mysql, [ + { address: ADDRESSES[0], index: 0, walletId: 'my-wallet', transactions: 0 }, + { address: ADDRESSES[1], index: 1, walletId: 'my-wallet', transactions: 0 }, + { address: ADDRESSES[2], index: 3, walletId: 'my-wallet', transactions: 0 }, + { address: ADDRESSES[3], index: 4, walletId: 'my-wallet', transactions: 0 }, + ]); + + // missing wallet + await _testMissingWallet(newAddressesGet, 'some-wallet'); + + // wallet not ready + await _testWalletNotReady(checkMine); + + await _testCORSHeaders(checkMine, 'my-wallet', {}); + + // success case + + let event = makeGatewayEventWithAuthorizer('my-wallet', {}, JSON.stringify({ + addresses: [ + ADDRESSES[0], + ADDRESSES[1], + ADDRESSES[8], + ], + })); + let result = await checkMine(event, null, null) as APIGatewayProxyResult; + let returnBody = JSON.parse(result.body as string); + + expect(result.statusCode).toBe(200); + expect(returnBody.success).toBe(true); + expect(Object.keys(returnBody.addresses)).toHaveLength(3); + expect(returnBody.addresses).toStrictEqual({ + [ADDRESSES[0]]: true, + [ADDRESSES[1]]: true, + [ADDRESSES[8]]: false, + }); + + // validation error, invalid json + + event = makeGatewayEventWithAuthorizer('my-wallet', {}, 'invalid-json'); + result = await checkMine(event, null, null) as APIGatewayProxyResult; + returnBody = JSON.parse(result.body as string); + + expect(result.statusCode).toBe(400); + expect(returnBody.success).toBe(false); + expect(returnBody.error).toBe(ApiError.INVALID_PAYLOAD); + expect(returnBody.details).toHaveLength(1); + expect(returnBody.details[0].message).toStrictEqual('"value" must be of type object'); + + // validation error, addresses shouldn't be empty + + event = makeGatewayEventWithAuthorizer('my-wallet', {}, JSON.stringify({ + addresses: [], + })); + result = await checkMine(event, null, null) as APIGatewayProxyResult; + returnBody = JSON.parse(result.body as string); + + expect(result.statusCode).toBe(400); + expect(returnBody.success).toBe(false); + expect(returnBody.error).toBe(ApiError.INVALID_PAYLOAD); + expect(returnBody.details).toHaveLength(1); + expect(returnBody.details[0].message).toStrictEqual('"addresses" must contain at least 1 items'); + + // validation error, invalid address + + event = makeGatewayEventWithAuthorizer('my-wallet', {}, JSON.stringify({ + addresses: [ + 'invalid-address', + ], + })); + result = await checkMine(event, null, null) as APIGatewayProxyResult; + returnBody = JSON.parse(result.body as string); + + expect(result.statusCode).toBe(400); + expect(returnBody.success).toBe(false); + expect(returnBody.error).toBe(ApiError.INVALID_PAYLOAD); + expect(returnBody.details).toHaveLength(2); + expect(returnBody.details[0].message).toStrictEqual('"addresses[0]" with value "invalid-address" fails to match the required pattern: /^[A-HJ-NP-Za-km-z1-9]*$/'); + expect(returnBody.details[1].message).toStrictEqual('"addresses[0]" length must be at least 34 characters long'); +}); + test('GET /addresses/new', async () => { expect.hasAssertions(); diff --git a/tests/db.test.ts b/tests/db.test.ts index 1d57cf19..35a94663 100644 --- a/tests/db.test.ts +++ b/tests/db.test.ts @@ -64,6 +64,12 @@ import { getAvailableAuthorities, getAffectedAddressTxCountFromTxList, incrementTokensTxCount, + registerPushDevice, + existsPushDevice, + updatePushDevice, + unregisterPushDevice, + removeAllPushDevicesByDeviceId, + existsWallet, } from '@src/db'; import { beginTransaction, @@ -111,6 +117,7 @@ import { createInput, countTxOutputTable, checkTokenTable, + checkPushDevicesTable, } from '@tests/utils'; import { AddressTxHistoryTableEntry } from '@tests/types'; @@ -849,6 +856,18 @@ test('getWalletAddresses', async () => { expect(i).toBe(address.index); expect(address.address).toBe(ADDRESSES[i]); } + + // if we pass the filterAddresses optional parameter, we should receive just these + const filteredReturnedAddresses = await getWalletAddresses(mysql, walletId, [ + ADDRESSES[0], + ADDRESSES[2], + ADDRESSES[3], + ]); + + expect(filteredReturnedAddresses).toHaveLength(3); + expect(filteredReturnedAddresses[0].address).toBe(ADDRESSES[0]); + expect(filteredReturnedAddresses[1].address).toBe(ADDRESSES[2]); + expect(filteredReturnedAddresses[2].address).toBe(ADDRESSES[3]); }); test('getWalletAddressDetail', async () => { @@ -2175,3 +2194,300 @@ test('incrementTokensTxCount', async () => { transactions: htr.transactions + 1, }])).resolves.toBe(true); }); + +test('existsPushDevice', async () => { + expect.hasAssertions(); + + const walletId = 'wallet1'; + const deviceId = 'device1'; + const pushProvider = 'android'; + const enablePush = true; + const enableShowAmounts = false; + + await createWallet(mysql, walletId, XPUBKEY, AUTH_XPUBKEY, 5); + + let existsResult = await existsPushDevice(mysql, deviceId, walletId); + + // there is no device registered to a wallet at this stage + expect(existsResult).toBe(false); + + // register the device to a wallet + await registerPushDevice(mysql, { + walletId, + deviceId, + pushProvider, + enablePush, + enableShowAmounts, + }); + + existsResult = await existsPushDevice(mysql, deviceId, walletId); + + // there is a device registered to a wallet + expect(existsResult).toBe(true); +}); + +test('registerPushDevice', async () => { + expect.hasAssertions(); + + const walletId = 'wallet1'; + const deviceId = 'device1'; + const pushProvider = 'android'; + const enablePush = true; + const enableShowAmounts = false; + + await createWallet(mysql, walletId, XPUBKEY, AUTH_XPUBKEY, 5); + + await registerPushDevice(mysql, { + walletId, + deviceId, + pushProvider, + enablePush, + enableShowAmounts, + }); + + await expect(checkPushDevicesTable(mysql, 1, { + walletId, + deviceId, + pushProvider, + enablePush, + enableShowAmounts, + })).resolves.toBe(true); +}); + +describe('updatePushDevice', () => { + it('should update pushDevice when register exists', async () => { + expect.hasAssertions(); + + const walletId = 'wallet1'; + const deviceId = 'device1'; + const pushProvider = 'android'; + const enableShowAmounts = false; + + await createWallet(mysql, walletId, XPUBKEY, AUTH_XPUBKEY, 5); + + await registerPushDevice(mysql, { + walletId, + deviceId, + pushProvider, + enablePush: false, + enableShowAmounts, + }); + + await updatePushDevice(mysql, { + walletId, + deviceId, + enablePush: true, + enableShowAmounts, + }); + + await expect(checkPushDevicesTable(mysql, 1, { + walletId, + deviceId, + pushProvider, + enablePush: true, + enableShowAmounts, + })).resolves.toBe(true); + }); + + it('should update pushDevice when more than 1 wallet is related', async () => { + expect.hasAssertions(); + + const deviceToUpdate = 'device1'; + const deviceToKeep = 'device2'; + const walletId = 'wallet1'; + const pushProvider = 'android'; + const enableShowAmounts = false; + + await createWallet(mysql, walletId, XPUBKEY, AUTH_XPUBKEY, 5); + + const devicesToAdd = [deviceToUpdate, deviceToKeep]; + devicesToAdd.forEach(async (eachDevice) => { + await registerPushDevice(mysql, { + walletId, + deviceId: eachDevice, + pushProvider, + enablePush: false, + enableShowAmounts, + }); + }); + await expect(checkPushDevicesTable(mysql, devicesToAdd.length)).resolves.toBe(true); + + await updatePushDevice(mysql, { + walletId, + deviceId: deviceToUpdate, + enablePush: true, + enableShowAmounts, + }); + + await expect(checkPushDevicesTable(mysql, 1, { + walletId, + deviceId: deviceToUpdate, + pushProvider, + enablePush: true, + enableShowAmounts, + })).resolves.toBe(true); + + await expect(checkPushDevicesTable(mysql, 1, { + walletId, + deviceId: deviceToKeep, + pushProvider, + enablePush: false, + enableShowAmounts, + })).resolves.toBe(true); + }); + + it('should run update successfuly even when there is no device registered', async () => { + expect.hasAssertions(); + + const deviceId = 'device1'; + const walletId = 'wallet1'; + const enablePush = true; + const enableShowAmounts = false; + + await createWallet(mysql, walletId, XPUBKEY, AUTH_XPUBKEY, 5); + + await updatePushDevice(mysql, { + walletId, + deviceId, + enablePush, + enableShowAmounts, + }); + + await expect(checkPushDevicesTable(mysql, 0)).resolves.toBe(true); + }); +}); + +test('removeAllPushDeviceByDeviceId', async () => { + expect.hasAssertions(); + + const walletId = 'wallet1'; + // eslint-disable-next-line @typescript-eslint/naming-convention, camelcase + const deviceId_1 = 'device_1'; + // eslint-disable-next-line @typescript-eslint/naming-convention, camelcase + const deviceId_2 = 'device_2'; + const pushProvider = 'android'; + const enablePush = true; + const enableShowAmounts = false; + + // NOTE: Because deviceId is a primary key in push_devices table + // it is not possible to register more than one device with the same deviceId. + await createWallet(mysql, walletId, XPUBKEY, AUTH_XPUBKEY, 5); + await registerPushDevice(mysql, { + walletId, + deviceId: deviceId_1, + pushProvider, + enablePush, + enableShowAmounts, + }); + await registerPushDevice(mysql, { + walletId, + deviceId: deviceId_2, + pushProvider, + enablePush, + enableShowAmounts, + }); + await expect(checkPushDevicesTable(mysql, 2)).resolves.toBe(true); + + // remove all push device registered + await removeAllPushDevicesByDeviceId(mysql, deviceId_1); + await expect(checkPushDevicesTable(mysql, 1)).resolves.toBe(true); +}); + +test('existsWallet', async () => { + expect.hasAssertions(); + + // wallet do not exists yet + const walletId = 'wallet1'; + let exists = await existsWallet(mysql, walletId); + + expect(exists).toBe(false); + + // wallet exists + await createWallet(mysql, walletId, XPUBKEY, AUTH_XPUBKEY, 5); + exists = await existsWallet(mysql, walletId); + + expect(exists).toBe(true); +}); + +describe('unregisterPushDevice', () => { + it('should unregister device', async () => { + expect.hasAssertions(); + + const walletId = 'wallet1'; + const deviceId = 'device1'; + const pushProvider = 'android'; + const enablePush = false; + const enableShowAmounts = false; + + await createWallet(mysql, walletId, XPUBKEY, AUTH_XPUBKEY, 5); + + await registerPushDevice(mysql, { + walletId, + deviceId, + pushProvider, + enablePush, + enableShowAmounts, + }); + + await expect(checkPushDevicesTable(mysql, 1, { + walletId, + deviceId, + pushProvider, + enablePush, + enableShowAmounts, + })).resolves.toBe(true); + + await unregisterPushDevice(mysql, deviceId, walletId); + + await expect(checkPushDevicesTable(mysql, 0)).resolves.toBe(true); + }); + + it('should unregister the right device in face of many', async () => { + expect.hasAssertions(); + + const pushProvider = 'android'; + const enablePush = false; + const enableShowAmounts = false; + const deviceToUnregister = 'device1'; + const deviceToRemain = 'device2'; + const devicesToAdd = [deviceToUnregister, deviceToRemain]; + + const walletId = 'wallet1'; + await createWallet(mysql, walletId, XPUBKEY, AUTH_XPUBKEY, 5); + + devicesToAdd.forEach(async (eachDevice) => { + await registerPushDevice(mysql, { + walletId, + deviceId: eachDevice, + pushProvider, + enablePush, + enableShowAmounts, + }); + }); + await expect(checkPushDevicesTable(mysql, 2)).resolves.toBe(true); + + await unregisterPushDevice(mysql, deviceToUnregister, walletId); + + await expect(checkPushDevicesTable(mysql, 1)).resolves.toBe(true); + await expect(checkPushDevicesTable(mysql, 1, { + walletId, + deviceId: deviceToRemain, + pushProvider, + enablePush, + enableShowAmounts, + })).resolves.toBe(true); + }); + + it('should succeed even when no device exists', async () => { + expect.hasAssertions(); + + const deviceId = 'device-not-exists'; + const walletId = 'wallet-not-exist'; + + await expect(checkPushDevicesTable(mysql, 0)).resolves.toBe(true); + + await unregisterPushDevice(mysql, deviceId, walletId); + + await expect(checkPushDevicesTable(mysql, 0)).resolves.toBe(true); + }); +}); diff --git a/tests/pushRegister.test.ts b/tests/pushRegister.test.ts new file mode 100644 index 00000000..d72e24e5 --- /dev/null +++ b/tests/pushRegister.test.ts @@ -0,0 +1,237 @@ +import { + register, +} from '@src/api/pushRegister'; +import { closeDbConnection, getDbConnection } from '@src/utils'; +import { + addToWalletTable, + makeGatewayEventWithAuthorizer, + cleanDatabase, + checkPushDevicesTable, +} from '@tests/utils'; +import { ApiError } from '@src/api/errors'; +import { APIGatewayProxyResult } from 'aws-lambda'; + +const mysql = getDbConnection(); + +beforeEach(async () => { + await cleanDatabase(mysql); +}); + +afterAll(async () => { + await closeDbConnection(mysql); +}); + +test('register a device for push notification', async () => { + expect.hasAssertions(); + + await addToWalletTable(mysql, [{ + id: 'my-wallet', + xpubkey: 'xpubkey', + authXpubkey: 'auth_xpubkey', + status: 'ready', + maxGap: 5, + createdAt: 10000, + readyAt: 10001, + }]); + + const event = makeGatewayEventWithAuthorizer('my-wallet', null, { + deviceId: 'device1', + pushProvider: 'android', + enablePush: true, + enableShowAmounts: false, + }); + + const result = await register(event, null, null) as APIGatewayProxyResult; + const returnBody = JSON.parse(result.body as string); + + expect(result.statusCode).toStrictEqual(200); + expect(returnBody.success).toStrictEqual(true); +}); + +describe('statusCode:200', () => { + it('should have default value for enablePush', async () => { + expect.hasAssertions(); + + await addToWalletTable(mysql, [{ + id: 'my-wallet', + xpubkey: 'xpubkey', + authXpubkey: 'auth_xpubkey', + status: 'ready', + maxGap: 5, + createdAt: 10000, + readyAt: 10001, + }]); + + const payloadWithoutEnablePush = { + deviceId: 'device1', + pushProvider: 'android', + enableShowAmounts: false, + }; + const event = makeGatewayEventWithAuthorizer('my-wallet', null, payloadWithoutEnablePush); + + const result = await register(event, null, null) as APIGatewayProxyResult; + const returnBody = JSON.parse(result.body as string); + + expect(result.statusCode).toStrictEqual(200); + expect(returnBody.success).toStrictEqual(true); + await expect( + checkPushDevicesTable(mysql, 1, { + walletId: 'my-wallet', + deviceId: 'device1', + pushProvider: 'android', + enablePush: false, + enableShowAmounts: false, + }), + ).resolves.toBe(true); + }); + + it('should have default value for enableShowAmounts', async () => { + expect.hasAssertions(); + + await addToWalletTable(mysql, [{ + id: 'my-wallet', + xpubkey: 'xpubkey', + authXpubkey: 'auth_xpubkey', + status: 'ready', + maxGap: 5, + createdAt: 10000, + readyAt: 10001, + }]); + + const payloadWithoutEnableShowAmounts = { + deviceId: 'device1', + pushProvider: 'android', + enablePush: true, + }; + const event = makeGatewayEventWithAuthorizer('my-wallet', null, payloadWithoutEnableShowAmounts); + + const result = await register(event, null, null) as APIGatewayProxyResult; + const returnBody = JSON.parse(result.body as string); + + expect(result.statusCode).toStrictEqual(200); + expect(returnBody.success).toStrictEqual(true); + await expect( + checkPushDevicesTable(mysql, 1, { + walletId: 'my-wallet', + deviceId: 'device1', + pushProvider: 'android', + enablePush: true, + enableShowAmounts: false, + }), + ).resolves.toBe(true); + }); + + it('should register even if alredy exists (idempotency proof)', async () => { + expect.hasAssertions(); + + await addToWalletTable(mysql, [{ + id: 'my-wallet', + xpubkey: 'xpubkey', + authXpubkey: 'auth_xpubkey', + status: 'ready', + maxGap: 5, + createdAt: 10000, + readyAt: 10001, + }]); + + const payload = { + deviceId: 'device1', + pushProvider: 'android', + enablePush: true, + enableShowAmounts: false, + }; + const event = makeGatewayEventWithAuthorizer('my-wallet', null, payload); + + let result = await register(event, null, null) as APIGatewayProxyResult; + let returnBody = JSON.parse(result.body as string); + + expect(result.statusCode).toStrictEqual(200); + expect(returnBody.success).toStrictEqual(true); + + result = await register(event, null, null) as APIGatewayProxyResult; + returnBody = JSON.parse(result.body as string); + + expect(result.statusCode).toStrictEqual(200); + expect(returnBody.success).toStrictEqual(true); + }); +}); + +describe('statusCode:400', () => { + it('should validate provider', async () => { + expect.hasAssertions(); + const pushProvider = 'not-supported-provider'; + + await addToWalletTable(mysql, [{ + id: 'my-wallet', + xpubkey: 'xpubkey', + authXpubkey: 'auth_xpubkey', + status: 'ready', + maxGap: 5, + createdAt: 10000, + readyAt: 10001, + }]); + + const event = makeGatewayEventWithAuthorizer('my-wallet', null, { + deviceId: 'device1', + pushProvider, + enablePush: true, + enableShowAmounts: false, + }); + + const result = await register(event, null, null) as APIGatewayProxyResult; + const returnBody = JSON.parse(result.body as string); + + expect(result.statusCode).toStrictEqual(400); + expect(returnBody.success).toStrictEqual(false); + expect(returnBody.error).toStrictEqual(ApiError.INVALID_PAYLOAD); + }); + + it('should validate deviceId', async () => { + expect.hasAssertions(); + const deviceId = (new Array(257)).fill('x').join(''); + + await addToWalletTable(mysql, [{ + id: 'my-wallet', + xpubkey: 'xpubkey', + authXpubkey: 'auth_xpubkey', + status: 'ready', + maxGap: 5, + createdAt: 10000, + readyAt: 10001, + }]); + + const event = makeGatewayEventWithAuthorizer('my-wallet', null, { + deviceId, + pushProvider: 'android', + enablePush: true, + enableShowAmounts: false, + }); + + const result = await register(event, null, null) as APIGatewayProxyResult; + const returnBody = JSON.parse(result.body as string); + + expect(result.statusCode).toStrictEqual(400); + expect(returnBody.success).toStrictEqual(false); + expect(returnBody.error).toStrictEqual(ApiError.INVALID_PAYLOAD); + }); +}); + +describe('statusCode:404', () => { + it('should validate wallet existence', async () => { + expect.hasAssertions(); + + const event = makeGatewayEventWithAuthorizer('my-wallet', null, { + deviceId: 'device1', + pushProvider: 'android', + enablePush: true, + enableShowAmounts: false, + }); + + const result = await register(event, null, null) as APIGatewayProxyResult; + const returnBody = JSON.parse(result.body as string); + + expect(result.statusCode).toStrictEqual(404); + expect(returnBody.success).toStrictEqual(false); + expect(returnBody.error).toStrictEqual(ApiError.WALLET_NOT_FOUND); + }); +}); diff --git a/tests/pushUnregister.test.ts b/tests/pushUnregister.test.ts new file mode 100644 index 00000000..d2b8c6bb --- /dev/null +++ b/tests/pushUnregister.test.ts @@ -0,0 +1,238 @@ +import { + unregister, +} from '@src/api/pushUnregister'; +import { closeDbConnection, getDbConnection } from '@src/utils'; +import { registerPushDevice, unregisterPushDevice } from '@src/db'; +import { + addToWalletTable, + makeGatewayEventWithAuthorizer, + cleanDatabase, + checkPushDevicesTable, +} from '@tests/utils'; +import { APIGatewayProxyResult } from 'aws-lambda'; +import { ApiError } from '@src/api/errors'; + +const mysql = getDbConnection(); + +beforeEach(async () => { + await cleanDatabase(mysql); +}); + +afterAll(async () => { + await closeDbConnection(mysql); +}); + +test('unregister push device given a wallet', async () => { + expect.hasAssertions(); + + // register a wallet + const walletId = 'wallet1'; + await addToWalletTable(mysql, [{ + id: walletId, + xpubkey: 'xpubkey', + authXpubkey: 'auth_xpubkey', + status: 'ready', + maxGap: 5, + createdAt: 10000, + readyAt: 10001, + }]); + + // register the device to a wallet + const deviceId = 'device1'; + const pushProvider = 'android'; + const enablePush = true; + const enableShowAmounts = false; + await registerPushDevice(mysql, { + walletId, + deviceId, + pushProvider, + enablePush, + enableShowAmounts, + }); + + await expect(checkPushDevicesTable(mysql, 1, { + walletId, + deviceId, + pushProvider, + enablePush: true, + enableShowAmounts, + })).resolves.toBe(true); + + const event = makeGatewayEventWithAuthorizer(walletId, null, { + deviceId, + }); + + const result = await unregister(event, null, null) as APIGatewayProxyResult; + const returnBody = JSON.parse(result.body as string); + + expect(result.statusCode).toStrictEqual(200); + expect(returnBody.success).toStrictEqual(true); + + await expect(checkPushDevicesTable(mysql, 0, { + walletId, + deviceId, + pushProvider, + enablePush: true, + enableShowAmounts, + })).resolves.toBe(true); +}); + +describe('statusCode:200', () => { + it('should unregister the right device in face of many', async () => { + expect.hasAssertions(); + + // register wallets + const walletId = 'wallet1'; + await addToWalletTable(mysql, [{ + id: walletId, + xpubkey: 'xpubkey', + authXpubkey: 'auth_xpubkey', + status: 'ready', + maxGap: 5, + createdAt: 10000, + readyAt: 10001, + }]); + + // register the device to a wallets + const pushProvider = 'android'; + const enablePush = true; + const enableShowAmounts = false; + + const deviceToUnregister = 'device1'; + const deviceToRemain = 'device2'; + const devicesToAdd = [deviceToUnregister, deviceToRemain]; + devicesToAdd.forEach(async (eachDevice) => { + await registerPushDevice(mysql, { + walletId, + deviceId: eachDevice, + pushProvider, + enablePush, + enableShowAmounts, + }); + }); + await expect(checkPushDevicesTable(mysql, 2)).resolves.toBe(true); + + const event = makeGatewayEventWithAuthorizer(walletId, null, { + deviceId: deviceToUnregister, + }); + + const result = await unregister(event, null, null) as APIGatewayProxyResult; + const returnBody = JSON.parse(result.body as string); + + expect(result.statusCode).toStrictEqual(200); + expect(returnBody.success).toStrictEqual(true); + + await expect(checkPushDevicesTable(mysql, 1, { + walletId, + deviceId: deviceToRemain, + pushProvider, + enablePush: true, + enableShowAmounts, + })).resolves.toBe(true); + }); + + it('should succeed even when there is no device registered', async () => { + expect.hasAssertions(); + + const walletId = 'wallet1'; + const deviceId = 'device1'; + + const event = makeGatewayEventWithAuthorizer(walletId, null, { + deviceId, + }); + + const result = await unregister(event, null, null) as APIGatewayProxyResult; + const returnBody = JSON.parse(result.body as string); + + expect(result.statusCode).toStrictEqual(200); + expect(returnBody.success).toStrictEqual(true); + + await expect(checkPushDevicesTable(mysql, 0)).resolves.toBe(true); + }); + + it('should succeed even when device id not exists', async () => { + expect.hasAssertions(); + + // register a wallet + const walletId = 'wallet1'; + await addToWalletTable(mysql, [{ + id: walletId, + xpubkey: 'xpubkey', + authXpubkey: 'auth_xpubkey', + status: 'ready', + maxGap: 5, + createdAt: 10000, + readyAt: 10001, + }]); + + // register the device to a wallet + const deviceId = 'device1'; + const pushProvider = 'android'; + const enablePush = true; + const enableShowAmounts = false; + await registerPushDevice(mysql, { + walletId, + deviceId, + pushProvider, + enablePush, + enableShowAmounts, + }); + + await expect(checkPushDevicesTable(mysql, 1, { + walletId, + deviceId, + pushProvider, + enablePush: true, + enableShowAmounts, + })).resolves.toBe(true); + + const event = makeGatewayEventWithAuthorizer(walletId, null, { + deviceId: 'device-not-exists', + }); + + const result = await unregister(event, null, null) as APIGatewayProxyResult; + const returnBody = JSON.parse(result.body as string); + + expect(result.statusCode).toStrictEqual(200); + expect(returnBody.success).toStrictEqual(true); + + await expect(checkPushDevicesTable(mysql, 1, { + walletId, + deviceId, + pushProvider, + enablePush, + enableShowAmounts, + })).resolves.toBe(true); + }); +}); + +describe('statusCode:400', () => { + it('should validate deviceId', async () => { + expect.hasAssertions(); + const deviceId = (new Array(257)).fill('x').join(''); + + await addToWalletTable(mysql, [{ + id: 'my-wallet', + xpubkey: 'xpubkey', + authXpubkey: 'auth_xpubkey', + status: 'ready', + maxGap: 5, + createdAt: 10000, + readyAt: 10001, + }]); + + const event = makeGatewayEventWithAuthorizer('my-wallet', null, { + deviceId, + pushProvider: 'android', + enablePush: true, + enableShowAmounts: false, + }); + + const result = await unregister(event, null, null) as APIGatewayProxyResult; + const returnBody = JSON.parse(result.body as string); + + expect(result.statusCode).toStrictEqual(400); + expect(returnBody.success).toStrictEqual(false); + expect(returnBody.error).toStrictEqual(ApiError.INVALID_PAYLOAD); + }); +}); diff --git a/tests/pushUpdate.test.ts b/tests/pushUpdate.test.ts new file mode 100644 index 00000000..4603ace7 --- /dev/null +++ b/tests/pushUpdate.test.ts @@ -0,0 +1,232 @@ +import { + update, +} from '@src/api/pushUpdate'; +import { closeDbConnection, getDbConnection } from '@src/utils'; +import { registerPushDevice } from '@src/db'; +import { + addToWalletTable, + makeGatewayEventWithAuthorizer, + cleanDatabase, + checkPushDevicesTable, +} from '@tests/utils'; +import { ApiError } from '@src/api/errors'; +import { APIGatewayProxyResult } from 'aws-lambda'; + +const mysql = getDbConnection(); + +beforeEach(async () => { + await cleanDatabase(mysql); +}); + +afterAll(async () => { + await closeDbConnection(mysql); +}); + +test('update push device given a wallet', async () => { + expect.hasAssertions(); + + // register a wallet + const walletId = 'wallet1'; + await addToWalletTable(mysql, [{ + id: walletId, + xpubkey: 'xpubkey', + authXpubkey: 'auth_xpubkey', + status: 'ready', + maxGap: 5, + createdAt: 10000, + readyAt: 10001, + }]); + + // register the device to a wallet + const deviceId = 'device1'; + const pushProvider = 'android'; + const enablePush = false; // disabled push notification + const enableShowAmounts = false; + await registerPushDevice(mysql, { + walletId, + deviceId, + pushProvider, + enablePush, + enableShowAmounts, + }); + + const event = makeGatewayEventWithAuthorizer(walletId, null, { + deviceId, + enablePush: true, // enables push notification + enableShowAmounts: false, + }); + + const result = await update(event, null, null) as APIGatewayProxyResult; + const returnBody = JSON.parse(result.body as string); + + expect(result.statusCode).toStrictEqual(200); + expect(returnBody.success).toStrictEqual(true); + + await expect(checkPushDevicesTable(mysql, 1, { + walletId, + deviceId, + pushProvider, + enablePush: true, + enableShowAmounts, + })).resolves.toBe(true); +}); + +describe('statusCode:200', () => { + it('should have default value for enablePush', async () => { + expect.hasAssertions(); + + // register a wallet + const walletId = 'wallet1'; + await addToWalletTable(mysql, [{ + id: walletId, + xpubkey: 'xpubkey', + authXpubkey: 'auth_xpubkey', + status: 'ready', + maxGap: 5, + createdAt: 10000, + readyAt: 10001, + }]); + + // register the device to a wallet + const deviceId = 'device1'; + const pushProvider = 'android'; + const enablePush = true; // start enabled + const enableShowAmounts = false; + await registerPushDevice(mysql, { + walletId, + deviceId, + pushProvider, + enablePush, + enableShowAmounts, + }); + + // enablePush should be disabled by default + const event = makeGatewayEventWithAuthorizer(walletId, null, { + deviceId, + enableShowAmounts: false, + }); + + const result = await update(event, null, null) as APIGatewayProxyResult; + const returnBody = JSON.parse(result.body as string); + + expect(result.statusCode).toStrictEqual(200); + expect(returnBody.success).toStrictEqual(true); + + await expect(checkPushDevicesTable(mysql, 1, { + walletId, + deviceId, + pushProvider, + enablePush: false, // default value + enableShowAmounts, + })).resolves.toBe(true); + }); + + it('should have default value for enableShowAmounts', async () => { + expect.hasAssertions(); + + // register a wallet + const walletId = 'wallet1'; + await addToWalletTable(mysql, [{ + id: walletId, + xpubkey: 'xpubkey', + authXpubkey: 'auth_xpubkey', + status: 'ready', + maxGap: 5, + createdAt: 10000, + readyAt: 10001, + }]); + + // register the device to a wallet + const deviceId = 'device1'; + const pushProvider = 'android'; + const enablePush = false; + const enableShowAmounts = true; // start enabled + await registerPushDevice(mysql, { + walletId, + deviceId, + pushProvider, + enablePush, + enableShowAmounts, + }); + + // enableShowAmounts should be disabled by default + const event = makeGatewayEventWithAuthorizer(walletId, null, { + deviceId, + enablePush, + }); + + const result = await update(event, null, null) as APIGatewayProxyResult; + const returnBody = JSON.parse(result.body as string); + + expect(result.statusCode).toStrictEqual(200); + expect(returnBody.success).toStrictEqual(true); + + await expect(checkPushDevicesTable(mysql, 1, { + walletId, + deviceId, + pushProvider, + enablePush, + enableShowAmounts: false, // default value + })).resolves.toBe(true); + }); +}); + +describe('statusCode:400', () => { + it('should validate deviceId', async () => { + expect.hasAssertions(); + const deviceId = (new Array(257)).fill('x').join(''); + + await addToWalletTable(mysql, [{ + id: 'my-wallet', + xpubkey: 'xpubkey', + authXpubkey: 'auth_xpubkey', + status: 'ready', + maxGap: 5, + createdAt: 10000, + readyAt: 10001, + }]); + + const event = makeGatewayEventWithAuthorizer('my-wallet', null, { + deviceId, + enablePush: false, + enableShowAmounts: false, + }); + + const result = await update(event, null, null) as APIGatewayProxyResult; + const returnBody = JSON.parse(result.body as string); + + expect(result.statusCode).toStrictEqual(400); + expect(returnBody.success).toStrictEqual(false); + expect(returnBody.error).toStrictEqual(ApiError.INVALID_PAYLOAD); + }); +}); + +describe('statusCode:404', () => { + it('should validate deviceId existence', async () => { + expect.hasAssertions(); + const deviceId = 'device-not-registered'; + + await addToWalletTable(mysql, [{ + id: 'my-wallet', + xpubkey: 'xpubkey', + authXpubkey: 'auth_xpubkey', + status: 'ready', + maxGap: 5, + createdAt: 10000, + readyAt: 10001, + }]); + + const event = makeGatewayEventWithAuthorizer('my-wallet', null, { + deviceId, + enablePush: false, + enableShowAmounts: false, + }); + + const result = await update(event, null, null) as APIGatewayProxyResult; + const returnBody = JSON.parse(result.body as string); + + expect(result.statusCode).toStrictEqual(404); + expect(returnBody.success).toStrictEqual(false); + expect(returnBody.error).toStrictEqual(ApiError.DEVICE_NOT_FOUND); + }); +}); diff --git a/tests/utils.ts b/tests/utils.ts index bea14852..365f3aa7 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -68,11 +68,13 @@ export const cleanDatabase = async (mysql: ServerlessMysql): Promise => { 'wallet_balance', 'wallet_tx_history', 'miner', + 'push_devices', ]; - + await mysql.query('SET FOREIGN_KEY_CHECKS = 0'); for (const table of TABLES) { await mysql.query(`DELETE FROM ${table}`); } + await mysql.query('SET FOREIGN_KEY_CHECKS = 1'); }; export const createOutput = (index: number, value: number, address: string, token = '00', timelock: number = null, locked = false, tokenData = 0, spentBy = null): TxOutputWithIndex => ( @@ -900,3 +902,65 @@ export const getAuthData = (now: number): any => { timestamp: now, }; }; + +export const checkPushDevicesTable = async ( + mysql: ServerlessMysql, + totalResults: number, + filter?: { + deviceId: string, + walletId: string, + pushProvider: string, + enablePush: boolean, + enableShowAmounts: boolean, + }, +): Promise> => { + let results: DbSelectResult = await mysql.query('SELECT * FROM `push_devices`'); + if (!filter && results.length !== totalResults) { + return { + error: 'checkPushDevicesTable total results', + expected: totalResults, + received: results.length, + results, + }; + } + + if (totalResults === 0) return true; + if (!filter) return true; + + // now fetch the exact entry + const baseQuery = ` + SELECT * + FROM \`push_devices\` + WHERE \`wallet_id\` = ? + AND \`device_id\` = ? + AND \`push_provider\` = ? + AND \`enable_push\` = ? + AND \`enable_show_amounts\` = ? + `; + + results = await mysql.query(baseQuery, [ + filter.walletId, + filter.deviceId, + filter.pushProvider, + filter.enablePush, + filter.enableShowAmounts, + ]); + + if (results.length !== totalResults) { + return { + error: 'checkPushDevicesTable total results after filter', + expected: totalResults, + received: results.length, + results, + }; + } + + if (results.length !== 1) { + return { + error: 'checkPushDevicesTable query', + params: { ...filter }, + results, + }; + } + return true; +};