Public Access
M3 built teams and stopped short of the two operations that decide who controls one. Both were written down as refusals rather than omissions: ADR 0009 listed ownership transfer under "deliberately not built", and design-import-gaps said an invitation needed "a token with a lifetime and an outbound mail path". One of those reasons had expired and the other never applied — an invitation does not need a token if it is not a thing anybody presents. Handing a team over is one write. The member you name becomes owner and you become an admin, in a single transaction, because ownership is sole: promoting first leaves the team owned twice, demoting first leaves it owned by nobody, and there is nobody left with the authority to finish a transfer that stopped in the middle. That is also why it is not two calls to the role endpoint, which refuses Owner outright. The outgoing owner is demoted rather than removed — removing them would revoke their vault key grants and flag every team vault for rekey, which is a far larger act than the one asked for, and somebody handing over a team is usually staying in it. It unblocks the thing that was impossible before: an owner can now leave, by handing the team on first. An invitation is a standing instruction rather than a message. This server has no outbound mail path, so nothing is sent and there is nothing for the invitee to present. The row says the next account signing in with that address joins this team at this role, and telling them to sign in is the caller's job over a channel this server does not carry. A link nobody can deliver would be worse than none. It lives in its own table rather than becoming a membership with MembershipStatus.Invited, and that member stays unwritten for the reason it always was: team_membership.user_id is not nullable and carries a foreign key, so somebody who has never signed in has nothing for that row to point at. Widening it would make the unique index on (team, user) meaningless, because PostgreSQL counts every NULL as distinct. Verification is the security boundary, and nothing in this server read it before. A claim requires the access token to assert email_verified. An invitation decides what the server will serve, so one claimable by anybody able to obtain a token carrying somebody else's address is a way into a team — which is precisely the attack OidcOptions.AllowEmailLinking exists to refuse, and it would have been reintroduced by the back door. There is deliberately no setting that relaxes it: a flag that exists is one somebody turns on for the afternoon their provider is misconfigured. Absence is refused rather than trusted, and logged, because a provider that never sends the claim otherwise leaves every invitation pending with nothing anywhere saying why. Claiming happens at just-in-time provisioning and again on an hourly sweep. The sweep is what makes it recoverable rather than one-shot — an invitation issued between an account being created and that person next signing in would otherwise be stranded for ever — and it shares its rate with the last-seen write because both are housekeeping nobody is waiting on. Archiving is refused while a team owns a vault, and that refusal is the end of the road rather than a step on it. A team vault is readable because of membership, so archiving one that still owned vaults would take them away from everybody holding a key, including the caller, quietly and all at once. Nothing in this product deletes a vault, so no order of operations gets past it today — which is stated with a count of what is in the way, for the reason the SFTP layer refuses a recursive delete: a refusal is visible and a quiet removal is not. It is owner-only, as handing over is; renaming is not, because a rename is visible to everybody and reversible by anybody who can do it. The slug is not renameable at all: it is unique only among live teams, so a rename could take one an archived team is still holding, and that team could then never be restored. LAST ACTIVE is real and coarse on purpose. UserAccount.LastSeenAtUtc is refreshed on ordinary authenticated requests, at most once per account per hour, through ExecuteUpdateAsync — user_account carries the xmin concurrency token, so a read-then-write on the hot path would start losing races between one user's own overlapping requests. An hour is the granularity the question is actually asked at, and the interface draws it to the day rather than the minute so it does not read as a precision that is not there. The remarks in Contracts and in the view model that argued at length for the column's absence are rewritten rather than extended; both had become false. Two endpoints already existed and nothing called them. ChangeTeamMemberRole and ListVaultGrants have been reachable since M3. The role picker refuses Owner itself rather than letting the server do it, since the interface already knew the rule; the key-holder list sits under the vault rather than beside the member, because a grant is per vault and a count on a member row would imply per-item sharing, which is M5. It lists withdrawn and stale grants and says which they are — a list that dropped them would show a departed colleague as merely absent rather than as somebody whose key was taken away — and staleness is decided by comparing generations, since a grant can be Active and still open nothing. ADD MEMBER stopped being a dead end. An address the directory did not know used to end at a sentence telling the user their colleague had to sign in first. It invites them instead, from the same button, because which of the two applies is a fact about the server's account table rather than about what the user is doing; which one happened is reported afterwards, because that decides what they do next. An address that merely has an account is invited rather than refused: refusing would have made the endpoint an oracle for which addresses have accounts here, answerable by anybody willing to create a team first. The phone has a TEAMS screen, behind MORE, and it is the reverse of every other row in design-import-gaps: a shipped screen the design had no slot for. It is there because an invitation is claimed by signing in, so somebody told they are now in a team is at least as likely to be holding a phone — and a membership visible only on a head they never installed is one they cannot see. It draws SHARE KEY and nothing that takes something away: wrapping a key is the one act on that screen a server cannot perform at all, and the desktop guards its revocations with a tooltip, which is a control a touch screen cannot show. Two defects were found by an adversarial pass and both were green against the whole suite at the time. The owner-only check on archiving and handing over had been weakened to the admin check while their messages and comments still said owner — and since nothing behind the archive endpoint re-checks it, an admin the owner had promoted could have archived the team out from under them. And the rename endpoint built its response with a hardcoded Owner role, so an admin who renamed a team was handed a summary claiming they owned it, and a client trusting that instead of re-listing would have offered them the two owner-only buttons the server then refuses. The new table gets its constraints tested rather than merely migrated: live uniqueness per (team, address), the citext proof that an address typed by a person matches one cased by a provider, and reissue after both revocation and acceptance. The teams screen gets its first entries in the layout suite, at the minimum window with every list populated and with each of the two states that cover half of it — it had none, and it just grew four sections and a second line in the member row. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
685 lines
25 KiB
C#
685 lines
25 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,
|
|
};
|
|
|
|
// ---- Team invitations ----
|
|
|
|
/// <remarks>
|
|
/// One live invitation per address per team. Without the index two admins acting a minute apart
|
|
/// would each leave a row, and the claim at sign-in would apply both — quietly overwriting whichever
|
|
/// role was decided second with whichever was written first.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task Invitation_IsUniquePerTeamAndAddress()
|
|
{
|
|
await using var context = fixture.CreateContext();
|
|
var user = await SeedUserAsync(context);
|
|
var team = SeedTeam(context, user.Id);
|
|
var email = $"invite{Guid.CreateVersion7():N}@example.com";
|
|
|
|
context.TeamInvitations.Add(NewInvitation(team.Id, email, user.Id));
|
|
await context.SaveChangesAsync();
|
|
|
|
context.TeamInvitations.Add(NewInvitation(team.Id, email, user.Id));
|
|
|
|
var exception = await Should.ThrowAsync<DbUpdateException>(() => context.SaveChangesAsync());
|
|
|
|
exception.InnerException.ShouldBeOfType<PostgresException>()
|
|
.SqlState.ShouldBe(PostgresErrorCodes.UniqueViolation);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The citext proof, and it is load-bearing rather than tidy: the address in an invitation is typed
|
|
/// by a person and the one on the token is chosen by the identity provider, so a column that
|
|
/// compared them case-sensitively would let <c>Alice@</c> and <c>alice@</c> be two invitations and
|
|
/// would make the claim at sign-in miss the one that was actually sent.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task Invitation_IsCaseInsensitivelyUniquePerTeam()
|
|
{
|
|
await using var context = fixture.CreateContext();
|
|
var user = await SeedUserAsync(context);
|
|
var team = SeedTeam(context, user.Id);
|
|
var email = $"Invite{Guid.CreateVersion7():N}@Example.com";
|
|
|
|
context.TeamInvitations.Add(NewInvitation(team.Id, email.ToUpperInvariant(), user.Id));
|
|
await context.SaveChangesAsync();
|
|
|
|
context.TeamInvitations.Add(NewInvitation(team.Id, email.ToLowerInvariant(), user.Id));
|
|
|
|
var exception = await Should.ThrowAsync<DbUpdateException>(() => context.SaveChangesAsync());
|
|
|
|
exception.InnerException.ShouldBeOfType<PostgresException>()
|
|
.SqlState.ShouldBe(PostgresErrorCodes.UniqueViolation);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The other half of the filter. Withdrawing an invitation and issuing a fresh one — at a different
|
|
/// role, say — has to be possible, so the uniqueness is among live rows rather than all of them, and
|
|
/// the withdrawn row stays for the history.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task Invitation_MayBeReissuedAfterItIsRevoked()
|
|
{
|
|
await using var context = fixture.CreateContext();
|
|
var user = await SeedUserAsync(context);
|
|
var team = SeedTeam(context, user.Id);
|
|
var email = $"invite{Guid.CreateVersion7():N}@example.com";
|
|
|
|
var first = NewInvitation(team.Id, email, user.Id);
|
|
context.TeamInvitations.Add(first);
|
|
await context.SaveChangesAsync();
|
|
|
|
first.RevokedAtUtc = Now;
|
|
await context.SaveChangesAsync();
|
|
|
|
context.TeamInvitations.Add(NewInvitation(team.Id, email, user.Id));
|
|
|
|
await Should.NotThrowAsync(() => context.SaveChangesAsync());
|
|
}
|
|
|
|
/// <remarks>
|
|
/// An accepted invitation frees the slot too, which is what lets somebody removed from a team be
|
|
/// invited back. The claim marks the old row accepted rather than deleting it, so without this the
|
|
/// second invitation would collide with a row that has already done its job.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task Invitation_MayBeReissuedAfterItIsAccepted()
|
|
{
|
|
await using var context = fixture.CreateContext();
|
|
var user = await SeedUserAsync(context);
|
|
var team = SeedTeam(context, user.Id);
|
|
var email = $"invite{Guid.CreateVersion7():N}@example.com";
|
|
|
|
var first = NewInvitation(team.Id, email, user.Id);
|
|
context.TeamInvitations.Add(first);
|
|
await context.SaveChangesAsync();
|
|
|
|
first.AcceptedAtUtc = Now;
|
|
first.AcceptedByUserId = user.Id;
|
|
await context.SaveChangesAsync();
|
|
|
|
context.TeamInvitations.Add(NewInvitation(team.Id, email, user.Id));
|
|
|
|
await Should.NotThrowAsync(() => context.SaveChangesAsync());
|
|
}
|
|
|
|
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,
|
|
};
|
|
|
|
private static TeamInvitation NewInvitation(Guid teamId, string email, Guid invitedBy) => new()
|
|
{
|
|
Id = Guid.CreateVersion7(),
|
|
TeamId = teamId,
|
|
Email = email,
|
|
Role = TeamRole.Member,
|
|
InvitedByUserId = invitedBy,
|
|
CreatedAtUtc = Now,
|
|
ExpiresAtUtc = Now.AddDays(14),
|
|
};
|
|
}
|