chadstart 1.0.4 → 1.0.6
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/Dockerfile +1 -1
- package/TODO.md +739 -0
- package/admin/index.html +27 -1
- package/{chadstart.example.yml → chadstart.example.yaml} +23 -1
- package/chadstart.schema.json +83 -0
- package/cli/cli.js +124 -35
- package/core/auth.js +160 -3
- package/core/backup.js +191 -0
- package/core/config-loader.js +266 -0
- package/core/db.js +4 -0
- package/core/email.js +170 -0
- package/core/entity-engine.js +4 -0
- package/core/logs.js +179 -0
- package/core/migrations.js +23 -18
- package/core/openapi.js +6 -2
- package/core/yaml-loader.js +8 -53
- package/docs/llm-rules.md +1 -1
- package/package.json +3 -1
- package/server/express-server.js +149 -18
- package/test/backup.test.js +146 -0
- package/test/config-loader.test.js +257 -0
- package/test/email.test.js +362 -0
- package/test/logs.test.js +239 -0
- package/test/verification.test.js +439 -0
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('assert');
|
|
4
|
+
const { getEmailConfig, initEmail, interpolate, getEmailStatus, sendEmail, verifyConnection } = require('../core/email');
|
|
5
|
+
const { validateSchema } = require('../core/schema-validator');
|
|
6
|
+
const { buildCore } = require('../core/entity-engine');
|
|
7
|
+
|
|
8
|
+
// Helper: set/restore env vars around a test
|
|
9
|
+
function withEnv(vars, fn) {
|
|
10
|
+
const saved = {};
|
|
11
|
+
for (const [k, v] of Object.entries(vars)) {
|
|
12
|
+
saved[k] = process.env[k];
|
|
13
|
+
if (v === undefined) delete process.env[k]; else process.env[k] = v;
|
|
14
|
+
}
|
|
15
|
+
try { return fn(); } finally {
|
|
16
|
+
for (const [k, v] of Object.entries(saved)) {
|
|
17
|
+
if (v === undefined) delete process.env[k]; else process.env[k] = v;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ── getEmailConfig ──────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
describe('getEmailConfig – disabled', () => {
|
|
25
|
+
it('returns null when no yaml and no env vars', () => {
|
|
26
|
+
withEnv({ SMTP_HOST: undefined, SMTP_PORT: undefined, SMTP_USER: undefined, SMTP_PASS: undefined, SMTP_FROM: undefined }, () => {
|
|
27
|
+
assert.strictEqual(getEmailConfig(null), null);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('returns null when yaml is empty object', () => {
|
|
32
|
+
withEnv({ SMTP_HOST: undefined }, () => {
|
|
33
|
+
assert.strictEqual(getEmailConfig({}), null);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('returns null when yaml has no host', () => {
|
|
38
|
+
withEnv({ SMTP_HOST: undefined }, () => {
|
|
39
|
+
assert.strictEqual(getEmailConfig({ port: 587, from: 'test@test.com' }), null);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('getEmailConfig – enabled via yaml', () => {
|
|
45
|
+
it('returns config when host is provided in yaml', () => {
|
|
46
|
+
withEnv({ SMTP_HOST: undefined, SMTP_PORT: undefined, SMTP_USER: undefined, SMTP_PASS: undefined, SMTP_FROM: undefined }, () => {
|
|
47
|
+
const cfg = getEmailConfig({ host: 'smtp.example.com' });
|
|
48
|
+
assert.ok(cfg);
|
|
49
|
+
assert.strictEqual(cfg.host, 'smtp.example.com');
|
|
50
|
+
assert.strictEqual(cfg.port, 587);
|
|
51
|
+
assert.strictEqual(cfg.user, '');
|
|
52
|
+
assert.strictEqual(cfg.pass, '');
|
|
53
|
+
assert.strictEqual(cfg.from, '');
|
|
54
|
+
assert.strictEqual(cfg.secure, false);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('reads all yaml fields', () => {
|
|
59
|
+
withEnv({ SMTP_HOST: undefined, SMTP_PORT: undefined, SMTP_USER: undefined, SMTP_PASS: undefined, SMTP_FROM: undefined }, () => {
|
|
60
|
+
const cfg = getEmailConfig({
|
|
61
|
+
host: 'smtp.example.com',
|
|
62
|
+
port: 465,
|
|
63
|
+
username: 'user@example.com',
|
|
64
|
+
from: 'App <noreply@example.com>',
|
|
65
|
+
secure: true,
|
|
66
|
+
});
|
|
67
|
+
assert.strictEqual(cfg.host, 'smtp.example.com');
|
|
68
|
+
assert.strictEqual(cfg.port, 465);
|
|
69
|
+
assert.strictEqual(cfg.user, 'user@example.com');
|
|
70
|
+
assert.strictEqual(cfg.from, 'App <noreply@example.com>');
|
|
71
|
+
assert.strictEqual(cfg.secure, true);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('auto-detects secure=true for port 465', () => {
|
|
76
|
+
withEnv({ SMTP_HOST: undefined, SMTP_PORT: undefined, SMTP_USER: undefined, SMTP_PASS: undefined, SMTP_FROM: undefined }, () => {
|
|
77
|
+
const cfg = getEmailConfig({ host: 'smtp.example.com', port: 465 });
|
|
78
|
+
assert.strictEqual(cfg.secure, true);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('auto-detects secure=false for port 587', () => {
|
|
83
|
+
withEnv({ SMTP_HOST: undefined, SMTP_PORT: undefined, SMTP_USER: undefined, SMTP_PASS: undefined, SMTP_FROM: undefined }, () => {
|
|
84
|
+
const cfg = getEmailConfig({ host: 'smtp.example.com', port: 587 });
|
|
85
|
+
assert.strictEqual(cfg.secure, false);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('explicit secure overrides port-based auto-detection', () => {
|
|
90
|
+
withEnv({ SMTP_HOST: undefined, SMTP_PORT: undefined, SMTP_USER: undefined, SMTP_PASS: undefined, SMTP_FROM: undefined }, () => {
|
|
91
|
+
const cfg = getEmailConfig({ host: 'smtp.example.com', port: 465, secure: false });
|
|
92
|
+
assert.strictEqual(cfg.secure, false);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('getEmailConfig – enabled via env vars', () => {
|
|
98
|
+
it('returns config when SMTP_HOST env var is set', () => {
|
|
99
|
+
withEnv({ SMTP_HOST: 'env-smtp.example.com', SMTP_PORT: undefined, SMTP_USER: undefined, SMTP_PASS: undefined, SMTP_FROM: undefined }, () => {
|
|
100
|
+
const cfg = getEmailConfig(null);
|
|
101
|
+
assert.ok(cfg);
|
|
102
|
+
assert.strictEqual(cfg.host, 'env-smtp.example.com');
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('env vars override yaml values', () => {
|
|
107
|
+
withEnv({
|
|
108
|
+
SMTP_HOST: 'env-host.com',
|
|
109
|
+
SMTP_PORT: '465',
|
|
110
|
+
SMTP_USER: 'env-user',
|
|
111
|
+
SMTP_PASS: 'env-pass',
|
|
112
|
+
SMTP_FROM: 'Env <env@test.com>',
|
|
113
|
+
}, () => {
|
|
114
|
+
const cfg = getEmailConfig({
|
|
115
|
+
host: 'yaml-host.com',
|
|
116
|
+
port: 587,
|
|
117
|
+
username: 'yaml-user',
|
|
118
|
+
from: 'Yaml <yaml@test.com>',
|
|
119
|
+
});
|
|
120
|
+
assert.strictEqual(cfg.host, 'env-host.com');
|
|
121
|
+
assert.strictEqual(cfg.port, 465);
|
|
122
|
+
assert.strictEqual(cfg.user, 'env-user');
|
|
123
|
+
assert.strictEqual(cfg.pass, 'env-pass');
|
|
124
|
+
assert.strictEqual(cfg.from, 'Env <env@test.com>');
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('SMTP_PASS is only accepted from env var (not yaml)', () => {
|
|
129
|
+
withEnv({ SMTP_HOST: undefined, SMTP_PORT: undefined, SMTP_USER: undefined, SMTP_PASS: 'secret123', SMTP_FROM: undefined }, () => {
|
|
130
|
+
const cfg = getEmailConfig({ host: 'smtp.example.com' });
|
|
131
|
+
assert.strictEqual(cfg.pass, 'secret123');
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// ── interpolate ─────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
describe('interpolate', () => {
|
|
139
|
+
it('replaces single variable', () => {
|
|
140
|
+
assert.strictEqual(interpolate('Hello {{name}}!', { name: 'World' }), 'Hello World!');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('replaces multiple variables', () => {
|
|
144
|
+
const result = interpolate('{{appName}} - {{name}} ({{link}})', {
|
|
145
|
+
appName: 'My App',
|
|
146
|
+
name: 'Alice',
|
|
147
|
+
link: 'https://example.com/verify',
|
|
148
|
+
});
|
|
149
|
+
assert.strictEqual(result, 'My App - Alice (https://example.com/verify)');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('replaces missing variables with empty string', () => {
|
|
153
|
+
assert.strictEqual(interpolate('Hello {{name}} at {{link}}', { name: 'Bob' }), 'Hello Bob at ');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('handles empty template', () => {
|
|
157
|
+
assert.strictEqual(interpolate('', { name: 'test' }), '');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('handles null/undefined template', () => {
|
|
161
|
+
assert.strictEqual(interpolate(null, { name: 'test' }), '');
|
|
162
|
+
assert.strictEqual(interpolate(undefined, { name: 'test' }), '');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('handles template with no placeholders', () => {
|
|
166
|
+
assert.strictEqual(interpolate('No variables here', { name: 'test' }), 'No variables here');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('handles repeated variables', () => {
|
|
170
|
+
assert.strictEqual(interpolate('{{x}} and {{x}}', { x: 'val' }), 'val and val');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('only matches word characters in variable names', () => {
|
|
174
|
+
assert.strictEqual(interpolate('{{a-b}}', { 'a-b': 'nope' }), '{{a-b}}');
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// ── initEmail / getEmailStatus ──────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
describe('initEmail', () => {
|
|
181
|
+
afterEach(() => {
|
|
182
|
+
// Reset email state
|
|
183
|
+
withEnv({ SMTP_HOST: undefined, SMTP_PORT: undefined, SMTP_USER: undefined, SMTP_PASS: undefined, SMTP_FROM: undefined }, () => {
|
|
184
|
+
initEmail(null);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('returns null when not configured', () => {
|
|
189
|
+
withEnv({ SMTP_HOST: undefined }, () => {
|
|
190
|
+
const result = initEmail(null);
|
|
191
|
+
assert.strictEqual(result, null);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('returns config metadata when configured', () => {
|
|
196
|
+
withEnv({ SMTP_HOST: undefined, SMTP_PORT: undefined, SMTP_USER: undefined, SMTP_PASS: undefined, SMTP_FROM: undefined }, () => {
|
|
197
|
+
const result = initEmail({ host: 'smtp.example.com', port: 587, from: 'App <noreply@example.com>' });
|
|
198
|
+
assert.ok(result);
|
|
199
|
+
assert.strictEqual(result.host, 'smtp.example.com');
|
|
200
|
+
assert.strictEqual(result.port, 587);
|
|
201
|
+
assert.strictEqual(result.from, 'App <noreply@example.com>');
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('getEmailStatus returns configured: false when not initialized', () => {
|
|
206
|
+
withEnv({ SMTP_HOST: undefined }, () => {
|
|
207
|
+
initEmail(null);
|
|
208
|
+
const status = getEmailStatus();
|
|
209
|
+
assert.strictEqual(status.configured, false);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('getEmailStatus returns metadata when initialized', () => {
|
|
214
|
+
withEnv({ SMTP_HOST: undefined, SMTP_PORT: undefined, SMTP_USER: undefined, SMTP_PASS: undefined, SMTP_FROM: undefined }, () => {
|
|
215
|
+
initEmail({ host: 'smtp.example.com', port: 587, from: 'App <noreply@example.com>' });
|
|
216
|
+
const status = getEmailStatus();
|
|
217
|
+
assert.strictEqual(status.configured, true);
|
|
218
|
+
assert.strictEqual(status.host, 'smtp.example.com');
|
|
219
|
+
assert.strictEqual(status.port, 587);
|
|
220
|
+
assert.strictEqual(status.from, 'App <noreply@example.com>');
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// ── sendEmail (without real SMTP) ───────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
describe('sendEmail – not configured', () => {
|
|
228
|
+
before(() => {
|
|
229
|
+
withEnv({ SMTP_HOST: undefined }, () => { initEmail(null); });
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('throws when email is not configured', async () => {
|
|
233
|
+
await assert.rejects(
|
|
234
|
+
() => sendEmail({ to: 'test@test.com', subject: 'Test' }),
|
|
235
|
+
/Email is not configured/
|
|
236
|
+
);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe('verifyConnection – not configured', () => {
|
|
241
|
+
before(() => {
|
|
242
|
+
withEnv({ SMTP_HOST: undefined }, () => { initEmail(null); });
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('returns failure when not configured', async () => {
|
|
246
|
+
const result = await verifyConnection();
|
|
247
|
+
assert.strictEqual(result.success, false);
|
|
248
|
+
assert.ok(result.message.includes('not configured'));
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// ── Schema validation ────────────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
describe('schema: email', () => {
|
|
255
|
+
it('accepts config without email section', () => {
|
|
256
|
+
assert.strictEqual(validateSchema({ name: 'App' }), true);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('accepts email with host only', () => {
|
|
260
|
+
assert.strictEqual(validateSchema({
|
|
261
|
+
name: 'App',
|
|
262
|
+
email: { host: 'smtp.example.com' },
|
|
263
|
+
}), true);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('accepts email with all fields', () => {
|
|
267
|
+
assert.strictEqual(validateSchema({
|
|
268
|
+
name: 'App',
|
|
269
|
+
email: {
|
|
270
|
+
host: 'smtp.example.com',
|
|
271
|
+
port: 587,
|
|
272
|
+
username: 'user@example.com',
|
|
273
|
+
from: 'App <noreply@example.com>',
|
|
274
|
+
secure: false,
|
|
275
|
+
},
|
|
276
|
+
}), true);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('accepts email with templates', () => {
|
|
280
|
+
assert.strictEqual(validateSchema({
|
|
281
|
+
name: 'App',
|
|
282
|
+
email: {
|
|
283
|
+
host: 'smtp.example.com',
|
|
284
|
+
templates: {
|
|
285
|
+
verification: {
|
|
286
|
+
subject: 'Verify {{appName}}',
|
|
287
|
+
text: 'Click {{link}}',
|
|
288
|
+
html: '<a href="{{link}}">Verify</a>',
|
|
289
|
+
},
|
|
290
|
+
passwordReset: {
|
|
291
|
+
subject: 'Reset {{appName}}',
|
|
292
|
+
text: 'Click {{link}}',
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
}), true);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('accepts empty email object', () => {
|
|
300
|
+
assert.strictEqual(validateSchema({ name: 'App', email: {} }), true);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('rejects unknown email key', () => {
|
|
304
|
+
assert.throws(() => validateSchema({
|
|
305
|
+
name: 'App',
|
|
306
|
+
email: { host: 'smtp.example.com', password: 'secret' },
|
|
307
|
+
}));
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('rejects email port as string', () => {
|
|
311
|
+
assert.throws(() => validateSchema({
|
|
312
|
+
name: 'App',
|
|
313
|
+
email: { host: 'smtp.example.com', port: '587' },
|
|
314
|
+
}));
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('rejects unknown template type', () => {
|
|
318
|
+
assert.throws(() => validateSchema({
|
|
319
|
+
name: 'App',
|
|
320
|
+
email: {
|
|
321
|
+
host: 'smtp.example.com',
|
|
322
|
+
templates: {
|
|
323
|
+
welcome: { subject: 'Hello' },
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
}));
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// ── buildCore: email passthrough ─────────────────────────────────────────
|
|
331
|
+
|
|
332
|
+
describe('buildCore: email passthrough', () => {
|
|
333
|
+
it('exposes email config when provided', () => {
|
|
334
|
+
const core = buildCore({
|
|
335
|
+
name: 'App',
|
|
336
|
+
email: { host: 'smtp.example.com', port: 587, from: 'App <noreply@example.com>' },
|
|
337
|
+
});
|
|
338
|
+
assert.ok(core.email);
|
|
339
|
+
assert.strictEqual(core.email.host, 'smtp.example.com');
|
|
340
|
+
assert.strictEqual(core.email.port, 587);
|
|
341
|
+
assert.strictEqual(core.email.from, 'App <noreply@example.com>');
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('sets email to null when not provided', () => {
|
|
345
|
+
const core = buildCore({ name: 'App' });
|
|
346
|
+
assert.strictEqual(core.email, null);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('passes templates through', () => {
|
|
350
|
+
const core = buildCore({
|
|
351
|
+
name: 'App',
|
|
352
|
+
email: {
|
|
353
|
+
host: 'smtp.example.com',
|
|
354
|
+
templates: {
|
|
355
|
+
verification: { subject: 'Verify', text: 'Click {{link}}' },
|
|
356
|
+
},
|
|
357
|
+
},
|
|
358
|
+
});
|
|
359
|
+
assert.ok(core.email.templates);
|
|
360
|
+
assert.strictEqual(core.email.templates.verification.subject, 'Verify');
|
|
361
|
+
});
|
|
362
|
+
});
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('assert');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const { buildCore } = require('../core/entity-engine');
|
|
8
|
+
const dbModule = require('../core/db');
|
|
9
|
+
const { initLogs, insertLog, queryLogs, cleanupOldLogs, requestLoggerMiddleware } = require('../core/logs');
|
|
10
|
+
const { validateSchema } = require('../core/schema-validator');
|
|
11
|
+
|
|
12
|
+
// ── Logs module ──────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
describe('logs module', () => {
|
|
15
|
+
let tmpDb;
|
|
16
|
+
const core = buildCore({ name: 'LogTest', entities: { Widget: { properties: ['name'] } } });
|
|
17
|
+
|
|
18
|
+
before(async () => {
|
|
19
|
+
tmpDb = path.join(os.tmpdir(), `chadstart-logs-${Date.now()}.db`);
|
|
20
|
+
await dbModule.initDb(core, tmpDb);
|
|
21
|
+
await initLogs();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
after(() => { try { fs.unlinkSync(tmpDb); } catch { /* noop */ } });
|
|
25
|
+
|
|
26
|
+
it('initLogs creates _cs_logs table', () => {
|
|
27
|
+
const tables = dbModule.getDb().prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='_cs_logs'").all();
|
|
28
|
+
assert.ok(tables.length > 0, '_cs_logs table should exist');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('insertLog inserts a record', async () => {
|
|
32
|
+
await insertLog({
|
|
33
|
+
method: 'GET',
|
|
34
|
+
path: '/api/collections/widgets',
|
|
35
|
+
statusCode: 200,
|
|
36
|
+
duration: 42,
|
|
37
|
+
ip: '127.0.0.1',
|
|
38
|
+
});
|
|
39
|
+
const result = await queryLogs();
|
|
40
|
+
assert.ok(result.data.length >= 1);
|
|
41
|
+
assert.strictEqual(result.data[0].method, 'GET');
|
|
42
|
+
assert.strictEqual(result.data[0].statusCode, 200);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('insertLog with user info', async () => {
|
|
46
|
+
await insertLog({
|
|
47
|
+
method: 'POST',
|
|
48
|
+
path: '/api/auth/admin/login',
|
|
49
|
+
statusCode: 200,
|
|
50
|
+
duration: 100,
|
|
51
|
+
ip: '192.168.1.1',
|
|
52
|
+
userId: 'user-123',
|
|
53
|
+
userEntity: 'Admin',
|
|
54
|
+
});
|
|
55
|
+
const result = await queryLogs({ method: 'POST' });
|
|
56
|
+
const log = result.data.find((l) => l.userId === 'user-123');
|
|
57
|
+
assert.ok(log);
|
|
58
|
+
assert.strictEqual(log.userEntity, 'Admin');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('queryLogs returns paginated results', async () => {
|
|
62
|
+
// Insert several logs
|
|
63
|
+
for (let i = 0; i < 5; i++) {
|
|
64
|
+
await insertLog({ method: 'GET', path: `/api/test/${i}`, statusCode: 200, duration: 10 });
|
|
65
|
+
}
|
|
66
|
+
const result = await queryLogs({}, { page: 1, perPage: 3 });
|
|
67
|
+
assert.ok(result.data.length <= 3);
|
|
68
|
+
assert.strictEqual(result.perPage, 3);
|
|
69
|
+
assert.strictEqual(result.currentPage, 1);
|
|
70
|
+
assert.ok(result.total >= 5);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('queryLogs filters by method', async () => {
|
|
74
|
+
await insertLog({ method: 'DELETE', path: '/api/delete-test', statusCode: 204, duration: 5 });
|
|
75
|
+
const result = await queryLogs({ method: 'DELETE' });
|
|
76
|
+
assert.ok(result.data.length >= 1);
|
|
77
|
+
assert.ok(result.data.every((l) => l.method === 'DELETE'));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('queryLogs filters by statusCode', async () => {
|
|
81
|
+
await insertLog({ method: 'GET', path: '/api/not-found', statusCode: 404, duration: 1 });
|
|
82
|
+
const result = await queryLogs({ statusCode: 404 });
|
|
83
|
+
assert.ok(result.data.length >= 1);
|
|
84
|
+
assert.ok(result.data.every((l) => l.statusCode === 404));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('queryLogs filters by path', async () => {
|
|
88
|
+
await insertLog({ method: 'GET', path: '/api/collections/unique-path', statusCode: 200, duration: 1 });
|
|
89
|
+
const result = await queryLogs({ path: 'unique-path' });
|
|
90
|
+
assert.ok(result.data.length >= 1);
|
|
91
|
+
assert.ok(result.data.every((l) => l.path.includes('unique-path')));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('queryLogs filters by date range', async () => {
|
|
95
|
+
const now = new Date();
|
|
96
|
+
const from = new Date(now - 60 * 1000).toISOString(); // 1 min ago
|
|
97
|
+
const to = new Date(now.getTime() + 60 * 1000).toISOString(); // 1 min ahead
|
|
98
|
+
const result = await queryLogs({ from, to });
|
|
99
|
+
assert.ok(result.data.length >= 1);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('queryLogs respects order parameter', async () => {
|
|
103
|
+
const resultAsc = await queryLogs({}, { order: 'ASC' });
|
|
104
|
+
const resultDesc = await queryLogs({}, { order: 'DESC' });
|
|
105
|
+
if (resultAsc.data.length > 1 && resultDesc.data.length > 1) {
|
|
106
|
+
// ASC: oldest first, DESC: newest first
|
|
107
|
+
assert.ok(resultAsc.data[0].createdAt <= resultAsc.data[resultAsc.data.length - 1].createdAt);
|
|
108
|
+
assert.ok(resultDesc.data[0].createdAt >= resultDesc.data[resultDesc.data.length - 1].createdAt);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('cleanupOldLogs removes old entries', async () => {
|
|
113
|
+
// Insert a log with a manually backdated createdAt
|
|
114
|
+
const oldDate = new Date(Date.now() - 60 * 24 * 60 * 60 * 1000).toISOString(); // 60 days ago
|
|
115
|
+
await dbModule.queryRun(
|
|
116
|
+
`INSERT INTO "_cs_logs" ("id","method","path","statusCode","duration","ip","createdAt") VALUES (?,?,?,?,?,?,?)`,
|
|
117
|
+
['old-log-id', 'GET', '/api/old', 200, 1, '127.0.0.1', oldDate]
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
// Verify it exists
|
|
121
|
+
const before = await dbModule.queryOne(
|
|
122
|
+
`SELECT * FROM "_cs_logs" WHERE "id" = ?`, ['old-log-id']
|
|
123
|
+
);
|
|
124
|
+
assert.ok(before);
|
|
125
|
+
|
|
126
|
+
// Cleanup logs older than 30 days
|
|
127
|
+
await cleanupOldLogs(30);
|
|
128
|
+
|
|
129
|
+
// Verify it's been deleted
|
|
130
|
+
const after = await dbModule.queryOne(
|
|
131
|
+
`SELECT * FROM "_cs_logs" WHERE "id" = ?`, ['old-log-id']
|
|
132
|
+
);
|
|
133
|
+
assert.ok(!after);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('cleanupOldLogs keeps recent entries', async () => {
|
|
137
|
+
const beforeCount = (await queryLogs()).total;
|
|
138
|
+
await cleanupOldLogs(30);
|
|
139
|
+
const afterCount = (await queryLogs()).total;
|
|
140
|
+
// Recent entries should not be deleted
|
|
141
|
+
assert.ok(afterCount >= 1);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('cleanupOldLogs with 0 days does nothing', async () => {
|
|
145
|
+
const beforeCount = (await queryLogs()).total;
|
|
146
|
+
const deleted = await cleanupOldLogs(0);
|
|
147
|
+
assert.strictEqual(deleted, 0);
|
|
148
|
+
const afterCount = (await queryLogs()).total;
|
|
149
|
+
assert.strictEqual(beforeCount, afterCount);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// ── requestLoggerMiddleware ──────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
describe('requestLoggerMiddleware', () => {
|
|
156
|
+
it('returns a function', () => {
|
|
157
|
+
const mw = requestLoggerMiddleware();
|
|
158
|
+
assert.strictEqual(typeof mw, 'function');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('calls next()', (done) => {
|
|
162
|
+
const mw = requestLoggerMiddleware();
|
|
163
|
+
const mockReq = { method: 'GET', path: '/api/test', originalUrl: '/api/test', ip: '127.0.0.1', connection: {} };
|
|
164
|
+
const mockRes = {
|
|
165
|
+
statusCode: 200,
|
|
166
|
+
end: function (...args) {
|
|
167
|
+
// original end
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
mw(mockReq, mockRes, done);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('skips excluded paths', (done) => {
|
|
174
|
+
const mw = requestLoggerMiddleware({ exclude: ['/health'] });
|
|
175
|
+
const mockReq = { method: 'GET', path: '/health', originalUrl: '/health', ip: '127.0.0.1' };
|
|
176
|
+
const mockRes = { statusCode: 200 };
|
|
177
|
+
mw(mockReq, mockRes, done);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('skips excluded path prefixes', (done) => {
|
|
181
|
+
const mw = requestLoggerMiddleware({ exclude: ['/admin/vendor'] });
|
|
182
|
+
const mockReq = { method: 'GET', path: '/admin/vendor/htmx.min.js', originalUrl: '/admin/vendor/htmx.min.js' };
|
|
183
|
+
const mockRes = { statusCode: 200 };
|
|
184
|
+
mw(mockReq, mockRes, done);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// ── Schema validation ───────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
describe('schema: logs', () => {
|
|
191
|
+
it('accepts config without logs section', () => {
|
|
192
|
+
assert.strictEqual(validateSchema({ name: 'App' }), true);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('accepts logs with retention', () => {
|
|
196
|
+
assert.strictEqual(validateSchema({ name: 'App', logs: { retention: 7 } }), true);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('accepts logs with exclude array', () => {
|
|
200
|
+
assert.strictEqual(validateSchema({
|
|
201
|
+
name: 'App',
|
|
202
|
+
logs: { exclude: ['/health', '/admin/vendor'] },
|
|
203
|
+
}), true);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('accepts empty logs object', () => {
|
|
207
|
+
assert.strictEqual(validateSchema({ name: 'App', logs: {} }), true);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('rejects unknown logs key', () => {
|
|
211
|
+
assert.throws(() => validateSchema({
|
|
212
|
+
name: 'App',
|
|
213
|
+
logs: { verbose: true },
|
|
214
|
+
}));
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('rejects retention as string', () => {
|
|
218
|
+
assert.throws(() => validateSchema({
|
|
219
|
+
name: 'App',
|
|
220
|
+
logs: { retention: '30' },
|
|
221
|
+
}));
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// ── buildCore: logs passthrough ─────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
describe('buildCore: logs passthrough', () => {
|
|
228
|
+
it('exposes logs config when provided', () => {
|
|
229
|
+
const core = buildCore({ name: 'App', logs: { retention: 7, exclude: ['/health'] } });
|
|
230
|
+
assert.ok(core.logs);
|
|
231
|
+
assert.strictEqual(core.logs.retention, 7);
|
|
232
|
+
assert.deepStrictEqual(core.logs.exclude, ['/health']);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('sets logs to null when not provided', () => {
|
|
236
|
+
const core = buildCore({ name: 'App' });
|
|
237
|
+
assert.strictEqual(core.logs, null);
|
|
238
|
+
});
|
|
239
|
+
});
|