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
8.0 KiB
JavaScript
227 lines
8.0 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 } from './page-data-routes.mjs';
|
|
import { createPageDataService } from './page-data-service.mjs';
|
|
import { createPageDataPublicService } from './page-data-public-service.mjs';
|
|
import { createPageDataSessionStore } from './page-data-session-store.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-ops-1';
|
|
const OWNER_ID = 'user-ops-1';
|
|
const OLD_PASSWORD = 'old-pass-123';
|
|
const NEW_PASSWORD = 'new-pass-456';
|
|
|
|
function createPool({ accessMode = 'password', password = OLD_PASSWORD, onUpdate } = {}) {
|
|
const passwordHash = password ? publicationInternals.hashPassword(password) : null;
|
|
const state = {
|
|
id: 'pub-ops-1',
|
|
user_id: OWNER_ID,
|
|
page_id: PAGE_ID,
|
|
access_mode: accessMode,
|
|
password_hash: passwordHash,
|
|
status: 'online',
|
|
expires_at: null,
|
|
};
|
|
return {
|
|
async query(sql, params) {
|
|
if (sql.includes('FROM h5_publish_records') && sql.includes('WHERE pr.page_id')) {
|
|
return [[{ ...state }]];
|
|
}
|
|
if (sql.startsWith('UPDATE h5_publish_records SET password_hash')) {
|
|
state.password_hash = params[0];
|
|
onUpdate?.(params);
|
|
return [{ affectedRows: 1 }];
|
|
}
|
|
return [[]];
|
|
},
|
|
};
|
|
}
|
|
|
|
async function setupWorkspace(workspaceRoot) {
|
|
const service = createUserDataSpaceService({ workspaceRoot });
|
|
await service.executeSql(`CREATE TABLE signups (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
phone TEXT,
|
|
created_at TEXT
|
|
);`);
|
|
await service.upsertDataset({
|
|
name: 'signups',
|
|
table: 'signups',
|
|
actions: ['read', 'insert'],
|
|
columns: {
|
|
read: ['id', 'name', 'phone', 'created_at'],
|
|
insert: ['name', 'phone'],
|
|
},
|
|
});
|
|
writePageAccessPolicy(workspaceRoot, {
|
|
pageId: PAGE_ID,
|
|
ownerUserId: OWNER_ID,
|
|
accessMode: 'password',
|
|
datasets: {
|
|
signups: {
|
|
read: true,
|
|
insert: true,
|
|
columns: {
|
|
read: ['id', 'name', 'phone', 'created_at'],
|
|
insert: ['name', 'phone'],
|
|
},
|
|
},
|
|
},
|
|
});
|
|
return service;
|
|
}
|
|
|
|
function buildApp(workspaceRoot, pool, sessionStore) {
|
|
const pageDataPublicService = createPageDataPublicService({
|
|
getPool: () => pool,
|
|
resolveWorkspaceRootForOwner: () => workspaceRoot,
|
|
sessionStore,
|
|
});
|
|
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.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('ops overview returns datasets stats logs and active sessions', async () => {
|
|
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-ops-overview-'));
|
|
const ownerService = await setupWorkspace(workspaceRoot);
|
|
await ownerService.insertDatasetRow('signups', { name: '概览项', phone: '13800000000' });
|
|
const sessionStore = createPageDataSessionStore();
|
|
sessionStore.issue({
|
|
pageId: PAGE_ID,
|
|
ownerUserId: OWNER_ID,
|
|
publicationId: 'pub-ops-1',
|
|
accessMode: 'password',
|
|
});
|
|
const app = buildApp(workspaceRoot, createPool(), sessionStore);
|
|
|
|
await request(app, 'POST', `/api/public/pages/${PAGE_ID}/data/signups/rows`, {
|
|
body: { name: '公开提交', phone: '13800000001' },
|
|
headers: { 'x-page-data-token': (await request(app, 'POST', `/api/public/pages/${PAGE_ID}/data-auth`, { body: { password: OLD_PASSWORD } })).body.data.token },
|
|
});
|
|
|
|
const overview = await request(app, 'GET', `/api/page-data/policies/${PAGE_ID}/ops`, {
|
|
headers: { 'x-test-user': '1' },
|
|
});
|
|
assert.equal(overview.status, 200);
|
|
assert.equal(overview.body.data.datasets.length, 1);
|
|
assert.equal(overview.body.data.datasets[0].stats.total, 2);
|
|
assert.ok(overview.body.data.activeSessionCount >= 1);
|
|
assert.ok(overview.body.data.recentLogs.length >= 1);
|
|
});
|
|
|
|
test('owner can revoke all tokens and reset password', async () => {
|
|
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-ops-reset-'));
|
|
await setupWorkspace(workspaceRoot);
|
|
const sessionStore = createPageDataSessionStore();
|
|
const auth1 = sessionStore.issue({
|
|
pageId: PAGE_ID,
|
|
ownerUserId: OWNER_ID,
|
|
publicationId: 'pub-ops-1',
|
|
accessMode: 'password',
|
|
});
|
|
const auth2 = sessionStore.issue({
|
|
pageId: PAGE_ID,
|
|
ownerUserId: OWNER_ID,
|
|
publicationId: 'pub-ops-1',
|
|
accessMode: 'password',
|
|
});
|
|
let updatedPasswordHash = null;
|
|
const app = buildApp(workspaceRoot, createPool({ onUpdate: ([hash]) => { updatedPasswordHash = hash; } }), sessionStore);
|
|
|
|
const revoked = await request(app, 'POST', `/api/page-data/policies/${PAGE_ID}/tokens/revoke`, {
|
|
headers: { 'x-test-user': '1' },
|
|
body: { revokeAll: true },
|
|
});
|
|
assert.equal(revoked.status, 200);
|
|
assert.equal(revoked.body.data.revokedCount, 2);
|
|
assert.equal(sessionStore.verify(auth1.token), null);
|
|
assert.equal(sessionStore.verify(auth2.token), null);
|
|
|
|
const reset = await request(app, 'POST', `/api/page-data/policies/${PAGE_ID}/password/reset`, {
|
|
headers: { 'x-test-user': '1' },
|
|
body: { password: NEW_PASSWORD },
|
|
});
|
|
assert.equal(reset.status, 200);
|
|
assert.equal(reset.body.data.passwordReset, true);
|
|
assert.ok(updatedPasswordHash);
|
|
assert.ok(publicationInternals.verifyPassword(NEW_PASSWORD, updatedPasswordHash));
|
|
|
|
const oldAuthDenied = await request(app, 'POST', `/api/public/pages/${PAGE_ID}/data-auth`, {
|
|
body: { password: OLD_PASSWORD },
|
|
});
|
|
assert.equal(oldAuthDenied.status, 401);
|
|
|
|
const newAuth = await request(app, 'POST', `/api/public/pages/${PAGE_ID}/data-auth`, {
|
|
body: { password: NEW_PASSWORD },
|
|
});
|
|
assert.equal(newAuth.status, 200);
|
|
assert.ok(newAuth.body.data.token);
|
|
});
|
|
|
|
test('owner can close dataset and public insert is denied afterwards', async () => {
|
|
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-ops-close-'));
|
|
await setupWorkspace(workspaceRoot);
|
|
const sessionStore = createPageDataSessionStore();
|
|
const app = buildApp(workspaceRoot, createPool(), sessionStore);
|
|
const auth = await request(app, 'POST', `/api/public/pages/${PAGE_ID}/data-auth`, {
|
|
body: { password: OLD_PASSWORD },
|
|
});
|
|
|
|
const closed = await request(app, 'POST', `/api/page-data/policies/${PAGE_ID}/datasets/signups/close`, {
|
|
headers: { 'x-test-user': '1' },
|
|
});
|
|
assert.equal(closed.status, 200);
|
|
assert.equal(closed.body.data.closed, true);
|
|
assert.equal(closed.body.data.policyDataset.closed, true);
|
|
|
|
const denied = await request(app, 'POST', `/api/public/pages/${PAGE_ID}/data/signups/rows`, {
|
|
headers: { 'x-page-data-token': auth.body.data.token },
|
|
body: { name: '关闭后提交', phone: '13800000002' },
|
|
});
|
|
assert.equal(denied.status, 403);
|
|
assert.equal(denied.body.error.code, 'action_not_allowed');
|
|
});
|