@tenonhq/sincronia-core 0.0.79 → 0.0.81
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/MultiScopeWatcher.js +43 -27
- package/dist/appUtils.js +106 -10
- package/dist/snClient.js +49 -6
- package/dist/tests/syncManifestWhitelist.test.js +192 -0
- package/dist/tests/unwrapSNResponseError.test.js +145 -0
- package/package.json +1 -1
|
@@ -55,7 +55,6 @@ class MultiScopeWatcherManager {
|
|
|
55
55
|
updateSetCheckInterval = null;
|
|
56
56
|
scopeLock = Promise.resolve();
|
|
57
57
|
cachedScope = null;
|
|
58
|
-
cachedUserSysId = null;
|
|
59
58
|
pendingScopes = new Map(); // scope -> first change timestamp
|
|
60
59
|
globalProcessQueue = null;
|
|
61
60
|
async startWatchingAllScopes(options) {
|
|
@@ -206,7 +205,41 @@ class MultiScopeWatcherManager {
|
|
|
206
205
|
}
|
|
207
206
|
var activeTask = this.readActiveTask();
|
|
208
207
|
if (!activeTask) {
|
|
209
|
-
|
|
208
|
+
// No active task — try to resolve the current update set from the ServiceNow
|
|
209
|
+
// session so pushWithUpdateSet can be used instead of the fallback updateRecord.
|
|
210
|
+
try {
|
|
211
|
+
var { defaultClient } = await Promise.resolve().then(() => __importStar(require("./snClient")));
|
|
212
|
+
var sessionClient = defaultClient();
|
|
213
|
+
var curResp = await sessionClient.getCurrentUpdateSet(scopeName);
|
|
214
|
+
var curData = curResp.data;
|
|
215
|
+
if (curData && curData.result) {
|
|
216
|
+
curData = curData.result;
|
|
217
|
+
}
|
|
218
|
+
var curSysId = curData && curData.sysId ? curData.sysId : null;
|
|
219
|
+
var curName = curData && curData.name ? curData.name : null;
|
|
220
|
+
if (curSysId && curName) {
|
|
221
|
+
var isDefault = curName === "Default" || curName.toLowerCase().indexOf("default") !== -1;
|
|
222
|
+
if (isDefault) {
|
|
223
|
+
Logger_1.logger.warn(`[${scopeName}] No update set configured and current update set is Default. ` +
|
|
224
|
+
`Use sinc createUpdateSet or activate a task in the dashboard.`);
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
// Use the session's current non-Default update set
|
|
228
|
+
config = this.getUpdateSetConfig();
|
|
229
|
+
config[scopeName] = { sys_id: curSysId, name: curName };
|
|
230
|
+
this.saveUpdateSetConfig(config);
|
|
231
|
+
Logger_1.logger.info(`[${scopeName}] Using session update set: ${curName}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
Logger_1.logger.warn(`[${scopeName}] No update set configured for scope ${scopeName}. ` +
|
|
236
|
+
`Use sinc createUpdateSet or activate a task in the dashboard.`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
catch (queryErr) {
|
|
240
|
+
Logger_1.logger.warn(`[${scopeName}] No update set configured and could not query current update set. ` +
|
|
241
|
+
`Changes will use direct Table API. Use sinc createUpdateSet or activate a task in the dashboard.`);
|
|
242
|
+
}
|
|
210
243
|
return;
|
|
211
244
|
}
|
|
212
245
|
var taskId = activeTask.taskId;
|
|
@@ -450,31 +483,14 @@ class MultiScopeWatcherManager {
|
|
|
450
483
|
return;
|
|
451
484
|
}
|
|
452
485
|
try {
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
//
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
if (!this.cachedUserSysId) {
|
|
462
|
-
const userResponse = await unwrapSNResponse(client.getUserSysId());
|
|
463
|
-
if (!userResponse || !Array.isArray(userResponse) || userResponse.length === 0 || !userResponse[0].sys_id) {
|
|
464
|
-
throw new Error("Could not get user sys_id");
|
|
465
|
-
}
|
|
466
|
-
this.cachedUserSysId = userResponse[0].sys_id;
|
|
467
|
-
}
|
|
468
|
-
// Get current app preference
|
|
469
|
-
const prefResponse = await unwrapSNResponse(client.getCurrentAppUserPrefSysId(this.cachedUserSysId));
|
|
470
|
-
if (prefResponse && Array.isArray(prefResponse) && prefResponse.length > 0 && prefResponse[0].sys_id) {
|
|
471
|
-
// Update existing preference
|
|
472
|
-
await client.updateCurrentAppUserPref(scopeResponse[0].sys_id, prefResponse[0].sys_id);
|
|
473
|
-
}
|
|
474
|
-
else {
|
|
475
|
-
// Create new preference
|
|
476
|
-
await client.createCurrentAppUserPref(scopeResponse[0].sys_id, this.cachedUserSysId);
|
|
477
|
-
}
|
|
486
|
+
var { defaultClient } = await Promise.resolve().then(() => __importStar(require("./snClient")));
|
|
487
|
+
var client = defaultClient();
|
|
488
|
+
// Use the Claude REST API changeScope endpoint to switch the session scope.
|
|
489
|
+
// This uses gs.setCurrentApplicationId() server-side, which correctly changes
|
|
490
|
+
// the REST API session scope. The previous approach used user preference updates
|
|
491
|
+
// (apps.current_app) which only writes a DB record and does NOT affect the
|
|
492
|
+
// active REST session — causing updateRecord() to operate in the wrong scope.
|
|
493
|
+
await client.changeScope(scopeName);
|
|
478
494
|
this.cachedScope = scopeName;
|
|
479
495
|
Logger_1.logger.debug(`Switched to scope: ${scopeName}`);
|
|
480
496
|
}
|
package/dist/appUtils.js
CHANGED
|
@@ -106,9 +106,37 @@ const countRecordsInTables = (tables) => {
|
|
|
106
106
|
return sum + Object.keys(tables[tableName].records).length;
|
|
107
107
|
}, 0);
|
|
108
108
|
};
|
|
109
|
-
const processTablesInManifest = async (tables, forceWrite, sourcePath, onRecordProcessed) => {
|
|
109
|
+
const processTablesInManifest = async (tables, forceWrite, sourcePath, onRecordProcessed, scope) => {
|
|
110
110
|
var basePath = sourcePath || ConfigManager.getSourcePath();
|
|
111
|
-
|
|
111
|
+
var tableNames = Object.keys(tables);
|
|
112
|
+
// Defense-in-depth: filter out any table not in the resolved whitelist before
|
|
113
|
+
// writing to disk. Protects against upstream defects that let non-whitelisted
|
|
114
|
+
// tables through (e.g. server-side manifest fanout, stale manifest entries).
|
|
115
|
+
if (scope) {
|
|
116
|
+
try {
|
|
117
|
+
var resolved = ConfigManager.resolveConfigForScope(scope);
|
|
118
|
+
var allowed = resolved.tables;
|
|
119
|
+
if (allowed && allowed.length > 0) {
|
|
120
|
+
var skipped = [];
|
|
121
|
+
tableNames = tableNames.filter(function (t) {
|
|
122
|
+
if (allowed.indexOf(t) === -1) {
|
|
123
|
+
skipped.push(t);
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
return true;
|
|
127
|
+
});
|
|
128
|
+
if (skipped.length > 0) {
|
|
129
|
+
FileLogger_1.fileLogger.debug("processTablesInManifest: dropped " + skipped.length +
|
|
130
|
+
" non-whitelisted tables for scope '" + scope + "': " + skipped.join(", "));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch (e) {
|
|
135
|
+
// Config resolution can fail for legacy single-scope manifests; fall
|
|
136
|
+
// through and process whatever the manifest contains.
|
|
137
|
+
FileLogger_1.fileLogger.debug("processTablesInManifest: could not resolve whitelist for scope '" + scope + "'");
|
|
138
|
+
}
|
|
139
|
+
}
|
|
112
140
|
await (0, genericUtils_1.processBatched)(tableNames, constants_1.CONCURRENCY_TABLES, function (tableName) {
|
|
113
141
|
return processRecsInManTable(path_1.default.join(basePath, tableName), tables[tableName], forceWrite, onRecordProcessed);
|
|
114
142
|
});
|
|
@@ -149,7 +177,7 @@ const processManifest = async (manifest, forceWrite = false, sourcePath) => {
|
|
|
149
177
|
scope: manifest.scope || "default",
|
|
150
178
|
total: recordCount,
|
|
151
179
|
});
|
|
152
|
-
await processTablesInManifest(manifest.tables, forceWrite, sourcePath, progress.tick);
|
|
180
|
+
await processTablesInManifest(manifest.tables, forceWrite, sourcePath, progress.tick, manifest.scope);
|
|
153
181
|
if (manifest.scope) {
|
|
154
182
|
await fUtils.writeScopeManifest(manifest.scope, manifest);
|
|
155
183
|
}
|
|
@@ -163,14 +191,51 @@ const syncManifest = async (scope) => {
|
|
|
163
191
|
const curManifest = await ConfigManager.getManifest();
|
|
164
192
|
if (!curManifest)
|
|
165
193
|
throw new Error("No manifest file loaded!");
|
|
194
|
+
const config = ConfigManager.getConfig();
|
|
195
|
+
const declaredScopes = (config.scopes && Object.keys(config.scopes)) || [];
|
|
166
196
|
// If a specific scope is provided, sync only that scope
|
|
167
197
|
if (scope) {
|
|
198
|
+
// Scope whitelist gate: refuse to refresh scopes not declared in sinc.config.js.
|
|
199
|
+
// Without this, stale entries in sinc.manifest.json leak undeclared scopes into
|
|
200
|
+
// the refresh loop (see RFC-0004 / sys_alias debris incident 2026-04-14).
|
|
201
|
+
if (declaredScopes.length > 0 && declaredScopes.indexOf(scope) === -1) {
|
|
202
|
+
Logger_1.logger.warn("Skipping scope '" + scope + "' — not declared in sinc.config.js `scopes`. " +
|
|
203
|
+
"Add it to config.scopes to sync, or remove its manifest file.");
|
|
204
|
+
FileLogger_1.fileLogger.debug("syncManifest: skipped undeclared scope '" + scope + "'");
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
168
207
|
Logger_1.logger.info("Refreshing scope: " + scope + "...");
|
|
169
208
|
const client = (0, snClient_1.defaultClient)();
|
|
170
|
-
|
|
171
|
-
// Resolve scope-specific source directory
|
|
209
|
+
// Resolve scope-specific source directory + table whitelist
|
|
172
210
|
var scopeSourcePath = ConfigManager.getSourcePathForScope(scope);
|
|
211
|
+
var resolvedConfig = ConfigManager.resolveConfigForScope(scope);
|
|
212
|
+
var allowedTables = resolvedConfig.tables;
|
|
173
213
|
const newManifest = (0, exports.normalizeManifestKeys)(await (0, snClient_1.unwrapSNResponse)(client.getManifest(scope, config)));
|
|
214
|
+
// Table whitelist gate: drop any table the server returned that is not in
|
|
215
|
+
// the resolved _tables whitelist for this scope. Mirrors the filter in
|
|
216
|
+
// commands.ts downloadCommand() and allScopesCommands.ts processScope().
|
|
217
|
+
if (allowedTables && allowedTables.length > 0) {
|
|
218
|
+
var manifestTableNames = Object.keys(newManifest.tables || {});
|
|
219
|
+
var filteredTables = {};
|
|
220
|
+
var skippedCount = 0;
|
|
221
|
+
for (var t = 0; t < manifestTableNames.length; t++) {
|
|
222
|
+
var tName = manifestTableNames[t];
|
|
223
|
+
if (allowedTables.indexOf(tName) !== -1) {
|
|
224
|
+
filteredTables[tName] = newManifest.tables[tName];
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
skippedCount++;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (skippedCount > 0) {
|
|
231
|
+
FileLogger_1.fileLogger.debug("syncManifest: filtered " + skippedCount + " tables not in _tables whitelist for " +
|
|
232
|
+
scope + " (kept " + Object.keys(filteredTables).length + " of " + manifestTableNames.length + ")");
|
|
233
|
+
}
|
|
234
|
+
newManifest.tables = filteredTables;
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
Logger_1.logger.warn("No _tables whitelist defined — writing ALL tables for " + scope);
|
|
238
|
+
}
|
|
174
239
|
const refreshTableCount = Object.keys(newManifest.tables).length;
|
|
175
240
|
FileLogger_1.fileLogger.debug("Refreshed manifest for " + scope + ": " + refreshTableCount + " tables");
|
|
176
241
|
await fUtils.writeScopeManifest(scope, newManifest);
|
|
@@ -182,9 +247,16 @@ const syncManifest = async (scope) => {
|
|
|
182
247
|
}
|
|
183
248
|
}
|
|
184
249
|
else {
|
|
185
|
-
// Sync all scopes
|
|
186
|
-
|
|
187
|
-
|
|
250
|
+
// Sync all scopes. Prefer the declared-scopes list (config.scopes) over
|
|
251
|
+
// the persisted manifest keys — the manifest may contain stale undeclared
|
|
252
|
+
// scopes that leaked in before the whitelist gate existed.
|
|
253
|
+
if (declaredScopes.length > 0) {
|
|
254
|
+
for (var d = 0; d < declaredScopes.length; d++) {
|
|
255
|
+
await (0, exports.syncManifest)(declaredScopes[d]);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
else if (ConfigManager.isMultiScopeManifest(curManifest)) {
|
|
259
|
+
// No declared scopes — fall back to the persisted manifest's scopes.
|
|
188
260
|
for (const scopeName of Object.keys(curManifest)) {
|
|
189
261
|
await (0, exports.syncManifest)(scopeName);
|
|
190
262
|
}
|
|
@@ -274,6 +346,11 @@ const findMissingFiles = async (manifest, sourcePath) => {
|
|
|
274
346
|
return missing;
|
|
275
347
|
};
|
|
276
348
|
exports.findMissingFiles = findMissingFiles;
|
|
349
|
+
// Chunk bulkDownload by table to stay under ServiceNow's 10 MB REST payload cap.
|
|
350
|
+
// A single unchunked call 500s on large scopes (e.g. x_cadso_automate at ~29 MB).
|
|
351
|
+
// Must mirror the chunk size used by allScopesCommands.ts (watch path) so behaviour
|
|
352
|
+
// is consistent across `refresh` and `watch`.
|
|
353
|
+
const BULK_DOWNLOAD_TABLE_CHUNK_SIZE = 5;
|
|
277
354
|
const processMissingFiles = async (newManifest, sourcePath) => {
|
|
278
355
|
try {
|
|
279
356
|
const missing = await (0, exports.findMissingFiles)(newManifest, sourcePath);
|
|
@@ -283,13 +360,32 @@ const processMissingFiles = async (newManifest, sourcePath) => {
|
|
|
283
360
|
FileLogger_1.fileLogger.debug("Downloading missing files from " + missingTableCount + " tables");
|
|
284
361
|
const { tableOptions = {} } = ConfigManager.getConfig();
|
|
285
362
|
const client = (0, snClient_1.defaultClient)();
|
|
286
|
-
|
|
363
|
+
// Chunk the bulkDownload request: ServiceNow rejects REST payloads > 10 MB,
|
|
364
|
+
// so send table batches and merge the results before processing.
|
|
365
|
+
const tableNames = Object.keys(missing);
|
|
366
|
+
const totalChunks = Math.ceil(tableNames.length / BULK_DOWNLOAD_TABLE_CHUNK_SIZE);
|
|
367
|
+
const filesToProcess = {};
|
|
368
|
+
for (var i = 0; i < tableNames.length; i += BULK_DOWNLOAD_TABLE_CHUNK_SIZE) {
|
|
369
|
+
const chunkTableNames = tableNames.slice(i, i + BULK_DOWNLOAD_TABLE_CHUNK_SIZE);
|
|
370
|
+
const chunkMissing = {};
|
|
371
|
+
for (var j = 0; j < chunkTableNames.length; j++) {
|
|
372
|
+
chunkMissing[chunkTableNames[j]] = missing[chunkTableNames[j]];
|
|
373
|
+
}
|
|
374
|
+
const batchNum = Math.floor(i / BULK_DOWNLOAD_TABLE_CHUNK_SIZE) + 1;
|
|
375
|
+
FileLogger_1.fileLogger.debug("Bulk download batch " + batchNum + "/" + totalChunks +
|
|
376
|
+
" (" + chunkTableNames.length + " tables): " + chunkTableNames.join(", "));
|
|
377
|
+
const chunkResult = await (0, snClient_1.unwrapSNResponse)(client.getMissingFiles(chunkMissing, tableOptions));
|
|
378
|
+
// Chunks are partitioned by table key, so merging is a simple assign.
|
|
379
|
+
for (var tableName in chunkResult) {
|
|
380
|
+
filesToProcess[tableName] = chunkResult[tableName];
|
|
381
|
+
}
|
|
382
|
+
}
|
|
287
383
|
var recordCount = countRecordsInTables(filesToProcess);
|
|
288
384
|
var progress = createScopeProgress(Logger_1.logger.getLogLevel(), {
|
|
289
385
|
scope: newManifest.scope || "default",
|
|
290
386
|
total: recordCount,
|
|
291
387
|
});
|
|
292
|
-
await processTablesInManifest(filesToProcess, false, sourcePath, progress.tick);
|
|
388
|
+
await processTablesInManifest(filesToProcess, false, sourcePath, progress.tick, newManifest.scope);
|
|
293
389
|
}
|
|
294
390
|
catch (e) {
|
|
295
391
|
throw e;
|
package/dist/snClient.js
CHANGED
|
@@ -369,13 +369,56 @@ const unwrapSNResponse = async (clientPromise) => {
|
|
|
369
369
|
return resp.data.result;
|
|
370
370
|
}
|
|
371
371
|
catch (e) {
|
|
372
|
-
let message;
|
|
373
|
-
if (e instanceof Error)
|
|
374
|
-
message = e.message;
|
|
375
|
-
else
|
|
376
|
-
message = String(e);
|
|
377
372
|
const instance = process.env.SN_INSTANCE || "unknown";
|
|
378
|
-
|
|
373
|
+
if (axios_1.default.isAxiosError(e) && e.response) {
|
|
374
|
+
const status = e.response.status;
|
|
375
|
+
const statusText = e.response.statusText || "";
|
|
376
|
+
const method = ((e.config && e.config.method) || "").toUpperCase();
|
|
377
|
+
const url = (e.config && e.config.url) || "";
|
|
378
|
+
const data = e.response.data;
|
|
379
|
+
// Extract ServiceNow-shaped error message if present (`{error: {message, detail}, status}`)
|
|
380
|
+
let snMessage = "";
|
|
381
|
+
if (data && typeof data === "object") {
|
|
382
|
+
if (data.error && typeof data.error === "object" && data.error.message) {
|
|
383
|
+
snMessage = " — " + String(data.error.message);
|
|
384
|
+
}
|
|
385
|
+
else if (typeof data.message === "string") {
|
|
386
|
+
snMessage = " — " + data.message;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
// Pull scope out of /getManifest/:scope URLs for easy grepping
|
|
390
|
+
let scope;
|
|
391
|
+
const manifestMatch = url.match(/\/getManifest\/([^/?]+)/);
|
|
392
|
+
if (manifestMatch)
|
|
393
|
+
scope = manifestMatch[1];
|
|
394
|
+
Logger_1.logger.error("Error from " + instance + ": HTTP " + status + " " +
|
|
395
|
+
method + " " + url + snMessage);
|
|
396
|
+
FileLogger_1.fileLogger.debug("REST error detail", {
|
|
397
|
+
instance: instance,
|
|
398
|
+
scope: scope,
|
|
399
|
+
method: method,
|
|
400
|
+
url: url,
|
|
401
|
+
status: status,
|
|
402
|
+
statusText: statusText,
|
|
403
|
+
responseData: data,
|
|
404
|
+
responseHeaders: e.response.headers,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
else {
|
|
408
|
+
// Non-Axios error: preserve today's behaviour, add debug detail
|
|
409
|
+
let message;
|
|
410
|
+
if (e instanceof Error)
|
|
411
|
+
message = e.message;
|
|
412
|
+
else
|
|
413
|
+
message = String(e);
|
|
414
|
+
Logger_1.logger.error("Error from " + instance + ": " + message);
|
|
415
|
+
FileLogger_1.fileLogger.debug("Non-Axios error detail", {
|
|
416
|
+
instance: instance,
|
|
417
|
+
errorName: e instanceof Error ? e.name : undefined,
|
|
418
|
+
errorStack: e instanceof Error ? e.stack : undefined,
|
|
419
|
+
message: message,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
379
422
|
throw e;
|
|
380
423
|
}
|
|
381
424
|
};
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Tests for the scope + table whitelist gates in syncManifest() and
|
|
4
|
+
* processTablesInManifest().
|
|
5
|
+
*
|
|
6
|
+
* Regression target: 2026-04-14 sys_alias debris incident.
|
|
7
|
+
* - `npx sinc refresh` iterated every scope in the persisted multi-scope
|
|
8
|
+
* manifest, including scopes not declared in sinc.config.js.
|
|
9
|
+
* - For each of those scopes, the server-side getManifest response returned
|
|
10
|
+
* hundreds of tables (up to 129 for x_cadso_work) that the config _tables
|
|
11
|
+
* whitelist never authorised, and the client wrote all of them to disk —
|
|
12
|
+
* including sys_alias/sys_alias_templates folders with 314 field files per
|
|
13
|
+
* record (fan-out of every script/html/css/xml field config across tables).
|
|
14
|
+
*
|
|
15
|
+
* These tests assert the two defensive filters:
|
|
16
|
+
* 1. Undeclared scopes are skipped before any REST call.
|
|
17
|
+
* 2. Tables not in the _tables whitelist are filtered from the manifest
|
|
18
|
+
* before writeScopeManifest / processMissingFiles run.
|
|
19
|
+
*/
|
|
20
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
21
|
+
if (k2 === undefined) k2 = k;
|
|
22
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
23
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
24
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
25
|
+
}
|
|
26
|
+
Object.defineProperty(o, k2, desc);
|
|
27
|
+
}) : (function(o, m, k, k2) {
|
|
28
|
+
if (k2 === undefined) k2 = k;
|
|
29
|
+
o[k2] = m[k];
|
|
30
|
+
}));
|
|
31
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
32
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
33
|
+
}) : function(o, v) {
|
|
34
|
+
o["default"] = v;
|
|
35
|
+
});
|
|
36
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
37
|
+
var ownKeys = function(o) {
|
|
38
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
39
|
+
var ar = [];
|
|
40
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
41
|
+
return ar;
|
|
42
|
+
};
|
|
43
|
+
return ownKeys(o);
|
|
44
|
+
};
|
|
45
|
+
return function (mod) {
|
|
46
|
+
if (mod && mod.__esModule) return mod;
|
|
47
|
+
var result = {};
|
|
48
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
49
|
+
__setModuleDefault(result, mod);
|
|
50
|
+
return result;
|
|
51
|
+
};
|
|
52
|
+
})();
|
|
53
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
54
|
+
var mockLogger = {
|
|
55
|
+
info: jest.fn(),
|
|
56
|
+
debug: jest.fn(),
|
|
57
|
+
warn: jest.fn(),
|
|
58
|
+
error: jest.fn(),
|
|
59
|
+
success: jest.fn(),
|
|
60
|
+
getLogLevel: function () { return "info"; },
|
|
61
|
+
};
|
|
62
|
+
var mockFileLogger = {
|
|
63
|
+
debug: jest.fn(),
|
|
64
|
+
info: jest.fn(),
|
|
65
|
+
warn: jest.fn(),
|
|
66
|
+
error: jest.fn(),
|
|
67
|
+
};
|
|
68
|
+
var mockClient = {
|
|
69
|
+
getManifest: jest.fn(),
|
|
70
|
+
};
|
|
71
|
+
var mockFUtils = {
|
|
72
|
+
writeScopeManifest: jest.fn().mockResolvedValue(undefined),
|
|
73
|
+
writeFileForce: jest.fn().mockResolvedValue(undefined),
|
|
74
|
+
writeSNFileCurry: jest.fn(() => jest.fn().mockResolvedValue(undefined)),
|
|
75
|
+
createDirRecursively: jest.fn().mockResolvedValue(undefined),
|
|
76
|
+
};
|
|
77
|
+
jest.mock("../Logger", function () { return { logger: mockLogger }; });
|
|
78
|
+
jest.mock("../FileLogger", function () { return { fileLogger: mockFileLogger }; });
|
|
79
|
+
jest.mock("../FileUtils", function () { return mockFUtils; });
|
|
80
|
+
jest.mock("../snClient", function () {
|
|
81
|
+
return {
|
|
82
|
+
defaultClient: function () { return mockClient; },
|
|
83
|
+
unwrapSNResponse: function (p) { return Promise.resolve(p).then(function (r) { return r; }); },
|
|
84
|
+
processPushResponse: jest.fn(),
|
|
85
|
+
retryOnErr: jest.fn(),
|
|
86
|
+
retryOnHttpErr: jest.fn(),
|
|
87
|
+
unwrapTableAPIFirstItem: jest.fn(),
|
|
88
|
+
};
|
|
89
|
+
});
|
|
90
|
+
// Mock config module. Individual tests override getConfig/getManifest/etc.
|
|
91
|
+
var mockConfig = {
|
|
92
|
+
getConfig: jest.fn(),
|
|
93
|
+
getManifest: jest.fn(),
|
|
94
|
+
getSourcePathForScope: jest.fn().mockReturnValue("/tmp/src"),
|
|
95
|
+
getSourcePath: jest.fn().mockReturnValue("/tmp/src"),
|
|
96
|
+
getManifestPath: jest.fn().mockReturnValue("/tmp/sinc.manifest.json"),
|
|
97
|
+
resolveConfigForScope: jest.fn(),
|
|
98
|
+
isMultiScopeManifest: jest.fn().mockReturnValue(true),
|
|
99
|
+
updateManifest: jest.fn(),
|
|
100
|
+
};
|
|
101
|
+
jest.mock("../config", function () { return mockConfig; });
|
|
102
|
+
// Prevent processMissingFiles' progress bar from touching stdout in tests.
|
|
103
|
+
jest.mock("progress", function () {
|
|
104
|
+
return jest.fn().mockImplementation(function () {
|
|
105
|
+
return { tick: jest.fn() };
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
// Keep the per-scope progress shim simple.
|
|
109
|
+
jest.mock("../genericUtils", function () {
|
|
110
|
+
var actual = jest.requireActual("../genericUtils");
|
|
111
|
+
return actual;
|
|
112
|
+
});
|
|
113
|
+
const AppUtils = __importStar(require("../appUtils"));
|
|
114
|
+
describe("syncManifest — scope + table whitelist gates", function () {
|
|
115
|
+
beforeEach(function () {
|
|
116
|
+
jest.clearAllMocks();
|
|
117
|
+
mockConfig.isMultiScopeManifest.mockReturnValue(true);
|
|
118
|
+
mockConfig.resolveConfigForScope.mockImplementation(function (_scope) {
|
|
119
|
+
return {
|
|
120
|
+
tables: ["sys_script_include", "sys_script", "sys_ux_macroponent"],
|
|
121
|
+
fieldOverrides: {},
|
|
122
|
+
apiIncludes: {},
|
|
123
|
+
apiExcludes: {},
|
|
124
|
+
};
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
test("skips undeclared scope — no REST call, warn logged", async function () {
|
|
128
|
+
mockConfig.getConfig.mockReturnValue({
|
|
129
|
+
scopes: { x_cadso_core: {}, x_cadso_work: {} },
|
|
130
|
+
});
|
|
131
|
+
mockConfig.getManifest.mockResolvedValue({
|
|
132
|
+
x_cadso_core: { scope: "x_cadso_core", tables: {} },
|
|
133
|
+
x_cadso_click: { scope: "x_cadso_click", tables: {} }, // stale undeclared
|
|
134
|
+
});
|
|
135
|
+
await AppUtils.syncManifest("x_cadso_click");
|
|
136
|
+
expect(mockClient.getManifest).not.toHaveBeenCalled();
|
|
137
|
+
expect(mockFUtils.writeScopeManifest).not.toHaveBeenCalled();
|
|
138
|
+
var warnedAboutScope = mockLogger.warn.mock.calls.some(function (args) {
|
|
139
|
+
return typeof args[0] === "string" && args[0].indexOf("x_cadso_click") !== -1;
|
|
140
|
+
});
|
|
141
|
+
expect(warnedAboutScope).toBe(true);
|
|
142
|
+
});
|
|
143
|
+
test("declared scope — filters non-whitelisted tables before write", async function () {
|
|
144
|
+
mockConfig.getConfig.mockReturnValue({
|
|
145
|
+
scopes: { x_cadso_core: {} },
|
|
146
|
+
});
|
|
147
|
+
mockConfig.getManifest.mockResolvedValue({
|
|
148
|
+
x_cadso_core: { scope: "x_cadso_core", tables: {} },
|
|
149
|
+
});
|
|
150
|
+
// Server returns two whitelisted tables + sys_alias (not whitelisted).
|
|
151
|
+
mockClient.getManifest.mockResolvedValue({
|
|
152
|
+
scope: "x_cadso_core",
|
|
153
|
+
tables: {
|
|
154
|
+
sys_script_include: { records: { FooInclude: { name: "FooInclude", sys_id: "a1", files: [] } } },
|
|
155
|
+
sys_script: { records: { BarBR: { name: "BarBR", sys_id: "a2", files: [] } } },
|
|
156
|
+
sys_alias: { records: { DebrisRec: { name: "DebrisRec", sys_id: "a3", files: [] } } },
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
await AppUtils.syncManifest("x_cadso_core");
|
|
160
|
+
expect(mockClient.getManifest).toHaveBeenCalledWith("x_cadso_core", expect.any(Object));
|
|
161
|
+
expect(mockFUtils.writeScopeManifest).toHaveBeenCalledTimes(1);
|
|
162
|
+
var writtenScope = mockFUtils.writeScopeManifest.mock.calls[0][0];
|
|
163
|
+
var writtenManifest = mockFUtils.writeScopeManifest.mock.calls[0][1];
|
|
164
|
+
expect(writtenScope).toBe("x_cadso_core");
|
|
165
|
+
var writtenTables = Object.keys(writtenManifest.tables);
|
|
166
|
+
expect(writtenTables).toContain("sys_script_include");
|
|
167
|
+
expect(writtenTables).toContain("sys_script");
|
|
168
|
+
expect(writtenTables).not.toContain("sys_alias");
|
|
169
|
+
});
|
|
170
|
+
test("no-scope call iterates only declared scopes, even when manifest has stale ones", async function () {
|
|
171
|
+
mockConfig.getConfig.mockReturnValue({
|
|
172
|
+
scopes: { x_cadso_core: {}, x_cadso_work: {} },
|
|
173
|
+
});
|
|
174
|
+
// Persisted multi-scope manifest still carries stale undeclared scopes.
|
|
175
|
+
mockConfig.getManifest.mockResolvedValue({
|
|
176
|
+
x_cadso_core: { scope: "x_cadso_core", tables: {} },
|
|
177
|
+
x_cadso_work: { scope: "x_cadso_work", tables: {} },
|
|
178
|
+
x_cadso_click: { scope: "x_cadso_click", tables: {} },
|
|
179
|
+
x_nuvo_sinc: { scope: "x_nuvo_sinc", tables: {} },
|
|
180
|
+
x_cadso_ti_agile: { scope: "x_cadso_ti_agile", tables: {} },
|
|
181
|
+
});
|
|
182
|
+
mockClient.getManifest.mockImplementation(function (scope) {
|
|
183
|
+
return Promise.resolve({ scope: scope, tables: {} });
|
|
184
|
+
});
|
|
185
|
+
await AppUtils.syncManifest();
|
|
186
|
+
var refreshedScopes = mockClient.getManifest.mock.calls.map(function (c) { return c[0]; });
|
|
187
|
+
expect(refreshedScopes.sort()).toEqual(["x_cadso_core", "x_cadso_work"]);
|
|
188
|
+
expect(refreshedScopes).not.toContain("x_cadso_click");
|
|
189
|
+
expect(refreshedScopes).not.toContain("x_nuvo_sinc");
|
|
190
|
+
expect(refreshedScopes).not.toContain("x_cadso_ti_agile");
|
|
191
|
+
});
|
|
192
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Tests for the enhanced error path in unwrapSNResponse.
|
|
4
|
+
*
|
|
5
|
+
* When a ServiceNow REST call throws, the catch block must:
|
|
6
|
+
* - Log a structured one-liner via logger.error including HTTP status, method, URL
|
|
7
|
+
* and (when present) the ServiceNow-shaped error message from the response body.
|
|
8
|
+
* - Dump the full error surface (status, statusText, responseData, responseHeaders,
|
|
9
|
+
* scope extracted from /getManifest/:scope URLs) via fileLogger.debug.
|
|
10
|
+
* - Re-throw the original error so upstream callers see identical behaviour.
|
|
11
|
+
*
|
|
12
|
+
* Non-Axios errors must preserve the original log shape ("Error from <instance>: <msg>")
|
|
13
|
+
* and also produce a debug dump for future triage.
|
|
14
|
+
*/
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
var mockLogger = {
|
|
17
|
+
info: jest.fn(),
|
|
18
|
+
debug: jest.fn(),
|
|
19
|
+
warn: jest.fn(),
|
|
20
|
+
error: jest.fn(),
|
|
21
|
+
getLogLevel: function () { return "debug"; },
|
|
22
|
+
};
|
|
23
|
+
var mockFileLogger = {
|
|
24
|
+
debug: jest.fn(),
|
|
25
|
+
info: jest.fn(),
|
|
26
|
+
warn: jest.fn(),
|
|
27
|
+
error: jest.fn(),
|
|
28
|
+
};
|
|
29
|
+
jest.mock("../Logger", function () {
|
|
30
|
+
return { logger: mockLogger };
|
|
31
|
+
});
|
|
32
|
+
jest.mock("../FileLogger", function () {
|
|
33
|
+
return { fileLogger: mockFileLogger };
|
|
34
|
+
});
|
|
35
|
+
const snClient_1 = require("../snClient");
|
|
36
|
+
function makeAxiosError(overrides) {
|
|
37
|
+
var error = new Error("Request failed with status code " + overrides.status);
|
|
38
|
+
error.isAxiosError = true;
|
|
39
|
+
error.config = {
|
|
40
|
+
method: overrides.method || "post",
|
|
41
|
+
url: overrides.url || "api/sinc/sincronia/getManifest/x_cadso_example",
|
|
42
|
+
};
|
|
43
|
+
error.response = {
|
|
44
|
+
status: overrides.status,
|
|
45
|
+
statusText: overrides.statusText || "",
|
|
46
|
+
headers: overrides.headers || { "x-test": "1" },
|
|
47
|
+
data: overrides.data,
|
|
48
|
+
};
|
|
49
|
+
return error;
|
|
50
|
+
}
|
|
51
|
+
describe("unwrapSNResponse — error handling", function () {
|
|
52
|
+
var origInstance;
|
|
53
|
+
beforeAll(function () {
|
|
54
|
+
origInstance = process.env.SN_INSTANCE;
|
|
55
|
+
process.env.SN_INSTANCE = "tenontest.service-now.com";
|
|
56
|
+
});
|
|
57
|
+
afterAll(function () {
|
|
58
|
+
if (origInstance === undefined)
|
|
59
|
+
delete process.env.SN_INSTANCE;
|
|
60
|
+
else
|
|
61
|
+
process.env.SN_INSTANCE = origInstance;
|
|
62
|
+
});
|
|
63
|
+
beforeEach(function () {
|
|
64
|
+
jest.clearAllMocks();
|
|
65
|
+
});
|
|
66
|
+
test("Axios 500 with ServiceNow-shaped body: one-liner includes status + URL + SN message, debug dump carries full detail", async function () {
|
|
67
|
+
var snBody = {
|
|
68
|
+
error: {
|
|
69
|
+
message: "org.mozilla.javascript.EcmaError: TypeError",
|
|
70
|
+
detail: "Cannot read property 'name' of null",
|
|
71
|
+
},
|
|
72
|
+
status: "failure",
|
|
73
|
+
};
|
|
74
|
+
var axErr = makeAxiosError({
|
|
75
|
+
status: 500,
|
|
76
|
+
method: "post",
|
|
77
|
+
url: "api/sinc/sincronia/getManifest/x_cadso_automate",
|
|
78
|
+
data: snBody,
|
|
79
|
+
statusText: "Internal Server Error",
|
|
80
|
+
});
|
|
81
|
+
var rejected = Promise.reject(axErr);
|
|
82
|
+
// Silence the unhandled-rejection warning before jest inspects it
|
|
83
|
+
rejected.catch(function () { });
|
|
84
|
+
await expect((0, snClient_1.unwrapSNResponse)(rejected)).rejects.toBe(axErr);
|
|
85
|
+
expect(mockLogger.error).toHaveBeenCalledTimes(1);
|
|
86
|
+
var userLine = mockLogger.error.mock.calls[0][0];
|
|
87
|
+
expect(userLine).toContain("tenontest.service-now.com");
|
|
88
|
+
expect(userLine).toContain("HTTP 500");
|
|
89
|
+
expect(userLine).toContain("POST");
|
|
90
|
+
expect(userLine).toContain("getManifest/x_cadso_automate");
|
|
91
|
+
expect(userLine).toContain("org.mozilla.javascript.EcmaError");
|
|
92
|
+
expect(mockFileLogger.debug).toHaveBeenCalledTimes(1);
|
|
93
|
+
var debugLabel = mockFileLogger.debug.mock.calls[0][0];
|
|
94
|
+
var debugPayload = mockFileLogger.debug.mock.calls[0][1];
|
|
95
|
+
expect(debugLabel).toBe("REST error detail");
|
|
96
|
+
expect(debugPayload).toMatchObject({
|
|
97
|
+
instance: "tenontest.service-now.com",
|
|
98
|
+
scope: "x_cadso_automate",
|
|
99
|
+
method: "POST",
|
|
100
|
+
status: 500,
|
|
101
|
+
statusText: "Internal Server Error",
|
|
102
|
+
responseData: snBody,
|
|
103
|
+
responseHeaders: { "x-test": "1" },
|
|
104
|
+
});
|
|
105
|
+
expect(debugPayload.url).toContain("getManifest/x_cadso_automate");
|
|
106
|
+
});
|
|
107
|
+
test("Axios 500 with non-SN body: one-liner falls back cleanly, no crash", async function () {
|
|
108
|
+
var axErr = makeAxiosError({
|
|
109
|
+
status: 500,
|
|
110
|
+
method: "get",
|
|
111
|
+
url: "api/now/table/sys_script_include",
|
|
112
|
+
data: "<html><body>Gateway error</body></html>",
|
|
113
|
+
});
|
|
114
|
+
var rejected = Promise.reject(axErr);
|
|
115
|
+
rejected.catch(function () { });
|
|
116
|
+
await expect((0, snClient_1.unwrapSNResponse)(rejected)).rejects.toBe(axErr);
|
|
117
|
+
var userLine = mockLogger.error.mock.calls[0][0];
|
|
118
|
+
expect(userLine).toContain("HTTP 500");
|
|
119
|
+
expect(userLine).toContain("GET");
|
|
120
|
+
expect(userLine).toContain("table/sys_script_include");
|
|
121
|
+
// No SN message to append; no em dash
|
|
122
|
+
expect(userLine).not.toContain(" — ");
|
|
123
|
+
expect(mockFileLogger.debug).toHaveBeenCalledTimes(1);
|
|
124
|
+
var debugPayload = mockFileLogger.debug.mock.calls[0][1];
|
|
125
|
+
// Non-manifest URL — scope should be undefined
|
|
126
|
+
expect(debugPayload.scope).toBeUndefined();
|
|
127
|
+
expect(debugPayload.responseData).toBe("<html><body>Gateway error</body></html>");
|
|
128
|
+
});
|
|
129
|
+
test("Non-Axios error: preserves legacy log shape and re-throws", async function () {
|
|
130
|
+
var nonAxios = new Error("socket hang up");
|
|
131
|
+
var rejected = Promise.reject(nonAxios);
|
|
132
|
+
rejected.catch(function () { });
|
|
133
|
+
await expect((0, snClient_1.unwrapSNResponse)(rejected)).rejects.toBe(nonAxios);
|
|
134
|
+
expect(mockLogger.error).toHaveBeenCalledTimes(1);
|
|
135
|
+
var userLine = mockLogger.error.mock.calls[0][0];
|
|
136
|
+
expect(userLine).toBe("Error from tenontest.service-now.com: socket hang up");
|
|
137
|
+
expect(mockFileLogger.debug).toHaveBeenCalledTimes(1);
|
|
138
|
+
var debugLabel = mockFileLogger.debug.mock.calls[0][0];
|
|
139
|
+
expect(debugLabel).toBe("Non-Axios error detail");
|
|
140
|
+
var debugPayload = mockFileLogger.debug.mock.calls[0][1];
|
|
141
|
+
expect(debugPayload.message).toBe("socket hang up");
|
|
142
|
+
expect(debugPayload.errorName).toBe("Error");
|
|
143
|
+
expect(typeof debugPayload.errorStack).toBe("string");
|
|
144
|
+
});
|
|
145
|
+
});
|