@@ -66,6 +66,41 @@ test("callV4() targets the sandbox v4 base in sandbox mode", async () => {
6666 }
6767} ) ;
6868
69+ test ( "callV4() retries a 5xx for a Get action then returns data" , async ( ) => {
70+ let calls = 0 ;
71+ const mock = mockFetch ( ( ) => {
72+ calls ++ ;
73+ if ( calls === 1 ) return new Response ( "gateway" , { status : 502 } ) ;
74+ return new Response ( JSON . stringify ( { data : { Accounts : [ ] } } ) , { status : 200 } ) ;
75+ } ) ;
76+ try {
77+ const client = new YandexDirectClient ( { token : "T" , lang : "ru" , sandbox : false , retryBaseMs : 0 } ) ;
78+ const result = await client . callV4 ( "AccountManagement" , { Action : "Get" , SelectionCriteria : { } } ) ;
79+ assert . deepEqual ( result , { Accounts : [ ] } ) ;
80+ assert . equal ( calls , 2 ) ;
81+ } finally {
82+ mock . restore ( ) ;
83+ }
84+ } ) ;
85+
86+ test ( "callV4() does NOT retry a 5xx for a non-Get action (no duplicate write)" , async ( ) => {
87+ let calls = 0 ;
88+ const mock = mockFetch ( ( ) => {
89+ calls ++ ;
90+ return new Response ( "gateway" , { status : 502 } ) ;
91+ } ) ;
92+ try {
93+ const client = new YandexDirectClient ( { token : "T" , lang : "ru" , sandbox : false , retryBaseMs : 0 } ) ;
94+ await assert . rejects (
95+ ( ) => client . callV4 ( "AccountManagement" , { Action : "Update" } ) ,
96+ / L i v e v 4 " A c c o u n t M a n a g e m e n t " f a i l e d w i t h H T T P 5 0 2 / ,
97+ ) ;
98+ assert . equal ( calls , 1 ) ;
99+ } finally {
100+ mock . restore ( ) ;
101+ }
102+ } ) ;
103+
69104test ( "call() targets sandbox, sends bearer token and parses result" , async ( ) => {
70105 const mock = mockFetch (
71106 ( ) => new Response ( JSON . stringify ( { result : { Campaigns : [ ] } } ) , { status : 200 } ) ,
@@ -214,7 +249,10 @@ test("getAll stops at maxPages and flags the truncation loudly", async () => {
214249 // Hitting the cap is explicit, not a bare LimitedBy that the model may ignore.
215250 assert . equal ( result . _truncated , true ) ;
216251 assert . match ( result . _truncatedNote ?? "" , / m o r e o b j e c t s r e m a i n / ) ;
217- assert . notEqual ( result . LimitedBy , undefined ) ;
252+ // LimitedBy is the cursor AFTER the last merged page (page 2 → offset 2), not the stale
253+ // page-1 value copied from the first page's scalar (which was 1).
254+ assert . equal ( result . LimitedBy , 2 ) ;
255+ assert . match ( result . _truncatedNote ?? "" , / L i m i t e d B y = 2 / ) ;
218256 } finally {
219257 mock . restore ( ) ;
220258 }
@@ -327,7 +365,14 @@ test("call() aborts and reports a timeout when the request hangs", async () => {
327365 ) ;
328366 } ) ) as typeof fetch ;
329367 try {
330- const client = new YandexDirectClient ( { token : "T" , lang : "ru" , sandbox : true , timeoutMs : 10 } ) ;
368+ // maxRetries:0 so the timeout surfaces immediately (a hung read is otherwise retried).
369+ const client = new YandexDirectClient ( {
370+ token : "T" ,
371+ lang : "ru" ,
372+ sandbox : true ,
373+ timeoutMs : 10 ,
374+ maxRetries : 0 ,
375+ } ) ;
331376 await assert . rejects ( ( ) => client . call ( "campaigns" , "get" , { } ) , / t i m e d o u t a f t e r 1 0 m s / ) ;
332377 } finally {
333378 globalThis . fetch = original ;
@@ -371,3 +416,107 @@ test("report() gives up on a persistent 5xx after maxPolls", async () => {
371416 mock . restore ( ) ;
372417 }
373418} ) ;
419+
420+ test ( "call() rejects a service path that resolves to a foreign origin and never fetches" , async ( ) => {
421+ // SSRF guard: an absolute/scheme-bearing service, or a backslash/protocol-relative one,
422+ // resolves to a foreign origin and would rebase the token-bearing request onto another
423+ // host — reject before fetching. (The backslash form slips past a naive `startsWith("/")`
424+ // string test, which is why the guard compares the resolved origin.)
425+ const mock = mockFetch ( ( ) => new Response ( JSON . stringify ( { result : { } } ) , { status : 200 } ) ) ;
426+ try {
427+ const client = new YandexDirectClient ( { token : "T" , lang : "ru" , sandbox : true } ) ;
428+ for ( const evil of [ "https://evil.example/steal" , "http://evil.example/x" , "\\\\evil.example/x" ] ) {
429+ await assert . rejects ( ( ) => client . call ( evil , "get" , { } ) , / f o r e i g n o r i g i n / ) ;
430+ }
431+ assert . equal ( mock . calls . length , 0 ) ;
432+ // A normal relative service still works.
433+ const result = await client . call ( "campaigns" , "get" , { } ) ;
434+ assert . deepEqual ( result , { } ) ;
435+ assert . equal ( mock . calls . length , 1 ) ;
436+ } finally {
437+ mock . restore ( ) ;
438+ }
439+ } ) ;
440+
441+ test ( "call() does NOT retry an HTTP 5xx for a write method (no duplicate write)" , async ( ) => {
442+ // A write (add/update/delete/set) may have committed before the gateway error, so a blind
443+ // retry could duplicate it. Only reads (get/has/check) are retried on 5xx.
444+ let calls = 0 ;
445+ const mock = mockFetch ( ( ) => {
446+ calls ++ ;
447+ return new Response ( "bad gateway" , { status : 502 } ) ;
448+ } ) ;
449+ try {
450+ const client = new YandexDirectClient ( { token : "T" , lang : "ru" , sandbox : true , retryBaseMs : 0 } ) ;
451+ await assert . rejects ( ( ) => client . call ( "campaigns" , "add" , { } ) , / H T T P 5 0 2 / ) ;
452+ assert . equal ( calls , 1 ) ; // single attempt, no retry
453+ } finally {
454+ mock . restore ( ) ;
455+ }
456+ } ) ;
457+
458+ test ( "call() retries a rate-limit code even for a write method (request not processed)" , async ( ) => {
459+ // 506/52 mean the request was NOT processed (like 429), so retrying a write is safe.
460+ let calls = 0 ;
461+ const mock = mockFetch ( ( ) => {
462+ calls ++ ;
463+ if ( calls === 1 ) {
464+ return new Response (
465+ JSON . stringify ( { error : { error_code : 506 , error_string : "Too many requests" } } ) ,
466+ { status : 200 } ,
467+ ) ;
468+ }
469+ return new Response ( JSON . stringify ( { result : { AddResults : [ { Id : 1 } ] } } ) , { status : 200 } ) ;
470+ } ) ;
471+ try {
472+ const client = new YandexDirectClient ( { token : "T" , lang : "ru" , sandbox : true , retryBaseMs : 0 } ) ;
473+ const result = await client . call ( "campaigns" , "add" , { } ) ;
474+ assert . deepEqual ( result , { AddResults : [ { Id : 1 } ] } ) ;
475+ assert . equal ( calls , 2 ) ;
476+ } finally {
477+ mock . restore ( ) ;
478+ }
479+ } ) ;
480+
481+ test ( "call() retries a network error for a read method, then succeeds" , async ( ) => {
482+ let calls = 0 ;
483+ const mock = mockFetch ( ( ) => {
484+ calls ++ ;
485+ if ( calls === 1 ) throw Object . assign ( new Error ( "ECONNRESET" ) , { code : "ECONNRESET" } ) ;
486+ return new Response ( JSON . stringify ( { result : { ok : true } } ) , { status : 200 } ) ;
487+ } ) ;
488+ try {
489+ const client = new YandexDirectClient ( { token : "T" , lang : "ru" , sandbox : true , retryBaseMs : 0 } ) ;
490+ const result = await client . call ( "campaigns" , "get" , { } ) ;
491+ assert . deepEqual ( result , { ok : true } ) ;
492+ assert . equal ( calls , 2 ) ;
493+ } finally {
494+ mock . restore ( ) ;
495+ }
496+ } ) ;
497+
498+ test ( "call() does NOT retry a network error for a write method" , async ( ) => {
499+ let calls = 0 ;
500+ const mock = mockFetch ( ( ) => {
501+ calls ++ ;
502+ throw Object . assign ( new Error ( "ECONNRESET" ) , { code : "ECONNRESET" } ) ;
503+ } ) ;
504+ try {
505+ const client = new YandexDirectClient ( { token : "T" , lang : "ru" , sandbox : true , retryBaseMs : 0 } ) ;
506+ await assert . rejects ( ( ) => client . call ( "campaigns" , "add" , { } ) , / E C O N N R E S E T / ) ;
507+ assert . equal ( calls , 1 ) ;
508+ } finally {
509+ mock . restore ( ) ;
510+ }
511+ } ) ;
512+
513+ test ( "YandexDirectError appends request_id to the message when present" , ( ) => {
514+ const err = new YandexDirectError ( {
515+ error_code : 54 ,
516+ error_string : "No units" ,
517+ request_id : "abc123" ,
518+ } ) ;
519+ assert . match ( err . message , / \[ 5 4 \] N o u n i t s / ) ;
520+ assert . match ( err . message , / r e q u e s t _ i d : a b c 1 2 3 / ) ;
521+ assert . equal ( err . requestId , "abc123" ) ;
522+ } ) ;
0 commit comments