sap-ai-dev-toolkit 0.5.0 → 0.5.6
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/abap-developer.agent.md +3 -3
- package/.github/agents/abap-runtime-debugger.agent.md +3 -3
- package/.github/agents/hana-cloud-hdi-specialist.agent.md +1 -1
- package/.github/agents/rap-service-developer.agent.md +1 -1
- package/.github/agents/sap-solution-architect.agent.md +1 -1
- package/.github/skills/abap-debugging/SKILL.md +3 -3
- package/.github/skills/abap-development/SKILL.md +1 -1
- package/.github/skills/abap-runtime-analysis/SKILL.md +3 -3
- package/.github/skills/abap-testing-quality/SKILL.md +1 -1
- package/.github/skills/cds-development/SKILL.md +1 -1
- package/.github/skills/clean-core-extensibility/SKILL.md +1 -1
- package/.github/skills/hana-cloud-inspection/SKILL.md +1 -1
- package/.github/skills/hana-cloud-native-development/SKILL.md +2 -2
- package/.github/skills/hana-cloud-validation/SKILL.md +1 -1
- package/.github/skills/rap-development/SKILL.md +1 -1
- package/.github/skills/rap-service-delivery/SKILL.md +1 -1
- package/.github/skills/sap-sdlc-orchestration/SKILL.md +1 -1
- package/.github/skills/sap-standard-api-analysis/SKILL.md +1 -1
- package/.github/skills/sap-transport-release/SKILL.md +1 -1
- package/README.md +17 -11
- package/inventory.md +179 -0
- package/package.json +2 -2
- package/scripts/build-vsp.mjs +2 -1
- package/scripts/postinstall.mjs +17 -18
- package/src/bas-destination-relay.mjs +58 -22
- package/src/bas-discovery.mjs +24 -8
- package/src/binary.mjs +101 -17
- package/src/cf-destination.mjs +55 -1
- package/src/credentials-store.mjs +0 -10
- package/src/engineering-tools.mjs +32 -10
- package/src/hana-tools.mjs +9 -2
- package/src/launcher.mjs +22 -19
- package/src/mcp-config.mjs +41 -7
- package/src/mcp-proxy.mjs +392 -104
- package/src/redact.mjs +28 -0
- package/src/setup.mjs +33 -3
- package/tools.md +4 -2
- package/test/bas-destination-relay.test.mjs +0 -374
- package/test/cf-connectivity.test.mjs +0 -287
- package/test/cf-destination.test.mjs +0 -307
- package/test/cf-runtime.test.mjs +0 -527
- package/test/copilot-assets.test.mjs +0 -63
- package/test/copilot-content.test.mjs +0 -249
- package/test/credentials-store.test.mjs +0 -187
- package/test/discovery.test.mjs +0 -257
- package/test/fixtures/fake-vsp.mjs +0 -263
- package/test/hana-config.test.mjs +0 -168
- package/test/hana-inspector-stdio.test.mjs +0 -44
- package/test/hana-tools.test.mjs +0 -206
- package/test/launcher.test.mjs +0 -399
- package/test/live-s4h.test.mjs +0 -202
- package/test/mcp-config-cf.test.mjs +0 -407
- package/test/mcp-proxy.test.mjs +0 -409
- package/test/pty.mjs +0 -8
- package/test/setup-cf.test.mjs +0 -489
- package/test/setup.test.mjs +0 -417
- package/test/terminal-ui.test.mjs +0 -47
|
@@ -1,287 +0,0 @@
|
|
|
1
|
-
import test from 'node:test';
|
|
2
|
-
import assert from 'node:assert/strict';
|
|
3
|
-
import { createServer, request as httpRequest } from 'node:http';
|
|
4
|
-
import net from 'node:net';
|
|
5
|
-
import { createCfConnectivityProxy } from '../src/cf-connectivity.mjs';
|
|
6
|
-
|
|
7
|
-
async function listen(server) {
|
|
8
|
-
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
9
|
-
return server.address().port;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
async function close(server) {
|
|
13
|
-
if (!server.listening) return;
|
|
14
|
-
server.closeAllConnections?.();
|
|
15
|
-
for (const socket of server.__sockets || []) socket.destroy();
|
|
16
|
-
await new Promise(resolve => server.close(resolve));
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function trackSockets(server) {
|
|
20
|
-
server.__sockets = new Set();
|
|
21
|
-
server.on('connection', socket => {
|
|
22
|
-
server.__sockets.add(socket);
|
|
23
|
-
socket.once('close', () => server.__sockets.delete(socket));
|
|
24
|
-
});
|
|
25
|
-
return server;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function responseToken(token, expires = 300) {
|
|
29
|
-
return new Response(JSON.stringify({ access_token: token, expires_in: expires }), {
|
|
30
|
-
status: 200,
|
|
31
|
-
headers: { 'content-type': 'application/json' }
|
|
32
|
-
});
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const connectivityCredentials = port => ({
|
|
36
|
-
onpremise_proxy_host: '127.0.0.1',
|
|
37
|
-
onpremise_proxy_http_port: port,
|
|
38
|
-
token_service_url: 'https://token.example',
|
|
39
|
-
clientid: 'connectivity-client',
|
|
40
|
-
clientsecret: 'connectivity-secret'
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
test('routes HTTP ADT traffic through Connectivity with scoped bearer and location headers', async t => {
|
|
44
|
-
const observed = { token: [], proxy: [], target: [] };
|
|
45
|
-
const target = trackSockets(createServer((request, response) => {
|
|
46
|
-
observed.target.push({ path: request.url, authorization: request.headers.authorization });
|
|
47
|
-
response.writeHead(200, { 'content-type': 'text/plain' });
|
|
48
|
-
response.end('ADT response');
|
|
49
|
-
}));
|
|
50
|
-
const targetPort = await listen(target);
|
|
51
|
-
t.after(() => close(target));
|
|
52
|
-
|
|
53
|
-
const btpProxy = trackSockets(createServer((request, response) => {
|
|
54
|
-
observed.proxy.push({
|
|
55
|
-
authorization: request.headers['proxy-authorization'],
|
|
56
|
-
location: request.headers['sap-connectivity-scc-location_id'],
|
|
57
|
-
path: request.url
|
|
58
|
-
});
|
|
59
|
-
if (request.headers['proxy-authorization'] !== 'Bearer application-token' || request.headers['sap-connectivity-scc-location_id'] !== 'scc-east') {
|
|
60
|
-
response.writeHead(403);
|
|
61
|
-
response.end('proxy credentials rejected');
|
|
62
|
-
return;
|
|
63
|
-
}
|
|
64
|
-
const targetUrl = new URL(request.url);
|
|
65
|
-
const headers = { ...request.headers, host: targetUrl.host };
|
|
66
|
-
delete headers['proxy-authorization'];
|
|
67
|
-
delete headers['sap-connectivity-scc-location_id'];
|
|
68
|
-
forwardHttpRequest(targetUrl, request.method, headers, response);
|
|
69
|
-
request.resume();
|
|
70
|
-
}));
|
|
71
|
-
const btpPort = await listen(btpProxy);
|
|
72
|
-
t.after(() => close(btpProxy));
|
|
73
|
-
|
|
74
|
-
const tokenRequests = [];
|
|
75
|
-
const route = await createCfConnectivityProxy({
|
|
76
|
-
destinationUrl: `http://127.0.0.1:${targetPort}`,
|
|
77
|
-
connectivityCredentials: connectivityCredentials(btpPort),
|
|
78
|
-
locationId: 'scc-east',
|
|
79
|
-
authMode: 'application',
|
|
80
|
-
fetchImpl: async (url, options) => {
|
|
81
|
-
tokenRequests.push({ url: String(url), headers: options.headers, body: new URLSearchParams(options.body) });
|
|
82
|
-
return responseToken('application-token');
|
|
83
|
-
}
|
|
84
|
-
});
|
|
85
|
-
t.after(() => route.close());
|
|
86
|
-
assert.deepEqual(Object.keys(route).sort(), ['close', 'url']);
|
|
87
|
-
assert.equal(tokenRequests.length, 1);
|
|
88
|
-
assert.equal(tokenRequests[0].url, 'https://token.example/oauth/token');
|
|
89
|
-
assert.equal(tokenRequests[0].headers.authorization, `Basic ${Buffer.from('connectivity-client:connectivity-secret').toString('base64')}`);
|
|
90
|
-
assert.equal(tokenRequests[0].body.get('grant_type'), 'client_credentials');
|
|
91
|
-
|
|
92
|
-
const response = await proxyRequest(route.url, `http://127.0.0.1:${targetPort}/sap/bc/adt/discovery`, {
|
|
93
|
-
authorization: 'Basic sap-user:sap-password'
|
|
94
|
-
});
|
|
95
|
-
assert.equal(response.status, 200);
|
|
96
|
-
assert.equal(response.body, 'ADT response');
|
|
97
|
-
assert.deepEqual(observed.proxy.map(request => [request.authorization, request.location]), [['Bearer application-token', 'scc-east']]);
|
|
98
|
-
assert.deepEqual(observed.target.map(request => [request.path, request.authorization]), [['/sap/bc/adt/discovery', 'Basic sap-user:sap-password']]);
|
|
99
|
-
|
|
100
|
-
const forbidden = await proxyRequest(route.url, 'http://not-the-selected-host.example/sap/bc/adt/discovery');
|
|
101
|
-
assert.equal(forbidden.status, 403);
|
|
102
|
-
assert.equal(observed.proxy.length, 1, 'unauthorized targets never reach the BTP proxy');
|
|
103
|
-
});
|
|
104
|
-
test('closes stalled Connectivity HTTP forwarding when the route shuts down', async t => {
|
|
105
|
-
let requestReceived;
|
|
106
|
-
const receivedRequest = new Promise(resolve => { requestReceived = resolve; });
|
|
107
|
-
const btpProxy = trackSockets(createServer(request => {
|
|
108
|
-
request.resume();
|
|
109
|
-
requestReceived();
|
|
110
|
-
}));
|
|
111
|
-
const btpPort = await listen(btpProxy);
|
|
112
|
-
t.after(() => close(btpProxy));
|
|
113
|
-
const route = await createCfConnectivityProxy({
|
|
114
|
-
destinationUrl: 'http://sap.example:8080',
|
|
115
|
-
connectivityCredentials: connectivityCredentials(btpPort),
|
|
116
|
-
authMode: 'application',
|
|
117
|
-
fetchImpl: async () => responseToken('application-token')
|
|
118
|
-
});
|
|
119
|
-
t.after(() => route.close());
|
|
120
|
-
|
|
121
|
-
const response = proxyRequest(route.url, 'http://sap.example:8080/sap/bc/adt/discovery', {}, 1_000);
|
|
122
|
-
await receivedRequest;
|
|
123
|
-
await route.close();
|
|
124
|
-
await assert.rejects(response);
|
|
125
|
-
await new Promise(resolve => setTimeout(resolve, 0));
|
|
126
|
-
assert.equal(btpProxy.__sockets.size, 0, 'route shutdown closes its outstanding BTP request');
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
test('uses the CF user JWT for PrincipalPropagation CONNECT and never falls back to an application token', async t => {
|
|
130
|
-
const observed = { connect: [], tunnel: [] };
|
|
131
|
-
const btpProxy = trackSockets(createServer());
|
|
132
|
-
btpProxy.on('connect', (request, socket, head) => {
|
|
133
|
-
observed.connect.push({
|
|
134
|
-
target: request.url,
|
|
135
|
-
authorization: request.headers['proxy-authorization'],
|
|
136
|
-
location: request.headers['sap-connectivity-scc-location_id']
|
|
137
|
-
});
|
|
138
|
-
if (request.headers['proxy-authorization'] !== 'Bearer user-exchange-token' || request.headers['sap-connectivity-scc-location_id'] !== 'scc-west') {
|
|
139
|
-
socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
|
|
140
|
-
return;
|
|
141
|
-
}
|
|
142
|
-
socket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
|
143
|
-
if (head.length) observed.tunnel.push(head.toString());
|
|
144
|
-
socket.on('data', data => observed.tunnel.push(data.toString()));
|
|
145
|
-
});
|
|
146
|
-
const btpPort = await listen(btpProxy);
|
|
147
|
-
t.after(() => close(btpProxy));
|
|
148
|
-
|
|
149
|
-
const tokenRequests = [];
|
|
150
|
-
let userTokenCalls = 0;
|
|
151
|
-
const route = await createCfConnectivityProxy({
|
|
152
|
-
destinationUrl: 'https://sap.example:8443',
|
|
153
|
-
connectivityCredentials: connectivityCredentials(btpPort),
|
|
154
|
-
locationId: 'scc-west',
|
|
155
|
-
authMode: 'principal-propagation',
|
|
156
|
-
userTokenProvider: async () => { userTokenCalls += 1; return 'current-cf-user-jwt'; },
|
|
157
|
-
fetchImpl: async (url, options) => {
|
|
158
|
-
tokenRequests.push({ url: String(url), headers: options.headers, body: new URLSearchParams(options.body) });
|
|
159
|
-
return responseToken('user-exchange-token');
|
|
160
|
-
}
|
|
161
|
-
});
|
|
162
|
-
t.after(() => route.close());
|
|
163
|
-
assert.equal(userTokenCalls, 1);
|
|
164
|
-
assert.equal(tokenRequests.length, 1);
|
|
165
|
-
assert.equal(tokenRequests[0].headers.authorization, `Basic ${Buffer.from('connectivity-client:connectivity-secret').toString('base64')}`);
|
|
166
|
-
assert.deepEqual(Object.fromEntries(tokenRequests[0].body), {
|
|
167
|
-
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
|
168
|
-
assertion: 'current-cf-user-jwt',
|
|
169
|
-
token_format: 'jwt',
|
|
170
|
-
response_type: 'token'
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
const port = Number(new URL(route.url).port);
|
|
174
|
-
const socket = net.connect(port, '127.0.0.1');
|
|
175
|
-
t.after(() => socket.destroy());
|
|
176
|
-
const handshake = await new Promise((resolve, reject) => {
|
|
177
|
-
let data = '';
|
|
178
|
-
socket.once('error', reject);
|
|
179
|
-
socket.on('data', chunk => {
|
|
180
|
-
data += chunk.toString();
|
|
181
|
-
if (data.includes('\r\n\r\n')) resolve(data);
|
|
182
|
-
});
|
|
183
|
-
socket.once('connect', () => socket.write('CONNECT sap.example:8443 HTTP/1.1\r\nHost: sap.example:8443\r\n\r\n'));
|
|
184
|
-
});
|
|
185
|
-
assert.match(handshake, /^HTTP\/1\.1 200 Connection Established/);
|
|
186
|
-
assert.deepEqual(observed.connect, [{
|
|
187
|
-
target: 'sap.example:8443',
|
|
188
|
-
authorization: 'Bearer user-exchange-token',
|
|
189
|
-
location: 'scc-west'
|
|
190
|
-
}]);
|
|
191
|
-
|
|
192
|
-
await route.close();
|
|
193
|
-
const failedRequests = [];
|
|
194
|
-
await assert.rejects(() => createCfConnectivityProxy({
|
|
195
|
-
destinationUrl: 'https://sap.example:8443',
|
|
196
|
-
connectivityCredentials: connectivityCredentials(btpPort),
|
|
197
|
-
authMode: 'principal-propagation',
|
|
198
|
-
userTokenProvider: async () => 'current-cf-user-jwt',
|
|
199
|
-
fetchImpl: async (_url, options) => {
|
|
200
|
-
failedRequests.push(new URLSearchParams(options.body));
|
|
201
|
-
return new Response(JSON.stringify({ error: 'jwt exchange rejected' }), { status: 401 });
|
|
202
|
-
}
|
|
203
|
-
}), /Connectivity token exchange was rejected/);
|
|
204
|
-
assert.equal(failedRequests.length, 1);
|
|
205
|
-
assert.equal(failedRequests[0].get('grant_type'), 'urn:ietf:params:oauth:grant-type:jwt-bearer');
|
|
206
|
-
assert.equal(failedRequests[0].has('assertion'), true);
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
function forwardHttpRequest(target, method, headers, response) {
|
|
210
|
-
const upstream = httpRequest({
|
|
211
|
-
hostname: target.hostname,
|
|
212
|
-
port: Number(target.port || 80),
|
|
213
|
-
method,
|
|
214
|
-
path: `${target.pathname}${target.search}`,
|
|
215
|
-
headers
|
|
216
|
-
}, upstreamResponse => {
|
|
217
|
-
response.writeHead(upstreamResponse.statusCode || 502, upstreamResponse.headers);
|
|
218
|
-
upstreamResponse.pipe(response);
|
|
219
|
-
});
|
|
220
|
-
upstream.on('error', () => {
|
|
221
|
-
if (!response.headersSent) response.writeHead(502);
|
|
222
|
-
response.end();
|
|
223
|
-
});
|
|
224
|
-
upstream.end();
|
|
225
|
-
}
|
|
226
|
-
function proxyRequest(proxyUrl, targetUrl, headers = {}, timeoutMs = 5_000) {
|
|
227
|
-
return new Promise((resolve, reject) => {
|
|
228
|
-
const proxy = new URL(proxyUrl);
|
|
229
|
-
const request = httpRequest({
|
|
230
|
-
hostname: proxy.hostname,
|
|
231
|
-
port: Number(proxy.port),
|
|
232
|
-
method: 'GET',
|
|
233
|
-
path: targetUrl,
|
|
234
|
-
headers: { host: new URL(targetUrl).host, ...headers }
|
|
235
|
-
}, response => {
|
|
236
|
-
let body = '';
|
|
237
|
-
response.setEncoding('utf8');
|
|
238
|
-
response.on('data', chunk => { body += chunk; });
|
|
239
|
-
response.on('end', () => resolve({ status: response.statusCode || 0, body }));
|
|
240
|
-
});
|
|
241
|
-
request.setTimeout(timeoutMs, () => request.destroy(new Error('proxy request timed out')));
|
|
242
|
-
request.on('error', reject);
|
|
243
|
-
request.end();
|
|
244
|
-
});
|
|
245
|
-
}
|
|
246
|
-
test('refreshes a PrincipalPropagation token 60 seconds before expiry with a fresh CF JWT', async () => {
|
|
247
|
-
const originalNow = Date.now;
|
|
248
|
-
let now = 1_000;
|
|
249
|
-
Date.now = () => now;
|
|
250
|
-
const proxyHeaders = [];
|
|
251
|
-
const btpProxy = trackSockets(createServer((request, response) => {
|
|
252
|
-
proxyHeaders.push(request.headers['proxy-authorization']);
|
|
253
|
-
response.writeHead(204);
|
|
254
|
-
response.end();
|
|
255
|
-
}));
|
|
256
|
-
let route;
|
|
257
|
-
try {
|
|
258
|
-
const port = await listen(btpProxy);
|
|
259
|
-
let userTokenCalls = 0;
|
|
260
|
-
const tokenRequests = [];
|
|
261
|
-
route = await createCfConnectivityProxy({
|
|
262
|
-
destinationUrl: 'http://sap.example:8080',
|
|
263
|
-
connectivityCredentials: connectivityCredentials(port),
|
|
264
|
-
authMode: 'principal-propagation',
|
|
265
|
-
userTokenProvider: async () => `current-user-jwt-${++userTokenCalls}`,
|
|
266
|
-
fetchImpl: async (_url, options) => {
|
|
267
|
-
tokenRequests.push(new URLSearchParams(options.body));
|
|
268
|
-
return responseToken(`user-token-${tokenRequests.length}`, 100);
|
|
269
|
-
}
|
|
270
|
-
});
|
|
271
|
-
assert.equal(tokenRequests.length, 1);
|
|
272
|
-
const first = await proxyRequest(route.url, 'http://sap.example:8080/first');
|
|
273
|
-
assert.equal(first.status, 204);
|
|
274
|
-
assert.equal(tokenRequests.length, 1);
|
|
275
|
-
now = 41_000;
|
|
276
|
-
const second = await proxyRequest(route.url, 'http://sap.example:8080/second');
|
|
277
|
-
assert.equal(second.status, 204);
|
|
278
|
-
assert.equal(tokenRequests.length, 2);
|
|
279
|
-
assert.equal(userTokenCalls, 2);
|
|
280
|
-
assert.deepEqual(proxyHeaders, ['Bearer user-token-1', 'Bearer user-token-2']);
|
|
281
|
-
assert.deepEqual(tokenRequests.map(body => body.get('assertion')), ['current-user-jwt-1', 'current-user-jwt-2']);
|
|
282
|
-
} finally {
|
|
283
|
-
Date.now = originalNow;
|
|
284
|
-
await route?.close();
|
|
285
|
-
await close(btpProxy);
|
|
286
|
-
}
|
|
287
|
-
});
|
|
@@ -1,307 +0,0 @@
|
|
|
1
|
-
import test from 'node:test';
|
|
2
|
-
import assert from 'node:assert/strict';
|
|
3
|
-
import { createServer } from 'node:http';
|
|
4
|
-
import { discoverCloudFoundryDestinations, getCloudFoundryTarget, resolveConfiguredCloudFoundryDestination } from '../src/cf-destination.mjs';
|
|
5
|
-
|
|
6
|
-
const SPACE_GUID = 'space-123';
|
|
7
|
-
const DESTINATION_GUID = 'destination-instance-123';
|
|
8
|
-
const DESTINATION_INSTANCE = 'destination-service';
|
|
9
|
-
|
|
10
|
-
async function listen(server) {
|
|
11
|
-
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
12
|
-
return `http://127.0.0.1:${server.address().port}`;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
function mockCf({ origin, instances = true, spaceGuid = SPACE_GUID } = {}) {
|
|
16
|
-
const calls = [];
|
|
17
|
-
const createdKeys = [];
|
|
18
|
-
const credentials = {
|
|
19
|
-
uri: origin,
|
|
20
|
-
url: `${origin}/uaa`,
|
|
21
|
-
clientid: 'destination-client',
|
|
22
|
-
clientsecret: 'destination-service-secret'
|
|
23
|
-
};
|
|
24
|
-
const execFileImpl = (_command, args, _options, callback) => {
|
|
25
|
-
calls.push([...args]);
|
|
26
|
-
let output = '';
|
|
27
|
-
const path = args[0] === 'curl' ? args[1] : '';
|
|
28
|
-
if (args[0] === 'version') output = 'cf version 8.18.0+1234567';
|
|
29
|
-
else if (args[0] === 'target') output = 'space: MASS\n';
|
|
30
|
-
else if (args[0] === 'space' && args[1] === 'MASS' && args[2] === '--guid') output = `${spaceGuid}\n`;
|
|
31
|
-
else if (args[0] === 'oauth-token') output = 'bearer cf-user-jwt';
|
|
32
|
-
else if (args[0] === 'curl' && path.startsWith('/v3/service_instances?')) {
|
|
33
|
-
output = JSON.stringify({ resources: instances ? [{
|
|
34
|
-
guid: DESTINATION_GUID,
|
|
35
|
-
name: DESTINATION_INSTANCE,
|
|
36
|
-
relationships: {
|
|
37
|
-
service_plan: { data: { guid: 'plan-guid' } },
|
|
38
|
-
space: { data: { guid: SPACE_GUID } }
|
|
39
|
-
}
|
|
40
|
-
}] : [] });
|
|
41
|
-
} else if (args[0] === 'curl' && path === `/v3/service_instances/${DESTINATION_GUID}`) {
|
|
42
|
-
output = JSON.stringify({
|
|
43
|
-
guid: DESTINATION_GUID,
|
|
44
|
-
name: DESTINATION_INSTANCE,
|
|
45
|
-
relationships: { space: { data: { guid: SPACE_GUID } } }
|
|
46
|
-
});
|
|
47
|
-
} else if (args[0] === 'curl' && path === '/v3/service_plans/plan-guid') {
|
|
48
|
-
output = JSON.stringify({ relationships: { service_offering: { data: { guid: 'offering-guid' } } } });
|
|
49
|
-
} else if (args[0] === 'curl' && path === '/v3/service_offerings/offering-guid') {
|
|
50
|
-
output = JSON.stringify({ name: 'Destination' });
|
|
51
|
-
} else if (args[0] === 'create-service-key') {
|
|
52
|
-
createdKeys.push({ instanceName: args[1], keyName: args[2] });
|
|
53
|
-
} else if (args[0] === 'service-key' && args[1] === '--json') {
|
|
54
|
-
output = JSON.stringify({ credentials: { ...credentials, uri: origin } });
|
|
55
|
-
} else {
|
|
56
|
-
callback(new Error('untrusted CLI stderr with secret=must-not-leak'), '', 'secret=must-not-leak');
|
|
57
|
-
return;
|
|
58
|
-
}
|
|
59
|
-
callback(null, output, '');
|
|
60
|
-
};
|
|
61
|
-
return { calls, createdKeys, credentials, execFileImpl };
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function destinationApi(records, detail) {
|
|
65
|
-
const observed = { tokenAuthorization: [], listAuthorization: [], adtAuthorization: [], detailRequests: [] };
|
|
66
|
-
const server = createServer((request, response) => {
|
|
67
|
-
if (request.url === '/uaa/oauth/token') {
|
|
68
|
-
observed.tokenAuthorization.push(request.headers.authorization);
|
|
69
|
-
response.writeHead(200, { 'content-type': 'application/json' });
|
|
70
|
-
response.end(JSON.stringify({ access_token: 'api-access-token', expires_in: 300 }));
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
73
|
-
if (request.url === '/destination-configuration/v1/instanceDestinations') {
|
|
74
|
-
observed.listAuthorization.push(request.headers.authorization);
|
|
75
|
-
response.writeHead(200, { 'content-type': 'application/json' });
|
|
76
|
-
response.end(JSON.stringify(records));
|
|
77
|
-
return;
|
|
78
|
-
}
|
|
79
|
-
if (request.url === `/destination-configuration/v1/instanceDestinations/${encodeURIComponent(detail?.name || '')}` && detail) {
|
|
80
|
-
observed.detailRequests.push(request.url);
|
|
81
|
-
response.writeHead(200, { 'content-type': 'application/json' });
|
|
82
|
-
response.end(JSON.stringify(detail.body));
|
|
83
|
-
return;
|
|
84
|
-
}
|
|
85
|
-
if (request.url === '/sap/bc/adt/discovery') {
|
|
86
|
-
observed.adtAuthorization.push(request.headers.authorization);
|
|
87
|
-
response.writeHead(401);
|
|
88
|
-
response.end();
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
response.writeHead(404);
|
|
92
|
-
response.end();
|
|
93
|
-
});
|
|
94
|
-
return { server, observed };
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const envForLocal = () => ({ PATH: process.env.PATH, HTTP_PROXY: '', HTTPS_PROXY: '', NO_PROXY: '127.0.0.1,localhost' });
|
|
98
|
-
|
|
99
|
-
test('imports instance Destination records with redacted descriptors and disables unsupported records', async t => {
|
|
100
|
-
let origin;
|
|
101
|
-
const records = [
|
|
102
|
-
{
|
|
103
|
-
Name: 'internet-basic',
|
|
104
|
-
Type: 'hTtP',
|
|
105
|
-
URL: 'http://127.0.0.1:1',
|
|
106
|
-
Authentication: 'bAsIcAuThEnTiCaTiOn',
|
|
107
|
-
ProxyType: 'iNtErNeT',
|
|
108
|
-
Properties: [
|
|
109
|
-
{ Key: 'sap-client', Value: '123' },
|
|
110
|
-
{ Key: 'User', Value: 'sap-user' },
|
|
111
|
-
{ Key: 'Password', Value: 'sap-password' }
|
|
112
|
-
]
|
|
113
|
-
},
|
|
114
|
-
{ Name: 'detail system' },
|
|
115
|
-
{ Name: 'not-http', Type: 'RFC', URL: 'http://127.0.0.1:1', Authentication: 'NoAuthentication', ProxyType: 'Internet' },
|
|
116
|
-
{ Name: 'userinfo-url', Type: 'HTTP', URL: 'http://user:password@127.0.0.1:1', Authentication: 'NoAuthentication', ProxyType: 'Internet' },
|
|
117
|
-
{ Name: 'incomplete-basic', Type: 'HTTP', URL: 'http://127.0.0.1:1', Authentication: 'BasicAuthentication', ProxyType: 'Internet', User: 'only-user' },
|
|
118
|
-
{ Name: '', Type: 'HTTP', URL: 'http://127.0.0.1:1', Authentication: 'NoAuthentication', ProxyType: 'Internet' }
|
|
119
|
-
];
|
|
120
|
-
const api = destinationApi(records, {
|
|
121
|
-
name: 'detail system',
|
|
122
|
-
body: { destinationConfiguration: {
|
|
123
|
-
Name: 'detail system', Type: 'HTTP', URL: 'http://127.0.0.1:1',
|
|
124
|
-
Authentication: 'NoAuthentication', ProxyType: 'Internet',
|
|
125
|
-
Properties: [{ Key: 'sap-client', Value: '250' }]
|
|
126
|
-
} }
|
|
127
|
-
});
|
|
128
|
-
origin = await listen(api.server);
|
|
129
|
-
// Point service-key credentials at the local Destination API while retaining a separate SAP target.
|
|
130
|
-
const targetServer = createServer((request, response) => {
|
|
131
|
-
if (request.url === '/sap/bc/adt/discovery') {
|
|
132
|
-
api.observed.adtAuthorization.push(request.headers.authorization);
|
|
133
|
-
response.writeHead(403);
|
|
134
|
-
response.end();
|
|
135
|
-
return;
|
|
136
|
-
}
|
|
137
|
-
response.writeHead(404);
|
|
138
|
-
response.end();
|
|
139
|
-
});
|
|
140
|
-
const targetOrigin = await listen(targetServer);
|
|
141
|
-
t.after(async () => {
|
|
142
|
-
await new Promise(resolve => api.server.close(resolve));
|
|
143
|
-
await new Promise(resolve => targetServer.close(resolve));
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
records[0].URL = targetOrigin;
|
|
147
|
-
records[1] = { Name: 'detail system' };
|
|
148
|
-
api.server.removeAllListeners('request');
|
|
149
|
-
api.server.on('request', (request, response) => {
|
|
150
|
-
if (request.url === '/uaa/oauth/token') {
|
|
151
|
-
api.observed.tokenAuthorization.push(request.headers.authorization);
|
|
152
|
-
response.writeHead(200, { 'content-type': 'application/json' });
|
|
153
|
-
response.end(JSON.stringify({ access_token: 'api-access-token', expires_in: 300 }));
|
|
154
|
-
} else if (request.url === '/destination-configuration/v1/instanceDestinations') {
|
|
155
|
-
api.observed.listAuthorization.push(request.headers.authorization);
|
|
156
|
-
response.writeHead(200, { 'content-type': 'application/json' });
|
|
157
|
-
response.end(JSON.stringify(records));
|
|
158
|
-
} else if (request.url === `/destination-configuration/v1/instanceDestinations/detail%20system`) {
|
|
159
|
-
api.observed.detailRequests.push(request.url);
|
|
160
|
-
response.writeHead(200, { 'content-type': 'application/json' });
|
|
161
|
-
response.end(JSON.stringify({ destinationConfiguration: {
|
|
162
|
-
Name: 'detail system', Type: 'HTTP', URL: targetOrigin,
|
|
163
|
-
Authentication: 'NoAuthentication', ProxyType: 'Internet',
|
|
164
|
-
Properties: [{ Key: 'sap-client', Value: '250' }]
|
|
165
|
-
} }));
|
|
166
|
-
} else {
|
|
167
|
-
response.writeHead(404);
|
|
168
|
-
response.end();
|
|
169
|
-
}
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
const cf = mockCf({ origin });
|
|
173
|
-
const result = await discoverCloudFoundryDestinations({
|
|
174
|
-
env: envForLocal(), spaceGuid: SPACE_GUID, execFileImpl: cf.execFileImpl,
|
|
175
|
-
fetchImpl: globalThis.fetch, input: process.stdin, output: process.stdout
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
assert.equal(cf.createdKeys.length, 1);
|
|
179
|
-
assert.match(cf.createdKeys[0].keyName, /^sap-ai-dev-toolkit-[0-9a-f-]{36}$/);
|
|
180
|
-
assert.deepEqual(result.createdKeys.map(({ kind, spaceGuid, instanceGuid, instanceName, keyName }) => [kind, spaceGuid, instanceGuid, instanceName, keyName]), [
|
|
181
|
-
['destination', SPACE_GUID, DESTINATION_GUID, DESTINATION_INSTANCE, cf.createdKeys[0].keyName]
|
|
182
|
-
]);
|
|
183
|
-
const imported = result.destinations.find(item => item.name === 'internet-basic');
|
|
184
|
-
assert.equal(imported.source, 'cloud-foundry');
|
|
185
|
-
assert.equal(imported.serverName, `cf:${SPACE_GUID}:${DESTINATION_GUID}:internet-basic`);
|
|
186
|
-
assert.equal(imported.client, '123');
|
|
187
|
-
assert.equal(imported.authentication, 'BasicAuthentication');
|
|
188
|
-
assert.equal(imported.proxyType, 'Internet');
|
|
189
|
-
assert.equal(imported.probe.status, 'auth-required');
|
|
190
|
-
assert.deepEqual(imported.cf, {
|
|
191
|
-
spaceGuid: SPACE_GUID,
|
|
192
|
-
destinationInstanceGuid: DESTINATION_GUID,
|
|
193
|
-
destinationInstanceName: DESTINATION_INSTANCE,
|
|
194
|
-
destinationKeyName: cf.createdKeys[0].keyName
|
|
195
|
-
});
|
|
196
|
-
assert.equal(result.destinations.find(item => item.name === 'detail system').client, '250');
|
|
197
|
-
assert.equal(api.observed.detailRequests.length, 1);
|
|
198
|
-
assert.match(result.destinations.find(item => item.name === 'not-http').disabledReason, /Type must be HTTP/);
|
|
199
|
-
assert.match(result.destinations.find(item => item.name === 'userinfo-url').disabledReason, /embedded user information/);
|
|
200
|
-
assert.match(result.destinations.find(item => item.name === 'incomplete-basic').disabledReason, /requires both User and Password/);
|
|
201
|
-
assert.ok(result.warnings.some(warning => /Skipped 1 unnamed destination record/.test(warning)));
|
|
202
|
-
assert.deepEqual(api.observed.tokenAuthorization, [`Basic ${Buffer.from('destination-client:destination-service-secret').toString('base64')}`]);
|
|
203
|
-
assert.deepEqual(api.observed.listAuthorization, ['Bearer api-access-token']);
|
|
204
|
-
assert.ok(api.observed.adtAuthorization.includes(`Basic ${Buffer.from('sap-user:sap-password').toString('base64')}`));
|
|
205
|
-
const safe = JSON.stringify({ destinations: result.destinations, createdKeys: result.createdKeys, warnings: result.warnings });
|
|
206
|
-
for (const secret of ['destination-service-secret', 'api-access-token', 'sap-password', 'sap-user', 'http://127.0.0.1']) assert.equal(safe.includes(secret), false);
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
test('probes HTTP destinations through forward proxies without CONNECT', async t => {
|
|
210
|
-
const api = destinationApi([{
|
|
211
|
-
Name: 'proxied-http',
|
|
212
|
-
Type: 'HTTP',
|
|
213
|
-
URL: 'http://abap.virtual:1443',
|
|
214
|
-
Authentication: 'NoAuthentication',
|
|
215
|
-
ProxyType: 'Internet'
|
|
216
|
-
}]);
|
|
217
|
-
const apiOrigin = await listen(api.server);
|
|
218
|
-
const proxyRequests = [];
|
|
219
|
-
const proxyServer = createServer((request, response) => {
|
|
220
|
-
proxyRequests.push({ method: request.method, url: request.url, host: request.headers.host });
|
|
221
|
-
response.writeHead(401);
|
|
222
|
-
response.end();
|
|
223
|
-
});
|
|
224
|
-
proxyServer.on('connect', (request, socket) => {
|
|
225
|
-
proxyRequests.push({ method: 'CONNECT', url: request.url, host: request.headers.host });
|
|
226
|
-
socket.end('HTTP/1.1 502 Bad Gateway\r\n\r\n');
|
|
227
|
-
});
|
|
228
|
-
const proxyOrigin = await listen(proxyServer);
|
|
229
|
-
t.after(async () => {
|
|
230
|
-
await new Promise(resolve => api.server.close(resolve));
|
|
231
|
-
await new Promise(resolve => proxyServer.close(resolve));
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
const cf = mockCf({ origin: apiOrigin });
|
|
235
|
-
const result = await discoverCloudFoundryDestinations({
|
|
236
|
-
env: { ...envForLocal(), HTTP_PROXY: proxyOrigin },
|
|
237
|
-
spaceGuid: SPACE_GUID,
|
|
238
|
-
execFileImpl: cf.execFileImpl,
|
|
239
|
-
fetchImpl: globalThis.fetch,
|
|
240
|
-
input: process.stdin,
|
|
241
|
-
output: process.stdout
|
|
242
|
-
});
|
|
243
|
-
|
|
244
|
-
assert.deepEqual(proxyRequests, [{
|
|
245
|
-
method: 'GET',
|
|
246
|
-
url: 'http://abap.virtual:1443/sap/bc/adt/discovery',
|
|
247
|
-
host: 'abap.virtual:1443'
|
|
248
|
-
}]);
|
|
249
|
-
assert.equal(result.destinations[0].probe.status, 'auth-required');
|
|
250
|
-
});
|
|
251
|
-
|
|
252
|
-
test('skips CF import without creating a key when no Destination service exists', async () => {
|
|
253
|
-
const cf = mockCf({ instances: false, origin: 'http://127.0.0.1:1' });
|
|
254
|
-
const result = await discoverCloudFoundryDestinations({
|
|
255
|
-
env: envForLocal(), spaceGuid: SPACE_GUID, execFileImpl: cf.execFileImpl,
|
|
256
|
-
input: process.stdin, output: process.stdout
|
|
257
|
-
});
|
|
258
|
-
assert.deepEqual(result.destinations, []);
|
|
259
|
-
assert.deepEqual(result.createdKeys, []);
|
|
260
|
-
assert.equal(cf.createdKeys.length, 0);
|
|
261
|
-
assert.ok(result.warnings.some(warning => /No Destination service instance/.test(warning)));
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
test('resolves a CF Basic destination only after matching the active space', async t => {
|
|
265
|
-
const records = [{
|
|
266
|
-
Name: 'runtime-basic', Type: 'HTTP', URL: 'http://sap.example',
|
|
267
|
-
Authentication: 'BasicAuthentication', ProxyType: 'Internet',
|
|
268
|
-
User: 'runtime-user', Password: 'runtime-password', 'sap-client': '007'
|
|
269
|
-
}];
|
|
270
|
-
const api = destinationApi(records);
|
|
271
|
-
const origin = await listen(api.server);
|
|
272
|
-
t.after(() => new Promise(resolve => api.server.close(resolve)));
|
|
273
|
-
const cf = mockCf({ origin });
|
|
274
|
-
const env = {
|
|
275
|
-
...envForLocal(),
|
|
276
|
-
SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE: 'cloud-foundry',
|
|
277
|
-
SAP_AI_DEV_TOOLKIT_DESTINATION: 'runtime-basic',
|
|
278
|
-
BAS_CF_SPACE_GUID: SPACE_GUID,
|
|
279
|
-
BAS_CF_DESTINATION_INSTANCE_GUID: DESTINATION_GUID,
|
|
280
|
-
BAS_CF_DESTINATION_INSTANCE: DESTINATION_INSTANCE,
|
|
281
|
-
BAS_CF_DESTINATION_KEY: 'managed-destination-key',
|
|
282
|
-
BAS_CF_DESTINATION_NAME: 'runtime-basic'
|
|
283
|
-
};
|
|
284
|
-
const runtime = await resolveConfiguredCloudFoundryDestination({ env, execFileImpl: cf.execFileImpl });
|
|
285
|
-
assert.equal(runtime.source, 'cloud-foundry');
|
|
286
|
-
assert.equal(runtime.name, 'runtime-basic');
|
|
287
|
-
assert.equal(runtime.serverName, `cf:${SPACE_GUID}:${DESTINATION_GUID}:runtime-basic`);
|
|
288
|
-
assert.equal(runtime.client, '007');
|
|
289
|
-
assert.deepEqual(runtime.childEnv, { SAP_USER: 'runtime-user', SAP_PASSWORD: 'runtime-password', SAP_VERBOSE: 'false' });
|
|
290
|
-
assert.equal(cf.calls.some(args => args[0] === 'service-key' && args.at(-1) === 'managed-destination-key'), true);
|
|
291
|
-
await assert.rejects(
|
|
292
|
-
() => resolveConfiguredCloudFoundryDestination({ env: { ...env, BAS_CF_SPACE_GUID: 'different-space' }, execFileImpl: cf.execFileImpl }),
|
|
293
|
-
/does not match configured space different-space/
|
|
294
|
-
);
|
|
295
|
-
assert.equal(JSON.stringify(cf.calls).includes('runtime-password'), false);
|
|
296
|
-
assert.equal(JSON.stringify(runtime.childEnv).includes('destination-service-secret'), false);
|
|
297
|
-
});
|
|
298
|
-
|
|
299
|
-
test('requires cf CLI 8.18 and a targeted space before prompting', async () => {
|
|
300
|
-
const oldCli = (_command, args, _options, callback) => {
|
|
301
|
-
callback(null, args[0] === 'version' ? 'cf version 8.17.1' : '', '');
|
|
302
|
-
};
|
|
303
|
-
assert.deepEqual(await getCloudFoundryTarget({ env: {}, execFileImpl: oldCli }), {
|
|
304
|
-
available: false,
|
|
305
|
-
reason: 'Cloud Foundry CLI 8.18 or newer is required; CF destinations were skipped.'
|
|
306
|
-
});
|
|
307
|
-
});
|