Add mock test for libsignal websockets

This commit is contained in:
trevor-signal
2025-02-21 12:00:56 -05:00
committed by GitHub
parent cfe5a51a1f
commit 1bc5cc339b
16 changed files with 249 additions and 50 deletions

View File

@@ -219,7 +219,7 @@
"@indutny/parallel-prettier": "3.0.0",
"@indutny/rezip-electron": "2.0.1",
"@napi-rs/canvas": "0.1.61",
"@signalapp/mock-server": "10.5.0",
"@signalapp/mock-server": "11.0.0",
"@storybook/addon-a11y": "8.4.4",
"@storybook/addon-actions": "8.4.4",
"@storybook/addon-controls": "8.4.4",

17
pnpm-lock.yaml generated
View File

@@ -432,8 +432,8 @@ importers:
specifier: 0.1.61
version: 0.1.61
'@signalapp/mock-server':
specifier: 10.5.0
version: 10.5.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)
specifier: 11.0.0
version: 11.0.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)
'@storybook/addon-a11y':
specifier: 8.4.4
version: 8.4.4(storybook@8.4.4(bufferutil@4.0.9)(prettier@3.3.3)(utf-8-validate@5.0.10))
@@ -2530,8 +2530,8 @@ packages:
'@signalapp/libsignal-client@0.66.2':
resolution: {integrity: sha512-zwgU0LXIJjJNVBxJWnjbJN1+gIUJ+eY+BnqIP1d0751Sq1zy5VMHs+/5nmEyVGNn6ptD0qseYXkxtr/XLs07Lw==}
'@signalapp/mock-server@10.5.0':
resolution: {integrity: sha512-w7KXcWYRPXhAxYWzeBNesu+A9DO6FYJUhg52md3M9yQkPR2Yr/YBvc9zBhFo5fafZmkaRoxDoz9y29Ivd5ZykQ==}
'@signalapp/mock-server@11.0.0':
resolution: {integrity: sha512-JHEqdjXvWcXyLJ90OtaTIQVtizH/w+rmnPplNmHuUiDZIQIEvE3lRyZFyIvgVTNg29lQWs/Gw/o+hICerdOChQ==}
'@signalapp/parchment-cjs@3.0.1':
resolution: {integrity: sha512-hSBMQ1M7wE4GcC8ZeNtvpJF+DAJg3eIRRf1SiHS3I3Algav/sgJJNm6HIYm6muHuK7IJmuEjkL3ILSXgmu0RfQ==}
@@ -6802,9 +6802,6 @@ packages:
resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==}
engines: {node: '>= 0.6.0'}
long@4.0.0:
resolution: {integrity: sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==}
long@5.2.3:
resolution: {integrity: sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==}
@@ -12262,7 +12259,7 @@ snapshots:
type-fest: 4.26.1
uuid: 8.3.2
'@signalapp/mock-server@10.5.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)':
'@signalapp/mock-server@11.0.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)':
dependencies:
'@indutny/parallel-prettier': 3.0.0(prettier@3.3.3)
'@signalapp/libsignal-client': 0.60.2
@@ -12270,7 +12267,7 @@ snapshots:
'@tus/server': 1.10.2
debug: 4.3.7(supports-color@8.1.1)
is-plain-obj: 3.0.0
long: 4.0.0
long: 5.2.3
micro: 9.4.1
microrouter: 3.1.3
prettier: 3.3.3
@@ -17566,8 +17563,6 @@ snapshots:
loglevel@1.9.2: {}
long@4.0.0: {}
long@5.2.3: {}
longest-streak@2.0.4: {}

View File

@@ -17,6 +17,7 @@ import { SECOND } from './util/durations';
import { isSignalRoute } from './util/signalRoutes';
import { strictAssert } from './util/assert';
import { MessageModel } from './models/messages';
import type { SocketStatuses } from './textsecure/SocketManager';
type ResolveType = (data: unknown) => void;
@@ -28,6 +29,7 @@ export type CIType = {
sentAt: number
): Promise<ReadonlyArray<MessageAttributesType>>;
getPendingEventCount: (event: string) => number;
getSocketStatus: () => SocketStatuses;
handleEvent: (event: string, data: unknown) => unknown;
setProvisioningURL: (url: string) => unknown;
solveChallenge: (response: ChallengeResponseType) => unknown;
@@ -202,6 +204,10 @@ export function getCI({
handleEvent('print', format(...args));
}
function getSocketStatus() {
return window.getSocketStatus();
}
async function resetReleaseNotesFetcher() {
await Promise.all([
window.textsecure.storage.put(
@@ -218,6 +224,7 @@ export function getCI({
getConversationId,
createNotificationToken,
getMessagesBySentAt,
getSocketStatus,
handleEvent,
setProvisioningURL,
solveChallenge,

View File

@@ -398,7 +398,10 @@ export async function startApp(): Promise<void> {
window.getSocketStatus = () => {
if (server === undefined) {
return SocketStatus.CLOSED;
return {
authenticated: { status: SocketStatus.CLOSED },
unauthenticated: { status: SocketStatus.CLOSED },
};
}
return server.getSocketStatus();
};
@@ -1141,7 +1144,8 @@ export async function startApp(): Promise<void> {
setupAppState();
drop(start());
window.Signal.Services.initializeNetworkObserver(
window.reduxActions.network
window.reduxActions.network,
() => window.getSocketStatus().authenticated.status
);
window.Signal.Services.initializeUpdateListener(
window.reduxActions.updates
@@ -1947,7 +1951,7 @@ export async function startApp(): Promise<void> {
window.addEventListener('offline', onNavigatorOffline);
window.Whisper.events.on('socketStatusChange', () => {
if (window.getSocketStatus() === SocketStatus.OPEN) {
if (window.getSocketStatus().authenticated.status === SocketStatus.OPEN) {
pauseQueuesAndNotificationsOnSocketConnect();
}
});
@@ -1976,7 +1980,7 @@ export async function startApp(): Promise<void> {
}
function isSocketOnline() {
const socketStatus = window.getSocketStatus();
const socketStatus = window.getSocketStatus().authenticated.status;
return (
socketStatus === SocketStatus.CONNECTING ||
socketStatus === SocketStatus.OPEN

View File

@@ -61,7 +61,7 @@ export function Inbox({
}
const interval = setInterval(() => {
const status = window.getSocketStatus();
const { status } = window.getSocketStatus().authenticated;
switch (status) {
case 'CONNECTING':
break;

View File

@@ -5,7 +5,6 @@ import type {
SetNetworkStatusPayloadType,
NetworkActionType,
} from '../state/ducks/network';
import { getSocketStatus } from '../shims/socketStatus';
import * as log from '../logging/log';
import { SECOND } from '../util/durations';
import { electronLookup } from '../util/dns';
@@ -31,14 +30,15 @@ type NetworkActions = {
};
export function initializeNetworkObserver(
networkActions: NetworkActions
networkActions: NetworkActions,
getAuthSocketStatus: () => SocketStatus
): void {
log.info('Initializing network observer');
let onlineStatus = OnlineStatus.Online;
const refresh = () => {
const socketStatus = getSocketStatus();
const socketStatus = getAuthSocketStatus();
networkActions.setNetworkStatus({
isOnline: onlineStatus !== OnlineStatus.Offline,

View File

@@ -1,10 +0,0 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { SocketStatus } from '../types/SocketStatus';
export function getSocketStatus(): SocketStatus {
const { getSocketStatus: getMessageReceiverStatus } = window;
return getMessageReceiverStatus();
}

View File

@@ -142,6 +142,15 @@ function sanitizePathComponent(component: string): string {
return normalizePath(component.replace(/[^a-z]+/gi, '-'));
}
const DEFAULT_REMOTE_CONFIG = [
['desktop.backup.credentialFetch', { enabled: true }],
['desktop.internalUser', { enabled: true }],
['desktop.releaseNotes', { enabled: true }],
['desktop.senderKey.retry', { enabled: true }],
['global.groupsv2.groupSizeHardLimit', { enabled: true, value: '64' }],
['global.groupsv2.maxGroupSize', { enabled: true, value: '32' }],
] as const;
//
// Bootstrap is a class that prepares mock server and desktop for running
// tests/benchmarks.
@@ -266,6 +275,10 @@ export class Bootstrap {
path.join(os.tmpdir(), 'mock-signal-')
);
DEFAULT_REMOTE_CONFIG.forEach(([key, value]) =>
this.server.setRemoteConfig(key, value)
);
debug('setting storage path=%j', this.#storagePath);
}
@@ -725,7 +738,7 @@ export class Bootstrap {
storagePath: this.#storagePath,
storageProfile: 'mock',
serverUrl: url,
storageUrl: url,
storageUrl: `${url}/storageService`,
resourcesUrl: `${url}/updates2`,
sfuUrl: url,
cdn: {

View File

@@ -0,0 +1,95 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import createDebug from 'debug';
import { assert } from 'chai';
import { type PrimaryDevice, StorageState } from '@signalapp/mock-server';
import type { App } from '../playwright';
import { Bootstrap } from '../bootstrap';
import { typeIntoInput, waitForEnabledComposer } from '../helpers';
import { MINUTE } from '../../util/durations';
export const debug = createDebug('mock:test:libsignal');
describe('Libsignal-net', function (this: Mocha.Suite) {
this.timeout(MINUTE);
let bootstrap: Bootstrap;
let app: App;
let contact: PrimaryDevice;
beforeEach(async () => {
bootstrap = new Bootstrap();
await bootstrap.init();
[contact] = bootstrap.contacts;
let state = StorageState.getEmpty();
state = state.addContact(contact, {
identityKey: contact.publicKey.serialize(),
profileKey: contact.profileKey.serialize(),
whitelisted: true,
});
await bootstrap.phone.setStorageState(state);
bootstrap.server.setRemoteConfig(
'desktop.experimentalTransportEnabled.alpha',
{ enabled: true }
);
bootstrap.server.setRemoteConfig(
'desktop.experimentalTransport.enableAuth',
{ enabled: true }
);
// Link & close so that app can get remote config first over non-libsignal websocket,
// and then on next app start it will connect via libsignal
await bootstrap.linkAndClose();
app = await bootstrap.startApp();
});
afterEach(async function (this: Mocha.Context) {
if (!bootstrap) {
return;
}
await bootstrap.maybeSaveLogs(this.currentTest, app);
await app.close();
await bootstrap.teardown();
});
it('can send and receive messages', async () => {
const window = await app.getWindow();
const { desktop } = bootstrap;
debug('receiving incoming message');
await contact.sendText(bootstrap.desktop, 'incoming message');
debug('ensuring app received message, opening conversation');
{
const leftPane = window.locator('#LeftPane');
const item = leftPane
.getByTestId(contact.toContact().aci)
.getByText('incoming message');
await item.click();
}
debug('sending outgoing message');
const input = await waitForEnabledComposer(window);
await typeIntoInput(input, 'outgoing message');
await input.press('Enter');
debug('waiting for message on server side');
const { body, source } = await contact.waitForMessage();
assert.strictEqual(body, 'outgoing message');
assert.strictEqual(source, desktop);
debug('confirming app successfully sent message');
await app.waitForMessageSend();
debug('confirming that app was actually using libsignal');
const { authenticated, unauthenticated } = await app.getSocketStatus();
assert.strictEqual(authenticated.lastConnectionTransport, 'libsignal');
assert.strictEqual(unauthenticated.lastConnectionTransport, 'libsignal');
});
});

View File

@@ -14,6 +14,7 @@ import type { ReceiptType } from '../types/Receipt';
import { SECOND } from '../util/durations';
import { drop } from '../util/drop';
import type { MessageAttributesType } from '../model-types';
import type { SocketStatuses } from '../textsecure/SocketManager';
export type AppLoadedInfoType = Readonly<{
loadTime: number;
@@ -187,6 +188,11 @@ export class App extends EventEmitter {
);
}
public async getSocketStatus(): Promise<SocketStatuses> {
const window = await this.getWindow();
return window.evaluate('window.SignalCI.getSocketStatus()');
}
public async getMessagesBySentAt(
timestamp: number
): Promise<Array<MessageAttributesType>> {

View File

@@ -27,6 +27,10 @@ describe('release notes', function (this: Mocha.Suite) {
bootstrap = new Bootstrap();
await bootstrap.init();
bootstrap.server.setRemoteConfig('desktop.releaseNotes', {
enabled: true,
});
app = await bootstrap.link();
});

View File

@@ -26,7 +26,7 @@ import { sleep } from '../util/sleep';
import { drop } from '../util/drop';
import type { ProxyAgent } from '../util/createProxyAgent';
import { createProxyAgent } from '../util/createProxyAgent';
import { SocketStatus } from '../types/SocketStatus';
import { type SocketInfo, SocketStatus } from '../types/SocketStatus';
import * as Errors from '../types/errors';
import * as Bytes from '../Bytes';
import * as log from '../logging/log';
@@ -39,6 +39,7 @@ import type {
import WebSocketResource, {
connectAuthenticatedLibsignal,
connectUnauthenticatedLibsignal,
LibsignalWebSocketResource,
ServerRequestType,
TransportOption,
WebSocketResourceWithShadowing,
@@ -48,6 +49,7 @@ import type { IRequestHandler, WebAPICredentials } from './Types.d';
import { connect as connectWebSocket } from './WebSocket';
import { isNightly, isBeta, isStaging } from '../util/version';
import { getBasicAuth } from '../util/getBasicAuth';
import { isTestOrMockEnvironment } from '../environment';
const FIVE_MINUTES = 5 * durations.MINUTE;
@@ -68,6 +70,20 @@ export type SocketManagerOptions = Readonly<{
hasStoriesDisabled: boolean;
}>;
type SocketStatusUpdate =
| {
status: SocketStatus.OPEN;
transportOption: TransportOption.Libsignal | TransportOption.Original;
}
| {
status: Exclude<SocketStatus, SocketStatus.OPEN>;
};
export type SocketStatuses = Record<
'authenticated' | 'unauthenticated',
SocketInfo
>;
// This class manages two websocket resources:
//
// - Authenticated IWebSocketResource which uses supplied WebAPICredentials and
@@ -92,7 +108,12 @@ export class SocketManager extends EventListener {
#unauthenticatedExpirationTimer?: NodeJS.Timeout;
#credentials?: WebAPICredentials;
#lazyProxyAgent?: Promise<ProxyAgent>;
#status = SocketStatus.CLOSED;
#authenticatedStatus: SocketInfo = {
status: SocketStatus.CLOSED,
};
#unathenticatedStatus: SocketInfo = {
status: SocketStatus.CLOSED,
};
#requestHandlers = new Set<IRequestHandler>();
#incomingRequestQueue = new Array<IncomingWebSocketRequest>();
#isNavigatorOffline = false;
@@ -111,8 +132,11 @@ export class SocketManager extends EventListener {
this.#hasStoriesDisabled = options.hasStoriesDisabled;
}
public getStatus(): SocketStatus {
return this.#status;
public getStatus(): SocketStatuses {
return {
authenticated: this.#authenticatedStatus,
unauthenticated: this.#unathenticatedStatus,
};
}
#markOffline() {
@@ -163,7 +187,7 @@ export class SocketManager extends EventListener {
`(hasStoriesDisabled=${this.#hasStoriesDisabled})`
);
this.#setStatus(SocketStatus.CONNECTING);
this.#setAuthenticatedStatus({ status: SocketStatus.CONNECTING });
const proxyAgent = await this.#getProxyAgent();
const useLibsignalTransport =
@@ -252,7 +276,14 @@ export class SocketManager extends EventListener {
let authenticated: IWebSocketResource;
try {
authenticated = await process.getResult();
this.#setStatus(SocketStatus.OPEN);
this.#setAuthenticatedStatus({
status: SocketStatus.OPEN,
transportOption:
authenticated instanceof LibsignalWebSocketResource
? TransportOption.Libsignal
: TransportOption.Original,
});
} catch (error) {
log.warn(
'SocketManager: authenticated socket connection failed with ' +
@@ -290,6 +321,11 @@ export class SocketManager extends EventListener {
) {
this.emit('authError');
return;
} else if (
error instanceof LibSignalErrorBase &&
error.code === ErrorCode.IoError
) {
this.#markOffline();
} else if (
error instanceof LibSignalErrorBase &&
error.code === ErrorCode.AppExpired
@@ -566,21 +602,44 @@ export class SocketManager extends EventListener {
// Private
//
#setStatus(status: SocketStatus): void {
if (this.#status === status) {
#setAuthenticatedStatus(newStatus: SocketStatusUpdate): void {
if (this.#authenticatedStatus.status === newStatus.status) {
return;
}
this.#status = status;
this.#authenticatedStatus.status = newStatus.status;
this.emit('statusChange');
if (this.#status === SocketStatus.OPEN && !this.#privIsOnline) {
if (newStatus.status === SocketStatus.OPEN) {
this.#authenticatedStatus.lastConnectionTimestamp = Date.now();
this.#authenticatedStatus.lastConnectionTransport =
newStatus.transportOption;
if (!this.#privIsOnline) {
this.#privIsOnline = true;
this.emit('online');
}
}
}
#setUnauthenticatedStatus(newStatus: SocketStatusUpdate): void {
this.#unathenticatedStatus.status = newStatus.status;
if (newStatus.status === SocketStatus.OPEN) {
this.#unathenticatedStatus.lastConnectionTimestamp = Date.now();
this.#unathenticatedStatus.lastConnectionTransport =
newStatus.transportOption;
}
}
#transportOption(): TransportOption {
if (isTestOrMockEnvironment()) {
const configValue = window.Signal.RemoteConfig.isEnabled(
'desktop.experimentalTransportEnabled.alpha'
);
return configValue ? TransportOption.Libsignal : TransportOption.Original;
}
// in staging, switch to using libsignal transport
if (isStaging(this.options.version)) {
return TransportOption.Libsignal;
@@ -641,6 +700,10 @@ export class SocketManager extends EventListener {
`SocketManager: connecting unauthenticated socket, transport option [${transportOption}]`
);
this.#setUnauthenticatedStatus({
status: SocketStatus.CONNECTING,
});
let process: AbortableProcess<IWebSocketResource>;
if (transportOption === TransportOption.Libsignal) {
@@ -667,6 +730,13 @@ export class SocketManager extends EventListener {
let unauthenticated: IWebSocketResource;
try {
unauthenticated = await this.#unauthenticated.getResult();
this.#setUnauthenticatedStatus({
status: SocketStatus.OPEN,
transportOption:
unauthenticated instanceof LibsignalWebSocketResource
? TransportOption.Libsignal
: TransportOption.Original,
});
} catch (error) {
log.info(
'SocketManager: failed to connect unauthenticated socket ' +
@@ -824,7 +894,7 @@ export class SocketManager extends EventListener {
this.#incomingRequestQueue = [];
this.#authenticated = undefined;
this.#setStatus(SocketStatus.CLOSED);
this.#setAuthenticatedStatus({ status: SocketStatus.CLOSED });
}
#dropUnauthenticated(process: AbortableProcess<IWebSocketResource>): void {
@@ -833,6 +903,7 @@ export class SocketManager extends EventListener {
}
this.#unauthenticated = undefined;
this.#setUnauthenticatedStatus({ status: SocketStatus.CLOSED });
if (!this.#unauthenticatedExpirationTimer) {
return;
}

View File

@@ -30,7 +30,6 @@ import { createHTTPSAgent } from '../util/createHTTPSAgent';
import { createProxyAgent } from '../util/createProxyAgent';
import type { ProxyAgent } from '../util/createProxyAgent';
import type { FetchFunctionType } from '../util/uploads/tusProtocol';
import type { SocketStatus } from '../types/SocketStatus';
import { VerificationTransport } from '../types/VerificationTransport';
import { toLogFormat } from '../types/errors';
import { isPackIdValid, redactPackId } from '../types/Stickers';
@@ -52,7 +51,7 @@ import { randomInt } from '../Crypto';
import * as linkPreviewFetch from '../linkPreviews/linkPreviewFetch';
import { isBadgeImageFileUrlValid } from '../badges/isBadgeImageFileUrlValid';
import { SocketManager } from './SocketManager';
import { SocketManager, type SocketStatuses } from './SocketManager';
import type { CDSAuthType, CDSResponseType } from './cds/Types.d';
import { CDSI } from './cds/CDSI';
import { SignalService as Proto } from '../protobuf';
@@ -1578,7 +1577,7 @@ export type WebAPIType = {
getConfig: () => Promise<RemoteConfigResponseType>;
authenticate: (credentials: WebAPICredentials) => Promise<void>;
logout: () => Promise<void>;
getSocketStatus: () => SocketStatus;
getSocketStatus: () => SocketStatuses;
registerRequestHandler: (handler: IRequestHandler) => void;
unregisterRequestHandler: (handler: IRequestHandler) => void;
onHasStoriesDisabledChange: (newValue: boolean) => void;
@@ -2106,7 +2105,7 @@ export function initialize({
}
}
function getSocketStatus(): SocketStatus {
function getSocketStatus(): SocketStatuses {
return socketManager.getStatus();
}

View File

@@ -1,6 +1,8 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { TransportOption } from '../textsecure/WebsocketResources';
// Maps to values found here: https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/readyState
// which are returned by libtextsecure's MessageReceiver
export enum SocketStatus {
@@ -9,3 +11,11 @@ export enum SocketStatus {
CLOSING = 'CLOSING',
CLOSED = 'CLOSED',
}
export type SocketInfo = {
status: SocketStatus;
lastConnectionTimestamp?: number;
lastConnectionTransport?:
| TransportOption.Libsignal
| TransportOption.Original;
};

8
ts/window.d.ts vendored
View File

@@ -51,6 +51,7 @@ import type { RetryPlaceholders } from './util/retryPlaceholders';
import type { PropsPreloadType as PreferencesPropsType } from './components/Preferences';
import type { WindowsNotificationData } from './services/notifications';
import type { QueryStatsOptions } from './sql/main';
import type { SocketStatuses } from './textsecure/SocketManager';
export { Long } from 'long';
@@ -146,7 +147,10 @@ export type SignalCoreType = {
calling: CallingClass;
backups: BackupsService;
initializeGroupCredentialFetcher: () => Promise<void>;
initializeNetworkObserver: (network: ReduxActions['network']) => void;
initializeNetworkObserver: (
network: ReduxActions['network'],
getAuthSocketStatus: () => SocketStatus
) => void;
initializeUpdateListener: (updates: ReduxActions['updates']) => void;
retryPlaceholders?: RetryPlaceholders;
lightSessionResetQueue?: PQueue;
@@ -201,7 +205,7 @@ declare global {
getBackupServerPublicParams: () => string;
getSfuUrl: () => string;
getIceServerOverride: () => string;
getSocketStatus: () => SocketStatus;
getSocketStatus: () => SocketStatuses;
getTitle: () => string;
waitForEmptyEventQueue: () => Promise<void>;
getVersion: () => string;

View File

@@ -78,6 +78,7 @@ if (
getSfuUrl: () => window.Signal.Services.calling._sfuUrl,
getIceServerOverride: () =>
window.Signal.Services.calling._iceServerOverride,
getSocketStatus: () => window.textsecure.server?.getSocketStatus(),
getStorageItem: (name: keyof StorageAccessType) => window.storage.get(name),
putStorageItem: <K extends keyof StorageAccessType>(
name: K,