diff --git a/CredentialCache.Test/NullPropertyTests.cs b/CredentialCache.Test/NullPropertyTests.cs new file mode 100644 index 0000000..ffbb2f7 --- /dev/null +++ b/CredentialCache.Test/NullPropertyTests.cs @@ -0,0 +1,84 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.CredentialCache.Test; + +using System.Text; +using ktsu.CredentialCache.Storage; +using ktsu.Semantics.Strings; + +/// +/// Tests that a credential whose non-nullable property is null is neither read back as found +/// nor written to a store, so no caller is handed a credential that throws on first use. +/// +[TestClass] +public class NullPropertyTests +{ + private const string NullToken = "{\"$type\":\"CredentialWithToken\",\"Token\":null}"; + private const string NullUsername = "{\"$type\":\"CredentialWithUsernamePassword\",\"Username\":null,\"Password\":\"hunter2\"}"; + private const string NullPassword = "{\"$type\":\"CredentialWithUsernamePassword\",\"Username\":\"alice\",\"Password\":null}"; + + [TestMethod] + [DataRow(NullToken)] + [DataRow(NullUsername)] + [DataRow(NullPassword)] + public void DeserializeReturnsNullForANullNonNullableProperty(string json) => + Assert.IsNull(CredentialSerialization.Deserialize(Encoding.UTF8.GetBytes(json))); + + [TestMethod] + [DataRow(NullToken)] + [DataRow(NullUsername)] + [DataRow(NullPassword)] + public void DeserializeFromStringReturnsNullForANullNonNullableProperty(string json) => + Assert.IsNull(CredentialSerialization.DeserializeFromString(json)); + + [TestMethod] + [DataRow("{\"$type\":\"CredentialWithToken\",\"Token\":\"abc\"}")] + [DataRow("{\"$type\":\"CredentialWithUsernamePassword\",\"Username\":\"alice\",\"Password\":\"hunter2\"}")] + [DataRow("{\"$type\":\"CredentialWithNothing\"}")] + public void DeserializeStillReadsCompleteCredentials(string json) => + Assert.IsNotNull(CredentialSerialization.Deserialize(Encoding.UTF8.GetBytes(json))); + + [TestMethod] + [DataRow(NullToken)] + [DataRow(NullUsername)] + [DataRow(NullPassword)] + public void TryGetReturnsFalseForAStoredEntryWithANullNonNullableProperty(string json) + { + RawBlobCredentialStore store = new(); + using CredentialCache cache = new(store); + PersonaGUID persona = CredentialCache.CreatePersonaGUID(); + store.Blobs[persona] = Encoding.UTF8.GetBytes(json); + + bool found = cache.TryGet(persona, out Credential? credential); + + Assert.IsFalse(found); + Assert.IsNull(credential); + } + + [TestMethod] + public void SerializeRejectsANullNonNullableProperty() + { + CredentialWithToken credential = new() { Token = null! }; + + Assert.ThrowsExactly(() => CredentialSerialization.Serialize(credential)); + Assert.ThrowsExactly(() => CredentialSerialization.SerializeToString(credential)); + } + + [TestMethod] + public void AddOrReplaceRejectsANullNonNullablePropertyAndLeavesTheStoreAndCacheUnchanged() + { + RawBlobCredentialStore store = new(); + using CredentialCache cache = new(store); + PersonaGUID persona = CredentialCache.CreatePersonaGUID(); + CredentialWithUsernamePassword credential = new() + { + Username = SemanticString.Create("alice"), + Password = null!, + }; + + Assert.ThrowsExactly(() => cache.AddOrReplace(persona, credential)); + + Assert.IsFalse(store.Blobs.ContainsKey(persona)); + Assert.IsFalse(cache.TryGet(persona, out _)); + } +} diff --git a/CredentialCache/CredentialCache.cs b/CredentialCache/CredentialCache.cs index b5ffca9..854c7d4 100644 --- a/CredentialCache/CredentialCache.cs +++ b/CredentialCache/CredentialCache.cs @@ -197,6 +197,10 @@ public bool TryGet(PersonaGUID persona, out Credential? credential) /// registered with . The /// cache and the store are left unchanged. /// + /// + /// The store serializes credentials and has a non-nullable + /// property set to null. The cache and the store are left unchanged. + /// public void AddOrReplace(PersonaGUID persona, Credential credential) { ArgumentNullException.ThrowIfNull(persona); diff --git a/CredentialCache/Storage/CredentialSerialization.cs b/CredentialCache/Storage/CredentialSerialization.cs index 8a0707d..a72b3bc 100644 --- a/CredentialCache/Storage/CredentialSerialization.cs +++ b/CredentialCache/Storage/CredentialSerialization.cs @@ -72,6 +72,10 @@ private static JsonSerializerOptions BuildOptions(Dictionary deriv // Persuade System.Text.Json to use the SemanticString factory methods (Create / FromString) // rather than treating SemanticString as an IEnumerable collection. built.Converters.Add(new RoundTripStringJsonConverterFactory()); + // Refuse a JSON null for a non-nullable property, in both directions. Otherwise a stored + // "Token":null reads back as a found credential whose Token throws on first use, and a + // credential with a null! property is persisted and round-trips. + built.RespectNullableAnnotations = true; return built; } @@ -169,10 +173,22 @@ internal static void ThrowIfUnknownType(Credential credential) /// The credential's type is neither one of the library's own nor registered with /// . /// + /// + /// has a non-nullable property set to null, so it could + /// not be read back. + /// public static byte[] Serialize(Credential credential) { ThrowIfUnknownType(credential); - return JsonSerializer.SerializeToUtf8Bytes(credential, options); + + try + { + return JsonSerializer.SerializeToUtf8Bytes(credential, options); + } + catch (JsonException exception) + { + throw NotSerializable(credential, exception); + } } /// @@ -182,12 +198,27 @@ public static byte[] Serialize(Credential credential) /// The credential's type is neither one of the library's own nor registered with /// . /// + /// + /// has a non-nullable property set to null, so it could + /// not be read back. + /// public static string SerializeToString(Credential credential) { ThrowIfUnknownType(credential); - return JsonSerializer.Serialize(credential, options); + + try + { + return JsonSerializer.Serialize(credential, options); + } + catch (JsonException exception) + { + throw NotSerializable(credential, exception); + } } + private static ArgumentException NotSerializable(Credential credential, JsonException exception) => + new($"{credential.GetType()} cannot be stored: {exception.Message}", nameof(credential), exception); + /// /// Deserializes a credential from a UTF-8 JSON byte array. Returns null if the bytes /// do not represent a known credential.