server-landing.test.js 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137
  1. import assert from 'node:assert/strict';
  2. import { spawn } from 'node:child_process';
  3. import { existsSync, mkdtempSync, readdirSync, writeFileSync } from 'node:fs';
  4. import { tmpdir } from 'node:os';
  5. import path from 'node:path';
  6. import { test } from 'node:test';
  7. import net from 'node:net';
  8. test('anonymous root serves landing page with no-store cache header', async () => {
  9. ensureLandingArtifact();
  10. const port = await freePort();
  11. const child = spawnServer(port);
  12. try {
  13. await waitForOutput(child, 'MailHub listening');
  14. const response = await fetch(`http://127.0.0.1:${port}/`);
  15. assert.equal(response.status, 200);
  16. assert.match(response.headers.get('cache-control') || '', /no-store/i);
  17. const html = await response.text();
  18. assert.match(html, /MailHub/i);
  19. assert.match(html, /data-i18n|hero|Get started|开始使用|landing/i);
  20. assert.match(html, /api\/mailboxes|mailboxes|邮箱/i);
  21. assert.match(html, /mailboxes:write|长期邮箱|permanent/i);
  22. assert.match(html, /expiresInMinutes|临时邮箱|temporary/i);
  23. assert.match(html, /Receiving mail|收信|IMAP|POP3/i);
  24. assert.match(html, /POST \/api\/send|发送 API|Send API/i);
  25. assert.match(html, /domainsDoc|域名配置|Domain configuration/i);
  26. assert.doesNotMatch(html, /id="root"/);
  27. } finally {
  28. child.kill('SIGTERM');
  29. await waitForExit(child, 1000);
  30. }
  31. });
  32. test('authenticated root serves admin app shell', async () => {
  33. ensureLandingArtifact();
  34. const port = await freePort();
  35. const child = spawnServer(port);
  36. try {
  37. await waitForOutput(child, 'MailHub listening');
  38. const baseUrl = `http://127.0.0.1:${port}`;
  39. const login = await fetch(`${baseUrl}/api/login`, {
  40. method: 'POST',
  41. headers: { 'Content-Type': 'application/json' },
  42. body: JSON.stringify({ username: 'admin', password: 'password123' })
  43. });
  44. assert.equal(login.status, 200);
  45. const cookie = login.headers.get('set-cookie')?.split(';')[0] || '';
  46. assert.ok(cookie);
  47. const response = await fetch(`${baseUrl}/`, { headers: { Cookie: cookie } });
  48. assert.equal(response.status, 200);
  49. assert.match(response.headers.get('cache-control') || '', /no-store/i);
  50. const html = await response.text();
  51. assert.match(html, /id="root"/);
  52. } finally {
  53. child.kill('SIGTERM');
  54. await waitForExit(child, 1000);
  55. }
  56. });
  57. test('landing.html is publicly reachable without auth', async () => {
  58. ensureLandingArtifact();
  59. const port = await freePort();
  60. const child = spawnServer(port);
  61. try {
  62. await waitForOutput(child, 'MailHub listening');
  63. const response = await fetch(`http://127.0.0.1:${port}/landing.html`);
  64. assert.equal(response.status, 200);
  65. assert.notEqual(response.headers.get('location'), '/login');
  66. } finally {
  67. child.kill('SIGTERM');
  68. await waitForExit(child, 1000);
  69. }
  70. });
  71. function ensureLandingArtifact() {
  72. const landingPath = path.join(process.cwd(), 'public', 'landing.html');
  73. if (existsSync(landingPath)) return;
  74. writeFileSync(landingPath, '<!doctype html><html><body><h1>MailHub Landing</h1><div data-i18n="hero.title">Get started</div></body></html>');
  75. }
  76. function spawnServer(port) {
  77. return spawn(process.execPath, ['src/server.js'], {
  78. cwd: process.cwd(),
  79. env: {
  80. ...process.env,
  81. PORT: String(port),
  82. DATA_DIR: mkdtempSync(path.join(tmpdir(), 'mailhub-landing-test-')),
  83. ADMIN_PASSWORD: 'password123',
  84. SUBMISSION_ENABLED: 'false',
  85. IMAP_ENABLED: 'false',
  86. POP3_ENABLED: 'false',
  87. WEBHOOK_WORKER_ENABLED: '0'
  88. },
  89. stdio: ['ignore', 'pipe', 'pipe']
  90. });
  91. }
  92. function freePort() {
  93. return new Promise((resolve, reject) => {
  94. const server = net.createServer();
  95. server.listen(0, '127.0.0.1', () => {
  96. const { port } = server.address();
  97. server.close((error) => (error ? reject(error) : resolve(port)));
  98. });
  99. server.on('error', reject);
  100. });
  101. }
  102. function waitForOutput(child, text, timeoutMs = 8000) {
  103. return new Promise((resolve, reject) => {
  104. let buffer = '';
  105. const timer = setTimeout(() => reject(new Error(`Timed out waiting for: ${text}\n${buffer}`)), timeoutMs);
  106. const onData = (chunk) => {
  107. buffer += String(chunk);
  108. if (buffer.includes(text)) {
  109. clearTimeout(timer);
  110. child.stdout?.off('data', onData);
  111. child.stderr?.off('data', onData);
  112. resolve();
  113. }
  114. };
  115. child.stdout?.on('data', onData);
  116. child.stderr?.on('data', onData);
  117. });
  118. }
  119. function waitForExit(child, timeoutMs) {
  120. return new Promise((resolve) => {
  121. if (child.exitCode != null) return resolve(true);
  122. const timer = setTimeout(() => resolve(false), timeoutMs);
  123. child.once('exit', () => {
  124. clearTimeout(timer);
  125. resolve(true);
  126. });
  127. });
  128. }