@deepexi/datasense-connector 3.0.1 → 3.1.0

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.
Files changed (185) hide show
  1. package/README.md +158 -139
  2. package/package.json +19 -28
  3. package/scripts/build.ts +188 -0
  4. package/src/define.ts +267 -0
  5. package/src/descriptor.ts +116 -0
  6. package/src/errors.ts +105 -0
  7. package/src/index.ts +108 -0
  8. package/src/internal/location.ts +23 -0
  9. package/src/runtime/guest/bridge.ts +238 -0
  10. package/src/runtime/guest/cache.ts +43 -0
  11. package/src/runtime/guest/check.ts +17 -0
  12. package/src/runtime/guest/crypto.ts +166 -0
  13. package/src/runtime/guest/ctx.ts +157 -0
  14. package/src/runtime/guest/db.ts +59 -0
  15. package/src/runtime/guest/failure.ts +69 -0
  16. package/src/runtime/guest/http.ts +194 -0
  17. package/src/runtime/guest/index.ts +102 -0
  18. package/{runtime/guest/limits.d.ts → src/runtime/guest/limits.ts} +12 -12
  19. package/src/runtime/guest/loader.ts +210 -0
  20. package/src/runtime/guest/log.ts +47 -0
  21. package/src/runtime/guest/map.ts +45 -0
  22. package/src/runtime/guest/operation.ts +193 -0
  23. package/src/runtime/guest/paginate.ts +169 -0
  24. package/src/runtime/guest/pull.ts +177 -0
  25. package/src/runtime/guest/push.ts +92 -0
  26. package/src/runtime/guest/signal.ts +14 -0
  27. package/src/runtime/guest/suggest.ts +51 -0
  28. package/src/runtime/guest/time.ts +137 -0
  29. package/src/runtime/guest-runtime.ts +75 -0
  30. package/src/runtime/index.ts +53 -0
  31. package/src/runtime/protocol.ts +557 -0
  32. package/src/runtime/session.ts +216 -0
  33. package/src/testing/child.ts +21 -0
  34. package/src/testing/compliance.ts +343 -0
  35. package/src/testing/engine.ts +445 -0
  36. package/src/testing/fixtures.ts +591 -0
  37. package/src/testing/gateway.ts +482 -0
  38. package/src/testing/import-guard.ts +221 -0
  39. package/src/testing/index.ts +43 -0
  40. package/src/testing/local-db.ts +226 -0
  41. package/src/testing/mock-external-system.ts +249 -0
  42. package/src/testing/pinned-http.ts +144 -0
  43. package/src/testing/recorder.ts +140 -0
  44. package/src/testing/redaction.ts +94 -0
  45. package/src/testing/replay.ts +437 -0
  46. package/src/testing/run-tests.ts +115 -0
  47. package/src/testing/schema.ts +70 -0
  48. package/src/testing/source-loader.ts +374 -0
  49. package/src/testing/types.ts +78 -0
  50. package/src/types.ts +329 -0
  51. package/src/validate.ts +267 -0
  52. package/test/build.test.ts +159 -0
  53. package/test/define.test-d.ts +182 -0
  54. package/test/fixtures/acceptance.ts +156 -0
  55. package/test/fixtures/connectors/http-pull/.ontos/descriptor.json +72 -0
  56. package/test/fixtures/connectors/http-pull/.ontos/types.ts +61 -0
  57. package/test/fixtures/connectors/http-pull/package.json +5 -0
  58. package/test/fixtures/connectors/http-pull/streams/items/index.ts +37 -0
  59. package/test/fixtures/connectors/http-pull/streams/items/map.ts +12 -0
  60. package/test/fixtures/connectors/http-pull/streams/items/tests/basic/case.json +15 -0
  61. package/test/fixtures/connectors/http-pull/streams/items/tests/basic/exchanges.ndjson +2 -0
  62. package/test/fixtures/connectors/pg-pull/.ontos/descriptor.json +151 -0
  63. package/test/fixtures/connectors/pg-pull/.ontos/types.ts +62 -0
  64. package/test/fixtures/connectors/pg-pull/lib/sql.ts +14 -0
  65. package/test/fixtures/connectors/pg-pull/package.json +5 -0
  66. package/test/fixtures/connectors/pg-pull/streams/customers/index.ts +30 -0
  67. package/test/fixtures/connectors/pg-pull/streams/customers/tests/customers-full/case.json +15 -0
  68. package/test/fixtures/connectors/pg-pull/streams/customers/tests/customers-full/exchanges.ndjson +3 -0
  69. package/test/fixtures/connectors/pg-pull/streams/orders/index.ts +32 -0
  70. package/test/fixtures/descriptor.ts +54 -0
  71. package/test/fixtures/kingdee-example.ts +82 -0
  72. package/test/fixtures/record-acceptance.ts +32 -0
  73. package/test/runtime/ctx.test.ts +434 -0
  74. package/test/runtime/helpers.ts +157 -0
  75. package/test/runtime/jobs.test.ts +444 -0
  76. package/test/runtime/operation.test.ts +175 -0
  77. package/test/runtime/session.test.ts +229 -0
  78. package/test/testing/acceptance-fixtures.test.ts +69 -0
  79. package/test/testing/compliance.test.ts +247 -0
  80. package/test/testing/engine.test.ts +283 -0
  81. package/test/testing/fixtures.test.ts +221 -0
  82. package/test/testing/gateway.test.ts +337 -0
  83. package/test/testing/import-guard.test.ts +45 -0
  84. package/test/testing/mock-external-system.test.ts +144 -0
  85. package/test/testing/operation-fixtures.test.ts +160 -0
  86. package/test/testing/recorder.test.ts +163 -0
  87. package/test/testing/redaction.test.ts +54 -0
  88. package/test/testing/replay.test.ts +207 -0
  89. package/test/testing/run-tests.test.ts +137 -0
  90. package/test/testing/source-loader.test.ts +220 -0
  91. package/test/validate-config.test.ts +104 -0
  92. package/tsconfig.build.json +23 -0
  93. package/tsconfig.json +19 -0
  94. package/cjs/index.cjs +0 -1015
  95. package/cjs/runtime.cjs +0 -752
  96. package/cjs/testing.cjs +0 -3591
  97. package/errors.d.ts +0 -17
  98. package/errors.js +0 -53
  99. package/guest.cjs +0 -2659
  100. package/index.d.ts +0 -13
  101. package/index.js +0 -7
  102. package/internal/location.d.ts +0 -6
  103. package/internal/location.js +0 -8
  104. package/presentation.d.ts +0 -38
  105. package/presentation.js +0 -268
  106. package/runtime/guest/bridge.d.ts +0 -53
  107. package/runtime/guest/bridge.js +0 -187
  108. package/runtime/guest/cache.d.ts +0 -3
  109. package/runtime/guest/cache.js +0 -42
  110. package/runtime/guest/crypto.d.ts +0 -13
  111. package/runtime/guest/crypto.js +0 -177
  112. package/runtime/guest/ctx.d.ts +0 -30
  113. package/runtime/guest/ctx.js +0 -124
  114. package/runtime/guest/db.d.ts +0 -6
  115. package/runtime/guest/db.js +0 -59
  116. package/runtime/guest/discover.d.ts +0 -29
  117. package/runtime/guest/discover.js +0 -51
  118. package/runtime/guest/failure.d.ts +0 -24
  119. package/runtime/guest/failure.js +0 -63
  120. package/runtime/guest/http.d.ts +0 -4
  121. package/runtime/guest/http.js +0 -204
  122. package/runtime/guest/index.d.ts +0 -12
  123. package/runtime/guest/index.js +0 -89
  124. package/runtime/guest/limits.js +0 -12
  125. package/runtime/guest/loader.d.ts +0 -15
  126. package/runtime/guest/loader.js +0 -42
  127. package/runtime/guest/log.d.ts +0 -10
  128. package/runtime/guest/log.js +0 -36
  129. package/runtime/guest/map.d.ts +0 -4
  130. package/runtime/guest/map.js +0 -45
  131. package/runtime/guest/paginate.d.ts +0 -6
  132. package/runtime/guest/paginate.js +0 -180
  133. package/runtime/guest/pull.d.ts +0 -12
  134. package/runtime/guest/pull.js +0 -150
  135. package/runtime/guest/push.d.ts +0 -18
  136. package/runtime/guest/push.js +0 -80
  137. package/runtime/guest/signal.d.ts +0 -3
  138. package/runtime/guest/signal.js +0 -12
  139. package/runtime/guest/time.d.ts +0 -5
  140. package/runtime/guest/time.js +0 -127
  141. package/runtime/guest-runtime.d.ts +0 -7
  142. package/runtime/guest-runtime.js +0 -51
  143. package/runtime/index.d.ts +0 -18
  144. package/runtime/index.js +0 -3
  145. package/runtime/protocol.d.ts +0 -276
  146. package/runtime/protocol.js +0 -409
  147. package/runtime/session.d.ts +0 -12
  148. package/runtime/session.js +0 -191
  149. package/t.d.ts +0 -18
  150. package/t.js +0 -26
  151. package/testing/child.cjs +0 -350
  152. package/testing/child.d.ts +0 -1
  153. package/testing/child.js +0 -12
  154. package/testing/compliance.d.ts +0 -41
  155. package/testing/compliance.js +0 -291
  156. package/testing/engine.d.ts +0 -66
  157. package/testing/engine.js +0 -339
  158. package/testing/fixtures.d.ts +0 -145
  159. package/testing/fixtures.js +0 -407
  160. package/testing/gateway.d.ts +0 -65
  161. package/testing/gateway.js +0 -402
  162. package/testing/import-guard.d.ts +0 -57
  163. package/testing/import-guard.js +0 -187
  164. package/testing/index.d.ts +0 -33
  165. package/testing/index.js +0 -10
  166. package/testing/local-db.d.ts +0 -27
  167. package/testing/local-db.js +0 -181
  168. package/testing/pinned-http.d.ts +0 -14
  169. package/testing/pinned-http.js +0 -125
  170. package/testing/recorder.d.ts +0 -15
  171. package/testing/recorder.js +0 -116
  172. package/testing/redaction.d.ts +0 -28
  173. package/testing/redaction.js +0 -67
  174. package/testing/replay.d.ts +0 -50
  175. package/testing/replay.js +0 -313
  176. package/testing/schema.d.ts +0 -13
  177. package/testing/schema.js +0 -65
  178. package/testing/source-loader.d.ts +0 -19
  179. package/testing/source-loader.js +0 -238
  180. package/testing/types.d.ts +0 -79
  181. package/testing/types.js +0 -18
  182. package/types.d.ts +0 -302
  183. package/types.js +0 -13
  184. package/validate.d.ts +0 -78
  185. package/validate.js +0 -597
@@ -0,0 +1,337 @@
1
+ /**
2
+ * 本地模拟网关:主机求交、元数据地址拦截、句柄替换与遮蔽、限流、数据库驱动缺失;可选的真实 Postgres 只读查询。
3
+ */
4
+ import { describe, expect, test } from 'bun:test';
5
+ import { defineCheck, definePush, type CheckFn, type ConfigField, type ConnectorDescriptor, type GeneratedDescriptor } from '../../src';
6
+ import type { EgressHttpRequest } from '../../src/runtime/protocol';
7
+ import { createLocalGateway, LocalRunError, runCheck, type LocalExchange } from '../../src/testing';
8
+ import { LocalLimiter } from '../../src/testing/gateway';
9
+ import { localPgUnavailable } from '../fixtures/acceptance';
10
+ import { configField, descriptor, push } from '../fixtures/descriptor';
11
+
12
+ const pgUnavailable = await localPgUnavailable();
13
+ if (pgUnavailable) console.warn(`[gateway] 本机 :15432 没有 Postgres,跳过真库用例:${pgUnavailable}`);
14
+
15
+ interface SentRequest { url: string; method: string; headers: Record<string, string> }
16
+
17
+ function fakeFetch(sent: SentRequest[], respond: (url: string, headers: Record<string, string>) => Response = () => Response.json({ ok: true })): typeof fetch {
18
+ const impl = async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
19
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
20
+ const headers = Object.fromEntries(new Headers(init?.headers).entries());
21
+ sent.push({ url, method: init?.method ?? 'GET', headers });
22
+ return respond(url, headers);
23
+ };
24
+ return impl as typeof fetch;
25
+ }
26
+
27
+ function get(url: string, headers: Record<string, string> = {}): EgressHttpRequest {
28
+ return { method: 'GET', url, headers, query: {}, body: null, bodyEncoding: 'utf8' };
29
+ }
30
+
31
+ /** 只有 check 的连接器(描述要有至少一个推送 / 数据流,补一个空推送)。 */
32
+ type Loose = GeneratedDescriptor & { config: Record<string, unknown>; secrets: Record<string, string> };
33
+
34
+ function checkConnector(name: string, run: CheckFn<Loose>, options: { config?: Record<string, ConfigField>; extra?: Partial<ConnectorDescriptor> } = {}) {
35
+ return {
36
+ descriptor: descriptor({ name, check: true, config: options.config ?? {}, pushes: { default: push({}) }, ...options.extra }),
37
+ implementations: { check: defineCheck<Loose>(run), pushes: { default: definePush('default', { map() {} }) } },
38
+ };
39
+ }
40
+
41
+ async function rejection(promise: Promise<unknown>): Promise<{ code?: unknown; message?: unknown }> {
42
+ try {
43
+ await promise;
44
+ } catch (error) {
45
+ return error as { code?: unknown; message?: unknown };
46
+ }
47
+ throw new Error('expected the call to fail');
48
+ }
49
+
50
+ describe('host intersection', () => {
51
+ test('a host outside hosts is HOST_DENIED and onExchange sees the failed exchange', async () => {
52
+ const exchanges: LocalExchange[] = [];
53
+ const sent: SentRequest[] = [];
54
+ const gateway = createLocalGateway({ hosts: ['api.example.com'], secrets: {}, fetch: fakeFetch(sent), onExchange: (entry) => exchanges.push(entry) });
55
+ const error = await rejection(gateway.call('http', get('https://other.example.com/x')));
56
+ expect(error).toBeInstanceOf(LocalRunError);
57
+ expect(error.code).toBe('HOST_DENIED');
58
+ expect(sent).toHaveLength(0);
59
+ expect(exchanges).toHaveLength(1);
60
+ expect(exchanges[0]?.kind).toBe('http');
61
+ expect(exchanges[0]?.error?.code).toBe('HOST_DENIED');
62
+ expect(exchanges[0]?.response).toBeNull();
63
+ });
64
+
65
+ test('the connector sees the platform code EGRESS_HOST_DENIED', async () => {
66
+ const connector = checkConnector('denied', async (ctx) => {
67
+ try {
68
+ await ctx.http.get('https://other.example.com/x');
69
+ } catch (error) {
70
+ ctx.log.info(`code=${String((error as { code?: unknown }).code)}`);
71
+ throw error;
72
+ }
73
+ });
74
+ const gateway = createLocalGateway({ hosts: ['api.example.com'], secrets: {}, fetch: fakeFetch([]) });
75
+ const result = await runCheck(connector, { config: {}, host: gateway });
76
+ expect(result.logs.map((log) => log.message)).toContain('code=EGRESS_HOST_DENIED');
77
+ expect(result.error).not.toBeNull();
78
+ });
79
+
80
+ test('wildcard hosts match subdomains only', async () => {
81
+ const sent: SentRequest[] = [];
82
+ const gateway = createLocalGateway({ hosts: ['*.example.com'], secrets: {}, fetch: fakeFetch(sent) });
83
+ await gateway.call('http', get('https://a.b.example.com/x'));
84
+ expect((await rejection(gateway.call('http', get('https://example.com/x')))).code).toBe('HOST_DENIED');
85
+ expect(sent.map((request) => request.url)).toEqual(['https://a.b.example.com/x']);
86
+ });
87
+
88
+ test('cloud metadata addresses stay denied even when listed; 127.0.0.1:8080 is allowed', async () => {
89
+ const sent: SentRequest[] = [];
90
+ const gateway = createLocalGateway({ hosts: ['169.254.169.254', '[::ffff:a9fe:a9fe]', '127.0.0.1:8080'], secrets: {}, fetch: fakeFetch(sent) });
91
+ expect((await rejection(gateway.call('http', get('http://169.254.169.254/')))).code).toBe('HOST_DENIED');
92
+ expect((await rejection(gateway.call('http', get('http://[::ffff:a9fe:a9fe]/')))).code).toBe('HOST_DENIED');
93
+ const response = await gateway.call('http', get('http://127.0.0.1:8080/health')) as { status: number };
94
+ expect(response.status).toBe(200);
95
+ expect(sent.map((request) => request.url)).toEqual(['http://127.0.0.1:8080/health']);
96
+ });
97
+
98
+ test('a metadata address as the db host is denied', async () => {
99
+ const gateway = createLocalGateway({
100
+ hosts: [], secrets: {}, db: { host: '169.254.169.254', port: 5432, database: 'x', user: 'u', password: 'p' },
101
+ });
102
+ expect((await rejection(gateway.call('db.query', { sql: 'select 1', params: [] }))).code).toBe('HOST_DENIED');
103
+ });
104
+ });
105
+
106
+ describe('secret handles and redaction', () => {
107
+ test('handles are substituted on the way out; onExchange keeps the handle form; the response is redacted', async () => {
108
+ const exchanges: LocalExchange[] = [];
109
+ const sent: SentRequest[] = [];
110
+ const gateway = createLocalGateway({
111
+ hosts: ['api.example.com'],
112
+ secrets: { token: 'abcd1234' },
113
+ // 上游把收到的鉴权头原样回显
114
+ fetch: fakeFetch(sent, (_url, headers) => Response.json({ echo: headers.authorization })),
115
+ onExchange: (entry) => exchanges.push(entry),
116
+ });
117
+ const response = await gateway.call('http', get('https://api.example.com/x?key=ontos:secret:token', { Authorization: 'Bearer ontos:secret:token' })) as { body: string };
118
+ expect(sent[0]?.headers.authorization).toBe('Bearer abcd1234');
119
+ expect(sent[0]?.url).toBe('https://api.example.com/x?key=abcd1234');
120
+ expect((exchanges[0]?.request as EgressHttpRequest).headers.Authorization).toBe('Bearer ontos:secret:token');
121
+ expect(JSON.parse((exchanges[0]?.response as { body: string }).body)).toEqual({ echo: 'Bearer abcd1234' });
122
+ expect(JSON.parse(response.body)).toEqual({ echo: 'Bearer ***' });
123
+ expect(gateway.redact('x abcd1234 y')).toBe('x *** y');
124
+ });
125
+
126
+ test('plaintext secrets that reach the logs are replaced with ***', async () => {
127
+ const sent: SentRequest[] = [];
128
+ const connector = checkConnector('redact', async (ctx) => {
129
+ const response = await ctx.http.get<{ echo: string }>('https://api.example.com/me', { headers: { authorization: `Bearer ${ctx.secrets.token}` } });
130
+ ctx.log.info(`echo ${response.data.echo}`);
131
+ ctx.log.info(`pin ${ctx.secrets.pin}`);
132
+ ctx.log.warn(`handle ${ctx.secrets.token}`);
133
+ }, { config: { token: configField('secret'), pin: configField('secret', { exposeToScript: true }) }, extra: { hosts: ['api.example.com'] } });
134
+ const gateway = createLocalGateway({
135
+ hosts: ['api.example.com'],
136
+ secrets: { token: 'abcd1234', pin: 'pin-5678' },
137
+ fetch: fakeFetch(sent, (_url, headers) => Response.json({ echo: headers.authorization })),
138
+ });
139
+ const result = await runCheck(connector, { config: {}, host: gateway });
140
+ expect(result.error).toBeNull();
141
+ expect(sent[0]?.headers.authorization).toBe('Bearer abcd1234');
142
+ expect(result.logs.map((log) => log.message)).toEqual(['echo Bearer ***', 'pin ***', 'handle ontos:secret:token']);
143
+ expect(JSON.stringify(result)).not.toContain('abcd1234');
144
+ expect(JSON.stringify(result)).not.toContain('pin-5678');
145
+ });
146
+ });
147
+
148
+ describe('rate limit', () => {
149
+ test('perSecond 2 with concurrency 1: four requests take at least 1.5 seconds', async () => {
150
+ const sent: SentRequest[] = [];
151
+ const gateway = createLocalGateway({ hosts: ['api.example.com'], secrets: {}, rateLimit: { perSecond: 2, concurrency: 1 }, fetch: fakeFetch(sent) });
152
+ const startedAt = Date.now();
153
+ await Promise.all([1, 2, 3, 4].map((n) => gateway.call('http', get(`https://api.example.com/${n}`))));
154
+ expect(Date.now() - startedAt).toBeGreaterThanOrEqual(1450);
155
+ expect(sent).toHaveLength(4);
156
+ });
157
+ });
158
+
159
+ describe('database', () => {
160
+ const DB = { host: 'localhost', port: 15432, database: 'postgres', user: 'admin', password: 'password' };
161
+
162
+ test('an unusable postgres driver is LocalRunError DB_DRIVER_MISSING', async () => {
163
+ const gateway = createLocalGateway({ hosts: [], secrets: {}, db: DB, postgres: { notTheDriver: true } });
164
+ const error = await rejection(gateway.call('db.query', { sql: 'select 1', params: [] }));
165
+ expect(error).toBeInstanceOf(LocalRunError);
166
+ expect(error.code).toBe('DB_DRIVER_MISSING');
167
+ });
168
+
169
+ test('a run whose ctx.db cannot load the driver ends with DB_DRIVER_MISSING', async () => {
170
+ const connector = checkConnector('dbcheck', async (ctx) => {
171
+ for await (const row of ctx.db.query('select 1 as one')) ctx.log.info(JSON.stringify(row));
172
+ }, { extra: { db: { engine: 'postgresql' } } });
173
+ const gateway = createLocalGateway({ hosts: [], secrets: {}, db: DB, postgres: { notTheDriver: true } });
174
+ const result = await runCheck(connector, { config: {}, host: gateway });
175
+ expect(result.error?.code).toBe('DB_DRIVER_MISSING');
176
+ });
177
+
178
+ test('ctx.db without local db configuration is DB_PROXY_NOT_DECLARED', async () => {
179
+ const gateway = createLocalGateway({ hosts: [], secrets: {} });
180
+ expect((await rejection(gateway.call('db.query', { sql: 'select 1', params: [] }))).code).toBe('DB_PROXY_NOT_DECLARED');
181
+ });
182
+
183
+ // 本机没有 :15432 的 Postgres 时跳过
184
+ test.skipIf(Boolean(pgUnavailable))('a real Postgres (localhost:15432) runs a read-only query with cursor paging', async () => {
185
+ const gateway = createLocalGateway({ hosts: [], secrets: {}, db: DB });
186
+ try {
187
+ const first = await gateway.call('db.query', {
188
+ sql: 'select g as n, $1::text as tag from generate_series(1, 2500) g', params: ['x'],
189
+ }) as { cursorId: string | null; fields: Array<{ name: string }>; rows: Array<Record<string, unknown>>; done: boolean };
190
+ expect(first.fields.map((field) => field.name)).toEqual(['n', 'tag']);
191
+ expect(first.rows).toHaveLength(1000);
192
+ expect(first.done).toBe(false);
193
+ let total = first.rows.length;
194
+ let done = first.done;
195
+ while (!done) {
196
+ const page = await gateway.call('db.next', { cursorId: first.cursorId }) as { rows: unknown[]; done: boolean };
197
+ total += page.rows.length;
198
+ done = page.done;
199
+ }
200
+ expect(total).toBe(2500);
201
+ const write = await rejection(gateway.call('db.query', { sql: 'create table sdk_local_db_probe (id int)', params: [] }));
202
+ expect(write.code).toBe('DB_PROXY_FAILED');
203
+ expect(String(write.message)).toContain('read-only');
204
+ } finally {
205
+ await gateway.close();
206
+ }
207
+ });
208
+ });
209
+
210
+ describe('cache, crypto and secret.reveal', () => {
211
+ test('cache entries live in memory; crypto substitutes handles; secret.reveal returns plaintext', async () => {
212
+ const gateway = createLocalGateway({ hosts: [], secrets: { key: 'k-secret' } });
213
+ expect(await gateway.call('cache.get', { key: 'a' })).toBeNull();
214
+ await gateway.call('cache.set', { key: 'a', value: 'v', ttlSeconds: 60 });
215
+ expect((await gateway.call('cache.get', { key: 'a' }) as { value: string }).value).toBe('v');
216
+ const hmac = await gateway.call('crypto', { op: 'hmac', algorithm: 'sha256', key: 'ontos:secret:key', data: 'payload', encoding: 'hex' }) as { value: string };
217
+ const { createHmac } = await import('node:crypto');
218
+ expect(hmac.value).toBe(createHmac('sha256', 'k-secret').update('payload').digest('hex'));
219
+ expect(await gateway.call('secret.reveal', { field: 'key' })).toEqual({ value: 'k-secret' });
220
+ });
221
+ });
222
+
223
+ describe('review fixes: encoded secrets, limiter hand-off, pinned connections', () => {
224
+ test('URL-encoded and form-encoded secrets are redacted in the redirected url, the body and the recorder', async () => {
225
+ const secret = 'ab+cd/ef== x';
226
+ const exchanges: LocalExchange[] = [];
227
+ const sent: SentRequest[] = [];
228
+ const gateway = createLocalGateway({
229
+ hosts: ['api.example.com'],
230
+ secrets: { token: secret },
231
+ fetch: fakeFetch(sent, (url) => (url.includes('/start')
232
+ ? new Response(null, { status: 302, headers: { location: `/next?key=${encodeURIComponent(secret)}` } })
233
+ : Response.json({ echoed: url, form: new URLSearchParams([['k', secret]]).toString() }))),
234
+ onExchange: (entry) => exchanges.push(entry),
235
+ });
236
+ const response = await gateway.call('http', get('https://api.example.com/start?key=ontos:secret:token')) as { url: string; body: string };
237
+ expect(sent[0]?.url).toContain(encodeURIComponent(secret));
238
+ expect(response.url).toBe('https://api.example.com/next?key=***');
239
+ expect(response.body).not.toContain(encodeURIComponent(secret));
240
+ expect(response.body).not.toContain('ab%2Bcd%2Fef%3D%3D+x');
241
+ expect(gateway.redact(`a ${encodeURIComponent(secret)} b`)).toBe('a *** b');
242
+
243
+ const { createRecorder } = await import('../../src/testing');
244
+ const recorder = createRecorder({ secrets: { token: secret }, extraSecrets: ['db-password-1'] });
245
+ for (const entry of exchanges) recorder.onExchange(entry);
246
+ recorder.onExchange({ kind: 'db', request: { sql: "select 'db-password-1'", params: [] }, response: { fields: [], rows: [], cursorId: null, done: true }, error: null, durationMs: 1 });
247
+ const recorded = JSON.stringify(recorder.exchanges());
248
+ expect(recorded).not.toContain(encodeURIComponent(secret));
249
+ expect(recorded).not.toContain('ab%2Bcd%2Fef%3D%3D+x');
250
+ expect(recorded).not.toContain('db-password-1');
251
+ });
252
+
253
+ test('concurrency 1: a slot freed while others wait goes to the next waiter, not to a request arriving in between', async () => {
254
+ const limiter = new LocalLimiter(1_000_000, 1);
255
+ let holders = 0;
256
+ let peak = 0;
257
+ const hold = async (): Promise<() => void> => {
258
+ const release = await limiter.acquire();
259
+ holders += 1;
260
+ peak = Math.max(peak, holders);
261
+ return () => {
262
+ holders -= 1;
263
+ release();
264
+ };
265
+ };
266
+ const releaseFirst = await hold();
267
+ const waiter = hold();
268
+ await Promise.resolve();
269
+ releaseFirst();
270
+ // 同一时刻新来的请求:旧实现会让它和被唤醒的等待者同时拿到槽位。
271
+ const newcomer = hold();
272
+ const releaseWaiter = await waiter;
273
+ await new Promise((resolve) => setTimeout(resolve, 5));
274
+ expect(peak).toBe(1);
275
+ releaseWaiter();
276
+ (await newcomer)();
277
+ expect(peak).toBe(1);
278
+ });
279
+
280
+ test('real network: the connection goes to the checked address with the original Host; every hop re-resolves', async () => {
281
+ const hosts: string[] = [];
282
+ const server = Bun.serve({
283
+ hostname: '127.0.0.1',
284
+ port: 0,
285
+ fetch(request) {
286
+ hosts.push(request.headers.get('host') ?? '');
287
+ const url = new URL(request.url);
288
+ if (url.pathname === '/start') return new Response(null, { status: 302, headers: { location: '/end' } });
289
+ return Response.json({ path: url.pathname });
290
+ },
291
+ });
292
+ try {
293
+ let calls = 0;
294
+ const resolve = async () => {
295
+ calls += 1;
296
+ return [{ address: '127.0.0.1', family: 4 }];
297
+ };
298
+ const gateway = createLocalGateway({ hosts: ['rebind.test'], secrets: {}, resolve });
299
+ const response = await gateway.call('http', get(`http://rebind.test:${server.port}/start`)) as { status: number; body: string };
300
+ expect(response.status).toBe(200);
301
+ expect(JSON.parse(response.body)).toEqual({ path: '/end' });
302
+ expect(hosts).toEqual([`rebind.test:${server.port}`, `rebind.test:${server.port}`]);
303
+ expect(calls).toBe(2);
304
+
305
+ // 第二次解析换成元数据地址(DNS 重绑定):在重定向那一跳被拦下,不会连过去。
306
+ let flips = 0;
307
+ const rebinding = createLocalGateway({
308
+ hosts: ['rebind.test'],
309
+ secrets: {},
310
+ resolve: async () => (flips++ === 0 ? [{ address: '127.0.0.1', family: 4 }] : [{ address: '169.254.169.254', family: 4 }]),
311
+ });
312
+ hosts.length = 0;
313
+ expect((await rejection(rebinding.call('http', get(`http://rebind.test:${server.port}/start`)))).code).toBe('HOST_DENIED');
314
+ expect(hosts).toHaveLength(1);
315
+ } finally {
316
+ server.stop(true);
317
+ }
318
+ });
319
+
320
+ test('real network: an unresolvable host is EGRESS_UPSTREAM_FAILED; a gzip body is decompressed', async () => {
321
+ const server = Bun.serve({
322
+ hostname: '127.0.0.1',
323
+ port: 0,
324
+ fetch: () => new Response(Bun.gzipSync(Buffer.from('{"ok":true}')), { headers: { 'content-encoding': 'gzip', 'content-type': 'application/json' } }),
325
+ });
326
+ try {
327
+ const failing = createLocalGateway({ hosts: ['nowhere.test'], secrets: {}, resolve: async () => { throw new Error('ENOTFOUND'); } });
328
+ expect((await rejection(failing.call('http', get('http://nowhere.test/')))).code).toBe('EGRESS_UPSTREAM_FAILED');
329
+ const gateway = createLocalGateway({ hosts: ['127.0.0.1'], secrets: {} });
330
+ const response = await gateway.call('http', get(`http://127.0.0.1:${server.port}/`, { 'accept-encoding': 'gzip' })) as { body: string; headers: Record<string, string> };
331
+ expect(JSON.parse(response.body)).toEqual({ ok: true });
332
+ expect(response.headers['content-encoding']).toBeUndefined();
333
+ } finally {
334
+ server.stop(true);
335
+ }
336
+ });
337
+ });
@@ -0,0 +1,45 @@
1
+ /**
2
+ * SDK 的导入检查与 shared `connector-source-guard.ts` 的对照:同一批样例两份实现结论一致(SDK 不能依赖 shared)。
3
+ */
4
+ import { describe, expect, test } from 'bun:test';
5
+ import ts from 'typescript';
6
+ import { findForbiddenSourceSyntax as sharedGuard } from '../../../shared/src/server/connector-source-guard';
7
+ import { classifySpecifier, findForbiddenSourceSyntax } from '../../src/testing/import-guard';
8
+
9
+ const RETIRED = ['@mu-ontos', 'connector-sdk'].join('/');
10
+
11
+ const SAMPLES: Array<[string, string, string]> = [
12
+ ['clean', 'src/index.ts', "import { defineConnector } from '@deepexi/datasense-connector';\nexport default defineConnector;\n"],
13
+ ['sdk subpath', 'src/index.ts', "import { runStream } from '@deepexi/datasense-connector/testing';\n"],
14
+ ['retired sdk', 'src/index.ts', `import { t } from '${RETIRED}';\n`],
15
+ ['macro scheme', 'src/index.ts', "import x from 'macro:./m';\n"],
16
+ ['import attributes', 'src/index.ts', "import data from './a.json' with { type: 'json' };\n"],
17
+ ['assert attributes', 'src/index.ts', "import data from './a.json' assert { type: 'json' };\n"],
18
+ ['dynamic import with options', 'src/index.ts', "export async function f() { return import('./b', { with: { type: 'macro' } }); }\n"],
19
+ ['export from subpath', 'src/index.ts', "export { t } from '@deepexi/datasense-connector/runtime';\n"],
20
+ ['require subpath', 'src/lib.js', "const x = require('@deepexi/datasense-connector/testing');\n"],
21
+ ['import equals', 'src/index.ts', "import sdk = require('@deepexi/datasense-connector/testing');\n"],
22
+ ['syntax error', 'src/index.ts', 'export const = 1;\n'],
23
+ ['tsx', 'src/view.tsx', 'export const x = <div>{1}</div>;\n'],
24
+ ['node builtin is not a guard concern', 'src/index.ts', "import fs from 'node:fs';\nexport const f = fs;\n"],
25
+ ];
26
+
27
+ describe('findForbiddenSourceSyntax matches shared', () => {
28
+ test.each(SAMPLES)('%s', async (_name, file, content) => {
29
+ const local = findForbiddenSourceSyntax(ts, file, content).map(({ kind: _kind, ...rest }) => rest);
30
+ expect(local).toEqual(await sharedGuard(file, content));
31
+ });
32
+ });
33
+
34
+ describe('classifySpecifier', () => {
35
+ test('sdk, relative, escape and forbidden', () => {
36
+ expect(classifySpecifier('@deepexi/datasense-connector', 'src/index.ts').kind).toBe('sdk');
37
+ expect(classifySpecifier('./map', 'src/index.ts').kind).toBe('relative');
38
+ expect(classifySpecifier('../lib/x', 'src/a/index.ts').kind).toBe('relative');
39
+ expect(classifySpecifier('../../outside', 'src/index.ts').kind).toBe('escape');
40
+ expect(classifySpecifier('node:fs', 'src/index.ts').kind).toBe('forbidden');
41
+ expect(classifySpecifier('fs', 'src/index.ts').kind).toBe('forbidden');
42
+ expect(classifySpecifier('lodash', 'src/index.ts').kind).toBe('forbidden');
43
+ expect(classifySpecifier('@deepexi/datasense-connector/testing', 'src/index.ts').kind).toBe('forbidden');
44
+ });
45
+ });
@@ -0,0 +1,144 @@
1
+ /**
2
+ * 模拟外部系统:路由与占位、排好的成功 / 拒绝 / 超时 / 慢响应、请求记录,以及经本地模拟网关跑连接器操作的完整用法。
3
+ */
4
+ import { describe, expect, test } from 'bun:test';
5
+ import { defineOperation, defineStream, errors } from '../../src';
6
+ import { createInProcessExecutor, createLocalGateway, createMockExternalSystem, runOperation, type MockExternalSystem } from '../../src/testing';
7
+ import { configField, descriptor, operation, stream, table, valueField } from '../fixtures/descriptor';
8
+
9
+ type D = {
10
+ config: { baseUrl: string };
11
+ secrets: Record<never, never>;
12
+ streams: { orders: { tables: { orders: { id: string } }; params: Record<never, never> } };
13
+ extensibleStreams: Record<never, never>;
14
+ pushes: Record<never, never>;
15
+ operations: { create_order: { input: { sku: string; qty: number }; output: { externalId: string }; idempotency: 'supported' } };
16
+ };
17
+
18
+ function connectorWith(requestTimeoutMs: number) {
19
+ return {
20
+ descriptor: descriptor({
21
+ name: 'erp', hosts: ['erp.example.com'], config: { baseUrl: configField('string') },
22
+ streams: { orders: stream({ orders: table({ id: 'string' }, ['id']) }, { syncModes: ['full'] }) },
23
+ operations: {
24
+ create_order: operation({ sku: valueField('string'), qty: valueField('integer') }, { externalId: valueField('string') }, { risk: 'high' }),
25
+ },
26
+ }),
27
+ implementations: {
28
+ streams: { orders: defineStream<D, 'orders'>('orders', async function* () {}) },
29
+ operations: {
30
+ create_order: defineOperation<D, 'create_order'>('create_order', {
31
+ async run(ctx, params, meta) {
32
+ const res = await ctx.http.post(`${ctx.config.baseUrl}/orders`, {
33
+ headers: { 'Idempotency-Key': meta.idempotencyKey }, body: params, raw: true, timeoutMs: requestTimeoutMs,
34
+ });
35
+ const data = JSON.parse(res.text) as { id?: string; code?: string; message?: string };
36
+ if (res.status === 422) throw new errors.RejectedError(data.message ?? 'rejected', data.code !== undefined ? { code: data.code } : undefined);
37
+ if (res.status >= 400) throw new errors.FatalError(`unexpected status ${res.status}`);
38
+ return { externalId: String(data.id) };
39
+ },
40
+ }),
41
+ },
42
+ },
43
+ };
44
+ }
45
+
46
+ function erp(): MockExternalSystem {
47
+ let next = 0;
48
+ return createMockExternalSystem({
49
+ 'POST /orders': () => {
50
+ next += 1;
51
+ return { status: 201, body: { id: `SO-${next}` } };
52
+ },
53
+ 'GET /orders/:id': ({ params }) => ({ body: { id: params.id } }),
54
+ });
55
+ }
56
+
57
+ async function createOrder(system: MockExternalSystem, options: { requestTimeoutMs?: number; timeoutMs?: number } = {}) {
58
+ const host = createLocalGateway({ hosts: ['erp.example.com'], secrets: {}, fetch: system.fetch });
59
+ try {
60
+ return await runOperation(createInProcessExecutor(connectorWith(options.requestTimeoutMs ?? 100)), {
61
+ config: { baseUrl: 'https://erp.example.com' }, operation: 'create_order', params: { sku: 'A-1', qty: 2 },
62
+ idempotencyKey: 'stable:order-1', host, secretFields: [], ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
63
+ });
64
+ } finally {
65
+ await host.close();
66
+ }
67
+ }
68
+
69
+ describe('createMockExternalSystem', () => {
70
+ test("script(['timeout', 'ok']):第一次请求超时(TRANSIENT_ERROR),第二次成功;calls 记下两次请求与 Idempotency-Key", async () => {
71
+ const system = erp();
72
+ system.script('POST /orders', ['timeout', 'ok']);
73
+
74
+ const first = await createOrder(system);
75
+ expect(first).toMatchObject({ ok: false, code: 'TRANSIENT_ERROR' });
76
+ const second = await createOrder(system);
77
+ expect(second).toMatchObject({ ok: true, output: { externalId: 'SO-1' } });
78
+
79
+ const calls = system.calls('POST /orders');
80
+ expect(calls).toHaveLength(2);
81
+ for (const call of calls) {
82
+ expect(call).toMatchObject({ method: 'POST', path: '/orders', body: { sku: 'A-1', qty: 2 } });
83
+ expect(call.headers['idempotency-key']).toBe('stable:order-1');
84
+ }
85
+ });
86
+
87
+ test('执行器超时先到时得到 CANCELED', async () => {
88
+ const system = erp();
89
+ system.script('POST /orders', ['timeout']);
90
+ const result = await createOrder(system, { requestTimeoutMs: 1_000, timeoutMs: 100 });
91
+ expect(result).toMatchObject({ ok: false, code: 'CANCELED' });
92
+ });
93
+
94
+ test('reject 回 422,操作抛 RejectedError(REJECTED,带外部系统的业务码)', async () => {
95
+ const system = erp();
96
+ system.script('POST /orders', ['reject']);
97
+ const result = await createOrder(system);
98
+ expect(result).toMatchObject({ ok: false, code: 'REJECTED', external: { code: 'REJECTED' } });
99
+
100
+ const response = await system.fetch('https://erp.example.com/orders', { method: 'POST', body: '{}' });
101
+ expect(response.status).toBe(201);
102
+ });
103
+
104
+ test('{ slowMs } 延迟后照常应答;排完的结果之后回到处理函数', async () => {
105
+ const system = erp();
106
+ system.script('POST /orders', [{ slowMs: 30 }]);
107
+ const startedAt = Date.now();
108
+ const slow = await system.fetch('https://erp.example.com/orders', { method: 'POST' });
109
+ expect(Date.now() - startedAt).toBeGreaterThanOrEqual(25);
110
+ expect(slow.status).toBe(201);
111
+ expect((await system.fetch('https://erp.example.com/orders', { method: 'POST' })).status).toBe(201);
112
+ });
113
+
114
+ test('路径占位、查询参数、未定义路由 404;calls 不带参数返回全部,reset 清空记录与排好的结果', async () => {
115
+ const seen: Array<{ query: Record<string, string>; headers: Record<string, string> }> = [];
116
+ const system = createMockExternalSystem({
117
+ 'get /orders/:id': ({ params, query, headers }) => {
118
+ seen.push({ query, headers });
119
+ return { body: { id: params.id } };
120
+ },
121
+ });
122
+ const found = await system.fetch('https://erp.example.com/orders/SO%2F9?expand=lines', { headers: { 'X-Trace': 't1' } });
123
+ expect(await found.json()).toEqual({ id: 'SO/9' });
124
+ expect(seen).toEqual([{ query: { expand: 'lines' }, headers: { 'x-trace': 't1' } }]);
125
+
126
+ const missing = await system.fetch('https://erp.example.com/customers', { method: 'POST' });
127
+ expect(missing.status).toBe(404);
128
+ expect(system.calls().map((call) => `${call.method} ${call.path}`)).toEqual(['GET /orders/SO%2F9', 'POST /customers']);
129
+ expect(system.calls('GET /orders/:id')).toHaveLength(1);
130
+
131
+ system.script('GET /orders/:id', ['reject']);
132
+ system.reset();
133
+ expect(system.calls()).toEqual([]);
134
+ expect((await system.fetch('https://erp.example.com/orders/1')).status).toBe(200);
135
+ });
136
+
137
+ test('路由键不合法、script 未定义的路由或不认识的步骤时抛 TypeError', () => {
138
+ expect(() => createMockExternalSystem({ '/orders': () => ({}) })).toThrow(TypeError);
139
+ const system = erp();
140
+ expect(() => system.script('DELETE /orders', ['ok'])).toThrow(TypeError);
141
+ expect(() => system.script('POST /orders', ['later' as 'ok'])).toThrow(TypeError);
142
+ expect(() => system.calls('PUT /x')).toThrow(TypeError);
143
+ });
144
+ });