sap-ai-dev-toolkit 0.5.5 → 0.5.7
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 +8 -8
- package/.github/agents/abap-runtime-debugger.agent.md +6 -6
- package/.github/agents/hana-cloud-hdi-specialist.agent.md +1 -1
- package/.github/agents/rap-service-developer.agent.md +5 -5
- package/.github/agents/sap-solution-architect.agent.md +2 -2
- package/.github/skills/abap-debugging/SKILL.md +3 -3
- package/.github/skills/abap-development/SKILL.md +3 -3
- package/.github/skills/abap-runtime-analysis/SKILL.md +4 -4
- package/.github/skills/abap-testing-quality/SKILL.md +3 -3
- package/.github/skills/cds-development/SKILL.md +3 -3
- package/.github/skills/clean-core-extensibility/SKILL.md +1 -1
- package/.github/skills/hana-cloud-inspection/SKILL.md +2 -2
- 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 +3 -3
- package/.github/skills/rap-service-delivery/SKILL.md +2 -2
- 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 +2 -2
- package/README.md +30 -23
- package/inventory.md +181 -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 +14 -13
- package/src/mcp-config.mjs +19 -1
- package/src/mcp-proxy.mjs +284 -103
- package/src/redact.mjs +28 -0
- package/src/setup.mjs +30 -1
- package/tools.md +10 -2
- 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/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
|
|
5
|
+
The names below are the logical tool names. Publicly they are exposed in lowercase snake_case without a prefix (`get_table_contents`, `run_query`) because each generated MCP server is scoped to one destination; only when a single server fronts multiple destinations does each name gain its destination slug (`demo-abap_get_table_contents`) to stay unambiguous. 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
|
|
|
@@ -75,7 +75,15 @@ The proxy adds local multi-step tools to each destination when their required VS
|
|
|
75
75
|
- `PlanABAPCloudMigration` — batch SAP API release-state checks and prioritize recognized unreleased results.
|
|
76
76
|
- `GenerateRAPRegressionSuite` and `RunRAPRegressionSuite` — create and run reusable OData GET-only checks under the selected service root.
|
|
77
77
|
|
|
78
|
-
`sap-ai-dev
|
|
78
|
+
`sap-ai-dev --doctor` checks destination probing, VSP startup, the `get_system_info` probe, and MCP tool listing. `sap-ai-dev --demo` runs the sample tools and OData fixtures without contacting SAP; demo writes and transport creation remain in memory until that process exits.
|
|
79
|
+
|
|
80
|
+
### Read-only mode
|
|
81
|
+
|
|
82
|
+
Set `SAP_AI_DEV_TOOLKIT_READ_ONLY=true` (or legacy `BAS_VSP_READ_ONLY`) on a destination server to run it strictly read-only: `WriteSource`, `EditSource`, `Activate`, `ActivateMultiple`, `CreateTransport`, `SetBreakpoint`, and the change-set workflow tools are removed from the surface, and the VSP child starts with `--transport-read-only` so transport writes are rejected upstream as well. Inspection, queries, linting, and checks remain available.
|
|
83
|
+
|
|
84
|
+
### Request timeouts
|
|
85
|
+
|
|
86
|
+
Calls forwarded to a VSP child time out after `SAP_AI_DEV_TOOLKIT_REQUEST_TIMEOUT_MS` (default 600000 ms; `0` disables). A stalled request fails with a timeout error; the child keeps running so unrelated in-flight requests are unaffected.
|
|
79
87
|
|
|
80
88
|
## Separate optional HANA Cloud inspector
|
|
81
89
|
|
|
@@ -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
|
-
});
|