mirror of
https://github.com/bitwarden/browser
synced 2026-02-12 22:44:11 +00:00
1249 lines
44 KiB
TypeScript
1249 lines
44 KiB
TypeScript
import { mock, MockProxy } from "jest-mock-extended";
|
|
import { ObservedValueOf, of } from "rxjs";
|
|
|
|
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
|
// eslint-disable-next-line no-restricted-imports
|
|
import { LogoutReason } from "@bitwarden/auth/common";
|
|
import { UserId } from "@bitwarden/user-core";
|
|
|
|
import { mockAccountInfoWith } from "../../spec";
|
|
import { AccountService } from "../auth/abstractions/account.service";
|
|
import { TokenService } from "../auth/abstractions/token.service";
|
|
import { DeviceType } from "../enums";
|
|
import {
|
|
VaultTimeoutAction,
|
|
VaultTimeoutSettingsService,
|
|
VaultTimeoutStringType,
|
|
} from "../key-management/vault-timeout";
|
|
import { ErrorResponse } from "../models/response/error.response";
|
|
import { AppIdService } from "../platform/abstractions/app-id.service";
|
|
import { Environment, EnvironmentService } from "../platform/abstractions/environment.service";
|
|
import { LogService } from "../platform/abstractions/log.service";
|
|
import { PlatformUtilsService } from "../platform/abstractions/platform-utils.service";
|
|
|
|
import { InsecureUrlNotAllowedError } from "./api-errors";
|
|
import { ApiService, HttpOperations } from "./api.service";
|
|
|
|
describe("ApiService", () => {
|
|
let tokenService: MockProxy<TokenService>;
|
|
let platformUtilsService: MockProxy<PlatformUtilsService>;
|
|
let environmentService: MockProxy<EnvironmentService>;
|
|
let appIdService: MockProxy<AppIdService>;
|
|
let refreshAccessTokenErrorCallback: jest.Mock<void, []>;
|
|
let logService: MockProxy<LogService>;
|
|
let logoutCallback: jest.Mock<Promise<void>, [reason: LogoutReason]>;
|
|
let vaultTimeoutSettingsService: MockProxy<VaultTimeoutSettingsService>;
|
|
let accountService: MockProxy<AccountService>;
|
|
let httpOperations: MockProxy<HttpOperations>;
|
|
|
|
let sut: ApiService;
|
|
|
|
const testActiveUser = "activeUser" as UserId;
|
|
const testInactiveUser = "inactiveUser" as UserId;
|
|
|
|
beforeEach(() => {
|
|
tokenService = mock();
|
|
platformUtilsService = mock();
|
|
platformUtilsService.getDevice.mockReturnValue(DeviceType.ChromeExtension);
|
|
|
|
environmentService = mock();
|
|
appIdService = mock();
|
|
refreshAccessTokenErrorCallback = jest.fn();
|
|
logService = mock();
|
|
logoutCallback = jest.fn();
|
|
vaultTimeoutSettingsService = mock();
|
|
accountService = mock();
|
|
|
|
accountService.activeAccount$ = of({
|
|
id: testActiveUser,
|
|
...mockAccountInfoWith({
|
|
email: "user1@example.com",
|
|
name: "Test Name",
|
|
}),
|
|
} satisfies ObservedValueOf<AccountService["activeAccount$"]>);
|
|
|
|
httpOperations = mock();
|
|
|
|
sut = new ApiService(
|
|
tokenService,
|
|
platformUtilsService,
|
|
environmentService,
|
|
appIdService,
|
|
refreshAccessTokenErrorCallback,
|
|
logService,
|
|
logoutCallback,
|
|
vaultTimeoutSettingsService,
|
|
accountService,
|
|
httpOperations,
|
|
"custom-user-agent",
|
|
);
|
|
});
|
|
|
|
describe("send", () => {
|
|
it("handles ok GET", async () => {
|
|
environmentService.environment$ = of({
|
|
getApiUrl: () => "https://example.com",
|
|
} satisfies Partial<Environment> as Environment);
|
|
|
|
environmentService.getEnvironment$.mockReturnValue(
|
|
of({
|
|
getApiUrl: () => "https://authed.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.mockResolvedValue("access_token");
|
|
tokenService.tokenNeedsRefresh.mockResolvedValue(false);
|
|
|
|
const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
|
|
|
|
nativeFetch.mockImplementation((request) => {
|
|
return Promise.resolve({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve({ hello: "world" }),
|
|
headers: new Headers({
|
|
"content-type": "application/json",
|
|
}),
|
|
} satisfies Partial<Response> as Response);
|
|
});
|
|
|
|
sut.nativeFetch = nativeFetch;
|
|
|
|
const response = await sut.send("GET", "/something", null, true, true, null, null);
|
|
|
|
expect(nativeFetch).toHaveBeenCalledTimes(1);
|
|
const request = nativeFetch.mock.calls[0][0];
|
|
expect(request.url).toBe("https://authed.example.com/something");
|
|
// This should get set for users of send
|
|
expect(request.cache).toBe("no-store");
|
|
// TODO: Could expect on the credentials parameter
|
|
expect(request.headers.get("Device-Type")).toBe("2"); // Chrome Extension
|
|
// Custom user agent should get set
|
|
expect(request.headers.get("User-Agent")).toBe("custom-user-agent");
|
|
// This should be set when the caller has indicated there is a response
|
|
expect(request.headers.get("Accept")).toBe("application/json");
|
|
// If they have indicated that it's authed, then the authorization header should get set.
|
|
expect(request.headers.get("Authorization")).toBe("Bearer access_token");
|
|
// The response body
|
|
expect(response).toEqual({ hello: "world" });
|
|
});
|
|
|
|
it("authenticates with non-active user when user is passed in", async () => {
|
|
environmentService.environment$ = of({
|
|
getApiUrl: () => "https://example.com",
|
|
} satisfies Partial<Environment> as Environment);
|
|
|
|
environmentService.getEnvironment$.calledWith(testInactiveUser).mockReturnValueOnce(
|
|
of({
|
|
getApiUrl: () => "https://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(testInactiveUser)
|
|
.mockResolvedValue("inactive_access_token");
|
|
|
|
tokenService.tokenNeedsRefresh.calledWith(testInactiveUser).mockResolvedValue(false);
|
|
|
|
const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
|
|
|
|
nativeFetch.mockImplementation((request) => {
|
|
return Promise.resolve({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve({ hello: "world" }),
|
|
headers: new Headers({
|
|
"content-type": "application/json",
|
|
}),
|
|
} satisfies Partial<Response> as Response);
|
|
});
|
|
|
|
sut.nativeFetch = nativeFetch;
|
|
|
|
const response = await sut.send(
|
|
"GET",
|
|
"/something",
|
|
null,
|
|
testInactiveUser,
|
|
true,
|
|
null,
|
|
null,
|
|
);
|
|
|
|
expect(nativeFetch).toHaveBeenCalledTimes(1);
|
|
const request = nativeFetch.mock.calls[0][0];
|
|
expect(request.url).toBe("https://inactive.example.com/something");
|
|
// This should get set for users of send
|
|
expect(request.cache).toBe("no-store");
|
|
// TODO: Could expect on the credentials parameter
|
|
expect(request.headers.get("Device-Type")).toBe("2"); // Chrome Extension
|
|
// Custom user agent should get set
|
|
expect(request.headers.get("User-Agent")).toBe("custom-user-agent");
|
|
// This should be set when the caller has indicated there is a response
|
|
expect(request.headers.get("Accept")).toBe("application/json");
|
|
// If they have indicated that it's authed, then the authorization header should get set.
|
|
expect(request.headers.get("Authorization")).toBe("Bearer inactive_access_token");
|
|
// The response body
|
|
expect(response).toEqual({ hello: "world" });
|
|
});
|
|
|
|
const cases: {
|
|
name: string;
|
|
authedOrUserId: boolean | UserId;
|
|
expectedEffectiveUser: UserId;
|
|
}[] = [
|
|
{
|
|
name: "refreshes active user when true passed in for auth",
|
|
authedOrUserId: true,
|
|
expectedEffectiveUser: testActiveUser,
|
|
},
|
|
{
|
|
name: "refreshes acess token when the user passed in happens to be the active one",
|
|
authedOrUserId: testActiveUser,
|
|
expectedEffectiveUser: testActiveUser,
|
|
},
|
|
{
|
|
name: "refreshes access token when the user passed in happens to be inactive",
|
|
authedOrUserId: testInactiveUser,
|
|
expectedEffectiveUser: testInactiveUser,
|
|
},
|
|
];
|
|
|
|
it.each(cases)("$name does", async ({ authedOrUserId, expectedEffectiveUser }) => {
|
|
environmentService.getEnvironment$.calledWith(expectedEffectiveUser).mockReturnValue(
|
|
of({
|
|
getApiUrl: () => `https://${expectedEffectiveUser}.example.com`,
|
|
getIdentityUrl: () => `https://${expectedEffectiveUser}.identity.example.com`,
|
|
} satisfies Partial<Environment> as Environment),
|
|
);
|
|
|
|
tokenService.getAccessToken
|
|
.calledWith(expectedEffectiveUser)
|
|
.mockResolvedValue(`${expectedEffectiveUser}_access_token`);
|
|
|
|
tokenService.tokenNeedsRefresh.calledWith(expectedEffectiveUser).mockResolvedValue(true);
|
|
|
|
tokenService.getRefreshToken
|
|
.calledWith(expectedEffectiveUser)
|
|
.mockResolvedValue(`${expectedEffectiveUser}_refresh_token`);
|
|
|
|
tokenService.decodeAccessToken
|
|
.calledWith(expectedEffectiveUser)
|
|
.mockResolvedValue({ client_id: "web" });
|
|
|
|
tokenService.decodeAccessToken
|
|
.calledWith(`${expectedEffectiveUser}_new_access_token`)
|
|
.mockResolvedValue({ sub: expectedEffectiveUser });
|
|
|
|
vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$
|
|
.calledWith(expectedEffectiveUser)
|
|
.mockReturnValue(of(VaultTimeoutAction.Lock));
|
|
|
|
vaultTimeoutSettingsService.getVaultTimeoutByUserId$
|
|
.calledWith(expectedEffectiveUser)
|
|
.mockReturnValue(of(VaultTimeoutStringType.Never));
|
|
|
|
tokenService.setTokens
|
|
.calledWith(
|
|
`${expectedEffectiveUser}_new_access_token`,
|
|
VaultTimeoutAction.Lock,
|
|
VaultTimeoutStringType.Never,
|
|
`${expectedEffectiveUser}_new_refresh_token`,
|
|
)
|
|
.mockResolvedValue({ accessToken: `${expectedEffectiveUser}_refreshed_access_token` });
|
|
|
|
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) => {
|
|
if (request.url.includes("identity")) {
|
|
return Promise.resolve({
|
|
ok: true,
|
|
status: 200,
|
|
json: () =>
|
|
Promise.resolve({
|
|
access_token: `${expectedEffectiveUser}_new_access_token`,
|
|
token_type: "Bearer",
|
|
refresh_token: `${expectedEffectiveUser}_new_refresh_token`,
|
|
}),
|
|
} satisfies Partial<Response> as Response);
|
|
}
|
|
return Promise.resolve({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve({ hello: "world" }),
|
|
headers: new Headers({
|
|
"content-type": "application/json",
|
|
}),
|
|
} satisfies Partial<Response> as Response);
|
|
});
|
|
|
|
sut.nativeFetch = nativeFetch;
|
|
|
|
await sut.send("GET", "/something", null, authedOrUserId, true, null, null);
|
|
|
|
expect(nativeFetch).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
const errorData: {
|
|
name: string;
|
|
input: Partial<Response>;
|
|
error: Partial<ErrorResponse>;
|
|
}[] = [
|
|
{
|
|
name: "json response in camel case",
|
|
input: {
|
|
json: () => Promise.resolve({ message: "Something bad happened." }),
|
|
headers: new Headers({
|
|
"content-type": "application/json",
|
|
}),
|
|
},
|
|
error: {
|
|
message: "Something bad happened.",
|
|
},
|
|
},
|
|
{
|
|
name: "json response in pascal case",
|
|
input: {
|
|
json: () => Promise.resolve({ Message: "Something bad happened." }),
|
|
headers: new Headers({
|
|
"content-type": "application/json",
|
|
}),
|
|
},
|
|
error: {
|
|
message: "Something bad happened.",
|
|
},
|
|
},
|
|
{
|
|
name: "json response with charset in content type",
|
|
input: {
|
|
json: () => Promise.resolve({ message: "Something bad happened." }),
|
|
headers: new Headers({
|
|
"content-type": "application/json; charset=utf-8",
|
|
}),
|
|
},
|
|
error: {
|
|
message: "Something bad happened.",
|
|
},
|
|
},
|
|
{
|
|
name: "text/plain response",
|
|
input: {
|
|
text: () => Promise.resolve("Something bad happened."),
|
|
headers: new Headers({
|
|
"content-type": "text/plain",
|
|
}),
|
|
},
|
|
error: {
|
|
message: "Something bad happened.",
|
|
},
|
|
},
|
|
];
|
|
|
|
it.each(errorData)(
|
|
"throws error-like response when not ok response with $name",
|
|
async ({ input, error }) => {
|
|
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;
|
|
});
|
|
|
|
const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
|
|
|
|
nativeFetch.mockImplementation((request) => {
|
|
return Promise.resolve({
|
|
ok: false,
|
|
status: 400,
|
|
...input,
|
|
} satisfies Partial<Response> as Response);
|
|
});
|
|
|
|
sut.nativeFetch = nativeFetch;
|
|
|
|
await expect(
|
|
async () => await sut.send("GET", "/something", null, true, true, null, null),
|
|
).rejects.toMatchObject(error);
|
|
},
|
|
);
|
|
|
|
it("throws error when trying to fetch an insecure URL", async () => {
|
|
environmentService.getEnvironment$.calledWith(testActiveUser).mockReturnValue(
|
|
of({
|
|
getApiUrl: () => "http://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 ?? undefined,
|
|
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: true,
|
|
status: 204,
|
|
headers: new Headers(),
|
|
} satisfies Partial<Response> as Response);
|
|
});
|
|
sut.nativeFetch = nativeFetch;
|
|
|
|
await expect(
|
|
async () => await sut.send("GET", "/something", null, true, true, null),
|
|
).rejects.toThrow(InsecureUrlNotAllowedError);
|
|
expect(nativeFetch).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,
|
|
...mockAccountInfoWith({
|
|
email: "user1@example.com",
|
|
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,
|
|
...mockAccountInfoWith({
|
|
email: "user2@example.com",
|
|
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();
|
|
});
|
|
});
|
|
});
|