From 64e24fc6c81f42bb51c182cfb8f5eec44d3556d3 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Tue, 20 Oct 2026 13:32:54 -0400 Subject: [PATCH] feat(server): take nest record counts, material and thickness from the archive The server stored whatever plate count, part count, material and thickness the client claimed, so a record could disagree with its own archive, and a bulk importer had to read each written .nest back in full just to describe it (about a quarter of the PEP archive import's time). Uploads (POST and PUT /file) now read only the archive's nest.json - never programs or geometry, and without referencing OpenNest.IO - and overwrite those fields, following NestReader's fallbacks for thickness, material, parts with a missing drawing and the trailing new-plate slot. A file without a readable nest.json is 400 and stores nothing, so the server can guarantee the record describes the bytes; nest.json decompression stops at 64 MB. A metadata-only PUT keeps the stored counts because the archive is unchanged; material and thickness stay editable there, since the desktop applies the record's values on open. Server tests that uploaded placeholder bytes now upload small deterministic archives, and the container smoke's near-limit probe is a padded real archive. New tests pin the server's values to a full NestReader read of the same archive. Co-Authored-By: Claude Opus 5.5 --- OpenNest.Server.Tests/NestApiTests.cs | 10 +- .../NestArchiveFieldsTests.cs | 184 ++++++++++++++++++ .../NestHistoryClientTests.cs | 8 +- OpenNest.Server.Tests/NestMutationTests.cs | 50 ++--- OpenNest.Server.Tests/NestRevisionTests.cs | 38 ++-- .../NestUploadValidationTests.cs | 22 +-- OpenNest.Server.Tests/TestNests.cs | 32 +++ OpenNest.Server/NestArchive.cs | 126 ++++++++++++ OpenNest.Server/NestDatabase.cs | 6 + OpenNest.Server/Program.cs | 7 +- docs/nest-storage.md | 9 +- scripts/Server.Tests/Program.cs | 27 ++- 12 files changed, 446 insertions(+), 73 deletions(-) create mode 100644 OpenNest.Server.Tests/NestArchiveFieldsTests.cs create mode 100644 OpenNest.Server.Tests/TestNests.cs create mode 100644 OpenNest.Server/NestArchive.cs diff --git a/OpenNest.Server.Tests/NestApiTests.cs b/OpenNest.Server.Tests/NestApiTests.cs index 0257fb9f..5f88bc0a 100644 --- a/OpenNest.Server.Tests/NestApiTests.cs +++ b/OpenNest.Server.Tests/NestApiTests.cs @@ -216,7 +216,7 @@ public sealed class NestApiTests () => new HttpRequestMessage(HttpMethod.Get, $"/api/nests/{unknown}/file"), () => new HttpRequestMessage(HttpMethod.Put, $"/api/nests/{unknown}/file") { - Content = Multipart(JsonSerializer.Serialize(record, JsonOptions), new byte[] { 1, 2, 3 }), + Content = Multipart(JsonSerializer.Serialize(record, JsonOptions), TestNests.Archive(1, 2, 3)), }, () => new HttpRequestMessage(HttpMethod.Put, $"/api/nests/{unknown}/metadata") { @@ -287,15 +287,15 @@ public sealed class NestApiTests content.Headers.ContentType = MediaTypeHeaderValue.Parse("multipart/form-data; boundary=expected-boundary"); return content; case "missing boundary": - var unbounded = Multipart(metadata, new byte[] { 1 }); + var unbounded = Multipart(metadata, TestNests.Archive(1)); unbounded.Headers.ContentType = MediaTypeHeaderValue.Parse("multipart/form-data"); return unbounded; case "missing metadata": - return Multipart(null, new byte[] { 1 }); + return Multipart(null, TestNests.Archive(1)); case "invalid metadata JSON": - return Multipart("{not-json", new byte[] { 1 }); + return Multipart("{not-json", TestNests.Archive(1)); case "null metadata": - return Multipart("null", new byte[] { 1 }); + return Multipart("null", TestNests.Archive(1)); case "missing file": return Multipart(metadata, null); case "empty file": diff --git a/OpenNest.Server.Tests/NestArchiveFieldsTests.cs b/OpenNest.Server.Tests/NestArchiveFieldsTests.cs new file mode 100644 index 00000000..840e86a0 --- /dev/null +++ b/OpenNest.Server.Tests/NestArchiveFieldsTests.cs @@ -0,0 +1,184 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using OpenNest.Data; +using OpenNest.Geometry; +using OpenNest.IO; + +namespace OpenNest.Server.Tests; + +/// +/// Plate count, part count, material and thickness describe the uploaded archive: the server +/// reads them from its nest.json, whatever the client claims, and refuses files it cannot read. +/// +public sealed class NestArchiveFieldsTests +{ + private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web) + { Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() } }; + + [Fact] + public async Task Upload_TakesArchiveFieldsFromTheFileNotTheClient() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var archive = NestApiTests.Archive(TwoPlateNest()); + + using var response = await client.PostAsync("/api/nests", NestRevisionTests.Body(Claimed(), archive)); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var stored = (await response.Content.ReadFromJsonAsync(Options))!; + Assert.Equal(2, stored.PlateCount); + Assert.Equal(3, stored.PartCount); + Assert.Equal("Synthetic alloy", stored.Material); + Assert.Equal(0.25, stored.Thickness); + // Same values the desktop computed by reading the archive back in full. + using var stream = new MemoryStream(archive, writable: false); + var expected = NestRecordFactory.FromNest(new NestReader(stream).Read(), stored.Id, archive.LongLength); + Assert.Equal( + (expected.PlateCount, expected.PartCount, expected.Material, expected.Thickness), + (stored.PlateCount, stored.PartCount, stored.Material, stored.Thickness)); + // Client-owned fields are still the client's. + Assert.Equal("Claimed name", stored.Name); + } + + [Fact] + public async Task UpdateFile_TakesArchiveFieldsFromTheNewFile() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var id = await Create(client, NestApiTests.Archive(TwoPlateNest())); + client.DefaultRequestHeaders.TryAddWithoutValidation("If-Match", "\"1\""); + + using var response = await client.PutAsync($"/api/nests/{id}/file", + NestRevisionTests.Body(Claimed(), NestApiTests.Archive(NestApiTests.SyntheticNest()))); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var stored = (await response.Content.ReadFromJsonAsync(Options))!; + Assert.Equal((1, 1), (stored.PlateCount, stored.PartCount)); + } + + [Fact] + public async Task MetadataOnlySave_KeepsArchiveCountsAndAcceptsEditedMaterialAndThickness() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var id = await Create(client, NestApiTests.Archive(TwoPlateNest())); + client.DefaultRequestHeaders.TryAddWithoutValidation("If-Match", "\"1\""); + var edit = Claimed(); + edit.Material = "Edited alloy"; + edit.Thickness = 0.5; + + using var response = await client.PutAsJsonAsync($"/api/nests/{id}/metadata", edit, Options); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var stored = (await response.Content.ReadFromJsonAsync(Options))!; + // The archive is unchanged, so its counts are too; material and thickness are + // editable record fields the desktop applies when it opens the nest. + Assert.Equal((2, 3), (stored.PlateCount, stored.PartCount)); + Assert.Equal(("Edited alloy", 0.5), (stored.Material, stored.Thickness)); + Assert.Equal(2, stored.Revision); + } + + [Theory] + [InlineData("not a zip")] + [InlineData("no nest.json")] + [InlineData("nest.json not JSON")] + [InlineData("nest.json null")] + public async Task Upload_FileThatIsNotANest_Returns400AndStoresNothing(string row) + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + + using var response = await client.PostAsync("/api/nests", NestRevisionTests.Body(Claimed(), NotANest(row))); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + using var reopened = new NestDatabase(factory.DatabasePath, pooling: false); + Assert.Empty(reopened.List()); + } + + [Theory] + [InlineData("not a zip")] + [InlineData("no nest.json")] + public async Task UpdateFile_FileThatIsNotANest_Returns400AndLeavesTheRecord(string row) + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var original = TestNests.Archive(1); + var id = await Create(client, original); + client.DefaultRequestHeaders.TryAddWithoutValidation("If-Match", "\"1\""); + + using var response = await client.PutAsync($"/api/nests/{id}/file", NestRevisionTests.Body(Claimed(), NotANest(row))); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(original, await client.GetByteArrayAsync($"/api/nests/{id}/file")); + using var reopened = new NestDatabase(factory.DatabasePath, pooling: false); + Assert.Equal(1, reopened.Get(id)!.Revision); + } + + // Hand-written nest.json for the reader fallbacks NestWriter never produces. + [Theory] + // Thickness that is not positive falls back to the plate defaults'. + [InlineData("""{"thickness":0,"plateDefaults":{"thickness":0.375}}""", "", 0.375, 0, 0)] + // An explicit null material falls back to the plate defaults'; an absent one is empty. + [InlineData("""{"material":null,"plateDefaults":{"material":{"name":"Default"}}}""", "Default", 0, 0, 0)] + [InlineData("""{"plateDefaults":{"material":{"name":"Default"}}}""", "", 0, 0, 0)] + // A part whose drawing is missing is dropped, as NestReader drops it. + [InlineData("""{"drawings":[{"id":1}],"plates":[{"id":1,"parts":[{"drawingId":1},{"drawingId":7}]}]}""", "", 0, 1, 1)] + // A trailing plate with nothing on it is the new-plate slot; one with only cut-offs counts. + [InlineData("""{"drawings":[{"id":1}],"plates":[{"id":1,"parts":[{"drawingId":1}]},{"id":2}]}""", "", 0, 1, 1)] + [InlineData("""{"drawings":[{"id":1}],"plates":[{"id":1,"parts":[{"drawingId":1}]},{"id":2,"cutOffs":[{}]}]}""", "", 0, 2, 1)] + // Plates are ordered by id, as NestReader orders them, before finding the last one. + [InlineData("""{"drawings":[{"id":1}],"plates":[{"id":2},{"id":1,"parts":[{"drawingId":1}]}]}""", "", 0, 1, 1)] + public async Task Upload_FollowsNestReaderFallbacks(string nestJson, string material, double thickness, + int plateCount, int partCount) + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + + using var response = await client.PostAsync("/api/nests", + NestRevisionTests.Body(Claimed(), TestNests.Zip("nest.json", nestJson))); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var stored = (await response.Content.ReadFromJsonAsync(Options))!; + Assert.Equal((material, thickness, plateCount, partCount), + (stored.Material, stored.Thickness, stored.PlateCount, stored.PartCount)); + } + + /// A client record whose archive-derived fields are all wrong. + private static NestRecord Claimed() => new() + { + Name = "Claimed name", + Material = "Claimed alloy", + Thickness = 9, + PlateCount = 99, + PartCount = 99, + }; + + private static Nest TwoPlateNest() + { + var nest = NestApiTests.SyntheticNest(); + var drawing = nest.Drawings.Single(); + var plate = new Plate(new Size(20, 30)) { Quantity = 1 }; + plate.Parts.Add(new Part(drawing, new Vector(2, 3))); + plate.Parts.Add(new Part(drawing, new Vector(10, 3))); + nest.Plates.Add(plate); + return nest; + } + + private static byte[] NotANest(string row) => row switch + { + "not a zip" => Encoding.UTF8.GetBytes("nest.json"), + "no nest.json" => TestNests.Zip("readme.txt", "not a nest"), + "nest.json not JSON" => TestNests.Zip("nest.json", "{not json"), + "nest.json null" => TestNests.Zip("nest.json", "null"), + _ => throw new ArgumentOutOfRangeException(nameof(row), row, null), + }; + + private static async Task Create(HttpClient client, byte[] archive) + { + using var created = await client.PostAsync("/api/nests", NestRevisionTests.Body(Claimed(), archive)); + Assert.Equal(HttpStatusCode.OK, created.StatusCode); + return (await created.Content.ReadFromJsonAsync(Options))!.Id; + } +} diff --git a/OpenNest.Server.Tests/NestHistoryClientTests.cs b/OpenNest.Server.Tests/NestHistoryClientTests.cs index 21061713..dc5f282f 100644 --- a/OpenNest.Server.Tests/NestHistoryClientTests.cs +++ b/OpenNest.Server.Tests/NestHistoryClientTests.cs @@ -135,8 +135,8 @@ public sealed class NestHistoryClientTests using var http = factory.CreateClient(); using var repository = new RemoteNestRepository(http, http.BaseAddress!.ToString()); var id = Guid.NewGuid(); - var first = new byte[] { 80, 75, 0, 1, 255 }; - var second = new byte[] { 80, 75, 0, 2, 254 }; + var first = TestNests.Archive(80, 75, 0, 1, 255); + var second = TestNests.Archive(80, 75, 0, 2, 254); var created = await repository.UploadAsync(first, new NestRecord { Id = id, Name = "Synthetic" }); Assert.Equal(1, created.Revision); @@ -163,9 +163,9 @@ public sealed class NestHistoryClientTests using var factory = new ServerFactory(); using var http = factory.CreateClient(); using var repository = new RemoteNestRepository(http, http.BaseAddress!.ToString()); - var created = await repository.UploadAsync([1, 2, 3], new NestRecord { Name = "Synthetic" }); + var created = await repository.UploadAsync(TestNests.Archive(1, 2, 3), new NestRecord { Name = "Synthetic" }); using var response = await http.DeleteAsync($"/api/nests/{created.Id}"); Assert.Equal((HttpStatusCode)428, response.StatusCode); - Assert.Equal(new byte[] { 1, 2, 3 }, await repository.GetRevisionFileAsync(created.Id, 1)); + Assert.Equal(TestNests.Archive(1, 2, 3), await repository.GetRevisionFileAsync(created.Id, 1)); } } diff --git a/OpenNest.Server.Tests/NestMutationTests.cs b/OpenNest.Server.Tests/NestMutationTests.cs index 60500ed9..f3ecaf60 100644 --- a/OpenNest.Server.Tests/NestMutationTests.cs +++ b/OpenNest.Server.Tests/NestMutationTests.cs @@ -13,7 +13,7 @@ public sealed class NestMutationTests using var factory = new ServerFactory(); using var client = factory.CreateClient(); using var repository = new RemoteNestRepository(client, client.BaseAddress!.ToString()); - var original = await repository.UploadAsync([0, 255, 1], new NestRecord { Name = "Fixture" }); + var original = await repository.UploadAsync(TestNests.Archive(0, 255, 1), new NestRecord { Name = "Fixture" }); original.Comments = "Edited"; var saved = await repository.UpdateMetadataAsync(original.Id, original); Assert.Equal(2, saved.Revision); @@ -24,10 +24,10 @@ public sealed class NestMutationTests var history = await repository.GetRevisionsAsync(saved.Id); Assert.Equal(new[] { "Edited", "" }, history.Items.Select(r => r.Comments)); foreach (var revision in history.Items) - Assert.Equal(new byte[] { 0, 255, 1 }, await repository.GetRevisionFileAsync(saved.Id, revision.Revision)); + Assert.Equal(TestNests.Archive(0, 255, 1), await repository.GetRevisionFileAsync(saved.Id, revision.Revision)); using var reopened = new NestDatabase(factory.DatabasePath, pooling: false); Assert.Equal(2, reopened.History(saved.Id, 0, 100)!.Total); - Assert.Equal(new byte[] { 0, 255, 1 }, reopened.GetFile(saved.Id)); + Assert.Equal(TestNests.Archive(0, 255, 1), reopened.GetFile(saved.Id)); } [Theory] @@ -39,7 +39,7 @@ public sealed class NestMutationTests using var factory = new ServerFactory(); using var client = factory.CreateClient(); using var repository = new RemoteNestRepository(client, client.BaseAddress!.ToString()); - var saved = await repository.UploadAsync([1], new NestRecord { Name = "Fixture" }); + var saved = await repository.UploadAsync(TestNests.Archive(1), new NestRecord { Name = "Fixture" }); if (header is not null) client.DefaultRequestHeaders.TryAddWithoutValidation("If-Match", header); using var response = await client.PutAsJsonAsync($"/api/nests/{saved.Id}/metadata", new NestRecord { Name = "Rejected" }); Assert.Equal(status, (int)response.StatusCode); @@ -54,7 +54,7 @@ public sealed class NestMutationTests using var factory = new ServerFactory(); using var client = factory.CreateClient(); using var repository = new RemoteNestRepository(client, client.BaseAddress!.ToString()); - var saved = await repository.UploadAsync([1], new NestRecord { Name = "Fixture" }); + var saved = await repository.UploadAsync(TestNests.Archive(1), new NestRecord { Name = "Fixture" }); FailSnapshots(factory.DatabasePath); saved.Comments = "Rejected"; Assert.Contains("500", (await Assert.ThrowsAsync(() => repository.UpdateMetadataAsync(saved.Id, saved))).Message); @@ -69,7 +69,7 @@ public sealed class NestMutationTests using var factory = new ServerFactory(); using var client = factory.CreateClient(); using var repository = new RemoteNestRepository(client, client.BaseAddress!.ToString()); - var saved = await repository.UploadAsync([0, 255], new NestRecord { Name = "Fixture" }); + var saved = await repository.UploadAsync(TestNests.Archive(0, 255), new NestRecord { Name = "Fixture" }); var deleted = await repository.DeleteAsync(saved.Id, 1); Assert.True(deleted.Deleted); Assert.Equal(2, deleted.Revision); @@ -85,16 +85,16 @@ public sealed class NestMutationTests // Recovery is explicit and bounded, never ordinary search. Assert.Equal(saved.Id, Assert.Single((await repository.QueryDeletedAsync(new NestQuery { Limit = 1 })).Items).Id); Assert.True((await repository.GetDeletedMetadataAsync(saved.Id))!.Deleted); - Assert.Equal(new byte[] { 0, 255 }, await repository.GetDeletedFileAsync(saved.Id)); + Assert.Equal(TestNests.Archive(0, 255), await repository.GetDeletedFileAsync(saved.Id)); var history = await repository.GetDeletedRevisionsAsync(saved.Id); Assert.Equal(new[] { true, false }, history.Items.Select(r => r.Deleted)); foreach (var item in history.Items) - Assert.Equal(new byte[] { 0, 255 }, await repository.GetDeletedRevisionFileAsync(saved.Id, item.Revision)); + Assert.Equal(TestNests.Archive(0, 255), await repository.GetDeletedRevisionFileAsync(saved.Id, item.Revision)); var recovered = await repository.RecoverAsync(saved.Id, 2); Assert.False(recovered.Deleted); Assert.Empty((await repository.QueryDeletedAsync(new NestQuery())).Items); Assert.Equal(3, (await repository.GetMetadataAsync(saved.Id))!.Revision); - Assert.Equal(new byte[] { 0, 255 }, await repository.GetFileAsync(saved.Id)); + Assert.Equal(TestNests.Archive(0, 255), await repository.GetFileAsync(saved.Id)); Assert.Equal(3, (await repository.GetRevisionsAsync(saved.Id)).Total); } @@ -110,7 +110,7 @@ public sealed class NestMutationTests using var factory = new ServerFactory(); using var client = factory.CreateClient(); using var repository = new RemoteNestRepository(client, client.BaseAddress!.ToString()); - var saved = await repository.UploadAsync([1], new NestRecord { Name = "Fixture" }); + var saved = await repository.UploadAsync(TestNests.Archive(1), new NestRecord { Name = "Fixture" }); if (recover) await repository.DeleteAsync(saved.Id, 1); using var request = new HttpRequestMessage(recover ? HttpMethod.Post : HttpMethod.Delete, recover ? $"/api/nests/deleted/{saved.Id}/recover" : $"/api/nests/{saved.Id}"); @@ -119,7 +119,7 @@ public sealed class NestMutationTests Assert.Equal(status, (int)response.StatusCode); using var reopened = new NestDatabase(factory.DatabasePath, pooling: false); Assert.Equal(recover ? 2 : 1, reopened.History(saved.Id, 0, 100, recover)!.Total); - Assert.Equal(new byte[] { 1 }, reopened.GetFile(saved.Id, recover)); + Assert.Equal(TestNests.Archive(1), reopened.GetFile(saved.Id, recover)); Assert.Equal(recover, reopened.Get(saved.Id, recover)!.Deleted); } @@ -131,14 +131,14 @@ public sealed class NestMutationTests using var factory = new ServerFactory(); using var client = factory.CreateClient(); using var repository = new RemoteNestRepository(client, client.BaseAddress!.ToString()); - var saved = await repository.UploadAsync([1], new NestRecord { Name = "Fixture" }); + var saved = await repository.UploadAsync(TestNests.Archive(1), new NestRecord { Name = "Fixture" }); if (recover) await repository.DeleteAsync(saved.Id, 1); FailSnapshots(factory.DatabasePath); Assert.Contains("500", (await Assert.ThrowsAsync(() => recover ? repository.RecoverAsync(saved.Id, 2) : repository.DeleteAsync(saved.Id, 1))).Message); using var reopened = new NestDatabase(factory.DatabasePath, pooling: false); Assert.Equal(recover ? 2 : 1, reopened.History(saved.Id, 0, 100, recover)!.Total); - Assert.Equal(new byte[] { 1 }, reopened.GetFile(saved.Id, recover)); + Assert.Equal(TestNests.Archive(1), reopened.GetFile(saved.Id, recover)); } [Fact] @@ -147,8 +147,8 @@ public sealed class NestMutationTests using var factory = new ServerFactory(); using var client = factory.CreateClient(); using var repository = new RemoteNestRepository(client, client.BaseAddress!.ToString()); - var first = await repository.UploadAsync([0, 255], new NestRecord { Name = "Original", Comments = "Before" }); - var second = await repository.UpdateFileAsync(first.Id, [3, 4], new NestRecord { Name = "Changed" }, 1); + var first = await repository.UploadAsync(TestNests.Archive(0, 255), new NestRecord { Name = "Original", Comments = "Before" }); + var second = await repository.UpdateFileAsync(first.Id, TestNests.Archive(3, 4), new NestRecord { Name = "Changed" }, 1); var before = await repository.GetRevisionsAsync(first.Id); var restored = await repository.RestoreAsync(first.Id, 1, 2); Assert.Equal(1, restored.RestoredFromRevision); @@ -157,14 +157,14 @@ public sealed class NestMutationTests Assert.Equal(first.Id, current.Id); Assert.Equal("Original", current.Name); Assert.Equal("Before", current.Comments); - Assert.Equal(new byte[] { 0, 255 }, await repository.GetFileAsync(first.Id)); - Assert.Equal(new byte[] { 3, 4 }, await repository.GetRevisionFileAsync(first.Id, 2)); + Assert.Equal(TestNests.Archive(0, 255), await repository.GetFileAsync(first.Id)); + Assert.Equal(TestNests.Archive(3, 4), await repository.GetRevisionFileAsync(first.Id, 2)); var after = await repository.GetRevisionsAsync(first.Id); Assert.Equal(3, after.Total); Assert.Equal(System.Text.Json.JsonSerializer.Serialize(before.Items), System.Text.Json.JsonSerializer.Serialize(after.Items.Skip(1))); using var reopened = new NestDatabase(factory.DatabasePath, pooling: false); Assert.Equal(3, reopened.History(first.Id, 0, 100)!.Total); - Assert.Equal(new byte[] { 0, 255 }, reopened.GetFile(first.Id)); + Assert.Equal(TestNests.Archive(0, 255), reopened.GetFile(first.Id)); } [Theory] @@ -178,14 +178,14 @@ public sealed class NestMutationTests using var factory = new ServerFactory(); using var client = factory.CreateClient(); using var repository = new RemoteNestRepository(client, client.BaseAddress!.ToString()); - var saved = await repository.UploadAsync([1], new NestRecord { Name = "Fixture" }); + var saved = await repository.UploadAsync(TestNests.Archive(1), new NestRecord { Name = "Fixture" }); using var request = new HttpRequestMessage(HttpMethod.Post, $"/api/nests/{saved.Id}/revisions/{source}/restore"); if (header is not null) request.Headers.TryAddWithoutValidation("If-Match", header); using var response = await client.SendAsync(request); Assert.Equal(status, (int)response.StatusCode); using var reopened = new NestDatabase(factory.DatabasePath, pooling: false); Assert.Equal(1, reopened.History(saved.Id, 0, 100)!.Total); - Assert.Equal(new byte[] { 1 }, reopened.GetFile(saved.Id)); + Assert.Equal(TestNests.Archive(1), reopened.GetFile(saved.Id)); Assert.Null(reopened.Get(saved.Id)!.RestoredFromRevision); } @@ -195,7 +195,7 @@ public sealed class NestMutationTests using var factory = new ServerFactory(); using var client = factory.CreateClient(); using var repository = new RemoteNestRepository(client, client.BaseAddress!.ToString()); - var saved = await repository.UploadAsync([1], new NestRecord { Name = "Fixture" }); + var saved = await repository.UploadAsync(TestNests.Archive(1), new NestRecord { Name = "Fixture" }); var restored = await repository.RestoreAsync(saved.Id, 1, 1); Assert.Equal(2, restored.Revision); Assert.Equal(1, restored.RestoredFromRevision); @@ -203,7 +203,7 @@ public sealed class NestMutationTests Assert.Contains("500", (await Assert.ThrowsAsync(() => repository.RestoreAsync(saved.Id, 1, 2))).Message); using var reopened = new NestDatabase(factory.DatabasePath, pooling: false); Assert.Equal(2, reopened.History(saved.Id, 0, 100)!.Total); - Assert.Equal(new byte[] { 1 }, reopened.GetFile(saved.Id)); + Assert.Equal(TestNests.Archive(1), reopened.GetFile(saved.Id)); Assert.Equal(1, reopened.Get(saved.Id)!.RestoredFromRevision); } @@ -217,7 +217,7 @@ public sealed class NestMutationTests using var factory = new ServerFactory(); using var client = factory.CreateClient(); using var repository = new RemoteNestRepository(client, client.BaseAddress!.ToString()); - var saved = await repository.UploadAsync([1], new NestRecord { Name = "Fixture" }); + var saved = await repository.UploadAsync(TestNests.Archive(1), new NestRecord { Name = "Fixture" }); if (operation != "recover") await repository.DeleteAsync(saved.Id, 1); foreach (var id in new[] { saved.Id, Guid.NewGuid() }) { @@ -249,7 +249,7 @@ public sealed class NestMutationTests using var factory = new ServerFactory(); using var client = factory.CreateClient(); using var repository = new RemoteNestRepository(client, client.BaseAddress!.ToString()); - var saved = await repository.UploadAsync([1], new NestRecord { Name = "Fixture" }); + var saved = await repository.UploadAsync(TestNests.Archive(1), new NestRecord { Name = "Fixture" }); var expected = 1; if (operation == "recover") { await repository.DeleteAsync(saved.Id, 1); expected = 2; } var attempts = Enumerable.Range(0, 2).Select(async i => @@ -273,7 +273,7 @@ public sealed class NestMutationTests Assert.Single(statuses.Where(s => s == (operation is "delete" or "recover" ? HttpStatusCode.NotFound : HttpStatusCode.Conflict))); using var reopened = new NestDatabase(factory.DatabasePath, pooling: false); Assert.Equal(expected + 1, reopened.History(saved.Id, 0, 100, operation == "delete")!.Total); - Assert.Equal(new byte[] { 1 }, reopened.GetRevisionFile(saved.Id, 1, operation == "delete")); + Assert.Equal(TestNests.Archive(1), reopened.GetRevisionFile(saved.Id, 1, operation == "delete")); } private static void FailSnapshots(string path) diff --git a/OpenNest.Server.Tests/NestRevisionTests.cs b/OpenNest.Server.Tests/NestRevisionTests.cs index 0c8aecd1..efd65876 100644 --- a/OpenNest.Server.Tests/NestRevisionTests.cs +++ b/OpenNest.Server.Tests/NestRevisionTests.cs @@ -36,20 +36,20 @@ public sealed class NestRevisionTests using var factory = new ServerFactory(); using var client = factory.CreateClient(); var record = new NestRecord { Name = "Initial" }; - using var created = await client.PostAsync("/api/nests", Body(record, [1, 2])); + using var created = await client.PostAsync("/api/nests", Body(record, TestNests.Archive(1, 2))); var current = (await created.Content.ReadFromJsonAsync(Options))!; Assert.Equal(1, current.Revision); record.Name = "Changed"; client.DefaultRequestHeaders.TryAddWithoutValidation("If-Match", "\"1\""); - using var updated = await client.PutAsync($"/api/nests/{current.Id}/file", Body(record, [3, 4])); + using var updated = await client.PutAsync($"/api/nests/{current.Id}/file", Body(record, TestNests.Archive(3, 4))); Assert.Equal(HttpStatusCode.OK, updated.StatusCode); var latest = (await updated.Content.ReadFromJsonAsync(Options))!; Assert.Equal(2, latest.Revision); - Assert.Equal(new byte[] { 1, 2 }, await client.GetByteArrayAsync($"/api/nests/{current.Id}/revisions/1/file")); - Assert.Equal(new byte[] { 3, 4 }, await client.GetByteArrayAsync($"/api/nests/{current.Id}/file")); + Assert.Equal(TestNests.Archive(1, 2), await client.GetByteArrayAsync($"/api/nests/{current.Id}/revisions/1/file")); + Assert.Equal(TestNests.Archive(3, 4), await client.GetByteArrayAsync($"/api/nests/{current.Id}/file")); client.DefaultRequestHeaders.Remove("If-Match"); client.DefaultRequestHeaders.TryAddWithoutValidation("If-Match", "\"2\""); - using var retry = await client.PutAsync($"/api/nests/{current.Id}/file", Body(record, [3, 4])); + using var retry = await client.PutAsync($"/api/nests/{current.Id}/file", Body(record, TestNests.Archive(3, 4))); Assert.Equal(latest.SavedAt, (await retry.Content.ReadFromJsonAsync(Options))!.SavedAt); var page = await client.GetFromJsonAsync($"/api/nests/{current.Id}/revisions?limit=1", Options); Assert.Equal(2, page!.Total); @@ -67,12 +67,12 @@ public sealed class NestRevisionTests Guid id; using (var client = factory.CreateClient()) { - using var created = await client.PostAsync("/api/nests", Body(new NestRecord { Name = "Initial" }, [1])); + using var created = await client.PostAsync("/api/nests", Body(new NestRecord { Name = "Initial" }, TestNests.Archive(1))); id = (await created.Content.ReadFromJsonAsync(Options))!.Id; if (header is not null) client.DefaultRequestHeaders.TryAddWithoutValidation("If-Match", header); - using var response = await client.PutAsync($"/api/nests/{id}/file", Body(new NestRecord { Name = "Rejected" }, [2])); + using var response = await client.PutAsync($"/api/nests/{id}/file", Body(new NestRecord { Name = "Rejected" }, TestNests.Archive(2))); Assert.Equal(status, (int)response.StatusCode); - Assert.Equal(new byte[] { 1 }, await client.GetByteArrayAsync($"/api/nests/{id}/file")); + Assert.Equal(TestNests.Archive(1), await client.GetByteArrayAsync($"/api/nests/{id}/file")); } using var reopened = new NestDatabase(factory.DatabasePath, pooling: false); Assert.Equal("Initial", reopened.Get(id)!.Name); @@ -84,7 +84,7 @@ public sealed class NestRevisionTests { using var factory = new ServerFactory(); using var client = factory.CreateClient(); - using var created = await client.PostAsync("/api/nests", Body(new NestRecord { Name = "Initial" }, [1])); + using var created = await client.PostAsync("/api/nests", Body(new NestRecord { Name = "Initial" }, TestNests.Archive(1))); var current = (await created.Content.ReadFromJsonAsync(Options))!; using (var connection = new SqliteConnection($"Data Source={factory.DatabasePath};Pooling=False")) { @@ -94,9 +94,9 @@ public sealed class NestRevisionTests command.ExecuteNonQuery(); } client.DefaultRequestHeaders.TryAddWithoutValidation("If-Match", "\"1\""); - using var failed = await client.PutAsync($"/api/nests/{current.Id}/file", Body(new NestRecord { Name = "Rejected" }, [2])); + using var failed = await client.PutAsync($"/api/nests/{current.Id}/file", Body(new NestRecord { Name = "Rejected" }, TestNests.Archive(2))); Assert.Equal(HttpStatusCode.InternalServerError, failed.StatusCode); - Assert.Equal(new byte[] { 1 }, await client.GetByteArrayAsync($"/api/nests/{current.Id}/file")); + Assert.Equal(TestNests.Archive(1), await client.GetByteArrayAsync($"/api/nests/{current.Id}/file")); using var reopened = new NestDatabase(factory.DatabasePath, pooling: false); Assert.Equal("Initial", reopened.Get(current.Id)!.Name); Assert.Equal(1, reopened.History(current.Id, 0, 100)!.Total); @@ -106,7 +106,7 @@ public sealed class NestRevisionTests { using var factory = new ServerFactory(); var ids = new[] { Guid.NewGuid(), Guid.NewGuid() }; - var bytes = new[] { new byte[] { 0, 1, 255 }, new byte[] { 7, 8, 9, 0 } }; + var bytes = new[] { TestNests.Archive(0, 1, 255), TestNests.Archive(7, 8, 9, 0) }; var timestamp = new DateTime(2026, 1, 2, 3, 4, 5); using (var connection = new SqliteConnection($"Data Source={factory.DatabasePath};Pooling=False")) { @@ -166,11 +166,11 @@ public sealed class NestRevisionTests { using var factory = new ServerFactory(); using var client = factory.CreateClient(); - using var created = await client.PostAsync("/api/nests", Body(new NestRecord { Name = "Initial" }, [1])); + using var created = await client.PostAsync("/api/nests", Body(new NestRecord { Name = "Initial" }, TestNests.Archive(1))); var current = (await created.Content.ReadFromJsonAsync(Options))!; client.DefaultRequestHeaders.TryAddWithoutValidation("If-Match", "\"1\""); var responses = await Task.WhenAll(Enumerable.Range(2, 2).Select(i => - client.PutAsync($"/api/nests/{current.Id}/file", Body(new NestRecord { Name = $"Writer {i}" }, [(byte)i])))); + client.PutAsync($"/api/nests/{current.Id}/file", Body(new NestRecord { Name = $"Writer {i}" }, TestNests.Archive((byte)i))))); try { Assert.Single(responses.Where(r => r.StatusCode == HttpStatusCode.OK)); @@ -178,7 +178,7 @@ public sealed class NestRevisionTests Assert.Equal(2, (await conflict.Content.ReadFromJsonAsync(Options))!.Revision); var history = await client.GetFromJsonAsync($"/api/nests/{current.Id}/revisions", Options); Assert.Equal(new[] { 2, 1 }, history!.Items.Select(r => r.Revision)); - Assert.Equal(new byte[] { 1 }, await client.GetByteArrayAsync($"/api/nests/{current.Id}/revisions/1/file")); + Assert.Equal(TestNests.Archive(1), await client.GetByteArrayAsync($"/api/nests/{current.Id}/revisions/1/file")); } finally { foreach (var response in responses) response.Dispose(); } } @@ -192,19 +192,19 @@ public sealed class NestRevisionTests { using var factory = new ServerFactory(); using var client = factory.CreateClient(); - using var created = await client.PostAsync("/api/nests", Body(new NestRecord { Name = "Initial" }, [1])); + using var created = await client.PostAsync("/api/nests", Body(new NestRecord { Name = "Initial" }, TestNests.Archive(1))); var current = (await created.Content.ReadFromJsonAsync(Options))!; if (header is not null) client.DefaultRequestHeaders.TryAddWithoutValidation("If-Match", header); using var response = route == "delete" ? await client.DeleteAsync($"/api/nests/{current.Id}") : await client.PutAsync($"/api/nests/{current.Id}/{route}", new StringContent("Malformed")); Assert.Equal(status, (int)response.StatusCode); - Assert.Equal(new byte[] { 1 }, await client.GetByteArrayAsync($"/api/nests/{current.Id}/file")); + Assert.Equal(TestNests.Archive(1), await client.GetByteArrayAsync($"/api/nests/{current.Id}/file")); var history = await client.GetFromJsonAsync($"/api/nests/{current.Id}/revisions", Options); Assert.Equal(1, history!.Total); using var reopened = new NestDatabase(factory.DatabasePath, pooling: false); Assert.Equal("Initial", reopened.Get(current.Id)!.Name); - Assert.Equal(new byte[] { 1 }, reopened.GetRevisionFile(current.Id, 1)); + Assert.Equal(TestNests.Archive(1), reopened.GetRevisionFile(current.Id, 1)); } [Fact] @@ -214,7 +214,7 @@ public sealed class NestRevisionTests using var client = factory.CreateClient(); using var unknown = await client.GetAsync($"/api/nests/{Guid.NewGuid()}/revisions"); Assert.Equal(HttpStatusCode.NotFound, unknown.StatusCode); - using var created = await client.PostAsync("/api/nests", Body(new NestRecord { Name = "Initial" }, [1])); + using var created = await client.PostAsync("/api/nests", Body(new NestRecord { Name = "Initial" }, TestNests.Archive(1))); var current = (await created.Content.ReadFromJsonAsync(Options))!; using var missing = await client.GetAsync($"/api/nests/{current.Id}/revisions/2/file"); Assert.Equal(HttpStatusCode.NotFound, missing.StatusCode); diff --git a/OpenNest.Server.Tests/NestUploadValidationTests.cs b/OpenNest.Server.Tests/NestUploadValidationTests.cs index 3667680a..e43b2fb3 100644 --- a/OpenNest.Server.Tests/NestUploadValidationTests.cs +++ b/OpenNest.Server.Tests/NestUploadValidationTests.cs @@ -21,16 +21,16 @@ public sealed class NestUploadValidationTests using var factory = new ServerFactory(); using var client = factory.CreateClient(); var id = Guid.NewGuid(); - using var created = await client.PostAsync("/api/nests", NestRevisionTests.Body(new NestRecord { Id = id, Name = "Original" }, [1])); + using var created = await client.PostAsync("/api/nests", NestRevisionTests.Body(new NestRecord { Id = id, Name = "Original" }, TestNests.Archive(1))); Assert.Equal(HttpStatusCode.OK, created.StatusCode); - using var duplicate = await client.PostAsync("/api/nests", NestRevisionTests.Body(new NestRecord { Id = id, Name = "Replacement" }, [2])); + using var duplicate = await client.PostAsync("/api/nests", NestRevisionTests.Body(new NestRecord { Id = id, Name = "Replacement" }, TestNests.Archive(2))); Assert.Equal(HttpStatusCode.Conflict, duplicate.StatusCode); var current = (await duplicate.Content.ReadFromJsonAsync(Options))!; Assert.Equal(id, current.Id); Assert.Equal("Original", current.Name); - Assert.Equal(new byte[] { 1 }, await client.GetByteArrayAsync($"/api/nests/{id}/file")); + Assert.Equal(TestNests.Archive(1), await client.GetByteArrayAsync($"/api/nests/{id}/file")); using var reopened = new NestDatabase(factory.DatabasePath, pooling: false); Assert.Equal(1, reopened.History(id, 0, 100)!.Total); } @@ -41,20 +41,20 @@ public sealed class NestUploadValidationTests using var factory = new ServerFactory(); using var client = factory.CreateClient(); var id = Guid.NewGuid(); - using var created = await client.PostAsync("/api/nests", NestRevisionTests.Body(new NestRecord { Id = id, Name = "Original" }, [1])); + using var created = await client.PostAsync("/api/nests", NestRevisionTests.Body(new NestRecord { Id = id, Name = "Original" }, TestNests.Archive(1))); using var delete = new HttpRequestMessage(HttpMethod.Delete, $"/api/nests/{id}"); delete.Headers.TryAddWithoutValidation("If-Match", "\"1\""); using var deleted = await client.SendAsync(delete); Assert.Equal(HttpStatusCode.OK, deleted.StatusCode); - using var duplicate = await client.PostAsync("/api/nests", NestRevisionTests.Body(new NestRecord { Id = id, Name = "Replacement" }, [2])); + using var duplicate = await client.PostAsync("/api/nests", NestRevisionTests.Body(new NestRecord { Id = id, Name = "Replacement" }, TestNests.Archive(2))); Assert.Equal(HttpStatusCode.Conflict, duplicate.StatusCode); var current = (await duplicate.Content.ReadFromJsonAsync(Options))!; Assert.True(current.Deleted); Assert.Equal(2, current.Revision); Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync($"/api/nests/{id}")).StatusCode); - Assert.Equal(new byte[] { 1 }, await client.GetByteArrayAsync($"/api/nests/deleted/{id}/file")); + Assert.Equal(TestNests.Archive(1), await client.GetByteArrayAsync($"/api/nests/deleted/{id}/file")); } [Theory] @@ -71,7 +71,7 @@ public sealed class NestUploadValidationTests using var factory = new ServerFactory(); using var client = factory.CreateClient(); - using var response = await client.PostAsync("/api/nests", RawBody(metadata, [1])); + using var response = await client.PostAsync("/api/nests", RawBody(metadata, TestNests.Archive(1))); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); using var reopened = new NestDatabase(factory.DatabasePath, pooling: false); @@ -88,7 +88,7 @@ public sealed class NestUploadValidationTests using var factory = new ServerFactory(); using var client = factory.CreateClient(); - using var response = await client.PostAsync("/api/nests", RawBody(metadata, [1])); + using var response = await client.PostAsync("/api/nests", RawBody(metadata, TestNests.Archive(1))); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(expected, (await response.Content.ReadFromJsonAsync(Options))!.Status); @@ -104,7 +104,7 @@ public sealed class NestUploadValidationTests var id = await Create(client); client.DefaultRequestHeaders.TryAddWithoutValidation("If-Match", "\"1\""); - using var response = await client.PutAsync($"/api/nests/{id}/file", RawBody(metadata, [2])); + using var response = await client.PutAsync($"/api/nests/{id}/file", RawBody(metadata, TestNests.Archive(2))); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); await AssertUnchanged(factory, client, id); @@ -130,14 +130,14 @@ public sealed class NestUploadValidationTests private static async Task Create(HttpClient client) { - using var created = await client.PostAsync("/api/nests", NestRevisionTests.Body(new NestRecord { Name = "Original" }, [1])); + using var created = await client.PostAsync("/api/nests", NestRevisionTests.Body(new NestRecord { Name = "Original" }, TestNests.Archive(1))); Assert.Equal(HttpStatusCode.OK, created.StatusCode); return (await created.Content.ReadFromJsonAsync(Options))!.Id; } private static async Task AssertUnchanged(ServerFactory factory, HttpClient client, Guid id) { - Assert.Equal(new byte[] { 1 }, await client.GetByteArrayAsync($"/api/nests/{id}/file")); + Assert.Equal(TestNests.Archive(1), await client.GetByteArrayAsync($"/api/nests/{id}/file")); using var reopened = new NestDatabase(factory.DatabasePath, pooling: false); var current = reopened.Get(id)!; Assert.Equal("Original", current.Name); diff --git a/OpenNest.Server.Tests/TestNests.cs b/OpenNest.Server.Tests/TestNests.cs new file mode 100644 index 00000000..128f8be0 --- /dev/null +++ b/OpenNest.Server.Tests/TestNests.cs @@ -0,0 +1,32 @@ +using System.IO.Compression; +using System.Text; + +namespace OpenNest.Server.Tests; + +/// +/// Smallest archives the server accepts as nests. The tag names the nest, so different tags +/// give different bytes, and fixed entry times make the same tag give identical bytes; tests +/// compare stored archives exactly, as they did with arbitrary byte payloads. +/// +internal static class TestNests +{ + private static readonly DateTimeOffset EntryTime = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + + public static byte[] Archive(params byte[] tag) => + Zip("nest.json", $$"""{"version":2,"name":"tag {{Convert.ToHexString(tag)}}","drawings":[],"plates":[]}"""); + + /// A valid ZIP holding ; not necessarily a nest. + public static byte[] Zip(string name, string content) + { + using var stream = new MemoryStream(); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + var entry = archive.CreateEntry(name); + entry.LastWriteTime = EntryTime; + using var writer = new StreamWriter(entry.Open(), Encoding.UTF8); + writer.Write(content); + } + + return stream.ToArray(); + } +} diff --git a/OpenNest.Server/NestArchive.cs b/OpenNest.Server/NestArchive.cs new file mode 100644 index 00000000..71eabee3 --- /dev/null +++ b/OpenNest.Server/NestArchive.cs @@ -0,0 +1,126 @@ +using System.IO.Compression; +using System.Text.Json; +using OpenNest.Data; + +namespace OpenNest.Server; + +/// +/// Reads the facts a nest record states about its archive straight from the uploaded +/// .nest, so the stored record always describes the stored bytes. Only +/// nest.json is parsed: programs and geometry are never rebuilt, which keeps the +/// check cheap and keeps the server off OpenNest.IO. The fallbacks mirror +/// NestReader; NestArchiveTests pins them against real NestWriter output. +/// +internal static class NestArchive +{ + /// Largest nest.json the server will decompress. + public const int MaxNestJsonBytes = 64 * 1024 * 1024; + + private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web); + + /// + /// Overwrites the record's plate count, part count, material and thickness with the + /// archive's own. Returns an error when the bytes are not a readable nest archive. + /// + public static string? Apply(NestRecord record, byte[] file) + { + NestJson? nest; + try + { + using var archive = new ZipArchive(new MemoryStream(file, writable: false), ZipArchiveMode.Read); + var entry = archive.GetEntry("nest.json"); + if (entry is null) + return "The file is not a nest archive: it has no nest.json."; + if (entry.Length > MaxNestJsonBytes) + return "The nest archive's nest.json is too large."; + using var json = new MemoryStream(); + using (var stream = entry.Open()) + CopyBounded(stream, json); + json.Position = 0; + nest = JsonSerializer.Deserialize(json, Options); + } + catch (Exception ex) when (ex is InvalidDataException or JsonException or IOException) + { + return "The file is not a readable nest archive."; + } + + if (nest is null) + return "The nest archive's nest.json is empty."; + + var defaults = nest.PlateDefaults ?? new PlateDefaultsJson(); + // NestReader: an absent nest-level material is an empty one; only an explicit null + // falls back to the plate defaults. Thickness falls back when not positive. + record.Material = (nest.Material ?? defaults.Material)?.Name ?? ""; + record.Thickness = nest.Thickness > 0 ? nest.Thickness : defaults.Thickness; + + // NestReader drops parts whose drawing is missing. Cut-off parts are never written as + // parts, so every remaining part counts. + var drawingIds = (nest.Drawings ?? new()).Select(d => d.Id).ToHashSet(); + var plates = (nest.Plates ?? new()).OrderBy(p => p.Id).ToList(); + record.PartCount = plates.Sum(p => (p.Parts ?? new()).Count(part => drawingIds.Contains(part.DrawingId))); + // PlateDisplayNumbering.DisplayedPlateCount: a trailing plate with nothing on it is the + // editor's new-plate slot, not a plate of the nest. + var last = plates.LastOrDefault(); + var trailingEmpty = last is not null + && !(last.Parts ?? new()).Any(part => drawingIds.Contains(part.DrawingId)) + && (last.CutOffs ?? new()).Count == 0; + record.PlateCount = plates.Count - (trailingEmpty ? 1 : 0); + return null; + } + + // The declared entry size is untrusted; stop at the cap however much the data inflates. + private static void CopyBounded(Stream source, Stream destination) + { + var buffer = new byte[81920]; + long total = 0; + int read; + while ((read = source.Read(buffer, 0, buffer.Length)) > 0) + { + total += read; + if (total > MaxNestJsonBytes) + throw new InvalidDataException("nest.json exceeds the size limit."); + destination.Write(buffer, 0, read); + } + } + + private sealed class NestJson + { + public double Thickness { get; set; } + public MaterialJson? Material { get; set; } = new(); + public PlateDefaultsJson? PlateDefaults { get; set; } + public List? Drawings { get; set; } + public List? Plates { get; set; } + } + + private sealed class PlateDefaultsJson + { + public double Thickness { get; set; } + public MaterialJson? Material { get; set; } = new(); + } + + private sealed class MaterialJson + { + public string? Name { get; set; } = ""; + } + + private sealed class DrawingJson + { + public int Id { get; set; } + } + + private sealed class PlateJson + { + public int Id { get; set; } + public List? Parts { get; set; } + public List? CutOffs { get; set; } + } + + private sealed class PartJson + { + public int DrawingId { get; set; } + } + + private sealed class CutOffJson + { + } +} diff --git a/OpenNest.Server/NestDatabase.cs b/OpenNest.Server/NestDatabase.cs index c8af6537..5cada1d5 100644 --- a/OpenNest.Server/NestDatabase.cs +++ b/OpenNest.Server/NestDatabase.cs @@ -179,6 +179,12 @@ public sealed partial class NestDatabase : IDisposable var current = Get(id); if (current is null) return null; if (current.Revision != expectedRevision) throw new RevisionConflictException(current); + if (file is null) + { + // The counts describe the archive, which a metadata-only save leaves unchanged. + record.PlateCount = current.PlateCount; + record.PartCount = current.PartCount; + } file ??= GetFile(id)!; if (SameMetadata(current, record) && GetFile(id)!.AsSpan().SequenceEqual(file)) return current; diff --git a/OpenNest.Server/Program.cs b/OpenNest.Server/Program.cs index 5aeb2292..8c57959b 100644 --- a/OpenNest.Server/Program.cs +++ b/OpenNest.Server/Program.cs @@ -203,7 +203,12 @@ static async Task<(NestRecord? Record, byte[]? File, IResult? Error)> ReadMultip using var stream = filePart.OpenReadStream(); using var buffer = new MemoryStream((int)filePart.Length); await stream.CopyToAsync(buffer); - return (record, buffer.ToArray(), null); + var file = buffer.ToArray(); + + // Counts, material and thickness come from the archive itself, not the client's claim. + if (NestArchive.Apply(record, file) is { } archiveError) + return (null, null, Results.BadRequest(archiveError)); + return (record, file, null); } // Entry-point type for in-memory integration tests (WebApplicationFactory). diff --git a/docs/nest-storage.md b/docs/nest-storage.md index 98fbeee5..2cbe7204 100644 --- a/docs/nest-storage.md +++ b/docs/nest-storage.md @@ -70,6 +70,9 @@ carry string text fields (`null` is 400) and a defined `status`: a name, or for existing callers its integer value (0 quote, 1 toBeCut, 2 hasBeenCut, 3 draft); an undefined value is 400 and stores nothing. A server older than `draft` refuses it, so upgrade the server before desktops that can set it. +An uploaded `file` (POST and PUT `/file`) must be a `.nest` archive with a readable +`nest.json`, or the request is 400 and stores nothing. The server reads only that +entry, never programs or geometry, and takes the archive-derived fields below from it. | Field | Type | Notes | |---|---|---| @@ -77,11 +80,11 @@ so upgrade the server before desktops that can set it. | `name` | string | | | `customer` | string | | | `dateCreated` / `dateModified` | datetime | Set by the client from the nest's own metadata. | -| `material` | string | Material name (type), e.g. `Stainless`. | +| `material` | string | Material name (type), e.g. `Stainless`. Read from the uploaded archive (nest material, else plate defaults' on an explicit null); a metadata-only PUT may change it, and the desktop applies the record's value when opening the nest. | | `materialGrade` | string | Optional; default blank. Older servers drop it. A blank grade on a record saved before schema v8 is treated as unrecorded: opening keeps the archive's grade while the material name matches. | -| `thickness` | number | | +| `thickness` | number | Read from the uploaded archive (nest thickness, else plate defaults' when not positive); editable like `material`. | | `status` | `"draft"` \| `"quote"` \| `"toBeCut"` \| `"hasBeenCut"` | Default `quote`. | -| `plateCount` / `partCount` | integer | Computed client-side at save time (non-cutoff parts only). | +| `plateCount` / `partCount` | integer | Server-computed from the uploaded archive (non-cutoff parts only; a trailing plate with no parts or cut-offs is not counted); ignored on upload and kept by a metadata-only PUT. | | `comments` | string | | | `madeBy` | string | | | `fileSize` | integer | Server-computed; ignored on upload. | diff --git a/scripts/Server.Tests/Program.cs b/scripts/Server.Tests/Program.cs index 34cf8fe2..fd3a70d1 100644 --- a/scripts/Server.Tests/Program.cs +++ b/scripts/Server.Tests/Program.cs @@ -191,14 +191,14 @@ internal static class Program var before = await Snapshot(repository, ct); Check(before.Count > 0, "Limit checks require seeded records on an owned server."); - // Opaque synthetic bytes (the server does not parse archives) above the 64 KiB form - // memory threshold, so the non-root runtime must buffer the part to its temp directory. - var accepted = new byte[29_000_000]; - RandomNumberGenerator.Fill(accepted); + // A readable nest archive (the server parses its nest.json) padded with incompressible + // bytes above the 64 KiB form memory threshold, so the non-root runtime must buffer the + // part to its temp directory. + var accepted = PaddedArchive(29_000_000); var created = await repository.UploadAsync(accepted, new NestRecord { Name = "Synthetic upload-limit probe", - Comments = "Random bytes; deleted by the same smoke stage", + Comments = "Random padding; deleted by the same smoke stage", }, ct); Check(created.Id != Guid.Empty && created.FileSize == accepted.LongLength, "A near-limit upload must be stored with its exact size."); @@ -363,6 +363,23 @@ internal static class Program return stream.ToArray(); } + /// A minimal nest archive with a stored random entry, close to bytes. + private static byte[] PaddedArchive(int size) + { + using var stream = new MemoryStream(); + using (var zip = new System.IO.Compression.ZipArchive(stream, System.IO.Compression.ZipArchiveMode.Create, leaveOpen: true)) + { + using (var writer = new StreamWriter(zip.CreateEntry("nest.json").Open())) + writer.Write("""{"version":2,"name":"Synthetic upload-limit probe","drawings":[],"plates":[]}"""); + var padding = new byte[size - 1024]; + RandomNumberGenerator.Fill(padding); + using var entry = zip.CreateEntry("padding.bin", System.IO.Compression.CompressionLevel.NoCompression).Open(); + entry.Write(padding); + } + + return stream.ToArray(); + } + private static GeometryState Geometry(Nest nest) => new() { PlateWidth = nest.Plates[0].Size.Width,