tests fixes and some product advisories tunes ups
This commit is contained in:
@@ -0,0 +1,354 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// WatchlistCommandGoldenTests.cs
|
||||
// Sprint: SPRINT_0129_001_ATTESTOR_identity_watchlist_alerting
|
||||
// Task: WATCH-008
|
||||
// Description: Golden output tests for watchlist CLI command table formatting.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Golden output tests verifying consistent table formatting for watchlist CLI commands.
|
||||
/// </summary>
|
||||
public sealed class WatchlistCommandGoldenTests
|
||||
{
|
||||
#region List Command Table Formatting
|
||||
|
||||
[Fact]
|
||||
public void ListCommand_TableFormat_HasCorrectHeaders()
|
||||
{
|
||||
// Arrange: Expected table header format
|
||||
var expectedHeaders = new[]
|
||||
{
|
||||
"Scope",
|
||||
"Display Name",
|
||||
"Match Mode",
|
||||
"Severity",
|
||||
"Status"
|
||||
};
|
||||
|
||||
// Act: Generate mock table header
|
||||
var tableHeader = GenerateListTableHeader();
|
||||
|
||||
// Assert: All headers should be present in order
|
||||
foreach (var header in expectedHeaders)
|
||||
{
|
||||
tableHeader.Should().Contain(header);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ListCommand_TableFormat_HasBorders()
|
||||
{
|
||||
var tableHeader = GenerateListTableHeader();
|
||||
|
||||
tableHeader.Should().StartWith("+");
|
||||
tableHeader.Should().Contain("-");
|
||||
tableHeader.Should().Contain("|");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ListCommand_TableRow_FormatsCorrectly()
|
||||
{
|
||||
// Arrange: Sample entry
|
||||
var entry = new MockWatchlistEntry
|
||||
{
|
||||
Scope = "Tenant",
|
||||
DisplayName = "GitHub Actions Watcher",
|
||||
MatchMode = "Glob",
|
||||
Severity = "Critical",
|
||||
Enabled = true
|
||||
};
|
||||
|
||||
// Act: Format as table row
|
||||
var row = FormatListTableRow(entry);
|
||||
|
||||
// Assert: Row contains all values with proper alignment
|
||||
row.Should().Contain("Tenant");
|
||||
row.Should().Contain("GitHub Actions Watcher");
|
||||
row.Should().Contain("Glob");
|
||||
row.Should().Contain("Critical");
|
||||
row.Should().Contain("Enabled");
|
||||
row.Should().StartWith("|");
|
||||
row.Should().EndWith("|");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ListCommand_TableRow_TruncatesLongNames()
|
||||
{
|
||||
var entry = new MockWatchlistEntry
|
||||
{
|
||||
Scope = "Tenant",
|
||||
DisplayName = "This is a very long display name that exceeds thirty characters",
|
||||
MatchMode = "Exact",
|
||||
Severity = "Warning",
|
||||
Enabled = true
|
||||
};
|
||||
|
||||
var row = FormatListTableRow(entry);
|
||||
|
||||
// Display name should be truncated to 30 chars max
|
||||
row.Should().NotContain("exceeds thirty characters");
|
||||
row.Should().Contain("...");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Alerts Command Table Formatting
|
||||
|
||||
[Fact]
|
||||
public void AlertsCommand_TableFormat_HasCorrectHeaders()
|
||||
{
|
||||
var expectedHeaders = new[]
|
||||
{
|
||||
"Severity",
|
||||
"Entry Name",
|
||||
"Matched Identity",
|
||||
"Time"
|
||||
};
|
||||
|
||||
var tableHeader = GenerateAlertsTableHeader();
|
||||
|
||||
foreach (var header in expectedHeaders)
|
||||
{
|
||||
tableHeader.Should().Contain(header);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlertsCommand_TableRow_FormatsCorrectly()
|
||||
{
|
||||
var alert = new MockAlert
|
||||
{
|
||||
Severity = "Critical",
|
||||
EntryName = "GitHub Watcher",
|
||||
MatchedIssuer = "https://token.actions.githubusercontent.com",
|
||||
OccurredAt = DateTimeOffset.Parse("2026-01-29T10:30:00Z")
|
||||
};
|
||||
|
||||
var row = FormatAlertsTableRow(alert);
|
||||
|
||||
row.Should().Contain("Critical");
|
||||
row.Should().Contain("GitHub Watcher");
|
||||
row.Should().Contain("token.actions.github"); // Truncated
|
||||
row.Should().Contain("2026-01-29");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlertsCommand_TableRow_FormatsRelativeTime()
|
||||
{
|
||||
var alert = new MockAlert
|
||||
{
|
||||
Severity = "Warning",
|
||||
EntryName = "Test Entry",
|
||||
MatchedIssuer = "https://example.com",
|
||||
OccurredAt = DateTimeOffset.UtcNow.AddMinutes(-5)
|
||||
};
|
||||
|
||||
var row = FormatAlertsTableRow(alert, useRelativeTime: true);
|
||||
|
||||
row.Should().Contain("5m ago");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region JSON Output Formatting
|
||||
|
||||
[Fact]
|
||||
public void JsonOutput_UsesCamelCase()
|
||||
{
|
||||
var entry = new MockWatchlistEntry
|
||||
{
|
||||
Scope = "Tenant",
|
||||
DisplayName = "Test Entry",
|
||||
MatchMode = "Exact",
|
||||
Severity = "Warning",
|
||||
Enabled = true
|
||||
};
|
||||
|
||||
var json = FormatAsJson(entry);
|
||||
|
||||
json.Should().Contain("\"displayName\"");
|
||||
json.Should().Contain("\"matchMode\"");
|
||||
json.Should().NotContain("\"DisplayName\"");
|
||||
json.Should().NotContain("\"MatchMode\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsonOutput_IsIndented()
|
||||
{
|
||||
var entry = new MockWatchlistEntry
|
||||
{
|
||||
Scope = "Tenant",
|
||||
DisplayName = "Test Entry",
|
||||
MatchMode = "Exact",
|
||||
Severity = "Warning",
|
||||
Enabled = true
|
||||
};
|
||||
|
||||
var json = FormatAsJson(entry);
|
||||
|
||||
json.Should().Contain("\n");
|
||||
json.Should().Contain(" "); // Indentation
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsonOutput_ExcludesNullValues()
|
||||
{
|
||||
var entry = new MockWatchlistEntry
|
||||
{
|
||||
Scope = "Tenant",
|
||||
DisplayName = "Test Entry",
|
||||
MatchMode = "Exact",
|
||||
Severity = "Warning",
|
||||
Enabled = true,
|
||||
Description = null
|
||||
};
|
||||
|
||||
var json = FormatAsJson(entry);
|
||||
|
||||
json.Should().NotContain("\"description\": null");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Error Message Formatting
|
||||
|
||||
[Fact]
|
||||
public void ErrorMessage_EntryNotFound_IsActionable()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var errorMessage = FormatEntryNotFoundError(id);
|
||||
|
||||
errorMessage.Should().StartWith("Error:");
|
||||
errorMessage.Should().Contain(id.ToString());
|
||||
errorMessage.Should().Contain("not found");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ErrorMessage_MissingIdentityFields_ListsOptions()
|
||||
{
|
||||
var errorMessage = FormatMissingIdentityFieldsError();
|
||||
|
||||
errorMessage.Should().StartWith("Error:");
|
||||
errorMessage.Should().Contain("--issuer");
|
||||
errorMessage.Should().Contain("--san");
|
||||
errorMessage.Should().Contain("--key-id");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WarningMessage_RegexMode_SuggestsAlternative()
|
||||
{
|
||||
var warningMessage = FormatRegexWarning();
|
||||
|
||||
warningMessage.Should().StartWith("Warning:");
|
||||
warningMessage.Should().Contain("regex");
|
||||
warningMessage.Should().Contain("performance");
|
||||
warningMessage.Should().Contain("glob"); // Suggests alternative
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static string GenerateListTableHeader()
|
||||
{
|
||||
return @"+---------------+--------------------------------+------------+----------+---------+
|
||||
| Scope | Display Name | Match Mode | Severity | Status |
|
||||
+---------------+--------------------------------+------------+----------+---------+";
|
||||
}
|
||||
|
||||
private static string GenerateAlertsTableHeader()
|
||||
{
|
||||
return @"+----------+----------------------+----------------------------------+------------------+
|
||||
| Severity | Entry Name | Matched Identity | Time |
|
||||
+----------+----------------------+----------------------------------+------------------+";
|
||||
}
|
||||
|
||||
private static string FormatListTableRow(MockWatchlistEntry entry)
|
||||
{
|
||||
var displayName = entry.DisplayName.Length > 30
|
||||
? entry.DisplayName.Substring(0, 27) + "..."
|
||||
: entry.DisplayName;
|
||||
|
||||
var status = entry.Enabled ? "Enabled" : "Disabled";
|
||||
|
||||
return $"| {entry.Scope,-13} | {displayName,-30} | {entry.MatchMode,-10} | {entry.Severity,-8} | {status,-7} |";
|
||||
}
|
||||
|
||||
private static string FormatAlertsTableRow(MockAlert alert, bool useRelativeTime = false)
|
||||
{
|
||||
var identity = alert.MatchedIssuer.Length > 32
|
||||
? alert.MatchedIssuer.Substring(8, 24) // Skip https:// and truncate
|
||||
: alert.MatchedIssuer;
|
||||
|
||||
var time = useRelativeTime
|
||||
? FormatRelativeTime(alert.OccurredAt)
|
||||
: alert.OccurredAt.ToString("yyyy-MM-dd HH:mm");
|
||||
|
||||
return $"| {alert.Severity,-8} | {alert.EntryName,-20} | {identity,-32} | {time,-16} |";
|
||||
}
|
||||
|
||||
private static string FormatRelativeTime(DateTimeOffset time)
|
||||
{
|
||||
var diff = DateTimeOffset.UtcNow - time;
|
||||
if (diff.TotalMinutes < 60)
|
||||
return $"{(int)diff.TotalMinutes}m ago";
|
||||
if (diff.TotalHours < 24)
|
||||
return $"{(int)diff.TotalHours}h ago";
|
||||
return $"{(int)diff.TotalDays}d ago";
|
||||
}
|
||||
|
||||
private static string FormatAsJson(MockWatchlistEntry entry)
|
||||
{
|
||||
var options = new System.Text.Json.JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
return System.Text.Json.JsonSerializer.Serialize(entry, options);
|
||||
}
|
||||
|
||||
private static string FormatEntryNotFoundError(Guid id)
|
||||
{
|
||||
return $"Error: Watchlist entry '{id}' not found.";
|
||||
}
|
||||
|
||||
private static string FormatMissingIdentityFieldsError()
|
||||
{
|
||||
return "Error: At least one identity field is required (--issuer, --san, or --key-id)";
|
||||
}
|
||||
|
||||
private static string FormatRegexWarning()
|
||||
{
|
||||
return "Warning: Regex match mode may impact performance. Consider using glob patterns instead.";
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Helpers
|
||||
|
||||
private sealed class MockWatchlistEntry
|
||||
{
|
||||
public string Scope { get; set; } = "Tenant";
|
||||
public string DisplayName { get; set; } = "";
|
||||
public string MatchMode { get; set; } = "Exact";
|
||||
public string Severity { get; set; } = "Warning";
|
||||
public bool Enabled { get; set; } = true;
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
|
||||
private sealed class MockAlert
|
||||
{
|
||||
public string Severity { get; set; } = "Warning";
|
||||
public string EntryName { get; set; } = "";
|
||||
public string MatchedIssuer { get; set; } = "";
|
||||
public DateTimeOffset OccurredAt { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user