@@ -293,7 +293,7 @@ public async Task ReissuedTokenAfterPasswordChange_CanAccessEndpoint()
293293 }
294294
295295 // ─────────────────────────────────────────────────────────
296- // 3. SignalR auth
296+ // 3. SignalR auth — header-based (HubConnection via AccessTokenProvider)
297297 // ─────────────────────────────────────────────────────────
298298
299299 [ Fact ]
@@ -353,7 +353,130 @@ await act.Should().ThrowAsync<HttpRequestException>()
353353 }
354354
355355 // ─────────────────────────────────────────────────────────
356- // 4. GitHub OAuth config endpoints
356+ // 3b. SignalR query-string auth — exercises the OnMessageReceived
357+ // handler in AuthenticationRegistration.cs that extracts
358+ // ?access_token= from the query string for WebSocket connections.
359+ // Uses raw HTTP POST to /hubs/boards/negotiate to bypass the
360+ // .NET HubConnection client (which always uses the Authorization header).
361+ // ─────────────────────────────────────────────────────────
362+
363+ [ Fact ]
364+ public async Task SignalR_QueryStringAuth_ValidTokenAccepted ( )
365+ {
366+ using var client = _factory . CreateClient ( ) ;
367+ var user = await ApiTestHarness . AuthenticateAsync ( client , "signalr-qsv" ) ;
368+
369+ // POST to the negotiate endpoint with the token in the query string,
370+ // NOT in the Authorization header. This exercises the OnMessageReceived
371+ // handler that extracts access_token from Request.Query.
372+ using var rawClient = _factory . CreateClient ( ) ;
373+ var negotiateUrl = $ "/hubs/boards/negotiate?access_token={ Uri . EscapeDataString ( user . Token ) } ";
374+ var response = await rawClient . PostAsync ( negotiateUrl , null ) ;
375+
376+ response . StatusCode . Should ( ) . Be ( HttpStatusCode . OK ,
377+ "SignalR negotiate should accept a valid JWT passed via ?access_token= query string" ) ;
378+ }
379+
380+ [ Fact ]
381+ public async Task SignalR_QueryStringAuth_ExpiredTokenRejected ( )
382+ {
383+ using var client = _factory . CreateClient ( ) ;
384+ var user = await ApiTestHarness . AuthenticateAsync ( client , "signalr-qse" ) ;
385+
386+ var expiredToken = CreateCustomJwt (
387+ userId : user . UserId ,
388+ username : user . Username ,
389+ email : user . Email ,
390+ secretKey : "TaskdeckDevelopmentOnlySecretKeyChangeMe123!" ,
391+ issuer : "Taskdeck" ,
392+ audience : "TaskdeckUsers" ,
393+ expiresIn : TimeSpan . FromMinutes ( - 5 ) ) ;
394+
395+ using var rawClient = _factory . CreateClient ( ) ;
396+ var negotiateUrl = $ "/hubs/boards/negotiate?access_token={ Uri . EscapeDataString ( expiredToken ) } ";
397+ var response = await rawClient . PostAsync ( negotiateUrl , null ) ;
398+
399+ response . StatusCode . Should ( ) . Be ( HttpStatusCode . Unauthorized ,
400+ "SignalR negotiate should reject an expired JWT passed via ?access_token= query string" ) ;
401+ }
402+
403+ [ Fact ]
404+ public async Task SignalR_QueryStringAuth_WrongKeyRejected ( )
405+ {
406+ using var client = _factory . CreateClient ( ) ;
407+ var user = await ApiTestHarness . AuthenticateAsync ( client , "signalr-qsk" ) ;
408+
409+ var wrongKeyToken = CreateCustomJwt (
410+ userId : user . UserId ,
411+ username : user . Username ,
412+ email : user . Email ,
413+ secretKey : "CompletelyDifferentWrongSecretKey_Padding1234!" ,
414+ issuer : "Taskdeck" ,
415+ audience : "TaskdeckUsers" ,
416+ expiresIn : TimeSpan . FromMinutes ( 60 ) ) ;
417+
418+ using var rawClient = _factory . CreateClient ( ) ;
419+ var negotiateUrl = $ "/hubs/boards/negotiate?access_token={ Uri . EscapeDataString ( wrongKeyToken ) } ";
420+ var response = await rawClient . PostAsync ( negotiateUrl , null ) ;
421+
422+ response . StatusCode . Should ( ) . Be ( HttpStatusCode . Unauthorized ,
423+ "SignalR negotiate should reject a JWT signed with the wrong key via ?access_token= query string" ) ;
424+ }
425+
426+ [ Fact ]
427+ public async Task SignalR_QueryStringAuth_NoTokenRejected ( )
428+ {
429+ // POST to negotiate without any token at all — should be rejected
430+ using var rawClient = _factory . CreateClient ( ) ;
431+ var response = await rawClient . PostAsync ( "/hubs/boards/negotiate" , null ) ;
432+
433+ response . StatusCode . Should ( ) . Be ( HttpStatusCode . Unauthorized ,
434+ "SignalR negotiate should reject unauthenticated requests" ) ;
435+ }
436+
437+ // ─────────────────────────────────────────────────────────
438+ // 4. Expired JWT → multiple endpoints return 401
439+ // Verifies that the 401 + ApiErrorResponse contract is
440+ // not accidental to one controller, but systemic.
441+ // ─────────────────────────────────────────────────────────
442+
443+ [ Theory ]
444+ [ InlineData ( "/api/boards" ) ]
445+ [ InlineData ( "/api/capture/items" ) ]
446+ [ InlineData ( "/api/auth/change-password" ) ]
447+ public async Task ExpiredJwt_MultipleEndpoints_Return401 ( string endpoint )
448+ {
449+ using var client = _factory . CreateClient ( ) ;
450+ var user = await ApiTestHarness . AuthenticateAsync ( client , "tok-multi" ) ;
451+
452+ var expiredToken = CreateCustomJwt (
453+ userId : user . UserId ,
454+ username : user . Username ,
455+ email : user . Email ,
456+ secretKey : "TaskdeckDevelopmentOnlySecretKeyChangeMe123!" ,
457+ issuer : "Taskdeck" ,
458+ audience : "TaskdeckUsers" ,
459+ expiresIn : TimeSpan . FromMinutes ( - 5 ) ) ;
460+
461+ using var expiredClient = _factory . CreateClient ( ) ;
462+ expiredClient . DefaultRequestHeaders . Authorization = new AuthenticationHeaderValue ( "Bearer" , expiredToken ) ;
463+
464+ // Use POST for change-password; GET for everything else. Either way should 401 before body validation.
465+ var response = endpoint . Contains ( "change-password" )
466+ ? await expiredClient . PostAsJsonAsync ( endpoint , new
467+ {
468+ CurrentPassword = "password123" ,
469+ NewPassword = "NewSecurePassword!789"
470+ } )
471+ : await expiredClient . GetAsync ( endpoint ) ;
472+
473+ response . StatusCode . Should ( ) . Be ( HttpStatusCode . Unauthorized ,
474+ $ "endpoint { endpoint } should reject expired JWT") ;
475+ await ApiTestHarness . AssertErrorContractAsync ( response , HttpStatusCode . Unauthorized ) ;
476+ }
477+
478+ // ─────────────────────────────────────────────────────────
479+ // 5. GitHub OAuth config endpoints
357480 // ─────────────────────────────────────────────────────────
358481
359482 [ Fact ]
0 commit comments