#!/usr/bin/env node import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; import fs from 'node:fs/promises'; import path from 'node:path'; import { createDbPool } from '../db.mjs'; import { loadH5Environment } from './load-env.mjs'; loadH5Environment(import.meta.dirname); const suffix = `${Date.now()}${Math.floor(Math.random() * 1000)}`; const portalPort = Number(process.env.MINDSPACE_PUBLICATION_PORT ?? 18142); const baseUrl = `http://127.0.0.1:${portalPort}`; const storageRoot = path.join('/tmp', `mindspace-publications-${suffix}`); const users = [ { username: `msp_pub_owner_${suffix}`, email: `msp-pub-owner-${suffix}@example.test`, password: 'MindSpace-Publication-Owner-2026', }, { username: `msp_pub_other_${suffix}`, email: `msp-pub-other-${suffix}@example.test`, password: 'MindSpace-Publication-Other-2026', }, ]; const portal = spawn(process.execPath, ['server.mjs'], { cwd: path.join(import.meta.dirname, '..'), env: { ...process.env, H5_PORT: String(portalPort), H5_PUBLIC_BASE_URL: baseUrl, TKMIND_API_TARGET: 'http://127.0.0.1:9', MINDSPACE_STORAGE_ROOT: storageRoot, MINDSPACE_FREE_PUBLIC_PAGE_LIMIT: '1', }, stdio: ['ignore', 'pipe', 'pipe'], }); let portalLogs = ''; portal.stdout.on('data', (chunk) => { portalLogs += chunk.toString(); }); portal.stderr.on('data', (chunk) => { portalLogs += chunk.toString(); }); async function waitForPortal() { for (let attempt = 0; attempt < 60; attempt += 1) { if (portal.exitCode != null) throw new Error(`Portal 提前退出\n${portalLogs}`); try { const response = await fetch(`${baseUrl}/auth/status`); if (response.ok) return; } catch { // Portal is still starting. } await new Promise((resolve) => setTimeout(resolve, 100)); } throw new Error(`Portal 启动超时\n${portalLogs}`); } async function request(pathname, options = {}) { const url = /^https?:\/\//i.test(pathname) ? pathname : `${baseUrl}${pathname}`; const response = await fetch(url, options); const contentType = response.headers.get('content-type') ?? ''; const body = contentType.includes('application/json') ? await response.json() : await response.text(); return { response, body }; } async function registerAndLogin(user) { const registration = await request('/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...user, displayName: user.username }), }); assert.equal(registration.response.status, 200, JSON.stringify(registration.body)); const login = await request('/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: user.username, password: user.password }), }); assert.equal(login.response.status, 200, JSON.stringify(login.body)); return { id: registration.body.user.id, cookie: login.response.headers.get('set-cookie')?.split(';', 1)[0], }; } function authHeaders(cookie) { return { Cookie: cookie, 'Content-Type': 'application/json' }; } const pool = createDbPool(); let originalPublicPageLimit = null; async function setPublicPageLimitForTest() { const [rows] = await pool.query( "SELECT value FROM mindspace_config WHERE `key` = 'public_page_limit' LIMIT 1", ); originalPublicPageLimit = rows[0]?.value ?? null; await pool.query( `INSERT INTO mindspace_config (\`key\`, value, description, updated_at) VALUES ('public_page_limit', '1', '公开页面数量上限', ?) ON DUPLICATE KEY UPDATE value = VALUES(value), updated_at = VALUES(updated_at)`, [Date.now()], ); } async function restorePublicPageLimit() { if (originalPublicPageLimit == null) { await pool.query("DELETE FROM mindspace_config WHERE `key` = 'public_page_limit'"); return; } await pool.query( "UPDATE mindspace_config SET value = ?, updated_at = ? WHERE `key` = 'public_page_limit'", [originalPublicPageLimit, Date.now()], ); } try { await waitForPortal(); await setPublicPageLimitForTest(); const owner = await registerAndLogin(users[0]); const other = await registerAndLogin(users[1]); const created = await request('/api/mindspace/v1/pages', { method: 'POST', headers: authHeaders(owner.cookie), body: JSON.stringify({ title: '发布隔离测试', summary: '第一版摘要', content: '# 第一版\n\n这是线上应该保持的 V1 内容。', template_id: 'editorial', }), }); assert.equal(created.response.status, 201, JSON.stringify(created.body)); const page = created.body.data; const checked = await request(`/api/mindspace/v1/pages/${page.id}/publish-check`, { method: 'POST', headers: authHeaders(owner.cookie), body: JSON.stringify({ page_version_id: page.currentVersionId, access_mode: 'public', url_slug: 'immutable-page', }), }); assert.equal(checked.response.status, 200, JSON.stringify(checked.body)); assert.equal(checked.body.data.allowed, true); const published = await request(`/api/mindspace/v1/pages/${page.id}/publish`, { method: 'POST', headers: authHeaders(owner.cookie), body: JSON.stringify({ page_version_id: page.currentVersionId, access_mode: 'public', url_slug: 'immutable-page', acknowledged_finding_ids: [], }), }); assert.equal(published.response.status, 201, JSON.stringify(published.body)); const publicUrl = published.body.data.publicUrl; const publicV1 = await request(publicUrl, { headers: { 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0) Mobile', Referer: 'https://example.com/campaign/private-path', }, }); assert.equal(publicV1.response.status, 200); assert.match(publicV1.body, /V1 内容/); const usageAfterPublish = await request('/api/mindspace/v1/space', { headers: { Cookie: owner.cookie }, }); assert.equal(usageAfterPublish.body.data.quota.publicPageUsed, 1); assert.equal(usageAfterPublish.body.data.quota.monthlyViewUsed, 1); assert.equal( usageAfterPublish.body.data.categories.find((category) => category.code === 'public') .itemCount, 1, ); const overLimitPage = await request('/api/mindspace/v1/pages', { method: 'POST', headers: authHeaders(owner.cookie), body: JSON.stringify({ title: '第二个公开页面', content: '这个页面应被套餐数量限制阻止。', template_id: 'editorial', }), }); const overLimit = await request( `/api/mindspace/v1/pages/${overLimitPage.body.data.id}/publish`, { method: 'POST', headers: authHeaders(owner.cookie), body: JSON.stringify({ page_version_id: overLimitPage.body.data.currentVersionId, access_mode: 'public', url_slug: 'over-limit-page', acknowledged_finding_ids: [], }), }, ); assert.equal(overLimit.response.status, 429); assert.equal(overLimit.body.error.code, 'public_page_limit_exceeded'); const updated = await request(`/api/mindspace/v1/pages/${page.id}`, { method: 'PUT', headers: authHeaders(owner.cookie), body: JSON.stringify({ expected_version: 1, title: '发布隔离测试 v2', summary: '第二版摘要', content: '# 第二版\n\n这是尚未重新发布的 V2 内容。', template_id: 'report', }), }); assert.equal(updated.response.status, 200, JSON.stringify(updated.body)); const stillV1 = await request(publicUrl); assert.equal(stillV1.response.status, 200); assert.match(stillV1.body, /V1 内容/); assert.doesNotMatch(stillV1.body, /V2 内容/); const blockedRepublish = await request(`/api/mindspace/v1/pages/${page.id}/publish`, { method: 'POST', headers: authHeaders(owner.cookie), body: JSON.stringify({ page_version_id: updated.body.data.currentVersionId, access_mode: 'owner_only', url_slug: 'immutable-page', acknowledged_finding_ids: [], }), }); assert.equal(blockedRepublish.response.status, 409); assert.equal(blockedRepublish.body.error.code, 'page_already_online'); const offlineBeforeRepublish = await request( `/api/mindspace/v1/publications/${published.body.data.id}/offline`, { method: 'POST', headers: authHeaders(owner.cookie), body: '{}', }, ); assert.equal(offlineBeforeRepublish.response.status, 200); const republished = await request(`/api/mindspace/v1/pages/${page.id}/publish`, { method: 'POST', headers: authHeaders(owner.cookie), body: JSON.stringify({ page_version_id: updated.body.data.currentVersionId, access_mode: 'owner_only', url_slug: 'immutable-page', acknowledged_finding_ids: [], }), }); assert.equal(republished.response.status, 201, JSON.stringify(republished.body)); const anonymousProtected = await request(publicUrl); assert.equal(anonymousProtected.response.status, 404); const foreignProtected = await request(publicUrl, { headers: { Cookie: other.cookie } }); assert.equal(foreignProtected.response.status, 404); const ownerProtected = await request(publicUrl, { headers: { Cookie: owner.cookie } }); assert.equal(ownerProtected.response.status, 200); assert.match(ownerProtected.body, /V2 内容/); let currentPublicationId = republished.body.data.id; const offlineCurrentPublication = async () => { if (!currentPublicationId) return; const response = await request( `/api/mindspace/v1/publications/${currentPublicationId}/offline`, { method: 'POST', headers: authHeaders(owner.cookie), body: '{}', }, ); assert.equal(response.response.status, 200, JSON.stringify(response.body)); }; const publishMode = async (accessMode, extra = {}) => { await offlineCurrentPublication(); const response = await request(`/api/mindspace/v1/pages/${page.id}/publish`, { method: 'POST', headers: authHeaders(owner.cookie), body: JSON.stringify({ page_version_id: updated.body.data.currentVersionId, access_mode: accessMode, url_slug: 'immutable-page', acknowledged_finding_ids: [], ...extra, }), }); assert.equal(response.response.status, 201, JSON.stringify(response.body)); currentPublicationId = response.body.data.id; return response.body.data; }; const loginRequired = await publishMode('login_required'); const anonymousLoginRequired = await request(publicUrl); assert.equal(anonymousLoginRequired.response.status, 401); const loggedInVisitor = await request(publicUrl, { headers: { Cookie: other.cookie } }); assert.equal(loggedInVisitor.response.status, 200); assert.match(loggedInVisitor.body, /V2 内容/); const accessPassword = 'Protected-Page-2026'; const passwordProtected = await publishMode('password', { password: accessPassword }); const passwordGate = await request(publicUrl); assert.equal(passwordGate.response.status, 200); assert.match(passwordGate.body, /此页面受密码保护/); assert.doesNotMatch(passwordGate.body, /V2 内容/); const wrongPassword = await request(publicUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ password: 'wrong-password' }), }); assert.equal(wrongPassword.response.status, 403); const correctPassword = await request(publicUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ password: accessPassword }), }); assert.equal(correctPassword.response.status, 200); assert.match(correctPassword.body, /V2 内容/); const privateLink = await publishMode('private_link'); assert.match(privateLink.publicUrl, /^\/s\/[A-Za-z0-9_-]+$/); const privateLinkPage = await request(privateLink.publicUrl); assert.equal(privateLinkPage.response.status, 200); assert.match(privateLinkPage.body, /V2 内容/); const hiddenLongRoute = await request(publicUrl); assert.equal(hiddenLongRoute.response.status, 404); const timeLimited = await publishMode('time_limited', { expires_at: Date.now() + 60_000, }); const beforeExpiry = await request(publicUrl); assert.equal(beforeExpiry.response.status, 200); await pool.query(`UPDATE h5_publish_records SET expires_at = ? WHERE id = ?`, [ Date.now() - 1, timeLimited.id, ]); const afterExpiry = await request(publicUrl); assert.equal(afterExpiry.response.status, 404); currentPublicationId = null; const finalPublication = await publishMode('public'); const finalView = await request(publicUrl, { headers: { Cookie: owner.cookie, 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', }, }); assert.equal(finalView.response.status, 200); const stats = await request( `/api/mindspace/v1/publications/${finalPublication.id}/stats`, { headers: { Cookie: owner.cookie } }, ); assert.equal(stats.response.status, 200); assert.equal(stats.body.data.totalViews, 1); assert.deepEqual(stats.body.data.devices, [{ deviceType: 'desktop', views: 1 }]); assert.deepEqual(stats.body.data.sources, [{ source: 'direct', views: 1 }]); const publicHomepage = await request(`/u/${users[0].username}`); assert.equal(publicHomepage.response.status, 200); assert.match(publicHomepage.body, /当前公开发布且在线的 MindSpace 页面/); assert.match(publicHomepage.body, /发布隔离测试 v2/); assert.match(publicHomepage.body, /1 个公开页面/); const foreignStats = await request( `/api/mindspace/v1/publications/${finalPublication.id}/stats`, { headers: { Cookie: other.cookie } }, ); assert.equal(foreignStats.response.status, 404); const risky = await request('/api/mindspace/v1/pages', { method: 'POST', headers: authHeaders(owner.cookie), body: JSON.stringify({ title: '高风险页面', content: '身份证号码 310101199001011234', template_id: 'editorial', }), }); const blocked = await request(`/api/mindspace/v1/pages/${risky.body.data.id}/publish`, { method: 'POST', headers: authHeaders(owner.cookie), body: JSON.stringify({ page_version_id: risky.body.data.currentVersionId, access_mode: 'public', url_slug: 'blocked-page', acknowledged_finding_ids: [], }), }); assert.equal(blocked.response.status, 422); assert.equal(blocked.body.error.code, 'security_risk_blocked'); const foreignOffline = await request( `/api/mindspace/v1/publications/${finalPublication.id}/offline`, { method: 'POST', headers: authHeaders(other.cookie), body: '{}', }, ); assert.equal(foreignOffline.response.status, 404); const offlined = await request( `/api/mindspace/v1/publications/${finalPublication.id}/offline`, { method: 'POST', headers: authHeaders(owner.cookie), body: '{}', }, ); assert.equal(offlined.response.status, 200); const afterOffline = await request(publicUrl, { headers: { Cookie: owner.cookie } }); assert.equal(afterOffline.response.status, 404); const usageAfterOffline = await request('/api/mindspace/v1/space', { headers: { Cookie: owner.cookie }, }); assert.equal(usageAfterOffline.body.data.quota.publicPageUsed, 0); const [events] = await pool.query( `SELECT event_type FROM h5_publication_events WHERE publish_id IN ( SELECT id FROM h5_publish_records WHERE page_id = ? ) ORDER BY created_at`, [page.id], ); assert.equal(events[0].event_type, 'published'); assert.equal(events.at(-1).event_type, 'offlined'); assert.equal( events.filter((event) => event.event_type === 'republished').length, 6, ); console.log('MindSpace publication API E2E passed'); } finally { await restorePublicPageLimit(); await pool.query(`DELETE FROM h5_users WHERE username IN (?, ?)`, [ users[0].username, users[1].username, ]); await pool.end(); portal.kill('SIGTERM'); await new Promise((resolve) => portal.once('exit', resolve)); await new Promise((resolve) => portalLogs.includes('TKMind H5') ? resolve() : setTimeout(resolve, 10)); await fs.rm(storageRoot, { recursive: true, force: true }); }