tutanota/test/tests/api/worker/rest/EntityRestClientTest.ts
vis 16a5590760 Disallow Partial instance initializers
Co-authored-by: @paw-hub, @mpfau, @vaf-hub
tutanota#6118
2023-12-12 18:52:44 +01:00

944 lines
36 KiB
TypeScript

import o from "@tutao/otest"
import {
BadRequestError,
ConnectionError,
InternalServerError,
NotAuthorizedError,
PayloadTooLargeError,
} from "../../../../../src/api/common/error/RestError.js"
import { assertThrows } from "@tutao/tutanota-test-utils"
import { SetupMultipleError } from "../../../../../src/api/common/error/SetupMultipleError.js"
import { HttpMethod, MediaType, resolveTypeReference } from "../../../../../src/api/common/EntityFunctions.js"
import { createCustomer, CustomerTypeRef } from "../../../../../src/api/entities/sys/TypeRefs.js"
import { doBlobRequestWithRetry, EntityRestClient, tryServers, typeRefToPath } from "../../../../../src/api/worker/rest/EntityRestClient.js"
import { RestClient } from "../../../../../src/api/worker/rest/RestClient.js"
import type { CryptoFacade } from "../../../../../src/api/worker/crypto/CryptoFacade.js"
import { InstanceMapper } from "../../../../../src/api/worker/crypto/InstanceMapper.js"
import { func, instance, matchers, object, verify, when } from "testdouble"
import tutanotaModelInfo from "../../../../../src/api/entities/tutanota/ModelInfo.js"
import sysModelInfo from "../../../../../src/api/entities/sys/ModelInfo.js"
import { AuthDataProvider } from "../../../../../src/api/worker/facades/UserFacade.js"
import { LoginIncompleteError } from "../../../../../src/api/common/error/LoginIncompleteError.js"
import {
BlobServerAccessInfoTypeRef,
BlobServerUrlTypeRef,
createBlobServerAccessInfo,
createBlobServerUrl,
} from "../../../../../src/api/entities/storage/TypeRefs.js"
import { Mapper, ofClass } from "@tutao/tutanota-utils"
import { ProgrammingError } from "../../../../../src/api/common/error/ProgrammingError.js"
import { BlobAccessTokenFacade } from "../../../../../src/api/worker/facades/BlobAccessTokenFacade.js"
import {
CalendarEventTypeRef,
Contact,
ContactTypeRef,
createContact,
createInternalRecipientKeyData,
InternalRecipientKeyDataTypeRef,
MailDetailsBlob,
MailDetailsBlobTypeRef,
} from "../../../../../src/api/entities/tutanota/TypeRefs.js"
import { DateProvider } from "../../../../../src/api/common/DateProvider.js"
import { DefaultDateProvider } from "../../../../../src/calendar/date/CalendarUtils.js"
import { createTestEntity } from "../../../TestUtils.js"
const { anything, argThat } = matchers
const accessToken = "My cool access token"
const authHeader = {
accessToken: accessToken,
}
function createArrayOf<T>(count: number, factory: (index: number) => T): Array<T> {
return (
Array(count)
// @ts-ignore
.fill()
.map((_, idx) => factory(idx))
)
}
const countFrom = (start, count) => createArrayOf(count, (idx) => String(idx + start))
function contacts(count) {
const contactFactory = (idx) =>
createTestEntity(ContactTypeRef, {
firstName: `Contact${idx}`,
})
return createArrayOf(count, contactFactory)
}
o.spec("EntityRestClient", async function () {
let entityRestClient: EntityRestClient
let restClient: RestClient
let instanceMapperMock: InstanceMapper
let cryptoFacadeMock: CryptoFacade
let fullyLoggedIn: boolean
let blobAccessTokenFacade: BlobAccessTokenFacade
let dateProvider: DateProvider
o.beforeEach(function () {
cryptoFacadeMock = object()
when(cryptoFacadeMock.applyMigrations(anything(), anything())).thenDo(async (typeRef, data) => {
return Promise.resolve({ ...data, migrated: true })
})
when(cryptoFacadeMock.applyMigrationsForInstance(anything())).thenDo((decryptedInstance) => {
return Promise.resolve({ ...decryptedInstance, migratedForInstance: true })
})
when(cryptoFacadeMock.setNewOwnerEncSessionKey(anything(), anything())).thenResolve([])
when(cryptoFacadeMock.encryptBucketKeyForInternalRecipient(anything(), anything(), anything())).thenResolve(
createTestEntity(InternalRecipientKeyDataTypeRef),
)
when(cryptoFacadeMock.resolveSessionKey(anything(), anything())).thenResolve([])
instanceMapperMock = object()
when(instanceMapperMock.encryptAndMapToLiteral(anything(), anything(), anything())).thenDo((typeModel, instance, sessionKey) => {
return Promise.resolve({ ...instance, encrypted: true })
})
when(instanceMapperMock.decryptAndMapToInstance(anything(), anything(), anything())).thenDo((typeModel, migratedEntity, sessionKey) => {
return Promise.resolve({ ...migratedEntity, decrypted: true })
})
blobAccessTokenFacade = instance(BlobAccessTokenFacade)
restClient = object()
fullyLoggedIn = true
const authDataProvider: AuthDataProvider = {
createAuthHeaders(): Dict {
return authHeader
},
isFullyLoggedIn(): boolean {
return fullyLoggedIn
},
}
dateProvider = instance(DefaultDateProvider)
entityRestClient = new EntityRestClient(authDataProvider, restClient, () => cryptoFacadeMock, instanceMapperMock, blobAccessTokenFacade)
})
function assertThatNoRequestsWereMade() {
verify(restClient.request(anything(), anything()), { ignoreExtraArgs: true, times: 0 })
}
o.spec("Load", function () {
o("loading a list element", async function () {
const calendarListId = "calendarListId"
const id1 = "id1"
when(
restClient.request(`${typeRefToPath(CalendarEventTypeRef)}/${calendarListId}/${id1}`, HttpMethod.GET, {
headers: { ...authHeader, v: String(tutanotaModelInfo.version) },
responseType: MediaType.Json,
queryParams: undefined,
}),
).thenResolve(JSON.stringify({ instance: "calendar" }))
const result = await entityRestClient.load(CalendarEventTypeRef, [calendarListId, id1])
o(result as any).deepEquals({ instance: "calendar", decrypted: true, migrated: true, migratedForInstance: true })
})
o("loading an element ", async function () {
const id1 = "id1"
when(
restClient.request(`${typeRefToPath(CustomerTypeRef)}/${id1}`, HttpMethod.GET, {
headers: { ...authHeader, v: String(sysModelInfo.version) },
responseType: MediaType.Json,
queryParams: undefined,
}),
).thenResolve(JSON.stringify({ instance: "customer" }))
const result = await entityRestClient.load(CustomerTypeRef, id1)
o(result as any).deepEquals({ instance: "customer", decrypted: true, migrated: true, migratedForInstance: true })
})
o("query parameters and additional headers + access token and version are always passed to the rest client", async function () {
const calendarListId = "calendarListId"
const id1 = "id1"
when(
restClient.request(`${typeRefToPath(CalendarEventTypeRef)}/${calendarListId}/${id1}`, HttpMethod.GET, {
headers: { ...authHeader, v: String(tutanotaModelInfo.version), baz: "quux" },
responseType: MediaType.Json,
queryParams: { foo: "bar" },
}),
).thenResolve(JSON.stringify({ instance: "calendar" }))
await entityRestClient.load(CalendarEventTypeRef, [calendarListId, id1], { foo: "bar" }, { baz: "quux" })
})
o("when loading encrypted instance and not being logged in it throws an error", async function () {
fullyLoggedIn = false
await assertThrows(LoginIncompleteError, () => entityRestClient.load(CalendarEventTypeRef, ["listId", "id"]))
assertThatNoRequestsWereMade()
})
o("when ownerKey is passed it is used instead for session key resolution", async function () {
const calendarListId = "calendarListId"
const id1 = "id1"
when(
restClient.request(`${typeRefToPath(CalendarEventTypeRef)}/${calendarListId}/${id1}`, HttpMethod.GET, {
headers: { ...authHeader, v: String(tutanotaModelInfo.version) },
responseType: MediaType.Json,
queryParams: undefined,
}),
).thenResolve(JSON.stringify({ instance: "calendar" }))
const ownerKey = [1, 2, 3]
const sessionKey = [3, 2, 1]
when(cryptoFacadeMock.resolveSessionKeyWithOwnerKey(anything(), ownerKey)).thenReturn(sessionKey)
const result = await entityRestClient.load(CalendarEventTypeRef, [calendarListId, id1], undefined, undefined, ownerKey)
const typeModel = await resolveTypeReference(CalendarEventTypeRef)
verify(instanceMapperMock.decryptAndMapToInstance(typeModel, anything(), sessionKey))
verify(cryptoFacadeMock.resolveSessionKey(anything(), anything()), { times: 0 })
o(result as any).deepEquals({ instance: "calendar", decrypted: true, migrated: true, migratedForInstance: true })
})
})
o.spec("Load Range", function () {
o("Loads a countFrom of entities in a single request", async function () {
const startId = "42"
const count = 5
const listId = "listId"
when(
restClient.request(`${typeRefToPath(CalendarEventTypeRef)}/${listId}`, HttpMethod.GET, {
headers: { ...authHeader, v: String(tutanotaModelInfo.version) },
queryParams: { start: startId, count: String(count), reverse: String(false) },
responseType: MediaType.Json,
}),
).thenResolve(JSON.stringify([{ instance: 1 }, { instance: 2 }]))
const result = await entityRestClient.loadRange(CalendarEventTypeRef, listId, startId, count, false)
// There's some weird optimization for list requests where the types to migrate
// are hardcoded (e.g. PushIdentifier) for *vaguely gestures* optimization reasons.
o(result as any).deepEquals([
{ instance: 1, /*migrated: true,*/ decrypted: true, migratedForInstance: true },
{ instance: 2, /*migrated: true,*/ decrypted: true, migratedForInstance: true },
])
})
o("when loading encrypted instance list and not being logged in it throws an error", async function () {
fullyLoggedIn = false
await assertThrows(LoginIncompleteError, () => entityRestClient.loadRange(CalendarEventTypeRef, "listId", "startId", 40, false))
assertThatNoRequestsWereMade()
})
})
o.spec("Load multiple", function () {
o("Less than 100 entities requested should result in a single rest request", async function () {
const ids = countFrom(0, 5)
when(
restClient.request(`${typeRefToPath(CustomerTypeRef)}`, HttpMethod.GET, {
headers: { ...authHeader, v: String(sysModelInfo.version) },
queryParams: { ids: "0,1,2,3,4" },
responseType: MediaType.Json,
}),
).thenResolve(JSON.stringify([{ instance: 1 }, { instance: 2 }]))
const result = await entityRestClient.loadMultiple(CustomerTypeRef, null, ids)
// There's some weird optimization for list requests where the types to migrate
// are hardcoded (e.g. PushIdentifier) for *vaguely gestures* optimization reasons.
o(result as any).deepEquals([
{ instance: 1, /*migrated: true,*/ decrypted: true, migratedForInstance: true },
{ instance: 2, /*migrated: true,*/ decrypted: true, migratedForInstance: true },
])
})
o("Exactly 100 entities requested should result in a single rest request", async function () {
const ids = countFrom(0, 100)
when(
restClient.request(`${typeRefToPath(CustomerTypeRef)}`, HttpMethod.GET, {
headers: { ...authHeader, v: String(sysModelInfo.version) },
queryParams: { ids: ids.join(",") },
responseType: MediaType.Json,
}),
{ times: 1 },
).thenResolve(JSON.stringify([{ instance: 1 }, { instance: 2 }]))
const result = await entityRestClient.loadMultiple(CustomerTypeRef, null, ids)
// There's some weird optimization for list requests where the types to migrate
// are hardcoded (e.g. PushIdentifier) for *vaguely gestures* optimization reasons.
o(result as any).deepEquals([
{ instance: 1, /*migrated: true,*/ decrypted: true, migratedForInstance: true },
{ instance: 2, /*migrated: true,*/ decrypted: true, migratedForInstance: true },
])
})
o("More than 100 entities requested results in 2 rest requests", async function () {
const ids = countFrom(0, 101)
when(
restClient.request(`${typeRefToPath(CustomerTypeRef)}`, HttpMethod.GET, {
headers: { ...authHeader, v: String(sysModelInfo.version) },
queryParams: { ids: countFrom(0, 100).join(",") },
responseType: MediaType.Json,
}),
{ times: 1 },
).thenResolve(JSON.stringify([{ instance: 1 }]))
when(
restClient.request(`${typeRefToPath(CustomerTypeRef)}`, HttpMethod.GET, {
headers: { ...authHeader, v: String(sysModelInfo.version) },
queryParams: { ids: "100" },
responseType: MediaType.Json,
}),
{ times: 1 },
).thenResolve(JSON.stringify([{ instance: 2 }]))
const result = await entityRestClient.loadMultiple(CustomerTypeRef, null, ids)
o(result as any).deepEquals([
{ instance: 1, /*migrated: true,*/ decrypted: true, migratedForInstance: true },
{ instance: 2, /*migrated: true,*/ decrypted: true, migratedForInstance: true },
])
})
o("More than 200 entities requested results in 3 rest requests", async function () {
const ids = countFrom(0, 211)
when(
restClient.request(typeRefToPath(CustomerTypeRef), HttpMethod.GET, {
headers: { ...authHeader, v: String(sysModelInfo.version) },
queryParams: { ids: countFrom(0, 100).join(",") },
responseType: MediaType.Json,
}),
{ times: 1 },
).thenResolve(JSON.stringify([{ instance: 1 }]))
when(
restClient.request(typeRefToPath(CustomerTypeRef), HttpMethod.GET, {
headers: { ...authHeader, v: String(sysModelInfo.version) },
queryParams: { ids: countFrom(100, 100).join(",") },
responseType: MediaType.Json,
}),
{ times: 1 },
).thenResolve(JSON.stringify([{ instance: 2 }]))
when(
restClient.request(typeRefToPath(CustomerTypeRef), HttpMethod.GET, {
headers: { ...authHeader, v: String(sysModelInfo.version) },
queryParams: { ids: countFrom(200, 11).join(",") },
responseType: MediaType.Json,
}),
{ times: 1 },
).thenResolve(JSON.stringify([{ instance: 3 }]))
const result = await entityRestClient.loadMultiple(CustomerTypeRef, null, ids)
o(result as any).deepEquals([
{ instance: 1, /*migrated: true,*/ decrypted: true, migratedForInstance: true },
{ instance: 2, /*migrated: true,*/ decrypted: true, migratedForInstance: true },
{ instance: 3, /*migrated: true,*/ decrypted: true, migratedForInstance: true },
])
})
o("when loading encrypted instance list and not being logged in it throws an error", async function () {
fullyLoggedIn = false
await assertThrows(LoginIncompleteError, () => entityRestClient.loadMultiple(CalendarEventTypeRef, "listId", ["startId", "anotherId"]))
assertThatNoRequestsWereMade()
})
o("when loading blob elements a blob access token is requested and the correct headers and parameters are set", async function () {
const ids = countFrom(0, 5)
const archiveId = "archiveId"
const firstServer = "firstServer"
const blobAccessToken = "123"
let blobServerAccessInfo = createTestEntity(BlobServerAccessInfoTypeRef, {
blobAccessToken,
servers: [createTestEntity(BlobServerUrlTypeRef, { url: firstServer }), createTestEntity(BlobServerUrlTypeRef, { url: "otherServer" })],
})
when(blobAccessTokenFacade.requestReadTokenArchive(archiveId)).thenResolve(blobServerAccessInfo)
when(blobAccessTokenFacade.createQueryParams(blobServerAccessInfo, anything(), anything())).thenDo((blobServerAccessInfo, authHeaders) => {
return Object.assign({ blobAccessToken: blobServerAccessInfo.blobAccessToken }, authHeaders)
})
when(restClient.request(anything(), HttpMethod.GET, anything())).thenResolve(JSON.stringify([{ instance: 1 }, { instance: 2 }]))
const result = await entityRestClient.loadMultiple(MailDetailsBlobTypeRef, archiveId, ids)
let expectedOptions = {
headers: {},
queryParams: { ids: "0,1,2,3,4", ...authHeader, blobAccessToken, v: String(tutanotaModelInfo.version) },
responseType: MediaType.Json,
noCORS: true,
baseUrl: firstServer,
}
verify(
restClient.request(
`${typeRefToPath(MailDetailsBlobTypeRef)}/${archiveId}`,
HttpMethod.GET,
argThat((optionsArg) => {
o(optionsArg.headers).deepEquals(expectedOptions.headers)("headers")
o(optionsArg.responseType).equals(expectedOptions.responseType)("responseType")
o(optionsArg.baseUrl).equals(expectedOptions.baseUrl)("baseUrl")
o(optionsArg.noCORS).equals(expectedOptions.noCORS)("noCORS")
o(optionsArg.queryParams).deepEquals({
blobAccessToken: "123",
...authHeader,
ids: "0,1,2,3,4",
v: String(tutanotaModelInfo.version),
})
return true
}),
),
)
// There's some weird optimization for list requests where the types to migrate
// are hardcoded (e.g. PushIdentifier) for *vaguely gestures* optimization reasons.
o(result as any).deepEquals([
{ instance: 1, /*migrated: true,*/ decrypted: true, migratedForInstance: true },
{ instance: 2, /*migrated: true,*/ decrypted: true, migratedForInstance: true },
])
})
o("when loading blob elements request is retried with another server url if it failed", async function () {
const ids = countFrom(0, 5)
const archiveId = "archiveId"
const firstServer = "firstServer"
const blobAccessToken = "123"
const otherServer = "otherServer"
const blobServerAccessInfo = createTestEntity(BlobServerAccessInfoTypeRef, {
blobAccessToken,
servers: [createTestEntity(BlobServerUrlTypeRef, { url: firstServer }), createTestEntity(BlobServerUrlTypeRef, { url: otherServer })],
})
when(blobAccessTokenFacade.requestReadTokenArchive(archiveId)).thenResolve(blobServerAccessInfo)
when(blobAccessTokenFacade.createQueryParams(blobServerAccessInfo, anything(), anything())).thenDo((blobServerAccessInfo, authHeaders) => {
return Object.assign({ blobAccessToken: blobServerAccessInfo.blobAccessToken }, authHeaders)
})
when(
restClient.request(anything(), HttpMethod.GET, {
headers: {},
queryParams: { ids: "0,1,2,3,4", ...authHeader, blobAccessToken, v: String(tutanotaModelInfo.version) },
responseType: MediaType.Json,
noCORS: true,
baseUrl: firstServer,
}),
).thenReject(new ConnectionError("test connection error for retry"))
when(
restClient.request(anything(), HttpMethod.GET, {
headers: {},
queryParams: { ids: "0,1,2,3,4", ...authHeader, blobAccessToken, v: String(tutanotaModelInfo.version) },
responseType: MediaType.Json,
noCORS: true,
baseUrl: otherServer,
}),
).thenResolve(JSON.stringify([{ instance: 1 }, { instance: 2 }]))
const result = await entityRestClient.loadMultiple(MailDetailsBlobTypeRef, archiveId, ids)
verify(restClient.request(`${typeRefToPath(MailDetailsBlobTypeRef)}/${archiveId}`, HttpMethod.GET, anything()), { times: 2 })
// There's some weird optimization for list requests where the types to migrate
// are hardcoded (e.g. PushIdentifier) for *vaguely gestures* optimization reasons.
o(result as any).deepEquals([
{ instance: 1, /*migrated: true,*/ decrypted: true, migratedForInstance: true },
{ instance: 2, /*migrated: true,*/ decrypted: true, migratedForInstance: true },
])
})
o("when loading blob elements without an archiveId it throws", async function () {
const ids = countFrom(0, 5)
const archiveId = null
let result: Array<MailDetailsBlob> | null = null
try {
result = await entityRestClient.loadMultiple(MailDetailsBlobTypeRef, archiveId, ids)
o(true).equals(false)("loadMultiple should have thrown an exception")
} catch (e) {
o(e.message).equals("archiveId must be set to load BlobElementTypes")
}
verify(restClient.request(anything(), anything(), anything()), { times: 0 })
verify(blobAccessTokenFacade.requestReadTokenArchive(anything()), { times: 0 })
o(result).equals(null)
})
})
o.spec("Setup", async function () {
o("Setup list entity", async function () {
const v = (await resolveTypeReference(ContactTypeRef)).version
const newContact = createTestEntity(ContactTypeRef)
const resultId = "id"
when(
restClient.request(`/rest/tutanota/contact/listId`, HttpMethod.POST, {
baseUrl: undefined,
headers: { ...authHeader, v },
queryParams: undefined,
responseType: MediaType.Json,
body: JSON.stringify({ ...newContact, encrypted: true }),
}),
{ times: 1 },
).thenResolve(JSON.stringify({ generatedId: resultId }))
const result = await entityRestClient.setup("listId", newContact)
o(result).equals(resultId)
})
o("Setup list entity throws when no listid is passed", async function () {
const newContact = createTestEntity(ContactTypeRef)
const result = await assertThrows(Error, async () => await entityRestClient.setup(null, newContact))
o(result.message).equals("List id must be defined for LETs")
})
o("Setup entity", async function () {
const v = (await resolveTypeReference(CustomerTypeRef)).version
const newCustomer = createTestEntity(CustomerTypeRef)
const resultId = "id"
when(
restClient.request(`/rest/sys/customer`, HttpMethod.POST, {
baseUrl: undefined,
headers: { ...authHeader, v },
queryParams: undefined,
responseType: MediaType.Json,
body: JSON.stringify({ ...newCustomer, encrypted: true }),
}),
{ times: 1 },
).thenResolve(JSON.stringify({ generatedId: resultId }))
const result = await entityRestClient.setup(null, newCustomer)
o(result).equals(resultId)
})
o("Setup entity throws when listid is passed", async function () {
const newCustomer = createTestEntity(CustomerTypeRef)
const result = await assertThrows(Error, async () => await entityRestClient.setup("listId", newCustomer))
o(result.message).equals("List id must not be defined for ETs")
})
o("Base URL option is passed to the rest client", async function () {
when(restClient.request(anything(), anything(), anything()), { times: 1 }).thenResolve(JSON.stringify({ generatedId: null }))
await entityRestClient.setup("listId", createTestEntity(ContactTypeRef), undefined, { baseUrl: "some url" })
verify(
restClient.request(
anything(),
HttpMethod.POST,
argThat((arg) => arg.baseUrl === "some url"),
),
)
})
o("when ownerKey is passed it is used instead for session key resolution", async function () {
const typeModel = await resolveTypeReference(CustomerTypeRef)
const v = typeModel.version
const newCustomer = createTestEntity(CustomerTypeRef)
const resultId = "id"
when(
restClient.request(`/rest/sys/customer`, HttpMethod.POST, {
baseUrl: undefined,
headers: { ...authHeader, v },
queryParams: undefined,
responseType: MediaType.Json,
body: JSON.stringify({ ...newCustomer, encrypted: true }),
}),
{ times: 1 },
).thenResolve(JSON.stringify({ generatedId: resultId }))
const ownerKey = [1, 2, 3]
const sessionKey = [3, 2, 1]
when(cryptoFacadeMock.setNewOwnerEncSessionKey(typeModel, anything(), ownerKey)).thenReturn(sessionKey)
const result = await entityRestClient.setup(null, newCustomer, undefined, { ownerKey })
verify(instanceMapperMock.encryptAndMapToLiteral(anything(), anything(), sessionKey))
verify(cryptoFacadeMock.resolveSessionKey(anything(), anything()), { times: 0 })
o(result).equals(resultId)
})
})
o.spec("Setup multiple", async function () {
o("Less than 100 entities created should result in a single rest request", async function () {
const newContacts = contacts(1)
const resultId = "id1"
const { version } = await resolveTypeReference(ContactTypeRef)
when(
restClient.request(`/rest/tutanota/contact/listId`, HttpMethod.POST, {
headers: { ...authHeader, v: version },
queryParams: { count: "1" },
responseType: MediaType.Json,
body: JSON.stringify([{ ...newContacts[0], encrypted: true }]),
}),
{ times: 1 },
).thenResolve(JSON.stringify([{ generatedId: resultId }]))
const result = await entityRestClient.setupMultiple("listId", newContacts)
o(result).deepEquals([resultId])
})
o("Exactly 100 entities created should result in a single rest request", async function () {
const newContacts = contacts(100)
const resultIds = countFrom(0, 100).map(String)
const { version } = await resolveTypeReference(ContactTypeRef)
when(
restClient.request(`/rest/tutanota/contact/listId`, HttpMethod.POST, {
headers: { ...authHeader, v: version },
queryParams: { count: "100" },
responseType: MediaType.Json,
body: JSON.stringify(
newContacts.map((c) => {
return { ...c, encrypted: true }
}),
),
}),
{ times: 1 },
).thenResolve(
JSON.stringify(
resultIds.map((id) => {
return { generatedId: id }
}),
),
)
const result = await entityRestClient.setupMultiple("listId", newContacts)
o(result).deepEquals(resultIds)
})
o("More than 100 entities created should result in 2 rest requests", async function () {
const newContacts = contacts(101)
const resultIds = countFrom(0, 101).map(String)
const { version } = await resolveTypeReference(ContactTypeRef)
when(
restClient.request(`/rest/tutanota/contact/listId`, HttpMethod.POST, {
headers: { ...authHeader, v: version },
queryParams: { count: "100" },
responseType: MediaType.Json,
body: JSON.stringify(
newContacts.slice(0, 100).map((c) => {
return { ...c, encrypted: true }
}),
),
}),
{ times: 1 },
).thenResolve(
JSON.stringify(
resultIds.slice(0, 100).map((id) => {
return { generatedId: id }
}),
),
)
when(
restClient.request(`/rest/tutanota/contact/listId`, HttpMethod.POST, {
headers: { ...authHeader, v: version },
queryParams: { count: "1" },
responseType: MediaType.Json,
body: JSON.stringify(
newContacts.slice(100).map((c) => {
return { ...c, encrypted: true }
}),
),
}),
{ times: 1 },
).thenResolve(
JSON.stringify(
resultIds.slice(100).map((id) => {
return { generatedId: id }
}),
),
)
const result = await entityRestClient.setupMultiple("listId", newContacts)
o(result).deepEquals(resultIds)
})
o("More than 200 entities created should result in 3 rest requests", async function () {
const newContacts = contacts(211)
const resultIds = countFrom(0, 211).map(String)
const { version } = await resolveTypeReference(ContactTypeRef)
when(
restClient.request(`/rest/tutanota/contact/listId`, HttpMethod.POST, {
headers: { ...authHeader, v: version },
queryParams: { count: "100" },
responseType: MediaType.Json,
body: JSON.stringify(
newContacts.slice(0, 100).map((c) => {
return { ...c, encrypted: true }
}),
),
}),
{ times: 1 },
).thenResolve(
JSON.stringify(
resultIds.slice(0, 100).map((id) => {
return { generatedId: id }
}),
),
)
when(
restClient.request(`/rest/tutanota/contact/listId`, HttpMethod.POST, {
headers: { ...authHeader, v: version },
queryParams: { count: "100" },
responseType: MediaType.Json,
body: JSON.stringify(
newContacts.slice(100, 200).map((c) => {
return { ...c, encrypted: true }
}),
),
}),
{ times: 1 },
).thenResolve(
JSON.stringify(
resultIds.slice(100, 200).map((id) => {
return { generatedId: id }
}),
),
)
when(
restClient.request(`/rest/tutanota/contact/listId`, HttpMethod.POST, {
headers: { ...authHeader, v: version },
queryParams: { count: "11" },
responseType: MediaType.Json,
body: JSON.stringify(
newContacts.slice(200).map((c) => {
return { ...c, encrypted: true }
}),
),
}),
{ times: 1 },
).thenResolve(
JSON.stringify(
resultIds.slice(200).map((id) => {
return { generatedId: id }
}),
),
)
const result = await entityRestClient.setupMultiple("listId", newContacts)
o(result).deepEquals(resultIds)
})
o("A single request is made and an error occurs, all entities should be returned as failedInstances", async function () {
when(restClient.request(anything(), anything(), anything())).thenReject(new BadRequestError("canny do et"))
const newContacts = contacts(100)
const result = await assertThrows(SetupMultipleError, () => entityRestClient.setupMultiple("listId", newContacts))
o(result.failedInstances.length).equals(newContacts.length)
o(result.errors.length).equals(1)
o(result.errors[0] instanceof BadRequestError).equals(true)
o(result.failedInstances).deepEquals(newContacts)
})
o("Post multiple: An error is encountered for part of the request, only failed entities are returned in the result", async function () {
let requestCounter = 0
when(restClient.request(anything(), anything(), anything())).thenDo(() => {
requestCounter += 1
if (requestCounter % 2 === 0) {
// Second and Fourth requests are success
return JSON.stringify(
countFrom(0, 100).map((c) => {
return { generatedId: c }
}),
)
} else {
// First and Third requests are failure
throw new BadRequestError("It was a bad request")
}
})
const newContacts = contacts(400)
const result = await assertThrows(SetupMultipleError, () => entityRestClient.setupMultiple("listId", newContacts))
verify(restClient.request(anything(), anything()), { times: 4, ignoreExtraArgs: true })
o(result.failedInstances).deepEquals(newContacts.slice(0, 100).concat(newContacts.slice(200, 300)))
o(result.errors.length).equals(2)
o(result.errors.every((e) => e instanceof BadRequestError)).equals(true)
})
o("Post multiple: When a PayloadTooLarge error occurs individual instances are posted", async function () {
const listId = "listId"
const idArray = ["0", null, "2"] // GET fails for id 1
let instances: Contact[] = []
for (let i = 0; i < idArray.length; i++) {
instances.push(createTestEntity(ContactTypeRef))
}
let step = 0
when(restClient.request(anything(), anything(), anything())).thenDo((path: string, method: HttpMethod, { body }) => {
//post multiple - body is an array
if (body && body.startsWith("[")) {
throw new PayloadTooLargeError("test") //post single
} else if (step === 1) {
step += 1
throw new InternalServerError("might happen")
} else {
return JSON.stringify(idArray[step++])
}
})
const result = await assertThrows(SetupMultipleError, async () => {
return await entityRestClient.setupMultiple(listId, instances)
})
//one post multiple and three individual posts
verify(restClient.request(anything(), anything()), { ignoreExtraArgs: true, times: 4 })
o(result.failedInstances.length).equals(1) //one individual post results in an error
o(result.errors.length).equals(1)
o(result.errors[0] instanceof InternalServerError).equals(true)
o(result.failedInstances).deepEquals([instances[1]])
})
})
o.spec("Update", function () {
o("Update entity", async function () {
const { version } = await resolveTypeReference(CustomerTypeRef)
const newCustomer = createTestEntity(CustomerTypeRef, {
_id: "id",
})
when(
restClient.request("/rest/sys/customer/id", HttpMethod.PUT, {
headers: { ...authHeader, v: version },
body: JSON.stringify({ ...newCustomer, encrypted: true }),
}),
)
await entityRestClient.update(newCustomer)
})
o("Update entity throws if entity does not have an id", async function () {
const newCustomer = createTestEntity(CustomerTypeRef)
const result = await assertThrows(Error, async () => await entityRestClient.update(newCustomer))
o(result.message).equals("Id must be defined")
})
o("when ownerKey is passed it is used instead for session key resolution", async function () {
const typeModel = await resolveTypeReference(CustomerTypeRef)
const version = typeModel.version
const newCustomer = createTestEntity(CustomerTypeRef, {
_id: "id",
})
when(
restClient.request("/rest/sys/customer/id", HttpMethod.PUT, {
headers: { ...authHeader, v: version },
body: JSON.stringify({ ...newCustomer, encrypted: true }),
}),
)
const ownerKey = [1, 2, 3]
const sessionKey = [3, 2, 1]
when(cryptoFacadeMock.resolveSessionKeyWithOwnerKey(anything(), ownerKey)).thenReturn(sessionKey)
await entityRestClient.update(newCustomer, ownerKey)
verify(instanceMapperMock.encryptAndMapToLiteral(anything(), anything(), sessionKey))
verify(cryptoFacadeMock.resolveSessionKey(anything(), anything()), { times: 0 })
})
})
o.spec("Delete", function () {
o("Delete entity", async function () {
const { version } = await resolveTypeReference(CustomerTypeRef)
const id = "id"
const newCustomer = createTestEntity(CustomerTypeRef, {
_id: id,
})
when(
restClient.request("/rest/sys/customer/id", HttpMethod.DELETE, {
headers: { ...authHeader, v: version },
}),
)
await entityRestClient.erase(newCustomer)
})
})
o.spec("tryServers", function () {
o("tryServers successful", async function () {
let servers = [createTestEntity(BlobServerUrlTypeRef, { url: "w1" }), createTestEntity(BlobServerUrlTypeRef, { url: "w2" })]
const mapperMock = func<Mapper<string, object>>()
const expectedResult = { response: "response-from-server" }
when(mapperMock(anything(), anything())).thenResolve(expectedResult)
const result = await tryServers(servers, mapperMock, "error")
o(result).equals(expectedResult)
verify(mapperMock("w1", 0), { times: 1 })
verify(mapperMock("w2", 1), { times: 0 })
})
o("tryServers error", async function () {
let servers = [createTestEntity(BlobServerUrlTypeRef, { url: "w1" }), createTestEntity(BlobServerUrlTypeRef, { url: "w2" })]
const mapperMock = func<Mapper<string, object>>()
when(mapperMock("w1", 0)).thenReject(new ProgrammingError("test"))
const e = await assertThrows(ProgrammingError, () => tryServers(servers, mapperMock, "error"))
o(e.message).equals("test")
verify(mapperMock(anything(), anything()), { times: 1 })
})
o("tryServers ConnectionError and successful response", async function () {
let servers = [createTestEntity(BlobServerUrlTypeRef, { url: "w1" }), createTestEntity(BlobServerUrlTypeRef, { url: "w2" })]
const mapperMock = func<Mapper<string, object>>()
const expectedResult = { response: "response-from-server" }
when(mapperMock("w1", 0)).thenReject(new ConnectionError("test"))
when(mapperMock("w2", 1)).thenResolve(expectedResult)
const result = await tryServers(servers, mapperMock, "error")
o(result).deepEquals(expectedResult)
verify(mapperMock(anything(), anything()), { times: 2 })
})
o("tryServers multiple ConnectionError", async function () {
let servers = [createTestEntity(BlobServerUrlTypeRef, { url: "w1" }), createTestEntity(BlobServerUrlTypeRef, { url: "w2" })]
const mapperMock = func<Mapper<string, object>>()
when(mapperMock("w1", 0)).thenReject(new ConnectionError("test"))
when(mapperMock("w2", 1)).thenReject(new ConnectionError("test"))
const e = await assertThrows(ConnectionError, () => tryServers(servers, mapperMock, "error log msg"))
o(e.message).equals("test")
verify(mapperMock(anything(), anything()), { times: 2 })
})
})
o.spec("doBlobRequestWithRetry", function () {
o("retry once after NotAuthorizedError, then fails", async function () {
let blobRequestCallCount = 0
let evictCacheCallCount = 0
let errorThrown = 0
const doBlobRequest = async () => {
blobRequestCallCount += 1
throw new NotAuthorizedError("test error")
}
const evictCache = () => {
evictCacheCallCount += 1
}
await doBlobRequestWithRetry(doBlobRequest, evictCache).catch(
ofClass(NotAuthorizedError, (e) => {
errorThrown += 1 // must be thrown
}),
)
o(errorThrown).equals(1)
o(blobRequestCallCount).equals(2)
o(evictCacheCallCount).equals(1)
})
o("retry once after NotAuthorizedError, then succeeds", async function () {
let blobRequestCallCount = 0
let evictCacheCallCount = 0
const doBlobRequest = async () => {
//only throw on first call
if (blobRequestCallCount === 0) {
blobRequestCallCount += 1
throw new NotAuthorizedError("test error")
}
}
const evictCache = () => {
evictCacheCallCount += 1
}
await doBlobRequestWithRetry(doBlobRequest, evictCache)
o(blobRequestCallCount).equals(1)
o(evictCacheCallCount).equals(1)
})
})
})