@@ -21,7 +21,10 @@ import {
2121} from '@/lib/internal/oracle-epm/endpoint'
2222import { OracleEpmError } from '@/lib/internal/oracle-epm/errors'
2323import { 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
2629const 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 : / ^ o u t b o x \/ % 6 1 \. c s v $ / ,
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