diff --git a/admin-web/src/services/dashboard.ts b/admin-web/src/services/dashboard.ts index 087b554..aacd440 100644 --- a/admin-web/src/services/dashboard.ts +++ b/admin-web/src/services/dashboard.ts @@ -106,12 +106,11 @@ export async function loadDashboard(permissions: string[]): Promise numberOf(recordOf(workbenchMetrics, key), 'value') const hasWorkbenchMetric = (key: string) => Object.prototype.hasOwnProperty.call(workbenchMetrics, key) const workbenchAvailable = (key: string) => Boolean(recordOf(workbenchMetrics, key).available) - const metricStatus = (key: string, permission: string, fallbackAvailable: boolean): DashboardMetric['status'] => { + const metricStatus = (key: string): DashboardMetric['status'] => { if (hasWorkbenchMetric(key)) return workbenchAvailable(key) ? 'ready' : 'forbidden' - if (!permissions.includes(permission)) return 'forbidden' - return fallbackAvailable ? 'ready' : 'error' + return 'error' } - const metricAvailable = (key: string, permission: string, fallbackAvailable: boolean) => metricStatus(key, permission, fallbackAvailable) === 'ready' + const metricAvailable = (key: string) => metricStatus(key) === 'ready' const metricRoute = (status: DashboardMetric['status'], route: string) => status === 'ready' ? route : '' const core = fulfilledData(coreResult) const service = fulfilledData(serviceResult) @@ -121,9 +120,6 @@ export async function loadDashboard(permissions: string[]): Promise(managementResult) const managedStats = summarizeMatchmakerServices(managedServices || []) const matchmakerAvailable = canManageMatchmaker ? Boolean(managedServices) : Boolean(matchmaker) - const matchmakerPending = canManageMatchmaker - ? managedStats.pending - : numberOf(matchmaker || undefined, 'pendingCount') + numberOf(matchmaker || undefined, 'inProgressCount') const matchmakerCompleted = canManageMatchmaker ? managedStats.completed : numberOf(matchmaker || undefined, 'completedCount') @@ -134,38 +130,37 @@ export async function loadDashboard(permissions: string[]): Promise assertTrue(lifecycle.contains("profile_revision")), + () -> assertTrue(lifecycle.contains("published_profile_revision")), + () -> assertTrue(lifecycle.contains("user_credentials")), + () -> assertTrue(lifecycle.contains("must_change_password")), + () -> assertFalse(lifecycle.toLowerCase().contains("plaintext_password")) + ); + + String imports = Files.readString(DIR.resolve("V063__add_v12_import_batches.sql")); + assertAll( + () -> assertTrue(imports.contains("user_import_batches")), + () -> assertTrue(imports.contains("user_import_rows")), + () -> assertTrue(imports.contains("idempotency_key")) + ); + + String consent = Files.readString(DIR.resolve("V064__add_v12_consent_and_service_state.sql")); + assertAll( + () -> assertTrue(consent.contains("user_consent_states")), + () -> assertTrue(consent.contains("user_service_states")), + () -> assertTrue(consent.contains("service_active")) + ); + } + + @Test + void lifecycleMigrationsDoNotDropExistingObjects() throws Exception { + List names = List.of( + "V062__add_v12_user_account_lifecycle.sql", + "V063__add_v12_import_batches.sql", + "V064__add_v12_consent_and_service_state.sql", + "V065__add_v12_registration_applications.sql", + "V066__grant_user_onboarding_permission.sql"); + for (String name : names) { + String sql = Files.readString(DIR.resolve(name)).toUpperCase(); + assertFalse(sql.contains("DROP TABLE"), name); + assertFalse(sql.contains("DROP COLUMN"), name); + } + } + + @Test + void matchmakerCrossDomainPermissionsAreRevoked() throws Exception { + String sql = Files.readString(DIR.resolve("V070__remove_matchmaker_cross_domain_permissions.sql")); + assertAll( + () -> assertTrue(sql.contains("r.code IN ('matchmaker', 'matchmaker_leader')")), + () -> assertTrue(sql.contains("p.code LIKE 'counseling.%'")), + () -> assertTrue(sql.contains("p.code LIKE 'service_appointment.%'")), + () -> assertTrue(sql.contains("'activity.view'")), + () -> assertTrue(sql.contains("'activity.checkin'")), + () -> assertTrue(sql.contains("'activity.review_registration'")), + () -> assertTrue(sql.contains("'analytics.view'")) + ); + } + + @Test + void matchmakerReviewPermissionsAreRevoked() throws Exception { + String sql = Files.readString(DIR.resolve("V071__remove_matchmaker_review_permissions.sql")); + assertAll( + () -> assertTrue(sql.contains("r.code IN ('matchmaker', 'matchmaker_leader')")), + () -> assertTrue(sql.contains("'review.view'")), + () -> assertTrue(sql.contains("'review.process'")), + () -> assertTrue(sql.contains("'local_eligibility.review'")) + ); + } + + @Test + void matchmakerWorkbenchPermissionsAreRestoredWithoutSensitiveAccess() throws Exception { + Path migration = DIR.resolve("V074__restore_matchmaker_workbench_permissions.sql"); + assertTrue(Files.exists(migration)); + String sql = Files.readString(migration); + assertAll( + () -> assertTrue(sql.contains("r.code IN ('matchmaker', 'matchmaker_leader')")), + () -> assertTrue(sql.contains("'workbench.view'")), + () -> assertTrue(sql.contains("'review.process'")), + () -> assertTrue(sql.contains("'local_eligibility.review'")), + () -> assertTrue(sql.contains("'counseling.service.assign'")), + () -> assertTrue(sql.contains("'service_appointment.confirm'")), + () -> assertTrue(sql.contains("'service_appointment.complete'")), + () -> assertFalse(sql.contains("'local_eligibility.evidence.view'")), + () -> assertFalse(sql.contains("'counseling.note.view'")), + () -> assertFalse(sql.contains("'matchmaker.assign'")), + () -> assertTrue(sql.contains("ON CONFLICT (role_id, permission_id) DO NOTHING")) + ); + } + + private static void assertMigration(String name) { + assertTrue(Files.exists(DIR.resolve(name)), "缺少迁移: " + name); + } +} diff --git a/docs/Phase-V12-Final-Fix-Completion-Report.md b/docs/Phase-V12-Final-Fix-Completion-Report.md new file mode 100644 index 0000000..9fd83b2 --- /dev/null +++ b/docs/Phase-V12-Final-Fix-Completion-Report.md @@ -0,0 +1,19 @@ +# Phase V1.2 Final Fix Completion Report + +## 完成内容 + +- 新增 `V074__restore_matchmaker_workbench_permissions.sql`,幂等恢复 `matchmaker`、`matchmaker_leader` 的工作台六卡片及审核、分配、确认、完成操作权限。 +- 未授予资格原始佐证、咨询备注查看、`matchmaker.assign` 等敏感或代用户决策权限;未修改 V070/V071。 +- 将迁移基线更新为 V074,并增加 V074 权限契约测试。 +- workbench 请求失败或缺少数据时,六项 dashboard 卡片固定返回 `status=error`、`available=false`、`value=0`、空路由,不再使用旧 analytics/service/matchmaker/review 接口回退;workbench 成功返回真实 0 仍保留。 + +## 验证 + +- `tests/admin-dashboard-behavior.cjs` +- Maven 定向迁移契约测试:PASS(`MigrationValidationTest,V12MigrationContractTest`) +- `pnpm type-check`:PASS +- `node tests/admin-dashboard-behavior.cjs`:PASS + +## 限制 + +本次未执行真实生产数据库迁移、真实角色登录或生产部署验收。 diff --git a/tests/admin-dashboard-behavior.cjs b/tests/admin-dashboard-behavior.cjs index b55d369..2c00106 100644 --- a/tests/admin-dashboard-behavior.cjs +++ b/tests/admin-dashboard-behavior.cjs @@ -23,14 +23,20 @@ const ts = require('../admin-web/node_modules/typescript'); }); const perms = ['analytics.view', 'activity.view', 'matchmaker.view', 'matchmaker.assign', 'review.view']; let model = await exports.loadDashboard(perms); - assert.equal(model.metrics.find(m => m.key === 'pending_reviews').value, 3); - assert.equal(model.metrics.find(m => m.key === 'matchmaker_todos').value, 1); + const failedWorkbenchMetrics = ['pending_reviews', 'matchmaker_todos', 'counseling_todos', 'station_appointments', 'effective_matches', 'activity_registrations']; + for (const key of failedWorkbenchMetrics) { + const metric = model.metrics.find(m => m.key === key); + assert.equal(metric.value, 0, `${key} workbench 失败时不得使用旧接口 fallback`); + assert.equal(metric.available, false); + assert.equal(metric.status, 'error'); + assert.equal(metric.route, ''); + } assert.equal(model.followups[0].content, 'Demo验收跟进'); assert.equal(exports.summarizeMatchmakerServices(responses['/operator/matchmaker/management/services']).total, 1); - assert.equal(model.metrics.find(m => m.key === 'matchmaker_todos').route, '/matchmaker'); + assert.equal(model.metrics.find(m => m.key === 'matchmaker_todos').route, ''); pending = 1; model = await exports.loadDashboard(perms); - assert.equal(model.metrics.find(m => m.key === 'pending_reviews').value, 2); + assert.equal(model.metrics.find(m => m.key === 'pending_reviews').value, 0); workbench = { metrics: { pendingReviews: { value: 7, available: false }, matchmakerTodos: { value: 0, available: true },