Implement PATCH requests and JSON Patch for resources

We implement three patch operation types (replace for values and One
and ZeroOrOne associations, additem and removeitem for Any associations)
on attributes to do PATCH requests instead of doing PUT requests to
minimize the request payload by sending only the changed fields. The
payload format is specified in the fast-sync design documentation.

Co-authored-by: das <das@tutao.de>
Co-authored-by: jomapp <17314077+jomapp@users.noreply.github.com>
Co-authored-by: abp <abp@tutao.de>
This commit is contained in:
abp 2025-06-03 12:08:18 +02:00
parent 60313fba09
commit ccc474c0db
No known key found for this signature in database
GPG key ID: 791D4EC38A7AA7C2
17 changed files with 1064 additions and 76 deletions

View file

@ -519,6 +519,7 @@ export function assembleEditResultAndAssignFromExisting(existingEvent: CalendarE
newEvent._id = existingEvent._id
newEvent._ownerGroup = existingEvent._ownerGroup
newEvent._permissions = existingEvent._permissions
newEvent._original = existingEvent._original
return {
hasUpdateWorthyChanges: eventHasChanged(newEvent, existingEvent),

View file

@ -26,9 +26,16 @@ export const enum HttpMethod {
GET = "GET",
POST = "POST",
PUT = "PUT",
PATCH = "PATCH",
DELETE = "DELETE",
}
export const enum PatchOperationType {
ADD_ITEM = "0",
REMOVE_ITEM = "1",
REPLACE = "2",
}
export const enum MediaType {
Json = "application/json",
Binary = "application/octet-stream",

View file

@ -229,6 +229,7 @@ export type ServerModelUntypedInstance = Distinct<UntypedInstance, ServerModelTy
export interface Entity {
/** the address of the TypeModel this entity conforms to. */
_type: TypeRef<this>
_original?: this
_finalIvs?: Record<number, Nullable<Uint8Array>>
bucketKey?: null | BucketKey
_ownerGroup?: null | Id

View file

@ -81,6 +81,7 @@ export class MessageDispatcher<OutgoingRequestType extends string, IncomingReque
postRequest(msg: Request<OutgoingRequestType>): Promise<any> {
msg.id = this.nextId()
return newPromise(
(resolve, reject) => {
this._messages[msg.id!] = {

View file

@ -1,4 +1,6 @@
import {
arrayEquals,
assertNotNull,
base64ExtToBase64,
base64ToBase64Ext,
base64ToBase64Url,
@ -6,8 +8,10 @@ import {
base64UrlToBase64,
clone,
compare,
deepEqual,
Hex,
hexToBase64,
isEmpty,
isSameTypeRef,
pad,
repeat,
@ -16,8 +20,24 @@ import {
uint8ArrayToBase64,
utf8Uint8ArrayToString,
} from "@tutao/tutanota-utils"
import { Cardinality, ValueType } from "../EntityConstants.js"
import { ElementEntity, Entity, ModelValue, SomeEntity, TypeModel } from "../EntityTypes"
import { AssociationType, Cardinality, ValueType } from "../EntityConstants.js"
import {
ClientModelEncryptedParsedInstance,
ClientModelParsedInstance,
ClientModelUntypedInstance,
ElementEntity,
Entity,
ModelValue,
ParsedValue,
SomeEntity,
TypeModel,
UntypedValue,
} from "../EntityTypes"
import { ClientTypeReferenceResolver, PatchOperationType } from "../EntityFunctions"
import { Nullable } from "@tutao/tutanota-utils/dist/Utils"
import { AttributeModel } from "../AttributeModel"
import { createPatch, createPatchList, Patch, PatchList } from "../../entities/sys/TypeRefs"
import { instance } from "testdouble"
/**
* the maximum ID for elements stored on the server (number with the length of 10 bytes) => 2^80 - 1
@ -61,7 +81,7 @@ export const DELETE_MULTIPLE_LIMIT = 100
*/
export type Stripped<T extends Partial<SomeEntity>> = Omit<
T,
"_id" | "_area" | "_owner" | "_ownerGroup" | "_ownerEncSessionKey" | "_ownerKeyVersion" | "_permissions" | "_errors" | "_format" | "_type"
"_id" | "_area" | "_owner" | "_ownerGroup" | "_ownerEncSessionKey" | "_ownerKeyVersion" | "_permissions" | "_errors" | "_format" | "_type" | "_original"
>
type OptionalEntity<T extends Entity> = T & {
@ -70,7 +90,20 @@ type OptionalEntity<T extends Entity> = T & {
}
export type StrippedEntity<T extends Entity> =
| Omit<T, "_id" | "_ownerGroup" | "_ownerEncSessionKey" | "_ownerKeyVersion" | "_permissions" | "_errors" | "_format" | "_type" | "_area" | "_owner">
| Omit<
T,
| "_id"
| "_ownerGroup"
| "_ownerEncSessionKey"
| "_ownerKeyVersion"
| "_permissions"
| "_errors"
| "_format"
| "_type"
| "_area"
| "_owner"
| "_original"
>
| OptionalEntity<T>
/**
@ -266,6 +299,247 @@ export function create<T>(typeModel: TypeModel, typeRef: TypeRef<T>, createDefau
return i as T
}
// visible for testing
export function areValuesDifferent(
valueType: Values<typeof ValueType>,
originalParsedValue: Nullable<ParsedValue>,
currentParsedValue: Nullable<ParsedValue>,
): boolean {
if (originalParsedValue === null && currentParsedValue === null) {
return false
}
const valueChangedToOrFromNull =
(originalParsedValue === null && currentParsedValue !== null) || (currentParsedValue === null && originalParsedValue !== null)
if (valueChangedToOrFromNull) {
return true
}
switch (valueType) {
case ValueType.Bytes:
return !arrayEquals(originalParsedValue as Uint8Array, currentParsedValue as Uint8Array)
case ValueType.Date:
return originalParsedValue?.valueOf() !== currentParsedValue?.valueOf()
case ValueType.Number:
case ValueType.String:
case ValueType.Boolean:
case ValueType.CompressedString:
return originalParsedValue !== currentParsedValue
case ValueType.CustomId:
case ValueType.GeneratedId:
if (typeof originalParsedValue === "string") {
return !isSameId(originalParsedValue as Id, currentParsedValue as Id)
} else if (typeof originalParsedValue === "object") {
return !isSameId(originalParsedValue as IdTuple, currentParsedValue as IdTuple)
}
}
return false
}
export async function computePatchPayload(
originalInstance: ClientModelParsedInstance | ClientModelEncryptedParsedInstance,
currentInstance: ClientModelParsedInstance | ClientModelEncryptedParsedInstance,
currentUntypedInstance: ClientModelUntypedInstance,
typeModel: TypeModel,
typeReferenceResolver: ClientTypeReferenceResolver,
): Promise<PatchList> {
const patches = await computePatches(originalInstance, currentInstance, currentUntypedInstance, typeModel, typeReferenceResolver)
return createPatchList({ patches: patches })
}
// visible for testing
export async function computePatches(
originalInstance: ClientModelParsedInstance | ClientModelEncryptedParsedInstance,
modifiedInstance: ClientModelParsedInstance | ClientModelEncryptedParsedInstance,
modifiedUntypedInstance: ClientModelUntypedInstance,
typeModel: TypeModel,
typeReferenceResolver: ClientTypeReferenceResolver,
): Promise<Patch[]> {
modifiedUntypedInstance = AttributeModel.removeNetworkDebuggingInfoIfNeeded(modifiedUntypedInstance)
let patches: Patch[] = []
for (const [valueIdStr, modelValue] of Object.entries(typeModel.values)) {
if (modelValue.final && !(modelValue.name == "_ownerEncSessionKey" || modelValue.name == "_ownerKeyVersion")) {
continue
}
const attributeId = parseInt(valueIdStr)
let originalParsedValue = originalInstance[attributeId] as Nullable<ParsedValue>
let modifiedParsedValue = modifiedInstance[attributeId] as Nullable<ParsedValue>
let modifiedUntypedValue = modifiedUntypedInstance[attributeId] as UntypedValue
if (areValuesDifferent(modelValue.type, originalParsedValue, modifiedParsedValue)) {
let value = null
if (modifiedUntypedValue !== null) {
value = typeof modifiedUntypedValue === "object" ? JSON.stringify(modifiedUntypedValue) : modifiedUntypedValue
}
patches.push(
createPatch({
attributePath: valueIdStr,
value: value,
patchOperation: PatchOperationType.REPLACE,
}),
)
}
}
for (const [associationIdStr, modelAssociation] of Object.entries(typeModel.associations)) {
if (modelAssociation.final) {
continue
}
const attributeId = parseInt(associationIdStr)
if (modelAssociation.type == AssociationType.Aggregation) {
const appName = modelAssociation.dependency ?? typeModel.app
const typeId = modelAssociation.refTypeId
const aggregateTypeModel = await typeReferenceResolver(new TypeRef(appName, typeId))
const originalAggregatedEntities = (originalInstance[attributeId] ?? []) as Array<ClientModelParsedInstance>
const modifiedAggregatedEntities = (modifiedInstance[attributeId] ?? []) as Array<ClientModelParsedInstance>
const modifiedAggregatedUntypedEntities = (modifiedUntypedInstance[attributeId] ?? []) as Array<ClientModelUntypedInstance>
const addedItems = modifiedAggregatedUntypedEntities.filter(
(element) =>
!originalAggregatedEntities.some((item) => {
const aggregateIdAttributeId = assertNotNull(AttributeModel.getAttributeId(aggregateTypeModel, "_id"))
return isSameId(item[aggregateIdAttributeId] as Id, element[aggregateIdAttributeId] as Id)
}),
)
const removedItems = originalAggregatedEntities.filter(
(element) =>
!modifiedAggregatedEntities.some((item) => {
const aggregateIdAttributeId = assertNotNull(AttributeModel.getAttributeId(aggregateTypeModel, "_id"))
return isSameId(item[aggregateIdAttributeId] as Id, element[aggregateIdAttributeId] as Id)
}),
)
const commonItems = originalAggregatedEntities.filter(
(element) =>
!removedItems.some((item) => {
const aggregateIdAttributeId = assertNotNull(AttributeModel.getAttributeId(aggregateTypeModel, "_id"))
return isSameId(item[aggregateIdAttributeId] as Id, element[aggregateIdAttributeId] as Id)
}),
)
const commonAggregateIds = commonItems.map((instance) => instance[assertNotNull(AttributeModel.getAttributeId(aggregateTypeModel, "_id"))] as Id)
for (let commonAggregateId of commonAggregateIds) {
const commonItemOriginal = assertNotNull(
originalAggregatedEntities.find((instance) => {
const aggregateIdAttributeId = assertNotNull(AttributeModel.getAttributeId(aggregateTypeModel, "_id"))
return isSameId(instance[aggregateIdAttributeId] as Id, commonAggregateId)
}),
)
const commonItemModified = assertNotNull(
modifiedAggregatedEntities.find((instance) => {
const aggregateIdAttributeId = assertNotNull(AttributeModel.getAttributeId(aggregateTypeModel, "_id"))
return isSameId(instance[aggregateIdAttributeId] as Id, commonAggregateId)
}),
)
const commonItemModifiedUntyped = assertNotNull(
modifiedAggregatedUntypedEntities.find((instance) => {
const aggregateIdAttributeId = assertNotNull(AttributeModel.getAttributeId(aggregateTypeModel, "_id"))
return isSameId(instance[aggregateIdAttributeId] as Id, commonAggregateId)
}),
)
const fullPath = `${attributeId}/${commonAggregateId}/`
const items = await computePatches(
commonItemOriginal,
commonItemModified,
commonItemModifiedUntyped,
aggregateTypeModel,
typeReferenceResolver,
)
items.map((item) => {
item.attributePath = fullPath + item.attributePath
})
patches = patches.concat(items)
}
if (modelAssociation.cardinality == Cardinality.Any) {
if (removedItems.length > 0) {
const removedAggregateIds = removedItems.map(
(instance) => instance[assertNotNull(AttributeModel.getAttributeId(aggregateTypeModel, "_id"))] as Id,
)
patches.push(
createPatch({
attributePath: attributeId.toString(),
value: JSON.stringify(removedAggregateIds),
patchOperation: PatchOperationType.REMOVE_ITEM,
}),
)
}
if (addedItems.length > 0) {
patches.push(
createPatch({
attributePath: attributeIdStr,
value: JSON.stringify(addedItems),
patchOperation: PatchOperationType.ADD_ITEM,
}),
)
}
} else if (isEmpty(originalAggregatedEntities)) {
// ZeroOrOne with original aggregation on server is []
patches.push(
createPatch({
attributePath: attributeId.toString(),
value: JSON.stringify(modifiedAggregatedUntypedEntities),
patchOperation: PatchOperationType.ADD_ITEM,
}),
)
} else {
// ZeroOrOne or One with original aggregation on server already there (i.e. it is a list of one)
const aggregateId = AttributeModel.getAttribute(assertNotNull(originalAggregatedEntities[0]), "_id", aggregateTypeModel)
const fullPath = `${attributeIdStr}/${aggregateId}/`
const items = await computePatches(
originalAggregatedEntities[0],
modifiedAggregatedEntities[0],
modifiedAggregatedUntypedEntities[0],
aggregateTypeModel,
typeReferenceResolver,
isNetworkDebuggingEnabled,
)
items.map((item) => {
item.attributePath = fullPath + item.attributePath
})
patches = patches.concat(items)
}
} else {
// non aggregation associations
const originalAssociationValue = (originalInstance[attributeId] ?? []) as Array<Id | IdTuple>
const modifiedAssociationValue = (modifiedInstance[attributeId] ?? []) as Array<Id | IdTuple>
const addedItems = modifiedAssociationValue.filter((element) => !originalAssociationValue.some((item) => isSameId(item, element)))
const removedItems = originalAssociationValue.filter((element) => !modifiedAssociationValue.some((item) => isSameId(item, element)))
// Only Any associations support ADD_ITEM and REMOVE_ITEM operations
// All cardinalities support REPLACE operation
if (modelAssociation.cardinality == Cardinality.Any) {
if (removedItems.length > 0) {
patches.push(
createPatch({
attributePath: attributeId.toString(),
value: JSON.stringify(removedItems),
patchOperation: PatchOperationType.REMOVE_ITEM,
}),
)
}
if (addedItems.length > 0) {
patches.push(
createPatch({
attributePath: attributeIdStr,
value: JSON.stringify(addedItems),
patchOperation: PatchOperationType.ADD_ITEM,
}),
)
}
} else if (!deepEqual(originalAssociationValue, modifiedAssociationValue)) {
patches.push(
createPatch({
attributePath: attributeId.toString(),
value: JSON.stringify(modifiedAssociationValue),
patchOperation: PatchOperationType.REPLACE,
}),
)
}
}
}
return patches
}
function _getDefaultValue(valueName: string, value: ModelValue): any {
if (valueName === "_format") {
return "0"

View file

@ -26,7 +26,7 @@ import {
SYSTEM_GROUP_MAIL_ADDRESS,
} from "../../common/TutanotaConstants"
import { HttpMethod, TypeModelResolver } from "../../common/EntityFunctions"
import type { BucketPermission, GroupMembership, InstanceSessionKey, Permission } from "../../entities/sys/TypeRefs.js"
import { BucketPermission, GroupMembership, InstanceSessionKey, PatchListTypeRef, Permission } from "../../entities/sys/TypeRefs.js"
import {
BucketPermissionTypeRef,
createInstanceSessionKey,
@ -73,7 +73,7 @@ import { IServiceExecutor } from "../../common/ServiceRequest"
import { EncryptTutanotaPropertiesService } from "../../entities/tutanota/Services"
import { UpdatePermissionKeyService } from "../../entities/sys/Services"
import { UserFacade } from "../facades/UserFacade"
import { elementIdPart, getElementId, getListId } from "../../common/utils/EntityUtils.js"
import { computePatchPayload, elementIdPart, getElementId, getListId } from "../../common/utils/EntityUtils.js"
import { OwnerEncSessionKeysUpdateQueue } from "./OwnerEncSessionKeysUpdateQueue.js"
import { DefaultEntityRestCache } from "../rest/DefaultEntityRestCache.js"
import { CryptoError } from "@tutao/tutanota-crypto/error.js"
@ -774,6 +774,7 @@ export class CryptoFacade {
private async updateOwnerEncSessionKey(instance: EntityAdapter, ownerGroupKey: VersionedKey, resolvedSessionKey: AesKey) {
const newOwnerEncSessionKey = encryptKeyWithVersionedKey(ownerGroupKey, resolvedSessionKey)
const oldInstance = structuredClone(instance)
this.setOwnerEncSessionKey(instance, newOwnerEncSessionKey)
const id = instance._id
@ -787,10 +788,20 @@ export class CryptoFacade {
instance.encryptedParsedInstance as ClientModelEncryptedParsedInstance,
)
const patchList = await computePatchPayload(
oldInstance.encryptedParsedInstance as ClientModelEncryptedParsedInstance,
instance.encryptedParsedInstance as ClientModelEncryptedParsedInstance,
untypedInstance,
instance.typeModel,
this.typeModelResolver.resolveClientTypeReference.bind(this.typeModelResolver),
)
const patchPayload = await this.instancePipeline.mapAndEncrypt(PatchListTypeRef, patchList, null)
await this.restClient
.request(path, HttpMethod.PUT, {
.request(path, HttpMethod.PATCH, {
headers,
body: JSON.stringify(untypedInstance),
body: JSON.stringify(patchPayload),
queryParams: { updateOwnerEncSessionKey: "true" },
})
.catch(

View file

@ -12,7 +12,7 @@ import {
import { AssociationType, Cardinality, Type, ValueType } from "../../common/EntityConstants.js"
import { compress, uncompress } from "../Compression"
import { ClientModelParsedInstance, Entity, ModelAssociation, ParsedAssociation, ParsedValue, ServerModelParsedInstance } from "../../common/EntityTypes"
import { assertWorkerOrNode, isWebClient } from "../../common/Env"
import { assertWorkerOrNode, isTest, isWebClient } from "../../common/Env"
import { Nullable } from "@tutao/tutanota-utils/dist/Utils"
import { ClientTypeReferenceResolver, ServerTypeReferenceResolver } from "../../common/EntityFunctions"
import { random } from "@tutao/tutanota-crypto"
@ -219,6 +219,10 @@ export class ModelMapper {
}
}
// _original is used for PATCH requests, and is not required for most tests
if (!isTest()) {
clientInstance._original = structuredClone(clientInstance)
}
return clientInstance as T
}

View file

@ -25,7 +25,7 @@ import type {
TypeModel,
UntypedInstance,
} from "../../common/EntityTypes"
import { elementIdPart, LOAD_MULTIPLE_LIMIT, POST_MULTIPLE_LIMIT } from "../../common/utils/EntityUtils"
import { computePatchPayload, elementIdPart, LOAD_MULTIPLE_LIMIT, POST_MULTIPLE_LIMIT } from "../../common/utils/EntityUtils"
import { Type } from "../../common/EntityConstants.js"
import { SetupMultipleError } from "../../common/error/SetupMultipleError"
import { AuthDataProvider } from "../facades/UserFacade"
@ -41,6 +41,7 @@ import { EntityAdapter } from "../crypto/EntityAdapter"
import { AttributeModel } from "../../common/AttributeModel"
import { PersistenceResourcePostReturnTypeRef } from "../../entities/base/TypeRefs"
import { EntityUpdateData } from "../../common/utils/EntityUpdateUtils"
import { createPatchList, PatchListTypeRef } from "../../entities/sys/TypeRefs"
import { parseKeyVersion } from "../facades/KeyLoaderFacade.js"
import { expandId } from "./RestClientIdUtils"
@ -594,12 +595,21 @@ export class EntityRestClient implements EntityRestInterface {
options?.ownerKeyProvider,
)
const sessionKey = await this.resolveSessionKey(options?.ownerKeyProvider, instance)
// map and encrypt instance._original and the instance
const originalParsedInstance = await this.instancePipeline.modelMapper.mapToClientModelParsedInstance(instance._type, assertNotNull(instance._original))
const parsedInstance = await this.instancePipeline.modelMapper.mapToClientModelParsedInstance(instance._type as TypeRef<any>, instance)
const typeModel = await this.typeModelResolver.resolveClientTypeReference(instance._type)
const typeReferenceResolver = this.typeModelResolver.resolveClientTypeReference.bind(this.typeModelResolver)
const untypedInstance = await this.instancePipeline.mapAndEncrypt(downcast(instance._type), instance, sessionKey)
await this.restClient.request(path, HttpMethod.PUT, {
// figure out differing fields and build the PATCH request payload
const patchList = await computePatchPayload(originalParsedInstance, parsedInstance, untypedInstance, typeModel, typeReferenceResolver)
// PatchList has no encrypted fields (sk == null)
const patchPayload = await this.instancePipeline.mapAndEncrypt(PatchListTypeRef, patchList, null)
await this.restClient.request(path, HttpMethod.PATCH, {
baseUrl: options?.baseUrl,
queryParams,
headers,
body: JSON.stringify(untypedInstance),
body: JSON.stringify(patchPayload),
responseType: MediaType.Json,
})
}

View file

@ -118,6 +118,8 @@ export class ServiceExecutor implements IServiceExecutor {
return (service as PutService)["put"]
case HttpMethod.DELETE:
return (service as DeleteService)["delete"]
case HttpMethod.PATCH:
throw new ProgrammingError("Services do not implement PATCH for now")
}
}

View file

@ -15,6 +15,7 @@ import { type fetch as undiciFetch, type Response } from "undici"
import { Cardinality, ValueType } from "../../src/common/api/common/EntityConstants.js"
import { InstancePipeline } from "../../src/common/api/worker/crypto/InstancePipeline"
import { ModelMapper } from "../../src/common/api/worker/crypto/ModelMapper"
import { dummyResolver } from "./api/worker/crypto/InstancePipelineTestUtils"
import { EncryptedDbWrapper } from "../../src/common/api/worker/search/EncryptedDbWrapper"
export const browserDataStub: BrowserData = {
@ -227,6 +228,16 @@ export function createTestEntity<T extends Entity>(
}
}
export async function createTestEntityWithDummyResolver<T extends Entity>(typeRef: TypeRef<T>, values?: Partial<T>): Promise<T> {
const typeModel = await dummyResolver(typeRef)
const entity = create(typeModel, typeRef, getDefaultTestValue)
if (values) {
return Object.assign(entity, values)
} else {
return entity
}
}
export function mockFetchRequest(mock: typeof undiciFetch, url: string, headers: Record<string, string>, status: number, jsonObject: unknown): Promise<void> {
const response = object<Writeable<Response>>()
response.ok = status >= 200 && status < 300
@ -294,6 +305,7 @@ export function removeAggregateIds(instance: Entity, aggregate: boolean = false)
}
return instance
}
export function clientModelAsServerModel(clientModel: ClientModelInfo): ServerModelInfo {
let models = Object.keys(clientModel.typeModels).reduce((obj, app) => {
Object.assign(obj, {

View file

@ -1,8 +1,11 @@
import o from "@tutao/otest"
import {
areValuesDifferent,
computePatches,
constructMailSetEntryId,
create,
deconstructMailSetEntryId,
GENERATED_MAX_ID,
GENERATED_MIN_ID,
generatedIdToTimestamp,
removeTechnicalFields,
@ -12,9 +15,28 @@ import {
import { MailTypeRef } from "../../../../../src/common/api/entities/tutanota/TypeRefs.js"
import { typeModels } from "../../../../../src/common/api/entities/tutanota/TypeModels.js"
import { ElementEntity } from "../../../../../src/common/api/common/EntityTypes.js"
import { clone, TypeRef } from "@tutao/tutanota-utils"
import { ClientModelEncryptedParsedInstance, ClientTypeModel, ElementEntity } from "../../../../../src/common/api/common/EntityTypes.js"
import { assertNotNull, base64ToUint8Array, clone, TypeRef, uint8ArrayToBase64 } from "@tutao/tutanota-utils"
import { hasError } from "../../../../../src/common/api/common/utils/ErrorUtils.js"
import {
dummyResolver,
TestAggregate,
testAggregateModel,
TestAggregateOnAggregate,
TestAggregateOnAggregateRef,
TestAggregateRef,
TestEntity,
testTypeModel,
TestTypeRef,
} from "../../worker/crypto/InstancePipelineTestUtils"
import { ClientTypeReferenceResolver, PatchOperationType, ServerTypeReferenceResolver } from "../../../../../src/common/api/common/EntityFunctions"
import { createTestEntityWithDummyResolver } from "../../../TestUtils"
import { InstancePipeline } from "../../../../../src/common/api/worker/crypto/InstancePipeline"
import { aes256RandomKey } from "@tutao/tutanota-crypto"
import { AttributeModel } from "../../../../../src/common/api/common/AttributeModel.js"
import { createPatch } from "../../../../../src/common/api/entities/sys/TypeRefs"
import { ValueType } from "../../../../../src/common/api/common/EntityConstants.js"
import { compressString } from "../../../../../src/common/api/worker/crypto/ModelMapper"
o.spec("EntityUtils", function () {
o("TimestampToHexGeneratedId ", function () {
@ -116,4 +138,521 @@ o.spec("EntityUtils", function () {
})
})
})
o.spec("computePatches", function () {
const dummyTypeReferenceResolver = dummyResolver as ClientTypeReferenceResolver
const dummyInstancePipeline = new InstancePipeline(dummyResolver as ClientTypeReferenceResolver, dummyResolver as ServerTypeReferenceResolver)
o("computePatches returns empty list for equal objects", async function () {
const testEntity = await createFilledTestEntity()
let sk = aes256RandomKey()
const originalParsedInstance = await dummyInstancePipeline.modelMapper.mapToClientModelParsedInstance(
TestTypeRef,
assertNotNull(testEntity._original),
)
const currentParsedInstance = await dummyInstancePipeline.modelMapper.mapToClientModelParsedInstance(TestTypeRef, testEntity)
const currentUntypedInstance = await dummyInstancePipeline.mapAndEncrypt(TestTypeRef, testEntity, sk)
const objectDiff = await computePatches(
originalParsedInstance,
currentParsedInstance,
currentUntypedInstance,
testTypeModel,
dummyTypeReferenceResolver,
)
o(objectDiff).deepEquals([])
})
o("computePatches works on values in the root level", async function () {
const testEntity = await createFilledTestEntity()
const date = new Date()
testEntity.testDate = date
let sk = aes256RandomKey()
const originalParsedInstance = await dummyInstancePipeline.modelMapper.mapToClientModelParsedInstance(
TestTypeRef,
assertNotNull(testEntity._original),
)
const currentParsedInstance = await dummyInstancePipeline.modelMapper.mapToClientModelParsedInstance(TestTypeRef, testEntity)
const currentUntypedInstance = await dummyInstancePipeline.mapAndEncrypt(TestTypeRef, testEntity, sk)
const objectDiff = await computePatches(
originalParsedInstance,
currentParsedInstance,
currentUntypedInstance,
testTypeModel,
dummyTypeReferenceResolver,
)
o(objectDiff).deepEquals([
createPatch({
attributePath: "5",
value: `${date.valueOf()}`,
patchOperation: PatchOperationType.REPLACE,
}),
])
})
o("computePatches works when setting values to null", async function () {
const testEntity = await createFilledTestEntity()
testEntity.testBoolean = null
let sk = aes256RandomKey()
const originalParsedInstance = await dummyInstancePipeline.modelMapper.mapToClientModelParsedInstance(
TestTypeRef,
assertNotNull(testEntity._original),
)
const currentParsedInstance = await dummyInstancePipeline.modelMapper.mapToClientModelParsedInstance(TestTypeRef, testEntity)
const currentUntypedInstance = await dummyInstancePipeline.mapAndEncrypt(TestTypeRef, testEntity, sk)
const objectDiff = await computePatches(
originalParsedInstance,
currentParsedInstance,
currentUntypedInstance,
testTypeModel,
dummyTypeReferenceResolver,
)
o(objectDiff).deepEquals([
createPatch({
attributePath: "7",
value: null,
patchOperation: PatchOperationType.REPLACE,
}),
])
})
o("computePatches works when modifying multiple values", async function () {
const testEntity = await createFilledTestEntity()
const date = new Date()
testEntity.testDate = date
testEntity.testBoolean = null
let sk = aes256RandomKey()
const originalParsedInstance = await dummyInstancePipeline.modelMapper.mapToClientModelParsedInstance(
TestTypeRef,
assertNotNull(testEntity._original),
)
const currentParsedInstance = await dummyInstancePipeline.modelMapper.mapToClientModelParsedInstance(TestTypeRef, testEntity)
const currentUntypedInstance = await dummyInstancePipeline.mapAndEncrypt(TestTypeRef, testEntity, sk)
const objectDiff = await computePatches(
originalParsedInstance,
currentParsedInstance,
currentUntypedInstance,
testTypeModel,
dummyTypeReferenceResolver,
)
o(objectDiff).deepEquals([
createPatch({
attributePath: "5",
value: `${date.valueOf()}`,
patchOperation: PatchOperationType.REPLACE,
}),
createPatch({
attributePath: "7",
value: null,
patchOperation: PatchOperationType.REPLACE,
}),
])
})
o("computePatches works on values on the aggregates", async function () {
const testEntity = await createFilledTestEntity()
testEntity.testAssociation[0].testNumber = "1234"
let sk = aes256RandomKey()
const originalParsedInstance = await dummyInstancePipeline.modelMapper.mapToClientModelParsedInstance(
TestTypeRef,
assertNotNull(testEntity._original),
)
const currentParsedInstance = await dummyInstancePipeline.modelMapper.mapToClientModelParsedInstance(TestTypeRef, testEntity)
const currentUntypedInstance = await dummyInstancePipeline.mapAndEncrypt(TestTypeRef, testEntity, sk)
const objectDiff = await computePatches(
originalParsedInstance,
currentParsedInstance,
currentUntypedInstance,
testTypeModel,
dummyTypeReferenceResolver,
)
o(objectDiff).deepEquals([
createPatch({
attributePath: "3/aggId/2",
value: "1234",
patchOperation: PatchOperationType.REPLACE,
}),
])
})
o("computePatches works on Any non-aggregation associations and additem operation", async function () {
const testEntity = await createFilledTestEntity()
testEntity.testListElementAssociation.push(["listId", "elementId"], ["list2Id", "element2Id"])
let sk = aes256RandomKey()
const originalParsedInstance = await dummyInstancePipeline.modelMapper.mapToClientModelParsedInstance(
TestTypeRef,
assertNotNull(testEntity._original),
)
const currentParsedInstance = await dummyInstancePipeline.modelMapper.mapToClientModelParsedInstance(TestTypeRef, testEntity)
const currentUntypedInstance = await dummyInstancePipeline.mapAndEncrypt(TestTypeRef, testEntity, sk)
let objectDiff = await computePatches(
originalParsedInstance,
currentParsedInstance,
currentUntypedInstance,
testTypeModel,
dummyTypeReferenceResolver,
)
o(objectDiff).deepEquals([
createPatch({
attributePath: "8",
value: '[["listId","elementId"], ["list2Id", "element2Id"]]',
patchOperation: PatchOperationType.ADD_ITEM,
}),
])
})
o("computePatches works on Any non-aggregation associations and removeitem operation", async function () {
const testEntity = await createTestEntityWithOriginal({
testListElementAssociation: [
["listId", "elementId"],
["listId2", "elementId2"],
["listId3", "elementId3"],
],
})
testEntity.testListElementAssociation.pop()
testEntity.testListElementAssociation.pop()
let sk = aes256RandomKey()
const originalParsedInstance = await dummyInstancePipeline.modelMapper.mapToClientModelParsedInstance(
TestTypeRef,
assertNotNull(testEntity._original),
)
const currentParsedInstance = await dummyInstancePipeline.modelMapper.mapToClientModelParsedInstance(TestTypeRef, testEntity)
const currentUntypedInstance = await dummyInstancePipeline.mapAndEncrypt(TestTypeRef, testEntity, sk)
const objectDiff = await computePatches(
originalParsedInstance,
currentParsedInstance,
currentUntypedInstance,
testTypeModel,
dummyTypeReferenceResolver,
)
o(objectDiff).deepEquals([
createPatch({
attributePath: "8",
value: '[["listId2","elementId2"], ["listId3", "elementId3"]]',
patchOperation: PatchOperationType.REMOVE_ITEM,
}),
])
})
o("computePatches works on ZeroOrOne non-aggregation associations and replace operation", async function () {
const testEntity = await createFilledTestEntity()
testEntity.testElementAssociation = "elementId"
let sk = aes256RandomKey()
const originalParsedInstance = await dummyInstancePipeline.modelMapper.mapToClientModelParsedInstance(
TestTypeRef,
assertNotNull(testEntity._original),
)
const currentParsedInstance = await dummyInstancePipeline.modelMapper.mapToClientModelParsedInstance(TestTypeRef, testEntity)
const currentUntypedInstance = await dummyInstancePipeline.mapAndEncrypt(TestTypeRef, testEntity, sk)
let objectDiff = await computePatches(
originalParsedInstance,
currentParsedInstance,
currentUntypedInstance,
testTypeModel,
dummyTypeReferenceResolver,
)
o(objectDiff).deepEquals([
createPatch({
attributePath: "4",
value: '["elementId"]',
patchOperation: PatchOperationType.REPLACE,
}),
])
})
o("computePatches works on ZeroOrOne non-aggregation list element associations and replace operation", async function () {
const testEntity = await createFilledTestEntity()
testEntity.testZeroOrOneListElementAssociation = ["listId", "elementId"]
let sk = aes256RandomKey()
const originalParsedInstance = await dummyInstancePipeline.modelMapper.mapToClientModelParsedInstance(
TestTypeRef,
assertNotNull(testEntity._original),
)
const currentParsedInstance = await dummyInstancePipeline.modelMapper.mapToClientModelParsedInstance(TestTypeRef, testEntity)
const currentUntypedInstance = await dummyInstancePipeline.mapAndEncrypt(TestTypeRef, testEntity, sk)
let objectDiff = await computePatches(
originalParsedInstance,
currentParsedInstance,
currentUntypedInstance,
testTypeModel,
dummyTypeReferenceResolver,
false,
)
o(objectDiff).deepEquals([
createPatch({
attributePath: "14",
value: '[["listId","elementId"]]',
patchOperation: PatchOperationType.REPLACE,
}),
])
})
o("computePatches works on ZeroOrOne non-aggregation associations and replace operation setting to null", async function () {
const testEntity = await createFilledTestEntity()
testEntity.testElementAssociation = null
let sk = aes256RandomKey()
const originalParsedInstance = await dummyInstancePipeline.modelMapper.mapToClientModelParsedInstance(
TestTypeRef,
assertNotNull(testEntity._original),
)
const currentParsedInstance = await dummyInstancePipeline.modelMapper.mapToClientModelParsedInstance(TestTypeRef, testEntity)
const currentUntypedInstance = await dummyInstancePipeline.mapAndEncrypt(TestTypeRef, testEntity, sk)
let objectDiff = await computePatches(
originalParsedInstance,
currentParsedInstance,
currentUntypedInstance,
testTypeModel,
dummyTypeReferenceResolver,
)
o(objectDiff).deepEquals([
createPatch({
attributePath: "4",
value: "[]",
patchOperation: PatchOperationType.REPLACE,
}),
])
})
o("computePatches works on aggregations and additem operation", async function () {
const testEntity = await createFilledTestEntity()
testEntity.testAssociation.push(await createTestEntityWithDummyResolver(TestAggregateRef, { _id: "newAgId" }))
testEntity.testAssociation.push(await createTestEntityWithDummyResolver(TestAggregateRef, { _id: "newAgId2" }))
let sk = aes256RandomKey()
const originalParsedInstance = await dummyInstancePipeline.modelMapper.mapToClientModelParsedInstance(
TestTypeRef,
assertNotNull(testEntity._original),
)
const currentParsedInstance = await dummyInstancePipeline.modelMapper.mapToClientModelParsedInstance(TestTypeRef, testEntity)
const currentEncryptedParsedInstance = await dummyInstancePipeline.cryptoMapper.encryptParsedInstance(
testTypeModel as ClientTypeModel,
currentParsedInstance,
sk,
)
const currentUntypedInstance = await dummyInstancePipeline.typeMapper.applyDbTypes(testTypeModel as ClientTypeModel, currentEncryptedParsedInstance)
const testAssociationFirstEncryptedInstance = (
AttributeModel.getAttribute(currentEncryptedParsedInstance, "testAssociation", testTypeModel) as Array<ClientModelEncryptedParsedInstance>
)[1]
const testAssociationSecondEncryptedInstance = (
AttributeModel.getAttribute(currentEncryptedParsedInstance, "testAssociation", testTypeModel) as Array<ClientModelEncryptedParsedInstance>
)[2]
let objectDiff = await computePatches(
originalParsedInstance,
currentParsedInstance,
currentUntypedInstance,
testTypeModel,
dummyTypeReferenceResolver,
)
o(objectDiff).deepEquals([
createPatch({
attributePath: "3",
value: JSON.stringify([testAssociationFirstEncryptedInstance, testAssociationSecondEncryptedInstance]),
patchOperation: PatchOperationType.ADD_ITEM,
}),
])
})
o("computePatches works on aggregations and removeitem operation", async function () {
const testEntity = await createFilledTestEntity()
testEntity.testAssociation.pop()
let sk = aes256RandomKey()
const originalParsedInstance = await dummyInstancePipeline.modelMapper.mapToClientModelParsedInstance(
TestTypeRef,
assertNotNull(testEntity._original),
)
const currentParsedInstance = await dummyInstancePipeline.modelMapper.mapToClientModelParsedInstance(TestTypeRef, testEntity)
const currentUntypedInstance = await dummyInstancePipeline.mapAndEncrypt(TestTypeRef, testEntity, sk)
let objectDiff = await computePatches(
originalParsedInstance,
currentParsedInstance,
currentUntypedInstance,
testTypeModel,
dummyTypeReferenceResolver,
)
o(objectDiff).deepEquals([
createPatch({
attributePath: "3",
value: '["aggId"]',
patchOperation: PatchOperationType.REMOVE_ITEM,
}),
])
})
o("computePatches works on values on aggregations on aggregations and replace operation", async function () {
const testEntity = await createFilledTestEntity()
const newValue = new Uint8Array(8)
testEntity.testAssociation[0].testSecondLevelAssociation[0].testBytes = newValue
let sk = aes256RandomKey()
const originalParsedInstance = await dummyInstancePipeline.modelMapper.mapToClientModelParsedInstance(
TestTypeRef,
assertNotNull(testEntity._original),
)
const currentParsedInstance = await dummyInstancePipeline.modelMapper.mapToClientModelParsedInstance(TestTypeRef, testEntity)
const currentUntypedInstance = await dummyInstancePipeline.mapAndEncrypt(TestTypeRef, testEntity, sk)
let objectDiff = await computePatches(
originalParsedInstance,
currentParsedInstance,
currentUntypedInstance,
testTypeModel,
dummyTypeReferenceResolver,
)
o(objectDiff).deepEquals([
createPatch({
attributePath: "3/aggId/9/aggOnAggId/10",
value: uint8ArrayToBase64(newValue),
patchOperation: PatchOperationType.REPLACE,
}),
])
})
o("computePatches works on aggregates on aggregations and additem operation", async function () {
const testEntity = await createFilledTestEntity()
const testAggregateOnAggregateEntity = await createTestEntityWithDummyResolver(TestAggregateOnAggregateRef)
testEntity.testAssociation[0].testSecondLevelAssociation.push(testAggregateOnAggregateEntity)
let sk = aes256RandomKey()
const originalParsedInstance = await dummyInstancePipeline.modelMapper.mapToClientModelParsedInstance(
TestTypeRef,
assertNotNull(testEntity._original),
)
const currentParsedInstance = await dummyInstancePipeline.modelMapper.mapToClientModelParsedInstance(TestTypeRef, testEntity)
const currentEncryptedParsedInstance = await dummyInstancePipeline.cryptoMapper.encryptParsedInstance(
testTypeModel as ClientTypeModel,
currentParsedInstance,
sk,
)
const currentUntypedInstance = await dummyInstancePipeline.typeMapper.applyDbTypes(testTypeModel as ClientTypeModel, currentEncryptedParsedInstance)
const testAssociationEncrypted = AttributeModel.getAttribute(
currentEncryptedParsedInstance,
"testAssociation",
testTypeModel,
) as Array<ClientModelEncryptedParsedInstance>
const addedTestAggregateOnAggregateEncrypted = (
AttributeModel.getAttribute(
testAssociationEncrypted[0],
"testSecondLevelAssociation",
testAggregateModel,
) as Array<ClientModelEncryptedParsedInstance>
)[1]
let objectDiff = await computePatches(
originalParsedInstance,
currentParsedInstance,
currentUntypedInstance,
testTypeModel,
dummyTypeReferenceResolver,
)
o(objectDiff).deepEquals([
createPatch({
attributePath: "3/aggId/9",
value: JSON.stringify([addedTestAggregateOnAggregateEncrypted]),
patchOperation: PatchOperationType.ADD_ITEM,
}),
])
})
o("computePatches works on aggregates on aggregations and removeitem operation", async function () {
const testEntity = await createFilledTestEntity()
testEntity.testAssociation[0].testSecondLevelAssociation.pop()
let sk = aes256RandomKey()
const originalParsedInstance = await dummyInstancePipeline.modelMapper.mapToClientModelParsedInstance(
TestTypeRef,
assertNotNull(testEntity._original),
)
const currentParsedInstance = await dummyInstancePipeline.modelMapper.mapToClientModelParsedInstance(TestTypeRef, testEntity)
const currentEncryptedParsedInstance = await dummyInstancePipeline.cryptoMapper.encryptParsedInstance(
testTypeModel as ClientTypeModel,
currentParsedInstance,
sk,
)
const currentUntypedInstance = await dummyInstancePipeline.typeMapper.applyDbTypes(testTypeModel as ClientTypeModel, currentEncryptedParsedInstance)
let objectDiff = await computePatches(
originalParsedInstance,
currentParsedInstance,
currentUntypedInstance,
testTypeModel,
dummyTypeReferenceResolver,
)
o(objectDiff).deepEquals([
createPatch({
attributePath: "3/aggId/9",
value: '["aggOnAggId"]',
patchOperation: PatchOperationType.REMOVE_ITEM,
}),
])
})
async function createTestEntityWithOriginal(overrides: Partial<TestEntity>): Promise<TestEntity> {
const instance: TestEntity = await createTestEntityWithDummyResolver(TestTypeRef, overrides)
instance._original = structuredClone(instance)
return instance
}
async function createFilledTestEntity(): Promise<TestEntity> {
return await createTestEntityWithOriginal({
_type: TestTypeRef,
_finalIvs: {},
testAssociation: [
{
_type: TestAggregateRef,
_finalIvs: {},
_id: "aggId",
testNumber: "123456",
testSecondLevelAssociation: [
{
_type: TestAggregateOnAggregateRef,
_finalIvs: {},
_id: "aggOnAggId",
testBytes: null,
} as TestAggregateOnAggregate,
],
} as TestAggregate,
],
testBoolean: false,
testDate: new Date("2025-01-01T13:00:00.000Z"),
testElementAssociation: "associatedElementId",
testListElementAssociation: [["listId", "listElementId"]],
testZeroOrOneListElementAssociation: null,
testValue: "some encrypted string",
testGeneratedId: GENERATED_MIN_ID,
_id: [GENERATED_MIN_ID, GENERATED_MIN_ID],
})
}
})
o("areValuesDifferent works as expected", function () {
o(areValuesDifferent(ValueType.String, "example", "example")).equals(false)
o(areValuesDifferent(ValueType.String, "example", "different")).equals(true)
o(areValuesDifferent(ValueType.Number, 123, 123)).equals(false)
o(areValuesDifferent(ValueType.Number, 123, 456)).equals(true)
o(areValuesDifferent(ValueType.Bytes, base64ToUint8Array("byte"), base64ToUint8Array("byte"))).equals(false)
o(areValuesDifferent(ValueType.Bytes, base64ToUint8Array("byte"), base64ToUint8Array("diffbyte"))).equals(true)
o(areValuesDifferent(ValueType.Date, new Date(2025, 6, 6), new Date(2025, 6, 6))).equals(false)
o(areValuesDifferent(ValueType.Date, new Date(2025, 6, 6), new Date(2025, 6, 5))).equals(true)
o(areValuesDifferent(ValueType.Boolean, true, true)).equals(false)
o(areValuesDifferent(ValueType.Boolean, true, false)).equals(true)
o(areValuesDifferent(ValueType.GeneratedId, GENERATED_MIN_ID, GENERATED_MIN_ID)).equals(false)
o(areValuesDifferent(ValueType.GeneratedId, GENERATED_MIN_ID, GENERATED_MAX_ID)).equals(true)
o(areValuesDifferent(ValueType.GeneratedId, [GENERATED_MIN_ID, GENERATED_MIN_ID], [GENERATED_MIN_ID, GENERATED_MIN_ID])).equals(false)
o(areValuesDifferent(ValueType.GeneratedId, [GENERATED_MIN_ID, GENERATED_MIN_ID], [GENERATED_MAX_ID, GENERATED_MAX_ID])).equals(true)
o(areValuesDifferent(ValueType.CustomId, "customId", "customId")).equals(false)
o(areValuesDifferent(ValueType.CustomId, "customId", "diffcustomId")).equals(true)
o(areValuesDifferent(ValueType.CompressedString, "compress string", "compress string")).equals(false)
o(areValuesDifferent(ValueType.CompressedString, "compress string", "compress different string")).equals(true)
})
})

View file

@ -411,7 +411,7 @@ o.spec("CryptoFacadeTest", function () {
).thenResolve({ decryptedAesKey: bk, senderIdentityPubKey: senderIdentityKeyPair.publicKey })
when(userFacade.createAuthHeaders()).thenReturn({})
when(restClient.request(anything(), HttpMethod.PUT, anything())).thenResolve(undefined)
when(restClient.request(anything(), HttpMethod.PATCH, anything())).thenResolve(undefined)
when(entityClient.loadAll(BucketPermissionTypeRef, getListId(bucketPermission))).thenResolve([bucketPermission])
when(entityClient.loadAll(PermissionTypeRef, getListId(permission))).thenResolve([permission])
@ -493,7 +493,7 @@ o.spec("CryptoFacadeTest", function () {
),
).thenResolve({ decryptedAesKey: bk, senderIdentityPubKey: senderIdentityKeyPair.publicKey })
when(userFacade.createAuthHeaders()).thenReturn({})
when(restClient.request(anything(), HttpMethod.PUT, anything())).thenResolve(undefined)
when(restClient.request(anything(), HttpMethod.PATCH, anything())).thenResolve(undefined)
when(entityClient.loadAll(BucketPermissionTypeRef, getListId(bucketPermission))).thenResolve([bucketPermission])
when(entityClient.loadAll(PermissionTypeRef, getListId(permission))).thenResolve([permission])
@ -566,7 +566,7 @@ o.spec("CryptoFacadeTest", function () {
),
).thenResolve({ decryptedAesKey: bk, senderIdentityPubKey: senderIdentityKeyPair.publicKey })
when(userFacade.createAuthHeaders()).thenReturn({})
when(restClient.request(anything(), HttpMethod.PUT, anything())).thenResolve(undefined)
when(restClient.request(anything(), HttpMethod.PATCH, anything())).thenResolve(undefined)
when(
asymmetricCryptoFacade.authenticateSender(
{

View file

@ -223,9 +223,9 @@ o.spec("CryptoMapper", function () {
const sk = [4136869568, 4101282953, 2038999435, 962526794, 1053028316, 3236029410, 1618615449, 3232287205]
const encryptedInstance: ServerModelEncryptedParsedInstance = {
1: "AV1kmZZfCms1pNvUtGrdhOlnDAr3zb2JWpmlpWEhgG5zqYK3g7PfRsi0vQAKLxXmrNRGp16SBKBa0gqXeFw9F6l7nbGs3U8uNLvs6Fi+9IWj",
3: [{ 2: "123", 6: "someCustomId" }],
3: [{ 2: "123", 6: "someCustomId", 9: [] }],
7: "AWBaC3ipyi9kxJn7USkbW1SLXPjgU8T5YqpIP/dmTbyRwtXFU9tQbYBm12gNpI9KJfwO14FN25hjC3SlngSBlzs=",
4: ["associatedListId"],
4: ["associatedElementId"],
5: new Date("2025-01-01T13:00:00.000Z"),
} as any as ServerModelEncryptedParsedInstance
const expectedFinalIv = new Uint8Array([93, 100, 153, 150, 95, 10, 107, 53, 164, 219, 212, 180, 106, 221, 132, 233])
@ -235,7 +235,7 @@ o.spec("CryptoMapper", function () {
o((decryptedInstance[5] as Date).toISOString()).equals("2025-01-01T13:00:00.000Z")
o(decryptedInstance[3]![0][2]).equals("123")
o(decryptedInstance[4]![0]).equals("associatedListId")
o(decryptedInstance[4]![0]).equals("associatedElementId")
o(typeof decryptedInstance._finalIvs[7]).equals("undefined")
o(Array.from(decryptedInstance["_finalIvs"][1] as Uint8Array)).deepEquals(Array.from(expectedFinalIv))
o(typeof decryptedInstance._errors).equals("undefined")
@ -248,8 +248,8 @@ o.spec("CryptoMapper", function () {
5: new Date("2025-01-01T13:00:00.000Z"),
7: true,
// 6 is _id and will be generated
3: [{ 2: "123", 6: "aggregateId" }],
4: ["associatedListId"],
3: [{ 2: "123", 6: "aggregateId", 9: [] }],
4: ["associatedElementId"],
_finalIvs: { 1: new Uint8Array([93, 100, 153, 150, 95, 10, 107, 53, 164, 219, 212, 180, 106, 221, 132, 233]) },
} as unknown as ClientModelParsedInstance
const encryptedInstance = await cryptoMapper.encryptParsedInstance(testTypeModel as ClientTypeModel, parsedInstance, sk)
@ -265,14 +265,14 @@ o.spec("CryptoMapper", function () {
o(encryptedAggregate[2]).equals(parsedInstance[3]![0][2] as string)
o(encryptedAggregate[6]).equals("aggregateId")
o(encryptedAggregate[2])
o(encryptedInstance[4]![0]).equals("associatedListId")
o(encryptedInstance[4]![0]).equals("associatedElementId")
})
o("decryptParsedInstance with missing sk sets _errors", async function () {
const encryptedInstance: ServerModelEncryptedParsedInstance = {
1: "AV1kmZZfCms1pNvUtGrdhOlnDAr3zb2JWpmlpWEhgG5zqYK3g7PfRsi0vQAKLxXmrNRGp16SBKBa0gqXeFw9F6l7nbGs3U8uNLvs6Fi+9IWj",
3: [{ 2: "123", 6: "someCustomId" }],
4: ["associatedListId"],
3: [{ 2: "123", 6: "someCustomId", 9: [] }],
4: ["associatedElementId"],
5: new Date("2025-01-01T13:00:00.000Z"),
} as any as ServerModelEncryptedParsedInstance
const instance = await cryptoMapper.decryptParsedInstance(testTypeModel as ServerTypeModel, encryptedInstance, null)
@ -285,8 +285,8 @@ o.spec("CryptoMapper", function () {
1: "encrypted string",
5: new Date("2025-01-01T13:00:00.000Z"),
// 6 is _id and will be generated
3: [{ 2: "123" }],
4: ["associatedListId"],
3: [{ 2: "123", 9: [] }],
4: ["associatedElementId"],
_finalIvs: { 1: new Uint8Array([93, 100, 153, 150, 95, 10, 107, 53, 164, 219, 212, 180, 106, 221, 132, 233]) },
} as unknown as ClientModelParsedInstance
await assertThrows(CryptoError, () => cryptoMapper.encryptParsedInstance(testTypeModel as ClientTypeModel, parsedInstance, null))
@ -297,8 +297,8 @@ o.spec("CryptoMapper", function () {
const encryptedInstance: ServerModelEncryptedParsedInstance = {
1: "",
3: [{ 2: "123", 6: "someCustomId" }],
4: ["associatedListId"],
3: [{ 2: "123", 6: "someCustomId", 9: [] }],
4: ["associatedElementId"],
5: new Date("2025-01-01T13:00:00.000Z"),
} as any as ServerModelEncryptedParsedInstance
@ -314,8 +314,8 @@ o.spec("CryptoMapper", function () {
1: "",
5: new Date("2025-01-01T13:00:00.000Z"),
// 6 is _id and will be generated
3: [{ 2: "123" }],
4: ["associatedListId"],
3: [{ 2: "123", 9: [] }],
4: ["associatedElementId"],
_finalIvs: { 1: null },
} as unknown as ClientModelParsedInstance
@ -327,8 +327,8 @@ o.spec("CryptoMapper", function () {
const sk = [4136869568, 4101282953, 2038999435, 962526794, 1053028316, 3236029410, 1618615449, 3232287205]
const encryptedInstance: ServerModelEncryptedParsedInstance = {
1: "AV1kmZZfCms1pNvUtGrdhOlnDAr3zb2pmlpWEhgG5iwzqYK3g7PfRsi0vQAKLxXmrNRGp16SBKBa0gqXeFw9F6l7nbGs3U8uNLvs6Fi+9IWj",
3: [{ 2: "123", 6: "someCustomId" }],
4: ["associatedListId"],
3: [{ 2: "123", 6: "someCustomId", 9: [] }],
4: ["associatedElementId"],
5: new Date("2025-01-01T13:00:00.000Z"),
} as any as ServerModelEncryptedParsedInstance

View file

@ -21,7 +21,7 @@ export const testTypeModel: TypeModel = {
encrypted: true,
},
"5": {
id: 1,
id: 5,
name: "testDate",
type: ValueType.Date,
cardinality: Cardinality.One,
@ -29,29 +29,45 @@ export const testTypeModel: TypeModel = {
encrypted: false,
},
"7": {
id: 1,
id: 7,
name: "testBoolean",
type: ValueType.Boolean,
cardinality: Cardinality.One,
cardinality: Cardinality.ZeroOrOne,
final: false,
encrypted: true,
},
"12": {
id: 12,
name: "testGeneratedId",
type: ValueType.GeneratedId,
cardinality: Cardinality.One,
final: false,
encrypted: false,
},
"13": {
id: 12,
name: "_id",
type: ValueType.GeneratedId,
cardinality: Cardinality.One,
final: false,
encrypted: false,
},
},
associations: {
"3": {
id: 3,
name: "testAssociation",
type: AssociationType.Aggregation,
cardinality: Cardinality.One,
cardinality: Cardinality.Any,
refTypeId: 43,
final: false,
dependency: "tutanota",
},
"4": {
id: 4,
name: "testListAssociation",
type: AssociationType.ListAssociation,
cardinality: Cardinality.One,
name: "testElementAssociation",
type: AssociationType.ElementAssociation,
cardinality: Cardinality.ZeroOrOne,
refTypeId: 44,
final: false,
dependency: null,
@ -60,7 +76,16 @@ export const testTypeModel: TypeModel = {
id: 8,
name: "testListElementAssociation",
type: AssociationType.ListElementAssociationGenerated,
cardinality: Cardinality.One,
cardinality: Cardinality.Any,
refTypeId: 44,
final: false,
dependency: null,
},
"14": {
id: 14,
name: "testZeroOrOneListElementAssociation",
type: AssociationType.ListElementAssociationGenerated,
cardinality: Cardinality.ZeroOrOne,
refTypeId: 44,
final: false,
dependency: null,
@ -97,6 +122,48 @@ export const testAggregateModel: TypeModel = {
encrypted: false,
},
},
associations: {
"9": {
id: 9,
name: "testSecondLevelAssociation",
type: AssociationType.Aggregation,
cardinality: Cardinality.Any,
refTypeId: 44,
final: false,
dependency: "tutanota",
},
},
version: 0,
versioned: false,
}
export const testAggregateOnAggregateModel: TypeModel = {
app: "tutanota",
encrypted: true,
id: 44,
name: "TestAggregateOnAggregate",
rootId: "SoMeId",
since: 41,
type: Type.Aggregated,
isPublic: true,
values: {
"10": {
id: 10,
name: "testBytes",
type: ValueType.Bytes,
cardinality: Cardinality.ZeroOrOne,
final: false,
encrypted: false,
},
"11": {
id: 11,
name: "_id",
type: ValueType.CustomId,
cardinality: Cardinality.One,
final: true,
encrypted: false,
},
},
associations: {},
version: 0,
versioned: false,
@ -104,24 +171,41 @@ export const testAggregateModel: TypeModel = {
export const TestTypeRef = new TypeRef<TestEntity>("tutanota", 42)
export const TestAggregateRef = new TypeRef<TestAggregate>("tutanota", 43)
export const TestAggregateOnAggregateRef = new TypeRef<TestAggregateOnAggregate>("tutanota", 44)
export type TestAggregateOnAggregate = Entity & {
_id: Id
testBytes: null | Uint8Array
}
export type TestAggregate = Entity & {
_id: Id
testNumber: NumberString
testSecondLevelAssociation: TestAggregateOnAggregate[]
}
export type TestEntity = Entity & {
_id: IdTuple
testGeneratedId: Id
testValue: string
testDate: Date
testBoolean: boolean
testAssociation: TestAggregate
testListAssociation: Id
testListElementAssociation: IdTuple
testBoolean: boolean | null
testAssociation: TestAggregate[]
testElementAssociation: Id | null
testListElementAssociation: IdTuple[]
testZeroOrOneListElementAssociation: IdTuple | null
}
export const dummyResolver = (tr: TypeRef<unknown>) => {
const model = tr.typeId === 42 ? testTypeModel : testAggregateModel
return Promise.resolve(model)
switch (tr.typeId) {
case 42:
return Promise.resolve(testTypeModel)
case 43:
return Promise.resolve(testAggregateModel)
case 44:
return Promise.resolve(testAggregateOnAggregateModel)
}
return Promise.resolve(testTypeModel)
}
export function createEncryptedValueType(

View file

@ -16,6 +16,7 @@ import { ClientModelParsedInstance, ModelAssociation, ServerModelParsedInstance
import { assertThrows } from "@tutao/tutanota-test-utils"
import { ProgrammingError } from "../../../../../src/common/api/common/error/ProgrammingError"
import { ClientTypeReferenceResolver, ServerTypeReferenceResolver } from "../../../../../src/common/api/common/EntityFunctions"
import { GENERATED_MIN_ID } from "../../../../../src/common/api/common/utils/EntityUtils.js"
o.spec("ModelMapper", function () {
const modelMapper: ModelMapper = new ModelMapper(dummyResolver as ClientTypeReferenceResolver, dummyResolver as ServerTypeReferenceResolver)
@ -79,8 +80,10 @@ o.spec("ModelMapper", function () {
const parsedInstance: ServerModelParsedInstance = {
1: "some encrypted string",
5: new Date("2025-01-01T13:00:00.000Z"),
3: [{ 2: "123", 6: "123456", _finalIvs: {} } as unknown as ServerModelParsedInstance],
4: ["associatedListId"],
3: [{ 2: "123", 6: "123456", _finalIvs: {}, 9: [] } as unknown as ServerModelParsedInstance],
12: "generatedId",
13: ["listId", "elementId"],
4: ["associatedElementId"],
7: true,
8: [["listId", "listElementId"]],
_finalIvs: {},
@ -88,17 +91,20 @@ o.spec("ModelMapper", function () {
const mappedInstance = (await modelMapper.mapToInstance(TestTypeRef, parsedInstance)) as any
o(mappedInstance._type).deepEquals(TestTypeRef)
o(mappedInstance._id).deepEquals(["listId", "elementId"])
o(mappedInstance.testValue).equals("some encrypted string")
o(mappedInstance.testBoolean).equals(true)
o(mappedInstance.testDate.toISOString()).equals("2025-01-01T13:00:00.000Z")
o(mappedInstance.testAssociation).deepEquals({
o(mappedInstance.testAssociation[0]).deepEquals({
_type: TestAggregateRef,
_finalIvs: {},
testNumber: "123",
_id: "123456",
testSecondLevelAssociation: [],
})
o(mappedInstance.testListAssociation).equals("associatedListId")
o(mappedInstance.testListElementAssociation).deepEquals(["listId", "listElementId"])
o(mappedInstance.testElementAssociation).equals("associatedElementId")
o(mappedInstance.testGeneratedId).equals("generatedId")
o(mappedInstance.testListElementAssociation).deepEquals([["listId", "listElementId"]])
o(mappedInstance._finalIvs).deepEquals(parsedInstance._finalIvs)
o(typeof mappedInstance._errors).equals("undefined")
})
@ -141,16 +147,21 @@ o.spec("ModelMapper", function () {
const instance: TestEntity = {
_type: TestTypeRef,
_finalIvs: {},
testAssociation: {
_type: TestAggregateRef,
_finalIvs: {},
testNumber: "123456",
} as TestAggregate,
testAssociation: [
{
_type: TestAggregateRef,
_finalIvs: {},
testNumber: "123456",
} as TestAggregate,
],
testBoolean: false,
testDate: new Date("2025-01-01T13:00:00.000Z"),
testListAssociation: "associatedListId",
testListElementAssociation: ["listId", "listElementId"],
testElementAssociation: "associatedElementId",
testListElementAssociation: [["listId", "listElementId"]],
testZeroOrOneListElementAssociation: null,
testValue: "some encrypted string",
testGeneratedId: GENERATED_MIN_ID,
_id: [GENERATED_MIN_ID, GENERATED_MIN_ID],
}
const parsedInstance: ClientModelParsedInstance = await modelMapper.mapToClientModelParsedInstance(TestTypeRef, instance)
@ -161,7 +172,7 @@ o.spec("ModelMapper", function () {
o(testAssociation[2]).equals("123456")
o(testAssociation[6].length).deepEquals(6) // custom generated id
o(testAssociation._finalIvs).deepEquals({})
o(parsedInstance[4]).deepEquals(["associatedListId"])
o(parsedInstance[4]).deepEquals(["associatedElementId"])
o(parsedInstance._finalIvs).deepEquals(instance._finalIvs!)
o(typeof parsedInstance._errors).equals("undefined")
})

View file

@ -15,7 +15,7 @@ import { AttributeModel } from "../../../../../src/common/api/common/AttributeMo
const serverModelUntypedInstanceNetworkDebugging: ServerModelUntypedInstance = {
"1:testValue": "test string",
"3:testAssociation": [{ "2:testNumber": "123" }],
"3:testAssociation": [{ "2:testNumber": "123", "9:testSecondLevelAssociation": [] }],
"4:testListAssociation": ["assocId"],
"5:testDate": "1735736415000",
"7:testBoolean": "encryptedBool",
@ -23,7 +23,7 @@ const serverModelUntypedInstanceNetworkDebugging: ServerModelUntypedInstance = {
const serverModelUntypedInstance: ServerModelUntypedInstance = {
"1": "test string",
"3": [{ "2": "123" }],
"3": [{ "2": "123", 9: [] }],
"4": ["assocId"],
"5": "1735736415000",
"7": "encryptedBool",
@ -31,7 +31,7 @@ const serverModelUntypedInstance: ServerModelUntypedInstance = {
const clientModelEncryptedParsedInstance: ClientModelEncryptedParsedInstance = {
"1": "base64EncodedString",
"3": [{ "2": "123" }],
"3": [{ "2": "123", 9: [] }],
"4": ["assocId"],
"5": new Date("2025-01-01T13:00:15Z"),
"7": "encryptedBool",
@ -39,7 +39,7 @@ const clientModelEncryptedParsedInstance: ClientModelEncryptedParsedInstance = {
const faultyEncryptedParsedInstance: ClientModelEncryptedParsedInstance = {
"1": new Uint8Array(2),
"3": [{ "2": "123" }],
"3": [{ "2": "123", 9: [] }],
"4": ["assocId"],
"5": new Date("2025-01-01T13:00:15Z"),
} as unknown as ClientModelEncryptedParsedInstance

View file

@ -8,8 +8,14 @@ import {
} from "../../../../../src/common/api/common/error/RestError.js"
import { assertThrows } from "@tutao/tutanota-test-utils"
import { SetupMultipleError } from "../../../../../src/common/api/common/error/SetupMultipleError.js"
import { HttpMethod, MediaType, TypeModelResolver } from "../../../../../src/common/api/common/EntityFunctions.js"
import { AccountingInfoTypeRef, CustomerTypeRef, GroupMemberTypeRef } from "../../../../../src/common/api/entities/sys/TypeRefs.js"
import { HttpMethod, MediaType, PatchOperationType, TypeModelResolver } from "../../../../../src/common/api/common/EntityFunctions.js"
import {
AccountingInfoTypeRef,
createPatchList,
CustomerTypeRef,
GroupMemberTypeRef,
PatchListTypeRef,
} from "../../../../../src/common/api/entities/sys/TypeRefs.js"
import { doBlobRequestWithRetry, EntityRestClient, tryServers, typeModelToRestPath } from "../../../../../src/common/api/worker/rest/EntityRestClient.js"
import { RestClient } from "../../../../../src/common/api/worker/rest/RestClient.js"
import { CryptoFacade } from "../../../../../src/common/api/worker/crypto/CryptoFacade.js"
@ -19,7 +25,18 @@ import sysModelInfo from "../../../../../src/common/api/entities/sys/ModelInfo.j
import { AuthDataProvider, UserFacade } from "../../../../../src/common/api/worker/facades/UserFacade.js"
import { LoginIncompleteError } from "../../../../../src/common/api/common/error/LoginIncompleteError.js"
import { BlobServerAccessInfoTypeRef, BlobServerUrlTypeRef } from "../../../../../src/common/api/entities/storage/TypeRefs.js"
import { Base64, base64ToUint8Array, deepEqual, KeyVersion, Mapper, ofClass, promiseMap, TypeRef } from "@tutao/tutanota-utils"
import {
assertNotNull,
Base64,
base64ToUint8Array,
deepEqual,
KeyVersion,
Mapper,
ofClass,
promiseMap,
TypeRef,
uint8ArrayToBase64,
} from "@tutao/tutanota-utils"
import { ProgrammingError } from "../../../../../src/common/api/common/error/ProgrammingError.js"
import { BlobAccessTokenFacade } from "../../../../../src/common/api/worker/facades/BlobAccessTokenFacade.js"
import {
@ -1125,11 +1142,13 @@ o.spec("EntityRestClient", function () {
const newSupportData = createTestEntity(SupportDataTypeRef, {
_id: "id",
})
const untypedSupportData = await instancePipeline.mapAndEncrypt(SupportDataTypeRef, newSupportData, null)
newSupportData._original = structuredClone(newSupportData)
const patchPayload = createPatchList({ patches: [] })
const untypedPatchPayload = await instancePipeline.mapAndEncrypt(PatchListTypeRef, patchPayload, null)
when(
restClient.request("/rest/tutanota/supportdata/id", HttpMethod.PUT, {
restClient.request("/rest/tutanota/supportdata/id", HttpMethod.PATCH, {
headers: { ...authHeader, v: String(version) },
body: JSON.stringify(untypedSupportData),
body: JSON.stringify(untypedPatchPayload),
}),
)
@ -1152,9 +1171,10 @@ o.spec("EntityRestClient", function () {
_id: "id1",
_permissions: "permissionsId",
_ownerGroup: ownerGroupId,
_ownerEncSessionKey: ownerEncSessionKey.key,
_ownerKeyVersion: ownerEncSessionKey.encryptingKeyVersion.toString(),
})
newAccountingInfo._original = structuredClone(newAccountingInfo)
newAccountingInfo._ownerEncSessionKey = ownerEncSessionKey.key
newAccountingInfo._ownerKeyVersion = ownerEncSessionKey.encryptingKeyVersion.toString()
when(restClient.request(anything(), anything(), anything())).thenResolve(null)
await entityRestClient.update(newAccountingInfo, {
@ -1167,15 +1187,26 @@ o.spec("EntityRestClient", function () {
verify(
restClient.request(
"/rest/sys/accountinginfo/id1",
HttpMethod.PUT,
HttpMethod.PATCH,
argThat(async (options) => {
const untypedInstance = JSON.parse(options.body)
const updatedAccountingInfo = await instancePipeline.decryptAndMap(AccountingInfoTypeRef, untypedInstance, ownerKeyProviderSk)
// this patch list must include two patch operations: replace for _ownerEncSessionKey and _ownerKeyVersion on newAccountingInfo
const patchList = await instancePipeline.decryptAndMap(PatchListTypeRef, JSON.parse(options.body), null)
const ownerEncSessionKeyOperation = assertNotNull(
patchList.patches.find((operation) => typeModel.values[parseInt(operation.attributePath)].name == "_ownerEncSessionKey"),
)
const ownerKeyVersionOperation = assertNotNull(
patchList.patches.find((operation) => typeModel.values[parseInt(operation.attributePath)].name == "_ownerKeyVersion"),
)
return (
deepEqual(options.headers, {
...authHeader,
v: String(version),
}) && deepEqual(newAccountingInfo, updatedAccountingInfo)
}) &&
patchList.patches.length == 2 &&
ownerEncSessionKeyOperation.value == uint8ArrayToBase64(ownerEncSessionKey.key) &&
ownerKeyVersionOperation.value == ownerEncSessionKey.encryptingKeyVersion.toString() &&
ownerEncSessionKeyOperation.patchOperation == PatchOperationType.REPLACE &&
ownerKeyVersionOperation.patchOperation == PatchOperationType.REPLACE
)
}),
),