Skip to content
Draft
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
98 changes: 98 additions & 0 deletions api/CourseRegistration.API/Controllers/CertificatesController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
using Microsoft.AspNetCore.Mvc;
using CourseRegistration.Application.DTOs;
using CourseRegistration.Application.Services;

namespace CourseRegistration.API.Controllers;

/// <summary>
/// Controller for certificate operations
/// </summary>
[ApiController]
[Route("api/[controller]")]
[Produces("application/json")]
public class CertificatesController : ControllerBase
{
private readonly ICertificateService _certificateService;
private readonly ILogger<CertificatesController> _logger;

/// <summary>
/// Initializes a new instance of the CertificatesController
/// </summary>
public CertificatesController(ICertificateService certificateService, ILogger<CertificatesController> logger)
{
_certificateService = certificateService ?? throw new ArgumentNullException(nameof(certificateService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}

/// <summary>
/// Gets a certificate by ID
/// </summary>
/// <param name="id">Certificate ID</param>
/// <returns>Certificate details</returns>
[HttpGet("{id:guid}")]
[ProducesResponseType(typeof(CertificateDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CertificateDto>> GetCertificate(Guid id)
{
_logger.LogInformation("Getting certificate with ID: {CertificateId}", id);

var certificate = await _certificateService.GetCertificateByIdAsync(id);
if (certificate == null)
{
return NotFound(new { message = "Certificate not found" });
}

return Ok(certificate);
}

/// <summary>
/// Searches certificates by student name
/// </summary>
/// <param name="studentName">Student name to search for</param>
/// <returns>List of matching certificates</returns>
[HttpGet("search")]
[ProducesResponseType(typeof(IEnumerable<CertificateDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<IEnumerable<CertificateDto>>> SearchCertificates(
[FromQuery] string studentName = "")
{
_logger.LogInformation("Searching certificates for student name: {StudentName}", studentName);

Check failure

Code scanning / CodeQL

Log entries created from user input High

This log entry depends on a
user-provided value
.

Copilot Autofix

AI 11 days ago

In general, to fix this kind of issue, all user-provided data that is written into log messages should be sanitized to remove or encode characters that could alter the log structure (notably \r and \n). For plain-text logs, removing newline characters is usually sufficient; for HTML logs, HTML-encoding should be used. Since this controller likely writes to plain-text/structured logs, the minimal non-breaking fix is to create a sanitized version of studentName with line breaks removed and use that sanitized variable in the log call, leaving the underlying business logic (GetCertificatesByStudentNameAsync) unchanged.

Concretely, in api/CourseRegistration.API/Controllers/CertificatesController.cs, within the SearchCertificates action, we’ll introduce a local variable (e.g., sanitizedStudentName) that replaces any \r and \n characters with empty strings (or otherwise strips them) before passing it to _logger.LogInformation. The rest of the method will continue to use the original studentName for validation and searching to avoid altering functional behavior. No new using directives or external dependencies are needed; we can use string.Replace from the BCL. The change is localized around line 59: add the sanitization line immediately before the log call and change the log call to use the sanitized variable.

Suggested changeset 1
api/CourseRegistration.API/Controllers/CertificatesController.cs

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/api/CourseRegistration.API/Controllers/CertificatesController.cs b/api/CourseRegistration.API/Controllers/CertificatesController.cs
--- a/api/CourseRegistration.API/Controllers/CertificatesController.cs
+++ b/api/CourseRegistration.API/Controllers/CertificatesController.cs
@@ -56,8 +56,13 @@
     public async Task<ActionResult<IEnumerable<CertificateDto>>> SearchCertificates(
         [FromQuery] string studentName = "")
     {
-        _logger.LogInformation("Searching certificates for student name: {StudentName}", studentName);
+        var sanitizedStudentName = studentName?
+            .Replace(Environment.NewLine, string.Empty)
+            .Replace("\n", string.Empty)
+            .Replace("\r", string.Empty);
 
+        _logger.LogInformation("Searching certificates for student name: {StudentName}", sanitizedStudentName);
+
         if (string.IsNullOrWhiteSpace(studentName))
         {
             return BadRequest(new { message = "Student name is required" });
EOF
@@ -56,8 +56,13 @@
public async Task<ActionResult<IEnumerable<CertificateDto>>> SearchCertificates(
[FromQuery] string studentName = "")
{
_logger.LogInformation("Searching certificates for student name: {StudentName}", studentName);
var sanitizedStudentName = studentName?
.Replace(Environment.NewLine, string.Empty)
.Replace("\n", string.Empty)
.Replace("\r", string.Empty);

_logger.LogInformation("Searching certificates for student name: {StudentName}", sanitizedStudentName);

if (string.IsNullOrWhiteSpace(studentName))
{
return BadRequest(new { message = "Student name is required" });
Copilot is powered by AI and may make mistakes. Always verify output.

if (string.IsNullOrWhiteSpace(studentName))
{
return BadRequest(new { message = "Student name is required" });
}

var certificates = await _certificateService.GetCertificatesByStudentNameAsync(studentName);
return Ok(certificates);
}

/// <summary>
/// Downloads a certificate as a PDF file
/// </summary>
/// <param name="certificateId">Certificate ID</param>
/// <returns>PDF file</returns>
[HttpGet("{certificateId:guid}/download")]
[Produces("application/pdf")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> DownloadCertificate(Guid certificateId)
{
_logger.LogInformation("Download requested for certificate ID: {CertificateId}", certificateId);

if (certificateId == Guid.Empty)
{
return BadRequest(new { message = "Invalid certificate ID" });
}

var pdfBytes = await _certificateService.DownloadCertificatePdfAsync(certificateId);
if (pdfBytes == null)
{
return NotFound(new { message = "Certificate not found" });
}

var fileName = $"certificate-{certificateId}.pdf";
return File(pdfBytes, "application/pdf", fileName);
}
}
1 change: 1 addition & 0 deletions api/CourseRegistration.API/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
builder.Services.AddScoped<IStudentService, StudentService>();
builder.Services.AddScoped<ICourseService, CourseService>();
builder.Services.AddScoped<IRegistrationService, RegistrationService>();
builder.Services.AddScoped<ICertificateService, CertificateService>();

// Register authorization services
builder.Services.AddScoped<AuthorizationService>();
Expand Down
116 changes: 116 additions & 0 deletions api/CourseRegistration.Application/Services/CertificateService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,122 @@ public string GenerateCertificateNumber()
return $"CERT-{year}-{sequence:D3}";
}

public async Task<byte[]?> DownloadCertificatePdfAsync(Guid certificateId)
{
await Task.CompletedTask; // Simulate async operation

var certificate = _certificates.FirstOrDefault(c => c.CertificateId == certificateId);
if (certificate == null)
{
return null;
}

var dto = MapToDto(certificate);
return BuildMinimalPdf(dto);
}

private static byte[] BuildMinimalPdf(CertificateDto cert)
{
// Build the page content stream using standard PDF Type1 font (Helvetica)
var content = new System.Text.StringBuilder();
content.AppendLine("BT");
content.AppendLine("/F1 28 Tf");
content.AppendLine("160 720 Td");
content.AppendLine($"(Certificate of Completion) Tj");
content.AppendLine("/F1 18 Tf");
content.AppendLine("0 -60 Td");
content.AppendLine($"(This certifies that) Tj");
content.AppendLine("/F1 22 Tf");
content.AppendLine("0 -40 Td");
var displayName = cert.StudentName.Length > 60 ? cert.StudentName[..60] + "..." : cert.StudentName;
content.AppendLine($"({EscapePdfString(displayName)}) Tj");
content.AppendLine("/F1 16 Tf");
content.AppendLine("0 -40 Td");
content.AppendLine("(has successfully completed the course:) Tj");
content.AppendLine("/F1 20 Tf");
content.AppendLine("0 -36 Td");
content.AppendLine($"({EscapePdfString(cert.CourseName)}) Tj");
content.AppendLine("/F1 14 Tf");
content.AppendLine("0 -50 Td");
content.AppendLine($"(Instructor: {EscapePdfString(cert.InstructorName)}) Tj");
content.AppendLine("0 -24 Td");
content.AppendLine($"(Issue Date: {cert.IssueDate.ToUniversalTime():yyyy-MM-ddTHH:mm:ssZ}) Tj");
content.AppendLine("0 -24 Td");
content.AppendLine($"(Certificate No: {EscapePdfString(cert.CertificateNumber)}) Tj");
content.AppendLine("0 -24 Td");
content.AppendLine($"(Grade: {cert.FinalGrade}) Tj");
content.AppendLine("ET");

var streamBytes = System.Text.Encoding.Latin1.GetBytes(content.ToString());
var streamLength = streamBytes.Length;

// Assemble PDF objects
var objects = new List<string>();

// Object 1: Catalog
objects.Add("1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj");
// Object 2: Pages
objects.Add("2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj");
// Object 3: Page
objects.Add("3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792]\n /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>\nendobj");
// Object 4: Content stream
objects.Add($"4 0 obj\n<< /Length {streamLength} >>\nstream\n{content}endstream\nendobj");
// Object 5: Font
objects.Add("5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj");

// Build xref table
var pdfBuilder = new System.Text.StringBuilder();
pdfBuilder.Append("%PDF-1.4\n");

var offsets = new List<int>();
foreach (var obj in objects)
{
offsets.Add(pdfBuilder.Length);
pdfBuilder.Append(obj);
pdfBuilder.Append("\n");
}

var xrefOffset = pdfBuilder.Length;
pdfBuilder.Append("xref\n");
pdfBuilder.Append($"0 {objects.Count + 1}\n");
pdfBuilder.Append("0000000000 65535 f \n");
foreach (var offset in offsets)
{
pdfBuilder.Append($"{offset:D10} 00000 n \n");
}
pdfBuilder.Append("trailer\n");
pdfBuilder.Append($"<< /Size {objects.Count + 1} /Root 1 0 R >>\n");
pdfBuilder.Append("startxref\n");
pdfBuilder.Append($"{xrefOffset}\n");
pdfBuilder.Append("%%EOF\n");

return System.Text.Encoding.Latin1.GetBytes(pdfBuilder.ToString());
}

private static string EscapePdfString(string input)
{
// Filter to Latin1 (ISO-8859-1) range, then escape special PDF string characters
var latin1Safe = new System.Text.StringBuilder(input.Length);
foreach (var ch in input)
{
if (ch <= 0xFF)
{
latin1Safe.Append(ch);
}
else
{
latin1Safe.Append('?');
}
}

return latin1Safe.ToString()
.Replace("\\", "\\\\")
.Replace("(", "\\(")
.Replace(")", "\\)")
.Replace("\r", "")
.Replace("\n", " ");
}

private CertificateDto MapToDto(Certificate certificate)
{
var student = _students.FirstOrDefault(s => s.StudentId == certificate.StudentId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,11 @@ public interface ICertificateService
/// </summary>
/// <returns>Unique certificate number</returns>
string GenerateCertificateNumber();

/// <summary>
/// Generate a PDF byte array for the given certificate
/// </summary>
/// <param name="certificateId">Certificate ID</param>
/// <returns>PDF bytes, or null if the certificate is not found</returns>
Task<byte[]?> DownloadCertificatePdfAsync(Guid certificateId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
using Xunit;
using CourseRegistration.Application.Services;
using CourseRegistration.Application.DTOs;
using CourseRegistration.Domain.Enums;

namespace CourseRegistration.Tests.Services;

/// <summary>
/// Unit tests for CertificateService PDF download functionality
/// </summary>
public class CertificateServiceDownloadTests
{
private readonly CertificateService _service;

public CertificateServiceDownloadTests()
{
_service = new CertificateService();
}

[Fact]
public async Task DownloadCertificatePdfAsync_WithValidId_ReturnsPdfBytes()
{
// Arrange
var newCert = await _service.CreateCertificateAsync(new CreateCertificateDto
{
StudentId = Guid.Parse("11111111-1111-1111-1111-111111111111"),
CourseId = Guid.Parse("33333333-3333-3333-3333-333333333333"),
FinalGrade = Grade.A,
Remarks = "Test certificate"
});

// Act
var pdfBytes = await _service.DownloadCertificatePdfAsync(newCert.CertificateId);

// Assert
Assert.NotNull(pdfBytes);
Assert.True(pdfBytes.Length > 0);
}

[Fact]
public async Task DownloadCertificatePdfAsync_WithValidId_ReturnsPdfHeader()
{
// Arrange
var newCert = await _service.CreateCertificateAsync(new CreateCertificateDto
{
StudentId = Guid.Parse("11111111-1111-1111-1111-111111111111"),
CourseId = Guid.Parse("33333333-3333-3333-3333-333333333333"),
FinalGrade = Grade.B,
Remarks = "PDF header test"
});

// Act
var pdfBytes = await _service.DownloadCertificatePdfAsync(newCert.CertificateId);

// Assert
Assert.NotNull(pdfBytes);
var header = System.Text.Encoding.Latin1.GetString(pdfBytes, 0, Math.Min(8, pdfBytes.Length));
Assert.StartsWith("%PDF-", header);
}

[Fact]
public async Task DownloadCertificatePdfAsync_WithInvalidId_ReturnsNull()
{
// Arrange
var nonExistentId = Guid.NewGuid();

// Act
var pdfBytes = await _service.DownloadCertificatePdfAsync(nonExistentId);

// Assert
Assert.Null(pdfBytes);
}

[Fact]
public async Task DownloadCertificatePdfAsync_WithEmptyGuid_ReturnsNull()
{
// Act
var pdfBytes = await _service.DownloadCertificatePdfAsync(Guid.Empty);

// Assert
Assert.Null(pdfBytes);
}

[Fact]
public async Task DownloadCertificatePdfAsync_PdfContainsCertificateDetails()
{
// Arrange
// StudentId 11111111... maps to "John Doe" and CourseId 33333333... maps to
// "Introduction to Programming" per CertificateService in-memory seed data.
var newCert = await _service.CreateCertificateAsync(new CreateCertificateDto
{
StudentId = Guid.Parse("11111111-1111-1111-1111-111111111111"),
CourseId = Guid.Parse("33333333-3333-3333-3333-333333333333"),
FinalGrade = Grade.A,
Remarks = "Content verification test"
});

// Act
var pdfBytes = await _service.DownloadCertificatePdfAsync(newCert.CertificateId);

// Assert
Assert.NotNull(pdfBytes);
var pdfContent = System.Text.Encoding.Latin1.GetString(pdfBytes);
Assert.Contains("Certificate of Completion", pdfContent);
Assert.Contains("John Doe", pdfContent);
Assert.Contains("Introduction to Programming", pdfContent);
}

[Fact]
public async Task DownloadCertificatePdfAsync_PdfEndsWithEof()
{
// Arrange
var newCert = await _service.CreateCertificateAsync(new CreateCertificateDto
{
StudentId = Guid.Parse("22222222-2222-2222-2222-222222222222"),
CourseId = Guid.Parse("44444444-4444-4444-4444-444444444444"),
FinalGrade = Grade.B,
Remarks = "EOF test"
});

// Act
var pdfBytes = await _service.DownloadCertificatePdfAsync(newCert.CertificateId);

// Assert
Assert.NotNull(pdfBytes);
var pdfContent = System.Text.Encoding.Latin1.GetString(pdfBytes);
Assert.Contains("%%EOF", pdfContent);
}
}
Loading
Loading