tutanota/test/tests/desktop/files/DesktopFileFacadeTest.ts

505 lines
19 KiB
TypeScript
Raw Normal View History

import o from "@tutao/otest"
import { createDataFile } from "../../../../src/common/api/common/DataFile.js"
import { DesktopFileFacade } from "../../../../src/common/desktop/files/DesktopFileFacade.js"
import { ApplicationWindow } from "../../../../src/common/desktop/ApplicationWindow.js"
import { func, matchers, object, verify, when } from "testdouble"
import { ElectronExports, FsExports, PathExports } from "../../../../src/common/desktop/ElectronExportTypes.js"
import { NotFoundError, PreconditionFailedError, TooManyRequestsError } from "../../../../src/common/api/common/error/RestError.js"
import type fs from "node:fs"
import { assertThrows } from "@tutao/tutanota-test-utils"
import { stringToUtf8Uint8Array } from "@tutao/tutanota-utils"
import { DesktopConfig } from "../../../../src/common/desktop/config/DesktopConfig.js"
import { DesktopUtils } from "../../../../src/common/desktop/DesktopUtils.js"
import { DateProvider } from "../../../../src/common/api/common/DateProvider.js"
import { TempFs } from "../../../../src/common/desktop/files/TempFs.js"
import { BuildConfigKey, DesktopConfigKey } from "../../../../src/common/desktop/config/ConfigKeys.js"
import { HttpMethod } from "../../../../src/common/api/common/EntityFunctions"
import { FetchImpl, FetchResult } from "../../../../src/common/desktop/net/NetAgent"
import { CommandExecutor } from "../../../../src/common/desktop/CommandExecutor"
const DEFAULT_DOWNLOAD_PATH = "/a/download/path/"
o.spec("DesktopFileFacade", function () {
let win: ApplicationWindow
let conf: DesktopConfig
let du: DesktopUtils
let dp: DateProvider
let fetch: FetchImpl
let electron: ElectronExports
let fs: FsExports
let tfs: TempFs
let ff: DesktopFileFacade
let path: PathExports
let executor: CommandExecutor
let process: Writeable<Partial<NodeJS.Process>>
o.beforeEach(function () {
win = object()
fetch = func() as FetchImpl
fs = object()
tfs = object()
path = object()
fs.promises = object()
process = {
env: {},
}
when(fs.promises.stat(matchers.anything())).thenResolve({ size: 42 })
electron = object()
// @ts-ignore read-only prop
electron["shell"] = object()
// @ts-ignore read-only prop
electron["dialog"] = object()
process.platform = "linux"
conf = object()
du = object()
dp = object()
executor = object()
ff = new DesktopFileFacade(win, conf, dp, fetch, electron, tfs, fs, path, executor, process as NodeJS.Process)
})
o.spec("saveDataFile", function () {
o("when there's no existing file it will be simply written", async function () {
const dataFile = createDataFile("blob", "application/octet-stream", new Uint8Array([1]))
when(fs.promises.readdir(matchers.anything())).thenResolve(["somethingelse"])
when(fs.promises.mkdir("/tutanota/tmp/path/download", { recursive: true })).thenResolve(undefined)
when(fs.promises.writeFile("/tutanota/tmp/path/download/blob", dataFile.data)).thenResolve()
await ff.writeTempDataFile(dataFile)
})
o("with default download path but file exists -> nonclobbering name is chosen", async function () {
const e = new Error() as any
e.code = "EEXISTS"
const dataFile = createDataFile("blob", "application/octet-stream", new Uint8Array([1]))
when(fs.promises.writeFile("/tutanota/tmp/path/download/blob", matchers.anything())).thenReject(e)
when(fs.promises.readdir(matchers.anything())).thenResolve(["blob"])
when(fs.promises.mkdir("/tutanota/tmp/path/download", { recursive: true })).thenResolve(undefined)
when(fs.promises.writeFile("/tutanota/tmp/path/download/blob-1", dataFile.data)).thenResolve()
await ff.writeTempDataFile(dataFile)
})
})
o.spec("download", function () {
o("no error", async function () {
const headers = { v: "foo", accessToken: "bar" }
const expectedFilePath = "/tutanota/tmp/path/encrypted/nativelyDownloadedFile"
const response: FetchResult = mockResponse(200, { responseBody: new Uint8Array() })
const ws: fs.WriteStream = mockWriteStream(response)
when(fs.createWriteStream(expectedFilePath, { emitClose: true })).thenReturn(ws)
when(
fetch(urlMatches(new URL("some://url/file")), {
method: "GET",
headers,
}),
).thenResolve(response)
// @ts-ignore callback omit
when(ws.on("finish")).thenCallback(undefined, undefined)
when(tfs.ensureEncryptedDir()).thenResolve("/tutanota/tmp/path/encrypted")
const downloadResult = await ff.download("some://url/file", "nativelyDownloadedFile", headers)
o(downloadResult.statusCode).equals(200)
o(downloadResult.encryptedFileUri).equals(expectedFilePath)
})
o("404 error gets returned", async function () {
const headers = {
v: "foo",
accessToken: "bar",
}
const errorId = "123"
const response: FetchResult = mockResponse(NotFoundError.CODE, {
responseHeaders: {
"error-id": errorId,
},
})
when(fetch(matchers.anything(), matchers.anything())).thenResolve(response)
const result = await ff.download("some://url/file", "nativelyDownloadedFile", headers)
o(result).deepEquals({
statusCode: 404,
errorId,
precondition: null,
suspensionTime: null,
encryptedFileUri: null,
})
verify(fs.createWriteStream(matchers.anything(), matchers.anything()), { times: 0 })
})
o("retry-after", async function () {
const retryAfter = "20"
const errorId = "123"
const response: FetchResult = mockResponse(TooManyRequestsError.CODE, {
responseHeaders: {
"error-id": errorId,
"retry-after": retryAfter,
},
})
when(fetch(matchers.anything(), matchers.anything())).thenResolve(response)
const headers = { v: "foo", accessToken: "bar" }
const result = await ff.download("some://url/file", "nativelyDownloadedFile", headers)
o(result).deepEquals({
statusCode: TooManyRequestsError.CODE,
errorId,
precondition: null,
suspensionTime: retryAfter,
encryptedFileUri: null,
})
verify(fs.createWriteStream(matchers.anything(), matchers.anything()), { times: 0 })
})
o("suspension", async function () {
const headers = { v: "foo", accessToken: "bar" }
const errorId = "123"
const retryAfter = "20"
const response: FetchResult = mockResponse(TooManyRequestsError.CODE, {
responseHeaders: {
"error-id": errorId,
"suspension-time": retryAfter,
},
})
when(fetch(matchers.anything(), matchers.anything())).thenResolve(response)
const result = await ff.download("some://url/file", "nativelyDownloadedFile", headers)
o(result).deepEquals({
statusCode: TooManyRequestsError.CODE,
errorId,
precondition: null,
suspensionTime: retryAfter,
encryptedFileUri: null,
})
verify(fs.createWriteStream(matchers.anything(), matchers.anything()), { times: 0 })
})
o("precondition", async function () {
const headers = { v: "foo", accessToken: "bar" }
const errorId = "123"
const precondition = "a.2"
const response: FetchResult = mockResponse(PreconditionFailedError.CODE, {
responseHeaders: {
"error-id": errorId,
precondition: precondition,
},
})
when(fetch(matchers.anything(), matchers.anything())).thenResolve(response)
const result = await ff.download("some://url/file", "nativelyDownloadedFile", headers)
o(result).deepEquals({
statusCode: PreconditionFailedError.CODE,
errorId,
precondition,
suspensionTime: null,
encryptedFileUri: null,
})
verify(fs.createWriteStream(matchers.anything(), matchers.anything()), { times: 0 })
})
o("IO error during download leads to cleanup and error is thrown", async function () {
const headers = { v: "foo", accessToken: "bar" }
const response: FetchResult = mockResponse(200, { responseBody: new Uint8Array() })
when(fetch(matchers.anything(), matchers.anything())).thenResolve(response)
const error = new Error("Test! I/O error")
const ws = mockWriteStream()
when(ws.on("finish", matchers.anything())).thenThrow(error)
when(fs.createWriteStream(matchers.anything(), matchers.anything())).thenReturn(ws)
when(tfs.ensureEncryptedDir()).thenResolve("/tutanota/tmp/path/encrypted")
const e = await assertThrows(Error, () => ff.download("some://url/file", "nativelyDownloadedFile", headers))
o(e).equals(error)
verify(fs.promises.unlink("/tutanota/tmp/path/encrypted/nativelyDownloadedFile"), { times: 1 })
})
})
o.spec("upload", function () {
const fileToUploadPath = "/tutnaota/tmp/path/encrypted/toUpload.txt"
const targetUrl = "https://test.tutanota.com/rest/for/a/bit"
o("when there's no error it uploads correct data and returns the right result", async function () {
const body = stringToUtf8Uint8Array("BODY")
const response = mockResponse(200, { responseBody: body })
const headers = {
blobAccessToken: "1236",
}
const fileStreamMock = mockReadStream()
when(fs.createReadStream(fileToUploadPath)).thenReturn(fileStreamMock)
when(
fetch(urlMatches(new URL(targetUrl)), {
method: HttpMethod.POST,
headers,
body: fileStreamMock,
}),
).thenResolve(response)
const uploadResult = await ff.upload(fileToUploadPath, targetUrl, HttpMethod.POST, headers)
o(uploadResult.statusCode).equals(200)
o(uploadResult.errorId).equals(null)
o(uploadResult.precondition).equals(null)
o(uploadResult.suspensionTime).equals(null)
o(Array.from(uploadResult.responseBody)).deepEquals(Array.from(body))
})
o("when 404 is returned it returns correct result", async function () {
const errorId = "123"
const response = mockResponse(404, { responseHeaders: { "error-id": errorId } })
when(fetch(matchers.anything(), matchers.anything())).thenResolve(response)
const uploadResult = await ff.upload(fileToUploadPath, targetUrl, HttpMethod.POST, {})
o(uploadResult.statusCode).equals(404)
o(uploadResult.errorId).equals(errorId)
o(uploadResult.precondition).equals(null)
o(uploadResult.suspensionTime).equals(null)
o(Array.from(uploadResult.responseBody)).deepEquals([])
})
o("when retry-after is returned, it is propagated", async function () {
const retryAFter = "20"
const errorId = "123"
const response = mockResponse(TooManyRequestsError.CODE, {
responseHeaders: {
"error-id": errorId,
"retry-after": retryAFter,
},
})
when(fetch(matchers.anything(), matchers.anything())).thenResolve(response)
const uploadResult = await ff.upload(fileToUploadPath, targetUrl, HttpMethod.POST, {})
o(uploadResult.statusCode).equals(TooManyRequestsError.CODE)
o(uploadResult.errorId).equals(errorId)
o(uploadResult.precondition).equals(null)
o(uploadResult.suspensionTime).equals(retryAFter)
o(Array.from(uploadResult.responseBody)).deepEquals([])
})
o("when suspension-time is returned, it is propagated", async function () {
const retryAFter = "20"
const errorId = "123"
const response = mockResponse(TooManyRequestsError.CODE, {
responseHeaders: {
"error-id": errorId,
"suspension-time": retryAFter,
},
})
when(fetch(matchers.anything(), matchers.anything())).thenResolve(response)
const uploadResult = await ff.upload(fileToUploadPath, targetUrl, HttpMethod.POST, {})
o(uploadResult.statusCode).equals(TooManyRequestsError.CODE)
o(uploadResult.errorId).equals(errorId)
o(uploadResult.precondition).equals(null)
o(uploadResult.suspensionTime).equals(retryAFter)
o(Array.from(uploadResult.responseBody)).deepEquals([])
})
o("when precondition-time is returned, it is propagated", async function () {
const precondition = "a.2"
const errorId = "123"
const response = mockResponse(PreconditionFailedError.CODE, {
responseHeaders: {
"error-id": errorId,
precondition: precondition,
},
})
when(fetch(matchers.anything(), matchers.anything())).thenResolve(response)
const uploadResult = await ff.upload(fileToUploadPath, targetUrl, HttpMethod.POST, {})
o(uploadResult.statusCode).equals(PreconditionFailedError.CODE)
o(uploadResult.errorId).equals(errorId)
o(uploadResult.precondition).equals(precondition)
o(uploadResult.suspensionTime).equals(null)
o(Array.from(uploadResult.responseBody)).deepEquals([])
})
})
o.spec("open", function () {
o.spec("open on linux", () => {
o.test("open on ubuntu", async function () {
when(electron.shell.openPath("/some/folder/file")).thenReject(new Error("wrong function"))
when(executor.run(matchers.anything())).thenResolve({})
process.env!.ORIGINAL_XDG_CURRENT_DESKTOP = "original ;)"
process.env!.SOMETHING_ELSE = "something else!"
await ff.open("/some/folder/file")
verify(
executor.run({
executable: "xdg-open",
args: ["/some/folder/file"],
env: {
XDG_CURRENT_DESKTOP: "original ;)",
ORIGINAL_XDG_CURRENT_DESKTOP: "original ;)",
SOMETHING_ELSE: "something else!",
},
}),
)
})
o.test("open on non ubuntu", async function () {
when(electron.shell.openPath("/some/folder/file")).thenReject(new Error("wrong function"))
when(executor.run(matchers.anything())).thenResolve({})
process.env!.ORIGINAL_XDG_CURRENT_DESKTOP = undefined
process.env!.SOMETHING_ELSE = "something else!"
await ff.open("/some/folder/file")
verify(
executor.run({
executable: "xdg-open",
args: ["/some/folder/file"],
env: undefined,
}),
)
})
})
o.test("open on windows", async function () {
process.platform = "win32"
when(electron.dialog.showMessageBox(matchers.anything())).thenReturn(
Promise.resolve({
response: 1,
checkboxChecked: false,
}),
)
await ff.open("exec.exe")
verify(electron.shell.openPath(matchers.anything()), { times: 0 })
})
})
o.spec("join", function () {
o("join a single file", async function () {
const ws: fs.WriteStream = mockWriteStream()
const rs: fs.ReadStream = mockReadStream(ws)
when(fs.createWriteStream(matchers.anything(), matchers.anything())).thenReturn(ws)
when(fs.createReadStream(matchers.anything())).thenReturn(rs)
// @ts-ignore callback omit
when(rs.on("end")).thenCallback(undefined, undefined)
when(fs.promises.readdir("/tutanota/tmp/path/unencrypted")).thenResolve(["folderContents"])
when(tfs.ensureUnencrytpedDir()).thenResolve("/tutanota/tmp/path/unencrypted")
const joinedFilePath = await ff.joinFiles("fileName.pdf", ["/file1"])
o(joinedFilePath).equals("/tutanota/tmp/path/unencrypted/fileName.pdf")
})
})
o.spec("splitFile", function () {
o("returns one slice for a small file", async function () {
// fs mock returns file name as the content
const filename = "/tutanota/tmp/path/download/small.txt"
const fileContent = stringToUtf8Uint8Array(filename)
const filenameHash = "9ca089f82e397e9e860daa312ac25def39f2da0e066f0de94ffc02aa7b3a6250"
const expectedChunkPath = `/tutanota/tmp/path/unencrypted/${filenameHash}.0.blob`
when(tfs.ensureUnencrytpedDir()).thenResolve("/tutanota/tmp/path/unencrypted")
when(fs.promises.writeFile(expectedChunkPath, fileContent)).thenResolve()
when(fs.promises.readFile(filename)).thenResolve(Buffer.from(fileContent))
const chunks = await ff.splitFile(filename, 1024)
o(chunks).deepEquals([expectedChunkPath])("only one chunk")
})
o("returns multiple slices for a bigger file", async function () {
// fs mock returns file name as the content
const filename = "/tutanota/tmp/path/download/big.txt"
// length 37
const fileContent = stringToUtf8Uint8Array(filename)
const filenameHash = "c24646a4738a92d624cd03134f26c371d8a2950d2b3bbce7921c288de9a56fd3"
const expectedChunkPath0 = `/tutanota/tmp/path/unencrypted/${filenameHash}.0.blob`
const expectedChunkPath1 = `/tutanota/tmp/path/unencrypted/${filenameHash}.1.blob`
when(tfs.ensureUnencrytpedDir()).thenResolve("/tutanota/tmp/path/unencrypted")
when(fs.promises.writeFile(expectedChunkPath0, fileContent.slice(0, 30))).thenResolve()
when(fs.promises.writeFile(expectedChunkPath1, fileContent.slice(30))).thenResolve()
when(fs.promises.readFile(filename)).thenResolve(Buffer.from(fileContent))
const chunks = await ff.splitFile(filename, 30)
o(chunks).deepEquals([expectedChunkPath0, expectedChunkPath1])("both written files are in the returned array")
})
})
o.spec("showInFileExplorer", function () {
o("two downloads, open two filemanagers", async function () {
const dir = "/path/to"
const p = dir + "/file.txt"
await ff.showInFileExplorer(p)
verify(electron.shell.showItemInFolder(p), { times: 1 })
})
o("two downloads, open two filemanagers after a pause", async function () {
const time = 1629115820468
const dir = "/path/to"
const p = dir + "/file.txt"
await ff.showInFileExplorer(p)
when(dp.now()).thenReturn(time)
when(conf.getConst(BuildConfigKey.fileManagerTimeout)).thenResolve(2)
verify(electron.shell.showItemInFolder(p), { times: 1 })
when(dp.now()).thenReturn(time + 10)
await ff.showInFileExplorer(p)
verify(electron.shell.showItemInFolder(p), { times: 2 })
})
})
o.spec("putFileIntoDownloadsFolder", function () {
o("putFileIntoDownloadsFolder", async function () {
const src = "/path/random.pdf"
const filename = "fileName.pdf"
when(conf.getVar(DesktopConfigKey.defaultDownloadPath)).thenResolve(DEFAULT_DOWNLOAD_PATH)
when(fs.promises.readdir(matchers.anything())).thenResolve([])
const copiedFileUri = await ff.putFileIntoDownloadsFolder(src, filename)
verify(fs.promises.copyFile(src, DEFAULT_DOWNLOAD_PATH + "fileName.pdf"))
o(copiedFileUri).equals(DEFAULT_DOWNLOAD_PATH + "fileName.pdf")
})
})
o.spec("size", function () {
o("size", async function () {
when(fs.promises.stat(matchers.anything())).thenResolve({ size: 33 })
o(await ff.getSize("/file1")).equals(33)
})
})
o.spec("hash", function () {
o("hash", async function () {
when(fs.promises.readFile("/file1")).thenResolve(new Uint8Array([0, 1, 2, 3]) as Buffer)
o(await ff.hashFile("/file1")).equals("BU7ewdAh")
})
})
})
function mockReadStream(ws?: fs.WriteStream): fs.ReadStream {
const rs: fs.ReadStream = object()
if (ws != null) {
when(rs.pipe(ws, { end: false })).thenReturn(ws)
}
return rs
}
const urlMatches = matchers.create({
name: "urlMatches",
matches(matcherArgs: any[], actual: any): boolean {
return (actual as URL).toString() === (matcherArgs[0] as URL).toString()
},
})
function mockWriteStream(response?: FetchResult): fs.WriteStream {
const ws: fs.WriteStream = object()
if (response != null) {
// when(response.pipe(ws)).thenReturn(ws)
}
const closeCapturer = matchers.captor()
when(ws.on("close", closeCapturer.capture())).thenReturn(ws)
when(ws.close()).thenDo(() => closeCapturer.value())
return ws
}
function mockResponse(
statusCode: number,
resOpts: {
responseBody?: Uint8Array
responseHeaders?: Record<string, string>
},
): FetchResult {
const { responseBody, responseHeaders } = resOpts
return new global.Response(responseBody, {
status: statusCode,
headers: new Headers(responseHeaders),
}) as FetchResult
}