@signalapp/mock-server
Version:
Mock Signal Server for writing tests
562 lines (561 loc) • 19.1 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 node_buffer_1 = require("node:buffer");
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: {
record: this.record,
},
});
}
toIdentifier() {
return {
type: this.type,
raw: this.key,
};
}
isAccount() {
return this.type === IdentifierType.ACCOUNT && this.record.account != null;
}
isGroup(group) {
if (this.type !== IdentifierType.GROUPV2) {
return false;
}
(0, assert_1.default)(this.record.groupV2 != null, 'consistency check');
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;
}
(0, assert_1.default)(this.record.contact != null, 'consistency check');
if (serviceIdKind === types_1.ServiceIdKind.ACI) {
const existingAci = this.record.contact.aciBinary;
if (!existingAci?.length) {
return false;
}
return node_buffer_1.Buffer.compare(existingAci, device.aciRawUuid) === 0;
}
const existingPni = this.record.contact.pniBinary;
if (!existingPni?.length) {
return false;
}
return node_buffer_1.Buffer.compare(existingPni, device.pniRawUuid) === 0;
}
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,
};
}
}
const EMPTY_CONTACT = {
e164: null,
profileKey: null,
identityKey: null,
identityState: null,
givenName: null,
familyName: null,
username: null,
blocked: null,
whitelisted: null,
archived: null,
markedUnread: null,
mutedUntilTimestamp: null,
hideStory: null,
unregisteredAtTimestamp: null,
systemGivenName: null,
systemFamilyName: null,
systemNickname: null,
hidden: null,
pniSignatureVerified: null,
nickname: null,
note: null,
avatarColor: null,
aciBinary: null,
pniBinary: null,
};
const EMPTY_GROUP = {
masterKey: null,
blocked: null,
whitelisted: null,
archived: null,
markedUnread: null,
mutedUntilTimestamp: null,
dontNotifyForMentionsIfMuted: null,
hideStory: null,
storySendMode: null,
avatarColor: null,
};
class StorageState {
version;
items;
constructor(version, items) {
this.version = version;
this.items = items.map((options) => new StorageStateItem(options));
}
static getEmpty() {
return new StorageState(0n, [
new StorageStateItem({
key: StorageState.createStorageID(),
type: IdentifierType.ACCOUNT,
record: {
record: 'account',
account: {
profileKey: null,
givenName: null,
familyName: null,
avatarUrlPath: null,
noteToSelfArchived: null,
readReceipts: null,
sealedSenderIndicators: null,
typingIndicators: null,
noteToSelfMarkedUnread: null,
linkPreviews: null,
phoneNumberSharingMode: null,
unlistedPhoneNumber: null,
pinnedConversations: null,
preferContactAvatars: null,
payments: null,
universalExpireTimer: null,
preferredReactionEmoji: null,
donorSubscriberId: null,
donorSubscriberCurrencyCode: null,
displayBadgesOnProfile: null,
donorSubscriptionManuallyCancelled: null,
keepMutedChatsArchived: null,
hasSetMyStoriesPrivacy: null,
hasViewedOnboardingStory: null,
storiesDisabled: null,
storyViewReceiptsEnabled: null,
hasSeenGroupStoryEducationSheet: null,
username: null,
hasCompletedUsernameOnboarding: null,
usernameLink: null,
hasBackup: null,
backupTier: null,
backupSubscriberData: null,
avatarColor: null,
notificationProfileManualOverride: null,
notificationProfileSyncDisabled: null,
},
},
}),
]);
}
//
// Account
//
getAccountRecord() {
const item = this.items.find((item) => item.isAccount());
if (!item) {
return undefined;
}
return item.record.account;
}
updateAccount(diff) {
return this.updateItem((item) => item.isAccount(), (record) => {
return {
record: 'account',
account: {
...record.account,
...diff,
},
};
});
}
updateManyAccounts(diff) {
return this.updateManyItems((item) => item.isAccount(), (record) => {
return {
record: 'account',
account: {
...record.account,
...diff,
},
};
});
}
//
// Group
//
getGroup(group) {
const item = this.items.find((item) => item.isGroup(group));
if (!item) {
return undefined;
}
return item.record.groupV2;
}
addGroup(group, diff = {}) {
return this.addItem({
type: IdentifierType.GROUPV2,
record: {
groupV2: {
...EMPTY_GROUP,
...diff,
masterKey: group.masterKey,
},
},
});
}
updateGroup(group, diff) {
return this.updateItem((item) => item.isGroup(group), (record) => {
return {
groupV2: {
...record.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.identifier?.groupMasterKey == null) {
return false;
}
return group.masterKey.equals(convo.identifier.groupMasterKey);
});
}
//
// Contacts
//
addContact({ device }, diff = {}, serviceIdKind = types_1.ServiceIdKind.ACI) {
return this.addItem({
type: IdentifierType.CONTACT,
record: {
contact: {
...EMPTY_CONTACT,
aciBinary: serviceIdKind === types_1.ServiceIdKind.ACI ? device.aciRawUuid : null,
pniBinary: serviceIdKind === types_1.ServiceIdKind.PNI ? device.pniRawUuid : null,
e164: device.number,
...diff,
},
},
});
}
updateContact({ device }, diff, serviceIdKind = types_1.ServiceIdKind.ACI) {
return this.updateItem((item) => item.isContact(device, serviceIdKind), (record) => {
return {
record: 'contact',
contact: {
...record.contact,
...diff,
},
};
});
}
getContact({ device }, serviceIdKind = types_1.ServiceIdKind.ACI) {
const item = this.items.find((item) => item.isContact(device, serviceIdKind));
if (!item) {
return undefined;
}
return item.record.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, {
pniBinary: device.pniRawUuid,
...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) => {
if (convo.identifier?.contact == null) {
return false;
}
const existing = convo.identifier.contact.serviceIdBinary;
return existing && node_buffer_1.Buffer.compare(existing, device.aciRawUuid) === 0;
});
}
//
// Raw record access
//
addRecord(newRecord) {
return this.addItem(newRecord);
}
findRecord(find) {
const item = this.items.find((item) => {
return find(item.toRecord());
});
return item?.toRecord();
}
filterRecords(filter) {
return this.items.filter((item) => filter(item.toRecord()));
}
hasRecord(find) {
return (this.findRecord(find) !== undefined);
}
updateRecord(find, map) {
return this.updateItem((item) => find(item.toRecord()), map);
}
updateManyRecords(filter, map) {
return this.updateManyItems((item) => filter(item.toRecord()), map);
}
removeRecord(find) {
return this.removeItem((item) => find(item.toRecord()));
}
removeManyRecords(filter) {
return this.removeManyItems((item) => filter(item.toRecord()));
}
getAllGroupRecords() {
return this.items
.filter((item) => item.type === IdentifierType.GROUPV2)
.map((item) => item.toRecord());
}
hasKey(storageKey) {
return this.hasRecord((item) => item.key.equals(storageKey));
}
//
// General
//
createWriteOperation({ storageKey, recordIkm, previous, }) {
const newVersion = previous ? previous.version + 1n : this.version + 1n;
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: recordIkm ?? null,
sourceDevice: null,
});
return {
manifest,
insertItem,
deleteKey: Array.from(keysToDelete).map((key) => {
return node_buffer_1.Buffer.from(key, 'base64');
}),
clearAll: null,
};
}
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);
}
findItemIndex(find) {
const itemIndex = this.items.findIndex(find);
if (itemIndex === -1) {
throw new Error('Item not found');
}
const otherIndex = this.items.findLastIndex(find);
if (otherIndex !== itemIndex) {
throw new Error('Found multiple items');
}
return itemIndex;
}
updateItem(find, map) {
const itemIndex = this.findItemIndex(find);
const item = this.items[itemIndex];
(0, assert_1.default)(item, 'consistency check');
return this.replaceItem(itemIndex, {
type: item.type,
record: map(item.record),
});
}
updateManyItems(filter, map) {
let updated = 0;
const newItems = this.items.map((item, index) => {
if (filter(item, index)) {
updated += 1;
return new StorageStateItem({
type: item.type,
key: StorageState.createStorageID(),
record: map(item.record),
});
}
else {
return item;
}
});
if (updated === 0) {
throw new Error('No items updated');
}
return new StorageState(this.version, newItems);
}
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.findItemIndex(find);
const newItems = [
...this.items.slice(0, itemIndex),
...this.items.slice(itemIndex + 1),
];
return new StorageState(this.version, newItems);
}
removeManyItems(filter) {
const newItems = this.items.filter((item, index) => {
return !filter(item, index);
});
if (newItems.length === this.items.length) {
throw new Error('No items removed');
}
return new StorageState(this.version, newItems);
}
changePin({ device }, serviceIdKind, isPinned) {
const deviceServiceIdBinary = device.getServiceIdBinaryByKind(serviceIdKind);
return this.updateItem((item) => item.isAccount(), (record) => {
const { account } = record;
const { pinnedConversations } = account;
const newPinnedConversations = pinnedConversations?.slice() ?? [];
const existingIndex = newPinnedConversations.findIndex((convo) => {
if (convo.identifier?.contact == null) {
return false;
}
const existing = convo.identifier.contact.serviceIdBinary;
return (existing && node_buffer_1.Buffer.compare(existing, deviceServiceIdBinary) === 0);
});
if (isPinned && existingIndex === -1) {
newPinnedConversations.push({
identifier: {
contact: {
e164: null,
serviceIdBinary: deviceServiceIdBinary,
},
},
});
}
else if (!isPinned && existingIndex !== -1) {
newPinnedConversations.splice(existingIndex, 1);
}
return {
account: {
...account,
pinnedConversations: newPinnedConversations,
},
};
});
}
changeGroupPin(group, isPinned) {
return this.updateItem((item) => item.isAccount(), (record) => {
const { account } = record;
const { pinnedConversations } = account;
const newPinnedConversations = pinnedConversations?.slice() ?? [];
const existingIndex = newPinnedConversations.findIndex((convo) => {
if (convo.identifier?.groupMasterKey == null) {
return false;
}
return group.masterKey.equals(convo.identifier.groupMasterKey);
});
if (isPinned && existingIndex === -1) {
newPinnedConversations.push({
identifier: {
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;