diff --git a/src/main/kotlin/com/coder/gateway/sdk/BaseCoderRestClient.kt b/src/main/kotlin/com/coder/gateway/sdk/BaseCoderRestClient.kt index 212da79d..6cff4460 100644 --- a/src/main/kotlin/com/coder/gateway/sdk/BaseCoderRestClient.kt +++ b/src/main/kotlin/com/coder/gateway/sdk/BaseCoderRestClient.kt @@ -36,7 +36,6 @@ import okhttp3.logging.HttpLoggingInterceptor import org.imgscalr.Scalr import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory -import java.io.IOException import java.net.HttpURLConnection import java.net.URL import java.time.Instant @@ -103,6 +102,11 @@ open class BaseCoderRestClient( .build().create(CoderV2RestFacade::class.java) } + private fun error(action: String, res: retrofit2.Response): String { + val details = res.errorBody()?.charStream()?.use{ it.readText() } ?: "no details provided" + return "Unable to $action: url=$url, code=${res.code()}, details=$details" + } + /** * Authenticate and load information about the current user and the build * version. @@ -122,11 +126,7 @@ open class BaseCoderRestClient( fun me(): User { val userResponse = retroRestClient.me().execute() if (!userResponse.isSuccessful) { - throw AuthenticationResponseException( - "Unable to authenticate to $url: code ${userResponse.code()}, ${ - userResponse.message().ifBlank { "has your token expired?" } - }" - ) + throw AuthenticationResponseException(error("authenticate", userResponse)) } return userResponse.body()!! @@ -139,11 +139,7 @@ open class BaseCoderRestClient( fun workspaces(): List { val workspacesResponse = retroRestClient.workspaces("owner:me").execute() if (!workspacesResponse.isSuccessful) { - throw WorkspaceResponseException( - "Unable to retrieve workspaces from $url: code ${workspacesResponse.code()}, reason: ${ - workspacesResponse.message().ifBlank { "no reason provided" } - }" - ) + throw WorkspaceResponseException(error("retrieve workspaces", workspacesResponse)) } return workspacesResponse.body()!!.workspaces @@ -169,11 +165,7 @@ open class BaseCoderRestClient( fun resources(workspace: Workspace): List { val resourcesResponse = retroRestClient.templateVersionResources(workspace.latestBuild.templateVersionID).execute() if (!resourcesResponse.isSuccessful) { - throw WorkspaceResponseException( - "Unable to retrieve template resources for ${workspace.name} from $url: code ${resourcesResponse.code()}, reason: ${ - resourcesResponse.message().ifBlank { "no reason provided" } - }" - ) + throw WorkspaceResponseException(error("retrieve resources for ${workspace.name}", resourcesResponse)) } return resourcesResponse.body()!! } @@ -181,7 +173,7 @@ open class BaseCoderRestClient( fun buildInfo(): BuildInfo { val buildInfoResponse = retroRestClient.buildInfo().execute() if (!buildInfoResponse.isSuccessful) { - throw java.lang.IllegalStateException("Unable to retrieve build information for $url, code: ${buildInfoResponse.code()}, reason: ${buildInfoResponse.message().ifBlank { "no reason provided" }}") + throw java.lang.IllegalStateException(error("retrieve build information", buildInfoResponse)) } return buildInfoResponse.body()!! } @@ -189,11 +181,7 @@ open class BaseCoderRestClient( private fun template(templateID: UUID): Template { val templateResponse = retroRestClient.template(templateID).execute() if (!templateResponse.isSuccessful) { - throw TemplateResponseException( - "Unable to retrieve template with ID $templateID from $url, code: ${templateResponse.code()}, reason: ${ - templateResponse.message().ifBlank { "no reason provided" } - }" - ) + throw TemplateResponseException(error("retrieve template with ID $templateID", templateResponse)) } return templateResponse.body()!! } @@ -202,11 +190,7 @@ open class BaseCoderRestClient( val buildRequest = CreateWorkspaceBuildRequest(null, WorkspaceTransition.START, null, null, null, null) val buildResponse = retroRestClient.createWorkspaceBuild(workspaceID, buildRequest).execute() if (buildResponse.code() != HttpURLConnection.HTTP_CREATED) { - throw WorkspaceResponseException( - "Unable to build workspace $workspaceName on $url, code: ${buildResponse.code()}, reason: ${ - buildResponse.message().ifBlank { "no reason provided" } - }" - ) + throw WorkspaceResponseException(error("start workspace $workspaceName", buildResponse)) } return buildResponse.body()!! @@ -216,28 +200,32 @@ open class BaseCoderRestClient( val buildRequest = CreateWorkspaceBuildRequest(null, WorkspaceTransition.STOP, null, null, null, null) val buildResponse = retroRestClient.createWorkspaceBuild(workspaceID, buildRequest).execute() if (buildResponse.code() != HttpURLConnection.HTTP_CREATED) { - throw WorkspaceResponseException( - "Unable to stop workspace $workspaceName on $url, code: ${buildResponse.code()}, reason: ${ - buildResponse.message().ifBlank { "no reason provided" } - }" - ) + throw WorkspaceResponseException(error("stop workspace $workspaceName", buildResponse)) } return buildResponse.body()!! } fun updateWorkspace(workspaceID: UUID, workspaceName: String, lastWorkspaceTransition: WorkspaceTransition, templateID: UUID): WorkspaceBuild { + // Best practice is to STOP a workspace before doing an update if it is + // started. + // 1. If the update changes parameters, the old template might be needed + // to correctly STOP with the existing parameter values. + // 2. The agent gets a new ID and token on each START build. Many + // template authors are not diligent about making sure the agent gets + // restarted with this information when we do two START builds in a + // row. + if (lastWorkspaceTransition == WorkspaceTransition.START) { + stopWorkspace(workspaceID, workspaceName) + } + val template = template(templateID) val buildRequest = - CreateWorkspaceBuildRequest(template.activeVersionID, lastWorkspaceTransition, null, null, null, null) + CreateWorkspaceBuildRequest(template.activeVersionID, WorkspaceTransition.START, null, null, null, null) val buildResponse = retroRestClient.createWorkspaceBuild(workspaceID, buildRequest).execute() if (buildResponse.code() != HttpURLConnection.HTTP_CREATED) { - throw WorkspaceResponseException( - "Unable to update workspace $workspaceName on $url, code: ${buildResponse.code()}, reason: ${ - buildResponse.message().ifBlank { "no reason provided" } - }" - ) + throw WorkspaceResponseException(error("update workspace $workspaceName", buildResponse)) } return buildResponse.body()!! diff --git a/src/main/kotlin/com/coder/gateway/sdk/v2/models/Response.kt b/src/main/kotlin/com/coder/gateway/sdk/v2/models/Response.kt new file mode 100644 index 00000000..a203a173 --- /dev/null +++ b/src/main/kotlin/com/coder/gateway/sdk/v2/models/Response.kt @@ -0,0 +1,14 @@ +package com.coder.gateway.sdk.v2.models + +import com.google.gson.annotations.SerializedName + +data class Validation ( + @SerializedName("field") val field: String, + @SerializedName("detail") val detail: String, +) + +data class Response ( + @SerializedName("message") val message: String, + @SerializedName("detail") val detail: String, + @SerializedName("validations") val validations: List = emptyList(), +) diff --git a/src/test/kotlin/com/coder/gateway/CoderGatewayConnectionProviderTest.kt b/src/test/kotlin/com/coder/gateway/CoderGatewayConnectionProviderTest.kt index 8b0115f7..964d5dee 100644 --- a/src/test/kotlin/com/coder/gateway/CoderGatewayConnectionProviderTest.kt +++ b/src/test/kotlin/com/coder/gateway/CoderGatewayConnectionProviderTest.kt @@ -20,7 +20,7 @@ internal class CoderGatewayConnectionProviderTest { @Test fun getMatchingAgent() { - val ws = DataGen.workspace("ws", agents) + val ws = DataGen.workspace("ws", agents = agents) val tests = listOf( Pair(mapOf("agent" to "agent_name"), "9a920eee-47fb-4571-9501-e4b3120c12f2"), @@ -41,7 +41,7 @@ internal class CoderGatewayConnectionProviderTest { @Test fun failsToGetMatchingAgent() { - val ws = DataGen.workspace("ws", agents) + val ws = DataGen.workspace("ws", agents = agents) val tests = listOf( Triple(emptyMap(), MissingArgumentException::class, "Unable to determine"), Triple(mapOf("agent" to ""), MissingArgumentException::class, "Unable to determine"), @@ -68,7 +68,7 @@ internal class CoderGatewayConnectionProviderTest { @Test fun getsFirstAgentWhenOnlyOne() { - val ws = DataGen.workspace("ws", oneAgent) + val ws = DataGen.workspace("ws", agents = oneAgent) val tests = listOf( emptyMap(), mapOf("agent" to ""), @@ -84,7 +84,7 @@ internal class CoderGatewayConnectionProviderTest { @Test fun failsToGetAgentWhenOnlyOne() { - val ws = DataGen.workspace("ws", oneAgent) + val ws = DataGen.workspace("ws", agents = oneAgent) val tests = listOf( Triple(mapOf("agent" to "ws"), IllegalArgumentException::class, "agent named"), Triple(mapOf("agent" to "ws.agent_name_3"), IllegalArgumentException::class, "agent named"), @@ -120,4 +120,4 @@ internal class CoderGatewayConnectionProviderTest { assertContains(ex.message.toString(), it.third) } } -} \ No newline at end of file +} diff --git a/src/test/kotlin/com/coder/gateway/sdk/BaseCoderRestClientTest.kt b/src/test/kotlin/com/coder/gateway/sdk/BaseCoderRestClientTest.kt index 4b79d846..1ceb88ad 100644 --- a/src/test/kotlin/com/coder/gateway/sdk/BaseCoderRestClientTest.kt +++ b/src/test/kotlin/com/coder/gateway/sdk/BaseCoderRestClientTest.kt @@ -4,11 +4,15 @@ import kotlin.test.Test import kotlin.test.assertEquals import com.coder.gateway.sdk.convertors.InstantConverter +import com.coder.gateway.sdk.ex.WorkspaceResponseException import com.coder.gateway.sdk.v2.models.* import com.coder.gateway.services.CoderSettingsState import com.coder.gateway.settings.CoderSettings import com.coder.gateway.util.sslContextFromPEMs +import com.google.gson.Gson import com.google.gson.GsonBuilder +import com.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpHandler import com.sun.net.httpserver.HttpServer import com.sun.net.httpserver.HttpsConfigurator import com.sun.net.httpserver.HttpsServer @@ -24,116 +28,78 @@ import java.net.URL import java.nio.file.Path import java.time.Instant import java.util.UUID -import javax.net.ssl.HttpsURLConnection import javax.net.ssl.SSLHandshakeException import javax.net.ssl.SSLPeerUnverifiedException +import kotlin.test.assertContains import kotlin.test.assertFailsWith +internal fun toJson(src: Any?): String { + return GsonBuilder().registerTypeAdapter(Instant::class.java, InstantConverter()).create().toJson(src) +} + +internal class BaseHttpHandler(private val method: String, + private val handler: (exchange: HttpExchange) -> Unit): HttpHandler { + override fun handle(exchange: HttpExchange) { + try { + if (exchange.requestMethod != method) { + val body = toJson(Response("Not allowed", "Expected $method but got ${exchange.requestMethod}")).toByteArray() + exchange.sendResponseHeaders(HttpURLConnection.HTTP_BAD_METHOD, body.size.toLong()) + exchange.responseBody.write(body) + } else { + handler(exchange) + if (exchange.responseCode == -1) { + val body = toJson(Response("Not found", "The requested resource could not be found")).toByteArray() + exchange.sendResponseHeaders(HttpURLConnection.HTTP_NOT_FOUND, body.size.toLong()) + exchange.responseBody.write(body) + } + } + } catch (ex: Exception) { + // If we get here it is because of developer error. + val body = toJson(Response("Developer error", ex.message ?: "unknown error")).toByteArray() + exchange.sendResponseHeaders(HttpURLConnection.HTTP_BAD_REQUEST, body.size.toLong()) + exchange.responseBody.write(body) + } + exchange.close() + } +} + class BaseCoderRestClientTest { data class TestWorkspace(var workspace: Workspace, var resources: List? = emptyList()) /** - * Create, start, and return a server that mocks the Coder API. - * - * The resources map to the workspace index (to avoid having to manually - * hardcode IDs everywhere since you cannot use variables in the where - * blocks). + * Create, start, and return a server. */ - private fun mockServer(workspaces: List): Pair { + private fun mockServer(): Pair { val srv = HttpServer.create(InetSocketAddress(0), 0) - addServerContext(srv, workspaces) srv.start() return Pair(srv, "http://localhost:" + srv.address.port) } - private val resourceEndpoint = "/api/v2/templateversions/([^/]+)/resources".toRegex() - - private fun addServerContext(srv: HttpServer, workspaces: List = emptyList()) { - srv.createContext("/") { exchange -> - var code = HttpURLConnection.HTTP_NOT_FOUND - var response = "not found" - try { - val matches = resourceEndpoint.find(exchange.requestURI.path) - if (matches != null) { - val templateVersionId = UUID.fromString(matches.destructured.toList()[0]) - val ws = workspaces.first { it.workspace.latestBuild.templateVersionID == templateVersionId } - code = HttpURLConnection.HTTP_OK - response = GsonBuilder().registerTypeAdapter(Instant::class.java, InstantConverter()) - .create().toJson(ws.resources) - } else if (exchange.requestURI.path == "/api/v2/workspaces") { - code = HttpsURLConnection.HTTP_OK - response = GsonBuilder().registerTypeAdapter(Instant::class.java, InstantConverter()) - .create().toJson(WorkspacesResponse(workspaces.map{ it.workspace }, workspaces.size)) - } else if (exchange.requestURI.path == "/api/v2/users/me") { - code = HttpsURLConnection.HTTP_OK - val user = User( - UUID.randomUUID(), - "tester", - "tester@example.com", - Instant.now(), - Instant.now(), - UserStatus.ACTIVE, - listOf(), - listOf(), - "", - ) - response = GsonBuilder().registerTypeAdapter(Instant::class.java, InstantConverter()) - .create().toJson(user) - } - } catch (ex: Exception) { - // This will be a developer error. - code = HttpURLConnection.HTTP_INTERNAL_ERROR - response = ex.message.toString() - println(ex.message) // Print since it will not show up in the error. - } - - val body = response.toByteArray() - exchange.sendResponseHeaders(code, body.size.toLong()) - exchange.responseBody.write(body) - exchange.close() - } - } - - private fun mockTLSServer(certName: String, workspaces: List = emptyList()): Pair { + private fun mockTLSServer(certName: String): Pair { val srv = HttpsServer.create(InetSocketAddress(0), 0) val sslContext = sslContextFromPEMs( Path.of("src/test/fixtures/tls", "$certName.crt").toString(), Path.of("src/test/fixtures/tls", "$certName.key").toString(), "") srv.httpsConfigurator = HttpsConfigurator(sslContext) - addServerContext(srv, workspaces) srv.start() return Pair(srv, "https://localhost:" + srv.address.port) } private fun mockProxy(): HttpServer { val srv = HttpServer.create(InetSocketAddress(0), 0) - srv.createContext("/") { exchange -> - var code: Int - var response: String - + srv.createContext("/", BaseHttpHandler("GET") { exchange -> if (exchange.requestHeaders.getFirst("Proxy-Authorization") != "Basic Zm9vOmJhcg==") { - code = HttpURLConnection.HTTP_PROXY_AUTH - response = "authentication required" + exchange.sendResponseHeaders(HttpURLConnection.HTTP_PROXY_AUTH, 0) } else { - try { - val conn = URL(exchange.requestURI.toString()).openConnection() - exchange.requestHeaders.forEach { - conn.setRequestProperty(it.key, it.value.joinToString(",")) - } - response = InputStreamReader(conn.inputStream).use { it.readText() } - code = (conn as HttpURLConnection).responseCode - } catch (error: Exception) { - code = HttpURLConnection.HTTP_INTERNAL_ERROR - response = error.message.toString() - println(error) // Print since it will not show up in the error. + val conn = URL(exchange.requestURI.toString()).openConnection() + exchange.requestHeaders.forEach { + conn.setRequestProperty(it.key, it.value.joinToString(",")) } + val body = InputStreamReader(conn.inputStream).use { it.readText() }.toByteArray() + exchange.sendResponseHeaders((conn as HttpURLConnection).responseCode, body.size.toLong()) + exchange.responseBody.write(body) } - - val body = response.toByteArray() - exchange.sendResponseHeaders(code, body.size.toLong()) - exchange.responseBody.write(body) - exchange.close() - } + }) srv.start() return srv } @@ -146,10 +112,15 @@ class BaseCoderRestClientTest { listOf(DataGen.workspace("ws1"), DataGen.workspace("ws2")), ) - tests.forEach { - val (srv, url) = mockServer(it.map{ ws -> TestWorkspace(ws) }) + tests.forEach { workspaces -> + val (srv, url) = mockServer() val client = BaseCoderRestClient(URL(url), "token") - assertEquals(it.map{ ws -> ws.name }, client.workspaces().map{ ws -> ws.name }) + srv.createContext("/api/v2/workspaces", BaseHttpHandler("GET") { exchange -> + val body = toJson(WorkspacesResponse(workspaces, workspaces.size)).toByteArray() + exchange.sendResponseHeaders(HttpURLConnection.HTTP_OK, body.size.toLong()) + exchange.responseBody.write(body) + }) + assertEquals(workspaces.map{ ws -> ws.name }, client.workspaces().map{ ws -> ws.name }) srv.stop(0) } } @@ -160,15 +131,15 @@ class BaseCoderRestClientTest { // Nothing, so no resources. emptyList(), // One workspace with an agent, but no resources. - listOf(TestWorkspace(DataGen.workspace("ws1", mapOf("agent1" to "3f51da1d-306f-4a40-ac12-62bda5bc5f9a")))), + listOf(TestWorkspace(DataGen.workspace("ws1", agents = mapOf("agent1" to "3f51da1d-306f-4a40-ac12-62bda5bc5f9a")))), // One workspace with an agent and resources that do not match the agent. listOf(TestWorkspace( - workspace = DataGen.workspace("ws1", mapOf("agent1" to "3f51da1d-306f-4a40-ac12-62bda5bc5f9a")), + workspace = DataGen.workspace("ws1", agents = mapOf("agent1" to "3f51da1d-306f-4a40-ac12-62bda5bc5f9a")), resources = listOf(DataGen.resource("agent2", "968eea5e-8787-439d-88cd-5bc440216a34"), DataGen.resource("agent3", "72fbc97b-952c-40c8-b1e5-7535f4407728")))), // Multiple workspaces but only one has resources. listOf(TestWorkspace( - workspace = DataGen.workspace("ws1", mapOf("agent1" to "3f51da1d-306f-4a40-ac12-62bda5bc5f9a")), + workspace = DataGen.workspace("ws1", agents = mapOf("agent1" to "3f51da1d-306f-4a40-ac12-62bda5bc5f9a")), resources = emptyList()), TestWorkspace( workspace = DataGen.workspace("ws2"), @@ -179,11 +150,24 @@ class BaseCoderRestClientTest { resources = emptyList())), ) - tests.forEach { - val (srv, url) = mockServer(it) + val resourceEndpoint = "([^/]+)/resources".toRegex() + tests.forEach { workspaces -> + val (srv, url) = mockServer() val client = BaseCoderRestClient(URL(url), "token") + srv.createContext("/api/v2/templateversions", BaseHttpHandler("GET") { exchange -> + val matches = resourceEndpoint.find(exchange.requestURI.path) + if (matches != null) { + val templateVersionId = UUID.fromString(matches.destructured.toList()[0]) + val ws = workspaces.firstOrNull { it.workspace.latestBuild.templateVersionID == templateVersionId } + if (ws != null) { + val body = toJson(ws.resources).toByteArray() + exchange.sendResponseHeaders(HttpURLConnection.HTTP_OK, body.size.toLong()) + exchange.responseBody.write(body) + } + } + }) - it.forEach { ws-> + workspaces.forEach { ws -> assertEquals(ws.resources, client.resources(ws.workspace)) } @@ -191,15 +175,109 @@ class BaseCoderRestClientTest { } } + @Test + fun testUpdate() { + val templates = listOf(DataGen.template("template")) + val workspaces = listOf( + DataGen.workspace("ws1", templateID = templates[0].id), + DataGen.workspace("ws2", templateID = templates[0].id, transition = WorkspaceTransition.STOP)) + + val actions = mutableListOf>() + val (srv, url) = mockServer() + val client = BaseCoderRestClient(URL(url), "token") + val templateEndpoint = "/api/v2/templates/([^/]+)".toRegex() + srv.createContext("/api/v2/templates", BaseHttpHandler("GET") { exchange -> + val templateMatch = templateEndpoint.find(exchange.requestURI.path) + if (templateMatch != null) { + val templateId = UUID.fromString(templateMatch.destructured.toList()[0]) + actions.add(Pair("get_template", templateId)) + val template = templates.firstOrNull { it.id == templateId } + if (template != null) { + val body = toJson(template).toByteArray() + exchange.sendResponseHeaders(HttpURLConnection.HTTP_OK, body.size.toLong()) + exchange.responseBody.write(body) + } + } + }) + val buildEndpoint = "/api/v2/workspaces/([^/]+)/builds".toRegex() + srv.createContext("/api/v2/workspaces", BaseHttpHandler("POST") { exchange -> + val buildMatch = buildEndpoint.find(exchange.requestURI.path) + if (buildMatch != null) { + val workspaceId = UUID.fromString(buildMatch.destructured.toList()[0]) + val json = Gson().fromJson(InputStreamReader(exchange.requestBody), CreateWorkspaceBuildRequest::class.java) + val ws = workspaces.firstOrNull { it.id == workspaceId } + val templateVersionID = json.templateVersionID ?: ws?.latestBuild?.templateVersionID + if (json.templateVersionID != null) { + actions.add(Pair("update", workspaceId)) + } else { + when (json.transition) { + WorkspaceTransition.START -> actions.add(Pair("start", workspaceId)) + WorkspaceTransition.STOP -> actions.add(Pair("stop", workspaceId)) + WorkspaceTransition.DELETE -> Unit + } + } + if (ws != null && templateVersionID != null) { + val body = toJson(DataGen.build( + workspaceID = ws.id, + workspaceName = ws.name, + ownerID = ws.ownerID, + ownerName = ws.ownerName, + templateVersionID = templateVersionID, + transition = json.transition)).toByteArray() + exchange.sendResponseHeaders(HttpURLConnection.HTTP_CREATED, body.size.toLong()) + exchange.responseBody.write(body) + } + } + }) + + // Fails to stop a non-existent workspace. + val badWorkspace = DataGen.workspace("bad") + val ex = assertFailsWith( + exceptionClass = WorkspaceResponseException::class, + block = { client.updateWorkspace(badWorkspace.id, badWorkspace.name, badWorkspace.latestBuild.transition, badWorkspace.templateID) }) + assertEquals(listOf(Pair("stop", badWorkspace.id)), actions) + assertContains(ex.message.toString(), "The requested resource could not be found") + actions.clear() + + // When workspace is started it should stop first. + with(workspaces[0]) { + client.updateWorkspace(id, name, latestBuild.transition, templateID) + val expected = listOf( + Pair("stop", id), + Pair("get_template", templateID), + Pair("update", id)) + assertEquals(expected, actions) + actions.clear() + } + + // When workspace is stopped it will not stop first. + with(workspaces[1]) { + client.updateWorkspace(id, name, latestBuild.transition, templateID) + val expected = listOf( + Pair("get_template", templateID), + Pair("update", id)) + assertEquals(expected, actions) + actions.clear() + } + + srv.stop(0) + } + @Test fun testValidSelfSignedCert() { val settings = CoderSettings(CoderSettingsState( tlsCAPath = Path.of("src/test/fixtures/tls", "self-signed.crt").toString(), tlsAlternateHostname = "localhost")) + val user = DataGen.user() val (srv, url) = mockTLSServer("self-signed") val client = BaseCoderRestClient(URL(url), "token", settings) + srv.createContext("/api/v2/users/me", BaseHttpHandler("GET") { exchange -> + val body = toJson(user).toByteArray() + exchange.sendResponseHeaders(HttpURLConnection.HTTP_OK, body.size.toLong()) + exchange.responseBody.write(body) + }) - assertEquals("tester", client.me().username) + assertEquals(user.username, client.me().username) srv.stop(0) } @@ -237,10 +315,16 @@ class BaseCoderRestClientTest { fun testValidChain() { val settings = CoderSettings(CoderSettingsState( tlsCAPath = Path.of("src/test/fixtures/tls", "chain-root.crt").toString())) + val user = DataGen.user() val (srv, url) = mockTLSServer("chain") val client = BaseCoderRestClient(URL(url), "token", settings) + srv.createContext("/api/v2/users/me", BaseHttpHandler("GET") { exchange -> + val body = toJson(user).toByteArray() + exchange.sendResponseHeaders(HttpURLConnection.HTTP_OK, body.size.toLong()) + exchange.responseBody.write(body) + }) - assertEquals("tester", client.me().username) + assertEquals(user.username, client.me().username) srv.stop(0) } @@ -248,8 +332,13 @@ class BaseCoderRestClientTest { @Test fun usesProxy() { val settings = CoderSettings(CoderSettingsState()) - val workspaces = listOf(TestWorkspace(DataGen.workspace("ws1"))) - val (srv1, url1) = mockServer(workspaces) + val workspaces = listOf(DataGen.workspace("ws1")) + val (srv1, url1) = mockServer() + srv1.createContext("/api/v2/workspaces", BaseHttpHandler("GET") { exchange -> + val body = toJson(WorkspacesResponse(workspaces, workspaces.size)).toByteArray() + exchange.sendResponseHeaders(HttpURLConnection.HTTP_OK, body.size.toLong()) + exchange.responseBody.write(body) + }) val srv2 = mockProxy() val client = BaseCoderRestClient(URL(url1), "token", settings, ProxyValues( "foo", @@ -266,10 +355,9 @@ class BaseCoderRestClientTest { } )) - assertEquals(workspaces.map{ ws -> ws.workspace.name }, client.workspaces().map{ ws -> ws.name }) + assertEquals(workspaces.map{ ws -> ws.name }, client.workspaces().map{ ws -> ws.name }) srv1.stop(0) srv2.stop(0) } } - diff --git a/src/test/kotlin/com/coder/gateway/sdk/DataGen.kt b/src/test/kotlin/com/coder/gateway/sdk/DataGen.kt index 7530fe33..567a5b6b 100644 --- a/src/test/kotlin/com/coder/gateway/sdk/DataGen.kt +++ b/src/test/kotlin/com/coder/gateway/sdk/DataGen.kt @@ -4,6 +4,8 @@ import com.coder.gateway.models.WorkspaceAgentModel import com.coder.gateway.models.WorkspaceAndAgentStatus import com.coder.gateway.models.WorkspaceVersionStatus import com.coder.gateway.sdk.v2.models.* +import com.google.gson.annotations.SerializedName +import java.time.Instant import java.util.* class DataGen { @@ -75,58 +77,113 @@ class DataGen { ) } - fun workspace(name: String, agents: Map = emptyMap()): Workspace { + fun workspace(name: String, + templateID: UUID = UUID.randomUUID(), + agents: Map = emptyMap(), + transition: WorkspaceTransition = WorkspaceTransition.START): Workspace { val wsId = UUID.randomUUID() val ownerId = UUID.randomUUID() - val resources: List = agents.map{ resource(it.key, it.value) } return Workspace( - wsId, + id = wsId, createdAt = Date().toInstant(), updatedAt = Date().toInstant(), - ownerId, - "owner-name", - templateID = UUID.randomUUID(), - "template-name", - "template-display-name", - "template-icon", + ownerID = ownerId, + ownerName = "owner-name", + templateID = templateID, + templateName = "template-name", + templateDisplayName = "template-display-name", + templateIcon = "template-icon", templateAllowUserCancelWorkspaceJobs = false, - WorkspaceBuild( - id = UUID.randomUUID(), - createdAt = Date().toInstant(), - updatedAt = Date().toInstant(), - wsId, - name, - ownerId, - "owner-name", - templateVersionID = UUID.randomUUID(), - buildNumber = 0, - WorkspaceTransition.START, - initiatorID = UUID.randomUUID(), - "initiator-name", - ProvisionerJob( - id = UUID.randomUUID(), - createdAt = Date().toInstant(), - startedAt = null, - completedAt = null, - canceledAt = null, - error = null, - ProvisionerJobStatus.SUCCEEDED, - workerID = null, - fileID = UUID.randomUUID(), - tags = emptyMap(), - ), - BuildReason.INITIATOR, - resources, - deadline = null, - WorkspaceStatus.RUNNING, - dailyCost = 0, + latestBuild = build( + workspaceID = wsId, + workspaceName = name, + ownerID = ownerId, + ownerName = "owner-name", + transition = transition, + resources = agents.map{ resource(it.key, it.value) }, ), outdated = false, - name, + name = name, autostartSchedule = null, ttlMillis = null, lastUsedAt = Date().toInstant(), ) } + + fun build(workspaceID: UUID, + workspaceName: String, + ownerID: UUID, + ownerName: String, + transition: WorkspaceTransition = WorkspaceTransition.START, + templateVersionID: UUID = UUID.randomUUID(), + resources: List = emptyList()): WorkspaceBuild { + return WorkspaceBuild( + id = UUID.randomUUID(), + createdAt = Date().toInstant(), + updatedAt = Date().toInstant(), + workspaceID = workspaceID, + workspaceName = workspaceName, + workspaceOwnerID = ownerID, + workspaceOwnerName = ownerName, + templateVersionID = templateVersionID, + buildNumber = 0, + transition = transition, + initiatorID = UUID.randomUUID(), + initiatorUsername = ownerName, + job = ProvisionerJob( + id = UUID.randomUUID(), + createdAt = Date().toInstant(), + startedAt = null, + completedAt = null, + canceledAt = null, + error = null, + ProvisionerJobStatus.SUCCEEDED, + workerID = null, + fileID = UUID.randomUUID(), + tags = emptyMap(), + ), + reason = BuildReason.INITIATOR, + resources = resources, + deadline = Date().toInstant(), + status = WorkspaceStatus.RUNNING, + dailyCost = 0, + ) + } + + fun template(name: String): Template { + return Template( + id = UUID.randomUUID(), + createdAt = Date().toInstant(), + updatedAt = Date().toInstant(), + organizationIterator = UUID.randomUUID(), + name = name, + displayName = name, + provisioner = ProvisionerType.ECHO, + activeVersionID = UUID.randomUUID(), + workspaceOwnerCount = 0, + activeUserCount = 0, + buildTimeStats = emptyMap(), + description = "", + icon = "", + defaultTTLMillis = 0, + createdByID = UUID.randomUUID(), + createdByName = "", + allowUserCancelWorkspaceJobs = true, + ) + } + + fun user(): User { + return User( + UUID.randomUUID(), + "tester", + "tester@example.com", + Instant.now(), + Instant.now(), + UserStatus.ACTIVE, + listOf(), + listOf(), + "", + ) + } } -} \ No newline at end of file +}