@signalapp/mock-server
Version:
Mock Signal Server for writing tests
404 lines (403 loc) • 13.5 kB
JavaScript
"use strict";
// Copyright 2022 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.StorageState = void 0;
const assert_1 = __importDefault(require("assert"));
const crypto_1 = __importDefault(require("crypto"));
const long_1 = __importDefault(require("long"));
const compiled_1 = require("../../protos/compiled");
const crypto_2 = require("../crypto");
const types_1 = require("../types");
const KEY_SIZE = 16;
const IdentifierType = compiled_1.signalservice.ManifestRecord.Identifier.Type;
class StorageStateItem {
type;
key;
record;
constructor({ type, key, record }) {
this.type = type;
this.key = key;
this.record = record;
}
getKeyString() {
return this.key.toString('base64');
}
toStorageItem({ storageKey, recordIkm, }) {
return (0, crypto_2.encryptStorageItem)({
storageKey,
recordIkm,
key: this.key,
record: this.record,
});
}
toIdentifier() {
return {
type: this.type,
raw: this.key,
};
}
isAccount() {
return this.type === IdentifierType.ACCOUNT && Boolean(this.record.account);
}
isGroup(group) {
if (this.type !== IdentifierType.GROUPV2) {
return false;
}
const masterKey = this.record?.groupV2?.masterKey;
if (!masterKey) {
return false;
}
return group.masterKey.equals(masterKey);
}
isContact(device, serviceIdKind) {
if (this.type !== IdentifierType.CONTACT) {
return false;
}
if (serviceIdKind === types_1.ServiceIdKind.ACI) {
return this.record?.contact?.aci === device.aci;
}
const untaggedPni = this.record?.contact?.pni;
if (!untaggedPni) {
return false;
}
const pni = (0, types_1.tagPni)(untaggedPni);
return pni === device.pni;
}
inspect() {
return [
`type: ${this.type}`,
`key: ${this.key.toString('base64')}`,
...JSON.stringify(this.record, null, 2).split(/\n/g),
]
.map((line) => ` ${line}`)
.join('\n');
}
toRecord() {
return {
type: this.type,
key: this.key,
record: this.record,
};
}
}
class StorageState {
version;
items;
constructor(version, items) {
this.version = version;
this.items = items.map((options) => new StorageStateItem(options));
}
static getEmpty() {
return new StorageState(0, [
new StorageStateItem({
key: StorageState.createStorageID(),
type: IdentifierType.ACCOUNT,
record: {
account: {},
},
}),
]);
}
//
// Account
//
getAccountRecord() {
const item = this.items.find((item) => item.isAccount());
if (!item) {
return undefined;
}
const { account } = item.record;
(0, assert_1.default)(account, 'consistency check');
return account;
}
updateAccount(diff) {
return this.updateItem((item) => item.isAccount(), ({ account }) => ({
account: {
...account,
...diff,
},
}));
}
//
// Group
//
getGroup(group) {
const item = this.items.find((item) => item.isGroup(group));
if (!item) {
return undefined;
}
const { groupV2 } = item.record;
(0, assert_1.default)(groupV2, 'consistency check');
return groupV2;
}
addGroup(group, diff = {}) {
return this.addItem({
type: IdentifierType.GROUPV2,
record: {
groupV2: {
...diff,
masterKey: group.masterKey,
},
},
});
}
updateGroup(group, diff) {
return this.updateItem((item) => item.isGroup(group), ({ groupV2 }) => ({
groupV2: {
...groupV2,
...diff,
},
}));
}
pinGroup(group) {
return this.changeGroupPin(group, true);
}
unpinGroup(group) {
return this.changeGroupPin(group, false);
}
isGroupPinned(group) {
const account = this.getAccountRecord();
(0, assert_1.default)(account, 'No account record found');
return (account.pinnedConversations || []).some((convo) => {
if (!convo.groupMasterKey) {
return false;
}
return group.masterKey.equals(convo.groupMasterKey);
});
}
//
// Contacts
//
addContact({ device }, diff = {}, serviceIdKind = types_1.ServiceIdKind.ACI) {
return this.addItem({
type: IdentifierType.CONTACT,
record: {
contact: {
aci: serviceIdKind === types_1.ServiceIdKind.ACI ? device.aci : undefined,
pni: serviceIdKind === types_1.ServiceIdKind.PNI
? (0, types_1.untagPni)(device.pni)
: undefined,
e164: device.number,
...diff,
},
},
});
}
updateContact({ device }, diff, serviceIdKind = types_1.ServiceIdKind.ACI) {
return this.updateItem((item) => item.isContact(device, serviceIdKind), ({ contact }) => ({
contact: {
...contact,
...diff,
},
}));
}
getContact({ device }, serviceIdKind = types_1.ServiceIdKind.ACI) {
const item = this.items.find((item) => item.isContact(device, serviceIdKind));
if (!item) {
return undefined;
}
const { contact } = item.record;
(0, assert_1.default)(contact, 'consistency check');
return contact;
}
removeContact({ device }, serviceIdKind = types_1.ServiceIdKind.ACI) {
return this.removeItem((item) => item.isContact(device, serviceIdKind));
}
mergeContact(primary, diff) {
const { device } = primary;
return this.removeItem((item) => item.isContact(device, types_1.ServiceIdKind.ACI))
.removeItem((item) => item.isContact(device, types_1.ServiceIdKind.PNI))
.addContact(primary, {
pni: (0, types_1.untagPni)(device.pni),
...diff,
})
.unpin(primary, types_1.ServiceIdKind.PNI);
}
pin(primary, serviceIdKind = types_1.ServiceIdKind.ACI) {
return this.changePin(primary, serviceIdKind, true);
}
unpin(primary, serviceIdKind = types_1.ServiceIdKind.ACI) {
return this.changePin(primary, serviceIdKind, false);
}
isPinned({ device }) {
const account = this.getAccountRecord();
(0, assert_1.default)(account, 'No account record found');
return (account.pinnedConversations || []).some((convo) => {
return convo?.contact?.serviceId === device.aci;
});
}
//
// Raw record access
//
addRecord(newRecord) {
return this.addItem(newRecord);
}
findRecord(find) {
const item = this.items.find((item) => find(item.toRecord()));
return item?.toRecord();
}
hasRecord(find) {
return this.findRecord(find) !== undefined;
}
updateRecord(find, map) {
return this.updateItem((item) => find(item.toRecord()), map);
}
removeRecord(find) {
return this.removeItem((item) => find(item.toRecord()));
}
getAllGroupRecords() {
return this.items
.filter((item) => item.type === IdentifierType.GROUPV2)
.map((item) => item.toRecord());
}
hasKey(storageKey) {
return this.hasRecord(({ key }) => key?.equals(storageKey));
}
//
// General
//
createWriteOperation({ storageKey, recordIkm, previous, }) {
const newVersion = long_1.default.fromNumber(previous ? previous.version + 1 : this.version + 1);
const keysToDelete = new Set((previous?.items ?? []).map((item) => {
return item.getKeyString();
}));
const insertItem = new Array();
for (const item of this.items) {
if (!keysToDelete.delete(item.getKeyString())) {
insertItem.push(item.toStorageItem({ storageKey, recordIkm }));
}
}
const manifest = (0, crypto_2.encryptStorageManifest)(storageKey, {
version: newVersion,
identifiers: this.items.map((item) => item.toIdentifier()),
recordIkm,
});
return {
manifest,
insertItem,
deleteKey: Array.from(keysToDelete).map((key) => {
return Buffer.from(key, 'base64');
}),
};
}
inspect() {
return [
`version: ${this.version}`,
...this.items.map((item) => item.inspect()),
].join('\n');
}
diff(oldState) {
const addedIds = new Map();
const removedIds = new Map();
for (const item of this.items) {
addedIds.set(item.key.toString('base64'), item.record);
}
for (const item of oldState.items) {
const keyString = item.key.toString('base64');
if (!addedIds.delete(keyString)) {
removedIds.set(keyString, item.record);
}
}
return {
added: Array.from(addedIds.values()),
removed: Array.from(removedIds.values()),
};
}
//
// Private
//
addItem(newRecord) {
return this.replaceItem(this.items.length, newRecord);
}
updateItem(find, map) {
const itemIndex = this.items.findIndex(find);
if (itemIndex === -1) {
throw new Error('Item not found');
}
const item = this.items[itemIndex];
(0, assert_1.default)(item, 'consistency check');
return this.replaceItem(itemIndex, {
type: item.type,
record: map(item.record),
});
}
replaceItem(index, { type, record, key = StorageState.createStorageID(), }) {
const newItems = [
...this.items.slice(0, index),
new StorageStateItem({ type, key, record }),
...this.items.slice(index + 1),
];
return new StorageState(this.version, newItems);
}
removeItem(find) {
const itemIndex = this.items.findIndex((item) => find(item));
if (itemIndex === -1) {
throw new Error('Record not found');
}
const newItems = [
...this.items.slice(0, itemIndex),
...this.items.slice(itemIndex + 1),
];
return new StorageState(this.version, newItems);
}
changePin({ device }, serviceIdKind, isPinned) {
const deviceServiceId = device.getServiceIdByKind(serviceIdKind);
return this.updateItem((item) => item.isAccount(), ({ account }) => {
(0, assert_1.default)(account, 'consistency check');
const { pinnedConversations } = account;
const newPinnedConversations = pinnedConversations?.slice() || [];
const existingIndex = newPinnedConversations.findIndex((convo) => {
return convo?.contact?.serviceId === deviceServiceId;
});
if (isPinned && existingIndex === -1) {
newPinnedConversations.push({
contact: { serviceId: deviceServiceId },
});
}
else if (!isPinned && existingIndex !== -1) {
newPinnedConversations.splice(existingIndex, 1);
}
return {
account: {
...account,
pinnedConversations: newPinnedConversations,
},
};
});
}
changeGroupPin(group, isPinned) {
return this.updateItem((item) => item.isAccount(), ({ account }) => {
(0, assert_1.default)(account, 'consistency check');
const { pinnedConversations } = account;
const newPinnedConversations = pinnedConversations?.slice() || [];
const existingIndex = newPinnedConversations.findIndex((convo) => {
if (!convo.groupMasterKey) {
return false;
}
return group.masterKey.equals(convo.groupMasterKey);
});
if (isPinned && existingIndex === -1) {
newPinnedConversations.push({
groupMasterKey: group.masterKey,
});
}
else if (!isPinned && existingIndex !== -1) {
newPinnedConversations.splice(existingIndex, 1);
}
return {
account: {
...account,
pinnedConversations: newPinnedConversations,
},
};
});
}
static createStorageID() {
return crypto_1.default.randomBytes(KEY_SIZE);
}
}
exports.StorageState = StorageState;