176 lines
7.2 KiB
TypeScript
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();
|
|
});
|
|
});
|