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

301 lines
10 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Page Data API 第一版验收(对应 docs/architecture/page-data-api-public-access-plan-20260708.md §14
*/
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 } from './page-data-public-service.mjs';
import { createUserDataSpaceService } from './user-data-space-service.mjs';
import { writePageAccessPolicy } from './page-data-policy-store.mjs';
const PAGE_ID = 'page-acceptance-1';
const OWNER_ID = 'user-acceptance-1';
function createPool(accessMode = 'public') {
return {
async query(sql, params) {
if (sql.includes('FROM h5_publish_records')) {
return [
[
{
id: 'pub-acc-1',
user_id: OWNER_ID,
page_id: PAGE_ID,
access_mode: accessMode,
password_hash: null,
status: 'online',
expires_at: null,
},
],
];
}
if (sql.includes('h5_page_data_policy_index')) {
return [[]];
}
return [[]];
},
};
}
async function setupWorkspace(workspaceRoot) {
const service = createUserDataSpaceService({ workspaceRoot });
await service.executeSql(`CREATE TABLE leads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
note TEXT,
secret_field TEXT,
created_at TEXT,
deleted_at TEXT,
deleted_by TEXT
);`);
await service.upsertDataset({
name: 'leads',
table: 'leads',
description: '线索',
actions: ['read', 'insert', 'update', 'soft_delete'],
columns: {
read: ['id', 'name', 'note', 'created_at'],
insert: ['name', 'note'],
update: ['note'],
soft_delete: ['id'],
},
});
return service;
}
function buildApp(workspaceRoot, pool = createPool('public')) {
const pageDataPublicService = createPageDataPublicService({
getPool: () => pool,
resolveWorkspaceRootForOwner: () => workspaceRoot,
});
const api = express.Router();
api.use(express.json({ limit: '32kb' }));
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({
getPool: () => pool,
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('acceptance: agent dataset registry + owner read/insert via Page Data API', async () => {
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-acc-owner-'));
await setupWorkspace(workspaceRoot);
const app = buildApp(workspaceRoot);
const datasets = await request(app, 'GET', '/api/admin/page-data', {
headers: { 'x-test-user': '1' },
});
assert.equal(datasets.status, 200);
assert.equal(datasets.body.data.datasets[0].name, 'leads');
const inserted = await request(app, 'POST', '/api/admin/page-data/leads/rows', {
headers: { 'x-test-user': '1' },
body: { name: 'Owner 线索', note: '内部' },
});
assert.equal(inserted.status, 201);
const listed = await request(app, 'GET', '/api/admin/page-data/leads?limit=10', {
headers: { 'x-test-user': '1' },
});
assert.equal(listed.status, 200);
assert.equal(listed.body.data.rows.length, 1);
});
test('acceptance: public insert only when authorized; read/update/delete denied by default', async () => {
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-acc-public-'));
await setupWorkspace(workspaceRoot);
writePageAccessPolicy(workspaceRoot, {
pageId: PAGE_ID,
ownerUserId: OWNER_ID,
accessMode: 'public',
datasets: {
leads: {
insert: true,
columns: { insert: ['name', 'note'] },
},
},
});
const app = buildApp(workspaceRoot);
const ok = await request(app, 'POST', `/api/public/pages/${PAGE_ID}/data/leads/rows`, {
body: { name: '公开线索' },
});
assert.equal(ok.status, 201);
const readDenied = await request(app, 'GET', `/api/public/pages/${PAGE_ID}/data/leads`);
assert.equal(readDenied.status, 403);
assert.equal(readDenied.body.error.code, 'action_not_allowed');
const updateDenied = await request(app, 'PATCH', `/api/public/pages/${PAGE_ID}/data/leads/rows/1`, {
body: { note: 'hack' },
});
assert.equal(updateDenied.status, 403);
const deleteDenied = await request(app, 'DELETE', `/api/public/pages/${PAGE_ID}/data/leads/rows/1`);
assert.equal(deleteDenied.status, 403);
});
test('acceptance: public read and stats when policy enables read', async () => {
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-acc-public-read-'));
const ownerService = await setupWorkspace(workspaceRoot);
await ownerService.insertDatasetRow('leads', { name: '展示项', note: 'secret' });
writePageAccessPolicy(workspaceRoot, {
pageId: PAGE_ID,
ownerUserId: OWNER_ID,
accessMode: 'public',
datasets: {
leads: {
read: true,
columns: { read: ['id', 'name', 'created_at'] },
},
},
});
const app = buildApp(workspaceRoot);
const listed = await request(app, 'GET', `/api/public/pages/${PAGE_ID}/data/leads?limit=10`);
assert.equal(listed.status, 200);
assert.equal(listed.body.data.rows.length, 1);
assert.equal(listed.body.data.rows[0].name, '展示项');
assert.equal(listed.body.data.rows[0].note, undefined);
const stats = await request(app, 'GET', `/api/public/pages/${PAGE_ID}/data/leads/stats`);
assert.equal(stats.status, 200);
assert.equal(stats.body.data.total, 1);
});
test('acceptance: unauthorized dataset/action/column and SQL-like keys are rejected', async () => {
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-acc-deny-'));
await setupWorkspace(workspaceRoot);
writePageAccessPolicy(workspaceRoot, {
pageId: PAGE_ID,
ownerUserId: OWNER_ID,
accessMode: 'public',
datasets: {
leads: { insert: true, columns: { insert: ['name'] } },
},
});
const app = buildApp(workspaceRoot);
const unknownDataset = await request(app, 'POST', `/api/public/pages/${PAGE_ID}/data/unknown/rows`, {
body: { name: 'x' },
});
assert.equal(unknownDataset.status, 403);
const badColumn = await request(app, 'POST', `/api/public/pages/${PAGE_ID}/data/leads/rows`, {
body: { name: 'x', secret_field: 'leak' },
});
assert.equal(badColumn.status, 403);
assert.equal(badColumn.body.error.code, 'columns_not_allowed');
const sqlKey = await request(app, 'POST', `/api/public/pages/${PAGE_ID}/data/leads/rows`, {
body: { name: 'x', sql: 'DROP TABLE leads' },
});
assert.equal(sqlKey.status, 403);
});
test('acceptance: apply-publish creates policy for published page', async () => {
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-acc-publish-'));
await setupWorkspace(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: 'leads',
capabilities: { read: false, insert: true, update: false, softDelete: false },
},
});
assert.equal(applied.status, 200);
assert.equal(applied.body.data.policy.datasets.leads.insert, true);
assert.equal(applied.body.data.policy.datasets.leads.read, false);
const publicInsert = await request(app, 'POST', `/api/public/pages/${PAGE_ID}/data/leads/rows`, {
body: { name: '发布后提交' },
});
assert.equal(publicInsert.status, 201);
});
test('acceptance: turnstile enforced for public insert when secret configured', async () => {
const previousSecret = process.env.PAGE_DATA_TURNSTILE_SECRET;
const previousFetch = globalThis.fetch;
process.env.PAGE_DATA_TURNSTILE_SECRET = 'acceptance-secret';
globalThis.fetch = async (url, init) => {
if (String(url).includes('challenges.cloudflare.com')) {
return {
ok: true,
async json() {
return { success: true };
},
};
}
return previousFetch(url, init);
};
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-acc-captcha-'));
await setupWorkspace(workspaceRoot);
writePageAccessPolicy(workspaceRoot, {
pageId: PAGE_ID,
ownerUserId: OWNER_ID,
accessMode: 'public',
datasets: { leads: { insert: true, columns: { insert: ['name'] } } },
});
const app = buildApp(workspaceRoot);
try {
const denied = await request(app, 'POST', `/api/public/pages/${PAGE_ID}/data/leads/rows`, {
body: { name: '无验证码' },
});
assert.equal(denied.status, 400);
assert.equal(denied.body.error.code, 'captcha_required');
const ok = await request(app, 'POST', `/api/public/pages/${PAGE_ID}/data/leads/rows`, {
headers: { 'x-page-data-captcha': 'token-ok' },
body: { name: '有验证码' },
});
assert.equal(ok.status, 201);
} finally {
globalThis.fetch = previousFetch;
if (previousSecret == null) delete process.env.PAGE_DATA_TURNSTILE_SECRET;
else process.env.PAGE_DATA_TURNSTILE_SECRET = previousSecret;
}
});