Merge pull request #1523 from acelaya-forks/feature/server-credentials

Add advanced options to servers
This commit is contained in:
Alejandro Celaya 2025-04-20 17:02:24 +02:00 committed by GitHub
commit 557cd4f7e2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 230 additions and 101 deletions

View File

@ -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

14
package-lock.json generated
View File

@ -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"
}

View File

@ -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",

View File

@ -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

View File

@ -4,7 +4,7 @@ import type { GetState } from '../../container/types';
import type { ServerWithId } from '../../servers/data';
import { hasServerData } from '../../servers/data';
const apiClients: Record<string, ShlinkApiClient> = {};
const apiClients: Map<string, ShlinkApiClient> = 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;
};

View File

@ -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<string, string> => ({
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<string, unknown>): 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',
};
};

View File

@ -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<ServerFormProps> = ({ 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 (
<form name="serverForm" onSubmit={handleSubmit}>
@ -31,6 +39,23 @@ export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, child
onChange={(e) => setApiKey(e.target.value)}
required
/>
<Details summary="Advanced options">
<div className="tw:flex tw:flex-col tw:gap-0.5">
<Label className="tw:flex tw:items-center tw:gap-x-1.5 tw:cursor-pointer">
<Checkbox onChange={toggleForwardCredentials} checked={forwardCredentials} />
Forward credentials to this server on every request.
</Label>
<small className="tw:pl-5.5 tw:text-gray-600 tw:dark:text-gray-400 tw:mt-0.5">
{'"'}Credentials{'"'} here means cookies, TLS client certificates, or authentication headers containing a username
and password.
</small>
<small className="tw:pl-5.5 tw:text-gray-600 tw:dark:text-gray-400">
<b>Important!</b> 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 <code className="tw:whitespace-nowrap">Access-Control-Allow-Origin</code> than <code>*</code>.
</small>
</div>
</Details>
</SimpleCard>
<div className="tw:flex tw:items-center tw:justify-end tw:gap-x-2">{children}</div>

View File

@ -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<ServersMap>('servers') ?? {}).map(serverWithIdToServerData);
const servers = Object.values(this.#storage.get<ServersMap>('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);

View File

@ -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<ServerData[]> {
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);
}
}

View File

@ -1,6 +1,11 @@
import type { SyntheticEvent } from 'react';
import { useCallback } from 'react';
export const handleEventPreventingDefault = <T>(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 = <Event extends SyntheticEvent = SyntheticEvent>(handler: (e: Event) => void) =>
useCallback((e: Event) => {
e.preventDefault();
handler(e);
}, [handler]);

View File

@ -6,8 +6,8 @@ import type { ReachableServer, SelectedServer } from '../../../src/servers/data'
describe('ShlinkApiClientBuilder', () => {
const server = fromPartial<ReachableServer>;
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<HttpClient>({ 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<HttpClient>({ 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,
},

View File

@ -36,7 +36,7 @@ describe('<DeleteServerModal />', () => {
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) => {

View File

@ -47,16 +47,16 @@ describe('<EditServer />', () => {
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('<EditServer />', () => {
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,
}));
});
});

View File

@ -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('<ServerForm />', () => {
const onSubmit = vi.fn();
const setUp = () => render(<ServerForm onSubmit={onSubmit}>Something</ServerForm>);
const setUp = () => renderWithEvents(<ServerForm onSubmit={onSubmit}>Something</ServerForm>);
it('passes a11y checks', () => checkAccessibility(setUp()));
@ -15,6 +16,7 @@ describe('<ServerForm />', () => {
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('<ServerForm />', () => {
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();
});
});

View File

@ -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<LocalStorage>({
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));
});
});
});

View File

@ -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<ServerData>[] = [
{
url: 'foo',
apiKey: 'foo',
name: 'foo',
forwardCredentials: false,
},
{
url: 'bar',
apiKey: 'bar',
name: 'bar',
forwardCredentials: false,
},
];

View File

@ -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,
},
},