From b52de41e053f8b53094ca62b5fef28a25a8d2eec Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sat, 2 May 2026 14:55:51 -0700 Subject: [PATCH 1/2] RE1-T115 TTS changes and fixed issue with SystemAuth api breaking some api controllers --- Core/Resgrid.Config/TtsConfig.cs | 41 ++- ...partmentSettingsServiceTtsLanguageTests.cs | 4 +- .../Web/Tts/S3StorageServiceTests.cs | 238 ++------------- .../Web/Tts/TtsAdminControllerTests.cs | 14 +- .../Resgrid.Tests/Web/Tts/TtsServiceTests.cs | 43 +-- .../Controllers/v4/CallFilesController.cs | 1 - .../Controllers/v4/CallsController.cs | 1 - .../Controllers/v4/DepartmentsController.cs | 1 - .../Controllers/v4/GroupsController.cs | 1 - .../Controllers/v4/RolesController.cs | 1 - .../Controllers/v4/UnitsController.cs | 1 - ...uthenticatedApiControllerbaseSystemAuth.cs | 4 +- .../Middleware/AuthTokenMiddleware.cs | 272 ------------------ .../Middleware/MiddlewareExtensions.cs | 12 - .../Middleware/TokenAuthHandler.cs | 188 ------------ .../Resgrid.Web.Services.xml | 10 +- Web/Resgrid.Web.Services/Startup.cs | 3 +- .../Configuration/TtsOptions.cs | 34 ++- Web/Resgrid.Web.Tts/Dockerfile | 1 - .../Services/S3StorageService.cs | 22 +- Web/Resgrid.Web.Tts/Services/TtsService.cs | 5 +- Web/Resgrid.Web.Tts/k8s/deployment.yaml | 6 +- 22 files changed, 144 insertions(+), 759 deletions(-) delete mode 100644 Web/Resgrid.Web.Services/Middleware/AuthTokenMiddleware.cs delete mode 100644 Web/Resgrid.Web.Services/Middleware/MiddlewareExtensions.cs delete mode 100644 Web/Resgrid.Web.Services/Middleware/TokenAuthHandler.cs diff --git a/Core/Resgrid.Config/TtsConfig.cs b/Core/Resgrid.Config/TtsConfig.cs index eb5c2c51..d206f86c 100644 --- a/Core/Resgrid.Config/TtsConfig.cs +++ b/Core/Resgrid.Config/TtsConfig.cs @@ -24,8 +24,8 @@ public static class TtsConfig public static int S3PresignedUrlExpiryMinutes = 60; public static string S3PublicBaseUrl = ""; - public static string DefaultVoice = "en-us+klatt6"; - public static int DefaultSpeed = 175; + public static string DefaultVoice = "en-us+klatt4"; + public static int DefaultSpeed = 165; public static int MaxConcurrentGenerations = 4; public static int MaxTextLength = 1000; public static string EspeakExecutable = "espeak-ng"; @@ -35,7 +35,42 @@ public static class TtsConfig public static int NormalizedSampleRate = 8000; public static int NormalizedChannels = 1; public static bool WarmupEnabled = true; - public static string PreGeneratedPrompts = "Press 1 for yes;Press 2 for no;Invalid option;Please try again;Please stay on the line;This call has been closed. Goodbye.;You have been marked responding to the scene, goodbye.;Sorry, that was not a valid selection.;Hello, this is Resgrid calling with your verification code.;That was your Resgrid verification code. Goodbye.;Thank you for calling Resgrid, automated personnel system. The number you called is not tied to an active department or the department doesn't have this feature enabled. Goodbye.;We couldn't complete your verification call. Please request a new code and try again. Goodbye.;Please select from the following options.;To list current active calls, press 1.;To list current user statuses, press 2.;To list current unit statuses, press 3.;To list upcoming calendar events, press 4.;To list upcoming shifts, press 5.;To set your current status, press 6.;To set your current staffing level, press 7.;Press 0 to repeat. Press 1 to respond to the scene.;Press 0 to go back to the main menu.;Invalid status selection, goodbye.;No status selection made, goodbye.;Invalid staffing selection. Returning to the main menu.;No staffing selection made. Returning to the main menu.;Thank you. Your response has been recorded."; + public static string PreGeneratedPrompts = string.Join(";", new[] + { + "Press 1 for yes", + "Press 2 for no", + "Invalid option", + "Please try again", + "Please stay on the line", + "This call has been closed. Goodbye.", + "You have been marked responding to the scene. Goodbye.", + "Sorry, that was not a valid selection.", + "Hello, this is Resgrid calling with your verification code.", + "That was your Resgrid verification code. Goodbye.", + "Thank you for calling the Resgrid automated personnel system. The number you called is not tied to an active department, or the department doesn't have this feature enabled. Goodbye.", + "We couldn't complete your verification call. Please request a new code and try again. Goodbye.", + "Please select from the following options.", + "To list current active calls, press 1.", + "To list current user statuses, press 2.", + "To list current unit statuses, press 3.", + "To list upcoming calendar events, press 4.", + "To list upcoming shifts, press 5.", + "To set your current status, press 6.", + "To set your current staffing level, press 7.", + "Press 0 to repeat. Press 1 to respond to the scene.", + "To hear the dispatch again, press 1. To hear response options, press 2.", + "To choose a response option, enter the option number, then press pound.", + "To hear the dispatch again, enter 0 and press pound.", + "Press 0 to go back to the main menu.", + "To go back to the main menu, enter 0 and press pound.", + "To set your current status, enter the number of your selection, then press pound.", + "To set your current staffing, enter the number of your selection, then press pound.", + "Invalid status selection. Returning to the main menu.", + "No status selection made. Returning to the main menu.", + "Invalid staffing selection. Returning to the main menu.", + "No staffing selection made. Returning to the main menu.", + "Thank you. Your response has been recorded." + }); public static int RateLimitPermitLimit = 60; public static int RateLimitQueueLimit = 10; diff --git a/Tests/Resgrid.Tests/Services/DepartmentSettingsServiceTtsLanguageTests.cs b/Tests/Resgrid.Tests/Services/DepartmentSettingsServiceTtsLanguageTests.cs index d74d1135..fe275e77 100644 --- a/Tests/Resgrid.Tests/Services/DepartmentSettingsServiceTtsLanguageTests.cs +++ b/Tests/Resgrid.Tests/Services/DepartmentSettingsServiceTtsLanguageTests.cs @@ -41,7 +41,7 @@ public void SetUp() _cacheProvider.Object); global::Resgrid.Config.SystemBehaviorConfig.CacheEnabled = false; - TtsConfig.DefaultVoice = "en-us+klatt6"; + TtsConfig.DefaultVoice = "en-us+klatt4"; } [TearDown] @@ -83,7 +83,7 @@ public async Task should_fall_back_to_default_tts_language_when_setting_missing( [Test] public async Task should_fall_back_to_default_tts_language_when_setting_is_invalid() { - TtsConfig.DefaultVoice = "fr+klatt6"; + TtsConfig.DefaultVoice = "fr+klatt4"; _departmentSettingsRepository .Setup(x => x.GetDepartmentSettingByIdTypeAsync(7, DepartmentSettingTypes.TtsLanguage)) diff --git a/Tests/Resgrid.Tests/Web/Tts/S3StorageServiceTests.cs b/Tests/Resgrid.Tests/Web/Tts/S3StorageServiceTests.cs index 2598f030..ce438509 100644 --- a/Tests/Resgrid.Tests/Web/Tts/S3StorageServiceTests.cs +++ b/Tests/Resgrid.Tests/Web/Tts/S3StorageServiceTests.cs @@ -56,49 +56,32 @@ public async Task upload_async_should_buffer_non_seekable_stream_for_retries() } [Test] - public async Task exists_async_should_fall_back_to_presigned_head_when_metadata_response_is_malformed() + public async Task exists_async_should_treat_malformed_metadata_response_as_existing_object() { var s3Client = new Mock(MockBehavior.Strict); s3Client .Setup(x => x.GetObjectMetadataAsync(It.IsAny(), It.IsAny())) .ThrowsAsync(new FormatException("bad metadata expiration header")); - s3Client - .Setup(x => x.GetPreSignedURL(It.IsAny())) - .Returns(request => - { - request.BucketName.Should().Be("tts-bucket"); - request.Key.Should().Be("tts/audio.wav"); - request.Verb.Should().Be(HttpVerb.HEAD); - request.Protocol.Should().Be(Protocol.HTTP); - return "http://upload.example.com/tts/audio.wav?signature=head"; - }); - var handler = new RecordingHttpMessageHandler((request, _) => - { - request.Method.Should().Be(HttpMethod.Head); - request.RequestUri.Should().Be(new Uri("http://upload.example.com/tts/audio.wav?signature=head")); - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); - }); + var handler = new RecordingHttpMessageHandler((_, _) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK))); var service = CreateService(s3Client.Object, handler, useSsl: false); var exists = await service.ExistsAsync("tts/audio.wav", CancellationToken.None); exists.Should().BeTrue(); - handler.Requests.Should().HaveCount(1); + handler.Requests.Should().BeEmpty(); s3Client.Verify(x => x.GetObjectMetadataAsync(It.Is(request => request.BucketName == "tts-bucket" && request.Key == "tts/audio.wav"), It.IsAny()), Times.Once); - s3Client.Verify(x => x.GetPreSignedURL(It.IsAny()), Times.Once); + s3Client.Verify(x => x.GetPreSignedURL(It.IsAny()), Times.Never); } [Test] - public async Task upload_async_should_treat_format_exception_as_success_when_object_exists_after_upload() + public async Task upload_async_should_treat_malformed_put_response_as_success_without_using_presigned_uploads() { var s3Client = new Mock(MockBehavior.Strict); s3Client .Setup(x => x.PutObjectAsync(It.IsAny(), It.IsAny())) .ThrowsAsync(new FormatException("bad expiration header")); - s3Client - .Setup(x => x.GetObjectMetadataAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(new GetObjectMetadataResponse()); var handler = new RecordingHttpMessageHandler((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK))); @@ -109,216 +92,39 @@ public async Task upload_async_should_treat_format_exception_as_success_when_obj await service.UploadAsync("tts/audio.wav", content, "audio/wav", CancellationToken.None); handler.Requests.Should().BeEmpty(); - s3Client.Verify(x => x.GetObjectMetadataAsync(It.Is(request => request.BucketName == "tts-bucket" && request.Key == "tts/audio.wav"), It.IsAny()), Times.Once); + s3Client.Verify(x => x.PutObjectAsync(It.Is(request => request.BucketName == "tts-bucket" && request.Key == "tts/audio.wav" && request.ContentType == "audio/wav"), It.IsAny()), Times.Once); + s3Client.Verify(x => x.GetObjectMetadataAsync(It.IsAny(), It.IsAny()), Times.Never); s3Client.Verify(x => x.GetPreSignedURL(It.IsAny()), Times.Never); } [Test] - public async Task upload_async_should_fall_back_to_presigned_put_when_metadata_response_is_malformed() + public async Task upload_async_should_treat_malformed_put_response_as_success_even_if_sdk_disposes_input_stream() { var s3Client = new Mock(MockBehavior.Strict); + byte[] capturedPayload = null; s3Client .Setup(x => x.PutObjectAsync(It.IsAny(), It.IsAny())) - .ThrowsAsync(new FormatException("bad expiration header")); - s3Client - .Setup(x => x.GetObjectMetadataAsync(It.IsAny(), It.IsAny())) - .ThrowsAsync(new FormatException("bad metadata expiration header")); - s3Client - .Setup(x => x.GetPreSignedURL(It.IsAny())) - .Returns(request => - { - request.BucketName.Should().Be("tts-bucket"); - request.Key.Should().Be("tts/audio.wav"); - request.Protocol.Should().Be(Protocol.HTTP); - - return request.Verb switch - { - HttpVerb.HEAD => "http://upload.example.com/tts/audio.wav?signature=metadata-head", - HttpVerb.PUT => "http://upload.example.com/tts/audio.wav?signature=metadata-put", - _ => throw new AssertionException($"Unexpected presigned verb {request.Verb}") - }; - }); - - var headRequests = 0; - var putRequests = 0; - var handler = new RecordingHttpMessageHandler(async (request, cancellationToken) => - { - request.RequestUri.Should().NotBeNull(); - - if (request.Method == HttpMethod.Head) - { - headRequests++; - request.RequestUri.Should().Be(new Uri("http://upload.example.com/tts/audio.wav?signature=metadata-head")); - return new HttpResponseMessage(HttpStatusCode.NotFound); - } - - putRequests++; - request.Method.Should().Be(HttpMethod.Put); - request.RequestUri.Should().Be(new Uri("http://upload.example.com/tts/audio.wav?signature=metadata-put")); - - var body = await request.Content!.ReadAsByteArrayAsync(cancellationToken); - body.Should().Equal(2, 4, 6, 8); - request.Content.Headers.ContentType!.MediaType.Should().Be("audio/wav"); - - return new HttpResponseMessage(HttpStatusCode.OK); - }); - var service = CreateService(s3Client.Object, handler, useSsl: false); - - await using var content = new MemoryStream(new byte[] { 2, 4, 6, 8 }, writable: false); - - await service.UploadAsync("tts/audio.wav", content, "audio/wav", CancellationToken.None); - - headRequests.Should().Be(1); - putRequests.Should().Be(1); - handler.Requests.Should().HaveCount(2); - s3Client.Verify(x => x.GetObjectMetadataAsync(It.IsAny(), It.IsAny()), Times.Once); - s3Client.Verify(x => x.GetPreSignedURL(It.Is(request => request.Verb == HttpVerb.HEAD)), Times.Once); - s3Client.Verify(x => x.GetPreSignedURL(It.Is(request => request.Verb == HttpVerb.PUT)), Times.Once); - } - - [Test] - public async Task upload_async_should_fall_back_to_presigned_put_when_metadata_check_times_out() - { - var s3Client = new Mock(MockBehavior.Strict); - s3Client - .Setup(x => x.PutObjectAsync(It.IsAny(), It.IsAny())) - .ThrowsAsync(new FormatException("bad expiration header")); - s3Client - .Setup(x => x.GetObjectMetadataAsync(It.IsAny(), It.IsAny())) - .ThrowsAsync(new TaskCanceledException("metadata timeout")); - s3Client - .Setup(x => x.GetPreSignedURL(It.IsAny())) - .Returns("https://upload.example.com/tts/audio.wav?signature=timeout"); - - var handler = new RecordingHttpMessageHandler(async (request, cancellationToken) => - { - var body = await request.Content!.ReadAsByteArrayAsync(cancellationToken); - body.Should().Equal(6, 7, 8, 9); - request.Method.Should().Be(HttpMethod.Put); - request.RequestUri.Should().Be(new Uri("https://upload.example.com/tts/audio.wav?signature=timeout")); - request.Content!.Headers.ContentType!.MediaType.Should().Be("audio/wav"); - - return new HttpResponseMessage(HttpStatusCode.OK); - }); - var service = CreateService(s3Client.Object, handler); - - await using var content = new MemoryStream(new byte[] { 6, 7, 8, 9 }, writable: false); - - await service.UploadAsync("tts/audio.wav", content, "audio/wav", CancellationToken.None); - - handler.Requests.Should().HaveCount(1); - s3Client.Verify(x => x.GetObjectMetadataAsync(It.IsAny(), It.IsAny()), Times.Once); - s3Client.Verify(x => x.GetPreSignedURL(It.IsAny()), Times.Once); - } - - [Test] - public async Task upload_async_should_fall_back_to_presigned_put_when_put_response_is_malformed_and_object_is_missing() - { - var s3Client = new Mock(MockBehavior.Strict); - s3Client - .Setup(x => x.PutObjectAsync(It.IsAny(), It.IsAny())) - .ThrowsAsync(new FormatException("bad expiration header")); - s3Client - .Setup(x => x.GetObjectMetadataAsync(It.IsAny(), It.IsAny())) - .ThrowsAsync(new AmazonS3Exception("missing") - { - StatusCode = HttpStatusCode.NotFound, - ErrorCode = "NoSuchKey" - }); - s3Client - .Setup(x => x.GetPreSignedURL(It.IsAny())) - .Returns(request => - { - request.BucketName.Should().Be("tts-bucket"); - request.Key.Should().Be("tts/audio.wav"); - request.Verb.Should().Be(HttpVerb.PUT); - request.ContentType.Should().Be("audio/wav"); - return "https://upload.example.com/tts/audio.wav?signature=123"; - }); - - var handler = new RecordingHttpMessageHandler(async (request, cancellationToken) => - { - var body = await request.Content!.ReadAsByteArrayAsync(cancellationToken); - body.Should().Equal(5, 4, 3, 2); - request.Method.Should().Be(HttpMethod.Put); - request.RequestUri.Should().Be(new Uri("https://upload.example.com/tts/audio.wav?signature=123")); - request.Content!.Headers.ContentType!.MediaType.Should().Be("audio/wav"); - - return new HttpResponseMessage(HttpStatusCode.OK); - }); - var service = CreateService(s3Client.Object, handler); - - await using var content = new MemoryStream(new byte[] { 5, 4, 3, 2 }, writable: false); - - await service.UploadAsync("tts/audio.wav", content, "audio/wav", CancellationToken.None); - - handler.Requests.Should().HaveCount(1); - s3Client.Verify(x => x.GetObjectMetadataAsync(It.IsAny(), It.IsAny()), Times.Once); - s3Client.Verify(x => x.GetPreSignedURL(It.IsAny()), Times.Once); - } - - [Test] - public async Task upload_async_should_reuse_buffered_payload_when_falling_back_after_sdk_disposes_input_stream() - { - var s3Client = new Mock(MockBehavior.Strict); - s3Client - .Setup(x => x.PutObjectAsync(It.IsAny(), It.IsAny())) - .Returns((request, _) => + .Returns(async (request, _) => { + using var captureStream = new MemoryStream(); + await request.InputStream.CopyToAsync(captureStream); + capturedPayload = captureStream.ToArray(); request.InputStream.Dispose(); - return Task.FromException(new FormatException("bad expiration header")); + throw new FormatException("bad expiration header"); }); - s3Client - .Setup(x => x.GetObjectMetadataAsync(It.IsAny(), It.IsAny())) - .ThrowsAsync(new FormatException("bad metadata expiration header")); - s3Client - .Setup(x => x.GetPreSignedURL(It.IsAny())) - .Returns(request => - { - request.Protocol.Should().Be(Protocol.HTTP); - return request.Verb switch - { - HttpVerb.HEAD => "http://upload.example.com/tts/audio.wav?signature=disposed-head", - HttpVerb.PUT => "http://upload.example.com/tts/audio.wav?signature=disposed-put", - _ => throw new AssertionException($"Unexpected presigned verb {request.Verb}") - }; - }); - - var headRequests = 0; - var putRequests = 0; - var handler = new RecordingHttpMessageHandler(async (request, cancellationToken) => - { - if (request.Method == HttpMethod.Head) - { - headRequests++; - request.RequestUri.Should().Be(new Uri("http://upload.example.com/tts/audio.wav?signature=disposed-head")); - throw new HttpRequestException("connectivity failure"); - } - - putRequests++; - request.Method.Should().Be(HttpMethod.Put); - request.RequestUri.Should().Be(new Uri("http://upload.example.com/tts/audio.wav?signature=disposed-put")); - - var body = await request.Content!.ReadAsByteArrayAsync(cancellationToken); - body.Should().Equal(7, 5, 3, 1); - request.Content.Headers.ContentType!.MediaType.Should().Be("audio/wav"); - - return new HttpResponseMessage(HttpStatusCode.OK); - }); - - var service = CreateService(s3Client.Object, handler, useSsl: false); + var handler = new RecordingHttpMessageHandler((_, _) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK))); + var service = CreateService(s3Client.Object, handler); await using var content = new MemoryStream(new byte[] { 7, 5, 3, 1 }, writable: false); await service.UploadAsync("tts/audio.wav", content, "audio/wav", CancellationToken.None); - headRequests.Should().Be(3); - putRequests.Should().Be(1); - handler.Requests.Should().HaveCount(4); - s3Client.Verify(x => x.GetObjectMetadataAsync(It.IsAny(), It.IsAny()), Times.Once); - s3Client.Verify(x => x.GetPreSignedURL(It.Is(request => request.Verb == HttpVerb.HEAD)), Times.Exactly(3)); - s3Client.Verify(x => x.GetPreSignedURL(It.Is(request => request.Verb == HttpVerb.PUT)), Times.Once); + capturedPayload.Should().Equal(7, 5, 3, 1); + handler.Requests.Should().BeEmpty(); + s3Client.Verify(x => x.GetObjectMetadataAsync(It.IsAny(), It.IsAny()), Times.Never); + s3Client.Verify(x => x.GetPreSignedURL(It.IsAny()), Times.Never); } [Test] diff --git a/Tests/Resgrid.Tests/Web/Tts/TtsAdminControllerTests.cs b/Tests/Resgrid.Tests/Web/Tts/TtsAdminControllerTests.cs index 1d37a860..8a5c2231 100644 --- a/Tests/Resgrid.Tests/Web/Tts/TtsAdminControllerTests.cs +++ b/Tests/Resgrid.Tests/Web/Tts/TtsAdminControllerTests.cs @@ -33,8 +33,8 @@ public void SetUp() .Returns((_, hash) => new Uri($"https://tts.example.com/tts/audio/{hash}.wav")); _options = new TtsOptions { - DefaultVoice = "en-us+klatt6", - DefaultSpeed = 175, + DefaultVoice = "en-us+klatt4", + DefaultSpeed = 165, StaticPromptAdminKey = "secret-key", PreGeneratedPrompts = new List { "Alpha", "Beta" } }; @@ -56,7 +56,7 @@ public async Task regenerate_static_prompts_should_use_supplied_prompts_when_aut { var responses = new[] { - new TtsResponse { Hash = "a", ObjectKey = "tts/a.wav", Url = "https://cdn.example.com/tts/a.wav", Voice = "en-us", Speed = 175 } + new TtsResponse { Hash = "a", ObjectKey = "tts/a.wav", Url = "https://cdn.example.com/tts/a.wav", Voice = "en-us", Speed = 165 } }; List capturedPrompts = null; _ttsService @@ -95,8 +95,8 @@ public async Task regenerate_static_prompts_should_fall_back_to_configured_promp .Callback, CancellationToken>((requests, _) => capturedPrompts = requests.ToList()) .ReturnsAsync(new[] { - new TtsResponse { Hash = "a", ObjectKey = "tts/a.wav", Url = "https://cdn.example.com/tts/a.wav", Voice = "en-us+klatt6", Speed = 175 }, - new TtsResponse { Hash = "b", ObjectKey = "tts/b.wav", Url = "https://cdn.example.com/tts/b.wav", Voice = "en-us+klatt6", Speed = 175 } + new TtsResponse { Hash = "a", ObjectKey = "tts/a.wav", Url = "https://cdn.example.com/tts/a.wav", Voice = "en-us+klatt4", Speed = 165 }, + new TtsResponse { Hash = "b", ObjectKey = "tts/b.wav", Url = "https://cdn.example.com/tts/b.wav", Voice = "en-us+klatt4", Speed = 165 } }); var controller = BuildController(); @@ -106,8 +106,8 @@ public async Task regenerate_static_prompts_should_fall_back_to_configured_promp result.Result.Should().BeOfType(); capturedPrompts.Should().HaveCount(2); capturedPrompts!.Select(x => x.Text).Should().Equal("Alpha", "Beta"); - capturedPrompts.Select(x => x.Voice).Should().OnlyContain(x => x == "en-us+klatt6"); - capturedPrompts.Select(x => x.Speed).Should().OnlyContain(x => x == 175); + capturedPrompts.Select(x => x.Voice).Should().OnlyContain(x => x == "en-us+klatt4"); + capturedPrompts.Select(x => x.Speed).Should().OnlyContain(x => x == 165); } private TtsAdminController BuildController() diff --git a/Tests/Resgrid.Tests/Web/Tts/TtsServiceTests.cs b/Tests/Resgrid.Tests/Web/Tts/TtsServiceTests.cs index 75676d77..8dc76fdd 100644 --- a/Tests/Resgrid.Tests/Web/Tts/TtsServiceTests.cs +++ b/Tests/Resgrid.Tests/Web/Tts/TtsServiceTests.cs @@ -33,8 +33,8 @@ public void SetUp() _audioProcessingService.Object, Options.Create(new TtsOptions { - DefaultVoice = "en-us+klatt6", - DefaultSpeed = 175, + DefaultVoice = "en-us+klatt4", + DefaultSpeed = 165, MaxConcurrentGenerations = 2, MaxTextLength = 500 }), @@ -47,7 +47,7 @@ public async Task generate_async_should_return_cached_response_without_generatin var cachedUri = new Uri("https://cdn.example.com/tts/abc123.wav"); _cacheService - .Setup(x => x.CreateCacheKey("Press 1 for yes", "en-us+klatt6", 175)) + .Setup(x => x.CreateCacheKey("Press 1 for yes", "en-us+klatt4", 165)) .Returns(CacheKey); _cacheService .Setup(x => x.TryGetCachedUrlAsync(CacheKey, It.IsAny())) @@ -59,8 +59,8 @@ public async Task generate_async_should_return_cached_response_without_generatin result.Hash.Should().Be(CacheKey.Hash); result.ObjectKey.Should().Be(CacheKey.ObjectKey); result.Url.Should().Be(cachedUri.ToString()); - result.Voice.Should().Be("en-us+klatt6"); - result.Speed.Should().Be(175); + result.Voice.Should().Be("en-us+klatt4"); + result.Speed.Should().Be(165); _audioProcessingService.Verify( x => x.GenerateNormalizedWavAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), @@ -77,14 +77,14 @@ public async Task generate_async_should_generate_and_store_audio_when_cache_miss var objectUri = new Uri("https://cdn.example.com/tts/abc123.wav"); _cacheService - .Setup(x => x.CreateCacheKey("Press 1 for yes", "en-us+klatt6", 175)) + .Setup(x => x.CreateCacheKey("Press 1 for yes", "en-us+klatt4", 165)) .Returns(CacheKey); _cacheService .SetupSequence(x => x.TryGetCachedUrlAsync(CacheKey, It.IsAny())) .ReturnsAsync((Uri)null) .ReturnsAsync((Uri)null); _audioProcessingService - .Setup(x => x.GenerateNormalizedWavAsync("Press 1 for yes", "en-us+klatt6", 175, It.IsAny())) + .Setup(x => x.GenerateNormalizedWavAsync("Press 1 for yes", "en-us+klatt4", 165, It.IsAny())) .ReturnsAsync(audioBytes); _cacheService .Setup(x => x.StoreAsync(CacheKey, It.Is(bytes => bytes.SequenceEqual(audioBytes)), It.IsAny())) @@ -94,11 +94,11 @@ public async Task generate_async_should_generate_and_store_audio_when_cache_miss result.Cached.Should().BeFalse(); result.Url.Should().Be(objectUri.ToString()); - result.Voice.Should().Be("en-us+klatt6"); - result.Speed.Should().Be(175); + result.Voice.Should().Be("en-us+klatt4"); + result.Speed.Should().Be(165); _audioProcessingService.Verify( - x => x.GenerateNormalizedWavAsync("Press 1 for yes", "en-us+klatt6", 175, It.IsAny()), + x => x.GenerateNormalizedWavAsync("Press 1 for yes", "en-us+klatt4", 165, It.IsAny()), Times.Once); _cacheService.Verify( x => x.StoreAsync(CacheKey, It.Is(bytes => bytes.SequenceEqual(audioBytes)), It.IsAny()), @@ -112,7 +112,7 @@ public async Task generate_async_should_apply_configured_klatt_variant_to_reques var cacheKey = new TtsCacheKey("xyz789", "tts/xyz789.wav"); _cacheService - .Setup(x => x.CreateCacheKey("Bonjour", "fr+klatt6", 175)) + .Setup(x => x.CreateCacheKey("Bonjour", "fr+klatt4", 165)) .Returns(cacheKey); _cacheService .Setup(x => x.TryGetCachedUrlAsync(cacheKey, It.IsAny())) @@ -121,7 +121,7 @@ public async Task generate_async_should_apply_configured_klatt_variant_to_reques var result = await _service.GenerateAsync(new TtsRequest { Text = "Bonjour", Voice = "fr" }, CancellationToken.None); result.Cached.Should().BeTrue(); - result.Voice.Should().Be("fr+klatt6"); + result.Voice.Should().Be("fr+klatt4"); result.Url.Should().Be(cachedUri.ToString()); _audioProcessingService.Verify( @@ -129,23 +129,24 @@ public async Task generate_async_should_apply_configured_klatt_variant_to_reques Times.Never); } - [Test] - public async Task generate_async_should_replace_legacy_f3_voice_with_configured_klatt_variant() + [TestCase("en-us+f3")] + [TestCase("en-us+klatt6")] + public async Task generate_async_should_replace_legacy_default_voices_with_configured_klatt_variant(string requestedVoice) { var cachedUri = new Uri("https://cdn.example.com/tts/legacy.wav"); var cacheKey = new TtsCacheKey("legacy", "tts/legacy.wav"); _cacheService - .Setup(x => x.CreateCacheKey("Press 1 for yes", "en-us+klatt6", 175)) + .Setup(x => x.CreateCacheKey("Press 1 for yes", "en-us+klatt4", 165)) .Returns(cacheKey); _cacheService .Setup(x => x.TryGetCachedUrlAsync(cacheKey, It.IsAny())) .ReturnsAsync(cachedUri); - var result = await _service.GenerateAsync(new TtsRequest { Text = "Press 1 for yes", Voice = "en-us+f3" }, CancellationToken.None); + var result = await _service.GenerateAsync(new TtsRequest { Text = "Press 1 for yes", Voice = requestedVoice }, CancellationToken.None); result.Cached.Should().BeTrue(); - result.Voice.Should().Be("en-us+klatt6"); + result.Voice.Should().Be("en-us+klatt4"); result.Url.Should().Be(cachedUri.ToString()); _audioProcessingService.Verify( @@ -172,17 +173,17 @@ public async Task generate_async_should_deduplicate_concurrent_generation_for_th var cacheLookupCount = 0; _cacheService - .Setup(x => x.CreateCacheKey("Press 1 for yes", "en-us+klatt6", 175)) + .Setup(x => x.CreateCacheKey("Press 1 for yes", "en-us+klatt4", 165)) .Returns(CacheKey); _cacheService .Setup(x => x.TryGetCachedUrlAsync(CacheKey, It.IsAny())) .Returns(() => { var attempt = Interlocked.Increment(ref cacheLookupCount); - return Task.FromResult(attempt < 4 ? null : objectUri); + return Task.FromResult(attempt < 4 ? null : objectUri); }); _audioProcessingService - .Setup(x => x.GenerateNormalizedWavAsync("Press 1 for yes", "en-us+klatt6", 175, It.IsAny())) + .Setup(x => x.GenerateNormalizedWavAsync("Press 1 for yes", "en-us+klatt4", 165, It.IsAny())) .Returns(async () => { generationStarted.TrySetResult(true); @@ -206,7 +207,7 @@ public async Task generate_async_should_deduplicate_concurrent_generation_for_th responses.Count(response => response.Cached).Should().Be(1); responses.Count(response => !response.Cached).Should().Be(1); _audioProcessingService.Verify( - x => x.GenerateNormalizedWavAsync("Press 1 for yes", "en-us+klatt6", 175, It.IsAny()), + x => x.GenerateNormalizedWavAsync("Press 1 for yes", "en-us+klatt4", 165, It.IsAny()), Times.Once); _cacheService.Verify( x => x.StoreAsync(CacheKey, It.Is(bytes => bytes.SequenceEqual(audioBytes)), It.IsAny()), diff --git a/Web/Resgrid.Web.Services/Controllers/v4/CallFilesController.cs b/Web/Resgrid.Web.Services/Controllers/v4/CallFilesController.cs index 891bb8f4..6d145e97 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/CallFilesController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/CallFilesController.cs @@ -26,7 +26,6 @@ namespace Resgrid.Web.Services.Controllers.v4 [Route("api/v{VersionId:apiVersion}/[controller]")] [ApiVersion("4.0")] [ApiExplorerSettings(GroupName = "v4")] - [Authorize(AuthenticationSchemes = "BasicAuthentication,SystemApiKey")] public class CallFilesController : V4AuthenticatedApiControllerbaseSystemAuth { #region Members and Constructors diff --git a/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs index 08f9caa2..31b0ca64 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs @@ -32,7 +32,6 @@ namespace Resgrid.Web.Services.Controllers.v4 [Route("api/v{VersionId:apiVersion}/[controller]")] [ApiVersion("4.0")] [ApiExplorerSettings(GroupName = "v4")] - [Authorize(AuthenticationSchemes = "BasicAuthentication,SystemApiKey")] public class CallsController : V4AuthenticatedApiControllerbaseSystemAuth { #region Members and Constructors diff --git a/Web/Resgrid.Web.Services/Controllers/v4/DepartmentsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/DepartmentsController.cs index b377ee2c..8e15290d 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/DepartmentsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/DepartmentsController.cs @@ -17,7 +17,6 @@ namespace Resgrid.Web.Services.Controllers.v4 [Route("api/v{VersionId:apiVersion}/[controller]")] [ApiVersion("4.0")] [ApiExplorerSettings(GroupName = "v4")] - [Authorize(AuthenticationSchemes = "BasicAuthentication,SystemApiKey")] public class DepartmentsController : V4AuthenticatedApiControllerbaseSystemAuth { #region Members and Constructors diff --git a/Web/Resgrid.Web.Services/Controllers/v4/GroupsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/GroupsController.cs index 26ea691c..b45d7c64 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/GroupsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/GroupsController.cs @@ -17,7 +17,6 @@ namespace Resgrid.Web.Services.Controllers.v4 [Route("api/v{VersionId:apiVersion}/[controller]")] [ApiVersion("4.0")] [ApiExplorerSettings(GroupName = "v4")] - [Authorize(AuthenticationSchemes = "BasicAuthentication,SystemApiKey")] public class GroupsController : V4AuthenticatedApiControllerbaseSystemAuth { #region Members and Constructors diff --git a/Web/Resgrid.Web.Services/Controllers/v4/RolesController.cs b/Web/Resgrid.Web.Services/Controllers/v4/RolesController.cs index be5b05d3..b5621866 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/RolesController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/RolesController.cs @@ -18,7 +18,6 @@ namespace Resgrid.Web.Services.Controllers.v4 [Route("api/v{VersionId:apiVersion}/[controller]")] [ApiVersion("4.0")] [ApiExplorerSettings(GroupName = "v4")] - [Authorize(AuthenticationSchemes = "BasicAuthentication,SystemApiKey")] public class RolesController : V4AuthenticatedApiControllerbaseSystemAuth { #region Members and Constructors diff --git a/Web/Resgrid.Web.Services/Controllers/v4/UnitsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/UnitsController.cs index 82b4883d..b04ac630 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/UnitsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/UnitsController.cs @@ -24,7 +24,6 @@ namespace Resgrid.Web.Services.Controllers.v4 [Route("api/v{VersionId:apiVersion}/[controller]")] [ApiVersion("4.0")] [ApiExplorerSettings(GroupName = "v4")] - [Authorize(AuthenticationSchemes = "BasicAuthentication,SystemApiKey")] public class UnitsController : V4AuthenticatedApiControllerbaseSystemAuth { #region Members and Constructors diff --git a/Web/Resgrid.Web.Services/Controllers/v4/V4AuthenticatedApiControllerbaseSystemAuth.cs b/Web/Resgrid.Web.Services/Controllers/v4/V4AuthenticatedApiControllerbaseSystemAuth.cs index 4a5b4151..f8e64ac9 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/V4AuthenticatedApiControllerbaseSystemAuth.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/V4AuthenticatedApiControllerbaseSystemAuth.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Mvc; using System.Linq; using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; using Resgrid.Providers.Claims; using Resgrid.Web.ServicesCore.Helpers; @@ -12,11 +13,12 @@ namespace Resgrid.Web.Services.Controllers.v4 /// /// Base controller for v4 API endpoints that accept both standard OAuth/OIDC AND SystemApiKey /// authentication (used by the SMTP Relay in hosted multi-department mode). - /// + /// /// Controllers that only need standard OAuth should use instead. /// [ApiController] [Produces("application/json")] + [Authorize(AuthenticationSchemes = $"{OpenIddict.Validation.AspNetCore.OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme},SystemApiKey")] public class V4AuthenticatedApiControllerbaseSystemAuth : ControllerBase { /// diff --git a/Web/Resgrid.Web.Services/Middleware/AuthTokenMiddleware.cs b/Web/Resgrid.Web.Services/Middleware/AuthTokenMiddleware.cs deleted file mode 100644 index 744cb7f0..00000000 --- a/Web/Resgrid.Web.Services/Middleware/AuthTokenMiddleware.cs +++ /dev/null @@ -1,272 +0,0 @@ -using Resgrid.Framework; -using Resgrid.Model.Repositories; -using System; -using System.Net; -using System.Net.Http.Headers; -using System.Security.Principal; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using Resgrid.Model.Custom; -using Resgrid.Model.Providers; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Primitives; -using System.Linq; -using Resgrid.Providers.Claims; - -namespace Resgrid.Web.ServicesCore.Middleware -{ - public class AuthTokenMiddleware - { - private static string ValidateUserInfoCacheKey = "ValidateUserInfo_{0}"; - private static TimeSpan CacheLength = TimeSpan.FromDays(14); - - private readonly RequestDelegate _next; - private readonly ILogger _logger; - private readonly ICacheProvider _cacheProvider; - private readonly IDepartmentsRepository _departmentRepository; - - - public AuthTokenMiddleware(RequestDelegate next, ILoggerFactory loggerFactory, IDepartmentsRepository departmentRepository, ICacheProvider cacheProvider) - { - _next = next; - _logger = loggerFactory.CreateLogger(); - _departmentRepository = departmentRepository; - _cacheProvider = cacheProvider; - } - - public async Task Invoke(HttpContext context) - { - _logger.LogInformation("Handling API key for: " + context.Request.Path); - - if (!context.Request.Path.Value.ToLower().Contains("v3/auth/validate")) - await AuthAndSetPrinciple(_cacheProvider, _departmentRepository, context, context.Request.Path.Value.Contains("v3")); - - - await _next.Invoke(context); - - _logger.LogInformation("Finished handling api key."); - } - - public static async Task AuthAndSetPrinciple(ICacheProvider cacheProvider, IDepartmentsRepository departmentsRepository, HttpContext context, bool v3) - { - StringValues authHeader; - if (context.Request.Headers.TryGetValue("Authorization", out authHeader)) - { - if (authHeader.Count <= 0) - return false; - - if (!authHeader[0].Contains("Basic")) - return false; - - return await AuthAndSetPrinciple(cacheProvider, departmentsRepository, authHeader[0].Replace("Basic", "").Trim(), context, v3); - } - - return false; - } - - public static async Task AuthAndSetPrinciple(ICacheProvider cacheProvider, IDepartmentsRepository departmentsRepository, string authTokenString, HttpContext context, bool v3) - { - if (string.IsNullOrWhiteSpace(authTokenString)) - return false; - - var encodedUserPass = authTokenString.Trim(); - - if (v3) - { - var authToken = V3AuthToken.Decode(encodedUserPass); - - if (authToken != null) - { - string userId; - - if (Config.SecurityConfig.SystemLoginCredentials.ContainsKey(authToken.UserName)) - { - if (Config.SecurityConfig.SystemLoginCredentials[authToken.UserName] != encodedUserPass) - return false; - - authToken.UserId = authToken.UserName; - } - else - { - var result = await ValidateUserAndDepartmentByUser(cacheProvider, departmentsRepository, authToken.UserName, authToken.DepartmentId, null); - if (!result.IsValid) - return false; - - authToken.UserId = result.UserId; - } - - var principal = new ResgridPrincipleV3(authToken); - Thread.CurrentPrincipal = principal; - if (context != null) - { - context.User = new System.Security.Claims.ClaimsPrincipal(principal); - } - } - } - - return true; - } - - private static async Task ValidateUserAndDepartmentByUser(ICacheProvider cacheProvider, IDepartmentsRepository departmentRepository, string userName, int departmentId, string departmentCode) - { - var result = new AuthValidationResult(); - - var data = await GetValidateUserForDepartmentInfo(cacheProvider, departmentRepository, userName, false); - result.UserId = string.Empty; - - result.IsValid = true; - - if (data == null) - result.IsValid = false; - - result.UserId = data.UserId; - - if (data.DepartmentId != departmentId) - result.IsValid = false; - - if (data.IsDisabled.GetValueOrDefault()) - result.IsValid = false; - - if (data.IsDeleted.GetValueOrDefault()) - result.IsValid = false; - - if (departmentCode != null) - if (!data.Code.Equals(departmentCode, StringComparison.InvariantCultureIgnoreCase)) - result.IsValid = false; - - return result; - } - - private static async Task GetValidateUserForDepartmentInfo(ICacheProvider cacheProvider, IDepartmentsRepository departmentRepository, string userName, bool bypassCache = true) - { - async Task validateForDepartment() - { - return await departmentRepository.GetValidateUserForDepartmentDataAsync(userName); - } - - if (!bypassCache) - { - return await cacheProvider.RetrieveAsync(string.Format(ValidateUserInfoCacheKey, userName), validateForDepartment, CacheLength); - } - - return await validateForDepartment(); - } - } - - public class V3AuthToken - { - public string UserName { get; private set; } - public int DepartmentId { get; private set; } - public DateTime TokenExpiry { get; private set; } - public string UserId { get; set; } - - public V3AuthToken(string userName, int departmentId, DateTime tokenExpiry) - { - UserName = userName; - DepartmentId = departmentId; - TokenExpiry = tokenExpiry; - } - - public static V3AuthToken Decode(string authHeader) - { - if (string.IsNullOrEmpty(authHeader)) - throw new ArgumentException("value cannot be null or empty", "authHeader"); - - string[] rows = null; - - byte[] authBytes = null; - string cypherText = null; - string plainText = null; - - try - { - authBytes = Convert.FromBase64String(authHeader); - cypherText = Encoding.ASCII.GetString(authBytes); - plainText = SymmetricEncryption.Decrypt(cypherText, Config.SystemBehaviorConfig.ApiTokenEncryptionPassphrase); - - rows = plainText.Split('|'); - } - catch (Exception ex) - { - Logging.LogException(ex, $"{cypherText} {plainText}"); - //TODO: log exception here? with metada used in authHeader? - return null; - } - - if (rows.Length != 3) - { - return null; - } - - string username = rows[0]; - int departmentId; - DateTime tokenExpiry; - - if (string.IsNullOrEmpty(username)) - { - return null; - } - - if (!int.TryParse(rows[1], out departmentId)) - { - return null; - } - - if (!DateTime.TryParse(rows[2], out tokenExpiry)) - { - return null; - } - - if (tokenExpiry <= DateTime.UtcNow) - { - return null; - } - - return new V3AuthToken(username, departmentId, tokenExpiry); - } - - public static string Create(string userName, int departmentId) - { - var painText = string.Join("|", new[] { userName, departmentId.ToString(), DateTime.UtcNow.AddMonths(Config.SystemBehaviorConfig.APITokenMonthsTTL).ToShortDateString() }); - var encryptedText = SymmetricEncryption.Encrypt(painText, Config.SystemBehaviorConfig.ApiTokenEncryptionPassphrase); - var buffer = Encoding.ASCII.GetBytes(encryptedText); - var authHeader = Convert.ToBase64String(buffer); - - return authHeader; - } - - public static AuthenticationHeaderValue GetAuthHeaderValue(V3AuthToken authToken) - { - var authString = Create(authToken.UserName, authToken.DepartmentId); - return new AuthenticationHeaderValue("Basic", authString); - } - } - - public class ResgridPrincipleV3 : IPrincipal - { - private IIdentity _identity; - public ResgridPrincipleV3(V3AuthToken authToken) - { - AuthToken = authToken; - IsSystem = false; - - _identity = new GenericIdentity(authToken.UserName, "Basic"); - } - - public V3AuthToken AuthToken { get; private set; } - - public IIdentity Identity - { - get { return _identity; } - } - - public bool IsInRole(string role) - { - throw new NotImplementedException(); - } - - public bool IsSystem { get; set; } - } -} diff --git a/Web/Resgrid.Web.Services/Middleware/MiddlewareExtensions.cs b/Web/Resgrid.Web.Services/Middleware/MiddlewareExtensions.cs deleted file mode 100644 index f4c5e7c6..00000000 --- a/Web/Resgrid.Web.Services/Middleware/MiddlewareExtensions.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Microsoft.AspNetCore.Builder; - -namespace Resgrid.Web.ServicesCore.Middleware -{ - public static class MiddlewareExtensions - { - public static IApplicationBuilder UseV3AuthTokenMiddleware(this IApplicationBuilder builder) - { - return builder.UseMiddleware(); - } - } -} diff --git a/Web/Resgrid.Web.Services/Middleware/TokenAuthHandler.cs b/Web/Resgrid.Web.Services/Middleware/TokenAuthHandler.cs deleted file mode 100644 index 29da7558..00000000 --- a/Web/Resgrid.Web.Services/Middleware/TokenAuthHandler.cs +++ /dev/null @@ -1,188 +0,0 @@ -using System; -using System.Net.Http.Headers; -using System.Text; -using System.Text.Encodings.Web; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Identity; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Resgrid.Framework; -using Resgrid.Model.Custom; -using Resgrid.Model.Providers; -using Resgrid.Model.Repositories; -using Resgrid.Model.Services; -using Resgrid.Providers.Claims; -using Resgrid.Web.ServicesCore.Middleware; -using IdentityUser = Resgrid.Model.Identity.IdentityUser; - -namespace Resgrid.Web.Services.Middleware -{ - public class ResgridTokenAuthHandler : AuthenticationHandler - { - private static string ValidateUserInfoCacheKey = "ValidateUserInfo_{0}"; - private static TimeSpan CacheLength = TimeSpan.FromDays(14); - - private readonly ICacheProvider _cacheProvider; - private readonly IDepartmentsRepository _departmentRepository; - private readonly IUserClaimsPrincipalFactory _claimsPrincipalFactory; - private readonly IUsersService _usersService; - private readonly ILoggerFactory _logger; - - public ResgridTokenAuthHandler(IOptionsMonitor options, ILoggerFactory logger, - UrlEncoder encoder, ISystemClock clock, ICacheProvider cacheProvider, IDepartmentsRepository departmentRepository, - IUserClaimsPrincipalFactory claimsPrincipalFactory, IUsersService usersService) - : base(options, logger, encoder, clock) - { - _cacheProvider = cacheProvider; - _departmentRepository = departmentRepository; - _claimsPrincipalFactory = claimsPrincipalFactory; - _usersService = usersService; - _logger = logger; - } - - protected new ResgridAuthenticationEvents Events - { - get { return (ResgridAuthenticationEvents)base.Events; } - set { base.Events = value; } - } - - protected override Task CreateEventsAsync() => Task.FromResult(new ResgridAuthenticationEvents()); - - protected override async Task HandleAuthenticateAsync() - { - var endpoint = Context.GetEndpoint(); - if (endpoint?.Metadata?.GetMetadata() != null) - return AuthenticateResult.NoResult(); - - if (!Request.Headers.ContainsKey("Authorization")) - return AuthenticateResult.Fail("Missing Authorization Header"); - - try - { - //var authHeader = AuthenticationHeaderValue.Parse(Request.Headers["Authorization"]); - var authHeaderValue = Request.Headers["Authorization"].ToString(); - - if (string.IsNullOrWhiteSpace(authHeaderValue)) - return AuthenticateResult.Fail("Missing Authorization Header value, blank"); - - authHeaderValue = authHeaderValue.Replace("Basic", "", StringComparison.InvariantCultureIgnoreCase).Trim(); - - if (string.IsNullOrWhiteSpace(authHeaderValue)) - return AuthenticateResult.Fail("Missing Authorization Header value, no data with auth type"); - - var result = await AuthAndSetPrinciple(_cacheProvider, _departmentRepository, authHeaderValue); - - if (!result) - return AuthenticateResult.Fail($"Invalid Authorization Header: {authHeaderValue}"); - - var authToken = V3AuthToken.Decode(authHeaderValue); - - if (authToken == null) - return AuthenticateResult.Fail($"Invalid Authorization Header, null auth token: {authHeaderValue}"); - - var user = await _usersService.GetUserByNameAsync(authToken.UserName); - var principal = await _claimsPrincipalFactory.CreateAsync(user); - - Thread.CurrentPrincipal = principal; - Context.User = principal; - - var ticket = new AuthenticationTicket(principal, Scheme.Name); - return AuthenticateResult.Success(ticket); - } - catch (Exception ex) - { - Logging.LogException(ex); - return AuthenticateResult.Fail($"Invalid Authorization Header: {ex}"); - } - } - - public static async Task AuthAndSetPrinciple(ICacheProvider cacheProvider, IDepartmentsRepository departmentsRepository, string authTokenString) - { - if (string.IsNullOrWhiteSpace(authTokenString)) - return false; - - var encodedUserPass = authTokenString.Trim(); - - var authToken = V3AuthToken.Decode(encodedUserPass); - - if (authToken != null) - { - string userId; - - if (Config.SecurityConfig.SystemLoginCredentials.ContainsKey(authToken.UserName)) - { - if (Config.SecurityConfig.SystemLoginCredentials[authToken.UserName] != encodedUserPass) - return false; - - authToken.UserId = authToken.UserName; - } - else - { - var result = await ValidateUserAndDepartmentByUser(cacheProvider, departmentsRepository, authToken.UserName, authToken.DepartmentId, null); - if (!result.IsValid) - return false; - - authToken.UserId = result.UserId; - } - - //var principal = new ResgridPrincipleV3(authToken); - //Thread.CurrentPrincipal = principal; - //if (context != null) - //{ - // context.User = new System.Security.Claims.ClaimsPrincipal(principal); - //} - } - - return true; - } - - private static async Task ValidateUserAndDepartmentByUser(ICacheProvider cacheProvider, IDepartmentsRepository departmentRepository, string userName, int departmentId, string departmentCode) - { - var result = new AuthValidationResult(); - - var data = await GetValidateUserForDepartmentInfo(cacheProvider, departmentRepository, userName, false); - result.UserId = string.Empty; - - result.IsValid = true; - - if (data == null) - result.IsValid = false; - - result.UserId = data.UserId; - - if (data.DepartmentId != departmentId) - result.IsValid = false; - - if (data.IsDisabled.GetValueOrDefault()) - result.IsValid = false; - - if (data.IsDeleted.GetValueOrDefault()) - result.IsValid = false; - - if (departmentCode != null) - if (!data.Code.Equals(departmentCode, StringComparison.InvariantCultureIgnoreCase)) - result.IsValid = false; - - return result; - } - - private static async Task GetValidateUserForDepartmentInfo(ICacheProvider cacheProvider, IDepartmentsRepository departmentRepository, string userName, bool bypassCache = true) - { - async Task validateForDepartment() - { - return await departmentRepository.GetValidateUserForDepartmentDataAsync(userName); - } - - if (!bypassCache) - { - return await cacheProvider.RetrieveAsync(string.Format(ValidateUserInfoCacheKey, userName), validateForDepartment, CacheLength); - } - - return await validateForDepartment(); - } - } -} diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index 7fb17dc1..15601e50 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -1760,12 +1760,12 @@ - - Base controller for v4 API endpoints that accept both standard OAuth/OIDC AND SystemApiKey - authentication (used by the SMTP Relay in hosted multi-department mode). + + Base controller for v4 API endpoints that accept both standard OAuth/OIDC AND SystemApiKey + authentication (used by the SMTP Relay in hosted multi-department mode). - Controllers that only need standard OAuth should use instead. - + Controllers that only need standard OAuth should use instead. + diff --git a/Web/Resgrid.Web.Services/Startup.cs b/Web/Resgrid.Web.Services/Startup.cs index 2880afac..483b6de2 100644 --- a/Web/Resgrid.Web.Services/Startup.cs +++ b/Web/Resgrid.Web.Services/Startup.cs @@ -593,8 +593,7 @@ public void ConfigureServices(IServiceCollection services) //}); - services.AddAuthentication("BasicAuthentication") - .AddScheme("BasicAuthentication", null) + services.AddAuthentication(OpenIddict.Validation.AspNetCore.OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme) .AddScheme("SystemApiKey", null); //// TODO: Add IServiceCollection.AddOpenTelemetryMetrics extension method diff --git a/Web/Resgrid.Web.Tts/Configuration/TtsOptions.cs b/Web/Resgrid.Web.Tts/Configuration/TtsOptions.cs index 602d560b..38be54b0 100644 --- a/Web/Resgrid.Web.Tts/Configuration/TtsOptions.cs +++ b/Web/Resgrid.Web.Tts/Configuration/TtsOptions.cs @@ -5,10 +5,10 @@ namespace Resgrid.Web.Tts.Configuration public sealed class TtsOptions { [Required] - public string DefaultVoice { get; set; } = "en-us+klatt6"; + public string DefaultVoice { get; set; } = "en-us+klatt4"; [Range(80, 450)] - public int DefaultSpeed { get; set; } = 175; + public int DefaultSpeed { get; set; } = 165; [Range(1, 64)] public int MaxConcurrentGenerations { get; set; } = 4; @@ -53,7 +53,35 @@ public sealed class TtsOptions "Press 2 for no", "Invalid option", "Please try again", - "Please stay on the line" + "Please stay on the line", + "This call has been closed. Goodbye.", + "You have been marked responding to the scene. Goodbye.", + "Sorry, that was not a valid selection.", + "Hello, this is Resgrid calling with your verification code.", + "That was your Resgrid verification code. Goodbye.", + "Thank you for calling the Resgrid automated personnel system. The number you called is not tied to an active department, or the department doesn't have this feature enabled. Goodbye.", + "We couldn't complete your verification call. Please request a new code and try again. Goodbye.", + "Please select from the following options.", + "To list current active calls, press 1.", + "To list current user statuses, press 2.", + "To list current unit statuses, press 3.", + "To list upcoming calendar events, press 4.", + "To list upcoming shifts, press 5.", + "To set your current status, press 6.", + "To set your current staffing level, press 7.", + "Press 0 to repeat. Press 1 to respond to the scene.", + "To hear the dispatch again, press 1. To hear response options, press 2.", + "To choose a response option, enter the option number, then press pound.", + "To hear the dispatch again, enter 0 and press pound.", + "Press 0 to go back to the main menu.", + "To go back to the main menu, enter 0 and press pound.", + "To set your current status, enter the number of your selection, then press pound.", + "To set your current staffing, enter the number of your selection, then press pound.", + "Invalid status selection. Returning to the main menu.", + "No status selection made. Returning to the main menu.", + "Invalid staffing selection. Returning to the main menu.", + "No staffing selection made. Returning to the main menu.", + "Thank you. Your response has been recorded." }; } } diff --git a/Web/Resgrid.Web.Tts/Dockerfile b/Web/Resgrid.Web.Tts/Dockerfile index eef9f4e3..0c22b22e 100644 --- a/Web/Resgrid.Web.Tts/Dockerfile +++ b/Web/Resgrid.Web.Tts/Dockerfile @@ -21,7 +21,6 @@ RUN dotnet publish "Resgrid.Web.Tts.csproj" -c Release -o /app/publish /p:UseApp FROM base AS final RUN apt-get update \ && apt-get install -y --no-install-recommends espeak-ng ffmpeg ca-certificates \ - && printf 'language variant\nname klatt6\nklatt 6\n' > /usr/lib/x86_64-linux-gnu/espeak-ng-data/voices/!v/klatt6 \ && rm -rf /var/lib/apt/lists/* \ && groupadd --gid 10001 appgroup \ && useradd --uid 10001 --gid appgroup --create-home --shell /usr/sbin/nologin appuser diff --git a/Web/Resgrid.Web.Tts/Services/S3StorageService.cs b/Web/Resgrid.Web.Tts/Services/S3StorageService.cs index 426a4eb6..d871a84b 100644 --- a/Web/Resgrid.Web.Tts/Services/S3StorageService.cs +++ b/Web/Resgrid.Web.Tts/Services/S3StorageService.cs @@ -54,12 +54,12 @@ await ExecuteWithRetryAsync( } catch (FormatException ex) { - _logger.LogWarning( + _logger.LogDebug( ex, - "The S3 client could not parse the metadata response for {ObjectKey}. Falling back to a presigned HEAD request.", + "The S3 client could not parse the metadata response for {ObjectKey}. Treating the object as existing because the metadata request completed before the response parsing failure.", objectKey); - return await ExistsWithPresignedHeadAsync(objectKey, cancellationToken); + return true; } } @@ -80,27 +80,19 @@ await ExecuteWithRetryAsync( } } - private async Task HandleMalformedPutResponseAsync( + private Task HandleMalformedPutResponseAsync( string objectKey, byte[] payload, string contentType, FormatException exception, CancellationToken cancellationToken) { - _logger.LogWarning( + _logger.LogDebug( exception, - "The S3 client could not parse the PUT response for {ObjectKey}. Checking if the object was stored before falling back to a presigned PUT upload.", + "The S3 client could not parse the PUT response for {ObjectKey}. Treating the upload as successful because the object upload completed before the response parsing failure.", objectKey); - if (await WasUploadPersistedAsync(objectKey, cancellationToken)) - { - _logger.LogInformation( - "Treating upload of {ObjectKey} as successful because the object exists after the response parsing failure.", - objectKey); - return; - } - - await UploadWithPresignedUrlAsync(objectKey, payload, contentType, cancellationToken); + return Task.CompletedTask; } private async Task WasUploadPersistedAsync(string objectKey, CancellationToken cancellationToken) diff --git a/Web/Resgrid.Web.Tts/Services/TtsService.cs b/Web/Resgrid.Web.Tts/Services/TtsService.cs index 293fa799..2db62ce0 100644 --- a/Web/Resgrid.Web.Tts/Services/TtsService.cs +++ b/Web/Resgrid.Web.Tts/Services/TtsService.cs @@ -220,13 +220,14 @@ private NormalizedTtsRequest NormalizeRequest(TtsRequest? request) private string NormalizeVoice(string? voice) { var configuredDefaultVoice = string.IsNullOrWhiteSpace(_options.DefaultVoice) - ? "en-us+klatt6" + ? "en-us+klatt4" : _options.DefaultVoice.Trim(); var requestedVoice = string.IsNullOrWhiteSpace(voice) ? configuredDefaultVoice : voice.Trim(); - if (string.Equals(requestedVoice, "en-us+f3", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(requestedVoice, "en-us+f3", StringComparison.OrdinalIgnoreCase) || + string.Equals(requestedVoice, "en-us+klatt6", StringComparison.OrdinalIgnoreCase)) { return configuredDefaultVoice; } diff --git a/Web/Resgrid.Web.Tts/k8s/deployment.yaml b/Web/Resgrid.Web.Tts/k8s/deployment.yaml index a9213255..3dc94b59 100644 --- a/Web/Resgrid.Web.Tts/k8s/deployment.yaml +++ b/Web/Resgrid.Web.Tts/k8s/deployment.yaml @@ -17,8 +17,8 @@ data: RESGRID__TtsConfig__S3ForcePathStyle: "true" RESGRID__TtsConfig__S3UsePresignedUrls: "true" RESGRID__TtsConfig__S3PresignedUrlExpiryMinutes: "60" - RESGRID__TtsConfig__DefaultVoice: en-us+klatt6 - RESGRID__TtsConfig__DefaultSpeed: "175" + RESGRID__TtsConfig__DefaultVoice: en-us+klatt4 + RESGRID__TtsConfig__DefaultSpeed: "165" RESGRID__TtsConfig__MaxConcurrentGenerations: "4" RESGRID__TtsConfig__MaxTextLength: "1000" RESGRID__TtsConfig__EspeakExecutable: /usr/bin/espeak-ng @@ -30,7 +30,7 @@ data: RESGRID__TtsConfig__WarmupEnabled: "true" RESGRID__TtsConfig__StaticPromptRefreshIntervalMinutes: "1440" RESGRID__TtsConfig__PreGeneratedPrompts: >- - Press 1 for yes;Press 2 for no;Invalid option;Please try again;Please stay on the line;This call has been closed. Goodbye.;You have been marked responding to the scene, goodbye.;Sorry, that was not a valid selection.;Hello, this is Resgrid calling with your verification code.;That was your Resgrid verification code. Goodbye.;Thank you for calling Resgrid, automated personnel system. The number you called is not tied to an active department or the department doesn't have this feature enabled. Goodbye.;We couldn't complete your verification call. Please request a new code and try again. Goodbye.;Please select from the following options.;To list current active calls, press 1.;To list current user statuses, press 2.;To list current unit statuses, press 3.;To list upcoming calendar events, press 4.;To list upcoming shifts, press 5.;To set your current status, press 6.;To set your current staffing level, press 7.;Press 0 to repeat. Press 1 to respond to the scene.;Press 0 to go back to the main menu.;Invalid status selection, goodbye.;No status selection made, goodbye.;Invalid staffing selection. Returning to the main menu.;No staffing selection made. Returning to the main menu.;Thank you. Your response has been recorded. + Press 1 for yes;Press 2 for no;Invalid option;Please try again;Please stay on the line;This call has been closed. Goodbye.;You have been marked responding to the scene. Goodbye.;Sorry, that was not a valid selection.;Hello, this is Resgrid calling with your verification code.;That was your Resgrid verification code. Goodbye.;Thank you for calling the Resgrid automated personnel system. The number you called is not tied to an active department, or the department doesn't have this feature enabled. Goodbye.;We couldn't complete your verification call. Please request a new code and try again. Goodbye.;Please select from the following options.;To list current active calls, press 1.;To list current user statuses, press 2.;To list current unit statuses, press 3.;To list upcoming calendar events, press 4.;To list upcoming shifts, press 5.;To set your current status, press 6.;To set your current staffing level, press 7.;Press 0 to repeat. Press 1 to respond to the scene.;To hear the dispatch again, press 1. To hear response options, press 2.;To choose a response option, enter the option number, then press pound.;To hear the dispatch again, enter 0 and press pound.;Press 0 to go back to the main menu.;To go back to the main menu, enter 0 and press pound.;To set your current status, enter the number of your selection, then press pound.;To set your current staffing, enter the number of your selection, then press pound.;Invalid status selection. Returning to the main menu.;No status selection made. Returning to the main menu.;Invalid staffing selection. Returning to the main menu.;No staffing selection made. Returning to the main menu.;Thank you. Your response has been recorded. RESGRID__TtsConfig__RateLimitPermitLimit: "60" RESGRID__TtsConfig__RateLimitQueueLimit: "10" RESGRID__TtsConfig__RateLimitWindowSeconds: "60" From bbd10e3e0ca5877d3cbf1c2350e6bb7c4e05a10a Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sat, 2 May 2026 15:31:21 -0700 Subject: [PATCH 2/2] RE1-T115 PR#358 fixes --- .../Web/Tts/S3StorageServiceTests.cs | 180 ++++++++++++++++-- .../Services/S3StorageService.cs | 91 ++++++++- 2 files changed, 247 insertions(+), 24 deletions(-) diff --git a/Tests/Resgrid.Tests/Web/Tts/S3StorageServiceTests.cs b/Tests/Resgrid.Tests/Web/Tts/S3StorageServiceTests.cs index ce438509..c39eff5c 100644 --- a/Tests/Resgrid.Tests/Web/Tts/S3StorageServiceTests.cs +++ b/Tests/Resgrid.Tests/Web/Tts/S3StorageServiceTests.cs @@ -3,8 +3,10 @@ using System.IO; using System.Net; using System.Net.Http; +using System.Reflection; using System.Threading; using System.Threading.Tasks; +using Amazon.Runtime; using Amazon.S3; using Amazon.S3.Model; using FluentAssertions; @@ -56,32 +58,81 @@ public async Task upload_async_should_buffer_non_seekable_stream_for_retries() } [Test] - public async Task exists_async_should_treat_malformed_metadata_response_as_existing_object() + public async Task exists_async_should_verify_with_presigned_head_when_metadata_unmarshalling_fails() { var s3Client = new Mock(MockBehavior.Strict); s3Client .Setup(x => x.GetObjectMetadataAsync(It.IsAny(), It.IsAny())) - .ThrowsAsync(new FormatException("bad metadata expiration header")); + .ThrowsAsync(CreateMetadataUnmarshallingException("bad metadata expiration header")); + s3Client + .Setup(x => x.GetPreSignedURL(It.IsAny())) + .Returns(request => + { + request.BucketName.Should().Be("tts-bucket"); + request.Key.Should().Be("tts/audio.wav"); + request.Verb.Should().Be(HttpVerb.HEAD); + request.Protocol.Should().Be(Protocol.HTTP); + return "http://download.example.com/tts/audio.wav?signature=head"; + }); - var handler = new RecordingHttpMessageHandler((_, _) => - Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK))); + var handler = new RecordingHttpMessageHandler((request, _) => + { + request.Method.Should().Be(HttpMethod.Head); + request.RequestUri.Should().Be(new Uri("http://download.example.com/tts/audio.wav?signature=head")); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + }); var service = CreateService(s3Client.Object, handler, useSsl: false); var exists = await service.ExistsAsync("tts/audio.wav", CancellationToken.None); exists.Should().BeTrue(); - handler.Requests.Should().BeEmpty(); + handler.Requests.Should().HaveCount(1); s3Client.Verify(x => x.GetObjectMetadataAsync(It.Is(request => request.BucketName == "tts-bucket" && request.Key == "tts/audio.wav"), It.IsAny()), Times.Once); - s3Client.Verify(x => x.GetPreSignedURL(It.IsAny()), Times.Never); + s3Client.Verify(x => x.GetPreSignedURL(It.Is(request => request.BucketName == "tts-bucket" && request.Key == "tts/audio.wav" && request.Verb == HttpVerb.HEAD && request.Protocol == Protocol.HTTP)), Times.Once); + } + + [Test] + public async Task exists_async_should_return_false_when_presigned_head_reports_missing_after_metadata_unmarshalling_failure() + { + var s3Client = new Mock(MockBehavior.Strict); + s3Client + .Setup(x => x.GetObjectMetadataAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(CreateMetadataUnmarshallingException("bad metadata expiration header")); + s3Client + .Setup(x => x.GetPreSignedURL(It.IsAny())) + .Returns(request => + { + request.BucketName.Should().Be("tts-bucket"); + request.Key.Should().Be("tts/audio.wav"); + request.Verb.Should().Be(HttpVerb.HEAD); + request.Protocol.Should().Be(Protocol.HTTP); + return "http://download.example.com/tts/audio.wav?signature=head"; + }); + + var handler = new RecordingHttpMessageHandler((request, _) => + { + request.Method.Should().Be(HttpMethod.Head); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); + }); + var service = CreateService(s3Client.Object, handler, useSsl: false); + + var exists = await service.ExistsAsync("tts/audio.wav", CancellationToken.None); + + exists.Should().BeFalse(); + handler.Requests.Should().HaveCount(1); + s3Client.Verify(x => x.GetPreSignedURL(It.Is(request => request.BucketName == "tts-bucket" && request.Key == "tts/audio.wav" && request.Verb == HttpVerb.HEAD && request.Protocol == Protocol.HTTP)), Times.Once); } [Test] - public async Task upload_async_should_treat_malformed_put_response_as_success_without_using_presigned_uploads() + public async Task upload_async_should_treat_malformed_put_response_as_success_when_the_object_is_verified() { var s3Client = new Mock(MockBehavior.Strict); s3Client .Setup(x => x.PutObjectAsync(It.IsAny(), It.IsAny())) .ThrowsAsync(new FormatException("bad expiration header")); + s3Client + .Setup(x => x.GetObjectMetadataAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new GetObjectMetadataResponse()); var handler = new RecordingHttpMessageHandler((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK))); @@ -93,15 +144,16 @@ public async Task upload_async_should_treat_malformed_put_response_as_success_wi handler.Requests.Should().BeEmpty(); s3Client.Verify(x => x.PutObjectAsync(It.Is(request => request.BucketName == "tts-bucket" && request.Key == "tts/audio.wav" && request.ContentType == "audio/wav"), It.IsAny()), Times.Once); - s3Client.Verify(x => x.GetObjectMetadataAsync(It.IsAny(), It.IsAny()), Times.Never); + s3Client.Verify(x => x.GetObjectMetadataAsync(It.Is(request => request.BucketName == "tts-bucket" && request.Key == "tts/audio.wav"), It.IsAny()), Times.Once); s3Client.Verify(x => x.GetPreSignedURL(It.IsAny()), Times.Never); } [Test] - public async Task upload_async_should_treat_malformed_put_response_as_success_even_if_sdk_disposes_input_stream() + public async Task upload_async_should_retry_with_a_presigned_put_when_verification_reports_the_object_missing() { var s3Client = new Mock(MockBehavior.Strict); byte[] capturedPayload = null; + byte[] presignedPayload = null; s3Client .Setup(x => x.PutObjectAsync(It.IsAny(), It.IsAny())) .Returns(async (request, _) => @@ -112,19 +164,40 @@ public async Task upload_async_should_treat_malformed_put_response_as_success_ev request.InputStream.Dispose(); throw new FormatException("bad expiration header"); }); + s3Client + .Setup(x => x.GetObjectMetadataAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(CreateNotFoundS3Exception()); + s3Client + .Setup(x => x.GetPreSignedURL(It.IsAny())) + .Returns(request => + { + request.BucketName.Should().Be("tts-bucket"); + request.Key.Should().Be("tts/audio.wav"); + request.Verb.Should().Be(HttpVerb.PUT); + request.Protocol.Should().Be(Protocol.HTTP); + request.ContentType.Should().Be("audio/wav"); + return "http://upload.example.com/tts/audio.wav?signature=put"; + }); - var handler = new RecordingHttpMessageHandler((_, _) => - Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK))); - var service = CreateService(s3Client.Object, handler); + var handler = new RecordingHttpMessageHandler(async (request, _) => + { + request.Method.Should().Be(HttpMethod.Put); + request.RequestUri.Should().Be(new Uri("http://upload.example.com/tts/audio.wav?signature=put")); + request.Content.Headers.ContentType?.MediaType.Should().Be("audio/wav"); + presignedPayload = await request.Content.ReadAsByteArrayAsync(); + return new HttpResponseMessage(HttpStatusCode.OK); + }); + var service = CreateService(s3Client.Object, handler, useSsl: false); await using var content = new MemoryStream(new byte[] { 7, 5, 3, 1 }, writable: false); await service.UploadAsync("tts/audio.wav", content, "audio/wav", CancellationToken.None); capturedPayload.Should().Equal(7, 5, 3, 1); - handler.Requests.Should().BeEmpty(); - s3Client.Verify(x => x.GetObjectMetadataAsync(It.IsAny(), It.IsAny()), Times.Never); - s3Client.Verify(x => x.GetPreSignedURL(It.IsAny()), Times.Never); + presignedPayload.Should().Equal(7, 5, 3, 1); + handler.Requests.Should().HaveCount(1); + s3Client.Verify(x => x.GetObjectMetadataAsync(It.Is(request => request.BucketName == "tts-bucket" && request.Key == "tts/audio.wav"), It.IsAny()), Times.Once); + s3Client.Verify(x => x.GetPreSignedURL(It.Is(request => request.BucketName == "tts-bucket" && request.Key == "tts/audio.wav" && request.Verb == HttpVerb.PUT && request.Protocol == Protocol.HTTP && request.ContentType == "audio/wav")), Times.Once); } [Test] @@ -172,6 +245,83 @@ public async Task get_object_url_async_should_prefer_absolute_endpoint_scheme_ov s3Client.Verify(x => x.GetPreSignedURL(It.IsAny()), Times.Once); } + private static AmazonS3Exception CreateNotFoundS3Exception() + { + return new AmazonS3Exception( + "Object was not found.", + ErrorType.Unknown, + "NoSuchKey", + "request-id", + HttpStatusCode.NotFound); + } + + private static AmazonUnmarshallingException CreateMetadataUnmarshallingException(string message) + { + var innerException = new FormatException(message); + + foreach (var constructor in typeof(AmazonUnmarshallingException).GetConstructors(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) + { + var parameters = constructor.GetParameters(); + var arguments = new object[parameters.Length]; + var usedInnerException = false; + var usedMessage = false; + var supported = true; + + for (var index = 0; index < parameters.Length; index++) + { + var parameterType = parameters[index].ParameterType; + + if (!usedInnerException && typeof(Exception).IsAssignableFrom(parameterType)) + { + arguments[index] = innerException; + usedInnerException = true; + continue; + } + + if (parameterType == typeof(string)) + { + arguments[index] = usedMessage ? "/HeadObjectResult" : message; + usedMessage = true; + continue; + } + + if (parameterType == typeof(bool)) + { + arguments[index] = false; + continue; + } + + if (parameterType == typeof(int)) + { + arguments[index] = 0; + continue; + } + + supported = false; + break; + } + + if (!supported || !usedInnerException) + { + continue; + } + + try + { + if (constructor.Invoke(arguments) is AmazonUnmarshallingException exception + && exception.InnerException is FormatException) + { + return exception; + } + } + catch + { + } + } + + throw new InvalidOperationException("Unable to construct AmazonUnmarshallingException for the test."); + } + private static S3StorageService CreateService(IAmazonS3 s3Client, RecordingHttpMessageHandler handler = null, bool useSsl = true, string endpoint = null) { handler ??= new RecordingHttpMessageHandler((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK))); diff --git a/Web/Resgrid.Web.Tts/Services/S3StorageService.cs b/Web/Resgrid.Web.Tts/Services/S3StorageService.cs index d871a84b..cf916df5 100644 --- a/Web/Resgrid.Web.Tts/Services/S3StorageService.cs +++ b/Web/Resgrid.Web.Tts/Services/S3StorageService.cs @@ -52,15 +52,75 @@ await ExecuteWithRetryAsync( { return false; } - catch (FormatException ex) + catch (AmazonUnmarshallingException ex) when (ex.InnerException is FormatException formatException) { + return await HandleMalformedMetadataResponseAsync(objectKey, ex, formatException, cancellationToken); + } + } + + private async Task HandleMalformedMetadataResponseAsync( + string objectKey, + AmazonUnmarshallingException exception, + FormatException formatException, + CancellationToken cancellationToken) + { + _logger.LogWarning( + exception, + "The S3 client could not parse the metadata response for {ObjectKey}. Verifying existence with a presigned HEAD request. Inner format error: {InnerFormatErrorMessage}", + objectKey, + formatException.Message); + + _logger.LogDebug( + formatException, + "Inner FormatException while parsing the metadata response for {ObjectKey}. Last known location: {LastKnownLocation}.", + objectKey, + exception.LastKnownLocation ?? "unknown"); + + try + { + var exists = await ExistsWithPresignedHeadAsync(objectKey, cancellationToken); + _logger.LogDebug( - ex, - "The S3 client could not parse the metadata response for {ObjectKey}. Treating the object as existing because the metadata request completed before the response parsing failure.", - objectKey); + "Presigned HEAD verification after the metadata parsing failure reported that {ObjectKey} {ExistenceState}.", + objectKey, + exists ? "exists" : "does not exist"); - return true; + return exists; } + catch (AmazonServiceException verificationException) + { + _logger.LogWarning( + verificationException, + "Unable to verify whether {ObjectKey} exists after the metadata parsing failure. Assuming the object exists because S3 returned a response before the unmarshalling error.", + objectKey); + } + catch (HttpRequestException verificationException) + { + _logger.LogWarning( + verificationException, + "Unable to verify whether {ObjectKey} exists after the metadata parsing failure due to connectivity. Assuming the object exists because S3 returned a response before the unmarshalling error.", + objectKey); + } + catch (TaskCanceledException verificationException) when (!cancellationToken.IsCancellationRequested) + { + _logger.LogWarning( + verificationException, + "Unable to verify whether {ObjectKey} exists after the metadata parsing failure due to timeout. Assuming the object exists because S3 returned a response before the unmarshalling error.", + objectKey); + } + catch (IOException verificationException) + { + _logger.LogWarning( + verificationException, + "Unable to verify whether {ObjectKey} exists after the metadata parsing failure due to IO. Assuming the object exists because S3 returned a response before the unmarshalling error.", + objectKey); + } + + // The metadata request reached S3 and only failed while the SDK parsed the response. + // If the explicit presigned HEAD verification also fails, preserve the best-effort + // behavior and assume the object exists so callers such as CacheService understand + // this path can still return an optimistic result. + return true; } public async Task UploadAsync(string objectKey, Stream content, string contentType, CancellationToken cancellationToken) @@ -80,19 +140,32 @@ await ExecuteWithRetryAsync( } } - private Task HandleMalformedPutResponseAsync( + private async Task HandleMalformedPutResponseAsync( string objectKey, byte[] payload, string contentType, FormatException exception, CancellationToken cancellationToken) { - _logger.LogDebug( + _logger.LogWarning( exception, - "The S3 client could not parse the PUT response for {ObjectKey}. Treating the upload as successful because the object upload completed before the response parsing failure.", + "The S3 client could not parse the PUT response for {ObjectKey}. Verifying whether the upload persisted before falling back to a presigned PUT upload.", + objectKey); + + if (await WasUploadPersistedAsync(objectKey, cancellationToken)) + { + _logger.LogInformation( + "The upload for {ObjectKey} was verified after the PUT response parsing failure. Treating the upload as successful.", + objectKey); + + return; + } + + _logger.LogWarning( + "The upload for {ObjectKey} could not be verified after the PUT response parsing failure. Retrying with a presigned PUT upload.", objectKey); - return Task.CompletedTask; + await UploadWithPresignedUrlAsync(objectKey, payload, contentType, cancellationToken); } private async Task WasUploadPersistedAsync(string objectKey, CancellationToken cancellationToken)