diff --git a/CHANGELOG.md b/CHANGELOG.md index 560dbd8b..66809c94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org). -## [Unreleased] +## [4.4.0] - 2025-04-20 ### Added +* [#1510](https://github.com/shlinkio/shlink-web-client/issues/1510) Existing HTTP credentials (cookies, TLS certs, authentication headers) can now be forwarded to the API server if appropriate [CORS headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Credentials) are set * [shlink-web-component#637](https://github.com/shlinkio/shlink-web-component/pull/637) QR codes are now generated client-side, without hitting Shlink. * [shlink-web-component#641](https://github.com/shlinkio/shlink-web-component/issues/641) It is now possible to provide any logo to be used with QR codes. * [shlink-web-component#640](https://github.com/shlinkio/shlink-web-component/issues/640) Allow default QR code settings to be handled via app settings. -* [#1510](https://github.com/shlinkio/shlink-web-client/issues/1510) Existing HTTP credentials (cookies, TLS certs, authentication headers) are now automatically forwarded to the API server if appropriate [CORS headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Credentials) are set ### Changed * Update to `react-router` 7.0 diff --git a/package-lock.json b/package-lock.json index 90819167..06dddfde 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "@json2csv/plainjs": "^7.0.6", "@reduxjs/toolkit": "^2.7.0", "@shlinkio/data-manipulation": "^1.0.3", - "@shlinkio/shlink-frontend-kit": "^0.8.10", + "@shlinkio/shlink-frontend-kit": "^0.8.12", "@shlinkio/shlink-js-sdk": "^2.1.0", "@shlinkio/shlink-web-component": "^0.13.3", "bootstrap": "5.2.3", @@ -3438,9 +3438,9 @@ } }, "node_modules/@shlinkio/shlink-frontend-kit": { - "version": "0.8.10", - "resolved": "https://registry.npmjs.org/@shlinkio/shlink-frontend-kit/-/shlink-frontend-kit-0.8.10.tgz", - "integrity": "sha512-cB5qyZBCWEwLzEf3XK6ih/32x8i4ER4Tn6WNqIROhcr6Myjot0gvAfNStoXbEeYjJSw2+5wRFSccbAh3w5RxJA==", + "version": "0.8.12", + "resolved": "https://registry.npmjs.org/@shlinkio/shlink-frontend-kit/-/shlink-frontend-kit-0.8.12.tgz", + "integrity": "sha512-J3t0HnvOaZDLSZ1zjbAn9l025GNTy7XvcKEV5+t8iYirf6THyGCK7JDoY1CfgRWfjiWBCFA+WmzrK92a2PqcAA==", "license": "MIT", "dependencies": { "clsx": "^2.1.1" @@ -13699,9 +13699,9 @@ "requires": {} }, "@shlinkio/shlink-frontend-kit": { - "version": "0.8.10", - "resolved": "https://registry.npmjs.org/@shlinkio/shlink-frontend-kit/-/shlink-frontend-kit-0.8.10.tgz", - "integrity": "sha512-cB5qyZBCWEwLzEf3XK6ih/32x8i4ER4Tn6WNqIROhcr6Myjot0gvAfNStoXbEeYjJSw2+5wRFSccbAh3w5RxJA==", + "version": "0.8.12", + "resolved": "https://registry.npmjs.org/@shlinkio/shlink-frontend-kit/-/shlink-frontend-kit-0.8.12.tgz", + "integrity": "sha512-J3t0HnvOaZDLSZ1zjbAn9l025GNTy7XvcKEV5+t8iYirf6THyGCK7JDoY1CfgRWfjiWBCFA+WmzrK92a2PqcAA==", "requires": { "clsx": "^2.1.1" } diff --git a/package.json b/package.json index 40c85f15..ce439246 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "@json2csv/plainjs": "^7.0.6", "@reduxjs/toolkit": "^2.7.0", "@shlinkio/data-manipulation": "^1.0.3", - "@shlinkio/shlink-frontend-kit": "^0.8.10", + "@shlinkio/shlink-frontend-kit": "^0.8.12", "@shlinkio/shlink-js-sdk": "^2.1.0", "@shlinkio/shlink-web-component": "^0.13.3", "bootstrap": "5.2.3", diff --git a/scripts/docker/servers_from_env.sh b/scripts/docker/servers_from_env.sh index 8c49f674..2f3e2f27 100755 --- a/scripts/docker/servers_from_env.sh +++ b/scripts/docker/servers_from_env.sh @@ -10,7 +10,8 @@ setup_single_shlink_server() { [ -n "$SHLINK_SERVER_URL" ] || return 0 [ -n "$SHLINK_SERVER_API_KEY" ] || return 0 local name="${SHLINK_SERVER_NAME:-Shlink}" - echo "[{\"name\":\"${name}\",\"url\":\"${SHLINK_SERVER_URL}\",\"apiKey\":\"${SHLINK_SERVER_API_KEY}\"}]" > /usr/share/nginx/html/servers.json + local forwardCredentials="${SHLINK_SERVER_FORWARD_CREDENTIALS:-false}" + echo "[{\"name\":\"${name}\",\"url\":\"${SHLINK_SERVER_URL}\",\"apiKey\":\"${SHLINK_SERVER_API_KEY}\",\"forwardCredentials\":${forwardCredentials}}]" > /usr/share/nginx/html/servers.json } setup_single_shlink_server diff --git a/src/api/services/ShlinkApiClientBuilder.ts b/src/api/services/ShlinkApiClientBuilder.ts index cf468bd9..8e64c3ee 100644 --- a/src/api/services/ShlinkApiClientBuilder.ts +++ b/src/api/services/ShlinkApiClientBuilder.ts @@ -4,7 +4,7 @@ import type { GetState } from '../../container/types'; import type { ServerWithId } from '../../servers/data'; import { hasServerData } from '../../servers/data'; -const apiClients: Record = {}; +const apiClients: Map = new Map(); const isGetState = (getStateOrSelectedServer: GetState | ServerWithId): getStateOrSelectedServer is GetState => typeof getStateOrSelectedServer === 'function'; @@ -18,19 +18,22 @@ const getSelectedServerFromState = (getState: GetState): ServerWithId => { }; export const buildShlinkApiClient = (httpClient: HttpClient) => (getStateOrSelectedServer: GetState | ServerWithId) => { - const { url: baseUrl, apiKey } = isGetState(getStateOrSelectedServer) + const { url: baseUrl, apiKey, forwardCredentials } = isGetState(getStateOrSelectedServer) ? getSelectedServerFromState(getStateOrSelectedServer) : getStateOrSelectedServer; - const serverKey = `${apiKey}_${baseUrl}`; + const serverKey = `${apiKey}_${baseUrl}_${forwardCredentials ? 'forward' : 'no-forward'}`; + const existingApiClient = apiClients.get(serverKey); - const apiClient = apiClients[serverKey] ?? new ShlinkApiClient( + if (existingApiClient) { + return existingApiClient; + } + + const apiClient = new ShlinkApiClient( httpClient, { apiKey, baseUrl }, - // FIXME Disabling this as it's breaking existing Shlink servers as configured out of the box - // { requestCredentials: 'include' }, + { requestCredentials: forwardCredentials ? 'include' : undefined }, ); - apiClients[serverKey] = apiClient; - + apiClients.set(serverKey, apiClient); return apiClient; }; diff --git a/src/servers/data/index.ts b/src/servers/data/index.ts index cae33087..deac601f 100644 --- a/src/servers/data/index.ts +++ b/src/servers/data/index.ts @@ -4,6 +4,7 @@ export interface ServerData { name: string; url: string; apiKey: string; + forwardCredentials?: boolean; } export interface ServerWithId extends ServerData { @@ -44,4 +45,31 @@ export const isNotFoundServer = (server: SelectedServer): server is NotFoundServ export const getServerId = (server: SelectedServer) => (isServerWithId(server) ? server.id : ''); -export const serverWithIdToServerData = ({ name, url, apiKey }: ServerWithId): ServerData => ({ name, url, apiKey }); +/** + * Expose values that represent provided server, in a way that can be serialized in JSON or CSV strings. + */ +export const serializeServer = ({ name, url, apiKey, forwardCredentials }: ServerData): Record => ({ + name, + url, + apiKey, + forwardCredentials: forwardCredentials ? 'true' : 'false', +}); + +const validateServerData = (server: any): server is ServerData => + typeof server.url === 'string' && typeof server.apiKey === 'string' && typeof server.name === 'string'; + +/** + * Provided a record, it picks the right properties to build a ServerData object. + * @throws Error If any of the required ServerData properties is missing. + */ +export const deserializeServer = (potentialServer: Record): ServerData => { + const { forwardCredentials, ...serverData } = potentialServer; + if (!validateServerData(serverData)) { + throw new Error('Server is missing required "url", "apiKey" and/or "name" properties'); + } + + return { + ...serverData, + forwardCredentials: forwardCredentials === 'true', + }; +}; diff --git a/src/servers/helpers/ServerForm.tsx b/src/servers/helpers/ServerForm.tsx index 0229367e..523c0a73 100644 --- a/src/servers/helpers/ServerForm.tsx +++ b/src/servers/helpers/ServerForm.tsx @@ -1,11 +1,15 @@ +import { useToggle } from '@shlinkio/shlink-frontend-kit'; import { + Checkbox, + Details, + Label, LabelledInput, LabelledRevealablePasswordInput, SimpleCard, } from '@shlinkio/shlink-frontend-kit/tailwind'; import type { FC, PropsWithChildren, ReactNode } from 'react'; import { useState } from 'react'; -import { handleEventPreventingDefault } from '../../utils/utils'; +import { usePreventDefault } from '../../utils/utils'; import type { ServerData } from '../data'; type ServerFormProps = PropsWithChildren<{ @@ -18,7 +22,11 @@ export const ServerForm: FC = ({ onSubmit, initialValues, child const [name, setName] = useState(initialValues?.name ?? ''); const [url, setUrl] = useState(initialValues?.url ?? ''); const [apiKey, setApiKey] = useState(initialValues?.apiKey ?? ''); - const handleSubmit = handleEventPreventingDefault(() => onSubmit({ name, url, apiKey })); + const { flag: forwardCredentials, toggle: toggleForwardCredentials } = useToggle( + initialValues?.forwardCredentials ?? false, + true, + ); + const handleSubmit = usePreventDefault(() => onSubmit({ name, url, apiKey, forwardCredentials })); return (
@@ -31,6 +39,23 @@ export const ServerForm: FC = ({ onSubmit, initialValues, child onChange={(e) => setApiKey(e.target.value)} required /> +
+
+ + + {'"'}Credentials{'"'} here means cookies, TLS client certificates, or authentication headers containing a username + and password. + + + Important! If you are not sure what this means, leave it unchecked. Enabling this option will + make all requests fail for Shlink older than v4.5.0, as it requires the server to set a more strict + value for Access-Control-Allow-Origin than *. + +
+
{children}
diff --git a/src/servers/services/ServersExporter.ts b/src/servers/services/ServersExporter.ts index 528216fa..260c7c28 100644 --- a/src/servers/services/ServersExporter.ts +++ b/src/servers/services/ServersExporter.ts @@ -2,24 +2,27 @@ import type { JsonToCsv } from '../../utils/helpers/csvjson'; import { saveCsv } from '../../utils/helpers/files'; import type { LocalStorage } from '../../utils/services/LocalStorage'; import type { ServersMap } from '../data'; -import { serverWithIdToServerData } from '../data'; +import { serializeServer } from '../data'; const SERVERS_FILENAME = 'shlink-servers.csv'; export class ServersExporter { - public constructor( - private readonly storage: LocalStorage, - private readonly window: Window, - private readonly jsonToCsv: JsonToCsv, - ) {} + readonly #storage: LocalStorage; + readonly #window: Window; + readonly #jsonToCsv: JsonToCsv; + + public constructor(storage: LocalStorage, window: Window, jsonToCsv: JsonToCsv) { + this.#storage = storage; + this.#window = window; + this.#jsonToCsv = jsonToCsv; + } public readonly exportServers = async () => { - const servers = Object.values(this.storage.get('servers') ?? {}).map(serverWithIdToServerData); + const servers = Object.values(this.#storage.get('servers') ?? {}).map(serializeServer); try { - const csv = this.jsonToCsv(servers); - - saveCsv(this.window, csv, SERVERS_FILENAME); + const csv = this.#jsonToCsv(servers); + saveCsv(this.#window, csv, SERVERS_FILENAME); } catch (e) { // FIXME Handle error console.error(e); diff --git a/src/servers/services/ServersImporter.ts b/src/servers/services/ServersImporter.ts index a4dd0b7e..afb42ba4 100644 --- a/src/servers/services/ServersImporter.ts +++ b/src/servers/services/ServersImporter.ts @@ -1,14 +1,20 @@ import type { CsvToJson } from '../../utils/helpers/csvjson'; import type { ServerData } from '../data'; +import { deserializeServer } from '../data'; -const validateServer = (server: any): server is ServerData => - typeof server.url === 'string' && typeof server.apiKey === 'string' && typeof server.name === 'string'; - -const validateServers = (servers: any): servers is ServerData[] => - Array.isArray(servers) && servers.every(validateServer); +const validateAndDeserializeServers = (servers: unknown): ServerData[] => { + if (!Array.isArray(servers)) { + throw new Error('Provided file does not have the right format.'); + } + return servers.map(deserializeServer); +}; export class ServersImporter { - public constructor(private readonly csvToJson: CsvToJson) {} + readonly #csvToJson: CsvToJson; + + public constructor(csvToJson: CsvToJson) { + this.#csvToJson = csvToJson; + } public async importServersFromFile(file: File | null | undefined): Promise { if (!file) { @@ -16,12 +22,8 @@ export class ServersImporter { } const content = await file.text(); - const servers = await this.csvToJson(content); + const servers = await this.#csvToJson(content); - if (!validateServers(servers)) { - throw new Error('Provided file does not have the right format.'); - } - - return servers; + return validateAndDeserializeServers(servers); } } diff --git a/src/utils/utils.ts b/src/utils/utils.ts index b1b11ca0..33a88306 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -1,6 +1,11 @@ import type { SyntheticEvent } from 'react'; +import { useCallback } from 'react'; -export const handleEventPreventingDefault = (handler: () => T) => (e: SyntheticEvent) => { - e.preventDefault(); - handler(); -}; +/** + * Wraps an event handler so that it calls e.preventDefault() before invoking the event handler + */ +export const usePreventDefault = (handler: (e: Event) => void) => + useCallback((e: Event) => { + e.preventDefault(); + handler(e); + }, [handler]); diff --git a/test/api/services/ShlinkApiClientBuilder.test.ts b/test/api/services/ShlinkApiClientBuilder.test.ts index 591c112d..9c29f860 100644 --- a/test/api/services/ShlinkApiClientBuilder.test.ts +++ b/test/api/services/ShlinkApiClientBuilder.test.ts @@ -6,8 +6,8 @@ import type { ReachableServer, SelectedServer } from '../../../src/servers/data' describe('ShlinkApiClientBuilder', () => { const server = fromPartial; - const createBuilder = () => { - const builder = buildShlinkApiClient(fromPartial({})); + const createBuilder = (httpClient: HttpClient = fromPartial({})) => { + const builder = buildShlinkApiClient(httpClient); return (selectedServer: SelectedServer) => builder(() => fromPartial({ selectedServer })); }; @@ -17,9 +17,9 @@ describe('ShlinkApiClientBuilder', () => { const secondApiClient = builder(server({ url: 'bar', apiKey: 'bar' })); const thirdApiClient = builder(server({ url: 'bar', apiKey: 'foo' })); - expect(firstApiClient === secondApiClient).toEqual(false); - expect(firstApiClient === thirdApiClient).toEqual(false); - expect(secondApiClient === thirdApiClient).toEqual(false); + expect(firstApiClient).not.toBe(secondApiClient); + expect(firstApiClient).not.toBe(thirdApiClient); + expect(secondApiClient).not.toBe(thirdApiClient); }); it('returns existing instances when provided params are the same', () => { @@ -30,21 +30,39 @@ describe('ShlinkApiClientBuilder', () => { const secondApiClient = builder(selectedServer); const thirdApiClient = builder(selectedServer); - expect(firstApiClient === secondApiClient).toEqual(true); - expect(firstApiClient === thirdApiClient).toEqual(true); - expect(secondApiClient === thirdApiClient).toEqual(true); + expect(firstApiClient).toBe(secondApiClient); + expect(firstApiClient).toBe(thirdApiClient); + expect(secondApiClient).toBe(thirdApiClient); }); - it.only('does not fetch from state when provided param is already selected server', async () => { + it('does not fetch from state when provided param is already a server', async () => { const url = 'the_url'; const apiKey = 'the_api_key'; const jsonRequest = vi.fn(); const httpClient = fromPartial({ jsonRequest }); - const apiClient = buildShlinkApiClient(httpClient)(server({ url, apiKey })); + const apiClient = createBuilder(httpClient)(server({ url, apiKey })); await apiClient.health(); expect(jsonRequest).toHaveBeenCalledWith(expect.stringMatching(new RegExp(`^${url}`)), expect.objectContaining({ + credentials: undefined, + headers: { + 'X-Api-Key': apiKey, + }, + })); + }); + + it('includes credentials when forwarding is enabled', async () => { + const url = 'the_url'; + const apiKey = 'the_api_key'; + const jsonRequest = vi.fn(); + const httpClient = fromPartial({ jsonRequest }); + const apiClient = createBuilder(httpClient)(server({ url, apiKey, forwardCredentials: true })); + + await apiClient.health(); + + expect(jsonRequest).toHaveBeenCalledWith(expect.stringMatching(new RegExp(`^${url}`)), expect.objectContaining({ + credentials: 'include', headers: { 'X-Api-Key': apiKey, }, diff --git a/test/servers/DeleteServerModal.test.tsx b/test/servers/DeleteServerModal.test.tsx index c2d6b7bb..c573b93b 100644 --- a/test/servers/DeleteServerModal.test.tsx +++ b/test/servers/DeleteServerModal.test.tsx @@ -36,7 +36,7 @@ describe('', () => { expect(screen.getByText(serverName)).toBeInTheDocument(); }); - it.only.each([ + it.each([ [() => screen.getByRole('button', { name: 'Cancel' })], [() => screen.getByLabelText('Close dialog')], ])('closes dialog when clicking cancel button', async (getButton) => { diff --git a/test/servers/EditServer.test.tsx b/test/servers/EditServer.test.tsx index 3eaef72f..144e681f 100644 --- a/test/servers/EditServer.test.tsx +++ b/test/servers/EditServer.test.tsx @@ -47,16 +47,16 @@ describe('', () => { it('display the server info in the form components', () => { setUp(); - expect(screen.getByDisplayValue('the_name')).toBeInTheDocument(); - expect(screen.getByDisplayValue('the_url')).toBeInTheDocument(); - expect(screen.getByDisplayValue('the_api_key')).toBeInTheDocument(); + expect(screen.getByLabelText(/^Name/)).toBeInTheDocument(); + expect(screen.getByLabelText(/^URL/)).toBeInTheDocument(); + expect(screen.getByLabelText(/^API key/)).toBeInTheDocument(); }); it('edits server and redirects to it when form is submitted', async () => { const { user, history } = setUp(); - await user.type(screen.getByDisplayValue('the_name'), ' edited'); - await user.type(screen.getByDisplayValue('the_url'), ' edited'); + await user.type(screen.getByLabelText(/^Name/), ' edited'); + await user.type(screen.getByLabelText(/^URL/), ' edited'); // TODO Using fire event because userEvent.click on the Submit button does not submit the form // await user.click(screen.getByRole('button', { name: 'Save' })); fireEvent.submit(screen.getByRole('form')); @@ -65,9 +65,26 @@ describe('', () => { name: 'the_name edited', url: 'the_url edited', apiKey: 'the_api_key', + forwardCredentials: false, }); // After saving we go back, to the first route from history's initialEntries expect(history.location.pathname).toEqual('/foo'); }); + + it.each([ + { forwardCredentials: true }, + { forwardCredentials: false }, + ])('edits advanced options - forward credentials', async (serverPartial) => { + const { user } = setUp({ ...defaultSelectedServer, ...serverPartial }); + + await user.click(screen.getByText('Advanced options')); + await user.click(screen.getByLabelText('Forward credentials to this server on every request.')); + + fireEvent.submit(screen.getByRole('form')); + + expect(editServerMock).toHaveBeenCalledWith('abc123', expect.objectContaining({ + forwardCredentials: !serverPartial.forwardCredentials, + })); + }); }); diff --git a/test/servers/helpers/ServerForm.test.tsx b/test/servers/helpers/ServerForm.test.tsx index 2d6f6379..1305c51d 100644 --- a/test/servers/helpers/ServerForm.test.tsx +++ b/test/servers/helpers/ServerForm.test.tsx @@ -1,10 +1,11 @@ -import { fireEvent, render, screen } from '@testing-library/react'; +import { fireEvent, screen } from '@testing-library/react'; import { ServerForm } from '../../../src/servers/helpers/ServerForm'; import { checkAccessibility } from '../../__helpers__/accessibility'; +import { renderWithEvents } from '../../__helpers__/setUpTest'; describe('', () => { const onSubmit = vi.fn(); - const setUp = () => render(Something); + const setUp = () => renderWithEvents(Something); it('passes a11y checks', () => checkAccessibility(setUp())); @@ -15,6 +16,7 @@ describe('', () => { expect(screen.getByLabelText(/^URL/)).toBeInTheDocument(); expect(screen.getByLabelText(/^API key/)).toBeInTheDocument(); expect(screen.getByText('Something')).toBeInTheDocument(); + expect(screen.getByText('Advanced options')).toBeInTheDocument(); }); it('invokes submit callback when submit event is triggered', async () => { @@ -24,4 +26,13 @@ describe('', () => { fireEvent.submit(screen.getByRole('form'), { preventDefault: vi.fn() }); expect(onSubmit).toHaveBeenCalled(); }); + + it('shows advanced options', async () => { + const { user } = setUp(); + const forwardCredentialsLabel = 'Forward credentials to this server on every request.'; + + expect(screen.queryByLabelText(forwardCredentialsLabel)).not.toBeInTheDocument(); + await user.click(screen.getByText('Advanced options')); + expect(screen.getByLabelText(forwardCredentialsLabel)).toBeInTheDocument(); + }); }); diff --git a/test/servers/services/ServersExporter.test.ts b/test/servers/services/ServersExporter.test.ts index 504583fe..b0f5b9d0 100644 --- a/test/servers/services/ServersExporter.test.ts +++ b/test/servers/services/ServersExporter.test.ts @@ -1,27 +1,35 @@ import { fromPartial } from '@total-typescript/shoehorn'; +import type { ServersMap } from '../../../src/servers/data'; +import { serializeServer } from '../../../src/servers/data'; import { ServersExporter } from '../../../src/servers/services/ServersExporter'; import type { LocalStorage } from '../../../src/utils/services/LocalStorage'; import { appendChild, removeChild, windowMock } from '../../__mocks__/Window.mock'; describe('ServersExporter', () => { + const servers: ServersMap = { + abc123: { + id: 'abc123', + name: 'foo', + url: 'https://foo.com', + apiKey: 'foo_api_key', + autoConnect: true, + }, + def456: { + id: 'def456', + name: 'bar', + url: 'https://bar.com', + apiKey: 'bar_api_key', + forwardCredentials: true, + autoConnect: false, + }, + }; const storageMock = fromPartial({ - get: vi.fn(() => ({ - abc123: { - id: 'abc123', - name: 'foo', - autoConnect: true, - }, - def456: { - id: 'def456', - name: 'bar', - autoConnect: false, - }, - } as any)), + get: vi.fn(() => servers as any), }); const erroneousToCsv = vi.fn(() => { throw new Error(''); }); - const createCsvjsonMock = (throwError = false) => (throwError ? erroneousToCsv : vi.fn(() => '')); + const createJsonToCsvMock = (throwError = false) => (throwError ? erroneousToCsv : vi.fn(() => '')); describe('exportServers', () => { const error = vi.fn(); @@ -34,8 +42,8 @@ describe('ServersExporter', () => { }); it('logs an error if something fails', () => { - const csvjsonMock = createCsvjsonMock(true); - const exporter = new ServersExporter(storageMock, windowMock, csvjsonMock); + const jsonToCsvMock = createJsonToCsvMock(true); + const exporter = new ServersExporter(storageMock, windowMock, jsonToCsvMock); exporter.exportServers(); @@ -44,7 +52,8 @@ describe('ServersExporter', () => { }); it('makes use of download link API', () => { - const exporter = new ServersExporter(storageMock, windowMock, createCsvjsonMock()); + const jsonToCsvMock = createJsonToCsvMock(); + const exporter = new ServersExporter(storageMock, windowMock, jsonToCsvMock); const { document: { createElement } } = windowMock; exporter.exportServers(); @@ -53,6 +62,7 @@ describe('ServersExporter', () => { expect(createElement).toHaveBeenCalledTimes(1); expect(appendChild).toHaveBeenCalledTimes(1); expect(removeChild).toHaveBeenCalledTimes(1); + expect(jsonToCsvMock).toHaveBeenCalledWith(Object.values(servers).map(serializeServer)); }); }); }); diff --git a/test/servers/services/ServersImporter.test.ts b/test/servers/services/ServersImporter.test.ts index c5180fec..b3f74f8f 100644 --- a/test/servers/services/ServersImporter.test.ts +++ b/test/servers/services/ServersImporter.test.ts @@ -1,5 +1,5 @@ import { fromPartial } from '@total-typescript/shoehorn'; -import type { RegularServer } from '../../../src/servers/data'; +import type { RegularServer, ServerData } from '../../../src/servers/data'; import { ServersImporter } from '../../../src/servers/services/ServersImporter'; describe('ServersImporter', () => { @@ -25,20 +25,24 @@ describe('ServersImporter', () => { }); it.each([ - [{}], - [undefined], - [[{ foo: 'bar' }]], - [ - [ + { parsedObject: {}, expectedError: 'Provided file does not have the right format.' }, + { parsedObject: undefined, expectedError: 'Provided file does not have the right format.' }, + { + parsedObject: [{ foo: 'bar' }], + expectedError: 'Server is missing required "url", "apiKey" and/or "name" properties', + }, + { + parsedObject: [ { url: 1, apiKey: 1, name: 1, }, ], - ], - [ - [ + expectedError: 'Server is missing required "url", "apiKey" and/or "name" properties', + }, + { + parsedObject: [ { url: 'foo', apiKey: 'foo', @@ -46,26 +50,29 @@ describe('ServersImporter', () => { }, { bar: 'foo' }, ], - ], - ])('rejects with error if provided file does not parse to valid list of servers', async (parsedObject) => { + expectedError: 'Server is missing required "url", "apiKey" and/or "name" properties', + }, + ])('rejects with error if provided file does not parse to valid list of servers', async ({ + parsedObject, + expectedError, + }) => { csvjsonMock.mockResolvedValue(parsedObject); - - await expect(importer.importServersFromFile(fileMock())).rejects.toEqual( - new Error('Provided file does not have the right format.'), - ); + await expect(importer.importServersFromFile(fileMock())).rejects.toEqual(new Error(expectedError)); }); it('reads file when a CSV containing valid servers is provided', async () => { - const expectedServers = [ + const expectedServers: Required[] = [ { url: 'foo', apiKey: 'foo', name: 'foo', + forwardCredentials: false, }, { url: 'bar', apiKey: 'bar', name: 'bar', + forwardCredentials: false, }, ]; diff --git a/vite.config.ts b/vite.config.ts index 122d7d9a..c33d949e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -44,7 +44,6 @@ export default defineConfig({ instances: [{ browser: 'chromium' }], }, globals: true, - allowOnly: true, setupFiles: './config/test/setupTests.ts', coverage: { provider: 'v8', @@ -61,8 +60,8 @@ export default defineConfig({ // Required code coverage. Lower than this will make the check fail thresholds: { statements: 95, - branches: 90, - functions: 90, + branches: 95, + functions: 95, lines: 95, }, },