Public Access
Add data model, DbContext and initial migration (M1)
Schema for identity, vaults, grants, hosts and the sync change log, verified against a real PostgreSQL 18 container rather than an in-memory provider: partial unique indexes, CHECK constraints, citext and identity-always columns are all provider behaviour that an in-memory fake would not exercise. Invariants pushed into the database, so they hold even when application code has a bug: - ck_host_relay_target is a security boundary, not tidiness. A host may carry a plaintext hostname and port ONLY when relay is deliberately enabled. Both directions are tested; the important one is that relay-disabled hosts cannot carry an address, since otherwise a bug would silently give the server infrastructure visibility it was never granted. - ck_vault_owner: exactly one of owner_user_id or team_id, or permission resolution would have no defined answer. - ck_vault_key_grant_recipient: member grants name a user; recovery and escrow grants are wrapped to a key and must not. - ck_user_key_wrap_kdf: a password-derived wrap without its parameters is permanently unopenable, so a partial write is rejected outright. Present from the first migration on purpose: - GrantKind (Member/Recovery/Escrow). Recovery cannot be bolted on later — every vault created before it existed would be unrecoverable by design. - team and team_membership, though team features are M3. Adding them later would mean introducing a foreign key on a live vault table. - Host.ContentKeyId, reserved for per-item content keys wrapped to individual users. - user_key as its own table, so key rotation does not require altering the user row. Two things verified rather than assumed: - Npgsql's UseXminAsConcurrencyToken helper no longer exists in EF 10, so xmin is mapped directly in XminConcurrency. The generated migration *looks* like it creates an xmin column; it does not. Confirmed by inspecting pg_attribute (attnum -2, a system column) and by grepping the emitted DDL. A test pins both, because had it created a real column PostgreSQL would have rejected the name. - EF Core is now pinned centrally. The Npgsql provider asks for 10.0.4 while EntityFrameworkCore.Design pulls 10.0.10, and because Design is PrivateAssets=all that higher version does not flow to referencing projects — producing a CS1705 in any test project referencing Infrastructure. Also commits artifacts/schema/v0.1.sql, the idempotent script, as the baseline for future upgrade tests. Verified: 0 warnings, 122 tests pass (27 new against Postgres), format clean.
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
Runs against a real PostgreSQL container. An in-memory or SQLite provider would not
|
||||
exercise the things worth testing here: partial unique indexes, CHECK constraints, citext,
|
||||
xmin concurrency, and identity-always columns are all provider behaviour.
|
||||
-->
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/DodoSSH.Infrastructure/DodoSSH.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Testcontainers.PostgreSql" />
|
||||
<!--
|
||||
Referenced explicitly, not just transitively. Testcontainers.PostgreSql brings an older
|
||||
EF Core along, which loses to Infrastructure's version at compile time (CS1705).
|
||||
-->
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,58 @@
|
||||
using DodoSSH.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Testcontainers.PostgreSql;
|
||||
using Xunit;
|
||||
|
||||
namespace DodoSSH.Infrastructure.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// One PostgreSQL container per test assembly, migrated once.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A container per test would dominate the runtime. Tests that write must therefore use distinct
|
||||
/// identifiers rather than assuming an empty database.
|
||||
/// </remarks>
|
||||
public sealed class PostgresFixture : IAsyncLifetime
|
||||
{
|
||||
private readonly PostgreSqlContainer container = new PostgreSqlBuilder()
|
||||
.WithImage("postgres:18-alpine")
|
||||
.WithDatabase("dodossh")
|
||||
.WithUsername("postgres")
|
||||
.WithPassword("test")
|
||||
.Build();
|
||||
|
||||
/// <summary>Connection string for the running container.</summary>
|
||||
public string ConnectionString => container.GetConnectionString();
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
await container.StartAsync();
|
||||
|
||||
await using var context = CreateContext();
|
||||
await context.Database.MigrateAsync();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync() => await container.DisposeAsync();
|
||||
|
||||
/// <summary>Creates a context against the container.</summary>
|
||||
public DodoDbContext CreateContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<DodoDbContext>()
|
||||
.UseNpgsql(ConnectionString, npgsql =>
|
||||
npgsql.MigrationsHistoryTable("__EFMigrationsHistory", DodoDbContext.SchemaName))
|
||||
.UseSnakeCaseNamingConvention()
|
||||
.Options;
|
||||
|
||||
return new DodoDbContext(options);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Shares one container across every test class in the assembly.</summary>
|
||||
[CollectionDefinition(Name)]
|
||||
public sealed class PostgresCollection : ICollectionFixture<PostgresFixture>
|
||||
{
|
||||
/// <summary>Collection name.</summary>
|
||||
public const string Name = "postgres";
|
||||
}
|
||||
@@ -0,0 +1,571 @@
|
||||
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.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<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.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<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.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<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.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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
{
|
||||
"version": 2,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"Meziantou.Analyzer": {
|
||||
"type": "Direct",
|
||||
"requested": "[3.0.134, )",
|
||||
"resolved": "3.0.134",
|
||||
"contentHash": "tTYCcYKyOko3TMNxmxmA9nakbcHVUgglENmCMIhzIjl9y9FBZO/0tWSxTGC74Sp198FmWih5S5KkjQRBg5ePkQ=="
|
||||
},
|
||||
"Microsoft.CodeAnalysis.BannedApiAnalyzers": {
|
||||
"type": "Direct",
|
||||
"requested": "[5.6.0, )",
|
||||
"resolved": "5.6.0",
|
||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||
},
|
||||
"Npgsql.EntityFrameworkCore.PostgreSQL": {
|
||||
"type": "Direct",
|
||||
"requested": "[10.0.3, )",
|
||||
"resolved": "10.0.3",
|
||||
"contentHash": "IPGrrZnRkuW7OlHDhUESZz4G5DLkW7Nej/O3Cx+0iTsgyU5XJxBgpsvTHLloo3WWuAKKbDHXBvWPVkX1deRh1Q==",
|
||||
"dependencies": {
|
||||
"Microsoft.EntityFrameworkCore": "[10.0.4, 11.0.0)",
|
||||
"Microsoft.EntityFrameworkCore.Relational": "[10.0.4, 11.0.0)",
|
||||
"Npgsql": "10.0.3"
|
||||
}
|
||||
},
|
||||
"NSubstitute": {
|
||||
"type": "Direct",
|
||||
"requested": "[6.0.0, )",
|
||||
"resolved": "6.0.0",
|
||||
"contentHash": "0gvKMbiJ+/WrfbcfBfqRZZrvfLJcd3rqkqVMjjlY5dtmLRVzMY+o/K/rJUStofQ2haSr9Vd04YDfvZtVVGS3/A==",
|
||||
"dependencies": {
|
||||
"Castle.Core": "5.1.1"
|
||||
}
|
||||
},
|
||||
"Shouldly": {
|
||||
"type": "Direct",
|
||||
"requested": "[4.3.0, )",
|
||||
"resolved": "4.3.0",
|
||||
"contentHash": "sDetrWXrl6YXZ4HeLsdBoNk3uIa7K+V4uvIJ+cqdRa5DrFxeTED7VkjoxCuU1kJWpUuBDZz2QXFzSxBtVXLwRQ==",
|
||||
"dependencies": {
|
||||
"DiffEngine": "11.3.0",
|
||||
"EmptyFiles": "4.4.0"
|
||||
}
|
||||
},
|
||||
"Testcontainers.PostgreSql": {
|
||||
"type": "Direct",
|
||||
"requested": "[4.13.0, )",
|
||||
"resolved": "4.13.0",
|
||||
"contentHash": "2ow4AE8drI9iA9Fr4ycAPusXPB1lJfQyyNONSMLE/XqLpm8VuNAh3pK38fOjkOWtTrnD03s4hAIdl4036Ik69A==",
|
||||
"dependencies": {
|
||||
"Testcontainers": "4.13.0"
|
||||
}
|
||||
},
|
||||
"xunit.v3": {
|
||||
"type": "Direct",
|
||||
"requested": "[3.2.2, )",
|
||||
"resolved": "3.2.2",
|
||||
"contentHash": "L+4/4y0Uqcg8/d6hfnxhnwh4j9FaeULvefTwrk30rr1o4n/vdPfyUQ8k0yzH8VJx7bmFEkDdcRfbtbjEHlaYcA==",
|
||||
"dependencies": {
|
||||
"xunit.v3.mtp-v1": "[3.2.2]"
|
||||
}
|
||||
},
|
||||
"Castle.Core": {
|
||||
"type": "Transitive",
|
||||
"resolved": "5.1.1",
|
||||
"contentHash": "rpYtIczkzGpf+EkZgDr9CClTdemhsrwA/W5hMoPjLkRFnXzH44zDLoovXeKtmxb1ykXK9aJVODSpiJml8CTw2g==",
|
||||
"dependencies": {
|
||||
"System.Diagnostics.EventLog": "6.0.0"
|
||||
}
|
||||
},
|
||||
"DiffEngine": {
|
||||
"type": "Transitive",
|
||||
"resolved": "11.3.0",
|
||||
"contentHash": "k0ZgZqd09jLZQjR8FyQbSQE86Q7QZnjEzq1LPHtj1R2AoWO8sjV5x+jlSisL7NZAbUOI4y+7Bog8gkr9WIRBGw==",
|
||||
"dependencies": {
|
||||
"EmptyFiles": "4.4.0",
|
||||
"System.Management": "6.0.1"
|
||||
}
|
||||
},
|
||||
"Docker.DotNet.Enhanced": {
|
||||
"type": "Transitive",
|
||||
"resolved": "4.3.3",
|
||||
"contentHash": "nGicLwvd42FhRk+khY5uS6cx49ErNdwYKnYBg0F4m4BDKLp/R77AVmmN9xAiqI3W/wN5ZCHkdUhgxf5ORkZuFQ==",
|
||||
"dependencies": {
|
||||
"Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3",
|
||||
"Docker.DotNet.Enhanced.LegacyHttp": "4.3.3",
|
||||
"Docker.DotNet.Enhanced.NPipe": "4.3.3",
|
||||
"Docker.DotNet.Enhanced.NativeHttp": "4.3.3",
|
||||
"Docker.DotNet.Enhanced.Unix": "4.3.3",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
},
|
||||
"Docker.DotNet.Enhanced.Handler.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "4.3.3",
|
||||
"contentHash": "9Cp8hOgtynixcDoAs9lnEaQosluojSYmiW3fsLsLIVfZjlq/fznSIZNUhnmyT4Xo1Iyuok/y49WL/25O47u0Pw==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
},
|
||||
"Docker.DotNet.Enhanced.LegacyHttp": {
|
||||
"type": "Transitive",
|
||||
"resolved": "4.3.3",
|
||||
"contentHash": "7j3M16emv9PAQN7VwFn23xLYNj8GJmwPOcogveHkaWnOCqiC+anRaNKQwqIBNApM1AuwZKivehTKTPmmrjUUnw==",
|
||||
"dependencies": {
|
||||
"Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3"
|
||||
}
|
||||
},
|
||||
"Docker.DotNet.Enhanced.NativeHttp": {
|
||||
"type": "Transitive",
|
||||
"resolved": "4.3.3",
|
||||
"contentHash": "iNzK+jRFeEMobSA7l/h4ARwCKOOefOWtVN5/RB0ft6/6H6IQXvVUuOgGyZAjYLBT7TsyClRYno2B904f3dtBuQ==",
|
||||
"dependencies": {
|
||||
"Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3"
|
||||
}
|
||||
},
|
||||
"Docker.DotNet.Enhanced.NPipe": {
|
||||
"type": "Transitive",
|
||||
"resolved": "4.3.3",
|
||||
"contentHash": "ZTLYufuEfY0e6qLOgeH9QgXx2KYuoABRVaY5A8rsggyLgYqbDj9rCRfVAhHPCUv83S7pVxDHy+Tvm/BnxjWVpg==",
|
||||
"dependencies": {
|
||||
"Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3"
|
||||
}
|
||||
},
|
||||
"Docker.DotNet.Enhanced.Unix": {
|
||||
"type": "Transitive",
|
||||
"resolved": "4.3.3",
|
||||
"contentHash": "ypo8qNbmvHw1t9VfpRTMogCw2vht6VjkXzlGYUUeP2H2bf83USURdla1maW1njn2oq2rfLUFOGMfmt+A37QU2w==",
|
||||
"dependencies": {
|
||||
"Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3"
|
||||
}
|
||||
},
|
||||
"Docker.DotNet.Enhanced.X509": {
|
||||
"type": "Transitive",
|
||||
"resolved": "4.3.3",
|
||||
"contentHash": "oBDibWezEv4hgj3RIQxI3DVcxkNV1MdrD0d/jhjUu+h3DL+qc0wlkQva15kkwMatXmC/hWp1VP0DMoFXe+BmEw==",
|
||||
"dependencies": {
|
||||
"Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3"
|
||||
}
|
||||
},
|
||||
"EmptyFiles": {
|
||||
"type": "Transitive",
|
||||
"resolved": "4.4.0",
|
||||
"contentHash": "gwJEfIGS7FhykvtZoscwXj/XwW+mJY6UbAZk+qtLKFUGWC95kfKXnj8VkxsZQnWBxJemM/q664rGLN5nf+OHZw=="
|
||||
},
|
||||
"Microsoft.ApplicationInsights": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.23.0",
|
||||
"contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw=="
|
||||
},
|
||||
"Microsoft.Bcl.AsyncInterfaces": {
|
||||
"type": "Transitive",
|
||||
"resolved": "6.0.0",
|
||||
"contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg=="
|
||||
},
|
||||
"Microsoft.EntityFrameworkCore.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "bOzrFCl6uZCjaSh2bG1ToRQRdx+iXvxosCg9hFyG9OWeAzOFI4xev9OqKeWfKf/kAHyox2JnbcvLVf2ceA7sqA=="
|
||||
},
|
||||
"Microsoft.EntityFrameworkCore.Analyzers": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "2gLDordUCGf3aNOOuqtTbP5mxhiP9nk6TnvGiE3RnqT891O+Zf/qKu1PIREubs1M16A0SImr4vULBfU5BTDs1Q=="
|
||||
},
|
||||
"Microsoft.Extensions.Caching.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "4ZFBNE+jzR+CrWWlhOesnmywCW7pYKT0dxyAQRdL11yJwxe4jvcAu31eorFtEkoFeCDcUTeNssgPv2yaRRptaQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Primitives": "10.0.10"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Caching.Memory": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "N1w5H7uK6gCTnCBZAWzE0/EQYSPysij/uYwDqntqBVvBa6bjMmBKitsnEFd6yh/SX3wLm67nO6+OnZ84K+gZWg==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Caching.Abstractions": "10.0.10",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "10.0.10",
|
||||
"Microsoft.Extensions.Options": "10.0.10",
|
||||
"Microsoft.Extensions.Primitives": "10.0.10"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Configuration.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "5Vnd2I75DmZCVEjSynIdJ/0EGafgnLQwgR3t2C2/fkjx/nRG+cLwxLLdInoHeCEpkD5K4Ov/g9ZCRYrl4TRsaA==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Primitives": "10.0.10"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.DependencyInjection": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "ANyvsgkNBRvcJh2XLgn8veGmajf+8m0AbKK+HPWdRL1yraSNVVSmQhFntLtdz/C795jxqqup+k05cs/3jZQPOA==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "z/2xXlFw2aLGjHyEm6E0tQ+In6VfzQzTrtArbQ2c0TQE16ZbyDCMGPvaUT9I0s8rgy9sRWlU2P9waW37qV04qA=="
|
||||
},
|
||||
"Microsoft.Extensions.Logging": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "Tf6z5HsL0VDYRTfvsoNrTGHGheCwkTsZBA2FFh5ATJUbkAwug+FFNISJK2gjpUNemlAOoWllAK52HOWCjto3EQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection": "10.0.10",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "10.0.10",
|
||||
"Microsoft.Extensions.Options": "10.0.10"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Logging.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "zkFxGYUvdxAvIKTyXHrmW+Sux53D4SezD9dMyZ6hrwwzPQJNuwCRy1f5W7AvYTqacEGhWF2XderRQG1OvbV8og==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Options": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "srnhnk7nE8krBiIXp71LvBmKBtraBONWSRzdjJgRv1Ko9Mp8IVNqv4vIS9hGeVteBig8aQkva9ZG+sC+o5sVcA==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10",
|
||||
"Microsoft.Extensions.Primitives": "10.0.10"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Primitives": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "5wu/GrYVd8mG2DVUw3vFJzF+O336TyTGg/Kmcgw9bfwYhCoFiV5lR5QeEmKecJyrW4W54nMfD3p3589E8a7czQ=="
|
||||
},
|
||||
"Microsoft.Testing.Extensions.Telemetry": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.9.1",
|
||||
"contentHash": "No5AudZMmSb+uNXjlgL2y3/stHD2IT4uxqc5yHwkE+/nNux9jbKcaJMvcp9SwgP4DVD8L9/P3OUz8mmmcvEIdQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.ApplicationInsights": "2.23.0",
|
||||
"Microsoft.Testing.Platform": "1.9.1"
|
||||
}
|
||||
},
|
||||
"Microsoft.Testing.Extensions.TrxReport.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.9.1",
|
||||
"contentHash": "AL46Xe1WBi85Ntd4mNPvat5ZSsZ2uejiVqoKCypr8J3wK0elA5xJ3AN4G/Q4GIwzUFnggZoH/DBjnr9J18IO/g==",
|
||||
"dependencies": {
|
||||
"Microsoft.Testing.Platform": "1.9.1"
|
||||
}
|
||||
},
|
||||
"Microsoft.Testing.Platform": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.9.1",
|
||||
"contentHash": "QafNtNSmEI0zazdebnsIkDKmFtTSpmx/5PLOjURWwozcPb3tvRxzosQSL8xwYNM1iPhhKiBksXZyRSE2COisrA=="
|
||||
},
|
||||
"Microsoft.Testing.Platform.MSBuild": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.9.1",
|
||||
"contentHash": "oTUtyR4X/s9ytuiNA29FGsNCCH0rNmY5Wdm14NCKLjTM1cT9edVSlA+rGS/mVmusPqcP0l/x9qOnMXg16v87RQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.Testing.Platform": "1.9.1"
|
||||
}
|
||||
},
|
||||
"Microsoft.Win32.Registry": {
|
||||
"type": "Transitive",
|
||||
"resolved": "5.0.0",
|
||||
"contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg=="
|
||||
},
|
||||
"Npgsql": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.3",
|
||||
"contentHash": "7nb5YzXuvWWJxB0J8DiyL3we+X4FOctZrt0fIBnucOIaIevFEEwGQVZKtiu9olXdlNAK1eNgqSral6r/jlhI4w==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Logging.Abstractions": "10.0.0"
|
||||
}
|
||||
},
|
||||
"SharpZipLib": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.4.2",
|
||||
"contentHash": "yjj+3zgz8zgXpiiC3ZdF/iyTBbz2fFvMxZFEBPUcwZjIvXOf37Ylm+K58hqMfIBt5JgU/Z2uoUS67JmTLe973A=="
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
},
|
||||
"System.CodeDom": {
|
||||
"type": "Transitive",
|
||||
"resolved": "6.0.0",
|
||||
"contentHash": "CPc6tWO1LAer3IzfZufDBRL+UZQcj5uS207NHALQzP84Vp/z6wF0Aa0YZImOQY8iStY0A2zI/e3ihKNPfUm8XA=="
|
||||
},
|
||||
"System.Diagnostics.EventLog": {
|
||||
"type": "Transitive",
|
||||
"resolved": "6.0.0",
|
||||
"contentHash": "lcyUiXTsETK2ALsZrX+nWuHSIQeazhqPphLfaRxzdGaG93+0kELqpgEHtwWOlQe7+jSFnKwaCAgL4kjeZCQJnw=="
|
||||
},
|
||||
"System.Management": {
|
||||
"type": "Transitive",
|
||||
"resolved": "6.0.1",
|
||||
"contentHash": "10J1D0h/lioojphfJ4Fuh5ZUThT/xOVHdV9roGBittKKNP2PMjrvibEdbVTGZcPra1399Ja3tqIJLyQrc5Wmhg==",
|
||||
"dependencies": {
|
||||
"System.CodeDom": "6.0.0"
|
||||
}
|
||||
},
|
||||
"Testcontainers": {
|
||||
"type": "Transitive",
|
||||
"resolved": "4.13.0",
|
||||
"contentHash": "j8vi9jPBNSwaraGGx8w+2gtZyWrlbKxdhiGMS3nektg+KiwjFWx9ghCjs57EoQfvI+IAbzti0oQJupQChwgMog==",
|
||||
"dependencies": {
|
||||
"Docker.DotNet.Enhanced": "4.3.3",
|
||||
"Docker.DotNet.Enhanced.X509": "4.3.3",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3",
|
||||
"SSH.NET": "2025.1.0",
|
||||
"SharpZipLib": "1.4.2"
|
||||
}
|
||||
},
|
||||
"xunit.analyzers": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.27.0",
|
||||
"contentHash": "y/pxIQaLvk/kxAoDkZW9GnHLCEqzwl5TW0vtX3pweyQpjizB9y3DXhb9pkw2dGeUqhLjsxvvJM1k89JowU6z3g=="
|
||||
},
|
||||
"xunit.v3.assert": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.2.2",
|
||||
"contentHash": "BPciBghgEEaJN/JG00QfCYDfEfnLgQhfnYEy+j1izoeHVNYd5+3Wm8GJ6JgYysOhpBPYGE+sbf75JtrRc7jrdA=="
|
||||
},
|
||||
"xunit.v3.common": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.2.2",
|
||||
"contentHash": "Hj775PEH6GTbbg0wfKRvG2hNspDCvTH9irXhH4qIWgdrOSV1sQlqPie+DOvFeigsFg2fxSM3ZAaaCDQs+KreFA==",
|
||||
"dependencies": {
|
||||
"Microsoft.Bcl.AsyncInterfaces": "6.0.0"
|
||||
}
|
||||
},
|
||||
"xunit.v3.core.mtp-v1": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.2.2",
|
||||
"contentHash": "Ga5aA2Ca9ktz+5k3g5ukzwfexwoqwDUpV6z7atSEUvqtd6JuybU1XopHqg1oFd78QdTfZgZE9h5sHpO4qYIi5w==",
|
||||
"dependencies": {
|
||||
"Microsoft.Testing.Extensions.Telemetry": "1.9.1",
|
||||
"Microsoft.Testing.Extensions.TrxReport.Abstractions": "1.9.1",
|
||||
"Microsoft.Testing.Platform": "1.9.1",
|
||||
"Microsoft.Testing.Platform.MSBuild": "1.9.1",
|
||||
"xunit.v3.extensibility.core": "[3.2.2]",
|
||||
"xunit.v3.runner.inproc.console": "[3.2.2]"
|
||||
}
|
||||
},
|
||||
"xunit.v3.extensibility.core": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.2.2",
|
||||
"contentHash": "srY8z/oMPvh/t8axtO2DwrHajhFMH7tnqKildvYrVQIfICi8fOn3yIBWkVPAcrKmHMwvXRJ/XsQM3VMR6DOYfQ==",
|
||||
"dependencies": {
|
||||
"xunit.v3.common": "[3.2.2]"
|
||||
}
|
||||
},
|
||||
"xunit.v3.mtp-v1": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.2.2",
|
||||
"contentHash": "O41aAzYKBT5PWqATa1oEWVNCyEUypFQ4va6K0kz37dduV3EKzXNMaV2UnEhufzU4Cce1I33gg0oldS8tGL5I0A==",
|
||||
"dependencies": {
|
||||
"xunit.analyzers": "1.27.0",
|
||||
"xunit.v3.assert": "[3.2.2]",
|
||||
"xunit.v3.core.mtp-v1": "[3.2.2]"
|
||||
}
|
||||
},
|
||||
"xunit.v3.runner.common": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.2.2",
|
||||
"contentHash": "/hkHkQCzGrugelOAehprm7RIWdsUFVmIVaD6jDH/8DNGCymTlKKPTbGokD5czbAfqfex47mBP0sb0zbHYwrO/g==",
|
||||
"dependencies": {
|
||||
"Microsoft.Win32.Registry": "[5.0.0]",
|
||||
"xunit.v3.common": "[3.2.2]"
|
||||
}
|
||||
},
|
||||
"xunit.v3.runner.inproc.console": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.2.2",
|
||||
"contentHash": "ulWOdSvCk+bPXijJZ73bth9NyoOHsAs1ZOvamYbCkD4DNLX/Bd29Ve2ZNUwBbK0MqfIYWXHZViy/HKrdEC/izw==",
|
||||
"dependencies": {
|
||||
"xunit.v3.extensibility.core": "[3.2.2]",
|
||||
"xunit.v3.runner.common": "[3.2.2]"
|
||||
}
|
||||
},
|
||||
"dodossh.domain": {
|
||||
"type": "Project"
|
||||
},
|
||||
"dodossh.infrastructure": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"DodoSSH.Domain": "[1.0.0, )",
|
||||
"EFCore.NamingConventions": "[10.0.1, )",
|
||||
"Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.3, )"
|
||||
}
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
},
|
||||
"EFCore.NamingConventions": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[10.0.1, )",
|
||||
"resolved": "10.0.1",
|
||||
"contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==",
|
||||
"dependencies": {
|
||||
"Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)",
|
||||
"Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1"
|
||||
}
|
||||
},
|
||||
"Microsoft.EntityFrameworkCore": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[10.0.10, )",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "a0V7zj/VbYP6dTdWpUgE/r2PuLKtUGe2aJ0lVKkn/wP9ZhaxUz2kQydVfvOjCv2SKxlrqdBfHhPD4Cvlf+4ffA==",
|
||||
"dependencies": {
|
||||
"Microsoft.EntityFrameworkCore.Abstractions": "10.0.10",
|
||||
"Microsoft.EntityFrameworkCore.Analyzers": "10.0.10",
|
||||
"Microsoft.Extensions.Caching.Memory": "10.0.10",
|
||||
"Microsoft.Extensions.Logging": "10.0.10"
|
||||
}
|
||||
},
|
||||
"Microsoft.EntityFrameworkCore.Relational": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[10.0.10, )",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "wNonj40aZxia+GtuBiiD6ZqVh4h6y5Nje1bGdmzZ8/ui0QRsAN+S0SIrLHFCEGbG9cDbeaE40sh+Lr7o9rRs6g==",
|
||||
"dependencies": {
|
||||
"Microsoft.EntityFrameworkCore": "10.0.10",
|
||||
"Microsoft.Extensions.Caching.Memory": "10.0.10",
|
||||
"Microsoft.Extensions.Configuration.Abstractions": "10.0.10",
|
||||
"Microsoft.Extensions.Logging": "10.0.10"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user