203 lines
6.9 KiB
JavaScript
203 lines
6.9 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, 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');
|
|
});
|