Files
DodoSSH/tests/DodoSSH.Infrastructure.Tests/SchemaConstraintTests.cs
T
jaap-jan d3b14e6bc0 Add configuration, OIDC auth wiring and discovery endpoints (M1)
Options, JWT bearer validation, the /meta and .well-known endpoints, and a dev compose
stack with Keycloak. Verified end to end: compose up, migrate, run, both discovery
endpoints return correct payloads, and readiness reports the schema current.

Configuration:
- Strongly-typed options for Server, Oidc, Relay and Sync, all ValidateOnStart. A
  self-hosted server that boots half-configured and fails later per-request is far harder
  to diagnose than one that refuses to start and names the bad setting.
- Cross-field validation the annotations cannot express: relay needs a WebSocketUrl when
  enabled, idle timeout must be under max session duration, item payload cap under batch cap.
- Startup warnings for combinations that are individually valid but dangerous together:
  RequireHttpsMetadata false outside Development, and AllowEmailLinking (which turns any
  token bearing a victim's email into account takeover, hence default false).

Auth:
- JwtBearer with ClockSkew cut to 30s from the 5-minute default; five minutes of slack on a
  credential granting vault ciphertext access is more than any clock needs.
- IncludeErrorDetails off, and a FallbackPolicy so an endpoint without an explicit policy
  still requires a caller rather than silently being public.

Discovery, per ADR 0002:
- /api/v1/meta reports versions, features and push caps.
- /.well-known/dodossh-configuration is the onboarding story: the user types one server URL
  and the client discovers OIDC authority, client id, scopes and relay endpoint.

Two environment problems found by actually running the stack:
- PostgreSQL 18 changed its data mount point. Mounting /var/lib/postgresql/data — correct
  through 17 — makes the image refuse to start; 18+ wants a single mount at
  /var/lib/postgresql with the cluster in a subdirectory.
- Keycloak moved to host port 18080. An unrelated Apache Tomcat on this machine holds
  127.0.0.1:8080, and a loopback-specific bind beats Docker's 0.0.0.0 publish for
  "localhost". It presents as Keycloak 404ing every realm while its own log says the import
  succeeded, which is a genuinely misleading failure.

Also: CA1848 is enforced, not advisory — warnings are errors, so the .editorconfig comment
claiming otherwise was wrong. Startup and health logging now uses [LoggerMessage]. And a
clean rebuild is back to zero warnings; the incremental build had been hiding 40 in test
projects (banned Guid.NewGuid, an obsolete Testcontainers constructor, and two analyzer
families that are genuinely noise under a test host).

Verified: 0 warnings on a clean rebuild, 122 tests pass, format clean.
2026-07-28 14:33:54 +02:00

572 lines
20 KiB
C#

using DodoSSH.Domain;
using Microsoft.EntityFrameworkCore;
using Npgsql;
namespace DodoSSH.Infrastructure.Tests;
/// <summary>
/// Verifies the database enforces the invariants the design depends on.
/// </summary>
/// <remarks>
/// These are not tests of EF. They assert that the constraints hold even if application code has
/// a bug, which is the whole reason for putting them in the schema. The relay-target constraint in
/// particular is a security boundary: see ADR 0004.
/// </remarks>
[Collection(PostgresCollection.Name)]
public sealed class SchemaConstraintTests(PostgresFixture fixture)
{
private static readonly DateTimeOffset Now = new(2026, 7, 28, 12, 0, 0, TimeSpan.Zero);
[Fact]
public async Task Migration_AppliedCleanly_WithNoPendingModelChanges()
{
await using var context = fixture.CreateContext();
var applied = await context.Database.GetAppliedMigrationsAsync();
applied.ShouldNotBeEmpty();
(await context.Database.GetPendingMigrationsAsync()).ShouldBeEmpty();
}
[Fact]
public async Task Xmin_IsASystemColumnAndNotDeclaredInTheTable()
{
// Npgsql maps xmin as a concurrency token without emitting a column. A negative attnum
// means PostgreSQL's own system column; a positive one would mean we had created a real
// column, which PostgreSQL would in fact have rejected.
await using var context = fixture.CreateContext();
var attnum = await context.Database
.SqlQuery<short>($"""
SELECT a.attnum AS "Value"
FROM pg_attribute a
JOIN pg_class c ON c.oid = a.attrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'dodo' AND c.relname = 'vault' AND a.attname = 'xmin'
""")
.SingleAsync();
attnum.ShouldBeLessThan((short)0);
}
// ---- ADR 0004: the relay must never learn an address it was not granted ----
[Fact]
public async Task Host_WithRelayEnabled_RequiresHostnameAndPort()
{
await using var context = fixture.CreateContext();
var vault = await SeedVaultAsync(context);
context.Hosts.Add(NewHost(vault, relayEnabled: true, hostname: null, port: null));
var exception = await Should.ThrowAsync<DbUpdateException>(() => context.SaveChangesAsync());
ConstraintName(exception).ShouldBe("ck_host_relay_target");
}
[Fact]
public async Task Host_WithRelayDisabled_MustNotCarryAnAddress()
{
// The important direction. If application code could store a plaintext address without
// the user opting into relay, the server would silently learn infrastructure it was never
// granted visibility of.
await using var context = fixture.CreateContext();
var vault = await SeedVaultAsync(context);
context.Hosts.Add(NewHost(vault, relayEnabled: false, hostname: "secret.internal", port: 22));
var exception = await Should.ThrowAsync<DbUpdateException>(() => context.SaveChangesAsync());
ConstraintName(exception).ShouldBe("ck_host_relay_target");
}
[Fact]
public async Task Host_WithRelayEnabledAndAnAddress_IsAccepted()
{
await using var context = fixture.CreateContext();
var vault = await SeedVaultAsync(context);
context.Hosts.Add(NewHost(vault, relayEnabled: true, hostname: "bastion.internal", port: 22));
await context.SaveChangesAsync();
}
[Fact]
public async Task Host_WithRelayDisabledAndNoAddress_IsAccepted()
{
await using var context = fixture.CreateContext();
var vault = await SeedVaultAsync(context);
context.Hosts.Add(NewHost(vault, relayEnabled: false, hostname: null, port: null));
await context.SaveChangesAsync();
}
[Theory]
[InlineData(0)]
[InlineData(65536)]
[InlineData(-1)]
public async Task Host_RejectsAnOutOfRangePort(int port)
{
await using var context = fixture.CreateContext();
var vault = await SeedVaultAsync(context);
context.Hosts.Add(NewHost(vault, relayEnabled: true, hostname: "h.internal", port: port));
var exception = await Should.ThrowAsync<DbUpdateException>(() => context.SaveChangesAsync());
ConstraintName(exception).ShouldBe("ck_host_port_range");
}
// ---- Vault ownership ----
[Fact]
public async Task Vault_MustHaveExactlyOneOwner()
{
await using var context = fixture.CreateContext();
var user = await SeedUserAsync(context);
var team = SeedTeam(context, user.Id);
await context.SaveChangesAsync();
// Both owners set: permission resolution would have no defined answer.
context.Vaults.Add(new Vault
{
Id = Guid.CreateVersion7(),
Name = "ambiguous",
OwnerKind = VaultOwnerKind.Personal,
OwnerUserId = user.Id,
TeamId = team.Id,
CreatedAtUtc = Now,
UpdatedAtUtc = Now,
});
var exception = await Should.ThrowAsync<DbUpdateException>(() => context.SaveChangesAsync());
ConstraintName(exception).ShouldBe("ck_vault_owner");
}
[Fact]
public async Task Vault_PersonalWithoutAnOwnerUser_IsRejected()
{
await using var context = fixture.CreateContext();
context.Vaults.Add(new Vault
{
Id = Guid.CreateVersion7(),
Name = "orphan",
OwnerKind = VaultOwnerKind.Personal,
CreatedAtUtc = Now,
UpdatedAtUtc = Now,
});
var exception = await Should.ThrowAsync<DbUpdateException>(() => context.SaveChangesAsync());
ConstraintName(exception).ShouldBe("ck_vault_owner");
}
// ---- Grants ----
[Fact]
public async Task MemberGrant_RequiresARecipient()
{
await using var context = fixture.CreateContext();
var vault = await SeedVaultAsync(context);
context.VaultKeyGrants.Add(NewGrant(vault, GrantKind.Member, recipientUserId: null));
var exception = await Should.ThrowAsync<DbUpdateException>(() => context.SaveChangesAsync());
ConstraintName(exception).ShouldBe("ck_vault_key_grant_recipient");
}
[Fact]
public async Task RecoveryGrant_MustNotNameARecipient()
{
// Recovery and escrow grants are wrapped to a key, not to a user. Allowing a recipient
// would make it ambiguous whether revoking that user revokes recovery.
await using var context = fixture.CreateContext();
var vault = await SeedVaultAsync(context);
var user = await context.Users.FirstAsync(u => u.Id == vault.OwnerUserId);
context.VaultKeyGrants.Add(NewGrant(vault, GrantKind.Recovery, recipientUserId: user.Id));
var exception = await Should.ThrowAsync<DbUpdateException>(() => context.SaveChangesAsync());
ConstraintName(exception).ShouldBe("ck_vault_key_grant_recipient");
}
[Fact]
public async Task RecoveryGrant_IsAcceptedWithoutARecipient()
{
// Recovery must be expressible in the very first schema, or every vault created before it
// existed would be permanently unrecoverable.
await using var context = fixture.CreateContext();
var vault = await SeedVaultAsync(context);
context.VaultKeyGrants.Add(NewGrant(vault, GrantKind.Recovery, recipientUserId: null));
await context.SaveChangesAsync();
}
[Fact]
public async Task MemberGrant_IsUniquePerRecipientPerGeneration()
{
await using var context = fixture.CreateContext();
var vault = await SeedVaultAsync(context);
var userId = vault.OwnerUserId!.Value;
context.VaultKeyGrants.Add(NewGrant(vault, GrantKind.Member, userId));
await context.SaveChangesAsync();
context.VaultKeyGrants.Add(NewGrant(vault, GrantKind.Member, userId));
var exception = await Should.ThrowAsync<DbUpdateException>(() => context.SaveChangesAsync());
exception.InnerException.ShouldBeOfType<PostgresException>()
.SqlState.ShouldBe(PostgresErrorCodes.UniqueViolation);
}
[Fact]
public async Task MemberGrant_MayBeReissuedAfterRevocation()
{
// The uniqueness filter is on revocation, not deletion: revoked grants are retained so
// audit history stays intact, and a rewrap must still be possible.
await using var context = fixture.CreateContext();
var vault = await SeedVaultAsync(context);
var userId = vault.OwnerUserId!.Value;
var first = NewGrant(vault, GrantKind.Member, userId);
context.VaultKeyGrants.Add(first);
await context.SaveChangesAsync();
first.RevokedAtUtc = Now;
first.State = GrantState.Revoked;
await context.SaveChangesAsync();
context.VaultKeyGrants.Add(NewGrant(vault, GrantKind.Member, userId));
await context.SaveChangesAsync();
}
// ---- Key wraps ----
[Fact]
public async Task PassphraseWrap_RequiresKdfParameters()
{
// A password-derived wrap without its parameters is permanently unopenable.
await using var context = fixture.CreateContext();
var user = await SeedUserAsync(context);
context.UserKeyWraps.Add(new UserKeyWrap
{
Id = Guid.CreateVersion7(),
UserId = user.Id,
Kind = UserKeyWrapKind.Passphrase,
Wrap = [1, 2, 3],
WrapVersion = 1,
CreatedAtUtc = Now,
});
var exception = await Should.ThrowAsync<DbUpdateException>(() => context.SaveChangesAsync());
ConstraintName(exception).ShouldBe("ck_user_key_wrap_kdf");
}
[Fact]
public async Task DeviceWrap_RequiresADevice()
{
await using var context = fixture.CreateContext();
var user = await SeedUserAsync(context);
context.UserKeyWraps.Add(new UserKeyWrap
{
Id = Guid.CreateVersion7(),
UserId = user.Id,
Kind = UserKeyWrapKind.Device,
Wrap = [1, 2, 3],
WrapVersion = 1,
CreatedAtUtc = Now,
});
var exception = await Should.ThrowAsync<DbUpdateException>(() => context.SaveChangesAsync());
ConstraintName(exception).ShouldBe("ck_user_key_wrap_device");
}
[Fact]
public async Task PassphraseWrap_WithParameters_IsAccepted()
{
await using var context = fixture.CreateContext();
var user = await SeedUserAsync(context);
context.UserKeyWraps.Add(new UserKeyWrap
{
Id = Guid.CreateVersion7(),
UserId = user.Id,
Kind = UserKeyWrapKind.Passphrase,
Wrap = [1, 2, 3],
WrapVersion = 1,
KdfAlgorithm = "argon2id",
KdfSalt = new byte[16],
KdfMemoryKibibytes = 262144,
KdfPasses = 4,
KdfParallelism = 1,
CreatedAtUtc = Now,
});
await context.SaveChangesAsync();
}
// ---- Identity uniqueness ----
[Fact]
public async Task User_IsUniquePerIssuerAndSubject()
{
await using var context = fixture.CreateContext();
var subject = Guid.CreateVersion7().ToString();
context.Users.Add(NewUser("https://idp.example", subject));
await context.SaveChangesAsync();
context.Users.Add(NewUser("https://idp.example", subject));
var exception = await Should.ThrowAsync<DbUpdateException>(() => context.SaveChangesAsync());
exception.InnerException.ShouldBeOfType<PostgresException>()
.SqlState.ShouldBe(PostgresErrorCodes.UniqueViolation);
}
[Fact]
public async Task User_MayShareASubjectAcrossDifferentIssuers()
{
// Multi-issuer from the start: the issuer is part of the identity, not a detail.
await using var context = fixture.CreateContext();
var subject = Guid.CreateVersion7().ToString();
context.Users.Add(NewUser("https://idp-a.example", subject));
context.Users.Add(NewUser("https://idp-b.example", subject));
await context.SaveChangesAsync();
}
[Fact]
public async Task Email_IsCaseInsensitivelyUnique()
{
// citext: an attacker must not be able to register Alice@x with alice@x already present.
await using var context = fixture.CreateContext();
var local = $"user{Guid.CreateVersion7():N}";
var first = NewUser("https://idp.example", Guid.CreateVersion7().ToString());
first.Email = $"{local}@example.com";
context.Users.Add(first);
await context.SaveChangesAsync();
var second = NewUser("https://idp.example", Guid.CreateVersion7().ToString());
second.Email = $"{local.ToUpperInvariant()}@EXAMPLE.COM";
context.Users.Add(second);
var exception = await Should.ThrowAsync<DbUpdateException>(() => context.SaveChangesAsync());
exception.InnerException.ShouldBeOfType<PostgresException>()
.SqlState.ShouldBe(PostgresErrorCodes.UniqueViolation);
}
[Fact]
public async Task UserKey_AllowsOnlyOneCurrentGenerationPerUser()
{
// Two current keys would make it ambiguous which one to wrap a vault key to.
await using var context = fixture.CreateContext();
var user = await SeedUserAsync(context);
context.UserKeys.Add(NewUserKey(user.Id, generation: 1, isCurrent: true));
await context.SaveChangesAsync();
context.UserKeys.Add(NewUserKey(user.Id, generation: 2, isCurrent: true));
var exception = await Should.ThrowAsync<DbUpdateException>(() => context.SaveChangesAsync());
exception.InnerException.ShouldBeOfType<PostgresException>()
.SqlState.ShouldBe(PostgresErrorCodes.UniqueViolation);
}
[Fact]
public async Task UserKey_AllowsManySupersededGenerations()
{
await using var context = fixture.CreateContext();
var user = await SeedUserAsync(context);
context.UserKeys.Add(NewUserKey(user.Id, generation: 1, isCurrent: false));
context.UserKeys.Add(NewUserKey(user.Id, generation: 2, isCurrent: false));
context.UserKeys.Add(NewUserKey(user.Id, generation: 3, isCurrent: true));
await context.SaveChangesAsync();
}
// ---- Sync log ----
[Fact]
public async Task SyncChange_SequenceIsDatabaseAssignedAndMonotonic()
{
await using var context = fixture.CreateContext();
var vault = await SeedVaultAsync(context);
var first = NewChange(vault.Id);
var second = NewChange(vault.Id);
context.SyncChanges.AddRange(first, second);
await context.SaveChangesAsync();
first.Sequence.ShouldBeGreaterThan(0);
second.Sequence.ShouldBeGreaterThan(first.Sequence);
}
[Fact]
public async Task SyncChange_RejectsACallerSuppliedSequence()
{
// Identity ALWAYS. If anything could choose its own sequence, cursor ordering would stop
// meaning anything and delta sync would silently skip changes.
await using var context = fixture.CreateContext();
var vault = await SeedVaultAsync(context);
await using var connection = new NpgsqlConnection(fixture.ConnectionString);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = """
INSERT INTO dodo.sync_change
(sequence, vault_id, entity_type, entity_id, operation, revision, actor_user_id, occurred_at_utc)
VALUES (999999, @vault, 1, @entity, 1, 1, @actor, now())
""";
command.Parameters.AddWithValue("vault", vault.Id);
command.Parameters.AddWithValue("entity", Guid.CreateVersion7());
command.Parameters.AddWithValue("actor", vault.OwnerUserId!.Value);
var exception = await Should.ThrowAsync<PostgresException>(() => command.ExecuteNonQueryAsync());
// 428C9 is generated_always: PostgreSQL refuses a value for a GENERATED ALWAYS column.
exception.SqlState.ShouldBe("428C9");
}
// ---- Concurrency ----
[Fact]
public async Task Xmin_DetectsAConcurrentUpdate()
{
// Two clients racing a rekey must not silently overwrite one another.
await using var writer = fixture.CreateContext();
var vault = await SeedVaultAsync(writer);
await using var contextA = fixture.CreateContext();
await using var contextB = fixture.CreateContext();
var asA = await contextA.Vaults.SingleAsync(v => v.Id == vault.Id);
var asB = await contextB.Vaults.SingleAsync(v => v.Id == vault.Id);
asA.KeyGeneration = 2;
await contextA.SaveChangesAsync();
asB.KeyGeneration = 3;
await Should.ThrowAsync<DbUpdateConcurrencyException>(() => contextB.SaveChangesAsync());
}
// ---- Helpers ----
private static string? ConstraintName(DbUpdateException exception) =>
(exception.InnerException as PostgresException)?.ConstraintName;
private static UserAccount NewUser(string issuer, string subject) => new()
{
Id = Guid.CreateVersion7(),
Issuer = issuer,
Subject = subject,
Status = UserStatus.Active,
CreatedAtUtc = Now,
UpdatedAtUtc = Now,
};
private static async Task<UserAccount> SeedUserAsync(DodoDbContext context)
{
var user = NewUser("https://idp.example", Guid.CreateVersion7().ToString());
context.Users.Add(user);
await context.SaveChangesAsync();
return user;
}
private static Team SeedTeam(DodoDbContext context, Guid createdBy)
{
var team = new Team
{
Id = Guid.CreateVersion7(),
Name = "Team",
Slug = $"team-{Guid.CreateVersion7():N}",
CreatedByUserId = createdBy,
CreatedAtUtc = Now,
};
context.Teams.Add(team);
return team;
}
private static async Task<Vault> SeedVaultAsync(DodoDbContext context)
{
var user = await SeedUserAsync(context);
var vault = new Vault
{
Id = Guid.CreateVersion7(),
Name = "Personal",
OwnerKind = VaultOwnerKind.Personal,
OwnerUserId = user.Id,
KeyGeneration = 1,
CreatedAtUtc = Now,
UpdatedAtUtc = Now,
};
context.Vaults.Add(vault);
await context.SaveChangesAsync();
return vault;
}
private static Host NewHost(Vault vault, bool relayEnabled, string? hostname, int? port) => new()
{
Id = Guid.CreateVersion7(),
VaultId = vault.Id,
Payload = [1, 2, 3, 4],
KeyGeneration = vault.KeyGeneration,
PayloadAadVersion = 1,
RelayEnabled = relayEnabled,
Hostname = hostname,
Port = port,
Version = 1,
CreatedAtUtc = Now,
UpdatedAtUtc = Now,
CreatedByUserId = vault.OwnerUserId!.Value,
UpdatedByUserId = vault.OwnerUserId!.Value,
};
private static VaultKeyGrant NewGrant(Vault vault, GrantKind kind, Guid? recipientUserId) => new()
{
Id = Guid.CreateVersion7(),
VaultId = vault.Id,
KeyGeneration = vault.KeyGeneration,
Kind = kind,
RecipientUserId = recipientUserId,
RecipientKeyFingerprint = new byte[32],
WrappedKey = [1, 2, 3],
GranterUserId = vault.OwnerUserId!.Value,
GranterKeyFingerprint = new byte[32],
Signature = new byte[64],
State = GrantState.Active,
CreatedAtUtc = Now,
};
private static UserKey NewUserKey(Guid userId, int generation, bool isCurrent) => new()
{
Id = Guid.CreateVersion7(),
UserId = userId,
Generation = generation,
EncryptionPublicKey = new byte[32],
SigningPublicKey = new byte[32],
FingerprintSha256 = Guid.CreateVersion7().ToByteArray().Concat(Guid.CreateVersion7().ToByteArray()).ToArray(),
Statement = "{}",
StatementSignature = new byte[64],
IsCurrent = isCurrent,
CreatedAtUtc = Now,
};
private static SyncChange NewChange(Guid vaultId) => new()
{
VaultId = vaultId,
EntityType = ChangeEntityType.Host,
EntityId = Guid.CreateVersion7(),
Operation = ChangeOperation.Upsert,
Revision = 1,
ActorUserId = Guid.CreateVersion7(),
OccurredAtUtc = Now,
};
}