Public Access
Merge branch 'main' into the desktop updater, and give way on two numbers
Main landed a realtime push feature while this branch was building the updater, and the two collided in three places. Every one of them resolves the same way: main got there first, so this branch moves. **Two ADRs were both numbered 0012.** Main's is realtime push; this one is now [ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md). Git did not call this a conflict — the filenames differ — so it would have merged quietly and left the directory with two 0012s and every cross-reference ambiguous. Renumbered here along with the nine places that point at it. **Two manual-check phases were both numbered 15**, and that one git did catch. Main's "Changes that arrive without a timer" keeps 15; installing and updating the desktop client becomes Phase 16, with its checks and every reference to them renumbered. The file's own rule is that a number is for life, which is exactly why the one that had not been pushed is the one that gives way. **The merge rewrote several files with CRLF**, and `.editorconfig` asks for LF on everything except `*.ps1`. That is not cosmetic here: IDE0055 is an error and `EnforceCodeStyleInBuild` is on, so it failed the build on three lines of App.axaml.cs whose only change in this branch was an ADR number in a comment. Forty-six files normalised back to LF; the release script keeps CRLF, which is what `.gitattributes` and `.editorconfig` both already say for a PowerShell file. Nothing else conflicted. The updater does not touch the sync loop or the event stream, and the one file both sides edited heavily — MainWindowViewModel — merged without a hunk in common. Verified after merging: the solution restores locked and builds clean, and 304 shell, 100 layout, 54 session, 28 client-api and 25 contracts tests pass. The first two counts are higher than before the merge because main's own tests came with it and pass alongside these.
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.WebSockets;
|
||||
using DodoSSH.Contracts;
|
||||
using DodoSSH.Infrastructure;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
@@ -107,6 +109,34 @@ public sealed class ApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
|
||||
|
||||
/// <summary>Opens a database scope for arranging state and asserting on it.</summary>
|
||||
public AsyncServiceScope CreateScope() => Services.CreateAsyncScope();
|
||||
|
||||
/// <summary>
|
||||
/// Opens the event socket as the given subject, through the real pipeline.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The bearer token goes on the upgrade request, which is the whole of the socket's authorization
|
||||
/// — see ADR 0012 — so a test that stubbed it would be testing nothing. The subprotocol is offered
|
||||
/// because the server refuses an upgrade that does not, and that refusal is itself under test.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>TestServer</c> speaks WebSockets in-memory with no port and no network, so these run
|
||||
/// wherever the rest of the suite does.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public Task<WebSocket> ConnectEventsAsync(string subject, CancellationToken cancellationToken)
|
||||
{
|
||||
var token = IdentityProvider.MintToken(subject);
|
||||
var client = Server.CreateWebSocketClient();
|
||||
|
||||
client.SubProtocols.Add(VaultEvents.SubProtocol);
|
||||
// The server-side request, so the header is a raw string rather than a typed value.
|
||||
client.ConfigureRequest = request => request.Headers.Authorization = $"Bearer {token}";
|
||||
|
||||
return client.ConnectAsync(
|
||||
new Uri(Server.BaseAddress, VaultEvents.Path.TrimStart('/')),
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Shares one host and container across every test class in the assembly.</summary>
|
||||
|
||||
@@ -56,6 +56,12 @@ public sealed class EndpointInventoryTests(ApiFixture fixture)
|
||||
"POST /api/v1/vaults/{vaultId:guid}/sync/pull name=SyncPull tags=Sync policies=Enrolled anon=False",
|
||||
"POST /api/v1/vaults/{vaultId:guid}/sync/push name=SyncPush tags=Sync policies=Enrolled anon=False",
|
||||
|
||||
// The WebSocket, gated exactly as sync is and for the same reason — it announces changes to
|
||||
// vaults, and a caller who could not read one has nothing to be told about. It appears here as
|
||||
// an ordinary route because that is what it is until the upgrade: the bearer token authorises
|
||||
// the handshake, unlike the relay's ticket. See ADR 0012.
|
||||
"GET /api/v1/events name=VaultEvents tags=Events policies=Enrolled anon=False",
|
||||
|
||||
// Enrolled, because the answer exists to be wrapped to and a caller with no key of their own has
|
||||
// nothing to wrap and no signature to attribute it with. There is no search here — see
|
||||
// DirectoryService for why an exact-match-only directory is a decision rather than a shortcut.
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
using System.Net;
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using DodoSSH.Contracts;
|
||||
using DodoSSH.Domain;
|
||||
using DodoSSH.Infrastructure;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DodoSSH.Api.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The push channel, over a real socket through the real authentication pipeline.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The tests that matter most here are the two negatives: an unauthenticated upgrade is refused, and a
|
||||
/// change to somebody else's vault does not reach this socket. A push channel that leaked <em>which
|
||||
/// vault ids exist and when they change</em> would be a disclosure the pull path takes deliberate
|
||||
/// trouble to avoid — <c>SyncPullEndpoint</c> answers 404 rather than 403 for exactly that reason —
|
||||
/// and it would be invisible in a test that only checked that notices arrive.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Ordering is asserted rather than absence-within-a-timeout wherever possible. "Nothing arrived in
|
||||
/// two seconds" is a test that passes on a slow machine for the wrong reason; "the first notice this
|
||||
/// socket saw was about its own vault, although another vault was written to first" is not.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Collection(ApiCollection.Name)]
|
||||
public sealed class EventsEndpointTests(ApiFixture fixture)
|
||||
{
|
||||
private static readonly DateTimeOffset Now = new(2026, 8, 4, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
/// <summary>
|
||||
/// How long a test will wait for a frame before calling it a failure.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Generous, because it is not a measurement: every wait here is for something already committed,
|
||||
/// so the only thing this bounds is how long a genuinely broken build hangs before it reports.
|
||||
/// </remarks>
|
||||
private static readonly TimeSpan FrameTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
// ---- The handshake ----
|
||||
|
||||
[Fact]
|
||||
public async Task WithoutAToken_TheUpgradeIsRefused()
|
||||
{
|
||||
var client = fixture.Server.CreateWebSocketClient();
|
||||
client.SubProtocols.Add(VaultEvents.SubProtocol);
|
||||
|
||||
var connecting = client.ConnectAsync(
|
||||
new Uri(fixture.Server.BaseAddress, VaultEvents.Path.TrimStart('/')),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(connecting);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BeforeEnrolling_Is403WithAnActionableCode()
|
||||
{
|
||||
// The same bar as sync: a caller with no identity key holds no vault key either, so every
|
||||
// notice this socket could carry is about ciphertext they cannot read.
|
||||
var client = fixture.CreateClientFor(NewSubject());
|
||||
|
||||
var response = await client.GetAsync(
|
||||
new Uri(VaultEvents.Path, UriKind.Relative),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
||||
|
||||
var problem = await response.Content.ReadProblemAsync();
|
||||
problem.ShouldNotBeNull();
|
||||
problem.Code.ShouldBe(ProblemCodes.EnrollmentRequired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task APlainGet_SaysItIsAWebSocket()
|
||||
{
|
||||
// A person, or a client with the wrong URL. Answering with a problem document rather than a
|
||||
// socket that closes is the difference between a diagnosable mistake and a mysterious one.
|
||||
var client = fixture.CreateClientFor(await SeedEnrolledUserAsync());
|
||||
|
||||
var response = await client.GetAsync(
|
||||
new Uri(VaultEvents.Path, UriKind.Relative),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
||||
|
||||
var problem = await response.Content.ReadProblemAsync();
|
||||
problem.ShouldNotBeNull();
|
||||
problem.Code.ShouldBe(ProblemCodes.MalformedRequest);
|
||||
problem.Detail.ShouldNotBeNull().ShouldContain(VaultEvents.SubProtocol);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnUpgradeWithoutTheSubprotocol_IsRefused()
|
||||
{
|
||||
// The subprotocol is this socket's version negotiation, so accepting an upgrade that did not
|
||||
// offer it would mean answering a client in a dialect it never agreed to read.
|
||||
var (subject, _) = await SeedUserWithVaultAsync();
|
||||
|
||||
var client = fixture.Server.CreateWebSocketClient();
|
||||
client.ConfigureRequest = request => request.Headers.Authorization =
|
||||
$"Bearer {fixture.IdentityProvider.MintToken(subject)}";
|
||||
|
||||
var connecting = client.ConnectAsync(
|
||||
new Uri(fixture.Server.BaseAddress, VaultEvents.Path.TrimStart('/')),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(connecting);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheFirstFrameIsHello()
|
||||
{
|
||||
var (subject, _) = await SeedUserWithVaultAsync();
|
||||
|
||||
using var timeout = Timeout();
|
||||
using var socket = await fixture.ConnectEventsAsync(subject, timeout.Token);
|
||||
|
||||
var hello = await ReadAsync(socket, timeout.Token);
|
||||
|
||||
hello.Kind.ShouldBe(VaultEventKinds.Hello);
|
||||
|
||||
// Sent so a client knows when silence means the socket is dead rather than quiet, and so a
|
||||
// socket following nothing — a real state, for an account with no vaults — is distinguishable
|
||||
// from one that is broken.
|
||||
hello.HeartbeatSeconds.ShouldNotBeNull().ShouldBeGreaterThan(0);
|
||||
hello.VaultCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MetaAdvertisesTheFeature()
|
||||
{
|
||||
// How a client decides whether to hold a socket open at all. Absence is not an error — it
|
||||
// means synchronise on the timer, which is what every client did before this existed.
|
||||
var client = fixture.CreateClient();
|
||||
|
||||
var meta = await (await client.GetAsync(
|
||||
new Uri("/api/v1/meta", UriKind.Relative),
|
||||
TestContext.Current.CancellationToken))
|
||||
.Content.ReadContractAsync<MetaResponse>();
|
||||
|
||||
meta.ShouldNotBeNull();
|
||||
meta.Features.ShouldContain(
|
||||
feature => string.Equals(feature, VaultEvents.Feature, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
// ---- Notices ----
|
||||
|
||||
[Fact]
|
||||
public async Task APush_AnnouncesTheVaultToAFollowingSocket()
|
||||
{
|
||||
var (subject, vaultId) = await SeedUserWithVaultAsync();
|
||||
|
||||
using var timeout = Timeout();
|
||||
using var socket = await fixture.ConnectEventsAsync(subject, timeout.Token);
|
||||
|
||||
await ReadAsync(socket, timeout.Token);
|
||||
|
||||
var client = fixture.CreateClientFor(subject);
|
||||
var push = await client.PostContractAsync(PushUrl(vaultId), NewCreateBatch());
|
||||
push.EnsureSuccessStatusCode();
|
||||
|
||||
var notice = await ReadUntilAsync(socket, VaultEventKinds.VaultChanged, timeout.Token);
|
||||
|
||||
notice.VaultId.ShouldBe(vaultId);
|
||||
|
||||
// A hint for logging and coalescing, never a cursor: cursors are opaque and integrity-tagged,
|
||||
// and a client that tried to resume from this would be resuming from a number it invented.
|
||||
notice.Sequence.ShouldNotBeNull().ShouldBeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ANoticeCarriesNoCiphertext()
|
||||
{
|
||||
// The load-bearing property of the whole design. A notice says only that a vault moved; the
|
||||
// client's answer is the delta pull it would have run on its timer anyway, which keeps exactly
|
||||
// one code path applying changes. See ADR 0012.
|
||||
var (subject, vaultId) = await SeedUserWithVaultAsync();
|
||||
|
||||
using var timeout = Timeout();
|
||||
using var socket = await fixture.ConnectEventsAsync(subject, timeout.Token);
|
||||
|
||||
await ReadAsync(socket, timeout.Token);
|
||||
|
||||
var client = fixture.CreateClientFor(subject);
|
||||
var batch = NewCreateBatch();
|
||||
await client.PostContractAsync(PushUrl(vaultId), batch);
|
||||
|
||||
var raw = await ReadRawUntilAsync(socket, VaultEventKinds.VaultChanged, timeout.Token);
|
||||
|
||||
// The envelope this test pushed, as it would appear if a payload had been forwarded.
|
||||
raw.ShouldNotContain(Convert.ToBase64String(batch.Operations[0].Payload!.Envelope));
|
||||
raw.ShouldNotContain("payload", Case.Insensitive);
|
||||
raw.ShouldNotContain(batch.Operations[0].EntityId.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task APushToAnotherAccountsVault_IsNotAnnouncedHere()
|
||||
{
|
||||
// The disclosure that would matter: a socket learning that vault ids it cannot read exist,
|
||||
// and when somebody works on them.
|
||||
var (subject, vaultId) = await SeedUserWithVaultAsync();
|
||||
var (stranger, strangersVaultId) = await SeedUserWithVaultAsync();
|
||||
|
||||
using var timeout = Timeout();
|
||||
using var socket = await fixture.ConnectEventsAsync(subject, timeout.Token);
|
||||
|
||||
await ReadAsync(socket, timeout.Token);
|
||||
|
||||
// The stranger's write goes first, so a socket that leaked would have announced it before the
|
||||
// one this test then waits for. Ordering, not a timeout: "nothing arrived in two seconds"
|
||||
// passes on a slow machine for the wrong reason.
|
||||
var strangersClient = fixture.CreateClientFor(stranger);
|
||||
(await strangersClient.PostContractAsync(PushUrl(strangersVaultId), NewCreateBatch()))
|
||||
.EnsureSuccessStatusCode();
|
||||
|
||||
var ownClient = fixture.CreateClientFor(subject);
|
||||
(await ownClient.PostContractAsync(PushUrl(vaultId), NewCreateBatch()))
|
||||
.EnsureSuccessStatusCode();
|
||||
|
||||
var notice = await ReadUntilAsync(socket, VaultEventKinds.VaultChanged, timeout.Token);
|
||||
|
||||
notice.VaultId.ShouldBe(vaultId);
|
||||
notice.VaultId.ShouldNotBe(strangersVaultId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task APushThatAppliedNothing_AnnouncesNothing()
|
||||
{
|
||||
// A batch of pure conflicts moved no vault. Announcing one anyway would have every client on
|
||||
// it pull for a change that is not there.
|
||||
var (subject, vaultId) = await SeedUserWithVaultAsync();
|
||||
var client = fixture.CreateClientFor(subject);
|
||||
|
||||
using var timeout = Timeout();
|
||||
using var socket = await fixture.ConnectEventsAsync(subject, timeout.Token);
|
||||
|
||||
await ReadAsync(socket, timeout.Token);
|
||||
|
||||
// An update to an item that does not exist: rejected as a conflict, nothing written.
|
||||
var stale = new SyncPushRequest(
|
||||
[
|
||||
NewOperation(Guid.CreateVersion7(), expectedVersion: 7, envelope: [9, 9]),
|
||||
]);
|
||||
|
||||
var conflicted = await client.PostContractAsync(PushUrl(vaultId), stale);
|
||||
conflicted.EnsureSuccessStatusCode();
|
||||
|
||||
var results = await conflicted.Content.ReadContractAsync<SyncPushResponse>();
|
||||
results.ShouldNotBeNull();
|
||||
results.Results[0].Status.ShouldBe(SyncOperationStatus.Conflict);
|
||||
|
||||
// Then a write that did land. The first notice must be that one.
|
||||
(await client.PostContractAsync(PushUrl(vaultId), NewCreateBatch())).EnsureSuccessStatusCode();
|
||||
|
||||
var notice = await ReadUntilAsync(socket, VaultEventKinds.VaultChanged, timeout.Token);
|
||||
|
||||
notice.Sequence.ShouldNotBeNull().ShouldBeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task APing_IsAnswered()
|
||||
{
|
||||
var (subject, _) = await SeedUserWithVaultAsync();
|
||||
|
||||
using var timeout = Timeout();
|
||||
using var socket = await fixture.ConnectEventsAsync(subject, timeout.Token);
|
||||
|
||||
await ReadAsync(socket, timeout.Token);
|
||||
|
||||
await SendAsync(socket, new VaultEvent(VaultEventKinds.Ping), timeout.Token);
|
||||
|
||||
var pong = await ReadUntilAsync(socket, VaultEventKinds.Pong, timeout.Token);
|
||||
|
||||
pong.Kind.ShouldBe(VaultEventKinds.Pong);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AFrameThisServerCannotRead_DoesNotEndTheSocket()
|
||||
{
|
||||
// A control channel whose failure mode is "the client polls instead" should tolerate a frame
|
||||
// from a newer client rather than cost that client its push for the whole session.
|
||||
var (subject, vaultId) = await SeedUserWithVaultAsync();
|
||||
|
||||
using var timeout = Timeout();
|
||||
using var socket = await fixture.ConnectEventsAsync(subject, timeout.Token);
|
||||
|
||||
await ReadAsync(socket, timeout.Token);
|
||||
|
||||
await socket.SendAsync(
|
||||
Encoding.UTF8.GetBytes("{ not json at all"),
|
||||
WebSocketMessageType.Text,
|
||||
endOfMessage: true,
|
||||
timeout.Token);
|
||||
|
||||
var client = fixture.CreateClientFor(subject);
|
||||
(await client.PostContractAsync(PushUrl(vaultId), NewCreateBatch())).EnsureSuccessStatusCode();
|
||||
|
||||
var notice = await ReadUntilAsync(socket, VaultEventKinds.VaultChanged, timeout.Token);
|
||||
|
||||
notice.VaultId.ShouldBe(vaultId);
|
||||
socket.State.ShouldBe(WebSocketState.Open);
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
/// <summary>A token that gives up rather than letting a broken build hang the suite.</summary>
|
||||
private static CancellationTokenSource Timeout()
|
||||
{
|
||||
var source = CancellationTokenSource.CreateLinkedTokenSource(
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
source.CancelAfter(FrameTimeout);
|
||||
|
||||
return source;
|
||||
}
|
||||
|
||||
private static async Task<VaultEvent> ReadAsync(WebSocket socket, CancellationToken cancellationToken)
|
||||
{
|
||||
var json = await ReadRawAsync(socket, cancellationToken);
|
||||
|
||||
return JsonSerializer.Deserialize(json, DodoSshJsonContext.Default.VaultEvent)
|
||||
?? throw new InvalidOperationException($"The server sent a null frame: {json}");
|
||||
}
|
||||
|
||||
private static async Task<string> ReadRawAsync(WebSocket socket, CancellationToken cancellationToken)
|
||||
{
|
||||
var buffer = new byte[8 * 1024];
|
||||
|
||||
var received = await socket.ReceiveAsync(buffer, cancellationToken);
|
||||
|
||||
if (received.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"The server closed the socket: {received.CloseStatus} {received.CloseStatusDescription}");
|
||||
}
|
||||
|
||||
return Encoding.UTF8.GetString(buffer, 0, received.Count);
|
||||
}
|
||||
|
||||
/// <summary>Reads past the frames a test does not care about — hello, and heartbeats.</summary>
|
||||
private static async Task<VaultEvent> ReadUntilAsync(
|
||||
WebSocket socket,
|
||||
string kind,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var frame = await ReadAsync(socket, cancellationToken);
|
||||
|
||||
if (string.Equals(frame.Kind, kind, StringComparison.Ordinal))
|
||||
{
|
||||
return frame;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The same, but keeping the bytes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deserialising and asserting on the fields would prove only that this <em>record</em> has no
|
||||
/// payload member, which is a tautology. Asserting on what actually crossed the socket is what
|
||||
/// would catch a field added to the frame later without anybody thinking about disclosure.
|
||||
/// </remarks>
|
||||
private static async Task<string> ReadRawUntilAsync(
|
||||
WebSocket socket,
|
||||
string kind,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var json = await ReadRawAsync(socket, cancellationToken);
|
||||
var frame = JsonSerializer.Deserialize(json, DodoSshJsonContext.Default.VaultEvent);
|
||||
|
||||
if (string.Equals(frame?.Kind, kind, StringComparison.Ordinal))
|
||||
{
|
||||
return json;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Task SendAsync(WebSocket socket, VaultEvent frame, CancellationToken cancellationToken) =>
|
||||
socket.SendAsync(
|
||||
JsonSerializer.SerializeToUtf8Bytes(frame, DodoSshJsonContext.Default.VaultEvent),
|
||||
WebSocketMessageType.Text,
|
||||
endOfMessage: true,
|
||||
cancellationToken);
|
||||
|
||||
private static string PushUrl(Guid vaultId) => $"/api/v1/vaults/{vaultId}/sync/push";
|
||||
|
||||
private static string NewSubject() => $"events-{Guid.CreateVersion7():N}";
|
||||
|
||||
private static SyncPushOperation NewOperation(Guid entityId, int? expectedVersion, byte[] envelope) =>
|
||||
new(
|
||||
Guid.CreateVersion7(),
|
||||
SyncEntityType.Host,
|
||||
entityId,
|
||||
SyncOperation.Upsert,
|
||||
expectedVersion,
|
||||
new EncryptedPayload(envelope, [0xD, 0xE], Guid.CreateVersion7(), 1, 1),
|
||||
new SyncPlaintextFields());
|
||||
|
||||
private static SyncPushRequest NewCreateBatch() =>
|
||||
new([NewOperation(Guid.CreateVersion7(), expectedVersion: null, envelope: [1, 2, 3, 4])]);
|
||||
|
||||
private async Task<string> SeedEnrolledUserAsync()
|
||||
{
|
||||
var subject = NewSubject();
|
||||
|
||||
await using var scope = fixture.CreateScope();
|
||||
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
|
||||
|
||||
var user = NewUser(subject);
|
||||
database.Users.Add(user);
|
||||
database.UserKeys.Add(Seed.CurrentKey(user.Id, Now));
|
||||
await database.SaveChangesAsync();
|
||||
|
||||
return subject;
|
||||
}
|
||||
|
||||
private async Task<(string Subject, Guid VaultId)> SeedUserWithVaultAsync()
|
||||
{
|
||||
var subject = NewSubject();
|
||||
|
||||
await using var scope = fixture.CreateScope();
|
||||
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
|
||||
|
||||
var user = NewUser(subject);
|
||||
|
||||
var vault = new Vault
|
||||
{
|
||||
Id = Guid.CreateVersion7(),
|
||||
Name = "Personal",
|
||||
OwnerKind = VaultOwnerKind.Personal,
|
||||
OwnerUserId = user.Id,
|
||||
KeyGeneration = 1,
|
||||
CreatedAtUtc = Now,
|
||||
UpdatedAtUtc = Now,
|
||||
};
|
||||
|
||||
database.Users.Add(user);
|
||||
database.UserKeys.Add(Seed.CurrentKey(user.Id, Now));
|
||||
database.Vaults.Add(vault);
|
||||
await database.SaveChangesAsync();
|
||||
|
||||
return (subject, vault.Id);
|
||||
}
|
||||
|
||||
private UserAccount NewUser(string subject) => new()
|
||||
{
|
||||
Id = Guid.CreateVersion7(),
|
||||
Issuer = fixture.IdentityProvider.Authority,
|
||||
Subject = subject,
|
||||
Status = UserStatus.Active,
|
||||
CreatedAtUtc = Now,
|
||||
UpdatedAtUtc = Now,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user