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
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>
145 lines
4.6 KiB
C#
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();
|
|
}
|
|
}
|