Skip to content

Commit b81803b

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
feat(oracle-epm): support repository-path parameters
1 parent cb8b98a commit b81803b

5 files changed

Lines changed: 399 additions & 29 deletions

File tree

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

Lines changed: 300 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,10 @@ import {
2121
} from '@/lib/internal/oracle-epm/endpoint'
2222
import { OracleEpmError } from '@/lib/internal/oracle-epm/errors'
2323
import { defineOracleEpmRouteSpace } from '@/lib/internal/oracle-epm/route-space'
24-
import type { OracleEpmValidatedLink } from '@/lib/internal/oracle-epm/types'
24+
import type {
25+
OracleEpmEndpointDeclaration,
26+
OracleEpmValidatedLink,
27+
} from '@/lib/internal/oracle-epm/types'
2528

2629
const routes = defineOracleEpmRouteSpace({
2730
context: ['SyntheticAlpha', 'rest'],
@@ -419,4 +422,300 @@ describe('Oracle EPM guarded client', () => {
419422
OracleEpmError
420423
)
421424
})
425+
426+
describe('repository path parameters', () => {
427+
const declaration = {
428+
method: 'GET',
429+
version: 'v3',
430+
path: [
431+
oracleEpmLiteral('files'),
432+
oracleEpmPathParameter('fileName', { maxBytes: 255, mode: 'repository-path' }),
433+
oracleEpmLiteral('contents'),
434+
],
435+
query: { token: oracleEpmQuery.string({ maxBytes: 128 }) },
436+
body: 'none',
437+
response: 'stream',
438+
timeoutMs: 5_000,
439+
maxResponseBytes: 4_096,
440+
} satisfies OracleEpmEndpointDeclaration
441+
const download = routes.defineEndpoint(declaration)
442+
const prefix = 'https://epm.example.com/gateway/acme/SyntheticAlpha/rest/v3'
443+
const client = createOracleEpmClient({
444+
instanceUrl: 'https://epm.example.com/gateway/acme',
445+
accessToken: Buffer.from('u:p').toString('base64'),
446+
})
447+
448+
it.each([
449+
['outbox/reports/results.csv', 'outbox%2Freports%2Fresults.csv'],
450+
['inbox\\Monthly Report.csv', 'inbox%5CMonthly%20Report.csv'],
451+
['outbox\\reports/results.csv', 'outbox%5Creports%2Fresults.csv'],
452+
['outbox/résumé-😀.csv', 'outbox%2Fr%C3%A9sum%C3%A9-%F0%9F%98%80.csv'],
453+
[' report.csv ', '%20report.csv%20'],
454+
['outbox/100%.csv', 'outbox%2F100%25.csv'],
455+
['outbox%2Freport.csv', 'outbox%252Freport.csv'],
456+
['outbox/%2e%2e/report.csv', 'outbox%2F%252e%252e%2Freport.csv'],
457+
])('encodes raw filename %j once without rewriting it', async (fileName, encoded) => {
458+
await client.request(download, { pathParams: { fileName } })
459+
expect(mockSecureFetch.mock.calls[0][0]).toBe(`${prefix}/files/${encoded}/contents`)
460+
expect(mockValidateUrl).toHaveBeenCalledWith(
461+
'https://epm.example.com',
462+
'Oracle EPM destination',
463+
'configuredEndpoint',
464+
{ logDetails: false }
465+
)
466+
})
467+
468+
it.each([
469+
'',
470+
'/file.csv',
471+
'\\file.csv',
472+
'\\\\server\\file.csv',
473+
'C:\\file.csv',
474+
'c:file.csv',
475+
'outbox//file.csv',
476+
'outbox\\\\file.csv',
477+
'outbox/\\file.csv',
478+
'outbox/',
479+
'outbox\\',
480+
'.',
481+
'..',
482+
'./file.csv',
483+
'../file.csv',
484+
'outbox/./file.csv',
485+
'outbox/../file.csv',
486+
'outbox\\..\\file.csv',
487+
'outbox/..',
488+
'outbox/\nfile.csv',
489+
'outbox/\u0000file.csv',
490+
'outbox/\u007ffile.csv',
491+
'outbox/\uD800.csv',
492+
'a'.repeat(256),
493+
'é'.repeat(128),
494+
])('rejects invalid raw filename %j before DNS or fetch', async (fileName) => {
495+
await expect(client.request(download, { pathParams: { fileName } })).rejects.toMatchObject({
496+
category: 'invalid_input',
497+
})
498+
expect(mockValidateUrl).not.toHaveBeenCalled()
499+
expect(mockSecureFetch).not.toHaveBeenCalled()
500+
})
501+
502+
it('accepts the full 255-byte raw UTF-8 boundary', async () => {
503+
const fileName = `${'é'.repeat(127)}x`
504+
await client.request(download, { pathParams: { fileName } })
505+
expect(mockSecureFetch.mock.calls[0][0]).toBe(
506+
`${prefix}/files/${encodeURIComponent(fileName)}/contents`
507+
)
508+
})
509+
510+
it.each([undefined, 'segment'] as const)(
511+
'keeps mode %j strict for ordinary IDs',
512+
async (mode) => {
513+
const endpoint = routes.defineEndpoint({
514+
...declaration,
515+
path: [oracleEpmLiteral('jobs'), oracleEpmPathParameter('jobId', { maxBytes: 64, mode })],
516+
})
517+
for (const jobId of ['folder/id', 'folder\\id']) {
518+
await expect(client.request(endpoint, { pathParams: { jobId } })).rejects.toMatchObject({
519+
category: 'invalid_input',
520+
})
521+
}
522+
expect(mockValidateUrl).not.toHaveBeenCalled()
523+
expect(mockSecureFetch).not.toHaveBeenCalled()
524+
await client.request(endpoint, { pathParams: { jobId: 'job 42' } })
525+
expect(mockSecureFetch.mock.calls[0][0]).toBe(`${prefix}/jobs/job%2042`)
526+
}
527+
)
528+
529+
it('does not let request input select the parameter mode', async () => {
530+
await expect(
531+
client.request(getJob, { pathParams: { jobId: 'folder/id', mode: 'repository-path' } })
532+
).rejects.toMatchObject({ category: 'invalid_input' })
533+
expect(mockValidateUrl).not.toHaveBeenCalled()
534+
expect(mockSecureFetch).not.toHaveBeenCalled()
535+
})
536+
537+
describe.each(['endpoint', 'route'] as const)('%s-bound returned links', (binding) => {
538+
function definePolicy(endpointDeclaration = declaration, preserveGatewayBasePath = true) {
539+
return routes.defineReturnedLinkPolicy({
540+
relation: 'download',
541+
method: 'GET',
542+
...(binding === 'endpoint'
543+
? { endpoint: routes.defineEndpoint(endpointDeclaration) }
544+
: {
545+
version: endpointDeclaration.version,
546+
path: endpointDeclaration.path,
547+
query: endpointDeclaration.query,
548+
response: endpointDeclaration.response,
549+
timeoutMs: endpointDeclaration.timeoutMs,
550+
maxResponseBytes: endpointDeclaration.maxResponseBytes,
551+
}),
552+
preserveGatewayBasePath,
553+
})
554+
}
555+
const policy = definePolicy()
556+
557+
it.each([
558+
'outbox%2Freports%2Fresults.csv',
559+
'inbox%5CMonthly%20Report.csv',
560+
'outbox%2fr%C3%A9sum%C3%A9-%F0%9F%98%80.csv',
561+
'outbox%2F100%25.csv',
562+
'outbox%2F%2525252561.csv',
563+
`${'%C3%A9'.repeat(127)}x`,
564+
])('retains filename encoding and query bytes for %s', async (encoded) => {
565+
const href = `${prefix}/files/${encoded}/contents?token=secret%2bvalue`
566+
const link = client.validateReturnedLink(policy, { rel: 'download', href })
567+
expect(Object.isFrozen(link)).toBe(true)
568+
expect(Object.keys(link)).toEqual([])
569+
expect(JSON.stringify(link)).toBe('{}')
570+
await client.requestValidatedLink(link)
571+
expect(mockSecureFetch.mock.calls[0][0]).toBe(href)
572+
573+
const otherClient = createOracleEpmClient({
574+
instanceUrl: 'https://epm.example.com/gateway/acme',
575+
accessToken: Buffer.from('other:p').toString('base64'),
576+
})
577+
await expect(otherClient.requestValidatedLink(link)).rejects.toMatchObject({
578+
category: 'invalid_input',
579+
})
580+
expect(mockSecureFetch).toHaveBeenCalledTimes(1)
581+
})
582+
583+
it.each([
584+
'outbox/report.csv',
585+
'outbox\\report.csv',
586+
'%2Freport.csv',
587+
'%5C%5Cserver%5Creport.csv',
588+
'C%3A%5Creport.csv',
589+
'c%3Areport.csv',
590+
'outbox%2F%2Freport.csv',
591+
'outbox%2F',
592+
'outbox%2F.%2Freport.csv',
593+
'outbox%2F..%2Fsecret.csv',
594+
'outbox%5C..%5Csecret.csv',
595+
'outbox%2F%252e%252e%2Fsecret.csv',
596+
'outbox%252F%252e%252e%252Fsecret.csv',
597+
'outbox%2F%00report.csv',
598+
'outbox%2F%250areport.csv',
599+
'outbox%2F%7freport.csv',
600+
'outbox%2F%ED%A0%80.csv',
601+
'outbox%2Fbad%.csv',
602+
'outbox%2F%25FF.csv',
603+
'%C3%A9'.repeat(128),
604+
'%252525252561.csv',
605+
])('rejects invalid encoded filenames %s', (encoded) => {
606+
expect(() =>
607+
client.validateReturnedLink(policy, {
608+
rel: 'download',
609+
href: `${prefix}/files/${encoded}/contents`,
610+
})
611+
).toThrow(OracleEpmError)
612+
expect(mockValidateUrl).not.toHaveBeenCalled()
613+
expect(mockSecureFetch).not.toHaveBeenCalled()
614+
})
615+
616+
it('validates bounds and patterns against the once-decoded filename', async () => {
617+
const boundedDeclaration = {
618+
...declaration,
619+
path: [
620+
oracleEpmPathParameter('fileName', {
621+
maxBytes: 14,
622+
pattern: /^outbox\/%61\.csv$/,
623+
mode: 'repository-path',
624+
}),
625+
],
626+
}
627+
const endpoint = routes.defineEndpoint(boundedDeclaration)
628+
const boundedPolicy = definePolicy(boundedDeclaration)
629+
const fileName = 'outbox/%61.csv'
630+
const href = `${prefix}/outbox%2F%2561.csv`
631+
await client.request(endpoint, { pathParams: { fileName } })
632+
const handle = client.validateReturnedLink(boundedPolicy, { rel: 'download', href })
633+
await client.requestValidatedLink(handle)
634+
expect(mockSecureFetch.mock.calls.map(([url]) => url)).toEqual([href, href])
635+
for (const invalid of ['outbox/a.csv', 'inbox/%61.csv', 'outbox/long%61.csv']) {
636+
await expect(
637+
client.request(endpoint, { pathParams: { fileName: invalid } })
638+
).rejects.toMatchObject({ category: 'invalid_input' })
639+
expect(() =>
640+
client.validateReturnedLink(boundedPolicy, {
641+
rel: 'download',
642+
href: `${prefix}/${encodeURIComponent(invalid)}`,
643+
})
644+
).toThrow(OracleEpmError)
645+
}
646+
expect(mockSecureFetch).toHaveBeenCalledTimes(2)
647+
})
648+
649+
it('preserves the declared gateway-prefix policy', async () => {
650+
const originHref =
651+
'https://epm.example.com/SyntheticAlpha/rest/v3/files/outbox%2Freport.csv/contents'
652+
expect(() =>
653+
client.validateReturnedLink(policy, { rel: 'download', href: originHref })
654+
).toThrow(OracleEpmError)
655+
const originPolicy = definePolicy(declaration, false)
656+
const handle = client.validateReturnedLink(originPolicy, {
657+
rel: 'download',
658+
href: originHref,
659+
})
660+
await client.requestValidatedLink(handle)
661+
expect(mockSecureFetch.mock.calls[0][0]).toBe(originHref)
662+
expect(() =>
663+
client.validateReturnedLink(originPolicy, {
664+
rel: 'download',
665+
href: `${prefix}/files/outbox%2Freport.csv/contents`,
666+
})
667+
).toThrow(OracleEpmError)
668+
})
669+
670+
it.each([
671+
[
672+
'origin',
673+
`${prefix.replace('epm.example.com', 'other.example.com')}/files/outbox%2Freport.csv/contents`,
674+
],
675+
[
676+
'userinfo',
677+
`${prefix.replace('https://', 'https://user@')}/files/outbox%2Freport.csv/contents`,
678+
],
679+
[
680+
'gateway',
681+
`${prefix.replace('/gateway/acme/', '/gateway%2Facme/')}/files/outbox%2Freport.csv/contents`,
682+
],
683+
[
684+
'context',
685+
`${prefix.replace('/SyntheticAlpha/rest/', '/SyntheticAlpha%2Frest/')}/files/outbox%2Freport.csv/contents`,
686+
],
687+
['literal', `${prefix}/files%2Fextra/outbox%2Freport.csv/contents`],
688+
['suffix', `${prefix}/files/outbox%2Freport.csv/contents%2Fextra`],
689+
['extra segment', `${prefix}/files/outbox%2Freport.csv/extra/contents`],
690+
['duplicate query', `${prefix}/files/outbox%2Freport.csv/contents?token=a&token=b`],
691+
['unknown query', `${prefix}/files/outbox%2Freport.csv/contents?unknown=x`],
692+
['fragment', `${prefix}/files/outbox%2Freport.csv/contents#fragment`],
693+
])('preserves the %s restriction', (_label, href) => {
694+
expect(() => client.validateReturnedLink(policy, { rel: 'download', href })).toThrow(
695+
OracleEpmError
696+
)
697+
expect(mockSecureFetch).not.toHaveBeenCalled()
698+
})
699+
700+
it('keeps ordinary parameters, methods, and relations strict on repository endpoints', () => {
701+
const mixedPolicy = definePolicy({
702+
...declaration,
703+
path: [...declaration.path, oracleEpmPathParameter('jobId', { maxBytes: 64 })],
704+
})
705+
const href = `${prefix}/files/outbox%2Freport.csv/contents`
706+
for (const jobId of ['a%2Fb', 'a%5Cb']) {
707+
expect(() =>
708+
client.validateReturnedLink(mixedPolicy, { rel: 'download', href: `${href}/${jobId}` })
709+
).toThrow(OracleEpmError)
710+
}
711+
expect(() =>
712+
client.validateReturnedLink(policy, { rel: 'download', method: 'POST', href })
713+
).toThrow(OracleEpmError)
714+
expect(() => client.validateReturnedLink(policy, { rel: 'other', href })).toThrow(
715+
OracleEpmError
716+
)
717+
expect(mockSecureFetch).not.toHaveBeenCalled()
718+
})
719+
})
720+
})
422721
})

0 commit comments

Comments
 (0)