diff --git a/.github/workflows/nuget_release.yml b/.github/workflows/nuget_release.yml
index cd66567..3bee542 100644
--- a/.github/workflows/nuget_release.yml
+++ b/.github/workflows/nuget_release.yml
@@ -71,9 +71,10 @@ jobs:
env:
PGPASSWORD: trax123
run: |
- for db in trax_api_operations trax_api_workqueue trax_api_logs trax_api_health \
- trax_api_auth_http trax_api_auth_model trax_api_auth_sub \
- trax_api_auth_subprop trax_api_auth_authorize; do
+ # AuthE2E fixtures self-provision via AuthE2EHost.EnsureDatabaseExists,
+ # so new test classes in that suite require no changes here. Other
+ # E2E test categories still rely on this explicit list.
+ for db in trax_api_operations trax_api_workqueue trax_api_logs trax_api_health; do
psql -h localhost -U trax -d trax -c "CREATE DATABASE $db;"
done
diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml
index 1328742..4d513c0 100644
--- a/.github/workflows/pull_request.yml
+++ b/.github/workflows/pull_request.yml
@@ -54,10 +54,11 @@ jobs:
- name: Create per-fixture test databases
env:
PGPASSWORD: trax123
+ # AuthE2E fixtures self-provision via AuthE2EHost.EnsureDatabaseExists,
+ # so new test classes in that suite require no changes here. Other
+ # E2E test categories still rely on this explicit list.
run: |
- for db in trax_api_operations trax_api_workqueue trax_api_logs trax_api_health \
- trax_api_auth_http trax_api_auth_model trax_api_auth_sub \
- trax_api_auth_subprop trax_api_auth_authorize; do
+ for db in trax_api_operations trax_api_workqueue trax_api_logs trax_api_health; do
psql -h localhost -U trax -d trax -c "CREATE DATABASE $db;"
done
- run: dotnet test --configuration Release --no-build --collect:"XPlat Code Coverage" --results-directory ./TestResults
diff --git a/src/Trax.Api.GraphQL/Authorization/QueryModelAuthenticationInterceptor.cs b/src/Trax.Api.GraphQL/Authorization/QueryModelAuthenticationInterceptor.cs
new file mode 100644
index 0000000..6c97b93
--- /dev/null
+++ b/src/Trax.Api.GraphQL/Authorization/QueryModelAuthenticationInterceptor.cs
@@ -0,0 +1,73 @@
+using HotChocolate.AspNetCore;
+using HotChocolate.Execution;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Http;
+
+namespace Trax.Api.GraphQL.Authorization;
+
+///
+/// HotChocolate HTTP request interceptor that populates
+/// for inbound GraphQL requests by attempting authentication against every registered
+/// scheme until one succeeds. Wired automatically when at least one [TraxQueryModel]
+/// entity carries [TraxAuthorize].
+///
+///
+/// Without this, HotChocolate's @authorize directive evaluates against an
+/// anonymous principal whenever no default authentication scheme is configured on
+/// AddAuthentication(). ASP.NET Core's UseAuthentication() middleware
+/// only authenticates the default scheme; with multiple schemes registered (api-key
+/// + JWT, api-key + cookie, etc.) there is no default, so HC sees no user even when
+/// the request carries valid credentials for one of the registered schemes.
+///
+///
+///
+/// The interceptor:
+///
+///
+/// - Returns early if HttpContext.User is already authenticated (something
+/// upstream — endpoint-level RequireAuthorization, a default scheme, a custom
+/// interceptor — has handled it).
+/// - Otherwise, iterates over every registered authentication scheme and attempts
+/// authentication. The first scheme that succeeds wins; the resulting principal is
+/// assigned to HttpContext.User.
+/// - If no scheme succeeds, the principal stays anonymous and the request
+/// proceeds — @authorize will then reject any gated field/type the request
+/// touches.
+///
+///
+///
+/// WebSocket upgrades and the Banana Cake Pop tool page are not affected: HotChocolate
+/// invokes this interceptor only for actual GraphQL HTTP execution requests.
+///
+///
+internal sealed class QueryModelAuthenticationInterceptor(
+ IAuthenticationSchemeProvider schemeProvider
+) : DefaultHttpRequestInterceptor
+{
+ public override async ValueTask OnCreateAsync(
+ HttpContext context,
+ IRequestExecutor requestExecutor,
+ OperationRequestBuilder requestBuilder,
+ CancellationToken cancellationToken
+ )
+ {
+ if (context.User.Identity?.IsAuthenticated != true)
+ {
+ // Walk every registered scheme and attempt authentication. The first
+ // success wins. AuthenticateAsync against an inapplicable scheme returns
+ // NoResult (cheap) — schemes only do work when their credential header
+ // is present.
+ foreach (var scheme in await schemeProvider.GetAllSchemesAsync())
+ {
+ var result = await context.AuthenticateAsync(scheme.Name);
+ if (result.Succeeded && result.Principal is not null)
+ {
+ context.User = result.Principal;
+ break;
+ }
+ }
+ }
+
+ await base.OnCreateAsync(context, requestExecutor, requestBuilder, cancellationToken);
+ }
+}
diff --git a/src/Trax.Api.GraphQL/Configuration/QueryModelRegistration.cs b/src/Trax.Api.GraphQL/Configuration/QueryModelRegistration.cs
index e312441..26ad006 100644
--- a/src/Trax.Api.GraphQL/Configuration/QueryModelRegistration.cs
+++ b/src/Trax.Api.GraphQL/Configuration/QueryModelRegistration.cs
@@ -4,12 +4,20 @@ namespace Trax.Api.GraphQL.Configuration;
///
/// Represents a discovered entity type marked with
-/// and its owning DbContext type.
+/// and its owning DbContext type. captures every
+/// applied to the entity (including those inherited
+/// from base classes / interfaces) so the type module can attach the @authorize
+/// directive at ObjectType level for transitive enforcement.
///
public record QueryModelRegistration(
Type EntityType,
Type DbContextType,
TraxQueryModelAttribute Attribute,
Type? FilterInputType = null,
- Type? SortInputType = null
-);
+ Type? SortInputType = null,
+ IReadOnlyList? AuthorizeAttributes = null
+)
+{
+ public IReadOnlyList AuthorizeAttributes { get; init; } =
+ AuthorizeAttributes ?? Array.Empty();
+}
diff --git a/src/Trax.Api.GraphQL/Configuration/TraxGraphQLBuilder/TraxGraphQLBuilder.Build.cs b/src/Trax.Api.GraphQL/Configuration/TraxGraphQLBuilder/TraxGraphQLBuilder.Build.cs
index 29f89c2..e2109de 100644
--- a/src/Trax.Api.GraphQL/Configuration/TraxGraphQLBuilder/TraxGraphQLBuilder.Build.cs
+++ b/src/Trax.Api.GraphQL/Configuration/TraxGraphQLBuilder/TraxGraphQLBuilder.Build.cs
@@ -31,13 +31,17 @@ internal GraphQLConfiguration Build()
FilterTypeOverrides.TryGetValue(entityType, out var filterType);
SortTypeOverrides.TryGetValue(entityType, out var sortType);
+ var authorizeAttributes = DiscoverAuthorizeAttributes(entityType);
+ ValidateAuthorizeAttributeShapes(entityType, authorizeAttributes);
+
modelRegistrations.Add(
new QueryModelRegistration(
entityType,
dbContextType,
attr,
filterType,
- sortType
+ sortType,
+ authorizeAttributes
)
);
}
@@ -59,6 +63,70 @@ internal GraphQLConfiguration Build()
);
}
+ ///
+ /// Collects every declared on the entity type
+ /// (including any inherited via base classes or interfaces). De-duplicates by
+ /// reference identity so a single attribute instance is not counted twice when the
+ /// CLR returns it via multiple inheritance paths.
+ ///
+ private static IReadOnlyList DiscoverAuthorizeAttributes(
+ Type entityType
+ )
+ {
+ var seen = new HashSet(ReferenceEqualityComparer.Instance);
+ var ordered = new List();
+
+ // The entity class itself, plus every interface it implements. `Inherited = true`
+ // on the attribute already walks the base-class chain.
+ var carriers = new List { entityType };
+ carriers.AddRange(entityType.GetInterfaces());
+
+ foreach (var carrier in carriers)
+ {
+ foreach (var attr in carrier.GetCustomAttributes(inherit: true))
+ {
+ if (seen.Add(attr))
+ ordered.Add(attr);
+ }
+ }
+
+ return ordered;
+ }
+
+ ///
+ /// Build-time shape validation for on a query
+ /// model entity. Mirrors the train-side validator at
+ /// AuthorizationRegistrationValidator.ValidateAttributeShapes: whitespace
+ /// Policy and Roles values are caught here rather than producing a runtime gate
+ /// that silently denies everyone.
+ ///
+ private static void ValidateAuthorizeAttributeShapes(
+ Type entityType,
+ IReadOnlyList attributes
+ )
+ {
+ foreach (var attribute in attributes)
+ {
+ if (attribute.Policy is not null && string.IsNullOrWhiteSpace(attribute.Policy))
+ throw new InvalidOperationException(
+ $"[TraxAuthorize] on '{entityType.FullName}' has an empty or whitespace "
+ + "Policy value. Remove the parameter or provide a real policy name."
+ );
+
+ if (
+ attribute.Roles is not null
+ && attribute
+ .Roles.Split(',', StringSplitOptions.TrimEntries)
+ .All(string.IsNullOrEmpty)
+ )
+ throw new InvalidOperationException(
+ $"[TraxAuthorize(Roles=\"{attribute.Roles}\")] on '{entityType.FullName}' "
+ + "parsed to zero roles after splitting on ','. Remove the Roles "
+ + "argument or provide one or more non-empty role names."
+ );
+ }
+ }
+
private static void ValidateExposeAs(Type entityType, TraxQueryModelAttribute attr)
{
if (attr.ExposeAs is not { } exposeAs)
diff --git a/src/Trax.Api.GraphQL/Errors/TraxErrorFilter.cs b/src/Trax.Api.GraphQL/Errors/TraxErrorFilter.cs
index b2c7dfc..17ead52 100644
--- a/src/Trax.Api.GraphQL/Errors/TraxErrorFilter.cs
+++ b/src/Trax.Api.GraphQL/Errors/TraxErrorFilter.cs
@@ -47,6 +47,16 @@ internal class TraxErrorFilter : IErrorFilter
{
public IError OnError(IError error)
{
+ // HotChocolate's @authorize directive (used by [TraxAuthorize] on
+ // [TraxQueryModel] entities) raises errors without an attached
+ // exception — they carry only a code. Normalise both authentication-
+ // and authorization-failure codes to the Trax public shape so callers
+ // see a single uniform error regardless of which path failed.
+ if (error.Code is "AUTH_NOT_AUTHENTICATED" or "AUTH_NOT_AUTHORIZED")
+ return error
+ .WithMessage(TrainAuthorizationException.PublicMessage)
+ .WithCode("TRAX_AUTHORIZATION");
+
if (error.Exception is null)
return error;
diff --git a/src/Trax.Api.GraphQL/Extensions/GraphQLServiceExtensions.cs b/src/Trax.Api.GraphQL/Extensions/GraphQLServiceExtensions.cs
index 94a0d3f..185c8bd 100644
--- a/src/Trax.Api.GraphQL/Extensions/GraphQLServiceExtensions.cs
+++ b/src/Trax.Api.GraphQL/Extensions/GraphQLServiceExtensions.cs
@@ -181,6 +181,21 @@ Func configure
services.AddSingleton();
graphqlBuilder.AddTypeModule();
+ // Wire HotChocolate's @authorize directive handler whenever any
+ // model entity carries [TraxAuthorize]. The directive runs against
+ // ASP.NET Core's IAuthorizationService, so RequireRole / policy
+ // definitions registered via services.AddAuthorization(...) apply.
+ // Wiring is conditional so the dependency is opt-in for hosts that
+ // expose no gated models (the directive handler pulls in ASP.NET
+ // Core authorization machinery).
+ if (config.ModelRegistrations.Any(r => r.AuthorizeAttributes.Count > 0))
+ {
+ graphqlBuilder.AddAuthorization();
+ graphqlBuilder.AddHttpRequestInterceptor();
+ services.AddHostedService();
+ services.AddHostedService();
+ }
+
// Register DiscoverQueries base type and discover field on RootQuery.
// TrainTypeModule will skip creating these when it detects model registrations.
graphqlBuilder.AddType(new ObjectType());
diff --git a/src/Trax.Api.GraphQL/Startup/GraphQLModelExposureWarningService.cs b/src/Trax.Api.GraphQL/Startup/GraphQLModelExposureWarningService.cs
index 3d9e51d..290a9cb 100644
--- a/src/Trax.Api.GraphQL/Startup/GraphQLModelExposureWarningService.cs
+++ b/src/Trax.Api.GraphQL/Startup/GraphQLModelExposureWarningService.cs
@@ -6,17 +6,19 @@ namespace Trax.Api.GraphQL.Startup;
///
/// Warns at host start when a GraphQL schema exposes AddDbContext-driven
-/// model queries without an endpoint-level authorization gate. The model-query
-/// surface has no per-field authorization hook; anonymous clients can read every
-/// registered entity unless the consumer gates the endpoint explicitly via the
-/// configure callback on UseTraxGraphQL.
+/// model queries that are NOT individually gated by [TraxAuthorize]. The
+/// ungated model surface relies entirely on endpoint-level authorization; if the
+/// endpoint is anonymous, every authenticated caller can read every ungated
+/// registered entity.
///
///
/// Emits at only — it is a reminder, not a fatal
/// misconfiguration. Teams that intentionally ship public model-query endpoints
/// can ignore the log or filter it out. Teams that did not intend public reads
-/// should either add RequireAuthorization(...) to the endpoint or gate
-/// specific queries with [TraxAuthorize] (when that surface lands).
+/// should either add RequireAuthorization(...) to the endpoint or attach
+/// [TraxAuthorize] to the sensitive entity classes so the directive runs
+/// at type level (and is enforced even when the entity is reached transitively
+/// via a navigation property on an ungated parent).
///
internal sealed class GraphQLModelExposureWarningService(
GraphQLConfiguration configuration,
@@ -28,12 +30,22 @@ public Task StartAsync(CancellationToken cancellationToken)
if (configuration.ModelRegistrations.Count == 0)
return Task.CompletedTask;
+ var ungated = configuration
+ .ModelRegistrations.Where(r => r.AuthorizeAttributes.Count == 0)
+ .Count();
+
+ if (ungated == 0)
+ return Task.CompletedTask;
+
logger.LogWarning(
- "Trax GraphQL: {Count} model query registration(s) are active. "
- + "AddDbContext-backed model queries currently have no per-field authorization. "
- + "Unless you gate the endpoint via `UseTraxGraphQL(configure: e => e.RequireAuthorization(...))` "
- + "or accept that every authenticated caller can read every registered entity, "
- + "review this surface before shipping to production.",
+ "Trax GraphQL: {Ungated} of {Total} model query registration(s) carry no "
+ + "[TraxAuthorize]. Ungated model queries are exposed to every caller that "
+ + "reaches the endpoint. Either gate the endpoint via "
+ + "`UseTraxGraphQL(configure: e => e.RequireAuthorization(...))`, attach "
+ + "[TraxAuthorize] to the sensitive entity classes (which enforces at type "
+ + "level, including transitively through navigation properties), or accept "
+ + "the public-read posture intentionally.",
+ ungated,
configuration.ModelRegistrations.Count
);
diff --git a/src/Trax.Api.GraphQL/Startup/QueryModelAuthorizationSchemaValidator.cs b/src/Trax.Api.GraphQL/Startup/QueryModelAuthorizationSchemaValidator.cs
new file mode 100644
index 0000000..2ad5ead
--- /dev/null
+++ b/src/Trax.Api.GraphQL/Startup/QueryModelAuthorizationSchemaValidator.cs
@@ -0,0 +1,163 @@
+using HotChocolate.Authorization;
+using HotChocolate.Execution;
+using HotChocolate.Types;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Trax.Api.GraphQL.Configuration;
+using Trax.Api.GraphQL.TypeModules;
+using Trax.Effect.Attributes;
+
+namespace Trax.Api.GraphQL.Startup;
+
+///
+/// Post-build invariant check that every [TraxAuthorize]-decorated
+/// [TraxQueryModel] entity still carries the @authorize
+/// directive on its ObjectType and on its entry field under
+/// discover after the schema is fully built.
+///
+///
+/// This closes the only remaining escape hatch: a consumer-supplied
+/// ConfigureSchema callback (which runs after the standard Trax
+/// type-module wiring) could in principle add or replace types in a way that
+/// strips the directives Trax attached. The discovery, registration, and
+/// type-module layers all run inside Trax-controlled code and have no
+/// "skip auth" knob, but ConfigureSchema has full IRequestExecutorBuilder
+/// access by design. This validator runs at host start, materialises the
+/// schema, and re-asserts the invariant. If a gate has been removed, the
+/// host fails to start with a message naming the entity and the missing
+/// directive location.
+///
+///
+///
+/// The check is read-only and idempotent. It does not modify the schema.
+/// It runs once at startup and never again.
+///
+///
+internal sealed class QueryModelAuthorizationSchemaValidator(
+ GraphQLConfiguration configuration,
+ IServiceProvider serviceProvider
+) : IHostedService
+{
+ /// Schema name registered by Trax for the GraphQL endpoint.
+ private const string TraxSchemaName = "trax";
+
+ public async Task StartAsync(CancellationToken cancellationToken)
+ {
+ var gated = configuration
+ .ModelRegistrations.Where(r => r.AuthorizeAttributes.Count > 0)
+ .ToList();
+
+ if (gated.Count == 0)
+ return;
+
+ // Materialise the schema. Building it now (a) catches any other schema
+ // misconfiguration at host start instead of on the first request, and
+ // (b) lets us walk the resolved types.
+ using var scope = serviceProvider.CreateScope();
+ var resolver = scope.ServiceProvider.GetRequiredService();
+ var executor = await resolver.GetRequestExecutorAsync(TraxSchemaName, cancellationToken);
+ var schema = executor.Schema;
+
+ var queryType = schema.QueryType;
+
+ foreach (var reg in gated)
+ {
+ VerifyTypeLevelDirective(schema, reg);
+ VerifyEntryFieldDirective(queryType, reg);
+ }
+ }
+
+ ///
+ /// Walks the schema for the built from the entity's CLR
+ /// type and confirms it has at least one @authorize directive. This is the
+ /// gate that enforces transitive navigation — without it, any field elsewhere in
+ /// the schema whose return type is this entity would be readable.
+ ///
+ private static void VerifyTypeLevelDirective(ISchema schema, QueryModelRegistration reg)
+ {
+ var objectType = schema
+ .Types.OfType()
+ .FirstOrDefault(t => t.RuntimeType == reg.EntityType);
+
+ if (objectType is null)
+ throw new InvalidOperationException(
+ $"[TraxAuthorize] invariant violated: no ObjectType for "
+ + $"'{reg.EntityType.FullName}' is present in the built schema. "
+ + "A ConfigureSchema callback may have replaced or removed it. "
+ + "Trax cannot enforce type-level authorization on a type it "
+ + "cannot find."
+ );
+
+ if (!HasAuthorizeDirective(objectType.Directives))
+ throw new InvalidOperationException(
+ $"[TraxAuthorize] invariant violated: ObjectType for "
+ + $"'{reg.EntityType.FullName}' has no @authorize directive in "
+ + "the built schema. The directive was attached during type-module "
+ + "wiring but is now missing — a ConfigureSchema callback has "
+ + "replaced the ObjectType with an unauthorized variant. Remove the "
+ + "override, or remove [TraxAuthorize] from the entity if the "
+ + "exposure is intentional."
+ );
+ }
+
+ ///
+ /// Walks the discover namespace down to the entry field for this entity
+ /// and confirms the field carries an @authorize directive. The field-level
+ /// gate is what blocks Connection-shaped scalars (totalCount, pageInfo)
+ /// from leaking through when the request never resolves an entity node.
+ ///
+ private static void VerifyEntryFieldDirective(IObjectType rootQuery, QueryModelRegistration reg)
+ {
+ // Path: RootQuery.discover -> (DiscoverQueries[.namespace]).
+ var discoverField = rootQuery.Fields.FirstOrDefault(f => f.Name == "discover");
+ if (discoverField is null)
+ throw new InvalidOperationException(
+ $"[TraxAuthorize] invariant violated: the `discover` field is not "
+ + $"present on RootQuery, so the entry point for "
+ + $"'{reg.EntityType.FullName}' cannot be located. Likely a "
+ + "ConfigureSchema callback has removed the discover namespace."
+ );
+
+ var discoverType = (IObjectType)discoverField.Type.NamedType();
+ var container = discoverType;
+
+ if (reg.Attribute.Namespace is not null)
+ {
+ var nsFieldName = TrainTypeModule.CamelCase(reg.Attribute.Namespace);
+ var nsField = discoverType.Fields.FirstOrDefault(f => f.Name == nsFieldName);
+ if (nsField is null)
+ throw new InvalidOperationException(
+ $"[TraxAuthorize] invariant violated: namespace field "
+ + $"'{nsFieldName}' is missing under `discover` for "
+ + $"'{reg.EntityType.FullName}'."
+ );
+ container = (IObjectType)nsField.Type.NamedType();
+ }
+
+ var fieldName =
+ reg.Attribute.Name ?? QueryModelTypeModule.DeriveModelName(reg.EntityType.Name);
+ var entryField = container.Fields.FirstOrDefault(f => f.Name == fieldName);
+ if (entryField is null)
+ throw new InvalidOperationException(
+ $"[TraxAuthorize] invariant violated: entry field '{fieldName}' is "
+ + $"missing under `discover` for '{reg.EntityType.FullName}'."
+ );
+
+ if (!HasAuthorizeDirective(entryField.Directives))
+ throw new InvalidOperationException(
+ $"[TraxAuthorize] invariant violated: entry field '{fieldName}' for "
+ + $"'{reg.EntityType.FullName}' has no @authorize directive in the "
+ + "built schema. Without it, an unauthorized caller can read "
+ + "Connection-shaped scalars like `totalCount` and `pageInfo` "
+ + "even though they never resolve a node of the gated type. "
+ + "A ConfigureSchema callback has stripped the directive — "
+ + "remove the override, or remove [TraxAuthorize] from the "
+ + "entity if the exposure is intentional."
+ );
+ }
+
+ private static bool HasAuthorizeDirective(IDirectiveCollection directives) =>
+ directives.Any(d => d.Type.Name == "authorize");
+
+ public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+}
diff --git a/src/Trax.Api.GraphQL/Startup/QueryModelAuthorizationValidator.cs b/src/Trax.Api.GraphQL/Startup/QueryModelAuthorizationValidator.cs
new file mode 100644
index 0000000..746f5be
--- /dev/null
+++ b/src/Trax.Api.GraphQL/Startup/QueryModelAuthorizationValidator.cs
@@ -0,0 +1,50 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.Extensions.Hosting;
+using Trax.Api.GraphQL.Configuration;
+
+namespace Trax.Api.GraphQL.Startup;
+
+///
+/// Fail-loud startup check that every
+/// directive emitted for a [TraxQueryModel] entity points at a policy
+/// the host has actually registered. Without this, a typoed
+/// [TraxAuthorize(Policy = "AdmnPolicy")] would compile, ship, and
+/// silently deny every caller at runtime — the worst-of-both: insecure to
+/// reason about (looks gated, isn't), and broken in production.
+///
+///
+/// Mirrors TraxGraphQLAuthPolicyValidator, which performs the same
+/// check for the endpoint-level RequireAuthorization() opt-in.
+///
+internal sealed class QueryModelAuthorizationValidator(
+ GraphQLConfiguration configuration,
+ IAuthorizationPolicyProvider policyProvider
+) : IHostedService
+{
+ public async Task StartAsync(CancellationToken cancellationToken)
+ {
+ var seen = new HashSet(StringComparer.Ordinal);
+
+ foreach (var reg in configuration.ModelRegistrations)
+ {
+ foreach (var attr in reg.AuthorizeAttributes)
+ {
+ if (string.IsNullOrWhiteSpace(attr.Policy))
+ continue;
+ if (!seen.Add(attr.Policy))
+ continue;
+
+ var policy = await policyProvider.GetPolicyAsync(attr.Policy);
+ if (policy is null)
+ throw new InvalidOperationException(
+ $"[TraxAuthorize(Policy = \"{attr.Policy}\")] on '{reg.EntityType.FullName}' "
+ + "references an authorization policy that is not registered. Call "
+ + $"`services.AddAuthorization(opts => opts.AddPolicy(\"{attr.Policy}\", ...))` "
+ + "during host setup, or pass an existing policy name to [TraxAuthorize]."
+ );
+ }
+ }
+ }
+
+ public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+}
diff --git a/src/Trax.Api.GraphQL/Trax.Api.GraphQL.csproj b/src/Trax.Api.GraphQL/Trax.Api.GraphQL.csproj
index 2e6db3b..3948de6 100644
--- a/src/Trax.Api.GraphQL/Trax.Api.GraphQL.csproj
+++ b/src/Trax.Api.GraphQL/Trax.Api.GraphQL.csproj
@@ -23,6 +23,7 @@
+
diff --git a/src/Trax.Api.GraphQL/TypeModules/QueryModelTypeModule.cs b/src/Trax.Api.GraphQL/TypeModules/QueryModelTypeModule.cs
index c48b580..dcf35ea 100644
--- a/src/Trax.Api.GraphQL/TypeModules/QueryModelTypeModule.cs
+++ b/src/Trax.Api.GraphQL/TypeModules/QueryModelTypeModule.cs
@@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations.Schema;
using System.Linq.Expressions;
using System.Reflection;
+using HotChocolate.Authorization;
using HotChocolate.Data;
using HotChocolate.Data.Filters;
using HotChocolate.Data.Sorting;
@@ -23,7 +24,7 @@ namespace Trax.Api.GraphQL.TypeModules;
/// field under discover with optional cursor pagination, filtering,
/// sorting, and projection based on the attribute configuration.
///
-public class QueryModelTypeModule(GraphQLConfiguration configuration) : TypeModule
+public sealed class QueryModelTypeModule(GraphQLConfiguration configuration) : TypeModule
{
///
/// Discovers all registered query model entities and generates the GraphQL schema types:
@@ -49,7 +50,7 @@ CancellationToken cancellationToken
var objectType = (ITypeSystemMember)
CreateObjectTypeMethod
.MakeGenericMethod(reg.EntityType)
- .Invoke(null, [reg.Attribute])!;
+ .Invoke(null, [reg.Attribute, reg.AuthorizeAttributes])!;
types.Add(objectType);
}
}
@@ -154,6 +155,16 @@ QueryModelRegistration reg
{
var attr = reg.Attribute;
+ // Apply [TraxAuthorize] at field level so the entry point itself is
+ // gated. Type-level @authorize alone leaves a hole: a request that
+ // selects only Connection-shaped scalars like `totalCount` or
+ // `pageInfo.hasNextPage` never resolves a node of the entity type,
+ // so the type-level directive does not fire. Field-level enforcement
+ // blocks the entry point unconditionally; type-level enforcement
+ // (in CreateObjectType) covers transitive navigation from ungated
+ // parents.
+ ApplyAuthorizeDirectives(field, reg.AuthorizeAttributes);
+
// Apply features in the correct middleware pipeline order:
// Paging > Projection > Filtering > Sorting
if (attr.Paging)
@@ -230,7 +241,10 @@ QueryModelRegistration reg
});
}
- private static ObjectType CreateObjectType(TraxQueryModelAttribute attr)
+ private static ObjectType CreateObjectType(
+ TraxQueryModelAttribute attr,
+ IReadOnlyList authorizeAttributes
+ )
where TEntity : class
{
// ExposeAs takes precedence: build the GraphQL type from the supplied
@@ -259,11 +273,20 @@ var prop in typeof(TEntity).GetProperties(
if (allowedNames.Contains(prop.Name))
descriptor.Field(prop);
}
+
+ ApplyAuthorizeDirectives(descriptor, authorizeAttributes);
});
}
if (attr.BindFields != FieldBindingBehavior.Explicit)
- return new ObjectType();
+ {
+ if (authorizeAttributes.Count == 0)
+ return new ObjectType();
+
+ return new ObjectType(descriptor =>
+ ApplyAuthorizeDirectives(descriptor, authorizeAttributes)
+ );
+ }
return new ObjectType(descriptor =>
{
@@ -278,9 +301,97 @@ var prop in typeof(TEntity).GetProperties(
if (prop.GetCustomAttribute() is not null)
descriptor.Field(prop);
}
+
+ ApplyAuthorizeDirectives(descriptor, authorizeAttributes);
});
}
+ ///
+ /// Attaches HotChocolate @authorize directives to an
+ /// according to the supplied set. The directive
+ /// is applied at type level (not field level) so any field whose return
+ /// type is this object enforces the gate, including navigation properties reached
+ /// transitively from an ungated parent type.
+ ///
+ ///
+ /// Combinator semantics mirror 's
+ /// documented behavior for trains:
+ ///
+ ///
+ /// - Bare [TraxAuthorize] with no policy or roles is materialised by
+ /// emitting an empty @authorize directive, which the HotChocolate authorization
+ /// middleware treats as "require authenticated user."
+ /// - Every becomes its own directive;
+ /// HotChocolate evaluates them with AND semantics.
+ /// - All values across every attached
+ /// attribute are unioned (CSV split, trimmed, distinct) and emitted as a single
+ /// directive — the principal must hold at least one. Multiple role directives would
+ /// AND the OR-sets together, which is not the documented contract.
+ ///
+ ///
+ private static void ApplyAuthorizeDirectives(
+ IObjectTypeDescriptor descriptor,
+ IReadOnlyList attributes
+ )
+ where TEntity : class
+ {
+ ExtractRules(attributes, out var policies, out var roles);
+
+ foreach (var policy in policies)
+ descriptor.Authorize(policy, ApplyPolicy.BeforeResolver);
+
+ if (roles.Length > 0)
+ descriptor.Authorize(roles);
+ else if (policies.Length == 0 && attributes.Count > 0)
+ descriptor.Authorize(ApplyPolicy.BeforeResolver);
+ }
+
+ private static void ApplyAuthorizeDirectives(
+ IObjectFieldDescriptor descriptor,
+ IReadOnlyList attributes
+ )
+ {
+ ExtractRules(attributes, out var policies, out var roles);
+
+ foreach (var policy in policies)
+ descriptor.Authorize(policy, ApplyPolicy.BeforeResolver);
+
+ if (roles.Length > 0)
+ descriptor.Authorize(roles);
+ else if (policies.Length == 0 && attributes.Count > 0)
+ descriptor.Authorize(ApplyPolicy.BeforeResolver);
+ }
+
+ ///
+ /// Reduces a set of instances into the
+ /// distinct policy and role lists used to emit @authorize directives.
+ /// Policies AND across attributes; roles OR within an attribute (CSV split)
+ /// and OR across attributes (unioned). The semantics mirror
+ /// 's
+ /// train-side enforcement so a model and a train that declare the same
+ /// [TraxAuthorize] shape have identical access rules.
+ ///
+ private static void ExtractRules(
+ IReadOnlyList attributes,
+ out string[] policies,
+ out string[] roles
+ )
+ {
+ roles = attributes
+ .Where(a => a.Roles is not null)
+ .SelectMany(a => a.Roles!.Split(',', StringSplitOptions.TrimEntries))
+ .Where(r => !string.IsNullOrEmpty(r))
+ .Distinct(StringComparer.Ordinal)
+ .ToArray();
+
+ policies = attributes
+ .Select(a => a.Policy)
+ .Where(p => !string.IsNullOrWhiteSpace(p))
+ .Select(p => p!)
+ .Distinct(StringComparer.Ordinal)
+ .ToArray();
+ }
+
private static readonly MethodInfo FilterFieldGeneric = typeof(QueryModelTypeModule).GetMethod(
nameof(AddFilterField),
BindingFlags.NonPublic | BindingFlags.Static
diff --git a/tests/Trax.Api.Tests/AuthE2E/AuthE2EHost.cs b/tests/Trax.Api.Tests/AuthE2E/AuthE2EHost.cs
index ca7d931..f83d041 100644
--- a/tests/Trax.Api.Tests/AuthE2E/AuthE2EHost.cs
+++ b/tests/Trax.Api.Tests/AuthE2E/AuthE2EHost.cs
@@ -32,7 +32,10 @@ namespace Trax.Api.Tests.AuthE2E;
public static class AuthE2EHost
{
// Each AuthE2E test class passes its own database name to keep migrations
- // and advisory locks isolated; CI provisions the per-class DBs upfront.
+ // and advisory locks isolated; the database is provisioned on demand by
+ // EnsureDatabaseExists below (called from StartAsync and from the seeder
+ // helpers on the TestDbContexts), so no changes to CI workflows are
+ // required when a new fixture is added.
//
// Connection knobs:
// - Timeout=30 — CI runners occasionally need >15s (the Npgsql default)
@@ -48,6 +51,50 @@ public static string ConnectionString(string database) =>
+ "Maximum Pool Size=8;Minimum Pool Size=0;Connection Idle Lifetime=30;"
+ "Timeout=30;Tcp Keepalive=true";
+ ///
+ /// Idempotently creates the per-fixture test database. Each AuthE2E test
+ /// class uses its own database name so migrations and advisory locks stay
+ /// isolated; rather than pushing that list of names into the CI workflow
+ /// (where it would drift the moment someone adds a new fixture and forgets
+ /// to update the YAML), the host provisions on demand.
+ ///
+ ///
+ /// Connects to the cluster's maintenance database trax (which is
+ /// guaranteed to exist by the docker-compose entrypoint / CI service
+ /// image) and runs CREATE DATABASE. Catches the duplicate-database
+ /// SQLState (42P04) so re-runs are no-ops.
+ ///
+ public static void EnsureDatabaseExists(string database)
+ {
+ const string maintenanceConnectionString =
+ "Host=localhost;Port=5432;Database=trax;Username=trax;Password=trax123;Timeout=30";
+
+ using var connection = new Npgsql.NpgsqlConnection(maintenanceConnectionString);
+ connection.Open();
+ using var command = connection.CreateCommand();
+ // Database names are validated against an allowlist of characters here
+ // because the CREATE DATABASE statement does not support parameter
+ // binding for identifiers. The test fixtures always pass compile-time
+ // literals, but enforce the shape defensively to make accidental
+ // misuse loud.
+ if (!System.Text.RegularExpressions.Regex.IsMatch(database, "^[a-z_][a-z0-9_]*$"))
+ throw new ArgumentException(
+ $"Database name '{database}' contains characters outside [a-z0-9_]. "
+ + "Test fixtures must use snake_case ASCII identifiers.",
+ nameof(database)
+ );
+
+ command.CommandText = $"CREATE DATABASE \"{database}\"";
+ try
+ {
+ command.ExecuteNonQuery();
+ }
+ catch (Npgsql.PostgresException ex) when (ex.SqlState == "42P04")
+ {
+ // Database already exists — idempotent no-op.
+ }
+ }
+
public const string JwtIssuer = "https://trax-e2e-tests";
public const string JwtAudience = "trax-e2e";
public static readonly byte[] JwtKey = Encoding.UTF8.GetBytes(new string('e', 32));
@@ -66,6 +113,7 @@ public enum Schemes
public static async Task StartAsync(Schemes schemes, string database)
{
+ EnsureDatabaseExists(database);
var connectionString = ConnectionString(database);
var host = new HostBuilder()
.ConfigureWebHost(web =>
@@ -112,12 +160,23 @@ public static async Task StartAsync(Schemes schemes, string database)
o.UseNpgsql(connectionString)
);
+ // Second DbContext for [TraxAuthorize]-on-[TraxQueryModel]
+ // coverage. Lives in the `test_authz` schema so it does
+ // not interfere with the unauthorized fixtures. Only the
+ // QueryModelAuthorize E2E suite seeds it; the other
+ // suites tolerate its presence because none of their
+ // queries touch its fields.
+ services.AddDbContextFactory(o =>
+ o.UseNpgsql(connectionString)
+ );
+
services.AddTraxGraphQL(graphql =>
graphql
// Default is generous (15) and covers the
// discover/namespace/entity/nodes/field chain
// without needing an explicit override.
.AddDbContext()
+ .AddDbContext()
.AddTypeExtensions(typeof(AuthE2EHost).Assembly)
// Add custom subscription + mutation type
// extensions for principal-propagation tests.
diff --git a/tests/Trax.Api.Tests/AuthE2E/AuthzTestDbContext.cs b/tests/Trax.Api.Tests/AuthE2E/AuthzTestDbContext.cs
new file mode 100644
index 0000000..25026b8
--- /dev/null
+++ b/tests/Trax.Api.Tests/AuthE2E/AuthzTestDbContext.cs
@@ -0,0 +1,211 @@
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+using Microsoft.EntityFrameworkCore;
+using Trax.Effect.Attributes;
+
+namespace Trax.Api.Tests.AuthE2E;
+
+///
+/// Test entities for type-level [TraxAuthorize] on [TraxQueryModel].
+/// Lives in its own test_authz schema so the data does not collide with
+/// the unauthorized fixtures in .
+///
+/// The relationship between and is
+/// the centerpiece of the transitive-navigation security test: the parent
+/// type is ungated, the child type carries [TraxAuthorize(Roles="Admin")],
+/// and the E2E suite verifies that a Player principal cannot reach the child
+/// through the parent's books navigation property.
+///
+[TraxQueryModel(Namespace = "vault", Description = "Owners of vault items (ungated).")]
+[Table("owners", Schema = "test_authz")]
+public class Owner
+{
+ [Key]
+ [Column("id")]
+ public long Id { get; set; }
+
+ [Column("name")]
+ public string Name { get; set; } = "";
+
+ public List Books { get; set; } = new();
+}
+
+///
+/// Child entity gated with [TraxAuthorize(Roles="Admin")]. Direct access
+/// (top-level ownedBooks) and transitive access (through
+/// owners[].books) must both honor the role gate.
+///
+[TraxQueryModel(Namespace = "vault", Description = "Admin-only books.")]
+[TraxAuthorize(Roles = "Admin")]
+[Table("owned_books", Schema = "test_authz")]
+public class OwnedBook
+{
+ [Key]
+ [Column("id")]
+ public long Id { get; set; }
+
+ [Column("owner_id")]
+ public long OwnerId { get; set; }
+
+ [Column("title")]
+ public string Title { get; set; } = "";
+
+ public Owner Owner { get; set; } = null!;
+}
+
+///
+/// Entity gated with a CSV role list (Roles="Admin,Player"). Both roles
+/// satisfy the gate (OR semantics within a single attribute).
+///
+[TraxQueryModel(Namespace = "vault", Description = "Memos visible to Admin or Player.")]
+[TraxAuthorize(Roles = "Admin,Player")]
+[Table("memos", Schema = "test_authz")]
+public class Memo
+{
+ [Key]
+ [Column("id")]
+ public long Id { get; set; }
+
+ [Column("body")]
+ public string Body { get; set; } = "";
+}
+
+///
+/// Entity gated with two stacked attributes. AND semantics across attributes:
+/// the principal must hold Admin AND satisfy AdminPolicy. Both
+/// reduce to the same underlying claim in this test host, so the practical
+/// effect is "Admin only with both checks executed." The point of the test is
+/// that both directives fire.
+///
+[TraxQueryModel(Namespace = "vault", Description = "Stacked-attribute restricted documents.")]
+[TraxAuthorize(Roles = "Admin")]
+[TraxAuthorize(Policy = "AdminPolicy")]
+[Table("restricted_docs", Schema = "test_authz")]
+public class RestrictedDoc
+{
+ [Key]
+ [Column("id")]
+ public long Id { get; set; }
+
+ [Column("payload")]
+ public string Payload { get; set; } = "";
+}
+
+///
+/// Entity gated with bare [TraxAuthorize]: any authenticated user passes,
+/// anonymous callers fail.
+///
+[TraxQueryModel(Namespace = "vault", Description = "Members-only area.")]
+[TraxAuthorize]
+[Table("member_areas", Schema = "test_authz")]
+public class MemberArea
+{
+ [Key]
+ [Column("id")]
+ public long Id { get; set; }
+
+ [Column("name")]
+ public string Name { get; set; } = "";
+}
+
+public class AuthzTestDbContext(DbContextOptions options) : DbContext(options)
+{
+ public DbSet Owners { get; set; } = null!;
+ public DbSet OwnedBooks { get; set; } = null!;
+ public DbSet Memos { get; set; } = null!;
+ public DbSet RestrictedDocs { get; set; } = null!;
+ public DbSet MemberAreas { get; set; } = null!;
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.HasDefaultSchema("test_authz");
+
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(e => e.Id);
+ entity.Property(e => e.Id).ValueGeneratedOnAdd();
+ entity.HasMany(e => e.Books).WithOne(b => b.Owner).HasForeignKey(b => b.OwnerId);
+ });
+
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(e => e.Id);
+ entity.Property(e => e.Id).ValueGeneratedOnAdd();
+ entity.HasIndex(e => e.OwnerId);
+ });
+
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(e => e.Id);
+ entity.Property(e => e.Id).ValueGeneratedOnAdd();
+ });
+
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(e => e.Id);
+ entity.Property(e => e.Id).ValueGeneratedOnAdd();
+ });
+
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(e => e.Id);
+ entity.Property(e => e.Id).ValueGeneratedOnAdd();
+ });
+ }
+
+ ///
+ /// Idempotently provisions the test_authz schema and a deterministic
+ /// fixture row set. The owners + books fixture is the key payload for the
+ /// transitive-navigation security tests: each owner has at least one book
+ /// so the navigation actually attempts to materialize children.
+ ///
+ public static void EnsureSeeded(string connectionString)
+ {
+ AuthE2EHost.EnsureDatabaseExists(
+ new Npgsql.NpgsqlConnectionStringBuilder(connectionString).Database!
+ );
+
+ var opts = new DbContextOptionsBuilder()
+ .UseNpgsql(connectionString)
+ .Options;
+ using var db = new AuthzTestDbContext(opts);
+
+ db.Database.ExecuteSqlRaw("CREATE SCHEMA IF NOT EXISTS test_authz");
+ try
+ {
+ db.Database.ExecuteSqlRaw(db.Database.GenerateCreateScript());
+ }
+ catch (Npgsql.PostgresException ex) when (ex.SqlState == "42P07")
+ {
+ // Already exists.
+ }
+
+ db.Database.ExecuteSqlRaw("TRUNCATE TABLE test_authz.owned_books RESTART IDENTITY CASCADE");
+ db.Database.ExecuteSqlRaw("TRUNCATE TABLE test_authz.owners RESTART IDENTITY CASCADE");
+ db.Database.ExecuteSqlRaw("TRUNCATE TABLE test_authz.memos RESTART IDENTITY");
+ db.Database.ExecuteSqlRaw("TRUNCATE TABLE test_authz.restricted_docs RESTART IDENTITY");
+ db.Database.ExecuteSqlRaw("TRUNCATE TABLE test_authz.member_areas RESTART IDENTITY");
+
+ var alice = new Owner
+ {
+ Name = "Alice",
+ Books =
+ {
+ new OwnedBook { Title = "Alice's First Book" },
+ new OwnedBook { Title = "Alice's Second Book" },
+ },
+ };
+ var bob = new Owner
+ {
+ Name = "Bob",
+ Books = { new OwnedBook { Title = "Bob's Only Book" } },
+ };
+ db.Owners.AddRange(alice, bob);
+
+ db.Memos.AddRange(new Memo { Body = "memo-1" }, new Memo { Body = "memo-2" });
+ db.RestrictedDocs.Add(new RestrictedDoc { Payload = "restricted-1" });
+ db.MemberAreas.Add(new MemberArea { Name = "lounge" });
+
+ db.SaveChanges();
+ }
+}
diff --git a/tests/Trax.Api.Tests/AuthE2E/QueryModelAuthorizeE2ETests.cs b/tests/Trax.Api.Tests/AuthE2E/QueryModelAuthorizeE2ETests.cs
new file mode 100644
index 0000000..890b88b
--- /dev/null
+++ b/tests/Trax.Api.Tests/AuthE2E/QueryModelAuthorizeE2ETests.cs
@@ -0,0 +1,538 @@
+using System.Net.Http.Headers;
+using System.Text.Json;
+using FluentAssertions;
+using static Trax.Api.Tests.AuthE2E.AuthE2EHost;
+
+namespace Trax.Api.Tests.AuthE2E;
+
+///
+/// End-to-end coverage for [TraxAuthorize] applied to
+/// [TraxQueryModel]-annotated entities. The attribute attaches the
+/// @authorize directive to the generated ObjectType so the
+/// gate enforces uniformly regardless of how the type is reached:
+///
+///
+/// - top-level discover.vault.ownedBooks (direct entry point)
+/// - transitively via discover.vault.owners[].books (the navigation
+/// path through an ungated parent)
+///
+///
+/// The transitive case is the security-critical contract: a Player must not be
+/// able to read rows by traversing into them from
+/// , which is itself ungated.
+///
+[TestFixture]
+[NonParallelizable]
+public class QueryModelAuthorizeE2ETests
+{
+ private const string Database = "trax_api_auth_querymodel";
+
+ private static Task StartAsync(Schemes s) =>
+ AuthE2EHost.StartAsync(s, Database);
+
+ [OneTimeSetUp]
+ public void SeedDatabase()
+ {
+ // Both contexts use the same DB; seed the unauthorized fixtures too so
+ // any shared startup paths that touch test_auth do not blow up.
+ TestDbContext.EnsureSeeded(AuthE2EHost.ConnectionString(Database));
+ AuthzTestDbContext.EnsureSeeded(AuthE2EHost.ConnectionString(Database));
+ }
+
+ // ── Queries ───────────────────────────────────────────────────────────
+
+ private const string DirectOwnedBooksQuery = """
+ { discover { vault { ownedBooks { totalCount nodes { title } } } } }
+ """;
+
+ private const string OwnersOnlyQuery = """
+ { discover { vault { owners { totalCount nodes { name } } } } }
+ """;
+
+ ///
+ /// The transitive-navigation probe. Requests OwnedBook through the Owner's
+ /// books navigation. Must fail for anyone lacking the OwnedBook gate.
+ ///
+ private const string OwnersWithBooksQuery = """
+ {
+ discover {
+ vault {
+ owners {
+ nodes { name books { title } }
+ }
+ }
+ }
+ }
+ """;
+
+ private const string MemosQuery = """
+ { discover { vault { memos { totalCount } } } }
+ """;
+
+ private const string RestrictedDocsQuery = """
+ { discover { vault { restrictedDocs { totalCount } } } }
+ """;
+
+ private const string MemberAreasQuery = """
+ { discover { vault { memberAreas { totalCount } } } }
+ """;
+
+ // ── Top-level gating: Roles="Admin" ──────────────────────────────────
+
+ [Test]
+ public async Task DirectOwnedBooks_AsAdmin_Succeeds()
+ {
+ using var host = await StartAsync(Schemes.ApiKey);
+
+ using var doc = await host.PostGraphQLAsync(
+ DirectOwnedBooksQuery,
+ req => req.Headers.Add("X-Api-Key", AdminApiKey)
+ );
+
+ AssertNoErrors(doc);
+ OwnedBooksField(doc).GetProperty("totalCount").GetInt32().Should().Be(3);
+ }
+
+ [Test]
+ public async Task DirectOwnedBooks_AsPlayer_ReturnsAuthorizationError()
+ {
+ using var host = await StartAsync(Schemes.ApiKey);
+
+ using var doc = await host.PostGraphQLAsync(
+ DirectOwnedBooksQuery,
+ req => req.Headers.Add("X-Api-Key", PlayerApiKey)
+ );
+
+ AssertTraxAuthorizationError(doc);
+ }
+
+ [Test]
+ public async Task DirectOwnedBooks_Anonymous_ReturnsAuthorizationError()
+ {
+ using var host = await StartAsync(Schemes.ApiKey);
+
+ using var doc = await host.PostGraphQLAsync(DirectOwnedBooksQuery);
+
+ AssertTraxAuthorizationError(doc);
+ }
+
+ // ── Ungated sibling remains reachable ────────────────────────────────
+
+ [Test]
+ public async Task UngatedOwners_AsPlayer_Succeeds()
+ {
+ using var host = await StartAsync(Schemes.ApiKey);
+
+ using var doc = await host.PostGraphQLAsync(
+ OwnersOnlyQuery,
+ req => req.Headers.Add("X-Api-Key", PlayerApiKey)
+ );
+
+ AssertNoErrors(doc);
+ doc.RootElement.GetProperty("data")
+ .GetProperty("discover")
+ .GetProperty("vault")
+ .GetProperty("owners")
+ .GetProperty("totalCount")
+ .GetInt32()
+ .Should()
+ .Be(2);
+ }
+
+ [Test]
+ public async Task UngatedOwners_Anonymous_Succeeds()
+ {
+ using var host = await StartAsync(Schemes.ApiKey);
+
+ using var doc = await host.PostGraphQLAsync(OwnersOnlyQuery);
+
+ AssertNoErrors(doc);
+ doc.RootElement.GetProperty("data")
+ .GetProperty("discover")
+ .GetProperty("vault")
+ .GetProperty("owners")
+ .GetProperty("totalCount")
+ .GetInt32()
+ .Should()
+ .Be(2);
+ }
+
+ // ── CRITICAL: transitive navigation enforcement ──────────────────────
+
+ [Test]
+ public async Task TransitiveBooks_AsPlayer_FailsAuthorizationOnChildren()
+ {
+ using var host = await StartAsync(Schemes.ApiKey);
+
+ using var doc = await host.PostGraphQLAsync(
+ OwnersWithBooksQuery,
+ req => req.Headers.Add("X-Api-Key", PlayerApiKey)
+ );
+
+ AssertTraxAuthorizationError(doc);
+ }
+
+ [Test]
+ public async Task TransitiveBooks_Anonymous_FailsAuthorizationOnChildren()
+ {
+ using var host = await StartAsync(Schemes.ApiKey);
+
+ using var doc = await host.PostGraphQLAsync(OwnersWithBooksQuery);
+
+ AssertTraxAuthorizationError(doc);
+ }
+
+ [Test]
+ public async Task TransitiveBooks_AsAdmin_Succeeds()
+ {
+ using var host = await StartAsync(Schemes.ApiKey);
+
+ using var doc = await host.PostGraphQLAsync(
+ OwnersWithBooksQuery,
+ req => req.Headers.Add("X-Api-Key", AdminApiKey)
+ );
+
+ AssertNoErrors(doc);
+ var owners = doc
+ .RootElement.GetProperty("data")
+ .GetProperty("discover")
+ .GetProperty("vault")
+ .GetProperty("owners")
+ .GetProperty("nodes")
+ .EnumerateArray()
+ .ToList();
+ owners.Should().HaveCount(2);
+ var alice = owners.Single(o => o.GetProperty("name").GetString() == "Alice");
+ alice.GetProperty("books").GetArrayLength().Should().Be(2);
+ }
+
+ [Test]
+ public async Task TransitiveBooks_AsPlayer_DoesNotLeakBookTitlesInPayload()
+ {
+ using var host = await StartAsync(Schemes.ApiKey);
+
+ using var doc = await host.PostGraphQLAsync(
+ OwnersWithBooksQuery,
+ req => req.Headers.Add("X-Api-Key", PlayerApiKey)
+ );
+
+ // No book title from the seed fixture may appear anywhere in the
+ // response body — not in data, not in errors, not in extensions.
+ var raw = doc.RootElement.GetRawText();
+ raw.Should().NotContain("Alice's First Book");
+ raw.Should().NotContain("Alice's Second Book");
+ raw.Should().NotContain("Bob's Only Book");
+ }
+
+ [Test]
+ public async Task TransitiveBooks_Anonymous_DoesNotLeakBookTitlesInPayload()
+ {
+ using var host = await StartAsync(Schemes.ApiKey);
+
+ using var doc = await host.PostGraphQLAsync(OwnersWithBooksQuery);
+
+ var raw = doc.RootElement.GetRawText();
+ raw.Should().NotContain("Alice's First Book");
+ raw.Should().NotContain("Alice's Second Book");
+ raw.Should().NotContain("Bob's Only Book");
+ }
+
+ // ── JWT parity ──────────────────────────────────────────────────────
+
+ [Test]
+ public async Task DirectOwnedBooks_AsAdminJwt_Succeeds()
+ {
+ using var host = await StartAsync(Schemes.Jwt);
+ var token = SignJwt("alice", "Alice", "Admin");
+
+ using var doc = await host.PostGraphQLAsync(
+ DirectOwnedBooksQuery,
+ req => req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token)
+ );
+
+ AssertNoErrors(doc);
+ OwnedBooksField(doc).GetProperty("totalCount").GetInt32().Should().Be(3);
+ }
+
+ [Test]
+ public async Task DirectOwnedBooks_AsPlayerJwt_ReturnsAuthorizationError()
+ {
+ using var host = await StartAsync(Schemes.Jwt);
+ var token = SignJwt("alice", "Alice", "Player");
+
+ using var doc = await host.PostGraphQLAsync(
+ DirectOwnedBooksQuery,
+ req => req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token)
+ );
+
+ AssertTraxAuthorizationError(doc);
+ }
+
+ [Test]
+ public async Task TransitiveBooks_AsPlayerJwt_FailsAuthorizationOnChildren()
+ {
+ using var host = await StartAsync(Schemes.Jwt);
+ var token = SignJwt("alice", "Alice", "Player");
+
+ using var doc = await host.PostGraphQLAsync(
+ OwnersWithBooksQuery,
+ req => req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token)
+ );
+
+ AssertTraxAuthorizationError(doc);
+ }
+
+ // ── CSV roles (OR within attribute) ──────────────────────────────────
+
+ [Test]
+ public async Task CsvRoles_AsAdmin_Succeeds()
+ {
+ using var host = await StartAsync(Schemes.ApiKey);
+
+ using var doc = await host.PostGraphQLAsync(
+ MemosQuery,
+ req => req.Headers.Add("X-Api-Key", AdminApiKey)
+ );
+
+ AssertNoErrors(doc);
+ VaultField(doc, "memos").GetProperty("totalCount").GetInt32().Should().Be(2);
+ }
+
+ [Test]
+ public async Task CsvRoles_AsPlayer_Succeeds()
+ {
+ using var host = await StartAsync(Schemes.ApiKey);
+
+ using var doc = await host.PostGraphQLAsync(
+ MemosQuery,
+ req => req.Headers.Add("X-Api-Key", PlayerApiKey)
+ );
+
+ AssertNoErrors(doc);
+ VaultField(doc, "memos").GetProperty("totalCount").GetInt32().Should().Be(2);
+ }
+
+ [Test]
+ public async Task CsvRoles_Anonymous_ReturnsAuthorizationError()
+ {
+ using var host = await StartAsync(Schemes.ApiKey);
+
+ using var doc = await host.PostGraphQLAsync(MemosQuery);
+
+ AssertTraxAuthorizationError(doc);
+ }
+
+ // ── Stacked attributes (AND across) ──────────────────────────────────
+
+ [Test]
+ public async Task StackedAttributes_AsAdmin_SatisfiesBoth()
+ {
+ using var host = await StartAsync(Schemes.ApiKey);
+
+ using var doc = await host.PostGraphQLAsync(
+ RestrictedDocsQuery,
+ req => req.Headers.Add("X-Api-Key", AdminApiKey)
+ );
+
+ AssertNoErrors(doc);
+ VaultField(doc, "restrictedDocs").GetProperty("totalCount").GetInt32().Should().Be(1);
+ }
+
+ [Test]
+ public async Task StackedAttributes_AsPlayer_FailsOnRole()
+ {
+ using var host = await StartAsync(Schemes.ApiKey);
+
+ using var doc = await host.PostGraphQLAsync(
+ RestrictedDocsQuery,
+ req => req.Headers.Add("X-Api-Key", PlayerApiKey)
+ );
+
+ AssertTraxAuthorizationError(doc);
+ }
+
+ // ── Bare [TraxAuthorize] (any authenticated user) ───────────────────
+
+ [Test]
+ public async Task BareAuthorize_AsAdmin_Succeeds()
+ {
+ using var host = await StartAsync(Schemes.ApiKey);
+
+ using var doc = await host.PostGraphQLAsync(
+ MemberAreasQuery,
+ req => req.Headers.Add("X-Api-Key", AdminApiKey)
+ );
+
+ AssertNoErrors(doc);
+ VaultField(doc, "memberAreas").GetProperty("totalCount").GetInt32().Should().Be(1);
+ }
+
+ [Test]
+ public async Task BareAuthorize_AsPlayer_Succeeds()
+ {
+ using var host = await StartAsync(Schemes.ApiKey);
+
+ using var doc = await host.PostGraphQLAsync(
+ MemberAreasQuery,
+ req => req.Headers.Add("X-Api-Key", PlayerApiKey)
+ );
+
+ AssertNoErrors(doc);
+ VaultField(doc, "memberAreas").GetProperty("totalCount").GetInt32().Should().Be(1);
+ }
+
+ [Test]
+ public async Task BareAuthorize_Anonymous_ReturnsAuthorizationError()
+ {
+ using var host = await StartAsync(Schemes.ApiKey);
+
+ using var doc = await host.PostGraphQLAsync(MemberAreasQuery);
+
+ AssertTraxAuthorizationError(doc);
+ }
+
+ // ── Connection-shape side channels (totalCount, pageInfo) ───────────
+ //
+ // Type-level @authorize alone is not enough: a request that selects only
+ // Connection scalars like `totalCount` or `pageInfo` never resolves a
+ // node of the gated entity type, so the type-level directive never fires.
+ // The implementation also gates the entry field, which closes the
+ // side channel. These tests pin the closure: they MUST fail for an
+ // unauthorized caller, even though they ask for nothing about the entity
+ // itself.
+
+ [Test]
+ public async Task DirectOwnedBooks_TotalCountOnly_AsPlayer_StillBlocked()
+ {
+ using var host = await StartAsync(Schemes.ApiKey);
+
+ using var doc = await host.PostGraphQLAsync(
+ "{ discover { vault { ownedBooks { totalCount } } } }",
+ req => req.Headers.Add("X-Api-Key", PlayerApiKey)
+ );
+
+ AssertTraxAuthorizationError(doc);
+ }
+
+ [Test]
+ public async Task DirectOwnedBooks_PageInfoOnly_AsPlayer_StillBlocked()
+ {
+ using var host = await StartAsync(Schemes.ApiKey);
+
+ using var doc = await host.PostGraphQLAsync(
+ "{ discover { vault { ownedBooks { pageInfo { hasNextPage } } } } }",
+ req => req.Headers.Add("X-Api-Key", PlayerApiKey)
+ );
+
+ AssertTraxAuthorizationError(doc);
+ }
+
+ [Test]
+ public async Task DirectOwnedBooks_TotalCountOnly_Anonymous_StillBlocked()
+ {
+ using var host = await StartAsync(Schemes.ApiKey);
+
+ using var doc = await host.PostGraphQLAsync(
+ "{ discover { vault { ownedBooks { totalCount } } } }"
+ );
+
+ AssertTraxAuthorizationError(doc);
+ }
+
+ [Test]
+ public async Task DirectOwnedBooks_EdgesCursorOnly_AsPlayer_StillBlocked()
+ {
+ // edges.cursor doesn't materialize a node, but the entry field itself
+ // is gated — the request must still be denied.
+ using var host = await StartAsync(Schemes.ApiKey);
+
+ using var doc = await host.PostGraphQLAsync(
+ "{ discover { vault { ownedBooks { edges { cursor } } } } }",
+ req => req.Headers.Add("X-Api-Key", PlayerApiKey)
+ );
+
+ AssertTraxAuthorizationError(doc);
+ }
+
+ [Test]
+ public async Task DirectOwnedBooks_FilteredCount_AsPlayer_StillBlocked()
+ {
+ // totalCount with a `where` clause could otherwise be used to probe
+ // for the presence of rows matching arbitrary predicates (e.g.,
+ // "does a book titled 'secret' exist?"). Field-level auth closes
+ // that probe.
+ using var host = await StartAsync(Schemes.ApiKey);
+
+ using var doc = await host.PostGraphQLAsync(
+ "{ discover { vault { ownedBooks(where: { title: { contains: \"secret\" } }) { totalCount } } } }",
+ req => req.Headers.Add("X-Api-Key", PlayerApiKey)
+ );
+
+ AssertTraxAuthorizationError(doc);
+ }
+
+ [Test]
+ public async Task TransitiveBooks_TypenameOnly_AsPlayer_StillBlocked()
+ {
+ // Even __typename on a transitively reached gated type must fail —
+ // the type-level directive should fire before any field on the
+ // gated type is exposed, including the meta __typename field.
+ using var host = await StartAsync(Schemes.ApiKey);
+
+ using var doc = await host.PostGraphQLAsync(
+ "{ discover { vault { owners { nodes { name books { __typename } } } } } }",
+ req => req.Headers.Add("X-Api-Key", PlayerApiKey)
+ );
+
+ AssertTraxAuthorizationError(doc);
+ }
+
+ // ── Error-shape / opacity guards ─────────────────────────────────────
+
+ [Test]
+ public async Task ErrorMessage_DoesNotLeakEntityNameOrRoleName()
+ {
+ using var host = await StartAsync(Schemes.ApiKey);
+
+ using var doc = await host.PostGraphQLAsync(
+ DirectOwnedBooksQuery,
+ req => req.Headers.Add("X-Api-Key", PlayerApiKey)
+ );
+
+ var raw = doc.RootElement.GetRawText();
+ raw.Should().NotContain("OwnedBook");
+ raw.Should().NotContain("Admin", "role name should not leak in the error body");
+ }
+
+ // ── Helpers ──────────────────────────────────────────────────────────
+
+ private static JsonElement OwnedBooksField(JsonDocument doc) => VaultField(doc, "ownedBooks");
+
+ private static JsonElement VaultField(JsonDocument doc, string fieldName) =>
+ doc
+ .RootElement.GetProperty("data")
+ .GetProperty("discover")
+ .GetProperty("vault")
+ .GetProperty(fieldName);
+
+ private static void AssertNoErrors(JsonDocument doc) =>
+ doc
+ .RootElement.TryGetProperty("errors", out _)
+ .Should()
+ .BeFalse(doc.RootElement.GetRawText());
+
+ private static void AssertTraxAuthorizationError(JsonDocument doc)
+ {
+ doc.RootElement.TryGetProperty("errors", out var errors).Should().BeTrue();
+ errors.GetArrayLength().Should().BeGreaterThan(0);
+
+ var first = errors[0];
+ first.GetProperty("message").GetString().Should().Be("Not authorized.");
+ first
+ .GetProperty("extensions")
+ .GetProperty("code")
+ .GetString()
+ .Should()
+ .Be("TRAX_AUTHORIZATION");
+ }
+}
diff --git a/tests/Trax.Api.Tests/AuthE2E/QueryModelAuthorizeSchemaInvariantE2ETests.cs b/tests/Trax.Api.Tests/AuthE2E/QueryModelAuthorizeSchemaInvariantE2ETests.cs
new file mode 100644
index 0000000..828ade2
--- /dev/null
+++ b/tests/Trax.Api.Tests/AuthE2E/QueryModelAuthorizeSchemaInvariantE2ETests.cs
@@ -0,0 +1,105 @@
+using FluentAssertions;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Trax.Api.GraphQL.Extensions;
+using Trax.Effect.Data.Extensions;
+using Trax.Effect.Data.Postgres.Extensions;
+using Trax.Effect.Extensions;
+using Trax.Effect.Provider.Json.Extensions;
+using Trax.Mediator.Extensions;
+
+namespace Trax.Api.Tests.AuthE2E;
+
+///
+/// Defense-in-depth coverage for QueryModelAuthorizationSchemaValidator.
+/// The validator runs at host start, materialises the GraphQL schema, and
+/// reasserts that every [TraxAuthorize]-gated entity still carries
+/// its @authorize directive at both type level and entry-field level.
+///
+///
+/// The point of these tests is to prove that the gate survives the full
+/// configuration pipeline end-to-end. A separate suite of unit tests
+/// (QueryModelAuthorizationSchemaValidatorTests) exercises the
+/// validator directly against hand-rolled schemas where the directive has
+/// been stripped, since the "naive" bypass attempts via ConfigureSchema
+/// (e.g. duplicate ObjectType registrations) get rejected by
+/// HotChocolate's own type-uniqueness check before the validator even runs.
+/// That's a fine outcome — the host still fails to start, the security
+/// posture holds — but it does not exercise the validator's code path.
+///
+///
+[TestFixture]
+[NonParallelizable]
+public class QueryModelAuthorizeSchemaInvariantE2ETests
+{
+ private const string Database = "trax_api_auth_schema_inv";
+
+ [OneTimeSetUp]
+ public void SeedDatabase() =>
+ AuthzTestDbContext.EnsureSeeded(AuthE2EHost.ConnectionString(Database));
+
+ ///
+ /// Baseline: a normally-configured host starts cleanly and the validator
+ /// signs off on the materialised schema. Pins that the validator is not
+ /// over-zealous and rejecting valid schemas.
+ ///
+ [Test]
+ public async Task NormalHost_PassesValidator_AndHostStartsSuccessfully()
+ {
+ using var host = await BuildHostAsync();
+ host.Should().NotBeNull();
+ // Reaching here means StartAsync completed without the validator
+ // throwing — every gated entity passed both type-level and entry-field
+ // directive checks against the real, fully-built schema.
+ }
+
+ private static async Task BuildHostAsync()
+ {
+ AuthE2EHost.EnsureDatabaseExists(Database);
+ var connectionString = AuthE2EHost.ConnectionString(Database);
+ var host = new HostBuilder()
+ .ConfigureWebHost(web =>
+ web.UseTestServer()
+ .ConfigureServices(services =>
+ {
+ services.AddLogging();
+ services.AddRouting();
+ services.AddAuthorization(opts =>
+ opts.AddPolicy("AdminPolicy", p => p.RequireRole("Admin"))
+ );
+
+ services.AddTrax(trax =>
+ trax.AddEffects(effects =>
+ effects.UsePostgres(connectionString).AddJson()
+ )
+ .AddMediator(
+ typeof(QueryModelAuthorizeSchemaInvariantE2ETests).Assembly
+ )
+ );
+
+ services.AddDbContextFactory(o =>
+ o.UseNpgsql(connectionString)
+ );
+
+ services.AddTraxGraphQL(graphql =>
+ graphql.AddDbContext()
+ );
+ })
+ .Configure(app =>
+ {
+ app.UseRouting();
+ app.UseEndpoints(endpoints =>
+ endpoints.MapGraphQL("/trax/graphql", "trax")
+ );
+ })
+ )
+ .Build();
+
+ await host.StartAsync();
+ return host;
+ }
+}
diff --git a/tests/Trax.Api.Tests/AuthE2E/TestDbContext.cs b/tests/Trax.Api.Tests/AuthE2E/TestDbContext.cs
index d7fba43..88223aa 100644
--- a/tests/Trax.Api.Tests/AuthE2E/TestDbContext.cs
+++ b/tests/Trax.Api.Tests/AuthE2E/TestDbContext.cs
@@ -50,6 +50,10 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
///
public static void EnsureSeeded(string connectionString)
{
+ AuthE2EHost.EnsureDatabaseExists(
+ new Npgsql.NpgsqlConnectionStringBuilder(connectionString).Database!
+ );
+
var opts = new DbContextOptionsBuilder().UseNpgsql(connectionString).Options;
using var db = new TestDbContext(opts);
diff --git a/tests/Trax.Api.Tests/GraphQLModelExposureWarningServiceTests.cs b/tests/Trax.Api.Tests/GraphQLModelExposureWarningServiceTests.cs
new file mode 100644
index 0000000..a9cc8c5
--- /dev/null
+++ b/tests/Trax.Api.Tests/GraphQLModelExposureWarningServiceTests.cs
@@ -0,0 +1,195 @@
+using System.ComponentModel.DataAnnotations.Schema;
+using FluentAssertions;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using NSubstitute;
+using Trax.Api.GraphQL.Configuration.TraxGraphQLBuilder;
+using Trax.Api.GraphQL.Startup;
+using Trax.Effect.Attributes;
+
+namespace Trax.Api.Tests;
+
+///
+/// Coverage for the silent-success early-return paths in
+/// . The warning's existence
+/// is exercised wherever an ungated model is registered (e.g.
+/// ModelQueryAuthE2ETests), but the early-out paths — no models at all,
+/// or every model gated — never log. A regression that removes those early
+/// returns would emit spurious warnings on well-configured hosts; these tests
+/// pin the contract.
+///
+[TestFixture]
+public class GraphQLModelExposureWarningServiceTests
+{
+ [Test]
+ public async Task StartAsync_NoModelRegistrations_LogsNothing()
+ {
+ // Host that registers no [TraxQueryModel] entities at all. The
+ // warning service must short-circuit before computing anything.
+ var services = new ServiceCollection();
+ var config = new TraxGraphQLBuilder(services).Build();
+ config.ModelRegistrations.Should().BeEmpty();
+
+ var logger = new RecordingLogger();
+ var sut = new GraphQLModelExposureWarningService(config, logger);
+
+ await sut.StartAsync(CancellationToken.None);
+
+ logger.Entries.Should().BeEmpty();
+ }
+
+ [Test]
+ public async Task StartAsync_AllRegistrationsGated_LogsNothing()
+ {
+ // Every registered entity carries [TraxAuthorize] — there's no
+ // ungated surface to warn about. The warning would be misleading
+ // ("Trax GraphQL: 0 of N model query registration(s) carry no
+ // [TraxAuthorize]") so the service must skip emission entirely.
+ var services = new ServiceCollection();
+ var config = new TraxGraphQLBuilder(services).AddDbContext().Build();
+ config.ModelRegistrations.Should().NotBeEmpty();
+ config
+ .ModelRegistrations.All(r => r.AuthorizeAttributes.Count > 0)
+ .Should()
+ .BeTrue("the fixture must register only gated entities");
+
+ var logger = new RecordingLogger();
+ var sut = new GraphQLModelExposureWarningService(config, logger);
+
+ await sut.StartAsync(CancellationToken.None);
+
+ logger.Entries.Should().BeEmpty();
+ }
+
+ [Test]
+ public async Task StartAsync_SomeUngated_LogsWarningWithBothCounts()
+ {
+ // One gated + one ungated. The warning message must name both the
+ // ungated count and the total — that's how a reviewer of host logs
+ // distinguishes "I forgot to gate one entity" from "the gate isn't
+ // wired at all."
+ var services = new ServiceCollection();
+ var config = new TraxGraphQLBuilder(services).AddDbContext().Build();
+ config.ModelRegistrations.Should().HaveCount(2);
+
+ var logger = new RecordingLogger();
+ var sut = new GraphQLModelExposureWarningService(config, logger);
+
+ await sut.StartAsync(CancellationToken.None);
+
+ logger.Entries.Should().HaveCount(1);
+ var entry = logger.Entries.Single();
+ entry.Level.Should().Be(LogLevel.Warning);
+ entry.Message.Should().Contain("model query registration");
+ // Count tokens get materialised via structured-log argument formatting;
+ // assert both 1 (ungated) and 2 (total) appear somewhere in the rendered
+ // body so a refactor that drops one count is caught.
+ entry.RenderedMessage.Should().Contain("1");
+ entry.RenderedMessage.Should().Contain("2");
+ }
+
+ [Test]
+ public async Task StopAsync_CompletesSynchronously()
+ {
+ // The warning service owns no resources. Pin the IHostedService
+ // contract — a future refactor that adds async cleanup to release
+ // something must update this test instead of silently changing the
+ // host-shutdown shape.
+ var services = new ServiceCollection();
+ var config = new TraxGraphQLBuilder(services).Build();
+ var logger = new RecordingLogger();
+ var sut = new GraphQLModelExposureWarningService(config, logger);
+
+ var task = sut.StopAsync(CancellationToken.None);
+
+ task.IsCompletedSuccessfully.Should().BeTrue();
+ await task;
+ }
+
+ // ── Fixture entities ────────────────────────────────────────────────
+
+ [TraxQueryModel]
+ [TraxAuthorize(Roles = "Admin")]
+ [Table("gated_a", Schema = "test_warn")]
+ private class GatedA
+ {
+ [Column("id")]
+ public long Id { get; set; }
+ }
+
+ [TraxQueryModel]
+ [TraxAuthorize(Roles = "Admin")]
+ [Table("gated_b", Schema = "test_warn")]
+ private class GatedB
+ {
+ [Column("id")]
+ public long Id { get; set; }
+ }
+
+ [TraxQueryModel]
+ [Table("ungated", Schema = "test_warn")]
+ private class Ungated
+ {
+ [Column("id")]
+ public long Id { get; set; }
+ }
+
+ private class AllGatedDbContext(DbContextOptions options)
+ : DbContext(options)
+ {
+ public DbSet A { get; set; } = null!;
+ public DbSet B { get; set; } = null!;
+ }
+
+ private class MixedDbContext(DbContextOptions options) : DbContext(options)
+ {
+ public DbSet Gated { get; set; } = null!;
+ public DbSet Ungated { get; set; } = null!;
+ }
+
+ // ── Logger ──────────────────────────────────────────────────────────
+
+ private sealed class RecordingLogger : ILogger
+ {
+ public List Entries { get; } = new();
+
+ public IDisposable? BeginScope(TState state)
+ where TState : notnull => NullScope.Instance;
+
+ public bool IsEnabled(LogLevel logLevel) => true;
+
+ public void Log(
+ LogLevel logLevel,
+ EventId eventId,
+ TState state,
+ Exception? exception,
+ Func formatter
+ )
+ {
+ Entries.Add(
+ new LogEntry
+ {
+ Level = logLevel,
+ Message = state?.ToString() ?? string.Empty,
+ RenderedMessage = formatter(state, exception),
+ }
+ );
+ }
+
+ private sealed class NullScope : IDisposable
+ {
+ public static NullScope Instance { get; } = new();
+
+ public void Dispose() { }
+ }
+ }
+
+ private sealed class LogEntry
+ {
+ public LogLevel Level { get; init; }
+ public string Message { get; init; } = "";
+ public string RenderedMessage { get; init; } = "";
+ }
+}
diff --git a/tests/Trax.Api.Tests/QueryModelAuthenticationInterceptorTests.cs b/tests/Trax.Api.Tests/QueryModelAuthenticationInterceptorTests.cs
new file mode 100644
index 0000000..497dd05
--- /dev/null
+++ b/tests/Trax.Api.Tests/QueryModelAuthenticationInterceptorTests.cs
@@ -0,0 +1,206 @@
+using System.Security.Claims;
+using FluentAssertions;
+using HotChocolate.Execution;
+using HotChocolate.Execution.Configuration;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using NSubstitute;
+using Trax.Api.GraphQL.Authorization;
+
+namespace Trax.Api.Tests;
+
+///
+/// Direct unit coverage for .
+/// The interceptor populates HttpContext.User by walking every registered
+/// authentication scheme; the E2E suite proves the happy path against real HC
+/// infrastructure, while these tests pin the branches around it — the
+/// short-circuit when the request is already authenticated, and the silent
+/// no-op when no scheme matches the inbound credentials.
+///
+[TestFixture]
+public class QueryModelAuthenticationInterceptorTests
+{
+ [Test]
+ public async Task OnCreateAsync_UserAlreadyAuthenticated_DoesNotWalkSchemes()
+ {
+ // Upstream middleware (e.g. endpoint-level RequireAuthorization or a
+ // default-scheme UseAuthentication() pass) has already authenticated
+ // the request. The interceptor must NOT walk schemes again — doing so
+ // would double-authenticate, potentially overwriting a richer principal
+ // with one from a fall-through scheme.
+ var schemeProvider = Substitute.For();
+ var httpContext = BuildHttpContext(
+ authenticatedAs: new ClaimsPrincipal(
+ new ClaimsIdentity(
+ new[] { new Claim(ClaimTypes.Name, "alice") },
+ authenticationType: "preauth"
+ )
+ )
+ );
+
+ var sut = new QueryModelAuthenticationInterceptor(schemeProvider);
+
+ await sut.OnCreateAsync(
+ httpContext,
+ Substitute.For(),
+ OperationRequestBuilder.New(),
+ CancellationToken.None
+ );
+
+ await schemeProvider.DidNotReceive().GetAllSchemesAsync();
+ httpContext.User.Identity!.Name.Should().Be("alice");
+ httpContext.User.Identity.AuthenticationType.Should().Be("preauth");
+ }
+
+ [Test]
+ public async Task OnCreateAsync_NoSchemeSucceeds_LeavesUserAnonymous()
+ {
+ // None of the registered schemes recognise the request's credentials
+ // (e.g. an anonymous request, or a request whose Bearer token does not
+ // match any configured issuer). The interceptor must finish with the
+ // anonymous principal intact so HC's @authorize directive can reject
+ // the request on its own terms — not crash trying to read a partial
+ // half-authenticated principal.
+ var schemeA = new AuthenticationScheme(
+ "schemeA",
+ displayName: "A",
+ typeof(NoResultAuthenticationHandler)
+ );
+ var schemeB = new AuthenticationScheme(
+ "schemeB",
+ displayName: "B",
+ typeof(NoResultAuthenticationHandler)
+ );
+
+ var schemeProvider = Substitute.For();
+ schemeProvider.GetAllSchemesAsync().Returns(new[] { schemeA, schemeB });
+
+ var httpContext = BuildHttpContext(authenticatedAs: null);
+
+ var sut = new QueryModelAuthenticationInterceptor(schemeProvider);
+
+ await sut.OnCreateAsync(
+ httpContext,
+ Substitute.For(),
+ OperationRequestBuilder.New(),
+ CancellationToken.None
+ );
+
+ httpContext.User.Identity!.IsAuthenticated.Should().BeFalse();
+ }
+
+ [Test]
+ public async Task OnCreateAsync_FirstSchemeSucceeds_AssignsItsPrincipalAndStopsWalking()
+ {
+ // Two schemes are registered; the first one returns a successful
+ // ticket. The interceptor must assign that principal and stop —
+ // continuing into the second scheme could overwrite the principal
+ // and would also waste cycles (e.g. JWT validation against a JWKS).
+ var winningPrincipal = new ClaimsPrincipal(
+ new ClaimsIdentity(
+ new[] { new Claim(ClaimTypes.Name, "winner") },
+ authenticationType: "schemeA"
+ )
+ );
+
+ var winningScheme = new AuthenticationScheme(
+ "schemeA",
+ displayName: "A",
+ typeof(SuccessAuthenticationHandler)
+ );
+ var loserScheme = new AuthenticationScheme(
+ "schemeB",
+ displayName: "B",
+ typeof(ThrowingAuthenticationHandler)
+ );
+ var schemeProvider = Substitute.For();
+ schemeProvider.GetAllSchemesAsync().Returns(new[] { winningScheme, loserScheme });
+
+ var httpContext = BuildHttpContext(
+ authenticatedAs: null,
+ successPrincipalForScheme: ("schemeA", winningPrincipal)
+ );
+
+ var sut = new QueryModelAuthenticationInterceptor(schemeProvider);
+
+ await sut.OnCreateAsync(
+ httpContext,
+ Substitute.For(),
+ OperationRequestBuilder.New(),
+ CancellationToken.None
+ );
+
+ httpContext.User.Identity!.Name.Should().Be("winner");
+ httpContext.User.Identity.AuthenticationType.Should().Be("schemeA");
+ }
+
+ // ── Helpers ─────────────────────────────────────────────────────────
+
+ ///
+ /// Builds an HttpContext wired with an IAuthenticationService whose
+ /// AuthenticateAsync produces either NoResult (for every scheme) or a
+ /// successful ticket only for a named scheme.
+ ///
+ private static HttpContext BuildHttpContext(
+ ClaimsPrincipal? authenticatedAs,
+ (string Scheme, ClaimsPrincipal Principal)? successPrincipalForScheme = null
+ )
+ {
+ var ctx = new DefaultHttpContext();
+ if (authenticatedAs is not null)
+ ctx.User = authenticatedAs;
+
+ var authService = Substitute.For();
+ if (successPrincipalForScheme is { } match)
+ {
+ authService
+ .AuthenticateAsync(ctx, match.Scheme)
+ .Returns(
+ AuthenticateResult.Success(
+ new AuthenticationTicket(match.Principal, match.Scheme)
+ )
+ );
+ }
+ // All other scheme names return NoResult — the default for a substitute
+ // returns null which IAuthenticationService.AuthenticateAsync interprets
+ // as failure, but to keep the path explicit we wire NoResult for any
+ // unhandled scheme name.
+ authService
+ .AuthenticateAsync(ctx, Arg.Any())
+ .Returns(callInfo =>
+ {
+ if (successPrincipalForScheme is { } m && callInfo.ArgAt(1) == m.Scheme)
+ return Task.FromResult(
+ AuthenticateResult.Success(new AuthenticationTicket(m.Principal, m.Scheme))
+ );
+ return Task.FromResult(AuthenticateResult.NoResult());
+ });
+
+ var services = new ServiceCollection();
+ services.AddSingleton(authService);
+ ctx.RequestServices = services.BuildServiceProvider();
+ return ctx;
+ }
+
+ // The handler types below exist solely as type tokens on AuthenticationScheme.
+ // Authentication is short-circuited via the IAuthenticationService substitute
+ // wired into HttpContext.RequestServices, so these handlers never execute.
+ private class NoResultAuthenticationHandler : IAuthenticationHandler
+ {
+ public Task AuthenticateAsync() =>
+ Task.FromResult(AuthenticateResult.NoResult());
+
+ public Task ChallengeAsync(AuthenticationProperties? properties) => Task.CompletedTask;
+
+ public Task ForbidAsync(AuthenticationProperties? properties) => Task.CompletedTask;
+
+ public Task InitializeAsync(AuthenticationScheme scheme, HttpContext context) =>
+ Task.CompletedTask;
+ }
+
+ private sealed class SuccessAuthenticationHandler : NoResultAuthenticationHandler { }
+
+ private sealed class ThrowingAuthenticationHandler : NoResultAuthenticationHandler { }
+}
diff --git a/tests/Trax.Api.Tests/QueryModelAuthorizationValidatorTests.cs b/tests/Trax.Api.Tests/QueryModelAuthorizationValidatorTests.cs
new file mode 100644
index 0000000..fdc7c37
--- /dev/null
+++ b/tests/Trax.Api.Tests/QueryModelAuthorizationValidatorTests.cs
@@ -0,0 +1,262 @@
+using FluentAssertions;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Trax.Api.GraphQL.Configuration.TraxGraphQLBuilder;
+using Trax.Api.GraphQL.Startup;
+using Trax.Effect.Attributes;
+
+namespace Trax.Api.Tests;
+
+///
+/// Direct unit coverage for .
+/// The validator's whole reason for existing is to refuse to start a host
+/// whose gated entities point at policies the consumer never registered —
+/// the exact failure that, if missed, would turn into a silent deny-all in
+/// production. These tests pin both directions of that check.
+///
+[TestFixture]
+public class QueryModelAuthorizationValidatorTests
+{
+ [TraxQueryModel]
+ [TraxAuthorize(Policy = "RegisteredPolicy")]
+ private class GatedWithRegisteredPolicy
+ {
+ public int Id { get; set; }
+ }
+
+ [TraxQueryModel]
+ [TraxAuthorize(Policy = "MissingPolicy")]
+ private class GatedWithMissingPolicy
+ {
+ public int Id { get; set; }
+ }
+
+ [TraxQueryModel]
+ [TraxAuthorize(Roles = "Admin")]
+ private class GatedRolesOnly
+ {
+ public int Id { get; set; }
+ }
+
+ private class RegisteredPolicyDbContext(DbContextOptions options)
+ : DbContext(options)
+ {
+ public DbSet Rows { get; set; } = null!;
+ }
+
+ private class MissingPolicyDbContext(DbContextOptions options)
+ : DbContext(options)
+ {
+ public DbSet Rows { get; set; } = null!;
+ }
+
+ private class RolesOnlyDbContext(DbContextOptions options)
+ : DbContext(options)
+ {
+ public DbSet Rows { get; set; } = null!;
+ }
+
+ [Test]
+ public async Task Validator_RegisteredPolicy_DoesNotThrow()
+ {
+ var config = new TraxGraphQLBuilder(new ServiceCollection())
+ .AddDbContext()
+ .Build();
+
+ var services = new ServiceCollection();
+ services.AddAuthorization(opts =>
+ opts.AddPolicy("RegisteredPolicy", p => p.RequireAuthenticatedUser())
+ );
+ var policyProvider = services
+ .BuildServiceProvider()
+ .GetRequiredService();
+
+ var validator = new QueryModelAuthorizationValidator(config, policyProvider);
+
+ await validator
+ .Invoking(v => v.StartAsync(CancellationToken.None))
+ .Should()
+ .NotThrowAsync();
+ }
+
+ [Test]
+ public async Task Validator_MissingPolicy_ThrowsNamingPolicyAndEntity()
+ {
+ var config = new TraxGraphQLBuilder(new ServiceCollection())
+ .AddDbContext()
+ .Build();
+
+ var services = new ServiceCollection();
+ services.AddAuthorization(); // Default options only; "MissingPolicy" is intentionally not registered.
+ var policyProvider = services
+ .BuildServiceProvider()
+ .GetRequiredService();
+
+ var validator = new QueryModelAuthorizationValidator(config, policyProvider);
+
+ var act = async () => await validator.StartAsync(CancellationToken.None);
+
+ var assertion = await act.Should().ThrowAsync();
+ assertion
+ .Which.Message.Should()
+ .Contain("MissingPolicy", "the error must name the policy the consumer typo'd")
+ .And.Contain(
+ typeof(GatedWithMissingPolicy).FullName!,
+ "the error must name the entity that declared the policy"
+ )
+ .And.Contain("AddAuthorization", "the error must point the consumer at the fix");
+ }
+
+ [Test]
+ public async Task Validator_RolesOnlyEntity_NeverConsultsPolicyProvider()
+ {
+ // Roles-only attributes do not reference an ASP.NET Core policy, so the
+ // validator must skip them entirely. Pin that with a probe provider
+ // that throws on any GetPolicyAsync call.
+ var config = new TraxGraphQLBuilder(new ServiceCollection())
+ .AddDbContext()
+ .Build();
+ config
+ .ModelRegistrations.Single()
+ .AuthorizeAttributes.Should()
+ .HaveCount(1, "the fixture must register one [TraxAuthorize(Roles=...)] attribute");
+
+ var policyProvider = new ThrowingPolicyProvider();
+
+ var validator = new QueryModelAuthorizationValidator(config, policyProvider);
+
+ await validator
+ .Invoking(v => v.StartAsync(CancellationToken.None))
+ .Should()
+ .NotThrowAsync(
+ "a roles-only attribute carries no policy name, so the validator must not consult the policy provider"
+ );
+ policyProvider.GetPolicyCallCount.Should().Be(0);
+ }
+
+ [Test]
+ public async Task Validator_DuplicatePolicyReferences_ConsultsProviderOncePerName()
+ {
+ // Two entities reference the same policy. The validator deduplicates
+ // by policy name before calling GetPolicyAsync — pin the optimisation
+ // so a refactor that drops the `seen` set does not silently blow up
+ // policy-provider call volume on large schemas.
+ var config = new TraxGraphQLBuilder(new ServiceCollection())
+ .AddDbContext()
+ .Build();
+ config
+ .ModelRegistrations.Should()
+ .HaveCount(
+ 2,
+ "the fixture must register two entities for the dedup check to be meaningful"
+ );
+
+ var policyProvider = new CountingPolicyProvider();
+ // Make the policy resolvable so the test isolates the call-count check
+ // from the missing-policy throw path.
+ policyProvider.Register(
+ "SharedPolicy",
+ new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build()
+ );
+
+ var validator = new QueryModelAuthorizationValidator(config, policyProvider);
+
+ await validator
+ .Invoking(v => v.StartAsync(CancellationToken.None))
+ .Should()
+ .NotThrowAsync();
+ policyProvider
+ .GetPolicyCallCount.Should()
+ .Be(1, "the validator must dedup policy lookups by name across entities");
+ }
+
+ [Test]
+ public async Task Validator_StopAsync_CompletesSynchronously()
+ {
+ // The validator owns no resources to release. Pinning that StopAsync
+ // returns a synchronously-completed Task forces a future refactor that
+ // introduces async cleanup to update this contract explicitly.
+ var config = new TraxGraphQLBuilder(new ServiceCollection())
+ .AddDbContext()
+ .Build();
+ var validator = new QueryModelAuthorizationValidator(config, new ThrowingPolicyProvider());
+
+ var task = validator.StopAsync(CancellationToken.None);
+
+ task.IsCompletedSuccessfully.Should().BeTrue();
+ await task;
+ }
+
+ [TraxQueryModel]
+ [TraxAuthorize(Policy = "SharedPolicy")]
+ private class FirstGatedBySharedPolicy
+ {
+ public int Id { get; set; }
+ }
+
+ [TraxQueryModel]
+ [TraxAuthorize(Policy = "SharedPolicy")]
+ private class SecondGatedBySharedPolicy
+ {
+ public int Id { get; set; }
+ }
+
+ private class DuplicatePolicyDbContext(DbContextOptions options)
+ : DbContext(options)
+ {
+ public DbSet First { get; set; } = null!;
+ public DbSet Second { get; set; } = null!;
+ }
+
+ ///
+ /// Throws on any call to so a test can prove
+ /// the validator's roles-only short-circuit never reaches the provider.
+ ///
+ private sealed class ThrowingPolicyProvider : IAuthorizationPolicyProvider
+ {
+ public int GetPolicyCallCount { get; private set; }
+
+ public Task GetDefaultPolicyAsync() =>
+ throw new InvalidOperationException("validator must not request the default policy");
+
+ public Task GetFallbackPolicyAsync() =>
+ throw new InvalidOperationException("validator must not request the fallback policy");
+
+ public Task GetPolicyAsync(string policyName)
+ {
+ GetPolicyCallCount++;
+ throw new InvalidOperationException(
+ $"validator must not look up policy '{policyName}' for a roles-only attribute"
+ );
+ }
+ }
+
+ ///
+ /// Records every policy-name lookup so the dedup test can assert the
+ /// validator called the provider exactly once per distinct name.
+ ///
+ private sealed class CountingPolicyProvider : IAuthorizationPolicyProvider
+ {
+ private readonly Dictionary _policies = new(
+ StringComparer.Ordinal
+ );
+
+ public int GetPolicyCallCount { get; private set; }
+
+ public void Register(string name, AuthorizationPolicy policy) => _policies[name] = policy;
+
+ public Task GetDefaultPolicyAsync() =>
+ Task.FromResult(new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build());
+
+ public Task GetFallbackPolicyAsync() =>
+ Task.FromResult(null);
+
+ public Task GetPolicyAsync(string policyName)
+ {
+ GetPolicyCallCount++;
+ _policies.TryGetValue(policyName, out var policy);
+ return Task.FromResult(policy);
+ }
+ }
+}
diff --git a/tests/Trax.Api.Tests/QueryModelAuthorizeBuildTests.cs b/tests/Trax.Api.Tests/QueryModelAuthorizeBuildTests.cs
new file mode 100644
index 0000000..ca720dd
--- /dev/null
+++ b/tests/Trax.Api.Tests/QueryModelAuthorizeBuildTests.cs
@@ -0,0 +1,151 @@
+using FluentAssertions;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using NSubstitute;
+using Trax.Api.GraphQL.Configuration;
+using Trax.Api.GraphQL.Configuration.TraxGraphQLBuilder;
+using Trax.Api.GraphQL.Startup;
+using Trax.Effect.Attributes;
+
+namespace Trax.Api.Tests;
+
+///
+/// Coverage for . Discovery shape
+/// is exercised separately in QueryModelAuthorizeDiscoveryTests; runtime
+/// enforcement is in QueryModelAuthorizeE2ETests. This file focuses on
+/// the fail-loud startup check that every [TraxAuthorize(Policy = ...)]
+/// references a policy the host has registered.
+///
+[TestFixture]
+public class QueryModelAuthorizeBuildTests
+{
+ [TraxQueryModel]
+ [TraxAuthorize(Policy = "AdminPolicy")]
+ private class PolicyGatedEntity
+ {
+ public int Id { get; set; }
+ }
+
+ [TraxQueryModel]
+ [TraxAuthorize(Roles = "Admin")]
+ private class RoleOnlyEntity
+ {
+ public int Id { get; set; }
+ }
+
+ private class PolicyDbContext(DbContextOptions options) : DbContext(options)
+ {
+ public DbSet Items { get; set; } = null!;
+ }
+
+ private class RolesOnlyDbContext(DbContextOptions options)
+ : DbContext(options)
+ {
+ public DbSet Items { get; set; } = null!;
+ }
+
+ [Test]
+ public async Task Validator_PolicyRegistered_DoesNotThrow()
+ {
+ var config = new TraxGraphQLBuilder(new ServiceCollection())
+ .AddDbContext()
+ .Build();
+
+ var provider = Substitute.For();
+ provider
+ .GetPolicyAsync(Arg.Any())
+ .Returns(new AuthorizationPolicyBuilder().RequireAssertion(_ => true).Build());
+
+ var validator = new QueryModelAuthorizationValidator(config, provider);
+
+ await validator
+ .Invoking(v => v.StartAsync(CancellationToken.None))
+ .Should()
+ .NotThrowAsync();
+ }
+
+ [Test]
+ public async Task Validator_PolicyMissing_ThrowsWithEntityAndPolicyName()
+ {
+ var config = new TraxGraphQLBuilder(new ServiceCollection())
+ .AddDbContext()
+ .Build();
+
+ var provider = Substitute.For();
+ provider
+ .GetPolicyAsync(Arg.Any())
+ .Returns(Task.FromResult(null));
+
+ var validator = new QueryModelAuthorizationValidator(config, provider);
+
+ var act = async () => await validator.StartAsync(CancellationToken.None);
+
+ (await act.Should().ThrowAsync())
+ .WithMessage("*AdminPolicy*")
+ .WithMessage("*PolicyGatedEntity*not registered*");
+ }
+
+ [Test]
+ public async Task Validator_NoPolicies_DoesNotInvokeProvider()
+ {
+ // Entities gated only by roles (or bare [TraxAuthorize]) should never
+ // hit the provider — the validator's job is policy reachability only.
+ var config = new TraxGraphQLBuilder(new ServiceCollection())
+ .AddDbContext()
+ .Build();
+
+ var provider = Substitute.For();
+ var validator = new QueryModelAuthorizationValidator(config, provider);
+
+ await validator.StartAsync(CancellationToken.None);
+
+ await provider.DidNotReceive().GetPolicyAsync(Arg.Any());
+ }
+
+ [TraxQueryModel]
+ [TraxAuthorize(Policy = "AdminPolicy")]
+ private class DupPolicyA
+ {
+ public int Id { get; set; }
+ }
+
+ [TraxQueryModel]
+ [TraxAuthorize(Policy = "AdminPolicy")]
+ private class DupPolicyB
+ {
+ public int Id { get; set; }
+ }
+
+ private class DupPolicyDbContext(DbContextOptions options)
+ : DbContext(options)
+ {
+ public DbSet A { get; set; } = null!;
+ public DbSet B { get; set; } = null!;
+ }
+
+ [Test]
+ public async Task Validator_SamePolicyOnMultipleEntities_QueriesProviderOnce()
+ {
+ // Two entities reference the same policy name. The validator's
+ // dedup check (`seen.Add` returning false) must skip the second
+ // lookup — proves we don't redundantly hit the policy provider once
+ // per entity in production hosts where a single role policy is
+ // applied to dozens of [TraxQueryModel] entities.
+ var config = new TraxGraphQLBuilder(new ServiceCollection())
+ .AddDbContext()
+ .Build();
+ config.ModelRegistrations.Should().HaveCount(2);
+
+ var provider = Substitute.For();
+ provider
+ .GetPolicyAsync("AdminPolicy")
+ .Returns(new AuthorizationPolicyBuilder().RequireAssertion(_ => true).Build());
+
+ var validator = new QueryModelAuthorizationValidator(config, provider);
+
+ await validator.StartAsync(CancellationToken.None);
+
+ await provider.Received(1).GetPolicyAsync("AdminPolicy");
+ }
+}
diff --git a/tests/Trax.Api.Tests/QueryModelAuthorizeDiscoveryTests.cs b/tests/Trax.Api.Tests/QueryModelAuthorizeDiscoveryTests.cs
new file mode 100644
index 0000000..bd1c740
--- /dev/null
+++ b/tests/Trax.Api.Tests/QueryModelAuthorizeDiscoveryTests.cs
@@ -0,0 +1,261 @@
+using System.ComponentModel.DataAnnotations.Schema;
+using FluentAssertions;
+using Microsoft.EntityFrameworkCore;
+using Trax.Api.GraphQL.Configuration.TraxGraphQLBuilder;
+using Trax.Effect.Attributes;
+
+namespace Trax.Api.Tests;
+
+///
+/// Unit coverage for how discovers
+/// [TraxAuthorize] on [TraxQueryModel] entities and threads the
+/// attribute set through QueryModelRegistration.AuthorizeAttributes.
+/// The E2E suite proves the directive is wired correctly through the full
+/// HotChocolate pipeline; these tests pin the discovery surface so the data
+/// reaching the type module is the source of truth a future refactor must
+/// preserve.
+///
+[TestFixture]
+public class QueryModelAuthorizeDiscoveryTests
+{
+ // ── Discovery: AuthorizeAttributes is populated ──────────────────────
+
+ [Test]
+ public void Build_EntityWithSingleRoleAttribute_ExposesIt()
+ {
+ var sut = new TraxGraphQLBuilder(
+ new Microsoft.Extensions.DependencyInjection.ServiceCollection()
+ );
+ sut.AddDbContext();
+
+ var config = sut.Build();
+
+ var reg = config.ModelRegistrations.Single(r => r.EntityType == typeof(GatedRow));
+ reg.AuthorizeAttributes.Should().HaveCount(1);
+ reg.AuthorizeAttributes[0].Roles.Should().Be("Admin");
+ }
+
+ [Test]
+ public void Build_EntityWithoutAuthorize_HasEmptyList()
+ {
+ var sut = new TraxGraphQLBuilder(
+ new Microsoft.Extensions.DependencyInjection.ServiceCollection()
+ );
+ sut.AddDbContext();
+
+ var config = sut.Build();
+
+ var reg = config.ModelRegistrations.Single(r => r.EntityType == typeof(OpenRow));
+ reg.AuthorizeAttributes.Should().BeEmpty();
+ }
+
+ [Test]
+ public void Build_EntityWithStackedAuthorize_DiscoversAllAttributes()
+ {
+ var sut = new TraxGraphQLBuilder(
+ new Microsoft.Extensions.DependencyInjection.ServiceCollection()
+ );
+ sut.AddDbContext();
+
+ var config = sut.Build();
+
+ var reg = config.ModelRegistrations.Single(r => r.EntityType == typeof(StackedRow));
+ reg.AuthorizeAttributes.Should().HaveCount(2);
+ reg.AuthorizeAttributes.Should().Contain(a => a.Roles == "Admin");
+ reg.AuthorizeAttributes.Should().Contain(a => a.Policy == "AdminPolicy");
+ }
+
+ [Test]
+ public void Build_EntityInheritingAuthorize_DiscoversFromBase()
+ {
+ var sut = new TraxGraphQLBuilder(
+ new Microsoft.Extensions.DependencyInjection.ServiceCollection()
+ );
+ sut.AddDbContext();
+
+ var config = sut.Build();
+
+ var reg = config.ModelRegistrations.Single(r => r.EntityType == typeof(InheritedGatedRow));
+ reg.AuthorizeAttributes.Should().HaveCount(1);
+ reg.AuthorizeAttributes[0].Roles.Should().Be("BaseAdmin");
+ }
+
+ [Test]
+ public void Build_EntityWithBareAuthorize_RecordsAttributeWithoutPolicyOrRoles()
+ {
+ var sut = new TraxGraphQLBuilder(
+ new Microsoft.Extensions.DependencyInjection.ServiceCollection()
+ );
+ sut.AddDbContext();
+
+ var config = sut.Build();
+
+ var reg = config.ModelRegistrations.Single(r => r.EntityType == typeof(BareGatedRow));
+ reg.AuthorizeAttributes.Should().HaveCount(1);
+ reg.AuthorizeAttributes[0].Policy.Should().BeNull();
+ reg.AuthorizeAttributes[0].Roles.Should().BeNull();
+ }
+
+ // ── Build-time shape validation ──────────────────────────────────────
+
+ [Test]
+ public void Build_WhitespacePolicy_Throws()
+ {
+ var sut = new TraxGraphQLBuilder(
+ new Microsoft.Extensions.DependencyInjection.ServiceCollection()
+ );
+ sut.AddDbContext();
+
+ var act = () => sut.Build();
+
+ act.Should()
+ .Throw()
+ .WithMessage("*Policy value*")
+ .WithMessage($"*{typeof(WhitespacePolicyRow).FullName}*");
+ }
+
+ [Test]
+ public void Build_RolesEmptyAfterSplit_Throws()
+ {
+ var sut = new TraxGraphQLBuilder(
+ new Microsoft.Extensions.DependencyInjection.ServiceCollection()
+ );
+ sut.AddDbContext();
+
+ var act = () => sut.Build();
+
+ act.Should().Throw().WithMessage("*parsed to zero roles*");
+ }
+
+ // ── ExposeAs + [TraxAuthorize] compose, no conflict ──────────────────
+
+ [Test]
+ public void Build_ExposeAsAndAuthorize_Coexist()
+ {
+ var sut = new TraxGraphQLBuilder(
+ new Microsoft.Extensions.DependencyInjection.ServiceCollection()
+ );
+ sut.AddDbContext();
+
+ var config = sut.Build();
+
+ var reg = config.ModelRegistrations.Single();
+ reg.Attribute.ExposeAs.Should().Be(typeof(IExposedRow));
+ reg.AuthorizeAttributes.Should().HaveCount(1);
+ reg.AuthorizeAttributes[0].Roles.Should().Be("Admin");
+ }
+
+ // ── Test entities ────────────────────────────────────────────────────
+
+ [TraxQueryModel(Name = "openRows")]
+ [Table("open_rows", Schema = "test_disc")]
+ public class OpenRow
+ {
+ [Column("id")]
+ public long Id { get; set; }
+ }
+
+ [TraxQueryModel(Name = "gatedRows")]
+ [TraxAuthorize(Roles = "Admin")]
+ [Table("gated_rows", Schema = "test_disc")]
+ public class GatedRow
+ {
+ [Column("id")]
+ public long Id { get; set; }
+ }
+
+ [TraxQueryModel(Name = "stackedRows")]
+ [TraxAuthorize(Roles = "Admin")]
+ [TraxAuthorize(Policy = "AdminPolicy")]
+ [Table("stacked_rows", Schema = "test_disc")]
+ public class StackedRow
+ {
+ [Column("id")]
+ public long Id { get; set; }
+ }
+
+ [TraxAuthorize(Roles = "BaseAdmin")]
+ public abstract class GatedBase
+ {
+ [Column("id")]
+ public long Id { get; set; }
+ }
+
+ [TraxQueryModel(Name = "inheritedGatedRows")]
+ [Table("inherited_gated_rows", Schema = "test_disc")]
+ public class InheritedGatedRow : GatedBase { }
+
+ [TraxQueryModel(Name = "bareGatedRows")]
+ [TraxAuthorize]
+ [Table("bare_gated_rows", Schema = "test_disc")]
+ public class BareGatedRow
+ {
+ [Column("id")]
+ public long Id { get; set; }
+ }
+
+ public class DiscoveryContext(DbContextOptions options) : DbContext(options)
+ {
+ public DbSet OpenRows { get; set; } = null!;
+ public DbSet GatedRows { get; set; } = null!;
+ public DbSet StackedRows { get; set; } = null!;
+ public DbSet InheritedGatedRows { get; set; } = null!;
+ public DbSet BareGatedRows { get; set; } = null!;
+ }
+
+ [TraxQueryModel(Name = "whitespacePolicy")]
+ [TraxAuthorize(Policy = " ")]
+ [Table("ws_policy_rows", Schema = "test_disc")]
+ public class WhitespacePolicyRow
+ {
+ [Column("id")]
+ public long Id { get; set; }
+ }
+
+ public class WhitespacePolicyContext(DbContextOptions options)
+ : DbContext(options)
+ {
+ public DbSet Rows { get; set; } = null!;
+ }
+
+ [TraxQueryModel(Name = "emptyRolesRows")]
+ [TraxAuthorize(Roles = ",,,")]
+ [Table("empty_roles_rows", Schema = "test_disc")]
+ public class EmptyRolesRow
+ {
+ [Column("id")]
+ public long Id { get; set; }
+ }
+
+ public class EmptyRolesContext(DbContextOptions options) : DbContext(options)
+ {
+ public DbSet Rows { get; set; } = null!;
+ }
+
+ public interface IExposedRow
+ {
+ long Id { get; }
+ string Name { get; }
+ }
+
+ [TraxQueryModel(Name = "exposedRows", ExposeAs = typeof(IExposedRow))]
+ [TraxAuthorize(Roles = "Admin")]
+ [Table("exposed_rows", Schema = "test_disc")]
+ public class ExposedRow : IExposedRow
+ {
+ [Column("id")]
+ public long Id { get; set; }
+
+ [Column("name")]
+ public string Name { get; set; } = "";
+
+ [Column("secret")]
+ public string Secret { get; set; } = "";
+ }
+
+ public class ExposeAsAuthorizeContext(DbContextOptions options)
+ : DbContext(options)
+ {
+ public DbSet Rows { get; set; } = null!;
+ }
+}
diff --git a/tests/Trax.Api.Tests/QueryModelAuthorizeSchemaValidatorTests.cs b/tests/Trax.Api.Tests/QueryModelAuthorizeSchemaValidatorTests.cs
new file mode 100644
index 0000000..e00b7e0
--- /dev/null
+++ b/tests/Trax.Api.Tests/QueryModelAuthorizeSchemaValidatorTests.cs
@@ -0,0 +1,527 @@
+using FluentAssertions;
+using HotChocolate;
+using HotChocolate.Execution;
+using HotChocolate.Types;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Trax.Api.GraphQL.Configuration;
+using Trax.Api.GraphQL.Configuration.TraxGraphQLBuilder;
+using Trax.Api.GraphQL.Startup;
+using Trax.Effect.Attributes;
+
+namespace Trax.Api.Tests;
+
+///
+/// Direct unit coverage for .
+/// Builds GraphQL schemas by hand where the @authorize directive has
+/// been deliberately stripped from a gated entity, then verifies the validator
+/// throws at StartAsync with a message naming the entity and the
+/// missing gate location. This is the suite that proves the validator's
+/// failure paths — the corresponding success path runs through the full
+/// AddTraxGraphQL pipeline in
+/// QueryModelAuthorizeSchemaInvariantE2ETests.
+///
+[TestFixture]
+public class QueryModelAuthorizeSchemaValidatorTests
+{
+ [TraxQueryModel]
+ [TraxAuthorize(Roles = "Admin")]
+ private class GatedThing
+ {
+ public int Id { get; set; }
+ public string Name { get; set; } = "";
+ }
+
+ private class GatedDbContext(DbContextOptions options) : DbContext(options)
+ {
+ public DbSet Things { get; set; } = null!;
+ }
+
+ [Test]
+ public async Task Validator_BothDirectivesStripped_ThrowsNamingEntity()
+ {
+ var (config, services) = await BuildSchemaAsync(
+ includeAuthorizeOnType: false,
+ includeAuthorizeOnField: false
+ );
+ var validator = new QueryModelAuthorizationSchemaValidator(config, services);
+
+ var act = async () => await validator.StartAsync(CancellationToken.None);
+
+ (await act.Should().ThrowAsync())
+ .WithMessage("*[TraxAuthorize] invariant violated*")
+ .WithMessage("*GatedThing*");
+ }
+
+ [Test]
+ public async Task Validator_OnlyFieldDirectiveStripped_ThrowsNamingEntryField()
+ {
+ // Type-level gate is present, but the entry field has been stripped.
+ // This is the more subtle failure — transitive navigation would still
+ // be blocked, but `totalCount` and `pageInfo` would leak. The
+ // validator must still catch it.
+ var (config, services) = await BuildSchemaAsync(
+ includeAuthorizeOnType: true,
+ includeAuthorizeOnField: false
+ );
+ var validator = new QueryModelAuthorizationSchemaValidator(config, services);
+
+ var act = async () => await validator.StartAsync(CancellationToken.None);
+
+ (await act.Should().ThrowAsync())
+ .WithMessage("*[TraxAuthorize] invariant violated*")
+ .WithMessage("*entry field*")
+ .WithMessage("*gatedThings*");
+ }
+
+ [Test]
+ public async Task Validator_OnlyTypeDirectiveStripped_ThrowsNamingType()
+ {
+ // Entry field guarded but the type itself isn't — transitive nav
+ // through this type from another (ungated) entity would leak rows.
+ var (config, services) = await BuildSchemaAsync(
+ includeAuthorizeOnType: false,
+ includeAuthorizeOnField: true
+ );
+ var validator = new QueryModelAuthorizationSchemaValidator(config, services);
+
+ var act = async () => await validator.StartAsync(CancellationToken.None);
+
+ (await act.Should().ThrowAsync())
+ .WithMessage("*[TraxAuthorize] invariant violated*")
+ .WithMessage("*ObjectType*")
+ .WithMessage("*GatedThing*");
+ }
+
+ [Test]
+ public async Task Validator_BothDirectivesPresent_DoesNotThrow()
+ {
+ var (config, services) = await BuildSchemaAsync(
+ includeAuthorizeOnType: true,
+ includeAuthorizeOnField: true
+ );
+ var validator = new QueryModelAuthorizationSchemaValidator(config, services);
+
+ await validator
+ .Invoking(v => v.StartAsync(CancellationToken.None))
+ .Should()
+ .NotThrowAsync();
+ }
+
+ // ── Schema-shape failure paths ───────────────────────────────────────
+ //
+ // Beyond directive-stripping, the validator must also catch a hostile
+ // ConfigureSchema callback that *deletes* parts of the schema the gate
+ // relies on. Each test below removes one expected element and confirms
+ // the validator throws with a message that names what is missing.
+
+ ///
+ /// A consumer's ConfigureSchema callback removes the
+ /// discover root field, so the validator cannot navigate to the
+ /// gated entity's entry field at all. The validator must throw rather
+ /// than silently passing the now-unreachable entity.
+ ///
+ [Test]
+ public async Task Validator_DiscoverFieldMissing_ThrowsNamingDiscover()
+ {
+ var (config, services) = await BuildSchemaAsync(includeDiscoverField: false);
+ var validator = new QueryModelAuthorizationSchemaValidator(config, services);
+
+ var act = async () => await validator.StartAsync(CancellationToken.None);
+
+ (await act.Should().ThrowAsync())
+ .WithMessage("*`discover` field is not present*")
+ .WithMessage("*GatedThing*");
+ }
+
+ ///
+ /// A consumer keeps discover but the entry field
+ /// (gatedThings) has been removed. The validator must throw
+ /// rather than passing on the assumption that "absence implies
+ /// inaccessibility" — the test pins that the validator is strict about
+ /// every gated entity having a reachable entry.
+ ///
+ [Test]
+ public async Task Validator_EntryFieldMissing_ThrowsNamingEntryField()
+ {
+ var (config, services) = await BuildSchemaAsync(includeEntryField: false);
+ var validator = new QueryModelAuthorizationSchemaValidator(config, services);
+
+ var act = async () => await validator.StartAsync(CancellationToken.None);
+
+ (await act.Should().ThrowAsync())
+ .WithMessage("*entry field*missing*")
+ .WithMessage("*gatedThings*");
+ }
+
+ ///
+ /// Happy path for entities declared with a Namespace: the
+ /// validator must descend into the namespace intermediate type and find
+ /// the entry field there. Without this test, a refactor that broke the
+ /// namespace walk would only surface on namespaced models in production.
+ ///
+ [Test]
+ public async Task Validator_NamespacedEntity_BothDirectivesPresent_DoesNotThrow()
+ {
+ var (config, services) = await BuildNamespacedSchemaAsync(
+ includeAuthorizeOnType: true,
+ includeAuthorizeOnField: true,
+ includeNamespaceField: true
+ );
+ var validator = new QueryModelAuthorizationSchemaValidator(config, services);
+
+ await validator
+ .Invoking(v => v.StartAsync(CancellationToken.None))
+ .Should()
+ .NotThrowAsync();
+ }
+
+ ///
+ /// A namespaced entity where the namespace intermediate type
+ /// (discover.testns) is missing. The validator must report the
+ /// namespace field rather than the entry field — the failure-mode
+ /// distinction matters when a maintainer is reading the error message
+ /// trying to figure out which override broke things.
+ ///
+ [Test]
+ public async Task Validator_NamespaceFieldMissing_ThrowsNamingNamespaceField()
+ {
+ var (config, services) = await BuildNamespacedSchemaAsync(
+ includeAuthorizeOnType: true,
+ includeAuthorizeOnField: true,
+ includeNamespaceField: false
+ );
+ var validator = new QueryModelAuthorizationSchemaValidator(config, services);
+
+ var act = async () => await validator.StartAsync(CancellationToken.None);
+
+ (await act.Should().ThrowAsync())
+ .WithMessage("*namespace field*")
+ .WithMessage("*testns*");
+ }
+
+ ///
+ /// HotChocolate accepts a schema in which the gated entity's
+ /// ObjectType is never registered, as long as nothing references it.
+ /// Production code always registers it via the type module, but a hostile
+ /// ConfigureSchema that fully replaces the discover surface could
+ /// leave us without it. The validator must throw with a message that names
+ /// the entity rather than silently passing on an unreachable gate.
+ ///
+ [Test]
+ public async Task Validator_ObjectTypeMissingFromSchema_ThrowsNamingMissingType()
+ {
+ var (config, services) = await BuildSchemaWithoutGatedObjectTypeAsync();
+ var validator = new QueryModelAuthorizationSchemaValidator(config, services);
+
+ var act = async () => await validator.StartAsync(CancellationToken.None);
+
+ (await act.Should().ThrowAsync())
+ .WithMessage("*no ObjectType*")
+ .WithMessage("*GatedThing*");
+ }
+
+ ///
+ /// IHostedService contract: StopAsync must return a synchronously
+ /// completed task — the validator holds no resources to release. Pinning
+ /// this catches a future refactor that accidentally introduces async
+ /// cleanup without explicit consideration of the host lifecycle.
+ ///
+ [Test]
+ public async Task Validator_StopAsync_CompletesSynchronously()
+ {
+ var emptyConfig = new TraxGraphQLBuilder(new ServiceCollection()).Build();
+ var validator = new QueryModelAuthorizationSchemaValidator(
+ emptyConfig,
+ new ServiceCollection().BuildServiceProvider()
+ );
+
+ var task = validator.StopAsync(CancellationToken.None);
+
+ task.IsCompletedSuccessfully.Should().BeTrue();
+ await task;
+ }
+
+ ///
+ /// The validator must short-circuit when no [TraxQueryModel]
+ /// entity is gated. Important because it means a host with only ungated
+ /// query models does not pay the schema-materialisation cost at startup.
+ ///
+ [Test]
+ public async Task Validator_NoGatedEntities_ReturnsWithoutResolvingSchema()
+ {
+ var services = new ServiceCollection();
+ // Build a configuration whose entity carries [TraxQueryModel] but
+ // no [TraxAuthorize]. The validator should never touch the service
+ // provider, so an empty provider is sufficient to prove it.
+ var config = new TraxGraphQLBuilder(services).AddDbContext().Build();
+ config.ModelRegistrations.Should().NotBeEmpty("the test fixture must register a model");
+ config
+ .ModelRegistrations.Single()
+ .AuthorizeAttributes.Should()
+ .BeEmpty("the test fixture's entity must be ungated");
+
+ var throwingProvider = new ThrowingServiceProvider();
+ var validator = new QueryModelAuthorizationSchemaValidator(config, throwingProvider);
+
+ await validator
+ .Invoking(v => v.StartAsync(CancellationToken.None))
+ .Should()
+ .NotThrowAsync("no gated entities means the validator must not resolve the schema");
+ throwingProvider.CreateScopeCallCount.Should().Be(0);
+ }
+
+ [TraxQueryModel]
+ private class UngatedThing
+ {
+ public int Id { get; set; }
+ }
+
+ private class UngatedDbContext(DbContextOptions options) : DbContext(options)
+ {
+ public DbSet Things { get; set; } = null!;
+ }
+
+ ///
+ /// Service provider that throws on any access, used to prove the
+ /// validator's early-return path never consults DI when there is no
+ /// work to do.
+ ///
+ private sealed class ThrowingServiceProvider : IServiceProvider
+ {
+ public int CreateScopeCallCount { get; private set; }
+
+ public object? GetService(Type serviceType)
+ {
+ if (serviceType == typeof(IServiceScopeFactory))
+ {
+ CreateScopeCallCount++;
+ }
+ throw new InvalidOperationException(
+ "validator must not resolve any services when no entities are gated."
+ );
+ }
+ }
+
+ ///
+ /// Builds the canonical "single ungated namespace" schema variant the
+ /// extra tests share. Mirrors , parameterised
+ /// to omit either the discover root field or the entry field on the
+ /// namespace.
+ ///
+ private static async Task<(
+ GraphQLConfiguration Config,
+ IServiceProvider Services
+ )> BuildSchemaAsync(
+ bool includeDiscoverField = true,
+ bool includeEntryField = true,
+ bool includeAuthorizeOnType = true,
+ bool includeAuthorizeOnField = true
+ )
+ {
+ var services = new ServiceCollection();
+ services.AddLogging();
+ services.AddAuthorizationCore();
+
+ var config = new TraxGraphQLBuilder(services).AddDbContext().Build();
+ services.AddSingleton(config);
+ services.AddDbContextFactory(o => o.UseInMemoryDatabase("svtests-shape"));
+
+ var gql = services.AddGraphQLServer("trax").AddAuthorization();
+
+ ObjectType objectType = includeAuthorizeOnType
+ ? new ObjectType(d => d.Authorize(new[] { "Admin" }))
+ : new ObjectType();
+ gql.AddType(objectType);
+
+ gql.AddQueryType(d =>
+ {
+ d.Name("RootQuery");
+ if (includeDiscoverField)
+ d.Field("discover").Type().Resolve(_ => new object());
+ else
+ // RootQuery must have at least one field or HC refuses to
+ // build. Add a sentinel that the validator does not look at.
+ d.Field("ping").Type().Resolve(_ => "pong");
+ });
+
+ if (includeDiscoverField)
+ {
+ gql.AddTypeExtension(
+ new ObjectTypeExtension(d =>
+ {
+ d.Name("DiscoverQueries");
+ if (!includeEntryField)
+ {
+ // DiscoverQueries needs at least one field to be a
+ // valid GraphQL type even when the entry field for
+ // GatedThing is intentionally absent.
+ d.Field("sentinel")
+ .Type()
+ .Resolve(_ => "ok");
+ return;
+ }
+
+ var field = d.Field("gatedThings")
+ .Type>>()
+ .Resolve(_ => Array.Empty());
+ if (includeAuthorizeOnField)
+ field.Authorize(new[] { "Admin" });
+ })
+ );
+ }
+
+ var sp = services.BuildServiceProvider();
+ var resolver = sp.GetRequiredService();
+ _ = await resolver.GetRequestExecutorAsync("trax");
+
+ return (config, sp);
+ }
+
+ ///
+ /// Variant of for entities declared with
+ /// a . The entity here is
+ /// with Namespace = "testns";
+ /// the validator must descend through discover.testns.namespacedGatedThings.
+ ///
+ private static async Task<(
+ GraphQLConfiguration Config,
+ IServiceProvider Services
+ )> BuildNamespacedSchemaAsync(
+ bool includeAuthorizeOnType,
+ bool includeAuthorizeOnField,
+ bool includeNamespaceField
+ )
+ {
+ var services = new ServiceCollection();
+ services.AddLogging();
+ services.AddAuthorizationCore();
+
+ var config = new TraxGraphQLBuilder(services)
+ .AddDbContext()
+ .Build();
+ services.AddSingleton(config);
+ services.AddDbContextFactory(o =>
+ o.UseInMemoryDatabase("svtests-ns")
+ );
+
+ var gql = services.AddGraphQLServer("trax").AddAuthorization();
+
+ ObjectType objectType = includeAuthorizeOnType
+ ? new ObjectType(d => d.Authorize(new[] { "Admin" }))
+ : new ObjectType();
+ gql.AddType(objectType);
+
+ gql.AddQueryType(d =>
+ {
+ d.Name("RootQuery");
+ d.Field("discover").Type().Resolve(_ => new object());
+ });
+
+ if (includeNamespaceField)
+ {
+ gql.AddType(new ObjectType(d => d.Name("DiscoverQueries_testns")));
+ gql.AddTypeExtension(
+ new ObjectTypeExtension(d =>
+ {
+ d.Name("DiscoverQueries");
+ d.Field("testns")
+ .Type(new HotChocolate.Language.NamedTypeNode("DiscoverQueries_testns"))
+ .Resolve(_ => new object());
+ })
+ );
+ gql.AddTypeExtension(
+ new ObjectTypeExtension(d =>
+ {
+ d.Name("DiscoverQueries_testns");
+ var field = d.Field("namespacedGatedThings")
+ .Type>>()
+ .Resolve(_ => Array.Empty());
+ if (includeAuthorizeOnField)
+ field.Authorize(new[] { "Admin" });
+ })
+ );
+ }
+ else
+ {
+ gql.AddTypeExtension(
+ new ObjectTypeExtension(d =>
+ {
+ d.Name("DiscoverQueries");
+ d.Field("sentinel").Type().Resolve(_ => "ok");
+ })
+ );
+ }
+
+ var sp = services.BuildServiceProvider();
+ var resolver = sp.GetRequiredService();
+ _ = await resolver.GetRequestExecutorAsync("trax");
+
+ return (config, sp);
+ }
+
+ ///
+ /// Builds a schema where the gated entity's is
+ /// never registered. The DiscoverQueries placeholder field returns a
+ /// string so HC can build the schema without referencing GatedThing's
+ /// CLR type at all.
+ ///
+ private static async Task<(
+ GraphQLConfiguration Config,
+ IServiceProvider Services
+ )> BuildSchemaWithoutGatedObjectTypeAsync()
+ {
+ var services = new ServiceCollection();
+ services.AddLogging();
+ services.AddAuthorizationCore();
+
+ var config = new TraxGraphQLBuilder(services).AddDbContext().Build();
+ services.AddSingleton(config);
+ services.AddDbContextFactory(o => o.UseInMemoryDatabase("svtests-noobj"));
+
+ var gql = services.AddGraphQLServer("trax").AddAuthorization();
+
+ // NO AddType>. RootQuery exposes a `discover`
+ // field but DiscoverQueries has only a placeholder string field —
+ // GatedThing's CLR type is never reachable from the schema, so HC
+ // will not auto-discover the ObjectType either.
+ gql.AddQueryType(d =>
+ {
+ d.Name("RootQuery");
+ d.Field("discover").Type().Resolve(_ => new object());
+ });
+
+ gql.AddTypeExtension(
+ new ObjectTypeExtension(d =>
+ {
+ d.Name("DiscoverQueries");
+ d.Field("placeholder").Type().Resolve(_ => "ok");
+ })
+ );
+
+ var sp = services.BuildServiceProvider();
+ var resolver = sp.GetRequiredService();
+ _ = await resolver.GetRequestExecutorAsync("trax");
+
+ return (config, sp);
+ }
+
+ [TraxQueryModel(Namespace = "testns")]
+ [TraxAuthorize(Roles = "Admin")]
+ private class NamespacedGatedThing
+ {
+ public int Id { get; set; }
+ }
+
+ private class NamespacedGatedDbContext(DbContextOptions options)
+ : DbContext(options)
+ {
+ public DbSet Things { get; set; } = null!;
+ }
+
+ private sealed class DiscoverObjectType : ObjectType
+ {
+ protected override void Configure(IObjectTypeDescriptor descriptor) =>
+ descriptor.Name("DiscoverQueries");
+ }
+}
diff --git a/tests/Trax.Api.Tests/QueryModelTypeModuleAuthorizeCompositionTests.cs b/tests/Trax.Api.Tests/QueryModelTypeModuleAuthorizeCompositionTests.cs
new file mode 100644
index 0000000..5465ce0
--- /dev/null
+++ b/tests/Trax.Api.Tests/QueryModelTypeModuleAuthorizeCompositionTests.cs
@@ -0,0 +1,165 @@
+using System.ComponentModel.DataAnnotations.Schema;
+using FluentAssertions;
+using HotChocolate;
+using HotChocolate.Execution;
+using HotChocolate.Types;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Trax.Api.GraphQL.Configuration;
+using Trax.Api.GraphQL.Configuration.TraxGraphQLBuilder;
+using Trax.Api.GraphQL.Queries;
+using Trax.Api.GraphQL.TypeModules;
+using Trax.Effect.Attributes;
+
+namespace Trax.Api.Tests;
+
+///
+/// Coverage for the corners of
+/// and that compose
+/// [TraxAuthorize] with the other [TraxQueryModel] knobs.
+///
+/// - DeprecationReason is read and emitted as @deprecated on
+/// the entry field — a regression that drops the call would silently lose the
+/// schema-level deprecation marker.
+/// - BindFields = Explicit + [TraxAuthorize] compose without
+/// dropping the directive. The bind-explicit branch is a separate code path
+/// from the implicit / ExposeAs branches that the other tests cover.
+///
+///
+[TestFixture]
+public class QueryModelTypeModuleAuthorizeCompositionTests
+{
+ // ── DeprecationReason wiring ────────────────────────────────────────
+
+ [TraxQueryModel(DeprecationReason = "use NewThing instead")]
+ [Table("legacy_things", Schema = "test_tm")]
+ private class LegacyThing
+ {
+ [Column("id")]
+ public long Id { get; set; }
+
+ [Column("name")]
+ public string Name { get; set; } = "";
+ }
+
+ private class LegacyDbContext(DbContextOptions options) : DbContext(options)
+ {
+ public DbSet Things { get; set; } = null!;
+ }
+
+ [Test]
+ public async Task ConfigureField_DeprecationReasonSet_EntryFieldCarriesDeprecation()
+ {
+ var schema = await BuildSchemaAsync();
+
+ var discoverField = schema.QueryType.Fields.Single(f => f.Name == "discover");
+ var discoverType = (IObjectType)discoverField.Type.NamedType();
+ var entry = discoverType.Fields.Single(f => f.Name == "legacyThings");
+
+ entry.IsDeprecated.Should().BeTrue();
+ entry.DeprecationReason.Should().Be("use NewThing instead");
+ }
+
+ // ── BindFields.Explicit + [TraxAuthorize] compose ────────────────────
+
+ [TraxQueryModel(BindFields = FieldBindingBehavior.Explicit)]
+ [TraxAuthorize(Roles = "Admin")]
+ [Table("audit_rows", Schema = "test_tm")]
+ private class AuditRow
+ {
+ [Column("id")]
+ public long Id { get; set; }
+
+ [Column("public_name")]
+ public string PublicName { get; set; } = "";
+
+ // No [Column] — under Explicit binding this property must be hidden
+ // from the GraphQL schema. This is the property that proves the
+ // explicit-binding branch ran at all (vs. the implicit fallback that
+ // would have surfaced it).
+ public string InternalSecret { get; set; } = "";
+ }
+
+ private class AuditDbContext(DbContextOptions options) : DbContext(options)
+ {
+ public DbSet Rows { get; set; } = null!;
+ }
+
+ [Test]
+ public async Task CreateObjectType_ExplicitBindingWithAuthorize_HidesNonColumnPropertiesAndEmitsDirective()
+ {
+ var schema = await BuildSchemaAsync();
+
+ var auditType = schema
+ .Types.OfType()
+ .First(t => t.RuntimeType == typeof(AuditRow));
+
+ // Only [Column]-decorated properties appear. If the BindFields.Explicit
+ // branch regressed to fall through to implicit binding, InternalSecret
+ // would leak in. (`__typename` is HC's reserved introspection field
+ // and is filtered here to focus the assertion on entity-shape fields.)
+ var fieldNames = auditType
+ .Fields.Where(f => !f.IsIntrospectionField)
+ .Select(f => f.Name)
+ .Order()
+ .ToArray();
+ fieldNames.Should().BeEquivalentTo(new[] { "id", "publicName" });
+
+ // The @authorize directive must STILL be attached. The bind-explicit
+ // branch is a separate descriptor configuration path from the implicit
+ // one — a regression that called .Authorize() only in the implicit
+ // factory would silently un-gate every entity that uses explicit
+ // binding.
+ auditType.Directives.Any(d => d.Type.Name == "authorize").Should().BeTrue();
+ }
+
+ // ── Schema-build helper ─────────────────────────────────────────────
+
+ ///
+ /// Builds a minimal HotChocolate schema using
+ /// against an in-memory EF context. Avoids the full AddTraxGraphQL
+ /// dependency graph (TraxMarker, train discovery, etc.) so the test
+ /// focuses on the type module's emission behavior.
+ ///
+ private static async Task BuildSchemaAsync()
+ where TContext : DbContext
+ {
+ var services = new ServiceCollection();
+ services.AddDbContext(o => o.UseInMemoryDatabase("tmcomp_" + Guid.NewGuid()));
+
+ var builder = new TraxGraphQLBuilder(services);
+ builder.AddDbContext();
+ var config = builder.Build();
+
+ services.AddSingleton(config);
+ services.AddSingleton();
+
+ services
+ .AddGraphQLServer()
+ .AddAuthorization()
+ .AddQueryType()
+ .AddType()
+ .AddTypeModule()
+ .AddFiltering()
+ .AddSorting()
+ .AddProjections();
+
+ var provider = services.BuildServiceProvider();
+ var resolver = provider.GetRequiredService();
+ var executor = await resolver.GetRequestExecutorAsync();
+ return executor.Schema;
+ }
+
+ public class TestRootQuery
+ {
+ public DiscoverQueries Discover() => new();
+ }
+
+ public class DiscoverQueriesType : ObjectType
+ {
+ protected override void Configure(IObjectTypeDescriptor descriptor)
+ {
+ descriptor.Name("DiscoverQueries");
+ }
+ }
+}