1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-23 16:13:21 +00:00

Remove duplicate code from merge.

This commit is contained in:
Todd Martin
2026-01-11 11:42:28 -05:00
parent a7902b2ba6
commit 50f504f96b
2 changed files with 0 additions and 801 deletions

View File

@@ -446,13 +446,6 @@ export abstract class ApiService {
abstract postBitPayInvoice(request: BitPayInvoiceRequest): Promise<string>;
abstract postSetupPayment(): Promise<string>;
/**
* Retrieves the bearer access token for the user.
* If the access token is expired or within 5 minutes of expiration, attemps to refresh the token
* and persists the refresh token to state before returning it.
* @param userId The user for whom we're retrieving the access token
* @returns The access token, or an Error if no access token exists.
*/
/**
* Retrieves the bearer access token for the user.
* If the access token is expired or within 5 minutes of expiration, attempts to refresh the token

View File

@@ -1245,798 +1245,4 @@ describe("ApiService", () => {
expect(logoutCallback).not.toHaveBeenCalled();
});
});
describe("When a 401 Unauthorized status is received", () => {
it("retries request with refreshed token when initial request with access token returns 401", async () => {
// This test verifies the 401 retry flow:
// 1. Initial request with valid token returns 401 (token expired server-side)
// 2. After 401, buildRequest is called again, which checks tokenNeedsRefresh
// 3. tokenNeedsRefresh returns true, triggering refreshToken via getActiveBearerToken
// 4. refreshToken makes an HTTP call to /connect/token to get new tokens
// 5. setTokens is called to store the new tokens, returning the refreshed access token
// 6. Request is retried with the refreshed token and succeeds
environmentService.getEnvironment$.calledWith(testActiveUser).mockReturnValue(
of({
getApiUrl: () => "https://example.com",
getIdentityUrl: () => "https://identity.example.com",
} satisfies Partial<Environment> as Environment),
);
httpOperations.createRequest.mockImplementation((url, request) => {
return {
url: url,
cache: request.cache,
credentials: request.credentials,
method: request.method,
mode: request.mode,
signal: request.signal,
headers: new Headers(request.headers),
} satisfies Partial<Request> as unknown as Request;
});
tokenService.getAccessToken.calledWith(testActiveUser).mockResolvedValue("access_token");
// First call (initial request): token doesn't need refresh yet
// Subsequent calls (after 401): token needs refresh, triggering the refresh flow
tokenService.tokenNeedsRefresh
.calledWith(testActiveUser)
.mockResolvedValueOnce(false)
.mockResolvedValue(true);
tokenService.getRefreshToken.calledWith(testActiveUser).mockResolvedValue("refresh_token");
tokenService.decodeAccessToken
.calledWith(testActiveUser)
.mockResolvedValue({ client_id: "web" });
tokenService.decodeAccessToken
.calledWith("new_access_token")
.mockResolvedValue({ sub: testActiveUser });
vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$
.calledWith(testActiveUser)
.mockReturnValue(of(VaultTimeoutAction.Lock));
vaultTimeoutSettingsService.getVaultTimeoutByUserId$
.calledWith(testActiveUser)
.mockReturnValue(of(VaultTimeoutStringType.Never));
tokenService.setTokens
.calledWith(
"new_access_token",
VaultTimeoutAction.Lock,
VaultTimeoutStringType.Never,
"new_refresh_token",
)
.mockResolvedValue({ accessToken: "new_access_token" });
const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
let callCount = 0;
nativeFetch.mockImplementation((request) => {
callCount++;
// First call: initial request with expired token returns 401
if (callCount === 1) {
return Promise.resolve({
ok: false,
status: 401,
json: () => Promise.resolve({ message: "Unauthorized" }),
headers: new Headers({
"content-type": "application/json",
}),
} satisfies Partial<Response> as Response);
}
// Second call: token refresh request
if (callCount === 2 && request.url.includes("identity")) {
return Promise.resolve({
ok: true,
status: 200,
json: () =>
Promise.resolve({
access_token: "new_access_token",
token_type: "Bearer",
refresh_token: "new_refresh_token",
}),
} satisfies Partial<Response> as Response);
}
// Third call: retry with refreshed token succeeds
if (callCount === 3) {
expect(request.headers.get("Authorization")).toBe("Bearer new_access_token");
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve({ data: "success" }),
headers: new Headers({
"content-type": "application/json",
}),
} satisfies Partial<Response> as Response);
}
throw new Error(`Unexpected call #${callCount}: ${request.method} ${request.url}`);
});
sut.nativeFetch = nativeFetch;
const response = await sut.send("GET", "/something", null, true, true, null, null);
expect(nativeFetch).toHaveBeenCalledTimes(3);
expect(response).toEqual({ data: "success" });
});
it("does not retry when request has no access token and returns 401", async () => {
environmentService.environment$ = of({
getApiUrl: () => "https://example.com",
} satisfies Partial<Environment> as Environment);
httpOperations.createRequest.mockImplementation((url, request) => {
return {
url: url,
cache: request.cache,
credentials: request.credentials,
method: request.method,
mode: request.mode,
signal: request.signal,
headers: new Headers(request.headers),
} satisfies Partial<Request> as unknown as Request;
});
const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
nativeFetch.mockImplementation((request) => {
return Promise.resolve({
ok: false,
status: 401,
json: () => Promise.resolve({ message: "Unauthorized" }),
headers: new Headers({
"content-type": "application/json",
}),
} satisfies Partial<Response> as Response);
});
sut.nativeFetch = nativeFetch;
await expect(
async () => await sut.send("GET", "/something", null, false, true, null, null),
).rejects.toMatchObject({ message: "Unauthorized" });
// Should only be called once (no retry)
expect(nativeFetch).toHaveBeenCalledTimes(1);
});
it("does not retry when request returns non-401 error", async () => {
environmentService.getEnvironment$.calledWith(testActiveUser).mockReturnValue(
of({
getApiUrl: () => "https://example.com",
getIdentityUrl: () => "https://identity.example.com",
} satisfies Partial<Environment> as Environment),
);
httpOperations.createRequest.mockImplementation((url, request) => {
return {
url: url,
cache: request.cache,
credentials: request.credentials,
method: request.method,
mode: request.mode,
signal: request.signal,
headers: new Headers(request.headers),
} satisfies Partial<Request> as unknown as Request;
});
tokenService.getAccessToken.calledWith(testActiveUser).mockResolvedValue("valid_token");
tokenService.tokenNeedsRefresh.calledWith(testActiveUser).mockResolvedValue(false);
const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
nativeFetch.mockImplementation((request) => {
return Promise.resolve({
ok: false,
status: 400,
json: () => Promise.resolve({ message: "Bad Request" }),
headers: new Headers({
"content-type": "application/json",
}),
} satisfies Partial<Response> as Response);
});
sut.nativeFetch = nativeFetch;
await expect(
async () => await sut.send("GET", "/something", null, true, true, null, null),
).rejects.toMatchObject({ message: "Bad Request" });
// Should only be called once (no retry for non-401 errors)
expect(nativeFetch).toHaveBeenCalledTimes(1);
});
it("does not attempt to log out unauthenticated user", async () => {
environmentService.environment$ = of({
getApiUrl: () => "https://example.com",
} satisfies Partial<Environment> as Environment);
httpOperations.createRequest.mockImplementation((url, request) => {
return {
url: url,
cache: request.cache,
credentials: request.credentials,
method: request.method,
mode: request.mode,
signal: request.signal,
headers: new Headers(request.headers),
} satisfies Partial<Request> as unknown as Request;
});
const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
nativeFetch.mockImplementation((request) => {
return Promise.resolve({
ok: false,
status: 401,
json: () => Promise.resolve({ message: "Unauthorized" }),
headers: new Headers({
"content-type": "application/json",
}),
} satisfies Partial<Response> as Response);
});
sut.nativeFetch = nativeFetch;
await expect(
async () => await sut.send("GET", "/something", null, false, true, null, null),
).rejects.toMatchObject({ message: "Unauthorized" });
expect(logoutCallback).not.toHaveBeenCalled();
});
it("does not retry when hasResponse is false", async () => {
environmentService.environment$ = of({
getApiUrl: () => "https://example.com",
} satisfies Partial<Environment> as Environment);
environmentService.getEnvironment$.calledWith(testActiveUser).mockReturnValue(
of({
getApiUrl: () => "https://example.com",
getIdentityUrl: () => "https://identity.example.com",
} satisfies Partial<Environment> as Environment),
);
httpOperations.createRequest.mockImplementation((url, request) => {
return {
url: url,
cache: request.cache,
credentials: request.credentials,
method: request.method,
mode: request.mode,
signal: request.signal,
headers: new Headers(request.headers),
} satisfies Partial<Request> as unknown as Request;
});
tokenService.getAccessToken.calledWith(testActiveUser).mockResolvedValue("expired_token");
tokenService.tokenNeedsRefresh.calledWith(testActiveUser).mockResolvedValue(false);
const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
nativeFetch.mockImplementation((request) => {
return Promise.resolve({
ok: false,
status: 401,
json: () => Promise.resolve({ message: "Unauthorized" }),
headers: new Headers({
"content-type": "application/json",
}),
} satisfies Partial<Response> as Response);
});
sut.nativeFetch = nativeFetch;
// When hasResponse is false, the method should throw even though no retry happens
await expect(
async () => await sut.send("POST", "/something", null, true, false, null, null),
).rejects.toMatchObject({ message: "Unauthorized" });
// Should only be called once (no retry when hasResponse is false)
expect(nativeFetch).toHaveBeenCalledTimes(1);
});
it("uses original user token for retry even if active user changes between requests", async () => {
// Setup: Initial request is for testActiveUser, but during the retry, the active user switches
// to testInactiveUser. The retry should still use testActiveUser's refreshed token.
let activeUserId = testActiveUser;
// Mock accountService to return different active users based on when it's called
accountService.activeAccount$ = of({
id: activeUserId,
email: "user1@example.com",
emailVerified: true,
name: "Test Name",
} satisfies ObservedValueOf<AccountService["activeAccount$"]>);
environmentService.getEnvironment$.calledWith(testActiveUser).mockReturnValue(
of({
getApiUrl: () => "https://example.com",
getIdentityUrl: () => "https://identity.example.com",
} satisfies Partial<Environment> as Environment),
);
environmentService.getEnvironment$.calledWith(testInactiveUser).mockReturnValue(
of({
getApiUrl: () => "https://inactive.example.com",
getIdentityUrl: () => "https://identity.inactive.example.com",
} satisfies Partial<Environment> as Environment),
);
httpOperations.createRequest.mockImplementation((url, request) => {
return {
url: url,
cache: request.cache,
credentials: request.credentials,
method: request.method,
mode: request.mode,
signal: request.signal,
headers: new Headers(request.headers),
} satisfies Partial<Request> as unknown as Request;
});
tokenService.getAccessToken
.calledWith(testActiveUser)
.mockResolvedValue("active_access_token");
tokenService.tokenNeedsRefresh
.calledWith(testActiveUser)
.mockResolvedValueOnce(false)
.mockResolvedValue(true);
tokenService.getRefreshToken
.calledWith(testActiveUser)
.mockResolvedValue("active_refresh_token");
tokenService.decodeAccessToken
.calledWith(testActiveUser)
.mockResolvedValue({ client_id: "web" });
tokenService.decodeAccessToken
.calledWith("active_new_access_token")
.mockResolvedValue({ sub: testActiveUser });
vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$
.calledWith(testActiveUser)
.mockReturnValue(of(VaultTimeoutAction.Lock));
vaultTimeoutSettingsService.getVaultTimeoutByUserId$
.calledWith(testActiveUser)
.mockReturnValue(of(VaultTimeoutStringType.Never));
tokenService.setTokens
.calledWith(
"active_new_access_token",
VaultTimeoutAction.Lock,
VaultTimeoutStringType.Never,
"active_new_refresh_token",
)
.mockResolvedValue({ accessToken: "active_new_access_token" });
// Mock tokens for inactive user (should NOT be used)
tokenService.getAccessToken
.calledWith(testInactiveUser)
.mockResolvedValue("inactive_access_token");
const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
let callCount = 0;
nativeFetch.mockImplementation((request) => {
callCount++;
// First call: initial request with active user's token returns 401
if (callCount === 1) {
expect(request.url).toBe("https://example.com/something");
expect(request.headers.get("Authorization")).toBe("Bearer active_access_token");
// After the 401, simulate active user changing
activeUserId = testInactiveUser;
accountService.activeAccount$ = of({
id: testInactiveUser,
email: "user2@example.com",
emailVerified: true,
name: "Inactive User",
} satisfies ObservedValueOf<AccountService["activeAccount$"]>);
return Promise.resolve({
ok: false,
status: 401,
json: () => Promise.resolve({ message: "Unauthorized" }),
headers: new Headers({
"content-type": "application/json",
}),
} satisfies Partial<Response> as Response);
}
// Second call: token refresh request for ORIGINAL user (testActiveUser)
if (callCount === 2 && request.url.includes("identity")) {
expect(request.url).toContain("identity.example.com");
return Promise.resolve({
ok: true,
status: 200,
json: () =>
Promise.resolve({
access_token: "active_new_access_token",
token_type: "Bearer",
refresh_token: "active_new_refresh_token",
}),
} satisfies Partial<Response> as Response);
}
// Third call: retry with ORIGINAL user's refreshed token, NOT the new active user's token
if (callCount === 3) {
expect(request.url).toBe("https://example.com/something");
expect(request.headers.get("Authorization")).toBe("Bearer active_new_access_token");
// Verify we're NOT using the inactive user's endpoint
expect(request.url).not.toContain("inactive");
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve({ data: "success with original user" }),
headers: new Headers({
"content-type": "application/json",
}),
} satisfies Partial<Response> as Response);
}
throw new Error(`Unexpected call #${callCount}: ${request.method} ${request.url}`);
});
sut.nativeFetch = nativeFetch;
// Explicitly pass testActiveUser to ensure the request is for that specific user
const response = await sut.send("GET", "/something", null, testActiveUser, true, null, null);
expect(nativeFetch).toHaveBeenCalledTimes(3);
expect(response).toEqual({ data: "success with original user" });
// Verify that inactive user's token was never requested
expect(tokenService.getAccessToken.calledWith(testInactiveUser)).not.toHaveBeenCalled();
});
it("throws error when retry also returns 401", async () => {
environmentService.getEnvironment$.calledWith(testActiveUser).mockReturnValue(
of({
getApiUrl: () => "https://example.com",
getIdentityUrl: () => "https://identity.example.com",
} satisfies Partial<Environment> as Environment),
);
httpOperations.createRequest.mockImplementation((url, request) => {
return {
url: url,
cache: request.cache,
credentials: request.credentials,
method: request.method,
mode: request.mode,
signal: request.signal,
headers: new Headers(request.headers),
} satisfies Partial<Request> as unknown as Request;
});
tokenService.getAccessToken.calledWith(testActiveUser).mockResolvedValue("access_token");
// First call (initial request): token doesn't need refresh yet
// Subsequent calls (after 401): token needs refresh, triggering the refresh flow
tokenService.tokenNeedsRefresh
.calledWith(testActiveUser)
.mockResolvedValueOnce(false)
.mockResolvedValue(true);
tokenService.getRefreshToken.calledWith(testActiveUser).mockResolvedValue("refresh_token");
tokenService.decodeAccessToken
.calledWith(testActiveUser)
.mockResolvedValue({ client_id: "web" });
tokenService.decodeAccessToken
.calledWith("new_access_token")
.mockResolvedValue({ sub: testActiveUser });
vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$
.calledWith(testActiveUser)
.mockReturnValue(of(VaultTimeoutAction.Lock));
vaultTimeoutSettingsService.getVaultTimeoutByUserId$
.calledWith(testActiveUser)
.mockReturnValue(of(VaultTimeoutStringType.Never));
tokenService.setTokens
.calledWith(
"new_access_token",
VaultTimeoutAction.Lock,
VaultTimeoutStringType.Never,
"new_refresh_token",
)
.mockResolvedValue({ accessToken: "new_access_token" });
const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
let callCount = 0;
nativeFetch.mockImplementation((request) => {
callCount++;
// First call: initial request with expired token returns 401
if (callCount === 1) {
return Promise.resolve({
ok: false,
status: 401,
json: () => Promise.resolve({ message: "Unauthorized" }),
headers: new Headers({
"content-type": "application/json",
}),
} satisfies Partial<Response> as Response);
}
// Second call: token refresh request
if (callCount === 2 && request.url.includes("identity")) {
return Promise.resolve({
ok: true,
status: 200,
json: () =>
Promise.resolve({
access_token: "new_access_token",
token_type: "Bearer",
refresh_token: "new_refresh_token",
}),
} satisfies Partial<Response> as Response);
}
// Third call: retry with refreshed token still returns 401 (user no longer has permission)
if (callCount === 3) {
return Promise.resolve({
ok: false,
status: 401,
json: () => Promise.resolve({ message: "Still Unauthorized" }),
headers: new Headers({
"content-type": "application/json",
}),
} satisfies Partial<Response> as Response);
}
throw new Error("Unexpected call");
});
sut.nativeFetch = nativeFetch;
await expect(
async () => await sut.send("GET", "/something", null, true, true, null, null),
).rejects.toMatchObject({ message: "Still Unauthorized" });
expect(nativeFetch).toHaveBeenCalledTimes(3);
expect(logoutCallback).toHaveBeenCalledWith("invalidAccessToken");
});
it("handles concurrent requests that both receive 401 and share token refresh", async () => {
// This test verifies the race condition scenario:
// 1. Request A starts with valid token
// 2. Request B starts with valid token
// 3. Request A gets 401, triggers refresh
// 4. Request B gets 401 while A is refreshing
// 5. Request B should wait for A's refresh to complete (via refreshTokenPromise cache)
// 6. Both requests retry with the new token
environmentService.getEnvironment$.calledWith(testActiveUser).mockReturnValue(
of({
getApiUrl: () => "https://example.com",
getIdentityUrl: () => "https://identity.example.com",
} satisfies Partial<Environment> as Environment),
);
httpOperations.createRequest.mockImplementation((url, request) => {
return {
url: url,
cache: request.cache,
credentials: request.credentials,
method: request.method,
mode: request.mode,
signal: request.signal,
headers: new Headers(request.headers),
} satisfies Partial<Request> as unknown as Request;
});
tokenService.getAccessToken.calledWith(testActiveUser).mockResolvedValue("expired_token");
// First two calls: token doesn't need refresh yet
// Subsequent calls: token needs refresh
tokenService.tokenNeedsRefresh
.calledWith(testActiveUser)
.mockResolvedValueOnce(false) // Request A initial
.mockResolvedValueOnce(false) // Request B initial
.mockResolvedValue(true); // Both retries after 401
tokenService.getRefreshToken.calledWith(testActiveUser).mockResolvedValue("refresh_token");
tokenService.decodeAccessToken
.calledWith(testActiveUser)
.mockResolvedValue({ client_id: "web" });
tokenService.decodeAccessToken
.calledWith("new_access_token")
.mockResolvedValue({ sub: testActiveUser });
vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$
.calledWith(testActiveUser)
.mockReturnValue(of(VaultTimeoutAction.Lock));
vaultTimeoutSettingsService.getVaultTimeoutByUserId$
.calledWith(testActiveUser)
.mockReturnValue(of(VaultTimeoutStringType.Never));
tokenService.setTokens
.calledWith(
"new_access_token",
VaultTimeoutAction.Lock,
VaultTimeoutStringType.Never,
"new_refresh_token",
)
.mockResolvedValue({ accessToken: "new_access_token" });
const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
let apiRequestCount = 0;
let refreshRequestCount = 0;
nativeFetch.mockImplementation((request) => {
if (request.url.includes("identity")) {
refreshRequestCount++;
// Simulate slow token refresh to expose race condition
return new Promise((resolve) =>
setTimeout(
() =>
resolve({
ok: true,
status: 200,
json: () =>
Promise.resolve({
access_token: "new_access_token",
token_type: "Bearer",
refresh_token: "new_refresh_token",
}),
} satisfies Partial<Response> as Response),
100,
),
);
}
apiRequestCount++;
const currentCall = apiRequestCount;
// First two calls (Request A and B initial attempts): both return 401
if (currentCall === 1 || currentCall === 2) {
return Promise.resolve({
ok: false,
status: 401,
json: () => Promise.resolve({ message: "Unauthorized" }),
headers: new Headers({
"content-type": "application/json",
}),
} satisfies Partial<Response> as Response);
}
// Third and fourth calls (retries after refresh): both succeed
if (currentCall === 3 || currentCall === 4) {
expect(request.headers.get("Authorization")).toBe("Bearer new_access_token");
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve({ data: `success-${currentCall}` }),
headers: new Headers({
"content-type": "application/json",
}),
} satisfies Partial<Response> as Response);
}
throw new Error(`Unexpected API call #${currentCall}: ${request.method} ${request.url}`);
});
sut.nativeFetch = nativeFetch;
// Make two concurrent requests
const [responseA, responseB] = await Promise.all([
sut.send("GET", "/endpoint-a", null, testActiveUser, true, null, null),
sut.send("GET", "/endpoint-b", null, testActiveUser, true, null, null),
]);
// Both requests should succeed
expect(responseA).toMatchObject({ data: expect.stringContaining("success") });
expect(responseB).toMatchObject({ data: expect.stringContaining("success") });
// Verify only ONE token refresh was made (they shared the refresh)
expect(refreshRequestCount).toBe(1);
// Verify the total number of API requests: 2 initial + 2 retries = 4
expect(apiRequestCount).toBe(4);
// Verify setTokens was only called once
expect(tokenService.setTokens).toHaveBeenCalledTimes(1);
});
});
describe("When 403 Forbidden response is received from API request", () => {
it("logs out the authenticated user", async () => {
environmentService.getEnvironment$.calledWith(testActiveUser).mockReturnValue(
of({
getApiUrl: () => "https://example.com",
} satisfies Partial<Environment> as Environment),
);
httpOperations.createRequest.mockImplementation((url, request) => {
return {
url: url,
cache: request.cache,
credentials: request.credentials,
method: request.method,
mode: request.mode,
signal: request.signal,
headers: new Headers(request.headers),
} satisfies Partial<Request> as unknown as Request;
});
tokenService.getAccessToken.calledWith(testActiveUser).mockResolvedValue("valid_token");
tokenService.tokenNeedsRefresh.calledWith(testActiveUser).mockResolvedValue(false);
const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
nativeFetch.mockImplementation((request) => {
return Promise.resolve({
ok: false,
status: 403,
json: () => Promise.resolve({ message: "Forbidden" }),
headers: new Headers({
"content-type": "application/json",
}),
} satisfies Partial<Response> as Response);
});
sut.nativeFetch = nativeFetch;
await expect(
async () => await sut.send("GET", "/something", null, true, true, null, null),
).rejects.toMatchObject({ message: "Forbidden" });
expect(logoutCallback).toHaveBeenCalledWith("invalidAccessToken");
});
it("does not attempt to log out unauthenticated user", async () => {
environmentService.environment$ = of({
getApiUrl: () => "https://example.com",
} satisfies Partial<Environment> as Environment);
httpOperations.createRequest.mockImplementation((url, request) => {
return {
url: url,
cache: request.cache,
credentials: request.credentials,
method: request.method,
mode: request.mode,
signal: request.signal,
headers: new Headers(request.headers),
} satisfies Partial<Request> as unknown as Request;
});
const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
nativeFetch.mockImplementation((request) => {
return Promise.resolve({
ok: false,
status: 403,
json: () => Promise.resolve({ message: "Forbidden" }),
headers: new Headers({
"content-type": "application/json",
}),
} satisfies Partial<Response> as Response);
});
sut.nativeFetch = nativeFetch;
await expect(
async () => await sut.send("GET", "/something", null, false, true, null, null),
).rejects.toMatchObject({ message: "Forbidden" });
expect(logoutCallback).not.toHaveBeenCalled();
});
});
});