Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
87 changes: 87 additions & 0 deletions clients/admin-ui/cypress/e2e/properties.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@
"name": "Property A",
"type": "Website",
"id": "FDS-CEA9EV",
"paths": [],
"paths": ["/privacy"],
"experiences": []
}
Original file line number Diff line number Diff line change
@@ -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");
});
});
Original file line number Diff line number Diff line change
@@ -1,68 +1,78 @@
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,
}: {
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<typeof result.error>,
"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;

return {
showDownloadTroubleshootingData,
downloadTroubleshootingData,
isLoading: isFetching,
isLoading,
};
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import {
Page_Union_PrivacyRequestVerboseResponseExtended__PrivacyRequestResponseExtended__,
PrivacyRequestAccessResults,
PrivacyRequestCreateExtended as PrivacyRequestCreate,
PrivacyRequestDiagnosticsExportResponse,
PrivacyRequestFilter,
PrivacyRequestNotificationInfo,
PrivacyRequestStatus,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -662,7 +652,6 @@ export const {
useCreateStorageSecretsMutation,
useGetActiveStorageQuery,
useGetPrivacyRequestAccessResultsQuery,
useLazyGetPrivacyRequestDiagnosticsQuery,
useGetFilteredResultsQuery,
useGetTestLogsQuery,
usePostPrivacyRequestFinalizeMutation,
Expand Down
45 changes: 33 additions & 12 deletions clients/admin-ui/src/features/properties/PropertyForm.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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;
}) => <PathsEditor value={value ?? []} onChange={(next) => onChange?.(next)} />;

const PCConfigSectionAdapter = ({
propertyId,
value,
Expand Down Expand Up @@ -197,10 +198,30 @@ export const PropertyForm = ({
</Form.Item>
<Form.Item
label="Privacy center paths"
name="paths"
tooltip="Paths under your privacy center this property responds to. Each path must be unique across properties."
>
<PathsEditorAdapter />
<Form.List name="paths">
{(fields, { add, remove }) => (
<Flex vertical>
{fields.map((field) => (
<Flex className="my-1" key={field.key}>
<Form.Item name={field.name} className="mb-0 grow">
<Input placeholder="/privacy" />
</Form.Item>
<Button
aria-label="Remove path"
className="ml-2"
icon={<Icons.TrashCan />}
onClick={() => remove(field.name)}
/>
</Flex>
))}
<Button className="mt-2" onClick={() => add("")}>
Add path
</Button>
</Flex>
)}
</Form.List>
</Form.Item>
<Form.Item
name="privacy_center_config"
Expand Down
Loading
Loading