@@ -8,7 +8,14 @@ import {
88 workflowsUtilsMock ,
99 workflowsUtilsMockFns ,
1010} from '@sim/testing'
11- import { afterAll , beforeEach , describe , expect , it , vi } from 'vitest'
11+ import { afterAll , afterEach , beforeEach , describe , expect , it , vi } from 'vitest'
12+ import * as retention from '@/lib/billing/retention'
13+ import { clearLargeValueCacheForTests } from '@/lib/execution/payloads/cache'
14+ import type { LargeArrayManifest } from '@/lib/execution/payloads/large-array-manifest'
15+ import type { LargeValueRef } from '@/lib/execution/payloads/large-value-ref'
16+ import type { LoggingSession } from '@/lib/logs/execution/logging-session'
17+ import { ExecutionSnapshot } from '@/executor/execution/snapshot'
18+ import type { SerializableExecutionState } from '@/executor/execution/types'
1219
1320const {
1421 mergeSubblockStateWithValuesMock,
@@ -32,6 +39,9 @@ const {
3239 projectDisplayContentMock,
3340 projectDiagnosticErrorMock,
3441 decryptSecretMock,
42+ downloadFileMock,
43+ uploadFileMock,
44+ maskBatchMock,
3545} = vi . hoisted ( ( ) => ( {
3646 mergeSubblockStateWithValuesMock : vi . fn ( ) ,
3747 safeStartMock : vi . fn ( ) ,
@@ -54,6 +64,9 @@ const {
5464 projectDisplayContentMock : vi . fn ( ) ,
5565 projectDiagnosticErrorMock : vi . fn ( ) ,
5666 decryptSecretMock : vi . fn ( ) ,
67+ downloadFileMock : vi . fn ( ) ,
68+ uploadFileMock : vi . fn ( ) ,
69+ maskBatchMock : vi . fn ( ) ,
5770} ) )
5871
5972const getPersonalAndWorkspaceEnvMock = environmentUtilsMockFns . mockGetPersonalAndWorkspaceEnv
@@ -67,6 +80,19 @@ const loadWorkflowDeploymentVersionStateMock =
6780 workflowsPersistenceUtilsMockFns . mockLoadWorkflowDeploymentVersionState
6881const updateWorkflowRunCountsMock = workflowsUtilsMockFns . mockUpdateWorkflowRunCounts
6982
83+ vi . mock ( '@/lib/uploads' , ( ) => ( {
84+ StorageService : { downloadFile : downloadFileMock , uploadFile : uploadFileMock } ,
85+ } ) )
86+
87+ vi . mock ( '@/lib/guardrails/mask-client' , ( ) => ( {
88+ maskPIIBatchViaHttp : maskBatchMock ,
89+ } ) )
90+
91+ vi . mock ( '@/lib/execution/payloads/large-value-metadata' , ( ) => ( {
92+ registerLargeValueOwner : vi . fn ( ) . mockResolvedValue ( true ) ,
93+ addLargeValueReference : vi . fn ( ) . mockResolvedValue ( undefined ) ,
94+ } ) )
95+
7096vi . mock ( '@/lib/execution/cancellation' , ( ) => ( {
7197 clearExecutionCancellation : clearExecutionCancellationMock ,
7298} ) )
@@ -120,7 +146,7 @@ import {
120146 executeWorkflowCore ,
121147 FINALIZED_EXECUTION_ID_TTL_MS ,
122148 wasExecutionFinalizedByCore ,
123- } from '. /execution-core'
149+ } from '@/lib/workflows/executor /execution-core'
124150
125151const executionCoreLoggerCallIndex = loggerMock . createLogger . mock . calls . findIndex (
126152 ( [ name ] ) => name === 'ExecutionCore'
@@ -993,6 +1019,172 @@ describe('executeWorkflowCore terminal finalization sequencing', () => {
9931019 expect ( loadWorkflowDeploymentVersionStateMock ) . not . toHaveBeenCalled ( )
9941020 } )
9951021
1022+ describe ( 'PII redaction of restored large values' , ( ) => {
1023+ const sourceItems = [ { email : 'alice@example.com' , count : 7 } ]
1024+ const maskedItems = [ { email : '<EMAIL_ADDRESS>' , count : 7 } ]
1025+ const sourceBytes = Buffer . from ( JSON . stringify ( sourceItems ) )
1026+
1027+ function createManifest (
1028+ workspaceId = 'workspace-1' ,
1029+ workflowId = 'workflow-1' ,
1030+ executionId = 'source-execution'
1031+ ) : LargeArrayManifest {
1032+ const ref : LargeValueRef = {
1033+ __simLargeValueRef : true ,
1034+ version : 1 ,
1035+ id : 'lv_123456789012' ,
1036+ kind : 'array' ,
1037+ size : sourceBytes . length ,
1038+ executionId,
1039+ key : `execution/${ workspaceId } /${ workflowId } /${ executionId } /large-value-lv_123456789012.json` ,
1040+ }
1041+ return {
1042+ __simLargeArrayManifest : true ,
1043+ version : 2 ,
1044+ kind : 'array' ,
1045+ totalCount : 1 ,
1046+ chunkCount : 1 ,
1047+ byteSize : sourceBytes . length ,
1048+ chunks : [ { ref, count : 1 , byteSize : sourceBytes . length } ] ,
1049+ preview : sourceItems ,
1050+ }
1051+ }
1052+
1053+ function createRestoredState ( manifest : LargeArrayManifest ) : SerializableExecutionState {
1054+ return {
1055+ blockStates : { previous : { output : { result : manifest } } } ,
1056+ executedBlocks : [ 'previous' ] ,
1057+ blockLogs : [ ] ,
1058+ decisions : { router : { } , condition : { } } ,
1059+ completedLoops : [ ] ,
1060+ activeExecutionPath : [ ] ,
1061+ trustedLargeValueAccess : { executionIds : [ ] , largeValueKeys : [ ] , fileKeys : [ ] } ,
1062+ }
1063+ }
1064+
1065+ function createPiiSnapshot ( state ?: SerializableExecutionState , input : unknown = { } ) {
1066+ const base = createSnapshot ( )
1067+ return new ExecutionSnapshot (
1068+ { ...base . metadata , resumeFromSnapshot : state !== undefined } ,
1069+ base . workflow ,
1070+ input ,
1071+ { } ,
1072+ [ ] ,
1073+ state
1074+ )
1075+ }
1076+
1077+ beforeEach ( ( ) => {
1078+ clearLargeValueCacheForTests ( )
1079+ vi . spyOn ( retention , 'resolveEffectivePiiRedaction' ) . mockReturnValue ( {
1080+ ...retention . DEFAULT_PII_REDACTION ,
1081+ input : {
1082+ enabled : true ,
1083+ entityTypes : [ 'EMAIL_ADDRESS' ] ,
1084+ language : 'en' ,
1085+ customPatterns : [ ] ,
1086+ } ,
1087+ blockOutputs : {
1088+ enabled : true ,
1089+ entityTypes : [ 'EMAIL_ADDRESS' ] ,
1090+ language : 'en' ,
1091+ customPatterns : [ ] ,
1092+ } ,
1093+ } )
1094+ downloadFileMock . mockResolvedValue ( sourceBytes )
1095+ uploadFileMock . mockImplementation ( async ( { customKey } : { customKey : string } ) => ( {
1096+ key : customKey ,
1097+ } ) )
1098+ maskBatchMock . mockImplementation ( async ( texts : string [ ] ) =>
1099+ texts . map ( ( text ) => text . replaceAll ( 'alice@example.com' , '<EMAIL_ADDRESS>' ) )
1100+ )
1101+ executorExecuteMock . mockResolvedValue ( {
1102+ success : true ,
1103+ status : 'completed' ,
1104+ output : { done : true } ,
1105+ logs : [ ] ,
1106+ metadata : { duration : 1 , startTime : 'start' , endTime : 'end' } ,
1107+ } )
1108+ } )
1109+
1110+ afterEach ( ( ) => {
1111+ vi . restoreAllMocks ( )
1112+ clearLargeValueCacheForTests ( )
1113+ } )
1114+
1115+ it . each ( [ 'source execution' , 'trusted key' , 'resume' , 'input' ] as const ) (
1116+ 'masks cached manifest content with %s access and stores it under the new execution' ,
1117+ async ( mode ) => {
1118+ const manifest = createManifest ( )
1119+ const state = createRestoredState ( manifest )
1120+ if ( mode === 'trusted key' )
1121+ state . trustedLargeValueAccess ! . largeValueKeys = [ manifest . chunks [ 0 ] . ref . key ! ]
1122+ const snapshot = createPiiSnapshot (
1123+ mode === 'resume' ? state : undefined ,
1124+ mode === 'input' ? { result : manifest } : { }
1125+ )
1126+ if ( mode === 'input' ) snapshot . metadata . largeValueExecutionIds = [ 'source-execution' ]
1127+ const result = await executeWorkflowCore ( {
1128+ snapshot,
1129+ callbacks : { } ,
1130+ loggingSession : loggingSession as unknown as LoggingSession ,
1131+ ...( mode === 'resume' || mode === 'input'
1132+ ? { }
1133+ : {
1134+ runFromBlock : {
1135+ startBlockId : 'start-block' ,
1136+ sourceSnapshot : state ,
1137+ sourceExecutionId :
1138+ mode === 'trusted key' ? 'intermediate-execution' : 'source-execution' ,
1139+ } ,
1140+ } ) ,
1141+ } )
1142+ await loggingSession . setPostExecutionPromise . mock . calls [ 0 ] [ 0 ]
1143+ expect ( result . success ) . toBe ( true )
1144+ expect ( executorExecuteMock ) . toHaveBeenCalledOnce ( )
1145+ expect ( downloadFileMock ) . toHaveBeenCalledWith (
1146+ expect . objectContaining ( { key : manifest . chunks [ 0 ] . ref . key , maxBytes : 64 * 1024 * 1024 } )
1147+ )
1148+ expect ( uploadFileMock ) . toHaveBeenCalledWith (
1149+ expect . objectContaining ( {
1150+ customKey : expect . stringContaining ( 'execution/workspace-1/workflow-1/execution-1/' ) ,
1151+ file : Buffer . from ( JSON . stringify ( maskedItems ) ) ,
1152+ } )
1153+ )
1154+ if ( mode !== 'input' )
1155+ expect ( state . blockStates . previous . output ) . toMatchObject ( {
1156+ result : { preview : maskedItems } ,
1157+ } )
1158+ }
1159+ )
1160+
1161+ it . each ( [
1162+ [ 'another workspace' , 'workspace-2' , 'workflow-1' , 'source-execution' ] ,
1163+ [ 'another workflow' , 'workspace-1' , 'workflow-2' , 'source-execution' ] ,
1164+ [ 'an unauthorized execution' , 'workspace-1' , 'workflow-1' , 'unrelated-execution' ] ,
1165+ ] ) (
1166+ 'refuses cached manifest content from %s before reading storage' ,
1167+ async ( _ , workspaceId , workflowId , executionId ) => {
1168+ const state = createRestoredState ( createManifest ( workspaceId , workflowId , executionId ) )
1169+ await expect (
1170+ executeWorkflowCore ( {
1171+ snapshot : createPiiSnapshot ( ) ,
1172+ callbacks : { } ,
1173+ loggingSession : loggingSession as unknown as LoggingSession ,
1174+ runFromBlock : {
1175+ startBlockId : 'start-block' ,
1176+ sourceExecutionId : 'source-execution' ,
1177+ sourceSnapshot : state ,
1178+ } ,
1179+ } )
1180+ ) . rejects . toThrow ( 'Large execution value is not available in this execution.' )
1181+ expect ( downloadFileMock ) . not . toHaveBeenCalled ( )
1182+ expect ( uploadFileMock ) . not . toHaveBeenCalled ( )
1183+ expect ( executorExecuteMock ) . not . toHaveBeenCalled ( )
1184+ }
1185+ )
1186+ } )
1187+
9961188 it ( 'marks inherited client run-from-block provenance incomplete' , async ( ) => {
9971189 executorExecuteMock . mockResolvedValue ( {
9981190 success : true ,
0 commit comments