diff --git a/ingest-v2/pom.xml b/ingest-v2/pom.xml index f06c611e..767e6208 100644 --- a/ingest-v2/pom.xml +++ b/ingest-v2/pom.xml @@ -20,6 +20,7 @@ 1.4.14 11 5.10.0 + 3.3.1 7.15.0 2.0.9 2.46.1 @@ -183,6 +184,34 @@ + + + + org.apache.maven.plugins + maven-resources-plugin + ${maven.resources.plugin.version} + + + copy-well-known-endpoints + process-resources + + copy-resources + + + ${project.build.outputDirectory} + true + + + ${project.basedir}/../data/src/main/resources + + WellKnownKustoEndpoints.json + + + + + + + kotlin-maven-plugin org.jetbrains.kotlin diff --git a/ingest-v2/src/main/kotlin/com/microsoft/azure/kusto/ingest/v2/KustoBaseApiClient.kt b/ingest-v2/src/main/kotlin/com/microsoft/azure/kusto/ingest/v2/KustoBaseApiClient.kt index 5e1830bd..a96f3c94 100644 --- a/ingest-v2/src/main/kotlin/com/microsoft/azure/kusto/ingest/v2/KustoBaseApiClient.kt +++ b/ingest-v2/src/main/kotlin/com/microsoft/azure/kusto/ingest/v2/KustoBaseApiClient.kt @@ -5,6 +5,7 @@ package com.microsoft.azure.kusto.ingest.v2 import com.azure.core.credential.TokenCredential import com.azure.core.credential.TokenRequestContext import com.microsoft.azure.kusto.ingest.v2.apis.DefaultApi +import com.microsoft.azure.kusto.ingest.v2.auth.endpoints.KustoTrustedEndpoints import com.microsoft.azure.kusto.ingest.v2.common.models.ClientDetails import com.microsoft.azure.kusto.ingest.v2.common.serialization.OffsetDateTimeSerializer import io.ktor.client.HttpClientConfig @@ -37,6 +38,15 @@ open class KustoBaseApiClient( ) { private val logger = LoggerFactory.getLogger(KustoBaseApiClient::class.java) + init { + // Validate endpoint is trusted unless security checks are skipped + // Note: dmUrl might be empty/null in some test scenarios (e.g., mocked clients) + // The null check is required for Java interop - Java callers can pass null despite Kotlin's non-null type + if (!skipSecurityChecks && dmUrl != null && dmUrl.isNotBlank()) { + KustoTrustedEndpoints.validateTrustedEndpoint(dmUrl) + } + } + protected val setupConfig: (HttpClientConfig<*>) -> Unit = { config -> getClientConfig(config) } diff --git a/ingest-v2/src/main/kotlin/com/microsoft/azure/kusto/ingest/v2/auth/endpoints/FastSuffixMatcher.kt b/ingest-v2/src/main/kotlin/com/microsoft/azure/kusto/ingest/v2/auth/endpoints/FastSuffixMatcher.kt new file mode 100644 index 00000000..efeb6786 --- /dev/null +++ b/ingest-v2/src/main/kotlin/com/microsoft/azure/kusto/ingest/v2/auth/endpoints/FastSuffixMatcher.kt @@ -0,0 +1,116 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +package com.microsoft.azure.kusto.ingest.v2.auth.endpoints + +/** + * Represents a matching rule for endpoint validation. + * @param suffix The suffix or hostname to match + * @param exact If true, the candidate must exactly match the suffix. If false, candidate must end with the suffix. + */ +data class MatchRule( + val suffix: String, + val exact: Boolean, +) { + val suffixLength: Int + get() = suffix.length +} + +/** + * Result of a match operation. + * @param isMatch Whether the candidate matched + * @param matchedRule The rule that matched, or null if no match + */ +data class MatchResult( + val isMatch: Boolean, + val matchedRule: MatchRule?, +) + +/** + * A fast suffix matcher that efficiently matches hostnames against a set of rules. + * Uses a map indexed by suffix tail for O(1) lookup. + */ +class FastSuffixMatcher private constructor( + private val suffixLength: Int, + private val rules: Map>, +) { + companion object { + /** + * Creates a new matcher with the provided matching rules. + * @param rules One or more matching rules to apply when match is called + * @return FastSuffixMatcher + */ + fun create(rules: List): FastSuffixMatcher { + require(rules.isNotEmpty()) { "Rules cannot be empty" } + + val minRuleLength = rules.minOfOrNull { it.suffixLength } ?: 0 + require(minRuleLength > 0) { + "Cannot have a match rule whose length is zero" + } + + val processedRules = mutableMapOf>() + for (rule in rules) { + val suffix = rule.suffix.takeLast(minRuleLength).lowercase() + processedRules.getOrPut(suffix) { mutableListOf() }.add(rule.copy()) + } + + return FastSuffixMatcher(minRuleLength, processedRules) + } + + /** + * Creates a new matcher with the provided matching rules, extending an + * existing matcher. + * @param existing An existing matcher whose rules are to be baseline + * @param rules One or more matching rules to apply when match is called + * @return FastSuffixMatcher + */ + fun create( + existing: FastSuffixMatcher?, + rules: List, + ): FastSuffixMatcher { + if (existing == null || existing.rules.isEmpty()) { + return create(rules) + } + + if (rules.isEmpty()) { + return existing + } + + val combinedRules = + rules + existing.rules.values.flatten() + return create(combinedRules) + } + } + + /** + * Checks if a candidate string matches any of the rules. + * @param candidate A string to match to the list of match rules + * @return true if at least one of the rules matched + */ + fun isMatch(candidate: String): Boolean = match(candidate).isMatch + + /** + * Matches an input string to the list of match rules. + * @param candidate A string to match + * @return MatchResult with match status and the matched rule if any + */ + fun match(candidate: String): MatchResult { + if (candidate.length < suffixLength) { + return MatchResult(false, null) + } + + val tail = candidate.takeLast(suffixLength).lowercase() + val matchRules = rules[tail] + + if (matchRules != null) { + for (rule in matchRules) { + if (candidate.endsWith(rule.suffix, ignoreCase = true)) { + if (candidate.length == rule.suffix.length || !rule.exact) { + return MatchResult(true, rule) + } + } + } + } + + return MatchResult(false, null) + } +} \ No newline at end of file diff --git a/ingest-v2/src/main/kotlin/com/microsoft/azure/kusto/ingest/v2/auth/endpoints/KustoTrustedEndpoints.kt b/ingest-v2/src/main/kotlin/com/microsoft/azure/kusto/ingest/v2/auth/endpoints/KustoTrustedEndpoints.kt new file mode 100644 index 00000000..33efe691 --- /dev/null +++ b/ingest-v2/src/main/kotlin/com/microsoft/azure/kusto/ingest/v2/auth/endpoints/KustoTrustedEndpoints.kt @@ -0,0 +1,216 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +package com.microsoft.azure.kusto.ingest.v2.auth.endpoints + +import com.microsoft.azure.kusto.ingest.v2.exceptions.KustoClientInvalidConnectionStringException +import org.slf4j.LoggerFactory +import java.net.URI +import java.net.URISyntaxException + +/** + * A helper class to determine which DNS names are "well-known/trusted" + * Kusto endpoints. Untrusted endpoints might require additional configuration + * before they can be used, for security reasons. + */ +object KustoTrustedEndpoints { + private val logger = LoggerFactory.getLogger(KustoTrustedEndpoints::class.java) + + /** + * Global flag to enable/disable endpoint validation. + * When false, untrusted endpoints will only log a warning instead of + * throwing an exception. + */ + @JvmField + @Volatile + var enableWellKnownKustoEndpointsValidation: Boolean = true + + private val matchers: MutableMap = mutableMapOf() + + @Volatile + private var additionalMatcher: FastSuffixMatcher? = null + + @Volatile + private var overrideMatcher: ((String) -> Boolean)? = null + + // Default login endpoint for public cloud + private const val DEFAULT_PUBLIC_LOGIN_ENDPOINT = + "https://login.microsoftonline.com" + + init { + loadEndpointsFromJson() + } + + private fun loadEndpointsFromJson() { + try { + val endpointsData = WellKnownKustoEndpointsData.getInstance() + + endpointsData.allowedEndpointsByLogin.forEach { (loginEndpoint, allowedEndpoints) -> + val rules = mutableListOf() + + // Add suffix rules (exact = false) + allowedEndpoints.allowedKustoSuffixes.forEach { suffix -> + rules.add(MatchRule(suffix, exact = false)) + } + + // Add hostname rules (exact = true) + allowedEndpoints.allowedKustoHostnames.forEach { hostname -> + rules.add(MatchRule(hostname, exact = true)) + } + + if (rules.isNotEmpty()) { + matchers[loginEndpoint.lowercase()] = FastSuffixMatcher.create(rules) + } + } + + logger.debug( + "Loaded {} login endpoint configurations from WellKnownKustoEndpoints.json", + matchers.size, + ) + } catch (ex: Exception) { + logger.error("Failed to load WellKnownKustoEndpoints.json", ex) + throw ex + } + } + + /** + * Sets an override policy for endpoint validation. + * @param matcher Rules that determine if a hostname is a valid/trusted + * Kusto endpoint (replaces existing rules) + */ + fun setOverridePolicy(matcher: ((String) -> Boolean)?) { + overrideMatcher = matcher + } + + /** + * Adds additional trusted hosts to the matcher. + * @param rules A set of rules + * @param replace If true, nullifies the last added rules + */ + fun addTrustedHosts( + rules: List?, + replace: Boolean, + ) { + if (rules.isNullOrEmpty()) { + if (replace) { + additionalMatcher = null + } + return + } + + additionalMatcher = + FastSuffixMatcher.create(if (replace) null else additionalMatcher, rules) + } + + /** + * Validates that the endpoint is trusted. + * @param uri Kusto endpoint URI string + * @param loginEndpoint The login endpoint to check against (optional, defaults to public cloud) + * @throws KustoClientInvalidConnectionStringException if endpoint is not trusted + */ + fun validateTrustedEndpoint( + uri: String, + loginEndpoint: String = DEFAULT_PUBLIC_LOGIN_ENDPOINT, + ) { + try { + validateTrustedEndpoint(URI(uri), loginEndpoint) + } catch (ex: URISyntaxException) { + throw KustoClientInvalidConnectionStringException(uri, ex.message ?: "Invalid URI", ex) + } + } + + /** + * Validates that the endpoint is trusted. + * @param uri Kusto endpoint URI + * @param loginEndpoint The login endpoint to check against + * @throws KustoClientInvalidConnectionStringException if endpoint is not trusted + */ + fun validateTrustedEndpoint( + uri: URI, + loginEndpoint: String, + ) { + val host = uri.host ?: uri.toString() + validateHostnameIsTrusted(host, loginEndpoint) + } + + /** + * Validates that a hostname is trusted. + * @param hostname The hostname to validate + * @param loginEndpoint The login endpoint to check against + * @throws KustoClientInvalidConnectionStringException if hostname is not trusted + */ + private fun validateHostnameIsTrusted( + hostname: String, + loginEndpoint: String, + ) { + // Loopback addresses are unconditionally allowed (we trust ourselves) + if (isLocalAddress(hostname)) { + return + } + + // Check override matcher first + val override = overrideMatcher + if (override != null) { + if (override(hostname)) { + return + } + } else { + // Check against login-specific matchers + val matcher = matchers[loginEndpoint.lowercase()] + if (matcher != null && matcher.isMatch(hostname)) { + return + } + } + + // Check additional matchers + val additional = additionalMatcher + if (additional != null && additional.isMatch(hostname)) { + return + } + + // Not trusted + if (!enableWellKnownKustoEndpointsValidation) { + logger.warn( + "Can't communicate with '{}' as this hostname is currently not trusted; " + + "please see https://aka.ms/kustotrustedendpoints.", + hostname, + ) + return + } + + throw KustoClientInvalidConnectionStringException( + "\$\$ALERT[ValidateHostnameIsTrusted]: Can't communicate with '$hostname' " + + "as this hostname is currently not trusted; " + + "please see https://aka.ms/kustotrustedendpoints", + ) + } + + /** + * Checks if the hostname is a local/loopback address. + */ + private fun isLocalAddress(hostname: String): Boolean { + val lowerHost = hostname.lowercase() + return lowerHost == "localhost" || + lowerHost == "127.0.0.1" || + lowerHost == "::1" || + lowerHost == "[::1]" || + lowerHost.startsWith("localhost:") + } + + /** + * Checks if a hostname is trusted without throwing an exception. + * @param hostname The hostname to check + * @param loginEndpoint The login endpoint to check against + * @return true if the hostname is trusted + */ + fun isTrusted( + hostname: String, + loginEndpoint: String = DEFAULT_PUBLIC_LOGIN_ENDPOINT, + ): Boolean { + return try { + validateHostnameIsTrusted(hostname, loginEndpoint) + true + } catch (_: KustoClientInvalidConnectionStringException) { + false + } + } +} diff --git a/ingest-v2/src/main/kotlin/com/microsoft/azure/kusto/ingest/v2/auth/endpoints/WellKnownKustoEndpointsData.kt b/ingest-v2/src/main/kotlin/com/microsoft/azure/kusto/ingest/v2/auth/endpoints/WellKnownKustoEndpointsData.kt new file mode 100644 index 00000000..978c7608 --- /dev/null +++ b/ingest-v2/src/main/kotlin/com/microsoft/azure/kusto/ingest/v2/auth/endpoints/WellKnownKustoEndpointsData.kt @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +package com.microsoft.azure.kusto.ingest.v2.auth.endpoints + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable as KSerializable +import kotlinx.serialization.json.Json +import org.slf4j.LoggerFactory + +/** + * Data class representing the structure of WellKnownKustoEndpoints.json + */ +@KSerializable +data class AllowedEndpoints( + @SerialName("AllowedKustoSuffixes") + val allowedKustoSuffixes: List = emptyList(), + @SerialName("AllowedKustoHostnames") + val allowedKustoHostnames: List = emptyList(), +) + +@KSerializable +data class WellKnownKustoEndpointsData( + @SerialName("_Comments") + val comments: List = emptyList(), + @SerialName("AllowedEndpointsByLogin") + val allowedEndpointsByLogin: Map = emptyMap(), +) { + companion object { + private val logger = LoggerFactory.getLogger(WellKnownKustoEndpointsData::class.java) + + @Volatile + private var instance: WellKnownKustoEndpointsData? = null + + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + } + + fun getInstance(): WellKnownKustoEndpointsData { + return instance ?: synchronized(this) { + instance ?: readInstance().also { instance = it } + } + } + + private fun readInstance(): WellKnownKustoEndpointsData { + return try { + val resourceStream = WellKnownKustoEndpointsData::class.java + .getResourceAsStream("/WellKnownKustoEndpoints.json") + ?: throw RuntimeException("WellKnownKustoEndpoints.json not found in classpath") + + val content = resourceStream.bufferedReader().use { it.readText() } + json.decodeFromString(content) + } catch (ex: Exception) { + logger.error("Failed to read WellKnownKustoEndpoints.json", ex) + throw RuntimeException("Failed to read WellKnownKustoEndpoints.json", ex) + } + } + } +} diff --git a/ingest-v2/src/main/kotlin/com/microsoft/azure/kusto/ingest/v2/exceptions/KustoClientInvalidConnectionStringException.kt b/ingest-v2/src/main/kotlin/com/microsoft/azure/kusto/ingest/v2/exceptions/KustoClientInvalidConnectionStringException.kt new file mode 100644 index 00000000..7bda6f3f --- /dev/null +++ b/ingest-v2/src/main/kotlin/com/microsoft/azure/kusto/ingest/v2/exceptions/KustoClientInvalidConnectionStringException.kt @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +package com.microsoft.azure.kusto.ingest.v2.exceptions + +/** + * Exception thrown when a connection string is invalid or the endpoint is not + * trusted. + */ +class KustoClientInvalidConnectionStringException : RuntimeException { + constructor(message: String) : super(message) + + constructor( + uri: String, + message: String, + cause: Throwable, + ) : super("Invalid connection string for URI '$uri': $message", cause) +} diff --git a/ingest-v2/src/test/kotlin/com/microsoft/azure/kusto/ingest/v2/TrustedEndpointValidationTest.kt b/ingest-v2/src/test/kotlin/com/microsoft/azure/kusto/ingest/v2/TrustedEndpointValidationTest.kt new file mode 100644 index 00000000..e2b1c489 --- /dev/null +++ b/ingest-v2/src/test/kotlin/com/microsoft/azure/kusto/ingest/v2/TrustedEndpointValidationTest.kt @@ -0,0 +1,272 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +package com.microsoft.azure.kusto.ingest.v2 + +import com.azure.core.credential.AccessToken +import com.azure.core.credential.TokenCredential +import com.azure.core.credential.TokenRequestContext +import com.microsoft.azure.kusto.ingest.v2.auth.endpoints.KustoTrustedEndpoints +import com.microsoft.azure.kusto.ingest.v2.builders.QueuedIngestClientBuilder +import com.microsoft.azure.kusto.ingest.v2.builders.StreamingIngestClientBuilder +import com.microsoft.azure.kusto.ingest.v2.exceptions.KustoClientInvalidConnectionStringException +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertDoesNotThrow +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.api.parallel.Execution +import org.junit.jupiter.api.parallel.ExecutionMode +import reactor.core.publisher.Mono +import java.time.OffsetDateTime +import kotlin.test.assertTrue + +/** + * Tests for endpoint validation functionality in ingest-v2. + * + * These tests verify that: + * 1. Adhoc/untrusted Kusto endpoints are blocked by default + * 2. The skipSecurityChecks() method allows untrusted endpoints + * 3. Trusted Kusto endpoints are allowed by default + * + * The validation is now implemented natively in ingest-v2 using: + * - WellKnownKustoEndpoints.json (copied from data module during build) + * - KustoTrustedEndpoints object for validation logic + * - KustoClientInvalidConnectionStringException for untrusted endpoints + * + * Note: This test class uses SAME_THREAD execution mode to prevent race conditions + * when modifying the global enableWellKnownKustoEndpointsValidation flag. + */ +@Execution(ExecutionMode.SAME_THREAD) +class TrustedEndpointValidationTest { + + // Mock token credential for testing + private val mockTokenCredential = TokenCredential { _: TokenRequestContext -> + Mono.just(AccessToken("mock-token", OffsetDateTime.now().plusHours(1))) + } + + // Example of an adhoc/untrusted endpoint + private val untrustedEndpoint = "https://my-random-adhoc-cluster.example.com" + + // Example of a trusted Kusto endpoint (public cloud) + private val trustedEndpoint = "https://mycluster.kusto.windows.net" + + // Store original validation state to restore after tests + private var originalValidationState: Boolean = true + + @BeforeEach + fun setUp() { + // Save original state and ensure validation is enabled + originalValidationState = + KustoTrustedEndpoints.enableWellKnownKustoEndpointsValidation + KustoTrustedEndpoints.enableWellKnownKustoEndpointsValidation = true + } + + @AfterEach + fun tearDown() { + // Restore original validation state + KustoTrustedEndpoints.enableWellKnownKustoEndpointsValidation = + originalValidationState + } + + // ============================================================================ + // UNTRUSTED ENDPOINT TESTS - Should throw exception + // ============================================================================ + + @Test + @DisplayName("StreamingIngestClient: Untrusted endpoint throws exception without skipSecurityChecks") + fun `streaming client - untrusted endpoint throws without skip security checks`() { + val exception = + assertThrows { + StreamingIngestClientBuilder.create(untrustedEndpoint) + .withAuthentication(mockTokenCredential) + // Note: NOT calling skipSecurityChecks() + .build() + } + + assertTrue( + exception.message?.contains("not trusted") == true || + exception.message?.contains("kustotrustedendpoints") == true, + "Exception should indicate endpoint is not trusted. Actual: ${exception.message}", + ) + } + + @Test + @DisplayName("QueuedIngestClient: Untrusted endpoint throws exception without skipSecurityChecks") + fun `queued client - untrusted endpoint throws without skip security checks`() { + val exception = + assertThrows { + QueuedIngestClientBuilder.create(untrustedEndpoint) + .withAuthentication(mockTokenCredential) + // Note: NOT calling skipSecurityChecks() + .build() + } + + assertTrue( + exception.message?.contains("not trusted") == true || + exception.message?.contains("kustotrustedendpoints") == true, + "Exception should indicate endpoint is not trusted. Actual: ${exception.message}", + ) + } + + // ============================================================================ + // SKIP SECURITY CHECKS TESTS - Should work with the flag + // ============================================================================ + + @Test + @DisplayName("StreamingIngestClient: Untrusted endpoint works with skipSecurityChecks") + fun `streaming client - untrusted endpoint works with skip security checks`() { + assertDoesNotThrow { + StreamingIngestClientBuilder.create(untrustedEndpoint) + .withAuthentication(mockTokenCredential) + .skipSecurityChecks() + .build() + } + } + + @Test + @DisplayName("QueuedIngestClient: Untrusted endpoint works with skipSecurityChecks") + fun `queued client - untrusted endpoint works with skip security checks`() { + assertDoesNotThrow { + QueuedIngestClientBuilder.create(untrustedEndpoint) + .withAuthentication(mockTokenCredential) + .skipSecurityChecks() + .build() + } + } + + // ============================================================================ + // TRUSTED ENDPOINT TESTS - Should work without skipSecurityChecks + // ============================================================================ + + @Test + @DisplayName("StreamingIngestClient: Trusted Kusto endpoint works without skipSecurityChecks") + fun `streaming client - trusted endpoint works without skip security checks`() { + assertDoesNotThrow { + StreamingIngestClientBuilder.create(trustedEndpoint) + .withAuthentication(mockTokenCredential) + .build() + } + } + + @Test + @DisplayName("QueuedIngestClient: Trusted Kusto endpoint works without skipSecurityChecks") + fun `queued client - trusted endpoint works without skip security checks`() { + assertDoesNotThrow { + QueuedIngestClientBuilder.create(trustedEndpoint) + .withAuthentication(mockTokenCredential) + .build() + } + } + + // ============================================================================ + // GLOBAL FLAG TESTS + // ============================================================================ + + @Test + @DisplayName("Global validation flag can disable endpoint checks") + fun `global validation flag disables endpoint checks`() { + // Disable validation globally + KustoTrustedEndpoints.enableWellKnownKustoEndpointsValidation = false + + try { + // Now untrusted endpoints should work even without skipSecurityChecks + assertDoesNotThrow { + StreamingIngestClientBuilder.create(untrustedEndpoint) + .withAuthentication(mockTokenCredential) + .build() + } + } finally { + // Re-enable for other tests + KustoTrustedEndpoints.enableWellKnownKustoEndpointsValidation = true + } + } + + // ============================================================================ + // CLOUD-SPECIFIC ENDPOINT TESTS + // ============================================================================ + + @Test + @DisplayName("Various cloud-specific endpoints are trusted") + fun `cloud specific endpoints are trusted`() { + val trustedEndpoints = + listOf( + // Public cloud + "https://mycluster.kusto.windows.net", + "https://mycluster.kustodev.windows.net", + "https://mycluster.kustomfa.windows.net", + // Fabric + "https://mycluster.kusto.fabric.microsoft.com", + // Synapse + "https://mycluster.kusto.azuresynapse.net", + ) + + trustedEndpoints.forEach { endpoint -> + assertDoesNotThrow("Endpoint $endpoint should be trusted") { + StreamingIngestClientBuilder.create(endpoint) + .withAuthentication(mockTokenCredential) + .build() + } + } + } + + @Test + @DisplayName("Localhost endpoints are always trusted") + fun `localhost endpoints are trusted`() { + val localhostEndpoints = + listOf( + "https://localhost:8080", + "https://127.0.0.1:8080", + "https://localhost", + ) + + localhostEndpoints.forEach { endpoint -> + assertDoesNotThrow("Localhost endpoint $endpoint should be trusted") { + StreamingIngestClientBuilder.create(endpoint) + .withAuthentication(mockTokenCredential) + .build() + } + } + } + + // ============================================================================ + // DIRECT API TESTS - Test KustoTrustedEndpoints directly + // ============================================================================ + + @Test + @DisplayName("KustoTrustedEndpoints.isTrusted returns correct values") + fun `isTrusted returns correct values`() { + assertTrue( + KustoTrustedEndpoints.isTrusted("mycluster.kusto.windows.net"), + "Public cloud endpoint should be trusted", + ) + assertTrue( + KustoTrustedEndpoints.isTrusted("mycluster.kusto.fabric.microsoft.com"), + "Fabric endpoint should be trusted", + ) + assertTrue( + KustoTrustedEndpoints.isTrusted("localhost"), + "Localhost should be trusted", + ) + assertTrue( + !KustoTrustedEndpoints.isTrusted("random.example.com"), + "Random endpoint should not be trusted", + ) + } + + @Test + @DisplayName("KustoTrustedEndpoints.validateTrustedEndpoint throws for untrusted") + fun `validateTrustedEndpoint throws for untrusted endpoints`() { + assertThrows { + KustoTrustedEndpoints.validateTrustedEndpoint("https://evil.example.com") + } + } + + @Test + @DisplayName("KustoTrustedEndpoints.validateTrustedEndpoint passes for trusted") + fun `validateTrustedEndpoint passes for trusted endpoints`() { + assertDoesNotThrow { + KustoTrustedEndpoints.validateTrustedEndpoint("https://mycluster.kusto.windows.net") + } + } +} \ No newline at end of file