@aloma.io/integration-sdk 3.8.66 → 3.8.68
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/builder/runtime-context.mjs +2 -2
- package/build/cli.mjs +0 -0
- package/build/controller/index.d.mts +3 -2
- package/build/controller/index.mjs +4 -3
- package/build/internal/dispatcher/index.d.mts +1 -4
- package/build/internal/dispatcher/index.mjs +3 -2
- 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/package.json +8 -4
- package/src/builder/runtime-context.mts +2 -2
- package/src/controller/index.mts +4 -3
- package/src/internal/dispatcher/index.mts +3 -2
- 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/test/MUTATION-entry.md +21 -0
- package/test/MUTATION.md +43 -0
- package/test/dispatcher-ctx-forwarding.test.mjs +161 -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/tools/mutation-run-entry.mjs +52 -0
- package/tools/mutation-run.mjs +120 -0
- package/tools/run-tests.mjs +64 -0
|
@@ -0,0 +1,139 @@
|
|
|
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 { FakeAloma } from './fake-aloma.mjs';
|
|
6
|
+
import { testKeyPair } from './keys.mjs';
|
|
7
|
+
import { VendorMock } from './vendor-mock.mjs';
|
|
8
|
+
/**
|
|
9
|
+
* Runs a real connector against a fake ALOMA, in-process.
|
|
10
|
+
*
|
|
11
|
+
* It wires the same SDK parts as Connector.run() and makeServer(): Config, WebsocketConnector (connection,
|
|
12
|
+
* registration, transport), onConnect (config decryption, main()), onMessage (dispatch and reply).
|
|
13
|
+
* Left out on purpose: the Prometheus metrics server (fixed port 4050), process signal handlers and
|
|
14
|
+
* env-var configuration, which are global side effects that cannot run once per test.
|
|
15
|
+
*/
|
|
16
|
+
export class ConnectorHarness {
|
|
17
|
+
aloma;
|
|
18
|
+
vendor;
|
|
19
|
+
server;
|
|
20
|
+
started;
|
|
21
|
+
constructor(aloma, vendor, server, started) {
|
|
22
|
+
this.aloma = aloma;
|
|
23
|
+
this.vendor = vendor;
|
|
24
|
+
this.server = server;
|
|
25
|
+
this.started = started;
|
|
26
|
+
}
|
|
27
|
+
static async start(options) {
|
|
28
|
+
const { connector, timeoutMs = 5000 } = options;
|
|
29
|
+
const dispatcher = connector.dispatcher;
|
|
30
|
+
if (!dispatcher)
|
|
31
|
+
throw new Error('call connector.configure() before starting the harness');
|
|
32
|
+
const vendor = options.vendor || new VendorMock();
|
|
33
|
+
const aloma = await FakeAloma.start();
|
|
34
|
+
vendor.allowPassthrough((url) => url.origin === new URL(aloma.url).origin);
|
|
35
|
+
vendor.install();
|
|
36
|
+
// main() is what a connector runs once ALOMA has sent its configuration; observe it
|
|
37
|
+
let mainDone;
|
|
38
|
+
let mainFailed;
|
|
39
|
+
const started = new Promise((resolve, reject) => {
|
|
40
|
+
mainDone = resolve;
|
|
41
|
+
mainFailed = reject;
|
|
42
|
+
});
|
|
43
|
+
started.catch(() => { });
|
|
44
|
+
const originalMain = dispatcher._main;
|
|
45
|
+
dispatcher.main(async (arg) => {
|
|
46
|
+
try {
|
|
47
|
+
await originalMain?.(arg);
|
|
48
|
+
mainDone();
|
|
49
|
+
}
|
|
50
|
+
catch (e) {
|
|
51
|
+
mainFailed(e);
|
|
52
|
+
throw e;
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
let server;
|
|
56
|
+
try {
|
|
57
|
+
const { processPacket, start, introspect, configSchema } = dispatcher.build();
|
|
58
|
+
const keys = await testKeyPair();
|
|
59
|
+
const config = new Config({
|
|
60
|
+
id: connector.id,
|
|
61
|
+
version: connector.version,
|
|
62
|
+
name: connector.name,
|
|
63
|
+
registrationToken: aloma.registrationToken,
|
|
64
|
+
endpoint: aloma.url,
|
|
65
|
+
wsEndpoint: aloma.wsUrl,
|
|
66
|
+
privateKey: keys.privateKey,
|
|
67
|
+
publicKey: keys.publicKey,
|
|
68
|
+
introspect,
|
|
69
|
+
configSchema,
|
|
70
|
+
icon: connector.icon,
|
|
71
|
+
});
|
|
72
|
+
server = new WebsocketConnector({
|
|
73
|
+
config,
|
|
74
|
+
onConnect: onConnect({ config, configSchema, dispatcher, start }),
|
|
75
|
+
onMessage: onMessage(processPacket),
|
|
76
|
+
});
|
|
77
|
+
const harness = new ConnectorHarness(aloma, vendor, server, options.config === null ? null : started);
|
|
78
|
+
await server.start();
|
|
79
|
+
await aloma.waitForConnection(timeoutMs);
|
|
80
|
+
if (options.config !== null) {
|
|
81
|
+
await aloma.setConfig(options.config || {});
|
|
82
|
+
await withTimeout(started, timeoutMs, 'connector main() did not finish after the configuration was sent');
|
|
83
|
+
}
|
|
84
|
+
return harness;
|
|
85
|
+
}
|
|
86
|
+
catch (e) {
|
|
87
|
+
await ConnectorHarness.shutdown(server, vendor, aloma);
|
|
88
|
+
throw e;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
static async shutdown(server, vendor, aloma) {
|
|
92
|
+
try {
|
|
93
|
+
if (server) {
|
|
94
|
+
const anyServer = server;
|
|
95
|
+
await anyServer.leaving();
|
|
96
|
+
await anyServer.close();
|
|
97
|
+
// the SDK leaves a 45s ping watchdog timer behind; clear it so a test run can exit
|
|
98
|
+
clearTimeout(anyServer.transport?.ws?.pingTimeout);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
vendor.uninstall();
|
|
103
|
+
await aloma.close();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/** call a connector method and return the raw answer (`{error}` when it failed) */
|
|
107
|
+
call(method, variables, ctx) {
|
|
108
|
+
return this.aloma.call(method, variables, ctx);
|
|
109
|
+
}
|
|
110
|
+
/** call a connector method; throws when the connector answered with an error */
|
|
111
|
+
async callOk(method, variables) {
|
|
112
|
+
const answer = await this.call(method, variables);
|
|
113
|
+
if (answer?.error)
|
|
114
|
+
throw new Error(`${method} failed: ${answer.error}`);
|
|
115
|
+
return answer;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Shut down the connector and the fake endpoint, restore fetch and the network guard.
|
|
119
|
+
* Then fails if the connector made any request that was not mocked or the fake endpoint saw a protocol error.
|
|
120
|
+
*/
|
|
121
|
+
async stop({ verify = true } = {}) {
|
|
122
|
+
await ConnectorHarness.shutdown(this.server, this.vendor, this.aloma);
|
|
123
|
+
if (verify) {
|
|
124
|
+
this.vendor.verify();
|
|
125
|
+
if (this.aloma.protocolErrors.length) {
|
|
126
|
+
throw new Error(`protocol errors seen by the fake ALOMA: ${JSON.stringify(this.aloma.protocolErrors)}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const withTimeout = (promise, ms, message) => {
|
|
132
|
+
let timer;
|
|
133
|
+
return Promise.race([
|
|
134
|
+
promise,
|
|
135
|
+
new Promise((_, reject) => {
|
|
136
|
+
timer = setTimeout(() => reject(new Error(message)), ms);
|
|
137
|
+
}),
|
|
138
|
+
]).finally(() => clearTimeout(timer));
|
|
139
|
+
};
|
|
@@ -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,13 @@
|
|
|
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 { FakeAloma } from './fake-aloma.mjs';
|
|
10
|
+
export { VendorMock } from './vendor-mock.mjs';
|
|
11
|
+
export { installNetworkGuard, uninstallNetworkGuard } from './network-guard.mjs';
|
|
12
|
+
export { testKeyPair } from './keys.mjs';
|
|
13
|
+
export { connectorFromRuntimeContext } from './runtime-connector.mjs';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One throwaway RSA key pair per process, generated at test time.
|
|
3
|
+
* Never committed, never a real key. Same encoding the connector expects in
|
|
4
|
+
* env.PRIVATE_KEY / env.PUBLIC_KEY (base64 of the PEM).
|
|
5
|
+
*/
|
|
6
|
+
export declare const testKeyPair: () => Promise<{
|
|
7
|
+
privateKey: string;
|
|
8
|
+
publicKey: string;
|
|
9
|
+
}>;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import JWE from '../internal/util/jwe/index.mjs';
|
|
2
|
+
let pair;
|
|
3
|
+
/**
|
|
4
|
+
* One throwaway RSA key pair per process, generated at test time.
|
|
5
|
+
* Never committed, never a real key. Same encoding the connector expects in
|
|
6
|
+
* env.PRIVATE_KEY / env.PUBLIC_KEY (base64 of the PEM).
|
|
7
|
+
*/
|
|
8
|
+
export const testKeyPair = () => {
|
|
9
|
+
pair ||= (async () => {
|
|
10
|
+
const jwe = new JWE({ algorithm: 'RSA-OAEP-256' });
|
|
11
|
+
await jwe.newPair();
|
|
12
|
+
return { privateKey: await jwe.exportPrivateAsBase64(), publicKey: await jwe.exportPublicAsBase64() };
|
|
13
|
+
})();
|
|
14
|
+
return pair;
|
|
15
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import net from 'node:net';
|
|
2
|
+
/**
|
|
3
|
+
* Test-only network guard.
|
|
4
|
+
*
|
|
5
|
+
* While installed, any TCP connection to a host that is not loopback throws
|
|
6
|
+
* immediately. fetch (undici), http, https, ws and axios all end up in
|
|
7
|
+
* net.Socket.prototype.connect, so this is the safety net behind the vendor mock:
|
|
8
|
+
* even code that bypasses global fetch cannot reach the network from a test.
|
|
9
|
+
*/
|
|
10
|
+
const LOOPBACK = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']);
|
|
11
|
+
let original;
|
|
12
|
+
let installs = 0;
|
|
13
|
+
export const blocked = [];
|
|
14
|
+
const targetOf = (args) => {
|
|
15
|
+
// net.connect hands over a normalized [options, callback] array
|
|
16
|
+
const first = Array.isArray(args[0]) ? args[0][0] : args[0];
|
|
17
|
+
if (first && typeof first === 'object' && !Array.isArray(first)) {
|
|
18
|
+
if (first.path)
|
|
19
|
+
return { host: String(first.path), local: true };
|
|
20
|
+
return { host: String(first.host ?? 'localhost'), local: false };
|
|
21
|
+
}
|
|
22
|
+
if (typeof first === 'number' || (typeof first === 'string' && !Number.isNaN(Number(first)))) {
|
|
23
|
+
return { host: typeof args[1] === 'string' ? args[1] : 'localhost', local: false };
|
|
24
|
+
}
|
|
25
|
+
// unix socket path
|
|
26
|
+
return { host: String(first), local: true };
|
|
27
|
+
};
|
|
28
|
+
export const installNetworkGuard = () => {
|
|
29
|
+
if (installs++ > 0)
|
|
30
|
+
return;
|
|
31
|
+
original = net.Socket.prototype.connect;
|
|
32
|
+
net.Socket.prototype.connect = function (...args) {
|
|
33
|
+
const { host, local } = targetOf(args);
|
|
34
|
+
if (!local && !LOOPBACK.has(host)) {
|
|
35
|
+
const message = `NETWORK BLOCKED by connector test harness: connection to ${host} is not allowed in tests`;
|
|
36
|
+
blocked.push(host);
|
|
37
|
+
throw new Error(message);
|
|
38
|
+
}
|
|
39
|
+
return original.apply(this, args);
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
export const uninstallNetworkGuard = () => {
|
|
43
|
+
if (installs === 0)
|
|
44
|
+
return;
|
|
45
|
+
if (--installs > 0)
|
|
46
|
+
return;
|
|
47
|
+
net.Socket.prototype.connect = original;
|
|
48
|
+
original = undefined;
|
|
49
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type RuntimeContext from '../builder/runtime-context.mjs';
|
|
2
|
+
import { Connector } from '../internal/connector/index.mjs';
|
|
3
|
+
/**
|
|
4
|
+
* Turn what a connector repo builds (`await new Builder()...build()` gives a RuntimeContext) into a configured
|
|
5
|
+
* `Connector` that the ConnectorHarness can run, without calling `connector.run()` (which starts a metrics
|
|
6
|
+
* server on a fixed port and installs process signal handlers).
|
|
7
|
+
*
|
|
8
|
+
* This repeats the wiring of RuntimeContext.start(): methods become resolvers, config/oauth/endpoint are applied,
|
|
9
|
+
* main() starts the controller and sends a health check. If RuntimeContext.start() changes, change this too.
|
|
10
|
+
*/
|
|
11
|
+
export declare const connectorFromRuntimeContext: (context: RuntimeContext) => Connector;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { AbstractController } from '../controller/index.mjs';
|
|
2
|
+
import { buildResolvers } from '../builder/runtime-context.mjs';
|
|
3
|
+
import { Connector } from '../internal/connector/index.mjs';
|
|
4
|
+
/**
|
|
5
|
+
* Turn what a connector repo builds (`await new Builder()...build()` gives a RuntimeContext) into a configured
|
|
6
|
+
* `Connector` that the ConnectorHarness can run, without calling `connector.run()` (which starts a metrics
|
|
7
|
+
* server on a fixed port and installs process signal handlers).
|
|
8
|
+
*
|
|
9
|
+
* This repeats the wiring of RuntimeContext.start(): methods become resolvers, config/oauth/endpoint are applied,
|
|
10
|
+
* main() starts the controller and sends a health check. If RuntimeContext.start() changes, change this too.
|
|
11
|
+
*/
|
|
12
|
+
export const connectorFromRuntimeContext = (context) => {
|
|
13
|
+
const { controller, data } = context;
|
|
14
|
+
if (!(controller instanceof AbstractController)) {
|
|
15
|
+
throw new Error('the controller needs to extend AbstractController');
|
|
16
|
+
}
|
|
17
|
+
const connector = new Connector({
|
|
18
|
+
id: data.id,
|
|
19
|
+
version: data.version,
|
|
20
|
+
name: `${data.id}/${data.version}`,
|
|
21
|
+
icon: undefined,
|
|
22
|
+
});
|
|
23
|
+
const configuration = connector.configure().config(data.config || {});
|
|
24
|
+
const methods = [...data.methods, '__autocomplete', '__endpoint', '__default'];
|
|
25
|
+
configuration.types(data.types).resolvers(buildResolvers(methods, controller));
|
|
26
|
+
if (data.options?.endpoint?.enabled) {
|
|
27
|
+
configuration.endpoint((arg) => controller.__endpoint(arg), data.options?.endpoint?.required);
|
|
28
|
+
}
|
|
29
|
+
if (data.auth?.oauth) {
|
|
30
|
+
configuration.oauth(data.auth?.oauth);
|
|
31
|
+
}
|
|
32
|
+
configuration.main(async ({ newTask, updateTask, config, oauth, getClient, getBlob, getBlobContent, createBlob, healthCheck }) => {
|
|
33
|
+
await controller._doStop();
|
|
34
|
+
await controller._doStart(config, oauth, newTask, updateTask, getClient, getBlob, getBlobContent, createBlob);
|
|
35
|
+
await healthCheck(controller);
|
|
36
|
+
});
|
|
37
|
+
return connector;
|
|
38
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/// <reference types="node" resolution-mode="require"/>
|
|
2
|
+
export type VendorRequest = {
|
|
3
|
+
method: string;
|
|
4
|
+
url: string;
|
|
5
|
+
/** the url exactly as the connector passed it to fetch */
|
|
6
|
+
rawUrl: string;
|
|
7
|
+
headers: Record<string, string>;
|
|
8
|
+
body?: string;
|
|
9
|
+
};
|
|
10
|
+
export type VendorResponse = {
|
|
11
|
+
status?: number;
|
|
12
|
+
headers?: Record<string, string>;
|
|
13
|
+
/** sent as JSON with a json content-type */
|
|
14
|
+
json?: unknown;
|
|
15
|
+
/** sent as is */
|
|
16
|
+
text?: string;
|
|
17
|
+
};
|
|
18
|
+
export type VendorResponder = VendorResponse | ((request: VendorRequest) => VendorResponse | Promise<VendorResponse>);
|
|
19
|
+
export type UrlMatcher = string | RegExp | ((url: string) => boolean);
|
|
20
|
+
/**
|
|
21
|
+
* Mock for the HTTP calls a connector makes to its vendor API.
|
|
22
|
+
*
|
|
23
|
+
* - intercepts global fetch (what the SDK Fetcher and OAuth code use)
|
|
24
|
+
* - answers from routes you register with on()/get()/post()...
|
|
25
|
+
* - any request without a route is recorded in `unmocked` and rejected,
|
|
26
|
+
* verify() then fails the test even if the connector swallowed the error
|
|
27
|
+
* - also installs the network guard, so nothing can reach a non-loopback host
|
|
28
|
+
*/
|
|
29
|
+
export declare class VendorMock {
|
|
30
|
+
readonly requests: VendorRequest[];
|
|
31
|
+
readonly unmocked: VendorRequest[];
|
|
32
|
+
private routes;
|
|
33
|
+
private originalFetch?;
|
|
34
|
+
private passthrough;
|
|
35
|
+
/** urls for which the real fetch is used (the fake ALOMA endpoint); everything else is mocked */
|
|
36
|
+
allowPassthrough(fn: (url: URL) => boolean): this;
|
|
37
|
+
on(method: string, matcher: UrlMatcher, respond: VendorResponder, options?: {
|
|
38
|
+
times?: number;
|
|
39
|
+
}): this;
|
|
40
|
+
get(matcher: UrlMatcher, respond: VendorResponder, options?: {
|
|
41
|
+
times?: number;
|
|
42
|
+
}): this;
|
|
43
|
+
post(matcher: UrlMatcher, respond: VendorResponder, options?: {
|
|
44
|
+
times?: number;
|
|
45
|
+
}): this;
|
|
46
|
+
put(matcher: UrlMatcher, respond: VendorResponder, options?: {
|
|
47
|
+
times?: number;
|
|
48
|
+
}): this;
|
|
49
|
+
delete(matcher: UrlMatcher, respond: VendorResponder, options?: {
|
|
50
|
+
times?: number;
|
|
51
|
+
}): this;
|
|
52
|
+
install(): this;
|
|
53
|
+
uninstall(): void;
|
|
54
|
+
/** throws if the connector made a request that had no mock, even if it caught the error */
|
|
55
|
+
verify(): void;
|
|
56
|
+
/** throws if a registered route was never used */
|
|
57
|
+
verifyAllRoutesUsed(): void;
|
|
58
|
+
private handle;
|
|
59
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { installNetworkGuard, uninstallNetworkGuard } from './network-guard.mjs';
|
|
2
|
+
// "https:/host/x" (one slash, which the SDK Fetcher produces) and "https://host/x" are the same URL to fetch
|
|
3
|
+
const canonical = (url) => {
|
|
4
|
+
try {
|
|
5
|
+
return new URL(url).href;
|
|
6
|
+
}
|
|
7
|
+
catch (e) {
|
|
8
|
+
return url;
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
const matches = (matcher, url) => {
|
|
12
|
+
if (typeof matcher === 'string')
|
|
13
|
+
return canonical(matcher) === url;
|
|
14
|
+
if (matcher instanceof RegExp)
|
|
15
|
+
return matcher.test(url);
|
|
16
|
+
return matcher(url);
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Mock for the HTTP calls a connector makes to its vendor API.
|
|
20
|
+
*
|
|
21
|
+
* - intercepts global fetch (what the SDK Fetcher and OAuth code use)
|
|
22
|
+
* - answers from routes you register with on()/get()/post()...
|
|
23
|
+
* - any request without a route is recorded in `unmocked` and rejected,
|
|
24
|
+
* verify() then fails the test even if the connector swallowed the error
|
|
25
|
+
* - also installs the network guard, so nothing can reach a non-loopback host
|
|
26
|
+
*/
|
|
27
|
+
export class VendorMock {
|
|
28
|
+
requests = [];
|
|
29
|
+
unmocked = [];
|
|
30
|
+
routes = [];
|
|
31
|
+
originalFetch;
|
|
32
|
+
passthrough = () => false;
|
|
33
|
+
/** urls for which the real fetch is used (the fake ALOMA endpoint); everything else is mocked */
|
|
34
|
+
allowPassthrough(fn) {
|
|
35
|
+
this.passthrough = fn;
|
|
36
|
+
return this;
|
|
37
|
+
}
|
|
38
|
+
on(method, matcher, respond, options = {}) {
|
|
39
|
+
this.routes.push({ method: method.toUpperCase(), matcher, respond, times: options.times ?? Infinity, used: 0 });
|
|
40
|
+
return this;
|
|
41
|
+
}
|
|
42
|
+
get(matcher, respond, options) {
|
|
43
|
+
return this.on('GET', matcher, respond, options);
|
|
44
|
+
}
|
|
45
|
+
post(matcher, respond, options) {
|
|
46
|
+
return this.on('POST', matcher, respond, options);
|
|
47
|
+
}
|
|
48
|
+
put(matcher, respond, options) {
|
|
49
|
+
return this.on('PUT', matcher, respond, options);
|
|
50
|
+
}
|
|
51
|
+
delete(matcher, respond, options) {
|
|
52
|
+
return this.on('DELETE', matcher, respond, options);
|
|
53
|
+
}
|
|
54
|
+
install() {
|
|
55
|
+
if (this.originalFetch)
|
|
56
|
+
return this;
|
|
57
|
+
installNetworkGuard();
|
|
58
|
+
this.originalFetch = globalThis.fetch;
|
|
59
|
+
globalThis.fetch = ((input, init) => this.handle(input, init));
|
|
60
|
+
return this;
|
|
61
|
+
}
|
|
62
|
+
uninstall() {
|
|
63
|
+
if (!this.originalFetch)
|
|
64
|
+
return;
|
|
65
|
+
globalThis.fetch = this.originalFetch;
|
|
66
|
+
this.originalFetch = undefined;
|
|
67
|
+
uninstallNetworkGuard();
|
|
68
|
+
}
|
|
69
|
+
/** throws if the connector made a request that had no mock, even if it caught the error */
|
|
70
|
+
verify() {
|
|
71
|
+
if (this.unmocked.length) {
|
|
72
|
+
const list = this.unmocked.map((r) => `${r.method} ${r.url}`).join('\n ');
|
|
73
|
+
throw new Error(`UNMOCKED vendor requests were made (no network is allowed in tests):\n ${list}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** throws if a registered route was never used */
|
|
77
|
+
verifyAllRoutesUsed() {
|
|
78
|
+
const unused = this.routes.filter((r) => r.used === 0);
|
|
79
|
+
if (unused.length) {
|
|
80
|
+
throw new Error(`mocked routes never called: ${unused.map((r) => `${r.method} ${String(r.matcher)}`).join(', ')}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
async handle(input, init = {}) {
|
|
84
|
+
const rawUrl = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
|
85
|
+
const url = canonical(rawUrl);
|
|
86
|
+
const method = String(init.method || (typeof input === 'object' && input.method) || 'GET').toUpperCase();
|
|
87
|
+
const headers = {};
|
|
88
|
+
new Headers(init.headers || (typeof input === 'object' && input.headers) || {}).forEach((value, key) => {
|
|
89
|
+
headers[key] = value;
|
|
90
|
+
});
|
|
91
|
+
const request = { method, url, rawUrl, headers };
|
|
92
|
+
if (init.body != null)
|
|
93
|
+
request.body = typeof init.body === 'string' ? init.body : String(init.body);
|
|
94
|
+
const route = this.routes.find((r) => r.method === method && r.used < r.times && matches(r.matcher, url));
|
|
95
|
+
if (route) {
|
|
96
|
+
route.used++;
|
|
97
|
+
this.requests.push(request);
|
|
98
|
+
const spec = typeof route.respond === 'function' ? await route.respond(request) : route.respond;
|
|
99
|
+
const responseHeaders = { ...spec.headers };
|
|
100
|
+
let body = null;
|
|
101
|
+
if (spec.json !== undefined) {
|
|
102
|
+
body = JSON.stringify(spec.json);
|
|
103
|
+
responseHeaders['content-type'] ||= 'application/json';
|
|
104
|
+
}
|
|
105
|
+
else if (spec.text !== undefined) {
|
|
106
|
+
body = spec.text;
|
|
107
|
+
}
|
|
108
|
+
const status = spec.status ?? 200;
|
|
109
|
+
return new Response([204, 205, 304].includes(status) ? null : body, { status, headers: responseHeaders });
|
|
110
|
+
}
|
|
111
|
+
if (this.originalFetch && this.passthrough(new URL(url))) {
|
|
112
|
+
return this.originalFetch(input, init);
|
|
113
|
+
}
|
|
114
|
+
this.unmocked.push(request);
|
|
115
|
+
// status 400 makes the SDK Fetcher give up at once instead of retrying for seconds
|
|
116
|
+
const error = new Error(`UNMOCKED vendor request: ${method} ${url} (add a route with vendor.on(...))`);
|
|
117
|
+
error.status = 400;
|
|
118
|
+
error.alomaHarnessUnmocked = true;
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aloma.io/integration-sdk",
|
|
3
|
-
"version": "3.8.
|
|
3
|
+
"version": "3.8.68",
|
|
4
4
|
"description": "",
|
|
5
5
|
"author": "aloma.io",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
"scripts": {
|
|
12
12
|
"dev": "./node_modules/typescript/bin/tsc --watch",
|
|
13
13
|
"build": "./node_modules/typescript/bin/tsc",
|
|
14
|
-
"test": "
|
|
14
|
+
"test": "node tools/run-tests.mjs",
|
|
15
|
+
"test:harness": "./node_modules/mocha/bin/_mocha --exit \"test/harness*.test.mjs\"",
|
|
16
|
+
"test:mutation": "node tools/mutation-run.mjs",
|
|
15
17
|
"test:scenarios": "node test/verify-scenarios.mjs",
|
|
16
18
|
"test:all": "npm run test:scenarios",
|
|
17
19
|
"format": "yarn prettier --write src/",
|
|
@@ -55,11 +57,13 @@
|
|
|
55
57
|
"ts-node": "^10.9.2"
|
|
56
58
|
},
|
|
57
59
|
"resolutions": {
|
|
58
|
-
"path-to-regexp": "0.1.13"
|
|
60
|
+
"path-to-regexp": "0.1.13",
|
|
61
|
+
"qs": ">=6.15.2",
|
|
62
|
+
"serialize-javascript": ">=7.0.5"
|
|
59
63
|
},
|
|
60
64
|
"repository": {
|
|
61
65
|
"type": "git",
|
|
62
|
-
"url": "https://github.com/aloma-io/integration.git",
|
|
66
|
+
"url": "git+https://github.com/aloma-io/integration.git",
|
|
63
67
|
"directory": "integration-sdk"
|
|
64
68
|
},
|
|
65
69
|
"publishConfig": {
|
|
@@ -16,9 +16,9 @@ export function buildResolvers(methods: string[], controller: any): any {
|
|
|
16
16
|
const resolvers: any = {};
|
|
17
17
|
|
|
18
18
|
methods.forEach((method) => {
|
|
19
|
-
const handler = async (args: any) => {
|
|
19
|
+
const handler = async (args: any, ctx?: any) => {
|
|
20
20
|
if (!methods.includes(method)) throw new Error(`${method} not found`);
|
|
21
|
-
return controller[method](args);
|
|
21
|
+
return controller[method](args, ctx);
|
|
22
22
|
};
|
|
23
23
|
|
|
24
24
|
if (method.includes('.')) {
|
package/src/controller/index.mts
CHANGED
|
@@ -51,8 +51,9 @@ export abstract class AbstractController {
|
|
|
51
51
|
/**
|
|
52
52
|
* called, when the remote public method is not found on the controller
|
|
53
53
|
* @param arg
|
|
54
|
+
* @param ctx optional invocation context (taskId, namespace, sticky)
|
|
54
55
|
*/
|
|
55
|
-
protected fallback(arg: any): Promise<any> {
|
|
56
|
+
protected fallback(arg: any, ctx?: any): Promise<any> {
|
|
56
57
|
throw new Error('method not found');
|
|
57
58
|
}
|
|
58
59
|
|
|
@@ -186,8 +187,8 @@ export abstract class AbstractController {
|
|
|
186
187
|
/**
|
|
187
188
|
* @ignore
|
|
188
189
|
**/
|
|
189
|
-
async __default(arg: any): Promise<any | null> {
|
|
190
|
-
return this.fallback(arg);
|
|
190
|
+
async __default(arg: any, ctx?: any): Promise<any | null> {
|
|
191
|
+
return this.fallback(arg, ctx);
|
|
191
192
|
}
|
|
192
193
|
|
|
193
194
|
/**
|
|
@@ -163,7 +163,8 @@ ${arg.configurableClientScope}
|
|
|
163
163
|
return current;
|
|
164
164
|
};
|
|
165
165
|
|
|
166
|
-
const execute = async ({query, variables}) => {
|
|
166
|
+
const execute = async ({query, variables, taskId, namespace, sticky}: any) => {
|
|
167
|
+
const ctx = {taskId, namespace, sticky};
|
|
167
168
|
if (!Array.isArray(query)) query = [query];
|
|
168
169
|
|
|
169
170
|
query = query
|
|
@@ -177,7 +178,7 @@ ${arg.configurableClientScope}
|
|
|
177
178
|
const method = resolveMethod(query);
|
|
178
179
|
if (!method && !_resolvers.__default) throw new Error(`${originalQuery} not found`);
|
|
179
180
|
|
|
180
|
-
return method ? method(variables) : _resolvers.__default(variables ? {...variables, __method: originalQuery} : variables);
|
|
181
|
+
return method ? method(variables, ctx) : _resolvers.__default(variables ? {...variables, __method: originalQuery} : variables, ctx);
|
|
181
182
|
};
|
|
182
183
|
|
|
183
184
|
const introspect = () => local._types;
|