From 706e0f89f4d301b31c1430cbe59f9243635f2de1 Mon Sep 17 00:00:00 2001 From: Isaiah Clifford Opoku Date: Sun, 19 Jul 2026 16:39:14 +0000 Subject: [PATCH 1/7] Azure SDK compatibility, HTTPS, and test infra overhaul - Switch all endpoints to HTTPS and require IP-literal host for Blob SDK compatibility - Remove blob route prefixes for path-style addressing support - Add KeyVaultChallengeMiddleware for Key Vault SDK auth flow - Ensure Service Bus locked messages are invisible to other receivers - Refactor integration tests to use in-process emulator with isolated temp storage - Expand test coverage for Blob, Key Vault, Service Bus, ARM, and all state stores - Update SQLite dependencies and improve state store correctness - Fix Blob/Container error headers, XML generation, and ARM resource group handling These changes make AzLocal fully compatible with Azure SDKs and improve reliability and testability for local and CI use. --- README.md | 5 +- global.json | 2 +- src/AzLocal.Cli/Commands/StartCommand.cs | 6 +- src/AzLocal.Cli/Commands/StatusCommand.cs | 2 +- src/AzLocal.Cli/Commands/WaitCommand.cs | 2 +- src/AzLocal.Client/AzlocalClientFactory.cs | 64 +++-- src/AzLocal.Client/AzlocalFixture.cs | 2 +- src/AzLocal.Core/EmulatorDefaults.cs | 4 +- src/AzLocal.Host/Program.cs | 17 +- src/AzLocal.Host/appsettings.json | 2 +- .../AzLocal.Middleware.csproj | 3 +- .../KeyVaultChallengeMiddleware.cs | 40 +++ .../Arm/ResourceGroupHandler.cs | 18 +- .../BlobStorage/BlobRoutes.cs | 13 +- .../BlobStorage/BlobServiceHandler.cs | 59 +++-- .../KeyVault/KeyVaultSecretHandler.cs | 48 ++-- .../ServiceBus/ServiceBusQueueHandler.cs | 30 ++- src/AzLocal.State/AzLocal.State.csproj | 6 +- src/AzLocal.State/SqliteStateStore.cs | 1 + .../AzLocal.CompatTests.csproj | 3 +- tests/AzLocal.IntegrationTests/ArmTests.cs | 236 +++++++++++++++++ .../AzLocal.IntegrationTests.csproj | 7 + .../BlobStorageTests.cs | 216 +++++++++++++++- .../Fixtures/EmulatorFixture.cs | 46 +++- .../AzLocal.IntegrationTests/KeyVaultTests.cs | 159 +++++++++++- .../ServiceBusTests.cs | 244 +++++++++++++++++- .../InMemoryStateStoreTests.cs | 134 ++++++++++ .../JsonSnapshotStateStoreTests.cs | 142 ++++++++++ tests/AzLocal.UnitTests/ServiceRouterTests.cs | 108 ++++++++ .../SqliteStateStoreTests.cs | 139 ++++++++++ .../TempFileBlobStoreTests.cs | 144 +++++++++++ .../TestHelpers/InMemoryConfig.cs | 26 ++ 32 files changed, 1832 insertions(+), 96 deletions(-) create mode 100644 src/AzLocal.Middleware/KeyVaultChallengeMiddleware.cs create mode 100644 tests/AzLocal.IntegrationTests/ArmTests.cs create mode 100644 tests/AzLocal.UnitTests/InMemoryStateStoreTests.cs create mode 100644 tests/AzLocal.UnitTests/JsonSnapshotStateStoreTests.cs create mode 100644 tests/AzLocal.UnitTests/ServiceRouterTests.cs create mode 100644 tests/AzLocal.UnitTests/SqliteStateStoreTests.cs create mode 100644 tests/AzLocal.UnitTests/TempFileBlobStoreTests.cs create mode 100644 tests/AzLocal.UnitTests/TestHelpers/InMemoryConfig.cs diff --git a/README.md b/README.md index 6047273..87b54e0 100644 --- a/README.md +++ b/README.md @@ -21,10 +21,13 @@ azlocal runs a local server on your machine that speaks the same HTTP API as Azu You start azlocal once, then your code and tests work as if Azure is there. ```bash +# One-time: trust the local HTTPS dev certificate (Azure SDKs require HTTPS) +azlocal trust-cert + # Start the emulator azlocal start -# Emulator is now running at http://localhost:4566 +# Emulator is now running at https://127.0.0.1:4566 # All Azure services respond on that single port ``` diff --git a/global.json b/global.json index 1747333..6e79c1f 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { "version": "10.0.108", - "rollForward": "latestPatch" + "rollForward": "latestFeature" } } diff --git a/src/AzLocal.Cli/Commands/StartCommand.cs b/src/AzLocal.Cli/Commands/StartCommand.cs index 12f24e6..a1697e7 100644 --- a/src/AzLocal.Cli/Commands/StartCommand.cs +++ b/src/AzLocal.Cli/Commands/StartCommand.cs @@ -52,7 +52,7 @@ public static Command Build() } File.WriteAllText(pidFile, proc.Id.ToString()); - Console.WriteLine($"AzLocal started on http://localhost:{port} (pid {proc.Id})"); + Console.WriteLine($"AzLocal started on https://127.0.0.1:{port} (pid {proc.Id})"); }); return cmd; @@ -71,7 +71,7 @@ private static ProcessStartInfo BuildHostProcessInfo(int port) return new ProcessStartInfo { FileName = hostExe, - Arguments = $"--urls http://localhost:{port}", + Arguments = $"--urls https://127.0.0.1:{port}", UseShellExecute = false, CreateNoWindow = true, }; @@ -80,7 +80,7 @@ private static ProcessStartInfo BuildHostProcessInfo(int port) return new ProcessStartInfo { FileName = "dotnet", - Arguments = $"run --project src/AzLocal.Host --urls http://localhost:{port}", + Arguments = $"run --project src/AzLocal.Host --urls https://127.0.0.1:{port}", UseShellExecute = false, CreateNoWindow = true, }; diff --git a/src/AzLocal.Cli/Commands/StatusCommand.cs b/src/AzLocal.Cli/Commands/StatusCommand.cs index c981b07..ec7b388 100644 --- a/src/AzLocal.Cli/Commands/StatusCommand.cs +++ b/src/AzLocal.Cli/Commands/StatusCommand.cs @@ -22,7 +22,7 @@ public static Command Build() using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(3) }; try { - var response = await http.GetAsync($"http://localhost:{port}/", ct); + var response = await http.GetAsync($"https://127.0.0.1:{port}/", ct); Console.WriteLine($"RUNNING on port {port} (HTTP {(int)response.StatusCode})"); } catch diff --git a/src/AzLocal.Cli/Commands/WaitCommand.cs b/src/AzLocal.Cli/Commands/WaitCommand.cs index 72bf346..c3c9a1b 100644 --- a/src/AzLocal.Cli/Commands/WaitCommand.cs +++ b/src/AzLocal.Cli/Commands/WaitCommand.cs @@ -35,7 +35,7 @@ public static Command Build() { try { - var response = await http.GetAsync($"http://localhost:{port}/", ct); + var response = await http.GetAsync($"https://127.0.0.1:{port}/", ct); if (response.IsSuccessStatusCode) { Console.WriteLine(" ready."); diff --git a/src/AzLocal.Client/AzlocalClientFactory.cs b/src/AzLocal.Client/AzlocalClientFactory.cs index 73eb20c..458c5e9 100644 --- a/src/AzLocal.Client/AzlocalClientFactory.cs +++ b/src/AzLocal.Client/AzlocalClientFactory.cs @@ -1,4 +1,5 @@ using AzLocal.Core; +using Azure.Core.Pipeline; using Azure.Security.KeyVault.Secrets; using Azure.Storage.Blobs; using Azure.Storage.Blobs.Models; @@ -13,14 +14,22 @@ namespace AzLocal.Client; public sealed class AzlocalClientFactory { private readonly string _baseUrl; + private readonly HttpMessageHandler? _handler; private const string DefaultBaseUrl = EmulatorDefaults.BaseUrl; /// The credential that satisfies Azure SDK authentication against the local emulator. public AzlocalCredential Credential { get; } = new(); - public AzlocalClientFactory(string baseUrl = DefaultBaseUrl) + /// The emulator's base address. + /// + /// Optional transport handler. Pass the handler from an in-process test host (e.g. + /// WebApplicationFactory.Server.CreateHandler()) to route every client created by + /// this factory through that host in-memory instead of over a real socket connection. + /// + public AzlocalClientFactory(string baseUrl = DefaultBaseUrl, HttpMessageHandler? handler = null) { _baseUrl = baseUrl.TrimEnd('/'); + _handler = handler; } // ------------------------------------------------------------------------- @@ -50,7 +59,7 @@ public BlobServiceClient CreateBlobServiceClient(string account) /// public BlobContainerClient CreateBlobContainerClient(string account, string container) { - var uri = new Uri($"{_baseUrl}/azu/{account}/{container}"); + var uri = new Uri($"{_baseUrl}/{account}/{container}"); return new BlobContainerClient(uri, Credential, BuildBlobOptions()); } @@ -63,7 +72,7 @@ public BlobContainerClient CreateBlobContainerClient(string account, string cont /// public BlobClient CreateBlobClient(string account, string container, string blobName) { - var uri = new Uri($"{_baseUrl}/azu/{account}/{container}/{blobName}"); + var uri = new Uri($"{_baseUrl}/{account}/{container}/{blobName}"); return new BlobClient(uri, Credential, BuildBlobOptions()); } @@ -84,8 +93,14 @@ public SecretClient CreateSecretClient(string vault) var uri = new Uri($"{_baseUrl}/kv/{vault}"); var options = new SecretClientOptions { - Retry = { MaxRetries = 0 } + Retry = { MaxRetries = 0 }, + // The SDK's challenge-response auth refuses to send a token when the challenge's + // "resource" domain doesn't match the request host — which is always true for a + // local emulator, since it can never live at *.vault.azure.net. + DisableChallengeResourceVerification = true }; + if (_handler is not null) + options.Transport = new HttpClientTransport(_handler); return new SecretClient(uri, Credential, options); } @@ -104,12 +119,8 @@ public SecretClient CreateSecretClient(string vault) /// public HttpClient CreateServiceBusHttpClient(string @namespace) { - var client = new HttpClient - { - BaseAddress = new Uri($"{_baseUrl}/sb/{@namespace}/") - }; - client.DefaultRequestHeaders.Authorization = - new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", AzlocalCredential.TokenValue); + var client = CreateRawHttpClient(); + client.BaseAddress = new Uri($"{_baseUrl}/sb/{@namespace}/"); return client; } @@ -123,22 +134,39 @@ public HttpClient CreateServiceBusHttpClient(string @namespace) /// public HttpClient CreateHttpClient() { - var client = new HttpClient { BaseAddress = new Uri(_baseUrl) }; - client.DefaultRequestHeaders.Authorization = - new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", AzlocalCredential.TokenValue); + var client = CreateRawHttpClient(); + client.BaseAddress = new Uri(_baseUrl); return client; } /// Returns the blob storage base URI for the given account. - public Uri GetBlobEndpoint(string account) => new($"{_baseUrl}/azu/{account}"); + public Uri GetBlobEndpoint(string account) => new($"{_baseUrl}/{account}"); // ------------------------------------------------------------------------- // Private helpers // ------------------------------------------------------------------------- - private static BlobClientOptions BuildBlobOptions() => new() + private BlobClientOptions BuildBlobOptions() { - // Disable retries so tests fail fast instead of hanging on connection errors. - Retry = { MaxRetries = 0 } - }; + var options = new BlobClientOptions + { + // Disable retries so tests fail fast instead of hanging on connection errors. + Retry = { MaxRetries = 0 } + }; + if (_handler is not null) + options.Transport = new HttpClientTransport(_handler); + return options; + } + + // Handler is owned by the caller (e.g. WebApplicationFactory) when supplied, so it must + // not be disposed alongside the HttpClient. + private HttpClient CreateRawHttpClient() + { + var client = _handler is not null + ? new HttpClient(_handler, disposeHandler: false) + : new HttpClient(); + client.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", AzlocalCredential.TokenValue); + return client; + } } diff --git a/src/AzLocal.Client/AzlocalFixture.cs b/src/AzLocal.Client/AzlocalFixture.cs index dcfd976..5eddf19 100644 --- a/src/AzLocal.Client/AzlocalFixture.cs +++ b/src/AzLocal.Client/AzlocalFixture.cs @@ -14,7 +14,7 @@ public sealed class AzlocalFixture : IAsyncDisposable { public AzlocalClientFactory Clients { get; } - public AzlocalFixture(string baseUrl = "http://localhost:4566") + public AzlocalFixture(string baseUrl = "https://127.0.0.1:4566") { Clients = new AzlocalClientFactory(baseUrl); } diff --git a/src/AzLocal.Core/EmulatorDefaults.cs b/src/AzLocal.Core/EmulatorDefaults.cs index 093bc7c..3310e17 100644 --- a/src/AzLocal.Core/EmulatorDefaults.cs +++ b/src/AzLocal.Core/EmulatorDefaults.cs @@ -8,7 +8,9 @@ public static class EmulatorDefaults { public const string FakeToken = "fake-azlocal-token"; public const int DefaultPort = 4566; - public const string BaseUrl = "http://localhost:4566"; + // Must be a literal IP, not "localhost" — the Azure Storage SDK's BlobUriBuilder only + // recognizes path-style addressing (account/container/blob) for IP-literal hosts. + public const string BaseUrl = "https://127.0.0.1:4566"; public const string ObjectId = "00000000-0000-0000-0000-000000000001"; public const string TenantId = "00000000-0000-0000-0000-000000000002"; public const string SubscriptionId = "00000000-0000-0000-0000-000000000001"; diff --git a/src/AzLocal.Host/Program.cs b/src/AzLocal.Host/Program.cs index 6d8c43b..1fbd423 100644 --- a/src/AzLocal.Host/Program.cs +++ b/src/AzLocal.Host/Program.cs @@ -8,11 +8,12 @@ var app = builder.Build(); // Middleware pipeline — order matters. -app.UseMiddleware(); // 1. log every request -app.UseMiddleware(); // 2. intercept IMDS token requests -app.UseMiddleware(); // 3. stamp all requests as authenticated -app.UseAuthorization(); // 4. enforce [Authorize] using the stub identity -app.UseMiddleware(); // 5. parse and store Azure request context +app.UseMiddleware(); // 1. log every request +app.UseMiddleware(); // 2. intercept IMDS token requests +app.UseMiddleware(); // 3. answer the Key Vault SDK's auth challenge probe +app.UseMiddleware(); // 4. stamp all requests as authenticated +app.UseAuthorization(); // 5. enforce [Authorize] using the stub identity +app.UseMiddleware(); // 6. parse and store Azure request context // Auto-discover and register every IServiceHandler's routes. // Adding a new service only requires registering it in HostBuilder — nothing changes here. @@ -22,7 +23,7 @@ app.Logger.LogInformation("Registered service: {ServiceName}", handler.ServiceName); } -var baseUrl = app.Configuration["AzLocal:BaseUrl"] ?? "http://localhost:4566"; +var baseUrl = app.Configuration["AzLocal:BaseUrl"] ?? "https://127.0.0.1:4566"; app.MapGet("/", () => new { status = "AzLocal emulator running", @@ -31,3 +32,7 @@ }); app.Run(); + +// Exposes the top-level-statement Program class to WebApplicationFactory +// in AzLocal.IntegrationTests, which needs a public entry point to boot the host in-process. +public partial class Program { } diff --git a/src/AzLocal.Host/appsettings.json b/src/AzLocal.Host/appsettings.json index f5eb914..daba8e2 100644 --- a/src/AzLocal.Host/appsettings.json +++ b/src/AzLocal.Host/appsettings.json @@ -9,7 +9,7 @@ "AllowedHosts": "*", "AzLocal": { "StateMode": "InMemory", - "BaseUrl": "http://localhost:4566", + "BaseUrl": "https://127.0.0.1:4566", "FakeToken": "fake-azlocal-token", "ObjectId": "00000000-0000-0000-0000-000000000001", "TenantId": "00000000-0000-0000-0000-000000000002", diff --git a/src/AzLocal.Middleware/AzLocal.Middleware.csproj b/src/AzLocal.Middleware/AzLocal.Middleware.csproj index 5142489..95b9bf1 100644 --- a/src/AzLocal.Middleware/AzLocal.Middleware.csproj +++ b/src/AzLocal.Middleware/AzLocal.Middleware.csproj @@ -1,4 +1,5 @@ - + + diff --git a/src/AzLocal.Middleware/KeyVaultChallengeMiddleware.cs b/src/AzLocal.Middleware/KeyVaultChallengeMiddleware.cs new file mode 100644 index 0000000..beef285 --- /dev/null +++ b/src/AzLocal.Middleware/KeyVaultChallengeMiddleware.cs @@ -0,0 +1,40 @@ +using AzLocal.Core; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; + +namespace AzLocal.Middleware; + +/// +/// The Azure Key Vault SDK's ChallengeBasedAuthenticationPolicy always sends an initial, +/// anonymous, bodyless probe request expecting a 401 with a WWW-Authenticate challenge +/// before it will acquire a token and retry with the real request body attached — unlike +/// the Storage/Blob SDK, which attaches a bearer token optimistically on the first request. +/// +/// AzLocal's AuthStubMiddleware accepts every request as authenticated regardless of headers, +/// so without this middleware the SDK's bodyless probe is what reaches the Key Vault handler +/// (never the real request), and every Set/Get Secret call fails. +/// +public class KeyVaultChallengeMiddleware +{ + private readonly RequestDelegate _next; + private readonly string _tenantId; + + public KeyVaultChallengeMiddleware(RequestDelegate next, IConfiguration config) + { + _next = next; + _tenantId = config["AzLocal:TenantId"] ?? EmulatorDefaults.TenantId; + } + + public async Task InvokeAsync(HttpContext context) + { + if (context.Request.Path.StartsWithSegments("/kv") && !context.Request.Headers.ContainsKey("Authorization")) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + context.Response.Headers.Append("WWW-Authenticate", + $"Bearer authorization=\"https://login.microsoftonline.com/{_tenantId}\", resource=\"https://vault.azure.net\""); + return; // Do NOT call _next — the SDK retries with a token once it sees this challenge. + } + + await _next(context); + } +} diff --git a/src/AzLocal.Services/Arm/ResourceGroupHandler.cs b/src/AzLocal.Services/Arm/ResourceGroupHandler.cs index 73254ad..d5a8a51 100644 --- a/src/AzLocal.Services/Arm/ResourceGroupHandler.cs +++ b/src/AzLocal.Services/Arm/ResourceGroupHandler.cs @@ -26,9 +26,9 @@ public ResourceGroupHandler(IStateStore state, ILogger log public void MapRoutes(WebApplication app) { - app.MapGet(ArmRoutes.ResourceGroups, ListResourceGroupsAsync); - app.MapGet(ArmRoutes.ResourceGroup, GetResourceGroupAsync); - app.MapPut(ArmRoutes.ResourceGroup, CreateOrUpdateResourceGroupAsync); + app.MapGet(ArmRoutes.ResourceGroups, ListResourceGroupsAsync); + app.MapGet(ArmRoutes.ResourceGroup, GetResourceGroupAsync); + app.MapPut(ArmRoutes.ResourceGroup, CreateOrUpdateResourceGroupAsync); app.MapDelete(ArmRoutes.ResourceGroup, DeleteResourceGroupAsync); } @@ -77,9 +77,9 @@ private async Task CreateOrUpdateResourceGroupAsync( var rg = new ResourceGroup { - Name = resourceGroupName, - Location = location, - SubscriptionId = subscriptionId, + Name = resourceGroupName, + Location = location, + SubscriptionId = subscriptionId, ProvisioningState = "Succeeded" }; @@ -111,10 +111,10 @@ private static void SetRequestId(HttpContext ctx) => private static object ResourceGroupResponse(string subscriptionId, ResourceGroup rg) => new { - id = $"/subscriptions/{subscriptionId}/resourceGroups/{rg.Name}", - name = rg.Name, + id = $"/subscriptions/{subscriptionId}/resourceGroups/{rg.Name}", + name = rg.Name, location = rg.Location, - tags = rg.Tags, + tags = rg.Tags, properties = new { provisioningState = rg.ProvisioningState } }; diff --git a/src/AzLocal.Services/BlobStorage/BlobRoutes.cs b/src/AzLocal.Services/BlobStorage/BlobRoutes.cs index 1a239d2..407c7fb 100644 --- a/src/AzLocal.Services/BlobStorage/BlobRoutes.cs +++ b/src/AzLocal.Services/BlobStorage/BlobRoutes.cs @@ -4,10 +4,17 @@ /// Route constants for the Blob Storage emulator endpoints. /// {*blobName} is a catch-all segment so blob names containing forward slashes /// (e.g. "folder/subfolder/file.txt") are captured as a single parameter. +/// +/// Deliberately unprefixed (unlike /arm, /kv, /sb): the Azure Storage SDK's BlobUriBuilder +/// only recognizes URIs shaped exactly "{host}/{account}/{container}/{blob}" (Azurite-style +/// path addressing) as path-style — any extra prefix segment breaks its internal parsing of +/// account/container names, which silently corrupts URIs built via BlobContainerClient.GetBlobClient() +/// and similar SDK convenience methods. The host must also be a literal IP (e.g. 127.0.0.1), not +/// "localhost" — see EmulatorDefaults.BaseUrl. /// public static class BlobRoutes { - public const string ListContainers = "/azu/{account}"; - public const string Container = "/azu/{account}/{container}"; - public const string BlobItem = "/azu/{account}/{container}/{*blobName}"; + public const string ListContainers = "/{account}"; + public const string Container = "/{account}/{container}"; + public const string BlobItem = "/{account}/{container}/{*blobName}"; } diff --git a/src/AzLocal.Services/BlobStorage/BlobServiceHandler.cs b/src/AzLocal.Services/BlobStorage/BlobServiceHandler.cs index bd7ae71..bc1f3a9 100644 --- a/src/AzLocal.Services/BlobStorage/BlobServiceHandler.cs +++ b/src/AzLocal.Services/BlobStorage/BlobServiceHandler.cs @@ -23,18 +23,18 @@ public BlobServiceHandler(IStateStore state, IBlobFileStore blobs, ILogger ListContainersAsync(string account, HttpContext ctx) var xml = BuildXml(writer => { writer.WriteStartElement("EnumerationResults"); - writer.WriteAttributeString("ServiceEndpoint", $"{_baseUrl}/azu/{account}"); + writer.WriteAttributeString("ServiceEndpoint", $"{_baseUrl}/{account}"); writer.WriteStartElement("Containers"); foreach (var c in containers) { @@ -76,7 +76,7 @@ private async Task ListBlobsAsync(string account, string container, Htt var xml = BuildXml(writer => { writer.WriteStartElement("EnumerationResults"); - writer.WriteAttributeString("ServiceEndpoint", $"{_baseUrl}/azu/{account}"); + writer.WriteAttributeString("ServiceEndpoint", $"{_baseUrl}/{account}"); writer.WriteAttributeString("ContainerName", container); writer.WriteStartElement("Blobs"); foreach (var b in blobs) @@ -107,6 +107,9 @@ private async Task CreateContainerAsync(string account, string containe if (await _state.ExistsAsync(key)) { _logger.LogWarning("CreateContainer conflict account={Account} container={Container}", account, container); + // The SDK's CreateIfNotExistsAsync only swallows this 409 if it recognizes the + // x-ms-error-code — without it, the exception rethrows instead of returning null. + ctx.Response.Headers["x-ms-error-code"] = "ContainerAlreadyExists"; return Results.Conflict(); } @@ -136,7 +139,12 @@ private async Task DeleteContainerAsync(string account, string containe private async Task UploadBlobAsync(string account, string container, string blobName, HttpContext ctx) { - var contentType = ctx.Request.ContentType ?? "application/octet-stream"; + // The real Put Blob API takes the stored content type from x-ms-blob-content-type — + // that's what BlobClientOptions.HttpHeaders.ContentType sends — not the request's own + // Content-Type header, which describes the transfer body and is often generic/absent. + var contentType = ctx.Request.Headers["x-ms-blob-content-type"].FirstOrDefault() + ?? ctx.Request.ContentType + ?? "application/octet-stream"; await _blobs.WriteAsync(container, blobName, ctx.Request.Body, contentType); var etag = Guid.NewGuid().ToString("N"); @@ -167,7 +175,7 @@ private async Task DownloadBlobAsync(string account, string container, if (meta is null) { _logger.LogWarning("Blob not found account={Account} container={Container} blob={Blob}", account, container, blobName); - return Results.NotFound(); + return NotFoundBlob(ctx); } Stream stream; @@ -179,7 +187,7 @@ private async Task DownloadBlobAsync(string account, string container, { // Metadata exists but the file was deleted out-of-band — treat as not found. _logger.LogWarning(ex, "Blob file missing despite metadata account={Account} container={Container} blob={Blob}", account, container, blobName); - return Results.NotFound(); + return NotFoundBlob(ctx); } ctx.Response.Headers["ETag"] = $"\"{meta.ETag}\""; @@ -200,7 +208,7 @@ private async Task DeleteBlobAsync(string account, string container, st private async Task GetBlobPropertiesAsync(string account, string container, string blobName, HttpContext ctx) { var meta = await _state.GetAsync(BlobKey(account, container, blobName)); - if (meta is null) return Results.NotFound(); + if (meta is null) return NotFoundBlob(ctx); ctx.Response.Headers["ETag"] = $"\"{meta.ETag}\""; ctx.Response.Headers["Last-Modified"] = meta.LastModified.ToString("R"); @@ -225,14 +233,31 @@ private static string BlobKey(string account, string container, string blob) => private static void SetRequestId(HttpContext ctx) => ctx.Response.Headers["x-ms-request-id"] = Guid.NewGuid().ToString(); + // The SDK's ExistsAsync/DownloadIfExists-style convenience methods only swallow a 404 if + // they recognize the x-ms-error-code — without it, they rethrow instead of returning false/null. + private static IResult NotFoundBlob(HttpContext ctx) + { + ctx.Response.Headers["x-ms-error-code"] = "BlobNotFound"; + return Results.NotFound(); + } + // Uses XmlWriter so blob/container names with <, >, & are safely escaped. private static string BuildXml(Action build) { var sb = new StringBuilder(); - using var writer = XmlWriter.Create(sb, new XmlWriterSettings { Indent = true }); - writer.WriteStartDocument(); - build(writer); - writer.WriteEndDocument(); + // Block-scoped `using` so the writer's internal buffer is flushed via Dispose() + // *before* sb.ToString() runs — a `using var` here would read sb in the same + // scope it's declared, ahead of the deferred Dispose, and return an empty string. + // OmitXmlDeclaration avoids a mismatch: XmlWriter always declares "utf-16" for a + // StringBuilder/TextWriter target (since .NET strings are UTF-16), but Results.Content + // actually sends the body as UTF-8 — an explicit "utf-16" declaration over UTF-8 bytes + // makes the Azure SDK's XML parser reject the response as malformed. + using (var writer = XmlWriter.Create(sb, new XmlWriterSettings { Indent = true, OmitXmlDeclaration = true })) + { + writer.WriteStartDocument(); + build(writer); + writer.WriteEndDocument(); + } return sb.ToString(); } diff --git a/src/AzLocal.Services/KeyVault/KeyVaultSecretHandler.cs b/src/AzLocal.Services/KeyVault/KeyVaultSecretHandler.cs index 340307b..e032bfc 100644 --- a/src/AzLocal.Services/KeyVault/KeyVaultSecretHandler.cs +++ b/src/AzLocal.Services/KeyVault/KeyVaultSecretHandler.cs @@ -20,14 +20,14 @@ public KeyVaultSecretHandler(IStateStore state, ILogger l { _state = state; _logger = logger; - _baseUrl = (config["AzLocal:BaseUrl"] ?? "http://localhost").TrimEnd('/'); + _baseUrl = (config["AzLocal:BaseUrl"] ?? "https://127.0.0.1").TrimEnd('/'); } public void MapRoutes(WebApplication app) { - app.MapGet(KeyVaultRoutes.Secrets, ListSecretsAsync); - app.MapPut(KeyVaultRoutes.SecretByName, SetSecretAsync); - app.MapGet(KeyVaultRoutes.SecretByName, GetSecretAsync); + app.MapGet(KeyVaultRoutes.Secrets, ListSecretsAsync); + app.MapPut(KeyVaultRoutes.SecretByName, SetSecretAsync); + app.MapGet(KeyVaultRoutes.SecretByName, GetSecretAsync); app.MapGet(KeyVaultRoutes.SecretVersion, GetSecretVersionAsync); app.MapDelete(KeyVaultRoutes.SecretByName, DeleteSecretAsync); } @@ -44,7 +44,7 @@ private async Task ListSecretsAsync(string vault, HttpContext ctx) .Where(s => s.Enabled) .GroupBy(s => s.Name) .Select(g => g.OrderByDescending(s => s.UpdatedOn).First()) - .Select(s => new { id = SecretUrl(vault, s.Name, s.Version), attributes = Attributes(s) }); + .Select(s => new { id = SecretUrl(s.Name, s.Version), attributes = Attributes(s) }); SetRequestId(ctx); return Results.Ok(new { value = latest }); @@ -72,13 +72,13 @@ private async Task SetSecretAsync(string vault, string secretName, Http CreatedOn = DateTimeOffset.UtcNow, UpdatedOn = DateTimeOffset.UtcNow }; - secret.Id = SecretUrl(vault, secretName, secret.Version); + secret.Id = SecretUrl(secretName, secret.Version); await _state.SetAsync(SecretKey(vault, secretName, secret.Version), secret); _logger.LogInformation("Secret set vault={Vault} secret={Secret} version={Version}", vault, secretName, secret.Version); SetRequestId(ctx); - return Results.Ok(SecretResponse(vault, secret)); + return Results.Ok(SecretResponse(secret)); } private async Task GetSecretAsync(string vault, string secretName, HttpContext ctx) @@ -92,7 +92,7 @@ private async Task GetSecretAsync(string vault, string secretName, Http } SetRequestId(ctx); - return Results.Ok(SecretResponse(vault, secret)); + return Results.Ok(SecretResponse(secret)); } private async Task GetSecretVersionAsync(string vault, string secretName, string version, HttpContext ctx) @@ -105,7 +105,7 @@ private async Task GetSecretVersionAsync(string vault, string secretNam } SetRequestId(ctx); - return Results.Ok(SecretResponse(vault, secret)); + return Results.Ok(SecretResponse(secret)); } private async Task DeleteSecretAsync(string vault, string secretName, HttpContext ctx) @@ -119,7 +119,21 @@ private async Task DeleteSecretAsync(string vault, string secretName, H _logger.LogInformation("Secret deleted (soft) vault={Vault} secret={Secret} versions={Count}", vault, secretName, all.Count); SetRequestId(ctx); - return Results.Ok(); + + var latest = all.OrderByDescending(s => s.UpdatedOn).FirstOrDefault(); + if (latest is null) return Results.NotFound(); + + // SecretClient.StartDeleteSecretAsync deserializes the response as a DeletedSecret — + // an empty body (or plain 200 with no content) fails client-side JSON parsing. + var now = DateTimeOffset.UtcNow; + return Results.Ok(new + { + id = SecretUrl(secretName, latest.Version), + attributes = Attributes(latest), + recoveryId = $"{_baseUrl}/deletedsecrets/{secretName}", + deletedDate = now.ToUnixTimeSeconds(), + scheduledPurgeDate = now.AddDays(90).ToUnixTimeSeconds() + }); } #endregion @@ -129,8 +143,12 @@ private async Task DeleteSecretAsync(string vault, string secretName, H private static string SecretKey(string vault, string name, string version) => $"keyvault/secrets/{vault}/{name}/{version}".ToLowerInvariant(); - private string SecretUrl(string vault, string name, string version) => - $"{_baseUrl}/kv/{vault}/secrets/{name}/{version}"; + // Deliberately omits the "/kv/{vault}" prefix used for routing incoming requests: the Key + // Vault SDK's own KeyVaultIdentifier.Parse expects response "id" URLs shaped exactly + // "{host}/secrets/{name}/{version}" (real Azure identifies the vault via a subdomain, not + // a path segment) — an extra path segment here makes the SDK reject the response. + private string SecretUrl(string name, string version) => + $"{_baseUrl}/secrets/{name}/{version}"; private static void SetRequestId(HttpContext ctx) => ctx.Response.Headers["x-ms-request-id"] = Guid.NewGuid().ToString(); @@ -143,10 +161,10 @@ private static void SetRequestId(HttpContext ctx) => expires = s.ExpiresOn?.ToUnixTimeSeconds() }; - private object SecretResponse(string vault, KeyVaultSecret secret) => new + private object SecretResponse(KeyVaultSecret secret) => new { - value = secret.Value, - id = SecretUrl(vault, secret.Name, secret.Version), + value = secret.Value, + id = SecretUrl(secret.Name, secret.Version), attributes = Attributes(secret) }; diff --git a/src/AzLocal.Services/ServiceBus/ServiceBusQueueHandler.cs b/src/AzLocal.Services/ServiceBus/ServiceBusQueueHandler.cs index 5e9381d..2445cb4 100644 --- a/src/AzLocal.Services/ServiceBus/ServiceBusQueueHandler.cs +++ b/src/AzLocal.Services/ServiceBus/ServiceBusQueueHandler.cs @@ -75,8 +75,7 @@ private async Task PeekMessagesAsync(string @namespace, string queue, H /// private async Task ReceiveMessageAsync(string @namespace, string queue, HttpContext ctx) { - var messages = await _state.ListAsync(ActivePrefix(@namespace, queue)); - var message = messages.OrderBy(m => m.EnqueuedAt).FirstOrDefault(); + var message = await NextUnlockedMessageAsync(@namespace, queue); if (message is null) { _logger.LogDebug("ReceiveMessage ns={Namespace} queue={Queue} — queue empty", @namespace, queue); @@ -100,7 +99,9 @@ private async Task ReceiveMessageAsync(string @namespace, string queue, DeliveryCount = message.DeliveryCount, EnqueuedTimeUtc = message.EnqueuedAt }); - return Results.Ok(message.Body); + // The message body is the raw payload the caller sent — Results.Ok(string) would + // JSON-encode it (wrapping it in quotes), corrupting it for any consumer. + return Results.Text(message.Body); } /// @@ -109,8 +110,7 @@ private async Task ReceiveMessageAsync(string @namespace, string queue, /// private async Task ReceiveAndDeleteAsync(string @namespace, string queue, HttpContext ctx) { - var messages = await _state.ListAsync(ActivePrefix(@namespace, queue)); - var message = messages.OrderBy(m => m.EnqueuedAt).FirstOrDefault(); + var message = await NextUnlockedMessageAsync(@namespace, queue); if (message is null) return Results.NoContent(); await _state.DeleteAsync(ActiveKey(@namespace, queue, message.MessageId)); @@ -123,7 +123,9 @@ private async Task ReceiveAndDeleteAsync(string @namespace, string queu DeliveryCount = message.DeliveryCount + 1, EnqueuedTimeUtc = message.EnqueuedAt }); - return Results.Ok(message.Body); + // The message body is the raw payload the caller sent — Results.Ok(string) would + // JSON-encode it (wrapping it in quotes), corrupting it for any consumer. + return Results.Text(message.Body); } /// Completes a previously peek-locked message, removing it from the queue. @@ -185,12 +187,28 @@ private async Task DeadLetterMessageAsync(string @namespace, string que #region Private helpers + // Shared by peek-lock and receive-and-delete: a message currently held under an active + // lock must stay invisible to other receivers until it's completed, dead-lettered, or the + // lock expires — otherwise two consumers could process the same message concurrently. + private async Task NextUnlockedMessageAsync(string ns, string queue) + { + var lockedMessageIds = (await _state.ListAsync(LockPrefix(ns, queue))).ToHashSet(); + var messages = await _state.ListAsync(ActivePrefix(ns, queue)); + return messages + .Where(m => !lockedMessageIds.Contains(m.MessageId)) + .OrderBy(m => m.EnqueuedAt) + .FirstOrDefault(); + } + private static string ActivePrefix(string ns, string queue) => $"servicebus/{ns}/{queue}/active/".ToLowerInvariant(); private static string ActiveKey(string ns, string queue, string messageId) => $"servicebus/{ns}/{queue}/active/{messageId}".ToLowerInvariant(); + private static string LockPrefix(string ns, string queue) => + $"servicebus/{ns}/{queue}/locks/".ToLowerInvariant(); + private static string LockKey(string ns, string queue, string lockToken) => $"servicebus/{ns}/{queue}/locks/{lockToken}".ToLowerInvariant(); diff --git a/src/AzLocal.State/AzLocal.State.csproj b/src/AzLocal.State/AzLocal.State.csproj index 809825b..17462cd 100644 --- a/src/AzLocal.State/AzLocal.State.csproj +++ b/src/AzLocal.State/AzLocal.State.csproj @@ -1,9 +1,11 @@ - + + - + + net10.0 diff --git a/src/AzLocal.State/SqliteStateStore.cs b/src/AzLocal.State/SqliteStateStore.cs index eef24a7..bb0833b 100644 --- a/src/AzLocal.State/SqliteStateStore.cs +++ b/src/AzLocal.State/SqliteStateStore.cs @@ -80,6 +80,7 @@ public async Task> ListAsync(string prefix) return results; } + /// Returns true if an entry with the given key exists. public async Task ExistsAsync(string key) { diff --git a/tests/AzLocal.CompatTests/AzLocal.CompatTests.csproj b/tests/AzLocal.CompatTests/AzLocal.CompatTests.csproj index 6f9002a..8f4c902 100644 --- a/tests/AzLocal.CompatTests/AzLocal.CompatTests.csproj +++ b/tests/AzLocal.CompatTests/AzLocal.CompatTests.csproj @@ -1,4 +1,5 @@ - + + net10.0 diff --git a/tests/AzLocal.IntegrationTests/ArmTests.cs b/tests/AzLocal.IntegrationTests/ArmTests.cs new file mode 100644 index 0000000..9021969 --- /dev/null +++ b/tests/AzLocal.IntegrationTests/ArmTests.cs @@ -0,0 +1,236 @@ +using AzLocal.IntegrationTests.Fixtures; +using System.Net; +using System.Text; +using System.Text.Json; + +namespace AzLocal.IntegrationTests; + +// Note: like ServiceBus, ARM here is exercised via AzlocalClientFactory.CreateHttpClient's raw +// HTTP surface rather than the official Azure.ResourceManager SDK — that SDK is a much larger +// surface (long-running-operation polling, resource identifier composition, etc.) that AzLocal +// doesn't attempt to support today. +public class ArmTests : IClassFixture +{ + private readonly EmulatorFixture _fixture; + + public ArmTests(EmulatorFixture fixture) => _fixture = fixture; + + private static string UniqueName(string prefix) => $"{prefix}-{Guid.NewGuid():N}"; + + private HttpClient NewClient() => _fixture.Clients.CreateHttpClient(); + + private async Task GetConfiguredSubscriptionIdAsync(HttpClient client) + { + var response = await client.GetAsync("/arm/subscriptions"); + using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + return doc.RootElement.GetProperty("value")[0].GetProperty("subscriptionId").GetString()!; + } + + [Fact] + public async Task ListSubscriptions_ReturnsConfiguredSubscription() + { + var client = NewClient(); + + var response = await client.GetAsync("/arm/subscriptions"); + using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var values = doc.RootElement.GetProperty("value").EnumerateArray().ToList(); + Assert.Single(values); + Assert.Equal("Enabled", values[0].GetProperty("state").GetString()); + } + + [Fact] + public async Task GetSubscription_WithConfiguredId_ReturnsIt() + { + var client = NewClient(); + var subId = await GetConfiguredSubscriptionIdAsync(client); + + var response = await client.GetAsync($"/arm/subscriptions/{subId}"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task GetSubscription_WithUnknownId_Returns404() + { + var client = NewClient(); + + var response = await client.GetAsync($"/arm/subscriptions/{Guid.NewGuid()}"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task CreateResourceGroup_ReturnsCreated_AndReadsLocationFromBody() + { + var client = NewClient(); + var subId = await GetConfiguredSubscriptionIdAsync(client); + var rgName = UniqueName("rg"); + var body = new StringContent("{\"location\":\"eastus\"}", Encoding.UTF8, "application/json"); + + var response = await client.PutAsync($"/arm/subscriptions/{subId}/resourcegroups/{rgName}", body); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + } + + [Fact] + public async Task GetResourceGroup_AfterCreate_ReturnsMatchingLocation() + { + var client = NewClient(); + var subId = await GetConfiguredSubscriptionIdAsync(client); + var rgName = UniqueName("rg"); + await client.PutAsync($"/arm/subscriptions/{subId}/resourcegroups/{rgName}", + new StringContent("{\"location\":\"westus\"}", Encoding.UTF8, "application/json")); + + var response = await client.GetAsync($"/arm/subscriptions/{subId}/resourcegroups/{rgName}"); + using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(rgName, doc.RootElement.GetProperty("name").GetString()); + Assert.Equal("westus", doc.RootElement.GetProperty("location").GetString()); + Assert.Equal("Succeeded", doc.RootElement.GetProperty("properties").GetProperty("provisioningState").GetString()); + } + + [Fact] + public async Task GetResourceGroup_ThatWasNeverCreated_Returns404() + { + var client = NewClient(); + var subId = await GetConfiguredSubscriptionIdAsync(client); + + var response = await client.GetAsync($"/arm/subscriptions/{subId}/resourcegroups/{UniqueName("missing")}"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task CreateResourceGroup_Twice_SecondCallUpdatesAndReturnsOk() + { + var client = NewClient(); + var subId = await GetConfiguredSubscriptionIdAsync(client); + var rgName = UniqueName("rg"); + var firstBody = new StringContent("{\"location\":\"eastus\"}", Encoding.UTF8, "application/json"); + var secondBody = new StringContent("{\"location\":\"westus\"}", Encoding.UTF8, "application/json"); + + var first = await client.PutAsync($"/arm/subscriptions/{subId}/resourcegroups/{rgName}", firstBody); + var second = await client.PutAsync($"/arm/subscriptions/{subId}/resourcegroups/{rgName}", secondBody); + using var doc = JsonDocument.Parse(await second.Content.ReadAsStringAsync()); + + Assert.Equal(HttpStatusCode.Created, first.StatusCode); + Assert.Equal(HttpStatusCode.OK, second.StatusCode); + Assert.Equal("westus", doc.RootElement.GetProperty("location").GetString()); + } + + [Fact] + public async Task ListResourceGroups_IncludesCreatedGroup() + { + var client = NewClient(); + var subId = await GetConfiguredSubscriptionIdAsync(client); + var rgName = UniqueName("rg"); + await client.PutAsync($"/arm/subscriptions/{subId}/resourcegroups/{rgName}", + new StringContent("{\"location\":\"eastus\"}", Encoding.UTF8, "application/json")); + + var response = await client.GetAsync($"/arm/subscriptions/{subId}/resourcegroups"); + using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + + var names = doc.RootElement.GetProperty("value").EnumerateArray() + .Select(v => v.GetProperty("name").GetString()).ToList(); + Assert.Contains(rgName, names); + } + + [Fact] + public async Task DeleteResourceGroup_RemovesIt() + { + var client = NewClient(); + var subId = await GetConfiguredSubscriptionIdAsync(client); + var rgName = UniqueName("rg"); + await client.PutAsync($"/arm/subscriptions/{subId}/resourcegroups/{rgName}", + new StringContent("{\"location\":\"eastus\"}", Encoding.UTF8, "application/json")); + + var delete = await client.DeleteAsync($"/arm/subscriptions/{subId}/resourcegroups/{rgName}"); + var getAfter = await client.GetAsync($"/arm/subscriptions/{subId}/resourcegroups/{rgName}"); + + Assert.Equal(HttpStatusCode.Accepted, delete.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, getAfter.StatusCode); + } + + [Fact] + public async Task DeleteResourceGroup_ThatWasNeverCreated_DoesNotThrow() + { + var client = NewClient(); + var subId = await GetConfiguredSubscriptionIdAsync(client); + + var response = await client.DeleteAsync($"/arm/subscriptions/{subId}/resourcegroups/{UniqueName("missing")}"); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + } + + [Fact] + public async Task CreateResourceGroup_WithNoBody_DefaultsToLocalLocation() + { + var client = NewClient(); + var subId = await GetConfiguredSubscriptionIdAsync(client); + var rgName = UniqueName("rg"); + + var create = await client.PutAsync($"/arm/subscriptions/{subId}/resourcegroups/{rgName}", content: null); + var get = await client.GetAsync($"/arm/subscriptions/{subId}/resourcegroups/{rgName}"); + using var doc = JsonDocument.Parse(await get.Content.ReadAsStringAsync()); + + Assert.Equal(HttpStatusCode.Created, create.StatusCode); + Assert.Equal("local", doc.RootElement.GetProperty("location").GetString()); + } + + [Fact] + public async Task GetSubscription_IsCaseInsensitive() + { + var client = NewClient(); + var subId = await GetConfiguredSubscriptionIdAsync(client); + + var response = await client.GetAsync($"/arm/subscriptions/{subId.ToUpperInvariant()}"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task ResourceGroups_AreIsolatedAcrossSubscriptions() + { + var client = NewClient(); + var rgName = UniqueName("shared-name-rg"); + var subA = UniqueName("sub-a"); + var subB = UniqueName("sub-b"); + await client.PutAsync($"/arm/subscriptions/{subA}/resourcegroups/{rgName}", + new StringContent("{\"location\":\"eastus\"}", Encoding.UTF8, "application/json")); + + var getFromB = await client.GetAsync($"/arm/subscriptions/{subB}/resourcegroups/{rgName}"); + + Assert.Equal(HttpStatusCode.NotFound, getFromB.StatusCode); + } + + [Fact] + public async Task ListResourceGroups_ForSubscriptionWithNone_ReturnsEmptyList() + { + var client = NewClient(); + var subId = UniqueName("empty-sub"); + + var response = await client.GetAsync($"/arm/subscriptions/{subId}/resourcegroups"); + using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Empty(doc.RootElement.GetProperty("value").EnumerateArray()); + } + + [Fact] + public async Task ResourceGroupId_MatchesArmResourceIdFormat() + { + var client = NewClient(); + var subId = await GetConfiguredSubscriptionIdAsync(client); + var rgName = UniqueName("rg"); + await client.PutAsync($"/arm/subscriptions/{subId}/resourcegroups/{rgName}", + new StringContent("{\"location\":\"eastus\"}", Encoding.UTF8, "application/json")); + + var get = await client.GetAsync($"/arm/subscriptions/{subId}/resourcegroups/{rgName}"); + using var doc = JsonDocument.Parse(await get.Content.ReadAsStringAsync()); + + Assert.Equal($"/subscriptions/{subId}/resourceGroups/{rgName}", doc.RootElement.GetProperty("id").GetString()); + } +} diff --git a/tests/AzLocal.IntegrationTests/AzLocal.IntegrationTests.csproj b/tests/AzLocal.IntegrationTests/AzLocal.IntegrationTests.csproj index 0ce2650..2da0709 100644 --- a/tests/AzLocal.IntegrationTests/AzLocal.IntegrationTests.csproj +++ b/tests/AzLocal.IntegrationTests/AzLocal.IntegrationTests.csproj @@ -9,6 +9,7 @@ + @@ -24,4 +25,10 @@ + + + + + \ No newline at end of file diff --git a/tests/AzLocal.IntegrationTests/BlobStorageTests.cs b/tests/AzLocal.IntegrationTests/BlobStorageTests.cs index 47cc9f2..be9cf35 100644 --- a/tests/AzLocal.IntegrationTests/BlobStorageTests.cs +++ b/tests/AzLocal.IntegrationTests/BlobStorageTests.cs @@ -1,5 +1,217 @@ -namespace AzLocal.IntegrationTests; -public class BlobStorageTests +using AzLocal.IntegrationTests.Fixtures; +using Azure; +using System.Text; + +namespace AzLocal.IntegrationTests; + +public class BlobStorageTests : IClassFixture { + private readonly EmulatorFixture _fixture; + + public BlobStorageTests(EmulatorFixture fixture) => _fixture = fixture; + + private static string UniqueName(string prefix) => $"{prefix}-{Guid.NewGuid():N}"; + + [Fact] + public async Task CreateContainer_ThenUploadAndDownloadBlob_RoundTripsContent() + { + var containerClient = _fixture.Clients.CreateBlobContainerClient("acct1", UniqueName("container")); + await containerClient.CreateIfNotExistsAsync(); + + var blobClient = containerClient.GetBlobClient("file.txt"); + await blobClient.UploadAsync(new BinaryData("hello from azlocal"), overwrite: true); + + var download = await blobClient.DownloadContentAsync(); + + Assert.Equal("hello from azlocal", download.Value.Content.ToString()); + } + + [Fact] + public async Task GetBlobProperties_ReflectsUploadedContentType() + { + var containerClient = _fixture.Clients.CreateBlobContainerClient("acct1", UniqueName("container")); + await containerClient.CreateIfNotExistsAsync(); + var blobClient = containerClient.GetBlobClient("data.json"); + + await blobClient.UploadAsync( + new BinaryData(Encoding.UTF8.GetBytes("{}")), + new Azure.Storage.Blobs.Models.BlobUploadOptions + { + HttpHeaders = new Azure.Storage.Blobs.Models.BlobHttpHeaders { ContentType = "application/json" } + }); + + var props = await blobClient.GetPropertiesAsync(); + + Assert.Equal("application/json", props.Value.ContentType); + } + + [Fact] + public async Task ListBlobs_ReturnsUploadedBlob() + { + var containerClient = _fixture.Clients.CreateBlobContainerClient("acct1", UniqueName("container")); + await containerClient.CreateIfNotExistsAsync(); + await containerClient.UploadBlobAsync("only-blob.txt", new BinaryData("content")); + + var names = new List(); + await foreach (var item in containerClient.GetBlobsAsync()) + names.Add(item.Name); + + Assert.Contains("only-blob.txt", names); + } + + [Fact] + public async Task DeleteBlob_RemovesIt() + { + var containerClient = _fixture.Clients.CreateBlobContainerClient("acct1", UniqueName("container")); + await containerClient.CreateIfNotExistsAsync(); + var blobClient = containerClient.GetBlobClient("to-delete.txt"); + await blobClient.UploadAsync(new BinaryData("gone soon"), overwrite: true); + + await blobClient.DeleteAsync(); + + await Assert.ThrowsAsync(() => blobClient.DownloadContentAsync()); + } + + [Fact] + public async Task DownloadBlob_ThatWasNeverUploaded_Throws404() + { + var containerClient = _fixture.Clients.CreateBlobContainerClient("acct1", UniqueName("container")); + await containerClient.CreateIfNotExistsAsync(); + var blobClient = containerClient.GetBlobClient("never-existed.txt"); + + var ex = await Assert.ThrowsAsync(() => blobClient.DownloadContentAsync()); + Assert.Equal(404, ex.Status); + } + + [Fact] + public async Task CreateContainer_Twice_WithPlainCreate_ThrowsConflict() + { + var containerClient = _fixture.Clients.CreateBlobContainerClient("acct1", UniqueName("container")); + await containerClient.CreateAsync(); + + var ex = await Assert.ThrowsAsync(() => containerClient.CreateAsync()); + Assert.Equal(409, ex.Status); + } + + [Fact] + public async Task CreateContainer_Twice_WithCreateIfNotExists_DoesNotThrow() + { + var containerClient = _fixture.Clients.CreateBlobContainerClient("acct1", UniqueName("container")); + await containerClient.CreateIfNotExistsAsync(); + + var exception = await Record.ExceptionAsync(() => containerClient.CreateIfNotExistsAsync()); + + Assert.Null(exception); + } + + [Fact] + public async Task UploadBlob_WithNestedPath_RoundTripsContent() + { + var containerClient = _fixture.Clients.CreateBlobContainerClient("acct1", UniqueName("container")); + await containerClient.CreateIfNotExistsAsync(); + var blobClient = containerClient.GetBlobClient("folder/subfolder/file.txt"); + + await blobClient.UploadAsync(new BinaryData("nested content"), overwrite: true); + var download = await blobClient.DownloadContentAsync(); + + Assert.Equal("nested content", download.Value.Content.ToString()); + } + + [Fact] + public async Task UploadBlob_Overwrite_ReplacesContent() + { + var containerClient = _fixture.Clients.CreateBlobContainerClient("acct1", UniqueName("container")); + await containerClient.CreateIfNotExistsAsync(); + var blobClient = containerClient.GetBlobClient("overwrite-me.txt"); + await blobClient.UploadAsync(new BinaryData("original"), overwrite: true); + + await blobClient.UploadAsync(new BinaryData("replaced"), overwrite: true); + var download = await blobClient.DownloadContentAsync(); + + Assert.Equal("replaced", download.Value.Content.ToString()); + } + + [Fact] + public async Task UploadBlob_BinaryContent_RoundTripsExactBytes() + { + var containerClient = _fixture.Clients.CreateBlobContainerClient("acct1", UniqueName("container")); + await containerClient.CreateIfNotExistsAsync(); + var blobClient = containerClient.GetBlobClient("binary.dat"); + var bytes = new byte[] { 0x00, 0x01, 0xFF, 0x7F, 0x80, 0xDE, 0xAD, 0xBE, 0xEF }; + + await blobClient.UploadAsync(new BinaryData(bytes), overwrite: true); + var download = await blobClient.DownloadContentAsync(); + + Assert.Equal(bytes, download.Value.Content.ToArray()); + } + + [Fact] + public async Task GetProperties_ForMissingBlob_Throws404() + { + var containerClient = _fixture.Clients.CreateBlobContainerClient("acct1", UniqueName("container")); + await containerClient.CreateIfNotExistsAsync(); + var blobClient = containerClient.GetBlobClient("never-existed.txt"); + + var ex = await Assert.ThrowsAsync(() => blobClient.GetPropertiesAsync()); + Assert.Equal(404, ex.Status); + } + + [Fact] + public async Task BlobExists_ReturnsFalseThenTrueAfterUpload() + { + var containerClient = _fixture.Clients.CreateBlobContainerClient("acct1", UniqueName("container")); + await containerClient.CreateIfNotExistsAsync(); + var blobClient = containerClient.GetBlobClient("exists-check.txt"); + + var before = await blobClient.ExistsAsync(); + await blobClient.UploadAsync(new BinaryData("now it exists"), overwrite: true); + var after = await blobClient.ExistsAsync(); + + Assert.False(before.Value); + Assert.True(after.Value); + } + + [Fact] + public async Task DeleteContainer_RemovesAllBlobs() + { + var containerClient = _fixture.Clients.CreateBlobContainerClient("acct1", UniqueName("container")); + await containerClient.CreateIfNotExistsAsync(); + var blobClient = containerClient.GetBlobClient("doomed.txt"); + await blobClient.UploadAsync(new BinaryData("about to vanish"), overwrite: true); + + await containerClient.DeleteAsync(); + + var ex = await Assert.ThrowsAsync(() => blobClient.DownloadContentAsync()); + Assert.Equal(404, ex.Status); + } + + [Fact] + public async Task Containers_AreIsolatedAcrossAccounts() + { + var containerName = UniqueName("shared-name-container"); + var accountA = _fixture.Clients.CreateBlobContainerClient(UniqueName("acctA"), containerName); + var accountB = _fixture.Clients.CreateBlobContainerClient(UniqueName("acctB"), containerName); + await accountA.CreateIfNotExistsAsync(); + await accountA.UploadBlobAsync("only-in-a.txt", new BinaryData("a's data")); + + var namesInB = new List(); + await foreach (var item in accountB.GetBlobsAsync()) + namesInB.Add(item.Name); + + Assert.DoesNotContain("only-in-a.txt", namesInB); + } + + [Fact] + public async Task ListBlobs_OnEmptyContainer_ReturnsNoBlobs() + { + var containerClient = _fixture.Clients.CreateBlobContainerClient("acct1", UniqueName("container")); + await containerClient.CreateIfNotExistsAsync(); + + var names = new List(); + await foreach (var item in containerClient.GetBlobsAsync()) + names.Add(item.Name); + + Assert.Empty(names); + } } diff --git a/tests/AzLocal.IntegrationTests/Fixtures/EmulatorFixture.cs b/tests/AzLocal.IntegrationTests/Fixtures/EmulatorFixture.cs index b14a5d9..7d83e6d 100644 --- a/tests/AzLocal.IntegrationTests/Fixtures/EmulatorFixture.cs +++ b/tests/AzLocal.IntegrationTests/Fixtures/EmulatorFixture.cs @@ -1,5 +1,47 @@ -namespace AzLocal.IntegrationTests.Fixtures; +using AzLocal.Client; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; -public class EmulatorFixture +namespace AzLocal.IntegrationTests.Fixtures; + +/// +/// Boots the AzLocal emulator (AzLocal.Host) in-process via +/// — no separately started process, no real socket. State defaults to InMemory (the +/// host's own default), and blob file storage is redirected to a per-instance temp directory +/// so tests never touch %TEMP%/azlocal or collide with each other. +/// +/// Exposes , an whose Azure SDK clients +/// route through the in-memory . +/// +public sealed class EmulatorFixture : WebApplicationFactory { + private readonly string _blobStorePath = + Path.Combine(Path.GetTempPath(), "azlocal-inttest-blobs", Guid.NewGuid().ToString()); + + public AzlocalClientFactory Clients { get; } + + public EmulatorFixture() + { + // Must be an IP literal, not "localhost" — see the comment on BlobRoutes. + Clients = new AzlocalClientFactory("https://127.0.0.1", Server.CreateHandler()); + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureAppConfiguration((_, config) => + { + config.AddInMemoryCollection(new Dictionary + { + ["AzLocal:BlobStorePath"] = _blobStorePath + }); + }); + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + if (Directory.Exists(_blobStorePath)) + Directory.Delete(_blobStorePath, recursive: true); + } } diff --git a/tests/AzLocal.IntegrationTests/KeyVaultTests.cs b/tests/AzLocal.IntegrationTests/KeyVaultTests.cs index 270a850..a11896e 100644 --- a/tests/AzLocal.IntegrationTests/KeyVaultTests.cs +++ b/tests/AzLocal.IntegrationTests/KeyVaultTests.cs @@ -1,5 +1,160 @@ -namespace AzLocal.IntegrationTests; +using AzLocal.IntegrationTests.Fixtures; +using Azure; -public class KeyVaultTests +namespace AzLocal.IntegrationTests; + +public class KeyVaultTests : IClassFixture { + private readonly EmulatorFixture _fixture; + + public KeyVaultTests(EmulatorFixture fixture) => _fixture = fixture; + + private static string UniqueName(string prefix) => $"{prefix}-{Guid.NewGuid():N}"; + + [Fact] + public async Task SetSecret_ThenGetSecret_RoundTripsValue() + { + var secrets = _fixture.Clients.CreateSecretClient("myvault"); + var name = UniqueName("secret"); + + await secrets.SetSecretAsync(name, "super-secret-value"); + var result = await secrets.GetSecretAsync(name); + + Assert.Equal("super-secret-value", result.Value.Value); + } + + [Fact] + public async Task SetSecret_Twice_LatestVersionWins() + { + var secrets = _fixture.Clients.CreateSecretClient("myvault"); + var name = UniqueName("secret"); + + await secrets.SetSecretAsync(name, "first-value"); + await secrets.SetSecretAsync(name, "second-value"); + var result = await secrets.GetSecretAsync(name); + + Assert.Equal("second-value", result.Value.Value); + } + + [Fact] + public async Task GetSecret_ThatWasNeverSet_Throws404() + { + var secrets = _fixture.Clients.CreateSecretClient("myvault"); + + var ex = await Assert.ThrowsAsync( + () => secrets.GetSecretAsync(UniqueName("missing"))); + + Assert.Equal(404, ex.Status); + } + + [Fact] + public async Task DeleteSecret_MakesItUnavailable() + { + var secrets = _fixture.Clients.CreateSecretClient("myvault"); + var name = UniqueName("secret"); + await secrets.SetSecretAsync(name, "to-be-deleted"); + + await secrets.StartDeleteSecretAsync(name); + + var ex = await Assert.ThrowsAsync(() => secrets.GetSecretAsync(name)); + Assert.Equal(404, ex.Status); + } + + [Fact] + public async Task ListPropertiesOfSecrets_ReturnsSetSecret() + { + var secrets = _fixture.Clients.CreateSecretClient("myvault"); + var name = UniqueName("secret"); + await secrets.SetSecretAsync(name, "listed-value"); + + var names = new List(); + await foreach (var props in secrets.GetPropertiesOfSecretsAsync()) + names.Add(props.Name); + + Assert.Contains(name, names); + } + + [Fact] + public async Task GetSecret_WithExplicitVersion_ReturnsThatVersion() + { + var secrets = _fixture.Clients.CreateSecretClient("myvault"); + var name = UniqueName("secret"); + var firstSet = await secrets.SetSecretAsync(name, "version-one"); + await secrets.SetSecretAsync(name, "version-two"); + + var result = await secrets.GetSecretAsync(name, firstSet.Value.Properties.Version); + + Assert.Equal("version-one", result.Value.Value); + } + + + [Fact] + public async Task ListPropertiesOfSecrets_ShowsOnlyLatestVersion_NotEveryVersion() + { + var secrets = _fixture.Clients.CreateSecretClient("myvault"); + var name = UniqueName("secret"); + await secrets.SetSecretAsync(name, "v1"); + await secrets.SetSecretAsync(name, "v2"); + await secrets.SetSecretAsync(name, "v3"); + + var matches = new List(); + await foreach (var props in secrets.GetPropertiesOfSecretsAsync()) + if (props.Name == name) matches.Add(props.Version); + + Assert.Single(matches); + } + + [Fact] + public async Task ListPropertiesOfSecrets_ExcludesDeletedSecret() + { + var secrets = _fixture.Clients.CreateSecretClient("myvault"); + var name = UniqueName("secret"); + await secrets.SetSecretAsync(name, "will-be-deleted"); + await secrets.StartDeleteSecretAsync(name); + + var names = new List(); + await foreach (var props in secrets.GetPropertiesOfSecretsAsync()) + names.Add(props.Name); + + Assert.DoesNotContain(name, names); + } + + [Fact] + public async Task SetSecret_WithEmptyValue_RoundTrips() + { + var secrets = _fixture.Clients.CreateSecretClient("myvault"); + var name = UniqueName("secret"); + + await secrets.SetSecretAsync(name, string.Empty); + var result = await secrets.GetSecretAsync(name); + + Assert.Equal(string.Empty, result.Value.Value); + } + + [Fact] + public async Task Secrets_AreIsolatedAcrossVaults() + { + var name = UniqueName("secret"); + var vaultA = _fixture.Clients.CreateSecretClient(UniqueName("vaultA")); + var vaultB = _fixture.Clients.CreateSecretClient(UniqueName("vaultB")); + await vaultA.SetSecretAsync(name, "only-in-vault-a"); + + var ex = await Assert.ThrowsAsync(() => vaultB.GetSecretAsync(name)); + + Assert.Equal(404, ex.Status); + } + + [Fact] + public async Task DeleteSecret_ThenSetSecretAgain_MakesItAvailableAgain() + { + var secrets = _fixture.Clients.CreateSecretClient("myvault"); + var name = UniqueName("secret"); + await secrets.SetSecretAsync(name, "first-life"); + await secrets.StartDeleteSecretAsync(name); + + await secrets.SetSecretAsync(name, "second-life"); + var result = await secrets.GetSecretAsync(name); + + Assert.Equal("second-life", result.Value.Value); + } } diff --git a/tests/AzLocal.IntegrationTests/ServiceBusTests.cs b/tests/AzLocal.IntegrationTests/ServiceBusTests.cs index 8a1e40d..a933b6e 100644 --- a/tests/AzLocal.IntegrationTests/ServiceBusTests.cs +++ b/tests/AzLocal.IntegrationTests/ServiceBusTests.cs @@ -1,5 +1,245 @@ -namespace AzLocal.IntegrationTests; +using AzLocal.IntegrationTests.Fixtures; +using System.Net; +using System.Text; +using System.Text.Json; -public class ServiceBusTests +namespace AzLocal.IntegrationTests; + +// Note: Azure.Messaging.ServiceBus (the real SDK) speaks AMQP over TCP, not HTTP — it cannot +// talk to this emulator at all. These tests exercise AzlocalClientFactory.CreateServiceBusHttpClient's +// raw HTTP facade directly, since that's the only way to reach this service today. They validate +// the handler's own behavior, not real Azure SDK wire compatibility (see ServiceBusRoutes.cs). +public class ServiceBusTests : IClassFixture { + private readonly EmulatorFixture _fixture; + + public ServiceBusTests(EmulatorFixture fixture) => _fixture = fixture; + + private static string UniqueName(string prefix) => $"{prefix}-{Guid.NewGuid():N}"; + + private HttpClient NewClient(string @namespace) => _fixture.Clients.CreateServiceBusHttpClient(@namespace); + + private static string? GetLockToken(HttpResponseMessage response) + { + var brokerProps = response.Headers.GetValues("BrokerProperties").First(); + using var doc = JsonDocument.Parse(brokerProps); + return doc.RootElement.GetProperty("LockToken").GetString(); + } + + [Fact] + public async Task SendMessage_ReturnsCreatedWithContentLocation() + { + var client = NewClient(UniqueName("ns")); + var queue = UniqueName("queue"); + + var response = await client.PostAsync($"{queue}/messages", new StringContent("hello")); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + Assert.Contains($"/{queue}/messages/", response.Content.Headers.GetValues("Content-Location").First()); + } + + [Fact] + public async Task PeekMessages_ReturnsSentMessage_WithoutRemovingIt() + { + var client = NewClient(UniqueName("ns")); + var queue = UniqueName("queue"); + await client.PostAsync($"{queue}/messages", new StringContent("peek-me")); + + var response = await client.GetAsync($"{queue}/messages"); + var body = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(body); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var messages = doc.RootElement.EnumerateArray().ToList(); + Assert.Single(messages); + Assert.Equal("peek-me", messages[0].GetProperty("body").GetString()); + + // Peeking must not consume the message — it should still be there. + var secondPeek = await client.GetAsync($"{queue}/messages"); + using var doc2 = JsonDocument.Parse(await secondPeek.Content.ReadAsStringAsync()); + Assert.Single(doc2.RootElement.EnumerateArray()); + } + + [Fact] + public async Task ReceiveMessage_WithPeekLock_ThenComplete_RemovesMessage() + { + var client = NewClient(UniqueName("ns")); + var queue = UniqueName("queue"); + await client.PostAsync($"{queue}/messages", new StringContent("lock-me")); + + var received = await client.PostAsync($"{queue}/messages/head", content: null); + Assert.Equal(HttpStatusCode.OK, received.StatusCode); + Assert.Equal("lock-me", await received.Content.ReadAsStringAsync()); + var lockToken = GetLockToken(received); + + var complete = await client.DeleteAsync($"{queue}/messages/{lockToken}"); + Assert.Equal(HttpStatusCode.OK, complete.StatusCode); + + var peekAfter = await client.GetAsync($"{queue}/messages"); + using var doc = JsonDocument.Parse(await peekAfter.Content.ReadAsStringAsync()); + Assert.Empty(doc.RootElement.EnumerateArray()); + } + + [Fact] + public async Task ReceiveMessage_OnEmptyQueue_Returns204() + { + var client = NewClient(UniqueName("ns")); + var queue = UniqueName("queue"); + + var response = await client.PostAsync($"{queue}/messages/head", content: null); + + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + } + + [Fact] + public async Task ReceiveAndDelete_RemovesMessageImmediately() + { + var client = NewClient(UniqueName("ns")); + var queue = UniqueName("queue"); + await client.PostAsync($"{queue}/messages", new StringContent("destructive-read")); + + var request = new HttpRequestMessage(HttpMethod.Delete, $"{queue}/messages/head"); + var response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("destructive-read", await response.Content.ReadAsStringAsync()); + + var peekAfter = await client.GetAsync($"{queue}/messages"); + using var doc = JsonDocument.Parse(await peekAfter.Content.ReadAsStringAsync()); + Assert.Empty(doc.RootElement.EnumerateArray()); + } + + [Fact] + public async Task DeadLetterMessage_RemovesItFromActiveQueue() + { + var client = NewClient(UniqueName("ns")); + var queue = UniqueName("queue"); + await client.PostAsync($"{queue}/messages", new StringContent("bad-message")); + var received = await client.PostAsync($"{queue}/messages/head", content: null); + var lockToken = GetLockToken(received); + + var deadLetterBody = new StringContent("{\"deadLetterReason\":\"poison\"}", Encoding.UTF8, "application/json"); + var response = await client.PostAsync($"{queue}/messages/{lockToken}/deadletterqueue", deadLetterBody); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var peekAfter = await client.GetAsync($"{queue}/messages"); + using var doc = JsonDocument.Parse(await peekAfter.Content.ReadAsStringAsync()); + Assert.Empty(doc.RootElement.EnumerateArray()); + } + + [Fact] + public async Task CompleteMessage_WithUnknownLockToken_Returns404() + { + var client = NewClient(UniqueName("ns")); + var queue = UniqueName("queue"); + + var response = await client.DeleteAsync($"{queue}/messages/{Guid.NewGuid()}"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task ReceiveMessage_TwiceOnSingleMessageQueue_SecondCallSeesQueueAsEmpty() + { + // Once a message is peek-locked, it must be invisible to other receivers until it's + // completed, dead-lettered, or the lock expires — otherwise two consumers could both + // process the same message concurrently. + var client = NewClient(UniqueName("ns")); + var queue = UniqueName("queue"); + await client.PostAsync($"{queue}/messages", new StringContent("only-message")); + + var firstReceive = await client.PostAsync($"{queue}/messages/head", content: null); + var secondReceive = await client.PostAsync($"{queue}/messages/head", content: null); + + Assert.Equal(HttpStatusCode.OK, firstReceive.StatusCode); + Assert.Equal(HttpStatusCode.NoContent, secondReceive.StatusCode); + } + + [Fact] + public async Task PeekMessages_MultipleMessages_ReturnsThemInFifoOrder() + { + var client = NewClient(UniqueName("ns")); + var queue = UniqueName("queue"); + await client.PostAsync($"{queue}/messages", new StringContent("first")); + await client.PostAsync($"{queue}/messages", new StringContent("second")); + await client.PostAsync($"{queue}/messages", new StringContent("third")); + + var response = await client.GetAsync($"{queue}/messages"); + using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var bodies = doc.RootElement.EnumerateArray().Select(m => m.GetProperty("body").GetString()).ToList(); + + Assert.Equal(["first", "second", "third"], bodies); + } + + [Fact] + public async Task CompleteMessage_Twice_SecondCallReturns404() + { + var client = NewClient(UniqueName("ns")); + var queue = UniqueName("queue"); + await client.PostAsync($"{queue}/messages", new StringContent("complete-once")); + var received = await client.PostAsync($"{queue}/messages/head", content: null); + var lockToken = GetLockToken(received); + + var first = await client.DeleteAsync($"{queue}/messages/{lockToken}"); + var second = await client.DeleteAsync($"{queue}/messages/{lockToken}"); + + Assert.Equal(HttpStatusCode.OK, first.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, second.StatusCode); + } + + [Fact] + public async Task DeadLetterMessage_WithUnknownLockToken_Returns404() + { + var client = NewClient(UniqueName("ns")); + var queue = UniqueName("queue"); + + var response = await client.PostAsync($"{queue}/messages/{Guid.NewGuid()}/deadletterqueue", content: null); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task Queues_AreIsolatedAcrossNamespaces() + { + var queueName = UniqueName("shared-queue-name"); + var clientA = NewClient(UniqueName("nsA")); + var clientB = NewClient(UniqueName("nsB")); + await clientA.PostAsync($"{queueName}/messages", new StringContent("only-in-a")); + + var peekB = await clientB.GetAsync($"{queueName}/messages"); + using var doc = JsonDocument.Parse(await peekB.Content.ReadAsStringAsync()); + + Assert.Empty(doc.RootElement.EnumerateArray()); + } + + [Fact] + public async Task ReceiveMessage_IncrementsDeliveryCount() + { + var client = NewClient(UniqueName("ns")); + var queue = UniqueName("queue"); + await client.PostAsync($"{queue}/messages", new StringContent("redelivered")); + var received = await client.PostAsync($"{queue}/messages/head", content: null); + var brokerProps = JsonDocument.Parse(received.Headers.GetValues("BrokerProperties").First()); + + Assert.Equal(1, brokerProps.RootElement.GetProperty("DeliveryCount").GetInt32()); + } + + [Fact] + public async Task SendMessage_WithApplicationPropertyHeader_IsCarriedOnPeek() + { + var client = NewClient(UniqueName("ns")); + var queue = UniqueName("queue"); + var request = new HttpRequestMessage(HttpMethod.Post, $"{queue}/messages") + { + Content = new StringContent("with-props") + }; + request.Headers.Add("x-ms-prop-priority", "high"); + + await client.SendAsync(request); + + var peek = await client.GetAsync($"{queue}/messages"); + using var doc = JsonDocument.Parse(await peek.Content.ReadAsStringAsync()); + var props = doc.RootElement.EnumerateArray().First().GetProperty("applicationProperties"); + Assert.Equal("high", props.GetProperty("priority").GetString()); + } } diff --git a/tests/AzLocal.UnitTests/InMemoryStateStoreTests.cs b/tests/AzLocal.UnitTests/InMemoryStateStoreTests.cs new file mode 100644 index 0000000..b33147d --- /dev/null +++ b/tests/AzLocal.UnitTests/InMemoryStateStoreTests.cs @@ -0,0 +1,134 @@ +using AzLocal.State; + +namespace AzLocal.UnitTests; + +public class InMemoryStateStoreTests +{ + private record TestValue(string Name, int Count); + + [Fact] + + public async Task GetAsync_ReturnsNull_WhenKeyDoesNotExist() + { + var store = new InMemoryStateStore(); + + var result = await store.GetAsync + ("missing/key"); + + Assert.Null(result); + } + + + [Fact] + public async Task SetAsync_ThenGetAsync_RoundTripsValue() + { + var store = new InMemoryStateStore(); + var value = new TestValue("widget", 3); + + await store.SetAsync("blob/items/acct/container/widget", value); + var result = await store.GetAsync("blob/items/acct/container/widget"); + + Assert.Equal(value, result); + } + + [Fact] + public async Task SetAsync_OverwritesExistingValue() + { + var store = new InMemoryStateStore(); + await store.SetAsync("key", new TestValue("first", 1)); + + await store.SetAsync("key", new TestValue("second", 2)); + var result = await store.GetAsync("key"); + + Assert.Equal(new TestValue("second", 2), result); + } + + [Fact] + public async Task DeleteAsync_RemovesExistingKey() + { + var store = new InMemoryStateStore(); + await store.SetAsync("key", new TestValue("gone", 0)); + + await store.DeleteAsync("key"); + + Assert.Null(await store.GetAsync("key")); + } + + [Fact] + public async Task DeleteAsync_DoesNotThrow_WhenKeyDoesNotExist() + { + var store = new InMemoryStateStore(); + + var exception = await Record.ExceptionAsync(() => store.DeleteAsync("missing/key")); + + Assert.Null(exception); + } + + [Fact] + public async Task ExistsAsync_ReturnsFalse_WhenKeyDoesNotExist() + { + var store = new InMemoryStateStore(); + + Assert.False(await store.ExistsAsync("missing/key")); + } + + [Fact] + public async Task ExistsAsync_ReturnsTrue_AfterSet() + { + var store = new InMemoryStateStore(); + await store.SetAsync("key", new TestValue("present", 1)); + + Assert.True(await store.ExistsAsync("key")); + } + + [Fact] + public async Task ListAsync_ReturnsEmpty_WhenNoKeysMatchPrefix() + { + var store = new InMemoryStateStore(); + await store.SetAsync("blob/containers/acct/other", new TestValue("other", 1)); + + var result = await store.ListAsync("blob/containers/acct/mine/"); + + Assert.Empty(result); + } + + [Fact] + public async Task ListAsync_ReturnsOnlyValuesMatchingPrefix() + { + var store = new InMemoryStateStore(); + await store.SetAsync("blob/containers/acct/c1", new TestValue("c1", 1)); + await store.SetAsync("blob/containers/acct/c2", new TestValue("c2", 2)); + await store.SetAsync("keyvault/secrets/acct/s1", new TestValue("s1", 3)); + + var result = await store.ListAsync("blob/containers/acct/"); + + Assert.Equal(2, result.Count); + Assert.Contains(result, v => v.Name == "c1"); + Assert.Contains(result, v => v.Name == "c2"); + } + + [Theory] + [InlineData("Blob/Items/Acct/Widget", "blob/items/acct/widget")] + [InlineData("blob/items/acct/widget", "BLOB/ITEMS/ACCT/WIDGET")] + public async Task Keys_AreCaseInsensitive(string setKey, string getKey) + { + var store = new InMemoryStateStore(); + var value = new TestValue("widget", 1); + + await store.SetAsync(setKey, value); + var result = await store.GetAsync(getKey); + + Assert.Equal(value, result); + } + + [Fact] + public async Task ListAsync_PrefixMatch_IsCaseInsensitive() + { + var store = new InMemoryStateStore(); + await store.SetAsync("Blob/Containers/Acct/Mine", new TestValue("mine", 1)); + + var result = await store.ListAsync("blob/containers/acct/"); + + Assert.Single(result); + } +} diff --git a/tests/AzLocal.UnitTests/JsonSnapshotStateStoreTests.cs b/tests/AzLocal.UnitTests/JsonSnapshotStateStoreTests.cs new file mode 100644 index 0000000..ade489d --- /dev/null +++ b/tests/AzLocal.UnitTests/JsonSnapshotStateStoreTests.cs @@ -0,0 +1,142 @@ +using AzLocal.State; +using AzLocal.UnitTests.TestHelpers; + +namespace AzLocal.UnitTests; + +public class JsonSnapshotStateStoreTests : IDisposable +{ + private record TestValue(string Name, int Count); + + private readonly string _dir; + + public JsonSnapshotStateStoreTests() + { + _dir = Path.Combine(Path.GetTempPath(), "azlocal-tests", Guid.NewGuid().ToString()); + } + + public void Dispose() + { + if (Directory.Exists(_dir)) + Directory.Delete(_dir, recursive: true); + } + + private JsonSnapshotStateStore NewStore() => + new(new InMemoryConfig("AzLocal:SnapshotPath", _dir)); + + [Fact] + public async Task GetAsync_ReturnsNull_WhenKeyDoesNotExist() + { + var store = NewStore(); + + Assert.Null(await store.GetAsync("missing/key")); + } + + [Fact] + public async Task SetAsync_ThenGetAsync_RoundTripsValue() + { + var store = NewStore(); + var value = new TestValue("widget", 3); + + await store.SetAsync("blob/items/acct/container/widget", value); + + Assert.Equal(value, await store.GetAsync("blob/items/acct/container/widget")); + } + + [Fact] + public async Task SetAsync_OverwritesExistingValue() + { + var store = NewStore(); + await store.SetAsync("key", new TestValue("first", 1)); + + await store.SetAsync("key", new TestValue("second", 2)); + + Assert.Equal(new TestValue("second", 2), await store.GetAsync("key")); + } + + [Fact] + public async Task DeleteAsync_RemovesExistingKey() + { + var store = NewStore(); + await store.SetAsync("key", new TestValue("gone", 0)); + + await store.DeleteAsync("key"); + + Assert.Null(await store.GetAsync("key")); + } + + [Fact] + public async Task DeleteAsync_DoesNotThrow_WhenKeyDoesNotExist() + { + var store = NewStore(); + + var exception = await Record.ExceptionAsync(() => store.DeleteAsync("missing/key")); + + Assert.Null(exception); + } + + [Fact] + public async Task ExistsAsync_ReturnsTrue_AfterSet() + { + var store = NewStore(); + await store.SetAsync("key", new TestValue("present", 1)); + + Assert.True(await store.ExistsAsync("key")); + } + + [Fact] + public async Task ListAsync_ReturnsOnlyValuesMatchingPrefix() + { + var store = NewStore(); + await store.SetAsync("blob/containers/acct/c1", new TestValue("c1", 1)); + await store.SetAsync("blob/containers/acct/c2", new TestValue("c2", 2)); + await store.SetAsync("keyvault/secrets/acct/s1", new TestValue("s1", 3)); + + var result = await store.ListAsync("blob/containers/acct/"); + + Assert.Equal(2, result.Count); + Assert.Contains(result, v => v.Name == "c1"); + Assert.Contains(result, v => v.Name == "c2"); + } + + [Theory] + [InlineData("Blob/Items/Acct/Widget", "blob/items/acct/widget")] + [InlineData("blob/items/acct/widget", "BLOB/ITEMS/ACCT/WIDGET")] + public async Task Keys_AreCaseInsensitive(string setKey, string getKey) + { + var store = NewStore(); + var value = new TestValue("widget", 1); + + await store.SetAsync(setKey, value); + + Assert.Equal(value, await store.GetAsync(getKey)); + } + + [Fact] + public async Task SetAsync_WritesSnapshotFileToDisk() + { + var store = NewStore(); + + await store.SetAsync("key", new TestValue("on-disk", 1)); + + Assert.True(File.Exists(Path.Combine(_dir, "state.json"))); + } + + [Fact] + public async Task Data_PersistsAcrossStoreInstances_ByReloadingSnapshotFile() + { + var first = NewStore(); + await first.SetAsync("key", new TestValue("persisted", 42)); + + var second = NewStore(); + + Assert.Equal(new TestValue("persisted", 42), await second.GetAsync("key")); + } + + [Fact] + public async Task NewStore_WithNoExistingSnapshotFile_StartsEmpty() + { + var store = NewStore(); + + Assert.False(await store.ExistsAsync("anything")); + } +} diff --git a/tests/AzLocal.UnitTests/ServiceRouterTests.cs b/tests/AzLocal.UnitTests/ServiceRouterTests.cs new file mode 100644 index 0000000..f6d113b --- /dev/null +++ b/tests/AzLocal.UnitTests/ServiceRouterTests.cs @@ -0,0 +1,108 @@ +using AzLocal.Core.Routing; +using Microsoft.AspNetCore.Http; + +namespace AzLocal.UnitTests; + +public class ServiceRouterTests +{ + private static HttpRequest NewRequest(string host, string path, string? queryString = null) + { + var context = new DefaultHttpContext(); + context.Request.Host = new HostString(host); + context.Request.Path = new PathString(path); + if (queryString is not null) + context.Request.QueryString = new QueryString(queryString); + return context.Request; + } + + [Theory] + [InlineData("myaccount.blob.core.windows.net", "blob")] + [InlineData("myvault.vault.azure.net", "keyvault")] + [InlineData("myns.servicebus.windows.net", "servicebus")] + [InlineData("management.azure.com", "arm")] + [InlineData("localhost", "arm")] + public void BuildContext_SetsServiceType_FromHost(string host, string expectedServiceType) + { + var ctx = ServiceRouter.BuildContext(NewRequest(host, "/")); + + Assert.Equal(expectedServiceType, ctx.ServiceType); + } + + [Theory] + [InlineData("myaccount.blob.core.windows.net", "myaccount")] + [InlineData("myvault.vault.azure.net", "myvault")] + [InlineData("myns.servicebus.windows.net", "myns")] + public void BuildContext_SetsAccountName_FromFirstHostLabel(string host, string expectedAccount) + { + var ctx = ServiceRouter.BuildContext(NewRequest(host, "/")); + + Assert.Equal(expectedAccount, ctx.AccountName); + } + + [Fact] + public void BuildContext_ForNonStorageHost_LeavesAccountNameEmpty() + { + var ctx = ServiceRouter.BuildContext(NewRequest("management.azure.com", "/")); + + Assert.Equal(string.Empty, ctx.AccountName); + } + + [Fact] + public void BuildContext_ExtractsSubscriptionId_FromPath() + { + var ctx = ServiceRouter.BuildContext(NewRequest("localhost", "/subscriptions/sub-123/resourceGroups/my-rg")); + + Assert.Equal("sub-123", ctx.SubscriptionId); + } + + [Fact] + public void BuildContext_ExtractsResourceGroup_FromPath() + { + var ctx = ServiceRouter.BuildContext(NewRequest("localhost", "/subscriptions/sub-123/resourceGroups/my-rg")); + + Assert.Equal("my-rg", ctx.ResourceGroup); + } + + [Fact] + public void BuildContext_WhenPathHasNeitherSegment_LeavesBothEmpty() + { + var ctx = ServiceRouter.BuildContext(NewRequest("localhost", "/kv/myvault/secrets/mysecret")); + + Assert.Equal(string.Empty, ctx.SubscriptionId); + Assert.Equal(string.Empty, ctx.ResourceGroup); + } + + [Fact] + public void BuildContext_IsCaseSensitive_ForResourceGroupsSegment() + { + // Real ARM paths always use "resourceGroups" (capital G) — lowercase should not match. + var ctx = ServiceRouter.BuildContext(NewRequest("localhost", "/subscriptions/sub-123/resourcegroups/my-rg")); + + Assert.Equal("sub-123", ctx.SubscriptionId); + Assert.Equal(string.Empty, ctx.ResourceGroup); + } + + [Fact] + public void BuildContext_ExtractsApiVersion_FromQueryString() + { + var ctx = ServiceRouter.BuildContext(NewRequest("localhost", "/", "?api-version=2023-01-01")); + + Assert.Equal("2023-01-01", ctx.ApiVersion); + } + + [Fact] + public void BuildContext_WhenApiVersionMissing_LeavesItEmpty() + { + var ctx = ServiceRouter.BuildContext(NewRequest("localhost", "/", "?other=value")); + + Assert.Equal(string.Empty, ctx.ApiVersion); + } + + [Fact] + public void BuildContext_WhenQueryStringAbsent_ApiVersionIsEmpty() + { + var ctx = ServiceRouter.BuildContext(NewRequest("localhost", "/")); + + Assert.Equal(string.Empty, ctx.ApiVersion); + } +} diff --git a/tests/AzLocal.UnitTests/SqliteStateStoreTests.cs b/tests/AzLocal.UnitTests/SqliteStateStoreTests.cs new file mode 100644 index 0000000..7d1e419 --- /dev/null +++ b/tests/AzLocal.UnitTests/SqliteStateStoreTests.cs @@ -0,0 +1,139 @@ +using AzLocal.State; +using AzLocal.UnitTests.TestHelpers; +using Microsoft.Data.Sqlite; + +namespace AzLocal.UnitTests; + +public class SqliteStateStoreTests : IDisposable +{ + private record TestValue(string Name, int Count); + + private readonly string _dir; + + public SqliteStateStoreTests() + { + _dir = Path.Combine(Path.GetTempPath(), "azlocal-tests", Guid.NewGuid().ToString()); + } + + public void Dispose() + { + // Microsoft.Data.Sqlite pools native connections, which keeps the db file + // handle open on Windows even after `using var conn` disposes — clear the + // pool first so the directory can actually be deleted. + SqliteConnection.ClearAllPools(); + if (Directory.Exists(_dir)) + Directory.Delete(_dir, recursive: true); + } + + private SqliteStateStore NewStore() => + new(new InMemoryConfig("AzLocal:SqlitePath", _dir)); + + [Fact] + public async Task GetAsync_ReturnsNull_WhenKeyDoesNotExist() + { + var store = NewStore(); + + Assert.Null(await store.GetAsync("missing/key")); + } + + [Fact] + public async Task SetAsync_ThenGetAsync_RoundTripsValue() + { + var store = NewStore(); + var value = new TestValue("widget", 3); + + await store.SetAsync("blob/items/acct/container/widget", value); + var result = await store.GetAsync("blob/items/acct/container/widget"); + + Assert.Equal(value, result); + } + + [Fact] + public async Task SetAsync_OverwritesExistingValue() + { + var store = NewStore(); + await store.SetAsync("key", new TestValue("first", 1)); + + await store.SetAsync("key", new TestValue("second", 2)); + + Assert.Equal(new TestValue("second", 2), await store.GetAsync("key")); + } + + [Fact] + public async Task DeleteAsync_RemovesExistingKey() + { + var store = NewStore(); + await store.SetAsync("key", new TestValue("gone", 0)); + + + await store.DeleteAsync("key"); + + Assert.Null(await store.GetAsync("key")); + } + + [Fact] + public async Task DeleteAsync_DoesNotThrow_WhenKeyDoesNotExist() + { + var store = NewStore(); + + var exception = await Record.ExceptionAsync(() => store.DeleteAsync("missing/key")); + + Assert.Null(exception); + } + + [Fact] + public async Task ExistsAsync_ReturnsFalse_WhenKeyDoesNotExist() + { + var store = NewStore(); + + Assert.False(await store.ExistsAsync("missing/key")); + } + + [Fact] + public async Task ExistsAsync_ReturnsTrue_AfterSet() + { + var store = NewStore(); + await store.SetAsync("key", new TestValue("present", 1)); + + Assert.True(await store.ExistsAsync("key")); + } + + [Fact] + public async Task ListAsync_ReturnsOnlyValuesMatchingPrefix() + { + var store = NewStore(); + await store.SetAsync("blob/containers/acct/c1", new TestValue("c1", 1)); + await store.SetAsync("blob/containers/acct/c2", new TestValue("c2", 2)); + await store.SetAsync("keyvault/secrets/acct/s1", new TestValue("s1", 3)); + + var result = await store.ListAsync("blob/containers/acct/"); + + Assert.Equal(2, result.Count); + Assert.Contains(result, v => v.Name == "c1"); + Assert.Contains(result, v => v.Name == "c2"); + } + + [Theory] + [InlineData("Blob/Items/Acct/Widget", "blob/items/acct/widget")] + [InlineData("blob/items/acct/widget", "BLOB/ITEMS/ACCT/WIDGET")] + public async Task Keys_AreCaseInsensitive(string setKey, string getKey) + { + var store = NewStore(); + var value = new TestValue("widget", 1); + + await store.SetAsync(setKey, value); + + Assert.Equal(value, await store.GetAsync(getKey)); + } + + [Fact] + public async Task Data_PersistsAcrossStoreInstances_PointingAtSameDatabase() + { + var first = NewStore(); + await first.SetAsync("key", new TestValue("persisted", 42)); + + var second = NewStore(); + + Assert.Equal(new TestValue("persisted", 42), await second.GetAsync("key")); + } +} diff --git a/tests/AzLocal.UnitTests/TempFileBlobStoreTests.cs b/tests/AzLocal.UnitTests/TempFileBlobStoreTests.cs new file mode 100644 index 0000000..5af408a --- /dev/null +++ b/tests/AzLocal.UnitTests/TempFileBlobStoreTests.cs @@ -0,0 +1,144 @@ +using AzLocal.State; +using AzLocal.UnitTests.TestHelpers; +using System.Text; + +namespace AzLocal.UnitTests; + +public class TempFileBlobStoreTests : IDisposable +{ + private readonly string _dir; + + public TempFileBlobStoreTests() + { + _dir = Path.Combine(Path.GetTempPath(), "azlocal-tests", Guid.NewGuid().ToString()); + } + + public void Dispose() + { + if (Directory.Exists(_dir)) + Directory.Delete(_dir, recursive: true); + } + + private TempFileBlobStore NewStore() => + new(new InMemoryConfig("AzLocal:BlobStorePath", _dir)); + + private static Stream ToStream(string content) => new MemoryStream(Encoding.UTF8.GetBytes(content)); + + [Fact] + public async Task WriteAsync_ThenReadAsync_RoundTripsContent() + { + var store = NewStore(); + + await store.WriteAsync("mycontainer", "file.txt", ToStream("hello world"), "text/plain"); + + await using var stream = await store.ReadAsync("mycontainer", "file.txt"); + using var reader = new StreamReader(stream); + Assert.Equal("hello world", await reader.ReadToEndAsync()); + } + + [Fact] + public async Task WriteAsync_OverwritesExistingBlob() + { + var store = NewStore(); + await store.WriteAsync("mycontainer", "file.txt", ToStream("first"), "text/plain"); + + await store.WriteAsync("mycontainer", "file.txt", ToStream("second"), "text/plain"); + + await using var stream = await store.ReadAsync("mycontainer", "file.txt"); + using var reader = new StreamReader(stream); + Assert.Equal("second", await reader.ReadToEndAsync()); + } + + [Fact] + public async Task WriteAsync_CreatesIntermediateDirectories_ForNestedBlobNames() + { + var store = NewStore(); + + await store.WriteAsync("mycontainer", "folder/subfolder/file.txt", ToStream("nested"), "text/plain"); + + await using var stream = await store.ReadAsync("mycontainer", "folder/subfolder/file.txt"); + using var reader = new StreamReader(stream); + Assert.Equal("nested", await reader.ReadToEndAsync()); + } + + [Fact] + public async Task ReadAsync_Throws_WhenBlobDoesNotExist() + { + var store = NewStore(); + + await Assert.ThrowsAsync(() => store.ReadAsync("mycontainer", "missing.txt")); + } + + [Fact] + public async Task ExistsAsync_ReturnsFalse_WhenBlobDoesNotExist() + { + var store = NewStore(); + + Assert.False(await store.ExistsAsync("mycontainer", "missing.txt")); + } + + [Fact] + public async Task ExistsAsync_ReturnsTrue_AfterWrite() + { + var store = NewStore(); + await store.WriteAsync("mycontainer", "file.txt", ToStream("data"), "text/plain"); + + Assert.True(await store.ExistsAsync("mycontainer", "file.txt")); + } + + [Fact] + public async Task DeleteAsync_RemovesBlob() + { + var store = NewStore(); + await store.WriteAsync("mycontainer", "file.txt", ToStream("data"), "text/plain"); + + await store.DeleteAsync("mycontainer", "file.txt"); + + Assert.False(await store.ExistsAsync("mycontainer", "file.txt")); + } + + [Fact] + public async Task DeleteAsync_DoesNotThrow_WhenBlobDoesNotExist() + { + var store = NewStore(); + + var exception = await Record.ExceptionAsync(() => store.DeleteAsync("mycontainer", "missing.txt")); + + Assert.Null(exception); + } + + [Fact] + public async Task DeleteContainerAsync_RemovesAllBlobsInContainer() + { + var store = NewStore(); + await store.WriteAsync("mycontainer", "a.txt", ToStream("a"), "text/plain"); + await store.WriteAsync("mycontainer", "folder/b.txt", ToStream("b"), "text/plain"); + + await store.DeleteContainerAsync("mycontainer"); + + Assert.False(await store.ExistsAsync("mycontainer", "a.txt")); + Assert.False(await store.ExistsAsync("mycontainer", "folder/b.txt")); + } + + [Fact] + public async Task DeleteContainerAsync_DoesNotAffectOtherContainers() + { + var store = NewStore(); + await store.WriteAsync("keep", "a.txt", ToStream("a"), "text/plain"); + await store.WriteAsync("remove", "b.txt", ToStream("b"), "text/plain"); + + await store.DeleteContainerAsync("remove"); + + Assert.True(await store.ExistsAsync("keep", "a.txt")); + } + + [Fact] + public async Task DeleteContainerAsync_DoesNotThrow_WhenContainerDoesNotExist() + { + var store = NewStore(); + + var exception = await Record.ExceptionAsync(() => store.DeleteContainerAsync("missing-container")); + + Assert.Null(exception); + } +} diff --git a/tests/AzLocal.UnitTests/TestHelpers/InMemoryConfig.cs b/tests/AzLocal.UnitTests/TestHelpers/InMemoryConfig.cs new file mode 100644 index 0000000..496c5a2 --- /dev/null +++ b/tests/AzLocal.UnitTests/TestHelpers/InMemoryConfig.cs @@ -0,0 +1,26 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Primitives; + +namespace AzLocal.UnitTests.TestHelpers; + +/// +/// Minimal stub for tests. The state store constructors +/// only ever read a single key via the string indexer, so that's all this implements. +/// +internal sealed class InMemoryConfig : IConfiguration +{ + private readonly Dictionary _values; + + public InMemoryConfig(string key, string? value) => + _values = new Dictionary { [key] = value }; + + public string? this[string key] + { + get => _values.TryGetValue(key, out var value) ? value : null; + set => _values[key] = value; + } + + public IEnumerable GetChildren() => throw new NotSupportedException(); + public IChangeToken GetReloadToken() => throw new NotSupportedException(); + public IConfigurationSection GetSection(string key) => throw new NotSupportedException(); +} From 172b44d3ed1ac7e3edfc99e895e1cced073cb46e Mon Sep 17 00:00:00 2001 From: Isaiah Clifford Opoku Date: Sun, 19 Jul 2026 16:48:01 +0000 Subject: [PATCH 2/7] Add unit tests for middleware and enhance InMemoryConfig Added unit tests for AuthStub, Imds, KeyVaultChallenge, RequestContext, and RequestLogging middleware. Improved InMemoryConfig with additional constructors for flexible test configuration. --- .../AuthStubMiddlewareTests.cs | 66 ++++++++++++++++ .../AzLocal.UnitTests/ImdsMiddlewareTests.cs | 77 +++++++++++++++++++ .../KeyVaultChallengeMiddlewareTests.cs | 70 +++++++++++++++++ .../RequestContextMiddlewareTests.cs | 38 +++++++++ .../RequestLoggingMiddlewareTests.cs | 31 ++++++++ .../TestHelpers/InMemoryConfig.cs | 8 +- 6 files changed, 288 insertions(+), 2 deletions(-) create mode 100644 tests/AzLocal.UnitTests/AuthStubMiddlewareTests.cs create mode 100644 tests/AzLocal.UnitTests/ImdsMiddlewareTests.cs create mode 100644 tests/AzLocal.UnitTests/KeyVaultChallengeMiddlewareTests.cs create mode 100644 tests/AzLocal.UnitTests/RequestContextMiddlewareTests.cs create mode 100644 tests/AzLocal.UnitTests/RequestLoggingMiddlewareTests.cs diff --git a/tests/AzLocal.UnitTests/AuthStubMiddlewareTests.cs b/tests/AzLocal.UnitTests/AuthStubMiddlewareTests.cs new file mode 100644 index 0000000..832ae70 --- /dev/null +++ b/tests/AzLocal.UnitTests/AuthStubMiddlewareTests.cs @@ -0,0 +1,66 @@ +using AzLocal.Core; +using AzLocal.Middleware; +using AzLocal.UnitTests.TestHelpers; +using Microsoft.AspNetCore.Http; + +namespace AzLocal.UnitTests; + +public class AuthStubMiddlewareTests +{ + [Fact] + public async Task InvokeAsync_StampsAuthenticatedUser_AndCallsNext() + { + var called = false; + RequestDelegate next = _ => { called = true; return Task.CompletedTask; }; + var middleware = new AuthStubMiddleware(next, new InMemoryConfig()); + var context = new DefaultHttpContext(); + + await middleware.InvokeAsync(context); + + Assert.True(called); + Assert.True(context.User.Identity!.IsAuthenticated); + } + + [Fact] + public async Task InvokeAsync_UsesConfiguredObjectIdAndTenantId() + { + var config = new InMemoryConfig(new Dictionary + { + ["AzLocal:ObjectId"] = "my-object-id", + ["AzLocal:TenantId"] = "my-tenant-id" + }); + var middleware = new AuthStubMiddleware(_ => Task.CompletedTask, config); + var context = new DefaultHttpContext(); + + await middleware.InvokeAsync(context); + + Assert.Equal("my-object-id", context.User.FindFirst("oid")!.Value); + Assert.Equal("my-tenant-id", context.User.FindFirst("tid")!.Value); + } + + [Fact] + public async Task InvokeAsync_WithoutConfiguredIds_UsesDefaults() + { + var middleware = new AuthStubMiddleware(_ => Task.CompletedTask, new InMemoryConfig()); + var context = new DefaultHttpContext(); + + await middleware.InvokeAsync(context); + + Assert.Equal(EmulatorDefaults.ObjectId, context.User.FindFirst("oid")!.Value); + Assert.Equal(EmulatorDefaults.TenantId, context.User.FindFirst("tid")!.Value); + } + + [Fact] + public async Task InvokeAsync_AuthenticatesEvenWithGarbageAuthorizationHeader() + { + // The whole point of this middleware is to bypass real token validation locally — + // an invalid/garbage bearer token must not cause a rejection. + var middleware = new AuthStubMiddleware(_ => Task.CompletedTask, new InMemoryConfig()); + var context = new DefaultHttpContext(); + context.Request.Headers.Authorization = "Bearer not-a-real-token"; + + await middleware.InvokeAsync(context); + + Assert.True(context.User.Identity!.IsAuthenticated); + } +} diff --git a/tests/AzLocal.UnitTests/ImdsMiddlewareTests.cs b/tests/AzLocal.UnitTests/ImdsMiddlewareTests.cs new file mode 100644 index 0000000..b94aa14 --- /dev/null +++ b/tests/AzLocal.UnitTests/ImdsMiddlewareTests.cs @@ -0,0 +1,77 @@ +using AzLocal.Middleware; +using AzLocal.UnitTests.TestHelpers; +using Microsoft.AspNetCore.Http; +using System.Text.Json; + +namespace AzLocal.UnitTests; + +public class ImdsMiddlewareTests +{ + [Fact] + public async Task InvokeAsync_ForImdsPath_ReturnsTokenJson_WithoutCallingNext() + { + var called = false; + RequestDelegate next = _ => { called = true; return Task.CompletedTask; }; + var middleware = new ImdsMiddleware(next, new InMemoryConfig("AzLocal:FakeToken", "my-fake-token")); + var context = new DefaultHttpContext(); + context.Request.Path = "/metadata/identity/oauth2/token"; + context.Response.Body = new MemoryStream(); + + await middleware.InvokeAsync(context); + + Assert.False(called); + Assert.Equal(200, context.Response.StatusCode); + Assert.Equal("application/json", context.Response.ContentType); + + context.Response.Body.Seek(0, SeekOrigin.Begin); + using var doc = JsonDocument.Parse(context.Response.Body); + Assert.Equal("my-fake-token", doc.RootElement.GetProperty("access_token").GetString()); + Assert.Equal("Bearer", doc.RootElement.GetProperty("token_type").GetString()); + } + + [Fact] + public async Task InvokeAsync_ForImdsPath_WithoutConfiguredToken_UsesDefaultFakeToken() + { + var middleware = new ImdsMiddleware(_ => Task.CompletedTask, new InMemoryConfig()); + var context = new DefaultHttpContext(); + context.Request.Path = "/metadata/identity/oauth2/token"; + context.Response.Body = new MemoryStream(); + + await middleware.InvokeAsync(context); + + context.Response.Body.Seek(0, SeekOrigin.Begin); + using var doc = JsonDocument.Parse(context.Response.Body); + Assert.Equal(AzLocal.Core.EmulatorDefaults.FakeToken, doc.RootElement.GetProperty("access_token").GetString()); + } + + [Fact] + public async Task InvokeAsync_ForImdsPath_MatchesSubPaths() + { + var called = false; + RequestDelegate next = _ => { called = true; return Task.CompletedTask; }; + var middleware = new ImdsMiddleware(next, new InMemoryConfig()); + var context = new DefaultHttpContext(); + context.Request.Path = "/metadata/identity/oauth2/token/extra"; + context.Response.Body = new MemoryStream(); + + await middleware.InvokeAsync(context); + + Assert.False(called); + Assert.Equal(200, context.Response.StatusCode); + } + + [Fact] + public async Task InvokeAsync_ForOtherPaths_CallsNext_WithoutWritingResponse() + { + var called = false; + RequestDelegate next = _ => { called = true; return Task.CompletedTask; }; + var middleware = new ImdsMiddleware(next, new InMemoryConfig()); + var context = new DefaultHttpContext(); + context.Request.Path = "/azu/myaccount/mycontainer"; + + await middleware.InvokeAsync(context); + + Assert.True(called); + Assert.Equal(200, context.Response.StatusCode); // default, untouched + } +} diff --git a/tests/AzLocal.UnitTests/KeyVaultChallengeMiddlewareTests.cs b/tests/AzLocal.UnitTests/KeyVaultChallengeMiddlewareTests.cs new file mode 100644 index 0000000..ed80daf --- /dev/null +++ b/tests/AzLocal.UnitTests/KeyVaultChallengeMiddlewareTests.cs @@ -0,0 +1,70 @@ +using AzLocal.Middleware; +using AzLocal.UnitTests.TestHelpers; +using Microsoft.AspNetCore.Http; + +namespace AzLocal.UnitTests; + +public class KeyVaultChallengeMiddlewareTests +{ + [Fact] + public async Task InvokeAsync_ForKvPath_WithoutAuthorizationHeader_Returns401WithChallenge() + { + var called = false; + RequestDelegate next = _ => { called = true; return Task.CompletedTask; }; + var middleware = new KeyVaultChallengeMiddleware(next, new InMemoryConfig("AzLocal:TenantId", "my-tenant")); + var context = new DefaultHttpContext(); + context.Request.Path = "/kv/myvault/secrets/mysecret"; + + await middleware.InvokeAsync(context); + + Assert.False(called); + Assert.Equal(401, context.Response.StatusCode); + var challenge = context.Response.Headers["WWW-Authenticate"].ToString(); + Assert.Contains("Bearer", challenge); + Assert.Contains("https://login.microsoftonline.com/my-tenant", challenge); + Assert.Contains("resource=\"https://vault.azure.net\"", challenge); + } + + [Fact] + public async Task InvokeAsync_ForKvPath_WithAuthorizationHeader_CallsNext() + { + var called = false; + RequestDelegate next = _ => { called = true; return Task.CompletedTask; }; + var middleware = new KeyVaultChallengeMiddleware(next, new InMemoryConfig()); + var context = new DefaultHttpContext(); + context.Request.Path = "/kv/myvault/secrets/mysecret"; + context.Request.Headers.Authorization = "Bearer some-token"; + + await middleware.InvokeAsync(context); + + Assert.True(called); + Assert.Equal(200, context.Response.StatusCode); // default, untouched + } + + [Fact] + public async Task InvokeAsync_ForNonKvPath_CallsNext_EvenWithoutAuthorizationHeader() + { + var called = false; + RequestDelegate next = _ => { called = true; return Task.CompletedTask; }; + var middleware = new KeyVaultChallengeMiddleware(next, new InMemoryConfig()); + var context = new DefaultHttpContext(); + context.Request.Path = "/myaccount/mycontainer/myblob.txt"; + + await middleware.InvokeAsync(context); + + Assert.True(called); + } + + [Fact] + public async Task InvokeAsync_WithoutConfiguredTenantId_UsesDefault() + { + var middleware = new KeyVaultChallengeMiddleware(_ => Task.CompletedTask, new InMemoryConfig()); + var context = new DefaultHttpContext(); + context.Request.Path = "/kv/myvault/secrets/mysecret"; + + await middleware.InvokeAsync(context); + + var challenge = context.Response.Headers["WWW-Authenticate"].ToString(); + Assert.Contains(AzLocal.Core.EmulatorDefaults.TenantId, challenge); + } +} diff --git a/tests/AzLocal.UnitTests/RequestContextMiddlewareTests.cs b/tests/AzLocal.UnitTests/RequestContextMiddlewareTests.cs new file mode 100644 index 0000000..9c825bc --- /dev/null +++ b/tests/AzLocal.UnitTests/RequestContextMiddlewareTests.cs @@ -0,0 +1,38 @@ +using AzLocal.Core.Routing; +using AzLocal.Middleware; +using Microsoft.AspNetCore.Http; + +namespace AzLocal.UnitTests; + +public class RequestContextMiddlewareTests +{ + [Fact] + public async Task InvokeAsync_StoresRequestContext_InHttpContextItems() + { + var middleware = new RequestContextMiddleware(_ => Task.CompletedTask); + var context = new DefaultHttpContext(); + context.Request.Host = new HostString("myaccount.blob.core.windows.net"); + context.Request.Path = "/subscriptions/sub-1/resourceGroups/my-rg"; + + await middleware.InvokeAsync(context); + + var stored = Assert.IsType(context.Items["RequestContext"]); + Assert.Equal("blob", stored.ServiceType); + Assert.Equal("myaccount", stored.AccountName); + Assert.Equal("sub-1", stored.SubscriptionId); + Assert.Equal("my-rg", stored.ResourceGroup); + } + + [Fact] + public async Task InvokeAsync_CallsNext() + { + var called = false; + RequestDelegate next = _ => { called = true; return Task.CompletedTask; }; + var middleware = new RequestContextMiddleware(next); + var context = new DefaultHttpContext(); + + await middleware.InvokeAsync(context); + + Assert.True(called); + } +} diff --git a/tests/AzLocal.UnitTests/RequestLoggingMiddlewareTests.cs b/tests/AzLocal.UnitTests/RequestLoggingMiddlewareTests.cs new file mode 100644 index 0000000..958c9e4 --- /dev/null +++ b/tests/AzLocal.UnitTests/RequestLoggingMiddlewareTests.cs @@ -0,0 +1,31 @@ +using AzLocal.Middleware; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; + +namespace AzLocal.UnitTests; + +public class RequestLoggingMiddlewareTests +{ + [Fact] + public async Task InvokeAsync_CallsNext() + { + var called = false; + RequestDelegate next = _ => { called = true; return Task.CompletedTask; }; + var middleware = new RequestLoggingMiddleware(next, NullLogger.Instance); + var context = new DefaultHttpContext(); + + await middleware.InvokeAsync(context); + + Assert.True(called); + } + + [Fact] + public async Task InvokeAsync_PropagatesException_FromNext() + { + RequestDelegate next = _ => throw new InvalidOperationException("boom"); + var middleware = new RequestLoggingMiddleware(next, NullLogger.Instance); + var context = new DefaultHttpContext(); + + await Assert.ThrowsAsync(() => middleware.InvokeAsync(context)); + } +} diff --git a/tests/AzLocal.UnitTests/TestHelpers/InMemoryConfig.cs b/tests/AzLocal.UnitTests/TestHelpers/InMemoryConfig.cs index 496c5a2..edc2dce 100644 --- a/tests/AzLocal.UnitTests/TestHelpers/InMemoryConfig.cs +++ b/tests/AzLocal.UnitTests/TestHelpers/InMemoryConfig.cs @@ -4,16 +4,20 @@ namespace AzLocal.UnitTests.TestHelpers; /// -/// Minimal stub for tests. The state store constructors -/// only ever read a single key via the string indexer, so that's all this implements. +/// Minimal stub for tests. Constructors under test only ever +/// read config via the string indexer, so that's all this implements. /// internal sealed class InMemoryConfig : IConfiguration { private readonly Dictionary _values; + public InMemoryConfig() => _values = new Dictionary(); + public InMemoryConfig(string key, string? value) => _values = new Dictionary { [key] = value }; + public InMemoryConfig(Dictionary values) => _values = values; + public string? this[string key] { get => _values.TryGetValue(key, out var value) ? value : null; From 2e11a0fb88994e7b3de540baf1c7b875404abfb8 Mon Sep 17 00:00:00 2001 From: Isaiah Clifford Opoku Date: Sun, 19 Jul 2026 16:57:38 +0000 Subject: [PATCH 3/7] Expand docs, refactor CLI internals, add CLI unit tests README reorganized with clearer usage, SDK compatibility, and CI docs. Added SDK_COMPAT.md for SDK support details. Refactored StartCommand for testability (IsValidPort, BuildHostProcessInfo now internal). Exposed CLI internals to tests via InternalsVisibleTo. Added CLI reference to unit tests and new StartCommand unit tests. --- README.md | 248 +++++++++++++----- docs/SDK_COMPAT.md | 79 ++++++ src/AzLocal.Cli/AssemblyInfo.cs | 5 + src/AzLocal.Cli/Commands/StartCommand.cs | 6 +- .../AzLocal.UnitTests.csproj | 1 + tests/AzLocal.UnitTests/StartCommandTests.cs | 54 ++++ 6 files changed, 332 insertions(+), 61 deletions(-) create mode 100644 src/AzLocal.Cli/AssemblyInfo.cs create mode 100644 tests/AzLocal.UnitTests/StartCommandTests.cs diff --git a/README.md b/README.md index 87b54e0..27899e2 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,8 @@ **Local Azure emulator for .NET — develop and test without a real Azure subscription.** -> **Status: Under active development. Not production-ready.** +> **Status: Under active development. Not production-ready. Not yet published to NuGet — +> use it by cloning this repo (see below).** --- @@ -10,42 +11,156 @@ Every time you write .NET code that talks to Azure — Blob Storage, Key Vault, Service Bus — you need a live Azure environment to run and test it. That means a subscription, credentials, internet access, and real cloud costs just to run your tests. -azlocal removes that dependency entirely. +azlocal removes that dependency entirely: it runs a local server that speaks the same HTTP API as Azure, so your code and tests work as if Azure is there. --- -## What azlocal does +## Get started -azlocal runs a local server on your machine that speaks the same HTTP API as Azure. Your Azure SDK clients connect to it exactly as they would connect to real Azure — no code changes, no special mocking, no SDK forks. +### 1. Clone and build -You start azlocal once, then your code and tests work as if Azure is there. +```bash +git clone +cd azlocal +dotnet build +``` + +### 2. Trust the local HTTPS certificate (one-time) + +The Azure SDKs require HTTPS, so azlocal runs over HTTPS by default using the standard ASP.NET Core dev certificate: + +```bash +dotnet run --project src/AzLocal.Cli -- trust-cert +``` + +### 3. Start the emulator ```bash -# One-time: trust the local HTTPS dev certificate (Azure SDKs require HTTPS) -azlocal trust-cert +dotnet run --project src/AzLocal.Cli -- start -# Start the emulator -azlocal start +# AzLocal started on https://127.0.0.1:4566 (pid 12345) +``` + +This returns immediately — the host runs as a separate background process. Check it's actually up, or stop it, with: -# Emulator is now running at https://127.0.0.1:4566 -# All Azure services respond on that single port +```bash +dotnet run --project src/AzLocal.Cli -- wait # blocks until ready (useful in scripts/CI) +dotnet run --project src/AzLocal.Cli -- status # prints RUNNING/NOT running +dotnet run --project src/AzLocal.Cli -- stop # stops the host +dotnet run --project src/AzLocal.Cli -- reset # stops the host AND wipes all stored state ``` -In your C# code or test project: +### 4. Reference `AzLocal.Client` from your own project + +azlocal isn't on NuGet yet, so add a project reference to the client library instead of a package reference: + +```bash +dotnet add .csproj reference /src/AzLocal.Client/AzLocal.Client.csproj +``` + +### 5. Use it ```csharp -// Instead of connecting to real Azure: -var blobClient = new BlobServiceClient( - new Uri("https://myaccount.blob.core.windows.net"), - new DefaultAzureCredential() -); +using AzLocal.Client; -// Connect to azlocal — everything else stays the same: -var factory = new AzlocalClientFactory(); +var factory = new AzlocalClientFactory(); // defaults to https://127.0.0.1:4566 + +// Instead of connecting to real Azure: +// new BlobServiceClient(new Uri("https://myaccount.blob.core.windows.net"), new DefaultAzureCredential()); +// connect to azlocal — the real Azure SDK client, everything else stays the same: var blobClient = factory.CreateBlobServiceClient("myaccount"); ``` -Upload a blob, read a Key Vault secret, send a Service Bus message — it all works locally with no network, no subscription, and no credentials needed. +Blob Storage and Key Vault work with the **real, unmodified Azure SDK** — no mocking, no forks. Service Bus and ARM are also emulated, but only through `AzlocalClientFactory`'s own HTTP client, not the official SDKs (see [Services](#services) for why). + +--- + +## Usage examples + +Every example below assumes `var factory = new AzlocalClientFactory();` and that azlocal is running (steps 2–3 above). + +### Blob Storage — real `Azure.Storage.Blobs` SDK + +```csharp +var container = factory.CreateBlobContainerClient("myaccount", "uploads"); +await container.CreateIfNotExistsAsync(); + +var blob = container.GetBlobClient("hello.txt"); +await blob.UploadAsync(new BinaryData("hello from azlocal"), overwrite: true); + +var download = await blob.DownloadContentAsync(); +Console.WriteLine(download.Value.Content.ToString()); // "hello from azlocal" +``` + +### Key Vault Secrets — real `Azure.Security.KeyVault.Secrets` SDK + +```csharp +var secrets = factory.CreateSecretClient("myvault"); + +await secrets.SetSecretAsync("my-api-key", "super-secret-value"); +var secret = await secrets.GetSecretAsync("my-api-key"); +Console.WriteLine(secret.Value.Value); // "super-secret-value" +``` + +### Service Bus (Queues) — HTTP client, not the official SDK + +```csharp +var sb = factory.CreateServiceBusHttpClient("mynamespace"); + +await sb.PostAsync("myqueue/messages", new StringContent("hello")); // send +var received = await sb.PostAsync("myqueue/messages/head", null); // peek-lock receive +var body = await received.Content.ReadAsStringAsync(); // "hello" + +// Complete it using the lock token from the BrokerProperties response header: +var lockToken = /* parse "LockToken" out of received.Headers.GetValues("BrokerProperties") */; +await sb.DeleteAsync($"myqueue/messages/{lockToken}"); +``` + +### ARM (Resource Groups & Subscriptions) — HTTP client, not the official SDK + +```csharp +var arm = factory.CreateHttpClient(); + +// List the (single, fake) configured subscription: +var subs = await arm.GetAsync("/arm/subscriptions"); +var subId = /* parse "subscriptionId" out of the JSON "value" array */; + +var body = new StringContent("{\"location\":\"eastus\"}", Encoding.UTF8, "application/json"); +await arm.PutAsync($"/arm/subscriptions/{subId}/resourcegroups/my-rg", body); +``` + +--- + +## Using azlocal in your own tests + +**Option A — separately running process.** Start azlocal (steps 2–3 above, e.g. in a CI step or manually), then use `AzlocalFixture` as an xUnit class fixture: + +```csharp +public class MyTests : IClassFixture +{ + private readonly AzlocalFixture _fixture; + public MyTests(AzlocalFixture fixture) => _fixture = fixture; + + [Fact] + public async Task UploadsABlob() + { + var container = _fixture.Clients.CreateBlobContainerClient("acct", "container"); + // ... + } +} +``` + +**Option B — in-process, no separate process to start/stop.** Reference `AzLocal.Host` directly and boot it with `WebApplicationFactory` (this is exactly what azlocal's own integration tests do — see `tests/AzLocal.IntegrationTests/Fixtures/EmulatorFixture.cs` for the full pattern): + +```csharp +public sealed class EmulatorFixture : WebApplicationFactory +{ + public AzlocalClientFactory Clients { get; } + public EmulatorFixture() => Clients = new AzlocalClientFactory("https://127.0.0.1", Server.CreateHandler()); +} +``` + +This needs a `ProjectReference` to `AzLocal.Host` and the `Microsoft.AspNetCore.Mvc.Testing` package. It's faster (no process startup) and fully isolated per test run. --- @@ -58,36 +173,36 @@ Upload a blob, read a Key Vault secret, send a Service Bus message — it all wo ## Services -| Phase | Service | Status | -|---|---|---| -| 1 | Blob Storage | Planned | -| 1 | Key Vault Secrets | Planned | -| 1 | Resource Groups & Subscriptions | Planned | -| 1 | Managed Identity (IMDS stub) | Planned | -| 2 | Queue Storage | Planned | -| 2 | Table Storage | Planned | -| 2 | Service Bus (Queues) | Planned | -| 2 | App Configuration | Planned | -| 3 | Cosmos DB (SQL API) | Planned | -| 3 | Event Grid | Planned | -| 3 | Azure Functions (HTTP trigger) | Planned | +| Phase | Service | Status | Azure SDK compatible? | +|---|---|---|---| +| 1 | Blob Storage | Implemented | ✅ `Azure.Storage.Blobs` — real SDK, no code changes | +| 1 | Key Vault Secrets | Implemented | ✅ `Azure.Security.KeyVault.Secrets` — real SDK, no code changes | +| 1 | Resource Groups & Subscriptions (ARM) | Implemented | ⚠️ HTTP only — `AzlocalClientFactory.CreateHttpClient()`, not `Azure.ResourceManager` | +| 1 | Managed Identity (IMDS stub) | Implemented | ✅ Any `TokenCredential`-based SDK call acquires a token transparently | +| 2 | Service Bus (Queues) | Implemented | ⚠️ HTTP only — `AzlocalClientFactory.CreateServiceBusHttpClient()`, not `Azure.Messaging.ServiceBus` (that SDK speaks AMQP over TCP, which azlocal doesn't implement) | +| 2 | Queue Storage | Planned | — | +| 2 | Table Storage | Planned | — | +| 2 | App Configuration | Planned | — | +| 3 | Cosmos DB (SQL API) | Planned | — | +| 3 | Event Grid | Planned | — | +| 3 | Azure Functions (HTTP trigger) | Planned | — | + +See [docs/SDK_COMPAT.md](docs/SDK_COMPAT.md) for the details behind each ⚠️, and what it would take to close the gap. --- -## Requirements - -- [.NET 10 SDK](https://dotnet.microsoft.com/download) - ---- +## CLI reference -## Build from source +| Command | What it does | +|---|---| +| `start [--port N]` | Starts the host as a background process (default port 4566) | +| `stop` | Stops the running host | +| `reset` | Stops the host and deletes all stored state (`%TEMP%/azlocal`) | +| `wait [--port N] [--timeout S]` | Blocks until the host responds, or times out | +| `status [--port N]` | Prints whether a host is running on the given port | +| `trust-cert` | Trusts the local HTTPS dev certificate (`dotnet dev-certs https --trust`) | -```bash -git clone -cd azlocal -dotnet build -dotnet test -``` +Run any command via `dotnet run --project src/AzLocal.Cli -- ` until azlocal is published as a packaged tool. --- @@ -95,39 +210,54 @@ dotnet test ```yaml - name: Start azlocal - run: | - dotnet tool install -g azlocal - azlocal start --background - azlocal wait + run: dotnet run --project src/AzLocal.Cli -- start + +- name: Wait for azlocal to be ready + run: dotnet run --project src/AzLocal.Cli -- wait - name: Run tests run: dotnet test - name: Stop azlocal - run: azlocal stop + run: dotnet run --project src/AzLocal.Cli -- stop ``` +Only needed if your tests connect to a *separately-running* azlocal process. If you use Option B from [Using azlocal in your own tests](#using-azlocal-in-your-own-tests) instead, `dotnet test` alone is enough — no `start`/`stop` steps required. + +--- + +## Requirements + +- [.NET 10 SDK](https://dotnet.microsoft.com/download) + --- -## Project structure +## Contributing / working on azlocal itself + +```bash +dotnet build +dotnet test +``` + +`dotnet test` runs both the unit test suite (handler/store/middleware/CLI logic in isolation) and the integration suite, which exercises the real Azure SDKs against an in-process host via `WebApplicationFactory` — no separately-started process needed. ``` src/ - AzLocal.Host/ # Web host — receives all requests on :4566 - AzLocal.Core/ # Shared interfaces and models - AzLocal.Middleware/ # Auth stub, request logging, IMDS stub + AzLocal.Host/ # Web host — receives all requests on :4566 (HTTPS by default) + AzLocal.Core/ # Shared interfaces, models, and request routing + AzLocal.Middleware/ # Auth stub, request logging, IMDS stub, Key Vault auth-challenge responder AzLocal.Services/ # Service handlers (one per Azure service) AzLocal.State/ # State backends — in-memory, SQLite, JSON snapshot - AzLocal.Cli/ # CLI tool (azlocal start / stop / reset / wait) - AzLocal.Client/ # NuGet package — pre-configured SDK client factory + AzLocal.Cli/ # CLI tool (azlocal start / stop / reset / wait / trust-cert) + AzLocal.Client/ # Client library — pre-configured SDK client factory tests/ - AzLocal.UnitTests/ - AzLocal.IntegrationTests/ + AzLocal.UnitTests/ # Handler/store/middleware/CLI logic in isolation, no HTTP + AzLocal.IntegrationTests/ # Real Azure SDKs against an in-process host (WebApplicationFactory) AzLocal.CompatTests/ ``` - +--- ## License diff --git a/docs/SDK_COMPAT.md b/docs/SDK_COMPAT.md index e69de29..0deb8c1 100644 --- a/docs/SDK_COMPAT.md +++ b/docs/SDK_COMPAT.md @@ -0,0 +1,79 @@ +# Azure SDK compatibility + +azlocal's goal is that real Azure SDK code works against it unchanged. That's true today for +Blob Storage and Key Vault Secrets. It is **not** true for Service Bus or ARM — this page +explains why, and what actually works instead. + +## ✅ Blob Storage — `Azure.Storage.Blobs` + +Fully compatible. `AzlocalClientFactory.CreateBlobServiceClient`/`CreateBlobContainerClient`/ +`CreateBlobClient` return real `Azure.Storage.Blobs` SDK clients, and every method that +matters works against them: upload, download, list, delete, properties, `ExistsAsync`, +`CreateIfNotExistsAsync`, etc. + +Two things had to line up for this to work, both already handled by +`AzlocalClientFactory`/`EmulatorDefaults` — you don't need to do anything extra, but they +explain constraints you'll hit if you construct clients yourself instead of using the factory: + +- **The emulator must be HTTPS.** The Storage SDK refuses to send a `TokenCredential`-based + bearer token over plain HTTP. Run `azlocal trust-cert` once so the local dev certificate is + trusted. +- **Blob routes live at the URL root, and the host must be a literal IP.** The SDK's + `BlobUriBuilder` only recognizes "path-style" addressing (`{host}/{account}/{container}/{blob}`, + the same scheme Azurite uses) when the host is an IP address — `127.0.0.1`, not `localhost`. + Any extra path prefix (azlocal used to have `/azu/...`) breaks the SDK's own parsing of + account/container names out of URLs it builds internally (e.g. + `containerClient.GetBlobClient(name)`), even though a hand-rolled HTTP request to the same + URL works fine. That's why `EmulatorDefaults.BaseUrl` is `https://127.0.0.1:4566`. + +## ✅ Key Vault Secrets — `Azure.Security.KeyVault.Secrets` + +Fully compatible. `AzlocalClientFactory.CreateSecretClient` returns a real `SecretClient`. +Set/get/delete/list secrets and version history all work. + +This one needed more emulator-side work than Blob, because the Key Vault SDK's auth flow is +stricter: + +- **Challenge-based auth.** Unlike Storage, the Key Vault SDK never attaches a bearer token + optimistically — it first sends an anonymous, bodyless probe request expecting a `401` with a + `WWW-Authenticate` challenge header, then retries with a token and the real request body. + `KeyVaultChallengeMiddleware` answers that challenge for `/kv/*` requests missing an + `Authorization` header; without it, the SDK's bodyless probe (not your real request) is what + reaches the handler. +- **Challenge resource verification.** The SDK also checks that the challenge's `resource` + domain matches the request host, as an anti-phishing measure — which a local emulator can + never satisfy (it's not `*.vault.azure.net`). `AzlocalClientFactory.CreateSecretClient` sets + `SecretClientOptions.DisableChallengeResourceVerification = true` to allow this. +- **Response `id` shape.** The SDK's `KeyVaultIdentifier.Parse` expects secret `id` URLs shaped + exactly `{host}/secrets/{name}/{version}` — real Azure encodes the vault name in a subdomain, + not a path segment. azlocal's routes are still `/kv/{vault}/secrets/...` (needed so Key Vault, + Blob, Service Bus, and ARM can share one port), but the `id` returned in JSON responses omits + the `/kv/{vault}` prefix so the SDK can parse it. + +## ⚠️ Service Bus (Queues) — HTTP only, not `Azure.Messaging.ServiceBus` + +**Not compatible with the real SDK, and can't be** without a much larger investment: the real +`Azure.Messaging.ServiceBus` SDK speaks **AMQP 1.0 over TCP**, not HTTP. azlocal's Service Bus +emulation is a custom HTTP REST facade (`/sb/{namespace}/{queue}/messages`, peek-lock semantics, +dead-lettering) — a different wire protocol entirely. The real SDK will never even attempt to +connect to it. + +Use `AzlocalClientFactory.CreateServiceBusHttpClient(namespace)` instead — it returns a +pre-authenticated `HttpClient` for calling the emulator's HTTP endpoints directly: + +```csharp +var sb = factory.CreateServiceBusHttpClient("mynamespace"); +await sb.PostAsync("myqueue/messages", new StringContent("hello")); // send +var received = await sb.PostAsync("myqueue/messages/head", content: null); // peek-lock receive +``` + +Making the real SDK work would mean implementing an AMQP 1.0 listener (links, sessions, flow +control) — a substantially different undertaking from the REST work the rest of azlocal does. + +## ⚠️ ARM (Resource Groups & Subscriptions) — HTTP only, not `Azure.ResourceManager` + +ARM's real REST API is plain HTTPS (unlike Service Bus), so `Azure.ResourceManager` could in +principle work against a compatible emulator — but azlocal doesn't attempt that SDK's full +surface today (long-running-operation polling, resource identifier composition, etc.). Use +`AzlocalClientFactory.CreateHttpClient()` and call the ARM REST routes directly +(`/arm/subscriptions`, `/arm/subscriptions/{id}/resourcegroups/{name}`) instead. diff --git a/src/AzLocal.Cli/AssemblyInfo.cs b/src/AzLocal.Cli/AssemblyInfo.cs new file mode 100644 index 0000000..4fbdb0e --- /dev/null +++ b/src/AzLocal.Cli/AssemblyInfo.cs @@ -0,0 +1,5 @@ +using System.Runtime.CompilerServices; + +// Exposes internal, side-effect-free helpers (port validation, process-info building) to unit +// tests without making them part of the CLI's public surface. +[assembly: InternalsVisibleTo("AzLocal.UnitTests")] diff --git a/src/AzLocal.Cli/Commands/StartCommand.cs b/src/AzLocal.Cli/Commands/StartCommand.cs index a1697e7..f268e6e 100644 --- a/src/AzLocal.Cli/Commands/StartCommand.cs +++ b/src/AzLocal.Cli/Commands/StartCommand.cs @@ -21,7 +21,7 @@ public static Command Build() { var port = pr.GetValue(portOption); - if (port is < 1 or > 65535) + if (!IsValidPort(port)) { Console.Error.WriteLine($"Invalid port: {port}. Must be 1-65535."); return; @@ -58,7 +58,9 @@ public static Command Build() return cmd; } - private static ProcessStartInfo BuildHostProcessInfo(int port) + internal static bool IsValidPort(int port) => port is >= 1 and <= 65535; + + internal static ProcessStartInfo BuildHostProcessInfo(int port) { // When published, AzLocal.Host lives next to this binary. // Fall back to 'dotnet run' for local development. diff --git a/tests/AzLocal.UnitTests/AzLocal.UnitTests.csproj b/tests/AzLocal.UnitTests/AzLocal.UnitTests.csproj index ccf4184..d7b4ba4 100644 --- a/tests/AzLocal.UnitTests/AzLocal.UnitTests.csproj +++ b/tests/AzLocal.UnitTests/AzLocal.UnitTests.csproj @@ -23,6 +23,7 @@ + \ No newline at end of file diff --git a/tests/AzLocal.UnitTests/StartCommandTests.cs b/tests/AzLocal.UnitTests/StartCommandTests.cs new file mode 100644 index 0000000..912cb33 --- /dev/null +++ b/tests/AzLocal.UnitTests/StartCommandTests.cs @@ -0,0 +1,54 @@ +using AzLocal.Cli.Commands; + +namespace AzLocal.UnitTests; + +public class StartCommandTests +{ + [Theory] + [InlineData(1)] + [InlineData(4566)] + [InlineData(65535)] + public void IsValidPort_AcceptsInRangeValues(int port) + { + Assert.True(StartCommand.IsValidPort(port)); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(65536)] + [InlineData(int.MaxValue)] + [InlineData(int.MinValue)] + public void IsValidPort_RejectsOutOfRangeValues(int port) + { + Assert.False(StartCommand.IsValidPort(port)); + } + + [Fact] + public void BuildHostProcessInfo_WithNoPublishedHostExe_FallsBackToDotnetRun() + { + // The test binary's own output directory never contains AzLocal.Host.exe (AzLocal.UnitTests + // has no dependency on AzLocal.Host), so this deterministically exercises the dev fallback. + var psi = StartCommand.BuildHostProcessInfo(4566); + + Assert.Equal("dotnet", psi.FileName); + Assert.Equal("run --project src/AzLocal.Host --urls https://127.0.0.1:4566", psi.Arguments); + } + + [Fact] + public void BuildHostProcessInfo_DoesNotUseShellExecute_AndSuppressesWindow() + { + var psi = StartCommand.BuildHostProcessInfo(4566); + + Assert.False(psi.UseShellExecute); + Assert.True(psi.CreateNoWindow); + } + + [Fact] + public void BuildHostProcessInfo_EmbedsRequestedPort_InArguments() + { + var psi = StartCommand.BuildHostProcessInfo(9999); + + Assert.Contains("--urls https://127.0.0.1:9999", psi.Arguments); + } +} From 08ba33be0f6a6a18c8b0af27f59ad8721a89e6ca Mon Sep 17 00:00:00 2001 From: Isaiah Clifford Opoku Date: Sun, 19 Jul 2026 17:07:50 +0000 Subject: [PATCH 4/7] Update docs for tool install, SDK compat, and CI usage README and SDK_COMPAT.md updated to reflect azlocal's availability as a .NET global tool and NuGet package. CLI and CI examples now use the `azlocal` tool directly. Documentation clarifies SDK compatibility for each service, improves formatting, and distinguishes between using and developing azlocal. CI workflow now uses global.json for .NET version selection. --- .github/workflows/ci.yml | 2 +- README.md | 65 +++++++++++++++++++--------------------- docs/SDK_COMPAT.md | 8 ++--- 3 files changed, 35 insertions(+), 40 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 01f9b1c..ed4b9f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: '10.0.x' + global-json-file: global.json - name: Restore run: dotnet restore diff --git a/README.md b/README.md index 27899e2..85a494b 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,7 @@ **Local Azure emulator for .NET — develop and test without a real Azure subscription.** -> **Status: Under active development. Not production-ready. Not yet published to NuGet — -> use it by cloning this repo (see below).** +> **Status: Under active development. Not production-ready.** --- @@ -17,12 +16,11 @@ azlocal removes that dependency entirely: it runs a local server that speaks the ## Get started -### 1. Clone and build +### 1. Install the CLI and the client library ```bash -git clone -cd azlocal -dotnet build +dotnet tool install -g azlocal +dotnet add package AzLocal.Client ``` ### 2. Trust the local HTTPS certificate (one-time) @@ -30,13 +28,13 @@ dotnet build The Azure SDKs require HTTPS, so azlocal runs over HTTPS by default using the standard ASP.NET Core dev certificate: ```bash -dotnet run --project src/AzLocal.Cli -- trust-cert +azlocal trust-cert ``` ### 3. Start the emulator ```bash -dotnet run --project src/AzLocal.Cli -- start +azlocal start # AzLocal started on https://127.0.0.1:4566 (pid 12345) ``` @@ -44,21 +42,13 @@ dotnet run --project src/AzLocal.Cli -- start This returns immediately — the host runs as a separate background process. Check it's actually up, or stop it, with: ```bash -dotnet run --project src/AzLocal.Cli -- wait # blocks until ready (useful in scripts/CI) -dotnet run --project src/AzLocal.Cli -- status # prints RUNNING/NOT running -dotnet run --project src/AzLocal.Cli -- stop # stops the host -dotnet run --project src/AzLocal.Cli -- reset # stops the host AND wipes all stored state +azlocal wait # blocks until ready (useful in scripts/CI) +azlocal status # prints RUNNING/NOT running +azlocal stop # stops the host +azlocal reset # stops the host AND wipes all stored state ``` -### 4. Reference `AzLocal.Client` from your own project - -azlocal isn't on NuGet yet, so add a project reference to the client library instead of a package reference: - -```bash -dotnet add .csproj reference /src/AzLocal.Client/AzLocal.Client.csproj -``` - -### 5. Use it +### 4. Use it ```csharp using AzLocal.Client; @@ -71,7 +61,7 @@ var factory = new AzlocalClientFactory(); // defaults to https://127.0.0.1:4566 var blobClient = factory.CreateBlobServiceClient("myaccount"); ``` -Blob Storage and Key Vault work with the **real, unmodified Azure SDK** — no mocking, no forks. Service Bus and ARM are also emulated, but only through `AzlocalClientFactory`'s own HTTP client, not the official SDKs (see [Services](#services) for why). +Blob Storage and Key Vault work with the real, unmodified Azure SDK — no mocking, no forks. Service Bus and ARM are also emulated, but only through `AzlocalClientFactory`'s own HTTP client, not the official SDKs (see [Services](#services) for why). --- @@ -160,7 +150,7 @@ public sealed class EmulatorFixture : WebApplicationFactory } ``` -This needs a `ProjectReference` to `AzLocal.Host` and the `Microsoft.AspNetCore.Mvc.Testing` package. It's faster (no process startup) and fully isolated per test run. +This needs a package reference to `AzLocal.Host` and the `Microsoft.AspNetCore.Mvc.Testing` package. It's faster (no process startup) and fully isolated per test run. --- @@ -175,11 +165,11 @@ This needs a `ProjectReference` to `AzLocal.Host` and the `Microsoft.AspNetCore. | Phase | Service | Status | Azure SDK compatible? | |---|---|---|---| -| 1 | Blob Storage | Implemented | ✅ `Azure.Storage.Blobs` — real SDK, no code changes | -| 1 | Key Vault Secrets | Implemented | ✅ `Azure.Security.KeyVault.Secrets` — real SDK, no code changes | -| 1 | Resource Groups & Subscriptions (ARM) | Implemented | ⚠️ HTTP only — `AzlocalClientFactory.CreateHttpClient()`, not `Azure.ResourceManager` | -| 1 | Managed Identity (IMDS stub) | Implemented | ✅ Any `TokenCredential`-based SDK call acquires a token transparently | -| 2 | Service Bus (Queues) | Implemented | ⚠️ HTTP only — `AzlocalClientFactory.CreateServiceBusHttpClient()`, not `Azure.Messaging.ServiceBus` (that SDK speaks AMQP over TCP, which azlocal doesn't implement) | +| 1 | Blob Storage | Implemented | Yes — `Azure.Storage.Blobs`, real SDK, no code changes | +| 1 | Key Vault Secrets | Implemented | Yes — `Azure.Security.KeyVault.Secrets`, real SDK, no code changes | +| 1 | Resource Groups & Subscriptions (ARM) | Implemented | No — HTTP only, via `AzlocalClientFactory.CreateHttpClient()`, not `Azure.ResourceManager` | +| 1 | Managed Identity (IMDS stub) | Implemented | Yes — any `TokenCredential`-based SDK call acquires a token transparently | +| 2 | Service Bus (Queues) | Implemented | No — HTTP only, via `AzlocalClientFactory.CreateServiceBusHttpClient()`, not `Azure.Messaging.ServiceBus` (that SDK speaks AMQP over TCP, which azlocal doesn't implement) | | 2 | Queue Storage | Planned | — | | 2 | Table Storage | Planned | — | | 2 | App Configuration | Planned | — | @@ -187,7 +177,7 @@ This needs a `ProjectReference` to `AzLocal.Host` and the `Microsoft.AspNetCore. | 3 | Event Grid | Planned | — | | 3 | Azure Functions (HTTP trigger) | Planned | — | -See [docs/SDK_COMPAT.md](docs/SDK_COMPAT.md) for the details behind each ⚠️, and what it would take to close the gap. +See [docs/SDK_COMPAT.md](docs/SDK_COMPAT.md) for the details behind each "No", and what it would take to close the gap. --- @@ -202,27 +192,28 @@ See [docs/SDK_COMPAT.md](docs/SDK_COMPAT.md) for the details behind each ⚠️, | `status [--port N]` | Prints whether a host is running on the given port | | `trust-cert` | Trusts the local HTTPS dev certificate (`dotnet dev-certs https --trust`) | -Run any command via `dotnet run --project src/AzLocal.Cli -- ` until azlocal is published as a packaged tool. - --- ## Using in CI (GitHub Actions) ```yaml +- name: Install azlocal + run: dotnet tool install -g azlocal + - name: Start azlocal - run: dotnet run --project src/AzLocal.Cli -- start + run: azlocal start - name: Wait for azlocal to be ready - run: dotnet run --project src/AzLocal.Cli -- wait + run: azlocal wait - name: Run tests run: dotnet test - name: Stop azlocal - run: dotnet run --project src/AzLocal.Cli -- stop + run: azlocal stop ``` -Only needed if your tests connect to a *separately-running* azlocal process. If you use Option B from [Using azlocal in your own tests](#using-azlocal-in-your-own-tests) instead, `dotnet test` alone is enough — no `start`/`stop` steps required. +Only needed if your tests connect to a separately-running azlocal process. If you use Option B from [Using azlocal in your own tests](#using-azlocal-in-your-own-tests) instead, `dotnet test` alone is enough — no `start`/`stop` steps required. --- @@ -234,7 +225,11 @@ Only needed if your tests connect to a *separately-running* azlocal process. If ## Contributing / working on azlocal itself +Building azlocal from source (rather than installing it as a package) is only needed if you're working on azlocal itself: + ```bash +git clone +cd azlocal dotnet build dotnet test ``` diff --git a/docs/SDK_COMPAT.md b/docs/SDK_COMPAT.md index 0deb8c1..c786f18 100644 --- a/docs/SDK_COMPAT.md +++ b/docs/SDK_COMPAT.md @@ -4,7 +4,7 @@ azlocal's goal is that real Azure SDK code works against it unchanged. That's tr Blob Storage and Key Vault Secrets. It is **not** true for Service Bus or ARM — this page explains why, and what actually works instead. -## ✅ Blob Storage — `Azure.Storage.Blobs` +## Blob Storage — `Azure.Storage.Blobs` (compatible) Fully compatible. `AzlocalClientFactory.CreateBlobServiceClient`/`CreateBlobContainerClient`/ `CreateBlobClient` return real `Azure.Storage.Blobs` SDK clients, and every method that @@ -26,7 +26,7 @@ explain constraints you'll hit if you construct clients yourself instead of usin `containerClient.GetBlobClient(name)`), even though a hand-rolled HTTP request to the same URL works fine. That's why `EmulatorDefaults.BaseUrl` is `https://127.0.0.1:4566`. -## ✅ Key Vault Secrets — `Azure.Security.KeyVault.Secrets` +## Key Vault Secrets — `Azure.Security.KeyVault.Secrets` (compatible) Fully compatible. `AzlocalClientFactory.CreateSecretClient` returns a real `SecretClient`. Set/get/delete/list secrets and version history all work. @@ -50,7 +50,7 @@ stricter: Blob, Service Bus, and ARM can share one port), but the `id` returned in JSON responses omits the `/kv/{vault}` prefix so the SDK can parse it. -## ⚠️ Service Bus (Queues) — HTTP only, not `Azure.Messaging.ServiceBus` +## Service Bus (Queues) — HTTP only, not `Azure.Messaging.ServiceBus` **Not compatible with the real SDK, and can't be** without a much larger investment: the real `Azure.Messaging.ServiceBus` SDK speaks **AMQP 1.0 over TCP**, not HTTP. azlocal's Service Bus @@ -70,7 +70,7 @@ var received = await sb.PostAsync("myqueue/messages/head", content: null); Making the real SDK work would mean implementing an AMQP 1.0 listener (links, sessions, flow control) — a substantially different undertaking from the REST work the rest of azlocal does. -## ⚠️ ARM (Resource Groups & Subscriptions) — HTTP only, not `Azure.ResourceManager` +## ARM (Resource Groups & Subscriptions) — HTTP only, not `Azure.ResourceManager` ARM's real REST API is plain HTTPS (unlike Service Bus), so `Azure.ResourceManager` could in principle work against a compatible emulator — but azlocal doesn't attempt that SDK's full From 1c66cd93fe8b95abf64be514a25e9e8d2070dda4 Mon Sep 17 00:00:00 2001 From: Isaiah Clifford Opoku Date: Sun, 19 Jul 2026 17:22:27 +0000 Subject: [PATCH 5/7] Removing some files from this --- scripts/generate-cert.sh | 0 scripts/trust-cert.ps1 | 0 scripts/trust-cert.sh | 0 3 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 scripts/generate-cert.sh delete mode 100644 scripts/trust-cert.ps1 delete mode 100644 scripts/trust-cert.sh diff --git a/scripts/generate-cert.sh b/scripts/generate-cert.sh deleted file mode 100644 index e69de29..0000000 diff --git a/scripts/trust-cert.ps1 b/scripts/trust-cert.ps1 deleted file mode 100644 index e69de29..0000000 diff --git a/scripts/trust-cert.sh b/scripts/trust-cert.sh deleted file mode 100644 index e69de29..0000000 From e9a1e2adb978d09513200331f951547023a32eba Mon Sep 17 00:00:00 2001 From: Isaiah Clifford Opoku Date: Sun, 19 Jul 2026 17:22:37 +0000 Subject: [PATCH 6/7] Update the file --- README.md | 23 +-------------------- docs/CONTRIBUTING.md | 30 ++++++++++++++++++++++++++++ examples/github-actions/workflow.yml | 0 3 files changed, 31 insertions(+), 22 deletions(-) delete mode 100644 examples/github-actions/workflow.yml diff --git a/README.md b/README.md index 85a494b..47a39d0 100644 --- a/README.md +++ b/README.md @@ -232,28 +232,7 @@ git clone cd azlocal dotnet build dotnet test -``` - -`dotnet test` runs both the unit test suite (handler/store/middleware/CLI logic in isolation) and the integration suite, which exercises the real Azure SDKs against an in-process host via `WebApplicationFactory` — no separately-started process needed. - -``` -src/ - AzLocal.Host/ # Web host — receives all requests on :4566 (HTTPS by default) - AzLocal.Core/ # Shared interfaces, models, and request routing - AzLocal.Middleware/ # Auth stub, request logging, IMDS stub, Key Vault auth-challenge responder - AzLocal.Services/ # Service handlers (one per Azure service) - AzLocal.State/ # State backends — in-memory, SQLite, JSON snapshot - AzLocal.Cli/ # CLI tool (azlocal start / stop / reset / wait / trust-cert) - AzLocal.Client/ # Client library — pre-configured SDK client factory - -tests/ - AzLocal.UnitTests/ # Handler/store/middleware/CLI logic in isolation, no HTTP - AzLocal.IntegrationTests/ # Real Azure SDKs against an in-process host (WebApplicationFactory) - AzLocal.CompatTests/ -``` - ---- - +`` ## License MIT diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index e69de29..3eccb31 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -0,0 +1,30 @@ +# Contributing to azlocal + +Building from source (rather than installing it as a package) is only needed if you're working on azlocal itself. + +```bash +git clone +cd azlocal +dotnet build +dotnet test +``` + +`dotnet test` runs both the unit test suite (handler/store/middleware/CLI logic in isolation) and the integration suite, which exercises the real Azure SDKs against an in-process host via `WebApplicationFactory` — no separately-started process needed. + +## Project structure + +``` +src/ + AzLocal.Host/ # Web host — receives all requests on :4566 (HTTPS by default) + AzLocal.Core/ # Shared interfaces, models, and request routing + AzLocal.Middleware/ # Auth stub, request logging, IMDS stub, Key Vault auth-challenge responder + AzLocal.Services/ # Service handlers (one per Azure service) + AzLocal.State/ # State backends — in-memory, SQLite, JSON snapshot + AzLocal.Cli/ # CLI tool (azlocal start / stop / reset / wait / trust-cert) + AzLocal.Client/ # Client library — pre-configured SDK client factory + +tests/ + AzLocal.UnitTests/ # Handler/store/middleware/CLI logic in isolation, no HTTP + AzLocal.IntegrationTests/ # Real Azure SDKs against an in-process host (WebApplicationFactory) + AzLocal.CompatTests/ +``` diff --git a/examples/github-actions/workflow.yml b/examples/github-actions/workflow.yml deleted file mode 100644 index e69de29..0000000 From 48de154e49eafc4c11c6d964268ac585b9527e02 Mon Sep 17 00:00:00 2001 From: Isaiah Clifford Opoku Date: Sun, 19 Jul 2026 17:26:54 +0000 Subject: [PATCH 7/7] Add MIT license and update README with links Added LICENSE file with MIT terms and copyright for 2026 Isaiah Clifford Opoku. Updated README.md to reference the license and include a link to CONTRIBUTING.md for project details. --- LICENSE | 21 +++++++++++++++++++++ README.md | 9 +++++++-- 2 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3bb0a42 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Isaiah Clifford Opoku + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 47a39d0..8a43c2d 100644 --- a/README.md +++ b/README.md @@ -232,7 +232,12 @@ git clone cd azlocal dotnet build dotnet test -`` +``` + +See [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md) for the project layout and more detail. + +--- + ## License -MIT +[MIT](LICENSE)