From 3c56233742e956151c106d165ed47ce3d79791c9 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Fri, 2 Oct 2026 19:19:44 -0700 Subject: [PATCH 1/4] RG-T137 Edit Profile Save issue Fix, 2FA step up fix, Call Field Fix --- .../Areas/User/TwoFactor/TwoFactor.ar.resx | 24 + .../Areas/User/TwoFactor/TwoFactor.de.resx | 24 + .../Areas/User/TwoFactor/TwoFactor.el.resx | 24 + .../Areas/User/TwoFactor/TwoFactor.en.resx | 24 + .../Areas/User/TwoFactor/TwoFactor.es.resx | 24 + .../Areas/User/TwoFactor/TwoFactor.fr.resx | 24 + .../Areas/User/TwoFactor/TwoFactor.it.resx | 24 + .../Areas/User/TwoFactor/TwoFactor.pl.resx | 24 + .../Areas/User/TwoFactor/TwoFactor.sv.resx | 24 + .../Areas/User/TwoFactor/TwoFactor.uk.resx | 24 + Core/Resgrid.Model/EventingTypes.cs | 5 +- .../Events/UnitStatusAlertUpdatedEvent.cs | 13 + Core/Resgrid.Model/NewCallFieldPolicy.cs | 6 + .../Providers/IRabbitInboundEventProvider.cs | 3 + ...itStatusAlertAcknowledgementsRepository.cs | 17 + Core/Resgrid.Model/Services/IPushService.cs | 2 +- Core/Resgrid.Model/Services/ISmsService.cs | 2 +- .../Services/IUnitStatusAlertsService.cs | 36 ++ .../UnitStatusAlertAcknowledgement.cs | 96 ++++ .../UnitStatusAlertAcknowledgementResult.cs | 36 ++ .../Resgrid.Model/UnitStatusAlertEvaluator.cs | 66 +++ Core/Resgrid.Services/CommunicationService.cs | 2 +- .../ProtectedPushServiceDecorator.cs | 2 +- Core/Resgrid.Services/PushService.cs | 4 +- Core/Resgrid.Services/ServicesModule.cs | 1 + Core/Resgrid.Services/SmsService.cs | 21 +- .../UnitStatusAlertsService.cs | 190 ++++++++ .../RabbitInboundEventProvider.cs | 6 + .../RabbitTopicProvider.cs | 5 + .../OutboundEventProvider.cs | 9 + ...0258_AddUnitStatusAlertAcknowledgements.cs | 73 +++ ...58_AddUnitStatusAlertAcknowledgementsPg.cs | 66 +++ .../DeleteRepository.cs | 3 + .../Modules/ApiDataModule.cs | 1 + .../Modules/DataModule.cs | 1 + .../Modules/NonWebDataModule.cs | 1 + .../Modules/TestingDataModule.cs | 1 + ...itStatusAlertAcknowledgementsRepository.cs | 24 + Resgrid.sln | 206 --------- .../Models/NewCallFieldPolicyTests.cs | 40 ++ .../Models/UnitStatusAlertEvaluatorTests.cs | 141 ++++++ ...tatusAlertAcknowledgementsDatabaseTests.cs | 252 +++++++++++ .../Security/MfaActivityTests.cs | 3 +- .../Resgrid.Tests/Security/PasskeyApiTests.cs | 2 +- .../Security/StepUpFormReplayTests.cs | 425 ++++++++++++++++++ .../Security/WebLoginMfaTransactionTests.cs | 2 +- .../Security/WebPasskeysTests.cs | 2 +- .../Services/ProtectedOutboundGuardTests.cs | 2 +- .../Services/UnitStatusAlertsServiceTests.cs | 352 +++++++++++++++ .../Commands/MigrateDocsDbCommand.cs | 15 +- Web/Resgrid.Web.Eventing/Worker.cs | 1 + .../v4/UnitStatusAlertsController.cs | 175 ++++++++ .../Controllers/v4/UnitsController.cs | 6 +- .../UnitStatusAlertsModels.cs | 97 ++++ .../Models/v4/Units/UnitsInfoResult.cs | 7 + .../Resgrid.Web.Services.xml | 394 ++++++++-------- .../Controllers/AccountSecurityController.cs | 3 +- .../Areas/User/Controllers/HomeController.cs | 2 +- .../Controllers/StepUpResumeController.cs | 55 +++ .../User/Controllers/TwoFactorController.cs | 37 +- .../Models/Security/AccountCredentialViews.cs | 7 + .../Models/TwoFactor/TwoFactorViewModels.cs | 22 + .../AccountSecurity/Reauthenticate.cshtml | 9 + .../User/Views/Home/EditUserProfile.cshtml | 4 +- .../User/Views/StepUpResume/Index.cshtml | 44 ++ .../User/Views/TwoFactor/Verify2FA.cshtml | 9 + .../RequiresRecentTwoFactorAttribute.cs | 44 +- Web/Resgrid.Web/Controllers/HomeController.cs | 5 +- .../Filters/HeldSubmissionReplayFilter.cs | 82 ++++ Web/Resgrid.Web/Helpers/StepUpFormReplay.cs | 342 ++++++++++++++ Web/Resgrid.Web/Startup.cs | 3 + .../common/stepup/resgrid.stepup.resume.js | 63 +++ .../wwwroot/js/app/internal/resgrid.user.js | 8 + 73 files changed, 3316 insertions(+), 477 deletions(-) create mode 100644 Core/Resgrid.Model/Events/UnitStatusAlertUpdatedEvent.cs create mode 100644 Core/Resgrid.Model/Repositories/IUnitStatusAlertAcknowledgementsRepository.cs create mode 100644 Core/Resgrid.Model/Services/IUnitStatusAlertsService.cs create mode 100644 Core/Resgrid.Model/UnitStatusAlertAcknowledgement.cs create mode 100644 Core/Resgrid.Model/UnitStatusAlertAcknowledgementResult.cs create mode 100644 Core/Resgrid.Model/UnitStatusAlertEvaluator.cs create mode 100644 Core/Resgrid.Services/UnitStatusAlertsService.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0258_AddUnitStatusAlertAcknowledgements.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0258_AddUnitStatusAlertAcknowledgementsPg.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/UnitStatusAlertAcknowledgementsRepository.cs create mode 100644 Tests/Resgrid.Tests/Models/UnitStatusAlertEvaluatorTests.cs create mode 100644 Tests/Resgrid.Tests/Repositories/UnitStatusAlertAcknowledgementsDatabaseTests.cs create mode 100644 Tests/Resgrid.Tests/Security/StepUpFormReplayTests.cs create mode 100644 Tests/Resgrid.Tests/Services/UnitStatusAlertsServiceTests.cs create mode 100644 Web/Resgrid.Web.Services/Controllers/v4/UnitStatusAlertsController.cs create mode 100644 Web/Resgrid.Web.Services/Models/v4/UnitStatusAlerts/UnitStatusAlertsModels.cs create mode 100644 Web/Resgrid.Web/Areas/User/Controllers/StepUpResumeController.cs create mode 100644 Web/Resgrid.Web/Areas/User/Views/StepUpResume/Index.cshtml create mode 100644 Web/Resgrid.Web/Filters/HeldSubmissionReplayFilter.cs create mode 100644 Web/Resgrid.Web/Helpers/StepUpFormReplay.cs create mode 100644 Web/Resgrid.Web/wwwroot/js/app/common/stepup/resgrid.stepup.resume.js diff --git a/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.ar.resx b/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.ar.resx index 93175fe39..80b718575 100644 --- a/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.ar.resx @@ -462,4 +462,28 @@ تم تسجيل الخروج. يمكن للمشغل التالي تسجيل الدخول الآن. + + يتم الاحتفاظ بما كنت تحفظه. تحقق أدناه وسيتم حفظه. + + + تعذر الاحتفاظ بما أرسلته. بعد التحقق، ارجع وأرسله مرة أخرى. + + + إكمال الحفظ + + + جارٍ إكمال ما كنت تحفظه... + + + متابعة + + + تم إرسال عملية الحفظ هذه بالفعل أو انتهت صلاحيتها. ارجع وأرسلها مرة أخرى. + + + تعذر حفظ التغيير. ارجع وحاول مرة أخرى. + + + رجوع + diff --git a/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.de.resx b/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.de.resx index ee159e09a..4cef82a26 100644 --- a/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.de.resx +++ b/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.de.resx @@ -632,4 +632,28 @@ Abgemeldet. Die nächste Bedienperson kann sich jetzt anmelden. + + Ihre Eingaben werden aufbewahrt. Bestätigen Sie unten, dann werden sie gespeichert. + + + Ihre Eingaben konnten nicht aufbewahrt werden. Gehen Sie nach der Bestätigung zurück und senden Sie sie erneut. + + + Speichern wird abgeschlossen + + + Ihre Änderungen werden gespeichert... + + + Weiter + + + Dieser Speichervorgang wurde bereits gesendet oder ist abgelaufen. Gehen Sie zurück und senden Sie ihn erneut. + + + Ihre Änderung konnte nicht gespeichert werden. Gehen Sie zurück und versuchen Sie es erneut. + + + Zurück + diff --git a/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.el.resx b/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.el.resx index d7db2038f..6f8616634 100644 --- a/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.el.resx +++ b/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.el.resx @@ -479,5 +479,29 @@ Αποσυνδεθήκατε. Ο επόμενος χειριστής μπορεί να συνδεθεί τώρα. + + Αυτό που αποθηκεύατε διατηρείται. Επαληθεύστε παρακάτω και θα αποθηκευτεί. + + + Αυτό που υποβάλατε δεν ήταν δυνατό να διατηρηθεί. Μετά την επαλήθευση, επιστρέψτε και υποβάλετέ το ξανά. + + + Ολοκλήρωση αποθήκευσης + + + Ολοκληρώνεται η αποθήκευση... + + + Συνέχεια + + + Αυτή η αποθήκευση έχει ήδη σταλεί ή έχει λήξει. Επιστρέψτε και υποβάλετέ την ξανά. + + + Η αλλαγή σας δεν ήταν δυνατό να αποθηκευτεί. Επιστρέψτε και δοκιμάστε ξανά. + + + Επιστροφή + diff --git a/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.en.resx b/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.en.resx index dffeaf866..19fe807d1 100644 --- a/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.en.resx +++ b/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.en.resx @@ -479,5 +479,29 @@ Signed out. The next operator can sign in now. + + What you were saving is being held. Verify below and it will be saved. + + + What you submitted could not be held. After you verify, go back and submit it again. + + + Finishing Your Save + + + Finishing what you were saving... + + + Continue + + + This save was already sent or has expired. Go back and submit it again. + + + Your change could not be saved. Go back and try again. + + + Go Back + diff --git a/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.es.resx b/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.es.resx index 06bcf69c6..a93158c57 100644 --- a/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.es.resx +++ b/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.es.resx @@ -466,5 +466,29 @@ xxxx-xxxx-xxxx + + Lo que estaba guardando se mantiene en espera. Verifique a continuación y se guardará. + + + No se pudo conservar lo que envió. Después de verificar, regrese y envíelo de nuevo. + + + Finalizando el guardado + + + Finalizando lo que estaba guardando... + + + Continuar + + + Este guardado ya se envió o ha caducado. Regrese y envíelo de nuevo. + + + No se pudo guardar su cambio. Regrese e inténtelo de nuevo. + + + Volver + diff --git a/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.fr.resx b/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.fr.resx index e85405820..174a30a24 100644 --- a/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.fr.resx @@ -632,4 +632,28 @@ Déconnecté. L’opérateur suivant peut se connecter maintenant. + + Ce que vous enregistriez est conservé. Vérifiez ci-dessous et ce sera enregistré. + + + Ce que vous avez envoyé n'a pas pu être conservé. Après la vérification, revenez en arrière et envoyez-le à nouveau. + + + Finalisation de l'enregistrement + + + Finalisation de votre enregistrement... + + + Continuer + + + Cet enregistrement a déjà été envoyé ou a expiré. Revenez en arrière et envoyez-le à nouveau. + + + Votre modification n'a pas pu être enregistrée. Revenez en arrière et réessayez. + + + Retour + diff --git a/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.it.resx b/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.it.resx index 0eb323ede..08912c853 100644 --- a/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.it.resx +++ b/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.it.resx @@ -632,4 +632,28 @@ Disconnesso. Il prossimo operatore può accedere ora. + + Ciò che stavi salvando è in attesa. Verifica qui sotto e verrà salvato. + + + Ciò che hai inviato non è stato conservato. Dopo la verifica, torna indietro e invialo di nuovo. + + + Completamento del salvataggio + + + Completamento del salvataggio in corso... + + + Continua + + + Questo salvataggio è già stato inviato o è scaduto. Torna indietro e invialo di nuovo. + + + Non è stato possibile salvare la modifica. Torna indietro e riprova. + + + Indietro + diff --git a/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.pl.resx b/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.pl.resx index f9b5a95ce..46434ecd2 100644 --- a/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.pl.resx @@ -632,4 +632,28 @@ Wylogowano. Następny operator może się teraz zalogować. + + Zapisywane dane zostały zachowane. Po weryfikacji poniżej zostaną zapisane. + + + Nie udało się zachować przesłanych danych. Po weryfikacji wróć i prześlij je ponownie. + + + Kończenie zapisywania + + + Kończenie zapisywania... + + + Kontynuuj + + + Ten zapis został już wysłany lub wygasł. Wróć i prześlij go ponownie. + + + Nie udało się zapisać zmiany. Wróć i spróbuj ponownie. + + + Wróć + diff --git a/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.sv.resx b/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.sv.resx index f59337eba..299793b6b 100644 --- a/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.sv.resx @@ -632,4 +632,28 @@ Utloggad. Nästa operatör kan logga in nu. + + Det du sparade hålls kvar. Verifiera nedan så sparas det. + + + Det du skickade kunde inte hållas kvar. Gå tillbaka och skicka det igen efter verifieringen. + + + Slutför sparandet + + + Slutför det du sparade... + + + Fortsätt + + + Det här sparandet har redan skickats eller har gått ut. Gå tillbaka och skicka det igen. + + + Din ändring kunde inte sparas. Gå tillbaka och försök igen. + + + Gå tillbaka + diff --git a/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.uk.resx b/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.uk.resx index fbfd33196..08addadc5 100644 --- a/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.uk.resx @@ -632,4 +632,28 @@ Ви вийшли. Наступний оператор може увійти зараз. + + Те, що ви зберігали, утримується. Пройдіть перевірку нижче, і його буде збережено. + + + Не вдалося утримати надіслані дані. Після перевірки поверніться та надішліть їх ще раз. + + + Завершення збереження + + + Завершуємо збереження... + + + Продовжити + + + Це збереження вже надіслано або термін його дії минув. Поверніться та надішліть ще раз. + + + Не вдалося зберегти зміну. Поверніться та спробуйте ще раз. + + + Назад + diff --git a/Core/Resgrid.Model/EventingTypes.cs b/Core/Resgrid.Model/EventingTypes.cs index 4a13a91af..aef48cbd2 100644 --- a/Core/Resgrid.Model/EventingTypes.cs +++ b/Core/Resgrid.Model/EventingTypes.cs @@ -15,6 +15,9 @@ public enum EventingTypes ChecklistUpdated = 11, /// An event for one signed-in session only, such as an approval decision (passkey workbook section 7.4). - SessionEvent = 12 + SessionEvent = 12, + + /// A unit status timer acknowledgement changed; the item id is the unit id. + UnitStatusAlertUpdated = 13 } } diff --git a/Core/Resgrid.Model/Events/UnitStatusAlertUpdatedEvent.cs b/Core/Resgrid.Model/Events/UnitStatusAlertUpdatedEvent.cs new file mode 100644 index 000000000..dc86e55ca --- /dev/null +++ b/Core/Resgrid.Model/Events/UnitStatusAlertUpdatedEvent.cs @@ -0,0 +1,13 @@ +namespace Resgrid.Model.Events +{ + /// + /// Raised when a unit status timer acknowledgement is added, replaced or cleared. Fans out over the + /// eventing topic as unitStatusAlertUpdated, so every board in the department refreshes its + /// acknowledgements and nobody keeps staring at an alert a colleague has already handled. + /// + public class UnitStatusAlertUpdatedEvent + { + public int DepartmentId { get; set; } + public int UnitId { get; set; } + } +} diff --git a/Core/Resgrid.Model/NewCallFieldPolicy.cs b/Core/Resgrid.Model/NewCallFieldPolicy.cs index 526f49cef..d6cd18514 100644 --- a/Core/Resgrid.Model/NewCallFieldPolicy.cs +++ b/Core/Resgrid.Model/NewCallFieldPolicy.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.ComponentModel; using System.Linq; using ProtoBuf; @@ -66,6 +67,11 @@ public class NewCallFieldRule public string Key { get; set; } /// False hides the field from every call-creation surface. + /// + /// DefaultValue must match the initializer. Without it protobuf-net treats false as the implicit + /// zero default and never writes it, so a hidden field read back as visible and the rule was lost. + /// + [DefaultValue(true)] [ProtoMember(2)] public bool Visible { get; set; } = true; diff --git a/Core/Resgrid.Model/Providers/IRabbitInboundEventProvider.cs b/Core/Resgrid.Model/Providers/IRabbitInboundEventProvider.cs index f3cb5a2e1..474a46a3d 100644 --- a/Core/Resgrid.Model/Providers/IRabbitInboundEventProvider.cs +++ b/Core/Resgrid.Model/Providers/IRabbitInboundEventProvider.cs @@ -32,5 +32,8 @@ void RegisterForEvents(Func personnelStatusChanged, /// Events for one session (session id, serialized SessionEventMessage). void RegisterForSessionEvents(Func sessionEvent); + + /// Unit status timer acknowledgement changes (department id, unit id). + void RegisterForUnitStatusAlertEvents(Func unitStatusAlertEvent); } } diff --git a/Core/Resgrid.Model/Repositories/IUnitStatusAlertAcknowledgementsRepository.cs b/Core/Resgrid.Model/Repositories/IUnitStatusAlertAcknowledgementsRepository.cs new file mode 100644 index 000000000..b2936f0d2 --- /dev/null +++ b/Core/Resgrid.Model/Repositories/IUnitStatusAlertAcknowledgementsRepository.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Resgrid.Model.Repositories +{ + public interface IUnitStatusAlertAcknowledgementsRepository : IRepository + { + /// + /// The uncleared acknowledgements for the given status episodes. Callers pass the units' current state ids, + /// so acknowledgements from episodes that have already ended are never read. + /// + Task> GetActiveForUnitStatesAsync(int departmentId, IEnumerable unitStateIds); + + /// The uncleared acknowledgements for one status episode (normally zero or one). + Task> GetActiveForUnitStateAsync(int departmentId, int unitId, int unitStateId); + } +} diff --git a/Core/Resgrid.Model/Services/IPushService.cs b/Core/Resgrid.Model/Services/IPushService.cs index a414752af..4e67d6e21 100644 --- a/Core/Resgrid.Model/Services/IPushService.cs +++ b/Core/Resgrid.Model/Services/IPushService.cs @@ -10,7 +10,7 @@ public interface IPushService Task PushCall(StandardPushCall call, string userId, UserProfile profile = null, DepartmentCallPriority priority = null); Task Register(PushUri pushUri); Task UnRegister(PushUri pushUri); - void UnRegisterNotificationOnly(PushUri pushUri); + Task UnRegisterNotificationOnly(PushUri pushUri); Task PushNotification(StandardPushMessage message, string userId, UserProfile profile = null); Task PushICNotification(StandardPushMessage message, string userId, UserProfile profile = null); Task RegisterUnit(PushUri pushUri); diff --git a/Core/Resgrid.Model/Services/ISmsService.cs b/Core/Resgrid.Model/Services/ISmsService.cs index 554345eeb..c08ebe8e1 100644 --- a/Core/Resgrid.Model/Services/ISmsService.cs +++ b/Core/Resgrid.Model/Services/ISmsService.cs @@ -49,7 +49,7 @@ Task SendCancelCallAsync(Call call, CallDispatch dispatch, string departme /// The department number. /// The department identifier. /// The profile. - void SendTroubleAlert(Unit unit, Call call, string unitAddress, string departmentNumber, int departmentId, + Task SendTroubleAlertAsync(Unit unit, Call call, string unitAddress, string departmentNumber, int departmentId, UserProfile profile); /// diff --git a/Core/Resgrid.Model/Services/IUnitStatusAlertsService.cs b/Core/Resgrid.Model/Services/IUnitStatusAlertsService.cs new file mode 100644 index 000000000..41e01ea7f --- /dev/null +++ b/Core/Resgrid.Model/Services/IUnitStatusAlertsService.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// + /// Acknowledging, muting and annotating the alerts a unit raises when it sits in a status longer than its + /// department's allow. + /// + public interface IUnitStatusAlertsService + { + /// + /// The uncleared acknowledgements for each unit's current status episode. Acknowledgements from episodes + /// that ended when a unit changed status are not returned. + /// + Task> GetCurrentAcknowledgementsForDepartmentAsync(int departmentId); + + /// + /// Acknowledges or mutes the alert for the unit's current status episode, replacing any earlier + /// acknowledgement of that episode. + /// + /// The state the caller saw. If the unit has moved on since, nothing is written. + /// The level the caller saw. It is capped at the level the server measures, so an alert cannot be pre-acknowledged. + /// For a mute: minutes to mute for, or 0 to mute until the status changes. + Task AcknowledgeAsync(int departmentId, int unitId, int unitStateId, UnitStatusAlertLevels level, + UnitStatusAlertAcknowledgementModes mode, int muteMinutes, string note, string userId, CancellationToken cancellationToken = default(CancellationToken)); + + /// One acknowledgement, cleared or not. Null when it does not exist or belongs to another department. + Task GetAcknowledgementByIdAsync(int departmentId, string acknowledgementId); + + /// Withdraws an acknowledgement, so the alert shows as unacknowledged again. + Task ClearAsync(int departmentId, string acknowledgementId, string userId, + CancellationToken cancellationToken = default(CancellationToken)); + } +} diff --git a/Core/Resgrid.Model/UnitStatusAlertAcknowledgement.cs b/Core/Resgrid.Model/UnitStatusAlertAcknowledgement.cs new file mode 100644 index 000000000..ceff2306e --- /dev/null +++ b/Core/Resgrid.Model/UnitStatusAlertAcknowledgement.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace Resgrid.Model +{ + /// + /// How far past its limit a unit has gone. Ordered so a higher value is + /// a more serious level. + /// + public enum UnitStatusAlertLevels + { + None = 0, + Warn = 1, + Alert = 2 + } + + /// What the dispatcher chose to do with the alert. + public enum UnitStatusAlertAcknowledgementModes + { + /// Seen. The unit stays highlighted, marked with who saw it and any note. + Acknowledged = 0, + + /// Moved out of the way until (or the status changes). + Muted = 1 + } + + /// + /// A dispatcher's acknowledgement of a unit that has sat in a status past its threshold. + /// + /// + /// Keyed to the row that started the episode, not to the unit: when the unit + /// reports a new status it gets a new , so the acknowledgement stops applying + /// without any sweep. It also records the that was acknowledged, so a unit acknowledged + /// at the warning level comes back when it crosses into the alert level. Rows are never deleted while the + /// unit state exists; clearing or replacing one stamps , which leaves the history of + /// who saw what and when. + /// + public class UnitStatusAlertAcknowledgement : IEntity + { + /// Longest note a dispatcher may attach. + public const int MaxNoteLength = 500; + + /// Longest mute that can be set. Anything longer is "until the status changes". + public const int MaxMuteMinutes = 1440; + + public string UnitStatusAlertAcknowledgementId { get; set; } + + public int DepartmentId { get; set; } + + public int UnitId { get; set; } + + public int UnitStateId { get; set; } + + /// The value in force when acknowledged. + public int Level { get; set; } + + /// The value. + public int Mode { get; set; } + + /// UTC end of a mute. Null on a mute means until the status changes; ignored when acknowledged. + public DateTime? MutedUntil { get; set; } + + public string Note { get; set; } + + public string AcknowledgedByUserId { get; set; } + + public DateTime AcknowledgedOn { get; set; } + + public string ClearedByUserId { get; set; } + + /// Set when the acknowledgement was withdrawn or replaced by a newer one for the same episode. + public DateTime? ClearedOn { get; set; } + + [NotMapped] + public string TableName => "UnitStatusAlertAcknowledgements"; + + [NotMapped] + public string IdName => "UnitStatusAlertAcknowledgementId"; + + [NotMapped] + public int IdType => 1; + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return UnitStatusAlertAcknowledgementId; } + set { UnitStatusAlertAcknowledgementId = (string)value; } + } + + [NotMapped] + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" }; + } +} diff --git a/Core/Resgrid.Model/UnitStatusAlertAcknowledgementResult.cs b/Core/Resgrid.Model/UnitStatusAlertAcknowledgementResult.cs new file mode 100644 index 000000000..684c9494a --- /dev/null +++ b/Core/Resgrid.Model/UnitStatusAlertAcknowledgementResult.cs @@ -0,0 +1,36 @@ +namespace Resgrid.Model +{ + /// Outcome of acknowledging or clearing a unit status timer alert. + public class UnitStatusAlertAcknowledgementResult + { + /// The unit does not exist in this department, or the acknowledgement does not. + public const string NotFound = "unit_alert_not_found"; + + /// The unit has reported a new status since the board loaded; the alert being acknowledged is gone. + public const string StatusChanged = "unit_alert_status_changed"; + + /// The unit is not past a threshold, so there is nothing to acknowledge. + public const string NotOverdue = "unit_alert_not_overdue"; + + /// Another dispatcher acknowledged the same alert at the same moment. + public const string Conflict = "unit_alert_conflict"; + + public const string InvalidMode = "unit_alert_invalid_mode"; + + public const string InvalidLevel = "unit_alert_invalid_level"; + + public const string NoteTooLong = "unit_alert_note_too_long"; + + public bool Success { get; set; } + + public string Error { get; set; } + + public UnitStatusAlertAcknowledgement Acknowledgement { get; set; } + + public static UnitStatusAlertAcknowledgementResult Ok(UnitStatusAlertAcknowledgement acknowledgement) => + new UnitStatusAlertAcknowledgementResult { Success = true, Acknowledgement = acknowledgement }; + + public static UnitStatusAlertAcknowledgementResult Fail(string error, UnitStatusAlertAcknowledgement acknowledgement = null) => + new UnitStatusAlertAcknowledgementResult { Success = false, Error = error, Acknowledgement = acknowledgement }; + } +} diff --git a/Core/Resgrid.Model/UnitStatusAlertEvaluator.cs b/Core/Resgrid.Model/UnitStatusAlertEvaluator.cs new file mode 100644 index 000000000..790f67736 --- /dev/null +++ b/Core/Resgrid.Model/UnitStatusAlertEvaluator.cs @@ -0,0 +1,66 @@ +using System; + +namespace Resgrid.Model +{ + /// + /// The rules for whether a unit is past its status-time threshold, and whether an acknowledgement still + /// covers it. The Big Board runs the same rules in src/lib/unit-status-thresholds.ts. If you change + /// them here, change them there too, or the server and the board will disagree about what is overdue. + /// + public static class UnitStatusAlertEvaluator + { + /// + /// The level a unit is at. when the department has no threshold + /// for the status's base type, or the unit is still within it. + /// + public static UnitStatusAlertLevels Evaluate(UnitStatusThresholds thresholds, int? baseType, DateTime statusTimestampUtc, DateTime nowUtc) + { + if (thresholds == null || thresholds.IsEmpty || !baseType.HasValue) + return UnitStatusAlertLevels.None; + + var threshold = thresholds.Find(baseType.Value); + + if (threshold == null) + return UnitStatusAlertLevels.None; + + var elapsed = (nowUtc - statusTimestampUtc).TotalSeconds; + + if (elapsed < 0) + return UnitStatusAlertLevels.None; + + if (threshold.AlertSeconds > 0 && elapsed >= threshold.AlertSeconds) + return UnitStatusAlertLevels.Alert; + + if (threshold.WarnSeconds > 0 && elapsed >= threshold.WarnSeconds) + return UnitStatusAlertLevels.Warn; + + return UnitStatusAlertLevels.None; + } + + /// + /// What an acknowledgement means right now. Returns null when it no longer covers the unit, so the alert + /// shows as unacknowledged. That happens when the unit has moved to a new status (a different + /// ), or when it has escalated past the level that was acknowledged. + /// A mute that has run out falls back to acknowledged: someone did see it, and the row returning to the + /// main list is the reminder. + /// + public static UnitStatusAlertAcknowledgementModes? Resolve(UnitStatusAlertAcknowledgement acknowledgement, int currentUnitStateId, + UnitStatusAlertLevels currentLevel, DateTime nowUtc) + { + if (acknowledgement == null || acknowledgement.ClearedOn.HasValue) + return null; + + if (currentUnitStateId <= 0 || acknowledgement.UnitStateId != currentUnitStateId) + return null; + + if ((int)currentLevel > acknowledgement.Level) + return null; + + if (acknowledgement.Mode == (int)UnitStatusAlertAcknowledgementModes.Muted && + (!acknowledgement.MutedUntil.HasValue || acknowledgement.MutedUntil.Value > nowUtc)) + return UnitStatusAlertAcknowledgementModes.Muted; + + return UnitStatusAlertAcknowledgementModes.Acknowledged; + } + } +} diff --git a/Core/Resgrid.Services/CommunicationService.cs b/Core/Resgrid.Services/CommunicationService.cs index c56112e6c..2f28044e8 100644 --- a/Core/Resgrid.Services/CommunicationService.cs +++ b/Core/Resgrid.Services/CommunicationService.cs @@ -997,7 +997,7 @@ await _chatbotOutboundService.SendToUserAsync(recipient.UserId, departmentId, { try { - _smsService.SendTroubleAlert(unit, smsCall, smsSanitized ? null : unitAddress, departmentNumber, departmentId, recipient); + await _smsService.SendTroubleAlertAsync(unit, smsCall, smsSanitized ? null : unitAddress, departmentNumber, departmentId, recipient); } catch (Exception ex) { diff --git a/Core/Resgrid.Services/ProtectedPushServiceDecorator.cs b/Core/Resgrid.Services/ProtectedPushServiceDecorator.cs index 3dadfec56..9b625c9af 100644 --- a/Core/Resgrid.Services/ProtectedPushServiceDecorator.cs +++ b/Core/Resgrid.Services/ProtectedPushServiceDecorator.cs @@ -61,7 +61,7 @@ public Task PushChatMessageUnit(StandardPushMessage message, int unitId, s public Task UnRegister(PushUri pushUri) => _inner.UnRegister(pushUri); - public void UnRegisterNotificationOnly(PushUri pushUri) => _inner.UnRegisterNotificationOnly(pushUri); + public Task UnRegisterNotificationOnly(PushUri pushUri) => _inner.UnRegisterNotificationOnly(pushUri); public Task RegisterUnit(PushUri pushUri) => _inner.RegisterUnit(pushUri); diff --git a/Core/Resgrid.Services/PushService.cs b/Core/Resgrid.Services/PushService.cs index cb8804b90..c1492fe5b 100644 --- a/Core/Resgrid.Services/PushService.cs +++ b/Core/Resgrid.Services/PushService.cs @@ -187,9 +187,9 @@ public async Task UnRegisterUnit(PushUri pushUri) return true; } - public void UnRegisterNotificationOnly(PushUri pushUri) + public Task UnRegisterNotificationOnly(PushUri pushUri) { - _notificationProvider.UnRegisterPushByUserDeviceId(pushUri); + return _notificationProvider.UnRegisterPushByUserDeviceId(pushUri); } public async Task PushMessage(StandardPushMessage message, string userId, UserProfile profile = null) diff --git a/Core/Resgrid.Services/ServicesModule.cs b/Core/Resgrid.Services/ServicesModule.cs index d3833058d..1d112fd4b 100644 --- a/Core/Resgrid.Services/ServicesModule.cs +++ b/Core/Resgrid.Services/ServicesModule.cs @@ -360,6 +360,7 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().SingleInstance(); builder.RegisterType().As().SingleInstance(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); // Singleton: it keeps each department's reduced visibility matrix in memory between location pings. diff --git a/Core/Resgrid.Services/SmsService.cs b/Core/Resgrid.Services/SmsService.cs index abcd328bb..e5dd9e7f2 100644 --- a/Core/Resgrid.Services/SmsService.cs +++ b/Core/Resgrid.Services/SmsService.cs @@ -247,7 +247,7 @@ public async Task SendCallAsync(Call call, CallDispatch dispatch, string d } else { - SendCallViaEmailSmsGateway(call, address, profile); + await SendCallViaEmailSmsGatewayAsync(call, address, profile); } } @@ -350,7 +350,7 @@ private async Task SendCancelCallViaEmailSmsGatewayAsync(Call call, string addre } } - private void SendCallViaEmailSmsGateway(Call call, string address, UserProfile profile) + private async Task SendCallViaEmailSmsGatewayAsync(Call call, string address, UserProfile profile) { MailMessage email = new MailMessage(); email.To.Add(string.Format(Carriers.CarriersMap[(MobileCarriers)profile.MobileCarrier], ResolveGatewayNumber(profile))); @@ -377,10 +377,17 @@ private void SendCallViaEmailSmsGateway(Call call, string address, UserProfile p email.IsBodyHtml = false; - _emailSender.SendEmail(email); + try + { + await _emailSender.SendEmail(email); + } + catch (Exception ex) + { + Logging.LogException(ex); + } } - public void SendTroubleAlert(Unit unit, Call call, string unitAddress, string departmentNumber, int departmentId, UserProfile profile) + public async Task SendTroubleAlertAsync(Unit unit, Call call, string unitAddress, string departmentNumber, int departmentId, UserProfile profile) { if (Config.SystemBehaviorConfig.DoNotBroadcast && !Config.SystemBehaviorConfig.BypassDoNotBroadcastDepartments.Contains(unit.DepartmentId)) return; @@ -391,11 +398,11 @@ public void SendTroubleAlert(Unit unit, Call call, string unitAddress, string de if (Config.SystemBehaviorConfig.DepartmentsToForceSmsGateway.Contains(departmentId)) { - _textMessageProvider.SendTextMessage(ResolveDirectSendNumber(profile), FormatTextForMessage("Trouble Alert", text, ShouldDiscloseOptOut(profile.UserId)), departmentNumber, (MobileCarriers)profile.MobileCarrier, departmentId, true, false); + await _textMessageProvider.SendTextMessage(ResolveDirectSendNumber(profile), FormatTextForMessage("Trouble Alert", text, ShouldDiscloseOptOut(profile.UserId)), departmentNumber, (MobileCarriers)profile.MobileCarrier, departmentId, true, false); } else if (Carriers.DirectSendCarriers.Contains((MobileCarriers)profile.MobileCarrier)) { - _textMessageProvider.SendTextMessage(ResolveDirectSendNumber(profile), FormatTextForMessage("Trouble Alert", text, ShouldDiscloseOptOut(profile.UserId)), departmentNumber, (MobileCarriers)profile.MobileCarrier, departmentId, false, false); + await _textMessageProvider.SendTextMessage(ResolveDirectSendNumber(profile), FormatTextForMessage("Trouble Alert", text, ShouldDiscloseOptOut(profile.UserId)), departmentNumber, (MobileCarriers)profile.MobileCarrier, departmentId, false, false); } else { @@ -408,7 +415,7 @@ public void SendTroubleAlert(Unit unit, Call call, string unitAddress, string de email.Body = text; email.IsBodyHtml = false; - _emailSender.SendEmail(email); + await _emailSender.SendEmail(email); } } } diff --git a/Core/Resgrid.Services/UnitStatusAlertsService.cs b/Core/Resgrid.Services/UnitStatusAlertsService.cs new file mode 100644 index 000000000..d93000a8e --- /dev/null +++ b/Core/Resgrid.Services/UnitStatusAlertsService.cs @@ -0,0 +1,190 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.Events; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + public class UnitStatusAlertsService : IUnitStatusAlertsService + { + /// + /// How far ahead of the server's clock a board may be when it reports a unit as overdue. Without this, a + /// board whose clock runs a few seconds fast would be refused an acknowledgement for an alert it is showing. + /// + public static readonly TimeSpan ClockSkewAllowance = TimeSpan.FromSeconds(30); + + private readonly IUnitStatusAlertAcknowledgementsRepository _acknowledgementsRepository; + private readonly IUnitsService _unitsService; + private readonly ICustomStateService _customStateService; + private readonly IDepartmentSettingsService _departmentSettingsService; + private readonly IEventAggregator _eventAggregator; + + public UnitStatusAlertsService(IUnitStatusAlertAcknowledgementsRepository acknowledgementsRepository, IUnitsService unitsService, + ICustomStateService customStateService, IDepartmentSettingsService departmentSettingsService, IEventAggregator eventAggregator) + { + _acknowledgementsRepository = acknowledgementsRepository; + _unitsService = unitsService; + _customStateService = customStateService; + _departmentSettingsService = departmentSettingsService; + _eventAggregator = eventAggregator; + } + + public async Task> GetCurrentAcknowledgementsForDepartmentAsync(int departmentId) + { + var states = await _unitsService.GetAllLatestStatusForUnitsByDepartmentIdAsync(departmentId); + + // A unit that has never reported a status gets a placeholder with id 0, which no acknowledgement can reference. + var currentStateIds = states?.Where(x => x != null && x.UnitStateId > 0).Select(x => x.UnitStateId).Distinct().ToList() ?? new List(); + + if (!currentStateIds.Any()) + return new List(); + + var acknowledgements = await _acknowledgementsRepository.GetActiveForUnitStatesAsync(departmentId, currentStateIds); + + return acknowledgements?.ToList() ?? new List(); + } + + public async Task AcknowledgeAsync(int departmentId, int unitId, int unitStateId, UnitStatusAlertLevels level, + UnitStatusAlertAcknowledgementModes mode, int muteMinutes, string note, string userId, CancellationToken cancellationToken = default(CancellationToken)) + { + if (!Enum.IsDefined(typeof(UnitStatusAlertAcknowledgementModes), mode)) + return UnitStatusAlertAcknowledgementResult.Fail(UnitStatusAlertAcknowledgementResult.InvalidMode); + + if (level != UnitStatusAlertLevels.Warn && level != UnitStatusAlertLevels.Alert) + return UnitStatusAlertAcknowledgementResult.Fail(UnitStatusAlertAcknowledgementResult.InvalidLevel); + + note = string.IsNullOrWhiteSpace(note) ? null : note.Trim(); + + if (note != null && note.Length > UnitStatusAlertAcknowledgement.MaxNoteLength) + return UnitStatusAlertAcknowledgementResult.Fail(UnitStatusAlertAcknowledgementResult.NoteTooLong); + + var unit = await _unitsService.GetUnitByIdAsync(unitId); + + if (unit == null || unit.DepartmentId != departmentId) + return UnitStatusAlertAcknowledgementResult.Fail(UnitStatusAlertAcknowledgementResult.NotFound); + + var state = await _unitsService.GetLastUnitStateByUnitIdAsync(unitId); + + if (state == null || state.UnitStateId <= 0 || state.UnitStateId != unitStateId) + return UnitStatusAlertAcknowledgementResult.Fail(UnitStatusAlertAcknowledgementResult.StatusChanged); + + var now = DateTime.UtcNow; + var thresholds = await _departmentSettingsService.GetUnitStatusThresholdsAsync(departmentId); + var customState = await _customStateService.GetCustomUnitStateAsync(state); + var measured = UnitStatusAlertEvaluator.Evaluate(thresholds, customState?.BaseType, state.Timestamp, now.Add(ClockSkewAllowance)); + + if (measured == UnitStatusAlertLevels.None) + return UnitStatusAlertAcknowledgementResult.Fail(UnitStatusAlertAcknowledgementResult.NotOverdue); + + // The board may lag a level behind (it saw the warning just as the alert fired). Record what was seen, so + // the alert comes straight back. It may never run a level ahead, or an alert could be silenced before it fires. + var acknowledgedLevel = (UnitStatusAlertLevels)Math.Min((int)level, (int)measured); + + DateTime? mutedUntil = null; + if (mode == UnitStatusAlertAcknowledgementModes.Muted && muteMinutes > 0) + mutedUntil = now.AddMinutes(Math.Min(muteMinutes, UnitStatusAlertAcknowledgement.MaxMuteMinutes)); + + var acknowledgement = new UnitStatusAlertAcknowledgement + { + UnitStatusAlertAcknowledgementId = Guid.NewGuid().ToString(), + DepartmentId = departmentId, + UnitId = unitId, + UnitStateId = unitStateId, + Level = (int)acknowledgedLevel, + Mode = (int)mode, + MutedUntil = mutedUntil, + Note = note, + AcknowledgedByUserId = userId, + AcknowledgedOn = now + }; + + await ClearActiveAsync(departmentId, unitId, unitStateId, userId, now, cancellationToken); + + try + { + await _acknowledgementsRepository.InsertAsync(acknowledgement, cancellationToken); + } + catch (Exception) + { + // The filtered unique index allows one uncleared row per episode. If another dispatcher's insert got + // in between our clear and our insert, theirs stands; hand it back so the board can show it. + var winner = (await _acknowledgementsRepository.GetActiveForUnitStateAsync(departmentId, unitId, unitStateId))?.FirstOrDefault(); + + if (winner == null) + throw; + + return UnitStatusAlertAcknowledgementResult.Fail(UnitStatusAlertAcknowledgementResult.Conflict, winner); + } + + await NotifyAsync(departmentId, unitId); + + return UnitStatusAlertAcknowledgementResult.Ok(acknowledgement); + } + + public async Task GetAcknowledgementByIdAsync(int departmentId, string acknowledgementId) + { + if (string.IsNullOrWhiteSpace(acknowledgementId)) + return null; + + var acknowledgement = await _acknowledgementsRepository.GetByIdAsync(acknowledgementId); + + return acknowledgement != null && acknowledgement.DepartmentId == departmentId ? acknowledgement : null; + } + + public async Task ClearAsync(int departmentId, string acknowledgementId, string userId, + CancellationToken cancellationToken = default(CancellationToken)) + { + var acknowledgement = await GetAcknowledgementByIdAsync(departmentId, acknowledgementId); + + if (acknowledgement == null) + return UnitStatusAlertAcknowledgementResult.Fail(UnitStatusAlertAcknowledgementResult.NotFound); + + // Clearing twice (two dispatchers, or a retry) is not an error: the end state is the one asked for. + if (acknowledgement.ClearedOn.HasValue) + return UnitStatusAlertAcknowledgementResult.Ok(acknowledgement); + + acknowledgement.ClearedOn = DateTime.UtcNow; + acknowledgement.ClearedByUserId = userId; + + await _acknowledgementsRepository.UpdateAsync(acknowledgement, cancellationToken); + await NotifyAsync(departmentId, acknowledgement.UnitId); + + return UnitStatusAlertAcknowledgementResult.Ok(acknowledgement); + } + + private async Task ClearActiveAsync(int departmentId, int unitId, int unitStateId, string userId, DateTime now, CancellationToken cancellationToken) + { + var existing = await _acknowledgementsRepository.GetActiveForUnitStateAsync(departmentId, unitId, unitStateId); + + if (existing == null) + return; + + foreach (var previous in existing) + { + previous.ClearedOn = now; + previous.ClearedByUserId = userId; + + await _acknowledgementsRepository.UpdateAsync(previous, cancellationToken); + } + } + + private async Task NotifyAsync(int departmentId, int unitId) + { + // The acknowledgement is saved; a board that misses the push still picks it up on its next refresh. + try + { + await _eventAggregator.SendMessageAsync(new UnitStatusAlertUpdatedEvent { DepartmentId = departmentId, UnitId = unitId }); + } + catch (Exception ex) + { + Framework.Logging.LogException(ex); + } + } + } +} diff --git a/Providers/Resgrid.Providers.Bus.Rabbit/RabbitInboundEventProvider.cs b/Providers/Resgrid.Providers.Bus.Rabbit/RabbitInboundEventProvider.cs index da27884cb..9622fa38a 100644 --- a/Providers/Resgrid.Providers.Bus.Rabbit/RabbitInboundEventProvider.cs +++ b/Providers/Resgrid.Providers.Bus.Rabbit/RabbitInboundEventProvider.cs @@ -31,6 +31,7 @@ public class RabbitInboundEventProvider : IRabbitInboundEventProvider public Func ProcessChatEvent; public Func ProcessChecklistEvent; public Func ProcessSessionEvent; + public Func ProcessUnitStatusAlertEvent; public async Task Start(string clientName, string queueName) { @@ -177,6 +178,9 @@ await _channel.QueueBindAsync(queue: queue.QueueName, case EventingTypes.ChecklistUpdated: if (ProcessChecklistEvent != null) await ProcessChecklistEvent.Invoke(eventingMessage.DepartmentId, eventingMessage.ItemId); break; + case EventingTypes.UnitStatusAlertUpdated: + if (ProcessUnitStatusAlertEvent != null) await ProcessUnitStatusAlertEvent.Invoke(eventingMessage.DepartmentId, eventingMessage.ItemId); + break; case EventingTypes.SessionEvent: if (ProcessSessionEvent != null) await ProcessSessionEvent.Invoke(eventingMessage.ItemId, eventingMessage.Payload); break; @@ -248,6 +252,8 @@ public void RegisterForEvents(Func personnelStatusChanged, public void RegisterForSessionEvents(Func sessionEvent) => ProcessSessionEvent = sessionEvent; + public void RegisterForUnitStatusAlertEvents(Func unitStatusAlertEvent) => ProcessUnitStatusAlertEvent = unitStatusAlertEvent; + public void RegisterForChatEvents(Func chatEvent) { ProcessChatEvent = chatEvent; diff --git a/Providers/Resgrid.Providers.Bus.Rabbit/RabbitTopicProvider.cs b/Providers/Resgrid.Providers.Bus.Rabbit/RabbitTopicProvider.cs index afb78480f..2394c9e6c 100644 --- a/Providers/Resgrid.Providers.Bus.Rabbit/RabbitTopicProvider.cs +++ b/Providers/Resgrid.Providers.Bus.Rabbit/RabbitTopicProvider.cs @@ -86,6 +86,11 @@ public async Task CallUpdated(CallUpdatedEvent message) Id = Guid.NewGuid(), Type = (int)EventingTypes.ChecklistUpdated, TimeStamp = DateTime.UtcNow, DepartmentId = departmentId, ItemId = completionId }.SerializeJson()); + public Task UnitStatusAlertUpdated(int departmentId, int unitId) => SendMessage(Topics.EventingTopic, new EventingMessage + { + Id = Guid.NewGuid(), Type = (int)EventingTypes.UnitStatusAlertUpdated, TimeStamp = DateTime.UtcNow, DepartmentId = departmentId, ItemId = unitId.ToString() + }.SerializeJson()); + /// An event for one session's realtime connections (passkey workbook section 7.4). public Task SessionEvent(string sessionId, string payload) => SendMessage(Topics.EventingTopic, new EventingMessage { diff --git a/Providers/Resgrid.Providers.Bus/OutboundEventProvider.cs b/Providers/Resgrid.Providers.Bus/OutboundEventProvider.cs index ccd06485a..2c11cf571 100644 --- a/Providers/Resgrid.Providers.Bus/OutboundEventProvider.cs +++ b/Providers/Resgrid.Providers.Bus/OutboundEventProvider.cs @@ -68,6 +68,7 @@ public OutboundEventProvider(IEventAggregator eventAggregator, IOutboundQueuePro _eventAggregator.AddAsyncListener(personnelLocationUpdatedTopicHandler); _eventAggregator.AddAsyncListener(unitLocationUpdatedTopicHandler); _eventAggregator.AddListener(chatEventTopicHandler); + _eventAggregator.AddAsyncListener(unitStatusAlertUpdatedTopicHandler); _eventAggregator.AddAsyncListener(async message => { if (message.ProducerSubsystem != "Checklists") return; @@ -703,6 +704,14 @@ await _outboundQueueProvider.EnqueueNotification(new NotificationItem _rabbitTopicProvider.IncidentCommandUpdated(message); }; + public Func unitStatusAlertUpdatedTopicHandler = async delegate (UnitStatusAlertUpdatedEvent message) + { + if (_rabbitTopicProvider == null) + _rabbitTopicProvider = new RabbitTopicProvider(); + + await _rabbitTopicProvider.UnitStatusAlertUpdated(message.DepartmentId, message.UnitId); + }; + public Action chatEventTopicHandler = async delegate (ChatEventRaised message) { if (_rabbitTopicProvider == null) diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0258_AddUnitStatusAlertAcknowledgements.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0258_AddUnitStatusAlertAcknowledgements.cs new file mode 100644 index 000000000..cf2268089 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0258_AddUnitStatusAlertAcknowledgements.cs @@ -0,0 +1,73 @@ +using System.Data; +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Unit status timer acknowledgements: a dispatcher can mark a unit that has sat in a status past its + /// threshold as seen, mute it, and leave a note. Each row is keyed to the UnitStates row that began the + /// episode, so it cascades away with that state. One uncleared row per episode is enforced by a filtered + /// unique index. + /// + [Migration(258)] + public class M0258_AddUnitStatusAlertAcknowledgements : Migration + { + private const string Table = "UnitStatusAlertAcknowledgements"; + + public override void Up() + { + if (!Schema.Table(Table).Exists()) + { + Create.Table(Table) + .WithColumn("UnitStatusAlertAcknowledgementId").AsString(128).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("UnitId").AsInt32().NotNullable() + .WithColumn("UnitStateId").AsInt32().NotNullable() + .WithColumn("Level").AsInt32().NotNullable() + .WithColumn("Mode").AsInt32().NotNullable() + .WithColumn("MutedUntil").AsDateTime2().Nullable() + .WithColumn("Note").AsString(500).Nullable() + .WithColumn("AcknowledgedByUserId").AsString(128).NotNullable() + .WithColumn("AcknowledgedOn").AsDateTime2().NotNullable() + .WithColumn("ClearedByUserId").AsString(128).Nullable() + .WithColumn("ClearedOn").AsDateTime2().Nullable(); + + // M0101 created UQ_Units_DepartmentId_UnitId, so the unit and the department always agree. + Create.ForeignKey("FK_UnitStatusAlertAcknowledgements_Units_Department_Unit") + .FromTable(Table).ForeignColumns("DepartmentId", "UnitId") + .ToTable("Units").PrimaryColumns("DepartmentId", "UnitId"); + + Create.ForeignKey("FK_UnitStatusAlertAcknowledgements_UnitStates") + .FromTable(Table).ForeignColumn("UnitStateId") + .ToTable("UnitStates").PrimaryColumn("UnitStateId") + .OnDelete(Rule.Cascade); + } + + if (!Schema.Table(Table).Index("IX_UnitStatusAlertAcknowledgements_Department_Cleared").Exists()) + { + Create.Index("IX_UnitStatusAlertAcknowledgements_Department_Cleared") + .OnTable(Table) + .OnColumn("DepartmentId").Ascending() + .OnColumn("ClearedOn").Ascending(); + } + + Execute.Sql(@" + IF NOT EXISTS ( + SELECT 1 + FROM sys.indexes + WHERE name = 'UX_UnitStatusAlertAcknowledgements_ActiveEpisode' + AND object_id = OBJECT_ID(N'UnitStatusAlertAcknowledgements')) + BEGIN + CREATE UNIQUE NONCLUSTERED INDEX UX_UnitStatusAlertAcknowledgements_ActiveEpisode + ON UnitStatusAlertAcknowledgements (UnitStateId) + WHERE ClearedOn IS NULL; + END"); + } + + public override void Down() + { + if (Schema.Table(Table).Exists()) + Delete.Table(Table); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0258_AddUnitStatusAlertAcknowledgementsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0258_AddUnitStatusAlertAcknowledgementsPg.cs new file mode 100644 index 000000000..e27bc1a5e --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0258_AddUnitStatusAlertAcknowledgementsPg.cs @@ -0,0 +1,66 @@ +using System.Data; +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Unit status timer acknowledgements: a dispatcher can mark a unit that has sat in a status past its + /// threshold as seen, mute it, and leave a note. Each row is keyed to the unitstates row that began the + /// episode, so it cascades away with that state. One uncleared row per episode is enforced by a partial + /// unique index. + /// + [Migration(258)] + public class M0258_AddUnitStatusAlertAcknowledgementsPg : Migration + { + private const string Table = "unitstatusalertacknowledgements"; + + public override void Up() + { + if (!Schema.Table(Table).Exists()) + { + Create.Table(Table) + .WithColumn("unitstatusalertacknowledgementid").AsCustom("citext").NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("unitid").AsInt32().NotNullable() + .WithColumn("unitstateid").AsInt32().NotNullable() + .WithColumn("level").AsInt32().NotNullable() + .WithColumn("mode").AsInt32().NotNullable() + .WithColumn("muteduntil").AsDateTime2().Nullable() + .WithColumn("note").AsCustom("citext").Nullable() + .WithColumn("acknowledgedbyuserid").AsCustom("citext").NotNullable() + .WithColumn("acknowledgedon").AsDateTime2().NotNullable() + .WithColumn("clearedbyuserid").AsCustom("citext").Nullable() + .WithColumn("clearedon").AsDateTime2().Nullable(); + + // M0101 created uq_units_departmentid_unitid, so the unit and the department always agree. + Create.ForeignKey("fk_unitstatusalertacknowledgements_units_department_unit") + .FromTable(Table).ForeignColumns("departmentid", "unitid") + .ToTable("units").PrimaryColumns("departmentid", "unitid"); + + Create.ForeignKey("fk_unitstatusalertacknowledgements_unitstates") + .FromTable(Table).ForeignColumn("unitstateid") + .ToTable("unitstates").PrimaryColumn("unitstateid") + .OnDelete(Rule.Cascade); + } + + if (!Schema.Table(Table).Index("ix_unitstatusalertacknowledgements_department_cleared").Exists()) + { + Create.Index("ix_unitstatusalertacknowledgements_department_cleared") + .OnTable(Table) + .OnColumn("departmentid").Ascending() + .OnColumn("clearedon").Ascending(); + } + + Execute.Sql(@" + CREATE UNIQUE INDEX IF NOT EXISTS ux_unitstatusalertacknowledgements_activeepisode + ON unitstatusalertacknowledgements (unitstateid) + WHERE clearedon IS NULL;"); + } + + public override void Down() + { + if (Schema.Table(Table).Exists()) + Delete.Table(Table); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/DeleteRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/DeleteRepository.cs index 5f0249cc2..74c39bfab 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/DeleteRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/DeleteRepository.cs @@ -75,6 +75,9 @@ DELETE FROM [dbo].[CallReferences] WHERE SourceCallId IN (SELECT CallId FROM [db DELETE FROM [dbo].[UnitTrackingCredentials] WHERE UnitTrackingDeviceId IN (SELECT UnitTrackingDeviceId FROM [dbo].[UnitTrackingDevices] WHERE DepartmentId = @DepartmentId) DELETE FROM [dbo].[UnitTrackingDevices] WHERE DepartmentId = @DepartmentId + -- Status timer acknowledgements reference Units (DepartmentId, UnitId) and UnitStates (cascades, but be explicit) + DELETE FROM [dbo].[UnitStatusAlertAcknowledgements] WHERE DepartmentId = @DepartmentId + OPEN unit_cursor FETCH NEXT FROM unit_cursor INTO @UnitId diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs index a70f810be..f9e27d4c7 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs @@ -301,6 +301,7 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); // CheckIn Repositories builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs index d10db738f..54922411e 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs @@ -321,6 +321,7 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); // CheckIn Repositories builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs index cb2ead325..c16aba86f 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs @@ -288,6 +288,7 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); // CheckIn Repositories builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs index cc6c7542e..42b0ed9bd 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs @@ -322,6 +322,7 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); // CheckIn Repositories builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/UnitStatusAlertAcknowledgementsRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/UnitStatusAlertAcknowledgementsRepository.cs new file mode 100644 index 000000000..9dffcc14d --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/UnitStatusAlertAcknowledgementsRepository.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Repositories.DataRepository.Configs; + +namespace Resgrid.Repositories.DataRepository +{ + public class UnitStatusAlertAcknowledgementsRepository : RmsRepositoryBase, IUnitStatusAlertAcknowledgementsRepository + { + public UnitStatusAlertAcknowledgementsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task> GetActiveForUnitStatesAsync(int departmentId, IEnumerable unitStateIds) => + QueryAsync($"SELECT * FROM {Tbl("UnitStatusAlertAcknowledgements")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {InList("UnitStateId", "UnitStateIds")} AND {Col("ClearedOn")} IS NULL ORDER BY {Col("AcknowledgedOn")}", + new { DepartmentId = departmentId, UnitStateIds = InListValue(unitStateIds) }); + + public Task> GetActiveForUnitStateAsync(int departmentId, int unitId, int unitStateId) => + QueryAsync($"SELECT * FROM {Tbl("UnitStatusAlertAcknowledgements")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("UnitId")} = {P}UnitId AND {Col("UnitStateId")} = {P}UnitStateId AND {Col("ClearedOn")} IS NULL ORDER BY {Col("AcknowledgedOn")}", + new { DepartmentId = departmentId, UnitId = unitId, UnitStateId = unitStateId }); + } +} diff --git a/Resgrid.sln b/Resgrid.sln index 4e43069c3..da6064dae 100644 --- a/Resgrid.sln +++ b/Resgrid.sln @@ -168,8 +168,6 @@ Global {D867D4CA-D348-4485-A7B0-5293DF8F93D5}.Cloud|Any CPU.Build.0 = Debug|Any CPU {D867D4CA-D348-4485-A7B0-5293DF8F93D5}.Cloud|x86.ActiveCfg = Debug|Any CPU {D867D4CA-D348-4485-A7B0-5293DF8F93D5}.Cloud|x86.Build.0 = Debug|Any CPU - {D867D4CA-D348-4485-A7B0-5293DF8F93D5}.Cloud|x64.ActiveCfg = Cloud|Any CPU - {D867D4CA-D348-4485-A7B0-5293DF8F93D5}.Cloud|x64.Build.0 = Cloud|Any CPU {D867D4CA-D348-4485-A7B0-5293DF8F93D5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D867D4CA-D348-4485-A7B0-5293DF8F93D5}.Debug|Any CPU.Build.0 = Debug|Any CPU {D867D4CA-D348-4485-A7B0-5293DF8F93D5}.Debug|x86.ActiveCfg = Debug|Any CPU @@ -192,20 +190,14 @@ Global {D867D4CA-D348-4485-A7B0-5293DF8F93D5}.Staging|Any CPU.Build.0 = Debug|Any CPU {D867D4CA-D348-4485-A7B0-5293DF8F93D5}.Staging|x86.ActiveCfg = Debug|Any CPU {D867D4CA-D348-4485-A7B0-5293DF8F93D5}.Staging|x86.Build.0 = Debug|Any CPU - {D867D4CA-D348-4485-A7B0-5293DF8F93D5}.Staging|x64.ActiveCfg = Staging|Any CPU - {D867D4CA-D348-4485-A7B0-5293DF8F93D5}.Staging|x64.Build.0 = Staging|Any CPU {24E2241D-D82C-443D-9613-F900E44C003E}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {24E2241D-D82C-443D-9613-F900E44C003E}.Azure|Any CPU.Build.0 = Debug|Any CPU {24E2241D-D82C-443D-9613-F900E44C003E}.Azure|x86.ActiveCfg = Debug|Any CPU {24E2241D-D82C-443D-9613-F900E44C003E}.Azure|x86.Build.0 = Debug|Any CPU - {24E2241D-D82C-443D-9613-F900E44C003E}.Azure|x64.ActiveCfg = Azure|Any CPU - {24E2241D-D82C-443D-9613-F900E44C003E}.Azure|x64.Build.0 = Azure|Any CPU {24E2241D-D82C-443D-9613-F900E44C003E}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {24E2241D-D82C-443D-9613-F900E44C003E}.Cloud|Any CPU.Build.0 = Debug|Any CPU {24E2241D-D82C-443D-9613-F900E44C003E}.Cloud|x86.ActiveCfg = Debug|Any CPU {24E2241D-D82C-443D-9613-F900E44C003E}.Cloud|x86.Build.0 = Debug|Any CPU - {24E2241D-D82C-443D-9613-F900E44C003E}.Cloud|x64.ActiveCfg = Cloud|Any CPU - {24E2241D-D82C-443D-9613-F900E44C003E}.Cloud|x64.Build.0 = Cloud|Any CPU {24E2241D-D82C-443D-9613-F900E44C003E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {24E2241D-D82C-443D-9613-F900E44C003E}.Debug|Any CPU.Build.0 = Debug|Any CPU {24E2241D-D82C-443D-9613-F900E44C003E}.Debug|x86.ActiveCfg = Debug|Any CPU @@ -228,20 +220,14 @@ Global {24E2241D-D82C-443D-9613-F900E44C003E}.Staging|Any CPU.Build.0 = Debug|Any CPU {24E2241D-D82C-443D-9613-F900E44C003E}.Staging|x86.ActiveCfg = Debug|Any CPU {24E2241D-D82C-443D-9613-F900E44C003E}.Staging|x86.Build.0 = Debug|Any CPU - {24E2241D-D82C-443D-9613-F900E44C003E}.Staging|x64.ActiveCfg = Staging|Any CPU - {24E2241D-D82C-443D-9613-F900E44C003E}.Staging|x64.Build.0 = Staging|Any CPU {C8A18F9F-93BC-44F6-AFFD-6187EA0BFF78}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {C8A18F9F-93BC-44F6-AFFD-6187EA0BFF78}.Azure|Any CPU.Build.0 = Debug|Any CPU {C8A18F9F-93BC-44F6-AFFD-6187EA0BFF78}.Azure|x86.ActiveCfg = Debug|Any CPU {C8A18F9F-93BC-44F6-AFFD-6187EA0BFF78}.Azure|x86.Build.0 = Debug|Any CPU - {C8A18F9F-93BC-44F6-AFFD-6187EA0BFF78}.Azure|x64.ActiveCfg = Azure|Any CPU - {C8A18F9F-93BC-44F6-AFFD-6187EA0BFF78}.Azure|x64.Build.0 = Azure|Any CPU {C8A18F9F-93BC-44F6-AFFD-6187EA0BFF78}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {C8A18F9F-93BC-44F6-AFFD-6187EA0BFF78}.Cloud|Any CPU.Build.0 = Debug|Any CPU {C8A18F9F-93BC-44F6-AFFD-6187EA0BFF78}.Cloud|x86.ActiveCfg = Debug|Any CPU {C8A18F9F-93BC-44F6-AFFD-6187EA0BFF78}.Cloud|x86.Build.0 = Debug|Any CPU - {C8A18F9F-93BC-44F6-AFFD-6187EA0BFF78}.Cloud|x64.ActiveCfg = Cloud|Any CPU - {C8A18F9F-93BC-44F6-AFFD-6187EA0BFF78}.Cloud|x64.Build.0 = Cloud|Any CPU {C8A18F9F-93BC-44F6-AFFD-6187EA0BFF78}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C8A18F9F-93BC-44F6-AFFD-6187EA0BFF78}.Debug|Any CPU.Build.0 = Debug|Any CPU {C8A18F9F-93BC-44F6-AFFD-6187EA0BFF78}.Debug|x86.ActiveCfg = Debug|Any CPU @@ -264,20 +250,14 @@ Global {C8A18F9F-93BC-44F6-AFFD-6187EA0BFF78}.Staging|Any CPU.Build.0 = Debug|Any CPU {C8A18F9F-93BC-44F6-AFFD-6187EA0BFF78}.Staging|x86.ActiveCfg = Debug|Any CPU {C8A18F9F-93BC-44F6-AFFD-6187EA0BFF78}.Staging|x86.Build.0 = Debug|Any CPU - {C8A18F9F-93BC-44F6-AFFD-6187EA0BFF78}.Staging|x64.ActiveCfg = Staging|Any CPU - {C8A18F9F-93BC-44F6-AFFD-6187EA0BFF78}.Staging|x64.Build.0 = Staging|Any CPU {58CE37F7-706E-49E8-B814-926061E248C3}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {58CE37F7-706E-49E8-B814-926061E248C3}.Azure|Any CPU.Build.0 = Debug|Any CPU {58CE37F7-706E-49E8-B814-926061E248C3}.Azure|x86.ActiveCfg = Debug|Any CPU {58CE37F7-706E-49E8-B814-926061E248C3}.Azure|x86.Build.0 = Debug|Any CPU - {58CE37F7-706E-49E8-B814-926061E248C3}.Azure|x64.ActiveCfg = Azure|Any CPU - {58CE37F7-706E-49E8-B814-926061E248C3}.Azure|x64.Build.0 = Azure|Any CPU {58CE37F7-706E-49E8-B814-926061E248C3}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {58CE37F7-706E-49E8-B814-926061E248C3}.Cloud|Any CPU.Build.0 = Debug|Any CPU {58CE37F7-706E-49E8-B814-926061E248C3}.Cloud|x86.ActiveCfg = Debug|Any CPU {58CE37F7-706E-49E8-B814-926061E248C3}.Cloud|x86.Build.0 = Debug|Any CPU - {58CE37F7-706E-49E8-B814-926061E248C3}.Cloud|x64.ActiveCfg = Cloud|Any CPU - {58CE37F7-706E-49E8-B814-926061E248C3}.Cloud|x64.Build.0 = Cloud|Any CPU {58CE37F7-706E-49E8-B814-926061E248C3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {58CE37F7-706E-49E8-B814-926061E248C3}.Debug|Any CPU.Build.0 = Debug|Any CPU {58CE37F7-706E-49E8-B814-926061E248C3}.Debug|x86.ActiveCfg = Debug|Any CPU @@ -300,20 +280,14 @@ Global {58CE37F7-706E-49E8-B814-926061E248C3}.Staging|Any CPU.Build.0 = Debug|Any CPU {58CE37F7-706E-49E8-B814-926061E248C3}.Staging|x86.ActiveCfg = Debug|Any CPU {58CE37F7-706E-49E8-B814-926061E248C3}.Staging|x86.Build.0 = Debug|Any CPU - {58CE37F7-706E-49E8-B814-926061E248C3}.Staging|x64.ActiveCfg = Staging|Any CPU - {58CE37F7-706E-49E8-B814-926061E248C3}.Staging|x64.Build.0 = Staging|Any CPU {7EAFA222-4F03-4A64-BA90-73BE80092620}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {7EAFA222-4F03-4A64-BA90-73BE80092620}.Azure|Any CPU.Build.0 = Debug|Any CPU {7EAFA222-4F03-4A64-BA90-73BE80092620}.Azure|x86.ActiveCfg = Debug|Any CPU {7EAFA222-4F03-4A64-BA90-73BE80092620}.Azure|x86.Build.0 = Debug|Any CPU - {7EAFA222-4F03-4A64-BA90-73BE80092620}.Azure|x64.ActiveCfg = Azure|Any CPU - {7EAFA222-4F03-4A64-BA90-73BE80092620}.Azure|x64.Build.0 = Azure|Any CPU {7EAFA222-4F03-4A64-BA90-73BE80092620}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {7EAFA222-4F03-4A64-BA90-73BE80092620}.Cloud|Any CPU.Build.0 = Debug|Any CPU {7EAFA222-4F03-4A64-BA90-73BE80092620}.Cloud|x86.ActiveCfg = Debug|Any CPU {7EAFA222-4F03-4A64-BA90-73BE80092620}.Cloud|x86.Build.0 = Debug|Any CPU - {7EAFA222-4F03-4A64-BA90-73BE80092620}.Cloud|x64.ActiveCfg = Cloud|Any CPU - {7EAFA222-4F03-4A64-BA90-73BE80092620}.Cloud|x64.Build.0 = Cloud|Any CPU {7EAFA222-4F03-4A64-BA90-73BE80092620}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {7EAFA222-4F03-4A64-BA90-73BE80092620}.Debug|Any CPU.Build.0 = Debug|Any CPU {7EAFA222-4F03-4A64-BA90-73BE80092620}.Debug|x86.ActiveCfg = Debug|Any CPU @@ -336,20 +310,14 @@ Global {7EAFA222-4F03-4A64-BA90-73BE80092620}.Staging|Any CPU.Build.0 = Debug|Any CPU {7EAFA222-4F03-4A64-BA90-73BE80092620}.Staging|x86.ActiveCfg = Debug|Any CPU {7EAFA222-4F03-4A64-BA90-73BE80092620}.Staging|x86.Build.0 = Debug|Any CPU - {7EAFA222-4F03-4A64-BA90-73BE80092620}.Staging|x64.ActiveCfg = Staging|Any CPU - {7EAFA222-4F03-4A64-BA90-73BE80092620}.Staging|x64.Build.0 = Staging|Any CPU {970C7F13-2384-4ED8-8C33-0414A49970E5}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {970C7F13-2384-4ED8-8C33-0414A49970E5}.Azure|Any CPU.Build.0 = Debug|Any CPU {970C7F13-2384-4ED8-8C33-0414A49970E5}.Azure|x86.ActiveCfg = Debug|Any CPU {970C7F13-2384-4ED8-8C33-0414A49970E5}.Azure|x86.Build.0 = Debug|Any CPU - {970C7F13-2384-4ED8-8C33-0414A49970E5}.Azure|x64.ActiveCfg = Azure|Any CPU - {970C7F13-2384-4ED8-8C33-0414A49970E5}.Azure|x64.Build.0 = Azure|Any CPU {970C7F13-2384-4ED8-8C33-0414A49970E5}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {970C7F13-2384-4ED8-8C33-0414A49970E5}.Cloud|Any CPU.Build.0 = Debug|Any CPU {970C7F13-2384-4ED8-8C33-0414A49970E5}.Cloud|x86.ActiveCfg = Debug|Any CPU {970C7F13-2384-4ED8-8C33-0414A49970E5}.Cloud|x86.Build.0 = Debug|Any CPU - {970C7F13-2384-4ED8-8C33-0414A49970E5}.Cloud|x64.ActiveCfg = Cloud|Any CPU - {970C7F13-2384-4ED8-8C33-0414A49970E5}.Cloud|x64.Build.0 = Cloud|Any CPU {970C7F13-2384-4ED8-8C33-0414A49970E5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {970C7F13-2384-4ED8-8C33-0414A49970E5}.Debug|Any CPU.Build.0 = Debug|Any CPU {970C7F13-2384-4ED8-8C33-0414A49970E5}.Debug|x86.ActiveCfg = Debug|Any CPU @@ -372,20 +340,14 @@ Global {970C7F13-2384-4ED8-8C33-0414A49970E5}.Staging|Any CPU.Build.0 = Debug|Any CPU {970C7F13-2384-4ED8-8C33-0414A49970E5}.Staging|x86.ActiveCfg = Debug|Any CPU {970C7F13-2384-4ED8-8C33-0414A49970E5}.Staging|x86.Build.0 = Debug|Any CPU - {970C7F13-2384-4ED8-8C33-0414A49970E5}.Staging|x64.ActiveCfg = Staging|Any CPU - {970C7F13-2384-4ED8-8C33-0414A49970E5}.Staging|x64.Build.0 = Staging|Any CPU {94165DE6-7224-4EF0-ADB0-889A5EF77519}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {94165DE6-7224-4EF0-ADB0-889A5EF77519}.Azure|Any CPU.Build.0 = Debug|Any CPU {94165DE6-7224-4EF0-ADB0-889A5EF77519}.Azure|x86.ActiveCfg = Debug|Any CPU {94165DE6-7224-4EF0-ADB0-889A5EF77519}.Azure|x86.Build.0 = Debug|Any CPU - {94165DE6-7224-4EF0-ADB0-889A5EF77519}.Azure|x64.ActiveCfg = Azure|Any CPU - {94165DE6-7224-4EF0-ADB0-889A5EF77519}.Azure|x64.Build.0 = Azure|Any CPU {94165DE6-7224-4EF0-ADB0-889A5EF77519}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {94165DE6-7224-4EF0-ADB0-889A5EF77519}.Cloud|Any CPU.Build.0 = Debug|Any CPU {94165DE6-7224-4EF0-ADB0-889A5EF77519}.Cloud|x86.ActiveCfg = Debug|Any CPU {94165DE6-7224-4EF0-ADB0-889A5EF77519}.Cloud|x86.Build.0 = Debug|Any CPU - {94165DE6-7224-4EF0-ADB0-889A5EF77519}.Cloud|x64.ActiveCfg = Cloud|Any CPU - {94165DE6-7224-4EF0-ADB0-889A5EF77519}.Cloud|x64.Build.0 = Cloud|Any CPU {94165DE6-7224-4EF0-ADB0-889A5EF77519}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {94165DE6-7224-4EF0-ADB0-889A5EF77519}.Debug|Any CPU.Build.0 = Debug|Any CPU {94165DE6-7224-4EF0-ADB0-889A5EF77519}.Debug|x86.ActiveCfg = Debug|Any CPU @@ -408,20 +370,14 @@ Global {94165DE6-7224-4EF0-ADB0-889A5EF77519}.Staging|Any CPU.Build.0 = Debug|Any CPU {94165DE6-7224-4EF0-ADB0-889A5EF77519}.Staging|x86.ActiveCfg = Debug|Any CPU {94165DE6-7224-4EF0-ADB0-889A5EF77519}.Staging|x86.Build.0 = Debug|Any CPU - {94165DE6-7224-4EF0-ADB0-889A5EF77519}.Staging|x64.ActiveCfg = Staging|Any CPU - {94165DE6-7224-4EF0-ADB0-889A5EF77519}.Staging|x64.Build.0 = Staging|Any CPU {B3F5D0C1-3697-4691-9999-F4321CD0B6F9}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {B3F5D0C1-3697-4691-9999-F4321CD0B6F9}.Azure|Any CPU.Build.0 = Debug|Any CPU {B3F5D0C1-3697-4691-9999-F4321CD0B6F9}.Azure|x86.ActiveCfg = Debug|Any CPU {B3F5D0C1-3697-4691-9999-F4321CD0B6F9}.Azure|x86.Build.0 = Debug|Any CPU - {B3F5D0C1-3697-4691-9999-F4321CD0B6F9}.Azure|x64.ActiveCfg = Azure|Any CPU - {B3F5D0C1-3697-4691-9999-F4321CD0B6F9}.Azure|x64.Build.0 = Azure|Any CPU {B3F5D0C1-3697-4691-9999-F4321CD0B6F9}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {B3F5D0C1-3697-4691-9999-F4321CD0B6F9}.Cloud|Any CPU.Build.0 = Debug|Any CPU {B3F5D0C1-3697-4691-9999-F4321CD0B6F9}.Cloud|x86.ActiveCfg = Debug|Any CPU {B3F5D0C1-3697-4691-9999-F4321CD0B6F9}.Cloud|x86.Build.0 = Debug|Any CPU - {B3F5D0C1-3697-4691-9999-F4321CD0B6F9}.Cloud|x64.ActiveCfg = Cloud|Any CPU - {B3F5D0C1-3697-4691-9999-F4321CD0B6F9}.Cloud|x64.Build.0 = Cloud|Any CPU {B3F5D0C1-3697-4691-9999-F4321CD0B6F9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B3F5D0C1-3697-4691-9999-F4321CD0B6F9}.Debug|Any CPU.Build.0 = Debug|Any CPU {B3F5D0C1-3697-4691-9999-F4321CD0B6F9}.Debug|x86.ActiveCfg = Debug|Any CPU @@ -444,20 +400,14 @@ Global {B3F5D0C1-3697-4691-9999-F4321CD0B6F9}.Staging|Any CPU.Build.0 = Debug|Any CPU {B3F5D0C1-3697-4691-9999-F4321CD0B6F9}.Staging|x86.ActiveCfg = Debug|Any CPU {B3F5D0C1-3697-4691-9999-F4321CD0B6F9}.Staging|x86.Build.0 = Debug|Any CPU - {B3F5D0C1-3697-4691-9999-F4321CD0B6F9}.Staging|x64.ActiveCfg = Staging|Any CPU - {B3F5D0C1-3697-4691-9999-F4321CD0B6F9}.Staging|x64.Build.0 = Staging|Any CPU {A7C2E4F1-8B3D-4A56-9E7F-B8C1D2E3F4A5}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {A7C2E4F1-8B3D-4A56-9E7F-B8C1D2E3F4A5}.Azure|Any CPU.Build.0 = Debug|Any CPU {A7C2E4F1-8B3D-4A56-9E7F-B8C1D2E3F4A5}.Azure|x86.ActiveCfg = Debug|Any CPU {A7C2E4F1-8B3D-4A56-9E7F-B8C1D2E3F4A5}.Azure|x86.Build.0 = Debug|Any CPU - {A7C2E4F1-8B3D-4A56-9E7F-B8C1D2E3F4A5}.Azure|x64.ActiveCfg = Azure|Any CPU - {A7C2E4F1-8B3D-4A56-9E7F-B8C1D2E3F4A5}.Azure|x64.Build.0 = Azure|Any CPU {A7C2E4F1-8B3D-4A56-9E7F-B8C1D2E3F4A5}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {A7C2E4F1-8B3D-4A56-9E7F-B8C1D2E3F4A5}.Cloud|Any CPU.Build.0 = Debug|Any CPU {A7C2E4F1-8B3D-4A56-9E7F-B8C1D2E3F4A5}.Cloud|x86.ActiveCfg = Debug|Any CPU {A7C2E4F1-8B3D-4A56-9E7F-B8C1D2E3F4A5}.Cloud|x86.Build.0 = Debug|Any CPU - {A7C2E4F1-8B3D-4A56-9E7F-B8C1D2E3F4A5}.Cloud|x64.ActiveCfg = Cloud|Any CPU - {A7C2E4F1-8B3D-4A56-9E7F-B8C1D2E3F4A5}.Cloud|x64.Build.0 = Cloud|Any CPU {A7C2E4F1-8B3D-4A56-9E7F-B8C1D2E3F4A5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A7C2E4F1-8B3D-4A56-9E7F-B8C1D2E3F4A5}.Debug|Any CPU.Build.0 = Debug|Any CPU {A7C2E4F1-8B3D-4A56-9E7F-B8C1D2E3F4A5}.Debug|x86.ActiveCfg = Debug|Any CPU @@ -480,20 +430,14 @@ Global {A7C2E4F1-8B3D-4A56-9E7F-B8C1D2E3F4A5}.Staging|Any CPU.Build.0 = Debug|Any CPU {A7C2E4F1-8B3D-4A56-9E7F-B8C1D2E3F4A5}.Staging|x86.ActiveCfg = Debug|Any CPU {A7C2E4F1-8B3D-4A56-9E7F-B8C1D2E3F4A5}.Staging|x86.Build.0 = Debug|Any CPU - {A7C2E4F1-8B3D-4A56-9E7F-B8C1D2E3F4A5}.Staging|x64.ActiveCfg = Staging|Any CPU - {A7C2E4F1-8B3D-4A56-9E7F-B8C1D2E3F4A5}.Staging|x64.Build.0 = Staging|Any CPU {506F9CFB-7C67-4036-920D-A85639B02544}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {506F9CFB-7C67-4036-920D-A85639B02544}.Azure|Any CPU.Build.0 = Debug|Any CPU {506F9CFB-7C67-4036-920D-A85639B02544}.Azure|x86.ActiveCfg = Debug|Any CPU {506F9CFB-7C67-4036-920D-A85639B02544}.Azure|x86.Build.0 = Debug|Any CPU - {506F9CFB-7C67-4036-920D-A85639B02544}.Azure|x64.ActiveCfg = Azure|Any CPU - {506F9CFB-7C67-4036-920D-A85639B02544}.Azure|x64.Build.0 = Azure|Any CPU {506F9CFB-7C67-4036-920D-A85639B02544}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {506F9CFB-7C67-4036-920D-A85639B02544}.Cloud|Any CPU.Build.0 = Debug|Any CPU {506F9CFB-7C67-4036-920D-A85639B02544}.Cloud|x86.ActiveCfg = Debug|Any CPU {506F9CFB-7C67-4036-920D-A85639B02544}.Cloud|x86.Build.0 = Debug|Any CPU - {506F9CFB-7C67-4036-920D-A85639B02544}.Cloud|x64.ActiveCfg = Cloud|Any CPU - {506F9CFB-7C67-4036-920D-A85639B02544}.Cloud|x64.Build.0 = Cloud|Any CPU {506F9CFB-7C67-4036-920D-A85639B02544}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {506F9CFB-7C67-4036-920D-A85639B02544}.Debug|Any CPU.Build.0 = Debug|Any CPU {506F9CFB-7C67-4036-920D-A85639B02544}.Debug|x86.ActiveCfg = Debug|Any CPU @@ -516,20 +460,14 @@ Global {506F9CFB-7C67-4036-920D-A85639B02544}.Staging|Any CPU.Build.0 = Debug|Any CPU {506F9CFB-7C67-4036-920D-A85639B02544}.Staging|x86.ActiveCfg = Debug|Any CPU {506F9CFB-7C67-4036-920D-A85639B02544}.Staging|x86.Build.0 = Debug|Any CPU - {506F9CFB-7C67-4036-920D-A85639B02544}.Staging|x64.ActiveCfg = Staging|Any CPU - {506F9CFB-7C67-4036-920D-A85639B02544}.Staging|x64.Build.0 = Staging|Any CPU {5565BF04-1056-4B2B-AB1A-E3AD9C433283}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {5565BF04-1056-4B2B-AB1A-E3AD9C433283}.Azure|Any CPU.Build.0 = Debug|Any CPU {5565BF04-1056-4B2B-AB1A-E3AD9C433283}.Azure|x86.ActiveCfg = Debug|Any CPU {5565BF04-1056-4B2B-AB1A-E3AD9C433283}.Azure|x86.Build.0 = Debug|Any CPU - {5565BF04-1056-4B2B-AB1A-E3AD9C433283}.Azure|x64.ActiveCfg = Azure|Any CPU - {5565BF04-1056-4B2B-AB1A-E3AD9C433283}.Azure|x64.Build.0 = Azure|Any CPU {5565BF04-1056-4B2B-AB1A-E3AD9C433283}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {5565BF04-1056-4B2B-AB1A-E3AD9C433283}.Cloud|Any CPU.Build.0 = Debug|Any CPU {5565BF04-1056-4B2B-AB1A-E3AD9C433283}.Cloud|x86.ActiveCfg = Debug|Any CPU {5565BF04-1056-4B2B-AB1A-E3AD9C433283}.Cloud|x86.Build.0 = Debug|Any CPU - {5565BF04-1056-4B2B-AB1A-E3AD9C433283}.Cloud|x64.ActiveCfg = Cloud|Any CPU - {5565BF04-1056-4B2B-AB1A-E3AD9C433283}.Cloud|x64.Build.0 = Cloud|Any CPU {5565BF04-1056-4B2B-AB1A-E3AD9C433283}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {5565BF04-1056-4B2B-AB1A-E3AD9C433283}.Debug|Any CPU.Build.0 = Debug|Any CPU {5565BF04-1056-4B2B-AB1A-E3AD9C433283}.Debug|x86.ActiveCfg = Debug|Any CPU @@ -552,20 +490,14 @@ Global {5565BF04-1056-4B2B-AB1A-E3AD9C433283}.Staging|Any CPU.Build.0 = Debug|Any CPU {5565BF04-1056-4B2B-AB1A-E3AD9C433283}.Staging|x86.ActiveCfg = Debug|Any CPU {5565BF04-1056-4B2B-AB1A-E3AD9C433283}.Staging|x86.Build.0 = Debug|Any CPU - {5565BF04-1056-4B2B-AB1A-E3AD9C433283}.Staging|x64.ActiveCfg = Staging|Any CPU - {5565BF04-1056-4B2B-AB1A-E3AD9C433283}.Staging|x64.Build.0 = Staging|Any CPU {69FC3B6C-801F-4D98-BE3E-9BC47C42F874}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {69FC3B6C-801F-4D98-BE3E-9BC47C42F874}.Azure|Any CPU.Build.0 = Debug|Any CPU {69FC3B6C-801F-4D98-BE3E-9BC47C42F874}.Azure|x86.ActiveCfg = Debug|Any CPU {69FC3B6C-801F-4D98-BE3E-9BC47C42F874}.Azure|x86.Build.0 = Debug|Any CPU - {69FC3B6C-801F-4D98-BE3E-9BC47C42F874}.Azure|x64.ActiveCfg = Azure|Any CPU - {69FC3B6C-801F-4D98-BE3E-9BC47C42F874}.Azure|x64.Build.0 = Azure|Any CPU {69FC3B6C-801F-4D98-BE3E-9BC47C42F874}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {69FC3B6C-801F-4D98-BE3E-9BC47C42F874}.Cloud|Any CPU.Build.0 = Debug|Any CPU {69FC3B6C-801F-4D98-BE3E-9BC47C42F874}.Cloud|x86.ActiveCfg = Debug|Any CPU {69FC3B6C-801F-4D98-BE3E-9BC47C42F874}.Cloud|x86.Build.0 = Debug|Any CPU - {69FC3B6C-801F-4D98-BE3E-9BC47C42F874}.Cloud|x64.ActiveCfg = Cloud|Any CPU - {69FC3B6C-801F-4D98-BE3E-9BC47C42F874}.Cloud|x64.Build.0 = Cloud|Any CPU {69FC3B6C-801F-4D98-BE3E-9BC47C42F874}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {69FC3B6C-801F-4D98-BE3E-9BC47C42F874}.Debug|Any CPU.Build.0 = Debug|Any CPU {69FC3B6C-801F-4D98-BE3E-9BC47C42F874}.Debug|x86.ActiveCfg = Debug|Any CPU @@ -588,20 +520,14 @@ Global {69FC3B6C-801F-4D98-BE3E-9BC47C42F874}.Staging|Any CPU.Build.0 = Debug|Any CPU {69FC3B6C-801F-4D98-BE3E-9BC47C42F874}.Staging|x86.ActiveCfg = Debug|Any CPU {69FC3B6C-801F-4D98-BE3E-9BC47C42F874}.Staging|x86.Build.0 = Debug|Any CPU - {69FC3B6C-801F-4D98-BE3E-9BC47C42F874}.Staging|x64.ActiveCfg = Staging|Any CPU - {69FC3B6C-801F-4D98-BE3E-9BC47C42F874}.Staging|x64.Build.0 = Staging|Any CPU {C81FD7A4-B8CD-4D6A-88D7-DABC4D092043}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {C81FD7A4-B8CD-4D6A-88D7-DABC4D092043}.Azure|Any CPU.Build.0 = Debug|Any CPU {C81FD7A4-B8CD-4D6A-88D7-DABC4D092043}.Azure|x86.ActiveCfg = Debug|Any CPU {C81FD7A4-B8CD-4D6A-88D7-DABC4D092043}.Azure|x86.Build.0 = Debug|Any CPU - {C81FD7A4-B8CD-4D6A-88D7-DABC4D092043}.Azure|x64.ActiveCfg = Azure|Any CPU - {C81FD7A4-B8CD-4D6A-88D7-DABC4D092043}.Azure|x64.Build.0 = Azure|Any CPU {C81FD7A4-B8CD-4D6A-88D7-DABC4D092043}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {C81FD7A4-B8CD-4D6A-88D7-DABC4D092043}.Cloud|Any CPU.Build.0 = Debug|Any CPU {C81FD7A4-B8CD-4D6A-88D7-DABC4D092043}.Cloud|x86.ActiveCfg = Debug|Any CPU {C81FD7A4-B8CD-4D6A-88D7-DABC4D092043}.Cloud|x86.Build.0 = Debug|Any CPU - {C81FD7A4-B8CD-4D6A-88D7-DABC4D092043}.Cloud|x64.ActiveCfg = Cloud|Any CPU - {C81FD7A4-B8CD-4D6A-88D7-DABC4D092043}.Cloud|x64.Build.0 = Cloud|Any CPU {C81FD7A4-B8CD-4D6A-88D7-DABC4D092043}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C81FD7A4-B8CD-4D6A-88D7-DABC4D092043}.Debug|Any CPU.Build.0 = Debug|Any CPU {C81FD7A4-B8CD-4D6A-88D7-DABC4D092043}.Debug|x86.ActiveCfg = Debug|Any CPU @@ -624,20 +550,14 @@ Global {C81FD7A4-B8CD-4D6A-88D7-DABC4D092043}.Staging|Any CPU.Build.0 = Debug|Any CPU {C81FD7A4-B8CD-4D6A-88D7-DABC4D092043}.Staging|x86.ActiveCfg = Debug|Any CPU {C81FD7A4-B8CD-4D6A-88D7-DABC4D092043}.Staging|x86.Build.0 = Debug|Any CPU - {C81FD7A4-B8CD-4D6A-88D7-DABC4D092043}.Staging|x64.ActiveCfg = Staging|Any CPU - {C81FD7A4-B8CD-4D6A-88D7-DABC4D092043}.Staging|x64.Build.0 = Staging|Any CPU {6016D92A-C9E2-4501-A526-611D47790B6D}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {6016D92A-C9E2-4501-A526-611D47790B6D}.Azure|Any CPU.Build.0 = Debug|Any CPU {6016D92A-C9E2-4501-A526-611D47790B6D}.Azure|x86.ActiveCfg = Debug|Any CPU {6016D92A-C9E2-4501-A526-611D47790B6D}.Azure|x86.Build.0 = Debug|Any CPU - {6016D92A-C9E2-4501-A526-611D47790B6D}.Azure|x64.ActiveCfg = Azure|Any CPU - {6016D92A-C9E2-4501-A526-611D47790B6D}.Azure|x64.Build.0 = Azure|Any CPU {6016D92A-C9E2-4501-A526-611D47790B6D}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {6016D92A-C9E2-4501-A526-611D47790B6D}.Cloud|Any CPU.Build.0 = Debug|Any CPU {6016D92A-C9E2-4501-A526-611D47790B6D}.Cloud|x86.ActiveCfg = Debug|Any CPU {6016D92A-C9E2-4501-A526-611D47790B6D}.Cloud|x86.Build.0 = Debug|Any CPU - {6016D92A-C9E2-4501-A526-611D47790B6D}.Cloud|x64.ActiveCfg = Cloud|Any CPU - {6016D92A-C9E2-4501-A526-611D47790B6D}.Cloud|x64.Build.0 = Cloud|Any CPU {6016D92A-C9E2-4501-A526-611D47790B6D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {6016D92A-C9E2-4501-A526-611D47790B6D}.Debug|Any CPU.Build.0 = Debug|Any CPU {6016D92A-C9E2-4501-A526-611D47790B6D}.Debug|x86.ActiveCfg = Debug|Any CPU @@ -660,20 +580,14 @@ Global {6016D92A-C9E2-4501-A526-611D47790B6D}.Staging|Any CPU.Build.0 = Debug|Any CPU {6016D92A-C9E2-4501-A526-611D47790B6D}.Staging|x86.ActiveCfg = Debug|Any CPU {6016D92A-C9E2-4501-A526-611D47790B6D}.Staging|x86.Build.0 = Debug|Any CPU - {6016D92A-C9E2-4501-A526-611D47790B6D}.Staging|x64.ActiveCfg = Staging|Any CPU - {6016D92A-C9E2-4501-A526-611D47790B6D}.Staging|x64.Build.0 = Staging|Any CPU {34AC4E74-A259-4FBC-8E3C-BCFD85346693}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {34AC4E74-A259-4FBC-8E3C-BCFD85346693}.Azure|Any CPU.Build.0 = Debug|Any CPU {34AC4E74-A259-4FBC-8E3C-BCFD85346693}.Azure|x86.ActiveCfg = Debug|Any CPU {34AC4E74-A259-4FBC-8E3C-BCFD85346693}.Azure|x86.Build.0 = Debug|Any CPU - {34AC4E74-A259-4FBC-8E3C-BCFD85346693}.Azure|x64.ActiveCfg = Azure|Any CPU - {34AC4E74-A259-4FBC-8E3C-BCFD85346693}.Azure|x64.Build.0 = Azure|Any CPU {34AC4E74-A259-4FBC-8E3C-BCFD85346693}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {34AC4E74-A259-4FBC-8E3C-BCFD85346693}.Cloud|Any CPU.Build.0 = Debug|Any CPU {34AC4E74-A259-4FBC-8E3C-BCFD85346693}.Cloud|x86.ActiveCfg = Debug|Any CPU {34AC4E74-A259-4FBC-8E3C-BCFD85346693}.Cloud|x86.Build.0 = Debug|Any CPU - {34AC4E74-A259-4FBC-8E3C-BCFD85346693}.Cloud|x64.ActiveCfg = Cloud|Any CPU - {34AC4E74-A259-4FBC-8E3C-BCFD85346693}.Cloud|x64.Build.0 = Cloud|Any CPU {34AC4E74-A259-4FBC-8E3C-BCFD85346693}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {34AC4E74-A259-4FBC-8E3C-BCFD85346693}.Debug|Any CPU.Build.0 = Debug|Any CPU {34AC4E74-A259-4FBC-8E3C-BCFD85346693}.Debug|x86.ActiveCfg = Debug|Any CPU @@ -696,20 +610,14 @@ Global {34AC4E74-A259-4FBC-8E3C-BCFD85346693}.Staging|Any CPU.Build.0 = Debug|Any CPU {34AC4E74-A259-4FBC-8E3C-BCFD85346693}.Staging|x86.ActiveCfg = Debug|Any CPU {34AC4E74-A259-4FBC-8E3C-BCFD85346693}.Staging|x86.Build.0 = Debug|Any CPU - {34AC4E74-A259-4FBC-8E3C-BCFD85346693}.Staging|x64.ActiveCfg = Staging|Any CPU - {34AC4E74-A259-4FBC-8E3C-BCFD85346693}.Staging|x64.Build.0 = Staging|Any CPU {2EFE8D6C-64B4-4BAF-8F2E-BECB81290E4F}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {2EFE8D6C-64B4-4BAF-8F2E-BECB81290E4F}.Azure|Any CPU.Build.0 = Debug|Any CPU {2EFE8D6C-64B4-4BAF-8F2E-BECB81290E4F}.Azure|x86.ActiveCfg = Debug|Any CPU {2EFE8D6C-64B4-4BAF-8F2E-BECB81290E4F}.Azure|x86.Build.0 = Debug|Any CPU - {2EFE8D6C-64B4-4BAF-8F2E-BECB81290E4F}.Azure|x64.ActiveCfg = Azure|Any CPU - {2EFE8D6C-64B4-4BAF-8F2E-BECB81290E4F}.Azure|x64.Build.0 = Azure|Any CPU {2EFE8D6C-64B4-4BAF-8F2E-BECB81290E4F}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {2EFE8D6C-64B4-4BAF-8F2E-BECB81290E4F}.Cloud|Any CPU.Build.0 = Debug|Any CPU {2EFE8D6C-64B4-4BAF-8F2E-BECB81290E4F}.Cloud|x86.ActiveCfg = Debug|Any CPU {2EFE8D6C-64B4-4BAF-8F2E-BECB81290E4F}.Cloud|x86.Build.0 = Debug|Any CPU - {2EFE8D6C-64B4-4BAF-8F2E-BECB81290E4F}.Cloud|x64.ActiveCfg = Cloud|Any CPU - {2EFE8D6C-64B4-4BAF-8F2E-BECB81290E4F}.Cloud|x64.Build.0 = Cloud|Any CPU {2EFE8D6C-64B4-4BAF-8F2E-BECB81290E4F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {2EFE8D6C-64B4-4BAF-8F2E-BECB81290E4F}.Debug|Any CPU.Build.0 = Debug|Any CPU {2EFE8D6C-64B4-4BAF-8F2E-BECB81290E4F}.Debug|x86.ActiveCfg = Debug|Any CPU @@ -732,20 +640,14 @@ Global {2EFE8D6C-64B4-4BAF-8F2E-BECB81290E4F}.Staging|Any CPU.Build.0 = Debug|Any CPU {2EFE8D6C-64B4-4BAF-8F2E-BECB81290E4F}.Staging|x86.ActiveCfg = Debug|Any CPU {2EFE8D6C-64B4-4BAF-8F2E-BECB81290E4F}.Staging|x86.Build.0 = Debug|Any CPU - {2EFE8D6C-64B4-4BAF-8F2E-BECB81290E4F}.Staging|x64.ActiveCfg = Staging|Any CPU - {2EFE8D6C-64B4-4BAF-8F2E-BECB81290E4F}.Staging|x64.Build.0 = Staging|Any CPU {F549CC6D-5A89-4E61-8EAF-F8CBA8868853}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {F549CC6D-5A89-4E61-8EAF-F8CBA8868853}.Azure|Any CPU.Build.0 = Debug|Any CPU {F549CC6D-5A89-4E61-8EAF-F8CBA8868853}.Azure|x86.ActiveCfg = Debug|Any CPU {F549CC6D-5A89-4E61-8EAF-F8CBA8868853}.Azure|x86.Build.0 = Debug|Any CPU - {F549CC6D-5A89-4E61-8EAF-F8CBA8868853}.Azure|x64.ActiveCfg = Azure|Any CPU - {F549CC6D-5A89-4E61-8EAF-F8CBA8868853}.Azure|x64.Build.0 = Azure|Any CPU {F549CC6D-5A89-4E61-8EAF-F8CBA8868853}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {F549CC6D-5A89-4E61-8EAF-F8CBA8868853}.Cloud|Any CPU.Build.0 = Debug|Any CPU {F549CC6D-5A89-4E61-8EAF-F8CBA8868853}.Cloud|x86.ActiveCfg = Debug|Any CPU {F549CC6D-5A89-4E61-8EAF-F8CBA8868853}.Cloud|x86.Build.0 = Debug|Any CPU - {F549CC6D-5A89-4E61-8EAF-F8CBA8868853}.Cloud|x64.ActiveCfg = Cloud|Any CPU - {F549CC6D-5A89-4E61-8EAF-F8CBA8868853}.Cloud|x64.Build.0 = Cloud|Any CPU {F549CC6D-5A89-4E61-8EAF-F8CBA8868853}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F549CC6D-5A89-4E61-8EAF-F8CBA8868853}.Debug|Any CPU.Build.0 = Debug|Any CPU {F549CC6D-5A89-4E61-8EAF-F8CBA8868853}.Debug|x86.ActiveCfg = Debug|Any CPU @@ -768,20 +670,14 @@ Global {F549CC6D-5A89-4E61-8EAF-F8CBA8868853}.Staging|Any CPU.Build.0 = Debug|Any CPU {F549CC6D-5A89-4E61-8EAF-F8CBA8868853}.Staging|x86.ActiveCfg = Debug|Any CPU {F549CC6D-5A89-4E61-8EAF-F8CBA8868853}.Staging|x86.Build.0 = Debug|Any CPU - {F549CC6D-5A89-4E61-8EAF-F8CBA8868853}.Staging|x64.ActiveCfg = Staging|Any CPU - {F549CC6D-5A89-4E61-8EAF-F8CBA8868853}.Staging|x64.Build.0 = Staging|Any CPU {B67526E5-159B-4F6A-88C7-A05899F114AF}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {B67526E5-159B-4F6A-88C7-A05899F114AF}.Azure|Any CPU.Build.0 = Debug|Any CPU {B67526E5-159B-4F6A-88C7-A05899F114AF}.Azure|x86.ActiveCfg = Debug|Any CPU {B67526E5-159B-4F6A-88C7-A05899F114AF}.Azure|x86.Build.0 = Debug|Any CPU - {B67526E5-159B-4F6A-88C7-A05899F114AF}.Azure|x64.ActiveCfg = Azure|Any CPU - {B67526E5-159B-4F6A-88C7-A05899F114AF}.Azure|x64.Build.0 = Azure|Any CPU {B67526E5-159B-4F6A-88C7-A05899F114AF}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {B67526E5-159B-4F6A-88C7-A05899F114AF}.Cloud|Any CPU.Build.0 = Debug|Any CPU {B67526E5-159B-4F6A-88C7-A05899F114AF}.Cloud|x86.ActiveCfg = Debug|Any CPU {B67526E5-159B-4F6A-88C7-A05899F114AF}.Cloud|x86.Build.0 = Debug|Any CPU - {B67526E5-159B-4F6A-88C7-A05899F114AF}.Cloud|x64.ActiveCfg = Cloud|Any CPU - {B67526E5-159B-4F6A-88C7-A05899F114AF}.Cloud|x64.Build.0 = Cloud|Any CPU {B67526E5-159B-4F6A-88C7-A05899F114AF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B67526E5-159B-4F6A-88C7-A05899F114AF}.Debug|Any CPU.Build.0 = Debug|Any CPU {B67526E5-159B-4F6A-88C7-A05899F114AF}.Debug|x86.ActiveCfg = Debug|Any CPU @@ -804,20 +700,14 @@ Global {B67526E5-159B-4F6A-88C7-A05899F114AF}.Staging|Any CPU.Build.0 = Debug|Any CPU {B67526E5-159B-4F6A-88C7-A05899F114AF}.Staging|x86.ActiveCfg = Debug|Any CPU {B67526E5-159B-4F6A-88C7-A05899F114AF}.Staging|x86.Build.0 = Debug|Any CPU - {B67526E5-159B-4F6A-88C7-A05899F114AF}.Staging|x64.ActiveCfg = Staging|Any CPU - {B67526E5-159B-4F6A-88C7-A05899F114AF}.Staging|x64.Build.0 = Staging|Any CPU {D1BA29D3-3E92-429D-9FB2-D8F062F524B9}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {D1BA29D3-3E92-429D-9FB2-D8F062F524B9}.Azure|Any CPU.Build.0 = Debug|Any CPU {D1BA29D3-3E92-429D-9FB2-D8F062F524B9}.Azure|x86.ActiveCfg = Debug|Any CPU {D1BA29D3-3E92-429D-9FB2-D8F062F524B9}.Azure|x86.Build.0 = Debug|Any CPU - {D1BA29D3-3E92-429D-9FB2-D8F062F524B9}.Azure|x64.ActiveCfg = Azure|Any CPU - {D1BA29D3-3E92-429D-9FB2-D8F062F524B9}.Azure|x64.Build.0 = Azure|Any CPU {D1BA29D3-3E92-429D-9FB2-D8F062F524B9}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {D1BA29D3-3E92-429D-9FB2-D8F062F524B9}.Cloud|Any CPU.Build.0 = Debug|Any CPU {D1BA29D3-3E92-429D-9FB2-D8F062F524B9}.Cloud|x86.ActiveCfg = Debug|Any CPU {D1BA29D3-3E92-429D-9FB2-D8F062F524B9}.Cloud|x86.Build.0 = Debug|Any CPU - {D1BA29D3-3E92-429D-9FB2-D8F062F524B9}.Cloud|x64.ActiveCfg = Cloud|Any CPU - {D1BA29D3-3E92-429D-9FB2-D8F062F524B9}.Cloud|x64.Build.0 = Cloud|Any CPU {D1BA29D3-3E92-429D-9FB2-D8F062F524B9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D1BA29D3-3E92-429D-9FB2-D8F062F524B9}.Debug|Any CPU.Build.0 = Debug|Any CPU {D1BA29D3-3E92-429D-9FB2-D8F062F524B9}.Debug|x86.ActiveCfg = Debug|Any CPU @@ -840,20 +730,14 @@ Global {D1BA29D3-3E92-429D-9FB2-D8F062F524B9}.Staging|Any CPU.Build.0 = Debug|Any CPU {D1BA29D3-3E92-429D-9FB2-D8F062F524B9}.Staging|x86.ActiveCfg = Debug|Any CPU {D1BA29D3-3E92-429D-9FB2-D8F062F524B9}.Staging|x86.Build.0 = Debug|Any CPU - {D1BA29D3-3E92-429D-9FB2-D8F062F524B9}.Staging|x64.ActiveCfg = Staging|Any CPU - {D1BA29D3-3E92-429D-9FB2-D8F062F524B9}.Staging|x64.Build.0 = Staging|Any CPU {FE67B0C5-648E-4EAE-968D-1C56F5904558}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {FE67B0C5-648E-4EAE-968D-1C56F5904558}.Azure|Any CPU.Build.0 = Debug|Any CPU {FE67B0C5-648E-4EAE-968D-1C56F5904558}.Azure|x86.ActiveCfg = Debug|Any CPU {FE67B0C5-648E-4EAE-968D-1C56F5904558}.Azure|x86.Build.0 = Debug|Any CPU - {FE67B0C5-648E-4EAE-968D-1C56F5904558}.Azure|x64.ActiveCfg = Azure|Any CPU - {FE67B0C5-648E-4EAE-968D-1C56F5904558}.Azure|x64.Build.0 = Azure|Any CPU {FE67B0C5-648E-4EAE-968D-1C56F5904558}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {FE67B0C5-648E-4EAE-968D-1C56F5904558}.Cloud|Any CPU.Build.0 = Debug|Any CPU {FE67B0C5-648E-4EAE-968D-1C56F5904558}.Cloud|x86.ActiveCfg = Debug|Any CPU {FE67B0C5-648E-4EAE-968D-1C56F5904558}.Cloud|x86.Build.0 = Debug|Any CPU - {FE67B0C5-648E-4EAE-968D-1C56F5904558}.Cloud|x64.ActiveCfg = Cloud|Any CPU - {FE67B0C5-648E-4EAE-968D-1C56F5904558}.Cloud|x64.Build.0 = Cloud|Any CPU {FE67B0C5-648E-4EAE-968D-1C56F5904558}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {FE67B0C5-648E-4EAE-968D-1C56F5904558}.Debug|Any CPU.Build.0 = Debug|Any CPU {FE67B0C5-648E-4EAE-968D-1C56F5904558}.Debug|x86.ActiveCfg = Debug|Any CPU @@ -876,20 +760,14 @@ Global {FE67B0C5-648E-4EAE-968D-1C56F5904558}.Staging|Any CPU.Build.0 = Debug|Any CPU {FE67B0C5-648E-4EAE-968D-1C56F5904558}.Staging|x86.ActiveCfg = Debug|Any CPU {FE67B0C5-648E-4EAE-968D-1C56F5904558}.Staging|x86.Build.0 = Debug|Any CPU - {FE67B0C5-648E-4EAE-968D-1C56F5904558}.Staging|x64.ActiveCfg = Staging|Any CPU - {FE67B0C5-648E-4EAE-968D-1C56F5904558}.Staging|x64.Build.0 = Staging|Any CPU {9976F2AF-ED67-485D-BDF0-C191D75578C5}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {9976F2AF-ED67-485D-BDF0-C191D75578C5}.Azure|Any CPU.Build.0 = Debug|Any CPU {9976F2AF-ED67-485D-BDF0-C191D75578C5}.Azure|x86.ActiveCfg = Debug|Any CPU {9976F2AF-ED67-485D-BDF0-C191D75578C5}.Azure|x86.Build.0 = Debug|Any CPU - {9976F2AF-ED67-485D-BDF0-C191D75578C5}.Azure|x64.ActiveCfg = Azure|Any CPU - {9976F2AF-ED67-485D-BDF0-C191D75578C5}.Azure|x64.Build.0 = Azure|Any CPU {9976F2AF-ED67-485D-BDF0-C191D75578C5}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {9976F2AF-ED67-485D-BDF0-C191D75578C5}.Cloud|Any CPU.Build.0 = Debug|Any CPU {9976F2AF-ED67-485D-BDF0-C191D75578C5}.Cloud|x86.ActiveCfg = Debug|Any CPU {9976F2AF-ED67-485D-BDF0-C191D75578C5}.Cloud|x86.Build.0 = Debug|Any CPU - {9976F2AF-ED67-485D-BDF0-C191D75578C5}.Cloud|x64.ActiveCfg = Cloud|Any CPU - {9976F2AF-ED67-485D-BDF0-C191D75578C5}.Cloud|x64.Build.0 = Cloud|Any CPU {9976F2AF-ED67-485D-BDF0-C191D75578C5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {9976F2AF-ED67-485D-BDF0-C191D75578C5}.Debug|Any CPU.Build.0 = Debug|Any CPU {9976F2AF-ED67-485D-BDF0-C191D75578C5}.Debug|x86.ActiveCfg = Debug|Any CPU @@ -912,20 +790,14 @@ Global {9976F2AF-ED67-485D-BDF0-C191D75578C5}.Staging|Any CPU.Build.0 = Debug|Any CPU {9976F2AF-ED67-485D-BDF0-C191D75578C5}.Staging|x86.ActiveCfg = Debug|Any CPU {9976F2AF-ED67-485D-BDF0-C191D75578C5}.Staging|x86.Build.0 = Debug|Any CPU - {9976F2AF-ED67-485D-BDF0-C191D75578C5}.Staging|x64.ActiveCfg = Staging|Any CPU - {9976F2AF-ED67-485D-BDF0-C191D75578C5}.Staging|x64.Build.0 = Staging|Any CPU {0195779B-62DA-4517-BF1F-8F22D11970BE}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {0195779B-62DA-4517-BF1F-8F22D11970BE}.Azure|Any CPU.Build.0 = Debug|Any CPU {0195779B-62DA-4517-BF1F-8F22D11970BE}.Azure|x86.ActiveCfg = Debug|Any CPU {0195779B-62DA-4517-BF1F-8F22D11970BE}.Azure|x86.Build.0 = Debug|Any CPU - {0195779B-62DA-4517-BF1F-8F22D11970BE}.Azure|x64.ActiveCfg = Azure|Any CPU - {0195779B-62DA-4517-BF1F-8F22D11970BE}.Azure|x64.Build.0 = Azure|Any CPU {0195779B-62DA-4517-BF1F-8F22D11970BE}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {0195779B-62DA-4517-BF1F-8F22D11970BE}.Cloud|Any CPU.Build.0 = Debug|Any CPU {0195779B-62DA-4517-BF1F-8F22D11970BE}.Cloud|x86.ActiveCfg = Debug|Any CPU {0195779B-62DA-4517-BF1F-8F22D11970BE}.Cloud|x86.Build.0 = Debug|Any CPU - {0195779B-62DA-4517-BF1F-8F22D11970BE}.Cloud|x64.ActiveCfg = Cloud|Any CPU - {0195779B-62DA-4517-BF1F-8F22D11970BE}.Cloud|x64.Build.0 = Cloud|Any CPU {0195779B-62DA-4517-BF1F-8F22D11970BE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {0195779B-62DA-4517-BF1F-8F22D11970BE}.Debug|Any CPU.Build.0 = Debug|Any CPU {0195779B-62DA-4517-BF1F-8F22D11970BE}.Debug|x86.ActiveCfg = Debug|Any CPU @@ -948,20 +820,14 @@ Global {0195779B-62DA-4517-BF1F-8F22D11970BE}.Staging|Any CPU.Build.0 = Debug|Any CPU {0195779B-62DA-4517-BF1F-8F22D11970BE}.Staging|x86.ActiveCfg = Debug|Any CPU {0195779B-62DA-4517-BF1F-8F22D11970BE}.Staging|x86.Build.0 = Debug|Any CPU - {0195779B-62DA-4517-BF1F-8F22D11970BE}.Staging|x64.ActiveCfg = Staging|Any CPU - {0195779B-62DA-4517-BF1F-8F22D11970BE}.Staging|x64.Build.0 = Staging|Any CPU {D50E79F8-2E89-49E2-A6A6-7AB34BFF235B}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {D50E79F8-2E89-49E2-A6A6-7AB34BFF235B}.Azure|Any CPU.Build.0 = Debug|Any CPU {D50E79F8-2E89-49E2-A6A6-7AB34BFF235B}.Azure|x86.ActiveCfg = Debug|Any CPU {D50E79F8-2E89-49E2-A6A6-7AB34BFF235B}.Azure|x86.Build.0 = Debug|Any CPU - {D50E79F8-2E89-49E2-A6A6-7AB34BFF235B}.Azure|x64.ActiveCfg = Azure|Any CPU - {D50E79F8-2E89-49E2-A6A6-7AB34BFF235B}.Azure|x64.Build.0 = Azure|Any CPU {D50E79F8-2E89-49E2-A6A6-7AB34BFF235B}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {D50E79F8-2E89-49E2-A6A6-7AB34BFF235B}.Cloud|Any CPU.Build.0 = Debug|Any CPU {D50E79F8-2E89-49E2-A6A6-7AB34BFF235B}.Cloud|x86.ActiveCfg = Debug|Any CPU {D50E79F8-2E89-49E2-A6A6-7AB34BFF235B}.Cloud|x86.Build.0 = Debug|Any CPU - {D50E79F8-2E89-49E2-A6A6-7AB34BFF235B}.Cloud|x64.ActiveCfg = Cloud|Any CPU - {D50E79F8-2E89-49E2-A6A6-7AB34BFF235B}.Cloud|x64.Build.0 = Cloud|Any CPU {D50E79F8-2E89-49E2-A6A6-7AB34BFF235B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D50E79F8-2E89-49E2-A6A6-7AB34BFF235B}.Debug|Any CPU.Build.0 = Debug|Any CPU {D50E79F8-2E89-49E2-A6A6-7AB34BFF235B}.Debug|x86.ActiveCfg = Debug|Any CPU @@ -984,20 +850,14 @@ Global {D50E79F8-2E89-49E2-A6A6-7AB34BFF235B}.Staging|Any CPU.Build.0 = Debug|Any CPU {D50E79F8-2E89-49E2-A6A6-7AB34BFF235B}.Staging|x86.ActiveCfg = Debug|Any CPU {D50E79F8-2E89-49E2-A6A6-7AB34BFF235B}.Staging|x86.Build.0 = Debug|Any CPU - {D50E79F8-2E89-49E2-A6A6-7AB34BFF235B}.Staging|x64.ActiveCfg = Staging|Any CPU - {D50E79F8-2E89-49E2-A6A6-7AB34BFF235B}.Staging|x64.Build.0 = Staging|Any CPU {C89A962F-75AC-4D0B-9B8A-B481F63810EC}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {C89A962F-75AC-4D0B-9B8A-B481F63810EC}.Azure|Any CPU.Build.0 = Debug|Any CPU {C89A962F-75AC-4D0B-9B8A-B481F63810EC}.Azure|x86.ActiveCfg = Debug|Any CPU {C89A962F-75AC-4D0B-9B8A-B481F63810EC}.Azure|x86.Build.0 = Debug|Any CPU - {C89A962F-75AC-4D0B-9B8A-B481F63810EC}.Azure|x64.ActiveCfg = Azure|Any CPU - {C89A962F-75AC-4D0B-9B8A-B481F63810EC}.Azure|x64.Build.0 = Azure|Any CPU {C89A962F-75AC-4D0B-9B8A-B481F63810EC}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {C89A962F-75AC-4D0B-9B8A-B481F63810EC}.Cloud|Any CPU.Build.0 = Debug|Any CPU {C89A962F-75AC-4D0B-9B8A-B481F63810EC}.Cloud|x86.ActiveCfg = Debug|Any CPU {C89A962F-75AC-4D0B-9B8A-B481F63810EC}.Cloud|x86.Build.0 = Debug|Any CPU - {C89A962F-75AC-4D0B-9B8A-B481F63810EC}.Cloud|x64.ActiveCfg = Cloud|Any CPU - {C89A962F-75AC-4D0B-9B8A-B481F63810EC}.Cloud|x64.Build.0 = Cloud|Any CPU {C89A962F-75AC-4D0B-9B8A-B481F63810EC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C89A962F-75AC-4D0B-9B8A-B481F63810EC}.Debug|Any CPU.Build.0 = Debug|Any CPU {C89A962F-75AC-4D0B-9B8A-B481F63810EC}.Debug|x86.ActiveCfg = Debug|Any CPU @@ -1020,20 +880,14 @@ Global {C89A962F-75AC-4D0B-9B8A-B481F63810EC}.Staging|Any CPU.Build.0 = Debug|Any CPU {C89A962F-75AC-4D0B-9B8A-B481F63810EC}.Staging|x86.ActiveCfg = Debug|Any CPU {C89A962F-75AC-4D0B-9B8A-B481F63810EC}.Staging|x86.Build.0 = Debug|Any CPU - {C89A962F-75AC-4D0B-9B8A-B481F63810EC}.Staging|x64.ActiveCfg = Staging|Any CPU - {C89A962F-75AC-4D0B-9B8A-B481F63810EC}.Staging|x64.Build.0 = Staging|Any CPU {477AAB02-9403-44BE-B912-3DA98660F307}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {477AAB02-9403-44BE-B912-3DA98660F307}.Azure|Any CPU.Build.0 = Debug|Any CPU {477AAB02-9403-44BE-B912-3DA98660F307}.Azure|x86.ActiveCfg = Debug|Any CPU {477AAB02-9403-44BE-B912-3DA98660F307}.Azure|x86.Build.0 = Debug|Any CPU - {477AAB02-9403-44BE-B912-3DA98660F307}.Azure|x64.ActiveCfg = Azure|Any CPU - {477AAB02-9403-44BE-B912-3DA98660F307}.Azure|x64.Build.0 = Azure|Any CPU {477AAB02-9403-44BE-B912-3DA98660F307}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {477AAB02-9403-44BE-B912-3DA98660F307}.Cloud|Any CPU.Build.0 = Debug|Any CPU {477AAB02-9403-44BE-B912-3DA98660F307}.Cloud|x86.ActiveCfg = Debug|Any CPU {477AAB02-9403-44BE-B912-3DA98660F307}.Cloud|x86.Build.0 = Debug|Any CPU - {477AAB02-9403-44BE-B912-3DA98660F307}.Cloud|x64.ActiveCfg = Cloud|Any CPU - {477AAB02-9403-44BE-B912-3DA98660F307}.Cloud|x64.Build.0 = Cloud|Any CPU {477AAB02-9403-44BE-B912-3DA98660F307}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {477AAB02-9403-44BE-B912-3DA98660F307}.Debug|Any CPU.Build.0 = Debug|Any CPU {477AAB02-9403-44BE-B912-3DA98660F307}.Debug|x86.ActiveCfg = Debug|Any CPU @@ -1056,20 +910,14 @@ Global {477AAB02-9403-44BE-B912-3DA98660F307}.Staging|Any CPU.Build.0 = Debug|Any CPU {477AAB02-9403-44BE-B912-3DA98660F307}.Staging|x86.ActiveCfg = Debug|Any CPU {477AAB02-9403-44BE-B912-3DA98660F307}.Staging|x86.Build.0 = Debug|Any CPU - {477AAB02-9403-44BE-B912-3DA98660F307}.Staging|x64.ActiveCfg = Staging|Any CPU - {477AAB02-9403-44BE-B912-3DA98660F307}.Staging|x64.Build.0 = Staging|Any CPU {907CA744-0D45-4928-AC26-5724BA6A38EF}.Azure|Any CPU.ActiveCfg = Release|Any CPU {907CA744-0D45-4928-AC26-5724BA6A38EF}.Azure|Any CPU.Build.0 = Release|Any CPU {907CA744-0D45-4928-AC26-5724BA6A38EF}.Azure|x86.ActiveCfg = Release|Any CPU {907CA744-0D45-4928-AC26-5724BA6A38EF}.Azure|x86.Build.0 = Release|Any CPU - {907CA744-0D45-4928-AC26-5724BA6A38EF}.Azure|x64.ActiveCfg = Azure|Any CPU - {907CA744-0D45-4928-AC26-5724BA6A38EF}.Azure|x64.Build.0 = Azure|Any CPU {907CA744-0D45-4928-AC26-5724BA6A38EF}.Cloud|Any CPU.ActiveCfg = Release|Any CPU {907CA744-0D45-4928-AC26-5724BA6A38EF}.Cloud|Any CPU.Build.0 = Release|Any CPU {907CA744-0D45-4928-AC26-5724BA6A38EF}.Cloud|x86.ActiveCfg = Release|Any CPU {907CA744-0D45-4928-AC26-5724BA6A38EF}.Cloud|x86.Build.0 = Release|Any CPU - {907CA744-0D45-4928-AC26-5724BA6A38EF}.Cloud|x64.ActiveCfg = Cloud|Any CPU - {907CA744-0D45-4928-AC26-5724BA6A38EF}.Cloud|x64.Build.0 = Cloud|Any CPU {907CA744-0D45-4928-AC26-5724BA6A38EF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {907CA744-0D45-4928-AC26-5724BA6A38EF}.Debug|Any CPU.Build.0 = Debug|Any CPU {907CA744-0D45-4928-AC26-5724BA6A38EF}.Debug|x86.ActiveCfg = Debug|Any CPU @@ -1092,20 +940,14 @@ Global {907CA744-0D45-4928-AC26-5724BA6A38EF}.Staging|Any CPU.Build.0 = Release|Any CPU {907CA744-0D45-4928-AC26-5724BA6A38EF}.Staging|x86.ActiveCfg = Debug|Any CPU {907CA744-0D45-4928-AC26-5724BA6A38EF}.Staging|x86.Build.0 = Debug|Any CPU - {907CA744-0D45-4928-AC26-5724BA6A38EF}.Staging|x64.ActiveCfg = Staging|Any CPU - {907CA744-0D45-4928-AC26-5724BA6A38EF}.Staging|x64.Build.0 = Staging|Any CPU {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Azure|Any CPU.Build.0 = Debug|Any CPU {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Azure|x86.ActiveCfg = Debug|Any CPU {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Azure|x86.Build.0 = Debug|Any CPU - {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Azure|x64.ActiveCfg = Azure|Any CPU - {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Azure|x64.Build.0 = Azure|Any CPU {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Cloud|Any CPU.Build.0 = Debug|Any CPU {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Cloud|x86.ActiveCfg = Debug|Any CPU {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Cloud|x86.Build.0 = Debug|Any CPU - {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Cloud|x64.ActiveCfg = Cloud|Any CPU - {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Cloud|x64.Build.0 = Cloud|Any CPU {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Debug|Any CPU.Build.0 = Debug|Any CPU {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Debug|x86.ActiveCfg = Debug|Any CPU @@ -1128,20 +970,14 @@ Global {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Staging|Any CPU.Build.0 = Debug|Any CPU {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Staging|x86.ActiveCfg = Debug|Any CPU {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Staging|x86.Build.0 = Debug|Any CPU - {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Staging|x64.ActiveCfg = Staging|Any CPU - {A8B9C1D2-E3F4-5678-9ABC-DEF012345678}.Staging|x64.Build.0 = Staging|Any CPU {FFCBA7D4-853A-4D25-935B-F242851752EE}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {FFCBA7D4-853A-4D25-935B-F242851752EE}.Azure|Any CPU.Build.0 = Debug|Any CPU {FFCBA7D4-853A-4D25-935B-F242851752EE}.Azure|x86.ActiveCfg = Debug|Any CPU {FFCBA7D4-853A-4D25-935B-F242851752EE}.Azure|x86.Build.0 = Debug|Any CPU - {FFCBA7D4-853A-4D25-935B-F242851752EE}.Azure|x64.ActiveCfg = Azure|Any CPU - {FFCBA7D4-853A-4D25-935B-F242851752EE}.Azure|x64.Build.0 = Azure|Any CPU {FFCBA7D4-853A-4D25-935B-F242851752EE}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {FFCBA7D4-853A-4D25-935B-F242851752EE}.Cloud|Any CPU.Build.0 = Debug|Any CPU {FFCBA7D4-853A-4D25-935B-F242851752EE}.Cloud|x86.ActiveCfg = Debug|Any CPU {FFCBA7D4-853A-4D25-935B-F242851752EE}.Cloud|x86.Build.0 = Debug|Any CPU - {FFCBA7D4-853A-4D25-935B-F242851752EE}.Cloud|x64.ActiveCfg = Cloud|Any CPU - {FFCBA7D4-853A-4D25-935B-F242851752EE}.Cloud|x64.Build.0 = Cloud|Any CPU {FFCBA7D4-853A-4D25-935B-F242851752EE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {FFCBA7D4-853A-4D25-935B-F242851752EE}.Debug|Any CPU.Build.0 = Debug|Any CPU {FFCBA7D4-853A-4D25-935B-F242851752EE}.Debug|x86.ActiveCfg = Debug|Any CPU @@ -1164,20 +1000,14 @@ Global {FFCBA7D4-853A-4D25-935B-F242851752EE}.Staging|Any CPU.Build.0 = Debug|Any CPU {FFCBA7D4-853A-4D25-935B-F242851752EE}.Staging|x86.ActiveCfg = Debug|Any CPU {FFCBA7D4-853A-4D25-935B-F242851752EE}.Staging|x86.Build.0 = Debug|Any CPU - {FFCBA7D4-853A-4D25-935B-F242851752EE}.Staging|x64.ActiveCfg = Staging|Any CPU - {FFCBA7D4-853A-4D25-935B-F242851752EE}.Staging|x64.Build.0 = Staging|Any CPU {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Azure|Any CPU.Build.0 = Debug|Any CPU {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Azure|x86.ActiveCfg = Debug|Any CPU {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Azure|x86.Build.0 = Debug|Any CPU - {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Azure|x64.ActiveCfg = Azure|Any CPU - {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Azure|x64.Build.0 = Azure|Any CPU {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Cloud|Any CPU.Build.0 = Debug|Any CPU {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Cloud|x86.ActiveCfg = Debug|Any CPU {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Cloud|x86.Build.0 = Debug|Any CPU - {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Cloud|x64.ActiveCfg = Cloud|Any CPU - {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Cloud|x64.Build.0 = Cloud|Any CPU {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Debug|Any CPU.Build.0 = Debug|Any CPU {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Debug|x86.ActiveCfg = Debug|Any CPU @@ -1188,8 +1018,6 @@ Global {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Docker|Any CPU.Build.0 = Debug|Any CPU {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Docker|x86.ActiveCfg = Debug|Any CPU {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Docker|x86.Build.0 = Debug|Any CPU - {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Docker|x64.ActiveCfg = Docker|Any CPU - {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Docker|x64.Build.0 = Docker|Any CPU {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Release|Any CPU.ActiveCfg = Release|Any CPU {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Release|Any CPU.Build.0 = Release|Any CPU {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Release|x86.ActiveCfg = Release|Any CPU @@ -1200,20 +1028,14 @@ Global {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Staging|Any CPU.Build.0 = Debug|Any CPU {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Staging|x86.ActiveCfg = Debug|Any CPU {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Staging|x86.Build.0 = Debug|Any CPU - {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Staging|x64.ActiveCfg = Staging|Any CPU - {086A3F5A-F7D3-4E38-B9CD-B422DD3A709E}.Staging|x64.Build.0 = Staging|Any CPU {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Azure|Any CPU.Build.0 = Debug|Any CPU {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Azure|x86.ActiveCfg = Debug|Any CPU {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Azure|x86.Build.0 = Debug|Any CPU - {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Azure|x64.ActiveCfg = Azure|Any CPU - {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Azure|x64.Build.0 = Azure|Any CPU {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Cloud|Any CPU.Build.0 = Debug|Any CPU {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Cloud|x86.ActiveCfg = Debug|Any CPU {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Cloud|x86.Build.0 = Debug|Any CPU - {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Cloud|x64.ActiveCfg = Cloud|Any CPU - {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Cloud|x64.Build.0 = Cloud|Any CPU {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Debug|Any CPU.Build.0 = Debug|Any CPU {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Debug|x86.ActiveCfg = Debug|Any CPU @@ -1224,8 +1046,6 @@ Global {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Docker|Any CPU.Build.0 = Debug|Any CPU {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Docker|x86.ActiveCfg = Debug|Any CPU {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Docker|x86.Build.0 = Debug|Any CPU - {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Docker|x64.ActiveCfg = Docker|Any CPU - {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Docker|x64.Build.0 = Docker|Any CPU {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Release|Any CPU.ActiveCfg = Release|Any CPU {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Release|Any CPU.Build.0 = Release|Any CPU {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Release|x86.ActiveCfg = Release|Any CPU @@ -1236,20 +1056,14 @@ Global {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Staging|Any CPU.Build.0 = Debug|Any CPU {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Staging|x86.ActiveCfg = Debug|Any CPU {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Staging|x86.Build.0 = Debug|Any CPU - {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Staging|x64.ActiveCfg = Staging|Any CPU - {E39D63BA-5CCA-43EC-A3F9-375FA87EFE2F}.Staging|x64.Build.0 = Staging|Any CPU {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Azure|Any CPU.Build.0 = Debug|Any CPU {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Azure|x86.ActiveCfg = Debug|Any CPU {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Azure|x86.Build.0 = Debug|Any CPU - {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Azure|x64.ActiveCfg = Azure|Any CPU - {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Azure|x64.Build.0 = Azure|Any CPU {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Cloud|Any CPU.Build.0 = Debug|Any CPU {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Cloud|x86.ActiveCfg = Debug|Any CPU {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Cloud|x86.Build.0 = Debug|Any CPU - {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Cloud|x64.ActiveCfg = Cloud|Any CPU - {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Cloud|x64.Build.0 = Cloud|Any CPU {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Debug|Any CPU.Build.0 = Debug|Any CPU {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Debug|x86.ActiveCfg = Debug|Any CPU @@ -1260,8 +1074,6 @@ Global {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Docker|Any CPU.Build.0 = Debug|Any CPU {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Docker|x86.ActiveCfg = Debug|Any CPU {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Docker|x86.Build.0 = Debug|Any CPU - {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Docker|x64.ActiveCfg = Docker|Any CPU - {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Docker|x64.Build.0 = Docker|Any CPU {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Release|Any CPU.ActiveCfg = Release|Any CPU {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Release|Any CPU.Build.0 = Release|Any CPU {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Release|x86.ActiveCfg = Release|Any CPU @@ -1272,20 +1084,14 @@ Global {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Staging|Any CPU.Build.0 = Debug|Any CPU {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Staging|x86.ActiveCfg = Debug|Any CPU {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Staging|x86.Build.0 = Debug|Any CPU - {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Staging|x64.ActiveCfg = Staging|Any CPU - {B4D75669-B70B-4B75-8AB9-0B64D0176E2B}.Staging|x64.Build.0 = Staging|Any CPU {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Azure|Any CPU.Build.0 = Debug|Any CPU {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Azure|x86.ActiveCfg = Debug|Any CPU {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Azure|x86.Build.0 = Debug|Any CPU - {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Azure|x64.ActiveCfg = Azure|Any CPU - {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Azure|x64.Build.0 = Azure|Any CPU {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Cloud|Any CPU.Build.0 = Debug|Any CPU {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Cloud|x86.ActiveCfg = Debug|Any CPU {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Cloud|x86.Build.0 = Debug|Any CPU - {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Cloud|x64.ActiveCfg = Cloud|Any CPU - {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Cloud|x64.Build.0 = Cloud|Any CPU {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Debug|Any CPU.Build.0 = Debug|Any CPU {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Debug|x86.ActiveCfg = Debug|Any CPU @@ -1296,8 +1102,6 @@ Global {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Docker|Any CPU.Build.0 = Debug|Any CPU {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Docker|x86.ActiveCfg = Debug|Any CPU {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Docker|x86.Build.0 = Debug|Any CPU - {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Docker|x64.ActiveCfg = Docker|Any CPU - {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Docker|x64.Build.0 = Docker|Any CPU {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Release|Any CPU.ActiveCfg = Release|Any CPU {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Release|Any CPU.Build.0 = Release|Any CPU {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Release|x86.ActiveCfg = Release|Any CPU @@ -1308,20 +1112,14 @@ Global {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Staging|Any CPU.Build.0 = Debug|Any CPU {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Staging|x86.ActiveCfg = Debug|Any CPU {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Staging|x86.Build.0 = Debug|Any CPU - {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Staging|x64.ActiveCfg = Staging|Any CPU - {1345A104-2F6F-433A-BD8C-B2676C5D8473}.Staging|x64.Build.0 = Staging|Any CPU {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Azure|Any CPU.Build.0 = Debug|Any CPU {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Azure|x86.ActiveCfg = Debug|Any CPU {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Azure|x86.Build.0 = Debug|Any CPU - {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Azure|x64.ActiveCfg = Azure|Any CPU - {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Azure|x64.Build.0 = Azure|Any CPU {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Cloud|Any CPU.Build.0 = Debug|Any CPU {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Cloud|x86.ActiveCfg = Debug|Any CPU {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Cloud|x86.Build.0 = Debug|Any CPU - {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Cloud|x64.ActiveCfg = Cloud|Any CPU - {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Cloud|x64.Build.0 = Cloud|Any CPU {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Debug|Any CPU.Build.0 = Debug|Any CPU {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Debug|x86.ActiveCfg = Debug|Any CPU @@ -1332,8 +1130,6 @@ Global {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Docker|Any CPU.Build.0 = Debug|Any CPU {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Docker|x86.ActiveCfg = Debug|Any CPU {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Docker|x86.Build.0 = Debug|Any CPU - {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Docker|x64.ActiveCfg = Docker|Any CPU - {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Docker|x64.Build.0 = Docker|Any CPU {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Release|Any CPU.ActiveCfg = Release|Any CPU {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Release|Any CPU.Build.0 = Release|Any CPU {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Release|x86.ActiveCfg = Release|Any CPU @@ -1344,8 +1140,6 @@ Global {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Staging|Any CPU.Build.0 = Debug|Any CPU {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Staging|x86.ActiveCfg = Debug|Any CPU {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Staging|x86.Build.0 = Debug|Any CPU - {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Staging|x64.ActiveCfg = Staging|Any CPU - {744B3BB7-B5F6-4002-93E2-FC0821D41963}.Staging|x64.Build.0 = Staging|Any CPU {FA7DB8BC-315A-42D2-8BE7-859A9EBF19CA}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {FA7DB8BC-315A-42D2-8BE7-859A9EBF19CA}.Azure|Any CPU.Build.0 = Debug|Any CPU {FA7DB8BC-315A-42D2-8BE7-859A9EBF19CA}.Azure|x86.ActiveCfg = Debug|Any CPU diff --git a/Tests/Resgrid.Tests/Models/NewCallFieldPolicyTests.cs b/Tests/Resgrid.Tests/Models/NewCallFieldPolicyTests.cs index 9f84e8eb3..098bfa19b 100644 --- a/Tests/Resgrid.Tests/Models/NewCallFieldPolicyTests.cs +++ b/Tests/Resgrid.Tests/Models/NewCallFieldPolicyTests.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using FluentAssertions; using NUnit.Framework; +using Resgrid.Framework; using Resgrid.Model; using Resgrid.Model.Helpers; @@ -86,6 +87,45 @@ public void Normalize_keeps_the_last_rule_when_a_key_is_duplicated() policy.Rules[0].Required.Should().BeTrue(); policy.Rules[0].Visible.Should().BeTrue(); } + + [Test] + public void A_hidden_field_survives_the_stored_round_trip() + { + // The policy is stored protobuf-serialized. Visible defaults to true, so an unmarked false is + // the wire's implicit zero: it is never written, and reading it back leaves the initializer's + // true in place -- Normalize then drops the rule and every hidden field comes back visible. + var policy = PolicyWith( + new NewCallFieldRule { Key = NewCallFieldKeys.Address, Visible = false }, + new NewCallFieldRule { Key = NewCallFieldKeys.Note, Visible = false, Required = true }, + new NewCallFieldRule { Key = NewCallFieldKeys.IncidentId, Visible = true, Required = true }).Normalize(); + + var stored = ObjectSerialization.Serialize(policy); + var restored = ObjectSerialization.Deserialize(stored).Normalize(); + + restored.Rules.Should().HaveCount(3); + restored.IsVisible(NewCallFieldKeys.Address).Should().BeFalse(); + restored.IsVisible(NewCallFieldKeys.Note).Should().BeFalse(); + restored.IsRequired(NewCallFieldKeys.Note).Should().BeFalse(); + restored.IsVisible(NewCallFieldKeys.IncidentId).Should().BeTrue(); + restored.IsRequired(NewCallFieldKeys.IncidentId).Should().BeTrue(); + restored.IsVisible(NewCallFieldKeys.DispatchList).Should().BeTrue(); + } + + [Test] + public void A_policy_stored_before_visibility_was_written_still_reads_as_visible() + { + // Blobs already in the database never carry field 2 for a visible rule; they must keep + // reading as visible after the contract change. + var legacy = new NewCallFieldPolicy + { + Rules = new List { new NewCallFieldRule { Key = NewCallFieldKeys.Address, Visible = true, Required = true } } + }; + + var restored = ObjectSerialization.Deserialize(ObjectSerialization.Serialize(legacy)); + + restored.IsVisible(NewCallFieldKeys.Address).Should().BeTrue(); + restored.IsRequired(NewCallFieldKeys.Address).Should().BeTrue(); + } } [TestFixture] diff --git a/Tests/Resgrid.Tests/Models/UnitStatusAlertEvaluatorTests.cs b/Tests/Resgrid.Tests/Models/UnitStatusAlertEvaluatorTests.cs new file mode 100644 index 000000000..2895b44f4 --- /dev/null +++ b/Tests/Resgrid.Tests/Models/UnitStatusAlertEvaluatorTests.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Generic; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Model; + +namespace Resgrid.Tests.Models +{ + /// + /// The server's copy of the Big Board's "is this unit overdue, and does an acknowledgement still cover it" + /// rules. The cases that matter to dispatchers: a new status ends the acknowledgement, escalation brings the + /// alert back, and a mute that runs out falls back to acknowledged rather than vanishing. + /// + [TestFixture] + public class UnitStatusAlertEvaluatorTests + { + private static readonly DateTime Now = new DateTime(2026, 10, 2, 12, 0, 0, DateTimeKind.Utc); + + private static UnitStatusThresholds Dispatched(int warnSeconds, int alertSeconds) => new UnitStatusThresholds + { + Thresholds = new List + { + new UnitStatusThreshold { BaseType = (int)ActionBaseTypes.Dispatched, WarnSeconds = warnSeconds, AlertSeconds = alertSeconds } + } + }; + + private static UnitStatusAlertAcknowledgement Ack(int unitStateId = 77, UnitStatusAlertLevels level = UnitStatusAlertLevels.Warn, + UnitStatusAlertAcknowledgementModes mode = UnitStatusAlertAcknowledgementModes.Acknowledged, DateTime? mutedUntil = null) => + new UnitStatusAlertAcknowledgement { UnitStateId = unitStateId, Level = (int)level, Mode = (int)mode, MutedUntil = mutedUntil }; + + [Test] + public void Nothing_is_overdue_without_thresholds() + { + UnitStatusAlertEvaluator.Evaluate(new UnitStatusThresholds(), (int)ActionBaseTypes.Dispatched, Now.AddHours(-3), Now) + .Should().Be(UnitStatusAlertLevels.None); + } + + [Test] + public void Nothing_is_overdue_when_the_status_has_no_base_type() + { + UnitStatusAlertEvaluator.Evaluate(Dispatched(240, 480), null, Now.AddHours(-3), Now).Should().Be(UnitStatusAlertLevels.None); + } + + [Test] + public void A_status_without_a_threshold_is_never_overdue() + { + UnitStatusAlertEvaluator.Evaluate(Dispatched(240, 480), (int)ActionBaseTypes.OnScene, Now.AddHours(-3), Now).Should().Be(UnitStatusAlertLevels.None); + } + + [TestCase(239, UnitStatusAlertLevels.None)] + [TestCase(240, UnitStatusAlertLevels.Warn)] + [TestCase(479, UnitStatusAlertLevels.Warn)] + [TestCase(480, UnitStatusAlertLevels.Alert)] + [TestCase(4000, UnitStatusAlertLevels.Alert)] + public void Levels_follow_the_thresholds(int secondsInStatus, UnitStatusAlertLevels expected) + { + UnitStatusAlertEvaluator.Evaluate(Dispatched(240, 480), (int)ActionBaseTypes.Dispatched, Now.AddSeconds(-secondsInStatus), Now) + .Should().Be(expected); + } + + [Test] + public void An_alert_only_threshold_skips_the_warning() + { + UnitStatusAlertEvaluator.Evaluate(Dispatched(0, 300), (int)ActionBaseTypes.Dispatched, Now.AddSeconds(-299), Now).Should().Be(UnitStatusAlertLevels.None); + UnitStatusAlertEvaluator.Evaluate(Dispatched(0, 300), (int)ActionBaseTypes.Dispatched, Now.AddSeconds(-300), Now).Should().Be(UnitStatusAlertLevels.Alert); + } + + [Test] + public void A_status_timestamped_in_the_future_is_not_overdue() + { + UnitStatusAlertEvaluator.Evaluate(Dispatched(240, 480), (int)ActionBaseTypes.Dispatched, Now.AddMinutes(5), Now).Should().Be(UnitStatusAlertLevels.None); + } + + [Test] + public void An_acknowledgement_covers_its_own_episode() + { + UnitStatusAlertEvaluator.Resolve(Ack(), 77, UnitStatusAlertLevels.Warn, Now).Should().Be(UnitStatusAlertAcknowledgementModes.Acknowledged); + } + + [Test] + public void A_new_status_ends_the_acknowledgement() + { + UnitStatusAlertEvaluator.Resolve(Ack(unitStateId: 77), 78, UnitStatusAlertLevels.Warn, Now).Should().BeNull(); + } + + [Test] + public void Escalating_past_the_acknowledged_level_brings_the_alert_back() + { + UnitStatusAlertEvaluator.Resolve(Ack(level: UnitStatusAlertLevels.Warn), 77, UnitStatusAlertLevels.Alert, Now).Should().BeNull(); + } + + [Test] + public void An_alert_level_acknowledgement_covers_the_alert() + { + UnitStatusAlertEvaluator.Resolve(Ack(level: UnitStatusAlertLevels.Alert), 77, UnitStatusAlertLevels.Alert, Now) + .Should().Be(UnitStatusAlertAcknowledgementModes.Acknowledged); + } + + [Test] + public void A_mute_without_an_end_lasts_for_the_episode() + { + UnitStatusAlertEvaluator.Resolve(Ack(mode: UnitStatusAlertAcknowledgementModes.Muted), 77, UnitStatusAlertLevels.Warn, Now) + .Should().Be(UnitStatusAlertAcknowledgementModes.Muted); + } + + [Test] + public void A_running_mute_is_muted() + { + UnitStatusAlertEvaluator.Resolve(Ack(mode: UnitStatusAlertAcknowledgementModes.Muted, mutedUntil: Now.AddMinutes(1)), 77, UnitStatusAlertLevels.Warn, Now) + .Should().Be(UnitStatusAlertAcknowledgementModes.Muted); + } + + [Test] + public void An_expired_mute_falls_back_to_acknowledged() + { + UnitStatusAlertEvaluator.Resolve(Ack(mode: UnitStatusAlertAcknowledgementModes.Muted, mutedUntil: Now), 77, UnitStatusAlertLevels.Warn, Now) + .Should().Be(UnitStatusAlertAcknowledgementModes.Acknowledged); + } + + [Test] + public void A_mute_does_not_survive_escalation() + { + UnitStatusAlertEvaluator.Resolve(Ack(mode: UnitStatusAlertAcknowledgementModes.Muted), 77, UnitStatusAlertLevels.Alert, Now).Should().BeNull(); + } + + [Test] + public void A_cleared_acknowledgement_covers_nothing() + { + var cleared = Ack(); + cleared.ClearedOn = Now.AddMinutes(-1); + + UnitStatusAlertEvaluator.Resolve(cleared, 77, UnitStatusAlertLevels.Warn, Now).Should().BeNull(); + } + + [Test] + public void A_unit_with_no_status_record_matches_no_acknowledgement() + { + UnitStatusAlertEvaluator.Resolve(Ack(unitStateId: 0), 0, UnitStatusAlertLevels.Warn, Now).Should().BeNull(); + } + } +} diff --git a/Tests/Resgrid.Tests/Repositories/UnitStatusAlertAcknowledgementsDatabaseTests.cs b/Tests/Resgrid.Tests/Repositories/UnitStatusAlertAcknowledgementsDatabaseTests.cs new file mode 100644 index 000000000..1aca7630f --- /dev/null +++ b/Tests/Resgrid.Tests/Repositories/UnitStatusAlertAcknowledgementsDatabaseTests.cs @@ -0,0 +1,252 @@ +using System; +using System.Collections.Concurrent; +using System.Data.Common; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Dapper; +using FluentAssertions; +using FluentMigrator; +using FluentMigrator.Runner; +using FluentMigrator.Runner.Initialization; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Npgsql; +using NUnit.Framework; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Providers.Migrations.Migrations; +using Resgrid.Providers.MigrationsPg.Migrations; +using Resgrid.Repositories.DataRepository; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Queries; +using Resgrid.Repositories.DataRepository.Queries.Common; +using Resgrid.Repositories.DataRepository.Servers.SqlServer; + +namespace Resgrid.Tests.Repositories +{ + /// + /// Real-database proof for M0258 on both engines: an acknowledgement round-trips through the repository, the IN / ANY + /// list query finds it, only one live acknowledgement per status episode is allowed, the unit must belong to the + /// department, and deleting the status record takes its acknowledgement with it. Set + /// RESGRID_ADP_SQLSERVER_TEST_CONNECTION / RESGRID_ADP_POSTGRES_TEST_CONNECTION (server-level connections) to run. + /// + [TestFixture(DatabaseTypes.SqlServer), TestFixture(DatabaseTypes.Postgres), NonParallelizable] + public class UnitStatusAlertAcknowledgementsDatabaseTests(DatabaseTypes type) + { + private const string Prefix = "unit_alert_ack_"; + private const int DepartmentId = 42; + private DatabaseTypes _previous; + private string _master, _connection, _database; + private ServiceProvider _runner; + + // Whole seconds: SQL Server rounds sub-second values, and the assertions compare stored instants. + private static DateTime Now + { + get + { + var now = DateTime.UtcNow; + return now.AddTicks(-(now.Ticks % TimeSpan.TicksPerSecond)); + } + } + + private bool IsPostgres => type == DatabaseTypes.Postgres; + + private DbConnection Connect(string connection) => IsPostgres ? new NpgsqlConnection(connection) : new SqlConnection(connection); + + private SqlConfiguration Configuration() => IsPostgres ? new PostgreSqlConfiguration() : new SqlServerConfiguration(); + + private UnitStatusAlertAcknowledgementsRepository Repository() + { + var connections = new Mock(); + connections.Setup(c => c.Create()).Returns(() => Connect(_connection)); + + var configuration = Configuration(); + var queries = new ConcurrentDictionary(); + queries[typeof(InsertQuery)] = new InsertQuery(configuration); + queries[typeof(UpdateQuery)] = new UpdateQuery(configuration); + var list = new Mock(); + list.Setup(l => l.RetrieveQueryList()).Returns(queries); + + return new UnitStatusAlertAcknowledgementsRepository(connections.Object, configuration, Mock.Of(), new QueryFactory(list.Object)); + } + + [OneTimeSetUp] + public async Task Create_isolated_database() + { + _master = Environment.GetEnvironmentVariable(IsPostgres ? "RESGRID_ADP_POSTGRES_TEST_CONNECTION" : "RESGRID_ADP_SQLSERVER_TEST_CONNECTION"); + if (string.IsNullOrWhiteSpace(_master)) Assert.Ignore("Set a test connection to run real database checks."); + + // The API turns this on for PostgreSQL (Web.Services Startup); repositories write UTC DateTimes. + if (IsPostgres) AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true); + + _previous = DataConfig.DatabaseType; + DataConfig.DatabaseType = type; + _database = Prefix + Guid.NewGuid().ToString("N"); + await using (var master = Connect(_master)) + await master.ExecuteAsync("CREATE DATABASE " + _database); + _connection = IsPostgres + ? new NpgsqlConnectionStringBuilder(_master) { Database = _database }.ConnectionString + : new SqlConnectionStringBuilder(_master) { InitialCatalog = _database }.ConnectionString; + + // Minimal stand-ins for M0001's Units / UnitStates, with the (DepartmentId, UnitId) key M0101 adds. + await using (var database = Connect(_connection)) + { + await database.OpenAsync(); + await database.ExecuteAsync(IsPostgres + ? @"CREATE EXTENSION IF NOT EXISTS citext; + CREATE TABLE units (unitid serial PRIMARY KEY, departmentid int NOT NULL, CONSTRAINT uq_units_departmentid_unitid UNIQUE (departmentid, unitid)); + CREATE TABLE unitstates (unitstateid serial PRIMARY KEY, unitid int NOT NULL);" + : @"CREATE TABLE Units (UnitId int IDENTITY PRIMARY KEY, DepartmentId int NOT NULL, CONSTRAINT UQ_Units_DepartmentId_UnitId UNIQUE (DepartmentId, UnitId)); + CREATE TABLE UnitStates (UnitStateId int IDENTITY PRIMARY KEY, UnitId int NOT NULL);"); + + // Npgsql loaded this database's types on the first open, before citext existed; a real database has + // the extension before the application ever connects. + if (database is NpgsqlConnection postgres) + postgres.ReloadTypes(); + } + + var source = new Mock(); + source.Setup(s => s.GetMigrations()).Returns(IsPostgres + ? new IMigration[] { new M0258_AddUnitStatusAlertAcknowledgementsPg() } + : new IMigration[] { new M0258_AddUnitStatusAlertAcknowledgements() }); + _runner = new ServiceCollection().AddFluentMigratorCore().ConfigureRunner(r => + { + if (IsPostgres) r.AddPostgres(); else r.AddSqlServer(); + r.WithGlobalConnectionString(_connection); + }).AddSingleton(source.Object).BuildServiceProvider(); + _runner.GetRequiredService().MigrateUp(); + } + + [OneTimeTearDown] + public async Task Remove_only_this_fixture_database() + { + if (_database == null) return; + _runner?.Dispose(); + DataConfig.DatabaseType = _previous; + if (!_database.StartsWith(Prefix, StringComparison.Ordinal) || !Guid.TryParseExact(_database.Substring(Prefix.Length), "N", out _)) + throw new InvalidOperationException("Unexpected test database name."); + if (IsPostgres) NpgsqlConnection.ClearAllPools(); else SqlConnection.ClearAllPools(); + await using var master = Connect(_master); + await master.ExecuteAsync(IsPostgres ? "DROP DATABASE " + _database + " WITH (FORCE)" + : "ALTER DATABASE " + _database + " SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE " + _database); + } + + /// Adds a unit in the department and one status record for it; returns (unitId, unitStateId). + private async Task<(int UnitId, int UnitStateId)> SeedUnitAndStateAsync(int departmentId = DepartmentId) + { + await using var database = Connect(_connection); + var unitId = await database.ExecuteScalarAsync(IsPostgres + ? "INSERT INTO units (departmentid) VALUES (@DepartmentId) RETURNING unitid" + : "INSERT INTO Units (DepartmentId) OUTPUT INSERTED.UnitId VALUES (@DepartmentId)", new { DepartmentId = departmentId }); + var unitStateId = await database.ExecuteScalarAsync(IsPostgres + ? "INSERT INTO unitstates (unitid) VALUES (@UnitId) RETURNING unitstateid" + : "INSERT INTO UnitStates (UnitId) OUTPUT INSERTED.UnitStateId VALUES (@UnitId)", new { UnitId = unitId }); + return (unitId, unitStateId); + } + + private static UnitStatusAlertAcknowledgement NewAcknowledgement(int unitId, int unitStateId, int departmentId = DepartmentId) => new() + { + UnitStatusAlertAcknowledgementId = Guid.NewGuid().ToString(), + DepartmentId = departmentId, + UnitId = unitId, + UnitStateId = unitStateId, + Level = (int)UnitStatusAlertLevels.Warn, + Mode = (int)UnitStatusAlertAcknowledgementModes.Muted, + MutedUntil = Now.AddMinutes(15), + Note = "MUG not departed, technical malfunction reported.", + AcknowledgedByUserId = "dispatcher-1", + AcknowledgedOn = Now + }; + + [Test] + public async Task An_acknowledgement_round_trips_and_is_found_by_its_status_record() + { + var (unitId, unitStateId) = await SeedUnitAndStateAsync(); + var (_, otherStateId) = await SeedUnitAndStateAsync(); + var acknowledgement = NewAcknowledgement(unitId, unitStateId); + + await Repository().InsertAsync(acknowledgement, CancellationToken.None); + + var byList = (await Repository().GetActiveForUnitStatesAsync(DepartmentId, new[] { unitStateId, otherStateId })).ToList(); + byList.Should().ContainSingle(); + var stored = byList.Single(); + stored.UnitStatusAlertAcknowledgementId.Should().Be(acknowledgement.UnitStatusAlertAcknowledgementId); + stored.UnitId.Should().Be(unitId); + stored.Level.Should().Be((int)UnitStatusAlertLevels.Warn); + stored.Mode.Should().Be((int)UnitStatusAlertAcknowledgementModes.Muted); + stored.MutedUntil.Should().Be(acknowledgement.MutedUntil); + stored.AcknowledgedOn.Should().Be(acknowledgement.AcknowledgedOn); + stored.Note.Should().Be(acknowledgement.Note); + stored.AcknowledgedByUserId.Should().Be("dispatcher-1"); + stored.ClearedOn.Should().BeNull(); + + (await Repository().GetActiveForUnitStateAsync(DepartmentId, unitId, unitStateId)).Should().ContainSingle(); + (await Repository().GetActiveForUnitStatesAsync(DepartmentId + 1, new[] { unitStateId })).Should().BeEmpty("another department never reads it"); + (await Repository().GetActiveForUnitStatesAsync(DepartmentId, Array.Empty())).Should().BeEmpty(); + } + + [Test] + public async Task A_cleared_acknowledgement_is_no_longer_active() + { + var (unitId, unitStateId) = await SeedUnitAndStateAsync(); + var acknowledgement = NewAcknowledgement(unitId, unitStateId); + await Repository().InsertAsync(acknowledgement, CancellationToken.None); + + acknowledgement.ClearedOn = Now; + acknowledgement.ClearedByUserId = "dispatcher-2"; + await Repository().UpdateAsync(acknowledgement, CancellationToken.None); + + (await Repository().GetActiveForUnitStateAsync(DepartmentId, unitId, unitStateId)).Should().BeEmpty(); + (await Repository().GetActiveForUnitStatesAsync(DepartmentId, new[] { unitStateId })).Should().BeEmpty(); + } + + [Test] + public async Task Only_one_live_acknowledgement_per_status_episode() + { + var (unitId, unitStateId) = await SeedUnitAndStateAsync(); + var first = NewAcknowledgement(unitId, unitStateId); + await Repository().InsertAsync(first, CancellationToken.None); + + Func second = () => Repository().InsertAsync(NewAcknowledgement(unitId, unitStateId), CancellationToken.None); + await second.Should().ThrowAsync("the filtered unique index admits one uncleared row per UnitStateId"); + + // Once the first is cleared, a replacement is accepted: the history row stays. + first.ClearedOn = Now; + first.ClearedByUserId = "dispatcher-1"; + await Repository().UpdateAsync(first, CancellationToken.None); + await Repository().InsertAsync(NewAcknowledgement(unitId, unitStateId), CancellationToken.None); + + (await Repository().GetActiveForUnitStateAsync(DepartmentId, unitId, unitStateId)).Should().ContainSingle(); + } + + [Test] + public async Task The_unit_must_belong_to_the_department() + { + var (unitId, unitStateId) = await SeedUnitAndStateAsync(); + + Func act = () => Repository().InsertAsync(NewAcknowledgement(unitId, unitStateId, DepartmentId + 1), CancellationToken.None); + + await act.Should().ThrowAsync("the (DepartmentId, UnitId) foreign key rejects a unit from another department"); + } + + [Test] + public async Task Deleting_the_status_record_removes_its_acknowledgements() + { + var (unitId, unitStateId) = await SeedUnitAndStateAsync(); + await Repository().InsertAsync(NewAcknowledgement(unitId, unitStateId), CancellationToken.None); + + await using (var database = Connect(_connection)) + await database.ExecuteAsync(IsPostgres ? "DELETE FROM unitstates WHERE unitstateid = @Id" : "DELETE FROM UnitStates WHERE UnitStateId = @Id", new { Id = unitStateId }); + + await using (var database = Connect(_connection)) + (await database.ExecuteScalarAsync(IsPostgres + ? "SELECT COUNT(*) FROM unitstatusalertacknowledgements WHERE unitstateid = @Id" + : "SELECT COUNT(*) FROM UnitStatusAlertAcknowledgements WHERE UnitStateId = @Id", new { Id = unitStateId })).Should().Be(0); + } + } +} diff --git a/Tests/Resgrid.Tests/Security/MfaActivityTests.cs b/Tests/Resgrid.Tests/Security/MfaActivityTests.cs index cbcd324ce..94bcd8b4c 100644 --- a/Tests/Resgrid.Tests/Security/MfaActivityTests.cs +++ b/Tests/Resgrid.Tests/Security/MfaActivityTests.cs @@ -394,7 +394,8 @@ public async Task A_wrong_code_at_web_step_up_is_recorded_as_denied_activity_for System.Text.Encodings.Web.UrlEncoder.Default, localizer.Object, Mock.Of>(), new InMemoryUserMfaStateRepository(), Mock.Of(), Mock.Of(), Mock.Of(), new InMemoryUserPasskeyRepository(), Mock.Of(), activity.Object, Mock.Of(), Mock.Of(), Mock.Of(), - Mock.Of(), Mock.Of(), Mock.Of()) + Mock.Of(), Mock.Of(), Mock.Of(), + Mock.Of(), new Microsoft.AspNetCore.DataProtection.EphemeralDataProtectionProvider()) { ControllerContext = new ControllerContext { diff --git a/Tests/Resgrid.Tests/Security/PasskeyApiTests.cs b/Tests/Resgrid.Tests/Security/PasskeyApiTests.cs index 688933034..ae94bdc41 100644 --- a/Tests/Resgrid.Tests/Security/PasskeyApiTests.cs +++ b/Tests/Resgrid.Tests/Security/PasskeyApiTests.cs @@ -320,7 +320,7 @@ public async Task Web_refuses_to_turn_off_totp_while_passkeys_exist() var controller = new TwoFactorController(users.Object, null, Mock.Of(), UrlEncoder.Default, localizer.Object, Mock.Of>(), new InMemoryUserMfaStateRepository(), Mock.Of(), evidence.Object, Mock.Of(), passkeyRows, Mock.Of(), Mock.Of(), Mock.Of(), - Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of()) + Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), new Microsoft.AspNetCore.DataProtection.EphemeralDataProtectionProvider()) { ControllerContext = new ControllerContext { diff --git a/Tests/Resgrid.Tests/Security/StepUpFormReplayTests.cs b/Tests/Resgrid.Tests/Security/StepUpFormReplayTests.cs new file mode 100644 index 000000000..3738100c7 --- /dev/null +++ b/Tests/Resgrid.Tests/Security/StepUpFormReplayTests.cs @@ -0,0 +1,425 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Claims; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Primitives; +using Moq; +using NUnit.Framework; +using Resgrid.Model.Providers; +using Resgrid.Model.Security; +using Resgrid.Model.Services; +using Resgrid.Tests.Security.Live; +using Resgrid.Web.Areas.User.Controllers; +using Resgrid.Web.Areas.User.Models.Security; +using Resgrid.Web.Areas.User.Models.TwoFactor; +using Resgrid.Web.Attributes; +using Resgrid.Web.Filters; +using Resgrid.Web.Helpers; +using IdentityUser = Resgrid.Model.Identity.IdentityUser; + +namespace Resgrid.Tests.Security +{ + /// + /// A submission stopped for step-up is held and finished after verifying, never discarded: the guard holds it, the replay + /// puts it back before model binding, and only its own user, session, department and address may replay it, once. + /// + [TestFixture] + public class StepUpFormReplayTests + { + private const string UserId = "user-1"; + private const string SessionId = "session-9"; + private const int DepartmentId = 42; + private const string SettingsPath = "/User/Department/Settings"; + + private InMemoryCacheProvider _cache; + private IDataProtectionProvider _protection; + + [SetUp] + public void SetUp() + { + _cache = new InMemoryCacheProvider(); + _protection = new EphemeralDataProtectionProvider(); + } + + private static ClaimsPrincipal Principal(string userId = UserId, string sessionId = SessionId, int departmentId = DepartmentId) => + new(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.NameIdentifier, userId), + new Claim(SessionClaimTypes.SessionId, sessionId), + new Claim(ClaimTypes.PrimaryGroupSid, departmentId.ToString()) + }, "test")); + + private IServiceProvider Services(ICacheProvider cache = null) + { + var user = new IdentityUser { Id = UserId, UserName = "user1", AuthenticationGeneration = 4 }; + var users = new Mock>(Mock.Of>(), null, null, null, null, null, null, null, null); + users.Setup(m => m.GetUserAsync(It.IsAny())).ReturnsAsync(user); + users.Setup(m => m.GetTwoFactorEnabledAsync(user)).ReturnsAsync(true); + users.Setup(m => m.GetUserId(It.IsAny())) + .Returns((ClaimsPrincipal p) => p.FindFirst(ClaimTypes.NameIdentifier)?.Value); + + return new ServiceCollection() + .AddSingleton(users.Object) + .AddSingleton(Mock.Of()) + .AddSingleton(cache ?? _cache) + .AddSingleton(_protection) + .BuildServiceProvider(); + } + + private DefaultHttpContext Post(string path, Dictionary fields, ClaimsPrincipal principal = null, + bool script = false, FormFileCollection files = null, ICacheProvider cache = null, string query = null) + { + var http = new DefaultHttpContext { RequestServices = Services(cache), User = principal ?? Principal() }; + http.Request.Method = "POST"; + http.Request.Scheme = "https"; + http.Request.Host = new HostString("app.resgrid.test"); + http.Request.Path = path; + if (query != null) + http.Request.QueryString = new QueryString(query); + http.Request.ContentType = files == null ? "application/x-www-form-urlencoded" : "multipart/form-data; boundary=x"; + http.Request.ContentLength = 100; + http.Request.Headers.Referer = "https://app.resgrid.test" + path; + if (script) + http.Request.Headers["X-Requested-With"] = "XMLHttpRequest"; + http.Features.Set(new FormFeature(new FormCollection(fields, files))); + return http; + } + + private static Dictionary SettingsForm() => new() + { + ["Department.Name"] = "Station 9", + ["Use24HourTime"] = new StringValues(new[] { "true", "false" }), + ["__RequestVerificationToken"] = "token-from-the-original-page" + }; + + private static async Task<(ActionExecutingContext Context, bool Passed)> Guard(HttpContext http) + { + var context = new ActionExecutingContext(new ActionContext(http, new RouteData(), new ActionDescriptor()), + new List(), new Dictionary(), controller: null); + var passed = false; + await new RequiresRecentTwoFactorAttribute { RequireForOperation = true } + .OnActionExecutionAsync(context, () => { passed = true; return Task.FromResult(null); }); + return (context, passed); + } + + private async Task<(ResourceExecutingContext Context, bool Passed)> Replay(HttpContext http) + { + var context = new ResourceExecutingContext(new ActionContext(http, new RouteData(), new ActionDescriptor()), + new List(), new List()); + var passed = false; + await new HeldSubmissionReplayFilter(_cache, _protection, http.RequestServices.GetRequiredService>()) + .OnResourceExecutionAsync(context, () => { passed = true; return Task.FromResult(null); }); + return (context, passed); + } + + private async Task HoldSettingsPost(FormFileCollection files = null) + { + var (context, passed) = await Guard(Post(SettingsPath, SettingsForm(), files: files)); + passed.Should().BeFalse(); + + var returnUrl = (string)((RedirectToRouteResult)context.Result).RouteValues["returnUrl"]; + return QueryHelpers(returnUrl)["id"]; + } + + private static Dictionary QueryHelpers(string url) => + Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(new Uri("https://x" + url).Query) + .ToDictionary(x => x.Key, x => x.Value.ToString()); + + private static Dictionary ReplayForm(string id) => new() + { + [StepUpFormReplay.FieldName] = id, + ["__RequestVerificationToken"] = "fresh-token" + }; + + [Test] + public async Task A_form_post_stopped_for_step_up_is_held_and_verify_returns_through_the_resume_page() + { + var (context, passed) = await Guard(Post(SettingsPath, SettingsForm())); + + passed.Should().BeFalse(); + var route = context.Result.Should().BeOfType().Subject.RouteValues; + route["action"].Should().Be("Verify2FA"); + route.ContainsKey("resubmit").Should().BeFalse("the submission was held"); + + var returnUrl = (string)route["returnUrl"]; + returnUrl.Should().StartWith(StepUpFormReplay.ResumePath + "?id="); + QueryHelpers(returnUrl)["back"].Should().Be(SettingsPath, "an expired hold still sends the user back to the page they were on"); + new StepUpVerifyViewModel { ReturnUrl = returnUrl }.HoldingSubmission.Should().BeTrue(); + + var held = await StepUpFormReplay.PeekAsync(_cache, _protection, QueryHelpers(returnUrl)["id"]); + held.Target.Should().Be(SettingsPath); + held.Fields.Select(f => f.Name).Should().BeEquivalentTo(new[] { "Department.Name", "Use24HourTime" }, + "the stale antiforgery token is never held; the replay brings a fresh one"); + } + + [Test] + public async Task The_replay_puts_the_held_form_in_place_before_binding_and_runs_once() + { + var id = await HoldSettingsPost(); + + var replay = Post(SettingsPath, ReplayForm(id)); + var (first, firstPassed) = await Replay(replay); + + firstPassed.Should().BeTrue(); + first.Result.Should().BeNull(); + var form = await replay.Request.ReadFormAsync(); + form["Department.Name"].ToString().Should().Be("Station 9"); + form["Use24HourTime"].ToArray().Should().Equal("true", "false"); + form["__RequestVerificationToken"].ToString().Should().Be("fresh-token"); + + var (second, secondPassed) = await Replay(Post(SettingsPath, ReplayForm(id))); + secondPassed.Should().BeFalse("a held submission saves once, whatever is double-clicked"); + second.Result.Should().BeOfType().Which.Url.Should().StartWith(StepUpFormReplay.ResumePath); + } + + [TestCase("someone-else", SessionId, DepartmentId)] + [TestCase(UserId, "another-session", DepartmentId)] + [TestCase(UserId, SessionId, 7)] + public async Task Only_the_user_session_and_department_that_made_it_may_replay_it(string userId, string sessionId, int departmentId) + { + var id = await HoldSettingsPost(); + + var (refused, refusedPassed) = await Replay(Post(SettingsPath, ReplayForm(id), Principal(userId, sessionId, departmentId))); + refusedPassed.Should().BeFalse("the action must never run on the bare replay post"); + refused.Result.Should().NotBeNull(); + + var (owner, ownerPassed) = await Replay(Post(SettingsPath, ReplayForm(id))); + ownerPassed.Should().BeTrue("a refused attempt does not use up the owner's replay"); + } + + [Test] + public async Task A_held_submission_replays_only_to_the_address_it_was_made_to() + { + var id = await HoldSettingsPost(); + + var (elsewhere, passed) = await Replay(Post("/User/Personnel/DeletePerson", ReplayForm(id))); + + passed.Should().BeFalse(); + elsewhere.Result.Should().NotBeNull(); + } + + [Test] + public async Task Uploaded_files_come_back_with_the_replay() + { + var bytes = Encoding.UTF8.GetBytes(""); + var files = new FormFileCollection + { + new FormFile(new MemoryStream(bytes), 0, bytes.Length, "metadata", "idp.xml") + { + Headers = new HeaderDictionary { ["Content-Type"] = "text/xml" } + } + }; + var id = await HoldSettingsPost(files); + + var replay = Post(SettingsPath, ReplayForm(id)); + var (_, passed) = await Replay(replay); + + passed.Should().BeTrue(); + var file = (await replay.Request.ReadFormAsync()).Files.Single(); + file.Name.Should().Be("metadata"); + file.FileName.Should().Be("idp.xml"); + file.ContentType.Should().Be("text/xml"); + using var reader = new StreamReader(file.OpenReadStream()); + (await reader.ReadToEndAsync()).Should().Be(""); + } + + [Test] + public async Task A_script_call_is_told_where_to_verify_instead_of_following_a_redirect() + { + var http = Post("/User/Security/SetPermission", new Dictionary(), script: true, query: "?type=0&perm=1"); + + var (context, passed) = await Guard(http); + + passed.Should().BeFalse(); + var result = context.Result.Should().BeOfType().Subject; + result.StatusCode.Should().Be(StatusCodes.Status403Forbidden); + var verifyUrl = http.Response.Headers[RequiresRecentTwoFactorAttribute.StepUpRedirectHeader].ToString(); + verifyUrl.Should().StartWith("/User/TwoFactor/Verify2FA?returnUrl="); + + var resume = QueryHelpers(verifyUrl)["returnUrl"]; + var held = await StepUpFormReplay.PeekAsync(_cache, _protection, QueryHelpers(resume)["id"]); + held.Script.Should().BeTrue(); + held.Target.Should().Be("/User/Security/SetPermission?type=0&perm=1", "the query carries a script call's arguments"); + } + + [Test] + public async Task A_submission_that_cannot_be_held_says_so_instead_of_vanishing() + { + var refusing = new Mock(); + refusing.Setup(c => c.SetStringAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(false); + + var (cacheDown, _) = await Guard(Post(SettingsPath, SettingsForm(), cache: refusing.Object)); + var route = ((RedirectToRouteResult)cacheDown.Result).RouteValues; + route["resubmit"].Should().Be("1"); + route["returnUrl"].Should().Be(SettingsPath); + + var oversized = new Dictionary { ["Notes"] = new string('x', StepUpFormReplay.MaxFieldCharacters + 1) }; + var (tooLarge, _) = await Guard(Post(SettingsPath, oversized)); + ((RedirectToRouteResult)tooLarge.Result).RouteValues["resubmit"].Should().Be("1"); + + // A JSON body is not a form: holding it as an empty one would replay the action on blanks. + var json = Post(SettingsPath, new Dictionary()); + json.Request.ContentType = "application/json"; + json.Request.ContentLength = 12; + json.Features.Set(new FormFeature(json.Request)); // read the content type, as a real request does + var (notAForm, _) = await Guard(json); + ((RedirectToRouteResult)notAForm.Result).RouteValues["resubmit"].Should().Be("1"); + StepUpFormReplay.CanHold(json.Request).Should().BeFalse(); + } + + [Test] + public async Task A_page_request_is_still_returned_to_itself() + { + var http = Post(SettingsPath, new Dictionary()); + http.Request.Method = "GET"; + + var (context, _) = await Guard(http); + + var route = ((RedirectToRouteResult)context.Result).RouteValues; + route["returnUrl"].Should().Be(SettingsPath); + route.ContainsKey("resubmit").Should().BeFalse(); + } + + [Test] + public async Task The_resume_page_posts_only_its_owners_hold_back_to_where_it_was_made() + { + var id = await HoldSettingsPost(); + + async Task Resume(ClaimsPrincipal principal) + { + var url = new Mock(); + url.Setup(u => u.IsLocalUrl(It.IsAny())).Returns((string u) => u != null && u.StartsWith("/") && !u.StartsWith("//")); + url.Setup(u => u.Action(It.IsAny())).Returns("/User/Home/Dashboard"); + var controller = new StepUpResumeController(_cache, _protection, Services().GetRequiredService>()) + { + ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = principal } }, + Url = url.Object + }; + var view = (ViewResult)await controller.Index(id, "/User/Department/Settings"); + return (StepUpResumeViewModel)view.Model; + } + + var owner = await Resume(Principal()); + owner.Id.Should().Be(id); + owner.Target.Should().Be(SettingsPath); + owner.Script.Should().BeFalse(); + + var stranger = await Resume(Principal("someone-else")); + stranger.Id.Should().BeNull(); + stranger.Target.Should().BeNull(); + stranger.BackUrl.Should().Be("/User/Department/Settings"); + } + + // ---- Password re-confirmation --------------------------------------------------------------------------------- + + private TwoFactorController TwoFactor(HttpContext http, ICacheProvider cache = null) + { + var url = new Mock(); + url.Setup(u => u.Action(It.Is(c => c.Action == nameof(TwoFactorController.Disable2FA)))).Returns("/User/TwoFactor/Disable2FA"); + + // No fresh password evidence: the evidence mock answers false to HasFreshFirstFactorAsync. + return new TwoFactorController(http.RequestServices.GetRequiredService>(), null, Mock.Of(), + System.Text.Encodings.Web.UrlEncoder.Default, Mock.Of>(), + Mock.Of>(), Mock.Of(), Mock.Of(), + Mock.Of(), Mock.Of(), Mock.Of(), + Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), + Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), + cache ?? _cache, _protection) + { + ControllerContext = new ControllerContext { HttpContext = http }, + Url = url.Object + }; + } + + [Test] + public async Task A_form_post_stopped_for_password_reconfirmation_is_held_and_finished_after_confirming() + { + const string disablePath = "/User/TwoFactor/Disable2FA"; + var form = new Dictionary { ["Code"] = "123456", ["__RequestVerificationToken"] = "token-from-the-original-page" }; + + var result = await TwoFactor(Post(disablePath, form)).Disable2FA(new Disable2FAViewModel { Code = "123456" }, CancellationToken.None); + + var redirect = result.Should().BeOfType().Subject; + redirect.ControllerName.Should().Be("AccountSecurity"); + redirect.ActionName.Should().Be("Reauthenticate"); + redirect.RouteValues["resubmit"].Should().BeNull("the submission was held"); + var returnUrl = (string)redirect.RouteValues["returnUrl"]; + returnUrl.Should().StartWith(StepUpFormReplay.ResumePath + "?id="); + QueryHelpers(returnUrl)["back"].Should().Be(disablePath); + new ReauthenticateView { ReturnUrl = returnUrl }.HoldingSubmission.Should().BeTrue(); + + // Confirming the password returns through the resume page, whose post the global filter restores. + var replay = Post(disablePath, ReplayForm(QueryHelpers(returnUrl)["id"])); + var (_, passed) = await Replay(replay); + passed.Should().BeTrue(); + (await replay.Request.ReadFormAsync())["Code"].ToString().Should().Be("123456"); + } + + [Test] + public async Task A_submission_the_reconfirmation_cannot_hold_asks_to_resubmit() + { + var refusing = new Mock(); + refusing.Setup(c => c.SetStringAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(false); + var http = Post("/User/TwoFactor/Disable2FA", new Dictionary { ["Code"] = "123456" }); + + var result = await TwoFactor(http, refusing.Object).Disable2FA(new Disable2FAViewModel { Code = "123456" }, CancellationToken.None); + + var redirect = (RedirectToActionResult)result; + redirect.RouteValues["resubmit"].Should().Be("1"); + redirect.RouteValues["returnUrl"].Should().Be("/User/TwoFactor/Disable2FA"); + } + + [Test] + public async Task A_page_request_sent_to_reconfirm_returns_to_itself() + { + var http = Post("/User/TwoFactor/Disable2FA", new Dictionary()); + http.Request.Method = "GET"; + + var result = await TwoFactor(http).Disable2FA(); + + var redirect = (RedirectToActionResult)result; + redirect.RouteValues["returnUrl"].Should().Be("/User/TwoFactor/Disable2FA"); + redirect.RouteValues["resubmit"].Should().BeNull(); + } + + // ---- The global filter's reach ------------------------------------------------------------------------------ + + [Test] + public async Task Posts_that_cannot_be_a_replay_are_passed_through_unread() + { + var id = await HoldSettingsPost(); + + // An upload (multipart) carrying the field: never the resume page's post. + var upload = Post(SettingsPath, ReplayForm(id), files: new FormFileCollection()); + var (uploadContext, uploadPassed) = await Replay(upload); + uploadPassed.Should().BeTrue(); + uploadContext.Result.Should().BeNull(); + (await upload.Request.ReadFormAsync()).ContainsKey("Department.Name").Should().BeFalse("nothing was restored into it"); + + // A signed-out caller (a webhook) is never a replay. + var anonymous = Post(SettingsPath, ReplayForm(id), new ClaimsPrincipal(new ClaimsIdentity())); + var (_, anonymousPassed) = await Replay(anonymous); + anonymousPassed.Should().BeTrue(); + + // Neither used up the owner's replay. + var (_, ownerPassed) = await Replay(Post(SettingsPath, ReplayForm(id))); + ownerPassed.Should().BeTrue(); + } + } +} diff --git a/Tests/Resgrid.Tests/Security/WebLoginMfaTransactionTests.cs b/Tests/Resgrid.Tests/Security/WebLoginMfaTransactionTests.cs index 4b909be9f..082a2b978 100644 --- a/Tests/Resgrid.Tests/Security/WebLoginMfaTransactionTests.cs +++ b/Tests/Resgrid.Tests/Security/WebLoginMfaTransactionTests.cs @@ -692,7 +692,7 @@ private TwoFactorController StepUp(Mock evidence = null, bo localizer.Setup(l => l[It.IsAny()]).Returns((string key) => new LocalizedString(key, key)); return new TwoFactorController(_users.Object, null, _audits.Object, UrlEncoder.Default, localizer.Object, Mock.Of>(), new InMemoryUserMfaStateRepository(), Mock.Of(), (evidence ?? _evidence).Object, _policyService, new InMemoryUserPasskeyRepository(), - _notices.Object, _activity.Object, _passkeys.Object, _approvals.Object, _broker.Object, _returnTargets.Object, _sso.Object, _departments.Object) + _notices.Object, _activity.Object, _passkeys.Object, _approvals.Object, _broker.Object, _returnTargets.Object, _sso.Object, _departments.Object, Mock.Of(), new Microsoft.AspNetCore.DataProtection.EphemeralDataProtectionProvider()) { ControllerContext = new ControllerContext { HttpContext = http }, Url = Urls() diff --git a/Tests/Resgrid.Tests/Security/WebPasskeysTests.cs b/Tests/Resgrid.Tests/Security/WebPasskeysTests.cs index 4cd744cae..6193a05a7 100644 --- a/Tests/Resgrid.Tests/Security/WebPasskeysTests.cs +++ b/Tests/Resgrid.Tests/Security/WebPasskeysTests.cs @@ -183,7 +183,7 @@ private TwoFactorController TwoFactor(Mock evidence = null, Mock.Of>(), new InMemoryUserMfaStateRepository(), Mock.Of(), (evidence ?? new Mock()).Object, policy ?? Mock.Of(), new InMemoryUserPasskeyRepository(), Mock.Of(), (activity ?? new Mock()).Object, _passkeys.Object, Mock.Of(), Mock.Of(), - Mock.Of(), Mock.Of(), Mock.Of()) + Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), new Microsoft.AspNetCore.DataProtection.EphemeralDataProtectionProvider()) { ControllerContext = new ControllerContext { HttpContext = Http() }, Url = Urls() diff --git a/Tests/Resgrid.Tests/Services/ProtectedOutboundGuardTests.cs b/Tests/Resgrid.Tests/Services/ProtectedOutboundGuardTests.cs index 232388ce8..fe8376cf4 100644 --- a/Tests/Resgrid.Tests/Services/ProtectedOutboundGuardTests.cs +++ b/Tests/Resgrid.Tests/Services/ProtectedOutboundGuardTests.cs @@ -171,7 +171,7 @@ private System.Threading.Tasks.Task Record() public System.Threading.Tasks.Task PushCall(Resgrid.Model.Messages.StandardPushCall call, string userId, UserProfile profile = null, DepartmentCallPriority priority = null) => Record(); public System.Threading.Tasks.Task Register(PushUri pushUri) => Record(); public System.Threading.Tasks.Task UnRegister(PushUri pushUri) => Record(); - public void UnRegisterNotificationOnly(PushUri pushUri) { } + public System.Threading.Tasks.Task UnRegisterNotificationOnly(PushUri pushUri) => System.Threading.Tasks.Task.CompletedTask; public System.Threading.Tasks.Task PushNotification(Resgrid.Model.Messages.StandardPushMessage message, string userId, UserProfile profile = null) => Record(); public System.Threading.Tasks.Task PushICNotification(Resgrid.Model.Messages.StandardPushMessage message, string userId, UserProfile profile = null) => Record(); public System.Threading.Tasks.Task RegisterUnit(PushUri pushUri) => Record(); diff --git a/Tests/Resgrid.Tests/Services/UnitStatusAlertsServiceTests.cs b/Tests/Resgrid.Tests/Services/UnitStatusAlertsServiceTests.cs new file mode 100644 index 000000000..4c70e1cd2 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/UnitStatusAlertsServiceTests.cs @@ -0,0 +1,352 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Events; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class UnitStatusAlertsServiceTests + { + private const int DepartmentId = 4; + private const int UnitId = 12; + private const int UnitStateId = 900; + private const string UserId = "dispatcher-1"; + + private Mock _repository; + private Mock _unitsService; + private Mock _customStateService; + private Mock _settingsService; + private Mock _eventAggregator; + private List _inserted; + private List _updated; + private UnitStatusAlertsService _service; + + [SetUp] + public void SetUp() + { + _repository = new Mock(); + _unitsService = new Mock(); + _customStateService = new Mock(); + _settingsService = new Mock(); + _eventAggregator = new Mock(); + _inserted = new List(); + _updated = new List(); + + _unitsService.Setup(x => x.GetUnitByIdAsync(UnitId)).ReturnsAsync(new Unit { UnitId = UnitId, DepartmentId = DepartmentId }); + // Dispatched seven minutes ago against a 4 minute warning / 10 minute alert. + GivenCurrentState(UnitStateId, DateTime.UtcNow.AddMinutes(-7)); + _customStateService.Setup(x => x.GetCustomUnitStateAsync(It.IsAny())).ReturnsAsync(new CustomStateDetail { BaseType = (int)ActionBaseTypes.Dispatched }); + _settingsService.Setup(x => x.GetUnitStatusThresholdsAsync(DepartmentId, It.IsAny())).ReturnsAsync(new UnitStatusThresholds + { + Thresholds = new List { new UnitStatusThreshold { BaseType = (int)ActionBaseTypes.Dispatched, WarnSeconds = 240, AlertSeconds = 600 } } + }); + + _repository.Setup(x => x.GetActiveForUnitStateAsync(DepartmentId, UnitId, UnitStateId)).ReturnsAsync(new List()); + _repository.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((a, _, _) => _inserted.Add(a)) + .ReturnsAsync((UnitStatusAlertAcknowledgement a, CancellationToken _, bool _) => a); + _repository.Setup(x => x.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((a, _, _) => _updated.Add(a)) + .ReturnsAsync((UnitStatusAlertAcknowledgement a, CancellationToken _, bool _) => a); + + _service = new UnitStatusAlertsService(_repository.Object, _unitsService.Object, _customStateService.Object, _settingsService.Object, _eventAggregator.Object); + } + + private void GivenCurrentState(int unitStateId, DateTime timestampUtc) => + _unitsService.Setup(x => x.GetLastUnitStateByUnitIdAsync(UnitId)).ReturnsAsync(new UnitState { UnitStateId = unitStateId, UnitId = UnitId, Timestamp = timestampUtc }); + + private Task Acknowledge(UnitStatusAlertLevels level = UnitStatusAlertLevels.Warn, + UnitStatusAlertAcknowledgementModes mode = UnitStatusAlertAcknowledgementModes.Acknowledged, int muteMinutes = 0, string note = null, int unitStateId = UnitStateId) => + _service.AcknowledgeAsync(DepartmentId, UnitId, unitStateId, level, mode, muteMinutes, note, UserId); + + [Test] + public async Task Acknowledging_an_overdue_unit_records_who_saw_it_and_the_note() + { + var result = await Acknowledge(note: " MUG not departed, technical malfunction reported. "); + + result.Success.Should().BeTrue(); + _inserted.Should().ContainSingle(); + var saved = _inserted.Single(); + saved.UnitStatusAlertAcknowledgementId.Should().NotBeNullOrWhiteSpace(); + saved.DepartmentId.Should().Be(DepartmentId); + saved.UnitStateId.Should().Be(UnitStateId); + saved.Level.Should().Be((int)UnitStatusAlertLevels.Warn); + saved.Mode.Should().Be((int)UnitStatusAlertAcknowledgementModes.Acknowledged); + saved.MutedUntil.Should().BeNull(); + saved.Note.Should().Be("MUG not departed, technical malfunction reported."); + saved.AcknowledgedByUserId.Should().Be(UserId); + } + + [Test] + public async Task Acknowledging_tells_every_board_in_the_department() + { + await Acknowledge(); + + _eventAggregator.Verify(x => x.SendMessageAsync(It.Is(e => e.DepartmentId == DepartmentId && e.UnitId == UnitId)), Times.Once); + } + + [Test] + public async Task A_failed_push_does_not_undo_the_acknowledgement() + { + _eventAggregator.Setup(x => x.SendMessageAsync(It.IsAny())).ThrowsAsync(new InvalidOperationException("broker down")); + + var result = await Acknowledge(); + + result.Success.Should().BeTrue(); + _inserted.Should().ContainSingle(); + } + + [Test] + public async Task A_blank_note_is_stored_as_none() + { + await Acknowledge(note: " "); + + _inserted.Single().Note.Should().BeNull(); + } + + [Test] + public async Task A_note_over_the_limit_is_refused() + { + var result = await Acknowledge(note: new string('x', UnitStatusAlertAcknowledgement.MaxNoteLength + 1)); + + result.Success.Should().BeFalse(); + result.Error.Should().Be(UnitStatusAlertAcknowledgementResult.NoteTooLong); + _inserted.Should().BeEmpty(); + } + + [Test] + public async Task A_unit_in_another_department_is_not_found() + { + _unitsService.Setup(x => x.GetUnitByIdAsync(UnitId)).ReturnsAsync(new Unit { UnitId = UnitId, DepartmentId = DepartmentId + 1 }); + + var result = await Acknowledge(); + + result.Error.Should().Be(UnitStatusAlertAcknowledgementResult.NotFound); + _inserted.Should().BeEmpty(); + } + + [Test] + public async Task A_unit_that_has_moved_on_cannot_have_its_old_status_acknowledged() + { + GivenCurrentState(UnitStateId + 1, DateTime.UtcNow.AddMinutes(-7)); + + var result = await Acknowledge(); + + result.Error.Should().Be(UnitStatusAlertAcknowledgementResult.StatusChanged); + _inserted.Should().BeEmpty(); + } + + [Test] + public async Task A_unit_within_its_threshold_cannot_be_acknowledged() + { + GivenCurrentState(UnitStateId, DateTime.UtcNow.AddMinutes(-2)); + + var result = await Acknowledge(); + + result.Error.Should().Be(UnitStatusAlertAcknowledgementResult.NotOverdue); + _inserted.Should().BeEmpty(); + } + + [Test] + public async Task A_board_a_few_seconds_fast_can_still_acknowledge() + { + // The board's clock says four minutes; the server's says ten seconds short of it. + GivenCurrentState(UnitStateId, DateTime.UtcNow.AddSeconds(-230)); + + var result = await Acknowledge(); + + result.Success.Should().BeTrue(); + } + + [Test] + public async Task An_alert_cannot_be_acknowledged_before_it_fires() + { + // The unit is only at the warning level, so a request claiming the alert level is recorded as a warning. + // When the alert does fire it comes back. + var result = await Acknowledge(level: UnitStatusAlertLevels.Alert); + + result.Success.Should().BeTrue(); + _inserted.Single().Level.Should().Be((int)UnitStatusAlertLevels.Warn); + } + + [Test] + public async Task A_board_that_saw_the_warning_records_the_warning_even_after_the_alert_fired() + { + GivenCurrentState(UnitStateId, DateTime.UtcNow.AddMinutes(-11)); + + var result = await Acknowledge(level: UnitStatusAlertLevels.Warn); + + result.Success.Should().BeTrue(); + _inserted.Single().Level.Should().Be((int)UnitStatusAlertLevels.Warn); + } + + [Test] + public async Task A_timed_mute_ends_after_the_chosen_minutes() + { + var before = DateTime.UtcNow; + + await Acknowledge(mode: UnitStatusAlertAcknowledgementModes.Muted, muteMinutes: 15); + + _inserted.Single().MutedUntil.Should().BeCloseTo(before.AddMinutes(15), TimeSpan.FromSeconds(5)); + } + + [Test] + public async Task A_mute_without_minutes_lasts_until_the_status_changes() + { + await Acknowledge(mode: UnitStatusAlertAcknowledgementModes.Muted, muteMinutes: 0); + + _inserted.Single().MutedUntil.Should().BeNull(); + } + + [Test] + public async Task A_mute_is_capped_at_a_day() + { + var before = DateTime.UtcNow; + + await Acknowledge(mode: UnitStatusAlertAcknowledgementModes.Muted, muteMinutes: 100000); + + _inserted.Single().MutedUntil.Should().BeCloseTo(before.AddMinutes(UnitStatusAlertAcknowledgement.MaxMuteMinutes), TimeSpan.FromSeconds(5)); + } + + [Test] + public async Task Minutes_are_ignored_on_a_plain_acknowledgement() + { + await Acknowledge(mode: UnitStatusAlertAcknowledgementModes.Acknowledged, muteMinutes: 30); + + _inserted.Single().MutedUntil.Should().BeNull(); + } + + [Test] + public async Task An_unknown_mode_or_level_is_refused() + { + (await Acknowledge(mode: (UnitStatusAlertAcknowledgementModes)7)).Error.Should().Be(UnitStatusAlertAcknowledgementResult.InvalidMode); + (await Acknowledge(level: UnitStatusAlertLevels.None)).Error.Should().Be(UnitStatusAlertAcknowledgementResult.InvalidLevel); + _inserted.Should().BeEmpty(); + } + + [Test] + public async Task Acknowledging_again_replaces_the_earlier_acknowledgement() + { + var earlier = new UnitStatusAlertAcknowledgement { UnitStatusAlertAcknowledgementId = "earlier", DepartmentId = DepartmentId, UnitId = UnitId, UnitStateId = UnitStateId }; + _repository.Setup(x => x.GetActiveForUnitStateAsync(DepartmentId, UnitId, UnitStateId)).ReturnsAsync(new List { earlier }); + + var result = await Acknowledge(note: "second crew member unavailable"); + + result.Success.Should().BeTrue(); + _updated.Should().ContainSingle().Which.UnitStatusAlertAcknowledgementId.Should().Be("earlier"); + earlier.ClearedOn.Should().NotBeNull(); + earlier.ClearedByUserId.Should().Be(UserId); + _inserted.Should().ContainSingle(); + } + + [Test] + public async Task Losing_a_race_hands_back_the_winning_acknowledgement() + { + var winner = new UnitStatusAlertAcknowledgement { UnitStatusAlertAcknowledgementId = "winner", DepartmentId = DepartmentId, UnitId = UnitId, UnitStateId = UnitStateId }; + _repository.SetupSequence(x => x.GetActiveForUnitStateAsync(DepartmentId, UnitId, UnitStateId)) + .ReturnsAsync(new List()) + .ReturnsAsync(new List { winner }); + _repository.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("unique index")); + + var result = await Acknowledge(); + + result.Success.Should().BeFalse(); + result.Error.Should().Be(UnitStatusAlertAcknowledgementResult.Conflict); + result.Acknowledgement.Should().BeSameAs(winner); + } + + [Test] + public async Task An_insert_failure_that_is_not_a_race_is_not_swallowed() + { + _repository.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("database down")); + + Func act = () => Acknowledge(); + + await act.Should().ThrowAsync(); + } + + [Test] + public async Task Clearing_marks_the_acknowledgement_and_tells_the_boards() + { + var existing = new UnitStatusAlertAcknowledgement { UnitStatusAlertAcknowledgementId = "a1", DepartmentId = DepartmentId, UnitId = UnitId, UnitStateId = UnitStateId }; + _repository.Setup(x => x.GetByIdAsync("a1")).ReturnsAsync(existing); + + var result = await _service.ClearAsync(DepartmentId, "a1", UserId); + + result.Success.Should().BeTrue(); + existing.ClearedOn.Should().NotBeNull(); + existing.ClearedByUserId.Should().Be(UserId); + _eventAggregator.Verify(x => x.SendMessageAsync(It.Is(e => e.UnitId == UnitId)), Times.Once); + } + + [Test] + public async Task Clearing_twice_is_not_an_error() + { + var clearedOn = DateTime.UtcNow.AddMinutes(-1); + var existing = new UnitStatusAlertAcknowledgement { UnitStatusAlertAcknowledgementId = "a1", DepartmentId = DepartmentId, UnitId = UnitId, ClearedOn = clearedOn, ClearedByUserId = "someone-else" }; + _repository.Setup(x => x.GetByIdAsync("a1")).ReturnsAsync(existing); + + var result = await _service.ClearAsync(DepartmentId, "a1", UserId); + + result.Success.Should().BeTrue(); + existing.ClearedOn.Should().Be(clearedOn); + existing.ClearedByUserId.Should().Be("someone-else"); + _updated.Should().BeEmpty(); + } + + [Test] + public async Task Another_departments_acknowledgement_cannot_be_cleared() + { + _repository.Setup(x => x.GetByIdAsync("a1")).ReturnsAsync(new UnitStatusAlertAcknowledgement { UnitStatusAlertAcknowledgementId = "a1", DepartmentId = DepartmentId + 1 }); + + var result = await _service.ClearAsync(DepartmentId, "a1", UserId); + + result.Error.Should().Be(UnitStatusAlertAcknowledgementResult.NotFound); + _updated.Should().BeEmpty(); + } + + [Test] + public async Task Only_acknowledgements_for_current_statuses_are_read() + { + _unitsService.Setup(x => x.GetAllLatestStatusForUnitsByDepartmentIdAsync(DepartmentId)).ReturnsAsync(new List + { + new UnitState { UnitId = 1, UnitStateId = 501 }, + new UnitState { UnitId = 2, UnitStateId = 0 }, // never reported a status + new UnitState { UnitId = 3, UnitStateId = 503 } + }); + IEnumerable requested = null; + _repository.Setup(x => x.GetActiveForUnitStatesAsync(DepartmentId, It.IsAny>())) + .Callback>((_, ids) => requested = ids.ToList()) + .ReturnsAsync(new List { new UnitStatusAlertAcknowledgement { UnitStateId = 501 } }); + + var result = await _service.GetCurrentAcknowledgementsForDepartmentAsync(DepartmentId); + + requested.Should().BeEquivalentTo(new[] { 501, 503 }); + result.Should().ContainSingle(); + } + + [Test] + public async Task A_department_with_no_status_records_reads_nothing() + { + _unitsService.Setup(x => x.GetAllLatestStatusForUnitsByDepartmentIdAsync(DepartmentId)).ReturnsAsync(new List { new UnitState { UnitId = 1 } }); + + var result = await _service.GetCurrentAcknowledgementsForDepartmentAsync(DepartmentId); + + result.Should().BeEmpty(); + _repository.Verify(x => x.GetActiveForUnitStatesAsync(It.IsAny(), It.IsAny>()), Times.Never); + } + } +} diff --git a/Tools/Resgrid.Console/Commands/MigrateDocsDbCommand.cs b/Tools/Resgrid.Console/Commands/MigrateDocsDbCommand.cs index a55f869fc..a38b59ee2 100644 --- a/Tools/Resgrid.Console/Commands/MigrateDocsDbCommand.cs +++ b/Tools/Resgrid.Console/Commands/MigrateDocsDbCommand.cs @@ -68,9 +68,8 @@ public async Task ExecuteMainAsync(string[] args, CancellationToken ca if (existingLayer == null) { logger.LogInformation($"Migrating Map: {layer.Id.ToString()}"); - mapLayersDocRepository.InsertAsync(layer). - ContinueWith(t => logger.LogError(t.Exception?.ToString()), - TaskContinuationOptions.OnlyOnFaulted); + try { mapLayersDocRepository.InsertAsync(layer).GetAwaiter().GetResult(); } + catch (Exception ex) { logger.LogError(ex.ToString()); } } }); } @@ -86,9 +85,8 @@ public async Task ExecuteMainAsync(string[] args, CancellationToken ca if (existingLocation == null) { logger.LogInformation($"Migrating Unit Location: {unitLocation.Id.ToString()}"); - unitsLocationsDocRepository.InsertAsync(unitLocation). - ContinueWith(t => logger.LogError(t.Exception?.ToString()), - TaskContinuationOptions.OnlyOnFaulted); + try { unitsLocationsDocRepository.InsertAsync(unitLocation).GetAwaiter().GetResult(); } + catch (Exception ex) { logger.LogError(ex.ToString()); } } }); } @@ -104,9 +102,8 @@ public async Task ExecuteMainAsync(string[] args, CancellationToken ca if (existingLocation == null) { logger.LogInformation($"Migrating Personnel Location: {personLocation.Id.ToString()}"); - personnelLocationsDocRepository.InsertAsync(personLocation). - ContinueWith(t => logger.LogError(t.Exception?.ToString()), - TaskContinuationOptions.OnlyOnFaulted); + try { personnelLocationsDocRepository.InsertAsync(personLocation).GetAwaiter().GetResult(); } + catch (Exception ex) { logger.LogError(ex.ToString()); } } }); } diff --git a/Web/Resgrid.Web.Eventing/Worker.cs b/Web/Resgrid.Web.Eventing/Worker.cs index 4e50c25da..c9519fa52 100644 --- a/Web/Resgrid.Web.Eventing/Worker.cs +++ b/Web/Resgrid.Web.Eventing/Worker.cs @@ -61,6 +61,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken = def _rabbitInboundEventProvider.RegisterForChatEvents(ChatEventReceived); _rabbitInboundEventProvider.RegisterForChecklistEvents((departmentId, id) => _eventingHub.Clients.Group(departmentId.ToString()).SendAsync("checklistUpdated", id)); _rabbitInboundEventProvider.RegisterForSessionEvents(SessionEventReceived); + _rabbitInboundEventProvider.RegisterForUnitStatusAlertEvents((departmentId, id) => _eventingHub.Clients.Group(departmentId.ToString()).SendAsync("unitStatusAlertUpdated", id)); await StartProviderAsync(); diff --git a/Web/Resgrid.Web.Services/Controllers/v4/UnitStatusAlertsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/UnitStatusAlertsController.cs new file mode 100644 index 000000000..50d572270 --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/UnitStatusAlertsController.cs @@ -0,0 +1,175 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Mime; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Services.Helpers; +using Resgrid.Web.Services.Models.v4.UnitStatusAlerts; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + /// + /// Acknowledging, muting and annotating the alerts raised when a unit sits in a status longer than the + /// department's Unit Status Timers allow. Reading is open to anyone who can see units. Acknowledging and + /// clearing require the Create Call permission, the same people who dispatch the units. + /// + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + public class UnitStatusAlertsController : V4AuthenticatedApiControllerbase + { + private readonly IUnitStatusAlertsService _unitStatusAlertsService; + private readonly IDepartmentsService _departmentsService; + private readonly Model.Services.IAuthorizationService _authorizationService; + + public UnitStatusAlertsController(IUnitStatusAlertsService unitStatusAlertsService, IDepartmentsService departmentsService, + Model.Services.IAuthorizationService authorizationService) + { + _unitStatusAlertsService = unitStatusAlertsService; + _departmentsService = departmentsService; + _authorizationService = authorizationService; + } + + /// + /// Gets the acknowledgements that cover each unit's current status episode. Acknowledgements for units + /// the caller cannot see are left out. + /// + [HttpGet("GetActiveAcknowledgements")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Unit_View)] + public async Task> GetActiveAcknowledgements() + { + var result = new GetUnitStatusAlertAcknowledgementsResult(); + var acknowledgements = await _unitStatusAlertsService.GetCurrentAcknowledgementsForDepartmentAsync(DepartmentId); + + if (acknowledgements.Any()) + { + var names = await _departmentsService.GetAllPersonnelNamesForDepartmentAsync(DepartmentId); + var visibility = new Dictionary(); + + foreach (var acknowledgement in acknowledgements) + { + if (!visibility.TryGetValue(acknowledgement.UnitId, out var canView)) + { + canView = await _authorizationService.CanUserViewUnitViaMatrixAsync(acknowledgement.UnitId, UserId, DepartmentId); + visibility[acknowledgement.UnitId] = canView; + } + + if (canView) + result.Data.Add(ConvertAcknowledgement(acknowledgement, names)); + } + } + + result.PageSize = result.Data.Count; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + + return Ok(result); + } + + /// + /// Acknowledges or mutes the status timer alert for a unit's current status, optionally with a note. + /// Replaces any earlier acknowledgement of the same status. + /// + [HttpPost("Acknowledge")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [Authorize(Policy = ResgridResources.Unit_View)] + [Authorize(Policy = ResgridResources.Call_Create)] + public async Task> Acknowledge([FromBody] AcknowledgeUnitStatusAlertInput input, CancellationToken cancellationToken) + { + if (input == null) + return BadRequest(); + + if (!await _authorizationService.CanUserViewUnitViaMatrixAsync(input.UnitId, UserId, DepartmentId)) + return await ToActionResultAsync(UnitStatusAlertAcknowledgementResult.Fail(UnitStatusAlertAcknowledgementResult.NotFound)); + + var outcome = await _unitStatusAlertsService.AcknowledgeAsync(DepartmentId, input.UnitId, input.UnitStateId, (UnitStatusAlertLevels)input.Level, + (UnitStatusAlertAcknowledgementModes)input.Mode, input.MuteMinutes, input.Note, UserId, cancellationToken); + + return await ToActionResultAsync(outcome); + } + + /// + /// Withdraws an acknowledgement, so the alert shows as unacknowledged again. Clearing one that is already + /// cleared succeeds. + /// + [HttpDelete("Clear/{id}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = ResgridResources.Unit_View)] + [Authorize(Policy = ResgridResources.Call_Create)] + public async Task> Clear(string id, CancellationToken cancellationToken) + { + var existing = await _unitStatusAlertsService.GetAcknowledgementByIdAsync(DepartmentId, id); + + if (existing == null || !await _authorizationService.CanUserViewUnitViaMatrixAsync(existing.UnitId, UserId, DepartmentId)) + return await ToActionResultAsync(UnitStatusAlertAcknowledgementResult.Fail(UnitStatusAlertAcknowledgementResult.NotFound)); + + var outcome = await _unitStatusAlertsService.ClearAsync(DepartmentId, id, UserId, cancellationToken); + + return await ToActionResultAsync(outcome); + } + + private async Task> ToActionResultAsync(UnitStatusAlertAcknowledgementResult outcome) + { + var result = new SaveUnitStatusAlertAcknowledgementResult(); + + if (outcome.Acknowledgement != null) + { + var names = await _departmentsService.GetAllPersonnelNamesForDepartmentAsync(DepartmentId); + result.Data = ConvertAcknowledgement(outcome.Acknowledgement, names); + } + + result.Error = outcome.Error; + result.PageSize = result.Data == null ? 0 : 1; + result.Status = outcome.Success ? ResponseHelper.Success : ResponseHelper.Failure; + ResponseHelper.PopulateV4ResponseData(result); + + if (outcome.Success) + return Ok(result); + + switch (outcome.Error) + { + case UnitStatusAlertAcknowledgementResult.NotFound: + return NotFound(result); + case UnitStatusAlertAcknowledgementResult.StatusChanged: + case UnitStatusAlertAcknowledgementResult.NotOverdue: + case UnitStatusAlertAcknowledgementResult.Conflict: + return Conflict(result); + default: + return BadRequest(result); + } + } + + private static UnitStatusAlertAcknowledgementResultData ConvertAcknowledgement(UnitStatusAlertAcknowledgement acknowledgement, List names) + { + var name = names?.FirstOrDefault(x => string.Equals(x.UserId, acknowledgement.AcknowledgedByUserId, StringComparison.OrdinalIgnoreCase)); + + return new UnitStatusAlertAcknowledgementResultData + { + UnitStatusAlertAcknowledgementId = acknowledgement.UnitStatusAlertAcknowledgementId, + UnitId = acknowledgement.UnitId.ToString(), + UnitStateId = acknowledgement.UnitStateId, + Level = acknowledgement.Level, + Mode = acknowledgement.Mode, + MutedUntilUtc = acknowledgement.MutedUntil, + Note = acknowledgement.Note, + AcknowledgedByUserId = acknowledgement.AcknowledgedByUserId, + AcknowledgedByName = name == null ? null : $"{name.FirstName} {name.LastName}".Trim(), + AcknowledgedOnUtc = acknowledgement.AcknowledgedOn, + ClearedOnUtc = acknowledgement.ClearedOn + }; + } + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/UnitsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/UnitsController.cs index bcd886773..7aee630dc 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/UnitsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/UnitsController.cs @@ -390,6 +390,7 @@ public static UnitsInfoResultData ConvertUnitsInfoResultData(Model.Unit unit, Un data.CurrentStatusTimestamp = state.Timestamp.TimeConverter(new Department() { TimeZone = timeZone }); data.CurrentStatusTimestampUtc = state.Timestamp; + data.CurrentUnitStateId = state.UnitStateId; data.Note = ProtectedDataEnvelope.SafeDisplay(state.Note); if (state.DestinationId.HasValue) @@ -479,7 +480,8 @@ private async Task> GetFilterOptions() var stations = await _departmentGroupsService.GetAllGroupsForDepartmentAsync(DepartmentId); - Parallel.ForEach(stations, s => + // List.Add is not thread-safe; the old Parallel.ForEach could drop or corrupt entries. + foreach (var s in stations) { var respondingTo = new FilterResult(); respondingTo.Id = $"G:{s.DepartmentGroupId}"; @@ -487,7 +489,7 @@ private async Task> GetFilterOptions() respondingTo.Name = s.Name; result.Add(respondingTo); - }); + } return result; } diff --git a/Web/Resgrid.Web.Services/Models/v4/UnitStatusAlerts/UnitStatusAlertsModels.cs b/Web/Resgrid.Web.Services/Models/v4/UnitStatusAlerts/UnitStatusAlertsModels.cs new file mode 100644 index 000000000..af489dc6b --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/UnitStatusAlerts/UnitStatusAlertsModels.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using Newtonsoft.Json; +using Resgrid.Web.Services.Helpers; + +namespace Resgrid.Web.Services.Models.v4.UnitStatusAlerts +{ + /// + /// The acknowledgements covering each unit's current status episode. + /// + public class GetUnitStatusAlertAcknowledgementsResult : StandardApiResponseV4Base + { + public List Data { get; set; } + + public GetUnitStatusAlertAcknowledgementsResult() + { + Data = new List(); + } + } + + /// + /// The outcome of acknowledging or clearing a unit status timer alert. + /// + public class SaveUnitStatusAlertAcknowledgementResult : StandardApiResponseV4Base + { + /// + /// Null on success. Otherwise one of: unit_alert_not_found, unit_alert_status_changed (the unit moved to a + /// new status since the board loaded), unit_alert_not_overdue, unit_alert_conflict (another dispatcher + /// acknowledged it first; their acknowledgement is in Data), unit_alert_invalid_mode, + /// unit_alert_invalid_level, unit_alert_note_too_long. + /// + public string Error { get; set; } + + public UnitStatusAlertAcknowledgementResultData Data { get; set; } + } + + public class UnitStatusAlertAcknowledgementResultData + { + public string UnitStatusAlertAcknowledgementId { get; set; } + + public string UnitId { get; set; } + + /// The status record this acknowledgement covers. Compare it with the unit's CurrentUnitStateId. + public int UnitStateId { get; set; } + + /// 1 = warning, 2 = alert. If the unit goes past this level, the acknowledgement no longer covers it. + public int Level { get; set; } + + /// 0 = acknowledged (still highlighted, marked as seen), 1 = muted. + public int Mode { get; set; } + + /// When a mute runs out. Null on a mute means it lasts until the unit changes status. + [JsonConverter(typeof(UtcDateTimeConverter))] + public DateTime? MutedUntilUtc { get; set; } + + public string Note { get; set; } + + public string AcknowledgedByUserId { get; set; } + + public string AcknowledgedByName { get; set; } + + [JsonConverter(typeof(UtcDateTimeConverter))] + public DateTime AcknowledgedOnUtc { get; set; } + + /// Set when the acknowledgement has been withdrawn (only returned by Clear). + [JsonConverter(typeof(UtcDateTimeConverter))] + public DateTime? ClearedOnUtc { get; set; } + } + + public class AcknowledgeUnitStatusAlertInput + { + [Required] + [Range(1, int.MaxValue)] + public int UnitId { get; set; } + + /// The unit's CurrentUnitStateId as the board saw it. + [Required] + [Range(1, int.MaxValue)] + public int UnitStateId { get; set; } + + /// The level the board is showing: 1 = warning, 2 = alert. + [Range(1, 2)] + public int Level { get; set; } + + /// 0 = acknowledge, 1 = mute. + [Range(0, 1)] + public int Mode { get; set; } + + /// For a mute, the minutes to mute for. 0 mutes until the unit changes status. + [Range(0, 1440)] + public int MuteMinutes { get; set; } + + [MaxLength(500)] + public string Note { get; set; } + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/Units/UnitsInfoResult.cs b/Web/Resgrid.Web.Services/Models/v4/Units/UnitsInfoResult.cs index f51acd87e..67324618c 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Units/UnitsInfoResult.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Units/UnitsInfoResult.cs @@ -123,6 +123,13 @@ public class UnitsInfoResultData /// public int? CurrentStatusBaseType { get; set; } + /// + /// Id of the unit's current status record, or 0 when the unit has never reported a status. A status + /// timer acknowledgement names the status record it covers, so a board compares the two to tell + /// whether an acknowledgement still applies. + /// + public int CurrentUnitStateId { get; set; } + /// /// The Timestamp of the status, in the department's timezone. /// diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index 214af0c8d..43e73f926 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -4250,10 +4250,14 @@ own authorization rule; totals are null when any hit was dropped. Typeahead is the same query in prefix mode. - + Full search. is a comma-separated list of entity types (Call, Unit, Personnel, - Contact, Message, Document, Note, Record, Action); omit for all. + Contact, Message, Document, Note, Record, Log, Occupancy, Protocol, Training, CalendarEvent, Poi, Shift, Group, the + Business Operations families, Action); omit for all. Words must all match; a "quoted phrase" matches as a + phrase. / bound when the hit occurred, is + relevance (default), newest or oldest. A single family or a date range is authorized over a deeper window so its + count is exact more often. @@ -4265,6 +4269,9 @@ Department admins: host and index health plus this department's index state. + + Query-string instants bind as local or unspecified unless they carry an offset; treat a bare value as UTC. + Call Priorities, for example Low, Medium, High. Call Priorities can be system provided ones or custom for a department @@ -4813,6 +4820,31 @@ Optional department override for SystemApiKey (hosted multi-department) mode. UnitResult with the matching unit, or a not-found response. + + + Acknowledging, muting and annotating the alerts raised when a unit sits in a status longer than the + department's Unit Status Timers allow. Reading is open to anyone who can see units. Acknowledging and + clearing require the Create Call permission, the same people who dispatch the units. + + + + + Gets the acknowledgements that cover each unit's current status episode. Acknowledgements for units + the caller cannot see are left out. + + + + + Acknowledges or mutes the status timer alert for a unit's current status, optionally with a note. + Replaces any earlier acknowledgement of the same status. + + + + + Withdraws an acknowledgement, so the alert shows as unacknowledged again. Clearing one that is already + cleared succeeds. + + Units Status (State) information. For example is the unit Responding to a Call, or Available. @@ -13900,66 +13932,6 @@ Identifier of the new message - - - The second-factor choice for a named operation (passkey workbook section 7.2). Advisory: VerifyStepUp - re-checks everything. - - - - Methods the user can verify with now: enrolled and allowed. - - - The method to show first; null when nothing is usable. - - - True when the user must enroll a second factor before this operation. - - - How long one verification serves this operation. - - - - Assertion options for this app's passkeys, when a passkey is usable for the operation; send its request id and - the platform's response to VerifyStepUp with method passkey. - - - - One of security_change, adp_management, chat_export or account_security. - - - totp (the default), passkey, passkey_approval (Responder approval) or federated (provider step-up). - - - The current authenticator code, for totp. - - - The request id from StepUpOptions, for passkey. - - - The platform's assertion (PublicKeyCredential.toJSON()), for passkey. - - - For passkey_approval: the approved request from MfaApproval/Request (purpose step_up, same operation). - - - For federated: the transaction from Sso/Begin (purpose step_up, same operation). - - - For federated: the one-time sso_code the return target received. - - - For federated: the PKCE verifier whose challenge began the step-up. - - - Evidence stays on the server; no token is returned (workbook section 7.2). - - - When the second factor was verified (ISO 8601 UTC). - - - When this verification stops serving the operation (ISO 8601 UTC). - Asks the user's Responder to approve (workbook section 7.4). Send only after the user chose "Approve with Responder"; @@ -14020,6 +13992,66 @@ declined, or not_me ("I didn't request this": ends that sign-in and suspends approval requests). + + + The second-factor choice for a named operation (passkey workbook section 7.2). Advisory: VerifyStepUp + re-checks everything. + + + + Methods the user can verify with now: enrolled and allowed. + + + The method to show first; null when nothing is usable. + + + True when the user must enroll a second factor before this operation. + + + How long one verification serves this operation. + + + + Assertion options for this app's passkeys, when a passkey is usable for the operation; send its request id and + the platform's response to VerifyStepUp with method passkey. + + + + One of security_change, adp_management, chat_export or account_security. + + + totp (the default), passkey, passkey_approval (Responder approval) or federated (provider step-up). + + + The current authenticator code, for totp. + + + The request id from StepUpOptions, for passkey. + + + The platform's assertion (PublicKeyCredential.toJSON()), for passkey. + + + For passkey_approval: the approved request from MfaApproval/Request (purpose step_up, same operation). + + + For federated: the transaction from Sso/Begin (purpose step_up, same operation). + + + For federated: the one-time sso_code the return target received. + + + For federated: the PKCE verifier whose challenge began the step-up. + + + Evidence stays on the server; no token is returned (workbook section 7.2). + + + When the second factor was verified (ISO 8601 UTC). + + + When this verification stops serving the operation (ISO 8601 UTC). + Result containing all the data required to populate the Notes form @@ -14150,175 +14182,41 @@ Identifier of the new npte - - - The result of getting all personnel filters for the system - + + A started WebAuthn ceremony (workbook section 7.1): the request id to send back and the options for the platform. - - - The Id value of the filter - + + WebAuthn PublicKeyCredentialCreationOptions (registration) or PublicKeyCredentialRequestOptions (assertion) JSON. - - - The type of the filter - + + The platform's response: PublicKeyCredential.toJSON(), as an object or a JSON string. - - - The filters name - + + Optional name for the passkey; at most 100 characters. A default naming the app is used when empty. - + - Result containing all the data required to populate the New Call form + One passkey in the user's own inventory (plan section 6.5). Registration context, attachment and backup state are + server-observed or client-reported hints, not proof of a device. - - - Response Data - + + The app the passkey works in: web, responder, unit, dispatch or ic. - - - Result that contains all the options available to filter personnel against compatible Resgrid APIs - - - - - Response Data - - - - - Result containing all the data required to populate the New Call form - - - - - Response Data - - - - - Information about a User - - - - - The UserId GUID/UUID for the user - - - - - DepartmentId of the deparment the user belongs to - - - - - Department specificed ID number for this user - - - - - The Users First Name - - - - - The Users Last Name - - - - - The Users Email Address - - - - - The Users Mobile Telephone Number - - - - - GroupId the user is assigned to (0 for no group) - - - - - Name of the group the user is assigned to - - - - - Enumeration/List of roles the user currently holds - - - - - The current action/status type for the user - - - - - The current action/status string for the user - - - - - The current action/status color hex string for the user - - - - - The timestamp of the last action. This is converted UTC to the departments, or users, TimeZone. - - - - - The current action/status destination id for the user - - - - - The current action/status destination name for the user - - - - - The current staffing level (state) type for the user - - - - - The current staffing level (state) string for the user - - - - - The current staffing level (state) color hex string for the user - + + Responder passkeys only: whether it may approve other apps' requests. Null for other apps. - - - The timestamp of the last state/staffing level. This is converted UTC to the departments, or users, TimeZone. - + + web, responder, unit, dispatch or ic. - - - Users last known location - + + How many passkeys the change revoked (0 for a rename or approval change). - - - Sorting weight for the user - + + How many sessions that signed in with a removed passkey were ended. - - - User Defined Field values for this personnel record - + + True when this session was one of them: the client signs in again. @@ -15712,12 +15610,18 @@ True when the index could not serve; actions still return. + + True while the department's index is queued for, or in the middle of, a build: results may be incomplete. + Authorized total, or null when a hit was dropped by per-entity authorization. Relative web path; clients with native screens switch on EntityType/EntityId instead. + + Plain-text excerpt of the indexed text around the first match (full search only), e.g. the call note that matched. + Suppressed: shared-index counts include other departments. @@ -17364,6 +17268,51 @@ Default constructor + + + The acknowledgements covering each unit's current status episode. + + + + + The outcome of acknowledging or clearing a unit status timer alert. + + + + + Null on success. Otherwise one of: unit_alert_not_found, unit_alert_status_changed (the unit moved to a + new status since the board loaded), unit_alert_not_overdue, unit_alert_conflict (another dispatcher + acknowledged it first; their acknowledgement is in Data), unit_alert_invalid_mode, + unit_alert_invalid_level, unit_alert_note_too_long. + + + + The status record this acknowledgement covers. Compare it with the unit's CurrentUnitStateId. + + + 1 = warning, 2 = alert. If the unit goes past this level, the acknowledgement no longer covers it. + + + 0 = acknowledged (still highlighted, marked as seen), 1 = muted. + + + When a mute runs out. Null on a mute means it lasts until the unit changes status. + + + Set when the acknowledgement has been withdrawn (only returned by Clear). + + + The unit's CurrentUnitStateId as the board saw it. + + + The level the board is showing: 1 = warning, 2 = alert. + + + 0 = acknowledge, 1 = mute. + + + For a mute, the minutes to mute for. 0 mutes until the unit changes status. + Depicts a result after saving a unit status @@ -17870,6 +17819,13 @@ TypeId is the unit type, not a status. + + + Id of the unit's current status record, or 0 when the unit has never reported a status. A status + timer acknowledgement names the status record it covers, so a board compares the two to tell + whether an acknowledgement still applies. + + The Timestamp of the status, in the department's timezone. diff --git a/Web/Resgrid.Web/Areas/User/Controllers/AccountSecurityController.cs b/Web/Resgrid.Web/Areas/User/Controllers/AccountSecurityController.cs index fc86a3c23..af8c27407 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/AccountSecurityController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/AccountSecurityController.cs @@ -71,7 +71,7 @@ private async Task SsoReauthenticationAvailableAsync(CancellationToken can // for it. It cannot switch accounts, does not extend the session, and a refresh or remembered browser never counts. [HttpGet] - public async Task Reauthenticate(string returnUrl, CancellationToken cancellationToken) + public async Task Reauthenticate(string returnUrl, CancellationToken cancellationToken, bool resubmit = false) { var user = await _userManager.FindByIdAsync(UserId); if (user == null) @@ -80,6 +80,7 @@ public async Task Reauthenticate(string returnUrl, CancellationTo return View(new ReauthenticateView { ReturnUrl = SafeReturnUrl(returnUrl), + SubmissionNotHeld = resubmit, PasswordNotAllowed = !await IsPasswordReauthenticationAllowedAsync(user, cancellationToken), SsoAvailable = await SsoReauthenticationAvailableAsync(cancellationToken) }); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs b/Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs index 2044c54c1..3a9f5d504 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs @@ -904,7 +904,7 @@ public async Task EditUserProfile(EditProfileModel model, IFormCo if (member != null) { member.IsAdmin = model.IsUserGroupAdmin; - _departmentGroupsService.SaveGroupMember(member); + await _departmentGroupsService.SaveGroupMember(member, cancellationToken); } } else if (model.UserGroup <= 0) diff --git a/Web/Resgrid.Web/Areas/User/Controllers/StepUpResumeController.cs b/Web/Resgrid.Web/Areas/User/Controllers/StepUpResumeController.cs new file mode 100644 index 000000000..f07f9cb54 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Controllers/StepUpResumeController.cs @@ -0,0 +1,55 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Model.Providers; +using Resgrid.Web.Areas.User.Models.TwoFactor; +using Resgrid.Web.Helpers; +using IdentityUser = Resgrid.Model.Identity.IdentityUser; + +namespace Resgrid.Web.Areas.User.Controllers +{ + /// + /// Where Verify2FA returns when the step-up guard held a submission (): a page that posts it back + /// to the action it was made to, so verifying finishes the save instead of discarding it. + /// + [Area("User")] + [Authorize] + // Reads nothing of the department; the replayed action itself is still subject to any operation lock. + [Resgrid.Web.Filters.AllowDuringDepartmentLock] + public class StepUpResumeController : SecureBaseController + { + private readonly ICacheProvider _cacheProvider; + private readonly IDataProtectionProvider _dataProtection; + private readonly UserManager _userManager; + + public StepUpResumeController(ICacheProvider cacheProvider, IDataProtectionProvider dataProtection, UserManager userManager) + { + _cacheProvider = cacheProvider; + _dataProtection = dataProtection; + _userManager = userManager; + } + + [HttpGet] + public async Task Index(string id, string back = null) + { + Response.Headers["Cache-Control"] = "no-store"; + + var held = await StepUpFormReplay.PeekAsync(_cacheProvider, _dataProtection, id); + var usable = StepUpFormReplay.BelongsTo(held, _userManager.GetUserId(User), MfaEvidenceSession.KeyFor(User, HttpContext), + StepUpFormReplay.ActiveDepartmentOf(User)); + + var fallback = usable ? held.Back : back; + return View(new StepUpResumeViewModel + { + Id = usable ? id : null, + Target = usable ? held.Target : null, + Script = usable && held.Script, + BackUrl = !string.IsNullOrWhiteSpace(fallback) && Url.IsLocalUrl(fallback) + ? fallback + : Url.Action("Dashboard", "Home", new { area = "User" }) + }); + } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Controllers/TwoFactorController.cs b/Web/Resgrid.Web/Areas/User/Controllers/TwoFactorController.cs index 960dca3c8..1df23d004 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/TwoFactorController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/TwoFactorController.cs @@ -5,6 +5,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; @@ -15,6 +16,7 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Resgrid.Model; +using Resgrid.Model.Providers; using Resgrid.Model.Repositories; using Resgrid.Model.Security; using Resgrid.Model.Services; @@ -71,9 +73,13 @@ public TwoFactorController( ISsoBrokerService ssoBroker, ISsoReturnTargetRegistry ssoReturnTargets, IDepartmentSsoService departmentSso, - IDepartmentsService departments) + IDepartmentsService departments, + ICacheProvider cacheProvider, + IDataProtectionProvider dataProtection) { _departments = departments; + _cacheProvider = cacheProvider; + _dataProtection = dataProtection; _passkeys = passkeys; _approvals = approvals; _ssoBroker = ssoBroker; @@ -104,6 +110,8 @@ public TwoFactorController( private readonly ISsoReturnTargetRegistry _ssoReturnTargets; private readonly IDepartmentSsoService _departmentSso; private readonly IDepartmentsService _departments; + private readonly ICacheProvider _cacheProvider; + private readonly IDataProtectionProvider _dataProtection; // ── Index ───────────────────────────────────────────────────────────────── @@ -162,7 +170,7 @@ public async Task Enable2FA() // Setting up a factor needs a recent password (or SSO) verification for this session (plan section 6.2). if (!await HasFreshFirstFactorAsync(user, TwoFactorConfig.FirstFactorReauthWindowMinutes)) - return RedirectToReauthenticate(); + return await RedirectToReauthenticateAsync(); return View(await BuildAuthenticatorModelAsync(user, await StageNewAuthenticatorKeyAsync(user), isReplacement: false)); } @@ -233,7 +241,7 @@ public async Task ReplaceAuthenticator(CancellationToken cancella // Replacement needs a fresh first factor AND an existing factor (or recovery authority). if (!await HasFreshFirstFactorAsync(user, TwoFactorConfig.FirstFactorReauthWindowMinutes)) - return RedirectToReauthenticate(); + return await RedirectToReauthenticateAsync(); if (!await HasReplacementAuthorityAsync(user, cancellationToken)) return RedirectToStepUp(); @@ -355,7 +363,7 @@ public async Task Disable2FA() if (user == null) return NotFound(); if (!await HasFreshFirstFactorAsync(user, TwoFactorConfig.FirstFactorReauthWindowMinutes)) - return RedirectToReauthenticate(); + return await RedirectToReauthenticateAsync(); return View(new Disable2FAViewModel { BlockedByPasskeys = await _passkeyRepository.CountActiveForUserAsync(user.Id) > 0 }); } @@ -368,7 +376,7 @@ public async Task Disable2FA(Disable2FAViewModel model, Cancellat if (user == null) return NotFound(); if (!await HasFreshFirstFactorAsync(user, TwoFactorConfig.FirstFactorOperationWindowMinutes)) - return RedirectToReauthenticate(Url.Action(nameof(Disable2FA))); + return await RedirectToReauthenticateAsync(Url.Action(nameof(Disable2FA))); // TOTP is the fallback every passkey relies on in this release, so it cannot be turned off while any passkey // exists (plan section 7.5 rule 7). Remove the passkeys first. @@ -428,7 +436,7 @@ await _userSessionService.RevokeAllAfterCredentialChangeAsync(user.Id, user.Id, [HttpGet] [AllowAnonymous] public async Task Verify2FA(string returnUrl = null, string scope = null, CancellationToken cancellationToken = default, - int? entry = null) + int? entry = null, bool resubmit = false) { var methodScope = ParseScope(scope); var entering = await EntryDepartmentAsync(entry); @@ -437,6 +445,7 @@ public async Task Verify2FA(string returnUrl = null, string scope ReturnUrl = returnUrl, Scope = methodScope.ToString(), EntryDepartmentId = entering, + SubmissionNotHeld = resubmit, PasskeyAvailable = await PasskeyStepUpAvailableAsync(scope, cancellationToken, entering), ApprovalAvailable = await ApprovalStepUpAvailableAsync(methodScope, cancellationToken, entering), FederatedAvailable = entering == null && await FederatedStepUpAvailableAsync(methodScope, cancellationToken) @@ -847,10 +856,18 @@ private Task HasFreshFirstFactorAsync(IdentityUser user, int maxAgeMinutes => _mfaEvidenceService.HasFreshFirstFactorAsync(user.Id, MfaEvidenceSession.KeyFor(User, HttpContext), user.AuthenticationGeneration, TimeSpan.FromMinutes(Math.Max(1, maxAgeMinutes)), DateTime.UtcNow); - /// Sends the user to confirm their password, then back to (default: this page). - private IActionResult RedirectToReauthenticate(string returnUrl = null) - => RedirectToAction("Reauthenticate", "AccountSecurity", - new { area = "User", returnUrl = returnUrl ?? $"{Request.Path}{Request.QueryString}" }); + /// + /// Sends the user to confirm their password, then back to (default: this page). A submission + /// is held and finished after confirming () rather than discarded. + /// + private async Task RedirectToReauthenticateAsync(string returnUrl = null) + { + var (returnTo, submissionLost) = await StepUpFormReplay.HoldForVerificationAsync(HttpContext, _cacheProvider, _dataProtection, + _userManager.GetUserId(User), returnUrl ?? $"{Request.Path}{Request.QueryString}"); + + return RedirectToAction("Reauthenticate", "AccountSecurity", + new { area = "User", returnUrl = returnTo, resubmit = submissionLost ? "1" : null }); + } /// Records server-side evidence for this session; false when it could not be recorded. private async Task RecordEvidenceAsync(IdentityUser user, MfaEvidenceKind kind, MfaEvidenceMethod method, diff --git a/Web/Resgrid.Web/Areas/User/Models/Security/AccountCredentialViews.cs b/Web/Resgrid.Web/Areas/User/Models/Security/AccountCredentialViews.cs index df28e8d48..2abfa212a 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Security/AccountCredentialViews.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Security/AccountCredentialViews.cs @@ -31,6 +31,13 @@ public class ReauthenticateView [Required, DataType(DataType.Password)] [Display(Name = "Password")] public string Password { get; set; } + + /// The gate could not hold what the user submitted; after confirming they must submit it again. + public bool SubmissionNotHeld { get; set; } + + /// The gate is holding what the user submitted; confirming finishes it. + public bool HoldingSubmission => + ReturnUrl != null && ReturnUrl.Contains(Resgrid.Web.Helpers.StepUpFormReplay.ResumePath + "?", System.StringComparison.OrdinalIgnoreCase); } public class ChangePasswordView diff --git a/Web/Resgrid.Web/Areas/User/Models/TwoFactor/TwoFactorViewModels.cs b/Web/Resgrid.Web/Areas/User/Models/TwoFactor/TwoFactorViewModels.cs index ccb6e8fbd..fa5b7678a 100644 --- a/Web/Resgrid.Web/Areas/User/Models/TwoFactor/TwoFactorViewModels.cs +++ b/Web/Resgrid.Web/Areas/User/Models/TwoFactor/TwoFactorViewModels.cs @@ -106,6 +106,28 @@ public class StepUpVerifyViewModel /// The department being entered, when this verification is for entering it (plan section 7.6 row 5). public int? EntryDepartmentId { get; set; } + + /// The guard could not hold what the user submitted; after verifying they must submit it again. + public bool SubmissionNotHeld { get; set; } + + /// The guard is holding what the user submitted; verifying finishes it. + public bool HoldingSubmission => + ReturnUrl != null && ReturnUrl.Contains(Resgrid.Web.Helpers.StepUpFormReplay.ResumePath + "?", System.StringComparison.OrdinalIgnoreCase); + } + + /// The page that posts a held submission back after step-up (). + public class StepUpResumeViewModel + { + /// The held submission; null when it has expired, was already sent, or is not this session's. + public string Id { get; set; } + + /// The address it was made to. + public string Target { get; set; } + + /// It was a script call: replay it in the background, then return to . + public bool Script { get; set; } + + public string BackUrl { get; set; } } } diff --git a/Web/Resgrid.Web/Areas/User/Views/AccountSecurity/Reauthenticate.cshtml b/Web/Resgrid.Web/Areas/User/Views/AccountSecurity/Reauthenticate.cshtml index 500e3ee5c..24d84c466 100644 --- a/Web/Resgrid.Web/Areas/User/Views/AccountSecurity/Reauthenticate.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/AccountSecurity/Reauthenticate.cshtml @@ -11,6 +11,14 @@ {
@stepUpMessage
} + @if (Model.HoldingSubmission) + { +
@twoFactorLocalizer["StepUpHoldingSubmission"]
+ } + else if (Model.SubmissionNotHeld) + { +
@twoFactorLocalizer["StepUpSubmissionNotHeld"]
+ } @if (Model.PasswordNotAllowed) {
This account signs in through your organization's single sign-on. Sign out and sign in again with single sign-on to continue.
@@ -18,6 +26,7 @@
@Html.AntiForgeryToken() +
Cancel diff --git a/Web/Resgrid.Web/Areas/User/Views/Home/EditUserProfile.cshtml b/Web/Resgrid.Web/Areas/User/Views/Home/EditUserProfile.cshtml index c4a7beb56..a61144b90 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Home/EditUserProfile.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Home/EditUserProfile.cshtml @@ -201,7 +201,7 @@
-
+
@if (!string.IsNullOrWhiteSpace(Model.Profile?.HomeNumber)) { @@ -263,7 +263,7 @@
-
+
@if (!string.IsNullOrWhiteSpace(Model.Profile?.MobileNumber)) { diff --git a/Web/Resgrid.Web/Areas/User/Views/StepUpResume/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/StepUpResume/Index.cshtml new file mode 100644 index 000000000..aaaae8e8b --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/StepUpResume/Index.cshtml @@ -0,0 +1,44 @@ +@model Resgrid.Web.Areas.User.Models.TwoFactor.StepUpResumeViewModel +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["ResumePageTitle"]; + // Posts the held submission back to the action it was made to. The page carries only the hold's id and a fresh antiforgery + // token: the held fields (passwords and secrets included) never come back to the browser. +} + +
+
+
+
+
+ @if (Model.Id != null) + { + + @Html.AntiForgeryToken() + +

@localizer["ResumeSaving"]

+ + + + } + else + { +
@localizer["ResumeUnavailable"]
+ @localizer["ResumeGoBack"] + } +
+
+
+
+
+ +@section Scripts { + @if (Model.Id != null) + { + + } +} diff --git a/Web/Resgrid.Web/Areas/User/Views/TwoFactor/Verify2FA.cshtml b/Web/Resgrid.Web/Areas/User/Views/TwoFactor/Verify2FA.cshtml index 7a1d0df96..cc5294eeb 100644 --- a/Web/Resgrid.Web/Areas/User/Views/TwoFactor/Verify2FA.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/TwoFactor/Verify2FA.cshtml @@ -28,12 +28,21 @@ {
@stepUpMessage
} + @if (Model.HoldingSubmission) + { +
@localizer["StepUpHoldingSubmission"]
+ } + else if (Model.SubmissionNotHeld) + { +
@localizer["StepUpSubmissionNotHeld"]
+ }
@Html.AntiForgeryToken() +
diff --git a/Web/Resgrid.Web/Attributes/RequiresRecentTwoFactorAttribute.cs b/Web/Resgrid.Web/Attributes/RequiresRecentTwoFactorAttribute.cs index aa59a3cb7..132b208b7 100644 --- a/Web/Resgrid.Web/Attributes/RequiresRecentTwoFactorAttribute.cs +++ b/Web/Resgrid.Web/Attributes/RequiresRecentTwoFactorAttribute.cs @@ -2,6 +2,7 @@ using System.Globalization; using System.Security.Claims; using System.Threading.Tasks; +using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; @@ -9,8 +10,10 @@ using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Resgrid.Config; +using Resgrid.Model.Providers; using Resgrid.Model.Services; using Resgrid.Model.TwoFactor; +using Resgrid.Web.Helpers; using IdentityUser = Resgrid.Model.Identity.IdentityUser; namespace Resgrid.Web.Attributes @@ -27,12 +30,20 @@ namespace Resgrid.Web.Attributes /// — redirect to enrollment. /// — redirect to Verify2FA. /// + /// + /// A submission stopped for step-up is not lost: it is held (), Verify2FA returns to a page that + /// posts it back, and the global puts the held form in place before model + /// binding. A script call is answered 403 with the Verify2FA address instead of a redirect it cannot follow. + /// ///
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = false)] public sealed class RequiresRecentTwoFactorAttribute : Attribute, IAsyncActionFilter { internal const string MfaVerifiedAtHttpContextItemKey = "mfa_verified_at"; + /// The response header a script call reads for where to send the user to verify. + public const string StepUpRedirectHeader = "X-Resgrid-Step-Up"; + /// /// Requires MFA for the decorated operation even when the department-wide administrator /// 2FA setting is disabled. @@ -126,12 +137,10 @@ public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionE // The proof is server-side evidence bound to this session and the account's current generation (passkey plan // section 7.6 row 7), so a password change or authenticator change ends it everywhere at once. + var activeDepartmentId = StepUpFormReplay.ActiveDepartmentOf(claimsPrincipal); DateTime? lastStepUpVerifiedAtUtc; try { - int? activeDepartmentId = int.TryParse(claimsPrincipal.FindFirst(ClaimTypes.PrimaryGroupSid)?.Value, out var parsedDepartment) - ? parsedDepartment - : null; lastStepUpVerifiedAtUtc = await Helpers.StepUpEvidence.GetLatestSecondFactorUtcAsync( services.GetService(), identityUser, context.HttpContext, services.GetService(), activeDepartmentId, MethodScope, context.HttpContext.RequestAborted); @@ -176,8 +185,13 @@ public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionE case TwoFactorEnforcementOutcome.StepUpRequired: default: - var returnUrl = ReturnUrlFor(context.HttpContext.Request); - context.Result = new RedirectToRouteResult(new RouteValueDictionary + var request = context.HttpContext.Request; + + // A submission is held so verifying finishes it; only one that cannot be held must be made again. + var (returnUrl, submissionLost) = await StepUpFormReplay.HoldForVerificationAsync(context.HttpContext, + services.GetService(), services.GetService(), identityUser.Id, ReturnUrlFor(request)); + + var verifyRoute = new RouteValueDictionary { { "area", "User" }, { "controller", "TwoFactor" }, @@ -185,7 +199,25 @@ public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionE { "returnUrl", returnUrl }, // A display hint only: Verify2FA offers a passkey where this scope accepts one; this filter still decides. { "scope", MethodScope.ToString() } - }); + }; + if (submissionLost) + verifyRoute["resubmit"] = "1"; + + if (StepUpFormReplay.IsScriptRequest(request)) + { + // A script would follow a redirect and read the Verify2FA page as its answer (a "successful" save that never + // happened). Tell it where the user must go; the page's ajaxError handler navigates there. + var verifyUrl = $"{request.PathBase}/User/TwoFactor/Verify2FA?returnUrl={Uri.EscapeDataString(returnUrl)}" + + $"&scope={Uri.EscapeDataString(MethodScope.ToString())}" + (submissionLost ? "&resubmit=1" : ""); + context.HttpContext.Response.Headers[StepUpRedirectHeader] = verifyUrl; + context.Result = new JsonResult(new { success = false, error = "step_up_required", redirect = verifyUrl }) + { + StatusCode = StatusCodes.Status403Forbidden + }; + return; + } + + context.Result = new RedirectToRouteResult(verifyRoute); return; } } diff --git a/Web/Resgrid.Web/Controllers/HomeController.cs b/Web/Resgrid.Web/Controllers/HomeController.cs index e8095d785..7f9e96792 100644 --- a/Web/Resgrid.Web/Controllers/HomeController.cs +++ b/Web/Resgrid.Web/Controllers/HomeController.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Net; +using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json; using Resgrid.Model; @@ -59,7 +60,7 @@ public IActionResult OpenSource() } [HttpPost, ValidateAntiForgeryToken] - public IActionResult Contact(ContactView model) + public async Task Contact(ContactView model) { CaptchaResponse response = ValidateCaptcha(Request.Form["g-recaptcha-response"]); @@ -73,7 +74,7 @@ public IActionResult Contact(ContactView model) email.From = model.Email; email.Body = model.Message; - _emailService.Notify(email); + await _emailService.Notify(email); model.Result = "Your message has been sent. We will get back to you within 48 hours M-F."; model.Name = String.Empty; diff --git a/Web/Resgrid.Web/Filters/HeldSubmissionReplayFilter.cs b/Web/Resgrid.Web/Filters/HeldSubmissionReplayFilter.cs new file mode 100644 index 000000000..8a4efb183 --- /dev/null +++ b/Web/Resgrid.Web/Filters/HeldSubmissionReplayFilter.cs @@ -0,0 +1,82 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Resgrid.Model.Providers; +using Resgrid.Web.Helpers; +using IdentityUser = Resgrid.Model.Identity.IdentityUser; + +namespace Resgrid.Web.Filters +{ + /// + /// Replays a submission a verification gate held (): when the resume page posts a held + /// submission's id back to the action it was made to, this puts the held fields and files in place as the request's form + /// before model binding, so the action runs on exactly what the user submitted. + /// + /// + /// Global rather than on the gated actions, so no gate (2FA step-up, password re-confirmation, or one added later) can hold a + /// submission whose replay then reaches an action that does not restore it: a post carrying the id is either restored or + /// refused, never bound bare. Only the user, session and active department that made it may replay it, once, to the address + /// it was made to. Posts that cannot be a replay () are passed through unread. + /// + public sealed class HeldSubmissionReplayFilter : IAsyncResourceFilter + { + private readonly ICacheProvider _cacheProvider; + private readonly IDataProtectionProvider _dataProtection; + private readonly UserManager _userManager; + + public HeldSubmissionReplayFilter(ICacheProvider cacheProvider, IDataProtectionProvider dataProtection, UserManager userManager) + { + _cacheProvider = cacheProvider; + _dataProtection = dataProtection; + _userManager = userManager; + } + + public async Task OnResourceExecutionAsync(ResourceExecutingContext context, ResourceExecutionDelegate next) + { + var httpContext = context.HttpContext; + if (!StepUpFormReplay.MayBeReplay(httpContext)) + { + await next(); + return; + } + + // Buffered and rewound, so an action that reads the raw body still sees all of it. + var request = httpContext.Request; + request.EnableBuffering(); + var form = await request.ReadFormAsync(httpContext.RequestAborted); + request.Body.Position = 0; + + var heldId = form[StepUpFormReplay.FieldName].ToString(); + if (string.IsNullOrEmpty(heldId)) + { + await next(); + return; + } + + var held = await StepUpFormReplay.PeekAsync(_cacheProvider, _dataProtection, heldId); + var owned = StepUpFormReplay.BelongsTo(held, _userManager.GetUserId(httpContext.User), + MfaEvidenceSession.KeyFor(httpContext.User, httpContext), StepUpFormReplay.ActiveDepartmentOf(httpContext.User)); + var state = owned && StepUpFormReplay.IsFor(held, request) + ? await StepUpFormReplay.ClaimAsync(_cacheProvider, _dataProtection, heldId) + : null; + + if (state == null) + { + // Never run the action on the bare replay post: its form holds nothing but the id, and binding it would save blanks. + if (StepUpFormReplay.IsScriptRequest(request)) + context.Result = new JsonResult(new { success = false, error = "step_up_replay_unavailable" }) { StatusCode = StatusCodes.Status409Conflict }; + else + context.Result = new RedirectResult(StepUpFormReplay.ResumeUrl(request.PathBase, heldId, owned ? held.Back : null)); + return; + } + + httpContext.Items[StepUpFormReplay.HttpItemKey] = state; + StepUpFormReplay.UseAsRequestForm(httpContext, StepUpFormReplay.Restore(state, form["__RequestVerificationToken"])); + + await next(); + } + } +} diff --git a/Web/Resgrid.Web/Helpers/StepUpFormReplay.cs b/Web/Resgrid.Web/Helpers/StepUpFormReplay.cs new file mode 100644 index 000000000..118c9224c --- /dev/null +++ b/Web/Resgrid.Web/Helpers/StepUpFormReplay.cs @@ -0,0 +1,342 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.Primitives; +using Resgrid.Model.Providers; + +namespace Resgrid.Web.Helpers +{ + /// + /// What someone submitted when a verification gate stopped them (a 2FA step-up from + /// , or a password re-confirmation), held so that verifying finishes + /// the save instead of discarding it. + /// + /// + /// + /// The flow: the gate holds the submission (encrypted, in the shared cache, for ) and sends the user to + /// verify with a return address on . Every way of verifying (code, passkey, Responder approval, + /// provider step-up, password or SSO re-confirmation) already ends by redirecting to that address. The resume page posts back + /// to the original action carrying only and a fresh antiforgery token; the global + /// , running before model binding, swaps the held fields and files in as the + /// request's form. The action then runs exactly as if the first post had been allowed, and its gate checks the new evidence + /// as it always does: replay never skips a verification. + /// + /// + /// Nothing held reaches the browser again, so passwords and secrets in the form are never written into a page. A held + /// submission replays once, only for the user, session and active department that made it, and only to the same address. + /// + /// + public static class StepUpFormReplay + { + /// The form field the resume page posts; it names the held submission. + public const string FieldName = "__stepUpReplay"; + + /// Where verification returns to when a submission is being held. + public const string ResumePath = "/User/StepUpResume"; + + /// + /// The largest post the replay filter will open to look for . The resume page's post is an id and a + /// token, url-encoded; anything bigger, or an upload, is never a replay and is left unread. + /// + public const long MaxReplayPostBytes = 4096; + + /// How long a held submission waits for its verification. + public static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(15); + + /// Submissions larger than these are not held; the guard falls back to sending the user back to resubmit. + public const int MaxFieldCharacters = 1_000_000; + public const long MaxFileBytes = 10L * 1024 * 1024; + + internal const string HttpItemKey = "Resgrid.StepUpFormReplay"; + private const string AntiforgeryFieldName = "__RequestVerificationToken"; + private const string CacheKeyFormat = "StepUpReplay_{0}"; + private const string ClaimKeyFormat = "StepUpReplayClaim_{0}"; + private const string ProtectorPurpose = "Resgrid.Web.StepUpFormReplay.v1"; + + /// + /// Whether this request is a script call (jQuery sets the header) rather than a page navigation: a script cannot follow a + /// redirect to Verify2FA, so it is told where to send the user instead. + /// + public static bool IsScriptRequest(HttpRequest request) => + string.Equals(request.Headers["X-Requested-With"], "XMLHttpRequest", StringComparison.OrdinalIgnoreCase); + + /// + /// Whether the request's body is something the replay can carry: a form, or provably nothing (a script call whose arguments + /// are all in the query). A body of any other kind (JSON, or an unsized stream) is never held as "empty", since replaying + /// it as a blank form would run the action on blanks. + /// + public static bool CanHold(HttpRequest request) => + !HttpMethods.IsGet(request.Method) && !HttpMethods.IsHead(request.Method) && + (request.HasFormContentType || request.ContentLength == 0 || + (request.ContentLength == null && string.IsNullOrEmpty(request.ContentType) && !request.Headers.ContainsKey("Transfer-Encoding"))); + + /// + /// For a gate about to send the user away to verify: where verification should return to, and whether what they submitted + /// was lost. A page request returns to as it always has; a submission is held and returns through + /// the resume page; a submission that cannot be held returns to with SubmissionLost set so + /// the verify page can say to submit again. + /// + public static async Task<(string ReturnUrl, bool SubmissionLost)> HoldForVerificationAsync(HttpContext httpContext, ICacheProvider cache, + IDataProtectionProvider protection, string userId, string back) + { + var request = httpContext.Request; + if (HttpMethods.IsGet(request.Method) || HttpMethods.IsHead(request.Method)) + return (back, false); + + var heldId = await HoldAsync(httpContext, cache, protection, userId, back); + return heldId != null ? (ResumeUrl(request.PathBase, heldId, back), false) : (back, true); + } + + /// + /// Holds this request's submission; returns its id, or null when it cannot be held (too large, a body that is not a form, + /// or the cache is unavailable) and the caller must fall back to asking the user to submit again. + /// + public static async Task HoldAsync(HttpContext httpContext, ICacheProvider cache, IDataProtectionProvider protection, + string userId, string back) + { + var request = httpContext.Request; + if (cache == null || protection == null || string.IsNullOrWhiteSpace(userId) || !CanHold(request)) + return null; + + var state = new StepUpFormReplayState + { + UserId = userId, + // Bound to exactly what the replay filter checks, read the same way. + SessionKey = MfaEvidenceSession.KeyFor(httpContext.User, httpContext), + DepartmentId = ActiveDepartmentOf(httpContext.User), + Target = TargetOf(request), + // A replayed submission that needs verifying again keeps the page the user was really on. + Back = (httpContext.Items[HttpItemKey] as StepUpFormReplayState)?.Back ?? back, + Script = IsScriptRequest(request), + CreatedOnUtc = DateTime.UtcNow + }; + + if (request.HasFormContentType) + { + var form = await request.ReadFormAsync(httpContext.RequestAborted); + + long characters = 0; + foreach (var field in form) + { + if (string.Equals(field.Key, AntiforgeryFieldName, StringComparison.Ordinal) || string.Equals(field.Key, FieldName, StringComparison.Ordinal)) + continue; + + var values = field.Value.ToArray(); + characters += field.Key.Length + values.Sum(v => v?.Length ?? 0); + if (characters > MaxFieldCharacters) + return null; + + state.Fields.Add(new StepUpFormReplayField { Name = field.Key, Values = values }); + } + + long fileBytes = 0; + foreach (var file in form.Files) + { + fileBytes += file.Length; + if (fileBytes > MaxFileBytes) + return null; + + using var buffer = new MemoryStream(); + await using (var stream = file.OpenReadStream()) + await stream.CopyToAsync(buffer, httpContext.RequestAborted); + + state.Files.Add(new StepUpFormReplayFile + { + Name = file.Name, + FileName = file.FileName, + ContentType = file.ContentType, + ContentDisposition = file.ContentDisposition, + Content = buffer.ToArray() + }); + } + } + + var id = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + var payload = protection.CreateProtector(ProtectorPurpose).Protect(JsonSerializer.Serialize(state)); + + try + { + return await cache.SetStringAsync(string.Format(CacheKeyFormat, id), payload, Lifetime) ? id : null; + } + catch (Exception ex) + { + Framework.Logging.LogException(ex, "A submission could not be held for step-up; the user will be asked to submit again."); + return null; + } + } + + /// The held submission, still held (the resume page reads it to know where to post); null when gone or tampered with. + public static async Task PeekAsync(ICacheProvider cache, IDataProtectionProvider protection, string id) + { + if (cache == null || protection == null || !IsWellFormedId(id)) + return null; + + string payload; + try + { + payload = await cache.GetStringAsync(string.Format(CacheKeyFormat, id)); + } + catch (Exception ex) + { + Framework.Logging.LogException(ex, "A held step-up submission could not be read."); + return null; + } + + if (string.IsNullOrWhiteSpace(payload)) + return null; + + try + { + var state = JsonSerializer.Deserialize(protection.CreateProtector(ProtectorPurpose).Unprotect(payload)); + return state != null && DateTime.UtcNow - state.CreatedOnUtc <= Lifetime ? state : null; + } + catch (Exception ex) when (ex is CryptographicException or JsonException or FormatException) + { + return null; + } + } + + /// + /// Takes the held submission for its one replay. The claim is an atomic counter, so a double-clicked Continue or two tabs + /// cannot both replay it; a cache that cannot count refuses rather than risk a second save. + /// + public static async Task ClaimAsync(ICacheProvider cache, IDataProtectionProvider protection, string id) + { + var state = await PeekAsync(cache, protection, id); + if (state == null) + return null; + + try + { + if (await cache.IncrementAsync(string.Format(ClaimKeyFormat, id), Lifetime) != 1) + return null; + + await cache.RemoveAsync(string.Format(CacheKeyFormat, id)); + } + catch (Exception ex) + { + Framework.Logging.LogException(ex, "A held step-up submission could not be claimed."); + return null; + } + + return state; + } + + /// + /// Whether a post could be the resume page's replay, decided from headers alone so nothing else has its body opened: a + /// signed-in user's small url-encoded post. Uploads, large forms, raw bodies and anonymous callers (webhooks) are never + /// replays. + /// + public static bool MayBeReplay(HttpContext httpContext) + { + var request = httpContext.Request; + return HttpMethods.IsPost(request.Method) && + httpContext.User?.Identity?.IsAuthenticated == true && + request.ContentLength is > 0 and <= MaxReplayPostBytes && + request.ContentType != null && + request.ContentType.StartsWith("application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase); + } + + /// Whether the held submission was made by this user, in this session, in this active department. + public static bool BelongsTo(StepUpFormReplayState state, string userId, string sessionKey, int? departmentId) => + state != null && + !string.IsNullOrWhiteSpace(userId) && string.Equals(state.UserId, userId, StringComparison.Ordinal) && + string.Equals(state.SessionKey, sessionKey, StringComparison.Ordinal) && + state.DepartmentId == departmentId; + + /// The signed-in user's active department, from the claim the step-up evidence is scoped to. + public static int? ActiveDepartmentOf(ClaimsPrincipal principal) => + int.TryParse(principal?.FindFirst(ClaimTypes.PrimaryGroupSid)?.Value, out var department) ? department : null; + + /// Whether this request is the replay's own post: the address the submission was made to, and nothing else. + public static bool IsFor(StepUpFormReplayState state, HttpRequest request) => + state != null && string.Equals(state.Target, TargetOf(request), StringComparison.Ordinal); + + /// The held fields and files as a form, carrying the replay request's own (fresh) antiforgery token. + public static IFormCollection Restore(StepUpFormReplayState state, StringValues antiforgeryToken) + { + var fields = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var field in state.Fields ?? new List()) + fields[field.Name] = new StringValues(field.Values ?? Array.Empty()); + + if (!StringValues.IsNullOrEmpty(antiforgeryToken)) + fields[AntiforgeryFieldName] = antiforgeryToken; + + FormFileCollection files = null; + if (state.Files?.Count > 0) + { + files = new FormFileCollection(); + foreach (var held in state.Files) + { + var content = held.Content ?? Array.Empty(); + files.Add(new FormFile(new MemoryStream(content), 0, content.Length, held.Name, held.FileName) + { + Headers = new HeaderDictionary + { + ["Content-Type"] = held.ContentType ?? "application/octet-stream", + ["Content-Disposition"] = held.ContentDisposition ?? $"form-data; name=\"{held.Name}\"; filename=\"{held.FileName}\"" + } + }); + } + } + + return new FormCollection(fields, files); + } + + /// Makes the request's form, so model binding reads the held submission. + public static void UseAsRequestForm(HttpContext httpContext, IFormCollection form) => + httpContext.Features.Set(new FormFeature(form)); + + /// The resume address Verify2FA returns to, carrying the page to fall back to if the hold has expired. + public static string ResumeUrl(string pathBase, string id, string back) => + $"{pathBase}{ResumePath}?id={Uri.EscapeDataString(id)}" + (string.IsNullOrWhiteSpace(back) ? "" : $"&back={Uri.EscapeDataString(back)}"); + + private static string TargetOf(HttpRequest request) => $"{request.PathBase}{request.Path}{request.QueryString}"; + + private static bool IsWellFormedId(string id) => + !string.IsNullOrWhiteSpace(id) && id.Length <= 64 && id.All(c => char.IsAsciiLetterOrDigit(c) || c == '-' || c == '_'); + } + + public sealed class StepUpFormReplayState + { + public string UserId { get; set; } + public string SessionKey { get; set; } + public int? DepartmentId { get; set; } + + /// Path and query the submission was made to; the replay posts here and nowhere else. + public string Target { get; set; } + + /// The local page the user submitted from: where a script call's replay returns, and where an expired hold sends them. + public string Back { get; set; } + + /// A script call (no page navigation): the resume page replays it in the background and returns to . + public bool Script { get; set; } + + public DateTime CreatedOnUtc { get; set; } + public List Fields { get; set; } = new List(); + public List Files { get; set; } = new List(); + } + + public sealed class StepUpFormReplayField + { + public string Name { get; set; } + public string[] Values { get; set; } + } + + public sealed class StepUpFormReplayFile + { + public string Name { get; set; } + public string FileName { get; set; } + public string ContentType { get; set; } + public string ContentDisposition { get; set; } + public byte[] Content { get; set; } + } +} diff --git a/Web/Resgrid.Web/Startup.cs b/Web/Resgrid.Web/Startup.cs index 11eaef46a..9142eab06 100644 --- a/Web/Resgrid.Web/Startup.cs +++ b/Web/Resgrid.Web/Startup.cs @@ -500,6 +500,9 @@ public void ConfigureServices(IServiceCollection services) options.Filters.Add(); // RMS protected content (plan 5.9.3): a refused reveal becomes a step-up prompt, never a 500. options.Filters.Add(); + // A submission held while the user verified (2FA step-up, password re-confirmation) is put back as the form when + // the resume page posts it; a replay post is restored or refused, never bound bare. + options.Filters.Add(); }).AddJsonOptions(jsonOptions => { jsonOptions.JsonSerializerOptions.PropertyNamingPolicy = null; diff --git a/Web/Resgrid.Web/wwwroot/js/app/common/stepup/resgrid.stepup.resume.js b/Web/Resgrid.Web/wwwroot/js/app/common/stepup/resgrid.stepup.resume.js new file mode 100644 index 000000000..e8ccf00d1 --- /dev/null +++ b/Web/Resgrid.Web/wwwroot/js/app/common/stepup/resgrid.stepup.resume.js @@ -0,0 +1,63 @@ +// Finishes a submission the step-up guard held while the user verified (StepUpFormReplay). A page form is posted back as it was; +// a script call is replayed in the background and the user is returned to the page they were on. +(function () { + 'use strict'; + + function showFailure(form) { + form.style.display = 'none'; + var failed = document.getElementById('stepUpResumeFailed'); + if (failed) { failed.style.display = ''; } + } + + function replayScriptCall(form) { + var back = form.getAttribute('data-back') || '/'; + var body = new URLSearchParams(new FormData(form)); + + fetch(form.getAttribute('action'), { + method: 'POST', + body: body, + credentials: 'same-origin', + headers: { 'X-Requested-With': 'XMLHttpRequest' } + }).then(function (response) { + if (!response.ok) { + showFailure(form); + return null; + } + + return response.text().then(function (text) { + var result = null; + try { result = JSON.parse(text); } catch (e) { result = null; } + + if (result && result.success === false) { + showFailure(form); + return; + } + + window.location.replace(back); + }); + }, function () { + showFailure(form); + }); + } + + function resume() { + var form = document.getElementById('stepUpResumeForm'); + if (!form || form.getAttribute('data-sent') === 'true') { return; } + + form.setAttribute('data-sent', 'true'); + var button = form.querySelector('button[type="submit"]'); + if (button) { button.disabled = true; } + + if (form.getAttribute('data-script') === 'true') { + replayScriptCall(form); + } else { + form.submit(); + } + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', resume); + } else { + resume(); + } +})(); diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/resgrid.user.js b/Web/Resgrid.Web/wwwroot/js/app/internal/resgrid.user.js index b4037cd11..98c22ae1d 100644 --- a/Web/Resgrid.Web/wwwroot/js/app/internal/resgrid.user.js +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/resgrid.user.js @@ -32,6 +32,14 @@ var resgrid; } $(document).ajaxError(function (event, jqxhr) { + // A change that needs a recent second factor: the server is holding it, and verifying finishes it + // (RequiresRecentTwoFactorAttribute answers a script call with where to go instead of a redirect). + var stepUp = jqxhr && jqxhr.status === 403 ? jqxhr.getResponseHeader('X-Resgrid-Step-Up') : null; + if (stepUp) { + window.location.assign(stepUp); + return; + } + if (jqxhr && jqxhr.status === 401) { window.location.assign(resgrid.absoluteBaseUrl + '/Account/LogOn?returnUrl=' + encodeURIComponent(window.location.pathname + window.location.search)); } From 6817d00d14f5ce75c4bd8a4a18616a9ccf8b145d Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Fri, 2 Oct 2026 20:15:57 -0700 Subject: [PATCH 2/4] RG-T137 PR #539 fixes --- Core/Resgrid.Config/ConfigProcessor.cs | 39 +++ Core/Resgrid.Framework/StringHelpers.cs | 82 ++++- Core/Resgrid.Services/AuthorizationService.cs | 158 +++++---- Core/Resgrid.Services/CalendarService.cs | 33 +- Core/Resgrid.Services/MappingService.cs | 15 +- Core/Resgrid.Services/TrainingService.cs | 112 +++++- .../UnitStatusAlertsService.cs | 35 +- Docker/docker-compose.yml | 10 +- Docker/resgrid.env | 6 +- .../Migrations/M0001_InitialMigrationPg.cs | 3 + .../Sql/EF0001_PopulateOIDCDb.sql | 16 +- .../App.config | 66 ---- ...gProcessorConnectionStringFallbackTests.cs | 104 ++++++ .../Framework/StringHelperTests.cs | 127 +++++++ .../Services/CalendarServiceCheckInTests.cs | 318 ++++++++++++++++-- .../Services/CalendarServiceTests.cs | 56 +++ .../DocumentDatabaseProviderSelectionTests.cs | 22 ++ .../Services/TrainingServiceTests.cs | 203 ++++++++++- .../Services/UnitStatusAlertsServiceTests.cs | 63 +++- .../TwilioControllerVoiceVerificationTests.cs | 33 ++ .../Commands/MigrateDocsDbCommand.cs | 18 +- .../Controllers/TwilioController.cs | 24 +- .../Controllers/v4/CalendarController.cs | 21 +- .../Controllers/v4/NotesController.cs | 2 +- .../User/Controllers/CalendarController.cs | 127 +++++-- .../User/Controllers/ContactsController.cs | 2 +- .../User/Controllers/DispatchController.cs | 8 + .../User/Controllers/PersonnelController.cs | 6 +- .../User/Controllers/TrainingsController.cs | 81 +++-- .../User/Controllers/TwoFactorController.cs | 4 +- .../Areas/User/Views/Calendar/View.cshtml | 63 ++-- .../Areas/User/Views/Contacts/Index.cshtml | 31 +- .../Views/Dispatch/AddArchivedCall.cshtml | 2 +- .../Areas/User/Views/Dispatch/NewCall.cshtml | 2 +- .../User/Views/Personnel/AddPerson.cshtml | 3 + .../Areas/User/Views/Trainings/Edit.cshtml | 43 ++- .../Areas/User/Views/Trainings/Index.cshtml | 23 +- .../Areas/User/Views/Trainings/New.cshtml | 4 +- .../common/stepup/resgrid.stepup.resume.js | 12 +- .../contacts/resgrid.contacts.index.js | 12 +- .../resgrid.dispatch.addArchivedCall.js | 8 +- .../dispatch/resgrid.dispatch.editcall.js | 8 +- .../dispatch/resgrid.dispatch.newcall.js | 11 +- .../wwwroot/js/app/internal/resgrid.user.js | 7 +- .../training/resgrid.training.edittraining.js | 10 + Workers/Resgrid.Workers.Console/Program.cs | 5 + 46 files changed, 1684 insertions(+), 354 deletions(-) delete mode 100644 Repositories/Resgrid.Repositories.DataRepository/App.config create mode 100644 Tests/Resgrid.Tests/Config/ConfigProcessorConnectionStringFallbackTests.cs diff --git a/Core/Resgrid.Config/ConfigProcessor.cs b/Core/Resgrid.Config/ConfigProcessor.cs index 93eff2b93..87e7fd384 100644 --- a/Core/Resgrid.Config/ConfigProcessor.cs +++ b/Core/Resgrid.Config/ConfigProcessor.cs @@ -61,6 +61,7 @@ public static bool LoadAndProcessConfig(string path = null) } } + ApplyConnectionStringFallback(); return true; } } @@ -127,7 +128,45 @@ public static bool LoadAndProcessEnvVariables(IEnumerable + /// SQL Server installs have long configured only DataConfig.ConnectionString, but the database upgrade + /// (FluentMigrator), identity and claims read DataConfig.CoreConnectionString. Left empty, FluentMigrator + /// silently falls back to its connectionless preview processor: no DDL is applied and the first + /// Exists() check throws NotImplementedException (GitHub #536). On SQL Server, an unset core connection + /// string therefore follows ConnectionString. An explicitly configured value always wins, and because the + /// config file and the environment variables are applied in turn, this re-runs after each and only ever + /// replaces a value it derived itself. + /// + private static void ApplyConnectionStringFallback() + { + var coreIsUnset = String.IsNullOrWhiteSpace(DataConfig.CoreConnectionString) || + (_derivedCoreConnectionString != null && DataConfig.CoreConnectionString == _derivedCoreConnectionString); + + if (!coreIsUnset) + { + _derivedCoreConnectionString = null; + return; + } + + if (DataConfig.DatabaseType == DatabaseTypes.SqlServer && !String.IsNullOrWhiteSpace(DataConfig.ConnectionString)) + { + DataConfig.CoreConnectionString = DataConfig.ConnectionString; + _derivedCoreConnectionString = DataConfig.ConnectionString; + } + else if (_derivedCoreConnectionString != null) + { + // No longer SQL Server (e.g. DatabaseType switched to Postgres later in loading): PostgreSQL needs an + // explicit core connection string, so don't leave the SQL Server one behind. + DataConfig.CoreConnectionString = ""; + _derivedCoreConnectionString = null; + } + } } } diff --git a/Core/Resgrid.Framework/StringHelpers.cs b/Core/Resgrid.Framework/StringHelpers.cs index 5caee30e6..ec477286c 100644 --- a/Core/Resgrid.Framework/StringHelpers.cs +++ b/Core/Resgrid.Framework/StringHelpers.cs @@ -8,6 +8,7 @@ using System.Reflection; using System.Text; using System.Text.RegularExpressions; +using HtmlAgilityPack; using Vereyon.Web; namespace Resgrid.Framework @@ -82,24 +83,16 @@ public static string SanitizeHtmlInString(string source) string temp = source; // get rid of unnecessary tag spans (comments and title) - sc.Add(@""); - sc.Add(@"(w|W)+?"); + sc.Add(@""); + sc.Add(@"[\s\S]*?"); - // Get rid of classes and styles - sc.Add(@"s?class=w+"); - sc.Add(@"s+style='[^']+'"); + // Get rid of unnecessary tags but keep the text they wrap (document wrappers, Word's and smart tags) + sc.Add(@"<(meta|link|/?o:|/?style|/?st\d|/?head|/?html|body|/?body)[^>]*?>"); - // Get rid of unnecessary tags - sc.Add(@"<(meta|link|/?o:|/?style|/?std|/?head|/?html|body|/?body)[^>]*?>"); - - // Get rid of empty paragraph tags - sc.Add(@"(<[^>]+>)+ ()+"); - - // remove bizarre v: element attached to tag - sc.Add(@"s+v:w+=""[^""]+"""); - - // remove extra lines - sc.Add(@"(nr){2,}"); + // The rest of the classic Word clean-up list (class=, style='', v: attributes,  -only tags, repeated line + // breaks) is intentionally not applied: the sanitizer below already drops every attribute it does not allow, and + // those patterns ran over the whole string, so they deleted matching user text too. With their backslashes lost + // they were stripping "nr", "s" and "w" sequences out of ordinary words (#181). foreach (string s in sc) { @@ -118,6 +111,7 @@ public static string SanitizeHtmlInString(string source) sanitizer.Tag("h3").RemoveEmpty(); sanitizer.Tag("h4").RemoveEmpty(); sanitizer.Tag("h5").RemoveEmpty(); + sanitizer.Tag("h6").RemoveEmpty(); sanitizer.Tag("strong").RemoveEmpty(); sanitizer.Tag("b").Rename("strong").RemoveEmpty(); sanitizer.Tag("div").Rename("p").RemoveEmpty(); @@ -130,10 +124,29 @@ public static string SanitizeHtmlInString(string source) sanitizer.Tag("ul"); sanitizer.Tag("ol"); sanitizer.Tag("li"); - sanitizer.Tag("img").CheckAttribute("src", HtmlSanitizerCheckType.Url) + + // Formatting the rich text editors (Quill) produce. The sanitizer runs in white list mode, where an unlisted + // tag is removed together with its text, so underlined words, pasted links (usually wrapped in ) and code + // blocks vanished on save (#181). None of these get any attributes. + sanitizer.Tag("u").RemoveEmpty(); + sanitizer.Tag("ins").Rename("u").RemoveEmpty(); + sanitizer.Tag("s").RemoveEmpty(); + sanitizer.Tag("strike").Rename("s").RemoveEmpty(); + sanitizer.Tag("del").Rename("s").RemoveEmpty(); + sanitizer.Tag("sub").RemoveEmpty(); + sanitizer.Tag("sup").RemoveEmpty(); + sanitizer.Tag("blockquote").RemoveEmpty(); + sanitizer.Tag("pre").RemoveEmpty(); + sanitizer.Tag("code").RemoveEmpty(); + + // Presentational and structural wrappers from pasted Word, e-mail and web content: drop the tag, keep its text. + foreach (var tag in FlattenedHtmlTags) + sanitizer.Tag(tag).Operation(SanitizerOperation.FlattenTag); + + sanitizer.Tag("img").SanitizeAttributes("src", new SpaceEncodingUrlAttributeSanitizer()) .RemoveEmpty(); sanitizer.Tag("a").SetAttribute("rel", "nofollow") - .CheckAttribute("href", HtmlSanitizerCheckType.Url) + .SanitizeAttributes("href", new SpaceEncodingUrlAttributeSanitizer()) .RemoveEmpty(); string cleanHtml = sanitizer.Sanitize(source); @@ -145,6 +158,39 @@ public static string SanitizeHtmlInString(string source) return "Invalid HTML in Source String"; } + /// + /// Tags unwraps (tag removed, text kept) instead of deleting with their content. + /// + private static readonly string[] FlattenedHtmlTags = + { + "font", "center", "small", "big", "mark", "abbr", "cite", "q", "tt", "kbd", "samp", "var", "time", + "section", "article", "header", "footer", "main", "address", "figure", "figcaption" + }; + + /// + /// Link/image URL check used by . Runs the sanitizer's standard URL check (same + /// http/https/mailto/tel allow-list, so javascript:, data: etc. are still rejected) but percent-encodes interior + /// spaces first, so a link to "some file.pdf" is kept instead of dropped. The check writes the URL back unescaped, + /// so the spaces are re-encoded afterwards; otherwise "%20" became a space and the link was lost on the next save. + /// + private sealed class SpaceEncodingUrlAttributeSanitizer : IHtmlAttributeSanitizer + { + private readonly UrlCheckerAttributeSanitizer _urlChecker = new UrlCheckerAttributeSanitizer(); + + public SanitizerOperation SanitizeAttribute(HtmlAttribute attribute, HtmlSanitizerTagRule tagRule) + { + if (!string.IsNullOrEmpty(attribute.Value)) + attribute.Value = attribute.Value.Trim().Replace(" ", "%20"); + + var operation = _urlChecker.SanitizeAttribute(attribute, tagRule); + + if (operation == SanitizerOperation.DoNothing && !string.IsNullOrEmpty(attribute.Value)) + attribute.Value = attribute.Value.Replace(" ", "%20"); + + return operation; + } + } + public static string Truncate(this string value, int maxLength) { return value.Length <= maxLength ? value : value.Substring(0, maxLength); diff --git a/Core/Resgrid.Services/AuthorizationService.cs b/Core/Resgrid.Services/AuthorizationService.cs index 1171af645..3fb9d4b4b 100644 --- a/Core/Resgrid.Services/AuthorizationService.cs +++ b/Core/Resgrid.Services/AuthorizationService.cs @@ -1623,13 +1623,16 @@ public async Task CanUserModifyDepartmentAsync(string userId, int departme public async Task CanUserCheckInToCalendarEventAsync(string userId, int calendarItemId) { - var department = await _departmentsService.GetDepartmentByUserIdAsync(userId); var item = await _calendarService.GetCalendarItemByIdAsync(calendarItemId); - if (department == null || item == null) + if (item == null || string.IsNullOrWhiteSpace(userId)) return false; - if (item.DepartmentId != department.DepartmentId) + // The event's own department, with its full roster. GetDepartmentByUserIdAsync returns whichever + // department the user's active/default membership lands on first, and only the caller's row. + var department = await _departmentsService.GetDepartmentByIdAsync(item.DepartmentId); + + if (!IsCurrentCalendarDepartmentMember(department, userId)) return false; // Check-in disabled for this event @@ -1643,10 +1646,10 @@ public async Task CanUserCheckInToCalendarEventAsync(string userId, int ca // Admin-only mode: only creator, dept admin, or group admin can perform check-ins if (item.CheckInType == (int)CalendarItemCheckInTypes.AdminOnly) { - if (department.IsUserAnAdmin(userId)) + if (IsCalendarDepartmentAdmin(department, userId)) return true; - if (!string.IsNullOrWhiteSpace(item.CreatorUserId) && item.CreatorUserId == userId) + if (IsCalendarItemCreator(item, userId)) return true; var group = await _departmentGroupsService.GetGroupForUserAsync(userId, department.DepartmentId); @@ -1661,103 +1664,138 @@ public async Task CanUserCheckInToCalendarEventAsync(string userId, int ca public async Task CanUserAdminCheckInCalendarEventAsync(string userId, int calendarItemId, string targetUserId) { - var department = await _departmentsService.GetDepartmentByUserIdAsync(userId); var item = await _calendarService.GetCalendarItemByIdAsync(calendarItemId); - if (department == null || item == null) - return false; - - if (item.DepartmentId != department.DepartmentId) + if (item == null || string.IsNullOrWhiteSpace(userId) || string.IsNullOrWhiteSpace(targetUserId)) return false; // Check-in disabled for this event if (item.CheckInType == (int)CalendarItemCheckInTypes.Disabled) return false; - // Department admins can check in users in their department - if (department.IsUserAnAdmin(userId)) - { - if (department.IsUserInDepartment(targetUserId)) - return true; + // The event's department with every member row: the target has to be found in it, and the + // target need not be an attendee. (GetDepartmentByUserIdAsync carries the caller's row only, + // so every target looked like a non-member.) + var department = await _departmentsService.GetDepartmentByIdAsync(item.DepartmentId); + if (!IsCurrentCalendarDepartmentMember(department, userId)) return false; - } - - // Calendar item creator can check in users in their department - if (!string.IsNullOrWhiteSpace(item.CreatorUserId) && item.CreatorUserId == userId) - { - if (department.IsUserInDepartment(targetUserId)) - return true; + // Removed and disabled members cannot be checked in + if (!IsCurrentCalendarDepartmentMember(department, targetUserId)) return false; - } - // Group admins can check in users in their group or child groups - var adminGroup = await _departmentGroupsService.GetGroupForUserAsync(userId, department.DepartmentId); - if (adminGroup != null && adminGroup.IsUserGroupAdmin(userId)) - { - // Check if the target user is in the admin's group - if (adminGroup.IsUserInGroup(targetUserId)) - return true; + // Department admins can check in users in their department + if (IsCalendarDepartmentAdmin(department, userId)) + return true; - // Check child groups - var childGroups = await _departmentGroupsService.GetAllChildDepartmentGroupsAsync(adminGroup.DepartmentGroupId); - if (childGroups != null) - { - foreach (var childGroup in childGroups) - { - if (childGroup.IsUserInGroup(targetUserId)) - return true; - } - } - } + // Calendar item creator can check in users in their department + if (IsCalendarItemCreator(item, userId)) + return true; - return false; + // Group admins can check in users in their group or child groups + return await IsUserInCalendarAdminGroupScopeAsync(userId, department.DepartmentId, targetUserId); } public async Task CanUserEditCalendarCheckInAsync(string userId, string checkInId) { - var department = await _departmentsService.GetDepartmentByUserIdAsync(userId); var checkIn = await _calendarService.GetCheckInByIdAsync(checkInId); - if (department == null || checkIn == null) + if (checkIn == null || string.IsNullOrWhiteSpace(userId)) return false; - if (checkIn.DepartmentId != department.DepartmentId) + var department = await _departmentsService.GetDepartmentByIdAsync(checkIn.DepartmentId); + + if (!IsCurrentCalendarDepartmentMember(department, userId)) return false; - if (checkIn.UserId == userId) + if (string.Equals(checkIn.UserId, userId, StringComparison.OrdinalIgnoreCase)) return true; - if (department.IsUserAnAdmin(userId)) + if (IsCalendarDepartmentAdmin(department, userId)) return true; // Calendar item creator can edit check-ins var item = await _calendarService.GetCalendarItemByIdAsync(checkIn.CalendarItemId); - if (item != null && !string.IsNullOrWhiteSpace(item.CreatorUserId) && item.CreatorUserId == userId) + if (item != null && IsCalendarItemCreator(item, userId)) return true; // Group admins can edit check-ins for their group members - var adminGroup = await _departmentGroupsService.GetGroupForUserAsync(userId, department.DepartmentId); - if (adminGroup != null && adminGroup.IsUserGroupAdmin(userId)) - { - if (adminGroup.IsUserInGroup(checkIn.UserId)) - return true; + return await IsUserInCalendarAdminGroupScopeAsync(userId, department.DepartmentId, checkIn.UserId); + } + + /// + /// Whether the user holds a current membership (not removed, not disabled) in the department. The + /// managing user always counts, as in . + /// + private static bool IsCurrentCalendarDepartmentMember(Department department, string userId) + { + if (department == null || string.IsNullOrWhiteSpace(userId)) + return false; + + if (string.Equals(department.ManagingUserId, userId, StringComparison.OrdinalIgnoreCase)) + return true; - var childGroups = await _departmentGroupsService.GetAllChildDepartmentGroupsAsync(adminGroup.DepartmentGroupId); - if (childGroups != null) + return department.Members != null && department.Members.Any(m => + string.Equals(m.UserId, userId, StringComparison.OrdinalIgnoreCase) + && DepartmentMemberStateHelper.IsCurrentMember(m, department.DepartmentId)); + } + + /// + /// Admin test over current rows only: the full roster also carries removed rows, and removal leaves + /// IsAdmin set. + /// + private static bool IsCalendarDepartmentAdmin(Department department, string userId) + { + if (department == null || string.IsNullOrWhiteSpace(userId)) + return false; + + if (string.Equals(department.ManagingUserId, userId, StringComparison.OrdinalIgnoreCase)) + return true; + + return department.Members != null && department.Members.Any(m => + m.IsAdmin.GetValueOrDefault() + && string.Equals(m.UserId, userId, StringComparison.OrdinalIgnoreCase) + && DepartmentMemberStateHelper.IsCurrentMember(m, department.DepartmentId)); + } + + private static bool IsCalendarItemCreator(CalendarItem item, string userId) + { + return !string.IsNullOrWhiteSpace(item.CreatorUserId) + && string.Equals(item.CreatorUserId, userId, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Whether the user is a group admin and the target is a member of that group or one of its child groups. + /// + private async Task IsUserInCalendarAdminGroupScopeAsync(string userId, int departmentId, string targetUserId) + { + var adminGroup = await _departmentGroupsService.GetGroupForUserAsync(userId, departmentId); + if (adminGroup == null || !adminGroup.IsUserGroupAdmin(userId)) + return false; + + if (IsUserInCalendarGroup(adminGroup, targetUserId)) + return true; + + var childGroups = await _departmentGroupsService.GetAllChildDepartmentGroupsAsync(adminGroup.DepartmentGroupId); + if (childGroups != null) + { + foreach (var childGroup in childGroups) { - foreach (var childGroup in childGroups) - { - if (childGroup.IsUserInGroup(checkIn.UserId)) - return true; - } + if (IsUserInCalendarGroup(childGroup, targetUserId)) + return true; } } return false; } + private static bool IsUserInCalendarGroup(DepartmentGroup group, string userId) + { + return group?.Members != null + && group.Members.Any(m => string.Equals(m.UserId, userId, StringComparison.OrdinalIgnoreCase)); + } + public async Task CanUserDeleteCalendarCheckInAsync(string userId, string checkInId) { var department = await _departmentsService.GetDepartmentByUserIdAsync(userId); diff --git a/Core/Resgrid.Services/CalendarService.cs b/Core/Resgrid.Services/CalendarService.cs index feb7b86ce..f2165c605 100644 --- a/Core/Resgrid.Services/CalendarService.cs +++ b/Core/Resgrid.Services/CalendarService.cs @@ -273,6 +273,7 @@ public async Task GetCalendarItemTypeByIdAsync(int calendarIte calendarItem.IsAllDay = item.IsAllDay; calendarItem.ItemType = item.ItemType; calendarItem.SignupType = item.SignupType; + calendarItem.CheckInType = item.CheckInType; calendarItem.Public = item.Public; calendarItem.StartTimezone = timeZone; calendarItem.EndTimezone = timeZone; @@ -322,9 +323,10 @@ public async Task GetCalendarItemTypeByIdAsync(int calendarIte calItem.IsAllDay = saved.IsAllDay; calItem.ItemType = saved.ItemType; calItem.SignupType = saved.SignupType; + calItem.CheckInType = saved.CheckInType; calItem.Public = saved.Public; - calendarItem.StartTimezone = timeZone; - calendarItem.EndTimezone = timeZone; + calItem.StartTimezone = timeZone; + calItem.EndTimezone = timeZone; var saved2 = await SaveCalendarItemAsync(calItem, cancellationToken); } @@ -594,11 +596,17 @@ public async Task NotifyNewCalendarItemAsync(CalendarItem calendarItem) if (ConfigHelper.CanTransmit(department.DepartmentId)) { + // Notifications address active members only: removed, disabled and hidden memberships are skipped. + var activeUserIds = await GetActiveMemberUserIdsAsync(calendarItem.DepartmentId); + if (items.Any(x => x.StartsWith("D:"))) { // Notify the entire department foreach (var profile in profiles) { + if (!activeUserIds.Contains(profile.Key)) + continue; + await SendCalendarPromptAsync(calendarItem, profile.Key, message, departmentNumber, title, profile.Value, department); } } @@ -616,6 +624,9 @@ public async Task NotifyNewCalendarItemAsync(CalendarItem calendarItem) { foreach (var member in group.Members) { + if (!activeUserIds.Contains(member.UserId)) + continue; + if (profiles.ContainsKey(member.UserId)) await SendCalendarPromptAsync(calendarItem, member.UserId, message, departmentNumber, title, profiles[member.UserId], department); else @@ -655,8 +666,14 @@ public async Task NotifyUsersAboutCalendarItemAsync(CalendarItem calendarI if (ConfigHelper.CanTransmit(department.DepartmentId)) { + var activeUserIds = await GetActiveMemberUserIdsAsync(calendarItem.DepartmentId); + foreach (var userId in userIds) { + // Removed, disabled and hidden members are not notified. + if (!activeUserIds.Contains(userId)) + continue; + if (profiles.ContainsKey(userId)) await SendCalendarPromptAsync(calendarItem, userId, message, departmentNumber, title, profiles[userId], department); else @@ -667,6 +684,13 @@ public async Task NotifyUsersAboutCalendarItemAsync(CalendarItem calendarI return true; } + /// The department's active member ids (case-insensitive); never null. + private async Task> GetActiveMemberUserIdsAsync(int departmentId) + { + return await _departmentsService.GetActiveMemberUserIdsAsync(departmentId) + ?? new HashSet(StringComparer.OrdinalIgnoreCase); + } + private async System.Threading.Tasks.Task SendCalendarPromptAsync(CalendarItem calendarItem, string userId, string message, string departmentNumber, string title, UserProfile profile, Department department) { @@ -877,6 +901,9 @@ public async Task CheckInToEventAsync(int calendarItemId, s if (calendarItem == null) return null; + // A string-keyed entity with its id already set looks like an existing row to SaveOrUpdateAsync, + // which then runs an UPDATE that matches nothing and the check-in is silently lost. This is + // always a new row, so insert it explicitly. var checkIn = new CalendarItemCheckIn { CalendarItemCheckInId = Guid.NewGuid().ToString(), @@ -892,7 +919,7 @@ public async Task CheckInToEventAsync(int calendarItemId, s Timestamp = DateTime.UtcNow }; - var saved = await _calendarItemCheckInRepository.SaveOrUpdateAsync(checkIn, cancellationToken); + var saved = await _calendarItemCheckInRepository.InsertAsync(checkIn, cancellationToken); return saved; } diff --git a/Core/Resgrid.Services/MappingService.cs b/Core/Resgrid.Services/MappingService.cs index 8fd61d4e5..5db751634 100644 --- a/Core/Resgrid.Services/MappingService.cs +++ b/Core/Resgrid.Services/MappingService.cs @@ -180,9 +180,20 @@ public async Task> GetMapLayersForTypeDepartmentAsync(int departm } else { - var layers = await _mapLayersRepository.Value.FilterByAsync(filter => filter.DepartmentId == departmentId && filter.Type == (int)type && filter.IsDeleted == false); + // Layers are an optional overlay on every map page. An unreachable or misconfigured Mongo (common on + // self-hosted installs) must not take the whole map down with it (GitHub #243): log it and draw the + // map without layers. + try + { + var layers = await _mapLayersRepository.Value.FilterByAsync(filter => filter.DepartmentId == departmentId && filter.Type == (int)type && filter.IsDeleted == false); - return layers.ToList(); + return layers.ToList(); + } + catch (Exception ex) + { + Framework.Logging.LogException(ex, $"Unable to read map layers for department {departmentId} from the document database."); + return new List(); + } } } diff --git a/Core/Resgrid.Services/TrainingService.cs b/Core/Resgrid.Services/TrainingService.cs index f559c89c4..7491ada8b 100644 --- a/Core/Resgrid.Services/TrainingService.cs +++ b/Core/Resgrid.Services/TrainingService.cs @@ -20,11 +20,12 @@ public class TrainingService : ITrainingService private readonly ITrainingUserRepository _trainingUserRepository; private readonly ICommunicationService _communicationService; private readonly IDepartmentsService _departmentService; + private readonly IDepartmentSettingsService _departmentSettingsService; private readonly Lazy _searchProjections; public TrainingService(ITrainingRepository trainingRepository, ITrainingAttachmentRepository trainingAttachmentRepository, ITrainingUserRepository trainingUserRepository, ITrainingQuestionRepository trainingQuestionRepository, ICommunicationService communicationService, IDepartmentsService departmentService, - Lazy searchProjections = null) + IDepartmentSettingsService departmentSettingsService, Lazy searchProjections = null) { _searchProjections = searchProjections; _trainingRepository = trainingRepository; @@ -33,6 +34,7 @@ public TrainingService(ITrainingRepository trainingRepository, ITrainingAttachme _trainingQuestionRepository = trainingQuestionRepository; _communicationService = communicationService; _departmentService = departmentService; + _departmentSettingsService = departmentSettingsService; } public async Task> GetAllTrainingsForDepartmentAsync(int departmentId) @@ -52,8 +54,22 @@ public async Task> GetAllTrainingsForDepartmentAsync(int departme training.Description = StringHelpers.SanitizeHtmlInString(training.Description); training.TrainingText = StringHelpers.SanitizeHtmlInString(training.TrainingText); + // Questions are saved separately from the training row, so on an update reconcile them with what is stored + // before writing anything (#180): posted questions that carry their id are updated in place, removed ones are + // deleted. Re-adding every question as a new row duplicated the quiz on each edit and inflated the score divisor. + List removedQuestions = null; + if (training.TrainingId > 0 && training.Questions != null) + removedQuestions = await ReconcileQuestionsAsync(training); + var saved = await _trainingRepository.SaveOrUpdateAsync(training, cancellationToken, true); + if (removedQuestions != null) + { + // TrainingQuestionAnswers cascade on delete. Quiz results only keep the score, so nothing references these ids. + foreach (var removedQuestion in removedQuestions) + await _trainingQuestionRepository.DeleteAsync(removedQuestion, cancellationToken); + } + if (saved.Questions != null && saved.Questions.Any()) { var questions = saved.Questions.ToList(); @@ -94,6 +110,75 @@ public async Task> GetAllTrainingsForDepartmentAsync(int departme return saved; } + /// + /// Matches the questions on an existing training against the stored ones and returns the stored questions that are + /// no longer present (to delete). Question/answer ids that do not belong to this training (or question) are cleared + /// so they are inserted as new rows instead of overwriting someone else's. The repository only removes an updated + /// question's dropped answers when at least one stored answer id survives, so a question whose answers were all + /// removed or replaced is recreated rather than updated. + /// + private async Task> ReconcileQuestionsAsync(Training training) + { + var storedQuestions = (await _trainingQuestionRepository.GetTrainingQuestionsByTrainingIdAsync(training.TrainingId))?.ToList(); + + // The question read swallows errors and returns null; saving without it would duplicate every question again. + if (storedQuestions == null) + throw new InvalidOperationException($"Unable to load the existing questions for training {training.TrainingId}."); + + var keptQuestionIds = new HashSet(); + + foreach (var question in training.Questions) + { + var storedQuestion = question.TrainingQuestionId > 0 && !keptQuestionIds.Contains(question.TrainingQuestionId) + ? storedQuestions.FirstOrDefault(x => x.TrainingQuestionId == question.TrainingQuestionId) + : null; + + if (storedQuestion == null) + { + ClearQuestionIds(question); + continue; + } + + var storedAnswerIds = new HashSet(storedQuestion.Answers?.Select(x => x.TrainingQuestionAnswerId) ?? Enumerable.Empty()); + var keptAnswerIds = new HashSet(); + + if (question.Answers != null) + { + foreach (var answer in question.Answers) + { + if (answer.TrainingQuestionAnswerId > 0 && storedAnswerIds.Contains(answer.TrainingQuestionAnswerId) && keptAnswerIds.Add(answer.TrainingQuestionAnswerId)) + continue; + + answer.TrainingQuestionAnswerId = 0; + } + } + + if (storedAnswerIds.Count > 0 && keptAnswerIds.Count == 0) + { + ClearQuestionIds(question); + continue; + } + + keptQuestionIds.Add(question.TrainingQuestionId); + } + + return storedQuestions.Where(x => !keptQuestionIds.Contains(x.TrainingQuestionId)).ToList(); + } + + private static void ClearQuestionIds(TrainingQuestion question) + { + question.TrainingQuestionId = 0; + + if (question.Answers != null) + { + foreach (var answer in question.Answers) + { + answer.TrainingQuestionAnswerId = 0; + answer.TrainingQuestionId = 0; + } + } + } + public async Task GetTrainingByIdAsync(int trainingId) { var training = await _trainingRepository.GetTrainingByTrainingIdAsync(trainingId); @@ -183,18 +268,23 @@ public async Task GetTrainingAttachmentByIdAsync(int trainin public async Task SendInitialTrainingNoticeAsync(Training training) { - foreach (var user in training.Users) - { - var message = String.Empty; - if (training.ToBeCompletedBy.HasValue) - message = string.Format("Training ({0}) due on {1}", training.Name, - training.ToBeCompletedBy.Value.ToShortDateString()); - else - message = string.Format("Training ({0}) assigned to you", training.Name); + if (training?.Users == null || !training.Users.Any() || !ConfigHelper.CanTransmit(training.DepartmentId)) + return false; + + // Same wording, title and sending number as the notifier worker's first notice (TrainingNotifierLogic). + var message = String.Empty; + if (training.ToBeCompletedBy.HasValue) + message = string.Format("New Training ({0}) due on {1}", training.Name, + training.ToBeCompletedBy.Value.ToShortDateString()); + else + message = string.Format("New Training ({0}) assigned to you", training.Name); - var department = await _departmentService.GetDepartmentByIdAsync(training.DepartmentId); + var department = await _departmentService.GetDepartmentByIdAsync(training.DepartmentId, false); + var departmentNumber = await _departmentSettingsService.GetTextToCallNumberForDepartmentAsync(training.DepartmentId); - await _communicationService.SendNotificationAsync(user.UserId, training.DepartmentId, message, "New Training", department); + foreach (var user in training.Users) + { + await _communicationService.SendNotificationAsync(user.UserId, training.DepartmentId, message, departmentNumber, department, "New Training Notice"); } return true; diff --git a/Core/Resgrid.Services/UnitStatusAlertsService.cs b/Core/Resgrid.Services/UnitStatusAlertsService.cs index d93000a8e..e7acd031c 100644 --- a/Core/Resgrid.Services/UnitStatusAlertsService.cs +++ b/Core/Resgrid.Services/UnitStatusAlertsService.cs @@ -7,6 +7,7 @@ using Resgrid.Model.Events; using Resgrid.Model.Providers; using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Queries; using Resgrid.Model.Services; namespace Resgrid.Services @@ -24,15 +25,18 @@ public class UnitStatusAlertsService : IUnitStatusAlertsService private readonly ICustomStateService _customStateService; private readonly IDepartmentSettingsService _departmentSettingsService; private readonly IEventAggregator _eventAggregator; + private readonly IUnitOfWork _unitOfWork; public UnitStatusAlertsService(IUnitStatusAlertAcknowledgementsRepository acknowledgementsRepository, IUnitsService unitsService, - ICustomStateService customStateService, IDepartmentSettingsService departmentSettingsService, IEventAggregator eventAggregator) + ICustomStateService customStateService, IDepartmentSettingsService departmentSettingsService, IEventAggregator eventAggregator, + IUnitOfWork unitOfWork) { _acknowledgementsRepository = acknowledgementsRepository; _unitsService = unitsService; _customStateService = customStateService; _departmentSettingsService = departmentSettingsService; _eventAggregator = eventAggregator; + _unitOfWork = unitOfWork; } public async Task> GetCurrentAcknowledgementsForDepartmentAsync(int departmentId) @@ -104,14 +108,22 @@ public async Task AcknowledgeAsync(int dep AcknowledgedOn = now }; - await ClearActiveAsync(departmentId, unitId, unitStateId, userId, now, cancellationToken); + // The clear and the insert are one transaction, so a failed insert puts the earlier acknowledgement back + // instead of leaving the episode with none. + await _unitOfWork.CreateOrGetConnectionAsync(cancellationToken); try { + await ClearActiveAsync(departmentId, unitId, unitStateId, userId, now, cancellationToken); await _acknowledgementsRepository.InsertAsync(acknowledgement, cancellationToken); + + _unitOfWork.CommitChanges(); } - catch (Exception) + catch (Exception ex) when (IsUniqueViolation(ex)) { + // Rolled back before reading: PostgreSQL refuses every statement in a transaction that has failed. + _unitOfWork.DiscardChanges(); + // The filtered unique index allows one uncleared row per episode. If another dispatcher's insert got // in between our clear and our insert, theirs stands; hand it back so the board can show it. var winner = (await _acknowledgementsRepository.GetActiveForUnitStateAsync(departmentId, unitId, unitStateId))?.FirstOrDefault(); @@ -121,6 +133,11 @@ public async Task AcknowledgeAsync(int dep return UnitStatusAlertAcknowledgementResult.Fail(UnitStatusAlertAcknowledgementResult.Conflict, winner); } + catch + { + _unitOfWork.DiscardChanges(); + throw; + } await NotifyAsync(departmentId, unitId); @@ -183,8 +200,18 @@ private async Task NotifyAsync(int departmentId, int unitId) } catch (Exception ex) { - Framework.Logging.LogException(ex); + Framework.Logging.LogException(ex, $"Unit status alert update could not be published for department {departmentId}, unit {unitId}."); } } + + /// PostgreSQL 23505 or SQL Server 2601/2627: the only insert failure that means another dispatcher acknowledged first. + private static bool IsUniqueViolation(Exception ex) + { + if (ex is Npgsql.PostgresException postgres) + return postgres.SqlState == "23505"; + if (ex is Microsoft.Data.SqlClient.SqlException sql) + return sql.Number == 2601 || sql.Number == 2627; + return false; + } } } diff --git a/Docker/docker-compose.yml b/Docker/docker-compose.yml index 6d426c085..bd472be17 100644 --- a/Docker/docker-compose.yml +++ b/Docker/docker-compose.yml @@ -2,7 +2,7 @@ version: '3.9' services: web: - image: "resgridllc/resgridwebcore:0.6.70" + image: "resgridllc/resgridwebcore:${RESGRID_VERSION:-latest}" ports: - "5151:80" env_file: @@ -24,7 +24,7 @@ services: - WAIT_TIMEOUT=300 api: - image: "resgridllc/resgridwebservices:0.6.70" + image: "resgridllc/resgridwebservices:${RESGRID_VERSION:-latest}" ports: - "5152:80" env_file: @@ -45,7 +45,7 @@ services: - WAIT_TIMEOUT=300 events: - image: "resgridllc/resgridwebevents:0.6.70" + image: "resgridllc/resgridwebevents:${RESGRID_VERSION:-latest}" ports: - "5153:80" env_file: @@ -64,7 +64,7 @@ services: - WAIT_TIMEOUT=300 worker: - image: "resgridllc/resgridworkersconsole:0.6.70" + image: "resgridllc/resgridworkersconsole:${RESGRID_VERSION:-latest}" env_file: - resgrid.env restart: always @@ -83,7 +83,7 @@ services: tracker-gateway: profiles: ["tracking"] - image: "resgridllc/resgridtrackergateway:0.6.70" + image: "resgridllc/resgridtrackergateway:${RESGRID_VERSION:-latest}" env_file: - resgrid.env restart: always diff --git a/Docker/resgrid.env b/Docker/resgrid.env index 968c5a511..fca93eaa6 100644 --- a/Docker/resgrid.env +++ b/Docker/resgrid.env @@ -59,7 +59,11 @@ RESGRID__CacheConfig__RedisConnectionString=redis:6379,Password=,allowAdmin=True # Microsoft SQL Server Connection String for main Resgrid Database RESGRID__DataConfig__ConnectionString=Server=db;Database=Resgrid;User Id=sa;Password=Resgrid123!!;MultipleActiveResultSets=True;TrustServerCertificate=True;Connection Timeout=30; -RESGRID__DataConfig__NoSqlConnectionString=mongodb://resgridUser:Resgrid123!!@192.168.1.67:27017 +# Database engine: 0 = Microsoft SQL Server, 1 = PostgreSQL +RESGRID__DataConfig__DatabaseType=0 +# Core connection string used by the database upgrade (migrations), identity and claims. Keep it the same as ConnectionString. +RESGRID__DataConfig__CoreConnectionString=Server=db;Database=Resgrid;User Id=sa;Password=Resgrid123!!;MultipleActiveResultSets=True;TrustServerCertificate=True;Connection Timeout=30; +RESGRID__DataConfig__NoSqlConnectionString=mongodb://resgridUser:Resgrid123!!@mongodb:27017 # ----------------------------------------------- # --- Mapping Geolocation Config Section -------- diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0001_InitialMigrationPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0001_InitialMigrationPg.cs index 7954aafd2..c1f43f8f6 100644 --- a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0001_InitialMigrationPg.cs +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0001_InitialMigrationPg.cs @@ -7,6 +7,9 @@ public class M0001_InitialMigrationPg : Migration { public override void Up() { + // The schema uses citext for user ids, names and e-mail; a fresh database failed here without it (GitHub #536). + // citext is a trusted extension (PostgreSQL 13+), so the database owner can create it. + Execute.Sql("CREATE EXTENSION IF NOT EXISTS citext;"); Execute.EmbeddedScript("Resgrid.Providers.MigrationsPg.Sql.M0001_InitialMigration.sql"); } diff --git a/Providers/Resgrid.Providers.MigrationsPg/Sql/EF0001_PopulateOIDCDb.sql b/Providers/Resgrid.Providers.MigrationsPg/Sql/EF0001_PopulateOIDCDb.sql index e8f9bfe15..6164f91f7 100644 --- a/Providers/Resgrid.Providers.MigrationsPg/Sql/EF0001_PopulateOIDCDb.sql +++ b/Providers/Resgrid.Providers.MigrationsPg/Sql/EF0001_PopulateOIDCDb.sql @@ -298,19 +298,15 @@ IF NOT exists (select constraint_name from information_schema.table_constraints REFERENCES public."AspNetRoles"("Id") ON DELETE CASCADE ON UPDATE NO ACTION INITIALLY IMMEDIATE; END IF; + -- INSERT ... SELECT ... FROM "__Efmigrationshistory" inserted nothing while the table was empty, so + -- these rows were never written and this whole block re-ran on every upgrade (GitHub #286). INSERT INTO public."__Efmigrationshistory" ("MigrationId", "ProductVersion") - SELECT '20210904153137_CreateOpenIddictModels' AS "MigrationId",'5.0.9' AS "ProductVersion" FROM public."__Efmigrationshistory" - WHERE NOT EXISTS( - SELECT "MigrationId" FROM public."__Efmigrationshistory" WHERE "MigrationId" = '20210904153137_CreateOpenIddictModels' - ) - LIMIT 1; + VALUES ('20210904153137_CreateOpenIddictModels', '5.0.9') + ON CONFLICT ("MigrationId") DO NOTHING; INSERT INTO public."__Efmigrationshistory" ("MigrationId", "ProductVersion") - SELECT '20240412153137_UpdateOpenIddictModelsToV5' AS "MigrationId",'5.0.9' AS "ProductVersion" FROM public."__Efmigrationshistory" - WHERE NOT EXISTS( - SELECT "MigrationId" FROM public."__Efmigrationshistory" WHERE "MigrationId" = '20240412153137_UpdateOpenIddictModelsToV5' - ) - LIMIT 1; + VALUES ('20240412153137_UpdateOpenIddictModelsToV5', '5.0.9') + ON CONFLICT ("MigrationId") DO NOTHING; END IF; END $$; \ No newline at end of file diff --git a/Repositories/Resgrid.Repositories.DataRepository/App.config b/Repositories/Resgrid.Repositories.DataRepository/App.config deleted file mode 100644 index 787c84b3d..000000000 --- a/Repositories/Resgrid.Repositories.DataRepository/App.config +++ /dev/null @@ -1,66 +0,0 @@ - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Tests/Resgrid.Tests/Config/ConfigProcessorConnectionStringFallbackTests.cs b/Tests/Resgrid.Tests/Config/ConfigProcessorConnectionStringFallbackTests.cs new file mode 100644 index 000000000..21dd5ecaf --- /dev/null +++ b/Tests/Resgrid.Tests/Config/ConfigProcessorConnectionStringFallbackTests.cs @@ -0,0 +1,104 @@ +using System.Collections.Generic; +using System.Reflection; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Config; + +namespace Resgrid.Tests.Config +{ + /// + /// GitHub #536: a SQL Server install that only sets DataConfig.ConnectionString left CoreConnectionString empty, + /// and FluentMigrator ran the upgrade on its connectionless preview processor (no DDL, then NotImplementedException). + /// + [TestFixture] + [NonParallelizable] + public class ConfigProcessorConnectionStringFallbackTests + { + private DatabaseTypes _databaseType; + private string _connectionString; + private string _coreConnectionString; + + [SetUp] + public void SetUp() + { + _databaseType = DataConfig.DatabaseType; + _connectionString = DataConfig.ConnectionString; + _coreConnectionString = DataConfig.CoreConnectionString; + + DataConfig.DatabaseType = DatabaseTypes.SqlServer; + DataConfig.ConnectionString = "Server=default;"; + DataConfig.CoreConnectionString = ""; + ResetDerivedValue(); + } + + [TearDown] + public void TearDown() + { + DataConfig.DatabaseType = _databaseType; + DataConfig.ConnectionString = _connectionString; + DataConfig.CoreConnectionString = _coreConnectionString; + ResetDerivedValue(); + } + + [Test] + public void sql_server_core_connection_string_follows_connection_string_when_unset() + { + ConfigProcessor.LoadAndProcessEnvVariables(Env(("RESGRID:DataConfig:ConnectionString", "Server=db;Database=Resgrid;"))); + + DataConfig.CoreConnectionString.Should().Be("Server=db;Database=Resgrid;"); + } + + [Test] + public void explicit_core_connection_string_is_kept() + { + ConfigProcessor.LoadAndProcessEnvVariables(Env( + ("RESGRID:DataConfig:ConnectionString", "Server=db;Database=Resgrid;"), + ("RESGRID:DataConfig:CoreConnectionString", "Server=core;Database=Resgrid;"))); + + DataConfig.CoreConnectionString.Should().Be("Server=core;Database=Resgrid;"); + } + + [Test] + public void a_later_connection_string_override_replaces_the_derived_value() + { + // Config file pass, then the environment variable pass overriding ConnectionString. + ConfigProcessor.LoadAndProcessEnvVariables(Env(("RESGRID:DataConfig:ConnectionString", "Server=file;"))); + ConfigProcessor.LoadAndProcessEnvVariables(Env(("RESGRID:DataConfig:ConnectionString", "Server=env;"))); + + DataConfig.CoreConnectionString.Should().Be("Server=env;"); + } + + [Test] + public void a_later_explicit_core_connection_string_wins_over_the_derived_value() + { + ConfigProcessor.LoadAndProcessEnvVariables(Env(("RESGRID:DataConfig:ConnectionString", "Server=file;"))); + ConfigProcessor.LoadAndProcessEnvVariables(Env(("RESGRID:DataConfig:CoreConnectionString", "Server=core;"))); + ConfigProcessor.LoadAndProcessEnvVariables(Env(("RESGRID:DataConfig:ConnectionString", "Server=other;"))); + + DataConfig.CoreConnectionString.Should().Be("Server=core;"); + } + + [Test] + public void postgres_is_never_given_the_sql_server_connection_string() + { + ConfigProcessor.LoadAndProcessEnvVariables(Env(("RESGRID:DataConfig:ConnectionString", "Server=db;"))); + ConfigProcessor.LoadAndProcessEnvVariables(Env(("RESGRID:DataConfig:DatabaseType", "1"))); + + DataConfig.DatabaseType.Should().Be(DatabaseTypes.Postgres); + DataConfig.CoreConnectionString.Should().BeEmpty(); + } + + private static IEnumerable> Env(params (string Key, string Value)[] values) + { + foreach (var (key, value) in values) + yield return new KeyValuePair(key, value); + } + + private static void ResetDerivedValue() + { + typeof(ConfigProcessor) + .GetField("_derivedCoreConnectionString", BindingFlags.NonPublic | BindingFlags.Static)! + .SetValue(null, null); + } + } +} diff --git a/Tests/Resgrid.Tests/Framework/StringHelperTests.cs b/Tests/Resgrid.Tests/Framework/StringHelperTests.cs index c858f0d99..589735722 100644 --- a/Tests/Resgrid.Tests/Framework/StringHelperTests.cs +++ b/Tests/Resgrid.Tests/Framework/StringHelperTests.cs @@ -84,5 +84,132 @@ public void TestHtmlSanatizer_OnCallNotesWithWordHtmlFromEmailFW_NoTitle() result.Should().NotBeNullOrEmpty(); //result.Should().Be("burning complaint lougheed"); } + + // #181 - training articles lost URLs and short words like "NORA" on save. + + [Test] + public void SanitizeHtmlInString_ShouldKeepUnderlinedText() + { + var result = StringHelpers.SanitizeHtmlInString("

The NORA program

"); + + result.Should().Be("

The NORA program

"); + } + + [Test] + public void SanitizeHtmlInString_ShouldKeepPastedLinkText() + { + var result = StringHelpers.SanitizeHtmlInString("

See https://www.nfpa.org/codes now

"); + + result.Should().Be("

See https://www.nfpa.org/codes now

"); + } + + [Test] + public void SanitizeHtmlInString_ShouldKeepEditorFormattingTags() + { + var input = "
Quote
code line

H2O x2 old cmd

Small heading
"; + + var result = StringHelpers.SanitizeHtmlInString(input); + + result.Should().Be("
Quote
code line

H2O x2 old cmd

Small heading
"); + } + + [Test] + public void SanitizeHtmlInString_ShouldUnwrapPresentationalTagsKeepingText() + { + var result = StringHelpers.SanitizeHtmlInString("

Meet in Chicago at Station 1

"); + + result.Should().Be("

Meet in Chicago at Station 1

"); + } + + [Test] + public void SanitizeHtmlInString_ShouldKeepEscapedAngleBracketTextAsText() + { + var result = StringHelpers.SanitizeHtmlInString("

Contact NORA <nora@example.org> today

"); + + result.Should().Be("

Contact NORA <nora@example.org> today

"); + } + + [Test] + public void SanitizeHtmlInString_ShouldNotStripLettersFromOrdinaryText() + { + // The Word clean-up patterns had lost their backslashes and matched literal "nr", "s" and "w" sequences + var result = StringHelpers.SanitizeHtmlInString("

Unrnrated HENRNRY swims with class=wonderful style='solid'

"); + var plain = StringHelpers.SanitizeHtmlInString("Structure fire at HENRNRY Rd\r\n\r\n\r\nUnrnrated"); + + result.Should().Contain("Unrnrated HENRNRY swims with class=wonderful"); + result.Should().Contain("solid"); + plain.Should().Be("Structure fire at HENRNRY Rd\r\n\r\n\r\nUnrnrated"); + } + + [Test] + public void SanitizeHtmlInString_ShouldStillRemoveScriptsAndHandlers() + { + var result = StringHelpers.SanitizeHtmlInString("

ab

"); + + result.Should().NotContain("script"); + result.Should().NotContain("alert"); + result.Should().NotContain("onclick"); + result.Should().NotContain("onerror"); + result.Should().NotContain("iframe"); + result.Should().Contain("a").And.Contain("b"); + } + + [TestCase("javascript:alert(1)")] + [TestCase("JaVaScRiPt:alert(1)")] + [TestCase("javascript :alert(1)")] + [TestCase("java script:alert(1)")] + [TestCase("vbscript:msgbox(1)")] + [TestCase("data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==")] + public void SanitizeHtmlInString_ShouldStillRejectScriptUrls(string href) + { + var result = StringHelpers.SanitizeHtmlInString("

Click here or

"); + + result.Should().Be("

Click here or

"); + } + + [Test] + public void SanitizeHtmlInString_ShouldKeepLinkWithSpaceAcrossRepeatedSaves() + { + var first = StringHelpers.SanitizeHtmlInString("

NORA program

"); + var second = StringHelpers.SanitizeHtmlInString(first); + var third = StringHelpers.SanitizeHtmlInString(second); + + first.Should().Be("

NORA program

"); + second.Should().Be(first); + third.Should().Be(first); + } + + [Test] + public void SanitizeHtmlInString_ShouldKeepPercentEncodedSpaceInLink() + { + var input = "

doc

"; + + var result = StringHelpers.SanitizeHtmlInString(input); + + result.Should().Be("

doc

"); + StringHelpers.SanitizeHtmlInString(result).Should().Be(result); + } + + [TestCase("mailto:nora@example.org")] + [TestCase("tel:+15555550100")] + [TestCase("http://example.com/")] + public void SanitizeHtmlInString_ShouldKeepAllowedLinkSchemes(string href) + { + var result = StringHelpers.SanitizeHtmlInString("

link

"); + + result.Should().Be("

link

"); + } + + [Test] + public void SanitizeHtmlInString_ShouldBeStableForQuillContent() + { + var input = "

Overview

The NORA program starts today.

  1. One
  2. Two & three


See NFPA.

"; + + var once = StringHelpers.SanitizeHtmlInString(input); + + once.Should().Contain("starts").And.Contain("NFPA").And.Contain("Two & three"); + once.Should().Contain("href=\"https://www.nfpa.org/codes%20and%20standards\""); + StringHelpers.SanitizeHtmlInString(once).Should().Be(once); + } } } diff --git a/Tests/Resgrid.Tests/Services/CalendarServiceCheckInTests.cs b/Tests/Resgrid.Tests/Services/CalendarServiceCheckInTests.cs index 3422e764a..cf04260af 100644 --- a/Tests/Resgrid.Tests/Services/CalendarServiceCheckInTests.cs +++ b/Tests/Resgrid.Tests/Services/CalendarServiceCheckInTests.cs @@ -81,6 +81,12 @@ private static Lazy AllowedProtectedWrites() return new Lazy(() => stub.Object); } + private void SetupCheckInInsert() + { + _checkInRepo.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((CalendarItemCheckIn c, CancellationToken ct, bool f) => c); + } + [Test] public async Task CheckInToEvent_creates_new_record_when_none_exists() { @@ -88,8 +94,7 @@ public async Task CheckInToEvent_creates_new_record_when_none_exists() .ReturnsAsync((CalendarItemCheckIn)null); _calendarItemRepo.Setup(x => x.GetByIdAsync(It.IsAny())) .ReturnsAsync(new CalendarItem { CalendarItemId = 1, DepartmentId = 10, CheckInType = (int)CalendarItemCheckInTypes.SelfCheckIn }); - _checkInRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync((CalendarItemCheckIn c, CancellationToken ct, bool f) => c); + SetupCheckInInsert(); var result = await _service.CheckInToEventAsync(1, "user1", "test note"); @@ -101,6 +106,42 @@ public async Task CheckInToEvent_creates_new_record_when_none_exists() result.CalendarItemCheckInId.Should().NotBeNullOrEmpty(); } + /// + /// RepositoryBase.SaveOrUpdateAsync treats a string-keyed entity whose id is already set as an existing row and runs + /// an UPDATE that matches nothing, so a pre-keyed check-in saved that way was never stored (issue #331: the page + /// refreshed and nothing changed). A new check-in has to go through InsertAsync. + /// + [Test] + public async Task CheckInToEvent_inserts_the_new_row_instead_of_save_or_update() + { + _checkInRepo.Setup(x => x.GetCheckInByCalendarItemAndUserAsync(1, "user1")) + .ReturnsAsync((CalendarItemCheckIn)null); + _calendarItemRepo.Setup(x => x.GetByIdAsync(It.IsAny())) + .ReturnsAsync(new CalendarItem { CalendarItemId = 1, DepartmentId = 10, CheckInType = (int)CalendarItemCheckInTypes.SelfCheckIn }); + SetupCheckInInsert(); + + await _service.CheckInToEventAsync(1, "user1", null); + + _checkInRepo.Verify(x => x.InsertAsync(It.Is(c => !string.IsNullOrWhiteSpace(c.CalendarItemCheckInId) + && c.CalendarItemId == 1 && c.UserId == "user1"), It.IsAny(), It.IsAny()), Times.Once); + _checkInRepo.Verify(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + _checkInRepo.Verify(x => x.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task CheckInToEvent_returns_null_and_writes_nothing_when_event_missing() + { + _checkInRepo.Setup(x => x.GetCheckInByCalendarItemAndUserAsync(1, "user1")) + .ReturnsAsync((CalendarItemCheckIn)null); + _calendarItemRepo.Setup(x => x.GetByIdAsync(It.IsAny())) + .ReturnsAsync((CalendarItem)null); + + var result = await _service.CheckInToEventAsync(1, "user1", null); + + result.Should().BeNull(); + _checkInRepo.Verify(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + [Test] public async Task CheckInToEvent_returns_existing_when_already_checked_in() { @@ -189,8 +230,7 @@ public async Task AdminCheckIn_sets_CheckInByUserId() .ReturnsAsync((CalendarItemCheckIn)null); _calendarItemRepo.Setup(x => x.GetByIdAsync(It.IsAny())) .ReturnsAsync(new CalendarItem { CalendarItemId = 1, DepartmentId = 10, CheckInType = (int)CalendarItemCheckInTypes.SelfCheckIn }); - _checkInRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync((CalendarItemCheckIn c, CancellationToken ct, bool f) => c); + SetupCheckInInsert(); var result = await _service.CheckInToEventAsync(1, "user1", "admin note", "admin1"); @@ -227,8 +267,7 @@ public async Task CheckInToEvent_stores_coordinates() .ReturnsAsync((CalendarItemCheckIn)null); _calendarItemRepo.Setup(x => x.GetByIdAsync(It.IsAny())) .ReturnsAsync(new CalendarItem { CalendarItemId = 1, DepartmentId = 10, CheckInType = (int)CalendarItemCheckInTypes.SelfCheckIn }); - _checkInRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync((CalendarItemCheckIn c, CancellationToken ct, bool f) => c); + SetupCheckInInsert(); var result = await _service.CheckInToEventAsync(1, "user1", "note", null, "33.4484", "-112.0740"); @@ -286,6 +325,40 @@ public async Task DeleteCheckIn_removes_record() _checkInRepo.Verify(x => x.DeleteAsync(It.IsAny(), It.IsAny()), Times.Once); } + [Test] + public async Task UpdateCalendarItem_persists_the_check_in_type() + { + var stored = new CalendarItem + { + CalendarItemId = 1, + DepartmentId = 10, + Start = new DateTime(2026, 10, 1, 12, 0, 0, DateTimeKind.Utc), + End = new DateTime(2026, 10, 1, 13, 0, 0, DateTimeKind.Utc), + CheckInType = (int)CalendarItemCheckInTypes.AdminOnly, + CreatorUserId = "creator1" + }; + _calendarItemRepo.Setup(x => x.GetCalendarItemByIdAsync(1)).ReturnsAsync(stored); + _calendarItemRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((CalendarItem c, CancellationToken ct, bool f) => c); + _calendarItemRepo.Setup(x => x.GetCalendarItemsByRecurrenceIdAsync(1)).ReturnsAsync(new List()); + + var edited = new CalendarItem + { + CalendarItemId = 1, + DepartmentId = 10, + Start = new DateTime(2026, 10, 1, 8, 0, 0), + End = new DateTime(2026, 10, 1, 9, 0, 0), + CheckInType = (int)CalendarItemCheckInTypes.SelfCheckIn, + CreatorUserId = "creator1" + }; + + var result = await _service.UpdateCalendarItemAsync(edited, "UTC"); + + result.CheckInType.Should().Be((int)CalendarItemCheckInTypes.SelfCheckIn); + _calendarItemRepo.Verify(x => x.SaveOrUpdateAsync(It.Is(c => c.CheckInType == (int)CalendarItemCheckInTypes.SelfCheckIn), + It.IsAny(), It.IsAny()), Times.Once); + } + #endregion Service Logic Tests #region Authorization Tests @@ -325,14 +398,32 @@ private void SetupAuthService() new Mock().Object); } + /// + /// The event department as GetDepartmentByIdAsync returns it: every member row, removed ones included. + /// + private void SetupEventDepartment(params DepartmentMember[] members) + { + var dept = new Department { DepartmentId = 10, ManagingUserId = "owner1", Members = members.ToList() }; + _authDeptService.Setup(x => x.GetDepartmentByIdAsync(10, It.IsAny())).ReturnsAsync(dept); + } + + private static DepartmentMember Member(string userId, bool isAdmin = false, bool isDisabled = false, bool isDeleted = false, bool isHidden = false) + { + return new DepartmentMember { DepartmentId = 10, UserId = userId, IsAdmin = isAdmin, IsDisabled = isDisabled, IsDeleted = isDeleted, IsHidden = isHidden }; + } + + private void SetupEvent(int checkInType, string creatorUserId = null) + { + _authCalService.Setup(x => x.GetCalendarItemByIdAsync(1)) + .ReturnsAsync(new CalendarItem { CalendarItemId = 1, DepartmentId = 10, CheckInType = checkInType, CreatorUserId = creatorUserId }); + } + [Test] public async Task CanUserCheckIn_returns_true_when_same_department() { SetupAuthService(); - var dept = new Department { DepartmentId = 10, ManagingUserId = "admin1" }; - _authDeptService.Setup(x => x.GetDepartmentByUserIdAsync("user1", It.IsAny())).ReturnsAsync(dept); - _authCalService.Setup(x => x.GetCalendarItemByIdAsync(1)) - .ReturnsAsync(new CalendarItem { CalendarItemId = 1, DepartmentId = 10, CheckInType = (int)CalendarItemCheckInTypes.SelfCheckIn }); + SetupEventDepartment(Member("user1")); + SetupEvent((int)CalendarItemCheckInTypes.SelfCheckIn); var result = await _authService.CanUserCheckInToCalendarEventAsync("user1", 1); @@ -343,41 +434,143 @@ public async Task CanUserCheckIn_returns_true_when_same_department() public async Task CanUserCheckIn_returns_false_when_different_department() { SetupAuthService(); - var dept = new Department { DepartmentId = 20, ManagingUserId = "admin2" }; - _authDeptService.Setup(x => x.GetDepartmentByUserIdAsync("user1", It.IsAny())).ReturnsAsync(dept); - _authCalService.Setup(x => x.GetCalendarItemByIdAsync(1)) - .ReturnsAsync(new CalendarItem { CalendarItemId = 1, DepartmentId = 10, CheckInType = (int)CalendarItemCheckInTypes.SelfCheckIn }); + // User is not a member of dept 10, where the event is → should fail + SetupEventDepartment(Member("someoneelse")); + SetupEvent((int)CalendarItemCheckInTypes.SelfCheckIn); + + var result = await _authService.CanUserCheckInToCalendarEventAsync("user1", 1); + + result.Should().BeFalse(); + } + + /// + /// A member of several departments: the department GetDepartmentByUserIdAsync resolves is whichever active/default + /// membership the query lands on, which need not be the event's. Membership in the event's department decides. + /// + [Test] + public async Task CanUserCheckIn_uses_the_events_department_not_the_users_resolved_department() + { + SetupAuthService(); + _authDeptService.Setup(x => x.GetDepartmentByUserIdAsync("user1", It.IsAny())) + .ReturnsAsync(new Department { DepartmentId = 20, ManagingUserId = "owner2" }); + SetupEventDepartment(Member("user1")); + SetupEvent((int)CalendarItemCheckInTypes.SelfCheckIn); + + var result = await _authService.CanUserCheckInToCalendarEventAsync("user1", 1); + + result.Should().BeTrue(); + } + + [Test] + public async Task CanUserCheckIn_returns_false_for_disabled_member() + { + SetupAuthService(); + SetupEventDepartment(Member("user1", isDisabled: true)); + SetupEvent((int)CalendarItemCheckInTypes.SelfCheckIn); + + var result = await _authService.CanUserCheckInToCalendarEventAsync("user1", 1); + + result.Should().BeFalse(); + } + + [Test] + public async Task CanUserCheckIn_returns_false_when_check_in_disabled() + { + SetupAuthService(); + SetupEventDepartment(Member("user1", isAdmin: true)); + SetupEvent((int)CalendarItemCheckInTypes.Disabled); - // User is in dept 20, event is in dept 10 → should fail var result = await _authService.CanUserCheckInToCalendarEventAsync("user1", 1); result.Should().BeFalse(); } + [Test] + public async Task CanUserCheckIn_admin_only_rejects_regular_member_and_admits_admin() + { + SetupAuthService(); + SetupEventDepartment(Member("user1"), Member("admin1", isAdmin: true)); + SetupEvent((int)CalendarItemCheckInTypes.AdminOnly, "someoneelse"); + _authGroupService.Setup(x => x.GetGroupForUserAsync(It.IsAny(), 10)).ReturnsAsync((DepartmentGroup)null); + + (await _authService.CanUserCheckInToCalendarEventAsync("user1", 1)).Should().BeFalse(); + (await _authService.CanUserCheckInToCalendarEventAsync("admin1", 1)).Should().BeTrue(); + } + + /// + /// Issue #331: GetDepartmentByUserIdAsync carries the caller's member row only, so every target looked like a + /// non-member and an admin could check nobody in. The full roster of the event's department is used now. + /// [Test] public async Task CanUserAdminCheckIn_returns_true_when_department_admin() { SetupAuthService(); - var dept = new Department { DepartmentId = 10, ManagingUserId = "admin1", - Members = new List { new DepartmentMember { UserId = "admin1", IsAdmin = true }, new DepartmentMember { UserId = "user1" } } }; - _authDeptService.Setup(x => x.GetDepartmentByUserIdAsync("admin1", It.IsAny())).ReturnsAsync(dept); - _authCalService.Setup(x => x.GetCalendarItemByIdAsync(1)) - .ReturnsAsync(new CalendarItem { CalendarItemId = 1, DepartmentId = 10, CheckInType = (int)CalendarItemCheckInTypes.SelfCheckIn }); + _authDeptService.Setup(x => x.GetDepartmentByUserIdAsync("admin1", It.IsAny())) + .ReturnsAsync(new Department { DepartmentId = 10, ManagingUserId = "owner1", + Members = new List { Member("admin1", isAdmin: true) } }); + SetupEventDepartment(Member("admin1", isAdmin: true), Member("user1")); + SetupEvent((int)CalendarItemCheckInTypes.SelfCheckIn); var result = await _authService.CanUserAdminCheckInCalendarEventAsync("admin1", 1, "user1"); result.Should().BeTrue(); } + [Test] + public async Task CanUserAdminCheckIn_returns_true_for_admin_only_event_and_case_differing_target_id() + { + SetupAuthService(); + SetupEventDepartment(Member("admin1", isAdmin: true), Member("user1")); + SetupEvent((int)CalendarItemCheckInTypes.AdminOnly); + + var result = await _authService.CanUserAdminCheckInCalendarEventAsync("admin1", 1, "USER1"); + + result.Should().BeTrue(); + } + + [Test] + public async Task CanUserAdminCheckIn_returns_false_when_target_disabled_or_removed() + { + SetupAuthService(); + SetupEventDepartment(Member("admin1", isAdmin: true), Member("disabled1", isDisabled: true), Member("removed1", isDeleted: true)); + SetupEvent((int)CalendarItemCheckInTypes.SelfCheckIn); + + (await _authService.CanUserAdminCheckInCalendarEventAsync("admin1", 1, "disabled1")).Should().BeFalse(); + (await _authService.CanUserAdminCheckInCalendarEventAsync("admin1", 1, "removed1")).Should().BeFalse(); + (await _authService.CanUserAdminCheckInCalendarEventAsync("admin1", 1, "stranger")).Should().BeFalse(); + } + + [Test] + public async Task CanUserAdminCheckIn_returns_false_for_removed_admin_row() + { + SetupAuthService(); + // Removal leaves IsAdmin set; a removed admin is not an admin. + SetupEventDepartment(Member("admin1", isAdmin: true, isDeleted: true), Member("user1")); + SetupEvent((int)CalendarItemCheckInTypes.SelfCheckIn); + + var result = await _authService.CanUserAdminCheckInCalendarEventAsync("admin1", 1, "user1"); + + result.Should().BeFalse(); + } + + [Test] + public async Task CanUserAdminCheckIn_returns_false_when_check_in_disabled() + { + SetupAuthService(); + SetupEventDepartment(Member("admin1", isAdmin: true), Member("user1")); + SetupEvent((int)CalendarItemCheckInTypes.Disabled); + + var result = await _authService.CanUserAdminCheckInCalendarEventAsync("admin1", 1, "user1"); + + result.Should().BeFalse(); + } + [Test] public async Task CanUserAdminCheckIn_returns_true_when_event_creator() { SetupAuthService(); - var dept = new Department { DepartmentId = 10, ManagingUserId = "someoneelse", - Members = new List { new DepartmentMember { UserId = "creator1" }, new DepartmentMember { UserId = "user1" } } }; - _authDeptService.Setup(x => x.GetDepartmentByUserIdAsync("creator1", It.IsAny())).ReturnsAsync(dept); - _authCalService.Setup(x => x.GetCalendarItemByIdAsync(1)) - .ReturnsAsync(new CalendarItem { CalendarItemId = 1, DepartmentId = 10, CheckInType = (int)CalendarItemCheckInTypes.AdminOnly, CreatorUserId = "creator1" }); + SetupEventDepartment(Member("creator1"), Member("user1")); + SetupEvent((int)CalendarItemCheckInTypes.AdminOnly, "creator1"); _authGroupService.Setup(x => x.GetGroupForUserAsync("creator1", 10)) .ReturnsAsync((DepartmentGroup)null); @@ -386,14 +579,43 @@ public async Task CanUserAdminCheckIn_returns_true_when_event_creator() result.Should().BeTrue(); } + [Test] + public async Task CanUserAdminCheckIn_group_admin_limited_to_group_and_child_groups() + { + SetupAuthService(); + SetupEventDepartment(Member("gadmin1"), Member("groupmate1"), Member("childmate1"), Member("outsider1")); + SetupEvent((int)CalendarItemCheckInTypes.AdminOnly, "someoneelse"); + var group = new DepartmentGroup + { + DepartmentGroupId = 5, + DepartmentId = 10, + Members = new List + { + new DepartmentGroupMember { DepartmentGroupId = 5, UserId = "gadmin1", IsAdmin = true }, + new DepartmentGroupMember { DepartmentGroupId = 5, UserId = "groupmate1" } + } + }; + var child = new DepartmentGroup + { + DepartmentGroupId = 6, + DepartmentId = 10, + ParentDepartmentGroupId = 5, + Members = new List { new DepartmentGroupMember { DepartmentGroupId = 6, UserId = "childmate1" } } + }; + _authGroupService.Setup(x => x.GetGroupForUserAsync("gadmin1", 10)).ReturnsAsync(group); + _authGroupService.Setup(x => x.GetAllChildDepartmentGroupsAsync(5)).ReturnsAsync(new List { child }); + + (await _authService.CanUserAdminCheckInCalendarEventAsync("gadmin1", 1, "groupmate1")).Should().BeTrue(); + (await _authService.CanUserAdminCheckInCalendarEventAsync("gadmin1", 1, "childmate1")).Should().BeTrue(); + (await _authService.CanUserAdminCheckInCalendarEventAsync("gadmin1", 1, "outsider1")).Should().BeFalse(); + } + [Test] public async Task CanUserAdminCheckIn_returns_false_when_not_admin_nor_creator() { SetupAuthService(); - var dept = new Department { DepartmentId = 10, ManagingUserId = "admin1" }; - _authDeptService.Setup(x => x.GetDepartmentByUserIdAsync("user1", It.IsAny())).ReturnsAsync(dept); - _authCalService.Setup(x => x.GetCalendarItemByIdAsync(1)) - .ReturnsAsync(new CalendarItem { CalendarItemId = 1, DepartmentId = 10, CheckInType = (int)CalendarItemCheckInTypes.AdminOnly, CreatorUserId = "someoneelse" }); + SetupEventDepartment(Member("user1"), Member("user2")); + SetupEvent((int)CalendarItemCheckInTypes.AdminOnly, "someoneelse"); _authGroupService.Setup(x => x.GetGroupForUserAsync("user1", 10)) .ReturnsAsync((DepartmentGroup)null); @@ -402,12 +624,27 @@ public async Task CanUserAdminCheckIn_returns_false_when_not_admin_nor_creator() result.Should().BeFalse(); } + [Test] + public async Task CanUserAdminCheckIn_returns_false_when_caller_not_in_events_department() + { + SetupAuthService(); + // admin1 administers some other department; the event's department does not list them. + _authDeptService.Setup(x => x.GetDepartmentByUserIdAsync("admin1", It.IsAny())) + .ReturnsAsync(new Department { DepartmentId = 10, ManagingUserId = "owner1", + Members = new List { Member("admin1", isAdmin: true) } }); + SetupEventDepartment(Member("user1")); + SetupEvent((int)CalendarItemCheckInTypes.SelfCheckIn); + + var result = await _authService.CanUserAdminCheckInCalendarEventAsync("admin1", 1, "user1"); + + result.Should().BeFalse(); + } + [Test] public async Task CanUserEditCheckIn_returns_true_for_own_checkin() { SetupAuthService(); - var dept = new Department { DepartmentId = 10, ManagingUserId = "admin1" }; - _authDeptService.Setup(x => x.GetDepartmentByUserIdAsync("user1", It.IsAny())).ReturnsAsync(dept); + SetupEventDepartment(Member("user1")); _authCalService.Setup(x => x.GetCheckInByIdAsync("checkin1")) .ReturnsAsync(new CalendarItemCheckIn { CalendarItemCheckInId = "checkin1", DepartmentId = 10, UserId = "user1" }); @@ -420,8 +657,7 @@ public async Task CanUserEditCheckIn_returns_true_for_own_checkin() public async Task CanUserEditCheckIn_returns_true_for_department_admin() { SetupAuthService(); - var dept = new Department { DepartmentId = 10, ManagingUserId = "admin1" }; - _authDeptService.Setup(x => x.GetDepartmentByUserIdAsync("admin1", It.IsAny())).ReturnsAsync(dept); + SetupEventDepartment(Member("admin1", isAdmin: true), Member("user1")); _authCalService.Setup(x => x.GetCheckInByIdAsync("checkin1")) .ReturnsAsync(new CalendarItemCheckIn { CalendarItemCheckInId = "checkin1", DepartmentId = 10, UserId = "user1" }); @@ -430,12 +666,24 @@ public async Task CanUserEditCheckIn_returns_true_for_department_admin() result.Should().BeTrue(); } + [Test] + public async Task CanUserEditCheckIn_returns_false_for_non_member() + { + SetupAuthService(); + SetupEventDepartment(Member("user1")); + _authCalService.Setup(x => x.GetCheckInByIdAsync("checkin1")) + .ReturnsAsync(new CalendarItemCheckIn { CalendarItemCheckInId = "checkin1", DepartmentId = 10, UserId = "user1" }); + + var result = await _authService.CanUserEditCalendarCheckInAsync("stranger", "checkin1"); + + result.Should().BeFalse(); + } + [Test] public async Task CanUserEditCheckIn_returns_false_for_other_users_checkin() { SetupAuthService(); - var dept = new Department { DepartmentId = 10, ManagingUserId = "admin1" }; - _authDeptService.Setup(x => x.GetDepartmentByUserIdAsync("user2", It.IsAny())).ReturnsAsync(dept); + SetupEventDepartment(Member("user1"), Member("user2")); _authCalService.Setup(x => x.GetCheckInByIdAsync("checkin1")) .ReturnsAsync(new CalendarItemCheckIn { CalendarItemCheckInId = "checkin1", DepartmentId = 10, UserId = "user1", CalendarItemId = 1 }); _authCalService.Setup(x => x.GetCalendarItemByIdAsync(1)) diff --git a/Tests/Resgrid.Tests/Services/CalendarServiceTests.cs b/Tests/Resgrid.Tests/Services/CalendarServiceTests.cs index 49be48587..e5e33f5e1 100644 --- a/Tests/Resgrid.Tests/Services/CalendarServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/CalendarServiceTests.cs @@ -106,6 +106,12 @@ protected override void Before_all_tests() Resgrid.Config.SystemBehaviorConfig.BypassDoNotBroadcastDepartments.Add(_testDepartment.DepartmentId); _userProfileServiceMock.Setup(x => x.GetAllProfilesForDepartmentAsync(999, false)) .ReturnsAsync(new Dictionary()); + _departmentsServiceMock.Setup(x => x.GetActiveMemberUserIdsAsync(999)) + .ReturnsAsync(new HashSet(StringComparer.OrdinalIgnoreCase) { UserId }); + + // The mocks live for the whole fixture; each test verifies only its own calls. + _communicationServiceMock.Invocations.Clear(); + _textResponsePromptServiceMock.Invocations.Clear(); } protected override void After_all_tests() @@ -145,6 +151,56 @@ public async Task should_not_record_prompt_when_delivery_is_rejected() It.IsAny()), Times.Never); } + [Test] + public async Task should_not_notify_removed_disabled_or_hidden_members() + { + // Arrange: "inactive-1" is not in the active member set (removed, disabled or hidden) + var calendarItem = CreateRsvpCalendarItem(); + _communicationServiceMock.Setup(x => x.SendCalendarAsync(It.IsAny(), 999, It.IsAny(), + It.IsAny(), It.IsAny(), null, _testDepartment)).ReturnsAsync(true); + + // Act + await _calendarService.NotifyUsersAboutCalendarItemAsync(calendarItem, new List { "inactive-1", UserId.ToUpperInvariant() }); + + // Assert + _communicationServiceMock.Verify(x => x.SendCalendarAsync("inactive-1", It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + _communicationServiceMock.Verify(x => x.SendCalendarAsync(UserId.ToUpperInvariant(), 999, It.IsAny(), + It.IsAny(), It.IsAny(), null, _testDepartment), Times.Once); + } + + [Test] + public async Task should_not_notify_inactive_department_or_group_members_of_a_new_item() + { + // Arrange + var calendarItem = CreateRsvpCalendarItem(); + calendarItem.Entities = "G:7"; + _departmentGroupsServiceMock.Setup(x => x.GetAllGroupsForDepartmentAsync(999)).ReturnsAsync(new List + { + new DepartmentGroup + { + DepartmentGroupId = 7, + DepartmentId = 999, + Members = new List + { + new DepartmentGroupMember { DepartmentGroupId = 7, UserId = UserId }, + new DepartmentGroupMember { DepartmentGroupId = 7, UserId = "inactive-2" } + } + } + }); + _communicationServiceMock.Setup(x => x.SendCalendarAsync(It.IsAny(), 999, It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), _testDepartment)).ReturnsAsync(true); + + // Act + await _calendarService.NotifyNewCalendarItemAsync(calendarItem); + + // Assert + _communicationServiceMock.Verify(x => x.SendCalendarAsync("inactive-2", It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + _communicationServiceMock.Verify(x => x.SendCalendarAsync(UserId, 999, It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), _testDepartment), Times.Once); + } + private static CalendarItem CreateRsvpCalendarItem() { return new CalendarItem diff --git a/Tests/Resgrid.Tests/Services/DocumentDatabaseProviderSelectionTests.cs b/Tests/Resgrid.Tests/Services/DocumentDatabaseProviderSelectionTests.cs index 2cddad031..a4803f4f8 100644 --- a/Tests/Resgrid.Tests/Services/DocumentDatabaseProviderSelectionTests.cs +++ b/Tests/Resgrid.Tests/Services/DocumentDatabaseProviderSelectionTests.cs @@ -256,6 +256,28 @@ public async Task GetMapLayersForTypeDepartmentAsync_should_not_resolve_mongo_re mapLayersDocRepository.Verify(x => x.GetAllMapLayersByDepartmentIdAsync(7, MapLayerTypes.TopLevel), Times.Once); } + [Test] + public async Task GetMapLayersForTypeDepartmentAsync_should_return_no_layers_when_mongo_is_unreachable() + { + // GitHub #243: a self-hosted install pointing at an unreachable Mongo must still draw its maps. + DataConfig.DocDatabaseType = DatabaseTypes.MongoDb; + + var mongoRepository = new Mock>(); + mongoRepository + .Setup(x => x.FilterByAsync(It.IsAny>>())) + .ThrowsAsync(new TimeoutException("A timeout occurred after 5000ms selecting a server.")); + + var service = new MappingService( + new Mock().Object, + new Mock().Object, + new Lazy>(() => mongoRepository.Object), + new Mock().Object); + + var result = await service.GetMapLayersForTypeDepartmentAsync(7, MapLayerTypes.TopLevel); + + result.Should().BeEmpty(); + } + private static UnitsService CreateUnitsService(IEventAggregator eventAggregator, Lazy> mongoRepository, IUnitLocationsDocRepository unitLocationsDocRepository) { return new UnitsService( diff --git a/Tests/Resgrid.Tests/Services/TrainingServiceTests.cs b/Tests/Resgrid.Tests/Services/TrainingServiceTests.cs index 1efd683d8..10f768778 100644 --- a/Tests/Resgrid.Tests/Services/TrainingServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/TrainingServiceTests.cs @@ -23,6 +23,7 @@ public class TrainingServiceTests private MockTrainingUserRepository _userRepository; private Mock _communicationServiceMock; private Mock _departmentServiceMock; + private Mock _departmentSettingsServiceMock; private TrainingService _trainingService; [SetUp] @@ -34,6 +35,7 @@ public void SetUp() _userRepository = new MockTrainingUserRepository(); _communicationServiceMock = new Mock(); _departmentServiceMock = new Mock(); + _departmentSettingsServiceMock = new Mock(); _trainingService = new TrainingService( _trainingRepository, @@ -41,7 +43,8 @@ public void SetUp() _userRepository, _questionRepository, _communicationServiceMock.Object, - _departmentServiceMock.Object + _departmentServiceMock.Object, + _departmentSettingsServiceMock.Object ); } @@ -273,6 +276,204 @@ public async Task SaveAsync_Should_Sanitize_Html_In_Description_And_Text() result.TrainingText.Should().NotContain("