11/**
22 * @vitest -environment node
33 */
4- import { inputValidationMock , inputValidationMockFns } from '@sim/testing'
5- import { beforeEach , describe , expect , it , vi } from 'vitest'
6-
7- vi . mock ( '@/lib/core/security/input-validation.server' , ( ) => inputValidationMock )
8-
9- import { executeAgiloftRequest , resolveAgiloftInstance } from '@/tools/agiloft/utils'
4+ import { afterEach , beforeEach , describe , expect , it , vi } from 'vitest'
5+ import { executeAgiloftRequest } from '@/tools/agiloft/utils'
106
117const baseParams = {
128 instanceUrl : 'https://example.agiloft.com' ,
@@ -16,77 +12,34 @@ const baseParams = {
1612 table : 'contracts' ,
1713}
1814
19- const PINNED_IP = '93.184.216.34'
20-
21- function mockSecureFetchResponse ( body : {
22- ok ?: boolean
23- status ?: number
24- json ?: unknown
25- text ?: string
26- arrayBuffer ?: ArrayBuffer
27- } ) {
15+ function mockFetchResponse ( body : { ok ?: boolean ; status ?: number ; json ?: unknown ; text ?: string } ) {
2816 return {
2917 ok : body . ok ?? true ,
3018 status : body . status ?? 200 ,
3119 statusText : '' ,
3220 headers : new Headers ( ) ,
33- body : null ,
3421 text : async ( ) => body . text ?? '' ,
3522 json : async ( ) => body . json ?? { } ,
36- arrayBuffer : async ( ) => body . arrayBuffer ?? new ArrayBuffer ( 0 ) ,
37- }
23+ } as unknown as Response
3824}
3925
26+ const fetchSpy = vi . fn < typeof fetch > ( )
27+
4028beforeEach ( ( ) => {
41- vi . clearAllMocks ( )
42- inputValidationMockFns . mockValidateUrlWithDNS . mockResolvedValue ( {
43- isValid : true ,
44- resolvedIP : PINNED_IP ,
45- originalHostname : 'example.agiloft.com' ,
46- } )
29+ fetchSpy . mockReset ( )
30+ vi . stubGlobal ( 'fetch' , fetchSpy )
4731} )
4832
49- describe ( 'resolveAgiloftInstance' , ( ) => {
50- it ( 'returns the resolved IP for a valid URL' , async ( ) => {
51- const ip = await resolveAgiloftInstance ( 'https://example.agiloft.com' )
52- expect ( ip ) . toBe ( PINNED_IP )
53- expect ( inputValidationMockFns . mockValidateUrlWithDNS ) . toHaveBeenCalledWith (
54- 'https://example.agiloft.com' ,
55- 'instanceUrl'
56- )
57- } )
58-
59- it ( 'throws when the URL resolves to a blocked IP' , async ( ) => {
60- inputValidationMockFns . mockValidateUrlWithDNS . mockResolvedValueOnce ( {
61- isValid : false ,
62- error : 'instanceUrl resolves to a blocked IP address' ,
63- } )
64-
65- await expect ( resolveAgiloftInstance ( 'https://attacker.example.com' ) ) . rejects . toThrow (
66- 'instanceUrl resolves to a blocked IP address'
67- )
68- } )
69-
70- it ( 'throws when validation succeeds but no IP is returned' , async ( ) => {
71- inputValidationMockFns . mockValidateUrlWithDNS . mockResolvedValueOnce ( {
72- isValid : true ,
73- } )
74-
75- await expect ( resolveAgiloftInstance ( 'https://example.agiloft.com' ) ) . rejects . toThrow (
76- 'Invalid Agiloft instance URL'
77- )
78- } )
33+ afterEach ( ( ) => {
34+ vi . unstubAllGlobals ( )
7935} )
8036
8137describe ( 'executeAgiloftRequest' , ( ) => {
82- it ( 'pins the resolved IP across login, operation, and logout' , async ( ) => {
83- inputValidationMockFns . mockSecureFetchWithPinnedIP
84- // EWLogin
85- . mockResolvedValueOnce ( mockSecureFetchResponse ( { json : { access_token : 'tok-1' } } ) )
86- // operation
87- . mockResolvedValueOnce ( mockSecureFetchResponse ( { json : { id : 42 , fields : { name : 'foo' } } } ) )
88- // EWLogout
89- . mockResolvedValueOnce ( mockSecureFetchResponse ( { } ) )
38+ it ( 'logs in, runs the operation with the bearer token, then logs out' , async ( ) => {
39+ fetchSpy
40+ . mockResolvedValueOnce ( mockFetchResponse ( { json : { access_token : 'tok-1' } } ) )
41+ . mockResolvedValueOnce ( mockFetchResponse ( { json : { id : 42 , fields : { name : 'foo' } } } ) )
42+ . mockResolvedValueOnce ( mockFetchResponse ( { } ) )
9043
9144 const result = await executeAgiloftRequest (
9245 baseParams ,
@@ -106,43 +59,24 @@ describe('executeAgiloftRequest', () => {
10659
10760 expect ( result ) . toEqual ( { success : true , output : { id : '42' , fields : { name : 'foo' } } } )
10861
109- const calls = inputValidationMockFns . mockSecureFetchWithPinnedIP . mock . calls
62+ const calls = fetchSpy . mock . calls
11063 expect ( calls ) . toHaveLength ( 3 )
111-
112- // Every call MUST use the pre-resolved IP — this is the SSRF fix.
113- for ( const call of calls ) {
114- expect ( call [ 1 ] ) . toBe ( PINNED_IP )
115- }
116-
117- // Login URL preserves the original hostname (TLS SNI requirement).
11864 expect ( calls [ 0 ] [ 0 ] ) . toBe (
11965 'https://example.agiloft.com/ewws/EWLogin?$KB=demo&$login=admin&$password=secret'
12066 )
121- expect ( calls [ 0 ] [ 2 ] ) . toEqual ( { method : 'POST' } )
122-
123- // Operation request includes the bearer token issued by login.
12467 expect ( calls [ 1 ] [ 0 ] ) . toBe ( 'https://example.agiloft.com/ewws/REST/demo/contracts/42' )
125- expect ( calls [ 1 ] [ 2 ] ) . toMatchObject ( {
68+ expect ( calls [ 1 ] [ 1 ] ) . toMatchObject ( {
12669 method : 'GET' ,
12770 headers : { Accept : 'application/json' , Authorization : 'Bearer tok-1' } ,
12871 } )
129-
130- // Logout uses the bearer token and the original hostname.
13172 expect ( calls [ 2 ] [ 0 ] ) . toBe ( 'https://example.agiloft.com/ewws/EWLogout?$KB=demo' )
132- expect ( calls [ 2 ] [ 2 ] ) . toMatchObject ( {
133- method : 'POST' ,
134- headers : { Authorization : 'Bearer tok-1' } ,
135- } )
136-
137- // DNS lookup happens exactly once, before any HTTP request.
138- expect ( inputValidationMockFns . mockValidateUrlWithDNS ) . toHaveBeenCalledTimes ( 1 )
13973 } )
14074
14175 it ( 'still calls logout when the operation throws' , async ( ) => {
142- inputValidationMockFns . mockSecureFetchWithPinnedIP
143- . mockResolvedValueOnce ( mockSecureFetchResponse ( { json : { access_token : 'tok-2' } } ) )
144- . mockResolvedValueOnce ( mockSecureFetchResponse ( { ok : false , status : 500 } ) )
145- . mockResolvedValueOnce ( mockSecureFetchResponse ( { } ) )
76+ fetchSpy
77+ . mockResolvedValueOnce ( mockFetchResponse ( { json : { access_token : 'tok-2' } } ) )
78+ . mockResolvedValueOnce ( mockFetchResponse ( { ok : false , status : 500 } ) )
79+ . mockResolvedValueOnce ( mockFetchResponse ( { } ) )
14680
14781 await expect (
14882 executeAgiloftRequest (
@@ -155,16 +89,14 @@ describe('executeAgiloftRequest', () => {
15589 )
15690 ) . rejects . toThrow ( 'operation failed' )
15791
158- expect ( inputValidationMockFns . mockSecureFetchWithPinnedIP ) . toHaveBeenCalledTimes ( 3 )
159- expect ( inputValidationMockFns . mockSecureFetchWithPinnedIP . mock . calls [ 2 ] [ 0 ] ) . toContain (
160- '/ewws/EWLogout'
161- )
92+ expect ( fetchSpy ) . toHaveBeenCalledTimes ( 3 )
93+ expect ( fetchSpy . mock . calls [ 2 ] [ 0 ] ) . toContain ( '/ewws/EWLogout' )
16294 } )
16395
16496 it ( 'swallows logout failures (best-effort)' , async ( ) => {
165- inputValidationMockFns . mockSecureFetchWithPinnedIP
166- . mockResolvedValueOnce ( mockSecureFetchResponse ( { json : { access_token : 'tok-3' } } ) )
167- . mockResolvedValueOnce ( mockSecureFetchResponse ( { json : { ok : true } } ) )
97+ fetchSpy
98+ . mockResolvedValueOnce ( mockFetchResponse ( { json : { access_token : 'tok-3' } } ) )
99+ . mockResolvedValueOnce ( mockFetchResponse ( { json : { ok : true } } ) )
168100 . mockRejectedValueOnce ( new Error ( 'logout network error' ) )
169101
170102 const result = await executeAgiloftRequest (
@@ -177,10 +109,7 @@ describe('executeAgiloftRequest', () => {
177109 } )
178110
179111 it ( 'throws when login does not return an access token' , async ( ) => {
180- inputValidationMockFns . mockSecureFetchWithPinnedIP . mockResolvedValueOnce (
181- mockSecureFetchResponse ( { json : { } } )
182- )
183- // Login failure should still trigger no logout, since no token was issued.
112+ fetchSpy . mockResolvedValueOnce ( mockFetchResponse ( { json : { } } ) )
184113
185114 await expect (
186115 executeAgiloftRequest (
@@ -190,23 +119,18 @@ describe('executeAgiloftRequest', () => {
190119 )
191120 ) . rejects . toThrow ( 'Agiloft login did not return an access token' )
192121
193- expect ( inputValidationMockFns . mockSecureFetchWithPinnedIP ) . toHaveBeenCalledTimes ( 1 )
122+ expect ( fetchSpy ) . toHaveBeenCalledTimes ( 1 )
194123 } )
195124
196- it ( 'refuses to call any external endpoint when validation rejects the URL' , async ( ) => {
197- inputValidationMockFns . mockValidateUrlWithDNS . mockResolvedValueOnce ( {
198- isValid : false ,
199- error : 'instanceUrl resolves to a blocked IP address' ,
200- } )
201-
125+ it ( 'rejects an instance URL that fails synchronous URL validation' , async ( ) => {
202126 await expect (
203127 executeAgiloftRequest (
204- { ...baseParams , instanceUrl : 'https://attacker.example.com ' } ,
128+ { ...baseParams , instanceUrl : 'not-a-valid-url ' } ,
205129 ( base ) => ( { url : `${ base } /ewws/REST/demo/contracts/42` , method : 'GET' } ) ,
206130 async ( ) => ( { success : true , output : { } } )
207131 )
208- ) . rejects . toThrow ( 'instanceUrl resolves to a blocked IP address' )
132+ ) . rejects . toThrow ( / I n v a l i d A g i l o f t i n s t a n c e U R L / )
209133
210- expect ( inputValidationMockFns . mockSecureFetchWithPinnedIP ) . not . toHaveBeenCalled ( )
134+ expect ( fetchSpy ) . not . toHaveBeenCalled ( )
211135 } )
212136} )
0 commit comments