Repository: javalin/javalin
Pull request #2403 (2025-05-11 07:09:07)

feat: Add route roles for static files
Provided feature for the issue  https://github.com/javalin/javalin/issues/2364.
Example use case : 
``` Java 
var app = Javalin
            .create(config -> {
                config.staticFiles.add(staticFileConfig -> {
                    staticFileConfig.hostedPath = "/";
                    staticFileConfig.directory = "/public";
                    staticFileConfig.location = Location.CLASSPATH;
                    staticFileConfig.roles = Set.of(Role.USER);
                });
            })
            .start();
```

tipsy: Thanks @hupender - would be really great with some tests for this!
tipsy: Seems the implementation is making some of the existing tests fail too - you can run these locally by doing `./mvnw test`      (or `mvn test` if you have maven installed).
hupender: @tipsy i have made the changes and added unit test cases but getting failure in javalin-osgi. It was already failing even in the master branch.
<details><summary>Details</summary>
<p>
``` 
 Tests run: 819, Failures: 0, Errors: 0, Skipped: 24 in javalin-parent

[INFO] Reactor Summary for Javalin (parent) 6.6.1-SNAPSHOT:
[INFO] 
[INFO] Javalin (parent) ................................... SUCCESS [  0.646 s]
[INFO] javalin ............................................ SUCCESS [ 32.890 s]
[INFO] javalin-utils ...................................... SUCCESS [  0.015 s]
[INFO] javalin-context-mock ............................... SUCCESS [  2.358 s]
[INFO] javalin-testtools .................................. SUCCESS [  3.094 s]
[INFO] javalin-micrometer ................................. SUCCESS [  2.332 s]
[INFO] javalin-bundle ..................................... SUCCESS [  0.077 s]
[INFO] javalin-rendering .................................. SUCCESS [  8.396 s]
[INFO] Javalin SSL Plugin ................................. SUCCESS [ 11.083 s]
[INFO] Jacoco Coverage Report ............................. SUCCESS [  0.038 s]
[INFO] javalin-osgi ....................................... FAILURE [  0.560 s]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD FAILURE
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  01:02 min
[INFO] Finished at: 2025-05-12T09:56:06+05:30
[INFO] ------------------------------------------------------------------------
[ERROR] Failed to execute goal org.apache.maven.plugins:maven-dependency-plugin:2.8:unpack-dependencies (unpack-everything) on project javalin-osgi: Artifact has not been packaged yet. When used on reactor artifact, unpack should be executed after packaging: see MDEP-98. -> [Help 1]
[ERROR] 
[ERROR] To see the full stack trace of the errors, re-run Maven with the -e switch.
[ERROR] Re-run Maven using the -X switch to enable full debug logging.
[ERROR] 
[ERROR] For more information about the errors and possible solutions, please read the following articles:
[ERROR] [Help 1] http://cwiki.apache.org/confluence/display/MAVEN/MojoExecutionException
[ERROR] 
[ERROR] After correcting the problems, you can resume the build with the command
[ERROR]   mvn <args> -rf :javalin-osgi
```

</p>
</details> 
lazysegtree: I can do a complete test run without errors. 

<details><summary>Details</summary>
<p>

```
[INFO]
[INFO] --- maven-bundle-plugin:5.1.9:install (default-install) @ javalin-osgi ---
[INFO] Installing io/javalin/javalin-osgi/6.6.1-SNAPSHOT/javalin-osgi-6.6.1-SNAPSHOT.jar
[INFO] Writing OBR metadata
[INFO] ------------------------------------------------------------------------
[INFO] Reactor Summary for Javalin (parent) 6.6.1-SNAPSHOT:
[INFO]
[INFO] Javalin (parent) ................................... SUCCESS [  0.310 s]
[INFO] javalin ............................................ SUCCESS [ 52.688 s]
[INFO] javalin-utils ...................................... SUCCESS [  0.010 s]
[INFO] javalin-context-mock ............................... SUCCESS [  2.341 s]
[INFO] javalin-testtools .................................. SUCCESS [  2.219 s]
[INFO] javalin-micrometer ................................. SUCCESS [  1.547 s]
[INFO] javalin-bundle ..................................... SUCCESS [  0.036 s]
[INFO] javalin-rendering .................................. SUCCESS [  4.356 s]
[INFO] Javalin SSL Plugin ................................. SUCCESS [  8.240 s]
[INFO] Jacoco Coverage Report ............................. SUCCESS [  0.630 s]
[INFO] javalin-osgi ....................................... SUCCESS [  1.445 s]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  01:14 min
[INFO] Finished at: 2025-05-12T15:02:09+05:30
[INFO] ------------------------------------------------------------------------
➜  ~/Workspace/Other_proj/hupender/javalin git:(static_roles) [3:02:11] mvn clean install; say done
➜  ~/Workspace/Other_proj/hupender/javalin git:(static_roles) [3:02:13] git log --oneline | head -n 4
0c85e806 unit test cases added and bug fixes
61ee16ef feat: Add route roles for static files
bbe45ad9 [context] Add disableCompression() to disable response compression per request
1177ba2f [loom] Fix virtual threads bug for GraalVM (#2361)
➜  ~/Workspace/Other_proj/hupender/javalin git:(static_roles)
```

</p>
</details> 
tipsy: Thanks guys - @hupender this is expected:

https://github.com/javalin/javalin/blob/master/README.md#:~:text=If%20you%20run,98.%20%2D%3E%20%5BHelp%201%5D
hupender: @tipsy i have fixed the unit test cases. 
https://github.com/hupender/javalin/actions/runs/14986355056 
tipsy: Thanks - running again !
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/javalin/javalin/pull/2403?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=javalin) Report
Attention: Patch coverage is `0%` with `17 lines` in your changes missing coverage. Please review.
> Project coverage is 0.00%. Comparing base [(`bbe45ad`)](https://app.codecov.io/gh/javalin/javalin/commit/bbe45ad98db2eb06c2708bea3c196ba2eb0ca9f0?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=javalin) to head [(`046a3ec`)](https://app.codecov.io/gh/javalin/javalin/commit/046a3ec768c6ec4fe98070f85978230030b8c7b5?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=javalin).

| [Files with missing lines](https://app.codecov.io/gh/javalin/javalin/pull/2403?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=javalin) | Patch % | Lines |
|---|---|---|
| [...main/java/io/javalin/jetty/JettyResourceHandler.kt](https://app.codecov.io/gh/javalin/javalin/pull/2403?src=pr&el=tree&filepath=javalin%2Fsrc%2Fmain%2Fjava%2Fio%2Fjavalin%2Fjetty%2FJettyResourceHandler.kt&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=javalin#diff-amF2YWxpbi9zcmMvbWFpbi9qYXZhL2lvL2phdmFsaW4vamV0dHkvSmV0dHlSZXNvdXJjZUhhbmRsZXIua3Q=) | 0.00% | [7 Missing :warning: ](https://app.codecov.io/gh/javalin/javalin/pull/2403?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=javalin) |
| [.../main/java/io/javalin/http/servlet/DefaultTasks.kt](https://app.codecov.io/gh/javalin/javalin/pull/2403?src=pr&el=tree&filepath=javalin%2Fsrc%2Fmain%2Fjava%2Fio%2Fjavalin%2Fhttp%2Fservlet%2FDefaultTasks.kt&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=javalin#diff-amF2YWxpbi9zcmMvbWFpbi9qYXZhL2lvL2phdmFsaW4vaHR0cC9zZXJ2bGV0L0RlZmF1bHRUYXNrcy5rdA==) | 0.00% | [6 Missing :warning: ](https://app.codecov.io/gh/javalin/javalin/pull/2403?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=javalin) |
| [...c/main/java/io/javalin/config/StaticFilesConfig.kt](https://app.codecov.io/gh/javalin/javalin/pull/2403?src=pr&el=tree&filepath=javalin%2Fsrc%2Fmain%2Fjava%2Fio%2Fjavalin%2Fconfig%2FStaticFilesConfig.kt&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=javalin#diff-amF2YWxpbi9zcmMvbWFpbi9qYXZhL2lvL2phdmFsaW4vY29uZmlnL1N0YXRpY0ZpbGVzQ29uZmlnLmt0) | 0.00% | [2 Missing :warning: ](https://app.codecov.io/gh/javalin/javalin/pull/2403?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=javalin) |
| [...va/io/javalin/http/staticfiles/StaticFileConfig.kt](https://app.codecov.io/gh/javalin/javalin/pull/2403?src=pr&el=tree&filepath=javalin%2Fsrc%2Fmain%2Fjava%2Fio%2Fjavalin%2Fhttp%2Fstaticfiles%2FStaticFileConfig.kt&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=javalin#diff-amF2YWxpbi9zcmMvbWFpbi9qYXZhL2lvL2phdmFsaW4vaHR0cC9zdGF0aWNmaWxlcy9TdGF0aWNGaWxlQ29uZmlnLmt0) | 0.00% | [2 Missing :warning: ](https://app.codecov.io/gh/javalin/javalin/pull/2403?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=javalin) |

<details><summary>Additional details and impacted files</summary>


```diff
@@          Coverage Diff           @@
##           master   #2403   +/-   ##
======================================
  Coverage    0.00%   0.00%           
======================================
  Files         148     148           
  Lines        4493    4505   +12     
  Branches      513     516    +3     
======================================
- Misses       4493    4505   +12     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/javalin/javalin/pull/2403?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=javalin).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=javalin).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
lazysegtree: Could be due to these commits 

<img width="997" alt="image" src="https://github.com/user-attachments/assets/a60c5be6-c271-4437-99a2-04447bd6001c" />

tipsy: Codecov is fixed - I rebased the PR too. Will have a look now.
tipsy: Thanks guys - sorry this is taking some time, I am in the process of moving houses.
tipsy: Thanks @hupender, this looks really great. Sorry again about the long wait, I just got my home office up and running.

Patch:
diff --git a/javalin/src/main/java/io/javalin/config/StaticFilesConfig.kt b/javalin/src/main/java/io/javalin/config/StaticFilesConfig.kt
index 4324f987..08a63c64 100644
--- a/javalin/src/main/java/io/javalin/config/StaticFilesConfig.kt
+++ b/javalin/src/main/java/io/javalin/config/StaticFilesConfig.kt
@@ -4,6 +4,7 @@ import io.javalin.http.Header
 import io.javalin.http.staticfiles.Location
 import io.javalin.http.staticfiles.StaticFileConfig
 import io.javalin.jetty.JettyResourceHandler
+import io.javalin.security.RouteRole
 import java.util.function.Consumer
 
 /**
@@ -27,11 +28,13 @@ class StaticFilesConfig(private val cfg: JavalinConfig) {
      * Adds the given directory as a static file containers.
      * @param directory the directory where your files are located
      * @param location the location of the static directory (default: CLASSPATH)
+     * @param roles the roles which can access the the static directory (default: emptySet())
      */
     @JvmOverloads
-    fun add(directory: String, location: Location = Location.CLASSPATH) = add { staticFiles ->
+    fun add(directory: String, location: Location = Location.CLASSPATH, roles: Set<RouteRole> = emptySet()) = add { staticFiles ->
         staticFiles.directory = directory
         staticFiles.location = location
+        staticFiles.roles = roles
     }
 
     /**
diff --git a/javalin/src/main/java/io/javalin/http/servlet/DefaultTasks.kt b/javalin/src/main/java/io/javalin/http/servlet/DefaultTasks.kt
index 2b948509..ce0d8efe 100644
--- a/javalin/src/main/java/io/javalin/http/servlet/DefaultTasks.kt
+++ b/javalin/src/main/java/io/javalin/http/servlet/DefaultTasks.kt
@@ -22,13 +22,16 @@ object DefaultTasks {
     }
 
     val BEFORE_MATCHED = TaskInitializer<JavalinServletContext> { submitTask, servlet, ctx, requestUri ->
-        val willMatch by javalinLazy {
-            ctx.setRouteRoles(servlet.matchedRoles(ctx, requestUri)) // set roles for the matched handler
-            servlet.willMatch(ctx, requestUri)
-        }
         val httpHandlerOrNull by javalinLazy {
             servlet.router.findHttpHandlerEntries(ctx.method(), requestUri).firstOrNull()
         }
+        val isResourceHandler = httpHandlerOrNull == null && ctx.method() in setOf(HEAD, GET)
+        val matchedRouteRoles by javalinLazy { servlet.matchedRoles(ctx, requestUri) }
+        val resourceRouteRoles by javalinLazy { servlet.cfg.pvt.resourceHandler?.getResourceRouteRoles(ctx) ?: emptySet() }
+        val willMatch by javalinLazy {
+            ctx.setRouteRoles(if (isResourceHandler) resourceRouteRoles else matchedRouteRoles)
+            servlet.willMatch(ctx, requestUri)
+        }
         servlet.router.findHttpHandlerEntries(HandlerType.BEFORE_MATCHED, requestUri).forEach { entry ->
             if (willMatch) {
                 submitTask(LAST, Task(skipIfExceptionOccurred = true) {
diff --git a/javalin/src/main/java/io/javalin/http/staticfiles/ResourceHandler.java b/javalin/src/main/java/io/javalin/http/staticfiles/ResourceHandler.java
index 4f7f2b1e..309f4e07 100644
--- a/javalin/src/main/java/io/javalin/http/staticfiles/ResourceHandler.java
+++ b/javalin/src/main/java/io/javalin/http/staticfiles/ResourceHandler.java
@@ -1,6 +1,8 @@
 package io.javalin.http.staticfiles;
 
 import io.javalin.http.Context;
+import io.javalin.security.RouteRole;
+import java.util.Set;
 
 public interface ResourceHandler {
     boolean canHandle(Context context);
@@ -8,4 +10,6 @@ public interface ResourceHandler {
     boolean handle(Context context);
 
     boolean addStaticFileConfig(StaticFileConfig config);
+
+    Set<RouteRole> getResourceRouteRoles(Context ctx);
 }
diff --git a/javalin/src/main/java/io/javalin/http/staticfiles/StaticFileConfig.kt b/javalin/src/main/java/io/javalin/http/staticfiles/StaticFileConfig.kt
index 4e314b93..4494d0a5 100644
--- a/javalin/src/main/java/io/javalin/http/staticfiles/StaticFileConfig.kt
+++ b/javalin/src/main/java/io/javalin/http/staticfiles/StaticFileConfig.kt
@@ -3,6 +3,7 @@ package io.javalin.http.staticfiles
 import io.javalin.config.StaticFilesConfig
 import io.javalin.http.ContentType
 import io.javalin.http.Header
+import io.javalin.security.RouteRole
 import jakarta.servlet.http.HttpServletRequest
 import org.eclipse.jetty.server.handler.ContextHandler.AliasCheck
 
@@ -37,7 +38,8 @@ data class StaticFileConfig(
     @JvmField var aliasCheck: AliasCheck? = null,
     @JvmField var headers: Map<String, String> = mutableMapOf(Header.CACHE_CONTROL to "max-age=0"),
     @JvmField var skipFileFunction: (HttpServletRequest) -> Boolean = { false },
-    @JvmField val mimeTypes: MimeTypesConfig = MimeTypesConfig()
+    @JvmField val mimeTypes: MimeTypesConfig = MimeTypesConfig(),
+    @JvmField var roles: Set<RouteRole> = emptySet()
 ) {
     internal fun refinedToString(): String {
         return this.toString().replace(", skipFileFunction=(jakarta.servlet.http.HttpServletRequest) -> kotlin.Boolean", "")
diff --git a/javalin/src/main/java/io/javalin/jetty/JettyResourceHandler.kt b/javalin/src/main/java/io/javalin/jetty/JettyResourceHandler.kt
index 384845af..a1ca64d8 100644
--- a/javalin/src/main/java/io/javalin/jetty/JettyResourceHandler.kt
+++ b/javalin/src/main/java/io/javalin/jetty/JettyResourceHandler.kt
@@ -10,6 +10,7 @@ import io.javalin.config.PrivateConfig
 import io.javalin.http.Context
 import io.javalin.http.staticfiles.Location
 import io.javalin.http.staticfiles.StaticFileConfig
+import io.javalin.security.RouteRole
 import io.javalin.util.JavalinException
 import io.javalin.util.JavalinLogger
 import io.javalin.util.javalinLazy
@@ -44,7 +45,8 @@ class JettyResourceHandler(val pvt: PrivateConfig) : JavalinResourceHandler {
 
     override fun canHandle(ctx: Context) = nonSkippedHandlers(ctx.jettyReq()).any { handler ->
         try {
-            fileOrWelcomeFile(handler, ctx.target) != null
+            val target = URLDecoder.decode(ctx.target, "UTF-8")
+            fileOrWelcomeFile(handler, target) != null
         } catch (e: Exception) {
             e.message?.contains("Rejected alias reference") == true ||  // we want to say these are un-handleable (404)
                 e.message?.contains("Failed alias check") == true // we want to say these are un-handleable (404)
@@ -86,6 +88,17 @@ class JettyResourceHandler(val pvt: PrivateConfig) : JavalinResourceHandler {
 
     private val Context.target get() = this.req().requestURI.removePrefix(this.req().contextPath)
 
+    override fun getResourceRouteRoles(ctx: Context): Set<RouteRole> {
+        nonSkippedHandlers(ctx.jettyReq()).forEach { handler ->
+            val target = URLDecoder.decode(ctx.target, "UTF-8")
+            val fileOrWelcomeFile = fileOrWelcomeFile(handler, target)
+            if (fileOrWelcomeFile != null) {
+                return handler.config.roles;
+            }
+        }
+        return emptySet();
+    }
+
 }
 
 open class ConfigurableHandler(val config: StaticFileConfig, jettyServer: Server) : ResourceHandler() {


Test patch:
diff --git a/javalin/src/test/java/io/javalin/staticfiles/TestStaticFileAccess.kt b/javalin/src/test/java/io/javalin/staticfiles/TestStaticFileAccess.kt
new file mode 100644
index 00000000..d09c07ac
--- /dev/null
+++ b/javalin/src/test/java/io/javalin/staticfiles/TestStaticFileAccess.kt
@@ -0,0 +1,116 @@
+package io.javalin.staticfiles
+
+import io.javalin.Javalin
+import io.javalin.config.JavalinConfig
+import io.javalin.http.ContentType
+import io.javalin.http.Header
+import io.javalin.http.HttpStatus
+import io.javalin.http.HttpStatus.UNAUTHORIZED
+import io.javalin.http.UnauthorizedResponse
+import io.javalin.http.staticfiles.Location
+import io.javalin.security.RouteRole
+import io.javalin.testing.TestUtil
+import io.javalin.testing.httpCode
+import kong.unirest.Unirest
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.jupiter.api.Test
+import java.io.File
+
+
+class TestStaticFileAccess {
+
+    enum class MyRole : RouteRole { ROLE_ONE, ROLE_TWO}
+
+    private fun addAuthentication(config: JavalinConfig) {
+        config.router.mount {
+            it.beforeMatched { ctx ->
+                val role: RouteRole? = ctx.queryParam("role")?.let { role -> MyRole.valueOf(role) }
+                val routeRoles = ctx.routeRoles()
+                if (routeRoles != emptySet<RouteRole>() && role !in routeRoles) {
+                    throw UnauthorizedResponse()
+                }
+            }
+        }
+    }
+
+    private val defaultStaticResourceApp: Javalin by lazy {
+        Javalin.create {
+            it.staticFiles.add("/public", Location.CLASSPATH, roles = setOf(MyRole.ROLE_ONE))
+            addAuthentication(it)
+        }
+    }
+
+    private val passingStaticFileConfigResourceApp: Javalin by lazy {
+        Javalin.create {
+            it.staticFiles.add { staticFileConfig ->
+                staticFileConfig.hostedPath = "/"
+                staticFileConfig.directory = "/public"
+                staticFileConfig.location = Location.CLASSPATH
+                staticFileConfig.roles = setOf(MyRole.ROLE_ONE)
+            }
+            addAuthentication(it)
+        }
+    }
+
+    private val externalStaticResourceApp: Javalin by lazy {
+        Javalin.create {
+            it.staticFiles.add("src/test/external/", Location.EXTERNAL, roles = setOf(MyRole.ROLE_ONE))
+            addAuthentication(it)
+        }
+    }
+    private val multiLocationStaticResourceApp: Javalin by lazy {
+        Javalin.create {
+            it.staticFiles.add("src/test/external/", Location.EXTERNAL, roles = setOf(MyRole.ROLE_ONE))
+            it.staticFiles.add("/public/immutable", Location.CLASSPATH, roles = setOf(MyRole.ROLE_TWO))
+            addAuthentication(it)
+        }
+    }
+
+    @Test
+    fun `Authentication works for overlapping route and file name`() = TestUtil.test(defaultStaticResourceApp) { app, http ->
+        app.get("/file") { it.result("Test Route") }
+        assertThat(callWithRole(http.origin, "/file", role = "ROLE_ONE")).isEqualTo("Test Route")
+    }
+
+    @Test
+    fun `Authentication works for passing static config`() = TestUtil.test(passingStaticFileConfigResourceApp) { _, http ->
+        assertThat(callWithRole(http.origin, "/html.html", "ROLE_TWO")).isEqualTo(UNAUTHORIZED.message)
+        assertThat(http.get("/html.html?role=ROLE_ONE").httpCode()).isEqualTo(HttpStatus.OK)
+        assertThat(http.get("/html.html?role=ROLE_ONE").headers.getFirst(Header.CONTENT_TYPE)).contains(ContentType.HTML)
+        assertThat(http.getBody("/html.html?role=ROLE_ONE")).contains("HTML works")
+    }
+
+    @Test
+    fun `Authentication works for classPath location`() = TestUtil.test(defaultStaticResourceApp) { _, http ->
+        assertThat(callWithRole(http.origin, "/html.html", "ROLE_TWO")).isEqualTo(UNAUTHORIZED.message)
+        assertThat(http.get("/html.html?role=ROLE_ONE").httpCode()).isEqualTo(HttpStatus.OK)
+        assertThat(http.get("/html.html?role=ROLE_ONE").headers.getFirst(Header.CONTENT_TYPE)).contains(ContentType.HTML)
+        assertThat(http.getBody("/html.html?role=ROLE_ONE")).contains("HTML works")
+    }
+
+    @Test
+    fun `Authentication works for external location`() = TestUtil.test(externalStaticResourceApp) { _, http ->
+        assertThat(callWithRole(http.origin, "/html.html", "ROLE_TWO")).isEqualTo(UNAUTHORIZED.message)
+        assertThat(http.get("/html.html?role=ROLE_ONE").httpCode()).isEqualTo(HttpStatus.OK)
+        assertThat(http.get("/html.html?role=ROLE_ONE").headers.getFirst(Header.CONTENT_TYPE)).contains(ContentType.HTML)
+        assertThat(http.getBody("/html.html?role=ROLE_ONE")).contains("HTML works")
+    }
+
+    @Test
+    fun `Authentication works for multiple location`() = TestUtil.test(multiLocationStaticResourceApp) { _, http ->
+        assertThat(callWithRole(http.origin, "/txt.txt", "ROLE_TWO")).isEqualTo(UNAUTHORIZED.message)
+        assertThat(callWithRole(http.origin, "/txt.txt", "ROLE_ONE")).isEqualTo(
+            File("src/test/external/txt.txt").takeIf { it.exists() }?.readText())
+        assertThat(http.get("/txt.txt?role=ROLE_ONE").httpCode()).isEqualTo(HttpStatus.OK)
+
+        val content = javaClass.classLoader.getResourceAsStream("public/immutable/library-1.0.0.min.js")
+            ?.bufferedReader()
+            ?.use { it.readText() }
+        assertThat(callWithRole(http.origin, "/library-1.0.0.min.js", "ROLE_ONE")).isEqualTo(UNAUTHORIZED.message)
+        assertThat(callWithRole(http.origin, "/library-1.0.0.min.js", "ROLE_TWO")).isEqualTo(content)
+        assertThat(http.get("/library-1.0.0.min.js?role=ROLE_TWO").httpCode()).isEqualTo(HttpStatus.OK)
+    }
+
+    private fun callWithRole(origin: String, path: String, role: String) =
+        Unirest.get(origin + path).queryString("role", role).asString().body
+}