/** * 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; } });