From b2a10e6db3231f20502dfdfcfb435886bad06a9c Mon Sep 17 00:00:00 2001 From: Kerry Date: Wed, 14 Dec 2022 17:14:21 +1300 Subject: [PATCH] Support MSC3391: Account data deletion (#2967) * add deleteAccountData endpoint * check server support and test * test current state of memorystore * interpret account data events with empty content as deleted * add handling for (future) stable version of endpoint * add getSafeUserId * user getSafeUserId in deleteAccountData * better jsdoc for throws documentation --- spec/unit/matrix-client.spec.ts | 82 +++++++++++++++++++++++++++++++++ spec/unit/stores/memory.spec.ts | 65 ++++++++++++++++++++++++++ src/client.ts | 32 +++++++++++++ src/feature.ts | 4 ++ src/store/memory.ts | 8 +++- 5 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 spec/unit/stores/memory.spec.ts diff --git a/spec/unit/matrix-client.spec.ts b/spec/unit/matrix-client.spec.ts index b13dfc125..68c6ded9f 100644 --- a/spec/unit/matrix-client.spec.ts +++ b/spec/unit/matrix-client.spec.ts @@ -57,6 +57,7 @@ import { import { IOlmDevice } from "../../src/crypto/algorithms/megolm"; import { QueryDict } from "../../src/utils"; import { SyncState } from "../../src/sync"; +import * as featureUtils from "../../src/feature"; jest.useFakeTimers(); @@ -281,6 +282,23 @@ describe("MatrixClient", function () { client.stopClient(); }); + describe("getSafeUserId()", () => { + it("returns the logged in user id", () => { + expect(client.getSafeUserId()).toEqual(userId); + }); + + it("throws when there is not logged in user", () => { + const notLoggedInClient = new MatrixClient({ + baseUrl: "https://my.home.server", + idBaseUrl: identityServerUrl, + fetchFn: function () {} as any, // NOP + store: store, + scheduler: scheduler, + }); + expect(() => notLoggedInClient.getSafeUserId()).toThrow("Expected logged in user but found none."); + }); + }); + describe("sendEvent", () => { const roomId = "!room:example.org"; const body = "This is the body"; @@ -1828,4 +1846,68 @@ describe("MatrixClient", function () { expect(client.getUseE2eForGroupCall()).toBe(false); }); }); + + describe("delete account data", () => { + afterEach(() => { + jest.spyOn(featureUtils, "buildFeatureSupportMap").mockRestore(); + }); + it("makes correct request when deletion is supported by server in unstable versions", async () => { + const eventType = "im.vector.test"; + const versionsResponse = { + versions: ["1"], + unstable_features: { + "org.matrix.msc3391": true, + }, + }; + jest.spyOn(client.http, "request").mockResolvedValue(versionsResponse); + const requestSpy = jest.spyOn(client.http, "authedRequest").mockImplementation(() => Promise.resolve()); + const unstablePrefix = "/_matrix/client/unstable/org.matrix.msc3391"; + const path = `/user/${encodeURIComponent(userId)}/account_data/${eventType}`; + + // populate version support + await client.getVersions(); + await client.deleteAccountData(eventType); + + expect(requestSpy).toHaveBeenCalledWith(Method.Delete, path, undefined, undefined, { + prefix: unstablePrefix, + }); + }); + + it("makes correct request when deletion is supported by server based on matrix version", async () => { + const eventType = "im.vector.test"; + // we don't have a stable version for account data deletion yet to test this code path with + // so mock the support map to fake stable support + const stableSupportedDeletionMap = new Map(); + stableSupportedDeletionMap.set(featureUtils.Feature.AccountDataDeletion, featureUtils.ServerSupport.Stable); + jest.spyOn(featureUtils, "buildFeatureSupportMap").mockResolvedValue(new Map()); + const requestSpy = jest.spyOn(client.http, "authedRequest").mockImplementation(() => Promise.resolve()); + const path = `/user/${encodeURIComponent(userId)}/account_data/${eventType}`; + + // populate version support + await client.getVersions(); + await client.deleteAccountData(eventType); + + expect(requestSpy).toHaveBeenCalledWith(Method.Delete, path, undefined, undefined, undefined); + }); + + it("makes correct request when deletion is not supported by server", async () => { + const eventType = "im.vector.test"; + const versionsResponse = { + versions: ["1"], + unstable_features: { + "org.matrix.msc3391": false, + }, + }; + jest.spyOn(client.http, "request").mockResolvedValue(versionsResponse); + const requestSpy = jest.spyOn(client.http, "authedRequest").mockImplementation(() => Promise.resolve()); + const path = `/user/${encodeURIComponent(userId)}/account_data/${eventType}`; + + // populate version support + await client.getVersions(); + await client.deleteAccountData(eventType); + + // account data updated with empty content + expect(requestSpy).toHaveBeenCalledWith(Method.Put, path, undefined, {}); + }); + }); }); diff --git a/spec/unit/stores/memory.spec.ts b/spec/unit/stores/memory.spec.ts new file mode 100644 index 000000000..fac3267db --- /dev/null +++ b/spec/unit/stores/memory.spec.ts @@ -0,0 +1,65 @@ +/* +Copyright 2022 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 { MatrixEvent, MemoryStore } from "../../../src"; + +describe("MemoryStore", () => { + const event1 = new MatrixEvent({ type: "event1-type", content: { test: 1 } }); + const event2 = new MatrixEvent({ type: "event2-type", content: { test: 1 } }); + const event3 = new MatrixEvent({ type: "event3-type", content: { test: 1 } }); + const event4 = new MatrixEvent({ type: "event4-type", content: { test: 1 } }); + const event4Updated = new MatrixEvent({ type: "event4-type", content: { test: 2 } }); + const event1Empty = new MatrixEvent({ type: "event1-type", content: {} }); + + describe("account data", () => { + it("sets account data events correctly", () => { + const store = new MemoryStore(); + store.storeAccountDataEvents([event1, event2]); + expect(store.getAccountData(event1.getType())).toEqual(event1); + expect(store.getAccountData(event2.getType())).toEqual(event2); + }); + + it("returns undefined when no account data event exists for type", () => { + const store = new MemoryStore(); + expect(store.getAccountData("my-event-type")).toEqual(undefined); + }); + + it("updates account data events correctly", () => { + const store = new MemoryStore(); + // init store with event1, event2 + store.storeAccountDataEvents([event1, event2, event4]); + // remove event1, add event3 + store.storeAccountDataEvents([event1Empty, event3, event4Updated]); + // removed + expect(store.getAccountData(event1.getType())).toEqual(undefined); + // not removed + expect(store.getAccountData(event2.getType())).toEqual(event2); + // added + expect(store.getAccountData(event3.getType())).toEqual(event3); + // updated + expect(store.getAccountData(event4.getType())).toEqual(event4Updated); + }); + + it("removes all account data from state on deleteAllData", async () => { + const store = new MemoryStore(); + store.storeAccountDataEvents([event1, event2]); + await store.deleteAllData(); + + // empty object + expect(store.accountData).toEqual({}); + }); + }); +}); diff --git a/src/client.ts b/src/client.ts index 83c4ac2cd..bd21b854b 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1672,6 +1672,20 @@ export class MatrixClient extends TypedEventEmitter { + const msc3391DeleteAccountDataServerSupport = this.canSupport.get(Feature.AccountDataDeletion); + // if deletion is not supported overwrite with empty content + if (msc3391DeleteAccountDataServerSupport === ServerSupport.Unsupported) { + await this.setAccountData(eventType, {}); + return; + } + const path = utils.encodeUri("/user/$userId/account_data/$type", { + $userId: this.getSafeUserId(), + $type: eventType, + }); + const options = + msc3391DeleteAccountDataServerSupport === ServerSupport.Unstable + ? { prefix: "/_matrix/client/unstable/org.matrix.msc3391" } + : undefined; + return await this.http.authedRequest(Method.Delete, path, undefined, undefined, options); + } + /** * Gets the users that are ignored by this client * @returns The array of users that are ignored (empty if none) diff --git a/src/feature.ts b/src/feature.ts index d555a860b..158e1f7ee 100644 --- a/src/feature.ts +++ b/src/feature.ts @@ -26,6 +26,7 @@ export enum Feature { Thread = "Thread", ThreadUnreadNotifications = "ThreadUnreadNotifications", LoginTokenRequest = "LoginTokenRequest", + AccountDataDeletion = "AccountDataDeletion", } type FeatureSupportCondition = { @@ -45,6 +46,9 @@ const featureSupportResolver: Record = { [Feature.LoginTokenRequest]: { unstablePrefixes: ["org.matrix.msc3882"], }, + [Feature.AccountDataDeletion]: { + unstablePrefixes: ["org.matrix.msc3391"], + }, }; export async function buildFeatureSupportMap(versions: IServerVersions): Promise> { diff --git a/src/store/memory.ts b/src/store/memory.ts index 782d7edef..025a632aa 100644 --- a/src/store/memory.ts +++ b/src/store/memory.ts @@ -286,7 +286,13 @@ export class MemoryStore implements IStore { */ public storeAccountDataEvents(events: MatrixEvent[]): void { events.forEach((event) => { - this.accountData[event.getType()] = event; + // MSC3391: an event with content of {} should be interpreted as deleted + const isDeleted = !Object.keys(event.getContent()).length; + if (isDeleted) { + delete this.accountData[event.getType()]; + } else { + this.accountData[event.getType()] = event; + } }); }