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, isLegacyPageDataApiPath } from './page-data-routes.mjs'; import { createPageDataService } from './page-data-service.mjs'; import { createUserDataSpaceService } from './user-data-space-service.mjs'; function createResponseRecorder() { return { statusCode: 200, body: null, status(code) { this.statusCode = code; return this; }, json(payload) { this.body = payload; return this; }, }; } function createApiApp({ workspaceRoot, user }) { const api = express.Router(); api.use(express.json()); api.use((req, _res, next) => { req.currentUser = user; next(); }); const pageDataService = createPageDataService({ resolveWorkspaceRoot: async () => 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: () => pageDataService, }); const app = express(); app.use('/api', api); return app; } async function requestJson(app, method, url, body) { 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' } : undefined, body: body ? JSON.stringify(body) : undefined, }); const payload = await response.json(); return { status: response.status, body: payload }; } finally { await new Promise((resolve) => server.close(resolve)); } } test('page data routes allow logged-in owner to read and insert dataset rows', async () => { const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-api-')); const service = createUserDataSpaceService({ workspaceRoot }); await service.executeSql(`CREATE TABLE feedback ( id INTEGER PRIMARY KEY AUTOINCREMENT, message TEXT NOT NULL, created_at TEXT );`); await service.upsertDataset({ name: 'feedback', table: 'feedback', actions: ['read', 'insert'], columns: { read: ['id', 'message', 'created_at'], insert: ['message'], }, }); const app = createApiApp({ workspaceRoot, user: { id: 'user-1', workspaceRoot }, }); const inserted = await requestJson(app, 'POST', '/api/admin/page-data/feedback/rows', { message: '很好用', }); assert.equal(inserted.status, 201); assert.equal(inserted.body.data.row.message, '很好用'); const listed = await requestJson(app, 'GET', '/api/admin/page-data/feedback?limit=10'); assert.equal(listed.status, 200); assert.equal(listed.body.data.rows.length, 1); const schema = await requestJson(app, 'GET', '/api/admin/page-data/feedback/schema'); assert.equal(schema.status, 200); assert.equal(schema.body.data.dataset.name, 'feedback'); const stats = await requestJson(app, 'GET', '/api/admin/page-data/feedback/stats'); assert.equal(stats.status, 200); assert.equal(stats.body.data.total, 1); }); test('page data routes allow owner update and soft delete', async () => { const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-api-crud-')); const service = createUserDataSpaceService({ workspaceRoot }); await service.executeSql(`CREATE TABLE tasks ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, status TEXT DEFAULT 'open', created_at TEXT, deleted_at TEXT, deleted_by TEXT, updated_at TEXT );`); await service.upsertDataset({ name: 'tasks', table: 'tasks', actions: ['read', 'insert', 'update', 'soft_delete'], columns: { read: ['id', 'title', 'status', 'created_at'], insert: ['title'], update: ['status'], soft_delete: ['id'], }, }); const app = createApiApp({ workspaceRoot, user: { id: 'user-1', workspaceRoot }, }); const inserted = await requestJson(app, 'POST', '/api/admin/page-data/tasks/rows', { title: '待办' }); assert.equal(inserted.status, 201); const rowId = inserted.body.data.row.id; const updated = await requestJson(app, 'PATCH', `/api/admin/page-data/tasks/rows/${rowId}`, { status: 'done', }); assert.equal(updated.status, 200); assert.equal(updated.body.data.row.status, 'done'); const deleted = await requestJson(app, 'DELETE', `/api/admin/page-data/tasks/rows/${rowId}`); assert.equal(deleted.status, 200); assert.equal(deleted.body.data.deleted, true); const listed = await requestJson(app, 'GET', '/api/admin/page-data/tasks?limit=10'); assert.equal(listed.status, 200); assert.equal(listed.body.data.rows.length, 0); }); test('page data routes reject unauthorized dataset action', async () => { const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-api-deny-')); const service = createUserDataSpaceService({ workspaceRoot }); await service.executeSql(`CREATE TABLE hidden ( id INTEGER PRIMARY KEY AUTOINCREMENT, secret TEXT NOT NULL );`); await service.upsertDataset({ name: 'hidden', table: 'hidden', actions: ['insert'], columns: { insert: ['secret'] }, }); const app = createApiApp({ workspaceRoot, user: { id: 'user-1', workspaceRoot }, }); const denied = await requestJson(app, 'GET', '/api/admin/page-data/hidden'); assert.equal(denied.status, 403); assert.equal(denied.body.error.code, 'action_not_allowed'); }); test('legacy page data API is permanently unavailable', async () => { const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-api-legacy-')); const app = createApiApp({ workspaceRoot, user: { id: 'user-1', workspaceRoot } }); const blocked = await requestJson(app, 'POST', '/api/page-data/notes/rows', { content: '旧页面' }); assert.equal(blocked.status, 410); assert.equal(blocked.body.error.code, 'legacy_page_data_api_removed'); }); test('legacy API path detector does not include the admin namespace', () => { assert.equal(isLegacyPageDataApiPath('/page-data/notes/rows'), true); assert.equal(isLegacyPageDataApiPath('/admin/page-data/notes/rows'), false); }); test('page data routes require login', async () => { const api = express.Router(); attachPageDataRoutes(api, { sendData: (res, _req, data) => res.json({ data }), sendError: (res, _req, status, code, message) => res.status(status).json({ error: { code, message } }), getPageDataService: () => createPageDataService({ resolveWorkspaceRoot: async () => '/tmp' }), }); const handler = api.stack.find((layer) => layer.route?.path === '/admin/page-data/:dataset')?.route.stack[0].handle; const res = createResponseRecorder(); await handler({ params: { dataset: 'demo' } }, res); assert.equal(res.statusCode, 401); assert.equal(res.body.error.code, 'unauthorized'); });