search and ai stabilization work, localization stablized.
This commit is contained in:
@@ -0,0 +1,347 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class IdentityProviderEndpointsTests : IClassFixture<PlatformWebApplicationFactory>
|
||||
{
|
||||
private readonly PlatformWebApplicationFactory factory;
|
||||
|
||||
public IdentityProviderEndpointsTests(PlatformWebApplicationFactory factory)
|
||||
{
|
||||
this.factory = factory;
|
||||
}
|
||||
|
||||
private HttpClient CreateClient(string tenantId = "tenant-idp", string actorId = "actor-idp")
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", tenantId);
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", actorId);
|
||||
return client;
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task List_ReturnsEmptyForNewTenant()
|
||||
{
|
||||
using var client = CreateClient("tenant-idp-empty");
|
||||
|
||||
var items = await client.GetFromJsonAsync<List<IdentityProviderConfigDto>>(
|
||||
"/api/v1/platform/identity-providers",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(items);
|
||||
Assert.Empty(items!);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CrudLifecycle_LdapProvider()
|
||||
{
|
||||
using var client = CreateClient("tenant-idp-crud");
|
||||
|
||||
// Create
|
||||
var createRequest = new CreateIdentityProviderRequest(
|
||||
"test-ldap",
|
||||
"ldap",
|
||||
true,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["host"] = "ldap.example.com",
|
||||
["port"] = "389",
|
||||
["bindDn"] = "cn=admin,dc=example,dc=com",
|
||||
["bindPassword"] = "secret",
|
||||
["searchBase"] = "dc=example,dc=com"
|
||||
},
|
||||
"Test LDAP provider");
|
||||
|
||||
var createResponse = await client.PostAsJsonAsync(
|
||||
"/api/v1/platform/identity-providers",
|
||||
createRequest,
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
|
||||
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<IdentityProviderConfigDto>(
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(created);
|
||||
Assert.Equal("test-ldap", created!.Name);
|
||||
Assert.Equal("ldap", created.Type);
|
||||
Assert.True(created.Enabled);
|
||||
Assert.Equal("ldap.example.com", created.Configuration["host"]);
|
||||
|
||||
// Read
|
||||
var getResponse = await client.GetFromJsonAsync<IdentityProviderConfigDto>(
|
||||
$"/api/v1/platform/identity-providers/{created.Id}",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(getResponse);
|
||||
Assert.Equal(created.Id, getResponse!.Id);
|
||||
|
||||
// Update
|
||||
var updateRequest = new UpdateIdentityProviderRequest(
|
||||
null,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["host"] = "ldap2.example.com",
|
||||
["port"] = "636",
|
||||
["bindDn"] = "cn=admin,dc=example,dc=com",
|
||||
["bindPassword"] = "new-secret",
|
||||
["searchBase"] = "dc=example,dc=com"
|
||||
},
|
||||
"Updated LDAP");
|
||||
|
||||
var updateResponse = await client.PutAsJsonAsync(
|
||||
$"/api/v1/platform/identity-providers/{created.Id}",
|
||||
updateRequest,
|
||||
TestContext.Current.CancellationToken);
|
||||
updateResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var updated = await updateResponse.Content.ReadFromJsonAsync<IdentityProviderConfigDto>(
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
Assert.Equal("ldap2.example.com", updated!.Configuration["host"]);
|
||||
Assert.Equal("Updated LDAP", updated.Description);
|
||||
|
||||
// List
|
||||
var items = await client.GetFromJsonAsync<List<IdentityProviderConfigDto>>(
|
||||
"/api/v1/platform/identity-providers",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Single(items!);
|
||||
|
||||
// Delete
|
||||
var deleteResponse = await client.DeleteAsync(
|
||||
$"/api/v1/platform/identity-providers/{created.Id}",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode);
|
||||
|
||||
// Verify deleted
|
||||
var afterDelete = await client.GetAsync(
|
||||
$"/api/v1/platform/identity-providers/{created.Id}",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.NotFound, afterDelete.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Create_ValidationError_MissingRequiredFields()
|
||||
{
|
||||
using var client = CreateClient("tenant-idp-validation");
|
||||
|
||||
var request = new CreateIdentityProviderRequest(
|
||||
"invalid-ldap",
|
||||
"ldap",
|
||||
true,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["host"] = "ldap.example.com"
|
||||
// Missing port, bindDn, bindPassword, searchBase
|
||||
},
|
||||
null);
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/api/v1/platform/identity-providers",
|
||||
request,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Create_ValidationError_InvalidType()
|
||||
{
|
||||
using var client = CreateClient("tenant-idp-type");
|
||||
|
||||
var request = new CreateIdentityProviderRequest(
|
||||
"invalid-type",
|
||||
"kerberos",
|
||||
true,
|
||||
new Dictionary<string, string?>(),
|
||||
null);
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/api/v1/platform/identity-providers",
|
||||
request,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Create_DuplicateName_ReturnsBadRequest()
|
||||
{
|
||||
using var client = CreateClient("tenant-idp-dup");
|
||||
|
||||
var request = new CreateIdentityProviderRequest(
|
||||
"duplicate-provider",
|
||||
"standard",
|
||||
true,
|
||||
new Dictionary<string, string?>(),
|
||||
null);
|
||||
|
||||
var first = await client.PostAsJsonAsync(
|
||||
"/api/v1/platform/identity-providers",
|
||||
request,
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.Created, first.StatusCode);
|
||||
|
||||
var second = await client.PostAsJsonAsync(
|
||||
"/api/v1/platform/identity-providers",
|
||||
request,
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, second.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EnableDisable_TogglesState()
|
||||
{
|
||||
using var client = CreateClient("tenant-idp-toggle");
|
||||
|
||||
var createRequest = new CreateIdentityProviderRequest(
|
||||
"toggle-provider",
|
||||
"standard",
|
||||
true,
|
||||
new Dictionary<string, string?>(),
|
||||
null);
|
||||
|
||||
var createResponse = await client.PostAsJsonAsync(
|
||||
"/api/v1/platform/identity-providers",
|
||||
createRequest,
|
||||
TestContext.Current.CancellationToken);
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<IdentityProviderConfigDto>(
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Disable
|
||||
var disableResponse = await client.PostAsync(
|
||||
$"/api/v1/platform/identity-providers/{created!.Id}/disable",
|
||||
null,
|
||||
TestContext.Current.CancellationToken);
|
||||
disableResponse.EnsureSuccessStatusCode();
|
||||
var disabled = await disableResponse.Content.ReadFromJsonAsync<IdentityProviderConfigDto>(
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
Assert.False(disabled!.Enabled);
|
||||
|
||||
// Enable
|
||||
var enableResponse = await client.PostAsync(
|
||||
$"/api/v1/platform/identity-providers/{created.Id}/enable",
|
||||
null,
|
||||
TestContext.Current.CancellationToken);
|
||||
enableResponse.EnsureSuccessStatusCode();
|
||||
var enabled = await enableResponse.Content.ReadFromJsonAsync<IdentityProviderConfigDto>(
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
Assert.True(enabled!.Enabled);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task TestConnection_StandardProvider_AlwaysSucceeds()
|
||||
{
|
||||
using var client = CreateClient("tenant-idp-test");
|
||||
|
||||
var request = new TestConnectionRequest(
|
||||
"standard",
|
||||
new Dictionary<string, string?>());
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/api/v1/platform/identity-providers/test-connection",
|
||||
request,
|
||||
TestContext.Current.CancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<TestConnectionResult>(
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
Assert.True(result!.Success);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetTypes_ReturnsAllProviderTypes()
|
||||
{
|
||||
using var client = CreateClient("tenant-idp-types");
|
||||
|
||||
var types = await client.GetFromJsonAsync<List<IdentityProviderTypeSchema>>(
|
||||
"/api/v1/platform/identity-providers/types",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(types);
|
||||
Assert.Equal(4, types!.Count);
|
||||
Assert.Contains(types, t => t.Type == "standard");
|
||||
Assert.Contains(types, t => t.Type == "ldap");
|
||||
Assert.Contains(types, t => t.Type == "saml");
|
||||
Assert.Contains(types, t => t.Type == "oidc");
|
||||
|
||||
var ldap = types.Find(t => t.Type == "ldap");
|
||||
Assert.NotNull(ldap);
|
||||
Assert.Contains(ldap!.RequiredFields, f => f.Name == "host");
|
||||
Assert.Contains(ldap.RequiredFields, f => f.Name == "bindDn");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task TenantIsolation_CannotSeeOtherTenantProviders()
|
||||
{
|
||||
using var clientA = CreateClient("tenant-idp-a", "actor-a");
|
||||
using var clientB = CreateClient("tenant-idp-b", "actor-b");
|
||||
|
||||
var requestA = new CreateIdentityProviderRequest(
|
||||
"tenant-a-provider",
|
||||
"standard",
|
||||
true,
|
||||
new Dictionary<string, string?>(),
|
||||
null);
|
||||
|
||||
var createA = await clientA.PostAsJsonAsync(
|
||||
"/api/v1/platform/identity-providers",
|
||||
requestA,
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.Created, createA.StatusCode);
|
||||
|
||||
var created = await createA.Content.ReadFromJsonAsync<IdentityProviderConfigDto>(
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Tenant B cannot see tenant A's provider
|
||||
var listB = await clientB.GetFromJsonAsync<List<IdentityProviderConfigDto>>(
|
||||
"/api/v1/platform/identity-providers",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Empty(listB!);
|
||||
|
||||
// Tenant B cannot get tenant A's provider by ID
|
||||
var getB = await clientB.GetAsync(
|
||||
$"/api/v1/platform/identity-providers/{created!.Id}",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.NotFound, getB.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Health_ReturnsStatusForProvider()
|
||||
{
|
||||
using var client = CreateClient("tenant-idp-health");
|
||||
|
||||
var createRequest = new CreateIdentityProviderRequest(
|
||||
"health-check-provider",
|
||||
"standard",
|
||||
true,
|
||||
new Dictionary<string, string?>(),
|
||||
null);
|
||||
|
||||
var createResponse = await client.PostAsJsonAsync(
|
||||
"/api/v1/platform/identity-providers",
|
||||
createRequest,
|
||||
TestContext.Current.CancellationToken);
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<IdentityProviderConfigDto>(
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
var healthResponse = await client.GetFromJsonAsync<TestConnectionResult>(
|
||||
$"/api/v1/platform/identity-providers/{created!.Id}/health",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(healthResponse);
|
||||
Assert.True(healthResponse!.Success);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests that require real IDP containers (OpenLDAP + Keycloak).
|
||||
/// Run: docker compose -f devops/compose/docker-compose.idp-testing.yml --profile idp up -d
|
||||
/// Execute: dotnet test --filter "FullyQualifiedName~IdentityProviderContainerTests"
|
||||
/// </summary>
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Collection("IdpContainerTests")]
|
||||
public sealed class IdentityProviderContainerTests : IClassFixture<PlatformWebApplicationFactory>
|
||||
{
|
||||
private const string LdapHost = "localhost";
|
||||
private const int LdapPort = 3389;
|
||||
private const string KeycloakBaseUrl = "http://localhost:8280";
|
||||
|
||||
private readonly PlatformWebApplicationFactory factory;
|
||||
|
||||
public IdentityProviderContainerTests(PlatformWebApplicationFactory factory)
|
||||
{
|
||||
this.factory = factory;
|
||||
}
|
||||
|
||||
private HttpClient CreateClient()
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "tenant-container-test");
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "actor-container-test");
|
||||
return client;
|
||||
}
|
||||
|
||||
[Fact(Skip = "Requires docker compose idp containers")]
|
||||
public async Task TestConnection_Ldap_CorrectCredentials_Succeeds()
|
||||
{
|
||||
using var client = CreateClient();
|
||||
|
||||
var request = new TestConnectionRequest(
|
||||
"ldap",
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["host"] = LdapHost,
|
||||
["port"] = LdapPort.ToString(),
|
||||
["bindDn"] = "cn=admin,dc=stellaops,dc=test",
|
||||
["bindPassword"] = "admin-secret",
|
||||
["searchBase"] = "dc=stellaops,dc=test"
|
||||
});
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/api/v1/platform/identity-providers/test-connection",
|
||||
request,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
var result = await response.Content.ReadFromJsonAsync<TestConnectionResult>(
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.True(result!.Success);
|
||||
Assert.NotNull(result.LatencyMs);
|
||||
Assert.True(result.LatencyMs > 0);
|
||||
}
|
||||
|
||||
[Fact(Skip = "Requires docker compose idp containers")]
|
||||
public async Task TestConnection_Ldap_WrongCredentials_Fails()
|
||||
{
|
||||
using var client = CreateClient();
|
||||
|
||||
// TCP connect will succeed but bind would fail
|
||||
// (our current test only does TCP connect, so this tests unreachable host)
|
||||
var request = new TestConnectionRequest(
|
||||
"ldap",
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["host"] = "198.51.100.1", // RFC 5737 TEST-NET-2
|
||||
["port"] = "389",
|
||||
["bindDn"] = "cn=wrong,dc=stellaops,dc=test",
|
||||
["bindPassword"] = "wrong-password",
|
||||
["searchBase"] = "dc=stellaops,dc=test"
|
||||
});
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/api/v1/platform/identity-providers/test-connection",
|
||||
request,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
var result = await response.Content.ReadFromJsonAsync<TestConnectionResult>(
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.False(result!.Success);
|
||||
}
|
||||
|
||||
[Fact(Skip = "Requires docker compose idp containers")]
|
||||
public async Task TestConnection_SamlMetadata_Succeeds()
|
||||
{
|
||||
using var client = CreateClient();
|
||||
|
||||
var metadataUrl = $"{KeycloakBaseUrl}/realms/stellaops/protocol/saml/descriptor";
|
||||
|
||||
var request = new TestConnectionRequest(
|
||||
"saml",
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["spEntityId"] = "stellaops-saml-sp",
|
||||
["idpEntityId"] = $"{KeycloakBaseUrl}/realms/stellaops",
|
||||
["idpMetadataUrl"] = metadataUrl
|
||||
});
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/api/v1/platform/identity-providers/test-connection",
|
||||
request,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
var result = await response.Content.ReadFromJsonAsync<TestConnectionResult>(
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.True(result!.Success);
|
||||
Assert.Contains("metadata", result.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact(Skip = "Requires docker compose idp containers")]
|
||||
public async Task TestConnection_OidcDiscovery_Succeeds()
|
||||
{
|
||||
using var client = CreateClient();
|
||||
|
||||
var request = new TestConnectionRequest(
|
||||
"oidc",
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["authority"] = $"{KeycloakBaseUrl}/realms/stellaops",
|
||||
["clientId"] = "stellaops-oidc-client",
|
||||
["clientSecret"] = "stellaops-oidc-test-secret"
|
||||
});
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/api/v1/platform/identity-providers/test-connection",
|
||||
request,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
var result = await response.Content.ReadFromJsonAsync<TestConnectionResult>(
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.True(result!.Success);
|
||||
Assert.Contains("discovery", result.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact(Skip = "Requires docker compose idp containers")]
|
||||
public async Task TestConnection_UnreachableHost_TimesOut()
|
||||
{
|
||||
using var client = CreateClient();
|
||||
|
||||
var request = new TestConnectionRequest(
|
||||
"ldap",
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["host"] = "198.51.100.1", // TEST-NET-2 -- should be unreachable
|
||||
["port"] = "389",
|
||||
["bindDn"] = "cn=admin,dc=test",
|
||||
["bindPassword"] = "secret",
|
||||
["searchBase"] = "dc=test"
|
||||
});
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/api/v1/platform/identity-providers/test-connection",
|
||||
request,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
var result = await response.Content.ReadFromJsonAsync<TestConnectionResult>(
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.False(result!.Success);
|
||||
Assert.Contains("failed", result.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact(Skip = "Requires docker compose idp containers")]
|
||||
public async Task FullCrudLifecycle_WithHealthCheck()
|
||||
{
|
||||
using var client = CreateClient();
|
||||
|
||||
// Create LDAP provider
|
||||
var createRequest = new CreateIdentityProviderRequest(
|
||||
"container-test-ldap",
|
||||
"ldap",
|
||||
true,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["host"] = LdapHost,
|
||||
["port"] = LdapPort.ToString(),
|
||||
["bindDn"] = "cn=admin,dc=stellaops,dc=test",
|
||||
["bindPassword"] = "admin-secret",
|
||||
["searchBase"] = "dc=stellaops,dc=test"
|
||||
},
|
||||
"Container integration test LDAP provider");
|
||||
|
||||
var createResponse = await client.PostAsJsonAsync(
|
||||
"/api/v1/platform/identity-providers",
|
||||
createRequest,
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
|
||||
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<IdentityProviderConfigDto>(
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(created);
|
||||
|
||||
// Health check
|
||||
var healthResponse = await client.GetFromJsonAsync<TestConnectionResult>(
|
||||
$"/api/v1/platform/identity-providers/{created!.Id}/health",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(healthResponse);
|
||||
Assert.True(healthResponse!.Success);
|
||||
|
||||
// Update
|
||||
var updateRequest = new UpdateIdentityProviderRequest(
|
||||
null,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["host"] = LdapHost,
|
||||
["port"] = LdapPort.ToString(),
|
||||
["bindDn"] = "cn=admin,dc=stellaops,dc=test",
|
||||
["bindPassword"] = "admin-secret",
|
||||
["searchBase"] = "ou=users,dc=stellaops,dc=test"
|
||||
},
|
||||
"Updated container test LDAP provider");
|
||||
|
||||
var updateResponse = await client.PutAsJsonAsync(
|
||||
$"/api/v1/platform/identity-providers/{created.Id}",
|
||||
updateRequest,
|
||||
TestContext.Current.CancellationToken);
|
||||
updateResponse.EnsureSuccessStatusCode();
|
||||
|
||||
// List
|
||||
var list = await client.GetFromJsonAsync<List<IdentityProviderConfigDto>>(
|
||||
"/api/v1/platform/identity-providers",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Contains(list!, p => p.Name == "container-test-ldap");
|
||||
|
||||
// Delete
|
||||
var deleteResponse = await client.DeleteAsync(
|
||||
$"/api/v1/platform/identity-providers/{created.Id}",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class LocalizationEndpointsTests : IClassFixture<PlatformWebApplicationFactory>
|
||||
{
|
||||
private readonly PlatformWebApplicationFactory _factory;
|
||||
|
||||
public LocalizationEndpointsTests(PlatformWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task UiBundleEndpoint_ReturnsDefaultMergedBundle()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
|
||||
using var response = await client.GetAsync(
|
||||
"/platform/i18n/en-US.json",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
Assert.Equal("public, max-age=300", response.Headers.CacheControl?.ToString());
|
||||
|
||||
var bundle = await response.Content.ReadFromJsonAsync<Dictionary<string, string>>(
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(bundle);
|
||||
Assert.True(bundle!.ContainsKey("common.actions.save"));
|
||||
Assert.True(bundle.ContainsKey("ui.actions.save"));
|
||||
Assert.False(string.IsNullOrWhiteSpace(bundle["common.actions.save"]));
|
||||
Assert.False(string.IsNullOrWhiteSpace(bundle["ui.actions.save"]));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task UpsertOverride_IsReturnedFromUiBundle()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "tenant-localization");
|
||||
client.DefaultRequestHeaders.Add("X-Tenant-Id", "tenant-localization");
|
||||
client.DefaultRequestHeaders.Add("X-Actor", "test-actor");
|
||||
|
||||
var upsertPayload = new
|
||||
{
|
||||
locale = "en-US",
|
||||
strings = new Dictionary<string, string>
|
||||
{
|
||||
["ui.actions.save"] = "Speichern"
|
||||
}
|
||||
};
|
||||
|
||||
using var upsertResponse = await client.PutAsJsonAsync(
|
||||
"/api/v1/platform/localization/bundles",
|
||||
upsertPayload,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
upsertResponse.EnsureSuccessStatusCode();
|
||||
|
||||
using var bundleResponse = await client.GetAsync(
|
||||
"/platform/i18n/en-US.json",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
bundleResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var bundle = await bundleResponse.Content.ReadFromJsonAsync<Dictionary<string, string>>(
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(bundle);
|
||||
Assert.Equal("Speichern", bundle!["ui.actions.save"]);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task UiBundles_IncludeCommonLayerForAllSupportedLocales()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
|
||||
var locales = new[]
|
||||
{
|
||||
"en-US",
|
||||
"de-DE",
|
||||
"bg-BG",
|
||||
"ru-RU",
|
||||
"es-ES",
|
||||
"fr-FR",
|
||||
"uk-UA",
|
||||
"zh-TW",
|
||||
"zh-CN",
|
||||
};
|
||||
|
||||
foreach (var locale in locales)
|
||||
{
|
||||
using var response = await client.GetAsync(
|
||||
$"/platform/i18n/{locale}.json",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var bundle = await response.Content.ReadFromJsonAsync<Dictionary<string, string>>(
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(bundle);
|
||||
Assert.Contains("common.actions.save", bundle!);
|
||||
Assert.Contains("ui.actions.save", bundle);
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task AvailableLocales_IncludesExpandedLocaleSet()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "tenant-localization");
|
||||
client.DefaultRequestHeaders.Add("X-Tenant-Id", "tenant-localization");
|
||||
client.DefaultRequestHeaders.Add("X-Actor", "test-actor");
|
||||
|
||||
using var response = await client.GetAsync(
|
||||
"/api/v1/platform/localization/locales",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<JsonObject>(
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(payload);
|
||||
var locales = payload!["locales"]?.AsArray().Select(node => node?.GetValue<string>()).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
Assert.NotNull(locales);
|
||||
Assert.Contains("en-US", locales!);
|
||||
Assert.Contains("de-DE", locales);
|
||||
Assert.Contains("bg-BG", locales);
|
||||
Assert.Contains("ru-RU", locales);
|
||||
Assert.Contains("es-ES", locales);
|
||||
Assert.Contains("fr-FR", locales);
|
||||
Assert.Contains("uk-UA", locales);
|
||||
Assert.Contains("zh-TW", locales);
|
||||
Assert.Contains("zh-CN", locales);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task PlatformNamespaceBundles_AreAvailableForAllSupportedLocales()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "tenant-localization");
|
||||
client.DefaultRequestHeaders.Add("X-Tenant-Id", "tenant-localization");
|
||||
client.DefaultRequestHeaders.Add("X-Actor", "test-actor");
|
||||
|
||||
var locales = new[]
|
||||
{
|
||||
"en-US",
|
||||
"de-DE",
|
||||
"bg-BG",
|
||||
"ru-RU",
|
||||
"es-ES",
|
||||
"fr-FR",
|
||||
"uk-UA",
|
||||
"zh-TW",
|
||||
"zh-CN",
|
||||
};
|
||||
|
||||
foreach (var locale in locales)
|
||||
{
|
||||
using var response = await client.GetAsync(
|
||||
$"/api/v1/platform/localization/bundles/{locale}/platform",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<JsonObject>(
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(payload);
|
||||
var strings = payload!["strings"]?.AsObject();
|
||||
Assert.NotNull(strings);
|
||||
Assert.Contains("platform.health.status_healthy", strings!);
|
||||
Assert.Contains("platform.migration.failed", strings);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class PlatformTranslationsMigrationScriptTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Migration057_DefinesPlatformTranslationsSchemaObjects()
|
||||
{
|
||||
var scriptPath = GetMigrationPath("057_PlatformTranslations.sql");
|
||||
var sql = File.ReadAllText(scriptPath);
|
||||
|
||||
Assert.Contains("CREATE TABLE IF NOT EXISTS platform.translations", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CONSTRAINT ux_translations_tenant_locale_key UNIQUE (tenant_id, locale, key)", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE INDEX IF NOT EXISTS ix_translations_tenant_locale", sql, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Migration057_IsPresentInReleaseMigrationSequence()
|
||||
{
|
||||
var migrationsDir = GetMigrationsDirectory();
|
||||
var migrationNames = Directory.GetFiles(migrationsDir, "*.sql")
|
||||
.Select(Path.GetFileName)
|
||||
.Where(static name => name is not null)
|
||||
.Select(static name => name!)
|
||||
.OrderBy(static name => name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var index056 = Array.IndexOf(migrationNames, "056_RunCapsuleReplayLinkage.sql");
|
||||
var index057 = Array.IndexOf(migrationNames, "057_PlatformTranslations.sql");
|
||||
|
||||
Assert.True(index056 >= 0, "Expected migration 056 to exist.");
|
||||
Assert.True(index057 > index056, "Expected migration 057 to appear after migration 056.");
|
||||
}
|
||||
|
||||
private static string GetMigrationPath(string fileName)
|
||||
{
|
||||
return Path.Combine(GetMigrationsDirectory(), fileName);
|
||||
}
|
||||
|
||||
private static string GetMigrationsDirectory()
|
||||
{
|
||||
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (current is not null)
|
||||
{
|
||||
var candidate = Path.Combine(
|
||||
current.FullName,
|
||||
"src",
|
||||
"Platform",
|
||||
"__Libraries",
|
||||
"StellaOps.Platform.Database",
|
||||
"Migrations",
|
||||
"Release");
|
||||
|
||||
if (Directory.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Could not locate Platform release migrations directory.");
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
@@ -44,4 +45,87 @@ public sealed class PreferencesEndpointsTests : IClassFixture<PlatformWebApplica
|
||||
Assert.NotNull(widgets);
|
||||
Assert.Equal(new[] { "health", "quota" }, widgets!.Select(widget => widget!.GetValue<string>()).ToArray());
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task LanguagePreference_RoundTripAndSurvivesDashboardUpdate()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "tenant-preferences");
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "actor-preferences");
|
||||
|
||||
var initial = await client.GetFromJsonAsync<PlatformLanguagePreference>(
|
||||
"/api/v1/platform/preferences/language",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(initial);
|
||||
Assert.Null(initial!.Locale);
|
||||
|
||||
var setLanguage = await client.PutAsJsonAsync(
|
||||
"/api/v1/platform/preferences/language",
|
||||
new PlatformLanguagePreferenceRequest("es-ES"),
|
||||
TestContext.Current.CancellationToken);
|
||||
setLanguage.EnsureSuccessStatusCode();
|
||||
|
||||
var updated = await setLanguage.Content.ReadFromJsonAsync<PlatformLanguagePreference>(
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(updated);
|
||||
Assert.Equal("es-ES", updated!.Locale);
|
||||
|
||||
var dashboardUpdate = new PlatformDashboardPreferencesRequest(new JsonObject
|
||||
{
|
||||
["layout"] = "incident",
|
||||
["widgets"] = new JsonArray("health"),
|
||||
["filters"] = new JsonObject { ["scope"] = "tenant" }
|
||||
});
|
||||
|
||||
var dashboardResponse = await client.PutAsJsonAsync(
|
||||
"/api/v1/platform/preferences/dashboard",
|
||||
dashboardUpdate,
|
||||
TestContext.Current.CancellationToken);
|
||||
dashboardResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var reloaded = await client.GetFromJsonAsync<PlatformLanguagePreference>(
|
||||
"/api/v1/platform/preferences/language",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(reloaded);
|
||||
Assert.Equal("es-ES", reloaded!.Locale);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task LanguagePreference_RejectsUnsupportedLocale()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "tenant-preferences");
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "actor-preferences");
|
||||
|
||||
var response = await client.PutAsJsonAsync(
|
||||
"/api/v1/platform/preferences/language",
|
||||
new PlatformLanguagePreferenceRequest("xx-XX"),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task LanguagePreference_NormalizesUkrainianAlias()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "tenant-preferences");
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "actor-preferences");
|
||||
|
||||
var response = await client.PutAsJsonAsync(
|
||||
"/api/v1/platform/preferences/language",
|
||||
new PlatformLanguagePreferenceRequest("uk"),
|
||||
TestContext.Current.CancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var updated = await response.Content.ReadFromJsonAsync<PlatformLanguagePreference>(
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(updated);
|
||||
Assert.Equal("uk-UA", updated!.Locale);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,3 +19,7 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0762-A | DONE | Waived (test project; revalidated 2026-01-07). |
|
||||
| TASK-030-019 | BLOCKED | Added analytics maintenance + cache normalization + query executor tests; analytics schema fixtures blocked by ingestion dependencies. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| SPRINT_20260224_001-LOC-002-T | DONE | Sprint `docs/implplan/SPRINT_20260224_001_Platform_unified_translation_gap_closure.md`: added migration script + localization endpoint tests for translation persistence and override behavior. |
|
||||
| SPRINT_20260224_004-LOC-302-T | DONE | Sprint `docs/implplan/SPRINT_20260224_004_Platform_user_locale_expansion_and_cli_persistence.md`: added language preference endpoint coverage in `PreferencesEndpointsTests` (round-trip persistence + invalid locale rejection) and expanded locale catalog verification in `LocalizationEndpointsTests`. |
|
||||
| SPRINT_20260224_004-LOC-305-T | DONE | Sprint `docs/implplan/SPRINT_20260224_004_Platform_user_locale_expansion_and_cli_persistence.md`: extended `LocalizationEndpointsTests` to verify common-layer and `platform.*` namespace bundle availability for all supported locales. |
|
||||
| SPRINT_20260224_004-LOC-307-T | DONE | Sprint `docs/implplan/SPRINT_20260224_004_Platform_user_locale_expansion_and_cli_persistence.md`: extended localization and preference endpoint tests for Ukrainian rollout (`uk-UA` locale catalog/bundle assertions and alias normalization to canonical `uk-UA`). |
|
||||
|
||||
Reference in New Issue
Block a user