Extract functions for service worker usage, and add initial MSC3916 playwright test (when supported) (#12414)

* Send user credentials to service worker for MSC3916 authentication

* appease linter

* Add initial test

The test fails, seemingly because the service worker isn't being installed or because the network mock can't reach that far.

* Remove unsafe access token code

* Split out base IDB operations to avoid importing `document` in serviceworkers

* Use safe crypto access for service workers

* Fix tests/unsafe access

* Remove backwards compatibility layer & appease linter

* Add docs

* Fix tests

* Appease the linter

* Iterate tests

* Factor out pickle key handling for service workers

* Enable everything we can about service workers

* Appease the linter

* Add docs

* Rename win32 image to linux in hopes of it just working

* Use actual image

* Apply suggestions from code review

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* Improve documentation

* Document `??` not working

* Try to appease the tests

* Add some notes

---------

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
This commit is contained in:
Travis Ralston 2024-05-02 16:19:55 -06:00 committed by GitHub
parent 374cee9080
commit d25d529e86
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 435 additions and 176 deletions

View file

@ -26,7 +26,7 @@ import StorageEvictedDialog from "../src/components/views/dialogs/StorageEvicted
import { logout, restoreFromLocalStorage, setLoggedIn } from "../src/Lifecycle";
import { MatrixClientPeg } from "../src/MatrixClientPeg";
import Modal from "../src/Modal";
import * as StorageManager from "../src/utils/StorageManager";
import * as StorageAccess from "../src/utils/StorageAccess";
import { flushPromises, getMockClientWithEventEmitter, mockClientMethodsUser, mockPlatformPeg } from "./test-utils";
import { OidcClientStore } from "../src/stores/oidc/OidcClientStore";
import { makeDelegatedAuthConfig } from "./test-utils/oidc";
@ -128,13 +128,13 @@ describe("Lifecycle", () => {
};
const initIdbMock = (mockStore: Record<string, Record<string, unknown>> = {}): void => {
jest.spyOn(StorageManager, "idbLoad")
jest.spyOn(StorageAccess, "idbLoad")
.mockClear()
.mockImplementation(
// @ts-ignore mock type
async (table: string, key: string) => mockStore[table]?.[key] ?? null,
);
jest.spyOn(StorageManager, "idbSave")
jest.spyOn(StorageAccess, "idbSave")
.mockClear()
.mockImplementation(
// @ts-ignore mock type
@ -144,7 +144,7 @@ describe("Lifecycle", () => {
mockStore[tableKey] = table;
},
);
jest.spyOn(StorageManager, "idbDelete").mockClear().mockResolvedValue(undefined);
jest.spyOn(StorageAccess, "idbDelete").mockClear().mockResolvedValue(undefined);
};
const homeserverUrl = "https://server.org";
@ -258,16 +258,16 @@ describe("Lifecycle", () => {
expect(localStorage.setItem).toHaveBeenCalledWith("mx_is_guest", "false");
expect(localStorage.setItem).toHaveBeenCalledWith("mx_device_id", deviceId);
expect(StorageManager.idbSave).toHaveBeenCalledWith("account", "mx_access_token", accessToken);
expect(StorageAccess.idbSave).toHaveBeenCalledWith("account", "mx_access_token", accessToken);
// dont put accessToken in localstorage when we have idb
expect(localStorage.setItem).not.toHaveBeenCalledWith("mx_access_token", accessToken);
});
it("should persist access token when idb is not available", async () => {
jest.spyOn(StorageManager, "idbSave").mockRejectedValue("oups");
jest.spyOn(StorageAccess, "idbSave").mockRejectedValue("oups");
expect(await restoreFromLocalStorage()).toEqual(true);
expect(StorageManager.idbSave).toHaveBeenCalledWith("account", "mx_access_token", accessToken);
expect(StorageAccess.idbSave).toHaveBeenCalledWith("account", "mx_access_token", accessToken);
// put accessToken in localstorage as fallback
expect(localStorage.setItem).toHaveBeenCalledWith("mx_access_token", accessToken);
});
@ -316,11 +316,7 @@ describe("Lifecycle", () => {
// refresh token from storage is re-persisted
expect(localStorage.setItem).toHaveBeenCalledWith("mx_has_refresh_token", "true");
expect(StorageManager.idbSave).toHaveBeenCalledWith(
"account",
"mx_refresh_token",
refreshToken,
);
expect(StorageAccess.idbSave).toHaveBeenCalledWith("account", "mx_refresh_token", refreshToken);
});
it("should create new matrix client with credentials", async () => {
@ -359,7 +355,7 @@ describe("Lifecycle", () => {
expect(localStorage.setItem).toHaveBeenCalledWith("mx_has_access_token", "true");
// token encrypted and persisted
expect(StorageManager.idbSave).toHaveBeenCalledWith(
expect(StorageAccess.idbSave).toHaveBeenCalledWith(
"account",
"mx_access_token",
encryptedTokenShapedObject,
@ -368,7 +364,7 @@ describe("Lifecycle", () => {
it("should persist access token when idb is not available", async () => {
// dont fail for pickle key persist
jest.spyOn(StorageManager, "idbSave").mockImplementation(
jest.spyOn(StorageAccess, "idbSave").mockImplementation(
async (table: string, key: string | string[]) => {
if (table === "account" && key === "mx_access_token") {
throw new Error("oups");
@ -378,7 +374,7 @@ describe("Lifecycle", () => {
expect(await restoreFromLocalStorage()).toEqual(true);
expect(StorageManager.idbSave).toHaveBeenCalledWith(
expect(StorageAccess.idbSave).toHaveBeenCalledWith(
"account",
"mx_access_token",
encryptedTokenShapedObject,
@ -422,7 +418,7 @@ describe("Lifecycle", () => {
// refresh token from storage is re-persisted
expect(localStorage.setItem).toHaveBeenCalledWith("mx_has_refresh_token", "true");
expect(StorageManager.idbSave).toHaveBeenCalledWith(
expect(StorageAccess.idbSave).toHaveBeenCalledWith(
"account",
"mx_refresh_token",
encryptedTokenShapedObject,
@ -502,7 +498,7 @@ describe("Lifecycle", () => {
expect(localStorage.setItem).toHaveBeenCalledWith("mx_is_guest", "false");
expect(localStorage.setItem).toHaveBeenCalledWith("mx_device_id", deviceId);
expect(StorageManager.idbSave).toHaveBeenCalledWith("account", "mx_access_token", accessToken);
expect(StorageAccess.idbSave).toHaveBeenCalledWith("account", "mx_access_token", accessToken);
// dont put accessToken in localstorage when we have idb
expect(localStorage.setItem).not.toHaveBeenCalledWith("mx_access_token", accessToken);
});
@ -513,14 +509,14 @@ describe("Lifecycle", () => {
refreshToken,
});
expect(StorageManager.idbSave).toHaveBeenCalledWith("account", "mx_access_token", accessToken);
expect(StorageManager.idbSave).toHaveBeenCalledWith("account", "mx_refresh_token", refreshToken);
expect(StorageAccess.idbSave).toHaveBeenCalledWith("account", "mx_access_token", accessToken);
expect(StorageAccess.idbSave).toHaveBeenCalledWith("account", "mx_refresh_token", refreshToken);
// dont put accessToken in localstorage when we have idb
expect(localStorage.setItem).not.toHaveBeenCalledWith("mx_access_token", accessToken);
});
it("should remove any access token from storage when there is none in credentials and idb save fails", async () => {
jest.spyOn(StorageManager, "idbSave").mockRejectedValue("oups");
jest.spyOn(StorageAccess, "idbSave").mockRejectedValue("oups");
await setLoggedIn({
...credentials,
// @ts-ignore
@ -534,7 +530,7 @@ describe("Lifecycle", () => {
it("should clear stores", async () => {
await setLoggedIn(credentials);
expect(StorageManager.idbDelete).toHaveBeenCalledWith("account", "mx_access_token");
expect(StorageAccess.idbDelete).toHaveBeenCalledWith("account", "mx_access_token");
expect(sessionStorage.clear).toHaveBeenCalled();
expect(mockClient.clearStores).toHaveBeenCalled();
});
@ -566,7 +562,7 @@ describe("Lifecycle", () => {
});
// unpickled access token saved
expect(StorageManager.idbSave).toHaveBeenCalledWith("account", "mx_access_token", accessToken);
expect(StorageAccess.idbSave).toHaveBeenCalledWith("account", "mx_access_token", accessToken);
expect(mockPlatform.createPickleKey).not.toHaveBeenCalled();
});
@ -585,16 +581,12 @@ describe("Lifecycle", () => {
expect(localStorage.setItem).toHaveBeenCalledWith("mx_device_id", deviceId);
expect(localStorage.setItem).toHaveBeenCalledWith("mx_has_pickle_key", "true");
expect(StorageManager.idbSave).toHaveBeenCalledWith(
expect(StorageAccess.idbSave).toHaveBeenCalledWith(
"account",
"mx_access_token",
encryptedTokenShapedObject,
);
expect(StorageManager.idbSave).toHaveBeenCalledWith(
"pickleKey",
[userId, deviceId],
expect.any(Object),
);
expect(StorageAccess.idbSave).toHaveBeenCalledWith("pickleKey", [userId, deviceId], expect.any(Object));
// dont put accessToken in localstorage when we have idb
expect(localStorage.setItem).not.toHaveBeenCalledWith("mx_access_token", accessToken);
});
@ -604,12 +596,12 @@ describe("Lifecycle", () => {
await setLoggedIn(credentials);
// persist the unencrypted token
expect(StorageManager.idbSave).toHaveBeenCalledWith("account", "mx_access_token", accessToken);
expect(StorageAccess.idbSave).toHaveBeenCalledWith("account", "mx_access_token", accessToken);
});
it("should persist token in localStorage when idb fails to save token", async () => {
// dont fail for pickle key persist
jest.spyOn(StorageManager, "idbSave").mockImplementation(
jest.spyOn(StorageAccess, "idbSave").mockImplementation(
async (table: string, key: string | string[]) => {
if (table === "account" && key === "mx_access_token") {
throw new Error("oups");
@ -624,7 +616,7 @@ describe("Lifecycle", () => {
it("should remove any access token from storage when there is none in credentials and idb save fails", async () => {
// dont fail for pickle key persist
jest.spyOn(StorageManager, "idbSave").mockImplementation(
jest.spyOn(StorageAccess, "idbSave").mockImplementation(
async (table: string, key: string | string[]) => {
if (table === "account" && key === "mx_access_token") {
throw new Error("oups");

View file

@ -29,7 +29,7 @@ import { defer, sleep } from "matrix-js-sdk/src/utils";
import { UserVerificationStatus } from "matrix-js-sdk/src/crypto-api";
import MatrixChat from "../../../src/components/structures/MatrixChat";
import * as StorageManager from "../../../src/utils/StorageManager";
import * as StorageAccess from "../../../src/utils/StorageAccess";
import defaultDispatcher from "../../../src/dispatcher/dispatcher";
import { Action } from "../../../src/dispatcher/actions";
import { UserTab } from "../../../src/components/views/dialogs/UserTab";
@ -220,8 +220,8 @@ describe("<MatrixChat />", () => {
headers: { "content-type": "application/json" },
});
jest.spyOn(StorageManager, "idbLoad").mockReset();
jest.spyOn(StorageManager, "idbSave").mockResolvedValue(undefined);
jest.spyOn(StorageAccess, "idbLoad").mockReset();
jest.spyOn(StorageAccess, "idbSave").mockResolvedValue(undefined);
jest.spyOn(defaultDispatcher, "dispatch").mockClear();
jest.spyOn(defaultDispatcher, "fire").mockClear();
@ -459,7 +459,7 @@ describe("<MatrixChat />", () => {
describe("when login succeeds", () => {
beforeEach(() => {
jest.spyOn(StorageManager, "idbLoad").mockImplementation(
jest.spyOn(StorageAccess, "idbLoad").mockImplementation(
async (_table: string, key: string | string[]) => (key === "mx_access_token" ? accessToken : null),
);
loginClient.getProfileInfo.mockResolvedValue({
@ -553,7 +553,7 @@ describe("<MatrixChat />", () => {
beforeEach(async () => {
await populateStorageForSession();
jest.spyOn(StorageManager, "idbLoad").mockImplementation(async (table, key) => {
jest.spyOn(StorageAccess, "idbLoad").mockImplementation(async (table, key) => {
const safeKey = Array.isArray(key) ? key[0] : key;
return mockidb[table]?.[safeKey];
});
@ -868,7 +868,7 @@ describe("<MatrixChat />", () => {
mockClient.loginFlows.mockResolvedValue({ flows: [{ type: "m.login.password" }] });
jest.spyOn(StorageManager, "idbLoad").mockImplementation(async (table, key) => {
jest.spyOn(StorageAccess, "idbLoad").mockImplementation(async (table, key) => {
const safeKey = Array.isArray(key) ? key[0] : key;
return mockidb[table]?.[safeKey];
});
@ -1164,7 +1164,7 @@ describe("<MatrixChat />", () => {
describe("when login succeeds", () => {
beforeEach(() => {
jest.spyOn(StorageManager, "idbLoad").mockImplementation(
jest.spyOn(StorageAccess, "idbLoad").mockImplementation(
async (_table: string, key: string | string[]) => {
if (key === "mx_access_token") {
return accessToken as any;

View file

@ -0,0 +1,55 @@
/*
Copyright 2024 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import "core-js/stable/structured-clone"; // for idb access
import "fake-indexeddb/auto";
import { idbDelete, idbLoad, idbSave } from "../../src/utils/StorageAccess";
const NONEXISTENT_TABLE = "this_is_not_a_table_we_use_ever_and_so_we_can_use_it_in_tests";
const KNOWN_TABLES = ["account", "pickleKey"];
describe("StorageAccess", () => {
it.each(KNOWN_TABLES)("should save, load, and delete from known table '%s'", async (tableName: string) => {
const key = ["a", "b"];
const data = { hello: "world" };
// Should start undefined
let loaded = await idbLoad(tableName, key);
expect(loaded).toBeUndefined();
// ... then define a value
await idbSave(tableName, key, data);
// ... then check that value
loaded = await idbLoad(tableName, key);
expect(loaded).toEqual(data);
// ... then set it back to undefined
await idbDelete(tableName, key);
// ... which we then check again
loaded = await idbLoad(tableName, key);
expect(loaded).toBeUndefined();
});
it("should fail to save, load, and delete from a non-existent table", async () => {
// Regardless of validity on the key/data, or write order, these should all fail.
await expect(() => idbSave(NONEXISTENT_TABLE, "whatever", "value")).rejects.toThrow();
await expect(() => idbLoad(NONEXISTENT_TABLE, "whatever")).rejects.toThrow();
await expect(() => idbDelete(NONEXISTENT_TABLE, "whatever")).rejects.toThrow();
});
});