feat(page-data): add private and public dataset API (phase 1-3)
Extract UserDataSpaceService for shared SQLite access, wire logged-in Page Data routes, and add public insert plus password-token read/update/delete with policy storage, rate limits, and regression tests. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
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 { publicationInternals } from './mindspace-publications.mjs';
|
||||
|
||||
const PAGE_ID = 'page-integration-1';
|
||||
const OWNER_ID = 'user-integration-1';
|
||||
|
||||
function createPool(accessMode = 'public') {
|
||||
return {
|
||||
async query(sql) {
|
||||
if (sql.includes('FROM h5_publish_records')) {
|
||||
return [
|
||||
[
|
||||
{
|
||||
id: 'pub-1',
|
||||
user_id: OWNER_ID,
|
||||
page_id: PAGE_ID,
|
||||
access_mode: accessMode,
|
||||
password_hash: null,
|
||||
status: 'online',
|
||||
expires_at: null,
|
||||
},
|
||||
],
|
||||
];
|
||||
}
|
||||
return [[]];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function setupWorkspace(workspaceRoot) {
|
||||
const service = createUserDataSpaceService({ workspaceRoot });
|
||||
await service.executeSql(`CREATE TABLE entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
created_at TEXT
|
||||
);`);
|
||||
await service.upsertDataset({
|
||||
name: 'entries',
|
||||
table: 'entries',
|
||||
actions: ['read', 'insert'],
|
||||
columns: {
|
||||
read: ['id', 'title', 'created_at'],
|
||||
insert: ['title'],
|
||||
},
|
||||
});
|
||||
return service;
|
||||
}
|
||||
|
||||
function buildApp(workspaceRoot) {
|
||||
const pageDataPublicService = createPageDataPublicService({
|
||||
getPool: () => createPool('public'),
|
||||
resolveWorkspaceRootForOwner: () => workspaceRoot,
|
||||
});
|
||||
const api = express.Router();
|
||||
api.use(express.json());
|
||||
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({
|
||||
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('integration: owner private API and public insert coexist without breaking each other', async () => {
|
||||
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-integration-'));
|
||||
const service = await setupWorkspace(workspaceRoot);
|
||||
const app = buildApp(workspaceRoot);
|
||||
|
||||
const ownerInsert = await request(app, 'POST', '/api/page-data/entries/rows', {
|
||||
headers: { 'x-test-user': '1' },
|
||||
body: { title: 'owner 写入' },
|
||||
});
|
||||
assert.equal(ownerInsert.status, 201);
|
||||
assert.equal(ownerInsert.body.data.row.title, 'owner 写入');
|
||||
|
||||
const ownerList = await request(app, 'GET', '/api/page-data/entries?limit=10', {
|
||||
headers: { 'x-test-user': '1' },
|
||||
});
|
||||
assert.equal(ownerList.status, 200);
|
||||
assert.equal(ownerList.body.data.rows.length, 1);
|
||||
|
||||
const policy = {
|
||||
pageId: PAGE_ID,
|
||||
ownerUserId: OWNER_ID,
|
||||
accessMode: 'public',
|
||||
datasets: {
|
||||
entries: {
|
||||
insert: true,
|
||||
columns: { insert: ['title'] },
|
||||
},
|
||||
},
|
||||
};
|
||||
const policyWrite = await request(app, 'PUT', `/api/page-data/policies/${PAGE_ID}`, {
|
||||
headers: { 'x-test-user': '1' },
|
||||
body: policy,
|
||||
});
|
||||
assert.equal(policyWrite.status, 200);
|
||||
assert.equal(policyWrite.body.data.policy.pageId, PAGE_ID);
|
||||
|
||||
const publicInsert = await request(app, 'POST', `/api/public/pages/${PAGE_ID}/data/entries/rows`, {
|
||||
body: { title: 'public 写入' },
|
||||
});
|
||||
assert.equal(publicInsert.status, 201);
|
||||
assert.equal(publicInsert.body.data.row.title, 'public 写入');
|
||||
|
||||
const publicReadDenied = await request(app, 'GET', `/api/public/pages/${PAGE_ID}/data/entries`);
|
||||
assert.equal(publicReadDenied.status, 403);
|
||||
assert.equal(publicReadDenied.body.error.code, 'action_not_allowed');
|
||||
|
||||
const ownerListAfterPublic = await request(app, 'GET', '/api/page-data/entries?limit=10', {
|
||||
headers: { 'x-test-user': '1' },
|
||||
});
|
||||
assert.equal(ownerListAfterPublic.status, 200);
|
||||
assert.equal(ownerListAfterPublic.body.data.rows.length, 2);
|
||||
|
||||
const stats = service.getDatasetStats('entries');
|
||||
assert.equal(stats.total, 2);
|
||||
});
|
||||
|
||||
test('integration: public insert rejects SQL injection style payload keys', async () => {
|
||||
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-integration-sql-'));
|
||||
await setupWorkspace(workspaceRoot);
|
||||
const app = buildApp(workspaceRoot);
|
||||
await request(app, 'PUT', `/api/page-data/policies/${PAGE_ID}`, {
|
||||
headers: { 'x-test-user': '1' },
|
||||
body: {
|
||||
pageId: PAGE_ID,
|
||||
ownerUserId: OWNER_ID,
|
||||
accessMode: 'public',
|
||||
datasets: {
|
||||
entries: { insert: true, columns: { insert: ['title'] } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const denied = await request(app, 'POST', `/api/public/pages/${PAGE_ID}/data/entries/rows`, {
|
||||
body: { title: 'ok', sql: 'DROP TABLE entries' },
|
||||
});
|
||||
assert.equal(denied.status, 403);
|
||||
assert.equal(denied.body.error.code, 'columns_not_allowed');
|
||||
});
|
||||
|
||||
test('integration: password publication flow still works for read after data-auth', async () => {
|
||||
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-integration-password-'));
|
||||
const service = await setupWorkspace(workspaceRoot);
|
||||
await service.insertDatasetRow('entries', { title: '口令可见' });
|
||||
|
||||
const api = express.Router();
|
||||
api.use(express.json());
|
||||
api.use((req, _res, next) => {
|
||||
if (req.headers['x-test-user']) {
|
||||
req.currentUser = { id: OWNER_ID, workspaceRoot };
|
||||
}
|
||||
next();
|
||||
});
|
||||
const pageDataPublicService = createPageDataPublicService({
|
||||
getPool: () => ({
|
||||
async query(sql) {
|
||||
if (sql.includes('FROM h5_publish_records')) {
|
||||
return [
|
||||
[
|
||||
{
|
||||
id: 'pub-1',
|
||||
user_id: OWNER_ID,
|
||||
page_id: PAGE_ID,
|
||||
access_mode: 'password',
|
||||
password_hash: publicationInternals.hashPassword('secret-123'),
|
||||
status: 'online',
|
||||
expires_at: null,
|
||||
},
|
||||
],
|
||||
];
|
||||
}
|
||||
return [[]];
|
||||
},
|
||||
}),
|
||||
resolveWorkspaceRootForOwner: () => 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: () => createPageDataService({ resolveWorkspaceRoot: async () => workspaceRoot }),
|
||||
getPageDataPublicService: () => pageDataPublicService,
|
||||
});
|
||||
const app = express();
|
||||
app.use('/api', api);
|
||||
|
||||
await request(app, 'PUT', `/api/page-data/policies/${PAGE_ID}`, {
|
||||
headers: { 'x-test-user': '1' },
|
||||
body: {
|
||||
pageId: PAGE_ID,
|
||||
ownerUserId: OWNER_ID,
|
||||
accessMode: 'password',
|
||||
datasets: {
|
||||
entries: {
|
||||
read: true,
|
||||
columns: { read: ['id', 'title', 'created_at'] },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const auth = await request(app, 'POST', `/api/public/pages/${PAGE_ID}/data-auth`, {
|
||||
body: { password: 'secret-123' },
|
||||
});
|
||||
assert.equal(auth.status, 200);
|
||||
assert.ok(auth.body.data.token);
|
||||
|
||||
const listed = await request(app, 'GET', `/api/public/pages/${PAGE_ID}/data/entries`, {
|
||||
headers: { 'x-page-data-token': auth.body.data.token },
|
||||
});
|
||||
assert.equal(listed.status, 200);
|
||||
assert.equal(listed.body.data.rows[0].title, '口令可见');
|
||||
});
|
||||
Reference in New Issue
Block a user