This is the mode in which we sent membership events with the 'oldest membership' transport selection algorithm, which stopped being the default back in version 0.21.0. Users will no longer be able to select this mode in developer settings, and admins will no longer be able to select legacy mode through the config either. The app will still continue to support *receiving* membership events with the 'oldest membership' transport selection algorithm from others, however.
413 lines
14 KiB
TypeScript
413 lines
14 KiB
TypeScript
/*
|
|
Copyright 2025 Element Creations Ltd.
|
|
|
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|
Please see LICENSE in the repository root for full details.
|
|
*/
|
|
|
|
import {
|
|
afterEach,
|
|
beforeEach,
|
|
describe,
|
|
expect,
|
|
it,
|
|
type MockedObject,
|
|
vi,
|
|
} from "vitest";
|
|
import {
|
|
type CallMembership,
|
|
type LivekitTransportConfig,
|
|
} from "matrix-js-sdk/lib/matrixrtc";
|
|
import { BehaviorSubject, filter, lastValueFrom } from "rxjs";
|
|
import fetchMock from "fetch-mock";
|
|
|
|
import {
|
|
mockConfig,
|
|
flushPromises,
|
|
ownMemberMock,
|
|
testScope,
|
|
} from "../../../utils/test";
|
|
import {
|
|
createLocalTransport$,
|
|
JwtEndpointVersion,
|
|
type LocalTransportWithSFUConfig,
|
|
} from "./LocalTransport";
|
|
import { constant } from "../../Behavior";
|
|
import { Epoch, ObservableScope } from "../../ObservableScope";
|
|
import {
|
|
MatrixRTCTransportMissingError,
|
|
FailToGetOpenIdToken,
|
|
} from "../../../utils/errors";
|
|
import * as openIDSFU from "../../../livekit/openIDSFU";
|
|
import { customLivekitUrl } from "../../../settings/settings";
|
|
import { testJWTToken } from "../../../utils/test-fixtures";
|
|
|
|
describe("LocalTransport", () => {
|
|
const openIdResponse: openIDSFU.SFUConfig = {
|
|
url: "https://lk.example.org",
|
|
jwt: testJWTToken,
|
|
livekitAlias: "Akph4alDMhen",
|
|
livekitIdentity: "@lk_user:ABCDEF",
|
|
};
|
|
|
|
beforeEach(() => vi.clearAllMocks());
|
|
|
|
it("throws if config is missing", async () => {
|
|
const { advertised$, active$ } = createLocalTransport$({
|
|
scope: testScope(),
|
|
roomId: "!room:example.org",
|
|
memberships$: constant(new Epoch<CallMembership[]>([])),
|
|
client: {
|
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
|
getDomain: () => "example.org",
|
|
baseUrl: "example.org",
|
|
// These won't be called in this error path but satisfy the type
|
|
getOpenIdToken: vi.fn(),
|
|
getDeviceId: vi.fn(),
|
|
},
|
|
ownMembershipIdentity: ownMemberMock,
|
|
forceJwtEndpoint: JwtEndpointVersion.Legacy,
|
|
delayId$: constant("delay_id_mock"),
|
|
});
|
|
await flushPromises();
|
|
|
|
expect(() => advertised$.value).toThrow(
|
|
new MatrixRTCTransportMissingError("example.org"),
|
|
);
|
|
expect(() => active$.value).toThrow(
|
|
new MatrixRTCTransportMissingError("example.org"),
|
|
);
|
|
});
|
|
|
|
it("throws FailToGetOpenIdToken when OpenID fetch fails", async () => {
|
|
// Provide a valid config so makeTransportInternal resolves a transport
|
|
const scope = new ObservableScope();
|
|
mockConfig({
|
|
livekit: { livekit_service_url: "https://lk.example.org" },
|
|
});
|
|
const resolver = Promise.withResolvers<void>();
|
|
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockImplementation(
|
|
async () => {
|
|
await resolver.promise;
|
|
throw new FailToGetOpenIdToken(new Error("no openid"));
|
|
},
|
|
);
|
|
const observations: unknown[] = [];
|
|
const errors: Error[] = [];
|
|
const { advertised$, active$ } = createLocalTransport$({
|
|
scope,
|
|
roomId: "!example_room_id",
|
|
memberships$: constant(new Epoch<CallMembership[]>([])),
|
|
client: {
|
|
baseUrl: "https://example.org",
|
|
getDomain: () => "example.org",
|
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
|
getOpenIdToken: vi.fn(),
|
|
getDeviceId: vi.fn(),
|
|
},
|
|
ownMembershipIdentity: ownMemberMock,
|
|
forceJwtEndpoint: JwtEndpointVersion.Legacy,
|
|
delayId$: constant("delay_id_mock"),
|
|
});
|
|
active$.subscribe(
|
|
(o) => observations.push(o),
|
|
(e) => errors.push(e),
|
|
);
|
|
resolver.resolve();
|
|
await flushPromises();
|
|
|
|
const expectedError = new FailToGetOpenIdToken(new Error("no openid"));
|
|
expect(observations).toStrictEqual([null]);
|
|
expect(errors).toStrictEqual([expectedError]);
|
|
expect(() => advertised$.value).toThrow(expectedError);
|
|
expect(() => active$.value).toThrow(expectedError);
|
|
});
|
|
|
|
it("emits preferred transport after OpenID resolves", async () => {
|
|
// Use config so transport discovery succeeds, but delay OpenID JWT fetch
|
|
mockConfig({
|
|
livekit: { livekit_service_url: "https://lk.example.org" },
|
|
});
|
|
|
|
const openIdResolver = Promise.withResolvers<openIDSFU.SFUConfig>();
|
|
|
|
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockReturnValue(
|
|
openIdResolver.promise,
|
|
);
|
|
|
|
const { advertised$, active$ } = createLocalTransport$({
|
|
scope: testScope(),
|
|
roomId: "!room:example.org",
|
|
memberships$: constant(new Epoch<CallMembership[]>([])),
|
|
client: {
|
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
|
getDomain: () => "example.org",
|
|
getOpenIdToken: vi.fn(),
|
|
getDeviceId: vi.fn(),
|
|
baseUrl: "https://example.org",
|
|
},
|
|
ownMembershipIdentity: ownMemberMock,
|
|
forceJwtEndpoint: JwtEndpointVersion.Legacy,
|
|
delayId$: constant("delay_id_mock"),
|
|
});
|
|
|
|
openIdResolver.resolve?.({
|
|
url: "https://lk.example.org",
|
|
jwt: "jwt",
|
|
livekitAlias: "Akph4alDMhen",
|
|
livekitIdentity: ownMemberMock.userId + ":" + ownMemberMock.deviceId,
|
|
});
|
|
expect(advertised$.value).toBe(null);
|
|
expect(active$.value).toBe(null);
|
|
await flushPromises();
|
|
// final
|
|
const expectedTransport = {
|
|
livekit_service_url: "https://lk.example.org",
|
|
type: "livekit",
|
|
};
|
|
expect(advertised$.value).toStrictEqual(expectedTransport);
|
|
expect(active$.value).toStrictEqual({
|
|
transport: expectedTransport,
|
|
sfuConfig: {
|
|
jwt: "jwt",
|
|
livekitAlias: "Akph4alDMhen",
|
|
livekitIdentity: "@alice:example.org:DEVICE",
|
|
url: "https://lk.example.org",
|
|
},
|
|
});
|
|
});
|
|
|
|
type LocalTransportProps = Parameters<typeof createLocalTransport$>[0];
|
|
|
|
describe("transport configuration mechanisms", () => {
|
|
let localTransportOpts: LocalTransportProps & {
|
|
client: MockedObject<LocalTransportProps["client"]>;
|
|
};
|
|
let openIdResolver: PromiseWithResolvers<openIDSFU.SFUConfig>;
|
|
beforeEach(() => {
|
|
mockConfig({});
|
|
customLivekitUrl.setValue(customLivekitUrl.defaultValue);
|
|
localTransportOpts = {
|
|
ownMembershipIdentity: ownMemberMock,
|
|
scope: testScope(),
|
|
roomId: "!example_room_id",
|
|
forceJwtEndpoint: JwtEndpointVersion.Legacy,
|
|
delayId$: constant(null),
|
|
memberships$: constant(new Epoch<CallMembership[]>([])),
|
|
client: {
|
|
baseUrl: "https://example.org",
|
|
getDomain: vi.fn().mockReturnValue("example.org"),
|
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
_unstable_getRTCTransports: vi.fn().mockResolvedValue([]),
|
|
getOpenIdToken: vi.fn(),
|
|
getDeviceId: vi.fn(),
|
|
},
|
|
};
|
|
openIdResolver = Promise.withResolvers<openIDSFU.SFUConfig>();
|
|
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockReturnValue(
|
|
openIdResolver.promise,
|
|
);
|
|
});
|
|
|
|
afterEach(() => {
|
|
fetchMock.reset();
|
|
});
|
|
|
|
it("supports getting transport via application config", async () => {
|
|
mockConfig({
|
|
livekit: { livekit_service_url: "https://lk.example.org" },
|
|
});
|
|
const { advertised$, active$ } =
|
|
createLocalTransport$(localTransportOpts);
|
|
openIdResolver.resolve?.(openIdResponse);
|
|
expect(advertised$.value).toBe(null);
|
|
expect(active$.value).toBe(null);
|
|
await flushPromises();
|
|
const expectedTransport = {
|
|
livekit_service_url: "https://lk.example.org",
|
|
type: "livekit",
|
|
};
|
|
expect(advertised$.value).toStrictEqual(expectedTransport);
|
|
expect(active$.value).toStrictEqual({
|
|
transport: expectedTransport,
|
|
sfuConfig: {
|
|
jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
|
|
livekitAlias: "Akph4alDMhen",
|
|
livekitIdentity: "@lk_user:ABCDEF",
|
|
url: "https://lk.example.org",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("supports getting transport via user settings", async () => {
|
|
customLivekitUrl.setValue("https://lk.example.org");
|
|
const { advertised$, active$ } =
|
|
createLocalTransport$(localTransportOpts);
|
|
openIdResolver.resolve?.(openIdResponse);
|
|
expect(advertised$.value).toBe(null);
|
|
await flushPromises();
|
|
expect(active$.value).toStrictEqual({
|
|
transport: {
|
|
livekit_service_url: "https://lk.example.org",
|
|
type: "livekit",
|
|
},
|
|
sfuConfig: {
|
|
jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
|
|
livekitAlias: "Akph4alDMhen",
|
|
livekitIdentity: "@lk_user:ABCDEF",
|
|
url: "https://lk.example.org",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("supports getting transport via backend", async () => {
|
|
localTransportOpts.client._unstable_getRTCTransports.mockResolvedValue([
|
|
{ type: "livekit", livekit_service_url: "https://lk.example.org" },
|
|
]);
|
|
const { advertised$, active$ } =
|
|
createLocalTransport$(localTransportOpts);
|
|
openIdResolver.resolve?.(openIdResponse);
|
|
expect(advertised$.value).toBe(null);
|
|
expect(active$.value).toBe(null);
|
|
await flushPromises();
|
|
const expectedTransport = {
|
|
livekit_service_url: "https://lk.example.org",
|
|
type: "livekit",
|
|
};
|
|
expect(advertised$.value).toStrictEqual(expectedTransport);
|
|
expect(active$.value).toStrictEqual({
|
|
transport: expectedTransport,
|
|
sfuConfig: {
|
|
jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
|
|
livekitAlias: "Akph4alDMhen",
|
|
livekitIdentity: "@lk_user:ABCDEF",
|
|
url: "https://lk.example.org",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("fails fast if the openID request fails for backend config", async () => {
|
|
localTransportOpts.client._unstable_getRTCTransports.mockResolvedValue([
|
|
{ type: "livekit", livekit_service_url: "https://lk.example.org" },
|
|
]);
|
|
openIdResolver.reject(
|
|
new FailToGetOpenIdToken(new Error("Test driven error")),
|
|
);
|
|
await expect(async () =>
|
|
lastValueFrom(createLocalTransport$(localTransportOpts).active$),
|
|
).rejects.toThrow(expect.any(FailToGetOpenIdToken));
|
|
});
|
|
|
|
it("throws if no options are available", async () => {
|
|
const { advertised$, active$ } = createLocalTransport$({
|
|
scope: testScope(),
|
|
ownMembershipIdentity: ownMemberMock,
|
|
roomId: "!example_room_id",
|
|
forceJwtEndpoint: JwtEndpointVersion.Legacy,
|
|
delayId$: constant(null),
|
|
memberships$: constant(new Epoch<CallMembership[]>([])),
|
|
client: {
|
|
getDomain: () => "example.org",
|
|
baseUrl: "https://example.org",
|
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
|
// These won't be called in this error path but satisfy the type
|
|
getOpenIdToken: vi.fn(),
|
|
getDeviceId: vi.fn(),
|
|
},
|
|
});
|
|
await flushPromises();
|
|
|
|
expect(() => advertised$.value).toThrow(
|
|
new MatrixRTCTransportMissingError("example.org"),
|
|
);
|
|
expect(() => active$.value).toThrow(
|
|
new MatrixRTCTransportMissingError("example.org"),
|
|
);
|
|
});
|
|
});
|
|
|
|
it("should not update advertised/active transport on delayID changes, but delay Id delegation should be called", async () => {
|
|
// For simplicity, we'll just use the config livekit
|
|
customLivekitUrl.setValue("https://lk.example.org");
|
|
|
|
const authCallSpy = vi
|
|
.spyOn(openIDSFU, "getSFUConfigWithOpenID")
|
|
.mockResolvedValue(openIdResponse);
|
|
|
|
const delayId$ = new BehaviorSubject<string | null>(null);
|
|
|
|
const { advertised$, active$ } = createLocalTransport$({
|
|
scope: testScope(),
|
|
ownMembershipIdentity: ownMemberMock,
|
|
roomId: "!example_room_id",
|
|
// We want multi-sdu
|
|
forceJwtEndpoint: JwtEndpointVersion.Legacy,
|
|
delayId$: delayId$,
|
|
memberships$: constant(new Epoch<CallMembership[]>([])),
|
|
client: {
|
|
getDomain: () => "example.org",
|
|
baseUrl: "https://example.org",
|
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
|
// These won't be called in this error path but satisfy the type
|
|
getOpenIdToken: vi.fn(),
|
|
getDeviceId: vi.fn(),
|
|
},
|
|
});
|
|
|
|
const advertisedValues: LivekitTransportConfig[] = [];
|
|
const activeValues: LocalTransportWithSFUConfig[] = [];
|
|
advertised$
|
|
.pipe(filter((v) => v !== null))
|
|
.subscribe((t) => advertisedValues.push(t));
|
|
active$
|
|
.pipe(filter((v) => v !== null))
|
|
.subscribe((t) => activeValues.push(t));
|
|
|
|
await flushPromises();
|
|
|
|
// we have now an active and an advertised
|
|
expect(advertisedValues.length).toEqual(1);
|
|
expect(activeValues.length).toEqual(1);
|
|
expect(advertisedValues[0]!.livekit_service_url).toEqual(
|
|
"https://lk.example.org",
|
|
);
|
|
expect(activeValues[0]!.transport.livekit_service_url).toEqual(
|
|
"https://lk.example.org",
|
|
);
|
|
|
|
expect(authCallSpy).toHaveBeenCalledTimes(2);
|
|
// Now emits 3 new delays id
|
|
delayId$.next("delay_id_1");
|
|
await flushPromises();
|
|
delayId$.next("delay_id_2");
|
|
await flushPromises();
|
|
delayId$.next("delay_id_3");
|
|
await flushPromises();
|
|
|
|
// No new emissions should've happened, it is the same transport.
|
|
expect(advertisedValues.length).toEqual(1);
|
|
expect(activeValues.length).toEqual(1);
|
|
|
|
// Still we should have updated the delayID to auth
|
|
expect(authCallSpy).toHaveBeenCalledTimes(
|
|
4 * 2 /* 2 calls for each delayId ?? why */,
|
|
);
|
|
|
|
expect(authCallSpy).toHaveBeenLastCalledWith(
|
|
expect.anything(),
|
|
expect.anything(),
|
|
expect.anything(),
|
|
expect.anything(),
|
|
expect.objectContaining({
|
|
delayId: "delay_id_3",
|
|
}),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
});
|