Files
Joseph Doherty 26ff8d9b4f Initial commit: JDE Scoping Tool migration project
Set up repository with legacy .NET Framework 4.8 source (OLD/),
new .NET 10 Blazor solution (NEW/), OpenSpec specifications,
documentation, and project configuration.
2026-01-02 07:43:29 -05:00

35 KiB

Web API Design

Overview

This document describes the architecture and implementation approach for the Web API layer, including ASP.NET Core 10 controllers, SignalR hub, LDAP authentication, and cookie-based session management.

Architecture

High-Level Component Diagram

┌─────────────────────────────────────────────────────────────────────────────┐
│                         Blazor WebAssembly Client                           │
│  - HTTP API calls (fetch)                                                   │
│  - SignalR HubConnection                                                    │
└────────────────────────────────────┬────────────────────────────────────────┘
                                     │ HTTPS
         ┌───────────────────────────┼───────────────────────┐
         │                           │                       │
         ▼                           ▼                       ▼
┌─────────────────┐    ┌─────────────────┐    ┌─────────────────┐
│ AuthController  │    │ SearchController│    │ StatusHub       │
│ /api/auth/*     │    │ /api/search/*   │    │ /hubs/status    │
│ [AllowAnonymous]│    │ [Authorize]     │    │ Real-time       │
└────────┬────────┘    └────────┬────────┘    └────────┬────────┘
         │                      │                      │
         ▼                      ▼                      ▼
┌─────────────────┐    ┌─────────────────┐    ┌─────────────────┐
│ IAuthService    │    │ ILotFinder      │    │ IHubContext     │
│ (LDAP/Fake)     │    │ Repository      │    │ <StatusHub>     │
└─────────────────┘    └─────────────────┘    └─────────────────┘

Project Structure

NEW/src/JdeScoping.Api/
├── Controllers/
│   ├── ApiControllerBase.cs      # Base controller with CurrentUser
│   ├── AuthController.cs         # Authentication endpoints
│   ├── SearchController.cs       # Search management endpoints
│   ├── LookupController.cs       # Autocomplete lookup endpoints
│   └── FileController.cs         # File upload/download endpoints
├── Hubs/
│   └── StatusHub.cs              # SignalR hub for real-time updates
├── Services/
│   ├── IAuthService.cs           # Authentication service interface
│   ├── LdapAuthService.cs        # LDAP authentication implementation
│   └── FakeAuthService.cs        # Development mode auth bypass
├── Models/
│   ├── LoginRequest.cs           # Login request DTO
│   ├── AuthResult.cs             # Authentication result record
│   └── FileUploadResult.cs       # File upload response DTO
├── Security/
│   ├── UserIdentity.cs           # Claims-based user identity
│   └── ClaimsPrincipalExtensions.cs # Extension methods for claims
├── Configuration/
│   ├── LdapOptions.cs            # LDAP configuration options
│   └── AuthOptions.cs            # Authentication configuration
├── ServiceCollectionExtensions.cs # DI registration
└── JdeScoping.Api.csproj

Controller Architecture

ApiControllerBase

Base controller providing access to current user context:

[ApiController]
public abstract class ApiControllerBase : ControllerBase
{
    /// <summary>
    /// Gets the current authenticated user from claims.
    /// Returns null if not authenticated.
    /// </summary>
    protected UserInfo? CurrentUser => User.Identity?.IsAuthenticated == true
        ? User.ToUserInfo()
        : null;

    /// <summary>
    /// Gets the current username from claims.
    /// </summary>
    protected string? CurrentUserName => User.FindFirstValue(ClaimTypes.Name);
}

AuthController

Authentication endpoints for Blazor WASM client:

[Route("api/auth")]
[ApiController]
public class AuthController : ApiControllerBase
{
    private readonly IAuthService _authService;
    private readonly ILogger<AuthController> _logger;

    [HttpPost("login")]
    [AllowAnonymous]
    public async Task<ActionResult<UserInfo>> Login(
        [FromBody] LoginRequest request,
        CancellationToken ct)
    {
        var result = await _authService.AuthenticateAsync(
            request.Username, request.Password, ct);

        if (!result.Success)
        {
            return Unauthorized(new { message = result.ErrorMessage });
        }

        // Sign out existing session
        await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);

        // Create claims identity
        var claims = new List<Claim>
        {
            new(ClaimTypes.Name, result.User!.Username),
            new(ClaimTypes.GivenName, result.User.FirstName),
            new(ClaimTypes.Surname, result.User.LastName),
            new(ClaimTypes.Email, result.User.EmailAddress),
            new("title", result.User.Title),
            new("dn", result.User.DN)
        };

        var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
        var principal = new ClaimsPrincipal(identity);

        // Sign in with non-persistent cookie
        await HttpContext.SignInAsync(
            CookieAuthenticationDefaults.AuthenticationScheme,
            principal,
            new AuthenticationProperties { IsPersistent = false });

        return Ok(result.User);
    }

    [HttpPost("logout")]
    [Authorize]
    public async Task<IActionResult> Logout()
    {
        await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
        return Ok();
    }

    [HttpGet("me")]
    [Authorize]
    public ActionResult<UserInfo> GetCurrentUser()
    {
        return Ok(CurrentUser);
    }
}

SearchController

Search management with proper authorization:

[Route("api/search")]
[ApiController]
[Authorize]
public class SearchController : ApiControllerBase
{
    private readonly ILotFinderRepository _repository;
    private readonly IHubContext<StatusHub> _hubContext;
    private readonly ILogger<SearchController> _logger;

    [HttpGet]
    public async Task<ActionResult<IEnumerable<SearchViewModel>>> GetSearches(
        CancellationToken ct)
    {
        var searches = await _repository.GetUserSearchesAsync(CurrentUserName!, ct);
        return Ok(searches.OrderByDescending(s => s.StartDT));
    }

    [HttpGet("queue")]
    public async Task<ActionResult<IEnumerable<SearchViewModel>>> GetQueuedSearches(
        CancellationToken ct)
    {
        var searches = await _repository.GetQueuedSearchesAsync(ct);
        return Ok(searches);
    }

    [HttpGet("{id:int}")]
    public async Task<ActionResult<SearchViewModel>> GetSearch(int id, CancellationToken ct)
    {
        var search = await _repository.GetSearchAsync(id, ct);
        if (search is null) return NotFound();
        return Ok(new SearchViewModel(search));
    }

    [HttpPost("{id:int}/copy")]
    public async Task<ActionResult<int>> CopySearch(int id, CancellationToken ct)
    {
        var original = await _repository.GetSearchAsync(id, ct);
        if (original is null) return NotFound();

        var copy = original with
        {
            ID = 0,
            Status = SearchStatus.New,
            UserName = CurrentUserName!,
            SubmitDT = null,
            StartDT = null,
            EndDT = null
        };

        var newId = await _repository.SubmitSearchAsync(copy, ct);
        return CreatedAtAction(nameof(GetSearch), new { id = newId }, newId);
    }

    [HttpPost]
    public async Task<ActionResult<int>> CreateSearch(
        [FromBody] SearchViewModel viewModel,
        CancellationToken ct)
    {
        var search = viewModel.ToEntity();
        search.UserName = CurrentUserName!;

        var searchId = await _repository.SubmitSearchAsync(search, ct);

        // Publish to SignalR (best-effort, swallow exceptions)
        try
        {
            var searchUpdate = new SearchUpdate
            {
                ID = searchId,
                UserName = CurrentUserName!,
                Name = search.Name,
                Status = search.Status,
                Timestamp = DateTime.UtcNow
            };
            await _hubContext.Clients.All.SendAsync("searchUpdate", searchUpdate, ct);
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "Failed to publish search update to SignalR");
        }

        return CreatedAtAction(nameof(GetSearch), new { id = searchId }, searchId);
    }

    [HttpGet("{id:int}/results")]
    public async Task<IActionResult> GetResults(int id, CancellationToken ct)
    {
        var data = await _repository.GetSearchResultsAsync(id, ct);
        if (data is null || data.Length == 0) return NotFound();

        return File(
            data,
            "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
            "search_results.xlsx");
    }
}

LookupController

Public autocomplete endpoints (no authorization):

[Route("api/lookup")]
[ApiController]
public class LookupController : ApiControllerBase
{
    private readonly ILotFinderRepository _repository;

    [HttpGet("items")]
    public async Task<ActionResult<IEnumerable<ItemViewModel>>> FindItems(
        [FromQuery] string q,
        CancellationToken ct)
    {
        var items = await _repository.SearchItemsAsync(q, ct);
        return Ok(items.OrderBy(i => i.ItemNumber).Select(i => i.ToViewModel()));
    }

    [HttpGet("profit-centers")]
    public async Task<ActionResult<IEnumerable<ProfitCenterViewModel>>> FindProfitCenters(
        [FromQuery] string q,
        CancellationToken ct)
    {
        var centers = await _repository.SearchProfitCentersAsync(q, ct);
        return Ok(centers.OrderBy(pc => pc.Code).Select(pc => pc.ToViewModel()));
    }

    [HttpGet("work-centers")]
    public async Task<ActionResult<IEnumerable<WorkCenterViewModel>>> FindWorkCenters(
        [FromQuery] string q,
        CancellationToken ct)
    {
        var centers = await _repository.SearchWorkCentersAsync(q, ct);
        return Ok(centers.OrderBy(wc => wc.Code).Select(wc => wc.ToViewModel()));
    }

    [HttpGet("operators")]
    public async Task<ActionResult<IEnumerable<UserViewModel>>> FindOperators(
        [FromQuery] string q,
        CancellationToken ct)
    {
        var users = await _repository.SearchUsersAsync(q, ct);
        return Ok(users.OrderBy(u => u.FullName).Select(u => u.ToViewModel()));
    }
}

FileController

Excel file upload/download with caching:

[Route("api/file")]
[ApiController]
public class FileController : ApiControllerBase
{
    private const string ContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";

    private readonly ILotFinderRepository _repository;
    private readonly IMemoryCache _cache;
    private readonly ILogger<FileController> _logger;

    [HttpPost("work-orders/upload")]
    public async Task<ActionResult<FileUploadResult<WorkOrderViewModel>>> UploadWorkOrders(
        IFormFile file,
        CancellationToken ct)
    {
        if (file is null)
            return Ok(new FileUploadResult<WorkOrderViewModel> { WasSuccessful = false, ErrorMessage = "No file uploaded" });

        try
        {
            using var stream = file.OpenReadStream();
            using var workbook = new XLWorkbook(stream);
            var worksheet = workbook.Worksheet(1);

            var workOrderNumbers = new List<long>();
            var lastRow = worksheet.LastRowUsed()?.RowNumber() ?? 1;

            for (int row = 2; row <= lastRow; row++)
            {
                var cellValue = worksheet.Cell(row, 1).GetString()?.Trim();
                if (long.TryParse(cellValue, out var woNumber))
                {
                    workOrderNumbers.Add(woNumber);
                }
            }

            var workOrders = await _repository.LookupWorkOrdersAsync(workOrderNumbers, ct);
            var viewModels = workOrders
                .Select(wo => wo.ToViewModel())
                .DistinctBy(wo => new { wo.WorkOrderNumber, wo.ItemNumber })
                .OrderBy(wo => wo.WorkOrderNumber)
                .ToArray();

            return Ok(new FileUploadResult<WorkOrderViewModel>
            {
                WasSuccessful = true,
                Data = viewModels
            });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to parse uploaded work order file");
            return Ok(new FileUploadResult<WorkOrderViewModel>
            {
                WasSuccessful = false,
                ErrorMessage = "Failed to parse uploaded file"
            });
        }
    }

    [HttpPost("work-orders/template")]
    public ActionResult<string> GenerateWorkOrderTemplate([FromBody] List<long> workOrders)
    {
        var data = ExcelTemplateGenerator.Generate(workOrders ?? [], "Work Order Number");
        var key = CacheData(data);
        return Ok(key);
    }

    [HttpGet("work-orders/template/{key}")]
    public IActionResult DownloadWorkOrderTemplate(string key)
    {
        return DownloadCachedFile(key, "work_order_template.xlsx");
    }

    // Similar methods for part-numbers, component-lots, part-operations...

    private string CacheData(byte[] data)
    {
        var key = Guid.NewGuid().ToString("N").ToUpperInvariant();
        _cache.Set(key, data, new MemoryCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(1)
        });
        return key;
    }

    private IActionResult DownloadCachedFile(string key, string fileName)
    {
        if (!_cache.TryGetValue(key, out byte[]? data) || data is null)
        {
            return NotFound("Cached file not found or expired");
        }

        _cache.Remove(key);
        return File(data, ContentType, fileName);
    }
}

SignalR StatusHub

Real-time updates using ASP.NET Core SignalR:

public class StatusHub : Hub
{
    private static StatusUpdate _cachedStatus = new()
    {
        Message = "Unknown",
        Timestamp = DateTime.UtcNow
    };

    private readonly ILogger<StatusHub> _logger;

    public StatusHub(ILogger<StatusHub> logger)
    {
        _logger = logger;
    }

    /// <summary>
    /// Called by worker service to update status.
    /// Caches the update and broadcasts to all clients.
    /// </summary>
    public async Task SetStatus(StatusUpdate statusUpdate)
    {
        _cachedStatus = statusUpdate;
        await Clients.All.SendAsync("statusUpdate", statusUpdate);
    }

    /// <summary>
    /// Called by clients to get initial cached status on connection.
    /// </summary>
    public StatusUpdate GetCachedStatus()
    {
        return _cachedStatus;
    }

    /// <summary>
    /// Called by controllers/services to broadcast search updates.
    /// </summary>
    public async Task PublishSearchUpdate(SearchUpdate searchUpdate)
    {
        await Clients.All.SendAsync("searchUpdate", searchUpdate);
    }

    public override Task OnConnectedAsync()
    {
        _logger.LogInformation("Client {ConnectionId} connected to StatusHub", Context.ConnectionId);
        return base.OnConnectedAsync();
    }

    public override Task OnDisconnectedAsync(Exception? exception)
    {
        _logger.LogInformation("Client {ConnectionId} disconnected from StatusHub", Context.ConnectionId);
        return base.OnDisconnectedAsync(exception);
    }
}

Publishing from Controllers/Services

Use IHubContext<StatusHub> for publishing from outside the hub:

// Inject IHubContext<StatusHub> into controller
private readonly IHubContext<StatusHub> _hubContext;

// Publish status update
await _hubContext.Clients.All.SendAsync("statusUpdate", statusUpdate);

// Publish search update
await _hubContext.Clients.All.SendAsync("searchUpdate", searchUpdate);

LDAP Authentication

IAuthService Interface

public interface IAuthService
{
    Task<AuthResult> AuthenticateAsync(
        string username,
        string password,
        CancellationToken ct = default);

    Task<UserInfo?> GetUserInfoAsync(
        string username,
        CancellationToken ct = default);
}

public record AuthResult(
    bool Success,
    UserInfo? User,
    string? ErrorMessage);

LdapAuthService Implementation

Using System.DirectoryServices.Protocols for cross-platform compatibility:

public sealed class LdapAuthService : IAuthService
{
    private const string LdapLookupFormat = "(sAMAccountName={0})";

    private readonly LdapOptions _options;
    private readonly ILogger<LdapAuthService> _logger;

    public LdapAuthService(
        IOptions<LdapOptions> options,
        ILogger<LdapAuthService> logger)
    {
        _options = options.Value;
        _logger = logger;
    }

    public async Task<AuthResult> AuthenticateAsync(
        string username,
        string password,
        CancellationToken ct = default)
    {
        if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
        {
            return new AuthResult(false, null, "Username and password are required");
        }

        // Try each configured LDAP server
        string? lastError = null;
        foreach (var serverUrl in _options.ServerUrls)
        {
            try
            {
                ct.ThrowIfCancellationRequested();

                // Attempt authentication
                if (!await TryBindAsync(serverUrl, username, password, ct))
                {
                    return new AuthResult(false, null, "Incorrect username or password");
                }

                // Verify group membership
                if (!string.IsNullOrEmpty(_options.GroupDn))
                {
                    if (!await IsInGroupAsync(serverUrl, username, password, _options.GroupDn, ct))
                    {
                        return new AuthResult(false, null, "User is not a member of the required security group");
                    }
                }

                // Lookup user info
                var userInfo = await LookupUserAsync(serverUrl, username, password, ct);
                if (userInfo is null)
                {
                    return new AuthResult(false, null, "Failed to retrieve user information");
                }

                return new AuthResult(true, userInfo, null);
            }
            catch (LdapException ex)
            {
                _logger.LogWarning(ex, "LDAP authentication failed for server {Server}", serverUrl);
                lastError = ex.Message;
                continue; // Try next server
            }
        }

        return new AuthResult(false, null, lastError ?? "Unable to connect to directory server");
    }

    private async Task<bool> TryBindAsync(
        string serverUrl,
        string username,
        string password,
        CancellationToken ct)
    {
        using var connection = CreateConnection(serverUrl);
        var credential = new NetworkCredential(username, password);
        connection.Credential = credential;
        connection.AuthType = AuthType.Negotiate;

        try
        {
            await Task.Run(() => connection.Bind(), ct);
            return true;
        }
        catch (LdapException ex) when (ex.ErrorCode == 49) // Invalid credentials
        {
            return false;
        }
    }

    private async Task<bool> IsInGroupAsync(
        string serverUrl,
        string username,
        string password,
        string groupDn,
        CancellationToken ct)
    {
        using var connection = CreateConnection(serverUrl);
        connection.Credential = new NetworkCredential(username, password);
        connection.AuthType = AuthType.Negotiate;
        await Task.Run(() => connection.Bind(), ct);

        var searchRequest = new SearchRequest(
            _options.SearchBase,
            string.Format(LdapLookupFormat, username),
            SearchScope.Subtree,
            "memberOf");

        var response = (SearchResponse)await Task.Run(
            () => connection.SendRequest(searchRequest), ct);

        foreach (SearchResultEntry entry in response.Entries)
        {
            var memberOf = entry.Attributes["memberOf"];
            if (memberOf != null)
            {
                foreach (var group in memberOf.GetValues(typeof(string)))
                {
                    if (string.Equals((string)group, groupDn, StringComparison.OrdinalIgnoreCase))
                    {
                        return true;
                    }
                }
            }
        }

        return false;
    }

    private async Task<UserInfo?> LookupUserAsync(
        string serverUrl,
        string username,
        string password,
        CancellationToken ct)
    {
        using var connection = CreateConnection(serverUrl);
        connection.Credential = new NetworkCredential(username, password);
        connection.AuthType = AuthType.Negotiate;
        await Task.Run(() => connection.Bind(), ct);

        var searchRequest = new SearchRequest(
            _options.SearchBase,
            string.Format(LdapLookupFormat, username),
            SearchScope.Subtree,
            "distinguishedName", "givenName", "sn", "mail", "title");

        var response = (SearchResponse)await Task.Run(
            () => connection.SendRequest(searchRequest), ct);

        if (response.Entries.Count == 0) return null;

        var entry = response.Entries[0];
        return new UserInfo
        {
            DN = GetAttribute(entry, "distinguishedName"),
            Username = username.ToLowerInvariant(),
            FirstName = GetAttribute(entry, "givenName"),
            LastName = GetAttribute(entry, "sn"),
            EmailAddress = GetAttribute(entry, "mail"),
            Title = GetAttribute(entry, "title")
        };
    }

    private LdapConnection CreateConnection(string serverUrl)
    {
        var connection = new LdapConnection(serverUrl);
        connection.SessionOptions.ProtocolVersion = 3;
        connection.SessionOptions.SecureSocketLayer = false;
        connection.Timeout = TimeSpan.FromSeconds(_options.ConnectionTimeoutSeconds);
        return connection;
    }

    private static string GetAttribute(SearchResultEntry entry, string name)
    {
        var attr = entry.Attributes[name];
        return attr?.Count > 0 ? (string)attr[0] : string.Empty;
    }

    public Task<UserInfo?> GetUserInfoAsync(string username, CancellationToken ct = default)
    {
        // Not implemented for LDAP - user info is only available during authentication
        throw new NotSupportedException("GetUserInfoAsync requires password for LDAP lookup");
    }
}

FakeAuthService for Development

public sealed class FakeAuthService : IAuthService
{
    public Task<AuthResult> AuthenticateAsync(
        string username,
        string password,
        CancellationToken ct = default)
    {
        // Accept any credentials in development mode
        var user = new UserInfo
        {
            DN = $"CN={username},OU=Users,DC=example,DC=com",
            Username = username.ToLowerInvariant(),
            FirstName = "Dev",
            LastName = "User",
            EmailAddress = $"{username}@example.com",
            Title = "Developer"
        };

        return Task.FromResult(new AuthResult(true, user, null));
    }

    public Task<UserInfo?> GetUserInfoAsync(string username, CancellationToken ct = default)
    {
        var user = new UserInfo
        {
            DN = $"CN={username},OU=Users,DC=example,DC=com",
            Username = username.ToLowerInvariant(),
            FirstName = "Dev",
            LastName = "User",
            EmailAddress = $"{username}@example.com",
            Title = "Developer"
        };

        return Task.FromResult<UserInfo?>(user);
    }
}

Configuration Options

LdapOptions

public class LdapOptions
{
    public const string SectionName = "Ldap";

    /// <summary>
    /// LDAP server URLs (supports multiple for failover).
    /// Example: ["ldap.corp.example.com", "ldap2.corp.example.com"]
    /// </summary>
    public string[] ServerUrls { get; set; } = [];

    /// <summary>
    /// Distinguished name of required group for access.
    /// Example: "CN=ScopingTool-Users,OU=Groups,DC=corp,DC=example,DC=com"
    /// </summary>
    public string GroupDn { get; set; } = string.Empty;

    /// <summary>
    /// LDAP search base for user lookups.
    /// Example: "DC=corp,DC=example,DC=com"
    /// </summary>
    public string SearchBase { get; set; } = string.Empty;

    /// <summary>
    /// Connection timeout in seconds.
    /// </summary>
    public int ConnectionTimeoutSeconds { get; set; } = 30;
}

AuthOptions

public class AuthOptions
{
    public const string SectionName = "Auth";

    /// <summary>
    /// Enable fake authentication for development.
    /// When true, any credentials are accepted.
    /// </summary>
    public bool UseFakeAuth { get; set; } = false;

    /// <summary>
    /// Name of the authentication cookie.
    /// </summary>
    public string CookieName { get; set; } = "ScopingTool.Auth";

    /// <summary>
    /// Cookie expiration in minutes (default: 8 hours).
    /// </summary>
    public int CookieExpirationMinutes { get; set; } = 480;

    /// <summary>
    /// Optional list of usernames that bypass group check.
    /// Use sparingly for admin/testing purposes.
    /// </summary>
    public string[] AdminBypassUsers { get; set; } = [];
}

Configuration Example (appsettings.json)

{
  "Auth": {
    "UseFakeAuth": false,
    "CookieName": "ScopingTool.Auth",
    "CookieExpirationMinutes": 480,
    "AdminBypassUsers": []
  },
  "Ldap": {
    "ServerUrls": ["ldap.corp.example.com"],
    "GroupDn": "CN=ScopingTool-Users,OU=Groups,DC=corp,DC=example,DC=com",
    "SearchBase": "DC=corp,DC=example,DC=com",
    "ConnectionTimeoutSeconds": 30
  }
}

Development Configuration (appsettings.Development.json)

{
  "Auth": {
    "UseFakeAuth": true
  }
}

Program.cs Setup

// Configure authentication
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(options =>
    {
        var authOptions = builder.Configuration
            .GetSection(AuthOptions.SectionName)
            .Get<AuthOptions>() ?? new AuthOptions();

        options.Cookie.Name = authOptions.CookieName;
        options.Cookie.HttpOnly = true;
        options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
        options.Cookie.SameSite = SameSiteMode.Lax;
        options.ExpireTimeSpan = TimeSpan.FromMinutes(authOptions.CookieExpirationMinutes);
        options.SlidingExpiration = true;

        // Return 401 instead of redirect for API requests (Blazor WASM)
        options.Events.OnRedirectToLogin = context =>
        {
            context.Response.StatusCode = StatusCodes.Status401Unauthorized;
            return Task.CompletedTask;
        };

        options.Events.OnRedirectToAccessDenied = context =>
        {
            context.Response.StatusCode = StatusCodes.Status403Forbidden;
            return Task.CompletedTask;
        };
    });

builder.Services.AddAuthorization();

Service Registration

AddWebApi Extension Method

public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddWebApi(
        this IServiceCollection services,
        IConfiguration configuration)
    {
        // Bind configuration options
        services.Configure<LdapOptions>(configuration.GetSection(LdapOptions.SectionName));
        services.Configure<AuthOptions>(configuration.GetSection(AuthOptions.SectionName));

        // Register auth service based on configuration
        var authOptions = configuration
            .GetSection(AuthOptions.SectionName)
            .Get<AuthOptions>() ?? new AuthOptions();

        if (authOptions.UseFakeAuth)
        {
            services.AddSingleton<IAuthService, FakeAuthService>();
        }
        else
        {
            services.AddScoped<IAuthService, LdapAuthService>();
        }

        // Register memory cache for file downloads
        services.AddMemoryCache();

        // Configure SignalR
        services.AddSignalR();

        // Configure controllers
        services.AddControllers()
            .AddJsonOptions(options =>
            {
                options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
            });

        // Configure Swagger/OpenAPI
        services.AddEndpointsApiExplorer();
        services.AddSwaggerGen(c =>
        {
            c.SwaggerDoc("v1", new OpenApiInfo
            {
                Title = "JDE Scoping Tool API",
                Version = "v1"
            });
        });

        return services;
    }

    public static WebApplication UseWebApi(this WebApplication app)
    {
        // Use Swagger in development
        if (app.Environment.IsDevelopment())
        {
            app.UseSwagger();
            app.UseSwaggerUI();
        }

        app.UseAuthentication();
        app.UseAuthorization();

        app.MapControllers();
        app.MapHub<StatusHub>("/hubs/status");

        return app;
    }
}

Data Models

UserInfo (renamed from LDAPEntry)

public class UserInfo
{
    public string DN { get; set; } = string.Empty;
    public string Username { get; set; } = string.Empty;
    public string FirstName { get; set; } = string.Empty;
    public string LastName { get; set; } = string.Empty;
    public string DisplayName => string.IsNullOrWhiteSpace(FirstName) && string.IsNullOrWhiteSpace(LastName)
        ? Username
        : $"{FirstName} {LastName}".Trim();
    public string Title { get; set; } = string.Empty;
    public string EmailAddress { get; set; } = string.Empty;
}

StatusUpdate

public class StatusUpdate
{
    public string Message { get; set; } = string.Empty;
    public DateTime Timestamp { get; set; }
}

SearchUpdate

public class SearchUpdate
{
    public int ID { get; set; }
    public string UserName { get; set; } = string.Empty;
    public string Name { get; set; } = string.Empty;

    [JsonConverter(typeof(JsonStringEnumConverter))]
    public SearchStatus Status { get; set; }

    public DateTime? SubmitDT { get; set; }
    public DateTime? StartDT { get; set; }
    public DateTime? EndDT { get; set; }
    public DateTime Timestamp { get; set; }
}

LoginRequest

public class LoginRequest
{
    [Required]
    public string Username { get; set; } = string.Empty;

    [Required]
    public string Password { get; set; } = string.Empty;
}

FileUploadResult

public class FileUploadResult<T>
{
    public bool WasSuccessful { get; set; }
    public string? ErrorMessage { get; set; }
    public T[]? Data { get; set; }
}

Testing Strategy

Unit Tests

  • Controller Tests: Test action methods with mocked dependencies
  • Auth Service Tests: Test LDAP connection handling (integration tests)
  • File Controller Tests: Test upload parsing and caching
  • SignalR Hub Tests: Test hub method invocation

Mock Patterns

// Controller test with NSubstitute
[Fact]
public async Task GetSearches_ReturnsUserSearches_OrderedByStartDtDescending()
{
    // Arrange
    var repository = Substitute.For<ILotFinderRepository>();
    var hubContext = Substitute.For<IHubContext<StatusHub>>();
    var logger = Substitute.For<ILogger<SearchController>>();

    var searches = new[]
    {
        new Search { ID = 1, StartDT = DateTime.Now.AddHours(-2) },
        new Search { ID = 2, StartDT = DateTime.Now }
    };
    repository.GetUserSearchesAsync("testuser", Arg.Any<CancellationToken>())
        .Returns(searches);

    var controller = new SearchController(repository, hubContext, logger);
    controller.ControllerContext = CreateControllerContext("testuser");

    // Act
    var result = await controller.GetSearches(CancellationToken.None);

    // Assert
    var okResult = result.Result.ShouldBeOfType<OkObjectResult>();
    var viewModels = okResult.Value.ShouldBeOfType<SearchViewModel[]>();
    viewModels[0].ID.ShouldBe(2); // Most recent first
    viewModels[1].ID.ShouldBe(1);
}

NuGet Dependencies

<ItemGroup>
  <PackageReference Include="Microsoft.AspNetCore.Authentication.Cookies" Version="2.2.*" />
  <PackageReference Include="System.DirectoryServices.Protocols" Version="8.0.*" />
  <PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.1.*" />
  <PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.*" />
  <PackageReference Include="ClosedXML" Version="0.102.*" />
  <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.*" />
  <PackageReference Include="Microsoft.Extensions.Options" Version="9.0.*" />
  <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.*" />
</ItemGroup>

<ItemGroup Condition="'$(Configuration)' == 'Test'">
  <PackageReference Include="xunit" Version="2.9.*" />
  <PackageReference Include="Shouldly" Version="4.2.*" />
  <PackageReference Include="NSubstitute" Version="5.1.*" />
  <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.*" />
</ItemGroup>