Skip to content

Commit 7e79b1a

Browse files
authored
Merge pull request #737 from Chris0Jeky/test/707-oauth-auth-edge-cases
TST-40: OAuth and authentication edge case tests
2 parents a6aca0b + 35d6c7b commit 7e79b1a

3 files changed

Lines changed: 968 additions & 1 deletion

File tree

backend/src/Taskdeck.Application/Services/AuthenticationService.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,8 @@ public async Task<Result<AuthResultDto>> ExternalLoginAsync(ExternalLoginDto dto
179179
suffix++;
180180
if (suffix > maxUsernameSuffixAttempts)
181181
{
182-
candidateUsername = $"{normalizedUsername}-{Guid.NewGuid():N}".Substring(0, 50);
182+
var guidFallback = $"{normalizedUsername}-{Guid.NewGuid():N}";
183+
candidateUsername = guidFallback[..Math.Min(50, guidFallback.Length)];
183184
break;
184185
}
185186
candidateUsername = $"{normalizedUsername}{suffix}";
Lines changed: 375 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,375 @@
1+
using System.Collections.Concurrent;
2+
using System.IdentityModel.Tokens.Jwt;
3+
using System.Reflection;
4+
using System.Security.Claims;
5+
using System.Text.Json;
6+
using FluentAssertions;
7+
using Microsoft.AspNetCore.Http;
8+
using Microsoft.AspNetCore.Mvc;
9+
using Microsoft.Extensions.Logging.Abstractions;
10+
using Moq;
11+
using Taskdeck.Api.Contracts;
12+
using Taskdeck.Api.Controllers;
13+
using Taskdeck.Api.Middleware;
14+
using Taskdeck.Application.DTOs;
15+
using Taskdeck.Application.Interfaces;
16+
using Taskdeck.Application.Services;
17+
using Taskdeck.Domain.Entities;
18+
using Taskdeck.Domain.Exceptions;
19+
using Xunit;
20+
21+
namespace Taskdeck.Api.Tests;
22+
23+
/// <summary>
24+
/// Edge-case integration tests for AuthController and TokenValidationMiddleware,
25+
/// verifying security properties around OAuth flows, JWT lifecycle,
26+
/// and session invalidation.
27+
/// Linked to #707 (TST-40).
28+
/// </summary>
29+
public class AuthControllerEdgeCaseTests
30+
{
31+
private static readonly JwtSettings DefaultJwtSettings = new()
32+
{
33+
SecretKey = "TaskdeckTestsOnlySecretKeyMustBeLongEnough123!",
34+
Issuer = "TaskdeckTests",
35+
Audience = "TaskdeckUsers",
36+
ExpirationMinutes = 60
37+
};
38+
39+
// ─────────────────────────────────────────────────────────
40+
// OAuth code exchange edge cases
41+
// ─────────────────────────────────────────────────────────
42+
43+
[Fact]
44+
public void ExchangeCode_ShouldReturn400_WhenCodeIsEmpty()
45+
{
46+
var controller = CreateAuthController();
47+
var result = controller.ExchangeCode(new ExchangeCodeRequest(string.Empty));
48+
49+
var badRequest = result.Should().BeOfType<BadRequestObjectResult>().Subject;
50+
var error = badRequest.Value.Should().BeOfType<ApiErrorResponse>().Subject;
51+
error.ErrorCode.Should().Be(ErrorCodes.ValidationError);
52+
}
53+
54+
[Fact]
55+
public void ExchangeCode_ShouldReturn401_WhenCodeIsInvalid()
56+
{
57+
var controller = CreateAuthController();
58+
var result = controller.ExchangeCode(new ExchangeCodeRequest("nonexistent-code"));
59+
60+
var unauthorized = result.Should().BeOfType<UnauthorizedObjectResult>().Subject;
61+
var error = unauthorized.Value.Should().BeOfType<ApiErrorResponse>().Subject;
62+
error.ErrorCode.Should().Be(ErrorCodes.AuthenticationFailed);
63+
}
64+
65+
[Fact]
66+
public void ExchangeCode_ShouldPreventReplay_SecondUseOfSameCode()
67+
{
68+
// Insert a code into the static dictionary via reflection
69+
var code = "test-replay-code";
70+
var authResult = new AuthResultDto("fake-token", new UserDto(
71+
Guid.NewGuid(), "user", "user@test.com",
72+
Domain.Enums.UserRole.Editor, true,
73+
DateTimeOffset.UtcNow, DateTimeOffset.UtcNow));
74+
75+
InjectAuthCode(code, authResult, DateTimeOffset.UtcNow.AddSeconds(60));
76+
77+
var controller = CreateAuthController();
78+
79+
// First exchange — success
80+
var first = controller.ExchangeCode(new ExchangeCodeRequest(code));
81+
first.Should().BeOfType<OkObjectResult>();
82+
83+
// Second exchange with same code — should fail (code was consumed)
84+
var second = controller.ExchangeCode(new ExchangeCodeRequest(code));
85+
second.Should().BeOfType<UnauthorizedObjectResult>();
86+
}
87+
88+
[Fact]
89+
public void ExchangeCode_ShouldReturn401_WhenCodeHasExpired()
90+
{
91+
var code = "test-expired-code";
92+
var authResult = new AuthResultDto("fake-token", new UserDto(
93+
Guid.NewGuid(), "user", "user@test.com",
94+
Domain.Enums.UserRole.Editor, true,
95+
DateTimeOffset.UtcNow, DateTimeOffset.UtcNow));
96+
97+
// Inject code that expired 10 seconds ago
98+
InjectAuthCode(code, authResult, DateTimeOffset.UtcNow.AddSeconds(-10));
99+
100+
var controller = CreateAuthController();
101+
var result = controller.ExchangeCode(new ExchangeCodeRequest(code));
102+
103+
// TryRemove succeeds (code existed), then the expiry check fires — returns "Code has expired"
104+
var unauthorized = result.Should().BeOfType<UnauthorizedObjectResult>().Subject;
105+
var error = unauthorized.Value.Should().BeOfType<ApiErrorResponse>().Subject;
106+
error.Message.Should().Contain("expired");
107+
}
108+
109+
[Fact]
110+
public void GetProviders_ShouldReturnGitHubStatus()
111+
{
112+
var controller = CreateAuthController(gitHubConfigured: true);
113+
var result = controller.GetProviders();
114+
115+
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
116+
ok.Value.Should().NotBeNull();
117+
}
118+
119+
// ─────────────────────────────────────────────────────────
120+
// GitHub login edge cases
121+
// ─────────────────────────────────────────────────────────
122+
123+
[Fact]
124+
public void GitHubLogin_ShouldReturn404_WhenNotConfigured()
125+
{
126+
var controller = CreateAuthController(gitHubConfigured: false);
127+
var result = controller.GitHubLogin();
128+
129+
var notFound = result.Should().BeOfType<NotFoundObjectResult>().Subject;
130+
var error = notFound.Value.Should().BeOfType<ApiErrorResponse>().Subject;
131+
error.ErrorCode.Should().Be(ErrorCodes.NotFound);
132+
}
133+
134+
[Fact]
135+
public void GitHubLogin_ShouldReturn400_WhenReturnUrlIsExternal()
136+
{
137+
var controller = CreateAuthController(gitHubConfigured: true);
138+
139+
// The controller calls Url.IsLocalUrl which needs ActionContext setup.
140+
// We set up a mock URL helper that rejects external URLs.
141+
var urlHelper = new Mock<IUrlHelper>();
142+
urlHelper.Setup(u => u.IsLocalUrl("https://evil.com/steal")).Returns(false);
143+
controller.Url = urlHelper.Object;
144+
145+
var result = controller.GitHubLogin(returnUrl: "https://evil.com/steal");
146+
147+
var badRequest = result.Should().BeOfType<BadRequestObjectResult>().Subject;
148+
var error = badRequest.Value.Should().BeOfType<ApiErrorResponse>().Subject;
149+
error.ErrorCode.Should().Be(ErrorCodes.ValidationError);
150+
error.Message.Should().Contain("Invalid return URL");
151+
}
152+
153+
// ─────────────────────────────────────────────────────────
154+
// TokenValidationMiddleware — account deletion during active session
155+
// ─────────────────────────────────────────────────────────
156+
157+
[Fact]
158+
public async Task TokenValidationMiddleware_ShouldReturn401_WhenUserDeletedDuringSession()
159+
{
160+
var userId = Guid.NewGuid();
161+
var unitOfWorkMock = new Mock<IUnitOfWork>();
162+
var userRepoMock = new Mock<IUserRepository>();
163+
unitOfWorkMock.Setup(u => u.Users).Returns(userRepoMock.Object);
164+
165+
// User is deleted — returns null
166+
userRepoMock.Setup(r => r.GetByIdAsync(userId, It.IsAny<CancellationToken>()))
167+
.ReturnsAsync((User?)null);
168+
169+
var nextCalled = false;
170+
RequestDelegate next = _ => { nextCalled = true; return Task.CompletedTask; };
171+
var middleware = new TokenValidationMiddleware(next, NullLogger<TokenValidationMiddleware>.Instance);
172+
173+
var context = CreateAuthenticatedContext(userId, DateTimeOffset.UtcNow);
174+
context.Response.Body = new MemoryStream();
175+
176+
await middleware.InvokeAsync(context, unitOfWorkMock.Object);
177+
178+
nextCalled.Should().BeFalse();
179+
context.Response.StatusCode.Should().Be(StatusCodes.Status401Unauthorized);
180+
181+
var body = await ReadResponseBody(context);
182+
body.ErrorCode.Should().Be(ErrorCodes.Unauthorized);
183+
}
184+
185+
[Fact]
186+
public async Task TokenValidationMiddleware_ShouldReturn401_WhenTokenIssuedBeforeInvalidation()
187+
{
188+
var userId = Guid.NewGuid();
189+
var user = new User("testuser", "test@example.com", BCrypt.Net.BCrypt.HashPassword("password"));
190+
SetUserId(user, userId);
191+
192+
// Token was issued 2 hours ago
193+
var tokenIssuedAt = DateTimeOffset.UtcNow.AddHours(-2);
194+
195+
// Invalidation happened 1 hour ago
196+
user.InvalidateTokens();
197+
198+
var unitOfWorkMock = new Mock<IUnitOfWork>();
199+
var userRepoMock = new Mock<IUserRepository>();
200+
unitOfWorkMock.Setup(u => u.Users).Returns(userRepoMock.Object);
201+
userRepoMock.Setup(r => r.GetByIdAsync(userId, It.IsAny<CancellationToken>()))
202+
.ReturnsAsync(user);
203+
204+
var nextCalled = false;
205+
RequestDelegate next = _ => { nextCalled = true; return Task.CompletedTask; };
206+
var middleware = new TokenValidationMiddleware(next, NullLogger<TokenValidationMiddleware>.Instance);
207+
208+
var context = CreateAuthenticatedContext(userId, tokenIssuedAt);
209+
context.Response.Body = new MemoryStream();
210+
211+
await middleware.InvokeAsync(context, unitOfWorkMock.Object);
212+
213+
nextCalled.Should().BeFalse();
214+
context.Response.StatusCode.Should().Be(StatusCodes.Status401Unauthorized);
215+
216+
var body = await ReadResponseBody(context);
217+
body.Message.Should().Contain("invalidated");
218+
}
219+
220+
[Fact]
221+
public async Task TokenValidationMiddleware_ShouldPassThrough_WhenTokenIssuedAfterReauthentication()
222+
{
223+
// After invalidation, a freshly issued token should still work
224+
var userId = Guid.NewGuid();
225+
var user = new User("testuser", "test@example.com", BCrypt.Net.BCrypt.HashPassword("password"));
226+
SetUserId(user, userId);
227+
228+
user.InvalidateTokens();
229+
230+
var unitOfWorkMock = new Mock<IUnitOfWork>();
231+
var userRepoMock = new Mock<IUserRepository>();
232+
unitOfWorkMock.Setup(u => u.Users).Returns(userRepoMock.Object);
233+
userRepoMock.Setup(r => r.GetByIdAsync(userId, It.IsAny<CancellationToken>()))
234+
.ReturnsAsync(user);
235+
236+
var nextCalled = false;
237+
RequestDelegate next = _ => { nextCalled = true; return Task.CompletedTask; };
238+
var middleware = new TokenValidationMiddleware(next, NullLogger<TokenValidationMiddleware>.Instance);
239+
240+
// Token issued 2 seconds after invalidation
241+
var tokenIssuedAt = DateTimeOffset.UtcNow.AddSeconds(2);
242+
var context = CreateAuthenticatedContext(userId, tokenIssuedAt);
243+
244+
await middleware.InvokeAsync(context, unitOfWorkMock.Object);
245+
246+
nextCalled.Should().BeTrue();
247+
}
248+
249+
[Fact]
250+
public async Task TokenValidationMiddleware_ShouldPassThrough_WhenClaimsHaveNoUserId()
251+
{
252+
// Token with authenticated identity but no parseable userId
253+
var claims = new List<Claim> { new("username", "testuser") };
254+
var identity = new ClaimsIdentity(claims, "Bearer");
255+
var principal = new ClaimsPrincipal(identity);
256+
257+
var unitOfWorkMock = new Mock<IUnitOfWork>();
258+
var nextCalled = false;
259+
RequestDelegate next = _ => { nextCalled = true; return Task.CompletedTask; };
260+
var middleware = new TokenValidationMiddleware(next, NullLogger<TokenValidationMiddleware>.Instance);
261+
262+
var context = new DefaultHttpContext { User = principal };
263+
264+
await middleware.InvokeAsync(context, unitOfWorkMock.Object);
265+
266+
// Should pass through — let downstream handle it
267+
nextCalled.Should().BeTrue();
268+
}
269+
270+
// ─────────────────────────────────────────────────────────
271+
// Login controller-level edge cases
272+
// ─────────────────────────────────────────────────────────
273+
274+
[Fact]
275+
public async Task Login_ShouldReturn401_WhenBodyIsNull()
276+
{
277+
var authService = CreateMockAuthService();
278+
var controller = new AuthController(authService.Object, CreateGitHubSettings(false));
279+
280+
var result = await controller.Login(null);
281+
282+
var unauthorized = result.Should().BeOfType<UnauthorizedObjectResult>().Subject;
283+
var error = unauthorized.Value.Should().BeOfType<ApiErrorResponse>().Subject;
284+
error.ErrorCode.Should().Be(ErrorCodes.AuthenticationFailed);
285+
}
286+
287+
[Fact]
288+
public async Task Login_ShouldReturn401_WhenFieldsEmpty()
289+
{
290+
var authService = CreateMockAuthService();
291+
var controller = new AuthController(authService.Object, CreateGitHubSettings(false));
292+
293+
var result = await controller.Login(new LoginDto("", ""));
294+
295+
var unauthorized = result.Should().BeOfType<UnauthorizedObjectResult>().Subject;
296+
var error = unauthorized.Value.Should().BeOfType<ApiErrorResponse>().Subject;
297+
error.ErrorCode.Should().Be(ErrorCodes.AuthenticationFailed);
298+
}
299+
300+
// ─────────────────────────────────────────────────────────
301+
// Helpers
302+
// ─────────────────────────────────────────────────────────
303+
304+
private static AuthController CreateAuthController(bool gitHubConfigured = false)
305+
{
306+
var authServiceMock = CreateMockAuthService();
307+
var gitHubSettings = CreateGitHubSettings(gitHubConfigured);
308+
return new AuthController(authServiceMock.Object, gitHubSettings);
309+
}
310+
311+
private static Mock<AuthenticationService> CreateMockAuthService()
312+
{
313+
var unitOfWorkMock = new Mock<IUnitOfWork>();
314+
var userRepoMock = new Mock<IUserRepository>();
315+
unitOfWorkMock.Setup(u => u.Users).Returns(userRepoMock.Object);
316+
unitOfWorkMock.Setup(u => u.ExternalLogins).Returns(new Mock<IExternalLoginRepository>().Object);
317+
318+
// AuthenticationService is not sealed, but its constructor requires specific params
319+
return new Mock<AuthenticationService>(unitOfWorkMock.Object, DefaultJwtSettings) { CallBase = true };
320+
}
321+
322+
private static GitHubOAuthSettings CreateGitHubSettings(bool configured)
323+
{
324+
return configured
325+
? new GitHubOAuthSettings { ClientId = "test-client", ClientSecret = "test-secret" }
326+
: new GitHubOAuthSettings();
327+
}
328+
329+
private static void InjectAuthCode(string code, AuthResultDto result, DateTimeOffset expiry)
330+
{
331+
// Access the static _authCodes field via reflection
332+
var field = typeof(AuthController).GetField("_authCodes",
333+
BindingFlags.NonPublic | BindingFlags.Static);
334+
var dict = (ConcurrentDictionary<string, (AuthResultDto Result, DateTimeOffset Expiry)>)field!.GetValue(null)!;
335+
dict[code] = (result, expiry);
336+
}
337+
338+
private static DefaultHttpContext CreateAuthenticatedContext(Guid userId, DateTimeOffset? iat)
339+
{
340+
var claims = new List<Claim>
341+
{
342+
new(ClaimTypes.NameIdentifier, userId.ToString())
343+
};
344+
345+
if (iat.HasValue)
346+
{
347+
claims.Add(new Claim(
348+
JwtRegisteredClaimNames.Iat,
349+
iat.Value.ToUnixTimeSeconds().ToString(),
350+
ClaimValueTypes.Integer64));
351+
}
352+
353+
var identity = new ClaimsIdentity(claims, "Bearer");
354+
var principal = new ClaimsPrincipal(identity);
355+
356+
return new DefaultHttpContext { User = principal };
357+
}
358+
359+
private static void SetUserId(User user, Guid userId)
360+
{
361+
var idProperty = typeof(Domain.Common.Entity).GetProperty("Id");
362+
idProperty!.SetValue(user, userId);
363+
}
364+
365+
private static async Task<ApiErrorResponse> ReadResponseBody(HttpContext context)
366+
{
367+
context.Response.Body.Seek(0, SeekOrigin.Begin);
368+
using var reader = new StreamReader(context.Response.Body);
369+
var json = await reader.ReadToEndAsync();
370+
return JsonSerializer.Deserialize<ApiErrorResponse>(json, new JsonSerializerOptions
371+
{
372+
PropertyNameCaseInsensitive = true
373+
})!;
374+
}
375+
}

0 commit comments

Comments
 (0)