using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using Xunit; namespace TeamUp.IntegrationTests; /// /// M3 BYOK acceptance: an owner adds an API config (key encrypted, never returned by any endpoint), /// a connection test succeeds, and a non-owner Member cannot create or list configs. /// public sealed class ByokTests(PostgresFixture postgres) : IClassFixture { private const string SecretKey = "sk-teamup-test-deadbeef-do-not-leak"; private sealed record BootstrapResponse(string Token, Guid MemberId, Guid OrganizationId); private sealed record AuthResponse(string Token, Guid MemberId); private sealed record InviteResponse(Guid InvitationId, string Token); private sealed record ApiConfigDto(Guid Id, string Name, string Provider, string Model, string? Endpoint); private sealed record TestResultDto(bool Success, string? Error, long LatencyMs, string? Sample); [Fact] public async Task Owner_adds_config_key_never_returned_test_succeeds_member_forbidden() { await using var factory = new TeamUpWebFactory(postgres.ConnectionString); using var anon = factory.CreateClient(); var owner = await Bootstrap(anon); using var ownerClient = Authed(factory, owner.Token); // Owner creates a config (stub provider, no network). The key must NOT appear in the response. var createResponse = await ownerClient.PostAsJsonAsync("/api/integrations/api-configs", new { organizationId = owner.OrganizationId, name = "Stub-Pro", provider = "stub", model = "test-model", apiKey = SecretKey, }); Assert.Equal(HttpStatusCode.OK, createResponse.StatusCode); Assert.DoesNotContain(SecretKey, await createResponse.Content.ReadAsStringAsync()); var config = await createResponse.Content.ReadFromJsonAsync(); Assert.NotNull(config); Assert.Equal("stub", config!.Provider); // Listing returns the config but never the key. var listResponse = await ownerClient.GetAsync($"/api/integrations/api-configs?organizationId={owner.OrganizationId}"); Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode); var listBody = await listResponse.Content.ReadAsStringAsync(); Assert.DoesNotContain(SecretKey, listBody); Assert.Contains(config.Id.ToString(), listBody); // The connection test succeeds (stub uses the decrypted key server-side; never echoes it). var test = await ownerClient.PostAsync($"/api/integrations/api-configs/{config.Id}/test", content: null); Assert.Equal(HttpStatusCode.OK, test.StatusCode); var testBody = await test.Content.ReadAsStringAsync(); Assert.DoesNotContain(SecretKey, testBody); var result = await test.Content.ReadFromJsonAsync(); Assert.True(result!.Success); // A Member cannot manage or even list BYOK configs. var member = await InviteMember(ownerClient, anon, owner.OrganizationId); using var memberClient = Authed(factory, member.Token); var memberCreate = await memberClient.PostAsJsonAsync("/api/integrations/api-configs", new { organizationId = owner.OrganizationId, name = "Nope", provider = "stub", model = "x", apiKey = "sk-nope", }); Assert.Equal(HttpStatusCode.Forbidden, memberCreate.StatusCode); var memberList = await memberClient.GetAsync($"/api/integrations/api-configs?organizationId={owner.OrganizationId}"); Assert.Equal(HttpStatusCode.Forbidden, memberList.StatusCode); } private static async Task Bootstrap(HttpClient client) { var response = await client.PostAsJsonAsync("/api/identity/bootstrap", new { organizationName = "AliaSaaS", ownerEmail = "owner@alia.test", ownerDisplayName = "Owner", ownerPassword = "Passw0rd!", }); var owner = await response.Content.ReadFromJsonAsync(); Assert.NotNull(owner); return owner!; } private static async Task InviteMember(HttpClient ownerClient, HttpClient anon, Guid organizationId) { var invite = await ownerClient.PostAsJsonAsync("/api/identity/invitations", new { email = "dev@alia.test", scopeType = "Organization", scopeId = organizationId, role = "Member", organizationId, }); var inviteResponse = await invite.Content.ReadFromJsonAsync(); var accept = await anon.PostAsJsonAsync("/api/identity/invitations/accept", new { token = inviteResponse!.Token, displayName = "Dev", password = "Passw0rd!", }); var member = await accept.Content.ReadFromJsonAsync(); Assert.NotNull(member); return member!; } private static HttpClient Authed(TeamUpWebFactory factory, string token) { var client = factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); return client; } }