From 12e632da99503d61717602cbdea36322f445d385 Mon Sep 17 00:00:00 2001 From: voommen-livefront Date: Thu, 14 Aug 2025 14:14:34 -0500 Subject: [PATCH] PM-24655 Add delete option to integrations --- .../integration-card.component.ts | 36 +++- .../connect-dialog-hec.component.html | 12 ++ .../connect-dialog-hec.component.spec.ts | 63 +++++- .../connect-dialog-hec.component.ts | 59 ++++-- .../organization-integration.service.spec.ts | 200 ++++++++++++++++++ .../organization-integration.service.ts | 127 +++++++++-- 6 files changed, 451 insertions(+), 46 deletions(-) diff --git a/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-card/integration-card.component.ts b/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-card/integration-card.component.ts index 78b57b8c08f..60905b8add2 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-card/integration-card.component.ts +++ b/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-card/integration-card.component.ts @@ -18,7 +18,7 @@ import { OrganizationId } from "@bitwarden/common/types/guid"; import { DialogService, ToastService } from "@bitwarden/components"; import { SharedModule } from "../../../../../../shared/shared.module"; -import { openHecConnectDialog } from "../integration-dialog/index"; +import { HecConnectDialogResultStatus, openHecConnectDialog } from "../integration-dialog/index"; import { HecConfiguration, HecConfigurationTemplate, Integration } from "../models"; import { OrganizationIntegrationService } from "../services/organization-integration.service"; @@ -141,6 +141,19 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { const integration = new HecConfiguration(result.url, result.bearerToken, result.service); const configurationTemplate = new HecConfigurationTemplate(result.index, result.service); + if (result.success === HecConnectDialogResultStatus.Edited) { + await this.saveIntegration(integration, configurationTemplate); + } + + if (result.success === HecConnectDialogResultStatus.Delete) { + await this.deleteIntegration(integration, configurationTemplate); + } + } + + async saveIntegration( + integration: HecConfiguration, + configurationTemplate: HecConfigurationTemplate, + ) { // save the integration try { await this.organizationIntegrationService.saveHec( @@ -158,4 +171,25 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { return; } } + + async deleteIntegration( + integration: HecConfiguration, + configurationTemplate: HecConfigurationTemplate, + ) { + // delete the integration + try { + await this.organizationIntegrationService.deleteHec( + this.organizationId, + integration, + configurationTemplate, + ); + } catch { + this.toastService.showToast({ + variant: "error", + title: "", + message: this.i18nService.t("failedToDeleteIntegration"), + }); + return; + } + } } diff --git a/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.html b/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.html index bd72c3d77a6..9f9c2ac44e4 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.html +++ b/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.html @@ -37,6 +37,18 @@ + + @if (canDelete()) { +
+ +
+ } diff --git a/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.spec.ts b/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.spec.ts index d0481ebce79..f9403c0f116 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.spec.ts +++ b/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.spec.ts @@ -16,6 +16,7 @@ import { HecConnectDialogParams, HecConnectDialogResult, openHecConnectDialog, + HecConnectDialogResultStatus, } from "./connect-dialog-hec.component"; beforeAll(() => { @@ -57,6 +58,7 @@ describe("ConnectDialogHecComponent", () => { let component: ConnectHecDialogComponent; let fixture: ComponentFixture; let dialogRefMock = mock>(); + const dialogService = mock(); const mockI18nService = mock(); const integrationMock: Integration = { @@ -72,13 +74,6 @@ describe("ConnectDialogHecComponent", () => { } as Integration; const connectInfo: HecConnectDialogParams = { settings: integrationMock, - configuration: { - uri: "", - scheme: "https", - token: "", - service: "mock-service", - }, // Provide appropriate mock configuration if needed - template: null, // Provide appropriate mock template if needed }; beforeEach(async () => { @@ -92,8 +87,18 @@ describe("ConnectDialogHecComponent", () => { { provide: DialogRef, useValue: dialogRefMock }, { provide: I18nPipe, useValue: mock() }, { provide: I18nService, useValue: mockI18nService }, + { provide: DialogService, useValue: dialogService }, ], }).compileComponents(); + + TestBed.overrideComponent(ConnectHecDialogComponent, { + add: { + providers: [{ provide: DialogService, useValue: dialogService }], + }, + remove: { + providers: [DialogService], + }, + }); }); beforeEach(() => { @@ -163,8 +168,48 @@ describe("ConnectDialogHecComponent", () => { bearerToken: "token", index: "1", service: "Test Service", - success: true, - error: null, + success: HecConnectDialogResultStatus.Edited, + }); + }); + + describe("ConnectHecDialogComponent.delete", () => { + it("should call dialogService.openSimpleDialog and close dialog with delete result if confirmed", async () => { + // Arrange + const confirmed = true; + dialogService.openSimpleDialog.mockResolvedValue(confirmed); + component.formGroup.setValue({ + url: "https://test.com", + bearerToken: "token", + index: "1", + service: "Test Service", + }); + + // Act + await component.delete(); + + // Assert + expect(dialogService.openSimpleDialog).toHaveBeenCalledWith({ + title: { key: "deleteItem" }, + content: { key: "deleteItemConfirmation" }, + type: "warning", + }); + expect(dialogRefMock.close).toHaveBeenCalledWith({ + integrationSettings: integrationMock, + url: "https://test.com", + bearerToken: "token", + index: "1", + service: "Test Service", + success: HecConnectDialogResultStatus.Delete, + }); + }); + + it("should not close dialog if not confirmed", async () => { + dialogService.openSimpleDialog.mockResolvedValue(false); + + await component.delete(); + + expect(dialogService.openSimpleDialog).toHaveBeenCalled(); + expect(dialogRefMock.close).not.toHaveBeenCalled(); }); }); }); diff --git a/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.ts b/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.ts index 3593660aaaa..d397bfa83ba 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.ts +++ b/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.ts @@ -16,10 +16,17 @@ export interface HecConnectDialogResult { bearerToken: string; index: string; service: string; - success: boolean; - error: string | null; + success: HecConnectDialogResultStatusType | null; } +export const HecConnectDialogResultStatus = { + Edited: "edit", + Delete: "delete", +} as const; + +export type HecConnectDialogResultStatusType = + (typeof HecConnectDialogResultStatus)[keyof typeof HecConnectDialogResultStatus]; + @Component({ templateUrl: "./connect-dialog-hec.component.html", imports: [SharedModule], @@ -37,6 +44,7 @@ export class ConnectHecDialogComponent implements OnInit { @Inject(DIALOG_DATA) protected connectInfo: HecConnectDialogParams, protected formBuilder: FormBuilder, private dialogRef: DialogRef, + private dialogService: DialogService, ) {} ngOnInit(): void { @@ -52,6 +60,10 @@ export class ConnectHecDialogComponent implements OnInit { return !!this.connectInfo.settings.HecConfiguration; } + canDelete(): boolean { + return !!this.connectInfo.settings.HecConfiguration; + } + getSettingsAsJson(configuration: string) { try { return JSON.parse(configuration); @@ -61,22 +73,43 @@ export class ConnectHecDialogComponent implements OnInit { } submit = async (): Promise => { - const formJson = this.formGroup.getRawValue(); - - const result: HecConnectDialogResult = { - integrationSettings: this.connectInfo.settings, - url: formJson.url || "", - bearerToken: formJson.bearerToken || "", - index: formJson.index || "", - service: formJson.service || "", - success: true, - error: null, - }; + const result = this.getHecConnectDialogResult(HecConnectDialogResultStatus.Edited); this.dialogRef.close(result); return; }; + + delete = async (): Promise => { + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "deleteItem" }, + content: { + key: "deleteItemConfirmation", + }, + type: "warning", + }); + + if (confirmed) { + // Perform the deletion logic here + const result = this.getHecConnectDialogResult(HecConnectDialogResultStatus.Delete); + this.dialogRef.close(result); + } + }; + + private getHecConnectDialogResult( + status: HecConnectDialogResultStatusType, + ): HecConnectDialogResult { + const formJson = this.formGroup.getRawValue(); + + return { + integrationSettings: this.connectInfo.settings, + url: formJson.url || "", + bearerToken: formJson.bearerToken || "", + index: formJson.index || "", + service: formJson.service || "", + success: status, + }; + } } export function openHecConnectDialog( diff --git a/apps/web/src/app/admin-console/organizations/shared/components/integrations/services/organization-integration.service.spec.ts b/apps/web/src/app/admin-console/organizations/shared/components/integrations/services/organization-integration.service.spec.ts index d74f0c241e2..ce6a82d3222 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/integrations/services/organization-integration.service.spec.ts +++ b/apps/web/src/app/admin-console/organizations/shared/components/integrations/services/organization-integration.service.spec.ts @@ -5,7 +5,9 @@ import { mock } from "jest-mock-extended"; import { OrganizationIntegrationApiService, OrganizationIntegrationConfigurationApiService, + OrganizationIntegrationConfigurationResponse, } from "@bitwarden/bit-common/dirt/integrations"; +import { EventType } from "@bitwarden/common/enums"; import { OrganizationIntegrationService } from "./organization-integration.service"; @@ -139,4 +141,202 @@ describe("OrganizationIntegrationService", () => { expect(result).toBeNull(); }); }); + + describe("deleteHecIntegrationConfiguration", () => { + const organizationId = "org-123" as any; + const integrationId = "int-1" as any; + const serviceName = "splunk"; + + let mockConfig: any; + + beforeEach(() => { + mockConfig = { id: "conf-1", template: '{"service":"splunk"}' }; + + jest.resetAllMocks(); + + // Set up integrationConfigurations$ with a matching config/template + service["integrationConfigurations$"].next([ + { + integrationId, + configurationResponses: [mockConfig], + }, + ]); + + jest.spyOn(service, "convertToJson").mockImplementation((json: string) => { + try { + return JSON.parse(json); + } catch { + return null; + } + }); + }); + + it("should delete configuration when config and template exist", async () => { + mockOrgIntegrationConfigurationApiService.deleteOrganizationIntegrationConfiguration.mockResolvedValue( + undefined, + ); + + await service.deleteHecIntegrationConfiguration(organizationId, integrationId, serviceName); + + expect( + mockOrgIntegrationConfigurationApiService.deleteOrganizationIntegrationConfiguration, + ).toHaveBeenCalledWith(organizationId, integrationId, mockConfig.id); + + // The configurationResponses should be filtered (removed) + const updatedConfigs = service["integrationConfigurations$"].value.find( + (c) => c.integrationId === integrationId, + ); + expect(updatedConfigs?.configurationResponses).toEqual([]); + }); + + it("should do nothing if config or template is missing", async () => { + // Set up with no matching config/template + service["integrationConfigurations$"].next([ + { + integrationId, + configurationResponses: [ + { + id: "conf-2", + eventType: EventType.Cipher_AttachmentCreated, + configuration: "", + template: '{"service":"other"}', + } as OrganizationIntegrationConfigurationResponse, + ], + }, + ]); + + await service.deleteHecIntegrationConfiguration(organizationId, integrationId, serviceName); + + expect( + mockOrgIntegrationConfigurationApiService.deleteOrganizationIntegrationConfiguration, + ).not.toHaveBeenCalled(); + + // configurationResponses should remain unchanged + const configs = service["integrationConfigurations$"].value.find( + (c) => c.integrationId === integrationId, + ); + expect(configs?.configurationResponses.length).toBe(1); + }); + + it("should do nothing if integrationConfigurations is empty", async () => { + service["integrationConfigurations$"].next([]); + + await service.deleteHecIntegrationConfiguration(organizationId, integrationId, serviceName); + + expect( + mockOrgIntegrationConfigurationApiService.deleteOrganizationIntegrationConfiguration, + ).not.toHaveBeenCalled(); + expect(service["integrationConfigurations$"].value).toEqual([]); + }); + }); + + describe("deleteHecIntegration", () => { + const organizationId = "org-123" as any; + const integrationId = "int-1" as any; + + beforeEach(() => { + jest.clearAllMocks(); + service["integrations$"].next([ + { id: integrationId, type: "Hec", configuration: "{}", service: "splunk" } as any, + { id: "int-2", type: "Other", configuration: "{}" } as any, + ]); + }); + + it("should call deleteOrganizationIntegration and remove integration from store", async () => { + mockOrgIntegrationApiService.deleteOrganizationIntegration.mockResolvedValue(undefined); + + await service.deleteHecIntegration(organizationId, integrationId); + + expect(mockOrgIntegrationApiService.deleteOrganizationIntegration).toHaveBeenCalledWith( + organizationId, + integrationId, + ); + const remainingIntegrations = service["integrations$"].value; + expect(remainingIntegrations).toEqual([{ id: "int-2", type: "Other", configuration: "{}" }]); + }); + + it("should not throw if integration does not exist", async () => { + mockOrgIntegrationApiService.deleteOrganizationIntegration.mockResolvedValue(undefined); + service["integrations$"].next([{ id: "int-2", type: "Other", configuration: "{}" } as any]); + + await expect( + service.deleteHecIntegration(organizationId, integrationId), + ).resolves.not.toThrow(); + expect(service["integrations$"].value).toEqual([ + { id: "int-2", type: "Other", configuration: "{}" }, + ]); + }); + + it("should handle empty integrations list", async () => { + mockOrgIntegrationApiService.deleteOrganizationIntegration.mockResolvedValue(undefined); + service["integrations$"].next([]); + + await expect( + service.deleteHecIntegration(organizationId, integrationId), + ).resolves.not.toThrow(); + expect(service["integrations$"].value).toEqual([]); + }); + }); + + describe("deleteHec", () => { + const organizationId = "org-123" as any; + const hecConfiguration = { service: "splunk", toString: () => '{"service":"splunk"}' } as any; + const hecConfigurationTemplate = { + service: "splunk", + toString: () => '{"service":"splunk"}', + } as any; + const integrationId = "int-1"; + + beforeEach(() => { + jest.clearAllMocks(); + // Set up integrations$ with a matching Hec integration + service["integrations$"].next([ + { id: integrationId, type: "Hec", configuration: '{"service":"splunk"}' } as any, + ]); + // Spy on getIntegration to return the expected integration + jest.spyOn(service as any, "getIntegration").mockReturnValue({ + id: integrationId, + type: "Hec", + configuration: '{"service":"splunk"}', + }); + }); + + it("should delete HEC integration configuration and then integration", async () => { + const deleteConfigSpy = jest + .spyOn(service, "deleteHecIntegrationConfiguration") + .mockResolvedValue(undefined); + const deleteIntegrationSpy = jest + .spyOn(service, "deleteHecIntegration") + .mockResolvedValue(undefined); + + await service.deleteHec(organizationId, hecConfiguration, hecConfigurationTemplate); + + expect(deleteConfigSpy).toHaveBeenCalledWith( + organizationId, + integrationId, + hecConfigurationTemplate.service, + ); + expect(deleteIntegrationSpy).toHaveBeenCalledWith(organizationId, integrationId); + }); + + it("should handle errors thrown by deleteHecIntegrationConfiguration", async () => { + jest + .spyOn(service, "deleteHecIntegrationConfiguration") + .mockRejectedValue(new Error("Config error")); + jest.spyOn(service, "deleteHecIntegration").mockResolvedValue(undefined); + + await expect( + service.deleteHec(organizationId, hecConfiguration, hecConfigurationTemplate), + ).rejects.toThrow("Config error"); + }); + + it("should handle errors thrown by deleteHecIntegration", async () => { + jest.spyOn(service, "deleteHecIntegrationConfiguration").mockResolvedValue(undefined); + jest.spyOn(service, "deleteHecIntegration").mockRejectedValue(new Error("Integration error")); + + await expect( + service.deleteHec(organizationId, hecConfiguration, hecConfigurationTemplate), + ).rejects.toThrow("Integration error"); + }); + }); }); diff --git a/apps/web/src/app/admin-console/organizations/shared/components/integrations/services/organization-integration.service.ts b/apps/web/src/app/admin-console/organizations/shared/components/integrations/services/organization-integration.service.ts index 91eaf105e2d..25bd9a94ba4 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/integrations/services/organization-integration.service.ts +++ b/apps/web/src/app/admin-console/organizations/shared/components/integrations/services/organization-integration.service.ts @@ -68,6 +68,15 @@ export class OrganizationIntegrationService { .subscribe(([integrations, configurations]) => { const existingIntegrations = [...this.masterIntegrationList$.value]; + // reset - to original values + existingIntegrations.forEach((integration) => { + integration.isConnected = false; + integration.configuration = ""; + integration.template = ""; + integration.HecConfiguration = null; + integration.HecConfigurationTemplate = null; + }); + // Update the integrations list with the fetched integrations if (integrations && integrations.length > 0) { integrations.forEach((integration) => { @@ -81,7 +90,7 @@ export class OrganizationIntegrationService { existingIntegration.configuration = integration.configuration || ""; existingIntegration.HecConfiguration = hecConfigJson; - const template = this.getIntegrationConfiguration( + const { template } = this.getIntegrationConfiguration( integration.id, serviceName, configurations, @@ -199,9 +208,7 @@ export class OrganizationIntegrationService { ); // find the existing integration - const existingIntegration = this.integrations$.value.find( - (i) => i.type === OrganizationIntegrationType.Hec, - ); + const existingIntegration = this.getIntegration(OrganizationIntegrationType.Hec); if (existingIntegration) { // existing integration record found, invoke update API endpoint @@ -261,20 +268,12 @@ export class OrganizationIntegrationService { // check if we have an existing configuration for this integration const integrationConfigurations = this.integrationConfigurations$.value; - // find the existing configuration by integrationId - const existingConfigurations = integrationConfigurations - .filter((config) => config.integrationId === integrationId) - .flatMap((config) => config.configurationResponses || []); - - // find the configuration by service - const existingConfiguration = - existingConfigurations.length > 0 - ? existingConfigurations.find( - (config) => - config.template && - this.convertToJson(config.template)?.service === service, - ) - : null; + const { config } = this.getIntegrationConfiguration( + integrationId, + service, + integrationConfigurations, + ); + const existingConfiguration = config; if (existingConfiguration) { // existing configuration found, invoke update API endpoint @@ -328,13 +327,95 @@ export class OrganizationIntegrationService { } } + async deleteHec( + organizationId: OrganizationId, + integration: HecConfiguration, + template: HecConfigurationTemplate, + ) { + // find the existing integration + const existingIntegration = this.getIntegration(OrganizationIntegrationType.Hec); + + if (existingIntegration === null) { + // couldn't find existing integration for Hec and Service name + return; + } + + // drop HEC Integration Configuration first + await this.deleteHecIntegrationConfiguration( + organizationId, + existingIntegration.id, + template.service, + ); + + // drop HEC Integration + await this.deleteHecIntegration(organizationId, existingIntegration.id); + } + + async deleteHecIntegration( + organizationId: OrganizationId, + integrationId: OrganizationIntegrationId, + ) { + // drop HEC Integration + await this.integrationApiService.deleteOrganizationIntegration(organizationId, integrationId); + + const updatedIntegrations = this.integrations$.value.filter((i) => i.id !== integrationId); + + this.integrations$.next(updatedIntegrations); + } + + async deleteHecIntegrationConfiguration( + organizationId: OrganizationId, + integrationId: OrganizationIntegrationId, + service: string, + ) { + const integrationConfigurations = this.integrationConfigurations$.value; + + const { config, template } = this.getIntegrationConfiguration( + integrationId, + service, + integrationConfigurations, + ); + + if (!config || !template) { + return; + } + + // drop HEC Integration Configuration first + await this.integrationConfigurationApiService.deleteOrganizationIntegrationConfiguration( + organizationId, + integrationId, + config.id, + ); + + // remove the configuration from the local store + integrationConfigurations.forEach((integrationConfig) => { + if (integrationConfig.integrationId === integrationId) { + integrationConfig.configurationResponses = integrationConfig.configurationResponses.filter( + (config) => config.id !== config.id, + ); + } + }); + + this.integrationConfigurations$.next(integrationConfigurations); + } + + private getIntegration( + integrationType: OrganizationIntegrationType, + ): OrganizationIntegrationResponse | null { + const integrations = this.integrations$.value; + return integrations.find((i) => i.type === integrationType) ?? null; + } + private getIntegrationConfiguration( integrationId: OrganizationIntegrationId, service: string, integrationConfigurations: OrganizationIntegrationConfigurationResponseWithIntegrationId[], - ): HecConfigurationTemplate | null { + ): { + config: OrganizationIntegrationConfigurationResponse | null; + template: HecConfigurationTemplate | null; + } { if (integrationConfigurations.length === 0) { - return null; + return { config: null, template: null }; } const integrationConfigs = integrationConfigurations.find( @@ -342,17 +423,17 @@ export class OrganizationIntegrationService { ); if (!integrationConfigs) { - return null; + return { config: null, template: null }; } for (const config of integrationConfigs.configurationResponses) { const template = this.convertToJson(config.template || ""); if (template && template.service === service) { - return template; + return { config, template }; } } - return null; + return { config: null, template: null }; } convertToJson(jsonString?: string): T | null {