Răsfoiți Sursa

Mavnezz feature/inventory api (#217)

* Add inventory API endpoint for external client ingestion

Adds POST /api/inventory with API-key authentication (X-Api-Key header,
configured via RPK_API_KEY). The endpoint accepts machine snapshots and
upserts hardware + system resources with full sub-resource support
(CPUs, drives, GPUs, NICs).

- UpsertInventoryUseCase with create/update semantics
- ApiKeyEndpointFilter (empty key = 503, wrong key = 401)
- Request/response DTOs for inventory data
- 17 unit tests + 6 integration tests

* Add edge case and boundary-value tests for inventory API

Cover negative values, zero boundaries, whitespace normalization,
hostname length limits, API key case sensitivity, and system field
trigger combinations.

* Added inventory API endoint

* Fixed blazor server program.cs (to work with minimal api)

* Added tests

---------

Co-authored-by: mavnezz <githubb.com@stuch.me>
Tim Jones 1 lună în urmă
părinte
comite
af63a9df58

+ 13 - 0
RackPeek.Domain/Api/InventoryRequest.cs

@@ -0,0 +1,13 @@
+using RackPeek.Domain.Persistence;
+using RackPeek.Domain.Persistence.Yaml;
+
+namespace RackPeek.Domain.Api;
+
+public class ImportYamlRequest
+{
+    public string? Yaml { get; set; }
+    public object? Json { get; set; } 
+    public MergeMode Mode { get; set; } = MergeMode.Merge;
+
+    public bool DryRun { get; set; } = false;
+}

+ 14 - 0
RackPeek.Domain/Api/InventoryResponse.cs

@@ -0,0 +1,14 @@
+namespace RackPeek.Domain.Api;
+
+public class ImportYamlResponse
+{
+    public List<string> Added { get; set; } = new();
+    public List<string> Updated { get; set; } = new();
+    public List<string> Replaced { get; set; } = new();
+
+    public Dictionary<string, string> OldYaml { get; set; }
+        = new(StringComparer.OrdinalIgnoreCase);
+
+    public Dictionary<string, string> NewYaml { get; set; }
+        = new(StringComparer.OrdinalIgnoreCase);
+}

+ 148 - 0
RackPeek.Domain/Api/UpsertInventoryUseCase.cs

@@ -0,0 +1,148 @@
+using System.ComponentModel.DataAnnotations;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using RackPeek.Domain.Persistence;
+using RackPeek.Domain.Resources;
+using RackPeek.Domain.Persistence.Yaml;
+using YamlDotNet.Serialization;
+using YamlDotNet.Serialization.NamingConventions;
+
+namespace RackPeek.Domain.Api;
+
+public class UpsertInventoryUseCase(
+    IResourceCollection repo,
+    IResourceYamlMigrationService migrationService)
+    : IUseCase
+{
+    private static readonly JsonSerializerOptions JsonOptions = new()
+    {
+        PropertyNameCaseInsensitive = true,
+        WriteIndented = false,
+        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+        ReferenceHandler = ReferenceHandler.IgnoreCycles,
+        TypeInfoResolver = ResourcePolymorphismResolver.Create()
+    };
+
+    public async Task<ImportYamlResponse> ExecuteAsync(ImportYamlRequest request)
+    {
+        if (request == null)
+            throw new ValidationException("Invalid request.");
+
+        if (string.IsNullOrWhiteSpace(request.Yaml) && request.Json == null)
+            throw new ValidationException("Either 'yaml' or 'json' must be provided.");
+        
+        if (!string.IsNullOrWhiteSpace(request.Yaml) && request.Json != null)
+            throw new ValidationException("Provide either 'yaml' or 'json', not both.");
+        
+        
+        YamlRoot incomingRoot;
+        string yamlInput;
+
+        if (!string.IsNullOrWhiteSpace(request.Yaml))
+        {
+            yamlInput = request.Yaml!;
+            incomingRoot = await migrationService.DeserializeAsync(yamlInput)
+                           ?? throw new ValidationException("Invalid YAML structure.");
+        }
+        else
+        {
+            if (request.Json is not JsonElement element)
+                throw new ValidationException("Invalid JSON payload.");
+            
+            var rawJson = element.GetRawText();
+            incomingRoot = JsonSerializer.Deserialize<YamlRoot>(
+                               rawJson,
+                               JsonOptions)
+                           ?? throw new ValidationException("Invalid JSON structure.");
+            // Generate YAML only for persistence layer
+            var yamlSerializer = new SerializerBuilder()
+                .WithNamingConvention(CamelCaseNamingConvention.Instance)
+                .WithTypeConverter(new StorageSizeYamlConverter())
+                .WithTypeConverter(new NotesStringYamlConverter())
+                .ConfigureDefaultValuesHandling(
+                    DefaultValuesHandling.OmitNull |
+                    DefaultValuesHandling.OmitEmptyCollections)
+                .Build();
+
+            yamlInput = yamlSerializer.Serialize(incomingRoot);
+        }
+
+        if (incomingRoot.Resources == null)
+            throw new ValidationException("Missing 'resources' section.");
+
+        // 2️Compute Diff
+
+        var incomingResources = incomingRoot.Resources;
+        var currentResources = await repo.GetAllOfTypeAsync<Resource>();
+
+        var duplicate = incomingResources
+            .GroupBy(r => r.Name, StringComparer.OrdinalIgnoreCase)
+            .FirstOrDefault(g => g.Count() > 1);
+
+        if (duplicate != null)
+            throw new ValidationException($"Duplicate resource name: {duplicate.Key}");
+        
+        var currentDict = currentResources
+            .ToDictionary(r => r.Name, StringComparer.OrdinalIgnoreCase);
+
+        var serializerDiff = new SerializerBuilder()
+            .WithNamingConvention(CamelCaseNamingConvention.Instance)
+            .ConfigureDefaultValuesHandling(
+                DefaultValuesHandling.OmitNull |
+                DefaultValuesHandling.OmitEmptyCollections)
+            .Build();
+
+        var oldSnapshots = currentResources
+            .ToDictionary(
+                r => r.Name,
+                r => serializerDiff.Serialize(r),
+                StringComparer.OrdinalIgnoreCase);
+
+        var mergedResources = ResourceCollectionMerger.Merge(
+            currentResources,
+            incomingResources,
+            request.Mode);
+
+        var mergedDict = mergedResources
+            .ToDictionary(r => r.Name, StringComparer.OrdinalIgnoreCase);
+
+        var response = new ImportYamlResponse();
+
+        foreach (var incoming in incomingResources)
+        {
+            if (!mergedDict.TryGetValue(incoming.Name, out var merged))
+                continue;
+
+            var newYaml = serializerDiff.Serialize(merged);
+            response.NewYaml[incoming.Name] = newYaml;
+
+            if (!currentDict.ContainsKey(incoming.Name))
+            {
+                response.Added.Add(incoming.Name);
+                continue;
+            }
+
+            var oldYaml = oldSnapshots[incoming.Name];
+            response.OldYaml[incoming.Name] = oldYaml;
+
+            var existing = currentDict[incoming.Name];
+
+            if (request.Mode == MergeMode.Replace ||
+                existing.GetType() != incoming.GetType())
+            {
+                response.Replaced.Add(incoming.Name);
+            }
+            else if (oldYaml != newYaml)
+            {
+                response.Updated.Add(incoming.Name);
+            }
+        }
+
+        if (!request.DryRun)
+        {
+            await repo.Merge(yamlInput, request.Mode);
+        }
+
+        return response;
+    }
+}

+ 35 - 0
RackPeek.Web/Api/ApiKeyEndpointFilter.cs

@@ -0,0 +1,35 @@
+using System.Security.Cryptography;
+using System.Text;
+
+namespace RackPeek.Web.Api;
+
+public class ApiKeyEndpointFilter(IConfiguration configuration) : IEndpointFilter
+{
+    private const string ApiKeyHeaderName = "X-Api-Key";
+
+    public async ValueTask<object?> InvokeAsync(
+        EndpointFilterInvocationContext context,
+        EndpointFilterDelegate next)
+    {
+        var expectedKey = configuration["RPK_API_KEY"];
+        
+        if (string.IsNullOrWhiteSpace(expectedKey))
+            return Results.StatusCode(503);
+
+        if (!context.HttpContext.Request.Headers.TryGetValue(ApiKeyHeaderName, out var providedKey)
+            || !SecureEquals(providedKey.ToString(), expectedKey))
+        {
+            return Results.Json(new { error = "Unauthorized" }, statusCode: 401);
+        }
+
+        return await next(context);
+    }
+    private static bool SecureEquals(string a, string b)
+    {
+        var aBytes = Encoding.UTF8.GetBytes(a);
+        var bBytes = Encoding.UTF8.GetBytes(b);
+
+        return CryptographicOperations.FixedTimeEquals(aBytes, bBytes);
+    }
+    
+}

+ 31 - 0
RackPeek.Web/Api/InventoryEndpoints.cs

@@ -0,0 +1,31 @@
+using System.ComponentModel.DataAnnotations;
+using RackPeek.Domain.Api;
+
+namespace RackPeek.Web.Api;
+
+public static class InventoryEndpoints
+{
+    public static void MapInventoryApi(this WebApplication app)
+    {
+        app.MapPost("/api/inventory",
+                async (ImportYamlRequest request,
+                    UpsertInventoryUseCase useCase) =>
+                {
+                    try
+                    {
+                        var result = await useCase.ExecuteAsync(request);
+                        return Results.Ok(result);
+                    }
+                    catch (ValidationException ex)
+                    {
+                        return Results.BadRequest(new { error = ex.Message });
+                    }
+                    catch (Exception ex)
+                    {
+                        return Results.BadRequest(new { error = $"Import failed: {ex.Message}" });
+                    }
+                })
+            .AddEndpointFilter<ApiKeyEndpointFilter>()
+            .DisableAntiforgery();
+    }
+}

+ 18 - 17
RackPeek.Web/Program.cs

@@ -1,11 +1,11 @@
+using System.Text.Json.Serialization;
 using Microsoft.AspNetCore.Components;
 using Microsoft.AspNetCore.Hosting.StaticWebAssets;
 using RackPeek.Domain;
 using RackPeek.Domain.Persistence;
 using RackPeek.Domain.Persistence.Yaml;
+using RackPeek.Web.Api;
 using RackPeek.Web.Components;
-using YamlDotNet.Serialization;
-using YamlDotNet.Serialization.NamingConventions;
 using Shared.Rcl;
 
 namespace RackPeek.Web;
@@ -19,13 +19,12 @@ public class Program
             builder.Configuration
         );
 
-        builder.Configuration.AddJsonFile($"appsettings.json", optional: true, reloadOnChange: false);
-        
+        builder.Configuration.AddJsonFile("appsettings.json", optional: true, reloadOnChange: false);
+
         var yamlDir = builder.Configuration.GetValue<string>("RPK_YAML_DIR") ?? "./config";
         var yamlFileName = "config.yaml";
 
         var basePath = Directory.GetCurrentDirectory();
-
         var yamlPath = Path.IsPathRooted(yamlDir)
             ? yamlDir
             : Path.Combine(basePath, yamlDir);
@@ -36,19 +35,22 @@ public class Program
 
         if (!File.Exists(yamlFilePath))
         {
-            // Create empty file safely
             await using var fs = new FileStream(
                 yamlFilePath,
                 FileMode.CreateNew,
                 FileAccess.Write,
                 FileShare.None);
-            // optionally write default YAML content
+
             await using var writer = new StreamWriter(fs);
             await writer.WriteLineAsync("# default config");
         }
-
+        builder.Services.ConfigureHttpJsonOptions(options =>
+        {
+            options.SerializerOptions.Converters.Add(
+                new JsonStringEnumConverter());
+        });
         builder.Services.AddScoped<ITextFileStore, PhysicalTextFileStore>();
-        
+
         builder.Services.AddScoped(sp =>
         {
             var nav = sp.GetRequiredService<NavigationManager>();
@@ -58,9 +60,9 @@ public class Program
             };
         });
 
-
         var resources = new ResourceCollection();
         builder.Services.AddSingleton(resources);
+
         builder.Services.AddScoped<RackPeekConfigMigrationDeserializer>();
         builder.Services.AddScoped<IResourceYamlMigrationService, ResourceYamlMigrationService>();
 
@@ -73,31 +75,30 @@ public class Program
 
         // Infrastructure
         builder.Services.AddYamlRepos();
-
         builder.Services.AddUseCases();
         builder.Services.AddCommands();
         builder.Services.AddScoped<IConsoleEmulator, ConsoleEmulator>();
 
-        // Add services to the container.
+        // Razor Components
         builder.Services.AddRazorComponents()
             .AddInteractiveServerComponents();
 
         var app = builder.Build();
 
-        // Configure the HTTP request pipeline.
         if (!app.Environment.IsDevelopment())
         {
             app.UseExceptionHandler("/Error");
-            // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
             app.UseHsts();
         }
 
         app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
+
         app.UseHttpsRedirection();
         app.UseStaticFiles();
-
         app.UseAntiforgery();
 
+        app.MapInventoryApi();
+
         app.MapStaticAssets();
 
         app.MapRazorComponents<App>()
@@ -108,8 +109,8 @@ public class Program
 
     public static async Task Main(string[] args)
     {
-        var builder = WebApplication.CreateBuilder(args);        
+        var builder = WebApplication.CreateBuilder(args);
         var app = await BuildApp(builder);
         await app.RunAsync();
     }
-}
+}

+ 2 - 1
RackPeek.Web/appsettings.json

@@ -5,5 +5,6 @@
       "Microsoft.AspNetCore": "Warning"
     }
   },
-  "AllowedHosts": "*"
+  "AllowedHosts": "*",
+  "RPK_API_KEY": ""
 }

+ 1 - 2
Shared.Rcl/CliBootstrap.cs

@@ -75,8 +75,7 @@ public static class CliBootstrap
         var resolvedYamlDir = Path.IsPathRooted(yamlDir)
             ? yamlDir
             : Path.Combine(appBasePath, yamlDir);
-
-
+        
         Directory.CreateDirectory(resolvedYamlDir);
 
         var fullYamlPath = Path.Combine(resolvedYamlDir, yamlFile);

+ 6 - 0
Shared.Rcl/YamlFilePage.razor

@@ -14,6 +14,12 @@
                  data-testid="yaml-to-ansible-link">
             → Open Ansible Inventory Generator
         </NavLink>
+        
+        <NavLink href="yaml/import"
+                 class="text-sm text-emerald-400 hover:text-emerald-300 transition"
+                 data-testid="yaml-to-ansible-link">
+            → YAML import
+        </NavLink>
     </div>
 
     <YamlFileComponent Path="config/config.yaml">

+ 18 - 68
Shared.Rcl/YamlImportPage.razor

@@ -1,14 +1,10 @@
 @page "/yaml/import"
 <PageTitle>Yaml Import</PageTitle>
 
+@using RackPeek.Domain.Api
 @using RackPeek.Domain.Persistence
-@using RackPeek.Domain.Persistence.Yaml
-@using RackPeek.Domain.Resources
-@using YamlDotNet.Serialization
-@using YamlDotNet.Serialization.NamingConventions
 
-@inject IResourceCollection Resources
-@inject IResourceYamlMigrationService MigrationService
+@inject UpsertInventoryUseCase ImportUseCase
 
 <div class="border border-zinc-800 rounded p-4 bg-zinc-900 mt-6">
 
@@ -192,7 +188,6 @@
         _mode = mode;
         await ComputeDiff();
     }
-
     async Task ComputeDiff()
     {
         _validationError = null;
@@ -209,67 +204,19 @@
 
         try
         {
-            // Force deserialization to throw if invalid
-            var incomingRoot = await MigrationService.DeserializeAsync(_inputYaml);
-
-            if (incomingRoot?.Resources == null)
-                throw new Exception("YAML structure invalid or missing 'resources' section.");
-
-            var incomingResources = incomingRoot.Resources;
-            var currentResources = await Resources.GetAllOfTypeAsync<Resource>();
-
-            var currentDict = currentResources
-                .ToDictionary(r => r.Name, StringComparer.OrdinalIgnoreCase);
-
-            var serializer = new SerializerBuilder()
-                .WithNamingConvention(CamelCaseNamingConvention.Instance)
-                .ConfigureDefaultValuesHandling(
-                    DefaultValuesHandling.OmitNull |
-                    DefaultValuesHandling.OmitEmptyCollections)
-                .Build();
-
-            // SNAPSHOT current BEFORE merge
-            var oldSnapshots = currentResources
-                .ToDictionary(
-                    r => r.Name,
-                    r => serializer.Serialize(r),
-                    StringComparer.OrdinalIgnoreCase);
-
-            // Perform merge
-            var mergedResources = ResourceCollectionMerger.Merge(
-                currentResources,
-                incomingResources,
-                _mode);
-
-            var mergedDict = mergedResources
-                .ToDictionary(r => r.Name, StringComparer.OrdinalIgnoreCase);
-
-            foreach (var incoming in incomingResources)
+            var result = await ImportUseCase.ExecuteAsync(new ImportYamlRequest
             {
-                if (!mergedDict.TryGetValue(incoming.Name, out var merged))
-                    continue;
-
-                var newYaml = serializer.Serialize(merged);
-                _newYaml[incoming.Name] = newYaml;
+                Yaml = _inputYaml,
+                Mode = _mode,
+                DryRun = true
+            });
 
-                if (!currentDict.ContainsKey(incoming.Name))
-                {
-                    _added.Add(incoming.Name);
-                    continue;
-                }
+            _added = result.Added;
+            _updated = result.Updated;
+            _replaced = result.Replaced;
 
-                var oldYaml = oldSnapshots[incoming.Name];
-                _oldYaml[incoming.Name] = oldYaml;
-
-                if (_mode == MergeMode.Replace)
-                {
-                    _replaced.Add(incoming.Name);
-                }
-                else if (oldYaml != newYaml)
-                {
-                    _updated.Add(incoming.Name);
-                }
-            }
+            _oldYaml = result.OldYaml;
+            _newYaml = result.NewYaml;
 
             _isValid = true;
         }
@@ -286,7 +233,12 @@
 
         try
         {
-            await Resources.Merge(_inputYaml, _mode);
+            await ImportUseCase.ExecuteAsync(new ImportYamlRequest
+            {
+                Yaml = _inputYaml,
+                Mode = _mode,
+                DryRun = false
+            });
 
             _inputYaml = "";
             _isValid = false;
@@ -294,8 +246,6 @@
             _added.Clear();
             _updated.Clear();
             _replaced.Clear();
-            _oldYaml.Clear();
-            _newYaml.Clear();
         }
         catch (Exception ex)
         {

+ 108 - 0
Tests/Api/ApiTestBase.cs

@@ -0,0 +1,108 @@
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Shared.Rcl;
+using Xunit.Abstractions;
+
+namespace Tests.Api;
+
+public abstract class ApiTestBase : IDisposable
+{
+    private readonly string _tempDir;
+    protected readonly WebApplicationFactory<RackPeek.Web.Program> Factory;
+    protected readonly ITestOutputHelper Output;
+
+    protected ApiTestBase(ITestOutputHelper output)
+    {
+        Output = output;
+
+        _tempDir = Path.Combine(
+            Path.GetTempPath(),
+            "rackpeek-tests",
+            Guid.NewGuid().ToString());
+
+        Directory.CreateDirectory(_tempDir);
+
+        Factory = new WebApplicationFactory<RackPeek.Web.Program>()
+            .WithWebHostBuilder(builder =>
+            {
+                builder.ConfigureAppConfiguration((context, configBuilder) =>
+                {
+                    var baseConfig = new Dictionary<string, string?>
+                    {
+                        ["RPK_API_KEY"] = "test-key-123"
+                    };
+
+                    ConfigureTestConfiguration(baseConfig);
+
+                    configBuilder.AddInMemoryCollection(baseConfig);
+
+                    var configuration = configBuilder.Build();
+
+                    CliBootstrap.RegisterInternals(
+                            new ServiceCollection(),
+                            configuration,
+                            _tempDir,
+                            "test.yaml")
+                        .GetAwaiter()
+                        .GetResult();
+                });
+
+                builder.ConfigureServices(services =>
+                {
+                    services.AddLogging(logging =>
+                    {
+                        logging.ClearProviders();
+                        logging.AddProvider(
+                            new XUnitLoggerProvider(Output));
+                    });
+
+                    ConfigureTestServices(services);
+                });
+            });
+    }
+
+    /// <summary>
+    /// Override to modify configuration per test class
+    /// </summary>
+    protected virtual void ConfigureTestConfiguration(
+        IDictionary<string, string?> config)
+    {
+    }
+
+    /// <summary>
+    /// Override to modify services per test class
+    /// </summary>
+    protected virtual void ConfigureTestServices(
+        IServiceCollection services)
+    {
+    }
+
+    protected HttpClient CreateClient(bool withApiKey = false)
+    {
+        var client = Factory.CreateClient();
+
+        if (withApiKey)
+        {
+            client.DefaultRequestHeaders.Add("X-Api-Key", "test-key-123");
+        }
+
+        return client;
+    }
+
+    public void Dispose()
+    {
+        try
+        {
+            Factory.Dispose();
+
+            if (Directory.Exists(_tempDir))
+                Directory.Delete(_tempDir, recursive: true);
+        }
+        catch
+        {
+            // ignore cleanup issues
+        }
+    }
+}

+ 711 - 0
Tests/Api/InventoryEndpointTests.cs

@@ -0,0 +1,711 @@
+using System.Net;
+using System.Net.Http.Json;
+using RackPeek.Domain.Api;
+using Xunit.Abstractions;
+
+namespace Tests.Api;
+
+public class InventoryEndpointTests(ITestOutputHelper output) : ApiTestBase(output)
+{
+    [Fact]
+    public async Task DryRun_Add_New_Resource_Does_Not_Persist()
+    {
+        var client = CreateClient(withApiKey: true);
+
+        var yaml = """
+        resources:
+          - name: example-server
+            kind: Server
+        """;
+
+        var response = await client.PostAsJsonAsync("/api/inventory",
+            new
+            {
+                yaml,
+                dryRun = true
+            });
+
+        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+        var result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
+
+        Assert.Single(result!.Added);
+        Assert.Contains("example-server", result.Added);
+
+        // Call again — still should be "added" because dry run did not persist
+        var response2 = await client.PostAsJsonAsync("/api/inventory",
+            new
+            {
+                yaml,
+                dryRun = true
+            });
+
+        var result2 = await response2.Content.ReadFromJsonAsync<ImportYamlResponse>();
+        Assert.Single(result2!.Added);
+    }
+    
+    [Fact]
+    public async Task Merge_Add_New_Resource_Persists()
+    {
+        var client = CreateClient(withApiKey: true);
+
+        var yaml = """
+        version: 2
+        resources:
+          - kind: Server
+            name: server-merge
+            
+        """;
+
+        var response = await client.PostAsJsonAsync("/api/inventory",
+            new
+            {
+                Yaml = yaml,
+                mode = "Merge"
+            });
+
+        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+        var result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
+
+        Assert.Single(result!.Added);
+
+        // Now second call should detect no change
+        var response2 = await client.PostAsJsonAsync("/api/inventory",
+            new
+            {
+                yaml,
+                dryRun = true
+            });
+
+        var result2 = await response2.Content.ReadFromJsonAsync<ImportYamlResponse>();
+
+        Assert.Empty(result2!.Added);
+        Assert.Empty(result2.Updated);
+        Assert.Empty(result2.Replaced);
+    }
+    
+    [Fact]
+    public async Task Merge_Updates_Existing_Resource()
+    {
+        var client = CreateClient(withApiKey: true);
+
+        var initial = """
+        version: 2
+        resources:
+        - kind: Server
+          name: server-update
+          ipmi: true
+        """;
+
+        await client.PostAsJsonAsync("/api/inventory",
+            new { Yaml = initial });
+
+        var update = """
+        version: 2
+        resources:
+        - kind: Server
+          name: server-update
+          ipmi: false
+        """;
+
+        var response = await client.PostAsJsonAsync("/api/inventory",
+            new
+            {
+                Yaml = update,
+                mode = "Merge"
+            });
+
+        var result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
+
+        Assert.Single(result!.Updated);
+        Assert.Contains("server-update", result.Updated);
+    }
+    
+    [Fact]
+    public async Task Replace_Replaces_Existing_Resource()
+    {
+        var client = CreateClient(withApiKey: true);
+
+        var initial = """
+        resources:
+          - kind: Server
+            name: server-replace
+            ipmi: true
+        """;
+
+        await client.PostAsJsonAsync("/api/inventory",
+            new { yaml = initial });
+
+        var replace = """
+        resources:
+          - kind: Server
+            name: server-replace
+        """;
+
+        var response = await client.PostAsJsonAsync("/api/inventory",
+            new
+            {
+                yaml = replace,
+                mode = "Replace"
+            });
+
+        var result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
+
+        Assert.Single(result!.Replaced);
+        Assert.Contains("server-replace", result.Replaced);
+    }
+    
+    [Fact]
+    public async Task Invalid_Yaml_Returns_400()
+    {
+        var client = CreateClient(withApiKey: true);
+
+        var response = await client.PostAsJsonAsync("/api/inventory",
+            new
+            {
+                yaml = "not: valid: yaml:",
+            });
+
+        Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+    }
+
+    [Fact]
+    public async Task Missing_Resources_Section_Returns_400()
+    {
+        var client = CreateClient(withApiKey: true);
+
+        var yaml = """
+        somethingElse:
+          - name: test
+        """;
+
+        var response = await client.PostAsJsonAsync("/api/inventory",
+            new { yaml });
+
+        Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+    }
+    
+    [Fact]
+    public async Task Accepts_Json_Root_Input()
+    {
+        var client = CreateClient(withApiKey: true);
+
+        var response = await client.PostAsJsonAsync("/api/inventory",
+            new
+            {
+                json = new
+                {
+                    version = 1,
+                    resources = new[]
+                    {
+                        new { kind = "Server", name = "json-server",  }
+                    }
+                }
+            });
+
+        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+        var result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
+
+        Assert.Single(result!.Added);
+        Assert.Contains("json-server", result.Added);
+    }
+    
+    [Fact]
+    public async Task Requires_Api_Key()
+    {
+        var client = CreateClient();
+
+        var yaml = """
+        resources:
+          - name: no-auth
+            kind: Server
+        """;
+
+        var response = await client.PostAsJsonAsync("/api/inventory",
+            new { yaml });
+
+        Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+    }
+    
+    [Fact]
+    public async Task Import_Full_Config_Works()
+    {
+        var client = CreateClient(withApiKey: true);
+
+        var yaml = await File.ReadAllTextAsync("TestConfigs/v2/11-demo-config.yaml"); 
+        // Put your big sample YAML in TestData folder
+
+        var response = await client.PostAsJsonAsync("/api/inventory",
+            new { yaml });
+
+        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+        var result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
+
+        Assert.True(result!.Added.Count > 10);
+        Assert.Empty(result.Updated);
+        Assert.Empty(result.Replaced);
+    }
+    
+    [Fact]
+    public async Task Import_Full_Config_Twice_Is_Idempotent()
+    {
+        var client = CreateClient(withApiKey: true);
+        var yaml = await File.ReadAllTextAsync("TestConfigs/v2/11-demo-config.yaml"); 
+
+        await client.PostAsJsonAsync("/api/inventory", new { yaml });
+
+        var response2 = await client.PostAsJsonAsync("/api/inventory",
+            new { yaml, dryRun = true });
+
+        var result2 = await response2.Content.ReadFromJsonAsync<ImportYamlResponse>();
+
+        Assert.Empty(result2!.Added);
+        Assert.Empty(result2.Updated);
+        Assert.Empty(result2.Replaced);
+    }
+    
+    [Fact]
+    public async Task Merge_Updates_Nested_Object()
+    {
+        var client = CreateClient(withApiKey: true);
+
+        var initial = """
+                      version: 2
+                      resources:
+                        - kind: Server
+                          name: nested-test
+                          ram:
+                            size: 64
+                            mts: 2666
+                      """;
+
+        await client.PostAsJsonAsync("/api/inventory", new { yaml = initial });
+
+        var update = """
+                     version: 2
+                     resources:
+                       - kind: Server
+                         name: nested-test
+                         ram:
+                           size: 128
+                     """;
+
+        var response = await client.PostAsJsonAsync("/api/inventory",
+            new { yaml = update, mode = "Merge" });
+
+        var result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
+
+        Assert.Single(result!.Updated);
+    }
+    
+    [Fact]
+    public async Task Merge_Does_Not_Clear_List_When_Empty()
+    {
+        var client = CreateClient(withApiKey: true);
+
+        var initial = """
+                      resources:
+                        - kind: Server
+                          name: drive-test
+                          drives:
+                            - type: ssd
+                              size: 1024
+                      """;
+
+        await client.PostAsJsonAsync("/api/inventory", new { yaml = initial });
+
+        var update = """
+                     resources:
+                       - kind: Server
+                         name: drive-test
+                         drives: []
+                     """;
+
+        var response = await client.PostAsJsonAsync("/api/inventory",
+            new { yaml = update, mode = "Merge" });
+
+        var result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
+
+        // Should NOT count as update because empty list ignored
+        Assert.Empty(result!.Updated);
+    }
+    
+    [Fact]
+    public async Task Replace_Clears_List()
+    {
+        var client = CreateClient(withApiKey: true);
+
+        var initial = """
+                      resources:
+                        - kind: Server
+                          name: replace-drive-test
+                          drives:
+                            - type: ssd
+                              size: 1024
+                      """;
+
+        await client.PostAsJsonAsync("/api/inventory", new { yaml = initial });
+
+        var replace = """
+                      resources:
+                        - kind: Server
+                          name: replace-drive-test
+                          drives: []
+                      """;
+
+        var response = await client.PostAsJsonAsync("/api/inventory",
+            new { yaml = replace, mode = "Replace" });
+
+        var result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
+
+        Assert.Single(result!.Replaced);
+    }
+    
+    [Fact]
+    public async Task Type_Change_Forces_Replace()
+    {
+        var client = CreateClient(withApiKey: true);
+
+        var initial = """
+                      version: 2
+                      resources:
+                        - kind: Server
+                          name: polymorph-test
+                      """;
+
+        await client.PostAsJsonAsync("/api/inventory", new { yaml = initial });
+
+        var update = """
+                     version: 2
+                     resources:
+                       - kind: Firewall
+                         name: polymorph-test
+                     """;
+
+        var response = await client.PostAsJsonAsync("/api/inventory",
+            new { yaml = update, mode = "Merge" });
+
+        var result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
+
+        Assert.Single(result!.Replaced);
+    }
+    
+    [Fact]
+    public async Task Name_Matching_Is_Case_Insensitive()
+    {
+        var client = CreateClient(withApiKey: true);
+
+        var initial = """
+                      resources:
+                        - kind: Server
+                          name: CaseTest
+                      """;
+
+        await client.PostAsJsonAsync("/api/inventory", new { yaml = initial });
+
+        var update = """
+                     resources:
+                       - kind: Server
+                         name: casetest
+                         ipmi: true
+                     """;
+
+        var response = await client.PostAsJsonAsync("/api/inventory",
+            new { yaml = update });
+
+        var result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
+
+        Assert.Single(result!.Updated);
+    }
+    
+    [Fact]
+    public async Task Multiple_Resources_Are_Processed()
+    {
+        var client = CreateClient(withApiKey: true);
+
+        var yaml = """
+                   resources:
+                     - kind: Server
+                       name: multi-1
+                     - kind: Firewall
+                       name: multi-2
+                   """;
+
+        var response = await client.PostAsJsonAsync("/api/inventory",
+            new { yaml });
+
+        var result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
+
+        Assert.Equal(2, result!.Added.Count);
+    }
+    
+    [Fact]
+    public async Task DryRun_Replace_Does_Not_Persist()
+    {
+        var client = CreateClient(withApiKey: true);
+
+        var initial = """
+                      resources:
+                        - kind: Server
+                          name: dry-replace
+                          ipmi: true
+                      """;
+
+        await client.PostAsJsonAsync("/api/inventory", new { yaml = initial });
+
+        var replace = """
+                      resources:
+                        - kind: Server
+                          name: dry-replace
+                      """;
+
+        await client.PostAsJsonAsync("/api/inventory",
+            new { yaml = replace, mode = "Replace", dryRun = true });
+        
+        var check = await client.PostAsJsonAsync("/api/inventory",
+            new { yaml = replace, mode = "Replace", dryRun = true });
+
+        var result = await check.Content.ReadFromJsonAsync<ImportYamlResponse>();
+
+        Assert.Single(result!.Replaced);
+    }
+    
+    
+    [Fact]
+    public async Task Providing_Both_Yaml_And_Json_Returns_400()
+    {
+        var client = CreateClient(withApiKey: true);
+
+        var response = await client.PostAsJsonAsync("/api/inventory",
+            new
+            {
+                yaml = "resources: []",
+                json = new { resources = Array.Empty<object>() }
+            });
+
+        Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+    }
+    
+    [Fact]
+    public async Task Empty_Request_Returns_400()
+    {
+        var client = CreateClient(withApiKey: true);
+
+        var response = await client.PostAsJsonAsync("/api/inventory", new { });
+
+        Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+    }
+    
+    [Fact]
+    public async Task Version_1_Config_Is_Accepted()
+    {
+        var client = CreateClient(withApiKey: true);
+
+        var yaml = """
+                   version: 1
+                   resources:
+                     - kind: Server
+                       name: v1-server
+                   """;
+
+        var response = await client.PostAsJsonAsync("/api/inventory", new { yaml });
+
+        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+        var result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
+        Assert.Single(result!.Added);
+    }
+    
+    [Fact]
+    public async Task Replace_Removes_Existing_Fields()
+    {
+        var client = CreateClient(withApiKey: true);
+
+        var initial = """
+                      resources:
+                        - kind: Server
+                          name: destructive-test
+                          ipmi: true
+                      """;
+
+        await client.PostAsJsonAsync("/api/inventory", new { yaml = initial });
+
+        var replace = """
+                      resources:
+                        - kind: Server
+                          name: destructive-test
+                      """;
+
+        var response = await client.PostAsJsonAsync("/api/inventory",
+            new { yaml = replace, mode = "Replace" });
+
+        var result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
+
+        Assert.Single(result!.Replaced);
+
+        Assert.Contains("destructive-test", result.OldYaml.Keys);
+        Assert.Contains("destructive-test", result.NewYaml.Keys);
+    }
+    
+    [Fact]
+    public async Task Merge_Does_Not_Affect_Unspecified_Resources()
+    {
+        var client = CreateClient(withApiKey: true);
+
+        var full = await File.ReadAllTextAsync("TestConfigs/v2/11-demo-config.yaml");
+        await client.PostAsJsonAsync("/api/inventory", new { yaml = full });
+
+        var update = """
+                     resources:
+                       - kind: Server
+                         name: proxmox-node01
+                         ipmi: false
+                     """;
+
+        var response = await client.PostAsJsonAsync("/api/inventory",
+            new { yaml = update, mode = "Merge" });
+
+        var result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
+
+        Assert.Single(result!.Updated);
+        Assert.DoesNotContain("proxmox-node02", result.Updated);
+    }
+    
+    [Fact]
+    public async Task Json_Input_Resolves_Polymorphic_Resource()
+    {
+        var client = CreateClient(withApiKey: true);
+
+        var response = await client.PostAsJsonAsync("/api/inventory",
+            new
+            {
+                json = new
+                {
+                    version = 2,
+                    resources = new[]
+                    {
+                        new { kind = "Firewall", name = "json-fw", model = "Test" }
+                    }
+                }
+            });
+
+        var result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
+
+        Assert.Single(result!.Added);
+        Assert.Contains("json-fw", result.Added);
+    }
+    
+    [Fact]
+    public async Task Large_Config_Is_Fully_Idempotent()
+    {
+        var client = CreateClient(withApiKey: true);
+        var yaml = await File.ReadAllTextAsync("TestConfigs/v2/11-demo-config.yaml");
+
+        await client.PostAsJsonAsync("/api/inventory", new { yaml });
+
+        var response = await client.PostAsJsonAsync("/api/inventory",
+            new { yaml });
+
+        var result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
+
+        Assert.Empty(result!.Added);
+        Assert.Empty(result.Updated);
+        Assert.Empty(result.Replaced);
+    }
+    
+    [Fact]
+    public async Task Unknown_Kind_Returns_400()
+    {
+        var client = CreateClient(withApiKey: true);
+
+        var yaml = """
+                   resources:
+                     - kind: UnknownThing
+                       name: mystery
+                   """;
+
+        var response = await client.PostAsJsonAsync("/api/inventory", new { yaml });
+
+        Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+    }
+    
+    [Fact]
+    public async Task DryRun_Does_Not_Persist_Snapshots()
+    {
+        var client = CreateClient(withApiKey: true);
+
+        var yaml = """
+                   resources:
+                     - kind: Server
+                       name: dry-snapshot
+                   """;
+
+        var response = await client.PostAsJsonAsync("/api/inventory",
+            new { yaml, dryRun = true });
+
+        var result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
+
+        Assert.Empty(result!.OldYaml);
+    }
+    
+    
+    [Fact]
+    public async Task Reordering_List_Does_Not_Count_As_Update()
+    {
+        var client = CreateClient(withApiKey: true);
+
+        var initial = """
+                      resources:
+                        - kind: Server
+                          name: order-test
+                          drives:
+                            - type: ssd
+                              size: 1024
+                            - type: hdd
+                              size: 4096
+                      """;
+
+        await client.PostAsJsonAsync("/api/inventory", new { yaml = initial });
+
+        var reordered = """
+                        resources:
+                          - kind: Server
+                            name: order-test
+                            drives:
+                              - type: hdd
+                                size: 4096
+                              - type: ssd
+                                size: 1024
+                        """;
+
+        var response = await client.PostAsJsonAsync("/api/inventory",
+            new { yaml = reordered });
+
+        var result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
+
+        Assert.Single(result!.Updated);
+        Assert.Contains("order-test", result.Updated);
+        
+    }
+    
+    [Fact]
+    public async Task Duplicate_Names_In_Same_Request_Returns_400()
+    {
+        var client = CreateClient(withApiKey: true);
+
+        var yaml = """
+                   resources:
+                     - kind: Server
+                       name: dup
+                     - kind: Server
+                       name: dup
+                   """;
+
+        var response = await client.PostAsJsonAsync("/api/inventory", new { yaml });
+
+        Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+    }
+}

+ 2 - 0
Tests/Tests.csproj

@@ -17,6 +17,7 @@
         <PackageReference Include="Spectre.Console.Testing" Version="0.54.0"/>
         <PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.3"/>
         <PackageReference Include="Microsoft.Extensions.Logging.Console" Version="10.0.3"/>
+        <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.3"/>
     </ItemGroup>
 
     <ItemGroup>
@@ -25,6 +26,7 @@
 
     <ItemGroup>
         <ProjectReference Include="..\Shared.Rcl\Shared.Rcl.csproj"/>
+        <ProjectReference Include="..\RackPeek.Web\RackPeek.Web.csproj"/>
     </ItemGroup>
 
     <ItemGroup>