mirror of
https://github.com/rmcrackan/Libation.git
synced 2026-09-21 11:56:08 -04:00
The guard lived in AudibleUtilities.Tests, which meant it scanned three assemblies and found four exception types - a test only sees what its own project references. Moved to LibationUiBase.Tests, which reaches ApplicationServices, AppScaffolding, FileLiberator and DataLayer as well, it now covers 16 exception types across 10 assemblies. Two of those are the AudibleApi packages, on purpose. Identity is theirs, so an upstream exception that started carrying one would leak through Libation, and a version bump is where we would want to find that out rather than in someone's log. Nothing there reaches one today. Checked by adding AccountSummary to the forbidden list, which fails the test naming AuthenticationRequiredException.AccountInfo - so the walk does reach exception properties rather than passing on an empty set. Co-authored-by: rmcrackan <rmcrackan@gmail.com>
174 lines
6.4 KiB
C#
174 lines
6.4 KiB
C#
using AudibleApi;
|
|
using AudibleApi.Authorization;
|
|
using AudibleApi.Cryptography;
|
|
using AudibleUtilities;
|
|
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.
|