import assert from 'node:assert/strict'; import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; import { test } from 'node:test'; import { fileURLToPath } from 'node:url'; const scriptPath = fileURLToPath(new URL('../scripts/deploy-remote.sh', import.meta.url)); const scriptSource = readFileSync(scriptPath, 'utf8'); const canRun = process.platform !== 'win32'; test('checks Maildir access with Dovecot mail worker uid instead of container root', () => { assert.match(scriptSource, /compose exec -T --user 1000:1000 dovecot <\/dev\/null sh/); assert.match(scriptSource, /fs\.statSync\(probe\)\.isFile\(\)/); assert.doesNotMatch(scriptSource, /trap .*rm -f -- \"\$probe\"/); }); test('keeps the Maildir migration as an explicit deployment option', () => { assert.match(scriptSource, /run_maildir_migration="\$\{MAILHUB_DEPLOY_RUN_MAILDIR_MIGRATION:-0\}"/); assert.match(scriptSource, /if \[\[ "\$\{run_maildir_migration\}" == "1" \]\]; then/); assert.match( scriptSource, /docker compose run --rm --no-deps -T app <\/dev\/null node scripts\/migrate-sqlite-maildir\.js/ ); assert.match(scriptSource, /Skipping Maildir migration check; set MAILHUB_DEPLOY_RUN_MAILDIR_MIGRATION=1 to run it\./); }); test('isolates runtime probes and setup scripts from the SSH heredoc stdin', () => { assert.match(scriptSource, /docker compose exec -T app <\/dev\/null node -e/); assert.match(scriptSource, /\.\/scripts\/prepare-dovecot\.sh <\/dev\/null/); assert.equal( scriptSource.match(/\.\/scripts\/sync-tls-certificate\.sh <\/dev\/null/g)?.length, 3 ); }); test('checks the Dovecot IMAPS authentication path through Lua passdb', () => { assert.match(scriptSource, /tls\.connect\(\{[\s\S]*host: "dovecot"[\s\S]*port: 31993/); assert.match(scriptSource, /setTimeout\(\(\) => finish\(false\), 15000\)/); assert.match(scriptSource, /mailhub-healthcheck@invalid\.invalid/); assert.match(scriptSource, /temporary authentication failure\|unavailable/); assert.match(scriptSource, /Dovecot IMAPS authentication path check failed\./); assert.match(scriptSource, /wait_for_compose_health app postfix\s+docker compose restart dovecot <\/dev\/null/); }); test('waits for app and postfix before restarting Dovecot and certificate synchronization', { skip: !canRun }, (t) => { const fixture = createFixture(t); const result = runDeploy(fixture); assert.equal(result.status, 0, result.stderr); const events = readEvents(fixture.logFile); assert.deepEqual( events.filter((event) => event.startsWith('health:') || event.startsWith('sync:')), [ 'sync:0', 'health:app-container', 'health:postfix-container', 'health:app-container', 'health:postfix-container', 'health:dovecot-container', 'sync:1', 'health:app-container', 'health:postfix-container', 'health:dovecot-container' ] ); assert.equal(events.filter((event) => event === 'runtime:app').length, 6); assert.equal(events.filter((event) => event === 'runtime:dovecot').length, 2); }); test('continues after remote tools actively consume their stdin', { skip: !canRun }, (t) => { const fixture = createFixture(t); const result = runDeploy(fixture); assert.equal(result.status, 0, result.stderr); const events = readEvents(fixture.logFile); assert.equal(events.includes('migrate'), false, events.join('\n')); assert.ok(events.includes('sync:1'), events.join('\n')); assert.equal(events.at(-1), 'final:ps', events.join('\n')); }); test('skips the Maildir migration maintenance window by default', { skip: !canRun }, (t) => { const fixture = createFixture(t); const result = runDeploy(fixture); assert.equal(result.status, 0, result.stderr); const events = readEvents(fixture.logFile); const offlineSync = events.indexOf('sync:0'); const pull = events.indexOf('pull:dovecot'); const up = events.indexOf('up'); const dovecotRestart = events.indexOf('compose-restart:dovecot'); assert.equal(events.includes('stop:app,dovecot'), false, events.join('\n')); assert.equal(events.includes('migrate'), false, events.join('\n')); assert.ok(offlineSync >= 0 && offlineSync < up, events.join('\n')); assert.ok(pull >= 0 && pull < up, events.join('\n')); assert.ok(up >= 0 && up < dovecotRestart, events.join('\n')); }); test('runs the opt-in Maildir migration without consuming the SSH heredoc stdin', { skip: !canRun }, (t) => { const fixture = createFixture(t); const result = runDeploy(fixture, { runMaildirMigration: '1' }); assert.equal(result.status, 0, result.stderr); const events = readEvents(fixture.logFile); const offlineSync = events.indexOf('sync:0'); const pull = events.indexOf('pull:dovecot'); const stop = events.indexOf('stop:app,dovecot'); const migrate = events.indexOf('migrate'); const up = events.indexOf('up'); assert.ok(offlineSync >= 0 && offlineSync < stop, events.join('\n')); assert.ok(pull >= 0 && pull < stop, events.join('\n')); assert.ok(stop >= 0 && stop < migrate, events.join('\n')); assert.ok(migrate >= 0 && migrate < up, events.join('\n')); }); test('reports the previous revision when deployment fails', { skip: !canRun }, (t) => { const fixture = createFixture(t); const result = runDeploy(fixture, { syncStatus: '19' }); assert.equal(result.status, 19); assert.match( result.stderr, /Deployment failed\. Previous revision was previous-revision; inspect the running containers before recovery\./ ); assert.doesNotMatch(result.stderr, /Maildir cutover is already committed/); const events = readEvents(fixture.logFile); assert.equal(events.filter((event) => event === 'sync:0').length, 1); assert.equal(events.filter((event) => event === 'sync:1').length, 1); assert.equal(events.filter((event) => event === 'health:app-container').length, 2); assert.equal(events.filter((event) => event === 'health:postfix-container').length, 2); assert.equal(events.filter((event) => event === 'health:dovecot-container').length, 1); assert.equal(events.includes('stop:app,dovecot'), false, events.join('\n')); assert.equal(events.at(-1), 'final:ps', events.join('\n')); }); test('does not apply Maildir cutover protection to default runtime probe failures', { skip: !canRun }, (t) => { const fixture = createFixture(t); const result = runDeploy(fixture, { runtimeStatus: '29' }); assert.equal(result.status, 1); assert.match( result.stderr, /Deployment failed\. Previous revision was previous-revision; inspect the running containers before recovery\./ ); assert.doesNotMatch(result.stderr, /Maildir cutover is already committed/); const events = readEvents(fixture.logFile); assert.equal(events.includes('migrate'), false, events.join('\n')); assert.equal(events.includes('stop:app,dovecot'), false, events.join('\n')); assert.ok(events.includes('compose-restart:dovecot'), events.join('\n')); assert.equal(events.at(-1), 'final:ps', events.join('\n')); }); test('stops the app for migration and restarts the previous container if migration fails', { skip: !canRun }, (t) => { const fixture = createFixture(t); const result = runDeploy(fixture, { migrationStatus: '23', runMaildirMigration: '1' }); assert.equal(result.status, 23); assert.match(result.stderr, /Restarting the pre-migration MailHub mail services\./); assert.deepEqual(readEvents(fixture.logFile), [ 'sync:0', 'pull:dovecot', 'stop:app,dovecot', 'migrate', 'restart:app-container', 'restart:dovecot-container', 'final:ps' ]); }); test('keeps the maintenance window explicit after cutover health checks fail', { skip: !canRun }, (t) => { const fixture = createFixture(t); const result = runDeploy(fixture, { runtimeStatus: '29', runMaildirMigration: '1' }); assert.notEqual(result.status, 0); assert.match(result.stderr, /Maildir cutover is already committed; legacy mail services will not be restarted/); assert.match(result.stderr, /maintenance window remains active/); assert.equal(readEvents(fixture.logFile).some((event) => event.startsWith('restart:')), false); assert.equal(readEvents(fixture.logFile).filter((event) => event === 'stop:app,dovecot').length, 2); }); function createFixture(t) { const root = mkdtempSync(path.join(os.tmpdir(), 'mailhub-deploy-script-')); const fakeBin = path.join(root, 'bin'); const remoteDir = path.join(root, 'remote'); const remoteScriptsDir = path.join(remoteDir, 'scripts'); const logFile = path.join(root, 'events.log'); mkdirSync(fakeBin, { recursive: true }); mkdirSync(remoteScriptsDir, { recursive: true }); writeFileSync(logFile, ''); writeExecutable(path.join(fakeBin, 'git'), `#!/bin/sh if [ "$1" = "remote" ] && [ "$2" = "get-url" ]; then printf '%s\\n' 'ssh://git.example.test/mailhub.git' elif [ "$1" = "status" ]; then : elif [ "$1" = "rev-parse" ] && [ "$2" = "HEAD" ]; then if [ "$PWD" = "$MAILHUB_DEPLOY_TEST_REMOTE_DIR" ]; then printf '%s\\n' 'previous-revision' else printf '%s\\n' 'pushed-revision' fi elif [ "$1" = "rev-parse" ]; then printf '%s\\n' 'pushed-revision' fi `); writeExecutable(path.join(fakeBin, 'ssh'), `#!/bin/sh while [ "$#" -gt 0 ]; do case "$1" in -o) shift 2 ;; *) break ;; esac done [ "$#" -gt 0 ] && shift [ "$#" -gt 0 ] && shift [ "\${1:-}" = "--" ] && shift exec bash -s -- "$@" `); writeExecutable(path.join(fakeBin, 'docker'), `#!/bin/sh if [ "$1" = "compose" ] && [ "$2" = "ps" ] && [ "\${3:-}" = "--all" ]; then printf '%s-container\\n' "$5" exit 0 fi if [ "$1" = "compose" ] && [ "$2" = "stop" ]; then printf 'stop:%s,%s\\n' "$3" "$4" >> "$MAILHUB_DEPLOY_TEST_LOG" exit 0 fi if [ "$1" = "compose" ] && [ "$2" = "run" ]; then cat >/dev/null printf '%s\\n' 'migrate' >> "$MAILHUB_DEPLOY_TEST_LOG" exit "\${MAILHUB_DEPLOY_TEST_MIGRATION_STATUS:-0}" fi if [ "$1" = "compose" ] && [ "$2" = "pull" ]; then printf 'pull:%s\\n' "$3" >> "$MAILHUB_DEPLOY_TEST_LOG" exit 0 fi if [ "$1" = "compose" ] && [ "$2" = "up" ]; then printf '%s\\n' 'up' >> "$MAILHUB_DEPLOY_TEST_LOG" exit 0 fi if [ "$1" = "compose" ] && [ "$2" = "restart" ]; then printf 'compose-restart:%s\\n' "$3" >> "$MAILHUB_DEPLOY_TEST_LOG" exit 0 fi if [ "$1" = "compose" ] && [ "$2" = "exec" ]; then cat >/dev/null service="" for argument in "$@"; do if [ "$argument" = "app" ] || [ "$argument" = "dovecot" ]; then service="$argument" break fi done printf 'runtime:%s\\n' "$service" >> "$MAILHUB_DEPLOY_TEST_LOG" if [ "$service" = "app" ] && [ "\${MAILHUB_DEPLOY_TEST_RUNTIME_STATUS:-0}" != "0" ]; then exit "$MAILHUB_DEPLOY_TEST_RUNTIME_STATUS" fi exit 0 fi if [ "$1" = "compose" ] && [ "$2" = "ps" ] && [ -z "\${3:-}" ]; then printf '%s\\n' 'final:ps' >> "$MAILHUB_DEPLOY_TEST_LOG" exit 0 fi if [ "$1" = "start" ]; then printf 'restart:%s\\n' "$2" >> "$MAILHUB_DEPLOY_TEST_LOG" exit 0 fi if [ "$1" = "inspect" ]; then container='' for argument in "$@"; do container="$argument" done printf 'health:%s\\n' "$container" >> "$MAILHUB_DEPLOY_TEST_LOG" printf '%s\\n' 'running healthy' fi `); writeExecutable(path.join(remoteScriptsDir, 'sync-tls-certificate.sh'), `#!/bin/sh cat >/dev/null printf 'sync:%s\\n' "\${MAILHUB_CERT_RESTART:-}" >> "$MAILHUB_DEPLOY_TEST_LOG" if [ "\${MAILHUB_CERT_RESTART:-0}" = "1" ]; then exit "\${MAILHUB_DEPLOY_TEST_SYNC_STATUS:-0}" fi exit 0 `); writeExecutable(path.join(remoteScriptsDir, 'prepare-dovecot.sh'), `#!/bin/sh cat >/dev/null exit 0 `); t.after(() => rmSync(root, { recursive: true, force: true })); return { root, fakeBin, remoteDir, logFile }; } function runDeploy( fixture, { syncStatus = '0', migrationStatus = '0', runtimeStatus = '0', runMaildirMigration = '0' } = {} ) { return spawnSync('bash', [scriptPath], { cwd: fixture.root, encoding: 'utf8', env: { ...process.env, PATH: `${fixture.fakeBin}${path.delimiter}${process.env.PATH ?? ''}`, MAILHUB_DEPLOY_REMOTE: 'deploy@example.test', MAILHUB_DEPLOY_DIR: fixture.remoteDir, MAILHUB_DEPLOY_BRANCH: 'main', MAILHUB_DEPLOY_GIT_URL: 'ssh://git.example.test/mailhub.git', MAILHUB_DEPLOY_TEST_LOG: fixture.logFile, MAILHUB_DEPLOY_TEST_REMOTE_DIR: fixture.remoteDir, MAILHUB_DEPLOY_TEST_SYNC_STATUS: syncStatus, MAILHUB_DEPLOY_TEST_MIGRATION_STATUS: migrationStatus, MAILHUB_DEPLOY_TEST_RUNTIME_STATUS: runtimeStatus, MAILHUB_DEPLOY_RUN_MAILDIR_MIGRATION: runMaildirMigration } }); } function writeExecutable(filePath, contents) { writeFileSync(filePath, contents); chmodSync(filePath, 0o755); } function readEvents(logFile) { return readFileSync(logFile, 'utf8').trim().split('\n').filter(Boolean); }