1import type { Page } from "@playwright/test";
2import { expect, test, uid, usePersona } from "../helpers/fixtures";
3import { raiseTicket } from "../helpers/factories";
4import { PASSWORD, personas } from "../helpers/personas";
5import { expectDenied, type Api } from "../helpers/api";
6import { loginContext, openTicketInList } from "../helpers/portal";
7
8test.describe("plain agent", () => {
9 usePersona("agent");
10
11 test("cannot delete tickets, create agents or see admin settings", async ({
12 page,
13 api,
14 apiAs,
15 ticket,
16 }) => {
17 const agent = await apiAs("agent");
18
19 await expectDenied(agent.raw("frappe.client.delete", { doctype: "OD Ticket", name: ticket.name }));
20 await expectDenied(
21 agent.raw("frappe.client.insert", {
22 doc: { doctype: "OD Agent", user: personas.agent2.email, agent_name: "Nope" },
23 })
24 );
25
26 await page.goto(`/opendesk/tickets/${ticket.name}`);
27 await page.locator("#app-header").getByRole("button").filter({ hasNotText: /\S/ }).last().click();
28 await expect(page.getByRole("menuitem", { name: "Merge Ticket" })).toBeVisible();
29 await expect(page.getByRole("menuitem", { name: "Delete" })).toHaveCount(0);
30 await page.keyboard.press("Escape");
31
32 await selectRow(page, api, personas.agent.email, ticket.name);
33 await openSelectionMenu(page);
34 await expect(page.getByRole("menuitem", { name: "Edit" })).toBeVisible();
35 await expect(page.getByRole("menuitem", { name: "Delete" })).toHaveCount(0);
36 await page.keyboard.press("Escape");
37
38 await openSettings(page);
39 const settings = page.getByRole("dialog");
40 await expect(settings.getByText("Preferences")).toBeVisible();
41 for (const tab of ["Email Accounts", "General", "Agents", "SLA Policies"]) {
42 await expect(settings.getByText(tab, { exact: true })).toHaveCount(0);
43 }
44 });
45});
46
47test.describe("agent manager", () => {
48 usePersona("manager");
49
50 test("bulk deletes tickets from the list", async ({ page, api, ticket }) => {
51 await selectRow(page, api, personas.manager.email, ticket.name);
52 await openSelectionMenu(page);
53 await page.getByRole("menuitem", { name: "Delete" }).click();
54 await page.getByRole("dialog").getByRole("button", { name: "Delete" }).click();
55 await expect.poll(() => api.exists("OD Ticket", { name: ticket.name })).toBeUndefined();
56 });
57});
58
59test.describe("customer", () => {
60 const secret = () => `internal note ${uid()}`;
61 const spoofed = personas.customerManager.email;
62
63 test("cannot read internal comments through any API", async ({ apiAs }) => {
64 const customer = await apiAs("customer");
65 const ticket = await raiseTicket(customer);
66 const note = secret();
67 await addComment(await apiAs("agent"), ticket.name, `<p>${note}</p>`);
68
69 const reads = [
70 customer.raw("frappe.desk.form.load.getdoc", { doctype: "OD Ticket", name: ticket.name }),
71 customer.raw("frappe.desk.form.load.get_docinfo", { doctype: "OD Ticket", name: ticket.name }),
72 customer.raw("opendesk.api.timeline.get_comment_extras", { ticket: ticket.name }),
73 customer.raw("opendesk.opendesk.doctype.od_ticket.api.get_one", { name: ticket.name }),
74 customer.raw("frappe.client.get_list", {
75 doctype: "Comment",
76 filters: { reference_name: ticket.name },
77 fields: ["content"],
78 }),
79 customer.raw("frappe.client.get", { doctype: "OD Ticket", name: ticket.name }),
80 ];
81 for (const response of await Promise.all(reads)) {
82 expect(await response.text(), response.url()).not.toContain(note);
83 }
84 });
85
86 test("cannot add a comment through the API", async ({ api, apiAs }) => {
87 const customer = await apiAs("customer");
88 const ticket = await raiseTicket(customer);
89 const note = secret();
90
91 await expectDenied(addComment(customer, ticket.name, note, true));
92 await expectDenied(
93 customer.raw("frappe.desk.form.utils.add_comment", {
94 reference_doctype: "OD Ticket",
95 reference_name: ticket.name,
96 content: note,
97 comment_email: personas.customer.email,
98 comment_by: "Cora",
99 })
100 );
101 const comments = await api.list("Comment", {
102 filters: { reference_name: ticket.name, comment_type: "Comment" },
103 fields: ["content"],
104 });
105 expect(comments.map((c) => c.content).join()).not.toContain(note);
106 });
107
108 test("cannot open a private file attached to an internal comment", async ({
109 baseURL,
110 apiAs,
111 ticket,
112 }) => {
113 const agentContext = await loginContext(baseURL!, personas.agent.email, PASSWORD);
114 // unique bytes: Frappe dedupes files by content, sharing one URL across owners
115 const upload = await agentContext.post("/api/method/upload_file", {
116 multipart: {
117 file: {
118 name: `internal-${uid()}.txt`,
119 mimeType: "text/plain",
120 buffer: Buffer.from(`internal ${uid()}`),
121 },
122 is_private: "1",
123 doctype: "OD Ticket",
124 docname: ticket.name,
125 },
126 });
127 const fileUrl = (await upload.json()).message.file_url;
128 await (await apiAs("agent")).call("run_doc_method", {
129 dt: "OD Ticket",
130 dn: ticket.name,
131 method: "new_comment",
132 args: { content: "<p>see attached</p>", attachments: [{ file_url: fileUrl }] },
133 });
134
135 const customerContext = await loginContext(baseURL!, personas.customer.email, PASSWORD);
136 expect((await agentContext.get(fileUrl)).status()).toBe(200);
137 expect((await customerContext.get(fileUrl)).status()).toBe(403);
138 });
139
140 test("portal ticket creation ignores a spoofed raised_by", async ({ apiAs }) => {
141 const ticket = await (await apiAs("customer")).call(
142 "opendesk.opendesk.doctype.od_ticket.api.new",
143 { doc: { subject: `Spoof ${uid()}`, description: "spoof", raised_by: spoofed } }
144 );
145 expect(ticket.raised_by).toBe(personas.customer.email);
146 });
147});
148
149function addComment(api: Api, ticket: string, content: string, raw = false) {
150 const args = { dt: "OD Ticket", dn: ticket, method: "new_comment", args: { content } };
151 return raw ? api.raw("run_doc_method", args) : api.call("run_doc_method", args);
152}
153
154async function selectRow(page: Page, api: Api, user: string, ticket: string) {
155 await openTicketInList(page, api, user, ticket);
156 await page.getByRole("link", { name: new RegExp(`^${ticket} `) }).getByRole("checkbox").check();
157}
158
159async function openSelectionMenu(page: Page) {
160 await page
161 .locator("div")
162 .filter({ has: page.getByText(/row selected/) })
163 .filter({ has: page.getByRole("button", { name: "Select all" }) })
164 .last()
165 .getByRole("button")
166 .filter({ hasNotText: /\S/ })
167 .first()
168 .click();
169}
170
171async function openSettings(page: Page) {
172 await page.getByRole("button", { name: /^Opendesk / }).click();
173 await page.getByRole("menuitem", { name: "Settings" }).click();
174}
175