301 lines
10 KiB
JavaScript
301 lines
10 KiB
JavaScript
/**
|
||
* 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;
|
||
}
|
||
});
|