diff --git a/.github/workflows/google-cloud.yml b/.github/workflows/google-cloud.yml index 5578b4c5d..645a4a4bf 100644 --- a/.github/workflows/google-cloud.yml +++ b/.github/workflows/google-cloud.yml @@ -85,19 +85,16 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} script: | const comments = require('./.github/workflows/scripts/comments.js'); - const comment = await comments.get(context, github); - const fs = require('fs').promises; - + await comments.deleteAll(context, github, it => it.body.startsWith("Terraform")); + const start = "Terraform will perform the following actions:" - if (comment && comment.body.startsWith(start)) { - await comments.delete(context, github, comment.id) - } + const fs = require('fs').promises; const path = "${{ steps.plan.outputs.build-log }}" const content = await fs.readFile(path, 'utf8') const body = content.substring(content.indexOf(start), content.indexOf("───")) - await comments.create(context, github, content) + await comments.create(context, github, content ? content : "Build log empty") - name: Containerize Cloud Run env: @@ -109,7 +106,7 @@ jobs: if: github.ref == 'refs/heads/main' run: | ./gradlew cloud-run:jib \ - --image=europe-west1-docker.pkg.dev/${{ steps.google_cloud_auth.outputs.project_id }}/cloud-run-source-deploy/playground.ashdavies.dev \ + --image=europe-west1-docker.pkg.dev/${{ steps.google_cloud_auth.outputs.project_id }}/cloud-run-source-deploy/playground.ashdavies.dev \ --no-configuration-cache \ --console=plain \ --info diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml new file mode 100644 index 000000000..2cd00f306 --- /dev/null +++ b/.github/workflows/integration-test.yml @@ -0,0 +1,68 @@ +name: Integration Tests + +on: + workflow_dispatch: + inputs: + debug: + description: 'Debug' + required: false + default: false + type: boolean + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + + permissions: + id-token: 'write' + + steps: + - name: Setup JDK + uses: actions/setup-java@v3 + with: + distribution: temurin + java-version: 17 + + - name: Checkout Repository + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Google Cloud Auth + id: google_cloud_auth + uses: google-github-actions/auth@v1 + with: + workload_identity_provider: ${{ secrets.GOOGLE_WORKLOAD_IDENTITY }} + service_account: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_ID }} + token_format: access_token + + - name: Run Gradle Tasks + id: gradle_build + uses: gradle/gradle-build-action@v2 + env: + GOOGLE_PROJECT_API_KEY: ${{ secrets.google_project_api_key }} + GOOGLE_SERVICE_ACCOUNT_ID: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_ID }} + MOBILE_SDK_APP_ID: ${{ secrets.mobile_sdk_app_id }} + PLAYGROUND_API_KEY: ${{ secrets.PLAYGROUND_API_KEY }} + with: + gradle-home-cache-cleanup: true + arguments: > + cloud-run:integrationTest + ${{ inputs.debug && '--debug' || '--info' }} + --console=plain + + - name: Produce Test Summary + uses: test-summary/action@v2 + with: + paths: "**/build/test-results/test/TEST-*.xml" + + - name: Upload Test Report + uses: actions/upload-artifact@v3 + with: + name: test-report + path: "**/build/reports/tests" + if-no-files-found: error diff --git a/.github/workflows/pre-merge.yml b/.github/workflows/pre-merge.yml index 53167619a..5a376a82b 100644 --- a/.github/workflows/pre-merge.yml +++ b/.github/workflows/pre-merge.yml @@ -68,32 +68,31 @@ jobs: PLAYGROUND_API_KEY: ${{ secrets.PLAYGROUND_API_KEY }} with: gradle-home-cache-cleanup: true - arguments: | + arguments: > build ${{ (contains(github.event.pull_request.labels.*.name, 'Dry Run') && '--dry-run' || '') }} --console=plain --info - - name: Gradle Scan Link + - name: Delete Bot Comments uses: actions/github-script@v6 if: ${{ github.event_name == 'pull_request' && steps.gradle.outputs.build-scan-url }} with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | const comments = require('./.github/workflows/scripts/comments.js'); - const comment = await comments.get(context, github); - - const message = "Build scan published to"; - const url = /https:\/\/gradle\.com\/s\/\w+/; - - const pattern = new RegExp(`${message} ${url.source}`); + await comments.deleteAll(context, github) - if (comment && comment.body.match(pattern)) { - await comments.delete(context, github, comment.id) - } - - const body = `${message} ${{ steps.gradle.outputs.build-scan-url }}` - await comments.create(context, github, body) + - name: Gradle Scan Link + uses: actions/github-script@v6 + if: ${{ github.event_name == 'pull_request' && steps.gradle.outputs.build-scan-url }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const comments = require('./.github/workflows/scripts/comments.js'); + const url = `${{ steps.gradle.outputs.build-scan-url }}`; + const message = `Build scan published to ${url}`; + await comments.create(context, github, message); - name: Produce Test Summary uses: test-summary/action@v2 diff --git a/.github/workflows/scripts/comments.js b/.github/workflows/scripts/comments.js index 6ea9d6c71..3717aac09 100644 --- a/.github/workflows/scripts/comments.js +++ b/.github/workflows/scripts/comments.js @@ -1,13 +1,15 @@ -exports.get = async function (context, github) { +exports.findAll = async function (context, github, predicate = (it) => true) { const comments = await github.rest.issues.listComments({ issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, }); - return comments.data.find(comment => - comment.user.login.endsWith('[bot]') && comment.user.type === 'Bot' - ); + return comments.data.filter((comment) => { + return comment.user.login.endsWith("[bot]") && + comment.user.type === "Bot" && + predicate(comment); + }); }; exports.create = function (context, github, body) { @@ -26,3 +28,9 @@ exports.delete = function (context, github, id) { comment_id: id, }); }; + +exports.deleteAll = async function (context, github, predicate = (it) => true) { + for (const item of await exports.findAll(context, github, predicate)) { + await exports.delete(context, github, item.id); + } +}; diff --git a/build-plugins/src/main/kotlin/io.ashdavies.integration.gradle.kts b/build-plugins/src/main/kotlin/io.ashdavies.integration.gradle.kts index 79c68b081..715d499bb 100644 --- a/build-plugins/src/main/kotlin/io.ashdavies.integration.gradle.kts +++ b/build-plugins/src/main/kotlin/io.ashdavies.integration.gradle.kts @@ -16,6 +16,7 @@ kotlin { group = LifecycleBasePlugin.VERIFICATION_GROUP description = "Runs integration tests" testClassesDirs = output.classesDirs + outputs.upToDateWhen { false } } } } diff --git a/build.gradle.kts b/build.gradle.kts index c517073c8..678cc5efd 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -31,6 +31,11 @@ doctor { } configure { + javascript { + target("**/*.js") + prettier() + } + val ktLintVersion = libs.versions.pinterest.ktlint.get() fun FormatExtension.kotlinDefault(extension: String = "kt") { target("**/src/**/*.$extension") diff --git a/cloud-run/src/jvmIntegrationTest/kotlin/io/ashdavies/cloud/ApplicationTest.kt b/cloud-run/src/jvmIntegrationTest/kotlin/io/ashdavies/cloud/ApplicationTest.kt new file mode 100644 index 000000000..569f42929 --- /dev/null +++ b/cloud-run/src/jvmIntegrationTest/kotlin/io/ashdavies/cloud/ApplicationTest.kt @@ -0,0 +1,132 @@ +package io.ashdavies.cloud + +import io.ashdavies.http.AppCheckToken +import io.ashdavies.playground.models.Event +import io.ashdavies.playground.models.FirebaseApp +import io.ktor.client.HttpClient +import io.ktor.client.HttpClientConfig +import io.ktor.client.call.body +import io.ktor.client.engine.HttpClientEngineConfig +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.put +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import io.ktor.serialization.kotlinx.json.json +import io.ktor.server.application.Application +import io.ktor.server.testing.ApplicationTestBuilder +import io.ktor.server.testing.testApplication +import io.ktor.util.KtorDsl +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.serialization.Serializable +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +private val DefaultHttpConfig: HttpClientConfig.() -> Unit = { + install(ContentNegotiation, ContentNegotiation.Config::json) +} + +@ExperimentalCoroutinesApi +internal class ApplicationTest { + + @Test + fun `should sign in with custom token`() = testMainApplication { client -> + val apiKey = requireNotNull(System.getenv("GOOGLE_PROJECT_API_KEY")) + + val authResult = client.post("/firebase/auth") { + setBody(mapOf("uid" to "jane.smith@example.com")) + contentType(ContentType.Application.Json) + header("X-API-Key", apiKey) + }.body>() + + assertNotNull(authResult["idToken"]) + } + + @Test + fun `should get events with default limit`() = testMainApplication { client -> + val response = client.get("/events") { contentType(ContentType.Application.Json) } + val body = response.body>() + + assertEquals(50, body.size) + } + + @Test + fun `should aggregate events`() = testMainApplication { client -> + val client = createClient { install(ContentNegotiation, ContentNegotiation.Config::json) } + val response = client.post("/events:aggregate") + + assertEquals(HttpStatusCode.OK, response.status) + } + + @Test + fun `should create test application`() = testMainApplication { client -> + val response = client.get("/hello") + + assertEquals( + expected = HttpStatusCode.OK, + actual = response.status, + ) + + assertEquals( + actual = response.bodyAsText(), + expected = "Hello, World!", + ) + } + + @Test + fun `should return app check token for request`() = testMainApplication { client -> + val tokenResponse = client.post("/firebase/token") { + setBody(FirebaseApp(System.getenv("MOBILE_SDK_APP_ID"))) + // headers { append("X-API-Key", playgroundApiKey) } + contentType(ContentType.Application.Json) + }.body() + + assertEquals( + actual = tokenResponse.ttlMillis, + expected = 3_600_000, + ) + + val verifyResponse = client.put("/firebase/token:verify") { + header(HttpHeaders.AppCheckToken, tokenResponse.token) + }.body() + + assertEquals( + expected = verifyResponse.appId, + actual = verifyResponse.subject, + ) + } +} + +@KtorDsl +private fun testMainApplication( + configuration: HttpClientConfig.() -> Unit = DefaultHttpConfig, + application: Application.() -> Unit = { main() }, + block: suspend ApplicationTestBuilder.(HttpClient) -> Unit, +) = testApplication { + val client = createClient(configuration) + application(application) + block(client) +} + +@Serializable +private data class TokenResponse( + val ttlMillis: Long, + val token: String, +) + +@Serializable +private data class VerifyResponse( + val audience: List, + val expiresAt: Long, + val subject: String, + val issuedAt: Long, + val issuer: String, + val appId: String, +) diff --git a/cloud-run/src/jvmIntegrationTest/kotlin/io/ashdavies/cloud/AuthTest.kt b/cloud-run/src/jvmIntegrationTest/kotlin/io/ashdavies/cloud/AuthTest.kt deleted file mode 100644 index 31ba8adc3..000000000 --- a/cloud-run/src/jvmIntegrationTest/kotlin/io/ashdavies/cloud/AuthTest.kt +++ /dev/null @@ -1,28 +0,0 @@ -package io.ashdavies.cloud - -import io.ktor.client.call.body -import io.ktor.client.request.header -import io.ktor.client.request.post -import io.ktor.client.request.setBody -import io.ktor.http.ContentType -import io.ktor.http.contentType -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlin.test.Test -import kotlin.test.assertNotNull - -@ExperimentalCoroutinesApi -internal class AuthTest { - - @Test - fun `should sign in with custom token`() = testMainApplication { client -> - val apiKey = requireNotNull(System.getenv("GOOGLE_PROJECT_API_KEY")) - - val authResult = client.post("/firebase/auth") { - setBody(mapOf("uid" to "jane.smith@example.com")) - contentType(ContentType.Application.Json) - header("X-API-Key", apiKey) - }.body>() - - assertNotNull(authResult["idToken"]) - } -} diff --git a/cloud-run/src/jvmIntegrationTest/kotlin/io/ashdavies/cloud/EventsTest.kt b/cloud-run/src/jvmIntegrationTest/kotlin/io/ashdavies/cloud/EventsTest.kt deleted file mode 100644 index 24ff54d25..000000000 --- a/cloud-run/src/jvmIntegrationTest/kotlin/io/ashdavies/cloud/EventsTest.kt +++ /dev/null @@ -1,32 +0,0 @@ -package io.ashdavies.cloud - -import io.ashdavies.playground.models.Event -import io.ktor.client.call.body -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation -import io.ktor.client.request.get -import io.ktor.client.request.post -import io.ktor.http.ContentType -import io.ktor.http.HttpStatusCode -import io.ktor.http.contentType -import io.ktor.serialization.kotlinx.json.json -import kotlin.test.Test -import kotlin.test.assertEquals - -internal class EventsTest { - - @Test - fun `should get events with default limit`() = testMainApplication { client -> - val response = client.get("/events") { contentType(ContentType.Application.Json) } - val body = response.body>() - - assertEquals(50, body.size) - } - - @Test - fun `should aggregate events`() = testMainApplication { client -> - val client = createClient { install(ContentNegotiation, ContentNegotiation.Config::json) } - val response = client.post("/events:aggregate") - - assertEquals(HttpStatusCode.OK, response.status) - } -} diff --git a/cloud-run/src/jvmIntegrationTest/kotlin/io/ashdavies/cloud/HelloTest.kt b/cloud-run/src/jvmIntegrationTest/kotlin/io/ashdavies/cloud/HelloTest.kt deleted file mode 100644 index 7e6f65959..000000000 --- a/cloud-run/src/jvmIntegrationTest/kotlin/io/ashdavies/cloud/HelloTest.kt +++ /dev/null @@ -1,25 +0,0 @@ -package io.ashdavies.cloud - -import io.ktor.client.request.get -import io.ktor.client.statement.bodyAsText -import io.ktor.http.HttpStatusCode -import kotlin.test.Test -import kotlin.test.assertEquals - -internal class HelloTest { - - @Test - fun `should create test application`() = testMainApplication { client -> - val response = client.get("/hello") - - assertEquals( - expected = HttpStatusCode.OK, - actual = response.status, - ) - - assertEquals( - actual = response.bodyAsText(), - expected = "Hello, World!", - ) - } -} diff --git a/cloud-run/src/jvmIntegrationTest/kotlin/io/ashdavies/cloud/TestApplication.kt b/cloud-run/src/jvmIntegrationTest/kotlin/io/ashdavies/cloud/TestApplication.kt deleted file mode 100644 index 9ef13beed..000000000 --- a/cloud-run/src/jvmIntegrationTest/kotlin/io/ashdavies/cloud/TestApplication.kt +++ /dev/null @@ -1,26 +0,0 @@ -package io.ashdavies.cloud - -import io.ktor.client.HttpClient -import io.ktor.client.HttpClientConfig -import io.ktor.client.engine.HttpClientEngineConfig -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation -import io.ktor.serialization.kotlinx.json.json -import io.ktor.server.application.Application -import io.ktor.server.testing.ApplicationTestBuilder -import io.ktor.server.testing.testApplication -import io.ktor.util.KtorDsl - -private val DefaultHttpConfig: HttpClientConfig.() -> Unit = { - install(ContentNegotiation, ContentNegotiation.Config::json) -} - -@KtorDsl -internal fun testMainApplication( - configuration: HttpClientConfig.() -> Unit = DefaultHttpConfig, - application: Application.() -> Unit = { main() }, - block: suspend ApplicationTestBuilder.(HttpClient) -> Unit, -) = testApplication { - val client = createClient(configuration) - application(application) - block(client) -} diff --git a/cloud-run/src/jvmIntegrationTest/kotlin/io/ashdavies/cloud/TokenTest.kt b/cloud-run/src/jvmIntegrationTest/kotlin/io/ashdavies/cloud/TokenTest.kt deleted file mode 100644 index 2f84e5dce..000000000 --- a/cloud-run/src/jvmIntegrationTest/kotlin/io/ashdavies/cloud/TokenTest.kt +++ /dev/null @@ -1,57 +0,0 @@ -package io.ashdavies.cloud - -import io.ashdavies.http.AppCheckToken -import io.ashdavies.playground.models.FirebaseApp -import io.ktor.client.call.body -import io.ktor.client.request.header -import io.ktor.client.request.post -import io.ktor.client.request.put -import io.ktor.client.request.setBody -import io.ktor.http.ContentType -import io.ktor.http.HttpHeaders -import io.ktor.http.contentType -import kotlinx.serialization.Serializable -import kotlin.test.Test -import kotlin.test.assertEquals - -internal class TokenTest { - - @Test - fun `should return app check token for request`() = testMainApplication { client -> - val tokenResponse = client.post("/firebase/token") { - setBody(FirebaseApp(System.getenv("MOBILE_SDK_APP_ID"))) - // headers { append("X-API-Key", playgroundApiKey) } - contentType(ContentType.Application.Json) - }.body() - - assertEquals( - actual = tokenResponse.ttlMillis, - expected = 3_600_000, - ) - - val verifyResponse = client.put("/firebase/token:verify") { - header(HttpHeaders.AppCheckToken, tokenResponse.token) - }.body() - - assertEquals( - expected = verifyResponse.appId, - actual = verifyResponse.subject, - ) - } -} - -@Serializable -private data class TokenResponse( - val ttlMillis: Long, - val token: String, -) - -@Serializable -private data class VerifyResponse( - val audience: List, - val expiresAt: Long, - val subject: String, - val issuedAt: Long, - val issuer: String, - val appId: String, -) diff --git a/cloud-run/src/jvmMain/resources/application.conf b/cloud-run/src/jvmMain/resources/application.conf deleted file mode 100644 index 790d53311..000000000 --- a/cloud-run/src/jvmMain/resources/application.conf +++ /dev/null @@ -1,9 +0,0 @@ -ktor { - application { - modules = [ io.ashdavies.cloud.MainKt.main ] - } - deployment { - port = 8080 - } - development = true -}