forked from mirrors/jellyfin-plugin-sso
e9bc573e7d
ci/woodpecker/pr/ci Pipeline was successful
LINQ buffering of a ConcurrentDictionary is not atomic, so a concurrent write made Evict() throw and fail the login.
144 lines
4.4 KiB
C#
144 lines
4.4 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Threading.Tasks;
|
|
using Duende.IdentityModel.OidcClient;
|
|
using Jellyfin.Plugin.SSO_Auth.Api;
|
|
using Jellyfin.Plugin.SSO_Auth.State;
|
|
using Xunit;
|
|
|
|
namespace Jellyfin.Plugin.SSO_Auth.Tests;
|
|
|
|
public class InMemorySsoStateStoreTests
|
|
{
|
|
private static readonly TimeSpan Expiry = TimeSpan.FromSeconds(300);
|
|
|
|
[Fact]
|
|
public async Task GetAsync_WhenUnknown_ReturnsNull()
|
|
{
|
|
Assert.Null(await new InMemorySsoStateStore().GetAsync("nope"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Mutations_AreOnlyVisibleOnceWrittenBack()
|
|
{
|
|
var store = new InMemorySsoStateStore();
|
|
await store.CreateAsync(NewState("abc123"), Expiry);
|
|
|
|
var dropped = await store.GetAsync("abc123");
|
|
dropped.Valid = true;
|
|
dropped.Username = "mallory";
|
|
|
|
// Not written back, so the mutation must not be observable.
|
|
var reread = await store.GetAsync("abc123");
|
|
Assert.False(reread.Valid);
|
|
Assert.Null(reread.Username);
|
|
|
|
reread.Valid = true;
|
|
reread.Username = "alice";
|
|
reread.Admin = true;
|
|
reread.Folders = new List<string> { "movies" };
|
|
await store.UpdateAsync(reread, Expiry);
|
|
|
|
var redeemed = await store.ConsumeAsync("abc123");
|
|
Assert.True(redeemed.Valid);
|
|
Assert.Equal("alice", redeemed.Username);
|
|
Assert.True(redeemed.Admin);
|
|
Assert.Equal(new[] { "movies" }, redeemed.Folders);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ConsumeAsync_RedeemsOnlyOnce()
|
|
{
|
|
var store = new InMemorySsoStateStore();
|
|
await store.CreateAsync(NewState("abc123"), Expiry);
|
|
|
|
Assert.NotNull(await store.ConsumeAsync("abc123"));
|
|
Assert.Null(await store.ConsumeAsync("abc123"));
|
|
Assert.Null(await store.GetAsync("abc123"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ExpiredState_IsNotReturned()
|
|
{
|
|
var store = new InMemorySsoStateStore();
|
|
await store.CreateAsync(NewState("abc123"), TimeSpan.FromMilliseconds(-1));
|
|
|
|
Assert.Null(await store.GetAsync("abc123"));
|
|
Assert.Empty(await store.ListAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ListAsync_ReturnsLiveStates()
|
|
{
|
|
var store = new InMemorySsoStateStore();
|
|
await store.CreateAsync(NewState("abc123"), Expiry);
|
|
await store.CreateAsync(NewState("def456"), Expiry);
|
|
|
|
var states = await store.ListAsync();
|
|
|
|
Assert.Equal(2, states.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Writing_SweepsExpiredEntriesOutOfTheStore()
|
|
{
|
|
var store = new InMemorySsoStateStore();
|
|
await store.CreateAsync(NewState("stale1"), TimeSpan.FromMilliseconds(-1));
|
|
await store.CreateAsync(NewState("stale2"), TimeSpan.FromMilliseconds(-1));
|
|
await store.CreateAsync(NewState("stale3"), TimeSpan.FromMilliseconds(-1));
|
|
|
|
await store.CreateAsync(NewState("live"), Expiry);
|
|
|
|
Assert.Equal(1, store.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Writing_BoundsTheStoreAtItsCapacity()
|
|
{
|
|
var store = new InMemorySsoStateStore();
|
|
|
|
for (var i = 0; i < InMemorySsoStateStore.Capacity + 64; i++)
|
|
{
|
|
await store.CreateAsync(NewState($"state{i}"), Expiry);
|
|
}
|
|
|
|
Assert.Equal(InMemorySsoStateStore.Capacity, store.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Writing_AtCapacity_IsSafeUnderConcurrency()
|
|
{
|
|
var store = new InMemorySsoStateStore();
|
|
|
|
for (var i = 0; i < InMemorySsoStateStore.Capacity; i++)
|
|
{
|
|
await store.CreateAsync(NewState($"seed{i}"), Expiry);
|
|
}
|
|
|
|
const int Writers = 8;
|
|
|
|
var writers = Enumerable.Range(0, Writers).Select(writer => Task.Run(async () =>
|
|
{
|
|
for (var i = 0; i < 2000; i++)
|
|
{
|
|
var state = NewState($"w{writer}-{i}");
|
|
await store.CreateAsync(state, Expiry);
|
|
await store.UpdateAsync(state, Expiry);
|
|
}
|
|
}));
|
|
|
|
await Task.WhenAll(writers);
|
|
|
|
// Each in-flight writer can miss or repeat one eviction, so the cap holds to within the writer count.
|
|
Assert.InRange(store.Count, InMemorySsoStateStore.Capacity - Writers, InMemorySsoStateStore.Capacity + Writers);
|
|
}
|
|
|
|
private static TimedAuthorizeState NewState(string state)
|
|
{
|
|
return new TimedAuthorizeState(
|
|
new AuthorizeState { State = state, CodeVerifier = "code-verifier-secret" },
|
|
DateTime.UtcNow);
|
|
}
|
|
}
|