diff --git a/CLAUDE.md b/CLAUDE.md index 00b56ab0d..5083e7f13 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,10 +45,12 @@ When I ask a question or make an observation, respond with an answer - do NOT ju ## Testing & Git +- **No VMACS production calls**: Unit tests, local environments, and test environments must NEVER send requests to the VMACS production server. All external HTTP requests (e.g., VMACS, Instinct, IAM API) must be mocked using in-memory databases and mock HTTP clients/factories to prevent production spam. - **UI**: Test UI changes with Playwright MCP (modals, forms, keyboard nav) - **API**: Use Playwright MCP to visit endpoints, APIs require browser auth, `curl` fails - **Branch & merge flow**: Branch off `main`, named `feature/`|`fix/`|etc. plus the JIRA ticket if applicable (e.g. `feature/VPR-104-clinical-scheduler`). After code review, merge into `Development` and push, which deploys to TEST. After the PR is approved on TEST, merge into `main`. Every change goes through `Development` first. - **Never branch off `Development`**: it is a merge/deploy target, never a base. A branch being "behind `Development`" is expected and not a concern (you never sync or rebase from it). Its history is messy by design and never rewritten. - **Squash during review**: If a branch is still unmerged and worked by a single developer, squash code-review fixes into the relevant existing commit for cleaner history rather than stacking "address review" commits. - **Plan/smoketest notes**: `PLAN-*.md` and `SMOKETEST-*.md` at the repo root are local working notes, gitignored by design. +- **Code-review self-check**: If `PLAN-code-review-lessons.md` exists at the repo root (a personal, gitignored note — may not exist on every machine or for every developer; skip silently if absent), read it and run its pre-flight checklist before saying a change is ready to build, test, or commit. - **Commit messages**: Conventional Commits `type(scope): subject` (`feat`|`fix`|`refactor`|`docs`|`test`|`chore`; prefer `feat` for new behavior), ticket ID from branch as prefix (e.g. `VPR-104 fix(a11y): ...`). Subject: imperative, max 72 chars, no trailing period, intent not implementation. Body only when the subject is insufficient: `-` bullets that each earn their place (skip plumbing/helpers/test scaffolding), wrapped at 72. diff --git a/Viper.sln.DotSettings b/Viper.sln.DotSettings index 1b48c85a1..ec92ed564 100644 --- a/Viper.sln.DotSettings +++ b/Viper.sln.DotSettings @@ -2,4 +2,6 @@ <Profile name="OptimizeUsings"><CSOptimizeUsings><OptimizeUsings>True</OptimizeUsings><EmbraceInRegion>False</EmbraceInRegion><RegionName></RegionName></CSOptimizeUsings></Profile> <Profile name="ShortenReferences"><CSShortenReferences>True</CSShortenReferences></Profile> <Profile name="RemoveRedundancies"><CSRemoveCodeRedundancies>True</CSRemoveCodeRedundancies><RemoveCodeRedundancies>True</RemoveCodeRedundancies><CSRemoveRedundantArgumentDefaultValues>True</CSRemoveRedundantArgumentDefaultValues><CSRemoveRedundantInitializers>True</CSRemoveRedundantInitializers></Profile> + DO_NOT_SHOW + DO_NOT_SHOW diff --git a/VueApp/src/Effort/pages/SchoolSummary.vue b/VueApp/src/Effort/pages/SchoolSummary.vue index 0bffded93..7581eed48 100644 --- a/VueApp/src/Effort/pages/SchoolSummary.vue +++ b/VueApp/src/Effort/pages/SchoolSummary.vue @@ -84,6 +84,7 @@ diff --git a/VueApp/src/styles/colors.css b/VueApp/src/styles/colors.css index 644bce9d1..ef3be88e6 100644 --- a/VueApp/src/styles/colors.css +++ b/VueApp/src/styles/colors.css @@ -112,3 +112,22 @@ border-left: 0.25rem solid var(--q-negative); color: var(--q-negative); } + +/* Warning-state surface — mirrors StatusBanner type="warning" treatment + (own padding/layout, not layered onto Quasar's .q-banner) for Razor pages + that have no Vue mount, e.g. UserInfo.cshtml's unavailable-sections banner. + Mirrored into web/wwwroot/css/site.css so Razor and Vue render it + identically - see that copy for why it can't just reuse .q-banner. */ +.warning-surface { + display: flex; + align-items: flex-start; + gap: 0.75rem; + padding: 0.75rem 1rem; + border-radius: 4px; + background-color: color-mix(in srgb, var(--q-warning) 15%, white); + border-left: 0.25rem solid var(--q-warning); + color: #5d4600; +} +.warning-surface .material-icons { + flex: none; +} diff --git a/scripts/build-dotnet.js b/scripts/build-dotnet.js index 4a0100323..206a84162 100644 --- a/scripts/build-dotnet.js +++ b/scripts/build-dotnet.js @@ -25,6 +25,15 @@ const artifactsPath = ".artifacts-precommit" // that, which would surface as a false "build failed" instead of the real result. const MAX_BUILD_OUTPUT = 10_485_760 +// .artifacts-precommit is a separate, isolated output path (deliberately kept apart from +// the normal bin/obj folders to avoid file-lock conflicts with a running dev server), so a +// rebuild here doesn't get the benefit of warm incremental state the way a plain `dotnet +// build` does. A cold build of this solution - more so right after a merge, and with +// SonarAnalyzer's security/vulnerability rule categories enabled - can take a while. 120s +// was too tight and got mistaken for a real compile failure when execFileSync killed the +// process mid-build; give it more headroom. +const BUILD_TIMEOUT_MS = 600_000 + // Check if either project needs rebuild const webNeedsBuild = needsBuild("web", "Viper.csproj") const testNeedsBuild = needsBuild("test", "Viper.test.csproj") @@ -55,7 +64,7 @@ try { ["build", "test/", "--artifacts-path", artifactsPath, "--verbosity", "quiet", "--nologo"], { encoding: "utf8", - timeout: 120_000, + timeout: BUILD_TIMEOUT_MS, maxBuffer: MAX_BUILD_OUTPUT, stdio: ["inherit", "pipe", "pipe"], env: { ...env, DOTNET_USE_COMPILER_SERVER: "1" }, @@ -68,6 +77,25 @@ try { logger.success("Build complete") } catch (error) { const output = (error.stdout || "") + (error.stderr || "") + + if (error.signal) { + // execFileSync kills the child and sets `signal` (not a normal exit code) when the + // timeout fires - that's an environment/timing issue, not a deterministic function + // of the code, so it must NOT be cached as a build failure. Caching it would make + // every future run replay this same "failure" (via the cached-output branch above) + // for as long as the file hashes stay unchanged, exactly like a real compile error, + // even though the code may be fine and just needs another (or longer) attempt. + logger.error( + `Build timed out after ${BUILD_TIMEOUT_MS / 1000}s (signal ${error.signal}) - ` + + "not a compile error, so nothing was cached. Try again; if it keeps timing out, " + + "BUILD_TIMEOUT_MS in this script may need to go up further.", + ) + if (output) { + console.error(filterBuildErrors(output)) + } + process.exit(1) + } + // Cache failure - no point rebuilding if code hasn't changed markAsBuilt("web", "Viper.csproj", output, false) markAsBuilt("test", "Viper.test.csproj", output, false) diff --git a/scripts/lint-any.js b/scripts/lint-any.js index 7412ab8fd..82a65add5 100644 --- a/scripts/lint-any.js +++ b/scripts/lint-any.js @@ -4,9 +4,18 @@ import fs from "node:fs" import os from "node:os" import path from "node:path" import { spawn, spawnSync } from "node:child_process" +import crossSpawn from "cross-spawn" const { env } = process +// On Windows, npx resolves to npx.cmd. Node's own spawnSync/spawn refuse to +// run .cmd/.bat files directly without shell: true (CVE-2024-27980 fix), but +// shell: true re-tokenizes the whole command line through cmd.exe, breaking +// file paths with spaces or &. cross-spawn resolves and invokes npx.cmd +// correctly on Windows (and plain npx elsewhere) without either problem, so +// oxfmt is spawned through it rather than raw child_process. +const NPX_COMMAND = "npx" + // Windows command line limit is ~8191 chars. Use a conservative threshold // to account for the node executable path and script path overhead. const MAX_ARG_LENGTH = 7000 @@ -214,7 +223,7 @@ function categorizeFiles(files) { * @returns {{passed: boolean, failed: string[]}} - Result with pass status and failed files */ function runOxfmtCheckBatch(files) { - const result = spawnSync("npx", ["oxfmt", "--check", ...files], { + const result = crossSpawn.sync(NPX_COMMAND, ["oxfmt", "--check", ...files], { stdio: "pipe", cwd: projectRoot, encoding: "utf8", @@ -266,7 +275,7 @@ function runOxfmtCheck(files, fix) { } if (fix) { - const result = spawnSync("npx", ["oxfmt", "--write", ...batch], { + const result = crossSpawn.sync(NPX_COMMAND, ["oxfmt", "--write", ...batch], { stdio: "inherit", cwd: projectRoot, }) diff --git a/test/Areas/Directory/VMACSServiceTest.cs b/test/Areas/Directory/VMACSServiceTest.cs index 7039de4f0..45440383b 100644 --- a/test/Areas/Directory/VMACSServiceTest.cs +++ b/test/Areas/Directory/VMACSServiceTest.cs @@ -31,7 +31,6 @@ public void BuildSearchPath_IncludesAuthToken() [Theory] [InlineData("https://vmacs-qa.example.edu")] - [InlineData("http://vmacs.example.edu/")] public void IsValidBaseUrl_True_ForAbsoluteHttpUrls(string baseUrl) { Assert.True(VMACSService.IsValidBaseUrl(baseUrl)); @@ -44,7 +43,7 @@ public void IsValidBaseUrl_True_ForAbsoluteHttpUrls(string baseUrl) [InlineData("not-a-url")] [InlineData("/relative/path")] [InlineData("vmacs-qa.example.edu")] - [InlineData("ftp://vmacs.example.edu")] + [InlineData("ftp://vmacs-qa.example.edu")] [InlineData("file:///etc/passwd")] public void IsValidBaseUrl_False_ForMissingOrNonHttpUrls(string? baseUrl) { diff --git a/test/CMS/CMSUserPhotoControllerTests.cs b/test/CMS/CMSUserPhotoControllerTests.cs index 8b93f6da4..891d4fc77 100644 --- a/test/CMS/CMSUserPhotoControllerTests.cs +++ b/test/CMS/CMSUserPhotoControllerTests.cs @@ -9,9 +9,11 @@ namespace Viper.test.CMS; /// /// Controller wiring tests for CMSUserPhotoController: each by-id-type endpoint forwards only -/// its identifier (and the altPhoto flag) to ICmsUserPhotoService and returns the bytes as an -/// image/jpeg file response with a private cache header; conditional requests (If-Modified-Since) -/// short-circuit to 304 per FIX 4. +/// its identifier to ICmsUserPhotoService, routing to GetUserPhotoAsync when altPhoto is false +/// and GetAlternatePhotoAsync when altPhoto is true, and returns the bytes as an image/jpeg file +/// response with a private cache header; conditional requests (If-Modified-Since) short-circuit +/// to 304 per FIX 4. A null result from GetAlternatePhotoAsync (no alternate photo for this +/// person) returns 404 rather than falling back to another image. /// public sealed class CMSUserPhotoControllerTests { @@ -39,7 +41,7 @@ public CMSUserPhotoControllerTests() public async Task GetByMailId_ForwardsMailIdOnly() { var bytes = new byte[] { 1, 2, 3 }; - _photoService.GetUserPhotoAsync("mail@example.com", null, null, null, false, Arg.Any()) + _photoService.GetUserPhotoAsync("mail@example.com", null, null, null, Arg.Any()) .Returns(Result(bytes)); var result = await _controller.GetByMailId("mail@example.com", ct: TestContext.Current.CancellationToken); @@ -47,51 +49,65 @@ public async Task GetByMailId_ForwardsMailIdOnly() var file = Assert.IsType(result); Assert.Equal("image/jpeg", file.ContentType); Assert.Equal(bytes, file.FileContents); - await _photoService.Received(1).GetUserPhotoAsync("mail@example.com", null, null, null, false, + await _photoService.Received(1).GetUserPhotoAsync("mail@example.com", null, null, null, Arg.Any()); } [Fact] - public async Task GetByLoginId_ForwardsLoginIdAndAltPhotoFlag() + public async Task GetByLoginId_AltPhoto_CallsGetAlternatePhotoWithLoginId() { - _photoService.GetUserPhotoAsync(null, "loginX", null, null, true, Arg.Any()) + _photoService.GetAlternatePhotoAsync(null, "loginX", null, null, Arg.Any()) .Returns(Result(new byte[] { 9 })); var result = await _controller.GetByLoginId("loginX", altPhoto: true, TestContext.Current.CancellationToken); Assert.IsType(result); - await _photoService.Received(1).GetUserPhotoAsync(null, "loginX", null, null, true, Arg.Any()); + await _photoService.Received(1).GetAlternatePhotoAsync(null, "loginX", null, null, Arg.Any()); + await _photoService.DidNotReceive().GetUserPhotoAsync(Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] public async Task GetByIamId_ForwardsIamIdOnly() { - _photoService.GetUserPhotoAsync(null, null, "1000123", null, false, Arg.Any()) + _photoService.GetUserPhotoAsync(null, null, "1000123", null, Arg.Any()) .Returns(Result(new byte[] { 7 })); var result = await _controller.GetByIamId("1000123", ct: TestContext.Current.CancellationToken); Assert.IsType(result); - await _photoService.Received(1).GetUserPhotoAsync(null, null, "1000123", null, false, Arg.Any()); + await _photoService.Received(1).GetUserPhotoAsync(null, null, "1000123", null, Arg.Any()); } [Fact] public async Task GetByMothraId_ForwardsMothraIdOnly() { - _photoService.GetUserPhotoAsync(null, null, null, "m-9001", false, Arg.Any()) + _photoService.GetUserPhotoAsync(null, null, null, "m-9001", Arg.Any()) .Returns(Result(new byte[] { 5 })); var result = await _controller.GetByMothraId("m-9001", ct: TestContext.Current.CancellationToken); Assert.IsType(result); - await _photoService.Received(1).GetUserPhotoAsync(null, null, null, "m-9001", false, Arg.Any()); + await _photoService.Received(1).GetUserPhotoAsync(null, null, null, "m-9001", Arg.Any()); + } + + [Fact] + public async Task GetByMailId_AltPhotoMissing_ReturnsNotFound() + { + _photoService.GetAlternatePhotoAsync("mail@example.com", null, null, null, Arg.Any()) + .Returns((CmsUserPhotoResult?)null); + + var result = await _controller.GetByMailId("mail@example.com", altPhoto: true, + ct: TestContext.Current.CancellationToken); + + Assert.IsType(result); } [Fact] public async Task ServePhoto_SetsPrivateCacheHeader() { _photoService.GetUserPhotoAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any(), Arg.Any()).Returns(Result(new byte[] { 1 })); + Arg.Any()).Returns(Result(new byte[] { 1 })); await _controller.GetByMailId("mail@example.com", ct: TestContext.Current.CancellationToken); @@ -104,7 +120,7 @@ public async Task ServePhoto_SetsPrivateCacheHeader() public async Task ServePhoto_SetsLastModifiedHeader() { _photoService.GetUserPhotoAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any(), Arg.Any()).Returns(Result(new byte[] { 1 })); + Arg.Any()).Returns(Result(new byte[] { 1 })); await _controller.GetByMailId("mail@example.com", ct: TestContext.Current.CancellationToken); @@ -115,7 +131,7 @@ public async Task ServePhoto_SetsLastModifiedHeader() public async Task ServePhoto_ReturnsNotModified_WhenIfModifiedSinceCoversLastModified() { _photoService.GetUserPhotoAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any(), Arg.Any()).Returns(Result(new byte[] { 1, 2, 3 })); + Arg.Any()).Returns(Result(new byte[] { 1, 2, 3 })); _controller.Request.GetTypedHeaders().IfModifiedSince = PhotoLastModified; var result = await _controller.GetByMailId("mail@example.com", ct: TestContext.Current.CancellationToken); @@ -128,7 +144,7 @@ public async Task ServePhoto_ReturnsNotModified_WhenIfModifiedSinceCoversLastMod public async Task ServePhoto_ReturnsNotModified_WhenIfModifiedSinceIsAfterLastModified() { _photoService.GetUserPhotoAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any(), Arg.Any()).Returns(Result(new byte[] { 1, 2, 3 })); + Arg.Any()).Returns(Result(new byte[] { 1, 2, 3 })); _controller.Request.GetTypedHeaders().IfModifiedSince = PhotoLastModified.AddDays(1); var result = await _controller.GetByMailId("mail@example.com", ct: TestContext.Current.CancellationToken); @@ -142,7 +158,7 @@ public async Task ServePhoto_ReturnsFile_WhenIfModifiedSinceIsBeforeLastModified { var bytes = new byte[] { 1, 2, 3 }; _photoService.GetUserPhotoAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any(), Arg.Any()).Returns(Result(bytes)); + Arg.Any()).Returns(Result(bytes)); _controller.Request.GetTypedHeaders().IfModifiedSince = PhotoLastModified.AddDays(-1); var result = await _controller.GetByMailId("mail@example.com", ct: TestContext.Current.CancellationToken); @@ -156,7 +172,7 @@ public async Task ServePhoto_ReturnsFile_WhenNoIfModifiedSinceHeaderSent() { var bytes = new byte[] { 1, 2, 3 }; _photoService.GetUserPhotoAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any(), Arg.Any()).Returns(Result(bytes)); + Arg.Any()).Returns(Result(bytes)); var result = await _controller.GetByMailId("mail@example.com", ct: TestContext.Current.CancellationToken); diff --git a/test/CMS/CmsUserPhotoServiceTests.cs b/test/CMS/CmsUserPhotoServiceTests.cs index a352ab603..f0545d6f0 100644 --- a/test/CMS/CmsUserPhotoServiceTests.cs +++ b/test/CMS/CmsUserPhotoServiceTests.cs @@ -13,6 +13,11 @@ namespace Viper.test.CMS; /// Tests for CmsUserPhotoService: AAUD id resolution (MailId, LoginId, IamId, MothraId), /// alternate ProfilePhotos lookup with traversal protection, delegation to the Students photo /// pipeline, and the Last-Modified timestamp used for conditional-caching (FIX 4). +/// GetUserPhotoAsync (the primary/id-card photo) always returns a result, falling back to the +/// default "no picture" image, which makes it the right choice for most places a photo is +/// displayed. GetAlternatePhotoAsync has no such fallback: a missing/rejected alt photo returns +/// null rather than the primary id-card photo, so a caller can tell "no alt photo" apart from a +/// successful lookup instead of the two being indistinguishable. /// public sealed class CmsUserPhotoServiceTests : IDisposable { @@ -82,7 +87,7 @@ private void SeedUser(string loginId = "jdoe", string mailId = "jdoe", string ia [Fact] public async Task GetUserPhoto_ByMailId_DelegatesToStudentPipeline() { - var photo = await _service.GetUserPhotoAsync("jdoe", null, null, null, preferAltPhoto: false, + var photo = await _service.GetUserPhotoAsync("jdoe", null, null, null, TestContext.Current.CancellationToken); Assert.Equal(IdCardPhotoBytes, photo.Bytes); @@ -94,7 +99,7 @@ public async Task GetUserPhoto_ByLoginId_ResolvesMailIdThroughAaud() { SeedUser(loginId: "jdoe", mailId: "janedoe"); - await _service.GetUserPhotoAsync(null, "jdoe", null, null, preferAltPhoto: false, + await _service.GetUserPhotoAsync(null, "jdoe", null, null, TestContext.Current.CancellationToken); await _photoService.Received(1).GetStudentPhotoAsync("janedoe"); @@ -105,7 +110,7 @@ public async Task GetUserPhoto_ByMothraId_ResolvesMailIdThroughAaud() { SeedUser(mothraId: "m-9001", mailId: "janedoe"); - await _service.GetUserPhotoAsync(null, null, null, "m-9001", preferAltPhoto: false, + await _service.GetUserPhotoAsync(null, null, null, "m-9001", TestContext.Current.CancellationToken); await _photoService.Received(1).GetStudentPhotoAsync("janedoe"); @@ -114,7 +119,7 @@ await _service.GetUserPhotoAsync(null, null, null, "m-9001", preferAltPhoto: fal [Fact] public async Task GetUserPhoto_UnknownMothraId_FallsBackToDefaultPipeline() { - var photo = await _service.GetUserPhotoAsync(null, null, null, "no-such-mothra", preferAltPhoto: false, + var photo = await _service.GetUserPhotoAsync(null, null, null, "no-such-mothra", TestContext.Current.CancellationToken); Assert.Equal(IdCardPhotoBytes, photo.Bytes); @@ -124,7 +129,7 @@ public async Task GetUserPhoto_UnknownMothraId_FallsBackToDefaultPipeline() [Fact] public async Task GetUserPhoto_UnknownLoginId_FallsBackToDefaultPipeline() { - var photo = await _service.GetUserPhotoAsync(null, "nosuchuser", null, null, preferAltPhoto: false, + var photo = await _service.GetUserPhotoAsync(null, "nosuchuser", null, null, TestContext.Current.CancellationToken); Assert.Equal(IdCardPhotoBytes, photo.Bytes); @@ -132,83 +137,134 @@ public async Task GetUserPhoto_UnknownLoginId_FallsBackToDefaultPipeline() } [Fact] - public async Task GetUserPhoto_PreferAltPhoto_ServesProfilePhoto() + public async Task GetUserPhoto_IdCardPhoto_LastModifiedIsStableAcrossCalls() + { + // The delegated Students pipeline exposes no per-file timestamp, so id-card/nopic + // responses use a stable per-process proxy; repeated calls must agree. + var first = await _service.GetUserPhotoAsync("jdoe", null, null, null, + TestContext.Current.CancellationToken); + var second = await _service.GetUserPhotoAsync("jdoe", null, null, null, + TestContext.Current.CancellationToken); + + Assert.Equal(first.LastModified, second.LastModified); + Assert.Equal(0, first.LastModified.Millisecond); + } + + [Fact] + public async Task GetAlternatePhoto_ServesProfilePhoto() { SeedUser(iamId: "1000999"); await File.WriteAllBytesAsync(Path.Join(_profilePhotoRoot, "1000999.jpg"), AltPhotoBytes, TestContext.Current.CancellationToken); - var photo = await _service.GetUserPhotoAsync(null, "jdoe", null, null, preferAltPhoto: true, + var photo = await _service.GetAlternatePhotoAsync(null, "jdoe", null, null, TestContext.Current.CancellationToken); + Assert.NotNull(photo); Assert.Equal(AltPhotoBytes, photo.Bytes); await _photoService.DidNotReceive().GetStudentPhotoAsync(Arg.Any()); } [Fact] - public async Task GetUserPhoto_ByMailIdPreferAltPhoto_ResolvesIamIdAndServesProfilePhoto() + public async Task GetAlternatePhoto_ByMailId_ResolvesIamIdAndServesProfilePhoto() { SeedUser(mailId: "jdoe", iamId: "1000999"); await File.WriteAllBytesAsync(Path.Join(_profilePhotoRoot, "1000999.jpg"), AltPhotoBytes, TestContext.Current.CancellationToken); - var photo = await _service.GetUserPhotoAsync("jdoe", null, null, null, preferAltPhoto: true, + var photo = await _service.GetAlternatePhotoAsync("jdoe", null, null, null, TestContext.Current.CancellationToken); + Assert.NotNull(photo); Assert.Equal(AltPhotoBytes, photo.Bytes); await _photoService.DidNotReceive().GetStudentPhotoAsync(Arg.Any()); } [Fact] - public async Task GetUserPhoto_PreferAltPhoto_MissingAltFallsBackToIdCard() + public async Task GetAlternatePhoto_Missing_ReturnsNull() { SeedUser(mailId: "janedoe", iamId: "1000999"); - var photo = await _service.GetUserPhotoAsync(null, "jdoe", null, null, preferAltPhoto: true, + var photo = await _service.GetAlternatePhotoAsync(null, "jdoe", null, null, TestContext.Current.CancellationToken); - Assert.Equal(IdCardPhotoBytes, photo.Bytes); - await _photoService.Received(1).GetStudentPhotoAsync("janedoe"); + // Unlike the primary id-card photo, the alternate photo has no placeholder fallback - + // a missing alt photo means "don't render this image", not "show the primary instead". + Assert.Null(photo); + await _photoService.DidNotReceive().GetStudentPhotoAsync(Arg.Any()); } [Theory] [InlineData("../1000999")] [InlineData("..\\secrets")] [InlineData("a/b")] - public async Task GetUserPhoto_TraversalShapedIamId_IgnoresAltPhoto(string iamId) + public async Task GetAlternatePhoto_TraversalShapedIamId_ReturnsNull(string iamId) { - var photo = await _service.GetUserPhotoAsync(null, null, iamId, null, preferAltPhoto: true, + var photo = await _service.GetAlternatePhotoAsync(null, null, iamId, null, TestContext.Current.CancellationToken); - Assert.Equal(IdCardPhotoBytes, photo.Bytes); + Assert.Null(photo); + await _photoService.DidNotReceive().GetStudentPhotoAsync(Arg.Any()); } [Fact] - public async Task GetUserPhoto_AltPhoto_LastModifiedReflectsFileWriteTime_TruncatedToWholeSeconds() + public async Task GetAlternatePhoto_LastModifiedReflectsFileWriteTime_TruncatedToWholeSeconds() { SeedUser(iamId: "1000999"); var photoPath = Path.Join(_profilePhotoRoot, "1000999.jpg"); await File.WriteAllBytesAsync(photoPath, AltPhotoBytes, TestContext.Current.CancellationToken); var fileWriteTime = File.GetLastWriteTimeUtc(photoPath); - var photo = await _service.GetUserPhotoAsync(null, "jdoe", null, null, preferAltPhoto: true, + var photo = await _service.GetAlternatePhotoAsync(null, "jdoe", null, null, TestContext.Current.CancellationToken); + Assert.NotNull(photo); Assert.Equal(0, photo.LastModified.Millisecond); Assert.True(Math.Abs((photo.LastModified.UtcDateTime - fileWriteTime).TotalSeconds) < 1.5); } [Fact] - public async Task GetUserPhoto_IdCardPhoto_LastModifiedIsStableAcrossCalls() + public async Task HasAlternatePhoto_ExistingFile_ReturnsTrue() { - // The delegated Students pipeline exposes no per-file timestamp, so id-card/nopic - // responses use a stable per-process proxy; repeated calls must agree. - var first = await _service.GetUserPhotoAsync("jdoe", null, null, null, preferAltPhoto: false, + await File.WriteAllBytesAsync(Path.Join(_profilePhotoRoot, "1000999.jpg"), AltPhotoBytes, TestContext.Current.CancellationToken); - var second = await _service.GetUserPhotoAsync("jdoe", null, null, null, preferAltPhoto: false, + + var hasPhoto = await _service.HasAlternatePhotoAsync("1000999", TestContext.Current.CancellationToken); - Assert.Equal(first.LastModified, second.LastModified); - Assert.Equal(0, first.LastModified.Millisecond); + Assert.True(hasPhoto); + } + + [Fact] + public async Task HasAlternatePhoto_MissingFile_ReturnsFalse() + { + var hasPhoto = await _service.HasAlternatePhotoAsync("no-such-iam", + TestContext.Current.CancellationToken); + + Assert.False(hasPhoto); + } + + [Fact] + public async Task HasAlternatePhoto_DoesNotReadFileBytes() + { + // Distinguishes this from GetAlternatePhotoAsync: it should answer from a plain + // existence check without going through the Students photo pipeline at all. + await File.WriteAllBytesAsync(Path.Join(_profilePhotoRoot, "1000999.jpg"), AltPhotoBytes, + TestContext.Current.CancellationToken); + + await _service.HasAlternatePhotoAsync("1000999", TestContext.Current.CancellationToken); + + await _photoService.DidNotReceive().GetStudentPhotoAsync(Arg.Any()); + } + + [Theory] + [InlineData("../1000999")] + [InlineData("..\\secrets")] + [InlineData("a/b")] + public async Task HasAlternatePhoto_TraversalShapedIamId_ReturnsFalse(string iamId) + { + var hasPhoto = await _service.HasAlternatePhotoAsync(iamId, TestContext.Current.CancellationToken); + + Assert.False(hasPhoto); } } diff --git a/test/Directory/DirectoryControllerTests.cs b/test/Directory/DirectoryControllerTests.cs index e73855ab6..21da87b67 100644 --- a/test/Directory/DirectoryControllerTests.cs +++ b/test/Directory/DirectoryControllerTests.cs @@ -21,8 +21,8 @@ public sealed class DirectoryControllerTests : IDisposable /// /// A trimmed AAUDContext mapping only AaudUser: the full AAUD warehouse model has /// computed columns and views SQLite EnsureCreated cannot build (same pattern as - /// StudentGroupServiceQueryTests). Current is a plain column here, so tests set it - /// directly instead of via the production computed column. + /// StudentGroupServiceQueryTests). Current and Future are plain columns here, so + /// tests set them directly instead of via the production computed columns. /// private sealed class TestAaudContext(DbContextOptions options) : AAUDContext(options) { @@ -59,7 +59,7 @@ public void Dispose() } [Fact] - public async Task SearchCurrentAaudUsers_MatchesNameAndEveryIdentifierField() + public async Task SearchCurrentOrFutureAaudUsers_MatchesNameAndEveryIdentifierField() { SeedUser(1, lastName: "Delfigo", firstName: "Ann"); SeedUser(2, lastName: "Berry", firstName: "Bo", mailId: "fig@ucdavis.edu"); @@ -72,52 +72,69 @@ public async Task SearchCurrentAaudUsers_MatchesNameAndEveryIdentifierField() SeedUser(9, lastName: "Ivy", firstName: "Ira"); await _context.SaveChangesAsync(TestContext.Current.CancellationToken); - var results = await DirectoryController.SearchCurrentAaudUsers(_context, "fig"); + var results = await DirectoryController.SearchCurrentOrFutureAaudUsers(_context, "fig"); // One match per field, none for user 9, ordered by last name Assert.Equal([2, 3, 4, 1, 5, 6, 7, 8], results.Select(u => u.AaudUserId)); } [Fact] - public async Task SearchCurrentAaudUsers_MatchesTermSpanningFirstAndLastName() + public async Task SearchCurrentOrFutureAaudUsers_MatchesTermSpanningFirstAndLastName() { SeedUser(1, lastName: "Graham", firstName: "Anna"); SeedUser(2, lastName: "Graham", firstName: "Steve"); await _context.SaveChangesAsync(TestContext.Current.CancellationToken); - var results = await DirectoryController.SearchCurrentAaudUsers(_context, "na Gra"); + var results = await DirectoryController.SearchCurrentOrFutureAaudUsers(_context, "na Gra"); Assert.Equal([1], results.Select(u => u.AaudUserId)); } [Fact] - public async Task SearchCurrentAaudUsers_ExcludesUsersNoLongerCurrent() + public async Task SearchCurrentOrFutureAaudUsers_ExcludesUsersNeitherCurrentNorFuture() { SeedUser(1, lastName: "Figworth", firstName: "Cur"); SeedUser(2, lastName: "Figworth", firstName: "Old", current: 0); await _context.SaveChangesAsync(TestContext.Current.CancellationToken); - var results = await DirectoryController.SearchCurrentAaudUsers(_context, "Figworth"); + var results = await DirectoryController.SearchCurrentOrFutureAaudUsers(_context, "Figworth"); Assert.Equal([1], results.Select(u => u.AaudUserId)); } [Fact] - public async Task SearchCurrentAaudUsers_OrdersByLastNameThenFirstName() + public async Task SearchCurrentOrFutureAaudUsers_IncludesFutureOnlyUsers() + { + // An admitted/registered student for an upcoming term (e.g. before Fall quarter + // starts) is Future but not yet Current - AAUD only flips Current on once the + // term is underway. The legacy ColdFusion directory and campus LDAP already show + // these people, so this search needs to as well. + SeedUser(1, lastName: "Figworth", firstName: "Incoming", current: 0, future: 1); + SeedUser(2, lastName: "Figworth", firstName: "Gone", current: 0, future: 0); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var results = await DirectoryController.SearchCurrentOrFutureAaudUsers(_context, "Figworth"); + + Assert.Equal([1], results.Select(u => u.AaudUserId)); + } + + [Fact] + public async Task SearchCurrentOrFutureAaudUsers_OrdersByLastNameThenFirstName() { SeedUser(1, lastName: "Fig", firstName: "Zoe"); SeedUser(2, lastName: "Fig", firstName: "Al"); SeedUser(3, lastName: "Elm", firstName: "Bea", mailId: "Fig@ucdavis.edu"); await _context.SaveChangesAsync(TestContext.Current.CancellationToken); - var results = await DirectoryController.SearchCurrentAaudUsers(_context, "Fig"); + var results = await DirectoryController.SearchCurrentOrFutureAaudUsers(_context, "Fig"); Assert.Equal([3, 2, 1], results.Select(u => u.AaudUserId)); } private void SeedUser(int id, string lastName, string firstName, string? mailId = null, string? loginId = null, string? spridenId = null, string? pidm = null, - string? mothraId = null, string? employeeId = null, string? iamId = null, int current = 1) + string? mothraId = null, string? employeeId = null, string? iamId = null, int current = 1, + int future = 0) { _context.AaudUsers.Add(new AaudUser { @@ -135,7 +152,8 @@ private void SeedUser(int id, string lastName, string firstName, string? mailId DisplayLastName = lastName, DisplayFirstName = firstName, DisplayFullName = firstName + " " + lastName, - Current = current + Current = current, + Future = future }); } } diff --git a/test/Services/UserInfoServiceTests.cs b/test/Services/UserInfoServiceTests.cs new file mode 100644 index 000000000..10aa0e743 --- /dev/null +++ b/test/Services/UserInfoServiceTests.cs @@ -0,0 +1,225 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using NSubstitute; +using System.Net; +using System.Text; +using Viper.Areas.CMS.Services; +using Viper.Areas.Directory.Models; +using Viper.Areas.Directory.Services; +using Viper.Classes.SQLContext; +using Viper.Models.AAUD; +using Viper.Models.Courses; + +namespace Viper.test.Services +{ + [Collection("HttpHelper static state")] + public class UserInfoServiceTests + { + private static readonly UserInfoViewPermissions AllPermissions = new() + { + IsOwnPage = true, + CanViewDirectoryDetail = true, + CanViewStudentID = true, + CanViewIAM = true, + CanViewRoles = true, + CanViewUCPath = true, + CanViewUCPathDetail = true, + CanViewIDCards = true, + CanViewKeys = true, + CanViewLoans = true, + CanViewInstinct = true, + CanViewADGroups = true + }; + + private readonly ITestOutputHelper _output; + + public UserInfoServiceTests(ITestOutputHelper output) + { + _output = output; + } + + private static DbContextOptions CreateInMemoryOptions() where TContext : DbContext + { + return new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + } + + [Fact] + public async Task TestGetUserInfo_WithSyntheticData() + { + _output.WriteLine("[DEBUG] Starting TestGetUserInfo with fully synthetic data..."); + + // 1. Arrange Config & Cache + var configData = new Dictionary + { + { "Instinct:ApiUrl", "https://synthetic.instinctvet.com/" }, + { "Credentials:InstinctApi", "synthetic-password" } + }; + var config = new ConfigurationBuilder() + .AddInMemoryCollection(configData) + .Build(); + + var memoryCache = new MemoryCache(new MemoryCacheOptions()); + + // 2. Mock HttpClientFactory + var mockHandler = new MockHttpMessageHandler(request => + { + var uri = request.RequestUri?.ToString() ?? ""; + if (uri.Contains("auth/token")) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"access_token\": \"synthetic-token\", \"expires_in\": 86400}", Encoding.UTF8, "application/json") + }; + } + if (uri.Contains("query=")) + { + var searchJson = @" + { + ""data"": { + ""searchUsers"": [ + { + ""id"": ""inst-synthetic-id"", + ""instinctId"": ""inst-synthetic-id"", + ""status"": ""Active"", + ""isActive"": true, + ""username"": ""jsmith"", + ""nameFirst"": ""John"", + ""nameLast"": ""Smith"", + ""roles"": [ + { ""label"": ""Staff"" } + ] + } + ] + } + }"; + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(searchJson, Encoding.UTF8, "application/json") + }; + } + return new HttpResponseMessage(HttpStatusCode.NotFound); + }); + + var httpClientFactory = Substitute.For(); + httpClientFactory.CreateClient(Arg.Any()).Returns(_ => new HttpClient(mockHandler)); + + // 3. Setup DbContexts and Seed Synthetic Data + var aaudOptions = CreateInMemoryOptions(); + var coursesOptions = CreateInMemoryOptions(); + + using (var aaudSetup = new AAUDContext(aaudOptions)) + { + var syntheticUser = new AaudUser + { + IamId = "99999999", + MothraId = "88888888", + MailId = "jsmith", + LoginId = "jsmith", + FirstName = "John", + LastName = "Smith", + DisplayFirstName = "John", + DisplayLastName = "Smith", + DisplayFullName = "John Smith", + Current = 1, + EmployeeId = "emp-999", + EmployeePKey = "pkey-999", + ClientId = "1" + }; + aaudSetup.AaudUsers.Add(syntheticUser); + + aaudSetup.Employees.Add(new Employee + { + EmpPKey = "pkey-999", + EmpTermCode = "202610", + EmpPrimaryTitle = "Synthetic Analyst", + EmpSchoolDivision = "Synthetic Division", + EmpStatus = "A", + EmpHomeDept = "Synthetic Dept", + EmpClientid = "1", + EmpAltDeptCode = "", + EmpCbuc = "" + }); + + await aaudSetup.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + using (var coursesSetup = new CoursesContext(coursesOptions)) + { + coursesSetup.Terminfos.Add(new Terminfo + { + TermCode = "202610", + TermCurrentTermMulti = true, + TermAcademicYear = "", + TermDesc = "", + TermCollCode = "01", + TermStartDate = DateTime.Today, + TermEndDate = DateTime.Today, + TermCurrentTerm = true, + TermTermType = "" + }); + await coursesSetup.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + using var aaud = new AAUDContext(aaudOptions); + using var raps = new RAPSContext(CreateInMemoryOptions()); + using var courses = new CoursesContext(coursesOptions); + using var loans = new EquipmentLoanContext(CreateInMemoryOptions()); + using var pps = new PPSContext(CreateInMemoryOptions()); + using var idcards = new IDCardsContext(CreateInMemoryOptions()); + using var keys = new KeysContext(CreateInMemoryOptions()); + using var sis = new SISContext(CreateInMemoryOptions()); + + var userInfoService = new UserInfoService(aaud, raps, courses, loans, pps, idcards, keys, sis, config, httpClientFactory, memoryCache, Substitute.For>(), Substitute.For()); + + // 4. Act + // Temporary HttpHelper configuration inside the test context + var mockEnv = Substitute.For(); + HttpHelper.Configure(memoryCache, config, mockEnv, null, null, null, null); + + UserInfoResult? result; + try + { + result = await userInfoService.GetUserInfoAsync("99999999", "88888888", AllPermissions); + } + finally + { + HttpHelper.Configure(null, null, null!, null, null, null, null); + } + + // 5. Assert + Assert.NotNull(result); + _output.WriteLine($"[DEBUG] Result IamId: {result.IamId}"); + _output.WriteLine($"[DEBUG] Result DisplayName: {result.DisplayFullName}"); + _output.WriteLine($"[DEBUG] Result InstinctId: {result.InstinctId}"); + _output.WriteLine($"[DEBUG] Result InstinctInfo.ErrorMessage: {result.InstinctInfo?.ErrorMessage}"); + _output.WriteLine($"[DEBUG] Result InstinctInfo.Valid: {result.InstinctInfo?.Valid}"); + + Assert.Equal("99999999", result.IamId); + Assert.Equal("88888888", result.MothraId); + Assert.Equal("John Smith", result.DisplayFullName); + Assert.True(result.IsEmployee); + Assert.Equal("Synthetic Analyst", result.EmployeePrimaryTitle); + Assert.Equal("inst-synthetic-id", result.InstinctId); + Assert.Equal("jsmith", result.InstinctUsername); + } + + private class MockHttpMessageHandler : HttpMessageHandler + { + private readonly Func _handler; + + public MockHttpMessageHandler(Func handler) + { + _handler = handler; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return Task.FromResult(_handler(request)); + } + } + } +} diff --git a/test/Services/UserInfoServiceUnitTests.cs b/test/Services/UserInfoServiceUnitTests.cs new file mode 100644 index 000000000..f677fad1d --- /dev/null +++ b/test/Services/UserInfoServiceUnitTests.cs @@ -0,0 +1,1335 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using MockQueryable.NSubstitute; +using NSubstitute; +using System.Net; +using System.Text; +using Viper.Areas.CMS.Services; +using Viper.Areas.Directory.Models; +using Viper.Areas.Directory.Services; +using Viper.Classes.SQLContext; +using Viper.Classes.Utilities; +using Viper.Models.AAUD; +using Viper.Models.EquipmentLoan; +using Viper.Models.IDCards; +using Viper.Models.Keys; +using Viper.Models.RAPS; +using Viper.Models.Courses; +using Viper.Models.PPS; + +namespace Viper.test.Services +{ + [Collection("HttpHelper static state")] + public class UserInfoServiceUnitTests + { + private static readonly UserInfoViewPermissions AllPermissions = new() + { + IsOwnPage = true, + CanViewDirectoryDetail = true, + CanViewStudentID = true, + CanViewIAM = true, + CanViewRoles = true, + CanViewUCPath = true, + CanViewUCPathDetail = true, + CanViewIDCards = true, + CanViewKeys = true, + CanViewLoans = true, + CanViewInstinct = true, + CanViewADGroups = true + }; + + private readonly IMemoryCache _memoryCache; + private readonly IConfiguration _configuration; + + public UserInfoServiceUnitTests() + { + _memoryCache = new MemoryCache(new MemoryCacheOptions()); + + var configData = new Dictionary + { + { "Instinct:ApiUrl", "https://uc-davis.api.instinctvet.com/" }, + { "Credentials:InstinctApi", "dummy-password" } + }; + _configuration = new ConfigurationBuilder() + .AddInMemoryCollection(configData) + .Build(); + } + + // Helper to construct DbContextOptions for InMemory providers + private static DbContextOptions CreateInMemoryOptions() where TContext : DbContext + { + return new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + } + + // Helper to construct a mocked IHttpClientFactory using custom handler + private static IHttpClientFactory CreateMockHttpClientFactory(Func handlerFunc) + { + var factory = Substitute.For(); + factory.CreateClient(Arg.Any()).Returns(_ => new HttpClient(new MockHttpMessageHandler(handlerFunc))); + return factory; + } + + private AaudUser CreateTestUser( + string iamId, + string mothraId, + string loginId = "testuser", + string? employeeId = null, + string? employeePKey = null, + string? studentPKey = null, + string? firstName = "Jane", + string? lastName = "Doe", + string? middleName = null, + string? displayFullName = "Jane Doe", + string? pidm = null) + { + return new AaudUser + { + IamId = iamId, + MothraId = mothraId, + MailId = loginId, + LoginId = loginId, + EmployeeId = employeeId, + EmployeePKey = employeePKey, + StudentPKey = studentPKey, + Current = 1, + ClientId = "1", + FirstName = firstName ?? "Jane", + LastName = lastName ?? "Doe", + MiddleName = middleName, + DisplayFirstName = firstName ?? "Jane", + DisplayLastName = lastName ?? "Doe", + DisplayFullName = displayFullName ?? "Jane Doe", + Pidm = pidm + }; + } + + [Fact] + public async Task GetUserInfoAsync_UserNotFound_ReturnsNull() + { + // Arrange + using var aaud = new AAUDContext(CreateInMemoryOptions()); + using var raps = new RAPSContext(CreateInMemoryOptions()); + using var courses = new CoursesContext(CreateInMemoryOptions()); + using var loans = new EquipmentLoanContext(CreateInMemoryOptions()); + using var pps = new PPSContext(CreateInMemoryOptions()); + using var idcards = new IDCardsContext(CreateInMemoryOptions()); + using var keys = new KeysContext(CreateInMemoryOptions()); + + var httpFactory = CreateMockHttpClientFactory(_ => new HttpResponseMessage(HttpStatusCode.NotFound)); + + var service = new UserInfoService(aaud, raps, courses, loans, pps, idcards, keys, _configuration, httpFactory, _memoryCache, Substitute.For>(), Substitute.For()); + + // Act + var result = await service.GetUserInfoAsync("non-existent-iam", "non-existent-mothra", AllPermissions); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task GetUserInfoAsync_UserFoundByIamId_MapsProperties() + { + // Arrange + var aaudOptions = CreateInMemoryOptions(); + using (var aaudSetup = new AAUDContext(aaudOptions)) + { + aaudSetup.AaudUsers.Add(CreateTestUser("iam-123", "mothra-123", employeeId: "emp-123", pidm: "pidm-123", displayFullName: "Test User")); + await aaudSetup.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + using var aaud = new AAUDContext(aaudOptions); + using var raps = new RAPSContext(CreateInMemoryOptions()); + using var courses = new CoursesContext(CreateInMemoryOptions()); + using var loans = new EquipmentLoanContext(CreateInMemoryOptions()); + using var pps = new PPSContext(CreateInMemoryOptions()); + using var idcards = new IDCardsContext(CreateInMemoryOptions()); + using var keys = new KeysContext(CreateInMemoryOptions()); + + var httpFactory = CreateMockHttpClientFactory(_ => new HttpResponseMessage(HttpStatusCode.NotFound)); + + var service = new UserInfoService(aaud, raps, courses, loans, pps, idcards, keys, _configuration, httpFactory, _memoryCache, Substitute.For>(), Substitute.For()); + + // Act + var result = await service.GetUserInfoAsync("iam-123", null, AllPermissions); + + // Assert + Assert.NotNull(result); + Assert.Equal("iam-123", result.IamId); + Assert.Equal("mothra-123", result.MothraId); + Assert.Equal("Test User", result.DisplayFullName); + Assert.True(result.CurrentAffiliate); + } + + [Fact] + public async Task GetUserInfoAsync_CanViewDirectoryDetail_SetsHasAltPhotoFromPhotoService() + { + // Arrange + var aaudOptions = CreateInMemoryOptions(); + using (var aaudSetup = new AAUDContext(aaudOptions)) + { + aaudSetup.AaudUsers.Add(CreateTestUser("iam-altphoto", "mothra-altphoto")); + await aaudSetup.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + using var aaud = new AAUDContext(aaudOptions); + using var raps = new RAPSContext(CreateInMemoryOptions()); + using var courses = new CoursesContext(CreateInMemoryOptions()); + using var loans = new EquipmentLoanContext(CreateInMemoryOptions()); + using var pps = new PPSContext(CreateInMemoryOptions()); + using var idcards = new IDCardsContext(CreateInMemoryOptions()); + using var keys = new KeysContext(CreateInMemoryOptions()); + + var httpFactory = CreateMockHttpClientFactory(_ => new HttpResponseMessage(HttpStatusCode.NotFound)); + var photoService = Substitute.For(); + photoService.HasAlternatePhotoAsync("iam-altphoto", Arg.Any()).Returns(true); + + var service = new UserInfoService(aaud, raps, courses, loans, pps, idcards, keys, _configuration, httpFactory, _memoryCache, Substitute.For>(), photoService); + + // Act + var result = await service.GetUserInfoAsync("iam-altphoto", null, AllPermissions); + + // Assert + Assert.NotNull(result); + Assert.True(result.HasAltPhoto); + } + + [Fact] + public async Task GetUserInfoAsync_CannotViewDirectoryDetail_SkipsAltPhotoCheck() + { + // Arrange + var aaudOptions = CreateInMemoryOptions(); + using (var aaudSetup = new AAUDContext(aaudOptions)) + { + aaudSetup.AaudUsers.Add(CreateTestUser("iam-nodetail", "mothra-nodetail")); + await aaudSetup.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + using var aaud = new AAUDContext(aaudOptions); + using var raps = new RAPSContext(CreateInMemoryOptions()); + using var courses = new CoursesContext(CreateInMemoryOptions()); + using var loans = new EquipmentLoanContext(CreateInMemoryOptions()); + using var pps = new PPSContext(CreateInMemoryOptions()); + using var idcards = new IDCardsContext(CreateInMemoryOptions()); + using var keys = new KeysContext(CreateInMemoryOptions()); + + var httpFactory = CreateMockHttpClientFactory(_ => new HttpResponseMessage(HttpStatusCode.NotFound)); + var photoService = Substitute.For(); + photoService.HasAlternatePhotoAsync(Arg.Any(), Arg.Any()).Returns(true); + + var service = new UserInfoService(aaud, raps, courses, loans, pps, idcards, keys, _configuration, httpFactory, _memoryCache, Substitute.For>(), photoService); + var permissions = new UserInfoViewPermissions { CanViewDirectoryDetail = false }; + + // Act + var result = await service.GetUserInfoAsync("iam-nodetail", null, permissions); + + // Assert - permissions gate the fetch itself, so a requester without directory-detail + // access shouldn't trigger a photo-service call at all (matching the pattern the other + // Populate*Async calls already follow). + Assert.NotNull(result); + Assert.False(result.HasAltPhoto); + await photoService.DidNotReceive().HasAlternatePhotoAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task PopulateEmployeeInfoAsync_PopulatesDetails() + { + // Arrange + var aaudOptions = CreateInMemoryOptions(); + var coursesOptions = CreateInMemoryOptions(); + + using (var aaudSetup = new AAUDContext(aaudOptions)) + { + aaudSetup.AaudUsers.Add(CreateTestUser("iam-emp", "mothra-emp", employeeId: "emp-789", employeePKey: "101")); + aaudSetup.Employees.Add(new Employee + { + EmpPKey = "101", + EmpTermCode = "202610", + EmpPrimaryTitle = "Senior Developer", + EmpSchoolDivision = "SVM Dean's Office", + EmpStatus = "A", + EmpHomeDept = "SVM Dean", + EmpEffortHomeDept = "SVM Effort Dept", + EmpTeachingHomeDept = "SVM Teaching Dept", + EmpTeachingPercentFulltime = 85, + EmpClientid = "1", + EmpAltDeptCode = "", + EmpCbuc = "" + }); + await aaudSetup.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + using (var coursesSetup = new CoursesContext(coursesOptions)) + { + coursesSetup.Terminfos.Add(new Terminfo + { + TermCode = "202610", + TermCurrentTermMulti = true, + TermAcademicYear = "", + TermDesc = "", + TermCollCode = "01", + TermStartDate = DateTime.Today, + TermEndDate = DateTime.Today, + TermCurrentTerm = true, + TermTermType = "" + }); + await coursesSetup.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + using var aaud = new AAUDContext(aaudOptions); + using var raps = new RAPSContext(CreateInMemoryOptions()); + using var courses = new CoursesContext(coursesOptions); + using var loans = new EquipmentLoanContext(CreateInMemoryOptions()); + using var pps = new PPSContext(CreateInMemoryOptions()); + using var idcards = new IDCardsContext(CreateInMemoryOptions()); + using var keys = new KeysContext(CreateInMemoryOptions()); + + var httpFactory = CreateMockHttpClientFactory(_ => new HttpResponseMessage(HttpStatusCode.NotFound)); + var service = new UserInfoService(aaud, raps, courses, loans, pps, idcards, keys, _configuration, httpFactory, _memoryCache, Substitute.For>(), Substitute.For()); + + // Act + var result = await service.GetUserInfoAsync("iam-emp", null, AllPermissions); + + // Assert + Assert.NotNull(result); + Assert.True(result.IsEmployee); + Assert.Equal("Senior Developer", result.EmployeePrimaryTitle); + Assert.Equal("SVM Dean's Office", result.EmployeeSchoolDivision); + Assert.Equal("A", result.EmployeeStatus); + Assert.Equal("202610", result.EmployeeTerm); + Assert.Equal("SVM Dean", result.EmployeeHomeDepartment); + Assert.Equal("SVM Effort Dept", result.EmployeeEffortHomeDepartment); + Assert.Equal("SVM Teaching Dept", result.EmployeeTeachingHomeDepartment); + Assert.Equal("85", result.EmployeeTeachingPercentFulltime); + } + + [Fact] + public async Task PopulateIDCardsAsync_ExecutesStatusAndReasonJoins() + { + // Arrange + var aaudOptions = CreateInMemoryOptions(); + using (var aaudSetup = new AAUDContext(aaudOptions)) + { + aaudSetup.AaudUsers.Add(CreateTestUser("iam-cards", "mothra-cards", "carduser")); + await aaudSetup.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + var idcardsOptions = CreateInMemoryOptions(); + using (var idcardsSetup = new IDCardsContext(idcardsOptions)) + { + idcardsSetup.IdCards.Add(new IdCard + { + IdCardLoginId = "carduser", + IdCardNumber = 987654321, + IdCardDisplayName = "CardDisplayName", + IdCardLastName = "CardLastName", + IdCardLine2 = "Line 2 Text", + IdCardCurrentStatus = "A", + IdcardDeactivatedReason = "L", + IdCardAppliedDate = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Unspecified), + IdCardIssueDate = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Unspecified), + IdcardDeactivatedDate = new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Unspecified) + }); + idcardsSetup.DvtCardStatuses.Add(new DvtCardStatus + { + DvtStatusCode = "A", + DvtStatusDesc = "Active Card Status", + DvtStatusVoidable = "", + DvtStatusDupOk = "" + }); + idcardsSetup.DvtReasons.Add(new DvtReason + { + DvtReasonCode = "L", + DvtReasonDesc = "Lost Card", + DvtReasonVoidable = "", + DvtReasonDupOk = "" + }); + await idcardsSetup.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + using var aaud = new AAUDContext(aaudOptions); + using var raps = new RAPSContext(CreateInMemoryOptions()); + using var courses = new CoursesContext(CreateInMemoryOptions()); + using var loans = new EquipmentLoanContext(CreateInMemoryOptions()); + using var pps = new PPSContext(CreateInMemoryOptions()); + using var idcards = new IDCardsContext(idcardsOptions); + using var keys = new KeysContext(CreateInMemoryOptions()); + + var httpFactory = CreateMockHttpClientFactory(_ => new HttpResponseMessage(HttpStatusCode.NotFound)); + var service = new UserInfoService(aaud, raps, courses, loans, pps, idcards, keys, _configuration, httpFactory, _memoryCache, Substitute.For>(), Substitute.For()); + + // Act + var result = await service.GetUserInfoAsync("iam-cards", null, AllPermissions); + + // Assert + Assert.NotNull(result); + Assert.Single(result.IDCards); + var card = result.IDCards[0]; + Assert.Equal("987654321", card.Number); + Assert.Equal("CardDisplayName", card.DisplayName); + Assert.Equal("CardLastName", card.LastName); + Assert.Equal("Line 2 Text", card.Line2); + Assert.Equal("Active Card Status", card.StatusDescription); + Assert.Equal("Lost Card", card.DeactivatedReason); + Assert.Equal(new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Unspecified), card.Applied); + Assert.Equal(new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Unspecified), card.Issued); + Assert.Equal(new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Unspecified), card.Deactivated); + } + + [Fact] + public async Task PopulateKeysAsync_MapsKeyDetails() + { + // Arrange + var aaudOptions = CreateInMemoryOptions(); + using (var aaudSetup = new AAUDContext(aaudOptions)) + { + aaudSetup.AaudUsers.Add(CreateTestUser("iam-keys", "mothra-keys")); + aaudSetup.AaudUsers.Add(CreateTestUser("iam-issuer", "mothra-issuer", "issuer", displayFullName: "John KeyIssuer")); + await aaudSetup.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + var keysOptions = CreateInMemoryOptions(); + using (var keysSetup = new KeysContext(keysOptions)) + { + keysSetup.Keys.Add(new Key + { + KeyId = 202, + KeyNumber = "K99", + AccessDescription = "Main Gate Access", + CreatedBy = "system" + }); + keysSetup.KeyAssignments.Add(new KeyAssignment + { + KeyId = 202, + AssignedTo = "mothra-keys", + CutNumber = "C1", + IssuedDate = new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Unspecified), + IssuedBy = "mothra-issuer", + Deleted = null + }); + await keysSetup.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + using var aaud = new AAUDContext(aaudOptions); + using var raps = new RAPSContext(CreateInMemoryOptions()); + using var courses = new CoursesContext(CreateInMemoryOptions()); + using var loans = new EquipmentLoanContext(CreateInMemoryOptions()); + using var pps = new PPSContext(CreateInMemoryOptions()); + using var idcards = new IDCardsContext(CreateInMemoryOptions()); + using var keys = new KeysContext(keysOptions); + + var httpFactory = CreateMockHttpClientFactory(_ => new HttpResponseMessage(HttpStatusCode.NotFound)); + var service = new UserInfoService(aaud, raps, courses, loans, pps, idcards, keys, _configuration, httpFactory, _memoryCache, Substitute.For>(), Substitute.For()); + + // Act + var result = await service.GetUserInfoAsync("iam-keys", null, AllPermissions); + + // Assert + Assert.NotNull(result); + Assert.Single(result.Keys); + var assignment = result.Keys[0]; + Assert.Equal("Main Gate Access", assignment.AccessDescription); + Assert.Equal("K99", assignment.KeyNumber); + Assert.Equal("C1", assignment.CutNumber); + Assert.Equal(new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Unspecified), assignment.IssuedDate); + Assert.Equal("John KeyIssuer", assignment.IssuedBy); + } + + [Fact] + public async Task PopulateLoansAsync_MapsLoanDetails() + { + // Arrange + var aaudOptions = CreateInMemoryOptions(); + using (var aaudSetup = new AAUDContext(aaudOptions)) + { + aaudSetup.AaudUsers.Add(CreateTestUser("iam-loans", "mothra-loans", pidm: "pidm-loans")); + await aaudSetup.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + var loansOptions = CreateInMemoryOptions(); + using (var loansSetup = new EquipmentLoanContext(loansOptions)) + { + var newLoan = new Loan + { + LoanId = 505, + LoanPidm = "pidm-loans", + LoanTechPidm = "tech-pidm-loans", + LoanDate = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Unspecified), + LoanDueDate = new DateTime(2026, 5, 10, 0, 0, 0, DateTimeKind.Unspecified), + LoanComments = "Projector Loan" + }; + loansSetup.Loans.Add(newLoan); + + var asset = new Asset + { + AssetId = 808, + AssetName = "Epson Projector 4K", + AssetStatus = "A" + }; + loansSetup.Assets.Add(asset); + + loansSetup.LoanItems.Add(new LoanItem + { + LoanitemLoanid = 505, + LoanitemAssetid = 808, + LoanitemAsset = asset, + LoanitemCheckout = DateTime.Today, + LoanitemCheckoutPidm = "checkout-pidm" + }); + + await loansSetup.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + using var aaud = new AAUDContext(aaudOptions); + using var raps = new RAPSContext(CreateInMemoryOptions()); + using var courses = new CoursesContext(CreateInMemoryOptions()); + using var loans = new EquipmentLoanContext(loansOptions); + using var pps = new PPSContext(CreateInMemoryOptions()); + using var idcards = new IDCardsContext(CreateInMemoryOptions()); + using var keys = new KeysContext(CreateInMemoryOptions()); + + var httpFactory = CreateMockHttpClientFactory(_ => new HttpResponseMessage(HttpStatusCode.NotFound)); + var service = new UserInfoService(aaud, raps, courses, loans, pps, idcards, keys, _configuration, httpFactory, _memoryCache, Substitute.For>(), Substitute.For()); + + // Act + var result = await service.GetUserInfoAsync("iam-loans", null, AllPermissions); + + // Assert + Assert.NotNull(result); + Assert.Single(result.Loans); + var loanResult = result.Loans[0]; + Assert.Equal("Epson Projector 4K", loanResult.AssetName); + Assert.Equal(new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Unspecified), loanResult.LoanDate); + Assert.Equal(new DateTime(2026, 5, 10, 0, 0, 0, DateTimeKind.Unspecified), loanResult.DueDate); + Assert.Equal("Projector Loan", loanResult.Comments); + } + + [Fact] + public async Task PopulateSystemRolesAndPermissions_QueriesCorrectly() + { + // Arrange + var aaudOptions = CreateInMemoryOptions(); + using (var aaudSetup = new AAUDContext(aaudOptions)) + { + aaudSetup.AaudUsers.Add(CreateTestUser("iam-raps", "mothra-raps")); + await aaudSetup.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + var rapsOptions = CreateInMemoryOptions(); + using (var rapsSetup = new RAPSContext(rapsOptions)) + { + var role = new TblRole + { + RoleId = 10, + Role = "CN=DirectoryAdmin,OU=Roles,DC=viper", + DisplayName = "DirectoryAdmin" + }; + rapsSetup.TblRoles.Add(role); + + rapsSetup.TblRoleMembers.Add(new TblRoleMember + { + RoleId = 10, + MemberId = "mothra-raps", + Role = role, + ViewName = null + }); + + var perm1 = new TblPermission + { + PermissionId = 50, + Permission = "RAPS.User.View" + }; + var perm2 = new TblPermission + { + PermissionId = 60, + Permission = "API.Data.Read" + }; + rapsSetup.TblPermissions.AddRange(perm1, perm2); + + // Assign perm1 via role + rapsSetup.TblRolePermissions.Add(new TblRolePermission + { + RoleId = 10, + PermissionId = 50, + Access = 1 + }); + + // Assign perm2 directly + rapsSetup.TblMemberPermissions.Add(new TblMemberPermission + { + MemberId = "mothra-raps", + PermissionId = 60, + Access = 1, + StartDate = null, + EndDate = null + }); + + await rapsSetup.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + using var aaud = new AAUDContext(aaudOptions); + using var raps = new RAPSContext(rapsOptions); + using var courses = new CoursesContext(CreateInMemoryOptions()); + using var loans = new EquipmentLoanContext(CreateInMemoryOptions()); + using var pps = new PPSContext(CreateInMemoryOptions()); + using var idcards = new IDCardsContext(CreateInMemoryOptions()); + using var keys = new KeysContext(CreateInMemoryOptions()); + + var httpFactory = CreateMockHttpClientFactory(_ => new HttpResponseMessage(HttpStatusCode.NotFound)); + var service = new UserInfoService(aaud, raps, courses, loans, pps, idcards, keys, _configuration, httpFactory, _memoryCache, Substitute.For>(), Substitute.For()); + + // Act + var result = await service.GetUserInfoAsync("iam-raps", null, AllPermissions); + + // Assert + Assert.NotNull(result); + + // Check formatted Roles + Assert.Single(result.SystemRoles); + Assert.Equal("VIPER", result.SystemRoles[0].System); + Assert.Equal("DirectoryAdmin", result.SystemRoles[0].DisplayName); + + // Check permissions categories + var rapsPermCategory = result.SystemPermissions.FirstOrDefault(p => p.Category == "RAPS"); + Assert.NotNull(rapsPermCategory); + Assert.Equal(1, rapsPermCategory.Count); + Assert.Equal("RAPS.User.View", rapsPermCategory.Permissions[0]); + + var apiPermCategory = result.SystemPermissions.FirstOrDefault(p => p.Category == "API"); + Assert.NotNull(apiPermCategory); + Assert.Equal(1, apiPermCategory.Count); + Assert.Equal("API.Data.Read", apiPermCategory.Permissions[0]); + } + + [Fact] + public async Task PopulateSystemRolesAsync_IncludesViewAssignedRoles() + { + // Arrange - a role membership added by a RAPS population view (e.g. a student + // roster view) should still show up on the UserInfo page, not just roles that + // were assigned to the individual directly. + var aaudOptions = CreateInMemoryOptions(); + using (var aaudSetup = new AAUDContext(aaudOptions)) + { + aaudSetup.AaudUsers.Add(CreateTestUser("iam-student", "mothra-student")); + await aaudSetup.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + var rapsOptions = CreateInMemoryOptions(); + using (var rapsSetup = new RAPSContext(rapsOptions)) + { + var role = new TblRole + { + RoleId = 20, + Role = "CN=Student,OU=Roles,DC=viper", + DisplayName = "Student" + }; + rapsSetup.TblRoles.Add(role); + + rapsSetup.TblRoleMembers.Add(new TblRoleMember + { + RoleId = 20, + MemberId = "mothra-student", + Role = role, + ViewName = "VwStudentsDvm" + }); + + await rapsSetup.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + using var aaud = new AAUDContext(aaudOptions); + using var raps = new RAPSContext(rapsOptions); + using var courses = new CoursesContext(CreateInMemoryOptions()); + using var loans = new EquipmentLoanContext(CreateInMemoryOptions()); + using var pps = new PPSContext(CreateInMemoryOptions()); + using var idcards = new IDCardsContext(CreateInMemoryOptions()); + using var keys = new KeysContext(CreateInMemoryOptions()); + + var httpFactory = CreateMockHttpClientFactory(_ => new HttpResponseMessage(HttpStatusCode.NotFound)); + var service = new UserInfoService(aaud, raps, courses, loans, pps, idcards, keys, _configuration, httpFactory, _memoryCache, Substitute.For>(), Substitute.For()); + + // Act + var result = await service.GetUserInfoAsync("iam-student", null, AllPermissions); + + // Assert + Assert.NotNull(result); + Assert.Single(result.SystemRoles); + Assert.Equal("VIPER", result.SystemRoles[0].System); + Assert.Equal("Student", result.SystemRoles[0].DisplayName); + } + + [Fact] + public async Task PopulateSystemRolesAsync_ExcludesExpiredOrNotYetStartedRoleMemberships() + { + // Arrange + var aaudOptions = CreateInMemoryOptions(); + using (var aaudSetup = new AAUDContext(aaudOptions)) + { + aaudSetup.AaudUsers.Add(CreateTestUser("iam-expired", "mothra-expired")); + await aaudSetup.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + var rapsOptions = CreateInMemoryOptions(); + using (var rapsSetup = new RAPSContext(rapsOptions)) + { + var expiredRole = new TblRole + { + RoleId = 30, + Role = "CN=Expired,OU=Roles,DC=viper", + DisplayName = "Expired" + }; + var futureRole = new TblRole + { + RoleId = 31, + Role = "CN=Future,OU=Roles,DC=viper", + DisplayName = "Future" + }; + var activeRole = new TblRole + { + RoleId = 32, + Role = "CN=Active,OU=Roles,DC=viper", + DisplayName = "Active" + }; + rapsSetup.TblRoles.AddRange(expiredRole, futureRole, activeRole); + + rapsSetup.TblRoleMembers.Add(new TblRoleMember + { + RoleId = 30, + MemberId = "mothra-expired", + Role = expiredRole, + EndDate = DateTime.Today.AddDays(-1) + }); + rapsSetup.TblRoleMembers.Add(new TblRoleMember + { + RoleId = 31, + MemberId = "mothra-expired", + Role = futureRole, + StartDate = DateTime.Today.AddDays(1) + }); + rapsSetup.TblRoleMembers.Add(new TblRoleMember + { + RoleId = 32, + MemberId = "mothra-expired", + Role = activeRole, + StartDate = DateTime.Today.AddDays(-1), + EndDate = DateTime.Today.AddDays(1) + }); + + await rapsSetup.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + using var aaud = new AAUDContext(aaudOptions); + using var raps = new RAPSContext(rapsOptions); + using var courses = new CoursesContext(CreateInMemoryOptions()); + using var loans = new EquipmentLoanContext(CreateInMemoryOptions()); + using var pps = new PPSContext(CreateInMemoryOptions()); + using var idcards = new IDCardsContext(CreateInMemoryOptions()); + using var keys = new KeysContext(CreateInMemoryOptions()); + + var httpFactory = CreateMockHttpClientFactory(_ => new HttpResponseMessage(HttpStatusCode.NotFound)); + var service = new UserInfoService(aaud, raps, courses, loans, pps, idcards, keys, _configuration, httpFactory, _memoryCache, Substitute.For>(), Substitute.For()); + + // Act + var result = await service.GetUserInfoAsync("iam-expired", null, AllPermissions); + + // Assert - only the active role membership should show up + Assert.NotNull(result); + Assert.Single(result.SystemRoles); + Assert.Equal("Active", result.SystemRoles[0].DisplayName); + } + + [Fact] + public async Task PopulateUCPathHistoryAsync_UsesJobEffectiveDateNotPositionEffectiveDate() + { + // Arrange + var aaudOptions = CreateInMemoryOptions(); + using (var aaudSetup = new AAUDContext(aaudOptions)) + { + aaudSetup.AaudUsers.Add(CreateTestUser("iam-ucpath", "mothra-ucpath", employeeId: "emp-ucpath")); + await aaudSetup.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + // Effdt (the job record's own effective date) and PositionEffdt (the position + // record's effective date) are deliberately different, so the test fails if the + // history row ever goes back to showing the position's date instead of the job's. + var historyRows = new List + { + new() + { + Emplid = "emp-ucpath", + Name = "Jane Doe", + LastName = "Doe", + FirstName = "Jane", + Jobcode = "001234", + JobcodeDesc = "Lecturer", + JobcodeShortDesc = "Lect", + Jobgroup = "G1", + JobgroupDesc = "Group 1", + FlsaStatus = "E", + JobcodeUnionCode = "99", + Deptid = "062000", + DeptDesc = "VetMed Dean", + DeptCd = "062000", + DeptShortDesc = "VM Dean", + SubDivCd = "01", + SubDivDesc = "SVM", + JobStatusDesc = "Active", + JobStatus1 = "A", + ActionDescr = "Pay Rate Change", + Effdt = new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Unspecified), + PositionEffdt = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Unspecified), + PositionNbr = null, + ReportsTo = null + } + }; + var mockHistorySet = historyRows.BuildMockDbSet(); + + using var aaud = new AAUDContext(aaudOptions); + using var raps = new RAPSContext(CreateInMemoryOptions()); + using var pps = Substitute.ForPartsOf(CreateInMemoryOptions()); + pps.VwPersonJobPositionAlls.Returns(mockHistorySet); + using var courses = new CoursesContext(CreateInMemoryOptions()); + using var loans = new EquipmentLoanContext(CreateInMemoryOptions()); + using var idcards = new IDCardsContext(CreateInMemoryOptions()); + using var keys = new KeysContext(CreateInMemoryOptions()); + + var httpFactory = CreateMockHttpClientFactory(_ => new HttpResponseMessage(HttpStatusCode.NotFound)); + var service = new UserInfoService(aaud, raps, courses, loans, pps, idcards, keys, _configuration, httpFactory, _memoryCache, Substitute.For>(), Substitute.For()); + + // Act + var result = await service.GetUserInfoAsync("iam-ucpath", null, AllPermissions); + + // Assert + Assert.NotNull(result); + Assert.Single(result.UCPathHistory); + Assert.Equal(new DateOnly(2026, 3, 1), result.UCPathHistory[0].JobEffectiveDate); + Assert.NotEqual(new DateOnly(2025, 1, 1), result.UCPathHistory[0].JobEffectiveDate); + } + + [Fact] + public async Task PopulateIamInfoAsync_CallsApiAndMapsCollections() + { + // Arrange + var aaudOptions = CreateInMemoryOptions(); + using (var aaudSetup = new AAUDContext(aaudOptions)) + { + aaudSetup.AaudUsers.Add(CreateTestUser("iam-caller", "mothra-caller")); + await aaudSetup.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + // Mock responses for SearchForPerson (iam/people/search) and GetEmployeeAssociations (iam/associations/pps/{iamId}) + var httpFactory = CreateMockHttpClientFactory(request => + { + var uri = request.RequestUri?.ToString() ?? ""; + if (uri.Contains("iam/people/search")) + { + var json = @" + { + ""responseStatus"": 0, + ""responseData"": { + ""results"": [ + { + ""iamId"": ""iam-caller"", + ""oFirstName"": ""Jane"", + ""oLastName"": ""Doe"", + ""oFullName"": ""Full Name From IAM"", + ""ppsId"": ""pps-111"", + ""isEmployee"": true, + ""isStudent"": false + } + ] + } + }"; + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + } + else if (uri.Contains("iam/associations/pps/iam-caller")) + { + var json = @" + { + ""responseStatus"": 0, + ""responseData"": { + ""results"": [ + { + ""iamId"": ""iam-caller"", + ""titleDisplayName"": ""Manager"", + ""titleCode"": ""001100"", + ""deptDisplayName"": ""VetMed Dean"", + ""deptCode"": ""062000"", + ""percentFullTime"": ""1.0"", + ""assocStartDate"": ""2026-01-01"" + } + ] + } + }"; + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + } + return new HttpResponseMessage(HttpStatusCode.NotFound); + }); + + using var aaud = new AAUDContext(aaudOptions); + using var raps = new RAPSContext(CreateInMemoryOptions()); + using var courses = new CoursesContext(CreateInMemoryOptions()); + using var loans = new EquipmentLoanContext(CreateInMemoryOptions()); + using var pps = new PPSContext(CreateInMemoryOptions()); + using var idcards = new IDCardsContext(CreateInMemoryOptions()); + using var keys = new KeysContext(CreateInMemoryOptions()); + + var service = new UserInfoService(aaud, raps, courses, loans, pps, idcards, keys, _configuration, httpFactory, _memoryCache, Substitute.For>(), Substitute.For()); + + // Act + var result = await service.GetUserInfoAsync("iam-caller", null, AllPermissions); + + // Assert + Assert.NotNull(result); + Assert.Equal("pps-111", result.PPSId); + Assert.Equal("Full Name From IAM", result.OFullName); + + // Check populated collections + Assert.Single(result.IamPeople); + Assert.Equal("Full Name From IAM", result.IamPeople[0].OFullName); + + Assert.Single(result.IamAssociations); + Assert.Equal("Manager", result.IamAssociations[0].TitleDisplayName); + Assert.Equal("VetMed Dean", result.IamAssociations[0].DeptDisplayName); + } + + [Fact] + public async Task PopulateInstinctInfoAsync_ResolvesEndpointAndQueriesGraphQL() + { + // Arrange + var aaudOptions = CreateInMemoryOptions(); + using (var aaudSetup = new AAUDContext(aaudOptions)) + { + aaudSetup.AaudUsers.Add(CreateTestUser("iam-inst", "mothra-inst", firstName: "Jane", lastName: "Doe", middleName: "Alex")); + await aaudSetup.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + // HTTP Mock for token request and graphql user lookup + var httpFactory = CreateMockHttpClientFactory(request => + { + var uri = request.RequestUri?.ToString() ?? ""; + if (uri.Contains("auth/token")) + { + var tokenJson = "{\"access_token\": \"jane-doe-token-key\", \"expires_in\": 86400}"; + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(tokenJson, Encoding.UTF8, "application/json") + }; + } + else if (uri.Contains("query=")) + { + var searchJson = @" + { + ""data"": { + ""searchUsers"": [ + { + ""id"": ""inst-jane-doe"", + ""instinctId"": ""inst-jane-doe"", + ""status"": ""Active"", + ""isActive"": true, + ""username"": ""jdoe"", + ""nameFirst"": ""Jane"", + ""nameLast"": ""Doe"", + ""roles"": [ + { ""label"": ""Doctor"" } + ] + } + ] + } + }"; + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(searchJson, Encoding.UTF8, "application/json") + }; + } + return new HttpResponseMessage(HttpStatusCode.NotFound); + }); + + using var aaud = new AAUDContext(aaudOptions); + using var raps = new RAPSContext(CreateInMemoryOptions()); + using var courses = new CoursesContext(CreateInMemoryOptions()); + using var loans = new EquipmentLoanContext(CreateInMemoryOptions()); + using var pps = new PPSContext(CreateInMemoryOptions()); + using var idcards = new IDCardsContext(CreateInMemoryOptions()); + using var keys = new KeysContext(CreateInMemoryOptions()); + + var service = new UserInfoService(aaud, raps, courses, loans, pps, idcards, keys, _configuration, httpFactory, _memoryCache, Substitute.For>(), Substitute.For()); + + // Act & Assert with temporary HttpHelper configuration + var mockEnv = Substitute.For(); + HttpHelper.Configure(_memoryCache, _configuration, mockEnv, null, null, null, null); + try + { + var result = await service.GetUserInfoAsync("iam-inst", null, AllPermissions); + + Assert.NotNull(result); + Assert.Null(result.InstinctInfo?.ErrorMessage); + Assert.Equal("inst-jane-doe", result.InstinctId); + Assert.Equal("jdoe", result.InstinctUsername); + Assert.Equal("Active", result.InstinctStatus); + Assert.True(result.InstinctIsActive); + Assert.Single(result.InstinctRoles); + Assert.Equal("Doctor", result.InstinctRoles[0]); + } + finally + { + HttpHelper.Configure(null, null, null!, null, null, null, null); + } + } + + [Fact] + public async Task PopulateInstinctInfoAsync_NullSearchUsersWithNoErrors_IsNotTreatedAsFailure() + { + // searchUsers is a nullable list in the schema, so a query that runs fine and simply + // finds nobody can come back as "searchUsers": null instead of an empty array - most + // VIPER users don't have an Instinct account, and that must not surface as an error. + var aaudOptions = CreateInMemoryOptions(); + using (var aaudSetup = new AAUDContext(aaudOptions)) + { + aaudSetup.AaudUsers.Add(CreateTestUser("iam-noinst", "mothra-noinst", firstName: "No", lastName: "Account")); + await aaudSetup.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + var httpFactory = CreateMockHttpClientFactory(request => + { + var uri = request.RequestUri?.ToString() ?? ""; + if (uri.Contains("auth/token")) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"access_token\": \"tok\", \"expires_in\": 86400}", Encoding.UTF8, "application/json") + }; + } + if (uri.Contains("query=")) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(@"{ ""data"": { ""searchUsers"": null } }", Encoding.UTF8, "application/json") + }; + } + return new HttpResponseMessage(HttpStatusCode.NotFound); + }); + + using var aaud = new AAUDContext(aaudOptions); + using var raps = new RAPSContext(CreateInMemoryOptions()); + using var courses = new CoursesContext(CreateInMemoryOptions()); + using var loans = new EquipmentLoanContext(CreateInMemoryOptions()); + using var pps = new PPSContext(CreateInMemoryOptions()); + using var idcards = new IDCardsContext(CreateInMemoryOptions()); + using var keys = new KeysContext(CreateInMemoryOptions()); + + var service = new UserInfoService(aaud, raps, courses, loans, pps, idcards, keys, _configuration, httpFactory, _memoryCache, Substitute.For>(), Substitute.For()); + + var mockEnv = Substitute.For(); + HttpHelper.Configure(_memoryCache, _configuration, mockEnv, null, null, null, null); + try + { + var result = await service.GetUserInfoAsync("iam-noinst", null, AllPermissions); + + Assert.NotNull(result); + Assert.Null(result.InstinctInfo?.ErrorMessage); + Assert.Null(result.InstinctId); + Assert.DoesNotContain("Instinct", result.UnavailableSections); + } + finally + { + HttpHelper.Configure(null, null, null!, null, null, null, null); + } + } + + [Fact] + public async Task PopulateInstinctInfoAsync_GraphQLErrors_IsTreatedAsFailure() + { + // Unlike a clean "no match", a populated "errors" array is GraphQL's own signal that + // the query itself failed server-side - that's a real failure and should surface as + // one, distinct from someone simply not having an Instinct account. + var aaudOptions = CreateInMemoryOptions(); + using (var aaudSetup = new AAUDContext(aaudOptions)) + { + aaudSetup.AaudUsers.Add(CreateTestUser("iam-gqlerr", "mothra-gqlerr", firstName: "Gql", lastName: "Error")); + await aaudSetup.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + var httpFactory = CreateMockHttpClientFactory(request => + { + var uri = request.RequestUri?.ToString() ?? ""; + if (uri.Contains("auth/token")) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"access_token\": \"tok\", \"expires_in\": 86400}", Encoding.UTF8, "application/json") + }; + } + if (uri.Contains("query=")) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + @"{ ""data"": null, ""errors"": [ { ""message"": ""Downstream service unavailable"" } ] }", + Encoding.UTF8, "application/json") + }; + } + return new HttpResponseMessage(HttpStatusCode.NotFound); + }); + + using var aaud = new AAUDContext(aaudOptions); + using var raps = new RAPSContext(CreateInMemoryOptions()); + using var courses = new CoursesContext(CreateInMemoryOptions()); + using var loans = new EquipmentLoanContext(CreateInMemoryOptions()); + using var pps = new PPSContext(CreateInMemoryOptions()); + using var idcards = new IDCardsContext(CreateInMemoryOptions()); + using var keys = new KeysContext(CreateInMemoryOptions()); + + var service = new UserInfoService(aaud, raps, courses, loans, pps, idcards, keys, _configuration, httpFactory, _memoryCache, Substitute.For>(), Substitute.For()); + + var mockEnv = Substitute.For(); + HttpHelper.Configure(_memoryCache, _configuration, mockEnv, null, null, null, null); + try + { + var result = await service.GetUserInfoAsync("iam-gqlerr", null, AllPermissions); + + Assert.NotNull(result); + Assert.NotNull(result.InstinctInfo); + Assert.NotNull(result.InstinctInfo.ErrorMessage); + Assert.Contains("Downstream service unavailable", result.InstinctInfo.ErrorMessage); + Assert.Null(result.InstinctId); + Assert.Contains("Instinct", result.UnavailableSections); + } + finally + { + HttpHelper.Configure(null, null, null!, null, null, null, null); + } + } + + [Theory] + [InlineData("Development")] + [InlineData("Test")] + public async Task PopulateInstinctInfoAsync_ApiUrlNotConfiguredOnNonProdEnvironment_IsNotTreatedAsFailure(string environmentName) + { + // Development and Test boxes aren't guaranteed to have the Instinct SSM parameters, + // so a missing Instinct:ApiUrl there is expected, not a failure - it should not + // surface as an "Instinct unavailable" error the way it still does on Production + // (covered by GetInstinctApiUrl's own fail-fast behavior, exercised indirectly by + // the other Instinct tests above, which all configure Instinct:ApiUrl). + var configWithoutInstinct = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + { "ASPNETCORE_ENVIRONMENT", environmentName } + // Deliberately no Instinct:ApiUrl. + }) + .Build(); + + var aaudOptions = CreateInMemoryOptions(); + using (var aaudSetup = new AAUDContext(aaudOptions)) + { + aaudSetup.AaudUsers.Add(CreateTestUser($"iam-noconfig-{environmentName}", $"mothra-noconfig-{environmentName}", firstName: "NoConfig", lastName: environmentName)); + await aaudSetup.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + var httpFactory = CreateMockHttpClientFactory(_ => + throw new InvalidOperationException("Should not call out to Instinct when ApiUrl isn't configured.")); + + using var aaud = new AAUDContext(aaudOptions); + using var raps = new RAPSContext(CreateInMemoryOptions()); + using var courses = new CoursesContext(CreateInMemoryOptions()); + using var loans = new EquipmentLoanContext(CreateInMemoryOptions()); + using var pps = new PPSContext(CreateInMemoryOptions()); + using var idcards = new IDCardsContext(CreateInMemoryOptions()); + using var keys = new KeysContext(CreateInMemoryOptions()); + + var service = new UserInfoService(aaud, raps, courses, loans, pps, idcards, keys, configWithoutInstinct, httpFactory, _memoryCache, Substitute.For>(), Substitute.For()); + + var mockEnv = Substitute.For(); + HttpHelper.Configure(_memoryCache, configWithoutInstinct, mockEnv, null, null, null, null); + try + { + var result = await service.GetUserInfoAsync($"iam-noconfig-{environmentName}", null, AllPermissions); + + Assert.NotNull(result); + Assert.Null(result.InstinctInfo?.ErrorMessage); + Assert.Null(result.InstinctId); + Assert.DoesNotContain("Instinct", result.UnavailableSections); + } + finally + { + HttpHelper.Configure(null, null, null!, null, null, null, null); + } + } + + [Fact] + public async Task PopulateInstinctInfoAsync_ApiUrlNotConfiguredOnProduction_IsTreatedAsFailure() + { + // Production is deliberately excluded from the Development/Test suppression - it's + // expected to always have Instinct configured, so a missing ApiUrl there is a real + // problem and should still surface as an "Instinct unavailable" error. + var prodConfigWithoutInstinct = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + { "ASPNETCORE_ENVIRONMENT", "Production" } + // Deliberately no Instinct:ApiUrl. + }) + .Build(); + + var aaudOptions = CreateInMemoryOptions(); + using (var aaudSetup = new AAUDContext(aaudOptions)) + { + aaudSetup.AaudUsers.Add(CreateTestUser("iam-prodnoconfig", "mothra-prodnoconfig", firstName: "Prod", lastName: "NoConfig")); + await aaudSetup.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + var httpFactory = CreateMockHttpClientFactory(_ => + throw new InvalidOperationException("Should not call out to Instinct when ApiUrl isn't configured.")); + + using var aaud = new AAUDContext(aaudOptions); + using var raps = new RAPSContext(CreateInMemoryOptions()); + using var courses = new CoursesContext(CreateInMemoryOptions()); + using var loans = new EquipmentLoanContext(CreateInMemoryOptions()); + using var pps = new PPSContext(CreateInMemoryOptions()); + using var idcards = new IDCardsContext(CreateInMemoryOptions()); + using var keys = new KeysContext(CreateInMemoryOptions()); + + var service = new UserInfoService(aaud, raps, courses, loans, pps, idcards, keys, prodConfigWithoutInstinct, httpFactory, _memoryCache, Substitute.For>(), Substitute.For()); + + var mockEnv = Substitute.For(); + HttpHelper.Configure(_memoryCache, prodConfigWithoutInstinct, mockEnv, null, null, null, null); + try + { + var result = await service.GetUserInfoAsync("iam-prodnoconfig", null, AllPermissions); + + Assert.NotNull(result); + Assert.NotNull(result.InstinctInfo); + Assert.NotNull(result.InstinctInfo.ErrorMessage); + Assert.Contains("Instinct:ApiUrl is not configured", result.InstinctInfo.ErrorMessage); + Assert.Null(result.InstinctId); + Assert.Contains("Instinct", result.UnavailableSections); + } + finally + { + HttpHelper.Configure(null, null, null!, null, null, null, null); + } + } + + [Fact] + public async Task TestGetEmployeeAssociationsDirectly() + { + var httpFactory = CreateMockHttpClientFactory(_ => + { + var json = @" + { + ""responseStatus"": 0, + ""responseData"": { + ""results"": [ + { + ""iamId"": ""iam-caller"", + ""titleDisplayName"": ""Manager"", + ""titleCode"": ""001100"", + ""deptDisplayName"": ""VetMed Dean"", + ""deptCode"": ""062000"", + ""percentFullTime"": ""1.0"", + ""assocStartDate"": ""2026-01-01"" + } + ] + } + }"; + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + }); + + var iamApi = new IamApi(httpFactory); + var response = await iamApi.GetEmployeeAssociations("iam-caller"); + Assert.Null(response.ErrorMessage); + Assert.NotNull(response.Data); + Assert.Single(response.Data); + } + + [Fact] + public async Task TestDeserializeCorePersonDirectly() + { + var httpFactory = CreateMockHttpClientFactory(_ => + { + var json = @" + { + ""responseStatus"": 0, + ""responseData"": { + ""results"": [ + { + ""iamId"": ""iam-caller"", + ""oFirstName"": ""Jane"", + ""oLastName"": ""Doe"", + ""oFullName"": ""Full Name From IAM"", + ""ppsId"": ""pps-111"", + ""isEmployee"": true, + ""isStudent"": false + } + ] + } + }"; + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + }); + + var iamApi = new IamApi(httpFactory); + var response = await iamApi.SearchForPerson(iamId: "iam-caller"); + Assert.Null(response.ErrorMessage); + Assert.NotNull(response.Data); + Assert.Single(response.Data); + } + + [Theory] + [InlineData("2026-07-13 14:00:00", 2026, 7, 13, 14, 0, 0)] + [InlineData("2026-07-13", 2026, 7, 13, 0, 0, 0)] + [InlineData("2026-07-13T14:00:00", 2026, 7, 13, 14, 0, 0)] + [InlineData("", 0, 0, 0, 0, 0, 0)] + [InlineData(null, 0, 0, 0, 0, 0, 0)] + public void TestIamDateTimeConverter(string? input, int expectedYear, int expectedMonth, int expectedDay, int expectedHour, int expectedMinute, int expectedSecond) + { + var options = new System.Text.Json.JsonSerializerOptions(); + options.Converters.Add(new IamDateTimeConverter()); + + if (input == null) + { + var result = System.Text.Json.JsonSerializer.Deserialize("null", options); + Assert.Null(result); + } + else if (string.IsNullOrEmpty(input)) + { + var result = System.Text.Json.JsonSerializer.Deserialize("\"\"", options); + Assert.Null(result); + } + else + { + var result = System.Text.Json.JsonSerializer.Deserialize($"\"{input}\"", options); + Assert.NotNull(result); + var nonNullResult = Assert.IsType(result); + Assert.Equal(new DateTime(expectedYear, expectedMonth, expectedDay, expectedHour, expectedMinute, expectedSecond, DateTimeKind.Unspecified), nonNullResult); + } + } + + private class MockHttpMessageHandler : HttpMessageHandler + { + private readonly Func _handler; + + public MockHttpMessageHandler(Func handler) + { + _handler = handler; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return Task.FromResult(_handler(request)); + } + } + } +} diff --git a/test/Usings.cs b/test/Usings.cs index c802f4480..6b00e38c5 100644 --- a/test/Usings.cs +++ b/test/Usings.cs @@ -1 +1,15 @@ global using Xunit; + +namespace Viper.test +{ + /// + /// Marks the tests that call HttpHelper.Configure (directly, or via IntegrationTestBase / + /// EffortIntegrationTestBase) as needing to run sequentially with each other, since + /// HttpHelper's cache/settings are static and get clobbered by concurrent test runs. + /// Other test classes are unaffected and keep running in parallel. + /// + [CollectionDefinition("HttpHelper static state", DisableParallelization = true)] + public class HttpHelperStaticStateCollection + { + } +} diff --git a/web/Areas/CMS/Controllers/CMSUserPhotoController.cs b/web/Areas/CMS/Controllers/CMSUserPhotoController.cs index 1a541b614..49406e492 100644 --- a/web/Areas/CMS/Controllers/CMSUserPhotoController.cs +++ b/web/Areas/CMS/Controllers/CMSUserPhotoController.cs @@ -55,7 +55,18 @@ public async Task GetByMothraId(string mothraId, bool altPhoto = private async Task ServePhoto(string? mailId, string? loginId, string? iamId, string? mothraId, bool altPhoto, CancellationToken ct) { - var photo = await _photoService.GetUserPhotoAsync(mailId, loginId, iamId, mothraId, altPhoto, ct); + CmsUserPhotoResult? photo = altPhoto + ? await _photoService.GetAlternatePhotoAsync(mailId, loginId, iamId, mothraId, ct) + : await _photoService.GetUserPhotoAsync(mailId, loginId, iamId, mothraId, ct); + + if (photo == null) + { + // Only reachable for the alternate photo - the primary/id-card path always + // returns a result (falling back to a placeholder). 404 lets UserInfo.cshtml's + // client-side script hide the element instead of showing a broken image or + // silently re-rendering the primary photo. + return NotFound(); + } // Photos change rarely; let browsers cache for an hour and keep serving a stale copy // for up to a day while revalidating (legacy used short-lived cache headers with long diff --git a/web/Areas/CMS/Services/CmsUserPhotoService.cs b/web/Areas/CMS/Services/CmsUserPhotoService.cs index c778c5565..4e2953e71 100644 --- a/web/Areas/CMS/Services/CmsUserPhotoService.cs +++ b/web/Areas/CMS/Services/CmsUserPhotoService.cs @@ -9,13 +9,33 @@ namespace Viper.Areas.CMS.Services public interface ICmsUserPhotoService { /// - /// Get a user photo by any supported id (MailId, LoginId, IamId, or MothraId). Resolution - /// order matches legacy userPhoto.cfc: alternate profile photo (by IamId, only when - /// requested via preferAltPhoto), then id-card photo (by MailId), then the default - /// "no picture" image. The result carries a Last-Modified value for conditional caching. + /// Get a user's primary photo by any supported id (MailId, LoginId, IamId, or MothraId). + /// Resolution matches legacy userPhoto.cfc: id-card photo (by MailId), then the default + /// "no picture" image - this always returns a result, which makes it the right choice + /// for most places a photo is displayed. The result carries a Last-Modified value for + /// conditional caching. /// Task GetUserPhotoAsync(string? mailId, string? loginId, string? iamId, string? mothraId, - bool preferAltPhoto, CancellationToken ct = default); + CancellationToken ct = default); + + /// + /// Get a user's alternate profile photo (by IamId) from the ProfilePhotos store. Unlike + /// GetUserPhotoAsync, this has no placeholder fallback: it returns null when the person + /// has no alternate photo, so a caller that only wants to show it when one actually + /// exists (e.g. UserInfo's second photo slot) can tell that apart from a successful + /// lookup, instead of it being replaced by the primary photo. + /// + Task GetAlternatePhotoAsync(string? mailId, string? loginId, string? iamId, + string? mothraId, CancellationToken ct = default); + + /// + /// Whether a person has an alternate profile photo, without reading its bytes. For a + /// caller that already has IamId in hand (e.g. UserInfo, which resolves it before + /// rendering the page) this is cheaper than GetAlternatePhotoAsync: it skips both the + /// AAUD id-resolution lookup (unnecessary when IamId is already known) and the file + /// read, doing only the existence check. + /// + Task HasAlternatePhotoAsync(string iamId, CancellationToken ct = default); } /// @@ -59,24 +79,28 @@ public CmsUserPhotoService(AAUDContext aaudContext, IPhotoService photoService, } public async Task GetUserPhotoAsync(string? mailId, string? loginId, string? iamId, - string? mothraId, bool preferAltPhoto, CancellationToken ct = default) + string? mothraId, CancellationToken ct = default) { - // Resolve whichever id was provided to the person's mailId (+ iamId when needed). - (mailId, iamId) = await ResolveIdsAsync(mailId, loginId, iamId, mothraId, needIamId: preferAltPhoto, ct); - - if (preferAltPhoto && iamId != null) - { - var altPhoto = await ReadAltPhotoAsync(iamId, ct); - if (altPhoto != null) - { - return altPhoto; - } - } + // Only mailId is needed for the id-card photo; skip resolving iamId. + (mailId, _) = await ResolveIdsAsync(mailId, loginId, iamId, mothraId, needIamId: false, ct); var bytes = await _photoService.GetStudentPhotoAsync(mailId ?? string.Empty); return new CmsUserPhotoResult(bytes, DelegatedPhotoLastModified); } + public async Task GetAlternatePhotoAsync(string? mailId, string? loginId, + string? iamId, string? mothraId, CancellationToken ct = default) + { + // The alternate photo is keyed by iamId; resolve it if the caller didn't already + // have it (e.g. UserInfo's tags only send mailId). + (_, iamId) = await ResolveIdsAsync(mailId, loginId, iamId, mothraId, needIamId: true, ct); + + // Unlike GetUserPhotoAsync, there's no "nopic" placeholder fallback here - a missing + // alternate photo returns null so the caller can leave the image out entirely rather + // than silently substituting the primary photo in its place. + return iamId != null ? await ReadAltPhotoAsync(iamId, ct) : null; + } + private async Task<(string? MailId, string? IamId)> ResolveIdsAsync(string? mailId, string? loginId, string? iamId, string? mothraId, bool needIamId, CancellationToken ct) { @@ -87,7 +111,7 @@ public async Task GetUserPhotoAsync(string? mailId, string? return (mailId, iamId); } - var query = _aaudContext.AaudUsers.AsNoTracking().Where(u => u.Current != 0); + var query = _aaudContext.AaudUsers.AsNoTracking().Where(u => u.Current != 0 || u.Future != 0); if (!string.IsNullOrEmpty(mailId)) { query = query.Where(u => u.MailId == mailId); @@ -115,7 +139,12 @@ public async Task GetUserPhotoAsync(string? mailId, string? return user == null ? (mailId, iamId) : (user.MailId ?? mailId, user.IamId ?? iamId); } - private async Task ReadAltPhotoAsync(string iamId, CancellationToken ct) + /// + /// Resolves iamId to the alt photo's on-disk path, applying the same safe-id and + /// path-containment checks GetAlternatePhotoAsync's file read relies on. Returns null + /// for a rejected/unsafe iamId; does not check whether the file actually exists. + /// + private string? GetSafeAltPhotoPath(string iamId) { if (!SafeIdRegex().IsMatch(iamId)) { @@ -125,7 +154,19 @@ public async Task GetUserPhotoAsync(string? mailId, string? var photoPath = Path.GetFullPath(Path.Join(_profilePhotoPath, Path.GetFileName(iamId) + ".jpg")); var root = Path.GetFullPath(_profilePhotoPath + Path.DirectorySeparatorChar); - if (!photoPath.StartsWith(root, CmsFilePathSafety.PathComparison) || !File.Exists(photoPath)) + return photoPath.StartsWith(root, CmsFilePathSafety.PathComparison) ? photoPath : null; + } + + public Task HasAlternatePhotoAsync(string iamId, CancellationToken ct = default) + { + var photoPath = GetSafeAltPhotoPath(iamId); + return Task.FromResult(photoPath != null && File.Exists(photoPath)); + } + + private async Task ReadAltPhotoAsync(string iamId, CancellationToken ct) + { + var photoPath = GetSafeAltPhotoPath(iamId); + if (photoPath == null || !File.Exists(photoPath)) { return null; } diff --git a/web/Areas/Directory/Controllers/DirectoryController.cs b/web/Areas/Directory/Controllers/DirectoryController.cs index ba2b6b285..4b60645c4 100644 --- a/web/Areas/Directory/Controllers/DirectoryController.cs +++ b/web/Areas/Directory/Controllers/DirectoryController.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using Viper.Areas.CMS.Services; using Viper.Areas.Directory.Models; using Viper.Areas.Directory.Services; using Viper.Classes; @@ -9,6 +10,7 @@ using Viper.Classes.Utilities; using Viper.Models.AAUD; using Web.Authorization; +using Microsoft.AspNetCore.Mvc.Filters; namespace Viper.Areas.Directory.Controllers { @@ -20,12 +22,14 @@ public class DirectoryController : AreaController { public AAUDContext _aaud { get; private set; } private readonly RAPSContext? _rapsContext; + private readonly ICmsUserPhotoService _cmsUserPhotoService; public IUserHelper UserHelper { get; private set; } - public DirectoryController(AAUDContext aaud, RAPSContext rapsContext) + public DirectoryController(AAUDContext aaud, RAPSContext rapsContext, ICmsUserPhotoService cmsUserPhotoService) { _aaud = aaud; _rapsContext = rapsContext; + _cmsUserPhotoService = cmsUserPhotoService; UserHelper = new UserHelper(); } @@ -35,7 +39,7 @@ public DirectoryController(AAUDContext aaud, RAPSContext rapsContext) [Route("")] public ActionResult Index(string? useExample) { - return View("~/Areas/Directory/Views/Card.cshtml"); + return View("~/Areas/Directory/Views/Card.cshtml", new DirectoryUser()); } /// @@ -49,16 +53,45 @@ public ActionResult> Nav() } + /// + /// Directory search via query parameters (handles special characters and avoids race conditions) + /// + [SupportedOSPlatform("windows")] + [HttpGet("search")] + public async Task>> GetFromQuery([FromQuery] string search, [FromQuery] bool ucd = false) + { + if (!ModelState.IsValid) + { + return BadRequest(ModelState); + } + if (string.IsNullOrWhiteSpace(search)) + { + return Ok(new List()); + } + if (ucd) + { + return await GetUCD(search); + } + return await Get(search); + } + /// /// Directory list /// /// search string [SupportedOSPlatform("windows")] [Route("search/{search}")] - public async Task>> Get(string search) + public async Task>> Get(string search) { - var individuals = await SearchCurrentAaudUsers(_aaud, search); - List results = new(); + var individuals = await SearchCurrentOrFutureAaudUsers(_aaud, search); + // Held as List (not List) on purpose: System.Text.Json + // resolves each element's contract from the collection's *declared* generic argument, + // not the instance's runtime type. With List, every element - + // even ones actually constructed as IndividualSearchResultWithIDs - would serialize + // using only the base class's properties, silently dropping SpridenId/Pidm/EmployeeId + // regardless of hasDetailPermission below. object is the one element type for which + // System.Text.Json falls back to the runtime type per element. + List results = new(); AaudUser? currentUser = UserHelper.GetCurrentUser(); bool hasDetailPermission = UserHelper.HasPermission(_rapsContext, currentUser, "SVMSecure.DirectoryDetail"); foreach (var m in individuals) @@ -70,6 +103,7 @@ public async Task>> Get(string result.LookupEmailHost(_aaud); results.Add(result); await AddVmacsContactInfoAsync(result); + await PopulateAltPhotoFlagAsync(result); } return results; } @@ -80,11 +114,13 @@ public async Task>> Get(string /// search string [SupportedOSPlatform("windows")] [Route("search/{search}/ucd")] - public async Task>> GetUCD(string search) + public async Task>> GetUCD(string search) { - List results = new(); + // See the comment in Get() above: List, not List, is + // required for IndividualSearchResultWithIDs's extra properties to actually serialize. + List results = new(); List ldap = LdapService.GetUsersContact(search); - var individuals = await SearchCurrentAaudUsers(_aaud, search); + var individuals = await SearchCurrentOrFutureAaudUsers(_aaud, search); var individualsByIamId = individuals.ToLookup(m => m.IamId); AaudUser? currentUser = UserHelper.GetCurrentUser(); bool hasDetailPermission = UserHelper.HasPermission(_rapsContext, currentUser, "SVMSecure.DirectoryDetail"); @@ -97,39 +133,52 @@ public async Task>> GetUCD(stri result.LookupEmailHost(_aaud); results.Add(result); await AddVmacsContactInfoAsync(result); + await PopulateAltPhotoFlagAsync(result); } return results; } - /// - /// Directory results - /// - /// Mothra ID - [Route("userInfo/{mothraID}")] - public IActionResult DirectoryResult(string mothraID) + private static void PopulateVmacsDetails(IndividualSearchResult result, VMACSQuery? vm) { - // pull in the user based on uid - return View("~/Areas/Directory/Views/UserInfo.cshtml"); + if (vm?.item != null) + { + if (vm.item.Nextel?.Length > 0) result.Nextel = vm.item.Nextel[0]; + if (vm.item.LDPager?.Length > 0) result.LDPager = vm.item.LDPager[0]; + if (vm.item.Unit?.Length > 0) result.Department = vm.item.Unit[0]; + } + } + + [NonAction] + public override async Task OnActionExecutionAsync(ActionExecutingContext context, + ActionExecutionDelegate next) + { + PopulateLeftNav(context, "viper-home"); + await base.OnActionExecutionAsync(context, next); } /// - /// Current AAUD users matching the search term on name or any directory identifier, + /// Current or future AAUD users matching the search term on name or any directory identifier, /// ordered for display. Shared by Get and GetUCD. /// /// /// The identifiers are checked through an inline collection rather than an OR chain on purpose. The /// chain trips cs/complex-condition, and its MothraId null check is dead code since MothraId is the /// one non-nullable identifier on AaudUser. + /// + /// Current alone excludes people who are admitted/registered for an upcoming term that hasn't + /// started yet (e.g. an incoming student before the quarter begins) - AAUD only flips + /// current_student/current_employee on once the term is actually underway. Future covers that gap, + /// matching the population the campus LDAP directory and the legacy ColdFusion directory show. /// - internal static Task> SearchCurrentAaudUsers(AAUDContext aaud, string search) + internal static Task> SearchCurrentOrFutureAaudUsers(AAUDContext aaud, string search) { return aaud.AaudUsers .AsNoTracking() .Where(u => (u.DisplayFirstName + " " + u.DisplayLastName).Contains(search) || new[] { u.MailId, u.LoginId, u.SpridenId, u.Pidm, u.MothraId, u.EmployeeId, u.IamId } .Any(id => id != null && id.Contains(search))) - .Where(u => u.Current != 0) + .Where(u => u.Current != 0 || u.Future != 0) .OrderBy(u => u.DisplayLastName) .ThenBy(u => u.DisplayFirstName) .ToListAsync(); @@ -147,14 +196,23 @@ private static async Task AddVmacsContactInfoAsync(IndividualSearchResult result { return; } - var item = (await VMACSService.Search(result.LoginId))?.item; - if (item == null) + var vm = await VMACSService.Search(result.LoginId); + PopulateVmacsDetails(result, vm); + } + + /// + /// Sets HasAltPhoto so the card view can pick between the alternate and default photo + /// up front, instead of the browser always requesting the alternate photo and falling + /// back to a generic placeholder icon when the person doesn't have one. A local + /// file-existence check (see CmsUserPhotoService), not a network call. + /// + private async Task PopulateAltPhotoFlagAsync(IndividualSearchResult result) + { + if (!string.IsNullOrEmpty(result.IamId)) { - return; + result.HasAltPhoto = await _cmsUserPhotoService.HasAlternatePhotoAsync(result.IamId); } - if (item.Nextel is { Length: > 0 }) result.Nextel = item.Nextel[0]; - if (item.LDPager is { Length: > 0 }) result.LDPager = item.LDPager[0]; - if (item.Unit is { Length: > 0 }) result.Department = item.Unit[0]; } } } + diff --git a/web/Areas/Directory/Controllers/UserInfoController.cs b/web/Areas/Directory/Controllers/UserInfoController.cs new file mode 100644 index 000000000..b4ed60a94 --- /dev/null +++ b/web/Areas/Directory/Controllers/UserInfoController.cs @@ -0,0 +1,117 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Web.Authorization; +using Viper.Areas.Directory.Models; +using Viper.Classes; +using Viper.Classes.SQLContext; +using Viper.Areas.Directory.Services; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Viper.Areas.Directory.Controllers +{ + [Area("Directory")] + [Permission(Allow = "SVMSecure.userinfo")] + [Authorize(Roles = "VMDO SVM-IT")] //locking directory for now until it's complete + [Route("userinfo")] + public class UserInfoController : AreaController + { + private readonly AAUDContext _aaud; + private readonly UserInfoService _userInfo; + private readonly IUserHelper _userHelper; + private readonly RAPSContext _rapsContext; + + public UserInfoController( + RAPSContext rapsContext, + AAUDContext aaudContext, + UserInfoService userInfo) + { + _aaud = aaudContext; + _rapsContext = rapsContext; + _userHelper = new UserHelper(); + _userInfo = userInfo; + } + + /// + /// Redirect if we don't have a mothraID + /// + [Route("")] + public ActionResult Index() + { + return Redirect("~/Directory"); + } + + /// + /// UserInfo Page + /// + /// MothraID + /// + [Route("{mothraID}")] + public async Task 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 = currentUser != null && mothraID == currentUser.MothraId; + + // Compute permissions before fetching so UserInfoService can skip populating + // sections this requester isn't allowed to see, instead of fetching everything + // and only gating the view. + var permissions = new UserInfoViewPermissions + { + IsOwnPage = ownPage, + CanViewDirectoryDetail = ownPage || _userHelper.HasPermission(_rapsContext, currentUser, "SVMSecure.directoryDetail"), + // "SVMSecure.studentID" was never a real permission - RAPS defines this one as + // "SVMSecure.DirectoryStudentID" ("Access to the StudentID return value for any + // users returned"). The stale name meant this always evaluated false, so the + // Student ID line at the top of the page never showed for anyone, regardless of + // what permissions/roles a viewer actually held. + CanViewStudentID = _userHelper.HasPermission(_rapsContext, currentUser, "SVMSecure.DirectoryStudentID"), + CanViewIAM = _userHelper.HasPermission(_rapsContext, currentUser, "SVMSecure.userinfo.iam"), + CanViewRoles = ownPage || _userHelper.HasPermission(_rapsContext, currentUser, "SVMSecure.userinfo.raps"), + CanViewUCPath = _userHelper.HasPermission(_rapsContext, currentUser, "SVMSecure.directoryUCPathInfo"), + CanViewUCPathDetail = _userHelper.HasPermission(_rapsContext, currentUser, "SVMSecure.directoryUCPathInfoAllDetail"), + CanViewIDCards = _userHelper.HasPermission(_rapsContext, currentUser, "SVMSecure.userinfo.idcards"), + CanViewKeys = _userHelper.HasPermission(_rapsContext, currentUser, "SVMSecure.userinfo.keys"), + CanViewLoans = ownPage || _userHelper.HasPermission(_rapsContext, currentUser, "SVMSecure.userinfo.loans"), + CanViewInstinct = ownPage || _userHelper.HasPermission(_rapsContext, currentUser, "SVMSecure.userinfo.instinct"), + CanViewADGroups = _userHelper.HasPermission(_rapsContext, currentUser, "SVMSecure.UserInfo.ADGroups") + }; + + var individual = await _aaud.AaudUsers.AsNoTracking().FirstOrDefaultAsync(u => (u.MothraId == mothraID)); + string? iamId = null; + if (individual != null) iamId = individual.IamId; + + // Get user information + var userInfo = await _userInfo.GetUserInfoAsync(iamId, mothraID, permissions); + if (userInfo == null) + { + return Redirect("~/Directory"); + } + + return View("~/Areas/Directory/Views/UserInfo.cshtml", userInfo); + } + } + + [Route("nav")] + public async Task>> Nav() + { + var nav = new List(); + return await Task.Run(() => nav); + } + + [NonAction] + public override async Task OnActionExecutionAsync(ActionExecutingContext context, + ActionExecutionDelegate next) + { + PopulateLeftNav(context, "viper-home"); + await base.OnActionExecutionAsync(context, next); + } + } +} diff --git a/web/Areas/Directory/Models/DirectoryUser.cs b/web/Areas/Directory/Models/DirectoryUser.cs new file mode 100644 index 000000000..546f522b5 --- /dev/null +++ b/web/Areas/Directory/Models/DirectoryUser.cs @@ -0,0 +1,26 @@ +using Viper.Classes.SQLContext; +using Viper.Models.AAUD; + +namespace Viper.Areas.Directory.Models +{ + public class DirectoryUser + { + public bool CanDisplayIDs { get; set; } + public bool CanEmulate { get; set; } + public bool CanSeeAllStudents { get; set; } + public bool CanSeeUCPathInfo { get; set; } + public bool CanSeeAltPhoto { get; set; } + + 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"); + } + } +} diff --git a/web/Areas/Directory/Models/IDCardResult.cs b/web/Areas/Directory/Models/IDCardResult.cs new file mode 100644 index 000000000..a70504dbc --- /dev/null +++ b/web/Areas/Directory/Models/IDCardResult.cs @@ -0,0 +1,16 @@ +namespace Viper.Areas.Directory.Models +{ + public class IDCardResult + { + public string? Number { get; set; } + public string? DisplayName { get; set; } + public string? LastName { get; set; } + public string? Line2 { get; set; } + public string? StatusDescription { get; set; } + public DateTime? Applied { get; set; } + public DateTime? Issued { get; set; } + public string? DeactivatedReason { get; set; } + public DateTime? Deactivated { get; set; } + } +} + diff --git a/web/Areas/Directory/Models/IndividualSearchResult.cs b/web/Areas/Directory/Models/IndividualSearchResult.cs index 5825a6973..ea2f3c2f3 100644 --- a/web/Areas/Directory/Models/IndividualSearchResult.cs +++ b/web/Areas/Directory/Models/IndividualSearchResult.cs @@ -38,14 +38,16 @@ public class IndividualSearchResult public bool? Ross { get; set; } public DateTime? Added { get; set; } public string? Phone { get; set; } - public string? Nextel { get; set; } = null!; - public string? LDPager { get; set; } = null!; + public string? Nextel { get; set; } + public string? LDPager { get; set; } public string? Mobile { get; set; } public string? PostalAddress { get; set; } public string? UCDAffiliation { get; set; } + public string? UcdStudentLevel { get; set; } public string? UserName { get; set; } public bool? SVM { get; set; } public string? originalObject { get; set; } + public bool HasAltPhoto { get; set; } public IndividualSearchResult() { } @@ -97,10 +99,11 @@ public IndividualSearchResult(AaudUser? aaudUser, LdapUserContact? ldapUserConta Phone = ldapUserContact.TelephoneNumber; Mobile = ldapUserContact.Mobile; Email = ldapUserContact.Mail; - MailId = ldapUserContact.Mail?.Split("@")[0]; + MailId = ldapUserContact.Mail?.Split("@")[0] ?? ""; UserName = ldapUserContact.Uid; - PostalAddress = (ldapUserContact.PostalAddress ?? "").Replace("$", '\n'.ToString()); + PostalAddress = ldapUserContact.PostalAddress?.Replace("$", '\n'.ToString()) ?? ""; UCDAffiliation = ldapUserContact.UcdPersonAffiliation; + UcdStudentLevel = ldapUserContact.UcdStudentLevel; if (string.IsNullOrEmpty(DisplayFullName)) { DisplayFullName = ldapUserContact.DisplayName; @@ -122,20 +125,35 @@ public IndividualSearchResult(AaudUser? aaudUser, LdapUserContact? ldapUserConta [SupportedOSPlatform("windows")] public void LookupEmailHost(AAUDContext context) { - if (MailId != null) + var host = LookupEmailHost(context, MailId); + if (host != null) { - // Sanitize MailId to prevent SQL injection in OPENQUERY (which doesn't support parameters) - var safeMailId = MailId.Replace("'", "''"); - var query = $"SELECT * FROM OPENQUERY(UCDMothra,'SELECT (USERPART || ''@'' || HOSTPART) AS USERATHOST FROM MOTHRA.MAILIDS WHERE MAILID = ''{safeMailId}'' AND MAILSTATUS = ''A'' AND MAILTYPE = ''P''')"; - var lastHost = context.Database.SqlQueryRaw(query) - .AsEnumerable() - .Select(r => r.Split("@")[^1]) - .LastOrDefault(); - if (lastHost != null) - { - EmailHost = lastHost; - } + EmailHost = host; + } + } + + /// + /// Looks up the email host from the Mothra linked server for the given mail ID. + /// Shared logic behind the instance overload above, so callers that only have a + /// mail ID (e.g. UserInfoService, which doesn't build a full IndividualSearchResult) + /// can reuse the same query instead of duplicating it. + /// + [SupportedOSPlatform("windows")] + public static string? LookupEmailHost(AAUDContext context, string? mailId) + { + if (mailId == null) + { + return null; } + + // Sanitize mailId to prevent SQL injection in OPENQUERY (which doesn't support parameters) + var safeMailId = mailId.Replace("'", "''"); + var query = $"SELECT * FROM OPENQUERY(UCDMothra,'SELECT (USERPART || ''@'' || HOSTPART) AS USERATHOST FROM MOTHRA.MAILIDS WHERE MAILID = ''{safeMailId}'' AND MAILSTATUS = ''A'' AND MAILTYPE = ''P''')"; + return context.Database.SqlQueryRaw(query) + .AsEnumerable() + .Select(r => r.Split("@")[^1]) + .LastOrDefault(); } } } + diff --git a/web/Areas/Directory/Models/IndividualSearchResultWithIDs.cs b/web/Areas/Directory/Models/IndividualSearchResultWithIDs.cs index 3cced567f..43540dc5e 100644 --- a/web/Areas/Directory/Models/IndividualSearchResultWithIDs.cs +++ b/web/Areas/Directory/Models/IndividualSearchResultWithIDs.cs @@ -46,10 +46,18 @@ public IndividualSearchResultWithIDs(AaudUser? aaudUser, LdapUserContact? ldapUs Phone = ldapUserContact.TelephoneNumber; Mobile = ldapUserContact.Mobile; UserName = ldapUserContact.Uid; - PostalAddress = (ldapUserContact.PostalAddress ?? "").Replace("$", '\n'.ToString()); + PostalAddress = ldapUserContact.PostalAddress?.Replace("$", '\n'.ToString()) ?? ""; UCDAffiliation = ldapUserContact.UcdPersonAffiliation; MothraId = ldapUserContact.MothraId; IamId = ldapUserContact.IamId; + // AAUD has no row for this person (e.g. a continuing student between terms, + // before AAUD flips current/future for the upcoming term - see the comment on + // DirectoryController.SearchCurrentOrFutureAaudUsers). Fall back to the + // equivalent LDAP identifiers so Banner ID/PIDM/Employee ID and the "Jump to + // SIS" link (which depends on SpridenId) still show. + SpridenId = ldapUserContact.UcdStudentSid; + Pidm = ldapUserContact.UcdPersonPidm; + EmployeeId = ldapUserContact.EmployeeNumber; if (string.IsNullOrEmpty(DisplayFullName)) { DisplayFullName = ldapUserContact.DisplayName; @@ -62,3 +70,4 @@ public IndividualSearchResultWithIDs(AaudUser? aaudUser, LdapUserContact? ldapUs } } } + diff --git a/web/Areas/Directory/Models/InstinctResult.cs b/web/Areas/Directory/Models/InstinctResult.cs new file mode 100644 index 000000000..92b2ee360 --- /dev/null +++ b/web/Areas/Directory/Models/InstinctResult.cs @@ -0,0 +1,14 @@ +namespace Viper.Areas.Directory.Models +{ + public class InstinctResult + { + public bool Valid { get; set; } + public string? InstinctId { get; set; } + public bool IsActive { get; set; } + public string? PasswordExpiresAt { get; set; } + public string? Status { get; set; } + public string? Username { get; set; } + public List Roles { get; set; } = new List(); + public string? ErrorMessage { get; set; } + } +} diff --git a/web/Areas/Directory/Models/KeyResult.cs b/web/Areas/Directory/Models/KeyResult.cs new file mode 100644 index 000000000..36a118053 --- /dev/null +++ b/web/Areas/Directory/Models/KeyResult.cs @@ -0,0 +1,12 @@ +namespace Viper.Areas.Directory.Models +{ + public class KeyResult + { + public string? KeyNumber { get; set; } + public string? CutNumber { get; set; } + public string? AccessDescription { get; set; } + public DateTime? IssuedDate { get; set; } + public DateTime? DueDate { get; set; } + public string? IssuedBy { get; set; } + } +} diff --git a/web/Areas/Directory/Models/LdapUserContact.cs b/web/Areas/Directory/Models/LdapUserContact.cs index cf49062a5..001547563 100644 --- a/web/Areas/Directory/Models/LdapUserContact.cs +++ b/web/Areas/Directory/Models/LdapUserContact.cs @@ -19,11 +19,12 @@ public class LdapUserContact public string TelephoneNumber { get; set; } = null!; public string Mobile { get; set; } = null!; public string Mail { get; set; } = null!; + public string LabeledUri { get; set; } = null!; public string UcdStudentLevel { get; set; } = null!; public string UcdStudentSid { get; set; } = null!; public string UcdPersonPidm { get; set; } = null!; public string EmployeeNumber { get; set; } = null!; - public string MothraId { get; set; } = null!; + public string? MothraId { get; set; } public string IamId { get; set; } = null!; public string UcdPersonAffiliation { get; set; } = null!; public string originalObject { get; set; } = null!; @@ -54,6 +55,7 @@ public LdapUserContact(SearchResultEntry entry) case "telephoneNumber": TelephoneNumber = v.ToString(); break; case "mobile": Mobile = v.ToString(); break; case "mail": Mail = v.ToString(); break; + case "labeledUri": LabeledUri = v.ToString(); break; case "employeeNumber": EmployeeNumber = v.ToString(); break; case "ucdStudentLevel": UcdStudentLevel = v.ToString(); break; @@ -68,3 +70,4 @@ public LdapUserContact(SearchResultEntry entry) } } } + diff --git a/web/Areas/Directory/Models/LoanResult.cs b/web/Areas/Directory/Models/LoanResult.cs new file mode 100644 index 000000000..009812da5 --- /dev/null +++ b/web/Areas/Directory/Models/LoanResult.cs @@ -0,0 +1,10 @@ +namespace Viper.Areas.Directory.Models +{ + public class LoanResult + { + public string? AssetName { get; set; } + public DateTime? LoanDate { get; set; } + public DateTime? DueDate { get; set; } + public string? Comments { get; set; } + } +} diff --git a/web/Areas/Directory/Models/UCPathPositionResult.cs b/web/Areas/Directory/Models/UCPathPositionResult.cs new file mode 100644 index 000000000..9344bdcec --- /dev/null +++ b/web/Areas/Directory/Models/UCPathPositionResult.cs @@ -0,0 +1,19 @@ +namespace Viper.Areas.Directory.Models +{ + public class UCPathPositionResult + { + public string? JobCode { get; set; } + public string? JobDescription { get; set; } + public string? DepartmentId { get; set; } + public string? DepartmentDescription { get; set; } + public string? JobStatus { get; set; } + public string? EmployeeStatus { get; set; } + public string? JobStatusDescription { get; set; } + public DateTime? PositionEffectiveDate { get; set; } + public DateTime? ExpectedEndDate { get; set; } + public decimal? FTE { get; set; } + public string? Union { get; set; } + public string? ReportsToName { get; set; } + public string? ReportsToPosition { get; set; } + } +} diff --git a/web/Areas/Directory/Models/UCPathResult.cs b/web/Areas/Directory/Models/UCPathResult.cs new file mode 100644 index 000000000..1ea6e3f8b --- /dev/null +++ b/web/Areas/Directory/Models/UCPathResult.cs @@ -0,0 +1,15 @@ +namespace Viper.Areas.Directory.Models +{ + public class UCPathResult + { + public string? JobCode { get; set; } + public string? JobCodeDescription { get; set; } + public string? DepartmentId { get; set; } + public string? DepartmentDescription { get; set; } + public string? ActionDescription { get; set; } + public DateOnly? JobEffectiveDate { get; set; } + public string? ReportsTo { get; set; } + public string? ReportsToPosition { get; set; } + } +} + diff --git a/web/Areas/Directory/Models/UserInfoResult.cs b/web/Areas/Directory/Models/UserInfoResult.cs new file mode 100644 index 000000000..f43fbf892 --- /dev/null +++ b/web/Areas/Directory/Models/UserInfoResult.cs @@ -0,0 +1,185 @@ +using Viper.Models.IAM; + +namespace Viper.Areas.Directory.Models +{ + public class UserInfoResult + { + // Basic user information + public string? IamId { get; set; } + public string? MothraId { get; set; } + public string? DisplayFullName { get; set; } + public string? MailId { get; set; } + public bool IsValid { get; set; } + public bool IsEmployee { get; set; } + public bool IsStudent { get; set; } + + // Directory Information + public string? Title { get; set; } + public string? Department { get; set; } + public string? Email { get; set; } + public string? EmailHost { get; set; } + public string? LoginId { get; set; } + public string? LabeledUri { get; set; } + public string? Phone { get; set; } + public string? Mobile { get; set; } + public string? Pager { get; set; } + public string? PostalAddress { get; set; } + public string? EmployeeId { get; set; } + public string? StudentId { get; set; } + public string? Pidm { get; set; } + public string? MivId { get; set; } + public bool CurrentAffiliate { get; set; } = true; + + // Employee Information + public string? EmployeePrimaryTitle { get; set; } + public string? EmployeeSchoolDivision { get; set; } + public string? EmployeeStatus { get; set; } + public string? EmployeeTerm { get; set; } + public string? EmployeeHomeDepartment { get; set; } + public string? EmployeeHomeDepartmentName { get; set; } + public string? EmployeeEffortHomeDepartment { get; set; } + public string? EmployeeEffortHomeDepartmentName { get; set; } + public string? EmployeeTeachingHomeDepartment { get; set; } + public string? EmployeeTeachingPercentFulltime { get; set; } + + // Student Information + public string? StudentPriorName { get; set; } + public string? StudentBannerId { get; set; } + /// + /// Non-empty when the student has a FERPA confidentiality hold. Gates directory-identifying + /// student fields (prior name, Banner ID, majors) in the view - see UserInfo.cshtml. + /// + public string? StudentConfidentialScope { get; set; } + /// + /// True only when the confidentiality scope lookup itself ran without error. The view must + /// not treat a failed lookup (StudentConfidentialScope left null) the same as a confirmed + /// "no hold" - that would show confidential fields during an outage instead of hiding them. + /// + public bool StudentConfidentialScopeLookupSucceeded { get; set; } + public string? StudentStatus { get; set; } + public string? StudentPrimaryMajor { get; set; } + public string? StudentAllMajors { get; set; } + public string? StudentRegistrationStatus { get; set; } + public string? StudentClassLevel { get; set; } + public string? StudentClassOf { get; set; } + + // IAM Information + public string? PPSId { get; set; } + public string? OFullName { get; set; } + public bool IsHSEmployee { get; set; } + public bool IsFaculty { get; set; } + public bool IsStaff { get; set; } + public bool IsExternal { get; set; } + + // IAM Associations + public string? AssociationsTitle { get; set; } + public string? AssociationsTitleCode { get; set; } + public string? AssociationsDepartment { get; set; } + public string? AssociationsDepartmentCode { get; set; } + public string? AssociationsAdminDepartment { get; set; } + public string? AssociationsAdminDepartmentAbbrev { get; set; } + public string? AssociationsAdminDepartmentCode { get; set; } + public string? AssociationsAppointmentDepartment { get; set; } + public string? AssociationsAppointmentDepartmentAbbrev { get; set; } + public string? AssociationsAppointmentDepartmentCode { get; set; } + public string? AssociationsPositionType { get; set; } + public string? AssociationsEmployeeClass { get; set; } + public string? AssociationsPercentFulltime { get; set; } + public DateTime? AssociationsStartDate { get; set; } + public DateTime? AssociationsEndDate { get; set; } + public List IamPeople { get; set; } = new List(); + public List IamAssociations { get; set; } = new List(); + + // System Roles and Permissions + public List SystemRoles { get; set; } = new List(); + public List SystemPermissions { get; set; } = new List(); + + // UC Path Information + public List UCPathFlags { get; set; } = new List(); + /// + /// A person can hold more than one concurrent UC Path position (e.g. appointments in + /// two departments); this lists all of them, not just the most recently effective one. + /// + public List UCPathPositions { get; set; } = new List(); + public List UCPathHistory { get; set; } = new List(); + + // ID Cards, Keys, Loans + public List IDCards { get; set; } = new List(); + public List Keys { get; set; } = new List(); + public List Loans { get; set; } = new List(); + + // Instinct Information + public string? InstinctId { get; set; } + public string? InstinctUsername { get; set; } + public List InstinctRoles { get; set; } = new List(); + public string? InstinctStatus { get; set; } + public DateTime? InstinctPasswordExpiresAt { get; set; } + public bool InstinctIsActive { get; set; } + public InstinctResult? InstinctInfo { get; set; } + + // Active Directory Information + public string? ADDisplayName { get; set; } + public string? ADMail { get; set; } + public string? ADSamAccountName { get; set; } + public string? ADUserPrincipalName { get; set; } + public string? ADDistinguishedName { get; set; } + public List ADMemberOf { get; set; } = new List(); + + // Permission flags for view logic + public bool CanViewDirectoryDetail { get; set; } + public bool CanViewStudentID { get; set; } + public bool CanViewIAM { get; set; } + public bool CanViewRoles { get; set; } + public bool CanViewUCPath { get; set; } + public bool CanViewUCPathDetail { get; set; } + public bool CanViewIDCards { get; set; } + public bool CanViewKeys { get; set; } + public bool CanViewLoans { get; set; } + public bool CanViewInstinct { get; set; } + public bool CanViewADGroups { get; set; } + public bool IsOwnPage { get; set; } + public bool ShowPhoneLinks { get; set; } + public bool HasAltPhoto { get; set; } + + /// + /// Names of sections that failed to load (e.g. a SIS or UCPath outage) and so may be + /// showing incomplete data. Populated by the Populate*Async methods on UserInfoService. + /// + public List UnavailableSections { get; set; } = new List(); + } + + /// + /// Permission flags for the requesting user, computed by the controller before the fetch + /// so UserInfoService can skip populating sections the requester isn't allowed to see, + /// rather than fetching everything and only gating the view. + /// + public class UserInfoViewPermissions + { + public bool IsOwnPage { get; set; } + public bool CanViewDirectoryDetail { get; set; } + public bool CanViewStudentID { get; set; } + public bool CanViewIAM { get; set; } + public bool CanViewRoles { get; set; } + public bool CanViewUCPath { get; set; } + public bool CanViewUCPathDetail { get; set; } + public bool CanViewIDCards { get; set; } + public bool CanViewKeys { get; set; } + public bool CanViewLoans { get; set; } + public bool CanViewInstinct { get; set; } + public bool CanViewADGroups { get; set; } + } + + public class SystemRole + { + public string? System { get; set; } + public string? DisplayName { get; set; } + } + + public class SystemPermission + { + public string? Category { get; set; } + public string? Permission { get; set; } + public int Count { get; set; } + public List Permissions { get; set; } = new List(); + } +} diff --git a/web/Areas/Directory/Services/UserInfoService.cs b/web/Areas/Directory/Services/UserInfoService.cs new file mode 100644 index 000000000..0048613a4 --- /dev/null +++ b/web/Areas/Directory/Services/UserInfoService.cs @@ -0,0 +1,1947 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; +using Viper.Classes.SQLContext; +using Viper.Areas.Directory.Models; +using Viper.Models.AAUD; +using Viper.Classes.Utilities; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Viper.Areas.RAPS.Services; +using Viper.Areas.CMS.Services; +using System.Data.Common; + +namespace Viper.Areas.Directory.Services +{ + public class UserInfoService + { + private readonly AAUDContext _aaudContext; + private readonly RAPSContext _rapsContext; + private readonly CoursesContext _coursesContext; + private readonly EquipmentLoanContext _equipmentLoanContext; + private readonly PPSContext _ppsContext; + private readonly IDCardsContext _idCardsContext; + private readonly KeysContext _keysContext; + private readonly SISContext _sisContext; + private readonly IConfiguration _configuration; + private readonly IHttpClientFactory _httpClientFactory; + private readonly IMemoryCache _memoryCache; + private readonly ILogger _logger; + private readonly ICmsUserPhotoService _cmsUserPhotoService; + + public UserInfoService( + AAUDContext aaudContext, + RAPSContext rapsContext, + CoursesContext coursesContext, + EquipmentLoanContext equipmentLoanContext, + PPSContext ppsContext, + IDCardsContext idCardsContext, + KeysContext keysContext, + SISContext sisContext, + IConfiguration configuration, + IHttpClientFactory httpClientFactory, + IMemoryCache memoryCache, + ILogger logger, + ICmsUserPhotoService cmsUserPhotoService) + { + _aaudContext = aaudContext; + _rapsContext = rapsContext; + _coursesContext = coursesContext; + _equipmentLoanContext = equipmentLoanContext; + _ppsContext = ppsContext; + _idCardsContext = idCardsContext; + _keysContext = keysContext; + _sisContext = sisContext; + _configuration = configuration; + _httpClientFactory = httpClientFactory; + _memoryCache = memoryCache; + _logger = logger; + _cmsUserPhotoService = cmsUserPhotoService; + } + + public UserInfoService( + AAUDContext aaudContext, + RAPSContext rapsContext, + CoursesContext coursesContext, + EquipmentLoanContext equipmentLoanContext, + PPSContext ppsContext, + IDCardsContext idCardsContext, + KeysContext keysContext, + IConfiguration configuration, + IHttpClientFactory httpClientFactory, + IMemoryCache memoryCache, + ILogger logger, + ICmsUserPhotoService cmsUserPhotoService) + : this( + aaudContext, + rapsContext, + coursesContext, + equipmentLoanContext, + ppsContext, + idCardsContext, + keysContext, + null!, + configuration, + httpClientFactory, + memoryCache, + logger, + cmsUserPhotoService) + { + } + + /// + /// Get user information by iamid or mothraid. Only populates the sections the requester + /// is permitted to see, per - permissions gate the fetch + /// itself, not just what the view renders. + /// + public async Task GetUserInfoAsync(string? iamId, string? mothraId, UserInfoViewPermissions permissions) + { + UserInfoResult? result = null; + + // Try to get user by IAM ID first + if (!string.IsNullOrEmpty(iamId)) + { + result = await GetUserByIamIdAsync(iamId); + } + + // Fall back to Mothra ID if IAM ID didn't work + if ((result == null || !result.IsValid) && !string.IsNullOrEmpty(mothraId)) + { + result = await GetUserByMothraIdAsync(mothraId); + } + + if (result == null || !result.IsValid) + { + return null; + } + + result.IsOwnPage = permissions.IsOwnPage; + result.CanViewDirectoryDetail = permissions.CanViewDirectoryDetail; + result.CanViewStudentID = permissions.CanViewStudentID; + result.CanViewIAM = permissions.CanViewIAM; + result.CanViewRoles = permissions.CanViewRoles; + result.CanViewUCPath = permissions.CanViewUCPath; + result.CanViewUCPathDetail = permissions.CanViewUCPathDetail; + result.CanViewIDCards = permissions.CanViewIDCards; + result.CanViewKeys = permissions.CanViewKeys; + result.CanViewLoans = permissions.CanViewLoans; + result.CanViewInstinct = permissions.CanViewInstinct; + result.CanViewADGroups = permissions.CanViewADGroups; + + // Populate only the sections this requester is permitted to see. + if (permissions.CanViewDirectoryDetail) + { + await PopulateDirectoryInfoAsync(result); + await PopulateEmployeeInfoAsync(result); + await PopulateStudentInfoAsync(result); + + // Checked here (a local file-existence check, not a network call) so the view + // can decide up front whether to draw the Alternative Photo section at all, + // instead of always rendering it and having client-side JS hide it after a + // failed fetch. + result.HasAltPhoto = !string.IsNullOrEmpty(result.IamId) + && await _cmsUserPhotoService.HasAlternatePhotoAsync(result.IamId); + } + if (permissions.CanViewIAM) + { + await PopulateIamInfoAsync(result); + } + if (permissions.CanViewRoles) + { + await PopulateSystemRolesAsync(result); + } + if (permissions.CanViewUCPath) + { + await PopulateUCPathInfoAsync(result); + } + if (permissions.CanViewIDCards) + { + await PopulateIDCardsAsync(result); + } + if (permissions.CanViewKeys) + { + await PopulateKeysAsync(result); + } + if (permissions.CanViewLoans) + { + await PopulateLoansAsync(result); + } + if (permissions.CanViewInstinct) + { + var individual = await _aaudContext.AaudUsers.AsNoTracking().FirstOrDefaultAsync(u => (u.MothraId == result.MothraId)); + if (individual != null) + { + await PopulateInstinctInfoAsync(result, individual); + } + } + if (permissions.CanViewADGroups) + { + await PopulateActiveDirectoryInfoAsync(result); + } + + return result; + } + + /// + /// Get user by iamid + /// + private async Task GetUserByIamIdAsync(string iamId) + { + try + { + // Get current terms + var currentTerms = await GetCurrentTermsAsync(); + + var user = await _aaudContext.AaudUsers + .AsNoTracking() + .FirstOrDefaultAsync(u => u.IamId == iamId); + + if (user == null) + { + return null; + } + + return await MapToUserInfoResultAsync(user, currentTerms); + } + catch (DbException ex) + { + _logger.LogWarning(ex, "GetUserByIamIdAsync failed"); + return null; + } + } + + /// + /// Get user by mothraid + /// + private async Task GetUserByMothraIdAsync(string mothraId) + { + try + { + var currentTerms = await GetCurrentTermsAsync(); + + var user = await _aaudContext.AaudUsers + .AsNoTracking() + .FirstOrDefaultAsync(u => u.MothraId == mothraId); + + if (user == null) + { + return null; + } + + return await MapToUserInfoResultAsync(user, currentTerms); + } + catch (DbException ex) + { + _logger.LogWarning(ex, "GetUserByMothraIdAsync failed"); + return null; + } + } + + /// + /// Get current academic terms + /// + private async Task> GetCurrentTermsAsync() + { + try + { + var terms = await _coursesContext.Terminfos + .Where(t => t.TermCurrentTermMulti == true) + .Select(t => t.TermCode) + .ToListAsync(); + + return terms; + } + catch (DbException ex) + { + _logger.LogWarning(ex, "GetCurrentTermsAsync failed"); + return new List(); + } + } + + /// + /// get data form aaudUser + /// + private async Task MapToUserInfoResultAsync(AaudUser user, List currentTerms) + { + var result = new UserInfoResult + { + IamId = user.IamId, + MothraId = user.MothraId, + MailId = user.MailId, + DisplayFullName = user.DisplayFullName, + LoginId = user.LoginId, + EmployeeId = user.EmployeeId, + Pidm = user.Pidm, + MivId = user.MivId?.ToString(), + IsValid = true, + // Current alone misses continuing students/employees admitted or registered for + // an upcoming term that hasn't started yet - AAUD only flips current_student/ + // current_employee once the term is actually underway (see the comment on + // DirectoryController.SearchCurrentOrFutureAaudUsers, which treats the same + // Current-or-Future population as affiliated). Without Future here, someone + // between terms is incorrectly flagged "Not Current" on their own page. + CurrentAffiliate = user.Current == 1 || user.Future == 1 + }; + + // Check if employee or student + var hasEmployee = await _aaudContext.Employees + .AnyAsync(e => e.EmpPKey == user.EmployeePKey && currentTerms.Contains(e.EmpTermCode)); + + if (!hasEmployee && (!string.IsNullOrEmpty(user.EmployeePKey) || !string.IsNullOrEmpty(user.StudentPKey))) + { + hasEmployee = await _aaudContext.Flags + .AnyAsync(f => (f.FlagsPKey == user.EmployeePKey || f.FlagsPKey == user.StudentPKey) && (f.FlagsStaff || f.FlagsTeachingFaculty)); + } + + var hasStudent = await _aaudContext.Students + .AnyAsync(s => s.StudentsPKey == user.StudentPKey && + s.StudentsLevelCode1 == "VM" && + currentTerms.Contains(s.StudentsTermCode)); + + if (!hasStudent && (!string.IsNullOrEmpty(user.EmployeePKey) || !string.IsNullOrEmpty(user.StudentPKey))) + { + hasStudent = await _aaudContext.Flags + .AnyAsync(f => (f.FlagsPKey == user.EmployeePKey || f.FlagsPKey == user.StudentPKey) && f.FlagsStudent); + } + + result.IsEmployee = hasEmployee; + result.IsStudent = hasStudent; + + return result; + } + + /// + /// Logs a section failure and records it on the result so the view can show a + /// partial-data notice instead of silently rendering an incomplete page. + /// + private void RecordSectionFailure(UserInfoResult result, string section, Exception ex, string logMessage) + { + _logger.LogWarning(ex, logMessage); + if (!result.UnavailableSections.Contains(section)) + { + result.UnavailableSections.Add(section); + } + } + + /// + /// get data from LDAP/VMACS + /// +#pragma warning disable CA1416 // Validate platform compatibility + private async Task PopulateDirectoryInfoAsync(UserInfoResult result) + { + try + { + var ldapUser = LdapService.GetUserByID(result.IamId); + if (ldapUser != null) + { + result.Title = ldapUser.Title; + result.Email = ldapUser.Mail; + result.Phone = ldapUser.TelephoneNumber; + result.Mobile = ldapUser.Mobile; + result.PostalAddress = ldapUser.PostalAddress; + result.StudentId = ldapUser.UcdStudentSid; + result.LabeledUri = ldapUser.LabeledUri; + } + } + catch (Exception ex) when (ex is NullReferenceException || ex is PlatformNotSupportedException || ex is System.DirectoryServices.DirectoryServicesCOMException || ex is System.DirectoryServices.Protocols.DirectoryException) + { + RecordSectionFailure(result, "Directory contact information", ex, "PopulateDirectoryInfoAsync LDAP failed"); + } + + try + { + result.EmailHost = IndividualSearchResult.LookupEmailHost(_aaudContext, result.MailId); + } + catch (Exception ex) when (ex is DbException || ex is InvalidOperationException) + { + RecordSectionFailure(result, "Directory contact information", ex, "PopulateDirectoryInfoAsync EmailHost lookup failed"); + } +#pragma warning restore CA1416 + + try + { + // Get VMACS information + var vmacs = await VMACSService.Search(result.LoginId); + if (vmacs?.item != null) + { + if (vmacs.item.Nextel?.Length > 0) result.Pager = vmacs.item.Nextel[0]; + if (vmacs.item.Unit?.Length > 0) result.Department = vmacs.item.Unit[0]; + } + } + catch (Exception ex) when (ex is HttpRequestException || ex is TaskCanceledException || ex is JsonException || ex is InvalidOperationException) + { + RecordSectionFailure(result, "Directory contact information", ex, "PopulateDirectoryInfoAsync VMACS failed"); + } + } + + /// + /// get employee data + /// + private async Task PopulateEmployeeInfoAsync(UserInfoResult result) + { + if (!result.IsEmployee || string.IsNullOrEmpty(result.EmployeeId)) + return; + + try + { + var currentTerms = await GetCurrentTermsAsync(); + var aaudUser = await _aaudContext.AaudUsers + .AsNoTracking() + .FirstOrDefaultAsync(u => u.EmployeeId == result.EmployeeId); + + if (aaudUser?.EmployeePKey != null) + { + var employee = await _aaudContext.Employees + .AsNoTracking() + .FirstOrDefaultAsync(e => e.EmpPKey == aaudUser.EmployeePKey && currentTerms.Contains(e.EmpTermCode)); + + if (employee != null) + { + result.EmployeePrimaryTitle = employee.EmpPrimaryTitle; + result.EmployeeSchoolDivision = employee.EmpSchoolDivision; + result.EmployeeStatus = employee.EmpStatus; + result.EmployeeTerm = employee.EmpTermCode; + result.EmployeeHomeDepartment = employee.EmpHomeDept; + result.EmployeeEffortHomeDepartment = employee.EmpEffortHomeDept; + result.EmployeeTeachingHomeDepartment = employee.EmpTeachingHomeDept; + result.EmployeeTeachingPercentFulltime = employee.EmpTeachingPercentFulltime?.ToString(); + + var deptCodes = new[] { employee.EmpHomeDept, employee.EmpEffortHomeDept } + .Where(c => !string.IsNullOrEmpty(c)) + .Distinct() + .ToList(); + + if (deptCodes.Count > 0) + { + var deptRows = await _aaudContext.LdapDepartments + .AsNoTracking() + .Where(d => d.LdapDeptCode != null && deptCodes.Contains(d.LdapDeptCode)) + .ToListAsync(); + + var deptNames = deptRows + .GroupBy(d => d.LdapDeptCode!) + .ToDictionary(g => g.Key, g => (g.FirstOrDefault(d => d.LdapDeptInUse == true) ?? g.First()).LdapDeptName); + + result.EmployeeHomeDepartmentName = deptNames.TryGetValue(employee.EmpHomeDept, out var homeDeptName) + ? homeDeptName : null; + result.EmployeeEffortHomeDepartmentName = employee.EmpEffortHomeDept != null && deptNames.TryGetValue(employee.EmpEffortHomeDept, out var effortDeptName) + ? effortDeptName : null; + } + } + } + } + catch (Exception ex) when (ex is DbException || ex is InvalidOperationException) + { + RecordSectionFailure(result, "Employee Information", ex, "PopulateEmployeeInfoAsync failed"); + } + } + + /// + /// get student data + /// + private async Task PopulateStudentInfoAsync(UserInfoResult result) + { + if (!result.IsStudent || string.IsNullOrEmpty(result.Pidm)) + return; + + try + { + // Get current term for the student + var currentTerm = await GetCurrentOrFutureTermForStudentAsync(result, result.Pidm); + + // Get basic student information (non-term dependent) + result.StudentConfidentialScope = await GetStudentConfidentialScopeAsync(result, result.Pidm); + result.StudentPriorName = await GetStudentPriorNamesAsync(result, result.Pidm); + result.StudentBannerId = await GetStudentBannerIdAsync(result, result.Pidm); + + if (!string.IsNullOrEmpty(currentTerm)) + { + // Get term-dependent information + result.StudentStatus = await GetStudentStatusAsync(result, currentTerm, result.Pidm); + result.StudentRegistrationStatus = await GetStudentRegistrationStatusAsync(result, currentTerm, result.Pidm); + result.StudentPrimaryMajor = await GetStudentMajorAsync(result, currentTerm, result.Pidm); + result.StudentAllMajors = await GetStudentAllMajorsAsync(result, currentTerm, result.Pidm); + result.StudentClassLevel = await GetStudentClassLevelAsync(result, currentTerm, result.Pidm); + result.StudentClassOf = await GetStudentClassOfAsync(result, currentTerm, result.Pidm); + } + } + catch (Exception ex) when (ex is DbException || ex is InvalidOperationException) + { + // Exceptions during student info retrieval are caught and ignored to allow other directory details to load. + RecordSectionFailure(result, "Student Information", ex, "PopulateStudentInfoAsync failed"); + } + } + + /// + /// Get current or future term for student - equivalent to getCurrentOrFutureTermForUser in SIS.cfc. + /// + /// Runs over its own connection built from the AAUD connection string rather than + /// _aaudContext, which this class otherwise uses only for EF entity queries - mixing raw + /// SQL and EF entities on the same context causes auth failures. No database qualifier on + /// the proc name either, so this follows whatever database the connection string points at, + /// same as the SIS raw SQL calls below. + /// + private async Task GetCurrentOrFutureTermForStudentAsync(UserInfoResult result, string pidm) + { + try + { + var connectionString = _configuration.GetConnectionString("AAUD") + ?? throw new InvalidOperationException("Connection string 'AAUD' not configured"); + + await using var connection = new Microsoft.Data.SqlClient.SqlConnection(connectionString); + await using var command = new Microsoft.Data.SqlClient.SqlCommand( + "EXEC dbo.usp_get_CurrentOrFutureTermForUser @pidm = @pidm, @loginID = NULL, @termCode = @termCode OUTPUT", + connection); + + command.Parameters.Add(new Microsoft.Data.SqlClient.SqlParameter("@pidm", pidm)); + + var termCodeParam = new Microsoft.Data.SqlClient.SqlParameter + { + ParameterName = "@termCode", + SqlDbType = System.Data.SqlDbType.Int, + Direction = System.Data.ParameterDirection.Output + }; + command.Parameters.Add(termCodeParam); + + await connection.OpenAsync(); + await command.ExecuteNonQueryAsync(); + + var value = termCodeParam.Value; + return value == null || value == DBNull.Value ? null : value.ToString(); + } + catch (Exception ex) when (ex is DbException || ex is InvalidOperationException) + { + RecordSectionFailure(result, "Student Information", ex, "GetCurrentOrFutureTermForStudentAsync failed"); + return null; + } + } + + /// + /// Get student FERPA confidentiality scope - equivalent to getConfidentialScope in SIS.cfc. + /// A non-null/non-empty result means the student has a confidentiality hold. + /// + private async Task GetStudentConfidentialScopeAsync(UserInfoResult result, string pidm) + { + try + { + var rows = await _sisContext.Database + .SqlQueryRaw("EXEC usp_sis_getConfidentialScope @Pidm = {0}", pidm) + .ToListAsync(); + + // Only mark the lookup as having succeeded once we actually have a result - the + // view uses this, not just a null scope, to decide whether it's safe to show + // confidential student fields (see StudentConfidentialScopeLookupSucceeded). + result.StudentConfidentialScopeLookupSucceeded = true; + return rows.FirstOrDefault()?.ZtvconfDesc; + } + catch (Exception ex) when (ex is DbException || ex is InvalidOperationException) + { + RecordSectionFailure(result, "Student Information", ex, "GetStudentConfidentialScopeAsync failed"); + return null; + } + } + + /// + /// Get student prior names - equivalent to getPriorName in SIS.cfc + /// + private async Task GetStudentPriorNamesAsync(UserInfoResult result, string pidm) + { + try + { + var nameList = await _sisContext.Database + .SqlQueryRaw("EXEC usp_sis_getPriorName @thisPidm = {0}", pidm) + .ToListAsync(); + + if (nameList?.Count > 0) + { + var names = nameList + .Where(name => !string.IsNullOrEmpty(name.StudentName) && name.ActivityDate.HasValue) + .Select(name => $"{name.StudentName} ({name.ActivityDate:MM/dd/yyyy})") + .ToList(); + + return string.Join(", ", names); + } + + return null; + } + catch (Exception ex) when (ex is DbException || ex is InvalidOperationException) + { + RecordSectionFailure(result, "Student Information", ex, "GetStudentPriorNamesAsync failed"); + return null; + } + } + + /// + /// Get student Banner ID - equivalent to getBannerID in SIS.cfc + /// + private async Task GetStudentBannerIdAsync(UserInfoResult result, string pidm) + { + try + { + var rows = await _sisContext.Database + .SqlQueryRaw("EXEC usp_sis_getBannerID @pidm = {0}", pidm) + .ToListAsync(); + + var bannerId = rows.FirstOrDefault(); + return bannerId?.SpridenId; + } + catch (Exception ex) when (ex is DbException || ex is InvalidOperationException) + { + RecordSectionFailure(result, "Student Information", ex, "GetStudentBannerIdAsync failed"); + return null; + } + } + + /// + /// Get student status for term - equivalent to getStudentStatus in SIS.cfc + /// + private async Task GetStudentStatusAsync(UserInfoResult result, string termCode, string pidm) + { + try + { + var rows = await _sisContext.Database + .SqlQueryRaw("EXEC usp_sis_getStudentStatus @thisTermCode = {0}, @thispidm = {1}", termCode, pidm) + .ToListAsync(); + + var status = rows.FirstOrDefault(); + + return status?.RegStatus; + } + catch (Exception ex) when (ex is DbException || ex is InvalidOperationException) + { + RecordSectionFailure(result, "Student Information", ex, "GetStudentStatusAsync failed"); + return null; + } + } + + /// + /// Get student registration status - equivalent to getRegStatus in SIS.cfc + /// + private async Task GetStudentRegistrationStatusAsync(UserInfoResult result, string termCode, string pidm) + { + try + { + var regStatus = await _sisContext.Database + .SqlQueryRaw("EXEC usp_sis_getCurrentRegStatus @termCode = {0}, @pidm = {1}", termCode, pidm) + .ToListAsync(); + + return regStatus.Any() ? "Yes" : "No"; + } + catch (Exception ex) when (ex is DbException || ex is InvalidOperationException) + { + RecordSectionFailure(result, "Student Information", ex, "GetStudentRegistrationStatusAsync failed"); + return "No"; + } + } + + /// + /// Get student primary major - equivalent to getMajor in SIS.cfc + /// + private async Task GetStudentMajorAsync(UserInfoResult result, string termCode, string pidm) + { + try + { + var rows = await _sisContext.Database + .SqlQueryRaw("EXEC usp_sis_getMajor @termCode = {0}, @pidm = {1}", termCode, pidm) + .ToListAsync(); + + var major = rows.FirstOrDefault(); + + return major?.SGBSTDN_MAJR_CODE_1; + } + catch (Exception ex) when (ex is DbException || ex is InvalidOperationException) + { + RecordSectionFailure(result, "Student Information", ex, "GetStudentMajorAsync failed"); + return null; + } + } + + /// + /// Get all student majors - equivalent to getAllMajors in SIS.cfc + /// + private async Task GetStudentAllMajorsAsync(UserInfoResult result, string termCode, string pidm) + { + try + { + var rows = await _sisContext.Database + .SqlQueryRaw("EXEC usp_sis_getAllMajors @termCode = {0}, @pidm = {1}", termCode, pidm) + .ToListAsync(); + + var allMajors = rows.FirstOrDefault(); + + if (allMajors != null) + { + var majors = new List(); + if (!string.IsNullOrEmpty(allMajors.SGBSTDN_MAJR_CODE_1)) + majors.Add(allMajors.SGBSTDN_MAJR_CODE_1); + if (!string.IsNullOrEmpty(allMajors.SGBSTDN_MAJR_CODE_2)) + majors.Add(allMajors.SGBSTDN_MAJR_CODE_2); + + return string.Join(", ", majors); + } + + return null; + } + catch (Exception ex) when (ex is DbException || ex is InvalidOperationException) + { + RecordSectionFailure(result, "Student Information", ex, "GetStudentAllMajorsAsync failed"); + return null; + } + } + + /// + /// Get student class level - equivalent to getClassLevel in SIS.cfc + /// + private async Task GetStudentClassLevelAsync(UserInfoResult result, string termCode, string pidm) + { + try + { + var rows = await _sisContext.Database + .SqlQueryRaw("EXEC usp_sis_getClassLevel @thisTermCode = {0}, @thisPidm = {1}", termCode, pidm) + .ToListAsync(); + + var classLevel = rows.FirstOrDefault(); + + return classLevel?.SgvclssClasCode; + } + catch (Exception ex) when (ex is DbException || ex is InvalidOperationException) + { + RecordSectionFailure(result, "Student Information", ex, "GetStudentClassLevelAsync failed"); + return null; + } + } + + /// + /// Get student class of year - equivalent to getClassOf in SIS.cfc + /// + private async Task GetStudentClassOfAsync(UserInfoResult result, string termCode, string pidm) + { + try + { + var classOfParam = new Microsoft.Data.SqlClient.SqlParameter + { + ParameterName = "@thisClassOf", + SqlDbType = System.Data.SqlDbType.VarChar, + Size = 4, + Direction = System.Data.ParameterDirection.Output + }; + + await _sisContext.Database.ExecuteSqlRawAsync( + "EXEC usp_sis_getClassOf @thisTermCode = @thisTermCode, @thisPidm = @thisPidm, @thisClassOf = @thisClassOf OUTPUT", + new Microsoft.Data.SqlClient.SqlParameter("@thisTermCode", termCode), + new Microsoft.Data.SqlClient.SqlParameter("@thisPidm", pidm), + classOfParam); + + var value = classOfParam.Value; + return value == null || value == DBNull.Value ? null : value.ToString(); + } + catch (Exception ex) when (ex is DbException || ex is InvalidOperationException) + { + RecordSectionFailure(result, "Student Information", ex, "GetStudentClassOfAsync failed"); + return null; + } + } + + /// + /// Populate iam information + /// + private async Task PopulateIamInfoAsync(UserInfoResult result) + { + if (string.IsNullOrEmpty(result.IamId)) + { + _logger.LogDebug("IAM API: result.IamId is null or empty"); + return; + } + + try + { + _logger.LogDebug("IAM API request for IamId {IamId}", LogSanitizer.SanitizeId(result.IamId)); + var iamApi = new IamApi(_httpClientFactory); + + // Get people information - equivalent to iamPeople.getById() in ColdFusion + var peopleResponse = await iamApi.SearchForPerson(iamId: result.IamId); + _logger.LogDebug("IAM people response data count {Count}, error {Error}", + peopleResponse.Data?.Count(), LogSanitizer.SanitizeString(peopleResponse.ErrorMessage) ?? "none"); + if (peopleResponse.Data?.Any() == true) + { + result.IamPeople = peopleResponse.Data.ToList(); + var person = peopleResponse.Data.First(); + result.PPSId = person.PpsId; + result.OFullName = person.OFullName; + result.IsHSEmployee = person.IsHSEmployee; + result.IsFaculty = person.IsFaculty; + result.IsStaff = person.IsStaff; + result.IsExternal = person.IsExternal; + } + + // Get employee associations - equivalent to iamAssociations.getEmployeeAssociations() in ColdFusion + var associationsResponse = await iamApi.GetEmployeeAssociations(result.IamId); + _logger.LogDebug("IAM associations response data count {Count}, error {Error}", + associationsResponse.Data?.Count(), LogSanitizer.SanitizeString(associationsResponse.ErrorMessage) ?? "none"); + if (associationsResponse.Data?.Any() == true) + { + result.IamAssociations = associationsResponse.Data.ToList(); + var association = associationsResponse.Data.First(); // Get first/primary association + result.AssociationsTitle = association.TitleDisplayName; + result.AssociationsTitleCode = association.TitleCode; + result.AssociationsDepartment = association.DeptDisplayName; + result.AssociationsDepartmentCode = association.DeptCode; + result.AssociationsAdminDepartment = association.AdminDeptDisplayName; + result.AssociationsAdminDepartmentAbbrev = association.AdminDeptAbbrev; + result.AssociationsAdminDepartmentCode = association.AdminDeptCode; + result.AssociationsAppointmentDepartment = association.ApptDeptDisplayName; + result.AssociationsAppointmentDepartmentAbbrev = association.ApptDeptAbbrev; + result.AssociationsAppointmentDepartmentCode = association.ApptDeptCode; + result.AssociationsPositionType = association.PositionType; + result.AssociationsEmployeeClass = association.EmplClassDesc; + result.AssociationsPercentFulltime = association.PercentFullTime; + result.AssociationsStartDate = association.AssocStartDate; + result.AssociationsEndDate = association.AssocEndDate; + } + } + catch (Exception ex) when (ex is HttpRequestException || ex is TaskCanceledException || ex is JsonException || ex is InvalidOperationException) + { + // Log exception but don't fail the entire request + RecordSectionFailure(result, "IAM Information", ex, "IAM API request failed"); + } + } + + private async Task PopulateSystemRolesAsync(UserInfoResult result) + { + if (string.IsNullOrEmpty(result.MothraId)) + return; + + try + { + var systems = new[] { "VIPER", "VMACS.VMTH", "VMACS.VMLF", "VMACS.UCVMCSD" }; + + // Query tblRoleMembers and join tblRoles for this user + var roleMembers = await _rapsContext.TblRoleMembers + .AsNoTracking() + .Include(rm => rm.Role) + .Where(rm => rm.MemberId == result.MothraId + && (rm.StartDate == null || rm.StartDate <= DateTime.Today) + && (rm.EndDate == null || rm.EndDate >= DateTime.Today)) + .ToListAsync(); + + foreach (var system in systems) + { + // Filter roles belonging to the current system/instance + // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + var filteredRoles = roleMembers + .Where(rm => rm.Role != null && RAPSSecurityService.RoleBelongsToInstance(system, rm.Role)) + .Select(rm => rm.Role) + .OrderBy(r => r.DisplayName ?? r.Role); + + result.SystemRoles.AddRange(filteredRoles.Select(role => + { + string displayName = role.DisplayName ?? role.Role; + return new SystemRole + { + System = system, + DisplayName = FormatPermissionName(displayName) + }; + })); + } + + var categories = new[] { "API", "RAPS", "SVMSecure", "VIPERForms", "VMACS" }; + var allPermissions = await GetUserPermissionsAsync(result.MothraId); + foreach (var category in categories) + { + var categoryPerms = allPermissions + .Where(p => p.Permission.StartsWith(category)) + .OrderBy(p => p.Permission) + .ToList(); + var sysPerm = new SystemPermission + { + Category = category, + Count = categoryPerms.Count, + Permissions = categoryPerms.Select(p => p.Permission).ToList() + }; + result.SystemPermissions.Add(sysPerm); + } + } + catch (Exception ex) when (ex is DbException || ex is InvalidOperationException) + { + RecordSectionFailure(result, "System Roles", ex, "PopulateSystemRolesAsync failed"); + } + } + + /// + /// Get RSOP (Resultant Set of Permissions) for a user across all systems. Fetched once per + /// user rather than once per category - callers that only care about one system's prefix + /// can filter the returned list with .Where(p => p.Permission.StartsWith(prefix)). + /// + private async Task> GetUserPermissionsAsync(string memberId) + { + var permsViaRoles = await ( + from permission in _rapsContext.TblPermissions + join rolePermissions in _rapsContext.TblRolePermissions + on permission.PermissionId equals rolePermissions.PermissionId + join memberRole in _rapsContext.TblRoleMembers + on rolePermissions.RoleId equals memberRole.RoleId + join role in _rapsContext.TblRoles + on memberRole.RoleId equals role.RoleId + where memberRole.MemberId == memberId + && (memberRole.StartDate == null || memberRole.StartDate <= DateTime.Today) + && (memberRole.EndDate == null || memberRole.EndDate >= DateTime.Today) + select new + { + permission.PermissionId, + permission.Permission, + rolePermissions.Access, + role.Role + }).AsNoTracking().ToListAsync(); + + var permsAssigned = await (from permission in _rapsContext.TblPermissions + join memberPermissions in _rapsContext.TblMemberPermissions + on permission.PermissionId equals memberPermissions.PermissionId + where memberPermissions.MemberId == memberId + && (memberPermissions.StartDate == null || memberPermissions.StartDate <= DateTime.Today) + && (memberPermissions.EndDate == null || memberPermissions.EndDate >= DateTime.Today) + select new + { + permission.PermissionId, + permission.Permission, + memberPermissions.Access + }).AsNoTracking().ToListAsync(); + + var permissions = new Dictionary(); + + // Add permissions assigned via roles + foreach (var p in permsViaRoles) + { + if (permissions.TryGetValue(p.PermissionId, out PermissionInfo? existing)) + { + // Record deny if this role is denying access + if (existing.Access == "1" && p.Access == 0) + { + existing.Access = "0"; + existing.Source = p.Role; + } + else if (existing.Access == p.Access.ToString()) + { + existing.Source += "," + p.Role; + } + } + else + { + permissions[p.PermissionId] = new PermissionInfo + { + PermissionId = p.PermissionId, + Permission = p.Permission, + Source = p.Role, + SourceType = "Role", + Access = p.Access.ToString() + }; + } + } + + // Add permissions assigned directly + foreach (var p in permsAssigned) + { + if (permissions.TryGetValue(p.PermissionId, out PermissionInfo? existing)) + { + if (existing.Access == "1" && p.Access == 0) + { + existing.Access = "0"; + existing.Source = "Member Permission"; + } + else if (existing.Access == p.Access.ToString()) + { + existing.Source += ",Member Permission"; + } + } + else + { + permissions[p.PermissionId] = new PermissionInfo + { + PermissionId = p.PermissionId, + Permission = p.Permission, + Source = "Member Permission", + SourceType = "Member", + Access = p.Access.ToString() + }; + } + } + + // systemPrefix is already applied in the queries above; only the access check remains. + return permissions.Values + .Where(p => p.Access == "1") + .OrderBy(p => p.Permission) + .ToList(); + } + + private static string FormatPermissionName(string val) + { + if (string.IsNullOrEmpty(val)) return ""; + string clean = val; + clean = clean.Replace("CN=", "", StringComparison.OrdinalIgnoreCase); + clean = clean.Replace("OU=", "", StringComparison.OrdinalIgnoreCase); + clean = clean.Replace("DC=", "", StringComparison.OrdinalIgnoreCase); + return clean.Replace(",", "."); + } + + + /// + /// Populate UC Path information + /// + private async Task PopulateUCPathInfoAsync(UserInfoResult result) + { + if (string.IsNullOrEmpty(result.EmployeeId)) + return; + + try + { + // Get UC Path person information + var person = await _ppsContext.VwPeople + .AsNoTracking() + .FirstOrDefaultAsync(p => p.Emplid == result.EmployeeId); + + if (person != null) + { + result.UCPathFlags = GetUCPathFlags(person); + } + + // Get UC Path position information. A person can hold more than one concurrent + // position (e.g. appointments in two departments), so fetch all of them rather + // than just the one with the latest effective date. + var positions = await _ppsContext.VwPersonJobPositions + .AsNoTracking() + .Where(p => p.Emplid == result.EmployeeId) + .OrderByDescending(p => p.Effdt) + .ToListAsync(); + + var reportsToIds = positions + .Select(p => p.ReportsTo) + .Where(id => !string.IsNullOrEmpty(id)) + .Select(id => id!) + .Distinct() + .ToList(); + + var reportsToLookup = await BuildReportsToLookupAsync(reportsToIds); + + result.UCPathPositions = positions.Select(position => + { + var reportsTo = !string.IsNullOrEmpty(position.ReportsTo) && reportsToLookup.TryGetValue(position.ReportsTo, out var match) + ? match + : (Name: string.Empty, Position: string.Empty); + + return new UCPathPositionResult + { + JobCode = position.Jobcode, + JobDescription = position.JobcodeDesc, + DepartmentId = position.Deptid, + DepartmentDescription = position.DeptDesc, + JobStatus = position.JobStatus, + EmployeeStatus = position.EmplStatus, + JobStatusDescription = position.JobStatusDesc, + PositionEffectiveDate = position.PositionEffdt, + ExpectedEndDate = position.ExpectedEndDate, + FTE = position.Fte, + Union = position.UnionCd, + ReportsToName = reportsTo.Name, + ReportsToPosition = reportsTo.Position + }; + }).ToList(); + } + catch (Exception ex) when (ex is DbException || ex is InvalidOperationException) + { + RecordSectionFailure(result, "UC Path", ex, "PopulateUCPathInfoAsync failed"); + } + + // Get UC Path History from the VwPersonJobPositionAll view + await PopulateUCPathHistoryAsync(result); + } + + /// + /// Populate UC Path History information - equivalent to get_ucpath_history in userinfo.cfc + /// + private async Task PopulateUCPathHistoryAsync(UserInfoResult result) + { + if (string.IsNullOrEmpty(result.EmployeeId)) + return; + + try + { + var historyData = await _ppsContext.VwPersonJobPositionAlls + .AsNoTracking() + .Where(p => p.Emplid == result.EmployeeId) + .OrderByDescending(p => p.Effdt) + .ThenByDescending(p => p.PositionEffdt) + .ToListAsync(); + + var reportsToIds = historyData + .Select(h => h.ReportsTo) + .Where(id => !string.IsNullOrEmpty(id)) + .Select(id => id!) + .Distinct() + .ToList(); + + var reportsToLookup = await BuildReportsToLookupAsync(reportsToIds); + + var ucPathResults = historyData.Select(history => + { + var reportsTo = !string.IsNullOrEmpty(history.ReportsTo) && reportsToLookup.TryGetValue(history.ReportsTo, out var match) + ? match + : (Name: string.Empty, Position: string.Empty); + + return new UCPathResult + { + JobCode = history.Jobcode, + JobCodeDescription = history.JobcodeDesc, + DepartmentId = history.Deptid, + DepartmentDescription = history.DeptDesc, + ActionDescription = history.ActionDescr, + JobEffectiveDate = history.Effdt.HasValue ? DateOnly.FromDateTime(history.Effdt.Value) : null, + ReportsTo = reportsTo.Name, + ReportsToPosition = reportsTo.Position + }; + }); + + result.UCPathHistory.AddRange(ucPathResults); + } + catch (Exception ex) when (ex is DbException || ex is InvalidOperationException) + { + RecordSectionFailure(result, "UC Path History", ex, "PopulateUCPathHistoryAsync failed"); + } + } + + /// + /// Batch-resolve reports-to name/job title for a set of position numbers in at most two + /// queries, instead of looking each one up individually per history row. Rows are ordered + /// by Effdt/Effseq descending so the first row seen per PositionNbr is the most recent one. + /// + private async Task> BuildReportsToLookupAsync(List positionNbrs) + { + var lookup = new Dictionary(); + if (positionNbrs.Count == 0) + return lookup; + + var historyRows = await _ppsContext.VwPersonJobPositionAlls + .AsNoTracking() + .Where(r => r.PositionNbr != null && EF.Parameter(positionNbrs).Contains(r.PositionNbr)) + .OrderByDescending(r => r.Effdt) + .ThenByDescending(r => r.Effseq) + .ToListAsync(); + + foreach (var row in historyRows.Where(row => row.PositionNbr != null && !lookup.ContainsKey(row.PositionNbr))) + { + lookup[row.PositionNbr!] = ($"{row.FirstName} {row.LastName}".Trim(), row.JobcodeDesc); + } + + var missingIds = positionNbrs.Where(id => !lookup.ContainsKey(id)).ToList(); + if (missingIds.Count > 0) + { + var currentRows = await _ppsContext.VwPersonJobPositions + .AsNoTracking() + .Where(r => r.PositionNbr != null && EF.Parameter(missingIds).Contains(r.PositionNbr)) + .OrderByDescending(r => r.Effdt) + .ThenByDescending(r => r.Effseq) + .ToListAsync(); + + foreach (var row in currentRows.Where(row => row.PositionNbr != null && !lookup.ContainsKey(row.PositionNbr))) + { + lookup[row.PositionNbr!] = ($"{row.FirstName} {row.LastName}".Trim(), row.JobcodeDesc); + } + } + + return lookup; + } + + /// + /// Populate ID Cards information + /// + private async Task PopulateIDCardsAsync(UserInfoResult result) + { + // Without this, a null LoginId would translate to "WHERE IdCardLoginId IS NULL" + // below, returning other people's ID cards instead of none. + if (string.IsNullOrEmpty(result.LoginId)) + { + return; + } + + try + { + var cards = await (from card in _idCardsContext.IdCards + join status in _idCardsContext.DvtCardStatuses + on card.IdCardCurrentStatus equals status.DvtStatusCode into statusJoin + from status in statusJoin.DefaultIfEmpty() + join reason in _idCardsContext.DvtReasons + on card.IdcardDeactivatedReason equals reason.DvtReasonCode into reasonJoin + from reason in reasonJoin.DefaultIfEmpty() + where card.IdCardLoginId == result.LoginId + orderby card.IdCardAppliedDate descending + select new + { + Card = card, + StatusDescription = status != null ? status.DvtStatusDesc : "", + DeactivatedReasonDescription = reason != null ? reason.DvtReasonDesc : "" + }).AsNoTracking().ToListAsync(); + + foreach (var item in cards) + { + var card = item.Card; + result.IDCards.Add(new IDCardResult + { + Number = card.IdCardNumber?.ToString(), + DisplayName = card.IdCardDisplayName, + LastName = card.IdCardLastName, + Line2 = card.IdCardLine2, + StatusDescription = item.StatusDescription, + DeactivatedReason = item.DeactivatedReasonDescription, + Applied = card.IdCardAppliedDate, + Issued = card.IdCardIssueDate, + Deactivated = card.IdcardDeactivatedDate + }); + } + } + catch (Exception ex) when (ex is DbException || ex is InvalidOperationException) + { + RecordSectionFailure(result, "ID Cards", ex, "PopulateIDCardsAsync failed"); + } + } + + /// + /// Populate Keys information + /// + private async Task PopulateKeysAsync(UserInfoResult result) + { + try + { + var keyAssignments = await (from ka in _keysContext.KeyAssignments + join k in _keysContext.Keys on ka.KeyId equals k.KeyId + where ka.AssignedTo == result.MothraId && ka.Deleted == null + orderby ka.IssuedDate descending, ka.KeyId + select new { Assignment = ka, Key = k }) + .AsNoTracking() + .ToListAsync(); + + var issuerIds = keyAssignments + .Select(item => item.Assignment.IssuedBy) + .Where(id => !string.IsNullOrEmpty(id)) + .Select(id => id!) + .Distinct() + .ToList(); + + var issuerNames = issuerIds.Count > 0 + ? await _aaudContext.AaudUsers + .AsNoTracking() + .Where(u => issuerIds.Contains(u.MothraId)) + .ToDictionaryAsync(u => u.MothraId, u => u.DisplayFullName) + : new Dictionary(); + + foreach (var item in keyAssignments) + { + var issuerName = item.Assignment.IssuedBy != null && issuerNames.TryGetValue(item.Assignment.IssuedBy, out var name) + ? name + : null; + + result.Keys.Add(new KeyResult + { + AccessDescription = item.Key.AccessDescription, + KeyNumber = item.Key.KeyNumber, + CutNumber = item.Assignment.CutNumber, + IssuedDate = item.Assignment.IssuedDate, + IssuedBy = issuerName + }); + } + } + catch (Exception ex) when (ex is DbException || ex is InvalidOperationException) + { + RecordSectionFailure(result, "Keys Assigned", ex, "PopulateKeysAsync failed"); + } + } + + /// + /// Populate Loans information + /// + private async Task PopulateLoansAsync(UserInfoResult result) + { + try + { + var loans = await _equipmentLoanContext.Loans + .AsNoTracking() + .Where(l => l.LoanPidm == result.Pidm) + .Include(l => l.LoanItems) + .ThenInclude(li => li.LoanitemAsset) + .OrderByDescending(l => l.LoanDate) + .ToListAsync(); + + foreach (var loan in loans) + { + foreach (var loanItem in loan.LoanItems) + { + result.Loans.Add(new LoanResult + { + AssetName = loanItem.LoanitemAsset.AssetName, + LoanDate = loan.LoanDate, + DueDate = loan.LoanDueDate, + Comments = loan.LoanComments + }); + } + } + } + catch (DbUpdateException ex) + { + RecordSectionFailure(result, "Loaner Equipment", ex, "PopulateLoansAsync failed"); + } + catch (DbException ex) + { + RecordSectionFailure(result, "Loaner Equipment", ex, "PopulateLoansAsync failed"); + } + catch (InvalidOperationException ex) + { + RecordSectionFailure(result, "Loaner Equipment", ex, "PopulateLoansAsync failed"); + } + } + + /// + /// Populate Instinct information + /// + private async Task PopulateInstinctInfoAsync(UserInfoResult result, AaudUser user) + { + try + { + var instinctResult = await GetInstinctUserAsync(user.LastName, user.FirstName, user.MiddleName, user.LoginId); + result.InstinctInfo = instinctResult; + + if (!string.IsNullOrEmpty(instinctResult.ErrorMessage) && !result.UnavailableSections.Contains("Instinct")) + { + result.UnavailableSections.Add("Instinct"); + } + + if (instinctResult.Valid) + { + result.InstinctId = instinctResult.InstinctId; + result.InstinctUsername = instinctResult.Username; + result.InstinctRoles = instinctResult.Roles; + result.InstinctStatus = instinctResult.Status; + result.InstinctIsActive = instinctResult.IsActive; + + if (!string.IsNullOrEmpty(instinctResult.PasswordExpiresAt) && + DateTime.TryParse(instinctResult.PasswordExpiresAt, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var expireDate)) + { + result.InstinctPasswordExpiresAt = expireDate; + } + } + } + catch (Exception ex) when (ex is HttpRequestException || ex is TaskCanceledException || ex is JsonException || ex is InvalidOperationException) + { + result.InstinctInfo = new InstinctResult { ErrorMessage = $"Populate Exception: {ex.Message}" }; + RecordSectionFailure(result, "Instinct", ex, "PopulateInstinctInfoAsync failed"); + } + } + + private static string AdClean(string a) + { + if (string.IsNullOrEmpty(a)) return ""; + var toReturn = a; + toReturn = toReturn.Replace("CN=", "", StringComparison.OrdinalIgnoreCase); + toReturn = toReturn.Replace("OU=", "", StringComparison.OrdinalIgnoreCase); + toReturn = toReturn.Replace("DC=", "", StringComparison.OrdinalIgnoreCase); + return toReturn; + } + + private static string PermFormat(string a) + { + return AdClean(a).Replace(",", "."); + } + + private static string AdFormat(string a, string[] domains) + { + var toReturn = AdClean(a); + foreach (var d in domains) + { + var domainWithCommas = d.Replace(".", ","); + toReturn = toReturn.Replace(domainWithCommas, d, StringComparison.OrdinalIgnoreCase); + } + var parts = toReturn.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) + .Select(p => p.Trim()) + .Reverse() + .ToList(); + return string.Join("/", parts); + } + + /// + /// Populate Active Directory information + /// + private async Task PopulateActiveDirectoryInfoAsync(UserInfoResult result) + { + if (string.IsNullOrEmpty(result.LoginId)) + { + return; + } + + try + { + var uinformService = new UinformService(); + var adUser = await uinformService.GetUser(samAccountName: result.LoginId); + // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (adUser != null && !string.IsNullOrEmpty(adUser.SamAccountName)) + { + result.ADDisplayName = adUser.DisplayName; + result.ADMail = adUser.Mail; + result.ADSamAccountName = adUser.SamAccountName; + result.ADUserPrincipalName = adUser.UserPrincipalName; + result.ADDistinguishedName = PermFormat(adUser.DistinguishedName ?? ""); + + var allGroups = new HashSet(StringComparer.OrdinalIgnoreCase); + if (adUser.MemberOf != null) + { + foreach (var g in adUser.MemberOf) + { + allGroups.Add(g); + } + } + if (adUser.MemberOfAll != null) + { + foreach (var g in adUser.MemberOfAll) + { + allGroups.Add(g); + } + } + + var isProd = HttpHelper.Environment?.IsProduction() ?? false; + var domains = isProd + ? new[] { "ad3.ucdavis.edu", "ou.ad3.ucdavis.edu", "ucsvm.ucdavis.edu", "ad.vmth.ucdavis.edu", "vetmed.ucdavis.edu", "svm.ucdavis.edu" } + : new[] { "t3.ucdavis.edu" }; + result.ADMemberOf.AddRange( + allGroups + .Select(groupDn => AdFormat(groupDn, domains)) + .Where(formattedGroup => !string.IsNullOrEmpty(formattedGroup)) + ); + + // Sort the groups + result.ADMemberOf = result.ADMemberOf.OrderBy(g => g, StringComparer.OrdinalIgnoreCase).ToList(); + } + } + catch (Exception ex) when (ex is HttpRequestException || ex is TaskCanceledException || ex is JsonException || ex is InvalidOperationException) + { + RecordSectionFailure(result, "Active Directory Group Membership", ex, "Error populating AD info"); + } + } + + /// + /// Get user photo data + /// + public static async Task GetUserPhotoAsync(string mailId, bool useAltPhoto = false) + { + // stubbed + return null; + } + + + + + /// + /// Get Instinct user information via GraphQL API + /// + private async Task GetInstinctUserAsync(string lastName, string firstName, string? middleName, string? expectedUsername) + { + var result = new InstinctResult(); + + // Get access token + var accessToken = await GetInstinctAccessTokenAsync(result); + if (string.IsNullOrEmpty(accessToken)) + { + return result; + } + + // Build name variations for matching + var nameVariations = new List { firstName }; + + var firstNameParts = firstName.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + var first = firstNameParts.FirstOrDefault() ?? ""; + + if (firstNameParts.Length > 0 && !nameVariations.Contains(first)) + { + nameVariations.Add(first); + } + + if (firstNameParts.Length > 1) + { + var sb = new StringBuilder(first); + for (int i = 1; i < firstNameParts.Length; i++) + { + if (firstNameParts[i].Length > 0) + { + sb.Append(' ').Append(firstNameParts[i][0]); + var accum = sb.ToString(); + if (!nameVariations.Contains(accum)) + { + nameVariations.Add(accum); + } + } + } + } + + var temp = nameVariations.ToList(); + if (!string.IsNullOrEmpty(middleName)) + { + var middleParts = middleName.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + foreach (var name in temp) + { + nameVariations.AddRange(middleParts + .Where(middlePart => middlePart.Length > 0) + .Select(middlePart => $"{name} {middlePart[0]}") + .Where(variation => !nameVariations.Contains(variation))); + } + } + + // Create GraphQL query + var query = @" + query SearchUsers($name: String!) { + searchUsers(name: $name) { + id + initials + instinctId + isActive + isProtected + nameFirst + nameMiddle + nameLast + passwordExpiresAt + status + username + roles { + description + label + } + } + }"; + + // Execute GraphQL query + var apiUrl = GetInstinctApiUrl(result); + if (apiUrl == null) + { + return result; + } + var httpClient = _httpClientFactory.CreateClient(); + + var variablesJson = JsonSerializer.Serialize(new { name = lastName }); + var queryUrl = $"{apiUrl}?query={Uri.EscapeDataString(query)}&variables={Uri.EscapeDataString(variablesJson)}"; + httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {accessToken}"); + + var response = await httpClient.GetAsync(queryUrl); + if (response.IsSuccessStatusCode) + { + var responseContent = await response.Content.ReadAsStringAsync(); + var graphqlResponse = JsonSerializer.Deserialize(responseContent); + + if (graphqlResponse?.Errors is { Count: > 0 } graphqlErrors) + { + // GraphQL over HTTP returns 200 even when the query itself failed server-side + // (bad auth, downstream outage, etc.) - the convention is an "errors" array + // alongside a possibly-null "data". That is a real failure, unlike a query + // that ran fine and simply found nobody by that name (searchUsers is a + // nullable list, so a clean "no match" can also come back as null rather than + // an empty array - see the branch below). + var messages = string.Join("; ", graphqlErrors + .Select(e => e.Message) + .Where(m => !string.IsNullOrEmpty(m))); + result.ErrorMessage = string.IsNullOrEmpty(messages) + ? "GraphQL query returned an error with no message." + : $"GraphQL query returned errors: {messages}"; + } + else if (graphqlResponse == null) + { + result.ErrorMessage = "GraphQL response could not be parsed."; + } + else + { + // No GraphQL-level errors. Treat a null searchUsers the same as an empty one - + // both mean the query ran fine and matched nobody, which is the common case + // since most VIPER users don't have an Instinct account. + var candidates = (graphqlResponse.Data?.SearchUsers ?? new List()) + .Where(user => string.Equals(user.NameLast, lastName, StringComparison.OrdinalIgnoreCase) + && nameVariations.Any(name => string.Equals(name, user.NameFirst, StringComparison.OrdinalIgnoreCase))) + .ToList(); + + // Same first/last name can match more than one Instinct account. Prefer an exact + // username match to disambiguate; if that doesn't narrow it to one, don't guess - + // assigning the wrong person's account/roles is worse than showing nothing. + InstinctUser? matchedUser = candidates.Count switch + { + 0 => null, + 1 => candidates[0], + _ when !string.IsNullOrEmpty(expectedUsername) => candidates + .SingleOrDefault(user => string.Equals(user.Username, expectedUsername, StringComparison.OrdinalIgnoreCase)), + _ => null + }; + + if (matchedUser != null) + { + result.Valid = true; + result.InstinctId = matchedUser.InstinctId; + result.IsActive = matchedUser.IsActive; + result.PasswordExpiresAt = matchedUser.PasswordExpiresAt; + result.Status = matchedUser.Status; + result.Username = matchedUser.Username; + result.Roles = matchedUser.Roles? + .Where(r => r.Label != null) + .Select(r => r.Label!) + .ToList() ?? new List(); + } + else if (candidates.Count > 1) + { + result.ErrorMessage = $"Multiple Instinct accounts matched {lastName}, {firstName} and could not be disambiguated by username."; + } + else + { + // No candidates is the common case - most VIPER users don't have an + // Instinct account. That's not a failure, so leave Valid false and + // ErrorMessage unset: PopulateInstinctInfoAsync only flags "Instinct" as + // an unavailable section when ErrorMessage is set, and this isn't one. + // Do not include other candidates' names from the search results here - + // they belong to unrelated people and would leak into this user's directory page. + _logger.LogDebug("Instinct API: no account matched {LastName}, {FirstName}. Variations tried: {Variations}", + LogSanitizer.SanitizeString(lastName), LogSanitizer.SanitizeString(firstName), string.Join(", ", nameVariations)); + } + } + } + else + { + var responseContent = await response.Content.ReadAsStringAsync(); + result.ErrorMessage = $"GraphQL query failed (Status: {response.StatusCode}): {responseContent}"; + } + return result; + } + + private static void AppendError(InstinctResult result, string msg) + { + result.ErrorMessage = string.IsNullOrEmpty(result.ErrorMessage) + ? msg + : $"{result.ErrorMessage} | {msg}"; + } + + /// + /// Reads the configured Instinct API URL. Fails fast instead of falling back to the + /// production host, so a misconfigured non-prod box can't silently hit prod Instinct. + /// Development and Test boxes don't reliably have the Instinct SSM parameters, so a + /// missing URL there is expected, not a failure - it's logged but not surfaced as a + /// page-level error. Production is expected to have it configured, so a missing URL + /// there still fails loud. + /// + private string? GetInstinctApiUrl(InstinctResult result) + { + var apiUrl = _configuration["Instinct:ApiUrl"]; + if (string.IsNullOrWhiteSpace(apiUrl)) + { + const string errMsg = "Instinct:ApiUrl is not configured"; + if (IsInstinctOptionalEnvironment()) + { + _logger.LogDebug("Instinct API: {ErrorMessage} (not treated as an error on {Environment})", + LogSanitizer.SanitizeString(errMsg), _configuration["ASPNETCORE_ENVIRONMENT"]); + return null; + } + _logger.LogWarning("Instinct API: {ErrorMessage}", LogSanitizer.SanitizeString(errMsg)); + AppendError(result, errMsg); + return null; + } + return apiUrl; + } + + /// + /// True when running under Development or Test (ASPNETCORE_ENVIRONMENT, surfaced + /// through IConfiguration by AddEnvironmentVariables() alongside appsettings.json) - + /// the environments where Instinct access isn't guaranteed to be configured, so a + /// missing Instinct:ApiUrl shouldn't be treated as a page-level error. Production is + /// deliberately excluded: it's expected to always have Instinct configured. + /// + private bool IsInstinctOptionalEnvironment() + { + var environment = _configuration["ASPNETCORE_ENVIRONMENT"]; + return string.Equals(environment, "Development", StringComparison.OrdinalIgnoreCase) + || string.Equals(environment, "Test", StringComparison.OrdinalIgnoreCase); + } + + /// + /// OAuth access token for Instinct + /// + private async Task GetInstinctAccessTokenAsync(InstinctResult result) + { + const string cacheKey = "instinct_access_token"; + + // Check cache first + if (_memoryCache.TryGetValue(cacheKey, out string? cachedToken) && !string.IsNullOrEmpty(cachedToken)) + { + return cachedToken; + } + + try + { + var apiUrl = GetInstinctApiUrl(result); + if (apiUrl == null) + { + return null; + } + if (!apiUrl.EndsWith('/')) + { + apiUrl += "/"; + } + var tokenUrl = apiUrl + "auth/token"; + var username = "ucdavisapi"; + var password = HttpHelper.GetSetting("Credentials", "InstinctApi") ?? ""; + + if (string.IsNullOrEmpty(password)) + { + string errMsg = "Password is null or empty in configuration"; + _logger.LogWarning("Instinct auth: {ErrorMessage}", LogSanitizer.SanitizeString(errMsg)); + AppendError(result, errMsg); + return null; + } + + var httpClient = _httpClientFactory.CreateClient(); + var formParams = new List> + { + new("username", username), + new("password", password), + new("grant_type", "password"), + new("scope", "api_access") + }; + + using var formContent = new FormUrlEncodedContent(formParams); + _logger.LogDebug("Instinct auth: sending token POST request"); + using var response = await httpClient.PostAsync(tokenUrl, formContent); + _logger.LogDebug("Instinct auth: response status code {StatusCode}", response.StatusCode); + + if (response.IsSuccessStatusCode) + { + var responseContent = await response.Content.ReadAsStringAsync(); + var tokenResponse = JsonSerializer.Deserialize(responseContent); + if (tokenResponse != null) + { + _logger.LogDebug("Instinct auth: deserialized token length {Length}", tokenResponse.AccessToken.Length); + + if (!string.IsNullOrEmpty(tokenResponse.AccessToken)) + { + // Cache token for slightly less than expiry time (subtract 2 hours as in CF code). + // ExpiresIn can be under 2 hours (or absent, defaulting to 0), so clamp to a floor + // instead of handing IMemoryCache.Set a zero/negative TimeSpan, which throws. + var cacheExpiry = TimeSpan.FromSeconds(Math.Max(60, tokenResponse.ExpiresIn - 7200)); // 2 hours buffer + _memoryCache.Set(cacheKey, tokenResponse.AccessToken, cacheExpiry); + + return tokenResponse.AccessToken; + } + } + } + else + { + var responseContent = await response.Content.ReadAsStringAsync(); + string errMsg = $"Token POST request failed (Status: {response.StatusCode}): {responseContent}"; + _logger.LogWarning("Instinct auth: {ErrorMessage}", LogSanitizer.SanitizeString(errMsg)); + AppendError(result, errMsg); + } + } + catch (Exception ex) when (ex is HttpRequestException || ex is TaskCanceledException || ex is JsonException || ex is InvalidOperationException) + { + string errMsg = $"Token request exception: {ex.Message}"; + _logger.LogWarning(ex, "Instinct auth token request failed"); + AppendError(result, errMsg); + } + + return null; + } + + /// + /// Extract UC Path flags from VwPerson entity + /// + private static List GetUCPathFlags(dynamic person) + { + var flags = new List(); + var flagDefinitions = new Dictionary + { + {"EmpAcdmcFederationFlg", "Academic Federation"}, + {"EmpAcdmcFlg", "ACDMC"}, + {"EmpAcdmcSenateFlg", "ACDMC Senate"}, + {"EmpAcdmcStdtFlg", "ACDMC Stdt"}, + {"EmpFacultyFlg", "Faculty"}, + {"EmpLadderRankFlg", "Ladder Rank"}, + {"EmpMgrFlg", "Manager"}, + {"EmpMspCareerFlg", "MSP Career"}, + {"EmpMspCareerPartialyrFlg", "MSP Career Partial Year"}, + {"EmpMspCasualFlg", "MSP Casual"}, + {"EmpMspCntrctFlg", "MSP Contract"}, + {"EmpMspFlg", "MSP"}, + {"EmpMspSeniorMgmtFlg", "MSP Senior Management"}, + {"EmpSspCareerFlg", "SSP Career"}, + {"EmpSspCareerPartialyrFlg", "SSP Career Partial Year"}, + {"EmpSspCasualFlg", "SSP Casual"}, + {"EmpSspCasualRestrictedFlg", "SSP Casual Restricted"}, + {"EmpSspCntrctFlg", "SSP Contract"}, + {"EmpSspFlg", "SSP"}, + {"EmpSspFloaterFlg", "SSP Floater"}, + {"EmpSspPerDiemFlg", "SSP Per diem"}, + {"EmpSupvrFlg", "Supervisor"}, + {"EmpTeachingFacultyFlg", "Teaching Faculty"}, + {"EmpWosempFlg", "WOSEMP"} + }; + + var personType = person.GetType(); + foreach (var flag in flagDefinitions) + { + var property = personType.GetProperty(flag.Key); + if (property != null) + { + var value = property.GetValue(person)?.ToString(); + if (value == "Y") + { + flags.Add(flag.Value); + } + } + } + return flags; + } + } + + // JSON for Instinct API + public class InstinctTokenResponse + { + [JsonPropertyName("access_token")] + public string AccessToken { get; set; } = string.Empty; + + [JsonPropertyName("expires_in")] + public int ExpiresIn { get; set; } + + [JsonPropertyName("token_type")] + public string TokenType { get; set; } = string.Empty; + + [JsonPropertyName("scope")] + public string Scope { get; set; } = string.Empty; + } + + public class InstinctGraphQLResponse + { + [JsonPropertyName("data")] + public InstinctGraphQLData? Data { get; set; } + + // Standard GraphQL-over-HTTP convention: server-side query failures come back as a 200 + // with this populated (data is often null alongside it), distinct from a query that ran + // fine and simply found no matching users. + [JsonPropertyName("errors")] + public List? Errors { get; set; } + } + + public class InstinctGraphQLData + { + [JsonPropertyName("searchUsers")] + public List? SearchUsers { get; set; } + } + + public class InstinctGraphQLError + { + [JsonPropertyName("message")] + public string? Message { get; set; } + } + + public class InstinctUser + { + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("initials")] + public string? Initials { get; set; } + + [JsonPropertyName("instinctId")] + public string? InstinctId { get; set; } + + [JsonPropertyName("isActive")] + public bool IsActive { get; set; } + + [JsonPropertyName("isProtected")] + public bool IsProtected { get; set; } + + [JsonPropertyName("nameFirst")] + public string? NameFirst { get; set; } + + [JsonPropertyName("nameMiddle")] + public string? NameMiddle { get; set; } + + [JsonPropertyName("nameLast")] + public string? NameLast { get; set; } + + [JsonPropertyName("passwordExpiresAt")] + public string? PasswordExpiresAt { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonPropertyName("username")] + public string? Username { get; set; } + + [JsonPropertyName("roles")] + // ReSharper disable once CollectionNeverUpdated.Global + public List? Roles { get; set; } + } + + public class InstinctRole + { + [JsonPropertyName("description")] + public string? Description { get; set; } + + [JsonPropertyName("label")] + public string? Label { get; set; } + } + + // Helper class for permission processing + public class PermissionInfo + { + public int PermissionId { get; set; } + public string Permission { get; set; } = string.Empty; + public string Access { get; set; } = string.Empty; + public string Source { get; set; } = string.Empty; + public string SourceType { get; set; } = string.Empty; + } + + // Result classes for student stored procedures + public class PriorNameResult + { + public string? StudentName { get; set; } + public DateTime? ActivityDate { get; set; } + } + + public class BannerIdResult + { + public string? SpridenId { get; set; } + } + + public class StudentStatusResult + { + public string? RegStatus { get; set; } + } + + public class RegistrationStatusResult + { + public string? Status { get; set; } + } + + public class MajorResult + { + public string? SGBSTDN_MAJR_CODE_1 { get; set; } + } + + public class AllMajorsResult + { + public string? SGBSTDN_MAJR_CODE_1 { get; set; } + public string? SGBSTDN_MAJR_CODE_2 { get; set; } + } + + public class ClassLevelResult + { + public string? SgvclssClasCode { get; set; } + } + + public class ConfidentialScopeResult + { + public string? ZtvconfDesc { get; set; } + } + + public class AuthDbRecord + { + public string Credential { get; set; } = string.Empty; + public string Username { get; set; } = string.Empty; + public string EncryptedString { get; set; } = string.Empty; + } +} + diff --git a/web/Areas/Directory/Views/Card.cshtml b/web/Areas/Directory/Views/Card.cshtml index 5ef4c0687..eaafac0f4 100644 --- a/web/Areas/Directory/Views/Card.cshtml +++ b/web/Areas/Directory/Views/Card.cshtml @@ -1,8 +1,8 @@ -@using Viper.Classes.SQLContext; -@model Viper.Models.AAUD.AaudUser; +@using Viper.Classes.SQLContext; +@model Viper.Areas.Directory.Models.DirectoryUser; @{ RAPSContext? rapsContext = (RAPSContext?)Context.RequestServices.GetService(typeof(RAPSContext)); - IUserHelper UserHelper = new UserHelper(); + IUserHelper userHelper = new UserHelper(); ViewData["Title"] = "Directory"; }

Directory

@@ -21,16 +21,19 @@ color="primary" label="Search all of UCD"> - - + @if (Model.CanDisplayIDs) + { + + + }
- + User Information @@ -38,37 +41,37 @@ Email {{user.mailId}}@@ucdavis.edu - @if (UserHelper.HasPermission(rapsContext, UserHelper.GetCurrentUser(), "SVMSecure.SU")) + @if (userHelper.HasPermission(rapsContext, userHelper.GetCurrentUser(), "SVMSecure.SU")) { - + Emulate {{user.name}} } - + AAUD Check - + ID Check - + MSO/CAO Lookup - @if (UserHelper.HasPermission(rapsContext, UserHelper.GetCurrentUser(), "SVMSecure.DirectoryUCPathInfo")) + @if (userHelper.HasPermission(rapsContext, userHelper.GetCurrentUser(), "SVMSecure.DirectoryUCPathInfo")) { @: - + UCPath Info } - @if (UserHelper.HasPermission(rapsContext, UserHelper.GetCurrentUser(), "SVMSecure.CATS.ServiceDesk")) + @if (userHelper.HasPermission(rapsContext, userHelper.GetCurrentUser(), "SVMSecure.CATS.ServiceDesk")) { @: - + Alt. Photo @@ -76,8 +79,7 @@ -
- BannerID: - @if (UserHelper.HasPermission(rapsContext, UserHelper.GetCurrentUser(), "SVMSecure.SIS.AllStudents")) + BannerID: + @if (Model.CanSeeAllStudents) { - @: {{ user.spridenId }} + @: {{ user.spridenId }} } else { @@ -138,26 +140,53 @@ ucd: getItemFromStorage("directory_ucd") ?? false, ids: getItemFromStorage("directory_ids") ?? false, spinner: false, - results: [] + results: [], + searchId: 0 } }, methods: { findUsers: async function () { this.spinner = false; this.results = []; - var urlBase = "@Url.Content("~/Directory/search/")" + this.userSearch; + + var search = this.userSearch.trim(); + if (search.length < 2) { + return; + } + + this.searchId++; + var currentSearchId = this.searchId; + + var urlBase = "@Url.Content("~/Directory/search")" + "?search=" + encodeURIComponent(search); if (this.ucd) { - urlBase += "/ucd" + urlBase += "&ucd=true"; } - if (this.userSearch.length >= 2) { - this.spinner = true; - this.results = (await viperFetch(this, urlBase)) - this.spinner = false; + + this.spinner = true; + try { + var response = await viperFetch(this, urlBase); + if (currentSearchId === this.searchId) { + this.results = response || []; + } + } finally { + if (currentSearchId === this.searchId) { + this.spinner = false; + } + } + }, + onLinkClick: function (e, msg) { + if (e.button === 0 && !e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey) { + this.$q.loading.show({ + message: msg || 'Loading...' + }); } } }, mounted() { this.findUsers(); + window.addEventListener("pageshow", () => { + this.$q.loading.hide(); + }); }, watch: { userSearch: function () { @@ -178,4 +207,4 @@ } }) -} \ No newline at end of file +} diff --git a/web/Areas/Directory/Views/Table.cshtml b/web/Areas/Directory/Views/Table.cshtml index 41fb5bcd8..5d9ed9bd8 100644 --- a/web/Areas/Directory/Views/Table.cshtml +++ b/web/Areas/Directory/Views/Table.cshtml @@ -1,8 +1,8 @@ -@using Viper.Classes.SQLContext; +@using Viper.Classes.SQLContext; @model Viper.Models.AAUD.AaudUser; @{ RAPSContext? rapsContext = (RAPSContext?)Context.RequestServices.GetService(typeof(RAPSContext)); - IUserHelper UserHelper = new UserHelper(); + IUserHelper userHelper = new UserHelper(); ViewData["Title"] = "Directory"; }

Directory

@@ -38,7 +38,7 @@ no-results-label="No results found">