Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions apps/sim/app/api/table/row-secret-provenance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,19 @@ import {
PRIVATE_SECRET_PROVENANCE_BUNDLE_V1,
PRIVATE_SECRET_PROVENANCE_FIELD,
PRIVATE_SECRET_PROVENANCE_HEADER,
PRIVATE_TOOL_METADATA_REQUEST_HEADER,
PRIVATE_TOOL_METADATA_RESPONSE_HEADER,
RESOLVED_SECRET_PROVENANCE_METADATA_V1,
} from '@/lib/execution/private-tool-metadata'
import { TableRowProvenanceError } from '@/lib/table/application/row-secret-provenance'
import { rowDataNameToId } from '@/lib/table/column-keys'
import { tableRowSecretProvenanceSelectionKey } from '@/lib/table/secret-provenance-selection'
import type { RowData } from '@/lib/table/types'
import {
createTableWriteProvenanceTargets,
finalizeTableRowsProvenance,
negotiateTableRowsProvenance,
readTableRowProvenanceEnvelope,
resolveTableWriteSecretProvenance,
} from '@/app/api/table/row-secret-provenance'

Expand Down Expand Up @@ -258,3 +265,77 @@ describe('resolveTableWriteSecretProvenance', () => {
expect(result.success).toBe(false)
})
})

/**
* The transport half of the envelope, used by the migrated single-row routes.
*
* These are the only cover these helpers have: the route tests assert `mapInput`
* and `present`, so each helper could be replaced by a constant without a route
* test noticing — and a constant `readTableRowProvenanceEnvelope` would silently
* downgrade every executor write from a stamped bundle to untracked.
*/
describe('readTableRowProvenanceEnvelope', () => {
it('reports no envelope when the caller sent none', () => {
const request = createMockRequest('PATCH', { data: {} })

expect(readTableRowProvenanceEnvelope(request, { data: {} })).toEqual({ kind: 'none' })
})

it('hands the verified bundle over unresolved', () => {
const { request, payload } = bundleRequest([tableRowSecretProvenanceSelectionKey(0, 'email')])

const envelope = readTableRowProvenanceEnvelope(request, payload)

expect(envelope.kind).toBe('bundle')
expect(envelope).toEqual({ kind: 'bundle', value: payload[PRIVATE_SECRET_PROVENANCE_FIELD] })
})

it('rejects a declared bundle whose payload field is missing', () => {
const request = createMockRequest(
'PATCH',
{ data: {} },
{ [PRIVATE_SECRET_PROVENANCE_HEADER]: PRIVATE_SECRET_PROVENANCE_BUNDLE_V1 }
)

expect(() => readTableRowProvenanceEnvelope(request, { data: {} })).toThrow(
TableRowProvenanceError
)
})
})

describe('negotiateTableRowsProvenance', () => {
it('is not requested without the capability header', () => {
expect(negotiateTableRowsProvenance(createMockRequest('GET', undefined), true)).toBe(false)
})

it('is accepted for an internal caller that asked for it', () => {
const request = createMockRequest('GET', undefined, {
[PRIVATE_TOOL_METADATA_REQUEST_HEADER]: RESOLVED_SECRET_PROVENANCE_METADATA_V1,
})

expect(negotiateTableRowsProvenance(request, true)).toBe(true)
})

it('rejects a session caller that asks for the internal capability', () => {
const request = createMockRequest('GET', undefined, {
[PRIVATE_TOOL_METADATA_REQUEST_HEADER]: RESOLVED_SECRET_PROVENANCE_METADATA_V1,
})

expect(() => negotiateTableRowsProvenance(request, false)).toThrow(TableRowProvenanceError)
})
})

describe('finalizeTableRowsProvenance', () => {
it('adds nothing when the use case loaded no provenance', () => {
expect(finalizeTableRowsProvenance(undefined)).toEqual({})
})

it('adds the sibling body field and the capability header when it did', () => {
const finalized = finalizeTableRowsProvenance({ rows: [] })

expect(finalized.bodyFields).toBeDefined()
expect(new Headers(finalized.headers).get(PRIVATE_TOOL_METADATA_RESPONSE_HEADER)).toBe(
RESOLVED_SECRET_PROVENANCE_METADATA_V1
)
})
})
78 changes: 76 additions & 2 deletions apps/sim/lib/copilot/tools/server/table/user-table.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ const {
mockDownloadWorkspaceFile,
mockGetTableById,
mockBatchInsertRows,
mockInsertRow,
mockUpdateRow,
mockReplaceTableRows,
mockAddWorkflowGroup,
mockCreateTable,
Expand Down Expand Up @@ -42,6 +44,8 @@ const {
mockDownloadWorkspaceFile: vi.fn(),
mockGetTableById: vi.fn(),
mockBatchInsertRows: vi.fn(),
mockInsertRow: vi.fn(),
mockUpdateRow: vi.fn(),
mockReplaceTableRows: vi.fn(),
mockAddWorkflowGroup: vi.fn(),
mockCreateTable: vi.fn(),
Expand Down Expand Up @@ -208,10 +212,10 @@ vi.mock('@/lib/table/rows/service', () => ({
deleteRowsByFilter: mockDeleteRowsByFilter,
deleteRowsByIds: vi.fn(),
getRowById: vi.fn(),
insertRow: vi.fn(),
insertRow: mockInsertRow,
queryRows: mockQueryRows,
replaceTableRows: mockReplaceTableRows,
updateRow: vi.fn(),
updateRow: mockUpdateRow,
updateRowsByFilter: mockUpdateRowsByFilter,
}))

Expand Down Expand Up @@ -1669,3 +1673,73 @@ describe('userTableServerTool.delete bounds', () => {
expect(mockGetTableById).not.toHaveBeenCalled()
})
})

/**
* Copilot is the one row-write surface whose column keys come from a model rather
* than from the schema, so `dataKeying: 'names'` is what stands between an
* LLM-authored key and the storage column it means.
*
* These pin the translated outcome rather than the literal: the shared `buildTable`
* fixture uses legacy columns with no `id`, where name-to-id mapping is the identity
* and flipping the keying is unobservable. Columns whose `id` differs from `name` are
* what make the wrong keying fail — under `'ids'` the lax write path stores the
* model's key verbatim and reports success, corrupting the row silently.
*/
describe('userTableServerTool row writes key model-supplied columns by name', () => {
const KEYED_TABLE = buildTable({
schema: {
columns: [
{ id: 'col_name', name: 'name', type: 'string', required: true },
{ id: 'col_age', name: 'age', type: 'number' },
],
},
})

beforeEach(() => {
vi.clearAllMocks()
mockGetTableById.mockResolvedValue(KEYED_TABLE)
})

it('translates an inserted row to storage column ids', async () => {
mockInsertRow.mockResolvedValue({
id: 'row-1',
data: { col_name: 'Ada', col_age: 36 },
position: 0,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
})

await userTableServerTool.execute(
{
operation: 'insert_row',
args: { tableId: 'tbl_1', data: { name: 'Ada', age: 36 } },
},
buildToolContext()
)

expect(mockInsertRow).toHaveBeenCalledTimes(1)
expect(mockInsertRow.mock.calls[0][0].data).toEqual({ col_name: 'Ada', col_age: 36 })
})

it('translates an updated row to storage column ids', async () => {
mockUpdateRow.mockResolvedValue({
id: 'row-1',
data: { col_name: 'Grace' },
position: 0,
executions: {},
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
})

await userTableServerTool.execute(
{
operation: 'update_row',
args: { tableId: 'tbl_1', rowId: 'row-1', data: { name: 'Grace' } },
},
buildToolContext()
)

expect(mockUpdateRow).toHaveBeenCalledTimes(1)
expect(mockUpdateRow.mock.calls[0][0].data).toEqual({ col_name: 'Grace' })
})
})
Loading
Loading