-
Notifications
You must be signed in to change notification settings - Fork 0
Feature/userinfo #231
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Feature/userinfo #231
Changes from 13 commits
4272502
f80593f
d79992d
867e138
e0c56cb
6f9c135
2376539
3576547
2cae390
34f28d6
4af0abd
79f7b64
0504793
e0a329f
a6f703e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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)); | ||
| } | ||
|
|
||
| [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"); | ||
|
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}'"); | ||
|
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 ?? ""); | ||
| } | ||
| } | ||
| } | ||
| 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; | ||
| private UserInfoService _userInfo; | ||
|
Check warning on line 23 in web/Areas/Directory/Controllers/UserInfoController.cs
|
||
|
|
||
| public IUserHelper UserHelper; | ||
| private readonly RAPSContext _rapsContext; | ||
|
|
||
| public UserInfoController( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
| 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; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
| } | ||
| } | ||
| } | ||
| 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"); | ||
| } | ||
| } | ||
| } |
| 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; | ||
| } | ||
| } |
| 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; } | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This
Console.SetOutis process-global and never restored, so after this test ends the redirector still points at its now-inactiveITestOutputHelper. Later tests that callConsole.WriteLinethen throwInvalidOperationException: There is no currently active test(seen while merging to Development:EmailNotificationTest.RemoveInstructorScheduleAsync_*failed this way, stack-tracing back intoConsoleRedirector.WriteLine). It's order-dependent, so it can present as flaky.Minimal fix, make the class
IDisposable(xUnit callsDisposeper test):Console.Outis shared and classes run in parallel, so a[Collection]to serialize these (or dropping the global redirect) fully closes it.