PM-28450 generic should implement an interface for type safety

This commit is contained in:
voommen-livefront 2025-12-09 08:06:48 -06:00
parent 7462151540
commit a53d5f1b5f
15 changed files with 43 additions and 328 deletions

View File

@ -1,6 +1,7 @@
import { IOrgIntegrationJsonify } from "../integration-jsonify";
import { OrganizationIntegrationServiceType } from "../organization-integration-service-type";
export class DatadogConfiguration {
export class DatadogConfiguration implements IOrgIntegrationJsonify {
uri: string;
apiKey: string;
service: OrganizationIntegrationServiceType;

View File

@ -1,6 +1,7 @@
import { IOrgIntegrationJsonify } from "../integration-jsonify";
import { OrganizationIntegrationServiceType } from "../organization-integration-service-type";
export class HecConfiguration {
export class HecConfiguration implements IOrgIntegrationJsonify {
uri: string;
scheme = "Bearer";
token: string;

View File

@ -1,5 +1,7 @@
import { IOrgIntegrationJsonify } from "../integration-jsonify";
// Added to reflect how future webhook integrations could be structured within the OrganizationIntegration
export class WebhookConfiguration {
export class WebhookConfiguration implements IOrgIntegrationJsonify {
propA: string;
propB: string;

View File

@ -1,6 +1,7 @@
import { IOrgIntegrationJsonify } from "../../integration-jsonify";
import { OrganizationIntegrationServiceType } from "../../organization-integration-service-type";
export class DatadogTemplate {
export class DatadogTemplate implements IOrgIntegrationJsonify {
source_type_name = "Bitwarden";
title: string = "#Title#";
text: string =

View File

@ -1,6 +1,7 @@
import { IOrgIntegrationJsonify } from "../../integration-jsonify";
import { OrganizationIntegrationServiceType } from "../../organization-integration-service-type";
export class HecTemplate {
export class HecTemplate implements IOrgIntegrationJsonify {
event = "#EventMessage#";
source = "Bitwarden";
index: string;

View File

@ -1,5 +1,7 @@
import { IOrgIntegrationJsonify } from "../../integration-jsonify";
// Added to reflect how future webhook integrations could be structured within the OrganizationIntegration
export class WebhookTemplate {
export class WebhookTemplate implements IOrgIntegrationJsonify {
propA: string;
propB: string;

View File

@ -1,13 +0,0 @@
export class WebhookIntegrationConfigurationConfig {
propA: string;
propB: string;
constructor(propA: string, propB: string) {
this.propA = propA;
this.propB = propB;
}
toString(): string {
return JSON.stringify(this);
}
}

View File

@ -0,0 +1,3 @@
export interface IOrgIntegrationJsonify {
toString(): string;
}

View File

@ -7,13 +7,11 @@ import {
import { DatadogTemplate } from "./integration-configuration-config/configuration-template/datadog-template";
import { HecTemplate } from "./integration-configuration-config/configuration-template/hec-template";
import { WebhookTemplate } from "./integration-configuration-config/configuration-template/webhook-template";
import { WebhookIntegrationConfigurationConfig } from "./integration-configuration-config/webhook-integration-configuration-config";
export class OrganizationIntegrationConfiguration {
id: OrganizationIntegrationConfigurationId;
integrationId: OrganizationIntegrationId;
eventType?: EventType | null;
configuration?: WebhookIntegrationConfigurationConfig | null;
filters?: string;
template?: HecTemplate | WebhookTemplate | DatadogTemplate | null;
@ -21,14 +19,12 @@ export class OrganizationIntegrationConfiguration {
id: OrganizationIntegrationConfigurationId,
integrationId: OrganizationIntegrationId,
eventType?: EventType | null,
configuration?: WebhookIntegrationConfigurationConfig | null,
filters?: string,
template?: HecTemplate | WebhookTemplate | DatadogTemplate | null,
) {
this.id = id;
this.integrationId = integrationId;
this.eventType = eventType;
this.configuration = configuration;
this.filters = filters;
this.template = template;
}

View File

@ -300,90 +300,6 @@ describe("BaseOrganizationIntegrationService", () => {
});
});
describe("getIntegrationById", () => {
it("should get integration by ID", async () => {
service.setOrganizationIntegrations(organizationId);
const integrationResponse = {
id: integrationId,
type: OrganizationIntegrationType.Datadog,
configuration: JSON.stringify({ url, apiKey, service: serviceType }),
} as OrganizationIntegrationResponse;
const configResponse = {
id: configId,
template: JSON.stringify({ service: serviceType }),
} as OrganizationIntegrationConfigurationResponse;
mockIntegrationApiService.createOrganizationIntegration.mockResolvedValue(
integrationResponse,
);
mockIntegrationConfigurationApiService.createOrganizationIntegrationConfiguration.mockResolvedValue(
configResponse,
);
const config = service.createConfiguration(url, apiKey, serviceType);
const template = service.createTemplate(serviceType);
await service.testSave(organizationId, config, template);
const integration = await service.getIntegrationById(integrationId);
expect(integration).not.toBeNull();
expect(integration?.id).toBe(integrationId);
});
it("should return null for non-existent ID", async () => {
service.setOrganizationIntegrations(organizationId);
mockIntegrationApiService.getOrganizationIntegrations.mockResolvedValue([]);
const integration = await service.getIntegrationById(
"non-existent" as OrganizationIntegrationId,
);
expect(integration).toBeNull();
});
});
describe("getIntegrationByServiceType", () => {
it("should get integration by service type", async () => {
service.setOrganizationIntegrations(organizationId);
const integrationResponse = {
id: integrationId,
type: OrganizationIntegrationType.Datadog,
configuration: JSON.stringify({ url, apiKey, service: serviceType }),
} as OrganizationIntegrationResponse;
const configResponse = {
id: configId,
template: JSON.stringify({ service: serviceType }),
} as OrganizationIntegrationConfigurationResponse;
mockIntegrationApiService.createOrganizationIntegration.mockResolvedValue(
integrationResponse,
);
mockIntegrationConfigurationApiService.createOrganizationIntegrationConfiguration.mockResolvedValue(
configResponse,
);
const config = service.createConfiguration(url, apiKey, serviceType);
const template = service.createTemplate(serviceType);
await service.testSave(organizationId, config, template);
const integration = await service.getIntegrationByServiceType(serviceType);
expect(integration).not.toBeNull();
expect(integration?.serviceType).toBe(serviceType);
});
it("should return null for non-existent service type", async () => {
service.setOrganizationIntegrations(organizationId);
mockIntegrationApiService.getOrganizationIntegrations.mockResolvedValue([]);
const integration = await service.getIntegrationByServiceType(
OrganizationIntegrationServiceType.Datadog,
);
expect(integration).toBeNull();
});
});
describe("convertToJson", () => {
it("should convert valid JSON string", () => {
const jsonString = JSON.stringify({ test: "value" });

View File

@ -1,13 +1,4 @@
import {
BehaviorSubject,
firstValueFrom,
map,
Observable,
Subject,
switchMap,
takeUntil,
zip,
} from "rxjs";
import { BehaviorSubject, Observable, of, Subject, switchMap, takeUntil, zip } from "rxjs";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import {
@ -16,21 +7,20 @@ import {
OrganizationIntegrationConfigurationId,
} from "@bitwarden/common/types/guid";
import { IOrgIntegrationJsonify } from "../models/integration-jsonify";
import { OrganizationIntegration } from "../models/organization-integration";
import { OrganizationIntegrationConfiguration } from "../models/organization-integration-configuration";
import { OrganizationIntegrationConfigurationRequest } from "../models/organization-integration-configuration-request";
import { OrganizationIntegrationConfigurationResponse } from "../models/organization-integration-configuration-response";
import { OrganizationIntegrationRequest } from "../models/organization-integration-request";
import { OrganizationIntegrationResponse } from "../models/organization-integration-response";
import { OrganizationIntegrationServiceType } from "../models/organization-integration-service-type";
import { OrganizationIntegrationType } from "../models/organization-integration-type";
import { OrganizationIntegrationApiService } from "./organization-integration-api.service";
import { OrganizationIntegrationConfigurationApiService } from "./organization-integration-configuration-api.service";
/**
* Common result type for integration modification operations (save, update, delete).
* Indicates whether the operation succeeded and if failure was due to insufficient permissions.
* was the server side failure due to insufficient permissions (must be owner)?
*/
export type IntegrationModificationResult = {
mustBeOwner: boolean;
@ -44,7 +34,10 @@ export type IntegrationModificationResult = {
* @template TConfig - The configuration type specific to the integration (e.g., HecConfiguration, DatadogConfiguration)
* @template TTemplate - The template type specific to the integration (e.g., HecTemplate, DatadogTemplate)
*/
export abstract class BaseOrganizationIntegrationService<TConfig, TTemplate> {
export abstract class BaseOrganizationIntegrationService<
TConfig extends IOrgIntegrationJsonify,
TTemplate extends IOrgIntegrationJsonify,
> {
private organizationId$ = new BehaviorSubject<OrganizationId | null>(null);
private _integrations$ = new BehaviorSubject<OrganizationIntegration[]>([]);
private destroy$ = new Subject<void>();
@ -53,12 +46,11 @@ export abstract class BaseOrganizationIntegrationService<TConfig, TTemplate> {
private fetch$ = this.organizationId$
.pipe(
switchMap(async (orgId) => {
switchMap((orgId) => {
if (orgId) {
const data$ = await this.setIntegrations(orgId);
return await firstValueFrom(data$);
return this.setIntegrations(orgId);
} else {
return [] as OrganizationIntegration[];
return of([]) as Observable<OrganizationIntegration[]>;
}
}),
takeUntil(this.destroy$),
@ -70,29 +62,11 @@ export abstract class BaseOrganizationIntegrationService<TConfig, TTemplate> {
});
/**
* The integration type that this service manages.
* The integration type that this service manages ex: Hec, Webhook, DataDog etc
* Must be implemented by child classes to specify their integration type.
*/
protected abstract readonly integrationType: OrganizationIntegrationType;
/**
* Creates a configuration object specific to this integration type.
* Must be implemented by child classes.
*
* @param args - Arguments needed to create the configuration
* @returns The configuration object
*/
protected abstract createConfiguration(...args: any[]): TConfig;
/**
* Creates a template object specific to this integration type.
* Must be implemented by child classes.
*
* @param args - Arguments needed to create the template
* @returns The template object
*/
protected abstract createTemplate(...args: any[]): TTemplate;
constructor(
protected integrationApiService: OrganizationIntegrationApiService,
protected integrationConfigurationApiService: OrganizationIntegrationConfigurationApiService,
@ -130,13 +104,13 @@ export abstract class BaseOrganizationIntegrationService<TConfig, TTemplate> {
}
try {
const configString = (config as any).toString();
const configString = config.toString();
const newIntegrationResponse = await this.integrationApiService.createOrganizationIntegration(
organizationId,
new OrganizationIntegrationRequest(this.integrationType, configString),
);
const templateString = (template as any).toString();
const templateString = template.toString();
const newIntegrationConfigResponse =
await this.integrationConfigurationApiService.createOrganizationIntegrationConfiguration(
organizationId,
@ -182,7 +156,7 @@ export abstract class BaseOrganizationIntegrationService<TConfig, TTemplate> {
}
try {
const configString = (config as any).toString();
const configString = config.toString();
const updatedIntegrationResponse =
await this.integrationApiService.updateOrganizationIntegration(
organizationId,
@ -190,7 +164,7 @@ export abstract class BaseOrganizationIntegrationService<TConfig, TTemplate> {
new OrganizationIntegrationRequest(this.integrationType, configString),
);
const templateString = (template as any).toString();
const templateString = template.toString();
const updatedIntegrationConfigResponse =
await this.integrationConfigurationApiService.updateOrganizationIntegrationConfiguration(
organizationId,
@ -262,57 +236,6 @@ export abstract class BaseOrganizationIntegrationService<TConfig, TTemplate> {
}
}
/**
* Gets an OrganizationIntegration by its ID.
*
* @param integrationId - ID of the integration
* @returns Promise resolving to the OrganizationIntegration or null if not found
*/
async getIntegrationById(
integrationId: OrganizationIntegrationId,
): Promise<OrganizationIntegration | null> {
return await firstValueFrom(
this.integrations$.pipe(
map((integrations) => integrations.find((i) => i.id === integrationId) || null),
),
);
}
/**
* Gets an OrganizationIntegration by its service type.
*
* @param serviceType - Type of the service
* @returns Promise resolving to the OrganizationIntegration or null if not found
*/
async getIntegrationByServiceType(
serviceType: OrganizationIntegrationServiceType,
): Promise<OrganizationIntegration | null> {
return await firstValueFrom(
this.integrations$.pipe(
map((integrations) => integrations.find((i) => i.serviceType === serviceType) || null),
),
);
}
/**
* Gets all OrganizationIntegrationConfigurations for a given integration ID.
*
* @param integrationId - ID of the integration
* @returns Promise resolving to an array of OrganizationIntegrationConfiguration or null
*/
async getIntegrationConfigurations(
integrationId: OrganizationIntegrationId,
): Promise<OrganizationIntegrationConfiguration[] | null> {
return await firstValueFrom(
this.integrations$.pipe(
map((integrations) => {
const integration = integrations.find((i) => i.id === integrationId);
return integration ? integration.integrationConfiguration : null;
}),
),
);
}
/**
* Maps API responses to an OrganizationIntegration domain model.
*
@ -335,7 +258,6 @@ export abstract class BaseOrganizationIntegrationService<TConfig, TTemplate> {
configurationResponse.id,
integrationResponse.id,
null,
null,
"",
template as any,
);
@ -403,4 +325,11 @@ export abstract class BaseOrganizationIntegrationService<TConfig, TTemplate> {
return null;
}
}
/**
* Cleans up subscriptions. Should be called when the service is destroyed.
*/
destroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}

View File

@ -7,10 +7,6 @@ import {
OrganizationIntegrationId,
} from "@bitwarden/common/types/guid";
import { DatadogConfiguration } from "../models/configuration/datadog-configuration";
import { DatadogTemplate } from "../models/integration-configuration-config/configuration-template/datadog-template";
import { OrganizationIntegration } from "../models/organization-integration";
import { OrganizationIntegrationConfiguration } from "../models/organization-integration-configuration";
import { OrganizationIntegrationConfigurationResponse } from "../models/organization-integration-configuration-response";
import { OrganizationIntegrationResponse } from "../models/organization-integration-response";
import { OrganizationIntegrationServiceType } from "../models/organization-integration-service-type";
@ -117,58 +113,4 @@ describe("DatadogOrganizationIntegrationService", () => {
service.updateDatadog(organizationId, integrationId, configId, serviceType, url, apiKey),
).rejects.toThrow(Error("Organization ID mismatch"));
});
it("should get integration by id", async () => {
service["_integrations$"].next([
new OrganizationIntegration(
integrationId,
OrganizationIntegrationType.Datadog,
serviceType,
{} as DatadogConfiguration,
[],
),
]);
const integration = await service.getIntegrationById(integrationId);
expect(integration).not.toBeNull();
expect(integration!.id).toBe(integrationId);
});
it("should get integration by service type", async () => {
service["_integrations$"].next([
new OrganizationIntegration(
integrationId,
OrganizationIntegrationType.Datadog,
serviceType,
{} as DatadogConfiguration,
[],
),
]);
const integration = await service.getIntegrationByServiceType(serviceType);
expect(integration).not.toBeNull();
expect(integration!.serviceType).toBe(serviceType);
});
it("should get integration configurations", async () => {
const config = new OrganizationIntegrationConfiguration(
configId,
integrationId,
null,
null,
"",
{} as DatadogTemplate,
);
service["_integrations$"].next([
new OrganizationIntegration(
integrationId,
OrganizationIntegrationType.Datadog,
serviceType,
{} as DatadogConfiguration,
[config],
),
]);
const configs = await service.getIntegrationConfigurations(integrationId);
expect(configs).not.toBeNull();
expect(configs![0].id).toBe(configId);
});
});

View File

@ -38,15 +38,11 @@ export class DatadogOrganizationIntegrationService extends BaseOrganizationInteg
super(integrationApiService, integrationConfigurationApiService);
}
protected createConfiguration(
url: string,
apiKey: string,
service: string,
): DatadogConfiguration {
private createConfiguration(url: string, apiKey: string, service: string): DatadogConfiguration {
return new DatadogConfiguration(url, apiKey, service);
}
protected createTemplate(service: string): DatadogTemplate {
private createTemplate(service: string): DatadogTemplate {
return new DatadogTemplate(service);
}

View File

@ -7,10 +7,6 @@ import {
OrganizationIntegrationId,
} from "@bitwarden/common/types/guid";
import { HecConfiguration } from "../models/configuration/hec-configuration";
import { HecTemplate } from "../models/integration-configuration-config/configuration-template/hec-template";
import { OrganizationIntegration } from "../models/organization-integration";
import { OrganizationIntegrationConfiguration } from "../models/organization-integration-configuration";
import { OrganizationIntegrationConfigurationResponse } from "../models/organization-integration-configuration-response";
import { OrganizationIntegrationResponse } from "../models/organization-integration-response";
import { OrganizationIntegrationServiceType } from "../models/organization-integration-service-type";
@ -134,58 +130,4 @@ describe("HecOrganizationIntegrationService", () => {
),
).rejects.toThrow(Error("Organization ID mismatch"));
});
it("should get integration by id", async () => {
service["_integrations$"].next([
new OrganizationIntegration(
integrationId,
OrganizationIntegrationType.Hec,
serviceType,
{} as HecConfiguration,
[],
),
]);
const integration = await service.getIntegrationById(integrationId);
expect(integration).not.toBeNull();
expect(integration!.id).toBe(integrationId);
});
it("should get integration by service type", async () => {
service["_integrations$"].next([
new OrganizationIntegration(
integrationId,
OrganizationIntegrationType.Hec,
serviceType,
{} as HecConfiguration,
[],
),
]);
const integration = await service.getIntegrationByServiceType(serviceType);
expect(integration).not.toBeNull();
expect(integration!.serviceType).toBe(serviceType);
});
it("should get integration configurations", async () => {
const config = new OrganizationIntegrationConfiguration(
configId,
integrationId,
null,
null,
"",
{} as HecTemplate,
);
service["_integrations$"].next([
new OrganizationIntegration(
integrationId,
OrganizationIntegrationType.Hec,
serviceType,
{} as HecConfiguration,
[config],
),
]);
const configs = await service.getIntegrationConfigurations(integrationId);
expect(configs).not.toBeNull();
expect(configs![0].id).toBe(configId);
});
});

View File

@ -38,15 +38,11 @@ export class HecOrganizationIntegrationService extends BaseOrganizationIntegrati
super(integrationApiService, integrationConfigurationApiService);
}
protected createConfiguration(
url: string,
bearerToken: string,
service: string,
): HecConfiguration {
private createConfiguration(url: string, bearerToken: string, service: string): HecConfiguration {
return new HecConfiguration(url, bearerToken, service);
}
protected createTemplate(index: string, service: string): HecTemplate {
private createTemplate(index: string, service: string): HecTemplate {
return new HecTemplate(index, service);
}