はじめに
こんにちは。ウェルスナビでフルスタックエンジニアの高原です。 前回の記事はKeycloak のカスタマイズについて解説しました。 続きまして、今回は Keycloak のテストについて説明します。
対象の読者
以下のような方を想定しています。
- バックエンドエンジニア
- フロントエンドエンジニア
- インフラエンジニア
- DevOps
背景
前回の記事で説明した通り、Keycloak はSPI (Service Provider Interface)を実装することで様々なユースケースに対応できるよう各処理をカスタマイズできます。そして、アプリケーションをデプロイする際には、Keycloak の Docker イメージが必要です。そのため、テストを行う際にも Keycloak の Docker 環境を設定する必要があります。 この問題を解決するために、Keycloak の testcontainerを導入します。これにより、テスト実行時に Keycloak のイメージを簡単に設定できます。また、外部システム API リクエストをモックするため、MockServerを導入し、モックレスポンスを作成できます。 この記事では、Keycloak のテスト環境を構築し、外部システムと連携やアプリケーションの挙動をテストする手法について説明します。
テスト環境を構築
テストコードの構築
前回作成したextensions
ディレクトリにtest
コードを追加します。
テストコードの構築
keycloak_customize ├── ... ├── extensions │ ├── src │ │ ├── main │ │ └── test │ │ ├── java │ │ │ └── com.wealthnavi.keycloak │ │ │ ├── KeycloakIntegrationTest.java (テスト実行用のクラス) │ │ │ └── KeycloakTestSupport.java (テストのサポートクラス) │ │ └── resources │ │ ├── log4j.properties (ログの設定) │ │ ├── realm-export.json (テスト用のレルム設定) │ │ └── testcontainers.properties (テストコンテナの設定) │ └── pom.xml (ライブラリの定義ファイル) ├── ...
ライブラリを導入
まずはpom.xml
にテスト用のライブラリを導入します。
POMファイルを設定
<!-- Keycloakのテストコンテナ --> <dependency> <groupId>com.github.dasniko</groupId> <artifactId>testcontainers-keycloak</artifactId> <version>${keycloak.testcontainer.version}</version> <scope>test</scope> </dependency> <!-- モックサーバー --> <dependency> <groupId>org.mock-server</groupId> <artifactId>mockserver-netty-no-dependencies</artifactId> <version>${mockserver.version}</version> <scope>test</scope> </dependency> <!-- テスト用のユーティリティー--> <dependency> <groupId>io.rest-assured</groupId> <artifactId>rest-assured</artifactId> <version>${rest-assured.version}</version> <scope>test</scope> </dependency> <dependency> <groupId>commons-io</groupId> <artifactId>commons-io</artifactId> <version>${common.io.version}</version> <scope>test</scope> </dependency> <dependency> <groupId>org.slf4j</groupId> <artifactId>slf4j-simple</artifactId> <version>${slf4j.version}</version> <scope>test</scope> </dependency> <!-- JUnitとassertライブラリ --> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter</artifactId> <version>${junit.jupiter.version}</version> <scope>test</scope> </dependency> <dependency> <groupId>org.assertj</groupId> <artifactId>assertj-core</artifactId> <version>${assertj.version}</version> <scope>test</scope> </dependency>
環境構築
ネットワークを作成
Keycloak のカスタマイズをデプロイする際、ネットワークの構成は次の通りです。
そのため、各コンポーネントをテスト用に設定します。
- モックサーバーのテストコンテナ
- メールサーバーのテストコンテナ
- Keycloak のテストコンテナ
テストコードのKeycloakTestSupport.java
にネットワーク定義を追加します。
ネットワークを作成
@Slf4j class KeycloakTestSupport { private static final Network network = Network.newNetwork(); ... }
モックサーバーのテストコンテナを作成
次に、モックサーバーのテストコンテナを作成します。
Docker イメージのバージョンは pom ファイルのmockserver.version
と同じにします。
先ほど作成したネットワークを使用します。
KeycloakTestSupport.java
ファイルに以下の定義を追加します。
モックサーバーのテストコンテナを作成
private static final String mockServerAlias = "mockserver"; private static final int mockServerPort = 1080; private static final List<MockServerClient> mockServerClients = new ArrayList<>(); private static final MockServerContainer mockServer = new MockServerContainer(DockerImageName.parse("mockserver/mockserver:5.15.0")) .withNetwork(network) .withNetworkAliases(mockServerAlias) .withExposedPorts(mockServerPort) .withReuse(true); private static final String mockServerUrl = "http://" + mockServerAlias + ":" + mockServerPort;
メールサーバーのテストコンテナを作成
アプリをデプロイする際、MailHog
を使用するため、テスト時にMailHog
のテストコンテナを作成します。
モックサーバーの同じネットワーク構成を利用します。
KeycloakTestSupport.java
ファイルに以下の定義を追加します。
メールサーバーのテストコンテナを作成
private static final String mailHogServerAlias = "mailhog"; private static final int mailHogServerPort = 1025; private static final int mailHogServerWebPort = 8025; private static final GenericContainer<?> mailhogServer = new GenericContainer<>("mailhog/mailhog") .withNetwork(network) .withNetworkAliases(mailHogServerAlias) .withExposedPorts(mailHogServerPort, mailHogServerWebPort) .withReuse(true);
Keycloak のテストコンテナを作成
まず、テスト用のレルムを設定しましょう。 Keycloak のカスタマイズをデプロイした後、レルムの設定ファイルを出力します。
Keycloak Admin Console
にアクセスし、管理者権限でログインします。- テスト用の
test
レルムに切り替え、Configure
からRealm settings
に移動します。 Action
をクリックし、Partial export
を選択します。- 表示するポップアップに
Include clients
を有効にし、Export
ボタンをクリックし、ファイルを出力します。
次は、出力したファイルの設定を変更します。
- ファイルを開き、以下のテストユーザーを追加します。
テストユーザーを追加
"users": [ { "username": "taro", "enabled": true, "email": "taro@example.com", "firstName": "Taro", "lastName": "", "credentials": [ { "type": "password", "value": "taro123456" } ], "realmRoles": [ "user", "offline_access" ], "clientRoles": { "account": [ "manage-account" ] } }, { "username": "test1", "enabled": true, "email": "test1@example.com", "firstName": "Test1", "lastName": "", "credentials": [ { "type": "password", "value": "test123456" } ], "realmRoles": [ "user", "offline_access" ], "clientRoles": { "account": [ "manage-account" ] } } ]
authenticatorConfig
の一覧を開き、"alias": "External Authenticator"
アイテムのconfig
を以下のように変更します。
認証部分を設定
"config": { "risk-analyze-api-url": "http://mockserver:1080/api/v1/wealthnavi/risk_analyze", "wealthnavi-check-api-url": "http://mockserver:1080/api/v1/wealthnavi/verify_user" }
- 次に、
"alias": "custom registration with verify WealthNavi user existence",
アイテムのconfig
を以下のように変更します。
登録部分を設定
"config": { "wealthnavi-create-user-api-url": "http://mockserver:1080/api/v1/wealthnavi/create_user", "wealthnavi-check-api-url": "http://mockserver:1080/api/v1/wealthnavi/verify_user" }
- 最後に、これらの変更をテストリソースディレクトリに保存します。
Keycloak のテストコンテナを作成する際には、モックサーバーテストコンテナと MailHog テストコンテナも同じネットワーク構成を利用します。さらに、外部システムの API エンドポイントについては、モックサーバー用のエンドポイントを使用します。
KeycloakTestSupport.java
ファイルに以下の定義を追加します。
Keycloakのテストコンテナを作成
private static final KeycloakContainer keycloakContainer = new KeycloakContainer() .withNetwork(network) .withRealmImportFile("realm-export.json") .withProviderClassesFrom("target/classes") .withEnv("KC_SPI_REQUIRED_ACTION_TERMS_AND_CONDITIONS_ACTION_WEALTHNAVI_UPDATE_TERMS_AND_CONDITIONS_API_URL", mockServerUrl + "/api/v1/wealthnavi/update_terms_and_conditions") .withEnv("KC_SPI_REQUIRED_ACTION_UPDATE_PASSWORD_ACTION_WEALTHNAVI_UPDATE_PASSWORD_API_URL", mockServerUrl + "/api/v1/wealthnavi/update_password") .withEnv("KC_SPI_REQUIRED_ACTION_VERIFY_EMAIL_BY_CODE_ACTION_WEALTHNAVI_UPDATE_STATUS_API_URL", mockServerUrl + "/api/v1/wealthnavi/update_status") .withReuse(true);
テストコードを書く
実行前、実行後の設定
テストを書く際には、@BeforeAll と@AfterAll を使用して、テストの実行前と実行後に設定することが多いです。そのため、KeycloakTestSupport.java
ファイルにsetup
とstop
メソッドを実装します。
setup
メソッドでは Keycloak の各フローを設定し、さらにモックサーバテストコンテナやMailHog
テストコンテナを起動します。そして、stop
メソッドでは、各テストコンテナを停止させます。
テストの実行前、実行後の設定
private static final String realmName = "test"; private static final String browserFlow = "Custom browser"; private static final String registrationFlow = "Custom registration"; private static final String resetCredentialsFlow = "Custom reset credentials"; static void setup() { log.info("set up flows for keycloak container"); keycloakContainer.start(); keycloakContainer.followOutput(new Slf4jLogConsumer(log)); var keycloakAdminClient = keycloakContainer.getKeycloakAdminClient(); var realm = keycloakAdminClient.realm(realmName); var realmPresentation = realm.toRepresentation(); realmPresentation.setBrowserFlow(browserFlow); realmPresentation.setRegistrationFlow(registrationFlow); realmPresentation.setResetCredentialsFlow(resetCredentialsFlow); realm.update(realmPresentation); keycloakAdminClient.close(); log.info("create mock server client"); mockServer.start(); mockServer.followOutput(new Slf4jLogConsumer(log)); log.info("create mailhog server client"); mailhogServer.start(); mailhogServer.followOutput(new Slf4jLogConsumer(log)); } static void stop() { log.info("stopMockServer"); mockServerClients.forEach(MockServerClient::stop); mockServerClients.clear(); mockServer.stop(); log.info("stopKeycloakContainer"); keycloakContainer.stop(); log.info("stopMailhogServer"); mailhogServer.stop(); }
登録フローのテスト
最初に、ユーザー登録フローのテストを実施します。テストを開始する前に、フローのシーケンスを想定しましょう。以下にその手順を示します。
外部システムのモックレスポンスを作成
登録シーケンス図から、外部システムが以下の処理を行います。
- ユーザー存在確認
- 新規ユーザーを作成
- メール認証のステータスを更新
- 利用規約の承諾ステータスを更新
上記の処理に対応するため、各リクエストに合わせて、モックレスポンスを作成します。
KeycloakTestSupport.java
ファイルにcreateRegistrationMockResponses
メソッドを追加します。
モックレスポンスを作成
static void createRegistrationMockResponses() { log.info("create registration mock responses"); mockServer.stop(); mockServerClients.clear(); mockServer.start(); mockServer.followOutput(new Slf4jLogConsumer(log)); var mockVerifyExistedUserClient = new MockServerClient(mockServer.getHost(), mockServer.getServerPort()); mockServerClients.add(mockVerifyExistedUserClient); mockVerifyExistedUserClient.when( request() .withMethod("POST") .withPath("/api/v1/wealthnavi/verify_user") .withBody(json("{\"emailAddress\" : \"test1@example.com\"}", MatchType.STRICT)) ).respond( response() .withStatusCode(HttpStatusCode.OK_200.code()) .withBody("{\"isWealthNaviUser\": true}", MediaType.APPLICATION_JSON) ); var mockVerifyNotExistedUserClient = new MockServerClient(mockServer.getHost(), mockServer.getServerPort()); mockServerClients.add(mockVerifyNotExistedUserClient); mockVerifyNotExistedUserClient.when( request() .withMethod("POST") .withPath("/api/v1/wealthnavi/verify_user") .withBody(json("{\"emailAddress\" : \"test@example.com\"}", MatchType.STRICT)) ).respond( response() .withStatusCode(HttpStatusCode.OK_200.code()) .withBody("{\"isWealthNaviUser\": false}", MediaType.APPLICATION_JSON) ); var mockCreateUserClient = new MockServerClient(mockServer.getHost(), mockServer.getServerPort()); mockServerClients.add(mockCreateUserClient); mockCreateUserClient.when( request() .withMethod("POST") .withPath("/api/v1/wealthnavi/create_user") .withBody(json("{\"email\" : \"test@example.com\"}", MatchType.ONLY_MATCHING_FIELDS)) ).respond( response() .withStatusCode(HttpStatusCode.OK_200.code()) .withBody("{\"isSuccess\": true}", MediaType.APPLICATION_JSON) ); var mockCreateUserFailedClient = new MockServerClient(mockServer.getHost(), mockServer.getServerPort()); mockServerClients.add(mockCreateUserFailedClient); mockCreateUserClient.when( request() .withMethod("POST") .withPath("/api/v1/wealthnavi/create_user") .withBody(json("{\"email\" : \"test2@example.com\"}", MatchType.ONLY_MATCHING_FIELDS)) ).respond( response() .withStatusCode(HttpStatusCode.OK_200.code()) .withBody("{\"isSuccess\": false}", MediaType.APPLICATION_JSON) ); var mockUpdateStatusClient = new MockServerClient(mockServer.getHost(), mockServer.getServerPort()); mockServerClients.add(mockUpdateStatusClient); mockUpdateStatusClient.when( request() .withMethod("POST") .withPath("/api/v1/wealthnavi/update_status") .withBody(json("{\"email\" : \"test@example.com\", \"isVerified\": true}", MatchType.ONLY_MATCHING_FIELDS)) ).respond( response() .withStatusCode(HttpStatusCode.OK_200.code()) .withBody("{\"isSuccess\": true}", MediaType.APPLICATION_JSON) ); var mockUpdateTermClient = new MockServerClient(mockServer.getHost(), mockServer.getServerPort()); mockServerClients.add(mockUpdateTermClient); mockUpdateTermClient.when( request() .withMethod("POST") .withPath("/api/v1/wealthnavi/update_terms_and_conditions") .withBody(json("{\"isAgreed\": true}", MatchType.ONLY_MATCHING_FIELDS)) ).respond( response() .withStatusCode(HttpStatusCode.OK_200.code()) .withBody("{\"isSuccess\": true}", MediaType.APPLICATION_JSON) ); }
登録のテストコードを書く
登録シーケンス図に基づき、ユーザー側の手順を想定します。
- Keycloak のログインページを開き、登録ボタンをクリック、登録ページに移動
- ユーザーの登録情報を入力して、登録ボタンをクリック、登録リクエストを送信
- 認証コード画面が表示され、メールサーバーをアクセスし、コードを入力
- 利用規約の承諾画面が表示され、承諾して、登録完了
上記の想定に基づき、各ステップのテストコードを書いていきます。
1. 登録ページのリンクを取得
OpenID の標準に基づき、ログインのbase url
を利用して Keycloak の設定ファイルにアクセスし、authorization_endpoint
項目を取得します。
KeycloakIntegrationTest.java
ファイルに以下の実装を追加します。
ログインリンクを取得
private static String getOpenIdConfigurationUrl(String config) { return given() .get(KeycloakTestSupport.configurationUrl()) .then() .statusCode(HttpStatusCode.OK_200.code()) .extract() .path(config); } private static String getLoginUrl() { return getOpenIdConfigurationUrl("authorization_endpoint"); }
configurationUrl
がKeycloakTestSupport.java
に定義されます。
configurationUrlの定義
static String configurationUrl() { return keycloakContainer.getAuthServerUrl() + "/realms/" + realmName + "/.well-known/openid-configuration"; }
ログインのbase url
を取得した後、テスト用のfront_end
クライアントでリクエストし、HTML レスポンスを受け取ります。そこからregex
を利用して登録ページのリンクを取得します。
KeycloakIntegrationTest.java
ファイルに以下の実装を追加します。
認証リンクを取得
private static Response openLoginPage(String loginUrl) { return given() .headers(KeycloakTestSupport.getHeaders()) .queryParams(KeycloakTestSupport.getLoginQueryParams()) .get(loginUrl) .then() .statusCode(HttpStatusCode.OK_200.code()) .extract() .response(); } private static String getAuthLinks(String type, String pageResponse) { if ("login".equals(type)) return KeycloakTestSupport.extractLink("action=\"([^\"]*)\"", pageResponse, 1); else if ("register".equals(type)) return KeycloakTestSupport.extractLink("href=\"(/auth)?(/realms/[^\"]*/login-actions/registration[^\"]*)\"", pageResponse, 2); else if ("reset-password".equals(type)) return KeycloakTestSupport.extractLink("href=\"(/auth)?(/realms/[^\"]*/login-actions/reset-credentials[^\"]*)\"", pageResponse, 2); else return null; }
getHeaders
とgetLoginQueryParams
がKeycloakTestSupport.java
に定義されます。
getHeadersとgetLoginQueryParamsの定義
static final String redirectUri = "http://localhost:8080"; static Map<String, String> getHeaders() { return new HashMap<>() { @Serial private static final long serialVersionUID = -3042611989483299865L; { put("Accept", "text/html,application/xhtml+xml,application/xml"); put("Accept-Encoding", "gzip, deflate"); put("Accept-Language", "en-US,en;q=0.5"); put("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.8; rv:16.0) Gecko/20100101 Firefox/16.0"); } }; } static Map<String, String> getLoginQueryParams() { return new HashMap<>() { @Serial private static final long serialVersionUID = -2967571483916088610L; { put("login", "true"); put("response_type", "code"); put("client_id", "front_end"); put("state", UUID.randomUUID().toString()); put("redirect_uri", KeycloakTestSupport.redirectUri); put("scope", "openid"); } }; }
まとめて、KeycloakIntegrationTest.java
ファイルにtestRegistration
の実装を追加します。
登録ページのリンクを取得
@Test void testRegistration() { log.info("test registration"); KeycloakTestSupport.createRegistrationMockResponses(); String loginUrl = getLoginUrl(); // get register link Response loginPageResponse = openLoginPage(loginUrl); String registerLink = KeycloakTestSupport.getBaseUrl() + getAuthLinks("register", loginPageResponse.asString()); log.info("registerLink: {}", registerLink); ... }
getBaseUrl
がKeycloakTestSupport.java
に定義されます。
getBaseUrlの定義
static String getBaseUrl() { return keycloakContainer.getAuthServerUrl(); }
2. 登録リクエストを送信
登録ページの HTML レスポンスを受け取り、それからregex
を利用して、登録リンクを取得します。
ユーザー情報を登録した後、登録リクエストを送信します。Keycloak はセキュリティを強化するため、パラメータはこの段階で code 要素に設定されていますので、同様に cookies も統一されます。
KeycloakIntegrationTest.java
ファイルに以下の実装を追加します。
登録リクエストを送信
private static Response openPage(String url, Map<String, String> cookies) { return given() .cookies(cookies) .headers(KeycloakTestSupport.getHeaders()) .get(url) .then() .statusCode(HttpStatusCode.OK_200.code()) .extract() .response(); } private static Response register(String registerLink, Map<String, String> cookies, String firstName, String lastName, String email, String userName, String password) { Response registerPageResponse = openPage(registerLink, cookies); String registerPageLink = getAuthLinks("login", registerPageResponse.asString()); log.info("registerPageLink: {}", registerPageLink); return given() .cookies(cookies) .headers(KeycloakTestSupport.getHeaders()) .formParam("firstName", firstName) .formParam("lastName", lastName) .formParam("email", email) .formParam("username", userName) .formParam("password", password) .formParam("password-confirm", password) .post(registerPageLink); }
まとめて、KeycloakIntegrationTest.java
ファイルにtestRegistration
の実装を追加します。
登録が成功すると、コード認証画面に移動し、レスポンスからLocation
ヘッダーに含まれるコード入力するページのリンクを取得できます。
登録リクエストを検証
@Test void testRegistration() { ... // test return error if required fields are empty register(registerLink, loginPageResponse.cookies(), "", "", "", "", "") .then() .statusCode(HttpStatusCode.OK_200.code()) .extract() .path("errorMessage", Errors.INVALID_REGISTRATION); // test return error if username is existed register(registerLink, loginPageResponse.cookies(), "taro", "test", "test@example.com", "taro", "test12345678") .then() .statusCode(HttpStatusCode.BAD_REQUEST_400.code()) .extract() .path("errorMessage", Errors.USERNAME_IN_USE); // test return error is user is existed register(registerLink, loginPageResponse.cookies(), "test1", "test1", "test1@example.com", "test1", "test12345678") .then() .statusCode(HttpStatusCode.OK_200.code()) .extract() .path("errorMessage", Errors.EMAIL_IN_USE); // test return error if cannot create user in external system register(registerLink, loginPageResponse.cookies(), "test2", "test2", "test2@example.com", "test2", "test12345678") .then() .statusCode(HttpStatusCode.BAD_REQUEST_400.code()) .extract() .path("errorMessage", Errors.INVALID_REGISTRATION); // test redirect if registration is successful Response successRegisterResponse = register(registerLink, loginPageResponse.cookies(), "test", "test", "test@example.com", "test", "test123456"); successRegisterResponse.then() .statusCode(HttpStatusCode.FOUND_302.code()); String submitCodeUrl = successRegisterResponse.header("Location"); log.info("submitCodePage: {}", submitCodeUrl); ... }
3. 認証コードを検証
コード入力ページの HTML レスポンスを受け取り、それからregex
を利用して、コード認証リンクが取得できます。
さらに、コードを取得するために、MailHog
サーバーの API を使用して、認証コードが含まれたメールにアクセスできます。その後、regex
を使用してコードを取得します。
KeycloakIntegrationTest.java
ファイルにgetVerifyEmailCode
の実装を追加します。
認証コードを取得
private static String getVerifyEmailCode() { Response mailListResponse = given() .when() .get(KeycloakTestSupport.getMailListApiUrl()); JsonPath jsonResponse = mailListResponse.jsonPath(); assert jsonResponse.getInt("total") == 1; String emailContent = jsonResponse.getString("items[0].MIME.Parts[0].Body"); return KeycloakTestSupport.extractCode(emailContent, ".*\\r\\n\\r\\n(.*)\\r\\n\\r\\n.*"); }
getMailListApiUrl
とextractCode
がKeycloakTestSupport.java
に定義されます。
getMailListApiUrlとextractCodeの定義
static String getMailListApiUrl() { return "http://" + mailhogServer.getHost() + ":" + mailhogServer.getMappedPort(mailHogServerWebPort) + "/api/v2/messages"; } static String extractCode(String input, String pattern) { try { Pattern patternReg = Pattern.compile(pattern); Matcher matcher = patternReg.matcher(input); if (matcher.matches()) return matcher.group(1); return null; } catch (Exception e) { log.error("error", e); return null; } }
取得したコードを利用して、先ほどのコード認証リンクへリクエストします。
認証コードを送信
private static Response submitCode(String code, String submitCodeLink, Map<String, String> cookies) { return given() .cookies(cookies) .formParam("email_code", code) .post(submitCodeLink); }
まとめて、KeycloakIntegrationTest.java
ファイルにtestRegistration
の実装を追加します。
認証コードを検証
@Test void testRegistration() { ... String submitPageResponse = openPage(submitCodeUrl, loginPageResponse.cookies()) .then() .extract() .response().asString(); // test received verify email if registration is successful String code = getVerifyEmailCode(); String submitCodeLink = getAuthLinks("login", submitPageResponse); log.info("submitCodeLink: {} with code:{}", submitCodeLink, code); // test submit correct code submitCode(code, submitCodeLink, loginPageResponse.cookies()) .then() .statusCode(HttpStatusCode.OK_200.code()); ... }
4. 利用規約を承諾
認証コードを確認すると、利用規約を承諾する画面が表示されます。承諾すると、登録が完了します。
KeycloakIntegrationTest.java
ファイルにtestRegistration
の実装を追加します。
利用規約を承諾
@Test void testRegistration() { ... String updateTermUrl = submitCodeUrl.replaceAll("verify-email-by-code-action", "terms-and-conditions-action"); log.info("updateTermPage: {}", updateTermUrl); String updateTermPageResponse = openPage(updateTermUrl, loginPageResponse.cookies()) .then() .extract() .response().asString(); String updateTermLink = getAuthLinks("login", updateTermPageResponse); log.info("updateTermLink: {}", updateTermLink); // test agree terms and conditions given() .cookies(loginPageResponse.getCookies()) .headers(KeycloakTestSupport.getHeaders()) .post(updateTermLink) .then() .statusCode(HttpStatusCode.FOUND_302.code()) .header("Location", containsString(KeycloakTestSupport.redirectUri)); }
上記の 4 つのステップをまとめると、testRegistration
の実装は以下のようになります。
testRegistrationのまとめ
@Test void testRegistration() { log.info("test registration"); KeycloakTestSupport.createRegistrationMockResponses(); String loginUrl = getLoginUrl(); // get register link Response loginPageResponse = openLoginPage(loginUrl); String registerLink = KeycloakTestSupport.getBaseUrl() + getAuthLinks("register", loginPageResponse.asString()); log.info("registerLink: {}", registerLink); // test return error if required fields are empty register(registerLink, loginPageResponse.cookies(), "", "", "", "", "") .then() .statusCode(HttpStatusCode.OK_200.code()) .extract() .path("errorMessage", Errors.INVALID_REGISTRATION); // test return error if username is existed register(registerLink, loginPageResponse.cookies(), "taro", "test", "test@example.com", "taro", "test12345678") .then() .statusCode(HttpStatusCode.BAD_REQUEST_400.code()) .extract() .path("errorMessage", Errors.USERNAME_IN_USE); // test return error is user is existed register(registerLink, loginPageResponse.cookies(), "test1", "test1", "test1@example.com", "test1", "test12345678") .then() .statusCode(HttpStatusCode.OK_200.code()) .extract() .path("errorMessage", Errors.EMAIL_IN_USE); // test return error if cannot create user in external system register(registerLink, loginPageResponse.cookies(), "test2", "test2", "test2@example.com", "test2", "test12345678") .then() .statusCode(HttpStatusCode.BAD_REQUEST_400.code()) .extract() .path("errorMessage", Errors.INVALID_REGISTRATION); // test redirect if registration is successful Response successRegisterResponse = register(registerLink, loginPageResponse.cookies(), "test", "test", "test@example.com", "test", "test123456"); successRegisterResponse.then() .statusCode(HttpStatusCode.FOUND_302.code()); String submitCodeUrl = successRegisterResponse.header("Location"); log.info("submitCodePage: {}", submitCodeUrl); String submitPageResponse = openPage(submitCodeUrl, loginPageResponse.cookies()) .then() .extract() .response().asString(); // test received verify email if registration is successful String code = getVerifyEmailCode(); String submitCodeLink = getAuthLinks("login", submitPageResponse); log.info("submitCodeLink: {} with code:{}", submitCodeLink, code); // test submit correct code submitCode(code, submitCodeLink, loginPageResponse.cookies()) .then() .statusCode(HttpStatusCode.OK_200.code()); String updateTermUrl = submitCodeUrl.replaceAll("verify-email-by-code-action", "terms-and-conditions-action"); log.info("updateTermPage: {}", updateTermUrl); String updateTermPageResponse = openPage(updateTermUrl, loginPageResponse.cookies()) .then() .extract() .response().asString(); String updateTermLink = getAuthLinks("login", updateTermPageResponse); log.info("updateTermLink: {}", updateTermLink); // test agree terms and conditions given() .cookies(loginPageResponse.getCookies()) .headers(KeycloakTestSupport.getHeaders()) .post(updateTermLink) .then() .statusCode(HttpStatusCode.FOUND_302.code()) .header("Location", containsString(KeycloakTestSupport.redirectUri)); }
認証フローのテスト
次に、認証フローのテストを実施します。テストを開始する前に、フローのシーケンスを想定しましょう。以下にその手順を示します。
外部システムのモックレスポンスを作成
認証シーケンス図から、外部システムが以下の処理を行います。
- リスク分析(ログイン時に不正アクセスではないか)
- ユーザー存在確認
上記の処理に対応するため、各リクエストに合わせて、モックレスポンスを作成します。
KeycloakTestSupport.java
ファイルにcreateAuthenticateMockResponses
メソッドを追加します。
モックレスポンスを作成
static void createAuthenticateMockResponses() { log.info("create authenticate mock responses"); mockServer.stop(); mockServerClients.clear(); mockServer.start(); mockServer.followOutput(new Slf4jLogConsumer(log)); var mockRiskAnalyzeClient = new MockServerClient(mockServer.getHost(), mockServer.getServerPort()); mockServerClients.add(mockRiskAnalyzeClient); mockRiskAnalyzeClient.when( request() .withMethod("POST") .withPath("/api/v1/wealthnavi/risk_analyze") .withBody(json("{\"numOfLoginFailure\" : 0}", MatchType.ONLY_MATCHING_FIELDS)) ).respond( response() .withStatusCode(HttpStatusCode.OK_200.code()) .withBody("{\"score\": \"LOW\"}", MediaType.APPLICATION_JSON) ); var mockVerifyExistedUserClient = new MockServerClient(mockServer.getHost(), mockServer.getServerPort()); mockServerClients.add(mockVerifyExistedUserClient); mockVerifyExistedUserClient.when( request() .withMethod("POST") .withPath("/api/v1/wealthnavi/verify_user") .withBody(json("{\"emailAddress\" : \"taro@example.com\"}", MatchType.STRICT)) ).respond( response() .withStatusCode(HttpStatusCode.OK_200.code()) .withBody("{\"isWealthNaviUser\": true}", MediaType.APPLICATION_JSON) ); var mockVerifyNotExistedUserClient = new MockServerClient(mockServer.getHost(), mockServer.getServerPort()); mockServerClients.add(mockVerifyNotExistedUserClient); mockVerifyNotExistedUserClient.when( request() .withMethod("POST") .withPath("/api/v1/wealthnavi/verify_user") .withBody(json("{\"emailAddress\" : \"test1@example.com\"}", MatchType.STRICT)) ).respond( response() .withStatusCode(HttpStatusCode.OK_200.code()) .withBody("{\"isWealthNaviUser\": false}", MediaType.APPLICATION_JSON) ); }
認証のテストコードを書く
認証シーケンス図に基づき、ユーザー側の手順を想定します。
- Keycloak のログインページを開き
- 認証情報を入力して、ログインボタンをクリック、認証リクエストを送信
想定した手順に基づいて、上記のステップのテストコードを書きます。
KeycloakTestIntegration.java
ファイルにtestAuthentication
とauthenticate
の実装を追加します。
認証をテスト
@Test void testAuthentication() { log.info("test authentication"); KeycloakTestSupport.createAuthenticateMockResponses(); String loginUrl = getLoginUrl(); // test redirect if credentials are valid and user is existed in external system authenticate(loginUrl, "taro", "taro123456") .then() .statusCode(HttpStatusCode.FOUND_302.code()) .header("Location", containsString(KeycloakTestSupport.redirectUri)); // test not redirect if credentials are invalid (password is wrong) authenticate(loginUrl, "taro", "taro123456aaa") .then() .statusCode(HttpStatusCode.OK_200.code()) .extract() .path("errorMessage", Errors.INVALID_USER_CREDENTIALS); // test not redirect if credentials are invalid (user is not existed) authenticate(loginUrl, "test", "aaaa") .then() .statusCode(HttpStatusCode.OK_200.code()) .extract() .path("errorMessage", Errors.INVALID_USER_CREDENTIALS); // test return error if credentials are valid but user is not existed in external system authenticate(loginUrl, "test1", "test123456") .then() .statusCode(HttpStatusCode.BAD_REQUEST_400.code()) .extract() .path("errorMessage", Errors.INVALID_USER_CREDENTIALS); } private static Response authenticate(String loginUrl, String userName, String password) { Response loginPageResponse = openLoginPage(loginUrl); String loginLink = getAuthLinks("login", loginPageResponse.asString()); log.info("loginLink: {}", loginLink); return given() .headers(KeycloakTestSupport.getHeaders()) .cookies(loginPageResponse.getCookies()) .formParam("username", userName) .formParam("password", password) .when() .post(loginLink); }
パスワードリセットフローのテスト
最後は、パスワードリセットフローのテストを実施します。テストを開始する前に、フローのシーケンスを想定しましょう。以下にその手順を示します。
外部システムのモックレスポンスを作成
登録シーケンス図から、外部システムがパスワードを更新する処理を行います。このために、モックレスポンスを作成します。
KeycloakTestSupport.java
ファイルにcreateUpdatePasswordMockResponse
メソッドを追加します。
モックレスポンスを作成
static void createUpdatePasswordMockResponse() { log.info("create update password mock responses"); mockServer.stop(); mockServerClients.clear(); mailhogServer.stop(); mockServer.start(); mockServer.followOutput(new Slf4jLogConsumer(log)); mailhogServer.start(); mailhogServer.followOutput(new Slf4jLogConsumer(log)); var mockUpdatePasswordClient = new MockServerClient(mockServer.getHost(), mockServer.getServerPort()); mockServerClients.add(mockUpdatePasswordClient); mockUpdatePasswordClient.when( request() .withMethod("POST") .withPath("/api/v1/wealthnavi/update_password") .withBody(json("{\"email\": \"taro@example.com\"}", MatchType.ONLY_MATCHING_FIELDS)) ).respond( response() .withStatusCode(HttpStatusCode.OK_200.code()) .withBody("{\"isSuccess\": true}", MediaType.APPLICATION_JSON) ); }
パスワードリセットフローのテストコードを書く
パスワードリセットシーケンス図に基づき、ユーザー側の手順を想定します。
- Keycloak のログインページを開き、「パスワードをお忘れですか?」ボタンをクリック、ユーザー名またはメールアドレスを入力するページに移動
- ユーザーの情報を入力して、送信ボタンをクリック、認証コードが含まれたメールを送信
- 認証コード画面が表示され、メールサーバーにアクセスし、コードを入力
- パスワードを変更画面が表示され、パスワードを変更
想定した手順に基づいて、上記のステップのテストコードを書きます。
1. パスワードリセットリンクを取得
Keycloak のログインページを開き、HTML レスポンスを受け取ります。そこからregex
を利用してパスワードリセットページのリンクを取得します。
KeycloakIntegrationTest.java
ファイルにtestResetPassword
の実装を追加します。
パスワードリセットリンクを取得
@Test void testResetPassword() { log.info("test reset password"); KeycloakTestSupport.createUpdatePasswordMockResponse(); String loginUrl = getLoginUrl(); // get reset password link Response loginPageResponse = openLoginPage(loginUrl); String resetPasswordLink = KeycloakTestSupport.getBaseUrl() + getAuthLinks("reset-password", loginPageResponse.asString()); log.info("resetPasswordLink: {}", resetPasswordLink); ... }
2. 認証コードが含まれたメールを送信
上記のパスワードリセットページの HTML レスポンスを受け取り、それからregex
を利用して、メールを送信リクエストリンクを取得します。
Keycloak はセキュリティを強化するため、パラメータはこの段階で code 要素に設定されていますので、同様に cookies も統一されます。
KeycloakIntegrationTest.java
ファイルにtestResetPassword
とsendResetPasswordEmail
の実装を追加します。
メールを送信
@Test void testResetPassword() { ... Response sendResetPasswordEmailResponse = sendResetPasswordEmail(resetPasswordLink, loginPageResponse.getCookies(), "taro"); sendResetPasswordEmailResponse.then() .statusCode(HttpStatusCode.OK_200.code()); ... } private static Response sendResetPasswordEmail(String resetPasswordLink, Map<String, String> cookies, String username) { Response resetPasswordPageResponse = openPage(resetPasswordLink, cookies); String sendResetEmailLink = getAuthLinks("login", resetPasswordPageResponse.asString()); log.info("resetPasswordPageLink: {}", sendResetEmailLink); return given() .cookies(cookies) .headers(KeycloakTestSupport.getHeaders()) .formParam("username", username) .post(sendResetEmailLink); }
3. 認証コードを検証
メール送信リクエストリンクの HTML レスポンスを受け取り、それからregex
を利用して、コード認証リンクが取得できます。
さらに、コードを取得するために、MailHog
サーバーの API を使用して、認証コードが含まれたメールにアクセスできます。その後、regex
を使用してコードを取得します。
取得したコードを利用して、先ほどのコード認証リンクへリクエストします。
まとめて、KeycloakIntegrationTest.java
ファイルにtestResetPassword
の実装を追加します。
認証コードを検証
@Test void testResetPassword() { ... String submitCodeLink = getAuthLinks("login", sendResetPasswordEmailResponse.asString()); String code = getVerifyEmailCode(); // test submit correct code log.info("submitCodeLink: {} with code:{}", submitCodeLink, code); Response submitCodeResponse = submitCode(code, submitCodeLink, loginPageResponse.cookies()); submitCodeResponse.then().statusCode(HttpStatusCode.FOUND_302.code()); ... }
4. パスワードを更新
認証コードが確認できると、パスワード変更画面が表示されます。コードの認証レスポンスのLocation
ヘッダーからパスワード更新ページのリンクが取得できます。
続いて、パスワード更新ページでパスワードを更新します。
まとめて、KeycloakIntegrationTest.java
ファイルにtestResetPassword
とupdatePassword
の実装を追加します。
パスワード更新を検証
@Test void testResetPassword() { ... String updatePasswordPage = submitCodeResponse.header("Location"); log.info("updatePasswordPage: {}", updatePasswordPage); // test empty password updatePassword(updatePasswordPage, loginPageResponse.cookies(), "", "") .then() .statusCode(HttpStatusCode.OK_200.code()) .extract() .path("errorMessage", Errors.PASSWORD_MISSING); // test update password not match updatePassword(updatePasswordPage, loginPageResponse.cookies(), "wealth0428", "wealth0428a") .then() .statusCode(HttpStatusCode.OK_200.code()) .extract() .path("errorMessage", Errors.PASSWORD_CONFIRM_ERROR); // test update password updatePassword(updatePasswordPage, loginPageResponse.cookies(), "wealth0428", "wealth0428") .then() .statusCode(HttpStatusCode.FOUND_302.code()) .headers("Location", containsString(KeycloakTestSupport.redirectUri)); } private static Response updatePassword(String updatePasswordPage, Map<String, String> cookies, String password, String confirmPassword) { Response updatePasswordPageResponse = openPage(updatePasswordPage, cookies); String updatePasswordLink = getAuthLinks("login", updatePasswordPageResponse.asString()); log.info("updatePasswordLink: {}", updatePasswordLink); return given() .cookies(cookies) .headers(KeycloakTestSupport.getHeaders()) .formParam("password-new", password) .formParam("password-confirm", confirmPassword) .post(updatePasswordLink); }
上記の 4 つのステップをまとめると、testResetPassword
の実装は以下のようになります。
testResetPasswordのまとめ
@Test void testResetPassword() { log.info("test reset password"); KeycloakTestSupport.createUpdatePasswordMockResponse(); String loginUrl = getLoginUrl(); // get reset password link Response loginPageResponse = openLoginPage(loginUrl); String resetPasswordLink = KeycloakTestSupport.getBaseUrl() + getAuthLinks("reset-password", loginPageResponse.asString()); log.info("resetPasswordLink: {}", resetPasswordLink); Response sendResetPasswordEmailResponse = sendResetPasswordEmail(resetPasswordLink, loginPageResponse.getCookies(), "taro"); sendResetPasswordEmailResponse.then() .statusCode(HttpStatusCode.OK_200.code()); String submitCodeLink = getAuthLinks("login", sendResetPasswordEmailResponse.asString()); String code = getVerifyEmailCode(); // test submit correct code log.info("submitCodeLink: {} with code:{}", submitCodeLink, code); Response submitCodeResponse = submitCode(code, submitCodeLink, loginPageResponse.cookies()); submitCodeResponse.then().statusCode(HttpStatusCode.FOUND_302.code()); String updatePasswordPage = submitCodeResponse.header("Location"); log.info("updatePasswordPage: {}", updatePasswordPage); // test empty password updatePassword(updatePasswordPage, loginPageResponse.cookies(), "", "") .then() .statusCode(HttpStatusCode.OK_200.code()) .extract() .path("errorMessage", Errors.PASSWORD_MISSING); // test update password not match updatePassword(updatePasswordPage, loginPageResponse.cookies(), "wealth0428", "wealth0428a") .then() .statusCode(HttpStatusCode.OK_200.code()) .extract() .path("errorMessage", Errors.PASSWORD_CONFIRM_ERROR); // test update password updatePassword(updatePasswordPage, loginPageResponse.cookies(), "wealth0428", "wealth0428") .then() .statusCode(HttpStatusCode.FOUND_302.code()) .headers("Location", containsString(KeycloakTestSupport.redirectUri)); }
テストのコードカバー率レポート
テストを実行する際には、コードカバー率レポートが重要です。そのため、jacocoを導入します。 簡単に述べると、Keycloak のテストコンテナ内で Jacoco エージェントを実行し、テストが終了した後に生成されたファイルをホストマシンにコピーして、レポートを生成します。
POM ファイルを設定
まず、POM ファイルにjacoco
の設定を追加します。
POMファイルを設定
<!-- Jacocoエージェントを追加 --> <dependency> <groupId>org.jacoco</groupId> <artifactId>org.jacoco.agent</artifactId> <version>${jacoco.version}</version> <classifier>runtime</classifier> <scope>test</scope> </dependency> <!-- テストのプロフィールを設定 --> <profiles> <profile> <id>with-integration-tests</id> <build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-failsafe-plugin</artifactId> <version>${maven-failsafe-plugin.version}</version> <executions> <execution> <phase>integration-test</phase> <goals> <goal>integration-test</goal> <goal>verify</goal> </goals> </execution> </executions> <configuration> <includes> <include>**/*IntegrationTest.java</include> </includes> </configuration> </plugin> <!-- ビルドディレクトリにJacocoエージェントをコピー --> <plugin> <artifactId>maven-dependency-plugin</artifactId> <executions> <execution> <id>copy-jacoco</id> <goals> <goal>copy-dependencies</goal> </goals> <phase>compile</phase> <configuration> <includeArtifactIds>org.jacoco.agent</includeArtifactIds> <includeClassifiers>runtime</includeClassifiers> <outputDirectory>${project.build.directory}/jacoco-agent</outputDirectory> <stripVersion>true</stripVersion> </configuration> </execution> </executions> </plugin> <!-- ビルドディレクトリにレポートディレクトリを作成 --> <plugin> <groupId>org.codehaus.mojo</groupId> <artifactId>exec-maven-plugin</artifactId> <version>${exec-maven-plugin.version}</version> <executions> <execution> <id>create-directory</id> <phase>compile</phase> <goals> <goal>exec</goal> </goals> <configuration> <executable>mkdir</executable> <arguments> <argument>-p</argument> <argument>${project.build.directory}/jacoco-report</argument> </arguments> <longModulepath>false</longModulepath> </configuration> </execution> </executions> </plugin> <!-- Jacocoのレポートとルールを設定 --> <plugin> <groupId>org.jacoco</groupId> <artifactId>jacoco-maven-plugin</artifactId> <version>${jacoco.version}</version> <executions> <execution> <id>report</id> <phase>integration-test</phase> <goals> <goal>report</goal> </goals> <configuration> <dataFile>${project.build.directory}/jacoco-report/jacoco.exec</dataFile> </configuration> </execution> <execution> <id>check</id> <phase>post-integration-test</phase> <goals> <goal>check</goal> </goals> <configuration> <dataFile>${project.build.directory}/jacoco-report/jacoco.exec</dataFile> <rules> <rule> <element>BUNDLE</element> <limits> <limit> <counter>LINE</counter> <value>COVEREDRATIO</value> <minimum>0.7</minimum> </limit> <limit> <counter>BRANCH</counter> <value>COVEREDRATIO</value> <minimum>0.5</minimum> </limit> </limits> </rule> </rules> </configuration> </execution> </executions> </plugin> </plugins> </build> </profile> </profiles>
Keycloak のテストコンテナを設定
テストコンテナが起動する時、jacoco
が実行できるため、KeycloakTestSupport.java
の定義したkeycloakContainer
を以下のように更新します。
Keycloakのテストコンテナ定義を更新
private static final KeycloakContainer keycloakContainer = new KeycloakContainer() .withNetwork(network) .withRealmImportFile("realm-export.json") .withProviderClassesFrom("target/classes") .withEnv("KC_SPI_REQUIRED_ACTION_TERMS_AND_CONDITIONS_ACTION_WEALTHNAVI_UPDATE_TERMS_AND_CONDITIONS_API_URL", mockServerUrl + "/api/v1/wealthnavi/update_terms_and_conditions") .withEnv("KC_SPI_REQUIRED_ACTION_UPDATE_PASSWORD_ACTION_WEALTHNAVI_UPDATE_PASSWORD_API_URL", mockServerUrl + "/api/v1/wealthnavi/update_password") .withEnv("KC_SPI_REQUIRED_ACTION_VERIFY_EMAIL_BY_CODE_ACTION_WEALTHNAVI_UPDATE_STATUS_API_URL", mockServerUrl + "/api/v1/wealthnavi/update_status") .withReuse(true) .withCopyFileToContainer( MountableFile.forHostPath("target/jacoco-agent/"), "/jacoco-agent" ) .withEnv("JAVA_OPTS_APPEND", "-javaagent:/jacoco-agent/org.jacoco.agent-runtime.jar=destfile=/tmp/jacoco.exec");
テスト実行後の設定
テストが完了した後に生成されたファイルをビルドディレクトリにコピーするため、KeycloakTestSupport.java
のstop
メソッドを以下のように更新します。
テスト実行後の設定を更新
static void stop() { log.info("stopMockServer"); mockServerClients.forEach(MockServerClient::stop); mockServerClients.clear(); mockServer.stop(); log.info("stopKeycloakContainer"); keycloakContainer.getDockerClient().stopContainerCmd(keycloakContainer.getContainerId()).exec(); keycloakContainer.copyFileFromContainer("/tmp/jacoco.exec", "./target/jacoco-report/jacoco.exec"); keycloakContainer.stop(); log.info("stopMailhogServer"); mailhogServer.stop(); }
テストを実行
テストを実行する際は、以下のコマンドを実行します。
mvn clean verify -Pintegration-test
テストのコードカバー率レポート
テストを実行後、以下のコマンドを実行して、レポートが確認できます。
open extensions/target/site/jacoco/index.html
レポートは以下の通りです。
まとめ
本記事では、Keycloak カスタマイズのテスト周りについて紹介しました。 こちらを参考にすることで、CI/CD 戦略を設定できます。 Keycloak を導入検討する際の参考にしていただければ嬉しいです。
明日は、VPoE 和賀 の「CTOとVPoEへのキャリアアップについて」です!
お楽しみに!
📣 ウェルスナビは一緒に働く仲間を募集しています 📣
https://hrmos.co/pages/wealthnavi/jobs?category=1243739934161813504
著者プロフィール
高原 勇輝(たかはら ゆうき)
2023 年 2 月にウェルスナビに、フルスタックエンジニアとして入社。モバイルアプリの開発が得意ですが、バックエンド開発にも興味を持っています。趣味は LeetCode の問題解決、キャンプ、そしてサッカー観戦です。