// Ported from golang/nats-server/server/jetstream_test.go // Go reference: server/stream.go:1500-1600 (stream.update immutable field validation) // Covers: TestJetStreamStreamUpdate, TestJetStreamStreamUpdateMaxConsumers using NATS.Server.JetStream; using NATS.Server.JetStream.Api; using NATS.Server.JetStream.Models; using Shouldly; namespace NATS.Server.JetStream.Tests.JetStream.Streams; public class ConfigUpdateValidationTests { // Go ref: server/stream.go:1500-1600 (stream.update) // A valid update that only changes mutable fields (MaxMsgs) should produce no errors. [Fact] public void ValidateConfigUpdate_allows_valid_changes() { var existing = new StreamConfig { Name = "ORDERS", Storage = StorageType.Memory, Retention = RetentionPolicy.Limits, Subjects = ["orders.*"], MaxMsgs = 100, }; var proposed = new StreamConfig { Name = "ORDERS", Storage = StorageType.Memory, Retention = RetentionPolicy.Limits, Subjects = ["orders.*"], MaxMsgs = 500, }; var errors = StreamManager.ValidateConfigUpdate(existing, proposed); errors.ShouldBeEmpty(); } // Go ref: server/stream.go:1511-1513 (storage type immutability check) // Changing storage type from Memory to File must be rejected. [Fact] public void ValidateConfigUpdate_rejects_storage_type_change() { var existing = new StreamConfig { Name = "ORDERS", Storage = StorageType.Memory, Subjects = ["orders.*"], }; var proposed = new StreamConfig { Name = "ORDERS", Storage = StorageType.File, Subjects = ["orders.*"], }; var errors = StreamManager.ValidateConfigUpdate(existing, proposed); errors.ShouldContain(e => e.Contains("storage type")); } // Go ref: server/stream.go:1530-1535 (mirror immutability) // Changing the mirror origin must be rejected. [Fact] public void ValidateConfigUpdate_rejects_mirror_change() { var existing = new StreamConfig { Name = "MIRROR_STREAM", Storage = StorageType.Memory, Mirror = "ORIGIN_A", }; var proposed = new StreamConfig { Name = "MIRROR_STREAM", Storage = StorageType.Memory, Mirror = "ORIGIN_B", }; var errors = StreamManager.ValidateConfigUpdate(existing, proposed); errors.ShouldContain(e => e.Contains("mirror configuration")); } // Go ref: server/stream.go:1520-1525 (retention policy immutability) // Changing the retention policy must be rejected. [Fact] public void ValidateConfigUpdate_rejects_retention_change() { var existing = new StreamConfig { Name = "ORDERS", Storage = StorageType.Memory, Retention = RetentionPolicy.Limits, Subjects = ["orders.*"], }; var proposed = new StreamConfig { Name = "ORDERS", Storage = StorageType.Memory, Retention = RetentionPolicy.WorkQueue, Subjects = ["orders.*"], }; var errors = StreamManager.ValidateConfigUpdate(existing, proposed); errors.ShouldContain(e => e.Contains("retention policy")); } // Go ref: server/stream.go:1500-1502 (sealed stream guard) // Any modification attempt on a sealed stream must be rejected. [Fact] public void ValidateConfigUpdate_rejects_sealed_stream_changes() { var existing = new StreamConfig { Name = "SEALED", Storage = StorageType.Memory, Sealed = true, Subjects = ["sealed.*"], }; var proposed = new StreamConfig { Name = "SEALED", Storage = StorageType.Memory, Sealed = true, Subjects = ["sealed.new.*"], }; var errors = StreamManager.ValidateConfigUpdate(existing, proposed); errors.ShouldContain(e => e.Contains("sealed stream")); } // Go ref: server/stream.go:1537-1542 (sources immutability) // Changing the sources list after creation must be rejected. [Fact] public void ValidateConfigUpdate_rejects_source_change() { var existing = new StreamConfig { Name = "AGG", Storage = StorageType.Memory, Sources = [ new StreamSourceConfig { Name = "SRC_A" }, new StreamSourceConfig { Name = "SRC_B" }, ], }; var proposed = new StreamConfig { Name = "AGG", Storage = StorageType.Memory, Sources = [ new StreamSourceConfig { Name = "SRC_A" }, new StreamSourceConfig { Name = "SRC_C" }, ], }; var errors = StreamManager.ValidateConfigUpdate(existing, proposed); errors.ShouldContain(e => e.Contains("sources cannot be changed")); } // Go ref: server/jetstream.go — subject overlap detection between streams. // Proposing subjects that collide with another stream's subjects must be rejected. [Fact] public void ValidateConfigUpdate_detects_subject_overlap() { var existing = new StreamConfig { Name = "ORDERS", Storage = StorageType.Memory, Subjects = ["orders.*"], }; var proposed = new StreamConfig { Name = "ORDERS", Storage = StorageType.Memory, Subjects = ["orders.>"], }; var otherStreams = new[] { new StreamConfig { Name = "ARCHIVE", Storage = StorageType.Memory, Subjects = ["orders.archived"], }, }; var errors = StreamManager.ValidateConfigUpdate(existing, proposed, otherStreams); errors.ShouldContain(e => e.Contains("ARCHIVE")); } // Go ref: server/jetstream.go — no error for non-overlapping subject sets. // Proposing subjects that do not overlap with other streams must succeed. [Fact] public void ValidateConfigUpdate_allows_non_overlapping_subjects() { var existing = new StreamConfig { Name = "ORDERS", Storage = StorageType.Memory, Subjects = ["orders.*"], }; var proposed = new StreamConfig { Name = "ORDERS", Storage = StorageType.Memory, Subjects = ["orders.>"], }; var otherStreams = new[] { new StreamConfig { Name = "EVENTS", Storage = StorageType.Memory, Subjects = ["events.*"], }, }; var errors = StreamManager.ValidateConfigUpdate(existing, proposed, otherStreams); errors.ShouldBeEmpty(); } // Go ref: server/stream.go — MaxConsumers may not be decreased. // Decreasing MaxConsumers from a positive value must be rejected. [Fact] public void ValidateConfigUpdate_rejects_max_consumers_decrease() { var existing = new StreamConfig { Name = "ORDERS", Storage = StorageType.Memory, Subjects = ["orders.*"], MaxConsumers = 10, }; var proposed = new StreamConfig { Name = "ORDERS", Storage = StorageType.Memory, Subjects = ["orders.*"], MaxConsumers = 5, }; var errors = StreamManager.ValidateConfigUpdate(existing, proposed); errors.ShouldContain(e => e.Contains("max consumers can only be increased")); } // Go ref: server/stream.go — MaxConsumers may be raised without restriction. [Fact] public void ValidateConfigUpdate_allows_max_consumers_increase() { var existing = new StreamConfig { Name = "ORDERS", Storage = StorageType.Memory, Subjects = ["orders.*"], MaxConsumers = 5, }; var proposed = new StreamConfig { Name = "ORDERS", Storage = StorageType.Memory, Subjects = ["orders.*"], MaxConsumers = 20, }; var errors = StreamManager.ValidateConfigUpdate(existing, proposed); errors.ShouldBeEmpty(); } // Go ref: server/stream.go — Go server supports even replica counts (e.g., R2). // Even replicas should be accepted by config update validation. [Fact] public void ValidateConfigUpdate_accepts_even_replicas() { var existing = new StreamConfig { Name = "ORDERS", Storage = StorageType.Memory, Subjects = ["orders.*"], Replicas = 1, }; var proposed = new StreamConfig { Name = "ORDERS", Storage = StorageType.Memory, Subjects = ["orders.*"], Replicas = 2, }; var errors = StreamManager.ValidateConfigUpdate(existing, proposed); errors.ShouldBeEmpty(); } // Go ref: server/stream.go:1500-1600 (stream.update) — integration via StreamManager. // CreateOrUpdate must reject an update that changes storage type. [Fact] public void CreateOrUpdate_rejects_invalid_config_update() { var manager = new StreamManager(); var createResult = manager.CreateOrUpdate(new StreamConfig { Name = "EVENTS", Storage = StorageType.Memory, Subjects = ["events.*"], }); createResult.Error.ShouldBeNull(); var updateResult = manager.CreateOrUpdate(new StreamConfig { Name = "EVENTS", Storage = StorageType.File, Subjects = ["events.*"], }); updateResult.Error.ShouldNotBeNull(); updateResult.Error!.Description.ShouldContain("storage type"); } }