diff --git a/src/components/views/elements/Tag.tsx b/src/components/views/elements/Tag.tsx
index f6d90cede4..d7d46fe7e1 100644
--- a/src/components/views/elements/Tag.tsx
+++ b/src/components/views/elements/Tag.tsx
@@ -32,7 +32,12 @@ export const Tag: React.FC<IProps> = ({ icon, label, onDeleteClick, disabled = f
             {icon?.()}
             {label}
             {onDeleteClick && (
-                <AccessibleButton className="mx_Tag_delete" onClick={onDeleteClick} disabled={disabled}>
+                <AccessibleButton
+                    aria-label="Remove"
+                    className="mx_Tag_delete"
+                    onClick={onDeleteClick}
+                    disabled={disabled}
+                >
                     <CancelRounded />
                 </AccessibleButton>
             )}
diff --git a/src/components/views/settings/JoinRuleSettings.tsx b/src/components/views/settings/JoinRuleSettings.tsx
index d478639dc0..4aee5d74ca 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<IProps> = ({
+const JoinRuleSettings: React.FC<JoinRuleSettingsProps> = ({
     room,
     promptUpgrade,
     aliasWarning,
@@ -287,7 +287,10 @@ const JoinRuleSettings: React.FC<IProps> = ({
                                     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<IProps> = ({
                                         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 3e833b315f..6c6c38c0b4 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<VectorState | undefined>((maxVectorState, ruleId) => {
+    const vectorState = definition.syncedRuleIds.reduce<VectorState>((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<IProps, IState> {
     }
 
     private async refreshRules(): Promise<Partial<IState>> {
-        const ruleSets = await MatrixClientPeg.get().getPushRules();
+        const ruleSets = await MatrixClientPeg.get().getPushRules()!;
         const categories: Record<string, RuleClass> = {
             [RuleId.Master]: RuleClass.Master,
 
@@ -316,7 +319,7 @@ export default class Notifications extends React.PureComponent<IProps, IState> {
             // 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<IProps, IState> {
             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<IProps, IState> {
             } 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<IProps, IState> {
         }
     };
 
-    private async setKeywords(keywords: string[], originalRules: IAnnotatedPushRule[]): Promise<void> {
+    private async setKeywords(
+        unsafeKeywords: (string | undefined)[],
+        originalRules: IAnnotatedPushRule[],
+    ): Promise<void> {
         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<string>(Array.from(new Set(unsafeKeywords)));
+            const oldKeywords = filterBoolean<string>(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<string>(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<IProps, IState> {
                 }
             }
 
-            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<IProps, IState> {
     }
 
     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<IProps, IState> {
             },
             async (): Promise<void> => {
                 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<IProps, IState> {
     };
 
     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<IProps, IState> {
             },
             async (): Promise<void> => {
                 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<IProps, IState> {
 
         let keywordComposer: JSX.Element | undefined;
         if (category === RuleClass.VectorMentions) {
+            const tags = filterBoolean<string>(this.state.vectorKeywordRuleInfo?.rules.map((r) => r.pattern) || []);
             keywordComposer = (
                 <TagComposer
-                    tags={this.state.vectorKeywordRuleInfo?.rules.map((r) => 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 d7fdd9c143..d8ef992835 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 1df87008c7..4d249c8df8 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<void> {
         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 => (
                     <span className={sig.valid ? "mx_SecureBackupPanel_sigValid" : "mx_SecureBackupPanel_sigInvalid"}>
@@ -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 <validity>valid</validity> signature from " +
                             "<verify>unverified</verify> session <device></device>",
@@ -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 <validity>invalid</validity> signature from " +
                             "<verify>unverified</verify> session <device></device>",
diff --git a/src/components/views/settings/tabs/user/VoiceUserSettingsTab.tsx b/src/components/views/settings/tabs/user/VoiceUserSettingsTab.tsx
index 71d33171df..ada24bdf27 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<null>({ [kind]: deviceId });
+        this.setState<any>({ [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 0000000000..6cd3696a12
--- /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("<JoinRuleSettings />", () => {
+    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<JoinRuleSettingsProps> = {}) =>
+        render(<JoinRuleSettings {...defaultProps} {...props} />);
+
+    const setRoomStateEvents = (
+        room: Room,
+        version = "9",
+        joinRule?: JoinRule,
+        guestAccess?: GuestAccess,
+        history?: HistoryVisibility,
+    ): void => {
+        const events = filterBoolean<MatrixEvent>([
+            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<any>[] = [];
+                // 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 535239b212..523e89443c 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("<Notifications />", () => {
         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("<Notifications />", () => {
         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("<Notifications />", () => {
         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("<Notifications />", () => {
                 // 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("<Notifications />", () => {
                 ),
             ).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 0000000000..c8ad4790f1
--- /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("<SecureBackupPanel />", () => {
+    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(<SecureBackupPanel />);
+
+    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 0000000000..e17dfd0064
--- /dev/null
+++ b/test/components/views/settings/__snapshots__/SecureBackupPanel-test.tsx.snap
@@ -0,0 +1,116 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`<SecureBackupPanel /> suggests connecting session to key backup when backup exists 1`] = `
+<div>
+  <div>
+    <p>
+      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.
+    </p>
+    <p>
+      <span>
+        This session is 
+        <b>
+          not backing up your keys
+        </b>
+        , but you do have an existing backup you can restore from and add to going forward.
+      </span>
+    </p>
+    <p>
+      Connect this session to key backup before signing out to avoid losing any keys that may only be on this session.
+    </p>
+    <details>
+      <summary>
+        Advanced
+      </summary>
+      <table
+        class="mx_SecureBackupPanel_statusList"
+      >
+        <tr>
+          <th
+            scope="row"
+          >
+            Backup key stored:
+          </th>
+          <td>
+            not stored
+          </td>
+        </tr>
+        <tr>
+          <th
+            scope="row"
+          >
+            Backup key cached:
+          </th>
+          <td>
+            not found locally
+            
+          </td>
+        </tr>
+        <tr>
+          <th
+            scope="row"
+          >
+            Secret storage public key:
+          </th>
+          <td>
+            not found
+          </td>
+        </tr>
+        <tr>
+          <th
+            scope="row"
+          >
+            Secret storage:
+          </th>
+          <td>
+            not ready
+          </td>
+        </tr>
+        <tr>
+          <th
+            scope="row"
+          >
+            Backup version:
+          </th>
+          <td>
+            1
+          </td>
+        </tr>
+        <tr>
+          <th
+            scope="row"
+          >
+            Algorithm:
+          </th>
+          <td>
+            test
+          </td>
+        </tr>
+      </table>
+      
+      <div>
+        Backup is not signed by any of your sessions
+      </div>
+      <div />
+    </details>
+    <div
+      class="mx_SecureBackupPanel_buttonRow"
+    >
+      <div
+        class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
+        role="button"
+        tabindex="0"
+      >
+        Connect this session to Key Backup
+      </div>
+      <div
+        class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_danger"
+        role="button"
+        tabindex="0"
+      >
+        Delete Backup
+      </div>
+    </div>
+  </div>
+</div>
+`;
diff --git a/test/components/views/settings/tabs/user/VoiceUserSettingsTab-test.tsx b/test/components/views/settings/tabs/user/VoiceUserSettingsTab-test.tsx
index 9bc8b20517..1771b9c8ab 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("<VoiceUserSettingsTab />", () => {
     const getComponent = (): React.ReactElement => <VoiceUserSettingsTab />;
 
+    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", () => {