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
63 changes: 63 additions & 0 deletions backend/__tests__/unit/integrations/manifestReadiness.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
const { manifests } = require('../../../integrations/manifests');

const READINESS_ENV = [
'TELEGRAM_BOT_TOKEN',
'TELEGRAM_SECRET_TOKEN',
'TELEGRAM_WEBHOOK_ALLOW_UNVERIFIED',
'SLACK_CLIENT_ID',
'SLACK_CLIENT_SECRET',
'SLACK_SIGNING_SECRET',
'CONNECTOR_SECRET_KEYS',
'CONNECTOR_SECRET_ACTIVE_KEY',
];

describe('installable connector manifest readiness', () => {
const original = {};

beforeAll(() => {
READINESS_ENV.forEach((key) => { original[key] = process.env[key]; });
});

beforeEach(() => {
READINESS_ENV.forEach((key) => { delete process.env[key]; });
});

afterAll(() => {
READINESS_ENV.forEach((key) => {
if (original[key] === undefined) delete process.env[key];
else process.env[key] = original[key];
});
});

it('uses only a safe enum when Slack is not configured', () => {
expect(manifests.slack.readiness()).toEqual({ available: false, reason: 'not_configured' });
});

it('requires every Slack runtime secret before marking the provider available', () => {
const required = [
'SLACK_CLIENT_ID',
'SLACK_CLIENT_SECRET',
'SLACK_SIGNING_SECRET',
'CONNECTOR_SECRET_KEYS',
'CONNECTOR_SECRET_ACTIVE_KEY',
];
required.forEach((key) => { process.env[key] = 'configured'; });

expect(manifests.slack.readiness()).toEqual({ available: true });

delete process.env.CONNECTOR_SECRET_ACTIVE_KEY;
expect(manifests.slack.readiness()).toEqual({ available: false, reason: 'not_configured' });
});

it('accepts Telegram only with its secret token or the explicit unverified override', () => {
process.env.TELEGRAM_BOT_TOKEN = 'configured';
expect(manifests.telegram.readiness()).toEqual({ available: false, reason: 'not_configured' });

process.env.TELEGRAM_SECRET_TOKEN = 'configured';
expect(manifests.telegram.readiness()).toEqual({ available: true });

delete process.env.TELEGRAM_SECRET_TOKEN;
process.env.TELEGRAM_WEBHOOK_ALLOW_UNVERIFIED = 'true';
expect(manifests.telegram.readiness()).toEqual({ available: true });
});
});
61 changes: 61 additions & 0 deletions backend/__tests__/unit/routes/installables.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,27 @@ jest.mock('../../../middleware/auth', () => (req, res, next) => {

jest.mock('../../../middleware/integrationRateLimit', () => ({
writeIntegrationsRateLimit: (_req, _res, next) => next(),
listIntegrationsRateLimit: (_req, _res, next) => next(),
}));

jest.mock('../../../models/Pod', () => ({ findById: jest.fn() }));
jest.mock('../../../services/installable/installableCatalogService', () => ({
catalogFor: jest.fn(),
providerReadiness: jest.fn(() => ({ available: true })),
publicIntegration: jest.fn((integration) => {
if (!integration?.config?.adminPause) return integration;
return {
...integration,
config: {
...integration.config,
adminPause: {
reason: integration.config.adminPause.reason,
at: integration.config.adminPause.at,
},
},
};
}),
}));
jest.mock('../../../services/installable/installableInstallationService', () => ({
install: jest.fn(),
uninstall: jest.fn(),
Expand All @@ -33,6 +51,8 @@ jest.mock('../../../services/installable/installableInstallationService', () =>
}));

const Pod = require('../../../models/Pod');
const integrationRateLimit = require('../../../middleware/integrationRateLimit');
const catalogService = require('../../../services/installable/installableCatalogService');
const installationService = require('../../../services/installable/installableInstallationService');
const installableRoutes = require('../../../routes/installables');

Expand All @@ -46,6 +66,47 @@ const podId = '64b64c48c4f37a6b2f34c222';
describe('installable connector routes', () => {
beforeEach(() => {
jest.clearAllMocks();
catalogService.providerReadiness.mockReturnValue({ available: true });
});

it('requires auth for the user-scoped catalog', async () => {
const res = await request(app).get('/api/installables');

expect(res.status).toBe(401);
expect(catalogService.catalogFor).not.toHaveBeenCalled();
});

it('mounts the catalog limiter before auth', () => {
const catalogRoute = installableRoutes.stack.find((layer) => layer.route
&& layer.route.path === '/'
&& layer.route.methods.get);
const catalogHandles = catalogRoute.route.stack.map((layer) => layer.handle);
expect(catalogHandles[0]).toBe(integrationRateLimit.listIntegrationsRateLimit);
});

it('returns readiness enums and only the caller\'s safe parent/projection fields', async () => {
const installationId = '64b64c48c4f37a6b2f34c444';
catalogService.catalogFor.mockResolvedValue({ installables: [{ installationId }] });

const res = await request(app).get('/api/installables').set(auth);

expect(res.status).toBe(200);
expect(res.body).toEqual({ installables: [{ installationId }] });
expect(catalogService.catalogFor).toHaveBeenCalledWith('64b64c48c4f37a6b2f34c111');
});

it('refuses an unavailable provider before reading a pod or claiming a parent', async () => {
catalogService.providerReadiness.mockReturnValue({ available: false, reason: 'not_configured' });

const res = await request(app)
.post('/api/installables/slack/install')
.set(auth)
.send({ podId });

expect(res.status).toBe(422);
expect(res.body).toEqual({ code: 'provider_not_configured' });
expect(Pod.findById).not.toHaveBeenCalled();
expect(installationService.install).not.toHaveBeenCalled();
});

it('rejects a non-member before any install row is claimed', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ jest.mock('../../../middleware/auth', () => (req, res, next) => {
});
jest.mock('../../../middleware/integrationRateLimit', () => ({
writeIntegrationsRateLimit: (_req, _res, next) => next(),
listIntegrationsRateLimit: (_req, _res, next) => next(),
}));
jest.mock('../../../models/Pod', () => ({ findById: jest.fn() }));
jest.mock('../../../models/Integration', () => ({
Expand Down
103 changes: 103 additions & 0 deletions backend/__tests__/unit/services/installableCatalogService.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
jest.mock('../../../models/Installable', () => ({ find: jest.fn() }));
jest.mock('../../../models/InstallableInstallation', () => ({ find: jest.fn() }));
jest.mock('../../../models/Integration', () => ({ find: jest.fn() }));
jest.mock('../../../integrations/manifests', () => ({
manifests: {
telegram: { id: 'telegram', readiness: jest.fn(() => ({ available: true })) },
slack: { id: 'slack', readiness: jest.fn(() => ({ available: false, reason: 'not_configured' })) },
},
}));

const Installable = require('../../../models/Installable');
const InstallableInstallation = require('../../../models/InstallableInstallation');
const Integration = require('../../../models/Integration');
const { catalogFor } = require('../../../services/installable/installableCatalogService');

const userId = '64b64c48c4f37a6b2f34c111';
const installationId = '64b64c48c4f37a6b2f34c222';
const podId = '64b64c48c4f37a6b2f34c333';
const lean = (value) => ({ lean: jest.fn().mockResolvedValue(value) });

describe('installable catalog service', () => {
beforeEach(() => jest.clearAllMocks());

it('returns provider readiness and the caller parent without leaking private fields', async () => {
Installable.find.mockReturnValue(lean([
{ installableId: 'telegram', name: 'Telegram', description: 'Telegram description' },
{ installableId: 'slack', name: 'Slack', description: 'Slack description' },
]));
InstallableInstallation.find.mockReturnValue(lean([{
_id: installationId,
installableId: 'telegram',
status: 'error',
errorMessage: 'projection missing',
targetId: 'another-user',
installedBy: 'another-user',
claimId: 'private-claim',
boundPodId: podId,
claimedAt: '2026-09-05T00:00:00.000Z',
updatedAt: '2026-09-05T00:01:00.000Z',
components: [{ componentName: 'telegram-webhook', status: 'stale' }],
}]));
Integration.find.mockReturnValue(lean([{
installationId,
type: 'telegram',
config: {
botTokenRef: 'secret-ref',
oauthStateNonce: 'nonce',
chatTitle: 'Ops',
adminPause: {
reason: 'Safety review in progress.',
at: '2026-09-05T08:48:00.000Z',
adminId: 'admin-private-id',
},
},
}]));

const catalog = await catalogFor(userId);

expect(catalog.installables).toEqual([
expect.objectContaining({
installableId: 'telegram',
available: true,
installation: {
status: 'error',
errorMessage: 'projection missing',
boundPodId: podId,
claimedAt: '2026-09-05T00:00:00.000Z',
updatedAt: '2026-09-05T00:01:00.000Z',
components: [{ name: 'telegram-webhook', status: 'stale' }],
},
integration: expect.objectContaining({
installationId,
config: {
chatTitle: 'Ops',
adminPause: {
reason: 'Safety review in progress.',
at: '2026-09-05T08:48:00.000Z',
},
},
}),
}),
expect.objectContaining({
installableId: 'slack',
available: false,
unavailableReason: 'not_configured',
installation: null,
integration: null,
}),
]);
expect(InstallableInstallation.find).toHaveBeenCalledWith(expect.objectContaining({ targetId: userId }));
expect(JSON.stringify(catalog)).not.toMatch(/SLACK_|CONNECTOR_SECRET|private-claim|secret-ref|nonce|adminId|admin-private-id/);
});

it('does not read projections when the caller has no live parent', async () => {
Installable.find.mockReturnValue(lean([]));
InstallableInstallation.find.mockReturnValue(lean([]));

const catalog = await catalogFor(userId);

expect(catalog.installables).toHaveLength(2);
expect(Integration.find).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -599,15 +599,22 @@ describe('installable connector projection', () => {
expect((await InstallableInstallation.findById(activating._id)).status).toBe('error');
});

it('marks a live parent stale when its projected Integration has been deleted', async () => {
it('makes a missing active projection a retriable parent error', async () => {
const { userId, podId } = ids();
const installed = await install({ installableId: 'telegram', installedBy: userId, podId });
await Integration.deleteOne({ _id: installed.integration._id });

const reconciled = await sweep(new Date());
const parent = await InstallableInstallation.findById(installed.installation._id);
expect(reconciled.staleComponents).toBe(1);
expect(parent.status).toBe('error');
expect(parent.errorMessage).toBe('projection missing');
expect(parent.components.every((component) => component.status === 'stale')).toBe(true);

const retried = await install({ installableId: 'telegram', installedBy: userId, podId });
expect(String(retried.installation._id)).toBe(String(installed.installation._id));
expect(retried.state).toBe('active');
expect(await Integration.findById(retried.integration._id)).not.toBeNull();
});

it('reconciles pause projections and prunes gates when the owner leaves a pod', async () => {
Expand Down
30 changes: 30 additions & 0 deletions backend/integrations/manifests.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
export interface ProviderReadiness {
available: boolean;
/** A stable, user-safe enum; never expose configuration keys in API output. */
reason?: 'not_configured';
}

interface IntegrationManifest {
id: string;
requiredConfig: string[];
configSchema: unknown;
/** Runtime readiness for an installable provider, not legacy row config. */
readiness?: () => ProviderReadiness;
catalog: {
label: string;
provider: string;
Expand Down Expand Up @@ -34,6 +42,13 @@ try {

const { buildConfigSchema, validateManifest } = sdk as Required<typeof sdk>;

const hasConfiguration = (...keys: string[]): boolean => keys.every((key) => {
const value = process.env[key];
return typeof value === 'string' && value.trim().length > 0;
});

const notConfigured = (): ProviderReadiness => ({ available: false, reason: 'not_configured' });

const manifests: Record<string, IntegrationManifest> = {
discord: validateManifest({
id: 'discord',
Expand All @@ -52,6 +67,15 @@ const manifests: Record<string, IntegrationManifest> = {
id: 'slack',
requiredConfig: ['botToken', 'signingSecret', 'channelId'],
configSchema: buildConfigSchema(['botToken', 'signingSecret', 'channelId']),
readiness: () => (
hasConfiguration(
'SLACK_CLIENT_ID',
'SLACK_CLIENT_SECRET',
'SLACK_SIGNING_SECRET',
'CONNECTOR_SECRET_KEYS',
'CONNECTOR_SECRET_ACTIVE_KEY',
) ? { available: true } : notConfigured()
),
catalog: {
label: 'Slack',
provider: 'slack',
Expand All @@ -78,6 +102,12 @@ const manifests: Record<string, IntegrationManifest> = {
id: 'telegram',
requiredConfig: ['chatId'],
configSchema: buildConfigSchema(['chatId']),
readiness: () => (
hasConfiguration('TELEGRAM_BOT_TOKEN')
&& (hasConfiguration('TELEGRAM_SECRET_TOKEN') || process.env.TELEGRAM_WEBHOOK_ALLOW_UNVERIFIED === 'true')
? { available: true }
: notConfigured()
),
catalog: {
label: 'Telegram',
provider: 'telegram',
Expand Down
Loading
Loading