@signalapp/mock-server
Version:
Mock Signal Server for writing tests
244 lines (243 loc) • 10.9 kB
JavaScript
;
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.createHandler = void 0;
const assert_1 = __importDefault(require("assert"));
const buffer_1 = require("buffer");
const debug_1 = __importDefault(require("debug"));
const uuid_1 = require("uuid");
const micro_1 = require("micro");
const microrouter_1 = require("microrouter");
const libsignal_client_1 = require("@signalapp/libsignal-client");
const SealedSenderMultiRecipientMessage_1 = __importDefault(require("@signalapp/libsignal-client/dist/SealedSenderMultiRecipientMessage"));
const compiled_1 = require("../../protos/compiled");
const common_1 = require("./common");
const debug = (0, debug_1.default)('mock:grpc');
const ALL_METHODS = [microrouter_1.get, microrouter_1.post, microrouter_1.put, microrouter_1.patch, microrouter_1.del, microrouter_1.head, microrouter_1.options];
function toServiceIdentifier(string) {
const object = libsignal_client_1.ServiceId.parseFromServiceIdString(string);
if (object instanceof libsignal_client_1.Pni) {
return {
identityType: compiled_1.org.signal.chat.common.IdentityType.IDENTITY_TYPE_PNI,
uuid: object.getRawUuidBytes(),
};
}
if (object instanceof libsignal_client_1.Aci) {
return {
identityType: compiled_1.org.signal.chat.common.IdentityType.IDENTITY_TYPE_ACI,
uuid: object.getRawUuidBytes(),
};
}
throw new Error(`Invalid service id: ${string}`);
}
// gRPC status codes used by the mock.
const GRPC_STATUS_OK = 0;
const GRPC_STATUS_UNKNOWN = 2;
// A gRPC response over HTTP/2 always uses HTTP status 200; the actual gRPC
// status code is carried in the trailing HEADERS frame (`grpc-status`). `micro`
// has no notion of trailers, so we emit them via the HTTP/2 compat API. (Driving
// the raw stream directly conflicts with the Http2ServerResponse that `micro`
// holds and throws ERR_HTTP2_TRAILERS_ALREADY_SENT.)
function sendGrpcResponse(res, body, status, message) {
const trailers = {
'grpc-status': String(status),
};
if (message !== undefined) {
// Per the gRPC spec, `grpc-message` is percent-encoded.
trailers['grpc-message'] = encodeURIComponent(message);
}
res.writeHead(200, { 'content-type': 'application/grpc' });
res.addTrailers(trailers);
res.end(body);
}
function grpcRoute(endpoint, handler) {
const definition = compiled_1.$services[endpoint];
// TODO(indutny): enforce on type level
if (definition.isRequestStream || definition.isResponseStream) {
throw new Error(`Request/response stream is not supported`);
}
return (0, microrouter_1.post)(`/${endpoint}`, async (req, res) => {
try {
const raw = await (0, micro_1.buffer)(req);
(0, assert_1.default)(buffer_1.Buffer.isBuffer(raw));
(0, assert_1.default)(raw.buffer instanceof ArrayBuffer);
if (raw.length < 5) {
throw new Error('gRPC request is too short');
}
if (raw[0] !== 0) {
throw new Error('Unsupported request compression');
}
const len = raw.readUint32BE(1);
if (raw.length !== 5 + len) {
throw new Error('Invalid gRPC request size');
}
const request = definition.Request.decode(raw.subarray(5, 5 + len));
const response = await handler(request);
const data = definition.Response.encode(response);
const header = buffer_1.Buffer.alloc(5);
header.writeUint32BE(data.length, 1);
sendGrpcResponse(res, buffer_1.Buffer.concat([header, data]), GRPC_STATUS_OK);
}
catch (error) {
debug('gRPC handler error for %s', endpoint, error);
sendGrpcResponse(res, buffer_1.Buffer.alloc(0), GRPC_STATUS_UNKNOWN, error instanceof Error ? error.message : String(error));
}
});
}
const createHandler = (server) => {
// gRPC
async function onMultiRecipientMessage(request) {
const { message: givenMessage,
// TODO(indutny): check it at all?
// groupSendToken,
} = request;
if (givenMessage == null) {
throw new Error('Missing message');
}
const { timestamp, payload } = givenMessage;
const message = new SealedSenderMultiRecipientMessage_1.default(buffer_1.Buffer.from(payload));
const listByServiceId = new Map();
const recipients = message.recipientsByServiceIdString();
for (const [serviceId, recipient] of Object.entries(recipients)) {
let list = listByServiceId.get(serviceId);
if (!list) {
list = [];
listByServiceId.set(serviceId, list);
}
for (const [i, deviceId] of recipient.deviceIds.entries()) {
const registrationId = recipient.registrationIds.at(i);
list.push({
type: compiled_1.signalservice.Envelope.Type.UNIDENTIFIED_SENDER,
destinationDeviceId: deviceId,
destinationRegistrationId: registrationId,
content: buffer_1.Buffer.from(message.messageForRecipient(recipient)).toString('base64'),
});
}
}
const results = await Promise.all(Array.from(listByServiceId.entries()).map(async ([serviceId, messages]) => {
return {
uuid: serviceId,
prepared: await server.prepareMultiDeviceMessage(undefined, serviceId, messages, timestamp),
};
}));
const mismatchedDevices = results.filter(({ prepared }) => {
return prepared.status === 'incomplete' || prepared.status === 'stale';
});
if (mismatchedDevices.length > 0) {
return {
response: {
mismatchedDevices: {
mismatchedDevices: mismatchedDevices.map(({ uuid, prepared }) => {
if (prepared.status === 'incomplete') {
return {
serviceIdentifier: toServiceIdentifier(uuid),
missingDevices: prepared.missingDevices.slice(),
extraDevices: prepared.extraDevices.slice(),
staleDevices: null,
};
}
assert_1.default.ok(prepared.status === 'stale');
return {
serviceIdentifier: toServiceIdentifier(uuid),
missingDevices: null,
extraDevices: null,
staleDevices: prepared.staleDevices.slice(),
};
}),
},
},
};
}
const uuids404 = results
.filter(({ prepared }) => prepared.status === 'unknown')
.map(({ uuid }) => uuid);
const ok = results.filter(({ prepared }) => prepared.status === 'ok');
await Promise.all(ok.map(({ prepared }) => {
assert_1.default.ok(prepared.status === 'ok');
return server.handlePreparedMultiDeviceMessage(undefined, prepared.targetServiceId, prepared.result);
}));
return {
response: {
success: {
unresolvedRecipients: uuids404.map(toServiceIdentifier),
},
},
};
}
const onSendMultiRecipientMessage = grpcRoute('org.signal.chat.messages.MessagesAnonymous/SendMultiRecipientMessage', onMultiRecipientMessage);
const onSendMultiRecipientStory = grpcRoute('org.signal.chat.messages.MessagesAnonymous/SendMultiRecipientStory', onMultiRecipientMessage);
const onLookupUsernameHash = grpcRoute('org.signal.chat.account.AccountsAnonymous/LookupUsernameHash', async ({ usernameHash }) => {
const uuid = await server.lookupByUsernameHash(buffer_1.Buffer.from(usernameHash));
if (!uuid) {
return {
response: {
notFound: {},
},
};
}
return {
response: {
serviceIdentifier: toServiceIdentifier(uuid),
},
};
});
const onLookupUsernameLink = grpcRoute('org.signal.chat.account.AccountsAnonymous/LookupUsernameLink', async ({ usernameLinkHandle }) => {
const usernameCiphertext = await server.lookupByUsernameLink((0, uuid_1.stringify)(usernameLinkHandle));
if (!usernameCiphertext) {
return {
response: {
notFound: {},
},
};
}
return {
response: {
usernameCiphertext,
},
};
});
const onGetUploadForm = grpcRoute('org.signal.chat.attachments.Attachments/GetUploadForm', async () => {
const { cdn, key, headers, signedUploadLocation } = await server.getAttachmentUploadForm('attachments', (0, uuid_1.v4)());
return {
outcome: {
uploadForm: {
cdn,
key,
headers: new Map(Object.entries(headers)),
signedUploadLocation,
},
},
};
});
const notFoundAfterAuth = async (req, res) => {
const device = await (0, common_1.auth)(server, req, res);
if (!device) {
return;
}
debug('Unsupported request %s %s', req.method, req.url);
return (0, micro_1.send)(res, 404, { error: 'Not supported yet' });
};
const routes = (0, microrouter_1.router)(
// gRPC
onSendMultiRecipientMessage, onSendMultiRecipientStory, onLookupUsernameHash, onLookupUsernameLink, onGetUploadForm, ...ALL_METHODS.map((method) => method('/*', notFoundAfterAuth)));
return (req, res) => {
debug('got request %s %s', req.method, req.url);
try {
res.once('finish', () => {
debug('response %s %s', req.method, req.url, res.statusCode);
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return routes(req, res);
}
catch (error) {
(0, assert_1.default)(error instanceof Error);
debug('request failure %s %s', req.method, req.url, error.stack);
return (0, micro_1.send)(res, 500, error.message);
}
};
};
exports.createHandler = createHandler;