diff --git a/api/CourseRegistration.API/Controllers/DownloadsController.cs b/api/CourseRegistration.API/Controllers/DownloadsController.cs new file mode 100644 index 0000000..3993154 --- /dev/null +++ b/api/CourseRegistration.API/Controllers/DownloadsController.cs @@ -0,0 +1,135 @@ +using Microsoft.AspNetCore.Mvc; +using CourseRegistration.Application.Interfaces; +using System.Text; + +namespace CourseRegistration.API.Controllers; + +/// +/// Controller for downloading course and registration data as CSV files +/// +[ApiController] +[Route("api/[controller]")] +public class DownloadsController : ControllerBase +{ + private readonly ICourseService _courseService; + private readonly IRegistrationService _registrationService; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the DownloadsController + /// + public DownloadsController( + ICourseService courseService, + IRegistrationService registrationService, + ILogger logger) + { + _courseService = courseService ?? throw new ArgumentNullException(nameof(courseService)); + _registrationService = registrationService ?? throw new ArgumentNullException(nameof(registrationService)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Downloads all courses as a CSV file + /// + /// CSV file containing all courses + [HttpGet("courses")] + [ProducesResponseType(typeof(FileResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task DownloadCourses() + { + _logger.LogInformation("Downloading all courses as CSV"); + + var courses = await _courseService.SearchCoursesAsync(null, null); + + var csv = new StringBuilder(); + csv.AppendLine("CourseId,CourseName,InstructorName,Description,StartDate,EndDate,Schedule,CurrentEnrollment,IsActive"); + + foreach (var course in courses) + { + csv.AppendLine(string.Join(",", + EscapeCsvField(course.CourseId.ToString()), + EscapeCsvField(course.CourseName), + EscapeCsvField(course.InstructorName), + EscapeCsvField(course.Description ?? string.Empty), + EscapeCsvField(course.StartDate.ToString("yyyy-MM-dd")), + EscapeCsvField(course.EndDate.ToString("yyyy-MM-dd")), + EscapeCsvField(course.Schedule), + EscapeCsvField(course.CurrentEnrollment.ToString()), + EscapeCsvField(course.IsActive.ToString()))); + } + + var fileName = $"courses_{DateTime.UtcNow:yyyyMMdd}.csv"; + var bytes = Encoding.UTF8.GetBytes(csv.ToString()); + return File(bytes, "text/csv", fileName); + } + + /// + /// Downloads all registrations as a CSV file + /// + /// CSV file containing all registrations + [HttpGet("registrations")] + [ProducesResponseType(typeof(FileResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task DownloadRegistrations() + { + _logger.LogInformation("Downloading all registrations as CSV"); + + var registrations = await _registrationService.GetAllRegistrationsAsync(); + + var csv = new StringBuilder(); + csv.AppendLine("RegistrationId,StudentId,StudentName,StudentEmail,CourseId,CourseName,InstructorName,RegistrationDate,Status,Grade,Notes"); + + foreach (var reg in registrations) + { + csv.AppendLine(string.Join(",", + EscapeCsvField(reg.RegistrationId.ToString()), + EscapeCsvField(reg.StudentId.ToString()), + EscapeCsvField(reg.Student?.FullName ?? string.Empty), + EscapeCsvField(reg.Student?.Email ?? string.Empty), + EscapeCsvField(reg.CourseId.ToString()), + EscapeCsvField(reg.Course?.CourseName ?? string.Empty), + EscapeCsvField(reg.Course?.InstructorName ?? string.Empty), + EscapeCsvField(reg.RegistrationDate.ToString("yyyy-MM-dd")), + EscapeCsvField(reg.Status.ToString()), + EscapeCsvField(reg.Grade?.ToString() ?? string.Empty), + EscapeCsvField(reg.Notes ?? string.Empty))); + } + + var fileName = $"registrations_{DateTime.UtcNow:yyyyMMdd}.csv"; + var bytes = Encoding.UTF8.GetBytes(csv.ToString()); + return File(bytes, "text/csv", fileName); + } + + /// + /// Escapes a CSV field value to prevent injection and handle special characters. + /// Fields containing commas, double quotes, or newlines are wrapped in double quotes. + /// Double quotes within the value are escaped by doubling them. + /// Leading = + - @ characters are prefixed with a tab to prevent formula injection. + /// The tab prefix is applied before quoting so it is preserved correctly in all cases. + /// + private static string EscapeCsvField(string value) + { + if (string.IsNullOrEmpty(value)) + { + return string.Empty; + } + + // Prevent CSV formula injection by neutralizing dangerous leading characters. + // This covers the primary injection vectors: = (formula), + (formula), - (formula/unary), + // @ (DDE/macro trigger). Tab-prefix is the recommended approach as it is invisible to end + // users while preventing spreadsheet apps from interpreting the cell as a formula. + if (value[0] is '=' or '+' or '-' or '@') + { + value = "\t" + value; + } + + // Quote the field if it contains characters that need escaping. + // This is applied after the optional tab-prefix, so the tab is preserved inside quotes. + if (value.Contains(',') || value.Contains('"') || value.Contains('\n') || value.Contains('\r')) + { + return "\"" + value.Replace("\"", "\"\"") + "\""; + } + + return value; + } +} diff --git a/api/CourseRegistration.Application/Interfaces/IRegistrationService.cs b/api/CourseRegistration.Application/Interfaces/IRegistrationService.cs index a563f4c..80f2c44 100644 --- a/api/CourseRegistration.Application/Interfaces/IRegistrationService.cs +++ b/api/CourseRegistration.Application/Interfaces/IRegistrationService.cs @@ -57,4 +57,9 @@ Task> GetRegistrationsAsync( /// Checks if a student is already registered for a course /// Task IsStudentRegisteredForCourseAsync(Guid studentId, Guid courseId); + + /// + /// Gets all registrations without pagination + /// + Task> GetAllRegistrationsAsync(); } \ No newline at end of file diff --git a/api/CourseRegistration.Application/Services/RegistrationService.cs b/api/CourseRegistration.Application/Services/RegistrationService.cs index eaf3e6b..68b4a19 100644 --- a/api/CourseRegistration.Application/Services/RegistrationService.cs +++ b/api/CourseRegistration.Application/Services/RegistrationService.cs @@ -212,6 +212,15 @@ public async Task IsStudentRegisteredForCourseAsync(Guid studentId, Guid c return await _unitOfWork.Registrations.IsStudentRegisteredForCourseAsync(studentId, courseId); } + /// + /// Gets all registrations without pagination + /// + public async Task> GetAllRegistrationsAsync() + { + var registrations = await _unitOfWork.Registrations.GetRegistrationsWithFiltersAsync(null, null, null); + return _mapper.Map>(registrations); + } + /// /// Validates if a status transition is allowed /// diff --git a/api/CourseRegistration.Tests/Controllers/DownloadsControllerTests.cs b/api/CourseRegistration.Tests/Controllers/DownloadsControllerTests.cs new file mode 100644 index 0000000..6fd725f --- /dev/null +++ b/api/CourseRegistration.Tests/Controllers/DownloadsControllerTests.cs @@ -0,0 +1,311 @@ +using Xunit; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging.Abstractions; +using CourseRegistration.API.Controllers; +using CourseRegistration.Application.DTOs; +using CourseRegistration.Application.Interfaces; +using CourseRegistration.Domain.Enums; + +namespace CourseRegistration.Tests.Controllers; + +/// +/// Unit tests for the DownloadsController +/// +public class DownloadsControllerTests +{ + // ------------------------------------------------------------------------- + // Hand-written test fakes + // ------------------------------------------------------------------------- + + private sealed class FakeCourseService : ICourseService + { + private readonly IEnumerable _courses; + + public FakeCourseService(IEnumerable courses) + { + _courses = courses; + } + + public Task> SearchCoursesAsync(string? searchTerm, string? instructor) + => Task.FromResult(_courses); + + // Unused members + public Task> GetCoursesAsync(int page, int pageSize, string? searchTerm, string? instructor) => throw new NotImplementedException(); + public Task GetCourseByIdAsync(Guid id) => throw new NotImplementedException(); + public Task CreateCourseAsync(CreateCourseDto dto) => throw new NotImplementedException(); + public Task UpdateCourseAsync(Guid id, UpdateCourseDto dto) => throw new NotImplementedException(); + public Task DeleteCourseAsync(Guid id) => throw new NotImplementedException(); + public Task> GetAvailableCoursesAsync() => throw new NotImplementedException(); + public Task> GetCoursesByInstructorAsync(string instructorName) => throw new NotImplementedException(); + public Task> GetCourseRegistrationsAsync(Guid courseId) => throw new NotImplementedException(); + } + + private sealed class FakeRegistrationService : IRegistrationService + { + private readonly IEnumerable _registrations; + + public FakeRegistrationService(IEnumerable registrations) + { + _registrations = registrations; + } + + public Task> GetAllRegistrationsAsync() + => Task.FromResult(_registrations); + + // Unused members + public Task> GetRegistrationsAsync(int page, int pageSize, Guid? studentId, Guid? courseId, RegistrationStatus? status) => throw new NotImplementedException(); + public Task GetRegistrationByIdAsync(Guid id) => throw new NotImplementedException(); + public Task CreateRegistrationAsync(CreateRegistrationDto dto) => throw new NotImplementedException(); + public Task UpdateRegistrationStatusAsync(Guid id, UpdateRegistrationStatusDto dto) => throw new NotImplementedException(); + public Task CancelRegistrationAsync(Guid id) => throw new NotImplementedException(); + public Task> GetRegistrationsByStudentAsync(Guid studentId) => throw new NotImplementedException(); + public Task> GetRegistrationsByCourseAsync(Guid courseId) => throw new NotImplementedException(); + public Task> GetRegistrationsByStatusAsync(RegistrationStatus status) => throw new NotImplementedException(); + public Task IsStudentRegisteredForCourseAsync(Guid studentId, Guid courseId) => throw new NotImplementedException(); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private static DownloadsController CreateController( + IEnumerable? courses = null, + IEnumerable? registrations = null) + { + var courseService = new FakeCourseService(courses ?? Enumerable.Empty()); + var registrationService = new FakeRegistrationService(registrations ?? Enumerable.Empty()); + return new DownloadsController(courseService, registrationService, NullLogger.Instance); + } + + private static string ReadFileResult(IActionResult result) + { + var fileResult = Assert.IsType(result); + Assert.Equal("text/csv", fileResult.ContentType); + return System.Text.Encoding.UTF8.GetString(fileResult.FileContents); + } + + // ------------------------------------------------------------------------- + // DownloadCourses tests + // ------------------------------------------------------------------------- + + [Fact] + public async Task DownloadCourses_EmptyList_ReturnsCsvWithHeaderOnly() + { + // Arrange + var controller = CreateController(courses: Enumerable.Empty()); + + // Act + var result = await controller.DownloadCourses(); + + // Assert + var csv = ReadFileResult(result); + var lines = csv.Split('\n', StringSplitOptions.RemoveEmptyEntries); + Assert.Single(lines); // only header + Assert.Contains("CourseName", lines[0]); + Assert.Contains("InstructorName", lines[0]); + } + + [Fact] + public async Task DownloadCourses_WithCourses_ReturnsCsvWithHeaderAndDataRows() + { + // Arrange + var courseId = Guid.NewGuid(); + var courses = new[] + { + new CourseDto + { + CourseId = courseId, + CourseName = "Introduction to C#", + InstructorName = "Dr. Smith", + Description = "A beginner course", + StartDate = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), + EndDate = new DateTime(2026, 6, 30, 0, 0, 0, DateTimeKind.Utc), + Schedule = "MWF 9:00-10:30 AM", + CurrentEnrollment = 5, + IsActive = true + } + }; + var controller = CreateController(courses: courses); + + // Act + var result = await controller.DownloadCourses(); + + // Assert + var csv = ReadFileResult(result); + var lines = csv.Split('\n', StringSplitOptions.RemoveEmptyEntries); + Assert.Equal(2, lines.Length); // header + 1 data row + Assert.Contains("Introduction to C#", lines[1]); + Assert.Contains("Dr. Smith", lines[1]); + Assert.Contains(courseId.ToString(), lines[1]); + } + + [Fact] + public async Task DownloadCourses_FieldWithComma_IsQuotedInCsv() + { + // Arrange + var courses = new[] + { + new CourseDto + { + CourseId = Guid.NewGuid(), + CourseName = "Science, Technology, Engineering", + InstructorName = "Dr. Jones", + StartDate = DateTime.UtcNow.AddDays(1), + EndDate = DateTime.UtcNow.AddMonths(6), + Schedule = "TTh 2:00-3:30 PM" + } + }; + var controller = CreateController(courses: courses); + + // Act + var result = await controller.DownloadCourses(); + + // Assert + var csv = ReadFileResult(result); + Assert.Contains("\"Science, Technology, Engineering\"", csv); + } + + [Fact] + public async Task DownloadCourses_ReturnsFileWithCsvContentType() + { + // Arrange + var controller = CreateController(); + + // Act + var result = await controller.DownloadCourses(); + + // Assert + var fileResult = Assert.IsType(result); + Assert.Equal("text/csv", fileResult.ContentType); + Assert.StartsWith("courses_", fileResult.FileDownloadName); + Assert.EndsWith(".csv", fileResult.FileDownloadName); + } + + // ------------------------------------------------------------------------- + // DownloadRegistrations tests + // ------------------------------------------------------------------------- + + [Fact] + public async Task DownloadRegistrations_EmptyList_ReturnsCsvWithHeaderOnly() + { + // Arrange + var controller = CreateController(registrations: Enumerable.Empty()); + + // Act + var result = await controller.DownloadRegistrations(); + + // Assert + var csv = ReadFileResult(result); + var lines = csv.Split('\n', StringSplitOptions.RemoveEmptyEntries); + Assert.Single(lines); // only header + Assert.Contains("RegistrationId", lines[0]); + Assert.Contains("StudentName", lines[0]); + Assert.Contains("CourseName", lines[0]); + } + + [Fact] + public async Task DownloadRegistrations_WithRegistrations_ReturnsCsvWithHeaderAndDataRows() + { + // Arrange + var registrationId = Guid.NewGuid(); + var studentId = Guid.NewGuid(); + var courseId = Guid.NewGuid(); + var registrations = new[] + { + new RegistrationDto + { + RegistrationId = registrationId, + StudentId = studentId, + CourseId = courseId, + Student = new StudentDto { FullName = "Jane Doe", Email = "jane@example.com" }, + Course = new CourseDto { CourseName = "Advanced Math", InstructorName = "Prof. Brown" }, + RegistrationDate = new DateTime(2026, 2, 1, 0, 0, 0, DateTimeKind.Utc), + Status = RegistrationStatus.Confirmed, + Notes = "Priority student" + } + }; + var controller = CreateController(registrations: registrations); + + // Act + var result = await controller.DownloadRegistrations(); + + // Assert + var csv = ReadFileResult(result); + var lines = csv.Split('\n', StringSplitOptions.RemoveEmptyEntries); + Assert.Equal(2, lines.Length); // header + 1 data row + Assert.Contains("Jane Doe", lines[1]); + Assert.Contains("Advanced Math", lines[1]); + Assert.Contains(registrationId.ToString(), lines[1]); + Assert.Contains("Confirmed", lines[1]); + } + + [Fact] + public async Task DownloadRegistrations_ReturnsFileWithCsvContentType() + { + // Arrange + var controller = CreateController(); + + // Act + var result = await controller.DownloadRegistrations(); + + // Assert + var fileResult = Assert.IsType(result); + Assert.Equal("text/csv", fileResult.ContentType); + Assert.StartsWith("registrations_", fileResult.FileDownloadName); + Assert.EndsWith(".csv", fileResult.FileDownloadName); + } + + [Fact] + public async Task DownloadRegistrations_FieldWithDoubleQuote_IsEscapedInCsv() + { + // Arrange + var registrations = new[] + { + new RegistrationDto + { + RegistrationId = Guid.NewGuid(), + StudentId = Guid.NewGuid(), + CourseId = Guid.NewGuid(), + RegistrationDate = DateTime.UtcNow, + Status = RegistrationStatus.Pending, + Notes = "She said \"hello\"" + } + }; + var controller = CreateController(registrations: registrations); + + // Act + var result = await controller.DownloadRegistrations(); + + // Assert + var csv = ReadFileResult(result); + // Double quotes are escaped by doubling: "She said ""hello""" + Assert.Contains("She said \"\"hello\"\"", csv); + } + + [Fact] + public async Task DownloadCourses_FormulaInjectionField_IsPrefixedWithTab() + { + // Arrange + var courses = new[] + { + new CourseDto + { + CourseId = Guid.NewGuid(), + CourseName = "=SUM(1+1)", + InstructorName = "Dr. Evil", + StartDate = DateTime.UtcNow.AddDays(1), + EndDate = DateTime.UtcNow.AddMonths(6), + Schedule = "MWF" + } + }; + var controller = CreateController(courses: courses); + + // Act + var result = await controller.DownloadCourses(); + + // Assert + var csv = ReadFileResult(result); + // The formula-prefixed field should be tab-prefixed to neutralize injection + Assert.Contains("\t=SUM(1+1)", csv); + } +} diff --git a/frontend/index.html b/frontend/index.html index 9773824..4a40b9e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -181,6 +181,10 @@

Refresh + +