@cspeach/cli 1.1.22 → 1.1.23
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/dist/agent/tool-dispatch.js +4 -2
- package/dist/cli.js +13 -0
- package/dist/commands/plan-audit-evidence.js +61 -4
- package/dist/commands/plan-audit.js +13 -1
- package/dist/commands/sap-link.js +52 -0
- package/dist/lib/shutdown-hooks.js +87 -0
- package/dist/one-shot.js +4 -0
- package/dist/projects/plan-run.js +5 -1
- package/dist/projects/plan-schema.js +2 -1
- package/dist/renderer/tool-labels.js +5 -1
- package/dist/repl/credential-handover-gate.js +12 -6
- package/dist/repl.js +4 -0
- package/dist/sap/connection-manager.js +30 -24
- package/dist/sap-link/policy.js +185 -0
- package/dist/sap-link/project.js +163 -0
- package/dist/sap-link/registry.js +259 -0
- package/dist/sap-link/server.js +351 -0
- package/dist/sap-link/upstream.js +367 -0
- package/dist/sap-link/write-log.js +60 -0
- package/dist/skills/bundled-skills.js +1 -1
- package/dist/tools/cap/_fetch-errors.js +20 -0
- package/dist/tools/cap/link-smoke.js +703 -0
- package/dist/tools/cap/metadata-import.js +325 -0
- package/dist/tools/cap/sap-link-tool.js +133 -0
- package/dist/tools/cap/service-catalog.js +248 -0
- package/dist/tools/fiori/preview/registry.js +30 -24
- package/dist/tools/local-build.js +11 -0
- package/dist/tools/subagent/_background-shared.js +59 -37
- package/dist/tools/subagent/background_run.js +9 -5
- package/package.json +121 -121
|
@@ -0,0 +1,703 @@
|
|
|
1
|
+
// cspeach-cli/src/tools/cap/link-smoke.ts
|
|
2
|
+
/**
|
|
3
|
+
* CAP phase 2 — S/4 link smoke (spec §8.2, Task 11).
|
|
4
|
+
*
|
|
5
|
+
* READ leg: the app's S/4 entity shows rows, and a DIRECT S/4 read of the same
|
|
6
|
+
* keys (through the alias's own SapConnection, independent of the link) returns
|
|
7
|
+
* the same compareFields values — proof the app is not on mocks. Mode `mock`
|
|
8
|
+
* only checks that the app serves rows and says it proves nothing live.
|
|
9
|
+
*
|
|
10
|
+
* WRITE leg (live only, optional, a SAP write): THROUGH THE SAP LINK, change one
|
|
11
|
+
* harmless field on one test record, re-read, restore it, re-read again.
|
|
12
|
+
*
|
|
13
|
+
* Safety rules held here:
|
|
14
|
+
* - Every input is validated before any request (identifiers, key, Gateway
|
|
15
|
+
* servicePath, testValue).
|
|
16
|
+
* - Write gates run before anything is sent to the link, in this order: loopback
|
|
17
|
+
* app URL (checked first of all), role exactly 'dev', the approval verified for
|
|
18
|
+
* EXACTLY approvalObjectFor(alias, s4, key) with op 'modify', the nonce spent
|
|
19
|
+
* once, the link running. Mode `mock` never writes and never calls SAP.
|
|
20
|
+
* - V2 writes use MERGE, V4 writes use PATCH. If-Match is sent only when the read
|
|
21
|
+
* returned an ETag (V2 __metadata.etag, V4 @odata.etag, or an ETag header).
|
|
22
|
+
* - Everything after a successful test update runs inside one try: a drop at
|
|
23
|
+
* the re-read, the restore or the final read is RED and names the alias, the
|
|
24
|
+
* record key, the field, the test value and the original value.
|
|
25
|
+
* - Response bodies from the app, the link or SAP are never echoed. Only status
|
|
26
|
+
* codes and field values, each scrubbed to one line of at most 200 characters.
|
|
27
|
+
* - Fix round 1: the approval object is canonicalization-proof (R23, see
|
|
28
|
+
* approvalObjectFor) and the verify result must be an exact-record modify; a
|
|
29
|
+
* test update answered with anything but 2xx or a definite refusal is outcome
|
|
30
|
+
* unknown (re-read, restore if needed, always red); redirects are never
|
|
31
|
+
* followed (redirect: 'manual', 3xx is red); the link must be on the same
|
|
32
|
+
* alias; bodies are capped at 8 MB; before/restored compare raw JSON values.
|
|
33
|
+
* - R28 (live S4H defect): S/4 can show a write late. After any update that was
|
|
34
|
+
* not a definite refusal the tool polls for the test value, ALWAYS sends the
|
|
35
|
+
* restore, and polls for the original. Green needs both seen; "restored" is
|
|
36
|
+
* only said after a read made after the restore confirmed it.
|
|
37
|
+
* - Evidence (~/.cspeach/smoke/cap-link-<runId>/evidence.json) holds no headers,
|
|
38
|
+
* no bodies and no password.
|
|
39
|
+
*/
|
|
40
|
+
import * as os from 'node:os';
|
|
41
|
+
import * as path from 'node:path';
|
|
42
|
+
import { randomUUID } from 'node:crypto';
|
|
43
|
+
import { registerTool } from '../index.js';
|
|
44
|
+
import { resolveSafePath, assertRealPathContained } from '../_filesystem-shared.js';
|
|
45
|
+
import { loadConfig } from '../../config/loader.js';
|
|
46
|
+
import { verifyAndSpendApprovalId } from '../../approvals/jwt.js';
|
|
47
|
+
import { canonicalApprovalObject } from '../../approvals/canonical.js';
|
|
48
|
+
import { writeEvidence } from '../fiori/smoke/evidence.js';
|
|
49
|
+
import { isLoopbackAppUrl } from '../fiori/smoke/draft-safety.js';
|
|
50
|
+
import { findSapLink, writeLogFileFor } from '../../sap-link/registry.js';
|
|
51
|
+
import { writesBlockedMessage } from '../../sap-link/policy.js';
|
|
52
|
+
import { readWriteRows } from '../../sap-link/write-log.js';
|
|
53
|
+
import { unreachableCode } from '../../sap/unreachable.js';
|
|
54
|
+
// ── Input rules ─────────────────────────────────────────────────────────────
|
|
55
|
+
const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
|
|
56
|
+
/** Printable ASCII; no `/`, `\`, `?`, `#` (path/query breakers), no `%` or `;` (the link refuses them in a path). */
|
|
57
|
+
const KEY_RE = /^[\x20-\x7E]{1,120}$/;
|
|
58
|
+
const KEY_FORBIDDEN_RE = /[/\\?#%;]/;
|
|
59
|
+
const TEST_VALUE_RE = /^[\x20-\x7E]{1,60}$/;
|
|
60
|
+
const SEG = '[A-Za-z0-9_]+';
|
|
61
|
+
const V2_PATH_RE = new RegExp(`^/sap/opu/odata/${SEG}/${SEG}(?:;v=\\d+)?/?$`);
|
|
62
|
+
const V4_PATH_RE = new RegExp(`^/sap/opu/odata4(?:/${SEG}){6}/?$`);
|
|
63
|
+
/** A local CAP service path: /odata/v4/review/ — plain segments only. */
|
|
64
|
+
const APP_PATH_RE = /^(?:\/[A-Za-z0-9_-]+)+\/?$/;
|
|
65
|
+
const CAP_USER_RE = /^[A-Za-z0-9_.@-]{1,64}$/;
|
|
66
|
+
const MAX_COMPARE_FIELDS = 20;
|
|
67
|
+
const MAX_KEYS = 3;
|
|
68
|
+
const TIMEOUT_MS = 30_000;
|
|
69
|
+
/** R28: how often and how long to re-read after a write until it shows (S/4 commits through the update task). */
|
|
70
|
+
const POLL_INTERVAL_MS = 500;
|
|
71
|
+
const POLL_TIMEOUT_MS = 15_000;
|
|
72
|
+
/** Largest response body read from the app, the link or SAP. */
|
|
73
|
+
export const MAX_BODY_BYTES = 8 * 1024 * 1024;
|
|
74
|
+
/** Answers to the test update that mean SAP refused it: nothing was changed. Any other non-2xx is outcome unknown. */
|
|
75
|
+
const DEFINITE_REFUSALS = new Set([400, 403, 404, 405, 412, 428]);
|
|
76
|
+
const PATH_SHAPE = 's4.servicePath must be a Gateway OData path: /sap/opu/odata/<namespace>/<SERVICE> (optionally ending ;v=<number>) ' +
|
|
77
|
+
'or /sap/opu/odata4/ followed by 6 segments. No host, query, "..", "%", "\\", "?" or "#".';
|
|
78
|
+
function validKey(k) {
|
|
79
|
+
return typeof k === 'string' && KEY_RE.test(k) && !KEY_FORBIDDEN_RE.test(k);
|
|
80
|
+
}
|
|
81
|
+
function validGatewayPath(p) {
|
|
82
|
+
if (typeof p !== 'string' || !/^[\x20-\x7E]+$/.test(p))
|
|
83
|
+
return undefined;
|
|
84
|
+
if (p.includes('..') || /[%\\?#]/.test(p))
|
|
85
|
+
return undefined;
|
|
86
|
+
if (!V2_PATH_RE.test(p) && !V4_PATH_RE.test(p))
|
|
87
|
+
return undefined;
|
|
88
|
+
return p.replace(/\/+$/, '');
|
|
89
|
+
}
|
|
90
|
+
/** The first problem with the args, or undefined when they are usable. Pure: no request is made. */
|
|
91
|
+
function inputProblem(args) {
|
|
92
|
+
if (!args || typeof args !== 'object')
|
|
93
|
+
return 'arguments are missing.';
|
|
94
|
+
if (args.mode !== 'live' && args.mode !== 'mock')
|
|
95
|
+
return 'mode must be live or mock.';
|
|
96
|
+
const app = args.app;
|
|
97
|
+
if (!app || typeof app !== 'object')
|
|
98
|
+
return 'app is missing.';
|
|
99
|
+
if (typeof app.url !== 'string' || !isLoopbackAppUrl(app.url)) {
|
|
100
|
+
return 'app.url must be the local CAP server (localhost, 127.x or [::1]).';
|
|
101
|
+
}
|
|
102
|
+
const u = new URL(app.url);
|
|
103
|
+
if ((u.pathname !== '/' && u.pathname !== '') || u.search || u.hash)
|
|
104
|
+
return 'app.url must be the server root, such as http://localhost:4004.';
|
|
105
|
+
if (typeof app.servicePath !== 'string' || !APP_PATH_RE.test(app.servicePath)) {
|
|
106
|
+
return 'app.servicePath must be a plain path such as /odata/v4/review/.';
|
|
107
|
+
}
|
|
108
|
+
if (typeof app.entitySet !== 'string' || !IDENT_RE.test(app.entitySet))
|
|
109
|
+
return 'app.entitySet must be a plain OData name.';
|
|
110
|
+
if (typeof app.keyField !== 'string' || !IDENT_RE.test(app.keyField))
|
|
111
|
+
return 'app.keyField must be a plain OData name.';
|
|
112
|
+
if (app.capUser !== undefined && (typeof app.capUser !== 'string' || !CAP_USER_RE.test(app.capUser))) {
|
|
113
|
+
return 'app.capUser must be a plain user name such as alice.';
|
|
114
|
+
}
|
|
115
|
+
const s4 = args.s4;
|
|
116
|
+
if (!s4 || typeof s4 !== 'object')
|
|
117
|
+
return 's4 is missing.';
|
|
118
|
+
const svc = validGatewayPath(s4.servicePath);
|
|
119
|
+
if (!svc)
|
|
120
|
+
return PATH_SHAPE;
|
|
121
|
+
if (s4.odata !== undefined && s4.odata !== 'v2' && s4.odata !== 'v4')
|
|
122
|
+
return 's4.odata must be v2 or v4.';
|
|
123
|
+
const isV4Path = svc.startsWith('/sap/opu/odata4/');
|
|
124
|
+
if (isV4Path !== (s4.odata === 'v4')) {
|
|
125
|
+
return isV4Path ? 'This is an OData V4 path. Set s4.odata to v4.' : 'This is an OData V2 path. Leave s4.odata unset or set it to v2.';
|
|
126
|
+
}
|
|
127
|
+
if (typeof s4.entitySet !== 'string' || !IDENT_RE.test(s4.entitySet))
|
|
128
|
+
return 's4.entitySet must be a plain OData name.';
|
|
129
|
+
if (typeof s4.keyField !== 'string' || !IDENT_RE.test(s4.keyField))
|
|
130
|
+
return 's4.keyField must be a plain OData name.';
|
|
131
|
+
if (!Array.isArray(s4.compareFields) || s4.compareFields.length === 0 || s4.compareFields.length > MAX_COMPARE_FIELDS ||
|
|
132
|
+
!s4.compareFields.every((f) => typeof f === 'string' && IDENT_RE.test(f))) {
|
|
133
|
+
return `s4.compareFields must list 1 to ${MAX_COMPARE_FIELDS} plain OData field names.`;
|
|
134
|
+
}
|
|
135
|
+
if (args.write !== undefined) {
|
|
136
|
+
const w = args.write;
|
|
137
|
+
if (!w || typeof w !== 'object')
|
|
138
|
+
return 'write must be {key, field, testValue}.';
|
|
139
|
+
if (!validKey(w.key)) {
|
|
140
|
+
return 'write.key must be printable ASCII, at most 120 characters, with no "/", "\\", "?", "#", "%" or ";".';
|
|
141
|
+
}
|
|
142
|
+
if (typeof w.field !== 'string' || !IDENT_RE.test(w.field))
|
|
143
|
+
return 'write.field must be a plain OData field name.';
|
|
144
|
+
if (typeof w.testValue !== 'string' || !TEST_VALUE_RE.test(w.testValue)) {
|
|
145
|
+
return 'write.testValue must be 1 to 60 printable characters.';
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (args.approval_id !== undefined && typeof args.approval_id !== 'string')
|
|
149
|
+
return 'approval_id must be a string.';
|
|
150
|
+
if (args.projectDir !== undefined && typeof args.projectDir !== 'string')
|
|
151
|
+
return 'projectDir must be a string.';
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
// ── URLs ────────────────────────────────────────────────────────────────────
|
|
155
|
+
/** An OData string key literal: single quotes doubled. */
|
|
156
|
+
const literal = (k) => `'${k.replace(/'/g, "''")}'`;
|
|
157
|
+
const svcPath = (s4) => s4.servicePath.replace(/\/+$/, '');
|
|
158
|
+
/** Entity path as sent: the key literal percent-encoded (quotes, parentheses stay as they are). */
|
|
159
|
+
const entityUrlPath = (s4, key) => `${svcPath(s4)}/${s4.entitySet}(${encodeURIComponent(literal(key))})`;
|
|
160
|
+
/**
|
|
161
|
+
* The approval object for one record (ruling R23):
|
|
162
|
+
* `<ALIAS>:<servicePath>/<EntitySet> record <key> [<HEX>]`
|
|
163
|
+
* HEX = upper-case hex of the UTF-8 bytes of `<ALIAS>\n<servicePath>\n<EntitySet>\n<key>`.
|
|
164
|
+
*
|
|
165
|
+
* canonicalApprovalObject (shared by every SAP write) trims, strips a leading
|
|
166
|
+
* "TR:"-style label, strips TRAILING PARENTHETICALS, collapses whitespace and
|
|
167
|
+
* upper-cases. The old form ended in `('<key>')`, so the key was stripped and one
|
|
168
|
+
* approval covered every record. This form ends in `]` (no parenthetical to strip),
|
|
169
|
+
* has spaces (never a comma list of object names), and the hex part is unchanged
|
|
170
|
+
* by upper-casing and holds alias, service, entity set and key exactly, so no fold
|
|
171
|
+
* can merge two records, two key cases, or two aliases. The readable part keeps
|
|
172
|
+
* the approval card naming the record.
|
|
173
|
+
*/
|
|
174
|
+
export function approvalObjectFor(alias, s4, key) {
|
|
175
|
+
const hex = Buffer.from(`${alias}\n${svcPath(s4)}\n${s4.entitySet}\n${key}`, 'utf8').toString('hex').toUpperCase();
|
|
176
|
+
return `${alias}:${svcPath(s4)}/${s4.entitySet} record ${key} [${hex}]`;
|
|
177
|
+
}
|
|
178
|
+
// ── Values ──────────────────────────────────────────────────────────────────
|
|
179
|
+
/** A field value for a message or the evidence: one line, at most 200 characters. */
|
|
180
|
+
function scrub(v) {
|
|
181
|
+
const s = v === null || v === undefined ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v);
|
|
182
|
+
// eslint-disable-next-line no-control-regex
|
|
183
|
+
const one = s.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, ' ');
|
|
184
|
+
return one.length > 200 ? `${one.slice(0, 197)}...` : one;
|
|
185
|
+
}
|
|
186
|
+
/** V2 `{d:{…}}` / V4 `{…}` → the record plus its ETag (body first, then the header). */
|
|
187
|
+
function record(body, etagHeader) {
|
|
188
|
+
const j = JSON.parse(body);
|
|
189
|
+
if (!j || typeof j !== 'object' || Array.isArray(j))
|
|
190
|
+
throw new Error('not an OData record');
|
|
191
|
+
const d = j.d;
|
|
192
|
+
const rec = (d && typeof d === 'object' && !Array.isArray(d) ? d : j);
|
|
193
|
+
const md = rec.__metadata;
|
|
194
|
+
const fromBody = typeof md?.etag === 'string' ? md.etag : typeof rec['@odata.etag'] === 'string' ? rec['@odata.etag'] : undefined;
|
|
195
|
+
const etag = fromBody ?? (etagHeader || undefined);
|
|
196
|
+
return { rec, ...(etag ? { etag } : {}) };
|
|
197
|
+
}
|
|
198
|
+
const ok2xx = (s) => s >= 200 && s < 300;
|
|
199
|
+
/** Raw JSON equality: number 5 and string "5" differ. scrub() is for display only. */
|
|
200
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
201
|
+
/** A body over MAX_BODY_BYTES. */
|
|
202
|
+
class TooLarge extends Error {
|
|
203
|
+
}
|
|
204
|
+
/** Read a response body as text, refusing more than MAX_BODY_BYTES (content-length first, then while streaming). */
|
|
205
|
+
async function readCapped(r) {
|
|
206
|
+
const declared = Number(r.headers.get('content-length'));
|
|
207
|
+
if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
|
|
208
|
+
try {
|
|
209
|
+
await r.body?.cancel();
|
|
210
|
+
}
|
|
211
|
+
catch { /* best effort */ }
|
|
212
|
+
throw new TooLarge();
|
|
213
|
+
}
|
|
214
|
+
if (!r.body)
|
|
215
|
+
return '';
|
|
216
|
+
const reader = r.body.getReader();
|
|
217
|
+
const chunks = [];
|
|
218
|
+
let total = 0;
|
|
219
|
+
for (;;) {
|
|
220
|
+
const { done, value } = await reader.read();
|
|
221
|
+
if (done)
|
|
222
|
+
break;
|
|
223
|
+
total += value.byteLength;
|
|
224
|
+
if (total > MAX_BODY_BYTES) {
|
|
225
|
+
try {
|
|
226
|
+
await reader.cancel();
|
|
227
|
+
}
|
|
228
|
+
catch { /* best effort */ }
|
|
229
|
+
throw new TooLarge();
|
|
230
|
+
}
|
|
231
|
+
chunks.push(value);
|
|
232
|
+
}
|
|
233
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
234
|
+
}
|
|
235
|
+
/** Drop a body we do not read (write answers), so the socket is freed. */
|
|
236
|
+
async function discard(r) {
|
|
237
|
+
try {
|
|
238
|
+
await r.body?.cancel();
|
|
239
|
+
}
|
|
240
|
+
catch { /* best effort */ }
|
|
241
|
+
}
|
|
242
|
+
function evidenceRows(rows) {
|
|
243
|
+
return rows.flatMap((r) => {
|
|
244
|
+
if (!r || typeof r !== 'object')
|
|
245
|
+
return [];
|
|
246
|
+
const o = r;
|
|
247
|
+
return [{ at: o.at, method: o.method, path: o.path, status: o.status }];
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
// ── Handler ─────────────────────────────────────────────────────────────────
|
|
251
|
+
/** Nonces of approvals that already ran one smoke (this process). verifyAndSpend also spends them. */
|
|
252
|
+
const usedNonces = new Set();
|
|
253
|
+
export async function capLinkSmokeHandler(args, ctx, partial = {}) {
|
|
254
|
+
const runId = randomUUID().slice(0, 8);
|
|
255
|
+
const startedAt = new Date().toISOString();
|
|
256
|
+
const deps = {
|
|
257
|
+
fetch: globalThis.fetch,
|
|
258
|
+
sapGet: async (p) => {
|
|
259
|
+
const cfg = await loadConfig();
|
|
260
|
+
const sys = cfg.sap?.[ctx.sapAlias];
|
|
261
|
+
if (!sys)
|
|
262
|
+
throw new PlainError(`No SAP system named ${ctx.sapAlias}. Live mode needs one; mock mode does not.`);
|
|
263
|
+
try {
|
|
264
|
+
const { getConnection } = await import('../../sap/connection-manager.js');
|
|
265
|
+
const conn = await getConnection(ctx.sapAlias, sys);
|
|
266
|
+
const r = await conn.get(p, 'application/json');
|
|
267
|
+
return { status: r.status, body: r.body };
|
|
268
|
+
}
|
|
269
|
+
catch (err) {
|
|
270
|
+
throw new PlainError(unreachableCode(err)
|
|
271
|
+
? `Can't reach ${ctx.sapAlias} (${sys.host}:${sys.port}) from this machine. Check network access to the SAP system.`
|
|
272
|
+
: `Could not read from ${ctx.sapAlias}. Check the SAP login for this system and try again.`);
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
role: async () => (await loadConfig()).sap?.[ctx.sapAlias]?.role,
|
|
276
|
+
alias: ctx.sapAlias,
|
|
277
|
+
link: (dir) => {
|
|
278
|
+
const h = findSapLink(dir);
|
|
279
|
+
return h ? { url: h.url, alias: h.alias } : undefined;
|
|
280
|
+
},
|
|
281
|
+
verify: verifyAndSpendApprovalId,
|
|
282
|
+
evidenceDir: path.join(os.homedir(), '.cspeach', 'smoke', `cap-link-${runId}`),
|
|
283
|
+
writeLogRows: (dir, since) => readWriteRows(writeLogFileFor(dir), since),
|
|
284
|
+
pollIntervalMs: POLL_INTERVAL_MS,
|
|
285
|
+
pollTimeoutMs: POLL_TIMEOUT_MS,
|
|
286
|
+
...partial,
|
|
287
|
+
};
|
|
288
|
+
const problem = inputProblem(args);
|
|
289
|
+
if (problem)
|
|
290
|
+
return { content: `cap_link_smoke: refused (nothing was sent) — ${problem}`, is_error: true };
|
|
291
|
+
const ev = { tool: 'cap_link_smoke', runId, mode: args.mode, startedAt };
|
|
292
|
+
const finish = (ok, extra = {}) => writeEvidence(deps.evidenceDir, { ...ev, ok, ...extra });
|
|
293
|
+
const red = (m, extra = {}) => {
|
|
294
|
+
const file = finish(false, { ...extra, error: m });
|
|
295
|
+
return { content: `cap_link_smoke: red — ${m}${file ? `\nEvidence: ${file}` : ''}`, is_error: true };
|
|
296
|
+
};
|
|
297
|
+
const timeout = () => (typeof AbortSignal?.timeout === 'function' ? { signal: AbortSignal.timeout(TIMEOUT_MS) } : {});
|
|
298
|
+
// ── READ leg: the app. ──
|
|
299
|
+
const auth = args.app.capUser ? { authorization: 'Basic ' + Buffer.from(`${args.app.capUser}:`).toString('base64') } : undefined;
|
|
300
|
+
const origin = new URL(args.app.url).origin;
|
|
301
|
+
const appPath = args.app.servicePath.endsWith('/') ? args.app.servicePath : `${args.app.servicePath}/`;
|
|
302
|
+
const appUrl = `${origin}${appPath}${args.app.entitySet}?$top=5`;
|
|
303
|
+
let rows;
|
|
304
|
+
try {
|
|
305
|
+
// redirect: 'manual' — a redirect is never followed; a 3xx is not 200, so it is red.
|
|
306
|
+
const ar = await deps.fetch(appUrl, { headers: { accept: 'application/json', ...auth }, redirect: 'manual', ...timeout() });
|
|
307
|
+
if (ar.status !== 200) {
|
|
308
|
+
await discard(ar);
|
|
309
|
+
return red(`the app answered HTTP ${ar.status} on ${args.app.entitySet}.`);
|
|
310
|
+
}
|
|
311
|
+
let j;
|
|
312
|
+
try {
|
|
313
|
+
j = JSON.parse(await readCapped(ar));
|
|
314
|
+
}
|
|
315
|
+
catch (e) {
|
|
316
|
+
if (e instanceof TooLarge)
|
|
317
|
+
return red(`the app's answer on ${args.app.entitySet} is too large (over 8 MB).`);
|
|
318
|
+
return red(`the app did not answer OData JSON on ${args.app.entitySet}.`);
|
|
319
|
+
}
|
|
320
|
+
if (!Array.isArray(j?.value))
|
|
321
|
+
return red(`the app did not answer OData JSON on ${args.app.entitySet}.`);
|
|
322
|
+
rows = j.value.filter((r) => !!r && typeof r === 'object' && !Array.isArray(r));
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
return red(`could not read ${args.app.entitySet} from the app at ${origin}. Is the CAP server running?`);
|
|
326
|
+
}
|
|
327
|
+
if (rows.length === 0)
|
|
328
|
+
return red(`the app returned no ${args.app.entitySet} rows.`);
|
|
329
|
+
const readEv = { rows: rows.length, mismatches: [] };
|
|
330
|
+
ev.read = readEv;
|
|
331
|
+
if (args.mode === 'mock') {
|
|
332
|
+
const file = finish(true, { note: 'mock only, not proven against S/4' });
|
|
333
|
+
return {
|
|
334
|
+
content: `cap_link_smoke: read: green (${rows.length} rows) — mock only, not proven against S/4.` +
|
|
335
|
+
`${args.write ? ' Mock mode never writes; the write args were ignored.' : ' No write was attempted.'}` +
|
|
336
|
+
`${file ? `\nEvidence: ${file}` : ''}`,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
// ── READ leg: compare with a DIRECT S/4 read of the same keys (one GET per key, at most 3). ──
|
|
340
|
+
const v4 = args.s4.odata === 'v4';
|
|
341
|
+
for (const row of rows.slice(0, MAX_KEYS)) {
|
|
342
|
+
const raw = row[args.app.keyField];
|
|
343
|
+
const key = typeof raw === 'string' || typeof raw === 'number' ? String(raw) : '';
|
|
344
|
+
if (!validKey(key))
|
|
345
|
+
return red(`an app row has a ${args.app.keyField} that cannot be used as an S/4 key.`);
|
|
346
|
+
const directPath = v4 ? entityUrlPath(args.s4, key) : `${entityUrlPath(args.s4, key)}?$format=json`;
|
|
347
|
+
let sr;
|
|
348
|
+
try {
|
|
349
|
+
sr = await deps.sapGet(directPath);
|
|
350
|
+
}
|
|
351
|
+
catch (err) {
|
|
352
|
+
return red(err instanceof PlainError ? err.message : `could not read ${scrub(key)} directly from ${deps.alias}.`);
|
|
353
|
+
}
|
|
354
|
+
if (sr.status !== 200)
|
|
355
|
+
return red(`direct S/4 read of ${scrub(key)} answered HTTP ${sr.status}.`);
|
|
356
|
+
if (Buffer.byteLength(String(sr.body ?? ''), 'utf8') > MAX_BODY_BYTES)
|
|
357
|
+
return red(`direct S/4 read of ${scrub(key)} is too large (over 8 MB).`);
|
|
358
|
+
let rec;
|
|
359
|
+
try {
|
|
360
|
+
rec = record(sr.body).rec;
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
return red(`direct S/4 read of ${scrub(key)} did not answer OData JSON.`);
|
|
364
|
+
}
|
|
365
|
+
if (Object.prototype.hasOwnProperty.call(rec, args.s4.keyField) && scrub(rec[args.s4.keyField]) !== scrub(key)) {
|
|
366
|
+
readEv.mismatches.push(`${scrub(key)}: S/4 answered a record with ${args.s4.keyField} "${scrub(rec[args.s4.keyField])}"`);
|
|
367
|
+
}
|
|
368
|
+
for (const f of args.s4.compareFields) {
|
|
369
|
+
if (scrub(rec[f]) !== scrub(row[f]))
|
|
370
|
+
readEv.mismatches.push(`${scrub(key)}.${f}: app "${scrub(row[f])}" vs S/4 "${scrub(rec[f])}"`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
if (readEv.mismatches.length) {
|
|
374
|
+
return red(`app values differ from a direct S/4 read (is the app on mocks?): ${readEv.mismatches.join('; ')}`);
|
|
375
|
+
}
|
|
376
|
+
const lines = [`cap_link_smoke: read: green (${rows.length} rows; ${args.s4.compareFields.join(', ')} match a direct S/4 read)`];
|
|
377
|
+
if (!args.write) {
|
|
378
|
+
const file = finish(true);
|
|
379
|
+
if (file)
|
|
380
|
+
lines.push(`Evidence: ${file}`);
|
|
381
|
+
return { content: lines.join('\n') };
|
|
382
|
+
}
|
|
383
|
+
// ── WRITE leg: gates, before anything is sent to the link. ──
|
|
384
|
+
const w = args.write;
|
|
385
|
+
const role = await deps.role();
|
|
386
|
+
if (role !== 'dev')
|
|
387
|
+
return red(`${writesBlockedMessage(deps.alias, role)} Nothing was written.`);
|
|
388
|
+
const object = approvalObjectFor(deps.alias, args.s4, w.key);
|
|
389
|
+
const howToApprove = `Call request_approval with change {op:"modify", object:"${object}", type:"DATA"} and pass its id as approval_id.`;
|
|
390
|
+
if (!args.approval_id)
|
|
391
|
+
return red(`the write leg needs an approval. ${howToApprove}`);
|
|
392
|
+
const v = await deps.verify(args.approval_id, object, 'modify');
|
|
393
|
+
if (!v.ok)
|
|
394
|
+
return red(`APPROVAL_INVALID: ${v.reason}. ${howToApprove}`);
|
|
395
|
+
// R23b: the approval must be for EXACTLY this record and op, never a batch-list member.
|
|
396
|
+
if (v.matchKind === 'list_member' || v.payload.object !== canonicalApprovalObject(object) || v.payload.op !== 'modify') {
|
|
397
|
+
return red(`APPROVAL_INVALID: the approval is not a modify approval for exactly this record. ${howToApprove}`);
|
|
398
|
+
}
|
|
399
|
+
// R24: an S/4 data write needs an approval a human saw on screen; auto-approvals never count.
|
|
400
|
+
if (v.payload.auto) {
|
|
401
|
+
return red(`The write leg needs an approval you confirmed on screen, not an auto-approval. ${howToApprove}`);
|
|
402
|
+
}
|
|
403
|
+
if (usedNonces.has(v.payload.nonce))
|
|
404
|
+
return red(`that approval was already used. One approval runs one smoke. ${howToApprove}`);
|
|
405
|
+
usedNonces.add(v.payload.nonce);
|
|
406
|
+
const spentNote = 'That approval is now used; request a new approval for the next run.';
|
|
407
|
+
let dir;
|
|
408
|
+
try {
|
|
409
|
+
dir = await assertRealPathContained(resolveSafePath(ctx.cwd, args.projectDir ?? '.'), ctx.cwd);
|
|
410
|
+
}
|
|
411
|
+
catch {
|
|
412
|
+
return red(`projectDir must be an existing folder inside the workspace. ${spentNote}`);
|
|
413
|
+
}
|
|
414
|
+
const link = deps.link(dir);
|
|
415
|
+
if (!link)
|
|
416
|
+
return red(`no SAP link is running for this project. Run sap_link start first. ${spentNote}`);
|
|
417
|
+
if (link.alias !== deps.alias) {
|
|
418
|
+
return red(`The SAP link for this project is on ${link.alias}, not ${deps.alias}. Start it on ${deps.alias}. ${spentNote}`);
|
|
419
|
+
}
|
|
420
|
+
const logRows = () => {
|
|
421
|
+
try {
|
|
422
|
+
return evidenceRows(deps.writeLogRows(dir, startedAt));
|
|
423
|
+
}
|
|
424
|
+
catch {
|
|
425
|
+
return [];
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
const viaLink = `${link.url.replace(/\/+$/, '')}${entityUrlPath(args.s4, w.key)}`;
|
|
429
|
+
const verb = v4 ? 'PATCH' : 'MERGE';
|
|
430
|
+
/** HTTP status of the latest read through the link (null when no answer came). For evidence. */
|
|
431
|
+
let lastReadStatus = null;
|
|
432
|
+
const read = async () => {
|
|
433
|
+
const r = await deps.fetch(v4 ? viaLink : `${viaLink}?$format=json`, { headers: { accept: 'application/json' }, redirect: 'manual', ...timeout() });
|
|
434
|
+
lastReadStatus = r.status;
|
|
435
|
+
if (r.status !== 200) {
|
|
436
|
+
await discard(r);
|
|
437
|
+
throw new StepError(`read through the SAP link answered HTTP ${r.status}`);
|
|
438
|
+
}
|
|
439
|
+
let text;
|
|
440
|
+
try {
|
|
441
|
+
text = await readCapped(r);
|
|
442
|
+
}
|
|
443
|
+
catch (e) {
|
|
444
|
+
if (e instanceof TooLarge)
|
|
445
|
+
throw new StepError('read through the SAP link is too large (over 8 MB)');
|
|
446
|
+
throw e;
|
|
447
|
+
}
|
|
448
|
+
try {
|
|
449
|
+
return record(text, r.headers.get('etag'));
|
|
450
|
+
}
|
|
451
|
+
catch {
|
|
452
|
+
throw new StepError('read through the SAP link did not answer OData JSON');
|
|
453
|
+
}
|
|
454
|
+
};
|
|
455
|
+
const update = async (value, etag) => {
|
|
456
|
+
const r = await deps.fetch(viaLink, {
|
|
457
|
+
method: verb,
|
|
458
|
+
headers: { 'content-type': 'application/json', accept: 'application/json', ...(etag ? { 'if-match': etag } : {}) },
|
|
459
|
+
body: JSON.stringify({ [w.field]: value }),
|
|
460
|
+
redirect: 'manual',
|
|
461
|
+
...timeout(),
|
|
462
|
+
});
|
|
463
|
+
await discard(r);
|
|
464
|
+
return r.status;
|
|
465
|
+
};
|
|
466
|
+
const statuses = { read: null, update: null, reread: null, restore: null, final: null };
|
|
467
|
+
const writeEv = (extra) => ({ key: w.key, field: w.field, testValue: w.testValue, ...extra, statuses });
|
|
468
|
+
// Before: read the record. Nothing is changed yet.
|
|
469
|
+
let b;
|
|
470
|
+
try {
|
|
471
|
+
b = await read();
|
|
472
|
+
statuses.read = 200;
|
|
473
|
+
}
|
|
474
|
+
catch (e) {
|
|
475
|
+
return red(`${e instanceof StepError ? e.message : 'could not read the record through the SAP link'}. Nothing was changed.`, { write: writeEv({}), writeLog: logRows() });
|
|
476
|
+
}
|
|
477
|
+
if (!Object.prototype.hasOwnProperty.call(b.rec, w.field)) {
|
|
478
|
+
return red(`record ${scrub(w.key)} has no field ${w.field}. Nothing was changed.`, { write: writeEv({}), writeLog: logRows() });
|
|
479
|
+
}
|
|
480
|
+
const beforeRaw = b.rec[w.field];
|
|
481
|
+
if (beforeRaw !== null && typeof beforeRaw === 'object') {
|
|
482
|
+
return red(`${w.field} is not a simple field. Pick a harmless text field. Nothing was changed.`, { write: writeEv({}), writeLog: logRows() });
|
|
483
|
+
}
|
|
484
|
+
const before = scrub(beforeRaw);
|
|
485
|
+
if (same(beforeRaw, w.testValue)) {
|
|
486
|
+
return red(`${w.field} already holds "${w.testValue}". Pick another testValue. Nothing was changed.`, { write: writeEv({ before }), writeLog: logRows() });
|
|
487
|
+
}
|
|
488
|
+
const who = `${deps.alias} record ${scrub(w.key)}`;
|
|
489
|
+
// The test update. 2xx = applied. A definite refusal = nothing changed.
|
|
490
|
+
// Anything else (no answer, 3xx, 5xx, 409, …) = outcome unknown.
|
|
491
|
+
let unknownCause = '';
|
|
492
|
+
try {
|
|
493
|
+
const s1 = await update(w.testValue, b.etag);
|
|
494
|
+
statuses.update = s1;
|
|
495
|
+
if (!ok2xx(s1)) {
|
|
496
|
+
if (DEFINITE_REFUSALS.has(s1)) {
|
|
497
|
+
return red(`the test update of ${scrub(w.key)}.${w.field} answered HTTP ${s1}. Nothing was changed.`, { write: writeEv({ before }), writeLog: logRows() });
|
|
498
|
+
}
|
|
499
|
+
unknownCause = `answered HTTP ${s1}`;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
catch {
|
|
503
|
+
unknownCause = 'got no answer';
|
|
504
|
+
}
|
|
505
|
+
// R28 (live S4H finding): S/4 commits through the update task, so a read straight
|
|
506
|
+
// after a write can be stale. From here on:
|
|
507
|
+
// (a) poll the re-read until the test value shows (up to the poll window);
|
|
508
|
+
// (b) ALWAYS send the restore (beforeRaw), seen or not — unconditionally, outside any try;
|
|
509
|
+
// (c) poll until a read shows the original value. When the test value was NEVER
|
|
510
|
+
// seen, watch the whole window instead: if the test value lands late, send ONE
|
|
511
|
+
// more restore and poll again (bounded);
|
|
512
|
+
// (d) "restored" only when a read AFTER the restore confirmed it;
|
|
513
|
+
// (e) green only when the test value was seen, every restore answered 2xx, and
|
|
514
|
+
// the original was confirmed.
|
|
515
|
+
// Polls never throw; any failure names alias, record, field, test value and original value.
|
|
516
|
+
const timing = pollTimings(deps.pollIntervalMs, deps.pollTimeoutMs);
|
|
517
|
+
let etag = b.etag;
|
|
518
|
+
let lastCause = '';
|
|
519
|
+
const sleep = (ms) => new Promise((ok) => setTimeout(ok, ms));
|
|
520
|
+
/**
|
|
521
|
+
* Re-read until the field equals `want` (or, with `watchFor`, until that value shows),
|
|
522
|
+
* or the window ends. `fullWindow` keeps reading after `want` matched.
|
|
523
|
+
*/
|
|
524
|
+
const poll = async (want, o = {}) => {
|
|
525
|
+
const deadline = Date.now() + timing.timeoutMs;
|
|
526
|
+
const res = { hit: false, reads: 0, lastStatus: null, sawTest: false };
|
|
527
|
+
for (;;) {
|
|
528
|
+
res.reads++;
|
|
529
|
+
lastReadStatus = null;
|
|
530
|
+
try {
|
|
531
|
+
const r = await read();
|
|
532
|
+
if (r.etag)
|
|
533
|
+
etag = r.etag;
|
|
534
|
+
res.last = { raw: r.rec[w.field] };
|
|
535
|
+
res.hit = same(res.last.raw, want);
|
|
536
|
+
if (o.watchFor !== undefined && same(res.last.raw, o.watchFor)) {
|
|
537
|
+
res.sawTest = true;
|
|
538
|
+
res.lastStatus = lastReadStatus;
|
|
539
|
+
return res;
|
|
540
|
+
}
|
|
541
|
+
if (res.hit && !o.fullWindow) {
|
|
542
|
+
res.lastStatus = lastReadStatus;
|
|
543
|
+
return res;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
catch (e) {
|
|
547
|
+
res.hit = false;
|
|
548
|
+
lastCause = e instanceof StepError ? `The last ${e.message}.` : 'The connection to the SAP link dropped.';
|
|
549
|
+
}
|
|
550
|
+
res.lastStatus = lastReadStatus;
|
|
551
|
+
if (Date.now() >= deadline)
|
|
552
|
+
return res;
|
|
553
|
+
await sleep(timing.intervalMs);
|
|
554
|
+
}
|
|
555
|
+
};
|
|
556
|
+
const restoreCauses = [];
|
|
557
|
+
const sendRestore = async () => {
|
|
558
|
+
try {
|
|
559
|
+
const s = await update(beforeRaw, etag);
|
|
560
|
+
if (!ok2xx(s))
|
|
561
|
+
restoreCauses.push(`The restore answered HTTP ${s}.`);
|
|
562
|
+
return s;
|
|
563
|
+
}
|
|
564
|
+
catch {
|
|
565
|
+
restoreCauses.push('The restore got no answer.');
|
|
566
|
+
return null;
|
|
567
|
+
}
|
|
568
|
+
};
|
|
569
|
+
const seen = await poll(w.testValue);
|
|
570
|
+
statuses.reread = seen.lastStatus;
|
|
571
|
+
statuses.restore = await sendRestore();
|
|
572
|
+
let back;
|
|
573
|
+
let testValueSeen = seen.hit;
|
|
574
|
+
let secondRestore = false;
|
|
575
|
+
if (seen.hit) {
|
|
576
|
+
back = await poll(beforeRaw);
|
|
577
|
+
}
|
|
578
|
+
else {
|
|
579
|
+
// Never seen: the test write may still be pending. Watch the whole window.
|
|
580
|
+
back = await poll(beforeRaw, { fullWindow: true, watchFor: w.testValue });
|
|
581
|
+
if (back.sawTest) {
|
|
582
|
+
testValueSeen = true;
|
|
583
|
+
secondRestore = true;
|
|
584
|
+
statuses.restore2 = await sendRestore();
|
|
585
|
+
// Watch the whole window again; if the test value shows once more, stop: no third restore.
|
|
586
|
+
back = await poll(beforeRaw, { fullWindow: true, watchFor: w.testValue });
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
statuses.final = back.lastStatus;
|
|
590
|
+
const secs = `${Math.round(timing.timeoutMs / 100) / 10} s`;
|
|
591
|
+
const evWrite = writeEv({
|
|
592
|
+
before,
|
|
593
|
+
after: seen.last ? scrub(seen.last.raw) : null,
|
|
594
|
+
restored: back.last ? scrub(back.last.raw) : null,
|
|
595
|
+
testValueSeen,
|
|
596
|
+
originalConfirmed: back.hit,
|
|
597
|
+
secondRestore,
|
|
598
|
+
reads: { afterUpdate: seen.reads, afterRestore: back.reads },
|
|
599
|
+
...(unknownCause ? { updateOutcome: 'unknown' } : {}),
|
|
600
|
+
});
|
|
601
|
+
const unknownPrefix = unknownCause ? `the test update of ${w.field} ${unknownCause}: outcome unknown. ` : '';
|
|
602
|
+
const restoreRefused = restoreCauses.length > 0;
|
|
603
|
+
const causes = [...restoreCauses, back.hit ? '' : lastCause].filter(Boolean).join(' ');
|
|
604
|
+
const tail = `(Test value: "${w.testValue}".)${causes ? ` ${causes}` : ''}`;
|
|
605
|
+
if (!back.hit) {
|
|
606
|
+
const state = back.last
|
|
607
|
+
? `The last read says ${w.field} is still "${scrub(back.last.raw)}"; its original value was "${before}".`
|
|
608
|
+
: `No read succeeded after the restore. If ${w.field} is still "${w.testValue}", its original value was "${before}".`;
|
|
609
|
+
return red(`${who}: ${unknownPrefix}outcome unknown. The restore of ${w.field} to its original value "${before}" was not confirmed within ${secs}. ` +
|
|
610
|
+
`${state} Check the record in SAP and put it back if needed. ${tail}`, { write: evWrite, writeLog: logRows() });
|
|
611
|
+
}
|
|
612
|
+
if (restoreRefused) {
|
|
613
|
+
// A read shows the original, but a restore was refused or unanswered: the test
|
|
614
|
+
// write may still be pending (e.g. the update task holds the lock) and land later.
|
|
615
|
+
return red(`${who}: ${unknownPrefix}outcome unknown. A read shows ${w.field} = "${before}", but a restore did not answer 2xx, ` +
|
|
616
|
+
`so the test value "${w.testValue}" may still land. Check the record in SAP: its original value was "${before}". ${tail}`, { write: evWrite, writeLog: logRows() });
|
|
617
|
+
}
|
|
618
|
+
if (secondRestore) {
|
|
619
|
+
return red(`${who}: ${unknownPrefix}the test value "${w.testValue}" showed only after the first restore, so a second restore was sent ` +
|
|
620
|
+
`and a read confirmed ${w.field} = "${before}". Check the record in SAP: its original value was "${before}". ${tail}`, { write: evWrite, writeLog: logRows() });
|
|
621
|
+
}
|
|
622
|
+
if (!seen.hit) {
|
|
623
|
+
return red(`${who}: ${unknownPrefix}the test write could not be confirmed within ${secs}: no read showed ${w.field} = "${w.testValue}". ` +
|
|
624
|
+
`The restore to the original value "${before}" answered OK, and reads for another ${secs} showed ${w.field} = "${before}". ` +
|
|
625
|
+
`SAP may apply updates late, so check the record in SAP: its original value was "${before}".`, { write: evWrite, writeLog: logRows() });
|
|
626
|
+
}
|
|
627
|
+
if (unknownCause) {
|
|
628
|
+
return red(`${who}: ${unknownPrefix}A later read showed ${w.field} = "${w.testValue}", and after the restore a read confirmed ` +
|
|
629
|
+
`${w.field} = "${before}", its original value. ${tail} Check the record in SAP.`, { write: evWrite, writeLog: logRows() });
|
|
630
|
+
}
|
|
631
|
+
const file = finish(true, { write: evWrite, writeLog: logRows() });
|
|
632
|
+
lines.push(`write: green — ${scrub(w.key)}.${w.field} "${before}" → "${w.testValue}" → restored "${before}", confirmed by a read (through the SAP link)`);
|
|
633
|
+
if (file)
|
|
634
|
+
lines.push(`Evidence: ${file}`);
|
|
635
|
+
return { content: lines.join('\n') };
|
|
636
|
+
}
|
|
637
|
+
/** Poll timings, never infinite: anything not a finite number >= 0 falls back to the default. */
|
|
638
|
+
export function pollTimings(intervalMs, timeoutMs) {
|
|
639
|
+
const ok = (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0;
|
|
640
|
+
return { intervalMs: ok(intervalMs) ? intervalMs : POLL_INTERVAL_MS, timeoutMs: ok(timeoutMs) ? timeoutMs : POLL_TIMEOUT_MS };
|
|
641
|
+
}
|
|
642
|
+
/** An error whose message is already plain and safe to show. */
|
|
643
|
+
class PlainError extends Error {
|
|
644
|
+
}
|
|
645
|
+
/** A write-leg step failure; the message holds a status code only. */
|
|
646
|
+
class StepError extends Error {
|
|
647
|
+
}
|
|
648
|
+
registerTool({
|
|
649
|
+
name: 'cap_link_smoke',
|
|
650
|
+
description: 'S/4 link check for a local CAP app (CAP phase 2). READ: the app\'s S/4 entity shows rows and a direct S/4 read of ' +
|
|
651
|
+
'the same keys matches compareFields (mode live), or the app serves rows (mode mock — reported as "mock only, ' +
|
|
652
|
+
'not proven against S/4"). WRITE (live only, optional): through the SAP link, change one harmless field on one test ' +
|
|
653
|
+
'record, re-read, restore it, re-read — only when the alias role is exactly dev. NEEDS APPROVAL for the write: ' +
|
|
654
|
+
'request_approval with change {op:"modify", object:"<ALIAS>:<servicePath>/<EntitySet> record <key> [<HEX>]", type:"DATA"}, ' +
|
|
655
|
+
'where HEX is computed by the tool: call it once without approval_id and it answers with the exact object string. ' +
|
|
656
|
+
'One approval runs one smoke, for that one record; an auto-approval does not count. Needs the sap_link tool started ' +
|
|
657
|
+
'in this session for the project on the same SAP system.',
|
|
658
|
+
isMutating: true,
|
|
659
|
+
requiresSap: false,
|
|
660
|
+
category: 'fiori',
|
|
661
|
+
flagGated: true,
|
|
662
|
+
input_schema: {
|
|
663
|
+
type: 'object',
|
|
664
|
+
properties: {
|
|
665
|
+
projectDir: { type: 'string', description: 'CAP project root, relative to the workspace. Default ".".' },
|
|
666
|
+
mode: { type: 'string', enum: ['live', 'mock'] },
|
|
667
|
+
app: {
|
|
668
|
+
type: 'object',
|
|
669
|
+
properties: {
|
|
670
|
+
url: { type: 'string', description: 'Local CAP server root, e.g. http://localhost:4004' },
|
|
671
|
+
servicePath: { type: 'string', description: 'e.g. /odata/v4/review/' },
|
|
672
|
+
entitySet: { type: 'string' },
|
|
673
|
+
keyField: { type: 'string' },
|
|
674
|
+
capUser: { type: 'string', description: 'CAP mock user, e.g. alice' },
|
|
675
|
+
},
|
|
676
|
+
required: ['url', 'servicePath', 'entitySet', 'keyField'],
|
|
677
|
+
},
|
|
678
|
+
s4: {
|
|
679
|
+
type: 'object',
|
|
680
|
+
properties: {
|
|
681
|
+
servicePath: { type: 'string', description: 'Gateway path, e.g. /sap/opu/odata/sap/API_BUSINESS_PARTNER' },
|
|
682
|
+
entitySet: { type: 'string' },
|
|
683
|
+
keyField: { type: 'string' },
|
|
684
|
+
compareFields: { type: 'array', items: { type: 'string' } },
|
|
685
|
+
odata: { type: 'string', enum: ['v2', 'v4'] },
|
|
686
|
+
},
|
|
687
|
+
required: ['servicePath', 'entitySet', 'keyField', 'compareFields'],
|
|
688
|
+
},
|
|
689
|
+
write: {
|
|
690
|
+
type: 'object',
|
|
691
|
+
properties: {
|
|
692
|
+
key: { type: 'string' },
|
|
693
|
+
field: { type: 'string' },
|
|
694
|
+
testValue: { type: 'string', description: 'At most 60 printable characters.' },
|
|
695
|
+
},
|
|
696
|
+
required: ['key', 'field', 'testValue'],
|
|
697
|
+
},
|
|
698
|
+
approval_id: { type: 'string' },
|
|
699
|
+
},
|
|
700
|
+
required: ['mode', 'app', 's4'],
|
|
701
|
+
},
|
|
702
|
+
handler: (args, ctx) => capLinkSmokeHandler(args, ctx),
|
|
703
|
+
});
|