mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2026-02-18 02:55:51 +00:00
Merge pull request #1523 from acelaya-forks/feature/server-credentials
Add advanced options to servers
This commit is contained in:
commit
557cd4f7e2
@ -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
14
package-lock.json
generated
@ -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"
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
|
||||
@ -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',
|
||||
};
|
||||
};
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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]);
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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,
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user