fix: isolate ERP queues and force deletion by account
This commit is contained in:
1 parent
69ea6d2517
commit
d09b3032c0
9 files changed
+677
-97
No files matched your search
@@ -221,7 +221,7 @@ test('ordinary task access is enforced across reads, mutations, artifacts, event
|
||||
assert.match(body, /lockTaskForAccess\(/, `${mutation} uses the task access lock`);
|
||||
}
|
||||
assert.match(tasks, /async getTaskArtifact[\s\S]+assigned_user_id = \$4/);
|
||||
assert.match(tasks, /async eventsSince[\s\S]+t\.assigned_user_id = \$4/);
|
||||
assert.match(tasks, /async eventsSince[\s\S]+t\.assigned_user_id = \$3/);
|
||||
assert.match(tasks, /async getTaskInputHistory[\s\S]+actor_user_id/);
|
||||
assert.match(tasks, /connection\.organization_id = \$1[\s\S]+connection\.user_id = \$2[\s\S]+connection\.connection_id = \$3/);
|
||||
assert.match(tasks, /WHERE browser_connections\.user_id = EXCLUDED\.user_id/);
|
||||
@@ -238,10 +238,92 @@ test('ordinary task access is enforced across reads, mutations, artifacts, event
|
||||
assert.match(tasks, /pg_advisory_xact_lock/);
|
||||
assert.match(server, /tasks\.listTasksPage[\s\S]+access: contextFor\(session, request\)/);
|
||||
assert.match(server, /tasks\.getTaskArtifact[\s\S]+contextFor\(session, request\)/);
|
||||
assert.match(server, /tasks\.eventsSince\(session\.user\.organizationId, since, contextFor\(session, request\)\)/);
|
||||
assert.match(server, /tasks\.eventsSince\(session\.user\.organizationId, session\.user\.id, since\)/);
|
||||
});
|
||||
|
||||
test('operator UI exposes role-aware accounts, executive drill-through, original input, final output, and reversible archive', async () => {
|
||||
test('administrator visibility is isolated from executable events and plugin result routing', async () => {
|
||||
const [tasks, server, app] = await Promise.all([
|
||||
source('../src/task-service.ts'),
|
||||
source('../src/server.ts'),
|
||||
source('../../LianSyn-platform/app.js')
|
||||
]);
|
||||
const eventHistory = tasks.slice(tasks.indexOf('async eventsSince('));
|
||||
assert.match(eventHistory, /assignedUserId: string/);
|
||||
assert.match(eventHistory, /AND t\.assigned_user_id = \$3/);
|
||||
assert.doesNotMatch(eventHistory, /isTaskOwnerRestricted\(access\?\.role\)/);
|
||||
|
||||
const eventRoute = server.slice(
|
||||
server.indexOf("app.get('/api/events'"),
|
||||
server.indexOf('app.setErrorHandler')
|
||||
);
|
||||
assert.match(eventRoute, /event\.owner_user_id !== session\.user\.id/);
|
||||
assert.match(eventRoute, /command\.assigned_user_id !== session\.user\.id/);
|
||||
assert.match(eventRoute, /event: browser-command/);
|
||||
assert.match(eventRoute, /tasks\.eventsSince\(session\.user\.organizationId, session\.user\.id, since\)/);
|
||||
|
||||
const eventStream = app.slice(
|
||||
app.indexOf('function startRemoteEventStream()'),
|
||||
app.indexOf('function cacheRuntimeTask(')
|
||||
);
|
||||
assert.match(eventStream, /filter\(taskAssignedToCurrentAccount\)/);
|
||||
assert.match(eventStream, /executable_by=me/);
|
||||
assert.match(eventStream, /addEventListener\('browser-command'/);
|
||||
|
||||
const bridgeListener = app.slice(
|
||||
app.indexOf("window.addEventListener('message'"),
|
||||
app.indexOf('async function parseRawInstruction')
|
||||
);
|
||||
assert.match(bridgeListener, /TASK_RESULT_CHANGED/);
|
||||
assert.match(bridgeListener, /extensionTaskBelongsToCurrentAccount\(taskId\)/);
|
||||
const resultQueue = app.slice(
|
||||
app.indexOf('async function persistExtensionTaskResult('),
|
||||
app.indexOf('async function reconcileTaskReceipt(')
|
||||
);
|
||||
assert.match(resultQueue, /if \(!extensionTaskBelongsToCurrentAccount\(taskId\)\) return false/);
|
||||
assert.match(resultQueue, /!extensionTaskBelongsToCurrentAccount\(normalizedTaskId\)/);
|
||||
assert.match(resultQueue, /!knownTask \|\| !taskAssignedToCurrentAccount\(knownTask\)/);
|
||||
});
|
||||
|
||||
test('ERP browser claims serialize only the assigned account queue', async () => {
|
||||
const tasks = await source('../src/task-service.ts');
|
||||
const claim = tasks.slice(tasks.indexOf('async claimForBrowser('), tasks.indexOf('async recordExecutionResult('));
|
||||
assert.match(claim, /pg_advisory_xact_lock\([\s\S]+erp-account-queue:[\s\S]+context\.organizationId, context\.userId/);
|
||||
assert.doesNotMatch(claim, /SELECT id FROM organizations WHERE id = \$1 FOR UPDATE/);
|
||||
assert.match(claim, /WHERE t\.organization_id = \$1\s+AND t\.assigned_user_id = \$2[\s\S]+a\.status IN \('accepted', 'running'\)/);
|
||||
assert.match(claim, /WHERE organization_id = \$1\s+AND assigned_user_id = \$2\s+AND status = 'confirmed'/);
|
||||
assert.equal((claim.match(/assigned_user_id = \$2/g) || []).length, 2);
|
||||
assert.match(claim, /\[context\.organizationId, context\.userId\]/);
|
||||
});
|
||||
|
||||
test('force delete physically removes accessible tasks without the archive state gate', async () => {
|
||||
const [tasks, server] = await Promise.all([
|
||||
source('../src/task-service.ts'),
|
||||
source('../src/server.ts')
|
||||
]);
|
||||
const hardDelete = tasks.slice(tasks.indexOf('async hardDeleteTask('), tasks.indexOf('async cancelTask('));
|
||||
assert.match(hardDelete, /async hardDeleteTasks\(/);
|
||||
assert.match(hardDelete, /AND \(\$3::boolean = false OR assigned_user_id = \$4\)/);
|
||||
assert.match(hardDelete, /DELETE FROM outbox_events[\s\S]+aggregate_id = ANY\(\$2::text\[\]\)/);
|
||||
assert.match(hardDelete, /DELETE FROM tasks[\s\S]+WHERE id = ANY\(\$1::uuid\[\]\)/);
|
||||
assert.match(hardDelete, /task\.hard_deleted/);
|
||||
assert.match(hardDelete, /assigned_user_id: assignedUserId/);
|
||||
assert.match(hardDelete, /this\.notifyBrowserCommand\(command\)/);
|
||||
assert.match(hardDelete, /this\.artifactStore\.cleanup\(outcome\.artifacts\)/);
|
||||
assert.doesNotMatch(hardDelete, /task_archive_blocked|正在处理或等待 ERP 执行,不能归档/);
|
||||
const bulkDeleteRoute = server.slice(
|
||||
server.indexOf("app.post('/api/tasks/bulk-delete'"),
|
||||
server.indexOf("app.post('/api/tasks/bulk-archive'")
|
||||
);
|
||||
const singleDeleteRoute = server.slice(
|
||||
server.indexOf("app.delete('/api/tasks/:taskId'"),
|
||||
server.indexOf("app.post('/api/tasks/:taskId/archive'")
|
||||
);
|
||||
assert.match(bulkDeleteRoute, /tasks\.hardDeleteTasks/);
|
||||
assert.match(singleDeleteRoute, /tasks\.hardDeleteTask/);
|
||||
assert.doesNotMatch(`${bulkDeleteRoute}\n${singleDeleteRoute}`, /tasks\.archiveTask|tasks\.archiveTasks/);
|
||||
});
|
||||
|
||||
test('operator UI exposes role-aware accounts, executive drill-through, archive, and explicit permanent deletion', async () => {
|
||||
const [app, index, retention] = await Promise.all([
|
||||
source('../../LianSyn-platform/app.js'),
|
||||
source('../../LianSyn-platform/index.html'),
|
||||
@@ -273,6 +355,10 @@ test('operator UI exposes role-aware accounts, executive drill-through, original
|
||||
assert.match(index, /id="operationsDashboardDetail"/);
|
||||
assert.doesNotMatch(index, /平台运行全景|operations-dashboard-hero|OPERATIONS OVERVIEW|BUSINESS TRACE|指令操作历史/);
|
||||
assert.match(index, /id="historyArchiveInput"/);
|
||||
assert.match(index, /id="historyArchiveSelectedButton"/);
|
||||
assert.match(index, /id="historyDeleteSelectedButton"[^>]*>强制删除所选</);
|
||||
assert.match(index, /id="archiveTaskButton"/);
|
||||
assert.match(index, /id="deleteTaskButton"[^>]*>强制删除任务</);
|
||||
assert.match(app, /authUser\?\.role === 'admin'/);
|
||||
assert.doesNotMatch(app, /passwordChangeForced|must_change_password/);
|
||||
assert.match(app, /crypto\.randomUUID/);
|
||||
@@ -334,7 +420,11 @@ test('operator UI exposes role-aware accounts, executive drill-through, original
|
||||
assert.doesNotMatch(dashboardDetailRenderer, /任务生命周期|处理结果与技术上下文|renderTaskLifecycle|JSON\.stringify|task\.stage|parse_response|operation:/);
|
||||
assert.match(app, /\/api\/tasks\/\$\{encodeURIComponent\(taskId\)\}\/archive/);
|
||||
assert.match(app, /\/api\/tasks\/\$\{encodeURIComponent\(taskId\)\}\/restore/);
|
||||
assert.doesNotMatch(app, /sendToExtension\('DELETE_TASK'/);
|
||||
assert.match(app, /method: 'DELETE'/);
|
||||
assert.match(app, /sendToExtension\('DELETE_TASK'/);
|
||||
assert.match(app, /selectedTasks\.filter\(taskAssignedToCurrentAccount\)/);
|
||||
assert.match(app, /command\?\.target_user_id !== authUser\?\.id/);
|
||||
assert.match(app, /此操作不受“正在处理”或“等待 ERP 执行”状态限制/);
|
||||
assert.match(retention, /SET archived_at = now\(\)/);
|
||||
assert.doesNotMatch(retention, /DELETE FROM tasks/);
|
||||
assert.doesNotMatch(retention, /DELETE FROM audit_events/);
|
||||
@@ -363,6 +453,6 @@ test('account authorization editor uses a scroll-safe open layout without overri
|
||||
assert.match(openLayoutSource, /overflow:\s*visible/);
|
||||
assert.doesNotMatch(styles, /\.account-panel\s*\{\s*grid-template-rows:/);
|
||||
|
||||
assert.match(index, /styles\.css\?v=20260902-account-authorization-layout-1/);
|
||||
assert.match(index, /app\.js\?v=20260902-account-authorization-layout-1/);
|
||||
assert.match(index, /styles\.css\?v=20260902-account-routing-hard-delete-2/);
|
||||
assert.match(index, /app\.js\?v=20260902-account-routing-hard-delete-2/);
|
||||
});
|
||||
@@ -1231,8 +1231,8 @@ test('operator page has a login gate and uses the durable task API', async () =>
|
||||
const inpage = await readFile(new URL('../../chrome-extension/ltjt-order-assistant/inpage.js', import.meta.url), 'utf8');
|
||||
assert.match(index, /id="loginPanel"/);
|
||||
assert.match(index, /id="workbench"[^>]*hidden/);
|
||||
assert.match(index, /styles\.css\?v=20260902-account-authorization-layout-1/);
|
||||
assert.match(index, /app\.js\?v=20260902-account-authorization-layout-1/);
|
||||
assert.match(index, /styles\.css\?v=20260902-account-routing-hard-delete-2/);
|
||||
assert.match(index, /app\.js\?v=20260902-account-routing-hard-delete-2/);
|
||||
assert.match(index, /id="statusDetailsPopover"/);
|
||||
assert.match(index, /id="statusDetailsRefresh"/);
|
||||
assert.match(app, /apiRequest\(`\/api\/tasks\?\$\{params\.toString\(\)\}`/);
|
||||
@@ -1242,6 +1242,7 @@ test('operator page has a login gate and uses the durable task API', async () =>
|
||||
assert.match(app, /historyPagination/);
|
||||
assert.match(index, /id="historyBatchActions"/);
|
||||
assert.match(index, /id="historySelectAll"/);
|
||||
assert.match(index, /id="historyArchiveSelectedButton"/);
|
||||
assert.match(index, /id="historyDeleteSelectedButton"/);
|
||||
assert.match(app, /IS_HISTORY_PAGE = CURRENT_PAGE === '\/history'/);
|
||||
assert.match(index, /href="\/history"/);
|
||||
@@ -1392,28 +1393,39 @@ test('operator page has a login gate and uses the durable task API', async () =>
|
||||
assert.match(app, /operation_contract_validation/);
|
||||
assert.match(app, /api\/tasks\/\$\{encodeURIComponent\(taskId\)\}\/archive/);
|
||||
assert.match(app, /api\/tasks\/\$\{encodeURIComponent\(taskId\)\}\/restore/);
|
||||
assert.match(app, /const taskArchiveStates = new Map\(\)/);
|
||||
assert.match(app, /const taskDeleteStates = new Map\(\)/);
|
||||
assert.match(app, /taskDeleteStates\.get\(task\.task_id\)/);
|
||||
assert.match(app, /taskDeleteStates\.set\(taskId, 'deleting'\)/);
|
||||
assert.match(app, /deleteButton\.textContent = task\?\.archived_at \? '恢复中…' : '归档中…'/);
|
||||
assert.doesNotMatch(app, /sendToExtension\('DELETE_TASK'/);
|
||||
assert.match(app, /deleteButton\.textContent = '强制删除中…'/);
|
||||
assert.match(app, /sendToExtension\('DELETE_TASK'/);
|
||||
assert.match(app, /method: 'DELETE'/);
|
||||
assert.match(app, /此操作不受“正在处理”或“等待 ERP 执行”状态限制/);
|
||||
assert.match(taskService, /async archiveTask\(/);
|
||||
assert.match(taskService, /async archiveTasks\(/);
|
||||
assert.match(taskService, /async restoreTask\(/);
|
||||
assert.match(taskService, /async hardDeleteTask\(/);
|
||||
assert.match(taskService, /async hardDeleteTasks\(/);
|
||||
assert.match(taskService, /archived_at = now\(\), archived_by = \$1/);
|
||||
assert.match(taskService, /SET archived_at = NULL, archived_by = NULL, archive_reason = NULL/);
|
||||
assert.match(taskService, /const missingTaskIds = normalizedTaskIds\.filter/);
|
||||
assert.doesNotMatch(taskService, /async hardDeleteTask\(/);
|
||||
assert.match(taskService, /task\.hard_deleted/);
|
||||
assert.match(taskService, /this\.artifactStore\.cleanup\(outcome\.artifacts\)/);
|
||||
assert.doesNotMatch(taskService, /DELETE FROM audit_events/);
|
||||
assert.doesNotMatch(taskService, /DELETE FROM tasks/);
|
||||
assert.match(taskService, /DELETE FROM tasks/);
|
||||
assert.match(taskService, /erp-account-queue/);
|
||||
assert.match(taskService, /t\.assigned_user_id = \$2/);
|
||||
assert.match(taskService, /AND assigned_user_id = \$2\s+AND status = 'confirmed'/);
|
||||
assert.match(server, /taskBulkDeleteSchema/);
|
||||
assert.match(server, /taskBulkArchiveSchema/);
|
||||
assert.match(server, /\.max\(100\)/);
|
||||
assert.match(server, /app\.post\('\/api\/tasks\/bulk-delete'/);
|
||||
assert.match(server, /app\.post\('\/api\/tasks\/bulk-archive'/);
|
||||
assert.match(server, /app\.delete\('\/api\/tasks\/:taskId'/);
|
||||
assert.match(server, /app\.post\('\/api\/tasks\/:taskId\/archive'/);
|
||||
assert.match(server, /app\.post\('\/api\/tasks\/:taskId\/restore'/);
|
||||
assert.doesNotMatch(server, /tasks\.hardDelete/);
|
||||
assert.match(server, /tasks\.hardDeleteTasks/);
|
||||
assert.match(server, /tasks\.hardDeleteTask/);
|
||||
assert.match(bridge, /status: 'deleted'/);
|
||||
assert.match(background, /LTJT_HARD_DELETE_TASK/);
|
||||
assert.doesNotMatch(app, /task-json-output|taskResponseJson/);
|
||||
@@ -1428,6 +1440,7 @@ test('operator page has a login gate and uses the durable task API', async () =>
|
||||
assert.match(styles, /\.task-stage-card \{[\s\S]*display: flex;[\s\S]*justify-content: space-between;/);
|
||||
assert.match(styles, /\.workbench-grid \{[\s\S]*min-width: 0;[\s\S]*overflow: hidden;/);
|
||||
assert.match(styles, /overflow-x: hidden/);
|
||||
assert.match(styles, /\.task-card-actions/);
|
||||
assert.match(styles, /\.task-card-delete/);
|
||||
assert.match(styles, /\.history-batch-actions/);
|
||||
assert.match(app, /currentOption = stage\.options\.find/);
|
||||
|
||||
Reference in new issue
Block a user