Integrationstests mit Testcontainers und Keycloak

Wie das nach Konferenzen so ist: Es kribbelt in den Fingern und man möchte die Dinge schnell ausprobieren. Auf der diesjährigen Javaland gab es einen spannenden Talk zum Thema „Integrationstests mit Docker und Testcontainers“. Wir haben Testcontainers mal etwas genauer unter die Lupe genommen.

Was ist Testcontainers?

Testcontainers ist eine Java-Bibliothek die es uns ermöglicht, grundsätzlich alles, was in einem Docker-Container laufen kann, innerhalb eines JUnit Tests als isolierte Testumgebung zu nutzen. Damit werden viele Dinge sehr viel einfacher.

So können wir containerisierte Datenbanken im Rahmen eines automatisierten Tests zur Verfügung stellen und Integrationstests gegen einen „Wegwerf“-Container laufen lassen. Testcontainers nimmt uns hierbei einen Großteil der Arbeit ab. So steuert das Framework den kompletten Lebenszyklus des Containers innerhalb des Tests.

Neben JUnit4 wird auch JUnit5 und Spock als Testframework unterstützt.

Was ist Keycloak?

Keycloak ist ein von Red Hat entwickeltes Open-Source Identity- und Access Management. Keycloak bietet out-of-the box eine Vielzahl an Funktionen, unter Anderem:

  • Single-Sign On
  • Authentifizierung auf Basis von OpenID Connect, OAuth 2.0 und SAML 2.0
  • Zentralisiertes Management
  • Zahlreiche Adaptoren unter Anderem für Spring-Security
  • LDAP und Active Directory-Anbindung
  • Social Logins

Das Testszenario

Das Szenario ist relativ simpel gehalten. Wir haben eine Spring Boot-Applikation mit aktiviertem „Actuator“. Die Anforderung ist:

Alle Actuator-Endpunkte sollen vor einem unbefugten Zugriff geschützt werden. Zur Absicherung unserer API verwenden wir Spring-Security und integrieren Keycloak als Identity- und Accessmanagement. Die Actuator Endpunkte sollen dahingehend abgesichert werden, dass ein Zugriff nur mit validem JWT-Token möglich ist. Zudem muss der User einer „Monitoring“-Rolle zugewiesen werden.

Den vollständigen Code unseres Szenarios findet ihr auf Github.

First things first – die Keycloak Konfiguration

Nachdem wir die Spring Boot-Applikation soweit konfiguriert haben, stellt sich die Frage, wie wir nun einen Keycloak-Container für den Test gestartet bekommen. Ein unkonfigurierter Keycloak-Server würde uns hier nicht weiter bringen, da unser Szenario ein gewisses Setup voraussetzt.

Ein vorkonfigurierter Realm

So benötigen wir einen konfigurierten Realm mit entsprechend vorbereiteten Clients und hinterlegten Gruppen und Rollen. Details zu den jeweiligen Konzepten von Keycloak findet ihr in der Keycloak Dokumentation. Wir starten also zunächst einen Keycloak-Container mit entsprechender Standardkonfiguration, um darin dann die notwendigen Anpassungen durchzuführen:

 
$ docker run --detach --publish-all \ 
    --env KEYCLOAK_USER=admin \ 
    --env KEYCLOAK_PASSWORD=admin \
    jboss/keycloak:4.6.0.Final

Keycloak bietet uns die Möglichkeit, unser Setup zu exportieren:

Keycloak realm Export zur Vorbereitung für testcontainers

Und erfreulicherweise können wir beim Start des Containers die Realm Konfiguration über eine Umgebungsvariable importieren:

 
$ docker run --detach --publish-all \
    --env KEYCLOAK_USER=admin \ 
    --env KEYCLOAK_PASSWORD=admin \
    --env KEYCLOAK_IMPORT=/tmp/example-realm.json \ 
    --volume /tmp/example-realm.json:/tmp/example-realm.json \ 
    jboss/keycloak:4.6.0.Final

Im Anschluss daran haben wir einen Keycloak Container mit einer entsprechenden Testkonfiguration unseres Realms. Das ist schon einmal eine sehr gute Basis und hält unseren Test entsprechend schlank.

Keine User, keine Kekse

Unser Szenario setzt allerdings voraus, dass entsprechende Benutzer in Keycloak hinterlegt sind. Diese können allerdings nicht über die zuvor verwendete Export-Funktion exportiert werden.

Keycloak kommt allerdings mit einer entsprechenden Admin-CLI daher. Darüber haben wir die Möglichkeit, die von uns benötigten Benutzer für einen Test anzulegen. Wir haben also ein kleines Script geschrieben. Dieses Script können wir laufen lassen, sobald der Keycloak Container erfolgreich hochgefahren ist. Hierfür müssen wir den Container allerdings noch einmal neu starten, da ihm das Script bisher noch nicht zur Verfügung steht:

 
$ docker run 
    --env KEYCLOAK_USER=admin \ 
    --env KEYCLOAK_PASSWORD=admin \
    --env KEYCLOAK_IMPORT=/tmp/example-realm.json \
    --volume /tmp/example-realm.json:/tmp/example-realm.json \
    --volume /tmp/create-keycloak-user.sh:/opt/jboss/create-keycloak-user.sh \
    jboss/keycloak:4.6.0.Final

Nachdem der Container gestartet und Keycloak verfügbar ist, legen wir unsere Benutzer an:

 
$ docker exec <container_name> "sh /opt/jboss/create-keycloak-user.sh"

Et voilá! Wir haben einen Testuser!

Anlage eines Testusers für den Integrtationstest

Damit wir die Schritte nicht immer wieder manuell ausführen müssen, haben wir das komplette Setup in ein Dockerfile übertragen:

FROM jboss/keycloak:4.6.0.Final

ENV KEYCLOAK_USER=admin
ENV KEYCLOAK_PASSWORD=admin
ENV KEYCLOAK_IMPORT=/tmp/realm-export.json

ADD realm-export.json /tmp/realm-export.json
ADD create-keycloak-user.sh /opt/jboss/create-keycloak-user.sh

Wir haben nun alle notwendigen Vorbereitungen getroffen und können mit unserem Integrationstest starten.

Wie starte ich einen Keycloak Container via Testcontainers?

Die API von Testcontainers ist gut dokumentiert und leicht verständlich. Um unseren Keycloak Container zu starten, konfigurieren wir eine JUnit ClassRule:

public class KeycloakContainerTest {

  @ClassRule
  public static final GenericContainer keycloak =
      new GenericContainer("jboss/keycloak:4.6.0.Final")
          .withExposedPorts(8080)
          .withEnv("KEYCLOAK_USER", "admin")
          .withEnv("KEYCLOAK_PASSWORD", "admin")
          .withEnv("KEYCLOAK_IMPORT", "/tmp/realm.json")
          .withClasspathResourceMapping("realm-export.json", "/tmp/realm.json", BindMode.READ_ONLY)
          .withCopyFileToContainer(MountableFile.forClasspathResource("create-keycloak-user.sh", 700),
              "/opt/jboss/create-keycloak-user.sh")
          .waitingFor(Wait.forHttp("/auth"));
  protected static String keycloakHost;

  @BeforeClass
  public static void setupKeycloakContainer() throws IOException, InterruptedException {
    keycloakHost = "http://" + keycloak.getContainerIpAddress() + ":" + keycloak.getMappedPort(8080);
    Container.ExecResult commandResult = keycloak.execInContainer("sh", "/opt/jboss/create-keycloak-user.sh");
    assert commandResult.getExitCode() == 0;

  }
}

Wir haben uns für eine ClassRule entschieden, da wir den Keycloak-Container nicht bei jedem Test erneut starten wollen. Das reduziert die Laufzeit des Tests und ist in diesem Szenario vertretbar, da wir nur lesend auf Keycloak zugreifen. Es besteht auch die Möglichkeit, den Container als Rule zu konfigurieren. Hier würde dann vor jedem Test ein neuer Container hoch- und nach dem Test wieder heruntergefahren.

Testcontaineres bietet viele coole Features, die in unserem Setup sehr nützlich sind. So kann ich per „wait strategy“ sehr gut steuern, wann mein Container bereit ist und ich mit der Testausführung starten kann.

Da Keycloak seine Zeit benötigt, bis der komplette Initialisierungsprozess durch ist, soll der Test erst starten, wenn wir per Request auf /auth eine Response mit dem Statuscode 200 erhalten. Wir konfigurieren dies mit:

.waitingFor(Wait.forHttp("/auth"));

Nachdem Keycloak nun „online“ ist, müssen wir nur noch unsere User anlegen:

Container.ExecResult commandResult = keycloak.execInContainer("sh", "/opt/jboss/create-keycloak-user.sh");

Und fertig ist unser Keycloak-Testcontainer 🚀

Der finale Integrationstest

Nachdem unser Container nun bereit steht, können wir den Integrationstest schreiben. Wir möchten zunächst zwei Szenarien abdecken:

1.) Ein Zugriff auf den Actuator-Endpunkt ohne JWT-Token soll mit einem 401 Unauthorized abgewiesen werden

2.) Ein Zugriff auf den Actuator-Endpunkt mit JWT-Token und einem zulässigen User soll mit einem 200 success beantwortet werden.

Der erste Test ist relativ schnell aufgesetzt. Denn hier kann ich den Actuator-Endpunkt einfach ohne Angabe eines Tokens aufrufen. Die entsprechende Spring Security-Konfiguration greift und watscht uns entsprechend ab:

@Test
public void anAnonymousClientIsNotAuthorized() throws Exception {
  overwriteKeyCloakAuthServerUrl();
  this.mockMvc.perform(get("/actuator/info"))
    .andExpect(status().isUnauthorized());
}

Für den zweiten Test benötigen wir zunächst ein entsprechendes Token. Da wir nun einen vollfunktionsfähigen Keycloak-Server im Rücken haben, geht das relativ einfach von der Hand. Wir holen uns das Token direkt von unserem Keycloak-Container:

protected static String getAccessToken(String username, String password, String clientId, String realm) {
   var restTemplate = new RestTemplate();
   var headers = new HttpHeaders();
   headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

   var map = new LinkedMultiValueMap();
   map.add("grant_type", "password");
   map.add("client_id", clientId);
   map.add("username", username);
   map.add("password", password);
   var token = restTemplate.postForObject(keycloakHost + "/auth/realms/" + realm + "/protocol/openid-connect/token",
       new HttpEntity(map, headers), KeyCloakToken.class);

   assert token != null;
   return token.getAccessToken();
 }

Ohne weitere Anpassung hättten wir jetzt zwar ein entsprechendes Token, unsere Applikation würde das Token allerdings als unzulässig abweisen. Der Grund: Die Applikation ist noch nicht mit unserem Keycloak-Container verbunden sondern erwartet in unserer Default-Konfiguration, dass der Keycloak-Server unter http://localhost:9002/auth erreichbar ist.

Wir müssen die Konfiguration in unserem Testsetup also überschreiben. Da das Port-Binding des Testcontainers dynamisch ist, überschreiben wir die entsprechenden Properties in unserem Test.

@Test
public void aClientWithMonitoringRoleIsOk() throws Exception {
  overwriteKeyCloakAuthServerUrl();
  this.mockMvc.perform(get("/actuator/info")
      .header("Authorization", "Bearer " + getAccessToken("monitoring-user", "default", "demo-frontend", "demo")))
      .andExpect(status().isOk());
}

private void overwriteKeyCloakAuthServerUrl() {
  keycloakConfiguration.getProperties().setAuthServerUrl(keycloakHost + "/auth");
}

Nun ist alles sauber konfiguriert und unser Testlauf quittiert das mit zwei grünen Haken!

JUnit Testergebnisse des Integrationstests mit testcontainers

Testcontainers kümmert sich im Nachhinein darum, den gestarteten Container abzuräumen, sodass keine „Container-Leichen“ auf dem System verweilen.

Fazit

Testcontainers macht einen sehr guten Eindruck und hebt das Thema Integrationstests auf ein neues Level. Grundsätzlich ist das Setup sehr einfach. Die Komplexität steckt eher im jeweiligen Szenario. Wie gut sich ein System testen lässt, hängt sehr stark davon ab, welche Möglichkeiten mir der jeweilige Container zur Konfiguration bietet. Entsprechend einfach oder komplex kann das jeweilige Testsetup werden. Wir empfehlen, so viel Konfiguration wie möglich in den jeweiligen Container auszulagern, um den Test entsprechend schlank zu halten.

Durch die sehr gute Test Framework-Integration, können die Tests auch automatisiert auf einem Build-Server laufen.

Links

0 Antworten

Hinterlassen Sie einen Kommentar

Wollen Sie an der Diskussion teilnehmen?
Feel free to contribute!

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.