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.
@@ -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
+ });