From f2c2d6830fb85096ae1405bb0d168801f624d573 Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie Date: Fri, 10 Nov 2023 17:29:53 +0100 Subject: [PATCH 1/6] Add new checks on plan --- core/api/account/account.model.js | 18 +++++-- core/api/instance/instance.model.js | 17 ++++++ core/api/routes.js | 10 +++- core/api/user/user.model.js | 3 +- core/index.js | 2 + core/middleware/checkUserPlan.js | 34 ++++++++++++ .../20231110144143-add-plan-to-account.js | 53 +++++++++++++++++++ ...0231110144143-add-plan-to-account-down.sql | 5 ++ .../20231110144143-add-plan-to-account-up.sql | 8 +++ test/core/api/user/user.controller.test.js | 2 + test/tasks/fixtures/t_account.js | 2 + 11 files changed, 149 insertions(+), 5 deletions(-) create mode 100644 core/middleware/checkUserPlan.js create mode 100644 migrations/20231110144143-add-plan-to-account.js create mode 100644 migrations/sqls/20231110144143-add-plan-to-account-down.sql create mode 100644 migrations/sqls/20231110144143-add-plan-to-account-up.sql diff --git a/core/api/account/account.model.js b/core/api/account/account.model.js index 3cfe3d0a..b0fe3c88 100644 --- a/core/api/account/account.model.js +++ b/core/api/account/account.model.js @@ -73,13 +73,17 @@ module.exports = function AccountModel(logger, db, redisClient, stripeService, m // await stripeService.addTaxRate(subscription.id); const { email } = customer; + const stripeProductId = subscription?.items?.data[0]?.price?.product; // we first test if an account already exist with this email const account = await db.t_account.findOne({ name: email }); // it means an account already exist with this email if (account !== null) { - throw new AlreadyExistError(); + telegramService.sendAlert( + `Customer email = ${email}, language = ${language} already have an account and paid on the website`, + ); + throw new AlreadyExistError(`User ${email} already have an account!`); } const newAccount = { @@ -88,6 +92,7 @@ module.exports = function AccountModel(logger, db, redisClient, stripeService, m stripe_subscription_id: subscription.id, current_period_end: new Date(subscription.current_period_end * 1000), status: 'active', + plan: stripeProductId === process.env.STRIPE_LITE_PLAN_PRODUCT_ID ? 'lite' : 'plus', }; const insertedAccount = await db.t_account.insert(newAccount); @@ -467,12 +472,18 @@ module.exports = function AccountModel(logger, db, redisClient, stripeService, m break; case 'customer.subscription.updated': - // update status + // eslint-disable-next-line no-case-declarations + const stripeProductId = event.data.object.items?.data[0]?.price?.product; + // Update status await db.t_account.update( account.id, { status: event.data.object.status, - current_period_end: new Date(event.data.object.current_period_end * 1000), + current_period_end: + event.data.object.status === 'active' || event.data.object.status === 'trialing' + ? new Date(event.data.object.current_period_end * 1000) + : new Date(), + plan: stripeProductId === process.env.STRIPE_LITE_PLAN_PRODUCT_ID ? 'lite' : 'plus', }, { fields: ['id'], @@ -523,6 +534,7 @@ module.exports = function AccountModel(logger, db, redisClient, stripeService, m case 'customer.subscription.deleted': // subscription is canceled, remove the client + telegramService.sendAlert(`Subscription canceled! Customer email = ${email}, language = ${language}`); break; default: diff --git a/core/api/instance/instance.model.js b/core/api/instance/instance.model.js index 69d5dfdf..3a576639 100644 --- a/core/api/instance/instance.model.js +++ b/core/api/instance/instance.model.js @@ -201,6 +201,22 @@ module.exports = function InstanceModel(logger, db, redisClient, jwtService, fin return instanceId; } + async function getAccountByInstanceId(instanceId) { + const accounts = await db.query( + ` + SELECT t_account.id, t_account.plan, t_account.status + FROM t_account + JOIN t_instance ON t_instance.account_id = t_account.id + WHERE t_instance.id = $1; + `, + [instanceId], + ); + if (accounts.length === 0) { + throw new NotFoundError('Instance not found'); + } + return accounts[0]; + } + async function setInstanceAsPrimaryInstance(accountId, instanceId) { await db.withTransaction(async (tx) => { // set all other instances in account as secondary instance @@ -240,5 +256,6 @@ module.exports = function InstanceModel(logger, db, redisClient, jwtService, fin getPrimaryInstanceByAccount, getPrimaryInstanceIdByUserId, setInstanceAsPrimaryInstance, + getAccountByInstanceId, }; }; diff --git a/core/api/routes.js b/core/api/routes.js index 28e6886d..8c0ea1de 100644 --- a/core/api/routes.js +++ b/core/api/routes.js @@ -57,6 +57,7 @@ module.exports.load = function Routes(app, io, controllers, middlewares) { app.post( '/openai/ask', asyncMiddleware(middlewares.accessTokenInstanceAuth), + middlewares.checkUserPlan('plus'), middlewares.openAIAuthAndRateLimit, asyncMiddleware(controllers.openAIController.ask), ); @@ -425,12 +426,18 @@ module.exports.load = function Routes(app, io, controllers, middlewares) { ); // Backup - app.get('/backups', asyncMiddleware(middlewares.accessTokenInstanceAuth), controllers.backupController.get); + app.get( + '/backups', + asyncMiddleware(middlewares.accessTokenInstanceAuth), + middlewares.checkUserPlan('plus'), + controllers.backupController.get, + ); // Backup multi-part upload app.post( '/backups/multi_parts/initialize', asyncMiddleware(middlewares.accessTokenInstanceAuth), + middlewares.checkUserPlan('plus'), controllers.backupController.initializeMultipartUpload, ); app.post( @@ -451,6 +458,7 @@ module.exports.load = function Routes(app, io, controllers, middlewares) { app.post( '/cameras/streaming/start', asyncMiddleware(middlewares.accessTokenAuth({ scope: 'dashboard:read' })), + middlewares.checkUserPlan('plus'), controllers.cameraController.startStreaming, ); app.post( diff --git a/core/api/user/user.model.js b/core/api/user/user.model.js index b69760ea..09b4b8b4 100644 --- a/core/api/user/user.model.js +++ b/core/api/user/user.model.js @@ -92,7 +92,8 @@ module.exports = function UserModel(logger, db, redisClient, jwtService, mailSer ` SELECT t_user.id, t_user.name, t_user.email, t_user.role, t_user.language, t_user.profile_url, t_user.gladys_user_id, t_user.gladys_4_user_id, t_user.account_id, - (t_account.current_period_end + interval '24 hour') as current_period_end + (t_account.current_period_end + interval '24 hour') as current_period_end, t_account.plan as plan, + t_account.status as status FROM t_user JOIN t_account ON t_user.account_id = t_account.id WHERE t_user.id = $1 diff --git a/core/index.js b/core/index.js index c20a9084..284156cd 100644 --- a/core/index.js +++ b/core/index.js @@ -68,6 +68,7 @@ const requestExecutionTime = require('./middleware/requestExecutionTime'); const AdminApiAuth = require('./middleware/adminApiAuth'); const OpenAIAuthAndRateLimit = require('./middleware/openAIAuthAndRateLimit'); const CameraStreamAccessKeyAuth = require('./middleware/cameraStreamAccessKeyAuth'); +const CheckUserPlan = require('./middleware/checkUserPlan'); // Routes const routes = require('./api/routes'); @@ -236,6 +237,7 @@ module.exports = async (port) => { adminApiAuth: AdminApiAuth(logger, legacyRedisClient), openAIAuthAndRateLimit: OpenAIAuthAndRateLimit(logger, legacyRedisClient, db), cameraStreamAccessKeyAuth: CameraStreamAccessKeyAuth(redisClient, logger), + checkUserPlan: CheckUserPlan(models.userModel, models.instanceModel, logger), }; routes.load(app, io, controllers, middlewares); diff --git a/core/middleware/checkUserPlan.js b/core/middleware/checkUserPlan.js new file mode 100644 index 00000000..11c2e4ce --- /dev/null +++ b/core/middleware/checkUserPlan.js @@ -0,0 +1,34 @@ +const { ForbiddenError } = require('../common/error'); +const asyncMiddleware = require('./asyncMiddleware'); + +const ALLOWED_ACCOUNT_STATUS = ['active', 'trialing']; + +module.exports = function checkUserPlan(userModel, instanceModel, logger) { + return function checkUserPlanByPlan(plan) { + return asyncMiddleware(async (req, res, next) => { + let account; + // This middleware serves user + if (req.user) { + logger.debug(`checkUserPlan: Verify that user ${req.user.id} has access to plan ${plan} and is active.`); + account = await userModel.getMySelf(req.user); + } + // and instances! + if (req.instance) { + logger.debug( + `checkUserPlan: Verify that instance ${req.instance.id} has access to plan ${plan} and is active.`, + ); + account = await instanceModel.getAccountByInstanceId(req.instance.id); + } + + if (account.plan !== plan) { + throw new ForbiddenError(`Account is in plan ${account.plan} and should be in plan ${plan}`); + } + + if (ALLOWED_ACCOUNT_STATUS.indexOf(account.status) === -1) { + throw new ForbiddenError(`Account is not active`); + } + + next(); + }); + }; +}; diff --git a/migrations/20231110144143-add-plan-to-account.js b/migrations/20231110144143-add-plan-to-account.js new file mode 100644 index 00000000..e2e5aad0 --- /dev/null +++ b/migrations/20231110144143-add-plan-to-account.js @@ -0,0 +1,53 @@ +'use strict'; + +var dbm; +var type; +var seed; +var fs = require('fs'); +var path = require('path'); +var Promise; + +/** + * We receive the dbmigrate dependency from dbmigrate initially. + * This enables us to not have to rely on NODE_PATH. + */ +exports.setup = function(options, seedLink) { + dbm = options.dbmigrate; + type = dbm.dataType; + seed = seedLink; + Promise = options.Promise; +}; + +exports.up = function(db) { + var filePath = path.join(__dirname, 'sqls', '20231110144143-add-plan-to-account-up.sql'); + return new Promise( function( resolve, reject ) { + fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){ + if (err) return reject(err); + console.log('received data: ' + data); + + resolve(data); + }); + }) + .then(function(data) { + return db.runSql(data); + }); +}; + +exports.down = function(db) { + var filePath = path.join(__dirname, 'sqls', '20231110144143-add-plan-to-account-down.sql'); + return new Promise( function( resolve, reject ) { + fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){ + if (err) return reject(err); + console.log('received data: ' + data); + + resolve(data); + }); + }) + .then(function(data) { + return db.runSql(data); + }); +}; + +exports._meta = { + "version": 1 +}; diff --git a/migrations/sqls/20231110144143-add-plan-to-account-down.sql b/migrations/sqls/20231110144143-add-plan-to-account-down.sql new file mode 100644 index 00000000..ea4b6ff3 --- /dev/null +++ b/migrations/sqls/20231110144143-add-plan-to-account-down.sql @@ -0,0 +1,5 @@ +ALTER TABLE t_account + DROP COLUMN plan; + +DROP TYPE account_plan_type; + diff --git a/migrations/sqls/20231110144143-add-plan-to-account-up.sql b/migrations/sqls/20231110144143-add-plan-to-account-up.sql new file mode 100644 index 00000000..0c4048b1 --- /dev/null +++ b/migrations/sqls/20231110144143-add-plan-to-account-up.sql @@ -0,0 +1,8 @@ +CREATE TYPE account_plan_type AS ENUM( + 'plus', + 'lite' +); + +ALTER TABLE t_account + ADD COLUMN plan account_plan_type DEFAULT 'plus' NOT NULL; + diff --git a/test/core/api/user/user.controller.test.js b/test/core/api/user/user.controller.test.js index 439f0d2f..d7a80564 100644 --- a/test/core/api/user/user.controller.test.js +++ b/test/core/api/user/user.controller.test.js @@ -364,6 +364,8 @@ describe('GET /users/me', () => { role: 'admin', superAdmin: false, language: 'en', + plan: 'plus', + status: 'active', profile_url: null, gladys_user_id: null, gladys_4_user_id: null, diff --git a/test/tasks/fixtures/t_account.js b/test/tasks/fixtures/t_account.js index 64ee3b72..7f131ac8 100644 --- a/test/tasks/fixtures/t_account.js +++ b/test/tasks/fixtures/t_account.js @@ -4,6 +4,7 @@ module.exports = [ name: 'email-confirmed-two-factor-enabled@gladysprojet.com', stripe_portal_key: 'fee71731-5928-4f2f-a74b-c7858d39372f', current_period_end: new Date('2050-11-19T16:00:00.000Z'), + status: 'active', }, { id: 'be2b9666-5c72-451e-98f4-efca76ffef54', @@ -11,5 +12,6 @@ module.exports = [ stripe_customer_id: 'cus', stripe_subscription_id: 'sub', stripe_portal_key: '5959fcac-71b7-4a0e-8d67-5ab3f616f703', + status: 'active', }, ]; From 788b595bd9ef8e59db0a7b183a063c016bbde679 Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie Date: Thu, 16 Nov 2023 12:02:21 +0100 Subject: [PATCH 2/6] Add testing on webhook --- core/api/account/account.model.js | 4 +- test/bootstrap.test.js | 1 + .../account.stripeWebhook.controller.test.js | 349 ++++++++++++++++++ 3 files changed, 351 insertions(+), 3 deletions(-) create mode 100644 test/core/api/account/account.stripeWebhook.controller.test.js diff --git a/core/api/account/account.model.js b/core/api/account/account.model.js index b0fe3c88..fca24016 100644 --- a/core/api/account/account.model.js +++ b/core/api/account/account.model.js @@ -80,9 +80,7 @@ module.exports = function AccountModel(logger, db, redisClient, stripeService, m // it means an account already exist with this email if (account !== null) { - telegramService.sendAlert( - `Customer email = ${email}, language = ${language} already have an account and paid on the website`, - ); + telegramService.sendAlert(`Customer already have an account! Email = ${email}, language = ${language}.`); throw new AlreadyExistError(`User ${email} already have an account!`); } diff --git a/test/bootstrap.test.js b/test/bootstrap.test.js index e6585d69..56a36cdc 100644 --- a/test/bootstrap.test.js +++ b/test/bootstrap.test.js @@ -28,6 +28,7 @@ before(async function Before() { '-----BEGIN RSA PRIVATE KEY-----\n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\n-----END RSA PRIVATE KEY-----'; process.env.POSTGRESQL_DATABASE = process.env.POSTGRESQL_DATABASE_TEST; process.env.STRIPE_SECRET_KEY = 'test'; + process.env.STRIPE_LITE_PLAN_PRODUCT_ID = 'lite-product-id'; process.env.OPEN_AI_MAX_REQUESTS_PER_MONTH_PER_ACCOUNT = 100; // starting 2 backends to try multi-server socket exchange diff --git a/test/core/api/account/account.stripeWebhook.controller.test.js b/test/core/api/account/account.stripeWebhook.controller.test.js new file mode 100644 index 00000000..d1eacccc --- /dev/null +++ b/test/core/api/account/account.stripeWebhook.controller.test.js @@ -0,0 +1,349 @@ +const request = require('supertest'); +const nock = require('nock'); +const { expect } = require('chai'); +const Stripe = require('stripe'); + +describe('stripeWebhook', () => { + const stripe = Stripe(process.env.STRIPE_SECRET_KEY); + beforeEach(async () => {}); + it('should create new account with plus plan', async () => { + const event = { + id: 'evt_test_webhook', + object: 'event', + type: 'checkout.session.completed', + data: { + object: { + customer: 'cusnew', + subscription: 'subnew', + }, + }, + }; + const stringEvent = JSON.stringify(event); + const signatureHeader = stripe.webhooks.generateTestHeaderString({ + payload: stringEvent, + secret: process.env.STRIPE_ENDPOINT_SECRET, + }); + nock('https://api.stripe.com:443', { encodedQueryParams: true }) + .get('/v1/subscriptions/subnew') + .reply(200, { + id: 'subnew', + current_period_end: 1289482682000, // in 2010 + items: { + data: [ + { + price: { + product: 'plus-plan-id', + }, + }, + ], + }, + }); + nock('https://api.stripe.com:443', { encodedQueryParams: true }).get('/v1/customers/cusnew').reply(200, { + id: 'cusnew', + email: 'toto@test.fr', + }); + const response = await request(TEST_BACKEND_APP) + .post('/stripe/webhook') + .set('Accept', 'application/json') + .set('stripe-signature', signatureHeader) + .set('Content-type', 'application/json') + .send(stringEvent) + .expect(200); + + expect(response.body).to.deep.equal({ success: true }); + const accountUpdated = await TEST_DATABASE_INSTANCE.t_account.findOne({ + stripe_customer_id: 'cusnew', + }); + expect(accountUpdated).to.have.property('status', 'active'); + expect(accountUpdated).to.have.property('plan', 'plus'); + }); + it('should try to create account 2 times', async () => { + const event = { + id: 'evt_test_webhook', + object: 'event', + type: 'checkout.session.completed', + data: { + object: { + customer: 'cusnew', + subscription: 'subnew', + }, + }, + }; + const stringEvent = JSON.stringify(event); + const signatureHeader = stripe.webhooks.generateTestHeaderString({ + payload: stringEvent, + secret: process.env.STRIPE_ENDPOINT_SECRET, + }); + nock('https://api.stripe.com:443', { encodedQueryParams: true }) + .get('/v1/subscriptions/subnew') + .reply(200, { + id: 'subnew', + current_period_end: 1289482682000, // in 2010 + items: { + data: [ + { + price: { + product: 'plus-plan-id', + }, + }, + ], + }, + }); + nock('https://api.stripe.com:443', { encodedQueryParams: true }).get('/v1/customers/cusnew').reply(200, { + id: 'cusnew', + email: 'toto@test.fr', + }); + const response = await request(TEST_BACKEND_APP) + .post('/stripe/webhook') + .set('Accept', 'application/json') + .set('stripe-signature', signatureHeader) + .set('Content-type', 'application/json') + .send(stringEvent) + .expect(200); + + expect(response.body).to.deep.equal({ success: true }); + const accountUpdated = await TEST_DATABASE_INSTANCE.t_account.findOne({ + stripe_customer_id: 'cusnew', + }); + expect(accountUpdated).to.have.property('status', 'active'); + expect(accountUpdated).to.have.property('plan', 'plus'); + nock('https://api.stripe.com:443', { encodedQueryParams: true }) + .get('/v1/subscriptions/subnew') + .reply(200, { + id: 'subnew', + current_period_end: 1289482682000, // in 2010 + items: { + data: [ + { + price: { + product: 'plus-plan-id', + }, + }, + ], + }, + }); + nock('https://api.stripe.com:443', { encodedQueryParams: true }).get('/v1/customers/cusnew').reply(200, { + id: 'cusnew', + email: 'toto@test.fr', + }); + await request(TEST_BACKEND_APP) + .post('/stripe/webhook') + .set('Accept', 'application/json') + .set('stripe-signature', signatureHeader) + .set('Content-type', 'application/json') + .send(stringEvent) + .expect(409); + }); + it('should create new account with lite plan', async () => { + const event = { + id: 'evt_test_webhook', + object: 'event', + type: 'checkout.session.completed', + data: { + object: { + customer: 'cusnew', + subscription: 'subnew', + }, + }, + }; + const stringEvent = JSON.stringify(event); + const signatureHeader = stripe.webhooks.generateTestHeaderString({ + payload: stringEvent, + secret: process.env.STRIPE_ENDPOINT_SECRET, + }); + nock('https://api.stripe.com:443', { encodedQueryParams: true }) + .get('/v1/subscriptions/subnew') + .reply(200, { + id: 'subnew', + current_period_end: 1289482682000, // in 2010 + items: { + data: [ + { + price: { + product: process.env.STRIPE_LITE_PLAN_PRODUCT_ID, + }, + }, + ], + }, + }); + nock('https://api.stripe.com:443', { encodedQueryParams: true }).get('/v1/customers/cusnew').reply(200, { + id: 'cusnew', + email: 'toto@test.fr', + }); + const response = await request(TEST_BACKEND_APP) + .post('/stripe/webhook') + .set('Accept', 'application/json') + .set('stripe-signature', signatureHeader) + .set('Content-type', 'application/json') + .send(stringEvent) + .expect(200); + + expect(response.body).to.deep.equal({ success: true }); + const accountUpdated = await TEST_DATABASE_INSTANCE.t_account.findOne({ + stripe_customer_id: 'cusnew', + }); + expect(accountUpdated).to.have.property('status', 'active'); + expect(accountUpdated).to.have.property('plan', 'lite'); + }); + it('should let user switch from one subscription to another', async () => { + // First, subscribe to "lite" + const event = { + id: 'evt_test_webhook', + object: 'event', + type: 'checkout.session.completed', + data: { + object: { + customer: 'cusnew', + subscription: 'subnew', + }, + }, + }; + const stringEvent = JSON.stringify(event); + const signatureHeader = stripe.webhooks.generateTestHeaderString({ + payload: stringEvent, + secret: process.env.STRIPE_ENDPOINT_SECRET, + }); + nock('https://api.stripe.com:443', { encodedQueryParams: true }) + .get('/v1/subscriptions/subnew') + .reply(200, { + id: 'subnew', + current_period_end: 1289482682000, // in 2010 + items: { + data: [ + { + price: { + product: process.env.STRIPE_LITE_PLAN_PRODUCT_ID, + }, + }, + ], + }, + }); + nock('https://api.stripe.com:443', { encodedQueryParams: true }).get('/v1/customers/cusnew').reply(200, { + id: 'cusnew', + email: 'toto@test.fr', + }); + const response = await request(TEST_BACKEND_APP) + .post('/stripe/webhook') + .set('Accept', 'application/json') + .set('stripe-signature', signatureHeader) + .set('Content-type', 'application/json') + .send(stringEvent) + .expect(200); + + expect(response.body).to.deep.equal({ success: true }); + const accountUpdated = await TEST_DATABASE_INSTANCE.t_account.findOne({ + stripe_customer_id: 'cusnew', + }); + expect(accountUpdated).to.have.property('status', 'active'); + expect(accountUpdated).to.have.property('plan', 'lite'); + // Then, upgrade to "plus" + const updateEvent = { + id: 'evt_test_webhook', + object: 'event', + type: 'customer.subscription.updated', + data: { + object: { + id: 'subnew', + customer: 'cusnew', + status: 'active', + current_period_end: (new Date().getTime() + 10 * 60 * 1000) / 1000, + items: { + data: [ + { + price: { + product: 'plus-plan-id', + }, + }, + ], + }, + }, + }, + }; + const stringUpdateEvent = JSON.stringify(updateEvent); + const signatureUpdateHeader = stripe.webhooks.generateTestHeaderString({ + payload: stringUpdateEvent, + secret: process.env.STRIPE_ENDPOINT_SECRET, + }); + await request(TEST_BACKEND_APP) + .post('/stripe/webhook') + .set('Accept', 'application/json') + .set('stripe-signature', signatureUpdateHeader) + .set('Content-type', 'application/json') + .send(stringUpdateEvent) + .expect(200); + const accountUpdatedToPlus = await TEST_DATABASE_INSTANCE.t_account.findOne({ + stripe_customer_id: 'cusnew', + }); + expect(accountUpdatedToPlus).to.have.property('status', 'active'); + expect(accountUpdatedToPlus).to.have.property('plan', 'plus'); + }); + it('should delete subscription', async () => { + // First create subscription + const event = { + id: 'evt_test_webhook', + object: 'event', + type: 'checkout.session.completed', + data: { + object: { + customer: 'cusnew', + subscription: 'subnew', + }, + }, + }; + const stringEvent = JSON.stringify(event); + const signatureHeader = stripe.webhooks.generateTestHeaderString({ + payload: stringEvent, + secret: process.env.STRIPE_ENDPOINT_SECRET, + }); + nock('https://api.stripe.com:443', { encodedQueryParams: true }) + .get('/v1/subscriptions/subnew') + .reply(200, { + id: 'subnew', + current_period_end: 1289482682000, // in 2010 + items: { + data: [ + { + price: { + product: 'plus-plan-id', + }, + }, + ], + }, + }); + nock('https://api.stripe.com:443', { encodedQueryParams: true }).get('/v1/customers/cusnew').reply(200, { + id: 'cusnew', + email: 'toto@test.fr', + }); + await request(TEST_BACKEND_APP) + .post('/stripe/webhook') + .set('Accept', 'application/json') + .set('stripe-signature', signatureHeader) + .set('Content-type', 'application/json') + .send(stringEvent) + .expect(200); + + const deleteEVent = { + id: 'evt_test_webhook', + object: 'event', + type: 'customer.subscription.deleted', + data: { + object: { + id: 'subnew', + customer: 'cusnew', + }, + }, + }; + const stringDeleteEvent = JSON.stringify(deleteEVent); + const signatureDeleteHeader = stripe.webhooks.generateTestHeaderString({ + payload: stringDeleteEvent, + secret: process.env.STRIPE_ENDPOINT_SECRET, + }); + await request(TEST_BACKEND_APP) + .post('/stripe/webhook') + .set('Accept', 'application/json') + .set('stripe-signature', signatureDeleteHeader) + .set('Content-type', 'application/json') + .send(stringDeleteEvent) + .expect(200); + }); +}); From 5b0172b14fa415bdaa061b5987d015e6804ee976 Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie Date: Thu, 16 Nov 2023 12:03:14 +0100 Subject: [PATCH 3/6] Remove stream message --- core/api/camera/camera.controller.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/core/api/camera/camera.controller.js b/core/api/camera/camera.controller.js index 678025de..a4b7d1bc 100644 --- a/core/api/camera/camera.controller.js +++ b/core/api/camera/camera.controller.js @@ -114,8 +114,6 @@ module.exports = function CameraController( * @apiGroup Camera */ async function startStreaming(req, res, next) { - const user = await userModel.getMySelf(req.user); - telegramService.sendAlert(`User ${user.email} starting stream !`); const streamAccessKey = (await randomBytes(36)).toString('hex'); await redisClient.set(`${STREAMING_ACCESS_KEY_PREFIX}:${streamAccessKey}`, req.user.id, { EX: 60 * 60, // 1 hour in second @@ -217,7 +215,6 @@ module.exports = function CameraController( */ async function cleanCameraLive(req, res) { validateSessionId(req.params.session_id); - telegramService.sendAlert(`End of camera stream, session_id = ${req.params.session_id} !`); const folder = `${req.instance.id}/${req.params.session_id}`; await emptyS3Directory(process.env.CAMERA_STORAGE_BUCKET, folder); res.json({ success: true }); From 5c904584ecc3185fc84727724602e5e2263a2c20 Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie Date: Thu, 16 Nov 2023 12:08:10 +0100 Subject: [PATCH 4/6] Add STRIPE_ENDPOINT_SECRET to CI --- test/bootstrap.test.js | 1 + 1 file changed, 1 insertion(+) diff --git a/test/bootstrap.test.js b/test/bootstrap.test.js index 56a36cdc..6d4c21e0 100644 --- a/test/bootstrap.test.js +++ b/test/bootstrap.test.js @@ -28,6 +28,7 @@ before(async function Before() { '-----BEGIN RSA PRIVATE KEY-----\n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\n-----END RSA PRIVATE KEY-----'; process.env.POSTGRESQL_DATABASE = process.env.POSTGRESQL_DATABASE_TEST; process.env.STRIPE_SECRET_KEY = 'test'; + process.env.STRIPE_ENDPOINT_SECRET = 'test'; process.env.STRIPE_LITE_PLAN_PRODUCT_ID = 'lite-product-id'; process.env.OPEN_AI_MAX_REQUESTS_PER_MONTH_PER_ACCOUNT = 100; From 65daa98a07698066a623686d396f6a7f41fd8527 Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie Date: Thu, 16 Nov 2023 12:22:13 +0100 Subject: [PATCH 5/6] Add tests on backups on inactive account --- core/middleware/checkUserPlan.js | 6 +-- core/middleware/errorMiddleware.js | 3 +- .../core/api/backup/backup.controller.test.js | 46 +++++++++++++++++++ 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/core/middleware/checkUserPlan.js b/core/middleware/checkUserPlan.js index 11c2e4ce..11c7c9e4 100644 --- a/core/middleware/checkUserPlan.js +++ b/core/middleware/checkUserPlan.js @@ -1,4 +1,4 @@ -const { ForbiddenError } = require('../common/error'); +const { PaymentRequiredError } = require('../common/error'); const asyncMiddleware = require('./asyncMiddleware'); const ALLOWED_ACCOUNT_STATUS = ['active', 'trialing']; @@ -21,11 +21,11 @@ module.exports = function checkUserPlan(userModel, instanceModel, logger) { } if (account.plan !== plan) { - throw new ForbiddenError(`Account is in plan ${account.plan} and should be in plan ${plan}`); + throw new PaymentRequiredError(`Account is in plan ${account.plan} and should be in plan ${plan}`); } if (ALLOWED_ACCOUNT_STATUS.indexOf(account.status) === -1) { - throw new ForbiddenError(`Account is not active`); + throw new PaymentRequiredError(`Account is not active`); } next(); diff --git a/core/middleware/errorMiddleware.js b/core/middleware/errorMiddleware.js index e51dbf48..9ed15a54 100644 --- a/core/middleware/errorMiddleware.js +++ b/core/middleware/errorMiddleware.js @@ -26,7 +26,8 @@ module.exports = function getErrorMiddleware(logger) { error instanceof ForbiddenError || error instanceof UnauthorizedError || error instanceof BadRequestError || - error instanceof TooManyRequestsError + error instanceof TooManyRequestsError || + error instanceof PaymentRequiredError ) { return res.status(error.getStatus()).json(error.jsonError()); } diff --git a/test/core/api/backup/backup.controller.test.js b/test/core/api/backup/backup.controller.test.js index 6287165c..c1f200a6 100644 --- a/test/core/api/backup/backup.controller.test.js +++ b/test/core/api/backup/backup.controller.test.js @@ -163,6 +163,52 @@ describe('Upload backup', () => { error_message: 'File is too large. Maximum file size is 10240 MB.', }); }); + it('should not upload backup, wrong plan', async function Test() { + await TEST_DATABASE_INSTANCE.t_account.update( + { + id: 'b2d23f66-487d-493f-8acb-9c8adb400def', + }, + { plan: 'lite' }, + ); + this.timeout(10000); + const response = await request(TEST_BACKEND_APP) + .post('/backups/multi_parts/initialize') + .set('Accept', 'application/json') + .set('Authorization', configTest.jwtAccessTokenInstance) + .send({ + file_size: 12 * 1024 * 1024 * 1024, + }) + .expect('Content-Type', /json/) + .expect(402); + expect(response.body).to.deep.equal({ + status: 402, + error_code: 'PAYMENT_REQUIRED', + error_message: 'Account is in plan lite and should be in plan plus', + }); + }); + it('should not upload backup, inactive account', async function Test() { + await TEST_DATABASE_INSTANCE.t_account.update( + { + id: 'b2d23f66-487d-493f-8acb-9c8adb400def', + }, + { status: 'past_due' }, + ); + this.timeout(10000); + const response = await request(TEST_BACKEND_APP) + .post('/backups/multi_parts/initialize') + .set('Accept', 'application/json') + .set('Authorization', configTest.jwtAccessTokenInstance) + .send({ + file_size: 12 * 1024 * 1024 * 1024, + }) + .expect('Content-Type', /json/) + .expect(402); + expect(response.body).to.deep.equal({ + status: 402, + error_code: 'PAYMENT_REQUIRED', + error_message: 'Account is not active', + }); + }); it('should return 404 backup not found', async function Test() { this.timeout(10000); const filePath = path.join(__dirname, 'file_to_upload.enc'); From c573a9dc552eb298dfeb1106312df919c7342066 Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie Date: Thu, 16 Nov 2023 13:41:13 +0100 Subject: [PATCH 6/6] Add checks on Enedis routes --- core/api/routes.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/core/api/routes.js b/core/api/routes.js index 8c0ea1de..c3850aeb 100644 --- a/core/api/routes.js +++ b/core/api/routes.js @@ -376,31 +376,37 @@ module.exports.load = function Routes(app, io, controllers, middlewares) { app.get( '/enedis/initialize', asyncMiddleware(middlewares.accessTokenAuth({ scope: 'dashboard:write' })), + middlewares.checkUserPlan('plus'), asyncMiddleware(controllers.enedisController.initialize), ); app.post( '/enedis/finalize', asyncMiddleware(middlewares.accessTokenAuth({ scope: 'dashboard:write' })), + middlewares.checkUserPlan('plus'), asyncMiddleware(controllers.enedisController.finalize), ); app.get( '/enedis/metering_data/consumption_load_curve', asyncMiddleware(middlewares.accessTokenInstanceAuth), + middlewares.checkUserPlan('plus'), asyncMiddleware(controllers.enedisController.meteringDataConsumptionLoadCurve), ); app.get( '/enedis/metering_data/daily_consumption', asyncMiddleware(middlewares.accessTokenInstanceAuth), + middlewares.checkUserPlan('plus'), asyncMiddleware(controllers.enedisController.meteringDataDailyConsumption), ); app.post( '/enedis/refresh_all', asyncMiddleware(middlewares.accessTokenAuth({ scope: 'dashboard:write' })), + middlewares.checkUserPlan('plus'), asyncMiddleware(controllers.enedisController.refreshAllData), ); app.get( '/enedis/sync', asyncMiddleware(middlewares.accessTokenAuth({ scope: 'dashboard:read' })), + middlewares.checkUserPlan('plus'), asyncMiddleware(controllers.enedisController.getEnedisSync), );