diff --git a/build.gradle.kts b/build.gradle.kts index cdfc5e8..f466871 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -202,21 +202,13 @@ tasks.register("cleanAll", Delete::class.java) { private fun getPluginInstallDir(): Path { val userHome = System.getProperty("user.home").let { Path.of(it) } - val toolboxCachesDir = when { + val pluginsDir = when { SystemInfoRt.isWindows -> System.getenv("LOCALAPPDATA")?.let { Path.of(it) } ?: (userHome / "AppData" / "Local") // currently this is the location that TBA uses on Linux SystemInfoRt.isLinux -> System.getenv("XDG_DATA_HOME")?.let { Path.of(it) } ?: (userHome / ".local" / "share") SystemInfoRt.isMac -> userHome / "Library" / "Caches" else -> error("Unknown os") - } / "JetBrains" / "Toolbox" - - val pluginsDir = when { - SystemInfoRt.isWindows || - SystemInfoRt.isLinux || - SystemInfoRt.isMac -> toolboxCachesDir - - else -> error("Unknown os") - } / "plugins" + } / "JetBrains" / "Toolbox" / "plugins" return pluginsDir / extension.id } diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index ed4854c..40ade7a 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -318,6 +318,12 @@ class CoderRemoteProvider( */ override suspend fun handleUri(uri: URI) { try { + + if (context.oauthManager.canHandle(uri)) { + context.oauthManager.handle(uri) + return + } + linkHandler.handle( uri, shouldDoAutoSetup() diff --git a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt index ac3cbcc..e56b500 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt @@ -1,8 +1,11 @@ package com.coder.toolbox +import com.coder.toolbox.oauth.CoderAccount +import com.coder.toolbox.oauth.CoderOAuthCfg import com.coder.toolbox.store.CoderSecretsStore import com.coder.toolbox.store.CoderSettingsStore import com.coder.toolbox.util.toURL +import com.jetbrains.toolbox.api.core.auth.PluginAuthManager import com.jetbrains.toolbox.api.core.diagnostics.Logger import com.jetbrains.toolbox.api.core.os.LocalDesktopManager import com.jetbrains.toolbox.api.localization.LocalizableStringFactory @@ -18,6 +21,7 @@ import java.util.UUID @Suppress("UnstableApiUsage") data class CoderToolboxContext( + val oauthManager: PluginAuthManager, val ui: ToolboxUi, val envPageManager: EnvironmentUiPageManager, val envStateColorPalette: EnvironmentStateColorPalette, diff --git a/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt b/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt index 5cfcd11..ea37e4b 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt @@ -1,5 +1,7 @@ package com.coder.toolbox +import com.coder.toolbox.oauth.CoderAccount +import com.coder.toolbox.oauth.CoderOAuthManager import com.coder.toolbox.settings.Environment import com.coder.toolbox.store.CoderSecretsStore import com.coder.toolbox.store.CoderSettingsStore @@ -29,6 +31,11 @@ class CoderToolboxExtension : RemoteDevExtension { val logger = serviceLocator.getService(Logger::class.java) return CoderRemoteProvider( CoderToolboxContext( + serviceLocator.getAuthManager( + CoderAccount::class.java, + "Coder OAuth2 Manager", + CoderOAuthManager() + ), serviceLocator.getService(), serviceLocator.getService(), serviceLocator.getService(), diff --git a/src/main/kotlin/com/coder/toolbox/oauth/AuthorizationServer.kt b/src/main/kotlin/com/coder/toolbox/oauth/AuthorizationServer.kt new file mode 100644 index 0000000..4248ef1 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/oauth/AuthorizationServer.kt @@ -0,0 +1,24 @@ +package com.coder.toolbox.oauth + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class AuthorizationServer( + @field:Json(name = "authorization_endpoint") val authorizationEndpoint: String, + @field:Json(name = "token_endpoint") val tokenEndpoint: String, + @field:Json(name = "registration_endpoint") val registrationEndpoint: String, + @property:Json(name = "response_types_supported") val supportedResponseTypes: List, + @property:Json(name = "token_endpoint_auth_methods_supported") val authMethodForTokenEndpoint: List, +) + +enum class TokenEndpointAuthMethod { + @Json(name = "none") + NONE, + + @Json(name = "client_secret_post") + CLIENT_SECRET_POST, + + @Json(name = "client_secret_basic") + CLIENT_SECRET_BASIC, +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/oauth/ClientRegistrationRequest.kt b/src/main/kotlin/com/coder/toolbox/oauth/ClientRegistrationRequest.kt new file mode 100644 index 0000000..d0854d1 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/oauth/ClientRegistrationRequest.kt @@ -0,0 +1,14 @@ +package com.coder.toolbox.oauth + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class ClientRegistrationRequest( + @field:Json(name = "client_name") val clientName: String, + @field:Json(name = "redirect_uris") val redirectUris: List, + @field:Json(name = "grant_types") val grantTypes: List, + @field:Json(name = "response_types") val responseTypes: List, + @field:Json(name = "scope") val scope: String, + @field:Json(name = "token_endpoint_auth_method") val tokenEndpointAuthMethod: String? = null +) diff --git a/src/main/kotlin/com/coder/toolbox/oauth/ClientRegistrationResponse.kt b/src/main/kotlin/com/coder/toolbox/oauth/ClientRegistrationResponse.kt new file mode 100644 index 0000000..4ab5d19 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/oauth/ClientRegistrationResponse.kt @@ -0,0 +1,23 @@ +package com.coder.toolbox.oauth + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * DCR response + */ +@JsonClass(generateAdapter = true) +data class ClientRegistrationResponse( + @field:Json(name = "client_id") val clientId: String, + @field:Json(name = "client_secret") val clientSecret: String, + @field:Json(name = "client_name") val clientName: String, + @field:Json(name = "redirect_uris") val redirectUris: List, + @field:Json(name = "grant_types") val grantTypes: List, + @field:Json(name = "response_types") val responseTypes: List, + @field:Json(name = "scope") val scope: String, + @field:Json(name = "token_endpoint_auth_method") val tokenEndpointAuthMethod: String, + @field:Json(name = "client_id_issued_at") val clientIdIssuedAt: Long?, + @field:Json(name = "client_secret_expires_at") val clientSecretExpiresAt: Long?, + @field:Json(name = "registration_client_uri") val registrationClientUri: String, + @field:Json(name = "registration_access_token") val registrationAccessToken: String +) \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/oauth/CoderAccount.kt b/src/main/kotlin/com/coder/toolbox/oauth/CoderAccount.kt new file mode 100644 index 0000000..3b3d787 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/oauth/CoderAccount.kt @@ -0,0 +1,5 @@ +package com.coder.toolbox.oauth + +import com.jetbrains.toolbox.api.core.auth.Account + +data class CoderAccount(override val id: String, override val fullName: String) : Account \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/oauth/CoderAuthorizationApi.kt b/src/main/kotlin/com/coder/toolbox/oauth/CoderAuthorizationApi.kt new file mode 100644 index 0000000..8c7e3fe --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/oauth/CoderAuthorizationApi.kt @@ -0,0 +1,16 @@ +package com.coder.toolbox.oauth + +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST + +interface CoderAuthorizationApi { + @GET(".well-known/oauth-authorization-server") + suspend fun discoveryMetadata(): Response + + @POST("oauth2/register") + suspend fun registerClient( + @Body request: ClientRegistrationRequest + ): Response +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/oauth/CoderOAuthManager.kt b/src/main/kotlin/com/coder/toolbox/oauth/CoderOAuthManager.kt new file mode 100644 index 0000000..2488277 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/oauth/CoderOAuthManager.kt @@ -0,0 +1,92 @@ +package com.coder.toolbox.oauth + +import com.jetbrains.toolbox.api.core.auth.AuthConfiguration +import com.jetbrains.toolbox.api.core.auth.ContentType +import com.jetbrains.toolbox.api.core.auth.ContentType.FORM_URL_ENCODED +import com.jetbrains.toolbox.api.core.auth.OAuthToken +import com.jetbrains.toolbox.api.core.auth.PluginAuthInterface +import com.jetbrains.toolbox.api.core.auth.RefreshConfiguration + +class CoderOAuthManager : PluginAuthInterface { + private lateinit var refreshConf: CoderRefreshConfig + + override fun serialize(account: CoderAccount): String = "${account.id}|${account.fullName}" + + override fun deserialize(string: String): CoderAccount = CoderAccount( + string.split('|')[0], + string.split('|')[1] + ) + + override suspend fun createAccount( + token: OAuthToken, + config: AuthConfiguration + ): CoderAccount { + TODO("Not yet implemented") + } + + override suspend fun updateAccount( + token: OAuthToken, + account: CoderAccount + ): CoderAccount { + TODO("Not yet implemented") + } + + override fun createAuthConfig(loginConfiguration: CoderOAuthCfg): AuthConfiguration { + val codeVerifier = PKCEGenerator.generateCodeVerifier() + val codeChallenge = PKCEGenerator.generateCodeChallenge(codeVerifier) + refreshConf = loginConfiguration.toRefreshConf() + + return AuthConfiguration( + authParams = mapOf( + "client_id" to loginConfiguration.clientId, + "response_type" to "code", + "code_challenge" to codeChallenge + ), + tokenParams = mapOf( + "grant_type" to "authorization_code", + "client_id" to loginConfiguration.clientId, + "code_verifier" to codeVerifier + ), + baseUrl = loginConfiguration.baseUrl, + authUrl = loginConfiguration.authUrl, + tokenUrl = loginConfiguration.tokenUrl, + codeChallengeParamName = "code_challenge", + codeChallengeMethod = "S256", + verifierParamName = "code_verifier", + authorization = null + ) + } + + override fun createRefreshConfig(account: CoderAccount): RefreshConfiguration { + return object : RefreshConfiguration { + override val refreshUrl: String = refreshConf.refreshUrl + override val parameters: Map = mapOf( + "grant_type" to "refresh_token", + "client_id" to refreshConf.clientId, + "client_secret" to refreshConf.clientSecret + ) + override val authorization: String? = null + override val contentType: ContentType = FORM_URL_ENCODED + } + } +} + +data class CoderOAuthCfg( + val baseUrl: String, + val authUrl: String, + val tokenUrl: String, + val clientId: String, + val clientSecret: String, +) + +private data class CoderRefreshConfig( + val refreshUrl: String, + val clientId: String, + val clientSecret: String, +) + +private fun CoderOAuthCfg.toRefreshConf() = CoderRefreshConfig( + refreshUrl = this.tokenUrl, + clientId = this.clientId, + this.clientSecret +) \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/oauth/PKCEGenerator.kt b/src/main/kotlin/com/coder/toolbox/oauth/PKCEGenerator.kt new file mode 100644 index 0000000..fdbefd7 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/oauth/PKCEGenerator.kt @@ -0,0 +1,42 @@ +package com.coder.toolbox.oauth + +import java.security.MessageDigest +import java.security.SecureRandom +import java.util.Base64 + +private const val CODE_VERIFIER_LENGTH = 128 + +/** + * Generates OAuth2 PKCE code verifier and code challenge + */ +object PKCEGenerator { + + /** + * Generates a cryptographically random code verifier 128 chars in size + * @return Base64 URL-encoded code verifier + */ + fun generateCodeVerifier(): String { + val secureRandom = SecureRandom() + val bytes = ByteArray(CODE_VERIFIER_LENGTH) + secureRandom.nextBytes(bytes) + + return Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(bytes) + .take(CODE_VERIFIER_LENGTH) + } + + /** + * Generates code challenge from code verifier using S256 method + * @param codeVerifier The code verifier string + * @return Base64 URL-encoded SHA-256 hash of the code verifier + */ + fun generateCodeChallenge(codeVerifier: String): String { + val digest = MessageDigest.getInstance("SHA-256") + val hash = digest.digest(codeVerifier.toByteArray(Charsets.US_ASCII)) + + return Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(hash) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/util/URLExtensions.kt b/src/main/kotlin/com/coder/toolbox/util/URLExtensions.kt index 7e2a8e3..2f50ab5 100644 --- a/src/main/kotlin/com/coder/toolbox/util/URLExtensions.kt +++ b/src/main/kotlin/com/coder/toolbox/util/URLExtensions.kt @@ -8,6 +8,12 @@ import java.net.URL fun String.toURL(): URL = URI.create(this).toURL() +fun String.toBaseURL(): URL { + val url = this.toURL() + val port = if (url.port != -1) ":${url.port}" else "" + return URI.create("${url.protocol}://${url.host}$port").toURL() +} + fun String.validateStrictWebUrl(): WebUrlValidationResult = try { val uri = URI(this) @@ -21,15 +27,18 @@ fun String.validateStrictWebUrl(): WebUrlValidationResult = try { "The URL \"$this\" is missing a scheme (like https://). " + "Please enter a full web address like \"https://example.com\"" ) + uri.scheme?.lowercase() !in setOf("http", "https") -> Invalid( "The URL \"$this\" must start with http:// or https://, not \"${uri.scheme}\"" ) + uri.authority.isNullOrBlank() -> Invalid( "The URL \"$this\" does not include a valid website name. " + "Please enter a full web address like \"https://example.com\"" ) + else -> Valid } } catch (_: Exception) { diff --git a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt index 27e53f9..d51c0999 100644 --- a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt @@ -1,6 +1,14 @@ package com.coder.toolbox.views import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.browser.browse +import com.coder.toolbox.oauth.ClientRegistrationRequest +import com.coder.toolbox.oauth.CoderAuthorizationApi +import com.coder.toolbox.oauth.CoderOAuthCfg +import com.coder.toolbox.plugin.PluginManager +import com.coder.toolbox.sdk.CoderHttpClientBuilder +import com.coder.toolbox.sdk.convertors.LoggingConverterFactory +import com.coder.toolbox.sdk.interceptors.Interceptors import com.coder.toolbox.util.WebUrlValidationResult.Invalid import com.coder.toolbox.util.toURL import com.coder.toolbox.util.validateStrictWebUrl @@ -14,8 +22,12 @@ import com.jetbrains.toolbox.api.ui.components.RowGroup import com.jetbrains.toolbox.api.ui.components.TextField import com.jetbrains.toolbox.api.ui.components.TextType import com.jetbrains.toolbox.api.ui.components.ValidationErrorField +import com.squareup.moshi.Moshi import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory import java.net.MalformedURLException import java.net.URL @@ -42,6 +54,16 @@ class DeploymentUrlStep( private val errorField = ValidationErrorField(context.i18n.pnotr("")) + val interceptors = buildList { + add((Interceptors.userAgent(PluginManager.pluginInfo.version))) + add(Interceptors.logging(context)) + } + val okHttpClient = CoderHttpClientBuilder.build( + context, + interceptors + ) + + override val panel: RowGroup get() { if (!context.settingsStore.disableSignatureVerification) { @@ -86,6 +108,61 @@ class DeploymentUrlStep( errorReporter.report("URL is invalid", e) return false } + val service = Retrofit.Builder() + .baseUrl(CoderCliSetupContext.url!!) + .client(okHttpClient) + .addConverterFactory( + LoggingConverterFactory.wrap( + context, + MoshiConverterFactory.create(Moshi.Builder().build()) + ) + ) + .build() + .create(CoderAuthorizationApi::class.java) + context.cs.launch { + context.logger.info(">> checking if Coder supports OAuth2") + val response = service.discoveryMetadata() + if (response.isSuccessful) { + val authServer = requireNotNull(response.body()) { + "Successful response returned null body or oauth server discovery metadata" + } + context.logger.info(">> registering coder-jetbrains-toolbox as client app $response") + val clientResponse = service.registerClient( + ClientRegistrationRequest( + clientName = "coder-jetbrains-toolbox", + redirectUris = listOf("jetbrains://gateway/com.coder.toolbox/auth"),//URLEncoder.encode("jetbrains://gateway/com.coder.toolbox/oauth", StandardCharsets.UTF_8.toString())), + grantTypes = listOf("authorization_code", "refresh_token"), + responseTypes = authServer.supportedResponseTypes, + scope = "coder:workspaces.operate coder:workspaces.delete coder:workspaces.access user:read", + tokenEndpointAuthMethod = "client_secret_post" + ) + ) + if (clientResponse.isSuccessful) { + val clientResponse = + requireNotNull(clientResponse.body()) { "Successful response returned null body or client registration metadata" } + context.logger.info(">> initiating oauth login with $clientResponse") + + val oauthCfg = CoderOAuthCfg( + baseUrl = CoderCliSetupContext.url!!.toString(), + authUrl = authServer.authorizationEndpoint, + tokenUrl = authServer.tokenEndpoint, + clientId = clientResponse.clientId, + clientSecret = clientResponse.clientSecret, + ) + + val loginUrl = context.oauthManager.initiateLogin(oauthCfg) + context.logger.info(">> retrieving token") + context.desktop.browse(loginUrl) { + context.ui.showErrorInfoPopup(it) + } + val token = context.oauthManager.getToken("coder", forceRefresh = false) + context.logger.info(">> token is $token") + } else { + context.logger.error(">> ${clientResponse.code()} ${clientResponse.message()} || ${clientResponse.errorBody()}") + } + } + } + if (context.settingsStore.requireTokenAuth) { CoderCliSetupWizardState.goToNextStep() } else { diff --git a/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt index 7f5c831..8ecf59e 100644 --- a/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt @@ -4,6 +4,8 @@ import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.cli.ex.MissingVersionException import com.coder.toolbox.cli.ex.ResponseException import com.coder.toolbox.cli.ex.SSHConfigFormatException +import com.coder.toolbox.oauth.CoderAccount +import com.coder.toolbox.oauth.CoderOAuthCfg import com.coder.toolbox.sdk.DataGen.Companion.workspace import com.coder.toolbox.sdk.v2.models.Workspace import com.coder.toolbox.settings.Environment @@ -31,6 +33,7 @@ import com.coder.toolbox.util.getOS import com.coder.toolbox.util.pluginTestSettingsStore import com.coder.toolbox.util.sha1 import com.coder.toolbox.util.toURL +import com.jetbrains.toolbox.api.core.auth.PluginAuthManager import com.jetbrains.toolbox.api.core.diagnostics.Logger import com.jetbrains.toolbox.api.core.os.LocalDesktopManager import com.jetbrains.toolbox.api.localization.LocalizableStringFactory @@ -75,6 +78,7 @@ private val noOpTextProgress: (String) -> Unit = { _ -> } internal class CoderCLIManagerTest { private val ui = mockk(relaxed = true) private val context = CoderToolboxContext( + mockk>(), ui, mockk(), mockk(), diff --git a/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt b/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt index 49314c5..9db0032 100644 --- a/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt +++ b/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt @@ -1,6 +1,8 @@ package com.coder.toolbox.sdk import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.oauth.CoderAccount +import com.coder.toolbox.oauth.CoderOAuthCfg import com.coder.toolbox.sdk.convertors.InstantConverter import com.coder.toolbox.sdk.convertors.UUIDConverter import com.coder.toolbox.sdk.ex.APIResponseException @@ -20,6 +22,7 @@ import com.coder.toolbox.store.TLS_ALTERNATE_HOSTNAME import com.coder.toolbox.store.TLS_CA_PATH import com.coder.toolbox.util.pluginTestSettingsStore import com.coder.toolbox.util.sslContextFromPEMs +import com.jetbrains.toolbox.api.core.auth.PluginAuthManager import com.jetbrains.toolbox.api.core.diagnostics.Logger import com.jetbrains.toolbox.api.core.os.LocalDesktopManager import com.jetbrains.toolbox.api.localization.LocalizableStringFactory @@ -101,6 +104,7 @@ class CoderRestClientTest { .build() private val context = CoderToolboxContext( + mockk>(), mockk(), mockk(), mockk(), diff --git a/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt b/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt index 4a9ef88..8a1c300 100644 --- a/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt @@ -1,11 +1,14 @@ package com.coder.toolbox.util import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.oauth.CoderAccount +import com.coder.toolbox.oauth.CoderOAuthCfg import com.coder.toolbox.sdk.DataGen import com.coder.toolbox.settings.Environment import com.coder.toolbox.store.CoderSecretsStore import com.coder.toolbox.store.CoderSettingsStore import com.coder.toolbox.views.CoderSettingsPage +import com.jetbrains.toolbox.api.core.auth.PluginAuthManager import com.jetbrains.toolbox.api.core.diagnostics.Logger import com.jetbrains.toolbox.api.core.os.LocalDesktopManager import com.jetbrains.toolbox.api.localization.LocalizableStringFactory @@ -43,6 +46,7 @@ internal class CoderProtocolHandlerTest { } private val context = CoderToolboxContext( + mockk>(), mockk(relaxed = true), mockk(), mockk(), diff --git a/src/test/kotlin/com/coder/toolbox/util/URLExtensionsTest.kt b/src/test/kotlin/com/coder/toolbox/util/URLExtensionsTest.kt index af1b4ef..5783dee 100644 --- a/src/test/kotlin/com/coder/toolbox/util/URLExtensionsTest.kt +++ b/src/test/kotlin/com/coder/toolbox/util/URLExtensionsTest.kt @@ -4,6 +4,7 @@ import java.net.URI import java.net.URL import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith internal class URLExtensionsTest { @Test @@ -152,4 +153,37 @@ internal class URLExtensionsTest { result ) } + + @Test + fun `returns base URL without path or query`() { + val fullUrl = "https://example.com/path/to/page?param=1" + val result = fullUrl.toBaseURL() + assertEquals(URL("https://example.com"), result) + } + + @Test + fun `includes port if specified`() { + val fullUrl = "https://example.com:8080/api/v1/resource" + val result = fullUrl.toBaseURL() + assertEquals(URL("https://example.com:8080"), result) + } + + @Test + fun `handles subdomains correctly`() { + val fullUrl = "http://api.subdomain.example.org/v2/users" + val result = fullUrl.toBaseURL() + assertEquals(URL("http://api.subdomain.example.org"), result) + } + + @Test + fun `handles simple domain without path`() { + val fullUrl = "https://test.com" + val result = fullUrl.toBaseURL() + assertEquals(URL("https://test.com"), result) + } + + @Test + fun `throws exception for invalid URL`() { + assertFailsWith { "ht!tp://bad_url".toBaseURL() } + } }