// ============================================================================= // WebhookTestHelper.cs // Sprint: SPRINT_20251229_019 - Integration E2E Validation // Description: Utility class for webhook testing operations // ============================================================================= using System.Globalization; using System.Security.Cryptography; using System.Text; using System.Text.Json; namespace StellaOps.Integration.E2E.Integrations.Helpers; /// /// Provides utility methods for webhook testing, including signature generation /// and payload manipulation. /// public static class WebhookTestHelper { #region Signature Generation /// /// Generates an HMAC-SHA256 signature for a webhook payload. /// /// The webhook payload. /// The webhook secret. /// Optional prefix for the signature (e.g., "sha256="). /// The generated signature. public static string GenerateHmacSha256Signature(string payload, string secret, string prefix = "sha256=") { var secretBytes = Encoding.UTF8.GetBytes(secret); var payloadBytes = Encoding.UTF8.GetBytes(payload); using var hmac = new HMACSHA256(secretBytes); var hash = hmac.ComputeHash(payloadBytes); return prefix + Convert.ToHexStringLower(hash); } /// /// Generates a GitHub-style webhook signature. /// public static string GenerateGitHubSignature(string payload, string secret) { return GenerateHmacSha256Signature(payload, secret, "sha256="); } /// /// Generates a GitLab-style webhook token. /// GitLab uses X-Gitlab-Token header with the secret directly. /// public static string GenerateGitLabToken(string secret) { return secret; } /// /// Generates a Gitea-style webhook signature. /// public static string GenerateGiteaSignature(string payload, string secret) { return GenerateHmacSha256Signature(payload, secret, "sha256="); } /// /// Generates a Harbor-style webhook signature. /// public static string GenerateHarborSignature(string payload, string secret) { return GenerateHmacSha256Signature(payload, secret, "sha256="); } /// /// Generates a Docker Hub-style webhook signature. /// public static string GenerateDockerHubSignature(string payload, string secret) { return GenerateHmacSha256Signature(payload, secret, "sha256="); } #endregion #region Payload Manipulation /// /// Modifies a JSON payload by updating a specific field. /// /// The original JSON payload. /// Dot-separated path to the field (e.g., "repository.name"). /// The new value to set. /// The modified payload. public static string ModifyPayloadField(string payload, string jsonPath, object newValue) { var doc = JsonDocument.Parse(payload); var root = doc.RootElement; var dict = JsonSerializer.Deserialize>(payload, JsonOptions) ?? throw new InvalidOperationException("Failed to parse payload"); SetNestedValue(dict, jsonPath.Split('.'), newValue); return JsonSerializer.Serialize(dict, JsonOptions); } /// /// Corrupts a payload by modifying its hash/digest field. /// public static string CorruptPayloadDigest(string payload) { // Try common digest field names var digestFields = new[] { "digest", "image-digest", "sha", "hash" }; foreach (var field in digestFields) { if (payload.Contains($"\"{field}\"")) { // Replace any sha256 digest with a corrupted one return System.Text.RegularExpressions.Regex.Replace( payload, @"sha256:[a-f0-9]{64}", "sha256:0000000000000000000000000000000000000000000000000000000000000000"); } } return payload; } /// /// Creates a minimal valid webhook payload for testing. /// public static string CreateMinimalPayload(string provider, string eventType = "push") { return provider.ToLowerInvariant() switch { "harbor" => CreateMinimalHarborPayload(), "dockerhub" => CreateMinimalDockerHubPayload(), "acr" => CreateMinimalAcrPayload(), "ecr" => CreateMinimalEcrPayload(), "gcr" => CreateMinimalGcrPayload(), "ghcr" => CreateMinimalGhcrPayload(), "github" => CreateMinimalGitHubPushPayload(), "gitlab" => CreateMinimalGitLabPushPayload(), "gitea" => CreateMinimalGiteaPushPayload(), _ => throw new ArgumentException($"Unknown provider: {provider}") }; } #endregion #region Minimal Payload Generators private static string CreateMinimalHarborPayload() { return JsonSerializer.Serialize(new { type = "PUSH_ARTIFACT", occur_at = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), @operator = "test", event_data = new { resources = new[] { new { digest = "sha256:test123", tag = "latest", resource_url = "harbor.example.com/library/test:latest" } }, repository = new { name = "test", repo_full_name = "library/test" } } }, JsonOptions); } private static string CreateMinimalDockerHubPayload() { return JsonSerializer.Serialize(new { push_data = new { tag = "latest", pushed_at = DateTimeOffset.UtcNow.ToUnixTimeSeconds() }, repository = new { repo_name = "stellaops/test", name = "test", @namespace = "stellaops" } }, JsonOptions); } private static string CreateMinimalAcrPayload() { return JsonSerializer.Serialize(new { id = Guid.NewGuid().ToString(), action = "push", target = new { repository = "stellaops/test", tag = "latest", digest = "sha256:test123" } }, JsonOptions); } private static string CreateMinimalEcrPayload() { return JsonSerializer.Serialize(new Dictionary { ["version"] = "0", ["id"] = Guid.NewGuid().ToString(), ["detail-type"] = "ECR Image Action", ["source"] = "aws.ecr", ["detail"] = new Dictionary { ["action-type"] = "PUSH", ["repository-name"] = "stellaops/test", ["image-tag"] = "latest" } }, JsonOptions); } private static string CreateMinimalGcrPayload() { var innerData = new { action = "INSERT", tag = "latest", digest = "sha256:test123" }; var base64Data = Convert.ToBase64String(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(innerData))); return JsonSerializer.Serialize(new { message = new { data = base64Data, messageId = "gcr-test-123" } }, JsonOptions); } private static string CreateMinimalGhcrPayload() { return JsonSerializer.Serialize(new { action = "published", package = new { name = "test-package", package_type = "container", package_version = new { version = "v1.0.0" } }, repository = new { full_name = "stellaops/test" } }, JsonOptions); } private static string CreateMinimalGitHubPushPayload() { return JsonSerializer.Serialize(new { @ref = "refs/heads/main", after = "abc123", repository = new { full_name = "stellaops/test", name = "test", default_branch = "main" }, pusher = new { name = "test-user" }, sender = new { login = "test-user", type = "User" }, commits = Array.Empty() }, JsonOptions); } private static string CreateMinimalGitLabPushPayload() { return JsonSerializer.Serialize(new { object_kind = "push", @ref = "refs/heads/main", after = "abc123", project = new { path_with_namespace = "stellaops/test", name = "test", default_branch = "main" }, user_name = "test-user", commits = Array.Empty() }, JsonOptions); } private static string CreateMinimalGiteaPushPayload() { return JsonSerializer.Serialize(new { @ref = "refs/heads/main", after = "abc123", repository = new { full_name = "stellaops/test", name = "test", default_branch = "main" }, pusher = new { login = "test-user" }, sender = new { login = "test-user" }, commits = Array.Empty() }, JsonOptions); } #endregion #region Validation Helpers /// /// Validates that a webhook payload contains required fields. /// public static bool ValidateRequiredFields(string payload, params string[] fields) { try { var doc = JsonDocument.Parse(payload); var root = doc.RootElement; foreach (var field in fields) { if (!HasNestedProperty(root, field.Split('.'))) { return false; } } return true; } catch { return false; } } /// /// Extracts a field value from a JSON payload. /// public static string? ExtractField(string payload, string jsonPath) { try { var doc = JsonDocument.Parse(payload); var element = doc.RootElement; foreach (var part in jsonPath.Split('.')) { if (element.ValueKind == JsonValueKind.Array && int.TryParse(part, NumberStyles.None, CultureInfo.InvariantCulture, out var index)) { if (index < 0 || index >= element.GetArrayLength()) { return null; } element = element[index]; continue; } if (!element.TryGetProperty(part, out element)) { return null; } } return element.ValueKind == JsonValueKind.String ? element.GetString() : element.GetRawText(); } catch { return null; } } #endregion #region Private Helpers private static void SetNestedValue(Dictionary dict, string[] path, object value) { var current = dict; for (var i = 0; i < path.Length - 1; i++) { if (!current.TryGetValue(path[i], out var next)) { next = new Dictionary(); current[path[i]] = next; } if (next is JsonElement jsonElement) { var nested = JsonSerializer.Deserialize>(jsonElement.GetRawText()); if (nested != null) { current[path[i]] = nested; current = nested; } } else if (next is Dictionary nestedDict) { current = nestedDict; } } current[path[^1]] = value; } private static bool HasNestedProperty(JsonElement element, string[] path) { var current = element; foreach (var part in path) { if (current.ValueKind == JsonValueKind.Array && int.TryParse(part, NumberStyles.None, CultureInfo.InvariantCulture, out var index)) { if (index < 0 || index >= current.GetArrayLength()) { return false; } current = current[index]; continue; } if (current.ValueKind != JsonValueKind.Object || !current.TryGetProperty(part, out current)) { return false; } } return true; } private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = true }; #endregion }