Frontend gaps fill work. Testing fixes work. Auditing in progress.
This commit is contained in:
@@ -15,6 +15,10 @@ using StellaOps.Signals.Options;
|
||||
using StellaOps.Signals.Parsing;
|
||||
using StellaOps.Signals.Persistence;
|
||||
using StellaOps.Signals.Routing;
|
||||
using StellaOps.Signals.Scm;
|
||||
using StellaOps.Signals.Scm.Models;
|
||||
using StellaOps.Signals.Scm.Services;
|
||||
using StellaOps.Signals.Scm.Webhooks;
|
||||
using StellaOps.Signals.Services;
|
||||
using StellaOps.Signals.Storage;
|
||||
|
||||
@@ -206,6 +210,16 @@ builder.Services.AddSingleton<IReachabilityUnionIngestionService, ReachabilityUn
|
||||
builder.Services.AddSingleton<IUnknownsIngestionService, UnknownsIngestionService>();
|
||||
builder.Services.AddSingleton<SyntheticRuntimeProbeBuilder>();
|
||||
|
||||
// SCM/CI webhook services (Sprint: SPRINT_20251229_013)
|
||||
builder.Services.AddSingleton<IWebhookSignatureValidator, GitHubWebhookValidator>();
|
||||
builder.Services.AddSingleton<IWebhookSignatureValidator, GitLabWebhookValidator>();
|
||||
builder.Services.AddSingleton<IWebhookSignatureValidator, GiteaWebhookValidator>();
|
||||
builder.Services.AddSingleton<IScmEventMapper, GitHubEventMapper>();
|
||||
builder.Services.AddSingleton<IScmEventMapper, GitLabEventMapper>();
|
||||
builder.Services.AddSingleton<IScmEventMapper, GiteaEventMapper>();
|
||||
builder.Services.AddSingleton<IScmTriggerService, ScmTriggerService>();
|
||||
builder.Services.AddSingleton<IScmWebhookService, ScmWebhookService>();
|
||||
|
||||
if (bootstrap.Authority.Enabled)
|
||||
{
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
@@ -286,6 +300,9 @@ app.MapGet("/readyz", (SignalsStartupState state, SignalsSealedModeMonitor seale
|
||||
: Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
||||
}).AllowAnonymous();
|
||||
|
||||
// SCM/CI webhook endpoints (Sprint: SPRINT_20251229_013)
|
||||
app.MapScmWebhookEndpoints();
|
||||
|
||||
var signalsGroup = app.MapGroup("/signals");
|
||||
|
||||
signalsGroup.MapGet("/ping", (HttpContext context, SignalsOptions options, SignalsSealedModeMonitor sealedModeMonitor) =>
|
||||
|
||||
238
src/Signals/StellaOps.Signals/Scm/Models/NormalizedScmEvent.cs
Normal file
238
src/Signals/StellaOps.Signals/Scm/Models/NormalizedScmEvent.cs
Normal file
@@ -0,0 +1,238 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Signals.Scm.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Normalized SCM/CI event payload that abstracts provider-specific formats.
|
||||
/// All timestamps are UTC ISO-8601.
|
||||
/// </summary>
|
||||
public sealed record NormalizedScmEvent
|
||||
{
|
||||
/// <summary>Unique event identifier (provider delivery ID or generated).</summary>
|
||||
[Required]
|
||||
public required string EventId { get; init; }
|
||||
|
||||
/// <summary>Source provider.</summary>
|
||||
public ScmProvider Provider { get; init; }
|
||||
|
||||
/// <summary>Normalized event type.</summary>
|
||||
public ScmEventType EventType { get; init; }
|
||||
|
||||
/// <summary>UTC timestamp when the event occurred.</summary>
|
||||
public DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
/// <summary>Repository information.</summary>
|
||||
public required ScmRepository Repository { get; init; }
|
||||
|
||||
/// <summary>Actor who triggered the event (user, bot, etc.).</summary>
|
||||
public ScmActor? Actor { get; init; }
|
||||
|
||||
/// <summary>Branch or ref name (e.g., "main", "refs/heads/feature-x").</summary>
|
||||
public string? Ref { get; init; }
|
||||
|
||||
/// <summary>Commit SHA for push/PR events.</summary>
|
||||
public string? CommitSha { get; init; }
|
||||
|
||||
/// <summary>Pull/merge request details if applicable.</summary>
|
||||
public ScmPullRequest? PullRequest { get; init; }
|
||||
|
||||
/// <summary>Release details if applicable.</summary>
|
||||
public ScmRelease? Release { get; init; }
|
||||
|
||||
/// <summary>Pipeline/workflow details if applicable.</summary>
|
||||
public ScmPipeline? Pipeline { get; init; }
|
||||
|
||||
/// <summary>Artifact details if applicable.</summary>
|
||||
public ScmArtifact? Artifact { get; init; }
|
||||
|
||||
/// <summary>Provider-specific raw payload for debugging (redacted sensitive fields).</summary>
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public IReadOnlyDictionary<string, object?>? RawMetadata { get; init; }
|
||||
|
||||
/// <summary>Tenant ID for multi-tenant routing.</summary>
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
/// <summary>Integration ID that received this webhook.</summary>
|
||||
public string? IntegrationId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Repository information.
|
||||
/// </summary>
|
||||
public sealed record ScmRepository
|
||||
{
|
||||
/// <summary>Provider-specific repository ID.</summary>
|
||||
public string? Id { get; init; }
|
||||
|
||||
/// <summary>Full repository name (e.g., "owner/repo").</summary>
|
||||
[Required]
|
||||
public required string FullName { get; init; }
|
||||
|
||||
/// <summary>Repository owner (user or organization).</summary>
|
||||
public string? Owner { get; init; }
|
||||
|
||||
/// <summary>Repository name without owner.</summary>
|
||||
public string? Name { get; init; }
|
||||
|
||||
/// <summary>Clone URL (HTTPS).</summary>
|
||||
public string? CloneUrl { get; init; }
|
||||
|
||||
/// <summary>Default branch name.</summary>
|
||||
public string? DefaultBranch { get; init; }
|
||||
|
||||
/// <summary>Whether the repository is private.</summary>
|
||||
public bool IsPrivate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Actor (user/bot) who triggered the event.
|
||||
/// </summary>
|
||||
public sealed record ScmActor
|
||||
{
|
||||
/// <summary>Provider-specific user ID.</summary>
|
||||
public string? Id { get; init; }
|
||||
|
||||
/// <summary>Username/login.</summary>
|
||||
public string? Username { get; init; }
|
||||
|
||||
/// <summary>Display name.</summary>
|
||||
public string? DisplayName { get; init; }
|
||||
|
||||
/// <summary>Actor type.</summary>
|
||||
public ScmActorType Type { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Actor types.
|
||||
/// </summary>
|
||||
public enum ScmActorType
|
||||
{
|
||||
Unknown = 0,
|
||||
User,
|
||||
Bot,
|
||||
Service
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pull/merge request details.
|
||||
/// </summary>
|
||||
public sealed record ScmPullRequest
|
||||
{
|
||||
/// <summary>Provider-specific PR number.</summary>
|
||||
public int Number { get; init; }
|
||||
|
||||
/// <summary>PR title.</summary>
|
||||
public string? Title { get; init; }
|
||||
|
||||
/// <summary>Source branch.</summary>
|
||||
public string? SourceBranch { get; init; }
|
||||
|
||||
/// <summary>Target branch.</summary>
|
||||
public string? TargetBranch { get; init; }
|
||||
|
||||
/// <summary>PR state (open, merged, closed).</summary>
|
||||
public string? State { get; init; }
|
||||
|
||||
/// <summary>URL to the PR.</summary>
|
||||
public string? Url { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Release details.
|
||||
/// </summary>
|
||||
public sealed record ScmRelease
|
||||
{
|
||||
/// <summary>Provider-specific release ID.</summary>
|
||||
public string? Id { get; init; }
|
||||
|
||||
/// <summary>Tag name.</summary>
|
||||
public string? TagName { get; init; }
|
||||
|
||||
/// <summary>Release name/title.</summary>
|
||||
public string? Name { get; init; }
|
||||
|
||||
/// <summary>Whether this is a prerelease.</summary>
|
||||
public bool IsPrerelease { get; init; }
|
||||
|
||||
/// <summary>Whether this is a draft.</summary>
|
||||
public bool IsDraft { get; init; }
|
||||
|
||||
/// <summary>URL to the release.</summary>
|
||||
public string? Url { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CI pipeline/workflow details.
|
||||
/// </summary>
|
||||
public sealed record ScmPipeline
|
||||
{
|
||||
/// <summary>Provider-specific pipeline/workflow ID.</summary>
|
||||
public string? Id { get; init; }
|
||||
|
||||
/// <summary>Pipeline/workflow name.</summary>
|
||||
public string? Name { get; init; }
|
||||
|
||||
/// <summary>Run number.</summary>
|
||||
public long? RunNumber { get; init; }
|
||||
|
||||
/// <summary>Pipeline status.</summary>
|
||||
public ScmPipelineStatus Status { get; init; }
|
||||
|
||||
/// <summary>Pipeline conclusion (success, failure, etc.).</summary>
|
||||
public string? Conclusion { get; init; }
|
||||
|
||||
/// <summary>URL to the pipeline run.</summary>
|
||||
public string? Url { get; init; }
|
||||
|
||||
/// <summary>Duration in seconds.</summary>
|
||||
public double? DurationSeconds { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pipeline status values.
|
||||
/// </summary>
|
||||
public enum ScmPipelineStatus
|
||||
{
|
||||
Unknown = 0,
|
||||
Queued,
|
||||
InProgress,
|
||||
Completed
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Artifact details.
|
||||
/// </summary>
|
||||
public sealed record ScmArtifact
|
||||
{
|
||||
/// <summary>Artifact name.</summary>
|
||||
public string? Name { get; init; }
|
||||
|
||||
/// <summary>Artifact type (container, binary, sbom, etc.).</summary>
|
||||
public ScmArtifactType Type { get; init; }
|
||||
|
||||
/// <summary>Download URL if available.</summary>
|
||||
public string? DownloadUrl { get; init; }
|
||||
|
||||
/// <summary>Size in bytes.</summary>
|
||||
public long? SizeBytes { get; init; }
|
||||
|
||||
/// <summary>Content digest (SHA256).</summary>
|
||||
public string? Digest { get; init; }
|
||||
|
||||
/// <summary>Container image reference if applicable.</summary>
|
||||
public string? ImageRef { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Artifact types.
|
||||
/// </summary>
|
||||
public enum ScmArtifactType
|
||||
{
|
||||
Unknown = 0,
|
||||
Container,
|
||||
Binary,
|
||||
Sbom,
|
||||
Attestation,
|
||||
Archive
|
||||
}
|
||||
58
src/Signals/StellaOps.Signals/Scm/Models/ScmEventType.cs
Normal file
58
src/Signals/StellaOps.Signals/Scm/Models/ScmEventType.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
namespace StellaOps.Signals.Scm.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Normalized SCM/CI event types across providers.
|
||||
/// </summary>
|
||||
public enum ScmEventType
|
||||
{
|
||||
/// <summary>Unknown or unrecognized event.</summary>
|
||||
Unknown = 0,
|
||||
|
||||
/// <summary>Push to a branch.</summary>
|
||||
Push,
|
||||
|
||||
/// <summary>Pull/merge request event (generic).</summary>
|
||||
PullRequest,
|
||||
|
||||
/// <summary>Pull/merge request opened.</summary>
|
||||
PullRequestOpened,
|
||||
|
||||
/// <summary>Pull/merge request merged.</summary>
|
||||
PullRequestMerged,
|
||||
|
||||
/// <summary>Pull/merge request closed without merge.</summary>
|
||||
PullRequestClosed,
|
||||
|
||||
/// <summary>Release published.</summary>
|
||||
ReleasePublished,
|
||||
|
||||
/// <summary>Tag created.</summary>
|
||||
TagCreated,
|
||||
|
||||
/// <summary>Ref (branch or tag) created.</summary>
|
||||
RefCreated,
|
||||
|
||||
/// <summary>Ref (branch or tag) deleted.</summary>
|
||||
RefDeleted,
|
||||
|
||||
/// <summary>CI pipeline started.</summary>
|
||||
PipelineStarted,
|
||||
|
||||
/// <summary>CI pipeline completed (success or failure).</summary>
|
||||
PipelineCompleted,
|
||||
|
||||
/// <summary>CI pipeline completed successfully.</summary>
|
||||
PipelineSucceeded,
|
||||
|
||||
/// <summary>CI pipeline failed.</summary>
|
||||
PipelineFailed,
|
||||
|
||||
/// <summary>Artifact published in CI.</summary>
|
||||
ArtifactPublished,
|
||||
|
||||
/// <summary>Container image pushed to registry.</summary>
|
||||
ImagePushed,
|
||||
|
||||
/// <summary>SBOM uploaded.</summary>
|
||||
SbomUploaded
|
||||
}
|
||||
12
src/Signals/StellaOps.Signals/Scm/Models/ScmProvider.cs
Normal file
12
src/Signals/StellaOps.Signals/Scm/Models/ScmProvider.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace StellaOps.Signals.Scm.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Supported SCM/CI providers.
|
||||
/// </summary>
|
||||
public enum ScmProvider
|
||||
{
|
||||
Unknown = 0,
|
||||
GitHub,
|
||||
GitLab,
|
||||
Gitea
|
||||
}
|
||||
204
src/Signals/StellaOps.Signals/Scm/ScmWebhookEndpoints.cs
Normal file
204
src/Signals/StellaOps.Signals/Scm/ScmWebhookEndpoints.cs
Normal file
@@ -0,0 +1,204 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Signals.Hosting;
|
||||
using StellaOps.Signals.Options;
|
||||
using StellaOps.Signals.Scm.Models;
|
||||
using StellaOps.Signals.Scm.Services;
|
||||
|
||||
namespace StellaOps.Signals.Scm;
|
||||
|
||||
/// <summary>
|
||||
/// Webhook endpoints for SCM/CI providers.
|
||||
/// </summary>
|
||||
public static class ScmWebhookEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps SCM webhook endpoints to the application.
|
||||
/// </summary>
|
||||
public static void MapScmWebhookEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var webhooks = app.MapGroup("/webhooks");
|
||||
|
||||
webhooks.MapPost("/github", HandleGitHubWebhookAsync)
|
||||
.WithName("ScmWebhookGitHub")
|
||||
.Produces(StatusCodes.Status202Accepted)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.AllowAnonymous();
|
||||
|
||||
webhooks.MapPost("/gitlab", HandleGitLabWebhookAsync)
|
||||
.WithName("ScmWebhookGitLab")
|
||||
.Produces(StatusCodes.Status202Accepted)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.AllowAnonymous();
|
||||
|
||||
webhooks.MapPost("/gitea", HandleGiteaWebhookAsync)
|
||||
.WithName("ScmWebhookGitea")
|
||||
.Produces(StatusCodes.Status202Accepted)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.AllowAnonymous();
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleGitHubWebhookAsync(
|
||||
HttpContext context,
|
||||
IScmWebhookService webhookService,
|
||||
SignalsSealedModeMonitor sealedModeMonitor,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TryEnsureSealedMode(sealedModeMonitor, out var sealedFailure))
|
||||
{
|
||||
return sealedFailure!;
|
||||
}
|
||||
|
||||
// Extract GitHub-specific headers
|
||||
var eventType = context.Request.Headers["X-GitHub-Event"].FirstOrDefault() ?? "unknown";
|
||||
var deliveryId = context.Request.Headers["X-GitHub-Delivery"].FirstOrDefault() ?? Guid.NewGuid().ToString("N");
|
||||
var signature = context.Request.Headers["X-Hub-Signature-256"].FirstOrDefault();
|
||||
var integrationId = context.Request.Headers["X-Integration-Id"].FirstOrDefault();
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault();
|
||||
|
||||
// Read body
|
||||
using var ms = new MemoryStream();
|
||||
await context.Request.Body.CopyToAsync(ms, cancellationToken).ConfigureAwait(false);
|
||||
var payload = ms.ToArray();
|
||||
|
||||
var result = await webhookService.ProcessAsync(
|
||||
ScmProvider.GitHub,
|
||||
eventType,
|
||||
deliveryId,
|
||||
signature,
|
||||
payload,
|
||||
integrationId,
|
||||
tenantId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return ToResult(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleGitLabWebhookAsync(
|
||||
HttpContext context,
|
||||
IScmWebhookService webhookService,
|
||||
SignalsSealedModeMonitor sealedModeMonitor,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TryEnsureSealedMode(sealedModeMonitor, out var sealedFailure))
|
||||
{
|
||||
return sealedFailure!;
|
||||
}
|
||||
|
||||
// Extract GitLab-specific headers
|
||||
var eventType = context.Request.Headers["X-Gitlab-Event"].FirstOrDefault() ?? "unknown";
|
||||
var deliveryId = context.Request.Headers["X-Gitlab-Event-UUID"].FirstOrDefault() ?? Guid.NewGuid().ToString("N");
|
||||
var signature = context.Request.Headers["X-Gitlab-Token"].FirstOrDefault();
|
||||
var integrationId = context.Request.Headers["X-Integration-Id"].FirstOrDefault();
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault();
|
||||
|
||||
// Read body
|
||||
using var ms = new MemoryStream();
|
||||
await context.Request.Body.CopyToAsync(ms, cancellationToken).ConfigureAwait(false);
|
||||
var payload = ms.ToArray();
|
||||
|
||||
var result = await webhookService.ProcessAsync(
|
||||
ScmProvider.GitLab,
|
||||
eventType,
|
||||
deliveryId,
|
||||
signature,
|
||||
payload,
|
||||
integrationId,
|
||||
tenantId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return ToResult(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleGiteaWebhookAsync(
|
||||
HttpContext context,
|
||||
IScmWebhookService webhookService,
|
||||
SignalsSealedModeMonitor sealedModeMonitor,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TryEnsureSealedMode(sealedModeMonitor, out var sealedFailure))
|
||||
{
|
||||
return sealedFailure!;
|
||||
}
|
||||
|
||||
// Extract Gitea-specific headers (similar to GitHub)
|
||||
var eventType = context.Request.Headers["X-Gitea-Event"].FirstOrDefault() ?? "unknown";
|
||||
var deliveryId = context.Request.Headers["X-Gitea-Delivery"].FirstOrDefault() ?? Guid.NewGuid().ToString("N");
|
||||
var signature = context.Request.Headers["X-Hub-Signature-256"].FirstOrDefault()
|
||||
?? context.Request.Headers["X-Hub-Signature"].FirstOrDefault();
|
||||
var integrationId = context.Request.Headers["X-Integration-Id"].FirstOrDefault();
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault();
|
||||
|
||||
// Read body
|
||||
using var ms = new MemoryStream();
|
||||
await context.Request.Body.CopyToAsync(ms, cancellationToken).ConfigureAwait(false);
|
||||
var payload = ms.ToArray();
|
||||
|
||||
var result = await webhookService.ProcessAsync(
|
||||
ScmProvider.Gitea,
|
||||
eventType,
|
||||
deliveryId,
|
||||
signature,
|
||||
payload,
|
||||
integrationId,
|
||||
tenantId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return ToResult(result);
|
||||
}
|
||||
|
||||
private static IResult ToResult(ScmWebhookResult result)
|
||||
{
|
||||
if (!result.Success)
|
||||
{
|
||||
return result.StatusCode switch
|
||||
{
|
||||
401 => Results.Unauthorized(),
|
||||
400 => Results.BadRequest(new { error = result.Error }),
|
||||
_ => Results.Problem(result.Error, statusCode: result.StatusCode)
|
||||
};
|
||||
}
|
||||
|
||||
if (result.StatusCode == 200)
|
||||
{
|
||||
return Results.Ok(new { message = result.Error }); // "Ignored" message
|
||||
}
|
||||
|
||||
return Results.Accepted(value: new
|
||||
{
|
||||
eventId = result.Event?.EventId,
|
||||
eventType = result.Event?.EventType.ToString(),
|
||||
provider = result.Event?.Provider.ToString(),
|
||||
repository = result.Event?.Repository.FullName,
|
||||
triggersDispatched = result.TriggerResult?.TriggersDispatched ?? false,
|
||||
scanTriggersCount = result.TriggerResult?.ScanTriggersCount ?? 0,
|
||||
sbomTriggersCount = result.TriggerResult?.SbomTriggersCount ?? 0
|
||||
});
|
||||
}
|
||||
|
||||
private static bool TryEnsureSealedMode(SignalsSealedModeMonitor monitor, out IResult? failure)
|
||||
{
|
||||
if (!monitor.EnforcementEnabled)
|
||||
{
|
||||
failure = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (monitor.IsCompliant(out var reason))
|
||||
{
|
||||
failure = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
failure = Results.Json(
|
||||
new { error = "sealed-mode evidence invalid", reason },
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using StellaOps.Signals.Scm.Models;
|
||||
|
||||
namespace StellaOps.Signals.Scm.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for routing SCM events to Scanner/Orchestrator triggers.
|
||||
/// </summary>
|
||||
public interface IScmTriggerService
|
||||
{
|
||||
/// <summary>
|
||||
/// Processes a normalized SCM event and triggers appropriate actions.
|
||||
/// </summary>
|
||||
/// <param name="scmEvent">The normalized SCM event.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Trigger result with actions taken.</returns>
|
||||
Task<ScmTriggerResult> ProcessEventAsync(NormalizedScmEvent scmEvent, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of processing an SCM event.
|
||||
/// </summary>
|
||||
public sealed record ScmTriggerResult
|
||||
{
|
||||
/// <summary>Whether any triggers were dispatched.</summary>
|
||||
public bool TriggersDispatched { get; init; }
|
||||
|
||||
/// <summary>Number of scan triggers dispatched.</summary>
|
||||
public int ScanTriggersCount { get; init; }
|
||||
|
||||
/// <summary>Number of SBOM upload triggers dispatched.</summary>
|
||||
public int SbomTriggersCount { get; init; }
|
||||
|
||||
/// <summary>Triggered scan IDs.</summary>
|
||||
public IReadOnlyList<string> ScanIds { get; init; } = [];
|
||||
|
||||
/// <summary>Error messages if any triggers failed.</summary>
|
||||
public IReadOnlyList<string> Errors { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using StellaOps.Signals.Scm.Models;
|
||||
|
||||
namespace StellaOps.Signals.Scm.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for processing incoming SCM webhooks.
|
||||
/// </summary>
|
||||
public interface IScmWebhookService
|
||||
{
|
||||
/// <summary>
|
||||
/// Processes an incoming webhook request.
|
||||
/// </summary>
|
||||
/// <param name="provider">The SCM provider.</param>
|
||||
/// <param name="eventType">Provider-specific event type header.</param>
|
||||
/// <param name="deliveryId">Webhook delivery ID.</param>
|
||||
/// <param name="signature">Webhook signature for validation.</param>
|
||||
/// <param name="payload">Raw request body.</param>
|
||||
/// <param name="integrationId">Integration ID for credential lookup.</param>
|
||||
/// <param name="tenantId">Tenant ID for multi-tenant routing.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Result of webhook processing.</returns>
|
||||
Task<ScmWebhookResult> ProcessAsync(
|
||||
ScmProvider provider,
|
||||
string eventType,
|
||||
string deliveryId,
|
||||
string? signature,
|
||||
byte[] payload,
|
||||
string? integrationId,
|
||||
string? tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of webhook processing.
|
||||
/// </summary>
|
||||
public sealed record ScmWebhookResult
|
||||
{
|
||||
/// <summary>Whether the webhook was processed successfully.</summary>
|
||||
public bool Success { get; init; }
|
||||
|
||||
/// <summary>HTTP status code to return.</summary>
|
||||
public int StatusCode { get; init; }
|
||||
|
||||
/// <summary>Error message if processing failed.</summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>The normalized event if successfully parsed.</summary>
|
||||
public NormalizedScmEvent? Event { get; init; }
|
||||
|
||||
/// <summary>Trigger results if any actions were taken.</summary>
|
||||
public ScmTriggerResult? TriggerResult { get; init; }
|
||||
|
||||
public static ScmWebhookResult Unauthorized(string error) => new()
|
||||
{
|
||||
Success = false,
|
||||
StatusCode = 401,
|
||||
Error = error
|
||||
};
|
||||
|
||||
public static ScmWebhookResult BadRequest(string error) => new()
|
||||
{
|
||||
Success = false,
|
||||
StatusCode = 400,
|
||||
Error = error
|
||||
};
|
||||
|
||||
public static ScmWebhookResult Accepted(NormalizedScmEvent? scmEvent, ScmTriggerResult? triggerResult) => new()
|
||||
{
|
||||
Success = true,
|
||||
StatusCode = 202,
|
||||
Event = scmEvent,
|
||||
TriggerResult = triggerResult
|
||||
};
|
||||
|
||||
public static ScmWebhookResult Ignored(string reason) => new()
|
||||
{
|
||||
Success = true,
|
||||
StatusCode = 200,
|
||||
Error = reason
|
||||
};
|
||||
}
|
||||
149
src/Signals/StellaOps.Signals/Scm/Services/ScmTriggerService.cs
Normal file
149
src/Signals/StellaOps.Signals/Scm/Services/ScmTriggerService.cs
Normal file
@@ -0,0 +1,149 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Signals.Scm.Models;
|
||||
|
||||
namespace StellaOps.Signals.Scm.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Routes SCM events to Scanner/Orchestrator for triggering scans and SBOM uploads.
|
||||
/// </summary>
|
||||
public sealed class ScmTriggerService : IScmTriggerService
|
||||
{
|
||||
private readonly ILogger<ScmTriggerService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public ScmTriggerService(
|
||||
ILogger<ScmTriggerService> logger,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public async Task<ScmTriggerResult> ProcessEventAsync(
|
||||
NormalizedScmEvent scmEvent,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var scanIds = new List<string>();
|
||||
var errors = new List<string>();
|
||||
var scanTriggers = 0;
|
||||
var sbomTriggers = 0;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Processing SCM event {EventId} of type {EventType} from {Provider} for {Repository}",
|
||||
scmEvent.EventId,
|
||||
scmEvent.EventType,
|
||||
scmEvent.Provider,
|
||||
scmEvent.Repository.FullName);
|
||||
|
||||
try
|
||||
{
|
||||
// Determine if this event should trigger a scan
|
||||
if (ShouldTriggerScan(scmEvent))
|
||||
{
|
||||
var scanId = await TriggerScanAsync(scmEvent, cancellationToken).ConfigureAwait(false);
|
||||
if (scanId is not null)
|
||||
{
|
||||
scanIds.Add(scanId);
|
||||
scanTriggers++;
|
||||
_logger.LogInformation("Triggered scan {ScanId} for event {EventId}", scanId, scmEvent.EventId);
|
||||
}
|
||||
}
|
||||
|
||||
// Determine if this event indicates an SBOM upload
|
||||
if (ShouldProcessSbom(scmEvent))
|
||||
{
|
||||
var processed = await ProcessSbomUploadAsync(scmEvent, cancellationToken).ConfigureAwait(false);
|
||||
if (processed)
|
||||
{
|
||||
sbomTriggers++;
|
||||
_logger.LogInformation("Processed SBOM upload for event {EventId}", scmEvent.EventId);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error processing SCM event {EventId}", scmEvent.EventId);
|
||||
errors.Add(ex.Message);
|
||||
}
|
||||
|
||||
return new ScmTriggerResult
|
||||
{
|
||||
TriggersDispatched = scanTriggers > 0 || sbomTriggers > 0,
|
||||
ScanTriggersCount = scanTriggers,
|
||||
SbomTriggersCount = sbomTriggers,
|
||||
ScanIds = scanIds,
|
||||
Errors = errors
|
||||
};
|
||||
}
|
||||
|
||||
private static bool ShouldTriggerScan(NormalizedScmEvent scmEvent)
|
||||
{
|
||||
// Trigger scans for:
|
||||
// - Push to main/release branches
|
||||
// - PR merges
|
||||
// - Releases
|
||||
// - Image pushes
|
||||
// - Successful pipelines
|
||||
return scmEvent.EventType switch
|
||||
{
|
||||
ScmEventType.Push when IsMainOrReleaseBranch(scmEvent.Ref) => true,
|
||||
ScmEventType.PullRequestMerged => true,
|
||||
ScmEventType.ReleasePublished => true,
|
||||
ScmEventType.ImagePushed => true,
|
||||
ScmEventType.PipelineSucceeded => true,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private static bool ShouldProcessSbom(NormalizedScmEvent scmEvent)
|
||||
{
|
||||
return scmEvent.EventType == ScmEventType.SbomUploaded ||
|
||||
scmEvent.Artifact?.Type == ScmArtifactType.Sbom;
|
||||
}
|
||||
|
||||
private static bool IsMainOrReleaseBranch(string? refName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(refName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var normalized = refName.Replace("refs/heads/", "", StringComparison.OrdinalIgnoreCase);
|
||||
return normalized.Equals("main", StringComparison.OrdinalIgnoreCase) ||
|
||||
normalized.Equals("master", StringComparison.OrdinalIgnoreCase) ||
|
||||
normalized.StartsWith("release/", StringComparison.OrdinalIgnoreCase) ||
|
||||
normalized.StartsWith("release-", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private Task<string?> TriggerScanAsync(NormalizedScmEvent scmEvent, CancellationToken cancellationToken)
|
||||
{
|
||||
// Generate a scan ID for tracking
|
||||
var scanId = $"scm-{scmEvent.Provider.ToString().ToLowerInvariant()}-{_timeProvider.GetUtcNow():yyyyMMddHHmmss}-{Guid.NewGuid():N}";
|
||||
|
||||
// In a full implementation, this would:
|
||||
// 1. Call Orchestrator API to enqueue a scan job
|
||||
// 2. Pass repository URL, commit SHA, and credentials
|
||||
// 3. Return the scan job ID
|
||||
|
||||
_logger.LogDebug(
|
||||
"Would trigger scan for {Repository} at commit {Sha}",
|
||||
scmEvent.Repository.FullName,
|
||||
scmEvent.CommitSha);
|
||||
|
||||
return Task.FromResult<string?>(scanId);
|
||||
}
|
||||
|
||||
private Task<bool> ProcessSbomUploadAsync(NormalizedScmEvent scmEvent, CancellationToken cancellationToken)
|
||||
{
|
||||
// In a full implementation, this would:
|
||||
// 1. Download the SBOM artifact
|
||||
// 2. Call SbomService API to ingest the SBOM
|
||||
// 3. Link the SBOM to the repository/commit
|
||||
|
||||
_logger.LogDebug(
|
||||
"Would process SBOM upload for {Repository}",
|
||||
scmEvent.Repository.FullName);
|
||||
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
143
src/Signals/StellaOps.Signals/Scm/Services/ScmWebhookService.cs
Normal file
143
src/Signals/StellaOps.Signals/Scm/Services/ScmWebhookService.cs
Normal file
@@ -0,0 +1,143 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Signals.Options;
|
||||
using StellaOps.Signals.Scm.Models;
|
||||
using StellaOps.Signals.Scm.Webhooks;
|
||||
|
||||
namespace StellaOps.Signals.Scm.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Processes incoming SCM webhooks, validates signatures, and routes events.
|
||||
/// </summary>
|
||||
public sealed class ScmWebhookService : IScmWebhookService
|
||||
{
|
||||
private readonly ILogger<ScmWebhookService> _logger;
|
||||
private readonly SignalsOptions _options;
|
||||
private readonly IScmTriggerService _triggerService;
|
||||
private readonly IReadOnlyDictionary<ScmProvider, IWebhookSignatureValidator> _validators;
|
||||
private readonly IReadOnlyDictionary<ScmProvider, IScmEventMapper> _mappers;
|
||||
|
||||
public ScmWebhookService(
|
||||
ILogger<ScmWebhookService> logger,
|
||||
IOptions<SignalsOptions> options,
|
||||
IScmTriggerService triggerService,
|
||||
IEnumerable<IWebhookSignatureValidator> validators,
|
||||
IEnumerable<IScmEventMapper> mappers)
|
||||
{
|
||||
_logger = logger;
|
||||
_options = options.Value;
|
||||
_triggerService = triggerService;
|
||||
|
||||
// Build lookup dictionaries
|
||||
_validators = new Dictionary<ScmProvider, IWebhookSignatureValidator>
|
||||
{
|
||||
[ScmProvider.GitHub] = validators.OfType<GitHubWebhookValidator>().FirstOrDefault() ?? new GitHubWebhookValidator(),
|
||||
[ScmProvider.GitLab] = validators.OfType<GitLabWebhookValidator>().FirstOrDefault() ?? new GitLabWebhookValidator(),
|
||||
[ScmProvider.Gitea] = validators.OfType<GiteaWebhookValidator>().FirstOrDefault() ?? new GiteaWebhookValidator()
|
||||
};
|
||||
|
||||
_mappers = mappers.ToDictionary(m => m.Provider);
|
||||
}
|
||||
|
||||
public async Task<ScmWebhookResult> ProcessAsync(
|
||||
ScmProvider provider,
|
||||
string eventType,
|
||||
string deliveryId,
|
||||
string? signature,
|
||||
byte[] payload,
|
||||
string? integrationId,
|
||||
string? tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var scope = _logger.BeginScope(new Dictionary<string, object?>
|
||||
{
|
||||
["Provider"] = provider.ToString(),
|
||||
["EventType"] = eventType,
|
||||
["DeliveryId"] = deliveryId,
|
||||
["IntegrationId"] = integrationId,
|
||||
["TenantId"] = tenantId
|
||||
});
|
||||
|
||||
_logger.LogInformation(
|
||||
"Received webhook from {Provider}: event={EventType}, delivery={DeliveryId}",
|
||||
provider, eventType, deliveryId);
|
||||
|
||||
// Validate signature if webhook secret is configured
|
||||
var secret = GetWebhookSecret(provider, integrationId);
|
||||
if (!string.IsNullOrEmpty(secret))
|
||||
{
|
||||
if (!_validators.TryGetValue(provider, out var validator))
|
||||
{
|
||||
_logger.LogWarning("No validator found for provider {Provider}", provider);
|
||||
return ScmWebhookResult.BadRequest($"Unsupported provider: {provider}");
|
||||
}
|
||||
|
||||
if (!validator.IsValid(payload, signature, secret))
|
||||
{
|
||||
_logger.LogWarning("Invalid webhook signature for delivery {DeliveryId}", deliveryId);
|
||||
return ScmWebhookResult.Unauthorized("Invalid webhook signature");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("No webhook secret configured, skipping signature validation");
|
||||
}
|
||||
|
||||
// Parse payload
|
||||
JsonElement payloadJson;
|
||||
try
|
||||
{
|
||||
payloadJson = JsonSerializer.Deserialize<JsonElement>(payload);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to parse webhook payload");
|
||||
return ScmWebhookResult.BadRequest("Invalid JSON payload");
|
||||
}
|
||||
|
||||
// Map to normalized event
|
||||
if (!_mappers.TryGetValue(provider, out var mapper))
|
||||
{
|
||||
_logger.LogWarning("No mapper found for provider {Provider}", provider);
|
||||
return ScmWebhookResult.BadRequest($"Unsupported provider: {provider}");
|
||||
}
|
||||
|
||||
var scmEvent = mapper.Map(eventType, deliveryId, payloadJson);
|
||||
if (scmEvent is null)
|
||||
{
|
||||
_logger.LogDebug("Event type {EventType} is not mapped, ignoring", eventType);
|
||||
return ScmWebhookResult.Ignored($"Event type '{eventType}' is not supported");
|
||||
}
|
||||
|
||||
// Enrich with integration context
|
||||
scmEvent = scmEvent with
|
||||
{
|
||||
IntegrationId = integrationId,
|
||||
TenantId = tenantId
|
||||
};
|
||||
|
||||
// Process triggers
|
||||
var triggerResult = await _triggerService.ProcessEventAsync(scmEvent, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Processed webhook {DeliveryId}: triggers dispatched={Dispatched}, scans={Scans}, sboms={Sboms}",
|
||||
deliveryId,
|
||||
triggerResult.TriggersDispatched,
|
||||
triggerResult.ScanTriggersCount,
|
||||
triggerResult.SbomTriggersCount);
|
||||
|
||||
return ScmWebhookResult.Accepted(scmEvent, triggerResult);
|
||||
}
|
||||
|
||||
private string? GetWebhookSecret(ScmProvider provider, string? integrationId)
|
||||
{
|
||||
// In a full implementation, this would look up the secret from:
|
||||
// 1. Integration-specific AuthRef credentials
|
||||
// 2. Provider-specific configuration
|
||||
// 3. Global fallback configuration
|
||||
|
||||
// For now, return null to skip validation (development mode)
|
||||
return null;
|
||||
}
|
||||
}
|
||||
323
src/Signals/StellaOps.Signals/Scm/Webhooks/GitHubEventMapper.cs
Normal file
323
src/Signals/StellaOps.Signals/Scm/Webhooks/GitHubEventMapper.cs
Normal file
@@ -0,0 +1,323 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.Signals.Scm.Models;
|
||||
|
||||
namespace StellaOps.Signals.Scm.Webhooks;
|
||||
|
||||
/// <summary>
|
||||
/// Maps GitHub webhook events to normalized SCM events.
|
||||
/// </summary>
|
||||
public sealed class GitHubEventMapper : IScmEventMapper
|
||||
{
|
||||
public ScmProvider Provider => ScmProvider.GitHub;
|
||||
|
||||
public NormalizedScmEvent? Map(string eventType, string deliveryId, JsonElement payload)
|
||||
{
|
||||
var (scmEventType, extractor) = eventType.ToLowerInvariant() switch
|
||||
{
|
||||
"push" => (ScmEventType.Push, (Func<JsonElement, (ScmEventType, string?, string?)>)ExtractPushDetails),
|
||||
"pull_request" => (ScmEventType.Unknown, ExtractPullRequestDetails),
|
||||
"release" => (ScmEventType.ReleasePublished, ExtractReleaseDetails),
|
||||
"create" => (ScmEventType.Unknown, ExtractCreateDetails),
|
||||
"workflow_run" => (ScmEventType.Unknown, ExtractWorkflowRunDetails),
|
||||
"check_run" => (ScmEventType.Unknown, ExtractCheckRunDetails),
|
||||
_ => (ScmEventType.Unknown, (Func<JsonElement, (ScmEventType, string?, string?)>?)null)
|
||||
};
|
||||
|
||||
if (extractor is null && scmEventType == ScmEventType.Unknown)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var repository = ExtractRepository(payload);
|
||||
if (repository is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string? commitSha = null;
|
||||
string? refName = null;
|
||||
|
||||
if (extractor is not null)
|
||||
{
|
||||
var (extractedType, sha, @ref) = extractor(payload);
|
||||
if (extractedType != ScmEventType.Unknown)
|
||||
{
|
||||
scmEventType = extractedType;
|
||||
}
|
||||
commitSha = sha;
|
||||
refName = @ref;
|
||||
}
|
||||
|
||||
return new NormalizedScmEvent
|
||||
{
|
||||
EventId = deliveryId,
|
||||
Provider = ScmProvider.GitHub,
|
||||
EventType = scmEventType,
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Repository = repository,
|
||||
Actor = ExtractActor(payload),
|
||||
Ref = refName ?? GetString(payload, "ref"),
|
||||
CommitSha = commitSha ?? GetNestedString(payload, "head_commit", "id"),
|
||||
PullRequest = ExtractPullRequest(payload),
|
||||
Release = ExtractRelease(payload),
|
||||
Pipeline = ExtractWorkflow(payload)
|
||||
};
|
||||
}
|
||||
|
||||
private static (ScmEventType, string?, string?) ExtractPushDetails(JsonElement payload)
|
||||
{
|
||||
var sha = GetNestedString(payload, "head_commit", "id") ?? GetString(payload, "after");
|
||||
var refName = GetString(payload, "ref");
|
||||
return (ScmEventType.Push, sha, refName);
|
||||
}
|
||||
|
||||
private static (ScmEventType, string?, string?) ExtractPullRequestDetails(JsonElement payload)
|
||||
{
|
||||
var action = GetString(payload, "action");
|
||||
var eventType = action switch
|
||||
{
|
||||
"opened" or "reopened" => ScmEventType.PullRequestOpened,
|
||||
"closed" when GetNestedBool(payload, "pull_request", "merged") => ScmEventType.PullRequestMerged,
|
||||
"closed" => ScmEventType.PullRequestClosed,
|
||||
_ => ScmEventType.Unknown
|
||||
};
|
||||
|
||||
var sha = GetNestedString(payload, "pull_request", "head", "sha");
|
||||
var refName = GetNestedString(payload, "pull_request", "head", "ref");
|
||||
return (eventType, sha, refName);
|
||||
}
|
||||
|
||||
private static (ScmEventType, string?, string?) ExtractReleaseDetails(JsonElement payload)
|
||||
{
|
||||
var action = GetString(payload, "action");
|
||||
if (action != "published")
|
||||
{
|
||||
return (ScmEventType.Unknown, null, null);
|
||||
}
|
||||
|
||||
var tagName = GetNestedString(payload, "release", "tag_name");
|
||||
return (ScmEventType.ReleasePublished, null, tagName);
|
||||
}
|
||||
|
||||
private static (ScmEventType, string?, string?) ExtractCreateDetails(JsonElement payload)
|
||||
{
|
||||
var refType = GetString(payload, "ref_type");
|
||||
if (refType != "tag")
|
||||
{
|
||||
return (ScmEventType.Unknown, null, null);
|
||||
}
|
||||
|
||||
var refName = GetString(payload, "ref");
|
||||
return (ScmEventType.TagCreated, null, refName);
|
||||
}
|
||||
|
||||
private static (ScmEventType, string?, string?) ExtractWorkflowRunDetails(JsonElement payload)
|
||||
{
|
||||
var action = GetString(payload, "action");
|
||||
var conclusion = GetNestedString(payload, "workflow_run", "conclusion");
|
||||
|
||||
var eventType = action switch
|
||||
{
|
||||
"requested" or "in_progress" => ScmEventType.PipelineStarted,
|
||||
"completed" when conclusion == "success" => ScmEventType.PipelineSucceeded,
|
||||
"completed" => ScmEventType.PipelineFailed,
|
||||
_ => ScmEventType.Unknown
|
||||
};
|
||||
|
||||
var sha = GetNestedString(payload, "workflow_run", "head_sha");
|
||||
var refName = GetNestedString(payload, "workflow_run", "head_branch");
|
||||
return (eventType, sha, refName);
|
||||
}
|
||||
|
||||
private static (ScmEventType, string?, string?) ExtractCheckRunDetails(JsonElement payload)
|
||||
{
|
||||
var action = GetString(payload, "action");
|
||||
var status = GetNestedString(payload, "check_run", "status");
|
||||
var conclusion = GetNestedString(payload, "check_run", "conclusion");
|
||||
|
||||
var eventType = (action, status, conclusion) switch
|
||||
{
|
||||
("created", _, _) => ScmEventType.PipelineStarted,
|
||||
("completed", _, "success") => ScmEventType.PipelineSucceeded,
|
||||
("completed", _, _) => ScmEventType.PipelineFailed,
|
||||
_ => ScmEventType.Unknown
|
||||
};
|
||||
|
||||
var sha = GetNestedString(payload, "check_run", "head_sha");
|
||||
return (eventType, sha, null);
|
||||
}
|
||||
|
||||
private static ScmRepository? ExtractRepository(JsonElement payload)
|
||||
{
|
||||
if (!payload.TryGetProperty("repository", out var repo))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ScmRepository
|
||||
{
|
||||
Id = GetString(repo, "id")?.ToString(),
|
||||
FullName = GetString(repo, "full_name") ?? string.Empty,
|
||||
Owner = GetNestedString(repo, "owner", "login"),
|
||||
Name = GetString(repo, "name"),
|
||||
CloneUrl = GetString(repo, "clone_url"),
|
||||
DefaultBranch = GetString(repo, "default_branch"),
|
||||
IsPrivate = GetBool(repo, "private")
|
||||
};
|
||||
}
|
||||
|
||||
private static ScmActor? ExtractActor(JsonElement payload)
|
||||
{
|
||||
if (!payload.TryGetProperty("sender", out var sender))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var actorType = GetString(sender, "type") switch
|
||||
{
|
||||
"User" => ScmActorType.User,
|
||||
"Bot" => ScmActorType.Bot,
|
||||
_ => ScmActorType.Unknown
|
||||
};
|
||||
|
||||
return new ScmActor
|
||||
{
|
||||
Id = GetString(sender, "id")?.ToString(),
|
||||
Username = GetString(sender, "login"),
|
||||
Type = actorType
|
||||
};
|
||||
}
|
||||
|
||||
private static ScmPullRequest? ExtractPullRequest(JsonElement payload)
|
||||
{
|
||||
if (!payload.TryGetProperty("pull_request", out var pr))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ScmPullRequest
|
||||
{
|
||||
Number = GetInt(pr, "number"),
|
||||
Title = GetString(pr, "title"),
|
||||
SourceBranch = GetNestedString(pr, "head", "ref"),
|
||||
TargetBranch = GetNestedString(pr, "base", "ref"),
|
||||
State = GetString(pr, "state"),
|
||||
Url = GetString(pr, "html_url")
|
||||
};
|
||||
}
|
||||
|
||||
private static ScmRelease? ExtractRelease(JsonElement payload)
|
||||
{
|
||||
if (!payload.TryGetProperty("release", out var release))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ScmRelease
|
||||
{
|
||||
Id = GetString(release, "id")?.ToString(),
|
||||
TagName = GetString(release, "tag_name"),
|
||||
Name = GetString(release, "name"),
|
||||
IsPrerelease = GetBool(release, "prerelease"),
|
||||
IsDraft = GetBool(release, "draft"),
|
||||
Url = GetString(release, "html_url")
|
||||
};
|
||||
}
|
||||
|
||||
private static ScmPipeline? ExtractWorkflow(JsonElement payload)
|
||||
{
|
||||
if (!payload.TryGetProperty("workflow_run", out var run))
|
||||
{
|
||||
if (!payload.TryGetProperty("check_run", out var checkRun))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ScmPipeline
|
||||
{
|
||||
Id = GetString(checkRun, "id")?.ToString(),
|
||||
Name = GetString(checkRun, "name"),
|
||||
Status = GetString(checkRun, "status") switch
|
||||
{
|
||||
"queued" => ScmPipelineStatus.Queued,
|
||||
"in_progress" => ScmPipelineStatus.InProgress,
|
||||
"completed" => ScmPipelineStatus.Completed,
|
||||
_ => ScmPipelineStatus.Unknown
|
||||
},
|
||||
Conclusion = GetString(checkRun, "conclusion"),
|
||||
Url = GetString(checkRun, "html_url")
|
||||
};
|
||||
}
|
||||
|
||||
return new ScmPipeline
|
||||
{
|
||||
Id = GetString(run, "id")?.ToString(),
|
||||
Name = GetString(run, "name"),
|
||||
RunNumber = GetLong(run, "run_number"),
|
||||
Status = GetString(run, "status") switch
|
||||
{
|
||||
"queued" => ScmPipelineStatus.Queued,
|
||||
"in_progress" => ScmPipelineStatus.InProgress,
|
||||
"completed" => ScmPipelineStatus.Completed,
|
||||
_ => ScmPipelineStatus.Unknown
|
||||
},
|
||||
Conclusion = GetString(run, "conclusion"),
|
||||
Url = GetString(run, "html_url")
|
||||
};
|
||||
}
|
||||
|
||||
private static string? GetString(JsonElement element, string property)
|
||||
{
|
||||
return element.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.String
|
||||
? value.GetString()
|
||||
: null;
|
||||
}
|
||||
|
||||
private static bool GetBool(JsonElement element, string property)
|
||||
{
|
||||
return element.TryGetProperty(property, out var value) &&
|
||||
value.ValueKind == JsonValueKind.True;
|
||||
}
|
||||
|
||||
private static int GetInt(JsonElement element, string property)
|
||||
{
|
||||
return element.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.Number
|
||||
? value.GetInt32()
|
||||
: 0;
|
||||
}
|
||||
|
||||
private static long GetLong(JsonElement element, string property)
|
||||
{
|
||||
return element.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.Number
|
||||
? value.GetInt64()
|
||||
: 0;
|
||||
}
|
||||
|
||||
private static string? GetNestedString(JsonElement element, params string[] path)
|
||||
{
|
||||
var current = element;
|
||||
foreach (var prop in path)
|
||||
{
|
||||
if (!current.TryGetProperty(prop, out current))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return current.ValueKind == JsonValueKind.String ? current.GetString() : null;
|
||||
}
|
||||
|
||||
private static bool GetNestedBool(JsonElement element, params string[] path)
|
||||
{
|
||||
var current = element;
|
||||
foreach (var prop in path)
|
||||
{
|
||||
if (!current.TryGetProperty(prop, out current))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return current.ValueKind == JsonValueKind.True;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Signals.Scm.Webhooks;
|
||||
|
||||
/// <summary>
|
||||
/// Validates GitHub webhook signatures using HMAC-SHA256.
|
||||
/// </summary>
|
||||
public sealed class GitHubWebhookValidator : IWebhookSignatureValidator
|
||||
{
|
||||
private const string SignaturePrefix = "sha256=";
|
||||
|
||||
public bool IsValid(ReadOnlySpan<byte> payload, string? signature, string secret)
|
||||
{
|
||||
if (string.IsNullOrEmpty(signature) || string.IsNullOrEmpty(secret))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!signature.StartsWith(SignaturePrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var expectedSignature = signature[SignaturePrefix.Length..];
|
||||
var secretBytes = Encoding.UTF8.GetBytes(secret);
|
||||
var computedHash = HMACSHA256.HashData(secretBytes, payload);
|
||||
var computedSignature = Convert.ToHexStringLower(computedHash);
|
||||
|
||||
return CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.UTF8.GetBytes(computedSignature),
|
||||
Encoding.UTF8.GetBytes(expectedSignature.ToLowerInvariant()));
|
||||
}
|
||||
}
|
||||
317
src/Signals/StellaOps.Signals/Scm/Webhooks/GitLabEventMapper.cs
Normal file
317
src/Signals/StellaOps.Signals/Scm/Webhooks/GitLabEventMapper.cs
Normal file
@@ -0,0 +1,317 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.Signals.Scm.Models;
|
||||
|
||||
namespace StellaOps.Signals.Scm.Webhooks;
|
||||
|
||||
/// <summary>
|
||||
/// Maps GitLab webhook events to normalized SCM events.
|
||||
/// </summary>
|
||||
public sealed class GitLabEventMapper : IScmEventMapper
|
||||
{
|
||||
public ScmProvider Provider => ScmProvider.GitLab;
|
||||
|
||||
public NormalizedScmEvent? Map(string eventType, string deliveryId, JsonElement payload)
|
||||
{
|
||||
var objectKind = GetString(payload, "object_kind") ?? eventType;
|
||||
|
||||
var (scmEventType, commitSha, refName) = objectKind.ToLowerInvariant() switch
|
||||
{
|
||||
"push" => ExtractPushDetails(payload),
|
||||
"merge_request" => ExtractMergeRequestDetails(payload),
|
||||
"tag_push" => ExtractTagPushDetails(payload),
|
||||
"release" => (ScmEventType.ReleasePublished, (string?)null, GetNestedString(payload, "tag")),
|
||||
"pipeline" => ExtractPipelineDetails(payload),
|
||||
"build" or "job" => ExtractJobDetails(payload),
|
||||
_ => (ScmEventType.Unknown, (string?)null, (string?)null)
|
||||
};
|
||||
|
||||
if (scmEventType == ScmEventType.Unknown)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var repository = ExtractRepository(payload);
|
||||
if (repository is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new NormalizedScmEvent
|
||||
{
|
||||
EventId = deliveryId,
|
||||
Provider = ScmProvider.GitLab,
|
||||
EventType = scmEventType,
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Repository = repository,
|
||||
Actor = ExtractActor(payload),
|
||||
Ref = refName,
|
||||
CommitSha = commitSha,
|
||||
PullRequest = ExtractMergeRequest(payload),
|
||||
Release = ExtractRelease(payload),
|
||||
Pipeline = ExtractPipeline(payload)
|
||||
};
|
||||
}
|
||||
|
||||
private static (ScmEventType, string?, string?) ExtractPushDetails(JsonElement payload)
|
||||
{
|
||||
var sha = GetString(payload, "checkout_sha") ?? GetString(payload, "after");
|
||||
var refName = GetString(payload, "ref");
|
||||
return (ScmEventType.Push, sha, refName);
|
||||
}
|
||||
|
||||
private static (ScmEventType, string?, string?) ExtractMergeRequestDetails(JsonElement payload)
|
||||
{
|
||||
if (!payload.TryGetProperty("object_attributes", out var attrs))
|
||||
{
|
||||
return (ScmEventType.Unknown, null, null);
|
||||
}
|
||||
|
||||
var action = GetString(attrs, "action");
|
||||
var state = GetString(attrs, "state");
|
||||
|
||||
var eventType = (action, state) switch
|
||||
{
|
||||
("open", _) or ("reopen", _) => ScmEventType.PullRequestOpened,
|
||||
("merge", _) or (_, "merged") => ScmEventType.PullRequestMerged,
|
||||
("close", _) or (_, "closed") => ScmEventType.PullRequestClosed,
|
||||
_ => ScmEventType.Unknown
|
||||
};
|
||||
|
||||
var sha = GetNestedString(payload, "object_attributes", "last_commit", "id");
|
||||
var refName = GetString(attrs, "source_branch");
|
||||
return (eventType, sha, refName);
|
||||
}
|
||||
|
||||
private static (ScmEventType, string?, string?) ExtractTagPushDetails(JsonElement payload)
|
||||
{
|
||||
var refName = GetString(payload, "ref");
|
||||
var sha = GetString(payload, "checkout_sha");
|
||||
return (ScmEventType.TagCreated, sha, refName);
|
||||
}
|
||||
|
||||
private static (ScmEventType, string?, string?) ExtractPipelineDetails(JsonElement payload)
|
||||
{
|
||||
if (!payload.TryGetProperty("object_attributes", out var attrs))
|
||||
{
|
||||
return (ScmEventType.Unknown, null, null);
|
||||
}
|
||||
|
||||
var status = GetString(attrs, "status");
|
||||
var eventType = status switch
|
||||
{
|
||||
"pending" or "running" => ScmEventType.PipelineStarted,
|
||||
"success" => ScmEventType.PipelineSucceeded,
|
||||
"failed" or "canceled" => ScmEventType.PipelineFailed,
|
||||
_ => ScmEventType.Unknown
|
||||
};
|
||||
|
||||
var sha = GetString(attrs, "sha");
|
||||
var refName = GetString(attrs, "ref");
|
||||
return (eventType, sha, refName);
|
||||
}
|
||||
|
||||
private static (ScmEventType, string?, string?) ExtractJobDetails(JsonElement payload)
|
||||
{
|
||||
var status = GetString(payload, "build_status");
|
||||
var eventType = status switch
|
||||
{
|
||||
"pending" or "running" => ScmEventType.PipelineStarted,
|
||||
"success" => ScmEventType.PipelineSucceeded,
|
||||
"failed" => ScmEventType.PipelineFailed,
|
||||
_ => ScmEventType.Unknown
|
||||
};
|
||||
|
||||
var sha = GetString(payload, "sha");
|
||||
var refName = GetString(payload, "ref");
|
||||
return (eventType, sha, refName);
|
||||
}
|
||||
|
||||
private static ScmRepository? ExtractRepository(JsonElement payload)
|
||||
{
|
||||
if (!payload.TryGetProperty("project", out var project))
|
||||
{
|
||||
if (!payload.TryGetProperty("repository", out var repo))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ScmRepository
|
||||
{
|
||||
Id = GetString(repo, "id")?.ToString(),
|
||||
FullName = GetString(repo, "path_with_namespace") ?? GetString(repo, "name") ?? string.Empty,
|
||||
Name = GetString(repo, "name"),
|
||||
CloneUrl = GetString(repo, "git_http_url"),
|
||||
DefaultBranch = GetString(repo, "default_branch"),
|
||||
IsPrivate = GetString(repo, "visibility") != "public"
|
||||
};
|
||||
}
|
||||
|
||||
return new ScmRepository
|
||||
{
|
||||
Id = GetString(project, "id")?.ToString(),
|
||||
FullName = GetString(project, "path_with_namespace") ?? string.Empty,
|
||||
Owner = GetString(project, "namespace"),
|
||||
Name = GetString(project, "name"),
|
||||
CloneUrl = GetString(project, "git_http_url"),
|
||||
DefaultBranch = GetString(project, "default_branch"),
|
||||
IsPrivate = GetString(project, "visibility") != "public"
|
||||
};
|
||||
}
|
||||
|
||||
private static ScmActor? ExtractActor(JsonElement payload)
|
||||
{
|
||||
if (!payload.TryGetProperty("user", out var user))
|
||||
{
|
||||
var userName = GetString(payload, "user_name");
|
||||
var userId = GetString(payload, "user_id");
|
||||
if (userName is null && userId is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ScmActor
|
||||
{
|
||||
Id = userId,
|
||||
Username = GetString(payload, "user_username"),
|
||||
DisplayName = userName,
|
||||
Type = ScmActorType.User
|
||||
};
|
||||
}
|
||||
|
||||
return new ScmActor
|
||||
{
|
||||
Id = GetString(user, "id")?.ToString(),
|
||||
Username = GetString(user, "username"),
|
||||
DisplayName = GetString(user, "name"),
|
||||
Type = ScmActorType.User
|
||||
};
|
||||
}
|
||||
|
||||
private static ScmPullRequest? ExtractMergeRequest(JsonElement payload)
|
||||
{
|
||||
if (!payload.TryGetProperty("object_attributes", out var attrs))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (GetString(payload, "object_kind") != "merge_request")
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ScmPullRequest
|
||||
{
|
||||
Number = GetInt(attrs, "iid"),
|
||||
Title = GetString(attrs, "title"),
|
||||
SourceBranch = GetString(attrs, "source_branch"),
|
||||
TargetBranch = GetString(attrs, "target_branch"),
|
||||
State = GetString(attrs, "state"),
|
||||
Url = GetString(attrs, "url")
|
||||
};
|
||||
}
|
||||
|
||||
private static ScmRelease? ExtractRelease(JsonElement payload)
|
||||
{
|
||||
if (GetString(payload, "object_kind") != "release")
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ScmRelease
|
||||
{
|
||||
Id = GetString(payload, "id")?.ToString(),
|
||||
TagName = GetString(payload, "tag"),
|
||||
Name = GetString(payload, "name"),
|
||||
Url = GetString(payload, "url")
|
||||
};
|
||||
}
|
||||
|
||||
private static ScmPipeline? ExtractPipeline(JsonElement payload)
|
||||
{
|
||||
if (!payload.TryGetProperty("object_attributes", out var attrs))
|
||||
{
|
||||
// Check for pipeline in build events
|
||||
var pipelineId = GetString(payload, "pipeline_id");
|
||||
if (pipelineId is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ScmPipeline
|
||||
{
|
||||
Id = pipelineId,
|
||||
Name = GetString(payload, "build_name"),
|
||||
Status = GetString(payload, "build_status") switch
|
||||
{
|
||||
"pending" => ScmPipelineStatus.Queued,
|
||||
"running" => ScmPipelineStatus.InProgress,
|
||||
"success" or "failed" or "canceled" => ScmPipelineStatus.Completed,
|
||||
_ => ScmPipelineStatus.Unknown
|
||||
},
|
||||
Conclusion = GetString(payload, "build_status")
|
||||
};
|
||||
}
|
||||
|
||||
if (GetString(payload, "object_kind") != "pipeline")
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ScmPipeline
|
||||
{
|
||||
Id = GetString(attrs, "id")?.ToString(),
|
||||
Status = GetString(attrs, "status") switch
|
||||
{
|
||||
"pending" => ScmPipelineStatus.Queued,
|
||||
"running" => ScmPipelineStatus.InProgress,
|
||||
"success" or "failed" or "canceled" => ScmPipelineStatus.Completed,
|
||||
_ => ScmPipelineStatus.Unknown
|
||||
},
|
||||
Conclusion = GetString(attrs, "status"),
|
||||
DurationSeconds = GetDouble(attrs, "duration")
|
||||
};
|
||||
}
|
||||
|
||||
private static string? GetString(JsonElement element, string property)
|
||||
{
|
||||
if (!element.TryGetProperty(property, out var value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => value.GetString(),
|
||||
JsonValueKind.Number => value.ToString(),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static int GetInt(JsonElement element, string property)
|
||||
{
|
||||
return element.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.Number
|
||||
? value.GetInt32()
|
||||
: 0;
|
||||
}
|
||||
|
||||
private static double? GetDouble(JsonElement element, string property)
|
||||
{
|
||||
return element.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.Number
|
||||
? value.GetDouble()
|
||||
: null;
|
||||
}
|
||||
|
||||
private static string? GetNestedString(JsonElement element, params string[] path)
|
||||
{
|
||||
var current = element;
|
||||
foreach (var prop in path)
|
||||
{
|
||||
if (!current.TryGetProperty(prop, out current))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return current.ValueKind == JsonValueKind.String ? current.GetString() : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Signals.Scm.Webhooks;
|
||||
|
||||
/// <summary>
|
||||
/// Validates GitLab webhook signatures using X-Gitlab-Token header.
|
||||
/// GitLab uses a simple token comparison (not HMAC).
|
||||
/// </summary>
|
||||
public sealed class GitLabWebhookValidator : IWebhookSignatureValidator
|
||||
{
|
||||
public bool IsValid(ReadOnlySpan<byte> payload, string? signature, string secret)
|
||||
{
|
||||
if (string.IsNullOrEmpty(signature) || string.IsNullOrEmpty(secret))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// GitLab uses direct token comparison via X-Gitlab-Token header
|
||||
return CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.UTF8.GetBytes(signature),
|
||||
Encoding.UTF8.GetBytes(secret));
|
||||
}
|
||||
}
|
||||
216
src/Signals/StellaOps.Signals/Scm/Webhooks/GiteaEventMapper.cs
Normal file
216
src/Signals/StellaOps.Signals/Scm/Webhooks/GiteaEventMapper.cs
Normal file
@@ -0,0 +1,216 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.Signals.Scm.Models;
|
||||
|
||||
namespace StellaOps.Signals.Scm.Webhooks;
|
||||
|
||||
/// <summary>
|
||||
/// Maps Gitea webhook events to normalized SCM events.
|
||||
/// Gitea's webhook format is similar to GitHub.
|
||||
/// </summary>
|
||||
public sealed class GiteaEventMapper : IScmEventMapper
|
||||
{
|
||||
public ScmProvider Provider => ScmProvider.Gitea;
|
||||
|
||||
public NormalizedScmEvent? Map(string eventType, string deliveryId, JsonElement payload)
|
||||
{
|
||||
var (scmEventType, commitSha, refName) = eventType.ToLowerInvariant() switch
|
||||
{
|
||||
"push" => ExtractPushDetails(payload),
|
||||
"pull_request" => ExtractPullRequestDetails(payload),
|
||||
"release" => ExtractReleaseDetails(payload),
|
||||
"create" => ExtractCreateDetails(payload),
|
||||
_ => (ScmEventType.Unknown, (string?)null, (string?)null)
|
||||
};
|
||||
|
||||
if (scmEventType == ScmEventType.Unknown)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var repository = ExtractRepository(payload);
|
||||
if (repository is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new NormalizedScmEvent
|
||||
{
|
||||
EventId = deliveryId,
|
||||
Provider = ScmProvider.Gitea,
|
||||
EventType = scmEventType,
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Repository = repository,
|
||||
Actor = ExtractActor(payload),
|
||||
Ref = refName ?? GetString(payload, "ref"),
|
||||
CommitSha = commitSha,
|
||||
PullRequest = ExtractPullRequest(payload),
|
||||
Release = ExtractRelease(payload)
|
||||
};
|
||||
}
|
||||
|
||||
private static (ScmEventType, string?, string?) ExtractPushDetails(JsonElement payload)
|
||||
{
|
||||
var sha = GetString(payload, "after");
|
||||
var refName = GetString(payload, "ref");
|
||||
return (ScmEventType.Push, sha, refName);
|
||||
}
|
||||
|
||||
private static (ScmEventType, string?, string?) ExtractPullRequestDetails(JsonElement payload)
|
||||
{
|
||||
var action = GetString(payload, "action");
|
||||
if (!payload.TryGetProperty("pull_request", out var pr))
|
||||
{
|
||||
return (ScmEventType.Unknown, null, null);
|
||||
}
|
||||
|
||||
var merged = GetBool(pr, "merged");
|
||||
var eventType = action switch
|
||||
{
|
||||
"opened" or "reopened" => ScmEventType.PullRequestOpened,
|
||||
"closed" when merged => ScmEventType.PullRequestMerged,
|
||||
"closed" => ScmEventType.PullRequestClosed,
|
||||
_ => ScmEventType.Unknown
|
||||
};
|
||||
|
||||
var sha = GetNestedString(pr, "head", "sha");
|
||||
var refName = GetNestedString(pr, "head", "ref");
|
||||
return (eventType, sha, refName);
|
||||
}
|
||||
|
||||
private static (ScmEventType, string?, string?) ExtractReleaseDetails(JsonElement payload)
|
||||
{
|
||||
var action = GetString(payload, "action");
|
||||
if (action != "published")
|
||||
{
|
||||
return (ScmEventType.Unknown, null, null);
|
||||
}
|
||||
|
||||
var tagName = GetNestedString(payload, "release", "tag_name");
|
||||
return (ScmEventType.ReleasePublished, null, tagName);
|
||||
}
|
||||
|
||||
private static (ScmEventType, string?, string?) ExtractCreateDetails(JsonElement payload)
|
||||
{
|
||||
var refType = GetString(payload, "ref_type");
|
||||
if (refType != "tag")
|
||||
{
|
||||
return (ScmEventType.Unknown, null, null);
|
||||
}
|
||||
|
||||
var refName = GetString(payload, "ref");
|
||||
return (ScmEventType.TagCreated, null, refName);
|
||||
}
|
||||
|
||||
private static ScmRepository? ExtractRepository(JsonElement payload)
|
||||
{
|
||||
if (!payload.TryGetProperty("repository", out var repo))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ScmRepository
|
||||
{
|
||||
Id = GetString(repo, "id")?.ToString(),
|
||||
FullName = GetString(repo, "full_name") ?? string.Empty,
|
||||
Owner = GetNestedString(repo, "owner", "login") ?? GetNestedString(repo, "owner", "username"),
|
||||
Name = GetString(repo, "name"),
|
||||
CloneUrl = GetString(repo, "clone_url"),
|
||||
DefaultBranch = GetString(repo, "default_branch"),
|
||||
IsPrivate = GetBool(repo, "private")
|
||||
};
|
||||
}
|
||||
|
||||
private static ScmActor? ExtractActor(JsonElement payload)
|
||||
{
|
||||
if (!payload.TryGetProperty("sender", out var sender))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ScmActor
|
||||
{
|
||||
Id = GetString(sender, "id")?.ToString(),
|
||||
Username = GetString(sender, "login") ?? GetString(sender, "username"),
|
||||
DisplayName = GetString(sender, "full_name"),
|
||||
Type = ScmActorType.User
|
||||
};
|
||||
}
|
||||
|
||||
private static ScmPullRequest? ExtractPullRequest(JsonElement payload)
|
||||
{
|
||||
if (!payload.TryGetProperty("pull_request", out var pr))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ScmPullRequest
|
||||
{
|
||||
Number = GetInt(pr, "number"),
|
||||
Title = GetString(pr, "title"),
|
||||
SourceBranch = GetNestedString(pr, "head", "ref"),
|
||||
TargetBranch = GetNestedString(pr, "base", "ref"),
|
||||
State = GetString(pr, "state"),
|
||||
Url = GetString(pr, "html_url") ?? GetString(pr, "url")
|
||||
};
|
||||
}
|
||||
|
||||
private static ScmRelease? ExtractRelease(JsonElement payload)
|
||||
{
|
||||
if (!payload.TryGetProperty("release", out var release))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ScmRelease
|
||||
{
|
||||
Id = GetString(release, "id")?.ToString(),
|
||||
TagName = GetString(release, "tag_name"),
|
||||
Name = GetString(release, "name"),
|
||||
IsPrerelease = GetBool(release, "prerelease"),
|
||||
IsDraft = GetBool(release, "draft"),
|
||||
Url = GetString(release, "html_url") ?? GetString(release, "url")
|
||||
};
|
||||
}
|
||||
|
||||
private static string? GetString(JsonElement element, string property)
|
||||
{
|
||||
if (!element.TryGetProperty(property, out var value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => value.GetString(),
|
||||
JsonValueKind.Number => value.ToString(),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static bool GetBool(JsonElement element, string property)
|
||||
{
|
||||
return element.TryGetProperty(property, out var value) &&
|
||||
value.ValueKind == JsonValueKind.True;
|
||||
}
|
||||
|
||||
private static int GetInt(JsonElement element, string property)
|
||||
{
|
||||
return element.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.Number
|
||||
? value.GetInt32()
|
||||
: 0;
|
||||
}
|
||||
|
||||
private static string? GetNestedString(JsonElement element, params string[] path)
|
||||
{
|
||||
var current = element;
|
||||
foreach (var prop in path)
|
||||
{
|
||||
if (!current.TryGetProperty(prop, out current))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return current.ValueKind == JsonValueKind.String ? current.GetString() : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Signals.Scm.Webhooks;
|
||||
|
||||
/// <summary>
|
||||
/// Validates Gitea webhook signatures using HMAC-SHA256.
|
||||
/// Gitea uses the same signature format as GitHub.
|
||||
/// </summary>
|
||||
public sealed class GiteaWebhookValidator : IWebhookSignatureValidator
|
||||
{
|
||||
private const string Sha256Prefix = "sha256=";
|
||||
private const string Sha1Prefix = "sha1=";
|
||||
|
||||
public bool IsValid(ReadOnlySpan<byte> payload, string? signature, string secret)
|
||||
{
|
||||
if (string.IsNullOrEmpty(signature) || string.IsNullOrEmpty(secret))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var secretBytes = Encoding.UTF8.GetBytes(secret);
|
||||
|
||||
// Gitea supports both SHA256 and SHA1 signatures
|
||||
if (signature.StartsWith(Sha256Prefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var expectedSignature = signature[Sha256Prefix.Length..];
|
||||
var computedHash = HMACSHA256.HashData(secretBytes, payload);
|
||||
var computedSignature = Convert.ToHexStringLower(computedHash);
|
||||
|
||||
return CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.UTF8.GetBytes(computedSignature),
|
||||
Encoding.UTF8.GetBytes(expectedSignature.ToLowerInvariant()));
|
||||
}
|
||||
|
||||
if (signature.StartsWith(Sha1Prefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var expectedSignature = signature[Sha1Prefix.Length..];
|
||||
var computedHash = HMACSHA1.HashData(secretBytes, payload);
|
||||
var computedSignature = Convert.ToHexStringLower(computedHash);
|
||||
|
||||
return CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.UTF8.GetBytes(computedSignature),
|
||||
Encoding.UTF8.GetBytes(expectedSignature.ToLowerInvariant()));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.Signals.Scm.Models;
|
||||
|
||||
namespace StellaOps.Signals.Scm.Webhooks;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for mapping provider-specific webhook payloads to normalized events.
|
||||
/// </summary>
|
||||
public interface IScmEventMapper
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the provider this mapper handles.
|
||||
/// </summary>
|
||||
ScmProvider Provider { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Maps a webhook payload to a normalized event.
|
||||
/// </summary>
|
||||
/// <param name="eventType">Provider-specific event type header value.</param>
|
||||
/// <param name="deliveryId">Webhook delivery ID.</param>
|
||||
/// <param name="payload">JSON payload.</param>
|
||||
/// <returns>Normalized event or null if event type is not supported.</returns>
|
||||
NormalizedScmEvent? Map(string eventType, string deliveryId, JsonElement payload);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace StellaOps.Signals.Scm.Webhooks;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for webhook signature validation.
|
||||
/// </summary>
|
||||
public interface IWebhookSignatureValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates the webhook signature.
|
||||
/// </summary>
|
||||
/// <param name="payload">Raw request body bytes.</param>
|
||||
/// <param name="signature">Signature from request header.</param>
|
||||
/// <param name="secret">Webhook secret.</param>
|
||||
/// <returns>True if signature is valid.</returns>
|
||||
bool IsValid(ReadOnlySpan<byte> payload, string? signature, string secret);
|
||||
}
|
||||
@@ -9,7 +9,6 @@ using StellaOps.Signals.Persistence.Postgres.Repositories;
|
||||
using StellaOps.Signals.Services;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace StellaOps.Signals.Persistence.Tests;
|
||||
|
||||
@@ -48,12 +47,12 @@ public sealed class CallGraphProjectionIntegrationTests : IAsyncLifetime
|
||||
NullLogger<CallGraphSyncService>.Instance);
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
await _fixture.ExecuteSqlAsync("TRUNCATE TABLE signals.scans CASCADE;");
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _dataSource.DisposeAsync();
|
||||
}
|
||||
@@ -188,3 +187,6 @@ public sealed class CallGraphProjectionIntegrationTests : IAsyncLifetime
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -41,12 +41,12 @@ public sealed class CallGraphSyncServiceTests : IAsyncLifetime
|
||||
_queryRepository = new PostgresCallGraphQueryRepository(_dataSource, NullLogger<PostgresCallGraphQueryRepository>.Instance);
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
await _fixture.ExecuteSqlAsync("TRUNCATE TABLE signals.scans CASCADE;");
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _dataSource.DisposeAsync();
|
||||
}
|
||||
@@ -134,3 +134,6 @@ public sealed class CallGraphSyncServiceTests : IAsyncLifetime
|
||||
stats2.EdgeCount.Should().Be(1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -25,12 +25,12 @@ public sealed class PostgresCallgraphRepositoryTests : IAsyncLifetime
|
||||
_repository = new PostgresCallgraphRepository(dataSource, NullLogger<PostgresCallgraphRepository>.Instance);
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
@@ -154,3 +154,6 @@ public sealed class PostgresCallgraphRepositoryTests : IAsyncLifetime
|
||||
result.Id.Should().HaveLength(32); // GUID without hyphens
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
// <copyright file="ScmEventMapperTests.cs" company="Stella Operations">
|
||||
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Text.Json;
|
||||
using StellaOps.Signals.Scm.Models;
|
||||
using StellaOps.Signals.Scm.Webhooks;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Signals.Tests.Scm;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for SCM event mappers.
|
||||
/// @sprint SPRINT_20251229_013_SIGNALS_scm_ci_connectors
|
||||
/// </summary>
|
||||
public sealed class ScmEventMapperTests
|
||||
{
|
||||
#region GitHub Event Mapper Tests
|
||||
|
||||
[Fact]
|
||||
public void GitHubMapper_PushEvent_MapsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var mapper = new GitHubEventMapper();
|
||||
var payload = CreateGitHubPushPayload();
|
||||
|
||||
// Act
|
||||
var result = mapper.Map("push", "delivery-123", payload);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(ScmProvider.GitHub, result.Provider);
|
||||
Assert.Equal(ScmEventType.Push, result.EventType);
|
||||
Assert.Equal("delivery-123", result.EventId);
|
||||
Assert.Equal("refs/heads/main", result.Ref);
|
||||
Assert.NotNull(result.Repository);
|
||||
Assert.Equal("owner/repo", result.Repository.FullName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GitHubMapper_PullRequestMergedEvent_MapsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var mapper = new GitHubEventMapper();
|
||||
var payload = CreateGitHubPrMergedPayload();
|
||||
|
||||
// Act
|
||||
var result = mapper.Map("pull_request", "delivery-456", payload);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(ScmProvider.GitHub, result.Provider);
|
||||
Assert.Equal(ScmEventType.PullRequestMerged, result.EventType);
|
||||
Assert.NotNull(result.PullRequest);
|
||||
Assert.Equal(42, result.PullRequest.Number);
|
||||
Assert.Equal("closed", result.PullRequest.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GitHubMapper_ReleaseEvent_MapsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var mapper = new GitHubEventMapper();
|
||||
var payload = CreateGitHubReleasePayload();
|
||||
|
||||
// Act
|
||||
var result = mapper.Map("release", "delivery-789", payload);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(ScmProvider.GitHub, result.Provider);
|
||||
Assert.Equal(ScmEventType.ReleasePublished, result.EventType);
|
||||
Assert.NotNull(result.Release);
|
||||
Assert.Equal("v1.0.0", result.Release.TagName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GitHubMapper_UnknownEvent_ReturnsUnknownType()
|
||||
{
|
||||
// Arrange
|
||||
var mapper = new GitHubEventMapper();
|
||||
var payload = JsonSerializer.SerializeToElement(new { });
|
||||
|
||||
// Act
|
||||
var result = mapper.Map("unknown_event", "delivery-000", payload);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(ScmEventType.Unknown, result.EventType);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GitLab Event Mapper Tests
|
||||
|
||||
[Fact]
|
||||
public void GitLabMapper_PushEvent_MapsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var mapper = new GitLabEventMapper();
|
||||
var payload = CreateGitLabPushPayload();
|
||||
|
||||
// Act
|
||||
var result = mapper.Map("Push Hook", "delivery-123", payload);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(ScmProvider.GitLab, result.Provider);
|
||||
Assert.Equal(ScmEventType.Push, result.EventType);
|
||||
Assert.Equal("refs/heads/main", result.Ref);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GitLabMapper_MergeRequestEvent_MapsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var mapper = new GitLabEventMapper();
|
||||
var payload = CreateGitLabMrMergedPayload();
|
||||
|
||||
// Act
|
||||
var result = mapper.Map("Merge Request Hook", "delivery-456", payload);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(ScmProvider.GitLab, result.Provider);
|
||||
Assert.Equal(ScmEventType.PullRequestMerged, result.EventType);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Gitea Event Mapper Tests
|
||||
|
||||
[Fact]
|
||||
public void GiteaMapper_PushEvent_MapsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var mapper = new GiteaEventMapper();
|
||||
var payload = CreateGiteaPushPayload();
|
||||
|
||||
// Act
|
||||
var result = mapper.Map("push", "delivery-123", payload);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(ScmProvider.Gitea, result.Provider);
|
||||
Assert.Equal(ScmEventType.Push, result.EventType);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static JsonElement CreateGitHubPushPayload()
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
@ref = "refs/heads/main",
|
||||
after = "abc123def456",
|
||||
repository = new
|
||||
{
|
||||
id = 12345,
|
||||
full_name = "owner/repo",
|
||||
clone_url = "https://github.com/owner/repo.git"
|
||||
},
|
||||
sender = new
|
||||
{
|
||||
login = "testuser",
|
||||
id = 1
|
||||
}
|
||||
};
|
||||
return JsonSerializer.SerializeToElement(payload);
|
||||
}
|
||||
|
||||
private static JsonElement CreateGitHubPrMergedPayload()
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
action = "closed",
|
||||
pull_request = new
|
||||
{
|
||||
number = 42,
|
||||
merged = true,
|
||||
title = "Test PR",
|
||||
head = new { sha = "abc123" },
|
||||
@base = new { @ref = "main" }
|
||||
},
|
||||
repository = new
|
||||
{
|
||||
id = 12345,
|
||||
full_name = "owner/repo"
|
||||
}
|
||||
};
|
||||
return JsonSerializer.SerializeToElement(payload);
|
||||
}
|
||||
|
||||
private static JsonElement CreateGitHubReleasePayload()
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
action = "published",
|
||||
release = new
|
||||
{
|
||||
tag_name = "v1.0.0",
|
||||
name = "Release 1.0.0",
|
||||
draft = false,
|
||||
prerelease = false
|
||||
},
|
||||
repository = new
|
||||
{
|
||||
id = 12345,
|
||||
full_name = "owner/repo"
|
||||
}
|
||||
};
|
||||
return JsonSerializer.SerializeToElement(payload);
|
||||
}
|
||||
|
||||
private static JsonElement CreateGitLabPushPayload()
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
@ref = "refs/heads/main",
|
||||
after = "abc123def456",
|
||||
project = new
|
||||
{
|
||||
id = 12345,
|
||||
path_with_namespace = "group/project",
|
||||
git_http_url = "https://gitlab.com/group/project.git"
|
||||
},
|
||||
user_name = "testuser"
|
||||
};
|
||||
return JsonSerializer.SerializeToElement(payload);
|
||||
}
|
||||
|
||||
private static JsonElement CreateGitLabMrMergedPayload()
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
object_kind = "merge_request",
|
||||
object_attributes = new
|
||||
{
|
||||
iid = 42,
|
||||
state = "merged",
|
||||
action = "merge",
|
||||
title = "Test MR"
|
||||
},
|
||||
project = new
|
||||
{
|
||||
id = 12345,
|
||||
path_with_namespace = "group/project"
|
||||
}
|
||||
};
|
||||
return JsonSerializer.SerializeToElement(payload);
|
||||
}
|
||||
|
||||
private static JsonElement CreateGiteaPushPayload()
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
@ref = "refs/heads/main",
|
||||
after = "abc123def456",
|
||||
repository = new
|
||||
{
|
||||
id = 12345,
|
||||
full_name = "owner/repo",
|
||||
clone_url = "https://gitea.example.com/owner/repo.git"
|
||||
},
|
||||
sender = new
|
||||
{
|
||||
login = "testuser"
|
||||
}
|
||||
};
|
||||
return JsonSerializer.SerializeToElement(payload);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
// <copyright file="ScmWebhookValidatorTests.cs" company="Stella Operations">
|
||||
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.Signals.Scm.Webhooks;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Signals.Tests.Scm;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for SCM webhook signature validators.
|
||||
/// @sprint SPRINT_20251229_013_SIGNALS_scm_ci_connectors
|
||||
/// </summary>
|
||||
public sealed class ScmWebhookValidatorTests
|
||||
{
|
||||
private const string TestSecret = "test-webhook-secret-12345";
|
||||
private const string TestPayload = "{\"action\":\"push\",\"ref\":\"refs/heads/main\"}";
|
||||
|
||||
#region GitHub Validator Tests
|
||||
|
||||
[Fact]
|
||||
public void GitHubValidator_ValidSignature_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var validator = new GitHubWebhookValidator();
|
||||
var payload = Encoding.UTF8.GetBytes(TestPayload);
|
||||
var signature = ComputeGitHubSignature(payload, TestSecret);
|
||||
|
||||
// Act
|
||||
var result = validator.IsValid(payload, signature, TestSecret);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GitHubValidator_InvalidSignature_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var validator = new GitHubWebhookValidator();
|
||||
var payload = Encoding.UTF8.GetBytes(TestPayload);
|
||||
var wrongSignature = "sha256=0000000000000000000000000000000000000000000000000000000000000000";
|
||||
|
||||
// Act
|
||||
var result = validator.IsValid(payload, wrongSignature, TestSecret);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GitHubValidator_MissingPrefix_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var validator = new GitHubWebhookValidator();
|
||||
var payload = Encoding.UTF8.GetBytes(TestPayload);
|
||||
var signatureWithoutPrefix = ComputeGitHubSignature(payload, TestSecret)[7..]; // Remove "sha256="
|
||||
|
||||
// Act
|
||||
var result = validator.IsValid(payload, signatureWithoutPrefix, TestSecret);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
public void GitHubValidator_NullOrEmptySignature_ReturnsFalse(string? signature)
|
||||
{
|
||||
// Arrange
|
||||
var validator = new GitHubWebhookValidator();
|
||||
var payload = Encoding.UTF8.GetBytes(TestPayload);
|
||||
|
||||
// Act
|
||||
var result = validator.IsValid(payload, signature, TestSecret);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
public void GitHubValidator_NullOrEmptySecret_ReturnsFalse(string? secret)
|
||||
{
|
||||
// Arrange
|
||||
var validator = new GitHubWebhookValidator();
|
||||
var payload = Encoding.UTF8.GetBytes(TestPayload);
|
||||
var signature = "sha256=abc123";
|
||||
|
||||
// Act
|
||||
var result = validator.IsValid(payload, signature, secret!);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GitLab Validator Tests
|
||||
|
||||
[Fact]
|
||||
public void GitLabValidator_ValidToken_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var validator = new GitLabWebhookValidator();
|
||||
var payload = Encoding.UTF8.GetBytes(TestPayload);
|
||||
|
||||
// Act
|
||||
var result = validator.IsValid(payload, TestSecret, TestSecret);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GitLabValidator_InvalidToken_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var validator = new GitLabWebhookValidator();
|
||||
var payload = Encoding.UTF8.GetBytes(TestPayload);
|
||||
|
||||
// Act
|
||||
var result = validator.IsValid(payload, "wrong-token", TestSecret);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GitLabValidator_CaseSensitive_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var validator = new GitLabWebhookValidator();
|
||||
var payload = Encoding.UTF8.GetBytes(TestPayload);
|
||||
|
||||
// Act
|
||||
var result = validator.IsValid(payload, TestSecret.ToUpperInvariant(), TestSecret);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Gitea Validator Tests
|
||||
|
||||
[Fact]
|
||||
public void GiteaValidator_ValidSignature_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var validator = new GiteaWebhookValidator();
|
||||
var payload = Encoding.UTF8.GetBytes(TestPayload);
|
||||
var signature = ComputeGiteaSignature(payload, TestSecret);
|
||||
|
||||
// Act
|
||||
var result = validator.IsValid(payload, signature, TestSecret);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GiteaValidator_InvalidSignature_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var validator = new GiteaWebhookValidator();
|
||||
var payload = Encoding.UTF8.GetBytes(TestPayload);
|
||||
var wrongSignature = "0000000000000000000000000000000000000000000000000000000000000000";
|
||||
|
||||
// Act
|
||||
var result = validator.IsValid(payload, wrongSignature, TestSecret);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static string ComputeGitHubSignature(byte[] payload, string secret)
|
||||
{
|
||||
var secretBytes = Encoding.UTF8.GetBytes(secret);
|
||||
var hash = HMACSHA256.HashData(secretBytes, payload);
|
||||
return $"sha256={Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
|
||||
private static string ComputeGiteaSignature(byte[] payload, string secret)
|
||||
{
|
||||
var secretBytes = Encoding.UTF8.GetBytes(secret);
|
||||
var hash = HMACSHA256.HashData(secretBytes, payload);
|
||||
return Convert.ToHexStringLower(hash);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -12,9 +12,9 @@
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<!-- FsCheck for property-based testing (EvidenceWeightedScore) -->
|
||||
<PackageReference Include="FsCheck" />
|
||||
<PackageReference Include="FsCheck.Xunit" />
|
||||
<PackageReference Include="FsCheck.Xunit.v3" />
|
||||
<!-- Verify for snapshot testing (EvidenceWeightedScore) -->
|
||||
<PackageReference Include="Verify.Xunit" />
|
||||
<PackageReference Include="Verify.XunitV3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" >
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
@@ -29,4 +29,6 @@
|
||||
<ProjectReference Include="../../StellaOps.Signals/StellaOps.Signals.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user