From db096b7986aeb62c6f2f44cae2f425eaf2c2ea02 Mon Sep 17 00:00:00 2001
From: Valere <valeref@matrix.org>
Date: Thu, 15 Feb 2024 20:45:46 +0100
Subject: [PATCH] Add A-Element-R labels to rageshakes if rust (#12251)

* Add A-Element-R labels to rageshakes if rust

* fix import

* Add tests for rageshake  collect

* add ts-ignore in test

* refactor rageshake to match sonar Cognitive Complexity
---
 src/rageshake/submit-rageshake.ts | 322 ++++++++++------
 test/submit-rageshake-test.ts     | 608 ++++++++++++++++++++++++++++++
 test/test-utils/client.ts         |   2 +
 3 files changed, 810 insertions(+), 122 deletions(-)
 create mode 100644 test/submit-rageshake-test.ts

diff --git a/src/rageshake/submit-rageshake.ts b/src/rageshake/submit-rageshake.ts
index d15f3d9870..5f2a3990ad 100644
--- a/src/rageshake/submit-rageshake.ts
+++ b/src/rageshake/submit-rageshake.ts
@@ -17,7 +17,7 @@ limitations under the License.
 */
 
 import { logger } from "matrix-js-sdk/src/logger";
-import { Method } from "matrix-js-sdk/src/matrix";
+import { Method, MatrixClient, CryptoApi } from "matrix-js-sdk/src/matrix";
 
 import type * as Pako from "pako";
 import { MatrixClientPeg } from "../MatrixClientPeg";
@@ -37,34 +37,70 @@ interface IOpts {
     customFields?: Record<string, string>;
 }
 
-async function collectBugReport(opts: IOpts = {}, gzipLogs = true): Promise<FormData> {
-    const progressCallback = opts.progressCallback || ((): void => {});
+/**
+ * Exported only for testing.
+ * @internal public for test
+ */
+export async function collectBugReport(opts: IOpts = {}, gzipLogs = true): Promise<FormData> {
+    const progressCallback = opts.progressCallback;
 
-    progressCallback(_t("bug_reporting|collecting_information"));
-    let version: string | undefined;
-    try {
-        version = await PlatformPeg.get()?.getAppVersion();
-    } catch (err) {} // PlatformPeg already logs this.
-
-    const userAgent = window.navigator?.userAgent ?? "UNKNOWN";
-
-    let installedPWA = "UNKNOWN";
-    try {
-        // Known to work at least for desktop Chrome
-        installedPWA = String(window.matchMedia("(display-mode: standalone)").matches);
-    } catch (e) {}
-
-    let touchInput = "UNKNOWN";
-    try {
-        // MDN claims broad support across browsers
-        touchInput = String(window.matchMedia("(pointer: coarse)").matches);
-    } catch (e) {}
-
-    const client = MatrixClientPeg.get();
+    progressCallback?.(_t("bug_reporting|collecting_information"));
 
     logger.log("Sending bug report.");
 
     const body = new FormData();
+
+    await collectBaseInformation(body, opts);
+
+    const client = MatrixClientPeg.get();
+
+    if (client) {
+        await collectClientInfo(client, body);
+    }
+
+    collectLabels(client, opts, body);
+
+    collectSettings(body);
+
+    await collectStorageStatInfo(body);
+
+    collectMissingFeatures(body);
+
+    if (opts.sendLogs) {
+        await collectLogs(body, gzipLogs, progressCallback);
+    }
+
+    return body;
+}
+
+async function getAppVersion(): Promise<string | undefined> {
+    try {
+        return await PlatformPeg.get()?.getAppVersion();
+    } catch (err) {
+        // this happens if no version is set i.e. in dev
+    }
+}
+
+function matchesMediaQuery(query: string): string {
+    try {
+        return String(window.matchMedia(query).matches);
+    } catch (err) {
+        // if not supported in browser
+    }
+    return "UNKNOWN";
+}
+
+/**
+ * Collects base information about the user and the app to add to the report.
+ */
+async function collectBaseInformation(body: FormData, opts: IOpts): Promise<void> {
+    const version = await getAppVersion();
+
+    const userAgent = window.navigator?.userAgent ?? "UNKNOWN";
+
+    const installedPWA = matchesMediaQuery("(display-mode: standalone)");
+    const touchInput = matchesMediaQuery("(pointer: coarse)");
+
     body.append("text", opts.userText || "User did not supply any additional text.");
     body.append("app", opts.customApp || "element-web");
     body.append("version", version ?? "UNKNOWN");
@@ -77,98 +113,129 @@ async function collectBugReport(opts: IOpts = {}, gzipLogs = true): Promise<Form
             body.append(key, opts.customFields[key]);
         }
     }
+}
 
-    if (client) {
-        body.append("user_id", client.credentials.userId!);
-        body.append("device_id", client.deviceId!);
+/**
+ * Collects client and crypto related info.
+ */
+async function collectClientInfo(client: MatrixClient, body: FormData): Promise<void> {
+    body.append("user_id", client.credentials.userId!);
+    body.append("device_id", client.deviceId!);
 
-        const cryptoApi = client.getCrypto();
+    const cryptoApi = client.getCrypto();
 
-        if (cryptoApi) {
-            body.append("crypto_version", cryptoApi.getVersion());
+    if (cryptoApi) {
+        await collectCryptoInfo(cryptoApi, body);
+        await collectRecoveryInfo(client, cryptoApi, body);
+    }
 
-            const ownDeviceKeys = await cryptoApi.getOwnDeviceKeys();
-            const keys = [`curve25519:${ownDeviceKeys.curve25519}`, `ed25519:${ownDeviceKeys.ed25519}`];
-
-            body.append("device_keys", keys.join(", "));
-
-            // add cross-signing status information
-            const crossSigningStatus = await cryptoApi.getCrossSigningStatus();
-            const secretStorage = client.secretStorage;
-
-            body.append("cross_signing_ready", String(await cryptoApi.isCrossSigningReady()));
-            body.append("cross_signing_key", (await cryptoApi.getCrossSigningKeyId()) ?? "n/a");
-            body.append(
-                "cross_signing_privkey_in_secret_storage",
-                String(crossSigningStatus.privateKeysInSecretStorage),
-            );
-
-            body.append(
-                "cross_signing_master_privkey_cached",
-                String(crossSigningStatus.privateKeysCachedLocally.masterKey),
-            );
-            body.append(
-                "cross_signing_self_signing_privkey_cached",
-                String(crossSigningStatus.privateKeysCachedLocally.selfSigningKey),
-            );
-            body.append(
-                "cross_signing_user_signing_privkey_cached",
-                String(crossSigningStatus.privateKeysCachedLocally.userSigningKey),
-            );
-
-            body.append("secret_storage_ready", String(await cryptoApi.isSecretStorageReady()));
-            body.append("secret_storage_key_in_account", String(await secretStorage.hasKey()));
-
-            body.append("session_backup_key_in_secret_storage", String(!!(await client.isKeyBackupKeyStored())));
-            const sessionBackupKeyFromCache = await cryptoApi.getSessionBackupPrivateKey();
-            body.append("session_backup_key_cached", String(!!sessionBackupKeyFromCache));
-            body.append("session_backup_key_well_formed", String(sessionBackupKeyFromCache instanceof Uint8Array));
-        }
+    await collectSynapseSpecific(client, body);
+}
 
+/**
+ * Collects information about the home server.
+ */
+async function collectSynapseSpecific(client: MatrixClient, body: FormData): Promise<void> {
+    try {
+        // XXX: This is synapse-specific but better than nothing until MSC support for a server version endpoint
+        const data = await client.http.request<Record<string, any>>(
+            Method.Get,
+            "/server_version",
+            undefined,
+            undefined,
+            {
+                prefix: "/_synapse/admin/v1",
+            },
+        );
+        Object.keys(data).forEach((key) => {
+            body.append(`matrix_hs_${key}`, data[key]);
+        });
+    } catch {
         try {
-            // XXX: This is synapse-specific but better than nothing until MSC support for a server version endpoint
-            const data = await client.http.request<Record<string, any>>(
-                Method.Get,
-                "/server_version",
-                undefined,
-                undefined,
-                {
-                    prefix: "/_synapse/admin/v1",
-                },
-            );
-            Object.keys(data).forEach((key) => {
-                body.append(`matrix_hs_${key}`, data[key]);
-            });
+            // XXX: This relies on the federation listener being delegated via well-known
+            // or at the same place as the client server endpoint
+            const data = await getServerVersionFromFederationApi(client);
+            body.append("matrix_hs_name", data.server.name);
+            body.append("matrix_hs_version", data.server.version);
         } catch {
             try {
-                // XXX: This relies on the federation listener being delegated via well-known
-                // or at the same place as the client server endpoint
-                const data = await getServerVersionFromFederationApi(client);
-                body.append("matrix_hs_name", data.server.name);
-                body.append("matrix_hs_version", data.server.version);
-            } catch {
-                try {
-                    // If that fails we'll hit any endpoint and look at the server response header
-                    const res = await window.fetch(client.http.getUrl("/login"), {
-                        method: "GET",
-                        mode: "cors",
-                    });
-                    if (res.headers.has("server")) {
-                        body.append("matrix_hs_server", res.headers.get("server")!);
-                    }
-                } catch {
-                    // Could not determine server version
+                // If that fails we'll hit any endpoint and look at the server response header
+                const res = await window.fetch(client.http.getUrl("/login"), {
+                    method: "GET",
+                    mode: "cors",
+                });
+                if (res.headers.has("server")) {
+                    body.append("matrix_hs_server", res.headers.get("server")!);
                 }
+            } catch {
+                // Could not determine server version
             }
         }
     }
+}
+
+/**
+ * Collects crypto related information.
+ */
+async function collectCryptoInfo(cryptoApi: CryptoApi, body: FormData): Promise<void> {
+    body.append("crypto_version", cryptoApi.getVersion());
+
+    const ownDeviceKeys = await cryptoApi.getOwnDeviceKeys();
+    const keys = [`curve25519:${ownDeviceKeys.curve25519}`, `ed25519:${ownDeviceKeys.ed25519}`];
+
+    body.append("device_keys", keys.join(", "));
+
+    // add cross-signing status information
+    const crossSigningStatus = await cryptoApi.getCrossSigningStatus();
+
+    body.append("cross_signing_ready", String(await cryptoApi.isCrossSigningReady()));
+    body.append("cross_signing_key", (await cryptoApi.getCrossSigningKeyId()) ?? "n/a");
+    body.append("cross_signing_privkey_in_secret_storage", String(crossSigningStatus.privateKeysInSecretStorage));
+
+    body.append("cross_signing_master_privkey_cached", String(crossSigningStatus.privateKeysCachedLocally.masterKey));
+    body.append(
+        "cross_signing_self_signing_privkey_cached",
+        String(crossSigningStatus.privateKeysCachedLocally.selfSigningKey),
+    );
+    body.append(
+        "cross_signing_user_signing_privkey_cached",
+        String(crossSigningStatus.privateKeysCachedLocally.userSigningKey),
+    );
+}
+
+/**
+ * Collects information about secret storage and backup.
+ */
+async function collectRecoveryInfo(client: MatrixClient, cryptoApi: CryptoApi, body: FormData): Promise<void> {
+    const secretStorage = client.secretStorage;
+    body.append("secret_storage_ready", String(await cryptoApi.isSecretStorageReady()));
+    body.append("secret_storage_key_in_account", String(await secretStorage.hasKey()));
+
+    body.append("session_backup_key_in_secret_storage", String(!!(await client.isKeyBackupKeyStored())));
+    const sessionBackupKeyFromCache = await cryptoApi.getSessionBackupPrivateKey();
+    body.append("session_backup_key_cached", String(!!sessionBackupKeyFromCache));
+    body.append("session_backup_key_well_formed", String(sessionBackupKeyFromCache instanceof Uint8Array));
+}
+
+/**
+ * Collects labels to add to the report.
+ */
+export function collectLabels(client: MatrixClient | null, opts: IOpts, body: FormData): void {
+    if (client?.getCrypto()?.getVersion()?.startsWith(`Rust SDK`)) {
+        body.append("label", "A-Element-R");
+    }
 
     if (opts.labels) {
         for (const label of opts.labels) {
             body.append("label", label);
         }
     }
+}
 
+/**
+ * Collects some settings (lab flags and more) to add to the report.
+ */
+export function collectSettings(body: FormData): void {
     // add labs options
     const enabledLabs = SettingsStore.getFeatureSettingNames().filter((f) => SettingsStore.getValue(f));
     if (enabledLabs.length) {
@@ -179,6 +246,13 @@ async function collectBugReport(opts: IOpts = {}, gzipLogs = true): Promise<Form
         body.append("lowBandwidth", "enabled");
     }
 
+    body.append("mx_local_settings", localStorage.getItem("mx_local_settings")!);
+}
+
+/**
+ * Collects storage statistics to add to the report.
+ */
+async function collectStorageStatInfo(body: FormData): Promise<void> {
     // add storage persistence/quota information
     if (navigator.storage && navigator.storage.persisted) {
         try {
@@ -202,7 +276,9 @@ async function collectBugReport(opts: IOpts = {}, gzipLogs = true): Promise<Form
             }
         } catch (e) {}
     }
+}
 
+function collectMissingFeatures(body: FormData): void {
     if (window.Modernizr) {
         const missingFeatures = (Object.keys(window.Modernizr) as [keyof ModernizrStatic]).filter(
             (key: keyof ModernizrStatic) => window.Modernizr[key] === false,
@@ -211,33 +287,35 @@ async function collectBugReport(opts: IOpts = {}, gzipLogs = true): Promise<Form
             body.append("modernizr_missing_features", missingFeatures.join(", "));
         }
     }
-
-    body.append("mx_local_settings", localStorage.getItem("mx_local_settings")!);
-
-    if (opts.sendLogs) {
-        let pako: typeof Pako | undefined;
-        if (gzipLogs) {
-            pako = await import("pako");
-        }
-
-        progressCallback(_t("bug_reporting|collecting_logs"));
-        const logs = await rageshake.getLogsForReport();
-        for (const entry of logs) {
-            // encode as UTF-8
-            let buf = new TextEncoder().encode(entry.lines);
-
-            // compress
-            if (gzipLogs) {
-                buf = pako!.gzip(buf);
-            }
-
-            body.append("compressed-log", new Blob([buf]), entry.id);
-        }
-    }
-
-    return body;
 }
 
+/**
+ * Collects logs to add to the report if enabled.
+ */
+async function collectLogs(
+    body: FormData,
+    gzipLogs: boolean,
+    progressCallback: ((s: string) => void) | undefined,
+): Promise<void> {
+    let pako: typeof Pako | undefined;
+    if (gzipLogs) {
+        pako = await import("pako");
+    }
+
+    progressCallback?.(_t("bug_reporting|collecting_logs"));
+    const logs = await rageshake.getLogsForReport();
+    for (const entry of logs) {
+        // encode as UTF-8
+        let buf = new TextEncoder().encode(entry.lines);
+
+        // compress
+        if (gzipLogs) {
+            buf = pako!.gzip(buf);
+        }
+
+        body.append("compressed-log", new Blob([buf]), entry.id);
+    }
+}
 /**
  * Send a bug report.
  *
diff --git a/test/submit-rageshake-test.ts b/test/submit-rageshake-test.ts
new file mode 100644
index 0000000000..b2496c1d30
--- /dev/null
+++ b/test/submit-rageshake-test.ts
@@ -0,0 +1,608 @@
+/*
+Copyright 2024 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { Mocked, mocked } from "jest-mock";
+import {
+    HttpApiEvent,
+    HttpApiEventHandlerMap,
+    IHttpOpts,
+    MatrixClient,
+    TypedEventEmitter,
+    MatrixHttpApi,
+} from "matrix-js-sdk/src/matrix";
+import fetchMock from "fetch-mock-jest";
+
+import { getMockClientWithEventEmitter, mockClientMethodsCrypto, mockPlatformPeg } from "./test-utils";
+import { collectBugReport } from "../src/rageshake/submit-rageshake";
+import { MatrixClientPeg } from "../src/MatrixClientPeg";
+import SettingsStore from "../src/settings/SettingsStore";
+import { ConsoleLogger } from "../src/rageshake/rageshake";
+
+describe("Rageshakes", () => {
+    const RUST_CRYPTO_VERSION = "Rust SDK 0.7.0 (691ec63), Vodozemac 0.5.0";
+    const OLM_CRYPTO_VERSION = "Olm 3.2.15";
+    let mockClient: Mocked<MatrixClient>;
+    const mockHttpAPI: MatrixHttpApi<IHttpOpts & { onlyData: true }> = new MatrixHttpApi(
+        new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>(),
+        {
+            baseUrl: "https://alice-server.com",
+            prefix: "/_matrix/client/v3",
+            onlyData: true,
+        },
+    );
+
+    beforeEach(() => {
+        jest.spyOn(MatrixClientPeg, "getHomeserverName").mockReturnValue("alice-server.com");
+
+        mockClient = getMockClientWithEventEmitter({
+            credentials: { userId: "@test:example.com" },
+            deviceId: "AAAAAAAAAA",
+            baseUrl: "https://alice-server.com",
+            getHomeserverUrl: jest.fn().mockReturnValue("https://alice-server.com"),
+            ...mockClientMethodsCrypto(),
+            http: mockHttpAPI,
+        });
+        mocked(mockClient.getCrypto()!.getOwnDeviceKeys).mockResolvedValue({
+            ed25519: "",
+            curve25519: "",
+        });
+
+        fetchMock.restore();
+        fetchMock.catch(404);
+    });
+
+    describe("Basic Information", () => {
+        let mockWindow: Mocked<Window>;
+        let windowSpy: jest.SpyInstance;
+
+        beforeEach(() => {
+            mockWindow = {
+                matchMedia: jest.fn().mockReturnValue({ matches: false }),
+                navigator: {
+                    userAgent: "",
+                },
+            } as unknown as Mocked<Window>;
+            // @ts-ignore - We just need partial mock
+            windowSpy = jest.spyOn(global, "window", "get").mockReturnValue(mockWindow);
+        });
+
+        afterEach(() => {
+            windowSpy.mockRestore();
+        });
+
+        it("should include app version", async () => {
+            mockPlatformPeg({ getAppVersion: jest.fn().mockReturnValue("1.11.58") });
+
+            const formData = await collectBugReport();
+
+            const appVersion = formData.get("version");
+
+            expect(appVersion).toBe("1.11.58");
+        });
+
+        it("should put unknown app version if on dev", async () => {
+            mockPlatformPeg({ getAppVersion: jest.fn().mockRejectedValue(undefined) });
+
+            const formData = await collectBugReport();
+
+            const appVersion = formData.get("version");
+
+            expect(appVersion).toBe("UNKNOWN");
+        });
+
+        const mediaQueryTests: Array<[string, string, string, boolean]> = [
+            ["if installed WPA", "(display-mode: standalone)", "installed_pwa", true],
+            ["if not installed WPA", "(display-mode: standalone)", "installed_pwa", false],
+            ["if touchInput", "(pointer: coarse)", "touch_input", true],
+            ["if not touchInput", "(pointer: coarse)", "touch_input", false],
+        ];
+
+        it.each(mediaQueryTests)("should collect %s", async (_, query, label, matches) => {
+            mocked(mockWindow.matchMedia).mockImplementation((q): MediaQueryList => {
+                if (q === query) {
+                    return { matches: matches } as unknown as MediaQueryList;
+                }
+                return { matches: false } as unknown as MediaQueryList;
+            });
+
+            const formData = await collectBugReport();
+
+            const value = formData.get(label);
+            expect(value).toBe(String(matches));
+        });
+
+        const optionsTests: Array<[string, string, string, string]> = [
+            // [name, opt name, label, default]
+            ["userText", "userText", "text", "User did not supply any additional text."],
+            ["customApp", "customApp", "app", "element-web"],
+        ];
+
+        it.each(optionsTests)("should collect %s", async (_, optName, label, defaultValue) => {
+            const formData = await collectBugReport();
+
+            const value = formData.get(label);
+            expect(value).toBe(defaultValue);
+
+            const formDataWithOpt = await collectBugReport({ [optName]: "SomethingSomething" });
+            expect(formDataWithOpt.get(label)).toBe("SomethingSomething");
+        });
+
+        it("should collect custom fields", async () => {
+            const formDataWithOpt = await collectBugReport({
+                customFields: {
+                    something: "SomethingSomething",
+                    another: "AnotherThing",
+                },
+            });
+
+            expect(formDataWithOpt.get("something")).toBe("SomethingSomething");
+            expect(formDataWithOpt.get("another")).toBe("AnotherThing");
+        });
+
+        it("should collect user agent", async () => {
+            jest.replaceProperty(mockWindow.navigator, "userAgent", "jest navigator");
+            const formData = await collectBugReport();
+            const userAgent = formData.get("user_agent");
+            expect(userAgent).toBe("jest navigator");
+
+            // @ts-ignore - Need to force navigator to be undefined for test
+            jest.replaceProperty(mockWindow, "navigator", undefined);
+            const formDataWithoutNav = await collectBugReport();
+            expect(formDataWithoutNav.get("user_agent")).toBe("UNKNOWN");
+        });
+    });
+
+    describe("Credentials", () => {
+        it("should collect user id", async () => {
+            const formData = await collectBugReport();
+            expect(formData.get("user_id")).toBe("@test:example.com");
+        });
+
+        it("should collect device id", async () => {
+            const formData = await collectBugReport();
+
+            expect(formData.get("device_id")).toBe("AAAAAAAAAA");
+        });
+    });
+
+    describe("Crypto info", () => {
+        it("should collect crypto version", async () => {
+            mocked(mockClient.getCrypto()!.getVersion).mockReturnValue("0.0.0");
+            const formData = await collectBugReport();
+
+            expect(formData.get("crypto_version")).toBe("0.0.0");
+        });
+
+        it("should collect device keys", async () => {
+            const ownDeviceKeys = {
+                curve25519: "curve25519b64",
+                ed25519: "ed25519b64",
+            };
+
+            mocked(mockClient.getCrypto()!.getOwnDeviceKeys).mockResolvedValue(ownDeviceKeys);
+
+            const keys = [`curve25519:${ownDeviceKeys.curve25519}`, `ed25519:${ownDeviceKeys.ed25519}`].join(", ");
+
+            const formData = await collectBugReport();
+
+            expect(formData.get("device_keys")).toBe(keys);
+        });
+
+        describe("Cross-Signing", () => {
+            it.each([true, false])("should collect cross-signing ready %s", async (ready) => {
+                mocked(mockClient.getCrypto()!.isCrossSigningReady).mockResolvedValue(ready);
+
+                const formData = await collectBugReport();
+
+                expect(formData.get("cross_signing_ready")).toBe(String(ready));
+            });
+
+            it("should collect cross-signing pub key if set", async () => {
+                const crossSigningPubKey = "crossSigningPubKey";
+                mocked(mockClient.getCrypto()!.getCrossSigningKeyId).mockImplementation(
+                    async (type): Promise<string | null> => {
+                        if (!type || type === "master") {
+                            return crossSigningPubKey;
+                        }
+                        return null;
+                    },
+                );
+
+                const formData = await collectBugReport();
+
+                expect(formData.get("cross_signing_key")).toBe(crossSigningPubKey);
+            });
+
+            it("should not collect cross-signing pub key if not set", async () => {
+                mocked(mockClient.getCrypto()!.getCrossSigningKeyId).mockResolvedValue(null);
+                expect((await collectBugReport()).get("cross_signing_key")).toBe("n/a");
+            });
+
+            describe("Cross-signing status", () => {
+                const baseDetails = {
+                    masterKey: false,
+                    selfSigningKey: false,
+                    userSigningKey: false,
+                };
+                const baseStatus = {
+                    privateKeysInSecretStorage: false,
+                    publicKeysOnDevice: false,
+                    privateKeysCachedLocally: {
+                        ...baseDetails,
+                    },
+                };
+
+                it.each([true, false])("should collect if key cached locally %s", async (cached) => {
+                    mocked(mockClient.getCrypto()!.getCrossSigningStatus).mockResolvedValue({
+                        ...baseStatus,
+                        privateKeysInSecretStorage: cached,
+                    });
+
+                    const formData = await collectBugReport();
+
+                    expect(formData.get("cross_signing_privkey_in_secret_storage")).toBe(String(cached));
+                });
+
+                // @ts-ignore
+                const detailsTests: Array<[string, string, string]> = [
+                    ["master", "masterKey", "cross_signing_master_privkey_cached"],
+                    ["ssk", "selfSigningKey", "cross_signing_self_signing_privkey_cached"],
+                    ["usk", "userSigningKey", "cross_signing_user_signing_privkey_cached"],
+                ];
+                describe.each(detailsTests)("Cached locally %s", (_, objectKey, label) => {
+                    it.each([true, false])("should collect if cached locally %s", async (cached) => {
+                        mocked(mockClient.getCrypto()!.getCrossSigningStatus).mockResolvedValue({
+                            ...baseStatus,
+                            privateKeysCachedLocally: {
+                                ...baseDetails,
+                                [objectKey]: cached,
+                            },
+                        });
+
+                        const formData = await collectBugReport();
+
+                        expect(formData.get(label)).toBe(String(cached));
+                    });
+                });
+            });
+
+            describe("Secret Storage and backup", () => {
+                it.each([true, false])("should collect secret storage ready %s", async (ready) => {
+                    mocked(mockClient.getCrypto()!.isSecretStorageReady).mockResolvedValue(ready);
+
+                    const formData = await collectBugReport();
+
+                    expect(formData.get("secret_storage_ready")).toBe(String(ready));
+                });
+
+                it.each([true, false])("should collect secret storage key in account %s", async (stored) => {
+                    mocked(mockClient.secretStorage.hasKey).mockResolvedValue(stored);
+                    const formData = await collectBugReport();
+                    expect(formData.get("secret_storage_key_in_account")).toBe(String(stored));
+                });
+
+                it("should collect backup version", async () => {
+                    mocked(mockClient.isKeyBackupKeyStored).mockResolvedValue({});
+
+                    const formData = await collectBugReport();
+                    expect(formData.get("session_backup_key_in_secret_storage")).toBe(String(true));
+
+                    {
+                        mocked(mockClient.isKeyBackupKeyStored).mockResolvedValue(null);
+
+                        const formData = await collectBugReport();
+                        expect(formData.get("session_backup_key_in_secret_storage")).toBe(String(false));
+                    }
+                });
+
+                it("should collect backup key cached", async () => {
+                    mocked(mockClient.getCrypto()!.getSessionBackupPrivateKey).mockResolvedValue(
+                        new Uint8Array([0, 0]),
+                    );
+
+                    const formData = await collectBugReport();
+                    expect(formData.get("session_backup_key_cached")).toBe(String(true));
+                    expect(formData.get("session_backup_key_well_formed")).toBe(String(true));
+                });
+            });
+        });
+    });
+
+    describe("Synapse info", () => {
+        beforeEach(() => {
+            fetchMock.reset();
+        });
+
+        it("should collect synapse admin keys if available", async () => {
+            fetchMock.get("path:/_synapse/admin/v1/server_version", {
+                server_version: "1.101.0 (b=matrix-org-hotfixes,6dbedcf601)",
+                python_version: "3.7.8",
+            });
+
+            const formData = await collectBugReport();
+            expect(formData.get("matrix_hs_server_version")).toBe("1.101.0 (b=matrix-org-hotfixes,6dbedcf601)");
+            expect(formData.get("matrix_hs_python_version")).toBe("3.7.8");
+        });
+
+        it("should collect synapse admin keys with federation", async () => {
+            fetchMock.get("path:/_synapse/admin/v1/server_version", {
+                status: 404,
+            });
+            fetchMock.get("path:/_matrix/client/v3/login", {
+                status: 404,
+            });
+
+            fetchMock.get("path:/.well-known/matrix/server", {
+                "m.server": "matrix-federation.example.com:443",
+            });
+
+            fetchMock.get("https://matrix-federation.example.com/_matrix/federation/v1/version", {
+                server: {
+                    name: "Synapse",
+                    version: "1.101.0 (b=matrix-org-hotfixes,6dbedcf601)",
+                },
+            });
+
+            const formData = await collectBugReport();
+            expect(formData.get("matrix_hs_name")).toBe("Synapse");
+            expect(formData.get("matrix_hs_version")).toBe("1.101.0 (b=matrix-org-hotfixes,6dbedcf601)");
+        });
+
+        it("should collect synapse admin keys with fallback", async () => {
+            fetchMock.get("path:/_synapse/admin/v1/server_version", {
+                status: 404,
+            });
+            fetchMock.get("path:/.well-known/matrix/server", {
+                status: 404,
+            });
+
+            fetchMock.get("path:/_matrix/client/v3/login", {
+                status: 200,
+                body: {},
+                headers: {
+                    Server: "some_cdn",
+                },
+            });
+
+            const formData = await collectBugReport();
+            expect(formData.get("matrix_hs_server")).toBe("some_cdn");
+        });
+    });
+
+    describe("Settings Store", () => {
+        const mockSettingsStore = mocked(SettingsStore);
+
+        it("should collect labs from settings store", async () => {
+            const someFeatures: string[] = ["feature_video_rooms", "feature_notification_settings2", "feature_pinning"];
+            const enabledFeatures: string[] = ["feature_video_rooms", "feature_pinning"];
+            jest.spyOn(mockSettingsStore, "getFeatureSettingNames").mockReturnValue(someFeatures);
+            jest.spyOn(mockSettingsStore, "getValue").mockImplementation((settingName): any => {
+                return enabledFeatures.includes(settingName);
+            });
+
+            const formData = await collectBugReport();
+            expect(formData.get("enabled_labs")).toBe(enabledFeatures.join(", "));
+        });
+
+        it("should collect low bandWidth enabled", async () => {
+            jest.spyOn(mockSettingsStore, "getValue").mockImplementation((settingName): any => {
+                if (settingName == "lowBandwidth") {
+                    return true;
+                }
+            });
+
+            const formData = await collectBugReport();
+            expect(formData.get("lowBandwidth")).toBe("enabled");
+        });
+        it("should collect low bandWidth disabled", async () => {
+            jest.spyOn(mockSettingsStore, "getValue").mockImplementation((settingName): any => {
+                if (settingName == "lowBandwidth") {
+                    return false;
+                }
+            });
+
+            const formData = await collectBugReport();
+            expect(formData.get("lowBandwidth")).toBeNull();
+        });
+    });
+
+    describe("Navigator Storage", () => {
+        let mockNavigator: Mocked<Navigator>;
+        let navigatorSpy: jest.SpyInstance;
+
+        beforeEach(() => {
+            mockNavigator = {
+                storage: {
+                    estimate: jest.fn(),
+                    persisted: jest.fn(),
+                },
+            } as unknown as Mocked<Navigator>;
+            // @ts-ignore - We just need partial mock
+            navigatorSpy = jest.spyOn(global, "navigator", "get").mockReturnValue(mockNavigator);
+        });
+
+        afterEach(() => {
+            navigatorSpy.mockRestore();
+        });
+
+        it("should collect navigator storage persisted", async () => {
+            mocked(mockNavigator.storage.persisted).mockResolvedValue(true);
+            const formData = await collectBugReport();
+            expect(formData.get("storageManager_persisted")).toBe("true");
+        });
+
+        it("should collect navigator storage safari", async () => {
+            mocked(mockNavigator.storage.persisted).mockResolvedValue(true);
+            // @ts-ignore - Need to mock the safari
+            jest.replaceProperty(mockNavigator, "storage", undefined);
+
+            const mockDocument = {
+                hasStorageAccess: jest.fn().mockReturnValue(true),
+            } as unknown as Mocked<Document>;
+
+            const spy = jest.spyOn(global, "document", "get").mockReturnValue(mockDocument);
+
+            const formData = await collectBugReport();
+            expect(formData.get("storageManager_persisted")).toBe("true");
+
+            spy.mockRestore();
+        });
+
+        it("should collect navigator storage estimate", async () => {
+            const estimate = {
+                quota: 596797550592,
+                usage: 9147087,
+                usageDetails: {
+                    indexedDB: 9147045,
+                    serviceWorkerRegistrations: 42,
+                },
+            };
+            mocked(mockNavigator.storage.estimate).mockResolvedValue(estimate);
+
+            const formData = await collectBugReport();
+            expect(formData.get("storageManager_quota")).toEqual(estimate.quota.toString());
+            expect(formData.get("storageManager_usage")).toEqual(estimate.usage.toString());
+            expect(formData.get("storageManager_usage_indexedDB")).toEqual(
+                estimate.usageDetails["indexedDB"].toString(),
+            );
+            expect(formData.get("storageManager_usage_serviceWorkerRegistrations")).toEqual(
+                estimate.usageDetails["serviceWorkerRegistrations"].toString(),
+            );
+        });
+    });
+
+    it("should collect modernizer", async () => {
+        const allFeatures = {
+            cssanimations: false,
+            flexbox: true,
+            d0: false,
+            d1: false,
+            crypto: true,
+        };
+        const disabledFeatures = ["cssanimations", "d0", "d1"];
+        const mockWindow = {
+            Modernizr: {
+                ...allFeatures,
+            },
+        } as unknown as Mocked<Window>;
+        // @ts-ignore - We just need partial mock
+        const windowSpy = jest.spyOn(global, "window", "get").mockReturnValue(mockWindow);
+
+        const formData = await collectBugReport();
+
+        expect(formData.get("modernizr_missing_features")).toBe(disabledFeatures.join(", "));
+
+        windowSpy.mockRestore();
+    });
+
+    it("should collect localstorage settings", async () => {
+        const localSettings = {
+            language: "fr",
+            showHiddenEventsInTimeline: true,
+            activeCallRoomIds: [],
+        };
+
+        const spy = jest.spyOn(window.localStorage.__proto__, "getItem").mockImplementation((key) => {
+            return JSON.stringify(localSettings);
+        });
+
+        const formData = await collectBugReport();
+        expect(formData.get("mx_local_settings")).toBe(JSON.stringify(localSettings));
+
+        spy.mockRestore();
+    });
+
+    it("should collect logs", async () => {
+        const mockConsoleLogger = {
+            flush: jest.fn(),
+            consume: jest.fn(),
+            warn: jest.fn(),
+        } as unknown as Mocked<ConsoleLogger>;
+
+        // @ts-ignore - mock the console logger
+        global.mx_rage_logger = mockConsoleLogger;
+
+        // @ts-ignore
+        mockConsoleLogger.flush.mockReturnValue([
+            {
+                id: "instance-0",
+                line: "line 1",
+            },
+            {
+                id: "instance-1",
+                line: "line 2",
+            },
+        ]);
+
+        const formData = await collectBugReport({ sendLogs: true });
+
+        expect(formData.get("compressed-log")).toBeDefined();
+    });
+
+    describe("A-Element-R label", () => {
+        test("should add A-Element-R label if rust crypto", async () => {
+            mocked(mockClient.getCrypto()!.getVersion).mockReturnValue(RUST_CRYPTO_VERSION);
+
+            const formData = await collectBugReport();
+            const labelNames = formData.getAll("label");
+            expect(labelNames).toContain("A-Element-R");
+        });
+
+        test("should add A-Element-R label if rust crypto and new version", async () => {
+            mocked(mockClient.getCrypto()!.getVersion).mockReturnValue("Rust SDK 0.9.3 (909d09fd), Vodozemac 0.8.1");
+
+            const formData = await collectBugReport();
+            const labelNames = formData.getAll("label");
+            expect(labelNames).toContain("A-Element-R");
+        });
+
+        test("should not add A-Element-R label if not rust crypto", async () => {
+            mocked(mockClient.getCrypto()!.getVersion).mockReturnValue(OLM_CRYPTO_VERSION);
+
+            const formData = await collectBugReport();
+            const labelNames = formData.getAll("label");
+            expect(labelNames).not.toContain("A-Element-R");
+        });
+
+        test("should add A-Element-R label to the set of requested labels", async () => {
+            mocked(mockClient.getCrypto()!.getVersion).mockReturnValue(RUST_CRYPTO_VERSION);
+
+            const formData = await collectBugReport({
+                labels: ["Z-UISI", "Foo"],
+            });
+            const labelNames = formData.getAll("label");
+            expect(labelNames).toContain("A-Element-R");
+            expect(labelNames).toContain("Z-UISI");
+            expect(labelNames).toContain("Foo");
+        });
+
+        test("should not panic if there is no crypto", async () => {
+            mocked(mockClient.getCrypto).mockReturnValue(undefined);
+
+            const formData = await collectBugReport();
+            const labelNames = formData.getAll("label");
+            expect(labelNames).not.toContain("A-Element-R");
+        });
+    });
+
+    it("should notify progress", () => {
+        const progressCallback = jest.fn();
+
+        collectBugReport({ progressCallback });
+
+        expect(progressCallback).toHaveBeenCalled();
+    });
+});
diff --git a/test/test-utils/client.ts b/test/test-utils/client.ts
index 0a2e0cd617..8a991b0e9c 100644
--- a/test/test-utils/client.ts
+++ b/test/test-utils/client.ts
@@ -170,5 +170,7 @@ export const mockClientMethodsCrypto = (): Partial<
         isSecretStorageReady: jest.fn(),
         getSessionBackupPrivateKey: jest.fn(),
         getVersion: jest.fn().mockReturnValue("Version 0"),
+        getOwnDeviceKeys: jest.fn(),
+        getCrossSigningKeyId: jest.fn(),
     }),
 });