using DodoSSH.Domain; using Microsoft.EntityFrameworkCore; using Npgsql; namespace DodoSSH.Infrastructure.Tests; /// /// Verifies the database enforces the invariants the design depends on. /// /// /// 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. /// [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($""" 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(() => 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(() => 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(() => 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(() => 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(() => 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(() => 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(() => 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(() => context.SaveChangesAsync()); exception.InnerException.ShouldBeOfType() .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(() => 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(() => 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.NewGuid().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(() => context.SaveChangesAsync()); exception.InnerException.ShouldBeOfType() .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.NewGuid().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.NewGuid():N}"; var first = NewUser("https://idp.example", Guid.NewGuid().ToString()); first.Email = $"{local}@example.com"; context.Users.Add(first); await context.SaveChangesAsync(); var second = NewUser("https://idp.example", Guid.NewGuid().ToString()); second.Email = $"{local.ToUpperInvariant()}@EXAMPLE.COM"; context.Users.Add(second); var exception = await Should.ThrowAsync(() => context.SaveChangesAsync()); exception.InnerException.ShouldBeOfType() .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(() => context.SaveChangesAsync()); exception.InnerException.ShouldBeOfType() .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(() => 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(() => 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 SeedUserAsync(DodoDbContext context) { var user = NewUser("https://idp.example", Guid.NewGuid().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.NewGuid():N}", CreatedByUserId = createdBy, CreatedAtUtc = Now, }; context.Teams.Add(team); return team; } private static async Task 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.NewGuid().ToByteArray().Concat(Guid.NewGuid().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, }; }