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']); });