chadstart 1.0.0
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/.dockerignore +10 -0
- package/.env.example +46 -0
- package/.github/workflows/browser-test.yml +34 -0
- package/.github/workflows/docker-publish.yml +54 -0
- package/.github/workflows/docs.yml +31 -0
- package/.github/workflows/npm-chadstart.yml +27 -0
- package/.github/workflows/npm-sdk.yml +38 -0
- package/.github/workflows/test.yml +85 -0
- package/.weblate +9 -0
- package/Dockerfile +23 -0
- package/README.md +348 -0
- package/admin/index.html +2802 -0
- package/admin/login.html +207 -0
- package/chadstart.example.yml +416 -0
- package/chadstart.schema.json +367 -0
- package/chadstart.yaml +53 -0
- package/cli/cli.js +295 -0
- package/core/api-generator.js +606 -0
- package/core/auth.js +298 -0
- package/core/db.js +384 -0
- package/core/entity-engine.js +166 -0
- package/core/error-reporter.js +132 -0
- package/core/file-storage.js +97 -0
- package/core/functions-engine.js +353 -0
- package/core/openapi.js +171 -0
- package/core/plugin-loader.js +92 -0
- package/core/realtime.js +93 -0
- package/core/schema-validator.js +50 -0
- package/core/seeder.js +231 -0
- package/core/telemetry.js +119 -0
- package/core/upload.js +372 -0
- package/core/workers/php_worker.php +19 -0
- package/core/workers/python_worker.py +33 -0
- package/core/workers/ruby_worker.rb +21 -0
- package/core/yaml-loader.js +64 -0
- package/demo/chadstart.yaml +178 -0
- package/demo/docker-compose.yml +31 -0
- package/demo/functions/greet.go +39 -0
- package/demo/functions/hello.cpp +18 -0
- package/demo/functions/hello.py +13 -0
- package/demo/functions/hello.rb +10 -0
- package/demo/functions/onTodoCreated.js +13 -0
- package/demo/functions/ping.sh +13 -0
- package/demo/functions/stats.js +22 -0
- package/demo/public/index.html +522 -0
- package/docker-compose.yml +17 -0
- package/docs/access-policies.md +155 -0
- package/docs/admin-ui.md +29 -0
- package/docs/angular.md +69 -0
- package/docs/astro.md +71 -0
- package/docs/auth.md +160 -0
- package/docs/cli.md +56 -0
- package/docs/config.md +127 -0
- package/docs/crud.md +627 -0
- package/docs/deploy.md +113 -0
- package/docs/docker.md +59 -0
- package/docs/entities.md +385 -0
- package/docs/functions.md +196 -0
- package/docs/getting-started.md +79 -0
- package/docs/groups.md +85 -0
- package/docs/index.md +5 -0
- package/docs/llm-rules.md +81 -0
- package/docs/middlewares.md +78 -0
- package/docs/overrides/home.html +350 -0
- package/docs/plugins.md +59 -0
- package/docs/react.md +75 -0
- package/docs/realtime.md +43 -0
- package/docs/s3-storage.md +40 -0
- package/docs/security.md +23 -0
- package/docs/stylesheets/extra.css +375 -0
- package/docs/svelte.md +71 -0
- package/docs/telemetry.md +97 -0
- package/docs/upload.md +168 -0
- package/docs/validation.md +115 -0
- package/docs/vue.md +86 -0
- package/docs/webhooks.md +87 -0
- package/index.js +11 -0
- package/locales/en/admin.json +169 -0
- package/mkdocs.yml +82 -0
- package/package.json +65 -0
- package/playwright.config.js +24 -0
- package/public/.gitkeep +0 -0
- package/sdk/README.md +284 -0
- package/sdk/package.json +39 -0
- package/sdk/scripts/build.js +58 -0
- package/sdk/src/index.js +368 -0
- package/sdk/test/sdk.test.cjs +340 -0
- package/sdk/types/index.d.ts +217 -0
- package/server/express-server.js +734 -0
- package/test/access-policies.test.js +96 -0
- package/test/ai.test.js +81 -0
- package/test/api-keys.test.js +361 -0
- package/test/auth.test.js +122 -0
- package/test/browser/admin-ui.spec.js +127 -0
- package/test/browser/global-setup.js +71 -0
- package/test/browser/global-teardown.js +11 -0
- package/test/db.test.js +227 -0
- package/test/entity-engine.test.js +193 -0
- package/test/error-reporter.test.js +140 -0
- package/test/functions-engine.test.js +240 -0
- package/test/groups.test.js +212 -0
- package/test/hot-reload.test.js +153 -0
- package/test/i18n.test.js +173 -0
- package/test/middleware.test.js +76 -0
- package/test/openapi.test.js +67 -0
- package/test/schema-validator.test.js +83 -0
- package/test/sdk.test.js +90 -0
- package/test/seeder.test.js +279 -0
- package/test/settings.test.js +109 -0
- package/test/telemetry.test.js +254 -0
- package/test/test.js +17 -0
- package/test/upload.test.js +265 -0
- package/test/validation.test.js +96 -0
- package/test/yaml-loader.test.js +93 -0
- package/utils/logger.js +24 -0
|
@@ -0,0 +1,279 @@
|
|
|
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 bcrypt = require('bcryptjs');
|
|
8
|
+
const { buildCore } = require('../core/entity-engine');
|
|
9
|
+
const dbModule = require('../core/db');
|
|
10
|
+
const { seedAll, ADMIN_EMAIL, ADMIN_PASSWORD } = require('../core/seeder');
|
|
11
|
+
|
|
12
|
+
describe('seeder', () => {
|
|
13
|
+
let seedDbPath;
|
|
14
|
+
let firstSeedResult;
|
|
15
|
+
const seedCore = buildCore({
|
|
16
|
+
name: 'SeedTest',
|
|
17
|
+
entities: {
|
|
18
|
+
Author: {
|
|
19
|
+
authenticable: true,
|
|
20
|
+
properties: ['name'],
|
|
21
|
+
seedCount: 3,
|
|
22
|
+
},
|
|
23
|
+
Article: {
|
|
24
|
+
properties: [
|
|
25
|
+
{ name: 'title', type: 'string' },
|
|
26
|
+
{ name: 'body', type: 'text' },
|
|
27
|
+
{ name: 'views', type: 'integer' },
|
|
28
|
+
{ name: 'published', type: 'boolean' },
|
|
29
|
+
],
|
|
30
|
+
belongsTo: ['Author'],
|
|
31
|
+
seedCount: 5,
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
before(async () => {
|
|
37
|
+
seedDbPath = path.join(os.tmpdir(), `chadstart-seed-${Date.now()}.db`);
|
|
38
|
+
dbModule.initDb(seedCore, seedDbPath);
|
|
39
|
+
firstSeedResult = await seedAll(seedCore);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
after(() => { fs.unlinkSync(seedDbPath); });
|
|
43
|
+
|
|
44
|
+
it('seedAll returns correct counts', () => {
|
|
45
|
+
assert.strictEqual(firstSeedResult.summary.Author, 3);
|
|
46
|
+
assert.strictEqual(firstSeedResult.summary.Article, 5);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('seedAll inserts rows into the database', () => {
|
|
50
|
+
const authors = dbModule.findAll('author', {}, { perPage: 100 });
|
|
51
|
+
assert.ok(authors.total >= 3);
|
|
52
|
+
const articles = dbModule.findAll('article', {}, { perPage: 100 });
|
|
53
|
+
assert.ok(articles.total >= 5);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('seedAll creates authenticable records with email field', () => {
|
|
57
|
+
const authors = dbModule.findAll('author', {}, { perPage: 100 });
|
|
58
|
+
for (const a of authors.data) {
|
|
59
|
+
assert.ok(typeof a.email === 'string' && a.email.includes('@'));
|
|
60
|
+
assert.ok(typeof a.password === 'string' && a.password.length > 0);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('seedAll links belongsTo FK to a seeded parent', () => {
|
|
65
|
+
const articles = dbModule.findAll('article', {}, { perPage: 100 });
|
|
66
|
+
for (const art of articles.data) {
|
|
67
|
+
assert.ok(art.author_id !== null && art.author_id !== undefined);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('seedAll respects default seedCount of 50', async () => {
|
|
72
|
+
const defaultCore = buildCore({
|
|
73
|
+
name: 'DefaultSeed',
|
|
74
|
+
entities: { Tag: { properties: ['label'] } },
|
|
75
|
+
});
|
|
76
|
+
const defaultDbPath = path.join(os.tmpdir(), `chadstart-seed-default-${Date.now()}.db`);
|
|
77
|
+
dbModule.initDb(defaultCore, defaultDbPath);
|
|
78
|
+
const result = await seedAll(defaultCore);
|
|
79
|
+
assert.strictEqual(result.summary.Tag, 50);
|
|
80
|
+
fs.unlinkSync(defaultDbPath);
|
|
81
|
+
// Restore the original seedCore DB for subsequent tests in this describe block
|
|
82
|
+
dbModule.initDb(seedCore, seedDbPath);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('seedAll creates admin@chadstart.com in authenticable entities', () => {
|
|
86
|
+
assert.ok(firstSeedResult.adminEntities.includes('Author'));
|
|
87
|
+
assert.strictEqual(firstSeedResult.adminEmail, ADMIN_EMAIL);
|
|
88
|
+
assert.strictEqual(firstSeedResult.adminPassword, ADMIN_PASSWORD);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('seedAll creates admin user with correct email in the database', () => {
|
|
92
|
+
dbModule.initDb(seedCore, seedDbPath);
|
|
93
|
+
const admins = dbModule.findAllSimple('author', { email: ADMIN_EMAIL });
|
|
94
|
+
assert.strictEqual(admins.length, 1);
|
|
95
|
+
assert.strictEqual(admins[0].email, ADMIN_EMAIL);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('seedAll creates admin user in a fresh database', async () => {
|
|
99
|
+
const freshCore = buildCore({
|
|
100
|
+
name: 'FreshAdminTest',
|
|
101
|
+
entities: {
|
|
102
|
+
User: {
|
|
103
|
+
authenticable: true,
|
|
104
|
+
properties: ['name'],
|
|
105
|
+
seedCount: 2,
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
const freshDbPath = path.join(os.tmpdir(), `chadstart-seed-admin-${Date.now()}.db`);
|
|
110
|
+
dbModule.initDb(freshCore, freshDbPath);
|
|
111
|
+
const result = await seedAll(freshCore);
|
|
112
|
+
assert.ok(result.adminEntities.includes('User'));
|
|
113
|
+
const admins = dbModule.findAllSimple('user', { email: ADMIN_EMAIL });
|
|
114
|
+
assert.strictEqual(admins.length, 1);
|
|
115
|
+
assert.strictEqual(admins[0].email, ADMIN_EMAIL);
|
|
116
|
+
fs.unlinkSync(freshDbPath);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('seedAll does not create duplicate admin user when one already exists', async () => {
|
|
120
|
+
const dupCore = buildCore({
|
|
121
|
+
name: 'DupAdminTest',
|
|
122
|
+
admin: { enable_entity: false },
|
|
123
|
+
entities: {
|
|
124
|
+
Member: {
|
|
125
|
+
authenticable: true,
|
|
126
|
+
properties: ['name'],
|
|
127
|
+
seedCount: 2,
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
const dupDbPath = path.join(os.tmpdir(), `chadstart-seed-dup-${Date.now()}.db`);
|
|
132
|
+
dbModule.initDb(dupCore, dupDbPath);
|
|
133
|
+
// Manually create the admin user before seeding
|
|
134
|
+
dbModule.create('member', {
|
|
135
|
+
email: ADMIN_EMAIL,
|
|
136
|
+
password: bcrypt.hashSync(ADMIN_PASSWORD, 10),
|
|
137
|
+
name: 'pre-existing admin',
|
|
138
|
+
});
|
|
139
|
+
// seedAll should not create a duplicate
|
|
140
|
+
const result = await seedAll(dupCore);
|
|
141
|
+
assert.strictEqual(result.adminEntities.length, 0);
|
|
142
|
+
const admins = dbModule.findAllSimple('member', { email: ADMIN_EMAIL });
|
|
143
|
+
assert.strictEqual(admins.length, 1);
|
|
144
|
+
fs.unlinkSync(dupDbPath);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe('seeder – property types', () => {
|
|
149
|
+
let tmp;
|
|
150
|
+
const core = buildCore({
|
|
151
|
+
name: 'TypeTest',
|
|
152
|
+
entities: {
|
|
153
|
+
Sample: {
|
|
154
|
+
properties: [
|
|
155
|
+
{ name: 'myText', type: 'text' },
|
|
156
|
+
{ name: 'myRichText', type: 'richText' },
|
|
157
|
+
{ name: 'myInt', type: 'integer' },
|
|
158
|
+
{ name: 'myFloat', type: 'float' },
|
|
159
|
+
{ name: 'myReal', type: 'real' },
|
|
160
|
+
{ name: 'myMoney', type: 'money' },
|
|
161
|
+
{ name: 'myBool', type: 'boolean' },
|
|
162
|
+
{ name: 'myDate', type: 'date' },
|
|
163
|
+
{ name: 'myTimestamp', type: 'timestamp' },
|
|
164
|
+
{ name: 'myEmail', type: 'email' },
|
|
165
|
+
{ name: 'myLink', type: 'link' },
|
|
166
|
+
{ name: 'myPass', type: 'password' },
|
|
167
|
+
{ name: 'myChoice', type: 'choice' },
|
|
168
|
+
{ name: 'myLocation', type: 'location' },
|
|
169
|
+
{ name: 'myFile', type: 'file' },
|
|
170
|
+
{ name: 'myImage', type: 'image' },
|
|
171
|
+
{ name: 'myJson', type: 'json' },
|
|
172
|
+
{ name: 'myUnknown', type: 'custom_unknown' },
|
|
173
|
+
{ name: 'myOption', type: 'string', options: ['a', 'b', 'c'] },
|
|
174
|
+
],
|
|
175
|
+
seedCount: 3,
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
before(() => {
|
|
181
|
+
tmp = path.join(os.tmpdir(), `chadstart-seedtypes-${Date.now()}.db`);
|
|
182
|
+
dbModule.initDb(core, tmp);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
after(() => { fs.unlinkSync(tmp); });
|
|
186
|
+
|
|
187
|
+
it('seedAll generates values for every property type', async () => {
|
|
188
|
+
const result = await seedAll(core);
|
|
189
|
+
assert.strictEqual(result.summary.Sample, 3);
|
|
190
|
+
const rows = dbModule.findAll('sample', {}, { perPage: 100 });
|
|
191
|
+
assert.strictEqual(rows.total, 3);
|
|
192
|
+
const r = rows.data[0];
|
|
193
|
+
assert.ok(typeof r.myText === 'string' && r.myText.length > 0);
|
|
194
|
+
assert.ok(typeof r.myRichText === 'string');
|
|
195
|
+
assert.ok(typeof r.myInt === 'number');
|
|
196
|
+
assert.ok(typeof r.myFloat === 'number');
|
|
197
|
+
assert.ok(typeof r.myReal === 'number');
|
|
198
|
+
assert.ok(typeof r.myMoney === 'number');
|
|
199
|
+
assert.ok(r.myBool === 0 || r.myBool === 1);
|
|
200
|
+
assert.ok(typeof r.myDate === 'string' && r.myDate.length === 10);
|
|
201
|
+
assert.ok(typeof r.myTimestamp === 'string');
|
|
202
|
+
assert.ok(r.myEmail.includes('@'));
|
|
203
|
+
assert.ok(r.myLink.startsWith('https://'));
|
|
204
|
+
assert.ok(typeof r.myPass === 'string' && r.myPass.length > 0);
|
|
205
|
+
assert.ok(typeof r.myChoice === 'string');
|
|
206
|
+
assert.ok(r.myLocation.includes(','));
|
|
207
|
+
assert.ok(r.myFile.startsWith('/uploads/'));
|
|
208
|
+
assert.ok(r.myImage.startsWith('/uploads/'));
|
|
209
|
+
assert.doesNotThrow(() => JSON.parse(r.myJson));
|
|
210
|
+
assert.ok(typeof r.myUnknown === 'string');
|
|
211
|
+
assert.ok(['a', 'b', 'c'].includes(r.myOption));
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('seedAll seeds a single entity exactly once', async () => {
|
|
215
|
+
const singleCore = buildCore({
|
|
216
|
+
name: 'SingleTest',
|
|
217
|
+
entities: { Config: { single: true, properties: ['key', 'value'] } },
|
|
218
|
+
});
|
|
219
|
+
const singleTmp = path.join(os.tmpdir(), `chadstart-seedsingle-${Date.now()}.db`);
|
|
220
|
+
dbModule.initDb(singleCore, singleTmp);
|
|
221
|
+
const result = await seedAll(singleCore);
|
|
222
|
+
assert.strictEqual(result.summary.Config, 1);
|
|
223
|
+
fs.unlinkSync(singleTmp);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe('seeder – authenticable entities with explicit email/password properties', () => {
|
|
228
|
+
let tmp;
|
|
229
|
+
const core = buildCore({
|
|
230
|
+
name: 'AuthPropTest',
|
|
231
|
+
entities: {
|
|
232
|
+
Customer: {
|
|
233
|
+
authenticable: true,
|
|
234
|
+
// email and password explicitly listed — seeder should handle these correctly
|
|
235
|
+
properties: [
|
|
236
|
+
{ name: 'email', type: 'email' },
|
|
237
|
+
{ name: 'password', type: 'password' },
|
|
238
|
+
{ name: 'name', type: 'string' },
|
|
239
|
+
],
|
|
240
|
+
seedCount: 3,
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
before(() => {
|
|
246
|
+
tmp = path.join(os.tmpdir(), `chadstart-authprop-${Date.now()}.db`);
|
|
247
|
+
dbModule.initDb(core, tmp);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
after(() => { fs.unlinkSync(tmp); });
|
|
251
|
+
|
|
252
|
+
it('initDb does not fail with duplicate email/password columns', () => {
|
|
253
|
+
// The DB was already initialised in before() — if we get here, no duplicate column error
|
|
254
|
+
const cols = dbModule.getDb().pragma('table_info("customer")').map((r) => r.name);
|
|
255
|
+
assert.ok(cols.includes('email'));
|
|
256
|
+
assert.ok(cols.includes('password'));
|
|
257
|
+
assert.ok(cols.includes('name'));
|
|
258
|
+
// email should appear exactly once
|
|
259
|
+
assert.strictEqual(cols.filter((c) => c === 'email').length, 1);
|
|
260
|
+
assert.strictEqual(cols.filter((c) => c === 'password').length, 1);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('seedAll succeeds and creates records with valid email addresses', async () => {
|
|
264
|
+
const result = await seedAll(core);
|
|
265
|
+
assert.strictEqual(result.summary.Customer, 3);
|
|
266
|
+
const rows = dbModule.findAll('customer', {}, { perPage: 100 });
|
|
267
|
+
assert.ok(rows.total >= 3);
|
|
268
|
+
for (const r of rows.data) {
|
|
269
|
+
assert.ok(typeof r.email === 'string' && r.email.includes('@'), `email should contain @, got: ${r.email}`);
|
|
270
|
+
assert.ok(typeof r.password === 'string' && r.password.length > 0);
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('seedAll creates admin user with correct email when entity has explicit email property', async () => {
|
|
275
|
+
const admins = dbModule.findAllSimple('customer', { email: ADMIN_EMAIL });
|
|
276
|
+
assert.strictEqual(admins.length, 1);
|
|
277
|
+
assert.strictEqual(admins[0].email, ADMIN_EMAIL);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('assert');
|
|
4
|
+
const { buildCore } = require('../core/entity-engine');
|
|
5
|
+
const { validateSchema } = require('../core/schema-validator');
|
|
6
|
+
const { buildApiLimiters } = require('../server/express-server');
|
|
7
|
+
|
|
8
|
+
describe('rateLimits', () => {
|
|
9
|
+
it('schema accepts top-level rateLimits', () => {
|
|
10
|
+
assert.strictEqual(validateSchema({
|
|
11
|
+
name: 'App',
|
|
12
|
+
rateLimits: [
|
|
13
|
+
{ name: 'short', limit: 2, ttl: 1000 },
|
|
14
|
+
{ name: 'medium', limit: 50, ttl: 60000 },
|
|
15
|
+
],
|
|
16
|
+
}), true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('schema rejects rateLimit missing required name', () => {
|
|
20
|
+
assert.throws(() => validateSchema({ name: 'App', rateLimits: [{ limit: 2, ttl: 1000 }] }));
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('schema rejects rateLimit missing required limit', () => {
|
|
24
|
+
assert.throws(() => validateSchema({ name: 'App', rateLimits: [{ name: 'short', ttl: 1000 }] }));
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('schema rejects rateLimit missing required ttl', () => {
|
|
28
|
+
assert.throws(() => validateSchema({ name: 'App', rateLimits: [{ name: 'short', limit: 2 }] }));
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('schema rejects unknown top-level key', () => {
|
|
32
|
+
assert.throws(() => validateSchema({ name: 'App', unknownKey: true }));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('schema rejects settings property (removed)', () => {
|
|
36
|
+
assert.throws(() => validateSchema({ name: 'App', settings: { rateLimits: [] } }));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('buildCore exposes top-level rateLimits', () => {
|
|
40
|
+
const core = buildCore({
|
|
41
|
+
name: 'App',
|
|
42
|
+
rateLimits: [
|
|
43
|
+
{ name: 'short', limit: 2, ttl: 1000 },
|
|
44
|
+
{ name: 'medium', limit: 50, ttl: 60000 },
|
|
45
|
+
],
|
|
46
|
+
});
|
|
47
|
+
assert.ok(core.rateLimits);
|
|
48
|
+
assert.strictEqual(core.rateLimits.length, 2);
|
|
49
|
+
assert.strictEqual(core.rateLimits[0].name, 'short');
|
|
50
|
+
assert.strictEqual(core.rateLimits[0].limit, 2);
|
|
51
|
+
assert.strictEqual(core.rateLimits[0].ttl, 1000);
|
|
52
|
+
assert.strictEqual(core.rateLimits[1].name, 'medium');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('buildCore sets rateLimits to null when not provided', () => {
|
|
56
|
+
const core = buildCore({ name: 'App' });
|
|
57
|
+
assert.strictEqual(core.rateLimits, null);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('env vars', () => {
|
|
62
|
+
it('PORT env var sets server port', () => {
|
|
63
|
+
const saved = process.env.PORT;
|
|
64
|
+
process.env.PORT = '4242';
|
|
65
|
+
delete process.env.CHADSTART_PORT;
|
|
66
|
+
const core = buildCore({ name: 'App' });
|
|
67
|
+
if (saved === undefined) delete process.env.PORT; else process.env.PORT = saved;
|
|
68
|
+
assert.strictEqual(core.port, 4242);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('CHADSTART_PORT takes precedence over PORT', () => {
|
|
72
|
+
const savedC = process.env.CHADSTART_PORT;
|
|
73
|
+
const savedP = process.env.PORT;
|
|
74
|
+
process.env.CHADSTART_PORT = '5555';
|
|
75
|
+
process.env.PORT = '6666';
|
|
76
|
+
const core = buildCore({ name: 'App' });
|
|
77
|
+
if (savedC === undefined) delete process.env.CHADSTART_PORT; else process.env.CHADSTART_PORT = savedC;
|
|
78
|
+
if (savedP === undefined) delete process.env.PORT; else process.env.PORT = savedP;
|
|
79
|
+
assert.strictEqual(core.port, 5555);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('buildApiLimiters', () => {
|
|
84
|
+
it('returns 1 default limiter when no rateLimits', () => {
|
|
85
|
+
const core = buildCore({ name: 'App' });
|
|
86
|
+
const limiters = buildApiLimiters(core);
|
|
87
|
+
assert.strictEqual(limiters.length, 1);
|
|
88
|
+
assert.strictEqual(typeof limiters[0], 'function');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('returns one limiter per configured rateLimit entry', () => {
|
|
92
|
+
const core = buildCore({
|
|
93
|
+
name: 'App',
|
|
94
|
+
rateLimits: [
|
|
95
|
+
{ name: 'short', limit: 2, ttl: 1000 },
|
|
96
|
+
{ name: 'medium', limit: 50, ttl: 60000 },
|
|
97
|
+
],
|
|
98
|
+
});
|
|
99
|
+
const limiters = buildApiLimiters(core);
|
|
100
|
+
assert.strictEqual(limiters.length, 2);
|
|
101
|
+
assert.ok(limiters.every((l) => typeof l === 'function'));
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('falls back to default when rateLimits is empty array', () => {
|
|
105
|
+
const core = buildCore({ name: 'App', rateLimits: [] });
|
|
106
|
+
const limiters = buildApiLimiters(core);
|
|
107
|
+
assert.strictEqual(limiters.length, 1);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('assert');
|
|
4
|
+
const { getTelemetryConfig, parseOtlpHeaders, shutdownTelemetry } = require('../core/telemetry');
|
|
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
|
+
describe('parseOtlpHeaders', () => {
|
|
23
|
+
it('parses a single key=value pair', () => {
|
|
24
|
+
const h = parseOtlpHeaders('authorization=Bearer token123');
|
|
25
|
+
assert.strictEqual(h.authorization, 'Bearer token123');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('parses multiple comma-separated pairs', () => {
|
|
29
|
+
const h = parseOtlpHeaders('x-api-key=secret,x-tenant=acme');
|
|
30
|
+
assert.strictEqual(h['x-api-key'], 'secret');
|
|
31
|
+
assert.strictEqual(h['x-tenant'], 'acme');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('trims whitespace around keys and values', () => {
|
|
35
|
+
const h = parseOtlpHeaders(' x-key = myvalue ');
|
|
36
|
+
assert.strictEqual(h['x-key'], 'myvalue');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('handles a value that contains = signs', () => {
|
|
40
|
+
const h = parseOtlpHeaders('authorization=Basic dXNlcjpwYXNz==');
|
|
41
|
+
assert.strictEqual(h.authorization, 'Basic dXNlcjpwYXNz==');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('skips pairs without = separator', () => {
|
|
45
|
+
const h = parseOtlpHeaders('noequals,k=v');
|
|
46
|
+
assert.strictEqual(Object.keys(h).length, 1);
|
|
47
|
+
assert.strictEqual(h.k, 'v');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('returns an empty object for an empty string', () => {
|
|
51
|
+
const h = parseOtlpHeaders('');
|
|
52
|
+
assert.deepStrictEqual(h, {});
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('getTelemetryConfig – disabled', () => {
|
|
57
|
+
it('returns null when no env var and no yaml config', () => {
|
|
58
|
+
withEnv({ OTEL_ENABLED: undefined }, () => {
|
|
59
|
+
assert.strictEqual(getTelemetryConfig(null), null);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('returns null when settings object has no telemetry key', () => {
|
|
64
|
+
withEnv({ OTEL_ENABLED: undefined }, () => {
|
|
65
|
+
assert.strictEqual(getTelemetryConfig({ rateLimits: [] }), null);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('returns null when telemetry.enabled is explicitly false', () => {
|
|
70
|
+
withEnv({ OTEL_ENABLED: undefined }, () => {
|
|
71
|
+
assert.strictEqual(getTelemetryConfig({ telemetry: { enabled: false } }), null);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('returns null when OTEL_ENABLED is set to a non-true value', () => {
|
|
76
|
+
withEnv({ OTEL_ENABLED: 'false' }, () => {
|
|
77
|
+
assert.strictEqual(getTelemetryConfig(null), null);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('getTelemetryConfig – enabled via env var', () => {
|
|
83
|
+
it('returns config when OTEL_ENABLED=true', () => {
|
|
84
|
+
withEnv({ OTEL_ENABLED: 'true', OTEL_SERVICE_NAME: undefined, OTEL_EXPORTER_OTLP_ENDPOINT: undefined, OTEL_EXPORTER_OTLP_HEADERS: undefined }, () => {
|
|
85
|
+
const cfg = getTelemetryConfig(null);
|
|
86
|
+
assert.ok(cfg);
|
|
87
|
+
assert.strictEqual(cfg.enabled, true);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('uses default serviceName when none provided', () => {
|
|
92
|
+
withEnv({ OTEL_ENABLED: 'true', OTEL_SERVICE_NAME: undefined, OTEL_EXPORTER_OTLP_ENDPOINT: undefined, OTEL_EXPORTER_OTLP_HEADERS: undefined }, () => {
|
|
93
|
+
const cfg = getTelemetryConfig(null);
|
|
94
|
+
assert.strictEqual(cfg.serviceName, 'chadstart-app');
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('uses default endpoint when none provided', () => {
|
|
99
|
+
withEnv({ OTEL_ENABLED: 'true', OTEL_SERVICE_NAME: undefined, OTEL_EXPORTER_OTLP_ENDPOINT: undefined, OTEL_EXPORTER_OTLP_HEADERS: undefined }, () => {
|
|
100
|
+
const cfg = getTelemetryConfig(null);
|
|
101
|
+
assert.strictEqual(cfg.endpoint, 'http://localhost:4318');
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('reads serviceName from OTEL_SERVICE_NAME env var', () => {
|
|
106
|
+
withEnv({ OTEL_ENABLED: 'true', OTEL_SERVICE_NAME: 'env-service', OTEL_EXPORTER_OTLP_ENDPOINT: undefined, OTEL_EXPORTER_OTLP_HEADERS: undefined }, () => {
|
|
107
|
+
const cfg = getTelemetryConfig(null);
|
|
108
|
+
assert.strictEqual(cfg.serviceName, 'env-service');
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('reads endpoint from OTEL_EXPORTER_OTLP_ENDPOINT env var', () => {
|
|
113
|
+
withEnv({ OTEL_ENABLED: 'true', OTEL_SERVICE_NAME: undefined, OTEL_EXPORTER_OTLP_ENDPOINT: 'http://otel-collector:4318', OTEL_EXPORTER_OTLP_HEADERS: undefined }, () => {
|
|
114
|
+
const cfg = getTelemetryConfig(null);
|
|
115
|
+
assert.strictEqual(cfg.endpoint, 'http://otel-collector:4318');
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('reads headers from OTEL_EXPORTER_OTLP_HEADERS env var', () => {
|
|
120
|
+
withEnv({ OTEL_ENABLED: 'true', OTEL_SERVICE_NAME: undefined, OTEL_EXPORTER_OTLP_ENDPOINT: undefined, OTEL_EXPORTER_OTLP_HEADERS: 'authorization=Bearer mysecret' }, () => {
|
|
121
|
+
const cfg = getTelemetryConfig(null);
|
|
122
|
+
assert.deepStrictEqual(cfg.headers, { authorization: 'Bearer mysecret' });
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('returns empty headers when OTEL_EXPORTER_OTLP_HEADERS is not set', () => {
|
|
127
|
+
withEnv({ OTEL_ENABLED: 'true', OTEL_SERVICE_NAME: undefined, OTEL_EXPORTER_OTLP_ENDPOINT: undefined, OTEL_EXPORTER_OTLP_HEADERS: undefined }, () => {
|
|
128
|
+
const cfg = getTelemetryConfig(null);
|
|
129
|
+
assert.deepStrictEqual(cfg.headers, {});
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('getTelemetryConfig – enabled via yaml', () => {
|
|
135
|
+
it('returns config when telemetry.enabled is true', () => {
|
|
136
|
+
withEnv({ OTEL_ENABLED: undefined, OTEL_SERVICE_NAME: undefined, OTEL_EXPORTER_OTLP_ENDPOINT: undefined, OTEL_EXPORTER_OTLP_HEADERS: undefined }, () => {
|
|
137
|
+
const cfg = getTelemetryConfig({ enabled: true });
|
|
138
|
+
assert.ok(cfg);
|
|
139
|
+
assert.strictEqual(cfg.enabled, true);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('reads serviceName from yaml', () => {
|
|
144
|
+
withEnv({ OTEL_ENABLED: undefined, OTEL_SERVICE_NAME: undefined, OTEL_EXPORTER_OTLP_ENDPOINT: undefined, OTEL_EXPORTER_OTLP_HEADERS: undefined }, () => {
|
|
145
|
+
const cfg = getTelemetryConfig({ enabled: true, serviceName: 'yaml-service' });
|
|
146
|
+
assert.strictEqual(cfg.serviceName, 'yaml-service');
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('reads endpoint from yaml', () => {
|
|
151
|
+
withEnv({ OTEL_ENABLED: undefined, OTEL_SERVICE_NAME: undefined, OTEL_EXPORTER_OTLP_ENDPOINT: undefined, OTEL_EXPORTER_OTLP_HEADERS: undefined }, () => {
|
|
152
|
+
const cfg = getTelemetryConfig({ enabled: true, endpoint: 'http://my-collector:4318' });
|
|
153
|
+
assert.strictEqual(cfg.endpoint, 'http://my-collector:4318');
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('env var serviceName overrides yaml', () => {
|
|
158
|
+
withEnv({ OTEL_ENABLED: undefined, OTEL_SERVICE_NAME: 'env-wins', OTEL_EXPORTER_OTLP_ENDPOINT: undefined, OTEL_EXPORTER_OTLP_HEADERS: undefined }, () => {
|
|
159
|
+
const cfg = getTelemetryConfig({ enabled: true, serviceName: 'yaml-service' });
|
|
160
|
+
assert.strictEqual(cfg.serviceName, 'env-wins');
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('env var endpoint overrides yaml', () => {
|
|
165
|
+
withEnv({ OTEL_ENABLED: undefined, OTEL_SERVICE_NAME: undefined, OTEL_EXPORTER_OTLP_ENDPOINT: 'http://env-collector:4318', OTEL_EXPORTER_OTLP_HEADERS: undefined }, () => {
|
|
166
|
+
const cfg = getTelemetryConfig({ enabled: true, endpoint: 'http://yaml-collector:4318' });
|
|
167
|
+
assert.strictEqual(cfg.endpoint, 'http://env-collector:4318');
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('yaml has no headers field (secrets must come from env)', () => {
|
|
172
|
+
withEnv({ OTEL_ENABLED: undefined, OTEL_SERVICE_NAME: undefined, OTEL_EXPORTER_OTLP_ENDPOINT: undefined, OTEL_EXPORTER_OTLP_HEADERS: undefined }, () => {
|
|
173
|
+
const cfg = getTelemetryConfig({ enabled: true });
|
|
174
|
+
// Headers are always empty when OTEL_EXPORTER_OTLP_HEADERS env var is absent
|
|
175
|
+
assert.deepStrictEqual(cfg.headers, {});
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('OTEL_ENABLED=true overrides yaml enabled: false', () => {
|
|
180
|
+
withEnv({ OTEL_ENABLED: 'true', OTEL_SERVICE_NAME: undefined, OTEL_EXPORTER_OTLP_ENDPOINT: undefined, OTEL_EXPORTER_OTLP_HEADERS: undefined }, () => {
|
|
181
|
+
const cfg = getTelemetryConfig({ enabled: false });
|
|
182
|
+
assert.ok(cfg);
|
|
183
|
+
assert.strictEqual(cfg.enabled, true);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe('schema: telemetry', () => {
|
|
189
|
+
it('schema accepts telemetry with enabled only', () => {
|
|
190
|
+
assert.strictEqual(validateSchema({ name: 'App', telemetry: { enabled: true } }), true);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('schema accepts top-level telemetry', () => {
|
|
194
|
+
assert.strictEqual(validateSchema({
|
|
195
|
+
name: 'App',
|
|
196
|
+
telemetry: { enabled: true, serviceName: 'my-service', endpoint: 'http://localhost:4318' },
|
|
197
|
+
}), true);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('schema accepts telemetry with all non-secret fields', () => {
|
|
201
|
+
assert.strictEqual(validateSchema({
|
|
202
|
+
name: 'App',
|
|
203
|
+
telemetry: {
|
|
204
|
+
enabled: true,
|
|
205
|
+
serviceName: 'my-service',
|
|
206
|
+
endpoint: 'http://localhost:4318',
|
|
207
|
+
},
|
|
208
|
+
}), true);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('schema accepts disabled telemetry', () => {
|
|
212
|
+
assert.strictEqual(validateSchema({ name: 'App', telemetry: { enabled: false } }), true);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('schema accepts empty telemetry object', () => {
|
|
216
|
+
assert.strictEqual(validateSchema({ name: 'App', telemetry: {} }), true);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('schema rejects unknown telemetry key', () => {
|
|
220
|
+
assert.throws(() => validateSchema({ name: 'App', telemetry: { enabled: true, headers: {} } }));
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('schema rejects telemetry with enabled as non-boolean', () => {
|
|
224
|
+
assert.throws(() => validateSchema({ name: 'App', telemetry: { enabled: 'yes' } }));
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('schema rejects settings property (removed)', () => {
|
|
228
|
+
assert.throws(() => validateSchema({ name: 'App', settings: { telemetry: { enabled: true } } }));
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
describe('buildCore: telemetry passthrough', () => {
|
|
233
|
+
it('exposes top-level telemetry when provided', () => {
|
|
234
|
+
const core = buildCore({
|
|
235
|
+
name: 'App',
|
|
236
|
+
telemetry: { enabled: true, serviceName: 'top-svc', endpoint: 'http://host:4318' },
|
|
237
|
+
});
|
|
238
|
+
assert.ok(core.telemetry);
|
|
239
|
+
assert.strictEqual(core.telemetry.enabled, true);
|
|
240
|
+
assert.strictEqual(core.telemetry.serviceName, 'top-svc');
|
|
241
|
+
assert.strictEqual(core.telemetry.endpoint, 'http://host:4318');
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('sets telemetry to null when not provided', () => {
|
|
245
|
+
const core = buildCore({ name: 'App' });
|
|
246
|
+
assert.strictEqual(core.telemetry, null);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe('shutdownTelemetry', () => {
|
|
251
|
+
it('resolves without error when SDK was never initialized', async () => {
|
|
252
|
+
await assert.doesNotReject(() => shutdownTelemetry());
|
|
253
|
+
});
|
|
254
|
+
});
|
package/test/test.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Tests have been split into focused files. Run them with: npm test
|
|
3
|
+
// Each *.test.js file covers one feature area:
|
|
4
|
+
// schema-validator.test.js – schema validation
|
|
5
|
+
// entity-engine.test.js – entity engine & buildCore
|
|
6
|
+
// db.test.js – database CRUD, filters, relations
|
|
7
|
+
// openapi.test.js – OpenAPI spec generation
|
|
8
|
+
// yaml-loader.test.js – YAML config loading
|
|
9
|
+
// auth.test.js – JWT auth & middleware
|
|
10
|
+
// validation.test.js – request body validation & defaults
|
|
11
|
+
// seeder.test.js – database seeder
|
|
12
|
+
// upload.test.js – file upload helpers & sharp integration
|
|
13
|
+
// groups.test.js – group property serialization & validation
|
|
14
|
+
// settings.test.js – rate limits, env vars, API limiters
|
|
15
|
+
// sdk.test.js – backend SDK (createBackendSdk)
|
|
16
|
+
// access-policies.test.js – access policy enforcement
|
|
17
|
+
// middleware.test.js – middleware SDK injection
|