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,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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user