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,146 @@
|
|
|
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 { getBackupDir, createBackup, restoreBackup, listBackups } = require('../core/backup');
|
|
10
|
+
const { validateSchema } = require('../core/schema-validator');
|
|
11
|
+
|
|
12
|
+
// ── Backup module ──────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
describe('backup module', () => {
|
|
15
|
+
let tmpDb;
|
|
16
|
+
let tmpBackupDir;
|
|
17
|
+
const core = buildCore({
|
|
18
|
+
name: 'BackupTest',
|
|
19
|
+
entities: { Widget: { properties: ['name'] } },
|
|
20
|
+
backup: {},
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
before(async () => {
|
|
24
|
+
tmpDb = path.join(os.tmpdir(), `chadstart-backup-test-${Date.now()}.db`);
|
|
25
|
+
tmpBackupDir = path.join(os.tmpdir(), `chadstart-backups-${Date.now()}`);
|
|
26
|
+
await dbModule.initDb(core, tmpDb);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
after(() => {
|
|
30
|
+
try { fs.unlinkSync(tmpDb); } catch { /* noop */ }
|
|
31
|
+
// Clean up backup dir
|
|
32
|
+
try {
|
|
33
|
+
if (fs.existsSync(tmpBackupDir)) {
|
|
34
|
+
for (const f of fs.readdirSync(tmpBackupDir)) {
|
|
35
|
+
fs.unlinkSync(path.join(tmpBackupDir, f));
|
|
36
|
+
}
|
|
37
|
+
fs.rmdirSync(tmpBackupDir);
|
|
38
|
+
}
|
|
39
|
+
} catch { /* noop */ }
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('getBackupDir creates directory', () => {
|
|
43
|
+
const dir = getBackupDir({ dir: tmpBackupDir });
|
|
44
|
+
assert.ok(fs.existsSync(dir));
|
|
45
|
+
assert.strictEqual(dir, tmpBackupDir);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('getBackupDir uses default when no config', () => {
|
|
49
|
+
const dir = getBackupDir(null);
|
|
50
|
+
assert.ok(typeof dir === 'string');
|
|
51
|
+
assert.ok(dir.includes('backups'));
|
|
52
|
+
// Clean up the auto-created default dir
|
|
53
|
+
try { fs.rmdirSync(path.resolve('backups')); } catch { /* may not exist or not empty */ }
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('createBackup creates a backup file (SQLite)', async () => {
|
|
57
|
+
// Insert some data first
|
|
58
|
+
await dbModule.create('widget', { name: 'Backup Test Item' });
|
|
59
|
+
|
|
60
|
+
const result = await createBackup({ dir: tmpBackupDir });
|
|
61
|
+
assert.ok(result.file);
|
|
62
|
+
assert.ok(result.file.startsWith('backup-'));
|
|
63
|
+
assert.ok(result.file.endsWith('.db'));
|
|
64
|
+
assert.ok(result.size > 0);
|
|
65
|
+
assert.strictEqual(result.engine, 'sqlite');
|
|
66
|
+
assert.ok(fs.existsSync(result.path));
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('listBackups returns the backup we just created', () => {
|
|
70
|
+
const backups = listBackups({ dir: tmpBackupDir });
|
|
71
|
+
assert.ok(backups.length >= 1);
|
|
72
|
+
assert.ok(backups[0].file.startsWith('backup-'));
|
|
73
|
+
assert.ok(backups[0].size > 0);
|
|
74
|
+
assert.ok(backups[0].createdAt);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('listBackups sorts newest first', async () => {
|
|
78
|
+
// Create a second backup
|
|
79
|
+
await createBackup({ dir: tmpBackupDir });
|
|
80
|
+
const backups = listBackups({ dir: tmpBackupDir });
|
|
81
|
+
assert.ok(backups.length >= 2);
|
|
82
|
+
// Newest first
|
|
83
|
+
assert.ok(backups[0].createdAt >= backups[1].createdAt);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('listBackups returns empty for non-existent dir', () => {
|
|
87
|
+
const nonExistDir = path.join(os.tmpdir(), `nonexistent-${Date.now()}`);
|
|
88
|
+
const backups = listBackups({ dir: nonExistDir });
|
|
89
|
+
// getBackupDir creates the dir, so it returns empty array
|
|
90
|
+
assert.ok(Array.isArray(backups));
|
|
91
|
+
assert.strictEqual(backups.length, 0);
|
|
92
|
+
// Clean up
|
|
93
|
+
try { fs.rmdirSync(nonExistDir); } catch { /* noop */ }
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('restoreBackup fails for non-existent file', async () => {
|
|
97
|
+
const result = await restoreBackup('nonexistent.db', { dir: tmpBackupDir });
|
|
98
|
+
assert.strictEqual(result.success, false);
|
|
99
|
+
assert.ok(result.message.includes('not found'));
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('restoreBackup prevents path traversal', async () => {
|
|
103
|
+
const result = await restoreBackup('../../etc/passwd', { dir: tmpBackupDir });
|
|
104
|
+
assert.strictEqual(result.success, false);
|
|
105
|
+
// basename('../../etc/passwd') = 'passwd', which won't exist in backup dir
|
|
106
|
+
assert.ok(result.message.includes('not found'));
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// ── Schema validation ───────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
describe('schema: backup', () => {
|
|
113
|
+
it('accepts config without backup section', () => {
|
|
114
|
+
assert.strictEqual(validateSchema({ name: 'App' }), true);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('accepts backup with dir', () => {
|
|
118
|
+
assert.strictEqual(validateSchema({ name: 'App', backup: { dir: 'my-backups' } }), true);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('accepts empty backup object', () => {
|
|
122
|
+
assert.strictEqual(validateSchema({ name: 'App', backup: {} }), true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('rejects unknown backup key', () => {
|
|
126
|
+
assert.throws(() => validateSchema({
|
|
127
|
+
name: 'App',
|
|
128
|
+
backup: { schedule: '0 3 * * *' },
|
|
129
|
+
}));
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// ── buildCore: backup passthrough ───────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
describe('buildCore: backup passthrough', () => {
|
|
136
|
+
it('exposes backup config when provided', () => {
|
|
137
|
+
const core = buildCore({ name: 'App', backup: { dir: 'my-backups' } });
|
|
138
|
+
assert.ok(core.backup);
|
|
139
|
+
assert.strictEqual(core.backup.dir, 'my-backups');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('sets backup to null when not provided', () => {
|
|
143
|
+
const core = buildCore({ name: 'App' });
|
|
144
|
+
assert.strictEqual(core.backup, null);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('assert');
|
|
4
|
+
const { getEmailConfig, initEmail, interpolate, getEmailStatus, sendEmail, verifyConnection } = require('../core/email');
|
|
5
|
+
const { validateSchema } = require('../core/schema-validator');
|
|
6
|
+
const { buildCore } = require('../core/entity-engine');
|
|
7
|
+
|
|
8
|
+
// Helper: set/restore env vars around a test
|
|
9
|
+
function withEnv(vars, fn) {
|
|
10
|
+
const saved = {};
|
|
11
|
+
for (const [k, v] of Object.entries(vars)) {
|
|
12
|
+
saved[k] = process.env[k];
|
|
13
|
+
if (v === undefined) delete process.env[k]; else process.env[k] = v;
|
|
14
|
+
}
|
|
15
|
+
try { return fn(); } finally {
|
|
16
|
+
for (const [k, v] of Object.entries(saved)) {
|
|
17
|
+
if (v === undefined) delete process.env[k]; else process.env[k] = v;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ── getEmailConfig ──────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
describe('getEmailConfig – disabled', () => {
|
|
25
|
+
it('returns null when no yaml and no env vars', () => {
|
|
26
|
+
withEnv({ SMTP_HOST: undefined, SMTP_PORT: undefined, SMTP_USER: undefined, SMTP_PASS: undefined, SMTP_FROM: undefined }, () => {
|
|
27
|
+
assert.strictEqual(getEmailConfig(null), null);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('returns null when yaml is empty object', () => {
|
|
32
|
+
withEnv({ SMTP_HOST: undefined }, () => {
|
|
33
|
+
assert.strictEqual(getEmailConfig({}), null);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('returns null when yaml has no host', () => {
|
|
38
|
+
withEnv({ SMTP_HOST: undefined }, () => {
|
|
39
|
+
assert.strictEqual(getEmailConfig({ port: 587, from: 'test@test.com' }), null);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('getEmailConfig – enabled via yaml', () => {
|
|
45
|
+
it('returns config when host is provided in yaml', () => {
|
|
46
|
+
withEnv({ SMTP_HOST: undefined, SMTP_PORT: undefined, SMTP_USER: undefined, SMTP_PASS: undefined, SMTP_FROM: undefined }, () => {
|
|
47
|
+
const cfg = getEmailConfig({ host: 'smtp.example.com' });
|
|
48
|
+
assert.ok(cfg);
|
|
49
|
+
assert.strictEqual(cfg.host, 'smtp.example.com');
|
|
50
|
+
assert.strictEqual(cfg.port, 587);
|
|
51
|
+
assert.strictEqual(cfg.user, '');
|
|
52
|
+
assert.strictEqual(cfg.pass, '');
|
|
53
|
+
assert.strictEqual(cfg.from, '');
|
|
54
|
+
assert.strictEqual(cfg.secure, false);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('reads all yaml fields', () => {
|
|
59
|
+
withEnv({ SMTP_HOST: undefined, SMTP_PORT: undefined, SMTP_USER: undefined, SMTP_PASS: undefined, SMTP_FROM: undefined }, () => {
|
|
60
|
+
const cfg = getEmailConfig({
|
|
61
|
+
host: 'smtp.example.com',
|
|
62
|
+
port: 465,
|
|
63
|
+
username: 'user@example.com',
|
|
64
|
+
from: 'App <noreply@example.com>',
|
|
65
|
+
secure: true,
|
|
66
|
+
});
|
|
67
|
+
assert.strictEqual(cfg.host, 'smtp.example.com');
|
|
68
|
+
assert.strictEqual(cfg.port, 465);
|
|
69
|
+
assert.strictEqual(cfg.user, 'user@example.com');
|
|
70
|
+
assert.strictEqual(cfg.from, 'App <noreply@example.com>');
|
|
71
|
+
assert.strictEqual(cfg.secure, true);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('auto-detects secure=true for port 465', () => {
|
|
76
|
+
withEnv({ SMTP_HOST: undefined, SMTP_PORT: undefined, SMTP_USER: undefined, SMTP_PASS: undefined, SMTP_FROM: undefined }, () => {
|
|
77
|
+
const cfg = getEmailConfig({ host: 'smtp.example.com', port: 465 });
|
|
78
|
+
assert.strictEqual(cfg.secure, true);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('auto-detects secure=false for port 587', () => {
|
|
83
|
+
withEnv({ SMTP_HOST: undefined, SMTP_PORT: undefined, SMTP_USER: undefined, SMTP_PASS: undefined, SMTP_FROM: undefined }, () => {
|
|
84
|
+
const cfg = getEmailConfig({ host: 'smtp.example.com', port: 587 });
|
|
85
|
+
assert.strictEqual(cfg.secure, false);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('explicit secure overrides port-based auto-detection', () => {
|
|
90
|
+
withEnv({ SMTP_HOST: undefined, SMTP_PORT: undefined, SMTP_USER: undefined, SMTP_PASS: undefined, SMTP_FROM: undefined }, () => {
|
|
91
|
+
const cfg = getEmailConfig({ host: 'smtp.example.com', port: 465, secure: false });
|
|
92
|
+
assert.strictEqual(cfg.secure, false);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('getEmailConfig – enabled via env vars', () => {
|
|
98
|
+
it('returns config when SMTP_HOST env var is set', () => {
|
|
99
|
+
withEnv({ SMTP_HOST: 'env-smtp.example.com', SMTP_PORT: undefined, SMTP_USER: undefined, SMTP_PASS: undefined, SMTP_FROM: undefined }, () => {
|
|
100
|
+
const cfg = getEmailConfig(null);
|
|
101
|
+
assert.ok(cfg);
|
|
102
|
+
assert.strictEqual(cfg.host, 'env-smtp.example.com');
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('env vars override yaml values', () => {
|
|
107
|
+
withEnv({
|
|
108
|
+
SMTP_HOST: 'env-host.com',
|
|
109
|
+
SMTP_PORT: '465',
|
|
110
|
+
SMTP_USER: 'env-user',
|
|
111
|
+
SMTP_PASS: 'env-pass',
|
|
112
|
+
SMTP_FROM: 'Env <env@test.com>',
|
|
113
|
+
}, () => {
|
|
114
|
+
const cfg = getEmailConfig({
|
|
115
|
+
host: 'yaml-host.com',
|
|
116
|
+
port: 587,
|
|
117
|
+
username: 'yaml-user',
|
|
118
|
+
from: 'Yaml <yaml@test.com>',
|
|
119
|
+
});
|
|
120
|
+
assert.strictEqual(cfg.host, 'env-host.com');
|
|
121
|
+
assert.strictEqual(cfg.port, 465);
|
|
122
|
+
assert.strictEqual(cfg.user, 'env-user');
|
|
123
|
+
assert.strictEqual(cfg.pass, 'env-pass');
|
|
124
|
+
assert.strictEqual(cfg.from, 'Env <env@test.com>');
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('SMTP_PASS is only accepted from env var (not yaml)', () => {
|
|
129
|
+
withEnv({ SMTP_HOST: undefined, SMTP_PORT: undefined, SMTP_USER: undefined, SMTP_PASS: 'secret123', SMTP_FROM: undefined }, () => {
|
|
130
|
+
const cfg = getEmailConfig({ host: 'smtp.example.com' });
|
|
131
|
+
assert.strictEqual(cfg.pass, 'secret123');
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// ── interpolate ─────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
describe('interpolate', () => {
|
|
139
|
+
it('replaces single variable', () => {
|
|
140
|
+
assert.strictEqual(interpolate('Hello {{name}}!', { name: 'World' }), 'Hello World!');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('replaces multiple variables', () => {
|
|
144
|
+
const result = interpolate('{{appName}} - {{name}} ({{link}})', {
|
|
145
|
+
appName: 'My App',
|
|
146
|
+
name: 'Alice',
|
|
147
|
+
link: 'https://example.com/verify',
|
|
148
|
+
});
|
|
149
|
+
assert.strictEqual(result, 'My App - Alice (https://example.com/verify)');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('replaces missing variables with empty string', () => {
|
|
153
|
+
assert.strictEqual(interpolate('Hello {{name}} at {{link}}', { name: 'Bob' }), 'Hello Bob at ');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('handles empty template', () => {
|
|
157
|
+
assert.strictEqual(interpolate('', { name: 'test' }), '');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('handles null/undefined template', () => {
|
|
161
|
+
assert.strictEqual(interpolate(null, { name: 'test' }), '');
|
|
162
|
+
assert.strictEqual(interpolate(undefined, { name: 'test' }), '');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('handles template with no placeholders', () => {
|
|
166
|
+
assert.strictEqual(interpolate('No variables here', { name: 'test' }), 'No variables here');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('handles repeated variables', () => {
|
|
170
|
+
assert.strictEqual(interpolate('{{x}} and {{x}}', { x: 'val' }), 'val and val');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('only matches word characters in variable names', () => {
|
|
174
|
+
assert.strictEqual(interpolate('{{a-b}}', { 'a-b': 'nope' }), '{{a-b}}');
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// ── initEmail / getEmailStatus ──────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
describe('initEmail', () => {
|
|
181
|
+
afterEach(() => {
|
|
182
|
+
// Reset email state
|
|
183
|
+
withEnv({ SMTP_HOST: undefined, SMTP_PORT: undefined, SMTP_USER: undefined, SMTP_PASS: undefined, SMTP_FROM: undefined }, () => {
|
|
184
|
+
initEmail(null);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('returns null when not configured', () => {
|
|
189
|
+
withEnv({ SMTP_HOST: undefined }, () => {
|
|
190
|
+
const result = initEmail(null);
|
|
191
|
+
assert.strictEqual(result, null);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('returns config metadata when configured', () => {
|
|
196
|
+
withEnv({ SMTP_HOST: undefined, SMTP_PORT: undefined, SMTP_USER: undefined, SMTP_PASS: undefined, SMTP_FROM: undefined }, () => {
|
|
197
|
+
const result = initEmail({ host: 'smtp.example.com', port: 587, from: 'App <noreply@example.com>' });
|
|
198
|
+
assert.ok(result);
|
|
199
|
+
assert.strictEqual(result.host, 'smtp.example.com');
|
|
200
|
+
assert.strictEqual(result.port, 587);
|
|
201
|
+
assert.strictEqual(result.from, 'App <noreply@example.com>');
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('getEmailStatus returns configured: false when not initialized', () => {
|
|
206
|
+
withEnv({ SMTP_HOST: undefined }, () => {
|
|
207
|
+
initEmail(null);
|
|
208
|
+
const status = getEmailStatus();
|
|
209
|
+
assert.strictEqual(status.configured, false);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('getEmailStatus returns metadata when initialized', () => {
|
|
214
|
+
withEnv({ SMTP_HOST: undefined, SMTP_PORT: undefined, SMTP_USER: undefined, SMTP_PASS: undefined, SMTP_FROM: undefined }, () => {
|
|
215
|
+
initEmail({ host: 'smtp.example.com', port: 587, from: 'App <noreply@example.com>' });
|
|
216
|
+
const status = getEmailStatus();
|
|
217
|
+
assert.strictEqual(status.configured, true);
|
|
218
|
+
assert.strictEqual(status.host, 'smtp.example.com');
|
|
219
|
+
assert.strictEqual(status.port, 587);
|
|
220
|
+
assert.strictEqual(status.from, 'App <noreply@example.com>');
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// ── sendEmail (without real SMTP) ───────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
describe('sendEmail – not configured', () => {
|
|
228
|
+
before(() => {
|
|
229
|
+
withEnv({ SMTP_HOST: undefined }, () => { initEmail(null); });
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('throws when email is not configured', async () => {
|
|
233
|
+
await assert.rejects(
|
|
234
|
+
() => sendEmail({ to: 'test@test.com', subject: 'Test' }),
|
|
235
|
+
/Email is not configured/
|
|
236
|
+
);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe('verifyConnection – not configured', () => {
|
|
241
|
+
before(() => {
|
|
242
|
+
withEnv({ SMTP_HOST: undefined }, () => { initEmail(null); });
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('returns failure when not configured', async () => {
|
|
246
|
+
const result = await verifyConnection();
|
|
247
|
+
assert.strictEqual(result.success, false);
|
|
248
|
+
assert.ok(result.message.includes('not configured'));
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// ── Schema validation ────────────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
describe('schema: email', () => {
|
|
255
|
+
it('accepts config without email section', () => {
|
|
256
|
+
assert.strictEqual(validateSchema({ name: 'App' }), true);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('accepts email with host only', () => {
|
|
260
|
+
assert.strictEqual(validateSchema({
|
|
261
|
+
name: 'App',
|
|
262
|
+
email: { host: 'smtp.example.com' },
|
|
263
|
+
}), true);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('accepts email with all fields', () => {
|
|
267
|
+
assert.strictEqual(validateSchema({
|
|
268
|
+
name: 'App',
|
|
269
|
+
email: {
|
|
270
|
+
host: 'smtp.example.com',
|
|
271
|
+
port: 587,
|
|
272
|
+
username: 'user@example.com',
|
|
273
|
+
from: 'App <noreply@example.com>',
|
|
274
|
+
secure: false,
|
|
275
|
+
},
|
|
276
|
+
}), true);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('accepts email with templates', () => {
|
|
280
|
+
assert.strictEqual(validateSchema({
|
|
281
|
+
name: 'App',
|
|
282
|
+
email: {
|
|
283
|
+
host: 'smtp.example.com',
|
|
284
|
+
templates: {
|
|
285
|
+
verification: {
|
|
286
|
+
subject: 'Verify {{appName}}',
|
|
287
|
+
text: 'Click {{link}}',
|
|
288
|
+
html: '<a href="{{link}}">Verify</a>',
|
|
289
|
+
},
|
|
290
|
+
passwordReset: {
|
|
291
|
+
subject: 'Reset {{appName}}',
|
|
292
|
+
text: 'Click {{link}}',
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
}), true);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('accepts empty email object', () => {
|
|
300
|
+
assert.strictEqual(validateSchema({ name: 'App', email: {} }), true);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('rejects unknown email key', () => {
|
|
304
|
+
assert.throws(() => validateSchema({
|
|
305
|
+
name: 'App',
|
|
306
|
+
email: { host: 'smtp.example.com', password: 'secret' },
|
|
307
|
+
}));
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('rejects email port as string', () => {
|
|
311
|
+
assert.throws(() => validateSchema({
|
|
312
|
+
name: 'App',
|
|
313
|
+
email: { host: 'smtp.example.com', port: '587' },
|
|
314
|
+
}));
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('rejects unknown template type', () => {
|
|
318
|
+
assert.throws(() => validateSchema({
|
|
319
|
+
name: 'App',
|
|
320
|
+
email: {
|
|
321
|
+
host: 'smtp.example.com',
|
|
322
|
+
templates: {
|
|
323
|
+
welcome: { subject: 'Hello' },
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
}));
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// ── buildCore: email passthrough ─────────────────────────────────────────
|
|
331
|
+
|
|
332
|
+
describe('buildCore: email passthrough', () => {
|
|
333
|
+
it('exposes email config when provided', () => {
|
|
334
|
+
const core = buildCore({
|
|
335
|
+
name: 'App',
|
|
336
|
+
email: { host: 'smtp.example.com', port: 587, from: 'App <noreply@example.com>' },
|
|
337
|
+
});
|
|
338
|
+
assert.ok(core.email);
|
|
339
|
+
assert.strictEqual(core.email.host, 'smtp.example.com');
|
|
340
|
+
assert.strictEqual(core.email.port, 587);
|
|
341
|
+
assert.strictEqual(core.email.from, 'App <noreply@example.com>');
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('sets email to null when not provided', () => {
|
|
345
|
+
const core = buildCore({ name: 'App' });
|
|
346
|
+
assert.strictEqual(core.email, null);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('passes templates through', () => {
|
|
350
|
+
const core = buildCore({
|
|
351
|
+
name: 'App',
|
|
352
|
+
email: {
|
|
353
|
+
host: 'smtp.example.com',
|
|
354
|
+
templates: {
|
|
355
|
+
verification: { subject: 'Verify', text: 'Click {{link}}' },
|
|
356
|
+
},
|
|
357
|
+
},
|
|
358
|
+
});
|
|
359
|
+
assert.ok(core.email.templates);
|
|
360
|
+
assert.strictEqual(core.email.templates.verification.subject, 'Verify');
|
|
361
|
+
});
|
|
362
|
+
});
|