@@ -1017,6 +1017,155 @@ describe("CloudTaskService", () => {
10171017 expect ( mockStreamTokenFetch . mock . calls . length ) . toBe ( 1 ) ;
10181018 } ) ;
10191019
1020+ it ( "proxy 401 re-resolves the read target and resumes with a fresh token" , async ( ) => {
1021+ vi . useFakeTimers ( ) ;
1022+
1023+ mockStreamTokenFetch
1024+ . mockImplementationOnce ( ( ) =>
1025+ Promise . resolve (
1026+ createJsonResponse ( {
1027+ token : "expired-token" ,
1028+ stream_base_url : "https://proxy.example" ,
1029+ } ) ,
1030+ ) ,
1031+ )
1032+ . mockImplementation ( ( ) =>
1033+ Promise . resolve (
1034+ createJsonResponse ( {
1035+ token : "fresh-token" ,
1036+ stream_base_url : "https://proxy.example" ,
1037+ } ) ,
1038+ ) ,
1039+ ) ;
1040+
1041+ mockNetFetch . mockImplementation ( ( input : string | Request ) => {
1042+ const url = typeof input === "string" ? input : input . url ;
1043+ if ( url . includes ( "/session_logs/" ) ) {
1044+ return Promise . resolve (
1045+ createJsonResponse ( [ ] , 200 , { "X-Has-More" : "false" } ) ,
1046+ ) ;
1047+ }
1048+ return Promise . resolve (
1049+ createJsonResponse ( {
1050+ id : "run-1" ,
1051+ status : "in_progress" ,
1052+ stage : null ,
1053+ output : null ,
1054+ error_message : null ,
1055+ branch : "main" ,
1056+ updated_at : "2026-01-01T00:00:00Z" ,
1057+ } ) ,
1058+ ) ;
1059+ } ) ;
1060+
1061+ mockStreamFetch
1062+ . mockImplementationOnce ( ( ) =>
1063+ Promise . resolve ( createJsonResponse ( { detail : "expired" } , 401 ) ) ,
1064+ )
1065+ . mockImplementation ( ( ) =>
1066+ Promise . resolve (
1067+ createOpenSseResponse (
1068+ 'event: keepalive\ndata: {"type":"keepalive"}\n\n' ,
1069+ ) ,
1070+ ) ,
1071+ ) ;
1072+
1073+ service . watch ( {
1074+ taskId : "task-1" ,
1075+ runId : "run-1" ,
1076+ apiHost : "https://app.example.com" ,
1077+ teamId : 2 ,
1078+ } ) ;
1079+
1080+ await waitFor ( ( ) => mockStreamFetch . mock . calls . length >= 2 , 10_000 ) ;
1081+
1082+ expect ( mockStreamTokenFetch . mock . calls . length ) . toBe ( 2 ) ;
1083+ const [ secondUrl , secondInit ] = mockStreamFetch . mock . calls [ 1 ] ;
1084+ expect ( String ( secondUrl ) ) . toMatch (
1085+ / ^ h t t p s : \/ \/ p r o x y \. e x a m p l e \/ v 1 \/ r u n s \/ r u n - 1 \/ s t r e a m / ,
1086+ ) ;
1087+ expect ( ( secondInit ?. headers as Record < string , string > ) ?. Authorization ) . toBe (
1088+ "Bearer fresh-token" ,
1089+ ) ;
1090+ expect (
1091+ ( service as unknown as { watchers : Map < string , unknown > } ) . watchers . has (
1092+ "task-1:run-1" ,
1093+ ) ,
1094+ ) . toBe ( true ) ;
1095+ } ) ;
1096+
1097+ it ( "proxy 401 falls back to Django when the proxy is withdrawn" , async ( ) => {
1098+ vi . useFakeTimers ( ) ;
1099+
1100+ // The re-resolution after the 401 no longer offers a proxy (rollout flag turned
1101+ // off mid-run); the watcher continues on the Django leg, which still emits the
1102+ // stream-end sentinel.
1103+ mockStreamTokenFetch
1104+ . mockImplementationOnce ( ( ) =>
1105+ Promise . resolve (
1106+ createJsonResponse ( {
1107+ token : "expired-token" ,
1108+ stream_base_url : "https://proxy.example" ,
1109+ } ) ,
1110+ ) ,
1111+ )
1112+ . mockImplementation ( ( ) =>
1113+ Promise . resolve (
1114+ createJsonResponse ( { token : "django-token" , stream_base_url : null } ) ,
1115+ ) ,
1116+ ) ;
1117+
1118+ let runFetchCount = 0 ;
1119+ mockNetFetch . mockImplementation ( ( input : string | Request ) => {
1120+ const url = typeof input === "string" ? input : input . url ;
1121+ if ( url . includes ( "/session_logs/" ) ) {
1122+ return Promise . resolve (
1123+ createJsonResponse ( [ ] , 200 , { "X-Has-More" : "false" } ) ,
1124+ ) ;
1125+ }
1126+ runFetchCount += 1 ;
1127+ const terminal = runFetchCount >= 2 ;
1128+ return Promise . resolve (
1129+ createJsonResponse ( {
1130+ id : "run-1" ,
1131+ status : terminal ? "completed" : "in_progress" ,
1132+ stage : null ,
1133+ output : null ,
1134+ error_message : null ,
1135+ branch : "main" ,
1136+ updated_at : terminal
1137+ ? "2026-01-01T00:00:05Z"
1138+ : "2026-01-01T00:00:00Z" ,
1139+ } ) ,
1140+ ) ;
1141+ } ) ;
1142+
1143+ mockStreamFetch
1144+ . mockImplementationOnce ( ( ) =>
1145+ Promise . resolve ( createJsonResponse ( { detail : "expired" } , 401 ) ) ,
1146+ )
1147+ . mockImplementation ( ( ) =>
1148+ Promise . resolve ( createSseResponse ( "event: stream-end\ndata: {}\n\n" ) ) ,
1149+ ) ;
1150+
1151+ service . watch ( {
1152+ taskId : "task-1" ,
1153+ runId : "run-1" ,
1154+ apiHost : "https://app.example.com" ,
1155+ teamId : 2 ,
1156+ } ) ;
1157+
1158+ const hasWatcher = ( ) : boolean =>
1159+ ( service as unknown as { watchers : Map < string , unknown > } ) . watchers . has (
1160+ "task-1:run-1" ,
1161+ ) ;
1162+ await waitFor ( ( ) => ! hasWatcher ( ) , 10_000 ) ;
1163+
1164+ expect ( mockStreamTokenFetch . mock . calls . length ) . toBe ( 2 ) ;
1165+ const [ secondUrl ] = mockStreamFetch . mock . calls [ 1 ] ;
1166+ expect ( String ( secondUrl ) ) . toContain ( "https://app.example.com/api/" ) ;
1167+ } ) ;
1168+
10201169 it ( "stream-end still stops the watcher in legacy mode" , async ( ) => {
10211170 vi . useFakeTimers ( ) ;
10221171
0 commit comments