File size: 6,498 Bytes
46252cd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
// archiver v8 is ESM-only; stub it so ts-jest can load the module graph.
jest.mock('archiver', () => ({ TarArchive: jest.fn() }));

// Seed the well-known dev-admin-key BEFORE AppModule is imported (mirrors mcp-auth.e2e-spec.ts's
// pattern of setting the enabling env var ahead of the import).
process.env.ALLOW_DEV_API_KEY = 'true';
process.env.BASE_URL = 'https://api.example.com';

import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from '../src/app.module';
import { applyGlobalValidation } from '../src/config/app-validation';
import { PluginLoaderService } from '../src/core/plugins/plugin-loader.service';

// A stub ingress-capable plugin so the capability check passes without a real plugin on disk.
const INGRESS_PLUGIN = {
  manifest: { id: 'chatwoot', ingress: [{ route: 'chatwoot' }], permissions: ['webhook:ingress', 'conversation:send'] },
};
const NON_INGRESS_PLUGIN = { manifest: { id: 'plain', permissions: [] } };

interface InstanceViewBody {
  secret: string;
  enabled: boolean;
  sessionScope: string | null;
  ingressUrls: Array<{ route: string; url: string }>;
}

describe('IntegrationInstanceController (e2e)', () => {
  let app: INestApplication<App>;
  const key = 'dev-admin-key';
  const base = '/api/integration/plugins';

  beforeAll(async () => {
    const moduleRef = await Test.createTestingModule({ imports: [AppModule] })
      .overrideProvider(PluginLoaderService)
      .useValue({
        getPlugin: (id: string) =>
          id === 'chatwoot' ? INGRESS_PLUGIN : id === 'plain' ? NON_INGRESS_PLUGIN : undefined,
        dispatchWebhookForInstance: jest.fn(),
        // EngineFactory.onModuleInit registers the built-in whatsapp-web.js/baileys engine plugins
        // and auto-enables the configured one through these at boot; no-ops keep app boot working
        // without a real plugin registry (enablePlugin failing is caught+logged, not fatal, but a
        // no-op keeps the test log free of that expected-but-noisy error).
        registerBuiltInPlugin: jest.fn(),
        enablePlugin: jest.fn().mockResolvedValue(undefined),
        onModuleInit: jest.fn(),
        onModuleDestroy: jest.fn(),
      })
      .compile();
    app = moduleRef.createNestApplication();
    applyGlobalValidation(app);
    await app.init();
  });

  afterAll(async () => {
    try {
      await app?.close();
    } catch {
      /* ignore teardown-only multi-datasource quirk */
    }
  });

  it('requires an API key (401 without one)', async () => {
    await request(app.getHttpServer()).get(`${base}/chatwoot/instances`).expect(401);
  });

  it('mints an instance and returns the secret + ingress URLs exactly once, then masks on read', async () => {
    const mint = await request(app.getHttpServer())
      .post(`${base}/chatwoot/instances`)
      .set('X-API-Key', key)
      .send({ instanceId: 'acct1', sessionScope: 'sess-1' })
      .expect(201);
    const minted = mint.body as InstanceViewBody;
    expect(minted.secret).toMatch(/^[0-9a-f]{64}$/);
    expect(minted.ingressUrls).toEqual([
      { route: 'chatwoot', url: 'https://api.example.com/api/ingress/chatwoot/acct1/chatwoot' },
    ]);

    const list = await request(app.getHttpServer()).get(`${base}/chatwoot/instances`).set('X-API-Key', key).expect(200);
    expect((list.body as InstanceViewBody[])[0].secret).toBe('***');

    const one = await request(app.getHttpServer())
      .get(`${base}/chatwoot/instances/acct1`)
      .set('X-API-Key', key)
      .expect(200);
    expect((one.body as InstanceViewBody).secret).toBe('***');
  });

  it('rejects minting on a non-ingress plugin (400) and a duplicate (409)', async () => {
    await request(app.getHttpServer())
      .post(`${base}/plain/instances`)
      .set('X-API-Key', key)
      .send({ instanceId: 'x' })
      .expect(400);
    await request(app.getHttpServer())
      .post(`${base}/chatwoot/instances`)
      .set('X-API-Key', key)
      .send({ instanceId: 'acct1' })
      .expect(409);
  });

  it('rejects minting on an unknown plugin (404)', async () => {
    await request(app.getHttpServer())
      .post(`${base}/nonexistent/instances`)
      .set('X-API-Key', key)
      .send({ instanceId: 'x' })
      .expect(404);
  });

  it('rejects an invalid instanceId charset (400)', async () => {
    await request(app.getHttpServer())
      .post(`${base}/chatwoot/instances`)
      .set('X-API-Key', key)
      .send({ instanceId: 'bad:id' })
      .expect(400);
  });

  it('regenerates the secret and returns it once, then masks again on the next read', async () => {
    const regen = await request(app.getHttpServer())
      .post(`${base}/chatwoot/instances/acct1/regenerate-secret`)
      .set('X-API-Key', key)
      .expect(200);
    expect((regen.body as InstanceViewBody).secret).toMatch(/^[0-9a-f]{64}$/);

    const one = await request(app.getHttpServer())
      .get(`${base}/chatwoot/instances/acct1`)
      .set('X-API-Key', key)
      .expect(200);
    expect((one.body as InstanceViewBody).secret).toBe('***');
  });

  it('patches enabled + sessionScope and returns a masked view', async () => {
    const patched = await request(app.getHttpServer())
      .patch(`${base}/chatwoot/instances/acct1`)
      .set('X-API-Key', key)
      .send({ enabled: false, sessionScope: 'sess-2' })
      .expect(200);
    const body = patched.body as InstanceViewBody;
    expect(body.enabled).toBe(false);
    expect(body.sessionScope).toBe('sess-2');
    expect(body.secret).toBe('***');
  });

  it('404s GET/PATCH/DELETE for a missing instanceId on an ingress-capable plugin', async () => {
    await request(app.getHttpServer())
      .get(`${base}/chatwoot/instances/does-not-exist`)
      .set('X-API-Key', key)
      .expect(404);
    await request(app.getHttpServer())
      .patch(`${base}/chatwoot/instances/does-not-exist`)
      .set('X-API-Key', key)
      .send({ enabled: false })
      .expect(404);
    await request(app.getHttpServer())
      .delete(`${base}/chatwoot/instances/does-not-exist`)
      .set('X-API-Key', key)
      .expect(404);
  });

  it('deletes the instance (204) and audits it', async () => {
    await request(app.getHttpServer()).delete(`${base}/chatwoot/instances/acct1`).set('X-API-Key', key).expect(204);
    await request(app.getHttpServer()).get(`${base}/chatwoot/instances/acct1`).set('X-API-Key', key).expect(404);
  });
});