Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,20 @@ jobs:
test "$(yq 'select(.kind == "Deployment" and .metadata.name == "backend") | .spec.template.spec.containers[] | select(.name == "backend") | .readinessProbe.httpGet.path' /tmp/dev-rendered.yaml)" = "/api/health/ready"
test "$(yq 'select(.kind == "Deployment" and .metadata.name == "backend") | .spec.template.spec.containers[] | select(.name == "backend") | .lifecycle.preStop.exec.command[2]' /tmp/dev-rendered.yaml)" = "sleep 10"

- name: Render Slack OAuth secret wiring
run: |
helm template commonly-slack k8s/helm/commonly \
-f k8s/helm/commonly/values.yaml \
-f k8s/helm/commonly/values-dev.yaml \
--set slack.oauth.enabled=true \
> /tmp/slack-rendered.yaml
for key in slack-client-id slack-client-secret slack-signing-secret connector-secret-keys connector-secret-active-key; do
test "$(yq 'select(.kind == "ExternalSecret" and .metadata.name == "api-keys") | .spec.data[].secretKey' /tmp/slack-rendered.yaml | grep -Fx "$key")" = "$key"
done
for name in SLACK_CLIENT_ID SLACK_CLIENT_SECRET SLACK_SIGNING_SECRET CONNECTOR_SECRET_KEYS CONNECTOR_SECRET_ACTIVE_KEY; do
test "$(yq 'select(.kind == "Deployment" and .metadata.name == "backend") | .spec.template.spec.containers[] | select(.name == "backend") | .env[].name' /tmp/slack-rendered.yaml | grep -Fx "$name")" = "$name"
done

- name: Render and lint prod chart (if values-prod.yaml exists)
run: |
if [ -f k8s/helm/commonly/values-prod.yaml ]; then
Expand Down
67 changes: 67 additions & 0 deletions backend/__tests__/unit/routes/slack.webhook.signature.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
const crypto = require('crypto');
const request = require('supertest');
const express = require('express');

jest.mock('../../../models/Integration', () => ({ findOne: jest.fn(), findById: jest.fn() }));
jest.mock('../../../integrations', () => ({ get: jest.fn() }));
jest.mock('../../../services/slackEventReceiptService', () => ({ claim: jest.fn(), markDone: jest.fn() }));
jest.mock('../../../services/slackBridgeService', () => ({ relaySlackMessageToPod: jest.fn() }));

const receipts = require('../../../services/slackEventReceiptService');
const Integration = require('../../../models/Integration');
const slackRoutes = require('../../../routes/webhooks/slack');

const app = express();
app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf.toString(); } }));
app.use('/api/webhooks/slack', slackRoutes);

const signingSecret = 'slack-signing-secret';
const eventBody = {
event_id: 'Ev1',
team_id: 'T1',
event: {
type: 'message', channel_type: 'im', channel: 'D1', user: 'U1', text: 'hello',
},
};
const signatureHeaders = (body, timestamp = Math.floor(Date.now() / 1000)) => {
const raw = JSON.stringify(body);
const signature = `v0=${crypto.createHmac('sha256', signingSecret).update(`v0:${timestamp}:${raw}`).digest('hex')}`;
return { 'X-Slack-Request-Timestamp': String(timestamp), 'X-Slack-Signature': signature };
};

describe('installable Slack webhook signature and acknowledgement', () => {
beforeEach(() => {
process.env.SLACK_SIGNING_SECRET = signingSecret;
jest.clearAllMocks();
});
afterAll(() => delete process.env.SLACK_SIGNING_SECRET);

test('rejects invalid signatures before receipt creation', async () => {
const response = await request(app).post('/api/webhooks/slack/events').send(eventBody);
expect(response.status).toBe(401);
expect(receipts.claim).not.toHaveBeenCalled();
});

test('drops non-DM events before they create a receipt or resolve a connector', async () => {
const body = { ...eventBody, event: { ...eventBody.event, channel_type: 'channel' } };
const response = await request(app).post('/api/webhooks/slack/events').set(signatureHeaders(body)).send(body);
expect(response.status).toBe(200);
expect(receipts.claim).not.toHaveBeenCalled();
expect(Integration.findOne).not.toHaveBeenCalled();
});

test('acknowledges a claimed DM event and resolves only its bound team/channel', async () => {
receipts.claim.mockResolvedValue('claimed');
receipts.markDone.mockResolvedValue(undefined);
Integration.findOne.mockReturnValue({ lean: jest.fn().mockResolvedValue(null) });

const response = await request(app).post('/api/webhooks/slack/events').set(signatureHeaders(eventBody)).send(eventBody);
expect(response.status).toBe(200);
await new Promise((resolve) => setImmediate(resolve));
expect(receipts.claim).toHaveBeenCalledWith('Ev1', 'T1');
expect(Integration.findOne).toHaveBeenCalledWith(expect.objectContaining({
type: 'slack', 'config.teamId': 'T1', 'config.chatId': 'D1', 'config.chatType': 'im', isActive: true,
}));
expect(receipts.markDone).toHaveBeenCalledWith('Ev1');
});
});
296 changes: 296 additions & 0 deletions backend/__tests__/unit/routes/slackOAuth.installables.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,296 @@
const request = require('supertest');
const express = require('express');
const TEST_PUBLIC_APP_URL = 'https://connectors.example.test';
const originalPublicAppUrl = process.env.PUBLIC_APP_URL;
const originalFrontendUrl = process.env.FRONTEND_URL;

jest.mock('../../../middleware/auth', () => (req, res, next) => {
req.user = { id: '64b64c48c4f37a6b2f34c111' };
next();
});
jest.mock('../../../middleware/integrationRateLimit', () => ({
writeIntegrationsRateLimit: (_req, _res, next) => next(),
}));
jest.mock('../../../models/Pod', () => ({ findById: jest.fn() }));
jest.mock('../../../models/Integration', () => ({
findOne: jest.fn(), findOneAndUpdate: jest.fn(), updateOne: jest.fn(),
}));
jest.mock('../../../models/InstallableInstallation', () => ({ findOne: jest.fn() }));
jest.mock('../../../utils/secret', () => ({ randomSecret: jest.fn(() => 'nonce-value') }));
jest.mock('../../../services/telegramConnectCode', () => ({
mintConnectCode: jest.fn(() => ({ connectCode: 'r'.repeat(32), connectCodeExpiresAt: new Date(Date.now() + 60_000) })),
}));
jest.mock('../../../services/connectorSecrets', () => ({ get: jest.fn(), put: jest.fn(), revoke: jest.fn() }));
jest.mock('../../../services/slackApi', () => jest.fn().mockImplementation(() => ({
openConversation: jest.fn(),
})));
jest.mock('../../../services/slackOAuthService', () => {
class SlackOAuthConfigurationError extends Error { constructor() { super('not configured'); this.code = 'slack_oauth_not_configured'; } }
class SlackOAuthExchangeError extends Error { constructor() { super('exchange failed'); this.code = 'slack_oauth_exchange_failed'; } }
return {
SlackOAuthConfigurationError,
SlackOAuthExchangeError,
buildAuthorizeUrl: jest.fn((state) => `https://slack.test/authorize?state=${state}`),
exchangeCode: jest.fn(),
};
});
jest.mock('../../../services/installable/installableInstallationService', () => ({
install: jest.fn(), uninstall: jest.fn(),
InstallLockLostError: class InstallLockLostError extends Error {},
InstallableAlreadyInstalledError: class InstallableAlreadyInstalledError extends Error {},
InstallableNotFoundError: class InstallableNotFoundError extends Error {},
InstallableProjectionError: class InstallableProjectionError extends Error {},
InstallInProgressError: class InstallInProgressError extends Error {},
}));

const Integration = require('../../../models/Integration');
const Pod = require('../../../models/Pod');
const InstallableInstallation = require('../../../models/InstallableInstallation');
const connectorSecrets = require('../../../services/connectorSecrets');
const { mintConnectCode } = require('../../../services/telegramConnectCode');
const SlackApi = require('../../../services/slackApi');
const slackOAuth = require('../../../services/slackOAuthService');
const installableRoutes = require('../../../routes/installables');

const app = express();
app.use(express.json());
app.use('/api/installables', installableRoutes);
app.use('/api/webhooks/slack/oauth', installableRoutes.slackOAuthCallbackRouter);

const ownerId = '64b64c48c4f37a6b2f34c111';
const integration = {
_id: 'integration-1',
installationId: 'install-1',
type: 'slack',
isActive: true,
podId: '64b64c48c4f37a6b2f34c222',
config: {
connectCode: 'c'.repeat(32),
connectCodeExpiresAt: new Date(Date.now() + 60_000),
oauthStateNonce: 'nonce-value',
},
};

const own = () => {
InstallableInstallation.findOne.mockResolvedValue({ _id: 'install-1', targetId: ownerId, status: 'active' });
Integration.findOne.mockResolvedValue({ ...integration, config: { ...integration.config } });
};

describe('Slack installable OAuth routes', () => {
beforeAll(() => {
process.env.PUBLIC_APP_URL = TEST_PUBLIC_APP_URL;
delete process.env.FRONTEND_URL;
});

afterAll(() => {
if (originalPublicAppUrl === undefined) delete process.env.PUBLIC_APP_URL;
else process.env.PUBLIC_APP_URL = originalPublicAppUrl;
if (originalFrontendUrl === undefined) delete process.env.FRONTEND_URL;
else process.env.FRONTEND_URL = originalFrontendUrl;
});

beforeEach(() => {
jest.clearAllMocks();
own();
});

test('mints a browser-bound nonce and sends only the lifecycle code as OAuth state', async () => {
Integration.findOneAndUpdate.mockResolvedValue({ ...integration });
const response = await request(app).post('/api/installables/slack/authorize-url');

expect(response.status).toBe(200);
expect(response.body.authorizeUrl).toContain(`state=${integration.config.connectCode}`);
expect(response.headers['set-cookie'][0]).toContain('commonly_slack_oauth_nonce=nonce-value');
expect(Integration.findOneAndUpdate).toHaveBeenCalledWith(
expect.objectContaining({ _id: 'integration-1', 'config.connectCode': integration.config.connectCode }),
expect.objectContaining({
$set: expect.objectContaining({
'config.oauthStateNonce': 'nonce-value',
}),
}),
expect.anything(),
);
});

test('re-mints an expired Slack OAuth state without disconnecting the install', async () => {
const expired = {
...integration,
config: { ...integration.config, connectCodeExpiresAt: new Date(Date.now() - 1_000) },
};
Integration.findOne.mockResolvedValue(expired);
Integration.findOneAndUpdate
.mockResolvedValueOnce({ ...integration, config: { ...integration.config, connectCode: 'r'.repeat(32) } })
.mockResolvedValueOnce({ ...integration, config: { ...integration.config, connectCode: 'r'.repeat(32) } });

const response = await request(app).post('/api/installables/slack/authorize-url');

expect(response.status).toBe(200);
expect(mintConnectCode).toHaveBeenCalledTimes(1);
expect(slackOAuth.buildAuthorizeUrl).toHaveBeenCalledWith('r'.repeat(32));
expect(Integration.findOneAndUpdate.mock.calls[0]).toEqual([
expect.objectContaining({ _id: 'integration-1', type: 'slack', isActive: true }),
expect.objectContaining({ $set: expect.objectContaining({ 'config.connectCode': 'r'.repeat(32) }) }),
expect.anything(),
]);
});

test('refuses a callback without its browser nonce before Slack exchange', async () => {
const response = await request(app)
.get(`/api/webhooks/slack/oauth/callback?state=${integration.config.connectCode}&code=slack-code`);

expect(response.status).toBe(302);
expect(slackOAuth.exchangeCode).not.toHaveBeenCalled();
expect(Integration.findOneAndUpdate).not.toHaveBeenCalled();
expect(response.headers.location).toBe(`${TEST_PUBLIC_APP_URL}/v2/connectors?slack=error&code=invalid_state`);
});

test('refuses a callback with a different-length browser nonce before Slack exchange', async () => {
const response = await request(app)
.get(`/api/webhooks/slack/oauth/callback?state=${integration.config.connectCode}&code=slack-code`)
.set('Cookie', 'commonly_slack_oauth_nonce=short');

expect(slackOAuth.exchangeCode).not.toHaveBeenCalled();
expect(Integration.findOneAndUpdate).not.toHaveBeenCalled();
expect(response.status).toBe(302);
expect(response.headers.location).toBe(`${TEST_PUBLIC_APP_URL}/v2/connectors?slack=error&code=invalid_state`);
});

test('stores only a secret reference in a pending bind after a claimed callback', async () => {
Integration.findOneAndUpdate
.mockResolvedValueOnce({ ...integration })
.mockResolvedValueOnce({ ...integration, config: { pendingBind: { botTokenRef: 'secret-ref' } } });
slackOAuth.exchangeCode.mockResolvedValue({
accessToken: 'xoxb-never-store-on-integration',
teamId: 'T1', teamName: 'Example', slackUserId: 'U1', slackUserName: 'sam',
});
SlackApi.mock.instances[0]?.openConversation?.mockResolvedValue({ ok: true, channel: { id: 'D1' } });
// Constructor instance is created inside the handler; set its method via
// the default implementation's return object after construction below.
SlackApi.mockImplementationOnce(() => ({ openConversation: jest.fn().mockResolvedValue({ ok: true, channel: { id: 'D1' } }) }));
connectorSecrets.put.mockResolvedValue('secret-ref');

const response = await request(app)
.get(`/api/webhooks/slack/oauth/callback?state=${integration.config.connectCode}&code=slack-code`)
.set('Cookie', 'commonly_slack_oauth_nonce=nonce-value');

expect(response.status).toBe(302);
expect(response.headers.location).toBe(`${TEST_PUBLIC_APP_URL}/v2/connectors?slack=pending`);
expect(slackOAuth.exchangeCode).toHaveBeenCalledWith('slack-code');
expect(connectorSecrets.put).toHaveBeenCalledWith('integration-1', 'slack', 'xoxb-never-store-on-integration');
const [, commit] = Integration.findOneAndUpdate.mock.calls[1];
expect(commit.$set['config.pendingBind']).toMatchObject({ teamId: 'T1', chatId: 'D1', botTokenRef: 'secret-ref' });
expect(JSON.stringify(commit)).not.toContain('xoxb-never-store-on-integration');
});

test('refuses a replayed browser nonce when its row already has a pending bind', async () => {
Integration.findOne
.mockResolvedValueOnce({ ...integration, config: { ...integration.config } })
// A replay sees the same row after the first callback has committed its
// pending bind. The selector below must exclude it, so Mongo returns no
// candidate rather than exposing its consumed nonce.
.mockResolvedValueOnce(null);
Integration.findOneAndUpdate
.mockResolvedValueOnce({ ...integration })
.mockResolvedValueOnce({ ...integration, config: { pendingBind: { botTokenRef: 'secret-ref' } } });
slackOAuth.exchangeCode.mockResolvedValue({
accessToken: 'xoxb-never-store-on-integration',
teamId: 'T1', teamName: 'Example', slackUserId: 'U1', slackUserName: 'sam',
});
SlackApi.mockImplementationOnce(() => ({ openConversation: jest.fn().mockResolvedValue({ ok: true, channel: { id: 'D1' } }) }));
connectorSecrets.put.mockResolvedValue('secret-ref');

const first = await request(app)
.get(`/api/webhooks/slack/oauth/callback?state=${integration.config.connectCode}&code=slack-code`)
.set('Cookie', 'commonly_slack_oauth_nonce=nonce-value');
const exchangesBeforeReplay = slackOAuth.exchangeCode.mock.calls.length;
const writesBeforeReplay = Integration.findOneAndUpdate.mock.calls.length;
const replay = await request(app)
.get(`/api/webhooks/slack/oauth/callback?state=${integration.config.connectCode}&code=slack-code`)
.set('Cookie', 'commonly_slack_oauth_nonce=nonce-value');

expect(Integration.findOne.mock.calls[1][0]).toEqual(expect.objectContaining({
'config.pendingBind': { $exists: false },
}));
expect(slackOAuth.exchangeCode).toHaveBeenCalledTimes(exchangesBeforeReplay);
expect(Integration.findOneAndUpdate).toHaveBeenCalledTimes(writesBeforeReplay);
expect(first.headers.location).toBe(`${TEST_PUBLIC_APP_URL}/v2/connectors?slack=pending`);
expect(replay.status).toBe(302);
expect(replay.headers.location).toBe(`${TEST_PUBLIC_APP_URL}/v2/connectors?slack=error&code=invalid_state`);
expect(exchangesBeforeReplay).toBe(1);
expect(writesBeforeReplay).toBe(2);
});

test('confirms only the owner binding and never serializes secret material', async () => {
const pending = {
teamId: 'T1', teamName: 'Example', slackUserId: 'U1', slackUserName: 'sam',
chatId: 'D1', botTokenRef: 'secret-ref', expiresAt: new Date(Date.now() + 60_000),
};
Integration.findOne.mockResolvedValue({ ...integration, config: { pendingBind: pending } });
Pod.findById
.mockResolvedValueOnce({ createdBy: { toString: () => 'another' }, members: [ownerId] })
.mockReturnValueOnce({
select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue({ name: 'Launch' }) }),
});
Integration.findOneAndUpdate.mockResolvedValue({
...integration,
status: 'connected',
// Plain-object return deliberately bypasses Integration.toJSON(), as a
// lean query would. The route backstop must still strip both values.
config: { ...pending, botTokenRef: 'secret-ref', oauthStateNonce: 'nonce-never-return', chatType: 'im' },
});
connectorSecrets.get.mockResolvedValue('xoxb-secret');
SlackApi.mockImplementationOnce(() => ({ postMessage: jest.fn().mockResolvedValue({ ok: true, ts: '1.1' }) }));

const response = await request(app).post('/api/installables/slack/confirm');

expect(response.status).toBe(200);
expect(Integration.findOneAndUpdate).toHaveBeenCalledWith(
expect.objectContaining({ _id: 'integration-1', 'config.pendingBind.botTokenRef': 'secret-ref' }),
expect.objectContaining({ $set: expect.objectContaining({ 'config.chatType': 'im', 'config.botTokenRef': 'secret-ref' }) }),
expect.anything(),
);
expect(JSON.stringify(response.body)).not.toContain('secret-ref');
expect(JSON.stringify(response.body)).not.toContain('nonce-never-return');
expect(SlackApi.mock.results[0].value.postMessage).toHaveBeenCalledWith('D1', '[Launch] connected');
});

test('rejects a pending Slack bind and revokes its secret reference', async () => {
const pending = {
teamId: 'T1', slackUserId: 'U1', chatId: 'D1', botTokenRef: 'secret-ref',
expiresAt: new Date(Date.now() + 60_000),
};
Integration.findOne.mockResolvedValue({ ...integration, config: { pendingBind: pending } });
Integration.findOneAndUpdate.mockResolvedValue({ ...integration, config: {} });

const response = await request(app).post('/api/installables/slack/reject');

expect(response.status).toBe(200);
expect(response.body).toEqual({ status: 'rejected' });
expect(Integration.findOneAndUpdate).toHaveBeenCalledWith(
expect.objectContaining({ _id: 'integration-1', 'config.pendingBind.botTokenRef': 'secret-ref' }),
{ $unset: { 'config.pendingBind': 1 } },
expect.anything(),
);
expect(connectorSecrets.revoke).toHaveBeenCalledWith('secret-ref');
});

test('expires a stale pending bind back to active pending, not an invisible inactive row', async () => {
const pending = {
teamId: 'T1', slackUserId: 'U1', chatId: 'D1', botTokenRef: 'secret-ref',
expiresAt: new Date(Date.now() - 60_000),
};
Integration.findOne.mockResolvedValue({ ...integration, config: { pendingBind: pending } });
Integration.findOneAndUpdate.mockResolvedValue({ ...integration, status: 'pending', isActive: true, config: {} });

const response = await request(app).post('/api/installables/slack/confirm');

expect(response.status).toBe(409);
expect(response.body.code).toBe('slack_bind_expired');
expect(Integration.findOneAndUpdate).toHaveBeenCalledWith(
expect.objectContaining({ _id: 'integration-1', 'config.pendingBind.botTokenRef': 'secret-ref' }),
expect.objectContaining({ $set: { status: 'pending', errorMessage: null } }),
expect.anything(),
);
expect(connectorSecrets.revoke).toHaveBeenCalledWith('secret-ref');
});
});
Loading
Loading