Public Access
Merge branch 'main' into claude/host-management-ui-plan-7f20ab
Seven files needed a hand. Most were two branches adding something in the same place, but three were one branch changing what the other had moved or renamed, and those are the ones worth reading. The shell keeps both new fields and both constructor lines: the connection recorder this branch built and the teams view model main did. Where main put a teams load inside OnScreenChanged, it now sits beside the logs refresh rather than inside RaiseSurfaceState — this branch extracted that notification block and it is called from two properties, so a screen-specific side effect in there would fire on every terminal switch as well. Main gave four row types a vault id and a vault name, and this branch had moved one of them — KnownHostRowViewModel — into its own file when the pinned keys became a screen. Git resolved that as "deleted here, modified there" and took the delete, which compiles as long as nobody looks: the moved copy still had the two-argument constructor and the call site had grown to four. Carried over by hand, along with the ordering the pins list now does on them. The status line's quiet rule was the subtle one. Main extracted it into IsWorthReporting; this branch had changed the same condition to read item counts rather than raw ones, because every user action queues a log entry a moment later and this machine reads its own entries back on the next pull. Take main's structure and the merge builds, passes, and silently restores a bug this branch existed partly to fix — every save's message overwritten a second after it appears. The method now reads PulledItems and PushedItems, with the reason in its remarks. Two conflicts were prose that had gone stale rather than code. The keychain screen's comment said team vaults are refused by the server's access service, which was true when it was written and is not now; main's replacement stands, in this branch's vocabulary. The design-gaps row for groups was claimed by both — real host groups here, per-vault headings there — and they are different things, so both rows stay and the difference is stated: a group is a shelf the user chose, a vault is who can read the item. One defect the tests found and the compiler could not. Generating a key opens the same editor as pasting one, but not through NewKey — so it never set the target vault main added, and a generated key was filed into whatever vault was edited last, or none. Both key-generation tests failed on it. Fixed where the editor opens, with the reason recorded there. One gap is left deliberately and is written down rather than half-built. Hosts, keys, credentials and pins are read across every vault this session holds a key for; groups are read from the active vault alone, so a host a teammate filed shows under UNGROUPED. Nothing is lost or misfiled — it is what the sidebar already shows for a group that has been deleted — but closing it needs a vault id on every group row for rename and delete, and a way to tell two vaults' identically-named groups apart under a layout with one heading per group. Both are worth doing and neither is a merge's business. It is in the remarks on ReloadGroupsAsync and in docs/design-import-gaps.md. dotnet build, dotnet test and dotnet format --verify-no-changes are all clean: 1282 tests, including the end-to-end suite against real containers.
This commit is contained in:
@@ -56,6 +56,36 @@ 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",
|
||||
|
||||
// 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.
|
||||
"GET /api/v1/directory name=LookupDirectory tags=Identity policies=Enrolled anon=False",
|
||||
|
||||
// The other half of the same decision: the directory says what a key is, this is how a client
|
||||
// checks that claim against a chain the server cannot rewrite without every other client
|
||||
// noticing. Nothing in it is secret.
|
||||
"GET /api/v1/keylog name=ReadKeyLog tags=Identity policies=Enrolled anon=False",
|
||||
|
||||
// Authenticated, not Enrolled: reading and joining teams needs no key, and a member added before
|
||||
// they have set a vault up must still be able to see the team they are now in.
|
||||
"GET /api/v1/teams name=ListTeams tags=Teams policies=Authenticated anon=False",
|
||||
"GET /api/v1/teams/{teamId:guid}/members name=ListTeamMembers tags=Teams policies=Authenticated anon=False",
|
||||
"POST /api/v1/teams/{teamId:guid}/members name=AddTeamMember tags=Teams policies=Authenticated anon=False",
|
||||
"PUT /api/v1/teams/{teamId:guid}/members/{userId:guid}/role name=ChangeTeamMemberRole tags=Teams policies=Authenticated anon=False",
|
||||
"DELETE /api/v1/teams/{teamId:guid}/members/{userId:guid} name=RemoveTeamMember tags=Teams policies=Authenticated anon=False",
|
||||
|
||||
// Enrolled, because both end in a vault key being wrapped: creating a team means creating a vault
|
||||
// in it, and neither is reachable without a key of one's own.
|
||||
"POST /api/v1/teams name=CreateTeam tags=Teams policies=Enrolled anon=False",
|
||||
"POST /api/v1/teams/{teamId:guid}/vaults name=CreateTeamVault tags=Teams policies=Enrolled anon=False",
|
||||
|
||||
// Enrolled. The listing is gated on Read rather than Share — every member can already see the
|
||||
// sharing graph — and the two writes are gated on Share inside the handler, which this table
|
||||
// cannot see. See VaultGrantEndpoints.
|
||||
"GET /api/v1/vaults/{vaultId:guid}/grants name=ListVaultGrants tags=Vaults policies=Enrolled anon=False",
|
||||
"POST /api/v1/vaults/{vaultId:guid}/grants name=IssueVaultGrant tags=Vaults policies=Enrolled anon=False",
|
||||
"DELETE /api/v1/vaults/{vaultId:guid}/grants/{userId:guid} name=RevokeVaultGrant tags=Vaults policies=Enrolled anon=False",
|
||||
|
||||
// Anonymous on purpose, and load-bearing: DodoSSH.SystemTests waits on /healthz/ready before any
|
||||
// token exists, and an orchestrator probe that needs credentials reports the wrong thing.
|
||||
// MapHealthChecks constrains no verb, hence ANY.
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using DodoSSH.Contracts;
|
||||
|
||||
namespace DodoSSH.Api.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Teams, membership and the vault key grants that make a team vault readable.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The half of M3 that runs on the server, which is the half that decides <em>what will be served</em>.
|
||||
/// Whether a member can decrypt what they are served is decided by holding a key, and no test here can
|
||||
/// assert it — that lives in the client suite, where a key exists. The two are separate on purpose and
|
||||
/// these tests are written to keep them separate: none of them checks that a wrapped key is right,
|
||||
/// because the server cannot.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The cases worth having are the ones where a mistake would be invisible. A member removed but still
|
||||
/// served; a viewer allowed to push; a vault visible to a team it does not belong to; a grant accepted
|
||||
/// for a key its recipient no longer holds. Each of those looks exactly like working software from the
|
||||
/// outside.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Collection(ApiCollection.Name)]
|
||||
public sealed class TeamEndpointTests(ApiFixture fixture)
|
||||
{
|
||||
private const string TeamsUrl = "/api/v1/teams";
|
||||
private const string EnrollUrl = "/api/v1/me/enrollment";
|
||||
|
||||
[Fact]
|
||||
public async Task CreatingATeam_MakesTheCallerItsOwner()
|
||||
{
|
||||
var client = await EnrolledClientAsync("team-owner");
|
||||
|
||||
var team = await CreateTeamAsync(client, "Platform");
|
||||
|
||||
team.Role.ShouldBe(TeamMemberRole.Owner);
|
||||
team.MemberCount.ShouldBe(1);
|
||||
team.VaultCount.ShouldBe(0);
|
||||
|
||||
var listed = await ReadAsync<IReadOnlyList<TeamSummary>>(client, TeamsUrl);
|
||||
|
||||
listed.ShouldContain(row => row.TeamId == team.TeamId);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The same body twice, as a client whose response was lost would send it. Enrollment behaves this way
|
||||
/// and a team create has the same shape — a client-chosen id — so it has to behave the same or a lost
|
||||
/// response leaves somebody with two teams under one name.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task RepeatingACreate_ReturnsTheSameTeamRatherThanASecondOne()
|
||||
{
|
||||
var client = await EnrolledClientAsync("team-retry");
|
||||
|
||||
var request = new CreateTeamRequest(
|
||||
Guid.CreateVersion7(), "Retry", $"retry-{Guid.CreateVersion7():N}", null);
|
||||
|
||||
var first = await PostAsync<CreateTeamRequest, TeamSummary>(client, TeamsUrl, request);
|
||||
var second = await PostAsync<CreateTeamRequest, TeamSummary>(client, TeamsUrl, request);
|
||||
|
||||
second.TeamId.ShouldBe(first.TeamId);
|
||||
|
||||
var listed = await ReadAsync<IReadOnlyList<TeamSummary>>(client, TeamsUrl);
|
||||
|
||||
listed.Count(row => row.TeamId == first.TeamId).ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ASlugAlreadyInUse_IsRefusedWithItsOwnCode()
|
||||
{
|
||||
var client = await EnrolledClientAsync("team-slug");
|
||||
var slug = $"taken-{Guid.CreateVersion7():N}";
|
||||
|
||||
await PostAsync<CreateTeamRequest, TeamSummary>(
|
||||
client, TeamsUrl, new CreateTeamRequest(Guid.CreateVersion7(), "First", slug, null));
|
||||
|
||||
var response = await client.PostContractAsync(
|
||||
TeamsUrl, new CreateTeamRequest(Guid.CreateVersion7(), "Second", slug, null));
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Conflict);
|
||||
|
||||
var problem = await response.Content.ReadProblemAsync();
|
||||
|
||||
problem.Code.ShouldBe(ProblemCodes.TeamSlugTaken);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A team somebody is not in answers 404, not 403. Distinguishing them would let a caller confirm
|
||||
/// which team ids exist, and team ids travel in URLs.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ATeamTheCallerIsNotIn_IsIndistinguishableFromOneThatDoesNotExist()
|
||||
{
|
||||
var owner = await EnrolledClientAsync("team-private-owner");
|
||||
var stranger = await EnrolledClientAsync("team-private-stranger");
|
||||
|
||||
var team = await CreateTeamAsync(owner, "Private");
|
||||
|
||||
var real = await stranger.GetAsync(
|
||||
new Uri($"{TeamsUrl}/{team.TeamId}/members", UriKind.Relative),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
var invented = await stranger.GetAsync(
|
||||
new Uri($"{TeamsUrl}/{Guid.CreateVersion7()}/members", UriKind.Relative),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
real.StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
||||
invented.StatusCode.ShouldBe(real.StatusCode);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The membership half of M3 in one test: a team vault appears in the other member's <c>/me</c> the
|
||||
/// moment they are added, and it appears <em>without</em> a wrapped key. That null is the whole
|
||||
/// design — the server can grant access to the ciphertext and cannot grant the ability to read it.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AnAddedMember_SeesTheTeamVaultWithNoKeyUntilSomebodyWrapsOne()
|
||||
{
|
||||
var owner = await EnrolledClientAsync("grant-owner", "owner@example.com");
|
||||
var member = await EnrolledClientAsync("grant-member", "member@example.com");
|
||||
|
||||
var team = await CreateTeamAsync(owner, "Sharing");
|
||||
var vaultId = await CreateVaultAsync(owner, team.TeamId);
|
||||
|
||||
var entry = await LookupAsync(owner, "member@example.com");
|
||||
|
||||
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
|
||||
|
||||
var me = await ReadAsync<MeResponse>(member, "/api/v1/me");
|
||||
var vault = me.Vaults.SingleOrDefault(summary => summary.VaultId == vaultId);
|
||||
|
||||
vault.ShouldNotBeNull("membership is what makes a team vault visible");
|
||||
vault.WrappedVaultKey.ShouldBeNull("and it is not what makes it readable");
|
||||
vault.IsPersonal.ShouldBeFalse();
|
||||
vault.TeamId.ShouldBe(team.TeamId);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A viewer may read the vault and may not write to it. The failure this guards is the quiet one: a
|
||||
/// role that resolved to the wrong flags would let somebody who was added to look at a vault change
|
||||
/// what everybody else connects with.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AViewer_MayPullAndMayNotPush()
|
||||
{
|
||||
var owner = await EnrolledClientAsync("viewer-owner", "vowner@example.com");
|
||||
var viewer = await EnrolledClientAsync("viewer-member", "viewer@example.com");
|
||||
|
||||
var team = await CreateTeamAsync(owner, "Read only");
|
||||
var vaultId = await CreateVaultAsync(owner, team.TeamId);
|
||||
|
||||
var entry = await LookupAsync(owner, "viewer@example.com");
|
||||
|
||||
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Viewer);
|
||||
|
||||
var pull = await viewer.PostContractAsync(
|
||||
$"/api/v1/vaults/{vaultId}/sync/pull", new SyncPullRequest(null, null, null));
|
||||
|
||||
pull.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var push = await viewer.PostContractAsync(
|
||||
$"/api/v1/vaults/{vaultId}/sync/push", new SyncPushRequest([]));
|
||||
|
||||
push.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
||||
|
||||
var problem = await push.Content.ReadProblemAsync();
|
||||
|
||||
problem.Code.ShouldBe(ProblemCodes.Forbidden);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// And the reverse, which is what removal has to mean: the vault stops being served at all. Note what
|
||||
/// is <em>not</em> asserted — that they have forgotten anything. They have not, and ADR 0001 says so.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ARemovedMember_StopsBeingServedTheTeamsVault()
|
||||
{
|
||||
var owner = await EnrolledClientAsync("removal-owner", "rowner@example.com");
|
||||
var member = await EnrolledClientAsync("removal-member", "rmember@example.com");
|
||||
|
||||
var team = await CreateTeamAsync(owner, "Departures");
|
||||
var vaultId = await CreateVaultAsync(owner, team.TeamId);
|
||||
|
||||
var entry = await LookupAsync(owner, "rmember@example.com");
|
||||
|
||||
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
|
||||
|
||||
var before = await ReadAsync<MeResponse>(member, "/api/v1/me");
|
||||
before.Vaults.ShouldContain(summary => summary.VaultId == vaultId);
|
||||
|
||||
var removed = await owner.DeleteAsync(
|
||||
new Uri($"{TeamsUrl}/{team.TeamId}/members/{entry.UserId}", UriKind.Relative),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
removed.StatusCode.ShouldBe(HttpStatusCode.NoContent);
|
||||
|
||||
var after = await ReadAsync<MeResponse>(member, "/api/v1/me");
|
||||
after.Vaults.ShouldNotContain(summary => summary.VaultId == vaultId);
|
||||
|
||||
var pull = await member.PostContractAsync(
|
||||
$"/api/v1/vaults/{vaultId}/sync/pull", new SyncPullRequest(null, null, null));
|
||||
|
||||
pull.StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Removing a member leaves the vault flagged for rekey, which is a promise the server records and
|
||||
/// cannot keep on its own: rekeying re-wraps every item's data key and only a client holding the
|
||||
/// current one can do that. The flag is what the interface reads to say so; M5 is what acts on it.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task RemovingAMember_FlagsTheTeamsVaultsForRekey()
|
||||
{
|
||||
var owner = await EnrolledClientAsync("rekey-owner", "kowner@example.com");
|
||||
await EnrolledClientAsync("rekey-member", "kmember@example.com");
|
||||
|
||||
var team = await CreateTeamAsync(owner, "Rekeys");
|
||||
var vaultId = await CreateVaultAsync(owner, team.TeamId);
|
||||
|
||||
var entry = await LookupAsync(owner, "kmember@example.com");
|
||||
|
||||
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
|
||||
|
||||
await owner.DeleteAsync(
|
||||
new Uri($"{TeamsUrl}/{team.TeamId}/members/{entry.UserId}", UriKind.Relative),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
var grants = await ReadAsync<VaultGrantsResponse>(owner, $"/api/v1/vaults/{vaultId}/grants");
|
||||
|
||||
grants.RekeyRequired.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The owner cannot be removed or demoted, because nothing can appoint a replacement yet. Refused
|
||||
/// with a code the client can act on rather than a bare 400, since "you cannot do that" and "you did
|
||||
/// that wrong" lead somewhere different.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheOwner_CannotBeRemoved()
|
||||
{
|
||||
var owner = await EnrolledClientAsync("sole-owner", "sole@example.com");
|
||||
|
||||
var team = await CreateTeamAsync(owner, "Sole");
|
||||
var me = await ReadAsync<MeResponse>(owner, "/api/v1/me");
|
||||
|
||||
var response = await owner.DeleteAsync(
|
||||
new Uri($"{TeamsUrl}/{team.TeamId}/members/{me.UserId}", UriKind.Relative),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Conflict);
|
||||
|
||||
var problem = await response.Content.ReadProblemAsync();
|
||||
|
||||
problem.Code.ShouldBe(ProblemCodes.LastTeamOwner);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A grant to somebody outside the team is refused. It would be a row that looks like sharing and
|
||||
/// does nothing, because the access check will go on refusing them the vault — and a sharing screen
|
||||
/// listing a grant whose holder cannot fetch anything is worse than an error.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AGrantToANonMember_IsRefused()
|
||||
{
|
||||
var owner = await EnrolledClientAsync("outsider-owner", "oowner@example.com");
|
||||
await EnrolledClientAsync("outsider", "outsider@example.com");
|
||||
|
||||
var team = await CreateTeamAsync(owner, "Closed");
|
||||
var vaultId = await CreateVaultAsync(owner, team.TeamId);
|
||||
|
||||
var entry = await LookupAsync(owner, "outsider@example.com");
|
||||
|
||||
var response = await owner.PostContractAsync(
|
||||
$"/api/v1/vaults/{vaultId}/grants",
|
||||
new IssueVaultGrantRequest(
|
||||
entry.UserId,
|
||||
entry.Fingerprint,
|
||||
KeyGeneration: 1,
|
||||
WrappedVaultKey: new byte[110],
|
||||
KeyLogHead: new byte[32],
|
||||
GrantSignature: new byte[64],
|
||||
GrantedAt: DateTimeOffset.UnixEpoch));
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
||||
|
||||
var problem = await response.Content.ReadProblemAsync();
|
||||
|
||||
problem.Code.ShouldBe(ProblemCodes.InvalidVaultGrant);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A fingerprint that is not the recipient's current key is refused. The server cannot tell whether
|
||||
/// the wrap contains the right key — nothing on that machine can — but it can tell that this grant
|
||||
/// was made for a key nobody holds, which would otherwise surface at the far end days later as a tag
|
||||
/// failure indistinguishable from corruption.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AGrantForAKeyTheRecipientDoesNotHold_IsRefused()
|
||||
{
|
||||
var owner = await EnrolledClientAsync("stale-owner", "sowner@example.com");
|
||||
await EnrolledClientAsync("stale-member", "smember@example.com");
|
||||
|
||||
var team = await CreateTeamAsync(owner, "Stale");
|
||||
var vaultId = await CreateVaultAsync(owner, team.TeamId);
|
||||
|
||||
var entry = await LookupAsync(owner, "smember@example.com");
|
||||
|
||||
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
|
||||
|
||||
var response = await owner.PostContractAsync(
|
||||
$"/api/v1/vaults/{vaultId}/grants",
|
||||
new IssueVaultGrantRequest(
|
||||
entry.UserId,
|
||||
RecipientKeyFingerprint: new byte[32],
|
||||
KeyGeneration: 1,
|
||||
WrappedVaultKey: new byte[110],
|
||||
KeyLogHead: new byte[32],
|
||||
GrantSignature: new byte[64],
|
||||
GrantedAt: DateTimeOffset.UnixEpoch));
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The directory has no search. Asserting it rather than trusting the implementation, because a
|
||||
/// prefix match added later for convenience turns a server that stores addresses in plaintext into a
|
||||
/// way to enumerate an organisation's staff.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheDirectory_MatchesAnExactAddressAndNothingElse()
|
||||
{
|
||||
var client = await EnrolledClientAsync("directory-self", "findme@example.com");
|
||||
var address = addresses["findme@example.com"];
|
||||
|
||||
var exact = await ReadAsync<IReadOnlyList<DirectoryEntry>>(
|
||||
client, $"/api/v1/directory?email={Uri.EscapeDataString(address)}");
|
||||
|
||||
exact.Count.ShouldBe(1);
|
||||
|
||||
// Case-insensitive, because the column is citext and two addresses differing only in case are
|
||||
// one account. That is a match, not a search.
|
||||
var cased = await ReadAsync<IReadOnlyList<DirectoryEntry>>(
|
||||
client, $"/api/v1/directory?email={Uri.EscapeDataString(address.ToUpperInvariant())}");
|
||||
|
||||
cased.Count.ShouldBe(1);
|
||||
|
||||
// The address with its last character removed. A directory that answered this would be a way to
|
||||
// walk an organisation's staff list out of a server that stores addresses in plaintext.
|
||||
var prefix = await ReadAsync<IReadOnlyList<DirectoryEntry>>(
|
||||
client, $"/api/v1/directory?email={Uri.EscapeDataString(address[..^1])}");
|
||||
|
||||
prefix.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The key log has to verify from genesis with the hashes the server publishes, because that is the
|
||||
/// whole of what a client can check. A chain that only the server could reproduce would make key
|
||||
/// transparency a claim rather than a mechanism.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheKeyLog_ChainsFromGenesisWithTheHashesItPublishes()
|
||||
{
|
||||
var client = await EnrolledClientAsync("keylog-reader", "keylog@example.com");
|
||||
|
||||
var page = await ReadAsync<KeyLogPage>(client, "/api/v1/keylog?after=0");
|
||||
|
||||
page.Entries.ShouldNotBeEmpty();
|
||||
|
||||
var previous = Crypto.KeyLogChain.CreateGenesisPreviousHash();
|
||||
|
||||
foreach (var entry in page.Entries)
|
||||
{
|
||||
entry.PreviousHash.ShouldBe(previous);
|
||||
|
||||
Crypto.KeyLogChain.ComputeEntryHash(
|
||||
entry.PreviousHash,
|
||||
entry.UserId,
|
||||
entry.Generation,
|
||||
entry.EncryptionPublicKey,
|
||||
entry.SigningPublicKey,
|
||||
entry.StatementSignature,
|
||||
entry.CreatedAt).ShouldBe(entry.Hash);
|
||||
|
||||
previous = entry.Hash;
|
||||
}
|
||||
|
||||
// And the head the page reports is the last link, or a client that paged to the end could not
|
||||
// tell whether it had seen the whole log.
|
||||
if (!page.HasMore)
|
||||
{
|
||||
page.Head.ShouldBe(previous);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<HttpClient> EnrolledClientAsync(string subject, string? email = null)
|
||||
{
|
||||
var unique = $"{subject}-{Guid.CreateVersion7():N}";
|
||||
var address = email is null ? null : $"{Guid.CreateVersion7():N}-{email}";
|
||||
|
||||
using var enrollment = new TestEnrollment(fixture.IdentityProvider, unique, address);
|
||||
|
||||
var client = fixture.CreateClientFor(unique, address);
|
||||
|
||||
var response = await client.PostContractAsync(EnrollUrl, enrollment.Build());
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
// The address is remembered on the client so a later directory lookup can name it: the tests
|
||||
// uniquify addresses so that runs against a shared container cannot collide.
|
||||
if (address is not null)
|
||||
{
|
||||
addresses[email!] = address;
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
/// <summary>Uniquified addresses, keyed on the readable one a test wrote.</summary>
|
||||
private readonly Dictionary<string, string> addresses = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <remarks>
|
||||
/// The slug is generated rather than derived from the name, because a slug is lowercase letters,
|
||||
/// digits and hyphens and a display name is not — deriving one would make these tests depend on a
|
||||
/// transformation the product does not perform. It is uniquified because the container is shared
|
||||
/// across every class in this assembly and the slug is unique deployment-wide.
|
||||
/// </remarks>
|
||||
private Task<TeamSummary> CreateTeamAsync(HttpClient client, string name) =>
|
||||
PostAsync<CreateTeamRequest, TeamSummary>(
|
||||
client,
|
||||
TeamsUrl,
|
||||
new CreateTeamRequest(
|
||||
Guid.CreateVersion7(), name, $"team-{Guid.CreateVersion7():N}", null));
|
||||
|
||||
/// <remarks>
|
||||
/// The wrapped key and the signature are the right shape and nothing more. The server stores both
|
||||
/// opaquely and verifies neither — see docs/crypto.md §6 — so a real seal here would be testing the
|
||||
/// crypto library rather than the endpoint.
|
||||
/// </remarks>
|
||||
private async Task<Guid> CreateVaultAsync(HttpClient client, Guid teamId)
|
||||
{
|
||||
var vault = await PostAsync<CreateTeamVaultRequest, VaultSummary>(
|
||||
client,
|
||||
$"{TeamsUrl}/{teamId}/vaults",
|
||||
new CreateTeamVaultRequest(
|
||||
Guid.CreateVersion7(),
|
||||
"Team vault",
|
||||
WrappedVaultKey: new byte[110],
|
||||
GrantSignature: new byte[64],
|
||||
GrantedAt: DateTimeOffset.UnixEpoch));
|
||||
|
||||
return vault.VaultId;
|
||||
}
|
||||
|
||||
private async Task<DirectoryEntry> LookupAsync(HttpClient client, string email)
|
||||
{
|
||||
var address = addresses.GetValueOrDefault(email, email);
|
||||
|
||||
var found = await ReadAsync<IReadOnlyList<DirectoryEntry>>(
|
||||
client, $"/api/v1/directory?email={Uri.EscapeDataString(address)}");
|
||||
|
||||
return found.ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
private static async Task AddMemberAsync(
|
||||
HttpClient client,
|
||||
Guid teamId,
|
||||
Guid userId,
|
||||
TeamMemberRole role)
|
||||
{
|
||||
var response = await client.PostContractAsync(
|
||||
$"{TeamsUrl}/{teamId}/members", new AddTeamMemberRequest(userId, role));
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
private static async Task<TResponse> PostAsync<TRequest, TResponse>(
|
||||
HttpClient client,
|
||||
string url,
|
||||
TRequest body)
|
||||
{
|
||||
var response = await client.PostContractAsync(url, body);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return (await response.Content.ReadContractAsync<TResponse>())!;
|
||||
}
|
||||
|
||||
private static async Task<T> ReadAsync<T>(HttpClient client, string url)
|
||||
{
|
||||
var response = await client.GetAsync(
|
||||
new Uri(url, UriKind.Relative), TestContext.Current.CancellationToken);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return (await response.Content.ReadContractAsync<T>())!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using DodoSSH.Contracts;
|
||||
using DodoSSH.Domain;
|
||||
using DodoSSH.Domain.Authorization;
|
||||
|
||||
namespace DodoSSH.Api.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The team enums on the wire and the ones in the domain have to agree, and nothing but this makes them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The same hazard <c>EntityTypeAlignmentTests</c> exists for, one feature along and with a worse failure.
|
||||
/// <c>TeamService</c> maps <see cref="TeamMemberRole"/> to <see cref="TeamRole"/> member by member, so a
|
||||
/// renumbering on one side does not fail to compile — it silently changes what a role means. Someone
|
||||
/// added as a viewer would come back as an admin, or the reverse, on the next deployment.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Mapped by <em>name</em> in the service and asserted by <em>value</em> here, which is the pairing that
|
||||
/// catches the mistake: the service would go on compiling after a renumbering, and this would not go on
|
||||
/// passing.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class TeamEnumAlignmentTests
|
||||
{
|
||||
[Fact]
|
||||
public void EveryWireRole_HasADomainRoleWithTheSameValue()
|
||||
{
|
||||
((int)TeamMemberRole.Unspecified).ShouldBe((int)TeamRole.Unspecified);
|
||||
((int)TeamMemberRole.Viewer).ShouldBe((int)TeamRole.Viewer);
|
||||
((int)TeamMemberRole.Member).ShouldBe((int)TeamRole.Member);
|
||||
((int)TeamMemberRole.Admin).ShouldBe((int)TeamRole.Admin);
|
||||
((int)TeamMemberRole.Owner).ShouldBe((int)TeamRole.Owner);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheTwoRoleEnums_HaveTheSameNumberOfMembers() =>
|
||||
Enum.GetValues<TeamMemberRole>().Length.ShouldBe(Enum.GetValues<TeamRole>().Length);
|
||||
|
||||
[Fact]
|
||||
public void EveryWireMembershipStatus_HasADomainStatusWithTheSameValue()
|
||||
{
|
||||
((int)TeamMemberStatus.Unspecified).ShouldBe((int)MembershipStatus.Unspecified);
|
||||
((int)TeamMemberStatus.Invited).ShouldBe((int)MembershipStatus.Invited);
|
||||
((int)TeamMemberStatus.Active).ShouldBe((int)MembershipStatus.Active);
|
||||
((int)TeamMemberStatus.Revoked).ShouldBe((int)MembershipStatus.Revoked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryWireGrantState_HasADomainStateWithTheSameValue()
|
||||
{
|
||||
((int)VaultGrantState.Unspecified).ShouldBe((int)GrantState.Unspecified);
|
||||
((int)VaultGrantState.Active).ShouldBe((int)GrantState.Active);
|
||||
((int)VaultGrantState.AwaitingRewrap).ShouldBe((int)GrantState.AwaitingRewrap);
|
||||
((int)VaultGrantState.Revoked).ShouldBe((int)GrantState.Revoked);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <c>VaultSummary.Permissions</c> is an opaque int on the wire, on purpose — the flags live in
|
||||
/// <c>DodoSSH.Domain</c> and no client project references that assembly. So the client repeats the one
|
||||
/// bit it needs as a literal in <c>StoredVault.CanWrite</c>, and this pins the value it copied.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Asserted here rather than against <c>StoredVault</c> itself, which would mean a server test project
|
||||
/// taking a reference on a client assembly to check a constant. The consequence of drift is worth the
|
||||
/// literal either way: a Save button offered to somebody who is only a viewer of a team vault, ending
|
||||
/// in a 403 they can do nothing about — or no Save button for somebody who may write.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void TheWriteFlag_IsTheBitTheClientCopied()
|
||||
{
|
||||
((int)PermissionFlags.Read).ShouldBe(1 << 0);
|
||||
((int)PermissionFlags.Write).ShouldBe(1 << 1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user