From 45b1a5d89dc4bd113838edfdcc1af9d990a81e9c Mon Sep 17 00:00:00 2001 From: fboucher-os Date: Sat, 15 Aug 2026 11:33:32 -0400 Subject: [PATCH 1/2] feat: configure protected Azure Function Parser API and key propagation --- .../ResearchService.cs | 4 +- .../Services/PostParserClientTests.cs | 163 ++++++++++++++++++ src/NoteBookmark.Api/PostParserClient.cs | 24 ++- src/NoteBookmark.AppHost/AppHost.cs | 28 ++- src/NoteBookmark.AppHost/appsettings.json | 4 + .../NoteBookmark.MauiApp.csproj | 6 + 6 files changed, 217 insertions(+), 12 deletions(-) create mode 100644 src/NoteBookmark.Api.Tests/Services/PostParserClientTests.cs diff --git a/src/NoteBookmark.AIServices/ResearchService.cs b/src/NoteBookmark.AIServices/ResearchService.cs index 69ac93a..3b4d750 100644 --- a/src/NoteBookmark.AIServices/ResearchService.cs +++ b/src/NoteBookmark.AIServices/ResearchService.cs @@ -151,7 +151,9 @@ private async Task SaveToFile(string prefix, string responseContent) { string datetime = DateTime.Now.ToString("yyyy-MM-dd_HH-mm"); string fileName = $"{prefix}_{datetime}.json"; - string folderPath = "Data"; + // Use the app's sandboxed data directory so this works on Android/iOS as well as desktop + string folderPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Data"); Directory.CreateDirectory(folderPath); string filePath = Path.Combine(folderPath, fileName); await File.WriteAllTextAsync(filePath, responseContent); diff --git a/src/NoteBookmark.Api.Tests/Services/PostParserClientTests.cs b/src/NoteBookmark.Api.Tests/Services/PostParserClientTests.cs new file mode 100644 index 0000000..dea9f8a --- /dev/null +++ b/src/NoteBookmark.Api.Tests/Services/PostParserClientTests.cs @@ -0,0 +1,163 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Moq.Protected; + +namespace NoteBookmark.Api.Tests.Services; + +public class PostParserClientTests +{ + private readonly Mock _mockConfig; + private readonly Mock _mockHandler; + + public PostParserClientTests() + { + _mockConfig = new Mock(); + _mockHandler = new Mock(MockBehavior.Strict); + + // Default config setups + _mockConfig.Setup(c => c["Parser:BaseUrl"]).Returns((string?)null); + _mockConfig.Setup(c => c["Parser:ApiKey"]).Returns((string?)null); + } + + private PostParserClient CreateSut(HttpClient httpClient) => + new(httpClient, _mockConfig.Object, NullLogger.Instance); + + [Fact] + public async Task ExtractContentAsync_WithDefaults_CallsDefaultUrlWithoutApiKey() + { + // Arrange + var expectedUrl = "https://azpostlight-parser.azurewebsites.net/api/parser"; + var sourceUrl = "https://example.com/blog-post"; + + _mockHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.Method == HttpMethod.Post && + req.RequestUri != null && + req.RequestUri.ToString() == expectedUrl && + !req.Headers.Contains("x-functions-key")), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{\"content\":\"extracted blog content\"}", Encoding.UTF8, "application/json") + }); + + var httpClient = new HttpClient(_mockHandler.Object); + var sut = CreateSut(httpClient); + + // Act + var result = await sut.ExtractContentAsync(sourceUrl); + + // Assert + result.Should().Be("extracted blog content"); + _mockHandler.Protected().Verify( + "SendAsync", + Times.Once(), + ItExpr.Is(req => req.RequestUri != null && req.RequestUri.ToString() == expectedUrl), + ItExpr.IsAny()); + } + + [Fact] + public async Task ExtractContentAsync_WithApiKey_SendsXFunctionsKeyHeader() + { + // Arrange + var expectedUrl = "https://azpostlight-parser.azurewebsites.net/api/parser"; + var sourceUrl = "https://example.com/blog-post"; + var apiKey = "test-api-key-123"; + + _mockConfig.Setup(c => c["Parser:ApiKey"]).Returns(apiKey); + + _mockHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.Method == HttpMethod.Post && + req.RequestUri != null && + req.RequestUri.ToString() == expectedUrl && + req.Headers.Contains("x-functions-key") && + string.Join("", req.Headers.GetValues("x-functions-key")) == apiKey), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{\"content\":\"content with auth\"}", Encoding.UTF8, "application/json") + }); + + var httpClient = new HttpClient(_mockHandler.Object); + var sut = CreateSut(httpClient); + + // Act + var result = await sut.ExtractContentAsync(sourceUrl); + + // Assert + result.Should().Be("content with auth"); + } + + [Fact] + public async Task ExtractContentAsync_WithCustomUrl_CallsCustomUrl() + { + // Arrange + var customUrl = "https://my-custom-parser.com/api/parser"; + var sourceUrl = "https://example.com/blog-post"; + + _mockConfig.Setup(c => c["Parser:BaseUrl"]).Returns(customUrl); + + _mockHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.Method == HttpMethod.Post && + req.RequestUri != null && + req.RequestUri.ToString() == customUrl), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{\"content\":\"custom url content\"}", Encoding.UTF8, "application/json") + }); + + var httpClient = new HttpClient(_mockHandler.Object); + var sut = CreateSut(httpClient); + + // Act + var result = await sut.ExtractContentAsync(sourceUrl); + + // Assert + result.Should().Be("custom url content"); + } + + [Fact] + public async Task ExtractContentAsync_ParserReturnsErrorCode_ReturnsNull() + { + // Arrange + var sourceUrl = "https://example.com/blog-post"; + + _mockHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.InternalServerError + }); + + var httpClient = new HttpClient(_mockHandler.Object); + var sut = CreateSut(httpClient); + + // Act + var result = await sut.ExtractContentAsync(sourceUrl); + + // Assert + result.Should().BeNull(); + } +} diff --git a/src/NoteBookmark.Api/PostParserClient.cs b/src/NoteBookmark.Api/PostParserClient.cs index 2562eb9..91f1507 100644 --- a/src/NoteBookmark.Api/PostParserClient.cs +++ b/src/NoteBookmark.Api/PostParserClient.cs @@ -4,6 +4,7 @@ using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; namespace NoteBookmark.Api; @@ -11,17 +12,14 @@ namespace NoteBookmark.Api; public class PostParserClient : IPostParserClient { private readonly HttpClient _httpClient; + private readonly IConfiguration _config; private readonly ILogger _logger; - public PostParserClient(HttpClient httpClient, ILogger logger) + public PostParserClient(HttpClient httpClient, IConfiguration config, ILogger logger) { _httpClient = httpClient; + _config = config; _logger = logger; - // Configure base address or default headers if needed, but since URL is fully specified we can just configure it or call it directly. - if (_httpClient.BaseAddress == null) - { - _httpClient.BaseAddress = new Uri("https://azpostlight-parser.azurewebsites.net/"); - } } public async Task ExtractContentAsync(string url, CancellationToken cancellationToken = default) @@ -30,7 +28,19 @@ public PostParserClient(HttpClient httpClient, ILogger logger) { _logger.LogInformation("Calling parser API for URL: {Url}", url); var requestBody = new { url = url }; - var response = await _httpClient.PostAsJsonAsync("parser", requestBody, cancellationToken); + + var endpoint = _config["Parser:BaseUrl"] ?? "https://azpostlight-parser.azurewebsites.net/api/parser"; + var apiKey = _config["Parser:ApiKey"]; + + using var request = new HttpRequestMessage(HttpMethod.Post, endpoint); + request.Content = JsonContent.Create(requestBody); + + if (!string.IsNullOrEmpty(apiKey)) + { + request.Headers.Add("x-functions-key", apiKey); + } + + var response = await _httpClient.SendAsync(request, cancellationToken); if (!response.IsSuccessStatusCode) { diff --git a/src/NoteBookmark.AppHost/AppHost.cs b/src/NoteBookmark.AppHost/AppHost.cs index 531e157..024f1f8 100644 --- a/src/NoteBookmark.AppHost/AppHost.cs +++ b/src/NoteBookmark.AppHost/AppHost.cs @@ -5,6 +5,12 @@ var builder = DistributedApplication.CreateBuilder(args); +var parserUrl = builder.Configuration["Parser:BaseUrl"] + ?? Environment.GetEnvironmentVariable("PARSER_BASE_URL") + ?? "https://azpostlight-parser.azurewebsites.net/api/parser"; +var parserKey = builder.Configuration["Parser:ApiKey"] + ?? Environment.GetEnvironmentVariable("PARSER_API_KEY"); + // Load docker-compose environment var compose = builder.AddDockerComposeEnvironment("docker-env"); @@ -24,12 +30,19 @@ var tables = noteStorage.AddTables("nb-tables"); var blobs = noteStorage.AddBlobs("nb-blobs"); - var api = builder.AddProject("api") + var apiBuilder = builder.AddProject("api") .WithReference(tables) .WithReference(blobs) .WaitFor(tables) .WaitFor(blobs) - .PublishAsDockerComposeService((resource, service) => + .WithEnvironment("Parser__BaseUrl", parserUrl); + + if (!string.IsNullOrEmpty(parserKey)) + { + apiBuilder = apiBuilder.WithEnvironment("Parser__ApiKey", parserKey); + } + + var api = apiBuilder.PublishAsDockerComposeService((resource, service) => { service.ContainerName = "notebookmark-api"; }); @@ -58,12 +71,19 @@ var tables = noteStorage.AddTables("nb-tables"); var blobs = noteStorage.AddBlobs("nb-blobs"); - var api = builder.AddProject("api") + var apiBuilder = builder.AddProject("api") .WithReference(tables) .WithReference(blobs) .WaitFor(tables) .WaitFor(blobs) - .PublishAsDockerComposeService((resource, service) => + .WithEnvironment("Parser__BaseUrl", parserUrl); + + if (!string.IsNullOrEmpty(parserKey)) + { + apiBuilder = apiBuilder.WithEnvironment("Parser__ApiKey", parserKey); + } + + var api = apiBuilder.PublishAsDockerComposeService((resource, service) => { service.ContainerName = "notebookmark-api"; }); diff --git a/src/NoteBookmark.AppHost/appsettings.json b/src/NoteBookmark.AppHost/appsettings.json index 219b4a5..b3b4cb6 100644 --- a/src/NoteBookmark.AppHost/appsettings.json +++ b/src/NoteBookmark.AppHost/appsettings.json @@ -9,6 +9,10 @@ "AppSettings": { "REKA_API_KEY": "KEY_HERE" }, + "Parser": { + "BaseUrl": "https://azpostlight-parser.azurewebsites.net/api/parser", + "ApiKey": "KEY_HERE" + }, "Keycloak": { "Authority": "http://localhost:8080/realms/notebookmark", "ClientId": "notebookmark", diff --git a/src/NoteBookmark.MauiApp/NoteBookmark.MauiApp.csproj b/src/NoteBookmark.MauiApp/NoteBookmark.MauiApp.csproj index 4e2da8e..fce28f8 100644 --- a/src/NoteBookmark.MauiApp/NoteBookmark.MauiApp.csproj +++ b/src/NoteBookmark.MauiApp/NoteBookmark.MauiApp.csproj @@ -97,8 +97,14 @@ + + + + None + +