diff --git a/.bruno/CG-DMF/collection.bru b/.bruno/CG-DMF/collection.bru index fb0f7061..7efc9788 100644 --- a/.bruno/CG-DMF/collection.bru +++ b/.bruno/CG-DMF/collection.bru @@ -59,6 +59,7 @@ script:pre-request { client_id: clientId, user_id: "bruno-gzac@example.com", user_representation: "Bruno Client", + scope: "documenten.aanmaken documenten.lezen documenten.bijwerken documenten.verwijderen documenten.lock documenten.geforceerd-unlock audittrails.lezen" }; const token = jwt.sign(payload, clientSecret, { algorithm: "HS256" }); diff --git a/docker-compose.yml b/docker-compose.yml index 7ffe2cee..78008997 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -88,7 +88,7 @@ services: - '10000:10000' - '10001:10001' - '10002:10002' - command: 'azurite --blobHost 0.0.0.0 --queueHost 0.0.0.0 --tableHost 0.0.0.0' + command: ' azurite --skipApiVersionCheck --blobHost 0.0.0.0 --queueHost 0.0.0.0 --tableHost 0.0.0.0' notificaties-api: image: openzaak/open-notificaties:latest diff --git a/src/main/kotlin/api/documenten/routes/AuditTrailRoutes.kt b/src/main/kotlin/api/documenten/routes/AuditTrailRoutes.kt index 40ef03d3..2628e6d0 100644 --- a/src/main/kotlin/api/documenten/routes/AuditTrailRoutes.kt +++ b/src/main/kotlin/api/documenten/routes/AuditTrailRoutes.kt @@ -4,6 +4,7 @@ package com.baseflow.api.documenten.routes import com.baseflow.api.middleware.RequestScopeKey import com.baseflow.api.models.AuditTrailResponse +import com.baseflow.api.routes.checkScope import com.baseflow.services.AuditTrailService import io.ktor.http.* import io.ktor.openapi.jsonSchema @@ -13,11 +14,9 @@ import io.ktor.server.routing.openapi.* import io.ktor.utils.io.* import java.util.* -private val RoutingContext.service: AuditTrailService - get() = call.attributes[RequestScopeKey].get() - @OptIn(ExperimentalKtorApi::class) fun Route.auditTrailRoutes() { + // Require "audittrails.lezen" scope for all audit trail routes route("/{uuid}/audittrail/{auditTrailUuid}") { /** * Een specifieke audit trail regel opvragen. @@ -38,10 +37,10 @@ fun Route.auditTrailRoutes() { * @tag AuditTrail */ get { - val resourceUuid = - call.parameters["uuid"] - ?.let { UUID.fromString(it) } - ?: return@get call.respond(HttpStatusCode.BadRequest, "Invalid resource UUID") + call.checkScope("audittrails.lezen") + val resourceUuid = call.parameters["uuid"] + ?.let { UUID.fromString(it) } + ?: return@get call.respond(HttpStatusCode.BadRequest, "Invalid resource UUID") val auditTrailUuid = call.parameters["auditTrailUuid"] @@ -93,10 +92,10 @@ fun Route.auditTrailRoutes() { * @tag AuditTrail */ get { - val resourceUuid = - call.parameters["uuid"] - ?.let { UUID.fromString(it) } - ?: return@get call.respond(HttpStatusCode.BadRequest, "Invalid UUID") + call.checkScope("audittrails.lezen") + val resourceUuid = call.parameters["uuid"] + ?.let { UUID.fromString(it) } + ?: return@get call.respond(HttpStatusCode.BadRequest, "Invalid UUID") val auditTrails = service.listByResource(resourceUuid) call.respond(auditTrails) @@ -120,3 +119,6 @@ fun Route.auditTrailRoutes() { } } } + +private val RoutingContext.service: AuditTrailService + get() = call.attributes[RequestScopeKey].get() diff --git a/src/main/kotlin/api/documenten/routes/CheckScope.kt b/src/main/kotlin/api/documenten/routes/CheckScope.kt new file mode 100644 index 00000000..841b8d83 --- /dev/null +++ b/src/main/kotlin/api/documenten/routes/CheckScope.kt @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: EUPL-1.2 +// Copyright (C) 2026 Gemeente Utrecht +package com.baseflow.api.routes + +import io.ktor.server.application.* +import io.ktor.server.auth.* +import io.ktor.server.auth.jwt.* +import org.slf4j.LoggerFactory + +private val logger = LoggerFactory.getLogger("ScopeAuthorizationPlugin") + +/** + * Configuration for scope authorization. + */ +class ScopeAuthorizationConfig { + /** + * The name of the JWT claim containing scopes. Default is "scope" (OAuth2 standard). + */ + var scopeClaimName: String = "scope" + + /** + * Whether to enable wildcard scope matching (e.g., "documenten.*" matches "documenten.lezen"). + * Default is true. + */ + var wildcardEnabled: Boolean = true +} + +fun ApplicationCall.checkScope(vararg requiredScopes: String) { + checkScope(requiredScopes.toSet()) +} + +fun ApplicationCall.checkScope(requiredScopes: Set, config: ScopeAuthorizationConfig = ScopeAuthorizationConfig()) { + if (requiredScopes.isEmpty()) { + return + } + + val principal = principal() + + // If no principal, treat as unauthorized when scopes are required + if (principal == null) { + logger.warn("No JWT principal found while checking required scopes {}, denying access", requiredScopes) + throw ScopeAuthorizationException( + requiredScopes = requiredScopes, + ) + } + + // Extract scopes from JWT token + val userScopes = extractScopes(principal, config) + logger.debug("User scopes: {}", userScopes) + logger.debug("Route requires scopes: {}", requiredScopes) + + // Check if user has all required scopes + val hasAllScopes = requiredScopes.all { required -> + userScopes.any { userScope -> + matchScope(userScope, required, config.wildcardEnabled) + } + } + + if (!hasAllScopes) { + val missingScopes = requiredScopes.filter { required -> + !userScopes.any { userScope -> + matchScope(userScope, required, config.wildcardEnabled) + } + } + logger.warn( + "Access denied. User missing scopes: {}. User has: {}", + missingScopes, + userScopes, + ) + throw ScopeAuthorizationException( + requiredScopes = requiredScopes, + ) + } else { + logger.debug("Scope check passed") + } +} + +/** + * Extract scopes from JWT token. + * Supports both "scope" (space-separated string) and "scopes" (array) claims. + */ +private fun extractScopes(principal: JWTPrincipal, config: ScopeAuthorizationConfig): Set { + val scopes = mutableSetOf() + + // Try "scope" claim (space-separated string, OAuth2 standard) + principal.payload.getClaim(config.scopeClaimName)?.let { claim -> + if (!claim.isNull) { + when { + claim.asString() != null -> { + scopes.addAll(claim.asString().split(" ").filter { it.isNotBlank() }) + } + claim.asList(String::class.java) != null -> { + scopes.addAll(claim.asList(String::class.java)) + } + } + } + } + + // Try alternative "scopes" claim (array) + if (config.scopeClaimName != "scopes") { + principal.payload.getClaim("scopes")?.let { claim -> + if (!claim.isNull) { + claim.asList(String::class.java)?.let { scopes.addAll(it) } + } + } + } + + return scopes +} + +/** + * Match a user scope against a required scope. + * Supports wildcard matching if enabled (e.g., "documenten.*" matches "documenten.lezen"). + */ +private fun matchScope(userScope: String, requiredScope: String, wildcardEnabled: Boolean): Boolean { + if (userScope == requiredScope) return true + + if (!wildcardEnabled) return false + + // Wildcard matching: "documenten.*" matches "documenten.lezen" + if (userScope.endsWith(".*")) { + val prefix = userScope.removeSuffix(".*") + return requiredScope.startsWith("$prefix.") + } + + return false +} + +/** + * Exception thrown when a user lacks required scopes. + */ +class ScopeAuthorizationException(val requiredScopes: Set) : + Exception("Access denied. Required scopes: ${requiredScopes.joinToString(", ")}") diff --git a/src/main/kotlin/api/documenten/routes/EnkelvoudigInformatieObjectenRoutes.kt b/src/main/kotlin/api/documenten/routes/EnkelvoudigInformatieObjectenRoutes.kt index 7f48c1d2..91a569b5 100644 --- a/src/main/kotlin/api/documenten/routes/EnkelvoudigInformatieObjectenRoutes.kt +++ b/src/main/kotlin/api/documenten/routes/EnkelvoudigInformatieObjectenRoutes.kt @@ -7,6 +7,7 @@ import com.baseflow.api.DOCUMENTEN_API_VERSION import com.baseflow.api.middleware.ApiVersionHeader import com.baseflow.api.middleware.RequestScopeKey import com.baseflow.api.models.* +import com.baseflow.api.routes.checkScope import com.baseflow.entities.EIORecordEntity import com.baseflow.entities.latestVersion import com.baseflow.services.EnkelvoudigInformatieObjectService @@ -187,7 +188,9 @@ fun Route.enkelvoudigInformatieObjectenRoutes() { responses { response(200) { description = "Lijst van ENKELVOUDIGINFORMATIEOBJECTen." - ContentType.Application.Json { schema = jsonSchema>() } + ContentType.Application.Json { + schema = jsonSchema>() + } } response(400) { description = "Bad request." } response(401) { description = "Unauthorized." } @@ -282,7 +285,9 @@ fun Route.enkelvoudigInformatieObjectenRoutes() { responses { response(200) { description = "Lijst van gevonden ENKELVOUDIGINFORMATIEOBJECTen." - ContentType.Application.Json { schema = jsonSchema>() } + ContentType.Application.Json { + schema = jsonSchema>() + } } response(400) { description = "Bad request." } response(401) { description = "Unauthorized." } @@ -637,12 +642,14 @@ private val RoutingContext.service: EnkelvoudigInformatieObjectService get() = call.attributes[RequestScopeKey].get() private suspend fun RoutingContext.list() { + call.checkScope("documenten.lezen") val (page, pageSize, filter) = getFilters() val (items, totalCount) = service.getAll(filter) call.respond(PaginatedResponse.from(call, RESOURCE_SEGMENT, items, totalCount, page, pageSize)) } private suspend fun RoutingContext.create() { + call.checkScope("documenten.aanmaken") val request = call.receive() try { val response = service.create(request) @@ -663,6 +670,7 @@ private suspend fun RoutingContext.create() { } private suspend fun RoutingContext.zoek() { + call.checkScope("documenten.lezen") val request = call.receive() val (page, pageSize, filter) = getFilters(request.uuidIn, request.expand) @@ -746,6 +754,7 @@ private fun splitOnComma(params: String?): List = ( ) private suspend fun RoutingContext.head() { + call.checkScope("documenten.lezen") val uuidString = call.parameters["uuid"] if (uuidString == null) { call.respondProblem(HttpStatusCode.BadRequest, badRequest("UUID parameter is required", call.request.path())) @@ -770,6 +779,7 @@ private suspend fun RoutingContext.head() { } private suspend fun RoutingContext.get() { + call.checkScope("documenten.lezen") // TODO add version and registratieOp query parameters support val uuidString = call.parameters["uuid"] if (uuidString == null) { @@ -799,6 +809,7 @@ private suspend fun RoutingContext.get() { } private suspend fun RoutingContext.put() { + call.checkScope("documenten.bijwerken") val uuidString = call.parameters["uuid"] if (uuidString == null) { call.respondProblem(HttpStatusCode.BadRequest, badRequest("UUID parameter is required", call.request.path())) @@ -831,6 +842,7 @@ private suspend fun RoutingContext.put() { } private suspend fun RoutingContext.patch() { + call.checkScope("documenten.bijwerken") val uuidString = call.parameters["uuid"] if (uuidString == null) { call.respondProblem(HttpStatusCode.BadRequest, badRequest("UUID parameter is required", call.request.path())) @@ -861,6 +873,7 @@ private suspend fun RoutingContext.patch() { } private suspend fun RoutingContext.delete() { + call.checkScope("documenten.verwijderen") val uuidString = call.parameters["uuid"] if (uuidString == null) { call.respondProblem(HttpStatusCode.BadRequest, badRequest("UUID parameter is required", call.request.path())) @@ -898,6 +911,7 @@ private suspend fun RoutingContext.delete() { } private suspend fun RoutingContext.download() { + call.checkScope("documenten.lezen") // TODO add version and registratieOp query parameters support val uuidString = call.parameters["uuid"] if (uuidString == null) { @@ -960,6 +974,7 @@ private suspend fun RoutingContext.download() { } private suspend fun RoutingContext.lock() { + call.checkScope("documenten.lock") val uuidString = call.parameters["uuid"] if (uuidString == null) { call.respondProblem(HttpStatusCode.BadRequest, badRequest("UUID parameter is required", call.request.path())) @@ -986,6 +1001,7 @@ private suspend fun RoutingContext.lock() { } private suspend fun RoutingContext.unlock() { + call.checkScope("documenten.geforceerd-unlock") val uuidString = call.parameters["uuid"] if (uuidString == null) { call.respondProblem(HttpStatusCode.BadRequest, badRequest("UUID parameter is required", call.request.path())) diff --git a/src/main/kotlin/api/documenten/routes/ObjectInformatieObjectenRoutes.kt b/src/main/kotlin/api/documenten/routes/ObjectInformatieObjectenRoutes.kt index 43ee67c9..e0bb226c 100644 --- a/src/main/kotlin/api/documenten/routes/ObjectInformatieObjectenRoutes.kt +++ b/src/main/kotlin/api/documenten/routes/ObjectInformatieObjectenRoutes.kt @@ -7,6 +7,7 @@ import com.baseflow.api.DOCUMENTEN_API_VERSION import com.baseflow.api.middleware.ApiVersionHeader import com.baseflow.api.middleware.RequestScopeKey import com.baseflow.api.models.* +import com.baseflow.api.routes.checkScope import com.baseflow.services.ObjectInformatieObjectService import com.baseflow.services.models.CreateOIOResult import com.baseflow.services.models.DeleteOIOResult @@ -84,7 +85,9 @@ open class ObjectInformatieObjectenRoutes( summary = "Alle ${resourceSegment.title} relaties opvragen." description = "Geeft een lijst van OBJECTINFORMATIEOBJECT relaties, gefilterd via query-parameters." parameters { - query("informatieobject") { description = "Filter op URL-referentie naar het INFORMATIEOBJECT." } + query("informatieobject") { + description = "Filter op URL-referentie naar het INFORMATIEOBJECT." + } query("object") { description = "Filter op URL-referentie naar het gerelateerde OBJECT." } query("expand") { description = "Velden om te expanderen." } query("page") { description = "**EXPERIMENTEEL** Paginanummer." } @@ -263,6 +266,7 @@ open class ObjectInformatieObjectenRoutes( } private suspend fun RoutingContext.list() { + call.checkScope("documenten.lezen") val informatieobject = call.request.queryParameters["informatieobject"] val subjectObject = call.request.queryParameters["object"] val expand = call.request.queryParameters.getAll("expand") ?: emptyList() @@ -289,6 +293,7 @@ open class ObjectInformatieObjectenRoutes( } private suspend fun RoutingContext.create() { + call.checkScope("documenten.aanmaken") val request = call.receive() when (val result = service.create(request)) { @@ -312,6 +317,7 @@ open class ObjectInformatieObjectenRoutes( } private suspend fun RoutingContext.head(resourceTitle: String) { + call.checkScope("documenten.lezen") val uuidString = call.parameters["uuid"] if (uuidString == null) { call.respondProblem( @@ -339,6 +345,7 @@ open class ObjectInformatieObjectenRoutes( } private suspend fun RoutingContext.get(resourceTitle: String) { + call.checkScope("documenten.lezen") val uuidString = call.parameters["uuid"] if (uuidString == null) { call.respondProblem( @@ -368,6 +375,7 @@ open class ObjectInformatieObjectenRoutes( } private suspend fun RoutingContext.delete(resourceTitle: String) { + call.checkScope("documenten.verwijderen") val uuidString = call.parameters["uuid"] if (uuidString == null) { call.respondProblem( diff --git a/src/main/kotlin/api/middleware/GlobalExceptionHandler.kt b/src/main/kotlin/api/middleware/GlobalExceptionHandler.kt index df6bf906..b57392a7 100644 --- a/src/main/kotlin/api/middleware/GlobalExceptionHandler.kt +++ b/src/main/kotlin/api/middleware/GlobalExceptionHandler.kt @@ -5,6 +5,7 @@ package com.baseflow.api.middleware import com.baseflow.api.models.ProblemDetailsResponse import com.baseflow.api.models.badRequest import com.baseflow.api.models.respondProblem +import com.baseflow.api.routes.ScopeAuthorizationException import io.ktor.http.HttpStatusCode import io.ktor.serialization.JsonConvertException import io.ktor.server.application.* @@ -21,6 +22,19 @@ fun Application.configureStatusPages() { val logger = LoggerFactory.getLogger("GlobalExceptionHandler") install(StatusPages) { + exception { call, cause -> + logger.warn("Access denied at ${call.request.path()}: ${cause.message}") + call.respondProblem( + HttpStatusCode.Forbidden, + ProblemDetailsResponse( + title = "Insufficient permissions", + status = HttpStatusCode.Forbidden.value, + detail = "Required scopes: ${cause.requiredScopes.joinToString(", ")}", + instance = call.request.path(), + ), + ) + } + exception { call, cause -> logger.error("Bad request at ${call.request.path()}: ${cause.message}") diff --git a/src/test/kotlin/api/documenten/middleware/CheckScopeTest.kt b/src/test/kotlin/api/documenten/middleware/CheckScopeTest.kt new file mode 100644 index 00000000..0bb91019 --- /dev/null +++ b/src/test/kotlin/api/documenten/middleware/CheckScopeTest.kt @@ -0,0 +1,235 @@ +// SPDX-License-Identifier: EUPL-1.2 +// Copyright (C) 2026 Gemeente Utrecht +package com.baseflow.api.middleware + +import com.auth0.jwt.JWT +import com.auth0.jwt.algorithms.Algorithm +import com.baseflow.api.routes.ScopeAuthorizationException +import com.baseflow.api.routes.checkScope +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.* +import io.ktor.server.application.* +import io.ktor.server.auth.* +import io.ktor.server.auth.jwt.* +import io.ktor.server.plugins.contentnegotiation.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import io.ktor.server.testing.* +import kotlin.test.* + +class CheckScopeTest { + + private val jwtSecret = "test-secret-key-for-testing-only" + private val jwtIssuer = "test-issuer" + + private fun generateToken(scopes: String): String = JWT.create() + .withIssuer(jwtIssuer) + .withSubject("testuser") + .withClaim("scope", scopes) + .sign(Algorithm.HMAC256(jwtSecret)) + + private fun ApplicationTestBuilder.setupTestApp() { + application { + install(ContentNegotiation) { + json() + } + + install(io.ktor.server.plugins.statuspages.StatusPages) { + exception { call, cause -> + call.respond( + HttpStatusCode.Forbidden, + mapOf( + "error" to "Insufficient permissions", + "detail" to "Required scopes: ${cause.requiredScopes.joinToString(", ")}", + "code" to "insufficient_scope", + ), + ) + } + } + + install(Authentication) { + jwt("auth-jwt") { + verifier( + JWT.require(Algorithm.HMAC256(jwtSecret)) + .withIssuer(jwtIssuer) + .build(), + ) + validate { credential -> + if (credential.payload.subject != null) { + JWTPrincipal(credential.payload) + } else { + null + } + } + } + } + + routing { + authenticate("auth-jwt") { + // Route requiring single scope + route("/documents") { + get { + call.checkScope("documenten.lezen") + call.respond(HttpStatusCode.OK, mapOf("message" to "success")) + } + } + + // Route requiring multiple scopes + route("/documents/{id}") { + delete { + call.checkScope("documenten.verwijderen", "documenten.admin") + call.respond(HttpStatusCode.NoContent) + } + } + + // Route without scope requirement + get("/public") { + call.respond(HttpStatusCode.OK, mapOf("message" to "public")) + } + } + } + } + } + + @Test + fun testAccessWithCorrectScope() = testApplication { + setupTestApp() + + val token = generateToken("documenten.lezen") + + val response = client.get("/documents") { + header(HttpHeaders.Authorization, "Bearer $token") + } + + assertEquals(HttpStatusCode.OK, response.status) + } + + @Test + fun testAccessWithoutRequiredScope() = testApplication { + setupTestApp() + + val token = generateToken("other:scope") + + val response = client.get("/documents") { + header(HttpHeaders.Authorization, "Bearer $token") + } + + assertEquals(HttpStatusCode.Forbidden, response.status) + assertTrue(response.bodyAsText().contains("Insufficient permissions")) + } + + @Test + fun testAccessWithMultipleScopes() = testApplication { + setupTestApp() + + val token = generateToken("documenten.verwijderen documenten.admin") + + val response = client.delete("/documents/123") { + header(HttpHeaders.Authorization, "Bearer $token") + } + + assertEquals(HttpStatusCode.NoContent, response.status) + } + + @Test + fun testAccessWithMissingOneOfMultipleScopes() = testApplication { + setupTestApp() + + // Has documenten.bijwerken but missing documenten.admin + val token = generateToken("documenten.bijwerken") + + val response = client.delete("/documents/123") { + header(HttpHeaders.Authorization, "Bearer $token") + } + + assertEquals(HttpStatusCode.Forbidden, response.status) + } + + @Test + fun testAccessPublicRouteWithAnyScope() = testApplication { + setupTestApp() + + val token = generateToken("any:scope") + + val response = client.get("/public") { + header(HttpHeaders.Authorization, "Bearer $token") + } + + assertEquals(HttpStatusCode.OK, response.status) + } + + @Test + fun testWildcardScopeMatching() = testApplication { + setupTestApp() + + // User has wildcard scope + val token = generateToken("documenten.*") + + val response = client.get("/documents") { + header(HttpHeaders.Authorization, "Bearer $token") + } + + assertEquals(HttpStatusCode.OK, response.status) + } + + @Test + fun testScopeAsArray() = testApplication { + application { + install(ContentNegotiation) { + json() + } + + install(Authentication) { + jwt("auth-jwt") { + verifier( + JWT.require(Algorithm.HMAC256(jwtSecret)) + .withIssuer(jwtIssuer) + .build(), + ) + validate { credential -> + if (credential.payload.subject != null) { + JWTPrincipal(credential.payload) + } else { + null + } + } + } + } + + routing { + authenticate("auth-jwt") { + route("/documents") { + get { + call.checkScope("documenten.lezen") + call.respond(HttpStatusCode.OK, mapOf("message" to "success")) + } + } + } + } + } + + // Create token with array-style scopes + val token = JWT.create() + .withIssuer(jwtIssuer) + .withSubject("testuser") + .withArrayClaim("scope", arrayOf("documenten.lezen", "documenten.bijwerken")) + .sign(Algorithm.HMAC256(jwtSecret)) + + val response = client.get("/documents") { + header(HttpHeaders.Authorization, "Bearer $token") + } + + assertEquals(HttpStatusCode.OK, response.status) + } + + @Test + fun testAccessWithoutToken() = testApplication { + setupTestApp() + + val response = client.get("/documents") + + assertEquals(HttpStatusCode.Unauthorized, response.status) + } +} diff --git a/src/test/kotlin/api/documenten/routes/BestandsDelenRoutesTest.kt b/src/test/kotlin/api/documenten/routes/BestandsDelenRoutesTest.kt index 9831e9d4..24b8e599 100644 --- a/src/test/kotlin/api/documenten/routes/BestandsDelenRoutesTest.kt +++ b/src/test/kotlin/api/documenten/routes/BestandsDelenRoutesTest.kt @@ -91,6 +91,7 @@ class BestandsDelenRoutesTest : TestBase("bestandsdelen_routes") { @Test fun `PUT bestandsdeel with valid uuid and correct lock returns 200`() = testApplication { application { setup() } + val client = authenticatedClient() val (uuid, lock) = createBestandsdeelInDb() @@ -121,6 +122,7 @@ class BestandsDelenRoutesTest : TestBase("bestandsdelen_routes") { @Test fun `PUT bestandsdeel without inhoud field still succeeds when lock is present`() = testApplication { application { setup() } + val client = authenticatedClient() val (uuid, lock) = createBestandsdeelInDb() @@ -145,6 +147,7 @@ class BestandsDelenRoutesTest : TestBase("bestandsdelen_routes") { @Test fun `PUT bestandsdeel with invalid UUID returns 400`() = testApplication { application { setup() } + val client = authenticatedClient() val response = client.put("$BESTANDSDELEN_PATH/not-a-valid-uuid") { setBody( @@ -164,6 +167,7 @@ class BestandsDelenRoutesTest : TestBase("bestandsdelen_routes") { @Test fun `PUT bestandsdeel without lock field returns 400`() = testApplication { application { setup() } + val client = authenticatedClient() val (uuid, _) = createBestandsdeelInDb() @@ -192,6 +196,7 @@ class BestandsDelenRoutesTest : TestBase("bestandsdelen_routes") { @Test fun `PUT bestandsdeel with blank lock value returns 400`() = testApplication { application { setup() } + val client = authenticatedClient() val (uuid, _) = createBestandsdeelInDb() @@ -215,6 +220,7 @@ class BestandsDelenRoutesTest : TestBase("bestandsdelen_routes") { @Test fun `PUT bestandsdeel with wrong lock token returns 403`() = testApplication { application { setup() } + val client = authenticatedClient() val (uuid, _) = createBestandsdeelInDb() @@ -240,6 +246,7 @@ class BestandsDelenRoutesTest : TestBase("bestandsdelen_routes") { @Test fun `PUT bestandsdeel with unknown UUID returns 404`() = testApplication { application { setup() } + val client = authenticatedClient() val unknownUuid = UUID.randomUUID() diff --git a/src/test/kotlin/api/documenten/routes/EnkelvoudigInformatieObjectenRoutesTest.kt b/src/test/kotlin/api/documenten/routes/EnkelvoudigInformatieObjectenRoutesTest.kt index fa2b1058..5da9c901 100644 --- a/src/test/kotlin/api/documenten/routes/EnkelvoudigInformatieObjectenRoutesTest.kt +++ b/src/test/kotlin/api/documenten/routes/EnkelvoudigInformatieObjectenRoutesTest.kt @@ -33,6 +33,7 @@ class EnkelvoudigInformatieObjectenRoutesTest : TestBase("eio_routes") { @Test fun `test POST enkelvoudiginformatieobjecten with taal and bestandsnaam`() = testApplication { application { setup() } + val client = authenticatedClient() val request = generateTestDocument(taal = "dut", bestandsnaam = "test.pdf") val response = client.post("$API_BASE/$RESOURCE_SEGMENT") { @@ -61,6 +62,7 @@ class EnkelvoudigInformatieObjectenRoutesTest : TestBase("eio_routes") { @Test fun `test GET list includes url in each result`() = testApplication { application { setup() } + val client = authenticatedClient() // Create two documents val req1 = generateTestDocument(taal = "dut", bestandsnaam = "doc1.pdf") @@ -99,6 +101,7 @@ class EnkelvoudigInformatieObjectenRoutesTest : TestBase("eio_routes") { @Test fun `test GET enkelvoudiginformatieobjecten with invalid UUID`() = testApplication { application { setup() } + val client = authenticatedClient() val response = client.get("$API_BASE/$RESOURCE_SEGMENT/invalid-uuid") @@ -113,6 +116,7 @@ class EnkelvoudigInformatieObjectenRoutesTest : TestBase("eio_routes") { @Test fun `test GET enkelvoudiginformatieobjecten with missing UUID parameter`() = testApplication { application { setup() } + val client = authenticatedClient() val response = client.get("$API_BASE/$RESOURCE_SEGMENT/") @@ -122,6 +126,7 @@ class EnkelvoudigInformatieObjectenRoutesTest : TestBase("eio_routes") { @Test fun `test GET enkelvoudiginformatieobjecten with valid UUID after creation`() = testApplication { application { setup() } + val client = authenticatedClient() val request = generateTestDocument(taal = "dut", bestandsnaam = "test.pdf") val postResponse = client.post("$API_BASE/$RESOURCE_SEGMENT") { @@ -146,6 +151,7 @@ class EnkelvoudigInformatieObjectenRoutesTest : TestBase("eio_routes") { @Test fun `HEAD enkelvoudiginformatieobjecten returns 200 for existing resource`() = testApplication { application { setup() } + val client = authenticatedClient() // create val created = client.post("$API_BASE/$RESOURCE_SEGMENT") { @@ -164,6 +170,7 @@ class EnkelvoudigInformatieObjectenRoutesTest : TestBase("eio_routes") { @Test fun `HEAD enkelvoudiginformatieobjecten returns 404 for missing resource`() = testApplication { application { setup() } + val client = authenticatedClient() val resp = client.head("$API_BASE/$RESOURCE_SEGMENT/${UUID.randomUUID()}") assertEquals(HttpStatusCode.NotFound, resp.status) @@ -172,6 +179,7 @@ class EnkelvoudigInformatieObjectenRoutesTest : TestBase("eio_routes") { @Test fun `HEAD enkelvoudiginformatieobjecten returns 400 for invalid UUID`() = testApplication { application { setup() } + val client = authenticatedClient() val resp = client.head("$API_BASE/$RESOURCE_SEGMENT/not-a-uuid") assertEquals(HttpStatusCode.BadRequest, resp.status) @@ -180,6 +188,7 @@ class EnkelvoudigInformatieObjectenRoutesTest : TestBase("eio_routes") { @Test fun `DELETE enkelvoudiginformatieobjecten returns 204 and removes resource`() = testApplication { application { setup() } + val client = authenticatedClient() // create val created = client.post("$API_BASE/$RESOURCE_SEGMENT") { @@ -201,6 +210,7 @@ class EnkelvoudigInformatieObjectenRoutesTest : TestBase("eio_routes") { @Test fun `DELETE enkelvoudiginformatieobjecten returns 404 for missing resource`() = testApplication { application { setup() } + val client = authenticatedClient() val del = client.delete("$API_BASE/$RESOURCE_SEGMENT/${UUID.randomUUID()}") assertEquals(HttpStatusCode.NotFound, del.status) @@ -209,6 +219,7 @@ class EnkelvoudigInformatieObjectenRoutesTest : TestBase("eio_routes") { @Test fun `DELETE enkelvoudiginformatieobjecten returns 400 for invalid UUID`() = testApplication { application { setup() } + val client = authenticatedClient() val del = client.delete("$API_BASE/$RESOURCE_SEGMENT/not-a-uuid") assertEquals(HttpStatusCode.BadRequest, del.status) @@ -217,6 +228,7 @@ class EnkelvoudigInformatieObjectenRoutesTest : TestBase("eio_routes") { @Test fun `DELETE enkelvoudiginformatieobjecten returns 409 for locked resource`() = testApplication { application { setup() } + val client = authenticatedClient() // create val created = client.post("$API_BASE/$RESOURCE_SEGMENT") { @@ -243,6 +255,7 @@ class EnkelvoudigInformatieObjectenRoutesTest : TestBase("eio_routes") { @Test fun `test GET enkelvoudiginformatieobjecten with valid UUID returns JSON UTF-8`() = testApplication { application { setup() } + val client = authenticatedClient() val request = generateTestDocument(taal = "dut", bestandsnaam = "test.pdf") val postResponse = client.post("$API_BASE/$RESOURCE_SEGMENT") { @@ -267,6 +280,7 @@ class EnkelvoudigInformatieObjectenRoutesTest : TestBase("eio_routes") { @Test fun `lock returns 200 with lock token, second lock returns 409`() = testApplication { application { setup() } + val client = authenticatedClient() // Create an object first val createReq = generateTestDocument() @@ -293,6 +307,7 @@ class EnkelvoudigInformatieObjectenRoutesTest : TestBase("eio_routes") { @Test fun `unlock with correct token returns 204 and subsequent unlock returns 409`() = testApplication { application { setup() } + val client = authenticatedClient() // Create val createReq = generateTestDocument() @@ -327,6 +342,7 @@ class EnkelvoudigInformatieObjectenRoutesTest : TestBase("eio_routes") { @Test fun `unlock with invalid token returns 409 Conflict`() = testApplication { application { setup() } + val client = authenticatedClient() // Create and lock val createReq = generateTestDocument() @@ -350,9 +366,12 @@ class EnkelvoudigInformatieObjectenRoutesTest : TestBase("eio_routes") { @Test fun `lock and unlock with unknown UUID return 404`() = testApplication { application { setup() } + val client = authenticatedClient() val unknownId = "00000000-0000-0000-0000-000000000001" + client.prepareDelete("$API_BASE/$RESOURCE_SEGMENT/$unknownId") + val lockResp = client.post("$API_BASE/$RESOURCE_SEGMENT/$unknownId/lock") assertEquals(HttpStatusCode.NotFound, lockResp.status) @@ -367,6 +386,7 @@ class EnkelvoudigInformatieObjectenRoutesTest : TestBase("eio_routes") { @Test fun `lock and unlock with invalid UUID return 400`() = testApplication { application { setup() } + val client = authenticatedClient() val invalidId = "not-a-uuid" @@ -384,6 +404,7 @@ class EnkelvoudigInformatieObjectenRoutesTest : TestBase("eio_routes") { @Test fun `test zoek endpoint with uuid_In`() = testApplication { application { setup() } + val client = authenticatedClient() // Create two documents val req1 = generateTestDocument(identificatie = "DOC-001") @@ -429,6 +450,7 @@ class EnkelvoudigInformatieObjectenRoutesTest : TestBase("eio_routes") { @Test fun `test GET list paging`() = testApplication { application { setup() } + val client = authenticatedClient() // Create 12 documents for (i in 1..12) { @@ -472,6 +494,7 @@ class EnkelvoudigInformatieObjectenRoutesTest : TestBase("eio_routes") { @Test fun `test zoek endpoint paging`() = testApplication { application { setup() } + val client = authenticatedClient() val createdIds = mutableListOf() // Create 12 documents @@ -523,6 +546,7 @@ class EnkelvoudigInformatieObjectenRoutesTest : TestBase("eio_routes") { @Test fun `test GET list paging with custom pageSize`() = testApplication { application { setup() } + val client = authenticatedClient() // Create 12 documents for (i in 1..12) { @@ -560,6 +584,7 @@ class EnkelvoudigInformatieObjectenRoutesTest : TestBase("eio_routes") { @Test fun `test GET list paging preserves filters`() = testApplication { application { setup() } + val client = authenticatedClient() val bronOrganisatie = "999999999" // Create 12 documents with specific bronorganisatie @@ -587,6 +612,7 @@ class EnkelvoudigInformatieObjectenRoutesTest : TestBase("eio_routes") { @Test fun `test GET list with experimental object filters`() = testApplication { application { setup() } + val client = authenticatedClient() // Create an EIO val resEio = client.post("$API_BASE/$RESOURCE_SEGMENT") { @@ -629,6 +655,7 @@ class EnkelvoudigInformatieObjectenRoutesTest : TestBase("eio_routes") { @Test fun `test POST zoek with experimental object filters`() = testApplication { application { setup() } + val client = authenticatedClient() // Create an EIO val resEio = client.post("$API_BASE/$RESOURCE_SEGMENT") { @@ -672,6 +699,7 @@ class EnkelvoudigInformatieObjectenRoutesTest : TestBase("eio_routes") { @Test fun `GET download returns 200 with content headers for an EIO that has content`() = testApplication { application { setup() } + val client = authenticatedClient() val created = client.post("$API_BASE/$RESOURCE_SEGMENT") { contentType(ContentType.Application.Json) diff --git a/src/test/kotlin/api/documenten/routes/GlobalExceptionHandlerTest.kt b/src/test/kotlin/api/documenten/routes/GlobalExceptionHandlerTest.kt index c739ccc7..e847d52d 100644 --- a/src/test/kotlin/api/documenten/routes/GlobalExceptionHandlerTest.kt +++ b/src/test/kotlin/api/documenten/routes/GlobalExceptionHandlerTest.kt @@ -24,6 +24,7 @@ class GlobalExceptionHandlerTest : TestBase("global_exception_test") { @Test fun `test malformed JSON returns ProblemDetailsResponse`() = testApplication { application { setup() } + val client = authenticatedClient() val malformedJson = "{ \"identificatie\": \"test\", \"bronorganisatie\": \"012345678\", " // Missing closing brace and other fields @@ -42,6 +43,7 @@ class GlobalExceptionHandlerTest : TestBase("global_exception_test") { @Test fun `test invalid objectType value in JSON returns specific error message`() = testApplication { application { setup() } + val client = authenticatedClient() // OIO request with an objectType containing spaces, which is invalid per SubjectType validation val invalidJson = """ @@ -78,6 +80,7 @@ class GlobalExceptionHandlerTest : TestBase("global_exception_test") { } } } + val client = authenticatedClient() val response = client.get("/test-serialization-error") diff --git a/src/test/kotlin/api/documenten/routes/ObjectInformatieObjectenRoutesTest.kt b/src/test/kotlin/api/documenten/routes/ObjectInformatieObjectenRoutesTest.kt index 5054b432..e9cee371 100644 --- a/src/test/kotlin/api/documenten/routes/ObjectInformatieObjectenRoutesTest.kt +++ b/src/test/kotlin/api/documenten/routes/ObjectInformatieObjectenRoutesTest.kt @@ -56,6 +56,7 @@ class ObjectInformatieObjectenRoutesTest : TestBase("oio_routes") { @Test fun `test list empty objectinformatieobjecten returns empty array`() = testApplication { application { setup() } + val client = authenticatedClient() val response = client.get("$API_BASE/$RESOURCE_SEGMENT") @@ -69,6 +70,7 @@ class ObjectInformatieObjectenRoutesTest : TestBase("oio_routes") { @Test fun `test create objectinformatieobject returns 201 with location header`() = testApplication { application { setup() } + val client = authenticatedClient() val eioId = createTestEIO() val request = CreateOIORequest( @@ -102,6 +104,7 @@ class ObjectInformatieObjectenRoutesTest : TestBase("oio_routes") { @Test fun `test create duplicate objectinformatieobject returns 400`() = testApplication { application { setup() } + val client = authenticatedClient() val eioId = createTestEIO() val request = CreateOIORequest( @@ -128,6 +131,7 @@ class ObjectInformatieObjectenRoutesTest : TestBase("oio_routes") { @Test fun `test get objectinformatieobject by id returns 200`() = testApplication { application { setup() } + val client = authenticatedClient() val eioId = createTestEIO() val createRequest = CreateOIORequest( informatieobject = "$API_BASE/${ResourceSegments.ENKELVOUDIG_INFORMATIE_OBJECTEN}/$eioId", @@ -157,6 +161,7 @@ class ObjectInformatieObjectenRoutesTest : TestBase("oio_routes") { @Test fun `test get non-existent objectinformatieobject returns 404`() = testApplication { application { setup() } + val client = authenticatedClient() val nonExistentId = UUID.randomUUID() val response = client.get("$API_BASE/$RESOURCE_SEGMENT/$nonExistentId") @@ -167,6 +172,7 @@ class ObjectInformatieObjectenRoutesTest : TestBase("oio_routes") { @Test fun `test head existing objectinformatieobject returns 200`() = testApplication { application { setup() } + val client = authenticatedClient() val eioId = createTestEIO() val createRequest = CreateOIORequest( informatieobject = "$API_BASE/${ResourceSegments.ENKELVOUDIG_INFORMATIE_OBJECTEN}/$eioId", @@ -190,6 +196,7 @@ class ObjectInformatieObjectenRoutesTest : TestBase("oio_routes") { @Test fun `test head non-existent objectinformatieobject returns 404`() = testApplication { application { setup() } + val client = authenticatedClient() val nonExistentId = UUID.randomUUID() val response = client.head("$API_BASE/$RESOURCE_SEGMENT/$nonExistentId") @@ -200,6 +207,7 @@ class ObjectInformatieObjectenRoutesTest : TestBase("oio_routes") { @Test fun `test delete objectinformatieobject returns 204`() = testApplication { application { setup() } + val client = authenticatedClient() val eioId = createTestEIO() val createRequest = CreateOIORequest( informatieobject = "$API_BASE/${ResourceSegments.ENKELVOUDIG_INFORMATIE_OBJECTEN}/$eioId", @@ -226,6 +234,7 @@ class ObjectInformatieObjectenRoutesTest : TestBase("oio_routes") { @Test fun `test delete non-existent objectinformatieobject returns 404`() = testApplication { application { setup() } + val client = authenticatedClient() val nonExistentId = UUID.randomUUID() val response = client.delete("$API_BASE/$RESOURCE_SEGMENT/$nonExistentId") @@ -236,6 +245,7 @@ class ObjectInformatieObjectenRoutesTest : TestBase("oio_routes") { @Test fun `test filter by informatieobject returns matching relations`() = testApplication { application { setup() } + val client = authenticatedClient() val sharedEioId = createTestEIO() val sharedEioUrl = "$API_BASE/${ResourceSegments.ENKELVOUDIG_INFORMATIE_OBJECTEN}/$sharedEioId" @@ -302,6 +312,7 @@ class ObjectInformatieObjectenRoutesTest : TestBase("oio_routes") { @Test fun `test filter by object returns matching relations`() = testApplication { application { setup() } + val client = authenticatedClient() val sharedObjectUrl = "https://example.com/zaken/api/v1/zaken/87654321-4321-4321-4321-210987654321" @@ -364,6 +375,7 @@ class ObjectInformatieObjectenRoutesTest : TestBase("oio_routes") { @Test fun `test create objectinformatieobject with different object types`() = testApplication { application { setup() } + val client = authenticatedClient() // Create an actual EIO via API val eioId = createTestEIO() @@ -408,6 +420,7 @@ class ObjectInformatieObjectenRoutesTest : TestBase("oio_routes") { @Test fun `test get objectinformatieobject with invalid uuid returns 400`() = testApplication { application { setup() } + val client = authenticatedClient() val response = client.get("$API_BASE/$RESOURCE_SEGMENT/not-a-uuid") @@ -419,6 +432,7 @@ class ObjectInformatieObjectenRoutesTest : TestBase("oio_routes") { @Test fun `test create objectinformatieobject with invalid json returns 400`() = testApplication { application { setup() } + val client = authenticatedClient() val response = client.post("$API_BASE/$RESOURCE_SEGMENT") { contentType(ContentType.Application.Json) @@ -435,6 +449,7 @@ class ObjectInformatieObjectenRoutesTest : TestBase("oio_routes") { @Test fun `test head objectinformatieobject returns 200 for existing`() = testApplication { application { setup() } + val client = authenticatedClient() val eioId = createTestEIO() val request = CreateOIORequest( @@ -457,6 +472,7 @@ class ObjectInformatieObjectenRoutesTest : TestBase("oio_routes") { @Test fun `test head objectinformatieobject returns 404 for non-existing`() = testApplication { application { setup() } + val client = authenticatedClient() val response = client.head("$API_BASE/$RESOURCE_SEGMENT/${UUID.randomUUID()}") assertEquals(HttpStatusCode.NotFound, response.status) @@ -465,6 +481,8 @@ class ObjectInformatieObjectenRoutesTest : TestBase("oio_routes") { @Test fun `test create objectinformatieobject with non-standard objectType returns 201`() = testApplication { application { setup() } + val client = authenticatedClient() + val eioId = createTestEIO() // A type that is not zaak, verzoek or besluit but is a valid format should be accepted @@ -486,6 +504,8 @@ class ObjectInformatieObjectenRoutesTest : TestBase("oio_routes") { @Test fun `test create objectinformatieobject with invalid objectType format returns 400`() = testApplication { application { setup() } + val client = authenticatedClient() + val eioId = createTestEIO() // Inject raw invalid JSON to bypass Kotlin-side SubjectType validation diff --git a/src/test/kotlin/api/documenten/routes/SubjectInformatieObjectenRoutesTest.kt b/src/test/kotlin/api/documenten/routes/SubjectInformatieObjectenRoutesTest.kt index 5cabbce8..44fe458e 100644 --- a/src/test/kotlin/api/documenten/routes/SubjectInformatieObjectenRoutesTest.kt +++ b/src/test/kotlin/api/documenten/routes/SubjectInformatieObjectenRoutesTest.kt @@ -53,6 +53,7 @@ class SubjectInformatieObjectenRoutesTest : TestBase("subject_oio_routes") { @Test fun `test list empty subjectinformatieobjecten returns paginated empty results`() = testApplication { application { setup() } + val client = authenticatedClient() val response = client.get("$API_BASE/$RESOURCE_SEGMENT") @@ -69,6 +70,7 @@ class SubjectInformatieObjectenRoutesTest : TestBase("subject_oio_routes") { @Test fun `test create subjectinformatieobject returns 201 with location header`() = testApplication { application { setup() } + val client = authenticatedClient() val eioId = createTestEIO() val request = CreateOIORequest( @@ -93,6 +95,7 @@ class SubjectInformatieObjectenRoutesTest : TestBase("subject_oio_routes") { @Test fun `test subjectinformatieobjecten list paging`() = testApplication { application { setup() } + val client = authenticatedClient() val eioId = createTestEIO() // Create 12 relations @@ -131,6 +134,7 @@ class SubjectInformatieObjectenRoutesTest : TestBase("subject_oio_routes") { @Test fun `test get subjectinformatieobject by id`() = testApplication { application { setup() } + val client = authenticatedClient() val eioId = createTestEIO() val request = CreateOIORequest( informatieobject = "$API_BASE/${ResourceSegments.ENKELVOUDIG_INFORMATIE_OBJECTEN}/$eioId", @@ -154,6 +158,7 @@ class SubjectInformatieObjectenRoutesTest : TestBase("subject_oio_routes") { @Test fun `test delete subjectinformatieobject`() = testApplication { application { setup() } + val client = authenticatedClient() val eioId = createTestEIO() val request = CreateOIORequest( informatieobject = "$API_BASE/${ResourceSegments.ENKELVOUDIG_INFORMATIE_OBJECTEN}/$eioId", diff --git a/src/test/kotlin/api/documenten/routes/TestBase.kt b/src/test/kotlin/api/documenten/routes/TestBase.kt index 8f5e7747..7b28e6d7 100644 --- a/src/test/kotlin/api/documenten/routes/TestBase.kt +++ b/src/test/kotlin/api/documenten/routes/TestBase.kt @@ -2,16 +2,26 @@ // Copyright (C) 2026 Gemeente Utrecht package com.baseflow.api.documenten.routes +import com.auth0.jwt.JWT +import com.auth0.jwt.algorithms.Algorithm import com.baseflow.api.admin.adminModule import com.baseflow.api.apiJsonConfig import com.baseflow.api.documenten.documentenApiModule import com.baseflow.config.appModule import com.baseflow.services.StorageService import com.baseflow.tooling.AllTables +import io.ktor.client.* +import io.ktor.client.plugins.* +import io.ktor.client.request.* +import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* import io.ktor.server.application.Application import io.ktor.server.application.install +import io.ktor.server.auth.* +import io.ktor.server.auth.jwt.* import io.ktor.server.plugins.contentnegotiation.* +import io.ktor.server.response.* +import io.ktor.server.testing.* import io.mockk.every import io.mockk.mockk import org.jetbrains.exposed.v1.jdbc.Database @@ -30,6 +40,28 @@ open class TestBase(dbNamePrefix: String) { lateinit var mockStorageService: StorageService protected set + companion object { + const val TEST_JWT_SECRET = "test-secret-key-for-testing-only" + const val TEST_JWT_ISSUER = "test-issuer" + + // All scopes used in the application + val ALL_SCOPES = listOf( + "documenten.lezen", + "documenten.aanmaken", + "documenten.bijwerken", + "documenten.verwijderen", + "documenten.lock", + "documenten.geforceerd-unlock", + ).joinToString(" ") + + fun generateTestToken(scopes: String = ALL_SCOPES): String = JWT.create() + .withIssuer(TEST_JWT_ISSUER) + .withSubject("testuser") + .withClaim("scope", scopes) + .withClaim("username", "testuser") + .sign(Algorithm.HMAC256(TEST_JWT_SECRET)) + } + fun connectDb() { Database.connect( "jdbc:h2:mem:$dbName;DB_CLOSE_DELAY=-1;", @@ -71,7 +103,62 @@ open class TestBase(dbNamePrefix: String) { install(ContentNegotiation) { json(apiJsonConfig()) } - documentenApiModule(useAuthentication = false) + + // Install test JWT authentication - register both providers used by documentenApiModule + install(Authentication) { + jwt("auth-jwt") { + verifier( + JWT.require(Algorithm.HMAC256(TEST_JWT_SECRET)) + .withIssuer(TEST_JWT_ISSUER) + .build(), + ) + validate { credential -> + if (credential.payload.getClaim("username").asString() != "") { + JWTPrincipal(credential.payload) + } else { + null + } + } + challenge { _, _ -> + call.respondText( + text = "Unauthorized", + status = HttpStatusCode.Unauthorized, + ) + } + } + // Also register auth-zgw with the same config for tests + jwt("auth-zgw") { + verifier( + JWT.require(Algorithm.HMAC256(TEST_JWT_SECRET)) + .withIssuer(TEST_JWT_ISSUER) + .build(), + ) + validate { credential -> + if (credential.payload.getClaim("username").asString() != "") { + JWTPrincipal(credential.payload) + } else { + null + } + } + challenge { _, _ -> + call.respondText( + text = "Unauthorized", + status = HttpStatusCode.Unauthorized, + ) + } + } + } + + documentenApiModule(useAuthentication = true) adminModule(useAuthentication = false) } + + /** + * Creates an HTTP client with a default Bearer token containing all scopes. + */ + fun ApplicationTestBuilder.authenticatedClient(): HttpClient = createClient { + install(DefaultRequest) { + header(HttpHeaders.Authorization, "Bearer ${generateTestToken()}") + } + } }