diff --git a/apps/condo/domains/news/access/GetNewsSharingRecipientsService.js b/apps/condo/domains/news/access/GetNewsSharingRecipientsService.js new file mode 100644 index 00000000000..c6b5caf5745 --- /dev/null +++ b/apps/condo/domains/news/access/GetNewsSharingRecipientsService.js @@ -0,0 +1,31 @@ +/** + * Generated by `createservice news.GetNewsItemSharingRecipientsService --type queries` + */ +const get = require('lodash/get') + +const { throwAuthenticationError } = require('@open-condo/keystone/apolloErrorFormatter') +const { getById } = require('@open-condo/keystone/schema') + +const { checkPermissionInUserOrganizationOrRelatedOrganization } = require('@condo/domains/organization/utils/accessSchema') + +async function canGetNewsSharingRecipients ({ authentication: { item: user }, args: { data: { b2bAppContext: { id: b2bAppContextId } } } }) { + if (!user) return throwAuthenticationError() + if (user.deletedAt) return false + if (user.isAdmin || user.isSupport) return true + + const b2bContext = await getById('B2BAppContext', b2bAppContextId) + if (!b2bContext) { return false } + + const organizationId = get(b2bContext, 'organization', null) + if (!organizationId) { return false } + + return await checkPermissionInUserOrganizationOrRelatedOrganization(user.id, organizationId, 'canManageNewsItems') +} + +/* + Rules are logical functions that used for list access, and may return a boolean (meaning + all or no items are available) or a set of filters that limit the available items. +*/ +module.exports = { + canGetNewsSharingRecipients, +} \ No newline at end of file diff --git a/apps/condo/domains/news/gql.js b/apps/condo/domains/news/gql.js index dd8f242f240..a32e6221c9f 100644 --- a/apps/condo/domains/news/gql.js +++ b/apps/condo/domains/news/gql.js @@ -36,6 +36,12 @@ const NewsItemRecipientsExportTask = generateGqlQueries('NewsItemRecipientsExpor const NEWS_ITEM_SHARING_FIELDS = `{ b2bAppContext { id } newsItem { id } sharingParams status statusMessage lastPostRequest ${COMMON_FIELDS} }` const NewsItemSharing = generateGqlQueries('NewsItemSharing', NEWS_ITEM_SHARING_FIELDS) +const GET_NEWS_SHARING_RECIPIENTS_MUTATION = gql` + query getGetNewsSharingRecipients ($data: GetNewsSharingRecipientsInput!) { + result: getNewsSharingRecipients(data: $data) { id name receiversCount } + } +` + /* AUTOGENERATE MARKER */ module.exports = { @@ -46,5 +52,6 @@ module.exports = { GET_NEWS_ITEMS_RECIPIENTS_COUNTERS_MUTATION, NewsItemRecipientsExportTask, NewsItemSharing, + GET_NEWS_SHARING_RECIPIENTS_MUTATION, /* AUTOGENERATE MARKER */ } diff --git a/apps/condo/domains/news/schema/GetNewsSharingRecipientsService.js b/apps/condo/domains/news/schema/GetNewsSharingRecipientsService.js new file mode 100644 index 00000000000..1ec63e17ca5 --- /dev/null +++ b/apps/condo/domains/news/schema/GetNewsSharingRecipientsService.js @@ -0,0 +1,176 @@ +/** + * Generated by `createservice news.GetNewsItemSharingRecipientsService --type queries` + */ + +/** + * This service is part of "NewsSharing" functionality. + * + * -- + * + * To make your miniapp able to show up on /news page you need to implement getRecipients method. + * + * GetRecipients should return a list of available recipients with relevant data: + * + * Request: + * + * GET ?organizationId=string + * + * Where: + * - URL = getRecipients endpoint url provided in NewsItemSharingConfig + * + * Check response Payload in SCHEMA variable + * -- + * + * This method is just a proxy between Condo and miniapp. + ** + * -> -> + */ + +const Ajv = require('ajv') +const fetch = require('node-fetch') + +const { GQLError, GQLErrorCode: { BAD_USER_INPUT, INTERNAL_ERROR } } = require('@open-condo/keystone/errors') +const { GQLCustomSchema, getById } = require('@open-condo/keystone/schema') + +const { WRONG_VALUE, NETWORK_ERROR } = require('@condo/domains/common/constants/errors') +const access = require('@condo/domains/news/access/GetNewsSharingRecipientsService') + +const SCHEMA = { + 'type': 'array', + 'items': { + 'type': 'object', + 'properties': { + 'id': { 'type': 'string' }, + 'name': { 'type': 'string' }, + 'receiversCount': { 'type': 'number' }, + }, + 'required': ['id', 'name'], + 'additionalProperties': false, + }, +} + +const ajv = new Ajv() +const validateSchema = ajv.compile(SCHEMA) + +async function fetchWithTimeout (url, options = {}, timeout = 5000) { + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), timeout) + + try { + const response = await fetch(url, { ...options, signal: controller.signal }) + clearTimeout(timeoutId) + return response + } catch (error) { + clearTimeout(timeoutId) + throw error.name === 'AbortError' ? new Error('Timeout') : error + } +} + + +/** + * List of possible errors, that this custom schema can throw + * They will be rendered in documentation section in GraphiQL for this custom schema + */ +const ERRORS = { + NOT_NEWS_SHARING_APP: { + query: 'getNewsItemSharingRecipients', + variable: ['data', 'b2bAppContext'], + code: BAD_USER_INPUT, + type: WRONG_VALUE, + message: 'Provided b2bApp does not support NewsItemSharing', + messageForUser: 'api.newsItem.getNewsSharingRecipients.NOT_NEWS_SHARING_APP', + }, + NEWS_SHARING_APP_REQUEST_FAILED: { + query: 'getNewsItemSharingRecipients', + variable: ['data'], + code: INTERNAL_ERROR, + type: NETWORK_ERROR, + message: 'Could not get a successful response from NewsSharing miniapp', + messageForUser: 'api.newsItem.getNewsSharingRecipients.NEWS_SHARING_APP_REQUEST_FAILED', + }, + NEWS_SHARING_APP_REQUEST_BAD_RESPONSE: { + query: 'getNewsItemSharingRecipients', + variable: ['data'], + code: INTERNAL_ERROR, + type: WRONG_VALUE, + message: 'Response from NewsSharing miniapp was successful, but the data format was incorrect', + messageForUser: 'api.newsItem.getNewsSharingRecipients.NEWS_SHARING_APP_REQUEST_BAD_RESPONSE', + }, +} + +const GetNewsSharingRecipientsService = new GQLCustomSchema('GetNewsSharingRecipientsService', { + types: [ + { + access: true, + type: 'input GetNewsSharingRecipientsInput { dv: Int!, sender: JSON!, b2bAppContext: B2BAppContextWhereUniqueInput! }', + }, + { + access: true, + type: 'type GetNewsSharingRecipientsOutput { id: String!, name: String!, receiversCount: Int }', + }, + ], + + queries: [ + { + access: access.canGetNewsSharingRecipients, + schema: 'getNewsSharingRecipients (data: GetNewsSharingRecipientsInput!): [GetNewsSharingRecipientsOutput]', + resolver: async (parent, args, context, info, extra = {}) => { + const { data } = args + + const { dv, sender, b2bAppContext } = data + + const b2bAppContextData = await getById('B2BAppContext', b2bAppContext.id) + const b2bApp = await getById('B2BApp', b2bAppContextData.app) + + if (!b2bApp.newsSharingConfig) { + throw new GQLError(ERRORS.NOT_NEWS_SHARING_APP) + } + + const newsSharingConfig = await getById('B2BAppNewsSharingConfig', b2bApp.newsSharingConfig) + + if (!newsSharingConfig) { + throw new GQLError(ERRORS.NOT_NEWS_SHARING_APP) + } + + const getRecipientsUrl = newsSharingConfig.getRecipientsUrl + const organizationId = b2bAppContextData.organization + + let getRecipientsResult + + // Check that we can obtain result data + try { + getRecipientsResult = await fetchWithTimeout(`${getRecipientsUrl}?organizationId=${organizationId}`) + } + catch (err) { + throw new GQLError(ERRORS.NEWS_SHARING_APP_REQUEST_FAILED) + } + + // If status code of response is not 200, we need to raise an error + if (getRecipientsResult.status !== 200) { + throw new GQLError(ERRORS.NEWS_SHARING_APP_REQUEST_FAILED) + } + + // Check that result data is in good shape + let getRecipientsResultData + + try { + getRecipientsResultData = await getRecipientsResult.json() + } catch (err) { + throw new GQLError(ERRORS.NEWS_SHARING_APP_REQUEST_BAD_RESPONSE) + } + + if (!validateSchema(getRecipientsResultData)) { + throw new GQLError(ERRORS.NEWS_SHARING_APP_REQUEST_BAD_RESPONSE) + } + + return getRecipientsResultData + }, + }, + ], + +}) + +module.exports = { + GetNewsSharingRecipientsService, + ERRORS, +} diff --git a/apps/condo/domains/news/schema/GetNewsSharingRecipientsService.test.js b/apps/condo/domains/news/schema/GetNewsSharingRecipientsService.test.js new file mode 100644 index 00000000000..dd13ffb87fa --- /dev/null +++ b/apps/condo/domains/news/schema/GetNewsSharingRecipientsService.test.js @@ -0,0 +1,258 @@ +/** + * Generated by `createservice news.GetNewsItemSharingRecipientsService --type queries` + */ + +const { makeLoggedInAdminClient, makeClient, expectToThrowAuthenticationError, expectToThrowAccessDeniedErrorToResult, expectToThrowGQLError, initTestExpressApp, getTestExpressApp } = require('@open-condo/keystone/test.utils') + +const { + createTestB2BAppNewsSharingConfig, + createTestB2BApp, + createTestB2BAppContext, +} = require('@condo/domains/miniapp/utils/testSchema') +const { ERRORS } = require('@condo/domains/news/schema/GetNewsSharingRecipientsService') +const { getNewsSharingRecipientsByTestClient } = require('@condo/domains/news/utils/testSchema') +const { createTestOrganization } = require('@condo/domains/organization/utils/testSchema') +const { + createTestOrganizationEmployeeRole, + createTestOrganizationEmployee, +} = require('@condo/domains/organization/utils/testSchema') +const { makeClientWithNewRegisteredAndLoggedInUser } = require('@condo/domains/user/utils/testSchema') + +const { + SUCCESS_GET_RECIPIENTS_URL, + FAULTY_GET_RECIPIENTS_URL_404, + FAULTY_GET_RECIPIENTS_URL_500, + + INCORRECT_GET_RECIPIENTS_RESULT_URL_WRONG_RETURN_TYPE, + INCORRECT_GET_RECIPIENTS_URL_BAD_ID, + INCORRECT_GET_RECIPIENTS_URL_BAD_NAME, + INCORRECT_GET_RECIPIENTS_URL_OTHER_FIELDS, + + SUCCESS_PREVIEW_URL, + SUCCESS_PUBLISH_URL, + SUCCESS_GET_RECIPIENTS_RESULT, + NewsSharingTestingApp, +} = require('../utils/testSchema/NewsSharingTestingApp') + +let adminClient, dummyO10n, staffClientWithPermissions, dummyB2BContextWithNewsSharingConfig, testExpressAppBaseUrl + + +describe('GetNewsSharingRecipientsService', () => { + + initTestExpressApp('NewsSharing', new NewsSharingTestingApp().prepareMiddleware()) + + beforeAll(async () => { + const testExpressApp = getTestExpressApp('NewsSharing') + testExpressAppBaseUrl = testExpressApp.baseUrl + + adminClient = await makeLoggedInAdminClient() + + const [o10n] = await createTestOrganization(adminClient) + dummyO10n = o10n + + staffClientWithPermissions = await makeClientWithNewRegisteredAndLoggedInUser() + const [roleYes] = await createTestOrganizationEmployeeRole(adminClient, o10n, { canManageNewsItems: true }) + await createTestOrganizationEmployee(adminClient, o10n, staffClientWithPermissions.user, roleYes) + + const [B2BAppNewsSharingConfig] = await createTestB2BAppNewsSharingConfig(adminClient, { + getRecipientsUrl: `${testExpressAppBaseUrl}${SUCCESS_GET_RECIPIENTS_URL}`, + previewUrl: `${testExpressAppBaseUrl}${SUCCESS_PREVIEW_URL}`, + publishUrl: `${testExpressAppBaseUrl}${SUCCESS_PUBLISH_URL}`, + }) + const [B2BAppWithNewsSharing] = await createTestB2BApp(adminClient, { newsSharingConfig: { connect: { id: B2BAppNewsSharingConfig.id } } }) + const [B2BContextWithNewsSharingConfig] = await createTestB2BAppContext(adminClient, B2BAppWithNewsSharing, o10n) + dummyB2BContextWithNewsSharingConfig = B2BContextWithNewsSharingConfig + }) + + test('Admin can execute query', async () => { + const [data, attrs] = await getNewsSharingRecipientsByTestClient(adminClient, dummyB2BContextWithNewsSharingConfig) + + // If you are changing this test, that means you have changed schema of getRecipients. + // You need to make sure our integrations sustain this change! + expect(data[0]).toHaveProperty('id') + expect(data[0]).toHaveProperty('name') + expect(data[0]).toHaveProperty('receiversCount') + + expect(data).toMatchObject(SUCCESS_GET_RECIPIENTS_RESULT) + }) + + test('staff with permission can execute', async () => { + const [data, attrs] = await getNewsSharingRecipientsByTestClient(staffClientWithPermissions, dummyB2BContextWithNewsSharingConfig) + + expect(data).toMatchObject(SUCCESS_GET_RECIPIENTS_RESULT) + }) + + test('fails if B2BContext doesnt have NewsSharingConfig', async () => { + const [B2BApp] = await createTestB2BApp(adminClient) + const [B2BContext] = await createTestB2BAppContext(adminClient, B2BApp, dummyO10n) + + await expectToThrowGQLError(async () => { + await getNewsSharingRecipientsByTestClient(staffClientWithPermissions, B2BContext) + }, { + type: ERRORS.NOT_NEWS_SHARING_APP.type, + code: ERRORS.NOT_NEWS_SHARING_APP.code, + message: ERRORS.NOT_NEWS_SHARING_APP.message, + }, 'result') + }) + + test('fails if remote server return bad response code: 404', async () => { + const [B2BAppFailingNewsSharingConfig] = await createTestB2BAppNewsSharingConfig(adminClient, { + getRecipientsUrl: `${testExpressAppBaseUrl}${FAULTY_GET_RECIPIENTS_URL_404}`, + previewUrl: `${testExpressAppBaseUrl}${SUCCESS_PREVIEW_URL}`, + publishUrl: `${testExpressAppBaseUrl}${SUCCESS_PUBLISH_URL}`, + }) + const [B2BApp] = await createTestB2BApp(adminClient, { newsSharingConfig: { connect: { id: B2BAppFailingNewsSharingConfig.id } } }) + const [B2BContext] = await createTestB2BAppContext(adminClient, B2BApp, dummyO10n) + + await expectToThrowGQLError(async () => { + await getNewsSharingRecipientsByTestClient(staffClientWithPermissions, B2BContext) + }, { + type: ERRORS.NEWS_SHARING_APP_REQUEST_FAILED.type, + code: ERRORS.NEWS_SHARING_APP_REQUEST_FAILED.code, + message: ERRORS.NEWS_SHARING_APP_REQUEST_FAILED.message, + }, 'result') + }) + + test('fails if remote server return bad response code: 500', async () => { + const [B2BAppFailingNewsSharingConfig] = await createTestB2BAppNewsSharingConfig(adminClient, { + getRecipientsUrl: `${testExpressAppBaseUrl}${FAULTY_GET_RECIPIENTS_URL_500}`, + previewUrl: `${testExpressAppBaseUrl}${SUCCESS_PREVIEW_URL}`, + publishUrl: `${testExpressAppBaseUrl}${SUCCESS_PUBLISH_URL}`, + }) + const [B2BApp] = await createTestB2BApp(adminClient, { newsSharingConfig: { connect: { id: B2BAppFailingNewsSharingConfig.id } } }) + const [B2BContext] = await createTestB2BAppContext(adminClient, B2BApp, dummyO10n) + + await expectToThrowGQLError(async () => { + await getNewsSharingRecipientsByTestClient(staffClientWithPermissions, B2BContext) + }, { + type: ERRORS.NEWS_SHARING_APP_REQUEST_FAILED.type, + code: ERRORS.NEWS_SHARING_APP_REQUEST_FAILED.code, + message: ERRORS.NEWS_SHARING_APP_REQUEST_FAILED.message, + }, 'result') + }) + + test('fails if remote server returns wrong data (bad type of id field)', async () => { + const [B2BAppFailingNewsSharingConfig] = await createTestB2BAppNewsSharingConfig(adminClient, { + getRecipientsUrl: `${testExpressAppBaseUrl}${INCORRECT_GET_RECIPIENTS_URL_BAD_ID}`, + previewUrl: `${testExpressAppBaseUrl}${SUCCESS_PREVIEW_URL}`, + publishUrl: `${testExpressAppBaseUrl}${SUCCESS_PUBLISH_URL}`, + }) + const [B2BApp] = await createTestB2BApp(adminClient, { newsSharingConfig: { connect: { id: B2BAppFailingNewsSharingConfig.id } } }) + const [B2BContext] = await createTestB2BAppContext(adminClient, B2BApp, dummyO10n) + + await expectToThrowGQLError(async () => { + await getNewsSharingRecipientsByTestClient(staffClientWithPermissions, B2BContext) + }, { + type: ERRORS.NEWS_SHARING_APP_REQUEST_BAD_RESPONSE.type, + code: ERRORS.NEWS_SHARING_APP_REQUEST_BAD_RESPONSE.code, + message: ERRORS.NEWS_SHARING_APP_REQUEST_BAD_RESPONSE.message, + }, 'result') + }) + + test('fails if remote server returns wrong data (bad type of name field)', async () => { + const [B2BAppFailingNewsSharingConfig] = await createTestB2BAppNewsSharingConfig(adminClient, { + getRecipientsUrl: `${testExpressAppBaseUrl}${INCORRECT_GET_RECIPIENTS_URL_BAD_NAME}`, + previewUrl: `${testExpressAppBaseUrl}${SUCCESS_PREVIEW_URL}`, + publishUrl: `${testExpressAppBaseUrl}${SUCCESS_PUBLISH_URL}`, + }) + const [B2BApp] = await createTestB2BApp(adminClient, { newsSharingConfig: { connect: { id: B2BAppFailingNewsSharingConfig.id } } }) + const [B2BContext] = await createTestB2BAppContext(adminClient, B2BApp, dummyO10n) + + await expectToThrowGQLError(async () => { + await getNewsSharingRecipientsByTestClient(staffClientWithPermissions, B2BContext) + }, { + type: ERRORS.NEWS_SHARING_APP_REQUEST_BAD_RESPONSE.type, + code: ERRORS.NEWS_SHARING_APP_REQUEST_BAD_RESPONSE.code, + message: ERRORS.NEWS_SHARING_APP_REQUEST_BAD_RESPONSE.message, + }, 'result') + }) + + test('fails if remote server returns wrong data (wrong data type)', async () => { + const [B2BAppFailingNewsSharingConfig] = await createTestB2BAppNewsSharingConfig(adminClient, { + getRecipientsUrl: `${testExpressAppBaseUrl}${INCORRECT_GET_RECIPIENTS_RESULT_URL_WRONG_RETURN_TYPE}`, + previewUrl: `${testExpressAppBaseUrl}${SUCCESS_PREVIEW_URL}`, + publishUrl: `${testExpressAppBaseUrl}${SUCCESS_PUBLISH_URL}`, + }) + const [B2BApp] = await createTestB2BApp(adminClient, { newsSharingConfig: { connect: { id: B2BAppFailingNewsSharingConfig.id } } }) + const [B2BContext] = await createTestB2BAppContext(adminClient, B2BApp, dummyO10n) + + await expectToThrowGQLError(async () => { + await getNewsSharingRecipientsByTestClient(staffClientWithPermissions, B2BContext) + }, { + type: ERRORS.NEWS_SHARING_APP_REQUEST_BAD_RESPONSE.type, + code: ERRORS.NEWS_SHARING_APP_REQUEST_BAD_RESPONSE.code, + message: ERRORS.NEWS_SHARING_APP_REQUEST_BAD_RESPONSE.message, + }, 'result') + }) + + test('fails if remote server returns wrong data (additional fields)', async () => { + const [B2BAppFailingNewsSharingConfig] = await createTestB2BAppNewsSharingConfig(adminClient, { + getRecipientsUrl: `${testExpressAppBaseUrl}${INCORRECT_GET_RECIPIENTS_URL_OTHER_FIELDS}`, + previewUrl: `${testExpressAppBaseUrl}${SUCCESS_PREVIEW_URL}`, + publishUrl: `${testExpressAppBaseUrl}${SUCCESS_PUBLISH_URL}`, + }) + const [B2BApp] = await createTestB2BApp(adminClient, { newsSharingConfig: { connect: { id: B2BAppFailingNewsSharingConfig.id } } }) + const [B2BContext] = await createTestB2BAppContext(adminClient, B2BApp, dummyO10n) + + await expectToThrowGQLError(async () => { + await getNewsSharingRecipientsByTestClient(staffClientWithPermissions, B2BContext) + }, { + type: ERRORS.NEWS_SHARING_APP_REQUEST_BAD_RESPONSE.type, + code: ERRORS.NEWS_SHARING_APP_REQUEST_BAD_RESPONSE.code, + message: ERRORS.NEWS_SHARING_APP_REQUEST_BAD_RESPONSE.message, + }, 'result') + }) + + test('fails if url is wrong', async () => { + const [B2BAppFailingNewsSharingConfig] = await createTestB2BAppNewsSharingConfig(adminClient, { + getRecipientsUrl: 'ssh://192.168.255.255', + previewUrl: `${testExpressAppBaseUrl}${SUCCESS_PREVIEW_URL}`, + publishUrl: `${testExpressAppBaseUrl}${SUCCESS_PUBLISH_URL}`, + }) + const [B2BApp] = await createTestB2BApp(adminClient, { newsSharingConfig: { connect: { id: B2BAppFailingNewsSharingConfig.id } } }) + const [B2BContext] = await createTestB2BAppContext(adminClient, B2BApp, dummyO10n) + + await expectToThrowGQLError(async () => { + await getNewsSharingRecipientsByTestClient(staffClientWithPermissions, B2BContext) + }, { + type: ERRORS.NEWS_SHARING_APP_REQUEST_FAILED.type, + code: ERRORS.NEWS_SHARING_APP_REQUEST_FAILED.code, + message: ERRORS.NEWS_SHARING_APP_REQUEST_FAILED.message, + }, 'result') + }) + + test('fails if remote server is inaccessible', async () => { + const [B2BAppFailingNewsSharingConfig] = await createTestB2BAppNewsSharingConfig(adminClient, { + getRecipientsUrl: 'https://192.168.255.255', + previewUrl: `${testExpressAppBaseUrl}${SUCCESS_PREVIEW_URL}`, + publishUrl: `${testExpressAppBaseUrl}${SUCCESS_PUBLISH_URL}`, + }) + const [B2BApp] = await createTestB2BApp(adminClient, { newsSharingConfig: { connect: { id: B2BAppFailingNewsSharingConfig.id } } }) + const [B2BContext] = await createTestB2BAppContext(adminClient, B2BApp, dummyO10n) + + await expectToThrowGQLError(async () => { + await getNewsSharingRecipientsByTestClient(staffClientWithPermissions, B2BContext) + }, { + type: ERRORS.NEWS_SHARING_APP_REQUEST_FAILED.type, + code: ERRORS.NEWS_SHARING_APP_REQUEST_FAILED.code, + message: ERRORS.NEWS_SHARING_APP_REQUEST_FAILED.message, + }, 'result') + }) + + test('anonymous can\'t execute', async () => { + const anonymousClient = await makeClient() + await expectToThrowAuthenticationError(async () => { + await getNewsSharingRecipientsByTestClient(anonymousClient, dummyB2BContextWithNewsSharingConfig) + }, 'result') + }) + + test('staff without permission can\'t execute', async () => { + const staffClientWithoutPermissions = await makeClientWithNewRegisteredAndLoggedInUser() + const [roleNo] = await createTestOrganizationEmployeeRole(adminClient, dummyO10n, { canReadNewsItems: false }) + await createTestOrganizationEmployee(adminClient, dummyO10n, staffClientWithoutPermissions.user, roleNo) + + await expectToThrowAccessDeniedErrorToResult(async () => { + await getNewsSharingRecipientsByTestClient(staffClientWithoutPermissions, dummyB2BContextWithNewsSharingConfig) + }) + }) +}) \ No newline at end of file diff --git a/apps/condo/domains/news/schema/index.js b/apps/condo/domains/news/schema/index.js index 77e33414fc4..920710ce763 100644 --- a/apps/condo/domains/news/schema/index.js +++ b/apps/condo/domains/news/schema/index.js @@ -4,6 +4,7 @@ */ const { GetNewsItemsRecipientsCountersService } = require('./GetNewsItemsRecipientsCountersService') +const { GetNewsSharingRecipientsService } = require('./GetNewsSharingRecipientsService') const { NewsItem } = require('./NewsItem') const { NewsItemRecipientsExportTask } = require('./NewsItemRecipientsExportTask') const { NewsItemScope } = require('./NewsItemScope') @@ -20,5 +21,6 @@ module.exports = { GetNewsItemsRecipientsCountersService, NewsItemRecipientsExportTask, NewsItemSharing, + GetNewsSharingRecipientsService, /* AUTOGENERATE MARKER */ } diff --git a/apps/condo/domains/news/utils/serverSchema/index.js b/apps/condo/domains/news/utils/serverSchema/index.js index 44dd4a6c459..c2948d0add1 100644 --- a/apps/condo/domains/news/utils/serverSchema/index.js +++ b/apps/condo/domains/news/utils/serverSchema/index.js @@ -14,6 +14,7 @@ const { EXPORT_NEWS_RECIPIENTS_MUTATION } = require('@condo/domains/news/gql') const { GET_NEWS_ITEMS_RECIPIENTS_COUNTERS_MUTATION } = require('@condo/domains/news/gql') const { NewsItemRecipientsExportTask: NewsItemRecipientsExportTaskGQL } = require('@condo/domains/news/gql') const { NewsItemSharing: NewsItemSharingGQL } = require('@condo/domains/news/gql') +const { GET_NEWS_SHARING_RECIPIENTS_MUTATION } = require('@condo/domains/news/gql') /* AUTOGENERATE MARKER */ const NewsItem = generateServerUtils(NewsItemGQL) @@ -47,7 +48,22 @@ async function getNewsItemsRecipientsCounters (context, data) { } const NewsItemRecipientsExportTask = generateServerUtils(NewsItemRecipientsExportTaskGQL) + const NewsItemSharing = generateServerUtils(NewsItemSharingGQL) + +async function getNewsItemSharingRecipients (context, data) { + if (!context) throw new Error('no context') + if (!data) throw new Error('no data') + if (!data.sender) throw new Error('no data.sender') + + return await execGqlWithoutAccess(context, { + query: GET_NEWS_SHARING_RECIPIENTS_MUTATION, + variables: { data: { dv: 1, ...data } }, + errorMessage: '[error] Unable to getNewsSharingRecipients', + dataPath: 'obj', + }) +} + /* AUTOGENERATE MARKER */ module.exports = { @@ -59,5 +75,6 @@ module.exports = { getNewsItemsRecipientsCounters, NewsItemRecipientsExportTask, NewsItemSharing, + getNewsItemSharingRecipients, /* AUTOGENERATE MARKER */ } diff --git a/apps/condo/domains/news/utils/testSchema/NewsSharingTestingApp.js b/apps/condo/domains/news/utils/testSchema/NewsSharingTestingApp.js new file mode 100644 index 00000000000..82bf9728c7f --- /dev/null +++ b/apps/condo/domains/news/utils/testSchema/NewsSharingTestingApp.js @@ -0,0 +1,162 @@ +const express = require('express') + +const SUCCESS_GET_RECIPIENTS_URL = "/test/news-sharing-api/success/getRecipients" +const SUCCESS_PUBLISH_URL = "/test/news-sharing-api/success/getRecipients" +const SUCCESS_PREVIEW_URL = "/test/news-sharing-api/success/preview" +const FAULTY_GET_RECIPIENTS_URL_404 = '/test/news-sharing-api/fail/getRecipients/404' +const FAULTY_GET_RECIPIENTS_URL_500 = '/test/news-sharing-api/fail/getRecipients/500' + +const INCORRECT_GET_RECIPIENTS_RESULT_URL_WRONG_RETURN_TYPE = '/test/news-sharing-api/fail/getRecipients/0' +const INCORRECT_GET_RECIPIENTS_URL_BAD_NAME = '/test/news-sharing-api/fail/getRecipients/1' +const INCORRECT_GET_RECIPIENTS_URL_BAD_ID = '/test/news-sharing-api/fail/getRecipients/2' +const INCORRECT_GET_RECIPIENTS_URL_OTHER_FIELDS = '/test/news-sharing-api/fail/getRecipients/3' + +const SUCCESS_GET_RECIPIENTS_RESULT = [ + { + id: '1231-2312-3331-1231', + name: 'Mayview house chat', + receiversCount: 120, + }, + { + id: '5231-2312-3331-1233', + name: 'Bayview house chat', + receiversCount: 990, + }, + { + id: '5231-2312-3331-1233', + name: 'Bayview house chat', + }, +] + +const INCORRECT_GET_RECIPIENTS_RESULT_WRONG_RETURN_TYPE = {} + +const INCORRECT_GET_RECIPIENTS_RESULT_BAD_NAME = [ + { + // Name is bad + name: {}, + id: '1231-2312-3331-1231', + receiversCount: 120, + }, + { + // Correct + id: '2313', + name: 'Bayview house chat', + receiversCount: 990, + }, + { + // Correct + id: '2313', + name: 'Bayview house chat', + receiversCount: 990, + }, +] + +const INCORRECT_GET_RECIPIENTS_RESULT_BAD_ID = [ + { + // Correct + id: '2313', + name: 'Bayview house chat', + receiversCount: 990, + }, + { + // Does not have ID + name: 'Bayview house chat', + receiversCount: 990, + }, + { + // Correct + id: '2313', + name: 'Bayview house chat', + receiversCount: 990, + }, +] + +const INCORRECT_GET_RECIPIENTS_RESULT_OTHER_FIELDS = [ + { + // Correct + id: '2313', + name: 'Bayview house chat', + receiversCount: 990, + }, + { + // Correct + id: '2313', + name: 'Bayview house chat', + receiversCount: 990, + }, + { + // Has other fields + name: 'Bayview house chat', + receiversCount: 990, + recipients: '{}', + }, +] + + +class NewsSharingTestingApp { + prepareMiddleware () { + // this route can not be used for csrf attack (because no cookies and tokens are used in a public route) + // nosemgrep: javascript.express.security.audit.express-check-csurf-middleware-usage.express-check-csurf-middleware-usage + const app = express() + + app.get(SUCCESS_GET_RECIPIENTS_URL, async (req, res, next) => { + res.json(SUCCESS_GET_RECIPIENTS_RESULT) + }) + + app.post(SUCCESS_PUBLISH_URL, async (req, res, next) => { + res.status(200).send('OK') + }) + + app.post(SUCCESS_PREVIEW_URL, async (req, res, next) => { + res.status(200).send('OK') + }) + + app.get(INCORRECT_GET_RECIPIENTS_URL_BAD_ID, async (req, res, next) => { + res.status(200).send(INCORRECT_GET_RECIPIENTS_RESULT_BAD_ID) + }) + + app.get(INCORRECT_GET_RECIPIENTS_URL_BAD_NAME, async (req, res, next) => { + res.status(200).send(INCORRECT_GET_RECIPIENTS_RESULT_BAD_NAME) + }) + + app.get(INCORRECT_GET_RECIPIENTS_URL_OTHER_FIELDS, async (req, res, next) => { + res.status(200).send(INCORRECT_GET_RECIPIENTS_RESULT_OTHER_FIELDS) + }) + + app.get(INCORRECT_GET_RECIPIENTS_RESULT_URL_WRONG_RETURN_TYPE, async (req, res, next) => { + res.status(200).send(INCORRECT_GET_RECIPIENTS_RESULT_WRONG_RETURN_TYPE) + }) + + app.get(FAULTY_GET_RECIPIENTS_URL_500, async (req, res, next) => { + res.status(500).send({}) + }) + + app.get(FAULTY_GET_RECIPIENTS_URL_404, async (req, res, next) => { + res.status(404).send({}) + }) + + return app + } +} + +module.exports = { + NewsSharingTestingApp, + + SUCCESS_GET_RECIPIENTS_URL, + SUCCESS_PUBLISH_URL, + SUCCESS_PREVIEW_URL, + FAULTY_GET_RECIPIENTS_URL_404, + FAULTY_GET_RECIPIENTS_URL_500, + + INCORRECT_GET_RECIPIENTS_RESULT_URL_WRONG_RETURN_TYPE, + INCORRECT_GET_RECIPIENTS_URL_BAD_NAME, + INCORRECT_GET_RECIPIENTS_URL_BAD_ID, + INCORRECT_GET_RECIPIENTS_URL_OTHER_FIELDS, + + SUCCESS_GET_RECIPIENTS_RESULT, + + INCORRECT_GET_RECIPIENTS_RESULT_WRONG_RETURN_TYPE, + INCORRECT_GET_RECIPIENTS_RESULT_BAD_NAME, + INCORRECT_GET_RECIPIENTS_RESULT_BAD_ID, + INCORRECT_GET_RECIPIENTS_RESULT_OTHER_FIELDS, +} diff --git a/apps/condo/domains/news/utils/testSchema/index.js b/apps/condo/domains/news/utils/testSchema/index.js index 762d0059cfa..f9f3799fd5d 100644 --- a/apps/condo/domains/news/utils/testSchema/index.js +++ b/apps/condo/domains/news/utils/testSchema/index.js @@ -20,6 +20,7 @@ const { const { NEWS_TYPE_COMMON } = require('@condo/domains/news/constants/newsTypes') const { FLAT_UNIT_TYPE } = require('@condo/domains/property/constants/common') const { NewsItemSharing: NewsItemSharingGQL } = require('@condo/domains/news/gql') +const { GET_NEWS_SHARING_RECIPIENTS_MUTATION } = require('@condo/domains/news/gql') /* AUTOGENERATE MARKER */ const NewsItem = generateGQLTestUtils(NewsItemGQL) @@ -323,6 +324,24 @@ async function updateTestNewsItemSharing (client, id, extraAttrs = {}) { return [obj, attrs] } + +async function getNewsSharingRecipientsByTestClient(client, b2bAppContext, extraAttrs = {}) { + if (!client) throw new Error('no client') + + if (!b2bAppContext.id) throw new Error('no b2bAppContext id') + + const sender = { dv: 1, fingerprint: faker.random.alphaNumeric(8) } + + const attrs = { + dv: 1, + sender, + b2bAppContext: { id: b2bAppContext.id }, + ...extraAttrs, + } + const { data, errors } = await client.mutate(GET_NEWS_SHARING_RECIPIENTS_MUTATION, { data: attrs }) + throwIfError(data, errors) + return [data.result, attrs] +} /* AUTOGENERATE MARKER */ module.exports = { @@ -336,5 +355,6 @@ module.exports = { getNewsItemsRecipientsCountersByTestClient, NewsItemRecipientsExportTask, createTestNewsItemRecipientsExportTask, updateTestNewsItemRecipientsExportTask, NewsItemSharing, createTestNewsItemSharing, updateTestNewsItemSharing, + getNewsSharingRecipientsByTestClient, /* AUTOGENERATE MARKER */ } diff --git a/apps/condo/index.js b/apps/condo/index.js index 2ed012187ec..a5f749c488f 100644 --- a/apps/condo/index.js +++ b/apps/condo/index.js @@ -123,6 +123,7 @@ const checks = [ ] const lastApp = conf.NODE_ENV === 'test' ? undefined : new NextApp({ dir: '.' }) + const apps = () => { return [ new HealthCheck({ checks }), diff --git a/apps/condo/lang/en/en.json b/apps/condo/lang/en/en.json index 34f207c06d7..c01aa3bca3c 100644 --- a/apps/condo/lang/en/en.json +++ b/apps/condo/lang/en/en.json @@ -2200,6 +2200,9 @@ "api.newsItem.WRONG_SEND_DATE": "Wrong send date", "api.newsItem.EMPTY_NEWS_ITEM_SCOPE": "Empty scope", "api.newsItem.UNIT_NAME_WITHOUT_UNIT_TYPE": "Unit name set without unit type", + "api.newsItem.getNewsSharingRecipients.NOT_NEWS_SHARING_APP": "Provided b2bAppContext.app is not a NewsSharing miniapp", + "api.newsItem.getNewsSharingRecipients.NEWS_SHARING_APP_REQUEST_FAILED": "Could not get a successful response from NewsSharing miniapp. Please check the network and url configuration", + "api.newsItem.getNewsSharingRecipients.NEWS_SHARING_APP_REQUEST_BAD_RESPONSE": "Response from NewsSharing miniapp was successful, but the data format was incorrect. Please consult with miniapp developer", "api.callRecord.NEGATIVE_TALK_TIME": "Talk time must be greater or equal than 0", "api.callRecordFragment.INVALID_TICKET_ORGANIZATION": "Ticket must be from the same organization as call record", "api.newsItem.NO_NEWS_ITEM_SCOPES": "Scope-less news item publishing is forbidden", diff --git a/apps/condo/lang/ru/ru.json b/apps/condo/lang/ru/ru.json index 2a6bc989c4b..acaa0ccf56f 100644 --- a/apps/condo/lang/ru/ru.json +++ b/apps/condo/lang/ru/ru.json @@ -2200,6 +2200,9 @@ "api.newsItem.WRONG_SEND_DATE": "Неверная дата отправки", "api.newsItem.EMPTY_NEWS_ITEM_SCOPE": "Не указаны получатели", "api.newsItem.UNIT_NAME_WITHOUT_UNIT_TYPE": "Не задан unitName без unitType", + "api.newsItem.getNewsSharingRecipients.NOT_NEWS_SHARING_APP": "Этот миниапп не поддерживает функциональность отправки новостей", + "api.newsItem.getNewsSharingRecipients.NEWS_SHARING_APP_REQUEST_FAILED": "Запрос в миниапп завершился с ошибкой", + "api.newsItem.getNewsSharingRecipients.NEWS_SHARING_APP_REQUEST_BAD_RESPONSE": "Запрос в миниапп вернул некорректные данные", "api.callRecord.NEGATIVE_TALK_TIME": "Длительность разговора должна быть больше или равна 0", "api.callRecordFragment.INVALID_TICKET_ORGANIZATION": "Заявка должна быть из той же организации, что и запись разговора", "api.newsItem.NO_NEWS_ITEM_SCOPES": "Нельзя публиковать новость без получателей", diff --git a/apps/condo/schema.graphql b/apps/condo/schema.graphql index 7827b8c7120..608f8439366 100644 --- a/apps/condo/schema.graphql +++ b/apps/condo/schema.graphql @@ -73669,6 +73669,18 @@ type GetNewsItemsRecipientsCountersOutput { receiversCount: Int! } +input GetNewsSharingRecipientsInput { + dv: Int! + sender: JSON! + b2bAppContext: B2BAppContextWhereUniqueInput! +} + +type GetNewsSharingRecipientsOutput { + id: String! + name: String! + receiversCount: Int +} + enum AppCategory { DISPATCHING GIS @@ -77525,6 +77537,7 @@ type Query { getOverviewDashboard(data: GetOverviewDashboardInput!): GetOverviewDashboardOutput exportPropertyScopesToExcel(data: ExportPropertyScopeToExcelInput!): ExportPropertyScopeToExcelOutput getNewsItemsRecipientsCounters(data: GetNewsItemsRecipientsCountersInput!): GetNewsItemsRecipientsCountersOutput + getNewsSharingRecipients(data: GetNewsSharingRecipientsInput!): [GetNewsSharingRecipientsOutput] allMiniApps(data: AllMiniAppsInput!): [MiniAppOutput!] """The version of the Keystone application serving this API.""" diff --git a/apps/condo/schema.ts b/apps/condo/schema.ts index 69c9b690733..062d7d77dfd 100644 --- a/apps/condo/schema.ts +++ b/apps/condo/schema.ts @@ -22921,6 +22921,19 @@ export type GetNewsItemsRecipientsCountersOutput = { receiversCount: Scalars['Int']; }; +export type GetNewsSharingRecipientsInput = { + dv: Scalars['Int']; + sender: Scalars['JSON']; + b2bAppContext: B2BAppContextWhereUniqueInput; +}; + +export type GetNewsSharingRecipientsOutput = { + __typename?: 'GetNewsSharingRecipientsOutput'; + id: Scalars['String']; + name: Scalars['String']; + receiversCount?: Maybe; +}; + export enum GetOverviewDashboardAggregatePeriod { Day = 'day', Week = 'week' @@ -62944,6 +62957,7 @@ export type Query = { getOverviewDashboard?: Maybe; exportPropertyScopesToExcel?: Maybe; getNewsItemsRecipientsCounters?: Maybe; + getNewsSharingRecipients?: Maybe>>; allMiniApps?: Maybe>; /** The version of the Keystone application serving this API. */ appVersion?: Maybe; @@ -69384,6 +69398,11 @@ export type QueryGetNewsItemsRecipientsCountersArgs = { }; +export type QueryGetNewsSharingRecipientsArgs = { + data: GetNewsSharingRecipientsInput; +}; + + export type QueryAllMiniAppsArgs = { data: AllMiniAppsInput; }; diff --git a/packages/keystone/test.utils.js b/packages/keystone/test.utils.js index 440506bee37..606d383dd4e 100644 --- a/packages/keystone/test.utils.js +++ b/packages/keystone/test.utils.js @@ -163,6 +163,63 @@ function setFakeClientMode (entryPoint, prepareKeystoneOptions = {}) { __isAwaiting = true } +let __expressTestServers = {} + +/** + * Initializes provided express server on a free port. Returns an address of the server. Removes server after finishing tests + * Use ONLY inside jest test files! + * @param {string} name + * @param {Express} app + * @param {string} protocol like http, ssh, rdp, https. Used only in address + */ +function initTestExpressApp (name, app, protocol = 'http') { + if (!name) { + throw new Error('initTestExpressApp(name, app) no name!') + } + + if (!app) { + throw new Error('initTestExpressApp(name, app) no app!') + } + + if (getTestExpressApp(name)) { + throw new Error('initTestExpressApp(name, app) express app with this name is already initialized') + } + + beforeAll(async () => { + + __expressTestServers[name] = { + server: null, + address: null, + port: null, + baseUrl: null, + } + // This express runs only in tests + // nosemgrep: problem-based-packs.insecure-transport.js-node.using-http-server.using-http-server + __expressTestServers[name].server = await http.createServer(app).listen(0) + + const addressInfo = __expressTestServers[name].server.address() + __expressTestServers[name].address = addressInfo.address === '::' ? 'localhost' : addressInfo.address + __expressTestServers[name].port = addressInfo.port + __expressTestServers[name].baseUrl = `${protocol}://${__expressTestServers[name].address}:${__expressTestServers[name].port}` + }) + + afterAll(async () => { + if (__expressTestServers[name]) { + __expressTestServers[name].server.close() + delete __expressTestServers[name] + } + }) +} + +/** + * Returns test express app. Use when you need to get address of the app + * @param name + * @returns {*} + */ +function getTestExpressApp (name){ + return __expressTestServers[name] +} + /** * @param {function} callable * @param {Object} params @@ -831,4 +888,6 @@ module.exports = { setFeatureFlag, getFeatureFlag, setAllFeatureFlags, + initTestExpressApp, + getTestExpressApp, }