|
| 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], |
| 18 | + ['list_buckets', { ...AUTH, compartmentId: 'compartment' }], |
| 19 | + ['get_bucket', BUCKET], |
| 20 | + ['create_bucket', { ...BUCKET, compartmentId: 'compartment' }], |
| 21 | + ['update_bucket', { ...BUCKET, versioning: 'Enabled' }], |
| 22 | + ['delete_bucket', BUCKET], |
| 23 | + ['list_objects', BUCKET], |
| 24 | + ['head_object', OBJECT], |
| 25 | + ['upload_object', { ...OBJECT, content: '' }], |
| 26 | + ['download_object', OBJECT], |
| 27 | + [ |
| 28 | + 'copy_object', |
| 29 | + { |
| 30 | + ...OBJECT, |
| 31 | + destinationRegion: 'us-phoenix-1', |
| 32 | + destinationNamespace: 'namespace', |
| 33 | + destinationBucket: 'copies', |
| 34 | + destinationObjectName: 'copy', |
| 35 | + }, |
| 36 | + ], |
| 37 | + ['rename_object', { ...OBJECT, newName: 'new' }], |
| 38 | + ['delete_object', OBJECT], |
| 39 | + ['batch_delete_objects', { ...BUCKET, objects: [{ objectName: 'report.txt' }] }], |
| 40 | + ['list_object_versions', BUCKET], |
| 41 | + ['restore_object', OBJECT], |
| 42 | + ['update_object_storage_tier', { ...OBJECT, storageTier: 'Archive' }], |
| 43 | + ['get_lifecycle_policy', BUCKET], |
| 44 | + ['put_lifecycle_policy', { ...BUCKET, rules: [] }], |
| 45 | + ['delete_lifecycle_policy', BUCKET], |
| 46 | + ['create_multipart_upload', OBJECT], |
| 47 | + ['upload_part', { ...MULTIPART, partNumber: 1, content: '' }], |
| 48 | + ['list_multipart_uploads', BUCKET], |
| 49 | + ['list_multipart_parts', MULTIPART], |
| 50 | + ['commit_multipart_upload', { ...MULTIPART, partsToCommit: [{ partNum: 1, etag: 'etag' }] }], |
| 51 | + ['abort_multipart_upload', MULTIPART], |
| 52 | + [ |
| 53 | + 'create_preauthenticated_request', |
| 54 | + { |
| 55 | + ...OBJECT, |
| 56 | + name: 'Report', |
| 57 | + scope: 'object', |
| 58 | + accessType: 'ObjectRead', |
| 59 | + timeExpires: '2099-01-01T00:00:00Z', |
| 60 | + }, |
| 61 | + ], |
| 62 | + ['list_preauthenticated_requests', BUCKET], |
| 63 | + ['get_preauthenticated_request', { ...BUCKET, parId: 'par' }], |
| 64 | + ['delete_preauthenticated_request', { ...BUCKET, parId: 'par' }], |
| 65 | + ['get_work_request', { ...AUTH, workRequestId: 'work' }], |
| 66 | +] |
| 67 | + |
| 68 | +function request(operation: string, input: unknown): InternalToolOperationCall { |
| 69 | + return { |
| 70 | + toolId: `oci_object_storage_native_${operation}`, |
| 71 | + input, |
| 72 | + headers: new Headers(), |
| 73 | + context: { workflowId: 'workflow', workspaceId: 'trusted-workspace', userId: 'actor' }, |
| 74 | + requestId: 'request', |
| 75 | + } |
| 76 | +} |
| 77 | + |
| 78 | +describe('native OCI tool operation handler', () => { |
| 79 | + beforeEach(() => { |
| 80 | + vi.clearAllMocks() |
| 81 | + mocks.executeOciNativeOperation.mockResolvedValue({ success: true, output: {} }) |
| 82 | + }) |
| 83 | + |
| 84 | + it.each(CASES)( |
| 85 | + 'validates and dispatches %s with trusted workspace context', |
| 86 | + async (operation, input) => { |
| 87 | + const result = await executeOciObjectStorageNativeTool(request(operation, input)) |
| 88 | + expect(result.status).toBe(200) |
| 89 | + expect(mocks.executeOciNativeOperation).toHaveBeenCalledWith( |
| 90 | + expect.objectContaining({ ...input, operation }), |
| 91 | + { |
| 92 | + workspaceId: 'trusted-workspace', |
| 93 | + workflowId: 'workflow', |
| 94 | + executionId: undefined, |
| 95 | + userId: 'actor', |
| 96 | + requestId: 'request', |
| 97 | + signal: undefined, |
| 98 | + } |
| 99 | + ) |
| 100 | + } |
| 101 | + ) |
| 102 | + |
| 103 | + it('maps the authorized hidden reference and strips the caller execution context', async () => { |
| 104 | + const input = createOciNativeOperationInput({ |
| 105 | + oauthCredential: 'visible-selection', |
| 106 | + accessToken: 'authorized', |
| 107 | + _context: { workspaceId: 'attacker' }, |
| 108 | + _credentialId: 'bookkeeping', |
| 109 | + _workflowId: 'workflow', |
| 110 | + credential: undefined, |
| 111 | + impersonateUserEmail: undefined, |
| 112 | + namespace: 'namespace', |
| 113 | + }) |
| 114 | + expect(input).toEqual(AUTH) |
| 115 | + await executeOciObjectStorageNativeTool(request('get_namespace', input)) |
| 116 | + expect(mocks.executeOciNativeOperation).toHaveBeenCalledWith( |
| 117 | + expect.objectContaining({ credentialId: 'authorized' }), |
| 118 | + expect.objectContaining({ workspaceId: 'trusted-workspace' }) |
| 119 | + ) |
| 120 | + const missing = createOciNativeOperationInput({ oauthCredential: 'visible-selection' }) |
| 121 | + expect( |
| 122 | + (await executeOciObjectStorageNativeTool(request('get_namespace', missing))).status |
| 123 | + ).toBe(400) |
| 124 | + }) |
| 125 | + |
| 126 | + it.each([ |
| 127 | + { ...AUTH, workspaceId: 'injected' }, |
| 128 | + { ...AUTH, operation: 'delete_bucket' }, |
| 129 | + { ...AUTH, authorization: 'injected' }, |
| 130 | + ])('rejects unexpected authority or operation fields', async (input) => { |
| 131 | + expect((await executeOciObjectStorageNativeTool(request('get_namespace', input))).status).toBe( |
| 132 | + 400 |
| 133 | + ) |
| 134 | + expect(mocks.executeOciNativeOperation).not.toHaveBeenCalled() |
| 135 | + }) |
| 136 | + |
| 137 | + it('requires trusted workspace scope', async () => { |
| 138 | + const call = request('get_namespace', AUTH) |
| 139 | + call.context.workspaceId = undefined |
| 140 | + expect((await executeOciObjectStorageNativeTool(call)).status).toBe(403) |
| 141 | + expect(mocks.executeOciNativeOperation).not.toHaveBeenCalled() |
| 142 | + }) |
| 143 | + |
| 144 | + it('uses the delegated subject for file authorization', async () => { |
| 145 | + const call = request('upload_object', { |
| 146 | + ...OBJECT, |
| 147 | + file: { key: 'file', name: 'file.txt', size: 0 }, |
| 148 | + }) |
| 149 | + call.context.executorDelegationOrigin = { |
| 150 | + subjectUserId: 'delegated-actor', |
| 151 | + workflowId: 'origin-workflow', |
| 152 | + executionId: 'origin-execution', |
| 153 | + } |
| 154 | + await executeOciObjectStorageNativeTool(call) |
| 155 | + expect(mocks.executeOciNativeOperation).toHaveBeenCalledWith( |
| 156 | + expect.anything(), |
| 157 | + expect.objectContaining({ userId: 'delegated-actor', workspaceId: 'trusted-workspace' }) |
| 158 | + ) |
| 159 | + }) |
| 160 | + |
| 161 | + it('projects safe foundation failures without exposing arbitrary error details', async () => { |
| 162 | + mocks.executeOciNativeOperation.mockRejectedValueOnce( |
| 163 | + new OciClientError('request_failed', { status: 412 }) |
| 164 | + ) |
| 165 | + const known = await executeOciObjectStorageNativeTool(request('get_namespace', AUTH)) |
| 166 | + expect(known.status).toBe(412) |
| 167 | + await expect(known.json()).resolves.toEqual({ success: false, error: 'OCI request failed' }) |
| 168 | + mocks.executeOciNativeOperation.mockRejectedValueOnce( |
| 169 | + new Error('private-key-or-storage-secret') |
| 170 | + ) |
| 171 | + const unknown = await executeOciObjectStorageNativeTool(request('get_namespace', AUTH)) |
| 172 | + await expect(unknown.json()).resolves.toEqual({ |
| 173 | + success: false, |
| 174 | + error: 'OCI Object Storage operation failed', |
| 175 | + }) |
| 176 | + }) |
| 177 | + |
| 178 | + it('preserves cancellation before and after provider work', async () => { |
| 179 | + const controller = new AbortController() |
| 180 | + const reason = new DOMException('Canceled', 'AbortError') |
| 181 | + const call = { ...request('get_namespace', AUTH), signal: controller.signal } |
| 182 | + mocks.executeOciNativeOperation.mockImplementationOnce(async () => { |
| 183 | + controller.abort(reason) |
| 184 | + throw reason |
| 185 | + }) |
| 186 | + await expect(executeOciObjectStorageNativeTool(call)).rejects.toBe(reason) |
| 187 | + mocks.executeOciNativeOperation.mockClear() |
| 188 | + await expect(executeOciObjectStorageNativeTool(call)).rejects.toBe(reason) |
| 189 | + expect(mocks.executeOciNativeOperation).not.toHaveBeenCalled() |
| 190 | + }) |
| 191 | +}) |
0 commit comments