using System.Net; using System.Net.Http.Json; using DodoSSH.Contracts; namespace DodoSSH.Api.Tests; /// /// Teams, membership 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. /// /// [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>(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)); response.StatusCode.ShouldBe(HttpStatusCode.Conflict); var problem = await response.Content.ReadProblemAsync(); problem.Code.ShouldBe(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 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); } /// /// 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, "/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); } /// /// 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([])); push.StatusCode.ShouldBe(HttpStatusCode.Forbidden); var problem = await push.Content.ReadProblemAsync(); problem.Code.ShouldBe(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, "/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(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); } /// /// 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 owner.DeleteAsync( new Uri($"{TeamsUrl}/{team.TeamId}/members/{entry.UserId}", UriKind.Relative), TestContext.Current.CancellationToken); var grants = await ReadAsync(owner, $"/api/v1/vaults/{vaultId}/grants"); grants.RekeyRequired.ShouldBeTrue(); } /// /// 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. /// [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, "/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); } /// /// 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)); response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); var problem = await response.Content.ReadProblemAsync(); problem.Code.ShouldBe(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 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); } } 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; } /// 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 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 async Task CreateVaultAsync(HttpClient client, Guid teamId) { var vault = await PostAsync( 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 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( $"{TeamsUrl}/{teamId}/members", new AddTeamMemberRequest(userId, role)); response.EnsureSuccessStatusCode(); } 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 client.GetAsync( new Uri(url, UriKind.Relative), TestContext.Current.CancellationToken); response.EnsureSuccessStatusCode(); return (await response.Content.ReadContractAsync())!; } }