|
| 1 | +/** @vitest-environment node */ |
| 2 | +import { beforeEach, describe, expect, it, vi } from 'vitest' |
| 3 | + |
| 4 | +const mocks = vi.hoisted(() => ({ executeOciNativeOperation: vi.fn() })) |
| 5 | +vi.mock('@/lib/internal/oci-object-storage-native/operations', () => mocks) |
| 6 | + |
| 7 | +import { OciClientError } from '@/lib/internal/oci/errors' |
| 8 | +import { executeOciObjectStorageNativeTool } from '@/lib/internal/oci-object-storage-native/execute-tool' |
| 9 | +import type { InternalToolOperationCall } from '@/lib/internal/tool-operations/types' |
| 10 | +import { createOciNativeOperationInput } from '@/tools/oci_object_storage_native/shared' |
| 11 | + |
| 12 | +const AUTH = { credentialId: 'authorized', namespace: 'namespace' } |
| 13 | +const BUCKET = { ...AUTH, bucketName: 'reports' } |
| 14 | +const OBJECT = { ...BUCKET, objectName: 'report.txt' } |
| 15 | +const MULTIPART = { ...OBJECT, uploadId: 'upload' } |
| 16 | +const CASES: [string, Record<string, unknown>][] = [ |
| 17 | + ['get_namespace', AUTH], ['list_buckets', { ...AUTH, compartmentId: 'compartment' }], |
| 18 | + ['get_bucket', BUCKET], ['create_bucket', { ...BUCKET, compartmentId: 'compartment' }], |
| 19 | + ['update_bucket', { ...BUCKET, versioning: 'Enabled' }], ['delete_bucket', BUCKET], |
| 20 | + ['list_objects', BUCKET], ['head_object', OBJECT], ['upload_object', { ...OBJECT, content: '' }], |
| 21 | + ['download_object', OBJECT], ['copy_object', { ...OBJECT, destinationRegion: 'us-phoenix-1', destinationNamespace: 'namespace', destinationBucket: 'copies', destinationObjectName: 'copy' }], |
| 22 | + ['rename_object', { ...OBJECT, newName: 'new' }], ['delete_object', OBJECT], |
| 23 | + ['batch_delete_objects', { ...BUCKET, objects: [{ objectName: 'report.txt' }] }], |
| 24 | + ['list_object_versions', BUCKET], ['restore_object', OBJECT], |
| 25 | + ['update_object_storage_tier', { ...OBJECT, storageTier: 'Archive' }], |
| 26 | + ['get_lifecycle_policy', BUCKET], ['put_lifecycle_policy', { ...BUCKET, rules: [] }], ['delete_lifecycle_policy', BUCKET], |
| 27 | + ['create_multipart_upload', OBJECT], ['upload_part', { ...MULTIPART, partNumber: 1, content: '' }], |
| 28 | + ['list_multipart_uploads', BUCKET], ['list_multipart_parts', MULTIPART], |
| 29 | + ['commit_multipart_upload', { ...MULTIPART, partsToCommit: [{ partNum: 1, etag: 'etag' }] }], |
| 30 | + ['abort_multipart_upload', MULTIPART], |
| 31 | + ['create_preauthenticated_request', { ...OBJECT, name: 'Report', scope: 'object', accessType: 'ObjectRead', timeExpires: '2099-01-01T00:00:00Z' }], |
| 32 | + ['list_preauthenticated_requests', BUCKET], ['get_preauthenticated_request', { ...BUCKET, parId: 'par' }], |
| 33 | + ['delete_preauthenticated_request', { ...BUCKET, parId: 'par' }], ['get_work_request', { ...AUTH, workRequestId: 'work' }], |
| 34 | +] |
| 35 | + |
| 36 | +function request(operation: string, input: unknown): InternalToolOperationCall { |
| 37 | + return { toolId: `oci_object_storage_native_${operation}`, input, headers: new Headers(), |
| 38 | + context: { workflowId: 'workflow', workspaceId: 'trusted-workspace', userId: 'actor' }, requestId: 'request' } |
| 39 | +} |
| 40 | + |
| 41 | +describe('native OCI tool operation handler', () => { |
| 42 | + beforeEach(() => { |
| 43 | + vi.clearAllMocks() |
| 44 | + mocks.executeOciNativeOperation.mockResolvedValue({ success: true, output: {} }) |
| 45 | + }) |
| 46 | + |
| 47 | + it.each(CASES)('validates and dispatches %s with trusted workspace context', async (operation, input) => { |
| 48 | + const result = await executeOciObjectStorageNativeTool(request(operation, input)) |
| 49 | + expect(result.status).toBe(200) |
| 50 | + expect(mocks.executeOciNativeOperation).toHaveBeenCalledWith(expect.objectContaining({ ...input, operation }), { |
| 51 | + workspaceId: 'trusted-workspace', workflowId: 'workflow', executionId: undefined, userId: 'actor', requestId: 'request', signal: undefined, |
| 52 | + }) |
| 53 | + }) |
| 54 | + |
| 55 | + it('maps the authorized hidden reference and strips the caller execution context', async () => { |
| 56 | + const input = createOciNativeOperationInput({ oauthCredential: 'visible-selection', accessToken: 'authorized', _context: { workspaceId: 'attacker' }, _credentialId: 'bookkeeping', _workflowId: 'workflow', credential: undefined, impersonateUserEmail: undefined, namespace: 'namespace' }) |
| 57 | + expect(input).toEqual(AUTH) |
| 58 | + await executeOciObjectStorageNativeTool(request('get_namespace', input)) |
| 59 | + expect(mocks.executeOciNativeOperation).toHaveBeenCalledWith(expect.objectContaining({ credentialId: 'authorized' }), expect.objectContaining({ workspaceId: 'trusted-workspace' })) |
| 60 | + const missing = createOciNativeOperationInput({ oauthCredential: 'visible-selection' }) |
| 61 | + expect((await executeOciObjectStorageNativeTool(request('get_namespace', missing))).status).toBe(400) |
| 62 | + }) |
| 63 | + |
| 64 | + it.each([{ ...AUTH, workspaceId: 'injected' }, { ...AUTH, operation: 'delete_bucket' }, { ...AUTH, authorization: 'injected' }])('rejects unexpected authority or operation fields', async (input) => { |
| 65 | + expect((await executeOciObjectStorageNativeTool(request('get_namespace', input))).status).toBe(400) |
| 66 | + expect(mocks.executeOciNativeOperation).not.toHaveBeenCalled() |
| 67 | + }) |
| 68 | + |
| 69 | + it('requires trusted workspace scope', async () => { |
| 70 | + const call = request('get_namespace', AUTH) |
| 71 | + delete call.context.workspaceId |
| 72 | + expect((await executeOciObjectStorageNativeTool(call)).status).toBe(403) |
| 73 | + expect(mocks.executeOciNativeOperation).not.toHaveBeenCalled() |
| 74 | + }) |
| 75 | + |
| 76 | + it('uses the delegated subject for file authorization', async () => { |
| 77 | + const call = request('upload_object', { ...OBJECT, file: { key: 'file', name: 'file.txt', size: 0 } }) |
| 78 | + call.context.executorDelegationOrigin = { subjectUserId: 'delegated-actor', workflowId: 'origin-workflow', executionId: 'origin-execution' } |
| 79 | + await executeOciObjectStorageNativeTool(call) |
| 80 | + expect(mocks.executeOciNativeOperation).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ userId: 'delegated-actor', workspaceId: 'trusted-workspace' })) |
| 81 | + }) |
| 82 | + |
| 83 | + it('projects safe foundation failures without exposing arbitrary error details', async () => { |
| 84 | + mocks.executeOciNativeOperation.mockRejectedValueOnce(new OciClientError('request_failed', { status: 412 })) |
| 85 | + const known = await executeOciObjectStorageNativeTool(request('get_namespace', AUTH)) |
| 86 | + expect(known.status).toBe(412) |
| 87 | + await expect(known.json()).resolves.toEqual({ success: false, error: 'OCI request failed' }) |
| 88 | + mocks.executeOciNativeOperation.mockRejectedValueOnce(new Error('private-key-or-storage-secret')) |
| 89 | + const unknown = await executeOciObjectStorageNativeTool(request('get_namespace', AUTH)) |
| 90 | + await expect(unknown.json()).resolves.toEqual({ success: false, error: 'OCI Object Storage operation failed' }) |
| 91 | + }) |
| 92 | + |
| 93 | + it('preserves cancellation before and after provider work', async () => { |
| 94 | + const controller = new AbortController() |
| 95 | + const reason = new DOMException('Canceled', 'AbortError') |
| 96 | + const call = { ...request('get_namespace', AUTH), signal: controller.signal } |
| 97 | + mocks.executeOciNativeOperation.mockImplementationOnce(async () => { controller.abort(reason); throw reason }) |
| 98 | + await expect(executeOciObjectStorageNativeTool(call)).rejects.toBe(reason) |
| 99 | + mocks.executeOciNativeOperation.mockClear() |
| 100 | + await expect(executeOciObjectStorageNativeTool(call)).rejects.toBe(reason) |
| 101 | + expect(mocks.executeOciNativeOperation).not.toHaveBeenCalled() |
| 102 | + }) |
| 103 | +}) |
0 commit comments