Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6421,7 +6421,7 @@
<value>تستفيد معظم الأقسام من هذه.</value>
</data>
<data xml:space="preserve" name="Ui.Title">
<value>Resgrid Admin Assist</value>
<value>Admin Assist</value>
</data>
<data xml:space="preserve" name="Ui.True">
<value>مفعّل</value>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6421,7 +6421,7 @@
<value>Die meisten Abteilungen profitieren davon.</value>
</data>
<data xml:space="preserve" name="Ui.Title">
<value>Resgrid Admin Assist</value>
<value>Admin Assist</value>
</data>
<data xml:space="preserve" name="Ui.True">
<value>Aktiviert</value>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6421,7 +6421,7 @@
<value>Τα περισσότερα τμήματα επωφελούνται από αυτές.</value>
</data>
<data xml:space="preserve" name="Ui.Title">
<value>Resgrid Admin Assist</value>
<value>Admin Assist</value>
</data>
<data xml:space="preserve" name="Ui.True">
<value>Ενεργοποιημένο</value>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6421,7 +6421,7 @@
<value>Most departments benefit from these.</value>
</data>
<data xml:space="preserve" name="Ui.Title">
<value>Resgrid Admin Assist</value>
<value>Admin Assist</value>
</data>
<data xml:space="preserve" name="Ui.True">
<value>Enabled</value>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6421,7 +6421,7 @@
<value>La mayoría de los departamentos se benefician de estos.</value>
</data>
<data xml:space="preserve" name="Ui.Title">
<value>Resgrid Admin Assist</value>
<value>Admin Assist</value>
</data>
<data xml:space="preserve" name="Ui.True">
<value>Habilitado</value>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6421,7 +6421,7 @@
<value>La plupart des départements en tirent parti.</value>
</data>
<data xml:space="preserve" name="Ui.Title">
<value>Resgrid Admin Assist</value>
<value>Admin Assist</value>
</data>
<data xml:space="preserve" name="Ui.True">
<value>Activé</value>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6421,7 +6421,7 @@
<value>La maggior parte dei dipartimenti ne trae vantaggio.</value>
</data>
<data xml:space="preserve" name="Ui.Title">
<value>Resgrid Admin Assist</value>
<value>Admin Assist</value>
</data>
<data xml:space="preserve" name="Ui.True">
<value>Attivato</value>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6421,7 +6421,7 @@
<value>Większość oddziałów odnosi z nich korzyści.</value>
</data>
<data xml:space="preserve" name="Ui.Title">
<value>Resgrid Admin Assist</value>
<value>Admin Assist</value>
</data>
<data xml:space="preserve" name="Ui.True">
<value>Włączone</value>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6421,7 +6421,7 @@
<value>Most departments benefit from these.</value>
</data>
<data xml:space="preserve" name="Ui.Title">
<value>Resgrid Admin Assist</value>
<value>Admin Assist</value>
</data>
<data xml:space="preserve" name="Ui.True">
<value>Enabled</value>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6421,7 +6421,7 @@
<value>De flesta avdelningar har nytta av dessa.</value>
</data>
<data xml:space="preserve" name="Ui.Title">
<value>Resgrid Admin Assist</value>
<value>Admin Assist</value>
</data>
<data xml:space="preserve" name="Ui.True">
<value>Aktiverad</value>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6421,7 +6421,7 @@
<value>Більшість підрозділів отримують користь від цих модулів.</value>
</data>
<data xml:space="preserve" name="Ui.Title">
<value>Resgrid Admin Assist</value>
<value>Admin Assist</value>
</data>
<data xml:space="preserve" name="Ui.True">
<value>Увімкнено</value>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,29 @@ namespace Resgrid.Services.AdminAssist
/// <summary>Fresh rollout checks. Missing flags and evaluation failures disable the optional feature.</summary>
public static class AdminAssistFeatureAvailability
{
public static async Task<bool> IsEnabledAsync(IFeatureToggleService flags, int departmentId, bool setup, CancellationToken ct = default)
public static Task<bool> IsEnabledAsync(IFeatureToggleService flags, int departmentId, bool setup, CancellationToken ct = default) =>
IsFlagEnabledAsync(flags, setup ? FeatureFlagKeys.AdminSetup : FeatureFlagKeys.AdminAssist, departmentId, ct);

/// <summary>
/// The Admin Assist workspace (its menu entry and pages) launches with its AI, after the Setup Wizard and Setup Report.
/// Admin.Assist alone still drives field help, impact previews and digests; the workspace also needs Ai.AdminAssist.
/// </summary>
public static async Task<bool> IsWorkspaceEnabledAsync(IFeatureToggleService flags, int departmentId, CancellationToken ct = default) =>
await IsEnabledAsync(flags, departmentId, false, ct) && await IsFlagEnabledAsync(flags, FeatureFlagKeys.AiAdminAssist, departmentId, ct);

public static async Task<bool> CanConfigureOperatingProfileAsync(IFeatureToggleService flags, int departmentId, CancellationToken ct = default) =>
await IsEnabledAsync(flags, departmentId, true, ct) || await IsEnabledAsync(flags, departmentId, false, ct);

private static async Task<bool> IsFlagEnabledAsync(IFeatureToggleService flags, string key, int departmentId, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
if (departmentId <= 0) return false;
try
{
return (await flags.EvaluateFreshAsync(setup ? FeatureFlagKeys.AdminSetup : FeatureFlagKeys.AdminAssist, departmentId).WaitAsync(ct))?.IsEnabled == true;
return (await flags.EvaluateFreshAsync(key, departmentId).WaitAsync(ct))?.IsEnabled == true;
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch (Exception) { return false; }
}

public static async Task<bool> CanConfigureOperatingProfileAsync(IFeatureToggleService flags, int departmentId, CancellationToken ct = default) =>
await IsEnabledAsync(flags, departmentId, true, ct) || await IsEnabledAsync(flags, departmentId, false, ct);
}
}
16 changes: 16 additions & 0 deletions Tests/Resgrid.Tests/AdminAssist/FeatureToggleTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,22 @@ public async Task Setup_and_assist_are_independent_and_AI_does_not_enable_either
Assert.That(await AdminAssistFeatureAvailability.CanConfigureOperatingProfileAsync(_flags.Object,7), Is.EqualTo(setup || assist));
_flags.Verify(f => f.EvaluateFreshAsync(FeatureFlagKeys.AiAdminAssist,7), Times.Never);
}
[TestCase(false,false)] [TestCase(true,false)] [TestCase(false,true)] [TestCase(true,true)]
public async Task Workspace_launches_only_with_both_assist_and_AI(bool assist, bool ai)
{
Set(FeatureFlagKeys.AdminAssist,assist); Set(FeatureFlagKeys.AiAdminAssist,ai);
Assert.That(await AdminAssistFeatureAvailability.IsWorkspaceEnabledAsync(_flags.Object,7), Is.EqualTo(assist && ai));
// Field help, previews and digests keep following Admin.Assist alone.
Assert.That(await _access.CanAccessAsync(Actor,false), Is.EqualTo(assist));
}
[Test]
public async Task Workspace_fails_closed_when_the_AI_flag_is_missing_or_unavailable()
{
Set(FeatureFlagKeys.AdminAssist,true);
Assert.That(await AdminAssistFeatureAvailability.IsWorkspaceEnabledAsync(_flags.Object,7), Is.False);
_flags.Setup(f => f.EvaluateFreshAsync(FeatureFlagKeys.AiAdminAssist,7)).ThrowsAsync(new InvalidOperationException("Store unavailable"));
Assert.That(await AdminAssistFeatureAvailability.IsWorkspaceEnabledAsync(_flags.Object,7), Is.False);
}
[TestCase(true)] [TestCase(false)]
public async Task Missing_flag_and_store_outage_fail_closed(bool setup)
{
Expand Down
91 changes: 91 additions & 0 deletions Tests/Resgrid.Tests/AdminAssist/SetupPromptVisibilityTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
using System;
using System.Collections.Generic;
using System.Security.Claims;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewComponents;
using Microsoft.AspNetCore.Mvc.ViewEngines;
using Moq;
using NUnit.Framework;
using Resgrid.AdminAssist;
using Resgrid.Model.AdminAssist;
using Resgrid.Providers.Claims;
using Resgrid.Web.Helpers;
using Resgrid.Web.ViewComponents;

namespace Resgrid.Tests.AdminAssist
{
/// <summary>The dashboard's Setup Wizard banner is for department administrators only.</summary>
[TestFixture, NonParallelizable]
public class SetupPromptVisibilityTests
{
private static readonly AdminAssistActor Actor = new(7, "user-1");
private static readonly ConfigurationCatalog Catalog = new();
private IHttpContextAccessor _previousAccessor;
private Mock<IAdminAssistAccessService> _access;
private Mock<IAdminAssistRepository> _repository;

[SetUp]
public void SetUp()
{
_previousAccessor = ClaimsAuthorizationHelper._httpContextAccessor;
_access = new Mock<IAdminAssistAccessService>(MockBehavior.Strict);
_repository = new Mock<IAdminAssistRepository>(MockBehavior.Strict);
}

[TearDown]
public void TearDown() => ClaimsAuthorizationHelper._httpContextAccessor = _previousAccessor;

[Test]
public async Task Member_never_sees_the_banner_and_costs_no_flag_or_workspace_read()
{
var result = await InvokeAsync(admin: false);

Assert.That(result, Is.InstanceOf<ContentViewComponentResult>());
Assert.That(((ContentViewComponentResult)result).Content, Is.Empty);
_access.VerifyNoOtherCalls(); _repository.VerifyNoOtherCalls();
}

[Test]
public async Task Admin_sees_the_banner_while_setup_is_open()
{
_access.Setup(a => a.CanAccessAsync(It.Is<AdminAssistActor>(x => x.DepartmentId == Actor.DepartmentId && x.UserId == Actor.UserId), true, It.IsAny<CancellationToken>())).ReturnsAsync(true);
_repository.Setup(r => r.GetWorkspaceAsync(Actor.DepartmentId, Actor.UserId, Catalog.Version, It.IsAny<CancellationToken>()))
.ReturnsAsync(new SetupWorkspace(Actor.DepartmentId, 0, SetupMode.Fresh, new Dictionary<string, SetupAreaChoice>(), Array.Empty<string>(), Array.Empty<string>(), Catalog.Version, null));

var result = await InvokeAsync(admin: true);

Assert.That(result, Is.InstanceOf<ViewViewComponentResult>());
}

[Test]
public async Task Admin_claim_alone_is_not_enough_when_fresh_access_is_refused()
{
// The claim may be stale; the fresh membership and rollout check still decides.
_access.Setup(a => a.CanAccessAsync(It.IsAny<AdminAssistActor>(), true, It.IsAny<CancellationToken>())).ReturnsAsync(false);

var result = await InvokeAsync(admin: true);

Assert.That(((ContentViewComponentResult)result).Content, Is.Empty);
_repository.VerifyNoOtherCalls();
}

private async Task<IViewComponentResult> InvokeAsync(bool admin)
{
var claims = new List<Claim> { new(ClaimTypes.PrimarySid, Actor.UserId), new(ClaimTypes.PrimaryGroupSid, Actor.DepartmentId.ToString()) };
if (admin) claims.Add(new Claim(ResgridClaimTypes.Resources.Department, ResgridClaimTypes.Actions.Update));
var http = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity(claims, "Test")) };
ClaimsAuthorizationHelper._httpContextAccessor = new HttpContextAccessor { HttpContext = http };

var component = new AdminAssistSetupPromptViewComponent(_access.Object, _repository.Object, Catalog)
{
ViewComponentContext = new ViewComponentContext { ViewContext = new ViewContext { HttpContext = http } },
ViewEngine = Mock.Of<ICompositeViewEngine>()
};
return await component.InvokeAsync();
}
}
}
74 changes: 74 additions & 0 deletions Tests/Resgrid.Tests/Web/ApiLocalizationRegistrationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
using System;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Localization;
using NUnit.Framework;
using AdminAssistLabels = Resgrid.Localization.Areas.User.AdminAssist.AdminAssist;

namespace Resgrid.Tests.Web
{
/// <summary>
/// The API host never registered localization, so every v4 controller taking an IStringLocalizer (Admin Assist among
/// them) failed to construct and returned 500. The Setup Wizard and Setup Report showed only "The report could not be
/// loaded" because their Catalog and Overview reads never reached the controller.
/// </summary>
[TestFixture]
public class ApiLocalizationRegistrationTests
{
[Test]
public void Api_startup_registers_localization_for_controllers_that_take_string_localizers()
{
var localized = typeof(Resgrid.Web.Services.Controllers.v4.AdminAssistController).Assembly.GetTypes()
.Where(t => typeof(ControllerBase).IsAssignableFrom(t) && !t.IsAbstract)
.Where(t => t.GetConstructors().Any(c => c.GetParameters().Any(p => p.ParameterType.IsGenericType &&
p.ParameterType.GetGenericTypeDefinition() == typeof(IStringLocalizer<>))))
.Select(t => t.Name)
.ToList();
Comment on lines +26 to +31

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

kody code-review Kody Rules high

The long LINQ chain obscures the separate filtering and projection steps for controllers and localized. Store the controller filter in a named intermediate query before applying the localization filter and projection.

Kody rule violation: Limit Lengthy LINQ Chains

var controllers = typeof(Resgrid.Web.Services.Controllers.v4.AdminAssistController).Assembly.GetTypes()
				.Where(t => typeof(ControllerBase).IsAssignableFrom(t) && !t.IsAbstract);
			var localized = controllers
				.Where(t => t.GetConstructors().Any(c => c.GetParameters().Any(p => p.ParameterType.IsGenericType &&
					p.ParameterType.GetGenericTypeDefinition() == typeof(IStringLocalizer<>))))
				.Select(t => t.Name)
				.ToList();
Prompt for LLM

File Tests/Resgrid.Tests/Web/ApiLocalizationRegistrationTests.cs:

Line 26 to 31:

The long LINQ chain obscures the separate filtering and projection steps for `controllers` and `localized`. Store the controller filter in a named intermediate query before applying the localization filter and projection.

Suggested Code:

var controllers = typeof(Resgrid.Web.Services.Controllers.v4.AdminAssistController).Assembly.GetTypes()
				.Where(t => typeof(ControllerBase).IsAssignableFrom(t) && !t.IsAbstract);
			var localized = controllers
				.Where(t => t.GetConstructors().Any(c => c.GetParameters().Any(p => p.ParameterType.IsGenericType &&
					p.ParameterType.GetGenericTypeDefinition() == typeof(IStringLocalizer<>))))
				.Select(t => t.Name)
				.ToList();

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

​

​

localized.Should().Contain("AdminAssistController");

var startup = File.ReadAllText(FindRepositoryFile("Web/Resgrid.Web.Services/Startup.cs"));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

kody code-review Kody Rules high

An IOException from File.ReadAllText(FindRepositoryFile("Web/Resgrid.Web.Services/Startup.cs")) currently lacks contextual reporting when Startup.cs cannot be read. Wrap the file-system read in try/catch and call Assert.Fail with the exception message.

Kody rule violation: Add try-catch blocks for external calls

string startup;
try
{
	startup = File.ReadAllText(FindRepositoryFile("Web/Resgrid.Web.Services/Startup.cs"));
}
catch (IOException exception)
{
	Assert.Fail($"Unable to read Startup.cs: {exception.Message}");
	throw;
}
Prompt for LLM

File Tests/Resgrid.Tests/Web/ApiLocalizationRegistrationTests.cs:

Line 34:

An `IOException` from `File.ReadAllText(FindRepositoryFile("Web/Resgrid.Web.Services/Startup.cs"))` currently lacks contextual reporting when `Startup.cs` cannot be read. Wrap the file-system read in `try`/`catch` and call `Assert.Fail` with the exception message.

Suggested Code:

string startup;
try
{
	startup = File.ReadAllText(FindRepositoryFile("Web/Resgrid.Web.Services/Startup.cs"));
}
catch (IOException exception)
{
	Assert.Fail($"Unable to read Startup.cs: {exception.Message}");
	throw;
}

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

​

​

Regex.IsMatch(startup, @"^\s*services\.AddLocalization\(", RegexOptions.Multiline)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

kody code-review Kody Rules high

Regular expression processing without a timeout in Regex.IsMatch can allow untrusted input to trigger denial-of-service (DoS) attacks. Define a timeout for the regular expression.

Kody rule violation: Specify Timeout for Regular Expressions

Prompt for LLM

File Tests/Resgrid.Tests/Web/ApiLocalizationRegistrationTests.cs:

Line 35:

Regular expression processing without a timeout in `Regex.IsMatch` can allow untrusted input to trigger denial-of-service (DoS) attacks. Define a timeout for the regular expression.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

​

​

.Should().BeTrue($"the API constructs {string.Join(", ", localized)} with an IStringLocalizer");
}

[TestCase("")]
[TestCase("en")]
[TestCase("de")]
public void Admin_assist_catalog_strings_resolve_from_the_registered_localizer(string culture)
{
var previous = CultureInfo.CurrentUICulture;
try
{
CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo(culture);
// The host supplies logging; the localizer factory depends on it.
using var provider = new ServiceCollection().AddLogging().AddLocalization().BuildServiceProvider();
var labels = provider.GetRequiredService<IStringLocalizer<AdminAssistLabels>>();

// The same projection the Catalog endpoint returns to the page.
var strings = labels.GetAllStrings(true).GroupBy(s => s.Name).ToDictionary(g => g.Key, g => g.First().Value);

strings.Should().ContainKey("Ui.Error");
strings["Ui.Title"].Should().Be("Admin Assist");
}
finally { CultureInfo.CurrentUICulture = previous; }
}

private static string FindRepositoryFile(string relativePath)
{
var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory);

while (directory != null && !File.Exists(Path.Combine(directory.FullName, "Resgrid.sln")))
directory = directory.Parent;

if (directory == null)
throw new InvalidOperationException("Unable to locate the repository root.");

return Path.Combine(directory.FullName, relativePath.Replace('/', Path.DirectorySeparatorChar));
}
}
}
4 changes: 4 additions & 0 deletions Web/Resgrid.Web.Services/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,10 @@ public void ConfigureServices(IServiceCollection services)

services.AddCors();

// v4 controllers (Admin Assist, Checklists, Inventory, Work Orders) take IStringLocalizer<T>. Without this
// registration none of them can be constructed and every request to them fails with a 500.
services.AddLocalization();

services.AddControllers(options =>
{
// ADP department operation lock: refuses department-scoped mutations with 423 Locked
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ namespace Resgrid.Web.Areas.User.Controllers
[Authorize]
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
public sealed class AdminAssistController(IAdminAssistAccessService access, IAdminAssistService service,
IDepartmentDataProtectionService protection) : SecureBaseController
IDepartmentDataProtectionService protection, IFeatureToggleService flags) : SecureBaseController
{
[HttpGet]
public Task<IActionResult> Index(CancellationToken cancellationToken) => PageAsync("overview", false, cancellationToken);
Expand Down Expand Up @@ -48,6 +48,7 @@ public async Task<IActionResult> PrintReport(bool setup, CancellationToken cance
Response.Headers["Referrer-Policy"] = "no-referrer";
try
{
if (!setup && !await Resgrid.Services.AdminAssist.AdminAssistFeatureAvailability.IsWorkspaceEnabledAsync(flags, DepartmentId, cancellationToken)) return NotFound();
// A new authorized read: no previously rendered worklist content or browser snapshot is reused.
var overview = await service.GetOverviewAsync(new AdminAssistActor(DepartmentId, UserId, CultureInfo.CurrentUICulture.Name), setup, cancellationToken);
return View("PrintReport", overview);
Expand Down Expand Up @@ -75,6 +76,8 @@ private async Task<IActionResult> PageAsync(string page, bool setup, Cancellatio
{
var actor = new AdminAssistActor(DepartmentId, UserId, CultureInfo.CurrentUICulture.Name);
if (!await access.CanAccessAsync(actor, setup, ct)) return NotFound();
// The workspace launches after the Setup Wizard and Setup Report; it stays hidden until its AI is on.
if (!setup && !await Resgrid.Services.AdminAssist.AdminAssistFeatureAvailability.IsWorkspaceEnabledAsync(flags, DepartmentId, ct)) return NotFound();
ViewBag.AdminAssistPage = page;
ViewBag.AdminAssistSetup = setup;
ViewBag.AdminAssistProtected = await protection.IsProtectionEnforcedAsync(DepartmentId).WaitAsync(ct);
Expand Down
Loading
Loading