Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 31 additions & 3 deletions Backend.Tests/Controllers/CommentControllerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public async Task GetComments_WhenUserIsNotTaskOwner_ReturnsForbid()
var currentUserId = Guid.NewGuid();
var task = CreateTask(assignedUserId: Guid.NewGuid());
var commentService = new FakeCommentService();
var controller = CreateController(task, commentService, currentUserId, "User");
var controller = CreateController(task, commentService, currentUserId, "User", grantAccess: false);

var result = await controller.GetComments(task.Id);

Expand Down Expand Up @@ -92,9 +92,9 @@ public async Task UpdateComment_PassesTaskAndUserIdsToService()
Assert.Equal(currentUserId, commentService.LastUpdateUserId);
}

private static CommentController CreateController(TaskItem task, FakeCommentService commentService, Guid currentUserId, string role)
private static CommentController CreateController(TaskItem task, FakeCommentService commentService, Guid currentUserId, string role, bool grantAccess = true)
{
var controller = new CommentController(commentService, new FakeTaskService(task));
var controller = new CommentController(commentService, new FakeTaskService(task), new FakeProjectService(grantAccess));

controller.ControllerContext = new ControllerContext
{
Expand Down Expand Up @@ -204,4 +204,32 @@ public Task DeleteCommentAsync(Guid taskId, Guid commentId, Guid userId)
return Task.CompletedTask;
}
}

private sealed class FakeProjectService : IProjectService
{
private readonly bool _grantAccess;

public FakeProjectService(bool grantAccess = true)
{
_grantAccess = grantAccess;
}

public Task<List<Project>> GetAll() => throw new NotImplementedException();
public Task<List<Project>> GetAccessibleProjects(Guid userId, bool elevatedAccess) => throw new NotImplementedException();
public Task<Project> Create(ProjectDto dto, Guid creatorUserId) => throw new NotImplementedException();
public Task<Project?> GetById(Guid id) => throw new NotImplementedException();
public Task<Project?> Update(Guid id, ProjectDto dto) => throw new NotImplementedException();
public Task<bool> Delete(Guid id) => throw new NotImplementedException();
public Task<bool> ProjectExists(Guid id) => Task.FromResult(true);
public Task<bool> HasReadAccess(Guid projectId, Guid userId, bool elevatedAccess) => Task.FromResult(_grantAccess);
public Task<bool> HasWriteAccess(Guid projectId, Guid userId, bool elevatedAccess) => Task.FromResult(_grantAccess);
public Task<bool> HasManageAccess(Guid projectId, Guid userId, bool elevatedAccess) => throw new NotImplementedException();
public Task<List<ProjectMemberDto>> GetMembers(Guid projectId) => throw new NotImplementedException();
public Task<ProjectMemberDto> AddMember(Guid projectId, AddProjectMemberDto dto, Guid actorUserId) => throw new NotImplementedException();
public Task<List<ProjectInvitationDto>> GetInvitations(Guid projectId) => throw new NotImplementedException();
public Task<ProjectInvitationDto> CreateInvitation(Guid projectId, CreateProjectInvitationDto dto, Guid actorUserId) => throw new NotImplementedException();
public Task<List<ProjectInvitationLookupDto>> GetInvitationsForUser(Guid userId) => throw new NotImplementedException();
public Task<ProjectInvitationLookupDto> GetInvitationById(Guid invitationId) => throw new NotImplementedException();
public Task<AcceptProjectInvitationResultDto> AcceptInvitation(Guid invitationId, Guid actorUserId) => throw new NotImplementedException();
}
}
152 changes: 151 additions & 1 deletion Backend.Tests/Services/AuthServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,155 @@ await service.Register(new RegisterDto
Assert.Equal("Email already registered", duplicateResult.Message);
}

[Fact]
public async Task Register_ReturnsRefreshToken()
{
await using var context = CreateContext();
var service = CreateService(context);

var result = await service.Register(new RegisterDto
{
Name = "Test User",
Email = "test@example.com",
Password = "Password123!"
});

Assert.True(result.Success);
Assert.False(string.IsNullOrWhiteSpace(result.Token));
Assert.False(string.IsNullOrWhiteSpace(result.RefreshToken));
Assert.NotNull(result.RefreshTokenExpiry);
Assert.True(result.RefreshTokenExpiry > DateTime.UtcNow);
}

[Fact]
public async Task Login_ReturnsRefreshToken()
{
await using var context = CreateContext();
var service = CreateService(context);

await service.Register(new RegisterDto
{
Name = "Test User",
Email = "test@example.com",
Password = "Password123!"
});

var result = await service.Login(new LoginDto
{
Email = "test@example.com",
Password = "Password123!"
});

Assert.True(result.Success);
Assert.False(string.IsNullOrWhiteSpace(result.Token));
Assert.False(string.IsNullOrWhiteSpace(result.RefreshToken));
Assert.NotNull(result.RefreshTokenExpiry);
}

[Fact]
public async Task RefreshToken_ReturnsNewAccessAndRefreshToken()
{
await using var context = CreateContext();
var service = CreateService(context);

var loginResult = await service.Login(await RegisterAndLogin(service));

var refreshResult = await service.RefreshTokenAsync(loginResult.RefreshToken!);

Assert.True(refreshResult.Success);
Assert.False(string.IsNullOrWhiteSpace(refreshResult.Token));
Assert.False(string.IsNullOrWhiteSpace(refreshResult.RefreshToken));
Assert.NotEqual(loginResult.RefreshToken, refreshResult.RefreshToken);
Assert.NotNull(refreshResult.User);
}

[Fact]
public async Task RefreshToken_RevokesOldToken()
{
await using var context = CreateContext();
var service = CreateService(context);

var loginResult = await service.Login(await RegisterAndLogin(service));
var oldRefreshToken = loginResult.RefreshToken!;

// First refresh should succeed
var refreshResult = await service.RefreshTokenAsync(oldRefreshToken);
Assert.True(refreshResult.Success);

// Reusing the old token should fail (token reuse detection)
var reuseResult = await service.RefreshTokenAsync(oldRefreshToken);
Assert.False(reuseResult.Success);
}

[Fact]
public async Task RefreshToken_RejectsInvalidToken()
{
await using var context = CreateContext();
var service = CreateService(context);

var result = await service.RefreshTokenAsync("totally-invalid-token");

Assert.False(result.Success);
Assert.Equal("Invalid refresh token", result.Message);
}

[Fact]
public async Task Logout_RevokesRefreshToken()
{
await using var context = CreateContext();
var service = CreateService(context);

var loginResult = await service.Login(await RegisterAndLogin(service));

await service.LogoutAsync(loginResult.RefreshToken!);

// Trying to use the revoked token should fail
var refreshResult = await service.RefreshTokenAsync(loginResult.RefreshToken!);
Assert.False(refreshResult.Success);
}

[Fact]
public async Task RevokeAll_RevokesAllUserTokens()
{
await using var context = CreateContext();
var service = CreateService(context);

var dto = await RegisterAndLogin(service);

// Login multiple times to create multiple refresh tokens
var login1 = await service.Login(dto);
var login2 = await service.Login(dto);

// Get the user ID
var userId = login1.User!.Id;

// Revoke all
await service.RevokeAllTokensAsync(userId);

// Both tokens should be revoked
var refresh1 = await service.RefreshTokenAsync(login1.RefreshToken!);
var refresh2 = await service.RefreshTokenAsync(login2.RefreshToken!);

Assert.False(refresh1.Success);
Assert.False(refresh2.Success);
}

private static async Task<LoginDto> RegisterAndLogin(AuthService service)
{
await service.Register(new RegisterDto
{
Name = "Test User",
Email = "test@example.com",
Password = "Password123!"
});

return new LoginDto
{
Email = "test@example.com",
Password = "Password123!"
};
}

private static AppDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
Expand All @@ -92,7 +241,8 @@ private static AuthService CreateService(AppDbContext context)
Secret = "test-secret-key-that-is-long-enough-for-hmac",
Issuer = "TestIssuer",
Audience = "TestAudience",
ExpirationHours = 24
AccessTokenExpirationMinutes = 15,
RefreshTokenExpirationDays = 7
});

return new AuthService(context, jwtOptions, NullLogger<AuthService>.Instance);
Expand Down
37 changes: 35 additions & 2 deletions Backend/Controllers/AuthController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ namespace Backend.Controllers;
using Microsoft.AspNetCore.Mvc;
using Backend.Models.DTOs;
using Backend.Services.Interfaces;
using System.Security.Claims;

[ApiController]
[Asp.Versioning.ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/auth")]
[AllowAnonymous]
public class AuthController : ControllerBase
public class AuthController : BaseApiController
{
private readonly IAuthService _authService;

Expand All @@ -19,6 +19,7 @@ public AuthController(IAuthService authService)
}

[HttpPost("register")]
[AllowAnonymous]
public async Task<IActionResult> Register([FromBody] RegisterDto dto)
{
var result = await _authService.Register(dto);
Expand All @@ -32,6 +33,7 @@ public async Task<IActionResult> Register([FromBody] RegisterDto dto)
}

[HttpPost("login")]
[AllowAnonymous]
public async Task<IActionResult> Login([FromBody] LoginDto dto)
{
var result = await _authService.Login(dto);
Expand All @@ -43,4 +45,35 @@ public async Task<IActionResult> Login([FromBody] LoginDto dto)

return Ok(ApiResponseDto<AuthResponseDto>.Ok(result, result.Message));
}

[HttpPost("refresh")]
[AllowAnonymous]
public async Task<IActionResult> RefreshToken([FromBody] RefreshTokenDto dto)
{
var result = await _authService.RefreshTokenAsync(dto.RefreshToken);

if (!result.Success)
{
return Unauthorized(ApiResponseDto<object>.Fail(result.Message));
}

return Ok(ApiResponseDto<AuthResponseDto>.Ok(result, result.Message));
}

[HttpPost("logout")]
[Authorize]
public async Task<IActionResult> Logout([FromBody] RefreshTokenDto dto)
{
await _authService.LogoutAsync(dto.RefreshToken);
return Ok(ApiResponseDto<object>.Ok(null, "Logged out successfully"));
}

[HttpPost("revoke-all")]
[Authorize]
public async Task<IActionResult> RevokeAll()
{
var userId = GetCurrentUserId();
await _authService.RevokeAllTokensAsync(userId);
return Ok(ApiResponseDto<object>.Ok(null, "All sessions have been revoked"));
}
}
36 changes: 36 additions & 0 deletions Backend/Controllers/BaseApiController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
namespace Backend.Controllers;

using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;

/// <summary>
/// Base controller providing common helper methods shared across all API controllers.
/// Eliminates duplication of GetCurrentUserId() and HasElevatedAccess() across the codebase.
/// </summary>
public abstract class BaseApiController : ControllerBase
{
/// <summary>
/// Extracts the current authenticated user's ID from the JWT claims.
/// </summary>
protected Guid GetCurrentUserId()
{
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);

if (!Guid.TryParse(userIdClaim, out var userId))
throw new UnauthorizedAccessException("Invalid user context");

return userId;
}

/// <summary>
/// Checks whether the current user has elevated access (Admin or Manager roles).
/// Set <paramref name="includeManager"/> to false for Admin-only checks.
/// </summary>
protected bool HasElevatedAccess(bool includeManager = true)
{
if (User.IsInRole("Admin"))
return true;

return includeManager && User.IsInRole("Manager");
}
}
Loading
Loading