import assert from 'node:assert/strict'; import { chmodSync, mkdirSync, mkdtempSync, readFileSync, statSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; import { test } from 'node:test'; test('Dovecot preparation creates a private secret and Maildir root', () => { const projectDir = mkdtempSync(path.join(tmpdir(), 'mailhub-dovecot-prepare-')); const result = spawnSync('bash', ['scripts/prepare-dovecot.sh'], { cwd: path.resolve(import.meta.dirname, '..'), env: { ...process.env, MAILHUB_PROJECT_DIR: projectDir }, encoding: 'utf8' }); const unsupportedLinuxUid = process.platform === 'linux' && typeof process.getuid === 'function' && ![0, 1000].includes(process.getuid()); if (unsupportedLinuxUid) { assert.notEqual(result.status, 0); assert.match(result.stderr, /must run as root or host uid 1000/); return; } assert.equal(result.status, 0, result.stderr || result.stdout); const secretPath = path.join(projectDir, 'data', 'secrets', 'dovecot_auth_secret'); const webmailSecretPath = path.join(projectDir, 'data', 'secrets', 'webmail_sso_secret'); const maildirRoot = path.join(projectDir, 'data', 'maildir'); const secretStat = statSync(secretPath); assert.equal(secretStat.isFile(), true); assert.equal(secretStat.mode & 0o777, 0o400); assert.equal(statSync(webmailSecretPath).isFile(), true); assert.equal(statSync(webmailSecretPath).mode & 0o777, 0o440); assert.equal(statSync(path.dirname(webmailSecretPath)).mode & 0o777, 0o750); assert.notEqual(readFileSync(secretPath, 'utf8'), readFileSync(webmailSecretPath, 'utf8')); assert.equal(statSync(maildirRoot).isDirectory(), true); assert.equal(statSync(maildirRoot).mode & 0o777, 0o700); }); test('Dovecot preparation grants only the configured Roundcube worker gid', () => { const projectDir = mkdtempSync(path.join(tmpdir(), 'mailhub-webmail-reader-')); const binDir = path.join(projectDir, 'bin'); const chownLog = path.join(projectDir, 'chown.log'); mkdirSync(binDir); writeExecutable(path.join(binDir, 'id'), '#!/usr/bin/env bash\necho 0\n'); writeExecutable(path.join(binDir, 'uname'), '#!/usr/bin/env bash\necho Darwin\n'); writeExecutable(path.join(binDir, 'chown'), `#!/usr/bin/env bash printf '%s\\n' "$*" >> "\${CHOWN_LOG}" `); writeFileSync(path.join(projectDir, '.env'), 'WEBMAIL_SSO_READER_GID=33\n'); const result = spawnSync('bash', ['scripts/prepare-dovecot.sh'], { cwd: path.resolve(import.meta.dirname, '..'), env: { ...process.env, PATH: `${binDir}:${process.env.PATH}`, CHOWN_LOG: chownLog, MAILHUB_PROJECT_DIR: projectDir }, encoding: 'utf8' }); assert.equal(result.status, 0, result.stderr || result.stdout); const chownCalls = readFileSync(chownLog, 'utf8').trim().split('\n'); const privateCall = chownCalls.find((line) => line.startsWith('1000:1000 ')); const webmailCall = chownCalls.find((line) => line.startsWith('1000:33 ')); assert.ok(privateCall?.includes('dovecot_auth_secret')); assert.ok(privateCall?.includes('/maildir')); assert.ok(!privateCall?.includes('webmail_sso_secret')); assert.ok(webmailCall?.includes('/secrets')); assert.ok(webmailCall?.includes('webmail_sso_secret')); }); test('Dovecot preparation rejects ambiguous or invalid Roundcube reader gids', () => { for (const envContents of [ 'WEBMAIL_SSO_READER_GID=33\nWEBMAIL_SSO_READER_GID=34\n', 'WEBMAIL_SSO_READER_GID=www-data\n', 'WEBMAIL_SSO_READER_GID=0\n' ]) { const projectDir = mkdtempSync(path.join(tmpdir(), 'mailhub-webmail-reader-invalid-')); writeFileSync(path.join(projectDir, '.env'), envContents); const result = spawnSync('bash', ['scripts/prepare-dovecot.sh'], { cwd: path.resolve(import.meta.dirname, '..'), env: { ...process.env, MAILHUB_PROJECT_DIR: projectDir }, encoding: 'utf8' }); assert.notEqual(result.status, 0); assert.match(result.stderr, /reader gid|must appear at most once/i); } }); function writeExecutable(filePath, contents) { writeFileSync(filePath, contents); chmodSync(filePath, 0o755); }