Files
memind/page-data-public-service.test.mjs
T

581 lines
20 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 { createPageDataPublicService, isPageDataPublicPath } from './page-data-public-service.mjs';
import { createUserDataSpaceService } from './user-data-space-service.mjs';
import { writePageAccessPolicy, readPageAccessPolicy } from './page-data-policy-store.mjs';
import { publicationInternals } from './mindspace-publications.mjs';
import { createPageDataSessionStore } from './page-data-session-store.mjs';
const PAGE_ID = 'page-public-1';
const OWNER_ID = 'user-owner-1';
function createPublicationPool({
accessMode = 'public',
password = null,
pageId = PAGE_ID,
expiresAt = null,
} = {}) {
const passwordHash = password ? publicationInternals.hashPassword(password) : null;
return {
async query(sql, params = []) {
if (sql.includes('FROM h5_publish_records')) {
if (params[0] !== pageId) return [[]];
return [
[
{
id: 'pub-record-1',
user_id: OWNER_ID,
page_id: pageId,
access_mode: accessMode,
password_hash: passwordHash,
status: 'online',
expires_at: expiresAt,
},
],
];
}
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 = {}, serviceOptions = {}) {
return createPageDataPublicService({
getPool: () => createPublicationPool(poolOptions),
resolveWorkspaceRootForOwner: () => workspaceRoot,
...serviceOptions,
});
}
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('public page read works when policy explicitly enables read', async () => {
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-public-read-ok-'));
const ownerService = await setupPublicWorkspace(workspaceRoot, { accessMode: 'public', withRead: true });
await ownerService.insertRowForDataset(ownerService.getDataset('signups'), {
name: '公开可读',
phone: '13900000002',
});
const service = createPublicService(workspaceRoot, { accessMode: 'public' });
const rows = await service.listRows(PAGE_ID, 'signups', { headers: {} });
assert.equal(rows.rows.length, 1);
assert.equal(rows.rows[0].name, '公开可读');
});
test('login_required page read requires login and works for authenticated visitor', async () => {
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-login-required-'));
const ownerService = await setupPublicWorkspace(workspaceRoot, {
accessMode: 'login_required',
withRead: true,
});
await ownerService.insertRowForDataset(ownerService.getDataset('signups'), {
name: '登录可见',
phone: '13800000002',
});
const service = createPublicService(workspaceRoot, { accessMode: 'login_required' });
await assert.rejects(
() => service.listRows(PAGE_ID, 'signups', { headers: {} }),
(error) => error.code === 'auth_required',
);
const rows = await service.listRows(PAGE_ID, 'signups', {
currentUser: { id: 'visitor-1' },
headers: {},
});
assert.equal(rows.rows.length, 1);
assert.equal(rows.rows[0].name, '登录可见');
});
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('password page rejects missing, invalid, cross-page, and expired tokens', async () => {
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-password-token-'));
await setupPublicWorkspace(workspaceRoot, {
accessMode: 'password',
withRead: true,
});
const sessionStore = createPageDataSessionStore({ ttlMs: 60_000 });
const service = createPublicService(
workspaceRoot,
{ accessMode: 'password', password: 'team-2026' },
{ sessionStore },
);
for (const headers of [{}, { 'x-page-data-token': 'invalid-token' }]) {
await assert.rejects(
() => service.listRows(PAGE_ID, 'signups', { headers }),
(error) => error.code === 'token_invalid' && error.status === 401,
);
}
const crossPage = sessionStore.issue({
pageId: 'page-other',
ownerUserId: OWNER_ID,
publicationId: 'pub-other',
accessMode: 'password',
});
await assert.rejects(
() =>
service.listRows(PAGE_ID, 'signups', {
headers: { 'x-page-data-token': crossPage.token },
}),
(error) => error.code === 'token_invalid' && error.status === 401,
);
const expiredStore = createPageDataSessionStore({ ttlMs: -1 });
const expired = expiredStore.issue({
pageId: PAGE_ID,
ownerUserId: OWNER_ID,
publicationId: 'pub-expired',
accessMode: 'password',
});
const expiredService = createPublicService(
workspaceRoot,
{ accessMode: 'password', password: 'team-2026' },
{ sessionStore: expiredStore },
);
await assert.rejects(
() =>
expiredService.listRows(PAGE_ID, 'signups', {
headers: { 'x-page-data-token': expired.token },
}),
(error) => error.code === 'token_invalid' && error.status === 401,
);
});
test('public access never allows update or delete even when policy contains those actions', async () => {
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-public-mutation-'));
const ownerService = await setupPublicWorkspace(workspaceRoot, {
accessMode: 'public',
withRead: true,
});
const inserted = await ownerService.insertRowForDataset(ownerService.getDataset('signups'), {
name: '不可匿名修改',
status: 'pending',
});
const service = createPublicService(workspaceRoot, { accessMode: 'public' });
const req = { ip: '127.0.0.1', headers: {}, body: {} };
await assert.rejects(
() =>
service.updateRow(PAGE_ID, 'signups', inserted.row.id, req, {
status: 'done',
}),
(error) => error.code === 'action_not_allowed' && error.status === 403,
);
await assert.rejects(
() => service.deleteRow(PAGE_ID, 'signups', inserted.row.id, req),
(error) => error.code === 'action_not_allowed' && error.status === 403,
);
});
test('expired, unknown, and invalid-policy publications fail closed', async () => {
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-public-fail-closed-'));
await setupPublicWorkspace(workspaceRoot, { accessMode: 'public', withRead: true });
const expiredService = createPublicService(workspaceRoot, {
accessMode: 'public',
expiresAt: Date.now() - 1,
});
await assert.rejects(
() => expiredService.listRows(PAGE_ID, 'signups', { headers: {} }),
(error) => error.code === 'publication_not_found' && error.status === 404,
);
const service = createPublicService(workspaceRoot, { accessMode: 'public' });
await assert.rejects(
() => service.listRows('page-missing', 'signups', { headers: {} }),
(error) => error.code === 'publication_not_found' && error.status === 404,
);
writePageAccessPolicy(workspaceRoot, {
pageId: PAGE_ID,
ownerUserId: 'different-owner',
accessMode: 'public',
datasets: {
signups: {
read: true,
columns: { read: ['id', 'name'] },
},
},
});
await assert.rejects(
() => service.listRows(PAGE_ID, 'signups', { headers: {} }),
(error) => error.code === 'invalid_policy' && error.status === 400,
);
});
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);
assert.equal(isPageDataPublicPath('/public/pages/page-1/data/signups', 'POST'), false);
assert.equal(isPageDataPublicPath('/public/pages/page-1/data/signups/rows', 'GET'), false);
assert.equal(isPageDataPublicPath('/public/pages/page-1/dataevil/signups', 'GET'), false);
assert.equal(isPageDataPublicPath('/public/pages/page-1/data-auth', 'GET'), false);
assert.equal(isPageDataPublicPath('/admin/page-data/signups', 'GET'), false);
});
async function setupCollaborationWorkspace(workspaceRoot) {
const service = createUserDataSpaceService({ workspaceRoot });
await service.executeSql(`CREATE TABLE notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
status TEXT DEFAULT 'open',
created_by_user_id TEXT,
created_at TEXT,
deleted_at TEXT,
deleted_by TEXT,
updated_at TEXT,
updated_by_label TEXT
);`);
await service.upsertDataset({
name: 'notes',
table: 'notes',
actions: ['read', 'insert', 'update', 'soft_delete'],
columns: {
read: ['id', 'title', 'status', 'created_by_user_id', 'created_at'],
insert: ['title'],
update: ['status'],
soft_delete: ['id'],
},
});
writePageAccessPolicy(workspaceRoot, {
pageId: PAGE_ID,
ownerUserId: OWNER_ID,
accessMode: 'login_required',
defaultVisitorRole: 'deny',
visitors: {
'editor-a': 'editor',
'viewer-a': 'viewer',
},
datasets: {
notes: {
read: true,
insert: true,
update: true,
softDelete: true,
rowPolicy: { scope: 'own_rows', ownerColumn: 'created_by_user_id' },
columns: {
read: ['id', 'title', 'status', 'created_by_user_id', 'created_at'],
insert: ['title'],
update: ['status'],
},
},
},
});
return service;
}
test('login_required editor can insert and update only own_rows', async () => {
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-role-editor-'));
const ownerService = await setupCollaborationWorkspace(workspaceRoot);
const service = createPublicService(workspaceRoot, { accessMode: 'login_required' });
const inserted = await service.insertRow(
PAGE_ID,
'notes',
{ currentUser: { id: 'editor-a' }, ip: '127.0.0.1', headers: {} },
{ title: '编辑者 A 的笔记' },
);
assert.equal(inserted.row.title, '编辑者 A 的笔记');
const insertedB = await ownerService.insertRowForDataset(ownerService.getDataset('notes'), {
title: '编辑者 B 的笔记',
});
await ownerService.executeSql(
`UPDATE notes SET created_by_user_id = 'editor-b' WHERE id = ${insertedB.row.id};`,
);
const rowsA = await service.listRows(PAGE_ID, 'notes', {
currentUser: { id: 'editor-a' },
headers: {},
});
assert.equal(rowsA.rows.length, 1);
assert.equal(rowsA.rows[0].title, '编辑者 A 的笔记');
const updated = await service.updateRow(
PAGE_ID,
'notes',
inserted.row.id,
{ currentUser: { id: 'editor-a' }, ip: '127.0.0.1', headers: {}, body: {} },
{ status: 'done' },
);
assert.equal(updated.row.status, 'done');
await assert.rejects(
() =>
service.updateRow(
PAGE_ID,
'notes',
insertedB.row.id,
{ currentUser: { id: 'editor-a' }, ip: '127.0.0.1', headers: {}, body: {} },
{ status: 'hijacked' },
),
(error) => error.code === 'row_not_allowed',
);
});
test('login_required viewer can read but cannot insert', async () => {
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-role-viewer-'));
const ownerService = await setupCollaborationWorkspace(workspaceRoot);
await ownerService.executeSql(
`INSERT INTO notes (title, created_by_user_id, created_at) VALUES ('owner seed', '${OWNER_ID}', datetime('now'));`,
);
const service = createPublicService(workspaceRoot, { accessMode: 'login_required' });
const rows = await service.listRows(PAGE_ID, 'notes', {
currentUser: { id: 'viewer-a' },
headers: {},
});
assert.equal(rows.rows.length, 0);
await assert.rejects(
() =>
service.insertRow(
PAGE_ID,
'notes',
{ currentUser: { id: 'viewer-a' }, ip: '127.0.0.1', headers: {} },
{ title: 'viewer insert' },
),
(error) => error.code === 'action_not_allowed',
);
});
test('login_required denies visitors not listed when defaultVisitorRole is deny', async () => {
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-role-deny-'));
await setupCollaborationWorkspace(workspaceRoot);
const service = createPublicService(workspaceRoot, { accessMode: 'login_required' });
await assert.rejects(
() =>
service.listRows(PAGE_ID, 'notes', {
currentUser: { id: 'stranger-1' },
headers: {},
}),
(error) => error.code === 'forbidden',
);
});
test('public insert requires captcha when turnstile secret is configured', async () => {
const previousSecret = process.env.PAGE_DATA_TURNSTILE_SECRET;
process.env.PAGE_DATA_TURNSTILE_SECRET = 'test-secret';
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-captcha-required-'));
await setupPublicWorkspace(workspaceRoot);
const service = createPublicService(workspaceRoot, { accessMode: 'public' });
try {
await assert.rejects(
() =>
service.insertRow(PAGE_ID, 'signups', { ip: '127.0.0.1', headers: {} }, {
name: '匿名用户',
phone: '13900000000',
}),
(error) => error.code === 'captcha_required',
);
} finally {
if (previousSecret == null) delete process.env.PAGE_DATA_TURNSTILE_SECRET;
else process.env.PAGE_DATA_TURNSTILE_SECRET = previousSecret;
}
});
test('public insert accepts verified captcha token', async () => {
const previousSecret = process.env.PAGE_DATA_TURNSTILE_SECRET;
const previousFetch = globalThis.fetch;
process.env.PAGE_DATA_TURNSTILE_SECRET = 'test-secret';
globalThis.fetch = async () => ({
ok: true,
async json() {
return { success: true };
},
});
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-captcha-ok-'));
await setupPublicWorkspace(workspaceRoot);
const service = createPublicService(workspaceRoot, { accessMode: 'public' });
try {
const result = await service.insertRow(
PAGE_ID,
'signups',
{ ip: '127.0.0.1', headers: { 'x-page-data-captcha': 'turnstile-token' } },
{ name: '验证码用户', phone: '13900000001' },
);
assert.equal(result.row.name, '验证码用户');
} finally {
globalThis.fetch = previousFetch;
if (previousSecret == null) delete process.env.PAGE_DATA_TURNSTILE_SECRET;
else process.env.PAGE_DATA_TURNSTILE_SECRET = previousSecret;
}
});
test('saveOwnerPolicyFromPublish writes policy for published page', async () => {
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-apply-publish-'));
await setupPublicWorkspace(workspaceRoot, { accessMode: 'password', withRead: true });
const service = createPublicService(workspaceRoot, { accessMode: 'password' });
const user = { id: OWNER_ID, workspaceRoot };
const policy = await service.saveOwnerPolicyFromPublish(user, PAGE_ID, {
datasetName: 'signups',
capabilities: {
read: true,
insert: true,
update: false,
softDelete: false,
},
});
assert.equal(policy.pageId, PAGE_ID);
assert.equal(policy.accessMode, 'password');
assert.equal(policy.datasets.signups.read, true);
assert.equal(policy.datasets.signups.insert, true);
const saved = readPageAccessPolicy(workspaceRoot, PAGE_ID);
assert.equal(saved.datasets.signups.read, true);
});
test('saveOwnerPolicy rejects unregistered datasets without overwriting the current policy', async () => {
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-owner-policy-guard-'));
await setupPublicWorkspace(workspaceRoot, { accessMode: 'password', withRead: true });
const service = createPublicService(workspaceRoot, { accessMode: 'password' });
const user = { id: OWNER_ID, workspaceRoot };
await assert.rejects(
() =>
service.saveOwnerPolicy(user, PAGE_ID, {
accessMode: 'password',
datasets: {
split_crud_uat_20260724: {
read: true,
insert: true,
columns: { read: ['id'], insert: ['title'] },
},
},
}),
(error) => error.code === 'dataset_not_found' && error.status === 404,
);
const saved = readPageAccessPolicy(workspaceRoot, PAGE_ID);
assert.deepEqual(Object.keys(saved.datasets), ['signups']);
});