Files
rmyndharis--openwa/test/integration-fabric.e2e-spec.ts
wehub-resource-sync 4ce4204b6c
CI / Lint (push) Failing after 2s
CI / Build (push) Has been skipped
SDK CI / PHP SDK (push) Failing after 1s
Split PHP SDK / PHP SDK tests (push) Failing after 1s
CI / Test (push) Failing after 1s
CI / Dashboard (push) Failing after 0s
SDK CI / JavaScript SDK (push) Failing after 0s
SDK CI / Python SDK (push) Failing after 2s
SDK CI / Java SDK (push) Failing after 1s
Split PHP SDK / Mirror sdk/php -> rmyndharis/openwa-php (push) Has been skipped
CI / Test (PostgreSQL migrations) (push) Failing after 7m47s
CI / Docker Build (push) Has been skipped
chore: import upstream snapshot with attribution
2026-07-13 12:24:08 +08:00

182 lines
7.9 KiB
TypeScript

// archiver v8 is ESM-only and is pulled in transitively via the @Global StorageModule when
// AppModule boots; stub it so ts-jest (CommonJS) can load the module graph.
jest.mock('archiver', () => ({ TarArchive: jest.fn() }));
import { createHmac, randomBytes } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import request from 'supertest';
import { App } from 'supertest/types';
import { json, urlencoded, Request } from 'express';
import { AppModule } from './../src/app.module';
import { PluginLoaderService } from './../src/core/plugins/plugin-loader.service';
import { PluginInstance } from './../src/modules/integration/entities/plugin-instance.entity';
import { IntegrationDeliveryFailure } from './../src/modules/integration/entities/integration-delivery-failure.entity';
/**
* Byte-exact HTTP coverage for the Integration SDK v1 ingress contract, over the seam the
* service-level golden test (src/modules/integration/ingress-golden.spec.ts) can't reach: the real
* @Public IngressController route, the real raw-body json({verify}) wiring mirrored from main.ts
* (Nest's testing module does not replicate bootstrap()'s manual body-parser setup), and a real
* TypeORM-persisted plugin instance. PluginLoaderService's engine-registration side effects are left
* real (they run at module init regardless); only the two entry points the ingress pipeline calls
* (getPlugin, dispatchWebhookForInstance) are spied so no live sandbox worker is required — the
* contract under test is the wire path (verify -> dedup -> dispatch), not a real WhatsApp send.
*
* This is the production address, `app.setGlobalPrefix('api')` + `@Controller('ingress')` composing to
* a single `/api/ingress/...` (matching the metrics.controller.ts @Public precedent), and the
* `:pluginId/:instanceId/*path` wildcard reading `req.params.path` — the named-wildcard form Express 5
* / path-to-regexp v8 requires (see ingress.controller.ts for the routing fix itself).
*/
describe('Integration Fabric ingress (e2e)', () => {
let app: INestApplication<App>;
let instanceRepo: Repository<PluginInstance>;
let dispatchWebhookForInstance: jest.Mock;
const secret = randomBytes(32).toString('hex');
const raw = readFileSync(
join(__dirname, '../src/modules/integration/__fixtures__/chatwoot-message_created.json'),
'utf8',
);
const sig = 'sha256=' + createHmac('sha256', secret).update(raw).digest('hex');
// The real production address: main.ts's setGlobalPrefix('api') + the controller's bare
// @Controller('ingress') compose to a single /api/ingress/... path.
const INGRESS_PATH = '/api/ingress/chatwoot/acct1/chatwoot';
const ingressManifestRoute = {
route: 'chatwoot',
mode: 'async' as const,
verify: 'core' as const,
maxBodyBytes: 262144,
signature: {
scheme: 'hmac-sha256' as const,
header: 'X-Chatwoot-Signature',
contentTemplate: '{rawBody}',
encoding: 'hex' as const,
prefix: 'sha256=',
},
dedupHeader: 'x-chatwoot-delivery',
};
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
app.setGlobalPrefix('api');
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
// Mirror main.ts's raw-body wiring: the ingress controller reads req.rawBody (stashed by this
// verify callback) so the HMAC would be checked over the EXACT bytes the provider signed. Nest's
// testing module does not install this on its own — it's manual bootstrap()-only wiring in main.ts.
app.use(
json({
verify: (req: Request & { rawBody?: Buffer }, _res, buf) => {
req.rawBody = buf;
},
}),
);
app.use(urlencoded({ extended: true }));
await app.init();
instanceRepo = app.get(getRepositoryToken(PluginInstance, 'data'));
// The real PluginLoaderService boots the built-in engine plugins (wwjs/Baileys) at onModuleInit,
// so it can't be blanket-replaced with a useValue stub without reimplementing that bootstrap. Instead
// spy on just the two entry points the ingress pipeline calls, leaving engine registration real.
const loader = app.get(PluginLoaderService);
const realGetPlugin = loader.getPlugin.bind(loader);
jest.spyOn(loader, 'getPlugin').mockImplementation((pluginId: string) => {
if (pluginId === 'chatwoot') {
return { manifest: { ingress: [ingressManifestRoute] } } as unknown as ReturnType<typeof loader.getPlugin>;
}
return realGetPlugin(pluginId);
});
dispatchWebhookForInstance = jest.spyOn(loader, 'dispatchWebhookForInstance').mockResolvedValue(undefined) as never;
});
afterAll(async () => {
try {
await app?.close();
} catch {
/* ignore teardown-only multi-datasource quirk */
}
});
beforeEach(async () => {
dispatchWebhookForInstance.mockClear();
await instanceRepo.save(
instanceRepo.create({
id: 'chatwoot:acct1',
pluginId: 'chatwoot',
instanceId: 'acct1',
secret,
enabled: true,
sessionScope: 'sess-1',
verifyToken: null,
config: null,
}),
);
});
it('accepts a correctly signed Chatwoot delivery over the real /api/ingress path', async () => {
// This is the request shape the SDK v1 design doc and the golden fixture describe, sent to the
// real production address. QUEUE_ENABLED is unset in this test env, so IngressService's factory
// wires the inline-dispatch fallback — dispatchWebhookForInstance runs synchronously before the 202.
const res = await request(app.getHttpServer())
.post(INGRESS_PATH)
.set('X-Chatwoot-Signature', sig)
.set('X-Chatwoot-Delivery', 'delivery-http-1')
.set('Content-Type', 'application/json')
.send(raw);
expect(res.status).toBe(202);
expect(dispatchWebhookForInstance).toHaveBeenCalledTimes(1);
});
it('rejects a tampered signature with 401 and never dispatches', async () => {
const res = await request(app.getHttpServer())
.post(INGRESS_PATH)
.set('X-Chatwoot-Signature', 'sha256=' + '0'.repeat(64))
.set('X-Chatwoot-Delivery', 'delivery-http-2')
.set('Content-Type', 'application/json')
.send(raw);
expect(res.status).toBe(401);
expect(dispatchWebhookForInstance).not.toHaveBeenCalled();
});
it('persists a redrivable dead-letter row (still 202) when inline dispatch fails', async () => {
const failureRepo = app.get<Repository<IntegrationDeliveryFailure>>(
getRepositoryToken(IntegrationDeliveryFailure, 'data'),
);
// The plugin handler throws — the inline-dispatch fallback fails and is swallowed. Without a
// dead-letter row the event would be stranded: handle() still 202s, and RedriveService only scans
// this table (never ingress_events). The live-ingress wiring must therefore persist the failure here.
dispatchWebhookForInstance.mockRejectedValueOnce(new Error('sandbox handler 5xx'));
const res = await request(app.getHttpServer())
.post(INGRESS_PATH)
.set('X-Chatwoot-Signature', sig)
.set('X-Chatwoot-Delivery', 'delivery-dlq-1')
.set('Content-Type', 'application/json')
.send(raw);
// Provider still gets its 202 (at-least-once) — the failure is captured durably, not surfaced as 5xx.
expect(res.status).toBe(202);
const rows = await failureRepo.find({ where: { deliveryId: 'delivery-dlq-1', direction: 'inbound' } });
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
pluginId: 'chatwoot',
instanceId: 'acct1',
redriven: false,
lastError: 'sandbox handler 5xx',
});
});
});