diff --git a/web/packages/teleterm/src/services/grpcCredentials/files.test.ts b/web/packages/teleterm/src/services/grpcCredentials/files.test.ts new file mode 100644 index 0000000000000..385dfbd33f780 --- /dev/null +++ b/web/packages/teleterm/src/services/grpcCredentials/files.test.ts @@ -0,0 +1,84 @@ +/** + * @jest-environment node + */ +/** + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import timers from 'node:timers/promises'; + +import { readGrpcCert } from './files'; + +let tempDir: string; + +beforeAll(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'grpc-files-test')); +}); + +afterAll(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); +}); + +beforeEach(() => { + jest.restoreAllMocks(); +}); + +describe('readGrpcCert', () => { + it('reads the file if the file already exists', async () => { + await fs.writeFile(path.join(tempDir, 'already-exists'), 'foobar'); + + await expect( + readGrpcCert(tempDir, 'already-exists').then(buffer => buffer.toString()) + ).resolves.toEqual('foobar'); + }); + + it('reads the file when the file is created after starting a watcher', async () => { + const readGrpcCertPromise = readGrpcCert( + tempDir, + 'created-after-start' + ).then(buffer => buffer.toString()); + await timers.setTimeout(10); + + await fs.writeFile(path.join(tempDir, 'created-after-start'), 'foobar'); + + await expect(readGrpcCertPromise).resolves.toEqual('foobar'); + }); + + it('returns an error if the file is not created within the timeout', async () => { + await expect( + readGrpcCert(tempDir, 'non-existent', { timeoutMs: 1 }) + ).rejects.toMatchObject({ + message: expect.stringContaining('within the timeout'), + }); + }); + + it('returns an error if stat fails', async () => { + const expectedError = new Error('Something went wrong'); + jest.spyOn(fs, 'stat').mockRejectedValue(expectedError); + + await expect( + readGrpcCert( + tempDir, + 'non-existent', + { timeoutMs: 100 } // Make sure that the test doesn't hang for 10s on failure. + ) + ).rejects.toEqual(expectedError); + }); +}); diff --git a/web/packages/teleterm/src/services/grpcCredentials/files.ts b/web/packages/teleterm/src/services/grpcCredentials/files.ts index e639bf6d8382c..c8eaac1cffec4 100644 --- a/web/packages/teleterm/src/services/grpcCredentials/files.ts +++ b/web/packages/teleterm/src/services/grpcCredentials/files.ts @@ -17,9 +17,11 @@ */ import path from 'path'; -import { watch } from 'fs'; +import { type Stats, watch } from 'fs'; import { readFile, writeFile, stat, rename } from 'fs/promises'; +import { wait } from 'shared/utils/wait'; + import { makeCert } from './makeCert'; /** @@ -51,49 +53,64 @@ export async function generateAndSaveGrpcCert( /** * Reads a cert with given `certName` in the `certDir`. - * If the file doesn't exist, it will wait up to 10 seconds for it. + * If the file doesn't exist, by default it will wait up to 10 seconds for it. */ export async function readGrpcCert( certsDir: string, - certName: string + certName: string, + { timeoutMs = 10_000 } = {} ): Promise { const fullPath = path.join(certsDir, certName); const abortController = new AbortController(); async function fileExistsAndHasSize(): Promise { - return !!(await stat(fullPath)).size; + let stats: Stats; + try { + stats = await stat(fullPath); + } catch (error) { + if (error?.code === 'ENOENT') { + return false; + } + throw error; + } + + return !!stats.size; } - function watchForFile(): Promise { + function waitForFile(): Promise { return new Promise((resolve, reject) => { - abortController.signal.onabort = () => { - watcher.close(); - clearTimeout(timeout); - }; - - const timeout = setTimeout(() => { - reject( - `Could not read ${certName} certificate. The operation timed out.` - ); - }, 10_000); + wait(timeoutMs, abortController.signal).then( + () => + reject( + new Error( + `Could not read ${certName} certificate within the timeout.` + ) + ), + () => {} // Ignore abort errors. + ); - const watcher = watch(certsDir, async (event, filename) => { - if (certName === filename && (await fileExistsAndHasSize())) { - resolve(readFile(fullPath)); + // Watching must be started before checking if the file already exists to avoid race + // conditions. If we checked if the file exists and then started the watcher, the file could + // in theory be created between those two actions. + watch( + certsDir, + { signal: abortController.signal }, + async (_, filename) => { + if (certName === filename && (await fileExistsAndHasSize())) { + resolve(readFile(fullPath)); + } } - }); - }); - } + ); - async function checkIfFileAlreadyExists(): Promise { - if (await fileExistsAndHasSize()) { - return readFile(fullPath); - } + fileExistsAndHasSize().then( + exists => exists && resolve(readFile(fullPath)), + err => reject(err) + ); + }); } try { - // watching must be started before checking if the file already exists to avoid race conditions - return await Promise.any([watchForFile(), checkIfFileAlreadyExists()]); + return await waitForFile(); } finally { abortController.abort(); }