using System.Net; using System.Net.Http.Json; using DodoSSH.Contracts; namespace DodoSSH.Api.Tests; /// /// Teams, membership, invitations and the vault key grants that make a team vault readable. /// /// /// /// The half of M3 that runs on the server, which is the half that decides what will be served. /// 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. /// /// /// 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. /// /// /// Two boundaries in here are load-bearing beyond their own endpoint, and both are of that invisible /// kind. An admin who can archive a team or hand it away is an admin who can take it from the person /// who promoted them, and nothing about the response would say so. An invitation claimed on an address /// the identity provider never vouched for is a way into somebody else's team, and every other part of /// that request succeeds. Neither has a symptom; each has a test. /// /// [Collection(ApiCollection.Name)] public sealed class TeamEndpointTests(ApiFixture fixture) { private const string TeamsUrl = "/api/v1/teams"; private const string MeUrl = "/api/v1/me"; private const string EnrollUrl = "/api/v1/me/enrollment"; // ---- Creating and listing ---- [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>(client, TeamsUrl); listed.ShouldContain(row => row.TeamId == team.TeamId); } /// /// 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. /// [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(client, TeamsUrl, request); var second = await PostAsync(client, TeamsUrl, request); second.TeamId.ShouldBe(first.TeamId); var listed = await ReadAsync>(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( client, TeamsUrl, new CreateTeamRequest(Guid.CreateVersion7(), "First", slug, null)); var response = await client.PostContractAsync( TeamsUrl, new CreateTeamRequest(Guid.CreateVersion7(), "Second", slug, null)); await ShouldBeProblemAsync(response, HttpStatusCode.Conflict, ProblemCodes.TeamSlugTaken); } /// /// 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. /// [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 GetAsync(stranger, MembersUrl(team.TeamId)); var invented = await GetAsync(stranger, MembersUrl(Guid.CreateVersion7())); real.StatusCode.ShouldBe(HttpStatusCode.NotFound); invented.StatusCode.ShouldBe(real.StatusCode); } // ---- Renaming ---- /// /// The rename is read back from the list rather than from the response body, because the list is /// what a member's screen is built from and it is assembled by different code. The slug is asserted /// unchanged because it is unique only among live teams: a rename that moved it could take a slug an /// archived team still holds, and that archived team could then never be brought back. /// [Fact] public async Task RenamingATeam_ChangesTheListedNameAndLeavesTheSlugAlone() { var owner = await EnrolledClientAsync("rename-owner"); var team = await CreateTeamAsync(owner, "Before"); var response = await owner.PutContractAsync( TeamUrl(team.TeamId), new UpdateTeamRequest("After", "A description it did not have.")); response.StatusCode.ShouldBe(HttpStatusCode.OK); var listed = await ReadAsync>(owner, TeamsUrl); var renamed = listed.Where(row => row.TeamId == team.TeamId).ShouldHaveSingleItem(); renamed.Name.ShouldBe("After"); renamed.Description.ShouldBe("A description it did not have."); renamed.Slug.ShouldBe(team.Slug); } /// /// Null clears the description rather than leaving it alone. A PUT that treated an absent value as /// "no change" would make a cleared description impossible to express at all, since there is no /// other verb for it. /// [Fact] public async Task RenamingWithoutADescription_ClearsTheOneThatWasThere() { var owner = await EnrolledClientAsync("rename-clear-owner"); var team = await PostAsync( owner, TeamsUrl, new CreateTeamRequest( Guid.CreateVersion7(), "Described", $"team-{Guid.CreateVersion7():N}", "Something.")); team.Description.ShouldBe("Something."); var response = await owner.PutContractAsync( TeamUrl(team.TeamId), new UpdateTeamRequest("Described", null)); response.EnsureSuccessStatusCode(); var listed = await ReadAsync>(owner, TeamsUrl); listed.Where(row => row.TeamId == team.TeamId) .ShouldHaveSingleItem() .Description.ShouldBeNull(); } /// /// A member is refused with 403 rather than 404: the team is visible to them, so naming the reason /// leaks nothing and "you are not an admin" is a more useful answer than "no such team". /// [Fact] public async Task APlainMember_CanRenameNothingAndCanNeitherArchiveNorHandOverTheTeam() { var owner = await EnrolledClientAsync("member-limits-owner", "mlowner@example.com"); var member = await EnrolledClientAsync("member-limits-member", "mlmember@example.com"); var team = await CreateTeamAsync(owner, "Limits"); var entry = await LookupAsync(owner, "mlmember@example.com"); var memberMe = await ReadAsync(member, MeUrl); await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member); var renamed = await member.PutContractAsync( TeamUrl(team.TeamId), new UpdateTeamRequest("Theirs now", null)); await ShouldBeProblemAsync(renamed, HttpStatusCode.Forbidden, ProblemCodes.Forbidden); var archived = await DeleteAsync(member, TeamUrl(team.TeamId)); await ShouldBeProblemAsync(archived, HttpStatusCode.Forbidden, ProblemCodes.Forbidden); var transferred = await member.PostContractAsync( OwnerUrl(team.TeamId), new TransferTeamOwnershipRequest(memberMe.UserId)); await ShouldBeProblemAsync(transferred, HttpStatusCode.Forbidden, ProblemCodes.Forbidden); } /// /// /// The TeamAccess.IsOwner boundary, and the most important authorization test in this /// feature. An admin may manage members and vaults, and that is deliberately not the same /// permission as deciding whether the team continues to exist or who controls it. If either of /// these checks were widened to CanAdminister — which is what every neighbouring endpoint /// uses, so it is the easy mistake — anybody the owner promoted could archive the team out from /// under them or take it outright, and nothing in the response of any other test would change. /// /// /// The rename is asserted in the same test on purpose: it is what shows the admin genuinely holds /// administrative rights here, so the two refusals are the boundary rather than a broken role. /// /// [Fact] public async Task AnAdminWhoIsNotTheOwner_MayRenameButMayNotArchiveOrHandOverTheTeam() { var owner = await EnrolledClientAsync("admin-boundary-owner", "abowner@example.com"); var admin = await EnrolledClientAsync("admin-boundary-admin", "abadmin@example.com"); var team = await CreateTeamAsync(owner, "Boundary"); var entry = await LookupAsync(owner, "abadmin@example.com"); var adminMe = await ReadAsync(admin, MeUrl); await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Admin); // They really are an admin: this one succeeds. var renamed = await admin.PutContractAsync( TeamUrl(team.TeamId), new UpdateTeamRequest("Renamed by an admin", null)); renamed.StatusCode.ShouldBe(HttpStatusCode.OK); var archived = await DeleteAsync(admin, TeamUrl(team.TeamId)); await ShouldBeProblemAsync(archived, HttpStatusCode.Forbidden, ProblemCodes.Forbidden); var transferred = await admin.PostContractAsync( OwnerUrl(team.TeamId), new TransferTeamOwnershipRequest(adminMe.UserId)); await ShouldBeProblemAsync(transferred, HttpStatusCode.Forbidden, ProblemCodes.Forbidden); // And nothing moved: the team is still there and still owned by the person who made it. var members = await ReadAsync>( owner, MembersUrl(team.TeamId)); var ownerMe = await ReadAsync(owner, MeUrl); members.Where(row => row.UserId == ownerMe.UserId) .ShouldHaveSingleItem() .Role.ShouldBe(TeamMemberRole.Owner); } // ---- Renaming a vault ---- /// /// /// The rename the vaults screen offers, and the assertion that matters is the second one: the team is /// renamed with the vault when it owns nothing else. A vault made from that screen gets a team of its /// own that nobody was ever shown, so a rename that moved only the vault would leave the operator, the /// logs and the database naming it something no user recognises. /// /// /// The slug is asserted unchanged in the same breath. It is unique only among live teams, so a rename /// that moved it could take one an archived team is still holding — the same limit /// UpdateTeamRequest records. /// /// [Fact] public async Task RenamingAVault_RenamesTheTeamBehindItAndLeavesItsSlugAlone() { var owner = await EnrolledClientAsync("vault-rename-owner"); var team = await CreateTeamAsync(owner, "Platform secrets"); var vaultId = await CreateVaultAsync(owner, team.TeamId); var response = await owner.PutContractAsync( VaultUrl(vaultId), new UpdateVaultRequest("Platform")); response.EnsureSuccessStatusCode(); var renamed = (await response.Content.ReadContractAsync())!; renamed.Name.ShouldBe("Platform"); var me = await ReadAsync(owner, MeUrl); me.Vaults.Single(vault => vault.VaultId == vaultId).Name.ShouldBe("Platform"); var listed = await ReadAsync>(owner, TeamsUrl); var after = listed.Single(row => row.TeamId == team.TeamId); after.Name.ShouldBe("Platform"); after.Slug.ShouldBe(team.Slug); } /// /// A team carrying several vaults has a name of its own that somebody chose, so renaming one of its /// vaults must not take it. This is the arrangement the vaults screen cannot make and does not hide; /// the server draws the same line. /// [Fact] public async Task RenamingOneOfSeveralVaults_LeavesTheTeamsOwnNameAlone() { var owner = await EnrolledClientAsync("vault-rename-shared-owner"); var team = await CreateTeamAsync(owner, "Platform Engineering"); var vaultId = await CreateVaultAsync(owner, team.TeamId); await CreateVaultAsync(owner, team.TeamId); var response = await owner.PutContractAsync( VaultUrl(vaultId), new UpdateVaultRequest("Production")); response.EnsureSuccessStatusCode(); var listed = await ReadAsync>(owner, TeamsUrl); listed.Single(row => row.TeamId == team.TeamId).Name.ShouldBe("Platform Engineering"); } /// /// Admin rather than Write, and the line is the one the team rename draws: a name is what everybody in /// the vault sees it called, so a member who may add hosts to it may not rename it out from under them. /// A member is refused with 403 rather than 404 because the vault is visible to them, so naming the /// reason leaks nothing. /// [Fact] public async Task APlainMember_CannotRenameAVaultTheyCanWriteTo() { var owner = await EnrolledClientAsync("vault-rename-limits-owner", "vrowner@example.com"); var member = await EnrolledClientAsync("vault-rename-limits-member", "vrmember@example.com"); var team = await CreateTeamAsync(owner, "Limits"); var vaultId = await CreateVaultAsync(owner, team.TeamId); var entry = await LookupAsync(owner, "vrmember@example.com"); await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member); var response = await member.PutContractAsync( VaultUrl(vaultId), new UpdateVaultRequest("Theirs now")); await ShouldBeProblemAsync(response, HttpStatusCode.Forbidden, ProblemCodes.Forbidden); } /// /// An outsider gets 404 rather than 403, which is the rule IVaultAccessService states: a /// distinct "exists but forbidden" answer is an existence oracle for other tenants' vault ids. /// [Fact] public async Task RenamingSomebodyElsesVault_IsNotFound() { var owner = await EnrolledClientAsync("vault-rename-outsider-owner"); var outsider = await EnrolledClientAsync("vault-rename-outsider"); var team = await CreateTeamAsync(owner, "Private"); var vaultId = await CreateVaultAsync(owner, team.TeamId); var response = await outsider.PutContractAsync( VaultUrl(vaultId), new UpdateVaultRequest("Mine now")); response.StatusCode.ShouldBe(HttpStatusCode.NotFound); } /// /// An empty name is refused rather than stored, because a vault has to be pickable by name before /// anything in it is decrypted — one called nothing is one nobody can choose. /// [Fact] public async Task RenamingAVaultToNothing_IsRefused() { var owner = await EnrolledClientAsync("vault-rename-empty-owner"); var team = await CreateTeamAsync(owner, "Named"); var vaultId = await CreateVaultAsync(owner, team.TeamId); var response = await owner.PutContractAsync(VaultUrl(vaultId), new UpdateVaultRequest(" ")); await ShouldBeProblemAsync(response, HttpStatusCode.BadRequest, ProblemCodes.InvalidTeam); } // ---- Archiving ---- /// /// Archiving hides a team from every member's list at once, and a team vault resolves through /// membership — so archiving one that still owned vaults would take those vaults away from /// everybody holding a key, including the caller, with no way back because nothing in this product /// deletes a vault. The refusal is the end of that road rather than a step on it, which is why the /// team is asserted still listed afterwards. /// [Fact] public async Task ArchivingATeamThatOwnsAVault_IsRefusedAndLeavesTheTeamListed() { var owner = await EnrolledClientAsync("archive-vault-owner"); var team = await CreateTeamAsync(owner, "Occupied"); await CreateVaultAsync(owner, team.TeamId); var response = await DeleteAsync(owner, TeamUrl(team.TeamId)); await ShouldBeProblemAsync(response, HttpStatusCode.Conflict, ProblemCodes.TeamNotEmpty); var listed = await ReadAsync>(owner, TeamsUrl); listed.ShouldContain(row => row.TeamId == team.TeamId); } /// /// Every member's list, not just the caller's. Memberships are archived with the team in the same /// transaction, and a live membership pointing at an archived team would leave the other member /// still seeing it — which is the shape a half-applied archive takes. /// [Fact] public async Task ArchivingAnEmptyTeam_RemovesItFromEveryMembersList() { var owner = await EnrolledClientAsync("archive-owner", "aowner@example.com"); var member = await EnrolledClientAsync("archive-member", "amember@example.com"); var team = await CreateTeamAsync(owner, "Wound up"); var entry = await LookupAsync(owner, "amember@example.com"); await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member); (await ReadAsync>(member, TeamsUrl)) .ShouldContain(row => row.TeamId == team.TeamId); var response = await DeleteAsync(owner, TeamUrl(team.TeamId)); response.StatusCode.ShouldBe(HttpStatusCode.NoContent); (await ReadAsync>(owner, TeamsUrl)) .ShouldNotContain(row => row.TeamId == team.TeamId); (await ReadAsync>(member, TeamsUrl)) .ShouldNotContain(row => row.TeamId == team.TeamId); // And it is gone the way a team nobody is in is gone, rather than merely unlisted. (await GetAsync(owner, MembersUrl(team.TeamId))).StatusCode.ShouldBe(HttpStatusCode.NotFound); } // ---- Ownership ---- /// /// Both roles, because either alone would pass while the team was broken. Asserting only /// that the recipient is now owner would pass with the team owned twice; asserting only that the /// outgoing owner is an admin would pass with it owned by nobody. Ownership is sole and the two /// writes are one transaction precisely so that neither of those states can exist, so the count of /// owners is asserted too. /// [Fact] public async Task TransferringOwnership_MakesTheTargetOwnerAndTheOutgoingOwnerAnAdmin() { var owner = await EnrolledClientAsync("transfer-owner", "towner@example.com"); var successor = await EnrolledClientAsync("transfer-successor", "tsuccessor@example.com"); var team = await CreateTeamAsync(owner, "Handover"); var ownerMe = await ReadAsync(owner, MeUrl); var entry = await LookupAsync(owner, "tsuccessor@example.com"); await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member); var response = await owner.PostContractAsync( OwnerUrl(team.TeamId), new TransferTeamOwnershipRequest(entry.UserId)); response.StatusCode.ShouldBe(HttpStatusCode.NoContent); var members = await ReadAsync>( owner, MembersUrl(team.TeamId)); members.Where(row => row.UserId == entry.UserId) .ShouldHaveSingleItem() .Role.ShouldBe(TeamMemberRole.Owner); members.Where(row => row.UserId == ownerMe.UserId) .ShouldHaveSingleItem() .Role.ShouldBe(TeamMemberRole.Admin, "the outgoing owner is demoted, not removed"); members.Count(row => row.Role == TeamMemberRole.Owner).ShouldBe(1); // And the recipient is told so by the endpoint their own screen reads. (await ReadAsync>(successor, TeamsUrl)) .Where(row => row.TeamId == team.TeamId) .ShouldHaveSingleItem() .Role.ShouldBe(TeamMemberRole.Owner); } /// /// The thing that was impossible before this endpoint existed. An owner could not be removed and /// could not be demoted, so somebody who left the company owning a team left it owned by them for /// ever — a state only an operator with database access could fix. /// [Fact] public async Task AfterATransfer_TheFormerOwnerCanFinallyBeRemoved() { var owner = await EnrolledClientAsync("departing-owner", "downer@example.com"); var successor = await EnrolledClientAsync("departing-successor", "dsuccessor@example.com"); var team = await CreateTeamAsync(owner, "Departure"); var ownerMe = await ReadAsync(owner, MeUrl); var entry = await LookupAsync(owner, "dsuccessor@example.com"); await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member); var transferred = await owner.PostContractAsync( OwnerUrl(team.TeamId), new TransferTeamOwnershipRequest(entry.UserId)); transferred.StatusCode.ShouldBe(HttpStatusCode.NoContent); var removed = await DeleteAsync(successor, MemberUrl(team.TeamId, ownerMe.UserId)); removed.StatusCode.ShouldBe(HttpStatusCode.NoContent); (await ReadAsync>(owner, TeamsUrl)) .ShouldNotContain(row => row.TeamId == team.TeamId); } /// /// Adding somebody and handing them the team in one step would let an id supplied once take it, so /// the recipient has to be an active member already. This is the same refusal that stops a stranger /// being made owner by pasting their id. /// [Fact] public async Task TransferringToSomebodyWhoIsNotAMember_IsRefused() { var owner = await EnrolledClientAsync("transfer-closed-owner", "tcowner@example.com"); await EnrolledClientAsync("transfer-outsider", "toutsider@example.com"); var team = await CreateTeamAsync(owner, "Closed handover"); var entry = await LookupAsync(owner, "toutsider@example.com"); var response = await owner.PostContractAsync( OwnerUrl(team.TeamId), new TransferTeamOwnershipRequest(entry.UserId)); await ShouldBeProblemAsync(response, HttpStatusCode.BadRequest, ProblemCodes.InvalidTeam); } [Fact] public async Task TransferringToYourself_IsRefused() { var owner = await EnrolledClientAsync("transfer-self-owner"); var team = await CreateTeamAsync(owner, "Already mine"); var me = await ReadAsync(owner, MeUrl); var response = await owner.PostContractAsync( OwnerUrl(team.TeamId), new TransferTeamOwnershipRequest(me.UserId)); await ShouldBeProblemAsync(response, HttpStatusCode.BadRequest, ProblemCodes.InvalidTeam); } // ---- Membership ---- /// /// The membership half of M3 in one test: a team vault appears in the other member's /me the /// moment they are added, and it appears without 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. /// [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(member, MeUrl); 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); } /// /// /// An account exists from its owner's first authenticated request and publishes no key until they /// enroll, and the directory omits it for that whole window — deliberately, because an entry exists /// to be wrapped to. So the lookup is asserted empty first: that is not a missing account, and a /// caller that read it as one would invite an address that already has one. /// /// /// Adding by address is what covers the gap, and the member it produces says IsEnrolled /// false. Membership is authorization and grants nothing readable, so there is nothing inconsistent /// about a member with no key — it is the state everybody passes through. /// /// [Fact] public async Task AnAccountThatHasNotEnrolled_CanBeAddedByAddressThoughTheDirectoryOmitsIt() { var owner = await EnrolledClientAsync("unenrolled-owner", "uowner@example.com"); var address = NewAddress(); var (_, userId) = await SignInAsync(address); var team = await CreateTeamAsync(owner, "Newcomers"); var found = await ReadAsync>( owner, $"/api/v1/directory?email={Uri.EscapeDataString(address)}"); found.ShouldBeEmpty("they have published no key, so there is nothing to wrap to"); var member = await PostAsync( owner, MembersUrl(team.TeamId), new AddTeamMemberRequest(Guid.Empty, TeamMemberRole.Member, address)); member.UserId.ShouldBe(userId); member.IsEnrolled.ShouldBeFalse(); member.Role.ShouldBe(TeamMemberRole.Member); var listed = await ReadAsync>( owner, MembersUrl(team.TeamId)); listed.ShouldContain(row => row.UserId == userId && !row.IsEnrolled); } /// /// The other side of it. An address with no account is refused under its own code rather than the /// general one, because the caller can act on it unprompted — there is nobody to add, so invite /// them — and a code shared with a rejected role would leave them guessing which had happened. /// [Fact] public async Task AddingAnAddressWithNoAccount_IsRefusedWithItsOwnCode() { var owner = await EnrolledClientAsync("no-account-owner", "naowner@example.com"); var team = await CreateTeamAsync(owner, "Nobody"); var response = await owner.PostContractAsync( MembersUrl(team.TeamId), new AddTeamMemberRequest(Guid.Empty, TeamMemberRole.Member, NewAddress())); await ShouldBeProblemAsync( response, HttpStatusCode.NotFound, ProblemCodes.NoSuchAccount); } /// /// 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. /// [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([])); await ShouldBeProblemAsync(push, HttpStatusCode.Forbidden, ProblemCodes.Forbidden); } /// /// And the reverse, which is what removal has to mean: the vault stops being served at all. Note what /// is not asserted — that they have forgotten anything. They have not, and ADR 0001 says so. /// [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(member, MeUrl); before.Vaults.ShouldContain(summary => summary.VaultId == vaultId); var removed = await DeleteAsync(owner, MemberUrl(team.TeamId, entry.UserId)); removed.StatusCode.ShouldBe(HttpStatusCode.NoContent); var after = await ReadAsync(member, MeUrl); 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); } /// /// 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. /// [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 DeleteAsync(owner, MemberUrl(team.TeamId, entry.UserId)); var grants = await ReadAsync(owner, $"/api/v1/vaults/{vaultId}/grants"); grants.RekeyRequired.ShouldBeTrue(); } /// /// The owner cannot be removed even now that ownership can be handed over, and this is the standing /// guarantee rather than a leftover: transferring is what makes the team's next owner exist, so /// removing the current one first would still leave it with nobody who can manage it. 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. /// [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(owner, MeUrl); var response = await DeleteAsync(owner, MemberUrl(team.TeamId, me.UserId)); await ShouldBeProblemAsync(response, HttpStatusCode.Conflict, ProblemCodes.LastTeamOwner); } /// /// The other half of the same guarantee, and the one a transfer endpoint could plausibly have /// loosened. Demoting the owner through the role endpoint cannot appoint a replacement in the same /// breath, so it would leave the team ownerless — which is exactly the state /// TransferOwnershipAsync does two writes in one transaction to avoid. /// [Fact] public async Task TheOwner_CannotBeDemotedOnTheirOwn() { var owner = await EnrolledClientAsync("demote-owner", "demote@example.com"); var team = await CreateTeamAsync(owner, "Undemotable"); var me = await ReadAsync(owner, MeUrl); var response = await owner.PutContractAsync( MemberRoleUrl(team.TeamId, me.UserId), new ChangeTeamMemberRoleRequest(TeamMemberRole.Admin)); await ShouldBeProblemAsync(response, HttpStatusCode.Conflict, ProblemCodes.LastTeamOwner); var members = await ReadAsync>( owner, MembersUrl(team.TeamId)); members.Where(row => row.UserId == me.UserId) .ShouldHaveSingleItem() .Role.ShouldBe(TeamMemberRole.Owner); } /// /// A real value, not a placeholder. LastActiveAt is the field the teams screen uses to say /// whether a colleague has been here at all, and the failure it guards against is the one that made /// the column impossible to offer before: a null for somebody who has plainly been making requests /// reads as "never", which is a lie about a person. /// [Fact] public async Task AMemberListing_ReportsWhenAnAccountWasLastActive() { var owner = await EnrolledClientAsync("last-seen-owner"); var team = await CreateTeamAsync(owner, "Last seen"); var me = await ReadAsync(owner, MeUrl); var members = await ReadAsync>( owner, MembersUrl(team.TeamId)); var self = members.Where(row => row.UserId == me.UserId).ShouldHaveSingleItem(); self.LastActiveAt.ShouldNotBeNull( "this account has made several authenticated requests already"); // Within the window the server writes on, which is the whole of the precision this carries. self.LastActiveAt.Value.ShouldBeGreaterThan(TimeProvider.System.GetUtcNow().AddHours(-1)); } // ---- Invitations ---- /// /// The expiry is asserted rather than merely present. An invitation that never lapsed would be a /// standing offer against an address, and company addresses are handed to the next person to hold /// the job — so the person who inherits the mailbox would inherit the team. /// [Fact] public async Task InvitingAnAddress_ListsItAsPendingWithItsRoleAndAnExpiry() { var owner = await EnrolledClientAsync("invite-owner"); var address = NewAddress(); var team = await CreateTeamAsync(owner, "Invitations"); var created = await InviteAsync(owner, team.TeamId, address, TeamMemberRole.Admin); var listed = await FindInvitationAsync(owner, team.TeamId, created.InvitationId); listed.Email.ShouldBe(address); listed.Role.ShouldBe(TeamMemberRole.Admin); listed.State.ShouldBe(TeamInvitationState.Pending); listed.AcceptedAt.ShouldBeNull(); (listed.ExpiresAt - listed.CreatedAt).ShouldBe(TimeSpan.FromDays(14)); } /// /// The same body twice, as a client whose response was lost would send it — the shape team and /// vault creation already have. Two invitations to one address would show the same person twice on /// the teams screen and take two revocations to withdraw. /// [Fact] public async Task RepeatingAnInvitation_ReturnsTheSameOneRatherThanASecond() { var owner = await EnrolledClientAsync("invite-retry-owner"); var address = NewAddress(); var team = await CreateTeamAsync(owner, "Retried invitations"); var request = new CreateTeamInvitationRequest( Guid.CreateVersion7(), address, TeamMemberRole.Member); var first = await PostAsync( owner, InvitationsUrl(team.TeamId), request); var second = await PostAsync( owner, InvitationsUrl(team.TeamId), request); second.InvitationId.ShouldBe(first.InvitationId); var listed = await ReadAsync>( owner, InvitationsUrl(team.TeamId)); // Case-insensitively, because the column is citext and two addresses differing only in case // are one address — a second row under a different casing would still be a second invitation. listed.Count(row => string.Equals(row.Email, address, StringComparison.OrdinalIgnoreCase)) .ShouldBe(1); } [Fact] public async Task ASecondInvitationToTheSameAddress_IsRefused() { var owner = await EnrolledClientAsync("invite-duplicate-owner"); var address = NewAddress(); var team = await CreateTeamAsync(owner, "Duplicate invitations"); await InviteAsync(owner, team.TeamId, address); // A different id, so this is a second invitation rather than a retry of the first. var response = await owner.PostContractAsync( InvitationsUrl(team.TeamId), new CreateTeamInvitationRequest(Guid.CreateVersion7(), address, TeamMemberRole.Admin)); await ShouldBeProblemAsync( response, HttpStatusCode.BadRequest, ProblemCodes.InvalidTeamInvitation); } /// /// Ownership is sole and is handed over deliberately. An invitation that conferred it would let an /// address typed once take the team the moment somebody signed in with it — and the invitee is by /// definition somebody nobody here has met. /// [Fact] public async Task InvitingSomebodyAsOwner_IsRefused() { var owner = await EnrolledClientAsync("invite-owner-role-owner"); var team = await CreateTeamAsync(owner, "Not for sale"); var response = await owner.PostContractAsync( InvitationsUrl(team.TeamId), new CreateTeamInvitationRequest( Guid.CreateVersion7(), NewAddress(), TeamMemberRole.Owner)); await ShouldBeProblemAsync( response, HttpStatusCode.BadRequest, ProblemCodes.InvalidTeamInvitation); } [Fact] public async Task AMalformedAddress_IsRefused() { var owner = await EnrolledClientAsync("invite-malformed-owner"); var team = await CreateTeamAsync(owner, "Shapes"); var response = await owner.PostContractAsync( InvitationsUrl(team.TeamId), new CreateTeamInvitationRequest( Guid.CreateVersion7(), "not an address", TeamMemberRole.Member)); await ShouldBeProblemAsync( response, HttpStatusCode.BadRequest, ProblemCodes.InvalidTeamInvitation); } /// /// A withdrawn invitation stays in the listing rather than vanishing, so the screen can show that it /// was withdrawn rather than letting it read as never sent. Withdrawing it twice is 404, for the /// reason revoking a device grant gives: a caller driving towards "that invitation will not let /// anybody in" can treat 404 as having arrived. /// [Fact] public async Task RevokingAnInvitation_MarksItRevokedAndCannotBeDoneTwice() { var owner = await EnrolledClientAsync("invite-revoke-owner"); var team = await CreateTeamAsync(owner, "Withdrawals"); var invitation = await InviteAsync(owner, team.TeamId, NewAddress()); var revoked = await DeleteAsync( owner, InvitationUrl(team.TeamId, invitation.InvitationId)); revoked.StatusCode.ShouldBe(HttpStatusCode.NoContent); var listed = await FindInvitationAsync(owner, team.TeamId, invitation.InvitationId); listed.State.ShouldBe(TeamInvitationState.Revoked); var again = await DeleteAsync(owner, InvitationUrl(team.TeamId, invitation.InvitationId)); again.StatusCode.ShouldBe(HttpStatusCode.NotFound); } /// /// An invitation is a fact about the team, so any member may read the list — whoever is about to be /// handed a vault key needs to see who else is on their way in — but only an admin may write one. /// [Fact] public async Task APlainMember_MayReadInvitationsAndMayNotIssueThem() { var owner = await EnrolledClientAsync("invite-reader-owner", "irowner@example.com"); var member = await EnrolledClientAsync("invite-reader-member", "irmember@example.com"); var team = await CreateTeamAsync(owner, "Readable invitations"); var entry = await LookupAsync(owner, "irmember@example.com"); await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member); var invitation = await InviteAsync(owner, team.TeamId, NewAddress()); var listed = await ReadAsync>( member, InvitationsUrl(team.TeamId)); listed.ShouldContain(row => row.InvitationId == invitation.InvitationId); var refused = await member.PostContractAsync( InvitationsUrl(team.TeamId), new CreateTeamInvitationRequest( Guid.CreateVersion7(), NewAddress(), TeamMemberRole.Member)); await ShouldBeProblemAsync(refused, HttpStatusCode.Forbidden, ProblemCodes.Forbidden); } // ---- Claiming an invitation at sign-in ---- /// /// The heart of the feature, and the only path by which an invitation becomes anything. There is no /// token and no mail: the row says "the next account to sign in with this address joins this team", /// and just-in-time provisioning is what reads it. What it creates is a membership and not a key — /// the vault key still has to be wrapped to them from a machine that holds one. /// [Fact] public async Task SigningInWithAnInvitedAddress_JoinsTheTeamAndMarksTheInvitationAccepted() { var owner = await EnrolledClientAsync("claim-owner"); var address = NewAddress(); var team = await CreateTeamAsync(owner, "Claimed"); var invitation = await InviteAsync(owner, team.TeamId, address, TeamMemberRole.Member); var (invitee, inviteeUserId) = await SignInAsync(address); (await ReadAsync>(invitee, TeamsUrl)) .Where(row => row.TeamId == team.TeamId) .ShouldHaveSingleItem() .Role.ShouldBe(TeamMemberRole.Member); var members = await ReadAsync>( owner, MembersUrl(team.TeamId)); var joined = members.Where(row => row.UserId == inviteeUserId).ShouldHaveSingleItem(); joined.Status.ShouldBe(TeamMemberStatus.Active); joined.Role.ShouldBe(TeamMemberRole.Member); joined.IsEnrolled.ShouldBeFalse("membership is authorization, and they hold no key yet"); var listed = await FindInvitationAsync(owner, team.TeamId, invitation.InvitationId); listed.State.ShouldBe(TeamInvitationState.Accepted); listed.AcceptedAt.ShouldNotBeNull(); } /// /// The security test this feature stands on. An invitation is authorization: claiming one is /// what decides that this server will serve somebody a team's vaults. An address the identity /// provider has not vouched for is an address anybody able to obtain a token can name, so an /// unverified one must confer nothing — that is the same attack AllowEmailLinking exists to /// refuse, arriving by a different door. The failure would be silent by construction: the account is /// provisioned either way and every other part of the request succeeds, so nothing but this would /// notice that the wrong person had walked into the team. /// [Fact] public async Task AnAddressTheProviderHasNotVerified_ClaimsNothing() { var owner = await EnrolledClientAsync("unverified-owner"); var address = NewAddress(); var team = await CreateTeamAsync(owner, "Verified only"); var invitation = await InviteAsync(owner, team.TeamId, address); var (impostor, _) = await SignInAsync(address, emailVerified: false); (await ReadAsync>(impostor, TeamsUrl)).ShouldBeEmpty(); var listed = await FindInvitationAsync(owner, team.TeamId, invitation.InvitationId); listed.State.ShouldBe(TeamInvitationState.Pending); listed.AcceptedAt.ShouldBeNull(); } /// /// A withdrawn invitation is withdrawn, which the claim path has to honour independently — it reads /// the invitation table directly rather than going back through the endpoint that refused. /// [Fact] public async Task SigningInAfterAnInvitationWasWithdrawn_JoinsNothing() { var owner = await EnrolledClientAsync("withdrawn-owner"); var address = NewAddress(); var team = await CreateTeamAsync(owner, "Withdrawn"); var invitation = await InviteAsync(owner, team.TeamId, address); await DeleteAsync(owner, InvitationUrl(team.TeamId, invitation.InvitationId)); var (invitee, _) = await SignInAsync(address); (await ReadAsync>(invitee, TeamsUrl)).ShouldBeEmpty(); } // ---- Vault key grants ---- /// /// 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. /// [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)); await ShouldBeProblemAsync( response, HttpStatusCode.BadRequest, ProblemCodes.InvalidVaultGrant); } /// /// 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. /// [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); } /// /// The rotation itself: the generation advances, the caller holds the new key, and the flag a /// removal set is cleared because the rotation it recorded has happened. What the server cannot do /// is any part of the cryptography — the wrap arrives sealed and is stored as bytes. /// [Fact] public async Task RekeyingAVault_AdvancesTheGenerationAndWrapsItToTheCaller() { var owner = await EnrolledClientAsync("rotate-owner", "rotowner@example.com"); await EnrolledClientAsync("rotate-member", "rotmember@example.com"); var team = await CreateTeamAsync(owner, "Rotations"); var vaultId = await CreateVaultAsync(owner, team.TeamId); var entry = await LookupAsync(owner, "rotmember@example.com"); await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member); await DeleteAsync(owner, MemberUrl(team.TeamId, entry.UserId)); var rotated = await RekeyAsync(owner, vaultId, generation: 2); rotated.KeyGeneration.ShouldBe(2u); rotated.RekeyRequired.ShouldBeFalse(); var grants = await ReadAsync(owner, $"/api/v1/vaults/{vaultId}/grants"); grants.KeyGeneration.ShouldBe(2u); grants.RekeyRequired.ShouldBeFalse(); var me = await ReadAsync(owner, MeUrl); var summary = me.Vaults.Single(vault => vault.VaultId == vaultId); summary.KeyGeneration.ShouldBe(2u); summary.WrappedVaultKey.ShouldNotBeNull(); } /// /// The reason a rotation does not have to re-encrypt anything to be safe, and the reason it cannot /// throw the old grants away: every item still carries the generation it was sealed under, so the /// caller has to go on holding every key they were given or the vault's history becomes unreadable /// to the people who are still in the team. /// [Fact] public async Task ARotatedVault_StillServesTheCallerTheGenerationsItHasMovedPast() { var owner = await EnrolledClientAsync("history-owner", "histowner@example.com"); var team = await CreateTeamAsync(owner, "History"); var vaultId = await CreateVaultAsync(owner, team.TeamId); await RekeyAsync(owner, vaultId, generation: 2); await RekeyAsync(owner, vaultId, generation: 3); var me = await ReadAsync(owner, MeUrl); var summary = me.Vaults.Single(vault => vault.VaultId == vaultId); summary.KeyGeneration.ShouldBe(3u); summary.PriorKeyWraps.ShouldNotBeNull(); summary.PriorKeyWraps.Select(wrap => wrap.KeyGeneration).ShouldBe([1u, 2u]); } /// /// The sharing list answers "who can open this", so a member appears once however many generations /// they hold — and the generation on their row is the best key they have, which is what makes a row /// below the vault's own generation mean "still owed the new key". /// [Fact] public async Task TheGrantListing_ShowsAMemberOnceWithTheBestKeyTheyHold() { var owner = await EnrolledClientAsync("listing-owner", "listowner@example.com"); await EnrolledClientAsync("listing-member", "listmember@example.com"); var team = await CreateTeamAsync(owner, "Listings"); var vaultId = await CreateVaultAsync(owner, team.TeamId); var entry = await LookupAsync(owner, "listmember@example.com"); await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member); var issued = await owner.PostContractAsync( $"/api/v1/vaults/{vaultId}/grants", GrantRequest(entry, generation: 1)); issued.EnsureSuccessStatusCode(); await RekeyAsync(owner, vaultId, generation: 2); var grants = await ReadAsync(owner, $"/api/v1/vaults/{vaultId}/grants"); grants.KeyGeneration.ShouldBe(2u); grants.Grants.Count.ShouldBe(2); // The rotating owner holds both generations and is listed at the newer one. grants.Grants.Single(g => g.RecipientUserId != entry.UserId).KeyGeneration.ShouldBe(2u); // The member has not been re-wrapped, so their row says so by generation rather than by state. var stale = grants.Grants.Single(g => g.RecipientUserId == entry.UserId); stale.KeyGeneration.ShouldBe(1u); stale.State.ShouldBe(VaultGrantState.Active); } /// /// Two admins rotating at once must not both succeed, or one of them ends up holding a key nobody /// else has and every item they write is unreadable to the rest of the team. The generation is what /// makes that decidable: the second request is no longer one past the current, and is refused with a /// message that says to read the vault again. /// [Fact] public async Task ARekeyFromASupersededGeneration_IsRefused() { var owner = await EnrolledClientAsync("race-owner", "raceowner@example.com"); var team = await CreateTeamAsync(owner, "Races"); var vaultId = await CreateVaultAsync(owner, team.TeamId); await RekeyAsync(owner, vaultId, generation: 2); var stale = await owner.PostContractAsync( $"/api/v1/vaults/{vaultId}/rekey", RekeyRequest(generation: 2)); await ShouldBeProblemAsync(stale, HttpStatusCode.BadRequest, ProblemCodes.InvalidVaultGrant); } /// /// A member with no key to the current generation cannot rotate. They could not have wrapped the /// new key from the old one, so the request is either a mistake or a way to strand everybody else /// behind a key nobody holds. /// [Fact] public async Task ARekeyByAMemberWhoHoldsNoKey_IsRefused() { var owner = await EnrolledClientAsync("keyless-owner", "klowner@example.com"); var member = await EnrolledClientAsync("keyless-member", "klmember@example.com"); var team = await CreateTeamAsync(owner, "Keyless"); var vaultId = await CreateVaultAsync(owner, team.TeamId); var entry = await LookupAsync(owner, "klmember@example.com"); // Admin, so permission is not what stops them: what stops them is holding no key. await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Admin); var response = await member.PostContractAsync( $"/api/v1/vaults/{vaultId}/rekey", RekeyRequest(generation: 2)); await ShouldBeProblemAsync(response, HttpStatusCode.BadRequest, ProblemCodes.InvalidVaultGrant); } /// /// Sharing a rotated vault means handing over its history too, so a grant for a generation the vault /// has moved past is accepted. One for a generation it has not reached is not: nothing is sealed /// under it, and accepting it would let a client move the vault forward outside the one transaction /// that is allowed to. /// [Fact] public async Task AGrantForAnEarlierGeneration_IsAcceptedAndOneForALaterOneIsNot() { var owner = await EnrolledClientAsync("gen-owner", "genowner@example.com"); var member = await EnrolledClientAsync("gen-member", "genmember@example.com"); var team = await CreateTeamAsync(owner, "Generations"); var vaultId = await CreateVaultAsync(owner, team.TeamId); var entry = await LookupAsync(owner, "genmember@example.com"); await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member); await RekeyAsync(owner, vaultId, generation: 2); foreach (var generation in (uint[])[1, 2]) { var accepted = await owner.PostContractAsync( $"/api/v1/vaults/{vaultId}/grants", GrantRequest(entry, generation)); accepted.StatusCode.ShouldBe(HttpStatusCode.NoContent); } var ahead = await owner.PostContractAsync( $"/api/v1/vaults/{vaultId}/grants", GrantRequest(entry, generation: 3)); await ShouldBeProblemAsync(ahead, HttpStatusCode.BadRequest, ProblemCodes.InvalidVaultGrant); // Both grants are live at once, which is what lets the recipient read the vault's history and // its present. A single row per recipient would have made one of them overwrite the other. var me = await ReadAsync(member, MeUrl); var summary = me.Vaults.Single(vault => vault.VaultId == vaultId); summary.KeyGeneration.ShouldBe(2u); summary.PriorKeyWraps.ShouldNotBeNull().ShouldHaveSingleItem().KeyGeneration.ShouldBe(1u); } // ---- The directory and the key log ---- /// /// 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. /// [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>( 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>( 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>( client, $"/api/v1/directory?email={Uri.EscapeDataString(address[..^1])}"); prefix.ShouldBeEmpty(); } /// /// 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. /// [Fact] public async Task TheKeyLog_ChainsFromGenesisWithTheHashesItPublishes() { var client = await EnrolledClientAsync("keylog-reader", "keylog@example.com"); var page = await ReadAsync(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); } } // ---- Helpers ---- private static string TeamUrl(Guid teamId) => $"{TeamsUrl}/{teamId}"; private static string OwnerUrl(Guid teamId) => $"{TeamsUrl}/{teamId}/owner"; private static string MembersUrl(Guid teamId) => $"{TeamsUrl}/{teamId}/members"; private static string MemberUrl(Guid teamId, Guid userId) => $"{MembersUrl(teamId)}/{userId}"; private static string MemberRoleUrl(Guid teamId, Guid userId) => $"{MemberUrl(teamId, userId)}/role"; private static string InvitationsUrl(Guid teamId) => $"{TeamsUrl}/{teamId}/invitations"; private static string InvitationUrl(Guid teamId, Guid invitationId) => $"{InvitationsUrl(teamId)}/{invitationId}"; private static string TeamVaultsUrl(Guid teamId) => $"{TeamsUrl}/{teamId}/vaults"; private static string VaultUrl(Guid vaultId) => $"/api/v1/vaults/{vaultId}"; /// An address no account holds, uniquified because the container is shared. private static string NewAddress() => $"invitee-{Guid.CreateVersion7():N}@example.com"; private async Task 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; } /// Signs a brand-new account in, which is what claims any invitation to its address. /// /// No enrollment, because an invitee has no key and the claim path must not need one — requiring it /// would be requiring it of exactly the person who cannot yet supply it. Driving /me is what /// runs just-in-time provisioning, and provisioning is where the claim happens. /// private async Task<(HttpClient Client, Guid UserId)> SignInAsync( string email, bool emailVerified = true) { var client = fixture.CreateClientFor( $"invitee-{Guid.CreateVersion7():N}", email, emailVerified); var me = await ReadAsync(client, MeUrl); return (client, me.UserId); } /// Uniquified addresses, keyed on the readable one a test wrote. private readonly Dictionary addresses = new(StringComparer.OrdinalIgnoreCase); /// /// 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. /// private static Task CreateTeamAsync(HttpClient client, string name) => PostAsync( client, TeamsUrl, new CreateTeamRequest( Guid.CreateVersion7(), name, $"team-{Guid.CreateVersion7():N}", null)); /// /// 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. /// private static async Task CreateVaultAsync(HttpClient client, Guid teamId) { var vault = await PostAsync( client, TeamVaultsUrl(teamId), new CreateTeamVaultRequest( Guid.CreateVersion7(), "Team vault", WrappedVaultKey: new byte[110], GrantSignature: new byte[64], GrantedAt: DateTimeOffset.UnixEpoch)); return vault.VaultId; } /// /// The wrap is the right shape and nothing more, for the reason gives: /// the server stores it opaquely, so a real seal here would be exercising the crypto library. /// private static RekeyVaultRequest RekeyRequest(uint generation) => new( KeyGeneration: generation, WrappedVaultKey: new byte[110], GrantSignature: new byte[64], GrantedAt: DateTimeOffset.UnixEpoch); private static IssueVaultGrantRequest GrantRequest(DirectoryEntry entry, uint generation) => new( entry.UserId, entry.Fingerprint, KeyGeneration: generation, WrappedVaultKey: new byte[110], KeyLogHead: new byte[32], GrantSignature: new byte[64], GrantedAt: DateTimeOffset.UnixEpoch); private static async Task RekeyAsync( HttpClient client, Guid vaultId, uint generation) { var response = await client.PostContractAsync( $"/api/v1/vaults/{vaultId}/rekey", RekeyRequest(generation)); response.EnsureSuccessStatusCode(); return (await response.Content.ReadContractAsync())!; } private async Task LookupAsync(HttpClient client, string email) { var address = addresses.GetValueOrDefault(email, email); var found = await ReadAsync>( 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( MembersUrl(teamId), new AddTeamMemberRequest(userId, role)); response.EnsureSuccessStatusCode(); } private static Task InviteAsync( HttpClient client, Guid teamId, string email, TeamMemberRole role = TeamMemberRole.Member) => PostAsync( client, InvitationsUrl(teamId), new CreateTeamInvitationRequest(Guid.CreateVersion7(), email, role)); /// /// Filtered by id rather than taken from a position in the list, because the container is shared and /// every other class's invitations are in the same table. A count over the whole listing would pass /// or fail depending on what else ran. /// private static async Task FindInvitationAsync( HttpClient client, Guid teamId, Guid invitationId) { var listed = await ReadAsync>( client, InvitationsUrl(teamId)); return listed.Where(row => row.InvitationId == invitationId).ShouldHaveSingleItem(); } private static async Task PostAsync( HttpClient client, string url, TRequest body) { var response = await client.PostContractAsync(url, body); response.EnsureSuccessStatusCode(); return (await response.Content.ReadContractAsync())!; } private static async Task ReadAsync(HttpClient client, string url) { var response = await GetAsync(client, url); response.EnsureSuccessStatusCode(); return (await response.Content.ReadContractAsync())!; } private static Task GetAsync(HttpClient client, string url) => client.GetAsync(new Uri(url, UriKind.Relative), TestContext.Current.CancellationToken); private static Task DeleteAsync(HttpClient client, string url) => client.DeleteAsync(new Uri(url, UriKind.Relative), TestContext.Current.CancellationToken); /// /// Asserts on the code extension and never on the prose, for the reason /// gives: the code is the contract and the wording is not. /// private static async Task ShouldBeProblemAsync( HttpResponseMessage response, HttpStatusCode expectedStatus, string expectedCode) { response.StatusCode.ShouldBe(expectedStatus); var problem = await response.Content.ReadProblemAsync(); problem.ShouldNotBeNull(); problem.Code.ShouldBe(expectedCode); } }