From f744a37980cf9421430733097383ad555caaf32c Mon Sep 17 00:00:00 2001 From: Kristian Rekstad Date: Fri, 20 Feb 2026 12:35:50 +0100 Subject: [PATCH 01/13] Add AGENTS.md file --- AGENTS.md | 298 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 298 insertions(+) create mode 100644 AGENTS.md 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..989031bb --- /dev/null +++ b/src/main/resources/handlebars-htmx-templates/admin-configs.hbs @@ -0,0 +1,78 @@ + + + + + + {{title}} - Admin + + + + +
+ +
+
+

{{title}}

+
+ {{#if configs.length}} + + + + + + + + + + {{#each configs}} + + + + + + {{/each}} + +
IDDisplay NameOrganization Matchers
{{this.id}}{{this.displayName}}{{this.orgMatchers}}
+ {{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/no/liflig/cidashboard/admin/auth/CognitoAuthServiceTest.kt b/src/test/kotlin/no/liflig/cidashboard/admin/auth/CognitoAuthServiceTest.kt new file mode 100644 index 00000000..7e706be1 --- /dev/null +++ b/src/test/kotlin/no/liflig/cidashboard/admin/auth/CognitoAuthServiceTest.kt @@ -0,0 +1,97 @@ +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.http4k.core.Uri +import org.junit.jupiter.api.Test + +class CognitoAuthServiceTest { + + private val testConfig = + CognitoConfig( + userPoolId = "eu-north-1_test", + clientId = "test-client", + clientSecret = null, + domain = "test", + region = "eu-north-1", + requiredGroup = "admin", + bypassEnabled = false, + appBaseUrl = "http://localhost:8080", + ) + + private val callbackUri = Uri.of("http://localhost:8080/admin/oauth/callback") + + @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 = + org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException::class.java) { + 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, + callbackUri = callbackUri, + 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..a092643c --- /dev/null +++ b/src/test/kotlin/no/liflig/cidashboard/admin/gui/AdminGuiServiceTest.kt @@ -0,0 +1,121 @@ +package no.liflig.cidashboard.admin.gui + +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.Commit +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 service = AdminGuiService(ciStatusRepo = { statuses }, configRepo = { emptyList() }) + 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 service = AdminGuiService(ciStatusRepo = { emptyList() }, configRepo = { configs }) + 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..3313ae92 --- /dev/null +++ b/src/test/kotlin/no/liflig/cidashboard/common/config/CognitoConfigTest.kt @@ -0,0 +1,138 @@ +package no.liflig.cidashboard.common.config + +import java.util.Properties +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class CognitoConfigTest { + + @Test + fun `should create config from properties`() { + val props = + Properties().apply { + 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", "true") + } + + 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).isTrue + } + + @Test + fun `should return null when required properties are missing and bypass is disabled`() { + val props = Properties() + + val config = CognitoConfig.from(props) + + assertThat(config).isNull() + } + + @Test + fun `should create config with dummy values when bypass is enabled but properties missing`() { + val props = Properties().apply { setProperty("cognito.bypassEnabled", "true") } + + 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("not-configured") + assertThat(config.appBaseUrl).isEqualTo("not-configured") + assertThat(config.bypassEnabled).isTrue + } + + @Test + fun `should use default values for optional properties`() { + val props = + Properties().apply { + setProperty("cognito.userPoolId", "eu-north-1_abc123") + setProperty("cognito.clientId", "client-id-123") + setProperty("cognito.domain", "my-app") + setProperty("cognito.region", "eu-north-1") + setProperty("cognito.appBaseUrl", "https://myapp.example.com") + } + + val config = CognitoConfig.from(props) + + assertThat(config).isNotNull + assertThat(config!!.clientSecret).isNull() + assertThat(config.requiredGroup).isEqualTo("liflig-active") + assertThat(config.bypassEnabled).isFalse + } + + @Test + fun `should generate correct issuer url`() { + val config = + CognitoConfig( + userPoolId = "eu-north-1_abc123", + clientId = "client-id", + clientSecret = null, + 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 = null, + 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 = null, + 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..c3211273 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 @@ -18,6 +20,7 @@ import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds import no.liflig.cidashboard.App 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 +45,22 @@ 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() override fun beforeAll(context: ExtensionContext) { database.start() - val config = + var config = Config.load() .let { database.applyTo(it) } .let { setUnusedHttpPort(it) } @@ -66,6 +72,11 @@ class AcceptanceTestExtension(val fastPoll: Boolean = true) : } } + if (!cognitoBypassEnabled) { + cognito.start() + config = cognito.applyTo(config) + } + app = App(config) app.start() @@ -86,6 +97,9 @@ class AcceptanceTestExtension(val fastPoll: Boolean = true) : app.stop() database.stop() tvBrowser.close() + if (!cognitoBypassEnabled) { + cognito.stop() + } } override fun beforeEach(context: ExtensionContext) { @@ -292,6 +306,47 @@ END${'$'}${'$'};""" ) } } + + inner 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..764a20ef --- /dev/null +++ b/src/test/kotlin/test/util/CognitoWireMock.kt @@ -0,0 +1,107 @@ +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 + get() = 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 + get() = "eu-north-1" + + val userPoolId: String + get() = "eu-north-1_test" + + val clientId: String + get() = "test-client-id" + + val clientSecret: String + get() = "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() + } +} From 63e88abbcb3f4aa9121bb05da8ec482fd457629b Mon Sep 17 00:00:00 2001 From: Kristian Rekstad Date: Fri, 20 Feb 2026 22:57:16 +0100 Subject: [PATCH 03/13] Add config to development aid --- .../kotlin/acceptancetests/DevelopmentAid.kt | 2 + .../test/util/AcceptanceTestExtension.kt | 54 ++++++++++++++++++- 2 files changed, 55 insertions(+), 1 deletion(-) 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/test/util/AcceptanceTestExtension.kt b/src/test/kotlin/test/util/AcceptanceTestExtension.kt index c3211273..3367422f 100644 --- a/src/test/kotlin/test/util/AcceptanceTestExtension.kt +++ b/src/test/kotlin/test/util/AcceptanceTestExtension.kt @@ -19,6 +19,7 @@ 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 @@ -56,6 +57,7 @@ class AcceptanceTestExtension( val database = Database() val tvBrowser = TvBrowser() val cognito = Cognito() + val admin = Admin() override fun beforeAll(context: ExtensionContext) { database.start() @@ -91,6 +93,11 @@ class AcceptanceTestExtension( authToken = config.apiOptions.clientSecretToken, dashboardId = "abc", ) + + admin.initialize( + port = config.apiOptions.serverPort, + adminToken = config.apiOptions.adminSecretToken, + ) } override fun afterAll(context: ExtensionContext) { @@ -307,7 +314,52 @@ END${'$'}${'$'};""" } } - inner class Cognito { + 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 From 9164d31559b6000c31c75b19c31565599adb12e8 Mon Sep 17 00:00:00 2001 From: Kristian Rekstad Date: Mon, 23 Feb 2026 11:07:34 +0100 Subject: [PATCH 04/13] Show repo matchers for configs --- .../handlebars-htmx-templates/admin-configs.hbs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/main/resources/handlebars-htmx-templates/admin-configs.hbs b/src/main/resources/handlebars-htmx-templates/admin-configs.hbs index 989031bb..d571f860 100644 --- a/src/main/resources/handlebars-htmx-templates/admin-configs.hbs +++ b/src/main/resources/handlebars-htmx-templates/admin-configs.hbs @@ -25,6 +25,9 @@ .admin-table th { background-color: #f8f8f8; font-weight: 600; color: #666; } .admin-table tr:hover { background-color: #f9f9f9; } .admin-empty { text-align: center; padding: 2rem; color: #666; } + .admin-table__org-matcher { font-weight: 500; margin-bottom: 0.25rem; } + .admin-table__repo-list { list-style: none; margin-left: 1rem; margin-bottom: 0.5rem; } + .admin-table__repo-item { color: #666; font-size: 0.875rem; } @@ -64,7 +67,18 @@ {{this.id}} {{this.displayName}} - {{this.orgMatchers}} + + {{#each this.orgMatchers}} +
{{this.matcher}}
+ {{#if this.repoMatchers.length}} +
    + {{#each this.repoMatchers}} +
  • • {{this.matcher}}
  • + {{/each}} +
+ {{/if}} + {{/each}} + {{/each}} From 4b4839b2cb3a271d17de01e829c91204b335504d Mon Sep 17 00:00:00 2001 From: Kristian Rekstad Date: Mon, 23 Feb 2026 14:43:33 +0100 Subject: [PATCH 05/13] Cleanup and refactor --- src/main/kotlin/no/liflig/cidashboard/Api.kt | 40 ++++++------ src/main/kotlin/no/liflig/cidashboard/App.kt | 19 ++++-- .../admin/auth/CognitoAuthService.kt | 8 ++- .../admin/auth/CognitoOAuthProvider.kt | 10 +-- .../common/config/CognitoConfig.kt | 63 +++++++++++-------- .../resources-filtered/application.properties | 8 ++- .../admin/auth/CognitoAuthServiceTest.kt | 6 +- .../common/config/CognitoConfigTest.kt | 54 +++++++--------- 8 files changed, 108 insertions(+), 100 deletions(-) diff --git a/src/main/kotlin/no/liflig/cidashboard/Api.kt b/src/main/kotlin/no/liflig/cidashboard/Api.kt index 1f27ab79..14f70e23 100644 --- a/src/main/kotlin/no/liflig/cidashboard/Api.kt +++ b/src/main/kotlin/no/liflig/cidashboard/Api.kt @@ -29,7 +29,7 @@ import no.liflig.http4k.setup.LifligBasicApiSetup import no.liflig.http4k.setup.logging.LoggingFilter import org.http4k.contract.openapi.v3.ApiServer import org.http4k.core.Method -import org.http4k.core.Uri +import org.http4k.core.Response import org.http4k.core.then import org.http4k.filter.ServerFilters import org.http4k.routing.ResourceLoader.Companion.Classpath @@ -68,25 +68,21 @@ fun createApiServer( val indexEndpoint = IndexEndpoint(options.clientSecretToken, options.hotReloadTemplates, options.updatesPollRate) - val adminRoutes: RoutingHttpHandler = - if (cognitoConfig != null) { - val callbackUri = Uri.of("${cognitoConfig.appBaseUrl}/admin/oauth/callback") - val authService = - CognitoAuthService( - config = cognitoConfig, - callbackUri = callbackUri, - httpClient = org.http4k.client.JavaHttpClient(), - ) - val adminGuiService = services.adminGuiService - + val adminGuiRoutes: RoutingHttpHandler = + if (services.cognitoAuthService != null) { routes( - "/admin/oauth/callback" bind Method.GET to authService.callbackHandler(), + "/admin/oauth/callback" bind + Method.GET to + services.cognitoAuthService.callbackHandler(), "/admin" bind routes( "/" bind Method.GET to AdminIndexEndpoint(), "/ci-statuses" bind Method.GET to - CiStatusListEndpoint(adminGuiService, options.hotReloadTemplates), + CiStatusListEndpoint( + services.adminGuiService, + options.hotReloadTemplates, + ), "/ci-statuses/{id}" bind Method.DELETE to CiStatusDeleteEndpoint( @@ -101,17 +97,18 @@ fun createApiServer( ), "/configs" bind Method.GET to - ConfigListEndpoint(adminGuiService, options.hotReloadTemplates), + ConfigListEndpoint( + services.adminGuiService, + options.hotReloadTemplates, + ), ) - .withFilter(authService.authFilter()), + .withFilter(services.cognitoAuthService.authFilter()), ) } else { "/admin" bind - org.http4k.core.Method.GET to + Method.GET to { - org.http4k.core - .Response(org.http4k.core.Status.NOT_FOUND) - .body("Admin GUI not configured") + Response(org.http4k.core.Status.NOT_FOUND).body("Admin GUI not configured") } } @@ -143,9 +140,9 @@ fun createApiServer( Method.POST to ServerFilters.BearerAuth(token = options.adminSecretToken.value) .then(DashboardConfigEndpoint(services.dashboardConfigService)), + adminGuiRoutes, static(Classpath("/static")), webJars(), - adminRoutes, ) ) } @@ -164,4 +161,5 @@ data class ApiServices( val deleteDatabaseRowsService: DeleteDatabaseRowsService, val filteredStatusesService: FilteredStatusesService, val adminGuiService: AdminGuiService, + val cognitoAuthService: CognitoAuthService?, ) diff --git a/src/main/kotlin/no/liflig/cidashboard/App.kt b/src/main/kotlin/no/liflig/cidashboard/App.kt index fcbed5e0..1e279bdb 100644 --- a/src/main/kotlin/no/liflig/cidashboard/App.kt +++ b/src/main/kotlin/no/liflig/cidashboard/App.kt @@ -1,5 +1,6 @@ package no.liflig.cidashboard +import no.liflig.cidashboard.admin.auth.CognitoAuthService import no.liflig.cidashboard.admin.config.DashboardConfigService import no.liflig.cidashboard.admin.config.JdbiDashboardConfigTransaction import no.liflig.cidashboard.admin.database.DeleteDatabaseRowsService @@ -13,6 +14,7 @@ import no.liflig.cidashboard.dashboard.DashboardUpdatesService import no.liflig.cidashboard.dashboard.JdbiCiStatusDatabaseHandle import no.liflig.cidashboard.dashboard.JdbiDashboardConfigDatabaseHandle import no.liflig.cidashboard.health.HealthService +import no.liflig.cidashboard.persistence.CiStatus import no.liflig.cidashboard.status_api.FilteredStatusesService import no.liflig.cidashboard.webhook.IncomingWebhookService import no.liflig.cidashboard.webhook.JdbiCiStatusTransaction @@ -20,7 +22,7 @@ import no.liflig.logging.getLogger import org.jdbi.v3.core.Jdbi /** - * The main entry point for the application. Should be started from [Main]. + * The main entry point for the application. Should be started from [main]. * * @param config any options like ports and urls should be set here. Override values in the config * when doing testing of [App]. @@ -61,16 +63,22 @@ class App(val config: Config) { ) val dashboardConfigService = DashboardConfigService(JdbiDashboardConfigTransaction(jdbi)) - val ciStatusHandle = - JdbiCiStatusDatabaseHandle>(jdbi) - val configHandle = - JdbiDashboardConfigDatabaseHandle>(jdbi) + val ciStatusHandle = JdbiCiStatusDatabaseHandle>(jdbi) + val configHandle = JdbiDashboardConfigDatabaseHandle>(jdbi) val adminGuiService = AdminGuiService( ciStatusRepo = { ciStatusHandle { repo -> repo.getAll() } }, configRepo = { configHandle { repo -> repo.getAll() } }, ) + val cognitoAuthService = + config.cognitoConfig?.let { cognitoConfig -> + CognitoAuthService( + config = cognitoConfig, + httpClient = org.http4k.client.JavaHttpClient(), + ) + } + val services = ApiServices( healthService, @@ -80,6 +88,7 @@ class App(val config: Config) { DeleteDatabaseRowsService(JdbiCiStatusDatabaseHandle(jdbi)), FilteredStatusesService(JdbiCiStatusDatabaseHandle(jdbi)), adminGuiService, + cognitoAuthService, ) val server = diff --git a/src/main/kotlin/no/liflig/cidashboard/admin/auth/CognitoAuthService.kt b/src/main/kotlin/no/liflig/cidashboard/admin/auth/CognitoAuthService.kt index 0a419307..d4d8559e 100644 --- a/src/main/kotlin/no/liflig/cidashboard/admin/auth/CognitoAuthService.kt +++ b/src/main/kotlin/no/liflig/cidashboard/admin/auth/CognitoAuthService.kt @@ -13,16 +13,18 @@ import org.http4k.core.with import org.http4k.lens.RequestKey import org.http4k.lens.RequestLens import org.http4k.security.InsecureCookieBasedOAuthPersistence +import org.http4k.security.OAuthPersistence import org.http4k.security.OAuthProvider class CognitoAuthService( private val config: CognitoConfig, - private val callbackUri: Uri, - private val httpClient: HttpHandler, + httpClient: HttpHandler, ) { private val log = getLogger() private val jwtValidator = CognitoJwtValidator(config) - private val persistence = InsecureCookieBasedOAuthPersistence("cognito") + private val persistence: OAuthPersistence = InsecureCookieBasedOAuthPersistence("cognito") + + private val callbackUri: Uri = Uri.of("${config.appBaseUrl}/admin/oauth/callback") private val oAuthProvider: OAuthProvider = CognitoOAuthProvider.create( diff --git a/src/main/kotlin/no/liflig/cidashboard/admin/auth/CognitoOAuthProvider.kt b/src/main/kotlin/no/liflig/cidashboard/admin/auth/CognitoOAuthProvider.kt index 2432e005..305e216d 100644 --- a/src/main/kotlin/no/liflig/cidashboard/admin/auth/CognitoOAuthProvider.kt +++ b/src/main/kotlin/no/liflig/cidashboard/admin/auth/CognitoOAuthProvider.kt @@ -3,8 +3,9 @@ package no.liflig.cidashboard.admin.auth import no.liflig.cidashboard.common.config.CognitoConfig import no.liflig.logging.getLogger import org.http4k.core.Credentials +import org.http4k.core.HttpHandler import org.http4k.core.Uri -import org.http4k.security.InsecureCookieBasedOAuthPersistence +import org.http4k.security.OAuthPersistence import org.http4k.security.OAuthProvider import org.http4k.security.OAuthProviderConfig @@ -13,10 +14,9 @@ object CognitoOAuthProvider { fun create( config: CognitoConfig, - http: org.http4k.core.HttpHandler, + http: HttpHandler, callbackUri: Uri, - persistence: InsecureCookieBasedOAuthPersistence = - InsecureCookieBasedOAuthPersistence("cognito"), + persistence: OAuthPersistence, scopes: List = listOf("openid", "email", "profile"), ): OAuthProvider { log.info { @@ -30,7 +30,7 @@ object CognitoOAuthProvider { authBase = Uri.of(config.authBaseUrl), authPath = "/oauth2/authorize", tokenPath = "/oauth2/token", - credentials = Credentials(config.clientId, config.clientSecret ?: ""), + credentials = Credentials(config.clientId, config.clientSecret), ) return OAuthProvider( diff --git a/src/main/kotlin/no/liflig/cidashboard/common/config/CognitoConfig.kt b/src/main/kotlin/no/liflig/cidashboard/common/config/CognitoConfig.kt index 3ddb85fb..d5924ae4 100644 --- a/src/main/kotlin/no/liflig/cidashboard/common/config/CognitoConfig.kt +++ b/src/main/kotlin/no/liflig/cidashboard/common/config/CognitoConfig.kt @@ -1,12 +1,16 @@ package no.liflig.cidashboard.common.config import java.util.Properties -import no.liflig.properties.string +import no.liflig.logging.getLogger +import no.liflig.properties.booleanRequired +import no.liflig.properties.stringNotEmpty +import no.liflig.properties.stringNotNull +import software.amazon.awssdk.regions.Region data class CognitoConfig( val userPoolId: String, val clientId: String, - val clientSecret: String?, + val clientSecret: String, val domain: String, val region: String, val requiredGroup: String, @@ -34,37 +38,42 @@ data class CognitoConfig( get() = "$authBaseUrl/logout" companion object { + private val logger = getLogger() private const val DUMMY_VALUE = "not-configured" fun from(props: Properties): CognitoConfig? { - val bypassEnabled = props.string("cognito.bypassEnabled")?.toBoolean() ?: false + if (!props.booleanRequired("admin.gui.enabled")) { + return null + } - val userPoolId = - props.string("cognito.userPoolId")?.takeIf { it.isNotBlank() } - ?: if (bypassEnabled) DUMMY_VALUE else return null - val clientId = - props.string("cognito.clientId")?.takeIf { it.isNotBlank() } - ?: if (bypassEnabled) DUMMY_VALUE else return null - val domain = - props.string("cognito.domain")?.takeIf { it.isNotBlank() } - ?: if (bypassEnabled) DUMMY_VALUE else return null - val region = - props.string("cognito.region")?.takeIf { it.isNotBlank() } - ?: if (bypassEnabled) DUMMY_VALUE else return null - val appBaseUrl = - props.string("cognito.appBaseUrl")?.takeIf { it.isNotBlank() } - ?: if (bypassEnabled) DUMMY_VALUE else return null + val requiredGroup = props.stringNotEmpty("cognito.requiredGroup") + + val bypassEnabled = props.booleanRequired("cognito.bypassEnabled") + if (bypassEnabled) { + logger.warn { + "Cognito bypass enabled. Ensure this is not running in production: your admin gui is now unauthenticated." + } + return CognitoConfig( + userPoolId = DUMMY_VALUE, + clientId = DUMMY_VALUE, + clientSecret = DUMMY_VALUE, + domain = DUMMY_VALUE, + region = Region.EU_WEST_1.id(), + requiredGroup = requiredGroup, + bypassEnabled = true, + appBaseUrl = DUMMY_VALUE, + ) + } return CognitoConfig( - userPoolId = userPoolId, - clientId = clientId, - clientSecret = props.string("cognito.clientSecret")?.takeIf { it.isNotBlank() }, - domain = domain, - region = region, - requiredGroup = - props.string("cognito.requiredGroup")?.takeIf { it.isNotBlank() } ?: "liflig-active", - bypassEnabled = bypassEnabled, - appBaseUrl = appBaseUrl, + userPoolId = props.stringNotEmpty("cognito.userPoolId"), + clientId = props.stringNotEmpty("cognito.clientId"), + clientSecret = props.stringNotNull("cognito.clientSecret"), + domain = props.stringNotEmpty("cognito.domain"), + region = props.stringNotEmpty("cognito.region"), + requiredGroup = requiredGroup, + bypassEnabled = false, + appBaseUrl = props.stringNotEmpty("cognito.appBaseUrl"), ) } } diff --git a/src/main/resources-filtered/application.properties b/src/main/resources-filtered/application.properties index 2f5b96ed..dfdcff5d 100644 --- a/src/main/resources-filtered/application.properties +++ b/src/main/resources-filtered/application.properties @@ -37,11 +37,13 @@ dashboard.client.secretToken=change-me-to-something-secret admin.secretToken=very-very-secret-admin-token # Cognito (optional - required for Admin GUI) +admin.gui.enabled=true +## Only set true for local development +cognito.bypassEnabled=true +cognito.requiredGroup=liflig-active #cognito.userPoolId=eu-north-1_XXXXX #cognito.clientId=xxxxx -#cognito.clientSecret= +#cognito.clientSecret=xxxxxx #cognito.domain=my-app #cognito.region=eu-north-1 -#cognito.requiredGroup=liflig-active #cognito.appBaseUrl=https://your-app.example.com -cognito.bypassEnabled=true diff --git a/src/test/kotlin/no/liflig/cidashboard/admin/auth/CognitoAuthServiceTest.kt b/src/test/kotlin/no/liflig/cidashboard/admin/auth/CognitoAuthServiceTest.kt index 7e706be1..7ad27d11 100644 --- a/src/test/kotlin/no/liflig/cidashboard/admin/auth/CognitoAuthServiceTest.kt +++ b/src/test/kotlin/no/liflig/cidashboard/admin/auth/CognitoAuthServiceTest.kt @@ -6,7 +6,6 @@ import org.http4k.core.Method import org.http4k.core.Request import org.http4k.core.Response import org.http4k.core.Status -import org.http4k.core.Uri import org.junit.jupiter.api.Test class CognitoAuthServiceTest { @@ -15,7 +14,7 @@ class CognitoAuthServiceTest { CognitoConfig( userPoolId = "eu-north-1_test", clientId = "test-client", - clientSecret = null, + clientSecret = "", domain = "test", region = "eu-north-1", requiredGroup = "admin", @@ -23,8 +22,6 @@ class CognitoAuthServiceTest { appBaseUrl = "http://localhost:8080", ) - private val callbackUri = Uri.of("http://localhost:8080/admin/oauth/callback") - @Test fun `should redirect to oauth provider when no token present`() { val authService = createService(testConfig) @@ -90,7 +87,6 @@ class CognitoAuthServiceTest { private fun createService(config: CognitoConfig): CognitoAuthService { return CognitoAuthService( config = config, - callbackUri = callbackUri, httpClient = { Response(Status.OK) }, ) } diff --git a/src/test/kotlin/no/liflig/cidashboard/common/config/CognitoConfigTest.kt b/src/test/kotlin/no/liflig/cidashboard/common/config/CognitoConfigTest.kt index 3313ae92..0647b391 100644 --- a/src/test/kotlin/no/liflig/cidashboard/common/config/CognitoConfigTest.kt +++ b/src/test/kotlin/no/liflig/cidashboard/common/config/CognitoConfigTest.kt @@ -3,6 +3,7 @@ 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 { @@ -10,6 +11,7 @@ class CognitoConfigTest { 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") @@ -17,7 +19,7 @@ class CognitoConfigTest { setProperty("cognito.region", "eu-north-1") setProperty("cognito.requiredGroup", "admin-group") setProperty("cognito.appBaseUrl", "https://myapp.example.com") - setProperty("cognito.bypassEnabled", "true") + setProperty("cognito.bypassEnabled", "false") } val config = CognitoConfig.from(props) @@ -30,21 +32,30 @@ class CognitoConfigTest { assertThat(config.region).isEqualTo("eu-north-1") assertThat(config.requiredGroup).isEqualTo("admin-group") assertThat(config.appBaseUrl).isEqualTo("https://myapp.example.com") - assertThat(config.bypassEnabled).isTrue + assertThat(config.bypassEnabled).isFalse } @Test - fun `should return null when required properties are missing and bypass is disabled`() { - val props = Properties() - - val config = CognitoConfig.from(props) + 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") + } - assertThat(config).isNull() + assertThrows { + val config = CognitoConfig.from(props) + } } @Test fun `should create config with dummy values when bypass is enabled but properties missing`() { - val props = Properties().apply { setProperty("cognito.bypassEnabled", "true") } + val props = + Properties().apply { + setProperty("admin.gui.enabled", "true") + setProperty("cognito.bypassEnabled", "true") + setProperty("cognito.requiredGroup", "test") + } val config = CognitoConfig.from(props) @@ -52,37 +63,18 @@ class CognitoConfigTest { assertThat(config!!.userPoolId).isEqualTo("not-configured") assertThat(config.clientId).isEqualTo("not-configured") assertThat(config.domain).isEqualTo("not-configured") - assertThat(config.region).isEqualTo("not-configured") + assertThat(config.region).isEqualTo("eu-west-1") assertThat(config.appBaseUrl).isEqualTo("not-configured") assertThat(config.bypassEnabled).isTrue } - @Test - fun `should use default values for optional properties`() { - val props = - Properties().apply { - setProperty("cognito.userPoolId", "eu-north-1_abc123") - setProperty("cognito.clientId", "client-id-123") - setProperty("cognito.domain", "my-app") - setProperty("cognito.region", "eu-north-1") - setProperty("cognito.appBaseUrl", "https://myapp.example.com") - } - - val config = CognitoConfig.from(props) - - assertThat(config).isNotNull - assertThat(config!!.clientSecret).isNull() - assertThat(config.requiredGroup).isEqualTo("liflig-active") - assertThat(config.bypassEnabled).isFalse - } - @Test fun `should generate correct issuer url`() { val config = CognitoConfig( userPoolId = "eu-north-1_abc123", clientId = "client-id", - clientSecret = null, + clientSecret = "123", domain = "my-app", region = "eu-north-1", requiredGroup = "admin", @@ -100,7 +92,7 @@ class CognitoConfigTest { CognitoConfig( userPoolId = "eu-north-1_abc123", clientId = "client-id", - clientSecret = null, + clientSecret = "123", domain = "my-app", region = "eu-north-1", requiredGroup = "admin", @@ -120,7 +112,7 @@ class CognitoConfigTest { CognitoConfig( userPoolId = "eu-north-1_abc123", clientId = "client-id", - clientSecret = null, + clientSecret = "123", domain = "my-app", region = "eu-north-1", requiredGroup = "admin", From aff70b4be3a61db2a48accadca3702dee993ed41 Mon Sep 17 00:00:00 2001 From: Kristian Rekstad Date: Mon, 23 Feb 2026 15:06:00 +0100 Subject: [PATCH 06/13] Cleanup and refactor --- pom.xml | 5 ++- src/main/kotlin/no/liflig/cidashboard/Api.kt | 3 -- src/main/kotlin/no/liflig/cidashboard/App.kt | 2 +- .../admin/auth/CognitoAuthService.kt | 35 +++++++++++++------ .../admin/auth/CognitoJwtValidator.kt | 18 +++++----- .../admin/auth/CognitoOAuthProvider.kt | 19 +++++----- .../admin/gui/CiStatusDeleteEndpoint.kt | 11 ++++-- .../common/config/CognitoConfigTest.kt | 4 +-- 8 files changed, 60 insertions(+), 37 deletions(-) diff --git a/pom.xml b/pom.xml index 889c83aa..44967292 100644 --- a/pom.xml +++ b/pom.xml @@ -64,6 +64,7 @@ 3.51.0 12.0.1 0.7.4 + 10.0.2 6.0.3 @@ -232,6 +233,8 @@ org.http4k http4k-template-handlebars + + org.http4k http4k-security-oauth @@ -239,7 +242,7 @@ com.nimbusds nimbus-jose-jwt - 10.0.2 + ${nimbus-jose-jwt.version} diff --git a/src/main/kotlin/no/liflig/cidashboard/Api.kt b/src/main/kotlin/no/liflig/cidashboard/Api.kt index 14f70e23..249d1174 100644 --- a/src/main/kotlin/no/liflig/cidashboard/Api.kt +++ b/src/main/kotlin/no/liflig/cidashboard/Api.kt @@ -12,7 +12,6 @@ import no.liflig.cidashboard.admin.gui.CiStatusListEndpoint import no.liflig.cidashboard.admin.gui.ConfigListEndpoint import no.liflig.cidashboard.admin.gui.IntegrationGuideEndpoint import no.liflig.cidashboard.common.config.ApiOptions -import no.liflig.cidashboard.common.config.CognitoConfig import no.liflig.cidashboard.common.config.WebhookOptions import no.liflig.cidashboard.common.http4k.httpNoServerVersionHeader import no.liflig.cidashboard.dashboard.DashboardUpdatesEndpoint @@ -52,7 +51,6 @@ import org.http4k.server.asServer fun createApiServer( options: ApiOptions, webhookOptions: WebhookOptions, - cognitoConfig: CognitoConfig?, services: ApiServices, ): RoutingHttpHandler { val basicApiSetup = @@ -87,7 +85,6 @@ fun createApiServer( Method.DELETE to CiStatusDeleteEndpoint( services.deleteDatabaseRowsService, - options.hotReloadTemplates, ), "/integration" bind Method.GET to diff --git a/src/main/kotlin/no/liflig/cidashboard/App.kt b/src/main/kotlin/no/liflig/cidashboard/App.kt index 1e279bdb..8aefc13d 100644 --- a/src/main/kotlin/no/liflig/cidashboard/App.kt +++ b/src/main/kotlin/no/liflig/cidashboard/App.kt @@ -92,7 +92,7 @@ class App(val config: Config) { ) val server = - createApiServer(apiOptions, config.webhookOptions, config.cognitoConfig, services) + createApiServer(apiOptions, config.webhookOptions, services) .asJettyServer(config.apiOptions) server.start() runningTasks.add(server) diff --git a/src/main/kotlin/no/liflig/cidashboard/admin/auth/CognitoAuthService.kt b/src/main/kotlin/no/liflig/cidashboard/admin/auth/CognitoAuthService.kt index d4d8559e..c93367b4 100644 --- a/src/main/kotlin/no/liflig/cidashboard/admin/auth/CognitoAuthService.kt +++ b/src/main/kotlin/no/liflig/cidashboard/admin/auth/CognitoAuthService.kt @@ -1,5 +1,6 @@ package no.liflig.cidashboard.admin.auth +import java.net.URI import no.liflig.cidashboard.common.config.CognitoConfig import no.liflig.logging.getLogger import org.http4k.core.Filter @@ -17,27 +18,39 @@ import org.http4k.security.OAuthPersistence import org.http4k.security.OAuthProvider class CognitoAuthService( - private val config: CognitoConfig, + config: CognitoConfig, httpClient: HttpHandler, ) { private val log = getLogger() - private val jwtValidator = CognitoJwtValidator(config) + + private val bypassEnabled: Boolean = config.bypassEnabled + private val requiredGroup: String = config.requiredGroup + + private val jwtValidator = + CognitoJwtValidator( + jwksUrl = URI(config.jwksUrl).toURL(), + clientId = config.clientId, + issuerUrl = config.issuerUrl, + ) private val persistence: OAuthPersistence = InsecureCookieBasedOAuthPersistence("cognito") private val callbackUri: Uri = Uri.of("${config.appBaseUrl}/admin/oauth/callback") - private val oAuthProvider: OAuthProvider = CognitoOAuthProvider.create( - config = config, - http = httpClient, + domain = config.domain, + region = config.region, + authBaseUrl = config.authBaseUrl, + clientId = config.clientId, + clientSecret = config.clientSecret, + scopes = listOf("openid", "email", "profile"), callbackUri = callbackUri, persistence = persistence, - scopes = listOf("openid", "email", "profile"), + http = httpClient, ) fun authFilter(): Filter = Filter { next -> { request -> - if (config.bypassEnabled) { + if (bypassEnabled) { log.debug { "Cognito auth bypass enabled - skipping authentication" } return@Filter next(request.setUser(bypassUser())) } @@ -56,15 +69,15 @@ class CognitoAuthService( return@Filter oAuthProvider.authFilter.then(next)(request) } - if (!user.groups.contains(config.requiredGroup)) { + if (!user.groups.contains(requiredGroup)) { log.warn { field("user.username", user.username) field("user.groups", user.groups) - field("required.group", config.requiredGroup) + field("required.group", requiredGroup) "User does not have required group" } return@Filter Response(Status.FORBIDDEN) - .body("Access denied. Required group: ${config.requiredGroup}") + .body("Access denied. Required group: $requiredGroup") } next(request.setUser(user)) @@ -77,7 +90,7 @@ class CognitoAuthService( CognitoUser( username = "bypass-user", email = "bypass@localhost", - groups = listOf(config.requiredGroup), + groups = listOf(requiredGroup), ) companion object { diff --git a/src/main/kotlin/no/liflig/cidashboard/admin/auth/CognitoJwtValidator.kt b/src/main/kotlin/no/liflig/cidashboard/admin/auth/CognitoJwtValidator.kt index 0f88ba3e..9adbcd86 100644 --- a/src/main/kotlin/no/liflig/cidashboard/admin/auth/CognitoJwtValidator.kt +++ b/src/main/kotlin/no/liflig/cidashboard/admin/auth/CognitoJwtValidator.kt @@ -8,7 +8,6 @@ import com.nimbusds.jwt.JWTClaimsSet import com.nimbusds.jwt.proc.ConfigurableJWTProcessor import com.nimbusds.jwt.proc.DefaultJWTProcessor import java.net.URL -import no.liflig.cidashboard.common.config.CognitoConfig import no.liflig.logging.getLogger data class CognitoUser( @@ -17,13 +16,16 @@ data class CognitoUser( val groups: List, ) -class CognitoJwtValidator(private val config: CognitoConfig) { +class CognitoJwtValidator( + private val jwksUrl: URL, + private val clientId: String, + private val issuerUrl: String, +) { private val log = getLogger() private val jwtProcessor: ConfigurableJWTProcessor = DefaultJWTProcessor().apply { - val jwkSetUrl = URL(config.jwksUrl) - val jwkSource = JWKSourceBuilder.create(jwkSetUrl).build() + val jwkSource = JWKSourceBuilder.create(jwksUrl).build() val keySelector = JWSVerificationKeySelector(JWSAlgorithm.RS256, jwkSource) setJWSKeySelector(keySelector) } @@ -47,9 +49,9 @@ class CognitoJwtValidator(private val config: CognitoConfig) { private fun validateClaims(claims: JWTClaimsSet): CognitoUser? { val issuer = claims.issuer - if (issuer != config.issuerUrl) { + if (issuer != issuerUrl) { log.warn { - field("expected.issuer", config.issuerUrl) + field("expected.issuer", issuerUrl) field("actual.issuer", issuer) "JWT issuer mismatch" } @@ -57,9 +59,9 @@ class CognitoJwtValidator(private val config: CognitoConfig) { } val audience = claims.audience - if (!audience.contains(config.clientId)) { + if (!audience.contains(clientId)) { log.warn { - field("expected.audience", config.clientId) + field("expected.audience", clientId) field("actual.audience", audience) "JWT audience mismatch" } diff --git a/src/main/kotlin/no/liflig/cidashboard/admin/auth/CognitoOAuthProvider.kt b/src/main/kotlin/no/liflig/cidashboard/admin/auth/CognitoOAuthProvider.kt index 305e216d..4daf56f7 100644 --- a/src/main/kotlin/no/liflig/cidashboard/admin/auth/CognitoOAuthProvider.kt +++ b/src/main/kotlin/no/liflig/cidashboard/admin/auth/CognitoOAuthProvider.kt @@ -1,6 +1,5 @@ package no.liflig.cidashboard.admin.auth -import no.liflig.cidashboard.common.config.CognitoConfig import no.liflig.logging.getLogger import org.http4k.core.Credentials import org.http4k.core.HttpHandler @@ -13,24 +12,28 @@ object CognitoOAuthProvider { private val log = getLogger() fun create( - config: CognitoConfig, - http: HttpHandler, + domain: String, + region: String, + authBaseUrl: String, + clientId: String, + clientSecret: String, + scopes: List, callbackUri: Uri, persistence: OAuthPersistence, - scopes: List = listOf("openid", "email", "profile"), + http: HttpHandler, ): OAuthProvider { log.info { - field("cognito.domain", config.domain) - field("cognito.region", config.region) + field("cognito.domain", domain) + field("cognito.region", region) "Creating Cognito OAuth provider" } val providerConfig = OAuthProviderConfig( - authBase = Uri.of(config.authBaseUrl), + authBase = Uri.of(authBaseUrl), authPath = "/oauth2/authorize", tokenPath = "/oauth2/token", - credentials = Credentials(config.clientId, config.clientSecret), + credentials = Credentials(clientId, clientSecret), ) return OAuthProvider( diff --git a/src/main/kotlin/no/liflig/cidashboard/admin/gui/CiStatusDeleteEndpoint.kt b/src/main/kotlin/no/liflig/cidashboard/admin/gui/CiStatusDeleteEndpoint.kt index eee2880a..235a56f9 100644 --- a/src/main/kotlin/no/liflig/cidashboard/admin/gui/CiStatusDeleteEndpoint.kt +++ b/src/main/kotlin/no/liflig/cidashboard/admin/gui/CiStatusDeleteEndpoint.kt @@ -3,6 +3,7 @@ package no.liflig.cidashboard.admin.gui import no.liflig.cidashboard.admin.auth.CognitoAuthService import no.liflig.cidashboard.admin.database.DeleteDatabaseRowsService import no.liflig.cidashboard.persistence.CiStatusId +import no.liflig.logging.getLogger import org.http4k.core.HttpHandler import org.http4k.core.Request import org.http4k.core.Response @@ -11,17 +12,23 @@ import org.http4k.lens.Path class CiStatusDeleteEndpoint( private val deleteService: DeleteDatabaseRowsService, - useHotReload: Boolean, ) : HttpHandler { + private val logger = getLogger() private val idLens = Path.of("id") override fun invoke(request: Request): Response { - CognitoAuthService.requireCognitoUser(request) + val user = CognitoAuthService.requireCognitoUser(request) val id = CiStatusId(idLens(request)) deleteService.deleteById(id) + logger.info { + field("user", user.username) + field("repo.id", id) + "Repo deleted: $id" + } + return Response(Status.OK) } } diff --git a/src/test/kotlin/no/liflig/cidashboard/common/config/CognitoConfigTest.kt b/src/test/kotlin/no/liflig/cidashboard/common/config/CognitoConfigTest.kt index 0647b391..d66bdff6 100644 --- a/src/test/kotlin/no/liflig/cidashboard/common/config/CognitoConfigTest.kt +++ b/src/test/kotlin/no/liflig/cidashboard/common/config/CognitoConfigTest.kt @@ -43,9 +43,7 @@ class CognitoConfigTest { setProperty("cognito.bypassEnabled", "false") } - assertThrows { - val config = CognitoConfig.from(props) - } + assertThrows { CognitoConfig.from(props) } } @Test From 16c8d5570cbbe0e1f7edaa027c691cfc793ad993 Mon Sep 17 00:00:00 2001 From: Kristian Rekstad Date: Mon, 23 Feb 2026 15:11:48 +0100 Subject: [PATCH 07/13] Refactor --- src/main/kotlin/no/liflig/cidashboard/Api.kt | 93 +++++++++++--------- 1 file changed, 51 insertions(+), 42 deletions(-) diff --git a/src/main/kotlin/no/liflig/cidashboard/Api.kt b/src/main/kotlin/no/liflig/cidashboard/Api.kt index 249d1174..b44f5270 100644 --- a/src/main/kotlin/no/liflig/cidashboard/Api.kt +++ b/src/main/kotlin/no/liflig/cidashboard/Api.kt @@ -29,6 +29,7 @@ import no.liflig.http4k.setup.logging.LoggingFilter import org.http4k.contract.openapi.v3.ApiServer import org.http4k.core.Method import org.http4k.core.Response +import org.http4k.core.Status import org.http4k.core.then import org.http4k.filter.ServerFilters import org.http4k.routing.ResourceLoader.Companion.Classpath @@ -66,48 +67,7 @@ fun createApiServer( val indexEndpoint = IndexEndpoint(options.clientSecretToken, options.hotReloadTemplates, options.updatesPollRate) - val adminGuiRoutes: RoutingHttpHandler = - if (services.cognitoAuthService != null) { - routes( - "/admin/oauth/callback" bind - Method.GET to - services.cognitoAuthService.callbackHandler(), - "/admin" bind - routes( - "/" bind Method.GET to AdminIndexEndpoint(), - "/ci-statuses" bind - Method.GET to - CiStatusListEndpoint( - services.adminGuiService, - options.hotReloadTemplates, - ), - "/ci-statuses/{id}" bind - Method.DELETE to - CiStatusDeleteEndpoint( - services.deleteDatabaseRowsService, - ), - "/integration" bind - Method.GET to - IntegrationGuideEndpoint( - webhookOptions.secret, - options.hotReloadTemplates, - ), - "/configs" bind - Method.GET to - ConfigListEndpoint( - services.adminGuiService, - options.hotReloadTemplates, - ), - ) - .withFilter(services.cognitoAuthService.authFilter()), - ) - } else { - "/admin" bind - Method.GET to - { - Response(org.http4k.core.Status.NOT_FOUND).body("Admin GUI not configured") - } - } + val adminGuiRoutes: RoutingHttpHandler = createAdminGuiRoutes(services, options, webhookOptions) return coreFilters.then( routes( @@ -144,6 +104,55 @@ fun createApiServer( ) } +/** + * If admin gui is disabled or cognito is not configured, the `/admin` route returns 404. + */ +private fun createAdminGuiRoutes( + services: ApiServices, + options: ApiOptions, + webhookOptions: WebhookOptions +): RoutingHttpHandler = if (services.cognitoAuthService != null) { + routes( + "/admin/oauth/callback" bind + Method.GET to + services.cognitoAuthService.callbackHandler(), + "/admin" bind + routes( + "/" bind Method.GET to AdminIndexEndpoint(), + "/ci-statuses" bind + Method.GET to + CiStatusListEndpoint( + services.adminGuiService, + options.hotReloadTemplates, + ), + "/ci-statuses/{id}" bind + Method.DELETE to + CiStatusDeleteEndpoint( + services.deleteDatabaseRowsService, + ), + "/integration" bind + Method.GET to + IntegrationGuideEndpoint( + webhookOptions.secret, + options.hotReloadTemplates, + ), + "/configs" bind + Method.GET to + ConfigListEndpoint( + services.adminGuiService, + options.hotReloadTemplates, + ), + ) + .withFilter(services.cognitoAuthService.authFilter()), + ) +} else { + "/admin" bind + Method.GET to + { + Response(Status.NOT_FOUND).body("Admin GUI not configured") + } +} + fun RoutingHttpHandler.asJettyServer(options: ApiOptions): Http4kServer = this.asServer( Jetty(options.serverPort.value, httpNoServerVersionHeader(options.serverPort.value)) From 2cd86481a12e3dac2f1284e26b797e62a49ec456 Mon Sep 17 00:00:00 2001 From: Kristian Rekstad Date: Mon, 23 Feb 2026 15:13:13 +0100 Subject: [PATCH 08/13] Update ktfmt and wiremock --- pom.xml | 5 +- src/main/kotlin/no/liflig/cidashboard/Api.kt | 87 +++++++++----------- 2 files changed, 43 insertions(+), 49 deletions(-) diff --git a/pom.xml b/pom.xml index 44967292..940c14ba 100644 --- a/pom.xml +++ b/pom.xml @@ -73,9 +73,10 @@ 6.0.0 1.21.4 1.58.0 + 3.13.2 - 0.58 + 0.61 0.8.14 3.5.4 3.6.1 @@ -348,7 +349,7 @@ org.wiremock wiremock-standalone - 3.10.0 + ${wiremock-standalone.version} test diff --git a/src/main/kotlin/no/liflig/cidashboard/Api.kt b/src/main/kotlin/no/liflig/cidashboard/Api.kt index b44f5270..cf3217d4 100644 --- a/src/main/kotlin/no/liflig/cidashboard/Api.kt +++ b/src/main/kotlin/no/liflig/cidashboard/Api.kt @@ -104,54 +104,47 @@ fun createApiServer( ) } -/** - * If admin gui is disabled or cognito is not configured, the `/admin` route returns 404. - */ +/** If admin gui is disabled or cognito is not configured, the `/admin` route returns 404. */ private fun createAdminGuiRoutes( - services: ApiServices, - options: ApiOptions, - webhookOptions: WebhookOptions -): RoutingHttpHandler = if (services.cognitoAuthService != null) { - routes( - "/admin/oauth/callback" bind - Method.GET to - services.cognitoAuthService.callbackHandler(), - "/admin" bind - routes( - "/" bind Method.GET to AdminIndexEndpoint(), - "/ci-statuses" bind - Method.GET to - CiStatusListEndpoint( - services.adminGuiService, - options.hotReloadTemplates, - ), - "/ci-statuses/{id}" bind - Method.DELETE to - CiStatusDeleteEndpoint( - services.deleteDatabaseRowsService, - ), - "/integration" bind - Method.GET to - IntegrationGuideEndpoint( - webhookOptions.secret, - options.hotReloadTemplates, - ), - "/configs" bind - Method.GET to - ConfigListEndpoint( - services.adminGuiService, - options.hotReloadTemplates, - ), - ) - .withFilter(services.cognitoAuthService.authFilter()), - ) -} else { - "/admin" bind - Method.GET to - { - Response(Status.NOT_FOUND).body("Admin GUI not configured") - } -} + services: ApiServices, + options: ApiOptions, + webhookOptions: WebhookOptions, +): RoutingHttpHandler = + if (services.cognitoAuthService != null) { + routes( + "/admin/oauth/callback" bind Method.GET to services.cognitoAuthService.callbackHandler(), + "/admin" bind + routes( + "/" bind Method.GET to AdminIndexEndpoint(), + "/ci-statuses" bind + Method.GET to + CiStatusListEndpoint( + services.adminGuiService, + options.hotReloadTemplates, + ), + "/ci-statuses/{id}" bind + Method.DELETE to + CiStatusDeleteEndpoint( + services.deleteDatabaseRowsService, + ), + "/integration" bind + Method.GET to + IntegrationGuideEndpoint( + webhookOptions.secret, + options.hotReloadTemplates, + ), + "/configs" bind + Method.GET to + ConfigListEndpoint( + services.adminGuiService, + options.hotReloadTemplates, + ), + ) + .withFilter(services.cognitoAuthService.authFilter()), + ) + } else { + "/admin" bind Method.GET to { Response(Status.NOT_FOUND).body("Admin GUI not configured") } + } fun RoutingHttpHandler.asJettyServer(options: ApiOptions): Http4kServer = this.asServer( From d5bddea6f5716b645ff906dcf2303362b2319563 Mon Sep 17 00:00:00 2001 From: Kristian Rekstad Date: Mon, 23 Feb 2026 15:20:42 +0100 Subject: [PATCH 09/13] Cleanup --- src/test/kotlin/test/util/CognitoWireMock.kt | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/src/test/kotlin/test/util/CognitoWireMock.kt b/src/test/kotlin/test/util/CognitoWireMock.kt index 764a20ef..9995f36f 100644 --- a/src/test/kotlin/test/util/CognitoWireMock.kt +++ b/src/test/kotlin/test/util/CognitoWireMock.kt @@ -25,8 +25,7 @@ class CognitoWireMock(private val wireMock: WireMockServer) { private val signer: JWSSigner = RSASSASigner(rsaKey) - val jwksJson: String - get() = JWKSet(listOf(rsaKey.toPublicJWK())).toString() + val jwksJson: String = JWKSet(listOf(rsaKey.toPublicJWK())).toString() val issuer: String get() = "${wireMock.baseUrl()}/cognito-idp/eu-north-1_test" @@ -37,17 +36,13 @@ class CognitoWireMock(private val wireMock: WireMockServer) { val domain: String get() = wireMock.baseUrl().removePrefix("http://").removePrefix("https://") - val region: String - get() = "eu-north-1" + val region: String = "eu-north-1" - val userPoolId: String - get() = "eu-north-1_test" + val userPoolId: String = "eu-north-1_test" - val clientId: String - get() = "test-client-id" + val clientId: String = "test-client-id" - val clientSecret: String - get() = "test-client-secret" + val clientSecret: String = "test-client-secret" fun setupStubs() { wireMock.stubFor( From fbeb0422fda4e8923b0b9b3eefd844affa7e8e8d Mon Sep 17 00:00:00 2001 From: Kristian Rekstad Date: Mon, 23 Feb 2026 15:57:22 +0100 Subject: [PATCH 10/13] Cleanup --- src/main/kotlin/no/liflig/cidashboard/App.kt | 7 +--- .../cidashboard/admin/gui/AdminGuiService.kt | 42 ++++++++++--------- .../admin/gui/AdminGuiServiceTest.kt | 20 ++++++++- 3 files changed, 43 insertions(+), 26 deletions(-) diff --git a/src/main/kotlin/no/liflig/cidashboard/App.kt b/src/main/kotlin/no/liflig/cidashboard/App.kt index 8aefc13d..d6cb1c8f 100644 --- a/src/main/kotlin/no/liflig/cidashboard/App.kt +++ b/src/main/kotlin/no/liflig/cidashboard/App.kt @@ -14,7 +14,6 @@ import no.liflig.cidashboard.dashboard.DashboardUpdatesService import no.liflig.cidashboard.dashboard.JdbiCiStatusDatabaseHandle import no.liflig.cidashboard.dashboard.JdbiDashboardConfigDatabaseHandle import no.liflig.cidashboard.health.HealthService -import no.liflig.cidashboard.persistence.CiStatus import no.liflig.cidashboard.status_api.FilteredStatusesService import no.liflig.cidashboard.webhook.IncomingWebhookService import no.liflig.cidashboard.webhook.JdbiCiStatusTransaction @@ -63,12 +62,10 @@ class App(val config: Config) { ) val dashboardConfigService = DashboardConfigService(JdbiDashboardConfigTransaction(jdbi)) - val ciStatusHandle = JdbiCiStatusDatabaseHandle>(jdbi) - val configHandle = JdbiDashboardConfigDatabaseHandle>(jdbi) val adminGuiService = AdminGuiService( - ciStatusRepo = { ciStatusHandle { repo -> repo.getAll() } }, - configRepo = { configHandle { repo -> repo.getAll() } }, + ciStatusRepo = JdbiCiStatusDatabaseHandle(jdbi), + configRepo = JdbiDashboardConfigDatabaseHandle(jdbi), ) val cognitoAuthService = diff --git a/src/main/kotlin/no/liflig/cidashboard/admin/gui/AdminGuiService.kt b/src/main/kotlin/no/liflig/cidashboard/admin/gui/AdminGuiService.kt index 4f5931ea..d1096b0f 100644 --- a/src/main/kotlin/no/liflig/cidashboard/admin/gui/AdminGuiService.kt +++ b/src/main/kotlin/no/liflig/cidashboard/admin/gui/AdminGuiService.kt @@ -2,36 +2,40 @@ package no.liflig.cidashboard.admin.gui import java.time.ZoneId import java.time.format.DateTimeFormatter -import no.liflig.cidashboard.DashboardConfig -import no.liflig.cidashboard.persistence.CiStatus +import no.liflig.cidashboard.dashboard.UseCiStatusRepo +import no.liflig.cidashboard.dashboard.UseDashboardConfigRepo class AdminGuiService( - private val ciStatusRepo: () -> List, - private val configRepo: () -> List, + private val ciStatusRepo: UseCiStatusRepo>, + private val configRepo: UseDashboardConfigRepo>, ) { private val dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm").withZone(ZoneId.systemDefault()) fun getCiStatuses(): List { - return ciStatusRepo().map { status -> - CiStatusRow( - id = status.id.value, - repoOwner = status.repo.owner.value, - repoName = status.repo.name.value, - branch = status.branch.value, - status = status.lastStatus.name, - lastUpdated = dateFormatter.format(status.lastUpdatedAt), - ) + return ciStatusRepo { repo -> + repo.getAll().map { status -> + CiStatusRow( + id = status.id.value, + repoOwner = status.repo.owner.value, + repoName = status.repo.name.value, + branch = status.branch.value, + status = status.lastStatus.name, + lastUpdated = dateFormatter.format(status.lastUpdatedAt), + ) + } } } fun getConfigs(): List { - return configRepo().map { config -> - ConfigRow( - id = config.id.value, - displayName = config.displayName, - orgMatchers = config.orgMatchers.joinToString(", ") { it.matcher.pattern }, - ) + return configRepo { repo -> + repo.getAll().map { config -> + ConfigRow( + id = config.id.value, + displayName = config.displayName, + orgMatchers = config.orgMatchers.joinToString(", ") { it.matcher.pattern }, + ) + } } } } diff --git a/src/test/kotlin/no/liflig/cidashboard/admin/gui/AdminGuiServiceTest.kt b/src/test/kotlin/no/liflig/cidashboard/admin/gui/AdminGuiServiceTest.kt index a092643c..d46ccf16 100644 --- a/src/test/kotlin/no/liflig/cidashboard/admin/gui/AdminGuiServiceTest.kt +++ b/src/test/kotlin/no/liflig/cidashboard/admin/gui/AdminGuiServiceTest.kt @@ -1,5 +1,7 @@ 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 @@ -7,7 +9,9 @@ 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 @@ -73,8 +77,14 @@ class AdminGuiServiceTest { triggeredBy = Username("testuser"), ), ) + val ciStatusRepo = mockk { every { getAll() } returns statuses } + val dashboardConfigRepo = mockk { every { getAll() } returns emptyList() } - val service = AdminGuiService(ciStatusRepo = { statuses }, configRepo = { emptyList() }) + val service = + AdminGuiService( + ciStatusRepo = { callback -> callback(ciStatusRepo) }, + configRepo = { callback -> callback(dashboardConfigRepo) }, + ) val rows = service.getCiStatuses() assertThat(rows).hasSize(2) @@ -101,8 +111,14 @@ class AdminGuiServiceTest { ), ), ) + val ciStatusRepo = mockk { every { getAll() } returns emptyList() } + val dashboardConfigRepo = mockk { every { getAll() } returns configs } - val service = AdminGuiService(ciStatusRepo = { emptyList() }, configRepo = { configs }) + val service = + AdminGuiService( + ciStatusRepo = { callback -> callback(ciStatusRepo) }, + configRepo = { callback -> callback(dashboardConfigRepo) }, + ) val rows = service.getConfigs() assertThat(rows).hasSize(1) From 6e358bfd36d46f28c0254dcf308e88bd63a5c3a4 Mon Sep 17 00:00:00 2001 From: Kristian Rekstad Date: Mon, 23 Feb 2026 15:58:31 +0100 Subject: [PATCH 11/13] Cleanup --- src/main/kotlin/no/liflig/cidashboard/App.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/no/liflig/cidashboard/App.kt b/src/main/kotlin/no/liflig/cidashboard/App.kt index d6cb1c8f..0d9f8573 100644 --- a/src/main/kotlin/no/liflig/cidashboard/App.kt +++ b/src/main/kotlin/no/liflig/cidashboard/App.kt @@ -18,6 +18,7 @@ import no.liflig.cidashboard.status_api.FilteredStatusesService import no.liflig.cidashboard.webhook.IncomingWebhookService import no.liflig.cidashboard.webhook.JdbiCiStatusTransaction import no.liflig.logging.getLogger +import org.http4k.client.JavaHttpClient import org.jdbi.v3.core.Jdbi /** @@ -72,7 +73,7 @@ class App(val config: Config) { config.cognitoConfig?.let { cognitoConfig -> CognitoAuthService( config = cognitoConfig, - httpClient = org.http4k.client.JavaHttpClient(), + httpClient = JavaHttpClient(), ) } From eda52acf7bb741b5e13b57f7e382238fecd9c107 Mon Sep 17 00:00:00 2001 From: Kristian Rekstad Date: Mon, 23 Feb 2026 16:15:42 +0100 Subject: [PATCH 12/13] Cleanup --- .../liflig/cidashboard/admin/auth/CognitoAuthServiceTest.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/test/kotlin/no/liflig/cidashboard/admin/auth/CognitoAuthServiceTest.kt b/src/test/kotlin/no/liflig/cidashboard/admin/auth/CognitoAuthServiceTest.kt index 7ad27d11..575c4b55 100644 --- a/src/test/kotlin/no/liflig/cidashboard/admin/auth/CognitoAuthServiceTest.kt +++ b/src/test/kotlin/no/liflig/cidashboard/admin/auth/CognitoAuthServiceTest.kt @@ -6,7 +6,9 @@ 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 { @@ -70,9 +72,7 @@ class CognitoAuthServiceTest { val request = Request(Method.GET, "/test") val exception = - org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException::class.java) { - CognitoAuthService.requireCognitoUser(request) - } + assertThrows { CognitoAuthService.requireCognitoUser(request) } assertThat(exception.message).contains("No Cognito user in request context") } From a081d9b075ca3998e171505e1318a97484dc1a41 Mon Sep 17 00:00:00 2001 From: Kristian Rekstad Date: Mon, 23 Feb 2026 16:32:10 +0100 Subject: [PATCH 13/13] Make admin gui opt-in --- src/main/resources-filtered/application.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/resources-filtered/application.properties b/src/main/resources-filtered/application.properties index dfdcff5d..c59b58c2 100644 --- a/src/main/resources-filtered/application.properties +++ b/src/main/resources-filtered/application.properties @@ -37,9 +37,9 @@ dashboard.client.secretToken=change-me-to-something-secret admin.secretToken=very-very-secret-admin-token # Cognito (optional - required for Admin GUI) -admin.gui.enabled=true +admin.gui.enabled=false ## Only set true for local development -cognito.bypassEnabled=true +cognito.bypassEnabled=false cognito.requiredGroup=liflig-active #cognito.userPoolId=eu-north-1_XXXXX #cognito.clientId=xxxxx