diff --git a/backend/src/CodeSpace.Api/Controllers/ExternalTriggersController.cs b/backend/src/CodeSpace.Api/Controllers/ExternalTriggersController.cs new file mode 100644 index 000000000..c15eb4c35 --- /dev/null +++ b/backend/src/CodeSpace.Api/Controllers/ExternalTriggersController.cs @@ -0,0 +1,82 @@ +using CodeSpace.Api.Extensions; +using CodeSpace.Api.Http; +using CodeSpace.Core.Services.Workflows.ExternalTriggers; +using CodeSpace.Messages.Commands.Workflows; +using CodeSpace.Messages.Queries.Workflows; +using CodeSpace.Messages.Dtos.Workflows; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using CodeSpace.Messages.Failures; +using System.Text.Json; + +namespace CodeSpace.Api.Controllers; + +/// +/// Anonymous ingress authenticates before reading the bounded body or opening the receive transaction. +/// +[ApiController] +[Route("api/ext")] +[AllowAnonymous] +[EnableRateLimiting(WebhookIngressRateLimitExtension.PolicyName)] +public class ExternalTriggersController : ControllerBase +{ + private readonly IMediator _mediator; + + public ExternalTriggersController(IMediator mediator) { _mediator = mediator; } + + [HttpPost("{triggerId:guid}/run")] + [RequestSizeLimit(BoundedJsonBodyReader.MaxBytes + 1)] + public async Task Run([FromRoute] Guid triggerId, CancellationToken cancellationToken) + { + var authorization = Request.Headers.Authorization.ToString(); + var bearer = authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) ? authorization[7..].Trim() : null; + var apiKey = Request.Headers["X-Api-Key"].ToString(); + var presentedKey = !string.IsNullOrEmpty(bearer) ? bearer : !string.IsNullOrEmpty(apiKey) ? apiKey : null; + + var verification = await _mediator.Send(new VerifyExternalTriggerQuery { TriggerId = triggerId, PresentedKey = presentedKey }, cancellationToken).ConfigureAwait(false); + if (verification.Kind is ExternalTriggerDeliveryKind.NotFound or ExternalTriggerDeliveryKind.Unauthorized) + return Unauthorized(new { code = FailureCodes.Unauthorized, message = "Invalid external trigger credentials." }); + + JsonDocument document; + try { document = await BoundedJsonBodyReader.ReadAsync(Request, cancellationToken).ConfigureAwait(false); } + catch (BoundedJsonBodyReader.BodyTooLargeException) + { return StatusCode(StatusCodes.Status413PayloadTooLarge, new { code = FailureCodes.ExternalTriggerBodyTooLarge, message = "Request body exceeds 1048576 bytes." }); } + catch (JsonException) + { return BadRequest(new { code = FailureCodes.InvalidRequest, message = "Invalid external trigger input.", errors = new[] { "$: body must be a JSON object" } }); } + using var body = document; + + var idempotencyHeader = Request.Headers["Idempotency-Key"].ToString(); + var idempotencyKey = string.IsNullOrWhiteSpace(idempotencyHeader) ? null : idempotencyHeader; + var idempotencyRejected = idempotencyKey != null && !ExternalTriggerIdempotency.IsWellFormed(idempotencyKey); + var outcome = await _mediator.Send(new ReceiveExternalTriggerCommand + { + TriggerId = triggerId, + PresentedKey = presentedKey, + IdempotencyKey = idempotencyKey, + IdempotencyRejected = idempotencyRejected, + Body = body.RootElement, + }, cancellationToken).ConfigureAwait(false); + + return outcome.Kind switch + { + ExternalTriggerDeliveryKind.NotFound => NotFound(new { code = FailureCodes.NotFound, message = "External trigger is unavailable." }), + ExternalTriggerDeliveryKind.Unauthorized => Unauthorized(new { code = FailureCodes.Unauthorized, message = "Invalid external trigger credentials." }), + ExternalTriggerDeliveryKind.Forbidden => StatusCode(StatusCodes.Status403Forbidden, new { code = FailureCodes.Forbidden, message = "External trigger execution is forbidden." }), + ExternalTriggerDeliveryKind.Unprocessable => StatusCode(StatusCodes.Status422UnprocessableEntity, new { code = outcome.Code ?? FailureCodes.ExternalTriggerCompletionRefused, + message = outcome.Code == FailureCodes.ExternalTriggerIdempotencyConflict ? "Idempotency key was already used with different content." : "External trigger execution was refused." }), + ExternalTriggerDeliveryKind.InvalidPayload => BadRequest(new { code = FailureCodes.InvalidRequest, message = "Invalid external trigger input.", errors = outcome.Errors }), + ExternalTriggerDeliveryKind.RateLimited => RateLimited(), + ExternalTriggerDeliveryKind.Deduplicated => Ok(new { runId = outcome.RunId, deduplicated = true }), + ExternalTriggerDeliveryKind.Accepted => StatusCode(StatusCodes.Status202Accepted, new { runId = outcome.RunId, deduplicated = false }), + _ => throw new InvalidOperationException($"Unknown external trigger outcome: {outcome.Kind}"), + }; + } + + private IActionResult RateLimited() + { + Response.Headers.RetryAfter = ((int)ExternalTriggerRunAdmission.Window.TotalSeconds).ToString(); + return StatusCode(StatusCodes.Status429TooManyRequests, new { code = FailureCodes.RateLimited, message = "External trigger run capacity is exhausted." }); + } +} diff --git a/backend/src/CodeSpace.Api/Controllers/WorkflowExternalTriggersController.cs b/backend/src/CodeSpace.Api/Controllers/WorkflowExternalTriggersController.cs new file mode 100644 index 000000000..9695dc8bd --- /dev/null +++ b/backend/src/CodeSpace.Api/Controllers/WorkflowExternalTriggersController.cs @@ -0,0 +1,35 @@ +using CodeSpace.Messages.Commands.Workflows; +using CodeSpace.Messages.Queries.Workflows; +using MediatR; +using Microsoft.AspNetCore.Mvc; + +namespace CodeSpace.Api.Controllers; + +[ApiController] +[Route("api/workflows/external-triggers")] +public sealed class WorkflowExternalTriggersController(IMediator mediator) : ControllerBase +{ + [HttpGet] + public async Task List([FromQuery] ListWorkflowExternalTriggersQuery query, CancellationToken cancellationToken) + { + var result = await mediator.Send(query, cancellationToken).ConfigureAwait(false); + return result == null ? NotFound() : Ok(result); + } + + [HttpGet("{triggerId:guid}/key")] + public async Task Reveal([FromRoute] Guid triggerId, CancellationToken cancellationToken) + { + Response.Headers.CacheControl = "private, no-store"; + var key = await mediator.Send(new RevealExternalTriggerKeyCommand { TriggerId = triggerId }, cancellationToken).ConfigureAwait(false); + return Ok(new { key }); + } + + [HttpPost("{triggerId:guid}/rotate-key")] + public async Task Rotate([FromRoute] Guid triggerId, CancellationToken cancellationToken) + { + Response.Headers.CacheControl = "private, no-store"; + var key = await mediator.Send(new RotateExternalTriggerKeyCommand { TriggerId = triggerId }, cancellationToken).ConfigureAwait(false); + return Ok(new { key }); + } + +} diff --git a/backend/src/CodeSpace.Api/Extensions/WebhookIngressRateLimitExtension.cs b/backend/src/CodeSpace.Api/Extensions/WebhookIngressRateLimitExtension.cs index 362ce3825..f1db6aabb 100644 --- a/backend/src/CodeSpace.Api/Extensions/WebhookIngressRateLimitExtension.cs +++ b/backend/src/CodeSpace.Api/Extensions/WebhookIngressRateLimitExtension.cs @@ -1,5 +1,7 @@ using System.Threading.RateLimiting; using Microsoft.AspNetCore.RateLimiting; +using CodeSpace.Messages.Failures; +using System.Globalization; namespace CodeSpace.Api.Extensions; @@ -37,7 +39,7 @@ public static class WebhookIngressRateLimitExtension public static readonly TimeSpan Window = TimeSpan.FromMinutes(1); /// The route values that name what a request is addressed to, in the order the ingress routes declare them. - private static readonly string[] TargetRouteValues = { "webhookId", "connectionWebhookId", "token" }; + private static readonly string[] TargetRouteValues = { "webhookId", "connectionWebhookId", "token", "triggerId" }; /// The one partition every non-ingress request shares in the global limiter — no limit. Never an address. private const string NotIngress = "not-ingress"; @@ -47,6 +49,12 @@ public static void AddWebhookIngressRateLimit(this IServiceCollection services) services.AddRateLimiter(options => { options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + options.OnRejected = async (context, ct) => + { + var retry = context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var delay) ? delay : Window; + context.HttpContext.Response.Headers.RetryAfter = Math.Max(0, (int)Math.Ceiling(retry.TotalSeconds)).ToString(CultureInfo.InvariantCulture); + await context.HttpContext.Response.WriteAsJsonAsync(new { code = FailureCodes.RateLimited, message = "Request rate limit exceeded." }, ct).ConfigureAwait(false); + }; options.GlobalLimiter = PartitionedRateLimiter.Create(SourcePartition); options.AddPolicy(PolicyName, context => FixedWindow(PartitionKey(context), PermitsPerWindow)); }); @@ -70,6 +78,7 @@ private static RateLimitPartition FixedWindow(string key, int permits) = internal static string PartitionKey(HttpContext context) { var target = TargetRouteValues.Select(name => context.GetRouteValue(name)?.ToString()).FirstOrDefault(value => !string.IsNullOrEmpty(value)) ?? "-"; + if (context.GetRouteValue("triggerId") is not null && Guid.TryParse(target, out var triggerId)) target = triggerId.ToString("N"); return $"{target}|{SourcePartitionKey(context)}"; } diff --git a/backend/src/CodeSpace.Api/Http/BoundedJsonBodyReader.cs b/backend/src/CodeSpace.Api/Http/BoundedJsonBodyReader.cs new file mode 100644 index 000000000..89c110c93 --- /dev/null +++ b/backend/src/CodeSpace.Api/Http/BoundedJsonBodyReader.cs @@ -0,0 +1,41 @@ +using System.Text.Json; + +namespace CodeSpace.Api.Http; + +public static class BoundedJsonBodyReader +{ + public const int MaxBytes = 1_048_576; + + public static async Task ReadAsync(HttpRequest request, CancellationToken ct) + { + using var stream = new LimitedStream(request.Body); + try { return await JsonDocument.ParseAsync(stream, new JsonDocumentOptions { MaxDepth = 64 }, ct).ConfigureAwait(false); } + catch (BadHttpRequestException ex) when (ex.StatusCode == StatusCodes.Status413PayloadTooLarge) + { throw new BodyTooLargeException(); } + } + + public sealed class BodyTooLargeException : IOException; + + private sealed class LimitedStream(Stream source) : Stream + { + private int _read; + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => _read; set => throw new NotSupportedException(); } + public override async ValueTask ReadAsync(Memory buffer, CancellationToken ct = default) + { + var read = await source.ReadAsync(buffer[..Math.Min(buffer.Length, MaxBytes + 1 - _read)], ct).ConfigureAwait(false); + _read += read; + if (_read > MaxBytes) throw new BodyTooLargeException(); + return read; + } + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken ct) => ReadAsync(buffer.AsMemory(offset, count), ct).AsTask(); + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override void Flush() => throw new NotSupportedException(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + } +} diff --git a/backend/src/CodeSpace.Core/Handlers/CommandHandlers/Workflows/ExternalTriggerCommandHandlers.cs b/backend/src/CodeSpace.Core/Handlers/CommandHandlers/Workflows/ExternalTriggerCommandHandlers.cs new file mode 100644 index 000000000..74f9bb8dd --- /dev/null +++ b/backend/src/CodeSpace.Core/Handlers/CommandHandlers/Workflows/ExternalTriggerCommandHandlers.cs @@ -0,0 +1,88 @@ +using CodeSpace.Core.Persistence.Db; +using CodeSpace.Core.Services.Identity; +using CodeSpace.Core.Services.Workflows.ExternalTriggers; +using CodeSpace.Messages.Commands.Workflows; +using CodeSpace.Messages.Queries.Workflows; +using CodeSpace.Messages.Dtos.Workflows; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace CodeSpace.Core.Handlers.CommandHandlers.Workflows; + +public sealed class RevealExternalTriggerKeyCommandHandler( + IExternalTriggerStore store, CodeSpaceDbContext db, ExternalTriggerKeySealer sealer, + ICurrentTeam team, ICurrentUser actor, ILogger logger) + : IRequestHandler +{ + public async Task Handle(RevealExternalTriggerKeyCommand request, CancellationToken cancellationToken) + { + var trigger = await store.FindAsync(request.TriggerId, team.Id!.Value, cancellationToken).ConfigureAwait(false) + ?? throw new KeyNotFoundException("External trigger not found in this team."); + var ciphertext = await db.WorkflowExternalTrigger.AsNoTracking() + .Where(t => t.Id == trigger.Id && t.TeamId == team.Id.Value && t.DeletedDate == null) + .Select(t => t.KeyCiphertext).SingleOrDefaultAsync(cancellationToken).ConfigureAwait(false) + ?? throw new KeyNotFoundException("External trigger not found in this team."); + var key = sealer.Open(ciphertext); + logger.LogWarning("External trigger {TriggerId} key revealed to user {UserId} in team {TeamId}", trigger.Id, actor.Id, team.Id); + return key; + } +} + +public sealed class RotateExternalTriggerKeyCommandHandler(IExternalTriggerStore store, ICurrentTeam team, ICurrentUser actor) + : IRequestHandler +{ + public async Task Handle(RotateExternalTriggerKeyCommand request, CancellationToken cancellationToken) + { + var trigger = await store.FindAsync(request.TriggerId, team.Id!.Value, cancellationToken).ConfigureAwait(false) + ?? throw new KeyNotFoundException("External trigger not found in this team."); + return await store.RotateKeyAsync(trigger.Id, team.Id.Value, actor.Id!.Value, cancellationToken).ConfigureAwait(false); + } +} + +public sealed class ReceiveExternalTriggerCommandHandler(IExternalTriggerIngress ingress) + : IRequestHandler +{ + public async Task Handle(ReceiveExternalTriggerCommand request, CancellationToken cancellationToken) + { + var outcome = await ingress.ReceiveAsync(new ExternalTriggerCall( + request.TriggerId, request.PresentedKey, request.IdempotencyKey, string.Empty, request.IdempotencyRejected, request.Body), + cancellationToken).ConfigureAwait(false); + + return new ExternalTriggerDeliveryOutcome + { + Kind = outcome.Kind switch + { + ExternalTriggerOutcomeKind.Accepted => ExternalTriggerDeliveryKind.Accepted, + ExternalTriggerOutcomeKind.Deduplicated => ExternalTriggerDeliveryKind.Deduplicated, + ExternalTriggerOutcomeKind.NotFound => ExternalTriggerDeliveryKind.NotFound, + ExternalTriggerOutcomeKind.Unauthorized => ExternalTriggerDeliveryKind.Unauthorized, + ExternalTriggerOutcomeKind.Forbidden => ExternalTriggerDeliveryKind.Forbidden, + ExternalTriggerOutcomeKind.Unprocessable => ExternalTriggerDeliveryKind.Unprocessable, + ExternalTriggerOutcomeKind.InvalidPayload => ExternalTriggerDeliveryKind.InvalidPayload, + ExternalTriggerOutcomeKind.RateLimited => ExternalTriggerDeliveryKind.RateLimited, + _ => throw new InvalidOperationException($"Unknown external trigger outcome: {outcome.Kind}"), + }, + RunId = outcome.RunId, + Errors = outcome.Errors, + Code = outcome.Code, + }; + } +} + +public sealed class VerifyExternalTriggerQueryHandler(IExternalTriggerIngress ingress) + : IRequestHandler +{ + public async Task Handle(VerifyExternalTriggerQuery request, CancellationToken cancellationToken) + { + var verification = await ingress.VerifyAsync(request.TriggerId, request.PresentedKey, cancellationToken).ConfigureAwait(false); + var kind = verification.Kind switch + { + ExternalTriggerOutcomeKind.Accepted => ExternalTriggerDeliveryKind.Accepted, + ExternalTriggerOutcomeKind.NotFound => ExternalTriggerDeliveryKind.NotFound, + ExternalTriggerOutcomeKind.Unauthorized => ExternalTriggerDeliveryKind.Unauthorized, + _ => throw new InvalidOperationException($"Unexpected external trigger verification outcome: {verification.Kind}"), + }; + return new ExternalTriggerVerificationOutcome(kind); + } +} diff --git a/backend/src/CodeSpace.Core/Handlers/QueryHandlers/Workflows/ListWorkflowExternalTriggersQueryHandler.cs b/backend/src/CodeSpace.Core/Handlers/QueryHandlers/Workflows/ListWorkflowExternalTriggersQueryHandler.cs new file mode 100644 index 000000000..3b9ee2901 --- /dev/null +++ b/backend/src/CodeSpace.Core/Handlers/QueryHandlers/Workflows/ListWorkflowExternalTriggersQueryHandler.cs @@ -0,0 +1,24 @@ +using CodeSpace.Core.Persistence.Db; +using CodeSpace.Core.Services.Identity; +using CodeSpace.Core.Services.Workflows.ExternalTriggers; +using CodeSpace.Messages.Dtos.Workflows; +using CodeSpace.Messages.Queries.Workflows; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace CodeSpace.Core.Handlers.QueryHandlers.Workflows; + +public sealed class ListWorkflowExternalTriggersQueryHandler(CodeSpaceDbContext db, IExternalTriggerStore store, ICurrentTeam team) + : IRequestHandler +{ + public async Task Handle(ListWorkflowExternalTriggersQuery request, CancellationToken cancellationToken) + { + var teamId = team.Id!.Value; + if (!await db.Workflow.AnyAsync(w => w.Id == request.WorkflowId && w.TeamId == teamId && w.DeletedDate == null, cancellationToken).ConfigureAwait(false)) + return null; + + var triggers = await store.ListForWorkflowAsync(request.WorkflowId, teamId, cancellationToken).ConfigureAwait(false); + return new WorkflowExternalTriggersResult(triggers + .Select(t => new WorkflowExternalTriggerSummary(t.Id, t.WorkflowId, t.NodeId)).ToArray()); + } +} diff --git a/backend/src/CodeSpace.Core/Middlewares/Transactional/TransactionCompletionCallbacks.cs b/backend/src/CodeSpace.Core/Middlewares/Transactional/TransactionCompletionCallbacks.cs new file mode 100644 index 000000000..1552826ef --- /dev/null +++ b/backend/src/CodeSpace.Core/Middlewares/Transactional/TransactionCompletionCallbacks.cs @@ -0,0 +1,30 @@ +using CodeSpace.Core.DependencyInjection; + +namespace CodeSpace.Core.Middlewares.Transactional; + +public sealed class TransactionCompletionCallbacks : IScopedDependency, IDisposable +{ + private readonly List> _committed = []; + private readonly List _rolledBack = []; + + public void RegisterAfterCommit(Func callback) => _committed.Add(callback); + public void RegisterAfterRollback(Action callback) => _rolledBack.Add(callback); + + public async Task RunAfterCommitAsync(CancellationToken ct) + { + var callbacks = _committed.ToArray(); + _committed.Clear(); + _rolledBack.Clear(); + foreach (var callback in callbacks) await callback(ct).ConfigureAwait(false); + } + + public void RunAfterRollback() + { + var callbacks = _rolledBack.ToArray(); + _committed.Clear(); + _rolledBack.Clear(); + foreach (var callback in callbacks) callback(); + } + + public void Dispose() => RunAfterRollback(); +} diff --git a/backend/src/CodeSpace.Core/Middlewares/Transactional/TransactionalBehavior.cs b/backend/src/CodeSpace.Core/Middlewares/Transactional/TransactionalBehavior.cs index df8aed67f..ca6f1c7c3 100644 --- a/backend/src/CodeSpace.Core/Middlewares/Transactional/TransactionalBehavior.cs +++ b/backend/src/CodeSpace.Core/Middlewares/Transactional/TransactionalBehavior.cs @@ -10,12 +10,14 @@ public sealed class TransactionalBehavior : IPipelineBehavi private readonly CodeSpaceDbContext _dbContext; private readonly IPostCommitActions _postCommit; private readonly ILogger> _logger; + private readonly TransactionCompletionCallbacks? _completion; - public TransactionalBehavior(CodeSpaceDbContext dbContext, IPostCommitActions postCommit, ILogger> logger) + public TransactionalBehavior(CodeSpaceDbContext dbContext, IPostCommitActions postCommit, ILogger> logger, TransactionCompletionCallbacks? completion = null) { _dbContext = dbContext; _postCommit = postCommit; _logger = logger; + _completion = completion; } public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) @@ -28,6 +30,7 @@ public async Task Handle(TRequest request, RequestHandlerDelegate Handle(TRequest request, RequestHandlerDelegate Handle(TRequest request, RequestHandlerDelegate options, ICurrent public DbSet Workflow => Set(); public DbSet WorkflowVersion => Set(); public DbSet WorkflowActivation => Set(); + public DbSet WorkflowExternalTrigger => Set(); public DbSet TaskRouteSnapshot => Set(); public DbSet WorkflowRun => Set(); public DbSet WorkflowRunExecutionAuthority => Set(); diff --git a/backend/src/CodeSpace.Core/Persistence/DbUpFiles/0251_workflow_external_trigger.sql b/backend/src/CodeSpace.Core/Persistence/DbUpFiles/0251_workflow_external_trigger.sql new file mode 100644 index 000000000..e1d056cdf --- /dev/null +++ b/backend/src/CodeSpace.Core/Persistence/DbUpFiles/0251_workflow_external_trigger.sql @@ -0,0 +1,24 @@ +-- 0251_workflow_external_trigger.sql +-- External-trigger credentials live outside workflow_activation.config_jsonb because they have their own lifecycle and a +-- gated reveal must not expose a bearer key whenever any workflow definition is read. node_id is stable across saves while +-- ReplaceActivationsAsync soft-deletes and recreates every activation row, so activation_id would become stale. enabled is +-- intentionally absent: the activation already owns the switch, and a duplicate would create conflicting truth. + +CREATE TABLE workflow_external_trigger ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + team_id UUID NOT NULL REFERENCES team(id), + workflow_id UUID NOT NULL REFERENCES workflow(id), + node_id TEXT NOT NULL, + key_digest TEXT NOT NULL, + key_ciphertext TEXT NOT NULL, + created_date TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by UUID NOT NULL, + last_modified_date TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_modified_by UUID NOT NULL, + deleted_date TIMESTAMPTZ NULL +); +CREATE INDEX idx_wet_key_digest ON workflow_external_trigger (key_digest); +CREATE INDEX idx_wet_workflow_node ON workflow_external_trigger (workflow_id, node_id); +CREATE INDEX idx_wet_team ON workflow_external_trigger (team_id); +COMMENT ON TABLE workflow_external_trigger IS + 'Per-node credential and routing identity for a trigger.external node. key_digest verifies a presented key; key_ciphertext is the AES-GCM sealed form a workflows.write-gated reveal opens.'; diff --git a/backend/src/CodeSpace.Core/Persistence/DbUpFiles/0252_workflow_external_triggers_slug_reservation.sql b/backend/src/CodeSpace.Core/Persistence/DbUpFiles/0252_workflow_external_triggers_slug_reservation.sql new file mode 100644 index 000000000..480ecd30e --- /dev/null +++ b/backend/src/CodeSpace.Core/Persistence/DbUpFiles/0252_workflow_external_triggers_slug_reservation.sql @@ -0,0 +1,31 @@ +-- 0252_workflow_external_triggers_slug_reservation.sql +-- The management route /api/workflows/external-triggers is a literal GET sibling of /api/workflows/{idOrSlug}. +-- A workflow already holding that slug becomes unreachable, so move each live row to the same -N scheme the +-- runtime allocator uses, preserving soft-deleted rows because their slug is audit history. + +WITH reserved AS ( + SELECT id, team_id, slug + FROM workflow + WHERE slug = 'external-triggers' AND deleted_date IS NULL +), +candidates AS ( + SELECT r.id, r.team_id, r.slug, n + FROM reserved r + CROSS JOIN LATERAL generate_series(2, 1000) AS n + WHERE NOT EXISTS ( + SELECT 1 + FROM workflow w + WHERE w.team_id = r.team_id + AND w.slug = 'external-triggers-' || n::text + AND w.deleted_date IS NULL + ) +) +UPDATE workflow w +SET slug = 'external-triggers-' || c.n::text +FROM candidates c +WHERE w.id = c.id + AND c.n = ( + SELECT MIN(c2.n) + FROM candidates c2 + WHERE c2.id = c.id + ); diff --git a/backend/src/CodeSpace.Core/Persistence/Entities/WorkflowExternalTrigger.cs b/backend/src/CodeSpace.Core/Persistence/Entities/WorkflowExternalTrigger.cs new file mode 100644 index 000000000..a1556e078 --- /dev/null +++ b/backend/src/CodeSpace.Core/Persistence/Entities/WorkflowExternalTrigger.cs @@ -0,0 +1,16 @@ +namespace CodeSpace.Core.Persistence.Entities; + +public class WorkflowExternalTrigger : IEntity, IAuditable +{ + public Guid Id { get; set; } + public Guid TeamId { get; set; } + public Guid WorkflowId { get; set; } + public string NodeId { get; set; } = default!; + public string KeyDigest { get; set; } = default!; + public string KeyCiphertext { get; set; } = default!; + public DateTimeOffset CreatedDate { get; set; } + public Guid CreatedBy { get; set; } + public DateTimeOffset LastModifiedDate { get; set; } + public Guid LastModifiedBy { get; set; } + public DateTimeOffset? DeletedDate { get; set; } +} diff --git a/backend/src/CodeSpace.Core/Persistence/EntityConfigurations/WorkflowExternalTriggerConfiguration.cs b/backend/src/CodeSpace.Core/Persistence/EntityConfigurations/WorkflowExternalTriggerConfiguration.cs new file mode 100644 index 000000000..ce5c2f663 --- /dev/null +++ b/backend/src/CodeSpace.Core/Persistence/EntityConfigurations/WorkflowExternalTriggerConfiguration.cs @@ -0,0 +1,28 @@ +using CodeSpace.Core.Persistence.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CodeSpace.Core.Persistence.EntityConfigurations; + +public class WorkflowExternalTriggerConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("workflow_external_trigger"); + builder.HasKey(t => t.Id); + builder.Property(t => t.TeamId).HasColumnName("team_id"); + builder.Property(t => t.WorkflowId).HasColumnName("workflow_id"); + builder.Property(t => t.NodeId).HasColumnName("node_id").HasMaxLength(64); + builder.Property(t => t.KeyDigest).HasColumnName("key_digest"); + builder.Property(t => t.KeyCiphertext).HasColumnName("key_ciphertext"); + builder.Property(t => t.CreatedDate).HasColumnName("created_date"); + builder.Property(t => t.CreatedBy).HasColumnName("created_by"); + builder.Property(t => t.LastModifiedDate).HasColumnName("last_modified_date"); + builder.Property(t => t.LastModifiedBy).HasColumnName("last_modified_by"); + builder.Property(t => t.DeletedDate).HasColumnName("deleted_date"); + + builder.HasIndex(t => t.KeyDigest).HasDatabaseName("idx_wet_key_digest"); + builder.HasIndex(t => new { t.WorkflowId, t.NodeId }).HasDatabaseName("idx_wet_workflow_node"); + builder.HasIndex(t => t.TeamId).HasDatabaseName("idx_wet_team"); + } +} diff --git a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerDefinitionSync.cs b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerDefinitionSync.cs new file mode 100644 index 000000000..4ccb6d7c7 --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerDefinitionSync.cs @@ -0,0 +1,52 @@ +using System.Text.Json; +using CodeSpace.Core.DependencyInjection; +using CodeSpace.Core.Persistence.Db; +using CodeSpace.Messages.Dtos.Workflows; +using Microsoft.EntityFrameworkCore; + +namespace CodeSpace.Core.Services.Workflows.ExternalTriggers; + +public sealed class ExternalTriggerDefinitionSync(CodeSpaceDbContext db, IExternalTriggerStore store) : IScopedDependency +{ + public static WorkflowDefinition Normalize(WorkflowDefinition definition) => definition with + { + Nodes = definition.Nodes.Select(node => node.TypeKey == ExternalTriggerSlug.TypeKey + && node.Config.ValueKind == JsonValueKind.Object && node.Config.TryGetProperty("slug", out _) + ? node with { Config = JsonSerializer.SerializeToElement(node.Config.EnumerateObject() + .Where(property => property.Name != "slug").ToDictionary(property => property.Name, property => property.Value)) } + : node).ToArray(), + }; + + /// + /// Save-time schema validation runs before any workflow row is written, so an unsupported keyword cannot leave + /// a half-created workflow behind. + /// + public static void ValidateSchemas(WorkflowDefinition definition) + { + var schemaErrors = definition.Nodes + .Where(n => n.TypeKey == ExternalTriggerSlug.TypeKey) + .SelectMany(node => node.Config.ValueKind == JsonValueKind.Object + && node.Config.TryGetProperty("inputSchema", out var schema) + ? ExternalTriggerPayloadValidator.ValidateSchema(schema) + : []) + .ToArray(); + if (schemaErrors.Length != 0) + throw new WorkflowValidationException(schemaErrors); + } + + public async Task SyncAsync(Guid workflowId, Guid teamId, WorkflowDefinition definition, Guid actorId, CancellationToken cancellationToken) + { + var nodes = definition.Nodes.Where(n => n.TypeKey == ExternalTriggerSlug.TypeKey).ToArray(); + var nodeIds = nodes.Select(n => n.Id).ToArray(); + var retired = await db.WorkflowExternalTrigger.Where(t => t.WorkflowId == workflowId && t.TeamId == teamId && t.DeletedDate == null && !nodeIds.Contains(t.NodeId)) + .ToListAsync(cancellationToken).ConfigureAwait(false); + foreach (var row in retired) + { + row.DeletedDate = DateTimeOffset.UtcNow; + row.KeyCiphertext = string.Empty; + } + + foreach (var node in nodes) + await store.EnsureForNodeAsync(workflowId, teamId, node.Id, actorId, cancellationToken).ConfigureAwait(false); + } +} diff --git a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerIdempotency.cs b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerIdempotency.cs new file mode 100644 index 000000000..7798efd40 --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerIdempotency.cs @@ -0,0 +1,20 @@ +namespace CodeSpace.Core.Services.Workflows.ExternalTriggers; + +/// +/// No header means no idempotency key: deduplication belongs to the caller. Identical bodies can be retries or +/// distinct heartbeats and periodic alerts; any server-derived fallback key would silently swallow real events. +/// +public static class ExternalTriggerIdempotency +{ + public const int MaxLength = 200; + + public static bool IsWellFormed(string? value) => + !string.IsNullOrEmpty(value) && value.Length <= MaxLength && + value.All(c => c is >= 'A' and <= 'Z' or >= 'a' and <= 'z' or >= '0' and <= '9' or '.' or '_' or '~' or '-'); + + /// + /// Scope follows the durable trigger row, not the activation row: ReplaceActivationsAsync recreates activations on + /// every workflow save, so an activation-scoped key would stop deduplicating after an ordinary edit. + /// + public static string KeyFor(string value, Guid triggerId) => $"{ExternalTriggerSlug.TypeKey}:{value}:{triggerId:N}"; +} diff --git a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerIdempotencyIntegrityException.cs b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerIdempotencyIntegrityException.cs new file mode 100644 index 000000000..7a3c4d134 --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerIdempotencyIntegrityException.cs @@ -0,0 +1,9 @@ +using CodeSpace.Messages.Failures; + +namespace CodeSpace.Core.Services.Workflows.ExternalTriggers; + +public sealed class ExternalTriggerIdempotencyIntegrityException(string detail) : Exception(detail), IFailure +{ + public FailureKind Kind => FailureKind.Internal; + public string Code => FailureCodes.Internal; +} diff --git a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerIngress.cs b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerIngress.cs new file mode 100644 index 000000000..f529d7ef4 --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerIngress.cs @@ -0,0 +1,266 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using CodeSpace.Core.Middlewares.Transactional; +using CodeSpace.Core.Persistence.Db; +using CodeSpace.Core.Persistence.Entities; +using CodeSpace.Core.Services.Agents.Authority.Exceptions; +using CodeSpace.Core.Services.Completion.Exceptions; +using CodeSpace.Core.Services.Webhooks; +using CodeSpace.Core.Services.Workflows.Dispatch; +using CodeSpace.Core.Services.Workflows.RunSources; +using CodeSpace.Messages.Constants; +using CodeSpace.Messages.Dtos.Workflows; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace CodeSpace.Core.Services.Workflows.ExternalTriggers; + +/// +/// SourceType must equal the activation TypeKey: ActivationTypeFor only translates cron, so any other value fails +/// VerifyActivation with trigger-snapshot-mismatch. Anonymous runs also need the activation's id and authority snapshot. +/// Each call resolves the live activation afresh because ReplaceActivationsAsync rebuilds every row on workflow save. +/// The latest version's node id binds the credential to its original node; replacing a node must not revive its old key. +/// Its node config also supplies the input contract so identity and validation use the same definition version. +/// No Idempotency-Key means no deduplication: identical bodies can represent distinct real events, not just retries. +/// +public sealed class ExternalTriggerIngress( + CodeSpaceDbContext db, + IRunStarter runStarter, + IWorkflowRunDispatcher runDispatcher, + IPostCommitActions postCommit, + IIngestionAuditor auditor, + ExternalTriggerRunAdmission admission, + ExternalTriggerVerificationContext verificationContext, + TransactionCompletionCallbacks completion, + ILogger logger) : IExternalTriggerIngress +{ + public async Task VerifyAsync(Guid triggerId, string? presentedKey, CancellationToken ct) + { + verificationContext.Ticket = null; + var trigger = await db.WorkflowExternalTrigger.AsNoTracking() + .Where(t => t.Id == triggerId && t.DeletedDate == null) + .Select(t => new ExternalTriggerVerificationTicket(t.Id, t.TeamId, t.WorkflowId, t.NodeId, t.KeyDigest)) + .SingleOrDefaultAsync(ct).ConfigureAwait(false); + if (trigger is null || !KeyMatches(presentedKey, trigger.KeyDigest)) + return Verification(ExternalTriggerOutcomeKind.Unauthorized); + + verificationContext.Ticket = trigger; + return Verification(ExternalTriggerOutcomeKind.Accepted); + } + + private async Task ResolveAsync(ExternalTriggerVerificationTicket ticket, CancellationToken ct) + { + var trigger = await db.WorkflowExternalTrigger.AsNoTracking() + .Where(t => t.Id == ticket.TriggerId && t.DeletedDate == null) + .Select(t => new ExternalTriggerVerificationTicket(t.Id, t.TeamId, t.WorkflowId, t.NodeId, t.KeyDigest)) + .SingleOrDefaultAsync(ct).ConfigureAwait(false); + if (trigger is null || trigger != ticket) return Verification(ExternalTriggerOutcomeKind.Unauthorized); + + var activations = await db.WorkflowActivation.AsNoTracking().Include(a => a.Workflow) + .Where(a => a.WorkflowId == trigger.WorkflowId && a.TypeKey == ExternalTriggerSlug.TypeKey + && a.Enabled && a.DeletedDate == null && a.Workflow.Enabled && a.Workflow.DeletedDate == null) + .ToListAsync(ct).ConfigureAwait(false); + if (activations.Count != 1) + { + if (activations.Count > 1) + logger.LogWarning("External trigger workflow {WorkflowId} has {Count} live activations; refusing ambiguous endpoint", trigger.WorkflowId, activations.Count); + return Verification(ExternalTriggerOutcomeKind.NotFound); + } + var activation = activations[0]; + + var definitionJson = await db.WorkflowVersion.AsNoTracking() + .Where(v => v.WorkflowId == activation.WorkflowId && v.Version == activation.Workflow.LatestVersion) + .Select(v => v.DefinitionJson).SingleOrDefaultAsync(ct).ConfigureAwait(false); + if (definitionJson is null) return Verification(ExternalTriggerOutcomeKind.NotFound); + var definition = JsonSerializer.Deserialize(definitionJson, WorkflowJson.Options); + var nodes = definition?.Nodes.Where(n => n.TypeKey == ExternalTriggerSlug.TypeKey).ToList(); + if (nodes is not { Count: 1 } || nodes[0].Id != trigger.NodeId) return Verification(ExternalTriggerOutcomeKind.NotFound); + var config = nodes[0].Config; + + var schema = config.ValueKind == JsonValueKind.Object + && config.TryGetProperty("inputSchema", out var declared) && declared.ValueKind == JsonValueKind.Object + ? declared : default; + + return new ExternalTriggerVerification( + ExternalTriggerOutcomeKind.Accepted, + trigger.TriggerId, + activation.Id, + activation.WorkflowId, + activation.Workflow.TeamId, + activation.Workflow.LatestVersion, + ActivationAuthoritySnapshot.Serialize(activation), + schema); + } + + public async Task ReceiveAsync(ExternalTriggerCall call, CancellationToken ct) + { + var ticket = verificationContext.Ticket; + verificationContext.Ticket = null; + if (ticket is null || ticket.TriggerId != call.TriggerId || !KeyMatches(call.PresentedKey, ticket.KeyDigest)) + { + var authentication = await VerifyAsync(call.TriggerId, call.PresentedKey, ct).ConfigureAwait(false); + ticket = verificationContext.Ticket; + verificationContext.Ticket = null; + if (authentication.Kind != ExternalTriggerOutcomeKind.Accepted || ticket is null) + return Outcome(ExternalTriggerOutcomeKind.Unauthorized); + } + if (call.IdempotencyRejected) + return Outcome(ExternalTriggerOutcomeKind.InvalidPayload, + errors: [$"Idempotency-Key must be at most {ExternalTriggerIdempotency.MaxLength} characters from [A-Za-z0-9._~-]"]); + + JsonDocument? body; + try { body = call.ParsedBody.HasValue ? null : JsonDocument.Parse(call.RawBody); } + catch (JsonException) { return InvalidBody(); } + using (body) + { + var payload = call.ParsedBody ?? body!.RootElement; + if (payload.ValueKind != JsonValueKind.Object) return InvalidBody(); + var errors = ExternalTriggerPayloadValidator.ValidatePayload(payload); + if (errors.Count != 0) return Outcome(ExternalTriggerOutcomeKind.InvalidPayload, errors: errors); + + if (db.Database.CurrentTransaction is not null) + return await ReceiveInTransactionAsync(call, ticket, payload, ct).ConfigureAwait(false); + + var checkpoint = postCommit.CreateCheckpoint(); + await using var transaction = await ScopedTransaction.OwnOrJoinAsync(db.Database, ct).ConfigureAwait(false); + ExternalTriggerOutcome outcome; + try + { + outcome = await ReceiveInTransactionAsync(call, ticket, payload, ct).ConfigureAwait(false); + await db.SaveChangesAsync(ct).ConfigureAwait(false); + await transaction.CommitAsync(ct).ConfigureAwait(false); + } + catch + { + try { await db.Database.CurrentTransaction!.RollbackAsync(CancellationToken.None).ConfigureAwait(false); } + finally { postCommit.RollbackTo(checkpoint); completion.RunAfterRollback(); } + throw; + } + await transaction.DisposeAsync().ConfigureAwait(false); + await completion.RunAfterCommitAsync(CancellationToken.None).ConfigureAwait(false); + await postCommit.RunAllAsync(CancellationToken.None).ConfigureAwait(false); + return outcome; + } + } + + private async Task ReceiveInTransactionAsync(ExternalTriggerCall call, ExternalTriggerVerificationTicket ticket, JsonElement payload, CancellationToken ct) + { + // Saves and ingestion take workflow before trigger; body reads never hold these locks. + await db.Database.ExecuteSqlInterpolatedAsync($"SELECT id FROM workflow WHERE id = {ticket.WorkflowId} FOR UPDATE", ct).ConfigureAwait(false); + await db.Database.ExecuteSqlInterpolatedAsync($"SELECT id FROM workflow_external_trigger WHERE id = {ticket.TriggerId} FOR UPDATE", ct).ConfigureAwait(false); + var current = await db.WorkflowExternalTrigger.AsNoTracking() + .Where(t => t.Id == ticket.TriggerId && t.DeletedDate == null) + .Select(t => new ExternalTriggerVerificationTicket(t.Id, t.TeamId, t.WorkflowId, t.NodeId, t.KeyDigest)) + .SingleOrDefaultAsync(ct).ConfigureAwait(false); + if (current != ticket) return Outcome(ExternalTriggerOutcomeKind.Unauthorized); + + var idempotencyKey = call.IdempotencyKey is null ? null : ExternalTriggerIdempotency.KeyFor(call.IdempotencyKey, ticket.TriggerId); + var existing = await FindReplayAsync(idempotencyKey, payload, ct).ConfigureAwait(false); + if (existing is not null) return existing; + + var verification = await ResolveAsync(ticket, ct).ConfigureAwait(false); + if (verification.Kind != ExternalTriggerOutcomeKind.Accepted) return Outcome(verification.Kind); + using var fallback = JsonDocument.Parse("""{"type":"object"}"""); + var schema = verification.InputSchema.ValueKind == JsonValueKind.Object ? verification.InputSchema : fallback.RootElement; + var errors = ExternalTriggerPayloadValidator.Validate(payload, schema); + if (errors.Count != 0) return Outcome(ExternalTriggerOutcomeKind.InvalidPayload, errors: errors); + + var envelope = new RunSourceEnvelope + { + TeamId = verification.TeamId, + WorkflowId = verification.WorkflowId, + WorkflowVersion = verification.WorkflowVersion, + SourceType = ExternalTriggerSlug.TypeKey, + ActorType = WorkflowRunActorTypes.Webhook, + ActorId = null, + CreatedBy = SystemUsers.SeederId, + ActivationId = verification.ActivationId, + ActivationSnapshotJson = verification.ActivationSnapshotJson, + NormalizedPayloadJson = payload.GetRawText(), + IdempotencyKey = idempotencyKey, + }; + + var reservation = admission.Reserve(verification.TriggerId); + if (reservation is null) + { + await AuditRefusalAsync(verification, call, WorkflowRunRequestRejectionReasons.TriggerRateLimited, "per-trigger run admission refused", ct).ConfigureAwait(false); + return Outcome(ExternalTriggerOutcomeKind.RateLimited); + } + + Guid runId; + try { runId = await runStarter.StartAsync(envelope, ct).ConfigureAwait(false); } + catch (AgentAuthorityDeniedException ex) + { + reservation.Release(); + await AuditRefusalAsync(verification, call, ex.Code, ex.Reason, ct).ConfigureAwait(false); + return Outcome(ExternalTriggerOutcomeKind.Forbidden, errors: [ex.Message]); + } + catch (CompletionAdmissionRefusedException ex) + { + reservation.Release(); + await AuditRefusalAsync(verification, call, WorkflowRunRequestRejectionReasons.CompletionAdmissionRefused, ex.Message, ct).ConfigureAwait(false); + return Outcome(ExternalTriggerOutcomeKind.Unprocessable, errors: [ex.Message]); + } + catch { reservation.Release(); throw; } + + if (runId == Guid.Empty) + { + reservation.Release(); + return await FindReplayAsync(idempotencyKey, payload, ct).ConfigureAwait(false) + ?? throw new ExternalTriggerIdempotencyIntegrityException("External trigger duplicate has no durable run."); + } + + if (db.Database.CurrentTransaction is null) reservation.Commit(); + else + { + completion.RegisterAfterCommit(_ => { reservation.Commit(); return Task.CompletedTask; }); + completion.RegisterAfterRollback(reservation.Release); + } + await db.SaveChangesAsync(ct).ConfigureAwait(false); + await postCommit.RunAfterCommitAsync(token => runDispatcher.DispatchAsync(runId, token), ct).ConfigureAwait(false); + return Outcome(ExternalTriggerOutcomeKind.Accepted, runId); + } + + private async Task FindReplayAsync(string? key, JsonElement payload, CancellationToken ct) + { + if (key is null) return null; + var request = await db.WorkflowRunRequest.AsNoTracking().Where(r => r.IdempotencyKey == key) + .Select(r => new { r.Id, r.NormalizedPayloadJson }).SingleOrDefaultAsync(ct).ConfigureAwait(false); + if (request is null) return null; + var runId = await db.WorkflowRun.AsNoTracking().Where(r => r.RunRequestId == request.Id) + .Select(r => (Guid?)r.Id).SingleOrDefaultAsync(ct).ConfigureAwait(false) + ?? throw new ExternalTriggerIdempotencyIntegrityException($"External trigger accepted request {request.Id} has no durable run."); + using var stored = JsonDocument.Parse(request.NormalizedPayloadJson); + return ExternalTriggerJsonEquality.AreEquivalent(payload, stored.RootElement) + ? Outcome(ExternalTriggerOutcomeKind.Deduplicated, runId) + : new ExternalTriggerOutcome(ExternalTriggerOutcomeKind.Unprocessable, Guid.Empty, [], CodeSpace.Messages.Failures.FailureCodes.ExternalTriggerIdempotencyConflict); + } + + private async Task AuditRefusalAsync(ExternalTriggerVerification verification, ExternalTriggerCall call, string reason, string detail, CancellationToken ct) + { + logger.LogWarning("External trigger activation {ActivationId} workflow {WorkflowId} refused: {Reason} {Detail}", verification.ActivationId, verification.WorkflowId, reason, detail); + await auditor.WriteWebhookRejectedAsync(new WebhookRejectionContext + { + TeamId = verification.TeamId, + SourceType = ExternalTriggerSlug.TypeKey, + ExternalEventId = call.IdempotencyKey, + Reason = reason, + Detail = $"Activation {verification.ActivationId} workflow {verification.WorkflowId}: {detail}", + DedupKey = BuildRefusalWindowKey(verification.TriggerId, reason, DateTimeOffset.UtcNow), + }, ct).ConfigureAwait(false); + } + + private static string BuildRefusalWindowKey(Guid triggerId, string reason, DateTimeOffset now) => + $"refused:{triggerId:N}:{reason}:{now.UtcTicks / WebhookIngestionService.RefusalAuditWindow.Ticks}"; + + private static ExternalTriggerVerification Verification(ExternalTriggerOutcomeKind kind) => + new(kind, Guid.Empty, Guid.Empty, Guid.Empty, Guid.Empty, 0, string.Empty, default); + + private static bool KeyMatches(string? presentedKey, string digest) => presentedKey is not null + && CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(ExternalTriggerKeySealer.Digest(presentedKey)), Encoding.ASCII.GetBytes(digest)); + + private static ExternalTriggerOutcome InvalidBody() => Outcome(ExternalTriggerOutcomeKind.InvalidPayload, errors: ["$: body must be a JSON object"]); + + private static ExternalTriggerOutcome Outcome(ExternalTriggerOutcomeKind kind, Guid runId = default, IReadOnlyList? errors = null) => new(kind, runId, errors ?? []); +} diff --git a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerInputProjection.cs b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerInputProjection.cs new file mode 100644 index 000000000..2e9046b61 --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerInputProjection.cs @@ -0,0 +1,46 @@ +using System.Text.Json; +using CodeSpace.Messages.Dtos.Workflows; + +namespace CodeSpace.Core.Services.Workflows.ExternalTriggers; + +/// +/// Enforces inputSchema as the single input declaration so ingress validation and {{input.*}} cannot drift. +/// Apply before definition validation, serialization and hashing to freeze the same contract the engine resolves. +/// +public static class ExternalTriggerInputProjection +{ + public static IReadOnlyList Apply(IReadOnlyList declared, WorkflowDefinition definition) + { + var node = definition.Nodes.FirstOrDefault(n => n.TypeKey == ExternalTriggerSlug.TypeKey); + if (node is null) return declared; + + if (node.Config.ValueKind != JsonValueKind.Object + || !node.Config.TryGetProperty("inputSchema", out var schema) || schema.ValueKind != JsonValueKind.Object + || !schema.TryGetProperty("properties", out var properties) || properties.ValueKind != JsonValueKind.Object) + return Array.Empty(); + + var required = schema.TryGetProperty("required", out var names) && names.ValueKind == JsonValueKind.Array + ? names.EnumerateArray().Where(n => n.ValueKind == JsonValueKind.String).Select(n => n.GetString()!).ToHashSet(StringComparer.Ordinal) + : new HashSet(StringComparer.Ordinal); + + var inputs = new List(); + foreach (var property in properties.EnumerateObject()) + { + var value = property.Value; + if (value.ValueKind is not (JsonValueKind.Object or JsonValueKind.True or JsonValueKind.False)) continue; + var isObject = value.ValueKind == JsonValueKind.Object; + + inputs.Add(new WorkflowVariable + { + Name = property.Name, + Label = isObject && value.TryGetProperty("title", out var title) && title.ValueKind == JsonValueKind.String ? title.GetString() : null, + Description = isObject && value.TryGetProperty("description", out var description) && description.ValueKind == JsonValueKind.String ? description.GetString() : null, + Schema = value.Clone(), + Default = isObject && value.TryGetProperty("default", out var fallback) ? fallback.Clone() : null, + Required = required.Contains(property.Name), + }); + } + + return inputs; + } +} diff --git a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerJsonEquality.cs b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerJsonEquality.cs new file mode 100644 index 000000000..a3b7f7f10 --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerJsonEquality.cs @@ -0,0 +1,59 @@ +using System.Text.Json; + +namespace CodeSpace.Core.Services.Workflows.ExternalTriggers; + +public static class ExternalTriggerJsonEquality +{ + public static bool AreEquivalent(JsonElement left, JsonElement right) => + AreEquivalent(left, right, int.MaxValue, out _, out _); + + public static bool AreEquivalent(JsonElement left, JsonElement right, int maxNodes, out int nodesVisited, out bool exhausted) + { + var remaining = maxNodes; + exhausted = false; + var equivalent = Compare(left, right, ref remaining, ref exhausted); + nodesVisited = maxNodes - remaining; + return equivalent; + } + + private static bool Compare(JsonElement left, JsonElement right, ref int remaining, ref bool exhausted) + { + if (remaining == 0) { exhausted = true; return false; } + remaining--; + if (left.ValueKind != right.ValueKind) return false; + + if (left.ValueKind == JsonValueKind.Object) + { + // Index once so member order does not turn a bounded comparison into repeated object scans. + var properties = new Dictionary(StringComparer.Ordinal); + foreach (var property in right.EnumerateObject()) + { + if (remaining == 0) { exhausted = true; return false; } + remaining--; + if (!properties.TryAdd(property.Name, property.Value)) return false; + } + foreach (var property in left.EnumerateObject()) + { + if (remaining == 0) { exhausted = true; return false; } + remaining--; + if (!properties.Remove(property.Name, out var other) + || !Compare(property.Value, other, ref remaining, ref exhausted)) return false; + } + return properties.Count == 0; + } + + if (left.ValueKind == JsonValueKind.Array) + { + var leftItems = left.EnumerateArray(); + var rightItems = right.EnumerateArray(); + if (left.GetArrayLength() != right.GetArrayLength()) return false; + using var leftEnumerator = leftItems.GetEnumerator(); + using var rightEnumerator = rightItems.GetEnumerator(); + while (leftEnumerator.MoveNext() && rightEnumerator.MoveNext()) + if (!Compare(leftEnumerator.Current, rightEnumerator.Current, ref remaining, ref exhausted)) return false; + return true; + } + + return JsonElement.DeepEquals(left, right); + } +} diff --git a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerKeySealer.cs b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerKeySealer.cs new file mode 100644 index 000000000..801d12e5d --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerKeySealer.cs @@ -0,0 +1,29 @@ +using System.Security.Cryptography; +using System.Text; +using CodeSpace.Core.DependencyInjection; +using CodeSpace.Core.Services.Variables; + +namespace CodeSpace.Core.Services.Workflows.ExternalTriggers; + +/// +/// Stores a SHA-256 digest for anonymous-route key lookup and AES-GCM ciphertext for permission-gated reveal. Data Protection +/// is not used because its key ring is stored unencrypted in the same database (data_protection_keys). +/// +public sealed class ExternalTriggerKeySealer : ISingletonDependency +{ + private readonly IVariableValueEncryption _encryption; + + public ExternalTriggerKeySealer(IVariableValueEncryption encryption) => _encryption = encryption; + + public SealedExternalTriggerKey Mint() + { + var key = "ext_" + Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)).Replace('+', '-').Replace('/', '_').TrimEnd('='); + return new SealedExternalTriggerKey(key, Digest(key), Convert.ToBase64String(_encryption.Encrypt(key))); + } + + public static string Digest(string key) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(key))); + + public string Open(string ciphertext) => _encryption.Decrypt(Convert.FromBase64String(ciphertext)); +} + +public sealed record SealedExternalTriggerKey(string Key, string Digest, string Ciphertext); diff --git a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerPayloadValidator.cs b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerPayloadValidator.cs new file mode 100644 index 000000000..3e5019a9a --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerPayloadValidator.cs @@ -0,0 +1,381 @@ +using System.Text.Json; +using System.Globalization; +using System.Text; + +namespace CodeSpace.Core.Services.Workflows.ExternalTriggers; + +/// +/// Validates external request bodies strictly against the supported input-schema keywords. The LLM validator is +/// deliberately lenient about additional properties, so using it here would silently weaken a public API contract. +/// +public static class ExternalTriggerPayloadValidator +{ + public const int MaxErrors = 20; + public const int MaxNumberTokenLength = 256; + public const int MaxSchemaDepth = 64; + public const int MaxNodes = 10_000; + public const int MaxEnumComparisonNodes = 100_000; + + /// + /// The schema keywords the HTTP contract actually enforces. A keyword outside this set is refused at save time + /// rather than silently weakening the published contract. + /// + private static readonly HashSet SupportedSchemaKeywords = new(StringComparer.Ordinal) + { + "type", "enum", "required", "properties", "additionalProperties", "items", "maxLength", "title", "description", "default", + }; + + public static IReadOnlyList Validate(JsonElement body, JsonElement inputSchema) + { + var errors = new List(); + ValidateRepresentable(body, errors, new ValidationBudget(), depth: 0); + if (errors.Count == 0) ValidateNode(body, inputSchema, "$", errors, new ValidationBudget(), depth: 0); + return errors; + } + + public static IReadOnlyList ValidatePayload(JsonElement body) + { + var errors = new List(); + ValidateRepresentable(body, errors, new ValidationBudget(), depth: 0); + return errors; + } + + /// Save-time gate for the author-declared schema; payload validation does not guess unsupported semantics. + public static IReadOnlyList ValidateSchema(JsonElement schema) + { + var errors = new List(); + ValidateRepresentable(schema, errors, new ValidationBudget(), depth: 0); + if (errors.Count != 0) return errors; + if (schema.ValueKind != JsonValueKind.Object || !schema.TryGetProperty("type", out var rootType) + || rootType.ValueKind != JsonValueKind.String || rootType.GetString() != "object") + errors.Add("$: schema root must declare type 'object'"); + ValidateSchemaNode(schema, "$", errors, new ValidationBudget(), depth: 0); + return errors; + } + + private static void ValidateNode(JsonElement value, JsonElement schema, string path, List errors, ValidationBudget budget, int depth) + { + if (errors.Count >= MaxErrors || budget.Exhausted) return; + if (depth > MaxSchemaDepth) + { + errors.Add($"{path}: schema or value is too deep (max {MaxSchemaDepth})"); + return; + } + if (++budget.Nodes > MaxNodes) + { + errors.Add("$: schema or value contains too many nodes"); + budget.Exhausted = true; + return; + } + if (schema.ValueKind == JsonValueKind.False) { errors.Add($"{path}: value is not allowed"); return; } + if (schema.ValueKind != JsonValueKind.Object) return; + + if (schema.TryGetProperty("type", out var type) && !TypeMatches(value, type)) + { + errors.Add($"{path}: expected type '{DescribeType(type)}' but got {KindName(value.ValueKind)}"); + return; + } + + if (schema.TryGetProperty("enum", out var enumValues) && enumValues.ValueKind == JsonValueKind.Array) + { + var matched = false; + foreach (var candidate in enumValues.EnumerateArray()) + { + var remaining = MaxEnumComparisonNodes - budget.EnumComparisonNodes; + matched = ExternalTriggerJsonEquality.AreEquivalent(value, candidate, remaining, out var visited, out var exhausted); + budget.EnumComparisonNodes += visited; + if (exhausted) + { + errors.Add("$: enum comparison budget exceeded"); + budget.Exhausted = true; + return; + } + if (matched) break; + } + if (!matched) errors.Add($"{path}: value '{DisplayValue(value)}' is not one of the allowed enum values"); + } + + if (value.ValueKind == JsonValueKind.Object) + { + ValidateObject(value, schema, path, errors, budget, depth); + } + else if (value.ValueKind == JsonValueKind.Array && schema.TryGetProperty("items", out var items)) + { + var index = 0; + foreach (var item in value.EnumerateArray()) + { + if (errors.Count >= MaxErrors || budget.Exhausted) return; + ValidateNode(item, items, $"{path}[{index++}]", errors, budget, depth + 1); + } + } + else if (value.ValueKind == JsonValueKind.String && schema.TryGetProperty("maxLength", out var maxLength) && + maxLength.TryGetInt32(out var maximum) && TryGetString(value, out var text) && text.EnumerateRunes().Count() > maximum) + { + errors.Add($"{path}: string exceeds maximum length {maximum}"); + } + } + + private static void ValidateObject(JsonElement value, JsonElement schema, string path, List errors, ValidationBudget budget, int depth) + { + if (schema.TryGetProperty("required", out var required) && required.ValueKind == JsonValueKind.Array) + { + foreach (var property in required.EnumerateArray()) + { + if (errors.Count >= MaxErrors) return; + if (property.ValueKind == JsonValueKind.String && !value.TryGetProperty(property.GetString()!, out _)) + errors.Add($"{path}.{property.GetString()}: required property is missing"); + } + } + + var hasProperties = schema.TryGetProperty("properties", out var properties) && properties.ValueKind == JsonValueKind.Object; + var closesObject = schema.TryGetProperty("additionalProperties", out var additionalProperties) && + additionalProperties.ValueKind == JsonValueKind.False; + + foreach (var property in value.EnumerateObject()) + { + if (errors.Count >= MaxErrors || budget.Exhausted) return; + if (hasProperties && properties.TryGetProperty(property.Name, out var propertySchema)) + ValidateNode(property.Value, propertySchema, $"{path}.{property.Name}", errors, budget, depth + 1); + else if (closesObject) + errors.Add($"{path}.{property.Name}: additional property is not allowed"); + else if (additionalProperties.ValueKind == JsonValueKind.Object) + ValidateNode(property.Value, additionalProperties, $"{path}.{property.Name}", errors, budget, depth + 1); + } + } + + private static void ValidateSchemaNode(JsonElement schema, string path, List errors, ValidationBudget budget, int depth) + { + if (errors.Count >= MaxErrors || budget.Exhausted) return; + if (schema.ValueKind is JsonValueKind.True or JsonValueKind.False) return; + if (schema.ValueKind != JsonValueKind.Object) { errors.Add($"{path}: schema must be an object or boolean"); return; } + if (depth > MaxSchemaDepth) + { + errors.Add($"{path}: schema is too deep (max {MaxSchemaDepth})"); + return; + } + if (++budget.Nodes > MaxNodes) + { + errors.Add("$: schema contains too many nodes"); + budget.Exhausted = true; + return; + } + + foreach (var keyword in schema.EnumerateObject()) + { + if (errors.Count >= MaxErrors) return; + if (!SupportedSchemaKeywords.Contains(keyword.Name) && !keyword.Name.StartsWith("x-", StringComparison.Ordinal)) + errors.Add($"{path}.{keyword.Name}: schema keyword '{keyword.Name}' is not supported"); + var value = keyword.Value; + var valid = keyword.Name switch + { + "required" => value.ValueKind == JsonValueKind.Array && value.EnumerateArray().All(v => v.ValueKind == JsonValueKind.String), + "enum" => value.ValueKind == JsonValueKind.Array && value.GetArrayLength() > 0, + "maxLength" => value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var maximum) && maximum >= 0, + "properties" => value.ValueKind == JsonValueKind.Object, + "items" => value.ValueKind is JsonValueKind.Object or JsonValueKind.True or JsonValueKind.False, + "additionalProperties" => value.ValueKind is JsonValueKind.Object or JsonValueKind.True or JsonValueKind.False, + "title" or "description" => value.ValueKind == JsonValueKind.String, + _ => true, + }; + if (!valid && errors.Count < MaxErrors) errors.Add($"{path}.{keyword.Name}: invalid schema keyword value"); + } + + if (schema.TryGetProperty("type", out var type) && !TypeKeywordIsSupported(type)) + errors.Add($"{path}.type: type '{type}' is not supported"); + + if (schema.TryGetProperty("additionalProperties", out var additionalProperties) && + additionalProperties.ValueKind == JsonValueKind.Object) + ValidateSchemaNode(additionalProperties, $"{path}.additionalProperties", errors, budget, depth + 1); + + if (schema.TryGetProperty("properties", out var properties) && properties.ValueKind == JsonValueKind.Object) + { + foreach (var property in properties.EnumerateObject()) + ValidateSchemaNode(property.Value, $"{path}.properties.{property.Name}", errors, budget, depth + 1); + } + + if (schema.TryGetProperty("items", out var items)) + ValidateSchemaNode(items, $"{path}.items", errors, budget, depth + 1); + } + + private static bool TypeKeywordIsSupported(JsonElement type) => type.ValueKind switch + { + JsonValueKind.String => IsSupportedType(type.GetString()!), + JsonValueKind.Array => type.GetArrayLength() > 0 && type.EnumerateArray().All(candidate => candidate.ValueKind == JsonValueKind.String && IsSupportedType(candidate.GetString()!)), + _ => false, + }; + + private static bool IsSupportedType(string type) => type is "object" or "array" or "string" or "integer" or "number" or "boolean" or "null"; + + /// + /// jsonb rejects lone surrogates and numeric values it cannot encode. Reject both while the payload is still a + /// caller-visible 400, instead of letting the write fail as a server error. + /// + private static void ValidateRepresentable(JsonElement value, List errors, ValidationBudget budget, int depth) + { + if (errors.Count >= MaxErrors || budget.Exhausted) return; + if (depth > MaxSchemaDepth) + { + errors.Add($"$: payload is too deep (max {MaxSchemaDepth})"); + budget.Exhausted = true; + return; + } + if (++budget.Nodes > MaxNodes) + { + errors.Add("$: payload contains too many nodes"); + budget.Exhausted = true; + return; + } + + switch (value.ValueKind) + { + case JsonValueKind.String: + if (!TryGetString(value, out var text)) + { + errors.Add("$: string is not representable"); + break; + } + if (!IsWellFormedUtf16(text)) + errors.Add("$: string contains text that cannot be stored"); + break; + case JsonValueKind.Number: + var raw = value.GetRawText(); + if (raw.Length > MaxNumberTokenLength) + { + errors.Add("$: number token is too long"); + break; + } + if (!TryGetRepresentableNumber(raw)) + errors.Add("$: number is not representable"); + break; + case JsonValueKind.Object: + var names = new HashSet(StringComparer.Ordinal); + foreach (var property in value.EnumerateObject()) + { + if (errors.Count >= MaxErrors || budget.Exhausted) break; + string name; + try { name = property.Name; } + catch (InvalidOperationException) { errors.Add("$: property name is not representable"); continue; } + if (!IsWellFormedUtf16(name)) errors.Add("$: property name contains text that cannot be stored"); + if (!names.Add(name) && errors.Count < MaxErrors) errors.Add("$: duplicate property name is not allowed"); + ValidateRepresentable(property.Value, errors, budget, depth + 1); + } + break; + case JsonValueKind.Array: + foreach (var item in value.EnumerateArray()) + { + if (errors.Count >= MaxErrors || budget.Exhausted) break; + ValidateRepresentable(item, errors, budget, depth + 1); + } + break; + } + } + + private static bool TryGetRepresentableNumber(string raw) + { + var exponentIndex = raw.IndexOfAny(['e', 'E']); + if (exponentIndex >= 0) + { + if (!int.TryParse(raw.AsSpan(exponentIndex + 1), NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var exponent) + || exponent is < -330 or > 330) return false; + } + + return double.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out var number) && double.IsFinite(number); + } + + private static bool TryGetString(JsonElement value, out string text) + { + try { text = value.GetString()!; return true; } + catch (InvalidOperationException) { text = string.Empty; return false; } + } + + /// Postgres jsonb rejects unpaired UTF-16 surrogates; .NET strings can still contain them. + private static bool IsWellFormedUtf16(string value) + { + for (var i = 0; i < value.Length; i++) + { + if (value[i] == '\0') return false; + if (char.IsHighSurrogate(value[i])) + { + if (i + 1 >= value.Length || !char.IsLowSurrogate(value[i + 1])) return false; + i++; + } + else if (char.IsLowSurrogate(value[i])) return false; + } + return true; + } + + private static bool TypeMatches(JsonElement value, JsonElement type) => type.ValueKind switch + { + JsonValueKind.String => TypeMatches(value, type.GetString()!), + JsonValueKind.Array => type.EnumerateArray().Any(candidate => candidate.ValueKind == JsonValueKind.String && TypeMatches(value, candidate.GetString()!)), + _ => true, + }; + + private static bool TypeMatches(JsonElement value, string type) => type switch + { + "object" => value.ValueKind == JsonValueKind.Object, + "array" => value.ValueKind == JsonValueKind.Array, + "string" => value.ValueKind == JsonValueKind.String, + "integer" => value.ValueKind == JsonValueKind.Number && IsInteger(value.GetRawText()), + "number" => value.ValueKind == JsonValueKind.Number, + "boolean" => value.ValueKind is JsonValueKind.True or JsonValueKind.False, + "null" => value.ValueKind == JsonValueKind.Null, + _ => true, + }; + + private static string DescribeType(JsonElement type) + { + var names = type.ValueKind == JsonValueKind.Array + ? type.EnumerateArray().Where(candidate => candidate.ValueKind == JsonValueKind.String).Select(candidate => candidate.GetString()).ToArray() + : type.ValueKind == JsonValueKind.String ? [type.GetString()!] : []; + + return names.Length == 0 ? "a valid JSON type (the schema declares none)" : string.Join(" | ", names); + } + + private static bool IsInteger(string rawNumber) + { + var exponentIndex = rawNumber.IndexOfAny(['e', 'E']); + var mantissa = exponentIndex < 0 ? rawNumber : rawNumber[..exponentIndex]; + var exponent = exponentIndex < 0 ? 0 : int.Parse(rawNumber.AsSpan(exponentIndex + 1), CultureInfo.InvariantCulture); + var decimalIndex = mantissa.IndexOf('.'); + var fractionalDigits = decimalIndex < 0 ? 0 : mantissa.Length - decimalIndex - 1; + var digits = mantissa.Replace(".", "", StringComparison.Ordinal).TrimStart('-'); + var significantDigits = digits.TrimEnd('0'); + if (significantDigits.Length == 0) return true; + + // JSON Schema treats 5.0 as an integer: accept a decimal token when its exact value has no fractional + // part after applying the exponent. Checking token digits avoids double rounding away a non-zero fraction. + var remainingFractionalDigits = fractionalDigits - exponent; + if (remainingFractionalDigits <= 0) return true; + return remainingFractionalDigits <= digits.Length - significantDigits.Length; + } + + private static bool JsonValueEquals(JsonElement left, JsonElement right) + { + return JsonElement.DeepEquals(left, right); + } + + private static string KindName(JsonValueKind kind) => kind switch + { + JsonValueKind.True or JsonValueKind.False => "boolean", + _ => kind.ToString().ToLowerInvariant(), + }; + + private static string DisplayValue(JsonElement value) => value.ValueKind == JsonValueKind.String && !TryGetString(value, out _) + ? value.GetRawText() + : value.ToString(); + + /// Container raw text can still contain an unreadable string escape, so compare tokens without reading strings. + private static bool RawTextEquals(JsonElement left, JsonElement right) + { + try { return left.GetRawText() == right.GetRawText(); } + catch (InvalidOperationException) { return false; } + } + + private sealed class ValidationBudget + { + public int Nodes { get; set; } + public int EnumComparisonNodes { get; set; } + public bool Exhausted { get; set; } + } +} diff --git a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerRunAdmission.cs b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerRunAdmission.cs new file mode 100644 index 000000000..78ef54b23 --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerRunAdmission.cs @@ -0,0 +1,70 @@ +using System.Collections.Concurrent; +using CodeSpace.Core.DependencyInjection; + +namespace CodeSpace.Core.Services.Workflows.ExternalTriggers; + +/// +/// Process-local budget; only durable new runs consume capacity. +/// +public sealed class ExternalTriggerRunAdmission(TimeProvider? timeProvider = null) : ISingletonDependency, IDisposable +{ + public const int PermitsPerWindow = 60; + public static readonly TimeSpan Window = TimeSpan.FromMinutes(1); + + private readonly TimeProvider _clock = timeProvider ?? TimeProvider.System; + private readonly ConcurrentDictionary _windows = new(); + + public Reservation? Reserve(Guid triggerId) + { + var state = _windows.GetOrAdd(triggerId, _ => new WindowState()); + lock (state) + { + var now = _clock.GetUtcNow(); + if (now >= state.Start + Window) + { + state.Start = now; + state.Generation++; + state.Successful = 0; + state.Reserved = 0; + } + if (state.Successful + state.Reserved >= PermitsPerWindow) return null; + state.Reserved++; + return new Reservation(state, state.Generation); + } + } + + public void Dispose() => _windows.Clear(); + + internal sealed class WindowState + { + public DateTimeOffset Start = DateTimeOffset.MinValue; + public long Generation; + public int Successful; + public int Reserved; + } + + public sealed class Reservation : IDisposable + { + private readonly WindowState _state; + private readonly long _generation; + private bool _completed; + + internal Reservation(WindowState state, long generation) { _state = state; _generation = generation; } + + public void Commit() => Complete(committed: true); + public void Release() => Complete(committed: false); + public void Dispose() => Release(); + + private void Complete(bool committed) + { + lock (_state) + { + if (_completed) return; + _completed = true; + if (_generation != _state.Generation) return; + _state.Reserved--; + if (committed) _state.Successful++; + } + } + } +} diff --git a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerSlug.cs b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerSlug.cs new file mode 100644 index 000000000..801326da8 --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerSlug.cs @@ -0,0 +1,6 @@ +namespace CodeSpace.Core.Services.Workflows.ExternalTriggers; + +public static class ExternalTriggerSlug +{ + public const string TypeKey = "trigger.external"; +} diff --git a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerStore.cs b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerStore.cs new file mode 100644 index 000000000..6f26d14a4 --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerStore.cs @@ -0,0 +1,69 @@ +using CodeSpace.Core.Persistence.Db; +using CodeSpace.Core.Persistence.Entities; +using Microsoft.EntityFrameworkCore; + +namespace CodeSpace.Core.Services.Workflows.ExternalTriggers; + +public sealed class ExternalTriggerStore(CodeSpaceDbContext db, ExternalTriggerKeySealer sealer) : IExternalTriggerStore +{ + public async Task> ListForWorkflowAsync(Guid workflowId, Guid teamId, CancellationToken cancellationToken) => + await db.WorkflowExternalTrigger.AsNoTracking() + .Where(t => t.WorkflowId == workflowId && t.TeamId == teamId && t.DeletedDate == null) + .OrderBy(t => t.NodeId) + .Select(t => new ExternalTriggerView(t.Id, t.TeamId, t.WorkflowId, t.NodeId)) + .ToArrayAsync(cancellationToken).ConfigureAwait(false); + + public async Task FindAsync(Guid triggerId, Guid teamId, CancellationToken cancellationToken) => + await db.WorkflowExternalTrigger.AsNoTracking() + .Where(t => t.Id == triggerId && t.TeamId == teamId && t.DeletedDate == null) + .Select(t => new ExternalTriggerView(t.Id, t.TeamId, t.WorkflowId, t.NodeId)) + .SingleOrDefaultAsync(cancellationToken).ConfigureAwait(false); + + /// + /// Node identity survives activation replacement on every save; an activation id would retire a working credential. + /// + public async Task EnsureForNodeAsync(Guid workflowId, Guid teamId, string nodeId, Guid actorId, CancellationToken cancellationToken) + { + var row = await db.WorkflowExternalTrigger.SingleOrDefaultAsync( + t => t.WorkflowId == workflowId && t.TeamId == teamId && t.NodeId == nodeId && t.DeletedDate == null, cancellationToken).ConfigureAwait(false); + if (row is not null) return View(row); + + if (!await db.Workflow.AnyAsync(w => w.Id == workflowId && w.TeamId == teamId && w.DeletedDate == null, cancellationToken).ConfigureAwait(false)) + throw new KeyNotFoundException("Workflow not found in this team."); + + var key = sealer.Mint(); + row = new WorkflowExternalTrigger + { + Id = Guid.NewGuid(), WorkflowId = workflowId, TeamId = teamId, NodeId = nodeId, + KeyDigest = key.Digest, KeyCiphertext = key.Ciphertext, CreatedBy = actorId, LastModifiedBy = actorId, + }; + db.WorkflowExternalTrigger.Add(row); + await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + return View(row); + } + + public async Task RotateKeyAsync(Guid triggerId, Guid teamId, Guid actorId, CancellationToken cancellationToken) + { + await using var transaction = await ScopedTransaction.OwnOrJoinAsync(db.Database, cancellationToken).ConfigureAwait(false); + var workflowId = await db.WorkflowExternalTrigger.AsNoTracking() + .Where(t => t.Id == triggerId && t.TeamId == teamId) + .Select(t => (Guid?)t.WorkflowId).SingleOrDefaultAsync(cancellationToken).ConfigureAwait(false) + ?? throw new KeyNotFoundException("External trigger not found in this team."); + + await db.Database.ExecuteSqlInterpolatedAsync( + $"SELECT id FROM workflow WHERE id = {workflowId} AND team_id = {teamId} FOR UPDATE", cancellationToken).ConfigureAwait(false); + var row = await db.WorkflowExternalTrigger.FromSqlInterpolated( + $"SELECT * FROM workflow_external_trigger WHERE id = {triggerId} AND team_id = {teamId} AND deleted_date IS NULL FOR UPDATE") + .SingleOrDefaultAsync(cancellationToken).ConfigureAwait(false) + ?? throw new KeyNotFoundException("External trigger not found in this team."); + var key = sealer.Mint(); + row.KeyDigest = key.Digest; + row.KeyCiphertext = key.Ciphertext; + row.LastModifiedBy = actorId; + await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); + return key.Key; + } + + private static ExternalTriggerView View(WorkflowExternalTrigger row) => new(row.Id, row.TeamId, row.WorkflowId, row.NodeId); +} diff --git a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerVerificationTicket.cs b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerVerificationTicket.cs new file mode 100644 index 000000000..a9682d76b --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerVerificationTicket.cs @@ -0,0 +1,10 @@ +using CodeSpace.Core.DependencyInjection; + +namespace CodeSpace.Core.Services.Workflows.ExternalTriggers; + +internal sealed record ExternalTriggerVerificationTicket(Guid TriggerId, Guid TeamId, Guid WorkflowId, string NodeId, string KeyDigest); + +public sealed class ExternalTriggerVerificationContext : IScopedDependency +{ + internal ExternalTriggerVerificationTicket? Ticket { get; set; } +} diff --git a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/IExternalTriggerIngress.cs b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/IExternalTriggerIngress.cs new file mode 100644 index 000000000..ea4063cd6 --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/IExternalTriggerIngress.cs @@ -0,0 +1,29 @@ +using CodeSpace.Core.DependencyInjection; +using System.Text.Json; + +namespace CodeSpace.Core.Services.Workflows.ExternalTriggers; + +public interface IExternalTriggerIngress : IScopedDependency +{ + /// Resolves and authenticates the endpoint before any body is read. + Task VerifyAsync(Guid triggerId, string? presentedKey, CancellationToken ct); + + Task ReceiveAsync(ExternalTriggerCall call, CancellationToken ct); +} + +/// The authenticated endpoint identity retained between body read and delivery. +public sealed record ExternalTriggerVerification( + ExternalTriggerOutcomeKind Kind, + Guid TriggerId, + Guid ActivationId, + Guid WorkflowId, + Guid TeamId, + int WorkflowVersion, + string ActivationSnapshotJson, + JsonElement InputSchema); + +public sealed record ExternalTriggerCall(Guid TriggerId, string? PresentedKey, string? IdempotencyKey, string RawBody, bool IdempotencyRejected, JsonElement? ParsedBody = null); + +public sealed record ExternalTriggerOutcome(ExternalTriggerOutcomeKind Kind, Guid RunId, IReadOnlyList Errors, string? Code = null); + +public enum ExternalTriggerOutcomeKind { Accepted, Deduplicated, NotFound, Unauthorized, Forbidden, Unprocessable, InvalidPayload, RateLimited } diff --git a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/IExternalTriggerStore.cs b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/IExternalTriggerStore.cs new file mode 100644 index 000000000..eff194fce --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/IExternalTriggerStore.cs @@ -0,0 +1,13 @@ +using CodeSpace.Core.DependencyInjection; + +namespace CodeSpace.Core.Services.Workflows.ExternalTriggers; + +public interface IExternalTriggerStore : IScopedDependency +{ + Task FindAsync(Guid triggerId, Guid teamId, CancellationToken cancellationToken); + Task> ListForWorkflowAsync(Guid workflowId, Guid teamId, CancellationToken cancellationToken); + Task EnsureForNodeAsync(Guid workflowId, Guid teamId, string nodeId, Guid actorId, CancellationToken cancellationToken); + Task RotateKeyAsync(Guid triggerId, Guid teamId, Guid actorId, CancellationToken cancellationToken); +} + +public sealed record ExternalTriggerView(Guid Id, Guid TeamId, Guid WorkflowId, string NodeId); diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/TriggerExternalNode.cs b/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/TriggerExternalNode.cs new file mode 100644 index 000000000..a5680c112 --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/TriggerExternalNode.cs @@ -0,0 +1,40 @@ +using CodeSpace.Core.Services.Workflows.ExternalTriggers; +using CodeSpace.Messages.Enums; + +namespace CodeSpace.Core.Services.Workflows.Nodes.Builtin; + +/// +/// Accepts runs started by an authenticated external API request. +/// IsManual must remain false: deriveActivations projects an activation row from that +/// flag, and anonymous runs need its lineage or ExecutionAuthorityService rejects every call. +/// +public sealed class TriggerExternalNode : INodeRuntime +{ + public string TypeKey => ExternalTriggerSlug.TypeKey; + + public NodeManifest Manifest { get; } = new() + { + DisplayName = "External API", + Category = "Triggers", + Kind = NodeKind.Trigger, + IconKey = "webhook", + IsManual = false, + ConfigSchema = SchemaBuilder.Parse(""" + { + "type": "object", + "properties": { + "inputSchema": { "type": "object" } + }, + "additionalProperties": false + } + """), + InputSchema = SchemaBuilder.EmptyObject(), + OutputSchema = SchemaBuilder.EmptyObject(), + }; + + public Task RunAsync(NodeRunContext context, CancellationToken cancellationToken) + { + var outputs = context.Scope.Trigger.ToDictionary(kv => kv.Key, kv => kv.Value); + return Task.FromResult(NodeResult.Ok(outputs)); + } +} diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Plugins/Builtin/CoreFlowPlugin.cs b/backend/src/CodeSpace.Core/Services/Workflows/Plugins/Builtin/CoreFlowPlugin.cs index cfb4891ae..e17466021 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/Plugins/Builtin/CoreFlowPlugin.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/Plugins/Builtin/CoreFlowPlugin.cs @@ -21,6 +21,7 @@ public sealed class CoreFlowPlugin : IPluginModule public IReadOnlyList Nodes { get; } = new[] { typeof(TriggerManualNode), + typeof(TriggerExternalNode), typeof(TriggerScheduleNode), typeof(TerminalNode), typeof(LogicIfNode), diff --git a/backend/src/CodeSpace.Core/Services/Workflows/WorkflowService.cs b/backend/src/CodeSpace.Core/Services/Workflows/WorkflowService.cs index 18e3744ac..5a9c7275a 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/WorkflowService.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/WorkflowService.cs @@ -10,6 +10,7 @@ using CodeSpace.Core.Services.Slugs; using CodeSpace.Core.Services.Workflows.Dispatch; using CodeSpace.Core.Services.Workflows.Engine; +using CodeSpace.Core.Services.Workflows.ExternalTriggers; using CodeSpace.Core.Services.Workflows.Nodes; using CodeSpace.Core.Services.Workflows.RunSources; using CodeSpace.Core.Services.Workflows.RunSources.Matchers; @@ -50,6 +51,7 @@ public sealed class WorkflowService : IWorkflowService, IScopedDependency private readonly Rerun.IRerunCellSeeder _cellSeeder; private readonly Engine.IRunCancellationRegistry _cancellationRegistry; private readonly ILogger _logger; + private readonly ExternalTriggerDefinitionSync _externalTriggers; /// Reason stamped on a branch agent run aborted by the kill-wave when an operator cancels its parent workflow run. private const string OperatorCancelledAgentReason = "Cancelled because its parent workflow run was cancelled by an operator."; @@ -63,7 +65,7 @@ public sealed class WorkflowService : IWorkflowService, IScopedDependency /// What someone answering the question of a run that ended — its card or its Room decision — is told. The run's end closed the wait unanswered (Discarded): an operator's stop, or the engine's own cleanup when the run failed. Continue re-runs the step, which asks again: telling them the question was "already resolved" would say someone answered it. public const string EndedRunQuestionMessage = "This run ended (it was stopped or failed), so its question closed unanswered. It opens again when the run is continued."; - public WorkflowService(CodeSpaceDbContext db, WorkflowDefinitionServices definition, WorkflowLaunchServices launch, WorkflowControlServices control, ILogger logger) + public WorkflowService(CodeSpaceDbContext db, WorkflowDefinitionServices definition, WorkflowLaunchServices launch, WorkflowControlServices control, ILogger logger, ExternalTriggerDefinitionSync externalTriggers) { _db = db; _validator = definition.Validator; @@ -81,6 +83,7 @@ public WorkflowService(CodeSpaceDbContext db, WorkflowDefinitionServices definit _cellSeeder = control.Cells; _cancellationRegistry = control.Cancellation; _logger = logger; + _externalTriggers = externalTriggers; } public async Task> ListAsync(Guid teamId, CancellationToken cancellationToken) @@ -164,6 +167,9 @@ public async Task> ListAsync(Guid teamId, Cancell public async Task CreateAsync(Guid teamId, string name, string? description, WorkflowDefinition definition, IReadOnlyList activations, bool enabled, CancellationToken cancellationToken) { + definition = ExternalTriggerDefinitionSync.Normalize(definition); + ExternalTriggerDefinitionSync.ValidateSchemas(definition); + definition = definition with { Inputs = ExternalTriggerInputProjection.Apply(definition.Inputs, definition) }; EnsureValidDefinition(definition); await EnsureActivationRepositoriesHeldAsync(teamId, activations, cancellationToken).ConfigureAwait(false); @@ -213,6 +219,8 @@ public async Task CreateAsync(Guid teamId, string name, string? descriptio throw new InvalidOperationException($"A workflow with slug '{slug}' was created concurrently in this team. Please retry.", ex); } + await _externalTriggers.SyncAsync(workflowId, teamId, definition, _author.Id ?? Guid.Empty, cancellationToken).ConfigureAwait(false); + _logger.LogInformation( "Workflow created. WorkflowId={WorkflowId} TeamId={TeamId} Slug={Slug} Name={Name} Nodes={NodeCount} Edges={EdgeCount} Activations={ActivationCount} Enabled={Enabled}", workflowId, teamId, slug, name, definition.Nodes.Count, definition.Edges.Count, activations.Count, enabled); @@ -222,9 +230,13 @@ public async Task CreateAsync(Guid teamId, string name, string? descriptio public async Task UpdateAsync(Guid workflowId, Guid teamId, string name, string? description, WorkflowDefinition definition, IReadOnlyList activations, CancellationToken cancellationToken) { + definition = ExternalTriggerDefinitionSync.Normalize(definition); + ExternalTriggerDefinitionSync.ValidateSchemas(definition); + definition = definition with { Inputs = ExternalTriggerInputProjection.Apply(definition.Inputs, definition) }; EnsureValidDefinition(definition); await EnsureActivationRepositoriesHeldAsync(teamId, activations, cancellationToken).ConfigureAwait(false); + await _db.Database.ExecuteSqlInterpolatedAsync($"SELECT id FROM workflow WHERE id = {workflowId} AND team_id = {teamId} FOR UPDATE", cancellationToken).ConfigureAwait(false); var workflow = await LoadWorkflowAsync(workflowId, teamId, cancellationToken).ConfigureAwait(false) ?? throw new KeyNotFoundException($"Workflow {workflowId} not found in team {teamId}."); @@ -253,6 +265,8 @@ public async Task UpdateAsync(Guid workflowId, Guid teamId, string name, string? await ReplaceActivationsAsync(workflowId, activations, cancellationToken).ConfigureAwait(false); + await _externalTriggers.SyncAsync(workflowId, teamId, definition, _author.Id ?? Guid.Empty, cancellationToken).ConfigureAwait(false); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); _logger.LogInformation( @@ -265,16 +279,27 @@ public async Task DeleteAsync(Guid workflowId, Guid teamId, CancellationToken ca var workflow = await LoadWorkflowAsync(workflowId, teamId, cancellationToken).ConfigureAwait(false); if (workflow == null) return; - workflow.DeletedDate = DateTimeOffset.UtcNow; + var now = DateTimeOffset.UtcNow; + workflow.DeletedDate = now; var activations = await _db.WorkflowActivation.Where(a => a.WorkflowId == workflowId && a.DeletedDate == null).ToListAsync(cancellationToken).ConfigureAwait(false); - foreach (var a in activations) a.DeletedDate = DateTimeOffset.UtcNow; + foreach (var a in activations) a.DeletedDate = now; + + // Retiring the trigger row is what actually kills the credential: reveal/rotate/rename resolve the slug + // through workflow_external_trigger alone. The digest and row keep the audit trail; the ciphertext does not + // outlive the endpoint. + var triggers = await _db.WorkflowExternalTrigger.Where(t => t.WorkflowId == workflowId && t.DeletedDate == null).ToListAsync(cancellationToken).ConfigureAwait(false); + foreach (var t in triggers) + { + t.DeletedDate = now; + t.KeyCiphertext = string.Empty; + } await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); _logger.LogInformation( - "Workflow soft-deleted. WorkflowId={WorkflowId} TeamId={TeamId} ActivationsRemoved={ActivationCount}", - workflowId, teamId, activations.Count); + "Workflow soft-deleted. WorkflowId={WorkflowId} TeamId={TeamId} ActivationsRemoved={ActivationCount} TriggersRemoved={TriggerCount}", + workflowId, teamId, activations.Count, triggers.Count); } public async Task SetEnabledAsync(Guid workflowId, Guid teamId, bool enabled, CancellationToken cancellationToken) @@ -2211,10 +2236,11 @@ private async Task DeriveAvailableSlugAsync(Guid teamId, string? name, C /// /// Slugs that would be shadowed by a literal api/workflows/… GET route — ASP.NET gives those /// literals precedence over the {idOrSlug} parameter, so a workflow slugged to one would be - /// unreachable. Forced to a -N variant at derivation. Keep in sync with the 0099 backfill list. + /// unreachable. Forced to a -N variant at derivation. external-triggers was added after + /// the 0099 backfill and is repaired by 0252 for existing rows. /// private static readonly HashSet ReservedSlugs = - new(new[] { "runs", "node-manifests", "system-variables", "decisions" }, StringComparer.Ordinal); + new(new[] { "runs", "node-manifests", "system-variables", "decisions", "external-triggers" }, StringComparer.Ordinal); private static bool IsWorkflowSlugUniqueViolation(DbUpdateException ex) => ex.InnerException is Npgsql.PostgresException { SqlState: "23505" } pg diff --git a/backend/src/CodeSpace.Messages/Commands/Workflows/ReceiveExternalTriggerCommand.cs b/backend/src/CodeSpace.Messages/Commands/Workflows/ReceiveExternalTriggerCommand.cs new file mode 100644 index 000000000..9e50ad0ab --- /dev/null +++ b/backend/src/CodeSpace.Messages/Commands/Workflows/ReceiveExternalTriggerCommand.cs @@ -0,0 +1,28 @@ +using CodeSpace.Messages.Dtos.Workflows; +using CodeSpace.Messages.Mediation; +using System.Text.Json; + +namespace CodeSpace.Messages.Commands.Workflows; + +/// +/// An anonymous external trigger delivery: the per-node key is the only credential, not a user or team +/// session, so this declares NO authorization marker — proof is the key digest checked against the +/// trigger row. Marking it would make the pipeline reject a caller that is exactly who the endpoint +/// means to admit. +/// +public sealed record ReceiveExternalTriggerCommand : ICommand +{ + public required Guid TriggerId { get; init; } + + /// The key as presented, already normalized from Bearer / X-Api-Key. Null when none was sent. + public required string? PresentedKey { get; init; } + + /// Null when the caller sent no Idempotency-Key, or sent only whitespace — deduplication is then the caller's business. + public required string? IdempotencyKey { get; init; } + + /// True when a key WAS presented but is malformed; the ingress answers 400 without staging anything. + public required bool IdempotencyRejected { get; init; } + + /// The delivery body, already bounded by the endpoint's size limit. + public required JsonElement Body { get; init; } +} diff --git a/backend/src/CodeSpace.Messages/Commands/Workflows/RevealExternalTriggerKeyCommand.cs b/backend/src/CodeSpace.Messages/Commands/Workflows/RevealExternalTriggerKeyCommand.cs new file mode 100644 index 000000000..e155994e6 --- /dev/null +++ b/backend/src/CodeSpace.Messages/Commands/Workflows/RevealExternalTriggerKeyCommand.cs @@ -0,0 +1,12 @@ +using CodeSpace.Messages.Authorization; +using CodeSpace.Messages.Constants; +using CodeSpace.Messages.Mediation; + +namespace CodeSpace.Messages.Commands.Workflows; + +/// Exposes an anonymous-execution bearer credential, so membership alone must not grant Viewers access. +public sealed record RevealExternalTriggerKeyCommand : ICommand, IRequireTeamPermission +{ + public string RequiredPermission => TeamPermissions.WorkflowsWrite; + public required Guid TriggerId { get; init; } +} diff --git a/backend/src/CodeSpace.Messages/Commands/Workflows/RotateExternalTriggerKeyCommand.cs b/backend/src/CodeSpace.Messages/Commands/Workflows/RotateExternalTriggerKeyCommand.cs new file mode 100644 index 000000000..020d3c2a4 --- /dev/null +++ b/backend/src/CodeSpace.Messages/Commands/Workflows/RotateExternalTriggerKeyCommand.cs @@ -0,0 +1,12 @@ +using CodeSpace.Messages.Authorization; +using CodeSpace.Messages.Constants; +using CodeSpace.Messages.Mediation; + +namespace CodeSpace.Messages.Commands.Workflows; + +/// Immediately invalidates callers' existing credentials without a grace period; ordinary saves must never do this. +public sealed record RotateExternalTriggerKeyCommand : ICommand, IRequireTeamPermission +{ + public string RequiredPermission => TeamPermissions.WorkflowsWrite; + public required Guid TriggerId { get; init; } +} diff --git a/backend/src/CodeSpace.Messages/Constants/WorkflowRunRequestRejectionReasons.cs b/backend/src/CodeSpace.Messages/Constants/WorkflowRunRequestRejectionReasons.cs index f9f054607..4351cced7 100644 --- a/backend/src/CodeSpace.Messages/Constants/WorkflowRunRequestRejectionReasons.cs +++ b/backend/src/CodeSpace.Messages/Constants/WorkflowRunRequestRejectionReasons.cs @@ -104,4 +104,7 @@ public static class WorkflowRunRequestRejectionReasons /// to start runs on demand; a push moves the head, so new commits are never held back. /// public const string PullRequestDebounced = "pull_request_debounced"; + + /// A trigger's authenticated delivery rate exceeded its committed admission budget. The body is already known-good; the caller should retry in the next window. + public const string TriggerRateLimited = "trigger_rate_limited"; } diff --git a/backend/src/CodeSpace.Messages/Dtos/Workflows/ExternalTriggerDeliveryOutcome.cs b/backend/src/CodeSpace.Messages/Dtos/Workflows/ExternalTriggerDeliveryOutcome.cs new file mode 100644 index 000000000..c1778df9d --- /dev/null +++ b/backend/src/CodeSpace.Messages/Dtos/Workflows/ExternalTriggerDeliveryOutcome.cs @@ -0,0 +1,31 @@ +namespace CodeSpace.Messages.Dtos.Workflows; + +/// +/// What an anonymous external trigger delivery produced. Mirrors the ingress's own outcome so the +/// controller can map it to a status without the outcome type having to cross from CodeSpace.Core. +/// A staged run is ; a deterministic admission +/// refusal is or . +/// +public sealed record ExternalTriggerDeliveryOutcome +{ + public required ExternalTriggerDeliveryKind Kind { get; init; } + + /// The run this call staged or deduplicated onto; when none was. + public required Guid RunId { get; init; } + + /// Non-empty only on — the ingress's own messages. + public required IReadOnlyList Errors { get; init; } + public string? Code { get; init; } +} + +public enum ExternalTriggerDeliveryKind +{ + Accepted, + Deduplicated, + NotFound, + Unauthorized, + Forbidden, + Unprocessable, + InvalidPayload, + RateLimited, +} diff --git a/backend/src/CodeSpace.Messages/Dtos/Workflows/WorkflowExternalTriggerSummary.cs b/backend/src/CodeSpace.Messages/Dtos/Workflows/WorkflowExternalTriggerSummary.cs new file mode 100644 index 000000000..f0ae28ffd --- /dev/null +++ b/backend/src/CodeSpace.Messages/Dtos/Workflows/WorkflowExternalTriggerSummary.cs @@ -0,0 +1,8 @@ +namespace CodeSpace.Messages.Dtos.Workflows; + +/// +/// Credentials are absent because keys are obtainable only through the permission-gated reveal endpoint. +/// +public sealed record WorkflowExternalTriggerSummary(Guid Id, Guid WorkflowId, string NodeId); + +public sealed record WorkflowExternalTriggersResult(IReadOnlyList Triggers); diff --git a/backend/src/CodeSpace.Messages/Failures/FailureCodes.cs b/backend/src/CodeSpace.Messages/Failures/FailureCodes.cs index 05f6dd64d..a181ae6cb 100644 --- a/backend/src/CodeSpace.Messages/Failures/FailureCodes.cs +++ b/backend/src/CodeSpace.Messages/Failures/FailureCodes.cs @@ -84,6 +84,9 @@ public static class FailureCodes // ── Workflows and runs ───────────────────────────────────────────────────────── public const string WorkflowDefinitionInvalid = "workflow_definition_invalid"; + public const string ExternalTriggerIdempotencyConflict = "external_trigger_idempotency_conflict"; + public const string ExternalTriggerCompletionRefused = "external_trigger_completion_refused"; + public const string ExternalTriggerBodyTooLarge = "external_trigger_body_too_large"; public const string TaskRouteConfirmationRequired = "task_route_confirmation_required"; public const string TaskRouteSnapshotMismatch = "task_route_snapshot_mismatch"; public const string WorkspaceUnresolvable = "workspace_unresolvable"; diff --git a/backend/src/CodeSpace.Messages/Queries/Workflows/ListWorkflowExternalTriggersQuery.cs b/backend/src/CodeSpace.Messages/Queries/Workflows/ListWorkflowExternalTriggersQuery.cs new file mode 100644 index 000000000..ee47d45ea --- /dev/null +++ b/backend/src/CodeSpace.Messages/Queries/Workflows/ListWorkflowExternalTriggersQuery.cs @@ -0,0 +1,10 @@ +using CodeSpace.Messages.Authorization; +using CodeSpace.Messages.Dtos.Workflows; +using CodeSpace.Messages.Mediation; + +namespace CodeSpace.Messages.Queries.Workflows; + +public sealed record ListWorkflowExternalTriggersQuery : IQuery, IRequireTeamMembership +{ + public required Guid WorkflowId { get; init; } +} diff --git a/backend/src/CodeSpace.Messages/Queries/Workflows/VerifyExternalTriggerQuery.cs b/backend/src/CodeSpace.Messages/Queries/Workflows/VerifyExternalTriggerQuery.cs new file mode 100644 index 000000000..82130717b --- /dev/null +++ b/backend/src/CodeSpace.Messages/Queries/Workflows/VerifyExternalTriggerQuery.cs @@ -0,0 +1,13 @@ +using CodeSpace.Messages.Dtos.Workflows; +using CodeSpace.Messages.Mediation; + +namespace CodeSpace.Messages.Queries.Workflows; + +/// Anonymous digest check before body reading, outside the command transaction pipeline. +public sealed record VerifyExternalTriggerQuery : IQuery +{ + public required Guid TriggerId { get; init; } + public required string? PresentedKey { get; init; } +} + +public sealed record ExternalTriggerVerificationOutcome(ExternalTriggerDeliveryKind Kind); diff --git a/backend/tests/CodeSpace.E2ETests/Webhooks/ExternalTriggerEndpointE2ETests.cs b/backend/tests/CodeSpace.E2ETests/Webhooks/ExternalTriggerEndpointE2ETests.cs new file mode 100644 index 000000000..10a866cc5 --- /dev/null +++ b/backend/tests/CodeSpace.E2ETests/Webhooks/ExternalTriggerEndpointE2ETests.cs @@ -0,0 +1,461 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using CodeSpace.Api.Controllers; +using CodeSpace.Api.Extensions; +using CodeSpace.Core.Persistence.Db; +using CodeSpace.Core.Persistence.Entities; +using CodeSpace.Core.Services.Workflows; +using CodeSpace.Core.Services.Workflows.ExternalTriggers; +using CodeSpace.E2ETests.Infrastructure; +using CodeSpace.Messages.Dtos.Workflows; +using CodeSpace.Messages.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; + +namespace CodeSpace.E2ETests.Webhooks; + +[Trait("Category", "E2E")] +[Trait("Surface", "Http")] +public sealed class ExternalTriggerEndpointE2ETests(WebhookApiFactory factory) : IClassFixture +{ + private const string BODY = """{"service":"a"}"""; + private sealed record Seed(Guid UserId, Guid TeamId, Guid WorkflowId, Guid TriggerId, string Key); + + [Fact] + public async Task Metadata_lists_only_public_live_fields_and_allows_viewers() + { + var seed = await SeedAsync(); + var harness = new TenantHarness(factory); + var viewer = await harness.AccountAsync(); + await harness.AddMemberAsync(seed.TeamId, viewer, TeamRole.Viewer); + using var response = await harness.SendAsync(HttpMethod.Get, + $"/api/workflows/external-triggers?workflowId={seed.WorkflowId}", viewer, seed.TeamId); + + response.StatusCode.ShouldBe(HttpStatusCode.OK, await TenantHarness.DescribeAsync(response)); + var payload = await response.Content.ReadFromJsonAsync(); + payload.EnumerateObject().Select(p => p.Name).ShouldBe(new[] { "triggers" }); + var trigger = payload.GetProperty("triggers").EnumerateArray().ShouldHaveSingleItem(); + trigger.EnumerateObject().Select(p => p.Name).Order().ShouldBe(new[] { "id", "nodeId", "workflowId" }); + trigger.GetProperty("id").GetGuid().ShouldBe(seed.TriggerId); + trigger.GetProperty("workflowId").GetGuid().ShouldBe(seed.WorkflowId); + trigger.GetProperty("nodeId").GetString().ShouldBe("trigger"); + } + + [Fact] + public async Task Metadata_returns_404_for_a_missing_workflow_and_401_for_anonymous_callers() + { + var seed = await SeedAsync(); + var harness = new TenantHarness(factory); + using var missing = await harness.SendAsync(HttpMethod.Get, + $"/api/workflows/external-triggers?workflowId={Guid.NewGuid()}", seed.UserId, seed.TeamId); + missing.StatusCode.ShouldBe(HttpStatusCode.NotFound); + using var anonymous = await harness.Client().GetAsync($"/api/workflows/external-triggers?workflowId={seed.WorkflowId}"); + anonymous.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Metadata_returns_an_empty_list_when_the_workflow_has_no_live_trigger() + { + var seed = await SeedAsync(); + var harness = new TenantHarness(factory); + await harness.DbAsync(db => db.WorkflowExternalTrigger.Where(t => t.WorkflowId == seed.WorkflowId) + .ExecuteUpdateAsync(s => s.SetProperty(t => t.DeletedDate, DateTimeOffset.UtcNow))); + using var response = await harness.SendAsync(HttpMethod.Get, + $"/api/workflows/external-triggers?workflowId={seed.WorkflowId}", seed.UserId, seed.TeamId); + response.StatusCode.ShouldBe(HttpStatusCode.OK, await TenantHarness.DescribeAsync(response)); + var payload = await response.Content.ReadFromJsonAsync(); + payload.GetProperty("triggers").EnumerateArray().ShouldBeEmpty(); + } + + [Fact] + public async Task Management_reveals_and_rotates_without_changing_the_endpoint() + { + var seed = await SeedAsync(); + var harness = new TenantHarness(factory); + using var reveal = await harness.SendAsync(HttpMethod.Get, $"/api/workflows/external-triggers/{seed.TriggerId}/key", seed.UserId, seed.TeamId); + reveal.StatusCode.ShouldBe(HttpStatusCode.OK, await TenantHarness.DescribeAsync(reveal)); + reveal.Headers.CacheControl!.Private.ShouldBeTrue(); + reveal.Headers.CacheControl.NoStore.ShouldBeTrue(); + (await reveal.Content.ReadFromJsonAsync()).GetProperty("key").GetString().ShouldBe(seed.Key); + using var rotate = await harness.SendAsync(HttpMethod.Post, $"/api/workflows/external-triggers/{seed.TriggerId}/rotate-key", seed.UserId, seed.TeamId); + rotate.StatusCode.ShouldBe(HttpStatusCode.OK, await TenantHarness.DescribeAsync(rotate)); + rotate.Headers.CacheControl!.Private.ShouldBeTrue(); + rotate.Headers.CacheControl.NoStore.ShouldBeTrue(); + var fresh = (await rotate.Content.ReadFromJsonAsync()).GetProperty("key").GetString(); + fresh.ShouldNotBeNullOrEmpty(); + fresh.ShouldNotBe(seed.Key); + using var oldKey = await PostAsync(Url(seed), BODY, apiKey: seed.Key); + oldKey.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + using var newUrl = await PostAsync(Url(seed), BODY, apiKey: fresh); + newUrl.StatusCode.ShouldBe(HttpStatusCode.Accepted); + } + + [Theory] + [InlineData("key", "GET")] + [InlineData("rotate-key", "POST")] + public async Task Management_is_team_scoped_and_denies_viewers(string action, string method) + { + var seed = await SeedAsync(); + var harness = new TenantHarness(factory); + var viewer = await harness.AccountAsync(); + await harness.AddMemberAsync(seed.TeamId, viewer, TeamRole.Viewer); + var url = $"/api/workflows/external-triggers/{seed.TriggerId}/{action}"; + using var denied = await harness.SendAsync(new HttpMethod(method), url, viewer, seed.TeamId, method == "POST" ? new { desiredSlug = "unused" } : null); + denied.StatusCode.ShouldBe(HttpStatusCode.Forbidden); + var other = await harness.TeamAsync((seed.UserId, TeamRole.Member)); + using var absent = await harness.SendAsync(new HttpMethod(method), url, seed.UserId, other, method == "POST" ? new { desiredSlug = "unused" } : null); + absent.StatusCode.ShouldBe(HttpStatusCode.NotFound); + using var anonymous = await harness.Client().GetAsync($"/api/workflows/external-triggers/{seed.TriggerId}/key"); + anonymous.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Independent_trigger_identities_have_independent_endpoints() + { + var first = await SeedAsync(); + var other = await SeedAsync(); + first.TriggerId.ShouldNotBe(other.TriggerId); + using var firstCall = await PostAsync(Url(first), BODY, apiKey: first.Key); + using var otherCall = await PostAsync(Url(other), BODY, apiKey: other.Key); + firstCall.StatusCode.ShouldBe(HttpStatusCode.Accepted); + otherCall.StatusCode.ShouldBe(HttpStatusCode.Accepted); + } + + [Fact] + public async Task An_unknown_slug_is_404() + { + using var response = await PostAsync($"/api/ext/missing-{Guid.NewGuid():N}/run", "{}"); + response.StatusCode.ShouldBe(HttpStatusCode.NotFound); + } + + [Theory] + [InlineData(null)] + [InlineData("ext_wrong")] + [InlineData("Bearer ")] + public async Task A_missing_or_wrong_key_is_401_before_validation(string? authorization) + { + var seed = await SeedAsync(); + using var response = await PostAsync(Url(seed), "not json", authorization: authorization); + response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + await ShouldHaveNoRequestsAsync(seed); + } + + [Theory] + [InlineData("bearer")] + [InlineData("mixed-bearer")] + [InlineData("api-key")] + [InlineData("empty-bearer-fallback")] + [InlineData("wrong-scheme-fallback")] + public async Task The_supported_key_forms_start_a_run(string form) + { + var seed = await SeedAsync(); + var authorization = form switch + { + "bearer" => "Bearer " + seed.Key, + "mixed-bearer" => "bEaReR " + seed.Key, + "empty-bearer-fallback" => "Bearer ", + "wrong-scheme-fallback" => "Basic not-a-key", + _ => null, + }; + var apiKey = form is "api-key" or "empty-bearer-fallback" or "wrong-scheme-fallback" or "api-key-before-query" ? seed.Key : null; + using var response = await PostAsync(Url(seed), BODY, authorization, apiKey); + response.StatusCode.ShouldBe(HttpStatusCode.Accepted, await TenantHarness.DescribeAsync(response)); + var payload = await response.Content.ReadFromJsonAsync(); + payload.GetProperty("deduplicated").GetBoolean().ShouldBeFalse(); + var runs = await RunsAsync(seed); + payload.GetProperty("runId").GetGuid().ShouldBe(runs.ShouldHaveSingleItem()); + } + + [Fact] + public async Task A_key_in_the_query_string_is_not_accepted() + { + var seed = await SeedAsync(); + using var response = await PostAsync(Url(seed) + "?key=" + seed.Key, BODY); + response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + await ShouldHaveNoRequestsAsync(seed); + } + + [Fact] + public async Task A_bearer_key_takes_precedence_over_other_key_forms() + { + var seed = await SeedAsync(); + using var response = await PostAsync(Url(seed) + "?key=" + seed.Key, BODY, "Bearer ext_wrong", seed.Key); + response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + await ShouldHaveNoRequestsAsync(seed); + } + + [Fact] + public async Task A_body_over_the_limit_is_413() + { + var seed = await SeedAsync(); + using var response = await PostAsync(Url(seed), new string('x', 1_048_577), apiKey: seed.Key); + response.StatusCode.ShouldBe(HttpStatusCode.RequestEntityTooLarge); + await ShouldHaveNoRequestsAsync(seed); + } + + [Theory] + [InlineData(0, HttpStatusCode.Accepted)] + [InlineData(1, HttpStatusCode.RequestEntityTooLarge)] + public async Task The_actual_body_byte_limit_is_one_MiB(int extra, HttpStatusCode expected) + { + var seed = await SeedAsync(); + var body = BODY + new string(' ', 1_048_576 - Encoding.UTF8.GetByteCount(BODY) + extra); + using var response = await PostAsync(Url(seed), body, apiKey: seed.Key); + response.StatusCode.ShouldBe(expected); + if (extra != 0) await ShouldHaveNoRequestsAsync(seed); + } + + [Fact] + public async Task Unknown_retired_missing_and_wrong_keys_have_the_same_public_failure() + { + var seed = await SeedAsync(); + using var missing = await PostAsync(Url(seed), "bad"); + using var wrong = await PostAsync(Url(seed), "bad", apiKey: "wrong"); + using var unknown = await PostAsync($"/api/ext/{Guid.NewGuid()}/run", "bad", apiKey: seed.Key); + await new TenantHarness(factory).DbAsync(db => db.WorkflowExternalTrigger.Where(t => t.Id == seed.TriggerId) + .ExecuteUpdateAsync(s => s.SetProperty(t => t.DeletedDate, DateTimeOffset.UtcNow))); + using var retired = await PostAsync(Url(seed), "bad", apiKey: seed.Key); + foreach (var response in new[] { missing, wrong, unknown, retired }) + { + response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + var payload = await response.Content.ReadFromJsonAsync(); + payload.GetProperty("code").GetString().ShouldBe("unauthorized"); + payload.GetProperty("message").GetString().ShouldBe("Invalid external trigger credentials."); + } + } + + [Fact] + public async Task A_contract_violation_is_400_with_the_ingress_errors_unchanged() + { + var seed = await SeedAsync(); + const string INVALID_BODY = """{"service":7}"""; + using var scope = factory.Services.CreateScope(); + var expected = await scope.ServiceProvider.GetRequiredService().ReceiveAsync(new(seed.TriggerId, seed.Key, null, INVALID_BODY, false), CancellationToken.None); + using var response = await PostAsync(Url(seed), INVALID_BODY, apiKey: seed.Key); + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + var payload = await response.Content.ReadFromJsonAsync(); + payload.GetProperty("errors").EnumerateArray().Select(e => e.GetString()).ShouldBe(expected.Errors); + (await response.Content.ReadAsStringAsync()).ShouldContain("$.service"); + await ShouldHaveNoRequestsAsync(seed); + } + + [Theory] + [InlineData("not json")] + [InlineData("[]")] + [InlineData("7")] + public async Task An_authenticated_malformed_or_non_object_body_is_400_and_writes_nothing(string body) + { + var seed = await SeedAsync(); + using var response = await PostAsync(Url(seed), body, apiKey: seed.Key); + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + var payload = await response.Content.ReadFromJsonAsync(); + payload.GetProperty("errors").EnumerateArray().Select(e => e.GetString()).ShouldBe(new[] { "$: body must be a JSON object" }); + await ShouldHaveNoRequestsAsync(seed); + } + + [Fact] + public async Task An_unknown_slug_is_404_without_reading_the_body() + { + using var response = await PostAsync($"/api/ext/missing-{Guid.NewGuid():N}/run", new string('x', 1024 * 1024)); + response.StatusCode.ShouldBe(HttpStatusCode.NotFound); + } + + [Fact] + public async Task A_validated_endpoint_asks_for_retry_after_when_its_run_budget_is_exhausted() + { + var seed = await SeedAsync(); + for (var i = 0; i < ExternalTriggerRunAdmission.PermitsPerWindow; i++) + { + using var admitted = await PostAsync(Url(seed), BODY, apiKey: seed.Key); + admitted.StatusCode.ShouldBe(HttpStatusCode.Accepted); + } + + using var refused = await PostAsync(Url(seed), BODY, apiKey: seed.Key); + refused.StatusCode.ShouldBe(HttpStatusCode.TooManyRequests); + refused.Headers.RetryAfter!.Delta.ShouldBe(ExternalTriggerRunAdmission.Window); + } + + [Fact] + public async Task The_request_limiter_returns_retry_after_for_canonical_UUID_targets() + { + var id = Guid.NewGuid(); + for (var i = 0; i < WebhookIngressRateLimitExtension.PermitsPerWindow; i++) + { + using var response = await PostAsync($"/api/ext/{id:D}/run", "{}"); + response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + } + using var refused = await PostAsync($"/api/ext/{id.ToString("N").ToUpperInvariant()}/run", "{}"); + refused.StatusCode.ShouldBe(HttpStatusCode.TooManyRequests); + refused.Headers.RetryAfter.ShouldNotBeNull().Delta.ShouldNotBeNull().ShouldBeGreaterThanOrEqualTo(TimeSpan.Zero); + var body = await refused.Content.ReadFromJsonAsync(); + body.GetProperty("code").GetString().ShouldBe("rate_limited"); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task An_invalid_idempotency_key_is_400_and_starts_nothing(bool oversized) + { + var seed = await SeedAsync(); + using var response = await PostAsync(Url(seed), BODY, apiKey: seed.Key, idempotencyKey: oversized ? new string('a', 201) : "bad key"); + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + var payload = await response.Content.ReadFromJsonAsync(); + payload.GetProperty("errors").EnumerateArray().Select(e => e.GetString()).ShouldBe(new[] { "Idempotency-Key must be at most 200 characters from [A-Za-z0-9._~-]" }); + await ShouldHaveNoRequestsAsync(seed); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task An_absent_or_blank_idempotency_key_does_not_deduplicate(string? idempotencyKey) + { + var seed = await SeedAsync(); + for (var i = 0; i < 2; i++) + { + using var response = await PostAsync(Url(seed), BODY, apiKey: seed.Key, idempotencyKey: idempotencyKey); + response.StatusCode.ShouldBe(HttpStatusCode.Accepted); + } + (await RunsAsync(seed)).Length.ShouldBe(2); + } + + [Fact] + public async Task A_deduplicated_delivery_answers_200_with_the_first_run_id() + { + var seed = await SeedAsync(); + using var first = await PostAsync(Url(seed), BODY, apiKey: seed.Key, idempotencyKey: "evt-1"); + using var second = await PostAsync(Url(seed), BODY, apiKey: seed.Key, idempotencyKey: "evt-1"); + first.StatusCode.ShouldBe(HttpStatusCode.Accepted); + second.StatusCode.ShouldBe(HttpStatusCode.OK); + var accepted = await first.Content.ReadFromJsonAsync(); + var repeated = await second.Content.ReadFromJsonAsync(); + repeated.GetProperty("deduplicated").GetBoolean().ShouldBeTrue(); + repeated.GetProperty("runId").GetGuid().ShouldBe(accepted.GetProperty("runId").GetGuid()); + (await RunsAsync(seed)).ShouldHaveSingleItem().ShouldBe(accepted.GetProperty("runId").GetGuid()); + } + + [Fact] + public async Task An_authority_refusal_is_403_without_a_run() + { + var seed = await SeedAsync(); + await new TenantHarness(factory).DbAsync(db => db.TeamMembership.Where(m => m.TeamId == seed.TeamId).ExecuteDeleteAsync()); + using var response = await PostAsync(Url(seed), BODY, apiKey: seed.Key); + response.StatusCode.ShouldBe(HttpStatusCode.Forbidden); + var payload = await response.Content.ReadFromJsonAsync(); + payload.GetProperty("code").GetString().ShouldBe("forbidden"); + payload.GetProperty("message").GetString().ShouldBe("External trigger execution is forbidden."); + (await response.Content.ReadAsStringAsync()).ShouldNotContain("membership-revoked"); + (await RunsAsync(seed)).ShouldBeEmpty(); + } + + [Fact] + public async Task A_completion_policy_refusal_is_422_without_a_run() + { + var seed = await SeedAsync(); + await new TenantHarness(factory).DbAsync(async db => + { + var workflow = await db.Workflow.SingleAsync(w => w.Id == seed.WorkflowId); + var graph = JsonSerializer.Deserialize(workflow.DefinitionJson, WorkflowJson.Options)!; + var definition = JsonSerializer.Serialize(graph with { CompletionMode = WorkflowDefinition.CompletionModeEnforced }, WorkflowJson.Options); + workflow.LatestVersion = 2; + workflow.DefinitionJson = definition; + db.WorkflowVersion.Add(new WorkflowVersion + { + WorkflowId = seed.WorkflowId, + Version = 2, + DefinitionJson = definition, + DefinitionHash = "legacy", + CommittedAt = DateTimeOffset.UtcNow, + CreatedDate = DateTimeOffset.UtcNow, + CreatedBy = seed.UserId, + }); + await db.SaveChangesAsync(); + return true; + }); + + using var response = await PostAsync(Url(seed), BODY, apiKey: seed.Key); + response.StatusCode.ShouldBe(HttpStatusCode.UnprocessableEntity); + var payload = await response.Content.ReadFromJsonAsync(); + payload.GetProperty("code").GetString().ShouldBe("external_trigger_completion_refused"); + payload.GetProperty("message").GetString().ShouldBe("External trigger execution was refused."); + (await response.Content.ReadAsStringAsync()).ShouldNotContain("ModeProfileRegistry"); + (await RunsAsync(seed)).ShouldBeEmpty(); + } + + [Fact] + public async Task A_refusal_is_audited_once_per_trigger_reason_and_day() + { + var seed = await SeedAsync(); + await new TenantHarness(factory).DbAsync(db => db.TeamMembership.Where(m => m.TeamId == seed.TeamId).ExecuteDeleteAsync()); + + using (await PostAsync(Url(seed), BODY, apiKey: seed.Key)) + using (await PostAsync(Url(seed), BODY, apiKey: seed.Key)) + using (await PostAsync(Url(seed), BODY, apiKey: seed.Key, idempotencyKey: "first")) + using (await PostAsync(Url(seed), BODY, apiKey: seed.Key, idempotencyKey: "second")) + { + var rows = await new TenantHarness(factory).DbAsync(db => db.WorkflowRunRequest.AsNoTracking() + .Where(r => r.TeamId == seed.TeamId && r.Status == WorkflowRunRequestStatus.Rejected) + .Select(r => r.IdempotencyKey).ToArrayAsync()); + rows.ShouldHaveSingleItem().ShouldStartWith("refused:"); + } + } + + private static string Url(Seed seed) => $"/api/ext/{seed.TriggerId}/run"; + + private async Task PostAsync(string url, string body, string? authorization = null, string? apiKey = null, string? idempotencyKey = null) + { + using var client = new TenantHarness(factory).Client(); + using var request = new HttpRequestMessage(HttpMethod.Post, url) { Content = new StringContent(body, Encoding.UTF8, "application/json") }; + if (authorization != null) request.Headers.TryAddWithoutValidation("Authorization", authorization); + if (apiKey != null) request.Headers.TryAddWithoutValidation("X-Api-Key", apiKey); + if (idempotencyKey != null) request.Headers.TryAddWithoutValidation("Idempotency-Key", idempotencyKey); + return await client.SendAsync(request); + } + + private async Task SeedAsync() + { + var harness = new TenantHarness(factory); + var userId = await harness.AccountAsync(); + var teamId = await harness.TeamAsync((userId, TeamRole.Owner)); + var slug = "alerts-" + Guid.NewGuid().ToString("N"); + var config = new { slug, inputSchema = new { type = "object", properties = new { service = new { type = "string" } }, required = new[] { "service" } } }; + using var response = await harness.SendAsync(HttpMethod.Post, "/api/workflows", userId, teamId, new + { + name = slug, enabled = true, + definition = new + { + schemaVersion = 1, + nodes = new object[] + { + new { id = "trigger", typeKey = "trigger.external", config, inputs = new { } }, + new { id = "end", typeKey = "builtin.terminal", config = new { }, inputs = new { } }, + }, + edges = new[] { new { from = "trigger", to = "end" } }, + }, + activations = new[] { new { typeKey = "trigger.external", enabled = true, config } }, + }); + response.StatusCode.ShouldBe(HttpStatusCode.OK, await TenantHarness.DescribeAsync(response)); + var payload = await response.Content.ReadFromJsonAsync(); + return await SeedCredentialAsync(userId, teamId, payload.GetProperty("id").GetGuid(), slug); + } + + private async Task SeedCredentialAsync(Guid userId, Guid teamId, Guid workflowId, string slug) + { + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var row = await db.WorkflowExternalTrigger.AsNoTracking().SingleAsync(t => t.WorkflowId == workflowId && t.TeamId == teamId && t.DeletedDate == null); + var key = scope.ServiceProvider.GetRequiredService().Open(row.KeyCiphertext); + return new(userId, teamId, workflowId, row.Id, key); + } + + private Task RunsAsync(Seed seed) => new TenantHarness(factory).DbAsync(db => db.WorkflowRun.AsNoTracking().Where(r => r.WorkflowId == seed.WorkflowId).Select(r => r.Id).ToArrayAsync()); + + private async Task ShouldHaveNoRequestsAsync(Seed seed) + { + (await RunsAsync(seed)).ShouldBeEmpty(); + (await new TenantHarness(factory).DbAsync(db => db.WorkflowRunRequest.CountAsync(r => r.WorkflowId == seed.WorkflowId))).ShouldBe(0); + } +} diff --git a/backend/tests/CodeSpace.E2ETests/Webhooks/WebhookIngressSourceLimitE2ETests.cs b/backend/tests/CodeSpace.E2ETests/Webhooks/WebhookIngressSourceLimitE2ETests.cs index cff3f3c77..25159c90b 100644 --- a/backend/tests/CodeSpace.E2ETests/Webhooks/WebhookIngressSourceLimitE2ETests.cs +++ b/backend/tests/CodeSpace.E2ETests/Webhooks/WebhookIngressSourceLimitE2ETests.cs @@ -50,6 +50,7 @@ public async Task One_source_changing_the_target_on_every_request_is_limited_by_ var over = await client.PostAsync(RotatingTarget(WebhookIngressRateLimitExtension.SourcePermitsPerWindow), new StringContent("{}")); over.StatusCode.ShouldBe(HttpStatusCode.TooManyRequests, customMessage: "a sender that changes the target every time must still run out — check the GlobalLimiter in WebhookIngressRateLimitExtension; without it each new target is a new limiter and nothing ever answers 429"); + over.Headers.RetryAfter.ShouldNotBeNull().Delta.ShouldNotBeNull().ShouldBeGreaterThanOrEqualTo(TimeSpan.Zero); } [Fact] diff --git a/backend/tests/CodeSpace.IntegrationTests/Middlewares/TransactionalBehaviorPostCommitTests.cs b/backend/tests/CodeSpace.IntegrationTests/Middlewares/TransactionalBehaviorPostCommitTests.cs index 2b584d781..91dcaf1df 100644 --- a/backend/tests/CodeSpace.IntegrationTests/Middlewares/TransactionalBehaviorPostCommitTests.cs +++ b/backend/tests/CodeSpace.IntegrationTests/Middlewares/TransactionalBehaviorPostCommitTests.cs @@ -30,6 +30,33 @@ private sealed record ProbeCommand : ICommand; private sealed record SweepProbeCommand : ICommand, INonTransactionalCommand; + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Completion_callbacks_follow_the_outer_transaction_even_on_cancellation(bool cancel) + { + using var scope = _fixture.BeginScope(); + var db = scope.Resolve(); + var callbacks = scope.Resolve(); + var behavior = new TransactionalBehavior(db, + scope.Resolve(), NullLogger>.Instance, callbacks); + var committed = false; + var released = false; + using var cancellation = new CancellationTokenSource(); + Task Next(CancellationToken ct) + { + callbacks.RegisterAfterCommit(_ => { committed = true; return Task.CompletedTask; }); + callbacks.RegisterAfterRollback(() => released = true); + if (cancel) { cancellation.Cancel(); ct.ThrowIfCancellationRequested(); } + return Task.FromResult(Unit.Value); + } + var error = await Record.ExceptionAsync(() => behavior.Handle(new ProbeCommand(), Next, cancellation.Token)); + committed.ShouldBe(!cancel); + released.ShouldBe(cancel); + if (cancel) error.ShouldBeAssignableTo(); + else error.ShouldBeNull(); + } + [Fact] public async Task A_non_transactional_command_reaches_its_handler_with_no_ambient_transaction() { diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerIngressContractTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerIngressContractTests.cs new file mode 100644 index 000000000..73df59612 --- /dev/null +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerIngressContractTests.cs @@ -0,0 +1,137 @@ +using System.Text.Json; +using System.Reflection; +using CodeSpace.Api.Controllers; +using CodeSpace.Api.Extensions; +using CodeSpace.Api.Http; +using CodeSpace.Messages.Commands.Workflows; +using CodeSpace.Messages.Queries.Workflows; +using CodeSpace.Messages.Dtos.Workflows; +using MediatR; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http.Metadata; +using Microsoft.AspNetCore.RateLimiting; +using Autofac; +using CodeSpace.IntegrationTests.Infrastructure; +using CodeSpace.Core.Persistence.Db; +using CodeSpace.Core.Services.Workflows.ExternalTriggers; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using System.Data.Common; +using Shouldly; + +namespace CodeSpace.IntegrationTests.Workflows; + +[Trait("Category", "Integration")] +[Collection(PostgresCollection.Name)] +public sealed class ExternalTriggerIngressContractTests(PostgresFixture fixture) +{ + [Fact] + public async Task Verification_projects_only_identity_and_digest_without_a_transaction() + { + var reads = new AuthenticationReads(); + using var scope = fixture.BeginScope(builder => + { + var options = new DbContextOptionsBuilder().UseNpgsql(fixture.ConnectionString) + .UseSnakeCaseNamingConvention().AddInterceptors(reads).Options; + builder.RegisterInstance(options).As>(); + }); + var result = await scope.Resolve().Send(new VerifyExternalTriggerQuery { TriggerId = Guid.NewGuid(), PresentedKey = "wrong" }); + result.Kind.ShouldBe(ExternalTriggerDeliveryKind.Unauthorized); + var sql = reads.Commands.ShouldHaveSingleItem(); + sql.ShouldContain("key_digest"); + sql.ShouldNotContain("key_ciphertext"); + sql.ShouldNotContain("definition"); + sql.ShouldNotContain("activation"); + scope.Resolve().Database.CurrentTransaction.ShouldBeNull(); + } + + [Fact] + public void The_ext_route_is_anonymous_bounded_and_rate_limited_like_webhook_ingress() + { + var controller = typeof(ExternalTriggersController); + controller.GetCustomAttribute().ShouldNotBeNull(); + controller.GetCustomAttribute().ShouldNotBeNull().Template.ShouldBe("api/ext"); + var method = controller.GetMethod("Run").ShouldNotBeNull(); + method.GetCustomAttribute().ShouldNotBeNull().Template.ShouldBe("{triggerId:guid}/run"); + ((IRequestSizeLimitMetadata)method.GetCustomAttribute().ShouldNotBeNull()).MaxRequestBodySize.ShouldBe(1_048_577); + controller.GetCustomAttribute().ShouldNotBeNull().PolicyName.ShouldBe(WebhookIngressRateLimitExtension.PolicyName); + method.GetParameters().ShouldNotContain(p => p.Name == "key" && p.GetCustomAttribute() != null); + } + + [Fact] + public async Task Failed_authentication_does_not_read_the_request_stream() + { + using var scope = fixture.BeginScope(); + var context = new DefaultHttpContext(); + context.Request.Body = new UnreadableStream(); + var controller = new ExternalTriggersController(scope.Resolve()) { ControllerContext = new ControllerContext { HttpContext = context } }; + (await controller.Run(Guid.NewGuid(), CancellationToken.None)).ShouldBeOfType(); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + public async Task The_reader_counts_actual_bytes_without_ContentLength(int extra) + { + var context = new DefaultHttpContext(); + var bytes = new byte[1_048_576 + extra]; + Array.Fill(bytes, (byte)' '); + bytes[0] = (byte)'{'; bytes[1] = (byte)'}'; + context.Request.Body = new MemoryStream(bytes); + context.Request.ContentLength.ShouldBeNull(); + if (extra == 0) + { + using var result = await BoundedJsonBodyReader.ReadAsync(context.Request, CancellationToken.None); + result.RootElement.ValueKind.ShouldBe(JsonValueKind.Object); + } + else await Should.ThrowAsync(() => BoundedJsonBodyReader.ReadAsync(context.Request, CancellationToken.None)); + } + + [Theory] + [InlineData(64, true)] + [InlineData(65, false)] + public async Task Parser_depth_has_a_controlled_boundary(int depth, bool accepted) + { + var json = string.Concat(Enumerable.Repeat("[", depth)) + "0" + string.Concat(Enumerable.Repeat("]", depth)); + var context = new DefaultHttpContext(); + context.Request.Body = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); + if (accepted) + { + using var body = await BoundedJsonBodyReader.ReadAsync(context.Request, CancellationToken.None); + ExternalTriggerPayloadValidator.ValidatePayload(body.RootElement).ShouldBeEmpty(); + } + else await Should.ThrowAsync(() => BoundedJsonBodyReader.ReadAsync(context.Request, CancellationToken.None)); + } + + [Fact] + public void Equivalent_UUID_spellings_share_the_target_request_budget() + { + var a = new DefaultHttpContext(); + var b = new DefaultHttpContext(); + a.Request.RouteValues["triggerId"] = "56DA5193-D2A0-4309-9ED7-EEF395D72124"; + b.Request.RouteValues["triggerId"] = "56da5193d2a043099ed7eef395d72124"; + WebhookIngressRateLimitExtension.PartitionKey(a).ShouldBe(WebhookIngressRateLimitExtension.PartitionKey(b)); + a.Request.RouteValues["triggerId"] = Guid.NewGuid(); + WebhookIngressRateLimitExtension.PartitionKey(a).ShouldNotBe(WebhookIngressRateLimitExtension.PartitionKey(b)); + } + + private sealed class UnreadableStream : MemoryStream + { + public override ValueTask ReadAsync(Memory buffer, CancellationToken ct = default) => throw new InvalidOperationException("Unauthenticated body was read."); + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken ct) => throw new InvalidOperationException("Unauthenticated body was read."); + } + + private sealed class AuthenticationReads : DbCommandInterceptor + { + public List Commands { get; } = []; + public override ValueTask> ReaderExecutingAsync(DbCommand command, CommandEventData eventData, + InterceptionResult result, CancellationToken ct = default) + { + command.Transaction.ShouldBeNull("cheap authentication must never start a transaction"); + Commands.Add(command.CommandText); + return ValueTask.FromResult(result); + } + } +} diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerIngressFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerIngressFlowTests.cs new file mode 100644 index 000000000..ebafc4b27 --- /dev/null +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerIngressFlowTests.cs @@ -0,0 +1,507 @@ +using System.Text.Json; +using Autofac; +using CodeSpace.Core.Failures; +using CodeSpace.Core.Middlewares.Transactional; +using CodeSpace.Core.Persistence.Db; +using CodeSpace.Core.Persistence.Entities; +using CodeSpace.Core.Services.Workflows; +using CodeSpace.Core.Services.Workflows.Dispatch; +using CodeSpace.Core.Services.Workflows.ExternalTriggers; +using CodeSpace.Core.Services.Workflows.RunSources; +using CodeSpace.IntegrationTests.Infrastructure; +using CodeSpace.IntegrationTests.Infrastructure.Jobs; +using CodeSpace.IntegrationTests.Workflows.Infrastructure; +using CodeSpace.Messages.Constants; +using CodeSpace.Messages.Dtos.Workflows; +using CodeSpace.Messages.Enums; +using CodeSpace.Messages.Failures; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Shouldly; + +namespace CodeSpace.IntegrationTests.Workflows; + +[Collection(PostgresCollection.Name)] +[Trait("Category", "Integration")] +public class ExternalTriggerIngressFlowTests(PostgresFixture fixture) +{ + private const string Config = """{"slug":"alerts","inputSchema":{"type":"object","properties":{"service":{"type":"string"}},"required":["service"]}}"""; + private sealed record Seed(Guid TeamId, Guid UserId, Guid WorkflowId, Guid ActivationId, Guid TriggerId, string Slug, string Key); + private sealed record RollbackProbe : CodeSpace.Messages.Mediation.ICommand; + + [Fact] + public async Task A_correct_key_starts_a_run_that_carries_the_activation_lineage() + { + var seed = await SeedAsync(); + var outcome = await ReceiveAsync(seed); + outcome.Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); + var run = (await RunsAsync(seed)).ShouldHaveSingleItem(); + outcome.RunId.ShouldBe(run.Id); + run.RunRequest.SourceType.ShouldBe("trigger.external"); + run.RunRequest.ActorType.ShouldBe("webhook"); + run.RunRequest.ActorId.ShouldBeNull(); + run.RunRequest.ActivationId.ShouldBe(seed.ActivationId); + run.RunRequest.ActivationSnapshotJson.ShouldNotBeNullOrEmpty(); + using var snapshot = JsonDocument.Parse(run.RunRequest.ActivationSnapshotJson); + snapshot.RootElement.GetProperty("id").GetGuid().ShouldBe(seed.ActivationId); + using var payload = JsonDocument.Parse(run.RunRequest.NormalizedPayloadJson); + payload.RootElement.GetProperty("service").GetString().ShouldBe("api"); + run.Status.ShouldBe(WorkflowRunStatus.Enqueued); + } + + [Theory] + [InlineData("ext_wrong")] + [InlineData(null)] + public async Task The_wrong_or_missing_key_is_unauthorized_before_payload_validation(string? key) + { + var seed = await SeedAsync(); + (await ReceiveAsync(seed, presentedKey: key, body: "bad", rejected: true)).Kind.ShouldBe(ExternalTriggerOutcomeKind.Unauthorized); + (await RequestsAsync(seed)).ShouldBeEmpty(); + } + + [Theory] + [InlineData("{\"service\":7}", "$.service")] + [InlineData("not json", "$: body must be a JSON object")] + [InlineData("[]", "$: body must be a JSON object")] + [InlineData("null", "$: body must be a JSON object")] + public async Task An_invalid_body_is_refused_without_writing_a_request_row(string body, string error) + { + var seed = await SeedAsync(); + var outcome = await ReceiveAsync(seed, body: body); + outcome.Kind.ShouldBe(ExternalTriggerOutcomeKind.InvalidPayload); + outcome.Errors.ShouldContain(e => e.Contains(error)); + (await RequestsAsync(seed)).ShouldBeEmpty(); + } + + [Fact] + public async Task A_rejected_idempotency_header_precedes_body_validation() + { + var seed = await SeedAsync(); + var outcome = await ReceiveAsync(seed, body: "bad", rejected: true); + outcome.Kind.ShouldBe(ExternalTriggerOutcomeKind.InvalidPayload); + outcome.Errors.ShouldBe(new[] { "Idempotency-Key must be at most 200 characters from [A-Za-z0-9._~-]" }); + (await RequestsAsync(seed)).ShouldBeEmpty(); + } + + [Fact] + public async Task An_unknown_trigger_is_unauthorized() + { + var seed = await SeedAsync(); + (await ReceiveAsync(seed with { TriggerId = Guid.NewGuid() })).Kind.ShouldBe(ExternalTriggerOutcomeKind.Unauthorized); + } + + [Fact] + public async Task Cheap_verification_does_not_require_a_definition_or_activation() + { + var seed = await SeedAsync(); + using var scope = fixture.BeginScope(); + var db = scope.Resolve(); + await db.Workflow.Where(w => w.Id == seed.WorkflowId).ExecuteUpdateAsync(s => s.SetProperty(w => w.LatestVersion, 999)); + await db.WorkflowActivation.Where(a => a.WorkflowId == seed.WorkflowId).ExecuteDeleteAsync(); + (await scope.Resolve().VerifyAsync(seed.TriggerId, seed.Key, CancellationToken.None)) + .Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); + db.Database.CurrentTransaction.ShouldBeNull(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task A_ticket_cannot_bypass_a_rotation_or_retirement_during_body_read(bool retire) + { + var seed = await SeedAsync(); + using var scope = fixture.BeginScope(); + var ingress = scope.Resolve(); + (await ingress.VerifyAsync(seed.TriggerId, seed.Key, CancellationToken.None)).Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); + using (var edit = fixture.BeginScope()) + { + var db = edit.Resolve(); + var row = await db.WorkflowExternalTrigger.SingleAsync(t => t.Id == seed.TriggerId); + if (retire) row.DeletedDate = DateTimeOffset.UtcNow; + else row.KeyDigest = ExternalTriggerKeySealer.Digest("rotated"); + await db.SaveChangesAsync(); + } + (await ingress.ReceiveAsync(new(seed.TriggerId, seed.Key, null, "{}", false), CancellationToken.None)) + .Kind.ShouldBe(ExternalTriggerOutcomeKind.Unauthorized); + (await RunsAsync(seed)).ShouldBeEmpty(); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task A_trigger_for_a_replaced_node_is_not_found_before_authentication(bool correctKey) + { + var seed = await SeedAsync(); + using (var edit = fixture.BeginScope()) + { + var db = edit.Resolve(); + var trigger = await db.WorkflowExternalTrigger.SingleAsync(t => t.Id == seed.TriggerId); + trigger.NodeId = "removed-node"; + await db.SaveChangesAsync(); + } + + var outcome = await ReceiveAsync(seed, presentedKey: correctKey ? seed.Key : "wrong"); + outcome.Kind.ShouldBe(correctKey ? ExternalTriggerOutcomeKind.NotFound : ExternalTriggerOutcomeKind.Unauthorized); + (await RunsAsync(seed)).ShouldBeEmpty(); + (await RequestsAsync(seed)).ShouldBeEmpty(); + } + + [Fact] + public async Task A_deduplicated_delivery_without_a_matching_run_is_a_server_error_without_dispatch() + { + var seed = await SeedAsync(); + var logger = new IngressWarnings(); + var dispatcher = new IngressDispatches(); + using var scope = fixture.BeginScope(b => + { + b.RegisterType().As().InstancePerLifetimeScope(); + b.RegisterInstance(logger).As>(); + b.RegisterInstance(dispatcher).As(); + }); + var jobs = scope.Resolve(); + var before = jobs.Calls.Count; + + var error = await Record.ExceptionAsync(() => scope.Resolve().ReceiveAsync( + new(seed.TriggerId, seed.Key, "missing-run", """{"service":"api"}""", false), CancellationToken.None)); + error.ShouldNotBeNull(); + FailureClassifier.Classify(error).Kind.ShouldBe(FailureKind.Internal); + FailureClassifier.Classify(error).ClientMessage.ShouldBe(FailureClassifier.MaskedMessage); + dispatcher.Calls.ShouldBe(0); + jobs.Calls.Count.ShouldBe(before); + (await RunsAsync(seed)).ShouldBeEmpty(); + (await RequestsAsync(seed)).ShouldBeEmpty(); + } + + private sealed class EmptyRunStarter : IRunStarter + { + public Task StartAsync(RunSourceEnvelope envelope, CancellationToken cancellationToken) => Task.FromResult(Guid.Empty); + } + + private sealed class IngressDispatches : IWorkflowRunDispatcher + { + public int Calls { get; private set; } + public Task DispatchAsync(Guid runId, CancellationToken cancellationToken) + { + Calls++; + return Task.FromResult(true); + } + } + + private sealed class IngressWarnings : ILogger + { + public List Warnings { get; } = []; + public IDisposable? BeginScope(TState state) where TState : notnull => null; + public bool IsEnabled(LogLevel logLevel) => true; + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + if (logLevel == LogLevel.Warning) Warnings.Add(formatter(state, exception)); + } + } + + [Theory] + [InlineData("disabled-activation")] + [InlineData("deleted-activation")] + [InlineData("disabled-workflow")] + [InlineData("deleted-workflow")] + [InlineData("deleted-trigger")] + [InlineData("multiple-activations")] + public async Task An_unavailable_or_ambiguous_endpoint_is_not_found(string state) + { + var seed = await SeedAsync(); + using (var scope = fixture.BeginScope()) + { + var db = scope.Resolve(); + var activation = await db.WorkflowActivation.SingleAsync(a => a.Id == seed.ActivationId); + var workflow = await db.Workflow.SingleAsync(w => w.Id == seed.WorkflowId); + switch (state) + { + case "disabled-activation": activation.Enabled = false; break; + case "deleted-activation": activation.DeletedDate = DateTimeOffset.UtcNow; break; + case "disabled-workflow": workflow.Enabled = false; break; + case "deleted-workflow": workflow.DeletedDate = DateTimeOffset.UtcNow; break; + case "deleted-trigger": (await db.WorkflowExternalTrigger.SingleAsync(t => t.Id == seed.TriggerId)).DeletedDate = DateTimeOffset.UtcNow; break; + case "multiple-activations": db.WorkflowActivation.Add(Activation(seed, Guid.NewGuid(), Config)); break; + } + await db.SaveChangesAsync(); + } + (await ReceiveAsync(seed, presentedKey: "wrong", body: "bad")).Kind.ShouldBe(ExternalTriggerOutcomeKind.Unauthorized); + (await ReceiveAsync(seed)).Kind.ShouldBe(state == "deleted-trigger" ? ExternalTriggerOutcomeKind.Unauthorized : ExternalTriggerOutcomeKind.NotFound); + (await RequestsAsync(seed)).ShouldBeEmpty(); + } + + [Fact] + public async Task Two_deliveries_without_an_idempotency_key_are_two_runs() + { + var seed = await SeedAsync(); + (await ReceiveAsync(seed)).Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); + (await ReceiveAsync(seed)).Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); + (await RunsAsync(seed)).Length.ShouldBe(2); + } + + [Fact] + public async Task An_authenticated_delivery_cannot_exceed_the_per_trigger_run_budget() + { + var seed = await SeedAsync(); + using var scope = fixture.BeginScope(b => b.RegisterInstance(new ExternalTriggerRunAdmission()).As().SingleInstance()); + var ingress = scope.Resolve(); + var admitted = 0; + for (var i = 0; i < ExternalTriggerRunAdmission.PermitsPerWindow + 1; i++) + { + var outcome = await ingress.ReceiveAsync(new ExternalTriggerCall(seed.TriggerId, seed.Key, null, """{"service":"api"}""", false), CancellationToken.None); + if (outcome.Kind == ExternalTriggerOutcomeKind.Accepted) admitted++; + } + + admitted.ShouldBe(ExternalTriggerRunAdmission.PermitsPerWindow); + (await RunsAsync(seed)).Length.ShouldBe(ExternalTriggerRunAdmission.PermitsPerWindow); + } + + [Fact] + public async Task The_same_idempotency_key_twice_starts_one_run_and_returns_the_first_run_id() + { + var seed = await SeedAsync(); + var first = await ReceiveAsync(seed, idempotencyKey: "evt-1"); + var second = await ReceiveAsync(seed, idempotencyKey: "evt-1"); + first.Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); + second.Kind.ShouldBe(ExternalTriggerOutcomeKind.Deduplicated); + second.RunId.ShouldBe(first.RunId); + (await RunsAsync(seed)).Length.ShouldBe(1); + } + + [Fact] + public async Task Reusing_an_idempotency_key_with_different_content_is_a_conflict() + { + var seed = await SeedAsync(); + var first = await ReceiveAsync(seed, idempotencyKey: "conflict"); + var second = await ReceiveAsync(seed, idempotencyKey: "conflict", body: "{\"service\":\"different\"}"); + second.Kind.ShouldBe(ExternalTriggerOutcomeKind.Unprocessable); + (await RunsAsync(seed)).ShouldHaveSingleItem().Id.ShouldBe(first.RunId); + } + + [Fact] + public async Task Replays_do_not_consume_the_next_new_event_capacity() + { + var seed = await SeedAsync(); + var first = await ReceiveAsync(seed, idempotencyKey: "replay"); + for (var i = 0; i < ExternalTriggerRunAdmission.PermitsPerWindow; i++) + { + var replay = await ReceiveAsync(seed, idempotencyKey: "replay"); + replay.Kind.ShouldBe(ExternalTriggerOutcomeKind.Deduplicated); + replay.RunId.ShouldBe(first.RunId); + } + (await ReceiveAsync(seed)).Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); + } + + [Fact] + public async Task An_equivalent_replay_precedes_the_current_schema() + { + var seed = await SeedAsync(); + var first = await ReceiveAsync(seed, idempotencyKey: "same", body: "{\"service\":\"api\",\"count\":1}"); + using (var edit = fixture.BeginScope()) + { + var db = edit.Resolve(); + var workflow = await db.Workflow.SingleAsync(w => w.Id == seed.WorkflowId); + var definition = JsonSerializer.Deserialize(workflow.DefinitionJson, WorkflowJson.Options)!; + definition = definition with { Nodes = definition.Nodes.Select(n => n.Id == "start" ? n with + { + Config = WorkflowsTestSeed.Json("{\"inputSchema\":{\"type\":\"object\",\"required\":[\"newField\"]}}"), + } : n).ToArray() }; + workflow.LatestVersion = 2; + workflow.DefinitionJson = JsonSerializer.Serialize(definition, WorkflowJson.Options); + db.WorkflowVersion.Add(new WorkflowVersion { WorkflowId = seed.WorkflowId, Version = 2, DefinitionJson = workflow.DefinitionJson, DefinitionHash = "test", CommittedAt = DateTimeOffset.UtcNow, CreatedBy = seed.UserId }); + await db.SaveChangesAsync(); + } + var replay = await ReceiveAsync(seed, idempotencyKey: "same", body: "{\"count\":1.0, \"service\":\"a\\u0070i\"}"); + replay.Kind.ShouldBe(ExternalTriggerOutcomeKind.Deduplicated); + replay.RunId.ShouldBe(first.RunId); + } + + [Fact] + public async Task Concurrent_deliveries_of_one_key_start_exactly_one_run() + { + var seed = await SeedAsync(); + var results = await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => ReceiveAsync(seed, idempotencyKey: "evt-concurrent"))); + (await RunsAsync(seed)).Length.ShouldBe(1); + results.Select(r => r.RunId).Distinct().ShouldHaveSingleItem().ShouldNotBe(Guid.Empty); + results.Count(r => r.Kind == ExternalTriggerOutcomeKind.Accepted).ShouldBe(1); + results.Count(r => r.Kind == ExternalTriggerOutcomeKind.Deduplicated).ShouldBe(7); + } + + [Fact] + public async Task Concurrent_different_payloads_with_one_key_have_one_winner() + { + var seed = await SeedAsync(); + var outcomes = await Task.WhenAll(Enumerable.Range(0, 8).Select(i => ReceiveAsync(seed, + idempotencyKey: "different", body: $"{{\"service\":\"{i}\"}}"))); + outcomes.Count(o => o.Kind == ExternalTriggerOutcomeKind.Accepted).ShouldBe(1); + outcomes.Count(o => o.Kind == ExternalTriggerOutcomeKind.Unprocessable).ShouldBe(7); + (await RunsAsync(seed)).ShouldHaveSingleItem(); + } + + [Fact] + public async Task Tiny_json_numbers_round_trip_without_becoming_zero() + { + var seed = await SeedAsync(); + (await ReceiveAsync(seed, body: "{\"service\":\"api\",\"tiny\":1e-330}")).Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); + using var persisted = JsonDocument.Parse((await RunsAsync(seed)).ShouldHaveSingleItem().RunRequest.NormalizedPayloadJson); + using var expected = JsonDocument.Parse("1e-330"); + JsonElement.DeepEquals(persisted.RootElement.GetProperty("tiny"), expected.RootElement).ShouldBeTrue(); + } + + [Fact] + public async Task An_outer_rollback_releases_capacity_and_does_not_dispatch() + { + var seed = await SeedAsync(); + using var scope = fixture.BeginScope(); + var db = scope.Resolve(); + var behavior = new TransactionalBehavior( + db, scope.Resolve(), Microsoft.Extensions.Logging.Abstractions.NullLogger>.Instance, + scope.Resolve()); + var request = new RollbackProbe(); + var error = await Record.ExceptionAsync(() => behavior.Handle(request, async ct => + { + (await scope.Resolve().ReceiveAsync(new(seed.TriggerId, seed.Key, null, "{\"service\":\"api\"}", false), ct)).Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); + throw new OperationCanceledException(); + }, CancellationToken.None)); + error.ShouldBeOfType(); + (await RunsAsync(seed)).ShouldBeEmpty(); + var admission = scope.Resolve(); + var reservations = Enumerable.Range(0, 60).Select(_ => admission.Reserve(seed.TriggerId)).ToArray(); + try { reservations.ShouldAllBe(r => r != null); } + finally { foreach (var reservation in reservations) reservation?.Release(); } + } + + [Fact] + public async Task A_recreated_activation_is_resolved_fresh_and_the_trigger_still_deduplicates() + { + var seed = await SeedAsync(); + using var scope = fixture.BeginScope(); + var ingress = scope.Resolve(); + var call = new ExternalTriggerCall(seed.TriggerId, seed.Key, "evt-1", """{"service":"api"}""", false); + (await ingress.ReceiveAsync(call, CancellationToken.None)).Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); + var replacement = Guid.NewGuid(); + using (var edit = fixture.BeginScope()) + { + var db = edit.Resolve(); + await db.WorkflowActivation.Where(a => a.Id == seed.ActivationId).ExecuteUpdateAsync(s => s.SetProperty(a => a.DeletedDate, DateTimeOffset.UtcNow)); + db.WorkflowActivation.Add(Activation(seed, replacement, Config)); + await db.SaveChangesAsync(); + } + var outcome = await ingress.ReceiveAsync(call, CancellationToken.None); + outcome.Kind.ShouldBe(ExternalTriggerOutcomeKind.Deduplicated); + (await RunsAsync(seed)).ShouldHaveSingleItem().RunRequest.ActivationId.ShouldBe(seed.ActivationId); + } + + [Theory] + [InlineData("{}")] + [InlineData("{\"inputSchema\":7}")] + public async Task A_missing_or_non_object_schema_imposes_no_extra_constraints(string config) + { + var seed = await SeedAsync(config); + (await ReceiveAsync(seed, body: "{}")).Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); + } + + [Theory] + [InlineData(false, "agent.authority_denied", ExternalTriggerOutcomeKind.Forbidden)] + [InlineData(true, "completion_admission_refused", ExternalTriggerOutcomeKind.Unprocessable)] + public async Task A_deterministic_admission_refusal_is_classified_without_a_run_and_durably_audited( + bool completion, string reason, ExternalTriggerOutcomeKind expectedKind) + { + var seed = await SeedAsync(); + using (var scope = fixture.BeginScope()) + { + var db = scope.Resolve(); + if (completion) + { + var workflow = await db.Workflow.SingleAsync(w => w.Id == seed.WorkflowId); + var graph = JsonSerializer.Deserialize(workflow.DefinitionJson, WorkflowJson.Options)!; + var definition = JsonSerializer.Serialize(graph with { CompletionMode = WorkflowDefinition.CompletionModeEnforced }, WorkflowJson.Options); + workflow.LatestVersion = 2; + workflow.DefinitionJson = definition; + db.WorkflowVersion.Add(new WorkflowVersion { WorkflowId = seed.WorkflowId, Version = 2, DefinitionJson = definition, DefinitionHash = "legacy", CommittedAt = DateTimeOffset.UtcNow, CreatedDate = DateTimeOffset.UtcNow, CreatedBy = seed.UserId }); + } + else await db.TeamMembership.Where(m => m.TeamId == seed.TeamId).ExecuteDeleteAsync(); + await db.SaveChangesAsync(); + } + for (var i = 0; i < 2; i++) + { + var outcome = await ReceiveAsync(seed, idempotencyKey: "refused"); + outcome.Kind.ShouldBe(expectedKind); + outcome.RunId.ShouldBe(Guid.Empty); + } + (await RunsAsync(seed)).ShouldBeEmpty(); + using var verify = fixture.BeginScope(); + var audit = (await verify.Resolve().WorkflowRunRequest.Where(r => r.TeamId == seed.TeamId).ToListAsync()).ShouldHaveSingleItem(); + audit.Status.ShouldBe(WorkflowRunRequestStatus.Rejected); + audit.SourceType.ShouldBe("trigger.external"); + audit.Error.ShouldNotBeNull(); + audit.Error!.ShouldContain(reason); + audit.IdempotencyKey.ShouldStartWith($"refused:"); + audit.IdempotencyKey.ShouldContain($":{reason}:"); + } + + [Fact] + public async Task Dispatch_waits_until_the_ambient_transaction_commits() + { + var seed = await SeedAsync(); + using var scope = fixture.BeginScope(); + var db = scope.Resolve(); + Guid runId; + await using (var transaction = await db.Database.BeginTransactionAsync()) + { + var outcome = await scope.Resolve().ReceiveAsync(new(seed.TriggerId, seed.Key, null, """{"service":"api"}""", false), CancellationToken.None); + runId = outcome.RunId; + (await db.WorkflowRun.AsNoTracking().SingleAsync(r => r.Id == runId)).Status.ShouldBe(WorkflowRunStatus.Pending); + (await RunsAsync(seed)).ShouldBeEmpty(); + await transaction.CommitAsync(); + } + await scope.Resolve().RunAllAsync(CancellationToken.None); + (await RunsAsync(seed)).ShouldHaveSingleItem().Status.ShouldBe(WorkflowRunStatus.Enqueued); + } + + private async Task SeedAsync(string config = Config) + { + var (teamId, userId) = await WorkflowsTestSeed.SeedTeamAsync(fixture, inProcessPool: false); + using var scope = fixture.BeginScopeAs(userId, teamId); + var db = scope.Resolve(); + var key = scope.Resolve().Mint(); + var seed = new Seed(teamId, userId, Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), "alerts-" + Guid.NewGuid().ToString("N"), key.Key); + var graph = WorkflowsTestSeed.MinimalDefinition() with + { + Nodes = new[] + { + new NodeDefinition { Id = "start", TypeKey = "trigger.external", Config = WorkflowsTestSeed.Json(config), Inputs = WorkflowsTestSeed.EmptyJson() }, + new NodeDefinition { Id = "end", TypeKey = "builtin.terminal", Config = WorkflowsTestSeed.EmptyJson(), Inputs = WorkflowsTestSeed.EmptyJson() }, + }, + }; + var definition = JsonSerializer.Serialize(graph, WorkflowJson.Options); + db.Workflow.Add(new Workflow { Id = seed.WorkflowId, TeamId = teamId, Slug = seed.Slug, Name = "External ingress", DefinitionJson = definition, Enabled = true, CreatedBy = userId, LastModifiedBy = userId }); + db.WorkflowVersion.Add(new WorkflowVersion { WorkflowId = seed.WorkflowId, Version = 1, DefinitionJson = definition, DefinitionHash = "test", CommittedAt = DateTimeOffset.UtcNow, CreatedDate = DateTimeOffset.UtcNow, CreatedBy = userId }); + db.WorkflowActivation.Add(Activation(seed, seed.ActivationId, config)); + db.WorkflowExternalTrigger.Add(new WorkflowExternalTrigger { Id = seed.TriggerId, TeamId = teamId, WorkflowId = seed.WorkflowId, NodeId = "start", KeyDigest = key.Digest, KeyCiphertext = key.Ciphertext, CreatedBy = userId, LastModifiedBy = userId }); + await db.SaveChangesAsync(); + return seed; + } + + private static WorkflowActivation Activation(Seed seed, Guid id, string config) => new() + { + Id = id, WorkflowId = seed.WorkflowId, TypeKey = "trigger.external", ConfigJson = config, + Enabled = true, CreatedBy = seed.UserId, LastModifiedBy = seed.UserId, + }; + + private async Task ReceiveAsync(Seed seed, string? presentedKey = "use-seeded-key", string? idempotencyKey = null, string body = "{\"service\": \"api\"}", bool rejected = false) + { + using var scope = fixture.BeginScope(); + return await scope.Resolve().ReceiveAsync(new(seed.TriggerId, presentedKey == "use-seeded-key" ? seed.Key : presentedKey, idempotencyKey, body, rejected), CancellationToken.None); + } + + private async Task RunsAsync(Seed seed) + { + using var scope = fixture.BeginScope(); + return await scope.Resolve().WorkflowRun.AsNoTracking().Include(r => r.RunRequest).Where(r => r.WorkflowId == seed.WorkflowId).ToArrayAsync(); + } + + private async Task RequestsAsync(Seed seed) + { + using var scope = fixture.BeginScope(); + return await scope.Resolve().WorkflowRunRequest.AsNoTracking().Where(r => r.WorkflowId == seed.WorkflowId).ToArrayAsync(); + } +} diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerManagementFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerManagementFlowTests.cs new file mode 100644 index 000000000..b25d14ca7 --- /dev/null +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerManagementFlowTests.cs @@ -0,0 +1,511 @@ +using System.Text.Json; +using Autofac; +using CodeSpace.Core.Authorization; +using CodeSpace.Core.Failures; +using CodeSpace.Core.Persistence.Db; +using CodeSpace.Core.Persistence.Entities; +using CodeSpace.Core.Services.Workflows; +using CodeSpace.Core.Services.Workflows.ExternalTriggers; +using CodeSpace.IntegrationTests.Infrastructure; +using CodeSpace.IntegrationTests.Workflows.Infrastructure; +using CodeSpace.Messages.Commands.Workflows; +using CodeSpace.Messages.Dtos.Workflows; +using CodeSpace.Messages.Enums; +using CodeSpace.Messages.Failures; +using CodeSpace.Messages.Queries.Workflows; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Shouldly; + +namespace CodeSpace.IntegrationTests.Workflows; + +[Collection(PostgresCollection.Name)] +[Trait("Category", "Integration")] +public sealed class ExternalTriggerManagementFlowTests(PostgresFixture fixture) +{ + private sealed record Seed(Guid TeamId, Guid UserId, Guid WorkflowId, Guid TriggerId, string Slug, string Key); + + [Fact] + public async Task Metadata_returns_the_stable_trigger_identity_without_a_slug() + { + var seed = await SeedAsync(); + await UpdateAsync(seed, Definition(seed.Slug)); + using var read = fixture.BeginScopeAs(seed.UserId, seed.TeamId); + var result = await read.Resolve().Send(new ListWorkflowExternalTriggersQuery { WorkflowId = seed.WorkflowId }); + var trigger = result!.Triggers.ShouldHaveSingleItem(); + trigger.Id.ShouldBe((await RowAsync(seed)).Id); + trigger.WorkflowId.ShouldBe(seed.WorkflowId); + trigger.NodeId.ShouldBe("start"); + JsonSerializer.SerializeToElement(trigger, WorkflowJson.Options).TryGetProperty("slug", out _).ShouldBeFalse(); + } + + [Fact] + public async Task Saving_a_legacy_slug_removes_it_without_changing_the_trigger_credential() + { + var seed = await SeedAsync(); + var before = await RowAsync(seed); + string originalDefinition; + string originalHash; + using (var history = fixture.BeginScope()) + { + var original = await history.Resolve().WorkflowVersion.AsNoTracking() + .SingleAsync(v => v.WorkflowId == seed.WorkflowId && v.Version == 1); + originalDefinition = original.DefinitionJson; + originalHash = original.DefinitionHash; + } + await UpdateAsync(seed, Definition("legacy-name")); + var after = await RowAsync(seed); + after.Id.ShouldBe(before.Id); + after.KeyDigest.ShouldBe(before.KeyDigest); + after.KeyCiphertext.ShouldBe(before.KeyCiphertext); + using var scope = fixture.BeginScope(); + var version = await scope.Resolve().WorkflowVersion.AsNoTracking() + .SingleAsync(v => v.WorkflowId == seed.WorkflowId && v.Version == 2); + var definition = JsonSerializer.Deserialize(version.DefinitionJson, WorkflowJson.Options)!; + definition.Nodes.Single(n => n.Id == "start").Config.TryGetProperty("slug", out _).ShouldBeFalse(); + version.DefinitionHash.ShouldBe(DefinitionHash.Compute(definition)); + var preserved = await scope.Resolve().WorkflowVersion.AsNoTracking() + .SingleAsync(v => v.WorkflowId == seed.WorkflowId && v.Version == 1); + preserved.DefinitionJson.ShouldBe(originalDefinition); + preserved.DefinitionHash.ShouldBe(originalHash); + } + + [Fact] + public async Task Metadata_does_not_return_another_teams_workflow() + { + var seed = await SeedAsync(); + var other = await SeedAsync(); + using var scope = fixture.BeginScopeAs(other.UserId, other.TeamId); + (await scope.Resolve().Send(new ListWorkflowExternalTriggersQuery { WorkflowId = seed.WorkflowId })).ShouldBeNull(); + var own = await scope.Resolve().Send(new ListWorkflowExternalTriggersQuery { WorkflowId = other.WorkflowId }); + own!.Triggers.ShouldHaveSingleItem().WorkflowId.ShouldBe(other.WorkflowId); + } + + [Fact] + public async Task Metadata_is_empty_for_a_workflow_that_never_had_an_external_trigger() + { + var (teamId, userId) = await WorkflowsTestSeed.SeedTeamAsync(fixture, false); + using var scope = fixture.BeginScopeAs(userId, teamId); + var workflowId = await CreateAsync(scope, WorkflowsTestSeed.MinimalDefinition()); + var result = await scope.Resolve().Send(new ListWorkflowExternalTriggersQuery { WorkflowId = workflowId }); + result.ShouldNotBeNull(); + result.Triggers.ShouldBeEmpty(); + } + + [Fact] + public async Task Metadata_lists_every_live_row_without_assuming_a_single_trigger() + { + var seed = await SeedAsync(); + using var scope = fixture.BeginScopeAs(seed.UserId, seed.TeamId); + await scope.Resolve().EnsureForNodeAsync(seed.WorkflowId, seed.TeamId, + "second", seed.UserId, CancellationToken.None); + var result = await scope.Resolve().Send(new ListWorkflowExternalTriggersQuery { WorkflowId = seed.WorkflowId }); + result!.Triggers.Select(t => t.NodeId).Order().ShouldBe(new[] { "second", "start" }); + result.Triggers.ShouldAllBe(t => t.WorkflowId == seed.WorkflowId); + } + + [Fact] + public async Task Metadata_excludes_retired_nodes_and_deleted_workflows() + { + var seed = await SeedAsync(); + using (var setup = fixture.BeginScope()) + { + var db = setup.Resolve(); + await db.WorkflowExternalTrigger.Where(t => t.WorkflowId == seed.WorkflowId) + .ExecuteUpdateAsync(s => s.SetProperty(t => t.DeletedDate, DateTimeOffset.UtcNow)); + } + using (var read = fixture.BeginScopeAs(seed.UserId, seed.TeamId)) + { + var result = await read.Resolve().Send(new ListWorkflowExternalTriggersQuery { WorkflowId = seed.WorkflowId }); + result!.Triggers.ShouldBeEmpty(); + } + using (var setup = fixture.BeginScope()) + await setup.Resolve().Workflow.Where(w => w.Id == seed.WorkflowId) + .ExecuteUpdateAsync(s => s.SetProperty(w => w.DeletedDate, DateTimeOffset.UtcNow)); + using var verify = fixture.BeginScopeAs(seed.UserId, seed.TeamId); + (await verify.Resolve().Send(new ListWorkflowExternalTriggersQuery { WorkflowId = seed.WorkflowId })).ShouldBeNull(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Saving_projects_the_node_contract_into_the_hashed_version_inputs(bool update) + { + var seed = await SeedAsync(); + if (update) + { + var definition = Definition(seed.Slug) with + { + Inputs = [new WorkflowVariable { Name = "stale", Schema = WorkflowsTestSeed.Json("""{"type":"string"}""") }], + }; + definition = definition with + { + Nodes = definition.Nodes.Select(n => n.Id == "start" ? n with { Config = Config(seed.Slug, "integer") } : n).ToArray(), + }; + await UpdateAsync(seed, definition); + } + + using var scope = fixture.BeginScope(); + var db = scope.Resolve(); + var version = await db.WorkflowVersion.AsNoTracking().SingleAsync(v => v.WorkflowId == seed.WorkflowId && v.Version == (update ? 2 : 1)); + using var json = JsonDocument.Parse(version.DefinitionJson); + var inputs = json.RootElement.GetProperty("inputs"); + inputs.GetArrayLength().ShouldBe(1); + inputs[0].GetProperty("name").GetString().ShouldBe("service"); + inputs[0].GetProperty("required").GetBoolean().ShouldBeTrue(); + inputs[0].GetProperty("schema").GetProperty("type").GetString().ShouldBe(update ? "integer" : "string"); + var saved = JsonSerializer.Deserialize(version.DefinitionJson, WorkflowJson.Options)!; + version.DefinitionHash.ShouldBe(DefinitionHash.Compute(saved)); + var workflow = await db.Workflow.AsNoTracking().SingleAsync(w => w.Id == seed.WorkflowId); + workflow.DefinitionJson.ShouldBe(version.DefinitionJson); + } + + [Theory] + [InlineData("label")] + [InlineData("position")] + [InlineData("schema")] + [InlineData("slug")] + public async Task Saving_the_workflow_again_leaves_the_key_and_endpoint_working(string edit) + { + var seed = await SeedAsync(); + var before = await RowAsync(seed); + var definition = Definition(seed.Slug); + definition = definition with + { + Nodes = definition.Nodes.Select(n => edit switch + { + "label" when n.Id == "end" => n with { Label = "Renamed" }, + "position" when n.Id == "start" => n with { Position = new NodePosition { X = 400, Y = 250 } }, + "schema" when n.Id == "start" => n with { Config = Config(seed.Slug, "integer") }, + "slug" when n.Id == "start" => n with { Config = Config("different-draft") }, + _ => n, + }).ToArray(), + }; + await UpdateAsync(seed, definition); + var after = await RowAsync(seed); + after.Id.ShouldBe(before.Id); + after.KeyDigest.ShouldBe(before.KeyDigest); + after.KeyCiphertext.ShouldBe(before.KeyCiphertext); + (await ReceiveAsync(seed, body: edit == "schema" ? "{\"service\":7}" : "{\"service\":\"api\"}")).Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); + if (edit == "schema") + (await ReceiveAsync(seed)).Kind.ShouldBe(ExternalTriggerOutcomeKind.InvalidPayload); + using var scope = fixture.BeginScope(); + var db = scope.Resolve(); + var activations = await db.WorkflowActivation.Where(a => a.WorkflowId == seed.WorkflowId).ToArrayAsync(); + activations.Count(a => a.DeletedDate == null).ShouldBe(1); + activations.Count(a => a.DeletedDate != null).ShouldBe(1); + var run = await db.WorkflowRun.Include(r => r.RunRequest).SingleAsync(r => r.WorkflowId == seed.WorkflowId); + run.RunRequest.ActivationId.ShouldBe(activations.Single(a => a.DeletedDate == null).Id); + } + + [Fact] + public async Task A_legacy_name_does_not_affect_the_live_endpoint() + { + var seed = await SeedAsync(); + (await ReceiveAsync(seed)).Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); + var before = await RowAsync(seed); + + await UpdateAsync(seed, Definition("告警")); + + var after = await RowAsync(seed); + after.Id.ShouldBe(before.Id); + after.KeyDigest.ShouldBe(before.KeyDigest); + after.KeyCiphertext.ShouldBe(before.KeyCiphertext); + var outcome = await ReceiveAsync(seed); + outcome.Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); + outcome.RunId.ShouldNotBe(Guid.Empty); + using var scope = fixture.BeginScope(); + var run = await scope.Resolve().WorkflowRun.SingleAsync(r => r.Id == outcome.RunId); + run.WorkflowId.ShouldBe(seed.WorkflowId); + + } + + [Fact] + public async Task Reveal_hands_back_the_minted_key() + { + var seed = await SeedAsync(); + using var scope = fixture.BeginScopeAs(seed.UserId, seed.TeamId); + (await scope.Resolve().Send(new RevealExternalTriggerKeyCommand { TriggerId = seed.TriggerId })).ShouldBe(seed.Key); + } + + [Theory] + [InlineData(TeamRole.Viewer, "reveal")] + [InlineData(TeamRole.Viewer, "rotate")] + [InlineData(TeamRole.Member, "reveal")] + [InlineData(TeamRole.Member, "rotate")] + [InlineData(TeamRole.Admin, "reveal")] + public async Task Management_requires_workflows_write(TeamRole role, string operation) + { + var seed = await SeedAsync(); + using (var setup = fixture.BeginScope()) + { + var db = setup.Resolve(); + (await db.TeamMembership.SingleAsync(m => m.TeamId == seed.TeamId)).Role = role; + await db.SaveChangesAsync(); + } + using var scope = fixture.BeginScopeAs(seed.UserId, seed.TeamId); + var error = await Record.ExceptionAsync(() => ManageAsync(scope.Resolve(), operation, seed.TriggerId)); + if (role == TeamRole.Viewer) error.ShouldBeOfType(); + else error.ShouldBeNull(); + } + + [Fact] + public async Task Rotating_invalidates_the_old_key_immediately_and_reveal_returns_the_new_key() + { + var seed = await SeedAsync(); + string fresh; + using (var scope = fixture.BeginScopeAs(seed.UserId, seed.TeamId)) + fresh = await scope.Resolve().Send(new RotateExternalTriggerKeyCommand { TriggerId = seed.TriggerId }); + fresh.ShouldNotBe(seed.Key); + (await ReceiveAsync(seed)).Kind.ShouldBe(ExternalTriggerOutcomeKind.Unauthorized); + (await ReceiveAsync(seed with { Key = fresh })).Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); + await UpdateAsync(seed, Definition(seed.Slug)); + using var verify = fixture.BeginScopeAs(seed.UserId, seed.TeamId); + (await verify.Resolve().Send(new RevealExternalTriggerKeyCommand { TriggerId = seed.TriggerId })).ShouldBe(fresh); + } + + [Fact] + public async Task Rotation_waits_for_workflow_retirement_and_cannot_restore_the_retired_key() + { + var seed = await SeedAsync(); + using var retirement = fixture.BeginScope(); + var retirementDb = retirement.Resolve(); + await using var retirementTransaction = await retirementDb.Database.BeginTransactionAsync(); + await retirementDb.Database.ExecuteSqlInterpolatedAsync($"SELECT id FROM workflow WHERE id = {seed.WorkflowId} FOR UPDATE"); + await retirementDb.WorkflowExternalTrigger.Where(trigger => trigger.Id == seed.TriggerId) + .ExecuteUpdateAsync(update => update + .SetProperty(trigger => trigger.DeletedDate, DateTimeOffset.UtcNow) + .SetProperty(trigger => trigger.KeyCiphertext, string.Empty)); + + using var rotation = fixture.BeginScopeAs(seed.UserId, seed.TeamId); + var rotationDb = rotation.Resolve(); + await rotationDb.Database.OpenConnectionAsync(); + var processId = await rotationDb.Database.SqlQueryRaw("SELECT pg_backend_pid() AS \"Value\"").SingleAsync(); + var rotationTask = rotation.Resolve().RotateKeyAsync( + seed.TriggerId, seed.TeamId, seed.UserId, CancellationToken.None); + + await WaitForLockAsync(processId); + await retirementTransaction.CommitAsync(); + + var error = await Record.ExceptionAsync(async () => await rotationTask); + error.ShouldBeOfType(); + using var verify = fixture.BeginScope(); + var retired = await verify.Resolve().WorkflowExternalTrigger.AsNoTracking() + .SingleAsync(trigger => trigger.Id == seed.TriggerId); + retired.DeletedDate.ShouldNotBeNull(); + retired.KeyCiphertext.ShouldBeEmpty(); + } + + private async Task WaitForLockAsync(int processId) + { + var deadline = DateTimeOffset.UtcNow.AddSeconds(5); + while (DateTimeOffset.UtcNow < deadline) + { + using var observer = fixture.BeginScope(); + var waitType = await observer.Resolve().Database + .SqlQueryRaw("SELECT wait_event_type AS \"Value\" FROM pg_stat_activity WHERE pid = {0}", processId) + .SingleOrDefaultAsync(); + if (waitType == "Lock") return; + await Task.Delay(10); + } + + throw new TimeoutException("The key rotation did not wait for the workflow lock."); + } + + [Theory] + [InlineData("reveal")] + [InlineData("rotate")] + public async Task Another_teams_trigger_is_not_found(string operation) + { + var first = await SeedAsync(); + var other = await SeedAsync(); + using var scope = fixture.BeginScopeAs(other.UserId, other.TeamId); + await Should.ThrowAsync(() => ManageAsync(scope.Resolve(), operation, first.TriggerId)); + (await ReceiveAsync(first)).Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); + } + + [Theory] + [InlineData("reveal")] + [InlineData("rotate")] + public async Task Deleting_the_workflow_retires_its_trigger_so_the_key_stops_resolving(string operation) + { + var seed = await SeedAsync(); + using (var scope = fixture.BeginScopeAs(seed.UserId, seed.TeamId)) + await scope.Resolve().Send(new DeleteWorkflowCommand { WorkflowId = seed.WorkflowId }); + + using (var verify = fixture.BeginScopeAs(seed.UserId, seed.TeamId)) + { + var error = await Record.ExceptionAsync(() => ManageAsync(verify.Resolve(), operation, seed.TriggerId)); + error.ShouldBeOfType(); + FailureClassifier.Classify(error).Kind.ShouldBe(FailureKind.NotFound); + } + + (await ReceiveAsync(seed)).Kind.ShouldBe(ExternalTriggerOutcomeKind.Unauthorized); + using var audit = fixture.BeginScope(); + var db = audit.Resolve(); + var row = await db.WorkflowExternalTrigger.AsNoTracking().SingleAsync(t => t.WorkflowId == seed.WorkflowId); + row.DeletedDate.ShouldNotBeNull(); + row.KeyDigest.ShouldBe(ExternalTriggerKeySealer.Digest(seed.Key)); + row.KeyCiphertext.ShouldBeEmpty(); + (await db.WorkflowExternalTrigger.CountAsync(t => t.Id == seed.TriggerId && t.DeletedDate == null)).ShouldBe(0); + } + + [Theory] + [InlineData("!!!")] + [InlineData("告警")] + [InlineData("")] + public async Task A_legacy_name_is_not_a_workflow_creation_constraint(string desired) + { + var (teamId, userId) = await WorkflowsTestSeed.SeedTeamAsync(fixture, false); + using var scope = fixture.BeginScopeAs(userId, teamId); + await CreateAsync(scope, Definition(desired)); + using var verify = fixture.BeginScope(); + (await verify.Resolve().Workflow.CountAsync(w => w.TeamId == teamId)).ShouldBe(1); + (await verify.Resolve().WorkflowExternalTrigger.CountAsync(w => w.TeamId == teamId)).ShouldBe(1); + } + + [Fact] + public async Task An_unsupported_schema_keyword_is_refused_at_save_without_creating_a_workflow() + { + var (teamId, userId) = await WorkflowsTestSeed.SeedTeamAsync(fixture, false); + using var scope = fixture.BeginScopeAs(userId, teamId); + var config = JsonSerializer.SerializeToElement(new + { + slug = "unsupported-schema-" + Guid.NewGuid().ToString("N"), + inputSchema = new { type = "object", properties = new { service = new { type = "string", pattern = "^a" } } }, + }); + var definition = Definition("unused") with + { + Nodes = Definition("unused").Nodes.Select(n => n.Id == "start" ? n with { Config = config } : n).ToArray(), + }; + + var error = await Record.ExceptionAsync(() => CreateAsync(scope, definition)); + + error.ShouldNotBeNull(); + FailureClassifier.Classify(error).Kind.ShouldBe(FailureKind.Unprocessable); + using var verify = fixture.BeginScope(); + (await verify.Resolve().Workflow.CountAsync(w => w.TeamId == teamId)).ShouldBe(0); + } + + [Theory] + [InlineData("{\"type\":\"object\",\"properties\":{\"\\uD800\":{\"type\":\"string\"}}}")] + [InlineData("{\"type\":\"object\",\"properties\":{\"a\":{\"type\":\"string\",\"title\":\"\\uD800\"}}}")] + [InlineData("{\"type\":\"object\",\"required\":[\"\\uD800\"]}")] + public async Task An_unstorable_schema_is_refused_before_input_projection(string schema) + { + var (teamId, userId) = await WorkflowsTestSeed.SeedTeamAsync(fixture, false); + using var scope = fixture.BeginScopeAs(userId, teamId); + var config = WorkflowsTestSeed.Json("{\"inputSchema\":" + schema + "}"); + var definition = Definition("legacy") with + { + Nodes = Definition("legacy").Nodes.Select(n => n.Id == "start" ? n with { Config = config } : n).ToArray(), + }; + var error = await Record.ExceptionAsync(() => CreateAsync(scope, definition)); + error.ShouldNotBeNull(); + FailureClassifier.Classify(error).Kind.ShouldBe(FailureKind.Unprocessable); + using var verify = fixture.BeginScope(); + (await verify.Resolve().Workflow.CountAsync(w => w.TeamId == teamId)).ShouldBe(0); + } + + [Fact] + public async Task Two_teams_can_save_the_same_legacy_name_with_independent_trigger_identities() + { + var seed = await SeedAsync(); + var (teamId, userId) = await WorkflowsTestSeed.SeedTeamAsync(fixture, false); + using var scope = fixture.BeginScopeAs(userId, teamId); + var workflowId = await CreateAsync(scope, Definition(seed.Slug)); + using var verify = fixture.BeginScope(); + var row = await verify.Resolve().WorkflowExternalTrigger.AsNoTracking() + .SingleAsync(t => t.WorkflowId == workflowId); + row.Id.ShouldNotBe((await RowAsync(seed)).Id); + row.KeyDigest.ShouldNotBe(ExternalTriggerKeySealer.Digest(seed.Key)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Removing_or_replacing_the_node_retires_its_old_credential(bool replace) + { + var seed = await SeedAsync(); + var definition = Definition(seed.Slug); + definition = definition with + { + Nodes = definition.Nodes.Select(n => n.Id != "start" ? n : replace + ? n with { Id = "replacement" } + : n with { TypeKey = "trigger.manual", Config = WorkflowsTestSeed.EmptyJson() }).ToArray(), + Edges = [new EdgeDefinition { From = replace ? "replacement" : "start", To = "end" }], + }; + await UpdateAsync(seed, definition); + (await ReceiveAsync(seed)).Kind.ShouldBe(ExternalTriggerOutcomeKind.Unauthorized); + using var scope = fixture.BeginScopeAs(seed.UserId, seed.TeamId); + var rows = await scope.Resolve().WorkflowExternalTrigger.Where(t => t.WorkflowId == seed.WorkflowId).ToArrayAsync(); + var retired = rows.Single(t => t.NodeId == "start"); + retired.DeletedDate.ShouldNotBeNull(); + retired.KeyDigest.ShouldBe(ExternalTriggerKeySealer.Digest(seed.Key)); + retired.KeyCiphertext.ShouldBeEmpty(); + if (replace) + { + var replacement = rows.Single(t => t.NodeId == "replacement"); + replacement.Id.ShouldNotBe(seed.TriggerId); + var fresh = await scope.Resolve().Send(new RevealExternalTriggerKeyCommand { TriggerId = replacement.Id }); + fresh.ShouldNotBe(seed.Key); + (await ReceiveAsync(seed with { TriggerId = replacement.Id, Key = fresh })).Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); + } + } + + private static JsonElement Config(string slug, string type = "string") => JsonSerializer.SerializeToElement(new + { + slug, + inputSchema = new { type = "object", properties = new { service = new { type } }, required = new[] { "service" } }, + }); + + private static WorkflowDefinition Definition(string slug) => WorkflowsTestSeed.MinimalDefinition() with + { + Nodes = [new NodeDefinition { Id = "start", TypeKey = "trigger.external", Config = Config(slug), Inputs = WorkflowsTestSeed.EmptyJson() }, + new NodeDefinition { Id = "end", TypeKey = "builtin.terminal", Config = WorkflowsTestSeed.EmptyJson(), Inputs = WorkflowsTestSeed.EmptyJson() }], + }; + + private static WorkflowActivationInput[] Activations(WorkflowDefinition definition) => definition.Nodes + .Where(n => n.TypeKey == "trigger.external") + .Select(n => new WorkflowActivationInput { TypeKey = n.TypeKey, Config = n.Config }).ToArray(); + + private static Task CreateAsync(ILifetimeScope scope, WorkflowDefinition definition) => scope.Resolve().Send(new CreateWorkflowCommand + { + Name = "External management", Definition = definition, Activations = Activations(definition), + }); + + private async Task SeedAsync() + { + var (teamId, userId) = await WorkflowsTestSeed.SeedTeamAsync(fixture, false); + var slug = "alerts-" + Guid.NewGuid().ToString("N"); + using var scope = fixture.BeginScopeAs(userId, teamId); + var workflowId = await CreateAsync(scope, Definition(slug)); + var row = await scope.Resolve().WorkflowExternalTrigger.SingleAsync(t => t.WorkflowId == workflowId && t.DeletedDate == null); + row.NodeId.ShouldBe("start"); + return new(teamId, userId, workflowId, row.Id, slug, scope.Resolve().Open(row.KeyCiphertext)); + } + + private async Task UpdateAsync(Seed seed, WorkflowDefinition definition) + { + using var scope = fixture.BeginScopeAs(seed.UserId, seed.TeamId); + await scope.Resolve().Send(new UpdateWorkflowCommand { WorkflowId = seed.WorkflowId, Name = "Edited", Definition = definition, Activations = Activations(definition) }); + } + + private async Task RowAsync(Seed seed) + { + using var scope = fixture.BeginScope(); + return await scope.Resolve().WorkflowExternalTrigger.AsNoTracking().SingleAsync(t => t.WorkflowId == seed.WorkflowId && t.DeletedDate == null); + } + + private async Task ReceiveAsync(Seed seed, string body = "{\"service\":\"api\"}") + { + using var scope = fixture.BeginScope(); + return await scope.Resolve().ReceiveAsync(new(seed.TriggerId, seed.Key, null, body, false), CancellationToken.None); + } + + private static Task ManageAsync(IMediator mediator, string operation, Guid triggerId) => operation switch + { + "reveal" => mediator.Send(new RevealExternalTriggerKeyCommand { TriggerId = triggerId }), + _ => mediator.Send(new RotateExternalTriggerKeyCommand { TriggerId = triggerId }), + }; +} diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerSchemaContractFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerSchemaContractFlowTests.cs new file mode 100644 index 000000000..2e956b371 --- /dev/null +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerSchemaContractFlowTests.cs @@ -0,0 +1,69 @@ +using System.Text.Json; +using Autofac; +using CodeSpace.Core.Persistence.Db; +using CodeSpace.Core.Services.Workflows; +using CodeSpace.Core.Services.Workflows.Engine; +using CodeSpace.Core.Services.Workflows.ExternalTriggers; +using CodeSpace.IntegrationTests.Infrastructure; +using CodeSpace.IntegrationTests.Workflows.Infrastructure; +using CodeSpace.Messages.Commands.Workflows; +using CodeSpace.Messages.Dtos.Workflows; +using CodeSpace.Messages.Enums; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Shouldly; + +namespace CodeSpace.IntegrationTests.Workflows; + +[Collection(PostgresCollection.Name)] +[Trait("Category", "Integration")] +public sealed class ExternalTriggerSchemaContractFlowTests(PostgresFixture fixture) +{ + [Fact] + public async Task Boolean_and_untyped_property_schemas_are_saved_and_resolve_through_workflow_inputs() + { + var (teamId, userId) = await WorkflowsTestSeed.SeedTeamAsync(fixture, false); + using var scope = fixture.BeginScopeAs(userId, teamId); + var schema = WorkflowsTestSeed.Json("""{"type":"object","properties":{"allowed":true,"blocked":false,"untyped":{}}}"""); + var config = JsonSerializer.SerializeToElement(new { inputSchema = schema }); + var definition = WorkflowsTestSeed.MinimalDefinition() with + { + Outputs = [new WorkflowVariable { Name = "allowed", Schema = WorkflowsTestSeed.Json("true") }, + new WorkflowVariable { Name = "untyped", Schema = WorkflowsTestSeed.Json("{}") }], + Nodes = [new NodeDefinition { Id = "start", TypeKey = "trigger.external", Config = config, Inputs = WorkflowsTestSeed.EmptyJson() }, + new NodeDefinition { Id = "end", TypeKey = "builtin.terminal", Config = WorkflowsTestSeed.EmptyJson(), + Inputs = WorkflowsTestSeed.Json("""{"allowed":"{{input.allowed}}","untyped":"{{input.untyped}}"} """) }], + }; + + var workflowId = await scope.Resolve().Send(new CreateWorkflowCommand + { + Name = "Boolean schema contract", Definition = definition, + Activations = [new WorkflowActivationInput { TypeKey = "trigger.external", Config = config }], + }); + var version = await scope.Resolve().WorkflowVersion.AsNoTracking() + .SingleAsync(candidate => candidate.WorkflowId == workflowId && candidate.Version == 1); + var saved = JsonSerializer.Deserialize(version.DefinitionJson, WorkflowJson.Options)!; + + saved.Inputs.Select(input => input.Name).ShouldBe(["allowed", "blocked", "untyped"]); + saved.Inputs[0].Schema.ValueKind.ShouldBe(JsonValueKind.True); + saved.Inputs[1].Schema.ValueKind.ShouldBe(JsonValueKind.False); + saved.Inputs[2].Schema.ValueKind.ShouldBe(JsonValueKind.Object); + + var trigger = await scope.Resolve().WorkflowExternalTrigger.AsNoTracking() + .SingleAsync(candidate => candidate.WorkflowId == workflowId); + var key = scope.Resolve().Open(trigger.KeyCiphertext); + ExternalTriggerOutcome outcome; + using (var receive = fixture.BeginScope()) + outcome = await receive.Resolve().ReceiveAsync( + new(trigger.Id, key, null, """{"allowed":true,"untyped":{"value":"kept"}}""", false), CancellationToken.None); + outcome.Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); + using (var engine = fixture.BeginScope()) + await engine.Resolve().ExecuteRunAsync(outcome.RunId, CancellationToken.None); + using var verify = fixture.BeginScope(); + var run = await verify.Resolve().WorkflowRun.AsNoTracking().SingleAsync(candidate => candidate.Id == outcome.RunId); + run.Status.ShouldBe(WorkflowRunStatus.Success); + using var outputs = JsonDocument.Parse(run.OutputsJson); + outputs.RootElement.GetProperty("allowed").GetBoolean().ShouldBeTrue(); + outputs.RootElement.GetProperty("untyped").GetProperty("value").GetString().ShouldBe("kept"); + } +} diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerStorageMigrationTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerStorageMigrationTests.cs new file mode 100644 index 000000000..7559a4f3d --- /dev/null +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerStorageMigrationTests.cs @@ -0,0 +1,74 @@ +using Autofac; +using CodeSpace.Core.Persistence.Db; +using CodeSpace.Core.Persistence.Entities; +using CodeSpace.IntegrationTests.Infrastructure; +using CodeSpace.IntegrationTests.Workflows.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using Shouldly; +using Xunit; + +namespace CodeSpace.IntegrationTests.Workflows; + +[Collection(PostgresCollection.Name)] +[Trait("Category", "Integration")] +public sealed class ExternalTriggerStorageMigrationTests(PostgresFixture fixture) +{ + [Fact] + public async Task A_trigger_is_persisted_without_a_slug() + { + var (teamId, userId) = await WorkflowsTestSeed.SeedTeamAsync(fixture); + var workflowId = await SeedWorkflowAsync(teamId, userId); + var row = NewRow(teamId, workflowId, userId, live: true); + await SeedAsync(row); + using var scope = fixture.BeginScope(); + var db = scope.Resolve(); + var saved = await db.WorkflowExternalTrigger.AsNoTracking().SingleAsync(t => t.Id == row.Id); + saved.KeyDigest.ShouldBe(row.KeyDigest); + saved.KeyCiphertext.ShouldBe(row.KeyCiphertext); + (await db.Database.SqlQuery($"SELECT count(*)::int AS \"Value\" FROM information_schema.columns WHERE table_name = 'workflow_external_trigger' AND column_name = 'slug'").SingleAsync()).ShouldBe(0); + } + + [Fact] + public async Task A_new_trigger_does_not_reuse_the_retired_identity() + { + var (teamId, userId) = await WorkflowsTestSeed.SeedTeamAsync(fixture); + var workflowId = await SeedWorkflowAsync(teamId, userId); + var retired = NewRow(teamId, workflowId, userId, live: false); + var replacement = NewRow(teamId, workflowId, userId, live: true); + await SeedAsync(retired); + await SeedAsync(replacement); + replacement.Id.ShouldNotBe(retired.Id); + using var scope = fixture.BeginScope(); + var rows = await scope.Resolve().WorkflowExternalTrigger.AsNoTracking().Where(t => t.WorkflowId == workflowId).ToArrayAsync(); + rows.Single(t => t.Id == retired.Id).DeletedDate.ShouldNotBeNull(); + rows.Single(t => t.Id == replacement.Id).DeletedDate.ShouldBeNull(); + } + + private async Task SeedWorkflowAsync(Guid teamId, Guid userId) + { + using var scope = fixture.BeginScope(); + var db = scope.Resolve(); + var id = Guid.NewGuid(); + var now = DateTimeOffset.UtcNow; + var workflow = new Workflow { Id = id, TeamId = teamId, Slug = "external-trigger-" + id.ToString("N")[..8], Name = "external-trigger", DefinitionJson = "{}", CreatedDate = now, CreatedBy = userId, LastModifiedDate = now, LastModifiedBy = userId }; + db.Workflow.Add(workflow); + await db.SaveChangesAsync(); + return workflow.Id; + } + + private async Task SeedAsync(WorkflowExternalTrigger row) + { + using var scope = fixture.BeginScope(); + scope.Resolve().WorkflowExternalTrigger.Add(row); + await scope.Resolve().SaveChangesAsync(); + } + + private static WorkflowExternalTrigger NewRow(Guid teamId, Guid workflowId, Guid userId, bool live) => new() + { + Id = Guid.NewGuid(), TeamId = teamId, WorkflowId = workflowId, NodeId = "notify", + KeyDigest = new string('a', 64), KeyCiphertext = "sealed", CreatedDate = DateTimeOffset.UtcNow, + CreatedBy = userId, LastModifiedDate = DateTimeOffset.UtcNow, LastModifiedBy = userId, + DeletedDate = live ? null : DateTimeOffset.UtcNow, + }; +} diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/WorkflowRoundTripFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/WorkflowRoundTripFlowTests.cs index 4042a2efe..ca83be858 100644 --- a/backend/tests/CodeSpace.IntegrationTests/Workflows/WorkflowRoundTripFlowTests.cs +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/WorkflowRoundTripFlowTests.cs @@ -459,6 +459,7 @@ public async Task GetByRef_returns_null_for_a_ref_that_matches_nothing(string id [InlineData("Node Manifests", "node-manifests-2")] // shadows api/workflows/node-manifests [InlineData("System Variables", "system-variables-2")] // shadows api/workflows/system-variables [InlineData("Decisions", "decisions-2")] // shadows api/workflows/decisions + [InlineData("External Triggers", "external-triggers-2")] // shadows api/workflows/external-triggers public async Task Create_forces_a_suffix_when_the_slug_would_shadow_a_literal_route(string name, string expectedSlug) { // A workflow slugged to a literal sibling GET route (runs / node-manifests / system-variables / decisions) diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/WorkflowSlugBackfillTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/WorkflowSlugBackfillTests.cs index bbdafcc58..58275d815 100644 --- a/backend/tests/CodeSpace.IntegrationTests/Workflows/WorkflowSlugBackfillTests.cs +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/WorkflowSlugBackfillTests.cs @@ -76,6 +76,25 @@ await db.Database.ExecuteSqlRawAsync( await tx.RollbackAsync(); } + [Fact] + public async Task Migration_0252_frees_an_existing_external_triggers_slug() + { + var (teamId, userId) = await WorkflowsTestSeed.SeedTeamAsync(_fixture); + Guid id; + using (var scope = _fixture.BeginScopeAs(userId, teamId, Roles.Admin)) + id = await scope.Resolve().Send(Create("temp")); + + using var verify = _fixture.BeginScope(); + var db = verify.Resolve(); + await using var tx = await db.Database.BeginTransactionAsync(); + await db.Database.ExecuteSqlRawAsync("UPDATE workflow SET slug = 'external-triggers' WHERE id = {0}", id); + + await db.Database.ExecuteSqlRawAsync(Migration0252); + + (await db.Workflow.AsNoTracking().SingleAsync(w => w.Id == id)).Slug.ShouldBe("external-triggers-2"); + await tx.RollbackAsync(); + } + private static CreateWorkflowCommand Create(string name) => new() { Name = name, @@ -114,4 +133,32 @@ OR EXISTS (SELECT 1 FROM workflow w UPDATE workflow SET slug = candidate WHERE id = r.id; END LOOP; END $$;"; + + private const string Migration0252 = @" +WITH reserved AS ( + SELECT id, team_id, slug + FROM workflow + WHERE slug = 'external-triggers' AND deleted_date IS NULL +), +candidates AS ( + SELECT r.id, r.team_id, r.slug, n + FROM reserved r + CROSS JOIN LATERAL generate_series(2, 1000) AS n + WHERE NOT EXISTS ( + SELECT 1 + FROM workflow w + WHERE w.team_id = r.team_id + AND w.slug = 'external-triggers-' || n::text + AND w.deleted_date IS NULL + ) +) +UPDATE workflow w +SET slug = 'external-triggers-' || c.n::text +FROM candidates c +WHERE w.id = c.id + AND c.n = ( + SELECT MIN(c2.n) + FROM candidates c2 + WHERE c2.id = c.id + );"; } diff --git a/backend/tests/CodeSpace.UnitTests/Architecture/FailureTaxonomyTests.cs b/backend/tests/CodeSpace.UnitTests/Architecture/FailureTaxonomyTests.cs index 484d5141c..20ac1bd7f 100644 --- a/backend/tests/CodeSpace.UnitTests/Architecture/FailureTaxonomyTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Architecture/FailureTaxonomyTests.cs @@ -175,6 +175,8 @@ public void Every_declared_code_is_reachable() // ProviderApiException reports provider_error, and only a real 401/403 from a provider // produces provider_unauthorized. A probe sees one side of a conditional Code, never both. .Concat(new[] { FailureCodes.Unauthorized, FailureCodes.NotFound, FailureCodes.DuplicateResource, FailureCodes.InvalidRequest, FailureCodes.Internal, FailureCodes.ProviderUnauthorized }) + // External-trigger outcomes are returned, not thrown; HTTP tests exercise these three producers. + .Concat(new[] { FailureCodes.ExternalTriggerBodyTooLarge, FailureCodes.ExternalTriggerCompletionRefused, FailureCodes.ExternalTriggerIdempotencyConflict }) .ToHashSet(StringComparer.Ordinal); var unreachable = DeclaredCodes().Where(c => !emitted.Contains(c)).OrderBy(c => c, StringComparer.Ordinal).ToList(); diff --git a/backend/tests/CodeSpace.UnitTests/Architecture/RequestAuthorizationInventoryTests.cs b/backend/tests/CodeSpace.UnitTests/Architecture/RequestAuthorizationInventoryTests.cs index 4360026ff..dff861763 100644 --- a/backend/tests/CodeSpace.UnitTests/Architecture/RequestAuthorizationInventoryTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Architecture/RequestAuthorizationInventoryTests.cs @@ -16,7 +16,7 @@ namespace CodeSpace.UnitTests.Architecture; /// /// Why pin it: the marker is what selects a request into an authorization behavior. A request /// without one is not "authorized by default" — it is not authorized at all, and it looks exactly -/// like every other request in the diff. Today the unmarked set is a deliberate, auditable 21; this +/// like every other request in the diff. Today the unmarked set is a deliberate, auditable 47; this /// test makes the 22nd a build failure rather than a discovery. /// /// The two allow-lists are kept SEPARATE on purpose. Adding a sweep is routine; adding an @@ -35,6 +35,8 @@ public class RequestAuthorizationInventoryTests ["SignInCommand"] = "the call that mints the JWT; proof is the password.", ["ReceiveWebhookCommand"] = "provider-signed inbound; proof is the HMAC verified against the per-webhook secret.", ["ReceiveConnectionWebhookCommand"] = "the same inbound, from a group/organization hook; proof is the HMAC verified against THAT hook's own secret, never a repository's.", + ["ReceiveExternalTriggerCommand"] = "an anonymous external trigger delivery; proof is the per-node key, checked against the trigger row's key digest.", + ["VerifyExternalTriggerQuery"] = "the anonymous identity check before an external trigger body is read; proof is the same per-node key, checked against the trigger row's key digest.", ["ResumeWorkflowCallbackCommand"] = "proof is the single-use high-entropy callback token in the URL.", ["WebhookExistsQuery"] = "asked before a delivery's body is read; it answers only whether the hook id names a hook, which the delivery's own 404 already tells the caller.", ["CompleteCredentialOAuthCommand"] = "the provider redirect carries no JWT; proof is the one-time OAuthPendingState row together with the nonce cookie only the starting browser holds. Without it nothing is consumed: the callback only hands the code to the SPA's completion page, where CompleteCredentialOAuthAsInitiatorCommand requires the signed-in starter.", diff --git a/backend/tests/CodeSpace.UnitTests/Architecture/ScopedTransactionInventoryTests.cs b/backend/tests/CodeSpace.UnitTests/Architecture/ScopedTransactionInventoryTests.cs index 727539610..4b2082882 100644 --- a/backend/tests/CodeSpace.UnitTests/Architecture/ScopedTransactionInventoryTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Architecture/ScopedTransactionInventoryTests.cs @@ -43,6 +43,8 @@ public class ScopedTransactionInventoryTests "Agents/AgentRunSpoolReaper.cs", "Workflows/Budget/BudgetLedger.cs", "Workflows/Engine/RunGenerationFence.cs", + "Workflows/ExternalTriggers/ExternalTriggerIngress.cs", + "Workflows/ExternalTriggers/ExternalTriggerStore.cs", "Workflows/ModelCalls/WorkflowRunModelCallProjector.cs", "Workflows/ToolCalls/WorkflowRunToolCallProjector.cs", "Workflows/WorkflowService.cs", diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/BuiltinPluginModuleTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/BuiltinPluginModuleTests.cs index d480ad2a1..d4d43d3cd 100644 --- a/backend/tests/CodeSpace.UnitTests/Workflows/BuiltinPluginModuleTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Workflows/BuiltinPluginModuleTests.cs @@ -20,6 +20,7 @@ public void Core_flow_plugin_lists_terminal_branch_and_iteration_primitives() module.Name.ShouldBe("Core Flow"); module.Nodes.ShouldContain(typeof(CodeSpace.Core.Services.Workflows.Nodes.Builtin.TriggerManualNode)); + module.Nodes.ShouldContain(typeof(CodeSpace.Core.Services.Workflows.Nodes.Builtin.TriggerExternalNode)); module.Nodes.ShouldContain(typeof(CodeSpace.Core.Services.Workflows.Nodes.Builtin.TriggerScheduleNode)); module.Nodes.ShouldContain(typeof(CodeSpace.Core.Services.Workflows.Nodes.Builtin.TerminalNode)); module.Nodes.ShouldContain(typeof(CodeSpace.Core.Services.Workflows.Nodes.Builtin.LogicIfNode)); @@ -37,7 +38,7 @@ public void Core_flow_plugin_lists_terminal_branch_and_iteration_primitives() module.Nodes.ShouldContain(typeof(CodeSpace.Core.Services.Workflows.Nodes.Builtin.FlowTryStartNode)); module.Nodes.ShouldContain(typeof(CodeSpace.Core.Services.Workflows.Nodes.Builtin.FlowMapNode)); module.Nodes.ShouldContain(typeof(CodeSpace.Core.Services.Workflows.Nodes.Builtin.FlowMapStartNode)); - module.Nodes.Count.ShouldBe(18); + module.Nodes.Count.ShouldBe(19); module.RunSourceMatchers.ShouldBeEmpty("manual + schedule triggers subscribe to no inbound event (schedule is producer-driven), so Core Flow still ships zero matchers"); } @@ -122,6 +123,6 @@ public void All_builtin_plugins_together_cover_every_builtin_node() }; var total = all.SelectMany(p => p.Nodes).Distinct().Count(); - total.ShouldBe(43, "43 builtin node types across 6 domain plugins — incl. the Git plugin's git.integrate_run run-sourced integrate (P4) — adjust this number when adding a builtin"); + total.ShouldBe(44, "44 builtin node types across 6 domain plugins — adjust this number when adding a builtin"); } } diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerIdempotencyTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerIdempotencyTests.cs new file mode 100644 index 000000000..0772d4086 --- /dev/null +++ b/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerIdempotencyTests.cs @@ -0,0 +1,35 @@ +using CodeSpace.Core.Services.Workflows.ExternalTriggers; +using Shouldly; + +namespace CodeSpace.UnitTests.Workflows; + +[Trait("Category", "Unit")] +public class ExternalTriggerIdempotencyTests +{ + [Theory] + [InlineData("abc-123_XYZ.~")] + public void A_well_formed_key_passes(string value) => ExternalTriggerIdempotency.IsWellFormed(value).ShouldBeTrue(); + + [Theory] + [InlineData("has space")] + [InlineData("has/slash")] + [InlineData("\u4e2d\u6587")] + [InlineData("")] + [InlineData(null)] + public void A_key_outside_the_charset_or_absent_is_refused(string? value) => ExternalTriggerIdempotency.IsWellFormed(value).ShouldBeFalse(); + + [Fact] + public void An_over_long_key_is_refused() => ExternalTriggerIdempotency.IsWellFormed(new string('a', 201)).ShouldBeFalse(); + + [Fact] + public void A_maximum_length_key_passes() => ExternalTriggerIdempotency.IsWellFormed(new string('a', 200)).ShouldBeTrue(); + + [Fact] + public void The_key_scopes_to_the_trigger_so_two_triggers_never_share_it() + { + var a = ExternalTriggerIdempotency.KeyFor("evt-1", Guid.Parse("11111111-1111-1111-1111-111111111111")); + var b = ExternalTriggerIdempotency.KeyFor("evt-1", Guid.Parse("22222222-2222-2222-2222-222222222222")); + a.ShouldNotBe(b); + a.ShouldBe("trigger.external:evt-1:11111111111111111111111111111111"); + } +} diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerInputProjectionTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerInputProjectionTests.cs new file mode 100644 index 000000000..31c8ac418 --- /dev/null +++ b/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerInputProjectionTests.cs @@ -0,0 +1,105 @@ +using System.Text.Json; +using CodeSpace.Core.Services.Workflows.ExternalTriggers; +using CodeSpace.Messages.Dtos.Workflows; +using Shouldly; + +namespace CodeSpace.UnitTests.Workflows; + +[Trait("Category", "Unit")] +public sealed class ExternalTriggerInputProjectionTests +{ + [Fact] + public void The_declared_schema_becomes_the_workflow_inputs() + { + var inputs = ExternalTriggerInputProjection.Apply([], DefinitionWith(""" + { "slug": "a", "inputSchema": { + "type": "object", + "properties": { + "service": { "type": "string", "title": "Service", "description": "Which service" }, + "count": { "type": "integer", "default": 1 } + }, + "required": ["service"] } } + """)); + + inputs.Select(i => i.Name).ShouldBe(["service", "count"]); + var service = inputs[0]; + service.Label.ShouldBe("Service"); + service.Description.ShouldBe("Which service"); + service.Required.ShouldBeTrue(); + service.Default.ShouldBeNull(); + service.Schema.GetProperty("type").GetString().ShouldBe("string"); + service.Schema.GetProperty("title").GetString().ShouldBe("Service"); + service.Schema.GetProperty("description").GetString().ShouldBe("Which service"); + inputs[1].Required.ShouldBeFalse(); + inputs[1].Label.ShouldBeNull(); + inputs[1].Description.ShouldBeNull(); + inputs[1].Default!.Value.GetInt32().ShouldBe(1); + } + + [Fact] + public void A_workflow_without_an_external_node_keeps_its_inputs_as_declared() + { + var declared = new[] { new WorkflowVariable { Name = "x", Schema = Json("""{"type":"string"}""") } }; + var definition = new WorkflowDefinition { Nodes = [], Edges = [] }; + + ExternalTriggerInputProjection.Apply(declared, definition).ShouldBeSameAs(declared); + } + + [Theory] + [InlineData("""{ "slug": "a" }""")] + [InlineData("""{ "inputSchema": null }""")] + [InlineData("""{ "inputSchema": [] }""")] + [InlineData("""{ "inputSchema": { "type": "object" } }""")] + [InlineData("""{ "inputSchema": { "properties": [] } }""")] + public void A_node_without_object_properties_declares_nothing(string config) + { + var declared = new[] { new WorkflowVariable { Name = "stale", Schema = Json("""{"type":"string"}""") } }; + + ExternalTriggerInputProjection.Apply(declared, DefinitionWith(config)).ShouldBeEmpty(); + } + + [Fact] + public void Properties_with_boolean_or_untyped_schemas_are_projected_as_inputs() + { + var inputs = ExternalTriggerInputProjection.Apply([], DefinitionWith( + """{ "inputSchema": { "type": "object", "properties": { "allowed": true, "blocked": false, "untyped": { } } } }""")); + + inputs.Select(input => input.Name).ShouldBe(["allowed", "blocked", "untyped"]); + inputs[0].Schema.ValueKind.ShouldBe(JsonValueKind.True); + inputs[1].Schema.ValueKind.ShouldBe(JsonValueKind.False); + inputs[2].Schema.ValueKind.ShouldBe(JsonValueKind.Object); + } + + [Fact] + public void The_node_schema_replaces_stale_inputs_and_preserves_the_full_property_schema() + { + var declared = new[] { new WorkflowVariable { Name = "stale", Schema = Json("""{"type":"string"}""") } }; + var definition = DefinitionWith(""" + { "inputSchema": { "properties": { + "Service_ID": { "type": "array", "items": { "type": "string", "enum": ["api", "worker"] }, "default": ["api"] } + }, "required": ["service_id"] } } + """); + + var inputs = ExternalTriggerInputProjection.Apply(declared, definition); + + inputs.Count.ShouldBe(1); + inputs[0].Name.ShouldBe("Service_ID"); + inputs[0].Required.ShouldBeFalse(); + inputs[0].Schema.GetProperty("items").GetProperty("enum").EnumerateArray() + .Select(v => v.GetString()).ShouldBe(["api", "worker"]); + inputs[0].Default!.Value[0].GetString().ShouldBe("api"); + definition.Inputs.ShouldBeEmpty(); + } + + private static WorkflowDefinition DefinitionWith(string configJson) => new() + { + Nodes = [new NodeDefinition { Id = "ext", TypeKey = "trigger.external", Config = Json(configJson) }], + Edges = [], + }; + + private static JsonElement Json(string json) + { + using var document = JsonDocument.Parse(json); + return document.RootElement.Clone(); + } +} diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerJsonEqualityTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerJsonEqualityTests.cs new file mode 100644 index 000000000..da4398ab2 --- /dev/null +++ b/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerJsonEqualityTests.cs @@ -0,0 +1,38 @@ +using System.Text.Json; +using CodeSpace.Core.Services.Workflows.ExternalTriggers; +using Shouldly; + +namespace CodeSpace.UnitTests.Workflows; + +[Trait("Category", "Unit")] +public sealed class ExternalTriggerJsonEqualityTests +{ + [Theory] + [InlineData("{\"a\":1,\"b\":\"x\"}", "{\"b\":\"\\u0078\",\"a\":1.0}", true)] + [InlineData("1", "1e0", true)] + [InlineData("1e-330", "0", false)] + [InlineData("9007199254740992.1", "9007199254740992", false)] + [InlineData("12345678901234567890.01", "1234567890123456789001e-2", true)] + [InlineData("[1,2]", "[2,1]", false)] + [InlineData("\"1\"", "1", false)] + [InlineData("\"A\"", "\"a\"", false)] + public void Json_content_is_compared_without_numeric_rounding(string left, string right, bool equivalent) + { + using var a = JsonDocument.Parse(left); + using var b = JsonDocument.Parse(right); + ExternalTriggerJsonEquality.AreEquivalent(a.RootElement, b.RootElement).ShouldBe(equivalent); + } + + [Fact] + public void Wide_object_member_scans_consume_the_comparison_budget() + { + var properties = string.Join(',', Enumerable.Range(0, 50).Select(i => $"\"p{i}\":{i}")); + using var left = JsonDocument.Parse("{" + properties + "}"); + using var right = JsonDocument.Parse("{" + properties + "}"); + + ExternalTriggerJsonEquality.AreEquivalent(left.RootElement, right.RootElement, 100, out var visited, out var exhausted) + .ShouldBeFalse(); + exhausted.ShouldBeTrue(); + visited.ShouldBe(100); + } +} diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerKeySealerTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerKeySealerTests.cs new file mode 100644 index 000000000..9e892c32c --- /dev/null +++ b/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerKeySealerTests.cs @@ -0,0 +1,43 @@ +using System.Security.Cryptography; +using System.Text; +using CodeSpace.Core.Services.Variables; +using CodeSpace.Core.Services.Workflows.ExternalTriggers; +using Shouldly; +using Xunit; + +namespace CodeSpace.UnitTests.Workflows; + +[Trait("Category", "Unit")] +public sealed class ExternalTriggerKeySealerTests +{ + [Fact] + public void A_minted_key_round_trips_through_its_own_digest_and_ciphertext() + { + var sealer = new ExternalTriggerKeySealer(new FixedEncryption()); + var minted = sealer.Mint(); + minted.Key.ShouldStartWith("ext_"); + minted.Key.Length.ShouldBe(47); + minted.Digest.ShouldBe(ExternalTriggerKeySealer.Digest(minted.Key)); + sealer.Open(minted.Ciphertext).ShouldBe(minted.Key); + } + + [Fact] + public void The_digest_is_lowercase_hex_sha256_of_the_key() + { + var key = new ExternalTriggerKeySealer(new FixedEncryption()).Mint().Key; + ExternalTriggerKeySealer.Digest(key).ShouldBe(Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(key)))); + } + + [Fact] + public void Two_mints_produce_different_keys() + { + var sealer = new ExternalTriggerKeySealer(new FixedEncryption()); + sealer.Mint().Key.ShouldNotBe(sealer.Mint().Key); + } + + private sealed class FixedEncryption : IVariableValueEncryption + { + public byte[] Encrypt(string plaintext) => Encoding.UTF8.GetBytes("sealed:" + plaintext); + public string Decrypt(byte[] envelope) => Encoding.UTF8.GetString(envelope)["sealed:".Length..]; + } +} diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerPayloadValidatorTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerPayloadValidatorTests.cs new file mode 100644 index 000000000..3af721c70 --- /dev/null +++ b/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerPayloadValidatorTests.cs @@ -0,0 +1,322 @@ +using System.Text.Json; +using CodeSpace.Core.Services.Workflows.ExternalTriggers; +using Shouldly; + +namespace CodeSpace.UnitTests.Workflows; + +[Trait("Category", "Unit")] +public class ExternalTriggerPayloadValidatorTests +{ + private const string Shape = """ + { "type": "object", + "properties": { "service": { "type": "string" }, "count": { "type": "integer" } }, + "required": ["service"], + "additionalProperties": false } + """; + + [Fact] + public void A_conforming_body_passes() + { + ExternalTriggerPayloadValidator.Validate( + Schema("""{ "service": "checkout", "count": 3 }"""), Schema(Shape)).ShouldBeEmpty(); + } + + [Theory] + [InlineData("{\"a\":1,\"a\":2}")] + [InlineData("{\"a\\u0000\":1}")] + [InlineData("{\"a\":\"\\u0000\"}")] + [InlineData("{\"\\uD800\":1}")] + [InlineData("{\"a\":1e-331}")] + [InlineData("{\"a\":0e331}")] + public void Unsafe_payloads_are_rejected_before_schema_checks(string body) => + ExternalTriggerPayloadValidator.Validate(Schema(body), Schema("{\"type\":\"object\"}")).ShouldNotBeEmpty(); + + [Fact] + public void Representability_and_schema_have_independent_node_budgets() + { + var body = "[" + string.Join(",", Enumerable.Repeat("1", 9999)) + "]"; + ExternalTriggerPayloadValidator.Validate(Schema(body), Schema("{\"type\":\"array\",\"items\":{\"type\":\"integer\"}}")).ShouldBeEmpty(); + } + + [Theory] + [InlineData("{\"type\":\"object\",\"required\":3}")] + [InlineData("{\"type\":\"object\",\"enum\":[]}")] + [InlineData("{\"type\":\"object\",\"properties\":[]}")] + [InlineData("{\"type\":\"object\",\"items\":7}")] + [InlineData("{\"type\":\"object\",\"additionalProperties\":7}")] + [InlineData("{\"type\":\"object\",\"maxLength\":-1}")] + [InlineData("{\"type\":\"object\",\"description\":1}")] + [InlineData("{\"type\":\"object\",\"type\":\"object\"}")] + [InlineData("[]")] + public void Malformed_schema_shapes_cannot_be_saved(string schema) => + ExternalTriggerPayloadValidator.ValidateSchema(Schema(schema)).ShouldNotBeEmpty(); + + [Fact] + public void Editor_annotations_are_accepted() => ExternalTriggerPayloadValidator.ValidateSchema(Schema( + "{\"type\":\"object\",\"properties\":{\"text\":{\"type\":\"string\",\"x-long\":true,\"x-hidden\":true,\"x-selector\":\"repository\"}}}")).ShouldBeEmpty(); + + [Theory] + [InlineData("{\"type\":\"object\",\"additionalProperties\":{\"type\":\"string\"}}")] + [InlineData("{\"type\":\"object\",\"properties\":{\"a\":false}}")] + public void Additional_properties_and_boolean_schemas_are_enforced(string schema) => + ExternalTriggerPayloadValidator.Validate(Schema("{\"a\":1}"), Schema(schema)).ShouldNotBeEmpty(); + + [Fact] + public void Numeric_enum_comparison_does_not_round_large_fractional_values() => + ExternalTriggerPayloadValidator.Validate(Schema("9007199254740992.1"), + Schema("{\"type\":\"number\",\"enum\":[9007199254740992]}")).ShouldNotBeEmpty(); + + [Theory] + [InlineData(256, true)] + [InlineData(257, false)] + public void Number_token_length_is_bounded_before_integer_validation(int length, bool valid) + { + var number = "1" + new string('0', length - 1); + (ExternalTriggerPayloadValidator.Validate(Schema(number), Schema("{\"type\":\"integer\"}")).Count == 0).ShouldBe(valid); + } + + [Theory] + [InlineData("1e-330", true)] + [InlineData("0e330", true)] + [InlineData("1e-331", false)] + [InlineData("0e331", false)] + [InlineData("1e000000000000000000000000000001", true)] + public void Explicit_exponent_values_have_a_bounded_range(string token, bool valid) => + (ExternalTriggerPayloadValidator.Validate(Schema(token), Schema("{\"type\":\"number\"}")).Count == 0).ShouldBe(valid); + + [Fact] + public void A_payload_over_the_node_budget_is_refused() + { + var body = "[" + string.Join(",", Enumerable.Repeat("1", 10_000)) + "]"; + ExternalTriggerPayloadValidator.ValidatePayload(Schema(body)).ShouldContain(e => e.Contains("too many nodes")); + } + + [Fact] + public void Schema_errors_are_also_bounded() + { + var properties = string.Join(",", Enumerable.Range(0, 100).Select(i => $"\"x{i}\":{{\"unsupported\":true}}")); + ExternalTriggerPayloadValidator.ValidateSchema(Schema($"{{\"type\":\"object\",\"properties\":{{{properties}}}}}")) + .Count.ShouldBeLessThanOrEqualTo(20); + } + + [Fact] + public void Enum_comparisons_have_a_bounded_total_work_budget() + { + const int enumSize = 6_000; + var values = string.Join(",", Enumerable.Range(0, enumSize)); + var payload = "[" + string.Join(",", Enumerable.Repeat("-1", enumSize)) + "]"; + var schema = "{\"type\":\"array\",\"items\":{\"type\":\"integer\",\"enum\":[" + values + "]}}"; + var errors = ExternalTriggerPayloadValidator.Validate(Schema(payload), Schema(schema)); + errors.ShouldContain(e => e.Contains("enum comparison budget")); + errors.Count.ShouldBeLessThanOrEqualTo(ExternalTriggerPayloadValidator.MaxErrors); + } + + [Fact] + public void A_missing_required_field_is_named() + { + var errors = ExternalTriggerPayloadValidator.Validate(Schema("""{ "count": 1 }"""), Schema(Shape)); + errors.ShouldContain(e => e.Contains("$.service") && e.Contains("required")); + } + + [Fact] + public void A_wrong_type_is_named() + { + var errors = ExternalTriggerPayloadValidator.Validate(Schema("""{ "service": 7 }"""), Schema(Shape)); + errors.ShouldContain(e => e.Contains("$.service")); + } + + [Fact] + public void A_large_fractional_number_is_not_an_integer() + { + var errors = ExternalTriggerPayloadValidator.Validate( + Schema("""{ "service": "a", "count": 9007199254740992.1 }"""), Schema(Shape)); + errors.ShouldContain(e => e.Contains("$.count")); + } + + [Theory] + [InlineData("5.0")] + [InlineData("1e2")] + public void An_integer_value_with_decimal_or_exponent_notation_is_accepted(string number) + { + ExternalTriggerPayloadValidator.Validate( + Schema($"{{ \"service\": \"a\", \"count\": {number} }}"), Schema(Shape)).ShouldBeEmpty(); + } + + [Fact] + public void An_extra_property_is_refused_when_the_schema_closes_the_object() + { + var errors = ExternalTriggerPayloadValidator.Validate( + Schema("""{ "service": "a", "unexpected": true }"""), Schema(Shape)); + errors.ShouldContain(e => e.Contains("$.unexpected")); + } + + [Fact] + public void An_extra_property_is_allowed_when_the_schema_leaves_it_open() + { + var errors = ExternalTriggerPayloadValidator.Validate( + Schema("""{ "service": "a", "extra": 1 }"""), + Schema("""{ "type": "object", "properties": { "service": { "type": "string" } } }""")); + errors.ShouldBeEmpty(); + } + + [Theory] + [InlineData("object", "{}", true)] + [InlineData("array", "[]", true)] + [InlineData("string", "\"text\"", true)] + [InlineData("integer", "3", true)] + [InlineData("integer", "3.5", false)] + [InlineData("integer", "1e-1", false)] + [InlineData("number", "3.5", true)] + [InlineData("boolean", "true", true)] + [InlineData("null", "null", true)] + public void Json_types_are_checked(string type, string json, bool isValid) + { + var errors = ExternalTriggerPayloadValidator.Validate(Schema(json), Schema($"{{ \"type\": \"{type}\" }}")); + (errors.Count == 0).ShouldBe(isValid); + } + + [Fact] + public void An_enum_value_outside_the_contract_is_refused() + { + var errors = ExternalTriggerPayloadValidator.Validate( + Schema("\"paused\""), Schema("""{ "type": "string", "enum": ["active", "disabled"] }""")); + errors.ShouldContain(e => e.Contains("$")); + } + + [Fact] + public void A_string_over_max_length_is_refused() + { + var errors = ExternalTriggerPayloadValidator.Validate( + Schema("\"long\""), Schema("""{ "type": "string", "maxLength": 3 }""")); + errors.ShouldContain(e => e.Contains("$")); + } + + [Fact] + public void Array_items_are_validated_with_indexed_paths() + { + var errors = ExternalTriggerPayloadValidator.Validate( + Schema("[\"ok\", 7]"), Schema("""{ "type": "array", "items": { "type": "string" } }""")); + errors.ShouldContain(e => e.Contains("$[1]")); + } + + [Theory] + [InlineData("\"text\"", true)] + [InlineData("7", true)] + [InlineData("true", true)] + [InlineData("null", false)] + [InlineData("{}", false)] + public void Array_items_accept_a_union_of_json_types(string json, bool isValid) + { + var errors = ExternalTriggerPayloadValidator.Validate( + Schema($"[{json}]"), + Schema("""{ "type": "array", "items": { "type": ["string", "number", "boolean"] } }""")); + (errors.Count == 0).ShouldBe(isValid); + } + + [Fact] + public void An_empty_type_union_is_refused_with_a_usable_error() + { + var errors = ExternalTriggerPayloadValidator.Validate( + Schema("\"text\""), + Schema("""{ "type": [] }""")); + errors.ShouldHaveSingleItem().ShouldContain("the schema declares none"); + } + + [Fact] + public void An_unknown_type_keyword_is_refused_instead_of_silently_matching() + { + var errors = ExternalTriggerPayloadValidator.ValidateSchema(Schema("""{ "type": "sting" }""")); + errors.ShouldContain(e => e.Contains("sting") && e.Contains("not supported")); + } + + [Fact] + public void Validation_stops_after_a_bounded_number_of_errors() + { + var errors = ExternalTriggerPayloadValidator.Validate( + Schema("[" + string.Join(",", Enumerable.Repeat("1", 100)) + "]"), + Schema("""{ "type": "array", "items": { "type": "string" } }""")); + errors.Count.ShouldBe(ExternalTriggerPayloadValidator.MaxErrors); + } + + [Fact] + public void An_excessively_long_integer_exponent_is_refused_without_parsing_it() + { + var exponent = new string('9', 100_000); + var errors = ExternalTriggerPayloadValidator.Validate( + Schema("1e" + exponent), + Schema("""{ "type": "integer" }""")); + errors.ShouldContain(e => e.Contains("number") && e.Contains("too long")); + } + + [Fact] + public void A_number_postgres_cannot_represent_is_refused() + { + var errors = ExternalTriggerPayloadValidator.Validate( + Schema("1e200000"), + Schema("""{ "type": "object" }""")); + errors.ShouldContain(e => e.Contains("not representable")); + } + + [Fact] + public void An_unpaired_surrogate_is_refused_without_reading_an_invalid_string() + { + var errors = ExternalTriggerPayloadValidator.Validate( + Schema("\"\\uD800\""), + Schema("""{ "type": "string", "maxLength": 10 }""")); + errors.ShouldContain(e => e.Contains("not representable")); + } + + [Fact] + public void An_unpaired_surrogate_does_not_turn_enum_validation_into_a_server_error() + { + var errors = ExternalTriggerPayloadValidator.Validate( + Schema("\"\\uD800\""), + Schema("""{ "type": "string", "enum": ["ok"] }""")); + errors.ShouldContain(e => e.Contains("not representable")); + } + + [Fact] + public void An_unpaired_surrogate_nested_in_an_enum_value_does_not_escape_as_a_server_error() + { + var errors = ExternalTriggerPayloadValidator.Validate( + Schema("""[{ "text": "\uD800" }]"""), + Schema("""{ "type": "array", "items": { "type": "object", "enum": [{ "text": "ok" }] } }""")); + errors.ShouldContain(e => e.Contains("not representable")); + } + + [Theory] + [InlineData("""{ "type": "object", "properties": { "x": { "pattern": "^a" } } }""")] + [InlineData("""{ "type": "object", "properties": { "x": { "oneOf": [{ "type": "string" }] } } }""")] + [InlineData("""{ "type": "object", "properties": { "x": { "$ref": "#/$defs/x" } } }""")] + [InlineData("""{ "type": "object", "additionalProperties": { "type": "string", "pattern": "^a" } }""")] + public void Unsupported_schema_keywords_are_refused_at_save_validation(string schema) + { + var errors = ExternalTriggerPayloadValidator.ValidateSchema(Schema(schema)); + errors.ShouldContain(e => e.Contains("not supported")); + } + + [Fact] + public void A_schema_deeper_than_the_limit_is_refused() + { + var schema = """{ "type": "object", "properties": { "x": """ + + string.Concat(Enumerable.Repeat("""{ "type": "array", "items": """, ExternalTriggerPayloadValidator.MaxSchemaDepth + 1)) + + """{ "type": "string" }""" + + string.Concat(Enumerable.Repeat(" }", ExternalTriggerPayloadValidator.MaxSchemaDepth + 1)) + + """ } }"""; + ExternalTriggerPayloadValidator.ValidateSchema(Schema(schema, maxDepth: 256)).ShouldContain(e => e.Contains("too deep")); + } + + [Fact] + public void A_payload_deeper_than_the_limit_is_refused_before_representability_recursion() + { + var body = string.Concat(Enumerable.Repeat("[", ExternalTriggerPayloadValidator.MaxSchemaDepth + 2)) + + string.Concat(Enumerable.Repeat("]", ExternalTriggerPayloadValidator.MaxSchemaDepth + 2)); + var errors = ExternalTriggerPayloadValidator.Validate( + Schema(body, maxDepth: 256), + Schema("""{ "type": "array" }""")); + errors.ShouldContain(e => e.Contains("too deep")); + } + + private static JsonElement Schema(string json, int maxDepth = 64) => + JsonDocument.Parse(json, new JsonDocumentOptions { MaxDepth = maxDepth }).RootElement.Clone(); +} diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerRunAdmissionTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerRunAdmissionTests.cs new file mode 100644 index 000000000..a0aa232ef --- /dev/null +++ b/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerRunAdmissionTests.cs @@ -0,0 +1,47 @@ +using CodeSpace.Core.Services.Workflows.ExternalTriggers; +using Shouldly; + +namespace CodeSpace.UnitTests.Workflows; + +[Trait("Category", "Unit")] +public sealed class ExternalTriggerRunAdmissionTests +{ + [Fact] + public void Only_committed_reservations_consume_the_window() + { + using var admission = new ExternalTriggerRunAdmission(); + var id = Guid.NewGuid(); + for (var i = 0; i < 100; i++) + { + using var released = admission.Reserve(id); + released.ShouldNotBeNull(); + } + for (var i = 0; i < 60; i++) + { + using var committed = admission.Reserve(id); + committed.ShouldNotBeNull(); + committed.Commit(); + } + admission.Reserve(id).ShouldBeNull(); + admission.Reserve(Guid.NewGuid()).ShouldNotBeNull(); + } + + [Fact] + public void A_late_release_cannot_refund_a_different_window() + { + var clock = new Clock(); + using var admission = new ExternalTriggerRunAdmission(clock); + var id = Guid.NewGuid(); + using var old = admission.Reserve(id); + clock.Now += TimeSpan.FromMinutes(1); + for (var i = 0; i < 60; i++) admission.Reserve(id)!.Commit(); + old!.Release(); + admission.Reserve(id).ShouldBeNull(); + } + + private sealed class Clock : TimeProvider + { + public DateTimeOffset Now { get; set; } = DateTimeOffset.UnixEpoch; + public override DateTimeOffset GetUtcNow() => Now; + } +} diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/NodeManifestContractTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/NodeManifestContractTests.cs index 97b97ed88..45c3f5800 100644 --- a/backend/tests/CodeSpace.UnitTests/Workflows/NodeManifestContractTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Workflows/NodeManifestContractTests.cs @@ -27,6 +27,7 @@ public static IEnumerable AllPureNodes() yield return new object[] { new TriggerPushNode() }; yield return new object[] { new TriggerScheduleNode() }; yield return new object[] { new TriggerManualNode() }; + yield return new object[] { new TriggerExternalNode() }; yield return new object[] { new TerminalNode() }; } @@ -80,6 +81,7 @@ public void Trigger_nodes_declare_trigger_kind() new TriggerPushNode().Manifest.Kind.ShouldBe(NodeKind.Trigger); new TriggerScheduleNode().Manifest.Kind.ShouldBe(NodeKind.Trigger); new TriggerManualNode().Manifest.Kind.ShouldBe(NodeKind.Trigger); + new TriggerExternalNode().Manifest.Kind.ShouldBe(NodeKind.Trigger); } [Fact] @@ -103,6 +105,8 @@ public void Only_the_manual_trigger_declares_IsManual_true() "event triggers subscribe to webhooks and MUST keep IsManual=false so deriveActivations still emits their activation"); new TriggerScheduleNode().Manifest.IsManual.ShouldBeFalse( "the schedule trigger MUST keep IsManual=false so deriveActivations emits the activation row the producer queries"); + new TriggerExternalNode().Manifest.IsManual.ShouldBeFalse( + "anonymous runs must carry activation lineage, so deriveActivations MUST project this trigger's activation row"); } [Fact] @@ -130,6 +134,29 @@ public async Task Manual_trigger_echoes_run_payload_as_outputs() result.Outputs["ticket"].GetString().ShouldBe("ABC-123"); } + [Fact] + public async Task External_trigger_echoes_run_payload_as_outputs() + { + var payload = JsonDocument.Parse("""{"ticket":"ABC-123"}""").RootElement + .EnumerateObject().ToDictionary(p => p.Name, p => p.Value.Clone()); + + var context = new NodeRunContext + { + Inputs = new Dictionary(), + Config = new Dictionary(), + RawInputs = JsonDocument.Parse("{}").RootElement, + RawConfig = JsonDocument.Parse("{}").RootElement, + Scope = new NodeRunScope { Trigger = payload }, + Logger = NullLogger.Instance, + Observability = NodeObservability.NoOp, + }; + + var result = await new TriggerExternalNode().RunAsync(context, CancellationToken.None); + + result.Status.ShouldBe(NodeStatus.Success); + result.Outputs["ticket"].GetString().ShouldBe("ABC-123"); + } + [Fact] public void Terminal_node_declares_terminal_kind() { diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/TriggerExternalNodeTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/TriggerExternalNodeTests.cs new file mode 100644 index 000000000..2d434af92 --- /dev/null +++ b/backend/tests/CodeSpace.UnitTests/Workflows/TriggerExternalNodeTests.cs @@ -0,0 +1,16 @@ +using CodeSpace.Core.Services.Workflows.Nodes.Builtin; +using Shouldly; + +namespace CodeSpace.UnitTests.Workflows; + +[Trait("Category", "Unit")] +public class TriggerExternalNodeTests +{ + [Fact] + public void The_manifest_declares_an_input_schema_without_a_route_name() + { + var config = new TriggerExternalNode().Manifest.ConfigSchema; + config.GetProperty("properties").TryGetProperty("slug", out _).ShouldBeFalse(); + config.GetProperty("properties").TryGetProperty("inputSchema", out _).ShouldBeTrue(); + } +} diff --git a/frontend/src/api/workflows.ts b/frontend/src/api/workflows.ts index 2b5e6e52b..61b1d3eff 100644 --- a/frontend/src/api/workflows.ts +++ b/frontend/src/api/workflows.ts @@ -161,6 +161,20 @@ export interface WorkflowActivationInput { enabled: boolean; } +export interface WorkflowExternalTriggerSummary { + id: string; + workflowId: string; + nodeId: string; +} + +export interface WorkflowExternalTriggersResult { + triggers: WorkflowExternalTriggerSummary[]; +} + +export interface ExternalTriggerKeyResult { + key: string; +} + export interface CreateWorkflowInput { name: string; description?: string | null; @@ -1440,6 +1454,18 @@ function decodeRunDataCompleteness(value: unknown, expectedRunId: string): Workf export const workflowsApi = { list: () => fetchJson("/api/workflows"), + listExternalTriggers: (workflowId: string) => fetchJson( + `/api/workflows/external-triggers?${new URLSearchParams({ workflowId })}`, + ), + + revealExternalTriggerKey: (triggerId: string) => fetchJson( + `/api/workflows/external-triggers/${encodeURIComponent(triggerId)}/key`, + ), + + rotateExternalTriggerKey: (triggerId: string) => fetchJson( + `/api/workflows/external-triggers/${encodeURIComponent(triggerId)}/rotate-key`, { method: "POST" }, + ), + /** Resolve one workflow by ref — its GUID (legacy link) or team-unique slug (clean URL). */ get: (ref: string) => fetchJson(`/api/workflows/${encodeURIComponent(ref)}`), diff --git a/frontend/src/components/workflows/ModelCallsPanel.test.tsx b/frontend/src/components/workflows/ModelCallsPanel.test.tsx index 71feeb93a..05c6703d8 100644 --- a/frontend/src/components/workflows/ModelCallsPanel.test.tsx +++ b/frontend/src/components/workflows/ModelCallsPanel.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { workflowsApi, type WorkflowRunModelCallDetailMetadata, type WorkflowRunModelCallPage } from "@/api/workflows"; @@ -28,7 +28,10 @@ const page: WorkflowRunModelCallPage = { }], }; -afterEach(() => vi.restoreAllMocks()); +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); describe("ModelCallsPanel", () => { it("leaves the loading state when the run becomes inaccessible", async () => { @@ -71,6 +74,7 @@ describe("ModelCallsPanel", () => { }); it("keeps refreshing pending capture metadata after the workflow is terminal", async () => { + vi.useFakeTimers(); vi.spyOn(workflowsApi, "pageRunModelCalls").mockResolvedValue(page); const pending = { ...page.items[0], @@ -83,15 +87,15 @@ describe("ModelCallsPanel", () => { bodies: [{ ...pending.bodies[0], captureHealth: "Available" }], } as WorkflowRunModelCallDetailMetadata; const detailSpy = vi.spyOn(workflowsApi, "getRunModelCallById").mockResolvedValueOnce(pending).mockResolvedValueOnce(available); - vi.spyOn(window, "setInterval").mockImplementation((handler: TimerHandler) => { - queueMicrotask(() => { if (typeof handler === "function") handler(); }); - return 1; - }); render(); - fireEvent.click(await screen.findByRole("button", { name: /agent\.model-call/i })); - await screen.findByText("Stable call id"); - await waitFor(() => expect(detailSpy).toHaveBeenCalledTimes(2)); + await act(async () => { await vi.advanceTimersByTimeAsync(0); }); + fireEvent.click(screen.getByRole("button", { name: /agent\.model-call/i })); + await act(async () => { await vi.advanceTimersByTimeAsync(0); }); + expect(screen.getByText("Stable call id")).toBeTruthy(); + + await act(async () => { await vi.advanceTimersByTimeAsync(3_000); }); + expect(detailSpy).toHaveBeenCalledTimes(2); }); }); diff --git a/frontend/src/components/workflows/external-trigger-editor.tsx b/frontend/src/components/workflows/external-trigger-editor.tsx new file mode 100644 index 000000000..2d9360674 --- /dev/null +++ b/frontend/src/components/workflows/external-trigger-editor.tsx @@ -0,0 +1,25 @@ +import { useWorkflowExternalTriggers } from "@/hooks/use-workflows"; + +import { ExternalTriggerInspector } from "./external-trigger-inspector"; + +interface IExternalTriggerEditorProps { + workflowId: string; + nodeId: string; + config: Record; + onConfigChange: (next: Record) => void; +} + +export function ExternalTriggerEditor({ workflowId, nodeId, config, onConfigChange }: IExternalTriggerEditorProps) { + const triggers = useWorkflowExternalTriggers(workflowId); + + if (triggers.isPending) return
Loading endpoint...
; + if (triggers.isError) return ( +
+

{triggers.error.message}

+ +
+ ); + + const currentTriggerId = triggers.data.triggers.find((trigger) => trigger.nodeId === nodeId)?.id ?? null; + return ; +} diff --git a/frontend/src/components/workflows/external-trigger-inspector.test.tsx b/frontend/src/components/workflows/external-trigger-inspector.test.tsx new file mode 100644 index 000000000..4ec9c02c3 --- /dev/null +++ b/frontend/src/components/workflows/external-trigger-inspector.test.tsx @@ -0,0 +1,172 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, fireEvent, render, renderHook, screen, waitFor, within } from "@testing-library/react"; +import { useState } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ExternalTriggerInspector } from "./external-trigger-inspector"; +import { ExternalTriggerEditor } from "./external-trigger-editor"; +import { useUpdateWorkflow, useWorkflowExternalTriggers } from "@/hooks/use-workflows"; + +const REQUESTS = vi.fn(); +const CONFIG_CHANGE = vi.fn(); +const SUMMARY = { id: "56da5193-d2a0-4309-9ed7-eef395d72124", workflowId: "workflow-1", nodeId: "ext" }; + +function renderInspector(currentTriggerId: string | null = "56da5193-d2a0-4309-9ed7-eef395d72124", slug = "56da5193-d2a0-4309-9ed7-eef395d72124") { + const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + function Inspector() { + const [config, setConfig] = useState>({ slug, inputSchema: {} }); + return { CONFIG_CHANGE(next); setConfig(next); }} />; + } + return render(); +} + +beforeEach(() => { + CONFIG_CHANGE.mockReset(); + REQUESTS.mockReset(); + REQUESTS.mockImplementation(async (_url, init) => new Response(init?.method === "POST" && String(_url).endsWith("/rename") ? null : JSON.stringify({ key: "ext_secret" }), { status: init?.method === "POST" && String(_url).endsWith("/rename") ? 204 : 200 })); + vi.stubGlobal("fetch", REQUESTS); +}); + +afterEach(() => vi.unstubAllGlobals()); + +describe("ExternalTriggerInspector", () => { + it("preserves a boolean-false property when another property's label changes", () => { + const client = new QueryClient(); + const config = { slug: "56da5193-d2a0-4309-9ed7-eef395d72124", inputSchema: { properties: { blocked: false, message: { type: "string" } } } }; + render(); + fireEvent.click(screen.getByText("message")); + fireEvent.change(screen.getByPlaceholderText("message"), { target: { value: "Alert message" } }); + fireEvent.click(screen.getByRole("button", { name: /^save$/i })); + expect(CONFIG_CHANGE).toHaveBeenLastCalledWith({ slug: "56da5193-d2a0-4309-9ed7-eef395d72124", inputSchema: { type: "object", properties: { blocked: false, message: { type: "string", title: "Alert message" } }, required: [] } }); + expect(CONFIG_CHANGE.mock.lastCall?.[0].inputSchema.properties.blocked).toBe(false); + }); + + it("preserves a boolean constraint and warns when its display label cannot be applied", () => { + const client = new QueryClient(); + const config = { slug: "56da5193-d2a0-4309-9ed7-eef395d72124", inputSchema: { properties: { blocked: false } } }; + render(); + fireEvent.click(screen.getByText("blocked")); + fireEvent.change(screen.getByPlaceholderText("blocked"), { target: { value: "Blocked field" } }); + fireEvent.click(screen.getByRole("button", { name: /^save$/i })); + expect(CONFIG_CHANGE.mock.lastCall?.[0].inputSchema.properties.blocked).toBe(false); + expect(screen.getByRole("alert")).toHaveTextContent(/blocked.*label.*default.*could not be applied.*constraint.*preserved/i); + }); + + it("shows the keyless canonical endpoint and only copies a key-bearing curl after reveal", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } }); + renderInspector(); + expect(screen.getByText("/api/ext/56da5193-d2a0-4309-9ed7-eef395d72124/run")).toBeInTheDocument(); + expect(REQUESTS).not.toHaveBeenCalled(); + expect(screen.queryByDisplayValue(/^ext_/)).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /show key/i })); + expect(await screen.findByDisplayValue("ext_secret")).toBeInTheDocument(); + expect(REQUESTS).toHaveBeenCalledWith(expect.stringContaining("/56da5193-d2a0-4309-9ed7-eef395d72124/key"), expect.anything()); + fireEvent.click(screen.getByRole("button", { name: /copy key/i })); + await waitFor(() => expect(writeText).toHaveBeenCalledWith("ext_secret")); + fireEvent.click(screen.getByRole("button", { name: /copy curl/i })); + await waitFor(() => expect(writeText).toHaveBeenCalledWith(expect.stringContaining("/api/ext/56da5193-d2a0-4309-9ed7-eef395d72124/run"))); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining("curl -X POST")); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining("Authorization: Bearer ext_secret")); + expect(writeText).not.toHaveBeenCalledWith(expect.stringContaining("?key=")); + }); + + it("requires confirmation before rotating and warns that the old key stops working immediately", async () => { + renderInspector(); + fireEvent.click(screen.getByRole("button", { name: /regenerate/i })); + const group = screen.getByRole("group", { name: /confirm.*regenerate/i }); + expect(within(group).getByText(/old key stops working immediately/i)).toBeInTheDocument(); + expect(REQUESTS).not.toHaveBeenCalled(); + fireEvent.click(within(group).getByRole("button", { name: /cancel/i })); + expect(REQUESTS).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: /regenerate/i })); + fireEvent.click(within(screen.getByRole("group")).getByRole("button", { name: /confirm/i })); + expect(await screen.findByDisplayValue("ext_secret")).toBeInTheDocument(); + expect(REQUESTS).toHaveBeenCalledWith(expect.stringContaining("/56da5193-d2a0-4309-9ed7-eef395d72124/rotate-key"), expect.objectContaining({ method: "POST" })); + }); + + it("uses the server identity without offering route edits", () => { + renderInspector("56da5193-d2a0-4309-9ed7-eef395d72124", "New Alerts"); + expect(screen.getByText("/api/ext/56da5193-d2a0-4309-9ed7-eef395d72124/run")).toBeInTheDocument(); + expect(screen.queryByRole("textbox", { name: /slug/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /^rename/i })).not.toBeInTheDocument(); + expect(REQUESTS).not.toHaveBeenCalled(); + }); + + it("shows a save-first state without any live credential actions", () => { + renderInspector(null); + expect(screen.getByText(/save the workflow to create this endpoint/i)).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /show key|regenerate|rename/i })).not.toBeInTheDocument(); + expect(screen.queryByText("/api/ext/56da5193-d2a0-4309-9ed7-eef395d72124/run")).not.toBeInTheDocument(); + }); + + it("reports management errors without showing a key", async () => { + REQUESTS.mockResolvedValue(new Response(JSON.stringify({ code: "forbidden", message: "Permission denied" }), { status: 403 })); + renderInspector(); + fireEvent.click(screen.getByRole("button", { name: /show key/i })); + expect(await screen.findByRole("alert")).toHaveTextContent("Permission denied"); + expect(screen.queryByDisplayValue(/^ext_/)).not.toBeInTheDocument(); + }); + + it("uses the existing field modal to compile an input JSON Schema", async () => { + renderInspector(); + fireEvent.click(screen.getByTitle("Add field")); + fireEvent.change(screen.getByPlaceholderText("e.g. start_time"), { target: { value: "message" } }); + fireEvent.click(screen.getByRole("button", { name: /^save$/i })); + await waitFor(() => expect(CONFIG_CHANGE).toHaveBeenCalledWith(expect.objectContaining({ inputSchema: { type: "object", properties: { message: { type: "string" } }, required: ["message"] } }))); + }); + + it("preserves schema constraints and can clear the display name while editing", async () => { + const client = new QueryClient(); + const config = { slug: "56da5193-d2a0-4309-9ed7-eef395d72124", inputSchema: { type: "object", additionalProperties: false, properties: { message: { type: "string", title: "Alert message", pattern: "^[a-z]+$", description: "Alert text" } }, required: ["message"] } }; + render(); + fireEvent.click(screen.getByTitle("Edit field")); + fireEvent.change(screen.getByPlaceholderText("message"), { target: { value: "" } }); + fireEvent.click(screen.getByRole("button", { name: /^save$/i })); + await waitFor(() => expect(CONFIG_CHANGE).toHaveBeenCalledWith({ slug: "56da5193-d2a0-4309-9ed7-eef395d72124", inputSchema: { type: "object", additionalProperties: false, properties: { message: { type: "string", pattern: "^[a-z]+$", description: "Alert text" } }, required: ["message"] } })); + }); +}); + +describe("ExternalTriggerEditor live metadata", () => { + it("reads the live UUID by nodeId and never uses the draft as identity", async () => { + REQUESTS.mockResolvedValue(new Response(JSON.stringify({ triggers: [{ ...SUMMARY, id: "other", nodeId: "other" }, SUMMARY] }), { status: 200 })); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render(); + expect(await screen.findByText("/api/ext/56da5193-d2a0-4309-9ed7-eef395d72124/run")).toBeInTheDocument(); + expect(REQUESTS).toHaveBeenCalledWith(expect.stringContaining("/api/workflows/external-triggers?workflowId=workflow-1"), expect.anything()); + expect(screen.queryByText("/api/ext/draft/run")).not.toBeInTheDocument(); + }); + + it("treats an empty list as unsaved, not as a draft-backed endpoint", async () => { + REQUESTS.mockResolvedValue(new Response(JSON.stringify({ triggers: [] }), { status: 200 })); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render(); + expect(await screen.findByText(/save the workflow to create this endpoint/i)).toBeInTheDocument(); + }); + + it("does not mislabel a list error as an unsaved endpoint and permits retry", async () => { + REQUESTS.mockResolvedValueOnce(new Response(JSON.stringify({ message: "Workflow not found" }), { status: 404 })); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render(); + expect(await screen.findByRole("alert")).toHaveTextContent("Workflow not found"); + expect(screen.queryByText(/save the workflow/i)).not.toBeInTheDocument(); + REQUESTS.mockResolvedValue(new Response(JSON.stringify({ triggers: [SUMMARY] }), { status: 200 })); + fireEvent.click(screen.getByRole("button", { name: /retry/i })); + expect(await screen.findByText("/api/ext/56da5193-d2a0-4309-9ed7-eef395d72124/run")).toBeInTheDocument(); + }); + + it("refreshes live metadata after saving a newly added node", async () => { + let saved = false; + REQUESTS.mockImplementation(async (_url, init) => { + if (init?.method === "PUT") { saved = true; return new Response(null, { status: 204 }); } + return new Response(JSON.stringify({ triggers: saved ? [SUMMARY] : [] }), { status: 200 }); + }); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const { result } = renderHook(() => ({ list: useWorkflowExternalTriggers("workflow-1"), update: useUpdateWorkflow() }), { + wrapper: ({ children }) => {children}, + }); + await waitFor(() => expect(result.current.list.data).toEqual({ triggers: [] })); + await act(async () => result.current.update.mutateAsync({ workflowId: "workflow-1", input: { name: "Workflow", definition: { schemaVersion: 1, nodes: [], edges: [] }, activations: [] } })); + await waitFor(() => expect(result.current.list.data).toEqual({ triggers: [SUMMARY] })); + }); +}); diff --git a/frontend/src/components/workflows/external-trigger-inspector.tsx b/frontend/src/components/workflows/external-trigger-inspector.tsx new file mode 100644 index 000000000..7a600c14a --- /dev/null +++ b/frontend/src/components/workflows/external-trigger-inspector.tsx @@ -0,0 +1,131 @@ +import { useState } from "react"; + +import { Ic } from "@/_imported/ai-code-space/icons"; +import type { WorkflowVariable } from "@/api/workflows"; +import { useRevealExternalTriggerKey, useRotateExternalTriggerKey } from "@/hooks/use-workflows"; +import { externalTriggerUrl } from "@/lib/external-trigger-url"; + +import { StartNodeInputsEditor } from "./StartNodeInputsEditor"; + +interface IExternalTriggerInspectorProps { + config: Record; + currentTriggerId: string | null; + onConfigChange: (next: Record) => void; +} + +function asObject(value: unknown): Record { + return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} + +export function ExternalTriggerInspector({ config, currentTriggerId, onConfigChange }: IExternalTriggerInspectorProps) { + const reveal = useRevealExternalTriggerKey(); + const rotate = useRotateExternalTriggerKey(); + const [confirming, setConfirming] = useState(false); + const [key, setKey] = useState(null); + const [copyStatus, setCopyStatus] = useState(null); + const [copyError, setCopyError] = useState(null); + const [schemaWarning, setSchemaWarning] = useState(null); + const schema = asObject(config.inputSchema); + const properties = asObject(schema.properties); + const required = Array.isArray(schema.required) ? schema.required : []; + const fields: WorkflowVariable[] = Object.entries(properties).map(([name, value]) => { + const property = asObject(value); + return { name, schema: value, label: typeof property.title === "string" ? property.title : undefined, default: property.default, required: required.includes(name) }; + }); + const pending = reveal.isPending || rotate.isPending; + const error = reveal.error ?? rotate.error; + const endpoint = currentTriggerId ? `/api/ext/${encodeURIComponent(currentTriggerId)}/run` : null; + const fullUrl = endpoint ? externalTriggerUrl(endpoint) : ""; + const curl = key ? `curl -X POST '${fullUrl}' -H 'Authorization: Bearer ${key}' -H 'Content-Type: application/json' -d '{}'` : null; + + const changeFields = (next: WorkflowVariable[]) => { + const unappliedMetadata: string[] = []; + const nextProperties = Object.fromEntries(next.map((field) => { + const hasMetadata = field.label != null || field.default !== undefined; + if (field.schema === null || typeof field.schema !== "object" || Array.isArray(field.schema)) { + if (hasMetadata) unappliedMetadata.push(field.name); + return [field.name, field.schema]; + } + if (!hasMetadata && !("title" in field.schema) && !("default" in field.schema)) return [field.name, field.schema]; + const property = { ...asObject(field.schema) }; + if (field.label != null) property.title = field.label; + else delete property.title; + if (field.default !== undefined) property.default = field.default; + else delete property.default; + return [field.name, property]; + })); + setSchemaWarning(unappliedMetadata.length > 0 + ? `${unappliedMetadata.join(", ")}: label/default could not be applied to a non-object schema. The original constraint was preserved.` + : null); + onConfigChange({ ...config, inputSchema: { ...schema, type: "object", properties: nextProperties, required: next.filter((field) => field.required).map((field) => field.name) } }); + }; + + const copy = async (text: string) => { + setCopyStatus(null); + setCopyError(null); + try { await navigator.clipboard.writeText(text); setCopyStatus("Copied"); } + catch { setCopyError("Could not copy to clipboard."); } + }; + + const requestKey = () => { + if (!currentTriggerId || pending) return; + reveal.reset(); + rotate.reset(); + reveal.mutate(currentTriggerId, { onSuccess: (result) => setKey(result.key) }); + }; + + const confirm = () => { + if (!currentTriggerId || pending) return; + reveal.reset(); + rotate.reset(); + setKey(null); + rotate.mutate(currentTriggerId, { onSuccess: (result) => { setKey(result.key); setConfirming(false); } }); + }; + + return ( + <> + + {schemaWarning &&

{schemaWarning}

} + +
+

Endpoint

+ {!endpoint ?

Save the workflow to create this endpoint.

: ( + <> +
Trigger ID{currentTriggerId}
+ {endpoint} + +
+ + +
+ {confirming && ( +
+

The old key stops working immediately. Existing callers must use the new key.

+
+ + +
+
+ )} + {key && ( +
+ + +
+ )} + {curl && ( +
+
{curl}
+ +
+ )} + + )} + {pending &&

Working...

} + {error &&

{error.message}

} + {copyStatus &&

{copyStatus}

} + {copyError &&

{copyError}

} +
+ + ); +} diff --git a/frontend/src/hooks/use-workflows.ts b/frontend/src/hooks/use-workflows.ts index 7adb72e8e..33adb04c8 100644 --- a/frontend/src/hooks/use-workflows.ts +++ b/frontend/src/hooks/use-workflows.ts @@ -55,10 +55,27 @@ export function useUpdateWorkflow() { onSuccess: (_, { workflowId }) => { qc.invalidateQueries({ queryKey: ["workflows"] }); qc.invalidateQueries({ queryKey: ["workflow", workflowId] }); + qc.invalidateQueries({ queryKey: ["workflow-external-triggers", workflowId] }); }, }); } +export function useWorkflowExternalTriggers(workflowId: string) { + return useQuery({ + queryKey: ["workflow-external-triggers", workflowId], + queryFn: () => workflowsApi.listExternalTriggers(workflowId), + staleTime: 0, + }); +} + +export function useRevealExternalTriggerKey() { + return useMutation({ mutationFn: (triggerId: string) => workflowsApi.revealExternalTriggerKey(triggerId), gcTime: 0 }); +} + +export function useRotateExternalTriggerKey() { + return useMutation({ mutationFn: (triggerId: string) => workflowsApi.rotateExternalTriggerKey(triggerId), gcTime: 0 }); +} + export function useDeleteWorkflow() { const qc = useQueryClient(); return useMutation({ diff --git a/frontend/src/lib/external-trigger-url.ts b/frontend/src/lib/external-trigger-url.ts new file mode 100644 index 000000000..950165de4 --- /dev/null +++ b/frontend/src/lib/external-trigger-url.ts @@ -0,0 +1,3 @@ +export function externalTriggerUrl(endpoint: string): string { + return new URL(endpoint, import.meta.env.VITE_API_URL || window.location.origin).href; +} diff --git a/frontend/src/routes/_app.teams.$teamSlug.workflows.$workflowSlug.index.tsx b/frontend/src/routes/_app.teams.$teamSlug.workflows.$workflowSlug.index.tsx index ad4415223..055e74d25 100644 --- a/frontend/src/routes/_app.teams.$teamSlug.workflows.$workflowSlug.index.tsx +++ b/frontend/src/routes/_app.teams.$teamSlug.workflows.$workflowSlug.index.tsx @@ -44,6 +44,7 @@ import { PostMessageInputsEditor } from "@/components/workflows/PostMessageInput import { ERROR_HANDLE, errorRouteTarget, setErrorRoute } from "@/lib/workflowErrorRoute"; import { introspectScope } from "@/components/workflows/scope-introspection"; import { StartNodeInputsEditor } from "@/components/workflows/StartNodeInputsEditor"; +import { ExternalTriggerEditor } from "@/components/workflows/external-trigger-editor"; import { SubworkflowEditor } from "@/components/workflows/SubworkflowEditor"; import { LoopEditor } from "@/components/workflows/LoopEditor"; import { MapEditor } from "@/components/workflows/MapEditor"; @@ -1464,6 +1465,8 @@ function NodeInspector({ start node"). Its Config/Input schemas are empty, so there's nothing else to show. */} {manifest.isManual ? ( + ) : manifest.typeKey === "trigger.external" ? ( + ) : manifest.typeKey === "flow.subworkflow" ? ( // Sub-workflow gets a dedicated editor: pick the child workflow, then map onto ITS declared // inputs (rendered via SchemaForm) — far better than the generic "workflowId + raw object".