db.test.js 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  1. import assert from 'node:assert/strict';
  2. import { mkdtempSync } from 'node:fs';
  3. import { tmpdir } from 'node:os';
  4. import path from 'node:path';
  5. import { test } from 'node:test';
  6. import { DatabaseSync } from 'node:sqlite';
  7. import {
  8. authenticateUser,
  9. claimLegacyData,
  10. createApiToken,
  11. createDomain,
  12. createUser,
  13. getSendAnalytics,
  14. getSmtpCredential,
  15. initDatabase,
  16. listDomains,
  17. listSendEvents,
  18. logSendEvent,
  19. saveSmtpCredential,
  20. seedAdminUser,
  21. verifyApiToken,
  22. verifySmtpCredential
  23. } from '../src/db.js';
  24. test('migrates legacy data to the seeded admin user', () => {
  25. const dataDir = tempDataDir();
  26. const dbPath = path.join(dataDir, 'mailhub.sqlite');
  27. const legacy = new DatabaseSync(dbPath);
  28. legacy.exec(`
  29. CREATE TABLE domains (
  30. id INTEGER PRIMARY KEY AUTOINCREMENT,
  31. domain TEXT NOT NULL UNIQUE,
  32. selector TEXT NOT NULL,
  33. verification_token TEXT NOT NULL,
  34. dkim_public TEXT NOT NULL,
  35. dkim_private TEXT NOT NULL,
  36. sender_host TEXT NOT NULL,
  37. sending_ip TEXT NOT NULL,
  38. spf_extra TEXT NOT NULL DEFAULT '',
  39. dmarc_policy TEXT NOT NULL DEFAULT 'none',
  40. dmarc_rua TEXT NOT NULL DEFAULT '',
  41. status_json TEXT NOT NULL DEFAULT '{}',
  42. created_at TEXT NOT NULL,
  43. updated_at TEXT NOT NULL
  44. );
  45. CREATE TABLE send_events (
  46. id INTEGER PRIMARY KEY AUTOINCREMENT,
  47. domain_id INTEGER,
  48. sender TEXT NOT NULL,
  49. recipients TEXT NOT NULL,
  50. subject TEXT NOT NULL,
  51. status TEXT NOT NULL,
  52. detail TEXT NOT NULL DEFAULT '',
  53. created_at TEXT NOT NULL
  54. );
  55. CREATE TABLE smtp_credentials (
  56. id INTEGER PRIMARY KEY CHECK (id = 1),
  57. username TEXT NOT NULL,
  58. password_hash TEXT NOT NULL,
  59. password_secret TEXT NOT NULL DEFAULT '',
  60. created_at TEXT NOT NULL,
  61. updated_at TEXT NOT NULL
  62. );
  63. `);
  64. legacy
  65. .prepare(`
  66. INSERT INTO domains (
  67. domain, selector, verification_token, dkim_public, dkim_private,
  68. sender_host, sending_ip, spf_extra, dmarc_policy, dmarc_rua, created_at, updated_at
  69. ) VALUES ('legacy.example', 'mh', 'tok', 'pub', 'priv', 'mail.legacy.example', '127.0.0.1', '', 'none', '', 'now', 'now')
  70. `)
  71. .run();
  72. legacy
  73. .prepare(`
  74. INSERT INTO send_events (domain_id, sender, recipients, subject, status, detail, created_at)
  75. VALUES (1, 'noreply@legacy.example', '["user@example.com"]', 'hi', 'queued', '', 'now')
  76. `)
  77. .run();
  78. legacy
  79. .prepare(`
  80. INSERT INTO smtp_credentials (id, username, password_hash, password_secret, created_at, updated_at)
  81. VALUES (1, 'legacy-smtp', 'scrypt$salt$hash', '', 'now', 'now')
  82. `)
  83. .run();
  84. legacy.close();
  85. initDatabase(dataDir, 'test-secret');
  86. const admin = seedAdminUser({ username: 'admin', email: 'admin@example.com', password: 'password123' });
  87. claimLegacyData(admin.id);
  88. assert.equal(listDomains(admin.id).length, 1);
  89. assert.equal(listSendEvents(admin.id).length, 1);
  90. assert.equal(getSmtpCredential(admin.id).username, 'legacy-smtp');
  91. });
  92. test('isolates domains, smtp credentials, and api tokens by user', () => {
  93. initDatabase(tempDataDir(), 'test-secret');
  94. const admin = seedAdminUser({ username: 'admin', email: 'admin@example.com', password: 'password123' });
  95. claimLegacyData(admin.id);
  96. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  97. const bob = createUser({ username: 'bob', email: 'bob@example.com', password: 'password123' });
  98. assert.equal(authenticateUser('alice', 'password123').id, alice.id);
  99. assert.equal(authenticateUser('alice', 'wrong'), null);
  100. createDomain(alice.id, domainFixture('alice.example'));
  101. assert.equal(listDomains(alice.id).length, 1);
  102. assert.equal(listDomains(bob.id).length, 0);
  103. logSendEvent({
  104. userId: alice.id,
  105. domainId: listDomains(alice.id)[0].id,
  106. sender: 'noreply@alice.example',
  107. recipients: ['user@example.com'],
  108. subject: 'Hello',
  109. status: 'queued'
  110. });
  111. assert.equal(listSendEvents(alice.id).length, 1);
  112. assert.equal(listSendEvents(bob.id).length, 0);
  113. saveSmtpCredential(alice.id, { username: 'smtp-alice', password: 'copy-me-123' });
  114. assert.equal(getSmtpCredential(alice.id, { includePassword: true }).password, 'copy-me-123');
  115. assert.equal(verifySmtpCredential('smtp-alice', 'copy-me-123').user.id, alice.id);
  116. assert.equal(verifySmtpCredential('smtp-alice', 'wrong'), null);
  117. const token = createApiToken(alice.id, 'send');
  118. assert.equal(verifyApiToken(token.token).id, alice.id);
  119. });
  120. test('summarizes send analytics by user', () => {
  121. initDatabase(tempDataDir(), 'test-secret');
  122. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  123. const bob = createUser({ username: 'bob', email: 'bob@example.com', password: 'password123' });
  124. const aliceDomain = createDomain(alice.id, domainFixture('alice.example'));
  125. const bobDomain = createDomain(bob.id, domainFixture('bob.example'));
  126. logSendEvent({
  127. userId: alice.id,
  128. domainId: aliceDomain.id,
  129. sender: 'noreply@alice.example',
  130. recipients: ['a@example.com', 'b@example.com'],
  131. subject: 'Queued',
  132. status: 'queued'
  133. });
  134. logSendEvent({
  135. userId: alice.id,
  136. domainId: aliceDomain.id,
  137. sender: 'noreply@alice.example',
  138. recipients: ['c@example.com'],
  139. subject: 'Failed',
  140. status: 'failed',
  141. detail: 'relay rejected'
  142. });
  143. logSendEvent({
  144. userId: bob.id,
  145. domainId: bobDomain.id,
  146. sender: 'noreply@bob.example',
  147. recipients: ['x@example.com'],
  148. subject: 'Hidden',
  149. status: 'queued'
  150. });
  151. const analytics = getSendAnalytics(alice.id, { days: 7 });
  152. assert.equal(analytics.summary.total, 2);
  153. assert.equal(analytics.summary.queued, 1);
  154. assert.equal(analytics.summary.failed, 1);
  155. assert.equal(analytics.summary.recipients, 3);
  156. assert.equal(analytics.summary.successRate, 50);
  157. assert.equal(analytics.byDomain.length, 1);
  158. assert.equal(analytics.byDomain[0].domain, 'alice.example');
  159. assert.equal(analytics.recentFailures.length, 1);
  160. assert.equal(analytics.recentFailures[0].detail, 'relay rejected');
  161. });
  162. test('excludes queued and sent messages from recent delivery failures', () => {
  163. initDatabase(tempDataDir(), 'test-secret');
  164. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  165. const domain = createDomain(alice.id, domainFixture('alice.example'));
  166. for (const event of [
  167. { subject: 'Queued', status: 'queued', detail: 'accepted by postfix' },
  168. { subject: 'Sent', status: 'sent', detail: '250 OK' },
  169. { subject: 'Deferred', status: 'deferred', detail: 'temporary failure' },
  170. { subject: 'Bounced', status: 'bounced', detail: '550 user unknown' },
  171. { subject: 'Failed', status: 'failed', detail: 'relay rejected' }
  172. ]) {
  173. logSendEvent({
  174. userId: alice.id,
  175. domainId: domain.id,
  176. sender: 'noreply@alice.example',
  177. recipients: ['user@example.com'],
  178. subject: event.subject,
  179. status: event.status,
  180. detail: event.detail
  181. });
  182. }
  183. const analytics = getSendAnalytics(alice.id, { days: 7 });
  184. assert.deepEqual(
  185. analytics.recentFailures.map((event) => event.subject),
  186. ['Failed', 'Bounced', 'Deferred']
  187. );
  188. });
  189. test('stores and returns structured delivery logs for send events', () => {
  190. initDatabase(tempDataDir(), 'test-secret');
  191. const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
  192. const domain = createDomain(alice.id, domainFixture('alice.example'));
  193. const deliveryLog = [
  194. {
  195. at: '2026-07-08T00:00:00.000Z',
  196. phase: 'connect',
  197. direction: 'system',
  198. message: 'Connected to relay.test:25',
  199. ok: true
  200. },
  201. {
  202. at: '2026-07-08T00:00:01.000Z',
  203. phase: 'queue',
  204. direction: 'server',
  205. code: 250,
  206. response: '250 queued as ABC123',
  207. ok: true
  208. }
  209. ];
  210. logSendEvent({
  211. userId: alice.id,
  212. domainId: domain.id,
  213. sender: 'noreply@alice.example',
  214. recipients: ['user@example.com'],
  215. subject: 'Delivery log',
  216. status: 'queued',
  217. detail: '250 queued as ABC123',
  218. deliveryLog
  219. });
  220. const [event] = listSendEvents(alice.id);
  221. assert.deepEqual(event.deliveryLog, deliveryLog);
  222. });
  223. function domainFixture(domain) {
  224. return {
  225. domain,
  226. selector: 'mh202607',
  227. verificationToken: 'token',
  228. dkimPublic: 'public',
  229. dkimPrivate: 'private',
  230. senderHost: `mail.${domain}`,
  231. sendingIp: '127.0.0.1',
  232. spfExtra: '',
  233. dmarcPolicy: 'none',
  234. dmarcRua: ''
  235. };
  236. }
  237. function tempDataDir() {
  238. return mkdtempSync(path.join(tmpdir(), 'mailhub-test-'));
  239. }