mirror of
https://github.com/tutao/tutanota.git
synced 2025-10-19 07:53:47 +00:00
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:
parent
60313fba09
commit
ccc474c0db
17 changed files with 1064 additions and 76 deletions
|
@ -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),
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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!] = {
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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, {
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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(
|
||||
{
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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")
|
||||
})
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}),
|
||||
),
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue