chore: import upstream snapshot with attribution
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* @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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user