@tenonhq/sincronia-core 0.0.80 → 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/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
- const tableNames = Object.keys(tables);
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
- const config = ConfigManager.getConfig();
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 if manifest has multiple scopes
186
- if (ConfigManager.isMultiScopeManifest(curManifest)) {
187
- // Multiple scopes detected
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
- const filesToProcess = await (0, snClient_1.unwrapSNResponse)(client.getMissingFiles(missing, tableOptions));
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
- Logger_1.logger.error("Error from " + instance + ": " + message);
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
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tenonhq/sincronia-core",
3
- "version": "0.0.80",
3
+ "version": "0.0.81",
4
4
  "description": "Next-gen file syncer",
5
5
  "license": "GPL-3.0",
6
6
  "main": "./dist/index.js",