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:
2026-07-28 14:17:37 +02:00
parent 06d04b490b
commit eaf68c86b0
24 changed files with 5843 additions and 0 deletions
@@ -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"
}
}
}
}
}