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 { 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 { BreachAccountResponse } from "../models/response/breach-account.response"; 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 { ApiService, HttpOperations } from "./api.service"; describe("ApiService", () => { let tokenService: MockProxy; let platformUtilsService: MockProxy; let environmentService: MockProxy; let appIdService: MockProxy; let refreshAccessTokenErrorCallback: jest.Mock; let logService: MockProxy; let logoutCallback: jest.Mock, [reason: LogoutReason]>; let vaultTimeoutSettingsService: MockProxy; let accountService: MockProxy; let httpOperations: MockProxy; 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, email: "user1@example.com", emailVerified: true, name: "Test Name", } satisfies ObservedValueOf); 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 as Environment); environmentService.getEnvironment$.mockReturnValue( of({ getApiUrl: () => "https://authed.example.com", } satisfies Partial 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 as unknown as Request; }); tokenService.getAccessToken.mockResolvedValue("access_token"); tokenService.tokenNeedsRefresh.mockResolvedValue(false); const nativeFetch = jest.fn, [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 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 as Environment); environmentService.getEnvironment$.calledWith(testInactiveUser).mockReturnValueOnce( of({ getApiUrl: () => "https://inactive.example.com", } satisfies Partial 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 as unknown as Request; }); tokenService.getAccessToken .calledWith(testInactiveUser) .mockResolvedValue("inactive_access_token"); tokenService.tokenNeedsRefresh.calledWith(testInactiveUser).mockResolvedValue(false); const nativeFetch = jest.fn, [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 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 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 as unknown as Request; }); const nativeFetch = jest.fn, [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 as Response); } return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ hello: "world" }), headers: new Headers({ "content-type": "application/json", }), } satisfies Partial 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; error: Partial; }[] = [ { 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 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 as unknown as Request; }); const nativeFetch = jest.fn, [request: Request]>(); nativeFetch.mockImplementation((request) => { return Promise.resolve({ ok: false, status: 400, ...input, } satisfies Partial as Response); }); sut.nativeFetch = nativeFetch; await expect( async () => await sut.send("GET", "/something", null, true, true, null, null), ).rejects.toMatchObject(error); }, ); describe("getHibpBreach", () => { it("should properly URL encode username with special characters", async () => { const mockResponse = [{ name: "test" }]; const username = "connect#bwpm@simplelogin.co"; jest.spyOn(sut, "send").mockResolvedValue(mockResponse); const result = await sut.getHibpBreach(username); expect(sut.send).toHaveBeenCalledWith( "GET", "/hibp/breach?username=" + encodeURIComponent(username), null, true, true, ); expect(result).toBeInstanceOf(Array); expect(result).toHaveLength(1); expect(result[0]).toBeInstanceOf(BreachAccountResponse); }); }); });