1559975518
SharedKernel: Autonomy dial enum; IModelClient (ModelRequest/ModelCompletion);
IApiConfigResolver (+ ApiConfigSummary/ResolvedApiConfig) — server-side, decrypted.
Integrations module:
- ApiConfig entity (org-scoped) + IntegrationsDbContext (schema "integrations") +
InitialIntegrations migration; the key is AES-256-GCM encrypted at rest (key derived from
Encryption:MasterKey) and never returned to a client.
- Model adapters: StubModelClient (no-network, provider "stub"/"echo"), an OpenAI-compatible
HTTP adapter, and a ModelClientRouter; ApiConfigResolver decrypts server-side only.
- Endpoints: POST/GET/DELETE /api/integrations/api-configs and POST .../{id}/test. Create/
test/delete require ManageApiKeys (owner); listing requires ConfigureAgents (assign-only,
no key). Dev master key in appsettings; override Encryption__MasterKey in prod.
Verified: build green; ArchitectureTests 8/8 (Integrations references only SharedKernel);
IntegrationTests 26/26 incl. a BYOK flow — key never appears in any response, the connection
test succeeds (stub), and a Member is 403'd from create + list.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
126 lines
5.2 KiB
C#
126 lines
5.2 KiB
C#
using System.Net;
|
|
using System.Net.Http.Headers;
|
|
using System.Net.Http.Json;
|
|
using Xunit;
|
|
|
|
namespace TeamUp.IntegrationTests;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public sealed class ByokTests(PostgresFixture postgres) : IClassFixture<PostgresFixture>
|
|
{
|
|
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<ApiConfigDto>();
|
|
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<TestResultDto>();
|
|
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<BootstrapResponse> 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<BootstrapResponse>();
|
|
Assert.NotNull(owner);
|
|
return owner!;
|
|
}
|
|
|
|
private static async Task<AuthResponse> 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<InviteResponse>();
|
|
var accept = await anon.PostAsJsonAsync("/api/identity/invitations/accept", new
|
|
{
|
|
token = inviteResponse!.Token,
|
|
displayName = "Dev",
|
|
password = "Passw0rd!",
|
|
});
|
|
var member = await accept.Content.ReadFromJsonAsync<AuthResponse>();
|
|
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;
|
|
}
|
|
}
|