a3f669d4e7
Signed-off-by: john <cynell@139.com>
454 lines
16 KiB
JavaScript
454 lines
16 KiB
JavaScript
#!/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 });
|
|
}
|