456 lines
14 KiB
C#
456 lines
14 KiB
C#
// =============================================================================
|
|
// 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;
|
|
|
|
/// <summary>
|
|
/// Provides utility methods for webhook testing, including signature generation
|
|
/// and payload manipulation.
|
|
/// </summary>
|
|
public static class WebhookTestHelper
|
|
{
|
|
#region Signature Generation
|
|
|
|
/// <summary>
|
|
/// Generates an HMAC-SHA256 signature for a webhook payload.
|
|
/// </summary>
|
|
/// <param name="payload">The webhook payload.</param>
|
|
/// <param name="secret">The webhook secret.</param>
|
|
/// <param name="prefix">Optional prefix for the signature (e.g., "sha256=").</param>
|
|
/// <returns>The generated signature.</returns>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Generates a GitHub-style webhook signature.
|
|
/// </summary>
|
|
public static string GenerateGitHubSignature(string payload, string secret)
|
|
{
|
|
return GenerateHmacSha256Signature(payload, secret, "sha256=");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Generates a GitLab-style webhook token.
|
|
/// GitLab uses X-Gitlab-Token header with the secret directly.
|
|
/// </summary>
|
|
public static string GenerateGitLabToken(string secret)
|
|
{
|
|
return secret;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Generates a Gitea-style webhook signature.
|
|
/// </summary>
|
|
public static string GenerateGiteaSignature(string payload, string secret)
|
|
{
|
|
return GenerateHmacSha256Signature(payload, secret, "sha256=");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Generates a Harbor-style webhook signature.
|
|
/// </summary>
|
|
public static string GenerateHarborSignature(string payload, string secret)
|
|
{
|
|
return GenerateHmacSha256Signature(payload, secret, "sha256=");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Generates a Docker Hub-style webhook signature.
|
|
/// </summary>
|
|
public static string GenerateDockerHubSignature(string payload, string secret)
|
|
{
|
|
return GenerateHmacSha256Signature(payload, secret, "sha256=");
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Payload Manipulation
|
|
|
|
/// <summary>
|
|
/// Modifies a JSON payload by updating a specific field.
|
|
/// </summary>
|
|
/// <param name="payload">The original JSON payload.</param>
|
|
/// <param name="jsonPath">Dot-separated path to the field (e.g., "repository.name").</param>
|
|
/// <param name="newValue">The new value to set.</param>
|
|
/// <returns>The modified payload.</returns>
|
|
public static string ModifyPayloadField(string payload, string jsonPath, object newValue)
|
|
{
|
|
var doc = JsonDocument.Parse(payload);
|
|
var root = doc.RootElement;
|
|
|
|
var dict = JsonSerializer.Deserialize<Dictionary<string, object>>(payload, JsonOptions)
|
|
?? throw new InvalidOperationException("Failed to parse payload");
|
|
|
|
SetNestedValue(dict, jsonPath.Split('.'), newValue);
|
|
|
|
return JsonSerializer.Serialize(dict, JsonOptions);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Corrupts a payload by modifying its hash/digest field.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a minimal valid webhook payload for testing.
|
|
/// </summary>
|
|
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<string, object>
|
|
{
|
|
["version"] = "0",
|
|
["id"] = Guid.NewGuid().ToString(),
|
|
["detail-type"] = "ECR Image Action",
|
|
["source"] = "aws.ecr",
|
|
["detail"] = new Dictionary<string, object>
|
|
{
|
|
["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<object>()
|
|
}, 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<object>()
|
|
}, 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<object>()
|
|
}, JsonOptions);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Validation Helpers
|
|
|
|
/// <summary>
|
|
/// Validates that a webhook payload contains required fields.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extracts a field value from a JSON payload.
|
|
/// </summary>
|
|
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<string, object> 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<string, object>();
|
|
current[path[i]] = next;
|
|
}
|
|
|
|
if (next is JsonElement jsonElement)
|
|
{
|
|
var nested = JsonSerializer.Deserialize<Dictionary<string, object>>(jsonElement.GetRawText());
|
|
if (nested != null)
|
|
{
|
|
current[path[i]] = nested;
|
|
current = nested;
|
|
}
|
|
}
|
|
else if (next is Dictionary<string, object> 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
|
|
}
|