@aloma.io/integration-sdk 3.8.67 → 3.8.69
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -0
- package/build/testing/fake-aloma.d.mts +87 -0
- package/build/testing/fake-aloma.mjs +248 -0
- package/build/testing/harness.d.mts +51 -0
- package/build/testing/harness.mjs +139 -0
- package/build/testing/index.d.mts +15 -0
- package/build/testing/index.mjs +13 -0
- package/build/testing/keys.d.mts +9 -0
- package/build/testing/keys.mjs +15 -0
- package/build/testing/network-guard.d.mts +3 -0
- package/build/testing/network-guard.mjs +49 -0
- package/build/testing/runtime-connector.d.mts +11 -0
- package/build/testing/runtime-connector.mjs +38 -0
- package/build/testing/vendor-mock.d.mts +59 -0
- package/build/testing/vendor-mock.mjs +121 -0
- package/build/transform/index.d.mts +2 -2
- package/build/transform/index.mjs +493 -78
- package/package.json +8 -4
- package/src/testing/fake-aloma.mts +306 -0
- package/src/testing/harness.mts +172 -0
- package/src/testing/index.mts +15 -0
- package/src/testing/keys.mts +19 -0
- package/src/testing/network-guard.mts +57 -0
- package/src/testing/runtime-connector.mts +50 -0
- package/src/testing/vendor-mock.mts +166 -0
- package/src/transform/index.mts +530 -96
- package/test/MUTATION-entry.md +21 -0
- package/test/MUTATION.md +43 -0
- package/test/fixtures/transform/basic/expected.json +9 -0
- package/test/fixtures/transform/basic/src/controller/abstract.mts +5 -0
- package/test/fixtures/transform/basic/src/controller/index.mts +30 -0
- package/test/fixtures/transform/basic/tsconfig.json +1 -0
- package/test/fixtures/transform/interface-param/src/controller/abstract.mts +5 -0
- package/test/fixtures/transform/interface-param/src/controller/index.mts +12 -0
- package/test/fixtures/transform/interface-param/tsconfig.json +1 -0
- package/test/fixtures/transform/overloads/expected.json +6 -0
- package/test/fixtures/transform/overloads/src/controller/abstract.mts +5 -0
- package/test/fixtures/transform/overloads/src/controller/index.mts +11 -0
- package/test/fixtures/transform/overloads/tsconfig.json +1 -0
- package/test/fixtures/transform/tdd-stub/expected.json +8 -0
- package/test/fixtures/transform/tdd-stub/src/controller/abstract.mts +6 -0
- package/test/fixtures/transform/tdd-stub/src/controller/index.mts +204 -0
- package/test/fixtures/transform/tdd-stub/tsconfig.json +1 -0
- package/test/fixtures/transform/two-exports/src/controller/abstract.mts +5 -0
- package/test/fixtures/transform/two-exports/src/controller/index.mts +9 -0
- package/test/fixtures/transform/two-exports/tsconfig.json +1 -0
- package/test/fixtures/transform/type-error/src/controller/abstract.mts +5 -0
- package/test/fixtures/transform/type-error/src/controller/index.mts +7 -0
- package/test/fixtures/transform/type-error/tsconfig.json +1 -0
- package/test/fixtures/transform/typed/expected.json +8 -0
- package/test/fixtures/transform/typed/src/controller/abstract.mts +5 -0
- package/test/fixtures/transform/typed/src/controller/index.mts +13 -0
- package/test/fixtures/transform/typed/tsconfig.json +1 -0
- package/test/fixtures/transform/visibility/expected.json +9 -0
- package/test/fixtures/transform/visibility/src/controller/abstract.mts +5 -0
- package/test/fixtures/transform/visibility/src/controller/index.mts +19 -0
- package/test/fixtures/transform/visibility/tsconfig.json +1 -0
- package/test/harness-connector-smoke.test.mjs +72 -0
- package/test/harness.test.mjs +375 -0
- package/test/openapi-to-connector.test.mts +25 -7
- package/test/scenarios.test.mts +15 -7
- package/test/transform.test.mjs +68 -0
- package/tools/mutation-run-entry.mjs +52 -0
- package/tools/mutation-run.mjs +120 -0
- package/tools/run-tests.mjs +64 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import {randomUUID} from 'node:crypto';
|
|
2
|
+
import http from 'node:http';
|
|
3
|
+
import type {AddressInfo} from 'node:net';
|
|
4
|
+
import * as jose from 'jose';
|
|
5
|
+
import {WebSocket, WebSocketServer} from 'ws';
|
|
6
|
+
|
|
7
|
+
type Packet = {id?: string; m?: string; c?: string; a?: any; e?: any};
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A fake ALOMA endpoint that speaks the connector protocol, in-process on a loopback port.
|
|
11
|
+
*
|
|
12
|
+
* Protocol (taken from src/internal/websocket and src/internal/dispatcher):
|
|
13
|
+
* 1. POST {url}connect with `Authorization: Connector <token>`. 401 means "register first", 200 + json means connected.
|
|
14
|
+
* 2. POST {url}register with `Authorization: Connector <registrationToken>` and
|
|
15
|
+
* {name, version, id, publicKey, schema:{configSchema, introspect}, icon}; answer 200 {key: <token>}.
|
|
16
|
+
* 3. The connector opens a websocket at wsUrl (subprotocol "connector", same Authorization header).
|
|
17
|
+
* 4. Both sides exchange text frames {p: [packet, ...]}; a packet is {id, m: method, c: callback key, a: args}.
|
|
18
|
+
* 5. ALOMA sends connector.set-config {secrets} (no reply), then connector.query {query, variables} with a
|
|
19
|
+
* callback key; the connector answers {c: key, a: result} or {c: key, a: {error}}.
|
|
20
|
+
*
|
|
21
|
+
* Anything that does not look like the real protocol is recorded in `protocolErrors`.
|
|
22
|
+
*/
|
|
23
|
+
export class FakeAloma {
|
|
24
|
+
readonly registrationToken = `fake-registration-${randomUUID()}`;
|
|
25
|
+
readonly sessionToken = `fake-session-${randomUUID()}`;
|
|
26
|
+
|
|
27
|
+
/** bodies of POST /register */
|
|
28
|
+
readonly registrations: any[] = [];
|
|
29
|
+
/** headers of POST /connect */
|
|
30
|
+
readonly connects: Array<{authorized: boolean; headers: http.IncomingHttpHeaders}> = [];
|
|
31
|
+
/** every packet the connector sent over the websocket, in order */
|
|
32
|
+
readonly received: Packet[] = [];
|
|
33
|
+
readonly protocolErrors: string[] = [];
|
|
34
|
+
|
|
35
|
+
private server!: http.Server;
|
|
36
|
+
private wss!: WebSocketServer;
|
|
37
|
+
private socket?: WebSocket;
|
|
38
|
+
private pending = new Map<string, (args: any) => void>();
|
|
39
|
+
private handlers = new Map<string, (args: any) => any | Promise<any>>();
|
|
40
|
+
private waiters: Array<() => void> = [];
|
|
41
|
+
private port = 0;
|
|
42
|
+
private id = '';
|
|
43
|
+
private version = '';
|
|
44
|
+
|
|
45
|
+
static async start(): Promise<FakeAloma> {
|
|
46
|
+
const fake = new FakeAloma();
|
|
47
|
+
await fake.listen();
|
|
48
|
+
|
|
49
|
+
return fake;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** value for the connector's endpoint setting (env.DEVICE_ENDPOINT) */
|
|
53
|
+
get url() {
|
|
54
|
+
return `http://127.0.0.1:${this.port}/`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** value for the connector's websocket setting (env.WEBSOCKET_ENDPOINT) */
|
|
58
|
+
get wsUrl() {
|
|
59
|
+
return `ws://127.0.0.1:${this.port}/transport/`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
get connected() {
|
|
63
|
+
return this.socket?.readyState === WebSocket.OPEN;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
get registration() {
|
|
67
|
+
return this.registrations[this.registrations.length - 1];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private async listen() {
|
|
71
|
+
this.server = http.createServer((req, res) => {
|
|
72
|
+
this.onHttp(req, res).catch((e) => {
|
|
73
|
+
this.protocolErrors.push(`http handler failed: ${e}`);
|
|
74
|
+
res.writeHead(500).end();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
this.wss = new WebSocketServer({
|
|
79
|
+
noServer: true,
|
|
80
|
+
handleProtocols: (protocols) => (protocols.has('connector') ? 'connector' : false),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
this.server.on('upgrade', (req, socket, head) => {
|
|
84
|
+
const problems: string[] = [];
|
|
85
|
+
if (req.headers.authorization !== `Connector ${this.sessionToken}`) problems.push('bad or missing Authorization on websocket');
|
|
86
|
+
if (!String(req.headers['sec-websocket-protocol'] || '').split(/,\s*/).includes('connector')) {
|
|
87
|
+
problems.push('websocket subprotocol "connector" not requested');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (problems.length) {
|
|
91
|
+
this.protocolErrors.push(...problems);
|
|
92
|
+
socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
|
|
93
|
+
socket.destroy();
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
this.wss.handleUpgrade(req, socket, head, (ws) => this.onSocket(ws));
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
await new Promise<void>((resolve) => this.server.listen(0, '127.0.0.1', () => resolve()));
|
|
101
|
+
this.port = (this.server.address() as AddressInfo).port;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private async readJson(req: http.IncomingMessage) {
|
|
105
|
+
let text = '';
|
|
106
|
+
for await (const chunk of req) text += chunk;
|
|
107
|
+
|
|
108
|
+
return text ? JSON.parse(text) : {};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private async onHttp(req: http.IncomingMessage, res: http.ServerResponse) {
|
|
112
|
+
const send = (status: number, body?: unknown) => {
|
|
113
|
+
res.writeHead(status, {'Content-Type': 'application/json'});
|
|
114
|
+
res.end(body === undefined ? '' : JSON.stringify(body));
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const body = await this.readJson(req);
|
|
118
|
+
const auth = req.headers.authorization;
|
|
119
|
+
|
|
120
|
+
if (req.method === 'POST' && req.url === '/connect') {
|
|
121
|
+
const authorized = auth === `Connector ${this.sessionToken}`;
|
|
122
|
+
this.connects.push({authorized, headers: req.headers});
|
|
123
|
+
|
|
124
|
+
return authorized ? send(200, {}) : send(401);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (req.method === 'POST' && req.url === '/register') {
|
|
128
|
+
if (auth !== `Connector ${this.registrationToken}`) {
|
|
129
|
+
this.protocolErrors.push('register called with a wrong registration token');
|
|
130
|
+
return send(401);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const missing = ['name', 'version', 'id', 'publicKey', 'schema'].filter((key) => body[key] == null);
|
|
134
|
+
if (missing.length) this.protocolErrors.push(`register body is missing: ${missing.join(', ')}`);
|
|
135
|
+
|
|
136
|
+
this.id = body.id;
|
|
137
|
+
this.version = body.version;
|
|
138
|
+
if (req.headers['user-agent'] !== `${body.id}/${body.version}`) {
|
|
139
|
+
this.protocolErrors.push(`register User-Agent should be id/version, got ${req.headers['user-agent']}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
this.registrations.push(body);
|
|
143
|
+
|
|
144
|
+
return send(200, {key: this.sessionToken});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (req.method === 'POST' && req.url === '/disconnect') return send(200, {});
|
|
148
|
+
|
|
149
|
+
this.protocolErrors.push(`unexpected request ${req.method} ${req.url}`);
|
|
150
|
+
send(404);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private onSocket(ws: WebSocket) {
|
|
154
|
+
this.socket = ws;
|
|
155
|
+
|
|
156
|
+
ws.on('message', (raw) => {
|
|
157
|
+
const frame = JSON.parse(raw.toString());
|
|
158
|
+
const packets: Packet[] = frame.p;
|
|
159
|
+
|
|
160
|
+
if (!Array.isArray(packets)) {
|
|
161
|
+
this.protocolErrors.push('frame without a "p" array');
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
packets.forEach((packet) => this.onPacket(packet));
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
this.waiters.splice(0).forEach((fn) => fn());
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private onPacket(packet: Packet) {
|
|
172
|
+
this.received.push(packet);
|
|
173
|
+
|
|
174
|
+
// an answer to something ALOMA sent
|
|
175
|
+
if (packet.c && !packet.m && this.pending.has(packet.c)) {
|
|
176
|
+
const done = this.pending.get(packet.c)!;
|
|
177
|
+
this.pending.delete(packet.c);
|
|
178
|
+
done(packet.a);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// the connector asks ALOMA for something (blob, task, ...) and expects an answer on its callback key
|
|
183
|
+
if (packet.m && this.handlers.has(packet.m)) {
|
|
184
|
+
Promise.resolve(this.handlers.get(packet.m)!(packet.a)).then(
|
|
185
|
+
(answer) => packet.c && this.send({c: packet.c, a: answer}),
|
|
186
|
+
(e) => packet.c && this.send({c: packet.c, a: {error: String(e)}})
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** answer connector -> ALOMA requests such as connector.blob.get or connector.task.new */
|
|
192
|
+
handle(method: string, fn: (args: any) => any | Promise<any>) {
|
|
193
|
+
this.handlers.set(method, fn);
|
|
194
|
+
return this;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** resolves when the connector's websocket is open (the handshake is finished) */
|
|
198
|
+
async waitForConnection(timeoutMs = 5000) {
|
|
199
|
+
if (this.connected) return;
|
|
200
|
+
|
|
201
|
+
await new Promise<void>((resolve, reject) => {
|
|
202
|
+
const timer = setTimeout(() => {
|
|
203
|
+
reject(
|
|
204
|
+
new Error(
|
|
205
|
+
`connector did not connect within ${timeoutMs}ms. registrations=${this.registrations.length} ` +
|
|
206
|
+
`connects=${this.connects.length} protocolErrors=${JSON.stringify(this.protocolErrors)}`
|
|
207
|
+
)
|
|
208
|
+
);
|
|
209
|
+
}, timeoutMs);
|
|
210
|
+
|
|
211
|
+
this.waiters.push(() => {
|
|
212
|
+
clearTimeout(timer);
|
|
213
|
+
resolve();
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** resolves with the first packet the connector sent for `method` (e.g. connector.health.check), now or later */
|
|
219
|
+
async waitForPacket(method: string, timeoutMs = 5000): Promise<Packet> {
|
|
220
|
+
const deadline = Date.now() + timeoutMs;
|
|
221
|
+
|
|
222
|
+
for (;;) {
|
|
223
|
+
const found = this.received.find((packet) => packet.m === method);
|
|
224
|
+
if (found) return found;
|
|
225
|
+
if (Date.now() > deadline) throw new Error(`connector did not send ${method} within ${timeoutMs}ms`);
|
|
226
|
+
|
|
227
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** send one raw packet to the connector */
|
|
232
|
+
send(packet: Packet) {
|
|
233
|
+
if (!this.connected) throw new Error('connector is not connected');
|
|
234
|
+
|
|
235
|
+
this.socket!.send(JSON.stringify({p: [{id: randomUUID(), ...packet}]}));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** send a packet with a callback key and wait for the connector's answer (its `a` field) */
|
|
239
|
+
request(method: string, args: any, timeoutMs = 5000): Promise<any> {
|
|
240
|
+
const key = `_req-${randomUUID()}`;
|
|
241
|
+
|
|
242
|
+
return new Promise((resolve, reject) => {
|
|
243
|
+
const timer = setTimeout(() => {
|
|
244
|
+
this.pending.delete(key);
|
|
245
|
+
reject(new Error(`no answer to ${method} within ${timeoutMs}ms`));
|
|
246
|
+
}, timeoutMs);
|
|
247
|
+
|
|
248
|
+
this.pending.set(key, (answer) => {
|
|
249
|
+
clearTimeout(timer);
|
|
250
|
+
resolve(answer);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
this.send({m: method, c: key, a: args});
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** ask the connector for its schema and method list (connector.introspect) */
|
|
258
|
+
introspect() {
|
|
259
|
+
return this.request('connector.introspect', {});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Send the configuration the way ALOMA does: every field that is not `plain` is a JWE encrypted with the
|
|
264
|
+
* public key the connector registered, audience = connector id. Resolves after the packet is sent;
|
|
265
|
+
* the ConnectorHarness waits for the connector's start.
|
|
266
|
+
*/
|
|
267
|
+
async setConfig(values: Record<string, any>) {
|
|
268
|
+
if (!this.registration) throw new Error('connector has not registered yet');
|
|
269
|
+
|
|
270
|
+
const fields = this.registration.schema?.configSchema?.fields || {};
|
|
271
|
+
const publicKey = await jose.importSPKI(Buffer.from(this.registration.publicKey, 'base64').toString(), 'RSA-OAEP-256');
|
|
272
|
+
const secrets: Record<string, any> = {};
|
|
273
|
+
|
|
274
|
+
for (const [key, value] of Object.entries(values)) {
|
|
275
|
+
if (fields[key]?.plain || key === 'endpointUrl') {
|
|
276
|
+
secrets[key] = value;
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
secrets[key] = await new jose.EncryptJWT({_data: value})
|
|
281
|
+
.setProtectedHeader({alg: 'RSA-OAEP-256', enc: 'A256GCM'})
|
|
282
|
+
.setIssuedAt()
|
|
283
|
+
.setIssuer('home.aloma.io')
|
|
284
|
+
.setAudience(this.id)
|
|
285
|
+
.encrypt(publicKey);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
this.send({m: 'connector.set-config', a: {secrets}});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Call a connector method like ALOMA does (connector.query). Resolves with the connector's raw answer:
|
|
293
|
+
* an object result is passed through, any other value is wrapped as {[method]: value}, a failure is {error}.
|
|
294
|
+
*/
|
|
295
|
+
call(method: string | string[], variables?: any, ctx: {taskId?: string; namespace?: string; sticky?: boolean} = {}) {
|
|
296
|
+
return this.request('connector.query', {query: method, variables, ...ctx});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async close() {
|
|
300
|
+
this.pending.clear();
|
|
301
|
+
this.wss.clients.forEach((client) => client.terminate());
|
|
302
|
+
this.wss.close();
|
|
303
|
+
this.server.closeAllConnections?.();
|
|
304
|
+
await new Promise<void>((resolve) => this.server.close(() => resolve()));
|
|
305
|
+
}
|
|
306
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import {Config} from '../internal/websocket/config.mjs';
|
|
2
|
+
import {WebsocketConnector} from '../internal/websocket/index.mjs';
|
|
3
|
+
import {onConnect} from '../internal/connector/server/on-connect/index.mjs';
|
|
4
|
+
import {onMessage} from '../internal/connector/server/on-message.mjs';
|
|
5
|
+
import type {Connector} from '../internal/connector/index.mjs';
|
|
6
|
+
import {FakeAloma} from './fake-aloma.mjs';
|
|
7
|
+
import {testKeyPair} from './keys.mjs';
|
|
8
|
+
import {VendorMock} from './vendor-mock.mjs';
|
|
9
|
+
|
|
10
|
+
export type HarnessOptions = {
|
|
11
|
+
/**
|
|
12
|
+
* The connector under test, already configured:
|
|
13
|
+
* `const c = new Connector({id, version, name}); c.configure().types(...).resolvers(...).main(...)`
|
|
14
|
+
*/
|
|
15
|
+
connector: Connector;
|
|
16
|
+
/** vendor mock; a fresh one is created when omitted */
|
|
17
|
+
vendor?: VendorMock;
|
|
18
|
+
/**
|
|
19
|
+
* configuration values ALOMA sends after the handshake (encrypted unless the field is `plain`).
|
|
20
|
+
* `null` skips sending any configuration (the connector then never starts its main()).
|
|
21
|
+
*/
|
|
22
|
+
config?: Record<string, any> | null;
|
|
23
|
+
/** how long to wait for each step, ms */
|
|
24
|
+
timeoutMs?: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Runs a real connector against a fake ALOMA, in-process.
|
|
29
|
+
*
|
|
30
|
+
* It wires the same SDK parts as Connector.run() and makeServer(): Config, WebsocketConnector (connection,
|
|
31
|
+
* registration, transport), onConnect (config decryption, main()), onMessage (dispatch and reply).
|
|
32
|
+
* Left out on purpose: the Prometheus metrics server (fixed port 4050), process signal handlers and
|
|
33
|
+
* env-var configuration, which are global side effects that cannot run once per test.
|
|
34
|
+
*/
|
|
35
|
+
export class ConnectorHarness {
|
|
36
|
+
|
|
37
|
+
private constructor(
|
|
38
|
+
readonly aloma: FakeAloma,
|
|
39
|
+
readonly vendor: VendorMock,
|
|
40
|
+
private readonly server: WebsocketConnector,
|
|
41
|
+
private readonly started: Promise<void> | null
|
|
42
|
+
) {}
|
|
43
|
+
|
|
44
|
+
static async start(options: HarnessOptions): Promise<ConnectorHarness> {
|
|
45
|
+
const {connector, timeoutMs = 5000} = options;
|
|
46
|
+
const dispatcher: any = connector.dispatcher;
|
|
47
|
+
if (!dispatcher) throw new Error('call connector.configure() before starting the harness');
|
|
48
|
+
|
|
49
|
+
const vendor = options.vendor || new VendorMock();
|
|
50
|
+
const aloma = await FakeAloma.start();
|
|
51
|
+
vendor.allowPassthrough((url) => url.origin === new URL(aloma.url).origin);
|
|
52
|
+
vendor.install();
|
|
53
|
+
|
|
54
|
+
// main() is what a connector runs once ALOMA has sent its configuration; observe it
|
|
55
|
+
let mainDone!: () => void;
|
|
56
|
+
let mainFailed!: (e: unknown) => void;
|
|
57
|
+
const started = new Promise<void>((resolve, reject) => {
|
|
58
|
+
mainDone = resolve;
|
|
59
|
+
mainFailed = reject;
|
|
60
|
+
});
|
|
61
|
+
started.catch(() => {});
|
|
62
|
+
|
|
63
|
+
const originalMain = dispatcher._main;
|
|
64
|
+
dispatcher.main(async (arg) => {
|
|
65
|
+
try {
|
|
66
|
+
await originalMain?.(arg);
|
|
67
|
+
mainDone();
|
|
68
|
+
} catch (e) {
|
|
69
|
+
mainFailed(e);
|
|
70
|
+
throw e;
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
let server: WebsocketConnector | undefined;
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const {processPacket, start, introspect, configSchema} = dispatcher.build();
|
|
78
|
+
const keys = await testKeyPair();
|
|
79
|
+
|
|
80
|
+
const config = new Config({
|
|
81
|
+
id: connector.id,
|
|
82
|
+
version: connector.version,
|
|
83
|
+
name: connector.name,
|
|
84
|
+
registrationToken: aloma.registrationToken,
|
|
85
|
+
endpoint: aloma.url,
|
|
86
|
+
wsEndpoint: aloma.wsUrl,
|
|
87
|
+
privateKey: keys.privateKey,
|
|
88
|
+
publicKey: keys.publicKey,
|
|
89
|
+
introspect,
|
|
90
|
+
configSchema,
|
|
91
|
+
icon: connector.icon,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
server = new WebsocketConnector({
|
|
95
|
+
config,
|
|
96
|
+
onConnect: onConnect({config, configSchema, dispatcher, start}),
|
|
97
|
+
onMessage: onMessage(processPacket),
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const harness = new ConnectorHarness(aloma, vendor, server, options.config === null ? null : started);
|
|
101
|
+
|
|
102
|
+
await server.start();
|
|
103
|
+
await aloma.waitForConnection(timeoutMs);
|
|
104
|
+
|
|
105
|
+
if (options.config !== null) {
|
|
106
|
+
await aloma.setConfig(options.config || {});
|
|
107
|
+
await withTimeout(started, timeoutMs, 'connector main() did not finish after the configuration was sent');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return harness;
|
|
111
|
+
} catch (e) {
|
|
112
|
+
await ConnectorHarness.shutdown(server, vendor, aloma);
|
|
113
|
+
throw e;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private static async shutdown(server: WebsocketConnector | undefined, vendor: VendorMock, aloma: FakeAloma) {
|
|
118
|
+
try {
|
|
119
|
+
if (server) {
|
|
120
|
+
const anyServer: any = server;
|
|
121
|
+
|
|
122
|
+
await anyServer.leaving();
|
|
123
|
+
await anyServer.close();
|
|
124
|
+
|
|
125
|
+
// the SDK leaves a 45s ping watchdog timer behind; clear it so a test run can exit
|
|
126
|
+
clearTimeout(anyServer.transport?.ws?.pingTimeout);
|
|
127
|
+
}
|
|
128
|
+
} finally {
|
|
129
|
+
vendor.uninstall();
|
|
130
|
+
await aloma.close();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** call a connector method and return the raw answer (`{error}` when it failed) */
|
|
135
|
+
call(method: string | string[], variables?: any, ctx?: {taskId?: string; namespace?: string; sticky?: boolean}) {
|
|
136
|
+
return this.aloma.call(method, variables, ctx);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** call a connector method; throws when the connector answered with an error */
|
|
140
|
+
async callOk(method: string | string[], variables?: any) {
|
|
141
|
+
const answer = await this.call(method, variables);
|
|
142
|
+
if (answer?.error) throw new Error(`${method} failed: ${answer.error}`);
|
|
143
|
+
|
|
144
|
+
return answer;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Shut down the connector and the fake endpoint, restore fetch and the network guard.
|
|
149
|
+
* Then fails if the connector made any request that was not mocked or the fake endpoint saw a protocol error.
|
|
150
|
+
*/
|
|
151
|
+
async stop({verify = true}: {verify?: boolean} = {}) {
|
|
152
|
+
await ConnectorHarness.shutdown(this.server, this.vendor, this.aloma);
|
|
153
|
+
|
|
154
|
+
if (verify) {
|
|
155
|
+
this.vendor.verify();
|
|
156
|
+
if (this.aloma.protocolErrors.length) {
|
|
157
|
+
throw new Error(`protocol errors seen by the fake ALOMA: ${JSON.stringify(this.aloma.protocolErrors)}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const withTimeout = <T,>(promise: Promise<T>, ms: number, message: string): Promise<T> => {
|
|
164
|
+
let timer: NodeJS.Timeout;
|
|
165
|
+
|
|
166
|
+
return Promise.race([
|
|
167
|
+
promise,
|
|
168
|
+
new Promise<never>((_, reject) => {
|
|
169
|
+
timer = setTimeout(() => reject(new Error(message)), ms);
|
|
170
|
+
}),
|
|
171
|
+
]).finally(() => clearTimeout(timer));
|
|
172
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test harness for connectors built on the ALOMA integration SDK.
|
|
3
|
+
*
|
|
4
|
+
* import {ConnectorHarness, VendorMock} from '@aloma.io/integration-sdk/build/testing/index.mjs';
|
|
5
|
+
*
|
|
6
|
+
* Test tooling only: nothing in the SDK runtime imports this folder.
|
|
7
|
+
*/
|
|
8
|
+
export {ConnectorHarness} from './harness.mjs';
|
|
9
|
+
export type {HarnessOptions} from './harness.mjs';
|
|
10
|
+
export {FakeAloma} from './fake-aloma.mjs';
|
|
11
|
+
export {VendorMock} from './vendor-mock.mjs';
|
|
12
|
+
export type {VendorRequest, VendorResponse, VendorResponder, UrlMatcher} from './vendor-mock.mjs';
|
|
13
|
+
export {installNetworkGuard, uninstallNetworkGuard} from './network-guard.mjs';
|
|
14
|
+
export {testKeyPair} from './keys.mjs';
|
|
15
|
+
export {connectorFromRuntimeContext} from './runtime-connector.mjs';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import JWE from '../internal/util/jwe/index.mjs';
|
|
2
|
+
|
|
3
|
+
let pair: Promise<{privateKey: string; publicKey: string}> | undefined;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* One throwaway RSA key pair per process, generated at test time.
|
|
7
|
+
* Never committed, never a real key. Same encoding the connector expects in
|
|
8
|
+
* env.PRIVATE_KEY / env.PUBLIC_KEY (base64 of the PEM).
|
|
9
|
+
*/
|
|
10
|
+
export const testKeyPair = () => {
|
|
11
|
+
pair ||= (async () => {
|
|
12
|
+
const jwe = new JWE({algorithm: 'RSA-OAEP-256'});
|
|
13
|
+
await jwe.newPair();
|
|
14
|
+
|
|
15
|
+
return {privateKey: await jwe.exportPrivateAsBase64(), publicKey: await jwe.exportPublicAsBase64()};
|
|
16
|
+
})();
|
|
17
|
+
|
|
18
|
+
return pair;
|
|
19
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import net from 'node:net';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Test-only network guard.
|
|
5
|
+
*
|
|
6
|
+
* While installed, any TCP connection to a host that is not loopback throws
|
|
7
|
+
* immediately. fetch (undici), http, https, ws and axios all end up in
|
|
8
|
+
* net.Socket.prototype.connect, so this is the safety net behind the vendor mock:
|
|
9
|
+
* even code that bypasses global fetch cannot reach the network from a test.
|
|
10
|
+
*/
|
|
11
|
+
const LOOPBACK = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']);
|
|
12
|
+
|
|
13
|
+
let original: typeof net.Socket.prototype.connect | undefined;
|
|
14
|
+
let installs = 0;
|
|
15
|
+
export const blocked: string[] = [];
|
|
16
|
+
|
|
17
|
+
const targetOf = (args: any[]): {host: string; local: boolean} => {
|
|
18
|
+
// net.connect hands over a normalized [options, callback] array
|
|
19
|
+
const first = Array.isArray(args[0]) ? args[0][0] : args[0];
|
|
20
|
+
|
|
21
|
+
if (first && typeof first === 'object' && !Array.isArray(first)) {
|
|
22
|
+
if (first.path) return {host: String(first.path), local: true};
|
|
23
|
+
return {host: String(first.host ?? 'localhost'), local: false};
|
|
24
|
+
}
|
|
25
|
+
if (typeof first === 'number' || (typeof first === 'string' && !Number.isNaN(Number(first)))) {
|
|
26
|
+
return {host: typeof args[1] === 'string' ? args[1] : 'localhost', local: false};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// unix socket path
|
|
30
|
+
return {host: String(first), local: true};
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const installNetworkGuard = () => {
|
|
34
|
+
if (installs++ > 0) return;
|
|
35
|
+
|
|
36
|
+
original = net.Socket.prototype.connect;
|
|
37
|
+
|
|
38
|
+
(net.Socket.prototype as any).connect = function (this: net.Socket, ...args: any[]) {
|
|
39
|
+
const {host, local} = targetOf(args);
|
|
40
|
+
|
|
41
|
+
if (!local && !LOOPBACK.has(host)) {
|
|
42
|
+
const message = `NETWORK BLOCKED by connector test harness: connection to ${host} is not allowed in tests`;
|
|
43
|
+
blocked.push(host);
|
|
44
|
+
throw new Error(message);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return (original as any).apply(this, args);
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const uninstallNetworkGuard = () => {
|
|
52
|
+
if (installs === 0) return;
|
|
53
|
+
if (--installs > 0) return;
|
|
54
|
+
|
|
55
|
+
(net.Socket.prototype as any).connect = original;
|
|
56
|
+
original = undefined;
|
|
57
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import {AbstractController} from '../controller/index.mjs';
|
|
2
|
+
import {buildResolvers} from '../builder/runtime-context.mjs';
|
|
3
|
+
import type RuntimeContext from '../builder/runtime-context.mjs';
|
|
4
|
+
import {Connector} from '../internal/connector/index.mjs';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Turn what a connector repo builds (`await new Builder()...build()` gives a RuntimeContext) into a configured
|
|
8
|
+
* `Connector` that the ConnectorHarness can run, without calling `connector.run()` (which starts a metrics
|
|
9
|
+
* server on a fixed port and installs process signal handlers).
|
|
10
|
+
*
|
|
11
|
+
* This repeats the wiring of RuntimeContext.start(): methods become resolvers, config/oauth/endpoint are applied,
|
|
12
|
+
* main() starts the controller and sends a health check. If RuntimeContext.start() changes, change this too.
|
|
13
|
+
*/
|
|
14
|
+
export const connectorFromRuntimeContext = (context: RuntimeContext): Connector => {
|
|
15
|
+
const {controller, data} = context as any as {controller: AbstractController; data: any};
|
|
16
|
+
|
|
17
|
+
if (!(controller instanceof AbstractController)) {
|
|
18
|
+
throw new Error('the controller needs to extend AbstractController');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const connector = new Connector({
|
|
22
|
+
id: data.id,
|
|
23
|
+
version: data.version,
|
|
24
|
+
name: `${data.id}/${data.version}`,
|
|
25
|
+
icon: undefined,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const configuration = connector.configure().config(data.config || {});
|
|
29
|
+
const methods: string[] = [...data.methods, '__autocomplete', '__endpoint', '__default'];
|
|
30
|
+
|
|
31
|
+
configuration.types(data.types).resolvers(buildResolvers(methods, controller));
|
|
32
|
+
|
|
33
|
+
if (data.options?.endpoint?.enabled) {
|
|
34
|
+
configuration.endpoint((arg) => (controller as any).__endpoint(arg), data.options?.endpoint?.required);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (data.auth?.oauth) {
|
|
38
|
+
configuration.oauth(data.auth?.oauth);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
configuration.main(
|
|
42
|
+
async ({newTask, updateTask, config, oauth, getClient, getBlob, getBlobContent, createBlob, healthCheck}) => {
|
|
43
|
+
await (controller as any)._doStop();
|
|
44
|
+
await (controller as any)._doStart(config, oauth, newTask, updateTask, getClient, getBlob, getBlobContent, createBlob);
|
|
45
|
+
await healthCheck(controller);
|
|
46
|
+
}
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
return connector;
|
|
50
|
+
};
|