diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..a0be8bf4 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,298 @@ +# Agent Guidelines for github-actions-ci-dashboard + +## Project Overview + +A Kotlin-based GitHub Actions CI Dashboard for displaying build status on TV monitors. The application ingests GitHub webhook events and provides HTMX/Handlebars-based dashboards. + +**Stack:** +- Kotlin with Java 21+ +- http4k framework for HTTP +- PostgreSQL database with Jdbi and Flyway migrations +- HTMX + Handlebars templating +- Use BEM for css class naming. `block__element--modifier`. Vanilla CSS. The styling for the Dashboard resides in the `index.hbs` ` + + +
+ +
+
+

{{title}}

+
+
+ {{#if statuses.length}} + + + + + + + + + + + + {{#each statuses}} + + + + + + + + {{/each}} + +
RepositoryBranchStatusLast UpdatedActions
{{this.repoOwner}}/{{this.repoName}}{{this.branch}} + {{#if (eq this.status 'SUCCEEDED')}}{{this.status}} + {{else if (eq this.status 'FAILED')}}{{this.status}} + {{else if (eq this.status 'IN_PROGRESS')}}{{this.status}} + {{else if (eq this.status 'QUEUED')}}{{this.status}} + {{else}}{{this.status}}{{/if}} + {{this.lastUpdated}} + +
+ {{else}} +

No CI statuses found.

+ {{/if}} +
+
+
+ + diff --git a/src/main/resources/handlebars-htmx-templates/admin-configs.hbs b/src/main/resources/handlebars-htmx-templates/admin-configs.hbs new file mode 100644 index 00000000..d571f860 --- /dev/null +++ b/src/main/resources/handlebars-htmx-templates/admin-configs.hbs @@ -0,0 +1,92 @@ + + + + + + {{title}} - Admin + + + + +
+ +
+
+

{{title}}

+
+ {{#if configs.length}} + + + + + + + + + + {{#each configs}} + + + + + + {{/each}} + +
IDDisplay NameOrganization Matchers
{{this.id}}{{this.displayName}} + {{#each this.orgMatchers}} +
{{this.matcher}}
+ {{#if this.repoMatchers.length}} +
    + {{#each this.repoMatchers}} +
  • • {{this.matcher}}
  • + {{/each}} +
+ {{/if}} + {{/each}} +
+ {{else}} +

No dashboard configurations found.

+ {{/if}} +
+
+ + diff --git a/src/main/resources/handlebars-htmx-templates/admin-integration.hbs b/src/main/resources/handlebars-htmx-templates/admin-integration.hbs new file mode 100644 index 00000000..1a73a9b8 --- /dev/null +++ b/src/main/resources/handlebars-htmx-templates/admin-integration.hbs @@ -0,0 +1,116 @@ + + + + + + {{title}} - Admin + + + + +
+ +
+
+

{{title}}

+
+
+

Adding a New GitHub Organization

+

To add a new GitHub organization to the CI Dashboard, follow these steps:

+
    +
  1. Create a GitHub App or Webhook in the target organization.
  2. +
  3. Configure the webhook URL to point to:
    https://your-domain.com/webhook
  4. +
  5. Set the webhook secret to the value shown below.
  6. +
  7. Subscribe to events: Select workflow_run events.
  8. +
  9. Save the webhook and verify it works by triggering a workflow.
  10. +
+
+
+

Webhook Secret

+

Use this secret when configuring webhooks in GitHub:

+
+ {{webhookSecret}} + + +
+
+
+

Example Webhook Payload

+

The webhook expects workflow_run events from GitHub Actions.

+
{
+  "action": "completed",
+  "workflow_run": {
+    "id": 123456,
+    "status": "completed",
+    "conclusion": "success",
+    "name": "CI",
+    "head_branch": "main",
+    "repository": {
+      "name": "my-repo",
+      "owner": { "login": "my-org" }
+    }
+  }
+}
+
+ +
+
+ + diff --git a/src/test/kotlin/acceptancetests/DevelopmentAid.kt b/src/test/kotlin/acceptancetests/DevelopmentAid.kt index f85abfb8..aa163d33 100644 --- a/src/test/kotlin/acceptancetests/DevelopmentAid.kt +++ b/src/test/kotlin/acceptancetests/DevelopmentAid.kt @@ -50,6 +50,8 @@ class DevelopmentAid { infra.gitHub.sendWebhook(createPayload("repo-a", CiStatus.PipelineStatus.QUEUED)) + infra.admin.uploadConfiguration() + println( "\n".repeat(10) + "http://localhost:" + diff --git a/src/test/kotlin/no/liflig/cidashboard/admin/auth/CognitoAuthServiceTest.kt b/src/test/kotlin/no/liflig/cidashboard/admin/auth/CognitoAuthServiceTest.kt new file mode 100644 index 00000000..575c4b55 --- /dev/null +++ b/src/test/kotlin/no/liflig/cidashboard/admin/auth/CognitoAuthServiceTest.kt @@ -0,0 +1,93 @@ +package no.liflig.cidashboard.admin.auth + +import no.liflig.cidashboard.common.config.CognitoConfig +import org.assertj.core.api.Assertions.assertThat +import org.http4k.core.Method +import org.http4k.core.Request +import org.http4k.core.Response +import org.http4k.core.Status +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +class CognitoAuthServiceTest { + + private val testConfig = + CognitoConfig( + userPoolId = "eu-north-1_test", + clientId = "test-client", + clientSecret = "", + domain = "test", + region = "eu-north-1", + requiredGroup = "admin", + bypassEnabled = false, + appBaseUrl = "http://localhost:8080", + ) + + @Test + fun `should redirect to oauth provider when no token present`() { + val authService = createService(testConfig) + val filter = authService.authFilter() + + val request = Request(Method.GET, "/admin/ci-statuses") + val response = filter { Response(Status.OK) }(request) + + assertThat(response.status).isEqualTo(Status.TEMPORARY_REDIRECT) + assertThat(response.header("Location")) + .contains("test.auth.eu-north-1.amazoncognito.com/oauth2/authorize") + } + + @Test + fun `should allow access when bypass is enabled`() { + val configWithBypass = testConfig.copy(bypassEnabled = true) + val authService = createService(configWithBypass) + val filter = authService.authFilter() + + val request = Request(Method.GET, "/admin/ci-statuses") + val response = filter { Response(Status.OK).body("success") }(request) + + assertThat(response.status).isEqualTo(Status.OK) + assertThat(response.bodyString()).isEqualTo("success") + } + + @Test + fun `should add user to request context when bypass enabled`() { + val configWithBypass = testConfig.copy(bypassEnabled = true) + val authService = createService(configWithBypass) + val filter = authService.authFilter() + + lateinit var capturedUser: CognitoUser + val request = Request(Method.GET, "/admin/ci-statuses") + filter { + capturedUser = CognitoAuthService.requireCognitoUser(it) + Response(Status.OK) + }(request) + + assertThat(capturedUser.username).isEqualTo("bypass-user") + assertThat(capturedUser.groups).contains("admin") + } + + @Test + fun `should require cognito user throw when no user present`() { + val request = Request(Method.GET, "/test") + + val exception = + assertThrows { CognitoAuthService.requireCognitoUser(request) } + + assertThat(exception.message).contains("No Cognito user in request context") + } + + @Test + fun `should provide callback handler`() { + val authService = createService(testConfig) + + assertThat(authService.callbackHandler()).isNotNull + } + + private fun createService(config: CognitoConfig): CognitoAuthService { + return CognitoAuthService( + config = config, + httpClient = { Response(Status.OK) }, + ) + } +} diff --git a/src/test/kotlin/no/liflig/cidashboard/admin/gui/AdminGuiApiTest.kt b/src/test/kotlin/no/liflig/cidashboard/admin/gui/AdminGuiApiTest.kt new file mode 100644 index 00000000..77101fe0 --- /dev/null +++ b/src/test/kotlin/no/liflig/cidashboard/admin/gui/AdminGuiApiTest.kt @@ -0,0 +1,65 @@ +package no.liflig.cidashboard.admin.gui + +import io.restassured.RestAssured +import org.http4k.core.Status +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension +import test.util.AcceptanceTestExtension +import test.util.Integration + +@Integration +class AdminGuiApiTest { + + companion object { + @JvmField @RegisterExtension val infra = AcceptanceTestExtension() + } + + @BeforeEach + fun setUp() { + RestAssured.port = infra.app.config.apiOptions.serverPort.value + } + + @Test + fun `admin index should redirect to ci-statuses when cognito bypass enabled`() { + RestAssured.given() + .redirects() + .follow(false) + .`when`() + .get("/admin") + .then() + .assertThat() + .statusCode(Status.FOUND.code) + .header("Location", "/admin/ci-statuses") + } + + @Test + fun `ci-statuses page should return 200 when cognito bypass enabled`() { + RestAssured.`when`() + .get("/admin/ci-statuses") + .then() + .assertThat() + .statusCode(Status.OK.code) + .contentType("text/html") + } + + @Test + fun `integration guide page should return 200 when cognito bypass enabled`() { + RestAssured.`when`() + .get("/admin/integration") + .then() + .assertThat() + .statusCode(Status.OK.code) + .contentType("text/html") + } + + @Test + fun `configs page should return 200 when cognito bypass enabled`() { + RestAssured.`when`() + .get("/admin/configs") + .then() + .assertThat() + .statusCode(Status.OK.code) + .contentType("text/html") + } +} diff --git a/src/test/kotlin/no/liflig/cidashboard/admin/gui/AdminGuiOAuthApiTest.kt b/src/test/kotlin/no/liflig/cidashboard/admin/gui/AdminGuiOAuthApiTest.kt new file mode 100644 index 00000000..dfceb1cb --- /dev/null +++ b/src/test/kotlin/no/liflig/cidashboard/admin/gui/AdminGuiOAuthApiTest.kt @@ -0,0 +1,75 @@ +package no.liflig.cidashboard.admin.gui + +import io.restassured.RestAssured +import org.http4k.core.Status +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension +import test.util.AcceptanceTestExtension +import test.util.Integration + +@Integration +class AdminGuiOAuthApiTest { + + companion object { + @JvmField @RegisterExtension val infra = AcceptanceTestExtension(cognitoBypassEnabled = false) + } + + @BeforeEach + fun setUp() { + RestAssured.port = infra.app.config.apiOptions.serverPort.value + } + + @Test + fun `admin index should redirect to oauth provider when not authenticated`() { + RestAssured.given() + .redirects() + .follow(false) + .`when`() + .get("/admin") + .then() + .assertThat() + .statusCode(Status.TEMPORARY_REDIRECT.code) + .header("Location", org.hamcrest.Matchers.containsString("oauth2/authorize")) + } + + @Test + fun `admin ci-statuses should redirect to oauth provider when not authenticated`() { + RestAssured.given() + .redirects() + .follow(false) + .`when`() + .get("/admin/ci-statuses") + .then() + .assertThat() + .statusCode(Status.TEMPORARY_REDIRECT.code) + .header("Location", org.hamcrest.Matchers.containsString("oauth2/authorize")) + } + + @Test + fun `oauth callback endpoint should exist and handle missing code`() { + RestAssured.`when`() + .get("/admin/oauth/callback") + .then() + .assertThat() + .statusCode( + org.hamcrest.Matchers.anyOf( + org.hamcrest.Matchers.equalTo(Status.BAD_REQUEST.code), + org.hamcrest.Matchers.equalTo(Status.FORBIDDEN.code), + org.hamcrest.Matchers.equalTo(Status.TEMPORARY_REDIRECT.code), + ) + ) + } + + @Test + fun `oauth callback should set csrf cookie on redirect`() { + RestAssured.given() + .redirects() + .follow(false) + .`when`() + .get("/admin/ci-statuses") + .then() + .assertThat() + .cookie("cognitoCsrf", org.hamcrest.Matchers.notNullValue()) + } +} diff --git a/src/test/kotlin/no/liflig/cidashboard/admin/gui/AdminGuiServiceTest.kt b/src/test/kotlin/no/liflig/cidashboard/admin/gui/AdminGuiServiceTest.kt new file mode 100644 index 00000000..d46ccf16 --- /dev/null +++ b/src/test/kotlin/no/liflig/cidashboard/admin/gui/AdminGuiServiceTest.kt @@ -0,0 +1,137 @@ +package no.liflig.cidashboard.admin.gui + +import io.mockk.every +import io.mockk.mockk +import java.time.Instant +import no.liflig.cidashboard.DashboardConfig +import no.liflig.cidashboard.DashboardConfigId +import no.liflig.cidashboard.OrganizationMatcher +import no.liflig.cidashboard.persistence.BranchName +import no.liflig.cidashboard.persistence.CiStatus +import no.liflig.cidashboard.persistence.CiStatusId +import no.liflig.cidashboard.persistence.CiStatusRepo +import no.liflig.cidashboard.persistence.Commit +import no.liflig.cidashboard.persistence.DashboardConfigRepo +import no.liflig.cidashboard.persistence.Repo +import no.liflig.cidashboard.persistence.RepoId +import no.liflig.cidashboard.persistence.RepoName +import no.liflig.cidashboard.persistence.User +import no.liflig.cidashboard.persistence.UserId +import no.liflig.cidashboard.persistence.Username +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class AdminGuiServiceTest { + + @Test + fun `should map ci statuses to rows`() { + val now = Instant.now() + val user = + User( + id = UserId(1), + username = Username("testuser"), + avatarUrl = "https://example.com/avatar.png", + ) + val commit = + Commit( + sha = "abc123", + commitDate = now, + title = "Test commit", + message = "Test message", + commiter = user, + ) + val statuses = + listOf( + CiStatus( + id = CiStatusId("status-1"), + repo = + Repo( + id = RepoId(1), + owner = Username("my-org"), + name = RepoName("my-repo"), + defaultBranch = BranchName("main"), + ), + branch = BranchName("main"), + lastStatus = CiStatus.PipelineStatus.SUCCEEDED, + startedAt = now, + lastUpdatedAt = now, + buildNumber = 1, + lastCommit = commit, + triggeredBy = Username("testuser"), + ), + CiStatus( + id = CiStatusId("status-2"), + repo = + Repo( + id = RepoId(2), + owner = Username("other-org"), + name = RepoName("other-repo"), + defaultBranch = BranchName("develop"), + ), + branch = BranchName("develop"), + lastStatus = CiStatus.PipelineStatus.FAILED, + startedAt = now, + lastUpdatedAt = now.plusSeconds(60), + buildNumber = 2, + lastCommit = commit, + triggeredBy = Username("testuser"), + ), + ) + val ciStatusRepo = mockk { every { getAll() } returns statuses } + val dashboardConfigRepo = mockk { every { getAll() } returns emptyList() } + + val service = + AdminGuiService( + ciStatusRepo = { callback -> callback(ciStatusRepo) }, + configRepo = { callback -> callback(dashboardConfigRepo) }, + ) + val rows = service.getCiStatuses() + + assertThat(rows).hasSize(2) + assertThat(rows[0].id).isEqualTo("status-1") + assertThat(rows[0].repoOwner).isEqualTo("my-org") + assertThat(rows[0].repoName).isEqualTo("my-repo") + assertThat(rows[0].branch).isEqualTo("main") + assertThat(rows[0].status).isEqualTo("SUCCEEDED") + assertThat(rows[1].id).isEqualTo("status-2") + assertThat(rows[1].status).isEqualTo("FAILED") + } + + @Test + fun `should map configs to rows`() { + val configs = + listOf( + DashboardConfig( + id = DashboardConfigId("config-1"), + displayName = "My Dashboard", + orgMatchers = + listOf( + OrganizationMatcher(Regex("org-1")), + OrganizationMatcher(Regex("org-2")), + ), + ), + ) + val ciStatusRepo = mockk { every { getAll() } returns emptyList() } + val dashboardConfigRepo = mockk { every { getAll() } returns configs } + + val service = + AdminGuiService( + ciStatusRepo = { callback -> callback(ciStatusRepo) }, + configRepo = { callback -> callback(dashboardConfigRepo) }, + ) + val rows = service.getConfigs() + + assertThat(rows).hasSize(1) + assertThat(rows[0].id).isEqualTo("config-1") + assertThat(rows[0].displayName).isEqualTo("My Dashboard") + assertThat(rows[0].orgMatchers).isEqualTo("org-1, org-2") + } + + @Test + fun `should handle empty lists`() { + val service = AdminGuiService(ciStatusRepo = { emptyList() }, configRepo = { emptyList() }) + + assertThat(service.getCiStatuses()).isEmpty() + assertThat(service.getConfigs()).isEmpty() + } +} diff --git a/src/test/kotlin/no/liflig/cidashboard/common/config/CognitoConfigTest.kt b/src/test/kotlin/no/liflig/cidashboard/common/config/CognitoConfigTest.kt new file mode 100644 index 00000000..d66bdff6 --- /dev/null +++ b/src/test/kotlin/no/liflig/cidashboard/common/config/CognitoConfigTest.kt @@ -0,0 +1,128 @@ +package no.liflig.cidashboard.common.config + +import java.util.Properties +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +class CognitoConfigTest { + + @Test + fun `should create config from properties`() { + val props = + Properties().apply { + setProperty("admin.gui.enabled", "true") + setProperty("cognito.userPoolId", "eu-north-1_abc123") + setProperty("cognito.clientId", "client-id-123") + setProperty("cognito.clientSecret", "secret-123") + setProperty("cognito.domain", "my-app") + setProperty("cognito.region", "eu-north-1") + setProperty("cognito.requiredGroup", "admin-group") + setProperty("cognito.appBaseUrl", "https://myapp.example.com") + setProperty("cognito.bypassEnabled", "false") + } + + val config = CognitoConfig.from(props) + + assertThat(config).isNotNull + assertThat(config!!.userPoolId).isEqualTo("eu-north-1_abc123") + assertThat(config.clientId).isEqualTo("client-id-123") + assertThat(config.clientSecret).isEqualTo("secret-123") + assertThat(config.domain).isEqualTo("my-app") + assertThat(config.region).isEqualTo("eu-north-1") + assertThat(config.requiredGroup).isEqualTo("admin-group") + assertThat(config.appBaseUrl).isEqualTo("https://myapp.example.com") + assertThat(config.bypassEnabled).isFalse + } + + @Test + fun `should throw when required properties are missing and bypass is disabled`() { + val props = + Properties().apply { + setProperty("admin.gui.enabled", "true") + setProperty("cognito.bypassEnabled", "false") + } + + assertThrows { CognitoConfig.from(props) } + } + + @Test + fun `should create config with dummy values when bypass is enabled but properties missing`() { + val props = + Properties().apply { + setProperty("admin.gui.enabled", "true") + setProperty("cognito.bypassEnabled", "true") + setProperty("cognito.requiredGroup", "test") + } + + val config = CognitoConfig.from(props) + + assertThat(config).isNotNull + assertThat(config!!.userPoolId).isEqualTo("not-configured") + assertThat(config.clientId).isEqualTo("not-configured") + assertThat(config.domain).isEqualTo("not-configured") + assertThat(config.region).isEqualTo("eu-west-1") + assertThat(config.appBaseUrl).isEqualTo("not-configured") + assertThat(config.bypassEnabled).isTrue + } + + @Test + fun `should generate correct issuer url`() { + val config = + CognitoConfig( + userPoolId = "eu-north-1_abc123", + clientId = "client-id", + clientSecret = "123", + domain = "my-app", + region = "eu-north-1", + requiredGroup = "admin", + bypassEnabled = false, + appBaseUrl = "https://myapp.example.com", + ) + + assertThat(config.issuerUrl) + .isEqualTo("https://cognito-idp.eu-north-1.amazonaws.com/eu-north-1_abc123") + } + + @Test + fun `should generate correct jwks url`() { + val config = + CognitoConfig( + userPoolId = "eu-north-1_abc123", + clientId = "client-id", + clientSecret = "123", + domain = "my-app", + region = "eu-north-1", + requiredGroup = "admin", + bypassEnabled = false, + appBaseUrl = "https://myapp.example.com", + ) + + assertThat(config.jwksUrl) + .isEqualTo( + "https://cognito-idp.eu-north-1.amazonaws.com/eu-north-1_abc123/.well-known/jwks.json" + ) + } + + @Test + fun `should generate correct oauth endpoints`() { + val config = + CognitoConfig( + userPoolId = "eu-north-1_abc123", + clientId = "client-id", + clientSecret = "123", + domain = "my-app", + region = "eu-north-1", + requiredGroup = "admin", + bypassEnabled = false, + appBaseUrl = "https://myapp.example.com", + ) + + assertThat(config.authorizationEndpoint) + .isEqualTo("https://my-app.auth.eu-north-1.amazoncognito.com/oauth2/authorize") + assertThat(config.tokenEndpoint) + .isEqualTo("https://my-app.auth.eu-north-1.amazoncognito.com/oauth2/token") + assertThat(config.logoutEndpoint) + .isEqualTo("https://my-app.auth.eu-north-1.amazoncognito.com/logout") + } +} diff --git a/src/test/kotlin/test/util/AcceptanceTestExtension.kt b/src/test/kotlin/test/util/AcceptanceTestExtension.kt index 15868a5e..3367422f 100644 --- a/src/test/kotlin/test/util/AcceptanceTestExtension.kt +++ b/src/test/kotlin/test/util/AcceptanceTestExtension.kt @@ -1,5 +1,7 @@ package test.util +import com.github.tomakehurst.wiremock.WireMockServer +import com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig import com.microsoft.playwright.Browser import com.microsoft.playwright.BrowserContext import com.microsoft.playwright.Page @@ -17,7 +19,9 @@ import java.nio.file.Paths import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds import no.liflig.cidashboard.App +import no.liflig.cidashboard.common.config.AdminSecretToken import no.liflig.cidashboard.common.config.ClientSecretToken +import no.liflig.cidashboard.common.config.CognitoConfig import no.liflig.cidashboard.common.config.Config import no.liflig.cidashboard.common.config.DbConfig import no.liflig.cidashboard.common.config.Port @@ -42,19 +46,23 @@ import org.testcontainers.containers.PostgreSQLContainer * To test at this high level and end-to-end, the system requires infrastructure or mocks/simulators * to properly interact with external systems. */ -class AcceptanceTestExtension(val fastPoll: Boolean = true) : - Extension, BeforeAllCallback, AfterAllCallback, BeforeEachCallback { +class AcceptanceTestExtension( + val fastPoll: Boolean = true, + val cognitoBypassEnabled: Boolean = true, +) : Extension, BeforeAllCallback, AfterAllCallback, BeforeEachCallback { lateinit var app: App val gitHub = GitHub() val database = Database() val tvBrowser = TvBrowser() + val cognito = Cognito() + val admin = Admin() override fun beforeAll(context: ExtensionContext) { database.start() - val config = + var config = Config.load() .let { database.applyTo(it) } .let { setUnusedHttpPort(it) } @@ -66,6 +74,11 @@ class AcceptanceTestExtension(val fastPoll: Boolean = true) : } } + if (!cognitoBypassEnabled) { + cognito.start() + config = cognito.applyTo(config) + } + app = App(config) app.start() @@ -80,12 +93,20 @@ class AcceptanceTestExtension(val fastPoll: Boolean = true) : authToken = config.apiOptions.clientSecretToken, dashboardId = "abc", ) + + admin.initialize( + port = config.apiOptions.serverPort, + adminToken = config.apiOptions.adminSecretToken, + ) } override fun afterAll(context: ExtensionContext) { app.stop() database.stop() tvBrowser.close() + if (!cognitoBypassEnabled) { + cognito.stop() + } } override fun beforeEach(context: ExtensionContext) { @@ -292,6 +313,92 @@ END${'$'}${'$'};""" ) } } + + class Admin { + private var port: Port = Port(8080) + private var adminToken: AdminSecretToken = AdminSecretToken("unset") + + fun initialize(port: Port, adminToken: AdminSecretToken) { + this.port = port + this.adminToken = adminToken + } + + fun uploadConfiguration() { + RestAssured.given() + .body( + """[{ + "id": "a1", + "displayName": "Team A", + "orgMatchers": [ + { + "matcher": "capralifecycle", + "repoMatchers": [ + { + "matcher": ".*-a" + }, + { + "matcher": "repo-y.*" + } + ] + } + ] + }]""" + ) + .header("Authorization", "Bearer ${adminToken.value}") + .log() + .method() + .log() + .uri() + .log() + .headers() + .post("http://localhost:${port.value}/admin/config") + .then() + .statusCode(200) + .log() + .ifError() + } + } + + class Cognito { + private lateinit var wireMock: WireMockServer + private lateinit var cognitoMock: CognitoWireMock + + fun start() { + wireMock = WireMockServer(wireMockConfig().dynamicPort()) + wireMock.start() + cognitoMock = CognitoWireMock(wireMock) + cognitoMock.setupStubs() + } + + fun stop() { + wireMock.stop() + } + + fun applyTo(config: Config): Config { + return config.copy( + cognitoConfig = + CognitoConfig( + userPoolId = cognitoMock.userPoolId, + clientId = cognitoMock.clientId, + clientSecret = cognitoMock.clientSecret, + domain = cognitoMock.domain, + region = cognitoMock.region, + requiredGroup = "liflig-active", + bypassEnabled = false, + issuerUrlOverride = cognitoMock.issuer, + authBaseOverride = cognitoMock.authBaseUrl, + appBaseUrl = "http://localhost:${config.apiOptions.serverPort.value}", + ) + ) + } + + fun generateAccessToken( + username: String = "test-user", + groups: List = listOf("liflig-active"), + ): String = cognitoMock.generateAccessToken(username, groups) + + fun baseUrl(): String = wireMock.baseUrl() + } } interface WebhookPayload { diff --git a/src/test/kotlin/test/util/CognitoWireMock.kt b/src/test/kotlin/test/util/CognitoWireMock.kt new file mode 100644 index 00000000..9995f36f --- /dev/null +++ b/src/test/kotlin/test/util/CognitoWireMock.kt @@ -0,0 +1,102 @@ +package test.util + +import com.github.tomakehurst.wiremock.WireMockServer +import com.github.tomakehurst.wiremock.client.WireMock +import com.nimbusds.jose.JWSAlgorithm +import com.nimbusds.jose.JWSHeader +import com.nimbusds.jose.JWSSigner +import com.nimbusds.jose.crypto.RSASSASigner +import com.nimbusds.jose.jwk.JWKSet +import com.nimbusds.jose.jwk.KeyUse +import com.nimbusds.jose.jwk.RSAKey +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator +import com.nimbusds.jwt.JWTClaimsSet +import com.nimbusds.jwt.SignedJWT +import java.util.Date +import java.util.UUID + +class CognitoWireMock(private val wireMock: WireMockServer) { + private val rsaKey: RSAKey = + RSAKeyGenerator(2048) + .keyID(UUID.randomUUID().toString()) + .keyUse(KeyUse.SIGNATURE) + .algorithm(JWSAlgorithm.RS256) + .generate() + + private val signer: JWSSigner = RSASSASigner(rsaKey) + + val jwksJson: String = JWKSet(listOf(rsaKey.toPublicJWK())).toString() + + val issuer: String + get() = "${wireMock.baseUrl()}/cognito-idp/eu-north-1_test" + + val authBaseUrl: String + get() = wireMock.baseUrl() + + val domain: String + get() = wireMock.baseUrl().removePrefix("http://").removePrefix("https://") + + val region: String = "eu-north-1" + + val userPoolId: String = "eu-north-1_test" + + val clientId: String = "test-client-id" + + val clientSecret: String = "test-client-secret" + + fun setupStubs() { + wireMock.stubFor( + WireMock.get(WireMock.urlPathEqualTo("/cognito-idp/eu-north-1_test/.well-known/jwks.json")) + .willReturn( + WireMock.aResponse() + .withHeader("Content-Type", "application/json") + .withBody(jwksJson) + ) + ) + + wireMock.stubFor( + WireMock.post(WireMock.urlPathEqualTo("/oauth2/token")) + .willReturn( + WireMock.aResponse() + .withHeader("Content-Type", "application/json") + .withBody( + """ + { + "access_token": "${generateAccessToken()}", + "token_type": "Bearer", + "expires_in": 3600 + } + """ + .trimIndent() + ) + ) + ) + } + + fun generateAccessToken( + username: String = "test-user", + groups: List = listOf("liflig-active"), + expirationMinutes: Int = 60, + ): String { + val now = Date() + val expiration = Date(now.time + expirationMinutes * 60 * 1000L) + + val claims = + JWTClaimsSet.Builder() + .issuer(issuer) + .subject(username) + .audience(clientId) + .issueTime(now) + .expirationTime(expiration) + .claim("cognito:username", username) + .claim("email", "$username@example.com") + .claim("cognito:groups", groups) + .build() + + val signedJWT = + SignedJWT(JWSHeader.Builder(JWSAlgorithm.RS256).keyID(rsaKey.keyID).build(), claims) + signedJWT.sign(signer) + + return signedJWT.serialize() + } +}