Restructure into src/tests and add build foundation (M0)

Moves the scaffold to src/DodoSSH.Api and establishes the repo conventions the rest
of the milestones build on.

Structure:
- src/{Contracts,Crypto,Domain,Infrastructure,Api}, tests/{Contracts,Crypto,Domain}.Tests
- DodoSSH.slnx rewritten with src/ and tests/ solution folders

Build:
- Directory.Build.props centralises TFM, nullable, deterministic builds and
  TreatWarningsAsErrors; Directory.Packages.props pins every version centrally
- packages.lock.json committed so CI restores in locked mode
- NuGet.config clears machine-level sources, which both fixes NU1507 under central
  package management and makes restore reproducible off this machine
- Microsoft.OpenApi pinned to 2.11.0: ASP.NET Core 10.0.10 resolves 2.0.0, which is
  covered by GHSA-v5pm-xwqc-g5wc (high, patched in 2.7.5)

Analyzers:
- AnalysisLevel is Recommended, not All. With warnings-as-errors, All turns opinionated
  naming rules into build breaks and trains people to blanket-suppress.
- BannedSymbols.txt bans DateTime.UtcNow (TimeProvider), Guid.NewGuid (CreateVersion7),
  sync-over-async, MD5/SHA1, PBKDF2 and SecureString
- CA1711/CA1724 disabled: both are .NET Framework CAS-era naming rules
- PublicApiAnalyzers on Contracts only, since that assembly is the client's real contract

API:
- weather-forecast template removed
- UseHttpsRedirection removed; TLS terminates at the reverse proxy and redirecting
  behind one causes loops
- /healthz/{live,ready,startup}. Liveness deliberately checks no dependencies so a
  transient database outage cannot restart the container and kill live SSH sessions.

Notes:
- No coverage collector yet. Microsoft.Testing.Extensions.CodeCoverage pulls an MTP 1.x
  MSBuild extension that throws TypeLoadException against the MTP 2.3.x xunit.v3 brings.
  Coverage gates are an M3 concern; revisit with an MTP 2.x-aligned version then.

Verified: dotnet build (0 warnings), 17 tests pass, format check clean, API serves
health and OpenAPI endpoints.
This commit is contained in:
2026-07-28 12:25:34 +02:00
parent 1138291d79
commit 3a81f3c90b
43 changed files with 1714 additions and 62 deletions
+17
View File
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<!--
TargetFramework, Nullable, ImplicitUsings, analyzers and central package
management all come from ../../Directory.Build.props.
-->
<ItemGroup>
<!-- Integration tests reach Program through WebApplicationFactory<Program>. -->
<InternalsVisibleTo Include="DodoSSH.Api.Tests" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
</ItemGroup>
</Project>
+26
View File
@@ -0,0 +1,26 @@
using DodoSSH.Api.Setup;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDodoOpenApi();
builder.Services.AddDodoHealthChecks();
// DateTime.UtcNow is banned repo-wide (see BannedSymbols.txt); everything takes
// TimeProvider so time can be faked in tests.
builder.Services.AddSingleton(TimeProvider.System);
var app = builder.Build();
// Deliberately no UseHttpsRedirection: the API is always fronted by a reverse proxy
// (Caddy in the reference compose stack) which terminates TLS. Redirecting here
// produces redirect loops behind a proxy. HTTPS in development comes from the
// launch profile instead.
app.MapDodoHealthChecks();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
await app.RunAsync().ConfigureAwait(false);
@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5233",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7217;http://localhost:5233",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
+57
View File
@@ -0,0 +1,57 @@
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.Extensions.Diagnostics.HealthChecks;
namespace DodoSSH.Api.Setup;
/// <summary>
/// Health check wiring.
/// </summary>
/// <remarks>
/// The split between liveness and readiness is deliberate and load-bearing for the
/// relay: <c>/healthz/live</c> checks the process and nothing else, so a transient
/// PostgreSQL outage cannot cause the orchestrator to restart the container and
/// guillotine every live SSH session. Dependency checks belong in
/// <c>/healthz/ready</c>, which only removes the instance from load balancing.
/// </remarks>
internal static class HealthChecks
{
/// <summary>Tag for checks that gate readiness (dependencies).</summary>
internal const string ReadyTag = "ready";
/// <summary>Tag for checks that gate startup completion.</summary>
internal const string StartupTag = "startup";
internal static IServiceCollection AddDodoHealthChecks(this IServiceCollection services)
{
services.AddHealthChecks();
// Dependency checks are registered by the milestone that introduces the
// dependency, each tagged ReadyTag:
// M1 — PostgreSQL, OIDC discovery + JWKS reachability, pending migrations
// M4 — Data Protection key ring readability
return services;
}
internal static WebApplication MapDodoHealthChecks(this WebApplication app)
{
// Liveness: process is running and the pipeline responds. No dependencies.
app.MapHealthChecks("/healthz/live", new HealthCheckOptions
{
Predicate = _ => false,
}).AllowAnonymous();
// Readiness: safe to route traffic here.
app.MapHealthChecks("/healthz/ready", new HealthCheckOptions
{
Predicate = registration => registration.Tags.Contains(ReadyTag),
}).AllowAnonymous();
// Startup: one-time initialisation finished (K8s startupProbe).
app.MapHealthChecks("/healthz/startup", new HealthCheckOptions
{
Predicate = registration => registration.Tags.Contains(StartupTag),
}).AllowAnonymous();
return app;
}
}
+27
View File
@@ -0,0 +1,27 @@
namespace DodoSSH.Api.Setup;
/// <summary>
/// OpenAPI document configuration.
/// </summary>
/// <remarks>
/// The generated document exists for third parties and a future CLI. It is emitted at
/// build time to <c>artifacts/openapi/v1.json</c> and diffed in CI so an unintended
/// contract change fails the pull request. The desktop client's actual contract is the
/// <c>DodoSSH.Contracts</c> assembly, guarded by PublicApiAnalyzers.
/// </remarks>
internal static class OpenApi
{
internal const string DocumentName = "v1";
internal static IServiceCollection AddDodoOpenApi(this IServiceCollection services)
{
services.AddOpenApi(DocumentName);
// Added in M1, once there are endpoints to describe:
// - a document transformer contributing the OAuth2 authorizationCode + PKCE
// security scheme, so the document is usable from a generated client
// - a schema transformer mapping byte[] to {type: string, format: byte},
// since every ciphertext field crosses the wire as base64
return services;
}
}
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
+34
View File
@@ -0,0 +1,34 @@
{
"version": 2,
"dependencies": {
"net10.0": {
"Meziantou.Analyzer": {
"type": "Direct",
"requested": "[3.0.134, )",
"resolved": "3.0.134",
"contentHash": "tTYCcYKyOko3TMNxmxmA9nakbcHVUgglENmCMIhzIjl9y9FBZO/0tWSxTGC74Sp198FmWih5S5KkjQRBg5ePkQ=="
},
"Microsoft.AspNetCore.OpenApi": {
"type": "Direct",
"requested": "[10.0.10, )",
"resolved": "10.0.10",
"contentHash": "d4Atx9IHq7JgX0F/h7Db+m9zAUzC+cKdI9k+OWnnyQIOUQtfvjIEuhvbjPigVMkAmPUgCbJ8Yp6M9ghUqHtJSQ==",
"dependencies": {
"Microsoft.OpenApi": "2.0.0"
}
},
"Microsoft.CodeAnalysis.BannedApiAnalyzers": {
"type": "Direct",
"requested": "[5.6.0, )",
"resolved": "5.6.0",
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
},
"Microsoft.OpenApi": {
"type": "CentralTransitive",
"requested": "[2.11.0, )",
"resolved": "2.11.0",
"contentHash": "/ignjfdeKT2SGLIR7QEv19KnI0rvoxRG/TYDOZdK9EsWLjKK9IK8i1Mo5NRm9PRV3i64DzlTqnIflWvoyfljLg=="
}
}
}
}