Skip to content

Commit 3738347

Browse files
committed
Harden VFS mutation handling
1 parent eaee669 commit 3738347

9 files changed

Lines changed: 752 additions & 124 deletions

File tree

apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts

Lines changed: 179 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,14 @@ const mocks = vi.hoisted(() => ({
4242
deleteFileVfsItems: vi.fn(),
4343
renameTableVfs: vi.fn(),
4444
deleteTableVfs: vi.fn(),
45+
transferTableVfs: vi.fn(),
46+
createTableFolders: vi.fn(),
47+
deleteTableFolders: vi.fn(),
4548
renameKnowledgeVfs: vi.fn(),
4649
deleteKnowledgeVfs: vi.fn(),
50+
transferKnowledgeVfs: vi.fn(),
51+
createKnowledgeFolders: vi.fn(),
52+
deleteKnowledgeFolders: vi.fn(),
4753
}))
4854

4955
vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock }))
@@ -163,6 +169,18 @@ vi.mock('@/lib/table/application/table-vfs', () => ({
163169
operation: tableOperations.deleteByVfsPath,
164170
execute: mocks.deleteTableVfs,
165171
},
172+
transferTableVfsItems: {
173+
operation: tableOperations.moveByVfsPath,
174+
execute: mocks.transferTableVfs,
175+
},
176+
createTableVfsFolders: {
177+
operation: tableOperations.createFolder,
178+
execute: mocks.createTableFolders,
179+
},
180+
deleteTableVfsFolders: {
181+
operation: tableOperations.deleteFolder,
182+
execute: mocks.deleteTableFolders,
183+
},
166184
}))
167185

168186
vi.mock('@/lib/knowledge/application/knowledge-vfs', () => ({
@@ -174,6 +192,18 @@ vi.mock('@/lib/knowledge/application/knowledge-vfs', () => ({
174192
operation: knowledgeOperations.deleteByVfsPath,
175193
execute: mocks.deleteKnowledgeVfs,
176194
},
195+
transferKnowledgeVfsItems: {
196+
operation: knowledgeOperations.moveByVfsPath,
197+
execute: mocks.transferKnowledgeVfs,
198+
},
199+
createKnowledgeVfsFolders: {
200+
operation: knowledgeOperations.manageVfsFolders,
201+
execute: mocks.createKnowledgeFolders,
202+
},
203+
deleteKnowledgeVfsFolders: {
204+
operation: knowledgeOperations.manageVfsFolders,
205+
execute: mocks.deleteKnowledgeFolders,
206+
},
177207
}))
178208

179209
vi.mock('@/lib/table/service', () => ({
@@ -776,15 +806,41 @@ describe('vfs mv/cp', () => {
776806
})
777807
})
778808

779-
it('rejects flat namespaces', async () => {
809+
it('creates table folders through the table application operation', async () => {
810+
mocks.createTableFolders.mockResolvedValue({
811+
outcomes: [
812+
{ source: 'tables/CRM', kind: 'folder', resourceId: 'fld-1', targetSegments: ['CRM'] },
813+
],
814+
})
815+
780816
const result = await executeVfsMkdir({ paths: ['tables/CRM'] }, context)
781-
expect(result.success).toBe(false)
817+
818+
expect(mocks.createTableFolders).toHaveBeenCalledWith(
819+
expect.objectContaining({
820+
input: {
821+
workspaceId: 'ws-1',
822+
paths: [{ source: 'tables/CRM', segments: ['CRM'] }],
823+
},
824+
})
825+
)
826+
expect(result.success).toBe(true)
782827
expect(result.output).toMatchObject({
783-
results: [{ from: 'tables/CRM', error: expect.stringContaining('flat namespace') }],
828+
results: [{ from: 'tables/CRM', to: 'tables/CRM', kind: 'table_folder', id: 'fld-1' }],
784829
})
785830
expect(mocks.ensureCopilotFileFolderPath).not.toHaveBeenCalled()
786831
})
787832

833+
it('rejects the reserved knowledgebases/connectors folder path', async () => {
834+
const result = await executeVfsMkdir({ paths: ['knowledgebases/connectors/sub'] }, context)
835+
expect(result.success).toBe(false)
836+
expect(result.output).toMatchObject({
837+
results: [
838+
{ from: 'knowledgebases/connectors/sub', error: expect.stringContaining('reserved') },
839+
],
840+
})
841+
expect(mocks.createKnowledgeFolders).not.toHaveBeenCalled()
842+
})
843+
788844
it('rejects creation inside a locked workflow folder', async () => {
789845
mocks.createWorkflowVfsFolders.mockResolvedValue({
790846
outcomes: [
@@ -804,30 +860,65 @@ describe('vfs mv/cp', () => {
804860
})
805861
})
806862

807-
describe('tables and knowledge bases (flat namespaces)', () => {
808-
it('renames a table', async () => {
863+
describe('tables and knowledge bases (foldered)', () => {
864+
it('renames a table through the transfer application operation', async () => {
865+
mocks.transferTableVfs.mockResolvedValue({
866+
outcomes: [
867+
{
868+
source: 'tables/Leads',
869+
kind: 'resource',
870+
resourceId: 'tbl-1',
871+
targetSegments: ['Customers'],
872+
},
873+
],
874+
})
875+
809876
const result = await executeVfsMv(
810877
{ sources: ['tables/Leads'], destination: 'tables/Customers' },
811878
context
812879
)
813880

814-
expect(mocks.renameTableVfs).toHaveBeenCalledWith(
881+
expect(mocks.transferTableVfs).toHaveBeenCalledWith(
815882
expect.objectContaining({
816-
input: { workspaceId: 'ws-1', sourceName: 'Leads', newName: 'Customers' },
883+
input: {
884+
workspaceId: 'ws-1',
885+
sources: [{ source: 'tables/Leads', segments: ['Leads'] }],
886+
destination: { segments: ['Customers'], trailingSlash: false },
887+
},
817888
})
818889
)
819890
expect(result.success).toBe(true)
820891
expect(result.output).toMatchObject({ results: [{ to: 'tables/Customers', kind: 'table' }] })
821892
})
822893

823-
it('rejects nested table destinations as flat-namespace violations', async () => {
894+
it('moves a table into a folder, folders auto-created server-side', async () => {
895+
mocks.transferTableVfs.mockResolvedValue({
896+
outcomes: [
897+
{
898+
source: 'tables/Leads',
899+
kind: 'resource',
900+
resourceId: 'tbl-1',
901+
targetSegments: ['CRM', 'Leads'],
902+
},
903+
],
904+
})
905+
824906
const result = await executeVfsMv(
825-
{ sources: ['tables/Leads'], destination: 'tables/CRM/Leads' },
907+
{ sources: ['tables/Leads'], destination: 'tables/CRM/' },
826908
context
827909
)
828-
expect(result.success).toBe(false)
829-
expect(result.error).toContain('flat namespace')
830-
expect(mocks.renameTable).not.toHaveBeenCalled()
910+
911+
expect(mocks.transferTableVfs).toHaveBeenCalledWith(
912+
expect.objectContaining({
913+
input: expect.objectContaining({
914+
destination: { segments: ['CRM'], trailingSlash: true },
915+
}),
916+
})
917+
)
918+
expect(result.success).toBe(true)
919+
expect(result.output).toMatchObject({
920+
results: [{ to: 'tables/CRM/Leads', kind: 'table' }],
921+
})
831922
})
832923

833924
it('rejects copying tables', async () => {
@@ -840,12 +931,23 @@ describe('vfs mv/cp', () => {
840931
})
841932

842933
it('renames a knowledge base through trusted application operations', async () => {
934+
mocks.transferKnowledgeVfs.mockResolvedValue({
935+
outcomes: [
936+
{
937+
source: 'knowledgebases/Docs',
938+
kind: 'resource',
939+
resourceId: 'kb-1',
940+
targetSegments: ['Product Docs'],
941+
},
942+
],
943+
})
944+
843945
const result = await executeVfsMv(
844946
{ sources: ['knowledgebases/Docs'], destination: 'knowledgebases/Product Docs' },
845947
context
846948
)
847949

848-
expect(mocks.renameKnowledgeVfs).toHaveBeenCalledWith(
950+
expect(mocks.transferKnowledgeVfs).toHaveBeenCalledWith(
849951
expect.objectContaining({
850952
principal: expect.objectContaining({
851953
kind: 'delegated',
@@ -855,16 +957,16 @@ describe('vfs mv/cp', () => {
855957
}),
856958
input: {
857959
workspaceId: 'ws-1',
858-
sourceName: 'Docs',
859-
newName: 'Product Docs',
960+
sources: [{ source: 'knowledgebases/Docs', segments: ['Docs'] }],
961+
destination: { segments: ['Product Docs'], trailingSlash: false },
860962
},
861963
})
862964
)
863965
expect(result.success).toBe(true)
864966
})
865967

866968
it('propagates knowledge application infrastructure failures', async () => {
867-
mocks.renameKnowledgeVfs.mockRejectedValueOnce(new Error('knowledge database unavailable'))
969+
mocks.transferKnowledgeVfs.mockRejectedValueOnce(new Error('knowledge database unavailable'))
868970

869971
await expect(
870972
executeVfsMv(
@@ -875,7 +977,7 @@ describe('vfs mv/cp', () => {
875977
})
876978

877979
it('preserves an actionable knowledge rename conflict', async () => {
878-
mocks.renameKnowledgeVfs.mockRejectedValue(
980+
mocks.transferKnowledgeVfs.mockRejectedValue(
879981
new OrchestrationError('conflict', 'A knowledge base named Product Docs already exists')
880982
)
881983

@@ -912,11 +1014,71 @@ describe('vfs mv/cp', () => {
9121014
input: {
9131015
workspaceId: 'ws-1',
9141016
sourceName: 'Docs',
1017+
sourceSegments: ['Docs'],
9151018
},
9161019
})
9171020
)
9181021
})
9191022

1023+
it('moves a whole table folder through the transfer operation', async () => {
1024+
mocks.transferTableVfs.mockResolvedValue({
1025+
outcomes: [
1026+
{
1027+
source: 'tables/CRM',
1028+
kind: 'folder',
1029+
resourceId: 'fld-1',
1030+
targetSegments: ['Archive', 'CRM'],
1031+
},
1032+
],
1033+
})
1034+
1035+
const result = await executeVfsMv(
1036+
{ sources: ['tables/CRM'], destination: 'tables/Archive/' },
1037+
context
1038+
)
1039+
1040+
expect(result.success).toBe(true)
1041+
expect(result.output).toMatchObject({
1042+
results: [{ to: 'tables/Archive/CRM', kind: 'table_folder' }],
1043+
})
1044+
})
1045+
1046+
it('rm retargets to the folder cascade when the path is a folder', async () => {
1047+
mocks.deleteTableVfs.mockRejectedValue(
1048+
new OrchestrationError('invalid', 'tables/CRM is a folder; this operation takes a table.')
1049+
)
1050+
mocks.deleteTableFolders.mockResolvedValue({
1051+
outcomes: [{ source: 'tables/CRM', kind: 'folder', resourceId: 'fld-1' }],
1052+
})
1053+
1054+
const result = await executeVfsRm({ paths: ['tables/CRM'] }, context)
1055+
1056+
expect(mocks.deleteTableFolders).toHaveBeenCalledWith(
1057+
expect.objectContaining({
1058+
input: { workspaceId: 'ws-1', paths: [{ source: 'tables/CRM', segments: ['CRM'] }] },
1059+
})
1060+
)
1061+
expect(result.success).toBe(true)
1062+
expect(result.output).toMatchObject({
1063+
results: [{ from: 'tables/CRM', kind: 'table_folder', id: 'fld-1' }],
1064+
})
1065+
})
1066+
1067+
it('deletes a nested knowledge base by its folder path', async () => {
1068+
const result = await executeVfsRm({ paths: ['knowledgebases/Legal/Contracts'] }, context)
1069+
1070+
expect(mocks.deleteKnowledgeVfs).toHaveBeenCalledWith(
1071+
expect.objectContaining({
1072+
input: {
1073+
workspaceId: 'ws-1',
1074+
sourceName: 'Contracts',
1075+
sourceSegments: ['Legal', 'Contracts'],
1076+
},
1077+
})
1078+
)
1079+
expect(result.success).toBe(true)
1080+
})
1081+
9201082
it('preserves an actionable knowledge delete failure', async () => {
9211083
mocks.deleteKnowledgeVfs.mockRejectedValue(
9221084
new OrchestrationError('not_found', 'Knowledge base no longer exists')

0 commit comments

Comments
 (0)