Files
memind/page-data-integration.test.mjs

464 lines
16 KiB
JavaScript

import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import express from 'express';
import { attachPageDataRoutes } from './page-data-routes.mjs';
import { createPageDataService } from './page-data-service.mjs';
import { createPageDataPublicService, isPageDataPublicPath } from './page-data-public-service.mjs';
import { createUserDataSpaceService } from './user-data-space-service.mjs';
import { publicationInternals } from './mindspace-publications.mjs';
import { writePageAccessPolicy } from './page-data-policy-store.mjs';
import {
resolvePortalAccessEnforcementConfig,
resolvePortalAccessEnforcementDecision,
shouldOverridePortalLegacyGlobalAuth,
} from './server/portal-access-policy.mjs';
const PAGE_ID = 'page-integration-1';
const OWNER_ID = 'user-integration-1';
function createPool(accessMode = 'public') {
return {
async query(sql) {
if (sql.includes('FROM h5_publish_records')) {
return [
[
{
id: 'pub-1',
user_id: OWNER_ID,
page_id: PAGE_ID,
access_mode: accessMode,
password_hash: null,
status: 'online',
expires_at: null,
},
],
];
}
return [[]];
},
};
}
async function setupWorkspace(workspaceRoot) {
const service = createUserDataSpaceService({ workspaceRoot });
await service.executeSql(`CREATE TABLE entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
created_at TEXT
);`);
await service.upsertDataset({
name: 'entries',
table: 'entries',
actions: ['read', 'insert'],
columns: {
read: ['id', 'title', 'created_at'],
insert: ['title'],
},
});
return service;
}
async function setupPublicWorkspace(workspaceRoot) {
const service = createUserDataSpaceService({ workspaceRoot });
await service.executeSql(`CREATE TABLE signups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
phone TEXT,
status TEXT DEFAULT 'pending',
created_at TEXT,
deleted_at TEXT,
deleted_by TEXT,
updated_at TEXT,
updated_by_label TEXT
);`);
await service.upsertDataset({
name: 'signups',
table: 'signups',
actions: ['read', 'insert', 'update', 'soft_delete'],
columns: {
read: ['id', 'name', 'phone', 'status', 'created_at'],
insert: ['name', 'phone', 'status'],
update: ['status'],
soft_delete: ['id'],
},
});
return service;
}
function buildApp(workspaceRoot) {
const pageDataPublicService = createPageDataPublicService({
getPool: () => createPool('public'),
resolveWorkspaceRootForOwner: () => workspaceRoot,
});
const api = express.Router();
api.use(express.json());
api.use((req, _res, next) => {
if (req.headers['x-test-user']) {
req.currentUser = { id: OWNER_ID, workspaceRoot };
}
next();
});
attachPageDataRoutes(api, {
sendData: (res, _req, data, status = 200) => res.status(status).json({ data }),
sendError: (res, _req, status, code, message) => res.status(status).json({ error: { code, message } }),
getPageDataService: () =>
createPageDataService({
resolveWorkspaceRoot: async () => workspaceRoot,
}),
getPageDataPublicService: () => pageDataPublicService,
});
const app = express();
app.set('trust proxy', true);
app.use('/api', api);
return app;
}
function buildEnforcedPageDataApp(workspaceRoot) {
const pageDataPublicService = createPageDataPublicService({
getPool: () => createPool('public'),
resolveWorkspaceRootForOwner: () => workspaceRoot,
});
const enforcementConfig = resolvePortalAccessEnforcementConfig({
MEMIND_PORTAL_ACCESS_POLICY_ENFORCEMENT_ENABLED: '1',
MEMIND_PORTAL_ACCESS_POLICY_ENFORCE_GROUPS: 'page-data-public',
});
const api = express.Router();
api.use(express.json());
api.use((req, res, next) => {
const decision = resolvePortalAccessEnforcementDecision(
{ path: req.path, method: req.method },
{ isPageDataPublicPath },
enforcementConfig,
);
if (shouldOverridePortalLegacyGlobalAuth(decision, false)) return next();
return res.status(401).json({ error: { code: 'unauthorized' } });
});
attachPageDataRoutes(api, {
sendData: (res, _req, data, status = 200) => res.status(status).json({ data }),
sendError: (res, _req, status, code, message) =>
res.status(status).json({ error: { code, message } }),
getPageDataService: () =>
createPageDataService({
resolveWorkspaceRoot: async () => workspaceRoot,
}),
getPageDataPublicService: () => pageDataPublicService,
});
const app = express();
app.set('trust proxy', true);
app.use('/api', api);
return app;
}
async function request(app, method, url, { body, headers } = {}) {
const server = app.listen(0);
try {
const { port } = server.address();
const response = await fetch(`http://127.0.0.1:${port}${url}`, {
method,
headers: {
...(body ? { 'content-type': 'application/json' } : {}),
...headers,
},
body: body ? JSON.stringify(body) : undefined,
});
return { status: response.status, body: await response.json() };
} finally {
await new Promise((resolve) => server.close(resolve));
}
}
test('integration: owner private API and public insert coexist without breaking each other', async () => {
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-integration-'));
const service = await setupWorkspace(workspaceRoot);
const app = buildApp(workspaceRoot);
const ownerInsert = await request(app, 'POST', '/api/admin/page-data/entries/rows', {
headers: { 'x-test-user': '1' },
body: { title: 'owner 写入' },
});
assert.equal(ownerInsert.status, 201);
assert.equal(ownerInsert.body.data.row.title, 'owner 写入');
const ownerInvalidInsert = await request(
app,
'POST',
'/api/admin/page-data/entries/rows',
{
headers: { 'x-test-user': '1' },
body: { forbidden_column: 'blocked' },
},
);
assert.equal(ownerInvalidInsert.status, 403);
assert.equal(
ownerInvalidInsert.body.error.code,
'columns_not_allowed',
);
const ownerList = await request(app, 'GET', '/api/admin/page-data/entries?limit=10', {
headers: { 'x-test-user': '1' },
});
assert.equal(ownerList.status, 200);
assert.equal(ownerList.body.data.rows.length, 1);
const policy = {
pageId: PAGE_ID,
ownerUserId: OWNER_ID,
accessMode: 'public',
datasets: {
entries: {
insert: true,
columns: { insert: ['title'] },
},
},
};
const policyWrite = await request(app, 'PUT', `/api/admin/page-data/policies/${PAGE_ID}`, {
headers: { 'x-test-user': '1' },
body: policy,
});
assert.equal(policyWrite.status, 200);
assert.equal(policyWrite.body.data.policy.pageId, PAGE_ID);
const publicInsert = await request(app, 'POST', `/api/public/pages/${PAGE_ID}/data/entries/rows`, {
body: { title: 'public 写入' },
});
assert.equal(publicInsert.status, 201);
assert.equal(publicInsert.body.data.row.title, 'public 写入');
const publicReadDenied = await request(app, 'GET', `/api/public/pages/${PAGE_ID}/data/entries`);
assert.equal(publicReadDenied.status, 403);
assert.equal(publicReadDenied.body.error.code, 'action_not_allowed');
const ownerListAfterPublic = await request(app, 'GET', '/api/admin/page-data/entries?limit=10', {
headers: { 'x-test-user': '1' },
});
assert.equal(ownerListAfterPublic.status, 200);
assert.equal(ownerListAfterPublic.body.data.rows.length, 2);
const stats = service.getDatasetStats('entries');
assert.equal(stats.total, 2);
});
test('integration: page-data-public enforcement delegates to route policy without opening adjacent APIs', async () => {
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-enforced-'));
await setupWorkspace(workspaceRoot);
writePageAccessPolicy(workspaceRoot, {
pageId: PAGE_ID,
ownerUserId: OWNER_ID,
accessMode: 'public',
datasets: {
entries: {
read: false,
insert: true,
columns: {
read: ['id', 'title', 'created_at'],
insert: ['title'],
},
},
},
});
const app = buildEnforcedPageDataApp(workspaceRoot);
const inserted = await request(
app,
'POST',
`/api/public/pages/${PAGE_ID}/data/entries/rows`,
{ body: { title: '灰度公开写入' } },
);
assert.equal(inserted.status, 201);
assert.equal(inserted.body.data.row.title, '灰度公开写入');
const policyDenied = await request(
app,
'GET',
`/api/public/pages/${PAGE_ID}/data/entries`,
);
assert.equal(policyDenied.status, 403);
assert.equal(policyDenied.body.error.code, 'action_not_allowed');
for (const [method, url] of [
['GET', '/api/admin/page-data/entries'],
['GET', '/api/plaza/v1/categories'],
['GET', `/api/public/pages/${PAGE_ID}/data/entries/rows`],
['POST', `/api/public/pages/${PAGE_ID}/data/entries`],
]) {
const denied = await request(app, method, url);
assert.equal(denied.status, 401, `${method} ${url}`);
assert.equal(denied.body.error.code, 'unauthorized');
}
});
test('integration: public insert rejects SQL injection style payload keys', async () => {
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-integration-sql-'));
await setupWorkspace(workspaceRoot);
const app = buildApp(workspaceRoot);
await request(app, 'PUT', `/api/admin/page-data/policies/${PAGE_ID}`, {
headers: { 'x-test-user': '1' },
body: {
pageId: PAGE_ID,
ownerUserId: OWNER_ID,
accessMode: 'public',
datasets: {
entries: { insert: true, columns: { insert: ['title'] } },
},
},
});
const denied = await request(app, 'POST', `/api/public/pages/${PAGE_ID}/data/entries/rows`, {
body: { title: 'ok', sql: 'DROP TABLE entries' },
});
assert.equal(denied.status, 403);
assert.equal(denied.body.error.code, 'columns_not_allowed');
});
test('integration: password publication flow still works for read after data-auth', async () => {
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-integration-password-'));
const service = await setupWorkspace(workspaceRoot);
await service.insertDatasetRow('entries', { title: '口令可见' });
const api = express.Router();
api.use(express.json());
api.use((req, _res, next) => {
if (req.headers['x-test-user']) {
req.currentUser = { id: OWNER_ID, workspaceRoot };
}
next();
});
const pageDataPublicService = createPageDataPublicService({
getPool: () => ({
async query(sql) {
if (sql.includes('FROM h5_publish_records')) {
return [
[
{
id: 'pub-1',
user_id: OWNER_ID,
page_id: PAGE_ID,
access_mode: 'password',
password_hash: publicationInternals.hashPassword('secret-123'),
status: 'online',
expires_at: null,
},
],
];
}
return [[]];
},
}),
resolveWorkspaceRootForOwner: () => workspaceRoot,
});
attachPageDataRoutes(api, {
sendData: (res, _req, data, status = 200) => res.status(status).json({ data }),
sendError: (res, _req, status, code, message) => res.status(status).json({ error: { code, message } }),
getPageDataService: () => createPageDataService({ resolveWorkspaceRoot: async () => workspaceRoot }),
getPageDataPublicService: () => pageDataPublicService,
});
const app = express();
app.use('/api', api);
await request(app, 'PUT', `/api/admin/page-data/policies/${PAGE_ID}`, {
headers: { 'x-test-user': '1' },
body: {
pageId: PAGE_ID,
ownerUserId: OWNER_ID,
accessMode: 'password',
datasets: {
entries: {
read: true,
columns: { read: ['id', 'title', 'created_at'] },
},
},
},
});
const auth = await request(app, 'POST', `/api/public/pages/${PAGE_ID}/data-auth`, {
body: { password: 'secret-123' },
});
assert.equal(auth.status, 200);
assert.ok(auth.body.data.token);
const listed = await request(app, 'GET', `/api/public/pages/${PAGE_ID}/data/entries`, {
headers: { 'x-page-data-token': auth.body.data.token },
});
assert.equal(listed.status, 200);
assert.equal(listed.body.data.rows[0].title, '口令可见');
});
test('integration: public insert writes operation logs for owner review', async () => {
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-logs-int-'));
await setupPublicWorkspace(workspaceRoot);
const app = buildApp(workspaceRoot);
await request(app, 'PUT', `/api/admin/page-data/policies/${PAGE_ID}`, {
headers: { 'x-test-user': '1' },
body: {
pageId: PAGE_ID,
ownerUserId: OWNER_ID,
accessMode: 'public',
datasets: { signups: { insert: true, columns: { insert: ['name', 'phone', 'status'] } } },
},
});
await request(app, 'POST', `/api/public/pages/${PAGE_ID}/data/signups/rows`, {
body: { name: '日志测试', phone: '13800000000' },
});
const logs = await request(app, 'GET', `/api/admin/page-data/policies/${PAGE_ID}/logs`, {
headers: { 'x-test-user': '1' },
});
assert.equal(logs.status, 200);
assert.equal(logs.body.data.logs.length, 1);
assert.equal(logs.body.data.logs[0].action, 'insert');
assert.equal(logs.body.data.logs[0].dataset, 'signups');
});
test('integration: owner can export dataset and restore soft-deleted rows', async () => {
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-owner-ops-'));
const ownerService = await setupPublicWorkspace(workspaceRoot);
const inserted = await ownerService.insertDatasetRow('signups', {
name: '导出项',
phone: '13800000001',
});
const app = buildApp(workspaceRoot);
const exported = await request(app, 'GET', '/api/admin/page-data/signups/export?format=json', {
headers: { 'x-test-user': '1' },
});
assert.equal(exported.status, 200);
assert.equal(exported.body.data.rowCount, 1);
await ownerService.softDeleteRowForDataset(ownerService.getDataset('signups'), inserted.row.id, {
deletedBy: 'test',
});
const restored = await request(app, 'POST', `/api/admin/page-data/signups/rows/${inserted.row.id}/restore`, {
headers: { 'x-test-user': '1' },
});
assert.equal(restored.status, 200);
assert.equal(restored.body.data.restored, true);
});
test('integration: apply-publish route binds dataset after publication', async () => {
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-apply-publish-route-'));
await setupPublicWorkspace(workspaceRoot);
const app = buildApp(workspaceRoot);
const applied = await request(app, 'POST', `/api/admin/page-data/policies/${PAGE_ID}/apply-publish`, {
headers: { 'x-test-user': '1' },
body: {
datasetName: 'signups',
capabilities: { read: true, insert: true, update: false, softDelete: false },
},
});
assert.equal(applied.status, 200);
assert.equal(applied.body.data.policy.datasets.signups.read, true);
const publicReadEmpty = await request(app, 'GET', `/api/public/pages/${PAGE_ID}/data/signups`);
assert.equal(publicReadEmpty.status, 200);
assert.equal(publicReadEmpty.body.data.rows.length, 0);
await request(app, 'POST', `/api/public/pages/${PAGE_ID}/data/signups/rows`, {
body: { name: '发布后公开读', phone: '13800000000' },
});
const publicRead = await request(app, 'GET', `/api/public/pages/${PAGE_ID}/data/signups`);
assert.equal(publicRead.status, 200);
assert.equal(publicRead.body.data.rows.length, 1);
});