2023-07-19 09:40:40 +02:00
|
|
|
/*
|
|
|
|
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 { Crypto } from "@peculiar/webcrypto";
|
|
|
|
import { logger } from "matrix-js-sdk/src/logger";
|
|
|
|
import * as MatrixJs from "matrix-js-sdk/src/matrix";
|
|
|
|
import { setCrypto } from "matrix-js-sdk/src/crypto/crypto";
|
|
|
|
import * as MatrixCryptoAes from "matrix-js-sdk/src/crypto/aes";
|
2023-10-15 23:35:25 +02:00
|
|
|
import { MockedObject } from "jest-mock";
|
2023-10-12 02:49:07 +02:00
|
|
|
import fetchMock from "fetch-mock-jest";
|
2023-07-19 09:40:40 +02:00
|
|
|
|
|
|
|
import StorageEvictedDialog from "../src/components/views/dialogs/StorageEvictedDialog";
|
2023-10-15 23:35:25 +02:00
|
|
|
import { logout, restoreFromLocalStorage, setLoggedIn } from "../src/Lifecycle";
|
2023-07-19 09:40:40 +02:00
|
|
|
import { MatrixClientPeg } from "../src/MatrixClientPeg";
|
|
|
|
import Modal from "../src/Modal";
|
2024-05-03 00:19:55 +02:00
|
|
|
import * as StorageAccess from "../src/utils/StorageAccess";
|
2023-10-15 23:35:25 +02:00
|
|
|
import { flushPromises, getMockClientWithEventEmitter, mockClientMethodsUser, mockPlatformPeg } from "./test-utils";
|
|
|
|
import { OidcClientStore } from "../src/stores/oidc/OidcClientStore";
|
2023-10-12 02:49:07 +02:00
|
|
|
import { makeDelegatedAuthConfig } from "./test-utils/oidc";
|
|
|
|
import { persistOidcAuthenticatedSettings } from "../src/utils/oidc/persistOidcSettings";
|
2024-02-22 16:41:21 +01:00
|
|
|
import { Action } from "../src/dispatcher/actions";
|
2023-07-19 09:40:40 +02:00
|
|
|
|
|
|
|
const webCrypto = new Crypto();
|
|
|
|
|
|
|
|
const windowCrypto = window.crypto;
|
|
|
|
|
|
|
|
describe("Lifecycle", () => {
|
|
|
|
const mockPlatform = mockPlatformPeg();
|
|
|
|
|
|
|
|
const realLocalStorage = global.localStorage;
|
|
|
|
|
2023-10-15 23:35:25 +02:00
|
|
|
let mockClient!: MockedObject<MatrixJs.MatrixClient>;
|
2023-07-19 09:40:40 +02:00
|
|
|
|
|
|
|
beforeEach(() => {
|
2023-10-15 23:35:25 +02:00
|
|
|
mockClient = getMockClientWithEventEmitter({
|
|
|
|
...mockClientMethodsUser(),
|
|
|
|
stopClient: jest.fn(),
|
|
|
|
removeAllListeners: jest.fn(),
|
|
|
|
clearStores: jest.fn(),
|
|
|
|
getAccountData: jest.fn(),
|
|
|
|
getDeviceId: jest.fn(),
|
|
|
|
isVersionSupported: jest.fn().mockResolvedValue(true),
|
|
|
|
getCrypto: jest.fn(),
|
|
|
|
getClientWellKnown: jest.fn(),
|
|
|
|
waitForClientWellKnown: jest.fn(),
|
|
|
|
getThirdpartyProtocols: jest.fn(),
|
|
|
|
store: {
|
|
|
|
destroy: jest.fn(),
|
|
|
|
},
|
|
|
|
getVersions: jest.fn().mockResolvedValue({ versions: ["v1.1"] }),
|
|
|
|
logout: jest.fn().mockResolvedValue(undefined),
|
|
|
|
getAccessToken: jest.fn(),
|
|
|
|
getRefreshToken: jest.fn(),
|
|
|
|
});
|
2023-07-19 09:40:40 +02:00
|
|
|
// stub this
|
|
|
|
jest.spyOn(MatrixClientPeg, "replaceUsingCreds").mockImplementation(() => {});
|
|
|
|
jest.spyOn(MatrixClientPeg, "start").mockResolvedValue(undefined);
|
|
|
|
|
|
|
|
// reset any mocking
|
|
|
|
// @ts-ignore mocking
|
|
|
|
delete global.localStorage;
|
|
|
|
global.localStorage = realLocalStorage;
|
|
|
|
|
|
|
|
setCrypto(webCrypto);
|
|
|
|
// @ts-ignore mocking
|
|
|
|
delete window.crypto;
|
|
|
|
window.crypto = webCrypto;
|
|
|
|
|
|
|
|
jest.spyOn(MatrixCryptoAes, "encryptAES").mockRestore();
|
|
|
|
});
|
|
|
|
|
|
|
|
afterAll(() => {
|
|
|
|
setCrypto(windowCrypto);
|
|
|
|
|
|
|
|
// @ts-ignore unmocking
|
|
|
|
delete window.crypto;
|
|
|
|
window.crypto = windowCrypto;
|
|
|
|
});
|
|
|
|
|
|
|
|
const initLocalStorageMock = (mockStore: Record<string, unknown> = {}): void => {
|
|
|
|
jest.spyOn(localStorage.__proto__, "getItem")
|
|
|
|
.mockClear()
|
|
|
|
.mockImplementation((key: unknown) => mockStore[key as string] ?? null);
|
|
|
|
jest.spyOn(localStorage.__proto__, "removeItem")
|
|
|
|
.mockClear()
|
|
|
|
.mockImplementation((key: unknown) => {
|
|
|
|
const { [key as string]: toRemove, ...newStore } = mockStore;
|
|
|
|
mockStore = newStore;
|
|
|
|
return toRemove;
|
|
|
|
});
|
|
|
|
jest.spyOn(localStorage.__proto__, "setItem")
|
|
|
|
.mockClear()
|
|
|
|
.mockImplementation((key: unknown, value: unknown) => {
|
|
|
|
mockStore[key as string] = value;
|
|
|
|
});
|
|
|
|
};
|
|
|
|
|
|
|
|
const initSessionStorageMock = (mockStore: Record<string, unknown> = {}): void => {
|
|
|
|
jest.spyOn(sessionStorage.__proto__, "getItem")
|
|
|
|
.mockClear()
|
|
|
|
.mockImplementation((key: unknown) => mockStore[key as string] ?? null);
|
|
|
|
jest.spyOn(sessionStorage.__proto__, "removeItem")
|
|
|
|
.mockClear()
|
|
|
|
.mockImplementation((key: unknown) => {
|
|
|
|
const { [key as string]: toRemove, ...newStore } = mockStore;
|
|
|
|
mockStore = newStore;
|
|
|
|
return toRemove;
|
|
|
|
});
|
|
|
|
jest.spyOn(sessionStorage.__proto__, "setItem")
|
|
|
|
.mockClear()
|
|
|
|
.mockImplementation((key: unknown, value: unknown) => {
|
|
|
|
mockStore[key as string] = value;
|
|
|
|
});
|
|
|
|
jest.spyOn(sessionStorage.__proto__, "clear").mockClear();
|
|
|
|
};
|
|
|
|
|
|
|
|
const initIdbMock = (mockStore: Record<string, Record<string, unknown>> = {}): void => {
|
2024-05-03 00:19:55 +02:00
|
|
|
jest.spyOn(StorageAccess, "idbLoad")
|
2023-07-19 09:40:40 +02:00
|
|
|
.mockClear()
|
|
|
|
.mockImplementation(
|
|
|
|
// @ts-ignore mock type
|
|
|
|
async (table: string, key: string) => mockStore[table]?.[key] ?? null,
|
|
|
|
);
|
2024-05-03 00:19:55 +02:00
|
|
|
jest.spyOn(StorageAccess, "idbSave")
|
2023-07-19 09:40:40 +02:00
|
|
|
.mockClear()
|
|
|
|
.mockImplementation(
|
|
|
|
// @ts-ignore mock type
|
|
|
|
async (tableKey: string, key: string, value: unknown) => {
|
|
|
|
const table = mockStore[tableKey] || {};
|
|
|
|
table[key as string] = value;
|
|
|
|
mockStore[tableKey] = table;
|
|
|
|
},
|
|
|
|
);
|
2024-05-03 00:19:55 +02:00
|
|
|
jest.spyOn(StorageAccess, "idbDelete").mockClear().mockResolvedValue(undefined);
|
2023-07-19 09:40:40 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
const homeserverUrl = "https://server.org";
|
|
|
|
const identityServerUrl = "https://is.org";
|
|
|
|
const userId = "@alice:server.org";
|
|
|
|
const deviceId = "abc123";
|
|
|
|
const accessToken = "test-access-token";
|
|
|
|
const localStorageSession = {
|
|
|
|
mx_hs_url: homeserverUrl,
|
|
|
|
mx_is_url: identityServerUrl,
|
|
|
|
mx_user_id: userId,
|
|
|
|
mx_device_id: deviceId,
|
|
|
|
};
|
|
|
|
const idbStorageSession = {
|
|
|
|
account: {
|
|
|
|
mx_access_token: accessToken,
|
|
|
|
},
|
|
|
|
};
|
|
|
|
const credentials = {
|
|
|
|
homeserverUrl,
|
|
|
|
identityServerUrl,
|
|
|
|
userId,
|
|
|
|
deviceId,
|
|
|
|
accessToken,
|
|
|
|
};
|
|
|
|
|
2023-09-28 06:38:31 +02:00
|
|
|
const refreshToken = "test-refresh-token";
|
|
|
|
|
2023-07-19 09:40:40 +02:00
|
|
|
const encryptedTokenShapedObject = {
|
|
|
|
ciphertext: expect.any(String),
|
|
|
|
iv: expect.any(String),
|
|
|
|
mac: expect.any(String),
|
|
|
|
};
|
|
|
|
|
|
|
|
describe("restoreFromLocalStorage()", () => {
|
|
|
|
beforeEach(() => {
|
|
|
|
initLocalStorageMock();
|
|
|
|
initSessionStorageMock();
|
|
|
|
initIdbMock();
|
|
|
|
|
|
|
|
jest.clearAllMocks();
|
|
|
|
jest.spyOn(logger, "log").mockClear();
|
|
|
|
|
|
|
|
jest.spyOn(MatrixJs, "createClient").mockReturnValue(mockClient);
|
|
|
|
|
|
|
|
// stub this out
|
|
|
|
jest.spyOn(Modal, "createDialog").mockReturnValue(
|
|
|
|
// @ts-ignore allow bad mock
|
|
|
|
{ finished: Promise.resolve([true]) },
|
|
|
|
);
|
|
|
|
});
|
|
|
|
|
|
|
|
it("should return false when localStorage is not available", async () => {
|
|
|
|
// @ts-ignore dirty mocking
|
|
|
|
delete global.localStorage;
|
|
|
|
// @ts-ignore dirty mocking
|
|
|
|
global.localStorage = undefined;
|
|
|
|
|
|
|
|
expect(await restoreFromLocalStorage()).toEqual(false);
|
|
|
|
});
|
|
|
|
|
|
|
|
it("should return false when no session data is found in local storage", async () => {
|
|
|
|
expect(await restoreFromLocalStorage()).toEqual(false);
|
|
|
|
expect(logger.log).toHaveBeenCalledWith("No previous session found.");
|
|
|
|
});
|
|
|
|
|
|
|
|
it("should abort login when we expect to find an access token but don't", async () => {
|
|
|
|
initLocalStorageMock({ mx_has_access_token: "true" });
|
|
|
|
|
|
|
|
await expect(() => restoreFromLocalStorage()).rejects.toThrow();
|
|
|
|
expect(Modal.createDialog).toHaveBeenCalledWith(StorageEvictedDialog);
|
|
|
|
expect(mockClient.clearStores).toHaveBeenCalled();
|
|
|
|
});
|
|
|
|
|
|
|
|
describe("when session is found in storage", () => {
|
|
|
|
beforeEach(() => {
|
|
|
|
initLocalStorageMock(localStorageSession);
|
|
|
|
initIdbMock(idbStorageSession);
|
|
|
|
});
|
|
|
|
|
|
|
|
describe("guest account", () => {
|
|
|
|
it("should ignore guest accounts when ignoreGuest is true", async () => {
|
|
|
|
initLocalStorageMock({ ...localStorageSession, mx_is_guest: "true" });
|
|
|
|
|
|
|
|
expect(await restoreFromLocalStorage({ ignoreGuest: true })).toEqual(false);
|
|
|
|
expect(logger.log).toHaveBeenCalledWith(`Ignoring stored guest account: ${userId}`);
|
|
|
|
});
|
|
|
|
|
|
|
|
it("should restore guest accounts when ignoreGuest is false", async () => {
|
|
|
|
initLocalStorageMock({ ...localStorageSession, mx_is_guest: "true" });
|
|
|
|
|
|
|
|
expect(await restoreFromLocalStorage({ ignoreGuest: false })).toEqual(true);
|
|
|
|
|
|
|
|
expect(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith(
|
|
|
|
expect.objectContaining({
|
|
|
|
userId,
|
|
|
|
guest: true,
|
|
|
|
}),
|
2023-10-12 02:49:07 +02:00
|
|
|
undefined,
|
2023-07-19 09:40:40 +02:00
|
|
|
);
|
|
|
|
expect(localStorage.setItem).toHaveBeenCalledWith("mx_is_guest", "true");
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
describe("without a pickle key", () => {
|
|
|
|
it("should persist credentials", async () => {
|
|
|
|
expect(await restoreFromLocalStorage()).toEqual(true);
|
|
|
|
|
|
|
|
expect(localStorage.setItem).toHaveBeenCalledWith("mx_user_id", userId);
|
|
|
|
expect(localStorage.setItem).toHaveBeenCalledWith("mx_has_access_token", "true");
|
|
|
|
expect(localStorage.setItem).toHaveBeenCalledWith("mx_is_guest", "false");
|
|
|
|
expect(localStorage.setItem).toHaveBeenCalledWith("mx_device_id", deviceId);
|
|
|
|
|
2024-05-03 00:19:55 +02:00
|
|
|
expect(StorageAccess.idbSave).toHaveBeenCalledWith("account", "mx_access_token", accessToken);
|
2023-07-19 09:40:40 +02:00
|
|
|
// 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 () => {
|
2024-05-03 00:19:55 +02:00
|
|
|
jest.spyOn(StorageAccess, "idbSave").mockRejectedValue("oups");
|
2023-07-19 09:40:40 +02:00
|
|
|
expect(await restoreFromLocalStorage()).toEqual(true);
|
|
|
|
|
2024-05-03 00:19:55 +02:00
|
|
|
expect(StorageAccess.idbSave).toHaveBeenCalledWith("account", "mx_access_token", accessToken);
|
2023-07-19 09:40:40 +02:00
|
|
|
// put accessToken in localstorage as fallback
|
|
|
|
expect(localStorage.setItem).toHaveBeenCalledWith("mx_access_token", accessToken);
|
|
|
|
});
|
|
|
|
|
|
|
|
it("should create new matrix client with credentials", async () => {
|
|
|
|
expect(await restoreFromLocalStorage()).toEqual(true);
|
|
|
|
|
2023-10-12 02:49:07 +02:00
|
|
|
expect(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith(
|
|
|
|
{
|
|
|
|
userId,
|
|
|
|
accessToken,
|
|
|
|
homeserverUrl,
|
|
|
|
identityServerUrl,
|
|
|
|
deviceId,
|
|
|
|
freshLogin: false,
|
|
|
|
guest: false,
|
|
|
|
pickleKey: undefined,
|
|
|
|
},
|
|
|
|
undefined,
|
|
|
|
);
|
2023-07-19 09:40:40 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
it("should remove fresh login flag from session storage", async () => {
|
|
|
|
expect(await restoreFromLocalStorage()).toEqual(true);
|
|
|
|
|
|
|
|
expect(sessionStorage.removeItem).toHaveBeenCalledWith("mx_fresh_login");
|
|
|
|
});
|
|
|
|
|
|
|
|
it("should start matrix client", async () => {
|
|
|
|
expect(await restoreFromLocalStorage()).toEqual(true);
|
|
|
|
|
|
|
|
expect(MatrixClientPeg.start).toHaveBeenCalled();
|
|
|
|
});
|
2023-09-28 06:38:31 +02:00
|
|
|
|
|
|
|
describe("with a refresh token", () => {
|
|
|
|
beforeEach(() => {
|
|
|
|
initLocalStorageMock({
|
|
|
|
...localStorageSession,
|
|
|
|
mx_refresh_token: refreshToken,
|
|
|
|
});
|
|
|
|
initIdbMock(idbStorageSession);
|
|
|
|
});
|
|
|
|
|
|
|
|
it("should persist credentials", async () => {
|
|
|
|
expect(await restoreFromLocalStorage()).toEqual(true);
|
|
|
|
|
|
|
|
// refresh token from storage is re-persisted
|
|
|
|
expect(localStorage.setItem).toHaveBeenCalledWith("mx_has_refresh_token", "true");
|
2024-05-03 00:19:55 +02:00
|
|
|
expect(StorageAccess.idbSave).toHaveBeenCalledWith("account", "mx_refresh_token", refreshToken);
|
2023-09-28 06:38:31 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
it("should create new matrix client with credentials", async () => {
|
|
|
|
expect(await restoreFromLocalStorage()).toEqual(true);
|
|
|
|
|
2023-10-12 02:49:07 +02:00
|
|
|
expect(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith(
|
|
|
|
{
|
|
|
|
userId,
|
|
|
|
accessToken,
|
|
|
|
// refreshToken included in credentials
|
|
|
|
refreshToken,
|
|
|
|
homeserverUrl,
|
|
|
|
identityServerUrl,
|
|
|
|
deviceId,
|
|
|
|
freshLogin: false,
|
|
|
|
guest: false,
|
|
|
|
pickleKey: undefined,
|
|
|
|
},
|
|
|
|
undefined,
|
|
|
|
);
|
2023-09-28 06:38:31 +02:00
|
|
|
});
|
|
|
|
});
|
2023-07-19 09:40:40 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
describe("with a pickle key", () => {
|
|
|
|
beforeEach(async () => {
|
|
|
|
initLocalStorageMock({});
|
|
|
|
initIdbMock({});
|
|
|
|
// setup storage with a session with encrypted token
|
|
|
|
await setLoggedIn(credentials);
|
|
|
|
});
|
|
|
|
|
|
|
|
it("should persist credentials", async () => {
|
|
|
|
expect(await restoreFromLocalStorage()).toEqual(true);
|
|
|
|
|
|
|
|
expect(localStorage.setItem).toHaveBeenCalledWith("mx_has_access_token", "true");
|
|
|
|
|
|
|
|
// token encrypted and persisted
|
2024-05-03 00:19:55 +02:00
|
|
|
expect(StorageAccess.idbSave).toHaveBeenCalledWith(
|
2023-07-19 09:40:40 +02:00
|
|
|
"account",
|
|
|
|
"mx_access_token",
|
|
|
|
encryptedTokenShapedObject,
|
|
|
|
);
|
|
|
|
});
|
|
|
|
|
|
|
|
it("should persist access token when idb is not available", async () => {
|
|
|
|
// dont fail for pickle key persist
|
2024-05-03 00:19:55 +02:00
|
|
|
jest.spyOn(StorageAccess, "idbSave").mockImplementation(
|
2023-07-19 09:40:40 +02:00
|
|
|
async (table: string, key: string | string[]) => {
|
|
|
|
if (table === "account" && key === "mx_access_token") {
|
|
|
|
throw new Error("oups");
|
|
|
|
}
|
|
|
|
},
|
|
|
|
);
|
|
|
|
|
|
|
|
expect(await restoreFromLocalStorage()).toEqual(true);
|
|
|
|
|
2024-05-03 00:19:55 +02:00
|
|
|
expect(StorageAccess.idbSave).toHaveBeenCalledWith(
|
2023-07-19 09:40:40 +02:00
|
|
|
"account",
|
|
|
|
"mx_access_token",
|
|
|
|
encryptedTokenShapedObject,
|
|
|
|
);
|
|
|
|
// put accessToken in localstorage as fallback
|
|
|
|
expect(localStorage.setItem).toHaveBeenCalledWith("mx_access_token", accessToken);
|
|
|
|
});
|
|
|
|
|
|
|
|
it("should create new matrix client with credentials", async () => {
|
|
|
|
expect(await restoreFromLocalStorage()).toEqual(true);
|
|
|
|
|
2023-10-12 02:49:07 +02:00
|
|
|
expect(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith(
|
|
|
|
{
|
|
|
|
userId,
|
|
|
|
// decrypted accessToken
|
|
|
|
accessToken,
|
|
|
|
homeserverUrl,
|
|
|
|
identityServerUrl,
|
|
|
|
deviceId,
|
|
|
|
freshLogin: true,
|
|
|
|
guest: false,
|
|
|
|
pickleKey: expect.any(String),
|
|
|
|
},
|
|
|
|
undefined,
|
|
|
|
);
|
2023-07-19 09:40:40 +02:00
|
|
|
});
|
2023-09-28 06:38:31 +02:00
|
|
|
|
|
|
|
describe("with a refresh token", () => {
|
|
|
|
beforeEach(async () => {
|
|
|
|
initLocalStorageMock({});
|
|
|
|
initIdbMock({});
|
|
|
|
// setup storage with a session with encrypted token
|
|
|
|
await setLoggedIn({
|
|
|
|
...credentials,
|
|
|
|
refreshToken,
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
it("should persist credentials", async () => {
|
|
|
|
expect(await restoreFromLocalStorage()).toEqual(true);
|
|
|
|
|
|
|
|
// refresh token from storage is re-persisted
|
|
|
|
expect(localStorage.setItem).toHaveBeenCalledWith("mx_has_refresh_token", "true");
|
2024-05-03 00:19:55 +02:00
|
|
|
expect(StorageAccess.idbSave).toHaveBeenCalledWith(
|
2023-09-28 06:38:31 +02:00
|
|
|
"account",
|
|
|
|
"mx_refresh_token",
|
|
|
|
encryptedTokenShapedObject,
|
|
|
|
);
|
|
|
|
});
|
|
|
|
|
|
|
|
it("should create new matrix client with credentials", async () => {
|
|
|
|
expect(await restoreFromLocalStorage()).toEqual(true);
|
|
|
|
|
2023-10-12 02:49:07 +02:00
|
|
|
expect(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith(
|
|
|
|
{
|
|
|
|
userId,
|
|
|
|
accessToken,
|
|
|
|
// refreshToken included in credentials
|
|
|
|
refreshToken,
|
|
|
|
homeserverUrl,
|
|
|
|
identityServerUrl,
|
|
|
|
deviceId,
|
|
|
|
freshLogin: false,
|
|
|
|
guest: false,
|
|
|
|
pickleKey: expect.any(String),
|
|
|
|
},
|
|
|
|
undefined,
|
|
|
|
);
|
2023-09-28 06:38:31 +02:00
|
|
|
});
|
|
|
|
});
|
2023-07-19 09:40:40 +02:00
|
|
|
});
|
2023-08-14 10:25:13 +02:00
|
|
|
|
2024-02-26 16:30:32 +01:00
|
|
|
it("should proceed if server is not accessible", async () => {
|
|
|
|
mockClient.isVersionSupported.mockRejectedValue(new Error("Oh, noes, the server is down!"));
|
2023-08-14 10:25:13 +02:00
|
|
|
|
|
|
|
expect(await restoreFromLocalStorage()).toEqual(true);
|
|
|
|
});
|
2023-07-19 09:40:40 +02:00
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
describe("setLoggedIn()", () => {
|
|
|
|
beforeEach(() => {
|
|
|
|
initLocalStorageMock();
|
|
|
|
initSessionStorageMock();
|
|
|
|
initIdbMock();
|
|
|
|
|
|
|
|
jest.clearAllMocks();
|
|
|
|
jest.spyOn(logger, "log").mockClear();
|
|
|
|
|
|
|
|
jest.spyOn(MatrixJs, "createClient").mockReturnValue(mockClient);
|
|
|
|
// remove any mock implementations
|
|
|
|
jest.spyOn(mockPlatform, "createPickleKey").mockRestore();
|
|
|
|
// but still spy and call through
|
|
|
|
jest.spyOn(mockPlatform, "createPickleKey");
|
|
|
|
});
|
|
|
|
|
2023-09-19 02:06:19 +02:00
|
|
|
const refreshToken = "test-refresh-token";
|
|
|
|
|
2023-07-19 09:40:40 +02:00
|
|
|
it("should remove fresh login flag from session storage", async () => {
|
|
|
|
await setLoggedIn(credentials);
|
|
|
|
|
|
|
|
expect(sessionStorage.removeItem).toHaveBeenCalledWith("mx_fresh_login");
|
|
|
|
});
|
|
|
|
|
|
|
|
it("should start matrix client", async () => {
|
|
|
|
await setLoggedIn(credentials);
|
|
|
|
|
|
|
|
expect(MatrixClientPeg.start).toHaveBeenCalled();
|
|
|
|
});
|
|
|
|
|
|
|
|
describe("without a pickle key", () => {
|
|
|
|
beforeEach(() => {
|
|
|
|
jest.spyOn(mockPlatform, "createPickleKey").mockResolvedValue(null);
|
|
|
|
});
|
|
|
|
|
|
|
|
it("should persist credentials", async () => {
|
|
|
|
await setLoggedIn(credentials);
|
|
|
|
|
|
|
|
expect(localStorage.setItem).toHaveBeenCalledWith("mx_user_id", userId);
|
|
|
|
expect(localStorage.setItem).toHaveBeenCalledWith("mx_has_access_token", "true");
|
|
|
|
expect(localStorage.setItem).toHaveBeenCalledWith("mx_is_guest", "false");
|
|
|
|
expect(localStorage.setItem).toHaveBeenCalledWith("mx_device_id", deviceId);
|
|
|
|
|
2024-05-03 00:19:55 +02:00
|
|
|
expect(StorageAccess.idbSave).toHaveBeenCalledWith("account", "mx_access_token", accessToken);
|
2023-07-19 09:40:40 +02:00
|
|
|
// dont put accessToken in localstorage when we have idb
|
|
|
|
expect(localStorage.setItem).not.toHaveBeenCalledWith("mx_access_token", accessToken);
|
2023-09-19 02:06:19 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
it("should persist a refreshToken when present", async () => {
|
|
|
|
await setLoggedIn({
|
|
|
|
...credentials,
|
|
|
|
refreshToken,
|
|
|
|
});
|
|
|
|
|
2024-05-03 00:19:55 +02:00
|
|
|
expect(StorageAccess.idbSave).toHaveBeenCalledWith("account", "mx_access_token", accessToken);
|
|
|
|
expect(StorageAccess.idbSave).toHaveBeenCalledWith("account", "mx_refresh_token", refreshToken);
|
2023-09-19 02:06:19 +02:00
|
|
|
// dont put accessToken in localstorage when we have idb
|
|
|
|
expect(localStorage.setItem).not.toHaveBeenCalledWith("mx_access_token", accessToken);
|
2023-07-19 09:40:40 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
it("should remove any access token from storage when there is none in credentials and idb save fails", async () => {
|
2024-05-03 00:19:55 +02:00
|
|
|
jest.spyOn(StorageAccess, "idbSave").mockRejectedValue("oups");
|
2023-07-19 09:40:40 +02:00
|
|
|
await setLoggedIn({
|
|
|
|
...credentials,
|
|
|
|
// @ts-ignore
|
|
|
|
accessToken: undefined,
|
|
|
|
});
|
|
|
|
|
|
|
|
expect(localStorage.removeItem).toHaveBeenCalledWith("mx_has_access_token");
|
|
|
|
expect(localStorage.removeItem).toHaveBeenCalledWith("mx_access_token");
|
|
|
|
});
|
|
|
|
|
|
|
|
it("should clear stores", async () => {
|
|
|
|
await setLoggedIn(credentials);
|
|
|
|
|
2024-05-03 00:19:55 +02:00
|
|
|
expect(StorageAccess.idbDelete).toHaveBeenCalledWith("account", "mx_access_token");
|
2023-07-19 09:40:40 +02:00
|
|
|
expect(sessionStorage.clear).toHaveBeenCalled();
|
|
|
|
expect(mockClient.clearStores).toHaveBeenCalled();
|
|
|
|
});
|
|
|
|
|
|
|
|
it("should create new matrix client with credentials", async () => {
|
|
|
|
expect(await setLoggedIn(credentials)).toEqual(mockClient);
|
|
|
|
|
2023-10-12 02:49:07 +02:00
|
|
|
expect(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith(
|
|
|
|
{
|
|
|
|
userId,
|
|
|
|
accessToken,
|
|
|
|
homeserverUrl,
|
|
|
|
identityServerUrl,
|
|
|
|
deviceId,
|
|
|
|
freshLogin: true,
|
|
|
|
guest: false,
|
|
|
|
pickleKey: null,
|
|
|
|
},
|
|
|
|
undefined,
|
|
|
|
);
|
2023-07-19 09:40:40 +02:00
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
describe("with a pickle key", () => {
|
|
|
|
it("should not create a pickle key when credentials do not include deviceId", async () => {
|
|
|
|
await setLoggedIn({
|
|
|
|
...credentials,
|
|
|
|
deviceId: undefined,
|
|
|
|
});
|
|
|
|
|
|
|
|
// unpickled access token saved
|
2024-05-03 00:19:55 +02:00
|
|
|
expect(StorageAccess.idbSave).toHaveBeenCalledWith("account", "mx_access_token", accessToken);
|
2023-07-19 09:40:40 +02:00
|
|
|
expect(mockPlatform.createPickleKey).not.toHaveBeenCalled();
|
|
|
|
});
|
|
|
|
|
|
|
|
it("creates a pickle key with userId and deviceId", async () => {
|
|
|
|
await setLoggedIn(credentials);
|
|
|
|
|
|
|
|
expect(mockPlatform.createPickleKey).toHaveBeenCalledWith(userId, deviceId);
|
|
|
|
});
|
|
|
|
|
|
|
|
it("should persist credentials", async () => {
|
|
|
|
await setLoggedIn(credentials);
|
|
|
|
|
|
|
|
expect(localStorage.setItem).toHaveBeenCalledWith("mx_user_id", userId);
|
|
|
|
expect(localStorage.setItem).toHaveBeenCalledWith("mx_has_access_token", "true");
|
|
|
|
expect(localStorage.setItem).toHaveBeenCalledWith("mx_is_guest", "false");
|
|
|
|
expect(localStorage.setItem).toHaveBeenCalledWith("mx_device_id", deviceId);
|
|
|
|
|
|
|
|
expect(localStorage.setItem).toHaveBeenCalledWith("mx_has_pickle_key", "true");
|
2024-05-03 00:19:55 +02:00
|
|
|
expect(StorageAccess.idbSave).toHaveBeenCalledWith(
|
2023-07-19 09:40:40 +02:00
|
|
|
"account",
|
|
|
|
"mx_access_token",
|
|
|
|
encryptedTokenShapedObject,
|
|
|
|
);
|
2024-05-03 00:19:55 +02:00
|
|
|
expect(StorageAccess.idbSave).toHaveBeenCalledWith("pickleKey", [userId, deviceId], expect.any(Object));
|
2023-07-19 09:40:40 +02:00
|
|
|
// dont put accessToken in localstorage when we have idb
|
|
|
|
expect(localStorage.setItem).not.toHaveBeenCalledWith("mx_access_token", accessToken);
|
|
|
|
});
|
|
|
|
|
|
|
|
it("should persist token when encrypting the token fails", async () => {
|
|
|
|
jest.spyOn(MatrixCryptoAes, "encryptAES").mockRejectedValue("MOCK REJECT ENCRYPTAES");
|
|
|
|
await setLoggedIn(credentials);
|
|
|
|
|
|
|
|
// persist the unencrypted token
|
2024-05-03 00:19:55 +02:00
|
|
|
expect(StorageAccess.idbSave).toHaveBeenCalledWith("account", "mx_access_token", accessToken);
|
2023-07-19 09:40:40 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
it("should persist token in localStorage when idb fails to save token", async () => {
|
|
|
|
// dont fail for pickle key persist
|
2024-05-03 00:19:55 +02:00
|
|
|
jest.spyOn(StorageAccess, "idbSave").mockImplementation(
|
2023-07-19 09:40:40 +02:00
|
|
|
async (table: string, key: string | string[]) => {
|
|
|
|
if (table === "account" && key === "mx_access_token") {
|
|
|
|
throw new Error("oups");
|
|
|
|
}
|
|
|
|
},
|
|
|
|
);
|
|
|
|
await setLoggedIn(credentials);
|
|
|
|
|
|
|
|
// put plain accessToken in localstorage when we dont have idb
|
|
|
|
expect(localStorage.setItem).toHaveBeenCalledWith("mx_access_token", accessToken);
|
|
|
|
});
|
|
|
|
|
|
|
|
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
|
2024-05-03 00:19:55 +02:00
|
|
|
jest.spyOn(StorageAccess, "idbSave").mockImplementation(
|
2023-07-19 09:40:40 +02:00
|
|
|
async (table: string, key: string | string[]) => {
|
|
|
|
if (table === "account" && key === "mx_access_token") {
|
|
|
|
throw new Error("oups");
|
|
|
|
}
|
|
|
|
},
|
|
|
|
);
|
|
|
|
await setLoggedIn({
|
|
|
|
...credentials,
|
|
|
|
// @ts-ignore
|
|
|
|
accessToken: undefined,
|
|
|
|
});
|
|
|
|
|
|
|
|
expect(localStorage.removeItem).toHaveBeenCalledWith("mx_has_access_token");
|
|
|
|
expect(localStorage.removeItem).toHaveBeenCalledWith("mx_access_token");
|
|
|
|
});
|
|
|
|
|
|
|
|
it("should create new matrix client with credentials", async () => {
|
|
|
|
expect(await setLoggedIn(credentials)).toEqual(mockClient);
|
|
|
|
|
2023-10-12 02:49:07 +02:00
|
|
|
expect(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith(
|
|
|
|
{
|
|
|
|
userId,
|
|
|
|
accessToken,
|
|
|
|
homeserverUrl,
|
|
|
|
identityServerUrl,
|
|
|
|
deviceId,
|
|
|
|
freshLogin: true,
|
|
|
|
guest: false,
|
|
|
|
pickleKey: expect.any(String),
|
|
|
|
},
|
|
|
|
undefined,
|
|
|
|
);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
describe("when authenticated via OIDC native flow", () => {
|
|
|
|
const clientId = "test-client-id";
|
|
|
|
const issuer = "https://auth.com/";
|
|
|
|
|
|
|
|
const delegatedAuthConfig = makeDelegatedAuthConfig(issuer);
|
2024-05-07 13:27:37 +02:00
|
|
|
const idToken =
|
|
|
|
"eyJhbGciOiJSUzI1NiIsImtpZCI6Imh4ZEhXb0Y5bW4ifQ.eyJzdWIiOiIwMUhQUDJGU0JZREU5UDlFTU04REQ3V1pIUiIsImlzcyI6Imh0dHBzOi8vYXV0aC1vaWRjLmxhYi5lbGVtZW50LmRldi8iLCJpYXQiOjE3MTUwNzE5ODUsImF1dGhfdGltZSI6MTcwNzk5MDMxMiwiY19oYXNoIjoidGt5R1RhUjU5aTk3YXoyTU4yMGdidyIsImV4cCI6MTcxNTA3NTU4NSwibm9uY2UiOiJxaXhwM0hFMmVaIiwiYXVkIjoiMDFIWDk0Mlg3QTg3REgxRUs2UDRaNjI4WEciLCJhdF9oYXNoIjoiNFlFUjdPRlVKTmRTeEVHV2hJUDlnZyJ9.HxODneXvSTfWB5Vc4cf7b8GiN2gdwUuTiyVqZuupWske2HkZiJZUt5Lsxg9BW3gz28POkE0Ln17snlkmy02B_AD3DQxKOOxQCzIIARHdfFvZxgGWsMdFcVQZDW7rtXcqgj-SpVaUQ_8acsgxSrz_DF2o0O4tto0PT6wVUiw8KlBmgWTscWPeAWe-39T-8EiQ8Wi16h6oSPcz2NzOQ7eOM_S9fDkOorgcBkRGLl1nrahrPSdWJSGAeruk5mX4YxN714YThFDyEA2t9YmKpjaiSQ2tT-Xkd7tgsZqeirNs2ni9mIiFX3bRX6t2AhUNzA7MaX9ZyizKGa6go3BESO_oDg";
|
2023-10-12 02:49:07 +02:00
|
|
|
|
|
|
|
beforeAll(() => {
|
|
|
|
fetchMock.get(
|
2024-02-23 17:43:14 +01:00
|
|
|
`${delegatedAuthConfig.metadata.issuer}.well-known/openid-configuration`,
|
2023-10-12 02:49:07 +02:00
|
|
|
delegatedAuthConfig.metadata,
|
|
|
|
);
|
2024-02-23 17:43:14 +01:00
|
|
|
fetchMock.get(`${delegatedAuthConfig.metadata.issuer}jwks`, {
|
2023-10-12 02:49:07 +02:00
|
|
|
status: 200,
|
|
|
|
headers: {
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
},
|
|
|
|
keys: [],
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
initSessionStorageMock();
|
|
|
|
// set values in session storage as they would be after a successful oidc authentication
|
2024-05-07 13:27:37 +02:00
|
|
|
persistOidcAuthenticatedSettings(clientId, issuer, idToken);
|
2023-10-12 02:49:07 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
it("should not try to create a token refresher without a refresh token", async () => {
|
|
|
|
await setLoggedIn(credentials);
|
|
|
|
|
|
|
|
// didn't try to initialise token refresher
|
2024-02-23 17:43:14 +01:00
|
|
|
expect(fetchMock).not.toHaveFetched(
|
|
|
|
`${delegatedAuthConfig.metadata.issuer}.well-known/openid-configuration`,
|
|
|
|
);
|
2023-10-12 02:49:07 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
it("should not try to create a token refresher without a deviceId", async () => {
|
|
|
|
await setLoggedIn({
|
|
|
|
...credentials,
|
|
|
|
refreshToken,
|
|
|
|
deviceId: undefined,
|
|
|
|
});
|
|
|
|
|
|
|
|
// didn't try to initialise token refresher
|
2024-02-23 17:43:14 +01:00
|
|
|
expect(fetchMock).not.toHaveFetched(
|
|
|
|
`${delegatedAuthConfig.metadata.issuer}.well-known/openid-configuration`,
|
|
|
|
);
|
2023-10-12 02:49:07 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
it("should not try to create a token refresher without an issuer in session storage", async () => {
|
|
|
|
persistOidcAuthenticatedSettings(
|
|
|
|
clientId,
|
|
|
|
// @ts-ignore set undefined issuer
|
|
|
|
undefined,
|
2024-05-07 13:27:37 +02:00
|
|
|
idToken,
|
2023-10-12 02:49:07 +02:00
|
|
|
);
|
|
|
|
await setLoggedIn({
|
|
|
|
...credentials,
|
|
|
|
refreshToken,
|
|
|
|
});
|
|
|
|
|
|
|
|
// didn't try to initialise token refresher
|
2024-02-23 17:43:14 +01:00
|
|
|
expect(fetchMock).not.toHaveFetched(
|
|
|
|
`${delegatedAuthConfig.metadata.issuer}.well-known/openid-configuration`,
|
|
|
|
);
|
2023-10-12 02:49:07 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
it("should create a client with a tokenRefreshFunction", async () => {
|
|
|
|
expect(
|
|
|
|
await setLoggedIn({
|
|
|
|
...credentials,
|
|
|
|
refreshToken,
|
|
|
|
}),
|
|
|
|
).toEqual(mockClient);
|
|
|
|
|
|
|
|
expect(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith(
|
|
|
|
expect.objectContaining({
|
|
|
|
accessToken,
|
|
|
|
refreshToken,
|
|
|
|
}),
|
|
|
|
expect.any(Function),
|
|
|
|
);
|
|
|
|
});
|
|
|
|
|
|
|
|
it("should create a client when creating token refresher fails", async () => {
|
|
|
|
// set invalid value in session storage for a malformed oidc authentication
|
2024-05-07 13:27:37 +02:00
|
|
|
persistOidcAuthenticatedSettings(null as any, issuer, idToken);
|
2023-10-12 02:49:07 +02:00
|
|
|
|
|
|
|
// succeeded
|
|
|
|
expect(
|
|
|
|
await setLoggedIn({
|
|
|
|
...credentials,
|
|
|
|
refreshToken,
|
|
|
|
}),
|
|
|
|
).toEqual(mockClient);
|
|
|
|
|
|
|
|
expect(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith(
|
|
|
|
expect.objectContaining({
|
|
|
|
accessToken,
|
|
|
|
refreshToken,
|
|
|
|
}),
|
|
|
|
// no token refresh function
|
|
|
|
undefined,
|
|
|
|
);
|
2023-07-19 09:40:40 +02:00
|
|
|
});
|
|
|
|
});
|
|
|
|
});
|
2023-10-15 23:35:25 +02:00
|
|
|
|
|
|
|
describe("logout()", () => {
|
|
|
|
let oidcClientStore!: OidcClientStore;
|
|
|
|
const accessToken = "test-access-token";
|
|
|
|
const refreshToken = "test-refresh-token";
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
oidcClientStore = new OidcClientStore(mockClient);
|
|
|
|
// stub
|
|
|
|
jest.spyOn(oidcClientStore, "revokeTokens").mockResolvedValue(undefined);
|
|
|
|
|
|
|
|
mockClient.getAccessToken.mockReturnValue(accessToken);
|
|
|
|
mockClient.getRefreshToken.mockReturnValue(refreshToken);
|
|
|
|
});
|
|
|
|
|
|
|
|
it("should call logout on the client when oidcClientStore is falsy", async () => {
|
|
|
|
logout();
|
|
|
|
|
|
|
|
await flushPromises();
|
|
|
|
|
|
|
|
expect(mockClient.logout).toHaveBeenCalledWith(true);
|
|
|
|
});
|
|
|
|
|
|
|
|
it("should call logout on the client when oidcClientStore.isUserAuthenticatedWithOidc is falsy", async () => {
|
|
|
|
jest.spyOn(oidcClientStore, "isUserAuthenticatedWithOidc", "get").mockReturnValue(false);
|
|
|
|
logout(oidcClientStore);
|
|
|
|
|
|
|
|
await flushPromises();
|
|
|
|
|
|
|
|
expect(mockClient.logout).toHaveBeenCalledWith(true);
|
|
|
|
expect(oidcClientStore.revokeTokens).not.toHaveBeenCalled();
|
|
|
|
});
|
|
|
|
|
|
|
|
it("should revoke tokens when user is authenticated with oidc", async () => {
|
|
|
|
jest.spyOn(oidcClientStore, "isUserAuthenticatedWithOidc", "get").mockReturnValue(true);
|
|
|
|
logout(oidcClientStore);
|
|
|
|
|
|
|
|
await flushPromises();
|
|
|
|
|
|
|
|
expect(mockClient.logout).not.toHaveBeenCalled();
|
|
|
|
expect(oidcClientStore.revokeTokens).toHaveBeenCalledWith(accessToken, refreshToken);
|
|
|
|
});
|
|
|
|
});
|
2024-02-22 16:41:21 +01:00
|
|
|
|
|
|
|
describe("overwritelogin", () => {
|
|
|
|
beforeEach(async () => {
|
|
|
|
jest.spyOn(MatrixJs, "createClient").mockReturnValue(mockClient);
|
|
|
|
});
|
|
|
|
|
|
|
|
it("should replace the current login with a new one", async () => {
|
|
|
|
const stopSpy = jest.spyOn(mockClient, "stopClient").mockReturnValue(undefined);
|
|
|
|
const dis = window.mxDispatcher;
|
|
|
|
|
|
|
|
const firstLoginEvent: Promise<void> = new Promise((resolve) => {
|
|
|
|
dis.register(({ action }) => {
|
|
|
|
if (action === Action.OnLoggedIn) {
|
|
|
|
resolve();
|
|
|
|
}
|
|
|
|
});
|
|
|
|
});
|
|
|
|
// set a logged in state
|
|
|
|
await setLoggedIn(credentials);
|
|
|
|
|
|
|
|
await firstLoginEvent;
|
|
|
|
|
|
|
|
expect(stopSpy).toHaveBeenCalledTimes(1);
|
|
|
|
// important the overwrite action should not call unset before replacing.
|
|
|
|
// So spy on it and make sure it's not called.
|
|
|
|
jest.spyOn(MatrixClientPeg, "unset").mockReturnValue(undefined);
|
|
|
|
|
|
|
|
expect(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith(
|
|
|
|
expect.objectContaining({
|
|
|
|
userId,
|
|
|
|
}),
|
|
|
|
undefined,
|
|
|
|
);
|
|
|
|
|
|
|
|
const otherCredentials = {
|
|
|
|
...credentials,
|
|
|
|
userId: "@bob:server.org",
|
|
|
|
deviceId: "def456",
|
|
|
|
};
|
|
|
|
|
|
|
|
const secondLoginEvent: Promise<void> = new Promise((resolve) => {
|
|
|
|
dis.register(({ action }) => {
|
|
|
|
if (action === Action.OnLoggedIn) {
|
|
|
|
resolve();
|
|
|
|
}
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
// Trigger the overwrite login action
|
|
|
|
dis.dispatch(
|
|
|
|
{
|
|
|
|
action: "overwrite_login",
|
|
|
|
credentials: otherCredentials,
|
|
|
|
},
|
|
|
|
true,
|
|
|
|
);
|
|
|
|
|
|
|
|
await secondLoginEvent;
|
|
|
|
// the client should have been stopped
|
|
|
|
expect(stopSpy).toHaveBeenCalledTimes(2);
|
|
|
|
|
|
|
|
expect(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith(
|
|
|
|
expect.objectContaining({
|
|
|
|
userId: otherCredentials.userId,
|
|
|
|
}),
|
|
|
|
undefined,
|
|
|
|
);
|
|
|
|
|
|
|
|
expect(MatrixClientPeg.unset).not.toHaveBeenCalled();
|
|
|
|
});
|
|
|
|
});
|
2023-07-19 09:40:40 +02:00
|
|
|
});
|