Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
@@ -0,0 +1,116 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { createMemoryRouter, RouterProvider } from "react-router";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { FeedbackWithDetails } from "../db/feedback.db.server";
|
||||
import { feedbackRedactionMarkers } from "../services/FeedbackRedaction.server";
|
||||
import FeedbackTable from "./FeedbackTable";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "de", exists: () => true } }),
|
||||
}));
|
||||
|
||||
vi.mock("~/components/ui/buttons/ButtonSecondary", () => ({
|
||||
default: ({ children, isLoading, destructive, ...props }: React.ComponentProps<"button"> & { isLoading?: boolean; destructive?: boolean }) => (
|
||||
<button type="button" aria-busy={isLoading} data-destructive={destructive ? "true" : undefined} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("~/components/ui/icons/RefreshIcon", () => ({
|
||||
default: ({ className }: { className?: string }) => <span className={className}>refresh</span>,
|
||||
}));
|
||||
|
||||
vi.mock("~/components/ui/input/InputFilters", () => ({
|
||||
default: () => <div data-testid="feedback-filters" />,
|
||||
}));
|
||||
|
||||
vi.mock("~/components/ui/input/InputSearchWithURL", () => ({
|
||||
default: () => <input aria-label="search" />,
|
||||
}));
|
||||
|
||||
vi.mock("~/components/ui/links/FilterableValueLink", () => ({
|
||||
FilterableValueLink: ({ value }: { value?: string | null }) => <span>{value}</span>,
|
||||
}));
|
||||
|
||||
const rawEmail = "legacy.user@example.com";
|
||||
const rawBearerToken = "raw-admin-token-123";
|
||||
const rawHexSecret = "abcdef0123456789abcdef0123456789";
|
||||
|
||||
const feedbackRow: FeedbackWithDetails = {
|
||||
id: "feedback_1",
|
||||
createdAt: new Date("2026-01-01T10:00:00.000Z"),
|
||||
tenantId: "tenant_1",
|
||||
userId: "user_1",
|
||||
message: `Login fails for ${rawEmail} with Bearer ${rawBearerToken} and trace ${rawHexSecret}`,
|
||||
fromUrl: "/acme?token=raw-url-token",
|
||||
tenant: {
|
||||
id: "tenant_1",
|
||||
name: "Acme",
|
||||
slug: "acme",
|
||||
icon: null,
|
||||
logo: null,
|
||||
deactivatedReason: null,
|
||||
types: [],
|
||||
active: true,
|
||||
theme: null,
|
||||
themeConfig: null,
|
||||
},
|
||||
user: {
|
||||
id: "user_1",
|
||||
email: "admin@example.com",
|
||||
firstName: "Ada",
|
||||
lastName: "Admin",
|
||||
phone: null,
|
||||
locale: "en",
|
||||
createdAt: new Date("2025-01-01T00:00:00.000Z"),
|
||||
},
|
||||
};
|
||||
|
||||
describe("FeedbackTable", () => {
|
||||
it("redacts sensitive legacy feedback message values before rendering the admin table", async () => {
|
||||
const router = createMemoryRouter([
|
||||
{
|
||||
path: "/feedback",
|
||||
element: (
|
||||
<FeedbackTable
|
||||
data={{
|
||||
items: [feedbackRow],
|
||||
filterableProperties: [],
|
||||
pagination: { page: 1, pageSize: 10, totalItems: 1, totalPages: 1 },
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
], { initialEntries: ["/feedback"] });
|
||||
|
||||
const { container } = render(<RouterProvider router={router} />);
|
||||
|
||||
await waitFor(() => expect(container).toHaveTextContent(feedbackRedactionMarkers.email));
|
||||
expect(container).toHaveTextContent(`Bearer ${feedbackRedactionMarkers.token}`);
|
||||
expect(container).toHaveTextContent(feedbackRedactionMarkers.secret);
|
||||
expect(container).not.toHaveTextContent(rawEmail);
|
||||
expect(container).not.toHaveTextContent(rawBearerToken);
|
||||
expect(container).not.toHaveTextContent(rawHexSecret);
|
||||
});
|
||||
|
||||
it("keeps help desk dates and counts on locale-aware formatters", () => {
|
||||
const repoRoot = process.cwd();
|
||||
const source = [
|
||||
"apps/web/app/modules/helpDesk/components/FeedbackTable.tsx",
|
||||
"apps/web/app/routes/admin/help-desk/index.tsx",
|
||||
"apps/web/app/routes/admin/help-desk/tickets.tsx",
|
||||
]
|
||||
.map((file) => fs.readFileSync(path.join(repoRoot, file), "utf8"))
|
||||
.join("\n");
|
||||
|
||||
expect(source).toContain("useLocaleFormatters");
|
||||
expect(source).toContain("formatters.dateTime");
|
||||
expect(source).toContain("formatters.number");
|
||||
expect(source).not.toContain("DateUtils");
|
||||
expect(source).not.toContain("NumberUtils");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useSubmit, useSearchParams, useNavigation, useParams } from "react-router";
|
||||
import { useState } from "react";
|
||||
import { FilterablePropertyDto } from "~/application/dtos/data/FilterablePropertyDto";
|
||||
import { PaginationDto } from "~/application/dtos/data/PaginationDto";
|
||||
import ButtonSecondary from "~/components/ui/buttons/ButtonSecondary";
|
||||
import RefreshIcon from "~/components/ui/icons/RefreshIcon";
|
||||
import InputFilters from "~/components/ui/input/InputFilters";
|
||||
import InputSearchWithURL from "~/components/ui/input/InputSearchWithURL";
|
||||
import { FilterableValueLink } from "~/components/ui/links/FilterableValueLink";
|
||||
import TableSimple from "~/components/ui/tables/TableSimple";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { FeedbackWithDetails } from "../db/feedback.db.server";
|
||||
import { redactFeedbackText } from "../services/FeedbackRedaction";
|
||||
import { useLocaleFormatters } from "~/locale/format";
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
items: FeedbackWithDetails[];
|
||||
filterableProperties: FilterablePropertyDto[];
|
||||
pagination: PaginationDto;
|
||||
};
|
||||
}
|
||||
export default function FeedbackTable({ data }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const formatters = useLocaleFormatters();
|
||||
const submit = useSubmit();
|
||||
const navigation = useNavigation();
|
||||
const params = useParams();
|
||||
|
||||
const canDelete = !params.tenant;
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [selectedRows, setSelectedRows] = useState<FeedbackWithDetails[]>([]);
|
||||
function onDelete(ids: string[]) {
|
||||
if (!canDelete) {
|
||||
return;
|
||||
}
|
||||
const form = new FormData();
|
||||
form.set("action", "delete");
|
||||
form.set("ids", ids.join(","));
|
||||
submit(form, {
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex w-full items-center space-x-2">
|
||||
<div className="flex-grow">
|
||||
<InputSearchWithURL />
|
||||
</div>
|
||||
{canDelete && selectedRows.length > 0 && (
|
||||
<ButtonSecondary
|
||||
destructive
|
||||
onClick={() => {
|
||||
onDelete(selectedRows.map((x) => x.id));
|
||||
setSelectedRows([]);
|
||||
}}
|
||||
>
|
||||
{t("shared.delete")} {selectedRows.length}
|
||||
</ButtonSecondary>
|
||||
)}
|
||||
<ButtonSecondary onClick={() => setSearchParams(searchParams)} isLoading={navigation.state === "loading"}>
|
||||
<RefreshIcon className="h-4 w-4" />
|
||||
</ButtonSecondary>
|
||||
<InputFilters filters={data.filterableProperties} />
|
||||
</div>
|
||||
<TableSimple
|
||||
selectedRows={canDelete ? selectedRows : undefined}
|
||||
onSelected={canDelete ? setSelectedRows : undefined}
|
||||
items={data.items}
|
||||
pagination={data.pagination}
|
||||
actions={[
|
||||
{
|
||||
title: t("shared.delete"),
|
||||
onClick: (_, item) => onDelete([item.id]),
|
||||
hidden: () => !canDelete,
|
||||
disabled: () => !canDelete,
|
||||
destructive: true,
|
||||
},
|
||||
]}
|
||||
headers={[
|
||||
{
|
||||
name: "createdAt",
|
||||
title: t("shared.createdAt"),
|
||||
value: (item) => formatters.dateTime(item.createdAt, { timeStyle: "medium" }),
|
||||
formattedValue: (item) => (
|
||||
<div className="text-muted-foreground text-xs">{item.createdAt && <span>{formatters.dateTime(item.createdAt, { timeStyle: "medium" })}</span>}</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "tenant",
|
||||
title: t("helpDesk.feedback.fields.tenant"),
|
||||
value: (item) => <FilterableValueLink name="tenantId" value={item?.tenant?.name} param={item?.tenant?.id} />,
|
||||
hidden: !!params.tenant,
|
||||
},
|
||||
{
|
||||
name: "user",
|
||||
title: t("models.user.object"),
|
||||
value: (item) => <FilterableValueLink name="userId" value={item.user?.email} param={item.user?.id} />,
|
||||
},
|
||||
{
|
||||
name: "message",
|
||||
title: t("feedback.message"),
|
||||
value: (item) => <div>{redactFeedbackText(item.message)}</div>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Feedback, Prisma } from "@toolctrl/db/prisma-browser";
|
||||
import { FilterablePropertyDto } from "~/application/dtos/data/FilterablePropertyDto";
|
||||
import { FiltersDto } from "~/application/dtos/data/FiltersDto";
|
||||
import { PaginationDto } from "~/application/dtos/data/PaginationDto";
|
||||
import { db } from "~/utils/db.server";
|
||||
import { TenantSimple } from "~/utils/db/tenants.db.server";
|
||||
import { UserSimple } from "~/utils/db/users.db.server";
|
||||
import TenantModelHelper from "~/utils/helpers/models/TenantModelHelper";
|
||||
import UserModelHelper from "~/utils/helpers/models/UserModelHelper";
|
||||
|
||||
export type FeedbackWithDetails = Feedback & {
|
||||
tenant: TenantSimple | null;
|
||||
user: UserSimple | null;
|
||||
};
|
||||
|
||||
export async function getAllFeedback({
|
||||
filters,
|
||||
filterableProperties,
|
||||
pagination,
|
||||
}: {
|
||||
filters: FiltersDto;
|
||||
filterableProperties: FilterablePropertyDto[];
|
||||
pagination: { pageSize: number; page: number };
|
||||
}): Promise<{
|
||||
items: FeedbackWithDetails[];
|
||||
pagination: PaginationDto;
|
||||
}> {
|
||||
const q = filters.query || "";
|
||||
|
||||
const AND_filters: Prisma.FeedbackWhereInput[] = [];
|
||||
filterableProperties.forEach((filter) => {
|
||||
const value = filters.properties.find((f) => f.name === filter.name)?.value;
|
||||
if (value) {
|
||||
AND_filters.push({
|
||||
[filter.name]: value === "null" ? null : value,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const OR_filters: Prisma.FeedbackWhereInput[] = [];
|
||||
if (q) {
|
||||
OR_filters.push({
|
||||
message: {
|
||||
contains: q,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const whereFilters: Prisma.FeedbackWhereInput = {};
|
||||
if (OR_filters.length > 0) {
|
||||
whereFilters.OR = OR_filters;
|
||||
}
|
||||
if (AND_filters.length > 0) {
|
||||
whereFilters.AND = AND_filters;
|
||||
}
|
||||
|
||||
const items = await db.feedback.findMany({
|
||||
take: pagination.pageSize,
|
||||
skip: pagination.pageSize * (pagination.page - 1),
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
include: {
|
||||
tenant: { select: TenantModelHelper.selectSimpleTenantProperties },
|
||||
user: { select: UserModelHelper.selectSimpleUserProperties },
|
||||
},
|
||||
});
|
||||
const totalItems = await db.feedback.count();
|
||||
return {
|
||||
items,
|
||||
pagination: {
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
totalItems,
|
||||
totalPages: Math.ceil(totalItems / pagination.pageSize),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function createFeedback(data: { tenantId: string | null; userId: string | null; message: string; fromUrl: string }) {
|
||||
return await db.feedback.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
userId: data.userId,
|
||||
message: data.message,
|
||||
fromUrl: data.fromUrl,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Prisma, SupportTicket } from "@toolctrl/db/prisma-browser";
|
||||
import { FilterablePropertyDto } from "~/application/dtos/data/FilterablePropertyDto";
|
||||
import { FiltersDto } from "~/application/dtos/data/FiltersDto";
|
||||
import { PaginationDto } from "~/application/dtos/data/PaginationDto";
|
||||
import { db } from "~/utils/db.server";
|
||||
import { TenantSimple } from "~/utils/db/tenants.db.server";
|
||||
import { UserSimple } from "~/utils/db/users.db.server";
|
||||
import TenantModelHelper from "~/utils/helpers/models/TenantModelHelper";
|
||||
import UserModelHelper from "~/utils/helpers/models/UserModelHelper";
|
||||
|
||||
export type SupportTicketWithDetails = SupportTicket & {
|
||||
tenant: TenantSimple | null;
|
||||
user: UserSimple | null;
|
||||
};
|
||||
|
||||
export type SupportTicketCreateInput = {
|
||||
tenantId?: string | null;
|
||||
userId?: string | null;
|
||||
source: "inline" | "email" | "api";
|
||||
subject: string;
|
||||
message: string;
|
||||
fromEmail?: string | null;
|
||||
fromUrl?: string | null;
|
||||
inboundMessageId?: string | null;
|
||||
priority?: string;
|
||||
};
|
||||
|
||||
export async function getAllSupportTickets({
|
||||
filters,
|
||||
filterableProperties,
|
||||
pagination,
|
||||
}: {
|
||||
filters: FiltersDto;
|
||||
filterableProperties: FilterablePropertyDto[];
|
||||
pagination: { pageSize: number; page: number };
|
||||
}): Promise<{
|
||||
items: SupportTicketWithDetails[];
|
||||
pagination: PaginationDto;
|
||||
}> {
|
||||
const q = filters.query || "";
|
||||
const AND: Prisma.SupportTicketWhereInput[] = [];
|
||||
for (const filter of filterableProperties) {
|
||||
const value = filters.properties.find((item) => item.name === filter.name)?.value;
|
||||
if (value) {
|
||||
AND.push({ [filter.name]: value === "null" ? null : value });
|
||||
}
|
||||
}
|
||||
const OR: Prisma.SupportTicketWhereInput[] = q ? [{ subject: { contains: q } }, { message: { contains: q } }, { fromEmail: { contains: q } }] : [];
|
||||
const where: Prisma.SupportTicketWhereInput = {};
|
||||
if (AND.length > 0) where.AND = AND;
|
||||
if (OR.length > 0) where.OR = OR;
|
||||
|
||||
const [items, totalItems] = await Promise.all([
|
||||
db.supportTicket.findMany({
|
||||
where,
|
||||
take: pagination.pageSize,
|
||||
skip: pagination.pageSize * (pagination.page - 1),
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: {
|
||||
tenant: { select: TenantModelHelper.selectSimpleTenantProperties },
|
||||
user: { select: UserModelHelper.selectSimpleUserProperties },
|
||||
},
|
||||
}),
|
||||
db.supportTicket.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items,
|
||||
pagination: {
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
totalItems,
|
||||
totalPages: Math.ceil(totalItems / pagination.pageSize),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function createSupportTicket(data: SupportTicketCreateInput) {
|
||||
return await db.supportTicket.create({
|
||||
data: {
|
||||
tenantId: data.tenantId ?? null,
|
||||
userId: data.userId ?? null,
|
||||
source: data.source,
|
||||
subject: data.subject,
|
||||
message: data.message,
|
||||
fromEmail: data.fromEmail ?? null,
|
||||
fromUrl: data.fromUrl ?? null,
|
||||
inboundMessageId: data.inboundMessageId ?? null,
|
||||
priority: data.priority ?? "normal",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateSupportTicketStatus(id: string, status: string) {
|
||||
return await db.supportTicket.update({ where: { id }, data: { status } });
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { db } from "~/utils/db.server";
|
||||
import { createFeedback } from "../db/feedback.db.server";
|
||||
import NotificationService from "~/modules/notifications/services/.server/NotificationService";
|
||||
import { feedbackRedactionMarkers } from "./FeedbackRedaction.server";
|
||||
|
||||
vi.mock("~/utils/db.server", () => ({
|
||||
db: {
|
||||
feedback: {
|
||||
count: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../db/feedback.db.server", () => ({
|
||||
createFeedback: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("~/modules/notifications/services/.server/NotificationService", () => ({
|
||||
default: {
|
||||
sendToRoles: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const countFeedbackMock = vi.mocked(db.feedback.count);
|
||||
const createFeedbackMock = vi.mocked(createFeedback);
|
||||
const sendToRolesMock = vi.mocked(NotificationService.sendToRoles);
|
||||
const t = ((key: string) => key) as TFunction;
|
||||
|
||||
describe("FeedbackServer.send", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
countFeedbackMock.mockResolvedValue(0);
|
||||
createFeedbackMock.mockResolvedValue({ id: "feedback_1" } as Awaited<ReturnType<typeof createFeedback>>);
|
||||
});
|
||||
|
||||
it("redacts sensitive message and URL values before notification and persistence", async () => {
|
||||
const rawEmail = "owner@example.com";
|
||||
const rawBearer = "secret-token-value";
|
||||
const rawHexSecret = "abcdefabcdefabcdefabcdefabcdefabcdefabcd";
|
||||
const rawQueryToken = "raw-token-123";
|
||||
const rawClientSecret = "super-secret-value";
|
||||
const message = `Login failed for ${rawEmail} using Bearer ${rawBearer}; trace ${rawHexSecret}.`;
|
||||
const fromUrl = `https://app.example.com/feedback?token=${rawQueryToken}&next=/home&client_secret=${rawClientSecret}`;
|
||||
|
||||
const { default: FeedbackServer } = await import("./Feedback.server");
|
||||
|
||||
await FeedbackServer.send({
|
||||
t,
|
||||
tenantId: "tenant_1",
|
||||
userId: "user_1",
|
||||
message,
|
||||
fromUrl,
|
||||
});
|
||||
|
||||
expect(sendToRolesMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
notification: expect.objectContaining({
|
||||
message: expect.stringContaining(feedbackRedactionMarkers.email),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(createFeedbackMock).toHaveBeenCalledWith({
|
||||
tenantId: "tenant_1",
|
||||
userId: "user_1",
|
||||
message: expect.stringContaining(feedbackRedactionMarkers.secret),
|
||||
fromUrl: expect.stringContaining(`token=${feedbackRedactionMarkers.token}`),
|
||||
});
|
||||
|
||||
const notifiedPayload = JSON.stringify(sendToRolesMock.mock.calls[0]);
|
||||
const createdPayload = JSON.stringify(createFeedbackMock.mock.calls[0]);
|
||||
for (const rawSecret of [rawEmail, rawBearer, rawHexSecret, rawQueryToken, rawClientSecret]) {
|
||||
expect(notifiedPayload).not.toContain(rawSecret);
|
||||
expect(createdPayload).not.toContain(rawSecret);
|
||||
}
|
||||
expect(notifiedPayload).toContain(`Bearer ${feedbackRedactionMarkers.token}`);
|
||||
expect(createdPayload).toContain(`client_secret=${feedbackRedactionMarkers.token}`);
|
||||
expect(createdPayload).toContain("next=/home");
|
||||
});
|
||||
|
||||
it("rejects feedback when the redacted trimmed message is empty", async () => {
|
||||
const { default: FeedbackServer } = await import("./Feedback.server");
|
||||
|
||||
await expect(
|
||||
FeedbackServer.send({
|
||||
t,
|
||||
tenantId: "tenant_1",
|
||||
userId: "user_1",
|
||||
message: " ",
|
||||
fromUrl: "/acme?token=raw-token-123",
|
||||
}),
|
||||
).rejects.toThrow("Message is required");
|
||||
|
||||
expect(sendToRolesMock).not.toHaveBeenCalled();
|
||||
expect(createFeedbackMock).not.toHaveBeenCalled();
|
||||
expect(countFeedbackMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { TFunction } from "i18next";
|
||||
import { db } from "~/utils/db.server";
|
||||
import { createFeedback } from "../db/feedback.db.server";
|
||||
import NotificationService from "~/modules/notifications/services/.server/NotificationService";
|
||||
import { redactFeedbackText } from "./FeedbackRedaction.server";
|
||||
|
||||
const MAX_FEEDBACK_PER_DAY = 3;
|
||||
|
||||
async function send({
|
||||
t,
|
||||
tenantId,
|
||||
userId,
|
||||
message,
|
||||
fromUrl,
|
||||
}: {
|
||||
t: TFunction;
|
||||
tenantId: string | null;
|
||||
userId: string | null;
|
||||
message: string;
|
||||
fromUrl: string;
|
||||
}) {
|
||||
const redactedMessage = redactFeedbackText(message);
|
||||
const redactedFromUrl = redactFeedbackText(fromUrl);
|
||||
if (!redactedMessage) {
|
||||
throw Error("Message is required");
|
||||
}
|
||||
|
||||
if (tenantId) {
|
||||
const feedbackToday = await db.feedback.count({ where: { tenantId } });
|
||||
if (feedbackToday >= MAX_FEEDBACK_PER_DAY) {
|
||||
throw Error(t("feedback.limitReached"));
|
||||
}
|
||||
} else if (userId) {
|
||||
const feedbackToday = await db.feedback.count({ where: { userId } });
|
||||
if (feedbackToday >= MAX_FEEDBACK_PER_DAY) {
|
||||
throw Error(t("feedback.limitReached"));
|
||||
}
|
||||
}
|
||||
await NotificationService.sendToRoles({
|
||||
channel: "admin-users",
|
||||
tenantId: null,
|
||||
notification: {
|
||||
message: "[New Feedback] " + redactedMessage,
|
||||
},
|
||||
});
|
||||
return await createFeedback({ tenantId, userId, message: redactedMessage, fromUrl: redactedFromUrl });
|
||||
}
|
||||
|
||||
async function deleteMany({ ids }: { ids: string[] }) {
|
||||
return await db.feedback.deleteMany({
|
||||
where: {
|
||||
id: { in: ids },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
send,
|
||||
deleteMany,
|
||||
};
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { feedbackRedactionMarkers, redactFeedbackText } from "./FeedbackRedaction.server";
|
||||
|
||||
describe("redactFeedbackText", () => {
|
||||
test("trims and preserves benign feedback text", () => {
|
||||
expect(redactFeedbackText(" The export button is hard to find on mobile. ")).toBe("The export button is hard to find on mobile.");
|
||||
expect(redactFeedbackText(null)).toBe("");
|
||||
expect(redactFeedbackText(undefined)).toBe("");
|
||||
});
|
||||
|
||||
test("redacts email addresses", () => {
|
||||
const redacted = redactFeedbackText("Please contact alex.user+demo@example.com about this bug.");
|
||||
|
||||
expect(redacted).toContain(feedbackRedactionMarkers.email);
|
||||
expect(redacted).not.toContain("alex.user+demo@example.com");
|
||||
});
|
||||
|
||||
test("redacts bearer tokens and api-key style values", () => {
|
||||
const bearer = "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9";
|
||||
const apiKey = "api_key=sk_live_1234567890abcdef";
|
||||
const token = "accessToken: app-token-1234567890";
|
||||
const redacted = redactFeedbackText(`${bearer} ${apiKey} ${token}`);
|
||||
|
||||
expect(redacted).toContain(`Bearer ${feedbackRedactionMarkers.token}`);
|
||||
expect(redacted).toContain(`api_key=${feedbackRedactionMarkers.token}`);
|
||||
expect(redacted).toContain(`accessToken: ${feedbackRedactionMarkers.token}`);
|
||||
expect(redacted).not.toContain("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9");
|
||||
expect(redacted).not.toContain("sk_live_1234567890abcdef");
|
||||
expect(redacted).not.toContain("app-token-1234567890");
|
||||
});
|
||||
|
||||
test("redacts sensitive query parameter values", () => {
|
||||
const url = "https://app.example.com/feedback?token=raw-token-123&next=/home&client_secret=super-secret-value";
|
||||
const redacted = redactFeedbackText(url);
|
||||
|
||||
expect(redacted).toContain(`token=${feedbackRedactionMarkers.token}`);
|
||||
expect(redacted).toContain(`client_secret=${feedbackRedactionMarkers.token}`);
|
||||
expect(redacted).toContain("next=/home");
|
||||
expect(redacted).not.toContain("raw-token-123");
|
||||
expect(redacted).not.toContain("super-secret-value");
|
||||
});
|
||||
|
||||
test("redacts long hex and base64-like secrets", () => {
|
||||
const hexSecret = "0123456789abcdef0123456789abcdef01234567";
|
||||
const base64Secret = "AbCdEfGhIjKlMnOpQrStUvWxYz1234567890+/==";
|
||||
const redacted = redactFeedbackText(`hex ${hexSecret} base64 ${base64Secret}`);
|
||||
|
||||
expect(redacted).toContain(feedbackRedactionMarkers.secret);
|
||||
expect(redacted).not.toContain(hexSecret);
|
||||
expect(redacted).not.toContain(base64Secret);
|
||||
});
|
||||
|
||||
test("redacts mixed sensitive feedback while preserving context", () => {
|
||||
const email = "owner@example.com";
|
||||
const token = "Bearer secret-token-value";
|
||||
const hexSecret = "abcdefabcdefabcdefabcdefabcdefabcdefabcd";
|
||||
const redacted = redactFeedbackText(`Login failed for ${email} using ${token}; trace ${hexSecret}.`);
|
||||
|
||||
expect(redacted).toBe(
|
||||
`Login failed for ${feedbackRedactionMarkers.email} using Bearer ${feedbackRedactionMarkers.token}; trace ${feedbackRedactionMarkers.secret}.`,
|
||||
);
|
||||
expect(redacted).not.toContain(email);
|
||||
expect(redacted).not.toContain("secret-token-value");
|
||||
expect(redacted).not.toContain(hexSecret);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { feedbackRedactionMarkers, redactFeedbackText } from "./FeedbackRedaction";
|
||||
@@ -0,0 +1,27 @@
|
||||
const REDACTED_EMAIL = "[REDACTED_EMAIL]";
|
||||
const REDACTED_TOKEN = "[REDACTED_TOKEN]";
|
||||
const REDACTED_SECRET = "[REDACTED_SECRET]";
|
||||
|
||||
const EMAIL_PATTERN = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi;
|
||||
const BEARER_TOKEN_PATTERN = /\bBearer\s+[^\s,;"')]+/gi;
|
||||
const SECRET_QUERY_VALUE_PATTERN = /([?&](?:api[_-]?key|access[_-]?token|auth[_-]?token|token|secret|client[_-]?secret|key)=)([^&\s]+)/gi;
|
||||
const KEY_VALUE_TOKEN_PATTERN = /\b((?:api[_-]?key|access[_-]?token|auth[_-]?token|token|secret|client[_-]?secret)\s*[:=]\s*)([^&\s,;"')]+)/gi;
|
||||
const HEX_SECRET_PATTERN = /\b[a-f0-9]{32,}\b/gi;
|
||||
const BASE64_SECRET_PATTERN = /\b(?=[A-Za-z0-9+/=_-]{32,}\b)(?=.*[A-Z])(?=.*[a-z])(?=.*\d)[A-Za-z0-9+/=_-]{32,}\b/g;
|
||||
|
||||
export function redactFeedbackText(value: string | null | undefined): string {
|
||||
return (value ?? "")
|
||||
.trim()
|
||||
.replace(EMAIL_PATTERN, REDACTED_EMAIL)
|
||||
.replace(BEARER_TOKEN_PATTERN, `Bearer ${REDACTED_TOKEN}`)
|
||||
.replace(SECRET_QUERY_VALUE_PATTERN, `$1${REDACTED_TOKEN}`)
|
||||
.replace(KEY_VALUE_TOKEN_PATTERN, `$1${REDACTED_TOKEN}`)
|
||||
.replace(HEX_SECRET_PATTERN, REDACTED_SECRET)
|
||||
.replace(BASE64_SECRET_PATTERN, REDACTED_SECRET);
|
||||
}
|
||||
|
||||
export const feedbackRedactionMarkers = {
|
||||
email: REDACTED_EMAIL,
|
||||
token: REDACTED_TOKEN,
|
||||
secret: REDACTED_SECRET,
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const createSupportTicketMock = vi.fn();
|
||||
const sendToRolesMock = vi.fn();
|
||||
|
||||
const t = ((key: string, fallback?: string) => fallback ?? key) as never;
|
||||
|
||||
describe("SupportTicketsServer", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
vi.doMock("../db/supportTickets.db.server", () => ({ createSupportTicket: createSupportTicketMock }));
|
||||
vi.doMock("~/modules/notifications/services/.server/NotificationService", () => ({
|
||||
default: { sendToRoles: sendToRolesMock },
|
||||
}));
|
||||
createSupportTicketMock.mockImplementation(async (data) => ({ id: "ticket_1", ...data }));
|
||||
});
|
||||
|
||||
it("creates redacted inline tickets and notifies platform admins", async () => {
|
||||
const { default: SupportTicketsServer } = await import("./SupportTickets.server");
|
||||
|
||||
await expect(
|
||||
SupportTicketsServer.createInline({
|
||||
t,
|
||||
tenantId: "tenant_1",
|
||||
userId: "user_1",
|
||||
subject: "Need help with token=sk_live_secret",
|
||||
message: "My api_key=abc123 should not leak",
|
||||
fromUrl: "/acme/dashboard?token=abc",
|
||||
}),
|
||||
).resolves.toMatchObject({ id: "ticket_1", source: "inline", tenantId: "tenant_1", userId: "user_1" });
|
||||
|
||||
expect(createSupportTicketMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
source: "inline",
|
||||
subject: expect.not.stringContaining("sk_live_secret"),
|
||||
message: expect.not.stringContaining("abc123"),
|
||||
fromUrl: expect.not.stringContaining("token=abc"),
|
||||
}),
|
||||
);
|
||||
expect(sendToRolesMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "admin-users",
|
||||
tenantId: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("creates idempotency-addressable inbound email tickets", async () => {
|
||||
const { default: SupportTicketsServer } = await import("./SupportTickets.server");
|
||||
|
||||
await SupportTicketsServer.createFromInboundEmail({
|
||||
tenantId: "tenant_1",
|
||||
fromEmail: "customer@example.test",
|
||||
subject: "Inbound support",
|
||||
message: "Please help",
|
||||
inboundMessageId: "mail_1",
|
||||
});
|
||||
|
||||
expect(createSupportTicketMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
source: "email",
|
||||
inboundMessageId: "mail_1",
|
||||
fromEmail: "[REDACTED_EMAIL]",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { TFunction } from "i18next";
|
||||
import NotificationService from "~/modules/notifications/services/.server/NotificationService";
|
||||
import { createSupportTicket } from "../db/supportTickets.db.server";
|
||||
import { redactFeedbackText } from "./FeedbackRedaction.server";
|
||||
|
||||
const MAX_SUBJECT_LENGTH = 160;
|
||||
|
||||
function normalizeSubject(subject: string, message: string) {
|
||||
const redactedSubject = redactFeedbackText(subject).trim();
|
||||
if (redactedSubject) {
|
||||
return redactedSubject.slice(0, MAX_SUBJECT_LENGTH);
|
||||
}
|
||||
return redactFeedbackText(message).trim().slice(0, MAX_SUBJECT_LENGTH) || "Support request";
|
||||
}
|
||||
|
||||
async function createInline({
|
||||
t,
|
||||
tenantId,
|
||||
userId,
|
||||
subject,
|
||||
message,
|
||||
fromUrl,
|
||||
}: {
|
||||
t: TFunction;
|
||||
tenantId: string | null;
|
||||
userId: string | null;
|
||||
subject: string;
|
||||
message: string;
|
||||
fromUrl: string;
|
||||
}) {
|
||||
const redactedMessage = redactFeedbackText(message);
|
||||
const redactedFromUrl = redactFeedbackText(fromUrl);
|
||||
if (!redactedMessage) {
|
||||
throw Error(t("feedback.messageRequired"));
|
||||
}
|
||||
const ticket = await createSupportTicket({
|
||||
tenantId,
|
||||
userId,
|
||||
source: "inline",
|
||||
subject: normalizeSubject(subject, redactedMessage),
|
||||
message: redactedMessage,
|
||||
fromUrl: redactedFromUrl,
|
||||
});
|
||||
await NotificationService.sendToRoles({
|
||||
channel: "admin-users",
|
||||
tenantId: null,
|
||||
notification: {
|
||||
message: `[New Support Ticket] ${ticket.subject}`,
|
||||
},
|
||||
});
|
||||
return ticket;
|
||||
}
|
||||
|
||||
async function createFromInboundEmail({
|
||||
tenantId,
|
||||
fromEmail,
|
||||
subject,
|
||||
message,
|
||||
inboundMessageId,
|
||||
}: {
|
||||
tenantId: string | null;
|
||||
fromEmail: string;
|
||||
subject: string;
|
||||
message: string;
|
||||
inboundMessageId: string;
|
||||
}) {
|
||||
const redactedMessage = redactFeedbackText(message);
|
||||
if (!redactedMessage) {
|
||||
throw Error("Message is required");
|
||||
}
|
||||
return await createSupportTicket({
|
||||
tenantId,
|
||||
userId: null,
|
||||
source: "email",
|
||||
subject: normalizeSubject(subject, redactedMessage),
|
||||
message: redactedMessage,
|
||||
fromEmail: redactFeedbackText(fromEmail),
|
||||
inboundMessageId,
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
createInline,
|
||||
createFromInboundEmail,
|
||||
};
|
||||
Reference in new issue
Block a user