Skip to content
Open
Show file tree
Hide file tree
Changes from 13 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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -503,4 +503,4 @@ web/Areas/Effort/Scripts/Effort_Database_Schema_And_Data_LEGACY.txt
.fallow/
VueApp/.fallow/
jscpd-report/
inspect-report/
inspect-report/
162 changes: 162 additions & 0 deletions test/Services/UserInfoServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using System.DirectoryServices.Protocols;
using Viper.Areas.Directory.Services;
using Viper.Areas.RAPS.Services;
using Viper.Areas.RAPS.Models.Uinform;
using Viper.Classes.SQLContext;
using Viper.Classes.Utilities;
using Xunit;
using Amazon;
using Amazon.Extensions.NETCore.Setup;

namespace Viper.test.Services
{
public class UserInfoServiceTests
{
private readonly ITestOutputHelper _output;

public UserInfoServiceTests(ITestOutputHelper output)
{
_output = output;
Console.SetOut(new ConsoleRedirector(output));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This Console.SetOut is process-global and never restored, so after this test ends the redirector still points at its now-inactive ITestOutputHelper. Later tests that call Console.WriteLine then throw InvalidOperationException: There is no currently active test (seen while merging to Development: EmailNotificationTest.RemoveInstructorScheduleAsync_* failed this way, stack-tracing back into ConsoleRedirector.WriteLine). It's order-dependent, so it can present as flaky.

Minimal fix, make the class IDisposable (xUnit calls Dispose per test):

private readonly TextWriter _originalOut = Console.Out;
public void Dispose() => Console.SetOut(_originalOut);

Console.Out is shared and classes run in parallel, so a [Collection] to serialize these (or dropping the global redirect) fully closes it.

}

[Fact]
public async Task TestGetUserInfo()
{
IConfigurationRoot config;
try
{
// Setup configuration using environment, appsettings, and SSM Parameter Store
var configuration = new ConfigurationBuilder()
.SetBasePath(AppContext.BaseDirectory)
.AddJsonFile("appsettings.json", optional: true)
.AddJsonFile("appsettings.Development.json", optional: true)
.AddEnvironmentVariables()
.Build();

var awsOptions = new AWSOptions
{
Region = RegionEndpoint.USWest1
};
var configBuilder = new ConfigurationBuilder()
.AddConfiguration(configuration)
.AddSystemsManager("/Development", awsOptions)
.AddSystemsManager("/Shared", awsOptions);
config = configBuilder.Build();
}
catch (Exception ex) when (ex.ToString().Contains("Amazon") || ex.ToString().Contains("EC2") || ex.ToString().Contains("Metadata") || ex.ToString().Contains("credential"))
{
_output.WriteLine($"[SKIPPED] AWS SSM Parameter Store is not available: {ex.Message}");
return; // Gracefully pass/skip the test in CI/CD pipeline
}

try
{
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(config);
services.AddMemoryCache();
services.AddHttpClient();

// Register database contexts using connection strings from SSM config
void RegisterContext<TContext>(string key) where TContext : DbContext
{
var connStr = config.GetConnectionString(key);
if (string.IsNullOrEmpty(connStr))
{
throw new Exception($"ConnectionString for '{key}' is empty or missing!");
}
services.AddDbContext<TContext>(options => options.UseSqlServer(connStr));
}

RegisterContext<AAUDContext>("AAUD");
RegisterContext<RAPSContext>("RAPS");
RegisterContext<CoursesContext>("Courses");
services.AddDbContext<EquipmentLoanContext>(options => options.UseSqlServer(config.GetConnectionString("VIPER")));
services.AddDbContext<PPSContext>(options => options.UseSqlServer(config.GetConnectionString("VIPER")));
services.AddDbContext<IDCardsContext>(options => options.UseSqlServer(config.GetConnectionString("VIPER")));
services.AddDbContext<KeysContext>(options => options.UseSqlServer(config.GetConnectionString("VIPER")));

services.AddScoped<UserInfoService>();

var serviceProvider = services.BuildServiceProvider();
HttpHelper.Configure(serviceProvider.GetRequiredService<IMemoryCache>(), config, null!, null!, null!, null!);

// Test logic will call GetUserInfoAsync and populate AD/Instinct details

var userInfoService = serviceProvider.GetRequiredService<UserInfoService>();

// Query Mothra ID 00065542 (Brandon Edwards - 'be5')
var result = await userInfoService.GetUserInfoAsync(null, "00065542");
Comment thread
rlorenzo marked this conversation as resolved.
Outdated

if (result == null)
{
_output.WriteLine("[DEBUG] GetUserInfoAsync returned null!");
Assert.Fail("GetUserInfoAsync returned null");
}

_output.WriteLine($"[DEBUG] IamId: '{result.IamId}'");
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
_output.WriteLine($"[DEBUG] DisplayName: '{result.DisplayFullName}'");
_output.WriteLine($"[DEBUG] InstinctId: '{result.InstinctId}'");
_output.WriteLine($"[DEBUG] InstinctUsername: '{result.InstinctUsername}'");
_output.WriteLine($"[DEBUG] InstinctStatus: '{result.InstinctStatus}'");
_output.WriteLine($"[DEBUG] InstinctIsActive: {result.InstinctIsActive}");

_output.WriteLine($"[DEBUG] ADDisplayName: '{result.ADDisplayName}'");
_output.WriteLine($"[DEBUG] ADMail: '{result.ADMail}'");
_output.WriteLine($"[DEBUG] ADSamAccountName: '{result.ADSamAccountName}'");
_output.WriteLine($"[DEBUG] ADUserPrincipalName: '{result.ADUserPrincipalName}'");
_output.WriteLine($"[DEBUG] ADDistinguishedName: '{result.ADDistinguishedName}'");
_output.WriteLine($"[DEBUG] ADMemberOf count: {result.ADMemberOf?.Count ?? 0}");
if (result.ADMemberOf != null)
{
foreach (var group in result.ADMemberOf)
{
_output.WriteLine($" Group: '{group}'");
}
}

if (result.InstinctRoles != null)
{
_output.WriteLine($"[DEBUG] InstinctRoles: {string.Join(", ", result.InstinctRoles)}");
}

if (result.InstinctInfo != null && !string.IsNullOrEmpty(result.InstinctInfo.ErrorMessage))
{
_output.WriteLine($"[DEBUG] Instinct API Error: {result.InstinctInfo.ErrorMessage}");
}

Assert.NotNull(result.InstinctId);
Assert.Equal("be5", result.InstinctUsername);
}
catch (Exception ex) when (ex.ToString().Contains("SqlException") || ex.ToString().Contains("network-related") || ex.ToString().Contains("login failed") || ex.ToString().Contains("LdapException") || ex.ToString().Contains("Active Directory"))
{
_output.WriteLine($"[SKIPPED] Database or network resources not accessible in this environment: {ex.Message}");
return; // Gracefully pass/skip the test in CI/CD pipeline
}
catch (Exception ex)
{
_output.WriteLine($"[DEBUG] Test execution failed with exception: {ex}");
throw;
}
}

private class ConsoleRedirector : TextWriter
{
private readonly ITestOutputHelper _output;
public ConsoleRedirector(ITestOutputHelper output) => _output = output;
public override System.Text.Encoding Encoding => System.Text.Encoding.UTF8;
public override void WriteLine(string? value) => _output.WriteLine(value ?? "");
public override void Write(string? value) => _output.WriteLine(value ?? "");
}
}
}
2 changes: 1 addition & 1 deletion web/Areas/Directory/Controllers/DirectoryController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public DirectoryController(AAUDContext aaud, RAPSContext rapsContext)
[Route("/[area]/")]
public async Task<ActionResult> Index(string? useExample)
{
return await Task.Run(() => View("~/Areas/Directory/Views/Card.cshtml"));
return await Task.Run(() => View("~/Areas/Directory/Views/Card.cshtml", new DirectoryUser()));
}
Comment on lines 35 to 38

/// <summary>
Expand Down
145 changes: 145 additions & 0 deletions web/Areas/Directory/Controllers/UserInfoController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Viper.Models.AAUD;
using Viper.Areas.RAPS.Services;
using Web.Authorization;
using Viper.Classes;
using Viper.Classes.SQLContext;
using Viper.Areas.Directory.Models;
using System.Runtime.Versioning;
using System.Collections.Generic;
using Viper.Areas.Directory.Services;
using Viper.Classes.Utilities;
using Microsoft.Extensions.Caching.Memory;

namespace Viper.Areas.Directory.Controllers
{
[Area("Directory")]
[Permission(Allow = "SVMSecure")]
public class UserInfoController : AreaController
{
public Classes.SQLContext.AAUDContext _aaud;

Check warning on line 22 in web/Areas/Directory/Controllers/UserInfoController.cs

View workflow job for this annotation

GitHub Actions / Backend Tests

Make this field 'private' and encapsulate it in a 'public' property.
private UserInfoService _userInfo;

Check warning on line 23 in web/Areas/Directory/Controllers/UserInfoController.cs

View workflow job for this annotation

GitHub Actions / Backend Tests

Make '_userInfo' 'readonly'.

Check notice

Code scanning / CodeQL

Missed 'readonly' opportunity Note

Field '_userInfo' can be 'readonly'.
public IUserHelper UserHelper;

Check warning on line 24 in web/Areas/Directory/Controllers/UserInfoController.cs

View workflow job for this annotation

GitHub Actions / Backend Tests

Make this field 'private' and encapsulate it in a 'public' property.
private readonly RAPSContext _rapsContext;

public UserInfoController(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UserInfoService is built by hand from the service locator (HttpHelper.HttpContext?.RequestServices.GetService(...)) and isn't registered in Program.cs.

Register it as scoped and inject it through the constructor like the contexts already are. That drops the ! null-forgiving on the resolved services (which genuinely can be null) and makes the service testable without an HttpContext.

Also, _aaud and UserHelper should be private rather than public fields.

RAPSContext rapsContext,
AAUDContext aaudContext,
CoursesContext coursesContext,
EquipmentLoanContext equipmentLoanContext,
PPSContext ppsContext,
IDCardsContext idCardsContext,
KeysContext keysContext)
{
_aaud = aaudContext;
_rapsContext = rapsContext;
UserHelper = new UserHelper();

// Get services from DI container
var httpClientFactory = HttpHelper.HttpContext?.RequestServices.GetService(typeof(IHttpClientFactory)) as IHttpClientFactory;
var memoryCache = HttpHelper.HttpContext?.RequestServices.GetService(typeof(IMemoryCache)) as IMemoryCache;
var configuration = HttpHelper.HttpContext?.RequestServices.GetService(typeof(IConfiguration)) as IConfiguration;

_userInfo = new UserInfoService(
aaudContext,
rapsContext,
coursesContext,
equipmentLoanContext,
ppsContext,
idCardsContext,
keysContext,
configuration!,
httpClientFactory!,
memoryCache!
);
}

/// <summary>
/// Redirect if we don't have a mothraID
/// </summary>
[Route("/userinfo/")]
public ActionResult Index()
{
return Redirect("/Directory");
}

/// <summary>
/// UserInfo Page
/// </summary>
/// <param name="id">MothraID</param>
/// <returns></returns>
[Route("/userinfo/{mothraID}")]
public async Task<ActionResult> UserInfo(string? mothraID)
{
// Validate required parameters
if (string.IsNullOrWhiteSpace(mothraID))
{
return Redirect("/Directory");
}
else
{
// Check if user is viewing their own page
var currentUser = UserHelper.GetCurrentUser();
bool ownPage = mothraID == currentUser.MothraId;

Check warning on line 85 in web/Areas/Directory/Controllers/UserInfoController.cs

View workflow job for this annotation

GitHub Actions / Backend Tests

Dereference of a possibly null reference.
var individual = await _aaud.AaudUsers.Where(u => (u.MothraId == mothraID)).FirstOrDefaultAsync();
string? iamId = null;
if (individual != null) iamId = individual.IamId;

// Get user information
var userInfo = await _userInfo.GetUserInfoAsync(iamId, mothraID);
if (userInfo == null)
{
return Redirect("/Directory");
}

// Set permissions for the view
userInfo.CanViewDirectoryDetail = ownPage || UserHelper.HasPermission(_rapsContext, currentUser, "SVMSecure.directoryDetail");
userInfo.CanViewStudentID = UserHelper.HasPermission(_rapsContext, currentUser, "SVMSecure.studentID");
userInfo.CanViewIAM = UserHelper.HasPermission(_rapsContext, currentUser, "SVMSecure.userinfo.iam");
userInfo.CanViewRoles = ownPage || UserHelper.HasPermission(_rapsContext, currentUser, "SVMSecure.userinfo.raps");
userInfo.CanViewUCPath = UserHelper.HasPermission(_rapsContext, currentUser, "SVMSecure.directoryUCPathInfo");
userInfo.CanViewUCPathDetail = UserHelper.HasPermission(_rapsContext, currentUser, "SVMSecure.directoryUCPathInfoAllDetail");
userInfo.CanViewIDCards = UserHelper.HasPermission(_rapsContext, currentUser, "SVMSecure.userinfo.idcards");
userInfo.CanViewKeys = UserHelper.HasPermission(_rapsContext, currentUser, "SVMSecure.userinfo.keys");
userInfo.CanViewLoans = ownPage || UserHelper.HasPermission(_rapsContext, currentUser, "SVMSecure.userinfo.loans");
userInfo.CanViewInstinct = ownPage || UserHelper.HasPermission(_rapsContext, currentUser, "SVMSecure.userinfo.instinct");
userInfo.CanViewADGroups = UserHelper.HasPermission(_rapsContext, currentUser, "SVMSecure.UserInfo.ADGroups");

userInfo.CanViewDirectoryDetail = true;
userInfo.CanViewStudentID = true;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@JasonRobertFrancis Is this right? You are overriding the permissions checks done above to all true? Was this for debugging left over?

userInfo.CanViewIAM = true;
userInfo.CanViewRoles = true;
userInfo.CanViewUCPath = true;
userInfo.CanViewUCPathDetail = true;
userInfo.CanViewIDCards = true;
userInfo.CanViewKeys = true;
userInfo.CanViewLoans = true;
userInfo.CanViewInstinct = true;
userInfo.CanViewADGroups = true;

return View("~/Areas/Directory/Views/UserInfo.cshtml", userInfo);
}
}

/// <summary>
/// Get user photo, stubbed for now
/// </summary>
/// <param name="mailID">Mail ID</param>
/// <param name="altphoto">Use alternative photo</param>
/// <returns></returns>
[Route("/userPhoto")]
public async Task<ActionResult> UserPhoto(string mailID, bool altphoto = false)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declared async with no await (will warn) and stubbed to NotFound(). Add a TODO or remove it until it's implemented.

{
return NotFound();
}

[Route("/[area]/nav")]
public async Task<ActionResult<IEnumerable<NavMenuItem>>> Nav()
{
var nav = new List<NavMenuItem>();
return await Task.Run(() => nav);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Task.Run(() => nav) bounces trivial synchronous work onto a thread-pool thread for no benefit. Make these non-async or use Task.FromResult.

}
}
}
27 changes: 27 additions & 0 deletions web/Areas/Directory/Models/DirectoryUser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using Viper.Classes.SQLContext;
using Viper.Models.AAUD;
using Viper.Areas.RAPS.Services;

namespace Viper.Areas.Directory.Models
{
public class DirectoryUser
{
public bool CanDisplayIDs { get; set; } = false;
public bool CanEmulate { get; set; } = false;
public bool CanSeeAllStudents { get; set; } = false;
public bool CanSeeUCPathInfo { get; set; } = false;
public bool CanSeeAltPhoto { get; set; } = false;

public DirectoryUser()
{
IUserHelper UserHelper = new UserHelper();
AaudUser? currentUser = UserHelper.GetCurrentUser();
RAPSContext? _rapsContext = (RAPSContext?)HttpHelper.HttpContext?.RequestServices.GetService(typeof(RAPSContext));
this.CanDisplayIDs = UserHelper.HasPermission(_rapsContext, currentUser, "SVMSecure.DirectoryDetail");
this.CanEmulate = UserHelper.HasPermission(_rapsContext, currentUser, "SVMSecure.SU");
this.CanSeeAllStudents = UserHelper.HasPermission(_rapsContext, currentUser, "SVMSecure.SIS.AllStudents");
this.CanSeeUCPathInfo = UserHelper.HasPermission(_rapsContext, currentUser, "SVMSecure.DirectoryUCPathInfo");
this.CanSeeAltPhoto = UserHelper.HasPermission(_rapsContext, currentUser, "SVMSecure.CATS.ServiceDesk");
}
}
}
15 changes: 15 additions & 0 deletions web/Areas/Directory/Models/IDCardResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace Viper.Areas.Directory.Models
{
public class IDCardResult
{
public string? Number { get; set; } = null;
public string? DisplayName { get; set; } = null;
public string? LastName { get; set; } = null;
public string? Line2 { get; set; } = null;
public string? StatusDescription { get; set; } = null;
public DateTime? Applied { get; set; } = null;
public DateTime? Issued { get; set; } = null;
public string? DeactivatedReason { get; set; } = null;
public DateTime? Deactivated { get; set; } = null;
}
}
20 changes: 20 additions & 0 deletions web/Areas/Directory/Models/InstinctResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using System;
using System.Collections.Generic;

namespace Viper.Areas.Directory.Models
{
public class InstinctResult
{
public bool Valid { get; set; } = false;
public string? Id { get; set; }
public string? Initials { get; set; }
public string? InstinctId { get; set; }
public bool IsActive { get; set; }
public bool IsProtected { get; set; }
public string? PasswordExpiresAt { get; set; }
public string? Status { get; set; }
public string? Username { get; set; }
public List<string> Roles { get; set; } = new List<string>();
public string? ErrorMessage { get; set; }
}
}
Loading
Loading