Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
61 changes: 60 additions & 1 deletion backend/__tests__/unit/routes/discordBindingContainment.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ jest.mock('../../../models/Integration', () => {

function Integration(data) {
Object.assign(this, data);
this._id = data._id || 'integration-new';
this._id = data._id || '6a8f6de1a1dccf2e02f31459';
this.save = jest.fn().mockResolvedValue(this);
lastInstance = this;
}
Expand Down Expand Up @@ -94,6 +94,18 @@ jest.mock('../../../services/discordMultiCommandService', () => ({
runDiscordCommandForIntegrations: jest.fn(),
}));

// The create path encrypts the webhook URL. This suite is about binding
// containment, not the envelope, so the envelope is stubbed here — leaving it
// real would have the route await a Mongo write against no connection (the
// suites that own that behaviour are integration.discordTokenCopy and the
// connectorSecrets unit file).
jest.mock('../../../services/connectorSecrets', () => ({
put: jest.fn(async () => 'secret-ref-1'),
get: jest.fn(),
revoke: jest.fn(),
listWithUnavailableKey: jest.fn(),
}));

const axios = require('axios');
const Pod = require('../../../models/Pod');
const User = require('../../../models/User');
Expand Down Expand Up @@ -140,12 +152,24 @@ const podIdsQuery = (rows) => {
};

describe('Discord binding containment (TASK-123 a)', () => {
const savedKeys = process.env.CONNECTOR_SECRET_KEYS;
const savedActiveKey = process.env.CONNECTOR_SECRET_ACTIVE_KEY;

afterAll(() => {
process.env.CONNECTOR_SECRET_KEYS = savedKeys;
process.env.CONNECTOR_SECRET_ACTIVE_KEY = savedActiveKey;
});

beforeEach(() => {
jest.clearAllMocks();
process.env.FRONTEND_URL = 'https://app.example.test';
process.env.DISCORD_BOT_TOKEN = 'bot-secret';
process.env.DISCORD_CLIENT_ID = 'client-id';
process.env.DISCORD_CLIENT_SECRET = 'client-secret';
// The create path encrypts the webhook URL (TASK-124 part 2), so a connector
// secret ring is part of the deployment this route runs against.
process.env.CONNECTOR_SECRET_KEYS = `k1:${Buffer.alloc(32, 1).toString('base64')}`;
process.env.CONNECTOR_SECRET_ACTIVE_KEY = 'k1';
podIdsQuery([{ _id: 'pod-1' }]);
Pod.findById.mockResolvedValue({ _id: 'pod-1', members: [MEMBER], createdBy: MEMBER });
User.findById.mockResolvedValue({ _id: MEMBER, role: 'member' });
Expand Down Expand Up @@ -333,6 +357,12 @@ describe('Discord binding containment (TASK-123 a)', () => {

expect(res.status).toBe(201);
expect(Integration.__getLastInstance().config).not.toHaveProperty('botToken');
// The URL the create path derived is encrypted, not written to the row: the
// strip above must not have eaten the ref the reader resolves through.
expect(Integration.findByIdAndUpdate).toHaveBeenCalledWith(
expect.anything(),
{ $set: { 'config.webhookUrlRef': 'secret-ref-1' } },
);
expect(axios.post).toHaveBeenCalledWith(
`https://discord.com/api/channels/${CHANNEL}/webhooks`,
{ name: 'Commonly Bot', avatar: null },
Expand Down Expand Up @@ -410,8 +440,37 @@ describe('Discord binding containment (TASK-123 a)', () => {
expect(stored.config).not.toHaveProperty('botToken');
expect(stored.config.channelId).toBe('C012345');
});

it('drops a caller-supplied webhookUrl and webhookUrlRef from the stored config', async () => {
// Both names join the server-owned list with this change: the URL is a
// bearer credential the server derives from the Discord API on both
// writers, and the ref is a pointer a caller must not be able to aim at
// another row's secret. A planted `config.webhookUrl` would also be picked
// up as the resolver's legacy fallback.
const res = await post({
podId: 'pod-1',
type: 'discord',
config: {
serverId: GUILD,
channelId: CHANNEL,
webhookUrl: 'https://discord.com/api/webhooks/999/caller-token',
webhookUrlRef: 'somebody-elses-ref',
},
});

expect(res.status).toBe(201);
const stored = Integration.__getLastInstance();
expect(stored.config).not.toHaveProperty('webhookUrl');
expect(stored.config).not.toHaveProperty('webhookUrlRef');
// The ref on the row is the one the server wrote from the envelope.
expect(Integration.findByIdAndUpdate).toHaveBeenCalledWith(
expect.anything(),
{ $set: { 'config.webhookUrlRef': 'secret-ref-1' } },
);
});
});


describe('PATCH /api/integrations/:id', () => {
const patch = (body) => request(app)
.patch('/api/integrations/64b64c7f8a9e2f0012345678')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const DiscordIntegration = require('../../../models/DiscordIntegration');
const Pod = require('../../../models/Pod');
const User = require('../../../models/User');
const integrationRoutes = require('../../../routes/integrations');
const { get: getConnectorSecret } = require('../../../services/connectorSecrets');

const GUILD = '123456789012345678';
const CHANNEL = '123456789012345679';
Expand All @@ -57,6 +58,8 @@ describe('discord connector stores no bot token copy (TASK-124)', () => {
let pod;
let creator;
const savedEnv = process.env.DISCORD_BOT_TOKEN;
const savedKeys = process.env.CONNECTOR_SECRET_KEYS;
const savedActiveKey = process.env.CONNECTOR_SECRET_ACTIVE_KEY;

const post = (body) => request(app)
.post('/api/integrations')
Expand All @@ -77,6 +80,10 @@ describe('discord connector stores no bot token copy (TASK-124)', () => {

beforeEach(async () => {
process.env.DISCORD_BOT_TOKEN = ENV_TOKEN;
// The create path now encrypts the webhook URL, so the key ring is part of
// the deployment it runs against — the same dependency the Slack bind has.
process.env.CONNECTOR_SECRET_KEYS = `k1:${Buffer.alloc(32, 1).toString('base64')}`;
process.env.CONNECTOR_SECRET_ACTIVE_KEY = 'k1';
await Integration.deleteMany({});
await DiscordIntegration.deleteMany({});
axios.post.mockReset();
Expand All @@ -88,6 +95,11 @@ describe('discord connector stores no bot token copy (TASK-124)', () => {
else process.env.DISCORD_BOT_TOKEN = savedEnv;
});

afterAll(() => {
process.env.CONNECTOR_SECRET_KEYS = savedKeys;
process.env.CONNECTOR_SECRET_ACTIVE_KEY = savedActiveKey;
});

it('creates the connector without persisting the token, and the row still saves', async () => {
const res = await post({
podId: String(pod._id),
Expand All @@ -104,8 +116,16 @@ describe('discord connector stores no bot token copy (TASK-124)', () => {
// so the absence assertion below cannot pass on a row that was never made.
expect(row).not.toBeNull();
expect(row.webhookId).toBe('wh-1');
expect(row.webhookUrl).toContain('/wh-1/');
expect(row.serverId).toBe(GUILD);
// The claim this change adds: the URL the create path derived is encrypted,
// not stored. No plaintext on the platform row, the ref on the Integration
// row, and the ref resolves back to the URL the Discord API returned — so
// "it is gone from the row" is not the same as "it is gone".
expect(row.webhookUrl).toBeUndefined();
const integrationRow = await Integration.findById(row.integrationId).lean();
expect(integrationRow.config.webhookUrlRef).toBeTruthy();
expect(await getConnectorSecret(integrationRow.config.webhookUrlRef))
.toBe('https://discord.com/api/webhooks/wh-1/wh-token');
// The claim: no copy of the instance-wide token is stored on the row.
expect(row.botToken).toBeUndefined();
});
Expand All @@ -122,6 +142,9 @@ describe('discord connector stores no bot token copy (TASK-124)', () => {
expect(res.status).toBe(201);
expect(res.body.platformIntegration).not.toHaveProperty('botToken');
expect(res.body.platformIntegration).not.toHaveProperty('webhookUrl');
// The pointer to the encrypted URL is a secret-config key, so it does not
// ride out either.
expect(res.body.integration.config).not.toHaveProperty('webhookUrlRef');
});

it('still requires a token to connect: with the env var absent the create is refused', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,13 @@ describe('Slack installable OAuth routes', () => {
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');
// The kind spec, not a bare provider string: it is the one definition of what
// this secret is and where its ref lives (TASK-124 part 2).
expect(connectorSecrets.put).toHaveBeenCalledWith(
'integration-1',
expect.objectContaining({ kind: 'slack-bot-token', provider: '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');
Expand Down
Loading
Loading