From ba928ea632663d71c3929757f104bfdca9619c8b Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 8 May 2026 21:28:38 +0800 Subject: [PATCH] test(enterprise): cover restart persistence recovery --- backend/package.json | 2 +- .../enterpriseRestartPersistence.test.ts | 237 ++++++++++++++++++ .../src/services/sessionPersistenceService.ts | 17 +- .../enterprise-multi-tenant/README.md | 7 +- 4 files changed, 256 insertions(+), 7 deletions(-) create mode 100644 backend/src/routes/__tests__/enterpriseRestartPersistence.test.ts diff --git a/backend/package.json b/backend/package.json index f62af94e..39d0167a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -41,7 +41,7 @@ "prepack": "npm run build", "typecheck": "tsc --noEmit", "test": "jest", - "test:core": "jest --runInBand --forceExit src/agent/communication/__tests__/agentMessageBus.test.ts src/agent/core/executors/__tests__/strategyExecutor.test.ts src/agent/core/executors/__tests__/hypothesisExecutor.test.ts src/agent/context/__tests__/enhancedSessionContext.test.ts src/tests/adbTools.test.ts src/services/__tests__/sessionLogger.test.ts src/services/__tests__/traceAnalysisSkillConfig.test.ts src/agent/agents/domain/__tests__/registry.test.ts src/agentv3/__tests__/sqlIncludeInjector.test.ts src/agentv3/__tests__/analysisPatternMemory.test.ts src/agentv3/__tests__/claudeRuntimeRuntimeSnapshots.test.ts src/middleware/__tests__/auth.test.ts src/services/__tests__/rbac.test.ts src/routes/__tests__/agentRoutesRbac.test.ts src/routes/__tests__/ownerGuardRoutes.test.ts src/routes/__tests__/requestContextRouteCoverage.test.ts src/middleware/__tests__/legacyApiCompatibility.test.ts src/services/__tests__/enterpriseDb.test.ts src/services/__tests__/enterpriseSchema.test.ts src/services/__tests__/enterpriseRepository.test.ts src/services/__tests__/enterpriseKnowledgeScope.test.ts src/services/__tests__/enterpriseMigration.test.ts src/services/__tests__/runtimeSnapshotStore.test.ts src/services/providerManager/__tests__/localSecretStore.test.ts src/services/providerManager/__tests__/enterpriseProviderStore.test.ts src/routes/__tests__/enterpriseTraceMetadataRoutes.test.ts src/routes/__tests__/enterpriseReportRoutes.test.ts", + "test:core": "jest --runInBand --forceExit src/agent/communication/__tests__/agentMessageBus.test.ts src/agent/core/executors/__tests__/strategyExecutor.test.ts src/agent/core/executors/__tests__/hypothesisExecutor.test.ts src/agent/context/__tests__/enhancedSessionContext.test.ts src/tests/adbTools.test.ts src/services/__tests__/sessionLogger.test.ts src/services/__tests__/traceAnalysisSkillConfig.test.ts src/agent/agents/domain/__tests__/registry.test.ts src/agentv3/__tests__/sqlIncludeInjector.test.ts src/agentv3/__tests__/analysisPatternMemory.test.ts src/agentv3/__tests__/claudeRuntimeRuntimeSnapshots.test.ts src/middleware/__tests__/auth.test.ts src/services/__tests__/rbac.test.ts src/routes/__tests__/agentRoutesRbac.test.ts src/routes/__tests__/ownerGuardRoutes.test.ts src/routes/__tests__/requestContextRouteCoverage.test.ts src/middleware/__tests__/legacyApiCompatibility.test.ts src/services/__tests__/enterpriseDb.test.ts src/services/__tests__/enterpriseSchema.test.ts src/services/__tests__/enterpriseRepository.test.ts src/services/__tests__/enterpriseKnowledgeScope.test.ts src/services/__tests__/enterpriseMigration.test.ts src/services/__tests__/runtimeSnapshotStore.test.ts src/services/providerManager/__tests__/localSecretStore.test.ts src/services/providerManager/__tests__/enterpriseProviderStore.test.ts src/routes/__tests__/enterpriseTraceMetadataRoutes.test.ts src/routes/__tests__/enterpriseReportRoutes.test.ts src/routes/__tests__/enterpriseRestartPersistence.test.ts", "test:watch": "jest --watch", "test:coverage": "jest --coverage", "test:unit": "jest --testPathPatterns=src/tests", diff --git a/backend/src/routes/__tests__/enterpriseRestartPersistence.test.ts b/backend/src/routes/__tests__/enterpriseRestartPersistence.test.ts new file mode 100644 index 00000000..f100644e --- /dev/null +++ b/backend/src/routes/__tests__/enterpriseRestartPersistence.test.ts @@ -0,0 +1,237 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2024-2026 Gracker (Chris) +// This file is part of SmartPerfetto. See LICENSE for details. + +import { afterEach, beforeEach, describe, expect, it } from '@jest/globals'; +import express from 'express'; +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; +import request from 'supertest'; +import { ENTERPRISE_FEATURE_FLAG_ENV } from '../../config'; +import { EnhancedSessionContext, sessionContextManager } from '../../agent/context/enhancedSessionContext'; +import { ENTERPRISE_DB_PATH_ENV, openEnterpriseDb } from '../../services/enterpriseDb'; +import { ENTERPRISE_MIGRATION_PHASE_ENV } from '../../services/enterpriseMigration'; +import { SessionPersistenceService } from '../../services/sessionPersistenceService'; +import { + ENTERPRISE_DATA_DIR_ENV, + writeTraceMetadata, +} from '../../services/traceMetadataStore'; +import agentRoutes from '../agentRoutes'; +import reportRoutes, { persistReport, reportStore } from '../reportRoutes'; +import traceRoutes from '../simpleTraceRoutes'; + +const originalEnv = { + enterprise: process.env[ENTERPRISE_FEATURE_FLAG_ENV], + migrationPhase: process.env[ENTERPRISE_MIGRATION_PHASE_ENV], + trustedHeaders: process.env.SMARTPERFETTO_SSO_TRUSTED_HEADERS, + enterpriseDbPath: process.env[ENTERPRISE_DB_PATH_ENV], + enterpriseDataDir: process.env[ENTERPRISE_DATA_DIR_ENV], + uploadDir: process.env.UPLOAD_DIR, + apiKey: process.env.SMARTPERFETTO_API_KEY, +}; + +const TENANT_ID = 'tenant-a'; +const WORKSPACE_ID = 'workspace-a'; +const USER_ID = 'user-a'; +const TRACE_ID = 'trace-restart-a'; +const SESSION_ID = 'session-restart-a'; +const RUN_ID = 'run-restart-a'; +const REPORT_ID = 'report-restart-a'; +const GENERATED_AT = 1_700_000_000_000; + +let tmpDir: string; +let dbPath: string; +let dataDir: string; +let uploadDir: string; + +function restoreEnvValue(key: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } +} + +function makeApp(): express.Express { + const app = express(); + app.use(express.json()); + app.use('/api/traces', traceRoutes); + app.use('/api/reports', reportRoutes); + app.use('/api/agent/v1', agentRoutes); + return app; +} + +function ssoHeaders(req: request.Test, workspaceId = WORKSPACE_ID): request.Test { + return req + .set('X-SmartPerfetto-SSO-User-Id', USER_ID) + .set('X-SmartPerfetto-SSO-Email', `${USER_ID}@example.test`) + .set('X-SmartPerfetto-SSO-Tenant-Id', TENANT_ID) + .set('X-SmartPerfetto-SSO-Workspace-Id', workspaceId) + .set('X-SmartPerfetto-SSO-Roles', 'workspace_admin') + .set( + 'X-SmartPerfetto-SSO-Scopes', + 'trace:read,trace:write,trace:download,report:read,report:delete,agent:run', + ); +} + +function readCount(table: 'trace_assets' | 'report_artifacts' | 'sessions'): number { + const db = openEnterpriseDb(dbPath); + try { + const row = db.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get() as { count: number }; + return row.count; + } finally { + db.close(); + } +} + +async function seedRestartState(): Promise { + const tracePath = path.join(dataDir, TENANT_ID, WORKSPACE_ID, 'traces', `${TRACE_ID}.trace`); + await fs.mkdir(path.dirname(tracePath), { recursive: true }); + await fs.writeFile(tracePath, 'trace bytes', 'utf-8'); + + await writeTraceMetadata({ + id: TRACE_ID, + filename: 'restart.trace', + size: Buffer.byteLength('trace bytes'), + uploadedAt: new Date(GENERATED_AT).toISOString(), + status: 'ready', + path: tracePath, + tenantId: TENANT_ID, + workspaceId: WORKSPACE_ID, + userId: USER_ID, + }); + + persistReport(REPORT_ID, { + html: 'restart report', + generatedAt: GENERATED_AT, + sessionId: SESSION_ID, + runId: RUN_ID, + traceId: TRACE_ID, + tenantId: TENANT_ID, + workspaceId: WORKSPACE_ID, + userId: USER_ID, + visibility: 'private', + }); + + const persistence = SessionPersistenceService.getInstance(); + persistence.saveSession({ + id: SESSION_ID, + traceId: TRACE_ID, + traceName: 'restart.trace', + question: '分析 restart 后是否可恢复', + createdAt: GENERATED_AT, + updatedAt: GENERATED_AT + 1000, + messages: [], + metadata: { + tenantId: TENANT_ID, + workspaceId: WORKSPACE_ID, + userId: USER_ID, + }, + }); + + const context = new EnhancedSessionContext(SESSION_ID, TRACE_ID); + context.addTurn('分析 restart 后是否可恢复', { + primaryGoal: 'restart_persistence', + aspects: ['session', 'report', 'trace'], + expectedOutputType: 'diagnosis', + complexity: 'moderate', + }); + context.getEntityStore().upsertFrame({ + frame_id: 'frame-restart-a', + jank_type: 'App Deadline Missed', + }); + persistence.saveSessionContext(SESSION_ID, context); +} + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'smartperfetto-enterprise-restart-')); + dbPath = path.join(tmpDir, 'enterprise.sqlite'); + dataDir = path.join(tmpDir, 'data'); + uploadDir = path.join(tmpDir, 'uploads'); + + process.env[ENTERPRISE_FEATURE_FLAG_ENV] = 'true'; + process.env[ENTERPRISE_MIGRATION_PHASE_ENV] = 'cutover'; + process.env.SMARTPERFETTO_SSO_TRUSTED_HEADERS = 'true'; + process.env[ENTERPRISE_DB_PATH_ENV] = dbPath; + process.env[ENTERPRISE_DATA_DIR_ENV] = dataDir; + process.env.UPLOAD_DIR = uploadDir; + delete process.env.SMARTPERFETTO_API_KEY; + + SessionPersistenceService.resetForTests(); + reportStore.clear(); + sessionContextManager.remove(SESSION_ID); +}); + +afterEach(async () => { + SessionPersistenceService.resetForTests(); + reportStore.clear(); + sessionContextManager.remove(SESSION_ID); + restoreEnvValue(ENTERPRISE_FEATURE_FLAG_ENV, originalEnv.enterprise); + restoreEnvValue(ENTERPRISE_MIGRATION_PHASE_ENV, originalEnv.migrationPhase); + restoreEnvValue('SMARTPERFETTO_SSO_TRUSTED_HEADERS', originalEnv.trustedHeaders); + restoreEnvValue(ENTERPRISE_DB_PATH_ENV, originalEnv.enterpriseDbPath); + restoreEnvValue(ENTERPRISE_DATA_DIR_ENV, originalEnv.enterpriseDataDir); + restoreEnvValue('UPLOAD_DIR', originalEnv.uploadDir); + restoreEnvValue('SMARTPERFETTO_API_KEY', originalEnv.apiKey); + await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +describe('enterprise restart persistence', () => { + it('recovers session, report, and trace metadata from durable storage after in-memory state is lost', async () => { + await seedRestartState(); + expect(readCount('trace_assets')).toBe(1); + expect(readCount('report_artifacts')).toBe(1); + expect(readCount('sessions')).toBe(1); + + reportStore.clear(); + sessionContextManager.remove(SESSION_ID); + SessionPersistenceService.resetForTests(); + + const app = makeApp(); + + const tracesRes = await ssoHeaders(request(app).get('/api/traces')); + expect(tracesRes.status).toBe(200); + expect(tracesRes.body.traces).toHaveLength(1); + expect(tracesRes.body.traces[0]).toEqual(expect.objectContaining({ + id: TRACE_ID, + filename: 'restart.trace', + tenantId: TENANT_ID, + workspaceId: WORKSPACE_ID, + userId: USER_ID, + status: 'ready', + })); + + const reportRes = await ssoHeaders(request(app).get(`/api/reports/${REPORT_ID}`)); + expect(reportRes.status).toBe(200); + expect(reportRes.text).toContain('restart report'); + + const sessionsRes = await ssoHeaders( + request(app).get('/api/agent/v1/sessions?includeRecoverable=true'), + ); + expect(sessionsRes.status).toBe(200); + expect(sessionsRes.body.recoverableSessions).toEqual([ + expect.objectContaining({ + sessionId: SESSION_ID, + status: 'recoverable', + traceId: TRACE_ID, + turnCount: 1, + }), + ]); + + const turnsRes = await ssoHeaders( + request(app).get(`/api/agent/v1/${SESSION_ID}/turns?order=asc`), + ); + expect(turnsRes.status).toBe(200); + expect(turnsRes.body).toEqual(expect.objectContaining({ + success: true, + sessionId: SESSION_ID, + traceId: TRACE_ID, + source: 'persistence', + totalTurns: 1, + })); + expect(turnsRes.body.turns[0]).toEqual(expect.objectContaining({ + query: '分析 restart 后是否可恢复', + })); + }); +}); diff --git a/backend/src/services/sessionPersistenceService.ts b/backend/src/services/sessionPersistenceService.ts index d05145a8..830bb7ed 100644 --- a/backend/src/services/sessionPersistenceService.ts +++ b/backend/src/services/sessionPersistenceService.ts @@ -26,6 +26,7 @@ import { FocusStore, FocusStoreSnapshot } from '../agent/context/focusStore'; import { TraceAgentState } from '../agent/state/traceAgentState'; import type { SessionStateSnapshot } from '../agentv3/sessionStateSnapshot'; import { applyEnterpriseMinimalSchema } from './enterpriseSchema'; +import { resolveEnterpriseDbPath } from './enterpriseDb'; // DB path is resolved lazily (in the constructor) rather than at module load. // Module-load resolution would capture `process.cwd()` at the time of the first @@ -33,17 +34,17 @@ import { applyEnterpriseMinimalSchema } from './enterpriseSchema'; // backend root so all services share one data dir, but that chdir happens // *after* imports have already resolved. Lazy resolution lets both HTTP (cwd // already == backend) and CLI (cwd set by bootstrap) land on the same path. -function resolveDbDir(): string { - return path.join(process.cwd(), 'data', 'sessions'); +export function resolveSessionPersistenceDbPath(env: NodeJS.ProcessEnv = process.env): string { + return resolveEnterpriseDbPath(env); } export class SessionPersistenceService { private db: Database.Database; - private static instance: SessionPersistenceService; + private static instance: SessionPersistenceService | null = null; private constructor() { - const dbDir = resolveDbDir(); - const dbPath = path.join(dbDir, 'sessions.db'); + const dbPath = resolveSessionPersistenceDbPath(); + const dbDir = path.dirname(dbPath); // Ensure data directory exists if (!fs.existsSync(dbDir)) { @@ -64,6 +65,12 @@ export class SessionPersistenceService { return SessionPersistenceService.instance; } + static resetForTests(): void { + if (!SessionPersistenceService.instance) return; + SessionPersistenceService.instance.db.close(); + SessionPersistenceService.instance = null; + } + private initializeSchema(): void { this.db.exec(` CREATE TABLE IF NOT EXISTS sessions ( diff --git a/docs/features/enterprise-multi-tenant/README.md b/docs/features/enterprise-multi-tenant/README.md index c6f9d1ef..32960ca9 100644 --- a/docs/features/enterprise-multi-tenant/README.md +++ b/docs/features/enterprise-multi-tenant/README.md @@ -48,7 +48,7 @@ - [x] 3.7 Memory / RAG / Case / Baseline 表加 scope(§14.1,先 filter 后语义召回) - [x] 3.8 双写 → 切读 → 退役 三阶段(§17),每阶段都能回滚;准备 filesystem + DB snapshot - [x] 3.9 SecretStore:libsodium 加密 + OS keyring 解 master key + secret rotation + 读取审计 -- [ ] 3.10 集成测试:backend restart 后 session/report/trace metadata 可恢复 +- [x] 3.10 集成测试:backend restart 后 session/report/trace metadata 可恢复 ### 0.4 主线 C:运行时隔离(§18 + §11) - [x] 4.1 §11.10 第一批最小改动(独立 PR) @@ -1045,6 +1045,11 @@ request delete - DB 是企业模式权威状态。 - 本地 dev 仍可低成本运行。 +当前已覆盖 `backend/src/routes/__tests__/enterpriseRestartPersistence.test.ts`: +清空 in-memory report/session context 并重建 `SessionPersistenceService` 后, +`/api/traces`、`/api/reports/:id`、`/api/agent/v1/sessions` 与 +`/api/agent/v1/:sessionId/turns` 均可从企业 SQLite DB 与 scoped data 文件恢复。 + ### 主线 C:运行时隔离 任务: