1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-12 14:23:32 +00:00
Files
browser/libs/common/src/services/api.service.spec.ts
Alex 3bc3bef50b [PM-814] Breach Report Escape Characters (#16264)
* encode username for uri and add spec

* verify response from getHibpBreach method

* test/validate for BreachAccountResponse type and length instead of mock response
2025-09-08 07:23:29 -07:00

438 lines
15 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 { 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<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,
email: "user1@example.com",
emailVerified: true,
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);
},
);
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);
});
});
});