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,96 @@
|
|
|
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 jwt = require('jsonwebtoken');
|
|
8
|
+
const bcrypt = require('bcryptjs');
|
|
9
|
+
const { buildCore } = require('../core/entity-engine');
|
|
10
|
+
const dbModule = require('../core/db');
|
|
11
|
+
const { JWT_SECRET } = require('../core/auth');
|
|
12
|
+
|
|
13
|
+
function makeToken(payload) {
|
|
14
|
+
return jwt.sign(payload, JWT_SECRET, { expiresIn: '1h' });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function mockRes() {
|
|
18
|
+
const r = { _status: 200, _body: undefined };
|
|
19
|
+
r.status = (s) => { r._status = s; return r; };
|
|
20
|
+
r.json = (b) => { r._body = b; };
|
|
21
|
+
return r;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('access-policies – read condition: self', () => {
|
|
25
|
+
const selfReadCore = buildCore({
|
|
26
|
+
name: 'SelfRead',
|
|
27
|
+
entities: {
|
|
28
|
+
User: {
|
|
29
|
+
authenticable: true,
|
|
30
|
+
properties: ['name'],
|
|
31
|
+
},
|
|
32
|
+
Project: {
|
|
33
|
+
properties: ['title'],
|
|
34
|
+
belongsTo: ['User'],
|
|
35
|
+
policies: {
|
|
36
|
+
read: [{ access: 'restricted', allow: 'User', condition: 'self' }],
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const entity = selfReadCore.entities.Project;
|
|
43
|
+
const policy = entity.policies.read[0];
|
|
44
|
+
|
|
45
|
+
it('read condition:self – sets req._selfFilter on valid token', () => {
|
|
46
|
+
const token = makeToken({ id: 'user-42', entity: 'User' });
|
|
47
|
+
const req = { headers: { authorization: `Bearer ${token}` }, params: {} };
|
|
48
|
+
const res = mockRes();
|
|
49
|
+
let nextCalled = false;
|
|
50
|
+
|
|
51
|
+
const allowed = Array.isArray(policy.allow) ? policy.allow : [policy.allow];
|
|
52
|
+
const mw = (req2, res2, next) => {
|
|
53
|
+
const header = req2.headers.authorization;
|
|
54
|
+
if (!header || !header.startsWith('Bearer ')) return res2.status(401).json({ error: 'Authorization required' });
|
|
55
|
+
try {
|
|
56
|
+
req2.user = jwt.verify(header.slice(7), JWT_SECRET);
|
|
57
|
+
if (!allowed.includes(req2.user.entity)) return res2.status(403).json({ error: 'Access denied' });
|
|
58
|
+
if (policy.condition === 'self') {
|
|
59
|
+
const userEntityObj = selfReadCore.entities[req2.user.entity];
|
|
60
|
+
if (userEntityObj) {
|
|
61
|
+
req2._selfFilter = { fk: `${userEntityObj.tableName}_id`, userId: req2.user.id };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
next();
|
|
65
|
+
} catch (e) {
|
|
66
|
+
return res2.status(401).json({ error: 'Invalid or expired token' });
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
mw(req, res, () => { nextCalled = true; });
|
|
71
|
+
assert.ok(nextCalled, 'next should be called');
|
|
72
|
+
assert.ok(req._selfFilter, '_selfFilter should be set');
|
|
73
|
+
assert.strictEqual(req._selfFilter.fk, 'user_id');
|
|
74
|
+
assert.strictEqual(req._selfFilter.userId, 'user-42');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('read condition:self – DB list is filtered to the owning user', () => {
|
|
78
|
+
const tmp = path.join(os.tmpdir(), `chadstart-selfread-${Date.now()}.db`);
|
|
79
|
+
dbModule.initDb(selfReadCore, tmp);
|
|
80
|
+
|
|
81
|
+
const user1 = dbModule.create('user', { name: 'Alice', email: 'alice@example.com', password: bcrypt.hashSync('pass1', 1) });
|
|
82
|
+
const user2 = dbModule.create('user', { name: 'Bob', email: 'bob@example.com', password: bcrypt.hashSync('pass2', 1) });
|
|
83
|
+
dbModule.create('project', { title: 'Alice Project 1', user_id: user1.id });
|
|
84
|
+
dbModule.create('project', { title: 'Alice Project 2', user_id: user1.id });
|
|
85
|
+
dbModule.create('project', { title: 'Bob Project', user_id: user2.id });
|
|
86
|
+
|
|
87
|
+
const selfFilter = { fk: 'user_id', userId: user1.id };
|
|
88
|
+
const query = { [selfFilter.fk]: selfFilter.userId };
|
|
89
|
+
const result = dbModule.findAll('project', query, { perPage: 100 });
|
|
90
|
+
|
|
91
|
+
assert.strictEqual(result.total, 2, 'only Alice\'s projects returned');
|
|
92
|
+
assert.ok(result.data.every((r) => r.user_id === user1.id));
|
|
93
|
+
|
|
94
|
+
fs.unlinkSync(tmp);
|
|
95
|
+
});
|
|
96
|
+
});
|
package/test/ai.test.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('assert');
|
|
4
|
+
const { getAiProvider, isAiConfigured } = require('../server/express-server');
|
|
5
|
+
|
|
6
|
+
// Save originals so we can restore after each test
|
|
7
|
+
const ENV_KEYS = ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'GOOGLE_API_KEY', 'GEMINI_API_KEY', 'OPENROUTER_API_KEY'];
|
|
8
|
+
function clearAiEnv() {
|
|
9
|
+
for (const k of ENV_KEYS) delete process.env[k];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe('AI provider detection', () => {
|
|
13
|
+
beforeEach(clearAiEnv);
|
|
14
|
+
after(clearAiEnv);
|
|
15
|
+
|
|
16
|
+
it('returns null when no AI keys are set', () => {
|
|
17
|
+
assert.strictEqual(getAiProvider(), null);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('returns "openai" when OPENAI_API_KEY is set', () => {
|
|
21
|
+
process.env.OPENAI_API_KEY = 'sk-test';
|
|
22
|
+
assert.strictEqual(getAiProvider(), 'openai');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('returns "anthropic" when ANTHROPIC_API_KEY is set', () => {
|
|
26
|
+
process.env.ANTHROPIC_API_KEY = 'sk-ant-test';
|
|
27
|
+
assert.strictEqual(getAiProvider(), 'anthropic');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('returns "google" when GOOGLE_API_KEY is set', () => {
|
|
31
|
+
process.env.GOOGLE_API_KEY = 'gkey';
|
|
32
|
+
assert.strictEqual(getAiProvider(), 'google');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('returns "google" when GEMINI_API_KEY is set', () => {
|
|
36
|
+
process.env.GEMINI_API_KEY = 'gemini-key';
|
|
37
|
+
assert.strictEqual(getAiProvider(), 'google');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('returns "openrouter" when OPENROUTER_API_KEY is set', () => {
|
|
41
|
+
process.env.OPENROUTER_API_KEY = 'sk-or-test';
|
|
42
|
+
assert.strictEqual(getAiProvider(), 'openrouter');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('openai takes priority over anthropic when both are set', () => {
|
|
46
|
+
process.env.OPENAI_API_KEY = 'sk-test';
|
|
47
|
+
process.env.ANTHROPIC_API_KEY = 'sk-ant-test';
|
|
48
|
+
assert.strictEqual(getAiProvider(), 'openai');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('anthropic takes priority over google when openai is absent', () => {
|
|
52
|
+
process.env.ANTHROPIC_API_KEY = 'sk-ant-test';
|
|
53
|
+
process.env.GOOGLE_API_KEY = 'gkey';
|
|
54
|
+
assert.strictEqual(getAiProvider(), 'anthropic');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('google takes priority over openrouter when neither openai nor anthropic are set', () => {
|
|
58
|
+
process.env.GOOGLE_API_KEY = 'gkey';
|
|
59
|
+
process.env.OPENROUTER_API_KEY = 'sk-or-test';
|
|
60
|
+
assert.strictEqual(getAiProvider(), 'google');
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('isAiConfigured', () => {
|
|
65
|
+
beforeEach(clearAiEnv);
|
|
66
|
+
after(clearAiEnv);
|
|
67
|
+
|
|
68
|
+
it('returns false when no keys are set', () => {
|
|
69
|
+
assert.strictEqual(isAiConfigured(), false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('returns true when OPENAI_API_KEY is set', () => {
|
|
73
|
+
process.env.OPENAI_API_KEY = 'sk-test';
|
|
74
|
+
assert.strictEqual(isAiConfigured(), true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('returns true when OPENROUTER_API_KEY is set', () => {
|
|
78
|
+
process.env.OPENROUTER_API_KEY = 'sk-or-test';
|
|
79
|
+
assert.strictEqual(isAiConfigured(), true);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,361 @@
|
|
|
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
|
+
|
|
8
|
+
// Isolate the DB for each test run
|
|
9
|
+
const TMP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'cs-apikey-test-'));
|
|
10
|
+
process.env.DB_PATH = path.join(TMP_DIR, 'test.db');
|
|
11
|
+
|
|
12
|
+
const { buildApp } = require('../server/express-server');
|
|
13
|
+
const http = require('http');
|
|
14
|
+
|
|
15
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
function req(options) {
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
const { method = 'GET', path: p, body, headers = {} } = options;
|
|
20
|
+
const data = body ? JSON.stringify(body) : undefined;
|
|
21
|
+
const opts = {
|
|
22
|
+
hostname: 'localhost', port: options.port, path: p, method,
|
|
23
|
+
headers: { 'Content-Type': 'application/json', ...headers,
|
|
24
|
+
...(data ? { 'Content-Length': Buffer.byteLength(data) } : {}) },
|
|
25
|
+
};
|
|
26
|
+
const r = http.request(opts, (res) => {
|
|
27
|
+
let buf = '';
|
|
28
|
+
res.on('data', (c) => { buf += c; });
|
|
29
|
+
res.on('end', () => {
|
|
30
|
+
let json;
|
|
31
|
+
try { json = JSON.parse(buf); } catch { json = buf; }
|
|
32
|
+
resolve({ status: res.statusCode, body: json });
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
r.on('error', reject);
|
|
36
|
+
if (data) r.write(data);
|
|
37
|
+
r.end();
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── Test suite ───────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
describe('API Keys', function () {
|
|
44
|
+
let server, port, adminToken, adminId;
|
|
45
|
+
|
|
46
|
+
const YAML_PATH = path.resolve(__dirname, '../chadstart.yaml');
|
|
47
|
+
|
|
48
|
+
before(async function () {
|
|
49
|
+
this.timeout(10000);
|
|
50
|
+
const { app } = await buildApp(YAML_PATH, null);
|
|
51
|
+
server = http.createServer(app);
|
|
52
|
+
await new Promise((resolve) => server.listen(0, resolve));
|
|
53
|
+
port = server.address().port;
|
|
54
|
+
|
|
55
|
+
// Sign up an admin user
|
|
56
|
+
const su = await req({ port, method: 'POST', path: '/api/auth/admin/signup',
|
|
57
|
+
body: { email: 'apikey@test.com', password: 'secret123', name: 'Test Admin' } });
|
|
58
|
+
assert.strictEqual(su.status, 201, 'signup should succeed');
|
|
59
|
+
adminToken = su.body.token;
|
|
60
|
+
adminId = su.body.user.id;
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
after(function (done) {
|
|
64
|
+
server.close(done);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// ── Unit tests (auth module) ──────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
describe('auth module – API key functions', function () {
|
|
70
|
+
const {
|
|
71
|
+
initApiKeys, createApiKey, verifyApiKeyStr, listApiKeys, deleteApiKey,
|
|
72
|
+
} = require('../core/auth');
|
|
73
|
+
|
|
74
|
+
it('createApiKey returns a key string and record', function () {
|
|
75
|
+
const { key, record } = createApiKey('user-1', 'Admin', { name: 'TestKey', permissions: ['read'], entities: [] });
|
|
76
|
+
assert.ok(key.startsWith('cs_'), 'key should start with cs_');
|
|
77
|
+
assert.strictEqual(key.length, 3 + 64, 'key should be cs_ + 64 hex chars');
|
|
78
|
+
assert.strictEqual(record.name, 'TestKey');
|
|
79
|
+
assert.strictEqual(record.userId, 'user-1');
|
|
80
|
+
assert.strictEqual(record.userEntity, 'Admin');
|
|
81
|
+
assert.deepStrictEqual(record.permissions, ['read']);
|
|
82
|
+
assert.ok(!record.keyHash, 'keyHash should not be in returned record');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('verifyApiKeyStr returns record for valid key', function () {
|
|
86
|
+
const { key } = createApiKey('user-2', 'Admin', { name: 'Verify' });
|
|
87
|
+
const record = verifyApiKeyStr(key);
|
|
88
|
+
assert.ok(record, 'should return a record');
|
|
89
|
+
assert.strictEqual(record.userId, 'user-2');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('verifyApiKeyStr returns null for wrong key', function () {
|
|
93
|
+
assert.strictEqual(verifyApiKeyStr('cs_notavalidkey'), null);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('verifyApiKeyStr returns null for non-cs_ prefixed string', function () {
|
|
97
|
+
assert.strictEqual(verifyApiKeyStr('eyJhbGciOiJIUzI1NiJ9.x.y'), null);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('verifyApiKeyStr returns null for expired key', function () {
|
|
101
|
+
const pastDate = new Date(Date.now() - 1000).toISOString();
|
|
102
|
+
const { key } = createApiKey('user-3', 'Admin', { expiresAt: pastDate });
|
|
103
|
+
assert.strictEqual(verifyApiKeyStr(key), null, 'expired key should return null');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('listApiKeys returns user keys', function () {
|
|
107
|
+
const { key } = createApiKey('user-list', 'Admin', { name: 'ListKey' });
|
|
108
|
+
const keys = listApiKeys('user-list', 'Admin');
|
|
109
|
+
assert.ok(keys.length >= 1);
|
|
110
|
+
assert.ok(keys.every((k) => !k.keyHash), 'keyHash should be stripped');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('deleteApiKey removes the key', function () {
|
|
114
|
+
const { record } = createApiKey('user-del', 'Admin', { name: 'ToDelete' });
|
|
115
|
+
deleteApiKey(record.id);
|
|
116
|
+
const verifiedAfterDelete = listApiKeys('user-del', 'Admin').find((k) => k.id === record.id);
|
|
117
|
+
assert.ok(!verifiedAfterDelete, 'key should be deleted');
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// ── resolveAuthHeader ─────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
describe('resolveAuthHeader', function () {
|
|
124
|
+
const { resolveAuthHeader, signToken, createApiKey } = require('../core/auth');
|
|
125
|
+
|
|
126
|
+
it('resolves JWT Bearer tokens', function () {
|
|
127
|
+
const token = signToken({ id: 'u1', entity: 'Admin' });
|
|
128
|
+
const { user, error } = resolveAuthHeader(`Bearer ${token}`);
|
|
129
|
+
assert.ok(user);
|
|
130
|
+
assert.strictEqual(user.id, 'u1');
|
|
131
|
+
assert.strictEqual(error, null);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('returns error for no header', function () {
|
|
135
|
+
const { user, error } = resolveAuthHeader(undefined);
|
|
136
|
+
assert.ok(!user);
|
|
137
|
+
assert.strictEqual(error, 'no_header');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('resolves API key Bearer tokens', function () {
|
|
141
|
+
const { key } = createApiKey('u-resolve', 'Admin', { permissions: ['read'], entities: ['posts'] });
|
|
142
|
+
const { user, apiKeyPermissions, error } = resolveAuthHeader(`Bearer ${key}`);
|
|
143
|
+
assert.ok(user);
|
|
144
|
+
assert.strictEqual(user.id, 'u-resolve');
|
|
145
|
+
assert.strictEqual(error, null);
|
|
146
|
+
assert.deepStrictEqual(apiKeyPermissions.operations, ['read']);
|
|
147
|
+
assert.deepStrictEqual(apiKeyPermissions.entities, ['posts']);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('returns error for invalid token', function () {
|
|
151
|
+
const { user, error } = resolveAuthHeader('Bearer bad-token-here');
|
|
152
|
+
assert.ok(!user);
|
|
153
|
+
assert.strictEqual(error, 'invalid_token');
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// ── HTTP API – user self-service ──────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
describe('GET /api/auth/admin/api-keys', function () {
|
|
160
|
+
it('returns 401 without token', async function () {
|
|
161
|
+
const r = await req({ port, path: '/api/auth/admin/api-keys' });
|
|
162
|
+
assert.strictEqual(r.status, 401);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('returns empty array initially', async function () {
|
|
166
|
+
const r = await req({ port, path: '/api/auth/admin/api-keys',
|
|
167
|
+
headers: { Authorization: `Bearer ${adminToken}` } });
|
|
168
|
+
assert.strictEqual(r.status, 200);
|
|
169
|
+
assert.ok(Array.isArray(r.body));
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe('POST /api/auth/admin/api-keys', function () {
|
|
174
|
+
it('returns 401 without token', async function () {
|
|
175
|
+
const r = await req({ port, method: 'POST', path: '/api/auth/admin/api-keys', body: { name: 'X' } });
|
|
176
|
+
assert.strictEqual(r.status, 401);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('creates a key and returns the plaintext key once', async function () {
|
|
180
|
+
const r = await req({ port, method: 'POST', path: '/api/auth/admin/api-keys',
|
|
181
|
+
headers: { Authorization: `Bearer ${adminToken}` },
|
|
182
|
+
body: { name: 'My Key', permissions: ['read'], entities: [], expiresAt: null },
|
|
183
|
+
});
|
|
184
|
+
assert.strictEqual(r.status, 201);
|
|
185
|
+
assert.ok(r.body.key, 'should return the plaintext key');
|
|
186
|
+
assert.ok(r.body.key.startsWith('cs_'), 'key should start with cs_');
|
|
187
|
+
assert.ok(r.body.record, 'should return the record');
|
|
188
|
+
assert.ok(!r.body.record.keyHash, 'keyHash must not be exposed');
|
|
189
|
+
assert.strictEqual(r.body.record.name, 'My Key');
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe('DELETE /api/auth/admin/api-keys/:id', function () {
|
|
194
|
+
it('deletes own API key', async function () {
|
|
195
|
+
// Create
|
|
196
|
+
const create = await req({ port, method: 'POST', path: '/api/auth/admin/api-keys',
|
|
197
|
+
headers: { Authorization: `Bearer ${adminToken}` },
|
|
198
|
+
body: { name: 'ToDelete' },
|
|
199
|
+
});
|
|
200
|
+
assert.strictEqual(create.status, 201);
|
|
201
|
+
const keyId = create.body.record.id;
|
|
202
|
+
|
|
203
|
+
// Delete
|
|
204
|
+
const del = await req({ port, method: 'DELETE', path: `/api/auth/admin/api-keys/${keyId}`,
|
|
205
|
+
headers: { Authorization: `Bearer ${adminToken}` } });
|
|
206
|
+
assert.strictEqual(del.status, 200);
|
|
207
|
+
assert.ok(del.body.success);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('returns 404 for non-existent key', async function () {
|
|
211
|
+
const r = await req({ port, method: 'DELETE', path: '/api/auth/admin/api-keys/nonexistent-id',
|
|
212
|
+
headers: { Authorization: `Bearer ${adminToken}` } });
|
|
213
|
+
assert.strictEqual(r.status, 404);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// ── HTTP API – using API key for authentication ───────────────────────
|
|
218
|
+
|
|
219
|
+
describe('API key authentication on entity routes', function () {
|
|
220
|
+
let apiKey;
|
|
221
|
+
|
|
222
|
+
before(async function () {
|
|
223
|
+
// Create an API key with read-only access to 'post' entity
|
|
224
|
+
const r = await req({ port, method: 'POST', path: '/api/auth/admin/api-keys',
|
|
225
|
+
headers: { Authorization: `Bearer ${adminToken}` },
|
|
226
|
+
body: { name: 'ReadOnly', permissions: ['read'], entities: ['post'], expiresAt: null },
|
|
227
|
+
});
|
|
228
|
+
assert.strictEqual(r.status, 201);
|
|
229
|
+
apiKey = r.body.key;
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('can access public routes with API key', async function () {
|
|
233
|
+
// Post has public read policy
|
|
234
|
+
const r = await req({ port, path: '/api/collections/post',
|
|
235
|
+
headers: { Authorization: `Bearer ${apiKey}` } });
|
|
236
|
+
assert.strictEqual(r.status, 200);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('rejects API key for entities not in entity access list', async function () {
|
|
240
|
+
// Create a key restricted to 'post' only
|
|
241
|
+
const createR = await req({ port, method: 'POST', path: '/api/auth/admin/api-keys',
|
|
242
|
+
headers: { Authorization: `Bearer ${adminToken}` },
|
|
243
|
+
body: { name: 'PostOnly', permissions: ['read', 'create', 'update', 'delete'], entities: ['post'] },
|
|
244
|
+
});
|
|
245
|
+
const restrictedKey = createR.body.key;
|
|
246
|
+
// Attempt to access 'comment' (not in entities list)
|
|
247
|
+
const r = await req({ port, path: '/api/collections/comment',
|
|
248
|
+
headers: { Authorization: `Bearer ${restrictedKey}` } });
|
|
249
|
+
assert.strictEqual(r.status, 403);
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// ── Admin routes ──────────────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
describe('GET /admin/api-keys', function () {
|
|
256
|
+
it('returns 401 without token', async function () {
|
|
257
|
+
const r = await req({ port, path: '/admin/api-keys' });
|
|
258
|
+
assert.strictEqual(r.status, 401);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('returns array with valid token', async function () {
|
|
262
|
+
const r = await req({ port, path: '/admin/api-keys',
|
|
263
|
+
headers: { Authorization: `Bearer ${adminToken}` } });
|
|
264
|
+
assert.strictEqual(r.status, 200);
|
|
265
|
+
assert.ok(Array.isArray(r.body));
|
|
266
|
+
assert.ok(r.body.every((k) => !k.keyHash), 'keyHash must never be exposed');
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
describe('POST /admin/api-keys (admin creates for any user)', function () {
|
|
271
|
+
it('creates key for a specific user', async function () {
|
|
272
|
+
const r = await req({ port, method: 'POST', path: '/admin/api-keys',
|
|
273
|
+
headers: { Authorization: `Bearer ${adminToken}` },
|
|
274
|
+
body: { userId: adminId, userEntity: 'Admin', name: 'AdminCreated', permissions: [], entities: [] },
|
|
275
|
+
});
|
|
276
|
+
assert.strictEqual(r.status, 201);
|
|
277
|
+
assert.ok(r.body.key.startsWith('cs_'));
|
|
278
|
+
assert.strictEqual(r.body.record.userId, adminId);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('returns 400 when userId is missing', async function () {
|
|
282
|
+
const r = await req({ port, method: 'POST', path: '/admin/api-keys',
|
|
283
|
+
headers: { Authorization: `Bearer ${adminToken}` },
|
|
284
|
+
body: { userEntity: 'Admin', name: 'NoUser' },
|
|
285
|
+
});
|
|
286
|
+
assert.strictEqual(r.status, 400);
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
describe('DELETE /admin/api-keys/:id', function () {
|
|
291
|
+
it('admin can delete any API key', async function () {
|
|
292
|
+
const create = await req({ port, method: 'POST', path: '/admin/api-keys',
|
|
293
|
+
headers: { Authorization: `Bearer ${adminToken}` },
|
|
294
|
+
body: { userId: adminId, userEntity: 'Admin', name: 'AdminDel' },
|
|
295
|
+
});
|
|
296
|
+
const keyId = create.body.record.id;
|
|
297
|
+
const del = await req({ port, method: 'DELETE', path: `/admin/api-keys/${keyId}`,
|
|
298
|
+
headers: { Authorization: `Bearer ${adminToken}` } });
|
|
299
|
+
assert.strictEqual(del.status, 200);
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// ── POST /admin/impersonate ───────────────────────────────────────────
|
|
304
|
+
|
|
305
|
+
describe('POST /admin/impersonate', function () {
|
|
306
|
+
it('returns 401 without token', async function () {
|
|
307
|
+
const r = await req({ port, method: 'POST', path: '/admin/impersonate',
|
|
308
|
+
body: { userId: adminId, userEntity: 'Admin' } });
|
|
309
|
+
assert.strictEqual(r.status, 401);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('returns 400 when userId is missing', async function () {
|
|
313
|
+
const r = await req({ port, method: 'POST', path: '/admin/impersonate',
|
|
314
|
+
headers: { Authorization: `Bearer ${adminToken}` },
|
|
315
|
+
body: { userEntity: 'Admin' } });
|
|
316
|
+
assert.strictEqual(r.status, 400);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('returns impersonation token with impersonated flag', async function () {
|
|
320
|
+
const r = await req({ port, method: 'POST', path: '/admin/impersonate',
|
|
321
|
+
headers: { Authorization: `Bearer ${adminToken}` },
|
|
322
|
+
body: { userId: adminId, userEntity: 'Admin' } });
|
|
323
|
+
assert.strictEqual(r.status, 200);
|
|
324
|
+
assert.ok(r.body.token, 'should return a token');
|
|
325
|
+
assert.ok(r.body.expiresAt, 'should return an expiry');
|
|
326
|
+
assert.strictEqual(r.body.userId, adminId);
|
|
327
|
+
// Verify the token decodes correctly
|
|
328
|
+
const { verifyToken } = require('../core/auth');
|
|
329
|
+
const payload = verifyToken(r.body.token);
|
|
330
|
+
assert.strictEqual(payload.id, adminId);
|
|
331
|
+
assert.strictEqual(payload.entity, 'Admin');
|
|
332
|
+
assert.strictEqual(payload.impersonated, true);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('returns 404 for non-existent user', async function () {
|
|
336
|
+
const r = await req({ port, method: 'POST', path: '/admin/impersonate',
|
|
337
|
+
headers: { Authorization: `Bearer ${adminToken}` },
|
|
338
|
+
body: { userId: 'non-existent-user', userEntity: 'Admin' } });
|
|
339
|
+
assert.strictEqual(r.status, 404);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('returns 404 for unknown user collection', async function () {
|
|
343
|
+
const r = await req({ port, method: 'POST', path: '/admin/impersonate',
|
|
344
|
+
headers: { Authorization: `Bearer ${adminToken}` },
|
|
345
|
+
body: { userId: adminId, userEntity: 'NonExistent' } });
|
|
346
|
+
assert.strictEqual(r.status, 404);
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
// ── Admin schema returns userCollections ──────────────────────────────
|
|
351
|
+
|
|
352
|
+
describe('GET /admin/schema', function () {
|
|
353
|
+
it('returns userCollections field with authenticable entities', async function () {
|
|
354
|
+
const r = await req({ port, path: '/admin/schema' });
|
|
355
|
+
assert.strictEqual(r.status, 200);
|
|
356
|
+
assert.ok(Array.isArray(r.body.userCollections), 'userCollections should be an array');
|
|
357
|
+
assert.ok(r.body.userCollections.every((e) => e.authenticable), 'all userCollections should be authenticable');
|
|
358
|
+
assert.ok(Array.isArray(r.body.entities), 'entities should be an array');
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('assert');
|
|
4
|
+
const { signToken, verifyToken, omitPassword, requireAuth, optionalAuth } = require('../core/auth');
|
|
5
|
+
|
|
6
|
+
function mockReq(headers = {}) {
|
|
7
|
+
return { headers, user: undefined };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function mockRes() {
|
|
11
|
+
const r = { _status: 200, _body: undefined };
|
|
12
|
+
r.status = (s) => { r._status = s; return r; };
|
|
13
|
+
r.json = (b) => { r._body = b; };
|
|
14
|
+
return r;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('auth', () => {
|
|
18
|
+
it('signToken/verifyToken round-trip', () => {
|
|
19
|
+
const t = signToken({ id: 1, entity: 'Admin' });
|
|
20
|
+
const d = verifyToken(t);
|
|
21
|
+
assert.strictEqual(d.id, 1);
|
|
22
|
+
assert.strictEqual(d.entity, 'Admin');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('verifyToken throws on invalid', () => assert.throws(() => verifyToken('bad'), /malformed|invalid/i));
|
|
26
|
+
it('omitPassword removes password', () => assert.ok(!('password' in omitPassword({ id: 1, password: 'x', email: 'a@b.com' }))));
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('auth – middleware', () => {
|
|
30
|
+
it('requireAuth: 401 when no Authorization header', () => {
|
|
31
|
+
const mw = requireAuth();
|
|
32
|
+
const req = mockReq();
|
|
33
|
+
const res = mockRes();
|
|
34
|
+
let nextCalled = false;
|
|
35
|
+
mw(req, res, () => { nextCalled = true; });
|
|
36
|
+
assert.strictEqual(res._status, 401);
|
|
37
|
+
assert.ok(!nextCalled);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('requireAuth: 401 when header lacks Bearer prefix', () => {
|
|
41
|
+
const mw = requireAuth();
|
|
42
|
+
const req = mockReq({ authorization: 'Basic abc123' });
|
|
43
|
+
const res = mockRes();
|
|
44
|
+
let nextCalled = false;
|
|
45
|
+
mw(req, res, () => { nextCalled = true; });
|
|
46
|
+
assert.strictEqual(res._status, 401);
|
|
47
|
+
assert.ok(!nextCalled);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('requireAuth: 401 for invalid token', () => {
|
|
51
|
+
const mw = requireAuth();
|
|
52
|
+
const req = mockReq({ authorization: 'Bearer not-a-valid-jwt' });
|
|
53
|
+
const res = mockRes();
|
|
54
|
+
let nextCalled = false;
|
|
55
|
+
mw(req, res, () => { nextCalled = true; });
|
|
56
|
+
assert.strictEqual(res._status, 401);
|
|
57
|
+
assert.ok(!nextCalled);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('requireAuth: 403 when entity does not match', () => {
|
|
61
|
+
const token = signToken({ id: 'u1', entity: 'Admin' });
|
|
62
|
+
const mw = requireAuth('User');
|
|
63
|
+
const req = mockReq({ authorization: `Bearer ${token}` });
|
|
64
|
+
const res = mockRes();
|
|
65
|
+
let nextCalled = false;
|
|
66
|
+
mw(req, res, () => { nextCalled = true; });
|
|
67
|
+
assert.strictEqual(res._status, 403);
|
|
68
|
+
assert.ok(!nextCalled);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('requireAuth: sets req.user and calls next for valid token (with entity filter)', () => {
|
|
72
|
+
const token = signToken({ id: 'u2', entity: 'Admin' });
|
|
73
|
+
const mw = requireAuth('Admin');
|
|
74
|
+
const req = mockReq({ authorization: `Bearer ${token}` });
|
|
75
|
+
const res = mockRes();
|
|
76
|
+
let nextCalled = false;
|
|
77
|
+
mw(req, res, () => { nextCalled = true; });
|
|
78
|
+
assert.ok(nextCalled);
|
|
79
|
+
assert.strictEqual(req.user.id, 'u2');
|
|
80
|
+
assert.strictEqual(req.user.entity, 'Admin');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('requireAuth: sets req.user and calls next without entity filter', () => {
|
|
84
|
+
const token = signToken({ id: 'u3', entity: 'Member' });
|
|
85
|
+
const mw = requireAuth();
|
|
86
|
+
const req = mockReq({ authorization: `Bearer ${token}` });
|
|
87
|
+
const res = mockRes();
|
|
88
|
+
let nextCalled = false;
|
|
89
|
+
mw(req, res, () => { nextCalled = true; });
|
|
90
|
+
assert.ok(nextCalled);
|
|
91
|
+
assert.strictEqual(req.user.entity, 'Member');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('optionalAuth: calls next without user when no header', () => {
|
|
95
|
+
const req = mockReq();
|
|
96
|
+
const res = mockRes();
|
|
97
|
+
let nextCalled = false;
|
|
98
|
+
optionalAuth(req, res, () => { nextCalled = true; });
|
|
99
|
+
assert.ok(nextCalled);
|
|
100
|
+
assert.ok(!req.user);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('optionalAuth: calls next without user when token is invalid', () => {
|
|
104
|
+
const req = mockReq({ authorization: 'Bearer bad-token' });
|
|
105
|
+
const res = mockRes();
|
|
106
|
+
let nextCalled = false;
|
|
107
|
+
optionalAuth(req, res, () => { nextCalled = true; });
|
|
108
|
+
assert.ok(nextCalled);
|
|
109
|
+
assert.ok(!req.user);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('optionalAuth: sets req.user when token is valid', () => {
|
|
113
|
+
const token = signToken({ id: 'u4', entity: 'Guest' });
|
|
114
|
+
const req = mockReq({ authorization: `Bearer ${token}` });
|
|
115
|
+
const res = mockRes();
|
|
116
|
+
let nextCalled = false;
|
|
117
|
+
optionalAuth(req, res, () => { nextCalled = true; });
|
|
118
|
+
assert.ok(nextCalled);
|
|
119
|
+
assert.strictEqual(req.user.id, 'u4');
|
|
120
|
+
assert.strictEqual(req.user.entity, 'Guest');
|
|
121
|
+
});
|
|
122
|
+
});
|