Files
DodoSSH/tests/DodoSSH.Infrastructure.Tests/SchemaConstraintTests.cs
T
jaap-jan 69bc9e270b Let a team be joined only by somebody who is already here
An invitation decided access from an assertion about an address. Everything else
in this model decides it from something a person did — an admin naming an
account, a key holder wrapping a vault key to a key they verified — and this was
the one place a token's email claim was the thing that let somebody in.

It was guarded as tightly as that can be guarded: the claim was refused outright
on an unverified or absent `email_verified`, with no setting to relax it. But the
guard and the risk were the same shape. The whole defence was one boolean sent by
a system the deployment does not control.

So `POST /teams/{id}/members` is the only way in, and an address with no account
is refused with `no-such-account` — which is now the end of the road rather than
the signal to invite. Both clients say the remedy: that person signs in here
once, which is what creates the account, and then they can be added. The desktop
leaves the address in the box, because a message telling you to come back later
is one you act on later.

Gone with it: the `team_invitation` table, the claim hook in the sign-in path,
and `Oidc:EmailVerifiedClaim`, which that hook was the only reader of. Nothing in
the server now reads the email claim to decide anything.

Pending invitations are dropped rather than converted. Converting one would mean
creating a membership because an address matched, which is the property being
removed — and an invitation to an address that did have an account here had
already been claimed by the hourly sweep, so what is left is offers to people who
never arrived.

Two tests carry the property rather than the feature: the endpoint inventory
asserts the three routes are absent, and the API suite adds an address that has
no account, watches the refusal, then signs that address in and checks it joined
nothing. Without the second half, a server that merely renamed the deferred path
would pass.
2026-08-05 08:28:57 +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.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<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 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,
};
}