Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions CredentialCache.Test/CredentialCacheTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,51 @@ public void AddOrReplacePersistsCredentialToBackingStore()
Assert.AreEqual("hunter2", typed.Password.ToString());
}

[TestMethod]
public void AddOrReplaceDoesNotCacheCredentialWhenStoreSaveThrows()
{
ThrowingCredentialStore store = new() { ThrowOnSave = true };
using CredentialCache cache = new(store);
PersonaGUID guid = CredentialCache.CreatePersonaGUID();

Assert.Throws<CredentialStoreException>(() => cache.AddOrReplace(guid, new CredentialWithNothing()));

Assert.IsFalse(
cache.TryGet(guid, out Credential? credential),
"A credential the store refused to persist must not be served from the in-memory cache");
Assert.IsNull(credential);
}

[TestMethod]
public void AddOrReplaceKeepsThePersistedCredentialWhenReplacementFailsToSave()
{
ThrowingCredentialStore store = new();
using CredentialCache cache = new(store);
PersonaGUID guid = CredentialCache.CreatePersonaGUID();
CredentialWithUsernamePassword persisted = new()
{
Username = SemanticString<CredentialUsername>.Create("alice"),
Password = SemanticString<CredentialPassword>.Create("hunter2"),
};
CredentialWithUsernamePassword replacement = new()
{
Username = SemanticString<CredentialUsername>.Create("alice"),
Password = SemanticString<CredentialPassword>.Create("oversized"),
};

cache.AddOrReplace(guid, persisted);
store.ThrowOnSave = true;
Assert.Throws<CredentialStoreException>(() => cache.AddOrReplace(guid, replacement));

Assert.IsTrue(cache.TryGet(guid, out Credential? credential));
CredentialWithUsernamePassword? typed = credential as CredentialWithUsernamePassword;
Assert.IsNotNull(typed);
Assert.AreEqual(
"hunter2",
typed!.Password.ToString(),
"The cache must keep the credential the store actually holds, not the one whose save failed");
}

[TestMethod]
public void RemoveDeletesCredentialFromBothLayers()
{
Expand Down Expand Up @@ -268,6 +313,32 @@ public class CredentialWithNothingFactory : ICredentialFactory<CredentialWithNot
public CredentialWithNothing Create() => new();
}

/// <summary>
/// An in-memory store whose <see cref="Save"/> can be made to fail on demand, standing in
/// for a native store that rejects a write (oversized blob, transient keychain failure).
/// </summary>
public sealed class ThrowingCredentialStore : ICredentialStore
{
private readonly InMemoryCredentialStore _inner = new();

public bool ThrowOnSave { get; set; }

public string Name => "Throwing";

public bool TryLoad(PersonaGUID persona, out Credential? credential) => _inner.TryLoad(persona, out credential);

public void Save(PersonaGUID persona, Credential credential)
{
if (ThrowOnSave)
{
throw new CredentialStoreException("Simulated store failure.");
}
_inner.Save(persona, credential);
}

public bool Remove(PersonaGUID persona) => _inner.Remove(persona);
}

public class AnotherCredential : Credential { }

public class AnotherCredentialFactory : ICredentialFactory<AnotherCredential>
Expand Down
7 changes: 6 additions & 1 deletion CredentialCache/CredentialCache.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
/// <summary>
/// Represents a globally unique identifier for a persona.
/// </summary>
public sealed record class PersonaGUID : SemanticString<PersonaGUID> { }

Check warning on line 12 in CredentialCache/CredentialCache.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Rename record 'PersonaGUID' to match pascal case naming rules, consider using 'PersonaGuid'.

Check warning on line 12 in CredentialCache/CredentialCache.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Rename record 'PersonaGUID' to match pascal case naming rules, consider using 'PersonaGuid'.

Check warning on line 12 in CredentialCache/CredentialCache.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Rename record 'PersonaGUID' to match pascal case naming rules, consider using 'PersonaGuid'.

Check warning on line 12 in CredentialCache/CredentialCache.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Rename record 'PersonaGUID' to match pascal case naming rules, consider using 'PersonaGuid'.

Check warning on line 12 in CredentialCache/CredentialCache.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Rename record 'PersonaGUID' to match pascal case naming rules, consider using 'PersonaGuid'.

Check warning on line 12 in CredentialCache/CredentialCache.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Rename record 'PersonaGUID' to match pascal case naming rules, consider using 'PersonaGuid'.

Check warning on line 12 in CredentialCache/CredentialCache.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Rename record 'PersonaGUID' to match pascal case naming rules, consider using 'PersonaGuid'.

Check warning on line 12 in CredentialCache/CredentialCache.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Rename record 'PersonaGUID' to match pascal case naming rules, consider using 'PersonaGuid'.

/// <summary>
/// Caches <see cref="Credential"/> instances in memory and persists each one through
Expand Down Expand Up @@ -141,14 +141,19 @@
/// <summary>
/// Adds or replaces the credential for <paramref name="persona"/> and persists it.
/// </summary>
/// <remarks>
/// The credential is persisted before the in-memory cache is updated, so a store
/// that throws leaves the cache untouched rather than serving a credential that
/// was never written to the backing store.
/// </remarks>
public void AddOrReplace(PersonaGUID persona, Credential credential)
{
ArgumentNullException.ThrowIfNull(persona);
ArgumentNullException.ThrowIfNull(credential);
ThrowIfDisposed();

_credentials[persona] = credential;
Store.Save(persona, credential);
_credentials[persona] = credential;
}

/// <summary>
Expand Down
Loading