Files
simstudioai--sim/apps/sim/lib/logs/execution/pii-redaction.test.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

195 lines
7.3 KiB
TypeScript

/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockMaskPIIBatch } = vi.hoisted(() => ({
mockMaskPIIBatch: vi.fn(),
}))
vi.mock('@/lib/guardrails/mask-client', () => ({
maskPIIBatchViaHttp: mockMaskPIIBatch,
}))
import {
PiiRedactionError,
REDACTION_FAILED_MARKER,
redactObjectStrings,
redactPIIFromExecution,
} from '@/lib/logs/execution/pii-redaction'
describe('redactPIIFromExecution', () => {
beforeEach(() => {
vi.clearAllMocks()
// Default: echo each input uppercased so we can assert substitution by position.
mockMaskPIIBatch.mockImplementation(async (texts: string[]) => texts.map((t) => `MASKED(${t})`))
})
it('collects and masks string leaves recursively, preserving structure', async () => {
const payload = {
traceSpans: [
{
blockId: 'b1',
status: 'success',
input: { email: 'a@b.com' },
output: { text: 'hello' },
children: [{ blockId: 'c1', output: { nested: 'deep' } }],
},
],
finalOutput: { answer: 'world' },
workflowInput: 'start',
}
const result = await redactPIIFromExecution(payload, { entityTypes: ['EMAIL_ADDRESS'] })
const span = (result.traceSpans as any[])[0]
expect(span.blockId).toBe('b1')
expect(span.status).toBe('success')
expect(span.input.email).toBe('MASKED(a@b.com)')
expect(span.output.text).toBe('MASKED(hello)')
expect(span.children[0].output.nested).toBe('MASKED(deep)')
expect((result.finalOutput as any).answer).toBe('MASKED(world)')
expect(result.workflowInput).toBe('MASKED(start)')
expect(mockMaskPIIBatch).toHaveBeenCalledTimes(1)
expect(mockMaskPIIBatch.mock.calls[0][0]).toEqual([
'a@b.com',
'hello',
'deep',
'world',
'start',
])
})
it('does not mutate the original payload', async () => {
const payload = { finalOutput: { answer: 'world' } }
await redactPIIFromExecution(payload, { entityTypes: [] })
expect(payload.finalOutput.answer).toBe('world')
})
it('scrubs all eligible strings when masking throws (no leak)', async () => {
mockMaskPIIBatch.mockRejectedValueOnce(new Error('presidio down'))
const payload = {
traceSpans: [{ output: { text: 'secret@x.com' } }],
finalOutput: 'another secret',
}
const result = await redactPIIFromExecution(payload, { entityTypes: [] })
expect((result.traceSpans as any[])[0].output.text).toBe(REDACTION_FAILED_MARKER)
expect(result.finalOutput).toBe(REDACTION_FAILED_MARKER)
})
it('masks large strings too (never left unredacted)', async () => {
const big = 'x'.repeat(200 * 1024)
const payload = { finalOutput: { big, small: 'pii' } }
const result = await redactPIIFromExecution(payload, { entityTypes: [] })
expect((result.finalOutput as any).big).toBe(`MASKED(${big})`)
expect((result.finalOutput as any).small).toBe('MASKED(pii)')
expect(mockMaskPIIBatch.mock.calls[0][0]).toEqual([big, 'pii'])
})
it('masks span error/errorMessage and top-level error, trigger, executionState, environment', async () => {
const payload = {
traceSpans: [{ blockId: 'b1', error: 'failed for bob@x.com', errorMessage: 'bad input z' }],
error: 'run failed: a@b.com',
completionFailure: 'cancelled by c@d.com',
trigger: { type: 'webhook', data: { from: 'caller@x.com' } },
executionState: { status: 'completed', note: 'state for e@f.com' },
environment: { variables: { CONTACT: 'admin@x.com' } },
correlation: { source: 'corr@x.com' },
}
const result = await redactPIIFromExecution(payload, { entityTypes: ['EMAIL_ADDRESS'] })
const span = (result.traceSpans as any[])[0]
expect(span.blockId).toBe('b1')
expect(span.error).toBe('MASKED(failed for bob@x.com)')
expect(span.errorMessage).toBe('MASKED(bad input z)')
expect(result.error).toBe('MASKED(run failed: a@b.com)')
expect(result.completionFailure).toBe('MASKED(cancelled by c@d.com)')
expect((result.trigger as any).type).toBe('MASKED(webhook)')
expect((result.trigger as any).data.from).toBe('MASKED(caller@x.com)')
expect((result.executionState as any).note).toBe('MASKED(state for e@f.com)')
expect((result.environment as any).variables.CONTACT).toBe('MASKED(admin@x.com)')
expect((result.correlation as any).source).toBe('MASKED(corr@x.com)')
})
it('returns payload unchanged when there is nothing to mask', async () => {
const payload = { traceSpans: [{ blockId: 'b1', count: 5 }] }
const result = await redactPIIFromExecution(payload, { entityTypes: [] })
expect(result).toBe(payload)
expect(mockMaskPIIBatch).not.toHaveBeenCalled()
})
})
describe('redactObjectStrings', () => {
beforeEach(() => {
vi.clearAllMocks()
mockMaskPIIBatch.mockImplementation(async (texts: string[]) => texts.map((t) => `MASKED(${t})`))
})
it('masks every string leaf and preserves structure', async () => {
const value = { name: 'bob', nested: { email: 'a@b.com' }, list: ['x', 1, true] }
const result = await redactObjectStrings(value, { entityTypes: ['PERSON'] })
expect(result).toEqual({
name: 'MASKED(bob)',
nested: { email: 'MASKED(a@b.com)' },
list: ['MASKED(x)', 1, true],
})
expect(mockMaskPIIBatch).toHaveBeenCalledTimes(1)
})
it('leaves non-string and empty values untouched', async () => {
const value = { count: 5, flag: false, empty: '', nullish: null }
const result = await redactObjectStrings(value, { entityTypes: [] })
expect(result).toEqual(value)
expect(mockMaskPIIBatch).not.toHaveBeenCalled()
})
it('throws PiiRedactionError on masking failure when onFailure is throw', async () => {
mockMaskPIIBatch.mockRejectedValueOnce(new Error('presidio down'))
await expect(
redactObjectStrings({ text: 'a@b.com' }, { entityTypes: [], onFailure: 'throw' })
).rejects.toBeInstanceOf(PiiRedactionError)
})
it('masks large payloads (no size ceiling) rather than scrubbing them', async () => {
const big = 'x'.repeat(17 * 1024 * 1024)
const result = (await redactObjectStrings({ big }, { entityTypes: [] })) as { big: string }
expect(result.big).toBe(`MASKED(${big})`)
expect(mockMaskPIIBatch).toHaveBeenCalledTimes(1)
})
it('scrubs (does not throw) by default on failure', async () => {
mockMaskPIIBatch.mockRejectedValueOnce(new Error('presidio down'))
const result = await redactObjectStrings({ text: 'a@b.com' }, { entityTypes: [] })
expect(result).toEqual({ text: REDACTION_FAILED_MARKER })
})
})
describe('transformStrings (via redactObjectStrings) leaves large-value refs intact', () => {
beforeEach(() => {
vi.clearAllMocks()
mockMaskPIIBatch.mockImplementation(async (texts: string[]) => texts.map((t) => `MASKED(${t})`))
})
it('does not recurse into / corrupt a large-value ref while masking siblings', async () => {
const ref = {
__simLargeValueRef: true,
version: 1,
id: 'lv_abcdef123456',
kind: 'object',
size: 9_000_000,
}
const result = (await redactObjectStrings(
{ name: 'bob', big: ref },
{ entityTypes: ['PERSON'] }
)) as any
expect(result.name).toBe('MASKED(bob)')
// The ref is left byte-for-byte intact (its key/id are not masked).
expect(result.big).toEqual(ref)
})
})