Skip to content

Commit 631cbab

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(oracle-fusion): support framework-v9 self links
1 parent 23032c2 commit 631cbab

2 files changed

Lines changed: 158 additions & 10 deletions

File tree

apps/sim/lib/internal/oracle-fusion/protocol.test.ts

Lines changed: 138 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,6 @@ const ORIGIN = 'https://vision.fa.us2.oraclecloud.com'
1313
const COLLECTION = '/hcmRestApi/resources/11.13.18.05/workers'
1414
const COLLECTION_ADDRESS = { family: 'hcm', relativePath: 'workers' } as const
1515

16-
function resource(href: unknown, links: unknown[] = []): Record<string, unknown> {
17-
return { links: [{ rel: 'self', href }, ...links] }
18-
}
19-
2016
describe('parseOracleFusionCollection', () => {
2117
it('projects a valid page and calculates the next offset', () => {
2218
expect(
@@ -132,7 +128,17 @@ describe('parseOracleFusionCollection', () => {
132128
})
133129
})
134130

135-
describe('Oracle self links', () => {
131+
describe.each(['legacy', 'context', 'both'] as const)('Oracle %s self links', (representation) => {
132+
function resource(href: unknown, otherLinks: unknown[] = []): Record<string, unknown> {
133+
const links = [{ rel: 'self', href }, ...otherLinks]
134+
return {
135+
...(representation !== 'context' ? { links } : {}),
136+
...(representation !== 'legacy'
137+
? { '@context': { key: 'not-the-resource-key', links } }
138+
: {}),
139+
}
140+
}
141+
136142
it('accepts exactly one same-origin self link for the expected path', () => {
137143
expect(() =>
138144
validateOracleFusionSelfLink(resource(`${ORIGIN}${COLLECTION}/abc`), ORIGIN, {
@@ -142,6 +148,42 @@ describe('Oracle self links', () => {
142148
).not.toThrow()
143149
})
144150

151+
it('extracts item keys from a collection without trusting context keys or collection links', () => {
152+
const address = { family: 'fscm', relativePath: 'invoices' } as const
153+
const collectionPath = '/fscmRestApi/resources/11.13.18.05/invoices'
154+
const key = 'invoice:123,installment=2'
155+
const encodedKey = encodeOracleFusionPathSegment(key)
156+
const page = parseOracleFusionCollection(
157+
{
158+
items: [resource(`${ORIGIN}${collectionPath}/${encodedKey}`)],
159+
count: 1,
160+
limit: 25,
161+
offset: 0,
162+
hasMore: false,
163+
links: [{ rel: 'self', href: `${ORIGIN}${collectionPath}` }],
164+
},
165+
(item) => {
166+
validateOracleFusionSelfLink(item, ORIGIN, {
167+
...address,
168+
relativePath: `invoices/${encodedKey}`,
169+
})
170+
return extractOracleFusionOpaqueKey(item, ORIGIN, address)
171+
}
172+
)
173+
174+
expect(page.items).toEqual([key])
175+
})
176+
177+
it('continues ignoring unrelated link relations and non-link entries', () => {
178+
const value = resource(`${ORIGIN}${COLLECTION}/abc`, [
179+
null,
180+
'not a link',
181+
[],
182+
{ rel: 'canonical', href: 'unrelated' },
183+
])
184+
expect(extractOracleFusionOpaqueKey(value, ORIGIN, COLLECTION_ADDRESS)).toBe('abc')
185+
})
186+
145187
it.each([
146188
[{}, 'exactly one'],
147189
[{ links: [] }, 'exactly one'],
@@ -153,6 +195,11 @@ describe('Oracle self links', () => {
153195
[resource('not a URL'), 'malformed'],
154196
[resource(`https://evil.example${COLLECTION}/abc`), 'credential-bound origin'],
155197
[resource(`${ORIGIN}${COLLECTION}/abc?secret=value`), 'credential-bound origin'],
198+
[resource(`${ORIGIN}${COLLECTION}/abc#fragment`), 'credential-bound origin'],
199+
[
200+
resource(`${ORIGIN.replace('https://', 'https://user:password@')}${COLLECTION}/abc`),
201+
'credential-bound origin',
202+
],
156203
[resource(`${ORIGIN}${COLLECTION}/other`), 'requested resource path'],
157204
])('rejects missing, duplicate, malformed, or unbound self links %#', (value, message) => {
158205
expect(() =>
@@ -258,3 +305,89 @@ describe('Oracle self links', () => {
258305
)
259306
})
260307
})
308+
309+
describe('Oracle self-link representation compatibility', () => {
310+
const href = `${ORIGIN}${COLLECTION}/abc`
311+
const links = [{ rel: 'self', href }]
312+
const detailAddress = { family: 'hcm', relativePath: 'workers/abc' } as const
313+
314+
it('accepts legacy links alongside context metadata without a links property', () => {
315+
const value = { links, '@context': { key: 'not-the-resource-key', headers: { ETag: 'etag' } } }
316+
expect(extractOracleFusionOpaqueKey(value, ORIGIN, COLLECTION_ADDRESS)).toBe('abc')
317+
expect(() => validateOracleFusionSelfLink(value, ORIGIN, detailAddress)).not.toThrow()
318+
})
319+
320+
it.each([undefined, null, [], 'context', 1].map((context) => ({ context })))(
321+
'rejects malformed context %# despite valid legacy links',
322+
({ context }) => {
323+
const value = { links, '@context': context }
324+
expect(() => extractOracleFusionOpaqueKey(value, ORIGIN, COLLECTION_ADDRESS)).toThrow(
325+
'Oracle resource context must be an object'
326+
)
327+
expect(() => validateOracleFusionSelfLink(value, ORIGIN, detailAddress)).toThrow(
328+
'Oracle resource context must be an object'
329+
)
330+
}
331+
)
332+
333+
it.each([{}, { '@context': {} }, { '@context': { key: 'abc' } }, { '@context': { links: [] } }])(
334+
'rejects missing self links without deriving a key from context %#',
335+
(value) => {
336+
expect(() => extractOracleFusionOpaqueKey(value, ORIGIN, COLLECTION_ADDRESS)).toThrow(
337+
'exactly one'
338+
)
339+
expect(() => validateOracleFusionSelfLink(value, ORIGIN, detailAddress)).toThrow(
340+
'exactly one'
341+
)
342+
}
343+
)
344+
345+
describe.each(['legacy', 'context'] as const)('invalid %s links', (representation) => {
346+
it.each([
347+
{ links: undefined, error: 'exactly one' },
348+
{ links: null, error: 'exactly one' },
349+
{ links: {}, error: 'exactly one' },
350+
{ links: [], error: 'exactly one' },
351+
{ links: [...links, ...links], error: 'exactly one' },
352+
{ links: [{ rel: 'self' }], error: 'malformed' },
353+
{ links: [{ rel: 'self', href: 'not a URL' }], error: 'malformed' },
354+
{
355+
links: [{ rel: 'self', href: `${ORIGIN}${COLLECTION}/parent/../abc` }],
356+
error: 'malformed',
357+
},
358+
])(
359+
'does not fall back to the other valid representation %#',
360+
({ links: invalidLinks, error }) => {
361+
const value = {
362+
links: representation === 'legacy' ? invalidLinks : links,
363+
'@context': { links: representation === 'context' ? invalidLinks : links },
364+
}
365+
expect(() => extractOracleFusionOpaqueKey(value, ORIGIN, COLLECTION_ADDRESS)).toThrow(error)
366+
expect(() => validateOracleFusionSelfLink(value, ORIGIN, detailAddress)).toThrow(error)
367+
}
368+
)
369+
})
370+
371+
it.each([
372+
`${ORIGIN}${COLLECTION}/other`,
373+
`https://other.fa.us2.oraclecloud.com${COLLECTION}/abc`,
374+
`${ORIGIN.toUpperCase()}${COLLECTION}/abc`,
375+
`${ORIGIN}${COLLECTION}/%61bc`,
376+
])('rejects conflicting href strings without normalizing or reflecting them %#', (otherHref) => {
377+
for (const [legacyHref, contextHref] of [
378+
[href, otherHref],
379+
[otherHref, href],
380+
]) {
381+
const value = {
382+
links: [{ rel: 'self', href: legacyHref }],
383+
'@context': { links: [{ rel: 'self', href: contextHref }] },
384+
}
385+
expect(() => extractOracleFusionOpaqueKey(value, ORIGIN, COLLECTION_ADDRESS)).toThrow(
386+
new Error('Oracle response self-link representations conflict')
387+
)
388+
expect(() => validateOracleFusionSelfLink(value, ORIGIN, detailAddress)).toThrow(
389+
new Error('Oracle response self-link representations conflict')
390+
)
391+
}
392+
})
393+
})

apps/sim/lib/internal/oracle-fusion/protocol.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -120,12 +120,11 @@ export function parseOracleFusionCollection<T>(
120120
}
121121
}
122122

123-
function getOnlySelfLink(value: unknown): URL {
124-
const resource = asObject(value, 'Oracle resource')
125-
if (!Array.isArray(resource.links)) {
123+
function readSelfLink(links: unknown): { href: string; url: URL } {
124+
if (!Array.isArray(links)) {
126125
throw new Error('Oracle response must include exactly one self link')
127126
}
128-
const selfLinks = resource.links.filter((link) => {
127+
const selfLinks = links.filter((link) => {
129128
if (!link || typeof link !== 'object' || Array.isArray(link)) return false
130129
return (link as Record<string, unknown>).rel === 'self'
131130
})
@@ -143,12 +142,28 @@ function getOnlySelfLink(value: unknown): URL {
143142
throw new Error('Oracle self link is malformed')
144143
}
145144
try {
146-
return new URL(href)
145+
return { href, url: new URL(href) }
147146
} catch {
148147
throw new Error('Oracle self link is malformed')
149148
}
150149
}
151150

151+
function getOnlySelfLink(value: unknown): URL {
152+
const resource = asObject(value, 'Oracle resource')
153+
const context = Object.hasOwn(resource, '@context')
154+
? asObject(resource['@context'], 'Oracle resource context')
155+
: undefined
156+
const legacyLink = Object.hasOwn(resource, 'links') ? readSelfLink(resource.links) : undefined
157+
const contextLink =
158+
context && Object.hasOwn(context, 'links') ? readSelfLink(context.links) : undefined
159+
if (legacyLink && contextLink && legacyLink.href !== contextLink.href) {
160+
throw new Error('Oracle response self-link representations conflict')
161+
}
162+
const link = contextLink ?? legacyLink
163+
if (!link) throw new Error('Oracle response must include exactly one self link')
164+
return link.url
165+
}
166+
152167
function validateSelfLinkBase(link: URL, instanceUrl: string): void {
153168
const origin = normalizeOracleFusionApplicationOrigin(instanceUrl)
154169
if (

0 commit comments

Comments
 (0)