Skip to content
Open
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';

import db from '@nangohq/database';
import { seeders } from '@nangohq/shared';

import { envs } from '../../../env.js';
import { authenticateUser, isSuccess, runServer, shouldBeProtected, shouldRequireQueryEnv } from '../../../utils/tests.js';

const route = '/api/v1/environments/current';
let api: Awaited<ReturnType<typeof runServer>>;

describe(`GET ${route}`, () => {
beforeAll(async () => {
api = await runServer();
});

afterAll(() => {
api.server.close();
});

it('should be protected', async () => {
// @ts-expect-error query params are required
const res = await api.fetch(route, { method: 'GET', query: { env: 'dev' } });

shouldBeProtected(res);
});

it('should enforce env query params', async () => {
const { secret } = await seeders.seedAccountEnvAndUser();

const res = await api.fetch(route, {
method: 'GET',
token: secret.secret
});

shouldRequireQueryEnv(res);
});

it('should return environment and account data', async () => {
const { env, account, user } = await seeders.seedAccountEnvAndUser();
const session = await authenticateUser(api, user);

const res = await api.fetch(route, {
method: 'GET',
// @ts-expect-error query params are required
query: { env: env.name },
session
});

expect(res.res.status).toBe(200);
isSuccess(res.json);
expect(res.json).toMatchObject({
environmentAndAccount: {
environment: expect.objectContaining({
name: env.name
}),
env_variables: [],
uuid: account.uuid,
name: account.name,
email: user.email,
slack_notifications_channel: ''
}
});
});

describe('slack_notifications_channel', () => {
let adminProdEnv: Awaited<ReturnType<typeof seeders.createEnvironmentSeed>>;
let originalAdminUUID: string | undefined;

beforeAll(async () => {
const { account: adminAccount } = await seeders.seedAccountEnvAndUser();
adminProdEnv = await seeders.createEnvironmentSeed(adminAccount.id, 'prod');
await seeders.createConfigSeed(adminProdEnv, 'slack', 'slack');
(envs as any).NANGO_ADMIN_UUID = adminAccount.uuid;
});

beforeEach(() => {
originalAdminUUID = envs.NANGO_ADMIN_UUID;
});

afterEach(() => {
(envs as any).NANGO_ADMIN_UUID = originalAdminUUID;
});
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would create the admin account and slack config in beforeAll so it doesn't need to be done in each test.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point done here.


it('should return channel using new ID-based connection', async () => {
const { account: customerAccount, env: customerEnv, user } = await seeders.seedAccountEnvAndUser();
await db.knex('_nango_environments').where({ id: customerEnv.id }).update({ slack_notifications: true });

await seeders.createConnectionSeed({
env: adminProdEnv,
provider: 'slack',
connectionId: `account-${customerAccount.uuid}-${customerEnv.id}`,
connectionConfig: { 'incoming_webhook.channel': '#new-format-alerts' }
});

const session = await authenticateUser(api, user);
const res = await api.fetch(route, {
method: 'GET',
// @ts-expect-error query params are required
query: { env: customerEnv.name },
session
});

expect(res.res.status).toBe(200);
isSuccess(res.json);
expect(res.json.environmentAndAccount.slack_notifications_channel).toBe('#new-format-alerts');
});

it('should return channel using legacy name-based connection', async () => {
const { account: customerAccount, env: customerEnv, user } = await seeders.seedAccountEnvAndUser();
await db.knex('_nango_environments').where({ id: customerEnv.id }).update({ slack_notifications: true });

await seeders.createConnectionSeed({
env: adminProdEnv,
provider: 'slack',
connectionId: `account-${customerAccount.uuid}-${customerEnv.name}`,
connectionConfig: { 'incoming_webhook.channel': '#legacy-format-alerts' }
});

const session = await authenticateUser(api, user);
const res = await api.fetch(route, {
method: 'GET',
// @ts-expect-error query params are required
query: { env: customerEnv.name },
session
});

expect(res.res.status).toBe(200);
isSuccess(res.json);
expect(res.json.environmentAndAccount.slack_notifications_channel).toBe('#legacy-format-alerts');
});
});
});
14 changes: 11 additions & 3 deletions packages/server/lib/controllers/v1/environment/getEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
connectionService,
environmentService,
externalWebhookService,
generateLegacySlackConnectionId,
generateSlackConnectionId,
getGlobalWebhookReceiveUrl,
getWebsocketsPath
Expand Down Expand Up @@ -45,16 +46,23 @@ export const getEnvironment = asyncWrapper<GetEnvironment>(async (req, res) => {

let slack_notifications_channel = '';
if (environment.slack_notifications) {
const connectionId = generateSlackConnectionId(account.uuid, environment.name);
const integrationId = envs.NANGO_SLACK_INTEGRATION_KEY;
const env = 'prod';
const info = await accountService.getAccountAndEnvironmentIdByUUID(envs.NANGO_ADMIN_UUID!, env);
if (info) {
const connectionConfig = await connectionService.getConnectionConfig({
// Try new ID-based connection ID first, fall back to legacy name-based for existing installations
let connectionConfig = await connectionService.getConnectionConfig({
provider_config_key: integrationId,
environment_id: info.environmentId,
connection_id: connectionId
connection_id: generateSlackConnectionId(account.uuid, environment.id)
});
if (!connectionConfig || !connectionConfig['incoming_webhook.channel']) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why !connectionConfig['incoming_webhook.channel']

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bc getConnectionConfig can either return an empty object or a null, see this. From my understanding, I could be wrong here since my JS is a bit rusty, when there is no record in the DB we return an empty object, but when there is a record we just return the connection_config column which can be null.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed this was the original pattern, see this

connectionConfig = await connectionService.getConnectionConfig({
provider_config_key: integrationId,
environment_id: info.environmentId,
connection_id: generateLegacySlackConnectionId(account.uuid, environment.name)
});
}
if (connectionConfig && connectionConfig['incoming_webhook.channel']) {
slack_notifications_channel = connectionConfig['incoming_webhook.channel'];
}
Expand Down
3 changes: 2 additions & 1 deletion packages/shared/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import remoteFileService from './services/file/remote.service.js';
import flowService from './services/flow.service.js';
import hmacService from './services/hmac.service.js';
import { errorNotificationService } from './services/notification/error.service.js';
import { SlackService, generateSlackConnectionId } from './services/notification/slack.service.js';
import { SlackService, generateLegacySlackConnectionId, generateSlackConnectionId } from './services/notification/slack.service.js';
import secretService from './services/secret.service.js';
import sharedCredentialsService from './services/shared-credentials.service.js';
import syncManager, { syncCommandToOperation } from './services/sync/manager.service.js';
Expand Down Expand Up @@ -73,6 +73,7 @@ export {
errorNotificationService,
externalWebhookService,
flowService,
generateLegacySlackConnectionId,
generateSlackConnectionId,
hmacService,
localFileService,
Expand Down
24 changes: 16 additions & 8 deletions packages/shared/lib/services/notification/slack.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ interface PostSlackMessageResponse {
};
}

export const generateSlackConnectionId = (accountUUID: string, environmentName: string) => `account-${accountUUID}-${environmentName}`;
export const generateSlackConnectionId = (accountUUID: string, environmentId: number) => `account-${accountUUID}-${environmentId}`;
export const generateLegacySlackConnectionId = (accountUUID: string, environmentName: string) => `account-${accountUUID}-${environmentName}`;

/**
* _nango_slack_notifications
Expand Down Expand Up @@ -597,14 +598,21 @@ export class SlackService {
return Err('failed_to_get_integration');
}

const slackConnectionId = generateSlackConnectionId(account.uuid, environment.name);
// Try new ID-based connection ID first, fall back to legacy name-based for existing installations
let slackConnectionResult = await connectionService.getConnection(
generateSlackConnectionId(account.uuid, environment.id),
this.integrationKey,
adminRes.environment.id
);
if (slackConnectionResult.error?.type === 'unknown_connection') {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we be consistent and use the same fallback condition here and in getEnvironment

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me know if Im missing something here, but both are not semantically equivalent. While this one just makes a look up to the db for checking if a connection exists for enabling the dual look up (originally there was an expectation that this function should always return a record and should be attributed to the connection slack one, otherwise this would mean that the dataset was corrupted or there was a bug - which is what is happening Today) the getEnvironment one uses the getConnectionConfig which is kinda different.

We could try to use in both cases connectionService.getConnection, but this is what we already had in place before. Also remember that we will remove this dual look up.

slackConnectionResult = await connectionService.getConnection(
generateLegacySlackConnectionId(account.uuid, environment.name),
this.integrationKey,
adminRes.environment.id
);
}

// we get the connection on the nango admin account to be able to send the notification
const {
success: connectionSuccess,
error: slackConnectionError,
response: slackConnection
} = await connectionService.getConnection(slackConnectionId, this.integrationKey, adminRes.environment.id);
const { success: connectionSuccess, error: slackConnectionError, response: slackConnection } = slackConnectionResult;

if (!connectionSuccess || !slackConnection) {
logger.error(slackConnectionError);
Expand Down
3 changes: 2 additions & 1 deletion packages/types/lib/api.endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ import type {
} from './connection/api/get.js';
import type { SetMetadata, UpdateMetadata } from './connection/api/metadata.js';
import type { PostDeploy, PostDeployConfirmation, PostDeployInternal } from './deploy/api.js';
import type { DeleteEnvironment, GetEnvironments, PatchEnvironment, PostEnvironment } from './environment/api/index.js';
import type { DeleteEnvironment, GetEnvironment, GetEnvironments, PatchEnvironment, PostEnvironment } from './environment/api/index.js';
import type { PatchWebhook } from './environment/api/webhook.js';
import type { PostEnvironmentVariables } from './environment/variable/api.js';
import type { PatchFlowDisable, PatchFlowEnable, PatchFlowFrequency, PostPreBuiltDeploy, PutUpgradePreBuiltFlow } from './flow/http.api.js';
Expand Down Expand Up @@ -174,6 +174,7 @@ export type PrivateApiEndpoints =
| PatchEnvironment
| DeleteEnvironment
| GetEnvironments
| GetEnvironment
| PatchWebhook
| PostEnvironmentVariables
| PostImpersonate
Expand Down
18 changes: 16 additions & 2 deletions packages/webapp/src/pages/Environment/Settings/SlackAlerts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,28 @@ export const SlackAlertsSettings: React.FC = () => {
const onFailure = () => {
setSlackIsConnecting(false);
};
await connectSlack({ accountUUID: environmentAndAccount.uuid, env, hostUrl: globalEnv.apiUrl, onFinish, onFailure });
await connectSlack({
accountUUID: environmentAndAccount.uuid,
envId: environmentAndAccount.environment.id,
env,
hostUrl: globalEnv.apiUrl,
onFinish,
onFailure
});
};

const slackDisconnect = async () => {
const res = await apiFetch(`/api/v1/connections/admin/account-${environmentAndAccount?.uuid}-${env}?env=${env}`, {
let res = await apiFetch(`/api/v1/connections/admin/account-${environmentAndAccount?.uuid}-${environmentAndAccount?.environment.id}?env=${env}`, {
method: 'DELETE'
});

// Fall back to legacy name-based connection id for existing installations
if (res.status === 404) {
res = await apiFetch(`/api/v1/connections/admin/account-${environmentAndAccount?.uuid}-${env}?env=${env}`, {
method: 'DELETE'
});
}

if (res.status !== 204) {
toast({ title: 'There was a problem when disconnecting Slack', variant: 'error' });
return;
Expand Down
4 changes: 3 additions & 1 deletion packages/webapp/src/utils/slack-connection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,20 @@ import { apiPatchEnvironment } from '../hooks/useEnvironment';

export const connectSlack = async ({
accountUUID,
envId,
env,
hostUrl,
onFinish,
onFailure
}: {
accountUUID: string;
envId: number;
env: string;
hostUrl: string;
onFinish: () => void;
onFailure: () => void;
}) => {
const connectionId = `account-${accountUUID}-${env}`;
const connectionId = `account-${accountUUID}-${envId}`;

const res = await apiFetch(`/api/v1/environment/admin-auth?connection_id=${connectionId}&env=${env}`, {
method: 'GET'
Expand Down
Loading