mirror of
https://github.com/rmcrackan/Libation.git
synced 2026-09-14 22:58:07 -04:00
173 lines
6.4 KiB
C#
173 lines
6.4 KiB
C#
using AudibleApi;
|
|
using AudibleApi.Authorization;
|
|
using AudibleApi.Cryptography;
|
|
using Dinah.Core.Security;
|
|
using LibationFileManager;
|
|
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
|
using Newtonsoft.Json;
|
|
using Newtonsoft.Json.Linq;
|
|
using Serilog;
|
|
using Serilog.Core;
|
|
using Serilog.Events;
|
|
using Serilog.Exceptions;
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Security.Cryptography;
|
|
|
|
namespace AudibleUtilities.Tests;
|
|
|
|
/// <summary>
|
|
/// The reported bug: an account's address reached a log file because the exception carried the live account and
|
|
/// Serilog.Exceptions reflects over every public property of whatever it is given. These tests run the real
|
|
/// pipeline - the same enricher Libation configures - and assert nothing private comes out the other end.
|
|
/// </summary>
|
|
[TestClass]
|
|
public class AuthenticationRequiredExceptionLogSafety
|
|
{
|
|
private const string AccountId = "jade@example.com";
|
|
private const string AccountName = "Jade";
|
|
private const string ActivationBytes = "1a2b3c4d";
|
|
private const string RefreshTokenValue = "Atnr|_CHAR_REFRESH_";
|
|
private const string AccessTokenValue = "Atna|_CHAR_ACCESS_";
|
|
private const string AdpTokenValue = "{enc:abcdefg}{key:1234}{iv:56789}{name:QURQVG9rZW5FbmNyeXB0aW9uS2V5}{serial:Mg==}";
|
|
private const string CookieValue = "cookie-secret-value";
|
|
private const string StoreAuthCookie = "store-auth-cookie-value";
|
|
|
|
private static Account registeredAccount()
|
|
{
|
|
var privateKey = RSA.Create(2048).ExportRSAPrivateKeyPem();
|
|
var identity = new Identity(Localization.Get("us"));
|
|
identity.Update(
|
|
new PrivateKey(privateKey),
|
|
new AdpToken(AdpTokenValue),
|
|
new AccessToken(AccessTokenValue, new DateTime(2200, 1, 1, 12, 0, 0, DateTimeKind.Utc)),
|
|
new RefreshToken(RefreshTokenValue),
|
|
new List<KeyValuePair<string, SecretString>> { new("session-id", CookieValue) },
|
|
deviceSerialNumber: "device-serial",
|
|
deviceType: "device-type",
|
|
amazonAccountId: "amzn-account",
|
|
deviceName: "device-name",
|
|
storeAuthenticationCookie: StoreAuthCookie);
|
|
|
|
return new Account(AccountId)
|
|
{
|
|
AccountName = AccountName,
|
|
DecryptKey = ActivationBytes,
|
|
IdentityTokens = identity
|
|
};
|
|
}
|
|
|
|
[TestMethod]
|
|
public void logging_the_exception_writes_nothing_private()
|
|
{
|
|
var account = registeredAccount();
|
|
var privateKeyHeader = account.IdentityTokens!.PrivateKey!.Reveal()[..40];
|
|
var ex = new AuthenticationRequiredException(
|
|
account,
|
|
message: $"Stored credentials for {account.MaskedLogEntry} are missing or incomplete.");
|
|
|
|
var written = renderThroughSerilog(ex);
|
|
|
|
// the masked form is present, so the entry is still useful for telling accounts apart
|
|
StringAssert.Contains(written, account.MaskedLogEntry);
|
|
|
|
foreach (var secret in new[] { AccountId, AccountName, ActivationBytes, RefreshTokenValue, AccessTokenValue, AdpTokenValue, CookieValue, StoreAuthCookie, privateKeyHeader })
|
|
Assert.IsFalse(written.Contains(secret, StringComparison.Ordinal), $"log contained a secret: {secret}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// The inner exception is logged too, so a failure that names an account on its way up the chain has the same
|
|
/// problem. This is the shape AutoScanRunner logs: an outer exception wrapping the authentication failure.
|
|
/// </summary>
|
|
[TestMethod]
|
|
public void logging_it_as_an_inner_exception_writes_nothing_private()
|
|
{
|
|
var ex = new InvalidOperationException(
|
|
"Error scanning library",
|
|
new AuthenticationRequiredException(registeredAccount()));
|
|
|
|
var written = renderThroughSerilog(ex);
|
|
|
|
Assert.IsFalse(written.Contains(AccountId, StringComparison.Ordinal), "log contained the account id");
|
|
Assert.IsFalse(written.Contains(ActivationBytes, StringComparison.Ordinal), "log contained the activation bytes");
|
|
}
|
|
|
|
[TestMethod]
|
|
public void the_dialog_still_gets_the_full_label()
|
|
{
|
|
var summary = AccountSummary.From(registeredAccount())!;
|
|
|
|
StringAssert.Contains(summary.RevealOwnerFacingLabel(), AccountId);
|
|
Assert.IsFalse(summary.MaskedLogEntry.Contains(AccountId, StringComparison.Ordinal));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Renders a log event the way Libation's file sink does: the message, the exception, and the expanded
|
|
/// {Properties:j} that WithExceptionDetails fills in.
|
|
/// </summary>
|
|
private static string renderThroughSerilog(Exception ex)
|
|
{
|
|
var sink = new CollectingSink();
|
|
using var logger = new LoggerConfiguration()
|
|
.Enrich.WithExceptionDetails()
|
|
.WriteTo.Sink(sink)
|
|
.CreateLogger();
|
|
|
|
logger.Warning(ex, "Auto-scan paused: Audible login is required.");
|
|
|
|
var logEvent = sink.Events.Single();
|
|
return logEvent.RenderMessage()
|
|
+ logEvent.Exception
|
|
+ string.Join("|", logEvent.Properties.Select(p => $"{p.Key}={p.Value}"));
|
|
}
|
|
|
|
private class CollectingSink : ILogEventSink
|
|
{
|
|
public List<LogEvent> Events { get; } = [];
|
|
public void Emit(LogEvent logEvent) => Events.Add(logEvent);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The paths that do not involve an exception at all: interpolating an account, and persisting its activation
|
|
/// bytes.
|
|
/// </summary>
|
|
[TestClass]
|
|
public class AccountMasking
|
|
{
|
|
[TestMethod]
|
|
public void interpolating_an_account_is_masked()
|
|
{
|
|
var account = new Account("jade@example.com") { AccountName = "Jade" };
|
|
|
|
var interpolated = $"{account}";
|
|
|
|
Assert.AreEqual(account.MaskedLogEntry, interpolated);
|
|
Assert.IsFalse(interpolated.Contains("jade@example.com", StringComparison.Ordinal));
|
|
Assert.IsFalse(interpolated.Contains("Jade", StringComparison.Ordinal));
|
|
}
|
|
|
|
[TestMethod]
|
|
public void an_account_declares_itself_maskable_for_serilog()
|
|
=> Assert.IsInstanceOfType<ILogMasked>(new Account("jade@example.com"));
|
|
|
|
/// <summary>
|
|
/// DecryptKey became a SecretString, which would serialize as an object and lose the value if the converter
|
|
/// were not doing its job. Existing settings files have to keep loading, and keep the shape they had.
|
|
/// </summary>
|
|
[TestMethod]
|
|
public void the_activation_bytes_still_persist_as_a_bare_string()
|
|
{
|
|
var json = JsonConvert.SerializeObject(new Account("jade@example.com") { DecryptKey = "1a2b3c4d" });
|
|
|
|
var decryptKey = JObject.Parse(json)["DecryptKey"]!;
|
|
Assert.AreEqual(JTokenType.String, decryptKey.Type);
|
|
Assert.AreEqual("1a2b3c4d", decryptKey.ToObject<string>());
|
|
}
|
|
}
|
|
|
|
// The guard that no exception type can reach an Account lives in LibationUiBase.Tests
|
|
// (ExceptionsCannotReachAnAccount): a test only sees the assemblies its own project references, and that one
|
|
// reaches ApplicationServices, FileLiberator, and DataLayer as well as these.
|