tutanota/test/tests/api/worker/rest/EntityRestClientTest.ts

945 lines
36 KiB
TypeScript
Raw Normal View History

2023-06-29 18:26:45 +02:00
import o from "@tutao/otest"
import {
BadRequestError,
ConnectionError,
InternalServerError,
NotAuthorizedError,
PayloadTooLargeError,
} from "../../../../../src/api/common/error/RestError.js"
2022-12-27 15:37:40 +01:00
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"
2022-12-27 15:37:40 +01:00
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"
2022-12-27 15:37:40 +01:00
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"
Rewrite calendar editor and calendar popup make updating calendar events delete the old uidIndex correctly when updating calendar events, we sometimes had events in the delete call that do not have the hashedUid set, causing us to be unable to delete the uid index entry. when re-creating the event, that leads to a db.exists error. make sure the event popup always has the current version of the event. now that edit operations are possible from the popup, we either need to close the popup after it calls the model factory to make sure it's only called once, or make sure the model factory always uses the last version of the event. we opted for the first option here to make sure repeated changes to the attendance are actually sent as a response. make contact resolution failure more controlled for external users previously, responding to an event from an external mailbox would try to resolve a contact for the response mail, fail, try to create a contact and fail again on an opaque assertNotNull. show partial editability banner when creating new event in shared calendar make it possible to add alarms to invites in private calendars don't make saving/sending invites and cancellations depend on user choice for own events updateExistingEvent() should call sendNotifications/saveEvent even when there are no update worthy changes or the user did not tick the sendUpdates checkbox because invites/cancellations must be sent in any case and sending updates has a separate check for sendUpdates also make the sendUpdates button on the popup use the same logic as the shortcut when clicked (ask confirmation) Co-authored-by: nig <nig@tutao.de>
2023-04-25 16:54:46 +02:00
import { DefaultDateProvider } from "../../../../../src/calendar/date/CalendarUtils.js"
import { createTestEntity } from "../../../TestUtils.js"
2022-12-27 15:37:40 +01:00
const { anything, argThat } = matchers
2021-12-15 16:07:07 +01:00
const accessToken = "My cool access token"
const authHeader = {
accessToken: accessToken,
}
function createArrayOf<T>(count: number, factory: (index: number) => T): Array<T> {
2022-12-27 15:37:40 +01:00
return (
Array(count)
// @ts-ignore
.fill()
.map((_, idx) => factory(idx))
)
}
2022-12-27 15:37:40 +01:00
const countFrom = (start, count) => createArrayOf(count, (idx) => String(idx + start))
function contacts(count) {
2022-12-27 15:37:40 +01:00
const contactFactory = (idx) =>
createTestEntity(ContactTypeRef, {
2022-01-13 13:24:37 +01:00
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) => {
2022-12-27 15:37:40 +01:00
return Promise.resolve({ ...data, migrated: true })
})
when(cryptoFacadeMock.applyMigrationsForInstance(anything())).thenDo((decryptedInstance) => {
2022-12-27 15:37:40 +01:00
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()
2022-12-27 15:37:40 +01:00
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
},
}
Rewrite calendar editor and calendar popup make updating calendar events delete the old uidIndex correctly when updating calendar events, we sometimes had events in the delete call that do not have the hashedUid set, causing us to be unable to delete the uid index entry. when re-creating the event, that leads to a db.exists error. make sure the event popup always has the current version of the event. now that edit operations are possible from the popup, we either need to close the popup after it calls the model factory to make sure it's only called once, or make sure the model factory always uses the last version of the event. we opted for the first option here to make sure repeated changes to the attendance are actually sent as a response. make contact resolution failure more controlled for external users previously, responding to an event from an external mailbox would try to resolve a contact for the response mail, fail, try to create a contact and fail again on an opaque assertNotNull. show partial editability banner when creating new event in shared calendar make it possible to add alarms to invites in private calendars don't make saving/sending invites and cancellations depend on user choice for own events updateExistingEvent() should call sendNotifications/saveEvent even when there are no update worthy changes or the user did not tick the sendUpdates checkbox because invites/cancellations must be sent in any case and sending updates has a separate check for sendUpdates also make the sendUpdates button on the popup use the same logic as the shortcut when clicked (ask confirmation) Co-authored-by: nig <nig@tutao.de>
2023-04-25 16:54:46 +02:00
dateProvider = instance(DefaultDateProvider)
entityRestClient = new EntityRestClient(authDataProvider, restClient, () => cryptoFacadeMock, instanceMapperMock, blobAccessTokenFacade)
})
function assertThatNoRequestsWereMade() {
2022-12-27 15:37:40 +01:00
verify(restClient.request(anything(), anything()), { ignoreExtraArgs: true, times: 0 })
}
2021-12-15 16:07:07 +01:00
o.spec("Load", function () {
o("loading a list element", async function () {
const calendarListId = "calendarListId"
const id1 = "id1"
2022-12-27 15:37:40 +01:00
when(
restClient.request(`${typeRefToPath(CalendarEventTypeRef)}/${calendarListId}/${id1}`, HttpMethod.GET, {
headers: { ...authHeader, v: String(tutanotaModelInfo.version) },
responseType: MediaType.Json,
queryParams: undefined,
2022-12-27 15:37:40 +01:00
}),
).thenResolve(JSON.stringify({ instance: "calendar" }))
2021-12-15 16:07:07 +01:00
const result = await entityRestClient.load(CalendarEventTypeRef, [calendarListId, id1])
2022-12-27 15:37:40 +01:00
o(result as any).deepEquals({ instance: "calendar", decrypted: true, migrated: true, migratedForInstance: true })
2021-12-15 16:07:07 +01:00
})
2021-12-15 16:07:07 +01:00
o("loading an element ", async function () {
const id1 = "id1"
2022-12-27 15:37:40 +01:00
when(
restClient.request(`${typeRefToPath(CustomerTypeRef)}/${id1}`, HttpMethod.GET, {
headers: { ...authHeader, v: String(sysModelInfo.version) },
responseType: MediaType.Json,
queryParams: undefined,
2022-12-27 15:37:40 +01:00
}),
).thenResolve(JSON.stringify({ instance: "customer" }))
2021-12-15 16:07:07 +01:00
const result = await entityRestClient.load(CustomerTypeRef, id1)
2022-12-27 15:37:40 +01:00
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 () {
2022-12-27 15:37:40 +01:00
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"
2022-12-27 15:37:40 +01:00
when(
restClient.request(`${typeRefToPath(CalendarEventTypeRef)}/${calendarListId}/${id1}`, HttpMethod.GET, {
headers: { ...authHeader, v: String(tutanotaModelInfo.version) },
responseType: MediaType.Json,
queryParams: undefined,
2022-12-27 15:37:40 +01:00
}),
).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))
2022-12-27 15:37:40 +01:00
verify(cryptoFacadeMock.resolveSessionKey(anything(), anything()), { times: 0 })
o(result as any).deepEquals({ instance: "calendar", decrypted: true, migrated: true, migratedForInstance: true })
})
})
2021-12-15 16:07:07 +01:00
o.spec("Load Range", function () {
o("Loads a countFrom of entities in a single request", async function () {
2021-12-15 16:07:07 +01:00
const startId = "42"
const count = 5
const listId = "listId"
2022-12-27 15:37:40 +01:00
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,
2022-12-27 15:37:40 +01:00
}),
).thenResolve(JSON.stringify([{ instance: 1 }, { instance: 2 }]))
const result = await entityRestClient.loadRange(CalendarEventTypeRef, listId, startId, count, false)
2022-03-14 10:16:41 +01:00
// 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([
2022-12-27 15:37:40 +01:00
{ instance: 1, /*migrated: true,*/ decrypted: true, migratedForInstance: true },
{ instance: 2, /*migrated: true,*/ decrypted: true, migratedForInstance: true },
])
2021-12-15 16:07:07 +01:00
})
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()
})
2021-12-15 16:07:07 +01:00
})
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)
2022-12-27 15:37:40 +01:00
when(
restClient.request(`${typeRefToPath(CustomerTypeRef)}`, HttpMethod.GET, {
headers: { ...authHeader, v: String(sysModelInfo.version) },
queryParams: { ids: "0,1,2,3,4" },
responseType: MediaType.Json,
2022-12-27 15:37:40 +01:00
}),
).thenResolve(JSON.stringify([{ instance: 1 }, { instance: 2 }]))
const result = await entityRestClient.loadMultiple(CustomerTypeRef, null, ids)
2022-03-14 10:16:41 +01:00
// 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([
2022-12-27 15:37:40 +01:00
{ 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)
2022-12-27 15:37:40 +01:00
when(
restClient.request(`${typeRefToPath(CustomerTypeRef)}`, HttpMethod.GET, {
headers: { ...authHeader, v: String(sysModelInfo.version) },
queryParams: { ids: ids.join(",") },
responseType: MediaType.Json,
2022-12-27 15:37:40 +01:00
}),
{ times: 1 },
).thenResolve(JSON.stringify([{ instance: 1 }, { instance: 2 }]))
const result = await entityRestClient.loadMultiple(CustomerTypeRef, null, ids)
2022-03-14 10:16:41 +01:00
// 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([
2022-12-27 15:37:40 +01:00
{ 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)
2022-12-27 15:37:40 +01:00
when(
restClient.request(`${typeRefToPath(CustomerTypeRef)}`, HttpMethod.GET, {
headers: { ...authHeader, v: String(sysModelInfo.version) },
queryParams: { ids: countFrom(0, 100).join(",") },
responseType: MediaType.Json,
2022-12-27 15:37:40 +01:00
}),
{ 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,
2022-12-27 15:37:40 +01:00
}),
{ times: 1 },
).thenResolve(JSON.stringify([{ instance: 2 }]))
const result = await entityRestClient.loadMultiple(CustomerTypeRef, null, ids)
o(result as any).deepEquals([
2022-12-27 15:37:40 +01:00
{ 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)
2022-12-27 15:37:40 +01:00
when(
restClient.request(typeRefToPath(CustomerTypeRef), HttpMethod.GET, {
headers: { ...authHeader, v: String(sysModelInfo.version) },
queryParams: { ids: countFrom(0, 100).join(",") },
responseType: MediaType.Json,
2022-12-27 15:37:40 +01:00
}),
{ 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,
2022-12-27 15:37:40 +01:00
}),
{ 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,
2022-12-27 15:37:40 +01:00
}),
{ times: 1 },
).thenResolve(JSON.stringify([{ instance: 3 }]))
const result = await entityRestClient.loadMultiple(CustomerTypeRef, null, ids)
o(result as any).deepEquals([
2022-12-27 15:37:40 +01:00
{ 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)
})
})
2021-12-15 16:07:07 +01:00
o.spec("Setup", async function () {
o("Setup list entity", async function () {
const v = (await resolveTypeReference(ContactTypeRef)).version
const newContact = createTestEntity(ContactTypeRef)
const resultId = "id"
2022-12-27 15:37:40 +01:00
when(
restClient.request(`/rest/tutanota/contact/listId`, HttpMethod.POST, {
baseUrl: undefined,
2022-12-27 15:37:40 +01:00
headers: { ...authHeader, v },
queryParams: undefined,
responseType: MediaType.Json,
2022-12-27 15:37:40 +01:00
body: JSON.stringify({ ...newContact, encrypted: true }),
}),
{ times: 1 },
).thenResolve(JSON.stringify({ generatedId: resultId }))
2021-12-15 16:07:07 +01:00
const result = await entityRestClient.setup("listId", newContact)
o(result).equals(resultId)
2021-12-15 16:07:07 +01:00
})
2021-12-15 16:07:07 +01:00
o("Setup list entity throws when no listid is passed", async function () {
const newContact = createTestEntity(ContactTypeRef)
2021-12-15 16:07:07 +01:00
const result = await assertThrows(Error, async () => await entityRestClient.setup(null, newContact))
o(result.message).equals("List id must be defined for LETs")
})
2021-12-15 16:07:07 +01:00
o("Setup entity", async function () {
const v = (await resolveTypeReference(CustomerTypeRef)).version
const newCustomer = createTestEntity(CustomerTypeRef)
const resultId = "id"
2022-12-27 15:37:40 +01:00
when(
restClient.request(`/rest/sys/customer`, HttpMethod.POST, {
baseUrl: undefined,
2022-12-27 15:37:40 +01:00
headers: { ...authHeader, v },
queryParams: undefined,
responseType: MediaType.Json,
2022-12-27 15:37:40 +01:00
body: JSON.stringify({ ...newCustomer, encrypted: true }),
}),
{ times: 1 },
).thenResolve(JSON.stringify({ generatedId: resultId }))
2021-12-15 16:07:07 +01:00
const result = await entityRestClient.setup(null, newCustomer)
o(result).equals(resultId)
2021-12-15 16:07:07 +01:00
})
2021-12-15 16:07:07 +01:00
o("Setup entity throws when listid is passed", async function () {
const newCustomer = createTestEntity(CustomerTypeRef)
2021-12-15 16:07:07 +01:00
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 () {
2022-12-27 15:37:40 +01:00
when(restClient.request(anything(), anything(), anything()), { times: 1 }).thenResolve(JSON.stringify({ generatedId: null }))
await entityRestClient.setup("listId", createTestEntity(ContactTypeRef), undefined, { baseUrl: "some url" })
2022-12-27 15:37:40 +01:00
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"
2022-12-27 15:37:40 +01:00
when(
restClient.request(`/rest/sys/customer`, HttpMethod.POST, {
baseUrl: undefined,
2022-12-27 15:37:40 +01:00
headers: { ...authHeader, v },
queryParams: undefined,
responseType: MediaType.Json,
2022-12-27 15:37:40 +01:00
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)
2022-12-27 15:37:40 +01:00
const result = await entityRestClient.setup(null, newCustomer, undefined, { ownerKey })
verify(instanceMapperMock.encryptAndMapToLiteral(anything(), anything(), sessionKey))
2022-12-27 15:37:40 +01:00
verify(cryptoFacadeMock.resolveSessionKey(anything(), anything()), { times: 0 })
o(result).equals(resultId)
})
2021-12-15 16:07:07 +01:00
})
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"
2022-12-27 15:37:40 +01:00
const { version } = await resolveTypeReference(ContactTypeRef)
when(
restClient.request(`/rest/tutanota/contact/listId`, HttpMethod.POST, {
headers: { ...authHeader, v: version },
queryParams: { count: "1" },
responseType: MediaType.Json,
2022-12-27 15:37:40 +01:00
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)
2022-12-27 15:37:40 +01:00
const { version } = await resolveTypeReference(ContactTypeRef)
when(
restClient.request(`/rest/tutanota/contact/listId`, HttpMethod.POST, {
headers: { ...authHeader, v: version },
queryParams: { count: "100" },
responseType: MediaType.Json,
2022-12-27 15:37:40 +01:00
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)
2022-12-27 15:37:40 +01:00
const { version } = await resolveTypeReference(ContactTypeRef)
2022-12-27 15:37:40 +01:00
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(
2022-01-13 13:24:37 +01:00
JSON.stringify(
2022-12-27 15:37:40 +01:00
resultIds.slice(0, 100).map((id) => {
return { generatedId: id }
}),
),
)
2022-12-27 15:37:40 +01:00
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(
2022-12-27 15:37:40 +01:00
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)
2022-12-27 15:37:40 +01:00
const { version } = await resolveTypeReference(ContactTypeRef)
2022-12-27 15:37:40 +01:00
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(
2022-01-13 13:24:37 +01:00
JSON.stringify(
2022-12-27 15:37:40 +01:00
resultIds.slice(0, 100).map((id) => {
return { generatedId: id }
}),
),
)
2022-12-27 15:37:40 +01:00
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(
2022-01-13 13:24:37 +01:00
JSON.stringify(
2022-12-27 15:37:40 +01:00
resultIds.slice(100, 200).map((id) => {
return { generatedId: id }
}),
),
)
2022-12-27 15:37:40 +01:00
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(
2022-12-27 15:37:40 +01:00
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 () {
2022-12-27 15:37:40 +01:00
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 () {
2022-12-27 15:37:40 +01:00
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
2022-12-27 15:37:40 +01:00
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
2022-12-27 15:37:40 +01:00
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]])
})
})
2021-12-15 16:07:07 +01:00
o.spec("Update", function () {
o("Update entity", async function () {
2022-12-27 15:37:40 +01:00
const { version } = await resolveTypeReference(CustomerTypeRef)
const newCustomer = createTestEntity(CustomerTypeRef, {
_id: "id",
})
2022-12-27 15:37:40 +01:00
when(
restClient.request("/rest/sys/customer/id", HttpMethod.PUT, {
headers: { ...authHeader, v: version },
body: JSON.stringify({ ...newCustomer, encrypted: true }),
}),
)
2021-12-15 16:07:07 +01:00
await entityRestClient.update(newCustomer)
})
2021-12-15 16:07:07 +01:00
o("Update entity throws if entity does not have an id", async function () {
const newCustomer = createTestEntity(CustomerTypeRef)
2021-12-15 16:07:07 +01:00
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",
})
2022-12-27 15:37:40 +01:00
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))
2022-12-27 15:37:40 +01:00
verify(cryptoFacadeMock.resolveSessionKey(anything(), anything()), { times: 0 })
})
2021-12-15 16:07:07 +01:00
})
2021-12-15 16:07:07 +01:00
o.spec("Delete", function () {
o("Delete entity", async function () {
2022-12-27 15:37:40 +01:00
const { version } = await resolveTypeReference(CustomerTypeRef)
2021-12-15 16:07:07 +01:00
const id = "id"
const newCustomer = createTestEntity(CustomerTypeRef, {
_id: id,
})
2022-12-27 15:37:40 +01:00
when(
restClient.request("/rest/sys/customer/id", HttpMethod.DELETE, {
headers: { ...authHeader, v: version },
}),
)
2021-12-15 16:07:07 +01:00
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)
})
})
2022-12-27 15:37:40 +01:00
})