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,153 @@
|
|
|
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 http = require('http');
|
|
8
|
+
const { buildApp } = require('../server/express-server');
|
|
9
|
+
const { signToken } = require('../core/auth');
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Helpers
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
function tmpYaml(name, obj) {
|
|
16
|
+
const YAML = require('yaml');
|
|
17
|
+
const file = path.join(os.tmpdir(), `cs-hr-${name}-${Date.now()}.yaml`);
|
|
18
|
+
fs.writeFileSync(file, YAML.stringify(obj), 'utf8');
|
|
19
|
+
return file;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function jsonFetch(url, opts = {}) {
|
|
23
|
+
const res = await fetch(url, opts);
|
|
24
|
+
const body = await res.json();
|
|
25
|
+
return { status: res.status, body };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
describe('hot-reload', () => {
|
|
31
|
+
let yamlFile, server, port, reloadFn;
|
|
32
|
+
|
|
33
|
+
const baseConfig = {
|
|
34
|
+
name: 'HotReloadTest',
|
|
35
|
+
entities: {
|
|
36
|
+
Admin: { authenticable: true, properties: ['name'] },
|
|
37
|
+
Post: { properties: ['title'] },
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
before(async () => {
|
|
42
|
+
yamlFile = tmpYaml('base', baseConfig);
|
|
43
|
+
|
|
44
|
+
let currentApp = null;
|
|
45
|
+
const dispatcher = (req, res) => currentApp(req, res);
|
|
46
|
+
server = http.createServer(dispatcher);
|
|
47
|
+
|
|
48
|
+
async function reload() {
|
|
49
|
+
const result = await buildApp(yamlFile, reload);
|
|
50
|
+
currentApp = result.app;
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
reloadFn = reload;
|
|
54
|
+
|
|
55
|
+
await reload();
|
|
56
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
57
|
+
port = server.address().port;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
after(async () => {
|
|
61
|
+
await new Promise((resolve) => server.close(resolve));
|
|
62
|
+
if (fs.existsSync(yamlFile)) fs.unlinkSync(yamlFile);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('server starts and /health reflects the initial config', async () => {
|
|
66
|
+
const { status, body } = await jsonFetch(`http://127.0.0.1:${port}/health`);
|
|
67
|
+
assert.strictEqual(status, 200);
|
|
68
|
+
assert.strictEqual(body.name, 'HotReloadTest');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('/admin/schema lists initial entities', async () => {
|
|
72
|
+
const { status, body } = await jsonFetch(`http://127.0.0.1:${port}/admin/schema`);
|
|
73
|
+
assert.strictEqual(status, 200);
|
|
74
|
+
const names = body.entities.map((e) => e.name);
|
|
75
|
+
assert.ok(names.includes('Admin'));
|
|
76
|
+
assert.ok(names.includes('Post'));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('hot reload swaps app — new entity appears after reload', async () => {
|
|
80
|
+
// Write updated config with an extra entity
|
|
81
|
+
const YAML = require('yaml');
|
|
82
|
+
const updated = {
|
|
83
|
+
...baseConfig,
|
|
84
|
+
name: 'HotReloadTest',
|
|
85
|
+
entities: {
|
|
86
|
+
...baseConfig.entities,
|
|
87
|
+
Comment: { properties: ['text'] },
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
fs.writeFileSync(yamlFile, YAML.stringify(updated), 'utf8');
|
|
91
|
+
|
|
92
|
+
// Trigger reload
|
|
93
|
+
await reloadFn();
|
|
94
|
+
|
|
95
|
+
// Schema must now include Comment
|
|
96
|
+
const { status, body } = await jsonFetch(`http://127.0.0.1:${port}/admin/schema`);
|
|
97
|
+
assert.strictEqual(status, 200);
|
|
98
|
+
const names = body.entities.map((e) => e.name);
|
|
99
|
+
assert.ok(names.includes('Comment'), 'Comment entity should appear after reload');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('PUT /admin/config returns reloading:true when reloadFn is provided', async () => {
|
|
103
|
+
const token = signToken({ id: 'test', entity: 'Admin' });
|
|
104
|
+
const newConfig = { ...baseConfig, name: 'Reloaded' };
|
|
105
|
+
|
|
106
|
+
const { status, body } = await jsonFetch(`http://127.0.0.1:${port}/admin/config`, {
|
|
107
|
+
method: 'PUT',
|
|
108
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
109
|
+
body: JSON.stringify(newConfig),
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
assert.strictEqual(status, 200);
|
|
113
|
+
assert.strictEqual(body.success, true);
|
|
114
|
+
assert.strictEqual(body.reloading, true);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('server is still responsive during and after reload', async () => {
|
|
118
|
+
// Reload a few times and confirm the server stays up
|
|
119
|
+
for (let i = 0; i < 3; i++) {
|
|
120
|
+
const YAML = require('yaml');
|
|
121
|
+
const cfg = { ...baseConfig, name: `Reload-${i}` };
|
|
122
|
+
fs.writeFileSync(yamlFile, YAML.stringify(cfg), 'utf8');
|
|
123
|
+
await reloadFn();
|
|
124
|
+
}
|
|
125
|
+
const { status } = await jsonFetch(`http://127.0.0.1:${port}/health`);
|
|
126
|
+
assert.strictEqual(status, 200);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('buildApp without reloadFn — PUT /admin/config returns no reloading flag', async () => {
|
|
130
|
+
const yamlFile2 = tmpYaml('noreload', baseConfig);
|
|
131
|
+
try {
|
|
132
|
+
const { app } = await buildApp(yamlFile2, null);
|
|
133
|
+
const testServer = http.createServer(app);
|
|
134
|
+
await new Promise((resolve) => testServer.listen(0, '127.0.0.1', resolve));
|
|
135
|
+
const p = testServer.address().port;
|
|
136
|
+
|
|
137
|
+
const token = signToken({ id: 'test', entity: 'Admin' });
|
|
138
|
+
const { status, body } = await jsonFetch(`http://127.0.0.1:${p}/admin/config`, {
|
|
139
|
+
method: 'PUT',
|
|
140
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
141
|
+
body: JSON.stringify(baseConfig),
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
assert.strictEqual(status, 200);
|
|
145
|
+
assert.strictEqual(body.success, true);
|
|
146
|
+
assert.strictEqual(body.reloading, undefined, 'reloading flag should not be set when no reloadFn');
|
|
147
|
+
|
|
148
|
+
await new Promise((resolve) => testServer.close(resolve));
|
|
149
|
+
} finally {
|
|
150
|
+
if (fs.existsSync(yamlFile2)) fs.unlinkSync(yamlFile2);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
});
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('assert');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
|
|
7
|
+
// ── Unit tests for i18n helpers in express-server.js ──────────────────────────
|
|
8
|
+
// We test loadLocale() and parseLang() by requiring the server module and
|
|
9
|
+
// exercising the locale files directly, without starting a full HTTP server.
|
|
10
|
+
|
|
11
|
+
const LOCALES_DIR = path.join(__dirname, '..', 'locales');
|
|
12
|
+
const EN_LOCALE = path.join(LOCALES_DIR, 'en', 'admin.json');
|
|
13
|
+
|
|
14
|
+
describe('i18n – locale files', () => {
|
|
15
|
+
it('English locale file exists', () => {
|
|
16
|
+
assert.ok(fs.existsSync(EN_LOCALE), `Expected ${EN_LOCALE} to exist`);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('English locale is valid JSON', () => {
|
|
20
|
+
const raw = fs.readFileSync(EN_LOCALE, 'utf8');
|
|
21
|
+
let parsed;
|
|
22
|
+
assert.doesNotThrow(() => { parsed = JSON.parse(raw); });
|
|
23
|
+
assert.strictEqual(typeof parsed, 'object');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('English locale has all required top-level sections', () => {
|
|
27
|
+
const locale = JSON.parse(fs.readFileSync(EN_LOCALE, 'utf8'));
|
|
28
|
+
const required = ['page', 'login', 'sidebar', 'header', 'modal', 'table', 'toast', 'config'];
|
|
29
|
+
for (const key of required) {
|
|
30
|
+
assert.ok(key in locale, `Missing top-level key: ${key}`);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('login section has required keys', () => {
|
|
35
|
+
const { login } = JSON.parse(fs.readFileSync(EN_LOCALE, 'utf8'));
|
|
36
|
+
const required = [
|
|
37
|
+
'title', 'collection_label', 'collection_placeholder',
|
|
38
|
+
'change_collection', 'hide_collection',
|
|
39
|
+
'email_label', 'password_label', 'sign_in', 'signing_in', 'errors',
|
|
40
|
+
];
|
|
41
|
+
for (const key of required) {
|
|
42
|
+
assert.ok(key in login, `login.${key} is missing`);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('login.errors section has required keys', () => {
|
|
47
|
+
const { login } = JSON.parse(fs.readFileSync(EN_LOCALE, 'utf8'));
|
|
48
|
+
const required = [
|
|
49
|
+
'no_admin_collections', 'all_fields_required',
|
|
50
|
+
'collection_not_found', 'login_failed', 'network_error',
|
|
51
|
+
];
|
|
52
|
+
for (const key of required) {
|
|
53
|
+
assert.ok(key in login.errors, `login.errors.${key} is missing`);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('table section has required keys', () => {
|
|
58
|
+
const { table } = JSON.parse(fs.readFileSync(EN_LOCALE, 'utf8'));
|
|
59
|
+
const required = ['no_records', 'actions', 'edit', 'delete', 'delete_confirm'];
|
|
60
|
+
for (const key of required) {
|
|
61
|
+
assert.ok(key in table, `table.${key} is missing`);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('config section has all tab description keys', () => {
|
|
66
|
+
const { config } = JSON.parse(fs.readFileSync(EN_LOCALE, 'utf8'));
|
|
67
|
+
const tabs = ['general', 'entities', 'functions', 'files', 'settings', 'all'];
|
|
68
|
+
for (const tab of tabs) {
|
|
69
|
+
assert.ok(tab in config.tabs, `config.tabs.${tab} is missing`);
|
|
70
|
+
assert.ok(tab in config.descriptions, `config.descriptions.${tab} is missing`);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('all string values in the locale are non-empty strings', () => {
|
|
75
|
+
const locale = JSON.parse(fs.readFileSync(EN_LOCALE, 'utf8'));
|
|
76
|
+
function check(obj, path) {
|
|
77
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
78
|
+
const cur = `${path}.${k}`;
|
|
79
|
+
if (typeof v === 'string') {
|
|
80
|
+
assert.ok(v.length > 0, `Empty string at ${cur}`);
|
|
81
|
+
} else if (typeof v === 'object' && v !== null) {
|
|
82
|
+
check(v, cur);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
check(locale, 'locale');
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// ── Integration tests for GET /admin/i18n/:lang route ─────────────────────────
|
|
91
|
+
// These tests build the Express app with a minimal config and hit the route.
|
|
92
|
+
|
|
93
|
+
const http = require('http');
|
|
94
|
+
const os = require('os');
|
|
95
|
+
const { buildApp } = require('../server/express-server');
|
|
96
|
+
|
|
97
|
+
/** Fire a GET request and collect the full response body. */
|
|
98
|
+
function get(port, path) {
|
|
99
|
+
return new Promise((resolve, reject) => {
|
|
100
|
+
const req = http.request({ hostname: '127.0.0.1', port, path, method: 'GET' }, (res) => {
|
|
101
|
+
let body = '';
|
|
102
|
+
res.on('data', chunk => { body += chunk; });
|
|
103
|
+
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body }));
|
|
104
|
+
});
|
|
105
|
+
req.on('error', reject);
|
|
106
|
+
req.end();
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
describe('i18n – GET /admin/i18n/:lang route', () => {
|
|
111
|
+
let server, port;
|
|
112
|
+
|
|
113
|
+
before(async () => {
|
|
114
|
+
// Write a minimal config YAML to a temp file so buildApp has something to load
|
|
115
|
+
const tmp = path.join(os.tmpdir(), `cs-i18n-test-${Date.now()}.yaml`);
|
|
116
|
+
const yaml = `name: i18nTest\nport: 0\nentities:\n Post:\n properties:\n - title\n`;
|
|
117
|
+
fs.writeFileSync(tmp, yaml);
|
|
118
|
+
|
|
119
|
+
const { app } = await buildApp(tmp, null);
|
|
120
|
+
server = http.createServer(app);
|
|
121
|
+
await new Promise(r => server.listen(0, '127.0.0.1', r));
|
|
122
|
+
port = server.address().port;
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
after(done => server.close(done));
|
|
126
|
+
|
|
127
|
+
it('returns 200 and JSON for the English locale', async () => {
|
|
128
|
+
const { status, headers, body } = await get(port, '/admin/i18n/en');
|
|
129
|
+
assert.strictEqual(status, 200);
|
|
130
|
+
assert.ok(headers['content-type'].includes('application/json'), 'Expected JSON content-type');
|
|
131
|
+
const locale = JSON.parse(body);
|
|
132
|
+
assert.strictEqual(typeof locale, 'object');
|
|
133
|
+
assert.ok('login' in locale);
|
|
134
|
+
assert.ok('table' in locale);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('falls back to English for an unknown language', async () => {
|
|
138
|
+
const { status, body } = await get(port, '/admin/i18n/xx');
|
|
139
|
+
assert.strictEqual(status, 200);
|
|
140
|
+
const locale = JSON.parse(body);
|
|
141
|
+
assert.ok('login' in locale, 'Fallback locale should contain login section');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('returns 404 when no locale is found and English is missing', async () => {
|
|
145
|
+
// We can verify this indirectly: requesting a non-existent but valid-format lang
|
|
146
|
+
// should still return 200 (falls back to English). Only when English is also
|
|
147
|
+
// absent would it return 404, which we cannot easily replicate without mocking fs.
|
|
148
|
+
// Instead, confirm that an unreachable language gracefully returns the EN fallback.
|
|
149
|
+
const { status } = await get(port, '/admin/i18n/zz');
|
|
150
|
+
assert.strictEqual(status, 200);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('rejects invalid lang params (path traversal characters) and falls back to English', async () => {
|
|
154
|
+
// Dots and slashes are not valid in lang codes; loadLocale() rejects them via
|
|
155
|
+
// the /^[a-z]{2,3}$/ regex and falls back to English. Test a percent-encoded dot
|
|
156
|
+
// sequence that the route param might receive.
|
|
157
|
+
const { status, body } = await get(port, '/admin/i18n/..%2Fetc');
|
|
158
|
+
// Express will decode %2F to / which splits the route — expect 404 from Express router
|
|
159
|
+
// OR the route matches and loadLocale safely falls back to English (200).
|
|
160
|
+
assert.ok(status === 200 || status === 404, `Unexpected status ${status}`);
|
|
161
|
+
if (status === 200) {
|
|
162
|
+
const locale = JSON.parse(body);
|
|
163
|
+
assert.ok('login' in locale, 'Fallback locale must be the English locale');
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('strips invalid characters from lang codes before file lookup', async () => {
|
|
168
|
+
// A lang param like "en!!" would be sanitised to "en" by parseLang/loadLocale.
|
|
169
|
+
// Express will reject chars like "!" at the router level, so just verify "en" works.
|
|
170
|
+
const { status } = await get(port, '/admin/i18n/en');
|
|
171
|
+
assert.strictEqual(status, 200);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
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 http = require('http');
|
|
8
|
+
const express = require('express');
|
|
9
|
+
const { buildCore } = require('../core/entity-engine');
|
|
10
|
+
const dbModule = require('../core/db');
|
|
11
|
+
const { registerApiRoutes } = require('../core/api-generator');
|
|
12
|
+
const { signToken } = require('../core/auth');
|
|
13
|
+
|
|
14
|
+
describe('runMiddlewares – SDK injection', () => {
|
|
15
|
+
let testServer, port, functionsDir, tmp;
|
|
16
|
+
const mwCore = buildCore({
|
|
17
|
+
name: 'MwTest',
|
|
18
|
+
entities: {
|
|
19
|
+
Item: {
|
|
20
|
+
properties: ['name'],
|
|
21
|
+
middlewares: { beforeCreate: [{ function: 'testMwFunction' }] },
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
before(async () => {
|
|
27
|
+
tmp = path.join(os.tmpdir(), `chadstart-mw-${Date.now()}.db`);
|
|
28
|
+
dbModule.initDb(mwCore, tmp);
|
|
29
|
+
|
|
30
|
+
functionsDir = path.join(os.tmpdir(), `chadstart-functions-${Date.now()}`);
|
|
31
|
+
fs.mkdirSync(functionsDir, { recursive: true });
|
|
32
|
+
const functionPath = path.join(functionsDir, 'testMwFunction.js');
|
|
33
|
+
fs.writeFileSync(functionPath, `
|
|
34
|
+
module.exports = async (req, res, chadstart) => {
|
|
35
|
+
req.app._lastSdkArg = chadstart;
|
|
36
|
+
};
|
|
37
|
+
`);
|
|
38
|
+
|
|
39
|
+
process.env.CHADSTART_FUNCTIONS_FOLDER = functionsDir;
|
|
40
|
+
delete require.cache[require.resolve(functionPath)];
|
|
41
|
+
|
|
42
|
+
const testApp = express();
|
|
43
|
+
testApp.use(express.json());
|
|
44
|
+
registerApiRoutes(testApp, mwCore, () => {});
|
|
45
|
+
testApp.get('/_inspect', (req, res) => res.json({ hasSdk: req.app._lastSdkArg != null }));
|
|
46
|
+
|
|
47
|
+
testServer = http.createServer(testApp);
|
|
48
|
+
await new Promise((resolve) => testServer.listen(0, resolve));
|
|
49
|
+
port = testServer.address().port;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
after(async () => {
|
|
53
|
+
await new Promise((resolve) => testServer.close(resolve));
|
|
54
|
+
delete process.env.CHADSTART_FUNCTIONS_FOLDER;
|
|
55
|
+
fs.rmSync(functionsDir, { recursive: true, force: true });
|
|
56
|
+
fs.unlinkSync(tmp);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('middleware function receives (req, res, chadstart) – sdk is passed', async () => {
|
|
60
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/collections/item`, {
|
|
61
|
+
method: 'POST',
|
|
62
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${signToken({ id: 'a1', entity: 'Admin' })}` },
|
|
63
|
+
body: JSON.stringify({ name: 'Widget' }),
|
|
64
|
+
});
|
|
65
|
+
assert.strictEqual(res.status, 201);
|
|
66
|
+
const data = await res.json();
|
|
67
|
+
assert.strictEqual(data.name, 'Widget');
|
|
68
|
+
|
|
69
|
+
const inspect = await fetch(`http://127.0.0.1:${port}/_inspect`).then((r) => r.json());
|
|
70
|
+
assert.strictEqual(inspect.hasSdk, true, 'chadstart SDK should be passed to middleware functions');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('CHADSTART_FUNCTIONS_FOLDER env var is used by middleware runner', () => {
|
|
74
|
+
assert.strictEqual(process.env.CHADSTART_FUNCTIONS_FOLDER, functionsDir);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('assert');
|
|
4
|
+
const { buildCore } = require('../core/entity-engine');
|
|
5
|
+
const { generateOpenApiSpec } = require('../core/openapi');
|
|
6
|
+
|
|
7
|
+
describe('openapi', () => {
|
|
8
|
+
it('generates valid spec', () => {
|
|
9
|
+
const spec = generateOpenApiSpec(buildCore({ name: 'Blog', entities: { Post: { properties: ['title'] } } }));
|
|
10
|
+
assert.strictEqual(spec.openapi, '3.0.0');
|
|
11
|
+
assert.ok(spec.paths['/api/collections/post']);
|
|
12
|
+
assert.ok(spec.paths['/api/collections/post/{id}']);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('includes file bucket paths', () => {
|
|
16
|
+
const spec = generateOpenApiSpec(buildCore({ name: 'App', files: { uploads: { path: '/tmp/uploads' } } }));
|
|
17
|
+
assert.ok(spec.paths['/files/uploads']);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('includes auth paths for authenticable entities', () => {
|
|
21
|
+
const spec = generateOpenApiSpec(buildCore({ name: 'App', entities: { Admin: { authenticable: true, properties: ['name'] } } }));
|
|
22
|
+
assert.ok(spec.paths['/api/auth/admin/signup']);
|
|
23
|
+
assert.ok(spec.paths['/api/auth/admin/login']);
|
|
24
|
+
assert.ok(spec.paths['/api/auth/admin/me']);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('security on restricted entity', () => {
|
|
28
|
+
const spec = generateOpenApiSpec(buildCore({ name: 'App', entities: { Post: { properties: ['t'], policies: { read: [{ access: 'public' }], create: [{ access: 'restricted' }] } } } }));
|
|
29
|
+
assert.ok(!spec.paths['/api/collections/post'].get.security);
|
|
30
|
+
assert.ok(spec.paths['/api/collections/post'].post.security);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('single entity uses /api/singles/ path', () => {
|
|
34
|
+
const spec = generateOpenApiSpec(buildCore({ name: 'App', entities: { Home: { single: true, properties: ['title'] } } }));
|
|
35
|
+
assert.ok(spec.paths['/api/singles/home']);
|
|
36
|
+
assert.ok(spec.paths['/api/singles/home'].put, 'PUT should exist for singles');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('collection spec includes PUT endpoint', () => {
|
|
40
|
+
const spec = generateOpenApiSpec(buildCore({ name: 'App', entities: { Post: { properties: ['t'] } } }));
|
|
41
|
+
assert.ok(spec.paths['/api/collections/post/{id}'].put, 'PUT should exist for collections');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('openapi includes pagination params', () => {
|
|
45
|
+
const spec = generateOpenApiSpec(buildCore({ name: 'App', entities: { Post: { properties: ['t'] } } }));
|
|
46
|
+
const params = spec.paths['/api/collections/post'].get.parameters;
|
|
47
|
+
assert.ok(params.some((p) => p.name === 'page'));
|
|
48
|
+
assert.ok(params.some((p) => p.name === 'perPage'));
|
|
49
|
+
assert.ok(params.some((p) => p.name === 'orderBy'));
|
|
50
|
+
assert.ok(params.some((p) => p.name === 'relations'));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('openapi hides hidden properties', () => {
|
|
54
|
+
const spec = generateOpenApiSpec(buildCore({ name: 'App', entities: { Post: { properties: ['title', { name: 'secret', type: 'string', hidden: true }] } } }));
|
|
55
|
+
const schema = spec.components.schemas.Post;
|
|
56
|
+
assert.ok(schema.properties.title);
|
|
57
|
+
assert.ok(!schema.properties.secret, 'hidden prop should not be in schema');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('openapi entity schema has UUID id and timestamps', () => {
|
|
61
|
+
const spec = generateOpenApiSpec(buildCore({ name: 'App', entities: { Post: { properties: ['t'] } } }));
|
|
62
|
+
const schema = spec.components.schemas.Post;
|
|
63
|
+
assert.strictEqual(schema.properties.id.format, 'uuid');
|
|
64
|
+
assert.ok(schema.properties.createdAt);
|
|
65
|
+
assert.ok(schema.properties.updatedAt);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('assert');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
|
|
7
|
+
describe('schema-validator', () => {
|
|
8
|
+
const { validateSchema } = require('../core/schema-validator');
|
|
9
|
+
|
|
10
|
+
it('accepts valid minimal config', () => assert.strictEqual(validateSchema({ name: 'Test' }), true));
|
|
11
|
+
it('rejects missing name', () => assert.throws(() => validateSchema({}), /name/i));
|
|
12
|
+
it('rejects non-string name', () => assert.throws(() => validateSchema({ name: 42 }), /must be string/i));
|
|
13
|
+
it('accepts entities map', () => assert.strictEqual(validateSchema({ name: 'App', entities: { Post: { properties: ['title'] } } }), true));
|
|
14
|
+
it('rejects entities as array', () => assert.throws(() => validateSchema({ name: 'App', entities: [] }), /must be object/i));
|
|
15
|
+
it('rejects unknown property type', () => {
|
|
16
|
+
assert.throws(() => validateSchema({ name: 'App', entities: { Post: { properties: [{ name: 'x', type: 'banana' }] } } }));
|
|
17
|
+
});
|
|
18
|
+
it('accepts authenticable entity', () => assert.strictEqual(validateSchema({ name: 'App', entities: { Admin: { authenticable: true, properties: ['name'] } } }), true));
|
|
19
|
+
it('rejects authenticable as non-boolean', () => assert.throws(() => validateSchema({ name: 'App', entities: { Admin: { authenticable: 'yes' } } })));
|
|
20
|
+
it('accepts policies', () => assert.strictEqual(validateSchema({ name: 'App', entities: { Post: { properties: ['title'], policies: { read: [{ access: 'public' }], create: [{ access: 'restricted', allow: 'Admin' }] } } } }), true));
|
|
21
|
+
it('rejects unknown policy rule', () => assert.throws(() => validateSchema({ name: 'App', entities: { Post: { policies: { unknown: [{ access: 'public' }] } } } })));
|
|
22
|
+
it('accepts validation rules', () => assert.strictEqual(validateSchema({ name: 'App', entities: { Post: { properties: ['title'], validation: { title: { required: true } } } } }), true));
|
|
23
|
+
it('accepts hooks', () => assert.strictEqual(validateSchema({ name: 'App', entities: { Post: { hooks: { beforeCreate: [{ url: 'https://example.com' }] } } } }), true));
|
|
24
|
+
it('accepts middlewares', () => assert.strictEqual(validateSchema({ name: 'App', entities: { Post: { middlewares: { afterCreate: [{ function: 'sendEmail' }] } } } }), true));
|
|
25
|
+
it('accepts belongsToMany', () => assert.strictEqual(validateSchema({ name: 'App', entities: { Player: { properties: ['name'], belongsToMany: ['Skill'] }, Skill: { properties: ['name'] } } }), true));
|
|
26
|
+
it('accepts single entity', () => assert.strictEqual(validateSchema({ name: 'App', entities: { Home: { single: true, properties: ['title'] } } }), true));
|
|
27
|
+
it('rejects function missing function field', () => assert.throws(() => validateSchema({ name: 'App', functions: { bad: { triggers: [{ type: 'http', method: 'GET', path: '/bad' }] } } })));
|
|
28
|
+
it('rejects deprecated endpoints key', () => assert.throws(() => validateSchema({ name: 'App', endpoints: { hello: { path: '/hello', method: 'GET', function: 'hello.js' } } })));
|
|
29
|
+
it('accepts new-format function with triggers', () => assert.strictEqual(validateSchema({ name: 'App', functions: { hello: { runtime: 'js', function: 'hello.js', triggers: [{ type: 'http', method: 'GET', path: '/hello' }] } } }), true));
|
|
30
|
+
it('rejects old-format function with path+method (no triggers)', () => assert.throws(() => validateSchema({ name: 'App', functions: { hello: { path: '/hello', method: 'GET', function: 'hello.js' } } })));
|
|
31
|
+
it('accepts function with cron trigger and predefined schedule', () => assert.strictEqual(validateSchema({ name: 'App', functions: { daily: { function: 'daily.js', triggers: [{ type: 'cron', schedule: '@daily' }] } } }), true));
|
|
32
|
+
it('accepts function with event trigger', () => assert.strictEqual(validateSchema({ name: 'App', functions: { onEvt: { function: 'onEvt.js', triggers: [{ type: 'event', name: 'user.created' }] } } }), true));
|
|
33
|
+
it('accepts python runtime function', () => assert.strictEqual(validateSchema({ name: 'App', functions: { fn: { runtime: 'python', function: 'fn.py', triggers: [{ type: 'http', method: 'POST', path: '/fn' }] } } }), true));
|
|
34
|
+
it('rejects unknown runtime', () => assert.throws(() => validateSchema({ name: 'App', functions: { fn: { runtime: 'deno', function: 'fn.js', triggers: [{ type: 'http', method: 'GET', path: '/fn' }] } } })));
|
|
35
|
+
it('accepts http trigger with public policy', () => assert.strictEqual(validateSchema({ name: 'App', functions: { fn: { function: 'fn.js', triggers: [{ type: 'http', method: 'GET', path: '/fn', policies: [{ access: 'public' }] }] } } }), true));
|
|
36
|
+
it('accepts http trigger with restricted policy and allow', () => assert.strictEqual(validateSchema({ name: 'App', functions: { fn: { function: 'fn.js', triggers: [{ type: 'http', method: 'GET', path: '/fn', policies: [{ access: 'restricted', allow: 'Admin' }] }] } } }), true));
|
|
37
|
+
it('accepts http trigger with admin policy', () => assert.strictEqual(validateSchema({ name: 'App', functions: { fn: { function: 'fn.js', triggers: [{ type: 'http', method: 'GET', path: '/fn', policies: [{ access: 'admin' }] }] } } }), true));
|
|
38
|
+
it('accepts http trigger with forbidden policy', () => assert.strictEqual(validateSchema({ name: 'App', functions: { fn: { function: 'fn.js', triggers: [{ type: 'http', method: 'GET', path: '/fn', policies: [{ access: 'forbidden' }] }] } } }), true));
|
|
39
|
+
it('accepts groups', () => assert.strictEqual(validateSchema({ name: 'App', groups: { T: { properties: [{ name: 'author', type: 'string' }] } } }), true));
|
|
40
|
+
it('rejects invalid file bucket', () => assert.throws(() => validateSchema({ name: 'App', files: { uploads: {} } }), /path/i));
|
|
41
|
+
it('rejects invalid plugin', () => assert.throws(() => validateSchema({ name: 'App', plugins: [{ name: 'bad' }] })));
|
|
42
|
+
it('accepts emoji access', () => assert.strictEqual(validateSchema({ name: 'App', entities: { Post: { policies: { read: [{ access: '🌐' }] } } } }), true));
|
|
43
|
+
it('rejects unknown top-level key', () => assert.throws(() => validateSchema({ name: 'App', userCollections: { Admin: {} } })));
|
|
44
|
+
it('error message names the unknown top-level property', () => {
|
|
45
|
+
try { validateSchema({ name: 'App', settings: {} }); assert.fail('should throw'); }
|
|
46
|
+
catch (e) { assert.match(e.message, /unknown property 'settings'/); }
|
|
47
|
+
});
|
|
48
|
+
it('error message names the unknown nested property', () => {
|
|
49
|
+
try { validateSchema({ name: 'App', telemetry: { enabled: true, secret: 'x' } }); assert.fail('should throw'); }
|
|
50
|
+
catch (e) { assert.match(e.message, /unknown property 'secret'/); }
|
|
51
|
+
});
|
|
52
|
+
it('error message lists allowed enum values on type violation', () => {
|
|
53
|
+
try { validateSchema({ name: 'App', entities: { U: { properties: [{ name: 'a', type: 'badtype' }] } } }); assert.fail('should throw'); }
|
|
54
|
+
catch (e) { assert.match(e.message, /string, text/); }
|
|
55
|
+
});
|
|
56
|
+
it('suppresses redundant oneOf noise from error message', () => {
|
|
57
|
+
try { validateSchema({ name: 'App', entities: { U: { properties: [{ name: 'a', type: 'badtype' }] } } }); assert.fail('should throw'); }
|
|
58
|
+
catch (e) { assert.doesNotMatch(e.message, /oneOf/); }
|
|
59
|
+
});
|
|
60
|
+
it('accepts sentry config with environment and tracesSampleRate', () => assert.strictEqual(validateSchema({ name: 'App', sentry: { environment: 'production', tracesSampleRate: 0.5 } }), true));
|
|
61
|
+
it('accepts sentry config with debug flag', () => assert.strictEqual(validateSchema({ name: 'App', sentry: { debug: true } }), true));
|
|
62
|
+
it('rejects sentry tracesSampleRate greater than 1', () => assert.throws(() => validateSchema({ name: 'App', sentry: { tracesSampleRate: 2 } })));
|
|
63
|
+
it('rejects sentry tracesSampleRate less than 0', () => assert.throws(() => validateSchema({ name: 'App', sentry: { tracesSampleRate: -0.1 } })));
|
|
64
|
+
it('rejects sentry config with unknown key (dsn not allowed in yaml)', () => assert.throws(() => validateSchema({ name: 'App', sentry: { dsn: 'https://x@sentry.io/1' } })));
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('json-schema', () => {
|
|
68
|
+
const { validateSchema } = require('../core/schema-validator');
|
|
69
|
+
const { loadYaml } = require('../core/yaml-loader');
|
|
70
|
+
|
|
71
|
+
it('chadstart.schema.json is valid JSON', () => {
|
|
72
|
+
const schema = JSON.parse(fs.readFileSync(path.resolve(__dirname, '..', 'chadstart.schema.json'), 'utf8'));
|
|
73
|
+
assert.strictEqual(schema.$schema, 'http://json-schema.org/draft-07/schema#');
|
|
74
|
+
assert.ok(schema.properties.entities);
|
|
75
|
+
assert.ok(schema.$defs.entity);
|
|
76
|
+
assert.ok(schema.$defs.policies);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('schema file can validate the example config', () => {
|
|
80
|
+
const config = loadYaml(path.resolve(__dirname, '..', 'chadstart.yaml'));
|
|
81
|
+
assert.strictEqual(validateSchema(config), true);
|
|
82
|
+
});
|
|
83
|
+
});
|
package/test/sdk.test.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
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 { createBackendSdk } = require('../core/api-generator');
|
|
10
|
+
|
|
11
|
+
describe('createBackendSdk', () => {
|
|
12
|
+
let tmp, sdk;
|
|
13
|
+
const sdkCore = buildCore({
|
|
14
|
+
name: 'SdkTest',
|
|
15
|
+
entities: {
|
|
16
|
+
Book: { properties: ['title', 'author'] },
|
|
17
|
+
Config: { single: true, properties: ['value'] },
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
before(() => {
|
|
22
|
+
tmp = path.join(os.tmpdir(), `chadstart-sdk-${Date.now()}.db`);
|
|
23
|
+
dbModule.initDb(sdkCore, tmp);
|
|
24
|
+
dbModule.create('config', { value: 'initial' });
|
|
25
|
+
sdk = createBackendSdk(sdkCore);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
after(() => { fs.unlinkSync(tmp); });
|
|
29
|
+
|
|
30
|
+
it('from() returns CRUD interface', () => {
|
|
31
|
+
const iface = sdk.from('book');
|
|
32
|
+
assert.strictEqual(typeof iface.find, 'function');
|
|
33
|
+
assert.strictEqual(typeof iface.findOneById, 'function');
|
|
34
|
+
assert.strictEqual(typeof iface.create, 'function');
|
|
35
|
+
assert.strictEqual(typeof iface.update, 'function');
|
|
36
|
+
assert.strictEqual(typeof iface.patch, 'function');
|
|
37
|
+
assert.strictEqual(typeof iface.delete, 'function');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('from().create and findOneById work', () => {
|
|
41
|
+
const book = sdk.from('book').create({ title: 'Dune', author: 'Herbert' });
|
|
42
|
+
assert.strictEqual(book.title, 'Dune');
|
|
43
|
+
const found = sdk.from('book').findOneById(book.id);
|
|
44
|
+
assert.strictEqual(found.author, 'Herbert');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('from().find returns paginated result', () => {
|
|
48
|
+
const result = sdk.from('book').find();
|
|
49
|
+
assert.ok(Array.isArray(result.data));
|
|
50
|
+
assert.ok(typeof result.total === 'number');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('from().patch updates a field', () => {
|
|
54
|
+
const book = sdk.from('book').create({ title: 'Old Title', author: 'Author' });
|
|
55
|
+
const updated = sdk.from('book').patch(book.id, { title: 'New Title' });
|
|
56
|
+
assert.strictEqual(updated.title, 'New Title');
|
|
57
|
+
assert.strictEqual(updated.author, 'Author');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('from().delete removes a record', () => {
|
|
61
|
+
const book = sdk.from('book').create({ title: 'To Delete', author: 'X' });
|
|
62
|
+
sdk.from('book').delete(book.id);
|
|
63
|
+
assert.strictEqual(sdk.from('book').findOneById(book.id), null);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('from() throws for unknown slug', () => {
|
|
67
|
+
assert.throws(() => sdk.from('nonexistent'), /Entity not found/);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('single() returns get/update/patch interface', () => {
|
|
71
|
+
const iface = sdk.single('config');
|
|
72
|
+
assert.strictEqual(typeof iface.get, 'function');
|
|
73
|
+
assert.strictEqual(typeof iface.update, 'function');
|
|
74
|
+
assert.strictEqual(typeof iface.patch, 'function');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('single().get retrieves the record', () => {
|
|
78
|
+
const record = sdk.single('config').get();
|
|
79
|
+
assert.strictEqual(record.value, 'initial');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('single().patch updates a field', () => {
|
|
83
|
+
const updated = sdk.single('config').patch({ value: 'changed' });
|
|
84
|
+
assert.strictEqual(updated.value, 'changed');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('single() throws for unknown slug', () => {
|
|
88
|
+
assert.throws(() => sdk.single('nonexistent'), /Single entity not found/);
|
|
89
|
+
});
|
|
90
|
+
});
|