Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions coverlet.runsettings
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Coverage settings shared by the unit and integration suites.

Run with: dotnet test TodoApp.sln (plus the settings flag pointing at this file)
Each test project writes a Cobertura report under its own TestResults/<guid>/.
-->
<RunSettings>
<DataCollectionRunSettings>
<DataCollectors>
<DataCollector friendlyName="XPlat code coverage">
<Configuration>
<Format>cobertura,json</Format>
<!-- Only our own assemblies; the test assemblies themselves are not the subject. -->
<Include>[TodoApp.Domain]*,[TodoApp.Application]*,[TodoApp.Infrastructure]*,[TodoApp.WebApi]*</Include>
<ExcludeByAttribute>Obsolete,GeneratedCodeAttribute,CompilerGeneratedAttribute,ExcludeFromCodeCoverageAttribute</ExcludeByAttribute>
<SkipAutoProps>true</SkipAutoProps>
<UseSourceLink>false</UseSourceLink>
<DeterministicReport>false</DeterministicReport>
</Configuration>
</DataCollector>
</DataCollectors>
</DataCollectionRunSettings>
</RunSettings>
30 changes: 28 additions & 2 deletions docs/development/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ the companion [CI/CD pipeline testing guide](../deployment/pipeline.md).
| Frontend | `frontend/src/**/*.test.{js,jsx}` | **Vitest** + React Testing Library | Pure helpers, a component, and the `useTodos` hook (optimistic move + error handling). |
| API — unit | `tests/TodoApp.UnitTests` | **xUnit** + FluentAssertions | Domain invariants and CQRS handlers (ownership, concurrency, auth/refresh) against real EF Core over in-memory SQLite. |
| API — integration | `tests/TodoApp.IntegrationTests` | **xUnit** + `WebApplicationFactory<Program>` | The whole API in-process, hit over HTTP end-to-end (register → authorize → call endpoints). |
| API — smoke | `scripts/todoapp-smoketest.ps1` | **PowerShell** (`Invoke-WebRequest`) | Every endpoint over HTTP against a *running* instance — a fast health / regression check (see §3.4). |
| API — smoke | `scripts/todoapp-smoketest.ps1` | **PowerShell** (`Invoke-WebRequest`) | Every endpoint over HTTP against a *running* instance — a fast health / regression check (see §3.5). |

The guiding rule on both sides: **tests use the real thing wherever it's cheap.** The API tests run
against a genuine EF Core context (in-memory SQLite, not the EF in-memory provider) so query
Expand Down Expand Up @@ -306,7 +306,33 @@ dotnet test tests/TodoApp.UnitTests # unit only
dotnet test tests/TodoApp.IntegrationTests # integration only
```

### 3.4 End-to-end smoke test — `scripts/todoapp-smoketest.ps1`
### 3.4 Measuring coverage

Both API test projects reference `coverlet.collector`, and `coverlet.runsettings` at the repository
root configures it: Cobertura **and** coverlet JSON output, only the four `TodoApp.*` assemblies in
scope, and auto-properties skipped (a getter nobody wrote is not a branch anybody needs tested).

```bash
dotnet test TodoApp.sln --settings coverlet.runsettings
```

Each project writes `coverage.cobertura.xml` and `coverage.json` under its own `TestResults/<guid>/`.

**Merge the JSON, not the Cobertura, when combining the two projects.** Cobertura records only a
per-line branch *percentage*, which cannot be unioned across runs — a branch the unit suite covers
and a branch the integration suite covers still read as "1 of 2" in both files, so a naive merge
under-reports. Coverlet's JSON keeps every branch as its own record with a hit count, so summing
per `(file, class, method, line, ordinal)` is exact.

Current state: **99.88% of lines and 100% of branches**. Two lines are not covered, both because
they need a live cloud service rather than because they lack a test:

| Location | Why |
| -------- | --- |
| `GoogleTokenValidator.ValidateAsync` — the mapping after a successful validation | Verifying a real Google ID token requires Google's live signing keys. The mapping itself is split into `FromPayload` and is tested; the configuration guard and the malformed-token path are tested too. |
| `Program.cs` — the closing brace of the Key Vault block | Only reached when `AddAzureKeyVault` succeeds against a real vault. The opt-in branch is covered both ways, including that a malformed `KeyVault:Uri` stops startup. |

### 3.5 End-to-end smoke test — `scripts/todoapp-smoketest.ps1`

Beyond the automated suites, a PowerShell script hits **every** API endpoint over HTTP against a
*running* instance and prints a pass/fail report — the fastest way to confirm the whole API behaves
Expand Down
17 changes: 12 additions & 5 deletions src/TodoApp.Infrastructure/Authentication/GoogleTokenValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,22 @@ public GoogleTokenValidator(IOptions<GoogleAuthSettings> options)

var payload = await GoogleJsonWebSignature.ValidateAsync(idToken, settings);

return new GoogleUserInfo(
payload.Subject,
payload.Email,
payload.EmailVerified,
payload.Name);
return FromPayload(payload);
}
catch (InvalidJwtException)
{
return null;
}
}

/// <summary>
/// Maps an already-verified Google payload onto our own model. Split out from
/// <see cref="ValidateAsync"/> so the mapping is testable without a Google-signed token —
/// validation itself needs Google's live signing keys and cannot run offline.
/// </summary>
public static GoogleUserInfo FromPayload(GoogleJsonWebSignature.Payload payload) => new(
payload.Subject,
payload.Email,
payload.EmailVerified,
payload.Name);
}
86 changes: 86 additions & 0 deletions src/TodoApp.WebApi/DatabaseStartup.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
using TodoApp.Application.Common.Interfaces;
using TodoApp.Infrastructure.Persistence;

namespace TodoApp.WebApi;

/// <summary>
/// Creates and seeds the database at startup, without letting a cold or paused database stop the
/// app from starting.
/// </summary>
/// <remarks>
/// Azure SQL serverless can be waking from auto-pause when the container comes up. Failing
/// startup there turns a slow database into a dead app, so an unreachable database is logged and
/// the initialization is retried off the startup path; requests in the meantime ride out the
/// wake-up via EF's <c>EnableRetryOnFailure</c>.
/// </remarks>
public static class DatabaseStartup
{
/// <summary>
/// Initializes the database, falling back to a background retry loop if the first attempt
/// fails.
/// </summary>
/// <returns>
/// <c>null</c> when the database was initialized immediately, otherwise the background retry
/// task. Production discards it — startup must not wait on it — but a test can await it.
/// </returns>
public static async Task<Task?> InitializeAsync(
IServiceProvider services,
DemoSeedOptions demoSeed,
ILogger logger,
TimeSpan retryDelay,
int maxRetryAttempts)
{
try
{
await InitializeOnceAsync(services, demoSeed);
return null;
}
catch (Exception ex)
{
logger.LogWarning(ex,
"Database initialization was deferred at startup (database may be resuming). " +
"It will be retried in the background.");

return Task.Run(() => RetryAsync(
services, demoSeed, logger, retryDelay, maxRetryAttempts));
}
Comment thread
bgard68 marked this conversation as resolved.
Dismissed
}

private static async Task InitializeOnceAsync(IServiceProvider services, DemoSeedOptions demoSeed)
{
using var scope = services.CreateScope();
var scoped = scope.ServiceProvider;

await DbInitializer.InitializeAsync(
scoped.GetRequiredService<ApplicationDbContext>(),
scoped.GetRequiredService<IPasswordHasher>(),
scoped.GetRequiredService<IDateTimeProvider>(),
demoSeed);
}

private static async Task RetryAsync(
IServiceProvider services,
DemoSeedOptions demoSeed,
ILogger logger,
TimeSpan retryDelay,
int maxRetryAttempts)
{
for (var attempt = 1; attempt <= maxRetryAttempts; attempt++)
{
await Task.Delay(retryDelay);

try
{
await InitializeOnceAsync(services, demoSeed);
logger.LogInformation(
"Database initialization completed on background attempt {Attempt}.", attempt);
return;
}
catch (Exception retryEx)
{
logger.LogWarning(retryEx,
"Background database initialization attempt {Attempt} failed.", attempt);
}
Comment thread
bgard68 marked this conversation as resolved.
Dismissed
}
}
}
52 changes: 8 additions & 44 deletions src/TodoApp.WebApi/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -181,50 +181,14 @@ await context.HttpContext.Response.WriteAsync(
// Create and seed the database on startup — but never let a cold/paused database (e.g. Azure
// SQL serverless waking from auto-pause) block the app from starting. If the DB is unreachable
// here, we log and carry on; the schema/seed is retried in the background until it succeeds, and
// requests ride out the wake-up via EF's EnableRetryOnFailure.
using (var scope = app.Services.CreateScope())
{
var services = scope.ServiceProvider;
var startupLogger = services.GetRequiredService<ILogger<Program>>();
try
{
var context = services.GetRequiredService<ApplicationDbContext>();
var passwordHasher = services.GetRequiredService<IPasswordHasher>();
var dateTime = services.GetRequiredService<IDateTimeProvider>();
await DbInitializer.InitializeAsync(context, passwordHasher, dateTime, demoSeed);
}
catch (Exception ex)
{
startupLogger.LogWarning(ex,
"Database initialization was deferred at startup (database may be resuming). " +
"It will be retried in the background.");

// Retry the initialization off the startup path so the app can start serving immediately.
_ = Task.Run(async () =>
{
for (var attempt = 1; attempt <= 10; attempt++)
{
await Task.Delay(TimeSpan.FromSeconds(15));
try
{
using var retryScope = app.Services.CreateScope();
var rs = retryScope.ServiceProvider;
await DbInitializer.InitializeAsync(
rs.GetRequiredService<ApplicationDbContext>(),
rs.GetRequiredService<IPasswordHasher>(),
rs.GetRequiredService<IDateTimeProvider>(),
demoSeed);
startupLogger.LogInformation("Database initialization completed on background attempt {Attempt}.", attempt);
break;
}
catch (Exception retryEx)
{
startupLogger.LogWarning(retryEx, "Background database initialization attempt {Attempt} failed.", attempt);
}
}
});
}
}
// requests ride out the wake-up via EF's EnableRetryOnFailure. The returned background task is
// deliberately not awaited: startup must not wait on it.
_ = await DatabaseStartup.InitializeAsync(
app.Services,
demoSeed,
app.Services.GetRequiredService<ILogger<Program>>(),
retryDelay: TimeSpan.FromSeconds(15),
maxRetryAttempts: 10);

app.UseExceptionHandler();

Expand Down
68 changes: 68 additions & 0 deletions tests/TodoApp.IntegrationTests/AuthenticationSetupTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
using FluentAssertions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using TodoApp.WebApi.Authentication;
using Xunit;

namespace TodoApp.IntegrationTests;

/// <summary>
/// The signing-key guard. A missing or too-short key must stop the app at startup rather than
/// let it run with authentication that cannot be trusted.
/// </summary>
public class AuthenticationSetupTests
{
private static IServiceCollection Register(string? key)
{
var settings = new Dictionary<string, string?> { ["Jwt:Issuer"] = "TodoApp" };
if (key is not null)
{
settings["Jwt:Key"] = key;
}

var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build();

return new ServiceCollection().AddJwtAuthentication(configuration);
}

[Fact]
public void NoJwtSectionAtAll_StopsStartup()
{
var configuration = new ConfigurationBuilder().Build();

var act = () => new ServiceCollection().AddJwtAuthentication(configuration);

act.Should().Throw<InvalidOperationException>().WithMessage("*at least 32 bytes*");
}

[Theory]
[InlineData(null)] // the section exists but carries no key
[InlineData("")] // present but empty
[InlineData(" ")] // whitespace
[InlineData("too-short")] // under 256 bits
[InlineData("31-bytes-is-still-one-too-few!!")]
public void AWeakOrMissingKey_StopsStartup(string? key)
{
var act = () => Register(key);

act.Should().Throw<InvalidOperationException>().WithMessage("*at least 32 bytes*");
}

[Fact]
public void AKeyOfExactlyTheMinimumLengthIsAccepted()
{
var key = new string('k', 32); // 32 ASCII characters == 32 bytes == 256 bits

var act = () => Register(key);

act.Should().NotThrow();
}

[Fact]
public void AValidKeyRegistersAuthentication()
{
var services = Register("a-perfectly-adequate-signing-key-of-sufficient-length");

services.Should().NotBeEmpty();
}
}
8 changes: 8 additions & 0 deletions tests/TodoApp.IntegrationTests/CustomWebApplicationFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,19 @@ public CustomWebApplicationFactory()
/// clear of what the suite generates (the whole run shares one client-IP partition), and demo
/// seeding is forced off so tests exercise the production default. Override to vary either.
/// </summary>
/// <remarks>
/// The breach check is off explicitly rather than by inheriting it from
/// appsettings.Development.json, so a factory that hosts a different environment does not
/// silently start calling Have I Been Pwned. That reaches the real service, which makes the
/// suite depend on the network and on whether a test password is in the corpus — "Password1"
/// is, so registration 400s on a machine with connectivity and passes on one without.
/// </remarks>
protected virtual IEnumerable<KeyValuePair<string, string?>> TestConfiguration =>
[
new("RateLimiting:Auth:PermitLimit", "10000"),
new("RateLimiting:Global:PermitLimit", "10000"),
new("Seed:DemoUser", "false"),
new("PasswordBreachCheck:Enabled", "false"),
];

protected override void ConfigureWebHost(IWebHostBuilder builder)
Expand Down
Loading