Skip to content

Commit eee6adb

Browse files
authored
Merge pull request #815 from Chris0Jeky/test/oauth-auth-code-token-lifecycle
TST-55: OAuth auth code store and token lifecycle tests
2 parents ab200fe + 4d238bb commit eee6adb

File tree

1 file changed

+125
-2
lines changed

1 file changed

+125
-2
lines changed

backend/tests/Taskdeck.Api.Tests/OAuthTokenLifecycleTests.cs

Lines changed: 125 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)