Jelajahi Sumber

test: add auth smtp dns coverage

Codex 1 bulan lalu
induk
melakukan
05f5b67606
4 mengubah file dengan 284 tambahan dan 3 penghapusan
  1. 2 1
      package.json
  2. 4 2
      src/db.js
  3. 146 0
      test/db.test.js
  4. 132 0
      test/dns-providers.test.js

+ 2 - 1
package.json

@@ -6,7 +6,8 @@
   "description": "Dockerized outbound mail control panel with DNS guidance and DKIM signing.",
   "scripts": {
     "start": "node src/server.js",
-    "dev": "NODE_ENV=development node src/server.js"
+    "dev": "NODE_ENV=development node src/server.js",
+    "test": "node --test"
   },
   "engines": {
     "node": ">=24.0.0"

+ 4 - 2
src/db.js

@@ -97,8 +97,6 @@ export function initDatabase(dataDir, secret = '') {
       updated_at TEXT NOT NULL
     );
 
-    CREATE INDEX IF NOT EXISTS idx_domains_user_id ON domains(user_id);
-    CREATE INDEX IF NOT EXISTS idx_events_user_id ON send_events(user_id);
     CREATE INDEX IF NOT EXISTS idx_tokens_user_id ON api_tokens(user_id);
     CREATE INDEX IF NOT EXISTS idx_dns_credentials_user_id ON dns_credentials(user_id);
   `);
@@ -106,6 +104,10 @@ export function initDatabase(dataDir, secret = '') {
   ensureColumn('domains', 'dns_credential_id', 'INTEGER');
   ensureColumn('send_events', 'user_id', 'INTEGER');
   ensureColumn('smtp_credentials', 'password_secret', "TEXT NOT NULL DEFAULT ''");
+  db.exec(`
+    CREATE INDEX IF NOT EXISTS idx_domains_user_id ON domains(user_id);
+    CREATE INDEX IF NOT EXISTS idx_events_user_id ON send_events(user_id);
+  `);
   return db;
 }
 

+ 146 - 0
test/db.test.js

@@ -0,0 +1,146 @@
+import assert from 'node:assert/strict';
+import { mkdtempSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { test } from 'node:test';
+import { DatabaseSync } from 'node:sqlite';
+import {
+  authenticateUser,
+  claimLegacyData,
+  createApiToken,
+  createDomain,
+  createUser,
+  getSmtpCredential,
+  initDatabase,
+  listDomains,
+  listSendEvents,
+  logSendEvent,
+  saveSmtpCredential,
+  seedAdminUser,
+  verifyApiToken,
+  verifySmtpCredential
+} from '../src/db.js';
+
+test('migrates legacy data to the seeded admin user', () => {
+  const dataDir = tempDataDir();
+  const dbPath = path.join(dataDir, 'mailhub.sqlite');
+  const legacy = new DatabaseSync(dbPath);
+  legacy.exec(`
+    CREATE TABLE domains (
+      id INTEGER PRIMARY KEY AUTOINCREMENT,
+      domain TEXT NOT NULL UNIQUE,
+      selector TEXT NOT NULL,
+      verification_token TEXT NOT NULL,
+      dkim_public TEXT NOT NULL,
+      dkim_private TEXT NOT NULL,
+      sender_host TEXT NOT NULL,
+      sending_ip TEXT NOT NULL,
+      spf_extra TEXT NOT NULL DEFAULT '',
+      dmarc_policy TEXT NOT NULL DEFAULT 'none',
+      dmarc_rua TEXT NOT NULL DEFAULT '',
+      status_json TEXT NOT NULL DEFAULT '{}',
+      created_at TEXT NOT NULL,
+      updated_at TEXT NOT NULL
+    );
+    CREATE TABLE send_events (
+      id INTEGER PRIMARY KEY AUTOINCREMENT,
+      domain_id INTEGER,
+      sender TEXT NOT NULL,
+      recipients TEXT NOT NULL,
+      subject TEXT NOT NULL,
+      status TEXT NOT NULL,
+      detail TEXT NOT NULL DEFAULT '',
+      created_at TEXT NOT NULL
+    );
+    CREATE TABLE smtp_credentials (
+      id INTEGER PRIMARY KEY CHECK (id = 1),
+      username TEXT NOT NULL,
+      password_hash TEXT NOT NULL,
+      password_secret TEXT NOT NULL DEFAULT '',
+      created_at TEXT NOT NULL,
+      updated_at TEXT NOT NULL
+    );
+  `);
+  legacy
+    .prepare(`
+      INSERT INTO domains (
+        domain, selector, verification_token, dkim_public, dkim_private,
+        sender_host, sending_ip, spf_extra, dmarc_policy, dmarc_rua, created_at, updated_at
+      ) VALUES ('legacy.example', 'mh', 'tok', 'pub', 'priv', 'mail.legacy.example', '127.0.0.1', '', 'none', '', 'now', 'now')
+    `)
+    .run();
+  legacy
+    .prepare(`
+      INSERT INTO send_events (domain_id, sender, recipients, subject, status, detail, created_at)
+      VALUES (1, 'noreply@legacy.example', '["user@example.com"]', 'hi', 'queued', '', 'now')
+    `)
+    .run();
+  legacy
+    .prepare(`
+      INSERT INTO smtp_credentials (id, username, password_hash, password_secret, created_at, updated_at)
+      VALUES (1, 'legacy-smtp', 'scrypt$salt$hash', '', 'now', 'now')
+    `)
+    .run();
+  legacy.close();
+
+  initDatabase(dataDir, 'test-secret');
+  const admin = seedAdminUser({ username: 'admin', email: 'admin@example.com', password: 'password123' });
+  claimLegacyData(admin.id);
+
+  assert.equal(listDomains(admin.id).length, 1);
+  assert.equal(listSendEvents(admin.id).length, 1);
+  assert.equal(getSmtpCredential(admin.id).username, 'legacy-smtp');
+});
+
+test('isolates domains, smtp credentials, and api tokens by user', () => {
+  initDatabase(tempDataDir(), 'test-secret');
+  const admin = seedAdminUser({ username: 'admin', email: 'admin@example.com', password: 'password123' });
+  claimLegacyData(admin.id);
+  const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
+  const bob = createUser({ username: 'bob', email: 'bob@example.com', password: 'password123' });
+
+  assert.equal(authenticateUser('alice', 'password123').id, alice.id);
+  assert.equal(authenticateUser('alice', 'wrong'), null);
+
+  createDomain(alice.id, domainFixture('alice.example'));
+  assert.equal(listDomains(alice.id).length, 1);
+  assert.equal(listDomains(bob.id).length, 0);
+
+  logSendEvent({
+    userId: alice.id,
+    domainId: listDomains(alice.id)[0].id,
+    sender: 'noreply@alice.example',
+    recipients: ['user@example.com'],
+    subject: 'Hello',
+    status: 'queued'
+  });
+  assert.equal(listSendEvents(alice.id).length, 1);
+  assert.equal(listSendEvents(bob.id).length, 0);
+
+  saveSmtpCredential(alice.id, { username: 'smtp-alice', password: 'copy-me-123' });
+  assert.equal(getSmtpCredential(alice.id, { includePassword: true }).password, 'copy-me-123');
+  assert.equal(verifySmtpCredential('smtp-alice', 'copy-me-123').user.id, alice.id);
+  assert.equal(verifySmtpCredential('smtp-alice', 'wrong'), null);
+
+  const token = createApiToken(alice.id, 'send');
+  assert.equal(verifyApiToken(token.token).id, alice.id);
+});
+
+function domainFixture(domain) {
+  return {
+    domain,
+    selector: 'mh202607',
+    verificationToken: 'token',
+    dkimPublic: 'public',
+    dkimPrivate: 'private',
+    senderHost: `mail.${domain}`,
+    sendingIp: '127.0.0.1',
+    spfExtra: '',
+    dmarcPolicy: 'none',
+    dmarcRua: ''
+  };
+}
+
+function tempDataDir() {
+  return mkdtempSync(path.join(tmpdir(), 'mailhub-test-'));
+}

+ 132 - 0
test/dns-providers.test.js

@@ -0,0 +1,132 @@
+import assert from 'node:assert/strict';
+import { afterEach, test } from 'node:test';
+import { applyDnsSetup, testDnsCredential } from '../src/dns-providers.js';
+
+const originalFetch = globalThis.fetch;
+
+afterEach(() => {
+  globalThis.fetch = originalFetch;
+});
+
+test('cloudflare provider tests credentials and replaces duplicate SPF records', async () => {
+  const calls = [];
+  globalThis.fetch = async (url, options = {}) => {
+    calls.push({ url: String(url), method: options.method || 'GET', body: options.body });
+    if (String(url).includes('/zones?')) return json({ success: true, result: [{ id: 'zone-1', name: 'example.com' }] });
+    if (String(url).includes('/zones/zone-1') && !String(url).includes('/dns_records')) {
+      return json({ success: true, result: { id: 'zone-1', name: 'example.com' } });
+    }
+    if (String(url).includes('/dns_records?')) {
+      return json({
+        success: true,
+        result: [
+          { id: 'spf-1', type: 'TXT', name: 'example.com', content: 'v=spf1 include:old ~all' },
+          { id: 'spf-2', type: 'TXT', name: 'example.com', content: 'v=spf1 include:duplicate ~all' }
+        ]
+      });
+    }
+    return json({ success: true, result: { id: 'ok' } });
+  };
+
+  const credential = cloudflareCredential();
+  assert.equal((await testDnsCredential(credential)).ok, true);
+  const result = await applyDnsSetup(domainFixture(), credential, {
+    records: [{ key: 'spf', host: 'example.com', type: 'TXT', value: 'v=spf1 ip4:127.0.0.1 ~all' }]
+  });
+
+  assert.equal(result.ok, true);
+  assert.ok(calls.some((call) => call.method === 'PUT' && call.url.includes('/dns_records/spf-1')));
+  assert.ok(calls.some((call) => call.method === 'DELETE' && call.url.includes('/dns_records/spf-2')));
+});
+
+test('aliyun provider signs and sends create/update record actions', async () => {
+  const actions = [];
+  globalThis.fetch = async (url) => {
+    const params = new URL(String(url)).searchParams;
+    const action = params.get('Action');
+    actions.push(action);
+    if (action === 'DescribeDomainRecords') {
+      return json({
+        DomainRecords: {
+          Record: [{ RecordId: '1', RR: '_dmarc', Type: 'TXT', Value: 'v=DMARC1; p=none' }]
+        }
+      });
+    }
+    return json({});
+  };
+
+  const credential = aliyunCredential();
+  assert.equal((await testDnsCredential(credential)).ok, true);
+  const result = await applyDnsSetup(domainFixture(), credential, {
+    records: [{ key: 'dmarc', host: '_dmarc.example.com', type: 'TXT', value: 'v=DMARC1; p=reject' }]
+  });
+
+  assert.equal(result.ok, true);
+  assert.ok(actions.includes('UpdateDomainRecord'));
+});
+
+test('dnspod provider signs and sends create record actions', async () => {
+  const actions = [];
+  globalThis.fetch = async (url, options = {}) => {
+    assert.equal(String(url), 'https://dnspod.tencentcloudapi.com');
+    actions.push(options.headers['X-TC-Action']);
+    if (options.headers['X-TC-Action'] === 'DescribeRecordList') {
+      return json({ Response: { RecordList: [] } });
+    }
+    return json({ Response: { RecordId: 123 } });
+  };
+
+  const credential = dnspodCredential();
+  assert.equal((await testDnsCredential(credential)).ok, true);
+  const result = await applyDnsSetup(domainFixture(), credential, {
+    records: [{ key: 'dkim', host: 'mh._domainkey.example.com', type: 'TXT', value: 'v=DKIM1; k=rsa; p=abc' }]
+  });
+
+  assert.equal(result.ok, true);
+  assert.ok(actions.includes('CreateRecord'));
+});
+
+function cloudflareCredential() {
+  return {
+    provider: 'cloudflare',
+    zoneName: 'example.com',
+    defaultTtl: 600,
+    credentials: { apiToken: 'token' }
+  };
+}
+
+function aliyunCredential() {
+  return {
+    provider: 'aliyun',
+    zoneName: 'example.com',
+    defaultTtl: 600,
+    credentials: { accessKeyId: 'id', accessKeySecret: 'secret' }
+  };
+}
+
+function dnspodCredential() {
+  return {
+    provider: 'dnspod',
+    zoneName: 'example.com',
+    defaultTtl: 600,
+    credentials: { secretId: 'id', secretKey: 'secret' }
+  };
+}
+
+function domainFixture() {
+  return {
+    domain: 'example.com',
+    senderHost: 'mail.example.com',
+    sendingIp: '127.0.0.1'
+  };
+}
+
+function json(payload) {
+  return {
+    ok: true,
+    status: 200,
+    async json() {
+      return payload;
+    }
+  };
+}