Files
ersatztv/ErsatzTV.Tests/Services/ApiKeyProviderTests.cs
T
timothyandClaude Opus 4.8 ee6be81c22
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m28s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m28s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m42s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 5m47s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 6m8s
test(#197): cover ApiKeyProvider unreadable-file rethrow + deleted-during-read race
Re-review of the fix commit (MERGEABLE-WITH-NITS) noted the headline L3 rethrow branch
itself had no test. Add a reader seam (internal ResolveKey Func overload) and two tests:
unreadable existing file throws + does not overwrite; a delete race between File.Exists
and the read falls back to generate rather than failing boot.

Refs #197 #280

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:22:26 +02:00

145 lines
4.6 KiB
C#

using System.Collections.Generic;
using System.IO;
using ErsatzTV.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Services;
[TestFixture]
public class ApiKeyProviderTests
{
private DirectoryInfo _tempDir = null!;
private string _keyPath = null!;
[SetUp]
public void SetUp()
{
_tempDir = Directory.CreateTempSubdirectory("etv-apikey-tests");
_keyPath = Path.Combine(_tempDir.FullName, "api.key");
}
[TearDown]
public void TearDown()
{
try
{
_tempDir.Delete(recursive: true);
}
catch
{
// best-effort cleanup
}
}
private static IConfiguration Config(params (string Key, string Value)[] settings)
{
var dict = new Dictionary<string, string?>();
foreach ((string key, string value) in settings)
{
dict[key] = value;
}
return new ConfigurationBuilder().AddInMemoryCollection(dict).Build();
}
[Test]
public void Configured_WriteKey_Takes_Precedence_And_Does_Not_Write_A_File()
{
IConfiguration config = Config((ApiKeyProvider.WriteKeyConfigurationKey, " from-config "));
string key = ApiKeyProvider.ResolveKey(config, _keyPath, NullLogger.Instance);
key.ShouldBe("from-config"); // trimmed
File.Exists(_keyPath).ShouldBeFalse();
}
[Test]
public void Existing_Key_File_Is_Loaded()
{
File.WriteAllText(_keyPath, " persisted-key \n");
string key = ApiKeyProvider.ResolveKey(Config(), _keyPath, NullLogger.Instance);
key.ShouldBe("persisted-key");
}
[Test]
public void Empty_Key_File_Is_Regenerated()
{
File.WriteAllText(_keyPath, " \n");
string key = ApiKeyProvider.ResolveKey(Config(), _keyPath, NullLogger.Instance);
key.ShouldMatch("^[0-9a-f]{64}$");
File.ReadAllText(_keyPath).Trim().ShouldBe(key); // the empty file was overwritten
}
[Test]
public void Generates_And_Persists_A_256_Bit_Key_When_None_Exists()
{
string key = ApiKeyProvider.ResolveKey(Config(), _keyPath, NullLogger.Instance);
key.ShouldMatch("^[0-9a-f]{64}$"); // 32 bytes hex
File.Exists(_keyPath).ShouldBeTrue();
File.ReadAllText(_keyPath).Trim().ShouldBe(key);
}
[Test]
[Platform(Exclude = "Win", Reason = "Unix file mode is not applicable on Windows")]
public void Persisted_Key_File_Is_Owner_Read_Write_Only()
{
ApiKeyProvider.ResolveKey(Config(), _keyPath, NullLogger.Instance);
UnixFileMode mode = File.GetUnixFileMode(_keyPath);
mode.ShouldBe(UnixFileMode.UserRead | UnixFileMode.UserWrite);
}
[Test]
public void Unreadable_Existing_Key_File_Throws_Rather_Than_Overwriting()
{
// The headline #285/L3 behavior: a present-but-unreadable key file must NOT be silently
// regenerated (that would invalidate every client's key). Inject a reader that throws to
// simulate a permission error portably (a real chmod-000 no-ops under root in CI).
File.WriteAllText(_keyPath, "good-key");
Should.Throw<IOException>(() =>
ApiKeyProvider.ResolveKey(Config(), _keyPath, NullLogger.Instance, _ => throw new IOException("boom")));
File.ReadAllText(_keyPath).ShouldBe("good-key"); // untouched
}
[Test]
public void Key_File_Deleted_During_Read_Falls_Back_To_Generate()
{
// Raced delete between File.Exists and the read → treat as absent (don't fail boot).
File.WriteAllText(_keyPath, "whatever");
string key = ApiKeyProvider.ResolveKey(
Config(),
_keyPath,
NullLogger.Instance,
_ => throw new FileNotFoundException());
key.ShouldMatch("^[0-9a-f]{64}$");
}
[Test]
public void Returns_A_Usable_Key_Even_When_Persist_Fails()
{
// Make persistence fail by giving the key a parent path that is a FILE, not a directory:
// Directory.CreateDirectory / file creation then throws, Persist swallows it, and the
// resolver must still return the in-memory generated key (the never-empty invariant, #280).
string fileAsParent = Path.Combine(_tempDir.FullName, "not-a-dir");
File.WriteAllText(fileAsParent, "x");
string unwritable = Path.Combine(fileAsParent, "api.key");
string key = ApiKeyProvider.ResolveKey(Config(), unwritable, NullLogger.Instance);
key.ShouldMatch("^[0-9a-f]{64}$");
File.Exists(unwritable).ShouldBeFalse();
}
}