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,176 @@
|
||||
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 { createPageDataPublicService, isPageDataPublicPath } from './page-data-public-service.mjs';
|
||||
import { createUserDataSpaceService } from './user-data-space-service.mjs';
|
||||
import { writePageAccessPolicy } from './page-data-policy-store.mjs';
|
||||
import { publicationInternals } from './mindspace-publications.mjs';
|
||||
|
||||
const PAGE_ID = 'page-public-1';
|
||||
const OWNER_ID = 'user-owner-1';
|
||||
|
||||
function createPublicationPool({ accessMode = 'public', password = null } = {}) {
|
||||
const passwordHash = password ? publicationInternals.hashPassword(password) : null;
|
||||
return {
|
||||
async query(sql) {
|
||||
if (sql.includes('FROM h5_publish_records')) {
|
||||
return [
|
||||
[
|
||||
{
|
||||
id: 'pub-record-1',
|
||||
user_id: OWNER_ID,
|
||||
page_id: PAGE_ID,
|
||||
access_mode: accessMode,
|
||||
password_hash: passwordHash,
|
||||
status: 'online',
|
||||
expires_at: null,
|
||||
},
|
||||
],
|
||||
];
|
||||
}
|
||||
return [[]];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function setupPublicWorkspace(workspaceRoot, { accessMode = 'public', withRead = false } = {}) {
|
||||
const service = createUserDataSpaceService({ workspaceRoot });
|
||||
await service.executeSql(`CREATE TABLE signups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
phone TEXT,
|
||||
status TEXT DEFAULT 'pending',
|
||||
created_at TEXT,
|
||||
deleted_at TEXT,
|
||||
deleted_by TEXT,
|
||||
updated_at TEXT,
|
||||
updated_by_label TEXT
|
||||
);`);
|
||||
await service.upsertDataset({
|
||||
name: 'signups',
|
||||
table: 'signups',
|
||||
actions: ['read', 'insert', 'update', 'soft_delete'],
|
||||
columns: {
|
||||
read: ['id', 'name', 'phone', 'status', 'created_at'],
|
||||
insert: ['name', 'phone', 'status'],
|
||||
update: ['status'],
|
||||
soft_delete: ['id'],
|
||||
},
|
||||
});
|
||||
writePageAccessPolicy(workspaceRoot, {
|
||||
pageId: PAGE_ID,
|
||||
ownerUserId: OWNER_ID,
|
||||
accessMode,
|
||||
datasets: {
|
||||
signups: {
|
||||
read: withRead,
|
||||
insert: true,
|
||||
update: withRead,
|
||||
softDelete: withRead,
|
||||
columns: {
|
||||
read: ['id', 'name', 'phone', 'status', 'created_at'],
|
||||
insert: ['name', 'phone', 'status'],
|
||||
update: ['status'],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return service;
|
||||
}
|
||||
|
||||
function createPublicService(workspaceRoot, poolOptions = {}) {
|
||||
return createPageDataPublicService({
|
||||
getPool: () => createPublicationPool(poolOptions),
|
||||
resolveWorkspaceRootForOwner: () => workspaceRoot,
|
||||
});
|
||||
}
|
||||
|
||||
test('public page insert works without login for public access mode', async () => {
|
||||
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-public-'));
|
||||
await setupPublicWorkspace(workspaceRoot);
|
||||
const service = createPublicService(workspaceRoot, { accessMode: 'public' });
|
||||
const result = await service.insertRow(PAGE_ID, 'signups', {
|
||||
ip: '127.0.0.1',
|
||||
headers: { 'user-agent': 'test' },
|
||||
}, {
|
||||
name: '匿名用户',
|
||||
phone: '13900000000',
|
||||
});
|
||||
assert.equal(result.row.name, '匿名用户');
|
||||
});
|
||||
|
||||
test('public page read is denied in public access mode by default', async () => {
|
||||
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-public-read-'));
|
||||
await setupPublicWorkspace(workspaceRoot);
|
||||
const service = createPublicService(workspaceRoot, { accessMode: 'public' });
|
||||
await assert.rejects(
|
||||
() => service.listRows(PAGE_ID, 'signups', { headers: {} }),
|
||||
(error) => error.code === 'action_not_allowed',
|
||||
);
|
||||
});
|
||||
|
||||
test('password page data-auth returns token and enables read', async () => {
|
||||
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-password-'));
|
||||
const ownerService = await setupPublicWorkspace(workspaceRoot, {
|
||||
accessMode: 'password',
|
||||
withRead: true,
|
||||
});
|
||||
await ownerService.insertRowForDataset(ownerService.getDataset('signups'), {
|
||||
name: '协作项',
|
||||
phone: '13800000001',
|
||||
});
|
||||
|
||||
const service = createPublicService(workspaceRoot, {
|
||||
accessMode: 'password',
|
||||
password: 'team-2026',
|
||||
});
|
||||
const auth = await service.authenticate(PAGE_ID, 'team-2026', { ip: '127.0.0.1' });
|
||||
assert.ok(auth.token);
|
||||
|
||||
const rows = await service.listRows(PAGE_ID, 'signups', {
|
||||
ip: '127.0.0.1',
|
||||
headers: { 'x-page-data-token': auth.token },
|
||||
});
|
||||
assert.equal(rows.rows.length, 1);
|
||||
assert.equal(rows.rows[0].name, '协作项');
|
||||
});
|
||||
|
||||
test('password page update and soft delete require token', async () => {
|
||||
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-password-mut-'));
|
||||
const ownerService = await setupPublicWorkspace(workspaceRoot, {
|
||||
accessMode: 'password',
|
||||
withRead: true,
|
||||
});
|
||||
const inserted = await ownerService.insertRowForDataset(ownerService.getDataset('signups'), {
|
||||
name: '待更新',
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
const service = createPublicService(workspaceRoot, {
|
||||
accessMode: 'password',
|
||||
password: 'team-2026',
|
||||
});
|
||||
const auth = await service.authenticate(PAGE_ID, 'team-2026', { ip: '127.0.0.1' });
|
||||
const req = {
|
||||
ip: '127.0.0.1',
|
||||
headers: { 'x-page-data-token': auth.token },
|
||||
body: { updated_by_label: '测试员' },
|
||||
};
|
||||
|
||||
const updated = await service.updateRow(PAGE_ID, 'signups', inserted.row.id, req, {
|
||||
status: 'done',
|
||||
});
|
||||
assert.equal(updated.row.status, 'done');
|
||||
|
||||
const deleted = await service.softDeleteRow(PAGE_ID, 'signups', inserted.row.id, req);
|
||||
assert.equal(deleted.deleted, true);
|
||||
});
|
||||
|
||||
test('isPageDataPublicPath allows public page data routes without auth', () => {
|
||||
assert.equal(isPageDataPublicPath('/public/pages/page-1/data/signups/rows', 'POST'), true);
|
||||
assert.equal(isPageDataPublicPath('/public/pages/page-1/data-auth', 'POST'), true);
|
||||
assert.equal(isPageDataPublicPath('/public/pages/page-1/data/signups', 'GET'), true);
|
||||
assert.equal(isPageDataPublicPath('/page-data/signups', 'GET'), false);
|
||||
});
|
||||
Reference in New Issue
Block a user