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.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(() => 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.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(() => 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.VaultChanges.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.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 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 SshHost 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 VaultChange NewChange(Guid vaultId) => new()
{
VaultId = vaultId,
EntityType = ChangeEntityType.SshHost,
EntityId = Guid.CreateVersion7(),
Operation = ChangeOperation.Upsert,
Revision = 1,
ActorUserId = Guid.CreateVersion7(),
OccurredAtUtc = Now,
};
}