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 @@ -11,7 +11,6 @@
using Microting.eForm.Infrastructure.Constants;
using Microting.eFormApi.BasePn.Abstractions;
using Microting.EformAngularFrontendBase.Infrastructure.Data;
using Microting.eFormApi.BasePn.Infrastructure.Database.Entities;
using Microting.eFormApi.BasePn.Infrastructure.Helpers.PluginDbOptions;
using Microting.TimePlanningBase.Infrastructure.Data.Entities;
using NSubstitute;
Expand Down Expand Up @@ -80,8 +79,7 @@ public async Task SetUpTest()

// The all-workers export scopes its site list to the signed-in caller,
// so these fixtures need a real one. Admin: scoping is a no-op.
var adminUserId = await GetBaseDbContextWithAdminAsync();
userService.GetCurrentUserAsync().Returns(new EformUser { Id = adminUserId });
await SeedAdminCallerAsync(userService);

_service = new TimePlanningWorkingHoursService(
Substitute.For<ILogger<TimePlanningWorkingHoursService>>(),
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,15 @@ public async Task SetUpTest()
var sdkDb = core.DbContextHelper.GetDbContext();

// --- SDK graph: site + worker + siteworker, keyed by the user's email ---
//
// The email keying is load-bearing beyond mere lookup: this fixture's
// EformUser carries no admin role and no security groups, so the
// working-hours grid resolves it through the PLAIN-WORKER branch of the
// caller scope — own site only, named by the SDK Worker that matches
// this email. The Index call below asks for SiteUid, the very site this
// worker is linked to, which is the only reason it is not refused.
// Break the email match, or link the worker to a different site, and
// every scoped call in this fixture starts failing with "SiteNotFound".
var language = await sdkDb.Languages.FirstOrDefaultAsync(l => l.LanguageCode == "da");
if (language == null)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -826,6 +826,10 @@ async Task<PlanRegistration> Stored(PlanRegistration row) =>
[Test]
public async Task WorkingHoursIndex_MarksReconciledLockedDaysAsIsLocked()
{
// Index scopes the requested site to the signed-in caller; this seeds
// the admin user and points _userService at it ("me").
await using var baseDbContext = GetBaseDbContext();
await BuildAdminIndexServiceAsync(baseDbContext);
await SeedAssignedSiteAsync(930);
// Keep the MaxDaysEditable window out of the way, so only the
// reconciled lock can set IsLocked on these past days.
Expand All @@ -835,7 +839,7 @@ public async Task WorkingHoursIndex_MarksReconciledLockedDaysAsIsLocked()
await SeedReconciledBoundaryAsync(930, DateTime.Now.Date.AddDays(-3));
await SeedPlain(930, DateTime.Now.Date.AddDays(-1));

var result = await BuildWorkingHoursService().Index(new TimePlanningWorkingHoursRequestModel
var result = await BuildWorkingHoursService(baseDbContext).Index(new TimePlanningWorkingHoursRequestModel
{
SiteId = 930,
DateFrom = DateTime.Now.Date.AddDays(-10),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@
using eFormCore;
using Microsoft.EntityFrameworkCore;
using Microting.eForm.Infrastructure;
using Microting.eFormApi.BasePn.Abstractions;
using Microting.eFormApi.BasePn.Infrastructure.Database.Entities;
using Microting.EformAngularFrontendBase.Infrastructure.Data;
using Microting.TimePlanningBase.Infrastructure.Data;
using NSubstitute;
using NUnit.Framework;
using Testcontainers.MariaDb;
using TimePlanning.Pn.Infrastructure.Data.Seed;
Expand Down Expand Up @@ -114,10 +116,9 @@ protected BaseDbContext GetBaseDbContext()
/// <summary>
/// Seeds a <see cref="SeededBaseDbContext"/> holding one admin user and
/// returns that user's id. Services that scope their result to the signed-in
/// caller — the planning board, the site-tags lookup, the all-workers
/// export — need a real caller to resolve; this is the admin caller, for
/// whom scoping is a no-op. Point the IUserService substitute's
/// GetCurrentUserAsync at the returned id.
/// caller — the planning board, the site-tags lookup, the working-hours grid,
/// both exports — need a real caller to resolve; this is the admin caller,
/// for whom scoping is a no-op.
/// </summary>
protected async Task<int> GetBaseDbContextWithAdminAsync(string email = "admin@example.com")
{
Expand All @@ -144,6 +145,20 @@ protected async Task<int> GetBaseDbContextWithAdminAsync(string email = "admin@e
return user.Id;
}

/// <summary>
/// Seeds the admin caller AND points <paramref name="userService"/> at it —
/// the two halves belong together, because a service handed
/// <see cref="SeededBaseDbContext"/> without a matching
/// <c>GetCurrentUserAsync</c> resolves no caller at all and every scoped
/// call fails.
/// </summary>
protected async Task SeedAdminCallerAsync(
IUserService userService, string email = "admin@example.com")
{
var adminUserId = await GetBaseDbContextWithAdminAsync(email);
userService.GetCurrentUserAsync().Returns(new EformUser { Id = adminUserId });
}

/// <summary>
/// The connection string of the plugin database <see cref="Setup"/>
/// migrates, for tests that must build a context the way production does
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,16 @@ public async Task SetUpTest()
SnapshotEnabled = "0"
});

// Index scopes the requested site to the signed-in caller, so this
// fixture needs a real one. Admin: scoping is a no-op.
await SeedAdminCallerAsync(userService);

_service = new TimePlanningWorkingHoursService(
Substitute.For<ILogger<TimePlanningWorkingHoursService>>(),
TimePlanningPnDbContext!,
userService,
localizationService,
baseDbContext: null!,
baseDbContext: SeededBaseDbContext!,
options,
coreService);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,16 @@ public async Task SetUpTest()
SnapshotEnabled = "0"
});

// The export scopes the requested site to the signed-in caller, so this
// fixture needs a real one. Admin: scoping is a no-op.
await SeedAdminCallerAsync(userService);

_service = new TimePlanningWorkingHoursService(
Substitute.For<ILogger<TimePlanningWorkingHoursService>>(),
TimePlanningPnDbContext!,
userService,
localizationService,
baseDbContext: null!,
baseDbContext: SeededBaseDbContext!,
options,
coreService);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
using Microting.eForm.Infrastructure.Constants;
using Microting.eFormApi.BasePn.Abstractions;
using Microting.EformAngularFrontendBase.Infrastructure.Data;
using Microting.eFormApi.BasePn.Infrastructure.Database.Entities;
using Microting.eFormApi.BasePn.Infrastructure.Helpers.PluginDbOptions;
using NSubstitute;
using NUnit.Framework;
Expand Down Expand Up @@ -81,8 +80,7 @@ public async Task SetUpTest()

// The all-workers export scopes its site list to the signed-in caller,
// so these fixtures need a real one. Admin: scoping is a no-op.
var adminUserId = await GetBaseDbContextWithAdminAsync();
userService.GetCurrentUserAsync().Returns(new EformUser { Id = adminUserId });
await SeedAdminCallerAsync(userService);

_service = new TimePlanningWorkingHoursService(
Substitute.For<ILogger<TimePlanningWorkingHoursService>>(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
using Microting.eForm.Infrastructure.Constants;
using Microting.eFormApi.BasePn.Abstractions;
using Microting.EformAngularFrontendBase.Infrastructure.Data;
using Microting.eFormApi.BasePn.Infrastructure.Database.Entities;
using Microting.eFormApi.BasePn.Infrastructure.Helpers.PluginDbOptions;
using NSubstitute;
using NUnit.Framework;
Expand Down Expand Up @@ -108,8 +107,7 @@ public async Task SetUpTest()

// The all-workers export scopes its site list to the signed-in caller,
// so these fixtures need a real one. Admin: scoping is a no-op.
var adminUserId = await GetBaseDbContextWithAdminAsync();
userService.GetCurrentUserAsync().Returns(new EformUser { Id = adminUserId });
await SeedAdminCallerAsync(userService);

_service = new TimePlanningWorkingHoursService(
Substitute.For<ILogger<TimePlanningWorkingHoursService>>(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
using Microting.eForm.Infrastructure.Constants;
using Microting.eFormApi.BasePn.Abstractions;
using Microting.EformAngularFrontendBase.Infrastructure.Data;
using Microting.eFormApi.BasePn.Infrastructure.Database.Entities;
using Microting.eFormApi.BasePn.Infrastructure.Helpers.PluginDbOptions;
using NSubstitute;
using NUnit.Framework;
Expand Down Expand Up @@ -104,8 +103,7 @@ public async Task SetUpTest()

// The all-workers export scopes its site list to the signed-in caller,
// so these fixtures need a real one. Admin: scoping is a no-op.
var adminUserId = await GetBaseDbContextWithAdminAsync();
userService.GetCurrentUserAsync().Returns(new EformUser { Id = adminUserId });
await SeedAdminCallerAsync(userService);

_service = new TimePlanningWorkingHoursService(
Substitute.For<ILogger<TimePlanningWorkingHoursService>>(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Spreadsheet;
using DocumentFormat.OpenXml.Validation;
using Microting.eForm.Infrastructure;
using Microting.eForm.Infrastructure.Data.Entities;
using Microting.EformAngularFrontendBase.Infrastructure.Data;
using Microting.TimePlanningBase.Infrastructure.Data.Entities;
Expand Down Expand Up @@ -69,8 +70,88 @@ public class TimePlanningWorkingHoursService(
IEFormCoreService coreHelper)
: ITimePlanningWorkingHoursService
{
/// <summary>
/// The set of sites the signed-in caller may see — the same scope the
/// planning board and the export dialog's worker count apply. Check
/// <see cref="SiteScope.ErrorKey"/> before reading anything else.
/// </summary>
private async Task<SiteScope> ResolveScopeAsync(MicrotingDbContext sdkContext)
{
var assignedSites = await dbContext.AssignedSites
.AsNoTracking()
.Where(x => x.WorkflowState != Constants.WorkflowStates.Removed)
.ToListAsync();
return await SiteScopeResolver
.ResolveForCurrentUserAsync(assignedSites, dbContext, sdkContext, baseDbContext, userService)
.ConfigureAwait(false);
}

/// <summary>
/// Null when the signed-in caller may see <paramref name="siteId"/>,
/// otherwise the localization key to refuse with.
/// </summary>
/// <remarks>
/// Every site-specific reason answers with the SAME key on purpose. An
/// unknown site id, a site whose AssignedSite is removed, and a real site
/// belonging to somebody else are indistinguishable to the caller —
/// otherwise the two different 400 bodies let anyone enumerate which site
/// ids exist. The caller-specific keys the resolver itself returns
/// (UserNotFound and friends) do not reopen that: they depend only on who
/// is asking, so they are the same for every id that caller tries.
///
/// An unknown id is refused for an admin too, and that is what makes the
/// two cases collapse: no AssignedSite row means the id is in nobody's
/// scope, admin's included.
/// </remarks>
private async Task<string?> ResolveSiteAccessErrorAsync(int siteId, MicrotingDbContext sdkContext)
{
var scope = await ResolveScopeAsync(sdkContext);
if (scope.ErrorKey != null)
{
return scope.ErrorKey;
}

return scope.Narrow([siteId]).Count == 0 ? "SiteNotFound" : null;
}

/// <summary>
/// The working-hours grid for one site. SiteId comes from the request body,
/// so the caller's scope is checked here before any data is read; the export
/// paths call <see cref="IndexUnscoped"/> instead, having resolved the very
/// same scope once for the whole workbook.
/// </summary>
public async Task<OperationDataResult<List<TimePlanningWorkingHoursModel>>> Index(
TimePlanningWorkingHoursRequestModel model)
{
try
{
var core = await coreHelper.GetCore();
await using var sdkDbContext = core.DbContextHelper.GetDbContext();
var accessError = await ResolveSiteAccessErrorAsync(model.SiteId, sdkDbContext);
if (accessError != null)
{
return new OperationDataResult<List<TimePlanningWorkingHoursModel>>(
false, localizationService.GetString(accessError));
}
}
catch (Exception ex)
{
SentrySdk.CaptureException(ex);
logger.LogError(ex.Message);
return new OperationDataResult<List<TimePlanningWorkingHoursModel>>(
false, localizationService.GetString("ErrorWhileObtainingPlannings"));
}

return await IndexUnscoped(model);
}

/// <summary>
/// <see cref="Index"/> without the caller-scope check. Private, and named
/// for what it omits: every caller must have established that the signed-in
/// user may see <c>model.SiteId</c> before calling it.
/// </summary>
private async Task<OperationDataResult<List<TimePlanningWorkingHoursModel>>> IndexUnscoped(
TimePlanningWorkingHoursRequestModel model)
{
try
{
Expand Down Expand Up @@ -2735,6 +2816,26 @@ public async Task<OperationDataResult<Stream>> GenerateExcelDashboard(TimePlanni
{
var core = await coreHelper.GetCore();
var sdkContext = core.DbContextHelper.GetDbContext();

// Scope to the caller, from the same code the planning board, the
// export dialog's worker count and the all-workers export use.
// SiteId arrives straight from the query string, so without this any
// signed-in user could export any worker's hours by guessing an id.
//
// Before the lookups below, not after: those throw into the catch
// for an id that names no site, and that second, distinguishable
// 400 body would tell the guesser which ids are real. Refusing here
// gives the unknown id and the out-of-scope id one answer.
var accessError = await ResolveSiteAccessErrorAsync(model.SiteId, sdkContext);
if (accessError != null)
{
// Refuse outright rather than narrow: an empty or substituted
// workbook would read as a successful export of the worker that
// was asked for.
return new OperationDataResult<Stream>(false,
localizationService.GetString(accessError));
}

var site = await sdkContext.Sites.FirstAsync(x => x.MicrotingUid == model.SiteId);
var siteWorker = await sdkContext.SiteWorkers.FirstAsync(x => x.SiteId == site.Id);
var worker = await sdkContext.Workers.FirstAsync(x => x.Id == siteWorker!.WorkerId);
Expand Down Expand Up @@ -2778,8 +2879,10 @@ public async Task<OperationDataResult<Stream>> GenerateExcelDashboard(TimePlanni
var timeStamp = $"{DateTime.UtcNow:yyyyMMdd_HHmmss}_{Guid.NewGuid():N}";
var filePath = Path.Combine(Path.GetTempPath(), "results", $"{timeStamp}_.xlsx");

// Fetch data early so we can pre-compute pay lines for header discovery
var content = await Index(model);
// Fetch data early so we can pre-compute pay lines for header
// discovery. Unscoped: the gate at the top of this method has
// already cleared this SiteId for this caller.
var content = await IndexUnscoped(model);
if (!content.Success) return new OperationDataResult<Stream>(false, content.Message);

// remove the first entry from the content.Model
Expand Down Expand Up @@ -3373,13 +3476,7 @@ public async Task<OperationDataResult<Stream>> GenerateExcelDashboard(
// export dialog's worker count use. Without this a manager saw "3
// workers" in the dialog and downloaded the whole organisation —
// and any non-admin could export every worker in the system.
var assignedSites = await dbContext.AssignedSites
.AsNoTracking()
.Where(x => x.WorkflowState != Constants.WorkflowStates.Removed)
.ToListAsync();
var scope = await SiteScopeResolver
.ResolveForCurrentUserAsync(assignedSites, dbContext, sdkContext, baseDbContext, userService)
.ConfigureAwait(false);
var scope = await ResolveScopeAsync(sdkContext);
if (scope.ErrorKey != null)
{
return new OperationDataResult<Stream>(false,
Expand Down Expand Up @@ -3469,7 +3566,10 @@ public async Task<OperationDataResult<Stream>> GenerateExcelDashboard(
}
}

var dataResult = await Index(new TimePlanningWorkingHoursRequestModel
// Unscoped: siteIds was narrowed to the caller's scope above, and
// re-resolving that same scope once per site would be N round
// trips for an answer that cannot change between them.
var dataResult = await IndexUnscoped(new TimePlanningWorkingHoursRequestModel
{
DateFrom = model.DateFrom,
DateTo = model.DateTo,
Expand Down
Loading