Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/modules/helpDesk/services/Feedback.server.test.ts
T

176 lines
7.2 KiB
TypeScript

import type { TFunction } from "i18next";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { db } from "~/utils/db.server";
import NotificationService from "~/modules/notifications/services/.server/NotificationService";
import { feedbackRedactionMarkers } from "./FeedbackRedaction.server";
import { createWidgetSupportTicket } from "@hivegrid/db/support-tickets.server";
import { createFileFromBytes } from "~/utils/integrations/fileService.server";
import { FeedbackUserError, MAX_FEEDBACK_ATTACHMENT_BYTES } from "./Feedback.server";
vi.mock("~/utils/db.server", () => ({
db: {
feedback: {
deleteMany: vi.fn(),
},
supportTicket: {
count: vi.fn(),
},
},
}));
vi.mock("@hivegrid/db/support-tickets.server", () => ({
createWidgetSupportTicket: vi.fn(),
WIDGET_SUPPORT_TICKET_SOURCE: "in_app_feedback_widget",
WIDGET_SUPPORT_TICKET_LEGACY_SOURCES: ["in_app_feedback_widget", "widget"],
}));
vi.mock("~/modules/notifications/services/.server/NotificationService", () => ({
default: {
sendToRoles: vi.fn(),
},
}));
vi.mock("~/utils/integrations/fileService.server", () => ({
createFileFromBytes: vi.fn(),
}));
vi.mock("~/utils/db/.server/tenantRls.server", () => ({
withTenantRLS: vi.fn(async (_tenantId: string, run: (tx: unknown) => unknown) =>
run({ fileEntityLink: { create: fileEntityLinkCreateMock } })
),
}));
const fileEntityLinkCreateMock = vi.hoisted(() => vi.fn());
const countWidgetTicketsMock = vi.mocked(db.supportTicket.count);
const createFileFromBytesMock = vi.mocked(createFileFromBytes);
const createWidgetSupportTicketMock = vi.mocked(createWidgetSupportTicket);
const sendToRolesMock = vi.mocked(NotificationService.sendToRoles);
const t = ((key: string) => key) as TFunction;
describe("FeedbackServer.send", () => {
beforeEach(() => {
vi.clearAllMocks();
countWidgetTicketsMock.mockResolvedValue(0);
createWidgetSupportTicketMock.mockResolvedValue({ ticketId: "ticket_1", tenantId: "tenant_1", subject: "Support request" });
createFileFromBytesMock.mockResolvedValue({ id: "file_1", displayName: "bug.png", mimeType: "image/png", sizeBytes: 3 });
});
// AK-P21 — the widget lets the reporter attach an image instead of the app
// capturing a screenshot itself (decision 2026-07-21).
const pngAttachment = { filename: "bug.png", mimeType: "image/png", bytes: new Uint8Array([1, 2, 3]) };
it("stores an attached image and links it to the ticket", async () => {
const { default: FeedbackServer } = await import("./Feedback.server");
await FeedbackServer.send({
t,
tenantId: "tenant_1",
userId: "user_1",
message: "Button kaputt",
fromUrl: "https://app.example.test/app/acme",
attachment: pngAttachment,
});
expect(createFileFromBytesMock).toHaveBeenCalledWith(
expect.objectContaining({ tenantId: "tenant_1", userId: "user_1", filename: "bug.png", mimeType: "image/png" })
);
expect(fileEntityLinkCreateMock).toHaveBeenCalledWith({
data: expect.objectContaining({ tenantId: "tenant_1", fileId: "file_1", entityType: "support_ticket", entityId: "ticket_1" }),
});
});
it("creates the ticket before the attachment so a rejected upload never eats the report", async () => {
const { default: FeedbackServer } = await import("./Feedback.server");
createFileFromBytesMock.mockRejectedValueOnce(new Error("scan rejected"));
await expect(
FeedbackServer.send({ t, tenantId: "tenant_1", userId: "user_1", message: "Button kaputt", fromUrl: "u", attachment: pngAttachment })
).rejects.toThrow("scan rejected");
expect(createWidgetSupportTicketMock).toHaveBeenCalledTimes(1);
});
it.each([
["oversized", { ...pngAttachment, bytes: new Uint8Array(MAX_FEEDBACK_ATTACHMENT_BYTES + 1) }],
["non-image", { ...pngAttachment, mimeType: "application/pdf" }],
["empty", { ...pngAttachment, bytes: new Uint8Array(0) }],
])("rejects an %s attachment before touching storage", async (_label, attachment) => {
const { default: FeedbackServer } = await import("./Feedback.server");
await expect(
FeedbackServer.send({ t, tenantId: "tenant_1", userId: "user_1", message: "Button kaputt", fromUrl: "u", attachment })
).rejects.toThrow();
expect(createFileFromBytesMock).not.toHaveBeenCalled();
});
it("ignores an attachment when the feedback has no tenant, because a File is always tenant-scoped", async () => {
const { default: FeedbackServer } = await import("./Feedback.server");
await FeedbackServer.send({ t, tenantId: null, userId: "user_1", message: "Button kaputt", fromUrl: "u", attachment: pngAttachment });
expect(createFileFromBytesMock).not.toHaveBeenCalled();
expect(fileEntityLinkCreateMock).not.toHaveBeenCalled();
});
it("redacts sensitive message and URL values before creating a support ticket", 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("[New Support Ticket]"),
}),
}),
);
expect(createWidgetSupportTicketMock).toHaveBeenCalledWith({
tenantId: "tenant_1",
userId: "user_1",
subject: undefined,
message: expect.stringContaining(feedbackRedactionMarkers.secret),
fromUrl: expect.stringContaining(`token=${feedbackRedactionMarkers.token}`),
});
const notifiedPayload = JSON.stringify(sendToRolesMock.mock.calls[0]);
const createdPayload = JSON.stringify(createWidgetSupportTicketMock.mock.calls[0]);
for (const rawSecret of [rawEmail, rawBearer, rawHexSecret, rawQueryToken, rawClientSecret]) {
expect(notifiedPayload).not.toContain(rawSecret);
expect(createdPayload).not.toContain(rawSecret);
}
expect(createdPayload).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.toEqual(new FeedbackUserError("feedback.messageRequired", 400));
expect(sendToRolesMock).not.toHaveBeenCalled();
expect(createWidgetSupportTicketMock).not.toHaveBeenCalled();
expect(countWidgetTicketsMock).not.toHaveBeenCalled();
});
});