1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-05 11:13:44 +00:00

PM-24655 Add delete option to integrations

This commit is contained in:
voommen-livefront
2025-08-14 14:14:34 -05:00
parent a7cc9c5a1c
commit 12e632da99
6 changed files with 451 additions and 46 deletions

View File

@@ -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;
}
}
}

View File

@@ -37,6 +37,18 @@
<button type="button" bitButton bitDialogClose buttonType="secondary" [disabled]="loading">
{{ "cancel" | i18n }}
</button>
@if (canDelete()) {
<div class="tw-ml-auto">
<button
bitIconButton="bwi-trash"
type="button"
buttonType="danger"
[appA11yTitle]="'delete' | i18n"
[bitAction]="delete"
></button>
</div>
}
</ng-container>
</bit-dialog>
</form>

View File

@@ -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<ConnectHecDialogComponent>;
let dialogRefMock = mock<DialogRef<HecConnectDialogResult>>();
const dialogService = mock<DialogService>();
const mockI18nService = mock<I18nService>();
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<I18nPipe>() },
{ 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();
});
});
});

View File

@@ -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<HecConnectDialogResult>,
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<void> => {
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<void> => {
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(

View File

@@ -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");
});
});
});

View File

@@ -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<HecConfigurationTemplate>(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<HecConfigurationTemplate>(config.template || "");
if (template && template.service === service) {
return template;
return { config, template };
}
}
return null;
return { config: null, template: null };
}
convertToJson<T>(jsonString?: string): T | null {