464 lines
16 KiB
JavaScript
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);
|
|
});
|