1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-15 15:53:27 +00:00

[PM-25417] DIRT API Service Refactor (ADR-0005) (#16353)

* encode username for uri and add spec

* verify response from getHibpBreach method

* test/validate for BreachAccountResponse type and length instead of mock response

* - extract dirt api method out of global api service
- create new directory structure
- change imports accordingly
- extract breach account response
- put extracted code into new dirt dir

* codeowners and dep injection for new hibp service
This commit is contained in:
Alex
2025-09-22 10:06:58 -04:00
committed by GitHub
parent 3a721b535c
commit 8531109081
21 changed files with 132 additions and 48 deletions

View File

@@ -97,7 +97,6 @@ import { UpdateAvatarRequest } from "../models/request/update-avatar.request";
import { UpdateDomainsRequest } from "../models/request/update-domains.request";
import { VerifyDeleteRecoverRequest } from "../models/request/verify-delete-recover.request";
import { VerifyEmailRequest } from "../models/request/verify-email.request";
import { BreachAccountResponse } from "../models/response/breach-account.response";
import { DomainsResponse } from "../models/response/domains.response";
import { EventResponse } from "../models/response/event.response";
import { ListResponse } from "../models/response/list.response";
@@ -516,8 +515,6 @@ export abstract class ApiService {
abstract getUserPublicKey(id: string): Promise<UserKeyResponse>;
abstract getHibpBreach(username: string): Promise<BreachAccountResponse[]>;
abstract postBitPayInvoice(request: BitPayInvoiceRequest): Promise<string>;
abstract postSetupPayment(): Promise<string>;

View File

@@ -1,4 +1,4 @@
import { BreachAccountResponse } from "../models/response/breach-account.response";
import { BreachAccountResponse } from "../dirt/models/response/breach-account.response";
export abstract class AuditService {
/**

View File

@@ -0,0 +1,2 @@
export * from "./models";
export * from "./services";

View File

@@ -0,0 +1 @@
export * from "./response";

View File

@@ -1,4 +1,4 @@
import { BaseResponse } from "./base.response";
import { BaseResponse } from "../../../models/response/base.response";
export class BreachAccountResponse extends BaseResponse {
addedDate: string;

View File

@@ -0,0 +1 @@
export * from "./breach-account.response";

View File

@@ -0,0 +1,20 @@
import { ApiService } from "../../abstractions/api.service";
import { DirtApiService } from "./dirt-api.service";
describe("DirtApiService", () => {
let sut: DirtApiService;
let apiService: jest.Mocked<ApiService>;
beforeEach(() => {
apiService = {
send: jest.fn(),
} as any;
sut = new DirtApiService(apiService);
});
it("should be created", () => {
expect(sut).toBeTruthy();
});
});

View File

@@ -0,0 +1,8 @@
import { ApiService } from "../../abstractions/api.service";
export class DirtApiService {
constructor(private apiService: ApiService) {}
// This service can be used for general DIRT-related API methods
// For specific domains like HIBP, use dedicated services like HibpApiService
}

View File

@@ -0,0 +1,39 @@
import { ApiService } from "../../abstractions/api.service";
import { BreachAccountResponse } from "../models";
import { HibpApiService } from "./hibp-api.service";
describe("HibpApiService", () => {
let sut: HibpApiService;
let apiService: jest.Mocked<ApiService>;
beforeEach(() => {
apiService = {
send: jest.fn(),
} as any;
sut = new HibpApiService(apiService);
});
describe("getHibpBreach", () => {
it("should properly URL encode username with special characters", async () => {
const mockResponse = [{ name: "test" }];
const username = "connect#bwpm@simplelogin.co";
apiService.send.mockResolvedValue(mockResponse);
const result = await sut.getHibpBreach(username);
expect(apiService.send).toHaveBeenCalledWith(
"GET",
"/hibp/breach?username=" + encodeURIComponent(username),
null,
true,
true,
);
expect(result).toBeInstanceOf(Array);
expect(result).toHaveLength(1);
expect(result[0]).toBeInstanceOf(BreachAccountResponse);
});
});
});

View File

@@ -0,0 +1,18 @@
import { ApiService } from "../../abstractions/api.service";
import { BreachAccountResponse } from "../models";
export class HibpApiService {
constructor(private apiService: ApiService) {}
async getHibpBreach(username: string): Promise<BreachAccountResponse[]> {
const encodedUsername = encodeURIComponent(username);
const r = await this.apiService.send(
"GET",
"/hibp/breach?username=" + encodedUsername,
null,
true,
true,
);
return r.map((a: any) => new BreachAccountResponse(a));
}
}

View File

@@ -0,0 +1,2 @@
export * from "./dirt-api.service";
export * from "./hibp-api.service";

View File

@@ -14,7 +14,6 @@ import {
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";
@@ -412,26 +411,4 @@ describe("ApiService", () => {
).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);
});
});
});

View File

@@ -113,7 +113,6 @@ import { UpdateAvatarRequest } from "../models/request/update-avatar.request";
import { UpdateDomainsRequest } from "../models/request/update-domains.request";
import { VerifyDeleteRecoverRequest } from "../models/request/verify-delete-recover.request";
import { VerifyEmailRequest } from "../models/request/verify-email.request";
import { BreachAccountResponse } from "../models/response/breach-account.response";
import { DomainsResponse } from "../models/response/domains.response";
import { ErrorResponse } from "../models/response/error.response";
import { EventResponse } from "../models/response/event.response";
@@ -1430,14 +1429,6 @@ export class ApiService implements ApiServiceAbstraction {
return new UserKeyResponse(r);
}
// HIBP APIs
async getHibpBreach(username: string): Promise<BreachAccountResponse[]> {
const encodedUsername = encodeURIComponent(username);
const r = await this.send("GET", "/hibp/breach?username=" + encodedUsername, null, true, true);
return r.map((a: any) => new BreachAccountResponse(a));
}
// Misc
async postBitPayInvoice(request: BitPayInvoiceRequest): Promise<string> {

View File

@@ -1,4 +1,5 @@
import { ApiService } from "../abstractions/api.service";
import { HibpApiService } from "../dirt/services/hibp-api.service";
import { CryptoFunctionService } from "../key-management/crypto/abstractions/crypto-function.service";
import { ErrorResponse } from "../models/response/error.response";
@@ -17,6 +18,7 @@ describe("AuditService", () => {
let auditService: AuditService;
let mockCrypto: jest.Mocked<CryptoFunctionService>;
let mockApi: jest.Mocked<ApiService>;
let mockHibpApi: jest.Mocked<HibpApiService>;
beforeEach(() => {
mockCrypto = {
@@ -27,10 +29,13 @@ describe("AuditService", () => {
nativeFetch: jest.fn().mockResolvedValue({
text: jest.fn().mockResolvedValue(`CDDEEFF:4\nDDEEFF:2\n123456:1`),
}),
getHibpBreach: jest.fn(),
} as unknown as jest.Mocked<ApiService>;
auditService = new AuditService(mockCrypto, mockApi, 2);
mockHibpApi = {
getHibpBreach: jest.fn(),
} as unknown as jest.Mocked<HibpApiService>;
auditService = new AuditService(mockCrypto, mockApi, mockHibpApi, 2);
});
it("should not exceed max concurrent passwordLeaked requests", async () => {
@@ -69,13 +74,13 @@ describe("AuditService", () => {
});
it("should return empty array for breachedAccounts on 404", async () => {
mockApi.getHibpBreach.mockRejectedValueOnce({ statusCode: 404 } as ErrorResponse);
mockHibpApi.getHibpBreach.mockRejectedValueOnce({ statusCode: 404 } as ErrorResponse);
const result = await auditService.breachedAccounts("user@example.com");
expect(result).toEqual([]);
});
it("should throw error for breachedAccounts on non-404 error", async () => {
mockApi.getHibpBreach.mockRejectedValueOnce({ statusCode: 500 } as ErrorResponse);
mockHibpApi.getHibpBreach.mockRejectedValueOnce({ statusCode: 500 } as ErrorResponse);
await expect(auditService.breachedAccounts("user@example.com")).rejects.toThrow();
});
});

View File

@@ -3,8 +3,9 @@ import { mergeMap } from "rxjs/operators";
import { ApiService } from "../abstractions/api.service";
import { AuditService as AuditServiceAbstraction } from "../abstractions/audit.service";
import { BreachAccountResponse } from "../dirt/models/response/breach-account.response";
import { HibpApiService } from "../dirt/services/hibp-api.service";
import { CryptoFunctionService } from "../key-management/crypto/abstractions/crypto-function.service";
import { BreachAccountResponse } from "../models/response/breach-account.response";
import { ErrorResponse } from "../models/response/error.response";
import { Utils } from "../platform/misc/utils";
@@ -20,6 +21,7 @@ export class AuditService implements AuditServiceAbstraction {
constructor(
private cryptoFunctionService: CryptoFunctionService,
private apiService: ApiService,
private hibpApiService: HibpApiService,
private readonly maxConcurrent: number = 100, // default to 100, can be overridden
) {
this.maxConcurrent = maxConcurrent;
@@ -69,7 +71,7 @@ export class AuditService implements AuditServiceAbstraction {
async breachedAccounts(username: string): Promise<BreachAccountResponse[]> {
try {
return await this.apiService.getHibpBreach(username);
return await this.hibpApiService.getHibpBreach(username);
} catch (e) {
const error = e as ErrorResponse;
if (error.statusCode === 404) {