From 54db2676b8960417f979d7682186ab79bf442132 Mon Sep 17 00:00:00 2001 From: Theaux Masquelier <43664045+Theauxm@users.noreply.github.com> Date: Mon, 11 May 2026 12:43:27 -0600 Subject: [PATCH 1/3] feat: add persisted-operations management surface Adds the admin surface for persisted operations under operations.persistedOperations on the GraphQL schema, plus the infrastructure that backs it: - IPersistedOperationValidator runs HotChocolate schema validation at upsert time so unknown fields, wrong variable types, and bad syntax surface before any row is written. - Structured exception hierarchy (PersistedOperationParseException, PersistedOperationValidationException, ShapeDiffViolationException) with stable codes the GraphQL mutations project into payload errors. - IPersistedOperationsCapability marker registered by UsePersistedOperations so consumers (dashboard) can gate UI on whether the subsystem is wired in. - Management mutations (uploadPersistedOperation, deactivatePersistedOperation, restorePersistedOperation) and queries (persistedOperations, persistedOperation, persistedOperationHistory) under operations.persistedOperations. - Middleware always lets the management surface through enforcement. Decouples version from the id name: ids are opaque, the operation name comes from the document's operation definition, and Version is an optional integer on UpsertOptions. Drops PersistedOperationIdParser and the name_vN parse requirement. --- ...CollectionPersistedOperationsExtensions.cs | 2 + ...hQLBuilderPersistedOperationsExtensions.cs | 29 ++ .../GraphQL/Models/MutationInputs.cs | 44 +++ .../GraphQL/Models/PersistedOperationDto.cs | 36 ++ .../GraphQL/Models/PersistedOperationError.cs | 82 +++++ .../Models/PersistedOperationHistoryDto.cs | 13 + .../Models/UploadPersistedOperationInput.cs | 11 + .../Models/UploadPersistedOperationPayload.cs | 24 ++ .../GraphQL/OperationsExtensions.cs | 35 ++ .../GraphQL/PersistedOperationMutations.cs | 193 ++++++++++ .../GraphQL/PersistedOperationQueries.cs | 122 +++++++ .../IPersistedOperationsCapability.cs | 13 + .../PersistedOperationsMiddleware.cs | 17 + .../Storage/DbPersistedOperationStorage.cs | 40 +- .../Exceptions/PersistedOperationException.cs | 25 ++ .../PersistedOperationParseException.cs | 52 +++ .../PersistedOperationValidationException.cs | 40 ++ .../Storage/Exceptions/ValidationFailure.cs | 22 ++ .../Storage/PersistedOperationIdParser.cs | 45 --- .../Storage/ShapeDiffViolationException.cs | 10 +- .../Storage/UpsertOptions.cs | 8 + .../Validation/HotChocolateSchemaValidator.cs | 115 ++++++ .../IPersistedOperationValidator.cs | 30 ++ .../NoOpPersistedOperationValidator.cs | 18 + .../Fixtures/GraphQLFixture.cs | 82 +++++ .../DbPersistedOperationStorageTests.cs | 49 ++- .../PersistedOperationMutationTests.cs | 344 ++++++++++++++++++ .../PersistedOperationParseExceptionTests.cs | 63 ++++ ...sistedOperationValidationExceptionTests.cs | 84 +++++ .../ShapeDiffViolationExceptionTests.cs | 39 ++ .../PersistedOperationIdParserTests.cs | 60 --- .../NoOpPersistedOperationValidatorTests.cs | 34 ++ 32 files changed, 1663 insertions(+), 118 deletions(-) create mode 100644 src/Trax.Api.GraphQL.PersistedOperations/GraphQL/Models/MutationInputs.cs create mode 100644 src/Trax.Api.GraphQL.PersistedOperations/GraphQL/Models/PersistedOperationDto.cs create mode 100644 src/Trax.Api.GraphQL.PersistedOperations/GraphQL/Models/PersistedOperationError.cs create mode 100644 src/Trax.Api.GraphQL.PersistedOperations/GraphQL/Models/PersistedOperationHistoryDto.cs create mode 100644 src/Trax.Api.GraphQL.PersistedOperations/GraphQL/Models/UploadPersistedOperationInput.cs create mode 100644 src/Trax.Api.GraphQL.PersistedOperations/GraphQL/Models/UploadPersistedOperationPayload.cs create mode 100644 src/Trax.Api.GraphQL.PersistedOperations/GraphQL/OperationsExtensions.cs create mode 100644 src/Trax.Api.GraphQL.PersistedOperations/GraphQL/PersistedOperationMutations.cs create mode 100644 src/Trax.Api.GraphQL.PersistedOperations/GraphQL/PersistedOperationQueries.cs create mode 100644 src/Trax.Api.GraphQL.PersistedOperations/IPersistedOperationsCapability.cs create mode 100644 src/Trax.Api.GraphQL.PersistedOperations/Storage/Exceptions/PersistedOperationException.cs create mode 100644 src/Trax.Api.GraphQL.PersistedOperations/Storage/Exceptions/PersistedOperationParseException.cs create mode 100644 src/Trax.Api.GraphQL.PersistedOperations/Storage/Exceptions/PersistedOperationValidationException.cs create mode 100644 src/Trax.Api.GraphQL.PersistedOperations/Storage/Exceptions/ValidationFailure.cs delete mode 100644 src/Trax.Api.GraphQL.PersistedOperations/Storage/PersistedOperationIdParser.cs create mode 100644 src/Trax.Api.GraphQL.PersistedOperations/Storage/Validation/HotChocolateSchemaValidator.cs create mode 100644 src/Trax.Api.GraphQL.PersistedOperations/Storage/Validation/IPersistedOperationValidator.cs create mode 100644 src/Trax.Api.GraphQL.PersistedOperations/Storage/Validation/NoOpPersistedOperationValidator.cs create mode 100644 tests/Trax.Api.Tests/PersistedOperations/Fixtures/GraphQLFixture.cs create mode 100644 tests/Trax.Api.Tests/PersistedOperations/IntegrationTests/PersistedOperationMutationTests.cs create mode 100644 tests/Trax.Api.Tests/PersistedOperations/UnitTests/Exceptions/PersistedOperationParseExceptionTests.cs create mode 100644 tests/Trax.Api.Tests/PersistedOperations/UnitTests/Exceptions/PersistedOperationValidationExceptionTests.cs create mode 100644 tests/Trax.Api.Tests/PersistedOperations/UnitTests/Exceptions/ShapeDiffViolationExceptionTests.cs delete mode 100644 tests/Trax.Api.Tests/PersistedOperations/UnitTests/PersistedOperationIdParserTests.cs create mode 100644 tests/Trax.Api.Tests/PersistedOperations/UnitTests/Validation/NoOpPersistedOperationValidatorTests.cs diff --git a/src/Trax.Api.GraphQL.PersistedOperations/Extensions/ServiceCollectionPersistedOperationsExtensions.cs b/src/Trax.Api.GraphQL.PersistedOperations/Extensions/ServiceCollectionPersistedOperationsExtensions.cs index 39bde49..5d2fabf 100644 --- a/src/Trax.Api.GraphQL.PersistedOperations/Extensions/ServiceCollectionPersistedOperationsExtensions.cs +++ b/src/Trax.Api.GraphQL.PersistedOperations/Extensions/ServiceCollectionPersistedOperationsExtensions.cs @@ -3,6 +3,7 @@ using Trax.Api.GraphQL.PersistedOperations.Broadcasting; using Trax.Api.GraphQL.PersistedOperations.Configuration; using Trax.Api.GraphQL.PersistedOperations.Storage; +using Trax.Api.GraphQL.PersistedOperations.Storage.Validation; namespace Trax.Api.GraphQL.PersistedOperations.Extensions; @@ -44,6 +45,7 @@ string databaseConnectionString IPersistedOperationBroadcaster, NoOpPersistedOperationBroadcaster >(); + services.TryAddSingleton(); services.AddSingleton(); services.AddSingleton(sp => diff --git a/src/Trax.Api.GraphQL.PersistedOperations/Extensions/TraxGraphQLBuilderPersistedOperationsExtensions.cs b/src/Trax.Api.GraphQL.PersistedOperations/Extensions/TraxGraphQLBuilderPersistedOperationsExtensions.cs index db7f95d..162e9d2 100644 --- a/src/Trax.Api.GraphQL.PersistedOperations/Extensions/TraxGraphQLBuilderPersistedOperationsExtensions.cs +++ b/src/Trax.Api.GraphQL.PersistedOperations/Extensions/TraxGraphQLBuilderPersistedOperationsExtensions.cs @@ -9,6 +9,7 @@ using Trax.Api.GraphQL.PersistedOperations.Configuration; using Trax.Api.GraphQL.PersistedOperations.Middleware; using Trax.Api.GraphQL.PersistedOperations.Storage; +using Trax.Api.GraphQL.PersistedOperations.Storage.Validation; namespace Trax.Api.GraphQL.PersistedOperations.Extensions; @@ -89,6 +90,34 @@ Action configure >(); } + // Validator: HotChocolate-backed in this path (we have a schema in process). + // Replace overrides the no-op default from AddPersistedOperationStore if it + // was also called. + services.Replace( + ServiceDescriptor.Singleton( + sp => new HotChocolateSchemaValidator(sp) + ) + ); + + // Capability marker: presence in DI signals to consumers (dashboard) + // that the full persisted-operations subsystem is wired in. + services.AddSingleton(); + + // Management mutations + queries. Scanned via the existing + // TraxGraphQLBuilder.AddTypeExtensions helper. Also flip the + // operations-exposed flags so AddTraxGraphQL emits the OperationsQueries + // and OperationsMutations namespaces that our type extensions graft onto. + builder.ExposeOperationQueries(); + builder.ExposeOperationMutations(); + builder.AddTypeExtensions(typeof(GraphQL.PersistedOperationMutations).Assembly); + + // The management surface lives under operations.persistedOperations and + // returns a payload with nested operation/errors objects, so the deepest + // query (errors.locations.line) is 6 levels under the root. Raise the + // default depth limit if the consumer has not already overridden it. + if (!builder.MaxExecutionDepthWasOverridden && builder.MaxExecutionDepthValue < 8) + builder.MaxExecutionDepth(8); + // Storage: implements both IPersistedOperationStore and the HC hot-path. services.AddSingleton(); services.AddSingleton(sp => diff --git a/src/Trax.Api.GraphQL.PersistedOperations/GraphQL/Models/MutationInputs.cs b/src/Trax.Api.GraphQL.PersistedOperations/GraphQL/Models/MutationInputs.cs new file mode 100644 index 0000000..238e9bc --- /dev/null +++ b/src/Trax.Api.GraphQL.PersistedOperations/GraphQL/Models/MutationInputs.cs @@ -0,0 +1,44 @@ +namespace Trax.Api.GraphQL.PersistedOperations.GraphQL.Models; + +/// Input for the deactivatePersistedOperation mutation. +public sealed record DeactivatePersistedOperationInput( + string Id, + string Reason, + string? TenantKey = null +); + +/// Result of deactivatePersistedOperation. +public sealed record DeactivatePersistedOperationPayload( + PersistedOperationDto? Operation, + IReadOnlyList Errors +) +{ + /// True when the change succeeded. + public bool Success => Operation is not null && Errors.Count == 0; +} + +/// Input for the restorePersistedOperation mutation. +public sealed record RestorePersistedOperationInput(string Id, string? TenantKey = null); + +/// Result of restorePersistedOperation. +public sealed record RestorePersistedOperationPayload( + PersistedOperationDto? Operation, + IReadOnlyList Errors +) +{ + /// True when the change succeeded. + public bool Success => Operation is not null && Errors.Count == 0; +} + +/// Filter for the persistedOperations query. +public sealed record PersistedOperationFilter( + bool? IsActive = null, + string? TenantKey = null, + string? IdStartsWith = null +); + +/// Paged result for the persistedOperations query. +public sealed record PersistedOperationsPage( + IReadOnlyList Items, + int TotalCount +); diff --git a/src/Trax.Api.GraphQL.PersistedOperations/GraphQL/Models/PersistedOperationDto.cs b/src/Trax.Api.GraphQL.PersistedOperations/GraphQL/Models/PersistedOperationDto.cs new file mode 100644 index 0000000..0828e60 --- /dev/null +++ b/src/Trax.Api.GraphQL.PersistedOperations/GraphQL/Models/PersistedOperationDto.cs @@ -0,0 +1,36 @@ +using Trax.Effect.Models.PersistedOperation; + +namespace Trax.Api.GraphQL.PersistedOperations.GraphQL.Models; + +/// +/// GraphQL surface for a persisted operation row. +/// +public sealed record PersistedOperationDto( + string Id, + string? TenantKey, + string OperationName, + int Version, + string Document, + string ShapeFingerprint, + bool IsActive, + string? DeprecationReason, + string? Description, + DateTime CreatedAt, + DateTime UpdatedAt +) +{ + internal static PersistedOperationDto From(PersistedOperation row) => + new( + row.Id, + row.TenantKey, + row.OperationName, + row.Version, + row.Document, + row.ShapeFingerprint, + row.IsActive, + row.DeprecationReason, + row.Description, + row.CreatedAt, + row.UpdatedAt + ); +} diff --git a/src/Trax.Api.GraphQL.PersistedOperations/GraphQL/Models/PersistedOperationError.cs b/src/Trax.Api.GraphQL.PersistedOperations/GraphQL/Models/PersistedOperationError.cs new file mode 100644 index 0000000..7de6855 --- /dev/null +++ b/src/Trax.Api.GraphQL.PersistedOperations/GraphQL/Models/PersistedOperationError.cs @@ -0,0 +1,82 @@ +using Trax.Api.GraphQL.PersistedOperations.Storage; +using Trax.Api.GraphQL.PersistedOperations.Storage.Exceptions; + +namespace Trax.Api.GraphQL.PersistedOperations.GraphQL.Models; + +/// +/// Structured error surfaced inside a mutation payload. Mutations never throw +/// to the GraphQL caller; failures are returned in the errors field +/// with a stable code so clients can branch without string-matching. +/// +public sealed record PersistedOperationError( + string Code, + string Message, + IReadOnlyList? Locations, + IReadOnlyList? Path, + string? OldFingerprint, + string? NewFingerprint +) +{ + internal static PersistedOperationError FromParseException(PersistedOperationParseException ex) + { + var locs = + ex.Line is { } line && ex.Column is { } column + ? new[] { new PersistedOperationErrorLocation(line, column) } + : null; + return new( + ex.Code, + ex.OriginalMessage, + locs, + Path: null, + OldFingerprint: null, + NewFingerprint: null + ); + } + + internal static IEnumerable FromValidationException( + PersistedOperationValidationException ex + ) + { + foreach (var failure in ex.Failures) + { + var locs = + failure.Locations.Count > 0 + ? failure + .Locations.Select(l => new PersistedOperationErrorLocation( + l.Line, + l.Column + )) + .ToArray() + : null; + var path = + failure.Path.Count > 0 + ? failure.Path.Select(p => p.ToString() ?? "").ToArray() + : null; + yield return new PersistedOperationError( + ex.Code, + failure.Message, + locs, + path, + null, + null + ); + } + } + + internal static PersistedOperationError FromShapeDiff(ShapeDiffViolationException ex) => + new(ex.Code, ex.Message, Locations: null, Path: null, ex.OldFingerprint, ex.NewFingerprint); + + /// Build a NOT_FOUND error. + public static PersistedOperationError NotFound(string id) => + new( + "NOT_FOUND", + $"Persisted operation '{id}' was not found.", + Locations: null, + Path: null, + OldFingerprint: null, + NewFingerprint: null + ); +} + +/// 1-based location in the candidate document. +public sealed record PersistedOperationErrorLocation(int Line, int Column); diff --git a/src/Trax.Api.GraphQL.PersistedOperations/GraphQL/Models/PersistedOperationHistoryDto.cs b/src/Trax.Api.GraphQL.PersistedOperations/GraphQL/Models/PersistedOperationHistoryDto.cs new file mode 100644 index 0000000..5628232 --- /dev/null +++ b/src/Trax.Api.GraphQL.PersistedOperations/GraphQL/Models/PersistedOperationHistoryDto.cs @@ -0,0 +1,13 @@ +namespace Trax.Api.GraphQL.PersistedOperations.GraphQL.Models; + +/// One row from the operation's audit log. +public sealed record PersistedOperationHistoryDto( + long HistoryId, + string Id, + string? TenantKey, + string Document, + string ShapeFingerprint, + string ChangeType, + DateTime ChangedAt, + string? ChangedReason +); diff --git a/src/Trax.Api.GraphQL.PersistedOperations/GraphQL/Models/UploadPersistedOperationInput.cs b/src/Trax.Api.GraphQL.PersistedOperations/GraphQL/Models/UploadPersistedOperationInput.cs new file mode 100644 index 0000000..d034586 --- /dev/null +++ b/src/Trax.Api.GraphQL.PersistedOperations/GraphQL/Models/UploadPersistedOperationInput.cs @@ -0,0 +1,11 @@ +namespace Trax.Api.GraphQL.PersistedOperations.GraphQL.Models; + +/// Input for the uploadPersistedOperation mutation. +public sealed record UploadPersistedOperationInput( + string Id, + string Document, + string? Description = null, + bool BypassShapeDiff = false, + int Version = 0, + string? TenantKey = null +); diff --git a/src/Trax.Api.GraphQL.PersistedOperations/GraphQL/Models/UploadPersistedOperationPayload.cs b/src/Trax.Api.GraphQL.PersistedOperations/GraphQL/Models/UploadPersistedOperationPayload.cs new file mode 100644 index 0000000..96407a0 --- /dev/null +++ b/src/Trax.Api.GraphQL.PersistedOperations/GraphQL/Models/UploadPersistedOperationPayload.cs @@ -0,0 +1,24 @@ +namespace Trax.Api.GraphQL.PersistedOperations.GraphQL.Models; + +/// +/// Result of uploadPersistedOperation. Exactly one of +/// and is populated. +/// +public sealed record UploadPersistedOperationPayload( + PersistedOperationDto? Operation, + IReadOnlyList Errors +) +{ + /// True when the upload succeeded. + public bool Success => Operation is not null && Errors.Count == 0; + + internal static UploadPersistedOperationPayload Ok(PersistedOperationDto op) => + new(op, Array.Empty()); + + internal static UploadPersistedOperationPayload Fail(params PersistedOperationError[] errors) => + new(null, errors); + + internal static UploadPersistedOperationPayload Fail( + IEnumerable errors + ) => new(null, errors.ToArray()); +} diff --git a/src/Trax.Api.GraphQL.PersistedOperations/GraphQL/OperationsExtensions.cs b/src/Trax.Api.GraphQL.PersistedOperations/GraphQL/OperationsExtensions.cs new file mode 100644 index 0000000..159c794 --- /dev/null +++ b/src/Trax.Api.GraphQL.PersistedOperations/GraphQL/OperationsExtensions.cs @@ -0,0 +1,35 @@ +using HotChocolate.Types; +using Trax.Api.GraphQL.Mutations; +using Trax.Api.GraphQL.Queries; + +namespace Trax.Api.GraphQL.PersistedOperations.GraphQL; + +/// +/// Grafts persistedOperations onto operations on the query side +/// of the schema. Mirrors how operations.deadLetters and +/// operations.manifestGroups are wired by the base +/// Trax.Api.GraphQL package. +/// +[ExtendObjectType(typeof(OperationsQueries))] +public sealed class OperationsQueriesPersistedOperationsExtension +{ + /// + /// Nested namespace exposing persisted-operation queries (paged list, + /// single lookup, audit history). + /// + public PersistedOperationQueries PersistedOperations() => new(); +} + +/// +/// Grafts persistedOperations onto operations on the mutation +/// side of the schema. +/// +[ExtendObjectType(typeof(OperationsMutations))] +public sealed class OperationsMutationsPersistedOperationsExtension +{ + /// + /// Nested namespace exposing persisted-operation mutations (upload, + /// deactivate, restore). + /// + public PersistedOperationMutations PersistedOperations() => new(); +} diff --git a/src/Trax.Api.GraphQL.PersistedOperations/GraphQL/PersistedOperationMutations.cs b/src/Trax.Api.GraphQL.PersistedOperations/GraphQL/PersistedOperationMutations.cs new file mode 100644 index 0000000..cb749b8 --- /dev/null +++ b/src/Trax.Api.GraphQL.PersistedOperations/GraphQL/PersistedOperationMutations.cs @@ -0,0 +1,193 @@ +using HotChocolate; +using Microsoft.EntityFrameworkCore; +using Trax.Api.GraphQL.PersistedOperations.GraphQL.Models; +using Trax.Api.GraphQL.PersistedOperations.Storage; +using Trax.Api.GraphQL.PersistedOperations.Storage.Exceptions; +using Trax.Effect.Data.Services.IDataContextFactory; + +namespace Trax.Api.GraphQL.PersistedOperations.GraphQL; + +/// +/// Body of the operations.persistedOperations mutation namespace. +/// Each mutation wraps and projects +/// structured exceptions into payload errors[] entries with stable +/// code values; mutations never throw to the client. +/// +public sealed class PersistedOperationMutations +{ + /// + /// Upload (insert or update) a persisted operation. Validates the + /// document against the live schema; rejects shape-changing edits unless + /// bypassShapeDiff is set. + /// + public async Task UploadPersistedOperation( + UploadPersistedOperationInput input, + [Service] IPersistedOperationStore store, + CancellationToken ct + ) + { + ArgumentNullException.ThrowIfNull(input); + if (string.IsNullOrWhiteSpace(input.Id)) + return UploadPersistedOperationPayload.Fail( + new PersistedOperationError( + "INVALID_INPUT", + "id is required.", + Locations: null, + Path: null, + OldFingerprint: null, + NewFingerprint: null + ) + ); + if (string.IsNullOrWhiteSpace(input.Document)) + return UploadPersistedOperationPayload.Fail( + new PersistedOperationError( + "INVALID_INPUT", + "document is required.", + Locations: null, + Path: null, + OldFingerprint: null, + NewFingerprint: null + ) + ); + + try + { + var row = await store + .UpsertAsync( + input.Id, + input.Document, + new UpsertOptions + { + TenantKey = input.TenantKey, + Description = input.Description, + BypassShapeDiff = input.BypassShapeDiff, + Version = input.Version, + }, + ct + ) + .ConfigureAwait(false); + return UploadPersistedOperationPayload.Ok(PersistedOperationDto.From(row)); + } + catch (PersistedOperationParseException ex) + { + return UploadPersistedOperationPayload.Fail( + PersistedOperationError.FromParseException(ex) + ); + } + catch (PersistedOperationValidationException ex) + { + return UploadPersistedOperationPayload.Fail( + PersistedOperationError.FromValidationException(ex) + ); + } + catch (ShapeDiffViolationException ex) + { + return UploadPersistedOperationPayload.Fail(PersistedOperationError.FromShapeDiff(ex)); + } + } + + /// Soft-delete an operation. Requires a non-empty reason. + public async Task DeactivatePersistedOperation( + DeactivatePersistedOperationInput input, + [Service] IPersistedOperationStore store, + CancellationToken ct + ) + { + ArgumentNullException.ThrowIfNull(input); + if (string.IsNullOrWhiteSpace(input.Id)) + return new DeactivatePersistedOperationPayload( + null, + new[] + { + new PersistedOperationError( + "INVALID_INPUT", + "id is required.", + null, + null, + null, + null + ), + } + ); + if (string.IsNullOrWhiteSpace(input.Reason)) + return new DeactivatePersistedOperationPayload( + null, + new[] + { + new PersistedOperationError( + "INVALID_INPUT", + "reason is required.", + null, + null, + null, + null + ), + } + ); + + var existing = await store.GetAsync(input.Id, input.TenantKey, ct).ConfigureAwait(false); + if (existing is null) + return new DeactivatePersistedOperationPayload( + null, + new[] { PersistedOperationError.NotFound(input.Id) } + ); + + await store + .DeactivateAsync(input.Id, input.TenantKey, input.Reason, ct) + .ConfigureAwait(false); + existing.IsActive = false; + existing.DeprecationReason = input.Reason; + return new DeactivatePersistedOperationPayload( + PersistedOperationDto.From(existing), + Array.Empty() + ); + } + + /// Reactivate a previously deactivated operation. + public async Task RestorePersistedOperation( + RestorePersistedOperationInput input, + [Service] IPersistedOperationStore store, + [Service] IDataContextProviderFactory contextFactory, + CancellationToken ct + ) + { + ArgumentNullException.ThrowIfNull(input); + if (string.IsNullOrWhiteSpace(input.Id)) + return new RestorePersistedOperationPayload( + null, + new[] + { + new PersistedOperationError( + "INVALID_INPUT", + "id is required.", + null, + null, + null, + null + ), + } + ); + + // Restore requires the row to exist (including deactivated rows). The + // store's GetAsync filters by IsActive, so look up directly via EF. + var sentinel = input.TenantKey ?? string.Empty; + await using var ctx = await contextFactory.CreateDbContextAsync(ct).ConfigureAwait(false); + var raw = await ctx + .PersistedOperations.AsNoTracking() + .FirstOrDefaultAsync(p => p.TenantKey == sentinel && p.Id == input.Id, ct) + .ConfigureAwait(false); + if (raw is null) + return new RestorePersistedOperationPayload( + null, + new[] { PersistedOperationError.NotFound(input.Id) } + ); + + await store.RestoreAsync(input.Id, input.TenantKey, ct).ConfigureAwait(false); + raw.IsActive = true; + raw.DeprecationReason = null; + return new RestorePersistedOperationPayload( + PersistedOperationDto.From(raw), + Array.Empty() + ); + } +} diff --git a/src/Trax.Api.GraphQL.PersistedOperations/GraphQL/PersistedOperationQueries.cs b/src/Trax.Api.GraphQL.PersistedOperations/GraphQL/PersistedOperationQueries.cs new file mode 100644 index 0000000..00bb67c --- /dev/null +++ b/src/Trax.Api.GraphQL.PersistedOperations/GraphQL/PersistedOperationQueries.cs @@ -0,0 +1,122 @@ +using HotChocolate; +using Microsoft.EntityFrameworkCore; +using Trax.Api.GraphQL.PersistedOperations.GraphQL.Models; +using Trax.Effect.Data.Services.IDataContextFactory; + +namespace Trax.Api.GraphQL.PersistedOperations.GraphQL; + +/// +/// Body of the operations.persistedOperations query namespace. Reads +/// hit the Trax data context directly via . +/// +public sealed class PersistedOperationQueries +{ + /// List persisted operations, newest-first, paginated. + public async Task PersistedOperations( + [Service] IDataContextProviderFactory contextFactory, + CancellationToken ct, + PersistedOperationFilter? filter = null, + int skip = 0, + int take = 50 + ) + { + if (take is <= 0 or > 200) + take = 50; + if (skip < 0) + skip = 0; + + await using var ctx = await contextFactory.CreateDbContextAsync(ct).ConfigureAwait(false); + IQueryable q = + ctx.PersistedOperations.AsNoTracking(); + + if (filter is not null) + { + if (filter.IsActive.HasValue) + q = q.Where(p => p.IsActive == filter.IsActive.Value); + if (!string.IsNullOrWhiteSpace(filter.TenantKey)) + q = q.Where(p => p.TenantKey == filter.TenantKey); + else if (filter.TenantKey == "") + q = q.Where(p => p.TenantKey == ""); + if (!string.IsNullOrWhiteSpace(filter.IdStartsWith)) + q = q.Where(p => p.Id.StartsWith(filter.IdStartsWith)); + } + + var total = await q.CountAsync(ct).ConfigureAwait(false); + var items = await q.OrderByDescending(p => p.UpdatedAt) + .Skip(skip) + .Take(take) + .Select(p => new PersistedOperationDto( + p.Id, + p.TenantKey == "" ? null : p.TenantKey, + p.OperationName, + p.Version, + p.Document, + p.ShapeFingerprint, + p.IsActive, + p.DeprecationReason, + p.Description, + p.CreatedAt, + p.UpdatedAt + )) + .ToListAsync(ct) + .ConfigureAwait(false); + + return new PersistedOperationsPage(items, total); + } + + /// Look up a single persisted operation. Returns null when missing. + public async Task PersistedOperation( + string id, + [Service] IDataContextProviderFactory contextFactory, + CancellationToken ct, + string? tenantKey = null + ) + { + ArgumentException.ThrowIfNullOrWhiteSpace(id); + var sentinel = tenantKey ?? string.Empty; + await using var ctx = await contextFactory.CreateDbContextAsync(ct).ConfigureAwait(false); + var row = await ctx + .PersistedOperations.AsNoTracking() + .FirstOrDefaultAsync(p => p.TenantKey == sentinel && p.Id == id, ct) + .ConfigureAwait(false); + return row is null ? null : PersistedOperationDto.From(row); + } + + /// Audit history for an operation, most-recent-first. + public async Task> PersistedOperationHistory( + string id, + [Service] IDataContextProviderFactory contextFactory, + CancellationToken ct, + string? tenantKey = null, + int skip = 0, + int take = 50 + ) + { + ArgumentException.ThrowIfNullOrWhiteSpace(id); + if (take is <= 0 or > 200) + take = 50; + if (skip < 0) + skip = 0; + var sentinel = tenantKey ?? string.Empty; + + await using var ctx = await contextFactory.CreateDbContextAsync(ct).ConfigureAwait(false); + return await ctx + .PersistedOperationHistories.AsNoTracking() + .Where(h => h.TenantKey == sentinel && h.Id == id) + .OrderByDescending(h => h.HistoryId) + .Skip(skip) + .Take(take) + .Select(h => new PersistedOperationHistoryDto( + h.HistoryId, + h.Id, + h.TenantKey == "" ? null : h.TenantKey, + h.Document, + h.ShapeFingerprint, + h.ChangeType, + h.ChangedAt, + h.ChangedReason + )) + .ToListAsync(ct) + .ConfigureAwait(false); + } +} diff --git a/src/Trax.Api.GraphQL.PersistedOperations/IPersistedOperationsCapability.cs b/src/Trax.Api.GraphQL.PersistedOperations/IPersistedOperationsCapability.cs new file mode 100644 index 0000000..435f2e4 --- /dev/null +++ b/src/Trax.Api.GraphQL.PersistedOperations/IPersistedOperationsCapability.cs @@ -0,0 +1,13 @@ +namespace Trax.Api.GraphQL.PersistedOperations; + +/// +/// Marker registered in DI by UsePersistedOperations(...). Consumers +/// (the Trax dashboard, custom admin UIs) probe for this via +/// IServiceProvider.GetService<IPersistedOperationsCapability>() +/// to decide whether to expose management UI or fall back to a "not enabled" +/// state. Presence implies the HotChocolate validator, capability marker, and +/// management GraphQL mutations/queries are all wired in. +/// +public interface IPersistedOperationsCapability { } + +internal sealed class PersistedOperationsCapability : IPersistedOperationsCapability { } diff --git a/src/Trax.Api.GraphQL.PersistedOperations/Middleware/PersistedOperationsMiddleware.cs b/src/Trax.Api.GraphQL.PersistedOperations/Middleware/PersistedOperationsMiddleware.cs index 16fec99..63d557f 100644 --- a/src/Trax.Api.GraphQL.PersistedOperations/Middleware/PersistedOperationsMiddleware.cs +++ b/src/Trax.Api.GraphQL.PersistedOperations/Middleware/PersistedOperationsMiddleware.cs @@ -107,6 +107,9 @@ private Decision Decide(GraphQLRequestShape parsed) if (_allowlist.IsAllowed(parsed.OperationName, parsed.DocumentId)) return Decision.PassThrough; + if (IsManagementOperation(parsed.Query)) + return Decision.PassThrough; + if (_options.AllowIntrospection && IsIntrospection(parsed)) return Decision.PassThrough; @@ -132,6 +135,20 @@ private static bool ShouldInspect(HttpContext context) || contentType.StartsWith("application/graphql", StringComparison.OrdinalIgnoreCase); } + /// + /// The management mutations and queries this package adds to the schema + /// always bypass enforcement. They live under operations.persistedOperations + /// (matching the convention for every other Trax management feature, e.g. + /// operations.deadLetters). Persisting them by id would create a + /// chicken-and-egg problem; they are already protected by whatever + /// ASP.NET auth middleware sits in front of the GraphQL endpoint. + /// Detection: the document must select the persistedOperations + /// namespace, which is unique to this management surface. + /// + private static bool IsManagementOperation(string? document) => + !string.IsNullOrEmpty(document) + && document.Contains("persistedOperations", StringComparison.Ordinal); + private static bool IsIntrospection(GraphQLRequestShape req) => IntrospectionDetector.LooksLikeIntrospectionByName(req.OperationName) || (req.Query is not null && IntrospectionDetector.IsPureIntrospection(req.Query)); diff --git a/src/Trax.Api.GraphQL.PersistedOperations/Storage/DbPersistedOperationStorage.cs b/src/Trax.Api.GraphQL.PersistedOperations/Storage/DbPersistedOperationStorage.cs index 74125e0..40a6bdb 100644 --- a/src/Trax.Api.GraphQL.PersistedOperations/Storage/DbPersistedOperationStorage.cs +++ b/src/Trax.Api.GraphQL.PersistedOperations/Storage/DbPersistedOperationStorage.cs @@ -1,9 +1,11 @@ using HotChocolate.Execution; +using HotChocolate.Language; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Trax.Api.GraphQL.PersistedOperations.Broadcasting; using Trax.Api.GraphQL.PersistedOperations.Configuration; using Trax.Api.GraphQL.PersistedOperations.ShapeDiff; +using Trax.Api.GraphQL.PersistedOperations.Storage.Validation; using Trax.Effect.Data.Services.IDataContextFactory; using Trax.Effect.Models.PersistedOperation; using Trax.Effect.Models.PersistedOperationHistory; @@ -31,6 +33,7 @@ internal sealed class DbPersistedOperationStorage private readonly PersistedOperationsOptions _options; private readonly IPersistedOperationCache _cache; private readonly IPersistedOperationBroadcaster _broadcaster; + private readonly IPersistedOperationValidator _validator; private readonly TimeProvider _clock; private readonly ILogger _logger; @@ -39,6 +42,7 @@ public DbPersistedOperationStorage( PersistedOperationsOptions options, IPersistedOperationCache cache, IPersistedOperationBroadcaster broadcaster, + IPersistedOperationValidator validator, TimeProvider clock, ILogger logger ) @@ -47,12 +51,14 @@ ILogger logger ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(cache); ArgumentNullException.ThrowIfNull(broadcaster); + ArgumentNullException.ThrowIfNull(validator); ArgumentNullException.ThrowIfNull(clock); ArgumentNullException.ThrowIfNull(logger); _factory = factory; _options = options; _cache = cache; _broadcaster = broadcaster; + _validator = validator; _clock = clock; _logger = logger; } @@ -176,7 +182,17 @@ CancellationToken ct ArgumentException.ThrowIfNullOrEmpty(id); ArgumentException.ThrowIfNullOrEmpty(document); - var (operationName, version) = PersistedOperationIdParser.Parse(id); + // Validate against the live schema before any DB work; the validator + // throws structured exceptions that callers project into form errors + // or GraphQL error payloads. No row is written and no broadcast fires + // if validation fails. + await _validator.ValidateAsync(document, ct).ConfigureAwait(false); + + // OperationName is taken from the document's operation definition + // (the GraphQL spec sense). The id is opaque — no parse rule. + // Version is operator-controlled metadata via UpsertOptions. + var operationName = ExtractOperationName(document); + var version = options?.Version ?? 0; // Convention: each persisted document holds exactly one operation, so // the fingerprint computer disambiguates by "the only operation". var fingerprint = ShapeFingerprintComputer.Compute(document); @@ -409,6 +425,28 @@ await _broadcaster private static string Normalize(string? tenantKey) => string.IsNullOrEmpty(tenantKey) ? NoTenantSentinel : tenantKey; + /// + /// Returns the GraphQL operation definition's name from the document, or + /// the empty string when the operation is anonymous. Convention: each + /// persisted document holds exactly one operation, so picking the first + /// definition is unambiguous. + /// + private static string ExtractOperationName(string document) + { + try + { + var parsed = Utf8GraphQLParser.Parse(document); + var op = parsed.Definitions.OfType().FirstOrDefault(); + return op?.Name?.Value ?? string.Empty; + } + catch + { + // Validator already ran; if parse fails here it's surprising. + // Fall back to empty rather than crash the upsert. + return string.Empty; + } + } + private static PersistedOperation Denormalize(PersistedOperation row) => new() { diff --git a/src/Trax.Api.GraphQL.PersistedOperations/Storage/Exceptions/PersistedOperationException.cs b/src/Trax.Api.GraphQL.PersistedOperations/Storage/Exceptions/PersistedOperationException.cs new file mode 100644 index 0000000..95ec2ea --- /dev/null +++ b/src/Trax.Api.GraphQL.PersistedOperations/Storage/Exceptions/PersistedOperationException.cs @@ -0,0 +1,25 @@ +namespace Trax.Api.GraphQL.PersistedOperations.Storage.Exceptions; + +/// +/// Base class for every exception thrown by the persisted-operations storage +/// layer that represents a rejected upload (as opposed to an internal fault). +/// Callers wrapping can +/// catch this single type to render a structured error to the user. +/// +public abstract class PersistedOperationException : InvalidOperationException +{ + /// + /// Stable machine-readable code for the failure category. Consumers + /// (GraphQL error payloads, dashboard form errors) key off this rather + /// than the exception subtype. + /// + public abstract string Code { get; } + + /// Build the exception with a human-readable message. + protected PersistedOperationException(string message) + : base(message) { } + + /// Build the exception with a human-readable message and inner cause. + protected PersistedOperationException(string message, Exception inner) + : base(message, inner) { } +} diff --git a/src/Trax.Api.GraphQL.PersistedOperations/Storage/Exceptions/PersistedOperationParseException.cs b/src/Trax.Api.GraphQL.PersistedOperations/Storage/Exceptions/PersistedOperationParseException.cs new file mode 100644 index 0000000..8630e1d --- /dev/null +++ b/src/Trax.Api.GraphQL.PersistedOperations/Storage/Exceptions/PersistedOperationParseException.cs @@ -0,0 +1,52 @@ +namespace Trax.Api.GraphQL.PersistedOperations.Storage.Exceptions; + +/// +/// Thrown when an uploaded document does not parse as valid GraphQL syntax. +/// Carries the originating line/column when the underlying parser exposed +/// them, so the dashboard can highlight the offending location. +/// +public sealed class PersistedOperationParseException : PersistedOperationException +{ + /// Stable code surfaced via . + public const string CodeValue = "PARSE_FAILED"; + + /// + public override string Code => CodeValue; + + /// 1-based line number of the syntax error, when known. + public int? Line { get; } + + /// 1-based column number of the syntax error, when known. + public int? Column { get; } + + /// The parser's original message, preserved without prefixing. + public string OriginalMessage { get; } + + /// + /// Build the exception from a parser failure. + /// + public PersistedOperationParseException( + string originalMessage, + int? line, + int? column, + Exception? inner = null + ) + : base( + BuildMessage(originalMessage, line, column), + inner ?? new InvalidOperationException() + ) + { + ArgumentException.ThrowIfNullOrWhiteSpace(originalMessage); + OriginalMessage = originalMessage; + Line = line; + Column = column; + } + + private static string BuildMessage(string originalMessage, int? line, int? column) + { + ArgumentException.ThrowIfNullOrWhiteSpace(originalMessage); + if (line.HasValue && column.HasValue) + return $"Persisted operation document failed to parse at line {line}, column {column}: {originalMessage}"; + return $"Persisted operation document failed to parse: {originalMessage}"; + } +} diff --git a/src/Trax.Api.GraphQL.PersistedOperations/Storage/Exceptions/PersistedOperationValidationException.cs b/src/Trax.Api.GraphQL.PersistedOperations/Storage/Exceptions/PersistedOperationValidationException.cs new file mode 100644 index 0000000..83dd812 --- /dev/null +++ b/src/Trax.Api.GraphQL.PersistedOperations/Storage/Exceptions/PersistedOperationValidationException.cs @@ -0,0 +1,40 @@ +namespace Trax.Api.GraphQL.PersistedOperations.Storage.Exceptions; + +/// +/// Thrown when an uploaded document parses but fails to validate against the +/// server schema (unknown field, wrong variable type, missing required +/// variable, etc.). Carries the full set of failures so the dashboard can +/// surface all errors at once instead of one-at-a-time. +/// +public sealed class PersistedOperationValidationException : PersistedOperationException +{ + /// Stable code surfaced via . + public const string CodeValue = "SCHEMA_VALIDATION_FAILED"; + + /// + public override string Code => CodeValue; + + /// The full set of failures detected in the document. + public IReadOnlyList Failures { get; } + + /// Build the exception with at least one failure. + public PersistedOperationValidationException(IReadOnlyList failures) + : base(BuildMessage(failures)) + { + Failures = failures; + } + + private static string BuildMessage(IReadOnlyList failures) + { + ArgumentNullException.ThrowIfNull(failures); + if (failures.Count == 0) + throw new ArgumentException( + "PersistedOperationValidationException requires at least one failure.", + nameof(failures) + ); + + if (failures.Count == 1) + return $"Persisted operation document failed schema validation: {failures[0].Message}"; + return $"Persisted operation document failed schema validation with {failures.Count} errors. First: {failures[0].Message}"; + } +} diff --git a/src/Trax.Api.GraphQL.PersistedOperations/Storage/Exceptions/ValidationFailure.cs b/src/Trax.Api.GraphQL.PersistedOperations/Storage/Exceptions/ValidationFailure.cs new file mode 100644 index 0000000..9b0d381 --- /dev/null +++ b/src/Trax.Api.GraphQL.PersistedOperations/Storage/Exceptions/ValidationFailure.cs @@ -0,0 +1,22 @@ +namespace Trax.Api.GraphQL.PersistedOperations.Storage.Exceptions; + +/// +/// A single schema-validation failure reported by the validator. Multiple +/// failures can be returned from one document (e.g. two unknown fields). +/// +/// Human-readable message from the validator. +/// 1-based (line, column) pairs the failure points at; empty when not available. +/// Response path components leading to the failure; empty when not applicable. +public sealed record ValidationFailure( + string Message, + IReadOnlyList Locations, + IReadOnlyList Path +) +{ + /// Build a failure with no location or path data. + public static ValidationFailure FromMessage(string message) => + new(message, Array.Empty(), Array.Empty()); +} + +/// 1-based location in the source document. +public readonly record struct ValidationFailureLocation(int Line, int Column); diff --git a/src/Trax.Api.GraphQL.PersistedOperations/Storage/PersistedOperationIdParser.cs b/src/Trax.Api.GraphQL.PersistedOperations/Storage/PersistedOperationIdParser.cs deleted file mode 100644 index e0c6f63..0000000 --- a/src/Trax.Api.GraphQL.PersistedOperations/Storage/PersistedOperationIdParser.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System.Text.RegularExpressions; - -namespace Trax.Api.GraphQL.PersistedOperations.Storage; - -/// -/// Parses ids of the form name.vN into (name, n) tuples. -/// Used by the storage layer to populate the operation_name and -/// version columns alongside the literal id. -/// -internal static partial class PersistedOperationIdParser -{ - [GeneratedRegex( - @"^(?[A-Za-z_][A-Za-z0-9_]*)_v(?\d+)$", - RegexOptions.CultureInvariant - )] - private static partial Regex IdRegex(); - - /// - /// Parse an id into its name and numeric version. Throws when the id - /// does not match the convention so misuse fails loudly. - /// - /// - /// Convention is name_vN (underscore, not dot) because - /// HotChocolate's OperationDocumentId rejects dots. The - /// underscore separator is the closest readable alternative. - /// - public static (string Name, int Version) Parse(string id) - { - ArgumentException.ThrowIfNullOrEmpty(id); - - var match = IdRegex().Match(id); - if (!match.Success) - throw new FormatException( - $"Persisted operation id '{id}' does not match the required form 'name_vN' " - + "(e.g. 'userProfile_v1'). Pick a stable name and bump v on breaking shape changes." - ); - - return (match.Groups["name"].Value, int.Parse(match.Groups["version"].Value)); - } - - /// - /// True when the id matches the name.vN convention. - /// - public static bool IsValid(string id) => !string.IsNullOrEmpty(id) && IdRegex().IsMatch(id); -} diff --git a/src/Trax.Api.GraphQL.PersistedOperations/Storage/ShapeDiffViolationException.cs b/src/Trax.Api.GraphQL.PersistedOperations/Storage/ShapeDiffViolationException.cs index 1917b54..1af1b80 100644 --- a/src/Trax.Api.GraphQL.PersistedOperations/Storage/ShapeDiffViolationException.cs +++ b/src/Trax.Api.GraphQL.PersistedOperations/Storage/ShapeDiffViolationException.cs @@ -1,3 +1,5 @@ +using Trax.Api.GraphQL.PersistedOperations.Storage.Exceptions; + namespace Trax.Api.GraphQL.PersistedOperations.Storage; /// @@ -7,8 +9,14 @@ namespace Trax.Api.GraphQL.PersistedOperations.Storage; /// (the dashboard --force path) when the operator has verified /// the change is shape-safe. /// -public sealed class ShapeDiffViolationException : InvalidOperationException +public sealed class ShapeDiffViolationException : PersistedOperationException { + /// Stable code surfaced via . + public const string CodeValue = "SHAPE_DIFF_VIOLATION"; + + /// + public override string Code => CodeValue; + /// /// The id of the operation whose shape would change. /// diff --git a/src/Trax.Api.GraphQL.PersistedOperations/Storage/UpsertOptions.cs b/src/Trax.Api.GraphQL.PersistedOperations/Storage/UpsertOptions.cs index 37fe981..1d6bfd1 100644 --- a/src/Trax.Api.GraphQL.PersistedOperations/Storage/UpsertOptions.cs +++ b/src/Trax.Api.GraphQL.PersistedOperations/Storage/UpsertOptions.cs @@ -25,4 +25,12 @@ public sealed record UpsertOptions /// intentionally a breaking version bump). /// public bool BypassShapeDiff { get; init; } + + /// + /// Operator-controlled metadata. Stored on the row and surfaced on the + /// dashboard for lifecycle tracking. Not used for request routing — the + /// id is the contract with shipped clients. Defaults to 0; bump + /// it explicitly when shipping a new client that requests a new id. + /// + public int Version { get; init; } } diff --git a/src/Trax.Api.GraphQL.PersistedOperations/Storage/Validation/HotChocolateSchemaValidator.cs b/src/Trax.Api.GraphQL.PersistedOperations/Storage/Validation/HotChocolateSchemaValidator.cs new file mode 100644 index 0000000..af4b7dc --- /dev/null +++ b/src/Trax.Api.GraphQL.PersistedOperations/Storage/Validation/HotChocolateSchemaValidator.cs @@ -0,0 +1,115 @@ +using System.Collections.Concurrent; +using HotChocolate; +using HotChocolate.Execution; +using HotChocolate.Language; +using HotChocolate.Validation; +using Microsoft.Extensions.DependencyInjection; +using Trax.Api.GraphQL.PersistedOperations.Storage.Exceptions; + +namespace Trax.Api.GraphQL.PersistedOperations.Storage.Validation; + +/// +/// Validates a candidate persisted-operation document against the live +/// HotChocolate schema. Runs the same validation rules HotChocolate runs at +/// execution time, so anything that passes here will execute at runtime +/// (modulo runtime data shape). +/// +public sealed class HotChocolateSchemaValidator : IPersistedOperationValidator +{ + private readonly IServiceProvider _services; + private readonly string _schemaName; + + private readonly ConcurrentDictionary _executorCache = new(); + + /// + /// Build the validator. defaults to the + /// Trax schema name used by AddTraxGraphQL. The + /// is resolved from + /// lazily on first validation, so the + /// validator can be constructed even before GraphQL composition is final. + /// + public HotChocolateSchemaValidator(IServiceProvider services, string schemaName = "trax") + { + ArgumentNullException.ThrowIfNull(services); + ArgumentException.ThrowIfNullOrEmpty(schemaName); + _services = services; + _schemaName = schemaName; + } + + /// + public async Task ValidateAsync(string document, CancellationToken ct) + { + ArgumentException.ThrowIfNullOrEmpty(document); + ct.ThrowIfCancellationRequested(); + + DocumentNode parsed; + try + { + parsed = Utf8GraphQLParser.Parse(document); + } + catch (SyntaxException ex) + { + // HC parser reports 1-based line/column. + throw new PersistedOperationParseException(ex.Message, ex.Line, ex.Column, ex); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + throw new PersistedOperationParseException(ex.Message, line: null, column: null, ex); + } + + var entry = await GetExecutorAsync(ct).ConfigureAwait(false); + + var result = await entry + .Validator.ValidateAsync( + entry.Schema, + parsed, + documentId: new OperationDocumentId("trax-po-validator"), + contextData: new Dictionary()!, + onlyNonCacheable: false, + cancellationToken: ct + ) + .ConfigureAwait(false); + + if (!result.HasErrors) + return; + + var failures = result.Errors.Select(ToFailure).ToArray(); + throw new PersistedOperationValidationException(failures); + } + + private async ValueTask GetExecutorAsync(CancellationToken ct) + { + if (_executorCache.TryGetValue(_schemaName, out var cached)) + return cached; + + var resolver = _services.GetRequiredService(); + var executor = await resolver + .GetRequestExecutorAsync(_schemaName, ct) + .ConfigureAwait(false); + + // The IDocumentValidator is registered per-schema via + // IDocumentValidatorFactory on the root container, not on + // executor.Services. Resolve through the factory. + var factory = _services.GetRequiredService(); + var validator = factory.CreateValidator(_schemaName); + + var entry = new ExecutorCacheEntry(executor.Schema, validator); + _executorCache[_schemaName] = entry; + return entry; + } + + private static ValidationFailure ToFailure(IError error) + { + var locations = error.Locations is { Count: > 0 } locs + ? locs.Select(l => new ValidationFailureLocation(l.Line, l.Column)).ToArray() + : Array.Empty(); + + var path = + error.Path?.ToList()?.Where(p => p is not null).Select(p => p!).ToArray() + ?? Array.Empty(); + + return new ValidationFailure(error.Message, locations, path); + } + + private readonly record struct ExecutorCacheEntry(ISchema Schema, IDocumentValidator Validator); +} diff --git a/src/Trax.Api.GraphQL.PersistedOperations/Storage/Validation/IPersistedOperationValidator.cs b/src/Trax.Api.GraphQL.PersistedOperations/Storage/Validation/IPersistedOperationValidator.cs new file mode 100644 index 0000000..3429a3d --- /dev/null +++ b/src/Trax.Api.GraphQL.PersistedOperations/Storage/Validation/IPersistedOperationValidator.cs @@ -0,0 +1,30 @@ +using Trax.Api.GraphQL.PersistedOperations.Storage.Exceptions; + +namespace Trax.Api.GraphQL.PersistedOperations.Storage.Validation; + +/// +/// Validates a GraphQL document string before it is persisted. Runs at +/// upload time so that errors surface to the operator immediately rather +/// than at execution time when a client tries to run the operation. +/// +/// +/// Two implementations ship in this package: +/// +/// : the default in +/// AddPersistedOperationStore (admin tooling without a schema). +/// HotChocolate-backed validator: registered automatically by +/// UsePersistedOperations. Validates against the live schema. +/// +/// Implementations throw for +/// syntax errors and for +/// schema-validation errors. Callers are expected to let these propagate; +/// the storage layer ensures no DB write or broadcast occurs on failure. +/// +public interface IPersistedOperationValidator +{ + /// + /// Validate . Returns successfully if the + /// document is valid; throws otherwise. + /// + Task ValidateAsync(string document, CancellationToken ct); +} diff --git a/src/Trax.Api.GraphQL.PersistedOperations/Storage/Validation/NoOpPersistedOperationValidator.cs b/src/Trax.Api.GraphQL.PersistedOperations/Storage/Validation/NoOpPersistedOperationValidator.cs new file mode 100644 index 0000000..b041ed3 --- /dev/null +++ b/src/Trax.Api.GraphQL.PersistedOperations/Storage/Validation/NoOpPersistedOperationValidator.cs @@ -0,0 +1,18 @@ +namespace Trax.Api.GraphQL.PersistedOperations.Storage.Validation; + +/// +/// Default validator for hosts that do not have a HotChocolate schema in +/// process (e.g. AddPersistedOperationStore in a console uploader). +/// Performs no checks; the document is taken at face value. The shape-diff +/// guardrail still runs at the storage layer. +/// +public sealed class NoOpPersistedOperationValidator : IPersistedOperationValidator +{ + /// + public Task ValidateAsync(string document, CancellationToken ct) + { + if (ct.IsCancellationRequested) + return Task.FromCanceled(ct); + return Task.CompletedTask; + } +} diff --git a/tests/Trax.Api.Tests/PersistedOperations/Fixtures/GraphQLFixture.cs b/tests/Trax.Api.Tests/PersistedOperations/Fixtures/GraphQLFixture.cs new file mode 100644 index 0000000..09725f0 --- /dev/null +++ b/tests/Trax.Api.Tests/PersistedOperations/Fixtures/GraphQLFixture.cs @@ -0,0 +1,82 @@ +using HotChocolate; +using HotChocolate.Execution; +using HotChocolate.Types; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using Trax.Api.GraphQL.Extensions; +using Trax.Api.GraphQL.PersistedOperations.Extensions; +using Trax.Effect.Configuration.TraxBuilder; +using Trax.Effect.Data.Postgres.Extensions; +using Trax.Effect.Data.Postgres.Utils; +using Trax.Effect.Extensions; +using Trax.Effect.Services.EffectRegistry; +using Trax.Mediator.Services.TrainDiscovery; + +namespace Trax.Api.Tests.PersistedOperations.Fixtures; + +/// +/// Spins up a minimal Trax + GraphQL + PersistedOperations stack against the +/// real Postgres in . Used by integration tests +/// that exercise the GraphQL mutations/queries the package extends onto the +/// root types. +/// +/// +/// The schema is intentionally tiny (a single hello field) so the +/// tests focus on the persisted-operations surface rather than other Trax +/// GraphQL features. The schema is rebuilt per-test class but the underlying +/// Postgres state is wiped via PostgresFixture.ClearAsync() per-test. +/// +public static class GraphQLFixture +{ + /// A document that validates against the test schema's hello field. + public const string ValidDocument = "query Greet { hello }"; + + /// A document referencing a field that does not exist on the schema. + public const string SchemaMismatchDocument = "query Greet { nonexistentField }"; + + /// A document with a parse error. + public const string SyntaxErrorDocument = "query Greet { hello"; + + /// A second valid document with the same response shape as ValidDocument. + public const string ValidDocumentRewrite = "query Greet { hello # rewrite\n}"; + + /// A valid document with a different response shape (extra field). + public const string ShapeChangingDocument = "query Greet { hello version }"; + + public static async Task BuildAsync() + { + await DatabaseMigrator.Migrate(PostgresFixture.ConnectionString); + var sc = new ServiceCollection(); + sc.AddLogging(); + sc.AddSingleton(); + sc.AddSingleton(Substitute.For()); + sc.AddSingleton(Substitute.For()); + sc.AddTrax(trax => + trax.AddEffects(effects => effects.UsePostgres(PostgresFixture.ConnectionString)) + ); + sc.AddTraxGraphQL(g => + g.ExposeOperationQueries() + .ExposeOperationMutations() + .AddTypeExtension() + .UsePersistedOperations(po => po.UseDatabase(PostgresFixture.ConnectionString)) + ); + return sc.BuildServiceProvider(); + } + + public static async Task GetExecutorAsync( + IServiceProvider sp, + CancellationToken ct = default + ) + { + var resolver = sp.GetRequiredService(); + return await resolver.GetRequestExecutorAsync("trax", ct); + } + + [ExtendObjectType("RootQuery")] + public class HelloQuery + { + public string Hello() => "world"; + + public string Version() => "v1"; + } +} diff --git a/tests/Trax.Api.Tests/PersistedOperations/IntegrationTests/DbPersistedOperationStorageTests.cs b/tests/Trax.Api.Tests/PersistedOperations/IntegrationTests/DbPersistedOperationStorageTests.cs index 8887a6c..793814c 100644 --- a/tests/Trax.Api.Tests/PersistedOperations/IntegrationTests/DbPersistedOperationStorageTests.cs +++ b/tests/Trax.Api.Tests/PersistedOperations/IntegrationTests/DbPersistedOperationStorageTests.cs @@ -6,6 +6,7 @@ using Trax.Api.GraphQL.PersistedOperations.Broadcasting; using Trax.Api.GraphQL.PersistedOperations.Configuration; using Trax.Api.GraphQL.PersistedOperations.Storage; +using Trax.Api.GraphQL.PersistedOperations.Storage.Validation; using Trax.Effect.Data.Services.IDataContextFactory; using Trax.Effect.Models.PersistedOperation; @@ -47,6 +48,7 @@ public async Task SetUp() options, cache, _broadcaster, + new NoOpPersistedOperationValidator(), TimeProvider.System, NullLogger.Instance ); @@ -60,12 +62,14 @@ public async Task Upsert_NewRow_Inserts() var row = await _storage.UpsertAsync( "greet_v1", doc, - options: null, + options: new UpsertOptions { Version = 1 }, CancellationToken.None ); row.Id.Should().Be("greet_v1"); - row.OperationName.Should().Be("greet"); + // OperationName is taken from the document's operation definition, + // not parsed out of the id. + row.OperationName.Should().Be("Greet"); row.Version.Should().Be(1); row.Document.Should().Be(doc); row.IsActive.Should().BeTrue(); @@ -73,6 +77,36 @@ public async Task Upsert_NewRow_Inserts() row.TenantKey.Should().BeNull(); } + [Test] + public async Task Upsert_AnonymousOperation_StoresEmptyOperationName() + { + var row = await _storage.UpsertAsync( + "anon_v1", + "{ greeting }", + options: null, + CancellationToken.None + ); + + row.OperationName.Should().BeEmpty(); + row.Version.Should().Be(0); + } + + [Test] + public async Task Upsert_ArbitraryId_DoesNotThrow() + { + // Ids are opaque strings; there is no parse rule. + var row = await _storage.UpsertAsync( + "this-is-an-opaque/id::v0", + "query Greet { greeting }", + null, + CancellationToken.None + ); + + row.Id.Should().Be("this-is-an-opaque/id::v0"); + row.OperationName.Should().Be("Greet"); + row.Version.Should().Be(0); + } + [Test] public async Task Upsert_Existing_UpdatesDocument() { @@ -248,6 +282,7 @@ public async Task Upsert_BroadcasterThrows_OperationStillSucceeds() options, new NoOpPersistedOperationCache(), throwing, + new NoOpPersistedOperationValidator(), TimeProvider.System, NullLogger.Instance ); @@ -543,15 +578,6 @@ private static async Task WrapAsync(Task t) private readonly record struct WrapResult(bool Success, string? Document, Exception? Exception); - [Test] - public async Task Upsert_InvalidIdFormat_Throws() - { - Func act = () => - _storage.UpsertAsync("not-versioned", "query { x }", null, CancellationToken.None); - - await act.Should().ThrowAsync(); - } - [Test] public async Task Upsert_EmptyDocument_Throws() { @@ -578,6 +604,7 @@ public async Task TryReadAsync_UsesCache_WhenConfigured() new PersistedOperationsBuilder().UseDatabase(PostgresFixture.ConnectionString).Build(), memCache, new NoOpPersistedOperationBroadcaster(), + new NoOpPersistedOperationValidator(), TimeProvider.System, NullLogger.Instance ); diff --git a/tests/Trax.Api.Tests/PersistedOperations/IntegrationTests/PersistedOperationMutationTests.cs b/tests/Trax.Api.Tests/PersistedOperations/IntegrationTests/PersistedOperationMutationTests.cs new file mode 100644 index 0000000..948bec4 --- /dev/null +++ b/tests/Trax.Api.Tests/PersistedOperations/IntegrationTests/PersistedOperationMutationTests.cs @@ -0,0 +1,344 @@ +using System.Text.Json; +using FluentAssertions; +using HotChocolate; +using HotChocolate.Execution; +using Microsoft.Extensions.DependencyInjection; +using Trax.Api.GraphQL.PersistedOperations.Storage; +using Trax.Api.Tests.PersistedOperations.Fixtures; + +namespace Trax.Api.Tests.PersistedOperations.IntegrationTests; + +/// +/// Drives the management mutations end-to-end through HotChocolate. Verifies +/// that exception projection produces the documented payload codes and that +/// successful uploads materialise into rows reachable via the store. +/// +[TestFixture] +[Category("Integration")] +public class PersistedOperationMutationTests +{ + private ServiceProvider _sp = null!; + private IRequestExecutor _executor = null!; + private IPersistedOperationStore _store = null!; + + [OneTimeSetUp] + public async Task OneTimeSetUp() + { + if (!PostgresFixture.IsPostgresReachable()) + Assert.Ignore("Postgres not reachable; skipping integration tests."); + + _sp = await GraphQLFixture.BuildAsync(); + _executor = await GraphQLFixture.GetExecutorAsync(_sp); + _store = _sp.GetRequiredService(); + } + + [OneTimeTearDown] + public async Task OneTimeTearDown() + { + if (_sp is not null) + await _sp.DisposeAsync(); + } + + [SetUp] + public async Task SetUp() => await PostgresFixture.ClearAsync(); + + [Test] + public async Task Upload_ValidDocument_ReturnsOperation_AndPersists() + { + var json = await ExecuteMutationAsync( + "upload_v1", + GraphQLFixture.ValidDocument, + description: "first upload" + ); + + json.RootElement.GetProperty("data") + .GetProperty("operations") + .GetProperty("persistedOperations") + .GetProperty("uploadPersistedOperation") + .GetProperty("success") + .GetBoolean() + .Should() + .BeTrue(); + + var row = await _store.GetAsync("upload_v1", null, CancellationToken.None); + row.Should().NotBeNull(); + row!.Document.Should().Be(GraphQLFixture.ValidDocument); + row.Description.Should().Be("first upload"); + } + + [Test] + public async Task Upload_SyntaxError_ReturnsParseError_AndDoesNotPersist() + { + var json = await ExecuteMutationAsync("syntax_v1", GraphQLFixture.SyntaxErrorDocument); + + var errors = json + .RootElement.GetProperty("data") + .GetProperty("operations") + .GetProperty("persistedOperations") + .GetProperty("uploadPersistedOperation") + .GetProperty("errors"); + errors.GetArrayLength().Should().BeGreaterThan(0); + errors[0].GetProperty("code").GetString().Should().Be("PARSE_FAILED"); + + (await _store.GetAsync("syntax_v1", null, CancellationToken.None)).Should().BeNull(); + } + + [Test] + public async Task Upload_SchemaMismatch_ReturnsValidationError_AndDoesNotPersist() + { + var json = await ExecuteMutationAsync("schema_v1", GraphQLFixture.SchemaMismatchDocument); + + var errors = json + .RootElement.GetProperty("data") + .GetProperty("operations") + .GetProperty("persistedOperations") + .GetProperty("uploadPersistedOperation") + .GetProperty("errors"); + errors.GetArrayLength().Should().BeGreaterThan(0); + errors[0].GetProperty("code").GetString().Should().Be("SCHEMA_VALIDATION_FAILED"); + errors[0].GetProperty("message").GetString().Should().NotBeNullOrEmpty(); + + (await _store.GetAsync("schema_v1", null, CancellationToken.None)).Should().BeNull(); + } + + [Test] + public async Task Upload_ShapeDiffWithoutBypass_ReturnsShapeDiffError_WithFingerprints() + { + // Seed. + await _store.UpsertAsync( + "shape_v1", + GraphQLFixture.ValidDocument, + null, + CancellationToken.None + ); + + var json = await ExecuteMutationAsync("shape_v1", GraphQLFixture.ShapeChangingDocument); + + var errors = json + .RootElement.GetProperty("data") + .GetProperty("operations") + .GetProperty("persistedOperations") + .GetProperty("uploadPersistedOperation") + .GetProperty("errors"); + errors.GetArrayLength().Should().Be(1); + errors[0].GetProperty("code").GetString().Should().Be("SHAPE_DIFF_VIOLATION"); + errors[0].GetProperty("oldFingerprint").GetString().Should().NotBeNullOrEmpty(); + errors[0].GetProperty("newFingerprint").GetString().Should().NotBeNullOrEmpty(); + + // Row unchanged. + var row = await _store.GetAsync("shape_v1", null, CancellationToken.None); + row!.Document.Should().Be(GraphQLFixture.ValidDocument); + } + + [Test] + public async Task Upload_ShapeDiffWithBypass_Succeeds() + { + await _store.UpsertAsync( + "bypass_v1", + GraphQLFixture.ValidDocument, + null, + CancellationToken.None + ); + + var json = await ExecuteMutationAsync( + "bypass_v1", + GraphQLFixture.ShapeChangingDocument, + bypassShapeDiff: true + ); + + json.RootElement.GetProperty("data") + .GetProperty("operations") + .GetProperty("persistedOperations") + .GetProperty("uploadPersistedOperation") + .GetProperty("success") + .GetBoolean() + .Should() + .BeTrue(); + + var row = await _store.GetAsync("bypass_v1", null, CancellationToken.None); + row!.Document.Should().Be(GraphQLFixture.ShapeChangingDocument); + } + + [Test] + public async Task Deactivate_ExistingId_MarksInactive() + { + await _store.UpsertAsync( + "deact_v1", + GraphQLFixture.ValidDocument, + null, + CancellationToken.None + ); + + var json = await Execute( + """ + mutation Deactivate($input: DeactivatePersistedOperationInput!) { + operations { + persistedOperations { + deactivatePersistedOperation(input: $input) { + success + errors { code } + } + } + } + } + """, + new Dictionary + { + ["input"] = new Dictionary + { + ["id"] = "deact_v1", + ["reason"] = "rotating", + }, + } + ); + + json.RootElement.GetProperty("data") + .GetProperty("operations") + .GetProperty("persistedOperations") + .GetProperty("deactivatePersistedOperation") + .GetProperty("success") + .GetBoolean() + .Should() + .BeTrue(); + + (await _store.GetAsync("deact_v1", null, CancellationToken.None)) + .Should() + .BeNull("because GetAsync filters by IsActive"); + } + + [Test] + public async Task Deactivate_UnknownId_ReturnsNotFound() + { + var json = await Execute( + """ + mutation Deactivate($input: DeactivatePersistedOperationInput!) { + operations { + persistedOperations { + deactivatePersistedOperation(input: $input) { + success + errors { code } + } + } + } + } + """, + new Dictionary + { + ["input"] = new Dictionary + { + ["id"] = "does_not_exist_v1", + ["reason"] = "anything", + }, + } + ); + + var errors = json + .RootElement.GetProperty("data") + .GetProperty("operations") + .GetProperty("persistedOperations") + .GetProperty("deactivatePersistedOperation") + .GetProperty("errors"); + errors.GetArrayLength().Should().Be(1); + errors[0].GetProperty("code").GetString().Should().Be("NOT_FOUND"); + } + + [Test] + public async Task Restore_ReactivatesDeactivatedRow() + { + await _store.UpsertAsync( + "restore_v1", + GraphQLFixture.ValidDocument, + null, + CancellationToken.None + ); + await _store.DeactivateAsync("restore_v1", null, "test", CancellationToken.None); + + var json = await Execute( + """ + mutation Restore($input: RestorePersistedOperationInput!) { + operations { + persistedOperations { + restorePersistedOperation(input: $input) { + success + errors { code } + } + } + } + } + """, + new Dictionary + { + ["input"] = new Dictionary { ["id"] = "restore_v1" }, + } + ); + + json.RootElement.GetProperty("data") + .GetProperty("operations") + .GetProperty("persistedOperations") + .GetProperty("restorePersistedOperation") + .GetProperty("success") + .GetBoolean() + .Should() + .BeTrue(); + + var row = await _store.GetAsync("restore_v1", null, CancellationToken.None); + row.Should().NotBeNull(); + row!.IsActive.Should().BeTrue(); + } + + private Task ExecuteMutationAsync( + string id, + string document, + string? description = null, + bool bypassShapeDiff = false + ) + { + const string query = """ + mutation Upload($input: UploadPersistedOperationInput!) { + operations { + persistedOperations { + uploadPersistedOperation(input: $input) { + success + operation { id document shapeFingerprint isActive } + errors { + code + message + oldFingerprint + newFingerprint + locations { line column } + } + } + } + } + } + """; + + var input = new Dictionary + { + ["id"] = id, + ["document"] = document, + ["bypassShapeDiff"] = bypassShapeDiff, + }; + if (description is not null) + input["description"] = description; + + return Execute(query, new Dictionary { ["input"] = input }); + } + + private async Task Execute( + string query, + IReadOnlyDictionary variables + ) + { + var request = OperationRequestBuilder + .New() + .SetDocument(query) + .SetVariableValues(variables.ToDictionary(p => p.Key, p => p.Value)) + .Build(); + var result = await _executor.ExecuteAsync(request); + var op = result as IOperationResult; + op.Should().NotBeNull("expected IOperationResult"); + op!.Errors.Should().BeNullOrEmpty(); + return JsonDocument.Parse(op.ToJson()); + } +} diff --git a/tests/Trax.Api.Tests/PersistedOperations/UnitTests/Exceptions/PersistedOperationParseExceptionTests.cs b/tests/Trax.Api.Tests/PersistedOperations/UnitTests/Exceptions/PersistedOperationParseExceptionTests.cs new file mode 100644 index 0000000..03e46df --- /dev/null +++ b/tests/Trax.Api.Tests/PersistedOperations/UnitTests/Exceptions/PersistedOperationParseExceptionTests.cs @@ -0,0 +1,63 @@ +using FluentAssertions; +using Trax.Api.GraphQL.PersistedOperations.Storage.Exceptions; + +namespace Trax.Api.Tests.PersistedOperations.UnitTests.Exceptions; + +[TestFixture] +public class PersistedOperationParseExceptionTests +{ + [Test] + public void Ctor_WithLineAndColumn_PreservesValues() + { + var ex = new PersistedOperationParseException("unexpected '!'", line: 3, column: 12); + + ex.Line.Should().Be(3); + ex.Column.Should().Be(12); + ex.OriginalMessage.Should().Be("unexpected '!'"); + ex.Code.Should().Be("PARSE_FAILED"); + ex.Message.Should() + .Contain("line 3") + .And.Contain("column 12") + .And.Contain("unexpected '!'"); + } + + [Test] + public void Ctor_WithoutLineColumn_OmitsThemFromMessage() + { + var ex = new PersistedOperationParseException("bad syntax", line: null, column: null); + + ex.Line.Should().BeNull(); + ex.Column.Should().BeNull(); + ex.Message.Should().Contain("bad syntax").And.NotContain("line").And.NotContain("column"); + } + + [Test] + public void Ctor_NullOrWhitespaceMessage_ThrowsArgumentException() + { + Action a = () => new PersistedOperationParseException(null!, 1, 1); + Action b = () => new PersistedOperationParseException("", 1, 1); + Action c = () => new PersistedOperationParseException(" ", 1, 1); + + a.Should().Throw(); + b.Should().Throw(); + c.Should().Throw(); + } + + [Test] + public void InheritsFromPersistedOperationException() + { + var ex = new PersistedOperationParseException("x", 1, 1); + + ex.Should().BeAssignableTo(); + ex.Should().BeAssignableTo(); + } + + [Test] + public void Ctor_WithInnerException_PreservesInner() + { + var inner = new InvalidOperationException("root cause"); + var ex = new PersistedOperationParseException("bad", 1, 1, inner); + + ex.InnerException.Should().BeSameAs(inner); + } +} diff --git a/tests/Trax.Api.Tests/PersistedOperations/UnitTests/Exceptions/PersistedOperationValidationExceptionTests.cs b/tests/Trax.Api.Tests/PersistedOperations/UnitTests/Exceptions/PersistedOperationValidationExceptionTests.cs new file mode 100644 index 0000000..e12ed06 --- /dev/null +++ b/tests/Trax.Api.Tests/PersistedOperations/UnitTests/Exceptions/PersistedOperationValidationExceptionTests.cs @@ -0,0 +1,84 @@ +using FluentAssertions; +using Trax.Api.GraphQL.PersistedOperations.Storage.Exceptions; + +namespace Trax.Api.Tests.PersistedOperations.UnitTests.Exceptions; + +[TestFixture] +public class PersistedOperationValidationExceptionTests +{ + [Test] + public void Ctor_WithFailures_PreservesList() + { + var failures = new[] + { + new ValidationFailure( + "Cannot query field 'foo'", + new[] { new ValidationFailureLocation(2, 5) }, + new object[] { "foo" } + ), + ValidationFailure.FromMessage("Variable $x is not defined"), + }; + + var ex = new PersistedOperationValidationException(failures); + + ex.Failures.Should().HaveCount(2); + ex.Failures[0].Message.Should().Be("Cannot query field 'foo'"); + ex.Failures[0] + .Locations.Should() + .ContainSingle() + .Which.Should() + .Be(new ValidationFailureLocation(2, 5)); + ex.Failures[1].Message.Should().Be("Variable $x is not defined"); + ex.Code.Should().Be("SCHEMA_VALIDATION_FAILED"); + } + + [Test] + public void Ctor_EmptyFailures_ThrowsArgumentException() + { + Action a = () => + new PersistedOperationValidationException(Array.Empty()); + a.Should().Throw(); + } + + [Test] + public void Ctor_NullFailures_ThrowsArgumentNullException() + { + Action a = () => new PersistedOperationValidationException(null!); + a.Should().Throw(); + } + + [Test] + public void Message_SingleFailure_QuotesIt() + { + var ex = new PersistedOperationValidationException( + new[] { ValidationFailure.FromMessage("only one") } + ); + + ex.Message.Should().Contain("only one"); + } + + [Test] + public void Message_MultipleFailures_ReportsCount() + { + var ex = new PersistedOperationValidationException( + new[] + { + ValidationFailure.FromMessage("first"), + ValidationFailure.FromMessage("second"), + ValidationFailure.FromMessage("third"), + } + ); + + ex.Message.Should().Contain("3 errors").And.Contain("first"); + } + + [Test] + public void InheritsFromPersistedOperationException() + { + var ex = new PersistedOperationValidationException( + new[] { ValidationFailure.FromMessage("x") } + ); + + ex.Should().BeAssignableTo(); + } +} diff --git a/tests/Trax.Api.Tests/PersistedOperations/UnitTests/Exceptions/ShapeDiffViolationExceptionTests.cs b/tests/Trax.Api.Tests/PersistedOperations/UnitTests/Exceptions/ShapeDiffViolationExceptionTests.cs new file mode 100644 index 0000000..28951fd --- /dev/null +++ b/tests/Trax.Api.Tests/PersistedOperations/UnitTests/Exceptions/ShapeDiffViolationExceptionTests.cs @@ -0,0 +1,39 @@ +using FluentAssertions; +using Trax.Api.GraphQL.PersistedOperations.Storage; +using Trax.Api.GraphQL.PersistedOperations.Storage.Exceptions; + +namespace Trax.Api.Tests.PersistedOperations.UnitTests.Exceptions; + +[TestFixture] +public class ShapeDiffViolationExceptionTests +{ + private const string OldFp = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"; + private const string NewFp = "fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321"; + + [Test] + public void Ctor_PreservesFingerprintsAndId() + { + var ex = new ShapeDiffViolationException("op_v1", OldFp, NewFp); + + ex.Id.Should().Be("op_v1"); + ex.OldFingerprint.Should().Be(OldFp); + ex.NewFingerprint.Should().Be(NewFp); + } + + [Test] + public void InheritsFromPersistedOperationException() + { + var ex = new ShapeDiffViolationException("op_v1", OldFp, NewFp); + + ex.Should().BeAssignableTo(); + ex.Code.Should().Be("SHAPE_DIFF_VIOLATION"); + } + + [Test] + public void Message_IncludesIdAndAbbreviatedFingerprints() + { + var ex = new ShapeDiffViolationException("op_v1", OldFp, NewFp); + + ex.Message.Should().Contain("op_v1").And.Contain(OldFp[..8]).And.Contain(NewFp[..8]); + } +} diff --git a/tests/Trax.Api.Tests/PersistedOperations/UnitTests/PersistedOperationIdParserTests.cs b/tests/Trax.Api.Tests/PersistedOperations/UnitTests/PersistedOperationIdParserTests.cs deleted file mode 100644 index 3463e8c..0000000 --- a/tests/Trax.Api.Tests/PersistedOperations/UnitTests/PersistedOperationIdParserTests.cs +++ /dev/null @@ -1,60 +0,0 @@ -using FluentAssertions; -using Trax.Api.GraphQL.PersistedOperations.Storage; - -namespace Trax.Api.Tests.PersistedOperations.UnitTests; - -[TestFixture] -public class PersistedOperationIdParserTests -{ - [TestCase("userProfile_v1", "userProfile", 1)] - [TestCase("Echo_v42", "Echo", 42)] - [TestCase("_camel_v0", "_camel", 0)] - [TestCase("with_underscore_5_v999", "with_underscore_5", 999)] - public void Parse_ValidId_ReturnsNameAndVersion(string id, string name, int version) - { - var parsed = PersistedOperationIdParser.Parse(id); - parsed.Name.Should().Be(name); - parsed.Version.Should().Be(version); - } - - [TestCase("userProfile")] - [TestCase("userProfile_")] - [TestCase("userProfile_v")] - [TestCase("userProfile_va")] - [TestCase("_v1")] - [TestCase("1userProfile_v1")] - [TestCase("user-profile_v1")] - [TestCase("userProfile_V1")] - [TestCase("userProfile_v-1")] - [TestCase("userProfile.v1")] - public void Parse_InvalidId_Throws(string id) - { - Action act = () => PersistedOperationIdParser.Parse(id); - act.Should().Throw().WithMessage("*name_vN*"); - } - - [Test] - public void Parse_NullId_Throws() - { - Action act = () => PersistedOperationIdParser.Parse(null!); - act.Should().Throw(); - } - - [Test] - public void Parse_EmptyId_Throws() - { - Action act = () => PersistedOperationIdParser.Parse(string.Empty); - act.Should().Throw(); - } - - [TestCase("userProfile_v1", true)] - [TestCase("userProfile", false)] - [TestCase("userProfile.v1", false)] - [TestCase("", false)] - public void IsValid_ReportsCorrectly(string id, bool expected) => - PersistedOperationIdParser.IsValid(id).Should().Be(expected); - - [Test] - public void IsValid_Null_ReturnsFalse() => - PersistedOperationIdParser.IsValid(null!).Should().BeFalse(); -} diff --git a/tests/Trax.Api.Tests/PersistedOperations/UnitTests/Validation/NoOpPersistedOperationValidatorTests.cs b/tests/Trax.Api.Tests/PersistedOperations/UnitTests/Validation/NoOpPersistedOperationValidatorTests.cs new file mode 100644 index 0000000..8f74be5 --- /dev/null +++ b/tests/Trax.Api.Tests/PersistedOperations/UnitTests/Validation/NoOpPersistedOperationValidatorTests.cs @@ -0,0 +1,34 @@ +using FluentAssertions; +using Trax.Api.GraphQL.PersistedOperations.Storage.Validation; + +namespace Trax.Api.Tests.PersistedOperations.UnitTests.Validation; + +[TestFixture] +public class NoOpPersistedOperationValidatorTests +{ + [TestCase("")] + [TestCase(" ")] + [TestCase("not a graphql document")] + [TestCase("query Q { foo }")] + [TestCase("{ malformed")] + public async Task ValidateAsync_AnyInput_DoesNotThrow(string document) + { + var validator = new NoOpPersistedOperationValidator(); + + Func act = () => validator.ValidateAsync(document, CancellationToken.None); + + await act.Should().NotThrowAsync(); + } + + [Test] + public async Task ValidateAsync_PreCancelledToken_ReturnsCancelled() + { + var validator = new NoOpPersistedOperationValidator(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + Func act = () => validator.ValidateAsync("query Q { x }", cts.Token); + + await act.Should().ThrowAsync(); + } +} From 0cf871b2fc7b6c7b4d78b692473ed0da46060d1a Mon Sep 17 00:00:00 2001 From: Theaux Masquelier <43664045+Theauxm@users.noreply.github.com> Date: Mon, 11 May 2026 12:48:52 -0600 Subject: [PATCH 2/3] feat: raise default GraphQL execution depth to 15 The previous default of 4 was tight enough to reject the new operations.persistedOperations.uploadPersistedOperation payload (operations -> persistedOperations -> uploadPersistedOperation -> errors -> locations -> line is 6 levels), and the earlier "narrow but useful" justification did not survive contact with nested management namespaces or model projections across several FK hops. 15 is generous enough to cover every Trax-shipped surface (management namespaces, model query chains, train discover trees) and any realistic hand-written query, while still rejecting pathological exhaustion probes that nest hundreds of selections deep. Hosts running a public-facing schema can still tighten via MaxExecutionDepth(n). Removes the conditional bump UsePersistedOperations was applying for its own management surface; the new default covers it. --- ...axGraphQLBuilderPersistedOperationsExtensions.cs | 7 ------- .../Configuration/GraphQLConfiguration.cs | 4 ++-- .../TraxGraphQLBuilder.Hardening.cs | 13 +++++++------ .../Extensions/GraphQLServiceExtensions.cs | 2 +- tests/Trax.Api.Tests/AuthE2E/AuthE2EHost.cs | 7 +++---- tests/Trax.Api.Tests/GraphQLHardeningTests.cs | 6 +++--- 6 files changed, 16 insertions(+), 23 deletions(-) diff --git a/src/Trax.Api.GraphQL.PersistedOperations/Extensions/TraxGraphQLBuilderPersistedOperationsExtensions.cs b/src/Trax.Api.GraphQL.PersistedOperations/Extensions/TraxGraphQLBuilderPersistedOperationsExtensions.cs index 162e9d2..73eb247 100644 --- a/src/Trax.Api.GraphQL.PersistedOperations/Extensions/TraxGraphQLBuilderPersistedOperationsExtensions.cs +++ b/src/Trax.Api.GraphQL.PersistedOperations/Extensions/TraxGraphQLBuilderPersistedOperationsExtensions.cs @@ -111,13 +111,6 @@ Action configure builder.ExposeOperationMutations(); builder.AddTypeExtensions(typeof(GraphQL.PersistedOperationMutations).Assembly); - // The management surface lives under operations.persistedOperations and - // returns a payload with nested operation/errors objects, so the deepest - // query (errors.locations.line) is 6 levels under the root. Raise the - // default depth limit if the consumer has not already overridden it. - if (!builder.MaxExecutionDepthWasOverridden && builder.MaxExecutionDepthValue < 8) - builder.MaxExecutionDepth(8); - // Storage: implements both IPersistedOperationStore and the HC hot-path. services.AddSingleton(); services.AddSingleton(sp => diff --git a/src/Trax.Api.GraphQL/Configuration/GraphQLConfiguration.cs b/src/Trax.Api.GraphQL/Configuration/GraphQLConfiguration.cs index e48eb4a..c3e5c16 100644 --- a/src/Trax.Api.GraphQL/Configuration/GraphQLConfiguration.cs +++ b/src/Trax.Api.GraphQL/Configuration/GraphQLConfiguration.cs @@ -39,7 +39,7 @@ public class GraphQLConfiguration internal HashSet RegisteredNamespaceTypes { get; } = new(StringComparer.Ordinal); /// - /// Max GraphQL execution depth (default 4). Queries deeper than this are rejected + /// Max GraphQL execution depth (default 15). Queries deeper than this are rejected /// during validation. /// public int MaxExecutionDepth { get; } @@ -94,7 +94,7 @@ public GraphQLConfiguration( IReadOnlyList additionalTypeModules, IReadOnlyList> schemaConfigurations, IReadOnlyList additionalTypeExtensions, - int maxExecutionDepth = 4, + int maxExecutionDepth = 15, Action? costOverride = null, Predicate? introspectionPredicate = null, int maxOperationsPerRequest = 50, diff --git a/src/Trax.Api.GraphQL/Configuration/TraxGraphQLBuilder/TraxGraphQLBuilder.Hardening.cs b/src/Trax.Api.GraphQL/Configuration/TraxGraphQLBuilder/TraxGraphQLBuilder.Hardening.cs index 2510be1..25e2b58 100644 --- a/src/Trax.Api.GraphQL/Configuration/TraxGraphQLBuilder/TraxGraphQLBuilder.Hardening.cs +++ b/src/Trax.Api.GraphQL/Configuration/TraxGraphQLBuilder/TraxGraphQLBuilder.Hardening.cs @@ -10,7 +10,7 @@ namespace Trax.Api.GraphQL.Configuration.TraxGraphQLBuilder; /// public partial class TraxGraphQLBuilder { - internal int MaxExecutionDepthValue { get; private set; } = 4; + internal int MaxExecutionDepthValue { get; private set; } = 15; internal bool MaxExecutionDepthWasOverridden { get; private set; } internal Action? CostOverride { get; private set; } @@ -24,11 +24,12 @@ public partial class TraxGraphQLBuilder internal string? AuthorizationPolicy { get; private set; } /// - /// Sets the maximum GraphQL query depth. The default is 4. - /// Callers that legitimately need deeper queries (e.g. model projections - /// with several foreign-key hops) should raise this deliberately; the - /// default is deep enough for train-shaped operations but rejects - /// resource-exhaustion probes. + /// Sets the maximum GraphQL query depth. The default is 15. + /// Tighten this when running a public-facing schema where you want to + /// reject deeply nested resource-exhaustion probes; loosen it (or keep + /// the default) when consumers legitimately need deep model projections + /// across several foreign-key hops or nested management namespaces like + /// operations.persistedOperations. /// public TraxGraphQLBuilder MaxExecutionDepth(int depth) { diff --git a/src/Trax.Api.GraphQL/Extensions/GraphQLServiceExtensions.cs b/src/Trax.Api.GraphQL/Extensions/GraphQLServiceExtensions.cs index cf8b998..94a0d3f 100644 --- a/src/Trax.Api.GraphQL/Extensions/GraphQLServiceExtensions.cs +++ b/src/Trax.Api.GraphQL/Extensions/GraphQLServiceExtensions.cs @@ -298,7 +298,7 @@ private static void ApplyHardeningDefaults( GraphQLConfiguration config ) { - // G1 — Max execution depth. Defaults to 4 unless the consumer overrides. + // G1 — Max execution depth. Defaults to 15 unless the consumer overrides. graphqlBuilder.AddMaxExecutionDepthRule( config.MaxExecutionDepth, skipIntrospectionFields: true diff --git a/tests/Trax.Api.Tests/AuthE2E/AuthE2EHost.cs b/tests/Trax.Api.Tests/AuthE2E/AuthE2EHost.cs index 8b95b09..ca7d931 100644 --- a/tests/Trax.Api.Tests/AuthE2E/AuthE2EHost.cs +++ b/tests/Trax.Api.Tests/AuthE2E/AuthE2EHost.cs @@ -114,10 +114,9 @@ public static async Task StartAsync(Schemes schemes, string database) services.AddTraxGraphQL(graphql => graphql - // Model-query chain (discover/namespace/entity/nodes/field) - // needs depth 6; default is 4. Tests are explicit about - // their shape so raising the cap here is safe. - .MaxExecutionDepth(8) + // Default is generous (15) and covers the + // discover/namespace/entity/nodes/field chain + // without needing an explicit override. .AddDbContext() .AddTypeExtensions(typeof(AuthE2EHost).Assembly) // Add custom subscription + mutation type diff --git a/tests/Trax.Api.Tests/GraphQLHardeningTests.cs b/tests/Trax.Api.Tests/GraphQLHardeningTests.cs index 0656954..713d158 100644 --- a/tests/Trax.Api.Tests/GraphQLHardeningTests.cs +++ b/tests/Trax.Api.Tests/GraphQLHardeningTests.cs @@ -17,11 +17,11 @@ public class GraphQLHardeningTests #region Builder defaults [Test] - public void MaxExecutionDepth_DefaultIs4() + public void MaxExecutionDepth_DefaultIs15() { var builder = new TraxGraphQLBuilder(new ServiceCollection()); - builder.MaxExecutionDepthValue.Should().Be(4); + builder.MaxExecutionDepthValue.Should().Be(15); builder.MaxExecutionDepthWasOverridden.Should().BeFalse(); } @@ -225,7 +225,7 @@ public void Build_NoOverrides_DefaultsApplied() var config = builder.Build(); - config.MaxExecutionDepth.Should().Be(4); + config.MaxExecutionDepth.Should().Be(15); config.MaxOperationsPerRequest.Should().Be(50); } From ced84a0734492be939bf6df7e28817ceacc05b08 Mon Sep 17 00:00:00 2001 From: Theaux Masquelier <43664045+Theauxm@users.noreply.github.com> Date: Mon, 11 May 2026 12:53:10 -0600 Subject: [PATCH 3/3] test: cover persisted-operations query surface and mutation input branches Codecov flagged gaps in the management surface. Closing them with behaviour-specific tests, not coverage padding: - PersistedOperationQueryTests: list ordering by UpdatedAt desc, filter-by-active and filter-by-id-prefix, take/skip pagination, single lookup returning every projected column, missing-id returning null, history newest-first with one row per upsert/deactivate, and empty array for unknown id. - PersistedOperationMutationTests: INVALID_INPUT branches for empty id and empty document on upload, empty reason on deactivate (audit-log integrity), empty id on deactivate and restore, and NOT_FOUND on restoring an id that never existed. - PersistedOperationsMiddlewareTests: two new bypass cases proving any document referencing operations.persistedOperations passes through enforcement (chicken-and-egg with persisting the upload mutation). --- .../PersistedOperationMutationTests.cs | 205 +++++++++++++ .../PersistedOperationQueryTests.cs | 290 ++++++++++++++++++ .../PersistedOperationsMiddlewareTests.cs | 41 +++ 3 files changed, 536 insertions(+) create mode 100644 tests/Trax.Api.Tests/PersistedOperations/IntegrationTests/PersistedOperationQueryTests.cs diff --git a/tests/Trax.Api.Tests/PersistedOperations/IntegrationTests/PersistedOperationMutationTests.cs b/tests/Trax.Api.Tests/PersistedOperations/IntegrationTests/PersistedOperationMutationTests.cs index 948bec4..e351fa3 100644 --- a/tests/Trax.Api.Tests/PersistedOperations/IntegrationTests/PersistedOperationMutationTests.cs +++ b/tests/Trax.Api.Tests/PersistedOperations/IntegrationTests/PersistedOperationMutationTests.cs @@ -286,6 +286,211 @@ mutation Restore($input: RestorePersistedOperationInput!) { row!.IsActive.Should().BeTrue(); } + [Test] + public async Task Upload_EmptyId_ReturnsInvalidInput_AndDoesNotPersist() + { + var json = await ExecuteMutationAsync( + id: string.Empty, + document: GraphQLFixture.ValidDocument + ); + + var payload = json + .RootElement.GetProperty("data") + .GetProperty("operations") + .GetProperty("persistedOperations") + .GetProperty("uploadPersistedOperation"); + payload.GetProperty("success").GetBoolean().Should().BeFalse(); + payload + .GetProperty("errors")[0] + .GetProperty("code") + .GetString() + .Should() + .Be("INVALID_INPUT"); + payload.GetProperty("errors")[0].GetProperty("message").GetString().Should().Contain("id"); + } + + [Test] + public async Task Upload_EmptyDocument_ReturnsInvalidInput() + { + var json = await ExecuteMutationAsync(id: "ok_v1", document: string.Empty); + + var payload = json + .RootElement.GetProperty("data") + .GetProperty("operations") + .GetProperty("persistedOperations") + .GetProperty("uploadPersistedOperation"); + payload.GetProperty("success").GetBoolean().Should().BeFalse(); + payload + .GetProperty("errors")[0] + .GetProperty("code") + .GetString() + .Should() + .Be("INVALID_INPUT"); + payload + .GetProperty("errors")[0] + .GetProperty("message") + .GetString() + .Should() + .Contain("document"); + + (await _store.GetAsync("ok_v1", null, CancellationToken.None)).Should().BeNull(); + } + + [Test] + public async Task Deactivate_EmptyReason_ReturnsInvalidInput_AndKeepsRowActive() + { + // Reason is required for audit accountability; passing an empty + // string must be rejected rather than silently writing "" to the + // deprecation_reason column. + await _store.UpsertAsync( + "deact_noreason_v1", + GraphQLFixture.ValidDocument, + null, + CancellationToken.None + ); + + var json = await Execute( + """ + mutation Deactivate($input: DeactivatePersistedOperationInput!) { + operations { + persistedOperations { + deactivatePersistedOperation(input: $input) { + success + errors { code message } + } + } + } + } + """, + new Dictionary + { + ["input"] = new Dictionary + { + ["id"] = "deact_noreason_v1", + ["reason"] = " ", + }, + } + ); + + var payload = json + .RootElement.GetProperty("data") + .GetProperty("operations") + .GetProperty("persistedOperations") + .GetProperty("deactivatePersistedOperation"); + payload.GetProperty("success").GetBoolean().Should().BeFalse(); + payload + .GetProperty("errors")[0] + .GetProperty("code") + .GetString() + .Should() + .Be("INVALID_INPUT"); + + var row = await _store.GetAsync("deact_noreason_v1", null, CancellationToken.None); + row.Should().NotBeNull("the rejected deactivate must not have run"); + row!.IsActive.Should().BeTrue(); + } + + [Test] + public async Task Restore_UnknownId_ReturnsNotFound() + { + var json = await Execute( + """ + mutation Restore($input: RestorePersistedOperationInput!) { + operations { + persistedOperations { + restorePersistedOperation(input: $input) { + success + errors { code } + } + } + } + } + """, + new Dictionary + { + ["input"] = new Dictionary { ["id"] = "never_existed_v1" }, + } + ); + + var payload = json + .RootElement.GetProperty("data") + .GetProperty("operations") + .GetProperty("persistedOperations") + .GetProperty("restorePersistedOperation"); + payload.GetProperty("success").GetBoolean().Should().BeFalse(); + payload.GetProperty("errors")[0].GetProperty("code").GetString().Should().Be("NOT_FOUND"); + } + + [Test] + public async Task Restore_EmptyId_ReturnsInvalidInput() + { + var json = await Execute( + """ + mutation Restore($input: RestorePersistedOperationInput!) { + operations { + persistedOperations { + restorePersistedOperation(input: $input) { + success + errors { code } + } + } + } + } + """, + new Dictionary + { + ["input"] = new Dictionary { ["id"] = "" }, + } + ); + + var payload = json + .RootElement.GetProperty("data") + .GetProperty("operations") + .GetProperty("persistedOperations") + .GetProperty("restorePersistedOperation"); + payload + .GetProperty("errors")[0] + .GetProperty("code") + .GetString() + .Should() + .Be("INVALID_INPUT"); + } + + [Test] + public async Task Deactivate_EmptyId_ReturnsInvalidInput() + { + var json = await Execute( + """ + mutation Deactivate($input: DeactivatePersistedOperationInput!) { + operations { + persistedOperations { + deactivatePersistedOperation(input: $input) { + success + errors { code } + } + } + } + } + """, + new Dictionary + { + ["input"] = new Dictionary { ["id"] = "", ["reason"] = "x" }, + } + ); + + var payload = json + .RootElement.GetProperty("data") + .GetProperty("operations") + .GetProperty("persistedOperations") + .GetProperty("deactivatePersistedOperation"); + payload + .GetProperty("errors")[0] + .GetProperty("code") + .GetString() + .Should() + .Be("INVALID_INPUT"); + } + private Task ExecuteMutationAsync( string id, string document, diff --git a/tests/Trax.Api.Tests/PersistedOperations/IntegrationTests/PersistedOperationQueryTests.cs b/tests/Trax.Api.Tests/PersistedOperations/IntegrationTests/PersistedOperationQueryTests.cs new file mode 100644 index 0000000..cc6becd --- /dev/null +++ b/tests/Trax.Api.Tests/PersistedOperations/IntegrationTests/PersistedOperationQueryTests.cs @@ -0,0 +1,290 @@ +using System.Text.Json; +using FluentAssertions; +using HotChocolate; +using HotChocolate.Execution; +using Microsoft.Extensions.DependencyInjection; +using Trax.Api.GraphQL.PersistedOperations.Storage; +using Trax.Api.Tests.PersistedOperations.Fixtures; + +namespace Trax.Api.Tests.PersistedOperations.IntegrationTests; + +/// +/// Drives the management queries end-to-end through HotChocolate. Each test +/// seeds rows via and then exercises +/// the GraphQL surface, asserting on filter, ordering, pagination, and +/// per-column projection. Failure of any of these signals a specific +/// regression named in the test method. +/// +[TestFixture] +[Category("Integration")] +public class PersistedOperationQueryTests +{ + private ServiceProvider _sp = null!; + private IRequestExecutor _executor = null!; + private IPersistedOperationStore _store = null!; + + [OneTimeSetUp] + public async Task OneTimeSetUp() + { + if (!PostgresFixture.IsPostgresReachable()) + Assert.Ignore("Postgres not reachable; skipping integration tests."); + + _sp = await GraphQLFixture.BuildAsync(); + _executor = await GraphQLFixture.GetExecutorAsync(_sp); + _store = _sp.GetRequiredService(); + } + + [OneTimeTearDown] + public async Task OneTimeTearDown() + { + if (_sp is not null) + await _sp.DisposeAsync(); + } + + [SetUp] + public async Task SetUp() => await PostgresFixture.ClearAsync(); + + [Test] + public async Task List_NoFilter_ReturnsAllRows_NewestUpdatedFirst() + { + // Seed three rows in order; ordering of the returned page must + // reflect UpdatedAt descending, not insertion order. + await _store.UpsertAsync("a_v1", GraphQLFixture.ValidDocument, null, default); + await _store.UpsertAsync("b_v1", GraphQLFixture.ValidDocument, null, default); + await _store.UpsertAsync("c_v1", GraphQLFixture.ValidDocument, null, default); + // Touch a_v1 so its UpdatedAt moves to the top. + await _store.UpsertAsync( + "a_v1", + GraphQLFixture.ValidDocument, + new UpsertOptions { Description = "touched" }, + default + ); + + var page = await ListAsync(filter: null); + + page.GetProperty("totalCount").GetInt32().Should().Be(3); + var ids = page.GetProperty("items") + .EnumerateArray() + .Select(e => e.GetProperty("id").GetString()) + .ToList(); + ids[0].Should().Be("a_v1", "a_v1 was most recently upserted"); + ids.Should().Contain(new[] { "b_v1", "c_v1" }); + } + + [Test] + public async Task List_FilterByActiveFalse_ReturnsOnlyDeactivated() + { + await _store.UpsertAsync("active_v1", GraphQLFixture.ValidDocument, null, default); + await _store.UpsertAsync("inactive_v1", GraphQLFixture.ValidDocument, null, default); + await _store.DeactivateAsync("inactive_v1", null, "test", default); + + var page = await ListAsync( + filter: new Dictionary { ["isActive"] = false } + ); + + page.GetProperty("totalCount").GetInt32().Should().Be(1); + page.GetProperty("items")[0].GetProperty("id").GetString().Should().Be("inactive_v1"); + page.GetProperty("items")[0].GetProperty("isActive").GetBoolean().Should().BeFalse(); + } + + [Test] + public async Task List_FilterByIdPrefix_ReturnsOnlyMatching() + { + await _store.UpsertAsync("greet_v1", GraphQLFixture.ValidDocument, null, default); + await _store.UpsertAsync("greet_v2", GraphQLFixture.ValidDocument, null, default); + await _store.UpsertAsync("lookup_v1", GraphQLFixture.ValidDocument, null, default); + + var page = await ListAsync( + filter: new Dictionary { ["idStartsWith"] = "greet" } + ); + + page.GetProperty("totalCount").GetInt32().Should().Be(2); + page.GetProperty("items") + .EnumerateArray() + .Select(e => e.GetProperty("id").GetString()) + .Should() + .BeEquivalentTo("greet_v1", "greet_v2"); + } + + [Test] + public async Task List_Pagination_TakeAndSkipReturnTheCorrectWindow() + { + for (var i = 0; i < 5; i++) + await _store.UpsertAsync($"op_{i}_v1", GraphQLFixture.ValidDocument, null, default); + + var page = await ListAsync(filter: null, take: 2, skip: 1); + + // totalCount counts the full result, page items reflect the window. + page.GetProperty("totalCount").GetInt32().Should().Be(5); + page.GetProperty("items").GetArrayLength().Should().Be(2); + } + + [Test] + public async Task Single_ExistingId_ReturnsRowWithEveryColumn() + { + await _store.UpsertAsync( + "single_v1", + GraphQLFixture.ValidDocument, + new UpsertOptions { Description = "first upload" }, + default + ); + + var row = await SingleAsync("single_v1"); + + row.ValueKind.Should().NotBe(JsonValueKind.Null); + row.GetProperty("id").GetString().Should().Be("single_v1"); + // OperationName is taken from the document, not the id, post-refactor. + row.GetProperty("operationName").GetString().Should().Be("Greet"); + row.GetProperty("description").GetString().Should().Be("first upload"); + row.GetProperty("isActive").GetBoolean().Should().BeTrue(); + row.GetProperty("shapeFingerprint").GetString().Should().HaveLength(64); + row.GetProperty("document").GetString().Should().Be(GraphQLFixture.ValidDocument); + } + + [Test] + public async Task Single_MissingId_ReturnsNull() + { + var row = await SingleAsync("does_not_exist_v1"); + + row.ValueKind.Should().Be(JsonValueKind.Null); + } + + [Test] + public async Task History_ReturnsOneRowPerUpsertAndDeactivate_NewestFirst() + { + await _store.UpsertAsync("history_v1", GraphQLFixture.ValidDocument, null, default); + await _store.UpsertAsync( + "history_v1", + GraphQLFixture.ValidDocument, + new UpsertOptions { Description = "second upsert" }, + default + ); + await _store.DeactivateAsync("history_v1", null, "rotating", default); + + var entries = await HistoryAsync("history_v1"); + + // Two upserts + one deactivate = three history rows. + entries.GetArrayLength().Should().Be(3); + // Most recent first: deactivate, then second upsert, then first. + entries[0].GetProperty("changeType").GetString().Should().Be("Deactivate"); + entries[0].GetProperty("changedReason").GetString().Should().Be("rotating"); + } + + [Test] + public async Task History_UnknownId_ReturnsEmptyArray() + { + var entries = await HistoryAsync("never_existed_v1"); + + entries.GetArrayLength().Should().Be(0); + } + + private async Task ListAsync( + IReadOnlyDictionary? filter, + int? take = null, + int? skip = null + ) + { + var variables = new Dictionary(); + if (filter is not null) + variables["filter"] = filter; + if (take is not null) + variables["take"] = take; + if (skip is not null) + variables["skip"] = skip; + + var json = await Execute( + """ + query List($filter: PersistedOperationFilterInput, $take: Int! = 50, $skip: Int! = 0) { + operations { + persistedOperations { + persistedOperations(filter: $filter, take: $take, skip: $skip) { + totalCount + items { id operationName isActive shapeFingerprint description document } + } + } + } + } + """, + variables + ); + + return json + .RootElement.GetProperty("data") + .GetProperty("operations") + .GetProperty("persistedOperations") + .GetProperty("persistedOperations"); + } + + private async Task SingleAsync(string id) + { + var json = await Execute( + """ + query Single($id: String!) { + operations { + persistedOperations { + persistedOperation(id: $id) { + id + operationName + isActive + document + shapeFingerprint + description + } + } + } + } + """, + new Dictionary { ["id"] = id } + ); + + return json + .RootElement.GetProperty("data") + .GetProperty("operations") + .GetProperty("persistedOperations") + .GetProperty("persistedOperation"); + } + + private async Task HistoryAsync(string id) + { + var json = await Execute( + """ + query History($id: String!) { + operations { + persistedOperations { + persistedOperationHistory(id: $id) { + historyId + changeType + changedReason + shapeFingerprint + } + } + } + } + """, + new Dictionary { ["id"] = id } + ); + + return json + .RootElement.GetProperty("data") + .GetProperty("operations") + .GetProperty("persistedOperations") + .GetProperty("persistedOperationHistory"); + } + + private async Task Execute( + string query, + IReadOnlyDictionary variables + ) + { + var request = OperationRequestBuilder + .New() + .SetDocument(query) + .SetVariableValues(variables.ToDictionary(p => p.Key, p => p.Value)) + .Build(); + var result = await _executor.ExecuteAsync(request); + var op = result as IOperationResult; + op.Should().NotBeNull(); + op!.Errors.Should().BeNullOrEmpty(); + return JsonDocument.Parse(op.ToJson()); + } +} diff --git a/tests/Trax.Api.Tests/PersistedOperations/UnitTests/PersistedOperationsMiddlewareTests.cs b/tests/Trax.Api.Tests/PersistedOperations/UnitTests/PersistedOperationsMiddlewareTests.cs index 8d28610..3f68489 100644 --- a/tests/Trax.Api.Tests/PersistedOperations/UnitTests/PersistedOperationsMiddlewareTests.cs +++ b/tests/Trax.Api.Tests/PersistedOperations/UnitTests/PersistedOperationsMiddlewareTests.cs @@ -123,6 +123,47 @@ public async Task InlineQuery_AllowlistedByName_PassesThrough() calls.NextCalls.Should().Be(1); } + [Test] + public async Task InlineQuery_ManagementMutation_BypassesEnforcement() + { + // The management mutations (uploadPersistedOperation et al.) and + // queries (persistedOperations, persistedOperationHistory) live under + // operations.persistedOperations and MUST bypass RequirePersisted — + // persisting the upload mutation by id is a chicken-and-egg. The + // bypass detects any document referencing the persistedOperations + // namespace. + var (mw, calls) = Build(b => b.RequirePersisted(true)); + var ctx = BuildContext( + query: """ + mutation { + operations { + persistedOperations { + uploadPersistedOperation(input: { id: "x", document: "{ x }" }) { success } + } + } + } + """, + operationName: null + ); + + await mw.InvokeAsync(ctx); + + calls.NextCalls.Should().Be(1, "the management mutation must reach the GraphQL endpoint"); + } + + [Test] + public async Task InlineQuery_ManagementQuery_BypassesEnforcement() + { + var (mw, calls) = Build(b => b.RequirePersisted(true)); + var ctx = BuildContext( + query: "query { operations { persistedOperations { persistedOperations { totalCount } } } }" + ); + + await mw.InvokeAsync(ctx); + + calls.NextCalls.Should().Be(1); + } + [Test] public async Task InlineQuery_AllowlistedByPredicate_PassesThrough() {