refactor: modularize portal server and harden session recovery
This commit is contained in:
@@ -7,25 +7,32 @@ import { createPageDataPublicService, isPageDataPublicPath } from './page-data-p
|
||||
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 } = {}) {
|
||||
function createPublicationPool({
|
||||
accessMode = 'public',
|
||||
password = null,
|
||||
pageId = PAGE_ID,
|
||||
expiresAt = null,
|
||||
} = {}) {
|
||||
const passwordHash = password ? publicationInternals.hashPassword(password) : null;
|
||||
return {
|
||||
async query(sql) {
|
||||
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: PAGE_ID,
|
||||
page_id: pageId,
|
||||
access_mode: accessMode,
|
||||
password_hash: passwordHash,
|
||||
status: 'online',
|
||||
expires_at: null,
|
||||
expires_at: expiresAt,
|
||||
},
|
||||
],
|
||||
];
|
||||
@@ -80,10 +87,11 @@ async function setupPublicWorkspace(workspaceRoot, { accessMode = 'public', with
|
||||
return service;
|
||||
}
|
||||
|
||||
function createPublicService(workspaceRoot, poolOptions = {}) {
|
||||
function createPublicService(workspaceRoot, poolOptions = {}, serviceOptions = {}) {
|
||||
return createPageDataPublicService({
|
||||
getPool: () => createPublicationPool(poolOptions),
|
||||
resolveWorkspaceRootForOwner: () => workspaceRoot,
|
||||
...serviceOptions,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -206,11 +214,133 @@ test('password page update and soft delete require token', async () => {
|
||||
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) {
|
||||
@@ -423,3 +553,28 @@ test('saveOwnerPolicyFromPublish writes policy for published page', async () =>
|
||||
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']);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user