sap-ai-dev-toolkit 0.5.5 → 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 +6 -6
- package/inventory.md +179 -0
- package/package.json +2 -2
- package/scripts/build-vsp.mjs +2 -1
- package/scripts/postinstall.mjs +14 -16
- package/src/bas-destination-relay.mjs +25 -3
- package/src/bas-discovery.mjs +9 -3
- 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 +8 -11
- package/src/mcp-config.mjs +19 -1
- package/src/mcp-proxy.mjs +230 -76
- package/src/redact.mjs +28 -0
- package/src/setup.mjs +30 -1
- package/tools.md +1 -1
- package/test/bas-destination-relay.test.mjs +0 -415
- 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 -282
- package/test/fixtures/fake-vsp.mjs +0 -266
- 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 -401
- package/test/live-s4h.test.mjs +0 -204
- package/test/mcp-config-cf.test.mjs +0 -475
- package/test/mcp-proxy.test.mjs +0 -604
- 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
package/src/setup.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { createInterface } from 'node:readline/promises';
|
|
|
2
2
|
import { stdin, stdout } from 'node:process';
|
|
3
3
|
import { checkboxPrompt, colorText, formatStatus, textPrompt } from './terminal-ui.mjs';
|
|
4
4
|
import { discoverDestinations, remediation } from './bas-discovery.mjs';
|
|
5
|
-
import { discoverCloudFoundryDestinations, getCloudFoundryTarget, deleteManagedCloudFoundryServiceKeys } from './cf-destination.mjs';
|
|
5
|
+
import { discoverCloudFoundryDestinations, getCloudFoundryTarget, deleteManagedCloudFoundryServiceKeys, findOrphanedCloudFoundryServiceKeys } from './cf-destination.mjs';
|
|
6
6
|
import { SAP_DEVELOPMENT_MCP_SERVERS, collectCloudFoundryKeyReferencesFromAllEntries, collectManagedCloudFoundryKeyReferences, installMcpConfig, readMcpConfig, resolveMcpConfigPath } from './mcp-config.mjs';
|
|
7
7
|
import { readCredentials, removeDestinationCredentials, resolveCredentialsPath, storeDestinationCredentials } from './credentials-store.mjs';
|
|
8
8
|
import { canPromptForCredentials, credentialKeyForDestination, credentialModeForDestination } from './credential-overrides.mjs';
|
|
@@ -224,6 +224,35 @@ export async function runSetup({
|
|
|
224
224
|
if (!target.available) {
|
|
225
225
|
warnings.push(target.reason);
|
|
226
226
|
} else {
|
|
227
|
+
// Sweep for service keys this toolkit created but no config references
|
|
228
|
+
// (an interrupted earlier setup leaves them behind; a Destination key
|
|
229
|
+
// exposes credentials for every destination in its instance).
|
|
230
|
+
try {
|
|
231
|
+
const orphans = await findOrphanedCloudFoundryServiceKeys({ env, spaceGuid: target.spaceGuid, managedKeys });
|
|
232
|
+
if (orphans.length) {
|
|
233
|
+
print(output, '');
|
|
234
|
+
print(output, formatStatus(`Found ${orphans.length} unreferenced sap-ai-dev-toolkit service key${orphans.length === 1 ? '' : 's'} in this CF space:`, 'warning', output, 'Cloud Foundry'));
|
|
235
|
+
for (const orphan of orphans.slice(0, 5)) print(output, ` • ${orphan.keyName} on ${orphan.instanceName}`);
|
|
236
|
+
const readline = createInterface({ input, output });
|
|
237
|
+
let removeOrphans = false;
|
|
238
|
+
try {
|
|
239
|
+
const answer = await readline.question('Delete these unreferenced service keys? (y + Enter = delete; Enter = keep) ');
|
|
240
|
+
removeOrphans = /^y(?:es)?$/i.test(answer.trim());
|
|
241
|
+
} finally {
|
|
242
|
+
readline.close();
|
|
243
|
+
}
|
|
244
|
+
if (removeOrphans) {
|
|
245
|
+
const result = await deleteManagedCloudFoundryServiceKeys({ env, keys: orphans });
|
|
246
|
+
warnings.push(...(result.warnings || []));
|
|
247
|
+
const deleted = orphans.length - (result.warnings || []).length;
|
|
248
|
+
print(output, formatStatus(`Removed ${deleted} unreferenced service key${deleted === 1 ? '' : 's'}.`, 'success', output, 'Cloud Foundry'));
|
|
249
|
+
} else {
|
|
250
|
+
warnings.push(`${orphans.length} unreferenced sap-ai-dev-toolkit service key${orphans.length === 1 ? '' : 's'} were kept; review them with "cf service-keys" and delete manually if unwanted.`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
} catch {
|
|
254
|
+
// The sweep is best-effort; a failed lookup must not block setup.
|
|
255
|
+
}
|
|
227
256
|
let shouldImport = false;
|
|
228
257
|
try {
|
|
229
258
|
shouldImport = await confirmCfImport({ input, output });
|
package/tools.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
The proxy no longer advertises every VSP tool. To keep the developer-lifecycle MCP surface manageable, each generated one-destination server exposes a cherry-picked set of VSP tools plus local workflow tools (currently 59 tools when all curated VSP capabilities are registered).
|
|
4
4
|
|
|
5
|
-
The names below are the logical tool names. Publicly, every tool is exposed as
|
|
5
|
+
The names below are the logical tool names. Publicly, every tool is exposed as `<destination-slug>_<tool>` in lowercase (for example `GetTableContents` on destination `DEMO_ABAP` is `demo-abap_get_table_contents`): BAS/VS Code chat tool references bind only lowercase identifiers, so mixed-case names never reach the model.
|
|
6
6
|
|
|
7
7
|
Hidden upstream VSP tools are not directly callable through the proxy. Local workflow tools may still call hidden or non-advertised upstream operations internally when they are required for a bounded workflow.
|
|
8
8
|
|
|
@@ -1,415 +0,0 @@
|
|
|
1
|
-
import test from 'node:test';
|
|
2
|
-
import assert from 'node:assert/strict';
|
|
3
|
-
import http from 'node:http';
|
|
4
|
-
import net from 'node:net';
|
|
5
|
-
import { request as httpRequest } from 'node:http';
|
|
6
|
-
import { createBasDestinationRelay } from '../src/bas-destination-relay.mjs';
|
|
7
|
-
|
|
8
|
-
const UNSAFE_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE'];
|
|
9
|
-
|
|
10
|
-
// Simulates SAP ADT semantics: a CSRF token is only valid when the unsafe
|
|
11
|
-
// request carries BOTH the token and the session cookies that were set
|
|
12
|
-
// together with it. This reproduces the 403 "CSRF token validation failed"
|
|
13
|
-
// seen on the BAS .dest proxy path before the relay paired sessions.
|
|
14
|
-
function sapLikeBackend({
|
|
15
|
-
neverAcceptUnsafe = false,
|
|
16
|
-
rotateOnFetch = false,
|
|
17
|
-
genericForbidden = false,
|
|
18
|
-
partialRefreshCookies = false,
|
|
19
|
-
omitCookies = false,
|
|
20
|
-
setCookieOnSafeGet = false,
|
|
21
|
-
secureSessionCookie = false,
|
|
22
|
-
postOnlyPaths = [],
|
|
23
|
-
acceptTokenWithoutCookies = false
|
|
24
|
-
} = {}) {
|
|
25
|
-
const state = { sessionCounter: 0, requests: [] };
|
|
26
|
-
const server = http.createServer((req, res) => {
|
|
27
|
-
state.requests.push({ method: req.method, path: req.url, headers: { ...req.headers } });
|
|
28
|
-
// POST-only services (ADT data preview among them) reject every GET,
|
|
29
|
-
// including CSRF token fetches, the way real SAP does.
|
|
30
|
-
if (req.method === 'GET' && postOnlyPaths.some(prefix => req.url.startsWith(prefix))) {
|
|
31
|
-
res.writeHead(405, { allow: 'POST' });
|
|
32
|
-
res.end('<method-not-allowed/>');
|
|
33
|
-
return;
|
|
34
|
-
}
|
|
35
|
-
if (req.method === 'GET' && req.headers['x-csrf-token'] === 'Fetch') {
|
|
36
|
-
state.sessionCounter += 1;
|
|
37
|
-
const headers = { 'x-csrf-token': `token-${state.sessionCounter}` };
|
|
38
|
-
if (!omitCookies) {
|
|
39
|
-
headers['set-cookie'] = [`sap-session-${state.sessionCounter}=s${state.sessionCounter}; path=/`];
|
|
40
|
-
if (!partialRefreshCookies || state.sessionCounter === 1) {
|
|
41
|
-
headers['set-cookie'].push('sap-usercontext=sap-client=100; path=/');
|
|
42
|
-
}
|
|
43
|
-
if (partialRefreshCookies) {
|
|
44
|
-
headers['set-cookie'].push(state.sessionCounter === 1
|
|
45
|
-
? 'sap-stale=delete-me; path=/'
|
|
46
|
-
: 'sap-stale=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/');
|
|
47
|
-
}
|
|
48
|
-
if (secureSessionCookie) headers['set-cookie'].push('sap-secure=secure-value; Secure; Path=/');
|
|
49
|
-
}
|
|
50
|
-
res.writeHead(200, headers);
|
|
51
|
-
res.end('<ok/>');
|
|
52
|
-
return;
|
|
53
|
-
}
|
|
54
|
-
if (req.method === 'GET' && req.url === '/sap/bc/adt/discovery' && setCookieOnSafeGet) {
|
|
55
|
-
res.writeHead(200, { 'set-cookie': 'bas-read-session=kept; path=/sap/bc/adt' });
|
|
56
|
-
res.end('<read/>');
|
|
57
|
-
return;
|
|
58
|
-
}
|
|
59
|
-
if (UNSAFE_METHODS.includes(req.method)) {
|
|
60
|
-
if (genericForbidden) {
|
|
61
|
-
res.writeHead(403);
|
|
62
|
-
res.end('User is not authorized to change this object');
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
|
-
if (neverAcceptUnsafe) {
|
|
66
|
-
res.writeHead(403, { 'x-csrf-token': 'Required' });
|
|
67
|
-
res.end('CSRF token validation failed');
|
|
68
|
-
return;
|
|
69
|
-
}
|
|
70
|
-
const token = req.headers['x-csrf-token'];
|
|
71
|
-
const cookie = String(req.headers.cookie || '');
|
|
72
|
-
const match = String(token || '').match(/^token-(\d+)$/);
|
|
73
|
-
// Without rotation any still-live token+cookie pair is accepted; with
|
|
74
|
-
// rotation only the newest issued session remains valid, matching SAP
|
|
75
|
-
// server-side session invalidation. acceptTokenWithoutCookies models a
|
|
76
|
-
// BAS proxy route where Set-Cookie is stripped yet the backend honors
|
|
77
|
-
// the token on the proxy-established Basic-auth session.
|
|
78
|
-
const valid = acceptTokenWithoutCookies
|
|
79
|
-
? Boolean(match)
|
|
80
|
-
: match
|
|
81
|
-
&& cookie.includes(`sap-session-${match[1]}=`)
|
|
82
|
-
&& (!rotateOnFetch || Number(match[1]) === state.sessionCounter)
|
|
83
|
-
&& (!partialRefreshCookies || cookie.includes('sap-usercontext=sap-client=100'))
|
|
84
|
-
&& (!partialRefreshCookies || Number(match[1]) === 1 || !cookie.includes('sap-stale='));
|
|
85
|
-
if (!valid) {
|
|
86
|
-
res.writeHead(403, { 'x-csrf-token': 'Required' });
|
|
87
|
-
res.end('CSRF token validation failed');
|
|
88
|
-
return;
|
|
89
|
-
}
|
|
90
|
-
res.writeHead(201);
|
|
91
|
-
res.end('<created/>');
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
res.writeHead(200);
|
|
95
|
-
res.end('<read/>');
|
|
96
|
-
});
|
|
97
|
-
return { state, server };
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
async function startServer(server) {
|
|
101
|
-
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
102
|
-
return server.address().port;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function relayFetch(relayUrl, path, options = {}) {
|
|
106
|
-
const target = new URL(path, relayUrl);
|
|
107
|
-
return new Promise((resolve, reject) => {
|
|
108
|
-
const req = httpRequest(target, { method: options.method || 'GET', headers: options.headers || {} }, response => {
|
|
109
|
-
let body = '';
|
|
110
|
-
response.setEncoding('utf8');
|
|
111
|
-
response.on('data', chunk => { body += chunk; });
|
|
112
|
-
response.on('end', () => resolve({ status: response.statusCode, headers: response.headers, body }));
|
|
113
|
-
});
|
|
114
|
-
req.on('error', reject);
|
|
115
|
-
if (options.body) req.write(options.body);
|
|
116
|
-
req.end();
|
|
117
|
-
});
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
async function withBackend(options, handler) {
|
|
121
|
-
const { state, server } = sapLikeBackend(options || {});
|
|
122
|
-
const backendPort = await startServer(server);
|
|
123
|
-
const logs = [];
|
|
124
|
-
const relay = createBasDestinationRelay(
|
|
125
|
-
{ name: 'TEST', url: `http://127.0.0.1:${backendPort}`, authentication: 'BasicAuthentication' },
|
|
126
|
-
{
|
|
127
|
-
env: { HTTP_PROXY: '', ...(options?.env || {}) },
|
|
128
|
-
fetchImpl: options?.fetchImpl || ((url, init) => globalThis.fetch(url, init)),
|
|
129
|
-
log: message => logs.push(message)
|
|
130
|
-
}
|
|
131
|
-
);
|
|
132
|
-
const relayUrl = await relay.ready;
|
|
133
|
-
try {
|
|
134
|
-
return await handler({ state, relay, relayUrl, logs });
|
|
135
|
-
} finally {
|
|
136
|
-
await relay.close();
|
|
137
|
-
await new Promise(resolve => server.close(resolve));
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
test('pairs CSRF token with its session cookies so unsafe requests succeed', async () => {
|
|
142
|
-
await withBackend(null, async ({ relayUrl, state }) => {
|
|
143
|
-
const response = await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', headers: { accept: 'application/xml' }, body: '<x/>' });
|
|
144
|
-
assert.equal(response.status, 201, `expected 201, got ${response.status}: ${response.body}`);
|
|
145
|
-
const post = state.requests.find(request => request.method === 'POST');
|
|
146
|
-
assert.match(post.headers['x-csrf-token'], /^token-\d+$/);
|
|
147
|
-
assert.match(String(post.headers.cookie), /sap-session-\d+=/);
|
|
148
|
-
});
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
test('fetches the CSRF token from a GET-capable endpoint when the target is POST-only (ADT data preview)', async () => {
|
|
152
|
-
await withBackend({ postOnlyPaths: ['/sap/bc/adt/datapreview'] }, async ({ relayUrl, state }) => {
|
|
153
|
-
const response = await relayFetch(relayUrl, '/sap/bc/adt/datapreview/freestyle', {
|
|
154
|
-
method: 'POST',
|
|
155
|
-
headers: { accept: 'application/xml' },
|
|
156
|
-
body: '<query/>'
|
|
157
|
-
});
|
|
158
|
-
assert.equal(response.status, 201, `expected 201, got ${response.status}: ${response.body}`);
|
|
159
|
-
const tokenFetch = state.requests.find(request => request.headers['x-csrf-token'] === 'Fetch');
|
|
160
|
-
assert.equal(tokenFetch.path, '/sap/bc/adt/discovery', 'token must be fetched from the GET-capable discovery endpoint');
|
|
161
|
-
const post = state.requests.find(request => request.method === 'POST');
|
|
162
|
-
assert.match(post.headers['x-csrf-token'], /^token-\d+$/);
|
|
163
|
-
assert.match(String(post.headers.cookie), /sap-session-\d+=/);
|
|
164
|
-
});
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
test('reuses the cached CSRF session for subsequent unsafe requests', async () => {
|
|
168
|
-
await withBackend(null, async ({ relay, relayUrl, state }) => {
|
|
169
|
-
await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
|
|
170
|
-
await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<y/>' });
|
|
171
|
-
const fetches = state.requests.filter(request => request.headers['x-csrf-token'] === 'Fetch');
|
|
172
|
-
assert.equal(fetches.length, 1, 'second POST must reuse the cached session');
|
|
173
|
-
assert.equal(state.requests.filter(request => request.method === 'POST').length, 2);
|
|
174
|
-
assert.equal(relay.stats.csrfFetches, 1);
|
|
175
|
-
assert.equal(relay.stats.csrfRetries, 0);
|
|
176
|
-
});
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
test('coalesces concurrent CSRF token fetches for the same ADT endpoint', async () => {
|
|
180
|
-
await withBackend(null, async ({ relayUrl, state }) => {
|
|
181
|
-
const responses = await Promise.all([
|
|
182
|
-
relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' }),
|
|
183
|
-
relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<y/>' })
|
|
184
|
-
]);
|
|
185
|
-
assert.deepEqual(responses.map(response => response.status), [201, 201]);
|
|
186
|
-
assert.equal(state.requests.filter(request => request.headers['x-csrf-token'] === 'Fetch').length, 1);
|
|
187
|
-
assert.equal(state.requests.filter(request => request.method === 'POST').length, 2);
|
|
188
|
-
});
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
test('seeds the relay cookie jar from the incoming MCP request cookie', async () => {
|
|
192
|
-
await withBackend(null, async ({ relayUrl, state }) => {
|
|
193
|
-
await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', {
|
|
194
|
-
method: 'POST',
|
|
195
|
-
headers: { cookie: 'sap-initial-session=from-vsp' },
|
|
196
|
-
body: '<x/>'
|
|
197
|
-
});
|
|
198
|
-
const tokenFetch = state.requests.find(request => request.headers['x-csrf-token'] === 'Fetch');
|
|
199
|
-
assert.match(String(tokenFetch.headers.cookie), /sap-initial-session=from-vsp/);
|
|
200
|
-
});
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
test('self-heals when SAP rotates the session: refetches token and cookies', async () => {
|
|
204
|
-
await withBackend({ rotateOnFetch: true }, async ({ relay, relayUrl, state }) => {
|
|
205
|
-
// A first POST establishes session token-1. SAP then invalidates it
|
|
206
|
-
// server-side (simulated by the rotation rule below).
|
|
207
|
-
await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
|
|
208
|
-
state.sessionCounter += 1;
|
|
209
|
-
const response = await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<y/>' });
|
|
210
|
-
assert.equal(response.status, 201, `rotated session must heal via refetch, got ${response.status}`);
|
|
211
|
-
assert.ok(relay.stats.csrfRetries >= 1, 'a retry must have been counted');
|
|
212
|
-
const fetches = state.requests.filter(request => request.headers['x-csrf-token'] === 'Fetch');
|
|
213
|
-
assert.ok(fetches.length >= 2, 'a fresh token fetch must have occurred');
|
|
214
|
-
});
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
test('merges partial session-cookie updates and applies cookie deletion on refresh', async () => {
|
|
218
|
-
await withBackend({ rotateOnFetch: true, partialRefreshCookies: true }, async ({ relayUrl, state }) => {
|
|
219
|
-
await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
|
|
220
|
-
state.sessionCounter += 1;
|
|
221
|
-
const response = await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<y/>' });
|
|
222
|
-
assert.equal(response.status, 201, `partial cookie refresh must heal, got ${response.status}`);
|
|
223
|
-
const posts = state.requests.filter(request => request.method === 'POST');
|
|
224
|
-
assert.match(String(posts[2].headers.cookie), /sap-usercontext=sap-client=100/);
|
|
225
|
-
assert.doesNotMatch(String(posts[2].headers.cookie), /sap-stale=/);
|
|
226
|
-
});
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
test('retains cookies received on a safe GET for later CSRF requests', async () => {
|
|
230
|
-
await withBackend({ setCookieOnSafeGet: true }, async ({ relayUrl, state }) => {
|
|
231
|
-
await relayFetch(relayUrl, '/sap/bc/adt/discovery');
|
|
232
|
-
const response = await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
|
|
233
|
-
assert.equal(response.status, 201);
|
|
234
|
-
const tokenFetch = state.requests.find(request => request.headers['x-csrf-token'] === 'Fetch');
|
|
235
|
-
assert.match(String(tokenFetch.headers.cookie), /bas-read-session=kept/);
|
|
236
|
-
});
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
test('does not retry generic authorization failures as CSRF errors', async () => {
|
|
240
|
-
await withBackend({ genericForbidden: true }, async ({ relayUrl, relay, state }) => {
|
|
241
|
-
const response = await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
|
|
242
|
-
assert.equal(response.status, 403);
|
|
243
|
-
assert.equal(state.requests.filter(request => request.method === 'POST').length, 1);
|
|
244
|
-
assert.equal(relay.stats.csrfRetries, 0);
|
|
245
|
-
});
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
test('sends a cookie-less token once and surfaces the rejection without retrying', async () => {
|
|
249
|
-
await withBackend({ omitCookies: true }, async ({ relayUrl, relay, state }) => {
|
|
250
|
-
const response = await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
|
|
251
|
-
assert.equal(response.status, 403, `a cookie-less session must surface SAP's verdict, got ${response.status}`);
|
|
252
|
-
assert.equal(state.requests.filter(request => request.method === 'POST').length, 1);
|
|
253
|
-
assert.equal(relay.stats.csrfRetries, 0, 'a cookie-less route cannot be fixed by a retry');
|
|
254
|
-
});
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
test('completes unsafe requests with a token-only session when the route strips cookies (BAS proxy)', async () => {
|
|
258
|
-
await withBackend({ omitCookies: true, acceptTokenWithoutCookies: true }, async ({ relayUrl, relay, state }) => {
|
|
259
|
-
const response = await relayFetch(relayUrl, '/sap/bc/adt/datapreview/freestyle', { method: 'POST', body: '<query/>' });
|
|
260
|
-
assert.equal(response.status, 201, `expected 201, got ${response.status}: ${response.body}`);
|
|
261
|
-
const post = state.requests.find(request => request.method === 'POST');
|
|
262
|
-
assert.match(post.headers['x-csrf-token'], /^token-\d+$/);
|
|
263
|
-
assert.equal(relay.stats.csrfSessionFailures, 0);
|
|
264
|
-
});
|
|
265
|
-
});
|
|
266
|
-
|
|
267
|
-
test('stops retrying after bounded attempts and surfaces the failure', async () => {
|
|
268
|
-
await withBackend({ neverAcceptUnsafe: true }, async ({ relay, relayUrl, state }) => {
|
|
269
|
-
const response = await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
|
|
270
|
-
assert.equal(response.status, 403);
|
|
271
|
-
const attempts = state.requests.filter(request => UNSAFE_METHODS.includes(request.method));
|
|
272
|
-
assert.equal(attempts.length, 1 + 3, 'one initial try plus three bounded retries');
|
|
273
|
-
assert.equal(relay.stats.csrfRetries, 3);
|
|
274
|
-
});
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
test('honors the configured bounded CSRF retry count', async () => {
|
|
278
|
-
await withBackend({ neverAcceptUnsafe: true, env: { SAP_AI_DEV_TOOLKIT_MAX_CSRF_RETRIES: '1' } }, async ({ relayUrl, relay, state }) => {
|
|
279
|
-
const response = await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
|
|
280
|
-
assert.equal(response.status, 403);
|
|
281
|
-
assert.equal(state.requests.filter(request => request.method === 'POST').length, 1 + 1);
|
|
282
|
-
assert.equal(relay.stats.csrfRetries, 1);
|
|
283
|
-
});
|
|
284
|
-
});
|
|
285
|
-
|
|
286
|
-
test('uses the default for invalid retry settings and caps excessive retry counts', async () => {
|
|
287
|
-
await withBackend({ neverAcceptUnsafe: true, env: { SAP_AI_DEV_TOOLKIT_MAX_CSRF_RETRIES: '1.5' } }, async ({ relayUrl, state }) => {
|
|
288
|
-
await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
|
|
289
|
-
assert.equal(state.requests.filter(request => request.method === 'POST').length, 4, 'invalid values use the default of three retries');
|
|
290
|
-
});
|
|
291
|
-
await withBackend({ neverAcceptUnsafe: true, env: { SAP_AI_DEV_TOOLKIT_MAX_CSRF_RETRIES: '100' } }, async ({ relayUrl, state }) => {
|
|
292
|
-
await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
|
|
293
|
-
assert.equal(state.requests.filter(request => request.method === 'POST').length, 11, 'retry counts are capped at ten');
|
|
294
|
-
});
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
test('prefers the documented branded proxy setting including an explicit direct-connection override', async t => {
|
|
298
|
-
let dispatchedWithProxy = false;
|
|
299
|
-
const relay = createBasDestinationRelay(
|
|
300
|
-
{ name: 'TEST', url: 'http://test.dest', authentication: 'BasicAuthentication' },
|
|
301
|
-
{
|
|
302
|
-
env: { HTTP_PROXY: 'http://127.0.0.1:1', SAP_AI_DEV_TOOLKIT_HTTP_PROXY: '' },
|
|
303
|
-
fetchImpl: async (_url, options = {}) => {
|
|
304
|
-
dispatchedWithProxy = Boolean(options.dispatcher);
|
|
305
|
-
return new Response('<ok/>', { status: 200, headers: { 'x-csrf-token': 'token', 'set-cookie': 'sap-session=s; path=/' } });
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
);
|
|
309
|
-
t.after(() => relay.close());
|
|
310
|
-
await relay.ready;
|
|
311
|
-
const probe = await relay.probeCsrfSession();
|
|
312
|
-
assert.equal(probe.sessionUsable, true);
|
|
313
|
-
assert.equal(dispatchedWithProxy, false, 'an explicit empty branded proxy setting means direct traffic');
|
|
314
|
-
});
|
|
315
|
-
|
|
316
|
-
test('GET requests pass through without CSRF handling', async () => {
|
|
317
|
-
await withBackend(null, async ({ relayUrl, state, relay }) => {
|
|
318
|
-
const response = await relayFetch(relayUrl, '/sap/bc/adt/discovery', { headers: { accept: 'application/xml' } });
|
|
319
|
-
assert.equal(response.status, 200);
|
|
320
|
-
assert.equal(state.requests.filter(request => request.headers['x-csrf-token']).length, 0);
|
|
321
|
-
assert.equal(relay.stats.csrfFetches, 0);
|
|
322
|
-
});
|
|
323
|
-
});
|
|
324
|
-
|
|
325
|
-
test('drops POST body headers from the token-fetch GET', async () => {
|
|
326
|
-
await withBackend(null, async ({ relayUrl, state }) => {
|
|
327
|
-
await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', {
|
|
328
|
-
method: 'POST',
|
|
329
|
-
headers: { accept: 'application/xml', 'content-type': 'application/xml', 'content-length': '5' },
|
|
330
|
-
body: '<xy/>'
|
|
331
|
-
});
|
|
332
|
-
const fetchRequest = state.requests.find(request => request.headers['x-csrf-token'] === 'Fetch');
|
|
333
|
-
assert.equal(fetchRequest.headers['content-type'], undefined, 'content-type must not leak into token GET');
|
|
334
|
-
assert.equal(fetchRequest.headers['content-length'], undefined, 'content-length must not leak into token GET');
|
|
335
|
-
});
|
|
336
|
-
});
|
|
337
|
-
|
|
338
|
-
test('falls back to a proxy tunnel when the absolute-form request is refused', async () => {
|
|
339
|
-
const fetchCalls = [];
|
|
340
|
-
const fetchImpl = (url, options = {}) => {
|
|
341
|
-
fetchCalls.push({ url: String(url), tunneled: Boolean(options.dispatcher && options.dispatcher.proxyTunnel !== false && options.dispatcher.constructor?.name === 'ProxyAgent' && options.dispatcher.__tunnelMarker !== false) });
|
|
342
|
-
// First call (absolute-form through proxy) is refused by the proxy itself.
|
|
343
|
-
if (fetchCalls.length === 1) return Promise.resolve(new Response('<gateway/>', { status: 502 }));
|
|
344
|
-
return Promise.resolve(new Response('<ok/>', { status: 200, headers: { 'x-csrf-token': 'tok' } }));
|
|
345
|
-
};
|
|
346
|
-
const { state, server } = sapLikeBackend();
|
|
347
|
-
void state;
|
|
348
|
-
const backendPort = await startServer(server);
|
|
349
|
-
const relay = createBasDestinationRelay(
|
|
350
|
-
{ name: 'TEST', url: `http://127.0.0.1:${backendPort}`, authentication: 'BasicAuthentication' },
|
|
351
|
-
{ env: { HTTP_PROXY: 'http://127.0.0.1:1' }, fetchImpl, log: () => {} }
|
|
352
|
-
);
|
|
353
|
-
const relayUrl = await relay.ready;
|
|
354
|
-
try {
|
|
355
|
-
const response = await relayFetch(relayUrl, '/sap/bc/adt/discovery');
|
|
356
|
-
assert.equal(response.status, 200);
|
|
357
|
-
assert.equal(relay.stats.tunnelFallbacks, 1, 'exactly one tunnel fallback must occur');
|
|
358
|
-
assert.equal(fetchCalls.length, 2, 'absolute-form attempt then one tunnel retry');
|
|
359
|
-
} finally {
|
|
360
|
-
await relay.close();
|
|
361
|
-
await new Promise(resolve => server.close(resolve));
|
|
362
|
-
}
|
|
363
|
-
});
|
|
364
|
-
|
|
365
|
-
test('keeps OnPremise Basic auth on a BAS proxy tunnel and verifies the CSRF session', async t => {
|
|
366
|
-
const { state, server } = sapLikeBackend({ secureSessionCookie: true });
|
|
367
|
-
const backendPort = await startServer(server);
|
|
368
|
-
const connectTargets = [];
|
|
369
|
-
const proxy = http.createServer();
|
|
370
|
-
proxy.on('connect', (request, clientSocket, head) => {
|
|
371
|
-
connectTargets.push(request.url);
|
|
372
|
-
const upstream = net.connect(backendPort, '127.0.0.1', () => {
|
|
373
|
-
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
|
374
|
-
if (head.length) upstream.write(head);
|
|
375
|
-
clientSocket.pipe(upstream);
|
|
376
|
-
upstream.pipe(clientSocket);
|
|
377
|
-
});
|
|
378
|
-
clientSocket.on('error', () => upstream.destroy());
|
|
379
|
-
upstream.on('error', () => clientSocket.destroy());
|
|
380
|
-
});
|
|
381
|
-
const proxyPort = await startServer(proxy);
|
|
382
|
-
const logs = [];
|
|
383
|
-
const relay = createBasDestinationRelay({
|
|
384
|
-
name: 'CC-SYSTEM',
|
|
385
|
-
url: 'http://cc-system.dest',
|
|
386
|
-
authentication: 'BasicAuthentication',
|
|
387
|
-
credentials: { user: 'DEVELOPER', password: 'secret123', mode: 'bas-tunnel' }
|
|
388
|
-
}, {
|
|
389
|
-
env: { HTTP_PROXY: `http://127.0.0.1:${proxyPort}` },
|
|
390
|
-
log: message => logs.push(message)
|
|
391
|
-
});
|
|
392
|
-
const relayUrl = await relay.ready;
|
|
393
|
-
t.after(async () => {
|
|
394
|
-
await relay.close();
|
|
395
|
-
proxy.closeAllConnections?.();
|
|
396
|
-
await new Promise(resolve => proxy.close(resolve));
|
|
397
|
-
await new Promise(resolve => server.close(resolve));
|
|
398
|
-
});
|
|
399
|
-
|
|
400
|
-
const probe = await relay.probeCsrfSession();
|
|
401
|
-
assert.deepEqual(probe, { httpStatus: 200, tokenReceived: true, cookieCount: 3, sessionUsable: true });
|
|
402
|
-
const response = await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
|
|
403
|
-
assert.equal(response.status, 201, `expected a paired CSRF session, got ${response.status}: ${response.body}`);
|
|
404
|
-
const post = state.requests.find(request => request.method === 'POST');
|
|
405
|
-
assert.match(post.headers.authorization, /^Basic /);
|
|
406
|
-
assert.match(String(post.headers.cookie), /sap-session-\d+=/);
|
|
407
|
-
assert.match(String(post.headers.cookie), /sap-secure=secure-value/, 'Secure SAP cookies must be sent through the HTTPS-backed BAS .dest route');
|
|
408
|
-
// The POST reuses the probe's cached CSRF session (tokens are shared across
|
|
409
|
-
// paths), so a single pooled tunnel connection can carry both requests.
|
|
410
|
-
assert.ok(connectTargets.length >= 1, 'upstream traffic must reach the backend through the BAS proxy tunnel');
|
|
411
|
-
assert.ok(connectTargets.every(target => target === 'cc-system.dest:80'));
|
|
412
|
-
assert.equal(relay.stats.direct, false);
|
|
413
|
-
assert.equal(relay.stats.proxyTunnel, true);
|
|
414
|
-
assert.ok(logs.some(message => message.includes('BAS proxy tunnel')));
|
|
415
|
-
});
|