From 4e09d57d7cb8f35aedc905163031bc736fffae25 Mon Sep 17 00:00:00 2001
From: b12f <git@benjaminbaedorf.eu>
Date: Sat, 25 Jan 2025 15:02:28 +0100
Subject: [PATCH] tests: keycloak w/ TOTP working

---
 flake.lock                                    |   8 +-
 tests/keycloak.nix                            | 137 +++++++++++++++---
 tests/support/client.nix                      |   8 +-
 .../keycloak-realm-export/realm-export.json   |  25 ++++
 tests/support/puppeteer-socket/src/index.mjs  |  20 ++-
 5 files changed, 160 insertions(+), 38 deletions(-)

diff --git a/flake.lock b/flake.lock
index cbafc31..8de9cee 100644
--- a/flake.lock
+++ b/flake.lock
@@ -335,11 +335,11 @@
         ]
       },
       "locked": {
-        "lastModified": 1724595780,
-        "narHash": "sha256-c6XxFH+qo3SbstKAFLcvGn3GHVJxbuXE2VtBnrjBk10=",
+        "lastModified": 1737810569,
+        "narHash": "sha256-b3ymxmPuMPnAG6Z8FNErmKzjmUcQkXiTs6WkAE1qBkk=",
         "ref": "main",
-        "rev": "f2a3da5f2637a859897c136e650b88046a89f9fd",
-        "revCount": 4,
+        "rev": "af99e9e38fcbdd691c12aa6044cf831c8eea28b4",
+        "revCount": 6,
         "type": "git",
         "url": "https://git.pub.solar/pub-solar/keycloak-event-listener"
       },
diff --git a/tests/keycloak.nix b/tests/keycloak.nix
index f97f661..ac0d51b 100644
--- a/tests/keycloak.nix
+++ b/tests/keycloak.nix
@@ -44,8 +44,11 @@ in
       import re
       import sys
 
-      def puppeteer_run(cmd):
-        client.succeed(f'puppeteer-run \'{cmd}\' ')
+      def puppeteer_succeed(cmd):
+        return client.succeed(f'puppeteer-run \'{cmd}\' ')
+
+      def puppeteer_execute(cmd):
+        return client.execute(f'puppeteer-run \'{cmd}\' ')
 
       start_all()
 
@@ -69,32 +72,39 @@ in
       client.wait_for_unit("system.slice")
       client.wait_for_file("/tmp/puppeteer.sock")
 
-      puppeteer_run('page.goto("https://auth.test.pub.solar")')
-      puppeteer_run('page.waitForNetworkIdle()')
+      ####### Registration #######
+
+      puppeteer_succeed('page.goto("https://auth.test.pub.solar")')
+      puppeteer_succeed('page.waitForNetworkIdle()')
       client.screenshot("initial")
-      puppeteer_run('page.locator("::-p-text(Sign in)").click()')
-      puppeteer_run('page.waitForNetworkIdle()')
+      puppeteer_succeed('page.locator("::-p-text(Sign in)").click()')
+      puppeteer_succeed('page.waitForNetworkIdle()')
       client.screenshot("sign-in")
-      puppeteer_run('page.locator("::-p-text(Register)").click()')
-      puppeteer_run('page.waitForNetworkIdle()')
+      puppeteer_succeed('page.locator("::-p-text(Register)").click()')
+      puppeteer_succeed('page.waitForNetworkIdle()')
       client.screenshot("register")
-      puppeteer_run('page.locator("[name=username]").fill("test-user")')
-      puppeteer_run('page.locator("[name=email]").fill("test-user@test.pub.solar")')
-      puppeteer_run('page.locator("[name=password]").fill("Password1234")')
-      puppeteer_run('page.locator("[name=password-confirm]").fill("Password1234")')
+      puppeteer_succeed('page.locator("[name=username]").fill("test-user")')
+      puppeteer_succeed('page.locator("[name=email]").fill("test-user@test.pub.solar")')
+      puppeteer_succeed('page.locator("[name=password]").fill("Password1234")')
+      puppeteer_succeed('page.locator("[name=password-confirm]").fill("Password1234")')
       client.screenshot("register-filled-in")
-      puppeteer_run('page.locator("input[type=submit][value=Register]").click()')
-      puppeteer_run('page.waitForNetworkIdle()')
-      client.screenshot("after-register")
+      puppeteer_succeed('page.locator("input[type=submit][value=Register]").click()')
+      puppeteer_succeed('page.waitForNetworkIdle()')
+      client.screenshot("before-email-confirm")
+
+      # Sometimes offlineimap errors out
+      # ERROR: [Errno 2] No such file or directory: '/home/test-user/.local/share/offlineimap'
+      client.succeed("${su "mkdir -p ~/.local/share/offlineimap"}")
 
       client.succeed("${su "offlineimap"}")
       client.succeed("${su "[ $(messages -s ~/Maildir/test-user@test.pub.solar/INBOX) -eq 1 ]"}")
 
-      puppeteer_run('page.locator("a::-p-text(Click here)").click()')
-      puppeteer_run('page.waitForNetworkIdle()')
+      puppeteer_succeed('page.locator("a::-p-text(Click here)").click()')
+      puppeteer_succeed('page.waitForNetworkIdle()')
 
       client.succeed("${su "offlineimap"}")
       client.succeed("${su "[ $(messages -s ~/Maildir/test-user@test.pub.solar/INBOX) -eq 2 ]"}")
+
       mail_text = client.execute("${su "echo p | mail -Nf ~/Maildir/test-user@test.pub.solar/INBOX"}")[1]
       boundary_match = re.search('boundary="(.*)"', mail_text, flags=re.M)
       if not boundary_match:
@@ -105,11 +115,94 @@ in
       print(url_match)
       if not url_match:
         sys.exit(1)
-      puppeteer_run(f'page.goto("{url_match.group(1)}")')
-      puppeteer_run('page.waitForNetworkIdle()')
-      client.screenshot("email-confirmed")
+      puppeteer_succeed(f'page.goto("{url_match.group(1)}")')
+      puppeteer_succeed('page.waitForNetworkIdle()')
+      client.screenshot("registration-complete")
 
-      sys.exit(0)
-      time.sleep(1)
+      ####### Logout #######
+
+      puppeteer_succeed('page.locator("[data-testid=options-toggle]").click()')
+      puppeteer_succeed('page.locator("::-p-text(Sign out)").click()')
+
+      puppeteer_succeed('page.waitForNetworkIdle()')
+      client.screenshot("logged-out")
+
+      ####### Login plain #######
+
+      puppeteer_succeed('page.locator("[name=username]").fill("test-user")')
+      puppeteer_succeed('page.locator("::-p-text(Sign In)").click()')
+      puppeteer_succeed('page.locator("::-p-text(Restart login)").click()')
+
+      puppeteer_succeed('page.locator("[name=username]").fill("test-user")')
+      puppeteer_succeed('page.locator("::-p-text(Sign In)").click()')
+      puppeteer_succeed('page.locator("[name=password]").fill("Password1234")')
+      puppeteer_succeed('page.locator("::-p-text(Sign In)").click()')
+
+      puppeteer_succeed('page.waitForNetworkIdle()')
+      client.screenshot("logged-in")
+
+      ####### Add TOTP #######
+
+      puppeteer_succeed('page.locator("::-p-text(Account security)").click()')
+      puppeteer_succeed('page.locator("::-p-text(Signing in)").click()')
+
+      puppeteer_succeed('page.waitForNetworkIdle()')
+      client.screenshot("signing-in-settings")
+
+      puppeteer_succeed('page.locator(`[data-testid="otp/create"]`).click()')
+
+      puppeteer_succeed('page.waitForNetworkIdle()')
+      client.screenshot("TOTP-setup-qr")
+
+      puppeteer_succeed('page.locator("::-p-text(Unable to scan?)").click()')
+
+      puppeteer_succeed('page.waitForNetworkIdle()')
+      client.screenshot("TOTP-setup-manual")
+
+      totp_secret_key = puppeteer_execute('(async () => { const el = await page.waitForSelector("#kc-totp-secret-key"); return el.evaluate(e => e.textContent); })()')[1]
+
+      totp = client.execute(f'oathtool --totp -b "{totp_secret_key}"')[1].replace("\n", "")
+
+      puppeteer_succeed(f'page.locator("[name=totp]").fill("{totp}")')
+      puppeteer_succeed('page.locator("[name=userLabel]").fill("My TOTP")')
+      client.screenshot("TOTP-form-filled")
+      puppeteer_succeed('page.locator("input[type=submit][value=Submit]").click()')
+
+      puppeteer_succeed('page.waitForNetworkIdle()')
+      client.screenshot("TOTP-added")
+
+      ####### Login w/ TOTP #######
+
+      puppeteer_succeed('page.locator("[data-testid=options-toggle]").click()')
+      puppeteer_succeed('page.locator("::-p-text(Sign out)").click()')
+
+      puppeteer_succeed('page.waitForNetworkIdle()')
+      client.screenshot("logged-out")
+
+      puppeteer_succeed('page.locator("[name=username]").fill("test-user")')
+      puppeteer_succeed('page.locator("::-p-text(Sign In)").click()')
+      puppeteer_succeed('page.locator("[name=password]").fill("Password1234")')
+      puppeteer_succeed('page.locator("::-p-text(Sign In)").click()')
+
+      puppeteer_succeed('page.waitForNetworkIdle()')
+      client.screenshot("TOTP-login-form")
+
+      print("Sleeping 30s to make sure we roll over into the next TOTP token")
+      time.sleep(30)
+
+      totp = client.execute(f'oathtool --totp -b "{totp_secret_key}"')[1].replace("\n", "")
+      puppeteer_succeed(f'page.locator("[name=otp]").fill("{totp}")')
+      puppeteer_succeed('page.locator("::-p-text(Sign In)").click()')
+
+      puppeteer_succeed('page.waitForNetworkIdle()')
+      client.screenshot("TOTP-signed-in")
+
+      ####### Delete TOTP #######
+
+      puppeteer_succeed('page.locator(`[data-testid="otp/credential-list"] button::-p-text(Delete)`).click()')
+      puppeteer_succeed('page.waitForNetworkIdle()')
+
+      puppeteer_succeed('page.locator("main").scroll({ scrollTop: 200 })')
+      client.screenshot("TOTP-deleted")
     '';
 }
diff --git a/tests/support/client.nix b/tests/support/client.nix
index a86f5d9..2c863d3 100644
--- a/tests/support/client.nix
+++ b/tests/support/client.nix
@@ -17,16 +17,13 @@ in
   ];
 
   security.polkit.enable = true;
-  services.xserver.enable = true;
-  services.xserver.displayManager.gdm.enable = true;
-  services.xserver.desktopManager.gnome.enable = true;
-  services.xserver.displayManager.autoLogin.enable = true;
-  services.xserver.displayManager.autoLogin.user = "test-user";
 
   environment.systemPackages = [
     puppeteer-run
     pkgs.alacritty
     pkgs.mailutils
+    pkgs.oath-toolkit
+    pkgs.firefox
   ];
 
   services.getty.autologinUser = "test-user";
@@ -41,6 +38,7 @@ in
 
     wayland.windowManager.sway = {
       enable = true;
+      systemd.enable = true;
       extraSessionCommands = ''
         export WLR_RENDERER=pixman
       '';
diff --git a/tests/support/keycloak-realm-export/realm-export.json b/tests/support/keycloak-realm-export/realm-export.json
index 63fd58c..2f4f7e8 100644
--- a/tests/support/keycloak-realm-export/realm-export.json
+++ b/tests/support/keycloak-realm-export/realm-export.json
@@ -483,6 +483,31 @@
   "webAuthnPolicyPasswordlessAcceptableAaguids": [],
   "webAuthnPolicyPasswordlessExtraOrigins": [],
   "users": [
+    {
+      "id" : "49fcf95e-6fb3-4430-a29a-506a8b20e77c",
+      "createdTimestamp" : 1673444664000,
+      "username" : "existing-user",
+      "enabled" : true,
+      "totp" : false,
+      "emailVerified" : true,
+      "firstName" : "Existing",
+      "lastName" : "Tester",
+      "email" : "existing-user@test.pub.solar",
+      "credentials" : [ {
+        "id" : "b9fbc30d-4269-49cc-a0ea-9170dc44a30c",
+        "type" : "password",
+        "createdDate" : 1673444664000,
+        "secretData" : "{\"value\":\"yxEZKVTeZlKufE5q4v0Hvxlggg2EaRta5zBtIMxialgwOHrQ3h4Hmre//uk9SlrEv2eqo4aH4bFgPDoktOTyHQ==\",\"salt\":\"d3mk1F43bvQrbV1D+jC1NQ==\",\"additionalParameters\":{}}",
+        "credentialData" : "{\"hashIterations\":27500,\"algorithm\":\"pbkdf2-sha256\",\"additionalParameters\":{}}"
+      } ],
+      "disableableCredentialTypes" : [ ],
+      "requiredActions" : [ ],
+      "realmRoles" : [
+        "default-roles-test.pub.solar"
+      ],
+      "notBefore" : 0,
+      "groups" : [ ]
+    },
     {
       "id": "a0a10fbb-2d1d-4bf1-918d-86659f7dcef1",
       "username": "service-account-admin-cli",
diff --git a/tests/support/puppeteer-socket/src/index.mjs b/tests/support/puppeteer-socket/src/index.mjs
index b3371d3..e05f320 100644
--- a/tests/support/puppeteer-socket/src/index.mjs
+++ b/tests/support/puppeteer-socket/src/index.mjs
@@ -16,12 +16,18 @@ const EXECUTABLE = process.env.EXECUTABLE || 'firefox';
   });
 
   const page = await firefoxBrowser.newPage();
-  page.on('request', request => {
-    console.log(request.url());
-  });
+  // page.on('request', request => {
+  //   console.log(`[puppeteer req] ${request.url()}`);
+  // });
 
-  page.on('response', response => {
-    console.log(response.url());
+  // page.on('response', response => {
+  //   console.log(`[puppeteer res] ${response.url()}`);
+  // });
+
+  await page.setViewport({
+    width: 1200,
+    height: 600,
+    deviceScaleFactor: 1,
   });
 
   const server = http.createServer({});
@@ -41,9 +47,9 @@ const EXECUTABLE = process.env.EXECUTABLE || 'firefox';
 
         const responseText = (() => {
           try {
-            return JSON.stringify({ data: val });
+            return val.toString();
           } catch (err) {
-            return JSON.stringify({ data: val.toString() });
+            return val;
           }
         })();