@@ -299,6 +299,190 @@ func TestFallbackNonRoutedRequestUntouched(t *testing.T) {
299299 }
300300}
301301
302+ // TestFallback503And504ToControlPlane: 503 and 504 from a dead VM also trigger a
303+ // single control-plane fallback (same as 502).
304+ func TestFallback503And504ToControlPlane (t * testing.T ) {
305+ for _ , status := range []int {http .StatusServiceUnavailable , http .StatusGatewayTimeout } {
306+ cache := NewRouteCache ()
307+ cache .Store (Route {SessionID : "sess-1" , BaseURL : "https://browser.example/browser/kernel" , JWT : "jwt-123" })
308+ middleware := DirectVMRoutingMiddleware (cache , []string {"curl" })
309+
310+ var calls int
311+ var lastReq * http.Request
312+ res , err := middleware (routedGetRequest (t ), func (req * http.Request ) (* http.Response , error ) {
313+ calls ++
314+ lastReq = req
315+ if calls == 1 {
316+ return & http.Response {
317+ StatusCode : status ,
318+ Header : http.Header {},
319+ Body : io .NopCloser (strings .NewReader ("upstream down" )),
320+ }, nil
321+ }
322+ return & http.Response {
323+ StatusCode : http .StatusOK ,
324+ Header : http.Header {},
325+ Body : io .NopCloser (strings .NewReader ("cp-ok" )),
326+ }, nil
327+ })
328+ if err != nil {
329+ t .Fatal (err )
330+ }
331+ if calls != 2 {
332+ t .Fatalf ("status %d: expected VM attempt + CP fallback, got %d attempts" , status , calls )
333+ }
334+ if lastReq .URL .Host != "api.example" {
335+ t .Fatalf ("status %d: expected fallback to control plane, got host %q" , status , lastReq .URL .Host )
336+ }
337+ if res .StatusCode != http .StatusOK {
338+ t .Fatalf ("status %d: expected control-plane 200, got %d" , status , res .StatusCode )
339+ }
340+ }
341+ }
342+
343+ // TestFallbackHeadToControlPlane: HEAD is idempotent and must fall back like GET.
344+ func TestFallbackHeadToControlPlane (t * testing.T ) {
345+ cache := NewRouteCache ()
346+ cache .Store (Route {SessionID : "sess-1" , BaseURL : "https://browser.example/browser/kernel" , JWT : "jwt-123" })
347+ middleware := DirectVMRoutingMiddleware (cache , []string {"curl" })
348+
349+ req := routedGetRequest (t )
350+ req .Method = http .MethodHead
351+
352+ var calls int
353+ var lastReq * http.Request
354+ res , err := middleware (req , func (req * http.Request ) (* http.Response , error ) {
355+ calls ++
356+ lastReq = req
357+ if calls == 1 {
358+ return & http.Response {
359+ StatusCode : http .StatusBadGateway ,
360+ Header : http.Header {},
361+ Body : io .NopCloser (strings .NewReader ("Upstream not available" )),
362+ }, nil
363+ }
364+ return & http.Response {
365+ StatusCode : http .StatusOK ,
366+ Header : http.Header {},
367+ Body : io .NopCloser (strings .NewReader ("" )),
368+ }, nil
369+ })
370+ if err != nil {
371+ t .Fatal (err )
372+ }
373+ if calls != 2 {
374+ t .Fatalf ("expected HEAD to fall back (VM + CP), got %d attempts" , calls )
375+ }
376+ if lastReq .URL .Host != "api.example" {
377+ t .Fatalf ("expected HEAD fallback to control plane, got host %q" , lastReq .URL .Host )
378+ }
379+ if lastReq .Header .Get ("Authorization" ) != "Bearer sk_test" {
380+ t .Fatalf ("expected Authorization restored on HEAD fallback, got %q" , lastReq .Header .Get ("Authorization" ))
381+ }
382+ if res .StatusCode != http .StatusOK {
383+ t .Fatalf ("expected control-plane 200 for HEAD, got %d" , res .StatusCode )
384+ }
385+ }
386+
387+ // TestFallbackGoneHeaderWrongCodeNoFallback: a 404 with the X-Kernel-Upstream:
388+ // gone header but a JSON code that is NOT "browser_gone" must NOT fall back, and
389+ // its body must remain readable on the returned response.
390+ func TestFallbackGoneHeaderWrongCodeNoFallback (t * testing.T ) {
391+ cache := NewRouteCache ()
392+ cache .Store (Route {SessionID : "sess-1" , BaseURL : "https://browser.example/browser/kernel" , JWT : "jwt-123" })
393+ middleware := DirectVMRoutingMiddleware (cache , []string {"curl" })
394+
395+ const bodyText = `{"code":"not_found","message":"nope"}`
396+ var calls int
397+ res , err := middleware (routedGetRequest (t ), func (req * http.Request ) (* http.Response , error ) {
398+ calls ++
399+ return & http.Response {
400+ StatusCode : http .StatusNotFound ,
401+ Header : http.Header {
402+ "Content-Type" : []string {"application/json" },
403+ "X-Kernel-Upstream" : []string {"gone" },
404+ },
405+ Body : io .NopCloser (strings .NewReader (bodyText )),
406+ }, nil
407+ })
408+ if err != nil {
409+ t .Fatal (err )
410+ }
411+ if calls != 1 {
412+ t .Fatalf ("expected no fallback when code != browser_gone, got %d attempts" , calls )
413+ }
414+ body , _ := io .ReadAll (res .Body )
415+ if string (body ) != bodyText {
416+ t .Fatalf ("expected body preserved after non-triggering gone sniff, got %q" , string (body ))
417+ }
418+ }
419+
420+ // TestFallbackGoneBodyWithoutHeaderNoFallback: a browser_gone JSON body WITHOUT
421+ // the X-Kernel-Upstream: gone header must NOT fall back (header is checked before
422+ // the body, so the body is never even sniffed).
423+ func TestFallbackGoneBodyWithoutHeaderNoFallback (t * testing.T ) {
424+ cache := NewRouteCache ()
425+ cache .Store (Route {SessionID : "sess-1" , BaseURL : "https://browser.example/browser/kernel" , JWT : "jwt-123" })
426+ middleware := DirectVMRoutingMiddleware (cache , []string {"curl" })
427+
428+ const bodyText = `{"code":"browser_gone","message":"browser not found"}`
429+ var calls int
430+ res , err := middleware (routedGetRequest (t ), func (req * http.Request ) (* http.Response , error ) {
431+ calls ++
432+ return & http.Response {
433+ StatusCode : http .StatusNotFound ,
434+ Header : http.Header {"Content-Type" : []string {"application/json" }},
435+ Body : io .NopCloser (strings .NewReader (bodyText )),
436+ }, nil
437+ })
438+ if err != nil {
439+ t .Fatal (err )
440+ }
441+ if calls != 1 {
442+ t .Fatalf ("expected no fallback without X-Kernel-Upstream header, got %d attempts" , calls )
443+ }
444+ body , _ := io .ReadAll (res .Body )
445+ if string (body ) != bodyText {
446+ t .Fatalf ("expected body untouched (header gate hit first), got %q" , string (body ))
447+ }
448+ }
449+
450+ // TestFallbackFinalCPBodyReadable: after a 502 fallback, the control-plane
451+ // response body must be fully readable by downstream consumers.
452+ func TestFallbackFinalCPBodyReadable (t * testing.T ) {
453+ cache := NewRouteCache ()
454+ cache .Store (Route {SessionID : "sess-1" , BaseURL : "https://browser.example/browser/kernel" , JWT : "jwt-123" })
455+ middleware := DirectVMRoutingMiddleware (cache , []string {"curl" })
456+
457+ const cpBody = `{"code":"browser_not_found"}`
458+ var calls int
459+ res , err := middleware (routedGetRequest (t ), func (req * http.Request ) (* http.Response , error ) {
460+ calls ++
461+ if calls == 1 {
462+ return & http.Response {
463+ StatusCode : http .StatusBadGateway ,
464+ Header : http.Header {},
465+ Body : io .NopCloser (strings .NewReader ("Upstream not available" )),
466+ }, nil
467+ }
468+ return & http.Response {
469+ StatusCode : http .StatusNotFound ,
470+ Header : http.Header {"Content-Type" : []string {"application/json" }},
471+ Body : io .NopCloser (strings .NewReader (cpBody )),
472+ }, nil
473+ })
474+ if err != nil {
475+ t .Fatal (err )
476+ }
477+ body , readErr := io .ReadAll (res .Body )
478+ if readErr != nil {
479+ t .Fatalf ("expected readable CP body, got error %v" , readErr )
480+ }
481+ if string (body ) != cpBody {
482+ t .Fatalf ("expected CP body %q, got %q" , cpBody , string (body ))
483+ }
484+ }
485+
302486func TestDirectVMRoutingMiddlewareClearsStaleRawPath (t * testing.T ) {
303487 cache := NewRouteCache ()
304488 cache .Store (Route {
0 commit comments