Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/browser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export {default as hasAuthParamsInUrl} from './utils/hasAuthParamsInUrl';
export {default as hasCalledForThisInstanceInUrl} from './utils/hasCalledForThisInstanceInUrl';
export {default as navigate} from './utils/navigate';
export {default as http} from './utils/http';
export {default as createHttpClientFetcher} from './utils/createHttpClientFetcher';
export {default as handleWebAuthnAuthentication} from './utils/handleWebAuthnAuthentication';
export {default as resolveEmojiUrisInHtml} from './utils/resolveEmojiUrisInHtml';
export {default as isAvatarUri, AVATAR_URI_SCHEME} from './utils/isAvatarUri';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Copyright 2026 The ThunderID Authors
// SPDX-License-Identifier: Apache-2.0

import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import FetchHttpClient from '../../FetchHttpClient';
import createHttpClientFetcher from '../createHttpClientFetcher';

const mockRequest = vi.fn();
let getInstanceSpy: ReturnType<typeof vi.spyOn>;

describe('createHttpClientFetcher', () => {
beforeEach(() => {
getInstanceSpy = vi
.spyOn(FetchHttpClient, 'getInstance')
.mockReturnValue({request: mockRequest} as unknown as FetchHttpClient);
});

afterEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
});

it('resolves an ok Response on a successful request', async () => {
mockRequest.mockResolvedValueOnce({data: {ok: true}, status: 200, statusText: 'OK'});

const response = await createHttpClientFetcher()('https://localhost:8090/x', {method: 'POST'});

expect(response.ok).toBe(true);
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({ok: true});
});

it('reconstructs a non-ok Response from a rejected request that carries a real HTTP response', async () => {
// FetchHttpClient throws on a non-2xx response rather than resolving it; this must convert
// that back into a resolved, non-ok Response so the caller sees the real status and body
// instead of treating it as a network failure.
mockRequest.mockRejectedValueOnce(
Object.assign(new Error('Bad Request'), {
response: {data: {code: 'USR-1017'}, status: 400, statusText: 'Bad Request'},
}),
);

const response = await createHttpClientFetcher()('https://localhost:8090/x', {method: 'POST'});

expect(response.ok).toBe(false);
expect(response.status).toBe(400);
await expect(response.json()).resolves.toEqual({code: 'USR-1017'});
});

it('rethrows a genuine network error with no attached response', async () => {
mockRequest.mockRejectedValueOnce(Object.assign(new Error('Failed to fetch'), {code: 'NETWORK_ERROR'}));

await expect(createHttpClientFetcher()('https://localhost:8090/x', {method: 'POST'})).rejects.toMatchObject({
code: 'NETWORK_ERROR',
});
});

it('uses the FetchHttpClient instance for the given instanceId', async () => {
mockRequest.mockResolvedValueOnce({data: undefined, status: 204, statusText: 'No Content'});

await createHttpClientFetcher(3)('https://localhost:8090/x', {method: 'POST'});

expect(getInstanceSpy).toHaveBeenCalledWith(3);
});
});
60 changes: 60 additions & 0 deletions packages/browser/src/utils/createHttpClientFetcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Copyright 2026 The ThunderID Authors
// SPDX-License-Identifier: Apache-2.0

import {HttpError, HttpRequestConfig, HttpResponse} from '@thunderid/javascript';
import FetchHttpClient from '../FetchHttpClient';

/**
* Builds a `fetch`-shaped function backed by the shared `FetchHttpClient` singleton, for
* framework wrappers (`@thunderid/react`, `@thunderid/vue`) that need to hand a core
* `@thunderid/javascript` API function a `fetcher` carrying the access token automatically.
*
* Unlike a plain `fetch` call, `FetchHttpClient.request` throws on a non-2xx response instead
* of resolving it. This adapter catches that and reconstructs a `Response`-shaped object from
* the thrown error's `response`, so the core API function sees the real status and body instead
* of treating every non-2xx result as a network failure. A genuine network error (no response
* attached) still propagates.
*
* @param instanceId - Which `FetchHttpClient` instance to use, for multi-instance apps. Defaults to `0`.
* @returns A function matching the `(url, config) => Promise<Response>` shape core API functions expect.
* @example
* ```typescript
* await updateMeCredentials({
* ...config,
* fetcher: fetcher ?? createHttpClientFetcher(instanceId),
* });
* ```
*/
const createHttpClientFetcher = (instanceId = 0): ((url: string, config: RequestInit) => Promise<Response>) => {
return async (url: string, config: RequestInit): Promise<Response> => {
const httpClient: FetchHttpClient = FetchHttpClient.getInstance(instanceId);

const toResponse = (data: unknown, status: number, statusText: string): Response =>
({
json: () => Promise.resolve(data),
ok: status >= 200 && status < 300,
status,
statusText,
text: () => Promise.resolve(typeof data === 'string' ? data : JSON.stringify(data)),
}) as Response;

try {
const response: HttpResponse<any> = await httpClient.request({
data: config.body ? JSON.parse(config.body as string) : undefined,
headers: config.headers as Record<string, string>,
method: config.method || 'POST',
url,
} as HttpRequestConfig);

return toResponse(response.data, response.status, response.statusText || '');
} catch (error) {
const httpError: HttpError = error as HttpError;
if (httpError?.response) {
return toResponse(httpError.response.data, httpError.response.status, httpError.response.statusText ?? '');
}
throw error;
}
};
};

export default createHttpClientFetcher;
180 changes: 180 additions & 0 deletions packages/javascript/src/api/__tests__/updateMeCredentials.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
// Copyright 2026 The ThunderID Authors
// SPDX-License-Identifier: Apache-2.0

import {Mock, beforeEach, describe, expect, it, vi} from 'vitest';
import ThunderIDAPIError from '../../errors/ThunderIDAPIError';
import updateMeCredentials from '../updateMeCredentials';

describe('updateMeCredentials', (): void => {
beforeEach((): void => {
vi.resetAllMocks();
});

it('should wrap the payload under attributes and resolve with no value on 204', async (): Promise<void> => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 204,
text: () => Promise.resolve(''),
});

const url = 'https://localhost:8090/users/me/update-credentials';

const result: void = await updateMeCredentials({
payload: {password: 'n3wP@ssword!'},
url,
});

expect(result).toBeUndefined();
expect(fetch).toHaveBeenCalledTimes(1);

const [calledUrl, init] = (fetch as unknown as Mock).mock.calls[0] as [string, RequestInit];

expect(calledUrl).toBe(url);
expect(init.method).toBe('POST');
expect((init.headers as Record<string, string>)['Content-Type']).toBe('application/json');
expect((init.headers as Record<string, string>)['Accept']).toBe('application/json');

const parsed = JSON.parse(init.body as string) as Record<string, unknown>;
expect(parsed).toEqual({attributes: {password: 'n3wP@ssword!'}});
});

it('should support updating more than one credential in a single call', async (): Promise<void> => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 204,
text: () => Promise.resolve(''),
});

await updateMeCredentials({
payload: {
password: 'n3wP@ss!',
pin: '1234',
},
url: 'https://localhost:8090/users/me/update-credentials',
});

const [, init] = (fetch as unknown as Mock).mock.calls[0] as [string, RequestInit];
const parsed = JSON.parse(init.body as string) as Record<string, unknown>;

expect(parsed).toEqual({
attributes: {
password: 'n3wP@ss!',
pin: '1234',
},
});
});

it('should never read the response body on success', async (): Promise<void> => {
const json: Mock = vi.fn();

global.fetch = vi.fn().mockResolvedValue({
json,
ok: true,
status: 204,
text: () => Promise.resolve(''),
});

await updateMeCredentials({
payload: {password: 'n3wP@ssword!'},
url: 'https://localhost:8090/users/me/update-credentials',
});

expect(json).not.toHaveBeenCalled();
});

it('should fall back to baseUrl when url is not provided', async (): Promise<void> => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 204,
text: () => Promise.resolve(''),
});

await updateMeCredentials({baseUrl: 'https://localhost:8090', payload: {password: 'n3wP@ssword!'}});

const [calledUrl] = (fetch as unknown as Mock).mock.calls[0] as [string, RequestInit];

expect(calledUrl).toBe('https://localhost:8090/users/me/update-credentials');
});

it('should strip a trailing slash from baseUrl', async (): Promise<void> => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 204,
text: () => Promise.resolve(''),
});

await updateMeCredentials({baseUrl: 'https://localhost:8090/', payload: {password: 'n3wP@ssword!'}});

const [calledUrl] = (fetch as unknown as Mock).mock.calls[0] as [string, RequestInit];

expect(calledUrl).toBe('https://localhost:8090/users/me/update-credentials');
});

it('should use a custom fetcher when provided', async (): Promise<void> => {
const fetcher: Mock = vi.fn().mockResolvedValue({
ok: true,
status: 204,
text: () => Promise.resolve(''),
});

global.fetch = vi.fn();

await updateMeCredentials({
fetcher,
payload: {password: 'n3wP@ssword!'},
url: 'https://localhost:8090/users/me/update-credentials',
});

expect(fetcher).toHaveBeenCalledTimes(1);
expect(fetch).not.toHaveBeenCalled();
});

it('should throw a validation error for a malformed URL', async (): Promise<void> => {
global.fetch = vi.fn();

await expect(updateMeCredentials({payload: {password: 'x'}, url: 'not-a-url'})).rejects.toThrow(ThunderIDAPIError);
expect(fetch).not.toHaveBeenCalled();

await expect(updateMeCredentials({payload: {password: 'x'}, url: 'not-a-url'})).rejects.toMatchObject({
code: 'updateMeCredentials-ValidationError-001',
});
});

it('should throw a response error carrying the server status', async (): Promise<void> => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 400,
statusText: 'Bad Request',
text: () =>
Promise.resolve(
JSON.stringify({
code: 'USR-1017',
message: {defaultValue: 'Missing credentials', key: 'error.userservice.missing_credentials'},
}),
),
});

await expect(
updateMeCredentials({
payload: {password: 'n3wP@ssword!'},
url: 'https://localhost:8090/users/me/update-credentials',
}),
).rejects.toMatchObject({
code: 'updateMeCredentials-ResponseError-001',
statusCode: 400,
});
});

it('should throw a network error when the request itself fails', async (): Promise<void> => {
global.fetch = vi.fn().mockRejectedValue(new Error('connection refused'));

await expect(
updateMeCredentials({
payload: {password: 'n3wP@ssword!'},
url: 'https://localhost:8090/users/me/update-credentials',
}),
).rejects.toMatchObject({
code: 'updateMeCredentials-NetworkError-001',
});
});
});
Loading
Loading