// Copyright 2012-2026 The NATS Authors // Licensed under the Apache License, Version 2.0 using Shouldly; using System.Text.Json; using ZB.MOM.NatsNet.Server.Auth.CertificateIdentityProvider; using ZB.MOM.NatsNet.Server.Auth.Ocsp; namespace ZB.MOM.NatsNet.Server.Tests.Auth; public sealed class OcspResponseCacheTests { [Fact] public void LocalDirCache_PutReplaceDelete_AdjustsStats() { var cache = new LocalDirCache(Path.Combine(Path.GetTempPath(), $"ocsp-{Guid.NewGuid():N}")); cache.Start(); cache.Put("k1", CreateResponse(OcspStatusAssertion.Good, [1, 2, 3]), "subj"); cache.Put("k1", CreateResponse(OcspStatusAssertion.Revoked, [4, 5, 6]), "subj"); cache.Put("k2", CreateResponse(OcspStatusAssertion.Unknown, [7, 8]), "subj"); var statsAfterPut = cache.Stats(); statsAfterPut.ShouldNotBeNull(); statsAfterPut.Responses.ShouldBe(2); statsAfterPut.Goods.ShouldBe(0); statsAfterPut.Revokes.ShouldBe(1); statsAfterPut.Unknowns.ShouldBe(1); cache.Delete("k1", wasMiss: false); var statsAfterDelete = cache.Stats(); statsAfterDelete.ShouldNotBeNull(); statsAfterDelete.Responses.ShouldBe(1); statsAfterDelete.Revokes.ShouldBe(0); statsAfterDelete.Unknowns.ShouldBe(1); } [Fact] public void LocalDirCache_DeletePreserveRevoked_WithMiss_AdjustsHitToMiss() { var config = OcspHandler.NewOCSPResponseCacheConfig(); config.PreserveRevoked = true; var cache = new LocalDirCache(config); cache.Start(); cache.Put("k1", CreateResponse(OcspStatusAssertion.Revoked, [9, 9, 9]), "subj"); cache.Get("k1").ShouldNotBeNull(); cache.Delete("k1", wasMiss: true); var stats = cache.Stats(); stats.ShouldNotBeNull(); stats.Responses.ShouldBe(1); stats.Revokes.ShouldBe(1); stats.Hits.ShouldBe(0); stats.Misses.ShouldBe(1); cache.Get("k1").ShouldNotBeNull(); } [Fact] public void LocalDirCache_OnlineTypeConfigStats_FollowLifecycle() { var config = OcspHandler.NewOCSPResponseCacheConfig(); var cache = new LocalDirCache(config); cache.Online().ShouldBeFalse(); cache.Type().ShouldBe("local"); cache.Config().LocalStore.ShouldBe(config.LocalStore); cache.Stats().ShouldBeNull(); cache.Start(); cache.Online().ShouldBeTrue(); cache.Stats().ShouldNotBeNull(); cache.Stop(); cache.Online().ShouldBeFalse(); } [Fact] public void LocalDirCache_CompressDecompress_RoundTripsPayload() { var cache = new LocalDirCache(Path.Combine(Path.GetTempPath(), $"ocsp-{Guid.NewGuid():N}")); var payload = "ocsp-cache-roundtrip-data"u8.ToArray(); var (compressed, compressError) = cache.Compress(payload); compressError.ShouldBeNull(); compressed.ShouldNotBeNull(); var (decompressed, decompressError) = cache.Decompress(compressed!); decompressError.ShouldBeNull(); decompressed.ShouldNotBeNull(); decompressed.ShouldBe(payload); } [Fact] public void LocalDirCache_LoadCache_MissingFile_LeavesCacheEmpty() { var dir = CreateTempDir(); try { var cache = new LocalDirCache(dir); var server = NewServer(new ServerOptions { NoSystemAccount = true }); cache.LoadCache(server); cache.Start(); var stats = cache.Stats(); stats.ShouldNotBeNull(); stats.Responses.ShouldBe(0); } finally { Directory.Delete(dir, recursive: true); } } [Fact] public void LocalDirCache_LoadCache_ValidFile_PopulatesCache() { var dir = CreateTempDir(); try { var cache = new LocalDirCache(dir); var cacheFile = Path.Combine(dir, "cache.json"); var seed = new Dictionary { ["k1"] = new() { Subject = "subj", CachedAt = DateTime.UtcNow, RespStatus = OcspStatusAssertion.Good, RespExpires = DateTime.UtcNow.AddMinutes(5), Resp = cache.Compress([1, 2, 3]).compressed!, }, }; File.WriteAllBytes(cacheFile, JsonSerializer.SerializeToUtf8Bytes(seed)); var server = NewServer(new ServerOptions { NoSystemAccount = true }); cache.LoadCache(server); cache.Start(); cache.Get("k1").ShouldBe([1, 2, 3]); cache.Stats()!.Responses.ShouldBe(1); } finally { Directory.Delete(dir, recursive: true); } } [Fact] public void LocalDirCache_SaveCache_DirtyWritesCacheFile() { var dir = CreateTempDir(); try { var cache = new LocalDirCache(dir); var server = NewServer(new ServerOptions { NoSystemAccount = true }); cache.Start(); cache.Put("k1", CreateResponse(OcspStatusAssertion.Good, [4, 5, 6]), "subj"); cache.SaveCache(server); File.Exists(Path.Combine(dir, "cache.json")).ShouldBeTrue(); } finally { Directory.Delete(dir, recursive: true); } } [Fact] public void NoOpCache_LifecycleAndStats_ShouldNoOpSafely() { var noOp = new NoOpCache(); noOp.Online().ShouldBeFalse(); noOp.Type().ShouldBe("none"); noOp.Config().ShouldNotBeNull(); noOp.Stats().ShouldBeNull(); noOp.Start(); noOp.Online().ShouldBeTrue(); noOp.Stats().ShouldNotBeNull(); noOp.Put("k", [5]); noOp.Get("k").ShouldBeNull(); noOp.Remove("k"); noOp.Delete("k"); noOp.Stop(); noOp.Online().ShouldBeFalse(); } private static OcspResponse CreateResponse(OcspStatusAssertion status, byte[] raw) => new() { Status = status, ThisUpdate = DateTime.UtcNow.AddMinutes(-1), NextUpdate = DateTime.UtcNow.AddMinutes(10), Raw = raw, }; private static NatsServer NewServer(ServerOptions options) { var (server, err) = NatsServer.NewServer(options); err.ShouldBeNull(); server.ShouldNotBeNull(); return server!; } private static string CreateTempDir() { var dir = Path.Combine(Path.GetTempPath(), "ocsp-cache-" + Path.GetRandomFileName()); Directory.CreateDirectory(dir); return dir; } }