Skip to content

Commit 7c35004

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(oracle-epm-platform): address contract and review findings
1 parent d835ef3 commit 7c35004

33 files changed

Lines changed: 639 additions & 193 deletions

apps/docs/content/docs/integrations/oracle_epm_platform.mdx

Lines changed: 105 additions & 76 deletions
Large diffs are not rendered by default.

apps/sim/blocks/blocks/oracle_epm_platform.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -690,7 +690,7 @@ export const OracleEpmPlatformBlock: BlockConfig<
690690
required: true,
691691
},
692692
{
693-
value: () => false,
693+
defaultValue: false,
694694
id: 'skipNext',
695695
title: 'Skip Next Scheduled Maintenance',
696696
type: 'switch',
@@ -701,7 +701,7 @@ export const OracleEpmPlatformBlock: BlockConfig<
701701
mode: 'advanced',
702702
},
703703
{
704-
value: () => false,
704+
defaultValue: false,
705705
id: 'enabled',
706706
title: 'Enable Setting',
707707
type: 'switch',
@@ -743,7 +743,7 @@ export const OracleEpmPlatformBlock: BlockConfig<
743743
field: 'operation',
744744
value: ['oracle_epm_platform_create_groups', 'oracle_epm_platform_delete_groups'],
745745
},
746-
placeholder: '[{"groupname":"Finance","description":"Finance team"}]',
746+
placeholder: '[{"groupname":"Finance"}]',
747747
required: true,
748748
rows: 4,
749749
},
@@ -882,7 +882,7 @@ export const OracleEpmPlatformBlock: BlockConfig<
882882
mode: 'advanced',
883883
},
884884
{
885-
value: () => false,
885+
defaultValue: false,
886886
id: 'importUsers',
887887
title: 'Import Identity-domain Users and Roles',
888888
type: 'switch',
@@ -908,7 +908,7 @@ export const OracleEpmPlatformBlock: BlockConfig<
908908
},
909909
},
910910
{
911-
value: () => true,
911+
defaultValue: true,
912912
id: 'resetPassword',
913913
title: 'Require Password Reset at First Login',
914914
type: 'switch',
@@ -930,7 +930,7 @@ export const OracleEpmPlatformBlock: BlockConfig<
930930
field: 'operation',
931931
value: ['oracle_epm_platform_get_admin_job_status'],
932932
},
933-
placeholder: 'Numeric ID returned by a starter',
933+
placeholder: 'Exact jobId returned by a starter',
934934
required: true,
935935
},
936936
{
@@ -959,7 +959,7 @@ export const OracleEpmPlatformBlock: BlockConfig<
959959
],
960960
},
961961
{
962-
value: () => false,
962+
defaultValue: false,
963963
id: 'waitForCompletion',
964964
title: 'Wait for Completion',
965965
type: 'switch',
@@ -1093,7 +1093,7 @@ export const OracleEpmPlatformBlock: BlockConfig<
10931093
startTime: {
10941094
type: 'string',
10951095
description:
1096-
'Whole-hour start time: HH:00, optionally followed by a space and a time zone such as 19:00 America/Los_Angeles',
1096+
'Start time from 00:00 through 23:59 (HH:MM), optionally followed by a space and a standard time zone such as 14:35 America/Los_Angeles',
10971097
},
10981098
skipNext: {
10991099
type: 'boolean',
@@ -1189,7 +1189,8 @@ export const OracleEpmPlatformBlock: BlockConfig<
11891189
},
11901190
jobId: {
11911191
type: 'string',
1192-
description: 'Numeric job ID returned by an administrative starter',
1192+
description:
1193+
'Exact jobId returned by a starter: an Oracle numeric ID or a tagged Sim repository-upload reference',
11931194
},
11941195
jobKind: {
11951196
type: 'string',
@@ -1531,7 +1532,7 @@ export const OracleEpmPlatformBlock: BlockConfig<
15311532
}
15321533

15331534
export const OracleEpmPlatformBlockMeta = {
1534-
tags: ['security', 'automation', 'monitoring'],
1535+
tags: ['identity', 'automation', 'monitoring'],
15351536
url: 'https://www.oracle.com/performance-management/',
15361537
templates: [
15371538
{

apps/sim/lib/internal/oracle-epm-platform/execute-tool.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,8 +121,17 @@ describe('Oracle EPM Platform in-process execution', () => {
121121
},
122122
{ operation: 'get_admin_job_status', input: { jobKind: 'planning', jobId: '12' } },
123123
{ operation: 'delete_file', input: { fileName: '../other' } },
124+
{ operation: 'delete_file', input: { fileName: 'inbox\\..\\other' } },
125+
{ operation: 'delete_file', input: { fileName: 'inbox\\\\other' } },
126+
{ operation: 'delete_file', input: { fileName: 'C:\\other' } },
127+
{ operation: 'delete_file', input: { fileName: 'inbox/\uD800.csv' } },
128+
{ operation: 'get_snapshot', input: { snapshotName: '\uDC00' } },
129+
{ operation: 'set_maintenance_window', input: { startTime: '24:00' } },
130+
{ operation: 'set_maintenance_window', input: { startTime: '23:60' } },
131+
{ operation: 'set_maintenance_window', input: { startTime: '14:35 Bad\\Zone' } },
124132
{ operation: 'create_users', input: { users: [{ userlogin: 'u', password: 'input-secret' }] } },
125133
{ operation: 'update_users', input: { users: [{ userlogin: 'u', password: 'input-secret' }] } },
134+
{ operation: 'update_users', input: { users: [{ userlogin: 'u' }] } },
126135
{
127136
operation: 'import_snapshot',
128137
input: { snapshotName: 'Snapshot', userPassword: 'input-secret', importUsers: false },
@@ -138,6 +147,40 @@ describe('Oracle EPM Platform in-process execution', () => {
138147
expect(mockSecureFetch).not.toHaveBeenCalled()
139148
})
140149

150+
it.each(['00:00', '23:59', '14:35 America/Los_Angeles'])(
151+
'accepts documented maintenance time %s unchanged',
152+
async (startTime) => {
153+
expect((await execute('set_maintenance_window', { ...auth, startTime })).status).toBe(200)
154+
expect(JSON.parse(mockSecureFetch.mock.calls[0][2].body)).toEqual({ startTime })
155+
}
156+
)
157+
158+
it.each(['inbox\\file1.csv', 'inbox/report📄.csv'])(
159+
'preserves the exact JSON delete filename %s, including valid Unicode pairs',
160+
async (fileName) => {
161+
expect((await execute('delete_file', { ...auth, fileName })).status).toBe(200)
162+
expect(mockSecureFetch.mock.calls[0][0]).toBe(
163+
'https://epm.example.com/gateway/interop/rest/v3/files/delete'
164+
)
165+
expect(JSON.parse(mockSecureFetch.mock.calls[0][2].body)).toEqual({ fileName })
166+
}
167+
)
168+
169+
it('does not report success when the caller cancels during response projection', async () => {
170+
const controller = new AbortController()
171+
mockSecureFetch.mockImplementation(async () => {
172+
const response = Response.json({ status: 0, items: [] })
173+
response.json = async () => {
174+
controller.abort(new DOMException('Cancelled', 'AbortError'))
175+
return { status: 0, items: [] }
176+
}
177+
return response
178+
})
179+
await expect(execute('list_files', auth, controller.signal)).rejects.toMatchObject({
180+
name: 'AbortError',
181+
})
182+
})
183+
141184
it('reports identity partial failure as tool failure while retaining structured item results', async () => {
142185
mockSecureFetch.mockImplementation(async () =>
143186
Response.json({

apps/sim/lib/internal/oracle-epm-platform/execute-tool.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { ORACLE_EPM_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/credentials/client
22
import { createOracleEpmClient } from '@/lib/internal/oracle-epm/client.server'
33
import { OracleEpmError } from '@/lib/internal/oracle-epm/errors'
44
import { OracleEpmPlatformFileError } from '@/lib/internal/oracle-epm-platform/files.server'
5-
import { oracleEpmPlatformOperations } from '@/lib/internal/oracle-epm-platform/operations'
5+
import { oracleEpmPlatformToolHandlers } from '@/lib/internal/oracle-epm-platform/operations'
66
import {
77
OracleEpmPlatformResponseError,
88
OracleEpmPlatformStatusError,
@@ -56,11 +56,12 @@ async function executeOperation<K extends OracleEpmPlatformOperation>(
5656
accessToken: input.accessToken,
5757
instanceUrl: input.instanceUrl,
5858
})
59-
const output = await oracleEpmPlatformOperations[operation](input, {
59+
const output = await oracleEpmPlatformToolHandlers[operation](input, {
6060
client,
6161
signal: request.signal,
6262
execution: request.context,
6363
})
64+
request.signal?.throwIfAborted()
6465
const success = output.status <= 0 && !('partialFailure' in output && output.partialFailure)
6566
return Response.json({
6667
success,

apps/sim/lib/internal/oracle-epm-platform/files.server.test.ts

Lines changed: 89 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -259,14 +259,56 @@ describe('Oracle EPM source files and chunked uploads', () => {
259259
})
260260
})
261261

262-
it('rejects repository size mismatch before sending a mutation', async () => {
263-
await expect(
264-
uploadRepositoryFile(
265-
{ ...auth, file: { ...file, size: 4 }, fileName: 'data.csv' },
262+
it.each([2, 4])(
263+
'rejects repository declared size %s mismatch before sending a mutation',
264+
async (size) => {
265+
await expect(
266+
uploadRepositoryFile(
267+
{ ...auth, file: { ...file, size }, fileName: 'data.csv' },
268+
fileContext
269+
)
270+
).rejects.toThrow('declared file size')
271+
expect(mockSecureFetch).not.toHaveBeenCalled()
272+
}
273+
)
274+
275+
it('copies repository source chunks directly into the bounded upload body', async () => {
276+
storage.downloadFileStream.mockImplementation(async () =>
277+
Readable.from([Buffer.from('a'), Buffer.from('bc')])
278+
)
279+
await uploadRepositoryFile({ ...auth, file, fileName: 'data.csv' }, fileContext)
280+
expect(mockSecureFetch.mock.calls[0][2].body).toEqual(Buffer.from('abc'))
281+
})
282+
283+
it('returns an immediate legacy extraction reference after a repository snapshot upload', async () => {
284+
mockSecureFetch.mockImplementation(async () =>
285+
Response.json({
286+
status: -1,
287+
links: [
288+
{
289+
rel: 'Job Status',
290+
action: 'GET',
291+
href: 'https://epm.example.com/gateway/interop/rest/11.1.2.3.600/applicationsnapshots/Artifact%20Snapshot.zip/contents/status',
292+
},
293+
],
294+
})
295+
)
296+
expect(
297+
await uploadRepositoryFile(
298+
{
299+
...auth,
300+
file,
301+
fileName: 'Artifact Snapshot.zip',
302+
},
266303
fileContext
267304
)
268-
).rejects.toThrow('declared file size')
269-
expect(mockSecureFetch).not.toHaveBeenCalled()
305+
).toMatchObject({
306+
bytesUploaded: 3,
307+
completed: false,
308+
jobKind: 'snapshot_upload',
309+
jobId: 'repository:Artifact%20Snapshot.zip',
310+
})
311+
expect(mockSecureFetch.mock.calls.map(([, , options]) => options.method)).toEqual(['POST'])
270312
})
271313

272314
it('uses inclusive contiguous ranges, one-based chunk numbers, and empty init/finalize control bodies', async () => {
@@ -405,6 +447,26 @@ describe('Oracle EPM source files and chunked uploads', () => {
405447
).toBe(true)
406448
})
407449

450+
it('rejects late cancellation after finalization without deleting the accepted snapshot', async () => {
451+
const controller = new AbortController()
452+
mockSecureFetch.mockImplementation(async (url: string) => {
453+
const response = Response.json({ status: 0 })
454+
if (JSON.parse(new URL(url).searchParams.get('q') ?? '{}').isLast) {
455+
response.json = async () => {
456+
controller.abort(new DOMException('Cancelled', 'AbortError'))
457+
return { status: 0 }
458+
}
459+
}
460+
return response
461+
})
462+
await expect(
463+
uploadSnapshot(snapshotInput(), { ...fileContext, signal: controller.signal })
464+
).rejects.toMatchObject({ name: 'AbortError' })
465+
expect(mockSecureFetch.mock.calls.some(([url]) => String(url).endsWith('/files/delete'))).toBe(
466+
false
467+
)
468+
})
469+
408470
it('cancels an interrupted source and uses a separate bounded signal for owned cleanup', async () => {
409471
const controller = new AbortController()
410472
storage.downloadFileStream.mockImplementation(async () =>
@@ -534,4 +596,25 @@ describe('Oracle EPM v2 streamed downloads', () => {
534596
true
535597
)
536598
})
599+
600+
it('removes its completed local output when cancellation overlaps remote cleanup', async () => {
601+
const controller = new AbortController()
602+
setDownload()
603+
const provider = mockSecureFetch.getMockImplementation()!
604+
mockSecureFetch.mockImplementation(async (...args) => {
605+
const response = await provider(...args)
606+
if (args[2].method === 'DELETE') {
607+
expect(storage.complete).toHaveBeenCalled()
608+
controller.abort(new DOMException('Cancelled', 'AbortError'))
609+
}
610+
return response
611+
})
612+
await expect(
613+
downloadRepositoryFile(downloadInput, { ...fileContext, signal: controller.signal })
614+
).rejects.toMatchObject({ name: 'AbortError' })
615+
expect(storage.deleteFile).toHaveBeenCalledWith({
616+
key: 'execution/result.zip',
617+
context: 'execution',
618+
})
619+
})
537620
})

apps/sim/lib/internal/oracle-epm-platform/files.server.ts

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@ import {
77
storeOracleEpmDownload,
88
} from '@/lib/internal/oracle-epm/files.server'
99
import { pollOracleEpmJob } from '@/lib/internal/oracle-epm/jobs'
10-
import { projectJob, readJobLink } from '@/lib/internal/oracle-epm-platform/jobs'
10+
import {
11+
projectJob,
12+
projectRepositoryUploadJob,
13+
readJobLink,
14+
} from '@/lib/internal/oracle-epm-platform/jobs'
1115
import type { OracleEpmPlatformOperationContext } from '@/lib/internal/oracle-epm-platform/operations'
1216
import {
1317
filesSchema,
@@ -30,6 +34,7 @@ import {
3034
SNAPSHOT_FILE_LIMIT,
3135
} from '@/lib/internal/oracle-epm-platform/routes'
3236
import type { OracleEpmPlatformInput } from '@/lib/internal/oracle-epm-platform/schemas'
37+
import { deleteFile } from '@/lib/uploads/core/storage-service'
3338
import type { UserFile } from '@/executor/types'
3439
import type {
3540
OracleEpmPlatformOutputMap,
@@ -141,14 +146,17 @@ export async function uploadRepositoryFile(
141146
// Generic uploads are bounded to 100 MiB and use the documented single-request API.
142147
const bytes = Buffer.alloc(input.file.size)
143148
let offset = 0
144-
for await (const chunk of verifiedChunks(
145-
source.chunks,
146-
input.file.size,
147-
SNAPSHOT_CHUNK_LIMIT,
148-
signal
149-
)) {
149+
for await (const chunk of source.chunks) {
150+
signal?.throwIfAborted()
151+
if (offset + chunk.byteLength > input.file.size) {
152+
throw new OracleEpmPlatformFileError('Source file bytes exceed the declared file size')
153+
}
150154
bytes.set(chunk, offset)
151-
offset += chunk.length
155+
offset += chunk.byteLength
156+
}
157+
signal?.throwIfAborted()
158+
if (offset !== input.file.size) {
159+
throw new OracleEpmPlatformFileError('Source file bytes do not match the declared file size')
152160
}
153161
const value = jsonBody(
154162
await client.request(endpoints.upload_repository_file, {
@@ -158,9 +166,10 @@ export async function uploadRepositoryFile(
158166
signal,
159167
})
160168
)
169+
signal?.throwIfAborted()
161170
// The upload reference also permits asynchronous extraction of an LCM artifact.
162171
return {
163-
...projectJob(client, value, 'snapshot_upload'),
172+
...projectRepositoryUploadJob(client, value, input.fileName),
164173
fileName: input.fileName,
165174
bytesUploaded: offset,
166175
}
@@ -231,6 +240,7 @@ export async function uploadSnapshot(
231240
}
232241
finalizing = true
233242
const value = await send({ isFirst: false, chunkSize: 14, fileSize, isLast: true })
243+
signal?.throwIfAborted()
234244
return {
235245
...projectJob(client, value, 'snapshot_upload'),
236246
snapshotName: input.snapshotName,
@@ -380,6 +390,12 @@ export async function downloadRepositoryFile(
380390
)
381391
}
382392
}
393+
if (signal?.aborted) {
394+
// Storage completed before remote cleanup began; only this newly created output is ours.
395+
// Match the foundation's completed-download cancellation cleanup.
396+
if (file?.key) await deleteFile({ key: file.key, context: 'execution' }).catch(() => undefined)
397+
signal.throwIfAborted()
398+
}
383399
if (!file) throw new OracleEpmPlatformResponseError()
384400
return {
385401
...statusOutput(0),

0 commit comments

Comments
 (0)