chadstart 1.0.5 → 1.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/TODO.md +739 -0
- package/chadstart.example.yaml +22 -0
- package/chadstart.schema.json +83 -0
- package/cli/cli.js +78 -0
- package/core/auth.js +160 -3
- package/core/backup.js +191 -0
- package/core/db.js +4 -0
- package/core/email.js +170 -0
- package/core/entity-engine.js +4 -0
- package/core/logs.js +179 -0
- package/core/openapi.js +6 -2
- package/package.json +2 -1
- package/server/express-server.js +113 -0
- package/test/backup.test.js +146 -0
- package/test/email.test.js +362 -0
- package/test/logs.test.js +239 -0
- package/test/verification.test.js +439 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('assert');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const { buildCore } = require('../core/entity-engine');
|
|
8
|
+
const dbModule = require('../core/db');
|
|
9
|
+
const { initLogs, insertLog, queryLogs, cleanupOldLogs, requestLoggerMiddleware } = require('../core/logs');
|
|
10
|
+
const { validateSchema } = require('../core/schema-validator');
|
|
11
|
+
|
|
12
|
+
// ── Logs module ──────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
describe('logs module', () => {
|
|
15
|
+
let tmpDb;
|
|
16
|
+
const core = buildCore({ name: 'LogTest', entities: { Widget: { properties: ['name'] } } });
|
|
17
|
+
|
|
18
|
+
before(async () => {
|
|
19
|
+
tmpDb = path.join(os.tmpdir(), `chadstart-logs-${Date.now()}.db`);
|
|
20
|
+
await dbModule.initDb(core, tmpDb);
|
|
21
|
+
await initLogs();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
after(() => { try { fs.unlinkSync(tmpDb); } catch { /* noop */ } });
|
|
25
|
+
|
|
26
|
+
it('initLogs creates _cs_logs table', () => {
|
|
27
|
+
const tables = dbModule.getDb().prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='_cs_logs'").all();
|
|
28
|
+
assert.ok(tables.length > 0, '_cs_logs table should exist');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('insertLog inserts a record', async () => {
|
|
32
|
+
await insertLog({
|
|
33
|
+
method: 'GET',
|
|
34
|
+
path: '/api/collections/widgets',
|
|
35
|
+
statusCode: 200,
|
|
36
|
+
duration: 42,
|
|
37
|
+
ip: '127.0.0.1',
|
|
38
|
+
});
|
|
39
|
+
const result = await queryLogs();
|
|
40
|
+
assert.ok(result.data.length >= 1);
|
|
41
|
+
assert.strictEqual(result.data[0].method, 'GET');
|
|
42
|
+
assert.strictEqual(result.data[0].statusCode, 200);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('insertLog with user info', async () => {
|
|
46
|
+
await insertLog({
|
|
47
|
+
method: 'POST',
|
|
48
|
+
path: '/api/auth/admin/login',
|
|
49
|
+
statusCode: 200,
|
|
50
|
+
duration: 100,
|
|
51
|
+
ip: '192.168.1.1',
|
|
52
|
+
userId: 'user-123',
|
|
53
|
+
userEntity: 'Admin',
|
|
54
|
+
});
|
|
55
|
+
const result = await queryLogs({ method: 'POST' });
|
|
56
|
+
const log = result.data.find((l) => l.userId === 'user-123');
|
|
57
|
+
assert.ok(log);
|
|
58
|
+
assert.strictEqual(log.userEntity, 'Admin');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('queryLogs returns paginated results', async () => {
|
|
62
|
+
// Insert several logs
|
|
63
|
+
for (let i = 0; i < 5; i++) {
|
|
64
|
+
await insertLog({ method: 'GET', path: `/api/test/${i}`, statusCode: 200, duration: 10 });
|
|
65
|
+
}
|
|
66
|
+
const result = await queryLogs({}, { page: 1, perPage: 3 });
|
|
67
|
+
assert.ok(result.data.length <= 3);
|
|
68
|
+
assert.strictEqual(result.perPage, 3);
|
|
69
|
+
assert.strictEqual(result.currentPage, 1);
|
|
70
|
+
assert.ok(result.total >= 5);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('queryLogs filters by method', async () => {
|
|
74
|
+
await insertLog({ method: 'DELETE', path: '/api/delete-test', statusCode: 204, duration: 5 });
|
|
75
|
+
const result = await queryLogs({ method: 'DELETE' });
|
|
76
|
+
assert.ok(result.data.length >= 1);
|
|
77
|
+
assert.ok(result.data.every((l) => l.method === 'DELETE'));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('queryLogs filters by statusCode', async () => {
|
|
81
|
+
await insertLog({ method: 'GET', path: '/api/not-found', statusCode: 404, duration: 1 });
|
|
82
|
+
const result = await queryLogs({ statusCode: 404 });
|
|
83
|
+
assert.ok(result.data.length >= 1);
|
|
84
|
+
assert.ok(result.data.every((l) => l.statusCode === 404));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('queryLogs filters by path', async () => {
|
|
88
|
+
await insertLog({ method: 'GET', path: '/api/collections/unique-path', statusCode: 200, duration: 1 });
|
|
89
|
+
const result = await queryLogs({ path: 'unique-path' });
|
|
90
|
+
assert.ok(result.data.length >= 1);
|
|
91
|
+
assert.ok(result.data.every((l) => l.path.includes('unique-path')));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('queryLogs filters by date range', async () => {
|
|
95
|
+
const now = new Date();
|
|
96
|
+
const from = new Date(now - 60 * 1000).toISOString(); // 1 min ago
|
|
97
|
+
const to = new Date(now.getTime() + 60 * 1000).toISOString(); // 1 min ahead
|
|
98
|
+
const result = await queryLogs({ from, to });
|
|
99
|
+
assert.ok(result.data.length >= 1);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('queryLogs respects order parameter', async () => {
|
|
103
|
+
const resultAsc = await queryLogs({}, { order: 'ASC' });
|
|
104
|
+
const resultDesc = await queryLogs({}, { order: 'DESC' });
|
|
105
|
+
if (resultAsc.data.length > 1 && resultDesc.data.length > 1) {
|
|
106
|
+
// ASC: oldest first, DESC: newest first
|
|
107
|
+
assert.ok(resultAsc.data[0].createdAt <= resultAsc.data[resultAsc.data.length - 1].createdAt);
|
|
108
|
+
assert.ok(resultDesc.data[0].createdAt >= resultDesc.data[resultDesc.data.length - 1].createdAt);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('cleanupOldLogs removes old entries', async () => {
|
|
113
|
+
// Insert a log with a manually backdated createdAt
|
|
114
|
+
const oldDate = new Date(Date.now() - 60 * 24 * 60 * 60 * 1000).toISOString(); // 60 days ago
|
|
115
|
+
await dbModule.queryRun(
|
|
116
|
+
`INSERT INTO "_cs_logs" ("id","method","path","statusCode","duration","ip","createdAt") VALUES (?,?,?,?,?,?,?)`,
|
|
117
|
+
['old-log-id', 'GET', '/api/old', 200, 1, '127.0.0.1', oldDate]
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
// Verify it exists
|
|
121
|
+
const before = await dbModule.queryOne(
|
|
122
|
+
`SELECT * FROM "_cs_logs" WHERE "id" = ?`, ['old-log-id']
|
|
123
|
+
);
|
|
124
|
+
assert.ok(before);
|
|
125
|
+
|
|
126
|
+
// Cleanup logs older than 30 days
|
|
127
|
+
await cleanupOldLogs(30);
|
|
128
|
+
|
|
129
|
+
// Verify it's been deleted
|
|
130
|
+
const after = await dbModule.queryOne(
|
|
131
|
+
`SELECT * FROM "_cs_logs" WHERE "id" = ?`, ['old-log-id']
|
|
132
|
+
);
|
|
133
|
+
assert.ok(!after);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('cleanupOldLogs keeps recent entries', async () => {
|
|
137
|
+
const beforeCount = (await queryLogs()).total;
|
|
138
|
+
await cleanupOldLogs(30);
|
|
139
|
+
const afterCount = (await queryLogs()).total;
|
|
140
|
+
// Recent entries should not be deleted
|
|
141
|
+
assert.ok(afterCount >= 1);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('cleanupOldLogs with 0 days does nothing', async () => {
|
|
145
|
+
const beforeCount = (await queryLogs()).total;
|
|
146
|
+
const deleted = await cleanupOldLogs(0);
|
|
147
|
+
assert.strictEqual(deleted, 0);
|
|
148
|
+
const afterCount = (await queryLogs()).total;
|
|
149
|
+
assert.strictEqual(beforeCount, afterCount);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// ── requestLoggerMiddleware ──────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
describe('requestLoggerMiddleware', () => {
|
|
156
|
+
it('returns a function', () => {
|
|
157
|
+
const mw = requestLoggerMiddleware();
|
|
158
|
+
assert.strictEqual(typeof mw, 'function');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('calls next()', (done) => {
|
|
162
|
+
const mw = requestLoggerMiddleware();
|
|
163
|
+
const mockReq = { method: 'GET', path: '/api/test', originalUrl: '/api/test', ip: '127.0.0.1', connection: {} };
|
|
164
|
+
const mockRes = {
|
|
165
|
+
statusCode: 200,
|
|
166
|
+
end: function (...args) {
|
|
167
|
+
// original end
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
mw(mockReq, mockRes, done);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('skips excluded paths', (done) => {
|
|
174
|
+
const mw = requestLoggerMiddleware({ exclude: ['/health'] });
|
|
175
|
+
const mockReq = { method: 'GET', path: '/health', originalUrl: '/health', ip: '127.0.0.1' };
|
|
176
|
+
const mockRes = { statusCode: 200 };
|
|
177
|
+
mw(mockReq, mockRes, done);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('skips excluded path prefixes', (done) => {
|
|
181
|
+
const mw = requestLoggerMiddleware({ exclude: ['/admin/vendor'] });
|
|
182
|
+
const mockReq = { method: 'GET', path: '/admin/vendor/htmx.min.js', originalUrl: '/admin/vendor/htmx.min.js' };
|
|
183
|
+
const mockRes = { statusCode: 200 };
|
|
184
|
+
mw(mockReq, mockRes, done);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// ── Schema validation ───────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
describe('schema: logs', () => {
|
|
191
|
+
it('accepts config without logs section', () => {
|
|
192
|
+
assert.strictEqual(validateSchema({ name: 'App' }), true);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('accepts logs with retention', () => {
|
|
196
|
+
assert.strictEqual(validateSchema({ name: 'App', logs: { retention: 7 } }), true);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('accepts logs with exclude array', () => {
|
|
200
|
+
assert.strictEqual(validateSchema({
|
|
201
|
+
name: 'App',
|
|
202
|
+
logs: { exclude: ['/health', '/admin/vendor'] },
|
|
203
|
+
}), true);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('accepts empty logs object', () => {
|
|
207
|
+
assert.strictEqual(validateSchema({ name: 'App', logs: {} }), true);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('rejects unknown logs key', () => {
|
|
211
|
+
assert.throws(() => validateSchema({
|
|
212
|
+
name: 'App',
|
|
213
|
+
logs: { verbose: true },
|
|
214
|
+
}));
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('rejects retention as string', () => {
|
|
218
|
+
assert.throws(() => validateSchema({
|
|
219
|
+
name: 'App',
|
|
220
|
+
logs: { retention: '30' },
|
|
221
|
+
}));
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// ── buildCore: logs passthrough ─────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
describe('buildCore: logs passthrough', () => {
|
|
228
|
+
it('exposes logs config when provided', () => {
|
|
229
|
+
const core = buildCore({ name: 'App', logs: { retention: 7, exclude: ['/health'] } });
|
|
230
|
+
assert.ok(core.logs);
|
|
231
|
+
assert.strictEqual(core.logs.retention, 7);
|
|
232
|
+
assert.deepStrictEqual(core.logs.exclude, ['/health']);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('sets logs to null when not provided', () => {
|
|
236
|
+
const core = buildCore({ name: 'App' });
|
|
237
|
+
assert.strictEqual(core.logs, null);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
@@ -0,0 +1,439 @@
|
|
|
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 {
|
|
10
|
+
signToken, verifyToken, omitPassword, generateSecureToken,
|
|
11
|
+
} = require('../core/auth');
|
|
12
|
+
const { validateSchema } = require('../core/schema-validator');
|
|
13
|
+
const { generateOpenApiSpec } = require('../core/openapi');
|
|
14
|
+
|
|
15
|
+
// ── Helper: mock Express request/response ──────────────────────────────────
|
|
16
|
+
|
|
17
|
+
function mockReq(headers = {}, body = {}) {
|
|
18
|
+
return { headers, body, user: undefined };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function mockRes() {
|
|
22
|
+
const r = { _status: 200, _body: undefined };
|
|
23
|
+
r.status = (s) => { r._status = s; return r; };
|
|
24
|
+
r.json = (b) => { r._body = b; };
|
|
25
|
+
return r;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ── generateSecureToken ─────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
describe('generateSecureToken', () => {
|
|
31
|
+
it('generates a 64-char hex string', () => {
|
|
32
|
+
const t = generateSecureToken();
|
|
33
|
+
assert.strictEqual(typeof t, 'string');
|
|
34
|
+
assert.strictEqual(t.length, 64);
|
|
35
|
+
assert.ok(/^[a-f0-9]+$/.test(t));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('generates unique tokens', () => {
|
|
39
|
+
const a = generateSecureToken();
|
|
40
|
+
const b = generateSecureToken();
|
|
41
|
+
assert.notStrictEqual(a, b);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// ── omitPassword (updated to strip internal auth fields) ────────────────
|
|
46
|
+
|
|
47
|
+
describe('omitPassword – extended', () => {
|
|
48
|
+
it('strips password field', () => {
|
|
49
|
+
assert.ok(!('password' in omitPassword({ id: '1', password: 'x', email: 'a@b.com' })));
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('strips emailVerificationToken', () => {
|
|
53
|
+
assert.ok(!('emailVerificationToken' in omitPassword({ id: '1', emailVerificationToken: 'tok' })));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('strips passwordResetToken', () => {
|
|
57
|
+
assert.ok(!('passwordResetToken' in omitPassword({ id: '1', passwordResetToken: 'tok' })));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('strips passwordResetExpiry', () => {
|
|
61
|
+
assert.ok(!('passwordResetExpiry' in omitPassword({ id: '1', passwordResetExpiry: '2025-01-01' })));
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('preserves emailVerified', () => {
|
|
65
|
+
const u = omitPassword({ id: '1', emailVerified: 1, password: 'x' });
|
|
66
|
+
assert.strictEqual(u.emailVerified, 1);
|
|
67
|
+
assert.ok(!('password' in u));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('preserves other fields', () => {
|
|
71
|
+
const u = omitPassword({ id: '1', email: 'a@b.com', name: 'Test', password: 'x', emailVerificationToken: 'y' });
|
|
72
|
+
assert.strictEqual(u.email, 'a@b.com');
|
|
73
|
+
assert.strictEqual(u.name, 'Test');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// ── Entity engine: requireEmailVerification ─────────────────────────────
|
|
78
|
+
|
|
79
|
+
describe('entity-engine: requireEmailVerification', () => {
|
|
80
|
+
it('defaults to false', () => {
|
|
81
|
+
const core = buildCore({ name: 'App', entities: { User: { authenticable: true, properties: ['name'] } } });
|
|
82
|
+
assert.strictEqual(core.entities.User.requireEmailVerification, false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('sets to true when configured', () => {
|
|
86
|
+
const core = buildCore({
|
|
87
|
+
name: 'App',
|
|
88
|
+
entities: { User: { authenticable: true, requireEmailVerification: true, properties: ['name'] } },
|
|
89
|
+
});
|
|
90
|
+
assert.strictEqual(core.entities.User.requireEmailVerification, true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('non-authenticable entities still parse the flag (but it has no effect)', () => {
|
|
94
|
+
const core = buildCore({
|
|
95
|
+
name: 'App',
|
|
96
|
+
entities: { Post: { requireEmailVerification: true, properties: ['title'] } },
|
|
97
|
+
});
|
|
98
|
+
// The flag is stored even on non-auth entities, but only auth routes check it
|
|
99
|
+
assert.strictEqual(core.entities.Post.requireEmailVerification, true);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// ── Schema validation: requireEmailVerification ─────────────────────────
|
|
104
|
+
|
|
105
|
+
describe('schema: requireEmailVerification', () => {
|
|
106
|
+
it('accepts entity with requireEmailVerification: true', () => {
|
|
107
|
+
assert.strictEqual(validateSchema({
|
|
108
|
+
name: 'App',
|
|
109
|
+
entities: { User: { authenticable: true, requireEmailVerification: true, properties: ['name'] } },
|
|
110
|
+
}), true);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('accepts entity with requireEmailVerification: false', () => {
|
|
114
|
+
assert.strictEqual(validateSchema({
|
|
115
|
+
name: 'App',
|
|
116
|
+
entities: { User: { authenticable: true, requireEmailVerification: false, properties: ['name'] } },
|
|
117
|
+
}), true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('accepts entity without requireEmailVerification', () => {
|
|
121
|
+
assert.strictEqual(validateSchema({
|
|
122
|
+
name: 'App',
|
|
123
|
+
entities: { User: { authenticable: true, properties: ['name'] } },
|
|
124
|
+
}), true);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// ── DB: authenticable columns ───────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
describe('db – verification/reset columns', () => {
|
|
131
|
+
let tmpDb;
|
|
132
|
+
const core = buildCore({
|
|
133
|
+
name: 'T',
|
|
134
|
+
entities: { User: { authenticable: true, properties: ['name'] } },
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
before(async () => {
|
|
138
|
+
tmpDb = path.join(os.tmpdir(), `chadstart-verif-${Date.now()}.db`);
|
|
139
|
+
await dbModule.initDb(core, tmpDb);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
after(() => { try { fs.unlinkSync(tmpDb); } catch { /* noop */ } });
|
|
143
|
+
|
|
144
|
+
it('has emailVerified column', () => {
|
|
145
|
+
const cols = dbModule.getDb().pragma('table_info("user")').map((r) => r.name);
|
|
146
|
+
assert.ok(cols.includes('emailVerified'), 'missing emailVerified column');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('has emailVerificationToken column', () => {
|
|
150
|
+
const cols = dbModule.getDb().pragma('table_info("user")').map((r) => r.name);
|
|
151
|
+
assert.ok(cols.includes('emailVerificationToken'), 'missing emailVerificationToken column');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('has passwordResetToken column', () => {
|
|
155
|
+
const cols = dbModule.getDb().pragma('table_info("user")').map((r) => r.name);
|
|
156
|
+
assert.ok(cols.includes('passwordResetToken'), 'missing passwordResetToken column');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('has passwordResetExpiry column', () => {
|
|
160
|
+
const cols = dbModule.getDb().pragma('table_info("user")').map((r) => r.name);
|
|
161
|
+
assert.ok(cols.includes('passwordResetExpiry'), 'missing passwordResetExpiry column');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('emailVerified defaults to 0', async () => {
|
|
165
|
+
const user = await dbModule.create('user', {
|
|
166
|
+
email: 'vertest@example.com',
|
|
167
|
+
password: 'hash',
|
|
168
|
+
});
|
|
169
|
+
assert.strictEqual(user.emailVerified, 0);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('can update emailVerified to 1', async () => {
|
|
173
|
+
const user = await dbModule.create('user', {
|
|
174
|
+
email: 'vertest2@example.com',
|
|
175
|
+
password: 'hash',
|
|
176
|
+
});
|
|
177
|
+
const updated = await dbModule.update('user', user.id, { emailVerified: 1 });
|
|
178
|
+
assert.strictEqual(updated.emailVerified, 1);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('can store and query verification token', async () => {
|
|
182
|
+
const token = generateSecureToken();
|
|
183
|
+
const user = await dbModule.create('user', {
|
|
184
|
+
email: 'vertest3@example.com',
|
|
185
|
+
password: 'hash',
|
|
186
|
+
emailVerificationToken: token,
|
|
187
|
+
});
|
|
188
|
+
const found = (await dbModule.findAllSimple('user', { emailVerificationToken: token }))[0];
|
|
189
|
+
assert.ok(found);
|
|
190
|
+
assert.strictEqual(found.id, user.id);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('can store and query password reset token', async () => {
|
|
194
|
+
const token = generateSecureToken();
|
|
195
|
+
const expiry = new Date(Date.now() + 3600000).toISOString();
|
|
196
|
+
const user = await dbModule.create('user', {
|
|
197
|
+
email: 'vertest4@example.com',
|
|
198
|
+
password: 'hash',
|
|
199
|
+
passwordResetToken: token,
|
|
200
|
+
passwordResetExpiry: expiry,
|
|
201
|
+
});
|
|
202
|
+
const found = (await dbModule.findAllSimple('user', { passwordResetToken: token }))[0];
|
|
203
|
+
assert.ok(found);
|
|
204
|
+
assert.strictEqual(found.id, user.id);
|
|
205
|
+
assert.ok(found.passwordResetExpiry);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('can clear tokens by setting to null', async () => {
|
|
209
|
+
const token = generateSecureToken();
|
|
210
|
+
const user = await dbModule.create('user', {
|
|
211
|
+
email: 'vertest5@example.com',
|
|
212
|
+
password: 'hash',
|
|
213
|
+
emailVerificationToken: token,
|
|
214
|
+
});
|
|
215
|
+
await dbModule.update('user', user.id, { emailVerificationToken: null });
|
|
216
|
+
const updated = await dbModule.findById('user', user.id);
|
|
217
|
+
assert.strictEqual(updated.emailVerificationToken, null);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// ── OpenAPI: verification/reset endpoints ───────────────────────────────
|
|
222
|
+
|
|
223
|
+
describe('openapi: verification/reset endpoints', () => {
|
|
224
|
+
const core = buildCore({
|
|
225
|
+
name: 'App',
|
|
226
|
+
entities: { User: { authenticable: true, properties: ['name'] } },
|
|
227
|
+
});
|
|
228
|
+
const spec = generateOpenApiSpec(core);
|
|
229
|
+
|
|
230
|
+
it('includes request-verification endpoint', () => {
|
|
231
|
+
assert.ok(spec.paths['/api/auth/user/request-verification']);
|
|
232
|
+
assert.ok(spec.paths['/api/auth/user/request-verification'].post);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('includes confirm-verification endpoint', () => {
|
|
236
|
+
assert.ok(spec.paths['/api/auth/user/confirm-verification']);
|
|
237
|
+
assert.ok(spec.paths['/api/auth/user/confirm-verification'].post);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('includes request-password-reset endpoint', () => {
|
|
241
|
+
assert.ok(spec.paths['/api/auth/user/request-password-reset']);
|
|
242
|
+
assert.ok(spec.paths['/api/auth/user/request-password-reset'].post);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('includes confirm-password-reset endpoint', () => {
|
|
246
|
+
assert.ok(spec.paths['/api/auth/user/confirm-password-reset']);
|
|
247
|
+
assert.ok(spec.paths['/api/auth/user/confirm-password-reset'].post);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('login response includes 403 for email not verified', () => {
|
|
251
|
+
const loginPath = spec.paths['/api/auth/user/login'];
|
|
252
|
+
assert.ok(loginPath.post.responses['403']);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('user schema includes emailVerified', () => {
|
|
256
|
+
assert.ok(spec.components.schemas.User.properties.emailVerified);
|
|
257
|
+
assert.strictEqual(spec.components.schemas.User.properties.emailVerified.type, 'boolean');
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('confirm-verification has token in request body', () => {
|
|
261
|
+
const ep = spec.paths['/api/auth/user/confirm-verification'].post;
|
|
262
|
+
assert.ok(ep.requestBody);
|
|
263
|
+
const schema = ep.requestBody.content['application/json'].schema;
|
|
264
|
+
assert.ok(schema.properties.token);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('confirm-password-reset has token and password in request body', () => {
|
|
268
|
+
const ep = spec.paths['/api/auth/user/confirm-password-reset'].post;
|
|
269
|
+
assert.ok(ep.requestBody);
|
|
270
|
+
const schema = ep.requestBody.content['application/json'].schema;
|
|
271
|
+
assert.ok(schema.properties.token);
|
|
272
|
+
assert.ok(schema.properties.password);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// Admin entity should also have the endpoints
|
|
276
|
+
it('includes admin verification endpoints', () => {
|
|
277
|
+
assert.ok(spec.paths['/api/auth/admin/request-verification']);
|
|
278
|
+
assert.ok(spec.paths['/api/auth/admin/confirm-verification']);
|
|
279
|
+
assert.ok(spec.paths['/api/auth/admin/request-password-reset']);
|
|
280
|
+
assert.ok(spec.paths['/api/auth/admin/confirm-password-reset']);
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// ── Integration: full verification + password reset flows ───────────────
|
|
285
|
+
|
|
286
|
+
describe('auth integration – email verification flow', () => {
|
|
287
|
+
let tmpDb;
|
|
288
|
+
const bcrypt = require('bcryptjs');
|
|
289
|
+
const core = buildCore({
|
|
290
|
+
name: 'TestApp',
|
|
291
|
+
entities: { User: { authenticable: true, requireEmailVerification: true, properties: ['name'] } },
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
before(async () => {
|
|
295
|
+
tmpDb = path.join(os.tmpdir(), `chadstart-auth-flow-${Date.now()}.db`);
|
|
296
|
+
await dbModule.initDb(core, tmpDb);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
after(() => { try { fs.unlinkSync(tmpDb); } catch { /* noop */ } });
|
|
300
|
+
|
|
301
|
+
it('signup creates user with emailVerified=0 and generates token', async () => {
|
|
302
|
+
const pw = await bcrypt.hash('testpass', 10);
|
|
303
|
+
const user = await dbModule.create('user', {
|
|
304
|
+
email: 'flow1@example.com',
|
|
305
|
+
password: pw,
|
|
306
|
+
emailVerified: 0,
|
|
307
|
+
emailVerificationToken: generateSecureToken(),
|
|
308
|
+
});
|
|
309
|
+
assert.strictEqual(user.emailVerified, 0);
|
|
310
|
+
assert.ok(user.emailVerificationToken);
|
|
311
|
+
assert.strictEqual(user.emailVerificationToken.length, 64);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('confirm-verification: can verify email with valid token', async () => {
|
|
315
|
+
const token = generateSecureToken();
|
|
316
|
+
const pw = await bcrypt.hash('testpass', 10);
|
|
317
|
+
const user = await dbModule.create('user', {
|
|
318
|
+
email: 'flow2@example.com',
|
|
319
|
+
password: pw,
|
|
320
|
+
emailVerified: 0,
|
|
321
|
+
emailVerificationToken: token,
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// Simulate confirm-verification
|
|
325
|
+
const found = (await dbModule.findAllSimple('user', { emailVerificationToken: token }))[0];
|
|
326
|
+
assert.ok(found);
|
|
327
|
+
assert.strictEqual(found.id, user.id);
|
|
328
|
+
|
|
329
|
+
await dbModule.update('user', user.id, { emailVerified: 1, emailVerificationToken: null });
|
|
330
|
+
const updated = await dbModule.findById('user', user.id);
|
|
331
|
+
assert.strictEqual(updated.emailVerified, 1);
|
|
332
|
+
assert.strictEqual(updated.emailVerificationToken, null);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('confirm-verification: returns null for invalid token', async () => {
|
|
336
|
+
const found = (await dbModule.findAllSimple('user', { emailVerificationToken: 'nonexistent-token-12345' }))[0];
|
|
337
|
+
assert.ok(!found);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('request-verification: can regenerate token', async () => {
|
|
341
|
+
const token1 = generateSecureToken();
|
|
342
|
+
const pw = await bcrypt.hash('testpass', 10);
|
|
343
|
+
const user = await dbModule.create('user', {
|
|
344
|
+
email: 'flow3@example.com',
|
|
345
|
+
password: pw,
|
|
346
|
+
emailVerified: 0,
|
|
347
|
+
emailVerificationToken: token1,
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
const token2 = generateSecureToken();
|
|
351
|
+
await dbModule.update('user', user.id, { emailVerificationToken: token2 });
|
|
352
|
+
const updated = await dbModule.findById('user', user.id);
|
|
353
|
+
assert.strictEqual(updated.emailVerificationToken, token2);
|
|
354
|
+
assert.notStrictEqual(token1, token2);
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
describe('auth integration – password reset flow', () => {
|
|
359
|
+
let tmpDb;
|
|
360
|
+
const bcrypt = require('bcryptjs');
|
|
361
|
+
const core = buildCore({
|
|
362
|
+
name: 'TestApp',
|
|
363
|
+
entities: { User: { authenticable: true, properties: ['name'] } },
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
before(async () => {
|
|
367
|
+
tmpDb = path.join(os.tmpdir(), `chadstart-reset-flow-${Date.now()}.db`);
|
|
368
|
+
await dbModule.initDb(core, tmpDb);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
after(() => { try { fs.unlinkSync(tmpDb); } catch { /* noop */ } });
|
|
372
|
+
|
|
373
|
+
it('request-password-reset: creates token and expiry', async () => {
|
|
374
|
+
const pw = await bcrypt.hash('oldpass', 10);
|
|
375
|
+
const user = await dbModule.create('user', {
|
|
376
|
+
email: 'reset1@example.com',
|
|
377
|
+
password: pw,
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
const token = generateSecureToken();
|
|
381
|
+
const expiry = new Date(Date.now() + 3600000).toISOString();
|
|
382
|
+
await dbModule.update('user', user.id, { passwordResetToken: token, passwordResetExpiry: expiry });
|
|
383
|
+
|
|
384
|
+
const updated = await dbModule.findById('user', user.id);
|
|
385
|
+
assert.strictEqual(updated.passwordResetToken, token);
|
|
386
|
+
assert.ok(updated.passwordResetExpiry);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it('confirm-password-reset: updates password and clears token', async () => {
|
|
390
|
+
const pw = await bcrypt.hash('oldpass', 10);
|
|
391
|
+
const token = generateSecureToken();
|
|
392
|
+
const expiry = new Date(Date.now() + 3600000).toISOString();
|
|
393
|
+
const user = await dbModule.create('user', {
|
|
394
|
+
email: 'reset2@example.com',
|
|
395
|
+
password: pw,
|
|
396
|
+
passwordResetToken: token,
|
|
397
|
+
passwordResetExpiry: expiry,
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
// Simulate confirm
|
|
401
|
+
const found = (await dbModule.findAllSimple('user', { passwordResetToken: token }))[0];
|
|
402
|
+
assert.ok(found);
|
|
403
|
+
assert.ok(new Date(found.passwordResetExpiry) > new Date());
|
|
404
|
+
|
|
405
|
+
const newPw = await bcrypt.hash('newpass', 10);
|
|
406
|
+
await dbModule.update('user', user.id, {
|
|
407
|
+
password: newPw,
|
|
408
|
+
passwordResetToken: null,
|
|
409
|
+
passwordResetExpiry: null,
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
const updated = await dbModule.findById('user', user.id);
|
|
413
|
+
assert.strictEqual(updated.passwordResetToken, null);
|
|
414
|
+
assert.strictEqual(updated.passwordResetExpiry, null);
|
|
415
|
+
assert.ok(await bcrypt.compare('newpass', updated.password));
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it('confirm-password-reset: rejects expired token', async () => {
|
|
419
|
+
const pw = await bcrypt.hash('oldpass', 10);
|
|
420
|
+
const token = generateSecureToken();
|
|
421
|
+
const expiry = new Date(Date.now() - 1000).toISOString(); // Already expired
|
|
422
|
+
const user = await dbModule.create('user', {
|
|
423
|
+
email: 'reset3@example.com',
|
|
424
|
+
password: pw,
|
|
425
|
+
passwordResetToken: token,
|
|
426
|
+
passwordResetExpiry: expiry,
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
const found = (await dbModule.findAllSimple('user', { passwordResetToken: token }))[0];
|
|
430
|
+
assert.ok(found);
|
|
431
|
+
// Token found but expired
|
|
432
|
+
assert.ok(new Date(found.passwordResetExpiry) < new Date());
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it('confirm-password-reset: returns null for invalid token', async () => {
|
|
436
|
+
const found = (await dbModule.findAllSimple('user', { passwordResetToken: 'nonexistent-token' }))[0];
|
|
437
|
+
assert.ok(!found);
|
|
438
|
+
});
|
|
439
|
+
});
|