diff --git a/src/components/views/elements/Tag.tsx b/src/components/views/elements/Tag.tsx index f6d90cede4c..d7d46fe7e11 100644 --- a/src/components/views/elements/Tag.tsx +++ b/src/components/views/elements/Tag.tsx @@ -32,7 +32,12 @@ export const Tag: React.FC = ({ icon, label, onDeleteClick, disabled = f {icon?.()} {label} {onDeleteClick && ( - + )} diff --git a/src/components/views/settings/JoinRuleSettings.tsx b/src/components/views/settings/JoinRuleSettings.tsx index d478639dc0a..4aee5d74ca6 100644 --- a/src/components/views/settings/JoinRuleSettings.tsx +++ b/src/components/views/settings/JoinRuleSettings.tsx @@ -36,7 +36,7 @@ import { Action } from "../../../dispatcher/actions"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { doesRoomVersionSupport, PreferredRoomVersions } from "../../../utils/PreferredRoomVersions"; -interface IProps { +export interface JoinRuleSettingsProps { room: Room; promptUpgrade?: boolean; closeSettingsFn(): void; @@ -45,7 +45,7 @@ interface IProps { aliasWarning?: ReactNode; } -const JoinRuleSettings: React.FC = ({ +const JoinRuleSettings: React.FC = ({ room, promptUpgrade, aliasWarning, @@ -287,7 +287,10 @@ const JoinRuleSettings: React.FC = ({ fn(_t("Upgrading room"), 0, total); } else if (!progress.roomSynced) { fn(_t("Loading new room"), 1, total); - } else if (progress.inviteUsersProgress < progress.inviteUsersTotal) { + } else if ( + progress.inviteUsersProgress !== undefined && + progress.inviteUsersProgress < progress.inviteUsersTotal + ) { fn( _t("Sending invites... (%(progress)s out of %(count)s)", { progress: progress.inviteUsersProgress, @@ -296,13 +299,16 @@ const JoinRuleSettings: React.FC = ({ 2 + progress.inviteUsersProgress, total, ); - } else if (progress.updateSpacesProgress < progress.updateSpacesTotal) { + } else if ( + progress.updateSpacesProgress !== undefined && + progress.updateSpacesProgress < progress.updateSpacesTotal + ) { fn( _t("Updating spaces... (%(progress)s out of %(count)s)", { progress: progress.updateSpacesProgress, count: progress.updateSpacesTotal, }), - 2 + progress.inviteUsersProgress + progress.updateSpacesProgress, + 2 + (progress.inviteUsersProgress ?? 0) + progress.updateSpacesProgress, total, ); } diff --git a/src/components/views/settings/Notifications.tsx b/src/components/views/settings/Notifications.tsx index 3e833b315fe..6c6c38c0b4f 100644 --- a/src/components/views/settings/Notifications.tsx +++ b/src/components/views/settings/Notifications.tsx @@ -167,7 +167,7 @@ const maximumVectorState = ( if (!definition.syncedRuleIds?.length) { return undefined; } - const vectorState = definition.syncedRuleIds.reduce((maxVectorState, ruleId) => { + const vectorState = definition.syncedRuleIds.reduce((maxVectorState, ruleId) => { // already set to maximum if (maxVectorState === VectorState.Loud) { return maxVectorState; @@ -177,12 +177,15 @@ const maximumVectorState = ( const syncedRuleVectorState = definition.ruleToVectorState(syncedRule); // if syncedRule is 'louder' than current maximum // set maximum to louder vectorState - if (OrderedVectorStates.indexOf(syncedRuleVectorState) > OrderedVectorStates.indexOf(maxVectorState)) { + if ( + syncedRuleVectorState && + OrderedVectorStates.indexOf(syncedRuleVectorState) > OrderedVectorStates.indexOf(maxVectorState) + ) { return syncedRuleVectorState; } } return maxVectorState; - }, definition.ruleToVectorState(rule)); + }, definition.ruleToVectorState(rule)!); return vectorState; }; @@ -281,7 +284,7 @@ export default class Notifications extends React.PureComponent { } private async refreshRules(): Promise> { - const ruleSets = await MatrixClientPeg.get().getPushRules(); + const ruleSets = await MatrixClientPeg.get().getPushRules()!; const categories: Record = { [RuleId.Master]: RuleClass.Master, @@ -316,7 +319,7 @@ export default class Notifications extends React.PureComponent { // noinspection JSUnfilteredForInLoop const kind = k as PushRuleKind; - for (const r of ruleSets.global[kind]) { + for (const r of ruleSets.global[kind]!) { const rule: IAnnotatedPushRule = Object.assign(r, { kind }); const category = categories[rule.rule_id] ?? RuleClass.Other; @@ -344,7 +347,7 @@ export default class Notifications extends React.PureComponent { preparedNewState.vectorPushRules[category] = []; for (const rule of defaultRules[category]) { const definition: VectorPushRuleDefinition = VectorPushRulesDefinitions[rule.rule_id]; - const vectorState = definition.ruleToVectorState(rule); + const vectorState = definition.ruleToVectorState(rule)!; preparedNewState.vectorPushRules[category]!.push({ ruleId: rule.rule_id, rule, @@ -441,8 +444,7 @@ export default class Notifications extends React.PureComponent { } else { const pusher = this.state.pushers?.find((p) => p.kind === "email" && p.pushkey === email); if (pusher) { - pusher.kind = null; // flag for delete - await MatrixClientPeg.get().setPusher(pusher); + await MatrixClientPeg.get().removePusher(pusher.pushkey, pusher.app_id); } } @@ -539,17 +541,20 @@ export default class Notifications extends React.PureComponent { } }; - private async setKeywords(keywords: string[], originalRules: IAnnotatedPushRule[]): Promise { + private async setKeywords( + unsafeKeywords: (string | undefined)[], + originalRules: IAnnotatedPushRule[], + ): Promise { try { // De-duplicate and remove empties - keywords = filterBoolean(Array.from(new Set(keywords))); - const oldKeywords = filterBoolean(Array.from(new Set(originalRules.map((r) => r.pattern)))); + const keywords = filterBoolean(Array.from(new Set(unsafeKeywords))); + const oldKeywords = filterBoolean(Array.from(new Set(originalRules.map((r) => r.pattern)))); // Note: Technically because of the UI interaction (at the time of writing), the diff // will only ever be +/-1 so we don't really have to worry about efficiently handling // tons of keyword changes. - const diff = arrayDiff(oldKeywords, keywords); + const diff = arrayDiff(oldKeywords, keywords); for (const word of diff.removed) { for (const rule of originalRules.filter((r) => r.pattern === word)) { @@ -557,16 +562,16 @@ export default class Notifications extends React.PureComponent { } } - let ruleVectorState = this.state.vectorKeywordRuleInfo?.vectorState; + let ruleVectorState = this.state.vectorKeywordRuleInfo!.vectorState; if (ruleVectorState === VectorState.Off) { // When the current global keywords rule is OFF, we need to look at // the flavor of existing rules to apply the same actions // when creating the new rule. - if (originalRules.length) { - ruleVectorState = PushRuleVectorState.contentRuleVectorStateKind(originalRules[0]) ?? undefined; - } else { - ruleVectorState = VectorState.On; // default - } + const existingRuleVectorState = originalRules.length + ? PushRuleVectorState.contentRuleVectorStateKind(originalRules[0]) + : undefined; + // set to same state as existing rule, or default to On + ruleVectorState = existingRuleVectorState ?? VectorState.On; //default } const kind = PushRuleKind.ContentSpecific; for (const word of diff.added) { @@ -588,6 +593,10 @@ export default class Notifications extends React.PureComponent { } private onKeywordAdd = (keyword: string): void => { + // should not encounter this + if (!this.state.vectorKeywordRuleInfo) { + throw new Error("Notification data is incomplete."); + } const originalRules = objectClone(this.state.vectorKeywordRuleInfo.rules); // We add the keyword immediately as a sort of local echo effect @@ -606,7 +615,7 @@ export default class Notifications extends React.PureComponent { }, async (): Promise => { await this.setKeywords( - this.state.vectorKeywordRuleInfo.rules.map((r) => r.pattern), + this.state.vectorKeywordRuleInfo!.rules.map((r) => r.pattern), originalRules, ); }, @@ -614,6 +623,10 @@ export default class Notifications extends React.PureComponent { }; private onKeywordRemove = (keyword: string): void => { + // should not encounter this + if (!this.state.vectorKeywordRuleInfo) { + throw new Error("Notification data is incomplete."); + } const originalRules = objectClone(this.state.vectorKeywordRuleInfo.rules); // We remove the keyword immediately as a sort of local echo effect @@ -627,7 +640,7 @@ export default class Notifications extends React.PureComponent { }, async (): Promise => { await this.setKeywords( - this.state.vectorKeywordRuleInfo.rules.map((r) => r.pattern), + this.state.vectorKeywordRuleInfo!.rules.map((r) => r.pattern), originalRules, ); }, @@ -749,9 +762,10 @@ export default class Notifications extends React.PureComponent { let keywordComposer: JSX.Element | undefined; if (category === RuleClass.VectorMentions) { + const tags = filterBoolean(this.state.vectorKeywordRuleInfo?.rules.map((r) => r.pattern) || []); keywordComposer = ( r.pattern)} + tags={tags} onAdd={this.onKeywordAdd} onRemove={this.onKeywordRemove} disabled={this.state.phase === Phase.Persisting} diff --git a/src/components/views/settings/ProfileSettings.tsx b/src/components/views/settings/ProfileSettings.tsx index d7fdd9c143f..d8ef9928358 100644 --- a/src/components/views/settings/ProfileSettings.tsx +++ b/src/components/views/settings/ProfileSettings.tsx @@ -65,7 +65,9 @@ export default class ProfileSettings extends React.Component<{}, IState> { private removeAvatar = (): void => { // clear file upload field so same file can be selected - this.avatarUpload.current.value = ""; + if (this.avatarUpload.current) { + this.avatarUpload.current.value = ""; + } this.setState({ avatarUrl: undefined, avatarFile: null, diff --git a/src/components/views/settings/SecureBackupPanel.tsx b/src/components/views/settings/SecureBackupPanel.tsx index 1df87008c78..4d249c8df8f 100644 --- a/src/components/views/settings/SecureBackupPanel.tsx +++ b/src/components/views/settings/SecureBackupPanel.tsx @@ -99,12 +99,12 @@ export default class SecureBackupPanel extends React.PureComponent<{}, IState> { private async checkKeyBackupStatus(): Promise { this.getUpdatedDiagnostics(); try { - const { backupInfo, trustInfo } = await MatrixClientPeg.get().checkKeyBackup(); + const keyBackupResult = await MatrixClientPeg.get().checkKeyBackup(); this.setState({ loading: false, error: null, - backupInfo, - backupSigStatus: trustInfo, + backupInfo: keyBackupResult?.backupInfo ?? null, + backupSigStatus: keyBackupResult?.trustInfo ?? null, }); } catch (e) { logger.log("Unable to fetch check backup status", e); @@ -123,7 +123,7 @@ export default class SecureBackupPanel extends React.PureComponent<{}, IState> { this.getUpdatedDiagnostics(); try { const backupInfo = await MatrixClientPeg.get().getKeyBackupVersion(); - const backupSigStatus = await MatrixClientPeg.get().isKeyBackupTrusted(backupInfo!); + const backupSigStatus = backupInfo ? await MatrixClientPeg.get().isKeyBackupTrusted(backupInfo) : null; if (this.unmounted) return; this.setState({ loading: false, @@ -192,7 +192,7 @@ export default class SecureBackupPanel extends React.PureComponent<{}, IState> { if (!proceed) return; this.setState({ loading: true }); MatrixClientPeg.get() - .deleteKeyBackupVersion(this.state.backupInfo.version) + .deleteKeyBackupVersion(this.state.backupInfo!.version!) .then(() => { this.loadBackupStatus(); }); @@ -285,7 +285,7 @@ export default class SecureBackupPanel extends React.PureComponent<{}, IState> { ); } - let backupSigStatuses: React.ReactNode = backupSigStatus?.sigs.map((sig, i) => { + let backupSigStatuses: React.ReactNode | undefined = backupSigStatus?.sigs?.map((sig, i) => { const deviceName = sig.device ? sig.device.getDisplayName() || sig.device.deviceId : null; const validity = (sub: string): JSX.Element => ( @@ -354,7 +354,7 @@ export default class SecureBackupPanel extends React.PureComponent<{}, IState> { {}, { validity, verify, device }, ); - } else if (sig.valid && !sig.deviceTrust.isVerified()) { + } else if (sig.valid && !sig.deviceTrust?.isVerified()) { sigStatus = _t( "Backup has a valid signature from " + "unverified session ", @@ -368,7 +368,7 @@ export default class SecureBackupPanel extends React.PureComponent<{}, IState> { {}, { validity, verify, device }, ); - } else if (!sig.valid && !sig.deviceTrust.isVerified()) { + } else if (!sig.valid && !sig.deviceTrust?.isVerified()) { sigStatus = _t( "Backup has an invalid signature from " + "unverified session ", diff --git a/src/components/views/settings/tabs/user/VoiceUserSettingsTab.tsx b/src/components/views/settings/tabs/user/VoiceUserSettingsTab.tsx index 71d33171df0..ada24bdf271 100644 --- a/src/components/views/settings/tabs/user/VoiceUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/VoiceUserSettingsTab.tsx @@ -83,7 +83,7 @@ export default class VoiceUserSettingsTab extends React.Component<{}, IState> { private setDevice = (deviceId: string, kind: MediaDeviceKindEnum): void => { MediaDeviceHandler.instance.setDevice(deviceId, kind); - this.setState({ [kind]: deviceId }); + this.setState({ [kind]: deviceId }); }; private changeWebRtcMethod = (p2p: boolean): void => { diff --git a/test/components/views/settings/JoinRuleSettings-test.tsx b/test/components/views/settings/JoinRuleSettings-test.tsx new file mode 100644 index 00000000000..6cd3696a124 --- /dev/null +++ b/test/components/views/settings/JoinRuleSettings-test.tsx @@ -0,0 +1,250 @@ +/* +Copyright 2023 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 React from "react"; +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { + EventType, + GuestAccess, + HistoryVisibility, + JoinRule, + MatrixEvent, + Room, + ClientEvent, + RoomMember, +} from "matrix-js-sdk/src/matrix"; +import { defer, IDeferred } from "matrix-js-sdk/src/utils"; + +import { + clearAllModals, + flushPromises, + getMockClientWithEventEmitter, + mockClientMethodsUser, +} from "../../../test-utils"; +import { filterBoolean } from "../../../../src/utils/arrays"; +import JoinRuleSettings, { JoinRuleSettingsProps } from "../../../../src/components/views/settings/JoinRuleSettings"; +import { PreferredRoomVersions } from "../../../../src/utils/PreferredRoomVersions"; +import SpaceStore from "../../../../src/stores/spaces/SpaceStore"; + +describe("", () => { + const userId = "@alice:server.org"; + const client = getMockClientWithEventEmitter({ + ...mockClientMethodsUser(userId), + getRoom: jest.fn(), + getLocalAliases: jest.fn().mockReturnValue([]), + sendStateEvent: jest.fn(), + upgradeRoom: jest.fn(), + getProfileInfo: jest.fn(), + invite: jest.fn().mockResolvedValue(undefined), + isRoomEncrypted: jest.fn().mockReturnValue(false), + }); + const roomId = "!room:server.org"; + const newRoomId = "!roomUpgraded:server.org"; + + const defaultProps = { + room: new Room(roomId, client, userId), + closeSettingsFn: jest.fn(), + onError: jest.fn(), + }; + const getComponent = (props: Partial = {}) => + render(); + + const setRoomStateEvents = ( + room: Room, + version = "9", + joinRule?: JoinRule, + guestAccess?: GuestAccess, + history?: HistoryVisibility, + ): void => { + const events = filterBoolean([ + new MatrixEvent({ + type: EventType.RoomCreate, + content: { version }, + sender: userId, + state_key: "", + room_id: room.roomId, + }), + guestAccess && + new MatrixEvent({ + type: EventType.RoomGuestAccess, + content: { guest_access: guestAccess }, + sender: userId, + state_key: "", + room_id: room.roomId, + }), + history && + new MatrixEvent({ + type: EventType.RoomHistoryVisibility, + content: { history_visibility: history }, + sender: userId, + state_key: "", + room_id: room.roomId, + }), + joinRule && + new MatrixEvent({ + type: EventType.RoomJoinRules, + content: { join_rule: joinRule }, + sender: userId, + state_key: "", + room_id: room.roomId, + }), + ]); + + room.currentState.setStateEvents(events); + }; + + beforeEach(() => { + client.sendStateEvent.mockReset().mockResolvedValue({ event_id: "test" }); + client.isRoomEncrypted.mockReturnValue(false); + client.upgradeRoom.mockResolvedValue({ replacement_room: newRoomId }); + client.getRoom.mockReturnValue(null); + }); + + describe("Restricted rooms", () => { + afterEach(async () => { + await clearAllModals(); + }); + describe("When room does not support restricted rooms", () => { + it("should not show restricted room join rule when upgrade not enabled", () => { + // room that doesnt support restricted rooms + const v8Room = new Room(roomId, client, userId); + setRoomStateEvents(v8Room, "8"); + + getComponent({ room: v8Room, promptUpgrade: false }); + + expect(screen.queryByText("Space members")).not.toBeInTheDocument(); + }); + + it("should show restricted room join rule when upgrade is enabled", () => { + // room that doesnt support restricted rooms + const v8Room = new Room(roomId, client, userId); + setRoomStateEvents(v8Room, "8"); + + getComponent({ room: v8Room, promptUpgrade: true }); + + expect(screen.getByText("Space members")).toBeInTheDocument(); + expect(screen.getByText("Upgrade required")).toBeInTheDocument(); + }); + + it("upgrades room when changing join rule to restricted", async () => { + const deferredInvites: IDeferred[] = []; + // room that doesnt support restricted rooms + const v8Room = new Room(roomId, client, userId); + const parentSpace = new Room("!parentSpace:server.org", client, userId); + jest.spyOn(SpaceStore.instance, "getKnownParents").mockReturnValue(new Set([parentSpace.roomId])); + setRoomStateEvents(v8Room, "8"); + const memberAlice = new RoomMember(roomId, "@alice:server.org"); + const memberBob = new RoomMember(roomId, "@bob:server.org"); + const memberCharlie = new RoomMember(roomId, "@charlie:server.org"); + jest.spyOn(v8Room, "getMembersWithMembership").mockImplementation((membership) => + membership === "join" ? [memberAlice, memberBob] : [memberCharlie], + ); + const upgradedRoom = new Room(newRoomId, client, userId); + setRoomStateEvents(upgradedRoom); + client.getRoom.mockImplementation((id) => { + if (roomId === id) return v8Room; + if (parentSpace.roomId === id) return parentSpace; + return null; + }); + + // resolve invites by hand + // flushPromises is too blunt to test reliably + client.invite.mockImplementation(() => { + const p = defer<{}>(); + deferredInvites.push(p); + return p.promise; + }); + + getComponent({ room: v8Room, promptUpgrade: true }); + + fireEvent.click(screen.getByText("Space members")); + + const dialog = await screen.findByRole("dialog"); + + fireEvent.click(within(dialog).getByText("Upgrade")); + + expect(client.upgradeRoom).toHaveBeenCalledWith(roomId, PreferredRoomVersions.RestrictedRooms); + + expect(within(dialog).getByText("Upgrading room")).toBeInTheDocument(); + + await flushPromises(); + + expect(within(dialog).getByText("Loading new room")).toBeInTheDocument(); + + // "create" our new room, have it come thru sync + client.getRoom.mockImplementation((id) => { + if (roomId === id) return v8Room; + if (newRoomId === id) return upgradedRoom; + if (parentSpace.roomId === id) return parentSpace; + return null; + }); + client.emit(ClientEvent.Room, upgradedRoom); + + // invite users + expect(await screen.findByText("Sending invites... (0 out of 2)")).toBeInTheDocument(); + deferredInvites.pop()!.resolve({}); + expect(await screen.findByText("Sending invites... (1 out of 2)")).toBeInTheDocument(); + deferredInvites.pop()!.resolve({}); + + // update spaces + expect(await screen.findByText("Updating space...")).toBeInTheDocument(); + + await flushPromises(); + + // done, modal closed + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("upgrades room with no parent spaces or members when changing join rule to restricted", async () => { + // room that doesnt support restricted rooms + const v8Room = new Room(roomId, client, userId); + setRoomStateEvents(v8Room, "8"); + const upgradedRoom = new Room(newRoomId, client, userId); + setRoomStateEvents(upgradedRoom); + + getComponent({ room: v8Room, promptUpgrade: true }); + + fireEvent.click(screen.getByText("Space members")); + + const dialog = await screen.findByRole("dialog"); + + fireEvent.click(within(dialog).getByText("Upgrade")); + + expect(client.upgradeRoom).toHaveBeenCalledWith(roomId, PreferredRoomVersions.RestrictedRooms); + + expect(within(dialog).getByText("Upgrading room")).toBeInTheDocument(); + + await flushPromises(); + + expect(within(dialog).getByText("Loading new room")).toBeInTheDocument(); + + // "create" our new room, have it come thru sync + client.getRoom.mockImplementation((id) => { + if (roomId === id) return v8Room; + if (newRoomId === id) return upgradedRoom; + return null; + }); + client.emit(ClientEvent.Room, upgradedRoom); + + await flushPromises(); + await flushPromises(); + + // done, modal closed + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + }); + }); +}); diff --git a/test/components/views/settings/Notifications-test.tsx b/test/components/views/settings/Notifications-test.tsx index 535239b2127..523e89443cb 100644 --- a/test/components/views/settings/Notifications-test.tsx +++ b/test/components/views/settings/Notifications-test.tsx @@ -1,5 +1,5 @@ /* -Copyright 2022 The Matrix.org Foundation C.I.C. +Copyright 2022, 2023 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. @@ -26,16 +26,18 @@ import { TweakName, ConditionKind, IPushRuleCondition, + PushRuleKind, } from "matrix-js-sdk/src/matrix"; import { randomString } from "matrix-js-sdk/src/randomstring"; import { IThreepid, ThreepidMedium } from "matrix-js-sdk/src/@types/threepids"; import { act, fireEvent, getByTestId, render, screen, waitFor, within } from "@testing-library/react"; import { mocked } from "jest-mock"; +import userEvent from "@testing-library/user-event"; import Notifications from "../../../../src/components/views/settings/Notifications"; import SettingsStore from "../../../../src/settings/SettingsStore"; import { StandardActions } from "../../../../src/notifications/StandardActions"; -import { getMockClientWithEventEmitter, mkMessage, mockClientMethodsUser } from "../../../test-utils"; +import { clearAllModals, getMockClientWithEventEmitter, mkMessage, mockClientMethodsUser } from "../../../test-utils"; // don't pollute test output with error logs from mock rejections jest.mock("matrix-js-sdk/src/logger"); @@ -257,6 +259,7 @@ describe("", () => { getPushers: jest.fn(), getThreePids: jest.fn(), setPusher: jest.fn(), + removePusher: jest.fn(), setPushRuleEnabled: jest.fn(), setPushRuleActions: jest.fn(), getRooms: jest.fn().mockReturnValue([]), @@ -274,10 +277,12 @@ describe("", () => { sendReadReceipt: jest.fn(), supportsThreads: jest.fn().mockReturnValue(true), isInitialSyncComplete: jest.fn().mockReturnValue(false), + addPushRule: jest.fn().mockResolvedValue({}), + deletePushRule: jest.fn().mockResolvedValue({}), }); mockClient.getPushRules.mockResolvedValue(pushRules); - beforeEach(() => { + beforeEach(async () => { let i = 0; mocked(randomString).mockImplementation(() => { return "testid_" + i++; @@ -286,9 +291,17 @@ describe("", () => { mockClient.getPushRules.mockClear().mockResolvedValue(pushRules); mockClient.getPushers.mockClear().mockResolvedValue({ pushers: [] }); mockClient.getThreePids.mockClear().mockResolvedValue({ threepids: [] }); - mockClient.setPusher.mockClear().mockResolvedValue({}); + mockClient.setPusher.mockReset().mockResolvedValue({}); + mockClient.removePusher.mockClear().mockResolvedValue({}); mockClient.setPushRuleActions.mockReset().mockResolvedValue({}); mockClient.pushRules = pushRules; + mockClient.getPushRules.mockClear().mockResolvedValue(pushRules); + mockClient.addPushRule.mockClear(); + mockClient.deletePushRule.mockClear(); + + userEvent.setup(); + + await clearAllModals(); }); it("renders spinner while loading", async () => { @@ -392,21 +405,30 @@ describe("", () => { // force render await flushPromises(); + const dialog = await screen.findByRole("dialog"); + + expect( + within(dialog).getByText("An error occurred whilst saving your notification preferences."), + ).toBeInTheDocument(); + + // dismiss the dialog + fireEvent.click(within(dialog).getByText("OK")); expect(screen.getByTestId("error-message")).toBeInTheDocument(); }); it("enables email notification when toggling off", async () => { - const testPusher = { kind: "email", pushkey: "tester@test.com" } as unknown as IPusher; + const testPusher = { + kind: "email", + pushkey: "tester@test.com", + app_id: "testtest", + } as unknown as IPusher; mockClient.getPushers.mockResolvedValue({ pushers: [testPusher] }); await getComponentAndWait(); const emailToggle = screen.getByTestId("notif-email-switch").querySelector('div[role="switch"]')!; fireEvent.click(emailToggle); - expect(mockClient.setPusher).toHaveBeenCalledWith({ - ...testPusher, - kind: null, - }); + expect(mockClient.removePusher).toHaveBeenCalledWith(testPusher.pushkey, testPusher.app_id); }); }); @@ -809,6 +831,66 @@ describe("", () => { ), ).toBeInTheDocument(); }); + + it("adds a new keyword", async () => { + await getComponentAndWait(); + + await userEvent.type(screen.getByLabelText("Keyword"), "jest"); + expect(screen.getByLabelText("Keyword")).toHaveValue("jest"); + + fireEvent.click(screen.getByText("Add")); + + expect(mockClient.addPushRule).toHaveBeenCalledWith("global", PushRuleKind.ContentSpecific, "jest", { + actions: [PushRuleActionName.Notify, { set_tweak: "highlight", value: false }], + pattern: "jest", + }); + }); + + it("adds a new keyword with same actions as existing rules when keywords rule is off", async () => { + const offContentRule = { + ...bananaRule, + enabled: false, + actions: [PushRuleActionName.Notify], + }; + const pushRulesWithContentOff = { + global: { + ...pushRules.global, + content: [offContentRule], + }, + }; + mockClient.pushRules = pushRulesWithContentOff; + mockClient.getPushRules.mockClear().mockResolvedValue(pushRulesWithContentOff); + + await getComponentAndWait(); + + const keywords = screen.getByTestId("vector_mentions_keywords"); + + expect(within(keywords).getByLabelText("Off")).toBeChecked(); + + await userEvent.type(screen.getByLabelText("Keyword"), "jest"); + expect(screen.getByLabelText("Keyword")).toHaveValue("jest"); + + fireEvent.click(screen.getByText("Add")); + + expect(mockClient.addPushRule).toHaveBeenCalledWith("global", PushRuleKind.ContentSpecific, "jest", { + actions: [PushRuleActionName.Notify, { set_tweak: TweakName.Highlight, value: false }], + pattern: "jest", + }); + }); + + it("removes keyword", async () => { + await getComponentAndWait(); + + await userEvent.type(screen.getByLabelText("Keyword"), "jest"); + + const keyword = screen.getByText("banana"); + + fireEvent.click(within(keyword.parentElement!).getByLabelText("Remove")); + + expect(mockClient.deletePushRule).toHaveBeenCalledWith("global", PushRuleKind.ContentSpecific, "banana"); + + await flushPromises(); + }); }); describe("clear all notifications", () => { diff --git a/test/components/views/settings/SecureBackupPanel-test.tsx b/test/components/views/settings/SecureBackupPanel-test.tsx new file mode 100644 index 00000000000..c8ad4790f1b --- /dev/null +++ b/test/components/views/settings/SecureBackupPanel-test.tsx @@ -0,0 +1,187 @@ +/* +Copyright 2023 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 React from "react"; +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { mocked } from "jest-mock"; + +import { flushPromises, getMockClientWithEventEmitter, mockClientMethodsUser } from "../../../test-utils"; +import SecureBackupPanel from "../../../../src/components/views/settings/SecureBackupPanel"; +import { accessSecretStorage } from "../../../../src/SecurityManager"; + +jest.mock("../../../../src/SecurityManager", () => ({ + accessSecretStorage: jest.fn(), +})); + +describe("", () => { + const userId = "@alice:server.org"; + const client = getMockClientWithEventEmitter({ + ...mockClientMethodsUser(userId), + checkKeyBackup: jest.fn(), + isKeyBackupKeyStored: jest.fn(), + isSecretStorageReady: jest.fn(), + getKeyBackupEnabled: jest.fn(), + getKeyBackupVersion: jest.fn().mockReturnValue("1"), + isKeyBackupTrusted: jest.fn().mockResolvedValue(true), + getClientWellKnown: jest.fn(), + deleteKeyBackupVersion: jest.fn(), + }); + // @ts-ignore allow it + client.crypto = { + secretStorage: { hasKey: jest.fn() }, + getSessionBackupPrivateKey: jest.fn(), + } as unknown as Crypto; + + const getComponent = () => render(); + + beforeEach(() => { + client.checkKeyBackup.mockResolvedValue({ + backupInfo: { + version: "1", + algorithm: "test", + auth_data: { + public_key: "1234", + }, + }, + trustInfo: { + usable: false, + sigs: [], + }, + }); + + mocked(client.crypto!.secretStorage.hasKey).mockClear().mockResolvedValue(false); + client.deleteKeyBackupVersion.mockClear().mockResolvedValue(); + client.getKeyBackupVersion.mockClear(); + client.isKeyBackupTrusted.mockClear(); + + mocked(accessSecretStorage).mockClear().mockResolvedValue(); + }); + + it("displays a loader while checking keybackup", async () => { + getComponent(); + expect(screen.getByRole("progressbar")).toBeInTheDocument(); + await flushPromises(); + expect(screen.queryByRole("progressbar")).not.toBeInTheDocument(); + }); + + it("handles null backup info", async () => { + // checkKeyBackup can fail and return null for various reasons + client.checkKeyBackup.mockResolvedValue(null); + getComponent(); + // flush checkKeyBackup promise + await flushPromises(); + + // no backup info + expect(screen.getByText("Back up your keys before signing out to avoid losing them.")).toBeInTheDocument(); + }); + + it("suggests connecting session to key backup when backup exists", async () => { + const { container } = getComponent(); + // flush checkKeyBackup promise + await flushPromises(); + + expect(container).toMatchSnapshot(); + }); + + it("displays when session is connected to key backup", async () => { + client.getKeyBackupEnabled.mockReturnValue(true); + getComponent(); + // flush checkKeyBackup promise + await flushPromises(); + + expect(screen.getByText("✅ This session is backing up your keys.")).toBeInTheDocument(); + }); + + it("asks for confirmation before deleting a backup", async () => { + getComponent(); + // flush checkKeyBackup promise + await flushPromises(); + + fireEvent.click(screen.getByText("Delete Backup")); + + const dialog = await screen.findByRole("dialog"); + + expect( + within(dialog).getByText( + "Are you sure? You will lose your encrypted messages if your keys are not backed up properly.", + ), + ).toBeInTheDocument(); + + fireEvent.click(within(dialog).getByText("Cancel")); + + expect(client.deleteKeyBackupVersion).not.toHaveBeenCalled(); + }); + + it("deletes backup after confirmation", async () => { + client.checkKeyBackup + .mockResolvedValueOnce({ + backupInfo: { + version: "1", + algorithm: "test", + auth_data: { + public_key: "1234", + }, + }, + trustInfo: { + usable: false, + sigs: [], + }, + }) + .mockResolvedValue(null); + getComponent(); + // flush checkKeyBackup promise + await flushPromises(); + + fireEvent.click(screen.getByText("Delete Backup")); + + const dialog = await screen.findByRole("dialog"); + + expect( + within(dialog).getByText( + "Are you sure? You will lose your encrypted messages if your keys are not backed up properly.", + ), + ).toBeInTheDocument(); + + fireEvent.click(within(dialog).getByTestId("dialog-primary-button")); + + expect(client.deleteKeyBackupVersion).toHaveBeenCalledWith("1"); + + // delete request + await flushPromises(); + // refresh backup info + await flushPromises(); + }); + + it("resets secret storage", async () => { + mocked(client.crypto!.secretStorage.hasKey).mockClear().mockResolvedValue(true); + getComponent(); + // flush checkKeyBackup promise + await flushPromises(); + + client.getKeyBackupVersion.mockClear(); + client.isKeyBackupTrusted.mockClear(); + + fireEvent.click(screen.getByText("Reset")); + + // enter loading state + expect(accessSecretStorage).toHaveBeenCalled(); + await flushPromises(); + + // backup status refreshed + expect(client.getKeyBackupVersion).toHaveBeenCalled(); + expect(client.isKeyBackupTrusted).toHaveBeenCalled(); + }); +}); diff --git a/test/components/views/settings/__snapshots__/SecureBackupPanel-test.tsx.snap b/test/components/views/settings/__snapshots__/SecureBackupPanel-test.tsx.snap new file mode 100644 index 00000000000..e17dfd0064c --- /dev/null +++ b/test/components/views/settings/__snapshots__/SecureBackupPanel-test.tsx.snap @@ -0,0 +1,116 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` suggests connecting session to key backup when backup exists 1`] = ` +
+
+

+ Back up your encryption keys with your account data in case you lose access to your sessions. Your keys will be secured with a unique Security Key. +

+

+ + This session is + + not backing up your keys + + , but you do have an existing backup you can restore from and add to going forward. + +

+

+ Connect this session to key backup before signing out to avoid losing any keys that may only be on this session. +

+
+ + Advanced + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Backup key stored: + + not stored +
+ Backup key cached: + + not found locally + +
+ Secret storage public key: + + not found +
+ Secret storage: + + not ready +
+ Backup version: + + 1 +
+ Algorithm: + + test +
+ +
+ Backup is not signed by any of your sessions +
+
+
+
+
+ Connect this session to Key Backup +
+
+ Delete Backup +
+
+
+
+`; diff --git a/test/components/views/settings/tabs/user/VoiceUserSettingsTab-test.tsx b/test/components/views/settings/tabs/user/VoiceUserSettingsTab-test.tsx index 9bc8b205171..1771b9c8ab1 100644 --- a/test/components/views/settings/tabs/user/VoiceUserSettingsTab-test.tsx +++ b/test/components/views/settings/tabs/user/VoiceUserSettingsTab-test.tsx @@ -16,10 +16,11 @@ limitations under the License. import React from "react"; import { mocked } from "jest-mock"; -import { render } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import VoiceUserSettingsTab from "../../../../../../src/components/views/settings/tabs/user/VoiceUserSettingsTab"; -import MediaDeviceHandler from "../../../../../../src/MediaDeviceHandler"; +import MediaDeviceHandler, { IMediaDevices, MediaDeviceKindEnum } from "../../../../../../src/MediaDeviceHandler"; +import { flushPromises } from "../../../../../test-utils"; jest.mock("../../../../../../src/MediaDeviceHandler"); const MediaDeviceHandlerMock = mocked(MediaDeviceHandler); @@ -27,8 +28,69 @@ const MediaDeviceHandlerMock = mocked(MediaDeviceHandler); describe("", () => { const getComponent = (): React.ReactElement => ; + const audioIn1 = { + deviceId: "1", + groupId: "g1", + kind: MediaDeviceKindEnum.AudioInput, + label: "Audio input test 1", + }; + const videoIn1 = { + deviceId: "2", + groupId: "g1", + kind: MediaDeviceKindEnum.VideoInput, + label: "Video input test 1", + }; + const videoIn2 = { + deviceId: "3", + groupId: "g1", + kind: MediaDeviceKindEnum.VideoInput, + label: "Video input test 2", + }; + const defaultMediaDevices = { + [MediaDeviceKindEnum.AudioOutput]: [], + [MediaDeviceKindEnum.AudioInput]: [audioIn1], + [MediaDeviceKindEnum.VideoInput]: [videoIn1, videoIn2], + } as unknown as IMediaDevices; + beforeEach(() => { jest.clearAllMocks(); + MediaDeviceHandlerMock.hasAnyLabeledDevices.mockResolvedValue(true); + MediaDeviceHandlerMock.getDevices.mockResolvedValue(defaultMediaDevices); + + // @ts-ignore bad mocking + MediaDeviceHandlerMock.instance = { setDevice: jest.fn() }; + }); + + describe("devices", () => { + it("renders dropdowns for input devices", async () => { + render(getComponent()); + await flushPromises(); + + expect(screen.getByLabelText("Microphone")).toHaveDisplayValue(audioIn1.label); + expect(screen.getByLabelText("Camera")).toHaveDisplayValue(videoIn1.label); + }); + + it("updates device", async () => { + render(getComponent()); + await flushPromises(); + + fireEvent.change(screen.getByLabelText("Camera"), { target: { value: videoIn2.deviceId } }); + + expect(MediaDeviceHandlerMock.instance.setDevice).toHaveBeenCalledWith( + videoIn2.deviceId, + MediaDeviceKindEnum.VideoInput, + ); + + expect(screen.getByLabelText("Camera")).toHaveDisplayValue(videoIn2.label); + }); + + it("does not render dropdown when no devices exist for type", async () => { + render(getComponent()); + await flushPromises(); + + expect(screen.getByText("No Audio Outputs detected")).toBeInTheDocument(); + expect(screen.queryByLabelText("Audio Output")).not.toBeInTheDocument(); + }); }); it("renders audio processing settings", () => {