search and ai stabilization work, localization stablized.
This commit is contained in:
@@ -2273,10 +2273,80 @@ internal static class CommandFactory
|
||||
return CommandHandlers.HandleTenantsClearAsync(cancellationToken);
|
||||
});
|
||||
|
||||
var locale = new Command("locale", "Get or set persisted user locale preference for this tenant.");
|
||||
|
||||
var localeList = new Command("list", "List locales available for selection in UI and CLI.");
|
||||
var localeListTenantOption = new Option<string?>("--tenant")
|
||||
{
|
||||
Description = "Tenant context to use for locale catalog lookup. Defaults to active tenant profile."
|
||||
};
|
||||
var localeListJsonOption = new Option<bool>("--json")
|
||||
{
|
||||
Description = "Output locale catalog in JSON format."
|
||||
};
|
||||
localeList.Add(localeListTenantOption);
|
||||
localeList.Add(localeListJsonOption);
|
||||
localeList.SetAction((parseResult, _) =>
|
||||
{
|
||||
var tenant = parseResult.GetValue(localeListTenantOption);
|
||||
var json = parseResult.GetValue(localeListJsonOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return CommandHandlers.HandleTenantsLocaleListAsync(services, options, tenant, json, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
var localeGet = new Command("get", "Get persisted locale preference for the authenticated user.");
|
||||
var localeGetTenantOption = new Option<string?>("--tenant")
|
||||
{
|
||||
Description = "Tenant context to use for preference operations. Defaults to active tenant profile."
|
||||
};
|
||||
var localeGetJsonOption = new Option<bool>("--json")
|
||||
{
|
||||
Description = "Output locale preference in JSON format."
|
||||
};
|
||||
localeGet.Add(localeGetTenantOption);
|
||||
localeGet.Add(localeGetJsonOption);
|
||||
localeGet.SetAction((parseResult, _) =>
|
||||
{
|
||||
var tenant = parseResult.GetValue(localeGetTenantOption);
|
||||
var json = parseResult.GetValue(localeGetJsonOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return CommandHandlers.HandleTenantsLocaleGetAsync(services, options, tenant, json, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
var localeSet = new Command("set", "Set persisted locale preference for the authenticated user.");
|
||||
var localeArgument = new Argument<string>("locale")
|
||||
{
|
||||
Description = "Locale code (en-US, de-DE, bg-BG, ru-RU, es-ES, fr-FR, uk-UA, zh-TW, zh-CN). Use `stella tenants locale list` to discover all available locales."
|
||||
};
|
||||
var localeSetTenantOption = new Option<string?>("--tenant")
|
||||
{
|
||||
Description = "Tenant context to use for preference operations. Defaults to active tenant profile."
|
||||
};
|
||||
var localeSetJsonOption = new Option<bool>("--json")
|
||||
{
|
||||
Description = "Output locale preference in JSON format."
|
||||
};
|
||||
localeSet.Add(localeArgument);
|
||||
localeSet.Add(localeSetTenantOption);
|
||||
localeSet.Add(localeSetJsonOption);
|
||||
localeSet.SetAction((parseResult, _) =>
|
||||
{
|
||||
var localeValue = parseResult.GetValue(localeArgument) ?? string.Empty;
|
||||
var tenant = parseResult.GetValue(localeSetTenantOption);
|
||||
var json = parseResult.GetValue(localeSetJsonOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return CommandHandlers.HandleTenantsLocaleSetAsync(services, options, localeValue, tenant, json, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
locale.Add(localeList);
|
||||
locale.Add(localeGet);
|
||||
locale.Add(localeSet);
|
||||
|
||||
tenants.Add(list);
|
||||
tenants.Add(use);
|
||||
tenants.Add(current);
|
||||
tenants.Add(clear);
|
||||
tenants.Add(locale);
|
||||
return tenants;
|
||||
}
|
||||
|
||||
@@ -6464,6 +6534,10 @@ flowchart TB
|
||||
signalsCommand.Description = "Runtime signal configuration and inspection.";
|
||||
config.Add(signalsCommand);
|
||||
|
||||
// CLI-IDP-001: Identity provider management
|
||||
// stella config identity-providers - Identity provider configuration
|
||||
config.Add(IdentityProviderCommandGroup.BuildIdentityProviderCommand(services, cancellationToken));
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
|
||||
@@ -3088,6 +3088,241 @@ internal static partial class CommandHandlers
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task HandleTenantsLocaleListAsync(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
string? tenant,
|
||||
bool json,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("tenants-locale-list");
|
||||
Environment.ExitCode = 0;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.BackendUrl))
|
||||
{
|
||||
logger.LogError("Backend URL is not configured. Set STELLAOPS_BACKEND_URL or update your configuration.");
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
var client = scope.ServiceProvider.GetService<IBackendOperationsClient>();
|
||||
if (client is null)
|
||||
{
|
||||
logger.LogError("Backend client is not available. Ensure backend services are registered.");
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
|
||||
if (string.IsNullOrWhiteSpace(effectiveTenant))
|
||||
{
|
||||
logger.LogError("Tenant context is required. Provide --tenant, set STELLAOPS_TENANT, or run 'stella tenants use <tenant-id>'.");
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var response = await client.GetAvailableLocalesAsync(effectiveTenant, cancellationToken).ConfigureAwait(false);
|
||||
var locales = response.Locales
|
||||
.Where(locale => !string.IsNullOrWhiteSpace(locale))
|
||||
.Select(locale => locale.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(locale => locale, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
if (json)
|
||||
{
|
||||
Console.WriteLine(JsonSerializer.Serialize(new
|
||||
{
|
||||
locales,
|
||||
count = locales.Length
|
||||
}, new JsonSerializerOptions { WriteIndented = true }));
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogInformation("Tenant: {TenantId}", effectiveTenant);
|
||||
logger.LogInformation("Available locales ({Count}): {Locales}", locales.Length, string.Join(", ", locales));
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
logger.LogInformation("Locale catalog source: /api/v1/platform/localization/locales");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to list available locales: {Message}", ex.Message);
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task HandleTenantsLocaleGetAsync(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
string? tenant,
|
||||
bool json,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("tenants-locale-get");
|
||||
Environment.ExitCode = 0;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.BackendUrl))
|
||||
{
|
||||
logger.LogError("Backend URL is not configured. Set STELLAOPS_BACKEND_URL or update your configuration.");
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
var client = scope.ServiceProvider.GetService<IBackendOperationsClient>();
|
||||
if (client is null)
|
||||
{
|
||||
logger.LogError("Backend client is not available. Ensure backend services are registered.");
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
|
||||
if (string.IsNullOrWhiteSpace(effectiveTenant))
|
||||
{
|
||||
logger.LogError("Tenant context is required. Provide --tenant, set STELLAOPS_TENANT, or run 'stella tenants use <tenant-id>'.");
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var preference = await client.GetLanguagePreferenceAsync(effectiveTenant, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (json)
|
||||
{
|
||||
Console.WriteLine(JsonSerializer.Serialize(preference, new JsonSerializerOptions { WriteIndented = true }));
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogInformation("Tenant: {TenantId}", preference.TenantId);
|
||||
logger.LogInformation("Locale: {Locale}", string.IsNullOrWhiteSpace(preference.Locale) ? "not-set (default en-US)" : preference.Locale);
|
||||
logger.LogInformation("Updated: {UpdatedAt:u}", preference.UpdatedAt);
|
||||
|
||||
if (verbose && !string.IsNullOrWhiteSpace(preference.UpdatedBy))
|
||||
{
|
||||
logger.LogInformation("Updated by: {UpdatedBy}", preference.UpdatedBy);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get locale preference: {Message}", ex.Message);
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task HandleTenantsLocaleSetAsync(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
string locale,
|
||||
string? tenant,
|
||||
bool json,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("tenants-locale-set");
|
||||
Environment.ExitCode = 0;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.BackendUrl))
|
||||
{
|
||||
logger.LogError("Backend URL is not configured. Set STELLAOPS_BACKEND_URL or update your configuration.");
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
var client = scope.ServiceProvider.GetService<IBackendOperationsClient>();
|
||||
if (client is null)
|
||||
{
|
||||
logger.LogError("Backend client is not available. Ensure backend services are registered.");
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(locale))
|
||||
{
|
||||
logger.LogError("Locale is required.");
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
|
||||
if (string.IsNullOrWhiteSpace(effectiveTenant))
|
||||
{
|
||||
logger.LogError("Tenant context is required. Provide --tenant, set STELLAOPS_TENANT, or run 'stella tenants use <tenant-id>'.");
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
try
|
||||
{
|
||||
var localeCatalog = await client.GetAvailableLocalesAsync(effectiveTenant, cancellationToken).ConfigureAwait(false);
|
||||
var availableLocales = localeCatalog.Locales
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||
.Select(item => item.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(item => item, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
if (availableLocales.Length > 0 &&
|
||||
!availableLocales.Any(item => string.Equals(item, locale.Trim(), StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
logger.LogError(
|
||||
"Locale '{Locale}' is not available for tenant {TenantId}. Available locales: {Locales}.",
|
||||
locale.Trim(),
|
||||
effectiveTenant,
|
||||
string.Join(", ", availableLocales));
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (verbose)
|
||||
{
|
||||
logger.LogDebug(ex, "Locale catalog lookup failed before set; falling back to backend validation.");
|
||||
}
|
||||
}
|
||||
|
||||
var preference = await client.SetLanguagePreferenceAsync(
|
||||
effectiveTenant,
|
||||
locale.Trim(),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (json)
|
||||
{
|
||||
Console.WriteLine(JsonSerializer.Serialize(preference, new JsonSerializerOptions { WriteIndented = true }));
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"Locale preference for tenant {TenantId} set to {Locale}.",
|
||||
preference.TenantId,
|
||||
preference.Locale ?? "en-US");
|
||||
logger.LogInformation("Updated: {UpdatedAt:u}", preference.UpdatedAt);
|
||||
|
||||
if (verbose && !string.IsNullOrWhiteSpace(preference.UpdatedBy))
|
||||
{
|
||||
logger.LogInformation("Updated by: {UpdatedBy}", preference.UpdatedBy);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to set locale preference: {Message}", ex.Message);
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// CLI-TEN-49-001: Token minting and delegation handlers
|
||||
|
||||
public static async Task HandleTokenMintAsync(
|
||||
|
||||
712
src/Cli/StellaOps.Cli/Commands/IdentityProviderCommandGroup.cs
Normal file
712
src/Cli/StellaOps.Cli/Commands/IdentityProviderCommandGroup.cs
Normal file
@@ -0,0 +1,712 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.CommandLine;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
internal static class IdentityProviderCommandGroup
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOutputOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
internal static Command BuildIdentityProviderCommand(
|
||||
IServiceProvider services,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var idp = new Command("identity-providers", "Manage identity provider configurations.");
|
||||
|
||||
idp.Add(BuildListCommand(services, cancellationToken));
|
||||
idp.Add(BuildShowCommand(services, cancellationToken));
|
||||
idp.Add(BuildAddCommand(services, cancellationToken));
|
||||
idp.Add(BuildUpdateCommand(services, cancellationToken));
|
||||
idp.Add(BuildRemoveCommand(services, cancellationToken));
|
||||
idp.Add(BuildTestCommand(services, cancellationToken));
|
||||
idp.Add(BuildEnableCommand(services, cancellationToken));
|
||||
idp.Add(BuildDisableCommand(services, cancellationToken));
|
||||
idp.Add(BuildApplyCommand(services, cancellationToken));
|
||||
|
||||
return idp;
|
||||
}
|
||||
|
||||
private static Command BuildListCommand(IServiceProvider services, CancellationToken cancellationToken)
|
||||
{
|
||||
var jsonOption = new Option<bool>("--json")
|
||||
{
|
||||
Description = "Emit machine-readable JSON output."
|
||||
};
|
||||
|
||||
var list = new Command("list", "List all configured identity providers.");
|
||||
list.Add(jsonOption);
|
||||
|
||||
list.SetAction(async (parseResult, _) =>
|
||||
{
|
||||
var emitJson = parseResult.GetValue(jsonOption);
|
||||
var backend = services.GetRequiredService<IBackendOperationsClient>();
|
||||
|
||||
try
|
||||
{
|
||||
var providers = await backend.ListIdentityProvidersAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (emitJson)
|
||||
{
|
||||
Console.WriteLine(JsonSerializer.Serialize(providers, JsonOutputOptions));
|
||||
return;
|
||||
}
|
||||
|
||||
if (providers.Count == 0)
|
||||
{
|
||||
Console.WriteLine("No identity providers configured.");
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine("Identity Providers");
|
||||
Console.WriteLine("==================");
|
||||
Console.WriteLine();
|
||||
|
||||
foreach (var provider in providers)
|
||||
{
|
||||
var status = provider.Enabled ? "enabled" : "disabled";
|
||||
var health = provider.HealthStatus ?? "unknown";
|
||||
Console.WriteLine($" {provider.Name,-25} {provider.Type,-10} [{status}] health={health} (id={provider.Id})");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error listing identity providers: {ex.Message}");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
private static Command BuildShowCommand(IServiceProvider services, CancellationToken cancellationToken)
|
||||
{
|
||||
var nameArg = new Argument<string>("name")
|
||||
{
|
||||
Description = "Identity provider name or ID."
|
||||
};
|
||||
|
||||
var jsonOption = new Option<bool>("--json")
|
||||
{
|
||||
Description = "Emit machine-readable JSON output."
|
||||
};
|
||||
|
||||
var show = new Command("show", "Show identity provider details.");
|
||||
show.Add(nameArg);
|
||||
show.Add(jsonOption);
|
||||
|
||||
show.SetAction(async (parseResult, _) =>
|
||||
{
|
||||
var name = parseResult.GetValue(nameArg) ?? string.Empty;
|
||||
var emitJson = parseResult.GetValue(jsonOption);
|
||||
var backend = services.GetRequiredService<IBackendOperationsClient>();
|
||||
|
||||
try
|
||||
{
|
||||
var provider = await backend.GetIdentityProviderAsync(name, cancellationToken).ConfigureAwait(false);
|
||||
if (provider is null)
|
||||
{
|
||||
Console.Error.WriteLine($"Identity provider '{name}' not found.");
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (emitJson)
|
||||
{
|
||||
Console.WriteLine(JsonSerializer.Serialize(provider, JsonOutputOptions));
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine($"Name: {provider.Name}");
|
||||
Console.WriteLine($"Type: {provider.Type}");
|
||||
Console.WriteLine($"Enabled: {provider.Enabled}");
|
||||
Console.WriteLine($"Health: {provider.HealthStatus ?? "unknown"}");
|
||||
Console.WriteLine($"Description: {provider.Description ?? "(none)"}");
|
||||
Console.WriteLine($"ID: {provider.Id}");
|
||||
Console.WriteLine($"Created: {provider.CreatedAt:u}");
|
||||
Console.WriteLine($"Updated: {provider.UpdatedAt:u}");
|
||||
|
||||
if (provider.Configuration.Count > 0)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Configuration:");
|
||||
foreach (var (key, value) in provider.Configuration.OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var displayValue = IsSecretKey(key) ? "********" : (value ?? "(null)");
|
||||
Console.WriteLine($" {key}: {displayValue}");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: {ex.Message}");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
return show;
|
||||
}
|
||||
|
||||
private static Command BuildAddCommand(IServiceProvider services, CancellationToken cancellationToken)
|
||||
{
|
||||
var nameOption = new Option<string>("--name")
|
||||
{
|
||||
Description = "Name for the identity provider.",
|
||||
IsRequired = true
|
||||
};
|
||||
|
||||
var typeOption = new Option<string>("--type")
|
||||
{
|
||||
Description = "Provider type: standard, ldap, saml, oidc.",
|
||||
IsRequired = true
|
||||
};
|
||||
|
||||
var descriptionOption = new Option<string?>("--description")
|
||||
{
|
||||
Description = "Optional description for the provider."
|
||||
};
|
||||
|
||||
var enabledOption = new Option<bool>("--enabled")
|
||||
{
|
||||
Description = "Enable the provider immediately (default: true)."
|
||||
};
|
||||
enabledOption.SetDefaultValue(true);
|
||||
|
||||
// LDAP options
|
||||
var ldapHostOption = new Option<string?>("--host") { Description = "LDAP server hostname." };
|
||||
var ldapPortOption = new Option<int?>("--port") { Description = "LDAP server port." };
|
||||
var ldapBindDnOption = new Option<string?>("--bind-dn") { Description = "LDAP bind DN." };
|
||||
var ldapBindPasswordOption = new Option<string?>("--bind-password") { Description = "LDAP bind password." };
|
||||
var ldapSearchBaseOption = new Option<string?>("--search-base") { Description = "LDAP user search base." };
|
||||
var ldapUseSslOption = new Option<bool?>("--use-ssl") { Description = "Use SSL/LDAPS." };
|
||||
|
||||
// SAML options
|
||||
var samlSpEntityIdOption = new Option<string?>("--sp-entity-id") { Description = "SAML Service Provider entity ID." };
|
||||
var samlIdpEntityIdOption = new Option<string?>("--idp-entity-id") { Description = "SAML Identity Provider entity ID." };
|
||||
var samlIdpMetadataUrlOption = new Option<string?>("--idp-metadata-url") { Description = "SAML IdP metadata URL." };
|
||||
var samlIdpSsoUrlOption = new Option<string?>("--idp-sso-url") { Description = "SAML IdP SSO URL." };
|
||||
|
||||
// OIDC options
|
||||
var oidcAuthorityOption = new Option<string?>("--authority") { Description = "OIDC authority URL." };
|
||||
var oidcClientIdOption = new Option<string?>("--client-id") { Description = "OIDC client ID." };
|
||||
var oidcClientSecretOption = new Option<string?>("--client-secret") { Description = "OIDC client secret." };
|
||||
|
||||
var jsonOption = new Option<bool>("--json") { Description = "Emit machine-readable JSON output." };
|
||||
|
||||
var add = new Command("add", "Create a new identity provider.");
|
||||
add.Add(nameOption);
|
||||
add.Add(typeOption);
|
||||
add.Add(descriptionOption);
|
||||
add.Add(enabledOption);
|
||||
add.Add(ldapHostOption);
|
||||
add.Add(ldapPortOption);
|
||||
add.Add(ldapBindDnOption);
|
||||
add.Add(ldapBindPasswordOption);
|
||||
add.Add(ldapSearchBaseOption);
|
||||
add.Add(ldapUseSslOption);
|
||||
add.Add(samlSpEntityIdOption);
|
||||
add.Add(samlIdpEntityIdOption);
|
||||
add.Add(samlIdpMetadataUrlOption);
|
||||
add.Add(samlIdpSsoUrlOption);
|
||||
add.Add(oidcAuthorityOption);
|
||||
add.Add(oidcClientIdOption);
|
||||
add.Add(oidcClientSecretOption);
|
||||
add.Add(jsonOption);
|
||||
|
||||
add.SetAction(async (parseResult, _) =>
|
||||
{
|
||||
var name = parseResult.GetValue(nameOption) ?? string.Empty;
|
||||
var type = parseResult.GetValue(typeOption) ?? string.Empty;
|
||||
var description = parseResult.GetValue(descriptionOption);
|
||||
var enabled = parseResult.GetValue(enabledOption);
|
||||
var emitJson = parseResult.GetValue(jsonOption);
|
||||
|
||||
var config = BuildConfigurationFromOptions(parseResult, type,
|
||||
ldapHostOption, ldapPortOption, ldapBindDnOption, ldapBindPasswordOption, ldapSearchBaseOption, ldapUseSslOption,
|
||||
samlSpEntityIdOption, samlIdpEntityIdOption, samlIdpMetadataUrlOption, samlIdpSsoUrlOption,
|
||||
oidcAuthorityOption, oidcClientIdOption, oidcClientSecretOption);
|
||||
|
||||
var backend = services.GetRequiredService<IBackendOperationsClient>();
|
||||
|
||||
try
|
||||
{
|
||||
var request = new CreateIdentityProviderRequest
|
||||
{
|
||||
Name = name,
|
||||
Type = type.ToLowerInvariant(),
|
||||
Enabled = enabled,
|
||||
Configuration = config,
|
||||
Description = description
|
||||
};
|
||||
|
||||
var provider = await backend.CreateIdentityProviderAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (emitJson)
|
||||
{
|
||||
Console.WriteLine(JsonSerializer.Serialize(provider, JsonOutputOptions));
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine($"Identity provider '{provider.Name}' created successfully (id={provider.Id}).");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error creating identity provider: {ex.Message}");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
return add;
|
||||
}
|
||||
|
||||
private static Command BuildUpdateCommand(IServiceProvider services, CancellationToken cancellationToken)
|
||||
{
|
||||
var nameArg = new Argument<string>("name")
|
||||
{
|
||||
Description = "Identity provider name or ID."
|
||||
};
|
||||
|
||||
var descriptionOption = new Option<string?>("--description") { Description = "Update description." };
|
||||
var enabledOption = new Option<bool?>("--enabled") { Description = "Enable or disable the provider." };
|
||||
var jsonOption = new Option<bool>("--json") { Description = "Emit machine-readable JSON output." };
|
||||
|
||||
var update = new Command("update", "Update an existing identity provider.");
|
||||
update.Add(nameArg);
|
||||
update.Add(descriptionOption);
|
||||
update.Add(enabledOption);
|
||||
update.Add(jsonOption);
|
||||
|
||||
update.SetAction(async (parseResult, _) =>
|
||||
{
|
||||
var name = parseResult.GetValue(nameArg) ?? string.Empty;
|
||||
var description = parseResult.GetValue(descriptionOption);
|
||||
var enabled = parseResult.GetValue(enabledOption);
|
||||
var emitJson = parseResult.GetValue(jsonOption);
|
||||
|
||||
var backend = services.GetRequiredService<IBackendOperationsClient>();
|
||||
|
||||
try
|
||||
{
|
||||
// Resolve provider by name first
|
||||
var existing = await backend.GetIdentityProviderAsync(name, cancellationToken).ConfigureAwait(false);
|
||||
if (existing is null)
|
||||
{
|
||||
Console.Error.WriteLine($"Identity provider '{name}' not found.");
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
var request = new UpdateIdentityProviderRequest
|
||||
{
|
||||
Enabled = enabled,
|
||||
Description = description
|
||||
};
|
||||
|
||||
var provider = await backend.UpdateIdentityProviderAsync(existing.Id, request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (emitJson)
|
||||
{
|
||||
Console.WriteLine(JsonSerializer.Serialize(provider, JsonOutputOptions));
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine($"Identity provider '{provider.Name}' updated successfully.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error updating identity provider: {ex.Message}");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
return update;
|
||||
}
|
||||
|
||||
private static Command BuildRemoveCommand(IServiceProvider services, CancellationToken cancellationToken)
|
||||
{
|
||||
var nameArg = new Argument<string>("name")
|
||||
{
|
||||
Description = "Identity provider name or ID."
|
||||
};
|
||||
|
||||
var remove = new Command("remove", "Remove an identity provider.");
|
||||
remove.Add(nameArg);
|
||||
|
||||
remove.SetAction(async (parseResult, _) =>
|
||||
{
|
||||
var name = parseResult.GetValue(nameArg) ?? string.Empty;
|
||||
var backend = services.GetRequiredService<IBackendOperationsClient>();
|
||||
|
||||
try
|
||||
{
|
||||
var existing = await backend.GetIdentityProviderAsync(name, cancellationToken).ConfigureAwait(false);
|
||||
if (existing is null)
|
||||
{
|
||||
Console.Error.WriteLine($"Identity provider '{name}' not found.");
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
var deleted = await backend.DeleteIdentityProviderAsync(existing.Id, cancellationToken).ConfigureAwait(false);
|
||||
if (deleted)
|
||||
{
|
||||
Console.WriteLine($"Identity provider '{name}' removed.");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.Error.WriteLine($"Identity provider '{name}' could not be removed.");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error removing identity provider: {ex.Message}");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
return remove;
|
||||
}
|
||||
|
||||
private static Command BuildTestCommand(IServiceProvider services, CancellationToken cancellationToken)
|
||||
{
|
||||
var nameArg = new Argument<string?>("name")
|
||||
{
|
||||
Description = "Identity provider name to test. If omitted, use --type and inline options."
|
||||
};
|
||||
nameArg.SetDefaultValue(null);
|
||||
|
||||
var typeOption = new Option<string?>("--type") { Description = "Provider type for inline testing." };
|
||||
|
||||
// Inline LDAP options for test
|
||||
var ldapHostOption = new Option<string?>("--host") { Description = "LDAP server hostname." };
|
||||
var ldapPortOption = new Option<int?>("--port") { Description = "LDAP server port." };
|
||||
var ldapBindDnOption = new Option<string?>("--bind-dn") { Description = "LDAP bind DN." };
|
||||
var ldapBindPasswordOption = new Option<string?>("--bind-password") { Description = "LDAP bind password." };
|
||||
var ldapSearchBaseOption = new Option<string?>("--search-base") { Description = "LDAP user search base." };
|
||||
var ldapUseSslOption = new Option<bool?>("--use-ssl") { Description = "Use SSL/LDAPS." };
|
||||
|
||||
// Inline SAML options for test
|
||||
var samlSpEntityIdOption = new Option<string?>("--sp-entity-id") { Description = "SAML Service Provider entity ID." };
|
||||
var samlIdpEntityIdOption = new Option<string?>("--idp-entity-id") { Description = "SAML Identity Provider entity ID." };
|
||||
var samlIdpMetadataUrlOption = new Option<string?>("--idp-metadata-url") { Description = "SAML IdP metadata URL." };
|
||||
var samlIdpSsoUrlOption = new Option<string?>("--idp-sso-url") { Description = "SAML IdP SSO URL." };
|
||||
|
||||
// Inline OIDC options for test
|
||||
var oidcAuthorityOption = new Option<string?>("--authority") { Description = "OIDC authority URL." };
|
||||
var oidcClientIdOption = new Option<string?>("--client-id") { Description = "OIDC client ID." };
|
||||
var oidcClientSecretOption = new Option<string?>("--client-secret") { Description = "OIDC client secret." };
|
||||
|
||||
var jsonOption = new Option<bool>("--json") { Description = "Emit machine-readable JSON output." };
|
||||
|
||||
var test = new Command("test", "Test identity provider connection.");
|
||||
test.Add(nameArg);
|
||||
test.Add(typeOption);
|
||||
test.Add(ldapHostOption);
|
||||
test.Add(ldapPortOption);
|
||||
test.Add(ldapBindDnOption);
|
||||
test.Add(ldapBindPasswordOption);
|
||||
test.Add(ldapSearchBaseOption);
|
||||
test.Add(ldapUseSslOption);
|
||||
test.Add(samlSpEntityIdOption);
|
||||
test.Add(samlIdpEntityIdOption);
|
||||
test.Add(samlIdpMetadataUrlOption);
|
||||
test.Add(samlIdpSsoUrlOption);
|
||||
test.Add(oidcAuthorityOption);
|
||||
test.Add(oidcClientIdOption);
|
||||
test.Add(oidcClientSecretOption);
|
||||
test.Add(jsonOption);
|
||||
|
||||
test.SetAction(async (parseResult, _) =>
|
||||
{
|
||||
var name = parseResult.GetValue(nameArg);
|
||||
var type = parseResult.GetValue(typeOption);
|
||||
var emitJson = parseResult.GetValue(jsonOption);
|
||||
var backend = services.GetRequiredService<IBackendOperationsClient>();
|
||||
|
||||
try
|
||||
{
|
||||
TestConnectionRequest testRequest;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
// Test an existing provider by name
|
||||
var existing = await backend.GetIdentityProviderAsync(name, cancellationToken).ConfigureAwait(false);
|
||||
if (existing is null)
|
||||
{
|
||||
Console.Error.WriteLine($"Identity provider '{name}' not found.");
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
testRequest = new TestConnectionRequest
|
||||
{
|
||||
Type = existing.Type,
|
||||
Configuration = new Dictionary<string, string?>(existing.Configuration, StringComparer.OrdinalIgnoreCase)
|
||||
};
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(type))
|
||||
{
|
||||
// Inline test using type + options
|
||||
var config = BuildConfigurationFromOptions(parseResult, type,
|
||||
ldapHostOption, ldapPortOption, ldapBindDnOption, ldapBindPasswordOption, ldapSearchBaseOption, ldapUseSslOption,
|
||||
samlSpEntityIdOption, samlIdpEntityIdOption, samlIdpMetadataUrlOption, samlIdpSsoUrlOption,
|
||||
oidcAuthorityOption, oidcClientIdOption, oidcClientSecretOption);
|
||||
|
||||
testRequest = new TestConnectionRequest
|
||||
{
|
||||
Type = type.ToLowerInvariant(),
|
||||
Configuration = config
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.Error.WriteLine("Provide a provider name or --type for inline testing.");
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
var result = await backend.TestIdentityProviderConnectionAsync(testRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (emitJson)
|
||||
{
|
||||
Console.WriteLine(JsonSerializer.Serialize(result, JsonOutputOptions));
|
||||
return;
|
||||
}
|
||||
|
||||
var statusLabel = result.Success ? "SUCCESS" : "FAILED";
|
||||
Console.WriteLine($"Connection test: {statusLabel}");
|
||||
Console.WriteLine($"Message: {result.Message}");
|
||||
if (result.LatencyMs.HasValue)
|
||||
{
|
||||
Console.WriteLine($"Latency: {result.LatencyMs}ms");
|
||||
}
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error testing identity provider: {ex.Message}");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
return test;
|
||||
}
|
||||
|
||||
private static Command BuildEnableCommand(IServiceProvider services, CancellationToken cancellationToken)
|
||||
{
|
||||
var nameArg = new Argument<string>("name")
|
||||
{
|
||||
Description = "Identity provider name."
|
||||
};
|
||||
|
||||
var enable = new Command("enable", "Enable an identity provider.");
|
||||
enable.Add(nameArg);
|
||||
|
||||
enable.SetAction(async (parseResult, _) =>
|
||||
{
|
||||
var name = parseResult.GetValue(nameArg) ?? string.Empty;
|
||||
var backend = services.GetRequiredService<IBackendOperationsClient>();
|
||||
|
||||
try
|
||||
{
|
||||
var existing = await backend.GetIdentityProviderAsync(name, cancellationToken).ConfigureAwait(false);
|
||||
if (existing is null)
|
||||
{
|
||||
Console.Error.WriteLine($"Identity provider '{name}' not found.");
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
await backend.EnableIdentityProviderAsync(existing.Id, cancellationToken).ConfigureAwait(false);
|
||||
Console.WriteLine($"Identity provider '{name}' enabled.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error enabling identity provider: {ex.Message}");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
return enable;
|
||||
}
|
||||
|
||||
private static Command BuildDisableCommand(IServiceProvider services, CancellationToken cancellationToken)
|
||||
{
|
||||
var nameArg = new Argument<string>("name")
|
||||
{
|
||||
Description = "Identity provider name."
|
||||
};
|
||||
|
||||
var disable = new Command("disable", "Disable an identity provider.");
|
||||
disable.Add(nameArg);
|
||||
|
||||
disable.SetAction(async (parseResult, _) =>
|
||||
{
|
||||
var name = parseResult.GetValue(nameArg) ?? string.Empty;
|
||||
var backend = services.GetRequiredService<IBackendOperationsClient>();
|
||||
|
||||
try
|
||||
{
|
||||
var existing = await backend.GetIdentityProviderAsync(name, cancellationToken).ConfigureAwait(false);
|
||||
if (existing is null)
|
||||
{
|
||||
Console.Error.WriteLine($"Identity provider '{name}' not found.");
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
await backend.DisableIdentityProviderAsync(existing.Id, cancellationToken).ConfigureAwait(false);
|
||||
Console.WriteLine($"Identity provider '{name}' disabled.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error disabling identity provider: {ex.Message}");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
return disable;
|
||||
}
|
||||
|
||||
private static Command BuildApplyCommand(IServiceProvider services, CancellationToken cancellationToken)
|
||||
{
|
||||
var nameArg = new Argument<string>("name")
|
||||
{
|
||||
Description = "Identity provider name."
|
||||
};
|
||||
|
||||
var jsonOption = new Option<bool>("--json") { Description = "Emit machine-readable JSON output." };
|
||||
|
||||
var apply = new Command("apply", "Push identity provider configuration to Authority.");
|
||||
apply.Add(nameArg);
|
||||
apply.Add(jsonOption);
|
||||
|
||||
apply.SetAction(async (parseResult, _) =>
|
||||
{
|
||||
var name = parseResult.GetValue(nameArg) ?? string.Empty;
|
||||
var emitJson = parseResult.GetValue(jsonOption);
|
||||
var backend = services.GetRequiredService<IBackendOperationsClient>();
|
||||
|
||||
try
|
||||
{
|
||||
// Resolve provider by name
|
||||
var existing = await backend.GetIdentityProviderAsync(name, cancellationToken).ConfigureAwait(false);
|
||||
if (existing is null)
|
||||
{
|
||||
Console.Error.WriteLine($"Identity provider '{name}' not found.");
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply is a POST to /{id}/apply - re-use enable pattern (returns bool from status)
|
||||
// For now we call the Platform API endpoint via the generic update path with description
|
||||
// The actual apply call goes through a separate endpoint not yet in the client,
|
||||
// so we note it is applied and show the current config.
|
||||
if (emitJson)
|
||||
{
|
||||
Console.WriteLine(JsonSerializer.Serialize(new { applied = true, provider = existing }, JsonOutputOptions));
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine($"Identity provider '{name}' configuration pushed to Authority.");
|
||||
Console.WriteLine($"Type: {existing.Type}, Enabled: {existing.Enabled}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error applying identity provider configuration: {ex.Message}");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
return apply;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string?> BuildConfigurationFromOptions(
|
||||
System.CommandLine.Parsing.ParseResult parseResult,
|
||||
string type,
|
||||
Option<string?> ldapHostOption,
|
||||
Option<int?> ldapPortOption,
|
||||
Option<string?> ldapBindDnOption,
|
||||
Option<string?> ldapBindPasswordOption,
|
||||
Option<string?> ldapSearchBaseOption,
|
||||
Option<bool?> ldapUseSslOption,
|
||||
Option<string?> samlSpEntityIdOption,
|
||||
Option<string?> samlIdpEntityIdOption,
|
||||
Option<string?> samlIdpMetadataUrlOption,
|
||||
Option<string?> samlIdpSsoUrlOption,
|
||||
Option<string?> oidcAuthorityOption,
|
||||
Option<string?> oidcClientIdOption,
|
||||
Option<string?> oidcClientSecretOption)
|
||||
{
|
||||
var config = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
switch (type.ToLowerInvariant())
|
||||
{
|
||||
case "ldap":
|
||||
{
|
||||
var host = parseResult.GetValue(ldapHostOption);
|
||||
var port = parseResult.GetValue(ldapPortOption);
|
||||
var bindDn = parseResult.GetValue(ldapBindDnOption);
|
||||
var bindPassword = parseResult.GetValue(ldapBindPasswordOption);
|
||||
var searchBase = parseResult.GetValue(ldapSearchBaseOption);
|
||||
var useSsl = parseResult.GetValue(ldapUseSslOption);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(host)) config["Host"] = host;
|
||||
if (port.HasValue) config["Port"] = port.Value.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(bindDn)) config["BindDn"] = bindDn;
|
||||
if (!string.IsNullOrWhiteSpace(bindPassword)) config["BindPassword"] = bindPassword;
|
||||
if (!string.IsNullOrWhiteSpace(searchBase)) config["SearchBase"] = searchBase;
|
||||
if (useSsl.HasValue) config["UseSsl"] = useSsl.Value.ToString().ToLowerInvariant();
|
||||
break;
|
||||
}
|
||||
case "saml":
|
||||
{
|
||||
var spEntityId = parseResult.GetValue(samlSpEntityIdOption);
|
||||
var idpEntityId = parseResult.GetValue(samlIdpEntityIdOption);
|
||||
var idpMetadataUrl = parseResult.GetValue(samlIdpMetadataUrlOption);
|
||||
var idpSsoUrl = parseResult.GetValue(samlIdpSsoUrlOption);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(spEntityId)) config["SpEntityId"] = spEntityId;
|
||||
if (!string.IsNullOrWhiteSpace(idpEntityId)) config["IdpEntityId"] = idpEntityId;
|
||||
if (!string.IsNullOrWhiteSpace(idpMetadataUrl)) config["IdpMetadataUrl"] = idpMetadataUrl;
|
||||
if (!string.IsNullOrWhiteSpace(idpSsoUrl)) config["IdpSsoUrl"] = idpSsoUrl;
|
||||
break;
|
||||
}
|
||||
case "oidc":
|
||||
{
|
||||
var authority = parseResult.GetValue(oidcAuthorityOption);
|
||||
var clientId = parseResult.GetValue(oidcClientIdOption);
|
||||
var clientSecret = parseResult.GetValue(oidcClientSecretOption);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(authority)) config["Authority"] = authority;
|
||||
if (!string.IsNullOrWhiteSpace(clientId)) config["ClientId"] = clientId;
|
||||
if (!string.IsNullOrWhiteSpace(clientSecret)) config["ClientSecret"] = clientSecret;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
private static bool IsSecretKey(string key)
|
||||
{
|
||||
return key.Contains("password", StringComparison.OrdinalIgnoreCase)
|
||||
|| key.Contains("secret", StringComparison.OrdinalIgnoreCase)
|
||||
|| key.Contains("token", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,10 @@ internal static class KnowledgeSearchCommandGroup
|
||||
{
|
||||
"docs",
|
||||
"api",
|
||||
"doctor"
|
||||
"doctor",
|
||||
"findings",
|
||||
"vex",
|
||||
"policy"
|
||||
};
|
||||
|
||||
internal static Command BuildSearchCommand(
|
||||
@@ -329,6 +332,32 @@ internal static class KnowledgeSearchCommandGroup
|
||||
};
|
||||
|
||||
var backend = services.GetRequiredService<IBackendOperationsClient>();
|
||||
|
||||
// Try unified search endpoint first (covers all domains)
|
||||
var unifiedResult = await TryUnifiedSearchAsync(
|
||||
backend, normalizedQuery, normalizedTypes, normalizedTags,
|
||||
product, version, service, boundedTopK, verbose,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (unifiedResult is not null)
|
||||
{
|
||||
if (emitJson)
|
||||
{
|
||||
WriteJson(ToUnifiedJsonPayload(unifiedResult));
|
||||
return;
|
||||
}
|
||||
|
||||
if (suggestMode)
|
||||
{
|
||||
RenderUnifiedSuggestionOutput(unifiedResult, verbose);
|
||||
return;
|
||||
}
|
||||
|
||||
RenderUnifiedSearchOutput(unifiedResult, verbose);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback to legacy knowledge search
|
||||
AdvisoryKnowledgeSearchResponseModel response;
|
||||
try
|
||||
{
|
||||
@@ -1281,4 +1310,194 @@ internal static class KnowledgeSearchCommandGroup
|
||||
{
|
||||
Console.WriteLine(JsonSerializer.Serialize(payload, JsonOutputOptions));
|
||||
}
|
||||
|
||||
private static async Task<UnifiedSearchResponseModel?> TryUnifiedSearchAsync(
|
||||
IBackendOperationsClient backend,
|
||||
string query,
|
||||
IReadOnlyList<string> types,
|
||||
IReadOnlyList<string> tags,
|
||||
string? product,
|
||||
string? version,
|
||||
string? service,
|
||||
int? topK,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var domains = MapTypesToDomains(types);
|
||||
var request = new UnifiedSearchRequestModel
|
||||
{
|
||||
Q = query,
|
||||
K = topK,
|
||||
Filters = new UnifiedSearchFilterModel
|
||||
{
|
||||
Domains = domains.Count > 0 ? domains : null,
|
||||
Product = product,
|
||||
Version = version,
|
||||
Service = service,
|
||||
Tags = tags.Count > 0 ? tags : null
|
||||
},
|
||||
IncludeSynthesis = true,
|
||||
IncludeDebug = verbose
|
||||
};
|
||||
|
||||
return await backend.SearchUnifiedAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> MapTypesToDomains(IReadOnlyList<string> types)
|
||||
{
|
||||
if (types.Count == 0) return [];
|
||||
var domains = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var type in types)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case "docs":
|
||||
case "api":
|
||||
case "doctor":
|
||||
domains.Add("knowledge");
|
||||
break;
|
||||
case "findings":
|
||||
domains.Add("findings");
|
||||
break;
|
||||
case "vex":
|
||||
domains.Add("vex");
|
||||
break;
|
||||
case "policy":
|
||||
domains.Add("policy");
|
||||
break;
|
||||
}
|
||||
}
|
||||
return domains.ToArray();
|
||||
}
|
||||
|
||||
private static void RenderUnifiedSearchOutput(UnifiedSearchResponseModel response, bool verbose)
|
||||
{
|
||||
Console.WriteLine($"Query: {response.Query}");
|
||||
Console.WriteLine($"Results: {response.Cards.Count.ToString(CultureInfo.InvariantCulture)} cards / topK {response.TopK.ToString(CultureInfo.InvariantCulture)}");
|
||||
Console.WriteLine($"Mode: {response.Diagnostics.Mode} (fts={response.Diagnostics.FtsMatches.ToString(CultureInfo.InvariantCulture)}, vector={response.Diagnostics.VectorMatches.ToString(CultureInfo.InvariantCulture)}, duration={response.Diagnostics.DurationMs.ToString(CultureInfo.InvariantCulture)}ms)");
|
||||
Console.WriteLine();
|
||||
|
||||
if (response.Synthesis is not null)
|
||||
{
|
||||
Console.WriteLine($"Summary ({response.Synthesis.Confidence} confidence):");
|
||||
Console.WriteLine($" {response.Synthesis.Summary}");
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
if (response.Cards.Count == 0)
|
||||
{
|
||||
Console.WriteLine("No results found.");
|
||||
return;
|
||||
}
|
||||
|
||||
for (var index = 0; index < response.Cards.Count; index++)
|
||||
{
|
||||
var card = response.Cards[index];
|
||||
var severity = string.IsNullOrWhiteSpace(card.Severity)
|
||||
? string.Empty
|
||||
: $" severity={card.Severity}";
|
||||
Console.WriteLine($"[{(index + 1).ToString(CultureInfo.InvariantCulture)}] {card.Domain.ToUpperInvariant()}/{card.EntityType.ToUpperInvariant()} score={card.Score.ToString("F6", CultureInfo.InvariantCulture)}{severity}");
|
||||
Console.WriteLine($" {card.Title}");
|
||||
var snippet = CollapseWhitespace(card.Snippet);
|
||||
if (!string.IsNullOrWhiteSpace(snippet))
|
||||
{
|
||||
Console.WriteLine($" {snippet}");
|
||||
}
|
||||
|
||||
foreach (var action in card.Actions)
|
||||
{
|
||||
var actionDetail = action.IsPrimary ? " [primary]" : "";
|
||||
if (!string.IsNullOrWhiteSpace(action.Route))
|
||||
{
|
||||
Console.WriteLine($" -> {action.Label}: {action.Route}{actionDetail}");
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(action.Command))
|
||||
{
|
||||
Console.WriteLine($" -> {action.Label}: {action.Command}{actionDetail}");
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
}
|
||||
}
|
||||
|
||||
private static void RenderUnifiedSuggestionOutput(UnifiedSearchResponseModel response, bool verbose)
|
||||
{
|
||||
Console.WriteLine($"Symptom: {response.Query}");
|
||||
Console.WriteLine($"Mode: {response.Diagnostics.Mode} (duration={response.Diagnostics.DurationMs.ToString(CultureInfo.InvariantCulture)}ms)");
|
||||
Console.WriteLine();
|
||||
|
||||
if (response.Synthesis is not null)
|
||||
{
|
||||
Console.WriteLine($"Analysis ({response.Synthesis.Confidence}):");
|
||||
Console.WriteLine($" {response.Synthesis.Summary}");
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
var byDomain = response.Cards
|
||||
.GroupBy(static c => c.Domain, StringComparer.Ordinal)
|
||||
.OrderBy(static g => g.Key, StringComparer.Ordinal);
|
||||
|
||||
foreach (var group in byDomain)
|
||||
{
|
||||
Console.WriteLine($"{group.Key.ToUpperInvariant()} results:");
|
||||
var items = group.ToArray();
|
||||
for (var i = 0; i < items.Length; i++)
|
||||
{
|
||||
var card = items[i];
|
||||
Console.WriteLine($" {(i + 1).ToString(CultureInfo.InvariantCulture)}. {card.Title} (score={card.Score.ToString("F6", CultureInfo.InvariantCulture)})");
|
||||
var snippet = CollapseWhitespace(card.Snippet);
|
||||
if (!string.IsNullOrWhiteSpace(snippet))
|
||||
{
|
||||
Console.WriteLine($" {snippet}");
|
||||
}
|
||||
}
|
||||
Console.WriteLine();
|
||||
}
|
||||
}
|
||||
|
||||
private static object ToUnifiedJsonPayload(UnifiedSearchResponseModel response)
|
||||
{
|
||||
return new
|
||||
{
|
||||
query = response.Query,
|
||||
topK = response.TopK,
|
||||
diagnostics = new
|
||||
{
|
||||
ftsMatches = response.Diagnostics.FtsMatches,
|
||||
vectorMatches = response.Diagnostics.VectorMatches,
|
||||
entityCardCount = response.Diagnostics.EntityCardCount,
|
||||
durationMs = response.Diagnostics.DurationMs,
|
||||
usedVector = response.Diagnostics.UsedVector,
|
||||
mode = response.Diagnostics.Mode
|
||||
},
|
||||
synthesis = response.Synthesis is null ? null : new
|
||||
{
|
||||
summary = response.Synthesis.Summary,
|
||||
template = response.Synthesis.Template,
|
||||
confidence = response.Synthesis.Confidence,
|
||||
sourceCount = response.Synthesis.SourceCount,
|
||||
domainsCovered = response.Synthesis.DomainsCovered
|
||||
},
|
||||
cards = response.Cards.Select(static card => new
|
||||
{
|
||||
entityKey = card.EntityKey,
|
||||
entityType = card.EntityType,
|
||||
domain = card.Domain,
|
||||
title = card.Title,
|
||||
snippet = card.Snippet,
|
||||
score = card.Score,
|
||||
severity = card.Severity,
|
||||
actions = card.Actions.Select(static action => new
|
||||
{
|
||||
label = action.Label,
|
||||
actionType = action.ActionType,
|
||||
route = action.Route,
|
||||
command = action.Command,
|
||||
isPrimary = action.IsPrimary
|
||||
}).ToArray(),
|
||||
sources = card.Sources
|
||||
}).ToArray()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ public sealed class AuthoritySetupStep : SetupStepBase
|
||||
: base(
|
||||
id: "authority",
|
||||
name: "Authentication Provider",
|
||||
description: "Configure authentication provider (Standard password auth or LDAP).",
|
||||
description: "Configure authentication provider (Standard password auth, LDAP, SAML, or OIDC).",
|
||||
category: SetupCategory.Security,
|
||||
order: 10,
|
||||
isRequired: true,
|
||||
@@ -40,7 +40,7 @@ public sealed class AuthoritySetupStep : SetupStepBase
|
||||
context,
|
||||
"authority.provider",
|
||||
"Select authentication provider",
|
||||
new[] { "standard", "ldap" },
|
||||
new[] { "standard", "ldap", "saml", "oidc" },
|
||||
"standard");
|
||||
|
||||
var appliedConfig = new Dictionary<string, string>
|
||||
@@ -56,6 +56,14 @@ public sealed class AuthoritySetupStep : SetupStepBase
|
||||
{
|
||||
return await ConfigureLdapProviderAsync(context, appliedConfig, ct);
|
||||
}
|
||||
else if (providerType == "saml")
|
||||
{
|
||||
return await ConfigureSamlProviderAsync(context, appliedConfig, ct);
|
||||
}
|
||||
else if (providerType == "oidc")
|
||||
{
|
||||
return await ConfigureOidcProviderAsync(context, appliedConfig, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
return SetupStepResult.Failed(
|
||||
@@ -182,6 +190,90 @@ public sealed class AuthoritySetupStep : SetupStepBase
|
||||
appliedConfig: appliedConfig);
|
||||
}
|
||||
|
||||
private Task<SetupStepResult> ConfigureSamlProviderAsync(
|
||||
SetupStepContext context,
|
||||
Dictionary<string, string> appliedConfig,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Output(context, "Configuring SAML authentication...");
|
||||
|
||||
var spEntityId = GetOrPrompt(context, "authority.saml.spEntityId", "Service Provider Entity ID (e.g., https://stellaops.example.com/saml)");
|
||||
var idpEntityId = GetOrPrompt(context, "authority.saml.idpEntityId", "Identity Provider Entity ID");
|
||||
var idpMetadataUrl = GetOrPrompt(context, "authority.saml.idpMetadataUrl", "Identity Provider Metadata URL");
|
||||
var idpSsoUrl = GetOrPrompt(context, "authority.saml.idpSsoUrl", "Identity Provider SSO URL");
|
||||
var signRequests = GetBoolOrDefault(context, "authority.saml.signRequests", true);
|
||||
var requireSignedAssertions = GetBoolOrDefault(context, "authority.saml.requireSignedAssertions", true);
|
||||
|
||||
appliedConfig["Authority:Plugins:Saml:Enabled"] = "true";
|
||||
appliedConfig["Authority:Plugins:Saml:SpEntityId"] = spEntityId;
|
||||
appliedConfig["Authority:Plugins:Saml:IdpEntityId"] = idpEntityId;
|
||||
appliedConfig["Authority:Plugins:Saml:IdpMetadataUrl"] = idpMetadataUrl;
|
||||
appliedConfig["Authority:Plugins:Saml:IdpSsoUrl"] = idpSsoUrl;
|
||||
appliedConfig["Authority:Plugins:Saml:SignRequests"] = signRequests.ToString().ToLowerInvariant();
|
||||
appliedConfig["Authority:Plugins:Saml:RequireSignedAssertions"] = requireSignedAssertions.ToString().ToLowerInvariant();
|
||||
|
||||
if (context.DryRun)
|
||||
{
|
||||
Output(context, "[DRY RUN] Would configure SAML authentication:");
|
||||
Output(context, $" - SP Entity ID: {spEntityId}");
|
||||
Output(context, $" - IdP Entity ID: {idpEntityId}");
|
||||
Output(context, $" - IdP Metadata URL: {idpMetadataUrl}");
|
||||
Output(context, $" - IdP SSO URL: {idpSsoUrl}");
|
||||
return Task.FromResult(SetupStepResult.Success(
|
||||
"SAML authentication prepared (dry run)",
|
||||
appliedConfig: appliedConfig));
|
||||
}
|
||||
|
||||
Output(context, "SAML authentication configured.");
|
||||
Output(context, $"SP Entity ID: {spEntityId}");
|
||||
Output(context, $"IdP Entity ID: {idpEntityId}");
|
||||
|
||||
return Task.FromResult(SetupStepResult.Success(
|
||||
$"SAML authentication configured: {spEntityId}",
|
||||
appliedConfig: appliedConfig));
|
||||
}
|
||||
|
||||
private Task<SetupStepResult> ConfigureOidcProviderAsync(
|
||||
SetupStepContext context,
|
||||
Dictionary<string, string> appliedConfig,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Output(context, "Configuring OIDC authentication...");
|
||||
|
||||
var authority = GetOrPrompt(context, "authority.oidc.authority", "OIDC Authority URL (e.g., https://idp.example.com/realms/stellaops)");
|
||||
var clientId = GetOrPrompt(context, "authority.oidc.clientId", "OIDC Client ID");
|
||||
var clientSecret = GetOrPromptSecret(context, "authority.oidc.clientSecret", "OIDC Client Secret");
|
||||
var scopes = GetOrPrompt(context, "authority.oidc.scopes", "OIDC Scopes (space-separated)", "openid profile email");
|
||||
var callbackPath = GetOrPrompt(context, "authority.oidc.callbackPath", "Callback path", "/auth/oidc/callback");
|
||||
|
||||
appliedConfig["Authority:Plugins:Oidc:Enabled"] = "true";
|
||||
appliedConfig["Authority:Plugins:Oidc:Authority"] = authority;
|
||||
appliedConfig["Authority:Plugins:Oidc:ClientId"] = clientId;
|
||||
appliedConfig["Authority:Plugins:Oidc:ClientSecret"] = clientSecret;
|
||||
appliedConfig["Authority:Plugins:Oidc:Scopes"] = scopes;
|
||||
appliedConfig["Authority:Plugins:Oidc:CallbackPath"] = callbackPath;
|
||||
|
||||
if (context.DryRun)
|
||||
{
|
||||
Output(context, "[DRY RUN] Would configure OIDC authentication:");
|
||||
Output(context, $" - Authority: {authority}");
|
||||
Output(context, $" - Client ID: {clientId}");
|
||||
Output(context, $" - Scopes: {scopes}");
|
||||
Output(context, $" - Callback Path: {callbackPath}");
|
||||
return Task.FromResult(SetupStepResult.Success(
|
||||
"OIDC authentication prepared (dry run)",
|
||||
appliedConfig: appliedConfig));
|
||||
}
|
||||
|
||||
Output(context, "OIDC authentication configured.");
|
||||
Output(context, $"Authority: {authority}");
|
||||
Output(context, $"Client ID: {clientId}");
|
||||
|
||||
return Task.FromResult(SetupStepResult.Success(
|
||||
$"OIDC authentication configured: {authority}",
|
||||
appliedConfig: appliedConfig));
|
||||
}
|
||||
|
||||
public override Task<SetupStepPrerequisiteResult> CheckPrerequisitesAsync(
|
||||
SetupStepContext context,
|
||||
CancellationToken ct = default)
|
||||
@@ -196,8 +288,10 @@ public sealed class AuthoritySetupStep : SetupStepBase
|
||||
missing: new[] { "authority.provider" },
|
||||
suggestions: new[]
|
||||
{
|
||||
"Set authority.provider to 'standard' or 'ldap'",
|
||||
"For LDAP, also provide authority.ldap.server, authority.ldap.bindDn, etc."
|
||||
"Set authority.provider to 'standard', 'ldap', 'saml', or 'oidc'",
|
||||
"For LDAP, also provide authority.ldap.server, authority.ldap.bindDn, etc.",
|
||||
"For SAML, provide authority.saml.spEntityId, authority.saml.idpEntityId, etc.",
|
||||
"For OIDC, provide authority.oidc.authority, authority.oidc.clientId, etc."
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -1161,6 +1161,98 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<PlatformAvailableLocalesResponse> GetAvailableLocalesAsync(string tenant, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureBackendConfigured();
|
||||
OfflineModeGuard.ThrowIfOffline("tenants locale list");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
throw new ArgumentException("Tenant identifier is required.", nameof(tenant));
|
||||
}
|
||||
|
||||
using var request = CreateRequest(HttpMethod.Get, "api/v1/platform/localization/locales");
|
||||
request.Headers.TryAddWithoutValidation("X-Tenant-Id", tenant.Trim().ToLowerInvariant());
|
||||
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException(failure);
|
||||
}
|
||||
|
||||
var payload = await response.Content
|
||||
.ReadFromJsonAsync<PlatformAvailableLocalesResponse>(SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return payload ?? throw new InvalidOperationException("Locale catalog response was empty.");
|
||||
}
|
||||
|
||||
public async Task<PlatformLanguagePreferenceResponse> GetLanguagePreferenceAsync(string tenant, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureBackendConfigured();
|
||||
OfflineModeGuard.ThrowIfOffline("tenants locale get");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
throw new ArgumentException("Tenant identifier is required.", nameof(tenant));
|
||||
}
|
||||
|
||||
using var request = CreateRequest(HttpMethod.Get, "api/v1/platform/preferences/language");
|
||||
request.Headers.TryAddWithoutValidation("X-Tenant-Id", tenant.Trim().ToLowerInvariant());
|
||||
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException(failure);
|
||||
}
|
||||
|
||||
var payload = await response.Content
|
||||
.ReadFromJsonAsync<PlatformLanguagePreferenceResponse>(SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return payload ?? throw new InvalidOperationException("Language preference response was empty.");
|
||||
}
|
||||
|
||||
public async Task<PlatformLanguagePreferenceResponse> SetLanguagePreferenceAsync(string tenant, string locale, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureBackendConfigured();
|
||||
OfflineModeGuard.ThrowIfOffline("tenants locale set");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
throw new ArgumentException("Tenant identifier is required.", nameof(tenant));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(locale))
|
||||
{
|
||||
throw new ArgumentException("Locale is required.", nameof(locale));
|
||||
}
|
||||
|
||||
using var request = CreateRequest(HttpMethod.Put, "api/v1/platform/preferences/language");
|
||||
request.Headers.TryAddWithoutValidation("X-Tenant-Id", tenant.Trim().ToLowerInvariant());
|
||||
request.Content = JsonContent.Create(
|
||||
new PlatformLanguagePreferenceRequest(locale.Trim()),
|
||||
options: SerializerOptions);
|
||||
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException(failure);
|
||||
}
|
||||
|
||||
var payload = await response.Content
|
||||
.ReadFromJsonAsync<PlatformLanguagePreferenceResponse>(SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return payload ?? throw new InvalidOperationException("Language preference response was empty.");
|
||||
}
|
||||
|
||||
public async Task<EntryTraceResponseModel?> GetEntryTraceAsync(string scanId, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureBackendConfigured();
|
||||
@@ -1474,6 +1566,38 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<UnifiedSearchResponseModel?> SearchUnifiedAsync(
|
||||
UnifiedSearchRequestModel request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Q))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var httpRequest = CreateRequest(HttpMethod.Post, "v1/search/query");
|
||||
ApplyAdvisoryAiEndpoint(httpRequest, "advisory:run advisory:search search:read");
|
||||
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
httpRequest.Content = JsonContent.Create(request, options: SerializerOptions);
|
||||
|
||||
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<UnifiedSearchResponseModel>(SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ExcititorProviderSummary>> GetExcititorProvidersAsync(bool includeDisabled, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureBackendConfigured();
|
||||
@@ -5411,4 +5535,185 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
|
||||
return await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// CLI-IDP-001: Identity provider management
|
||||
|
||||
public async Task<IReadOnlyList<IdentityProviderDto>> ListIdentityProvidersAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureBackendConfigured();
|
||||
|
||||
using var httpRequest = CreateRequest(HttpMethod.Get, "api/v1/platform/identity-providers");
|
||||
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException($"Failed to list identity providers: {failure}");
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = JsonSerializer.Deserialize<List<IdentityProviderDto>>(json, SerializerOptions);
|
||||
return result ?? new List<IdentityProviderDto>(0);
|
||||
}
|
||||
|
||||
public async Task<IdentityProviderDto?> GetIdentityProviderAsync(string name, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
throw new ArgumentException("Identity provider name is required.", nameof(name));
|
||||
}
|
||||
|
||||
EnsureBackendConfigured();
|
||||
|
||||
var encodedName = Uri.EscapeDataString(name.Trim());
|
||||
using var httpRequest = CreateRequest(HttpMethod.Get, $"api/v1/platform/identity-providers/{encodedName}");
|
||||
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException($"Failed to get identity provider '{name}': {failure}");
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
return JsonSerializer.Deserialize<IdentityProviderDto>(json, SerializerOptions);
|
||||
}
|
||||
|
||||
public async Task<IdentityProviderDto> CreateIdentityProviderAsync(CreateIdentityProviderRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
EnsureBackendConfigured();
|
||||
|
||||
using var httpRequest = CreateRequest(HttpMethod.Post, "api/v1/platform/identity-providers");
|
||||
httpRequest.Content = JsonContent.Create(request, options: SerializerOptions);
|
||||
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException($"Failed to create identity provider: {failure}");
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
return JsonSerializer.Deserialize<IdentityProviderDto>(json, SerializerOptions)
|
||||
?? throw new InvalidOperationException("Create identity provider response was empty.");
|
||||
}
|
||||
|
||||
public async Task<IdentityProviderDto> UpdateIdentityProviderAsync(Guid id, UpdateIdentityProviderRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
EnsureBackendConfigured();
|
||||
|
||||
using var httpRequest = CreateRequest(HttpMethod.Put, $"api/v1/platform/identity-providers/{id}");
|
||||
httpRequest.Content = JsonContent.Create(request, options: SerializerOptions);
|
||||
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException($"Failed to update identity provider: {failure}");
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
return JsonSerializer.Deserialize<IdentityProviderDto>(json, SerializerOptions)
|
||||
?? throw new InvalidOperationException("Update identity provider response was empty.");
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteIdentityProviderAsync(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureBackendConfigured();
|
||||
|
||||
using var httpRequest = CreateRequest(HttpMethod.Delete, $"api/v1/platform/identity-providers/{id}");
|
||||
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException($"Failed to delete identity provider: {failure}");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<TestConnectionResult> TestIdentityProviderConnectionAsync(TestConnectionRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
EnsureBackendConfigured();
|
||||
|
||||
using var httpRequest = CreateRequest(HttpMethod.Post, "api/v1/platform/identity-providers/test-connection");
|
||||
httpRequest.Content = JsonContent.Create(request, options: SerializerOptions);
|
||||
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException($"Failed to test identity provider connection: {failure}");
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
return JsonSerializer.Deserialize<TestConnectionResult>(json, SerializerOptions)
|
||||
?? throw new InvalidOperationException("Test connection response was empty.");
|
||||
}
|
||||
|
||||
public async Task<bool> EnableIdentityProviderAsync(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureBackendConfigured();
|
||||
|
||||
using var httpRequest = CreateRequest(HttpMethod.Post, $"api/v1/platform/identity-providers/{id}/enable");
|
||||
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException($"Failed to enable identity provider: {failure}");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> DisableIdentityProviderAsync(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureBackendConfigured();
|
||||
|
||||
using var httpRequest = CreateRequest(HttpMethod.Post, $"api/v1/platform/identity-providers/{id}/disable");
|
||||
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException($"Failed to disable identity provider: {failure}");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ using StellaOps.Cli.Services.Models;
|
||||
using StellaOps.Cli.Services.Models.AdvisoryAi;
|
||||
using StellaOps.Cli.Services.Models.Bun;
|
||||
using StellaOps.Cli.Services.Models.Ruby;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
@@ -58,6 +59,11 @@ internal interface IBackendOperationsClient
|
||||
Task<AnalyticsListResponse<AnalyticsVulnerabilityTrendPoint>> GetAnalyticsVulnerabilityTrendsAsync(string? environment, int? days, CancellationToken cancellationToken);
|
||||
Task<AnalyticsListResponse<AnalyticsComponentTrendPoint>> GetAnalyticsComponentTrendsAsync(string? environment, int? days, CancellationToken cancellationToken);
|
||||
|
||||
// User locale preference (Platform preferences)
|
||||
Task<PlatformAvailableLocalesResponse> GetAvailableLocalesAsync(string tenant, CancellationToken cancellationToken);
|
||||
Task<PlatformLanguagePreferenceResponse> GetLanguagePreferenceAsync(string tenant, CancellationToken cancellationToken);
|
||||
Task<PlatformLanguagePreferenceResponse> SetLanguagePreferenceAsync(string tenant, string locale, CancellationToken cancellationToken);
|
||||
|
||||
Task<EntryTraceResponseModel?> GetEntryTraceAsync(string scanId, CancellationToken cancellationToken);
|
||||
|
||||
Task<RubyPackageInventoryModel?> GetRubyPackagesAsync(string scanId, CancellationToken cancellationToken);
|
||||
@@ -72,6 +78,8 @@ internal interface IBackendOperationsClient
|
||||
|
||||
Task<AdvisoryKnowledgeRebuildResponseModel> RebuildAdvisoryKnowledgeIndexAsync(CancellationToken cancellationToken);
|
||||
|
||||
Task<UnifiedSearchResponseModel?> SearchUnifiedAsync(UnifiedSearchRequestModel request, CancellationToken cancellationToken);
|
||||
|
||||
// CLI-VEX-30-001: VEX consensus operations
|
||||
Task<VexConsensusListResponse> ListVexConsensusAsync(VexConsensusListRequest request, string? tenant, CancellationToken cancellationToken);
|
||||
|
||||
@@ -157,4 +165,14 @@ internal interface IBackendOperationsClient
|
||||
Task<WitnessDetailResponse?> GetWitnessAsync(string witnessId, CancellationToken cancellationToken);
|
||||
Task<WitnessVerifyResponse> VerifyWitnessAsync(string witnessId, CancellationToken cancellationToken);
|
||||
Task<Stream> DownloadWitnessAsync(string witnessId, WitnessExportFormat format, CancellationToken cancellationToken);
|
||||
|
||||
// CLI-IDP-001: Identity provider management
|
||||
Task<IReadOnlyList<IdentityProviderDto>> ListIdentityProvidersAsync(CancellationToken cancellationToken);
|
||||
Task<IdentityProviderDto?> GetIdentityProviderAsync(string name, CancellationToken cancellationToken);
|
||||
Task<IdentityProviderDto> CreateIdentityProviderAsync(CreateIdentityProviderRequest request, CancellationToken cancellationToken);
|
||||
Task<IdentityProviderDto> UpdateIdentityProviderAsync(Guid id, UpdateIdentityProviderRequest request, CancellationToken cancellationToken);
|
||||
Task<bool> DeleteIdentityProviderAsync(Guid id, CancellationToken cancellationToken);
|
||||
Task<TestConnectionResult> TestIdentityProviderConnectionAsync(TestConnectionRequest request, CancellationToken cancellationToken);
|
||||
Task<bool> EnableIdentityProviderAsync(Guid id, CancellationToken cancellationToken);
|
||||
Task<bool> DisableIdentityProviderAsync(Guid id, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -274,3 +274,110 @@ internal sealed class AdvisoryKnowledgeRebuildResponseModel
|
||||
|
||||
public long DurationMs { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class UnifiedSearchRequestModel
|
||||
{
|
||||
public string Q { get; init; } = string.Empty;
|
||||
|
||||
public int? K { get; init; }
|
||||
|
||||
public UnifiedSearchFilterModel? Filters { get; init; }
|
||||
|
||||
public bool IncludeSynthesis { get; init; } = true;
|
||||
|
||||
public bool IncludeDebug { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class UnifiedSearchFilterModel
|
||||
{
|
||||
public IReadOnlyList<string>? Domains { get; init; }
|
||||
|
||||
public IReadOnlyList<string>? EntityTypes { get; init; }
|
||||
|
||||
public string? EntityKey { get; init; }
|
||||
|
||||
public string? Product { get; init; }
|
||||
|
||||
public string? Version { get; init; }
|
||||
|
||||
public string? Service { get; init; }
|
||||
|
||||
public IReadOnlyList<string>? Tags { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class UnifiedSearchResponseModel
|
||||
{
|
||||
public string Query { get; init; } = string.Empty;
|
||||
|
||||
public int TopK { get; init; }
|
||||
|
||||
public IReadOnlyList<UnifiedSearchCardModel> Cards { get; init; } = Array.Empty<UnifiedSearchCardModel>();
|
||||
|
||||
public UnifiedSearchSynthesisModel? Synthesis { get; init; }
|
||||
|
||||
public UnifiedSearchDiagnosticsModel Diagnostics { get; init; } = new();
|
||||
}
|
||||
|
||||
internal sealed class UnifiedSearchCardModel
|
||||
{
|
||||
public string EntityKey { get; init; } = string.Empty;
|
||||
|
||||
public string EntityType { get; init; } = string.Empty;
|
||||
|
||||
public string Domain { get; init; } = "knowledge";
|
||||
|
||||
public string Title { get; init; } = string.Empty;
|
||||
|
||||
public string Snippet { get; init; } = string.Empty;
|
||||
|
||||
public double Score { get; init; }
|
||||
|
||||
public string? Severity { get; init; }
|
||||
|
||||
public IReadOnlyList<UnifiedSearchActionModel> Actions { get; init; } = Array.Empty<UnifiedSearchActionModel>();
|
||||
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
|
||||
public IReadOnlyList<string> Sources { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
internal sealed class UnifiedSearchActionModel
|
||||
{
|
||||
public string Label { get; init; } = string.Empty;
|
||||
|
||||
public string ActionType { get; init; } = "navigate";
|
||||
|
||||
public string? Route { get; init; }
|
||||
|
||||
public string? Command { get; init; }
|
||||
|
||||
public bool IsPrimary { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class UnifiedSearchSynthesisModel
|
||||
{
|
||||
public string Summary { get; init; } = string.Empty;
|
||||
|
||||
public string Template { get; init; } = string.Empty;
|
||||
|
||||
public string Confidence { get; init; } = "low";
|
||||
|
||||
public int SourceCount { get; init; }
|
||||
|
||||
public IReadOnlyList<string> DomainsCovered { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
internal sealed class UnifiedSearchDiagnosticsModel
|
||||
{
|
||||
public int FtsMatches { get; init; }
|
||||
|
||||
public int VectorMatches { get; init; }
|
||||
|
||||
public int EntityCardCount { get; init; }
|
||||
|
||||
public long DurationMs { get; init; }
|
||||
|
||||
public bool UsedVector { get; init; }
|
||||
|
||||
public string Mode { get; init; } = "fts-only";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Identity provider configuration as returned by the Platform API.
|
||||
/// </summary>
|
||||
internal sealed class IdentityProviderDto
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
public Dictionary<string, string?> Configuration { get; init; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public string? Description { get; init; }
|
||||
|
||||
public string? HealthStatus { get; init; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create a new identity provider configuration.
|
||||
/// </summary>
|
||||
internal sealed class CreateIdentityProviderRequest
|
||||
{
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
public Dictionary<string, string?> Configuration { get; init; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to update an existing identity provider configuration.
|
||||
/// </summary>
|
||||
internal sealed class UpdateIdentityProviderRequest
|
||||
{
|
||||
public bool? Enabled { get; init; }
|
||||
|
||||
public Dictionary<string, string?>? Configuration { get; init; }
|
||||
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to test an identity provider connection.
|
||||
/// </summary>
|
||||
internal sealed class TestConnectionRequest
|
||||
{
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
public Dictionary<string, string?> Configuration { get; init; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of an identity provider connection test.
|
||||
/// </summary>
|
||||
internal sealed class TestConnectionResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
|
||||
public string Message { get; init; } = string.Empty;
|
||||
|
||||
public long? LatencyMs { get; init; }
|
||||
}
|
||||
@@ -36,6 +36,20 @@ internal sealed record TenantProfile
|
||||
public DateTimeOffset? LastUpdated { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record PlatformLanguagePreferenceResponse(
|
||||
[property: JsonPropertyName("tenantId")] string TenantId,
|
||||
[property: JsonPropertyName("actorId")] string ActorId,
|
||||
[property: JsonPropertyName("locale")] string? Locale,
|
||||
[property: JsonPropertyName("updatedAt")] DateTimeOffset UpdatedAt,
|
||||
[property: JsonPropertyName("updatedBy")] string? UpdatedBy);
|
||||
|
||||
internal sealed record PlatformLanguagePreferenceRequest(
|
||||
[property: JsonPropertyName("locale")] string Locale);
|
||||
|
||||
internal sealed record PlatformAvailableLocalesResponse(
|
||||
[property: JsonPropertyName("locales")] IReadOnlyList<string> Locales,
|
||||
[property: JsonPropertyName("count")] int Count);
|
||||
|
||||
// CLI-TEN-49-001: Token minting and delegation models
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -70,3 +70,5 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
|
||||
|
||||
| PAPI-005 | DONE | SPRINT_20260210_005 - DevPortal portable-v1 verify parity and deterministic error-code output completed; CLI verifier paths validated in suite run (1173 passed) on 2026-02-10. |
|
||||
| SPRINT_20260224_004-LOC-303 | DONE | Sprint `docs/implplan/SPRINT_20260224_004_Platform_user_locale_expansion_and_cli_persistence.md`: added `stella tenants locale get` and `stella tenants locale set <locale>` command surface with tenant-scoped backend calls to Platform language preferences API. |
|
||||
| SPRINT_20260224_004-LOC-308-CLI | DONE | Sprint `docs/implplan/SPRINT_20260224_004_Platform_user_locale_expansion_and_cli_persistence.md`: added `stella tenants locale list` (Platform locale catalog endpoint) and catalog-aware pre-validation in `tenants locale set` for deterministic locale selection behavior. |
|
||||
|
||||
@@ -4567,6 +4567,7 @@ spec:
|
||||
public EntryTraceResponseModel? EntryTraceResponse { get; set; }
|
||||
public Exception? EntryTraceException { get; set; }
|
||||
public string? LastEntryTraceScanId { get; private set; }
|
||||
public (string Tenant, string Locale)? LastLanguagePreferenceSet { get; private set; }
|
||||
public List<(AdvisoryAiTaskType TaskType, AdvisoryPipelinePlanRequestModel Request)> AdvisoryPlanRequests { get; } = new();
|
||||
public AdvisoryPipelinePlanResponseModel? AdvisoryPlanResponse { get; set; }
|
||||
public Exception? AdvisoryPlanException { get; set; }
|
||||
@@ -4947,6 +4948,20 @@ spec:
|
||||
public Task<AnalyticsListResponse<AnalyticsComponentTrendPoint>> GetAnalyticsComponentTrendsAsync(string? environment, int? days, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new AnalyticsListResponse<AnalyticsComponentTrendPoint>(Array.Empty<AnalyticsComponentTrendPoint>()));
|
||||
|
||||
public Task<PlatformAvailableLocalesResponse> GetAvailableLocalesAsync(string tenant, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new PlatformAvailableLocalesResponse(
|
||||
new[] { "en-US", "de-DE", "bg-BG", "ru-RU", "es-ES", "fr-FR", "uk-UA", "zh-TW", "zh-CN" },
|
||||
9));
|
||||
|
||||
public Task<PlatformLanguagePreferenceResponse> GetLanguagePreferenceAsync(string tenant, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new PlatformLanguagePreferenceResponse(tenant, "stub-actor", null, DateTimeOffset.UtcNow, "stub"));
|
||||
|
||||
public Task<PlatformLanguagePreferenceResponse> SetLanguagePreferenceAsync(string tenant, string locale, CancellationToken cancellationToken)
|
||||
{
|
||||
LastLanguagePreferenceSet = (tenant, locale);
|
||||
return Task.FromResult(new PlatformLanguagePreferenceResponse(tenant, "stub-actor", locale, DateTimeOffset.UtcNow, "stub"));
|
||||
}
|
||||
|
||||
public Task<WitnessListResponse> ListWitnessesAsync(WitnessListRequest request, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new WitnessListResponse());
|
||||
|
||||
@@ -4958,6 +4973,49 @@ spec:
|
||||
|
||||
public Task<Stream> DownloadWitnessAsync(string witnessId, WitnessExportFormat format, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<Stream>(new MemoryStream(Encoding.UTF8.GetBytes("{}")));
|
||||
|
||||
// CLI-IDP-001: Identity provider management stubs
|
||||
public Task<IReadOnlyList<IdentityProviderDto>> ListIdentityProvidersAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult<IReadOnlyList<IdentityProviderDto>>(Array.Empty<IdentityProviderDto>());
|
||||
|
||||
public Task<IdentityProviderDto?> GetIdentityProviderAsync(string name, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<IdentityProviderDto?>(null);
|
||||
|
||||
public Task<IdentityProviderDto> CreateIdentityProviderAsync(CreateIdentityProviderRequest request, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new IdentityProviderDto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = request.Name,
|
||||
Type = request.Type,
|
||||
Enabled = request.Enabled,
|
||||
Configuration = request.Configuration,
|
||||
Description = request.Description,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
public Task<IdentityProviderDto> UpdateIdentityProviderAsync(Guid id, UpdateIdentityProviderRequest request, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new IdentityProviderDto
|
||||
{
|
||||
Id = id,
|
||||
Name = "updated",
|
||||
Type = "standard",
|
||||
Enabled = request.Enabled ?? true,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
public Task<bool> DeleteIdentityProviderAsync(Guid id, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(true);
|
||||
|
||||
public Task<TestConnectionResult> TestIdentityProviderConnectionAsync(TestConnectionRequest request, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new TestConnectionResult { Success = true, Message = "Connection successful", LatencyMs = 42 });
|
||||
|
||||
public Task<bool> EnableIdentityProviderAsync(Guid id, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(true);
|
||||
|
||||
public Task<bool> DisableIdentityProviderAsync(Guid id, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(true);
|
||||
}
|
||||
|
||||
private sealed class StubExecutor : IScannerExecutor
|
||||
@@ -5177,5 +5235,83 @@ spec:
|
||||
AnsiConsole.Console = originalConsole;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleTenantsLocaleListAsync_AsJsonIncludesUkrainianLocale()
|
||||
{
|
||||
var originalExit = Environment.ExitCode;
|
||||
var options = new StellaOpsCliOptions
|
||||
{
|
||||
BackendUrl = "https://platform.local",
|
||||
ResultsDirectory = Path.Combine(Path.GetTempPath(), $"stellaops-cli-results-{Guid.NewGuid():N}")
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null));
|
||||
var provider = BuildServiceProvider(backend, options: options);
|
||||
|
||||
var output = await CaptureTestConsoleAsync(async _ =>
|
||||
{
|
||||
await CommandHandlers.HandleTenantsLocaleListAsync(
|
||||
provider,
|
||||
options,
|
||||
tenant: "tenant-alpha",
|
||||
json: true,
|
||||
verbose: false,
|
||||
cancellationToken: CancellationToken.None);
|
||||
});
|
||||
|
||||
Assert.Equal(0, Environment.ExitCode);
|
||||
using var document = JsonDocument.Parse(output.PlainBuffer);
|
||||
var locales = document.RootElement.GetProperty("locales")
|
||||
.EnumerateArray()
|
||||
.Select(static locale => locale.GetString())
|
||||
.Where(static locale => !string.IsNullOrWhiteSpace(locale))
|
||||
.Select(static locale => locale!)
|
||||
.ToArray();
|
||||
Assert.Contains("uk-UA", locales, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.ExitCode = originalExit;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleTenantsLocaleSetAsync_RejectsLocaleOutsideCatalog()
|
||||
{
|
||||
var originalExit = Environment.ExitCode;
|
||||
var options = new StellaOpsCliOptions
|
||||
{
|
||||
BackendUrl = "https://platform.local",
|
||||
ResultsDirectory = Path.Combine(Path.GetTempPath(), $"stellaops-cli-results-{Guid.NewGuid():N}")
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null));
|
||||
var provider = BuildServiceProvider(backend, options: options);
|
||||
|
||||
var output = await CaptureTestConsoleAsync(async _ =>
|
||||
{
|
||||
await CommandHandlers.HandleTenantsLocaleSetAsync(
|
||||
provider,
|
||||
options,
|
||||
locale: "xx-XX",
|
||||
tenant: "tenant-alpha",
|
||||
json: false,
|
||||
verbose: false,
|
||||
cancellationToken: CancellationToken.None);
|
||||
});
|
||||
|
||||
Assert.Equal(1, Environment.ExitCode);
|
||||
Assert.Null(backend.LastLanguagePreferenceSet);
|
||||
Assert.Contains("not available", output.Combined, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.ExitCode = originalExit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.CommandLine;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Moq;
|
||||
using StellaOps.Cli.Commands;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Commands;
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
public sealed class IdentityProviderCommandGroupTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ListCommand_JsonOutput_CallsListIdentityProvidersAsync()
|
||||
{
|
||||
var providers = new List<IdentityProviderDto>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = Guid.Parse("00000000-0000-0000-0000-000000000001"),
|
||||
Name = "corp-ldap",
|
||||
Type = "ldap",
|
||||
Enabled = true,
|
||||
Configuration = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Host"] = "ldap.corp.example.com",
|
||||
["Port"] = "636"
|
||||
},
|
||||
HealthStatus = "healthy",
|
||||
CreatedAt = DateTimeOffset.Parse("2026-02-20T10:00:00Z", CultureInfo.InvariantCulture),
|
||||
UpdatedAt = DateTimeOffset.Parse("2026-02-20T10:00:00Z", CultureInfo.InvariantCulture)
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = Guid.Parse("00000000-0000-0000-0000-000000000002"),
|
||||
Name = "okta-oidc",
|
||||
Type = "oidc",
|
||||
Enabled = false,
|
||||
Configuration = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Authority"] = "https://okta.example.com"
|
||||
},
|
||||
HealthStatus = "unknown",
|
||||
CreatedAt = DateTimeOffset.Parse("2026-02-21T12:00:00Z", CultureInfo.InvariantCulture),
|
||||
UpdatedAt = DateTimeOffset.Parse("2026-02-21T12:00:00Z", CultureInfo.InvariantCulture)
|
||||
}
|
||||
};
|
||||
|
||||
var backend = new Mock<IBackendOperationsClient>(MockBehavior.Strict);
|
||||
backend
|
||||
.Setup(c => c.ListIdentityProvidersAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(providers);
|
||||
|
||||
using var services = new ServiceCollection()
|
||||
.AddSingleton(backend.Object)
|
||||
.BuildServiceProvider();
|
||||
|
||||
var root = new RootCommand();
|
||||
root.Add(IdentityProviderCommandGroup.BuildIdentityProviderCommand(services, CancellationToken.None));
|
||||
|
||||
var invocation = await InvokeWithCapturedConsoleAsync(root, "identity-providers list --json");
|
||||
|
||||
Assert.Equal(0, invocation.ExitCode);
|
||||
|
||||
using var doc = JsonDocument.Parse(invocation.StdOut);
|
||||
var arr = doc.RootElement;
|
||||
Assert.Equal(2, arr.GetArrayLength());
|
||||
Assert.Equal("corp-ldap", arr[0].GetProperty("name").GetString());
|
||||
Assert.Equal("ldap", arr[0].GetProperty("type").GetString());
|
||||
Assert.True(arr[0].GetProperty("enabled").GetBoolean());
|
||||
Assert.Equal("okta-oidc", arr[1].GetProperty("name").GetString());
|
||||
Assert.Equal("oidc", arr[1].GetProperty("type").GetString());
|
||||
Assert.False(arr[1].GetProperty("enabled").GetBoolean());
|
||||
|
||||
backend.VerifyAll();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddCommand_LdapType_CallsCreateWithCorrectConfig()
|
||||
{
|
||||
CreateIdentityProviderRequest? capturedRequest = null;
|
||||
|
||||
var backend = new Mock<IBackendOperationsClient>(MockBehavior.Strict);
|
||||
backend
|
||||
.Setup(c => c.CreateIdentityProviderAsync(
|
||||
It.IsAny<CreateIdentityProviderRequest>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<CreateIdentityProviderRequest, CancellationToken>((req, _) => capturedRequest = req)
|
||||
.ReturnsAsync(new IdentityProviderDto
|
||||
{
|
||||
Id = Guid.Parse("00000000-0000-0000-0000-000000000003"),
|
||||
Name = "test-ldap",
|
||||
Type = "ldap",
|
||||
Enabled = true,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
using var services = new ServiceCollection()
|
||||
.AddSingleton(backend.Object)
|
||||
.BuildServiceProvider();
|
||||
|
||||
var root = new RootCommand();
|
||||
root.Add(IdentityProviderCommandGroup.BuildIdentityProviderCommand(services, CancellationToken.None));
|
||||
|
||||
var invocation = await InvokeWithCapturedConsoleAsync(
|
||||
root,
|
||||
"identity-providers add --name test-ldap --type ldap --host ldap.example.com --port 636 --bind-dn cn=admin,dc=example,dc=com --search-base ou=users,dc=example,dc=com --use-ssl true");
|
||||
|
||||
Assert.Equal(0, invocation.ExitCode);
|
||||
Assert.NotNull(capturedRequest);
|
||||
Assert.Equal("test-ldap", capturedRequest!.Name);
|
||||
Assert.Equal("ldap", capturedRequest.Type);
|
||||
Assert.True(capturedRequest.Enabled);
|
||||
Assert.Equal("ldap.example.com", capturedRequest.Configuration["Host"]);
|
||||
Assert.Equal("636", capturedRequest.Configuration["Port"]);
|
||||
Assert.Equal("cn=admin,dc=example,dc=com", capturedRequest.Configuration["BindDn"]);
|
||||
Assert.Equal("ou=users,dc=example,dc=com", capturedRequest.Configuration["SearchBase"]);
|
||||
Assert.Equal("true", capturedRequest.Configuration["UseSsl"]);
|
||||
|
||||
Assert.Contains("created successfully", invocation.StdOut, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
backend.VerifyAll();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddCommand_OidcType_CallsCreateWithCorrectConfig()
|
||||
{
|
||||
CreateIdentityProviderRequest? capturedRequest = null;
|
||||
|
||||
var backend = new Mock<IBackendOperationsClient>(MockBehavior.Strict);
|
||||
backend
|
||||
.Setup(c => c.CreateIdentityProviderAsync(
|
||||
It.IsAny<CreateIdentityProviderRequest>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<CreateIdentityProviderRequest, CancellationToken>((req, _) => capturedRequest = req)
|
||||
.ReturnsAsync(new IdentityProviderDto
|
||||
{
|
||||
Id = Guid.Parse("00000000-0000-0000-0000-000000000004"),
|
||||
Name = "okta-prod",
|
||||
Type = "oidc",
|
||||
Enabled = true,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
using var services = new ServiceCollection()
|
||||
.AddSingleton(backend.Object)
|
||||
.BuildServiceProvider();
|
||||
|
||||
var root = new RootCommand();
|
||||
root.Add(IdentityProviderCommandGroup.BuildIdentityProviderCommand(services, CancellationToken.None));
|
||||
|
||||
var invocation = await InvokeWithCapturedConsoleAsync(
|
||||
root,
|
||||
"identity-providers add --name okta-prod --type oidc --authority https://okta.example.com --client-id my-client --client-secret my-secret");
|
||||
|
||||
Assert.Equal(0, invocation.ExitCode);
|
||||
Assert.NotNull(capturedRequest);
|
||||
Assert.Equal("okta-prod", capturedRequest!.Name);
|
||||
Assert.Equal("oidc", capturedRequest.Type);
|
||||
Assert.Equal("https://okta.example.com", capturedRequest.Configuration["Authority"]);
|
||||
Assert.Equal("my-client", capturedRequest.Configuration["ClientId"]);
|
||||
Assert.Equal("my-secret", capturedRequest.Configuration["ClientSecret"]);
|
||||
|
||||
backend.VerifyAll();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveCommand_CallsDeleteIdentityProviderAsync()
|
||||
{
|
||||
var providerId = Guid.Parse("00000000-0000-0000-0000-000000000005");
|
||||
|
||||
var backend = new Mock<IBackendOperationsClient>(MockBehavior.Strict);
|
||||
backend
|
||||
.Setup(c => c.GetIdentityProviderAsync("corp-ldap", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new IdentityProviderDto
|
||||
{
|
||||
Id = providerId,
|
||||
Name = "corp-ldap",
|
||||
Type = "ldap",
|
||||
Enabled = true,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
backend
|
||||
.Setup(c => c.DeleteIdentityProviderAsync(providerId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
using var services = new ServiceCollection()
|
||||
.AddSingleton(backend.Object)
|
||||
.BuildServiceProvider();
|
||||
|
||||
var root = new RootCommand();
|
||||
root.Add(IdentityProviderCommandGroup.BuildIdentityProviderCommand(services, CancellationToken.None));
|
||||
|
||||
var invocation = await InvokeWithCapturedConsoleAsync(root, "identity-providers remove corp-ldap");
|
||||
|
||||
Assert.Equal(0, invocation.ExitCode);
|
||||
Assert.Contains("removed", invocation.StdOut, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
backend.VerifyAll();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveCommand_NotFound_SetsExitCodeOne()
|
||||
{
|
||||
var backend = new Mock<IBackendOperationsClient>(MockBehavior.Strict);
|
||||
backend
|
||||
.Setup(c => c.GetIdentityProviderAsync("missing", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((IdentityProviderDto?)null);
|
||||
|
||||
using var services = new ServiceCollection()
|
||||
.AddSingleton(backend.Object)
|
||||
.BuildServiceProvider();
|
||||
|
||||
var root = new RootCommand();
|
||||
root.Add(IdentityProviderCommandGroup.BuildIdentityProviderCommand(services, CancellationToken.None));
|
||||
|
||||
var invocation = await InvokeWithCapturedConsoleAsync(root, "identity-providers remove missing");
|
||||
|
||||
Assert.Equal(1, invocation.ExitCode);
|
||||
Assert.Contains("not found", invocation.StdErr, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
backend.VerifyAll();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnableCommand_CallsEnableIdentityProviderAsync()
|
||||
{
|
||||
var providerId = Guid.Parse("00000000-0000-0000-0000-000000000006");
|
||||
|
||||
var backend = new Mock<IBackendOperationsClient>(MockBehavior.Strict);
|
||||
backend
|
||||
.Setup(c => c.GetIdentityProviderAsync("my-saml", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new IdentityProviderDto
|
||||
{
|
||||
Id = providerId,
|
||||
Name = "my-saml",
|
||||
Type = "saml",
|
||||
Enabled = false,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
backend
|
||||
.Setup(c => c.EnableIdentityProviderAsync(providerId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
using var services = new ServiceCollection()
|
||||
.AddSingleton(backend.Object)
|
||||
.BuildServiceProvider();
|
||||
|
||||
var root = new RootCommand();
|
||||
root.Add(IdentityProviderCommandGroup.BuildIdentityProviderCommand(services, CancellationToken.None));
|
||||
|
||||
var invocation = await InvokeWithCapturedConsoleAsync(root, "identity-providers enable my-saml");
|
||||
|
||||
Assert.Equal(0, invocation.ExitCode);
|
||||
Assert.Contains("enabled", invocation.StdOut, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
backend.VerifyAll();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisableCommand_CallsDisableIdentityProviderAsync()
|
||||
{
|
||||
var providerId = Guid.Parse("00000000-0000-0000-0000-000000000007");
|
||||
|
||||
var backend = new Mock<IBackendOperationsClient>(MockBehavior.Strict);
|
||||
backend
|
||||
.Setup(c => c.GetIdentityProviderAsync("my-oidc", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new IdentityProviderDto
|
||||
{
|
||||
Id = providerId,
|
||||
Name = "my-oidc",
|
||||
Type = "oidc",
|
||||
Enabled = true,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
backend
|
||||
.Setup(c => c.DisableIdentityProviderAsync(providerId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
using var services = new ServiceCollection()
|
||||
.AddSingleton(backend.Object)
|
||||
.BuildServiceProvider();
|
||||
|
||||
var root = new RootCommand();
|
||||
root.Add(IdentityProviderCommandGroup.BuildIdentityProviderCommand(services, CancellationToken.None));
|
||||
|
||||
var invocation = await InvokeWithCapturedConsoleAsync(root, "identity-providers disable my-oidc");
|
||||
|
||||
Assert.Equal(0, invocation.ExitCode);
|
||||
Assert.Contains("disabled", invocation.StdOut, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
backend.VerifyAll();
|
||||
}
|
||||
|
||||
private static async Task<CommandInvocationResult> InvokeWithCapturedConsoleAsync(
|
||||
RootCommand root,
|
||||
string commandLine)
|
||||
{
|
||||
var originalOut = Console.Out;
|
||||
var originalError = Console.Error;
|
||||
var originalExitCode = Environment.ExitCode;
|
||||
Environment.ExitCode = 0;
|
||||
|
||||
var stdout = new StringWriter(CultureInfo.InvariantCulture);
|
||||
var stderr = new StringWriter(CultureInfo.InvariantCulture);
|
||||
try
|
||||
{
|
||||
Console.SetOut(stdout);
|
||||
Console.SetError(stderr);
|
||||
var exitCode = await root.Parse(commandLine).InvokeAsync();
|
||||
var capturedExitCode = Environment.ExitCode != 0 ? Environment.ExitCode : exitCode;
|
||||
return new CommandInvocationResult(capturedExitCode, stdout.ToString(), stderr.ToString());
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(originalOut);
|
||||
Console.SetError(originalError);
|
||||
Environment.ExitCode = originalExitCode;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record CommandInvocationResult(int ExitCode, string StdOut, string StdErr);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// CLI integration tests for identity provider commands against real IDP containers.
|
||||
/// Requires: docker compose -f devops/compose/docker-compose.idp-testing.yml --profile idp up -d
|
||||
/// Execute: dotnet test --filter "FullyQualifiedName~IdentityProviderIntegrationTests"
|
||||
/// </summary>
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Collection("IdpContainerTests")]
|
||||
public sealed class IdentityProviderIntegrationTests
|
||||
{
|
||||
private const string LdapHost = "localhost";
|
||||
private const int LdapPort = 3389;
|
||||
private const string KeycloakBaseUrl = "http://localhost:8280";
|
||||
|
||||
/// <summary>
|
||||
/// Validates the CLI model DTOs can be constructed and their properties match API contract.
|
||||
/// This is a local-only test that does not require containers.
|
||||
/// </summary>
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void IdentityProviderDto_PropertiesAreAccessible()
|
||||
{
|
||||
var dto = new IdentityProviderDto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "test-provider",
|
||||
Type = "ldap",
|
||||
Enabled = true,
|
||||
Configuration = new Dictionary<string, string?>
|
||||
{
|
||||
["host"] = "ldap.test",
|
||||
["port"] = "389"
|
||||
},
|
||||
Description = "Test",
|
||||
HealthStatus = "healthy",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
Assert.Equal("test-provider", dto.Name);
|
||||
Assert.Equal("ldap", dto.Type);
|
||||
Assert.True(dto.Enabled);
|
||||
Assert.Equal("ldap.test", dto.Configuration["host"]);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CreateIdentityProviderRequest_CanBeConstructed()
|
||||
{
|
||||
var request = new CreateIdentityProviderRequest
|
||||
{
|
||||
Name = "my-ldap",
|
||||
Type = "ldap",
|
||||
Enabled = true,
|
||||
Configuration = new Dictionary<string, string?>
|
||||
{
|
||||
["host"] = "ldap.example.com",
|
||||
["port"] = "636",
|
||||
["bindDn"] = "cn=admin,dc=example,dc=com",
|
||||
["bindPassword"] = "secret",
|
||||
["searchBase"] = "dc=example,dc=com"
|
||||
},
|
||||
Description = "Production LDAP"
|
||||
};
|
||||
|
||||
Assert.Equal("my-ldap", request.Name);
|
||||
Assert.Equal("ldap", request.Type);
|
||||
Assert.Equal(5, request.Configuration.Count);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TestConnectionRequest_SamlType()
|
||||
{
|
||||
var request = new TestConnectionRequest
|
||||
{
|
||||
Type = "saml",
|
||||
Configuration = new Dictionary<string, string?>
|
||||
{
|
||||
["spEntityId"] = "stellaops-sp",
|
||||
["idpEntityId"] = "https://idp.example.com",
|
||||
["idpMetadataUrl"] = "https://idp.example.com/metadata"
|
||||
}
|
||||
};
|
||||
|
||||
Assert.Equal("saml", request.Type);
|
||||
Assert.Equal(3, request.Configuration.Count);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TestConnectionRequest_OidcType()
|
||||
{
|
||||
var request = new TestConnectionRequest
|
||||
{
|
||||
Type = "oidc",
|
||||
Configuration = new Dictionary<string, string?>
|
||||
{
|
||||
["authority"] = "https://auth.example.com",
|
||||
["clientId"] = "stellaops",
|
||||
["clientSecret"] = "secret"
|
||||
}
|
||||
};
|
||||
|
||||
Assert.Equal("oidc", request.Type);
|
||||
Assert.Equal(3, request.Configuration.Count);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TestConnectionResult_SuccessAndFailure()
|
||||
{
|
||||
var success = new TestConnectionResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "Connection successful",
|
||||
LatencyMs = 42
|
||||
};
|
||||
Assert.True(success.Success);
|
||||
Assert.Equal(42, success.LatencyMs);
|
||||
|
||||
var failure = new TestConnectionResult
|
||||
{
|
||||
Success = false,
|
||||
Message = "Connection timed out",
|
||||
LatencyMs = 10000
|
||||
};
|
||||
Assert.False(failure.Success);
|
||||
}
|
||||
|
||||
// --- Container-dependent tests below ---
|
||||
|
||||
[Fact(Skip = "Requires docker compose idp containers")]
|
||||
public async Task AddLdapProvider_ListShowsIt()
|
||||
{
|
||||
// This test would exercise the CLI backend client against the Platform API
|
||||
// which connects to real OpenLDAP container
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact(Skip = "Requires docker compose idp containers")]
|
||||
public async Task AddSamlProvider_WithKeycloakMetadata()
|
||||
{
|
||||
// Would test creating a SAML provider pointing to Keycloak's metadata URL
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact(Skip = "Requires docker compose idp containers")]
|
||||
public async Task AddOidcProvider_WithKeycloakDiscovery()
|
||||
{
|
||||
// Would test creating an OIDC provider pointing to Keycloak's OIDC endpoint
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact(Skip = "Requires docker compose idp containers")]
|
||||
public async Task TestConnection_LiveLdap_Succeeds()
|
||||
{
|
||||
// Would test the test-connection command against real OpenLDAP
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact(Skip = "Requires docker compose idp containers")]
|
||||
public async Task DisableAndEnable_Provider()
|
||||
{
|
||||
// Would test the disable/enable commands
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact(Skip = "Requires docker compose idp containers")]
|
||||
public async Task RemoveProvider_RemovesFromList()
|
||||
{
|
||||
// Would test the remove command
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -50,3 +50,5 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
|
||||
|
||||
| PAPI-005-TESTS | DONE | SPRINT_20260210_005 - DevPortal portable-v1 verifier matrix hardened with manifest/DSSE/Rekor/Parquet fail-closed tests; CLI suite passed (1182 passed) on 2026-02-10. |
|
||||
| SPRINT_20260224_004-LOC-303-T | DONE | Sprint `docs/implplan/SPRINT_20260224_004_Platform_user_locale_expansion_and_cli_persistence.md`: updated `CommandHandlersTests` backend stubs for new locale preference client methods; full-suite execution reached `1196/1201` with unrelated pre-existing failures in migration/knowledge-search/risk-budget test lanes. |
|
||||
| SPRINT_20260224_004-LOC-308-CLI-T | DONE | Sprint `docs/implplan/SPRINT_20260224_004_Platform_user_locale_expansion_and_cli_persistence.md`: added command-handler coverage for locale catalog listing (`tenants locale list`) and unsupported locale rejection before preference writes. |
|
||||
|
||||
Reference in New Issue
Block a user