@openmrs/esm-api 10.0.1-pre.5598 → 10.0.1-pre.5605

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,3 @@
1
- [0] Successfully compiled: 14 files with swc (89.34ms)
1
+ [0] Successfully compiled: 14 files with swc (61.75ms)
2
2
  [0] swc --strip-leading-paths src -d dist exited with code 0
3
3
  [1] tsc --project tsconfig.build.json exited with code 0
@@ -1 +1 @@
1
- {"version":3,"file":"openmrs-fetch.d.ts","sourceRoot":"","sources":["../src/openmrs-fetch.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAG7C,mEAAmE;AACnE,eAAO,MAAM,WAAW,gBAAgB,CAAC;AACzC,uEAAuE;AACvE,eAAO,MAAM,WAAW,iBAAiB,CAAC;AAC1C,sDAAsD;AACtD,eAAO,MAAM,eAAe,wBAA2B,CAAC;AAExD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,OAAO,CAAC,IAAI,EAAE,MAAM,UASnC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgDG;AACH,wBAAgB,YAAY,CAAC,CAAC,GAAG,GAAG,EAAE,IAAI,EAAE,MAAM,EAAE,SAAS,GAAE,WAAgB,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC,CAuK1G;AAED,qBAAa,iBAAkB,SAAQ,KAAM,YAAW,UAAU;gBACpD,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,QAAQ,EAAE,YAAY,EAAE,YAAY,GAAG,IAAI,EAAE,iBAAiB,EAAE,KAAK;IAQxG,QAAQ,EAAE,QAAQ,CAAC;IACnB,YAAY,EAAE,MAAM,GAAG,iBAAiB,GAAG,IAAI,CAAC;CACjD;AAED,MAAM,WAAW,WAAY,SAAQ,IAAI,CAAC,WAAW,EAAE,MAAM,GAAG,SAAS,CAAC;IACxE,OAAO,CAAC,EAAE,YAAY,CAAC;IACvB,IAAI,CAAC,EAAE,SAAS,GAAG,MAAM,CAAC;CAC3B;AAED,KAAK,YAAY,GAAG,MAAM,GAAG,iBAAiB,CAAC;AAE/C,MAAM,WAAW,YAAY;IAC3B,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;CAC9B;AAED,UAAU,SAAS;IACjB,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAC;CACpB;AAED,MAAM,WAAW,iBAAiB;IAChC,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAC;CACpB;AAED,MAAM,WAAW,UAAU;IACzB,QAAQ,EAAE,QAAQ,CAAC;IACnB,YAAY,EAAE,YAAY,GAAG,IAAI,CAAC;CACnC"}
1
+ {"version":3,"file":"openmrs-fetch.d.ts","sourceRoot":"","sources":["../src/openmrs-fetch.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAG7C,mEAAmE;AACnE,eAAO,MAAM,WAAW,gBAAgB,CAAC;AACzC,uEAAuE;AACvE,eAAO,MAAM,WAAW,iBAAiB,CAAC;AAC1C,sDAAsD;AACtD,eAAO,MAAM,eAAe,wBAA2B,CAAC;AAExD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,OAAO,CAAC,IAAI,EAAE,MAAM,UASnC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgDG;AACH,wBAAgB,YAAY,CAAC,CAAC,GAAG,GAAG,EAAE,IAAI,EAAE,MAAM,EAAE,SAAS,GAAE,WAAgB,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC,CAkM1G;AAED,qBAAa,iBAAkB,SAAQ,KAAM,YAAW,UAAU;gBACpD,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,QAAQ,EAAE,YAAY,EAAE,YAAY,GAAG,IAAI,EAAE,iBAAiB,EAAE,KAAK;IAQxG,QAAQ,EAAE,QAAQ,CAAC;IACnB,YAAY,EAAE,MAAM,GAAG,iBAAiB,GAAG,IAAI,CAAC;CACjD;AAED,MAAM,WAAW,WAAY,SAAQ,IAAI,CAAC,WAAW,EAAE,MAAM,GAAG,SAAS,CAAC;IACxE,OAAO,CAAC,EAAE,YAAY,CAAC;IACvB,IAAI,CAAC,EAAE,SAAS,GAAG,MAAM,CAAC;CAC3B;AAED,KAAK,YAAY,GAAG,MAAM,GAAG,iBAAiB,CAAC;AAE/C,MAAM,WAAW,YAAY;IAC3B,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;CAC9B;AAED,UAAU,SAAS;IACjB,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAC;CACpB;AAED,MAAM,WAAW,iBAAiB;IAChC,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAC;CACpB;AAED,MAAM,WAAW,UAAU;IACzB,QAAQ,EAAE,QAAQ,CAAC;IACnB,YAAY,EAAE,YAAY,GAAG,IAAI,CAAC;CACnC"}
@@ -123,8 +123,33 @@ import { defaultRedirectAuthFailureUrl } from "./config-schema.js";
123
123
  /* This tells the OpenMRS REST API not to return a WWW-Authenticate
124
124
  * header. Returning that header is useful when using the API, but
125
125
  * not from a UI.
126
- */ if (path.startsWith(restBaseUrl) && typeof fetchInit.headers['Disable-WWW-Authenticate'] === 'undefined') {
127
- fetchInit.headers['Disable-WWW-Authenticate'] = 'true';
126
+ */ let isRestRequest = false;
127
+ try {
128
+ const requestUrl = new URL(url, window.location.href);
129
+ const restUrl = new URL(makeUrl(restBaseUrl), window.location.href);
130
+ // Compare normalized paths without changing the outgoing URL. Pagination links
131
+ // may omit the extra slash introduced by an openmrsBase with a trailing slash.
132
+ const requestPath = requestUrl.pathname.replace(/\/{2,}/g, '/');
133
+ const restPath = restUrl.pathname.replace(/\/{2,}/g, '/');
134
+ isRestRequest = requestUrl.origin === restUrl.origin && (requestPath === restPath || requestPath.startsWith(`${restPath}/`));
135
+ } catch {
136
+ // Let native fetch reject malformed URLs instead of throwing synchronously here.
137
+ }
138
+ if (isRestRequest) {
139
+ let hasAuthHeader = false;
140
+ for (const [name, value] of Object.entries(fetchInit.headers)){
141
+ if (name.toLowerCase() === 'disable-www-authenticate') {
142
+ if (value === undefined) {
143
+ // Remove every casing so fetch cannot combine "undefined" with the default.
144
+ delete fetchInit.headers[name];
145
+ } else {
146
+ hasAuthHeader = true;
147
+ }
148
+ }
149
+ }
150
+ if (!hasAuthHeader) {
151
+ fetchInit.headers['Disable-WWW-Authenticate'] = 'true';
152
+ }
128
153
  }
129
154
  if (path.startsWith(fhirBaseUrl)) {
130
155
  const urlUrl = new URL(url, window.location.toString());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openmrs/esm-api",
3
- "version": "10.0.1-pre.5598",
3
+ "version": "10.0.1-pre.5605",
4
4
  "license": "MPL-2.0",
5
5
  "description": "The javascript module for interacting with the OpenMRS API",
6
6
  "type": "module",
@@ -57,16 +57,16 @@
57
57
  "lodash-es": "^4.17.21"
58
58
  },
59
59
  "peerDependencies": {
60
- "@openmrs/esm-config": "^10.0.1-pre.5598",
61
- "@openmrs/esm-error-handling": "^10.0.1-pre.5598",
62
- "@openmrs/esm-globals": "^10.0.1-pre.5598",
63
- "@openmrs/esm-navigation": "^10.0.1-pre.5598"
60
+ "@openmrs/esm-config": "^10.0.1-pre.5605",
61
+ "@openmrs/esm-error-handling": "^10.0.1-pre.5605",
62
+ "@openmrs/esm-globals": "^10.0.1-pre.5605",
63
+ "@openmrs/esm-navigation": "^10.0.1-pre.5605"
64
64
  },
65
65
  "devDependencies": {
66
- "@openmrs/esm-config": "10.0.1-pre.5598",
67
- "@openmrs/esm-error-handling": "10.0.1-pre.5598",
68
- "@openmrs/esm-globals": "10.0.1-pre.5598",
69
- "@openmrs/esm-navigation": "10.0.1-pre.5598",
66
+ "@openmrs/esm-config": "10.0.1-pre.5605",
67
+ "@openmrs/esm-error-handling": "10.0.1-pre.5605",
68
+ "@openmrs/esm-globals": "10.0.1-pre.5605",
69
+ "@openmrs/esm-navigation": "10.0.1-pre.5605",
70
70
  "@swc/cli": "0.8.1",
71
71
  "@swc/core": "1.15.21",
72
72
  "@vitest/coverage-v8": "^4.1.2",
@@ -0,0 +1,121 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { getConfig } from '@openmrs/esm-config';
3
+ import { openmrsFetch } from './openmrs-fetch';
4
+
5
+ vi.mock('@openmrs/esm-navigation', () => ({ clearHistory: vi.fn(), navigate: vi.fn() }));
6
+
7
+ const mockFetch = vi.fn<typeof fetch>();
8
+
9
+ beforeEach(() => {
10
+ window.openmrsBase = '/openmrs';
11
+ vi.stubGlobal('location', new URL('https://frontend.example/openmrs/spa/'));
12
+ vi.stubGlobal('fetch', mockFetch);
13
+ mockFetch.mockResolvedValue(new Response('{}'));
14
+ vi.mocked(getConfig).mockResolvedValue({
15
+ redirectAuthFailure: { enabled: false, url: '', errors: [401], resolvePromise: false },
16
+ followRedirects: true,
17
+ });
18
+ });
19
+
20
+ afterEach(() => vi.unstubAllGlobals());
21
+
22
+ describe('REST authentication challenge suppression', () => {
23
+ it('leaves malformed URLs to native fetch so callers can catch the rejected promise', async () => {
24
+ const error = new TypeError('Invalid URL');
25
+ mockFetch.mockRejectedValueOnce(error);
26
+
27
+ const request = openmrsFetch('http://[');
28
+
29
+ expect(request).toBeInstanceOf(Promise);
30
+ await expect(request.catch((reason) => reason)).resolves.toBe(error);
31
+ expect(mockFetch).toHaveBeenCalledWith('http://[', expect.any(Object));
32
+ });
33
+
34
+ it.each([
35
+ ['/openmrs', '/ws/rest/v1/patient', '/openmrs/ws/rest/v1/patient'],
36
+ ['/openmrs', 'ws/rest/v1/patient', '/openmrs/ws/rest/v1/patient'],
37
+ [
38
+ '/openmrs',
39
+ 'https://frontend.example/openmrs/ws/rest/v1/patient?startIndex=10',
40
+ 'https://frontend.example/openmrs/ws/rest/v1/patient?startIndex=10',
41
+ ],
42
+ ['/openmrs', '/ws/rest/v1', '/openmrs/ws/rest/v1'],
43
+ ['/openmrs/', '/ws/rest/v1/patient', '/openmrs//ws/rest/v1/patient'],
44
+ [
45
+ '/openmrs/',
46
+ 'https://frontend.example/openmrs/ws/rest/v1/patient',
47
+ 'https://frontend.example/openmrs/ws/rest/v1/patient',
48
+ ],
49
+ ['https://api.example/openmrs', '/ws/rest/v1/patient', 'https://api.example/openmrs/ws/rest/v1/patient'],
50
+ [
51
+ 'https://api.example/openmrs/',
52
+ 'https://api.example/openmrs/ws/rest/v1/patient',
53
+ 'https://api.example/openmrs/ws/rest/v1/patient',
54
+ ],
55
+ ])('adds the header for base %s and path %s without rewriting the request URL', async (base, path, expectedUrl) => {
56
+ window.openmrsBase = base;
57
+
58
+ await openmrsFetch(path);
59
+
60
+ expect(mockFetch).toHaveBeenCalledWith(expectedUrl, {
61
+ headers: { Accept: 'application/json', 'Disable-WWW-Authenticate': 'true' },
62
+ });
63
+ });
64
+
65
+ it.each([
66
+ ['/openmrs', 'https://other.example/openmrs/ws/rest/v1/patient'],
67
+ ['https://api.example/openmrs', 'https://frontend.example/openmrs/ws/rest/v1/patient'],
68
+ ['/openmrs', 'http://frontend.example/openmrs/ws/rest/v1/patient'],
69
+ ['/openmrs', 'https://frontend.example:8443/openmrs/ws/rest/v1/patient'],
70
+ ['/openmrs', 'https://frontend.example/another/ws/rest/v1/patient'],
71
+ ['/openmrs', '/ws/rest/v10/patient'],
72
+ ['/openmrs', '/ws/rest/v1relationship'],
73
+ ['/openmrs', '/ws/fhir2/R4/Patient'],
74
+ ])('does not add the header outside the configured REST API: %s, %s', async (base, path) => {
75
+ window.openmrsBase = base;
76
+
77
+ await openmrsFetch(path);
78
+
79
+ expect(new Headers(mockFetch.mock.calls[0][1]?.headers).has('Disable-WWW-Authenticate')).toBe(false);
80
+ });
81
+
82
+ it.each(['Disable-WWW-Authenticate', 'disable-www-authenticate', 'DISABLE-WWW-AUTHENTICATE'])(
83
+ 'preserves an explicit %s override, including null',
84
+ async (name) => {
85
+ for (const value of ['true', 'false', null]) {
86
+ // null retains legacy pass-through behavior, unlike Accept: null which removes the header.
87
+ const headers = { [name]: value };
88
+ await openmrsFetch('/ws/rest/v1/patient', { headers });
89
+ expect(mockFetch.mock.lastCall?.[1]?.headers).toEqual({ [name]: value, Accept: 'application/json' });
90
+ }
91
+ },
92
+ );
93
+
94
+ it.each([
95
+ { 'Disable-WWW-Authenticate': undefined },
96
+ { 'disable-www-authenticate': undefined },
97
+ { 'DISABLE-WWW-AUTHENTICATE': undefined },
98
+ { 'Disable-WWW-Authenticate': undefined, 'disable-www-authenticate': undefined },
99
+ ])('defaults undefined overrides without combining header values: %j', async (headers) => {
100
+ // @ts-expect-error JavaScript callers can supply undefined despite the FetchHeaders type.
101
+ await openmrsFetch('/ws/rest/v1/patient', { headers });
102
+
103
+ expect(mockFetch.mock.lastCall?.[1]?.headers).toEqual({
104
+ Accept: 'application/json',
105
+ 'Disable-WWW-Authenticate': 'true',
106
+ });
107
+ expect(new Headers(mockFetch.mock.lastCall?.[1]?.headers).get('Disable-WWW-Authenticate')).toBe('true');
108
+ });
109
+
110
+ it('preserves a defined override when another casing has an undefined value', async () => {
111
+ const headers = { 'Disable-WWW-Authenticate': undefined, 'disable-www-authenticate': 'false' };
112
+ // @ts-expect-error JavaScript callers can supply undefined despite the FetchHeaders type.
113
+ await openmrsFetch('/ws/rest/v1/patient', { headers });
114
+
115
+ expect(mockFetch.mock.lastCall?.[1]?.headers).toEqual({
116
+ Accept: 'application/json',
117
+ 'disable-www-authenticate': 'false',
118
+ });
119
+ expect(new Headers(mockFetch.mock.lastCall?.[1]?.headers).get('Disable-WWW-Authenticate')).toBe('false');
120
+ });
121
+ });
@@ -10,36 +10,35 @@ vi.mock('@openmrs/esm-navigation', () => ({
10
10
 
11
11
  const mockGetConfig = vi.mocked(getConfig);
12
12
  const mockNavigate = vi.mocked(navigate);
13
+ const mockFetch = vi.fn<typeof fetch>();
13
14
 
14
- describe('openmrsFetch', () => {
15
- beforeEach(() => {
16
- mockGetConfig.mockReturnValue(
17
- Promise.resolve({
18
- redirectAuthFailure: {
19
- enabled: true,
20
- url: '${openmrsSpaBase}/login',
21
- errors: [401],
22
- resolvePromise: false,
23
- },
24
- followRedirects: true,
25
- }),
26
- );
27
- window.openmrsBase = '/openmrs';
28
- window.getOpenmrsSpaBase = () => '/openmrs/spa/';
29
- window.fetch = vi.fn();
30
- Object.defineProperty(window, 'location', {
31
- writable: true,
32
- value: { assign: vi.fn() },
33
- });
34
- });
15
+ beforeEach(() => {
16
+ mockGetConfig.mockReturnValue(
17
+ Promise.resolve({
18
+ redirectAuthFailure: {
19
+ enabled: true,
20
+ url: '${openmrsSpaBase}/login',
21
+ errors: [401],
22
+ resolvePromise: false,
23
+ },
24
+ followRedirects: true,
25
+ }),
26
+ );
27
+ window.openmrsBase = '/openmrs';
28
+ window.getOpenmrsSpaBase = () => '/openmrs/spa/';
29
+ vi.stubGlobal('fetch', mockFetch);
30
+ vi.stubGlobal('location', new URL('https://frontend.example/openmrs/spa/'));
31
+ });
35
32
 
36
- afterEach(() => {
37
- // @ts-expect-error Not normally deletable
38
- delete window.openmrsBase;
39
- // @ts-expect-error Not normally deletable
40
- delete window.getOpenmrsSpaBase;
41
- });
33
+ afterEach(() => {
34
+ vi.unstubAllGlobals();
35
+ // @ts-expect-error Not normally deletable
36
+ delete window.openmrsBase;
37
+ // @ts-expect-error Not normally deletable
38
+ delete window.getOpenmrsSpaBase;
39
+ });
42
40
 
41
+ describe('openmrsFetch', () => {
43
42
  it(`throws an error if you don't pass in a url string`, () => {
44
43
  // @ts-expect-error
45
44
  expect(() => openmrsFetch()).toThrow(/first argument/);
@@ -63,8 +62,7 @@ describe('openmrsFetch', () => {
63
62
  });
64
63
 
65
64
  it('calls window.fetch with the correct arguments for a basic GET request', () => {
66
- // @ts-expect-error
67
- window.fetch.mockReturnValue(new Promise(() => {}));
65
+ mockFetch.mockReturnValue(new Promise(() => {}));
68
66
  openmrsFetch('/ws/rest/v1/session');
69
67
  expect(window.fetch).toHaveBeenCalledWith('/openmrs/ws/rest/v1/session', {
70
68
  headers: {
@@ -75,8 +73,7 @@ describe('openmrsFetch', () => {
75
73
  });
76
74
 
77
75
  it('calls window.fetch correctly for requests that have a request body', () => {
78
- // @ts-expect-error
79
- window.fetch.mockReturnValue(new Promise(() => {}));
76
+ mockFetch.mockReturnValue(new Promise(() => {}));
80
77
  const requestBody = { some: 'json' };
81
78
  openmrsFetch('/ws/rest/v1/session', {
82
79
  method: 'POST',
@@ -93,8 +90,7 @@ describe('openmrsFetch', () => {
93
90
  });
94
91
 
95
92
  it('allows you to specify your own Accept request header', () => {
96
- // @ts-expect-error mockReturnValue only exists on the mock, not on the raw type
97
- window.fetch.mockReturnValue(new Promise(() => {}));
93
+ mockFetch.mockReturnValue(new Promise(() => {}));
98
94
  openmrsFetch('/ws/rest/v1/session', {
99
95
  headers: {
100
96
  Accept: 'application/xml',
@@ -109,8 +105,7 @@ describe('openmrsFetch', () => {
109
105
  });
110
106
 
111
107
  it('allows you to specify no Accept request header to be sent', () => {
112
- // @ts-expect-error
113
- window.fetch.mockReturnValue(new Promise(() => {}));
108
+ mockFetch.mockReturnValue(new Promise(() => {}));
114
109
  openmrsFetch('/ws/rest/v1/session', {
115
110
  headers: {
116
111
  // specifically null on purpose
@@ -126,20 +121,7 @@ describe('openmrsFetch', () => {
126
121
  });
127
122
 
128
123
  it('returns a promise that resolves with a json object when the request succeeds', async () => {
129
- // @ts-expect-error
130
- window.fetch.mockReturnValue(
131
- Promise.resolve({
132
- ok: true,
133
- status: 200,
134
- headers: {
135
- has: () => false,
136
- get: () => null,
137
- },
138
- clone: () => ({
139
- text: () => Promise.resolve('{ "value": "hi" }'),
140
- }),
141
- }),
142
- );
124
+ mockFetch.mockResolvedValue(new Response('{"value":"hi"}'));
143
125
 
144
126
  const response = await openmrsFetch('/ws/rest/v1/session');
145
127
  expect(response.status).toBe(200);
@@ -147,18 +129,7 @@ describe('openmrsFetch', () => {
147
129
  });
148
130
 
149
131
  it('returns a promise that resolves with null when the request succeeds with HTTP 204', async () => {
150
- // @ts-expect-error
151
- window.fetch.mockReturnValue(
152
- Promise.resolve({
153
- ok: true,
154
- status: 204,
155
- headers: {
156
- has: () => false,
157
- get: () => null,
158
- },
159
- json: () => Promise.reject(Error("No json for HTTP 204's!!")),
160
- }),
161
- );
132
+ mockFetch.mockResolvedValue(new Response(null, { status: 204 }));
162
133
 
163
134
  const response = await openmrsFetch('/ws/rest/v1/session');
164
135
  expect(response.status).toBe(204);
@@ -166,56 +137,30 @@ describe('openmrsFetch', () => {
166
137
  });
167
138
 
168
139
  it('gives you an amazing error when the server responds with a 500 that has json', async () => {
169
- // @ts-expect-error
170
- window.fetch.mockReturnValue(
171
- Promise.resolve({
172
- ok: false,
140
+ mockFetch.mockResolvedValue(
141
+ new Response(JSON.stringify({ error: 'The server is dead' }), {
173
142
  status: 500,
174
143
  statusText: 'Internal Server Error',
175
- clone: () => ({
176
- text: () =>
177
- Promise.resolve(
178
- JSON.stringify({
179
- error: 'The server is dead',
180
- }),
181
- ),
182
- }),
183
144
  }),
184
145
  );
185
146
 
186
- try {
187
- await openmrsFetch('/ws/rest/v1/session');
188
- fail("Promise shouldn't resolve when server responds with 500");
189
- } catch (err) {
190
- expect(err.message).toMatch(/Server responded with 500 \(Internal Server Error\)/);
191
- expect(err.message).toMatch(/\/ws\/rest\/v1\/session/);
192
- expect(err.responseBody).toEqual({ error: 'The server is dead' });
193
- expect(err.response.status).toBe(500);
194
- }
147
+ await expect(openmrsFetch('/ws/rest/v1/session')).rejects.toMatchObject({
148
+ message: expect.stringContaining(
149
+ 'Server responded with 500 (Internal Server Error) for url /openmrs/ws/rest/v1/session',
150
+ ),
151
+ responseBody: { error: 'The server is dead' },
152
+ response: expect.objectContaining({ status: 500 }),
153
+ });
195
154
  });
196
155
 
197
156
  it("gives you an amazing error when the server responds with a 400 that doesn't have json", async () => {
198
- // @ts-expect-error
199
- window.fetch.mockReturnValue(
200
- Promise.resolve({
201
- ok: false,
202
- status: 400,
203
- statusText: 'You goofed up',
204
- clone: () => ({
205
- text: () => Promise.resolve('a string response body'),
206
- }),
207
- }),
208
- );
157
+ mockFetch.mockResolvedValue(new Response('a string response body', { status: 400, statusText: 'You goofed up' }));
209
158
 
210
- try {
211
- await openmrsFetch('/ws/rest/v1/session');
212
- fail("Promise shouldn't resolve when server responds with 400");
213
- } catch (err) {
214
- expect(err.message).toMatch(/Server responded with 400 \(You goofed up\)/);
215
- expect(err.message).toMatch(/\/ws\/rest\/v1\/session/);
216
- expect(err.responseBody).toEqual('a string response body');
217
- expect(err.response.status).toBe(400);
218
- }
159
+ await expect(openmrsFetch('/ws/rest/v1/session')).rejects.toMatchObject({
160
+ message: expect.stringContaining('Server responded with 400 (You goofed up) for url /openmrs/ws/rest/v1/session'),
161
+ responseBody: 'a string response body',
162
+ response: expect.objectContaining({ status: 400 }),
163
+ });
219
164
  });
220
165
 
221
166
  it('redirects to the Location header URL when a 401 response contains a Location header (auth-module challenge)', async () => {
@@ -228,18 +173,8 @@ describe('openmrsFetch', () => {
228
173
  },
229
174
  });
230
175
 
231
- // @ts-expect-error
232
- window.fetch.mockReturnValue(
233
- Promise.resolve({
234
- ok: false,
235
- status: 401,
236
- statusText: 'Unauthorized',
237
- headers: {
238
- has: (name: string) => name.toLowerCase() === 'location',
239
- get: (name: string) => (name.toLowerCase() === 'location' ? '/module/authentication/login.form' : null),
240
- },
241
- text: () => Promise.resolve(''),
242
- }),
176
+ mockFetch.mockResolvedValue(
177
+ new Response(null, { status: 401, headers: { Location: '/module/authentication/login.form' } }),
243
178
  );
244
179
 
245
180
  await openmrsFetch('/ws/rest/v1/session');
@@ -250,26 +185,9 @@ describe('openmrsFetch', () => {
250
185
  });
251
186
 
252
187
  it('redirects to the Location header URL when session endpoint called and it contains a Location header', async () => {
253
- // @ts-expect-error
254
- window.fetch.mockReturnValue(
255
- Promise.resolve({
256
- ok: true,
257
- status: 200,
258
- statusText: 'OK',
259
- headers: {
260
- has: (name: string) => name.toLowerCase() === 'location',
261
- get: (name: string) => (name.toLowerCase() === 'location' ? '/openmrs/spa/login' : null),
262
- },
263
- clone() {
264
- return this;
265
- },
266
- text: () => Promise.resolve(''),
267
- }),
268
- );
269
-
270
- const fetchPromise = openmrsFetch('/ws/rest/v1/session');
188
+ mockFetch.mockResolvedValue(new Response(null, { status: 200, headers: { Location: '/openmrs/spa/login' } }));
271
189
 
272
- await new Promise((resolve) => setTimeout(resolve, 0));
190
+ await openmrsFetch('/ws/rest/v1/session');
273
191
 
274
192
  expect(mockNavigate).toHaveBeenCalledWith({
275
193
  to: '/openmrs/spa/login',
@@ -286,19 +204,7 @@ describe('openmrsFetch', () => {
286
204
  },
287
205
  });
288
206
 
289
- // @ts-expect-error
290
- window.fetch.mockReturnValue(
291
- Promise.resolve({
292
- ok: false,
293
- status: 401,
294
- statusText: 'Unauthorized',
295
- headers: {
296
- has: (name: string) => false,
297
- get: (name: string) => null,
298
- },
299
- text: () => Promise.resolve(''),
300
- }),
301
- );
207
+ mockFetch.mockResolvedValue(new Response(null, { status: 401 }));
302
208
 
303
209
  await openmrsFetch('/ws/rest/v1/session');
304
210
 
@@ -317,15 +223,7 @@ describe('openmrsFetch', () => {
317
223
  },
318
224
  });
319
225
 
320
- // @ts-expect-error
321
- window.fetch.mockReturnValue(
322
- Promise.resolve({
323
- ok: false,
324
- status: 401,
325
- statusText: 'You are not authorized',
326
- text: () => Promise.resolve('a string response body'),
327
- }),
328
- );
226
+ mockFetch.mockResolvedValue(new Response('a string response body', { status: 401 }));
329
227
 
330
228
  await openmrsFetch('/ws/rest/v1/session');
331
229
 
@@ -134,8 +134,35 @@ export function openmrsFetch<T = any>(path: string, fetchInit: FetchConfig = {})
134
134
  * header. Returning that header is useful when using the API, but
135
135
  * not from a UI.
136
136
  */
137
- if (path.startsWith(restBaseUrl) && typeof fetchInit.headers['Disable-WWW-Authenticate'] === 'undefined') {
138
- fetchInit.headers['Disable-WWW-Authenticate'] = 'true';
137
+ let isRestRequest = false;
138
+ try {
139
+ const requestUrl = new URL(url, window.location.href);
140
+ const restUrl = new URL(makeUrl(restBaseUrl), window.location.href);
141
+ // Compare normalized paths without changing the outgoing URL. Pagination links
142
+ // may omit the extra slash introduced by an openmrsBase with a trailing slash.
143
+ const requestPath = requestUrl.pathname.replace(/\/{2,}/g, '/');
144
+ const restPath = restUrl.pathname.replace(/\/{2,}/g, '/');
145
+ isRestRequest =
146
+ requestUrl.origin === restUrl.origin && (requestPath === restPath || requestPath.startsWith(`${restPath}/`));
147
+ } catch {
148
+ // Let native fetch reject malformed URLs instead of throwing synchronously here.
149
+ }
150
+
151
+ if (isRestRequest) {
152
+ let hasAuthHeader = false;
153
+ for (const [name, value] of Object.entries(fetchInit.headers)) {
154
+ if (name.toLowerCase() === 'disable-www-authenticate') {
155
+ if (value === undefined) {
156
+ // Remove every casing so fetch cannot combine "undefined" with the default.
157
+ delete fetchInit.headers[name];
158
+ } else {
159
+ hasAuthHeader = true;
160
+ }
161
+ }
162
+ }
163
+ if (!hasAuthHeader) {
164
+ fetchInit.headers['Disable-WWW-Authenticate'] = 'true';
165
+ }
139
166
  }
140
167
 
141
168
  if (path.startsWith(fhirBaseUrl)) {