diff --git a/CHANGELOG.md b/CHANGELOG.md index 06d55f56d51..9fb6bb2e517 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,16 @@ Changes can also be flagged with a GitHub label for tracking purposes. The URL o - https://github.com/ethyca/fides/labels/high-risk: to indicate that a change is a "high-risk" change that could potentially lead to unanticipated regressions or degradations - https://github.com/ethyca/fides/labels/db-migration: to indicate that a given change includes a DB migration -## [Unreleased](https://github.com/ethyca/fides/compare/2.86.0..main) +## [Unreleased](https://github.com/ethyca/fides/compare/2.86.1..main) + +## [2.86.1](https://github.com/ethyca/fides/compare/2.86.0..2.86.1) + +### Fixed +- Fixed stuck DSRs when async task ConnectionConfig is deleted or disabled [#8211](https://github.com/ethyca/fides/pull/8211) +- Fixed property form paths not saving and actions not working during property creation [#8271](https://github.com/ethyca/fides/pull/8271) +- Fixed "Download troubleshooting data" feature to stream diagnostics ZIP directly instead of uploading to storage, eliminating storage configuration dependency and reliability issues [#8254](https://github.com/ethyca/fides/pull/8254) +- Fixed watchdog incorrectly erroring privacy requests paused for manual webhook or manual task input, and fixed connection config updates incorrectly requeuing manual task DSRs [#8264](https://github.com/ethyca/fides/pull/8264) +- Fixed permanently stuck privacy requests when erasure task creation fails silently by recreating missing erasure tasks from the current graph on retry [#8268](https://github.com/ethyca/fides/pull/8268) ## [2.86.0](https://github.com/ethyca/fides/compare/2.85.1..2.86.0) diff --git a/clients/admin-ui/cypress/e2e/properties.cy.ts b/clients/admin-ui/cypress/e2e/properties.cy.ts index 7f604b85e4e..541ff505a5f 100644 --- a/clients/admin-ui/cypress/e2e/properties.cy.ts +++ b/clients/admin-ui/cypress/e2e/properties.cy.ts @@ -104,6 +104,93 @@ describe("Properties page", () => { expect(body.paths).to.eql([]); }); }); + + it("Should include paths in the create payload", () => { + cy.intercept("POST", "/api/v1/plus/property", { + statusCode: 200, + body: { + id: "FDS-NEW456", + name: "Test Property", + type: "Website", + paths: ["/privacy", "/dsr"], + experiences: [], + }, + }).as("createProperty"); + + cy.visit(ADD_PROPERTY_ROUTE); + cy.getByTestId("input-name").type("Test Property"); + + // Add two paths via the Form.List + cy.contains("button", "Add path").click(); + cy.get("#paths_0").type("/privacy"); + cy.contains("button", "Add path").click(); + cy.get("#paths_1").type("/dsr"); + + cy.getByTestId("save-btn").click(); + + cy.wait("@createProperty").then((interception) => { + const { body } = interception.request; + expect(body.paths).to.eql(["/privacy", "/dsr"]); + }); + }); + + it("Should allow removing a path before saving", () => { + cy.intercept("POST", "/api/v1/plus/property", { + statusCode: 200, + body: { + id: "FDS-NEW789", + name: "Test Property", + type: "Website", + paths: ["/dsr"], + experiences: [], + }, + }).as("createProperty"); + + cy.visit(ADD_PROPERTY_ROUTE); + cy.getByTestId("input-name").type("Test Property"); + + // Add two paths, then remove the first + cy.contains("button", "Add path").click(); + cy.get("#paths_0").type("/privacy"); + cy.contains("button", "Add path").click(); + cy.get("#paths_1").type("/dsr"); + cy.get("button[aria-label='Remove path']").first().click(); + + cy.getByTestId("save-btn").click(); + + cy.wait("@createProperty").then((interception) => { + const { body } = interception.request; + expect(body.paths).to.eql(["/dsr"]); + }); + }); + }); + + describe("Edit", () => { + it("Should load existing paths and include them in the update payload", () => { + cy.intercept("GET", "/api/v1/plus/property/*", { + fixture: "properties/property.json", + }).as("getProperty"); + cy.intercept("PUT", "/api/v1/plus/property/*", { + fixture: "properties/property.json", + }).as("updateProperty"); + + cy.getAntTableRow("FDS-CEA9EV").contains("Property A").click(); + cy.wait("@getProperty"); + + // Verify existing path is loaded + cy.get("#paths_0").should("have.value", "/privacy"); + + // Add another path + cy.contains("button", "Add path").click(); + cy.get("#paths_1").type("/dsr"); + + cy.getByTestId("save-btn").click(); + + cy.wait("@updateProperty").then((interception) => { + const { body } = interception.request; + expect(body.paths).to.eql(["/privacy", "/dsr"]); + }); + }); }); describe("Delete", () => { diff --git a/clients/admin-ui/cypress/fixtures/properties/property.json b/clients/admin-ui/cypress/fixtures/properties/property.json index 74d9f36402c..8991d7e60f1 100644 --- a/clients/admin-ui/cypress/fixtures/properties/property.json +++ b/clients/admin-ui/cypress/fixtures/properties/property.json @@ -2,6 +2,6 @@ "name": "Property A", "type": "Website", "id": "FDS-CEA9EV", - "paths": [], + "paths": ["/privacy"], "experiences": [] } diff --git a/clients/admin-ui/src/features/privacy-requests/hooks/useDownloadPrivacyRequestDiagnostics.test.ts b/clients/admin-ui/src/features/privacy-requests/hooks/useDownloadPrivacyRequestDiagnostics.test.ts new file mode 100644 index 00000000000..8aa64401cbe --- /dev/null +++ b/clients/admin-ui/src/features/privacy-requests/hooks/useDownloadPrivacyRequestDiagnostics.test.ts @@ -0,0 +1,56 @@ +import { downloadBlob } from "./useDownloadPrivacyRequestDiagnostics"; + +describe("downloadBlob", () => { + const mockCreateObjectURL = jest.fn().mockReturnValue("blob:mock-url"); + const mockRevokeObjectURL = jest.fn(); + + beforeEach(() => { + URL.createObjectURL = mockCreateObjectURL; + URL.revokeObjectURL = mockRevokeObjectURL; + }); + + afterEach(() => { + jest.restoreAllMocks(); + mockCreateObjectURL.mockClear(); + mockRevokeObjectURL.mockClear(); + }); + + it("creates an object URL from the blob and triggers download", () => { + const clickSpy = jest.fn(); + const removeSpy = jest.fn(); + jest.spyOn(document, "createElement").mockReturnValue({ + href: "", + download: "", + click: clickSpy, + remove: removeSpy, + } as unknown as HTMLAnchorElement); + + const blob = new Blob(["test"], { type: "application/zip" }); + downloadBlob(blob, "diagnostics-abc.zip"); + + expect(mockCreateObjectURL).toHaveBeenCalledWith(blob); + expect(clickSpy).toHaveBeenCalled(); + expect(removeSpy).toHaveBeenCalled(); + expect(mockRevokeObjectURL).toHaveBeenCalledWith("blob:mock-url"); + }); + + it("sets the correct filename on the download link", () => { + let capturedDownload = ""; + jest.spyOn(document, "createElement").mockReturnValue({ + href: "", + set download(val: string) { + capturedDownload = val; + }, + get download() { + return capturedDownload; + }, + click: jest.fn(), + remove: jest.fn(), + } as unknown as HTMLAnchorElement); + + const blob = new Blob(["test"]); + downloadBlob(blob, "diagnostics-abc.zip"); + + expect(capturedDownload).toBe("diagnostics-abc.zip"); + }); +}); diff --git a/clients/admin-ui/src/features/privacy-requests/hooks/useDownloadPrivacyRequestDiagnostics.ts b/clients/admin-ui/src/features/privacy-requests/hooks/useDownloadPrivacyRequestDiagnostics.ts index 4915d8be66f..285fb040232 100644 --- a/clients/admin-ui/src/features/privacy-requests/hooks/useDownloadPrivacyRequestDiagnostics.ts +++ b/clients/admin-ui/src/features/privacy-requests/hooks/useDownloadPrivacyRequestDiagnostics.ts @@ -1,13 +1,23 @@ import { useMessage } from "fidesui"; +import { useState } from "react"; -import { getErrorMessage } from "~/features/common/helpers"; +import { useAppSelector } from "~/app/hooks"; +import { selectToken } from "~/features/auth/auth.slice"; +import { addCommonHeaders } from "~/features/common/CommonHeaders"; import { useHasPermission } from "~/features/common/Restrict"; import { ScopeRegistryEnum } from "~/types/api"; -import { useLazyGetPrivacyRequestDiagnosticsQuery } from "../privacy-requests.slice"; import { PrivacyRequestEntity } from "../types"; -const isLikelyRemoteUrl = (value: string) => /^https?:\/\//i.test(value); +export const downloadBlob = (blob: Blob, filename: string) => { + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + link.click(); + link.remove(); + URL.revokeObjectURL(url); +}; const useDownloadPrivacyRequestDiagnostics = ({ privacyRequest, @@ -15,46 +25,46 @@ const useDownloadPrivacyRequestDiagnostics = ({ privacyRequest: PrivacyRequestEntity; }) => { const message = useMessage(); + const token = useAppSelector(selectToken); + const [isLoading, setIsLoading] = useState(false); const hasPermissionsToReadPrivacyRequests = useHasPermission([ ScopeRegistryEnum.PRIVACY_REQUEST_READ, ]); - const [fetchDiagnostics, { isFetching }] = - useLazyGetPrivacyRequestDiagnosticsQuery(); - const downloadTroubleshootingData = async () => { - const result = await fetchDiagnostics({ - privacy_request_id: privacyRequest.id, - }); + setIsLoading(true); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 60_000); + try { + const headers = new Headers(); + addCommonHeaders(headers, token); - if ("error" in result) { - message.error( - getErrorMessage( - result.error as NonNullable, - "Unable to resolve download URL", - ), + const resp = await fetch( + `${process.env.NEXT_PUBLIC_FIDESCTL_API}/privacy-request/${privacyRequest.id}/diagnostics`, + { headers, signal: controller.signal }, ); - return; - } - const downloadUrl = result.data?.download_url ?? ""; - if (!downloadUrl) { - message.error("Unable to resolve download URL"); - return; - } + if (!resp.ok) { + const body = await resp.json().catch(() => null); + message.error( + body?.detail ?? "Unable to download troubleshooting data", + ); + return; + } - if (!isLikelyRemoteUrl(downloadUrl)) { - message.info("Troubleshooting data stored locally cannot be downloaded"); - return; + const blob = await resp.blob(); + downloadBlob(blob, `diagnostics-${privacyRequest.id}.zip`); + } catch (err) { + if (err instanceof DOMException && err.name === "AbortError") { + message.error("Download timed out. Please try again."); + } else { + message.error("Unable to download troubleshooting data"); + } + } finally { + clearTimeout(timeoutId); + setIsLoading(false); } - - const link = document.createElement("a"); - link.href = downloadUrl; - link.target = "_blank"; - link.rel = "noopener noreferrer"; - link.click(); - link.remove(); }; const showDownloadTroubleshootingData = hasPermissionsToReadPrivacyRequests; @@ -62,7 +72,7 @@ const useDownloadPrivacyRequestDiagnostics = ({ return { showDownloadTroubleshootingData, downloadTroubleshootingData, - isLoading: isFetching, + isLoading, }; }; diff --git a/clients/admin-ui/src/features/privacy-requests/privacy-requests.slice.ts b/clients/admin-ui/src/features/privacy-requests/privacy-requests.slice.ts index fb1a7779e8c..81e14b5f3ab 100644 --- a/clients/admin-ui/src/features/privacy-requests/privacy-requests.slice.ts +++ b/clients/admin-ui/src/features/privacy-requests/privacy-requests.slice.ts @@ -11,7 +11,6 @@ import { Page_Union_PrivacyRequestVerboseResponseExtended__PrivacyRequestResponseExtended__, PrivacyRequestAccessResults, PrivacyRequestCreateExtended as PrivacyRequestCreate, - PrivacyRequestDiagnosticsExportResponse, PrivacyRequestFilter, PrivacyRequestNotificationInfo, PrivacyRequestStatus, @@ -590,15 +589,6 @@ export const privacyRequestApi = baseApi.injectEndpoints({ url: `privacy-request/${privacy_request_id}/access-results`, }), }), - getPrivacyRequestDiagnostics: build.query< - PrivacyRequestDiagnosticsExportResponse, - { privacy_request_id: string } - >({ - query: ({ privacy_request_id }) => ({ - method: "GET", - url: `privacy-request/${privacy_request_id}/diagnostics`, - }), - }), getFilteredResults: build.query< { privacy_request_id: string; @@ -662,7 +652,6 @@ export const { useCreateStorageSecretsMutation, useGetActiveStorageQuery, useGetPrivacyRequestAccessResultsQuery, - useLazyGetPrivacyRequestDiagnosticsQuery, useGetFilteredResultsQuery, useGetTestLogsQuery, usePostPrivacyRequestFinalizeMutation, diff --git a/clients/admin-ui/src/features/properties/PropertyForm.tsx b/clients/admin-ui/src/features/properties/PropertyForm.tsx index 2c86c5db5e3..9e5ead75baa 100644 --- a/clients/admin-ui/src/features/properties/PropertyForm.tsx +++ b/clients/admin-ui/src/features/properties/PropertyForm.tsx @@ -1,4 +1,14 @@ -import { Button, Card, Flex, Form, Input, Select, Space, Spin } from "fidesui"; +import { + Button, + Card, + Flex, + Form, + Icons, + Input, + Select, + Space, + Spin, +} from "fidesui"; import { useRouter } from "next/router"; import { useCallback, useEffect, useMemo, useState } from "react"; @@ -15,20 +25,11 @@ import { } from "~/types/api"; import DeletePropertyModal from "./DeletePropertyModal"; -import { PathsEditor } from "./PathsEditor"; import { PrivacyCenterConfigSection, PrivacyCenterConfigValue, } from "./privacy-center-config/PrivacyCenterConfigSection"; -const PathsEditorAdapter = ({ - value, - onChange, -}: { - value?: string[]; - onChange?: (next: string[]) => void; -}) => onChange?.(next)} />; - const PCConfigSectionAdapter = ({ propertyId, value, @@ -197,10 +198,30 @@ export const PropertyForm = ({ - + + {(fields, { add, remove }) => ( + + {fields.map((field) => ( + + + + + + + )} + - - , + ...(propertyId + ? [ + + + , + ] + : []),