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; } -async function collectBugReport(opts: IOpts = {}, gzipLogs = true): Promise { - const progressCallback = opts.progressCallback || ((): void => {}); +/** + * Exported only for testing. + * @internal public for test + */ +export async function collectBugReport(opts: IOpts = {}, gzipLogs = true): Promise { + 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 { + 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 { + 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
{ + 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 { + try { + // XXX: This is synapse-specific but better than nothing until MSC support for a server version endpoint + const data = await client.http.request>( + 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>( - 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 { + 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 { + 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 { // add storage persistence/quota information if (navigator.storage && navigator.storage.persisted) { try { @@ -202,7 +276,9 @@ async function collectBugReport(opts: IOpts = {}, gzipLogs = true): Promise window.Modernizr[key] === false, @@ -211,33 +287,35 @@ async function collectBugReport(opts: IOpts = {}, gzipLogs = true): Promise void) | undefined, +): Promise { + 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; + const mockHttpAPI: MatrixHttpApi = new MatrixHttpApi( + new TypedEventEmitter(), + { + 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; + let windowSpy: jest.SpyInstance; + + beforeEach(() => { + mockWindow = { + matchMedia: jest.fn().mockReturnValue({ matches: false }), + navigator: { + userAgent: "", + }, + } as unknown as Mocked; + // @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 => { + 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; + let navigatorSpy: jest.SpyInstance; + + beforeEach(() => { + mockNavigator = { + storage: { + estimate: jest.fn(), + persisted: jest.fn(), + }, + } as unknown as Mocked; + // @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; + + 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; + // @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; + + // @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(), }), });