Guides
Test Your SqlOS Integration
Build consumer-owned integration tests for OAuth, SQL-backed authorization, tenant isolation, provider fakes, revocation, and upgrades.
SqlOS unit tests prove library behavior. Your application still owns client registration, callback URLs, audiences, claim-to-subject mapping, resource IDs, grants, middleware order, and database lifecycle. A small consumer integration suite is the most reliable place to prove those pieces fit together.
This page is for a product that uses SqlOS. If you are changing the SqlOS repository itself, use Testing SqlOS for the repository scripts, unit suite, benchmarks, and contributor workflow.
The SqlOS examples use a practical hybrid:
WebApplicationFactory<Program> boots the actual application entry point in process with the test connection string.HttpClient drives hosted auth, token exchange, protected APIs, and dashboard boundaries.This keeps the product host real while avoiding network calls to production identity and messaging providers.
MSTest / xUnit / NUnit
|
+-- Aspire test AppHost ----> SQL Server container
|
+-- WebApplicationFactory --> your Program.cs
|
+-- AddSqlOS + MapSqlOS
+-- real EF Core SQL Server provider
+-- fake email / OIDC / SMS adaptersDo not substitute EF Core's in-memory provider or SQLite for the SQL-backed suite. SqlOS bootstraps SQL Server scripts, uses SQL Server concurrency behavior, and implements FGA query filtering through SQL functions. A non-SQL provider can make the test green while skipping the behavior you need to trust.
The consumer test project needs:
<ItemGroup>
<PackageReference Include="Aspire.Hosting.Testing" Version="9.4.2" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.0" />
<!-- Add your normal test framework and assertion library. -->
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\YourProduct.Api\YourProduct.Api.csproj" />
<ProjectReference Include="..\YourProduct.TestAppHost\YourProduct.TestAppHost.csproj" />
</ItemGroup>Use versions compatible with the target framework and Aspire version in your solution. The values above match the current SqlOS repository; they are not a requirement imposed by the SqlOS package.
Your test AppHost can be intentionally small:
using Aspire.Hosting;
var builder = DistributedApplication.CreateBuilder(args);
// A non-production credential used only by this disposable test container.
var sqlPassword = builder.AddParameter(
"sql-password",
value: "SqlOS-Test-Only-Password!42");
var sql = builder.AddSqlServer("sql", password: sqlPassword)
.WithContainerRuntimeArgs("--platform", "linux/amd64");
sql.AddDatabase("sqlos-test");
builder.Build().Run();The repository's SqlOS.IntegrationTests.AppHost uses this same shape. Keep infrastructure in the AppHost and application configuration in WebApplicationFactory; that makes failures easier to localize.
The fixture below shows the important lifecycle. It uses MSTest assembly hooks because the SqlOS repository uses MSTest, but the same ownership works with xUnit fixtures or NUnit setup fixtures.
using Aspire.Hosting;
using Aspire.Hosting.Testing;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Data.SqlClient;
using Microsoft.VisualStudio.TestTools.UnitTesting;
[TestClass]
public static class ProductApiFixture
{
private static DistributedApplication? _infrastructure;
private static WebApplicationFactory<Program>? _factory;
private static string _connectionString = string.Empty;
public static HttpClient Client { get; private set; } = null!;
[AssemblyInitialize]
public static async Task StartAsync(TestContext testContext)
{
var appHost = await DistributedApplicationTestingBuilder
.CreateAsync<Projects.YourProduct_TestAppHost>();
_infrastructure = await appHost.BuildAsync();
await _infrastructure.StartAsync();
using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(3));
await _infrastructure.ResourceNotifications
.WaitForResourceHealthyAsync("sql", timeout.Token);
var baseConnectionString =
await _infrastructure.GetConnectionStringAsync("sqlos-test")
?? throw new InvalidOperationException("SQL connection string was not available.");
var connection = new SqlConnectionStringBuilder(baseConnectionString)
{
InitialCatalog = $"ProductSqlOS_{Guid.NewGuid():N}"[..30]
};
_connectionString = connection.ConnectionString;
_factory = BuildFactory();
Client = _factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
var discovery = await Client.GetAsync(
"/sqlos/auth/.well-known/oauth-authorization-server");
discovery.EnsureSuccessStatusCode();
testContext.WriteLine($"SqlOS test database: {connection.InitialCatalog}");
}
public static WebApplicationFactory<Program> CreateFactory(
Action<IWebHostBuilder>? configure = null)
=> BuildFactory(configure);
private static WebApplicationFactory<Program> BuildFactory(
Action<IWebHostBuilder>? configure = null)
=> new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.UseSetting("environment", "Development");
builder.UseSetting(
"ConnectionStrings:DefaultConnection",
_connectionString);
builder.UseSetting(
"SqlOS:PublicOrigin",
"https://sqlos.example.test");
builder.UseSetting(
"SqlOS:Issuer",
"https://sqlos.example.test/sqlos/auth");
configure?.Invoke(builder);
});
[AssemblyCleanup]
public static async Task StopAsync()
{
Client?.Dispose();
if (_factory is not null)
await _factory.DisposeAsync();
// Drop the uniquely named database here with your DbContext or a
// master-database helper. Make cleanup best-effort but observable.
if (_infrastructure is not null)
{
await _infrastructure.StopAsync();
await _infrastructure.DisposeAsync();
}
}
}The application under test must create or migrate the unique database during startup. If production deliberately keeps migration outside the web host, have the fixture create the database and run the same migration/bootstrap entry point before building WebApplicationFactory.
Two details prevent misleading tests:
AllowAutoRedirect = false for OAuth tests so assertions can inspect 302 locations, state, authorization codes, and error redirects.Program read the same configuration keys the fixture overrides. UseSetting cannot change a value that the application ignores or hard-codes.Compare the complete repository fixtures:
ExampleApiFixture — Aspire SQL plus WebApplicationFactory and an OIDC fake.TodoApiFixture — a fresh Todo database and exact public issuer/resource settings.AspireFixture — direct service integration against SQL Server, including isolated databases for schema/signing-key cases.Prefer the same HTTP boundary your real client uses. A hosted authorization-code test should perform these steps:
/sqlos/auth/authorize with the real client_id, redirect_uri, scopes, state, and resource when used.state and a code—not tokens./sqlos/auth/token as form data.Use one helper for the mechanical PKCE/redirect parsing, then keep each test focused on the product rule it proves. The current TodoSampleIntegrationTests contains working helpers for hosted signup, code exchange, bearer requests, CIMD, and DCR.
A 200 OK token response does not prove the integration is correct. Validate the JWT signature and assert iss, aud, sub, sid, client_id, expiration, authentication methods, and org_id where applicable. Then send that token through your actual API middleware.
Keep SqlOS services and your application endpoints real. Replace only nondeterministic external boundaries such as email, SMS, OIDC HTTP calls, DNS, and calendar providers.
For Email OTP, register a test sender that captures messages:
using Microsoft.Extensions.DependencyInjection.Extensions;
using SqlOS.AuthServer.Interfaces;
using SqlOS.Email.Interfaces;
await using var factory = ProductApiFixture.CreateFactory(builder =>
{
builder.ConfigureServices(services =>
{
services.RemoveAll<ISqlOSAuthEmailSender>();
services.RemoveAll<ISqlOSEmailSender>();
services.AddSingleton<TestAuthEmailSender>();
services.AddSingleton<ISqlOSAuthEmailSender>(sp =>
sp.GetRequiredService<TestAuthEmailSender>());
services.AddSingleton<ISqlOSEmailSender>(sp =>
sp.GetRequiredService<TestAuthEmailSender>());
});
});The fake should capture the rendered recipient/body and expose the latest OTP without changing SqlOS verification. That proves configuration, template rendering, challenge persistence, and consumption together. See the repository's TestAuthEmailSender.
For OIDC, emulate discovery, JWKS, token, and user-info responses and sign the fake ID token with the JWKS key. Returning a hard-coded profile without signature/nonce validation skips the security behavior under test. The FakeOidcProviderHttpClientFactory is a complete example.
For SAML, generate signed assertions with test-only keys and mutate one security property per negative case. Never call a real customer IdP from the normal pull-request suite.
Start with one test in every row, then add cases for the features you enable.
| Area | Happy-path proof | Negative proof |
|---|---|---|
| Startup/schema | Fresh database boots, scripts are deterministically ledgered, and a second start is idempotent | Bad connection fails startup; an interrupted script is retried without skipping same-version siblings |
| Discovery/JWKS | Published issuer and endpoints match the public origin; active key validates a token | Internal host/scheme never appears in metadata or links |
| Authorization code + PKCE | Hosted sign-in returns one exchangeable code and the expected state | Wrong verifier, redirect URI, client, reused code, or expired request fails |
| Token validation | Correct issuer, signature, lifetime, and exact audience reach the API | Missing, malformed, expired, wrong-audience, or wrong-issuer token returns 401 |
| Refresh/logout | Refresh rotates; logout revokes the session and protected API access | Reuse outside the grace behavior triggers the expected rejection/revocation |
| Organizations | The selected organization appears in org_id and app context | Multiple-org login requires selection; inactive/nonmember organization is denied |
| FGA list query | Subject sees exactly the rows granted through the hierarchy | Missing grant and cross-tenant rows are absent—not trimmed after loading |
| FGA mutation | Permitted subject can create/update/delete the resource | Cross-tenant or underprivileged mutation returns 403 and changes no row |
| Auth-to-FGA bridge | Login subject is provisioned and receives the intended role/resource grant | Unknown subject or missing resource never gains an implicit grant |
| Email/phone OTP | Captured code completes once and produces the expected session | Wrong, expired, replayed, or mismatched challenge cannot authenticate |
| Invitation | Bound email accepts once and creates only the intended membership | Wrong email, revoked/expired token, or replay creates no membership |
| OIDC/SAML | Signed response maps the intended identity and organization | Bad nonce/signature/audience/time/InResponseTo, missing bearer expiry, replayed response/assertion IDs, disabled connection, and JIT-off user fail |
| Dashboard | Configured operator can access root, page, and admin API | Anonymous caller is redirected, 401, or 404 according to the surface; auth routes remain public |
| Data Protection | Replacement instance sharing the key ring reads existing protected material | Test configuration with a different/lost key ring fails visibly and safely |
| Upgrade | Current build upgrades a restored previous-version database and completes smoke tests | Rollback/recovery procedure is exercised against an incompatible schema copy |
Use these as patterns, not as a substitute for product tests:
A fresh database per test assembly is a good default: container startup is amortized, while one CI job cannot inherit schema or identities from another. Within the suite:
Do not wrap an end-to-end HTTP test in a test-process EF transaction and assume the application server participates. It uses another DbContext/connection. Reset through public behavior or explicit fixture cleanup instead.
The machine needs the .NET SDK and a container runtime capable of running SQL Server. Fail early with a clear prerequisite message if the container runtime is unavailable.
For this repository:
dotnet test examples/SqlOS.Todo.IntegrationTests/SqlOS.Todo.IntegrationTests.csproj
dotnet test examples/SqlOS.Example.IntegrationTests/SqlOS.Example.IntegrationTests.csproj
dotnet test tests/SqlOS.IntegrationTests/SqlOS.IntegrationTests.csprojFor your product, keep a fast, required suite containing bootstrap, one hosted login, token/API validation, logout, and tenant/FGA isolation. Run the broader provider, upgrade, recovery, and concurrency matrix on every release or on a scheduled pipeline if its runtime is too high for every commit.
Your integration is covered when a test can answer all of these with evidence: