6b0c633a75
Add visitor roles, row-level scope, owner ops APIs, MySQL policy index, Turnstile captcha, browser client SDK, publish-panel dataset binding, acceptance tests, and usage documentation. Co-authored-by: Cursor <cursoragent@cursor.com>
227 lines
7.7 KiB
JavaScript
227 lines
7.7 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 { createUserDataSpaceService } from './user-data-space-service.mjs';
|
|
|
|
function createTempWorkspace() {
|
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'user-data-space-'));
|
|
}
|
|
|
|
test('createUserDataSpaceService supports agent SQL and dataset registry', async () => {
|
|
const workspaceRoot = createTempWorkspace();
|
|
const service = createUserDataSpaceService({ workspaceRoot });
|
|
|
|
await service.executeSql(`CREATE TABLE form_registrations (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
phone TEXT,
|
|
status TEXT DEFAULT 'pending',
|
|
note TEXT,
|
|
created_at TEXT,
|
|
deleted_at TEXT
|
|
);`);
|
|
|
|
const dataset = await service.upsertDataset({
|
|
name: 'registrations',
|
|
table: 'form_registrations',
|
|
description: '活动报名数据',
|
|
actions: ['read', 'insert'],
|
|
columns: {
|
|
read: ['id', 'name', 'phone', 'status', 'note', 'created_at'],
|
|
insert: ['name', 'phone', 'status', 'note'],
|
|
},
|
|
limits: { maxRowsPerRead: 50, maxInsertBytes: 4096 },
|
|
});
|
|
|
|
assert.equal(dataset.name, 'registrations');
|
|
assert.deepEqual(dataset.actions, ['read', 'insert']);
|
|
|
|
const inserted = await service.insertDatasetRow('registrations', {
|
|
name: '张三',
|
|
phone: '13800000000',
|
|
status: 'pending',
|
|
note: '第一场',
|
|
});
|
|
assert.equal(inserted.row.name, '张三');
|
|
|
|
const listed = service.readDatasetRows('registrations', { limit: 10 });
|
|
assert.equal(listed.rows.length, 1);
|
|
assert.equal(listed.rows[0].phone, '13800000000');
|
|
|
|
const stats = service.getDatasetStats('registrations');
|
|
assert.equal(stats.total, 1);
|
|
|
|
const schema = service.getDatasetSchema('registrations');
|
|
assert.equal(schema.dataset.name, 'registrations');
|
|
assert.ok(schema.tableColumns.some((column) => column.name === 'name'));
|
|
});
|
|
|
|
test('dataset insert rejects unauthorized columns', async () => {
|
|
const workspaceRoot = createTempWorkspace();
|
|
const service = createUserDataSpaceService({ workspaceRoot });
|
|
|
|
await service.executeSql(`CREATE TABLE secrets (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
secret_code TEXT
|
|
);`);
|
|
|
|
await service.upsertDataset({
|
|
name: 'public_names',
|
|
table: 'secrets',
|
|
actions: ['insert'],
|
|
columns: { insert: ['name'] },
|
|
});
|
|
|
|
await assert.rejects(
|
|
() => service.insertDatasetRow('public_names', { name: '公开项', secret_code: 'hidden' }),
|
|
(error) => error.code === 'columns_not_allowed',
|
|
);
|
|
});
|
|
|
|
test('dataset read rejects missing dataset', () => {
|
|
const workspaceRoot = createTempWorkspace();
|
|
const service = createUserDataSpaceService({ workspaceRoot });
|
|
|
|
assert.throws(
|
|
() => service.readDatasetRows('missing'),
|
|
(error) => error.code === 'dataset_not_found',
|
|
);
|
|
});
|
|
|
|
test('rejectDangerousSql blocks attach and dot commands', async () => {
|
|
const workspaceRoot = createTempWorkspace();
|
|
const service = createUserDataSpaceService({ workspaceRoot });
|
|
|
|
await assert.rejects(
|
|
() => service.querySql("ATTACH DATABASE '/tmp/other.sqlite' AS other; SELECT 1"),
|
|
/不允许/,
|
|
);
|
|
await assert.rejects(() => service.executeSql('.open /tmp/other.sqlite'), /dot command/);
|
|
});
|
|
|
|
test('user-data-space-service supports update and soft delete', async () => {
|
|
const workspaceRoot = createTempWorkspace();
|
|
const service = createUserDataSpaceService({ workspaceRoot });
|
|
await service.executeSql(`CREATE TABLE tasks (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
title TEXT NOT NULL,
|
|
status TEXT DEFAULT 'open',
|
|
updated_at TEXT,
|
|
updated_by_label TEXT,
|
|
deleted_at TEXT,
|
|
deleted_by TEXT
|
|
);`);
|
|
await service.upsertDataset({
|
|
name: 'tasks',
|
|
table: 'tasks',
|
|
actions: ['read', 'insert', 'update', 'soft_delete'],
|
|
columns: {
|
|
read: ['id', 'title', 'status', 'updated_at'],
|
|
insert: ['title', 'status'],
|
|
update: ['status'],
|
|
soft_delete: ['id'],
|
|
},
|
|
});
|
|
const inserted = await service.insertDatasetRow('tasks', { title: '整理资料', status: 'open' });
|
|
const updated = await service.updateRowForDataset(service.getDataset('tasks'), inserted.row.id, {
|
|
status: 'done',
|
|
}, { updatedByLabel: 'owner' });
|
|
assert.equal(updated.row.status, 'done');
|
|
const deleted = await service.softDeleteRowForDataset(service.getDataset('tasks'), inserted.row.id, {
|
|
deletedBy: 'owner',
|
|
});
|
|
assert.equal(deleted.deleted, true);
|
|
const listed = service.readDatasetRows('tasks', { limit: 10 });
|
|
assert.equal(listed.rows.length, 0);
|
|
});
|
|
|
|
test('exportDatasetRows supports json and csv output', async () => {
|
|
const workspaceRoot = createTempWorkspace();
|
|
const service = createUserDataSpaceService({ workspaceRoot });
|
|
await service.executeSql(`CREATE TABLE contacts (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
phone TEXT
|
|
);`);
|
|
await service.upsertDataset({
|
|
name: 'contacts',
|
|
table: 'contacts',
|
|
actions: ['read', 'insert'],
|
|
columns: {
|
|
read: ['id', 'name', 'phone'],
|
|
insert: ['name', 'phone'],
|
|
},
|
|
});
|
|
await service.insertDatasetRow('contacts', { name: '李四', phone: '13900000002' });
|
|
const json = service.exportDatasetRows('contacts', { format: 'json' });
|
|
assert.equal(json.rowCount, 1);
|
|
assert.equal(json.rows[0].name, '李四');
|
|
const csv = service.exportDatasetRows('contacts', { format: 'csv' });
|
|
assert.match(csv.content, /name,phone/);
|
|
assert.match(csv.content, /李四/);
|
|
});
|
|
|
|
test('restoreSoftDeletedRow clears deleted_at', async () => {
|
|
const workspaceRoot = createTempWorkspace();
|
|
const service = createUserDataSpaceService({ workspaceRoot });
|
|
await service.executeSql(`CREATE TABLE items (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
title TEXT NOT NULL,
|
|
deleted_at TEXT,
|
|
deleted_by TEXT
|
|
);`);
|
|
await service.upsertDataset({
|
|
name: 'items',
|
|
table: 'items',
|
|
actions: ['read', 'insert', 'soft_delete'],
|
|
columns: {
|
|
read: ['id', 'title', 'deleted_at'],
|
|
insert: ['title'],
|
|
soft_delete: ['id'],
|
|
},
|
|
});
|
|
const inserted = await service.insertDatasetRow('items', { title: '可恢复项' });
|
|
await service.softDeleteRowForDataset(service.getDataset('items'), inserted.row.id, { deletedBy: 'test' });
|
|
const hidden = service.readDatasetRows('items', { limit: 10 });
|
|
assert.equal(hidden.rows.length, 0);
|
|
const restored = await service.restoreSoftDeletedRow('items', inserted.row.id);
|
|
assert.equal(restored.restored, true);
|
|
const visible = service.readDatasetRows('items', { limit: 10 });
|
|
assert.equal(visible.rows.length, 1);
|
|
assert.equal(visible.rows[0].title, '可恢复项');
|
|
});
|
|
|
|
test('readRowsForDataset applies rowScope where clause', async () => {
|
|
const workspaceRoot = createTempWorkspace();
|
|
const service = createUserDataSpaceService({ workspaceRoot });
|
|
await service.executeSql(`CREATE TABLE scoped_items (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
title TEXT NOT NULL,
|
|
created_by_user_id TEXT
|
|
);`);
|
|
await service.upsertDataset({
|
|
name: 'scoped_items',
|
|
table: 'scoped_items',
|
|
actions: ['read', 'insert'],
|
|
columns: {
|
|
read: ['id', 'title', 'created_by_user_id'],
|
|
insert: ['title'],
|
|
},
|
|
});
|
|
await service.insertRowForDataset(service.getDataset('scoped_items'), { title: 'A' }, {
|
|
rowScope: { ownerColumn: 'created_by_user_id', visitorUserId: 'user-a' },
|
|
});
|
|
await service.insertRowForDataset(service.getDataset('scoped_items'), { title: 'B' }, {
|
|
rowScope: { ownerColumn: 'created_by_user_id', visitorUserId: 'user-b' },
|
|
});
|
|
const scoped = service.readRowsForDataset(service.getDataset('scoped_items'), {
|
|
limit: 10,
|
|
rowScope: { whereClause: `"created_by_user_id" = 'user-a'` },
|
|
});
|
|
assert.equal(scoped.rows.length, 1);
|
|
assert.equal(scoped.rows[0].title, 'A');
|
|
});
|