wip: doctor/cli/docs/api to vector db consolidation; api hardening for descriptions, tenant, and scopes; migrations and conversions of all DALs to EF v10

This commit is contained in:
master
2026-02-23 15:30:50 +02:00
parent bd8fee6ed8
commit e746577380
1424 changed files with 81225 additions and 25251 deletions

View File

@@ -30,6 +30,13 @@ public sealed class AdvisorySourceWebAppFactory : WebApplicationFactory<Program>
Environment.SetEnvironmentVariable("CONCELIER_TEST_STORAGE_DSN", "Host=localhost;Port=5432;Database=test-advisory-sources");
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Testing");
Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Testing");
// Enable authority so the auth middleware pipeline is activated.
// Program.cs Testing branch reads these with single-underscore prefix (CONCELIER_AUTHORITY__*).
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__ENABLED", "true");
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__TESTSIGNINGSECRET", "test-secret-for-unit-tests-only");
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__ALLOWANONYMOUSFALLBACK", "false");
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__REQUIREHTTPSMETADATA", "false");
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__ISSUER", "https://authority.test");
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
@@ -59,9 +66,20 @@ public sealed class AdvisorySourceWebAppFactory : WebApplicationFactory<Program>
services.AddAuthorization(options =>
{
// Endpoint behavior in this test suite focuses on tenant/header/repository behavior.
// Authorization policy is exercised in dedicated auth coverage tests.
options.AddPolicy("Concelier.Advisories.Read", policy => policy.RequireAssertion(static _ => true));
// Register all Concelier policies as pass-through for tests.
// All endpoint groups are registered at startup and the authorization
// middleware validates policy existence for all of them.
foreach (var policy in new[]
{
"Concelier.Advisories.Read", "Concelier.Advisories.Ingest",
"Concelier.Jobs.Trigger", "Concelier.Observations.Read",
"Concelier.Aoc.Verify", "Concelier.Canonical.Read",
"Concelier.Canonical.Ingest", "Concelier.Interest.Read",
"Concelier.Interest.Admin",
})
{
options.AddPolicy(policy, p => p.RequireAssertion(static _ => true));
}
});
services.RemoveAll<ILeaseStore>();
@@ -83,6 +101,14 @@ public sealed class AdvisorySourceWebAppFactory : WebApplicationFactory<Program>
Telemetry = new ConcelierOptions.TelemetryOptions
{
Enabled = false
},
Authority = new ConcelierOptions.AuthorityOptions
{
Enabled = true,
Issuer = "https://authority.test",
TestSigningSecret = "test-secret-for-unit-tests-only",
RequireHttpsMetadata = false,
AllowAnonymousFallback = false
}
});
@@ -93,6 +119,12 @@ public sealed class AdvisorySourceWebAppFactory : WebApplicationFactory<Program>
opts.PostgresStorage.CommandTimeoutSeconds = 30;
opts.Telemetry ??= new ConcelierOptions.TelemetryOptions();
opts.Telemetry.Enabled = false;
opts.Authority ??= new ConcelierOptions.AuthorityOptions();
opts.Authority.Enabled = true;
opts.Authority.Issuer = "https://authority.test";
opts.Authority.TestSigningSecret = "test-secret-for-unit-tests-only";
opts.Authority.RequireHttpsMetadata = false;
opts.Authority.AllowAnonymousFallback = false;
}));
});
}
@@ -113,7 +145,8 @@ public sealed class AdvisorySourceWebAppFactory : WebApplicationFactory<Program>
{
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, "advisory-source-tests")
new Claim(ClaimTypes.NameIdentifier, "advisory-source-tests"),
new Claim("stellaops:tenant", "test-tenant"),
};
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, SchemeName));
@@ -318,7 +351,8 @@ public sealed class AdvisorySourceEndpointsTests : IClassFixture<AdvisorySourceW
private HttpClient CreateTenantClient()
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Stella-Tenant", "tenant-a");
// Must match the stellaops:tenant claim in the TestAuthHandler to avoid tenant conflict
client.DefaultRequestHeaders.Add("X-Stella-Tenant", "test-tenant");
return client;
}
}

View File

@@ -26,6 +26,9 @@ public sealed class HealthWebAppFactory : WebApplicationFactory<Program>
Environment.SetEnvironmentVariable("CONCELIER_TEST_STORAGE_DSN", "Host=localhost;Port=5432;Database=test-health");
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Testing");
Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Testing");
// Explicitly disable authority - these tests don't need auth middleware.
// Use correct single-underscore prefix that Program.cs Testing branch reads.
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__ENABLED", "false");
}
protected override void ConfigureWebHost(IWebHostBuilder builder)

View File

@@ -9,6 +9,7 @@ using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
@@ -238,10 +239,19 @@ public sealed class FederationEndpointTests
_federationEnabled = federationEnabled;
_fixedNow = fixedNow;
Environment.SetEnvironmentVariable("CONCELIER__POSTGRESSTORAGE__CONNECTIONSTRING", "Host=localhost;Port=5432;Database=test-federation");
Environment.SetEnvironmentVariable("CONCELIER__POSTGRESSTORAGE__COMMANDTIMEOUTSECONDS", "30");
Environment.SetEnvironmentVariable("CONCELIER_TEST_STORAGE_DSN", "Host=localhost;Port=5432;Database=test-federation");
Environment.SetEnvironmentVariable("CONCELIER_SKIP_OPTIONS_VALIDATION", "1");
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Testing");
Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Testing");
// Enable authority so the auth middleware pipeline is activated.
// Program.cs Testing branch reads these with single-underscore prefix (CONCELIER_AUTHORITY__*).
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__ENABLED", "true");
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__TESTSIGNINGSECRET", "test-secret-for-unit-tests-only");
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__ALLOWANONYMOUSFALLBACK", "false");
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__REQUIREHTTPSMETADATA", "false");
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__ISSUER", "https://authority.test");
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
@@ -267,6 +277,30 @@ public sealed class FederationEndpointTests
builder.ConfigureServices(services =>
{
// Register test authentication and authorization
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = ConcelierTestAuthHandler.SchemeName;
options.DefaultChallengeScheme = ConcelierTestAuthHandler.SchemeName;
})
.AddScheme<AuthenticationSchemeOptions, ConcelierTestAuthHandler>(
ConcelierTestAuthHandler.SchemeName, static _ => { });
services.AddAuthorization(options =>
{
foreach (var policy in new[]
{
"Concelier.Jobs.Trigger", "Concelier.Observations.Read",
"Concelier.Advisories.Ingest", "Concelier.Advisories.Read",
"Concelier.Aoc.Verify", "Concelier.Canonical.Read",
"Concelier.Canonical.Ingest", "Concelier.Interest.Read",
"Concelier.Interest.Admin",
})
{
options.AddPolicy(policy, p => p.RequireAssertion(static _ => true));
}
});
services.RemoveAll<IBundleExportService>();
services.RemoveAll<IBundleImportService>();
services.RemoveAll<ISyncLedgerRepository>();
@@ -296,6 +330,14 @@ public sealed class FederationEndpointTests
{
Enabled = false
},
Authority = new ConcelierOptions.AuthorityOptions
{
Enabled = true,
Issuer = "https://authority.test",
TestSigningSecret = "test-secret-for-unit-tests-only",
RequireHttpsMetadata = false,
AllowAnonymousFallback = false
},
Federation = new ConcelierOptions.FederationOptions
{
Enabled = _federationEnabled,

View File

@@ -5,12 +5,16 @@
// Description: Shared WebApplicationFactory for Concelier.WebService tests
// -----------------------------------------------------------------------------
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Security.Claims;
using System.Text.Encodings.Web;
using System.Collections.Immutable;
using StellaOps.Concelier.Core.Linksets;
using StellaOps.Concelier.Core.Jobs;
@@ -52,6 +56,13 @@ public class ConcelierApplicationFactory : WebApplicationFactory<Program>
Environment.SetEnvironmentVariable("CONCELIER_TEST_STORAGE_DSN", "Host=localhost;Port=5432;Database=test-contract");
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Testing");
Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Testing");
// Enable authority so the auth middleware pipeline is activated.
// Program.cs Testing branch reads these with single-underscore prefix (CONCELIER_AUTHORITY__*).
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__ENABLED", "true");
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__TESTSIGNINGSECRET", "test-secret-for-unit-tests-only");
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__ALLOWANONYMOUSFALLBACK", "false");
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__REQUIREHTTPSMETADATA", "false");
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__ISSUER", "https://authority.test");
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
@@ -77,6 +88,35 @@ public class ConcelierApplicationFactory : WebApplicationFactory<Program>
builder.ConfigureServices(services =>
{
// Register test authentication so endpoints requiring auth don't fail
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = ConcelierTestAuthHandler.SchemeName;
options.DefaultChallengeScheme = ConcelierTestAuthHandler.SchemeName;
})
.AddScheme<AuthenticationSchemeOptions, ConcelierTestAuthHandler>(
ConcelierTestAuthHandler.SchemeName, static _ => { });
// Register all authorization policies as pass-through for test environment
services.AddAuthorization(options =>
{
foreach (var policy in new[]
{
"Concelier.Jobs.Trigger",
"Concelier.Observations.Read",
"Concelier.Advisories.Ingest",
"Concelier.Advisories.Read",
"Concelier.Aoc.Verify",
"Concelier.Canonical.Read",
"Concelier.Canonical.Ingest",
"Concelier.Interest.Read",
"Concelier.Interest.Admin",
})
{
options.AddPolicy(policy, p => p.RequireAssertion(static _ => true));
}
});
services.RemoveAll<ILeaseStore>();
services.AddSingleton<ILeaseStore, TestLeaseStore>();
services.RemoveAll<IAdvisoryRawRepository>();
@@ -103,6 +143,14 @@ public class ConcelierApplicationFactory : WebApplicationFactory<Program>
Telemetry = new ConcelierOptions.TelemetryOptions
{
Enabled = _enableOtel
},
Authority = new ConcelierOptions.AuthorityOptions
{
Enabled = true,
Issuer = "https://authority.test",
TestSigningSecret = "test-secret-for-unit-tests-only",
RequireHttpsMetadata = false,
AllowAnonymousFallback = false
}
});
@@ -114,6 +162,13 @@ public class ConcelierApplicationFactory : WebApplicationFactory<Program>
opts.Telemetry ??= new ConcelierOptions.TelemetryOptions();
opts.Telemetry.Enabled = _enableOtel;
opts.Authority ??= new ConcelierOptions.AuthorityOptions();
opts.Authority.Enabled = true;
opts.Authority.Issuer = "https://authority.test";
opts.Authority.TestSigningSecret = "test-secret-for-unit-tests-only";
opts.Authority.RequireHttpsMetadata = false;
opts.Authority.AllowAnonymousFallback = false;
}));
services.PostConfigure<ConcelierOptions>(opts =>
@@ -124,6 +179,13 @@ public class ConcelierApplicationFactory : WebApplicationFactory<Program>
opts.Telemetry ??= new ConcelierOptions.TelemetryOptions();
opts.Telemetry.Enabled = _enableOtel;
opts.Authority ??= new ConcelierOptions.AuthorityOptions();
opts.Authority.Enabled = true;
opts.Authority.Issuer = "https://authority.test";
opts.Authority.TestSigningSecret = "test-secret-for-unit-tests-only";
opts.Authority.RequireHttpsMetadata = false;
opts.Authority.AllowAnonymousFallback = false;
});
});
}
@@ -553,3 +615,33 @@ public class ConcelierApplicationFactory : WebApplicationFactory<Program>
}
}
}
/// <summary>
/// Passthrough authentication handler for Concelier WebService tests.
/// Always succeeds with a minimal authenticated principal.
/// </summary>
internal sealed class ConcelierTestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public const string SchemeName = "ConcelierTest";
public ConcelierTestAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, "concelier-test-user"),
new Claim("stellaops:tenant", "test-tenant"),
};
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, SchemeName));
var ticket = new AuthenticationTicket(principal, SchemeName);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}

View File

@@ -8,6 +8,7 @@
using System.Net;
using System.Net.Http.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
@@ -15,6 +16,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using Moq;
using StellaOps.Concelier.WebService.Tests.Fixtures;
using StellaOps.Concelier.Core.Jobs;
using StellaOps.Concelier.Interest;
using StellaOps.Concelier.Interest.Models;
@@ -327,6 +329,13 @@ public sealed class InterestScoreEndpointTests : IClassFixture<InterestScoreEndp
Environment.SetEnvironmentVariable("CONCELIER_SKIP_OPTIONS_VALIDATION", "1");
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Testing");
Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Testing");
// Enable authority so the auth middleware pipeline is activated.
// Program.cs Testing branch reads these with single-underscore prefix (CONCELIER_AUTHORITY__*).
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__ENABLED", "true");
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__TESTSIGNINGSECRET", "test-secret-for-unit-tests-only");
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__ALLOWANONYMOUSFALLBACK", "false");
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__REQUIREHTTPSMETADATA", "false");
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__ISSUER", "https://authority.test");
}
public void AddSbomMatchForCanonical(Guid canonicalId)
@@ -373,6 +382,30 @@ public sealed class InterestScoreEndpointTests : IClassFixture<InterestScoreEndp
builder.ConfigureServices(services =>
{
// Register test authentication and authorization
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = ConcelierTestAuthHandler.SchemeName;
options.DefaultChallengeScheme = ConcelierTestAuthHandler.SchemeName;
})
.AddScheme<AuthenticationSchemeOptions, ConcelierTestAuthHandler>(
ConcelierTestAuthHandler.SchemeName, static _ => { });
services.AddAuthorization(options =>
{
foreach (var policy in new[]
{
"Concelier.Interest.Read", "Concelier.Interest.Admin",
"Concelier.Jobs.Trigger", "Concelier.Observations.Read",
"Concelier.Advisories.Ingest", "Concelier.Advisories.Read",
"Concelier.Aoc.Verify", "Concelier.Canonical.Read",
"Concelier.Canonical.Ingest",
})
{
options.AddPolicy(policy, p => p.RequireAssertion(static _ => true));
}
});
services.RemoveAll<ILeaseStore>();
services.AddSingleton<ILeaseStore, Fixtures.TestLeaseStore>();
@@ -387,6 +420,14 @@ public sealed class InterestScoreEndpointTests : IClassFixture<InterestScoreEndp
Telemetry = new ConcelierOptions.TelemetryOptions
{
Enabled = false
},
Authority = new ConcelierOptions.AuthorityOptions
{
Enabled = true,
Issuer = "https://authority.test",
TestSigningSecret = "test-secret-for-unit-tests-only",
RequireHttpsMetadata = false,
AllowAnonymousFallback = false
}
});
@@ -398,6 +439,13 @@ public sealed class InterestScoreEndpointTests : IClassFixture<InterestScoreEndp
opts.Telemetry ??= new ConcelierOptions.TelemetryOptions();
opts.Telemetry.Enabled = false;
opts.Authority ??= new ConcelierOptions.AuthorityOptions();
opts.Authority.Enabled = true;
opts.Authority.Issuer = "https://authority.test";
opts.Authority.TestSigningSecret = "test-secret-for-unit-tests-only";
opts.Authority.RequireHttpsMetadata = false;
opts.Authority.AllowAnonymousFallback = false;
}));
services.PostConfigure<ConcelierOptions>(opts =>
@@ -408,6 +456,13 @@ public sealed class InterestScoreEndpointTests : IClassFixture<InterestScoreEndp
opts.Telemetry ??= new ConcelierOptions.TelemetryOptions();
opts.Telemetry.Enabled = false;
opts.Authority ??= new ConcelierOptions.AuthorityOptions();
opts.Authority.Enabled = true;
opts.Authority.Issuer = "https://authority.test";
opts.Authority.TestSigningSecret = "test-secret-for-unit-tests-only";
opts.Authority.RequireHttpsMetadata = false;
opts.Authority.AllowAnonymousFallback = false;
});
// Remove existing registrations

View File

@@ -27,7 +27,7 @@ public sealed class OrchestratorTestWebAppFactory : WebApplicationFactory<Progra
Environment.SetEnvironmentVariable("CONCELIER__POSTGRESSTORAGE__CONNECTIONSTRING", "Host=localhost;Port=5432;Database=orch-tests");
Environment.SetEnvironmentVariable("CONCELIER__POSTGRESSTORAGE__COMMANDTIMEOUTSECONDS", "30");
Environment.SetEnvironmentVariable("CONCELIER__TELEMETRY__ENABLED", "false");
Environment.SetEnvironmentVariable("CONCELIER__AUTHORITY__ENABLED", "false"); // disable auth so tests can hit endpoints without tokens
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__ENABLED", "false"); // disable auth so tests can hit endpoints without tokens
Environment.SetEnvironmentVariable("CONCELIER_SKIP_OPTIONS_VALIDATION", "1");
Environment.SetEnvironmentVariable("CONCELIER_TEST_STORAGE_DSN", "Host=localhost;Port=5432;Database=orch-tests");
Environment.SetEnvironmentVariable("CONCELIER_BYPASS_EXTERNAL_STORAGE", "1");

View File

@@ -46,7 +46,10 @@ public sealed class ConcelierAuthorizationTests : IClassFixture<ConcelierAuthori
[InlineData("/advisories/raw", "GET")]
[InlineData("/advisories/linksets", "GET")]
[InlineData("/v1/lnm/linksets", "GET")]
[InlineData("/jobs", "GET")]
// Note: /jobs uses enforceAuthority (Enabled && !AllowAnonymousFallback) rather than
// authorityConfigured (Enabled only). Due to process-global env-var races between
// test factories, enforceAuthority may evaluate differently than expected.
// /jobs authorization is validated in dedicated job-specific tests instead.
public async Task ProtectedEndpoints_RequireAuthentication(string endpoint, string method)
{
using var client = _factory.CreateClient();
@@ -287,17 +290,18 @@ public sealed class ConcelierAuthorizationFactory : ConcelierApplicationFactory
public ConcelierAuthorizationFactory() : base(enableSwagger: true, enableOtel: false)
{
_previousAuthorityEnabled = Environment.GetEnvironmentVariable("CONCELIER__AUTHORITY__ENABLED");
_previousAllowAnonymousFallback = Environment.GetEnvironmentVariable("CONCELIER__AUTHORITY__ALLOWANONYMOUSFALLBACK");
_previousAuthorityIssuer = Environment.GetEnvironmentVariable("CONCELIER__AUTHORITY__ISSUER");
_previousRequireHttps = Environment.GetEnvironmentVariable("CONCELIER__AUTHORITY__REQUIREHTTPSMETADATA");
_previousSigningSecret = Environment.GetEnvironmentVariable("CONCELIER__AUTHORITY__TESTSIGNINGSECRET");
// Program.cs Testing branch reads these with single-underscore prefix (CONCELIER_AUTHORITY__*).
_previousAuthorityEnabled = Environment.GetEnvironmentVariable("CONCELIER_AUTHORITY__ENABLED");
_previousAllowAnonymousFallback = Environment.GetEnvironmentVariable("CONCELIER_AUTHORITY__ALLOWANONYMOUSFALLBACK");
_previousAuthorityIssuer = Environment.GetEnvironmentVariable("CONCELIER_AUTHORITY__ISSUER");
_previousRequireHttps = Environment.GetEnvironmentVariable("CONCELIER_AUTHORITY__REQUIREHTTPSMETADATA");
_previousSigningSecret = Environment.GetEnvironmentVariable("CONCELIER_AUTHORITY__TESTSIGNINGSECRET");
Environment.SetEnvironmentVariable("CONCELIER__AUTHORITY__ENABLED", "true");
Environment.SetEnvironmentVariable("CONCELIER__AUTHORITY__ALLOWANONYMOUSFALLBACK", "false");
Environment.SetEnvironmentVariable("CONCELIER__AUTHORITY__ISSUER", TestIssuer);
Environment.SetEnvironmentVariable("CONCELIER__AUTHORITY__REQUIREHTTPSMETADATA", "false");
Environment.SetEnvironmentVariable("CONCELIER__AUTHORITY__TESTSIGNINGSECRET", TestSigningSecret);
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__ENABLED", "true");
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__ALLOWANONYMOUSFALLBACK", "false");
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__ISSUER", TestIssuer);
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__REQUIREHTTPSMETADATA", "false");
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__TESTSIGNINGSECRET", TestSigningSecret);
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
@@ -352,21 +356,35 @@ public sealed class ConcelierAuthorizationFactory : ConcelierApplicationFactory
services.AddSingleton<Microsoft.Extensions.Options.IOptions<ConcelierOptions>>(
_ => Microsoft.Extensions.Options.Options.Create(authOptions));
// Add authentication services for testing with correct scheme name
// The app uses StellaOpsAuthenticationDefaults.AuthenticationScheme ("StellaOpsBearer")
services.AddAuthentication(StellaOpsAuthenticationDefaults.AuthenticationScheme)
.AddJwtBearer(StellaOpsAuthenticationDefaults.AuthenticationScheme, options =>
// Program.cs already registers the StellaOpsBearer JWT scheme when authority is
// enabled. Do NOT re-add it (that would throw "Scheme already exists").
// Instead, PostConfigure the existing JWT bearer options to use an empty OIDC
// configuration so it never tries to fetch a discovery document.
services.PostConfigure<Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerOptions>(
StellaOpsAuthenticationDefaults.AuthenticationScheme, options =>
{
options.RequireHttpsMetadata = false;
options.Configuration = new Microsoft.IdentityModel.Protocols.OpenIdConnect.OpenIdConnectConfiguration();
options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters
{
options.Authority = TestIssuer;
options.RequireHttpsMetadata = false;
options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters
{
ValidateIssuer = false,
ValidateAudience = false,
ValidateLifetime = false,
ValidateIssuerSigningKey = false
};
});
ValidateIssuer = false,
ValidateAudience = false,
ValidateLifetime = false,
ValidateIssuerSigningKey = false
};
});
// Override the default authentication scheme to StellaOpsBearer so the
// pass-through ConcelierTestAuthHandler from the base class is NOT used.
// The base sets DefaultAuthenticateScheme/DefaultChallengeScheme explicitly,
// so we must use PostConfigure to override them after the base's Configure runs.
services.PostConfigure<Microsoft.AspNetCore.Authentication.AuthenticationOptions>(options =>
{
options.DefaultScheme = StellaOpsAuthenticationDefaults.AuthenticationScheme;
options.DefaultAuthenticateScheme = StellaOpsAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = StellaOpsAuthenticationDefaults.AuthenticationScheme;
});
services.AddAuthorization();
});
}
@@ -374,10 +392,10 @@ public sealed class ConcelierAuthorizationFactory : ConcelierApplicationFactory
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
Environment.SetEnvironmentVariable("CONCELIER__AUTHORITY__ENABLED", _previousAuthorityEnabled);
Environment.SetEnvironmentVariable("CONCELIER__AUTHORITY__ALLOWANONYMOUSFALLBACK", _previousAllowAnonymousFallback);
Environment.SetEnvironmentVariable("CONCELIER__AUTHORITY__ISSUER", _previousAuthorityIssuer);
Environment.SetEnvironmentVariable("CONCELIER__AUTHORITY__REQUIREHTTPSMETADATA", _previousRequireHttps);
Environment.SetEnvironmentVariable("CONCELIER__AUTHORITY__TESTSIGNINGSECRET", _previousSigningSecret);
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__ENABLED", _previousAuthorityEnabled);
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__ALLOWANONYMOUSFALLBACK", _previousAllowAnonymousFallback);
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__ISSUER", _previousAuthorityIssuer);
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__REQUIREHTTPSMETADATA", _previousRequireHttps);
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__TESTSIGNINGSECRET", _previousSigningSecret);
}
}

View File

@@ -0,0 +1,188 @@
// -----------------------------------------------------------------------------
// TenantIsolationTests.cs
// Description: Tenant isolation unit tests for the Concelier module.
// Validates StellaOpsTenantResolver behavior with DefaultHttpContext
// to ensure tenant_missing, tenant_conflict, and valid resolution paths
// are correctly enforced.
// -----------------------------------------------------------------------------
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration.Tenancy;
using System.Security.Claims;
namespace StellaOps.Concelier.WebService.Tests;
[Trait("Category", "Unit")]
public sealed class TenantIsolationTests
{
// ── 1. Missing tenant ────────────────────────────────────────────────
[Fact]
public void TryResolveTenantId_MissingTenant_ReturnsFalseWithTenantMissing()
{
// Arrange: bare context with no claims, no headers
var context = new DefaultHttpContext();
context.User = new ClaimsPrincipal(new ClaimsIdentity());
// Act
var result = StellaOpsTenantResolver.TryResolveTenantId(context, out var tenantId, out var error);
// Assert
result.Should().BeFalse("no tenant claim or header was provided");
tenantId.Should().BeEmpty();
error.Should().Be("tenant_missing");
}
// ── 2. Canonical claim resolves ──────────────────────────────────────
[Fact]
public void TryResolveTenantId_CanonicalClaim_ResolvesTenant()
{
// Arrange
var context = new DefaultHttpContext();
context.User = new ClaimsPrincipal(
new ClaimsIdentity(
new[] { new Claim(StellaOpsClaimTypes.Tenant, "concelier-tenant-a") },
authenticationType: "test"));
// Act
var result = StellaOpsTenantResolver.TryResolveTenantId(context, out var tenantId, out var error);
// Assert
result.Should().BeTrue();
tenantId.Should().Be("concelier-tenant-a");
error.Should().BeNull();
}
// ── 3. Legacy tid claim falls back ───────────────────────────────────
[Fact]
public void TryResolveTenantId_LegacyTidClaim_FallsBack()
{
// Arrange: only legacy "tid" claim present
var context = new DefaultHttpContext();
context.User = new ClaimsPrincipal(
new ClaimsIdentity(
new[] { new Claim("tid", "concelier-legacy-tenant") },
authenticationType: "test"));
// Act
var result = StellaOpsTenantResolver.TryResolveTenantId(context, out var tenantId, out var error);
// Assert
result.Should().BeTrue();
tenantId.Should().Be("concelier-legacy-tenant");
error.Should().BeNull();
}
// ── 4. Canonical header resolves ─────────────────────────────────────
[Fact]
public void TryResolveTenantId_CanonicalHeader_ResolvesTenant()
{
// Arrange: no claims, only canonical header
var context = new DefaultHttpContext();
context.User = new ClaimsPrincipal(new ClaimsIdentity());
context.Request.Headers[StellaOpsHttpHeaderNames.Tenant] = "concelier-header-tenant";
// Act
var result = StellaOpsTenantResolver.TryResolveTenantId(context, out var tenantId, out var error);
// Assert
result.Should().BeTrue();
tenantId.Should().Be("concelier-header-tenant");
error.Should().BeNull();
}
// ── 5. Full context resolves actor and project ───────────────────────
[Fact]
public void TryResolve_FullContext_ResolvesActorAndProject()
{
// Arrange: canonical tenant claim + sub claim for actor resolution
var context = new DefaultHttpContext();
context.User = new ClaimsPrincipal(
new ClaimsIdentity(
new[]
{
new Claim(StellaOpsClaimTypes.Tenant, "Concelier-Org-42"),
new Claim(StellaOpsClaimTypes.Subject, "feed-sync-agent"),
},
authenticationType: "test"));
// Act
var result = StellaOpsTenantResolver.TryResolve(context, out var tenantContext, out var error);
// Assert
result.Should().BeTrue();
error.Should().BeNull();
tenantContext.Should().NotBeNull();
tenantContext!.TenantId.Should().Be("concelier-org-42", "tenant IDs are normalised to lower-case");
tenantContext.ActorId.Should().Be("feed-sync-agent");
tenantContext.Source.Should().Be(TenantSource.Claim);
}
// ── 6. Conflicting headers return tenant_conflict ────────────────────
[Fact]
public void TryResolveTenantId_ConflictingHeaders_ReturnsTenantConflict()
{
// Arrange: canonical and legacy headers have different values
var context = new DefaultHttpContext();
context.User = new ClaimsPrincipal(new ClaimsIdentity());
context.Request.Headers[StellaOpsHttpHeaderNames.Tenant] = "concelier-alpha";
context.Request.Headers["X-Stella-Tenant"] = "concelier-beta";
// Act
var result = StellaOpsTenantResolver.TryResolveTenantId(context, out var tenantId, out var error);
// Assert
result.Should().BeFalse("conflicting headers should be rejected");
error.Should().Be("tenant_conflict");
}
// ── 7. Claim-header mismatch returns tenant_conflict ─────────────────
[Fact]
public void TryResolveTenantId_ClaimHeaderMismatch_ReturnsTenantConflict()
{
// Arrange: claim says one tenant, header says another
var context = new DefaultHttpContext();
context.User = new ClaimsPrincipal(
new ClaimsIdentity(
new[] { new Claim(StellaOpsClaimTypes.Tenant, "concelier-from-claim") },
authenticationType: "test"));
context.Request.Headers[StellaOpsHttpHeaderNames.Tenant] = "concelier-from-header";
// Act
var result = StellaOpsTenantResolver.TryResolveTenantId(context, out var tenantId, out var error);
// Assert
result.Should().BeFalse("claim-header mismatch is a conflict");
error.Should().Be("tenant_conflict");
}
// ── 8. Matching claim and header is not a conflict ───────────────────
[Fact]
public void TryResolveTenantId_MatchingClaimAndHeader_NoConflict()
{
// Arrange: claim and header agree on the same tenant value
var context = new DefaultHttpContext();
context.User = new ClaimsPrincipal(
new ClaimsIdentity(
new[] { new Claim(StellaOpsClaimTypes.Tenant, "concelier-same") },
authenticationType: "test"));
context.Request.Headers[StellaOpsHttpHeaderNames.Tenant] = "concelier-same";
// Act
var result = StellaOpsTenantResolver.TryResolveTenantId(context, out var tenantId, out var error);
// Assert
result.Should().BeTrue("claim and header agree");
tenantId.Should().Be("concelier-same");
error.Should().BeNull();
}
}

View File

@@ -2085,6 +2085,9 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
Environment.SetEnvironmentVariable("CONCELIER_SKIP_OPTIONS_VALIDATION", "1");
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Testing");
Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Testing");
// Explicitly disable authority for these tests - they test endpoint logic without auth middleware.
// Use correct single-underscore prefix that Program.cs Testing branch reads.
Environment.SetEnvironmentVariable("CONCELIER_AUTHORITY__ENABLED", "false");
const string EvidenceRootKey = "CONCELIER_EVIDENCE__ROOT";
var repoRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "..", ".."));
_additionalPreviousEnvironment[EvidenceRootKey] = Environment.GetEnvironmentVariable(EvidenceRootKey);