sap-ai-dev-toolkit 0.9.9913 → 0.9.9915
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/.github/agents/sap-integration-architect.agent.md +38 -0
- package/.github/agents/sap-integration-developer.agent.md +31 -0
- package/.github/agents/sap-solution-architect.agent.md +1 -0
- package/.github/skills/integration-suite-delivery/SKILL.md +74 -0
- package/.github/skills/sap-integration-architect/SKILL.md +76 -0
- package/NOTICE +10 -0
- package/README.md +35 -8
- package/dist/checksums.txt +5 -5
- package/dist/vsp-darwin-arm64 +0 -0
- package/dist/vsp-darwin-x64 +0 -0
- package/dist/vsp-linux-arm64 +0 -0
- package/dist/vsp-linux-x64 +0 -0
- package/dist/vsp-win32-x64.exe +0 -0
- package/inventory.md +62 -0
- package/package.json +5 -2
- package/scripts/run-live-integration.mjs +1079 -0
- package/src/apim-bundle.mjs +381 -0
- package/src/apim-tools.mjs +641 -0
- package/src/cf-destination.mjs +5 -5
- package/src/cf-integration.mjs +74 -0
- package/src/cpi-tools.mjs +1042 -0
- package/src/iflow-bundle.mjs +282 -0
- package/src/integration-common.mjs +232 -0
- package/src/integration-config.mjs +167 -0
- package/src/integration-credentials.mjs +65 -0
- package/src/integration-http.mjs +282 -0
- package/src/integration-server.mjs +125 -0
- package/src/integration-templates/https-request-reply-http.iflw +391 -0
- package/src/integration-tools.mjs +27 -0
- package/src/integration-verify.mjs +118 -0
- package/src/launcher.mjs +14 -2
- package/src/mcp-config.mjs +18 -3
- package/src/setup.mjs +310 -11
- package/tools.md +76 -0
|
@@ -0,0 +1,1079 @@
|
|
|
1
|
+
// Live end-to-end scenarios (S01–S94) for the Integration Suite companion against a real CPI + APIM tenant.
|
|
2
|
+
// WRITES TO THE TENANT: creates a package, iFlows (deployed, one undeployed again), a user credential, a number
|
|
3
|
+
// range, a Partner Directory parameter, API providers, API proxies (with policies), an API product and a key value
|
|
4
|
+
// map, all named with the RUN suffix. There are no delete tools; remove the artifacts in the Integration Suite UI.
|
|
5
|
+
// Usage: CPI_SERVICE_KEY=… APIM_SERVICE_KEY=… RUN=<unique suffix> npm run test:live:integration
|
|
6
|
+
// ONLY=S01,S02 limits the run to selected scenarios (later scenarios may depend on earlier ones).
|
|
7
|
+
import { readFile } from 'node:fs/promises';
|
|
8
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
9
|
+
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
|
10
|
+
import { createIntegrationServer } from '../src/integration-server.mjs';
|
|
11
|
+
import { INTEGRATION_TOOL_NAMES } from '../src/integration-tools.mjs';
|
|
12
|
+
|
|
13
|
+
const RUN = process.env.RUN;
|
|
14
|
+
if (!RUN || !/^[A-Za-z0-9]{1,8}$/.test(RUN)) {
|
|
15
|
+
console.error('Set RUN to a unique 1-8 character alphanumeric suffix; every run creates new tenant artifacts.');
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
const ONLY = process.env.ONLY ? new Set(process.env.ONLY.split(',')) : null;
|
|
19
|
+
const PKG = `ZAIDEVSMOKE${RUN}`;
|
|
20
|
+
const IFLOW = `Z_AI_Smoke_${RUN}`;
|
|
21
|
+
const IFLOW_ONPREM = `Z_AI_OnPrem_${RUN}`;
|
|
22
|
+
const IFLOW_TIMER_OK = `Z_AI_Timer_Ok_${RUN}`;
|
|
23
|
+
const IFLOW_TIMER_FAIL = `Z_AI_Timer_Fail_${RUN}`;
|
|
24
|
+
const PROVIDER = `ZAI_SMOKE_HTTPBIN${RUN}`;
|
|
25
|
+
const PROVIDER_ONPREM = `ZAI_SMOKE_ONPREM${RUN}`;
|
|
26
|
+
const PROXY = `ZAI_Smoke${RUN}`;
|
|
27
|
+
const PROXY_URL = `ZAI_SmokeUrl${RUN}`;
|
|
28
|
+
const PROXY_ODATA = `ZAI_SmokeOData${RUN}`;
|
|
29
|
+
const PROXY_CPI = `ZAI_SmokeCpi${RUN}`;
|
|
30
|
+
const SECRETS = [process.env.CPI_SERVICE_KEY, process.env.APIM_SERVICE_KEY].flatMap(raw => {
|
|
31
|
+
const key = JSON.parse(raw);
|
|
32
|
+
return [key.oauth?.clientsecret, key.clientSecret].filter(Boolean);
|
|
33
|
+
});
|
|
34
|
+
const PRODUCT = `ZAI_SmokeProduct${RUN}`;
|
|
35
|
+
const KVM = `ZAI_SmokeKvm${RUN}`;
|
|
36
|
+
const CREDENTIAL = `ZAI_SMOKE_USER_${RUN}`;
|
|
37
|
+
const NUMBER_RANGE = `ZAI_SMOKE_NR_${RUN}`;
|
|
38
|
+
const PARTNER = `ZAI_SMOKE_PARTNER_${RUN}`;
|
|
39
|
+
// Write-only secrets of this run: every tool output is scanned for them like the service-key secrets.
|
|
40
|
+
const RUN_SECRETS = { credential: `pw-${RUN}-${Date.now().toString(36)}`, kvm: `kvm-${RUN}-${Date.now().toString(36)}` };
|
|
41
|
+
SECRETS.push(...Object.values(RUN_SECRETS));
|
|
42
|
+
|
|
43
|
+
const runtime = createIntegrationServer({ pollIntervalMs: 3000, readBackDelayMs: 5000 });
|
|
44
|
+
const [a, b] = InMemoryTransport.createLinkedPair();
|
|
45
|
+
const client = new Client({ name: 'live-scenarios', version: '1' });
|
|
46
|
+
await runtime.server.connect(b);
|
|
47
|
+
await client.connect(a);
|
|
48
|
+
|
|
49
|
+
const ctx = {};
|
|
50
|
+
const results = [];
|
|
51
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
52
|
+
|
|
53
|
+
async function call(name, args = {}) {
|
|
54
|
+
const response = await client.callTool({ name, arguments: args });
|
|
55
|
+
const text = response.content[0].text;
|
|
56
|
+
for (const secret of SECRETS) if (text.includes(secret)) throw new Error(`SECRET LEAK in ${name}`);
|
|
57
|
+
let json;
|
|
58
|
+
try { json = JSON.parse(text); } catch { /* error text */ }
|
|
59
|
+
return { isError: response.isError === true, text, json };
|
|
60
|
+
}
|
|
61
|
+
function ok(result, label) {
|
|
62
|
+
if (result.isError) throw new Error(`${label}: ${result.text}`);
|
|
63
|
+
return result.json;
|
|
64
|
+
}
|
|
65
|
+
function expect(condition, message) {
|
|
66
|
+
if (!condition) throw new Error(message);
|
|
67
|
+
}
|
|
68
|
+
async function http(url, init) {
|
|
69
|
+
const response = await fetch(url, { ...init, signal: AbortSignal.timeout(30000) });
|
|
70
|
+
return { status: response.status, text: await response.text() };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---- CPI runtime calls (integration-flow plan key; test harness only, never used by the add-in)
|
|
74
|
+
const RUNTIME_KEY = process.env.CPI_RUNTIME_SERVICE_KEY ? (JSON.parse(process.env.CPI_RUNTIME_SERVICE_KEY).oauth ?? JSON.parse(process.env.CPI_RUNTIME_SERVICE_KEY)) : null;
|
|
75
|
+
if (RUNTIME_KEY?.clientsecret) SECRETS.push(RUNTIME_KEY.clientsecret);
|
|
76
|
+
let runtimeToken = null;
|
|
77
|
+
async function runtimeBearer() {
|
|
78
|
+
if (!RUNTIME_KEY) throw new Error('CPI_RUNTIME_SERVICE_KEY (Process Integration Runtime integration-flow plan) is required for runtime scenarios');
|
|
79
|
+
if (runtimeToken && runtimeToken.expiresAt > Date.now()) return runtimeToken.value;
|
|
80
|
+
const response = await fetch(RUNTIME_KEY.tokenurl, {
|
|
81
|
+
method: 'POST',
|
|
82
|
+
headers: { authorization: `Basic ${Buffer.from(`${RUNTIME_KEY.clientid}:${RUNTIME_KEY.clientsecret}`).toString('base64')}`, 'content-type': 'application/x-www-form-urlencoded' },
|
|
83
|
+
body: 'grant_type=client_credentials'
|
|
84
|
+
});
|
|
85
|
+
const payload = await response.json();
|
|
86
|
+
runtimeToken = { value: payload.access_token, expiresAt: Date.now() + (payload.expires_in - 120) * 1000 };
|
|
87
|
+
return runtimeToken.value;
|
|
88
|
+
}
|
|
89
|
+
// Call an endpoint until `done(result)` holds (endpoints register and redeploys switch over with a lag).
|
|
90
|
+
async function invoke(url, { method = 'POST', body, headers = {}, auth = true, done = result => result.status !== 404 && result.status !== 503, attempts = 36 } = {}) {
|
|
91
|
+
let result;
|
|
92
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
93
|
+
const response = await fetch(url, {
|
|
94
|
+
method,
|
|
95
|
+
headers: { ...(auth ? { authorization: `Bearer ${await runtimeBearer()}` } : {}), ...(body !== undefined ? { 'content-type': 'application/json' } : {}), ...headers },
|
|
96
|
+
body,
|
|
97
|
+
signal: AbortSignal.timeout(60000)
|
|
98
|
+
});
|
|
99
|
+
const text = await response.text();
|
|
100
|
+
let json;
|
|
101
|
+
try { json = JSON.parse(text); } catch { /* not JSON */ }
|
|
102
|
+
result = { status: response.status, text, json };
|
|
103
|
+
if (done(result)) return result;
|
|
104
|
+
await sleep(5000);
|
|
105
|
+
}
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
108
|
+
function cleanError(result, pattern) {
|
|
109
|
+
expect(result.isError, `expected an error, got ${result.text.slice(0, 200)}`);
|
|
110
|
+
expect(result.text !== 'Integration Suite request failed. Check service-key roles and tenant reachability.', 'generic error text hides the cause');
|
|
111
|
+
if (pattern) expect(pattern.test(result.text), `error text: ${result.text.slice(0, 300)}`);
|
|
112
|
+
return result.text.replace(/\s+/g, ' ').slice(0, 160);
|
|
113
|
+
}
|
|
114
|
+
async function waitForGuid(guid) {
|
|
115
|
+
for (let attempt = 0; attempt < 24; attempt += 1) {
|
|
116
|
+
const result = await call('cpi_get_message_log', { message_guid: guid });
|
|
117
|
+
if (!result.isError && result.json?.log?.Status && result.json.log.Status !== 'PROCESSING') return result.json;
|
|
118
|
+
await sleep(5000);
|
|
119
|
+
}
|
|
120
|
+
throw new Error(`message ${guid} not visible after 120 s`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function scenario(id, title, body) {
|
|
124
|
+
if (ONLY && !ONLY.has(id)) return;
|
|
125
|
+
const started = Date.now();
|
|
126
|
+
try {
|
|
127
|
+
const note = await body();
|
|
128
|
+
results.push({ id, title, status: 'PASS', note: note ?? '', ms: Date.now() - started });
|
|
129
|
+
console.log(`PASS ${id} ${title}${note ? ` — ${note}` : ''}`);
|
|
130
|
+
} catch (error) {
|
|
131
|
+
results.push({ id, title, status: 'FAIL', note: error.message, ms: Date.now() - started });
|
|
132
|
+
console.log(`FAIL ${id} ${title} — ${error.message}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---- custom timer BPMN derived from the vetted template
|
|
137
|
+
const template = await readFile(new URL('../src/integration-templates/https-request-reply-http.iflw', import.meta.url), 'utf8');
|
|
138
|
+
function timerIflw() {
|
|
139
|
+
let xml = template;
|
|
140
|
+
xml = xml.replace(/\s*<bpmn2:participant id="Participant_1"[\s\S]*?<\/bpmn2:participant>/, '');
|
|
141
|
+
xml = xml.replace(/\s*<bpmn2:messageFlow id="MessageFlow_4"[\s\S]*?<\/bpmn2:messageFlow>/, '');
|
|
142
|
+
xml = xml.replace(/\s*<bpmndi:BPMNShape bpmnElement="Participant_1"[\s\S]*?<\/bpmndi:BPMNShape>/, '');
|
|
143
|
+
xml = xml.replace(/\s*<bpmndi:BPMNEdge bpmnElement="MessageFlow_4"[\s\S]*?<\/bpmndi:BPMNEdge>/, '');
|
|
144
|
+
xml = xml.replace(/<bpmn2:startEvent id="StartEvent_2" name="Start">[\s\S]*?<\/bpmn2:startEvent>/,
|
|
145
|
+
`<bpmn2:startEvent id="StartEvent_2" name="Start Timer 1">
|
|
146
|
+
<bpmn2:outgoing>SequenceFlow_3</bpmn2:outgoing>
|
|
147
|
+
<bpmn2:timerEventDefinition id="TimerEventDefinition_1">
|
|
148
|
+
<bpmn2:extensionElements>
|
|
149
|
+
<ifl:property><key>scheduleKey</key><value>{{Timer_Schedule}}</value></ifl:property>
|
|
150
|
+
<ifl:property><key>componentVersion</key><value>1.0</value></ifl:property>
|
|
151
|
+
<ifl:property><key>cmdVariantUri</key><value>ctype::FlowstepVariant/cname::intermediatetimer/version::1.0.0</value></ifl:property>
|
|
152
|
+
<ifl:property><key>activityType</key><value>StartTimerEvent</value></ifl:property>
|
|
153
|
+
</bpmn2:extensionElements>
|
|
154
|
+
</bpmn2:timerEventDefinition>
|
|
155
|
+
</bpmn2:startEvent>`);
|
|
156
|
+
if (xml.includes('Participant_1') || xml.includes('MessageFlow_4')) throw new Error('timer BPMN still references the HTTPS sender');
|
|
157
|
+
return xml;
|
|
158
|
+
}
|
|
159
|
+
const RUN_ONCE = '<row><cell>dateType</cell><cell></cell></row><row><cell>timeType</cell><cell></cell></row><row><cell>dayValue</cell><cell></cell></row><row><cell>monthValue</cell><cell></cell></row><row><cell>yearValue</cell><cell></cell></row><row><cell>onWeekly</cell><cell></cell></row><row><cell>onMonthly</cell><cell></cell></row><row><cell>OnEveryMinute</cell><cell></cell></row><row><cell>fromInterval</cell><cell></cell></row><row><cell>toInterval</cell><cell></cell></row><row><cell>timeZone</cell><cell>( UTC 0:00 ) Greenwich Mean Time(Etc/GMT)</cell></row><row><cell>secondValue</cell><cell>0</cell></row><row><cell>minutesValue</cell><cell></cell></row><row><cell>hourValue</cell><cell></cell></row><row><cell>triggerType</cell><cell>simple</cell></row><row><cell>noOfSchedules</cell><cell>1</cell></row><row><cell>schedule1</cell><cell>fireNow=true</cell></row>';
|
|
160
|
+
function timerParameters(address) {
|
|
161
|
+
return {
|
|
162
|
+
Timer_Schedule: RUN_ONCE,
|
|
163
|
+
Receiver_Address: address,
|
|
164
|
+
Receiver_Query: '',
|
|
165
|
+
Receiver_Method: 'GET',
|
|
166
|
+
Receiver_Proxy_Type: 'default',
|
|
167
|
+
Receiver_Location_ID: '',
|
|
168
|
+
Receiver_Authentication: 'None',
|
|
169
|
+
Receiver_Credential_Name: '',
|
|
170
|
+
Receiver_Timeout: '30000'
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
async function deployAndWait(iflowId) {
|
|
176
|
+
const deployed = ok(await call('cpi_deploy_iflow', { iflow_id: iflowId, wait_seconds: 50, confirmation: `DEPLOY IFLOW ${iflowId}` }), `deploy ${iflowId}`);
|
|
177
|
+
let status = { Status: deployed.runtime_status, Version: deployed.runtime_version, error_information: deployed.error_information };
|
|
178
|
+
for (let attempt = 0; attempt < 36 && (deployed.timed_out || status.Status === 'STARTING' || !status.Status); attempt += 1) {
|
|
179
|
+
await sleep(5000);
|
|
180
|
+
status = ok(await call('cpi_get_iflow_runtime_status', { iflow_id: iflowId }), 'status');
|
|
181
|
+
if (status.Status && status.Status !== 'STARTING') break;
|
|
182
|
+
}
|
|
183
|
+
return { deployed, runtime: status };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ============================================================ shared
|
|
187
|
+
await scenario('S01', 'connection info reports both sides without secrets', async () => {
|
|
188
|
+
const info = ok(await call('integration_connection_info'), 'info');
|
|
189
|
+
expect(info.cpi.configured && info.apim.configured, 'both sides must be configured');
|
|
190
|
+
return `${info.cpi.host} / ${info.apim.host}`;
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
await scenario('S02', `tools/list exposes ${INTEGRATION_TOOL_NAMES.length} tools with write annotations`, async () => {
|
|
194
|
+
const { tools } = await client.listTools();
|
|
195
|
+
expect(tools.length === INTEGRATION_TOOL_NAMES.length, `expected ${INTEGRATION_TOOL_NAMES.length} tools, got ${tools.length}`);
|
|
196
|
+
const byName = Object.fromEntries(tools.map(tool => [tool.name, tool]));
|
|
197
|
+
expect(byName.cpi_update_iflow.annotations.destructiveHint === true, 'update must be destructive');
|
|
198
|
+
expect(byName.cpi_undeploy_artifact.annotations.destructiveHint === true, 'undeploy must be destructive');
|
|
199
|
+
expect(byName.cpi_list_packages.annotations.readOnlyHint === true, 'list must be read-only');
|
|
200
|
+
expect(!tools.some(tool => /delete/i.test(tool.name)), 'no delete tools');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// ============================================================ CPI packages
|
|
204
|
+
await scenario('S03', 'cpi_create_package with wrong confirmation is refused', async () => {
|
|
205
|
+
const result = await call('cpi_create_package', { package_id: PKG, name: 'AI smoke', short_text: 'AI smoke', confirmation: `CREATE CPI PACKAGE WRONG` });
|
|
206
|
+
expect(result.isError && result.text === `confirmation must equal CREATE CPI PACKAGE ${PKG}`, result.text);
|
|
207
|
+
const packages = ok(await call('cpi_list_packages', { top: 200 }), 'list');
|
|
208
|
+
expect(!packages.some(entry => entry.Id === PKG), 'package must not exist');
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
await scenario('S04', 'cpi_create_package creates the smoke package', async () => {
|
|
212
|
+
const created = ok(await call('cpi_create_package', { package_id: PKG, name: `AI smoke ${RUN}`, short_text: 'Created by sap-ai-integration live scenarios', description: 'Safe to delete', confirmation: `CREATE CPI PACKAGE ${PKG}` }), 'create');
|
|
213
|
+
expect(created.created === true, 'created flag');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
await scenario('S05', 'cpi_create_package on an existing id surfaces the tenant error', async () => {
|
|
217
|
+
const result = await call('cpi_create_package', { package_id: PKG, name: 'dup', short_text: 'dup', confirmation: `CREATE CPI PACKAGE ${PKG}` });
|
|
218
|
+
expect(result.isError && /^CPI POST \/api\/v1\/IntegrationPackages failed \(HTTP \d+\)/.test(result.text), result.text);
|
|
219
|
+
return result.text.slice(0, 140);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
await scenario('S06', 'cpi_list_packages shows the package', async () => {
|
|
223
|
+
const packages = ok(await call('cpi_list_packages', { top: 200 }), 'list');
|
|
224
|
+
const entry = packages.find(item => item.Id === PKG);
|
|
225
|
+
expect(entry, 'package missing');
|
|
226
|
+
expect(entry.ShortText === 'Created by sap-ai-integration live scenarios', `short text ${entry.ShortText}`);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// ============================================================ CPI template iFlow
|
|
230
|
+
await scenario('S07', 'cpi_create_iflow from the template (internet, None, httpbin)', async () => {
|
|
231
|
+
const created = ok(await call('cpi_create_iflow', {
|
|
232
|
+
package_id: PKG, iflow_id: IFLOW, name: `AI Smoke ${RUN}`, description: 'HTTPS -> httpbin',
|
|
233
|
+
template: 'https-request-reply-http',
|
|
234
|
+
template_parameters: { sender_path: `/ai/smoke${RUN}`, receiver_address: 'https://httpbin.org/anything', receiver_proxy_type: 'internet', receiver_authentication: 'None' },
|
|
235
|
+
confirmation: `CREATE IFLOW ${IFLOW}`
|
|
236
|
+
}), 'create');
|
|
237
|
+
expect(created.version === '1.0.0', 'version');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
await scenario('S08', 'cpi_create_iflow refuses a duplicate id before posting', async () => {
|
|
241
|
+
const result = await call('cpi_create_iflow', {
|
|
242
|
+
package_id: PKG, iflow_id: IFLOW, name: 'dup', template: 'https-request-reply-http',
|
|
243
|
+
template_parameters: { sender_path: '/dup', receiver_address: 'https://httpbin.org/anything', receiver_proxy_type: 'internet', receiver_authentication: 'None' },
|
|
244
|
+
confirmation: `CREATE IFLOW ${IFLOW}`
|
|
245
|
+
});
|
|
246
|
+
expect(result.isError && result.text === `iFlow ${IFLOW} already exists; use cpi_update_iflow`, result.text);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
await scenario('S09', 'cpi_list_iflows lists the iFlow at 1.0.0', async () => {
|
|
250
|
+
const iflows = ok(await call('cpi_list_iflows', { package_id: PKG }), 'list');
|
|
251
|
+
const entry = iflows.find(item => item.Id === IFLOW);
|
|
252
|
+
expect(entry && entry.Version === '1.0.0', JSON.stringify(iflows));
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
await scenario('S10', 'cpi_get_iflow returns configurations and bundle files', async () => {
|
|
256
|
+
const iflow = ok(await call('cpi_get_iflow', { iflow_id: IFLOW }), 'get');
|
|
257
|
+
const config = Object.fromEntries(iflow.configurations.map(entry => [entry.ParameterKey, entry.ParameterValue]));
|
|
258
|
+
expect(config.Receiver_Address === 'https://httpbin.org/anything', `Receiver_Address=${config.Receiver_Address}`);
|
|
259
|
+
expect(config.Sender_Path === `/ai/smoke${RUN}`, `Sender_Path=${config.Sender_Path}`);
|
|
260
|
+
const iflw = iflow.files.find(file => file.path.endsWith('.iflw'));
|
|
261
|
+
expect(iflw && iflw.text.includes('{{Receiver_Address}}'), 'iflw text');
|
|
262
|
+
return `${iflow.configurations.length} parameters, ${iflow.files.length} files`;
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
await scenario('S11', 'cpi_get_iflow file_path returns one file; unknown path errors', async () => {
|
|
266
|
+
const single = ok(await call('cpi_get_iflow', { iflow_id: IFLOW, file_path: 'src/main/resources/parameters.prop' }), 'file');
|
|
267
|
+
expect(/Receiver_Method=GET/.test(single.file.text), 'parameters.prop text');
|
|
268
|
+
const missing = await call('cpi_get_iflow', { iflow_id: IFLOW, file_path: 'nope.txt' });
|
|
269
|
+
expect(missing.isError && missing.text === 'file_path not found in the iFlow bundle', missing.text);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
await scenario('S12', 'cpi_update_iflow_configuration changes an externalized value', async () => {
|
|
273
|
+
const result = ok(await call('cpi_update_iflow_configuration', {
|
|
274
|
+
iflow_id: IFLOW,
|
|
275
|
+
parameters: [{ key: 'Receiver_Address', value: 'https://httpbin.org/anything/configured' }],
|
|
276
|
+
confirmation: `CONFIGURE IFLOW ${IFLOW}`
|
|
277
|
+
}), 'configure');
|
|
278
|
+
expect(result.failed === null && result.updated[0] === 'Receiver_Address', JSON.stringify(result));
|
|
279
|
+
const iflow = ok(await call('cpi_get_iflow', { iflow_id: IFLOW }), 'get');
|
|
280
|
+
const value = iflow.configurations.find(entry => entry.ParameterKey === 'Receiver_Address')?.ParameterValue;
|
|
281
|
+
expect(value === 'https://httpbin.org/anything/configured', `read back ${value}`);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
await scenario('S13', 'cpi_update_iflow_configuration reports an unknown key as failed', async () => {
|
|
285
|
+
const result = ok(await call('cpi_update_iflow_configuration', {
|
|
286
|
+
iflow_id: IFLOW,
|
|
287
|
+
parameters: [{ key: 'Receiver_Method', value: 'GET' }, { key: 'No_Such_Key', value: 'x' }],
|
|
288
|
+
confirmation: `CONFIGURE IFLOW ${IFLOW}`
|
|
289
|
+
}), 'configure');
|
|
290
|
+
expect(result.updated.length === 1 && result.failed?.key === 'No_Such_Key', JSON.stringify(result));
|
|
291
|
+
return result.failed.error.slice(0, 120);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
await scenario('S14', 'cpi_update_iflow rejects a stale expected_version', async () => {
|
|
295
|
+
const result = await call('cpi_update_iflow', {
|
|
296
|
+
iflow_id: IFLOW, expected_version: '0.9.9',
|
|
297
|
+
files: [{ path: 'src/main/resources/script/noop.groovy', content: 'x' }],
|
|
298
|
+
confirmation: `UPDATE IFLOW ${IFLOW}`
|
|
299
|
+
});
|
|
300
|
+
expect(result.isError && /is at version 1\.0\.0, not 0\.9\.9/.test(result.text), result.text);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
await scenario('S15', 'cpi_update_iflow adds a script and parameter and bumps to 1.0.1', async () => {
|
|
304
|
+
const updated = ok(await call('cpi_update_iflow', {
|
|
305
|
+
iflow_id: IFLOW, expected_version: '1.0.0',
|
|
306
|
+
files: [{ path: 'src/main/resources/script/noop.groovy', content: 'import com.sap.gateway.ip.core.customdev.util.Message\ndef Message processData(Message message) { return message }\n' }],
|
|
307
|
+
parameters: { Smoke_Marker: `run-${RUN}` },
|
|
308
|
+
confirmation: `UPDATE IFLOW ${IFLOW}`
|
|
309
|
+
}), 'update');
|
|
310
|
+
expect(updated.new_version === '1.0.1', JSON.stringify(updated));
|
|
311
|
+
const iflow = ok(await call('cpi_get_iflow', { iflow_id: IFLOW }), 'get');
|
|
312
|
+
expect(iflow.artifact.Version === '1.0.1', `version ${iflow.artifact.Version}`);
|
|
313
|
+
expect(iflow.files.some(file => file.path === 'src/main/resources/script/noop.groovy'), 'script present');
|
|
314
|
+
const marker = iflow.configurations.find(entry => entry.ParameterKey === 'Smoke_Marker');
|
|
315
|
+
expect(marker?.ParameterValue === `run-${RUN}`, `marker ${JSON.stringify(marker)}`);
|
|
316
|
+
const address = iflow.configurations.find(entry => entry.ParameterKey === 'Receiver_Address')?.ParameterValue;
|
|
317
|
+
return `Receiver_Address after update: ${address}`;
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
await scenario('S16', 'cpi_deploy_iflow deploys the template iFlow to STARTED', async () => {
|
|
321
|
+
const { deployed, runtime } = await deployAndWait(IFLOW);
|
|
322
|
+
expect(runtime.Status === 'STARTED', JSON.stringify({ deployed, runtime }));
|
|
323
|
+
return `build ${deployed.build_status}, timed_out=${deployed.timed_out}, runtime ${runtime.Status}`;
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
await scenario('S17', 'cpi_get_iflow_runtime_status reports STARTED at 1.0.1', async () => {
|
|
327
|
+
const status = ok(await call('cpi_get_iflow_runtime_status', { iflow_id: IFLOW }), 'status');
|
|
328
|
+
expect(status.deployed && status.Status === 'STARTED' && status.Version === '1.0.1', JSON.stringify(status));
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
await scenario('S18', 'cpi_list_service_endpoints returns the iFlow URL', async () => {
|
|
332
|
+
let endpoints = [];
|
|
333
|
+
for (let attempt = 0; attempt < 36 && !endpoints.length; attempt += 1) {
|
|
334
|
+
endpoints = ok(await call('cpi_list_service_endpoints', { iflow_id: IFLOW }), 'endpoints');
|
|
335
|
+
if (!endpoints.length) await sleep(5000);
|
|
336
|
+
}
|
|
337
|
+
const url = endpoints.flatMap(entry => entry.urls).find(value => value.endsWith(`/http/ai/smoke${RUN}`));
|
|
338
|
+
expect(url, JSON.stringify(endpoints));
|
|
339
|
+
ctx.cpiEndpoint = url;
|
|
340
|
+
const unauthenticated = await http(url, { method: 'POST', body: 'x' });
|
|
341
|
+
expect(unauthenticated.status === 401, `unauthenticated call returned ${unauthenticated.status}`);
|
|
342
|
+
return `${url} (401 without credentials)`;
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
await scenario('S19', 'on-premise + Basic template iFlow deploys with sapcc proxy settings', async () => {
|
|
346
|
+
ok(await call('cpi_create_iflow', {
|
|
347
|
+
package_id: PKG, iflow_id: IFLOW_ONPREM, name: `AI OnPrem ${RUN}`, template: 'https-request-reply-http',
|
|
348
|
+
template_parameters: {
|
|
349
|
+
sender_path: `/ai/onprem${RUN}`, receiver_address: 'http://s4h-virtual:44300/sap/opu/odata4/sap/zsmoke/srvd/sap/zsmoke/0001/',
|
|
350
|
+
receiver_query: 'sap-client=100', receiver_method: 'GET', receiver_proxy_type: 'on_premise', receiver_location_id: 'SMOKE',
|
|
351
|
+
receiver_authentication: 'Basic', receiver_credential_name: 'ZAI_SMOKE_USER', receiver_timeout_ms: 30000
|
|
352
|
+
},
|
|
353
|
+
confirmation: `CREATE IFLOW ${IFLOW_ONPREM}`
|
|
354
|
+
}), 'create');
|
|
355
|
+
const { deployed, runtime } = await deployAndWait(IFLOW_ONPREM);
|
|
356
|
+
expect(runtime.Status === 'STARTED' || (runtime.Status === 'ERROR' && runtime.error_information), JSON.stringify({ deployed, runtime }));
|
|
357
|
+
const prop = ok(await call('cpi_get_iflow', { iflow_id: IFLOW_ONPREM, file_path: 'src/main/resources/parameters.prop' }), 'file').file.text;
|
|
358
|
+
expect(/Receiver_Proxy_Type=sapcc/.test(prop) && /Receiver_Credential_Name=ZAI_SMOKE_USER/.test(prop), prop);
|
|
359
|
+
return `build ${deployed.build_status}, runtime ${runtime.Status}`;
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
await scenario('S20', 'custom BPMN (timer start) iFlow is created with caller parameters', async () => {
|
|
363
|
+
const xml = timerIflw();
|
|
364
|
+
ok(await call('cpi_create_iflow', { package_id: PKG, iflow_id: IFLOW_TIMER_OK, name: `AI Timer Ok ${RUN}`, template: 'custom', iflw_xml: xml, parameters: timerParameters('https://httpbin.org/anything/timer-ok'), confirmation: `CREATE IFLOW ${IFLOW_TIMER_OK}` }), 'create ok');
|
|
365
|
+
ok(await call('cpi_create_iflow', { package_id: PKG, iflow_id: IFLOW_TIMER_FAIL, name: `AI Timer Fail ${RUN}`, template: 'custom', iflw_xml: xml, parameters: timerParameters('https://httpbin.org/status/500'), confirmation: `CREATE IFLOW ${IFLOW_TIMER_FAIL}` }), 'create fail');
|
|
366
|
+
const iflows = ok(await call('cpi_list_iflows', { package_id: PKG }), 'list');
|
|
367
|
+
expect([IFLOW_TIMER_OK, IFLOW_TIMER_FAIL].every(id => iflows.some(entry => entry.Id === id)), 'both listed');
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
await scenario('S21', 'timer iFlows deploy and start', async () => {
|
|
371
|
+
for (const id of [IFLOW_TIMER_OK, IFLOW_TIMER_FAIL]) {
|
|
372
|
+
const { deployed, runtime } = await deployAndWait(id);
|
|
373
|
+
expect(runtime.Status === 'STARTED', `${id}: ${JSON.stringify({ deployed, runtime })}`);
|
|
374
|
+
}
|
|
375
|
+
ctx.timerDeployedAt = new Date(Date.now() - 5 * 60_000).toISOString();
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
async function waitForLog(iflowId, status) {
|
|
379
|
+
for (let attempt = 0; attempt < 24; attempt += 1) {
|
|
380
|
+
const result = ok(await call('cpi_list_message_logs', { iflow_id: iflowId, status, top: 5 }), 'logs');
|
|
381
|
+
if (result.logs.length) return result;
|
|
382
|
+
await sleep(5000);
|
|
383
|
+
}
|
|
384
|
+
throw new Error(`no ${status} log for ${iflowId} after 120 s`);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
await scenario('S22', 'cpi_list_message_logs finds the COMPLETED timer run', async () => {
|
|
388
|
+
const result = await waitForLog(IFLOW_TIMER_OK, 'COMPLETED');
|
|
389
|
+
const [log] = result.logs;
|
|
390
|
+
expect(log.Status === 'COMPLETED' && /^\d{4}-\d\d-\d\dT/.test(log.LogStart), JSON.stringify(log));
|
|
391
|
+
ctx.okGuid = log.MessageGuid;
|
|
392
|
+
return `${log.MessageGuid} ${log.LogStart}`;
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
await scenario('S23', 'cpi_list_message_logs + cpi_get_message_log return the FAILED run with error text', async () => {
|
|
396
|
+
const result = await waitForLog(IFLOW_TIMER_FAIL, 'FAILED');
|
|
397
|
+
const detail = ok(await call('cpi_get_message_log', { message_guid: result.logs[0].MessageGuid }), 'detail');
|
|
398
|
+
expect(detail.log.Status === 'FAILED', JSON.stringify(detail.log));
|
|
399
|
+
expect(detail.error_information && /500/.test(detail.error_information), `error text: ${detail.error_information}`);
|
|
400
|
+
return detail.error_information.replace(/\s+/g, ' ').slice(0, 140);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
await scenario('S24', 'cpi_get_message_log for a COMPLETED run has no error text', async () => {
|
|
404
|
+
expect(ctx.okGuid, 'needs S22');
|
|
405
|
+
const detail = ok(await call('cpi_get_message_log', { message_guid: ctx.okGuid }), 'detail');
|
|
406
|
+
expect(detail.log.Status === 'COMPLETED' && detail.error_information === null, JSON.stringify(detail).slice(0, 300));
|
|
407
|
+
expect(Array.isArray(detail.custom_header_properties), 'custom headers array');
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
await scenario('S25', 'cpi_list_message_logs time window filters (from/to)', async () => {
|
|
411
|
+
const future = ok(await call('cpi_list_message_logs', { iflow_id: IFLOW_TIMER_OK, from: '2099-01-01T00:00:00Z' }), 'future');
|
|
412
|
+
expect(future.logs.length === 0, 'future window must be empty');
|
|
413
|
+
const past = ok(await call('cpi_list_message_logs', { iflow_id: IFLOW_TIMER_OK, from: '2020-01-01T00:00:00Z', to: '2099-01-01T00:00:00Z' }), 'past');
|
|
414
|
+
expect(past.logs.length >= 1, 'wide window must include the run');
|
|
415
|
+
return `total=${past.total}`;
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
// ============================================================ APIM providers
|
|
419
|
+
await scenario('S26', 'apim_create_api_provider refuses reserved names without a request', async () => {
|
|
420
|
+
const result = await call('apim_create_api_provider', { name: 'TC_SMOKE', type: 'internet', host: 'httpbin.org', port: 443, confirmation: 'CREATE API PROVIDER TC_SMOKE' });
|
|
421
|
+
expect(result.isError && result.text === 'API provider name TC_SMOKE is reserved by API Management', result.text);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
await scenario('S27', 'apim_create_api_provider creates an Internet provider', async () => {
|
|
425
|
+
const created = ok(await call('apim_create_api_provider', { name: PROVIDER, title: 'httpbin (AI smoke)', description: 'Safe to delete', type: 'internet', host: 'httpbin.org', port: 443, confirmation: `CREATE API PROVIDER ${PROVIDER}` }), 'create');
|
|
426
|
+
expect(created.created === true, JSON.stringify(created));
|
|
427
|
+
return created.provider ? `read back ${created.provider.destType}` : 'read back delayed';
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
await scenario('S28', 'apim_get_api_provider returns sanitized provider fields', async () => {
|
|
431
|
+
const provider = ok(await call('apim_get_api_provider', { name: PROVIDER }), 'get');
|
|
432
|
+
expect(provider.host === 'httpbin.org' && Number(provider.port) === 443 && provider.useSSL === true, JSON.stringify(provider).slice(0, 300));
|
|
433
|
+
expect(!/"(?:password|clientSecret|userName|clientId)"/.test(JSON.stringify(provider)), 'credential keys present');
|
|
434
|
+
return `destType=${provider.destType} isOnPremise=${provider.isOnPremise}`;
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
await scenario('S29', 'apim_create_api_provider creates an On-Premise provider with sap-client', async () => {
|
|
438
|
+
const created = ok(await call('apim_create_api_provider', { name: PROVIDER_ONPREM, type: 'on_premise', host: 's4h-virtual', port: 44300, location_id: 'SMOKE', sap_client: '100', path_prefix: '/sap/opu', confirmation: `CREATE API PROVIDER ${PROVIDER_ONPREM}` }), 'create');
|
|
439
|
+
const provider = created.provider ?? ok(await call('apim_get_api_provider', { name: PROVIDER_ONPREM }), 'get');
|
|
440
|
+
expect(provider.isOnPremise === true, JSON.stringify(provider).slice(0, 400));
|
|
441
|
+
expect(provider.cloudConnectorLocation === 'SMOKE', `location ${provider.cloudConnectorLocation}`);
|
|
442
|
+
let props = provider.additionalProperties ?? [];
|
|
443
|
+
for (let attempt = 0; attempt < 6 && !props.length; attempt += 1) {
|
|
444
|
+
await sleep(5000);
|
|
445
|
+
props = ok(await call('apim_get_api_provider', { name: PROVIDER_ONPREM }), 'get').additionalProperties ?? [];
|
|
446
|
+
}
|
|
447
|
+
expect(props.some(entry => entry.name === 'sap-client' && entry.value === '100'), `additionalProperties ${JSON.stringify(props)}`);
|
|
448
|
+
return `destType=${provider.destType} useSSL=${provider.useSSL} pathPrefix=${provider.pathPrefix}`;
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
await scenario('S30', 'apim_list_api_providers lists both providers', async () => {
|
|
452
|
+
const providers = ok(await call('apim_list_api_providers', { top: 200 }), 'list');
|
|
453
|
+
expect([PROVIDER, PROVIDER_ONPREM].every(name => providers.some(entry => entry.name === name)), JSON.stringify(providers.map(entry => entry.name)));
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
// ============================================================ APIM proxies
|
|
457
|
+
await scenario('S31', 'apim_create_api_proxy fails for a missing provider before importing', async () => {
|
|
458
|
+
const result = await call('apim_create_api_proxy', { name: `${PROXY}x`, base_path: `/ai/missing${RUN}`, service_type: 'rest', target: { provider: 'ZAI_NO_SUCH_PROVIDER', relative_path: '/x' }, confirmation: `CREATE API PROXY ${PROXY}x` });
|
|
459
|
+
expect(result.isError && result.text === 'API provider ZAI_NO_SUCH_PROVIDER does not exist; create it with apim_create_api_provider', result.text);
|
|
460
|
+
const proxies = ok(await call('apim_list_api_proxies', { top: 200 }), 'list');
|
|
461
|
+
expect(!proxies.some(entry => entry.name === `${PROXY}x`), 'proxy must not exist');
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
await scenario('S32', 'apim_create_api_proxy (REST, provider target) is reachable at runtime_url', async () => {
|
|
465
|
+
const created = ok(await call('apim_create_api_proxy', { name: PROXY, title: 'AI smoke', base_path: `/ai/smoke${RUN}`, service_type: 'rest', target: { provider: PROVIDER, relative_path: '/anything' }, confirmation: `CREATE API PROXY ${PROXY}` }), 'create');
|
|
466
|
+
expect(created.runtime_url, JSON.stringify(created));
|
|
467
|
+
ctx.proxyUrl = created.runtime_url;
|
|
468
|
+
let response;
|
|
469
|
+
for (let attempt = 0; attempt < 12; attempt += 1) {
|
|
470
|
+
response = await http(`${created.runtime_url}/ping?x=1`);
|
|
471
|
+
if (response.status === 200) break;
|
|
472
|
+
await sleep(5000);
|
|
473
|
+
}
|
|
474
|
+
expect(response.status === 200, `HTTP ${response.status}: ${response.text.slice(0, 200)}`);
|
|
475
|
+
const echoed = JSON.parse(response.text);
|
|
476
|
+
expect(/\/anything\/ping/.test(echoed.url), `upstream url ${echoed.url}`);
|
|
477
|
+
return `${created.runtime_url} -> ${echoed.url}`;
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
await scenario('S33', 'apim_create_api_proxy refuses a duplicate name', async () => {
|
|
481
|
+
const result = await call('apim_create_api_proxy', { name: PROXY, base_path: `/ai/dup${RUN}`, service_type: 'rest', target: { provider: PROVIDER, relative_path: '/anything' }, confirmation: `CREATE API PROXY ${PROXY}` });
|
|
482
|
+
expect(result.isError && result.text === `API proxy ${PROXY} already exists; use apim_update_api_proxy`, result.text);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
await scenario('S34', 'apim_update_api_proxy re-imports with a new target path', async () => {
|
|
486
|
+
const updated = ok(await call('apim_update_api_proxy', { name: PROXY, title: 'AI smoke v2', base_path: `/ai/smoke${RUN}`, service_type: 'rest', target: { provider: PROVIDER, relative_path: '/anything/v2' }, confirmation: `UPDATE API PROXY ${PROXY}` }), 'update');
|
|
487
|
+
expect(updated.updated === true, JSON.stringify(updated));
|
|
488
|
+
let echoed;
|
|
489
|
+
for (let attempt = 0; attempt < 12; attempt += 1) {
|
|
490
|
+
const response = await http(`${ctx.proxyUrl}/ping`);
|
|
491
|
+
if (response.status === 200) {
|
|
492
|
+
echoed = JSON.parse(response.text);
|
|
493
|
+
if (/\/anything\/v2\/ping/.test(echoed.url)) break;
|
|
494
|
+
}
|
|
495
|
+
await sleep(5000);
|
|
496
|
+
}
|
|
497
|
+
expect(echoed && /\/anything\/v2\/ping/.test(echoed.url), `upstream ${echoed?.url}`);
|
|
498
|
+
return echoed.url;
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
await scenario('S35', 'apim_update_api_proxy refuses a proxy that does not exist', async () => {
|
|
502
|
+
const result = await call('apim_update_api_proxy', { name: 'ZAI_No_Such_Proxy', base_path: '/ai/none', service_type: 'rest', target: { url: 'https://httpbin.org/get' }, confirmation: 'UPDATE API PROXY ZAI_No_Such_Proxy' });
|
|
503
|
+
expect(result.isError && result.text === 'API proxy ZAI_No_Such_Proxy does not exist; use apim_create_api_proxy', result.text);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
await scenario('S36', 'apim_create_api_proxy with a fixed https URL target', async () => {
|
|
507
|
+
const created = ok(await call('apim_create_api_proxy', { name: PROXY_URL, base_path: `/ai/url${RUN}`, service_type: 'rest', target: { url: 'https://httpbin.org/anything/url-target' }, confirmation: `CREATE API PROXY ${PROXY_URL}` }), 'create');
|
|
508
|
+
let response;
|
|
509
|
+
for (let attempt = 0; attempt < 12; attempt += 1) {
|
|
510
|
+
response = await http(created.runtime_url);
|
|
511
|
+
if (response.status === 200) break;
|
|
512
|
+
await sleep(5000);
|
|
513
|
+
}
|
|
514
|
+
expect(response.status === 200 && /url-target/.test(JSON.parse(response.text).url), `HTTP ${response.status}`);
|
|
515
|
+
return created.runtime_url;
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
await scenario('S37', 'OData proxy needs an SAP service path (validated before any call)', async () => {
|
|
519
|
+
const result = await call('apim_create_api_proxy', { name: PROXY_ODATA, base_path: `/ai/odata${RUN}`, service_type: 'odata', target: { provider: PROVIDER_ONPREM, relative_path: '/api/orders' }, confirmation: `CREATE API PROXY ${PROXY_ODATA}` });
|
|
520
|
+
expect(result.isError && /\/sap\/opu\/odata/.test(result.text), result.text);
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
await scenario('S38', 'apim_create_api_proxy (OData, on-premise provider) imports and reads back', async () => {
|
|
524
|
+
const created = ok(await call('apim_create_api_proxy', { name: PROXY_ODATA, title: 'S/4 OData smoke', base_path: `/ai/odata${RUN}`, service_type: 'odata', target: { provider: PROVIDER_ONPREM, relative_path: '/sap/opu/odata4/sap/zsmoke/srvd/sap/zsmoke/0001/' }, confirmation: `CREATE API PROXY ${PROXY_ODATA}` }), 'create');
|
|
525
|
+
const proxy = ok(await call('apim_get_api_proxy', { name: PROXY_ODATA }), 'get');
|
|
526
|
+
const target = proxy.targetEndPoints?.[0];
|
|
527
|
+
expect(proxy.service_code === 'ODATA', `service_code ${proxy.service_code}`);
|
|
528
|
+
expect(target?.provider_id === PROVIDER_ONPREM, JSON.stringify(target).slice(0, 300));
|
|
529
|
+
return `${created.runtime_url} state=${proxy.state}`;
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
await scenario('S39', 'APIM in front of CPI: proxy to the iFlow endpoint reaches CPI', async () => {
|
|
533
|
+
expect(ctx.cpiEndpoint, 'needs S18');
|
|
534
|
+
const created = ok(await call('apim_create_api_proxy', { name: PROXY_CPI, base_path: `/ai/cpi${RUN}`, service_type: 'rest', target: { url: ctx.cpiEndpoint }, confirmation: `CREATE API PROXY ${PROXY_CPI}` }), 'create');
|
|
535
|
+
let response;
|
|
536
|
+
for (let attempt = 0; attempt < 12; attempt += 1) {
|
|
537
|
+
response = await http(created.runtime_url, { method: 'POST', body: 'x' });
|
|
538
|
+
if (response.status === 401) break;
|
|
539
|
+
await sleep(5000);
|
|
540
|
+
}
|
|
541
|
+
expect(response.status === 401, `expected CPI 401 through APIM, got ${response.status}: ${response.text.slice(0, 200)}`);
|
|
542
|
+
return `${created.runtime_url} -> CPI (401 without CPI credentials, as expected)`;
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
await scenario('S40', 'apim_list_api_proxies shows all smoke proxies deployed', async () => {
|
|
546
|
+
const proxies = ok(await call('apim_list_api_proxies', { top: 200 }), 'list');
|
|
547
|
+
const mine = proxies.filter(entry => [PROXY, PROXY_URL, PROXY_ODATA, PROXY_CPI].includes(entry.name));
|
|
548
|
+
expect(mine.length === 4, JSON.stringify(mine));
|
|
549
|
+
return mine.map(entry => `${entry.name}:${entry.state}`).join(', ');
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
// ============================================================ complex end-to-end scenarios (S41–S80)
|
|
553
|
+
const IFLOW_POST = `Z_AI_Post_${RUN}`;
|
|
554
|
+
const IFLOW_CRED = `Z_AI_Cred_${RUN}`;
|
|
555
|
+
const IFLOW_BROKEN = `Z_AI_Broken_${RUN}`;
|
|
556
|
+
const PROVIDER_CPI = `ZAI_CPI_RT${RUN}`;
|
|
557
|
+
const PROXY_CPI_PROVIDER = `ZAI_SmokeCpiProv${RUN}`;
|
|
558
|
+
const PROXY_XML = `ZAI_SmokeXml${RUN}`;
|
|
559
|
+
const PROXY_DASH = `ZAI-Smoke-Dash-${RUN}`;
|
|
560
|
+
const ASCII_NAME = `Cafe Gruesse Flow ${RUN}`;
|
|
561
|
+
const UNICODE_DESCRIPTION = 'Grüße aus Köln & <tags> "quoted" — ünïcödé';
|
|
562
|
+
const SPECIAL_VALUE = ' lead=a:b\\c #x! ü';
|
|
563
|
+
const LARGE_SCRIPT = `// large script for truncation checks\n${'def line = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"\n'.repeat(900)}`;
|
|
564
|
+
|
|
565
|
+
await scenario('S41', 'runtime call to the template iFlow passes body and query to the receiver', async () => {
|
|
566
|
+
expect(ctx.cpiEndpoint, 'needs S18');
|
|
567
|
+
const result = await invoke(`${ctx.cpiEndpoint}?probe=${RUN}`, { body: JSON.stringify({ run: RUN, kind: 'direct' }) });
|
|
568
|
+
expect(result.status === 200, `HTTP ${result.status}: ${result.text.slice(0, 200)}`);
|
|
569
|
+
expect(/\/anything\/configured/.test(result.json.url), `receiver url ${result.json.url}`);
|
|
570
|
+
expect(result.json.args.probe === RUN, `query ${JSON.stringify(result.json.args)}`);
|
|
571
|
+
expect(JSON.parse(result.json.data).kind === 'direct', `body ${result.json.data}`);
|
|
572
|
+
ctx.directGuid = result.json.headers['Sap-Messageprocessinglogid'];
|
|
573
|
+
expect(ctx.directGuid, 'no MPL id header forwarded');
|
|
574
|
+
return `MPL ${ctx.directGuid}`;
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
await scenario('S42', 'cpi_get_message_log finds the runtime call by its MPL id', async () => {
|
|
578
|
+
const detail = await waitForGuid(ctx.directGuid);
|
|
579
|
+
expect(detail.log.Status === 'COMPLETED' && detail.log.MessageGuid === ctx.directGuid, JSON.stringify(detail.log).slice(0, 300));
|
|
580
|
+
expect(detail.error_information === null, 'completed run must have no error');
|
|
581
|
+
return `${detail.log.IntegrationFlowName} ${detail.log.LogStart} → ${detail.log.LogEnd}`;
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
await scenario('S43', 'APIM proxy in front of CPI forwards the caller bearer token end to end', async () => {
|
|
585
|
+
const proxy = ok(await call('apim_get_api_proxy', { name: PROXY_CPI }), 'get');
|
|
586
|
+
const result = await invoke(proxy.runtime_url, { body: JSON.stringify({ via: 'apim-url-target', run: RUN }) });
|
|
587
|
+
expect(result.status === 200, `HTTP ${result.status}: ${result.text.slice(0, 200)}`);
|
|
588
|
+
expect(JSON.parse(result.json.data).via === 'apim-url-target', result.json.data);
|
|
589
|
+
ctx.apimGuid = result.json.headers['Sap-Messageprocessinglogid'];
|
|
590
|
+
return `${proxy.runtime_url} → CPI → httpbin`;
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
await scenario('S44', 'APIM provider on the CPI runtime host + proxy reaches the iFlow', async () => {
|
|
594
|
+
const host = new URL(ctx.cpiEndpoint).host;
|
|
595
|
+
ok(await call('apim_create_api_provider', { name: PROVIDER_CPI, title: 'CPI runtime', type: 'internet', host, port: 443, confirmation: `CREATE API PROVIDER ${PROVIDER_CPI}` }), 'provider');
|
|
596
|
+
const created = ok(await call('apim_create_api_proxy', { name: PROXY_CPI_PROVIDER, base_path: `/ai/cpiprov${RUN}`, service_type: 'rest', target: { provider: PROVIDER_CPI, relative_path: `/http/ai/smoke${RUN}` }, confirmation: `CREATE API PROXY ${PROXY_CPI_PROVIDER}` }), 'proxy');
|
|
597
|
+
const result = await invoke(created.runtime_url, { body: JSON.stringify({ via: 'apim-provider' }), done: response => response.status === 200 });
|
|
598
|
+
expect(result.status === 200 && JSON.parse(result.json.data).via === 'apim-provider', `HTTP ${result.status}: ${result.text.slice(0, 200)}`);
|
|
599
|
+
return created.runtime_url;
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
await scenario('S45', 'the APIM-routed call is visible as a COMPLETED CPI message log', async () => {
|
|
603
|
+
expect(ctx.apimGuid, 'needs S43');
|
|
604
|
+
const detail = await waitForGuid(ctx.apimGuid);
|
|
605
|
+
expect(detail.log.Status === 'COMPLETED', JSON.stringify(detail.log).slice(0, 300));
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
await scenario('S46', 'iFlow with Unicode description, POST receiver and static query deploys', async () => {
|
|
609
|
+
ok(await call('cpi_create_iflow', {
|
|
610
|
+
package_id: PKG, iflow_id: IFLOW_POST, name: ASCII_NAME, description: UNICODE_DESCRIPTION,
|
|
611
|
+
template: 'https-request-reply-http',
|
|
612
|
+
template_parameters: { sender_path: `/ai/post${RUN}`, receiver_address: 'https://httpbin.org/anything/post', receiver_query: 'static=1&mode=test', receiver_method: 'POST', receiver_proxy_type: 'internet', receiver_authentication: 'None', receiver_timeout_ms: 20000 },
|
|
613
|
+
confirmation: `CREATE IFLOW ${IFLOW_POST}`
|
|
614
|
+
}), 'create');
|
|
615
|
+
const listed = ok(await call('cpi_list_iflows', { package_id: PKG }), 'list').find(entry => entry.Id === IFLOW_POST);
|
|
616
|
+
expect(listed?.Name === ASCII_NAME, `name read back ${listed?.Name}`);
|
|
617
|
+
expect(listed?.Description === UNICODE_DESCRIPTION, `description read back ${listed?.Description}`);
|
|
618
|
+
const { runtime } = await deployAndWait(IFLOW_POST);
|
|
619
|
+
expect(runtime.Status === 'STARTED', JSON.stringify(runtime));
|
|
620
|
+
const endpoints = ok(await call('cpi_list_service_endpoints', { iflow_id: IFLOW_POST }), 'endpoints');
|
|
621
|
+
ctx.postEndpoint = endpoints.flatMap(entry => entry.urls)[0] ?? ctx.cpiEndpoint.replace(`/http/ai/smoke${RUN}`, `/http/ai/post${RUN}`);
|
|
622
|
+
return `description "${listed.Description}"`;
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
await scenario('S47', 'POST receiver gets method, static query and body', async () => {
|
|
626
|
+
const result = await invoke(ctx.postEndpoint, { body: JSON.stringify({ order: 42 }), done: response => response.status === 200 });
|
|
627
|
+
expect(result.status === 200, `HTTP ${result.status}: ${result.text.slice(0, 200)}`);
|
|
628
|
+
expect(result.json.method === 'POST', `method ${result.json.method}`);
|
|
629
|
+
expect(result.json.args.static === '1' && result.json.args.mode === 'test', JSON.stringify(result.json.args));
|
|
630
|
+
expect(JSON.parse(result.json.data).order === 42, result.json.data);
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
await scenario('S48', 'configuration change (method + query) takes effect after redeploy', async () => {
|
|
634
|
+
ok(await call('cpi_update_iflow_configuration', { iflow_id: IFLOW_POST, parameters: [{ key: 'Receiver_Method', value: 'PUT' }, { key: 'Receiver_Query', value: 'static=2' }], confirmation: `CONFIGURE IFLOW ${IFLOW_POST}` }), 'configure');
|
|
635
|
+
const { runtime } = await deployAndWait(IFLOW_POST);
|
|
636
|
+
expect(runtime.Status === 'STARTED', JSON.stringify(runtime));
|
|
637
|
+
const result = await invoke(ctx.postEndpoint, { body: '{}', done: response => response.status === 200 && response.json?.method === 'PUT' });
|
|
638
|
+
expect(result.json?.method === 'PUT' && result.json.args.static === '2', `method ${result.json?.method} args ${JSON.stringify(result.json?.args)}`);
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
await scenario('S49', 'special-character parameter survives the bundle round trip; config edits are preserved', async () => {
|
|
642
|
+
const updated = ok(await call('cpi_update_iflow', {
|
|
643
|
+
iflow_id: IFLOW_POST, expected_version: '1.0.0',
|
|
644
|
+
files: [{ path: 'src/main/resources/script/marker.groovy', content: '// marker\n' }],
|
|
645
|
+
parameters: { 'Special Key': SPECIAL_VALUE },
|
|
646
|
+
confirmation: `UPDATE IFLOW ${IFLOW_POST}`
|
|
647
|
+
}), 'update');
|
|
648
|
+
expect(updated.new_version === '1.0.1', JSON.stringify(updated));
|
|
649
|
+
const config = Object.fromEntries(ok(await call('cpi_get_iflow', { iflow_id: IFLOW_POST }), 'get').configurations.map(entry => [entry.ParameterKey, entry.ParameterValue]));
|
|
650
|
+
expect(config['Special Key'] === SPECIAL_VALUE, `read back ${JSON.stringify(config['Special Key'])}`);
|
|
651
|
+
expect(config.Receiver_Method === 'PUT' && config.Receiver_Query === 'static=2', `config edits lost: ${config.Receiver_Method} ${config.Receiver_Query}`);
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
await scenario('S50', 'large script (57 KiB): truncated in the listing, complete via file_path', async () => {
|
|
655
|
+
ok(await call('cpi_update_iflow', { iflow_id: IFLOW_POST, expected_version: '1.0.1', files: [{ path: 'src/main/resources/script/large.groovy', content: LARGE_SCRIPT }], confirmation: `UPDATE IFLOW ${IFLOW_POST}` }), 'update');
|
|
656
|
+
const listing = ok(await call('cpi_get_iflow', { iflow_id: IFLOW_POST }), 'get');
|
|
657
|
+
const entry = listing.files.find(file => file.path === 'src/main/resources/script/large.groovy');
|
|
658
|
+
expect(entry?.truncated === true && entry.bytes === LARGE_SCRIPT.length, JSON.stringify({ truncated: entry?.truncated, bytes: entry?.bytes }));
|
|
659
|
+
const single = ok(await call('cpi_get_iflow', { iflow_id: IFLOW_POST, file_path: 'src/main/resources/script/large.groovy' }), 'file');
|
|
660
|
+
expect(single.file.text === LARGE_SCRIPT && !single.file.truncated, `file_path length ${single.file.text?.length}`);
|
|
661
|
+
return `${LARGE_SCRIPT.length} bytes`;
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
await scenario('S51', 'rename through cpi_update_iflow keeps version chain consistent', async () => {
|
|
665
|
+
const renamed = ok(await call('cpi_update_iflow', { iflow_id: IFLOW_POST, expected_version: '1.0.2', name: `${ASCII_NAME} v2`, files: [{ path: 'src/main/resources/script/marker.groovy', content: '// marker v2\n' }], confirmation: `UPDATE IFLOW ${IFLOW_POST}` }), 'update');
|
|
666
|
+
expect(renamed.new_version === '1.0.3', JSON.stringify(renamed));
|
|
667
|
+
const listed = ok(await call('cpi_list_iflows', { package_id: PKG }), 'list').find(entry => entry.Id === IFLOW_POST);
|
|
668
|
+
expect(listed.Name === `${ASCII_NAME} v2` && listed.Version === '1.0.3', JSON.stringify(listed));
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
await scenario('S52', 'redeploy after three bundle updates runs 1.0.3 with preserved config', async () => {
|
|
672
|
+
const { runtime } = await deployAndWait(IFLOW_POST);
|
|
673
|
+
expect(runtime.Status === 'STARTED' && runtime.Version === '1.0.3', JSON.stringify(runtime));
|
|
674
|
+
const result = await invoke(ctx.postEndpoint, { body: '{"after":"updates"}', done: response => response.status === 200 });
|
|
675
|
+
expect(result.json?.method === 'PUT' && result.json.args.static === '2', `method ${result.json?.method}`);
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
await scenario('S53', 'message log filters: COMPLETED for the POST iFlow, top=1, past window empty', async () => {
|
|
679
|
+
// Message logs are indexed asynchronously; the call made in S52 can take a while to appear.
|
|
680
|
+
let completed;
|
|
681
|
+
for (let attempt = 0; attempt < 18; attempt += 1) {
|
|
682
|
+
completed = ok(await call('cpi_list_message_logs', { iflow_id: IFLOW_POST, status: 'COMPLETED', top: 50 }), 'completed');
|
|
683
|
+
if (completed.logs.length >= 3) break;
|
|
684
|
+
await sleep(5000);
|
|
685
|
+
}
|
|
686
|
+
expect(completed.logs.length >= 3 && completed.logs.every(log => log.Status === 'COMPLETED'), `${completed.logs.length} logs`);
|
|
687
|
+
const one = ok(await call('cpi_list_message_logs', { iflow_id: IFLOW_POST, top: 1 }), 'top1');
|
|
688
|
+
expect(one.returned === 1 && one.total >= completed.logs.length, JSON.stringify({ returned: one.returned, total: one.total }));
|
|
689
|
+
const past = ok(await call('cpi_list_message_logs', { iflow_id: IFLOW_POST, to: '2020-01-01T00:00:00Z' }), 'past');
|
|
690
|
+
expect(past.logs.length === 0, 'past window must be empty');
|
|
691
|
+
const ordered = completed.logs.map(log => log.LogEnd);
|
|
692
|
+
expect(ordered.every((value, index) => index === 0 || ordered[index - 1] >= value), 'logs must be newest first');
|
|
693
|
+
return `total=${one.total}`;
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
await scenario('S54', 'receiver HTTP 503 → caller error and FAILED log with the receiver status', async () => {
|
|
697
|
+
ok(await call('cpi_update_iflow_configuration', { iflow_id: IFLOW, parameters: [{ key: 'Receiver_Address', value: 'https://httpbin.org/status/503' }], confirmation: `CONFIGURE IFLOW ${IFLOW}` }), 'configure');
|
|
698
|
+
const { runtime } = await deployAndWait(IFLOW);
|
|
699
|
+
expect(runtime.Status === 'STARTED', JSON.stringify(runtime));
|
|
700
|
+
const result = await invoke(ctx.cpiEndpoint, { body: '{}', done: response => response.status >= 500 && response.status !== 503 });
|
|
701
|
+
expect(result.status === 500, `caller got HTTP ${result.status}`);
|
|
702
|
+
const failed = await waitForLog(IFLOW, 'FAILED');
|
|
703
|
+
const detail = ok(await call('cpi_get_message_log', { message_guid: failed.logs[0].MessageGuid }), 'detail');
|
|
704
|
+
expect(/503/.test(detail.error_information || ''), `error ${detail.error_information}`);
|
|
705
|
+
return detail.error_information.replace(/\s+/g, ' ').slice(0, 120);
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
await scenario('S55', 'missing security material → FAILED log names the credential', async () => {
|
|
709
|
+
ok(await call('cpi_create_iflow', {
|
|
710
|
+
package_id: PKG, iflow_id: IFLOW_CRED, name: `AI Cred ${RUN}`, template: 'https-request-reply-http',
|
|
711
|
+
template_parameters: { sender_path: `/ai/cred${RUN}`, receiver_address: 'https://httpbin.org/basic-auth/u/p', receiver_proxy_type: 'internet', receiver_authentication: 'Basic', receiver_credential_name: 'ZAI_MISSING_CRED' },
|
|
712
|
+
confirmation: `CREATE IFLOW ${IFLOW_CRED}`
|
|
713
|
+
}), 'create');
|
|
714
|
+
const { runtime } = await deployAndWait(IFLOW_CRED);
|
|
715
|
+
if (runtime.Status === 'ERROR') {
|
|
716
|
+
expect(/ZAI_MISSING_CRED|credential/i.test(runtime.error_information || ''), `runtime error ${runtime.error_information}`);
|
|
717
|
+
return `runtime ERROR: ${runtime.error_information.slice(0, 120)}`;
|
|
718
|
+
}
|
|
719
|
+
const endpoint = ctx.cpiEndpoint.replace(`/http/ai/smoke${RUN}`, `/http/ai/cred${RUN}`);
|
|
720
|
+
const result = await invoke(endpoint, { body: '{}', done: response => response.status !== 404 && response.status !== 503 });
|
|
721
|
+
expect(result.status >= 500, `caller got HTTP ${result.status}`);
|
|
722
|
+
const failed = await waitForLog(IFLOW_CRED, 'FAILED');
|
|
723
|
+
const detail = ok(await call('cpi_get_message_log', { message_guid: failed.logs[0].MessageGuid }), 'detail');
|
|
724
|
+
expect(/ZAI_MISSING_CRED|credential/i.test(detail.error_information || ''), `error ${detail.error_information}`);
|
|
725
|
+
return detail.error_information.replace(/\s+/g, ' ').slice(0, 120);
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
await scenario('S56', 'broken custom BPMN (dangling sequence flow) → deploy reports build FAIL with the exact cause', async () => {
|
|
729
|
+
const broken = template.replace(/<bpmn2:endEvent id="EndEvent_2"[\s\S]*?<\/bpmn2:endEvent>/, '');
|
|
730
|
+
expect(broken !== template, 'broken BPMN unchanged');
|
|
731
|
+
const parameters = { ...timerParameters('https://httpbin.org/anything'), Sender_Path: `/ai/broken${RUN}` };
|
|
732
|
+
delete parameters.Timer_Schedule;
|
|
733
|
+
ok(await call('cpi_create_iflow', { package_id: PKG, iflow_id: IFLOW_BROKEN, name: `AI Broken ${RUN}`, template: 'custom', iflw_xml: broken, parameters, confirmation: `CREATE IFLOW ${IFLOW_BROKEN}` }), 'create');
|
|
734
|
+
const deployed = ok(await call('cpi_deploy_iflow', { iflow_id: IFLOW_BROKEN, wait_seconds: 50, confirmation: `DEPLOY IFLOW ${IFLOW_BROKEN}` }), 'deploy');
|
|
735
|
+
expect(deployed.build_status === 'FAIL' && deployed.runtime_status === 'ERROR', JSON.stringify(deployed).slice(0, 400));
|
|
736
|
+
expect(/does not have an outgoing sequence flow/.test(deployed.error_information || ''), `error ${deployed.error_information}`);
|
|
737
|
+
expect(!deployed.error_information.trim().startsWith('{'), 'error information must be readable text, not raw JSON');
|
|
738
|
+
return deployed.error_information.split('\n').at(-1);
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
await scenario('S57', 'cpi_get_iflow_runtime_status on the broken iFlow is consistent with the deploy result', async () => {
|
|
742
|
+
const status = ok(await call('cpi_get_iflow_runtime_status', { iflow_id: IFLOW_BROKEN }), 'status');
|
|
743
|
+
if (status.deployed) {
|
|
744
|
+
expect(status.Status !== 'STARTED', `broken iFlow STARTED: ${JSON.stringify(status)}`);
|
|
745
|
+
if (status.Status === 'ERROR') expect(status.error_information, 'ERROR without error_information');
|
|
746
|
+
}
|
|
747
|
+
return status.deployed ? `${status.Status}` : 'not deployed';
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
await scenario('S58', 'APIM proxy passes method, query and body through (PATCH)', async () => {
|
|
751
|
+
const result = await invoke(`${ctx.proxyUrl}/items/7?filter=a%20b&n=2`, { method: 'PATCH', auth: false, body: '{"patch":true}', done: response => response.status === 200 });
|
|
752
|
+
expect(result.json.method === 'PATCH', `method ${result.json.method}`);
|
|
753
|
+
expect(result.json.args.filter === 'a b' && result.json.args.n === '2', JSON.stringify(result.json.args));
|
|
754
|
+
expect(JSON.parse(result.json.data).patch === true && /\/items\/7/.test(result.json.url), result.json.url);
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
await scenario('S59', 'APIM proxy forwards the client Authorization header to the backend', async () => {
|
|
758
|
+
const result = await invoke(`${ctx.proxyUrl}/auth`, { method: 'GET', auth: false, headers: { authorization: 'Bearer backend-token-123' }, done: response => response.status === 200 });
|
|
759
|
+
expect(result.json.headers.Authorization === 'Bearer backend-token-123', `headers ${JSON.stringify(result.json.headers).slice(0, 300)}`);
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
await scenario('S60', 'apim_update_api_proxy switches a provider target to a fixed URL', async () => {
|
|
763
|
+
ok(await call('apim_update_api_proxy', { name: PROXY, base_path: `/ai/smoke${RUN}`, service_type: 'rest', target: { url: 'https://httpbin.org/anything/switched' }, confirmation: `UPDATE API PROXY ${PROXY}` }), 'update');
|
|
764
|
+
const result = await invoke(`${ctx.proxyUrl}/x`, { method: 'GET', auth: false, done: response => response.status === 200 && /switched/.test(response.json?.url || '') });
|
|
765
|
+
expect(/\/anything\/switched\/x/.test(result.json?.url || ''), `upstream ${result.json?.url}`);
|
|
766
|
+
const proxy = ok(await call('apim_get_api_proxy', { name: PROXY }), 'get');
|
|
767
|
+
expect(/switched/.test(JSON.stringify(proxy.targetEndPoints)), JSON.stringify(proxy.targetEndPoints).slice(0, 300));
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
await scenario('S61', 'apim_update_api_proxy moves a proxy to a new base path', async () => {
|
|
771
|
+
const moved = ok(await call('apim_update_api_proxy', { name: PROXY_URL, base_path: `/ai/urlmoved${RUN}`, service_type: 'rest', target: { url: 'https://httpbin.org/anything/url-target' }, confirmation: `UPDATE API PROXY ${PROXY_URL}` }), 'update');
|
|
772
|
+
expect(moved.runtime_url.endsWith(`/ai/urlmoved${RUN}`), moved.runtime_url);
|
|
773
|
+
const result = await invoke(moved.runtime_url, { method: 'GET', auth: false, done: response => response.status === 200 });
|
|
774
|
+
expect(result.status === 200 && /url-target/.test(result.json.url), `HTTP ${result.status}`);
|
|
775
|
+
return moved.runtime_url;
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
await scenario('S62', 'apim_get_api_proxy runtime_url uses the default virtual host and base path', async () => {
|
|
779
|
+
const hosts = ok(await call('apim_list_virtual_hosts'), 'hosts');
|
|
780
|
+
const host = hosts.find(entry => entry.isDefault);
|
|
781
|
+
const proxy = ok(await call('apim_get_api_proxy', { name: PROXY_CPI_PROVIDER }), 'get');
|
|
782
|
+
expect(proxy.runtime_url === `https://${host.virtual_host}/ai/cpiprov${RUN}`, `${proxy.runtime_url} vs ${host.virtual_host}`);
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
await scenario('S63', 'apim_list_api_proxies honours top', async () => {
|
|
786
|
+
const one = ok(await call('apim_list_api_proxies', { top: 1 }), 'list');
|
|
787
|
+
expect(one.length === 1, `${one.length} proxies`);
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
await scenario('S64', 'XML-special title/description are escaped in the bundle and read back exactly', async () => {
|
|
791
|
+
const title = `R&D <Smoke> "q" 'a' ${RUN}`;
|
|
792
|
+
ok(await call('apim_create_api_proxy', { name: PROXY_XML, title, description: 'a < b && c > d', base_path: `/ai/xml${RUN}`, service_type: 'rest', target: { url: 'https://httpbin.org/anything/xml' }, confirmation: `CREATE API PROXY ${PROXY_XML}` }), 'create');
|
|
793
|
+
const proxy = ok(await call('apim_get_api_proxy', { name: PROXY_XML }), 'get');
|
|
794
|
+
expect(proxy.title === title, `title ${proxy.title}`);
|
|
795
|
+
// APIM stores descriptions as HTML and escapes markup characters (documented on the tool input).
|
|
796
|
+
expect(proxy.description === 'a < b && c > d', `description ${proxy.description}`);
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
await scenario('S65', 'proxy names with hyphens work end to end', async () => {
|
|
800
|
+
const created = ok(await call('apim_create_api_proxy', { name: PROXY_DASH, base_path: `/ai/dash${RUN}`, service_type: 'rest', target: { url: 'https://httpbin.org/anything/dash' }, confirmation: `CREATE API PROXY ${PROXY_DASH}` }), 'create');
|
|
801
|
+
const result = await invoke(created.runtime_url, { method: 'GET', auth: false, done: response => response.status === 200 });
|
|
802
|
+
expect(result.status === 200 && /dash/.test(result.json.url), `HTTP ${result.status}`);
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
await scenario('S66', 'duplicate API provider surfaces the APIM error text', async () => {
|
|
806
|
+
const result = await call('apim_create_api_provider', { name: PROVIDER, type: 'internet', host: 'httpbin.org', port: 443, confirmation: `CREATE API PROVIDER ${PROVIDER}` });
|
|
807
|
+
return cleanError(result, /^APIM POST \/apiportal\/api\/1\.0\/Management\.svc\/APIProviders failed \(HTTP \d+\)/);
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
await scenario('S67', 'API provider input validation rejects bad combinations', async () => {
|
|
811
|
+
const cases = [
|
|
812
|
+
{ name: 'ZAIBAD1', type: 'internet', host: 'httpbin.org', port: 443, location_id: 'X' },
|
|
813
|
+
{ name: 'ZAIBAD2', type: 'internet', host: 'bad host', port: 443 },
|
|
814
|
+
{ name: 'ZAIBAD3', type: 'internet', host: 'httpbin.org', port: 0 },
|
|
815
|
+
{ name: 'ZAIBAD4', type: 'on_premise', host: 's4h', port: 443, sap_client: '1000' },
|
|
816
|
+
{ name: '9starts-with-digit', type: 'internet', host: 'httpbin.org', port: 443 }
|
|
817
|
+
];
|
|
818
|
+
for (const args of cases) {
|
|
819
|
+
const result = await call('apim_create_api_provider', { ...args, confirmation: `CREATE API PROVIDER ${args.name}` });
|
|
820
|
+
expect(result.isError, `accepted ${JSON.stringify(args)}`);
|
|
821
|
+
}
|
|
822
|
+
const providers = ok(await call('apim_list_api_providers', { top: 200 }), 'list');
|
|
823
|
+
expect(!providers.some(entry => /^ZAIBAD/.test(entry.name)), 'invalid provider was created');
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
await scenario('S68', 'API proxy input validation rejects unsafe paths and targets', async () => {
|
|
827
|
+
const base = { name: 'ZAI_Bad', service_type: 'rest', confirmation: 'CREATE API PROXY ZAI_Bad' };
|
|
828
|
+
const cases = [
|
|
829
|
+
{ ...base, base_path: '/a//b', target: { url: 'https://httpbin.org/get' } },
|
|
830
|
+
{ ...base, base_path: '/a/', target: { url: 'https://httpbin.org/get' } },
|
|
831
|
+
{ ...base, base_path: '/a/../b', target: { url: 'https://httpbin.org/get' } },
|
|
832
|
+
{ ...base, base_path: '/ok', target: { url: 'http://httpbin.org/get' } },
|
|
833
|
+
{ ...base, base_path: '/ok', target: { provider: PROVIDER, relative_path: '/x?y=1' } },
|
|
834
|
+
{ ...base, base_path: '/ok', target: { provider: PROVIDER, relative_path: '/x', url: 'https://a.b' } }
|
|
835
|
+
];
|
|
836
|
+
for (const args of cases) expect((await call('apim_create_api_proxy', args)).isError, `accepted ${JSON.stringify(args)}`);
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
await scenario('S69', 'iFlow input validation rejects bad ids, paths, timeouts and enum values', async () => {
|
|
840
|
+
const base = { package_id: PKG, name: 'bad', template: 'https-request-reply-http' };
|
|
841
|
+
const parameters = { sender_path: '/ok', receiver_address: 'https://httpbin.org/anything', receiver_proxy_type: 'internet', receiver_authentication: 'None' };
|
|
842
|
+
const cases = [
|
|
843
|
+
{ ...base, iflow_id: '1bad', template_parameters: parameters },
|
|
844
|
+
{ ...base, iflow_id: 'Z_bad', template_parameters: { ...parameters, sender_path: 'noslash' } },
|
|
845
|
+
{ ...base, iflow_id: 'Z_bad', template_parameters: { ...parameters, receiver_timeout_ms: 10 } },
|
|
846
|
+
{ ...base, iflow_id: 'Z_bad', template_parameters: { ...parameters, receiver_authentication: 'Kerberos' } },
|
|
847
|
+
{ ...base, iflow_id: 'Z_bad', template_parameters: { ...parameters, receiver_address: 'ftp://x' } },
|
|
848
|
+
{ ...base, iflow_id: 'Z_bad', template: 'custom' },
|
|
849
|
+
{ ...base, iflow_id: 'Z_bad', name: 'Café Flow', template_parameters: parameters },
|
|
850
|
+
{ ...base, iflow_id: 'Z_bad', name: 'Trailing.', template_parameters: parameters }
|
|
851
|
+
];
|
|
852
|
+
for (const args of cases) expect((await call('cpi_create_iflow', { ...args, confirmation: `CREATE IFLOW ${args.iflow_id}` })).isError, `accepted ${JSON.stringify(args)}`);
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
await scenario('S70', 'cpi_create_iflow into a missing package surfaces the CPI error', async () => {
|
|
856
|
+
const result = await call('cpi_create_iflow', { package_id: 'ZAI_NO_SUCH_PKG', iflow_id: `Z_AI_Orphan_${RUN}`, name: 'orphan', template: 'https-request-reply-http', template_parameters: { sender_path: `/ai/orphan${RUN}`, receiver_address: 'https://httpbin.org/anything', receiver_proxy_type: 'internet', receiver_authentication: 'None' }, confirmation: `CREATE IFLOW Z_AI_Orphan_${RUN}` });
|
|
857
|
+
return cleanError(result, /^CPI POST \/api\/v1\/IntegrationDesigntimeArtifacts failed \(HTTP \d+\)/);
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
await scenario('S71', 'reads of missing objects return clean, specific errors', async () => {
|
|
861
|
+
const notes = [];
|
|
862
|
+
notes.push(cleanError(await call('cpi_get_iflow', { iflow_id: 'Z_AI_No_Such_Flow' }), /^CPI GET /));
|
|
863
|
+
notes.push(cleanError(await call('cpi_list_iflows', { package_id: 'ZAI_NO_SUCH_PKG' }), /^CPI GET /));
|
|
864
|
+
notes.push(cleanError(await call('apim_get_api_proxy', { name: 'ZAI_No_Such_Proxy' }), /does not exist/));
|
|
865
|
+
return notes[0];
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
await scenario('S72', 'message log lookups: malformed GUID rejected, unknown GUID is a clean error', async () => {
|
|
869
|
+
expect((await call('cpi_get_message_log', { message_guid: 'bad guid!' })).isError, 'malformed GUID accepted');
|
|
870
|
+
return cleanError(await call('cpi_get_message_log', { message_guid: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAA' }), /^CPI GET /);
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
await scenario('S73', 'deploy and configure of a missing iFlow fail cleanly', async () => {
|
|
874
|
+
cleanError(await call('cpi_deploy_iflow', { iflow_id: 'Z_AI_No_Such_Flow', wait_seconds: 5, confirmation: 'DEPLOY IFLOW Z_AI_No_Such_Flow' }), /^CPI /);
|
|
875
|
+
const configured = ok(await call('cpi_update_iflow_configuration', { iflow_id: 'Z_AI_No_Such_Flow', parameters: [{ key: 'A', value: 'b' }], confirmation: 'CONFIGURE IFLOW Z_AI_No_Such_Flow' }), 'configure');
|
|
876
|
+
expect(configured.updated.length === 0 && /^CPI PUT/.test(configured.failed?.error || ''), JSON.stringify(configured));
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
await scenario('S74', 'update with a missing iFlow fails without writing', async () => {
|
|
880
|
+
return cleanError(await call('cpi_update_iflow', { iflow_id: 'Z_AI_No_Such_Flow', expected_version: '1.0.0', files: [{ path: 'src/main/resources/script/a.groovy', content: 'x' }], confirmation: 'UPDATE IFLOW Z_AI_No_Such_Flow' }), /^CPI GET /);
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
await scenario('S75', 'parallel mixed CPI/APIM reads succeed (shared token caches)', async () => {
|
|
884
|
+
const calls = await Promise.all([
|
|
885
|
+
call('cpi_list_packages', { top: 50 }),
|
|
886
|
+
call('cpi_list_iflows', { package_id: PKG }),
|
|
887
|
+
call('cpi_list_message_logs', { top: 5 }),
|
|
888
|
+
call('apim_list_api_proxies', { top: 50 }),
|
|
889
|
+
call('apim_list_api_providers', { top: 50 }),
|
|
890
|
+
call('apim_list_virtual_hosts'),
|
|
891
|
+
call('cpi_get_iflow_runtime_status', { iflow_id: IFLOW_POST }),
|
|
892
|
+
call('apim_get_api_proxy', { name: PROXY_CPI })
|
|
893
|
+
]);
|
|
894
|
+
const failures = calls.filter(result => result.isError).map(result => result.text);
|
|
895
|
+
expect(!failures.length, failures.join(' | '));
|
|
896
|
+
return `${calls.length} concurrent calls`;
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
await scenario('S76', 'CPI-runtime API provider read back has no secrets and Internet type', async () => {
|
|
900
|
+
const provider = ok(await call('apim_get_api_provider', { name: PROVIDER_CPI }), 'get');
|
|
901
|
+
expect(provider.isOnPremise === false && provider.destType === 'Internet', JSON.stringify(provider).slice(0, 300));
|
|
902
|
+
expect(!/"(?:password|clientSecret|userName|clientId)"/.test(JSON.stringify(provider)), 'credential keys present');
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
await scenario('S77', 'service endpoints without a filter include every HTTPS iFlow of this run', async () => {
|
|
906
|
+
const endpoints = ok(await call('cpi_list_service_endpoints'), 'endpoints');
|
|
907
|
+
const urls = endpoints.flatMap(entry => entry.urls);
|
|
908
|
+
for (const suffix of [`/http/ai/smoke${RUN}`, `/http/ai/post${RUN}`]) expect(urls.some(url => url.endsWith(suffix)), `missing ${suffix}`);
|
|
909
|
+
return `${endpoints.length} endpoints`;
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
await scenario('S78', 'restore the template iFlow (config + redeploy) and verify recovery', async () => {
|
|
913
|
+
ok(await call('cpi_update_iflow_configuration', { iflow_id: IFLOW, parameters: [{ key: 'Receiver_Address', value: 'https://httpbin.org/anything/restored' }], confirmation: `CONFIGURE IFLOW ${IFLOW}` }), 'configure');
|
|
914
|
+
const { runtime } = await deployAndWait(IFLOW);
|
|
915
|
+
expect(runtime.Status === 'STARTED', JSON.stringify(runtime));
|
|
916
|
+
const result = await invoke(ctx.cpiEndpoint, { body: '{}', done: response => response.status === 200 && /restored/.test(response.json?.url || '') });
|
|
917
|
+
expect(result.status === 200 && /restored/.test(result.json.url), `HTTP ${result.status} ${result.json?.url}`);
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
await scenario('S79', 'full chain after recovery: client → APIM (provider) → CPI → backend', async () => {
|
|
921
|
+
const proxy = ok(await call('apim_get_api_proxy', { name: PROXY_CPI_PROVIDER }), 'get');
|
|
922
|
+
const result = await invoke(proxy.runtime_url, { body: '{"final":true}', done: response => response.status === 200 && /restored/.test(response.json?.url || '') });
|
|
923
|
+
expect(result.status === 200 && JSON.parse(result.json.data).final === true, `HTTP ${result.status}`);
|
|
924
|
+
const detail = await waitForGuid(result.json.headers['Sap-Messageprocessinglogid']);
|
|
925
|
+
expect(detail.log.Status === 'COMPLETED', detail.log.Status);
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
// ============================================================ extended coverage (runs after the original chain)
|
|
929
|
+
await scenario('S81', 'odata_get readers list entity sets from both $metadata documents', async () => {
|
|
930
|
+
const cpiMeta = ok(await call('cpi_odata_get', { path: '/$metadata' }), 'cpi metadata');
|
|
931
|
+
const apimMeta = ok(await call('apim_odata_get', { path: '/$metadata' }), 'apim metadata');
|
|
932
|
+
expect(cpiMeta.entity_sets.some(set => set.name === 'IntegrationRuntimeArtifacts'), 'CPI metadata misses IntegrationRuntimeArtifacts');
|
|
933
|
+
expect(apimMeta.entity_sets.some(set => set.name === 'APIProxies'), 'APIM metadata misses APIProxies');
|
|
934
|
+
const apimSets = new Set(apimMeta.entity_sets.map(set => set.name));
|
|
935
|
+
return `CPI ${cpiMeta.entity_sets.length} sets; APIM ${apimSets.size} sets (KeyMapEntryValues ${apimSets.has('KeyMapEntryValues')}, Applications ${apimSets.has('Applications')})`;
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
await scenario('S82', 'cpi_list_runtime_artifacts and cpi_list_package_artifacts show this run', async () => {
|
|
939
|
+
const runtime = ok(await call('cpi_list_runtime_artifacts', { top: 200 }), 'runtime');
|
|
940
|
+
const artifacts = runtime.artifacts ?? runtime;
|
|
941
|
+
expect(JSON.stringify(artifacts).includes(IFLOW), `${IFLOW} not deployed in runtime list`);
|
|
942
|
+
const contents = ok(await call('cpi_list_package_artifacts', { package_id: PKG }), 'package');
|
|
943
|
+
expect(JSON.stringify(contents).includes(IFLOW), 'package contents miss the iFlow');
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
await scenario('S83', 'cpi_get_message_log_steps returns the steps of a runtime call', async () => {
|
|
947
|
+
expect(ctx.directGuid, 'needs S41');
|
|
948
|
+
const steps = ok(await call('cpi_get_message_log_steps', { message_guid: ctx.directGuid }), 'steps');
|
|
949
|
+
expect(JSON.stringify(steps).length > 50, JSON.stringify(steps));
|
|
950
|
+
return JSON.stringify(steps).slice(0, 160);
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
await scenario('S84', 'monitoring reads: JMS broker, data stores, variables', async () => {
|
|
954
|
+
const notes = [];
|
|
955
|
+
for (const name of ['cpi_get_jms_broker_status', 'cpi_list_data_stores', 'cpi_list_variables']) {
|
|
956
|
+
const result = await call(name);
|
|
957
|
+
if (result.isError) cleanError(result);
|
|
958
|
+
notes.push(`${name}: ${result.isError ? result.text.slice(0, 80) : 'ok'}`);
|
|
959
|
+
}
|
|
960
|
+
return notes.join('; ');
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
await scenario('S85', 'cpi_set_user_credential creates then updates; password never returned', async () => {
|
|
964
|
+
const created = ok(await call('cpi_set_user_credential', { name: CREDENTIAL, user: 'smoke', password: RUN_SECRETS.credential, description: 'AI smoke', confirmation: `SET USER CREDENTIAL ${CREDENTIAL}` }), 'create');
|
|
965
|
+
expect(created.created === true, JSON.stringify(created));
|
|
966
|
+
const updated = ok(await call('cpi_set_user_credential', { name: CREDENTIAL, user: 'smoke2', password: RUN_SECRETS.credential, confirmation: `SET USER CREDENTIAL ${CREDENTIAL}` }), 'update');
|
|
967
|
+
expect(updated.updated === true, JSON.stringify(updated));
|
|
968
|
+
let listed;
|
|
969
|
+
for (let attempt = 0; attempt < 6; attempt += 1) {
|
|
970
|
+
listed = ok(await call('cpi_list_security_material', { kind: 'user_credentials' }), 'list');
|
|
971
|
+
if (JSON.stringify(listed).includes(CREDENTIAL)) break;
|
|
972
|
+
await sleep(5000);
|
|
973
|
+
}
|
|
974
|
+
expect(JSON.stringify(listed).includes(CREDENTIAL), 'credential not listed');
|
|
975
|
+
});
|
|
976
|
+
|
|
977
|
+
await scenario('S86', 'cpi_set_number_range creates a range and reads it back', async () => {
|
|
978
|
+
ok(await call('cpi_set_number_range', { name: NUMBER_RANGE, description: 'AI smoke', min_value: '1', max_value: '9999', rotate: true, field_length: 4, confirmation: `SET NUMBER RANGE ${NUMBER_RANGE}` }), 'set');
|
|
979
|
+
const ranges = ok(await call('cpi_list_number_ranges'), 'list');
|
|
980
|
+
expect(JSON.stringify(ranges).includes(NUMBER_RANGE), 'range not listed');
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
await scenario('S87', 'cpi_set_partner_parameter creates and updates a string parameter', async () => {
|
|
984
|
+
ok(await call('cpi_set_partner_parameter', { pid: PARTNER, id: 'ReceiverAddress', type: 'string', value: 'https://a.example', confirmation: `SET PARTNER PARAMETER ${PARTNER}/ReceiverAddress` }), 'create');
|
|
985
|
+
ok(await call('cpi_set_partner_parameter', { pid: PARTNER, id: 'ReceiverAddress', type: 'string', value: 'https://b.example', confirmation: `SET PARTNER PARAMETER ${PARTNER}/ReceiverAddress` }), 'update');
|
|
986
|
+
const listed = ok(await call('cpi_list_partner_parameters', { pid: PARTNER }), 'list');
|
|
987
|
+
expect(JSON.stringify(listed).includes('https://b.example'), JSON.stringify(listed).slice(0, 300));
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
await scenario('S88', 'apim_create_key_value_map (encrypted) and set entries; values never returned', async () => {
|
|
991
|
+
ok(await call('apim_create_key_value_map', { name: KVM, entries: [{ name: 'user', value: 'smoke' }, { name: 'password', value: RUN_SECRETS.kvm }], confirmation: `CREATE KEY VALUE MAP ${KVM}` }), 'create');
|
|
992
|
+
const updated = ok(await call('apim_set_key_value_map_entries', { name: KVM, entries: [{ name: 'password', value: RUN_SECRETS.kvm }, { name: 'extra', value: 'x' }], confirmation: `UPDATE KEY VALUE MAP ${KVM}` }), 'set');
|
|
993
|
+
expect(!updated.failed, JSON.stringify(updated));
|
|
994
|
+
const map = ok(await call('apim_get_key_value_map', { name: KVM }), 'get');
|
|
995
|
+
expect(map.entries.length === 3 && map.entries.every(entry => entry.value === '[redacted]'), JSON.stringify(map));
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
await scenario('S89', 'apim_attach_api_proxy_policies: header + spike arrest reach the backend', async () => {
|
|
999
|
+
const attached = ok(await call('apim_attach_api_proxy_policies', {
|
|
1000
|
+
name: PROXY,
|
|
1001
|
+
policies: [
|
|
1002
|
+
{ name: 'smokeSpike', template: 'spike_arrest', params: { rate: '100ps' } },
|
|
1003
|
+
{ name: 'smokeHeader', template: 'assign_headers', endpoint: 'target', params: { message: 'request', set: { 'X-AI-Smoke': RUN } } }
|
|
1004
|
+
],
|
|
1005
|
+
confirmation: `UPDATE API PROXY POLICIES ${PROXY}`
|
|
1006
|
+
}), 'attach');
|
|
1007
|
+
const url = attached.runtime_url ?? ok(await call('apim_get_api_proxy', { name: PROXY }), 'get').runtime_url;
|
|
1008
|
+
let echoed;
|
|
1009
|
+
for (let attempt = 0; attempt < 12; attempt += 1) {
|
|
1010
|
+
const response = await http(`${url}/policy`);
|
|
1011
|
+
if (response.status === 200) {
|
|
1012
|
+
echoed = JSON.parse(response.text);
|
|
1013
|
+
if (echoed.headers?.['X-Ai-Smoke'] === RUN) break;
|
|
1014
|
+
}
|
|
1015
|
+
await sleep(5000);
|
|
1016
|
+
}
|
|
1017
|
+
expect(echoed?.headers?.['X-Ai-Smoke'] === RUN, `backend headers ${JSON.stringify(echoed?.headers)}`);
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
await scenario('S90', 'apim_export_api_proxy shows the attached policies; update keeps them', async () => {
|
|
1021
|
+
const exported = ok(await call('apim_export_api_proxy', { name: PROXY }), 'export');
|
|
1022
|
+
const paths = exported.files.map(file => file.path);
|
|
1023
|
+
expect(paths.some(path => /Policy\/smokeSpike\.xml$/.test(path)), paths.join(','));
|
|
1024
|
+
const proxy = ok(await call('apim_get_api_proxy', { name: PROXY }), 'get');
|
|
1025
|
+
const basePath = proxy.proxyEndPoints?.[0]?.base_path ?? `/ai/smoke${RUN}`;
|
|
1026
|
+
const updated = ok(await call('apim_update_api_proxy', { name: PROXY, base_path: basePath, service_type: 'rest', target: { provider: PROVIDER, relative_path: '/anything/v3' }, confirmation: `UPDATE API PROXY ${PROXY}` }), 'update');
|
|
1027
|
+
expect(updated.preserved_policies?.includes('smokeSpike'), JSON.stringify(updated));
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
await scenario('S91', 'verify_api_key policy rejects calls without a key', async () => {
|
|
1031
|
+
const attached = ok(await call('apim_attach_api_proxy_policies', { name: PROXY, policies: [{ name: 'smokeVerifyKey', template: 'verify_api_key', params: {} }], confirmation: `UPDATE API PROXY POLICIES ${PROXY}` }), 'attach');
|
|
1032
|
+
const url = attached.runtime_url ?? ok(await call('apim_get_api_proxy', { name: PROXY }), 'get').runtime_url;
|
|
1033
|
+
let status;
|
|
1034
|
+
for (let attempt = 0; attempt < 12; attempt += 1) {
|
|
1035
|
+
status = (await http(`${url}/nokey`)).status;
|
|
1036
|
+
if (status === 401) break;
|
|
1037
|
+
await sleep(5000);
|
|
1038
|
+
}
|
|
1039
|
+
expect(status === 401, `expected 401 without apikey, got ${status}`);
|
|
1040
|
+
});
|
|
1041
|
+
|
|
1042
|
+
await scenario('S92', 'apim_create_api_product over the proxy, read back, and update quota', async () => {
|
|
1043
|
+
const created = ok(await call('apim_create_api_product', { name: PRODUCT, title: 'AI smoke product', proxies: [PROXY], quota: { count: 100, interval: 1, time_unit: 'minute' }, confirmation: `CREATE API PRODUCT ${PRODUCT}` }), 'create');
|
|
1044
|
+
expect(created.created === true, JSON.stringify(created));
|
|
1045
|
+
const product = ok(await call('apim_get_api_product', { name: PRODUCT }), 'get');
|
|
1046
|
+
expect(product.api_proxies.includes(PROXY), JSON.stringify(product));
|
|
1047
|
+
const updated = ok(await call('apim_update_api_product', { name: PRODUCT, quota: null, confirmation: `UPDATE API PRODUCT ${PRODUCT}` }), 'update');
|
|
1048
|
+
expect(updated.updated === true, JSON.stringify(updated));
|
|
1049
|
+
});
|
|
1050
|
+
|
|
1051
|
+
await scenario('S93', 'cpi_undeploy_artifact removes the failing timer iFlow from the runtime', async () => {
|
|
1052
|
+
const result = ok(await call('cpi_undeploy_artifact', { artifact_id: IFLOW_TIMER_FAIL, wait_seconds: 50, confirmation: `UNDEPLOY ARTIFACT ${IFLOW_TIMER_FAIL}` }), 'undeploy');
|
|
1053
|
+
let status = ok(await call('cpi_get_iflow_runtime_status', { iflow_id: IFLOW_TIMER_FAIL }), 'status');
|
|
1054
|
+
for (let attempt = 0; attempt < 12 && status.deployed; attempt += 1) {
|
|
1055
|
+
await sleep(5000);
|
|
1056
|
+
status = ok(await call('cpi_get_iflow_runtime_status', { iflow_id: IFLOW_TIMER_FAIL }), 'status');
|
|
1057
|
+
}
|
|
1058
|
+
expect(status.deployed === false, `still deployed: ${JSON.stringify(result)}`);
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
await scenario('S94', 'integration_connection_info verify: both sides sign in and every area is classified', async () => {
|
|
1062
|
+
const { verification } = ok(await call('integration_connection_info', { verify: true }), 'verify');
|
|
1063
|
+
expect(verification.ok, JSON.stringify(verification).slice(0, 400));
|
|
1064
|
+
const summary = side => side.checks.map(check => `${check.area}=${check.result}`).join(', ');
|
|
1065
|
+
return `CPI: ${summary(verification.cpi)} | APIM: ${summary(verification.apim)}`;
|
|
1066
|
+
});
|
|
1067
|
+
|
|
1068
|
+
await scenario('S80', `after all writes: ${INTEGRATION_TOOL_NAMES.length} tools, both sides configured, no secrets emitted`, async () => {
|
|
1069
|
+
expect((await client.listTools()).tools.length === INTEGRATION_TOOL_NAMES.length, 'tool count changed');
|
|
1070
|
+
const info = ok(await call('integration_connection_info'), 'info');
|
|
1071
|
+
expect(info.cpi.configured && info.apim.configured, 'side lost');
|
|
1072
|
+
return `${results.filter(result => result.status === 'PASS').length} passed so far; every tool output scanned for ${SECRETS.length} secrets`;
|
|
1073
|
+
});
|
|
1074
|
+
|
|
1075
|
+
await client.close();
|
|
1076
|
+
await runtime.close();
|
|
1077
|
+
const failed = results.filter(result => result.status === 'FAIL');
|
|
1078
|
+
console.log(`\n${results.length - failed.length}/${results.length} passed`);
|
|
1079
|
+
process.exitCode = failed.length ? 1 : 0;
|