cds-error-outbox 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/publish.yml +29 -0
- package/README.md +311 -283
- package/config/defaults.js +46 -46
- package/db/model.cds +18 -18
- package/index.cds +1 -1
- package/index.js +14 -14
- package/lib/bootstrap.js +71 -71
- package/lib/config.js +67 -67
- package/lib/dedup.js +91 -91
- package/lib/formatter.js +214 -214
- package/lib/interceptor.js +59 -48
- package/lib/scheduler.js +125 -125
- package/package.json +40 -34
- package/providers/index.js +40 -40
- package/providers/mock.js +23 -23
- package/providers/o365.js +164 -164
- package/providers/smtp.js +71 -71
- package/tests/config.test.js +92 -0
- package/tests/dedup.test.js +159 -0
- package/tests/formatter.test.js +90 -0
- package/tests/scheduler.test.js +147 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { describe, it } = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
|
|
6
|
+
const { createHash, upsertError } = require('../lib/dedup');
|
|
7
|
+
|
|
8
|
+
// ── createHash ───────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
describe('createHash', () => {
|
|
11
|
+
it('returns a 64-character hex string', () => {
|
|
12
|
+
const hash = createHash('err', 'svc', 'READ');
|
|
13
|
+
assert.equal(typeof hash, 'string');
|
|
14
|
+
assert.equal(hash.length, 64);
|
|
15
|
+
assert.match(hash, /^[0-9a-f]{64}$/);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('is deterministic — same inputs produce same hash', () => {
|
|
19
|
+
const a = createHash('boom', 'TestService', 'READ');
|
|
20
|
+
const b = createHash('boom', 'TestService', 'READ');
|
|
21
|
+
assert.equal(a, b);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('produces different hashes for different inputs', () => {
|
|
25
|
+
const a = createHash('boom', 'TestService', 'READ');
|
|
26
|
+
const b = createHash('boom', 'TestService', 'WRITE');
|
|
27
|
+
assert.notEqual(a, b);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// ── upsertError — DB mock helpers ────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
function makeDb({ existing = null } = {}) {
|
|
34
|
+
const ops = [];
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
ops,
|
|
38
|
+
run: async (query) => {
|
|
39
|
+
ops.push(query);
|
|
40
|
+
// SELECT returns existing record or empty
|
|
41
|
+
if (query && query._SELECT) return existing ? [existing] : [];
|
|
42
|
+
return {};
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// CDS query builders produce objects with internal symbols — we use a thin shim
|
|
48
|
+
// so the tests don't depend on @sap/cds internals.
|
|
49
|
+
// We patch SELECT/INSERT/UPDATE on global before each group.
|
|
50
|
+
|
|
51
|
+
function installCdsGlobals() {
|
|
52
|
+
const crypto = require('crypto');
|
|
53
|
+
|
|
54
|
+
// Minimal SELECT shim
|
|
55
|
+
global.SELECT = {
|
|
56
|
+
from: (entity) => ({
|
|
57
|
+
_SELECT: true,
|
|
58
|
+
_entity: entity,
|
|
59
|
+
where: function (c) { this._where = c; return this; },
|
|
60
|
+
and: function (c, v) { this._and = [c, v]; return this; },
|
|
61
|
+
limit: function (n) { this._limit = n; return this; }
|
|
62
|
+
})
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
global.INSERT = {
|
|
66
|
+
into: (entity) => ({
|
|
67
|
+
entries: (data) => ({ _INSERT: true, _entity: entity, _data: data })
|
|
68
|
+
})
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
global.UPDATE = (entity) => ({
|
|
72
|
+
_UPDATE: true,
|
|
73
|
+
_entity: entity,
|
|
74
|
+
set: function (d) { this._set = d; return this; },
|
|
75
|
+
where: function (c) { this._where = c; return this; }
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── upsertError — inserts ────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
describe('upsertError — new error', () => {
|
|
82
|
+
installCdsGlobals();
|
|
83
|
+
|
|
84
|
+
const config = {
|
|
85
|
+
dedup: { enabled: true, windowMinutes: 10 }
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
it('inserts a new record when no existing error is found', async () => {
|
|
89
|
+
const db = makeDb({ existing: null });
|
|
90
|
+
await upsertError(db, { message: 'boom', stack: 'at x', service: 'Svc', action: 'READ' }, config);
|
|
91
|
+
|
|
92
|
+
const insert = db.ops.find(o => o._INSERT);
|
|
93
|
+
assert.ok(insert, 'expected an INSERT operation');
|
|
94
|
+
assert.equal(insert._data.message, 'boom');
|
|
95
|
+
assert.equal(insert._data.service, 'Svc');
|
|
96
|
+
assert.equal(insert._data.action, 'READ');
|
|
97
|
+
assert.equal(insert._data.count, 1);
|
|
98
|
+
assert.equal(insert._data.sent, false);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('sets service/action to "unknown" when missing', async () => {
|
|
102
|
+
const db = makeDb({ existing: null });
|
|
103
|
+
await upsertError(db, { message: 'boom', stack: '' }, config);
|
|
104
|
+
|
|
105
|
+
const insert = db.ops.find(o => o._INSERT);
|
|
106
|
+
assert.equal(insert._data.service, 'unknown');
|
|
107
|
+
assert.equal(insert._data.action, 'unknown');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('truncates message to 5000 characters', async () => {
|
|
111
|
+
const long = 'x'.repeat(6000);
|
|
112
|
+
const db = makeDb({ existing: null });
|
|
113
|
+
await upsertError(db, { message: long, stack: '', service: 'S', action: 'A' }, config);
|
|
114
|
+
|
|
115
|
+
const insert = db.ops.find(o => o._INSERT);
|
|
116
|
+
assert.equal(insert._data.message.length, 5000);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('truncates stack to 10000 characters', async () => {
|
|
120
|
+
const long = 'x'.repeat(12000);
|
|
121
|
+
const db = makeDb({ existing: null });
|
|
122
|
+
await upsertError(db, { message: 'err', stack: long, service: 'S', action: 'A' }, config);
|
|
123
|
+
|
|
124
|
+
const insert = db.ops.find(o => o._INSERT);
|
|
125
|
+
assert.equal(insert._data.stack.length, 10000);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('inserts a new record when dedup is disabled (ignores existing)', async () => {
|
|
129
|
+
const existing = { ID: 'abc', count: 3, hash: 'x' };
|
|
130
|
+
const db = makeDb({ existing });
|
|
131
|
+
const cfg = { dedup: { enabled: false, windowMinutes: 10 } };
|
|
132
|
+
await upsertError(db, { message: 'boom', stack: '', service: 'S', action: 'A' }, cfg);
|
|
133
|
+
|
|
134
|
+
const insert = db.ops.find(o => o._INSERT);
|
|
135
|
+
assert.ok(insert, 'expected INSERT even when existing record present');
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// ── upsertError — updates (dedup) ────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
describe('upsertError — dedup update', () => {
|
|
142
|
+
installCdsGlobals();
|
|
143
|
+
|
|
144
|
+
const config = {
|
|
145
|
+
dedup: { enabled: true, windowMinutes: 10 }
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
it('updates count and lastSeen when a matching error exists', async () => {
|
|
149
|
+
const existing = { ID: 'existing-id', count: 2, hash: 'somehash' };
|
|
150
|
+
const db = makeDb({ existing });
|
|
151
|
+
await upsertError(db, { message: 'boom', stack: '', service: 'S', action: 'A' }, config);
|
|
152
|
+
|
|
153
|
+
const update = db.ops.find(o => o._UPDATE);
|
|
154
|
+
assert.ok(update, 'expected an UPDATE operation');
|
|
155
|
+
assert.equal(update._set.count, 3);
|
|
156
|
+
assert.ok(update._set.lastSeen, 'lastSeen should be updated');
|
|
157
|
+
assert.equal(update._where.ID, 'existing-id');
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { describe, it } = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
|
|
6
|
+
const { escapeHtml, formatHtmlEmail } = require('../lib/formatter');
|
|
7
|
+
|
|
8
|
+
// ── escapeHtml ───────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
describe('escapeHtml', () => {
|
|
11
|
+
it('escapes &', () => assert.equal(escapeHtml('a&b'), 'a&b'));
|
|
12
|
+
it('escapes <', () => assert.equal(escapeHtml('a<b'), 'a<b'));
|
|
13
|
+
it('escapes >', () => assert.equal(escapeHtml('a>b'), 'a>b'));
|
|
14
|
+
it('escapes "', () => assert.equal(escapeHtml('a"b'), 'a"b'));
|
|
15
|
+
it("escapes '", () => assert.equal(escapeHtml("a'b"), 'a'b'));
|
|
16
|
+
it('leaves plain strings unchanged', () => assert.equal(escapeHtml('hello world'), 'hello world'));
|
|
17
|
+
it('escapes multiple characters in one string', () => {
|
|
18
|
+
assert.equal(escapeHtml('<script>alert("xss")</script>'), '<script>alert("xss")</script>');
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// ── formatHtmlEmail ──────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
function makeError(overrides = {}) {
|
|
25
|
+
return {
|
|
26
|
+
ID: 'id-1',
|
|
27
|
+
service: 'TestService',
|
|
28
|
+
action: 'READ',
|
|
29
|
+
message: 'Something went wrong',
|
|
30
|
+
stack: 'Error: Something went wrong\n at handler (srv.js:10)\n at next (cds.js:5)',
|
|
31
|
+
count: 1,
|
|
32
|
+
firstSeen: '2026-06-02T10:00:00.000Z',
|
|
33
|
+
lastSeen: '2026-06-02T10:05:00.000Z',
|
|
34
|
+
sent: false,
|
|
35
|
+
...overrides
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('formatHtmlEmail', () => {
|
|
40
|
+
it('returns subject and html keys', () => {
|
|
41
|
+
const { subject, html } = formatHtmlEmail([makeError()]);
|
|
42
|
+
assert.equal(typeof subject, 'string');
|
|
43
|
+
assert.equal(typeof html, 'string');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('subject contains error count and occurrence count', () => {
|
|
47
|
+
const errors = [makeError({ count: 3 }), makeError({ ID: 'id-2', count: 2 })];
|
|
48
|
+
const { subject } = formatHtmlEmail(errors);
|
|
49
|
+
assert.match(subject, /5 occurrence\(s\)/);
|
|
50
|
+
assert.match(subject, /2 error\(s\)/);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('html contains service name', () => {
|
|
54
|
+
const { html } = formatHtmlEmail([makeError({ service: 'BookshopService' })]);
|
|
55
|
+
assert.ok(html.includes('BookshopService'), 'expected service name in HTML');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('html contains escaped message', () => {
|
|
59
|
+
const { html } = formatHtmlEmail([makeError({ message: '<script>xss</script>' })]);
|
|
60
|
+
assert.ok(html.includes('<script>'), 'message should be HTML-escaped');
|
|
61
|
+
assert.ok(!html.includes('<script>xss</script>'), 'raw script tag must not appear');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('groups errors by service', () => {
|
|
65
|
+
const errors = [
|
|
66
|
+
makeError({ ID: '1', service: 'ServiceA', message: 'err A' }),
|
|
67
|
+
makeError({ ID: '2', service: 'ServiceB', message: 'err B' })
|
|
68
|
+
];
|
|
69
|
+
const { html } = formatHtmlEmail(errors);
|
|
70
|
+
assert.ok(html.includes('ServiceA'));
|
|
71
|
+
assert.ok(html.includes('ServiceB'));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('stack trace is included in the email', () => {
|
|
75
|
+
const err = makeError({ stack: 'line1\nline2\nline3\nline4\nline5\nline6\nline7' });
|
|
76
|
+
const { html } = formatHtmlEmail([err]);
|
|
77
|
+
assert.ok(html.includes('line1'));
|
|
78
|
+
// Only first 5 lines should appear
|
|
79
|
+
assert.ok(!html.includes('line6'), 'stack should be truncated to 5 lines');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('handles empty errors array without throwing', () => {
|
|
83
|
+
assert.doesNotThrow(() => formatHtmlEmail([]));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('handles missing optional fields gracefully', () => {
|
|
87
|
+
const minimal = { ID: 'x', service: 'S', action: 'A', message: 'oops', count: 1 };
|
|
88
|
+
assert.doesNotThrow(() => formatHtmlEmail([minimal]));
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { describe, it, beforeEach, afterEach } = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
|
|
6
|
+
// Load @sap/cds first — this installs SELECT/INSERT/UPDATE globals (read-only)
|
|
7
|
+
const cds = require('@sap/cds');
|
|
8
|
+
|
|
9
|
+
const { start, stop, runBatch } = require('../lib/scheduler');
|
|
10
|
+
|
|
11
|
+
// ── DB mock factory ───────────────────────────────────────────────────────────
|
|
12
|
+
// runBatch calls cds.connect.to('db') — we replace that with a fake that
|
|
13
|
+
// returns a controlled db object. The global SELECT/UPDATE are used by the
|
|
14
|
+
// production code, so we let them be; we only track what db.run receives.
|
|
15
|
+
|
|
16
|
+
function makeDb({ errors = [] } = {}) {
|
|
17
|
+
const ops = [];
|
|
18
|
+
const db = {
|
|
19
|
+
ops,
|
|
20
|
+
run: async (query) => {
|
|
21
|
+
// Detect SELECT vs UPDATE by inspecting the query object CAP builds.
|
|
22
|
+
// A SELECT query has a .SELECT property; an UPDATE has .UPDATE.
|
|
23
|
+
if (query && query.SELECT) return errors;
|
|
24
|
+
ops.push(query);
|
|
25
|
+
return {};
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
return db;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function mockConnect(db) {
|
|
32
|
+
// cds.connect is a read-only getter, but cds.connect.to is a regular property
|
|
33
|
+
// we can replace on the existing object.
|
|
34
|
+
const orig = cds.connect.to;
|
|
35
|
+
cds.connect.to = async () => db;
|
|
36
|
+
return () => { cds.connect.to = orig; }; // returns restore function
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function mockConnectFail() {
|
|
40
|
+
const orig = cds.connect.to;
|
|
41
|
+
cds.connect.to = async () => { throw new Error('DB connection failed'); };
|
|
42
|
+
return () => { cds.connect.to = orig; };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
stop();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// ── start / stop ─────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
describe('start / stop', () => {
|
|
52
|
+
it('start returns a truthy handle', () => {
|
|
53
|
+
const config = { interval: 60000, batchSize: 10 };
|
|
54
|
+
const provider = { send: async () => {} };
|
|
55
|
+
const handle = start(config, provider);
|
|
56
|
+
assert.ok(handle, 'expected a truthy handle');
|
|
57
|
+
stop();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('stop does not throw when scheduler is not running', () => {
|
|
61
|
+
stop();
|
|
62
|
+
assert.doesNotThrow(() => stop());
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('calling start twice replaces the existing interval', () => {
|
|
66
|
+
const config = { interval: 60000, batchSize: 10 };
|
|
67
|
+
const provider = { send: async () => {} };
|
|
68
|
+
const h1 = start(config, provider);
|
|
69
|
+
const h2 = start(config, provider);
|
|
70
|
+
assert.notEqual(h1, h2);
|
|
71
|
+
stop();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// ── runBatch — empty queue ────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
describe('runBatch — empty queue', () => {
|
|
78
|
+
it('does nothing when there are no unsent errors', async () => {
|
|
79
|
+
const db = makeDb({ errors: [] });
|
|
80
|
+
const restore = mockConnect(db);
|
|
81
|
+
const provider = { send: async () => { throw new Error('should not be called'); } };
|
|
82
|
+
const config = { batchSize: 10, mail: {} };
|
|
83
|
+
try {
|
|
84
|
+
await assert.doesNotReject(() => runBatch(config, provider));
|
|
85
|
+
assert.equal(db.ops.length, 0, 'no UPDATE should occur');
|
|
86
|
+
} finally { restore(); }
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// ── runBatch — successful send ────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
describe('runBatch — successful send', () => {
|
|
93
|
+
it('calls provider.send and marks errors as sent', async () => {
|
|
94
|
+
const now = new Date().toISOString();
|
|
95
|
+
const errors = [
|
|
96
|
+
{ ID: 'id-1', service: 'S', action: 'A', message: 'boom', count: 1, sent: false, firstSeen: now, lastSeen: now },
|
|
97
|
+
{ ID: 'id-2', service: 'S', action: 'A', message: 'crash', count: 1, sent: false, firstSeen: now, lastSeen: now }
|
|
98
|
+
];
|
|
99
|
+
const db = makeDb({ errors });
|
|
100
|
+
const restore = mockConnect(db);
|
|
101
|
+
|
|
102
|
+
let sendCalled = false;
|
|
103
|
+
const provider = { send: async () => { sendCalled = true; } };
|
|
104
|
+
const config = { batchSize: 10, mail: {} };
|
|
105
|
+
try {
|
|
106
|
+
await runBatch(config, provider);
|
|
107
|
+
|
|
108
|
+
assert.ok(sendCalled, 'provider.send should have been called');
|
|
109
|
+
assert.ok(db.ops.length > 0, 'expected UPDATE to mark errors as sent');
|
|
110
|
+
const update = db.ops[0];
|
|
111
|
+
assert.ok(update && update.UPDATE, 'expected an UPDATE query');
|
|
112
|
+
} finally { restore(); }
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// ── runBatch — send failure ───────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
describe('runBatch — send failure', () => {
|
|
119
|
+
it('does NOT mark errors as sent when provider.send throws', async () => {
|
|
120
|
+
const now = new Date().toISOString();
|
|
121
|
+
const errors = [
|
|
122
|
+
{ ID: 'id-1', service: 'S', action: 'A', message: 'boom', count: 1, sent: false, firstSeen: now, lastSeen: now }
|
|
123
|
+
];
|
|
124
|
+
const db = makeDb({ errors });
|
|
125
|
+
const restore = mockConnect(db);
|
|
126
|
+
|
|
127
|
+
const provider = { send: async () => { throw new Error('SMTP timeout'); } };
|
|
128
|
+
const config = { batchSize: 10, mail: {} };
|
|
129
|
+
try {
|
|
130
|
+
await runBatch(config, provider);
|
|
131
|
+
assert.equal(db.ops.length, 0, 'errors must NOT be marked sent when send fails');
|
|
132
|
+
} finally { restore(); }
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// ── runBatch — DB failure ─────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
describe('runBatch — DB failure', () => {
|
|
139
|
+
it('does not throw when DB connection fails', async () => {
|
|
140
|
+
const restore = mockConnectFail();
|
|
141
|
+
const provider = { send: async () => {} };
|
|
142
|
+
const config = { batchSize: 10, mail: {} };
|
|
143
|
+
try {
|
|
144
|
+
await assert.doesNotReject(() => runBatch(config, provider));
|
|
145
|
+
} finally { restore(); }
|
|
146
|
+
});
|
|
147
|
+
});
|