vesta-import-cli.test.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353
  1. import assert from 'node:assert/strict';
  2. import {
  3. existsSync,
  4. mkdirSync,
  5. mkdtempSync,
  6. readFileSync,
  7. utimesSync,
  8. writeFileSync
  9. } from 'node:fs';
  10. import os from 'node:os';
  11. import path from 'node:path';
  12. import { DatabaseSync } from 'node:sqlite';
  13. import { test } from 'node:test';
  14. import {
  15. createAtomicCheckpointWriter,
  16. parseVestaImportArguments,
  17. preflightVestaImport,
  18. runVestaMaildirImport
  19. } from '../scripts/import-vesta-maildir.js';
  20. import {
  21. getDomainByName,
  22. initDatabase,
  23. listInboundMailboxes,
  24. listInboundMessages,
  25. seedAdminUser
  26. } from '../src/db.js';
  27. const legacyHash = '{MD5}$1$hfT7jp2q$G3yf0NUx7mUkX.LIFWQxN.';
  28. test('CLI arguments support production import paths and dry-run', () => {
  29. const parsed = parseVestaImportArguments([
  30. '--snapshot', 'snapshot',
  31. '--data-dir=data',
  32. '--user', 'admin',
  33. '--source', 'VESTA:Primary',
  34. '--checkpoint', 'state/checkpoint.json',
  35. '--dry-run'
  36. ], { cwd: '/tmp/mailhub-cli', env: {} });
  37. assert.equal(parsed.snapshot, '/tmp/mailhub-cli/snapshot');
  38. assert.equal(parsed.dataDir, '/tmp/mailhub-cli/data');
  39. assert.equal(parsed.user, 'admin');
  40. assert.equal(parsed.source, 'vesta:primary');
  41. assert.equal(parsed.checkpoint, '/tmp/mailhub-cli/state/checkpoint.json');
  42. assert.equal(parsed.dryRun, true);
  43. });
  44. test('dry-run checks all 35 hashes without business writes, then real import resumes safely', async () => {
  45. const fixture = createSnapshotFixture(35);
  46. const dataDir = mkdtempSync(path.join(os.tmpdir(), 'mailhub-vesta-cli-db-'));
  47. initDatabase(dataDir, 'cli-test-secret');
  48. const user = seedAdminUser({
  49. username: 'admin',
  50. email: 'admin@example.test',
  51. password: 'admin-password'
  52. });
  53. const checkpoint = path.join(dataDir, 'import.checkpoint.json');
  54. const output = captureOutput();
  55. const before = businessCounts(dataDir);
  56. const common = {
  57. snapshot: fixture.root,
  58. dataDir,
  59. user: 'admin',
  60. source: 'vesta:test',
  61. checkpoint
  62. };
  63. const dryRun = await runVestaMaildirImport({ ...common, dryRun: true }, {
  64. env: { MAIL_HOSTNAME: 'smtp.target.test', SENDING_IP: '192.0.2.44' },
  65. output
  66. });
  67. assert.deepEqual(businessCounts(dataDir), before);
  68. assert.equal(dryRun.preflight.hashes, 35);
  69. assert.equal(dryRun.preflight.supportedHashes, 35);
  70. assert.equal(dryRun.preflight.unsupportedHashes, 0);
  71. assert.equal(dryRun.summary.plannedMessages, 1);
  72. assert.equal(dryRun.summary.importedMessages, 0);
  73. assert.equal(existsSync(checkpoint), false);
  74. assert.match(output.text(), /preflight .*hashes=35 .*supported_hashes=35/);
  75. assert.match(output.text(), /complete dry_run=1/);
  76. assertNoSensitiveOutput(output.text());
  77. output.clear();
  78. const imported = await runVestaMaildirImport(common, {
  79. env: {
  80. SESSION_SECRET: 'cli-test-secret',
  81. MAIL_HOSTNAME: 'smtp.target.test',
  82. SENDING_IP: '192.0.2.44',
  83. DEFAULT_SPF_MECHANISMS: 'ip4:192.0.2.44',
  84. DMARC_POLICY: 'quarantine',
  85. DMARC_RUA: 'mailto:dmarc@target.test'
  86. },
  87. output
  88. });
  89. assert.equal(imported.summary.importedMessages, 1);
  90. assert.equal(listInboundMailboxes(user.id).length, 35);
  91. assert.equal(listInboundMessages(user.id, { folder: null }).length, 1);
  92. const domain = getDomainByName('import.example', { userId: user.id });
  93. assert.equal(domain.catchAllAddress, 'acct00@import.example');
  94. assert.equal(domain.senderHost, 'smtp.target.test');
  95. assert.equal(domain.sendingIp, '192.0.2.44');
  96. assert.equal(domain.spfExtra, 'ip4:192.0.2.44');
  97. assert.equal(domain.dmarcPolicy, 'quarantine');
  98. assert.equal(domain.dmarcRua, 'mailto:dmarc@target.test');
  99. assertNoSensitiveOutput(output.text());
  100. const checkpointValue = JSON.parse(readFileSync(checkpoint, 'utf8'));
  101. assert.equal(checkpointValue.complete, true);
  102. assert.equal(checkpointValue.processed, 1);
  103. assert.equal(typeof checkpointValue.lastSourceKey, 'string');
  104. assert.equal('warnings' in checkpointValue, false);
  105. assert.equal(checkpointValue.warningCount, 34);
  106. assertNoSensitiveOutput(JSON.stringify(checkpointValue));
  107. output.clear();
  108. const resumed = await runVestaMaildirImport(common, {
  109. env: { SESSION_SECRET: 'cli-test-secret' },
  110. output
  111. });
  112. assert.equal(resumed.summary.importedMessages, 0);
  113. assert.equal(resumed.summary.skippedMessages, 1);
  114. assert.equal(resumed.summary.resumeSkippedMessages, 0);
  115. assert.equal(listInboundMessages(user.id, { folder: null }).length, 1);
  116. assertNoSensitiveOutput(output.text());
  117. writeFileSync(
  118. path.join(fixture.root, 'usr/local/vesta/data/users/admin/mail.conf'),
  119. "DOMAIN='import.example' CATCHALL='acct01' SUSPENDED='no'\n"
  120. );
  121. await assert.rejects(
  122. runVestaMaildirImport(common, {
  123. env: { SESSION_SECRET: 'cli-test-secret' },
  124. output
  125. }),
  126. (error) => error?.category === 'checkpoint'
  127. );
  128. assert.equal(getDomainByName('import.example', { userId: user.id }).catchAllAddress, 'acct00@import.example');
  129. });
  130. test('preflight counts unsupported hashes and cross-user conflicts without exposing resource values', () => {
  131. const result = preflightVestaImport({
  132. domains: [{ domain: 'conflict.example' }],
  133. mailboxes: [{ address: 'private@conflict.example', passwordHash: '{SHA512-CRYPT}redacted' }],
  134. warnings: ['sensitive warning']
  135. }, {
  136. user: { id: 7 },
  137. domains: [{ domain: 'conflict.example', userId: 8 }],
  138. mailboxes: [{ address: 'private@conflict.example', userId: 8 }]
  139. });
  140. assert.equal(result.conflicts, 2);
  141. assert.equal(result.hashes, 1);
  142. assert.equal(result.unsupportedHashes, 1);
  143. assert.equal(result.warningCount, 1);
  144. assert.equal('warnings' in result, false);
  145. });
  146. test('preflight blocks missing hashes and invalid defaults for new domains', () => {
  147. const result = preflightVestaImport({
  148. domains: [{ domain: 'new.example' }],
  149. mailboxes: [{ address: 'missing@new.example', passwordHash: '' }],
  150. warnings: []
  151. }, {
  152. user: { id: 7 },
  153. domains: [],
  154. mailboxes: []
  155. }, {
  156. defaults: { senderHost: 'invalid host', sendingIp: '' }
  157. });
  158. assert.equal(result.newDomains, 1);
  159. assert.equal(result.missingHashes, 1);
  160. assert.equal(result.invalidDefaults, 1);
  161. });
  162. test('preflight blocks a soft-deleted mailbox that the import cannot reuse', () => {
  163. const result = preflightVestaImport({
  164. domains: [{ domain: 'existing.example' }],
  165. mailboxes: [{ address: 'archived@existing.example', passwordHash: legacyHash }],
  166. warnings: []
  167. }, {
  168. user: { id: 7 },
  169. domains: [{ domain: 'existing.example', userId: 7 }],
  170. mailboxes: [{
  171. address: 'archived@existing.example',
  172. userId: 7,
  173. deletedAt: '2026-07-15T00:00:00.000Z'
  174. }]
  175. });
  176. assert.equal(result.conflicts, 1);
  177. });
  178. test('preflight only allows an existing same-owner mailbox during checkpoint resume', () => {
  179. const snapshot = {
  180. domains: [{ domain: 'existing.example' }],
  181. mailboxes: [{ address: 'owner@existing.example', passwordHash: legacyHash }],
  182. warnings: []
  183. };
  184. const inventory = {
  185. user: { id: 7 },
  186. domains: [{ domain: 'existing.example', userId: 7 }],
  187. mailboxes: [{ address: 'owner@existing.example', userId: 7, deletedAt: null }]
  188. };
  189. const firstRun = preflightVestaImport(snapshot, inventory);
  190. const resume = preflightVestaImport(snapshot, inventory, { allowExistingMailboxes: true });
  191. assert.equal(firstRun.existingMailboxes, 1);
  192. assert.equal(firstRun.conflicts, 1);
  193. assert.equal(resume.existingMailboxes, 1);
  194. assert.equal(resume.conflicts, 0);
  195. });
  196. test('real import writes an incomplete checkpoint before entering the importer', async () => {
  197. const directory = mkdtempSync(path.join(os.tmpdir(), 'mailhub-vesta-initial-checkpoint-'));
  198. const checkpoint = path.join(directory, 'checkpoint.json');
  199. const user = { id: 7, username: 'admin', email: 'admin@example.test', status: 'active' };
  200. const snapshot = {
  201. domains: [{ domain: 'new.example' }],
  202. mailboxes: [{ address: 'owner@new.example', passwordHash: legacyHash }],
  203. warnings: ['count only']
  204. };
  205. await assert.rejects(
  206. runVestaMaildirImport({
  207. snapshot: path.join(directory, 'snapshot'),
  208. dataDir: directory,
  209. user: 'admin',
  210. source: 'vesta:checkpoint',
  211. checkpoint
  212. }, {
  213. env: { MAIL_HOSTNAME: 'smtp.target.test', SENDING_IP: '192.0.2.44' },
  214. inspectTarget: () => ({ user, domains: [], mailboxes: [] }),
  215. readSnapshot: async () => snapshot,
  216. dbApi: {
  217. initDatabase() {},
  218. getUserByLogin: () => user
  219. },
  220. importSnapshot: async () => {
  221. const initial = JSON.parse(readFileSync(checkpoint, 'utf8'));
  222. assert.equal(initial.complete, false);
  223. assert.equal(initial.lastSourceKey, '');
  224. assert.equal(initial.processed, 0);
  225. assert.equal(initial.warningCount, 1);
  226. throw new Error('simulated mailbox-stage crash');
  227. },
  228. output: captureOutput()
  229. }),
  230. /simulated mailbox-stage crash/
  231. );
  232. assert.equal(JSON.parse(readFileSync(checkpoint, 'utf8')).complete, false);
  233. });
  234. test('checkpoint writer atomically flushes every 100 messages or one second', async () => {
  235. const directory = mkdtempSync(path.join(os.tmpdir(), 'mailhub-vesta-checkpoint-'));
  236. const filePath = path.join(directory, 'checkpoint.json');
  237. let timestamp = 0;
  238. const writer = createAtomicCheckpointWriter({
  239. filePath,
  240. identity: { version: 1, source: 'vesta:test', targetUserId: 1, snapshotId: 'snapshot-id' },
  241. clock: () => timestamp
  242. });
  243. for (let index = 1; index < 100; index += 1) {
  244. assert.equal(await writer.record(`source-${index}`, { processed: index, bytes: index }), false);
  245. }
  246. assert.equal(existsSync(filePath), false);
  247. assert.equal(await writer.record('source-100', { processed: 100, bytes: 100 }), true);
  248. assert.equal(JSON.parse(readFileSync(filePath, 'utf8')).processed, 100);
  249. timestamp = 1000;
  250. assert.equal(await writer.record('source-101', { processed: 101, bytes: 101 }), true);
  251. const checkpoint = JSON.parse(readFileSync(filePath, 'utf8'));
  252. assert.equal(checkpoint.processed, 101);
  253. assert.equal(checkpoint.lastSourceKey, 'source-101');
  254. assert.equal(checkpoint.complete, false);
  255. });
  256. function createSnapshotFixture(accountCount) {
  257. const root = mkdtempSync(path.join(os.tmpdir(), 'mailhub-vesta-cli-snapshot-'));
  258. const userData = path.join(root, 'usr/local/vesta/data/users/admin');
  259. const accountConfig = path.join(userData, 'mail');
  260. const mailRoot = path.join(root, 'home/admin/mail/import.example');
  261. const firstMailbox = path.join(mailRoot, 'acct00');
  262. for (const directory of [
  263. accountConfig,
  264. path.join(firstMailbox, 'cur'),
  265. path.join(firstMailbox, 'new'),
  266. path.join(firstMailbox, 'tmp')
  267. ]) mkdirSync(directory, { recursive: true });
  268. writeFileSync(
  269. path.join(userData, 'mail.conf'),
  270. "DOMAIN='import.example' CATCHALL='acct00' SUSPENDED='no'\n"
  271. );
  272. const accounts = [];
  273. for (let index = 0; index < accountCount; index += 1) {
  274. const account = `acct${String(index).padStart(2, '0')}`;
  275. accounts.push(
  276. `ACCOUNT='${account}' MD5='${legacyHash}' QUOTA='unlimited' SUSPENDED='no' DATE='2024-01-02' TIME='03:04:05'`
  277. );
  278. }
  279. writeFileSync(path.join(accountConfig, 'import.example.conf'), `${accounts.join('\n')}\n`);
  280. const messagePath = path.join(firstMailbox, 'cur', '1700000000.M1P1.host:2,S');
  281. writeFileSync(messagePath, [
  282. 'From: sender@example.net',
  283. 'To: acct00@import.example',
  284. 'Subject: Private migration subject',
  285. 'Message-ID: <private-import@example.net>',
  286. '',
  287. 'Private migration body'
  288. ].join('\r\n'));
  289. const receivedAt = new Date('2024-01-02T03:04:05.000Z');
  290. utimesSync(messagePath, receivedAt, receivedAt);
  291. return { root };
  292. }
  293. function businessCounts(dataDir) {
  294. const database = new DatabaseSync(path.join(dataDir, 'mailhub.sqlite'), { readOnly: true });
  295. try {
  296. return {
  297. domains: Number(database.prepare('SELECT COUNT(*) AS count FROM domains').get().count),
  298. mailboxes: Number(database.prepare('SELECT COUNT(*) AS count FROM inbound_mailboxes').get().count),
  299. messages: Number(database.prepare('SELECT COUNT(*) AS count FROM inbound_messages').get().count)
  300. };
  301. } finally {
  302. database.close();
  303. }
  304. }
  305. function captureOutput() {
  306. let value = '';
  307. return {
  308. write(chunk) {
  309. value += String(chunk);
  310. return true;
  311. },
  312. text() {
  313. return value;
  314. },
  315. clear() {
  316. value = '';
  317. }
  318. };
  319. }
  320. function assertNoSensitiveOutput(value) {
  321. assert.doesNotMatch(value, /acct00@import\.example/i);
  322. assert.doesNotMatch(value, /\$1\$hfT7jp2q/i);
  323. assert.doesNotMatch(value, /Private migration (?:subject|body)/i);
  324. }